gitspace 0.2.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +21 -0
- package/.gitspace/bundle.json +50 -0
- package/.gitspace/select/01-status.sh +40 -0
- package/.gitspace/setup/01-install-deps.sh +12 -0
- package/.gitspace/setup/02-typecheck.sh +16 -0
- package/AGENTS.md +439 -0
- package/CLAUDE.md +1 -0
- package/LICENSE +25 -0
- package/README.md +607 -0
- package/bin/gssh +62 -0
- package/bun.lock +647 -0
- package/docs/CONNECTION.md +623 -0
- package/docs/GATEWAY-WORKER.md +319 -0
- package/docs/GETTING-STARTED.md +448 -0
- package/docs/GITSPACE-PLATFORM.md +1819 -0
- package/docs/INFRASTRUCTURE.md +1347 -0
- package/docs/PROTOCOL.md +619 -0
- package/docs/QUICKSTART.md +174 -0
- package/docs/RELAY.md +327 -0
- package/docs/REMOTE-DESIGN.md +549 -0
- package/docs/ROADMAP.md +564 -0
- package/docs/SITE_DOCS_FIGMA_MAKE.md +1167 -0
- package/docs/STACK-DESIGN.md +588 -0
- package/docs/UNIFIED_ARCHITECTURE.md +292 -0
- package/experiments/pty-benchmark.ts +148 -0
- package/experiments/pty-latency.ts +100 -0
- package/experiments/router/client.ts +199 -0
- package/experiments/router/protocol.ts +74 -0
- package/experiments/router/router.ts +217 -0
- package/experiments/router/session.ts +180 -0
- package/experiments/router/test.ts +133 -0
- package/experiments/socket-bandwidth.ts +77 -0
- package/homebrew/gitspace.rb +45 -0
- package/landing-page/ATTRIBUTIONS.md +3 -0
- package/landing-page/README.md +11 -0
- package/landing-page/bun.lock +801 -0
- package/landing-page/guidelines/Guidelines.md +61 -0
- package/landing-page/index.html +37 -0
- package/landing-page/package.json +90 -0
- package/landing-page/postcss.config.mjs +15 -0
- package/landing-page/public/_redirects +1 -0
- package/landing-page/public/favicon.png +0 -0
- package/landing-page/src/app/App.tsx +53 -0
- package/landing-page/src/app/components/figma/ImageWithFallback.tsx +27 -0
- package/landing-page/src/app/components/ui/accordion.tsx +66 -0
- package/landing-page/src/app/components/ui/alert-dialog.tsx +157 -0
- package/landing-page/src/app/components/ui/alert.tsx +66 -0
- package/landing-page/src/app/components/ui/aspect-ratio.tsx +11 -0
- package/landing-page/src/app/components/ui/avatar.tsx +53 -0
- package/landing-page/src/app/components/ui/badge.tsx +46 -0
- package/landing-page/src/app/components/ui/breadcrumb.tsx +109 -0
- package/landing-page/src/app/components/ui/button.tsx +57 -0
- package/landing-page/src/app/components/ui/calendar.tsx +75 -0
- package/landing-page/src/app/components/ui/card.tsx +92 -0
- package/landing-page/src/app/components/ui/carousel.tsx +241 -0
- package/landing-page/src/app/components/ui/chart.tsx +353 -0
- package/landing-page/src/app/components/ui/checkbox.tsx +32 -0
- package/landing-page/src/app/components/ui/collapsible.tsx +33 -0
- package/landing-page/src/app/components/ui/command.tsx +177 -0
- package/landing-page/src/app/components/ui/context-menu.tsx +252 -0
- package/landing-page/src/app/components/ui/dialog.tsx +135 -0
- package/landing-page/src/app/components/ui/drawer.tsx +132 -0
- package/landing-page/src/app/components/ui/dropdown-menu.tsx +257 -0
- package/landing-page/src/app/components/ui/form.tsx +168 -0
- package/landing-page/src/app/components/ui/hover-card.tsx +44 -0
- package/landing-page/src/app/components/ui/input-otp.tsx +77 -0
- package/landing-page/src/app/components/ui/input.tsx +21 -0
- package/landing-page/src/app/components/ui/label.tsx +24 -0
- package/landing-page/src/app/components/ui/menubar.tsx +276 -0
- package/landing-page/src/app/components/ui/navigation-menu.tsx +168 -0
- package/landing-page/src/app/components/ui/pagination.tsx +127 -0
- package/landing-page/src/app/components/ui/popover.tsx +48 -0
- package/landing-page/src/app/components/ui/progress.tsx +31 -0
- package/landing-page/src/app/components/ui/radio-group.tsx +45 -0
- package/landing-page/src/app/components/ui/resizable.tsx +56 -0
- package/landing-page/src/app/components/ui/scroll-area.tsx +58 -0
- package/landing-page/src/app/components/ui/select.tsx +189 -0
- package/landing-page/src/app/components/ui/separator.tsx +28 -0
- package/landing-page/src/app/components/ui/sheet.tsx +139 -0
- package/landing-page/src/app/components/ui/sidebar.tsx +726 -0
- package/landing-page/src/app/components/ui/skeleton.tsx +13 -0
- package/landing-page/src/app/components/ui/slider.tsx +63 -0
- package/landing-page/src/app/components/ui/sonner.tsx +25 -0
- package/landing-page/src/app/components/ui/switch.tsx +31 -0
- package/landing-page/src/app/components/ui/table.tsx +116 -0
- package/landing-page/src/app/components/ui/tabs.tsx +66 -0
- package/landing-page/src/app/components/ui/textarea.tsx +18 -0
- package/landing-page/src/app/components/ui/toggle-group.tsx +73 -0
- package/landing-page/src/app/components/ui/toggle.tsx +47 -0
- package/landing-page/src/app/components/ui/tooltip.tsx +61 -0
- package/landing-page/src/app/components/ui/use-mobile.ts +21 -0
- package/landing-page/src/app/components/ui/utils.ts +6 -0
- package/landing-page/src/components/docs/DocsContent.tsx +718 -0
- package/landing-page/src/components/docs/DocsSidebar.tsx +84 -0
- package/landing-page/src/components/landing/CTA.tsx +59 -0
- package/landing-page/src/components/landing/Comparison.tsx +84 -0
- package/landing-page/src/components/landing/FaultyTerminal.tsx +424 -0
- package/landing-page/src/components/landing/Features.tsx +201 -0
- package/landing-page/src/components/landing/Hero.tsx +142 -0
- package/landing-page/src/components/landing/Pricing.tsx +140 -0
- package/landing-page/src/components/landing/Roadmap.tsx +86 -0
- package/landing-page/src/components/landing/Security.tsx +81 -0
- package/landing-page/src/components/landing/TerminalWindow.tsx +27 -0
- package/landing-page/src/components/landing/UseCases.tsx +55 -0
- package/landing-page/src/components/landing/Workflow.tsx +101 -0
- package/landing-page/src/components/layout/DashboardNavbar.tsx +37 -0
- package/landing-page/src/components/layout/Footer.tsx +55 -0
- package/landing-page/src/components/layout/LandingNavbar.tsx +82 -0
- package/landing-page/src/components/ui/badge.tsx +39 -0
- package/landing-page/src/components/ui/breadcrumb.tsx +115 -0
- package/landing-page/src/components/ui/button.tsx +57 -0
- package/landing-page/src/components/ui/card.tsx +79 -0
- package/landing-page/src/components/ui/mock-terminal.tsx +68 -0
- package/landing-page/src/components/ui/separator.tsx +28 -0
- package/landing-page/src/lib/utils.ts +6 -0
- package/landing-page/src/main.tsx +10 -0
- package/landing-page/src/pages/Dashboard.tsx +133 -0
- package/landing-page/src/pages/DocsPage.tsx +79 -0
- package/landing-page/src/pages/LandingPage.tsx +31 -0
- package/landing-page/src/pages/TerminalView.tsx +106 -0
- package/landing-page/src/styles/fonts.css +0 -0
- package/landing-page/src/styles/index.css +3 -0
- package/landing-page/src/styles/tailwind.css +4 -0
- package/landing-page/src/styles/theme.css +181 -0
- package/landing-page/vite.config.ts +19 -0
- package/npm/darwin-arm64/bin/gssh +0 -0
- package/npm/darwin-arm64/package.json +20 -0
- package/package.json +74 -0
- package/scripts/build.ts +284 -0
- package/scripts/release.ts +140 -0
- package/src/__tests__/test-utils.ts +298 -0
- package/src/commands/__tests__/serve-messages.test.ts +190 -0
- package/src/commands/access.ts +298 -0
- package/src/commands/add.ts +452 -0
- package/src/commands/auth.ts +364 -0
- package/src/commands/connect.ts +287 -0
- package/src/commands/directory.ts +16 -0
- package/src/commands/host.ts +396 -0
- package/src/commands/identity.ts +184 -0
- package/src/commands/list.ts +200 -0
- package/src/commands/relay.ts +315 -0
- package/src/commands/remove.ts +241 -0
- package/src/commands/serve.ts +1493 -0
- package/src/commands/share.ts +456 -0
- package/src/commands/status.ts +125 -0
- package/src/commands/switch.ts +353 -0
- package/src/commands/tmux.ts +317 -0
- package/src/core/__tests__/access.test.ts +240 -0
- package/src/core/access.ts +277 -0
- package/src/core/bundle.ts +342 -0
- package/src/core/config.ts +510 -0
- package/src/core/git.ts +317 -0
- package/src/core/github.ts +151 -0
- package/src/core/identity.ts +631 -0
- package/src/core/linear.ts +225 -0
- package/src/core/shell.ts +161 -0
- package/src/core/trusted-relays.ts +315 -0
- package/src/index.ts +821 -0
- package/src/lib/remote-session/index.ts +7 -0
- package/src/lib/remote-session/protocol.ts +267 -0
- package/src/lib/remote-session/session-handler.ts +581 -0
- package/src/lib/remote-session/workspace-scanner.ts +167 -0
- package/src/lib/tmux-lite/README.md +81 -0
- package/src/lib/tmux-lite/cli.ts +796 -0
- package/src/lib/tmux-lite/crypto/__tests__/helpers/handshake-runner.ts +349 -0
- package/src/lib/tmux-lite/crypto/__tests__/helpers/mock-relay.ts +291 -0
- package/src/lib/tmux-lite/crypto/__tests__/helpers/test-identities.ts +142 -0
- package/src/lib/tmux-lite/crypto/__tests__/integration/authorization.integration.test.ts +339 -0
- package/src/lib/tmux-lite/crypto/__tests__/integration/e2e-communication.integration.test.ts +477 -0
- package/src/lib/tmux-lite/crypto/__tests__/integration/error-handling.integration.test.ts +499 -0
- package/src/lib/tmux-lite/crypto/__tests__/integration/handshake.integration.test.ts +371 -0
- package/src/lib/tmux-lite/crypto/__tests__/integration/security.integration.test.ts +573 -0
- package/src/lib/tmux-lite/crypto/access-control.test.ts +512 -0
- package/src/lib/tmux-lite/crypto/access-control.ts +320 -0
- package/src/lib/tmux-lite/crypto/frames.test.ts +262 -0
- package/src/lib/tmux-lite/crypto/frames.ts +141 -0
- package/src/lib/tmux-lite/crypto/handshake.ts +894 -0
- package/src/lib/tmux-lite/crypto/identity.test.ts +220 -0
- package/src/lib/tmux-lite/crypto/identity.ts +286 -0
- package/src/lib/tmux-lite/crypto/index.ts +51 -0
- package/src/lib/tmux-lite/crypto/invites.test.ts +381 -0
- package/src/lib/tmux-lite/crypto/invites.ts +215 -0
- package/src/lib/tmux-lite/crypto/keyexchange.ts +435 -0
- package/src/lib/tmux-lite/crypto/keys.test.ts +58 -0
- package/src/lib/tmux-lite/crypto/keys.ts +47 -0
- package/src/lib/tmux-lite/crypto/secretbox.test.ts +169 -0
- package/src/lib/tmux-lite/crypto/secretbox.ts +124 -0
- package/src/lib/tmux-lite/handshake-handler.ts +451 -0
- package/src/lib/tmux-lite/protocol.test.ts +307 -0
- package/src/lib/tmux-lite/protocol.ts +266 -0
- package/src/lib/tmux-lite/relay-client.ts +506 -0
- package/src/lib/tmux-lite/server.ts +1250 -0
- package/src/lib/tmux-lite/shell-integration.sh +37 -0
- package/src/lib/tmux-lite/terminal-queries.test.ts +54 -0
- package/src/lib/tmux-lite/terminal-queries.ts +49 -0
- package/src/relay/__tests__/e2e-flow.test.ts +1284 -0
- package/src/relay/__tests__/helpers/auth.ts +354 -0
- package/src/relay/__tests__/helpers/ports.ts +51 -0
- package/src/relay/__tests__/protocol-validation.test.ts +265 -0
- package/src/relay/authorization.ts +303 -0
- package/src/relay/embedded-assets.generated.d.ts +15 -0
- package/src/relay/identity.ts +352 -0
- package/src/relay/index.ts +57 -0
- package/src/relay/pipes.test.ts +427 -0
- package/src/relay/pipes.ts +195 -0
- package/src/relay/protocol.ts +804 -0
- package/src/relay/registries.test.ts +437 -0
- package/src/relay/registries.ts +593 -0
- package/src/relay/server.test.ts +1323 -0
- package/src/relay/server.ts +1092 -0
- package/src/relay/signing.ts +238 -0
- package/src/relay/types.ts +69 -0
- package/src/serve/client-session-manager.ts +622 -0
- package/src/serve/daemon.ts +497 -0
- package/src/serve/pty-session.ts +236 -0
- package/src/serve/types.ts +169 -0
- package/src/shared/components/Flow.tsx +453 -0
- package/src/shared/components/Flow.tui.tsx +343 -0
- package/src/shared/components/Flow.web.tsx +442 -0
- package/src/shared/components/Inbox.tsx +446 -0
- package/src/shared/components/Inbox.tui.tsx +262 -0
- package/src/shared/components/Inbox.web.tsx +329 -0
- package/src/shared/components/MachineList.tsx +187 -0
- package/src/shared/components/MachineList.tui.tsx +161 -0
- package/src/shared/components/MachineList.web.tsx +210 -0
- package/src/shared/components/ProjectList.tsx +176 -0
- package/src/shared/components/ProjectList.tui.tsx +109 -0
- package/src/shared/components/ProjectList.web.tsx +143 -0
- package/src/shared/components/SpacesBrowser.tsx +332 -0
- package/src/shared/components/SpacesBrowser.tui.tsx +163 -0
- package/src/shared/components/SpacesBrowser.web.tsx +221 -0
- package/src/shared/components/index.ts +103 -0
- package/src/shared/hooks/index.ts +16 -0
- package/src/shared/hooks/useNavigation.ts +226 -0
- package/src/shared/index.ts +122 -0
- package/src/shared/providers/LocalMachineProvider.ts +425 -0
- package/src/shared/providers/MachineProvider.ts +165 -0
- package/src/shared/providers/RemoteMachineProvider.ts +444 -0
- package/src/shared/providers/index.ts +26 -0
- package/src/shared/types.ts +145 -0
- package/src/tui/adapters.ts +120 -0
- package/src/tui/app.tsx +1816 -0
- package/src/tui/components/Terminal.tsx +580 -0
- package/src/tui/hooks/index.ts +35 -0
- package/src/tui/hooks/useAppState.ts +314 -0
- package/src/tui/hooks/useDaemonStatus.ts +174 -0
- package/src/tui/hooks/useInboxTUI.ts +113 -0
- package/src/tui/hooks/useRemoteMachines.ts +209 -0
- package/src/tui/index.ts +24 -0
- package/src/tui/state.ts +299 -0
- package/src/tui/terminal-bracketed-paste.test.ts +45 -0
- package/src/tui/terminal-bracketed-paste.ts +47 -0
- package/src/types/bundle.ts +112 -0
- package/src/types/config.ts +89 -0
- package/src/types/errors.ts +206 -0
- package/src/types/identity.ts +284 -0
- package/src/types/workspace-fuzzy.ts +49 -0
- package/src/types/workspace.ts +151 -0
- package/src/utils/bun-socket-writer.ts +80 -0
- package/src/utils/deps.ts +127 -0
- package/src/utils/fuzzy-match.ts +125 -0
- package/src/utils/logger.ts +127 -0
- package/src/utils/markdown.ts +254 -0
- package/src/utils/onboarding.ts +229 -0
- package/src/utils/prompts.ts +114 -0
- package/src/utils/run-commands.ts +112 -0
- package/src/utils/run-scripts.ts +142 -0
- package/src/utils/sanitize.ts +98 -0
- package/src/utils/secrets.ts +122 -0
- package/src/utils/shell-escape.ts +40 -0
- package/src/utils/utf8.ts +79 -0
- package/src/utils/workspace-state.ts +47 -0
- package/src/web/README.md +73 -0
- package/src/web/bun.lock +575 -0
- package/src/web/eslint.config.js +23 -0
- package/src/web/index.html +16 -0
- package/src/web/package.json +37 -0
- package/src/web/public/vite.svg +1 -0
- package/src/web/src/App.tsx +604 -0
- package/src/web/src/assets/react.svg +1 -0
- package/src/web/src/components/Terminal.tsx +207 -0
- package/src/web/src/hooks/useRelayConnection.ts +224 -0
- package/src/web/src/hooks/useTerminal.ts +699 -0
- package/src/web/src/index.css +55 -0
- package/src/web/src/lib/crypto/__tests__/web-terminal.test.ts +1158 -0
- package/src/web/src/lib/crypto/frames.ts +205 -0
- package/src/web/src/lib/crypto/handshake.ts +396 -0
- package/src/web/src/lib/crypto/identity.ts +128 -0
- package/src/web/src/lib/crypto/keyexchange.ts +246 -0
- package/src/web/src/lib/crypto/relay-signing.ts +53 -0
- package/src/web/src/lib/invite.ts +58 -0
- package/src/web/src/lib/storage/identity-store.ts +94 -0
- package/src/web/src/main.tsx +10 -0
- package/src/web/src/types/identity.ts +45 -0
- package/src/web/tsconfig.app.json +28 -0
- package/src/web/tsconfig.json +7 -0
- package/src/web/tsconfig.node.json +26 -0
- package/src/web/vite.config.ts +31 -0
- package/todo-security.md +92 -0
- package/tsconfig.json +23 -0
- package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/12b7107e435bf1b9a8713a7f320472a63e543104d633d89a26f8d21f4e4ef182.sqlite +0 -0
- package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/12b7107e435bf1b9a8713a7f320472a63e543104d633d89a26f8d21f4e4ef182.sqlite-shm +0 -0
- package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/12b7107e435bf1b9a8713a7f320472a63e543104d633d89a26f8d21f4e4ef182.sqlite-wal +0 -0
- package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/1a1ac3db1ab86ecf712f90322868a9aabc2c7dc9fe2dfbe94f9b075096276b0f.sqlite +0 -0
- package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/1a1ac3db1ab86ecf712f90322868a9aabc2c7dc9fe2dfbe94f9b075096276b0f.sqlite-shm +0 -0
- package/worker/.wrangler/state/v3/d1/miniflare-D1DatabaseObject/1a1ac3db1ab86ecf712f90322868a9aabc2c7dc9fe2dfbe94f9b075096276b0f.sqlite-wal +0 -0
- package/worker/bun.lock +237 -0
- package/worker/package.json +22 -0
- package/worker/schema.sql +96 -0
- package/worker/src/handlers/auth.ts +451 -0
- package/worker/src/handlers/subdomains.ts +376 -0
- package/worker/src/handlers/user.ts +98 -0
- package/worker/src/index.ts +70 -0
- package/worker/src/middleware/auth.ts +152 -0
- package/worker/src/services/cloudflare.ts +609 -0
- package/worker/src/types.ts +96 -0
- package/worker/tsconfig.json +15 -0
- package/worker/wrangler.toml +26 -0
|
@@ -0,0 +1,1092 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relay server - Bun.serve() WebSocket server with self-registration
|
|
3
|
+
*
|
|
4
|
+
* Endpoints:
|
|
5
|
+
* - GET /ws?role=<machine|client> - WebSocket upgrade
|
|
6
|
+
* - GET /health - Health check
|
|
7
|
+
*
|
|
8
|
+
* Protocol:
|
|
9
|
+
* - Machines authenticate via Ed25519 challenge-response
|
|
10
|
+
* - Clients connect via invites or directly if authorized
|
|
11
|
+
* - Data is routed point-to-point using connectionId
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { join, resolve, sep } from "path";
|
|
15
|
+
import { randomBytes } from "crypto";
|
|
16
|
+
import type { Server, ServerWebSocket } from "bun";
|
|
17
|
+
import type { RelayConfig, WebSocketData } from "./types";
|
|
18
|
+
import { ed25519 } from "@noble/curves/ed25519.js";
|
|
19
|
+
import { signMessage, verifySignedMessage, getSignerPublicKey, type SignedMessage } from "./signing.js";
|
|
20
|
+
import { PROTOCOL_VERSION } from "./protocol.js";
|
|
21
|
+
import { formatRelayFingerprint, type RelayIdentity } from "./identity.js";
|
|
22
|
+
import { isAuthorized, getAuthorizedMachine } from "./authorization.js";
|
|
23
|
+
import { deriveIdentityId } from "../lib/tmux-lite/crypto/identity.js";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Path to web terminal dist files (built by Vite)
|
|
27
|
+
* Used for development mode when assets aren't embedded
|
|
28
|
+
*/
|
|
29
|
+
const WEB_DIST_PATH = join(import.meta.dir, "../web/dist");
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Try to import embedded assets (only available in compiled binary)
|
|
33
|
+
*/
|
|
34
|
+
let embeddedAssets: typeof import("./embedded-assets.generated") | null = null;
|
|
35
|
+
try {
|
|
36
|
+
embeddedAssets = await import("./embedded-assets.generated.js");
|
|
37
|
+
} catch {
|
|
38
|
+
// Not running as compiled binary - use filesystem
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Check if we have embedded assets available
|
|
43
|
+
*/
|
|
44
|
+
function hasEmbeddedAssets(): boolean {
|
|
45
|
+
return embeddedAssets?.hasEmbeddedAssets() ?? false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Get content type for a file extension
|
|
50
|
+
*/
|
|
51
|
+
function getContentType(pathname: string): string {
|
|
52
|
+
const ext = pathname.split(".").pop();
|
|
53
|
+
const contentTypes: Record<string, string> = {
|
|
54
|
+
html: "text/html; charset=utf-8",
|
|
55
|
+
js: "application/javascript",
|
|
56
|
+
css: "text/css",
|
|
57
|
+
wasm: "application/wasm",
|
|
58
|
+
json: "application/json",
|
|
59
|
+
svg: "image/svg+xml",
|
|
60
|
+
png: "image/png",
|
|
61
|
+
jpg: "image/jpeg",
|
|
62
|
+
ico: "image/x-icon",
|
|
63
|
+
};
|
|
64
|
+
return contentTypes[ext || ""] || "application/octet-stream";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Serve a static file - tries embedded assets first, falls back to filesystem
|
|
69
|
+
*/
|
|
70
|
+
async function serveStaticFile(pathname: string): Promise<Response | null> {
|
|
71
|
+
// Try embedded assets first (compiled binary)
|
|
72
|
+
if (hasEmbeddedAssets() && embeddedAssets) {
|
|
73
|
+
const blob = embeddedAssets.getEmbeddedFile(pathname);
|
|
74
|
+
if (blob) {
|
|
75
|
+
return new Response(blob, {
|
|
76
|
+
headers: { "Content-Type": getContentType(pathname) },
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Fall back to filesystem (development mode)
|
|
82
|
+
const filePath = pathname === "/" ? "/index.html" : pathname;
|
|
83
|
+
const resolvedPath = resolveAssetPath(filePath);
|
|
84
|
+
if (!resolvedPath) return null;
|
|
85
|
+
|
|
86
|
+
const file = Bun.file(resolvedPath);
|
|
87
|
+
if (await file.exists()) {
|
|
88
|
+
return new Response(file, {
|
|
89
|
+
headers: { "Content-Type": getContentType(pathname) },
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
import {
|
|
96
|
+
registerMachine,
|
|
97
|
+
getMachine,
|
|
98
|
+
setMachineConnection,
|
|
99
|
+
registerInvite,
|
|
100
|
+
getInvite,
|
|
101
|
+
isInviteValid,
|
|
102
|
+
useInvite,
|
|
103
|
+
authorizeClient,
|
|
104
|
+
revokeClientAuthorization,
|
|
105
|
+
getAllMachinesWithAuthStatus,
|
|
106
|
+
getRegistryStats,
|
|
107
|
+
getEffectiveAccessList,
|
|
108
|
+
addGlobalAccess,
|
|
109
|
+
removeGlobalAccess,
|
|
110
|
+
broadcastAccessUpdate,
|
|
111
|
+
} from "./registries";
|
|
112
|
+
import {
|
|
113
|
+
parseMessage,
|
|
114
|
+
serializeMessage,
|
|
115
|
+
createErrorMessage,
|
|
116
|
+
isMachineDataMessage,
|
|
117
|
+
isClientDataMessage,
|
|
118
|
+
isClientHandshakeMessage,
|
|
119
|
+
type ProtocolMessage,
|
|
120
|
+
type RegisterMachineMessage,
|
|
121
|
+
type RegisterInviteMessage,
|
|
122
|
+
type AuthorizeClientMessage,
|
|
123
|
+
type RevokeClientMessage,
|
|
124
|
+
type ListMachinesMessage,
|
|
125
|
+
type ConnectWithInviteMessage,
|
|
126
|
+
type ConnectToMachineMessage,
|
|
127
|
+
type AddGlobalAccessMessage,
|
|
128
|
+
type RemoveGlobalAccessMessage,
|
|
129
|
+
type AccessListMessage,
|
|
130
|
+
} from "./protocol";
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Generate a unique connection ID using cryptographically secure randomness
|
|
134
|
+
*
|
|
135
|
+
* Security: Uses crypto.randomBytes instead of Math.random to prevent
|
|
136
|
+
* connection ID prediction attacks.
|
|
137
|
+
*/
|
|
138
|
+
function generateConnectionId(): string {
|
|
139
|
+
return randomBytes(8).toString("hex");
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Challenge timeout in milliseconds (30 seconds)
|
|
144
|
+
*/
|
|
145
|
+
const CHALLENGE_TIMEOUT_MS = 30000;
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Connection rate limiting (best-effort, in-memory)
|
|
149
|
+
*/
|
|
150
|
+
const RATE_LIMIT_WINDOW_MS = 60_000;
|
|
151
|
+
const MAX_CONNECTIONS_PER_IP = 20;
|
|
152
|
+
const connectionRateLimits = new Map<string, { count: number; lastReset: number }>();
|
|
153
|
+
|
|
154
|
+
function getClientIp(req: Request): string {
|
|
155
|
+
const forwarded = req.headers.get("x-forwarded-for");
|
|
156
|
+
if (forwarded) {
|
|
157
|
+
return forwarded.split(",")[0]?.trim() || "unknown";
|
|
158
|
+
}
|
|
159
|
+
const cfConnecting = req.headers.get("cf-connecting-ip");
|
|
160
|
+
if (cfConnecting) {
|
|
161
|
+
return cfConnecting.trim();
|
|
162
|
+
}
|
|
163
|
+
const realIp = req.headers.get("x-real-ip");
|
|
164
|
+
if (realIp) {
|
|
165
|
+
return realIp.trim();
|
|
166
|
+
}
|
|
167
|
+
return "unknown";
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function consumeConnectionSlot(ip: string): boolean {
|
|
171
|
+
const now = Date.now();
|
|
172
|
+
const record = connectionRateLimits.get(ip);
|
|
173
|
+
if (!record || now - record.lastReset > RATE_LIMIT_WINDOW_MS) {
|
|
174
|
+
connectionRateLimits.set(ip, { count: 1, lastReset: now });
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
if (record.count >= MAX_CONNECTIONS_PER_IP) {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
record.count += 1;
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function resolveAssetPath(pathname: string): string | null {
|
|
185
|
+
const webRoot = resolve(WEB_DIST_PATH);
|
|
186
|
+
const relativePath = pathname.replace(/^\/+/, "");
|
|
187
|
+
const resolvedPath = resolve(webRoot, relativePath);
|
|
188
|
+
if (!resolvedPath.startsWith(webRoot + sep)) {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
return resolvedPath;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
type SignedClientMessageType = "list_machines" | "connect_with_invite" | "connect_to_machine";
|
|
195
|
+
const SIGNED_CLIENT_MESSAGE_TYPES = new Set<SignedClientMessageType>([
|
|
196
|
+
"list_machines",
|
|
197
|
+
"connect_with_invite",
|
|
198
|
+
"connect_to_machine",
|
|
199
|
+
]);
|
|
200
|
+
|
|
201
|
+
function isSignedClientMessageType(type: unknown): type is SignedClientMessageType {
|
|
202
|
+
return typeof type === "string" && SIGNED_CLIENT_MESSAGE_TYPES.has(type as SignedClientMessageType);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function hasSignatureFields(signature: unknown): boolean {
|
|
206
|
+
if (!signature || typeof signature !== "object") return false;
|
|
207
|
+
const sig = signature as Record<string, unknown>;
|
|
208
|
+
return typeof sig.sig === "string" && typeof sig.pub === "string" && typeof sig.ts === "number";
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function rejectUnsignedClientMessage(
|
|
212
|
+
ws: ServerWebSocket<WebSocketData>,
|
|
213
|
+
rawMsg: unknown
|
|
214
|
+
): boolean {
|
|
215
|
+
if (ws.data.role !== "client") return false;
|
|
216
|
+
if (!rawMsg || typeof rawMsg !== "object") return false;
|
|
217
|
+
const msg = rawMsg as Record<string, unknown>;
|
|
218
|
+
if (!isSignedClientMessageType(msg.type)) return false;
|
|
219
|
+
if (hasSignatureFields(msg.signature)) return false;
|
|
220
|
+
ws.send(serializeMessage(createErrorMessage("INVALID_SIGNATURE", "Client message signature missing or invalid")));
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function verifyClientIdentity<T extends { clientIdentityId: string }>(
|
|
225
|
+
msg: SignedMessage<T>
|
|
226
|
+
): T | null {
|
|
227
|
+
const verified = verifySignedMessage(msg);
|
|
228
|
+
if (!verified) {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const signerKey = getSignerPublicKey(msg);
|
|
233
|
+
if (!signerKey) {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
let derivedId: string;
|
|
238
|
+
try {
|
|
239
|
+
derivedId = deriveIdentityId(signerKey);
|
|
240
|
+
} catch {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (derivedId !== verified.clientIdentityId) {
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return verified;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
interface RelayServerState {
|
|
252
|
+
clientConnections: Map<string, ServerWebSocket<WebSocketData>>;
|
|
253
|
+
machineClients: Map<string, Set<string>>;
|
|
254
|
+
pendingChallenges: Map<string, { nonce: Uint8Array; timestamp: number }>;
|
|
255
|
+
preAuthorizedMachines: Set<string>;
|
|
256
|
+
signRelayMessage: <T extends object>(msg: T) => T;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Set up a client connection to a machine
|
|
261
|
+
* Tracks the connection and updates machineClients map
|
|
262
|
+
*/
|
|
263
|
+
function setupClientConnection(
|
|
264
|
+
state: RelayServerState,
|
|
265
|
+
machineId: string,
|
|
266
|
+
connectionId: string,
|
|
267
|
+
ws: ServerWebSocket<WebSocketData>,
|
|
268
|
+
clientIdentityId: string
|
|
269
|
+
): void {
|
|
270
|
+
ws.data.machineId = machineId;
|
|
271
|
+
ws.data.clientIdentityId = clientIdentityId;
|
|
272
|
+
|
|
273
|
+
// Track client connection
|
|
274
|
+
state.clientConnections.set(connectionId, ws);
|
|
275
|
+
|
|
276
|
+
let clients = state.machineClients.get(machineId);
|
|
277
|
+
if (!clients) {
|
|
278
|
+
clients = new Set();
|
|
279
|
+
state.machineClients.set(machineId, clients);
|
|
280
|
+
}
|
|
281
|
+
clients.add(connectionId);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Create the relay server
|
|
286
|
+
*/
|
|
287
|
+
export function createRelayServer(config: RelayConfig): Server<WebSocketData> {
|
|
288
|
+
const { port, bind = "0.0.0.0", hostname, identity } = config;
|
|
289
|
+
const disableRateLimit = config.disableRateLimit === true;
|
|
290
|
+
|
|
291
|
+
// NOTE: This file can be used in Bun tests which run files in parallel.
|
|
292
|
+
// Keep mutable state per-server instance (not module-global) so multiple
|
|
293
|
+
// relay servers can coexist in the same process without interfering.
|
|
294
|
+
const relayIdentity: RelayIdentity = identity;
|
|
295
|
+
const fingerprint = formatRelayFingerprint(identity.signingPublicKey);
|
|
296
|
+
console.log(`[relay] Using identity: ${fingerprint}${identity.label ? ` (${identity.label})` : ""}`);
|
|
297
|
+
|
|
298
|
+
// Store pre-authorized machines (for ephemeral local relays)
|
|
299
|
+
const preAuthorizedMachines: Set<string> = config.preAuthorizedMachines instanceof Set
|
|
300
|
+
? config.preAuthorizedMachines
|
|
301
|
+
: new Set(config.preAuthorizedMachines || []);
|
|
302
|
+
if (preAuthorizedMachines.size > 0) {
|
|
303
|
+
console.log(`[relay] Pre-authorized ${preAuthorizedMachines.size} machine(s)`);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Client connections by connectionId (for routing machine → client)
|
|
308
|
+
*/
|
|
309
|
+
const clientConnections = new Map<string, ServerWebSocket<WebSocketData>>();
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Track which clients are connected to which machine
|
|
313
|
+
* machineId → Set<connectionId>
|
|
314
|
+
*/
|
|
315
|
+
const machineClients = new Map<string, Set<string>>();
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Track pending identity challenges for machine connections
|
|
319
|
+
* connectionId → { nonce, timestamp }
|
|
320
|
+
*/
|
|
321
|
+
interface PendingChallenge {
|
|
322
|
+
nonce: Uint8Array;
|
|
323
|
+
timestamp: number;
|
|
324
|
+
}
|
|
325
|
+
const pendingChallenges = new Map<string, PendingChallenge>();
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Sign a message with the relay's private key
|
|
329
|
+
* Returns the message with signature
|
|
330
|
+
*/
|
|
331
|
+
const signRelayMessage = <T extends object>(msg: T): T => {
|
|
332
|
+
const pubKeyBytes = new Uint8Array(Buffer.from(relayIdentity.signingPublicKey, "base64"));
|
|
333
|
+
return signMessage(msg, relayIdentity.signingPrivateKey, pubKeyBytes);
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
const state: RelayServerState = {
|
|
337
|
+
clientConnections,
|
|
338
|
+
machineClients,
|
|
339
|
+
pendingChallenges,
|
|
340
|
+
preAuthorizedMachines,
|
|
341
|
+
signRelayMessage,
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const server = Bun.serve<WebSocketData>({
|
|
345
|
+
port,
|
|
346
|
+
hostname: bind,
|
|
347
|
+
|
|
348
|
+
async fetch(req, server) {
|
|
349
|
+
const url = new URL(req.url);
|
|
350
|
+
|
|
351
|
+
// Check Host header if hostname is specified
|
|
352
|
+
if (hostname) {
|
|
353
|
+
const hostHeader = req.headers.get("host");
|
|
354
|
+
const host = hostHeader?.split(":")[0]; // Remove port
|
|
355
|
+
console.log(`[relay] Request: ${url.pathname} Host: ${hostHeader} -> ${host} (expected: ${hostname})`);
|
|
356
|
+
if (host !== hostname) {
|
|
357
|
+
console.log(`[relay] Rejecting request - hostname mismatch`);
|
|
358
|
+
return new Response("Not found", { status: 404 });
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Health check
|
|
363
|
+
if (url.pathname === "/health") {
|
|
364
|
+
const stats = getRegistryStats();
|
|
365
|
+
const clientCount = clientConnections.size;
|
|
366
|
+
return Response.json({
|
|
367
|
+
status: "ok",
|
|
368
|
+
...stats,
|
|
369
|
+
connectedClients: clientCount,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// WebSocket upgrade
|
|
374
|
+
// - Machines and clients connect freely
|
|
375
|
+
// - Machine authentication happens via challenge-response during registration
|
|
376
|
+
// - Client authorization happens via X3DH handshake with machine
|
|
377
|
+
if (url.pathname === "/ws") {
|
|
378
|
+
if (!disableRateLimit) {
|
|
379
|
+
const clientIp = getClientIp(req);
|
|
380
|
+
if (!consumeConnectionSlot(clientIp)) {
|
|
381
|
+
return new Response("Too many connections", { status: 429 });
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const role = url.searchParams.get("role") as "machine" | "client";
|
|
386
|
+
|
|
387
|
+
if (!role || !["machine", "client"].includes(role)) {
|
|
388
|
+
return new Response("Invalid role", { status: 400 });
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const wsData: WebSocketData = {
|
|
392
|
+
machineId: "", // Set later by protocol messages
|
|
393
|
+
role,
|
|
394
|
+
connectionId: generateConnectionId(),
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
// Upgrade to WebSocket
|
|
398
|
+
const upgraded = server.upgrade(req, { data: wsData });
|
|
399
|
+
|
|
400
|
+
if (!upgraded) {
|
|
401
|
+
return new Response("WebSocket upgrade failed", { status: 500 });
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return undefined;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Serve web terminal UI (embedded or from filesystem)
|
|
408
|
+
if (url.pathname === "/" || url.pathname === "/index.html" || url.pathname.startsWith("/assets/") || url.pathname === "/vite.svg") {
|
|
409
|
+
const response = await serveStaticFile(url.pathname);
|
|
410
|
+
if (response) return response;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
return new Response("Not Found", { status: 404 });
|
|
414
|
+
},
|
|
415
|
+
|
|
416
|
+
websocket: {
|
|
417
|
+
open(ws) {
|
|
418
|
+
const { role, connectionId } = ws.data;
|
|
419
|
+
console.log(`[ws] ${role} ${connectionId} connected`);
|
|
420
|
+
|
|
421
|
+
// Send relay_identity message to machines (includes challenge nonce)
|
|
422
|
+
if (role === "machine") {
|
|
423
|
+
const nonce = randomBytes(32);
|
|
424
|
+
const relayIdMsg = {
|
|
425
|
+
type: "relay_identity" as const,
|
|
426
|
+
publicKey: relayIdentity.signingPublicKey,
|
|
427
|
+
fingerprint: formatRelayFingerprint(relayIdentity.signingPublicKey),
|
|
428
|
+
label: relayIdentity.label,
|
|
429
|
+
challenge: nonce.toString("base64"),
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
// Store pending challenge
|
|
433
|
+
pendingChallenges.set(connectionId, {
|
|
434
|
+
nonce,
|
|
435
|
+
timestamp: Date.now(),
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
ws.send(serializeMessage(relayIdMsg));
|
|
439
|
+
console.log(`[ws] Sent relay_identity to machine ${connectionId}`);
|
|
440
|
+
}
|
|
441
|
+
},
|
|
442
|
+
|
|
443
|
+
message(ws, message) {
|
|
444
|
+
// Try to parse as protocol message (JSON)
|
|
445
|
+
const msgStr = typeof message === "string"
|
|
446
|
+
? message
|
|
447
|
+
: new TextDecoder().decode(message instanceof ArrayBuffer ? message : message);
|
|
448
|
+
|
|
449
|
+
let rawMsg: unknown = null;
|
|
450
|
+
// Handle ping/pong for keepalive FIRST (before protocol parsing)
|
|
451
|
+
// These are simple keepalive messages, not protocol messages
|
|
452
|
+
try {
|
|
453
|
+
rawMsg = JSON.parse(msgStr);
|
|
454
|
+
if (rawMsg && typeof rawMsg === "object" && (rawMsg as { type?: string }).type === "ping") {
|
|
455
|
+
ws.send(JSON.stringify({ type: "pong", timestamp: Date.now() }));
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
} catch {
|
|
459
|
+
// Not valid JSON - continue with normal handling
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const parsed = parseMessage(msgStr);
|
|
463
|
+
if (!parsed && rejectUnsignedClientMessage(ws, rawMsg)) {
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Route data and handshake messages between client and machine
|
|
468
|
+
// All other message types are protocol messages handled by the relay
|
|
469
|
+
if (parsed && parsed.type !== "data" && parsed.type !== "handshake") {
|
|
470
|
+
// Handle protocol message
|
|
471
|
+
handleProtocolMessage(state, ws, parsed);
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Handle data/handshake message - route based on role and connectionId
|
|
476
|
+
handleDataMessage(state, ws, message);
|
|
477
|
+
},
|
|
478
|
+
|
|
479
|
+
close(ws, code, reason) {
|
|
480
|
+
const { machineId, role, connectionId } = ws.data;
|
|
481
|
+
console.log(
|
|
482
|
+
`[ws] ${role} ${connectionId} disconnected (${code}: ${reason})`
|
|
483
|
+
);
|
|
484
|
+
|
|
485
|
+
// Clean up pending challenge if any
|
|
486
|
+
pendingChallenges.delete(connectionId);
|
|
487
|
+
|
|
488
|
+
if (role === "machine" && machineId) {
|
|
489
|
+
// Mark machine as offline
|
|
490
|
+
setMachineConnection(machineId, null);
|
|
491
|
+
|
|
492
|
+
// Notify connected clients that machine is offline
|
|
493
|
+
const clients = machineClients.get(machineId);
|
|
494
|
+
if (clients) {
|
|
495
|
+
for (const clientConnId of clients) {
|
|
496
|
+
const clientWs = clientConnections.get(clientConnId);
|
|
497
|
+
if (clientWs) {
|
|
498
|
+
clientWs.send(serializeMessage({
|
|
499
|
+
type: "connection_failed",
|
|
500
|
+
reason: "Machine disconnected",
|
|
501
|
+
}));
|
|
502
|
+
clientWs.close(1000, "Machine disconnected");
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
machineClients.delete(machineId);
|
|
506
|
+
}
|
|
507
|
+
} else if (role === "client") {
|
|
508
|
+
// Remove from client connections
|
|
509
|
+
clientConnections.delete(connectionId);
|
|
510
|
+
|
|
511
|
+
// Remove from machine's client set
|
|
512
|
+
if (machineId) {
|
|
513
|
+
const clients = machineClients.get(machineId);
|
|
514
|
+
if (clients) {
|
|
515
|
+
clients.delete(connectionId);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Notify machine of client disconnect
|
|
519
|
+
const machine = getMachine(machineId);
|
|
520
|
+
if (machine?.ws) {
|
|
521
|
+
machine.ws.send(serializeMessage({
|
|
522
|
+
type: "client_disconnected",
|
|
523
|
+
connectionId,
|
|
524
|
+
reason: reason || "Client disconnected",
|
|
525
|
+
}));
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
},
|
|
530
|
+
|
|
531
|
+
drain(_ws) {
|
|
532
|
+
// Called when the socket is ready for more data
|
|
533
|
+
},
|
|
534
|
+
},
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
console.log(`[relay] Listening on ${bind}:${port}${hostname ? ` (serving ${hostname})` : ""}`);
|
|
538
|
+
return server;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Handle protocol messages
|
|
543
|
+
*/
|
|
544
|
+
function handleProtocolMessage(
|
|
545
|
+
state: RelayServerState,
|
|
546
|
+
ws: ServerWebSocket<WebSocketData>,
|
|
547
|
+
msg: ProtocolMessage
|
|
548
|
+
): void {
|
|
549
|
+
const { role, connectionId } = ws.data;
|
|
550
|
+
|
|
551
|
+
switch (msg.type) {
|
|
552
|
+
// ========== Machine Messages ==========
|
|
553
|
+
|
|
554
|
+
case "register_machine": {
|
|
555
|
+
if (role !== "machine") {
|
|
556
|
+
ws.send(serializeMessage(createErrorMessage("FORBIDDEN", "Only machines can register")));
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const regMsg = msg as RegisterMachineMessage;
|
|
561
|
+
|
|
562
|
+
// Get pending challenge for this connection
|
|
563
|
+
const pending = state.pendingChallenges.get(connectionId);
|
|
564
|
+
if (!pending) {
|
|
565
|
+
ws.send(serializeMessage(createErrorMessage("INVALID_STATE", "No pending challenge - reconnect required")));
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Check challenge timeout
|
|
570
|
+
if (Date.now() - pending.timestamp > CHALLENGE_TIMEOUT_MS) {
|
|
571
|
+
state.pendingChallenges.delete(connectionId);
|
|
572
|
+
ws.send(serializeMessage(createErrorMessage("EXPIRED", "Challenge expired - reconnect required")));
|
|
573
|
+
ws.close();
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Verify challenge response signature
|
|
578
|
+
if (!regMsg.challengeResponse) {
|
|
579
|
+
ws.send(serializeMessage(createErrorMessage("INVALID_REQUEST", "Challenge response required")));
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
try {
|
|
584
|
+
const signatureBytes = new Uint8Array(Buffer.from(regMsg.challengeResponse, "base64"));
|
|
585
|
+
const pubkeyBytes = new Uint8Array(Buffer.from(regMsg.signingKey, "base64"));
|
|
586
|
+
|
|
587
|
+
if (!ed25519.verify(signatureBytes, pending.nonce, pubkeyBytes)) {
|
|
588
|
+
console.warn(`[relay] Challenge verification failed for ${connectionId}`);
|
|
589
|
+
state.pendingChallenges.delete(connectionId);
|
|
590
|
+
ws.send(serializeMessage(createErrorMessage("INVALID_SIGNATURE", "Challenge response signature invalid")));
|
|
591
|
+
ws.close();
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
} catch (err) {
|
|
595
|
+
console.error(`[relay] Challenge verification error:`, err);
|
|
596
|
+
state.pendingChallenges.delete(connectionId);
|
|
597
|
+
ws.send(serializeMessage(createErrorMessage("ERROR", "Challenge verification failed")));
|
|
598
|
+
ws.close();
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Challenge verified - clean up pending challenge
|
|
603
|
+
state.pendingChallenges.delete(connectionId);
|
|
604
|
+
|
|
605
|
+
// Check if machine is authorized to connect to this relay
|
|
606
|
+
// Check both on-disk list and pre-authorized set (for ephemeral local relays)
|
|
607
|
+
const isPreAuthorized = state.preAuthorizedMachines.has(regMsg.signingKey);
|
|
608
|
+
if (!isPreAuthorized && !isAuthorized(regMsg.signingKey)) {
|
|
609
|
+
console.warn(`[relay] Machine not authorized: ${regMsg.machineId} (signingKey not in authorized list)`);
|
|
610
|
+
ws.send(serializeMessage(createErrorMessage("UNAUTHORIZED", "Machine not authorized for this relay")));
|
|
611
|
+
ws.close();
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// Get authorized machine info for account tracking
|
|
616
|
+
const authorizedMachine = getAuthorizedMachine(regMsg.signingKey);
|
|
617
|
+
const accountId = authorizedMachine?.fingerprint || regMsg.machineId;
|
|
618
|
+
|
|
619
|
+
// Register the machine (with ownership verification for re-registration)
|
|
620
|
+
const result = registerMachine(
|
|
621
|
+
regMsg.machineId,
|
|
622
|
+
accountId,
|
|
623
|
+
regMsg.signingKey,
|
|
624
|
+
regMsg.keyExchangeKey,
|
|
625
|
+
ws,
|
|
626
|
+
regMsg.label
|
|
627
|
+
);
|
|
628
|
+
|
|
629
|
+
// Handle registration failure (e.g., machine hijacking attempt)
|
|
630
|
+
if (!result.success) {
|
|
631
|
+
console.warn(`[relay] Machine registration rejected: ${result.error} (machineId=${regMsg.machineId})`);
|
|
632
|
+
ws.send(serializeMessage(createErrorMessage("FORBIDDEN", result.error)));
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// Update ws data
|
|
637
|
+
ws.data.machineId = regMsg.machineId;
|
|
638
|
+
ws.data.accountId = accountId;
|
|
639
|
+
|
|
640
|
+
console.log(`[relay] Machine ${regMsg.machineId} registered (authorized: ${authorizedMachine?.label || authorizedMachine?.fingerprint || "unknown"})`);
|
|
641
|
+
|
|
642
|
+
ws.send(serializeMessage({
|
|
643
|
+
type: "registered",
|
|
644
|
+
machineId: regMsg.machineId,
|
|
645
|
+
}));
|
|
646
|
+
|
|
647
|
+
// Send global access list to newly registered machine (signed)
|
|
648
|
+
const accessEntries = getEffectiveAccessList(accountId, regMsg.machineId);
|
|
649
|
+
if (accessEntries.length > 0) {
|
|
650
|
+
const accessListMsg: AccessListMessage = {
|
|
651
|
+
type: "access_list",
|
|
652
|
+
entries: accessEntries.map(e => ({
|
|
653
|
+
clientIdentityId: e.clientIdentityId,
|
|
654
|
+
signingKey: e.signingKey,
|
|
655
|
+
keyExchangeKey: e.keyExchangeKey,
|
|
656
|
+
label: e.label,
|
|
657
|
+
accessType: e.accessType,
|
|
658
|
+
sessionId: e.sessionId,
|
|
659
|
+
grantedAt: e.grantedAt,
|
|
660
|
+
})),
|
|
661
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
662
|
+
};
|
|
663
|
+
// Sign the access_list message
|
|
664
|
+
const signedMsg = state.signRelayMessage(accessListMsg);
|
|
665
|
+
ws.send(serializeMessage(signedMsg));
|
|
666
|
+
console.log(`[relay] Sent ${accessEntries.length} access entries to machine ${regMsg.machineId} (signed)`);
|
|
667
|
+
}
|
|
668
|
+
break;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// Legacy challenge_response - kept for backwards compatibility
|
|
672
|
+
case "challenge_response": {
|
|
673
|
+
// In new flow, challenge response is part of register_machine message
|
|
674
|
+
// This is kept for backwards compatibility with older clients
|
|
675
|
+
ws.send(serializeMessage(createErrorMessage("DEPRECATED", "Use register_machine with challengeResponse field")));
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
case "register_invite": {
|
|
680
|
+
if (role !== "machine") {
|
|
681
|
+
ws.send(serializeMessage(createErrorMessage("FORBIDDEN", "Only machines can register invites")));
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const invMsg = msg as RegisterInviteMessage;
|
|
686
|
+
|
|
687
|
+
// Verify machine is registered and owned by this connection
|
|
688
|
+
const machine = getMachine(invMsg.machineId);
|
|
689
|
+
if (!machine || machine.accountId !== ws.data.accountId) {
|
|
690
|
+
ws.send(serializeMessage(createErrorMessage("NOT_FOUND", "Machine not registered or unauthorized")));
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// Register the invite
|
|
695
|
+
registerInvite(
|
|
696
|
+
invMsg.inviteId,
|
|
697
|
+
invMsg.machineId,
|
|
698
|
+
invMsg.expiresAt,
|
|
699
|
+
invMsg.maxUses
|
|
700
|
+
);
|
|
701
|
+
|
|
702
|
+
console.log(`[relay] Invite ${invMsg.inviteId} registered for machine ${invMsg.machineId}`);
|
|
703
|
+
|
|
704
|
+
ws.send(serializeMessage({
|
|
705
|
+
type: "registered",
|
|
706
|
+
machineId: invMsg.machineId,
|
|
707
|
+
}));
|
|
708
|
+
break;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
case "authorize_client": {
|
|
712
|
+
if (role !== "machine") {
|
|
713
|
+
ws.send(serializeMessage(createErrorMessage("FORBIDDEN", "Only machines can authorize clients")));
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
const authMsg = msg as AuthorizeClientMessage;
|
|
718
|
+
|
|
719
|
+
// Verify machine is registered and owned by this connection
|
|
720
|
+
const machine = getMachine(authMsg.machineId);
|
|
721
|
+
if (!machine || machine.accountId !== ws.data.accountId) {
|
|
722
|
+
ws.send(serializeMessage(createErrorMessage("NOT_FOUND", "Machine not registered or unauthorized")));
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// Authorize the client
|
|
727
|
+
authorizeClient(
|
|
728
|
+
authMsg.machineId,
|
|
729
|
+
authMsg.clientIdentityId,
|
|
730
|
+
authMsg.signingKey,
|
|
731
|
+
authMsg.keyExchangeKey,
|
|
732
|
+
authMsg.accessType,
|
|
733
|
+
authMsg.sessionId
|
|
734
|
+
);
|
|
735
|
+
|
|
736
|
+
console.log(`[relay] Client ${authMsg.clientIdentityId} authorized for machine ${authMsg.machineId}`);
|
|
737
|
+
|
|
738
|
+
ws.send(serializeMessage({
|
|
739
|
+
type: "client_authorized",
|
|
740
|
+
clientIdentityId: authMsg.clientIdentityId,
|
|
741
|
+
}));
|
|
742
|
+
break;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
case "revoke_client": {
|
|
746
|
+
if (role !== "machine") {
|
|
747
|
+
ws.send(serializeMessage(createErrorMessage("FORBIDDEN", "Only machines can revoke clients")));
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const revokeMsg = msg as RevokeClientMessage;
|
|
752
|
+
|
|
753
|
+
// Verify machine is registered and owned by this connection
|
|
754
|
+
const machine = getMachine(revokeMsg.machineId);
|
|
755
|
+
if (!machine || machine.accountId !== ws.data.accountId) {
|
|
756
|
+
ws.send(serializeMessage(createErrorMessage("NOT_FOUND", "Machine not registered or unauthorized")));
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// Revoke client authorization
|
|
761
|
+
revokeClientAuthorization(revokeMsg.machineId, revokeMsg.clientIdentityId);
|
|
762
|
+
|
|
763
|
+
console.log(`[relay] Client ${revokeMsg.clientIdentityId} revoked from machine ${revokeMsg.machineId}`);
|
|
764
|
+
|
|
765
|
+
ws.send(serializeMessage({
|
|
766
|
+
type: "client_revoked",
|
|
767
|
+
clientIdentityId: revokeMsg.clientIdentityId,
|
|
768
|
+
}));
|
|
769
|
+
break;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
case "add_global_access": {
|
|
773
|
+
if (role !== "machine") {
|
|
774
|
+
ws.send(serializeMessage(createErrorMessage("FORBIDDEN", "Only machines can add global access")));
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
if (!ws.data.accountId) {
|
|
779
|
+
ws.send(serializeMessage(createErrorMessage("FORBIDDEN", "Authentication required")));
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
const addMsg = msg as AddGlobalAccessMessage;
|
|
784
|
+
|
|
785
|
+
// Add to global access list
|
|
786
|
+
const entry = addGlobalAccess(ws.data.accountId, {
|
|
787
|
+
clientIdentityId: addMsg.clientIdentityId,
|
|
788
|
+
signingKey: addMsg.signingKey,
|
|
789
|
+
keyExchangeKey: addMsg.keyExchangeKey,
|
|
790
|
+
label: addMsg.label,
|
|
791
|
+
accessType: addMsg.accessType,
|
|
792
|
+
sessionId: addMsg.sessionId,
|
|
793
|
+
machineIds: addMsg.machineIds,
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
console.log(`[relay] Global access added: ${addMsg.clientIdentityId} by ${ws.data.accountId}`);
|
|
797
|
+
|
|
798
|
+
// Broadcast to all machines owned by this account (signed)
|
|
799
|
+
broadcastAccessUpdate(ws.data.accountId, [entry], [], state.signRelayMessage);
|
|
800
|
+
|
|
801
|
+
// Also authorize for per-machine tracking
|
|
802
|
+
const machineId = ws.data.machineId;
|
|
803
|
+
if (machineId) {
|
|
804
|
+
authorizeClient(
|
|
805
|
+
machineId,
|
|
806
|
+
addMsg.clientIdentityId,
|
|
807
|
+
addMsg.signingKey,
|
|
808
|
+
addMsg.keyExchangeKey,
|
|
809
|
+
addMsg.accessType,
|
|
810
|
+
addMsg.sessionId
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
ws.send(serializeMessage({
|
|
815
|
+
type: "client_authorized",
|
|
816
|
+
clientIdentityId: addMsg.clientIdentityId,
|
|
817
|
+
}));
|
|
818
|
+
break;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
case "remove_global_access": {
|
|
822
|
+
if (role !== "machine") {
|
|
823
|
+
ws.send(serializeMessage(createErrorMessage("FORBIDDEN", "Only machines can remove global access")));
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
if (!ws.data.accountId) {
|
|
828
|
+
ws.send(serializeMessage(createErrorMessage("FORBIDDEN", "Authentication required")));
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
const removeMsg = msg as RemoveGlobalAccessMessage;
|
|
833
|
+
|
|
834
|
+
// Remove from global access list
|
|
835
|
+
const removed = removeGlobalAccess(ws.data.accountId, removeMsg.clientIdentityId);
|
|
836
|
+
|
|
837
|
+
if (removed) {
|
|
838
|
+
console.log(`[relay] Global access removed: ${removeMsg.clientIdentityId} by ${ws.data.accountId}`);
|
|
839
|
+
|
|
840
|
+
// Broadcast to all machines owned by this account (signed)
|
|
841
|
+
broadcastAccessUpdate(ws.data.accountId, [], [removeMsg.clientIdentityId], state.signRelayMessage);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
ws.send(serializeMessage({
|
|
845
|
+
type: "client_revoked",
|
|
846
|
+
clientIdentityId: removeMsg.clientIdentityId,
|
|
847
|
+
}));
|
|
848
|
+
break;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// ========== Client Messages ==========
|
|
852
|
+
|
|
853
|
+
case "list_machines": {
|
|
854
|
+
if (role !== "client") {
|
|
855
|
+
ws.send(serializeMessage(createErrorMessage("FORBIDDEN", "Only clients can list machines")));
|
|
856
|
+
return;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
const listMsg = msg as ListMachinesMessage;
|
|
860
|
+
const verified = verifyClientIdentity(listMsg);
|
|
861
|
+
if (!verified) {
|
|
862
|
+
ws.send(serializeMessage(createErrorMessage("INVALID_SIGNATURE", "Client message signature invalid")));
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
if (ws.data.clientIdentityId && ws.data.clientIdentityId !== verified.clientIdentityId) {
|
|
867
|
+
ws.send(serializeMessage(createErrorMessage("IDENTITY_MISMATCH", "Client identity does not match connection")));
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
ws.data.clientIdentityId = verified.clientIdentityId;
|
|
871
|
+
|
|
872
|
+
// Get only AUTHORIZED machines for this client
|
|
873
|
+
// Client must be in the machine's access list to see it
|
|
874
|
+
const allMachines = getAllMachinesWithAuthStatus(verified.clientIdentityId);
|
|
875
|
+
const authorizedMachines = allMachines.filter(m => m.isAuthorized);
|
|
876
|
+
|
|
877
|
+
ws.send(serializeMessage({
|
|
878
|
+
type: "machine_list",
|
|
879
|
+
machines: authorizedMachines.map(({ machineId, machine, isAuthorized, accessType, sessionId }) => ({
|
|
880
|
+
machineId,
|
|
881
|
+
label: machine.label,
|
|
882
|
+
online: machine.ws !== null,
|
|
883
|
+
isAuthorized,
|
|
884
|
+
accessType,
|
|
885
|
+
sessionId,
|
|
886
|
+
lastConnectedAt: machine.lastConnectedAt,
|
|
887
|
+
})),
|
|
888
|
+
}));
|
|
889
|
+
break;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
case "connect_with_invite": {
|
|
893
|
+
if (role !== "client") {
|
|
894
|
+
ws.send(serializeMessage(createErrorMessage("FORBIDDEN", "Only clients can connect with invites")));
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
const inviteMsg = msg as ConnectWithInviteMessage;
|
|
899
|
+
const verified = verifyClientIdentity(inviteMsg);
|
|
900
|
+
if (!verified) {
|
|
901
|
+
ws.send(serializeMessage(createErrorMessage("INVALID_SIGNATURE", "Client message signature invalid")));
|
|
902
|
+
return;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
if (ws.data.clientIdentityId && ws.data.clientIdentityId !== verified.clientIdentityId) {
|
|
906
|
+
ws.send(serializeMessage(createErrorMessage("IDENTITY_MISMATCH", "Client identity does not match connection")));
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
// Look up invite
|
|
911
|
+
const invite = getInvite(inviteMsg.inviteId);
|
|
912
|
+
if (!invite) {
|
|
913
|
+
ws.send(serializeMessage(createErrorMessage("NOT_FOUND", "Invite not found")));
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
if (!isInviteValid(inviteMsg.inviteId)) {
|
|
918
|
+
ws.send(serializeMessage(createErrorMessage("INVALID", "Invite expired or exhausted")));
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// Check machine is online
|
|
923
|
+
const machine = getMachine(invite.machineId);
|
|
924
|
+
if (!machine || !machine.ws) {
|
|
925
|
+
ws.send(serializeMessage(createErrorMessage("OFFLINE", "Machine is offline")));
|
|
926
|
+
return;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// Use the invite (decrements use count)
|
|
930
|
+
useInvite(inviteMsg.inviteId);
|
|
931
|
+
|
|
932
|
+
// Set up client connection tracking
|
|
933
|
+
setupClientConnection(state, invite.machineId, connectionId, ws, verified.clientIdentityId);
|
|
934
|
+
|
|
935
|
+
// Notify machine of new client
|
|
936
|
+
machine.ws.send(serializeMessage({
|
|
937
|
+
type: "client_connected",
|
|
938
|
+
connectionId,
|
|
939
|
+
clientIdentityId: verified.clientIdentityId,
|
|
940
|
+
viaInvite: inviteMsg.inviteId,
|
|
941
|
+
}));
|
|
942
|
+
|
|
943
|
+
// Send connection established to client
|
|
944
|
+
ws.send(serializeMessage({
|
|
945
|
+
type: "connection_established",
|
|
946
|
+
machineId: invite.machineId,
|
|
947
|
+
connectionId,
|
|
948
|
+
}));
|
|
949
|
+
|
|
950
|
+
console.log(`[relay] Client ${verified.clientIdentityId} connected to ${invite.machineId} via invite`);
|
|
951
|
+
break;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
case "connect_to_machine": {
|
|
955
|
+
if (role !== "client") {
|
|
956
|
+
ws.send(serializeMessage(createErrorMessage("FORBIDDEN", "Only clients can connect to machines")));
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
const connectMsg = msg as ConnectToMachineMessage;
|
|
961
|
+
const verified = verifyClientIdentity(connectMsg);
|
|
962
|
+
if (!verified) {
|
|
963
|
+
ws.send(serializeMessage(createErrorMessage("INVALID_SIGNATURE", "Client message signature invalid")));
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
if (ws.data.clientIdentityId && ws.data.clientIdentityId !== verified.clientIdentityId) {
|
|
968
|
+
ws.send(serializeMessage(createErrorMessage("IDENTITY_MISMATCH", "Client identity does not match connection")));
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// Check machine exists
|
|
973
|
+
const machine = getMachine(connectMsg.machineId);
|
|
974
|
+
if (!machine) {
|
|
975
|
+
ws.send(serializeMessage(createErrorMessage("NOT_FOUND", "Machine not found")));
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
// Check machine is online
|
|
980
|
+
if (!machine.ws) {
|
|
981
|
+
ws.send(serializeMessage(createErrorMessage("OFFLINE", "Machine is offline")));
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
// NOTE: We don't check isClientAuthorized here anymore.
|
|
986
|
+
// Authorization happens via X3DH handshake - the machine will
|
|
987
|
+
// verify the client's identity and reject if not on ACL.
|
|
988
|
+
|
|
989
|
+
// Set up client connection tracking
|
|
990
|
+
setupClientConnection(state, connectMsg.machineId, connectionId, ws, verified.clientIdentityId);
|
|
991
|
+
|
|
992
|
+
// Notify machine of new client
|
|
993
|
+
machine.ws.send(serializeMessage({
|
|
994
|
+
type: "client_connected",
|
|
995
|
+
connectionId,
|
|
996
|
+
clientIdentityId: verified.clientIdentityId,
|
|
997
|
+
}));
|
|
998
|
+
|
|
999
|
+
// Send connection established to client
|
|
1000
|
+
ws.send(serializeMessage({
|
|
1001
|
+
type: "connection_established",
|
|
1002
|
+
machineId: connectMsg.machineId,
|
|
1003
|
+
connectionId,
|
|
1004
|
+
}));
|
|
1005
|
+
|
|
1006
|
+
console.log(`[relay] Client ${verified.clientIdentityId} connected to ${connectMsg.machineId} directly`);
|
|
1007
|
+
break;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
default: {
|
|
1011
|
+
// Log unhandled message types (data/handshake are handled separately)
|
|
1012
|
+
const unhandled = msg as { type: string };
|
|
1013
|
+
console.log(`[relay] Unknown message type: ${unhandled.type}`);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/**
|
|
1019
|
+
* Handle data/handshake messages - route between machine and clients
|
|
1020
|
+
*/
|
|
1021
|
+
function handleDataMessage(
|
|
1022
|
+
state: RelayServerState,
|
|
1023
|
+
ws: ServerWebSocket<WebSocketData>,
|
|
1024
|
+
message: string | ArrayBuffer | Uint8Array
|
|
1025
|
+
): void {
|
|
1026
|
+
const { role, machineId, connectionId } = ws.data;
|
|
1027
|
+
|
|
1028
|
+
if (!machineId) {
|
|
1029
|
+
console.log(`[relay] Data from unconnected ${role} ${connectionId}`);
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
const msgStr = typeof message === "string"
|
|
1034
|
+
? message
|
|
1035
|
+
: new TextDecoder().decode(message instanceof ArrayBuffer ? message : message);
|
|
1036
|
+
|
|
1037
|
+
const parsed = parseMessage(msgStr);
|
|
1038
|
+
|
|
1039
|
+
if (role === "machine") {
|
|
1040
|
+
// Machine sending data - parse to get target connectionId
|
|
1041
|
+
if (!parsed || !isMachineDataMessage(parsed)) {
|
|
1042
|
+
console.log(`[relay] Invalid data message from machine`);
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
// Route to specific client by connectionId (now properly typed)
|
|
1047
|
+
const targetConnId = parsed.connectionId;
|
|
1048
|
+
const clientWs = state.clientConnections.get(targetConnId);
|
|
1049
|
+
|
|
1050
|
+
if (clientWs) {
|
|
1051
|
+
// Forward data to client (unwrap the connectionId since client knows their own)
|
|
1052
|
+
clientWs.send(serializeMessage({
|
|
1053
|
+
type: "data",
|
|
1054
|
+
data: parsed.data,
|
|
1055
|
+
}));
|
|
1056
|
+
} else {
|
|
1057
|
+
console.log(`[relay] Target client ${targetConnId} not found`);
|
|
1058
|
+
}
|
|
1059
|
+
} else {
|
|
1060
|
+
// Client sending data/handshake - wrap with connectionId for machine
|
|
1061
|
+
const machine = getMachine(machineId);
|
|
1062
|
+
if (!machine || !machine.ws) {
|
|
1063
|
+
console.log(`[relay] Machine ${machineId} not connected`);
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
if (!parsed) {
|
|
1068
|
+
console.log(`[relay] Invalid message from client`);
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
if (isClientHandshakeMessage(parsed)) {
|
|
1073
|
+
// Wrap handshake message in data envelope for machine
|
|
1074
|
+
// The machine handler will decode the base64 and process as handshake
|
|
1075
|
+
const handshakeJson = JSON.stringify(parsed);
|
|
1076
|
+
machine.ws.send(serializeMessage({
|
|
1077
|
+
type: "data",
|
|
1078
|
+
connectionId,
|
|
1079
|
+
data: Buffer.from(handshakeJson).toString("base64"),
|
|
1080
|
+
}));
|
|
1081
|
+
} else if (isClientDataMessage(parsed)) {
|
|
1082
|
+
// Forward data message with connectionId (now properly typed)
|
|
1083
|
+
machine.ws.send(serializeMessage({
|
|
1084
|
+
type: "data",
|
|
1085
|
+
connectionId,
|
|
1086
|
+
data: parsed.data,
|
|
1087
|
+
}));
|
|
1088
|
+
} else {
|
|
1089
|
+
console.log(`[relay] Invalid message from client: ${parsed.type}`);
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
}
|