From 3e4516c0b5d1d30eaeb68b27e704ae4453e72480 Mon Sep 17 00:00:00 2001 From: objz Date: Mon, 26 Oct 2026 18:14:31 +0100 Subject: [PATCH 01/48] feat: backends report their server software to the proxy The auth request now carries the platform the backend detected (Paper, Folia, Bukkit, Forge, Fabric), and the proxy keeps it on the client session. Older backends send none and show as unknown. --- .../backends/net/out/AuthRequest.java | 3 ++- .../backends/platform/PlatformDetector.java | 22 +++++++++---------- .../platform/bootstrap/BukkitMain.java | 4 ++-- .../platform/PlatformDetectorTest.java | 11 +++++----- .../net/payloads/util/AuthRequestPayload.java | 2 ++ .../scripting/model/enums/ServerPlatform.java | 6 +++++ .../velocity/net/in/AuthHandler.java | 3 +++ .../velocity/net/session/ClientSession.java | 10 +++++++++ 8 files changed, 41 insertions(+), 20 deletions(-) create mode 100644 core/src/main/java/dev/objz/commandbridge/scripting/model/enums/ServerPlatform.java diff --git a/backends/src/main/java/dev/objz/commandbridge/backends/net/out/AuthRequest.java b/backends/src/main/java/dev/objz/commandbridge/backends/net/out/AuthRequest.java index 42d3b075..6757a92f 100644 --- a/backends/src/main/java/dev/objz/commandbridge/backends/net/out/AuthRequest.java +++ b/backends/src/main/java/dev/objz/commandbridge/backends/net/out/AuthRequest.java @@ -2,6 +2,7 @@ import dev.objz.commandbridge.backends.net.connection.ClientStatus; import dev.objz.commandbridge.backends.net.out.ctx.AuthRequestContext; +import dev.objz.commandbridge.backends.platform.PlatformDetector; import dev.objz.commandbridge.logging.Log; import dev.objz.commandbridge.net.OutboundHandler; import dev.objz.commandbridge.net.SendOperation; @@ -30,7 +31,7 @@ public SendOperation accept(AuthRequestContext ctx) { final String clientNonce = UUID.randomUUID().toString(); final String mac = auth.sign(clientId, clientNonce); - var payload = Envelope.MAPPER.valueToTree(new AuthRequestPayload(location, clientNonce, mac)); + var payload = Envelope.MAPPER.valueToTree(new AuthRequestPayload(location, PlatformDetector.detectPlatform(), clientNonce, mac)); Envelope env = Envelope.make(MessageType.AUTH_REQUEST, clientId, "proxy-auth", payload); SendOperation op = send(env) diff --git a/backends/src/main/java/dev/objz/commandbridge/backends/platform/PlatformDetector.java b/backends/src/main/java/dev/objz/commandbridge/backends/platform/PlatformDetector.java index 10c40baa..5382c329 100644 --- a/backends/src/main/java/dev/objz/commandbridge/backends/platform/PlatformDetector.java +++ b/backends/src/main/java/dev/objz/commandbridge/backends/platform/PlatformDetector.java @@ -1,29 +1,27 @@ package dev.objz.commandbridge.backends.platform; -public final class PlatformDetector { +import dev.objz.commandbridge.scripting.model.enums.ServerPlatform; - public enum Platform { - FOLIA, PAPER, BUKKIT, FORGE, FABRIC, UNKNOWN - } +public final class PlatformDetector { private PlatformDetector() { } - public static Platform detectPlatform() { - Platform p; + public static ServerPlatform detectPlatform() { + ServerPlatform p; if (classExists("io.papermc.paper.threadedregions.RegionizedServer")) { - p = Platform.FOLIA; + p = ServerPlatform.FOLIA; } else if (classExists("com.destroystokyo.paper.PaperConfig") || classExists("io.papermc.paper.configuration.Configuration")) { - p = Platform.PAPER; + p = ServerPlatform.PAPER; } else if (classExists("org.bukkit.Bukkit")) { - p = Platform.BUKKIT; + p = ServerPlatform.BUKKIT; } else if (classExists("net.minecraftforge.fml.common.Mod")) { - p = Platform.FORGE; + p = ServerPlatform.FORGE; } else if (classExists("net.fabricmc.loader.api.FabricLoader")) { - p = Platform.FABRIC; + p = ServerPlatform.FABRIC; } else { - p = Platform.UNKNOWN; + p = ServerPlatform.UNKNOWN; } return p; } diff --git a/backends/src/main/java/dev/objz/commandbridge/backends/platform/bootstrap/BukkitMain.java b/backends/src/main/java/dev/objz/commandbridge/backends/platform/bootstrap/BukkitMain.java index 59795f68..812abed2 100644 --- a/backends/src/main/java/dev/objz/commandbridge/backends/platform/bootstrap/BukkitMain.java +++ b/backends/src/main/java/dev/objz/commandbridge/backends/platform/bootstrap/BukkitMain.java @@ -2,7 +2,7 @@ import dev.objz.commandbridge.backends.platform.PlatformAdapter; import dev.objz.commandbridge.backends.platform.PlatformDetector; -import dev.objz.commandbridge.backends.platform.PlatformDetector.Platform; +import dev.objz.commandbridge.scripting.model.enums.ServerPlatform; import dev.objz.commandbridge.logging.Log; import org.bstats.bukkit.Metrics; @@ -23,7 +23,7 @@ public void onLoad() { Log.info("Initializing CommandBridge (Bukkit family)"); - Platform platform = PlatformDetector.detectPlatform(); + ServerPlatform platform = PlatformDetector.detectPlatform(); Log.info("Detected platform: {}", platform); try { adapter = switch (platform) { diff --git a/backends/src/test/java/dev/objz/commandbridge/backends/platform/PlatformDetectorTest.java b/backends/src/test/java/dev/objz/commandbridge/backends/platform/PlatformDetectorTest.java index 56747919..f92cbee1 100644 --- a/backends/src/test/java/dev/objz/commandbridge/backends/platform/PlatformDetectorTest.java +++ b/backends/src/test/java/dev/objz/commandbridge/backends/platform/PlatformDetectorTest.java @@ -1,6 +1,7 @@ package dev.objz.commandbridge.backends.platform; import dev.objz.commandbridge.backends.TestFixtures; +import dev.objz.commandbridge.scripting.model.enums.ServerPlatform; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -23,21 +24,21 @@ static void setUp() { @Test void detectReturnsNonNull() { - PlatformDetector.Platform result = PlatformDetector.detectPlatform(); + ServerPlatform result = PlatformDetector.detectPlatform(); assertNotNull(result, "detectPlatform() must never return null"); } @Test void detectReturnsConsistentResult() { - PlatformDetector.Platform first = PlatformDetector.detectPlatform(); - PlatformDetector.Platform second = PlatformDetector.detectPlatform(); + ServerPlatform first = PlatformDetector.detectPlatform(); + ServerPlatform second = PlatformDetector.detectPlatform(); assertEquals(first, second, "consecutive calls must return the same platform"); } @Test void detectResultIsValidPlatform() { - PlatformDetector.Platform result = PlatformDetector.detectPlatform(); - Set valid = Set.of(PlatformDetector.Platform.values()); + ServerPlatform result = PlatformDetector.detectPlatform(); + Set valid = Set.of(ServerPlatform.values()); assertTrue(valid.contains(result), "result must be one of the defined Platform enum values"); } diff --git a/core/src/main/java/dev/objz/commandbridge/net/payloads/util/AuthRequestPayload.java b/core/src/main/java/dev/objz/commandbridge/net/payloads/util/AuthRequestPayload.java index a7588365..ddf9c09a 100644 --- a/core/src/main/java/dev/objz/commandbridge/net/payloads/util/AuthRequestPayload.java +++ b/core/src/main/java/dev/objz/commandbridge/net/payloads/util/AuthRequestPayload.java @@ -1,9 +1,11 @@ package dev.objz.commandbridge.net.payloads.util; import dev.objz.commandbridge.scripting.model.enums.Location; +import dev.objz.commandbridge.scripting.model.enums.ServerPlatform; public record AuthRequestPayload( Location location, + ServerPlatform platform, String clientNonce, String hmac) { } diff --git a/core/src/main/java/dev/objz/commandbridge/scripting/model/enums/ServerPlatform.java b/core/src/main/java/dev/objz/commandbridge/scripting/model/enums/ServerPlatform.java new file mode 100644 index 00000000..d098f7bf --- /dev/null +++ b/core/src/main/java/dev/objz/commandbridge/scripting/model/enums/ServerPlatform.java @@ -0,0 +1,6 @@ +package dev.objz.commandbridge.scripting.model.enums; + +/** The server software a client runs, as it reports itself to the proxy when it authenticates. */ +public enum ServerPlatform { + FOLIA, PAPER, BUKKIT, FORGE, FABRIC, UNKNOWN +} diff --git a/velocity/src/main/java/dev/objz/commandbridge/velocity/net/in/AuthHandler.java b/velocity/src/main/java/dev/objz/commandbridge/velocity/net/in/AuthHandler.java index 65c80875..03dfa8a4 100644 --- a/velocity/src/main/java/dev/objz/commandbridge/velocity/net/in/AuthHandler.java +++ b/velocity/src/main/java/dev/objz/commandbridge/velocity/net/in/AuthHandler.java @@ -11,6 +11,7 @@ import dev.objz.commandbridge.net.proto.Envelope; import dev.objz.commandbridge.net.proto.MessageType; import dev.objz.commandbridge.scripting.model.enums.Location; +import dev.objz.commandbridge.scripting.model.enums.ServerPlatform; import dev.objz.commandbridge.velocity.net.EndpointServer; import dev.objz.commandbridge.velocity.net.session.ClientSession; import dev.objz.commandbridge.velocity.net.session.SessionHub; @@ -98,6 +99,8 @@ public void accept(Endpoint endpoint, Envelope env) { ClientSession s = sessions.add(env.from(), endpoint); s.location(ap.location() != null ? ap.location() : Location.BACKEND); + // clients older than this field send none + s.platform(ap.platform() != null ? ap.platform() : ServerPlatform.UNKNOWN); s.status(AuthStatus.AUTH_OK); reply(endpoint, env, MessageType.AUTH_OK, new AuthResponsePayload(serverNonce, serverMac)) diff --git a/velocity/src/main/java/dev/objz/commandbridge/velocity/net/session/ClientSession.java b/velocity/src/main/java/dev/objz/commandbridge/velocity/net/session/ClientSession.java index fc8030db..52dbc97d 100644 --- a/velocity/src/main/java/dev/objz/commandbridge/velocity/net/session/ClientSession.java +++ b/velocity/src/main/java/dev/objz/commandbridge/velocity/net/session/ClientSession.java @@ -2,6 +2,7 @@ import dev.objz.commandbridge.net.Endpoint; import dev.objz.commandbridge.scripting.model.enums.Location; +import dev.objz.commandbridge.scripting.model.enums.ServerPlatform; import dev.objz.commandbridge.security.AuthStatus; public final class ClientSession { @@ -9,6 +10,7 @@ public final class ClientSession { private volatile String id; private volatile AuthStatus status = AuthStatus.AUTH_FAIL; private volatile Location location = Location.BACKEND; + private volatile ServerPlatform platform = ServerPlatform.UNKNOWN; public ClientSession(Endpoint endpoint, String clientId) { this.endpoint = endpoint; @@ -43,4 +45,12 @@ public void location(Location location) { this.location = location; } + + public ServerPlatform platform() { + return platform; + } + + public void platform(ServerPlatform platform) { + this.platform = platform; + } } From 83b29a623b0565f6fa81621599d747956b79b453 Mon Sep 17 00:00:00 2001 From: objz Date: Mon, 26 Oct 2026 18:14:31 +0100 Subject: [PATCH 02/48] feat: live web editor for scripts (/cb editor) /cb editor opens a session through a relay on cb.objz.dev; every message is signed with a secret that only lives in the editor link. Changes are staged and written after /cb editor confirm , unsaved work is kept as a draft, and the session closes once the editor tab is gone. Adds /cb editor list and /cb editor close. --- .gitignore | 4 + README.md | 11 +- tools/cloudflare-dump-worker/README.md | 6 + tools/cloudflare-dump-worker/src/index.ts | 95 +++++ tools/cloudflare-dump-worker/wrangler.toml | 8 + velocity/build.gradle.kts | 1 + .../dev/objz/commandbridge/velocity/Main.java | 1 - .../commandbridge/velocity/ScriptManager.java | 69 ++++ .../commandbridge/velocity/cli/CBCommand.java | 17 +- .../cli/subcommands/EditorCommand.java | 124 +++++++ .../velocity/cli/subcommands/HelpCommand.java | 5 +- .../velocity/editor/EditorLayoutStore.java | 69 ++++ .../velocity/editor/EditorSession.java | 341 ++++++++++++++++++ .../velocity/EditorLayoutStoreTest.java | 61 ++++ .../velocity/ScriptManagerSaveTest.java | 75 ++++ 15 files changed, 876 insertions(+), 11 deletions(-) create mode 100644 velocity/src/main/java/dev/objz/commandbridge/velocity/cli/subcommands/EditorCommand.java create mode 100644 velocity/src/main/java/dev/objz/commandbridge/velocity/editor/EditorLayoutStore.java create mode 100644 velocity/src/main/java/dev/objz/commandbridge/velocity/editor/EditorSession.java create mode 100644 velocity/src/test/java/dev/objz/commandbridge/velocity/EditorLayoutStoreTest.java create mode 100644 velocity/src/test/java/dev/objz/commandbridge/velocity/ScriptManagerSaveTest.java diff --git a/.gitignore b/.gitignore index 7782df37..8e33b330 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,7 @@ dist/bin/ *.tmp *.bak *.swp + +# wrangler dev +node_modules/ +.wrangler/ diff --git a/README.md b/README.md index 98f12f6c..e7286669 100644 --- a/README.md +++ b/README.md @@ -234,11 +234,12 @@ api.onConnectionStateChanged(state -> { - [x] player locator service - [ ] more channel types and lifecycle hooks -- [ ] **web interface** - - [ ] ... - -- [ ] **admin GUI** - - [ ] ... +- [ ] **web editor (`/cb editor`)** + - [x] live session over a Cloudflare relay (no open ports, no self-hosting) + - [x] signed messages, secret only in the editor link + - [x] create, edit and delete scripts with inline validation errors + - [ ] config editing (without secrets and transport settings) + - [ ] backend config editing see the [open issues](https://github.com/objz/CommandBridge/issues) for bugs and feature requests. diff --git a/tools/cloudflare-dump-worker/README.md b/tools/cloudflare-dump-worker/README.md index d37d229e..18fbd7c9 100644 --- a/tools/cloudflare-dump-worker/README.md +++ b/tools/cloudflare-dump-worker/README.md @@ -13,6 +13,12 @@ This worker stores `/cb dump` payloads in Cloudflare D1 and serves them back to - `GET /api/dump/view/:id` - Compatibility redirect to `/dump/?id=` +- `GET /api/editor/:id/ws?role=proxy|editor` (WebSocket) + - Live editor relay, one Durable Object per session + - `proxy` requires the same bearer token; `editor` can only join while the proxy is connected + - One connection per role, forwarded as-is; sessions close after 1 hour or when the proxy leaves + - Messages are signed end to end (`HMAC-SHA256(secret, role \n seq \n body)`), the relay never sees the secret + ## Database Apply schema to D1: diff --git a/tools/cloudflare-dump-worker/src/index.ts b/tools/cloudflare-dump-worker/src/index.ts index d0d92f94..37821219 100644 --- a/tools/cloudflare-dump-worker/src/index.ts +++ b/tools/cloudflare-dump-worker/src/index.ts @@ -1,8 +1,11 @@ interface Env { DUMP_DB: D1Database; DUMP_AUTH_TOKEN?: string; + EDITOR_RELAY: DurableObjectNamespace; } +const EDITOR_SESSION_TTL_MS = 60 * 60 * 1000; + const DEFAULT_RETENTION_DAYS = 14; const MAX_RETENTION_DAYS = 30; const MAX_DUMP_BYTES = 2_000_000; @@ -53,6 +56,11 @@ export default { return handleFetch(id, env); } + const editorMatch = url.pathname.match(/^\/api\/editor\/([0-9a-f]{32})\/ws$/); + if (editorMatch) { + return handleEditorSocket(request, env, editorMatch[1], url.searchParams.get("role")); + } + if (url.pathname === "/api/health") { return json(200, { ok: true, service: "cb-dump-worker", storage: "d1" }); } @@ -152,6 +160,93 @@ async function handleFetch(id: string, env: Env): Promise { }); } +async function handleEditorSocket( + request: Request, + env: Env, + id: string, + role: string | null +): Promise { + if (request.headers.get("Upgrade") !== "websocket") { + return json(426, { error: "Expected WebSocket upgrade" }); + } + if (role !== "proxy" && role !== "editor") { + return json(400, { error: "Invalid role" }); + } + if (role === "proxy") { + const expectedToken = env.DUMP_AUTH_TOKEN || "cb-dump-upload-v1"; + if (request.headers.get("Authorization") !== `Bearer ${expectedToken}`) { + return json(401, { error: "Unauthorized" }); + } + } + return env.EDITOR_RELAY.get(env.EDITOR_RELAY.idFromName(id)).fetch(request); +} + +/** + * Relays messages between one proxy and one editor. It never reads them: both + * ends sign every message with a secret the relay never sees. + */ +export class EditorRelay implements DurableObject { + constructor(private state: DurableObjectState) {} + + async fetch(request: Request): Promise { + const role = new URL(request.url).searchParams.get("role") as "proxy" | "editor"; + + if (this.state.getWebSockets(role).length > 0) { + return json(409, { error: `An ${role} is already connected to this session` }); + } + if (role === "editor" && this.state.getWebSockets("proxy").length === 0) { + return json(404, { error: "Editor session not found or expired" }); + } + const pair = new WebSocketPair(); + this.state.acceptWebSocket(pair[1], [role]); + if (role === "proxy") { + await this.state.storage.setAlarm(Date.now() + EDITOR_SESSION_TTL_MS); + } + return new Response(null, { status: 101, webSocket: pair[0] }); + } + + async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise { + const other = this.roleOf(ws) === "proxy" ? "editor" : "proxy"; + for (const peer of this.state.getWebSockets(other)) { + peer.send(message); + } + } + + async webSocketClose(ws: WebSocket, code: number, reason: string): Promise { + if (this.roleOf(ws) === "proxy") { + this.closeAll(1000, "Proxy closed the session"); + } else { + safeClose(ws, code, reason); + } + } + + async webSocketError(ws: WebSocket): Promise { + await this.webSocketClose(ws, 1011, "Socket error"); + } + + async alarm(): Promise { + this.closeAll(1000, "Editor session expired"); + } + + private roleOf(ws: WebSocket): string { + return this.state.getTags(ws)[0]; + } + + private closeAll(code: number, reason: string): void { + for (const ws of this.state.getWebSockets()) { + safeClose(ws, code, reason); + } + } +} + +function safeClose(ws: WebSocket, code: number, reason: string): void { + try { + ws.close(code, reason); + } catch { + // already closed + } +} + function redirectToPublicDump(id: string): Response { if (!isValidDumpId(id)) { return json(400, { error: "Invalid dump id" }); diff --git a/tools/cloudflare-dump-worker/wrangler.toml b/tools/cloudflare-dump-worker/wrangler.toml index f9dfd029..431c55a6 100644 --- a/tools/cloudflare-dump-worker/wrangler.toml +++ b/tools/cloudflare-dump-worker/wrangler.toml @@ -5,6 +5,14 @@ compatibility_date = "2026-03-10" [vars] DUMP_AUTH_TOKEN = "cb-dump-upload-v1" +[[durable_objects.bindings]] +name = "EDITOR_RELAY" +class_name = "EditorRelay" + +[[migrations]] +tag = "v1" +new_sqlite_classes = ["EditorRelay"] + [[d1_databases]] binding = "DUMP_DB" database_name = "commandbridge-dumps" diff --git a/velocity/build.gradle.kts b/velocity/build.gradle.kts index 502d8702..735cc33e 100644 --- a/velocity/build.gradle.kts +++ b/velocity/build.gradle.kts @@ -40,6 +40,7 @@ dependencies { testImplementation(libs.junit.jupiter) testImplementation("com.velocitypowered:velocity-api:3.4.0-SNAPSHOT") + testImplementation("org.snakeyaml:snakeyaml-engine:2.10") testRuntimeOnly("org.junit.platform:junit-platform-launcher") } diff --git a/velocity/src/main/java/dev/objz/commandbridge/velocity/Main.java b/velocity/src/main/java/dev/objz/commandbridge/velocity/Main.java index 7fe4d7c7..738c8140 100644 --- a/velocity/src/main/java/dev/objz/commandbridge/velocity/Main.java +++ b/velocity/src/main/java/dev/objz/commandbridge/velocity/Main.java @@ -223,7 +223,6 @@ public void onProxyInitialization(ProxyInitializeEvent e) { installRoutes(); command = new CBCommand( - proxy, configManager, scriptManager, registrations, diff --git a/velocity/src/main/java/dev/objz/commandbridge/velocity/ScriptManager.java b/velocity/src/main/java/dev/objz/commandbridge/velocity/ScriptManager.java index c6ec3d85..1bde676d 100644 --- a/velocity/src/main/java/dev/objz/commandbridge/velocity/ScriptManager.java +++ b/velocity/src/main/java/dev/objz/commandbridge/velocity/ScriptManager.java @@ -204,6 +204,75 @@ public ToggleResult setEnabled(String name, boolean target) { return ToggleResult.changed(); } + public List save(String fileName, String yaml) { + List problems = validate(fileName, yaml); + if (!problems.isEmpty()) { + return problems; + } + Path file = resolveScriptFile(fileName); + try { + Files.createDirectories(scriptsDir); + Path tmp = scriptsDir.resolve(file.getFileName() + ".tmp"); + Files.writeString(tmp, yaml, StandardCharsets.UTF_8); + Files.move(tmp, file, java.nio.file.StandardCopyOption.REPLACE_EXISTING, + java.nio.file.StandardCopyOption.ATOMIC_MOVE); + } catch (IOException e) { + Log.error(e, "Failed to write script file '{}': {}", file, e.getMessage()); + return List.of("Failed to write file: " + e.getMessage()); + } + return List.of(); + } + + /** What would keep {@code yaml} from being saved as {@code fileName}; empty when it can be. */ + public List validate(String fileName, String yaml) { + Path file = resolveScriptFile(fileName); + if (file == null) { + return List.of("Invalid file name '" + fileName + "' (use letters, digits, '-' or '_' and .yml)"); + } + + LoadResult