@serfab/cadre-core 1.9.0 → 1.11.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 +148 -44
- package/dist/cadre-node.js +363 -139
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-authorization.d.ts +2 -2
- package/dist/control-authorization.js +1 -0
- package/dist/control-authorization.js.map +1 -1
- package/dist/control-cohort.d.ts +1 -1
- package/dist/control-cohort.js +1 -1
- package/dist/control-database.d.ts +105 -14
- package/dist/control-database.js +242 -73
- package/dist/control-database.js.map +1 -1
- package/dist/control-formation-recorder.d.ts +6 -6
- package/dist/control-formation-recorder.js +7 -8
- package/dist/control-formation-recorder.js.map +1 -1
- package/dist/control-schema.d.ts +1 -1
- package/dist/control-schema.js +1066 -979
- package/dist/control-schema.js.map +1 -1
- package/dist/control-write-retry.d.ts +21 -15
- package/dist/control-write-retry.js +21 -15
- package/dist/control-write-retry.js.map +1 -1
- package/dist/diagnostics/connection-path.d.ts +1 -18
- package/dist/diagnostics/connection-path.js +1 -20
- package/dist/diagnostics/connection-path.js.map +1 -1
- package/dist/identity-key.d.ts +0 -30
- package/dist/identity-key.js +7 -40
- package/dist/identity-key.js.map +1 -1
- package/dist/index.d.ts +8 -5
- package/dist/index.js +12 -6
- package/dist/index.js.map +1 -1
- package/dist/invitation-bootstrap.d.ts +32 -0
- package/dist/invitation-bootstrap.js +30 -0
- package/dist/invitation-bootstrap.js.map +1 -0
- package/dist/link-budget.d.ts +204 -119
- package/dist/link-budget.js +208 -120
- package/dist/link-budget.js.map +1 -1
- package/dist/membership-connection-gater.d.ts +3 -3
- package/dist/membership-connection-gater.js +3 -3
- package/dist/peer-dial.d.ts +18 -14
- package/dist/peer-dial.js +18 -14
- package/dist/peer-dial.js.map +1 -1
- package/dist/peer-join-backfill.d.ts +3 -3
- package/dist/pending-join-runner.d.ts +186 -0
- package/dist/pending-join-runner.js +649 -0
- package/dist/pending-join-runner.js.map +1 -0
- package/dist/push-fanout.d.ts +1 -1
- package/dist/push-fanout.js +1 -1
- package/dist/relay-stun.d.ts +24 -0
- package/dist/relay-stun.js +51 -0
- package/dist/relay-stun.js.map +1 -0
- package/dist/seed-bootstrap.d.ts +2 -2
- package/dist/seed-bootstrap.js +5 -5
- package/dist/strand-addr-protocol.js +2 -2
- package/dist/strand-formation-deadlines.d.ts +5 -5
- package/dist/strand-formation-deadlines.js +5 -5
- package/dist/strand-formation-manager.d.ts +18 -4
- package/dist/strand-formation-manager.js +48 -33
- package/dist/strand-formation-manager.js.map +1 -1
- package/dist/strand-formation-protocol.d.ts +71 -14
- package/dist/strand-formation-protocol.js +222 -47
- package/dist/strand-formation-protocol.js.map +1 -1
- package/dist/strand-formation-rejection.d.ts +94 -0
- package/dist/strand-formation-rejection.js +77 -0
- package/dist/strand-formation-rejection.js.map +1 -0
- package/dist/strand-instance-manager.js +4 -1
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-membership-writer.js +32 -32
- package/dist/strand-solicitation.d.ts +11 -3
- package/dist/strand-solicitation.js +9 -0
- package/dist/strand-solicitation.js.map +1 -1
- package/dist/types.d.ts +103 -17
- package/dist/types.js +5 -9
- package/dist/types.js.map +1 -1
- package/package.json +22 -22
- package/src/cadre-node.ts +8654 -8400
- package/src/control-authorization.ts +2 -1
- package/src/control-cohort.ts +128 -128
- package/src/control-database.ts +303 -82
- package/src/control-formation-recorder.ts +353 -354
- package/src/control-read-retry.ts +216 -216
- package/src/control-schema.ts +1078 -991
- package/src/control-write-retry.ts +567 -561
- package/src/diagnostics/connection-path.ts +1 -29
- package/src/formation-approval.ts +610 -610
- package/src/hibernation-manager.ts +638 -638
- package/src/identity-key.ts +7 -62
- package/src/index.ts +644 -627
- package/src/invitation-bootstrap.ts +40 -0
- package/src/joined-strand-store.ts +498 -498
- package/src/link-budget.ts +684 -583
- package/src/membership-connection-gater.ts +570 -570
- package/src/node-local-snapshot.ts +298 -298
- package/src/peer-dial.ts +315 -311
- package/src/peer-join-backfill.ts +710 -710
- package/src/pending-join-runner.ts +735 -0
- package/src/push-fanout.ts +1 -1
- package/src/relay-reservation.ts +1121 -1121
- package/src/relay-server.ts +98 -98
- package/src/relay-stun.ts +58 -0
- package/src/seed-bootstrap.ts +1448 -1448
- package/src/strand-addr-protocol.ts +547 -547
- package/src/strand-database.ts +287 -287
- package/src/strand-first-sync-gate.ts +391 -391
- package/src/strand-formation-deadlines.ts +227 -227
- package/src/strand-formation-manager.ts +725 -700
- package/src/strand-formation-protocol.ts +1036 -802
- package/src/strand-formation-rejection.ts +129 -0
- package/src/strand-fret-addrs.ts +145 -145
- package/src/strand-instance-manager.ts +1789 -1786
- package/src/strand-membership-writer.ts +1974 -1974
- package/src/strand-network-state-file.ts +40 -40
- package/src/strand-network-state.ts +243 -243
- package/src/strand-revocation-enforcer.ts +654 -654
- package/src/strand-solicitation.ts +532 -519
- package/src/strand-wake-protocol.ts +401 -401
- package/src/types.ts +2222 -2140
- package/dist/diagnostics/webrtc-turn-tracker.d.ts +0 -50
- package/dist/diagnostics/webrtc-turn-tracker.js +0 -196
- package/dist/diagnostics/webrtc-turn-tracker.js.map +0 -1
- package/src/diagnostics/webrtc-turn-tracker.ts +0 -222
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a formation dial fails, as types a joining device can branch on without reading the
|
|
3
|
+
* human-facing wording: a responder's refusal carries a fixed {@link FormationRejectionCode}
|
|
4
|
+
* on the wire and surfaces as a {@link FormationRejectedError}; a dial that never got an
|
|
5
|
+
* answer surfaces as a {@link FormationUnreachableError}.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Why a formation responder refused a join. Sent beside the free-text `reason` on every
|
|
10
|
+
* `approved: false` result frame (`FormationResultMessage.code`).
|
|
11
|
+
*/
|
|
12
|
+
export type FormationRejectionCode =
|
|
13
|
+
/**
|
|
14
|
+
* No invitation row for this token on THIS responder. Retryable: a sibling machine of the
|
|
15
|
+
* inviter may not have received the row yet. Leaks nothing — only a holder of the token can
|
|
16
|
+
* ask, and an unknown token means the same as one never minted.
|
|
17
|
+
*/
|
|
18
|
+
| 'token-unknown'
|
|
19
|
+
/**
|
|
20
|
+
* The invitation expired, or every allowed use is recorded. The loser of a redemption race
|
|
21
|
+
* and a latecomer both get this code, so neither can tell the two apart.
|
|
22
|
+
*/
|
|
23
|
+
| 'token-spent'
|
|
24
|
+
/** The joiner's consent signature does not verify (or `partyId` does not embed `peerKey`). */
|
|
25
|
+
| 'consent-invalid'
|
|
26
|
+
/** The responder's disclosure validator refused the disclosure. */
|
|
27
|
+
| 'disclosure-invalid'
|
|
28
|
+
| 'disclosure-too-large'
|
|
29
|
+
/** The invitation's outside approval hook answered no. */
|
|
30
|
+
| 'approval-refused'
|
|
31
|
+
/** The invitation's outside approval hook could not be reached or did not answer in time. */
|
|
32
|
+
| 'approval-unavailable'
|
|
33
|
+
/** The inviter's approval setup is wrong: a malformed answer, an unenrolled key, or a misconfigured hook. */
|
|
34
|
+
| 'approval-invalid'
|
|
35
|
+
/**
|
|
36
|
+
* The host strand is not running (or not yet replicated) on this responder, or issuing the
|
|
37
|
+
* joiner's membership invitation into it failed.
|
|
38
|
+
*/
|
|
39
|
+
| 'host-strand-unavailable'
|
|
40
|
+
/** The host strand predates the per-party identity split and can never admit a joiner. */
|
|
41
|
+
| 'host-strand-must-be-recreated'
|
|
42
|
+
/** The responder is at its cap on concurrent formation sessions. */
|
|
43
|
+
| 'busy'
|
|
44
|
+
/** Provisioning outran the responder's budget; the invitation is left unspent. */
|
|
45
|
+
| 'provisioning-timeout'
|
|
46
|
+
/** The consent write failed for a reason other than a spent invitation. */
|
|
47
|
+
| 'conflict'
|
|
48
|
+
/** An unexpected responder-side failure. */
|
|
49
|
+
| 'internal';
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Which rejections are worth retrying — the single place that says so. A retry repeats the
|
|
53
|
+
* same redemption later (or against another of the inviter's machines); a final code means
|
|
54
|
+
* no retry of this invitation can succeed.
|
|
55
|
+
*/
|
|
56
|
+
export const FORMATION_REJECTION_RETRYABLE: Readonly<Record<FormationRejectionCode, boolean>> = {
|
|
57
|
+
'token-unknown': true,
|
|
58
|
+
'token-spent': false,
|
|
59
|
+
'consent-invalid': false,
|
|
60
|
+
'disclosure-invalid': false,
|
|
61
|
+
'disclosure-too-large': false,
|
|
62
|
+
'approval-refused': false,
|
|
63
|
+
'approval-unavailable': true,
|
|
64
|
+
'approval-invalid': false,
|
|
65
|
+
'host-strand-unavailable': true,
|
|
66
|
+
'host-strand-must-be-recreated': false,
|
|
67
|
+
'busy': true,
|
|
68
|
+
'provisioning-timeout': true,
|
|
69
|
+
'conflict': true,
|
|
70
|
+
'internal': true
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** Is `value` a code this build knows? Own keys only, so a peer's `'constructor'` does not pass. */
|
|
74
|
+
export function isFormationRejectionCode(value: unknown): value is FormationRejectionCode {
|
|
75
|
+
return typeof value === 'string' && Object.prototype.hasOwnProperty.call(FORMATION_REJECTION_RETRYABLE, value);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The responder answered and refused the join.
|
|
80
|
+
*
|
|
81
|
+
* `code` is `'unrecognized'` when the frame carried no code or one this build does not know
|
|
82
|
+
* — formation runs between two parties on different versions — and such a rejection counts
|
|
83
|
+
* as retryable: retrying an unknown answer until the invitation expires is safe, while giving
|
|
84
|
+
* up on one that would have succeeded is not.
|
|
85
|
+
*/
|
|
86
|
+
export class FormationRejectedError extends Error {
|
|
87
|
+
readonly code: FormationRejectionCode | 'unrecognized';
|
|
88
|
+
readonly reason: string;
|
|
89
|
+
readonly retryable: boolean;
|
|
90
|
+
|
|
91
|
+
/** Both arguments come straight off the wire, so neither is trusted to have its declared type. */
|
|
92
|
+
constructor(code: unknown, reason: unknown) {
|
|
93
|
+
const text = typeof reason === 'string' ? reason : 'no reason provided';
|
|
94
|
+
super(`Formation rejected: ${text}`);
|
|
95
|
+
this.name = 'FormationRejectedError';
|
|
96
|
+
this.code = isFormationRejectionCode(code) ? code : 'unrecognized';
|
|
97
|
+
this.reason = text;
|
|
98
|
+
this.retryable = this.code === 'unrecognized' || FORMATION_REJECTION_RETRYABLE[this.code];
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The dial ended before a result frame was read: no responder address parsed, every address
|
|
104
|
+
* failed to dial, a deadline passed, or the stream closed early. Always retryable. Keeps the
|
|
105
|
+
* underlying failure's message, and the failure itself as `cause`.
|
|
106
|
+
*/
|
|
107
|
+
export class FormationUnreachableError extends Error {
|
|
108
|
+
readonly retryable = true;
|
|
109
|
+
|
|
110
|
+
constructor(message: string, options?: { cause?: unknown }) {
|
|
111
|
+
super(message, options);
|
|
112
|
+
this.name = 'FormationUnreachableError';
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The responder approved the join, so the invitation's one-time token is spent, and then a
|
|
118
|
+
* step on the joining machine failed: persisting the party's membership identity, or
|
|
119
|
+
* remembering the join. Never retryable: the same invitation can only answer `token-spent`
|
|
120
|
+
* from now on, so recovery is fixing the local cause and redeeming a fresh invitation.
|
|
121
|
+
*/
|
|
122
|
+
export class FormationPostApprovalError extends Error {
|
|
123
|
+
readonly retryable = false;
|
|
124
|
+
|
|
125
|
+
constructor(readonly strandId: string, message: string, options?: { cause?: unknown }) {
|
|
126
|
+
super(message, options);
|
|
127
|
+
this.name = 'FormationPostApprovalError';
|
|
128
|
+
}
|
|
129
|
+
}
|
package/src/strand-fret-addrs.ts
CHANGED
|
@@ -1,145 +1,145 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Reading the signed address records a running strand node's FRET routing table holds,
|
|
3
|
-
* as per-peer addresses ready for that node's own address book (`peer-addr-book.ts`).
|
|
4
|
-
*
|
|
5
|
-
* Why a strand node needs this. libp2p's peerStore hides every address one hour after
|
|
6
|
-
* it was first observed (the `NOTE:` on `mergePeerAddrs`), and FRET does not re-apply
|
|
7
|
-
* the records it holds: it hands a record to the peerStore when a neighbour's snapshot
|
|
8
|
-
* delivers it or a saved table is imported, and the peerStore refuses a record whose
|
|
9
|
-
* sequence number it already has. So a strand node that has been up for more than an
|
|
10
|
-
* hour would have no address for a peer whose connection then drops, although FRET
|
|
11
|
-
* still holds that peer's record. `CadreNode.refreshStrandPeerAddrs` re-merges the
|
|
12
|
-
* records on every pass, which is what keeps another party's strand nodes dialable;
|
|
13
|
-
* own-party siblings are re-resolved separately, over the strand-addr RPC.
|
|
14
|
-
*
|
|
15
|
-
* How long a record is available here is FRET's rule, not this module's: while FRET
|
|
16
|
-
* holds the entry, and at most 14 days after the record was last confirmed (a peer
|
|
17
|
-
* connected at export time counts as confirmed then).
|
|
18
|
-
*
|
|
19
|
-
* Every record is opened and verified here rather than taken on FRET's word. A record
|
|
20
|
-
* is self-certifying, the table is reached through a structural type rather than an
|
|
21
|
-
* interface this package depends on, and the address book write is attributed from what
|
|
22
|
-
* the record proves, not from the id the table filed it under.
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
import debug from 'debug';
|
|
26
|
-
import { PeerRecord, RecordEnvelope } from '@libp2p/peer-record';
|
|
27
|
-
import { peerIdFromPublicKey } from '@libp2p/peer-id';
|
|
28
|
-
import { multiaddr, type Multiaddr } from '@multiformats/multiaddr';
|
|
29
|
-
import { fromString as uint8ArrayFromString } from 'uint8arrays';
|
|
30
|
-
import { withTrailingPeerId } from './peer-record.js';
|
|
31
|
-
|
|
32
|
-
const log = debug('sereus:cadre:strand-fret-addrs');
|
|
33
|
-
|
|
34
|
-
/** The slice of FRET's exported routing table read here (`SerializedTable` in `p2p-fret`). */
|
|
35
|
-
interface FretTableSlice {
|
|
36
|
-
entries: ReadonlyArray<{
|
|
37
|
-
id: string;
|
|
38
|
-
/** `envelope` is the base64url of a marshaled libp2p record envelope. */
|
|
39
|
-
addressRecord?: { envelope: string };
|
|
40
|
-
}>;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** The slice of FRET's libp2p service called here. */
|
|
44
|
-
interface FretTableSource {
|
|
45
|
-
exportTable(): FretTableSlice;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* The slice of a strand's libp2p node this module touches. Structurally satisfied by
|
|
50
|
-
* `Libp2p`; db-p2p registers FRET as `services.fret`.
|
|
51
|
-
*/
|
|
52
|
-
export interface FretAddrHost {
|
|
53
|
-
peerId: { toString(): string };
|
|
54
|
-
services?: Record<string, unknown>;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/** What one read of a strand node's FRET table yields. */
|
|
58
|
-
export interface FretPeerAddrs {
|
|
59
|
-
/** Peer id → the addresses its record lists, each bound to that peer. Table order. */
|
|
60
|
-
peers: Map<string, Multiaddr[]>;
|
|
61
|
-
/**
|
|
62
|
-
* Records left out because they did not decode, did not verify, or were not signed
|
|
63
|
-
* by and about the peer the table filed them under.
|
|
64
|
-
*/
|
|
65
|
-
rejected: number;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* The addresses of every peer `host`'s FRET table holds a usable signed record for,
|
|
70
|
-
* `host` itself excluded. A node with no FRET service (a db-p2p build without it, a
|
|
71
|
-
* test double) reads as an empty table. One bad record never costs the others: it is
|
|
72
|
-
* counted in `rejected` and skipped.
|
|
73
|
-
*
|
|
74
|
-
* Throws only what `exportTable()` throws, which a caller treats like any other
|
|
75
|
-
* failure of a pass over a node that may be mid-teardown.
|
|
76
|
-
*/
|
|
77
|
-
export async function strandFretPeerAddrs(host: FretAddrHost): Promise<FretPeerAddrs> {
|
|
78
|
-
const result: FretPeerAddrs = { peers: new Map(), rejected: 0 };
|
|
79
|
-
const fret = host.services?.fret;
|
|
80
|
-
if (!isFretTableSource(fret)) {
|
|
81
|
-
return result;
|
|
82
|
-
}
|
|
83
|
-
const selfId = host.peerId.toString();
|
|
84
|
-
for (const entry of fret.exportTable().entries) {
|
|
85
|
-
if (entry.id === selfId || entry.addressRecord === undefined) {
|
|
86
|
-
continue;
|
|
87
|
-
}
|
|
88
|
-
const addrs = await verifiedRecordAddrs(entry.id, entry.addressRecord.envelope);
|
|
89
|
-
if (addrs === null) {
|
|
90
|
-
result.rejected++;
|
|
91
|
-
} else if (addrs.length > 0) {
|
|
92
|
-
result.peers.set(entry.id, addrs);
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
return result;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function isFretTableSource(service: unknown): service is FretTableSource {
|
|
99
|
-
return typeof service === 'object' && service !== null
|
|
100
|
-
&& typeof (service as { exportTable?: unknown }).exportTable === 'function';
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* The addresses one record lists, bound to `peerId`; null when the record must not be
|
|
105
|
-
* used. `openAndCertify` proves only that the envelope's own key signed it, so the
|
|
106
|
-
* signer and the peer the payload describes are each held against `peerId` here:
|
|
107
|
-
* without that, any peer could sign a record rewriting another peer's addresses.
|
|
108
|
-
*/
|
|
109
|
-
async function verifiedRecordAddrs(peerId: string, envelope: string): Promise<Multiaddr[] | null> {
|
|
110
|
-
try {
|
|
111
|
-
const opened = await RecordEnvelope.openAndCertify(uint8ArrayFromString(envelope, 'base64url'), PeerRecord.DOMAIN);
|
|
112
|
-
const record = PeerRecord.createFromProtobuf(opened.payload);
|
|
113
|
-
const signer = peerIdFromPublicKey(opened.publicKey).toString();
|
|
114
|
-
const subject = record.peerId.toString();
|
|
115
|
-
if (signer !== peerId || subject !== peerId) {
|
|
116
|
-
log('skipping the address record filed under %s: signed by %s about %s', peerId, signer, subject);
|
|
117
|
-
return null;
|
|
118
|
-
}
|
|
119
|
-
return bindToPeer(record.multiaddrs.map((addr) => addr.toString()), peerId);
|
|
120
|
-
} catch (error) {
|
|
121
|
-
log('skipping the address record filed under %s: %o', peerId, error);
|
|
122
|
-
return null;
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* A record names its peer once, in `peerId`, so its addresses carry no trailing
|
|
128
|
-
* `/p2p/<peerId>` and a relay-only peer's circuit address ends at the relay hop
|
|
129
|
-
* (`…/p2p/<relay>/p2p-circuit`). Each is given the suffix that makes it a complete
|
|
130
|
-
* dial target; one already terminating in a different peer id does not reach this peer
|
|
131
|
-
* and is dropped.
|
|
132
|
-
*
|
|
133
|
-
* Takes strings because `@libp2p/peer-record` carries its own `@multiformats/multiaddr`
|
|
134
|
-
* copy, a structurally different type from the one `withTrailingPeerId` takes.
|
|
135
|
-
*/
|
|
136
|
-
function bindToPeer(addrs: string[], peerId: string): Multiaddr[] {
|
|
137
|
-
const bound: Multiaddr[] = [];
|
|
138
|
-
for (const addr of addrs) {
|
|
139
|
-
const withPeer = withTrailingPeerId(multiaddr(addr), peerId);
|
|
140
|
-
if (withPeer !== null) {
|
|
141
|
-
bound.push(withPeer);
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
return bound;
|
|
145
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Reading the signed address records a running strand node's FRET routing table holds,
|
|
3
|
+
* as per-peer addresses ready for that node's own address book (`peer-addr-book.ts`).
|
|
4
|
+
*
|
|
5
|
+
* Why a strand node needs this. libp2p's peerStore hides every address one hour after
|
|
6
|
+
* it was first observed (the `NOTE:` on `mergePeerAddrs`), and FRET does not re-apply
|
|
7
|
+
* the records it holds: it hands a record to the peerStore when a neighbour's snapshot
|
|
8
|
+
* delivers it or a saved table is imported, and the peerStore refuses a record whose
|
|
9
|
+
* sequence number it already has. So a strand node that has been up for more than an
|
|
10
|
+
* hour would have no address for a peer whose connection then drops, although FRET
|
|
11
|
+
* still holds that peer's record. `CadreNode.refreshStrandPeerAddrs` re-merges the
|
|
12
|
+
* records on every pass, which is what keeps another party's strand nodes dialable;
|
|
13
|
+
* own-party siblings are re-resolved separately, over the strand-addr RPC.
|
|
14
|
+
*
|
|
15
|
+
* How long a record is available here is FRET's rule, not this module's: while FRET
|
|
16
|
+
* holds the entry, and at most 14 days after the record was last confirmed (a peer
|
|
17
|
+
* connected at export time counts as confirmed then).
|
|
18
|
+
*
|
|
19
|
+
* Every record is opened and verified here rather than taken on FRET's word. A record
|
|
20
|
+
* is self-certifying, the table is reached through a structural type rather than an
|
|
21
|
+
* interface this package depends on, and the address book write is attributed from what
|
|
22
|
+
* the record proves, not from the id the table filed it under.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import debug from 'debug';
|
|
26
|
+
import { PeerRecord, RecordEnvelope } from '@libp2p/peer-record';
|
|
27
|
+
import { peerIdFromPublicKey } from '@libp2p/peer-id';
|
|
28
|
+
import { multiaddr, type Multiaddr } from '@multiformats/multiaddr';
|
|
29
|
+
import { fromString as uint8ArrayFromString } from 'uint8arrays';
|
|
30
|
+
import { withTrailingPeerId } from './peer-record.js';
|
|
31
|
+
|
|
32
|
+
const log = debug('sereus:cadre:strand-fret-addrs');
|
|
33
|
+
|
|
34
|
+
/** The slice of FRET's exported routing table read here (`SerializedTable` in `p2p-fret`). */
|
|
35
|
+
interface FretTableSlice {
|
|
36
|
+
entries: ReadonlyArray<{
|
|
37
|
+
id: string;
|
|
38
|
+
/** `envelope` is the base64url of a marshaled libp2p record envelope. */
|
|
39
|
+
addressRecord?: { envelope: string };
|
|
40
|
+
}>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The slice of FRET's libp2p service called here. */
|
|
44
|
+
interface FretTableSource {
|
|
45
|
+
exportTable(): FretTableSlice;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The slice of a strand's libp2p node this module touches. Structurally satisfied by
|
|
50
|
+
* `Libp2p`; db-p2p registers FRET as `services.fret`.
|
|
51
|
+
*/
|
|
52
|
+
export interface FretAddrHost {
|
|
53
|
+
peerId: { toString(): string };
|
|
54
|
+
services?: Record<string, unknown>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** What one read of a strand node's FRET table yields. */
|
|
58
|
+
export interface FretPeerAddrs {
|
|
59
|
+
/** Peer id → the addresses its record lists, each bound to that peer. Table order. */
|
|
60
|
+
peers: Map<string, Multiaddr[]>;
|
|
61
|
+
/**
|
|
62
|
+
* Records left out because they did not decode, did not verify, or were not signed
|
|
63
|
+
* by and about the peer the table filed them under.
|
|
64
|
+
*/
|
|
65
|
+
rejected: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The addresses of every peer `host`'s FRET table holds a usable signed record for,
|
|
70
|
+
* `host` itself excluded. A node with no FRET service (a db-p2p build without it, a
|
|
71
|
+
* test double) reads as an empty table. One bad record never costs the others: it is
|
|
72
|
+
* counted in `rejected` and skipped.
|
|
73
|
+
*
|
|
74
|
+
* Throws only what `exportTable()` throws, which a caller treats like any other
|
|
75
|
+
* failure of a pass over a node that may be mid-teardown.
|
|
76
|
+
*/
|
|
77
|
+
export async function strandFretPeerAddrs(host: FretAddrHost): Promise<FretPeerAddrs> {
|
|
78
|
+
const result: FretPeerAddrs = { peers: new Map(), rejected: 0 };
|
|
79
|
+
const fret = host.services?.fret;
|
|
80
|
+
if (!isFretTableSource(fret)) {
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
const selfId = host.peerId.toString();
|
|
84
|
+
for (const entry of fret.exportTable().entries) {
|
|
85
|
+
if (entry.id === selfId || entry.addressRecord === undefined) {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const addrs = await verifiedRecordAddrs(entry.id, entry.addressRecord.envelope);
|
|
89
|
+
if (addrs === null) {
|
|
90
|
+
result.rejected++;
|
|
91
|
+
} else if (addrs.length > 0) {
|
|
92
|
+
result.peers.set(entry.id, addrs);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isFretTableSource(service: unknown): service is FretTableSource {
|
|
99
|
+
return typeof service === 'object' && service !== null
|
|
100
|
+
&& typeof (service as { exportTable?: unknown }).exportTable === 'function';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The addresses one record lists, bound to `peerId`; null when the record must not be
|
|
105
|
+
* used. `openAndCertify` proves only that the envelope's own key signed it, so the
|
|
106
|
+
* signer and the peer the payload describes are each held against `peerId` here:
|
|
107
|
+
* without that, any peer could sign a record rewriting another peer's addresses.
|
|
108
|
+
*/
|
|
109
|
+
async function verifiedRecordAddrs(peerId: string, envelope: string): Promise<Multiaddr[] | null> {
|
|
110
|
+
try {
|
|
111
|
+
const opened = await RecordEnvelope.openAndCertify(uint8ArrayFromString(envelope, 'base64url'), PeerRecord.DOMAIN);
|
|
112
|
+
const record = PeerRecord.createFromProtobuf(opened.payload);
|
|
113
|
+
const signer = peerIdFromPublicKey(opened.publicKey).toString();
|
|
114
|
+
const subject = record.peerId.toString();
|
|
115
|
+
if (signer !== peerId || subject !== peerId) {
|
|
116
|
+
log('skipping the address record filed under %s: signed by %s about %s', peerId, signer, subject);
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
return bindToPeer(record.multiaddrs.map((addr) => addr.toString()), peerId);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
log('skipping the address record filed under %s: %o', peerId, error);
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* A record names its peer once, in `peerId`, so its addresses carry no trailing
|
|
128
|
+
* `/p2p/<peerId>` and a relay-only peer's circuit address ends at the relay hop
|
|
129
|
+
* (`…/p2p/<relay>/p2p-circuit`). Each is given the suffix that makes it a complete
|
|
130
|
+
* dial target; one already terminating in a different peer id does not reach this peer
|
|
131
|
+
* and is dropped.
|
|
132
|
+
*
|
|
133
|
+
* Takes strings because `@libp2p/peer-record` carries its own `@multiformats/multiaddr`
|
|
134
|
+
* copy, a structurally different type from the one `withTrailingPeerId` takes.
|
|
135
|
+
*/
|
|
136
|
+
function bindToPeer(addrs: string[], peerId: string): Multiaddr[] {
|
|
137
|
+
const bound: Multiaddr[] = [];
|
|
138
|
+
for (const addr of addrs) {
|
|
139
|
+
const withPeer = withTrailingPeerId(multiaddr(addr), peerId);
|
|
140
|
+
if (withPeer !== null) {
|
|
141
|
+
bound.push(withPeer);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return bound;
|
|
145
|
+
}
|