@linkshell/gateway 0.5.2 → 0.6.0
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/Dockerfile +8 -22
- package/README.md +99 -44
- package/dist/gateway/src/accounts.js.map +1 -0
- package/dist/gateway/src/index.d.ts +4 -1
- package/dist/gateway/src/index.js +4 -863
- package/dist/gateway/src/index.js.map +1 -1
- package/dist/gateway/src/main.js +57 -0
- package/dist/gateway/src/main.js.map +1 -0
- package/dist/gateway/src/rate-limit.d.ts +22 -0
- package/dist/gateway/src/rate-limit.js +66 -0
- package/dist/gateway/src/rate-limit.js.map +1 -0
- package/dist/gateway/src/relay.d.ts +84 -5
- package/dist/gateway/src/relay.js +402 -529
- package/dist/gateway/src/relay.js.map +1 -1
- package/dist/gateway/src/serve.d.ts +34 -0
- package/dist/gateway/src/serve.js +102 -0
- package/dist/gateway/src/serve.js.map +1 -0
- package/dist/{gateway-v2 → gateway}/src/store.d.ts +5 -0
- package/dist/{gateway-v2 → gateway}/src/store.js +7 -0
- package/dist/gateway/src/store.js.map +1 -0
- package/dist/gateway/src/subscription.d.ts +18 -0
- package/dist/gateway/src/subscription.js +46 -0
- package/dist/gateway/src/subscription.js.map +1 -0
- package/dist/gateway/tsconfig.tsbuildinfo +1 -1
- package/dist/wire/src/direct.d.ts +22 -0
- package/dist/wire/src/direct.js +37 -0
- package/dist/wire/src/direct.js.map +1 -0
- package/dist/wire/src/index.d.ts +1 -0
- package/dist/wire/src/index.js +1 -0
- package/dist/wire/src/index.js.map +1 -1
- package/dist/wire/src/model.d.ts +387 -6
- package/dist/wire/src/model.js +74 -0
- package/dist/wire/src/model.js.map +1 -1
- package/dist/wire/src/relay.d.ts +8 -8
- package/dist/wire/src/rpc.d.ts +2524 -200
- package/dist/wire/src/rpc.js +77 -4
- package/dist/wire/src/rpc.js.map +1 -1
- package/dist/wire/src/updates.d.ts +390 -34
- package/dist/wire/src/updates.js +11 -1
- package/dist/wire/src/updates.js.map +1 -1
- package/package.json +12 -12
- package/src/accounts.ts +32 -0
- package/src/index.ts +4 -997
- package/src/main.ts +65 -0
- package/src/rate-limit.ts +72 -0
- package/src/relay.ts +435 -620
- package/src/serve.ts +130 -0
- package/src/store.ts +135 -0
- package/src/subscription.ts +51 -0
- package/dist/gateway/src/auth-middleware.d.ts +0 -44
- package/dist/gateway/src/auth-middleware.js +0 -204
- package/dist/gateway/src/auth-middleware.js.map +0 -1
- package/dist/gateway/src/cors.d.ts +0 -2
- package/dist/gateway/src/cors.js +0 -7
- package/dist/gateway/src/cors.js.map +0 -1
- package/dist/gateway/src/embedded.d.ts +0 -21
- package/dist/gateway/src/embedded.js +0 -438
- package/dist/gateway/src/embedded.js.map +0 -1
- package/dist/gateway/src/host-auth.d.ts +0 -32
- package/dist/gateway/src/host-auth.js +0 -78
- package/dist/gateway/src/host-auth.js.map +0 -1
- package/dist/gateway/src/pairings.d.ts +0 -38
- package/dist/gateway/src/pairings.js +0 -119
- package/dist/gateway/src/pairings.js.map +0 -1
- package/dist/gateway/src/presence.d.ts +0 -28
- package/dist/gateway/src/presence.js +0 -92
- package/dist/gateway/src/presence.js.map +0 -1
- package/dist/gateway/src/sessions.d.ts +0 -113
- package/dist/gateway/src/sessions.js +0 -352
- package/dist/gateway/src/sessions.js.map +0 -1
- package/dist/gateway/src/state-store.d.ts +0 -21
- package/dist/gateway/src/state-store.js +0 -103
- package/dist/gateway/src/state-store.js.map +0 -1
- package/dist/gateway/src/static-web.d.ts +0 -15
- package/dist/gateway/src/static-web.js +0 -129
- package/dist/gateway/src/static-web.js.map +0 -1
- package/dist/gateway/src/tokens.d.ts +0 -19
- package/dist/gateway/src/tokens.js +0 -151
- package/dist/gateway/src/tokens.js.map +0 -1
- package/dist/gateway/src/tunnel.d.ts +0 -61
- package/dist/gateway/src/tunnel.js +0 -576
- package/dist/gateway/src/tunnel.js.map +0 -1
- package/dist/gateway-v2/src/accounts.js.map +0 -1
- package/dist/gateway-v2/src/index.d.ts +0 -3
- package/dist/gateway-v2/src/index.js +0 -4
- package/dist/gateway-v2/src/index.js.map +0 -1
- package/dist/gateway-v2/src/main.js +0 -17
- package/dist/gateway-v2/src/main.js.map +0 -1
- package/dist/gateway-v2/src/server.d.ts +0 -84
- package/dist/gateway-v2/src/server.js +0 -426
- package/dist/gateway-v2/src/server.js.map +0 -1
- package/dist/gateway-v2/src/store.js.map +0 -1
- package/dist/shared-protocol/src/index.d.ts +0 -17029
- package/dist/shared-protocol/src/index.js +0 -1064
- package/dist/shared-protocol/src/index.js.map +0 -1
- package/src/auth-middleware.ts +0 -266
- package/src/cors.ts +0 -8
- package/src/embedded.ts +0 -526
- package/src/host-auth.ts +0 -88
- package/src/pairings.ts +0 -133
- package/src/presence.ts +0 -118
- package/src/sessions.ts +0 -432
- package/src/state-store.ts +0 -140
- package/src/static-web.ts +0 -133
- package/src/tokens.ts +0 -159
- package/src/tunnel.ts +0 -644
- /package/dist/{gateway-v2 → gateway}/src/accounts.d.ts +0 -0
- /package/dist/{gateway-v2 → gateway}/src/accounts.js +0 -0
- /package/dist/{gateway-v2 → gateway}/src/main.d.ts +0 -0
package/src/relay.ts
CHANGED
|
@@ -1,669 +1,484 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { createServer, type IncomingMessage, type Server } from "node:http";
|
|
2
|
+
import { randomBytes, randomInt, randomUUID } from "node:crypto";
|
|
3
|
+
import type { Duplex } from "node:stream";
|
|
4
|
+
import WebSocket, { WebSocketServer } from "ws";
|
|
2
5
|
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
import {
|
|
15
|
-
|
|
16
|
-
handleTunnelWsData,
|
|
17
|
-
handleTunnelWsClose,
|
|
18
|
-
} from "./tunnel.js";
|
|
19
|
-
|
|
20
|
-
export function handleSocketMessage(
|
|
21
|
-
socket: WebSocket,
|
|
22
|
-
raw: string,
|
|
23
|
-
role: "host" | "client",
|
|
24
|
-
sessionId: string,
|
|
25
|
-
deviceId: string,
|
|
26
|
-
sessions: SessionManager,
|
|
27
|
-
): void {
|
|
28
|
-
let envelope: Envelope;
|
|
29
|
-
try {
|
|
30
|
-
envelope = parseEnvelope(raw);
|
|
31
|
-
} catch {
|
|
32
|
-
sendSessionError(socket, sessionId, "invalid_message", "Failed to parse envelope");
|
|
33
|
-
return;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
if (envelope.sessionId !== sessionId) {
|
|
37
|
-
sendSessionError(
|
|
38
|
-
socket,
|
|
39
|
-
sessionId,
|
|
40
|
-
"invalid_message",
|
|
41
|
-
"Envelope sessionId does not match connection sessionId",
|
|
42
|
-
);
|
|
43
|
-
return;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const session = sessions.get(sessionId);
|
|
47
|
-
if (!session) {
|
|
48
|
-
sendSessionError(socket, sessionId, "session_not_found", "Session not found");
|
|
49
|
-
return;
|
|
50
|
-
}
|
|
6
|
+
clientFrameSchema,
|
|
7
|
+
gatewayChallengeMessage,
|
|
8
|
+
idOf,
|
|
9
|
+
RELAY_PATH,
|
|
10
|
+
verifySignature,
|
|
11
|
+
type DeviceEntry,
|
|
12
|
+
type MachineEntry,
|
|
13
|
+
type PeerRole,
|
|
14
|
+
type RelayEvents,
|
|
15
|
+
type ServerFrame,
|
|
16
|
+
} from "@linkshell/wire";
|
|
17
|
+
import type { VerifyToken } from "./accounts.js";
|
|
18
|
+
import { GatewayStore, type PeerRecord } from "./store.js";
|
|
51
19
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
20
|
+
export interface GatewayOptions {
|
|
21
|
+
port: number;
|
|
22
|
+
host?: string;
|
|
23
|
+
databasePath: string;
|
|
24
|
+
/** Validates account tokens; without it, accounts are off and only pairing works. */
|
|
25
|
+
verifyToken?: VerifyToken;
|
|
26
|
+
log?: (message: string) => void;
|
|
27
|
+
/** Called with every frame the gateway routes, for tests that check it only sees ciphertext. */
|
|
28
|
+
onRouted?: (frame: string) => void;
|
|
29
|
+
pairingTtlMs?: number;
|
|
30
|
+
heartbeatMs?: number;
|
|
31
|
+
/**
|
|
32
|
+
* Final say on a connecting peer, after its key (and token, if any) checked
|
|
33
|
+
* out: return a message to refuse it. The official gateway uses this to
|
|
34
|
+
* require a subscription for computers.
|
|
35
|
+
*/
|
|
36
|
+
admit?: (peer: { role: PeerRole; userId?: string }) => Promise<string | undefined>;
|
|
37
|
+
/**
|
|
38
|
+
* How much may wait to be written to one peer before the gateway stops
|
|
39
|
+
* reading from whoever is sending to it (default 4 MB), and how long a peer
|
|
40
|
+
* may stay that far behind before it is dropped (default 60 s).
|
|
41
|
+
*/
|
|
42
|
+
maxBufferedBytes?: number;
|
|
43
|
+
stalledPeerMs?: number;
|
|
77
44
|
}
|
|
78
45
|
|
|
79
|
-
|
|
80
|
-
|
|
46
|
+
interface Peer {
|
|
47
|
+
record: PeerRecord;
|
|
48
|
+
socket: WebSocket;
|
|
49
|
+
email?: string;
|
|
50
|
+
/** Not being read: a peer it sends to can't keep up. */
|
|
51
|
+
held?: boolean;
|
|
81
52
|
}
|
|
82
53
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
54
|
+
interface Offer {
|
|
55
|
+
code: string;
|
|
56
|
+
machineId: string;
|
|
57
|
+
expiresAt: number;
|
|
58
|
+
attempts: number;
|
|
86
59
|
}
|
|
87
60
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
)
|
|
94
|
-
|
|
95
|
-
socket.send(
|
|
96
|
-
serializeEnvelope(
|
|
97
|
-
createEnvelope({
|
|
98
|
-
type: "session.error",
|
|
99
|
-
sessionId,
|
|
100
|
-
payload: { code, message },
|
|
101
|
-
}),
|
|
102
|
-
),
|
|
103
|
-
);
|
|
61
|
+
class RelayError extends Error {
|
|
62
|
+
constructor(
|
|
63
|
+
readonly code: string,
|
|
64
|
+
message: string,
|
|
65
|
+
) {
|
|
66
|
+
super(message);
|
|
67
|
+
}
|
|
104
68
|
}
|
|
105
69
|
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
provider?: string;
|
|
110
|
-
title?: string;
|
|
111
|
-
lastMessagePreview?: string;
|
|
112
|
-
lastActivityAt?: number;
|
|
113
|
-
usage?: {
|
|
114
|
-
inputTokens?: number;
|
|
115
|
-
outputTokens?: number;
|
|
116
|
-
cacheReadTokens?: number;
|
|
117
|
-
totalTokens?: number;
|
|
118
|
-
contextWindow?: number;
|
|
119
|
-
totalCostUsd?: number;
|
|
120
|
-
};
|
|
121
|
-
};
|
|
122
|
-
|
|
123
|
-
function truncateDetail(text: string, max = 72): string {
|
|
124
|
-
const line = text.replace(/\s+/g, " ").trim();
|
|
125
|
-
return line.length > max ? `${line.slice(0, max - 1)}…` : line;
|
|
126
|
-
}
|
|
70
|
+
const AUTH_TIMEOUT_MS = 10_000;
|
|
71
|
+
const PAIR_DECISION_MS = 30_000;
|
|
72
|
+
const MAX_CLAIM_ATTEMPTS = 8;
|
|
127
73
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
74
|
+
/**
|
|
75
|
+
* Gateway v2: authenticates peers by key, routes their end-to-end encrypted
|
|
76
|
+
* frames to peers they may reach (paired, or same account), and brokers
|
|
77
|
+
* pairing. It has no way to read what it routes.
|
|
78
|
+
*/
|
|
79
|
+
export class Gateway {
|
|
80
|
+
readonly store: GatewayStore;
|
|
81
|
+
private server?: Server;
|
|
82
|
+
private readonly peers = new Map<string, Peer>();
|
|
83
|
+
private readonly offers = new Map<string, Offer>();
|
|
84
|
+
private readonly decisions = new Map<string, { machineId: string; resolve: (accept: boolean) => void }>();
|
|
85
|
+
private readonly claimTimes = new Map<string, number[]>();
|
|
86
|
+
private heartbeat?: ReturnType<typeof setInterval>;
|
|
87
|
+
/** Sockets that answered the last ping. */
|
|
88
|
+
private readonly alive = new WeakSet<WebSocket>();
|
|
89
|
+
private stopped = false;
|
|
90
|
+
private readonly log: (message: string) => void;
|
|
91
|
+
|
|
92
|
+
constructor(private readonly options: GatewayOptions) {
|
|
93
|
+
this.store = new GatewayStore(options.databasePath);
|
|
94
|
+
this.log = options.log ?? ((message) => process.stderr.write(`${new Date().toISOString()} [gateway] ${message}\n`));
|
|
145
95
|
}
|
|
146
|
-
return {};
|
|
147
|
-
}
|
|
148
96
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
)
|
|
97
|
+
private readonly wss = new WebSocketServer({ noServer: true, perMessageDeflate: false, maxPayload: 48 * 1024 * 1024 });
|
|
98
|
+
|
|
99
|
+
/** Runs on its own port. */
|
|
100
|
+
async start(): Promise<number> {
|
|
101
|
+
const server = createServer((request, response) => {
|
|
102
|
+
if (request.url === "/healthz") {
|
|
103
|
+
response.writeHead(200, { "content-type": "application/json" });
|
|
104
|
+
response.end(JSON.stringify({ ok: true, peers: this.peers.size }));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
response.writeHead(404);
|
|
108
|
+
response.end();
|
|
109
|
+
});
|
|
110
|
+
server.on("upgrade", (request: IncomingMessage, socket, head) => {
|
|
111
|
+
if (!request.url?.startsWith(RELAY_PATH)) {
|
|
112
|
+
socket.destroy();
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
this.handleUpgrade(request, socket, head);
|
|
116
|
+
});
|
|
117
|
+
this.server = server;
|
|
118
|
+
await new Promise<void>((resolve) => server.listen(this.options.port, this.options.host ?? "0.0.0.0", resolve));
|
|
119
|
+
this.attach();
|
|
120
|
+
const address = server.address();
|
|
121
|
+
return typeof address === "object" && address ? address.port : this.options.port;
|
|
163
122
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Runs inside another HTTP server, which passes it upgrades for
|
|
126
|
+
* `RELAY_PATH` through `handleUpgrade`.
|
|
127
|
+
*/
|
|
128
|
+
attach(): void {
|
|
129
|
+
if (this.heartbeat) return;
|
|
130
|
+
this.heartbeat = setInterval(() => this.sweep(), this.options.heartbeatMs ?? 30_000);
|
|
131
|
+
this.heartbeat.unref();
|
|
169
132
|
}
|
|
170
|
-
if (p.context?.trim()) return truncateDetail(p.context.trim());
|
|
171
|
-
if (p.toolName) return truncateDetail(`需要授权 · ${p.toolName}`);
|
|
172
|
-
return undefined;
|
|
173
|
-
}
|
|
174
133
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
status: string | undefined,
|
|
178
|
-
current?: { agentStatus?: string; agentDetail?: string },
|
|
179
|
-
): string | null | undefined {
|
|
180
|
-
if (status === "waiting_permission") {
|
|
181
|
-
if (current?.agentStatus === "waiting_permission" && current.agentDetail) return undefined;
|
|
182
|
-
return conversation?.lastMessagePreview;
|
|
134
|
+
handleUpgrade(request: IncomingMessage, socket: Duplex, head: Buffer): void {
|
|
135
|
+
this.wss.handleUpgrade(request, socket, head, (ws) => this.accept(ws));
|
|
183
136
|
}
|
|
184
|
-
|
|
185
|
-
|
|
137
|
+
|
|
138
|
+
get connected(): number {
|
|
139
|
+
return this.peers.size;
|
|
186
140
|
}
|
|
187
|
-
return status ? null : undefined;
|
|
188
|
-
}
|
|
189
141
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
)
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
const waiting = conversations.find((c) => c.status === "waiting_permission");
|
|
197
|
-
if (waiting) return waiting;
|
|
198
|
-
const running = conversations.find((c) => c.status === "running");
|
|
199
|
-
if (running) return running;
|
|
200
|
-
return [...conversations].sort((a, b) => (b.lastActivityAt ?? 0) - (a.lastActivityAt ?? 0))[0];
|
|
201
|
-
}
|
|
142
|
+
/** Bytes accepted from senders and not yet written to their receivers. */
|
|
143
|
+
get bufferedBytes(): number {
|
|
144
|
+
let total = 0;
|
|
145
|
+
for (const peer of this.peers.values()) total += peer.socket.bufferedAmount;
|
|
146
|
+
return total;
|
|
147
|
+
}
|
|
202
148
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
149
|
+
/** Drops connections that didn't answer the previous ping, pings the rest. */
|
|
150
|
+
private sweep(): void {
|
|
151
|
+
for (const peer of this.peers.values()) {
|
|
152
|
+
// A held peer isn't read, so its pong can't arrive; it is checked again once released.
|
|
153
|
+
if (peer.held) continue;
|
|
154
|
+
if (!this.alive.has(peer.socket)) {
|
|
155
|
+
peer.socket.terminate();
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
this.alive.delete(peer.socket);
|
|
159
|
+
peer.socket.ping();
|
|
160
|
+
}
|
|
161
|
+
}
|
|
208
162
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
case "waiting":
|
|
216
|
-
return "waiting_permission";
|
|
217
|
-
case "idle":
|
|
218
|
-
return "idle";
|
|
219
|
-
case "error":
|
|
220
|
-
return "error";
|
|
221
|
-
default:
|
|
222
|
-
return undefined;
|
|
163
|
+
async stop(): Promise<void> {
|
|
164
|
+
this.stopped = true;
|
|
165
|
+
clearInterval(this.heartbeat);
|
|
166
|
+
for (const peer of this.peers.values()) peer.socket.terminate();
|
|
167
|
+
await new Promise<void>((resolve) => (this.server ? this.server.close(() => resolve()) : resolve()));
|
|
168
|
+
this.store.close();
|
|
223
169
|
}
|
|
224
|
-
}
|
|
225
170
|
|
|
226
|
-
|
|
227
|
-
if (payload.toolName) return `外部终端 · ${payload.toolName}`;
|
|
228
|
-
if (payload.summary) return `外部终端 · ${payload.summary}`;
|
|
229
|
-
return "外部终端";
|
|
230
|
-
}
|
|
171
|
+
// ── connection ────────────────────────────────────────────────────
|
|
231
172
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
173
|
+
private accept(socket: WebSocket): void {
|
|
174
|
+
this.alive.add(socket);
|
|
175
|
+
socket.on("pong", () => this.alive.add(socket));
|
|
176
|
+
const nonce = randomBytes(32).toString("base64url");
|
|
177
|
+
let peer: Peer | undefined;
|
|
178
|
+
const send = (frame: ServerFrame) => {
|
|
179
|
+
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(frame));
|
|
180
|
+
};
|
|
181
|
+
const timer = setTimeout(() => {
|
|
182
|
+
if (!peer) socket.close(4001, "auth timeout");
|
|
183
|
+
}, AUTH_TIMEOUT_MS);
|
|
184
|
+
send({ t: "challenge", nonce });
|
|
236
185
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
sessions.cacheAgentSummary(envelope.sessionId, {
|
|
244
|
-
status,
|
|
245
|
-
provider: picked?.provider,
|
|
246
|
-
conversationId: picked?.id,
|
|
247
|
-
title: picked?.title,
|
|
248
|
-
detail: conversationAttentionDetail(picked, status, sessions.get(envelope.sessionId)),
|
|
249
|
-
lastActivity: picked?.lastActivityAt ?? Date.now(),
|
|
250
|
-
usage: picked?.usage ?? null,
|
|
251
|
-
});
|
|
252
|
-
return;
|
|
253
|
-
}
|
|
254
|
-
if (envelope.type === "agent.v2.conversation.opened") {
|
|
255
|
-
const p = parseTypedPayload("agent.v2.conversation.opened", envelope.payload);
|
|
256
|
-
const conversation = p.conversation;
|
|
257
|
-
sessions.cacheAgentSummary(envelope.sessionId, {
|
|
258
|
-
status: conversation.status,
|
|
259
|
-
provider: conversation.provider,
|
|
260
|
-
conversationId: conversation.id,
|
|
261
|
-
title: conversation.title,
|
|
262
|
-
detail: conversationAttentionDetail(conversation, conversation.status, sessions.get(envelope.sessionId)),
|
|
263
|
-
lastActivity: conversation.lastActivityAt ?? Date.now(),
|
|
264
|
-
usage: conversation.usage ?? null,
|
|
265
|
-
});
|
|
266
|
-
return;
|
|
267
|
-
}
|
|
268
|
-
if (envelope.type === "agent.v2.snapshot") {
|
|
269
|
-
const p = parseTypedPayload("agent.v2.snapshot", envelope.payload);
|
|
270
|
-
const picked = pickConversationSummary(p.conversations, p.activeConversationId);
|
|
271
|
-
const status = aggregateAgentStatus(p.conversations, picked?.status ?? "idle");
|
|
272
|
-
sessions.cacheAgentSummary(envelope.sessionId, {
|
|
273
|
-
status,
|
|
274
|
-
provider: picked?.provider,
|
|
275
|
-
conversationId: picked?.id ?? p.activeConversationId,
|
|
276
|
-
title: picked?.title,
|
|
277
|
-
detail: conversationAttentionDetail(picked, status, sessions.get(envelope.sessionId)),
|
|
278
|
-
lastActivity: picked?.lastActivityAt ?? Date.now(),
|
|
279
|
-
usage: picked?.usage ?? null,
|
|
280
|
-
});
|
|
281
|
-
return;
|
|
282
|
-
}
|
|
283
|
-
if (envelope.type === "agent.v2.event") {
|
|
284
|
-
const p = parseTypedPayload("agent.v2.event", envelope.payload);
|
|
285
|
-
const current = sessions.get(envelope.sessionId);
|
|
286
|
-
if (p.conversation) {
|
|
287
|
-
sessions.cacheAgentSummary(envelope.sessionId, {
|
|
288
|
-
status: p.conversation.status,
|
|
289
|
-
provider: p.conversation.provider,
|
|
290
|
-
conversationId: p.conversation.id,
|
|
291
|
-
title: p.conversation.title,
|
|
292
|
-
detail: conversationAttentionDetail(p.conversation, p.conversation.status, current),
|
|
293
|
-
lastActivity: p.conversation.lastActivityAt,
|
|
294
|
-
usage: p.conversation.usage ?? null,
|
|
295
|
-
});
|
|
296
|
-
} else if (p.patch?.status) {
|
|
297
|
-
sessions.cacheAgentSummary(envelope.sessionId, {
|
|
298
|
-
status: p.patch.status,
|
|
299
|
-
conversationId: p.conversationId,
|
|
300
|
-
detail: conversationAttentionDetail(undefined, p.patch.status, current),
|
|
301
|
-
lastActivity: Date.now(),
|
|
302
|
-
});
|
|
186
|
+
socket.on("message", async (data) => {
|
|
187
|
+
let parsed;
|
|
188
|
+
try {
|
|
189
|
+
parsed = clientFrameSchema.safeParse(JSON.parse(data.toString()));
|
|
190
|
+
} catch {
|
|
191
|
+
parsed = undefined;
|
|
303
192
|
}
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
const p = parseTypedPayload("agent.v2.permission.request", envelope.payload);
|
|
308
|
-
sessions.cacheAgentSummary(envelope.sessionId, {
|
|
309
|
-
status: "waiting_permission",
|
|
310
|
-
conversationId: p.conversationId,
|
|
311
|
-
detail: permissionAttentionDetail(p),
|
|
312
|
-
lastActivity: Date.now(),
|
|
313
|
-
});
|
|
314
|
-
return;
|
|
315
|
-
}
|
|
316
|
-
if (envelope.type === "agent.snapshot") {
|
|
317
|
-
const p = parseTypedPayload("agent.snapshot", envelope.payload);
|
|
318
|
-
sessions.cacheAgentSummary(envelope.sessionId, {
|
|
319
|
-
status: p.status,
|
|
320
|
-
conversationId: p.agentSessionId,
|
|
321
|
-
lastActivity: Date.now(),
|
|
322
|
-
});
|
|
323
|
-
return;
|
|
324
|
-
}
|
|
325
|
-
if (envelope.type === "agent.update") {
|
|
326
|
-
const p = parseTypedPayload("agent.update", envelope.payload);
|
|
327
|
-
if (p.status) {
|
|
328
|
-
sessions.cacheAgentSummary(envelope.sessionId, {
|
|
329
|
-
status: p.status,
|
|
330
|
-
conversationId: p.agentSessionId,
|
|
331
|
-
lastActivity: Date.now(),
|
|
332
|
-
});
|
|
193
|
+
if (!parsed?.success) {
|
|
194
|
+
send({ t: "error", code: "bad_frame", message: "unreadable frame" });
|
|
195
|
+
return;
|
|
333
196
|
}
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
197
|
+
const frame = parsed.data;
|
|
198
|
+
if (!peer) {
|
|
199
|
+
if (frame.t !== "auth") {
|
|
200
|
+
socket.close(4001, "auth first");
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
try {
|
|
204
|
+
peer = await this.authenticate(frame, nonce, socket);
|
|
205
|
+
} catch (error) {
|
|
206
|
+
const code = error instanceof RelayError ? error.code : "auth_failed";
|
|
207
|
+
send({ t: "error", code, message: error instanceof Error ? error.message : String(error) });
|
|
208
|
+
socket.close(4001, code);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
clearTimeout(timer);
|
|
212
|
+
send({ t: "ready", userId: peer.record.userId, email: peer.email });
|
|
213
|
+
this.announce(peer.record, true);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
switch (frame.t) {
|
|
217
|
+
case "ping":
|
|
218
|
+
send({ t: "pong" });
|
|
219
|
+
return;
|
|
220
|
+
case "to":
|
|
221
|
+
this.route(peer, frame.to, frame.d);
|
|
222
|
+
return;
|
|
223
|
+
case "req":
|
|
224
|
+
try {
|
|
225
|
+
const result = await this.handle(peer, frame.method, (frame.params ?? {}) as Record<string, unknown>);
|
|
226
|
+
send({ t: "res", id: frame.id, result });
|
|
227
|
+
} catch (error) {
|
|
228
|
+
const code = error instanceof RelayError ? error.code : "internal";
|
|
229
|
+
send({ t: "res", id: frame.id, error: { code, message: error instanceof Error ? error.message : String(error) } });
|
|
230
|
+
}
|
|
231
|
+
return;
|
|
232
|
+
case "auth":
|
|
233
|
+
return;
|
|
358
234
|
}
|
|
359
|
-
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
socket.on("close", () => {
|
|
238
|
+
clearTimeout(timer);
|
|
239
|
+
if (this.stopped || !peer || this.peers.get(peer.record.id)?.socket !== socket) return;
|
|
240
|
+
this.peers.delete(peer.record.id);
|
|
241
|
+
this.store.touch(peer.record.id);
|
|
242
|
+
this.announce(peer.record, false);
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
private async authenticate(frame: Extract<import("@linkshell/wire").ClientFrame, { t: "auth" }>, nonce: string, socket: WebSocket) {
|
|
247
|
+
const { identity, role } = frame;
|
|
248
|
+
if (idOf(identity.signKey) !== identity.id) throw new RelayError("bad_identity", "id doesn't match key");
|
|
249
|
+
if (!verifySignature(identity.signKey, gatewayChallengeMessage(nonce), frame.signature)) {
|
|
250
|
+
throw new RelayError("bad_signature", "signature check failed");
|
|
360
251
|
}
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
252
|
+
const existing = this.store.peer(identity.id);
|
|
253
|
+
if (existing && existing.role !== role) throw new RelayError("bad_identity", "this key is registered with another role");
|
|
254
|
+
let userId: string | undefined;
|
|
255
|
+
let email: string | undefined;
|
|
256
|
+
if (frame.token) {
|
|
257
|
+
if (!this.options.verifyToken) throw new RelayError("accounts_disabled", "this gateway doesn't support accounts");
|
|
258
|
+
const account = await this.options.verifyToken(frame.token);
|
|
259
|
+
if (!account) throw new RelayError("token_invalid", "account token is invalid or expired");
|
|
260
|
+
userId = account.userId;
|
|
261
|
+
email = account.email;
|
|
367
262
|
}
|
|
368
|
-
|
|
369
|
-
|
|
263
|
+
const refusal = await this.options.admit?.({ role: role as PeerRole, userId });
|
|
264
|
+
if (refusal) throw new RelayError("not_admitted", refusal);
|
|
265
|
+
const record: PeerRecord = {
|
|
266
|
+
id: identity.id,
|
|
267
|
+
role: role as PeerRole,
|
|
268
|
+
signKey: identity.signKey,
|
|
269
|
+
boxKey: identity.boxKey,
|
|
270
|
+
name: frame.name,
|
|
271
|
+
platform: frame.platform,
|
|
272
|
+
userId,
|
|
273
|
+
lastSeen: Date.now(),
|
|
274
|
+
};
|
|
275
|
+
this.store.upsertPeer(record);
|
|
276
|
+
const previous = this.peers.get(record.id);
|
|
277
|
+
const peer: Peer = { record, socket, email };
|
|
278
|
+
this.peers.set(record.id, peer);
|
|
279
|
+
// One live connection per identity: the newest wins.
|
|
280
|
+
previous?.socket.close(4000, "replaced by a newer connection");
|
|
281
|
+
this.log(`${role} ${record.id.slice(0, 8)} "${record.name}" online${userId ? " (account)" : ""}`);
|
|
282
|
+
return peer;
|
|
370
283
|
}
|
|
371
|
-
}
|
|
372
284
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
285
|
+
// ── routing ───────────────────────────────────────────────────────
|
|
286
|
+
|
|
287
|
+
private route(from: Peer, to: string, data: string): void {
|
|
288
|
+
const via = this.store.route(from.record.id, to);
|
|
289
|
+
const target = this.peers.get(to);
|
|
290
|
+
const undeliverable = (reason: string) =>
|
|
291
|
+
from.socket.send(JSON.stringify({ t: "undeliverable", to, reason } satisfies ServerFrame));
|
|
292
|
+
if (!via) return undeliverable("not_allowed");
|
|
293
|
+
if (!target) return undeliverable("offline");
|
|
294
|
+
const frame = JSON.stringify({
|
|
295
|
+
t: "from",
|
|
296
|
+
from: { id: from.record.id, signKey: from.record.signKey, boxKey: from.record.boxKey },
|
|
297
|
+
via,
|
|
298
|
+
d: data,
|
|
299
|
+
} satisfies ServerFrame);
|
|
300
|
+
this.options.onRouted?.(frame);
|
|
301
|
+
target.socket.send(frame);
|
|
302
|
+
this.holdWhileBehind(from, target);
|
|
383
303
|
}
|
|
384
304
|
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
305
|
+
/**
|
|
306
|
+
* Backpressure. What a peer can't take yet would otherwise pile up here, in
|
|
307
|
+
* memory every user of this gateway shares: stop reading the sender instead,
|
|
308
|
+
* so the data waits on its side until the receiver catches up.
|
|
309
|
+
*/
|
|
310
|
+
private holdWhileBehind(from: Peer, target: Peer): void {
|
|
311
|
+
const limit = this.options.maxBufferedBytes ?? 4 * 1024 * 1024;
|
|
312
|
+
if (from.held || target.socket.bufferedAmount <= limit) return;
|
|
313
|
+
from.held = true;
|
|
314
|
+
from.socket.pause();
|
|
315
|
+
const since = Date.now();
|
|
316
|
+
const timer = setInterval(() => {
|
|
317
|
+
const open = target.socket.readyState === WebSocket.OPEN;
|
|
318
|
+
if (open && target.socket.bufferedAmount > limit / 4) {
|
|
319
|
+
if (Date.now() - since < (this.options.stalledPeerMs ?? 60_000)) return;
|
|
320
|
+
this.log(`${target.record.role} ${target.record.id.slice(0, 8)} can't keep up; dropping it`);
|
|
321
|
+
target.socket.terminate();
|
|
398
322
|
}
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
p.platform ?? undefined,
|
|
406
|
-
p.cwd ?? undefined,
|
|
407
|
-
p.projectName ?? undefined,
|
|
408
|
-
);
|
|
409
|
-
}
|
|
410
|
-
break;
|
|
411
|
-
}
|
|
412
|
-
case "terminal.output": {
|
|
413
|
-
sessions.bufferOutput(session.id, envelope);
|
|
414
|
-
broadcastToClients(session, envelope);
|
|
415
|
-
break;
|
|
416
|
-
}
|
|
417
|
-
case "terminal.exit": {
|
|
418
|
-
// Don't terminate session — other terminals may still be running
|
|
419
|
-
broadcastToClients(session, envelope);
|
|
420
|
-
break;
|
|
421
|
-
}
|
|
422
|
-
case "session.heartbeat":
|
|
423
|
-
break;
|
|
424
|
-
case "permission.decision.result": {
|
|
425
|
-
broadcastToClients(session, envelope);
|
|
426
|
-
break;
|
|
427
|
-
}
|
|
428
|
-
// Tunnel: host → gateway (not broadcast to clients)
|
|
429
|
-
case "tunnel.response": {
|
|
430
|
-
const p = parseTypedPayload("tunnel.response", envelope.payload);
|
|
431
|
-
handleTunnelResponse(p);
|
|
432
|
-
return;
|
|
433
|
-
}
|
|
434
|
-
case "tunnel.ws.data": {
|
|
435
|
-
const p = parseTypedPayload("tunnel.ws.data", envelope.payload);
|
|
436
|
-
handleTunnelWsData(p);
|
|
437
|
-
return;
|
|
438
|
-
}
|
|
439
|
-
case "tunnel.ws.close": {
|
|
440
|
-
const p = parseTypedPayload("tunnel.ws.close", envelope.payload);
|
|
441
|
-
handleTunnelWsClose(p);
|
|
442
|
-
return;
|
|
443
|
-
}
|
|
444
|
-
case "control.grant":
|
|
445
|
-
case "control.reject":
|
|
446
|
-
broadcastToClients(session, envelope);
|
|
447
|
-
break;
|
|
448
|
-
// Screen sharing: host → clients
|
|
449
|
-
case "screen.frame":
|
|
450
|
-
case "screen.status":
|
|
451
|
-
case "screen.offer":
|
|
452
|
-
case "screen.ice":
|
|
453
|
-
// Agent GUI: host → clients
|
|
454
|
-
case "agent.capabilities":
|
|
455
|
-
case "agent.update":
|
|
456
|
-
case "agent.permission.request":
|
|
457
|
-
case "agent.snapshot":
|
|
458
|
-
cacheAgentEnvelope(envelope, sessions);
|
|
459
|
-
broadcastToClients(session, envelope);
|
|
460
|
-
break;
|
|
461
|
-
// Multi-terminal: host → clients
|
|
462
|
-
case "terminal.spawned":
|
|
463
|
-
case "terminal.list":
|
|
464
|
-
case "terminal.browse.result":
|
|
465
|
-
case "terminal.file.read.result":
|
|
466
|
-
broadcastToClients(session, envelope);
|
|
467
|
-
break;
|
|
468
|
-
// Structured status from hooks
|
|
469
|
-
case "terminal.status":
|
|
470
|
-
cacheAgentEnvelope(envelope, sessions);
|
|
471
|
-
sessions.cacheStatus(session.id, envelope);
|
|
472
|
-
broadcastToClients(session, envelope);
|
|
473
|
-
break;
|
|
474
|
-
default:
|
|
475
|
-
broadcastToClients(session, envelope);
|
|
476
|
-
break;
|
|
323
|
+
clearInterval(timer);
|
|
324
|
+
from.held = false;
|
|
325
|
+
this.alive.add(from.socket);
|
|
326
|
+
if (from.socket.readyState === WebSocket.OPEN) from.socket.resume();
|
|
327
|
+
}, 20);
|
|
328
|
+
timer.unref();
|
|
477
329
|
}
|
|
478
|
-
}
|
|
479
330
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
session: ReturnType<SessionManager["get"]> & {},
|
|
484
|
-
deviceId: string,
|
|
485
|
-
sessions: SessionManager,
|
|
486
|
-
): void {
|
|
487
|
-
const requireController = (): boolean => {
|
|
488
|
-
if (session.controllerId === deviceId) return true;
|
|
489
|
-
socket.send(
|
|
490
|
-
serializeEnvelope(
|
|
491
|
-
createEnvelope({
|
|
492
|
-
type: "session.error",
|
|
493
|
-
sessionId: session.id,
|
|
494
|
-
payload: {
|
|
495
|
-
code: "control_conflict",
|
|
496
|
-
message: "Not the controller",
|
|
497
|
-
},
|
|
498
|
-
}),
|
|
499
|
-
),
|
|
500
|
-
);
|
|
501
|
-
return false;
|
|
502
|
-
};
|
|
503
|
-
|
|
504
|
-
const agentRoute = agentV2MessageRoute(envelope.type);
|
|
505
|
-
if (agentRoute === "client_write") {
|
|
506
|
-
if (!requireController()) return;
|
|
507
|
-
sendToHost(session, envelope);
|
|
508
|
-
return;
|
|
331
|
+
private event<N extends keyof RelayEvents>(peerId: string, name: N, data: RelayEvents[N]): void {
|
|
332
|
+
const peer = this.peers.get(peerId);
|
|
333
|
+
if (peer?.socket.readyState === WebSocket.OPEN) peer.socket.send(JSON.stringify({ t: "event", name, data } satisfies ServerFrame));
|
|
509
334
|
}
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
335
|
+
|
|
336
|
+
/** Tells everyone who can reach `record` that it came or went. */
|
|
337
|
+
private announce(record: PeerRecord, online: boolean): void {
|
|
338
|
+
for (const { peer } of this.store.reachable(record.id)) this.event(peer.id, "presence", { id: record.id, online });
|
|
513
339
|
}
|
|
514
340
|
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
payload: { ...payload, isReplay: true },
|
|
549
|
-
}),
|
|
550
|
-
),
|
|
551
|
-
);
|
|
341
|
+
private machineEntry(peer: PeerRecord, via: "paired" | "account"): MachineEntry {
|
|
342
|
+
return {
|
|
343
|
+
id: peer.id,
|
|
344
|
+
signKey: peer.signKey,
|
|
345
|
+
boxKey: peer.boxKey,
|
|
346
|
+
name: peer.name,
|
|
347
|
+
platform: peer.platform,
|
|
348
|
+
online: this.peers.has(peer.id),
|
|
349
|
+
via,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
private deviceEntry(peer: PeerRecord, pairedAt?: number): DeviceEntry {
|
|
354
|
+
return { id: peer.id, signKey: peer.signKey, boxKey: peer.boxKey, name: peer.name, online: this.peers.has(peer.id), pairedAt };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ── control methods ───────────────────────────────────────────────
|
|
358
|
+
|
|
359
|
+
private async handle(peer: Peer, method: string, params: Record<string, unknown>): Promise<unknown> {
|
|
360
|
+
const role = peer.record.role;
|
|
361
|
+
const need = (wanted: PeerRole) => {
|
|
362
|
+
if (role !== wanted) throw new RelayError("forbidden", `${method} is for ${wanted}s`);
|
|
363
|
+
};
|
|
364
|
+
switch (method) {
|
|
365
|
+
case "pair.offer": {
|
|
366
|
+
need("machine");
|
|
367
|
+
for (const [code, offer] of this.offers) if (offer.machineId === peer.record.id) this.offers.delete(code);
|
|
368
|
+
let code: string;
|
|
369
|
+
do code = String(randomInt(0, 1_000_000)).padStart(6, "0");
|
|
370
|
+
while (this.offers.has(code));
|
|
371
|
+
const expiresAt = Date.now() + (this.options.pairingTtlMs ?? 10 * 60_000);
|
|
372
|
+
this.offers.set(code, { code, machineId: peer.record.id, expiresAt, attempts: 0 });
|
|
373
|
+
return { code, expiresAt };
|
|
552
374
|
}
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
375
|
+
case "pair.decide": {
|
|
376
|
+
need("machine");
|
|
377
|
+
const pending = this.decisions.get(String(params.requestId));
|
|
378
|
+
if (!pending || pending.machineId !== peer.record.id) throw new RelayError("not_found", "no such pairing request");
|
|
379
|
+
pending.resolve(params.accept === true);
|
|
380
|
+
return {};
|
|
557
381
|
}
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
},
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
382
|
+
case "pair.claim":
|
|
383
|
+
need("device");
|
|
384
|
+
return this.claim(peer, params);
|
|
385
|
+
case "machines.list":
|
|
386
|
+
need("device");
|
|
387
|
+
return {
|
|
388
|
+
machines: this.store
|
|
389
|
+
.reachable(peer.record.id)
|
|
390
|
+
.map(({ peer: machine, via }) => this.machineEntry(machine, via))
|
|
391
|
+
.sort((a, b) => Number(b.online) - Number(a.online) || a.name.localeCompare(b.name)),
|
|
392
|
+
};
|
|
393
|
+
case "machines.forget": {
|
|
394
|
+
need("device");
|
|
395
|
+
const machineId = String(params.machineId ?? "");
|
|
396
|
+
const unpaired = this.store.unlink(machineId, peer.record.id);
|
|
397
|
+
if (unpaired) this.event(machineId, "presence", { id: peer.record.id, online: false });
|
|
398
|
+
// A computer that is listed because it signed in to this account, and is gone (reinstalled,
|
|
399
|
+
// given away): taken off the account, so the list stops showing it. One that is connected
|
|
400
|
+
// stays: it is signed in right now, and would only be back at once.
|
|
401
|
+
const machine = this.store.peer(machineId);
|
|
402
|
+
const ours = machine?.role === "machine" && peer.record.userId !== undefined && machine.userId === peer.record.userId;
|
|
403
|
+
if (ours && !this.peers.has(machineId)) this.store.disown(machineId);
|
|
404
|
+
return {};
|
|
405
|
+
}
|
|
406
|
+
case "devices.list":
|
|
407
|
+
need("machine");
|
|
408
|
+
return {
|
|
409
|
+
devices: this.store.reachable(peer.record.id).map(({ peer: device, pairedAt }) => this.deviceEntry(device, pairedAt)),
|
|
410
|
+
};
|
|
411
|
+
case "devices.revoke": {
|
|
412
|
+
need("machine");
|
|
413
|
+
const deviceId = String(params.deviceId ?? "");
|
|
414
|
+
if (this.store.unlink(peer.record.id, deviceId)) {
|
|
415
|
+
this.event(deviceId, "machines.changed", {});
|
|
416
|
+
this.peers.get(deviceId)?.socket.close(4003, "unpaired");
|
|
417
|
+
}
|
|
418
|
+
return {};
|
|
419
|
+
}
|
|
420
|
+
default:
|
|
421
|
+
throw new RelayError("unknown_method", `unknown method ${method}`);
|
|
593
422
|
}
|
|
594
|
-
case "session.heartbeat":
|
|
595
|
-
break;
|
|
596
|
-
// Screen sharing: client → host
|
|
597
|
-
case "screen.start":
|
|
598
|
-
case "screen.stop":
|
|
599
|
-
case "screen.answer":
|
|
600
|
-
case "screen.ice":
|
|
601
|
-
case "agent.session.new":
|
|
602
|
-
case "agent.session.load":
|
|
603
|
-
case "agent.prompt":
|
|
604
|
-
case "agent.cancel":
|
|
605
|
-
case "agent.permission.response":
|
|
606
|
-
// Multi-terminal write ops: client → host (require controller)
|
|
607
|
-
case "terminal.spawn":
|
|
608
|
-
case "terminal.kill":
|
|
609
|
-
case "terminal.mkdir":
|
|
610
|
-
case "file.upload":
|
|
611
|
-
case "permission.decision":
|
|
612
|
-
if (!requireController()) return;
|
|
613
|
-
sendToHost(session, envelope);
|
|
614
|
-
break;
|
|
615
|
-
// Read-only ops: any client may issue (no controller gate)
|
|
616
|
-
case "terminal.list":
|
|
617
|
-
case "terminal.browse":
|
|
618
|
-
case "terminal.file.read":
|
|
619
|
-
case "terminal.history.request":
|
|
620
|
-
case "agent.initialize":
|
|
621
|
-
case "agent.session.list":
|
|
622
|
-
sendToHost(session, envelope);
|
|
623
|
-
break;
|
|
624
|
-
default:
|
|
625
|
-
sendToHost(session, envelope);
|
|
626
|
-
break;
|
|
627
423
|
}
|
|
628
|
-
}
|
|
629
424
|
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
const
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
envelope: Envelope,
|
|
637
|
-
): void {
|
|
638
|
-
const data = serializeEnvelope(envelope);
|
|
639
|
-
for (const [, client] of session.clients) {
|
|
640
|
-
if (client.socket.readyState !== client.socket.OPEN) continue;
|
|
641
|
-
if (client.socket.bufferedAmount > MAX_CLIENT_BUFFER_BYTES) continue;
|
|
642
|
-
client.socket.send(data);
|
|
643
|
-
}
|
|
644
|
-
}
|
|
425
|
+
private async claim(device: Peer, params: Record<string, unknown>): Promise<{ machine: MachineEntry }> {
|
|
426
|
+
// Throttle guessing: a handful of claims per device per minute.
|
|
427
|
+
const now = Date.now();
|
|
428
|
+
const recent = (this.claimTimes.get(device.record.id) ?? []).filter((t) => now - t < 60_000);
|
|
429
|
+
if (recent.length >= 6) throw new RelayError("rate_limited", "too many pairing attempts, wait a minute");
|
|
430
|
+
this.claimTimes.set(device.record.id, [...recent, now]);
|
|
645
431
|
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
)
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
432
|
+
const code = typeof params.code === "string" ? params.code.replace(/\D/g, "") : undefined;
|
|
433
|
+
const machineId = typeof params.machineId === "string" ? params.machineId : undefined;
|
|
434
|
+
const offer = code
|
|
435
|
+
? this.offers.get(code)
|
|
436
|
+
: [...this.offers.values()].find((candidate) => candidate.machineId === machineId);
|
|
437
|
+
if (!offer || offer.expiresAt < now) {
|
|
438
|
+
if (offer) this.offers.delete(offer.code);
|
|
439
|
+
throw new RelayError("pairing_expired", "this pairing code is wrong or has expired");
|
|
440
|
+
}
|
|
441
|
+
if (++offer.attempts > MAX_CLAIM_ATTEMPTS) {
|
|
442
|
+
this.offers.delete(offer.code);
|
|
443
|
+
throw new RelayError("pairing_expired", "too many attempts on this code; start pairing again");
|
|
444
|
+
}
|
|
445
|
+
const machine = this.peers.get(offer.machineId);
|
|
446
|
+
if (!machine) throw new RelayError("machine_offline", "the computer is offline");
|
|
658
447
|
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
)
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
448
|
+
// The machine checks the proof (it holds the QR secret) and decides.
|
|
449
|
+
const requestId = randomUUID();
|
|
450
|
+
const accepted = await new Promise<boolean>((resolve) => {
|
|
451
|
+
const timer = setTimeout(() => {
|
|
452
|
+
this.decisions.delete(requestId);
|
|
453
|
+
resolve(false);
|
|
454
|
+
}, PAIR_DECISION_MS);
|
|
455
|
+
this.decisions.set(requestId, {
|
|
456
|
+
machineId: machine.record.id,
|
|
457
|
+
resolve: (accept) => {
|
|
458
|
+
clearTimeout(timer);
|
|
459
|
+
this.decisions.delete(requestId);
|
|
460
|
+
resolve(accept);
|
|
461
|
+
},
|
|
462
|
+
});
|
|
463
|
+
this.event(machine.record.id, "pair.request", {
|
|
464
|
+
requestId,
|
|
465
|
+
device: { id: device.record.id, signKey: device.record.signKey, boxKey: device.record.boxKey, name: device.record.name },
|
|
466
|
+
proof: String(params.proof ?? ""),
|
|
467
|
+
code: offer.code,
|
|
468
|
+
});
|
|
469
|
+
});
|
|
470
|
+
if (!accepted) {
|
|
471
|
+
this.event(machine.record.id, "pair.done", { requestId, accepted: false });
|
|
472
|
+
throw new RelayError("pairing_refused", "the computer refused this pairing");
|
|
473
|
+
}
|
|
474
|
+
this.offers.delete(offer.code);
|
|
475
|
+
this.store.link(machine.record.id, device.record.id);
|
|
476
|
+
this.event(machine.record.id, "pair.done", {
|
|
477
|
+
requestId,
|
|
478
|
+
accepted: true,
|
|
479
|
+
device: this.deviceEntry(device.record, Date.now()),
|
|
480
|
+
});
|
|
481
|
+
this.log(`paired device ${device.record.id.slice(0, 8)} with machine ${machine.record.id.slice(0, 8)}`);
|
|
482
|
+
return { machine: this.machineEntry(machine.record, "paired") };
|
|
668
483
|
}
|
|
669
484
|
}
|