@serfab/cadre-core 0.12.0 → 0.13.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/README.md +3 -2
- package/dist/cadre-node.d.ts +328 -25
- package/dist/cadre-node.js +760 -59
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-database.d.ts +19 -0
- package/dist/control-database.js +51 -9
- package/dist/control-database.js.map +1 -1
- package/dist/control-schema.d.ts +1 -1
- package/dist/control-schema.js +30 -2
- package/dist/control-schema.js.map +1 -1
- package/dist/enrolled-machine-store-file.d.ts +13 -0
- package/dist/enrolled-machine-store-file.js +41 -0
- package/dist/enrolled-machine-store-file.js.map +1 -0
- package/dist/enrolled-machine-store.d.ts +81 -0
- package/dist/enrolled-machine-store.js +225 -0
- package/dist/enrolled-machine-store.js.map +1 -0
- package/dist/file-durable-slot.js +5 -3
- package/dist/file-durable-slot.js.map +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.js +23 -5
- package/dist/index.js.map +1 -1
- package/dist/membership-connection-gater.d.ts +15 -1
- package/dist/membership-connection-gater.js +9 -3
- package/dist/membership-connection-gater.js.map +1 -1
- package/dist/node-local-snapshot.d.ts +6 -0
- package/dist/node-local-snapshot.js +7 -1
- package/dist/node-local-snapshot.js.map +1 -1
- package/dist/{strand-backfill.d.ts → peer-join-backfill.d.ts} +49 -20
- package/dist/{strand-backfill.js → peer-join-backfill.js} +133 -52
- package/dist/peer-join-backfill.js.map +1 -0
- package/dist/relay-addrs.d.ts +73 -0
- package/dist/relay-addrs.js +195 -0
- package/dist/relay-addrs.js.map +1 -1
- package/dist/strand-database.d.ts +12 -0
- package/dist/strand-database.js +16 -0
- package/dist/strand-database.js.map +1 -1
- package/dist/strand-formation-manager.d.ts +12 -0
- package/dist/strand-formation-manager.js +10 -3
- package/dist/strand-formation-manager.js.map +1 -1
- package/dist/strand-formation-protocol.d.ts +76 -2
- package/dist/strand-formation-protocol.js +78 -2
- package/dist/strand-formation-protocol.js.map +1 -1
- package/dist/strand-instance-manager.d.ts +147 -14
- package/dist/strand-instance-manager.js +225 -45
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-membership-writer.d.ts +12 -0
- package/dist/strand-membership-writer.js +23 -1
- package/dist/strand-membership-writer.js.map +1 -1
- package/dist/strand-network-config.d.ts +84 -0
- package/dist/strand-network-config.js +136 -0
- package/dist/strand-network-config.js.map +1 -0
- package/dist/strand-revocation-enforcer.d.ts +392 -0
- package/dist/strand-revocation-enforcer.js +533 -0
- package/dist/strand-revocation-enforcer.js.map +1 -0
- package/dist/strand-solicitation.d.ts +7 -0
- package/dist/strand-solicitation.js +8 -2
- package/dist/strand-solicitation.js.map +1 -1
- package/dist/strand-watcher.js +6 -1
- package/dist/strand-watcher.js.map +1 -1
- package/dist/types.d.ts +210 -16
- package/dist/types.js +7 -1
- package/dist/types.js.map +1 -1
- package/package.json +16 -8
- package/src/cadre-node.ts +6353 -5603
- package/src/control-database.ts +53 -9
- package/src/control-schema.ts +30 -2
- package/src/enrolled-machine-store-file.ts +43 -0
- package/src/enrolled-machine-store.ts +280 -0
- package/src/file-durable-slot.ts +5 -3
- package/src/index.ts +52 -10
- package/src/membership-connection-gater.ts +9 -3
- package/src/node-local-snapshot.ts +13 -1
- package/src/peer-addr-book.ts +251 -250
- package/src/{strand-backfill.ts → peer-join-backfill.ts} +478 -389
- package/src/relay-addrs.ts +218 -0
- package/src/strand-database.ts +17 -0
- package/src/strand-formation-manager.ts +22 -3
- package/src/strand-formation-protocol.ts +808 -692
- package/src/strand-instance-manager.ts +317 -54
- package/src/strand-membership-writer.ts +23 -1
- package/src/strand-network-config.ts +172 -0
- package/src/strand-revocation-enforcer.ts +653 -0
- package/src/strand-solicitation.ts +484 -471
- package/src/strand-watcher.ts +319 -314
- package/src/types.ts +1554 -1354
- package/dist/strand-backfill.js.map +0 -1
|
@@ -6,7 +6,13 @@
|
|
|
6
6
|
* - the trusted-owner anchor (`trusted-owner-store.ts`),
|
|
7
7
|
* - the cold-start bootstrap-peer store (`bootstrap-peer-store.ts`).
|
|
8
8
|
*
|
|
9
|
-
*
|
|
9
|
+
* A THIRD node-local record — the enrolled-machine count
|
|
10
|
+
* (`enrolled-machine-store.ts`) — reuses {@link DurableSlot} but deliberately
|
|
11
|
+
* NOT this module's snapshot machinery: it is one scalar rather than an entry
|
|
12
|
+
* map, and it must cold-start rather than throw on an unreadable slot. Its
|
|
13
|
+
* module comment carries the reasoning; do not fold it in here.
|
|
14
|
+
*
|
|
15
|
+
* Both records here are the same mechanism with a different payload: a
|
|
10
16
|
* `{ version, partyId, <entries> }` envelope, snapshot-written whole on every
|
|
11
17
|
* change, loaded under one fail-safe-but-not-fail-silent policy. The only part
|
|
12
18
|
* that differs per platform is WHERE the bytes are kept — Node writes a file,
|
|
@@ -37,6 +43,12 @@ export interface DurableSlot {
|
|
|
37
43
|
* failed read reported as `undefined` silently converts a recoverable error
|
|
38
44
|
* (permissions, a blocked database upgrade, an I/O fault) into destruction
|
|
39
45
|
* of a still-intact record on the next save.
|
|
46
|
+
*
|
|
47
|
+
* A slot always reports the fault; what a READER does with it is the
|
|
48
|
+
* reader's policy. The two records here rethrow it; the enrolled-machine
|
|
49
|
+
* count catches it and cold-starts, because losing a repair hint must not
|
|
50
|
+
* stop a node. So an implementation must never soften this to `undefined`
|
|
51
|
+
* on a reader's behalf.
|
|
40
52
|
*/
|
|
41
53
|
load(): Promise<string | undefined>;
|
|
42
54
|
/**
|
package/src/peer-addr-book.ts
CHANGED
|
@@ -1,250 +1,251 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Merging verified cadre addresses into a libp2p node's **address book** (its
|
|
3
|
-
* peerStore), so that every layer below cadre-core which dials by bare peer id
|
|
4
|
-
* — Optimystic's cluster client, repo client, FRET ping/announce, all funnelling
|
|
5
|
-
* into `libp2p.dialProtocol(peerId, …)` — has an address to use for a sibling
|
|
6
|
-
* whose connection has dropped, instead of failing with `NoValidAddressesError`
|
|
7
|
-
* until the next reconcile pass happens to re-dial it.
|
|
8
|
-
*
|
|
9
|
-
* Shared by the control-cohort reconcile pass and the strand one (both in
|
|
10
|
-
* `CadreNode`): each resolves a peer's addresses its own way and then needs
|
|
11
|
-
* exactly this write, with exactly this workaround.
|
|
12
|
-
*
|
|
13
|
-
* Two exports, in the order a caller uses them: {@link groupAddrsByPeerId} turns
|
|
14
|
-
* a flat address list into per-peer groups (the strand path starts from the
|
|
15
|
-
* peer-agnostic union the strand-addr RPC returns), and {@link mergePeerAddrs}
|
|
16
|
-
* writes one peer's group.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import debug from 'debug';
|
|
20
|
-
import { peerIdFromString } from '@libp2p/peer-id';
|
|
21
|
-
import type { Address, Peer, PeerId, PeerStore, TagOptions } from '@libp2p/interface';
|
|
22
|
-
import { multiaddr, CODE_P2P, CODE_P2P_CIRCUIT, type Multiaddr } from '@multiformats/multiaddr';
|
|
23
|
-
|
|
24
|
-
const log = debug('sereus:cadre:peer-addr-book');
|
|
25
|
-
|
|
26
|
-
/** Outcome of one address-book merge, for logging and tests. */
|
|
27
|
-
export type MergeAddrsResult = 'merged' | 'restamped' | 'skipped' | 'failed';
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
* The slice of a libp2p node {@link mergePeerAddrs} touches. Structurally
|
|
31
|
-
* satisfied by `Libp2p`; narrow so a caller (or a test) can pass a bare
|
|
32
|
-
* peerStore holder.
|
|
33
|
-
*/
|
|
34
|
-
export interface PeerAddrBookHost {
|
|
35
|
-
peerStore: PeerStore;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* Write `addrs` into `host`'s address book for `peerId`, best-effort. `peerId`
|
|
40
|
-
* may be the string form callers hold (a `CadrePeer.PeerId` column, a strand-addr
|
|
41
|
-
* RPC result); parsing it is part of the best-effort contract below, so no caller
|
|
42
|
-
* has to wrap this in a second try/catch of its own.
|
|
43
|
-
*
|
|
44
|
-
* - **Empty `addrs` writes nothing** and reports `'skipped'`. That is how a
|
|
45
|
-
* revoked / stale / untrusted peer's existing entry is allowed to age out on
|
|
46
|
-
* its own: the caller resolves `[]` for it, and nothing here refreshes it.
|
|
47
|
-
* - Otherwise the addresses are merged, and — see the timestamp trap below —
|
|
48
|
-
* re-saved when the merge alone leaves them invisible (`'restamped'`).
|
|
49
|
-
* - Any throw (unparsable peer id, datastore failure, a peerStore double without
|
|
50
|
-
* `merge`) is logged and folded to `'failed'`. Callers are best-effort loops:
|
|
51
|
-
* an address-book failure must never abort a reconcile pass.
|
|
52
|
-
*
|
|
53
|
-
* NOTE: `peerStore.merge` cannot refresh an address's `observed` timestamp in
|
|
54
|
-
* `@libp2p/peer-store` 12.0.10 — `to-peer-pb.js` shadows its own loop variable
|
|
55
|
-
* (`…addresses?.find(addr => uint8ArrayEquals(addr.multiaddr, addr.multiaddr))`,
|
|
56
|
-
* always true, so it returns the FIRST stored address's timestamp), and every
|
|
57
|
-
* read filters out addresses older than `MAX_ADDRESS_AGE` (1 hour). So an entry
|
|
58
|
-
* silently dies at the one-hour mark and no amount of re-merging revives it —
|
|
59
|
-
* not even merging a brand-new address. The `save` below is the workaround:
|
|
60
|
-
* `save` is the one write path that omits `existingPeer` and therefore stamps
|
|
61
|
-
* `Date.now()`. Drop it once upstream stamps `Date.now()` on merge.
|
|
62
|
-
*
|
|
63
|
-
* NOTE: raising the store's own `maxAddressAge` (libp2p forwards `init.peerStore`
|
|
64
|
-
* straight into `persistentPeerStore`) would sidestep the bug in one line, and is
|
|
65
|
-
* deliberately NOT done: expiry is load-bearing here. Only *verified* addresses
|
|
66
|
-
* come through this helper, and the design relies on everything else — the
|
|
67
|
-
* cold-start seed entries, identify-learned addresses — still ageing out on the
|
|
68
|
-
* stock schedule. A global age bump would keep those alive too.
|
|
69
|
-
*
|
|
70
|
-
* NOTE: if the store ever REJECTS an address we hand it, the restamp repeats
|
|
71
|
-
* every pass forever — `allVisible` stays false, and `save` re-submits the same
|
|
72
|
-
* rejected address. libp2p wires the store's `addressFilter` to
|
|
73
|
-
* `connectionGater.filterMultiaddrForPeer`, which nothing in this repo implements
|
|
74
|
-
* today (`createMembershipConnectionGater` gates dials, not addresses), so the
|
|
75
|
-
* loop is unreachable. If an app ever supplies that gater hook via
|
|
76
|
-
* `NetworkConfig.connectionGater`, bound the retry — the cost is one redundant
|
|
77
|
-
* datastore write per gated sibling per reconcile pass.
|
|
78
|
-
*/
|
|
79
|
-
export async function mergePeerAddrs(
|
|
80
|
-
host: PeerAddrBookHost,
|
|
81
|
-
peerId: PeerId | string,
|
|
82
|
-
addrs: Multiaddr[]
|
|
83
|
-
): Promise<MergeAddrsResult> {
|
|
84
|
-
if (addrs.length === 0) {
|
|
85
|
-
return 'skipped';
|
|
86
|
-
}
|
|
87
|
-
try {
|
|
88
|
-
const id = typeof peerId === 'string' ? peerIdFromString(peerId) : peerId;
|
|
89
|
-
const peer = await host.peerStore.merge(id, { multiaddrs: addrs });
|
|
90
|
-
// The returned Peer is already expiry-filtered, so it says whether the merge
|
|
91
|
-
// is actually VISIBLE — which is the only question that matters downstream.
|
|
92
|
-
if (allVisible(peer.addresses, addrs, id)) {
|
|
93
|
-
return 'merged';
|
|
94
|
-
}
|
|
95
|
-
await host.peerStore.save(id, restampData(peer, addrs, id));
|
|
96
|
-
return 'restamped';
|
|
97
|
-
} catch (error) {
|
|
98
|
-
log('mergePeerAddrs: address-book write for %s failed: %o', peerId.toString(), error);
|
|
99
|
-
return 'failed';
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* Group multiaddr strings by the peer id in their final `/p2p/` component — the
|
|
105
|
-
* attribution step between a flat, peer-agnostic address list and the per-peer
|
|
106
|
-
* writes {@link mergePeerAddrs} takes.
|
|
107
|
-
*
|
|
108
|
-
* The **last** `/p2p/` component is the addressed peer, not the first: a relayed
|
|
109
|
-
* address (`…/p2p/<relay>/p2p-circuit/p2p/<dst>`) names the relay first and the
|
|
110
|
-
* destination last, while a direct address (`…/tcp/…/p2p/<dst>`) names only the
|
|
111
|
-
* destination. Taking the last therefore attributes both shapes to the peer the
|
|
112
|
-
* address actually reaches.
|
|
113
|
-
*
|
|
114
|
-
* Three shapes are dropped rather than attributed, because none of them names a
|
|
115
|
-
* peer the address reaches: one that does not parse, one carrying no `/p2p/`
|
|
116
|
-
* component at all (a peer advertising a bare listen addr), and one whose
|
|
117
|
-
* `p2p-circuit` comes AFTER its last `/p2p/` (`…/p2p/<relay>/p2p-circuit`) — that
|
|
118
|
-
* last one is a relay hop with the destination missing, so its trailing peer id
|
|
119
|
-
* is the RELAY's and filing it under the relay would claim the relay is reachable
|
|
120
|
-
* at a circuit leading nowhere. Duplicates collapse within a group. Insertion
|
|
121
|
-
* order is preserved, both between groups and inside one.
|
|
122
|
-
*/
|
|
123
|
-
export function groupAddrsByPeerId(addrs: string[]): Map<string, Multiaddr[]> {
|
|
124
|
-
const groups = new Map<string, Multiaddr[]>();
|
|
125
|
-
const seen = new Set<string>();
|
|
126
|
-
for (const addr of addrs) {
|
|
127
|
-
const attributed = attributeAddr(addr);
|
|
128
|
-
if (!attributed) {
|
|
129
|
-
continue;
|
|
130
|
-
}
|
|
131
|
-
const key = `${attributed.peerId}\n${addr}`;
|
|
132
|
-
if (seen.has(key)) {
|
|
133
|
-
continue;
|
|
134
|
-
}
|
|
135
|
-
seen.add(key);
|
|
136
|
-
const group = groups.get(attributed.peerId);
|
|
137
|
-
if (group) {
|
|
138
|
-
group.push(attributed.multiaddr);
|
|
139
|
-
} else {
|
|
140
|
-
groups.set(attributed.peerId, [attributed.multiaddr]);
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
return groups;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* Parse one address and pull out the peer id it addresses (its last `/p2p/`
|
|
148
|
-
* value); null when the address is unparsable or names no destination. Every case
|
|
149
|
-
* is logged at the same level — an unattributable address is a sibling telling us
|
|
150
|
-
* something we cannot use, which is worth seeing but never worth throwing over.
|
|
151
|
-
*/
|
|
152
|
-
function attributeAddr(addr: string): { peerId: string; multiaddr: Multiaddr } | null {
|
|
153
|
-
let parsed: Multiaddr;
|
|
154
|
-
try {
|
|
155
|
-
parsed = multiaddr(addr);
|
|
156
|
-
} catch (error) {
|
|
157
|
-
log('groupAddrsByPeerId: skipping unparsable addr %s: %o', addr, error);
|
|
158
|
-
return null;
|
|
159
|
-
}
|
|
160
|
-
const peerId = lastAddressedPeerId(parsed);
|
|
161
|
-
if (peerId === null) {
|
|
162
|
-
log('groupAddrsByPeerId: skipping addr naming no destination peer: %s', addr);
|
|
163
|
-
return null;
|
|
164
|
-
}
|
|
165
|
-
return { peerId, multiaddr: parsed };
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* The peer id a parsed address reaches: its last `/p2p/` value, or null when it
|
|
170
|
-
* has none — or when a `p2p-circuit` sits after it, which makes that value the
|
|
171
|
-
* relay's rather than a destination's (see {@link groupAddrsByPeerId}). Walking
|
|
172
|
-
* backwards, meeting the circuit first is exactly that case.
|
|
173
|
-
*/
|
|
174
|
-
function lastAddressedPeerId(parsed: Multiaddr): string | null {
|
|
175
|
-
const components = parsed.getComponents();
|
|
176
|
-
for (let i = components.length - 1; i >= 0; i--) {
|
|
177
|
-
const component = components[i];
|
|
178
|
-
if (component.code === CODE_P2P_CIRCUIT) {
|
|
179
|
-
return null;
|
|
180
|
-
}
|
|
181
|
-
if (component.code === CODE_P2P && component.value) {
|
|
182
|
-
return component.value;
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
return null;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/**
|
|
189
|
-
* The peerStore strips a trailing `/p2p/<peerId>` from an address it stores when
|
|
190
|
-
* that id is the address's FIRST `/p2p/` component (`dedupe-addresses.js`), so a
|
|
191
|
-
* plain address round-trips without its suffix while a relayed one — whose first
|
|
192
|
-
* `/p2p/` names the relay — keeps it. Comparing and de-duplicating on this
|
|
193
|
-
* suffix-stripped form matches either shape.
|
|
194
|
-
*/
|
|
195
|
-
function addrKey(addr: string, peerId: PeerId): string {
|
|
196
|
-
const suffix = `/p2p/${peerId.toString()}`;
|
|
197
|
-
return addr.endsWith(suffix) ? addr.slice(0, -suffix.length) : addr;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
/** Is every address in `addrs` readable back from the stored, expiry-filtered set? */
|
|
201
|
-
function allVisible(stored: Address[], addrs: Multiaddr[], peerId: PeerId): boolean {
|
|
202
|
-
const keys = new Set(stored.map((a) => addrKey(a.multiaddr.toString(), peerId)));
|
|
203
|
-
return addrs.every((addr) => keys.has(addrKey(addr.toString(), peerId)));
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/**
|
|
207
|
-
* The full `save` payload for a restamp: `save` re-uses the `patch` strategy with
|
|
208
|
-
* no existing peer, so every field it is not handed is DROPPED — and Optimystic
|
|
209
|
-
* classifies peers by their peerStore protocol list (`libp2p-key-network.ts`
|
|
210
|
-
* `membershipOf`), so losing protocols here would break cluster/repo routing.
|
|
211
|
-
* Everything therefore carries forward from the just-merged `peer`.
|
|
212
|
-
*
|
|
213
|
-
* The read-modify-write is NOT atomic against a concurrent `identify` write to
|
|
214
|
-
* the same entry. The loss window is milliseconds and costs at most one identify
|
|
215
|
-
* update, recovered by the next identify push — not worth a lock.
|
|
216
|
-
*
|
|
217
|
-
* A tag's remaining TTL is not carried: the public `Tag` type exposes only
|
|
218
|
-
* `value`, so the stored expiry is unreadable from here and the tag comes back
|
|
219
|
-
* non-expiring. `publicKey` is likewise not passed, which is lossless for the
|
|
220
|
-
* Ed25519 peer ids a cadre uses (the key is embedded in the id).
|
|
221
|
-
*/
|
|
222
|
-
function restampData(peer: Peer, addrs: Multiaddr[], peerId: PeerId): {
|
|
223
|
-
addresses: Address[];
|
|
224
|
-
protocols: string[];
|
|
225
|
-
metadata: Map<string, Uint8Array | undefined>;
|
|
226
|
-
tags: Map<string, TagOptions | undefined>;
|
|
227
|
-
peerRecordEnvelope?: Uint8Array;
|
|
228
|
-
} {
|
|
229
|
-
const addresses = new Map<string, Address>();
|
|
230
|
-
for (const address of peer.addresses) {
|
|
231
|
-
addresses.set(addrKey(address.multiaddr.toString(), peerId), address);
|
|
232
|
-
}
|
|
233
|
-
for (const addr of addrs) {
|
|
234
|
-
const key = addrKey(addr.toString(), peerId);
|
|
235
|
-
if (!addresses.has(key)) {
|
|
236
|
-
// Uncertified, matching what `merge`'s own `multiaddrs` path would have
|
|
237
|
-
// stored; an address already present keeps whatever flag it carried.
|
|
238
|
-
addresses.set(key, { multiaddr: addr, isCertified: false });
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
return {
|
|
242
|
-
addresses: [...addresses.values()],
|
|
243
|
-
protocols: peer.protocols,
|
|
244
|
-
metadata: new Map<string, Uint8Array | undefined>(peer.metadata),
|
|
245
|
-
tags: new Map<string, TagOptions | undefined>(
|
|
246
|
-
[...peer.tags].map(([name, tag]) => [name, { value: tag.value }])
|
|
247
|
-
),
|
|
248
|
-
peerRecordEnvelope: peer.peerRecordEnvelope
|
|
249
|
-
};
|
|
250
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Merging verified cadre addresses into a libp2p node's **address book** (its
|
|
3
|
+
* peerStore), so that every layer below cadre-core which dials by bare peer id
|
|
4
|
+
* — Optimystic's cluster client, repo client, FRET ping/announce, all funnelling
|
|
5
|
+
* into `libp2p.dialProtocol(peerId, …)` — has an address to use for a sibling
|
|
6
|
+
* whose connection has dropped, instead of failing with `NoValidAddressesError`
|
|
7
|
+
* until the next reconcile pass happens to re-dial it.
|
|
8
|
+
*
|
|
9
|
+
* Shared by the control-cohort reconcile pass and the strand one (both in
|
|
10
|
+
* `CadreNode`): each resolves a peer's addresses its own way and then needs
|
|
11
|
+
* exactly this write, with exactly this workaround.
|
|
12
|
+
*
|
|
13
|
+
* Two exports, in the order a caller uses them: {@link groupAddrsByPeerId} turns
|
|
14
|
+
* a flat address list into per-peer groups (the strand path starts from the
|
|
15
|
+
* peer-agnostic union the strand-addr RPC returns), and {@link mergePeerAddrs}
|
|
16
|
+
* writes one peer's group.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import debug from 'debug';
|
|
20
|
+
import { peerIdFromString } from '@libp2p/peer-id';
|
|
21
|
+
import type { Address, Peer, PeerId, PeerStore, TagOptions } from '@libp2p/interface';
|
|
22
|
+
import { multiaddr, CODE_P2P, CODE_P2P_CIRCUIT, type Multiaddr } from '@multiformats/multiaddr';
|
|
23
|
+
|
|
24
|
+
const log = debug('sereus:cadre:peer-addr-book');
|
|
25
|
+
|
|
26
|
+
/** Outcome of one address-book merge, for logging and tests. */
|
|
27
|
+
export type MergeAddrsResult = 'merged' | 'restamped' | 'skipped' | 'failed';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The slice of a libp2p node {@link mergePeerAddrs} touches. Structurally
|
|
31
|
+
* satisfied by `Libp2p`; narrow so a caller (or a test) can pass a bare
|
|
32
|
+
* peerStore holder.
|
|
33
|
+
*/
|
|
34
|
+
export interface PeerAddrBookHost {
|
|
35
|
+
peerStore: PeerStore;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Write `addrs` into `host`'s address book for `peerId`, best-effort. `peerId`
|
|
40
|
+
* may be the string form callers hold (a `CadrePeer.PeerId` column, a strand-addr
|
|
41
|
+
* RPC result); parsing it is part of the best-effort contract below, so no caller
|
|
42
|
+
* has to wrap this in a second try/catch of its own.
|
|
43
|
+
*
|
|
44
|
+
* - **Empty `addrs` writes nothing** and reports `'skipped'`. That is how a
|
|
45
|
+
* revoked / stale / untrusted peer's existing entry is allowed to age out on
|
|
46
|
+
* its own: the caller resolves `[]` for it, and nothing here refreshes it.
|
|
47
|
+
* - Otherwise the addresses are merged, and — see the timestamp trap below —
|
|
48
|
+
* re-saved when the merge alone leaves them invisible (`'restamped'`).
|
|
49
|
+
* - Any throw (unparsable peer id, datastore failure, a peerStore double without
|
|
50
|
+
* `merge`) is logged and folded to `'failed'`. Callers are best-effort loops:
|
|
51
|
+
* an address-book failure must never abort a reconcile pass.
|
|
52
|
+
*
|
|
53
|
+
* NOTE: `peerStore.merge` cannot refresh an address's `observed` timestamp in
|
|
54
|
+
* `@libp2p/peer-store` 12.0.10 — `to-peer-pb.js` shadows its own loop variable
|
|
55
|
+
* (`…addresses?.find(addr => uint8ArrayEquals(addr.multiaddr, addr.multiaddr))`,
|
|
56
|
+
* always true, so it returns the FIRST stored address's timestamp), and every
|
|
57
|
+
* read filters out addresses older than `MAX_ADDRESS_AGE` (1 hour). So an entry
|
|
58
|
+
* silently dies at the one-hour mark and no amount of re-merging revives it —
|
|
59
|
+
* not even merging a brand-new address. The `save` below is the workaround:
|
|
60
|
+
* `save` is the one write path that omits `existingPeer` and therefore stamps
|
|
61
|
+
* `Date.now()`. Drop it once upstream stamps `Date.now()` on merge.
|
|
62
|
+
*
|
|
63
|
+
* NOTE: raising the store's own `maxAddressAge` (libp2p forwards `init.peerStore`
|
|
64
|
+
* straight into `persistentPeerStore`) would sidestep the bug in one line, and is
|
|
65
|
+
* deliberately NOT done: expiry is load-bearing here. Only *verified* addresses
|
|
66
|
+
* come through this helper, and the design relies on everything else — the
|
|
67
|
+
* cold-start seed entries, identify-learned addresses — still ageing out on the
|
|
68
|
+
* stock schedule. A global age bump would keep those alive too.
|
|
69
|
+
*
|
|
70
|
+
* NOTE: if the store ever REJECTS an address we hand it, the restamp repeats
|
|
71
|
+
* every pass forever — `allVisible` stays false, and `save` re-submits the same
|
|
72
|
+
* rejected address. libp2p wires the store's `addressFilter` to
|
|
73
|
+
* `connectionGater.filterMultiaddrForPeer`, which nothing in this repo implements
|
|
74
|
+
* today (`createMembershipConnectionGater` gates dials, not addresses), so the
|
|
75
|
+
* loop is unreachable. If an app ever supplies that gater hook via
|
|
76
|
+
* `NetworkConfig.connectionGater`, bound the retry — the cost is one redundant
|
|
77
|
+
* datastore write per gated sibling per reconcile pass.
|
|
78
|
+
*/
|
|
79
|
+
export async function mergePeerAddrs(
|
|
80
|
+
host: PeerAddrBookHost,
|
|
81
|
+
peerId: PeerId | string,
|
|
82
|
+
addrs: Multiaddr[]
|
|
83
|
+
): Promise<MergeAddrsResult> {
|
|
84
|
+
if (addrs.length === 0) {
|
|
85
|
+
return 'skipped';
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
const id = typeof peerId === 'string' ? peerIdFromString(peerId) : peerId;
|
|
89
|
+
const peer = await host.peerStore.merge(id, { multiaddrs: addrs });
|
|
90
|
+
// The returned Peer is already expiry-filtered, so it says whether the merge
|
|
91
|
+
// is actually VISIBLE — which is the only question that matters downstream.
|
|
92
|
+
if (allVisible(peer.addresses, addrs, id)) {
|
|
93
|
+
return 'merged';
|
|
94
|
+
}
|
|
95
|
+
await host.peerStore.save(id, restampData(peer, addrs, id));
|
|
96
|
+
return 'restamped';
|
|
97
|
+
} catch (error) {
|
|
98
|
+
log('mergePeerAddrs: address-book write for %s failed: %o', peerId.toString(), error);
|
|
99
|
+
return 'failed';
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Group multiaddr strings by the peer id in their final `/p2p/` component — the
|
|
105
|
+
* attribution step between a flat, peer-agnostic address list and the per-peer
|
|
106
|
+
* writes {@link mergePeerAddrs} takes.
|
|
107
|
+
*
|
|
108
|
+
* The **last** `/p2p/` component is the addressed peer, not the first: a relayed
|
|
109
|
+
* address (`…/p2p/<relay>/p2p-circuit/p2p/<dst>`) names the relay first and the
|
|
110
|
+
* destination last, while a direct address (`…/tcp/…/p2p/<dst>`) names only the
|
|
111
|
+
* destination. Taking the last therefore attributes both shapes to the peer the
|
|
112
|
+
* address actually reaches.
|
|
113
|
+
*
|
|
114
|
+
* Three shapes are dropped rather than attributed, because none of them names a
|
|
115
|
+
* peer the address reaches: one that does not parse, one carrying no `/p2p/`
|
|
116
|
+
* component at all (a peer advertising a bare listen addr), and one whose
|
|
117
|
+
* `p2p-circuit` comes AFTER its last `/p2p/` (`…/p2p/<relay>/p2p-circuit`) — that
|
|
118
|
+
* last one is a relay hop with the destination missing, so its trailing peer id
|
|
119
|
+
* is the RELAY's and filing it under the relay would claim the relay is reachable
|
|
120
|
+
* at a circuit leading nowhere. Duplicates collapse within a group. Insertion
|
|
121
|
+
* order is preserved, both between groups and inside one.
|
|
122
|
+
*/
|
|
123
|
+
export function groupAddrsByPeerId(addrs: string[]): Map<string, Multiaddr[]> {
|
|
124
|
+
const groups = new Map<string, Multiaddr[]>();
|
|
125
|
+
const seen = new Set<string>();
|
|
126
|
+
for (const addr of addrs) {
|
|
127
|
+
const attributed = attributeAddr(addr);
|
|
128
|
+
if (!attributed) {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const key = `${attributed.peerId}\n${addr}`;
|
|
132
|
+
if (seen.has(key)) {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
seen.add(key);
|
|
136
|
+
const group = groups.get(attributed.peerId);
|
|
137
|
+
if (group) {
|
|
138
|
+
group.push(attributed.multiaddr);
|
|
139
|
+
} else {
|
|
140
|
+
groups.set(attributed.peerId, [attributed.multiaddr]);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return groups;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Parse one address and pull out the peer id it addresses (its last `/p2p/`
|
|
148
|
+
* value); null when the address is unparsable or names no destination. Every case
|
|
149
|
+
* is logged at the same level — an unattributable address is a sibling telling us
|
|
150
|
+
* something we cannot use, which is worth seeing but never worth throwing over.
|
|
151
|
+
*/
|
|
152
|
+
function attributeAddr(addr: string): { peerId: string; multiaddr: Multiaddr } | null {
|
|
153
|
+
let parsed: Multiaddr;
|
|
154
|
+
try {
|
|
155
|
+
parsed = multiaddr(addr);
|
|
156
|
+
} catch (error) {
|
|
157
|
+
log('groupAddrsByPeerId: skipping unparsable addr %s: %o', addr, error);
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
const peerId = lastAddressedPeerId(parsed);
|
|
161
|
+
if (peerId === null) {
|
|
162
|
+
log('groupAddrsByPeerId: skipping addr naming no destination peer: %s', addr);
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
return { peerId, multiaddr: parsed };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The peer id a parsed address reaches: its last `/p2p/` value, or null when it
|
|
170
|
+
* has none — or when a `p2p-circuit` sits after it, which makes that value the
|
|
171
|
+
* relay's rather than a destination's (see {@link groupAddrsByPeerId}). Walking
|
|
172
|
+
* backwards, meeting the circuit first is exactly that case.
|
|
173
|
+
*/
|
|
174
|
+
function lastAddressedPeerId(parsed: Multiaddr): string | null {
|
|
175
|
+
const components = parsed.getComponents();
|
|
176
|
+
for (let i = components.length - 1; i >= 0; i--) {
|
|
177
|
+
const component = components[i];
|
|
178
|
+
if (component.code === CODE_P2P_CIRCUIT) {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
if (component.code === CODE_P2P && component.value) {
|
|
182
|
+
return component.value;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The peerStore strips a trailing `/p2p/<peerId>` from an address it stores when
|
|
190
|
+
* that id is the address's FIRST `/p2p/` component (`dedupe-addresses.js`), so a
|
|
191
|
+
* plain address round-trips without its suffix while a relayed one — whose first
|
|
192
|
+
* `/p2p/` names the relay — keeps it. Comparing and de-duplicating on this
|
|
193
|
+
* suffix-stripped form matches either shape.
|
|
194
|
+
*/
|
|
195
|
+
function addrKey(addr: string, peerId: PeerId): string {
|
|
196
|
+
const suffix = `/p2p/${peerId.toString()}`;
|
|
197
|
+
return addr.endsWith(suffix) ? addr.slice(0, -suffix.length) : addr;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Is every address in `addrs` readable back from the stored, expiry-filtered set? */
|
|
201
|
+
function allVisible(stored: Address[], addrs: Multiaddr[], peerId: PeerId): boolean {
|
|
202
|
+
const keys = new Set(stored.map((a) => addrKey(a.multiaddr.toString(), peerId)));
|
|
203
|
+
return addrs.every((addr) => keys.has(addrKey(addr.toString(), peerId)));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The full `save` payload for a restamp: `save` re-uses the `patch` strategy with
|
|
208
|
+
* no existing peer, so every field it is not handed is DROPPED — and Optimystic
|
|
209
|
+
* classifies peers by their peerStore protocol list (`libp2p-key-network.ts`
|
|
210
|
+
* `membershipOf`), so losing protocols here would break cluster/repo routing.
|
|
211
|
+
* Everything therefore carries forward from the just-merged `peer`.
|
|
212
|
+
*
|
|
213
|
+
* The read-modify-write is NOT atomic against a concurrent `identify` write to
|
|
214
|
+
* the same entry. The loss window is milliseconds and costs at most one identify
|
|
215
|
+
* update, recovered by the next identify push — not worth a lock.
|
|
216
|
+
*
|
|
217
|
+
* A tag's remaining TTL is not carried: the public `Tag` type exposes only
|
|
218
|
+
* `value`, so the stored expiry is unreadable from here and the tag comes back
|
|
219
|
+
* non-expiring. `publicKey` is likewise not passed, which is lossless for the
|
|
220
|
+
* Ed25519 peer ids a cadre uses (the key is embedded in the id).
|
|
221
|
+
*/
|
|
222
|
+
function restampData(peer: Peer, addrs: Multiaddr[], peerId: PeerId): {
|
|
223
|
+
addresses: Address[];
|
|
224
|
+
protocols: string[];
|
|
225
|
+
metadata: Map<string, Uint8Array | undefined>;
|
|
226
|
+
tags: Map<string, TagOptions | undefined>;
|
|
227
|
+
peerRecordEnvelope?: Uint8Array;
|
|
228
|
+
} {
|
|
229
|
+
const addresses = new Map<string, Address>();
|
|
230
|
+
for (const address of peer.addresses) {
|
|
231
|
+
addresses.set(addrKey(address.multiaddr.toString(), peerId), address);
|
|
232
|
+
}
|
|
233
|
+
for (const addr of addrs) {
|
|
234
|
+
const key = addrKey(addr.toString(), peerId);
|
|
235
|
+
if (!addresses.has(key)) {
|
|
236
|
+
// Uncertified, matching what `merge`'s own `multiaddrs` path would have
|
|
237
|
+
// stored; an address already present keeps whatever flag it carried.
|
|
238
|
+
addresses.set(key, { multiaddr: addr, isCertified: false });
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
addresses: [...addresses.values()],
|
|
243
|
+
protocols: peer.protocols,
|
|
244
|
+
metadata: new Map<string, Uint8Array | undefined>(peer.metadata),
|
|
245
|
+
tags: new Map<string, TagOptions | undefined>(
|
|
246
|
+
[...peer.tags].map(([name, tag]) => [name, { value: tag.value }])
|
|
247
|
+
),
|
|
248
|
+
peerRecordEnvelope: peer.peerRecordEnvelope
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|