@serfab/cadre-core 1.8.0 → 1.9.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 +153 -104
- package/dist/cadre-node.js +227 -199
- package/dist/cadre-node.js.map +1 -1
- package/dist/index.d.ts +2 -4
- package/dist/index.js +10 -11
- package/dist/index.js.map +1 -1
- package/dist/node-local-snapshot.d.ts +6 -4
- package/dist/node-local-snapshot.js +7 -3
- package/dist/node-local-snapshot.js.map +1 -1
- package/dist/peer-join-backfill.d.ts +0 -9
- package/dist/peer-join-backfill.js +2 -3
- package/dist/peer-join-backfill.js.map +1 -1
- package/dist/strand-formation-protocol.d.ts +0 -11
- package/dist/strand-formation-protocol.js +3 -4
- package/dist/strand-formation-protocol.js.map +1 -1
- package/dist/strand-fret-addrs.d.ts +54 -0
- package/dist/strand-fret-addrs.js +108 -0
- package/dist/strand-fret-addrs.js.map +1 -0
- package/dist/strand-instance-manager.d.ts +9 -38
- package/dist/strand-instance-manager.js +13 -73
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-network-state-file.d.ts +12 -0
- package/dist/strand-network-state-file.js +38 -0
- package/dist/strand-network-state-file.js.map +1 -0
- package/dist/strand-network-state.d.ts +111 -0
- package/dist/strand-network-state.js +191 -0
- package/dist/strand-network-state.js.map +1 -0
- package/dist/types.d.ts +20 -23
- package/package.json +11 -10
- package/src/cadre-node.ts +238 -205
- package/src/index.ts +15 -55
- package/src/node-local-snapshot.ts +12 -7
- package/src/peer-join-backfill.ts +2 -3
- package/src/strand-formation-protocol.ts +3 -4
- package/src/strand-fret-addrs.ts +145 -0
- package/src/strand-instance-manager.ts +20 -96
- package/src/strand-network-state-file.ts +40 -0
- package/src/strand-network-state.ts +243 -0
- package/src/types.ts +20 -23
- package/dist/strand-peer-book-file.d.ts +0 -12
- package/dist/strand-peer-book-file.js +0 -37
- package/dist/strand-peer-book-file.js.map +0 -1
- package/dist/strand-peer-book-protocol.d.ts +0 -168
- package/dist/strand-peer-book-protocol.js +0 -356
- package/dist/strand-peer-book-protocol.js.map +0 -1
- package/dist/strand-peer-book-swap.d.ts +0 -104
- package/dist/strand-peer-book-swap.js +0 -284
- package/dist/strand-peer-book-swap.js.map +0 -1
- package/dist/strand-peer-book.d.ts +0 -159
- package/dist/strand-peer-book.js +0 -361
- package/dist/strand-peer-book.js.map +0 -1
- package/dist/strand-peer-observer.d.ts +0 -88
- package/dist/strand-peer-observer.js +0 -180
- package/dist/strand-peer-observer.js.map +0 -1
- package/src/strand-peer-book-file.ts +0 -39
- package/src/strand-peer-book-protocol.ts +0 -466
- package/src/strand-peer-book-swap.ts +0 -338
- package/src/strand-peer-book.ts +0 -475
- package/src/strand-peer-observer.ts +0 -235
|
@@ -1,39 +0,0 @@
|
|
|
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
|
-
};
|
|
@@ -1,466 +0,0 @@
|
|
|
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 16 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 cap on concurrent inbound swap streams a single strand node serves. */
|
|
92
|
-
const DEFAULT_MAX_CONCURRENT = 100;
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* One peer's own signed statement of where it is: a `StrandPeerEntry` without the
|
|
96
|
-
* receiver-local `lastSeenAt`. `addrs` may be empty — a peer that lost its relay
|
|
97
|
-
* reservation truthfully says "not reachable now", and that displaces a stale
|
|
98
|
-
* reachable list at every receiver.
|
|
99
|
-
*/
|
|
100
|
-
export interface SignedStrandPeerEntry {
|
|
101
|
-
/** The signer's strand transport peer id (Ed25519, so it embeds the verifying key). */
|
|
102
|
-
peerId: string;
|
|
103
|
-
/** Bound to `peerId`, signaling-first, at most `MAX_STRAND_ADDRS`. */
|
|
104
|
-
addrs: string[];
|
|
105
|
-
/** Signer's clock, ms. Greater wins between two statements by the same signer. */
|
|
106
|
-
issuedAt: number;
|
|
107
|
-
/** base64url Ed25519 signature over {@link signedStrandPeerEntryPayload}. */
|
|
108
|
-
sig: string;
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/** Request and response of the swap: the sender's signed book for one strand. */
|
|
112
|
-
export interface StrandPeerBookFrame {
|
|
113
|
-
/** Must name the strand whose network the frame arrived on; anything else is refused. */
|
|
114
|
-
strandId: string;
|
|
115
|
-
/** At most {@link MAX_STRAND_PEERS}. */
|
|
116
|
-
entries: SignedStrandPeerEntry[];
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/** The bytes a signed entry's signature covers. */
|
|
120
|
-
export function signedStrandPeerEntryPayload(
|
|
121
|
-
strandId: string,
|
|
122
|
-
entry: Pick<SignedStrandPeerEntry, 'peerId' | 'addrs' | 'issuedAt'>
|
|
123
|
-
): Uint8Array {
|
|
124
|
-
return new TextEncoder().encode(canonicalJson({
|
|
125
|
-
v: SIGNED_STRAND_PEER_ENTRY_VERSION,
|
|
126
|
-
strandId,
|
|
127
|
-
peerId: entry.peerId,
|
|
128
|
-
addrs: entry.addrs,
|
|
129
|
-
issuedAt: entry.issuedAt
|
|
130
|
-
}));
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* Sign this node's own entry with its strand transport key. `addrs` must already be
|
|
135
|
-
* bound to the key's peer id (`dialableAddrs` in `strand-peer-observer.ts` does that);
|
|
136
|
-
* a receiver rejects an entry whose addresses do not all attribute to the signer.
|
|
137
|
-
*/
|
|
138
|
-
export async function signStrandPeerEntry(
|
|
139
|
-
privateKey: PrivateKey,
|
|
140
|
-
strandId: string,
|
|
141
|
-
addrs: string[],
|
|
142
|
-
issuedAt: number
|
|
143
|
-
): Promise<SignedStrandPeerEntry> {
|
|
144
|
-
if (privateKey.type !== 'Ed25519') {
|
|
145
|
-
throw new Error(`signStrandPeerEntry requires an Ed25519 key, got ${privateKey.type}`);
|
|
146
|
-
}
|
|
147
|
-
const peerId = privateKey.publicKey.toString();
|
|
148
|
-
const entry = { peerId, addrs: [...addrs], issuedAt };
|
|
149
|
-
const sig = await privateKey.sign(signedStrandPeerEntryPayload(strandId, entry));
|
|
150
|
-
return { ...entry, sig: uint8ArrayToString(sig, 'base64url') };
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
/** Why a received entry was dropped — for logs and for the verification test. */
|
|
154
|
-
export type StrandPeerEntryVerdict =
|
|
155
|
-
| { ok: true; entry: SignedStrandPeerEntry }
|
|
156
|
-
| { ok: false; reason: string };
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* Verify one received entry: well-formed, an Ed25519 peer id, every address bound to
|
|
160
|
-
* that peer id, `issuedAt` not further ahead of `now` than the skew ceiling, and a
|
|
161
|
-
* valid self-signature. Never throws: junk from a peer is a verdict, not an error.
|
|
162
|
-
*/
|
|
163
|
-
export async function verifySignedStrandPeerEntry(
|
|
164
|
-
strandId: string,
|
|
165
|
-
raw: unknown,
|
|
166
|
-
now: number
|
|
167
|
-
): Promise<StrandPeerEntryVerdict> {
|
|
168
|
-
const shaped = shapeEntry(raw);
|
|
169
|
-
if (typeof shaped === 'string') {
|
|
170
|
-
return { ok: false, reason: shaped };
|
|
171
|
-
}
|
|
172
|
-
let publicKey;
|
|
173
|
-
try {
|
|
174
|
-
const peerId = peerIdFromString(shaped.peerId);
|
|
175
|
-
if (peerId.type !== 'Ed25519') {
|
|
176
|
-
return { ok: false, reason: `peer id ${shaped.peerId} is ${peerId.type}, not Ed25519` };
|
|
177
|
-
}
|
|
178
|
-
publicKey = peerId.publicKey;
|
|
179
|
-
} catch (error) {
|
|
180
|
-
return { ok: false, reason: `peer id ${shaped.peerId} does not parse: ${(error as Error).message}` };
|
|
181
|
-
}
|
|
182
|
-
const attributed = groupAddrsByPeerId([...shaped.addrs]).get(shaped.peerId)?.length ?? 0;
|
|
183
|
-
if (attributed !== shaped.addrs.length) {
|
|
184
|
-
return { ok: false, reason: `${shaped.addrs.length - attributed} addr(s) do not attribute to ${shaped.peerId}` };
|
|
185
|
-
}
|
|
186
|
-
if (shaped.issuedAt > now + STRAND_PEER_ISSUED_AT_SKEW_MS) {
|
|
187
|
-
return { ok: false, reason: `issuedAt ${shaped.issuedAt} is more than ${STRAND_PEER_ISSUED_AT_SKEW_MS} ms ahead of now` };
|
|
188
|
-
}
|
|
189
|
-
try {
|
|
190
|
-
const sig = uint8ArrayFromString(shaped.sig, 'base64url');
|
|
191
|
-
if (!(await publicKey.verify(signedStrandPeerEntryPayload(strandId, shaped), sig))) {
|
|
192
|
-
return { ok: false, reason: `signature does not verify for ${shaped.peerId}` };
|
|
193
|
-
}
|
|
194
|
-
} catch (error) {
|
|
195
|
-
return { ok: false, reason: `signature unusable for ${shaped.peerId}: ${(error as Error).message}` };
|
|
196
|
-
}
|
|
197
|
-
return { ok: true, entry: shaped };
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
/** Structural check of untrusted JSON: the fields, their types, the address cap. */
|
|
201
|
-
function shapeEntry(raw: unknown): SignedStrandPeerEntry | string {
|
|
202
|
-
if (typeof raw !== 'object' || raw === null) return 'entry is not an object';
|
|
203
|
-
const { peerId, addrs, issuedAt, sig } = raw as Record<string, unknown>;
|
|
204
|
-
if (typeof peerId !== 'string' || peerId.length === 0) return 'peerId is not a string';
|
|
205
|
-
if (!Array.isArray(addrs) || addrs.some((addr) => typeof addr !== 'string' || addr.length === 0)) {
|
|
206
|
-
return `addrs of ${peerId} is not a list of strings`;
|
|
207
|
-
}
|
|
208
|
-
if (addrs.length > MAX_STRAND_ADDRS) return `${peerId} lists ${addrs.length} addrs, over the cap of ${MAX_STRAND_ADDRS}`;
|
|
209
|
-
if (!Number.isSafeInteger(issuedAt) || (issuedAt as number) < 0) return `issuedAt of ${peerId} is not a timestamp`;
|
|
210
|
-
if (typeof sig !== 'string' || sig.length === 0) return `sig of ${peerId} is not a string`;
|
|
211
|
-
// A duplicate address is not a protocol error, but the store de-duplicates and the
|
|
212
|
-
// attribution count above compares lengths, so collapse it here.
|
|
213
|
-
return { peerId, addrs: [...new Set(addrs as string[])], issuedAt: issuedAt as number, sig };
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* Verify a whole received frame. A frame-level violation — the wrong strand, entries
|
|
218
|
-
* not a list, more entries than {@link MAX_STRAND_PEERS} — throws, and the caller
|
|
219
|
-
* refuses the frame. An entry that fails {@link verifySignedStrandPeerEntry} is
|
|
220
|
-
* dropped INDIVIDUALLY with a debug line; its siblings are kept. An entry naming
|
|
221
|
-
* `selfPeerId` is dropped too: this node's own statement is authoritative locally
|
|
222
|
-
* and a remote copy of it is at best redundant. One entry per peer id (the first).
|
|
223
|
-
*/
|
|
224
|
-
export async function verifyStrandPeerBookFrame(
|
|
225
|
-
strandId: string,
|
|
226
|
-
raw: unknown,
|
|
227
|
-
selfPeerId: string,
|
|
228
|
-
now: number
|
|
229
|
-
): Promise<SignedStrandPeerEntry[]> {
|
|
230
|
-
if (typeof raw !== 'object' || raw === null) {
|
|
231
|
-
throw new Error('strand peer book frame is not an object');
|
|
232
|
-
}
|
|
233
|
-
const { strandId: named, entries } = raw as Record<string, unknown>;
|
|
234
|
-
if (named !== strandId) {
|
|
235
|
-
throw new Error(`strand peer book frame names strand ${String(named)}, expected ${strandId}`);
|
|
236
|
-
}
|
|
237
|
-
if (!Array.isArray(entries)) {
|
|
238
|
-
throw new Error('strand peer book frame entries is not a list');
|
|
239
|
-
}
|
|
240
|
-
if (entries.length > MAX_STRAND_PEERS) {
|
|
241
|
-
throw new Error(`strand peer book frame carries ${entries.length} entries, over the cap of ${MAX_STRAND_PEERS}`);
|
|
242
|
-
}
|
|
243
|
-
const verified = new Map<string, SignedStrandPeerEntry>();
|
|
244
|
-
for (const candidate of entries) {
|
|
245
|
-
const verdict = await verifySignedStrandPeerEntry(strandId, candidate, now);
|
|
246
|
-
if (!verdict.ok) {
|
|
247
|
-
log('strand %s: dropping received entry: %s', strandId, verdict.reason);
|
|
248
|
-
continue;
|
|
249
|
-
}
|
|
250
|
-
if (verdict.entry.peerId === selfPeerId || verified.has(verdict.entry.peerId)) {
|
|
251
|
-
continue;
|
|
252
|
-
}
|
|
253
|
-
verified.set(verdict.entry.peerId, verdict.entry);
|
|
254
|
-
}
|
|
255
|
-
return [...verified.values()];
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
/** The encoded size of a frame, as {@link writeFrame} would send it (the 4-byte prefix aside). */
|
|
259
|
-
function encodedFrameBytes(frame: StrandPeerBookFrame): number {
|
|
260
|
-
return new TextEncoder().encode(JSON.stringify(frame)).length;
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
/**
|
|
264
|
-
* The frame with its LAST entries dropped until it encodes within
|
|
265
|
-
* {@link MAX_BOOK_FRAME_SIZE} and {@link MAX_STRAND_PEERS}. Callers list the entries
|
|
266
|
-
* in the order they would rather keep them (the own entry first, then freshest
|
|
267
|
-
* first), so what goes is the stalest forwarded statement, and a frame that fits is
|
|
268
|
-
* returned as is. Dropping an entry is logged: a forwarded peer the recipient never
|
|
269
|
-
* learns of is worth seeing in a trace.
|
|
270
|
-
*/
|
|
271
|
-
export function trimBookFrameToFit(frame: StrandPeerBookFrame): StrandPeerBookFrame {
|
|
272
|
-
let entries = frame.entries.slice(0, MAX_STRAND_PEERS);
|
|
273
|
-
while (entries.length > 0 && encodedFrameBytes({ strandId: frame.strandId, entries }) > MAX_BOOK_FRAME_SIZE) {
|
|
274
|
-
entries = entries.slice(0, -1);
|
|
275
|
-
}
|
|
276
|
-
if (entries.length === frame.entries.length) return frame;
|
|
277
|
-
log('strand %s: frame trimmed from %d to %d entr(ies) to fit the caps', frame.strandId, frame.entries.length, entries.length);
|
|
278
|
-
return { strandId: frame.strandId, entries };
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
/**
|
|
282
|
-
* Refuse to send a frame the receiver would reject: over the entry cap, or over
|
|
283
|
-
* {@link MAX_BOOK_FRAME_SIZE} once encoded. A caller that built its frame through
|
|
284
|
-
* {@link trimBookFrameToFit} cannot trip this; it is the invariant check, not a
|
|
285
|
-
* runtime condition to handle.
|
|
286
|
-
*/
|
|
287
|
-
export function assertBookFrameFits(frame: StrandPeerBookFrame): void {
|
|
288
|
-
if (frame.entries.length > MAX_STRAND_PEERS) {
|
|
289
|
-
throw new Error(`strand peer book frame carries ${frame.entries.length} entries, over the cap of ${MAX_STRAND_PEERS}`);
|
|
290
|
-
}
|
|
291
|
-
const bytes = encodedFrameBytes(frame);
|
|
292
|
-
if (bytes > MAX_BOOK_FRAME_SIZE) {
|
|
293
|
-
throw new Error(`strand peer book frame is ${bytes} bytes, over the cap of ${MAX_BOOK_FRAME_SIZE}`);
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
/** Read a stream to EOF and decode its single frame, bounded by `timeoutMs` and the size cap. */
|
|
298
|
-
async function readBookFrame(stream: ControlStream, timeoutMs: number): Promise<unknown> {
|
|
299
|
-
const data = await readStreamToEnd(stream, { maxBytes: MAX_BOOK_FRAME_SIZE, timeoutMs, label: 'Strand peer book' });
|
|
300
|
-
return JSON.parse(new TextDecoder().decode(decodeLengthPrefixedFrame(data, MAX_BOOK_FRAME_SIZE)));
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
/** What the {@link StrandPeerBookService} receiver needs from its host, injected so it is testable without a node. */
|
|
304
|
-
export interface StrandPeerBookServiceDeps {
|
|
305
|
-
strandId: string;
|
|
306
|
-
/** This strand node's own peer id: a received entry naming it is never merged. */
|
|
307
|
-
selfPeerId: string;
|
|
308
|
-
/**
|
|
309
|
-
* The entries to answer `forPeerId` with — this node's own signed entry plus every
|
|
310
|
-
* signed entry it holds, minus `forPeerId`'s own (the asker knows where it is).
|
|
311
|
-
*/
|
|
312
|
-
localEntries(forPeerId: string): SignedStrandPeerEntry[];
|
|
313
|
-
/** The asker's VERIFIED entries, for the host to merge. Must not throw. */
|
|
314
|
-
onEntries(entries: SignedStrandPeerEntry[], fromPeerId: string): void;
|
|
315
|
-
/** Clock, for tests. Default `Date.now`. */
|
|
316
|
-
now?: () => number;
|
|
317
|
-
/**
|
|
318
|
-
* How long the receiver waits for the inbound request frame (ms). Required for the same
|
|
319
|
-
* reason as {@link ExchangeStrandPeerBookOptions.timeoutMs}: the swap gives both sides one
|
|
320
|
-
* link-derived deadline.
|
|
321
|
-
*/
|
|
322
|
-
readTimeoutMs: number;
|
|
323
|
-
/** Default {@link DEFAULT_MAX_CONCURRENT}. Over it, an empty frame is returned unread. */
|
|
324
|
-
maxConcurrent?: number;
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
/**
|
|
328
|
-
* Receiver side: registers the protocol on a strand node and, per inbound frame,
|
|
329
|
-
* verifies and hands over the asker's entries, then answers with the local book.
|
|
330
|
-
* Registered through `node.handle`, so on a closed strand every inbound stream
|
|
331
|
-
* passes the revocation gate first — a revoked machine cannot push addresses into
|
|
332
|
-
* anyone's book.
|
|
333
|
-
*/
|
|
334
|
-
export class StrandPeerBookService {
|
|
335
|
-
private readonly readTimeoutMs: number;
|
|
336
|
-
private readonly maxConcurrent: number;
|
|
337
|
-
private readonly now: () => number;
|
|
338
|
-
private node: Libp2p | null = null;
|
|
339
|
-
private activeStreams = 0;
|
|
340
|
-
|
|
341
|
-
constructor(private readonly deps: StrandPeerBookServiceDeps) {
|
|
342
|
-
this.readTimeoutMs = deps.readTimeoutMs;
|
|
343
|
-
this.maxConcurrent = deps.maxConcurrent ?? DEFAULT_MAX_CONCURRENT;
|
|
344
|
-
this.now = deps.now ?? Date.now;
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
/** In-flight inbound streams. */
|
|
348
|
-
get activeCount(): number {
|
|
349
|
-
return this.activeStreams;
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
/** Register the handler. Resolves once libp2p has recorded the protocol on the self record. */
|
|
353
|
-
async initialize(node: Libp2p): Promise<void> {
|
|
354
|
-
this.node = node;
|
|
355
|
-
// `runOnLimitedConnection: true`: strand peers reach each other over relayed
|
|
356
|
-
// (limited) connections as a matter of course, and this is one small frame each way.
|
|
357
|
-
await node.handle(STRAND_PEER_BOOK_PROTOCOL, (rawStream: unknown, rawConnection: unknown) => {
|
|
358
|
-
const remotePeerId = (rawConnection as Connection).remotePeer.toString();
|
|
359
|
-
void this.handleStream(rawStream as ControlStream, remotePeerId);
|
|
360
|
-
}, { runOnLimitedConnection: true });
|
|
361
|
-
log('[%s] registered %s', this.deps.strandId, STRAND_PEER_BOOK_PROTOCOL);
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
/** Unregister the handler and release the node reference. */
|
|
365
|
-
async shutdown(): Promise<void> {
|
|
366
|
-
if (this.node) {
|
|
367
|
-
await this.node.unhandle(STRAND_PEER_BOOK_PROTOCOL);
|
|
368
|
-
this.node = null;
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
/**
|
|
373
|
-
* Read the request, merge what verifies, answer with the local book. Every failure
|
|
374
|
-
* (cap, malformed or oversized frame, wrong strand, read timeout) answers an empty
|
|
375
|
-
* frame rather than hanging or dropping the stream, so the asker's exchange settles.
|
|
376
|
-
*
|
|
377
|
-
* NOTE: the concurrency cap bounds streams in flight, not frames per peer per unit
|
|
378
|
-
* time — the ten-minute throttle is the CLIENT's, so a connected peer can push a
|
|
379
|
-
* verified frame, and the persist it costs, as fast as it likes; see
|
|
380
|
-
* `backlog/debt-strand-peer-book-remote-write-bounds`.
|
|
381
|
-
*/
|
|
382
|
-
private async handleStream(stream: ControlStream, remotePeerId: string): Promise<void> {
|
|
383
|
-
if (this.activeStreams >= this.maxConcurrent) {
|
|
384
|
-
log('[%s] refusing swap from %s: %d streams at the cap', this.deps.strandId, remotePeerId, this.activeStreams);
|
|
385
|
-
this.reply(stream, []);
|
|
386
|
-
await closeQuietly(stream);
|
|
387
|
-
return;
|
|
388
|
-
}
|
|
389
|
-
this.activeStreams++;
|
|
390
|
-
try {
|
|
391
|
-
const raw = await readBookFrame(stream, this.readTimeoutMs);
|
|
392
|
-
const entries = await verifyStrandPeerBookFrame(this.deps.strandId, raw, this.deps.selfPeerId, this.now());
|
|
393
|
-
log('[%s] swap from %s: %d verified entr(ies) received', this.deps.strandId, remotePeerId, entries.length);
|
|
394
|
-
this.deps.onEntries(entries, remotePeerId);
|
|
395
|
-
this.reply(stream, this.deps.localEntries(remotePeerId));
|
|
396
|
-
} catch (error) {
|
|
397
|
-
log('[%s] swap from %s failed: %o', this.deps.strandId, remotePeerId, error);
|
|
398
|
-
this.reply(stream, []);
|
|
399
|
-
} finally {
|
|
400
|
-
this.activeStreams--;
|
|
401
|
-
await closeQuietly(stream);
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
private reply(stream: ControlStream, entries: SignedStrandPeerEntry[]): void {
|
|
406
|
-
try {
|
|
407
|
-
const frame: StrandPeerBookFrame = { strandId: this.deps.strandId, entries };
|
|
408
|
-
assertBookFrameFits(frame);
|
|
409
|
-
writeFrame(stream, frame);
|
|
410
|
-
} catch (error) {
|
|
411
|
-
log('[%s] swap reply failed: %o', this.deps.strandId, error);
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
async function closeQuietly(stream: ControlStream): Promise<void> {
|
|
417
|
-
try {
|
|
418
|
-
await stream.close();
|
|
419
|
-
} catch (error) {
|
|
420
|
-
log('stream close failed (ignored): %o', error);
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
/** Options for {@link exchangeStrandPeerBook}. */
|
|
425
|
-
export interface ExchangeStrandPeerBookOptions {
|
|
426
|
-
/** This strand node's own peer id: a returned entry naming it is dropped. */
|
|
427
|
-
selfPeerId: string;
|
|
428
|
-
/**
|
|
429
|
-
* Whole-exchange deadline (ms). Required, with no fallback here: the exchange crosses the
|
|
430
|
-
* link, so its deadline derives from the declared link round trip (`link-budget.ts`), and
|
|
431
|
-
* the caller is the one that knows the declaration. A default in this module would be a
|
|
432
|
-
* second number free to drift from that derivation.
|
|
433
|
-
*/
|
|
434
|
-
timeoutMs: number;
|
|
435
|
-
/** Clock, for tests. Default `Date.now`. */
|
|
436
|
-
now?: () => number;
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
/**
|
|
440
|
-
* Client side: open one stream on `connection` — the live connection to the peer,
|
|
441
|
-
* relayed or not — send `request`, and return the peer's VERIFIED entries. Throws on
|
|
442
|
-
* any failure (the peer does not speak the protocol, a deadline, a refused frame);
|
|
443
|
-
* the caller logs and moves on, never retrying before its throttle expires.
|
|
444
|
-
*/
|
|
445
|
-
export async function exchangeStrandPeerBook(
|
|
446
|
-
connection: Connection,
|
|
447
|
-
request: StrandPeerBookFrame,
|
|
448
|
-
options: ExchangeStrandPeerBookOptions
|
|
449
|
-
): Promise<SignedStrandPeerEntry[]> {
|
|
450
|
-
const { timeoutMs } = options;
|
|
451
|
-
const remotePeerId = connection.remotePeer.toString();
|
|
452
|
-
assertBookFrameFits(request);
|
|
453
|
-
// One deadline for the stream open and the exchange together: its signal aborts the
|
|
454
|
-
// negotiation and resets a live stream, so neither leaks on a timeout.
|
|
455
|
-
const raw = await withDeadline(timeoutMs, `Strand peer book swap with ${remotePeerId}`, async (signal) => {
|
|
456
|
-
const stream = await connection.newStream(STRAND_PEER_BOOK_PROTOCOL, { runOnLimitedConnection: true, signal });
|
|
457
|
-
return exchangeFrame(
|
|
458
|
-
stream as unknown as ControlStream,
|
|
459
|
-
signal,
|
|
460
|
-
request,
|
|
461
|
-
(s) => readBookFrame(s, timeoutMs),
|
|
462
|
-
'Strand peer book swap aborted by timeout'
|
|
463
|
-
);
|
|
464
|
-
});
|
|
465
|
-
return verifyStrandPeerBookFrame(request.strandId, raw, options.selfPeerId, (options.now ?? Date.now)());
|
|
466
|
-
}
|