pipane 0.1.6 → 0.1.7
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/README.md +14 -3
- package/bin/pipane-rendezvous.js +21 -0
- package/bin/pipane.js +21 -1
- package/dist/client/assets/__vite-browser-external-DtLbhLGx.js +1 -0
- package/dist/client/assets/app-runtime-Rw-O-AwW.js +1 -0
- package/dist/client/assets/backend-landing-page-B7SbyIXQ.js +1 -0
- package/dist/client/assets/index-DwbBcYUf.js +2 -0
- package/dist/client/assets/index-iblQYBAl.css +1 -0
- package/dist/client/assets/main-LGItV9Aj.js +2807 -0
- package/dist/client/assets/pairing-page-D-WxlWZR.js +1 -0
- package/dist/client/assets/remote-backend-manager-BWuGh30I.js +1 -0
- package/dist/client/assets/rendezvous-trust-api-C2Zob5i4.js +4 -0
- package/dist/client/assets/webrtc-frame-transport-BijNnA7N.js +1 -0
- package/dist/client/assets/ws-agent-adapter-BRysf-Y7.js +6 -0
- package/dist/client/index.html +2 -2
- package/dist/server/rendezvous/rendezvous-hub.js +426 -0
- package/dist/server/rendezvous/server.js +247 -0
- package/dist/server/rendezvous/trust-store.js +432 -0
- package/dist/server/server/auth-guard.js +61 -0
- package/dist/server/server/backend-connection-authorizer.js +29 -0
- package/dist/server/server/backend-identity.js +167 -0
- package/dist/server/server/backend-protocol-handler.js +132 -0
- package/dist/server/server/backend-trust-store.js +157 -0
- package/dist/server/server/backend-webrtc.js +239 -0
- package/dist/server/server/conversation-file-access.js +97 -0
- package/dist/server/server/frame-connection.js +48 -0
- package/dist/server/server/frame-router.js +45 -0
- package/dist/server/server/ice-servers.js +24 -0
- package/dist/server/server/local-backend-api.js +389 -0
- package/dist/server/server/local-settings.js +17 -4
- package/dist/server/server/pi-rpc-protocol.js +407 -0
- package/dist/server/server/process-pool.js +27 -24
- package/dist/server/server/rendezvous-client.js +282 -0
- package/dist/server/server/rest-api.js +133 -176
- package/dist/server/server/server.js +163 -97
- package/dist/server/server/session-index.js +105 -28
- package/dist/server/server/session-jsonl.js +82 -5
- package/dist/server/server/session-path.js +145 -0
- package/dist/server/server/update-api.js +28 -0
- package/dist/server/server/update-check.js +33 -13
- package/dist/server/server/update-manager.js +231 -0
- package/dist/server/server/worktree-name.js +116 -31
- package/dist/server/server/ws-handler.js +267 -186
- package/dist/server/shared/backend-api.js +1 -0
- package/dist/server/shared/backend-protocol.js +164 -0
- package/dist/server/shared/node-trust-crypto.js +61 -0
- package/dist/server/shared/rendezvous-protocol.js +243 -0
- package/dist/server/shared/tool-runtime.js +1 -0
- package/dist/server/shared/trust-protocol.js +97 -0
- package/dist/server/shared/updates.js +4 -0
- package/dist/server/shared/ws-protocol.js +473 -1
- package/docs/protocol.md +127 -0
- package/package.json +21 -8
- package/dist/client/assets/index-Dl_wdLZH.css +0 -1
- package/dist/client/assets/index-hNqbnG06.js +0 -2482
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { WebSocket } from "ws";
|
|
3
|
+
import { RENDEZVOUS_PROTOCOL_VERSION, decodeBackendCommand, decodeBrowserCommand, encodeRendezvousMessage, } from "../shared/rendezvous-protocol.js";
|
|
4
|
+
import { deriveBackendId, extractDtlsFingerprint, sha256Base64Url, verifyBackendChallenge, verifyBackendIdentityBinding, } from "../server/backend-identity.js";
|
|
5
|
+
import { IceServerProvider, } from "./trust-store.js";
|
|
6
|
+
const MAX_PAIRING_LIFETIME_MS = 15 * 60_000;
|
|
7
|
+
export class RendezvousHub {
|
|
8
|
+
trustStore;
|
|
9
|
+
iceServerProvider;
|
|
10
|
+
registrationTimeoutMs;
|
|
11
|
+
now;
|
|
12
|
+
backendHandshakes = new Map();
|
|
13
|
+
backends = new Map();
|
|
14
|
+
browserRoutes = new Map();
|
|
15
|
+
routes = new Map();
|
|
16
|
+
pairings = new Map();
|
|
17
|
+
registeredListeners = new Set();
|
|
18
|
+
constructor(options) {
|
|
19
|
+
this.trustStore = options.trustStore;
|
|
20
|
+
this.iceServerProvider = options.iceServerProvider ?? new IceServerProvider();
|
|
21
|
+
this.registrationTimeoutMs = options.registrationTimeoutMs ?? 10_000;
|
|
22
|
+
this.now = options.now ?? Date.now;
|
|
23
|
+
}
|
|
24
|
+
onBackendRegistered(listener) {
|
|
25
|
+
this.registeredListeners.add(listener);
|
|
26
|
+
return () => this.registeredListeners.delete(listener);
|
|
27
|
+
}
|
|
28
|
+
isBackendOnline(backendId) {
|
|
29
|
+
return this.backends.get(backendId)?.socket.readyState === WebSocket.OPEN;
|
|
30
|
+
}
|
|
31
|
+
getBackendMetadata(backendId) {
|
|
32
|
+
return this.backends.get(backendId)?.metadata;
|
|
33
|
+
}
|
|
34
|
+
getOpenPairing(pairId) {
|
|
35
|
+
const pairing = this.pairings.get(pairId);
|
|
36
|
+
if (!pairing)
|
|
37
|
+
return undefined;
|
|
38
|
+
if (pairing.expiresAt <= this.now() || !this.isBackendOnline(pairing.backendId)) {
|
|
39
|
+
this.pairings.delete(pairId);
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
return { ...pairing };
|
|
43
|
+
}
|
|
44
|
+
notifyRevocation(result) {
|
|
45
|
+
const affectedBackends = result.backendId
|
|
46
|
+
? [this.backends.get(result.backendId)].filter((backend) => !!backend)
|
|
47
|
+
: [...this.backends.values()].filter((backend) => this.trustStore.getBackendOwner(backend.backendId) === result.accountId);
|
|
48
|
+
for (const backend of affectedBackends) {
|
|
49
|
+
this.sendBackend(backend.socket, {
|
|
50
|
+
type: "authorization_revoked",
|
|
51
|
+
accountId: result.accountId,
|
|
52
|
+
deviceId: result.deviceId,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
for (const route of [...this.routes.values()]) {
|
|
56
|
+
if (route.claims.accountId !== result.accountId)
|
|
57
|
+
continue;
|
|
58
|
+
if (result.backendId && route.backendId !== result.backendId)
|
|
59
|
+
continue;
|
|
60
|
+
if (result.deviceId && route.claims.deviceId !== result.deviceId)
|
|
61
|
+
continue;
|
|
62
|
+
this.removeRoute(route);
|
|
63
|
+
this.sendBrowser(route.browser, {
|
|
64
|
+
type: "connection_closed",
|
|
65
|
+
connectionId: route.connectionId,
|
|
66
|
+
reason: "Authorization revoked",
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
acceptBackend(socket) {
|
|
71
|
+
const nonce = randomBytes(32).toString("base64url");
|
|
72
|
+
const timeout = setTimeout(() => {
|
|
73
|
+
this.sendError(socket, "unauthorized_connection", "Backend registration timed out");
|
|
74
|
+
socket.close(1008, "Registration timed out");
|
|
75
|
+
}, this.registrationTimeoutMs);
|
|
76
|
+
timeout.unref?.();
|
|
77
|
+
this.backendHandshakes.set(socket, { nonce, timeout });
|
|
78
|
+
this.sendBackend(socket, { type: "challenge", nonce });
|
|
79
|
+
socket.on("message", (raw) => this.handleBackendMessage(socket, raw.toString()));
|
|
80
|
+
socket.on("close", () => this.removeBackendSocket(socket));
|
|
81
|
+
}
|
|
82
|
+
acceptBrowser(socket) {
|
|
83
|
+
socket.on("message", (raw) => this.handleBrowserMessage(socket, raw.toString()));
|
|
84
|
+
socket.on("close", () => this.removeBrowser(socket, "Browser disconnected"));
|
|
85
|
+
}
|
|
86
|
+
close() {
|
|
87
|
+
for (const handshake of this.backendHandshakes.values())
|
|
88
|
+
clearTimeout(handshake.timeout);
|
|
89
|
+
for (const backend of this.backends.values())
|
|
90
|
+
backend.socket.close(1001, "Rendezvous shutting down");
|
|
91
|
+
for (const browser of this.browserRoutes.keys())
|
|
92
|
+
browser.close(1001, "Rendezvous shutting down");
|
|
93
|
+
this.backendHandshakes.clear();
|
|
94
|
+
this.backends.clear();
|
|
95
|
+
this.browserRoutes.clear();
|
|
96
|
+
this.routes.clear();
|
|
97
|
+
this.pairings.clear();
|
|
98
|
+
}
|
|
99
|
+
handleBackendMessage(socket, raw) {
|
|
100
|
+
const decoded = decodeBackendCommand(raw);
|
|
101
|
+
if (!decoded.ok) {
|
|
102
|
+
this.sendError(socket, decoded.error.code, decoded.error.message);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const handshake = this.backendHandshakes.get(socket);
|
|
106
|
+
if (!handshake) {
|
|
107
|
+
this.sendError(socket, "unauthorized_connection", "Unknown backend connection");
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const command = decoded.value;
|
|
111
|
+
if (!handshake.registered) {
|
|
112
|
+
if (command.type !== "register_backend") {
|
|
113
|
+
this.sendError(socket, "unauthorized_connection", "Backend must register before other commands");
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
this.registerBackend(socket, handshake, command.publicKey, command.signature, command.metadata);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const backend = handshake.registered;
|
|
120
|
+
switch (command.type) {
|
|
121
|
+
case "register_backend":
|
|
122
|
+
this.sendError(socket, "invalid_message", "Backend is already registered");
|
|
123
|
+
break;
|
|
124
|
+
case "open_pairing":
|
|
125
|
+
this.openPairing(backend, command.pairId, command.expiresAt);
|
|
126
|
+
break;
|
|
127
|
+
case "confirm_pairing":
|
|
128
|
+
this.confirmPairing(backend, command.connectionId);
|
|
129
|
+
break;
|
|
130
|
+
case "signal":
|
|
131
|
+
this.relayBackendSignal(backend, command.connectionId, command.signal);
|
|
132
|
+
break;
|
|
133
|
+
case "connection_binding":
|
|
134
|
+
this.relayBackendBinding(backend, command.connectionId, command.binding);
|
|
135
|
+
break;
|
|
136
|
+
case "close_connection":
|
|
137
|
+
this.closeFromBackend(backend, command.connectionId, command.reason ?? "Backend closed connection");
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
registerBackend(socket, handshake, publicKey, signature, metadata) {
|
|
142
|
+
if (!verifyBackendChallenge(publicKey, handshake.nonce, signature)) {
|
|
143
|
+
this.sendError(socket, "unauthorized_connection", "Invalid backend registration signature");
|
|
144
|
+
socket.close(1008, "Invalid registration signature");
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
let backendId;
|
|
148
|
+
try {
|
|
149
|
+
backendId = deriveBackendId(publicKey);
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
this.sendError(socket, "invalid_message", "Invalid backend public key");
|
|
153
|
+
socket.close(1008, "Invalid public key");
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const previous = this.backends.get(backendId);
|
|
157
|
+
if (previous && previous.socket !== socket) {
|
|
158
|
+
this.removeRegisteredBackend(previous, "Backend reconnected");
|
|
159
|
+
previous.socket.close(4001, "Replaced by a newer connection");
|
|
160
|
+
}
|
|
161
|
+
clearTimeout(handshake.timeout);
|
|
162
|
+
const registered = { backendId, publicKey, metadata, socket };
|
|
163
|
+
handshake.registered = registered;
|
|
164
|
+
this.backends.set(backendId, registered);
|
|
165
|
+
this.sendBackend(socket, {
|
|
166
|
+
type: "registered",
|
|
167
|
+
backendId,
|
|
168
|
+
ticketPublicKey: this.trustStore.ticketPublicKey,
|
|
169
|
+
iceServers: this.iceServerProvider.issue(backendId),
|
|
170
|
+
});
|
|
171
|
+
const pendingRevocation = this.trustStore.getPendingBackendRevocation(backendId);
|
|
172
|
+
if (pendingRevocation) {
|
|
173
|
+
this.sendBackend(socket, {
|
|
174
|
+
type: "authorization_revoked",
|
|
175
|
+
accountId: pendingRevocation.accountId,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
for (const listener of this.registeredListeners)
|
|
179
|
+
listener(backendId);
|
|
180
|
+
}
|
|
181
|
+
openPairing(backend, pairId, expiresAt) {
|
|
182
|
+
const now = this.now();
|
|
183
|
+
if (expiresAt <= now || expiresAt > now + MAX_PAIRING_LIFETIME_MS) {
|
|
184
|
+
this.sendError(backend.socket, "invalid_pairing", "Pairing expiry is outside the allowed window");
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
const existing = this.getOpenPairing(pairId);
|
|
188
|
+
if (existing && existing.backendId !== backend.backendId) {
|
|
189
|
+
this.sendError(backend.socket, "invalid_pairing", "Pairing id is already active");
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
this.pairings.set(pairId, { pairId, backendId: backend.backendId, expiresAt });
|
|
193
|
+
this.sendBackend(backend.socket, { type: "pairing_opened", pairId, expiresAt });
|
|
194
|
+
}
|
|
195
|
+
confirmPairing(backend, connectionId) {
|
|
196
|
+
const route = this.routeForBackend(backend, connectionId);
|
|
197
|
+
if (!route)
|
|
198
|
+
return;
|
|
199
|
+
if (route.claims.kind !== "pairing" || !route.claims.pairId) {
|
|
200
|
+
this.sendError(backend.socket, "invalid_pairing", "Connection is not a pairing attempt", connectionId);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const pairing = this.getOpenPairing(route.claims.pairId);
|
|
204
|
+
if (!pairing || pairing.backendId !== backend.backendId) {
|
|
205
|
+
this.sendError(backend.socket, "invalid_pairing", "Pairing capability is missing or expired", connectionId);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
const pairId = route.claims.pairId;
|
|
210
|
+
const confirmation = this.trustStore.confirmPairing(route.claims);
|
|
211
|
+
route.claims = { ...route.claims, accountId: confirmation.accountId };
|
|
212
|
+
this.pairings.delete(pairId);
|
|
213
|
+
this.sendBackend(backend.socket, {
|
|
214
|
+
type: "pairing_confirmed",
|
|
215
|
+
connectionId,
|
|
216
|
+
pairId,
|
|
217
|
+
accountId: confirmation.accountId,
|
|
218
|
+
deviceId: confirmation.deviceId,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
this.sendError(backend.socket, "invalid_pairing", error instanceof Error ? error.message : String(error), connectionId);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
relayBackendSignal(backend, connectionId, signal) {
|
|
226
|
+
const route = this.routeForBackend(backend, connectionId);
|
|
227
|
+
if (!route)
|
|
228
|
+
return;
|
|
229
|
+
if (signal.kind === "description") {
|
|
230
|
+
if (signal.type !== "answer") {
|
|
231
|
+
this.sendError(backend.socket, "invalid_message", "Backend must send an SDP answer", connectionId);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
route.answerSdp = signal.sdp;
|
|
235
|
+
}
|
|
236
|
+
this.sendBrowser(route.browser, { type: "signal", connectionId, signal });
|
|
237
|
+
}
|
|
238
|
+
relayBackendBinding(backend, connectionId, binding) {
|
|
239
|
+
const route = this.routeForBackend(backend, connectionId);
|
|
240
|
+
if (!route)
|
|
241
|
+
return;
|
|
242
|
+
try {
|
|
243
|
+
if (!route.offerSdp || !route.answerSdp)
|
|
244
|
+
throw new Error("SDP exchange is incomplete");
|
|
245
|
+
if (binding.backendId !== backend.backendId || binding.publicKey !== backend.publicKey || binding.connectionId !== connectionId) {
|
|
246
|
+
throw new Error("Backend binding identity does not match the route");
|
|
247
|
+
}
|
|
248
|
+
if (binding.offerSha256 !== sha256Base64Url(route.offerSdp)
|
|
249
|
+
|| binding.answerSha256 !== sha256Base64Url(route.answerSdp)
|
|
250
|
+
|| binding.dtlsFingerprint !== extractDtlsFingerprint(route.answerSdp)
|
|
251
|
+
|| binding.expiresAt !== route.claims.expiresAt
|
|
252
|
+
|| !verifyBackendIdentityBinding(binding)) {
|
|
253
|
+
throw new Error("Backend identity binding is invalid");
|
|
254
|
+
}
|
|
255
|
+
this.sendBrowser(route.browser, { type: "connection_binding", connectionId, binding });
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
this.sendError(backend.socket, "invalid_message", error instanceof Error ? error.message : String(error), connectionId);
|
|
259
|
+
this.closeFromBackend(backend, connectionId, "Backend identity binding failed");
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
closeFromBackend(backend, connectionId, reason) {
|
|
263
|
+
const route = this.routeForBackend(backend, connectionId);
|
|
264
|
+
if (!route)
|
|
265
|
+
return;
|
|
266
|
+
this.removeRoute(route);
|
|
267
|
+
this.sendBrowser(route.browser, { type: "connection_closed", connectionId, reason });
|
|
268
|
+
}
|
|
269
|
+
handleBrowserMessage(socket, raw) {
|
|
270
|
+
const decoded = decodeBrowserCommand(raw);
|
|
271
|
+
if (!decoded.ok) {
|
|
272
|
+
this.sendBrowserError(socket, decoded.error.code, decoded.error.message);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
const command = decoded.value;
|
|
276
|
+
if (command.type === "connect_backend") {
|
|
277
|
+
this.connectBrowser(socket, command.backendId, command.ticket);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const route = this.browserRoutes.get(socket);
|
|
281
|
+
if (!route || route.connectionId !== command.connectionId) {
|
|
282
|
+
this.sendBrowserError(socket, "unauthorized_connection", "Connection does not belong to this browser", command.connectionId);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
const backend = this.backends.get(route.backendId);
|
|
286
|
+
if (!backend || backend.socket.readyState !== WebSocket.OPEN) {
|
|
287
|
+
this.removeRoute(route);
|
|
288
|
+
this.sendBrowserError(socket, "backend_offline", "Backend is offline", command.connectionId);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
if (command.type === "signal") {
|
|
292
|
+
if (command.signal.kind === "description") {
|
|
293
|
+
if (command.signal.type !== "offer") {
|
|
294
|
+
this.sendBrowserError(socket, "invalid_message", "Browser must send an SDP offer", command.connectionId);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
route.offerSdp = command.signal.sdp;
|
|
298
|
+
}
|
|
299
|
+
this.sendBackend(backend.socket, { type: "signal", connectionId: command.connectionId, signal: command.signal });
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
this.removeRoute(route);
|
|
303
|
+
this.sendBackend(backend.socket, {
|
|
304
|
+
type: "connection_closed",
|
|
305
|
+
connectionId: command.connectionId,
|
|
306
|
+
reason: command.reason ?? "Browser closed connection",
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
connectBrowser(socket, backendId, ticket) {
|
|
310
|
+
const backend = this.backends.get(backendId);
|
|
311
|
+
if (!backend || backend.socket.readyState !== WebSocket.OPEN) {
|
|
312
|
+
this.sendBrowserError(socket, "backend_offline", "Backend is offline");
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
let claims;
|
|
316
|
+
try {
|
|
317
|
+
claims = this.trustStore.consumeRouteTicket(ticket);
|
|
318
|
+
if (claims.backendId !== backendId)
|
|
319
|
+
throw new Error("Ticket targets another backend");
|
|
320
|
+
if (claims.kind === "pairing") {
|
|
321
|
+
const pairing = claims.pairId ? this.getOpenPairing(claims.pairId) : undefined;
|
|
322
|
+
if (!pairing || pairing.backendId !== backendId)
|
|
323
|
+
throw new Error("Pairing capability is missing or expired");
|
|
324
|
+
}
|
|
325
|
+
else if (!claims.accountId
|
|
326
|
+
|| this.trustStore.getBackendOwner(backendId) !== claims.accountId
|
|
327
|
+
|| !this.trustStore.isDeviceActive(claims.deviceId)) {
|
|
328
|
+
throw new Error("Ticket authorization was revoked");
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
catch (error) {
|
|
332
|
+
this.sendBrowserError(socket, "invalid_ticket", error instanceof Error ? error.message : String(error));
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
if (this.routes.has(claims.connectionId)) {
|
|
336
|
+
this.sendBrowserError(socket, "invalid_ticket", "Connection id is already active");
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
const previous = this.browserRoutes.get(socket);
|
|
340
|
+
if (previous) {
|
|
341
|
+
this.removeRoute(previous);
|
|
342
|
+
const previousBackend = this.backends.get(previous.backendId);
|
|
343
|
+
if (previousBackend) {
|
|
344
|
+
this.sendBackend(previousBackend.socket, {
|
|
345
|
+
type: "connection_closed",
|
|
346
|
+
connectionId: previous.connectionId,
|
|
347
|
+
reason: "Browser opened another connection",
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
const route = { connectionId: claims.connectionId, backendId, browser: socket, ticket, claims };
|
|
352
|
+
this.routes.set(route.connectionId, route);
|
|
353
|
+
this.browserRoutes.set(socket, route);
|
|
354
|
+
this.sendBackend(backend.socket, {
|
|
355
|
+
type: "connection_request",
|
|
356
|
+
connectionId: route.connectionId,
|
|
357
|
+
ticket,
|
|
358
|
+
iceServers: this.iceServerProvider.issue(backendId),
|
|
359
|
+
});
|
|
360
|
+
this.sendBrowser(socket, { type: "backend_connected", backendId, connectionId: route.connectionId });
|
|
361
|
+
}
|
|
362
|
+
routeForBackend(backend, connectionId) {
|
|
363
|
+
const route = this.routes.get(connectionId);
|
|
364
|
+
if (!route || route.backendId !== backend.backendId) {
|
|
365
|
+
this.sendError(backend.socket, "unauthorized_connection", "Connection does not belong to this backend", connectionId);
|
|
366
|
+
return undefined;
|
|
367
|
+
}
|
|
368
|
+
return route;
|
|
369
|
+
}
|
|
370
|
+
removeBackendSocket(socket) {
|
|
371
|
+
const handshake = this.backendHandshakes.get(socket);
|
|
372
|
+
if (!handshake)
|
|
373
|
+
return;
|
|
374
|
+
clearTimeout(handshake.timeout);
|
|
375
|
+
this.backendHandshakes.delete(socket);
|
|
376
|
+
if (handshake.registered && this.backends.get(handshake.registered.backendId)?.socket === socket) {
|
|
377
|
+
this.removeRegisteredBackend(handshake.registered, "Backend disconnected");
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
removeRegisteredBackend(backend, reason) {
|
|
381
|
+
if (this.backends.get(backend.backendId)?.socket === backend.socket)
|
|
382
|
+
this.backends.delete(backend.backendId);
|
|
383
|
+
for (const [pairId, pairing] of this.pairings) {
|
|
384
|
+
if (pairing.backendId === backend.backendId)
|
|
385
|
+
this.pairings.delete(pairId);
|
|
386
|
+
}
|
|
387
|
+
for (const route of [...this.routes.values()]) {
|
|
388
|
+
if (route.backendId !== backend.backendId)
|
|
389
|
+
continue;
|
|
390
|
+
this.removeRoute(route);
|
|
391
|
+
this.sendBrowser(route.browser, { type: "connection_closed", connectionId: route.connectionId, reason });
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
removeBrowser(socket, reason) {
|
|
395
|
+
const route = this.browserRoutes.get(socket);
|
|
396
|
+
if (!route)
|
|
397
|
+
return;
|
|
398
|
+
this.removeRoute(route);
|
|
399
|
+
const backend = this.backends.get(route.backendId);
|
|
400
|
+
if (backend)
|
|
401
|
+
this.sendBackend(backend.socket, { type: "connection_closed", connectionId: route.connectionId, reason });
|
|
402
|
+
}
|
|
403
|
+
removeRoute(route) {
|
|
404
|
+
if (this.routes.get(route.connectionId) === route)
|
|
405
|
+
this.routes.delete(route.connectionId);
|
|
406
|
+
if (this.browserRoutes.get(route.browser) === route)
|
|
407
|
+
this.browserRoutes.delete(route.browser);
|
|
408
|
+
}
|
|
409
|
+
sendBackend(socket, payload) {
|
|
410
|
+
this.send(socket, payload);
|
|
411
|
+
}
|
|
412
|
+
sendBrowser(socket, payload) {
|
|
413
|
+
this.send(socket, payload);
|
|
414
|
+
}
|
|
415
|
+
sendError(socket, code, message, connectionId) {
|
|
416
|
+
this.sendBackend(socket, { type: "error", code, message, connectionId });
|
|
417
|
+
}
|
|
418
|
+
sendBrowserError(socket, code, message, connectionId) {
|
|
419
|
+
this.sendBrowser(socket, { type: "error", code, message, connectionId });
|
|
420
|
+
}
|
|
421
|
+
send(socket, payload) {
|
|
422
|
+
if (socket.readyState !== WebSocket.OPEN)
|
|
423
|
+
return;
|
|
424
|
+
socket.send(encodeRendezvousMessage({ protocolVersion: RENDEZVOUS_PROTOCOL_VERSION, ...payload }));
|
|
425
|
+
}
|
|
426
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import express from "express";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { WebSocketServer } from "ws";
|
|
7
|
+
import { RENDEZVOUS_PROTOCOL_VERSION } from "../shared/rendezvous-protocol.js";
|
|
8
|
+
import { RendezvousHub } from "./rendezvous-hub.js";
|
|
9
|
+
import { IceServerProvider, RendezvousTrustStore, } from "./trust-store.js";
|
|
10
|
+
const BACKEND_PATH = `/v${RENDEZVOUS_PROTOCOL_VERSION}/rendezvous/backend`;
|
|
11
|
+
const BROWSER_PATH = `/v${RENDEZVOUS_PROTOCOL_VERSION}/rendezvous/browser`;
|
|
12
|
+
const MAX_SIGNAL_MESSAGE_BYTES = 256 * 1024;
|
|
13
|
+
const PING_INTERVAL_MS = 30_000;
|
|
14
|
+
export function createRendezvousServer(options = {}) {
|
|
15
|
+
const app = express();
|
|
16
|
+
const server = createServer(app);
|
|
17
|
+
const trustStore = options.trustStore ?? new RendezvousTrustStore(options);
|
|
18
|
+
const iceServerProvider = new IceServerProvider(options.iceServers, options.turn, options.now);
|
|
19
|
+
const hub = new RendezvousHub({
|
|
20
|
+
trustStore,
|
|
21
|
+
iceServerProvider,
|
|
22
|
+
registrationTimeoutMs: options.registrationTimeoutMs,
|
|
23
|
+
now: options.now,
|
|
24
|
+
});
|
|
25
|
+
const backendWss = new WebSocketServer({ noServer: true, maxPayload: MAX_SIGNAL_MESSAGE_BYTES });
|
|
26
|
+
const browserWss = new WebSocketServer({ noServer: true, maxPayload: MAX_SIGNAL_MESSAGE_BYTES });
|
|
27
|
+
const alive = new WeakMap();
|
|
28
|
+
app.use(express.json({ limit: "32kb" }));
|
|
29
|
+
app.use("/v1", (_request, response, next) => {
|
|
30
|
+
response.setHeader("Cache-Control", "no-store");
|
|
31
|
+
next();
|
|
32
|
+
});
|
|
33
|
+
const track = (socket) => {
|
|
34
|
+
alive.set(socket, true);
|
|
35
|
+
socket.on("pong", () => alive.set(socket, true));
|
|
36
|
+
};
|
|
37
|
+
backendWss.on("connection", (socket) => {
|
|
38
|
+
track(socket);
|
|
39
|
+
hub.acceptBackend(socket);
|
|
40
|
+
});
|
|
41
|
+
browserWss.on("connection", (socket) => {
|
|
42
|
+
track(socket);
|
|
43
|
+
hub.acceptBrowser(socket);
|
|
44
|
+
});
|
|
45
|
+
server.on("upgrade", (request, socket, head) => {
|
|
46
|
+
const pathname = new URL(request.url ?? "/", "http://localhost").pathname;
|
|
47
|
+
const target = pathname === BACKEND_PATH
|
|
48
|
+
? backendWss
|
|
49
|
+
: pathname === BROWSER_PATH
|
|
50
|
+
? browserWss
|
|
51
|
+
: undefined;
|
|
52
|
+
if (!target) {
|
|
53
|
+
socket.write("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n");
|
|
54
|
+
socket.destroy();
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
target.handleUpgrade(request, socket, head, (webSocket) => target.emit("connection", webSocket, request));
|
|
58
|
+
});
|
|
59
|
+
app.get("/health", (_request, response) => {
|
|
60
|
+
response.setHeader("Cache-Control", "no-store");
|
|
61
|
+
response.json({ ok: true });
|
|
62
|
+
});
|
|
63
|
+
app.get("/api/rendezvous/backends/:backendId", (request, response) => {
|
|
64
|
+
const backendId = request.params.backendId;
|
|
65
|
+
const online = hub.isBackendOnline(backendId);
|
|
66
|
+
const metadata = hub.getBackendMetadata(backendId);
|
|
67
|
+
response.status(online ? 200 : 404).json({ backendId, online, ...(metadata ? { metadata } : {}) });
|
|
68
|
+
});
|
|
69
|
+
app.get("/v1/pairings/:pairId", route((request, response) => {
|
|
70
|
+
const pairing = hub.getOpenPairing(param(request, "pairId"));
|
|
71
|
+
if (!pairing) {
|
|
72
|
+
response.status(404).json({ error: "Pairing capability is missing or expired" });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
response.json(pairing);
|
|
76
|
+
}));
|
|
77
|
+
app.post("/v1/auth/challenges", route((request, response) => {
|
|
78
|
+
const challengeRequest = request.body;
|
|
79
|
+
if (challengeRequest?.purpose === "pair") {
|
|
80
|
+
const pairing = challengeRequest.pairId ? hub.getOpenPairing(challengeRequest.pairId) : undefined;
|
|
81
|
+
if (!pairing || pairing.backendId !== challengeRequest.backendId) {
|
|
82
|
+
response.status(404).json({ error: "Pairing capability is missing or expired" });
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
else if (challengeRequest?.purpose === "connect"
|
|
87
|
+
&& challengeRequest.backendId
|
|
88
|
+
&& !hub.isBackendOnline(challengeRequest.backendId)) {
|
|
89
|
+
response.status(404).json({ error: "Backend is offline" });
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
response.json(trustStore.createChallenge(challengeRequest));
|
|
93
|
+
}));
|
|
94
|
+
app.post("/v1/pairings/:pairId/tickets", route((request, response) => {
|
|
95
|
+
const pairing = hub.getOpenPairing(param(request, "pairId"));
|
|
96
|
+
if (!pairing) {
|
|
97
|
+
response.status(404).json({ error: "Pairing capability is missing or expired" });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const { challengeId, signature } = requireSignatureBody(request.body);
|
|
101
|
+
const issued = trustStore.issuePairingTicket(challengeId, signature);
|
|
102
|
+
if (issued.claims.pairId !== pairing.pairId || issued.claims.backendId !== pairing.backendId) {
|
|
103
|
+
throw new Error("Device challenge does not match this pairing capability");
|
|
104
|
+
}
|
|
105
|
+
response.json({ ticket: issued.ticket, iceServers: iceServerProvider.issue(issued.claims.deviceId) });
|
|
106
|
+
}));
|
|
107
|
+
app.post("/v1/connections/tickets", route((request, response) => {
|
|
108
|
+
const { challengeId, signature } = requireSignatureBody(request.body);
|
|
109
|
+
const issued = trustStore.issueConnectionTicket(challengeId, signature);
|
|
110
|
+
response.json({ ticket: issued.ticket, iceServers: iceServerProvider.issue(issued.claims.deviceId) });
|
|
111
|
+
}));
|
|
112
|
+
app.post("/v1/accounts/backends", route((request, response) => {
|
|
113
|
+
const { challengeId, signature } = requireSignatureBody(request.body);
|
|
114
|
+
const backends = trustStore
|
|
115
|
+
.listAuthorizedBackendIds(challengeId, signature)
|
|
116
|
+
.map((backendId) => {
|
|
117
|
+
const metadata = hub.getBackendMetadata(backendId);
|
|
118
|
+
return {
|
|
119
|
+
backendId,
|
|
120
|
+
...(metadata?.name ? { name: metadata.name } : {}),
|
|
121
|
+
...(metadata?.softwareVersion ? { softwareVersion: metadata.softwareVersion } : {}),
|
|
122
|
+
protocolVersions: metadata?.protocolVersions ?? [],
|
|
123
|
+
online: hub.isBackendOnline(backendId),
|
|
124
|
+
};
|
|
125
|
+
});
|
|
126
|
+
response.json({ backends });
|
|
127
|
+
}));
|
|
128
|
+
app.post("/v1/revocations/devices", route((request, response) => {
|
|
129
|
+
const { challengeId, signature } = requireSignatureBody(request.body);
|
|
130
|
+
const result = trustStore.revokeDevice(challengeId, signature);
|
|
131
|
+
hub.notifyRevocation(result);
|
|
132
|
+
response.json({ ok: true });
|
|
133
|
+
}));
|
|
134
|
+
app.post("/v1/revocations/backends", route((request, response) => {
|
|
135
|
+
const { challengeId, signature } = requireSignatureBody(request.body);
|
|
136
|
+
const result = trustStore.revokeBackend(challengeId, signature);
|
|
137
|
+
hub.notifyRevocation(result);
|
|
138
|
+
response.json({ ok: true });
|
|
139
|
+
}));
|
|
140
|
+
const packagedClientDist = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../client");
|
|
141
|
+
const clientDist = options.clientDist === false
|
|
142
|
+
? undefined
|
|
143
|
+
: options.clientDist ?? (process.env.NODE_ENV === "production" ? packagedClientDist : undefined);
|
|
144
|
+
if (clientDist && existsSync(path.join(clientDist, "index.html"))) {
|
|
145
|
+
app.use(express.static(clientDist));
|
|
146
|
+
app.use((request, response, next) => {
|
|
147
|
+
if (request.method === "GET" && (/^\/pair\/[^/]+$/u.test(request.path) || /^\/backend\/[^/]+$/u.test(request.path))) {
|
|
148
|
+
response.sendFile(path.join(clientDist, "index.html"));
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
next();
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
// Translate expected trust failures into stable JSON without exposing stacks.
|
|
155
|
+
app.use((error, _request, response, _next) => {
|
|
156
|
+
response.status(400).json({ error: error instanceof Error ? error.message : "Invalid request" });
|
|
157
|
+
});
|
|
158
|
+
const pingInterval = setInterval(() => {
|
|
159
|
+
for (const wss of [backendWss, browserWss]) {
|
|
160
|
+
for (const socket of wss.clients) {
|
|
161
|
+
if (alive.get(socket) === false) {
|
|
162
|
+
socket.terminate();
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
alive.set(socket, false);
|
|
166
|
+
socket.ping();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}, options.pingIntervalMs ?? PING_INTERVAL_MS);
|
|
170
|
+
pingInterval.unref?.();
|
|
171
|
+
return {
|
|
172
|
+
app,
|
|
173
|
+
server,
|
|
174
|
+
hub,
|
|
175
|
+
trustStore,
|
|
176
|
+
listen(port = 0, host = "127.0.0.1") {
|
|
177
|
+
return new Promise((resolve, reject) => {
|
|
178
|
+
const onError = (error) => reject(error);
|
|
179
|
+
server.once("error", onError);
|
|
180
|
+
server.listen(port, host, () => {
|
|
181
|
+
server.off("error", onError);
|
|
182
|
+
const address = server.address();
|
|
183
|
+
if (!address || typeof address === "string") {
|
|
184
|
+
reject(new Error("Rendezvous server did not bind a TCP port"));
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
resolve(address.port);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
},
|
|
191
|
+
async close() {
|
|
192
|
+
clearInterval(pingInterval);
|
|
193
|
+
hub.close();
|
|
194
|
+
for (const wss of [backendWss, browserWss]) {
|
|
195
|
+
for (const socket of wss.clients)
|
|
196
|
+
socket.terminate();
|
|
197
|
+
}
|
|
198
|
+
if (!server.listening)
|
|
199
|
+
return;
|
|
200
|
+
await new Promise((resolve, reject) => {
|
|
201
|
+
server.close((error) => error ? reject(error) : resolve());
|
|
202
|
+
});
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
function route(handler) {
|
|
207
|
+
return (request, response, next) => {
|
|
208
|
+
Promise.resolve(handler(request, response)).catch(next);
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
function param(request, name) {
|
|
212
|
+
const value = request.params[name];
|
|
213
|
+
if (typeof value !== "string" || !value)
|
|
214
|
+
throw new Error(`Missing ${name}`);
|
|
215
|
+
return value;
|
|
216
|
+
}
|
|
217
|
+
function requireSignatureBody(value) {
|
|
218
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
219
|
+
throw new Error("Request body must be an object");
|
|
220
|
+
const body = value;
|
|
221
|
+
if (typeof body.challengeId !== "string" || !body.challengeId || typeof body.signature !== "string" || !body.signature) {
|
|
222
|
+
throw new Error("challengeId and signature are required");
|
|
223
|
+
}
|
|
224
|
+
return { challengeId: body.challengeId, signature: body.signature };
|
|
225
|
+
}
|
|
226
|
+
function envUrls(name) {
|
|
227
|
+
return (process.env[name] ?? "").split(",").map((value) => value.trim()).filter(Boolean);
|
|
228
|
+
}
|
|
229
|
+
const isMain = process.argv[1]
|
|
230
|
+
&& path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url));
|
|
231
|
+
if (isMain) {
|
|
232
|
+
const port = Number.parseInt(process.env.PORT || "8787", 10);
|
|
233
|
+
const host = process.env.HOST || "0.0.0.0";
|
|
234
|
+
const stunUrls = envUrls("PIPANE_STUN_URLS");
|
|
235
|
+
const turnUrls = envUrls("PIPANE_TURN_URLS");
|
|
236
|
+
const turnSecret = process.env.PIPANE_TURN_SECRET;
|
|
237
|
+
const rendezvous = createRendezvousServer({
|
|
238
|
+
iceServers: stunUrls.length > 0 ? [{ urls: stunUrls }] : [],
|
|
239
|
+
turn: turnUrls.length > 0 && turnSecret ? { urls: turnUrls, secret: turnSecret } : undefined,
|
|
240
|
+
});
|
|
241
|
+
void rendezvous.listen(port, host).then((boundPort) => {
|
|
242
|
+
console.log(`pipane rendezvous listening on http://${host}:${boundPort}`);
|
|
243
|
+
}).catch((error) => {
|
|
244
|
+
console.error(error);
|
|
245
|
+
process.exitCode = 1;
|
|
246
|
+
});
|
|
247
|
+
}
|