@serfab/cadre-core 1.5.0 → 1.7.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/dist/cadre-node.d.ts +206 -67
- package/dist/cadre-node.js +399 -98
- package/dist/cadre-node.js.map +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.js +17 -1
- package/dist/index.js.map +1 -1
- package/dist/joined-strand-store.d.ts +112 -0
- package/dist/joined-strand-store.js +263 -0
- package/dist/joined-strand-store.js.map +1 -0
- package/dist/key-store.d.ts +6 -0
- package/dist/key-store.js.map +1 -1
- package/dist/link-budget.d.ts +95 -53
- package/dist/link-budget.js +97 -53
- package/dist/link-budget.js.map +1 -1
- package/dist/node-local-snapshot.d.ts +5 -4
- package/dist/node-local-snapshot.js +6 -3
- package/dist/node-local-snapshot.js.map +1 -1
- package/dist/peer-dial.d.ts +3 -3
- package/dist/peer-dial.js +3 -3
- package/dist/peer-join-backfill.d.ts +35 -16
- package/dist/peer-join-backfill.js +117 -57
- package/dist/peer-join-backfill.js.map +1 -1
- package/dist/relay-reservation.d.ts +2 -2
- package/dist/relay-reservation.js +2 -2
- package/dist/seed-bootstrap.js +1 -1
- package/dist/strand-first-sync-gate.d.ts +88 -43
- package/dist/strand-first-sync-gate.js +92 -46
- package/dist/strand-first-sync-gate.js.map +1 -1
- package/dist/strand-instance-manager.d.ts +39 -0
- package/dist/strand-instance-manager.js +80 -3
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-peer-book-file.d.ts +12 -0
- package/dist/strand-peer-book-file.js +37 -0
- package/dist/strand-peer-book-file.js.map +1 -0
- package/dist/strand-peer-book-protocol.d.ts +159 -0
- package/dist/strand-peer-book-protocol.js +360 -0
- package/dist/strand-peer-book-protocol.js.map +1 -0
- package/dist/strand-peer-book-swap.d.ts +104 -0
- package/dist/strand-peer-book-swap.js +284 -0
- package/dist/strand-peer-book-swap.js.map +1 -0
- package/dist/strand-peer-book.d.ts +159 -0
- package/dist/strand-peer-book.js +354 -0
- package/dist/strand-peer-book.js.map +1 -0
- package/dist/strand-peer-observer.d.ts +88 -0
- package/dist/strand-peer-observer.js +180 -0
- package/dist/strand-peer-observer.js.map +1 -0
- package/dist/types.d.ts +84 -17
- package/dist/types.js +6 -0
- package/dist/types.js.map +1 -1
- package/package.json +11 -7
- package/src/cadre-node.ts +7776 -7445
- package/src/index.ts +68 -0
- package/src/joined-strand-store.ts +308 -0
- package/src/key-store.ts +6 -0
- package/src/link-budget.ts +100 -53
- package/src/node-local-snapshot.ts +293 -289
- package/src/peer-dial.ts +3 -3
- package/src/peer-join-backfill.ts +709 -647
- package/src/relay-reservation.ts +2 -2
- package/src/seed-bootstrap.ts +1 -1
- package/src/strand-first-sync-gate.ts +352 -306
- package/src/strand-instance-manager.ts +106 -3
- package/src/strand-peer-book-file.ts +39 -0
- package/src/strand-peer-book-protocol.ts +463 -0
- package/src/strand-peer-book-swap.ts +338 -0
- package/src/strand-peer-book.ts +468 -0
- package/src/strand-peer-observer.ts +235 -0
- package/src/types.ts +86 -17
|
@@ -4,6 +4,9 @@ import { createLibp2pNode, type IRawStorage } from '@optimystic/db-p2p';
|
|
|
4
4
|
import { wrapStorageWithCache, disposeStorageCache } from '@serfab/quereus-plugin-sereus';
|
|
5
5
|
import { StrandDatabase } from './strand-database.js';
|
|
6
6
|
import { PeerJoinBackfill, type PeerJoinBackfillConfig } from './peer-join-backfill.js';
|
|
7
|
+
import { StrandPeerObserver, type StrandPeerObservation } from './strand-peer-observer.js';
|
|
8
|
+
import { StrandPeerBookSwap } from './strand-peer-book-swap.js';
|
|
9
|
+
import type { StrandPeerBookStore } from './strand-peer-book.js';
|
|
7
10
|
import {
|
|
8
11
|
StrandRevocationEnforcer,
|
|
9
12
|
createRevocationConnectionGater,
|
|
@@ -41,7 +44,7 @@ import type {
|
|
|
41
44
|
import { DEFAULT_CONNECTION_MONITOR, resolveStrandClusterSize, strandClusterPolicy } from './types.js';
|
|
42
45
|
import { strandNodeAddrs } from './strand-network-config.js';
|
|
43
46
|
import { superviseRelayReservation, type RelayReservationSupervisor } from './relay-reservation.js';
|
|
44
|
-
import { peerJoinPushBudget, relayReservationBudgetMs, resolveLinkRoundTripMs } from './link-budget.js';
|
|
47
|
+
import { connectionManagerTimeouts, peerJoinPushBudget, relayReservationBudgetMs, resolveLinkRoundTripMs } from './link-budget.js';
|
|
45
48
|
|
|
46
49
|
const log = debug('sereus:cadre:strand-manager');
|
|
47
50
|
const timing = debug('sereus:cadre:timing');
|
|
@@ -261,6 +264,28 @@ export interface StartStrandConfig {
|
|
|
261
264
|
* hibernation wake's rebuilt supervisors carry it too.
|
|
262
265
|
*/
|
|
263
266
|
announceDelegateToRelay?: (strandId: string, relayAddr: string, delegatePeerId: string) => Promise<void>;
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Called when the strand's libp2p node identifies a STRAND peer — one whose
|
|
270
|
+
* identify result names this strand's block-transfer protocol, so never the
|
|
271
|
+
* circuit relay or a bootstrap node — with the addresses it is dialable at
|
|
272
|
+
* (`strand-peer-observer.ts`). Throttled per peer; must not throw. `CadreNode`
|
|
273
|
+
* wires it to the node-local strand peer book, which is what lets a restarted
|
|
274
|
+
* machine dial the peers it was talking to. Absent ⇒ nothing is observed.
|
|
275
|
+
* Retained with the launch config, so a hibernation wake re-arms it on the
|
|
276
|
+
* rebuilt node.
|
|
277
|
+
*/
|
|
278
|
+
onStrandPeerIdentified?: (strandId: string, observation: StrandPeerObservation) => void;
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* The node-local strand peer book the book SWAP reads and writes
|
|
282
|
+
* (`strand-peer-book-swap.ts`): the strand node signs its own addresses with
|
|
283
|
+
* {@link privateKey}, exchanges signed entries with every strand peer it identifies,
|
|
284
|
+
* and files what verifies. Absent ⇒ no swap runs — a peer that dials this node's
|
|
285
|
+
* swap handler gets no answer. `CadreNode` passes its book store. Retained with the
|
|
286
|
+
* launch config, so a hibernation wake re-arms the swap on the rebuilt node.
|
|
287
|
+
*/
|
|
288
|
+
strandPeerBook?: StrandPeerBookStore;
|
|
264
289
|
}
|
|
265
290
|
|
|
266
291
|
/**
|
|
@@ -370,6 +395,24 @@ export class StrandInstanceManager {
|
|
|
370
395
|
* missed writes).
|
|
371
396
|
*/
|
|
372
397
|
private backfills: Map<string, PeerJoinBackfill> = new Map();
|
|
398
|
+
/**
|
|
399
|
+
* The per-strand peer-book observation writer (`strand-peer-observer.ts`), keyed
|
|
400
|
+
* by strand id. Same lifecycle rationale as {@link backfills}: started in
|
|
401
|
+
* `buildStrandRuntime` right after the libp2p node exists, stopped and dropped in
|
|
402
|
+
* `releaseRuntime`, so quiesce → resume re-arms it on the rebuilt node. Only
|
|
403
|
+
* present when the launch config supplied `onStrandPeerIdentified`.
|
|
404
|
+
*/
|
|
405
|
+
private peerObservers: Map<string, StrandPeerObserver> = new Map();
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Per-strand strand peer book SWAP driver ({@link StrandPeerBookSwap}) — the signed
|
|
409
|
+
* exchange of book entries between strand peers. Same lifetime as the peer-book
|
|
410
|
+
* observer above: armed in `buildStrandRuntime` right after the libp2p node exists
|
|
411
|
+
* (its protocol handler must be registered before an early peer dials it), stopped
|
|
412
|
+
* and dropped in `releaseRuntime`. Only present when the launch config supplied
|
|
413
|
+
* `strandPeerBook`.
|
|
414
|
+
*/
|
|
415
|
+
private peerBookSwaps: Map<string, StrandPeerBookSwap> = new Map();
|
|
373
416
|
/**
|
|
374
417
|
* The per-strand revoked-peer enforcer (closed strands only), keyed by strand
|
|
375
418
|
* id. Same lifecycle rationale as {@link backfills}: created in
|
|
@@ -711,6 +754,10 @@ export class StrandInstanceManager {
|
|
|
711
754
|
// widened ping deadline has to reach the strand nodes too (see
|
|
712
755
|
// DEFAULT_CONNECTION_MONITOR).
|
|
713
756
|
connectionMonitor: config.network?.connectionMonitor ?? DEFAULT_CONNECTION_MONITOR,
|
|
757
|
+
// The same declared-link limits the control node takes, and for the same reason: a
|
|
758
|
+
// strand node is the listener for every other member's strand node (`link-budget.ts`,
|
|
759
|
+
// `connectionManagerTimeouts`).
|
|
760
|
+
connectionManager: connectionManagerTimeouts(config.network?.linkRoundTripMs),
|
|
714
761
|
// Listen entries plus the WebSocket transport switch they imply — a strand node
|
|
715
762
|
// announces nothing the operator configured (`strand-network-config.ts`), and
|
|
716
763
|
// spreads AFTER `transports` above because the switch is a no-op whenever the
|
|
@@ -730,6 +777,48 @@ export class StrandInstanceManager {
|
|
|
730
777
|
|
|
731
778
|
instance.libp2pNode = node;
|
|
732
779
|
|
|
780
|
+
// The peer-book observation writer, armed BEFORE the database bring-up below
|
|
781
|
+
// (unlike the backfill) because the bootstrap dials that follow `libp2p.start()`
|
|
782
|
+
// land during that bring-up, and they are exactly the peers worth remembering:
|
|
783
|
+
// identify for an early peer may already have fired by the time anything after
|
|
784
|
+
// the database initializes runs. Registered in the map immediately so the
|
|
785
|
+
// failure rollback (releaseRuntime) stops it like every other runtime component.
|
|
786
|
+
const onStrandPeerIdentified = config.onStrandPeerIdentified;
|
|
787
|
+
if (onStrandPeerIdentified) {
|
|
788
|
+
const observer = new StrandPeerObserver({
|
|
789
|
+
label: strandId,
|
|
790
|
+
libp2p: node,
|
|
791
|
+
protocolPrefix,
|
|
792
|
+
onObserved: (observation) => onStrandPeerIdentified(strandId, observation)
|
|
793
|
+
});
|
|
794
|
+
this.peerObservers.set(strandId, observer);
|
|
795
|
+
observer.start();
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// The strand peer book SWAP, armed at the same moment and for the same reason as
|
|
799
|
+
// the observer: a peer that dials this node's swap handler before it is registered
|
|
800
|
+
// fails its exchange and sits out a ten-minute throttle. The own entry is signed
|
|
801
|
+
// now and re-signed on every address change, which covers the relay reservation
|
|
802
|
+
// landing below (`self:peer:update` fires when the circuit addr is added), so no
|
|
803
|
+
// separate post-relay step is needed. Registered in the map immediately so the
|
|
804
|
+
// failure rollback (releaseRuntime) stops it like every other runtime component.
|
|
805
|
+
//
|
|
806
|
+
// NOTE: this is a third `peer:identify` listener per running strand, beside the
|
|
807
|
+
// observer's and the backfill's — see the backfill's NOTE below for when to fold
|
|
808
|
+
// them into one dispatcher.
|
|
809
|
+
if (config.strandPeerBook) {
|
|
810
|
+
const swap = new StrandPeerBookSwap({
|
|
811
|
+
strandId,
|
|
812
|
+
libp2p: node,
|
|
813
|
+
protocolPrefix,
|
|
814
|
+
store: config.strandPeerBook,
|
|
815
|
+
privateKey: config.privateKey,
|
|
816
|
+
linkRoundTripMs: config.network?.linkRoundTripMs
|
|
817
|
+
});
|
|
818
|
+
this.peerBookSwaps.set(strandId, swap);
|
|
819
|
+
swap.start();
|
|
820
|
+
}
|
|
821
|
+
|
|
733
822
|
// One reservation supervisor PER RELAY, started now so the first attempts
|
|
734
823
|
// overlap the database bring-up below (the relay is never in a strand's
|
|
735
824
|
// Optimystic cohort — its protocol ids are namespaced `/optimystic/strand-<id>/…`
|
|
@@ -846,7 +935,7 @@ export class StrandInstanceManager {
|
|
|
846
935
|
// peer-join-backfill.ts for the strand-side argument (and why the control
|
|
847
936
|
// network, which DOES gate, is different).
|
|
848
937
|
//
|
|
849
|
-
// NOTE: cost is one PeerJoinBackfill object + one `
|
|
938
|
+
// NOTE: cost is one PeerJoinBackfill object + one `peer:identify` listener
|
|
850
939
|
// per running strand — linear in strand count, negligible at the handful a
|
|
851
940
|
// device or host runs today. If a node ever hosts strands by the hundred,
|
|
852
941
|
// move to one shared listener that dispatches by strand id.
|
|
@@ -884,7 +973,7 @@ export class StrandInstanceManager {
|
|
|
884
973
|
// failing here would only trade that for `StrandWatcher`'s full-rebuild retry.
|
|
885
974
|
//
|
|
886
975
|
// NOTE: a relay that is down costs this launch one full drive — the reservation budget
|
|
887
|
-
// counted from the declared link round trip,
|
|
976
|
+
// counted from the declared link round trip, 14 s at its default (`link-budget.ts`) — and
|
|
888
977
|
// `StrandWatcher` launches strands one at a time, so N strands cost N of those in
|
|
889
978
|
// bring-up during a relay outage, and MORE on a host that declared a slower link. If that
|
|
890
979
|
// ever matters, stop awaiting here (the circuit addr then lands after `active`) rather
|
|
@@ -942,6 +1031,20 @@ export class StrandInstanceManager {
|
|
|
942
1031
|
backfill.stop();
|
|
943
1032
|
this.backfills.delete(instance.strandId);
|
|
944
1033
|
}
|
|
1034
|
+
// The peer-book observer goes with the node it listened on; a resume re-arms
|
|
1035
|
+
// one on the rebuilt node.
|
|
1036
|
+
const peerObserver = this.peerObservers.get(instance.strandId);
|
|
1037
|
+
if (peerObserver) {
|
|
1038
|
+
peerObserver.stop();
|
|
1039
|
+
this.peerObservers.delete(instance.strandId);
|
|
1040
|
+
}
|
|
1041
|
+
// The book swap likewise: unsubscribed and its handler unregistered while the node is
|
|
1042
|
+
// still up; a resume re-arms one, which re-signs the own entry over the new addresses.
|
|
1043
|
+
const peerBookSwap = this.peerBookSwaps.get(instance.strandId);
|
|
1044
|
+
if (peerBookSwap) {
|
|
1045
|
+
this.peerBookSwaps.delete(instance.strandId);
|
|
1046
|
+
await peerBookSwap.stop();
|
|
1047
|
+
}
|
|
945
1048
|
// The revoked-peer enforcer goes with the runtime it gated: a resume
|
|
946
1049
|
// rebuilds it with a fresh (initially empty, fail-open) snapshot.
|
|
947
1050
|
const revocationEnforcer = this.revocationEnforcers.get(instance.strandId);
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node-only file-backed {@link StrandPeerBookStore}: one JSON file per party under
|
|
3
|
+
* a configured directory (the node's state directory — `cadre-cli` passes
|
|
4
|
+
* `ResolvedConfig.nodeStateDir`), holding the node-local, non-replicated strand
|
|
5
|
+
* peer book (the strand peers this node has met, with their last-known addresses).
|
|
6
|
+
*
|
|
7
|
+
* Nothing but the file itself lives here: the envelope, load policy, per-entry
|
|
8
|
+
* validation, the merge rule and the serialised snapshot writes are cross-platform
|
|
9
|
+
* in `PersistentStrandPeerBookStore` / `node-local-snapshot.ts`, and the file is a
|
|
10
|
+
* `FileDurableSlot`. This module exists only so the `node:fs` edge stays out of the
|
|
11
|
+
* package's cross-platform default entry (`./index.js`) — import it from the
|
|
12
|
+
* dedicated subpath instead:
|
|
13
|
+
*
|
|
14
|
+
* ```ts
|
|
15
|
+
* import { FileStrandPeerBookStore } from '@serfab/cadre-core/strand-peer-book-file';
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* Same isolation pattern as `bootstrap-peer-store-file.ts` / `key-store-file.ts`.
|
|
19
|
+
*/
|
|
20
|
+
import { FileDurableSlot } from './file-durable-slot.js';
|
|
21
|
+
import { PersistentStrandPeerBookStore } from './strand-peer-book.js';
|
|
22
|
+
import type { StrandPeerBookOptions, StrandPeerBookStore } from './strand-peer-book.js';
|
|
23
|
+
|
|
24
|
+
/** Base name of the store file: `<dir>/strand-peers.<encoded partyId>.json`. */
|
|
25
|
+
const SLOT_NAME = 'strand-peers';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* File-backed {@link StrandPeerBookStore}. Open via {@link open}, which loads the
|
|
29
|
+
* existing file and returns the cross-platform store over it; an absent, corrupt,
|
|
30
|
+
* or wrong-party file is a cold start (empty book) and structurally junk or
|
|
31
|
+
* aged-out entries are dropped, while a present-but-unreadable file throws. Those
|
|
32
|
+
* rules and their reasoning live on `NodeLocalSnapshot.open`.
|
|
33
|
+
*/
|
|
34
|
+
export const FileStrandPeerBookStore = {
|
|
35
|
+
/** Load (or cold-start) the party's strand peer book from a file in `dir`. */
|
|
36
|
+
async open(dir: string, partyId: string, options?: StrandPeerBookOptions): Promise<StrandPeerBookStore> {
|
|
37
|
+
return PersistentStrandPeerBookStore.open(new FileDurableSlot(dir, SLOT_NAME, partyId), partyId, options);
|
|
38
|
+
}
|
|
39
|
+
};
|
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The strand peer book SWAP protocol, `/sereus/strand-peers/1.0.0`, on the STRAND
|
|
3
|
+
* libp2p node: when two strand peers connect, each sends the other the signed
|
|
4
|
+
* entries of its strand peer book (`strand-peer-book.ts`) — its own current
|
|
5
|
+
* addresses, self-signed, plus the freshest signed entry it holds for every other
|
|
6
|
+
* peer of the strand — and merges what comes back. That closes the two gaps the
|
|
7
|
+
* node-local book alone leaves: a peer whose relay reservation or address ROTATES is
|
|
8
|
+
* known to the others by its old address only until they happen to reconnect, and in
|
|
9
|
+
* a strand of three or more parties a late joiner holds an address only for the party
|
|
10
|
+
* that invited it. After one swap it holds everyone's.
|
|
11
|
+
*
|
|
12
|
+
* Wire shape, modeled on `strand-addr-protocol.ts`: 4-byte big-endian length-prefixed
|
|
13
|
+
* JSON frames over one stream, one request → one response, the shared
|
|
14
|
+
* `control-stream.ts` primitives, a read timeout and a concurrency cap on the
|
|
15
|
+
* receiver, and a {@link MAX_BOOK_FRAME_SIZE} cap on what either side buffers.
|
|
16
|
+
* Request and response are the same {@link StrandPeerBookFrame}. Only SIGNED entries
|
|
17
|
+
* ever travel: an entry this node observed or a formation carried is its own
|
|
18
|
+
* observation, not the peer's statement, and is never forwarded.
|
|
19
|
+
*
|
|
20
|
+
* ── Whose signature ──
|
|
21
|
+
*
|
|
22
|
+
* Each entry is signed by the named peer's own STRAND TRANSPORT key
|
|
23
|
+
* (`strand-transport-key.ts`, the key its strand libp2p node runs under), over the
|
|
24
|
+
* canonical JSON of `{ v: 1, strandId, peerId, addrs, issuedAt }`. Verification needs
|
|
25
|
+
* no key exchange: an Ed25519 libp2p peer id embeds its public key, so any receiver
|
|
26
|
+
* checks that the entry for peer X was signed by X. NOT the strand member key, for
|
|
27
|
+
* four reasons:
|
|
28
|
+
*
|
|
29
|
+
* 1. Open strands have no shared member key at all, and the book has to work there.
|
|
30
|
+
* 2. A member-key signature proves only "some member said this", so any member could
|
|
31
|
+
* forge another member's address and cost every peer a 14 s relayed dial per
|
|
32
|
+
* launch; a self-signature is a claim only the named peer can make.
|
|
33
|
+
* 3. On a closed strand the member key is shared by every machine of a party, so it
|
|
34
|
+
* cannot identify a machine anyway.
|
|
35
|
+
* 4. Membership is a separate question, judged at the connection by the revocation
|
|
36
|
+
* gate today (`strand-revocation-enforcer.ts`) and by the allowlist later
|
|
37
|
+
* (`feat-strand-member-allowlist-admission`); this protocol rides that gate
|
|
38
|
+
* (`node.handle` → `authorizeInboundStream` on a closed strand).
|
|
39
|
+
*
|
|
40
|
+
* **The book therefore proves "this peer's own claim about where it is", never "this
|
|
41
|
+
* peer is a member of the strand".** An address grants no authority (the dialed peer
|
|
42
|
+
* authenticates by peer id at the handshake), so the worst a forged or stale entry
|
|
43
|
+
* costs is a failed dial, and a self-signature is exactly what bounds who can impose
|
|
44
|
+
* that cost on whom.
|
|
45
|
+
*
|
|
46
|
+
* ── Clocks ──
|
|
47
|
+
*
|
|
48
|
+
* `issuedAt` is the signer's clock. It is compared only between entries from the
|
|
49
|
+
* SAME signer (the store's merge rule), so skew between machines is harmless — except
|
|
50
|
+
* that a far-future stamp would pin an entry forever, hence the
|
|
51
|
+
* {@link STRAND_PEER_ISSUED_AT_SKEW_MS} ceiling (reject and log). The store ages an
|
|
52
|
+
* entry from `max(issuedAt, lastSeenAt)`, so a signer far in the past ages out fast,
|
|
53
|
+
* which is the right failure for a machine with a broken clock.
|
|
54
|
+
*/
|
|
55
|
+
import debug from 'debug';
|
|
56
|
+
import type { Connection, Libp2p, PrivateKey } from '@libp2p/interface';
|
|
57
|
+
import { peerIdFromString } from '@libp2p/peer-id';
|
|
58
|
+
import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
|
|
59
|
+
import { canonicalJson } from './canonical-json.js';
|
|
60
|
+
import { type ControlStream, exchangeFrame, readStreamToEnd, withDeadline, writeFrame } from './control-stream.js';
|
|
61
|
+
import { decodeLengthPrefixedFrame } from './seed-bootstrap.js';
|
|
62
|
+
import { groupAddrsByPeerId } from './peer-addr-book.js';
|
|
63
|
+
import { MAX_STRAND_ADDRS } from './strand-formation-protocol.js';
|
|
64
|
+
import { MAX_STRAND_PEERS } from './strand-peer-book.js';
|
|
65
|
+
|
|
66
|
+
const log = debug('sereus:cadre:strand-peer-book-protocol');
|
|
67
|
+
|
|
68
|
+
/** Protocol id of the strand peer book swap, registered on every running strand node. */
|
|
69
|
+
export const STRAND_PEER_BOOK_PROTOCOL = '/sereus/strand-peers/1.0.0';
|
|
70
|
+
|
|
71
|
+
/** Version tag inside every signed payload, so a later payload shape cannot verify as this one. */
|
|
72
|
+
export const SIGNED_STRAND_PEER_ENTRY_VERSION = 1;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* How far ahead of the receiver's clock a signed entry's `issuedAt` may be. Skew
|
|
76
|
+
* between honest machines is seconds; a stamp further ahead than this is a broken
|
|
77
|
+
* clock or an attempt to pin an entry, and is rejected rather than held.
|
|
78
|
+
*/
|
|
79
|
+
export const STRAND_PEER_ISSUED_AT_SKEW_MS = 5 * 60 * 1000;
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Cap on one frame's bytes. A typical full frame — {@link MAX_STRAND_PEERS} entries of
|
|
83
|
+
* {@link MAX_STRAND_ADDRS} addresses at roughly 150 bytes each, plus a peer id and
|
|
84
|
+
* signature per entry — is about 40 KiB, but nothing bounds a multiaddr's length (a
|
|
85
|
+
* long DNS name can take one past 300 bytes), so a sender trims its forwarded
|
|
86
|
+
* entries to fit ({@link trimBookFrameToFit}) rather than trusting the arithmetic;
|
|
87
|
+
* {@link assertBookFrameFits} is then the invariant check.
|
|
88
|
+
*/
|
|
89
|
+
export const MAX_BOOK_FRAME_SIZE = 64 * 1024;
|
|
90
|
+
|
|
91
|
+
/** Default time the receiver waits for the inbound request frame before aborting (ms). */
|
|
92
|
+
const DEFAULT_READ_TIMEOUT_MS = 10_000;
|
|
93
|
+
|
|
94
|
+
/** Default time the client waits for the whole exchange (ms). */
|
|
95
|
+
const DEFAULT_EXCHANGE_TIMEOUT_MS = 10_000;
|
|
96
|
+
|
|
97
|
+
/** Default cap on concurrent inbound swap streams a single strand node serves. */
|
|
98
|
+
const DEFAULT_MAX_CONCURRENT = 100;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* One peer's own signed statement of where it is: a `StrandPeerEntry` without the
|
|
102
|
+
* receiver-local `lastSeenAt`. `addrs` may be empty — a peer that lost its relay
|
|
103
|
+
* reservation truthfully says "not reachable now", and that displaces a stale
|
|
104
|
+
* reachable list at every receiver.
|
|
105
|
+
*/
|
|
106
|
+
export interface SignedStrandPeerEntry {
|
|
107
|
+
/** The signer's strand transport peer id (Ed25519, so it embeds the verifying key). */
|
|
108
|
+
peerId: string;
|
|
109
|
+
/** Bound to `peerId`, signaling-first, at most `MAX_STRAND_ADDRS`. */
|
|
110
|
+
addrs: string[];
|
|
111
|
+
/** Signer's clock, ms. Greater wins between two statements by the same signer. */
|
|
112
|
+
issuedAt: number;
|
|
113
|
+
/** base64url Ed25519 signature over {@link signedStrandPeerEntryPayload}. */
|
|
114
|
+
sig: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Request and response of the swap: the sender's signed book for one strand. */
|
|
118
|
+
export interface StrandPeerBookFrame {
|
|
119
|
+
/** Must name the strand whose network the frame arrived on; anything else is refused. */
|
|
120
|
+
strandId: string;
|
|
121
|
+
/** At most {@link MAX_STRAND_PEERS}. */
|
|
122
|
+
entries: SignedStrandPeerEntry[];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** The bytes a signed entry's signature covers. */
|
|
126
|
+
export function signedStrandPeerEntryPayload(
|
|
127
|
+
strandId: string,
|
|
128
|
+
entry: Pick<SignedStrandPeerEntry, 'peerId' | 'addrs' | 'issuedAt'>
|
|
129
|
+
): Uint8Array {
|
|
130
|
+
return new TextEncoder().encode(canonicalJson({
|
|
131
|
+
v: SIGNED_STRAND_PEER_ENTRY_VERSION,
|
|
132
|
+
strandId,
|
|
133
|
+
peerId: entry.peerId,
|
|
134
|
+
addrs: entry.addrs,
|
|
135
|
+
issuedAt: entry.issuedAt
|
|
136
|
+
}));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Sign this node's own entry with its strand transport key. `addrs` must already be
|
|
141
|
+
* bound to the key's peer id (`dialableAddrs` in `strand-peer-observer.ts` does that);
|
|
142
|
+
* a receiver rejects an entry whose addresses do not all attribute to the signer.
|
|
143
|
+
*/
|
|
144
|
+
export async function signStrandPeerEntry(
|
|
145
|
+
privateKey: PrivateKey,
|
|
146
|
+
strandId: string,
|
|
147
|
+
addrs: string[],
|
|
148
|
+
issuedAt: number
|
|
149
|
+
): Promise<SignedStrandPeerEntry> {
|
|
150
|
+
if (privateKey.type !== 'Ed25519') {
|
|
151
|
+
throw new Error(`signStrandPeerEntry requires an Ed25519 key, got ${privateKey.type}`);
|
|
152
|
+
}
|
|
153
|
+
const peerId = privateKey.publicKey.toString();
|
|
154
|
+
const entry = { peerId, addrs: [...addrs], issuedAt };
|
|
155
|
+
const sig = await privateKey.sign(signedStrandPeerEntryPayload(strandId, entry));
|
|
156
|
+
return { ...entry, sig: uint8ArrayToString(sig, 'base64url') };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Why a received entry was dropped — for logs and for the verification test. */
|
|
160
|
+
export type StrandPeerEntryVerdict =
|
|
161
|
+
| { ok: true; entry: SignedStrandPeerEntry }
|
|
162
|
+
| { ok: false; reason: string };
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Verify one received entry: well-formed, an Ed25519 peer id, every address bound to
|
|
166
|
+
* that peer id, `issuedAt` not further ahead of `now` than the skew ceiling, and a
|
|
167
|
+
* valid self-signature. Never throws: junk from a peer is a verdict, not an error.
|
|
168
|
+
*/
|
|
169
|
+
export async function verifySignedStrandPeerEntry(
|
|
170
|
+
strandId: string,
|
|
171
|
+
raw: unknown,
|
|
172
|
+
now: number
|
|
173
|
+
): Promise<StrandPeerEntryVerdict> {
|
|
174
|
+
const shaped = shapeEntry(raw);
|
|
175
|
+
if (typeof shaped === 'string') {
|
|
176
|
+
return { ok: false, reason: shaped };
|
|
177
|
+
}
|
|
178
|
+
let publicKey;
|
|
179
|
+
try {
|
|
180
|
+
const peerId = peerIdFromString(shaped.peerId);
|
|
181
|
+
if (peerId.type !== 'Ed25519') {
|
|
182
|
+
return { ok: false, reason: `peer id ${shaped.peerId} is ${peerId.type}, not Ed25519` };
|
|
183
|
+
}
|
|
184
|
+
publicKey = peerId.publicKey;
|
|
185
|
+
} catch (error) {
|
|
186
|
+
return { ok: false, reason: `peer id ${shaped.peerId} does not parse: ${(error as Error).message}` };
|
|
187
|
+
}
|
|
188
|
+
const attributed = groupAddrsByPeerId([...shaped.addrs]).get(shaped.peerId)?.length ?? 0;
|
|
189
|
+
if (attributed !== shaped.addrs.length) {
|
|
190
|
+
return { ok: false, reason: `${shaped.addrs.length - attributed} addr(s) do not attribute to ${shaped.peerId}` };
|
|
191
|
+
}
|
|
192
|
+
if (shaped.issuedAt > now + STRAND_PEER_ISSUED_AT_SKEW_MS) {
|
|
193
|
+
return { ok: false, reason: `issuedAt ${shaped.issuedAt} is more than ${STRAND_PEER_ISSUED_AT_SKEW_MS} ms ahead of now` };
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
const sig = uint8ArrayFromString(shaped.sig, 'base64url');
|
|
197
|
+
if (!(await publicKey.verify(signedStrandPeerEntryPayload(strandId, shaped), sig))) {
|
|
198
|
+
return { ok: false, reason: `signature does not verify for ${shaped.peerId}` };
|
|
199
|
+
}
|
|
200
|
+
} catch (error) {
|
|
201
|
+
return { ok: false, reason: `signature unusable for ${shaped.peerId}: ${(error as Error).message}` };
|
|
202
|
+
}
|
|
203
|
+
return { ok: true, entry: shaped };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Structural check of untrusted JSON: the fields, their types, the address cap. */
|
|
207
|
+
function shapeEntry(raw: unknown): SignedStrandPeerEntry | string {
|
|
208
|
+
if (typeof raw !== 'object' || raw === null) return 'entry is not an object';
|
|
209
|
+
const { peerId, addrs, issuedAt, sig } = raw as Record<string, unknown>;
|
|
210
|
+
if (typeof peerId !== 'string' || peerId.length === 0) return 'peerId is not a string';
|
|
211
|
+
if (!Array.isArray(addrs) || addrs.some((addr) => typeof addr !== 'string' || addr.length === 0)) {
|
|
212
|
+
return `addrs of ${peerId} is not a list of strings`;
|
|
213
|
+
}
|
|
214
|
+
if (addrs.length > MAX_STRAND_ADDRS) return `${peerId} lists ${addrs.length} addrs, over the cap of ${MAX_STRAND_ADDRS}`;
|
|
215
|
+
if (!Number.isSafeInteger(issuedAt) || (issuedAt as number) < 0) return `issuedAt of ${peerId} is not a timestamp`;
|
|
216
|
+
if (typeof sig !== 'string' || sig.length === 0) return `sig of ${peerId} is not a string`;
|
|
217
|
+
// A duplicate address is not a protocol error, but the store de-duplicates and the
|
|
218
|
+
// attribution count above compares lengths, so collapse it here.
|
|
219
|
+
return { peerId, addrs: [...new Set(addrs as string[])], issuedAt: issuedAt as number, sig };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Verify a whole received frame. A frame-level violation — the wrong strand, entries
|
|
224
|
+
* not a list, more entries than {@link MAX_STRAND_PEERS} — throws, and the caller
|
|
225
|
+
* refuses the frame. An entry that fails {@link verifySignedStrandPeerEntry} is
|
|
226
|
+
* dropped INDIVIDUALLY with a debug line; its siblings are kept. An entry naming
|
|
227
|
+
* `selfPeerId` is dropped too: this node's own statement is authoritative locally
|
|
228
|
+
* and a remote copy of it is at best redundant. One entry per peer id (the first).
|
|
229
|
+
*/
|
|
230
|
+
export async function verifyStrandPeerBookFrame(
|
|
231
|
+
strandId: string,
|
|
232
|
+
raw: unknown,
|
|
233
|
+
selfPeerId: string,
|
|
234
|
+
now: number
|
|
235
|
+
): Promise<SignedStrandPeerEntry[]> {
|
|
236
|
+
if (typeof raw !== 'object' || raw === null) {
|
|
237
|
+
throw new Error('strand peer book frame is not an object');
|
|
238
|
+
}
|
|
239
|
+
const { strandId: named, entries } = raw as Record<string, unknown>;
|
|
240
|
+
if (named !== strandId) {
|
|
241
|
+
throw new Error(`strand peer book frame names strand ${String(named)}, expected ${strandId}`);
|
|
242
|
+
}
|
|
243
|
+
if (!Array.isArray(entries)) {
|
|
244
|
+
throw new Error('strand peer book frame entries is not a list');
|
|
245
|
+
}
|
|
246
|
+
if (entries.length > MAX_STRAND_PEERS) {
|
|
247
|
+
throw new Error(`strand peer book frame carries ${entries.length} entries, over the cap of ${MAX_STRAND_PEERS}`);
|
|
248
|
+
}
|
|
249
|
+
const verified = new Map<string, SignedStrandPeerEntry>();
|
|
250
|
+
for (const candidate of entries) {
|
|
251
|
+
const verdict = await verifySignedStrandPeerEntry(strandId, candidate, now);
|
|
252
|
+
if (!verdict.ok) {
|
|
253
|
+
log('strand %s: dropping received entry: %s', strandId, verdict.reason);
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (verdict.entry.peerId === selfPeerId || verified.has(verdict.entry.peerId)) {
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
verified.set(verdict.entry.peerId, verdict.entry);
|
|
260
|
+
}
|
|
261
|
+
return [...verified.values()];
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** The encoded size of a frame, as {@link writeFrame} would send it (the 4-byte prefix aside). */
|
|
265
|
+
function encodedFrameBytes(frame: StrandPeerBookFrame): number {
|
|
266
|
+
return new TextEncoder().encode(JSON.stringify(frame)).length;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The frame with its LAST entries dropped until it encodes within
|
|
271
|
+
* {@link MAX_BOOK_FRAME_SIZE} and {@link MAX_STRAND_PEERS}. Callers list the entries
|
|
272
|
+
* in the order they would rather keep them (the own entry first, then freshest
|
|
273
|
+
* first), so what goes is the stalest forwarded statement, and a frame that fits is
|
|
274
|
+
* returned as is. Dropping an entry is logged: a forwarded peer the recipient never
|
|
275
|
+
* learns of is worth seeing in a trace.
|
|
276
|
+
*/
|
|
277
|
+
export function trimBookFrameToFit(frame: StrandPeerBookFrame): StrandPeerBookFrame {
|
|
278
|
+
let entries = frame.entries.slice(0, MAX_STRAND_PEERS);
|
|
279
|
+
while (entries.length > 0 && encodedFrameBytes({ strandId: frame.strandId, entries }) > MAX_BOOK_FRAME_SIZE) {
|
|
280
|
+
entries = entries.slice(0, -1);
|
|
281
|
+
}
|
|
282
|
+
if (entries.length === frame.entries.length) return frame;
|
|
283
|
+
log('strand %s: frame trimmed from %d to %d entr(ies) to fit the caps', frame.strandId, frame.entries.length, entries.length);
|
|
284
|
+
return { strandId: frame.strandId, entries };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Refuse to send a frame the receiver would reject: over the entry cap, or over
|
|
289
|
+
* {@link MAX_BOOK_FRAME_SIZE} once encoded. A caller that built its frame through
|
|
290
|
+
* {@link trimBookFrameToFit} cannot trip this; it is the invariant check, not a
|
|
291
|
+
* runtime condition to handle.
|
|
292
|
+
*/
|
|
293
|
+
export function assertBookFrameFits(frame: StrandPeerBookFrame): void {
|
|
294
|
+
if (frame.entries.length > MAX_STRAND_PEERS) {
|
|
295
|
+
throw new Error(`strand peer book frame carries ${frame.entries.length} entries, over the cap of ${MAX_STRAND_PEERS}`);
|
|
296
|
+
}
|
|
297
|
+
const bytes = encodedFrameBytes(frame);
|
|
298
|
+
if (bytes > MAX_BOOK_FRAME_SIZE) {
|
|
299
|
+
throw new Error(`strand peer book frame is ${bytes} bytes, over the cap of ${MAX_BOOK_FRAME_SIZE}`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Read a stream to EOF and decode its single frame, bounded by `timeoutMs` and the size cap. */
|
|
304
|
+
async function readBookFrame(stream: ControlStream, timeoutMs: number): Promise<unknown> {
|
|
305
|
+
const data = await readStreamToEnd(stream, { maxBytes: MAX_BOOK_FRAME_SIZE, timeoutMs, label: 'Strand peer book' });
|
|
306
|
+
return JSON.parse(new TextDecoder().decode(decodeLengthPrefixedFrame(data, MAX_BOOK_FRAME_SIZE)));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** What the {@link StrandPeerBookService} receiver needs from its host, injected so it is testable without a node. */
|
|
310
|
+
export interface StrandPeerBookServiceDeps {
|
|
311
|
+
strandId: string;
|
|
312
|
+
/** This strand node's own peer id: a received entry naming it is never merged. */
|
|
313
|
+
selfPeerId: string;
|
|
314
|
+
/**
|
|
315
|
+
* The entries to answer `forPeerId` with — this node's own signed entry plus every
|
|
316
|
+
* signed entry it holds, minus `forPeerId`'s own (the asker knows where it is).
|
|
317
|
+
*/
|
|
318
|
+
localEntries(forPeerId: string): SignedStrandPeerEntry[];
|
|
319
|
+
/** The asker's VERIFIED entries, for the host to merge. Must not throw. */
|
|
320
|
+
onEntries(entries: SignedStrandPeerEntry[], fromPeerId: string): void;
|
|
321
|
+
/** Clock, for tests. Default `Date.now`. */
|
|
322
|
+
now?: () => number;
|
|
323
|
+
/** Default {@link DEFAULT_READ_TIMEOUT_MS}. */
|
|
324
|
+
readTimeoutMs?: number;
|
|
325
|
+
/** Default {@link DEFAULT_MAX_CONCURRENT}. Over it, an empty frame is returned unread. */
|
|
326
|
+
maxConcurrent?: number;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Receiver side: registers the protocol on a strand node and, per inbound frame,
|
|
331
|
+
* verifies and hands over the asker's entries, then answers with the local book.
|
|
332
|
+
* Registered through `node.handle`, so on a closed strand every inbound stream
|
|
333
|
+
* passes the revocation gate first — a revoked machine cannot push addresses into
|
|
334
|
+
* anyone's book.
|
|
335
|
+
*/
|
|
336
|
+
export class StrandPeerBookService {
|
|
337
|
+
private readonly readTimeoutMs: number;
|
|
338
|
+
private readonly maxConcurrent: number;
|
|
339
|
+
private readonly now: () => number;
|
|
340
|
+
private node: Libp2p | null = null;
|
|
341
|
+
private activeStreams = 0;
|
|
342
|
+
|
|
343
|
+
constructor(private readonly deps: StrandPeerBookServiceDeps) {
|
|
344
|
+
this.readTimeoutMs = deps.readTimeoutMs ?? DEFAULT_READ_TIMEOUT_MS;
|
|
345
|
+
this.maxConcurrent = deps.maxConcurrent ?? DEFAULT_MAX_CONCURRENT;
|
|
346
|
+
this.now = deps.now ?? Date.now;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** In-flight inbound streams. */
|
|
350
|
+
get activeCount(): number {
|
|
351
|
+
return this.activeStreams;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Register the handler. Resolves once libp2p has recorded the protocol on the self record. */
|
|
355
|
+
async initialize(node: Libp2p): Promise<void> {
|
|
356
|
+
this.node = node;
|
|
357
|
+
// `runOnLimitedConnection: true`: strand peers reach each other over relayed
|
|
358
|
+
// (limited) connections as a matter of course, and this is one small frame each way.
|
|
359
|
+
await node.handle(STRAND_PEER_BOOK_PROTOCOL, (rawStream: unknown, rawConnection: unknown) => {
|
|
360
|
+
const remotePeerId = (rawConnection as Connection).remotePeer.toString();
|
|
361
|
+
void this.handleStream(rawStream as ControlStream, remotePeerId);
|
|
362
|
+
}, { runOnLimitedConnection: true });
|
|
363
|
+
log('[%s] registered %s', this.deps.strandId, STRAND_PEER_BOOK_PROTOCOL);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Unregister the handler and release the node reference. */
|
|
367
|
+
async shutdown(): Promise<void> {
|
|
368
|
+
if (this.node) {
|
|
369
|
+
await this.node.unhandle(STRAND_PEER_BOOK_PROTOCOL);
|
|
370
|
+
this.node = null;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Read the request, merge what verifies, answer with the local book. Every failure
|
|
376
|
+
* (cap, malformed or oversized frame, wrong strand, read timeout) answers an empty
|
|
377
|
+
* frame rather than hanging or dropping the stream, so the asker's exchange settles.
|
|
378
|
+
*
|
|
379
|
+
* NOTE: the concurrency cap bounds streams in flight, not frames per peer per unit
|
|
380
|
+
* time — the ten-minute throttle is the CLIENT's, so a connected peer can push a
|
|
381
|
+
* verified frame, and the persist it costs, as fast as it likes; see
|
|
382
|
+
* `backlog/debt-strand-peer-book-remote-write-bounds`.
|
|
383
|
+
*/
|
|
384
|
+
private async handleStream(stream: ControlStream, remotePeerId: string): Promise<void> {
|
|
385
|
+
if (this.activeStreams >= this.maxConcurrent) {
|
|
386
|
+
log('[%s] refusing swap from %s: %d streams at the cap', this.deps.strandId, remotePeerId, this.activeStreams);
|
|
387
|
+
this.reply(stream, []);
|
|
388
|
+
await closeQuietly(stream);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
this.activeStreams++;
|
|
392
|
+
try {
|
|
393
|
+
const raw = await readBookFrame(stream, this.readTimeoutMs);
|
|
394
|
+
const entries = await verifyStrandPeerBookFrame(this.deps.strandId, raw, this.deps.selfPeerId, this.now());
|
|
395
|
+
log('[%s] swap from %s: %d verified entr(ies) received', this.deps.strandId, remotePeerId, entries.length);
|
|
396
|
+
this.deps.onEntries(entries, remotePeerId);
|
|
397
|
+
this.reply(stream, this.deps.localEntries(remotePeerId));
|
|
398
|
+
} catch (error) {
|
|
399
|
+
log('[%s] swap from %s failed: %o', this.deps.strandId, remotePeerId, error);
|
|
400
|
+
this.reply(stream, []);
|
|
401
|
+
} finally {
|
|
402
|
+
this.activeStreams--;
|
|
403
|
+
await closeQuietly(stream);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
private reply(stream: ControlStream, entries: SignedStrandPeerEntry[]): void {
|
|
408
|
+
try {
|
|
409
|
+
const frame: StrandPeerBookFrame = { strandId: this.deps.strandId, entries };
|
|
410
|
+
assertBookFrameFits(frame);
|
|
411
|
+
writeFrame(stream, frame);
|
|
412
|
+
} catch (error) {
|
|
413
|
+
log('[%s] swap reply failed: %o', this.deps.strandId, error);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async function closeQuietly(stream: ControlStream): Promise<void> {
|
|
419
|
+
try {
|
|
420
|
+
await stream.close();
|
|
421
|
+
} catch (error) {
|
|
422
|
+
log('stream close failed (ignored): %o', error);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** Options for {@link exchangeStrandPeerBook}. */
|
|
427
|
+
export interface ExchangeStrandPeerBookOptions {
|
|
428
|
+
/** This strand node's own peer id: a returned entry naming it is dropped. */
|
|
429
|
+
selfPeerId: string;
|
|
430
|
+
/** Whole-exchange deadline (ms). Default {@link DEFAULT_EXCHANGE_TIMEOUT_MS}. */
|
|
431
|
+
timeoutMs?: number;
|
|
432
|
+
/** Clock, for tests. Default `Date.now`. */
|
|
433
|
+
now?: () => number;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Client side: open one stream on `connection` — the live connection to the peer,
|
|
438
|
+
* relayed or not — send `request`, and return the peer's VERIFIED entries. Throws on
|
|
439
|
+
* any failure (the peer does not speak the protocol, a deadline, a refused frame);
|
|
440
|
+
* the caller logs and moves on, never retrying before its throttle expires.
|
|
441
|
+
*/
|
|
442
|
+
export async function exchangeStrandPeerBook(
|
|
443
|
+
connection: Connection,
|
|
444
|
+
request: StrandPeerBookFrame,
|
|
445
|
+
options: ExchangeStrandPeerBookOptions
|
|
446
|
+
): Promise<SignedStrandPeerEntry[]> {
|
|
447
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_EXCHANGE_TIMEOUT_MS;
|
|
448
|
+
const remotePeerId = connection.remotePeer.toString();
|
|
449
|
+
assertBookFrameFits(request);
|
|
450
|
+
// One deadline for the stream open and the exchange together: its signal aborts the
|
|
451
|
+
// negotiation and resets a live stream, so neither leaks on a timeout.
|
|
452
|
+
const raw = await withDeadline(timeoutMs, `Strand peer book swap with ${remotePeerId}`, async (signal) => {
|
|
453
|
+
const stream = await connection.newStream(STRAND_PEER_BOOK_PROTOCOL, { runOnLimitedConnection: true, signal });
|
|
454
|
+
return exchangeFrame(
|
|
455
|
+
stream as unknown as ControlStream,
|
|
456
|
+
signal,
|
|
457
|
+
request,
|
|
458
|
+
(s) => readBookFrame(s, timeoutMs),
|
|
459
|
+
'Strand peer book swap aborted by timeout'
|
|
460
|
+
);
|
|
461
|
+
});
|
|
462
|
+
return verifyStrandPeerBookFrame(request.strandId, raw, options.selfPeerId, (options.now ?? Date.now)());
|
|
463
|
+
}
|