replica-bridge 0.3.19 → 0.3.22
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/package.json +1 -1
- package/src/bridge/board.js +12 -5
- package/src/bridge/main.js +126 -5
- package/src/bridge/pair.js +2 -0
- package/src/bridge/ring.js +427 -0
- package/src/bridge/service.js +402 -25
- package/src/bridge/state.js +53 -1
- package/src/http.js +5 -2
- package/src/protocol.js +704 -3
- package/src/relay/config.js +8 -0
package/src/bridge/service.js
CHANGED
|
@@ -5,19 +5,28 @@
|
|
|
5
5
|
// the app-server says, so a phone that drops off the network resumes where it left off.
|
|
6
6
|
|
|
7
7
|
import crypto from "node:crypto";
|
|
8
|
+
import fs from "node:fs";
|
|
8
9
|
import os from "node:os";
|
|
9
10
|
import { Duplex } from "node:stream";
|
|
10
11
|
import { WebSocket } from "ws";
|
|
11
12
|
|
|
12
13
|
import {
|
|
13
|
-
CloseCode, FrameCipher, ChunkAssembler, MAX_FRAME_BYTES, REASSEMBLED_MAX_BYTES, RELAY_REPLY_LIMIT, StreamBuffer, encodeLine, b64url, fromB64url,
|
|
14
|
+
CloseCode, FrameCipher, ChunkAssembler, MAX_FRAME_BYTES, REASSEMBLED_MAX_BYTES, RELAY_REPLY_LIMIT, RING_ENTRIES_PER_FRAME, StreamBuffer, encodeLine, b64url, fromB64url,
|
|
15
|
+
RingState, generateKeyPair, hostSessionKeys, ringB64,
|
|
14
16
|
} from "../protocol.js";
|
|
15
|
-
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
16
20
|
import { relaySocketURL } from "../http.js";
|
|
17
21
|
import { BoardReader } from "./board.js";
|
|
18
22
|
import { readWork, workDirectories } from "./work.js";
|
|
19
23
|
import { killCommand, spawnCommand } from "./command.js";
|
|
20
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
LogGrowth, PENDING_LOG_MAX_BYTES, RING_LOG_MAX_ENTRIES, RelayRingStore, RingCache, acceptAnchor, admit, anchorPrompt, checkAnchor, isKey32, noteRingDevice, recordPending, rejectAnchor,
|
|
26
|
+
replayAll, storeLog, wireEntries,
|
|
27
|
+
} from "./ring.js";
|
|
28
|
+
|
|
29
|
+
import { hostKeyPair, loadState, statePathForWatch, updateState } from "./state.js";
|
|
21
30
|
import { annotateWriterError } from "./writer.js";
|
|
22
31
|
|
|
23
32
|
|
|
@@ -60,6 +69,13 @@ import { annotateWriterError } from "./writer.js";
|
|
|
60
69
|
|
|
61
70
|
|
|
62
71
|
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
|
|
63
79
|
|
|
64
80
|
|
|
65
81
|
|
|
@@ -79,6 +95,8 @@ import { annotateWriterError } from "./writer.js";
|
|
|
79
95
|
|
|
80
96
|
|
|
81
97
|
|
|
98
|
+
|
|
99
|
+
|
|
82
100
|
|
|
83
101
|
|
|
84
102
|
|
|
@@ -90,6 +108,22 @@ import { annotateWriterError } from "./writer.js";
|
|
|
90
108
|
|
|
91
109
|
|
|
92
110
|
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
|
|
93
127
|
|
|
94
128
|
|
|
95
129
|
export class BridgeService {
|
|
@@ -103,6 +137,16 @@ export class BridgeService {
|
|
|
103
137
|
|
|
104
138
|
options ;
|
|
105
139
|
board ;
|
|
140
|
+
ringCache = new RingCache();
|
|
141
|
+
relayRing = new RelayRingStore(() => loadState());
|
|
142
|
+
/** What connected ring peers last heard from this bridge, so a change is told once. */
|
|
143
|
+
ringTold = "";
|
|
144
|
+
/** The ring this bridge last saw itself anchored to, to catch up once a new anchor is confirmed. */
|
|
145
|
+
anchoredId ;
|
|
146
|
+
/** The newest relay push not handled yet, and whether one is being handled: pushes never pile up. */
|
|
147
|
+
nextPush = null;
|
|
148
|
+
pushRunning = false;
|
|
149
|
+
watching = null;
|
|
106
150
|
|
|
107
151
|
constructor(options = {}) {
|
|
108
152
|
this.options = options;
|
|
@@ -111,11 +155,16 @@ export class BridgeService {
|
|
|
111
155
|
}
|
|
112
156
|
|
|
113
157
|
start() {
|
|
158
|
+
try { this.anchoredId = loadState().ring?.id ?? null; } catch { this.anchoredId = null; /* not set up: connectRelay says so */ }
|
|
114
159
|
this.connectRelay();
|
|
160
|
+
// `replica-bridge ring accept`, `ring reset` and `revoke` run as other processes and change state.json.
|
|
161
|
+
this.watching = statePathForWatch();
|
|
162
|
+
fs.watchFile(this.watching, { interval: 1000 }, () => this.ringChanged());
|
|
115
163
|
}
|
|
116
164
|
|
|
117
165
|
stop() {
|
|
118
166
|
this.stopped = true;
|
|
167
|
+
if (this.watching) { fs.unwatchFile(this.watching); this.watching = null; }
|
|
119
168
|
if (this.retryTimer) clearTimeout(this.retryTimer);
|
|
120
169
|
this.relay?.close();
|
|
121
170
|
this.board.stop();
|
|
@@ -157,7 +206,8 @@ export class BridgeService {
|
|
|
157
206
|
|
|
158
207
|
connectRelay() {
|
|
159
208
|
const state = loadState();
|
|
160
|
-
|
|
209
|
+
// ring=1: this bridge reads `key` in `open` and the relay's ring pushes; a relay without the ring ignores it.
|
|
210
|
+
const ws = new WebSocket(relaySocketURL(state.relay, "/v1/host?ring=1"), { headers: { authorization: `Bearer ${state.hostToken}` }, maxPayload: MAX_FRAME_BYTES + 4096, perMessageDeflate: false });
|
|
161
211
|
this.relay = ws;
|
|
162
212
|
let refused = false;
|
|
163
213
|
// A laptop that sleeps or changes networks keeps a socket the relay dropped long ago, and nothing here would
|
|
@@ -178,6 +228,8 @@ export class BridgeService {
|
|
|
178
228
|
this.log(`connected to relay ${state.relay} as ${state.hostId}`);
|
|
179
229
|
this.options.onRelayConnected?.(true);
|
|
180
230
|
this.relayChanged();
|
|
231
|
+
this.relayRing.forget();
|
|
232
|
+
void this.catchUpFromRelay();
|
|
181
233
|
});
|
|
182
234
|
ws.on("message", (data ) => {
|
|
183
235
|
let envelope ;
|
|
@@ -243,55 +295,98 @@ export class BridgeService {
|
|
|
243
295
|
this.conns.delete(conn.id);
|
|
244
296
|
}
|
|
245
297
|
|
|
246
|
-
onEnvelope(envelope
|
|
298
|
+
onEnvelope(envelope ) {
|
|
247
299
|
if (envelope.type === "open") {
|
|
248
|
-
|
|
300
|
+
const offered = envelope.key;
|
|
301
|
+
this.conns.set(envelope.conn, {
|
|
302
|
+
id: envelope.conn, deviceId: envelope.device, cipher: null, stream: null, queue: Promise.resolve(), boardUnsubscribe: null,
|
|
303
|
+
offeredKey: isKey32(offered) ? fromB64url(offered) : null, admitted: null, ringPeer: false, held: null, ringWaiter: null, nextHead: null, headRunning: false, peerRing: null, peerSeq: -1,
|
|
304
|
+
});
|
|
249
305
|
} else if (envelope.type === "close") {
|
|
250
306
|
const conn = this.conns.get(envelope.conn);
|
|
251
307
|
if (conn) { this.detach(conn); this.conns.delete(conn.id); }
|
|
252
308
|
} else if (envelope.type === "frame") {
|
|
253
309
|
const conn = this.conns.get(envelope.conn);
|
|
254
310
|
if (!conn) return;
|
|
255
|
-
conn
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
this.closeConn(conn, "protocol error");
|
|
259
|
-
});
|
|
311
|
+
this.enqueue(conn, () => this.onFrame(conn, envelope.frame));
|
|
312
|
+
} else if (envelope.type === "ring") {
|
|
313
|
+
void this.onRingPush(envelope);
|
|
260
314
|
}
|
|
261
315
|
}
|
|
262
316
|
|
|
263
|
-
/**
|
|
264
|
-
|
|
317
|
+
/** Runs work in this connection's order; a failure is a protocol error that ends the connection. */
|
|
318
|
+
enqueue(conn , work ) {
|
|
319
|
+
conn.queue = conn.queue.then(work).catch((error) => {
|
|
320
|
+
this.log(`connection ${conn.id} from ${conn.deviceId}: ${(error ).message}`);
|
|
321
|
+
this.sendFrame(conn, { type: "error", code: "protocol", message: "the bridge could not handle a frame" });
|
|
322
|
+
this.closeConn(conn, "protocol error");
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Who this device is: paired here by QR code, or an active signer of the anchored ring (ring.ts admit). A device
|
|
328
|
+
* paired a moment ago may not be saved yet, so look again briefly; one the ring revoked is refused at once.
|
|
329
|
+
*/
|
|
330
|
+
async admitDevice(conn ) {
|
|
331
|
+
let refused = "";
|
|
265
332
|
for (let attempt = 0; attempt < 6; attempt++) {
|
|
266
333
|
const state = loadState();
|
|
267
|
-
const
|
|
268
|
-
|
|
334
|
+
const ring = this.ringCache.get(state.ring, this.log);
|
|
335
|
+
// Every handshake looks at the log again, so a revocation another process stored ends live connections too.
|
|
336
|
+
this.enforceRing(state, ring);
|
|
337
|
+
const result = admit(state, ring, hostKeyPair(state).publicKey, conn.deviceId, conn.offeredKey);
|
|
338
|
+
if (!("refused" in result)) return { state, admitted: result };
|
|
339
|
+
refused = result.refused;
|
|
340
|
+
// Only an unknown device may be one paired a moment ago; the ring's refusals are final.
|
|
341
|
+
if (state.devices.some((d) => d.deviceId === conn.deviceId) || !refused.startsWith("not paired")) break;
|
|
269
342
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
270
343
|
}
|
|
271
|
-
return
|
|
344
|
+
return { refused };
|
|
272
345
|
}
|
|
273
346
|
|
|
274
347
|
async onFrame(conn , frame ) {
|
|
275
348
|
if (frame.type === "hello") {
|
|
276
349
|
if (conn.cipher) throw new Error("second hello");
|
|
277
|
-
const
|
|
278
|
-
if (
|
|
279
|
-
this.
|
|
350
|
+
const found = await this.admitDevice(conn);
|
|
351
|
+
if ("refused" in found) {
|
|
352
|
+
this.log(`refused ${conn.deviceId}: ${found.refused}`);
|
|
353
|
+
this.sendFrame(conn, { type: "error", code: "unknown_device", message: found.refused === "not paired with this computer" ? "this phone is not paired with this computer" : `this device may not connect: ${found.refused}` });
|
|
280
354
|
return this.closeConn(conn, "unknown device");
|
|
281
355
|
}
|
|
282
356
|
const deviceEphemeral = fromB64url(frame.e);
|
|
283
357
|
if (deviceEphemeral.length !== 32) throw new Error("bad ephemeral key");
|
|
284
358
|
const ephemeral = generateKeyPair();
|
|
285
|
-
const keys = hostSessionKeys(hostKeyPair(
|
|
359
|
+
const keys = hostSessionKeys(hostKeyPair(found.state), ephemeral, found.admitted.key, deviceEphemeral);
|
|
286
360
|
conn.cipher = new FrameCipher(keys.hostToDevice, keys.deviceToHost, keys.transcript);
|
|
361
|
+
conn.admitted = found.admitted;
|
|
362
|
+
if (found.admitted.via === "ring") {
|
|
363
|
+
this.log(`${conn.deviceId} connects as ${found.admitted.name}, a signer of this computer's device ring`);
|
|
364
|
+
// Kept so `replica-bridge revoke <deviceId>` can refuse this key here, though it was never paired by QR code.
|
|
365
|
+
const { deviceId } = conn, { key, name } = found.admitted;
|
|
366
|
+
if (noteRingDevice(loadState(), deviceId, key, name)) updateState((state) => { noteRingDevice(state, deviceId, key, name); });
|
|
367
|
+
}
|
|
287
368
|
this.sendFrame(conn, { type: "welcome", v: 1, e: b64url(ephemeral.publicKey) });
|
|
288
369
|
return;
|
|
289
370
|
}
|
|
290
371
|
if (frame.type !== "data" || !conn.cipher) throw new Error(`unexpected ${frame.type} frame`);
|
|
291
372
|
const message = conn.cipher.openFrame(frame);
|
|
373
|
+
if (message.t === "ring/head" || message.t === "ring/get" || message.t === "ring/entries") return this.onRingMessage(conn, message);
|
|
374
|
+
// A ring peer's messages wait until its ring/head is handled (and any entries it has that this computer lacks).
|
|
375
|
+
if (conn.held) {
|
|
376
|
+
conn.held.messages.push(message);
|
|
377
|
+
conn.held.bytes += frame.c.length;
|
|
378
|
+
// A peer that keeps sending without ever saying its head is not held without limit.
|
|
379
|
+
if (conn.held.messages.length >= HELD_MESSAGES_MAX || conn.held.bytes >= HELD_BYTES_MAX) this.releaseHeld(conn);
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
await this.onMessage(conn, message);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async onMessage(conn , message ) {
|
|
292
386
|
if (message.t === "resume") {
|
|
293
387
|
if (conn.stream) throw new Error("second resume");
|
|
294
388
|
await this.resume(conn, message.stream, message.ack, message.seg === 1);
|
|
389
|
+
if (message.ring === 1 && conn.stream) this.holdForRing(conn);
|
|
295
390
|
} else if (message.t === "msg") {
|
|
296
391
|
const stream = conn.stream;
|
|
297
392
|
if (!stream) throw new Error("message before resume");
|
|
@@ -328,24 +423,298 @@ export class BridgeService {
|
|
|
328
423
|
}
|
|
329
424
|
}
|
|
330
425
|
|
|
426
|
+
// MARK: - Device ring (ring.ts, PROTOCOL.md "设备环:电脑一侧")
|
|
427
|
+
|
|
428
|
+
get ringWaitMs() { return (this.options.ringWaitSeconds ?? 10) * 1000; }
|
|
429
|
+
|
|
430
|
+
/** The pending anchors, as a person here is asked about them. */
|
|
431
|
+
pendingAnchors() { return (loadState().ringPending ?? []).map(anchorPrompt); }
|
|
432
|
+
|
|
433
|
+
/** A person here answered an anchor request (the terminal's y/n, the desktop app's dialog); `ring` is its id. */
|
|
434
|
+
answerAnchor(ring , accept ) {
|
|
435
|
+
let removed = [];
|
|
436
|
+
if (accept) {
|
|
437
|
+
removed = acceptAnchor(ring, hostKeyPair(loadState()).publicKey).removed;
|
|
438
|
+
this.log(`device ring: anchored to ring ${ring}${removed.length ? `; removed ${removed.join(", ")}, revoked in the ring, from the paired devices` : ""}`);
|
|
439
|
+
} else {
|
|
440
|
+
rejectAnchor(ring);
|
|
441
|
+
this.log(`device ring: rejected the request to anchor to ring ${ring}`);
|
|
442
|
+
}
|
|
443
|
+
this.ringChanged();
|
|
444
|
+
return { removed };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** Holds a ring peer's messages until its ring/head is handled, or for at most twice the wait. */
|
|
448
|
+
holdForRing(conn ) {
|
|
449
|
+
conn.ringPeer = true;
|
|
450
|
+
if (conn.held) return;
|
|
451
|
+
conn.held = { messages: [], bytes: 0, timer: setTimeout(() => this.releaseHeld(conn), this.ringWaitMs * 2), opening: false };
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Handles the held messages in order, behind whatever this connection already queued. */
|
|
455
|
+
releaseHeld(conn ) {
|
|
456
|
+
const held = conn.held;
|
|
457
|
+
if (!held || held.opening) return;
|
|
458
|
+
held.opening = true;
|
|
459
|
+
clearTimeout(held.timer);
|
|
460
|
+
this.enqueue(conn, async () => {
|
|
461
|
+
while (held.messages.length) {
|
|
462
|
+
if (this.conns.get(conn.id) !== conn) return;
|
|
463
|
+
await this.onMessage(conn, held.messages.shift() );
|
|
464
|
+
}
|
|
465
|
+
if (conn.held === held) conn.held = null;
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
onRingMessage(conn , message ) {
|
|
470
|
+
conn.ringPeer = true;
|
|
471
|
+
if (message.t === "ring/entries") {
|
|
472
|
+
const waiter = conn.ringWaiter;
|
|
473
|
+
conn.ringWaiter = null;
|
|
474
|
+
waiter?.(message);
|
|
475
|
+
} else if (message.t === "ring/get") {
|
|
476
|
+
this.answerRingGet(conn, message);
|
|
477
|
+
} else {
|
|
478
|
+
// Not awaited: catching up asks the peer for entries, and its answers arrive behind this frame.
|
|
479
|
+
conn.nextHead = message;
|
|
480
|
+
if (conn.headRunning) return;
|
|
481
|
+
conn.headRunning = true;
|
|
482
|
+
void (async () => {
|
|
483
|
+
while (conn.nextHead) {
|
|
484
|
+
const head = conn.nextHead;
|
|
485
|
+
conn.nextHead = null;
|
|
486
|
+
try { await this.onPeerHead(conn, head); } catch (error) { this.log(`device ring: ${conn.deviceId}: ${(error ).message}`); }
|
|
487
|
+
this.releaseHeld(conn);
|
|
488
|
+
}
|
|
489
|
+
conn.headRunning = false;
|
|
490
|
+
})();
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
answerRingGet(conn , message ) {
|
|
495
|
+
const state = loadState();
|
|
496
|
+
const ring = this.ringCache.get(state.ring, this.log);
|
|
497
|
+
const after = message.after ?? -1;
|
|
498
|
+
if (!state.ring || !ring || message.ring !== state.ring.id || !Number.isSafeInteger(after) || after < -1) {
|
|
499
|
+
return this.sendInner(conn, { t: "ring/entries", ring: String(message.ring), entries: [] });
|
|
500
|
+
}
|
|
501
|
+
const valid = state.ring.entries.slice(0, ring.seq + 1);
|
|
502
|
+
const entries = valid.slice(after + 1, after + 1 + RING_ENTRIES_PER_FRAME);
|
|
503
|
+
this.sendInner(conn, { t: "ring/entries", ring: state.ring.id, entries, ...(after + 1 + entries.length < valid.length ? { more: true } : {}) });
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
ownHead(state , ring , peerRing ) {
|
|
507
|
+
if (state.ring && ring) return { t: "ring/head", ring: state.ring.id, seq: ring.seq, id: b64url(ring.head) };
|
|
508
|
+
const pending = !state.ring && peerRing ? state.ringPending?.find((p) => p.ring === peerRing) : undefined;
|
|
509
|
+
return { t: "ring/head", ring: null, ...(pending ? { pending: pending.ring } : {}) };
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
async onPeerHead(conn , head ) {
|
|
513
|
+
const peerRing = isKey32(head.ring) ? head.ring : null;
|
|
514
|
+
const seq = typeof head.seq === "number" && Number.isSafeInteger(head.seq) && head.seq >= 0 ? head.seq : null;
|
|
515
|
+
conn.peerRing = peerRing;
|
|
516
|
+
conn.peerSeq = seq ?? -1;
|
|
517
|
+
const state = loadState();
|
|
518
|
+
if (state.ring) {
|
|
519
|
+
// An anchored computer listens to its own ring only.
|
|
520
|
+
if (peerRing === state.ring.id && seq !== null) await this.catchUp(peerRing, seq, isKey32(head.id) ? head.id : null, conn);
|
|
521
|
+
} else if (peerRing && seq !== null && isKey32(head.id) && isKey32(head.key) && conn.admitted?.via === "qr") {
|
|
522
|
+
await this.requestAnchor(conn, peerRing, seq, head.id, head.key);
|
|
523
|
+
}
|
|
524
|
+
if (this.conns.get(conn.id) !== conn) return;
|
|
525
|
+
const now = loadState();
|
|
526
|
+
this.sendInner(conn, this.ownHead(now, this.ringCache.get(now.ring, this.log), peerRing));
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Brings the stored log up to `until`: from the peer over the session first, then from the relay when it has the
|
|
531
|
+
* ring endpoints. Every entry is applied with RingState as it arrives (LogGrowth); the first that does not apply
|
|
532
|
+
* ends that source (a fork is only logged: fork handling is design only). Fetching holds no lock, so a slow peer
|
|
533
|
+
* delays nobody else; what was verified is merged into the stored log in one synchronous step (storeLog), and
|
|
534
|
+
* revocations in it take effect at once.
|
|
535
|
+
*/
|
|
536
|
+
async catchUp(ringId , until , untilId , conn ) {
|
|
537
|
+
const state = loadState();
|
|
538
|
+
if (state.ring?.id !== ringId || state.ring.stalled) return;
|
|
539
|
+
const base = state.ring.entries;
|
|
540
|
+
let working ;
|
|
541
|
+
try { working = replayAll(base); } catch (error) { return this.log(`device ring: the stored log does not replay, so it cannot grow: ${(error ).message}`); }
|
|
542
|
+
if (until <= working.seq) {
|
|
543
|
+
if (until === working.seq && untilId && untilId !== b64url(working.head)) this.log(`device ring: ${conn?.deviceId ?? "the relay"} has another entry at seq ${until} (a fork?); keeping this computer's`);
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
const growth = new LogGrowth(working, base, until);
|
|
547
|
+
if (conn) {
|
|
548
|
+
try { await this.pullFromPeer(conn, ringId, growth); } catch (error) { this.log(`device ring: ${conn.deviceId}: ${(error ).message}`); }
|
|
549
|
+
if (growth.stopped && !growth.full) { this.log(`device ring: ${growth.stopped}`); growth.stopped = null; }
|
|
550
|
+
}
|
|
551
|
+
if (!growth.done && await this.relayRing.supported()) {
|
|
552
|
+
try { await this.relayRing.fetchInto(ringId, growth); } catch (error) { this.log(`device ring: could not catch up from the relay: ${(error ).message}`); }
|
|
553
|
+
if (growth.stopped && !growth.full) this.log(`device ring: ${growth.stopped}`);
|
|
554
|
+
}
|
|
555
|
+
if (growth.state.seq === until && untilId && untilId !== b64url(growth.state.head)) this.log(`device ring: the head at seq ${until} is not the one ${conn?.deviceId ?? "the relay"} named (a fork?)`);
|
|
556
|
+
const stalled = growth.full ? growth.stopped : null;
|
|
557
|
+
if (stalled) this.log(`device ring: ${stalled} while there is more; the ring lets nobody in until "replica-bridge ring reset"`);
|
|
558
|
+
if (!growth.added.length && !stalled) return;
|
|
559
|
+
const stored = storeLog(ringId, base, growth.added, stalled);
|
|
560
|
+
if (stored) this.log(`device ring: caught up to seq ${stored.seq}${stored.removed.length ? `; removed ${stored.removed.join(", ")}, revoked in the ring, from the paired devices` : ""}`);
|
|
561
|
+
this.ringChanged();
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** After the relay socket opens, or a new anchor: entries the relay has that this computer missed. */
|
|
565
|
+
async catchUpFromRelay() {
|
|
566
|
+
try {
|
|
567
|
+
const state = loadState();
|
|
568
|
+
if (!state.ring || !(await this.relayRing.supported())) return;
|
|
569
|
+
await this.catchUp(state.ring.id, Number.MAX_SAFE_INTEGER, null, null);
|
|
570
|
+
} catch (error) {
|
|
571
|
+
this.log(`device ring: ${(error ).message}`);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** Handles the newest push only; pushes that arrive meanwhile replace each other. */
|
|
576
|
+
async onRingPush(push ) {
|
|
577
|
+
if (!Number.isSafeInteger(push.seq) || typeof push.ring !== "string") return;
|
|
578
|
+
if (!this.nextPush || push.seq > this.nextPush.seq) this.nextPush = push;
|
|
579
|
+
if (this.pushRunning) return;
|
|
580
|
+
this.pushRunning = true;
|
|
581
|
+
try {
|
|
582
|
+
while (this.nextPush) {
|
|
583
|
+
const next = this.nextPush;
|
|
584
|
+
this.nextPush = null;
|
|
585
|
+
try {
|
|
586
|
+
const state = loadState();
|
|
587
|
+
if (state.ring && next.ring === state.ring.id) await this.catchUp(next.ring, next.seq, isKey32(next.id) ? next.id : null, null);
|
|
588
|
+
} catch (error) {
|
|
589
|
+
this.log(`device ring: ${(error ).message}`);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
} finally {
|
|
593
|
+
this.pushRunning = false;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** Asks the peer for pages after the growth's head until it is done, the peer has no more, or a page brings nothing. */
|
|
598
|
+
async pullFromPeer(conn , ringId , growth ) {
|
|
599
|
+
while (!growth.done) {
|
|
600
|
+
const after = growth.state.seq;
|
|
601
|
+
const answer = await this.askPeer(conn, { t: "ring/get", ring: ringId, ...(after >= 0 ? { after } : {}) });
|
|
602
|
+
const page = answer.ring === ringId ? wireEntries(answer.entries) : null;
|
|
603
|
+
if (!page) throw new Error("the peer answered ring/get with something else");
|
|
604
|
+
if (!growth.take(page, conn.deviceId) || !answer.more || !page.length) return;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
askPeer(conn , message ) {
|
|
609
|
+
return new Promise((resolve, reject) => {
|
|
610
|
+
if (this.conns.get(conn.id) !== conn) return reject(new Error("the peer left"));
|
|
611
|
+
const timer = setTimeout(() => {
|
|
612
|
+
if (conn.ringWaiter === done) conn.ringWaiter = null;
|
|
613
|
+
reject(new Error("the peer did not answer ring/get in time"));
|
|
614
|
+
}, this.ringWaitMs);
|
|
615
|
+
const done = (answer ) => { clearTimeout(timer); resolve(answer); };
|
|
616
|
+
conn.ringWaiter = done;
|
|
617
|
+
this.sendInner(conn, message);
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* A paired signer named its ring and this computer has none yet: fetch the whole log (checked as it arrives, at most
|
|
623
|
+
* PENDING_LOG_MAX_BYTES), check the anchoring conditions (ring.ts checkAnchor) and record a pending anchor for a
|
|
624
|
+
* person here to confirm. Nothing else changes.
|
|
625
|
+
*/
|
|
626
|
+
async requestAnchor(conn , ringId , seq , headId , signingKey ) {
|
|
627
|
+
const admitted = conn.admitted ;
|
|
628
|
+
if (seq >= RING_LOG_MAX_ENTRIES) return this.log(`device ring: ${admitted.name}'s ring is longer than this bridge takes`);
|
|
629
|
+
let growth = new LogGrowth(new RingState(), [], seq, PENDING_LOG_MAX_BYTES);
|
|
630
|
+
try { await this.pullFromPeer(conn, ringId, growth); } catch (error) { this.log(`device ring: ${admitted.name}: ${(error ).message}`); }
|
|
631
|
+
if (growth.state.seq < seq && !growth.full && await this.relayRing.supported()) {
|
|
632
|
+
growth = new LogGrowth(new RingState(), [], seq, PENDING_LOG_MAX_BYTES);
|
|
633
|
+
try { await this.relayRing.fetchInto(ringId, growth); } catch (error) { this.log(`device ring: could not read ring ${ringId} from the relay: ${(error ).message}`); }
|
|
634
|
+
}
|
|
635
|
+
const entries = growth.added;
|
|
636
|
+
const state = loadState();
|
|
637
|
+
const checked = growth.full ? { refused: "the log is too long" } : checkAnchor(state, hostKeyPair(state).publicKey, { deviceId: conn.deviceId, key: admitted.key, signingKey: ringB64(signingKey), ring: ringId, entries });
|
|
638
|
+
const refused = "refused" in checked ? checked.refused : b64url(checked.ring.head) !== headId ? "the log does not end at the head it named" : null;
|
|
639
|
+
if (refused) return this.log(`device ring: not asking about ${admitted.name}'s request to anchor to ring ${ringId}: ${refused}`);
|
|
640
|
+
const fresh = !state.ringPending?.some((p) => p.ring === ringId);
|
|
641
|
+
const pending = { ring: ringId, entries, requester: { deviceId: conn.deviceId, name: admitted.name, key: b64url(admitted.key), signingKey }, requestedAt: new Date().toISOString() };
|
|
642
|
+
if (!recordPending(pending)) return this.log(`device ring: not recording ${admitted.name}'s request: this computer is anchored, or too many rings are waiting`);
|
|
643
|
+
if (!fresh) return;
|
|
644
|
+
const prompt = anchorPrompt(pending);
|
|
645
|
+
this.log(`device ring: ${prompt.requester} asks to anchor this computer to its ring (fingerprint ${prompt.fingerprint}); confirm with "npx replica-bridge ring accept"`);
|
|
646
|
+
this.options.onAnchorRequest?.(prompt);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/** Applies what state.json says now: drops whoever the ring no longer lets in, and tells ring peers a new head. */
|
|
650
|
+
ringChanged() {
|
|
651
|
+
let state ;
|
|
652
|
+
try { state = loadState(); } catch { return; }
|
|
653
|
+
const ring = this.ringCache.get(state.ring, this.log);
|
|
654
|
+
this.enforceRing(state, ring);
|
|
655
|
+
const anchored = state.ring?.id ?? null;
|
|
656
|
+
if (anchored !== this.anchoredId) {
|
|
657
|
+
const first = this.anchoredId === undefined;
|
|
658
|
+
this.anchoredId = anchored;
|
|
659
|
+
// A new anchor carries the log as the requester sent it, maybe hours ago: revocations since then come first.
|
|
660
|
+
if (anchored && !first) void this.catchUpAfterAnchor(anchored);
|
|
661
|
+
}
|
|
662
|
+
const told = state.ring && ring ? `${state.ring.id}|${b64url(ring.head)}` : `-|${(state.ringPending ?? []).map((p) => p.ring).join(",")}`;
|
|
663
|
+
if (told === this.ringTold) return;
|
|
664
|
+
this.ringTold = told;
|
|
665
|
+
for (const conn of this.conns.values()) if (conn.ringPeer && conn.cipher) this.sendInner(conn, this.ownHead(state, ring, conn.peerRing));
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
async catchUpAfterAnchor(ringId ) {
|
|
669
|
+
await this.catchUpFromRelay();
|
|
670
|
+
for (const conn of [...this.conns.values()]) {
|
|
671
|
+
if (conn.peerRing === ringId && conn.cipher) {
|
|
672
|
+
try { await this.catchUp(ringId, conn.peerSeq, null, conn); } catch (error) { this.log(`device ring: ${(error ).message}`); }
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/** Revocation takes effect at once: connections and kept streams of a device that would be refused now end. */
|
|
678
|
+
enforceRing(state , ring ) {
|
|
679
|
+
if (!this.conns.size && !this.streams.size) return;
|
|
680
|
+
const hostKey = hostKeyPair(state).publicKey;
|
|
681
|
+
const refusal = (deviceId , admitted ) => {
|
|
682
|
+
const now = admit(state, ring, hostKey, deviceId, admitted.via === "ring" ? admitted.key : null);
|
|
683
|
+
if ("refused" in now) return now.refused;
|
|
684
|
+
return now.key.equals(admitted.key) ? null : "its key changed";
|
|
685
|
+
};
|
|
686
|
+
for (const conn of [...this.conns.values()]) {
|
|
687
|
+
const why = conn.admitted && refusal(conn.deviceId, conn.admitted);
|
|
688
|
+
if (!why) continue;
|
|
689
|
+
this.log(`dropping ${conn.admitted .name} (${conn.deviceId}): ${why}`);
|
|
690
|
+
if (conn.stream) this.endStream(conn.stream, why);
|
|
691
|
+
if (this.conns.has(conn.id)) this.closeConn(conn, why);
|
|
692
|
+
}
|
|
693
|
+
for (const stream of [...this.streams.values()]) {
|
|
694
|
+
const why = refusal(stream.deviceId, stream.admitted);
|
|
695
|
+
if (why) this.endStream(stream, why);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
331
699
|
// MARK: - Streams
|
|
332
700
|
|
|
333
701
|
async resume(conn , streamId , peerAck , segmented ) {
|
|
334
702
|
let stream = streamId ? this.streams.get(streamId) : undefined;
|
|
335
703
|
const resumed = !!stream && stream.deviceId === conn.deviceId;
|
|
336
704
|
if (!resumed) {
|
|
337
|
-
stream = await this.openStream(conn.deviceId);
|
|
705
|
+
stream = await this.openStream(conn.deviceId, conn.admitted );
|
|
338
706
|
// The phone may have left while the app-server came up. It never heard this stream's id, so it cannot come back
|
|
339
707
|
// for it, and detach found no stream to start the timer on: left alone, its proxy stays as long as the bridge.
|
|
340
708
|
if (this.stopped || this.conns.get(conn.id) !== conn) return this.endStream(stream, "the phone left before its stream opened");
|
|
341
709
|
}
|
|
342
710
|
const s = stream ;
|
|
711
|
+
s.admitted = conn.admitted ;
|
|
343
712
|
if (s.conn && s.conn !== conn) this.closeConn(s.conn, "resumed elsewhere");
|
|
344
713
|
if (s.expiry) { clearTimeout(s.expiry); s.expiry = null; }
|
|
345
714
|
s.conn = conn;
|
|
346
715
|
s.segmented = segmented;
|
|
347
716
|
conn.stream = s;
|
|
348
|
-
this.sendInner(conn, { t: "ready", stream: s.id, resumed, ack: s.in.received, seg: 1 });
|
|
717
|
+
this.sendInner(conn, { t: "ready", stream: s.id, resumed, ack: s.in.received, seg: 1, ring: 1 });
|
|
349
718
|
// A new stream's sequence numbers start over, whatever the phone acknowledged on its old one.
|
|
350
719
|
const after = resumed ? peerAck : 0;
|
|
351
720
|
s.out.ack(after);
|
|
@@ -364,11 +733,11 @@ export class BridgeService {
|
|
|
364
733
|
}
|
|
365
734
|
}
|
|
366
735
|
|
|
367
|
-
async openStream(deviceId ) {
|
|
736
|
+
async openStream(deviceId , admitted ) {
|
|
368
737
|
const { ws: appServer, frameBytes } = await this.connectAppServer();
|
|
369
738
|
const stream = {
|
|
370
739
|
id: crypto.randomBytes(12).toString("base64url"), deviceId, appServer, out: new StreamBuffer(STREAM_BUFFER_BYTES), in: new StreamBuffer(STREAM_BUFFER_BYTES),
|
|
371
|
-
conn: null, expiry: null, ackTimer: null, relayed: Promise.resolve(), segmented: false, chunks: new ChunkAssembler(), appServerFrameBytes: frameBytes,
|
|
740
|
+
conn: null, expiry: null, ackTimer: null, relayed: Promise.resolve(), segmented: false, chunks: new ChunkAssembler(), appServerFrameBytes: frameBytes, admitted,
|
|
372
741
|
};
|
|
373
742
|
this.streams.set(stream.id, stream);
|
|
374
743
|
appServer.on("message", (data ) => {
|
|
@@ -405,6 +774,8 @@ export class BridgeService {
|
|
|
405
774
|
}
|
|
406
775
|
|
|
407
776
|
detach(conn ) {
|
|
777
|
+
if (conn.held) { clearTimeout(conn.held.timer); conn.held = null; }
|
|
778
|
+
conn.ringWaiter = null;
|
|
408
779
|
conn.boardUnsubscribe?.();
|
|
409
780
|
conn.boardUnsubscribe = null;
|
|
410
781
|
const stream = conn.stream;
|
|
@@ -417,12 +788,13 @@ export class BridgeService {
|
|
|
417
788
|
|
|
418
789
|
endStream(stream , reason ) {
|
|
419
790
|
if (!this.streams.delete(stream.id)) return;
|
|
420
|
-
if (stream.expiry) clearTimeout(stream.expiry);
|
|
421
791
|
if (stream.ackTimer) clearTimeout(stream.ackTimer);
|
|
422
792
|
if (stream.conn) {
|
|
423
793
|
this.sendInner(stream.conn, { t: "closed", reason });
|
|
424
794
|
this.closeConn(stream.conn, reason);
|
|
425
795
|
}
|
|
796
|
+
// After closing the connection: detach starts the stream's expiry, which an ended stream must not keep.
|
|
797
|
+
if (stream.expiry) clearTimeout(stream.expiry);
|
|
426
798
|
stream.appServer.close();
|
|
427
799
|
this.log(`closed stream ${stream.id}: ${reason}`);
|
|
428
800
|
}
|
|
@@ -525,6 +897,11 @@ export class BridgeService {
|
|
|
525
897
|
}
|
|
526
898
|
}
|
|
527
899
|
|
|
900
|
+
/** Messages a ring peer may send before its ring/head is handled; past this they go through anyway. */
|
|
901
|
+
const HELD_MESSAGES_MAX = 1024;
|
|
902
|
+
/** Nor more than this many bytes of them (sealed, as they came). */
|
|
903
|
+
const HELD_BYTES_MAX = 32 * 1024 * 1024;
|
|
904
|
+
|
|
528
905
|
/** Larger than any request the app-server makes of a client; only replies and notifications run this long. */
|
|
529
906
|
const MAX_REQUEST_CHARS_TO_INSPECT = 1024 * 1024;
|
|
530
907
|
|
package/src/bridge/state.js
CHANGED
|
@@ -3,7 +3,7 @@ import fs from "node:fs";
|
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { fromB64url, keyPairFromPrivate } from "../protocol.js";
|
|
6
|
-
|
|
6
|
+
|
|
7
7
|
|
|
8
8
|
|
|
9
9
|
|
|
@@ -20,6 +20,45 @@ import { fromB64url, keyPairFromPrivate } from "../protocol.js";
|
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
|
|
23
62
|
|
|
24
63
|
|
|
25
64
|
export const bridgeHome = () => process.env.REPLICA_BRIDGE_HOME ?? path.join(os.homedir(), ".config", "replica-bridge");
|
|
@@ -41,4 +80,17 @@ export function saveState(state ) {
|
|
|
41
80
|
fs.renameSync(temporary, statePath());
|
|
42
81
|
}
|
|
43
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Reads state.json, changes it and writes it back in one synchronous step, so this process never interleaves two
|
|
85
|
+
* changes; another process (`replica-bridge pair`, `ring accept`) writes the same way and the window between is tiny.
|
|
86
|
+
*/
|
|
87
|
+
export function updateState(change ) {
|
|
88
|
+
const state = loadState();
|
|
89
|
+
change(state);
|
|
90
|
+
saveState(state);
|
|
91
|
+
return state;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export const statePathForWatch = statePath;
|
|
95
|
+
|
|
44
96
|
export const hostKeyPair = (state ) => keyPairFromPrivate(fromB64url(state.hostPrivateKey));
|