@serfab/cadre-core 0.11.0 → 0.12.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/announce-addrs.d.ts +49 -0
- package/dist/announce-addrs.js +78 -0
- package/dist/announce-addrs.js.map +1 -0
- package/dist/cadre-node.d.ts +281 -30
- package/dist/cadre-node.js +513 -103
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-database.d.ts +155 -23
- package/dist/control-database.js +272 -104
- package/dist/control-database.js.map +1 -1
- package/dist/control-read-retry.d.ts +103 -0
- package/dist/control-read-retry.js +145 -0
- package/dist/control-read-retry.js.map +1 -0
- package/dist/control-retry.d.ts +101 -0
- package/dist/control-retry.js +108 -0
- package/dist/control-retry.js.map +1 -0
- package/dist/control-schema.d.ts +1 -1
- package/dist/control-schema.js +20 -4
- package/dist/control-schema.js.map +1 -1
- package/dist/control-write-retry.d.ts +59 -35
- package/dist/control-write-retry.js +21 -92
- package/dist/control-write-retry.js.map +1 -1
- package/dist/delegate-admission.d.ts +17 -15
- package/dist/delegate-admission.js +17 -15
- package/dist/delegate-admission.js.map +1 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.js +17 -8
- package/dist/index.js.map +1 -1
- package/dist/membership-connection-gater.d.ts +211 -25
- package/dist/membership-connection-gater.js +309 -29
- package/dist/membership-connection-gater.js.map +1 -1
- package/dist/peer-record.d.ts +26 -0
- package/dist/peer-record.js +32 -0
- package/dist/peer-record.js.map +1 -1
- package/dist/relay-addrs.d.ts +76 -26
- package/dist/relay-addrs.js +115 -27
- package/dist/relay-addrs.js.map +1 -1
- package/dist/relay-reservation.d.ts +20 -15
- package/dist/relay-reservation.js +18 -14
- package/dist/relay-reservation.js.map +1 -1
- package/dist/strand-addr-protocol.js +16 -0
- package/dist/strand-addr-protocol.js.map +1 -1
- package/dist/strand-backfill.js +13 -4
- package/dist/strand-backfill.js.map +1 -1
- package/dist/strand-formation-manager.d.ts +10 -9
- package/dist/strand-formation-manager.js +10 -9
- package/dist/strand-formation-manager.js.map +1 -1
- package/dist/strand-formation-protocol.d.ts +7 -7
- package/dist/strand-formation-protocol.js +8 -8
- package/dist/strand-formation-protocol.js.map +1 -1
- package/dist/strand-instance-manager.d.ts +4 -3
- package/dist/strand-instance-manager.js +31 -6
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-membership-writer.d.ts +106 -14
- package/dist/strand-membership-writer.js +180 -14
- package/dist/strand-membership-writer.js.map +1 -1
- package/dist/strand-solicitation.d.ts +3 -3
- package/dist/strand-solicitation.js +4 -5
- package/dist/strand-solicitation.js.map +1 -1
- package/dist/strand-wake-protocol.d.ts +47 -2
- package/dist/strand-wake-protocol.js +76 -6
- package/dist/strand-wake-protocol.js.map +1 -1
- package/dist/types.d.ts +74 -29
- package/dist/types.js.map +1 -1
- package/package.json +8 -8
- package/src/announce-addrs.ts +93 -0
- package/src/cadre-node.ts +5603 -5158
- package/src/control-database.ts +2584 -2406
- package/src/control-read-retry.ts +167 -0
- package/src/control-retry.ts +190 -0
- package/src/control-schema.ts +20 -4
- package/src/control-write-retry.ts +30 -130
- package/src/delegate-admission.ts +17 -15
- package/src/index.ts +435 -407
- package/src/membership-connection-gater.ts +533 -204
- package/src/peer-record.ts +33 -0
- package/src/relay-addrs.ts +218 -111
- package/src/relay-reservation.ts +828 -823
- package/src/strand-addr-protocol.ts +17 -0
- package/src/strand-backfill.ts +389 -366
- package/src/strand-formation-manager.ts +492 -491
- package/src/strand-formation-protocol.ts +8 -8
- package/src/strand-instance-manager.ts +636 -611
- package/src/strand-membership-writer.ts +209 -14
- package/src/strand-solicitation.ts +6 -7
- package/src/strand-wake-protocol.ts +94 -8
- package/src/types.ts +74 -29
package/dist/cadre-node.js
CHANGED
|
@@ -15,7 +15,7 @@ import { MemoryBootstrapPeerStore } from './bootstrap-peer-store.js';
|
|
|
15
15
|
import { mergePeerAddrs, groupAddrsByPeerId } from './peer-addr-book.js';
|
|
16
16
|
import { verifyCadrePeerVoucher } from './peer-authorization.js';
|
|
17
17
|
import { ed25519PublicKeyB64FromPeerId } from './seed-bootstrap.js';
|
|
18
|
-
import { signPeerRecord, verifyPeerRecordSignature, isPeerRecordFresh, orderSignalingFirst, isSignalingAddr,
|
|
18
|
+
import { signPeerRecord, verifyPeerRecordSignature, isPeerRecordFresh, orderSignalingFirst, isSignalingAddr, withTrailingPeerId, currentMemberTrustPolicy, DEFAULT_PEER_RECORD_MAX_AGE_MS, DEFAULT_PEER_RECORD_HEARTBEAT_MS } from './peer-record.js';
|
|
19
19
|
import { signDeviceTokenRecord, verifyDeviceTokenSignature, isPushPlatform } from './device-token.js';
|
|
20
20
|
import { StrandWatcher } from './strand-watcher.js';
|
|
21
21
|
import { StrandInstanceManager } from './strand-instance-manager.js';
|
|
@@ -27,11 +27,12 @@ import { HibernationManager } from './hibernation-manager.js';
|
|
|
27
27
|
import { ControlDatabase } from './control-database.js';
|
|
28
28
|
import { SeedBootstrapService } from './seed-bootstrap.js';
|
|
29
29
|
import { StrandSolicitationService } from './strand-solicitation.js';
|
|
30
|
-
import { createMembershipConnectionGater, DEFAULT_ENROLLMENT_WINDOW_MS } from './membership-connection-gater.js';
|
|
30
|
+
import { createMembershipConnectionGater, DEFAULT_ENROLLMENT_WINDOW_MS, UnauthorizedReservationBudget } from './membership-connection-gater.js';
|
|
31
31
|
import { StrandWakeService, dialWake } from './strand-wake-protocol.js';
|
|
32
32
|
import { StrandAddrService, collectStrandAddrs } from './strand-addr-protocol.js';
|
|
33
33
|
import { DelegateAdmissionStore, extractCircuitRelayTargets, dueRelayAnnounces, pruneStoppedStrandAnnounces, peerStrandKey } from './delegate-admission.js';
|
|
34
|
-
import { resolveListenAddrs } from './relay-addrs.js';
|
|
34
|
+
import { relayCircuitAddrs, resolveListenAddrs, RelayReservationFailedError } from './relay-addrs.js';
|
|
35
|
+
import { replacesAdvertisedAddrs, resolveAnnounceAddrs } from './announce-addrs.js';
|
|
35
36
|
import { superviseRelayReservation, resolveRelayReservationState } from './relay-reservation.js';
|
|
36
37
|
import { PushFanoutService } from './push-fanout.js';
|
|
37
38
|
import { summarizeConnectionPaths, classifyTransport } from './diagnostics/connection-path.js';
|
|
@@ -148,10 +149,11 @@ export class CadreNode {
|
|
|
148
149
|
this.windowWaiters = new Set();
|
|
149
150
|
this.eventHandlers = new Map();
|
|
150
151
|
/**
|
|
151
|
-
* Relay multiaddrs the caller asked {@link reserveRelays} to reserve through
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
152
|
+
* Relay multiaddrs the caller asked {@link reserveRelays} to reserve through —
|
|
153
|
+
* or, on a node that names `network.relayAddrs`, the ones
|
|
154
|
+
* {@link driveControlRelayReservation} asked for at the end of {@link start}.
|
|
155
|
+
* Empty (the default) means neither happened, so {@link getRelayReservationState}
|
|
156
|
+
* reports `none`.
|
|
155
157
|
*/
|
|
156
158
|
this.relayReserveAddrs = [];
|
|
157
159
|
/**
|
|
@@ -166,6 +168,23 @@ export class CadreNode {
|
|
|
166
168
|
* supervisor to hold one — i.e. the pre-start `control node unavailable` case.
|
|
167
169
|
*/
|
|
168
170
|
this.relayReserveError = null;
|
|
171
|
+
/**
|
|
172
|
+
* True from just before the control libp2p node is created until
|
|
173
|
+
* {@link ControlDatabase.initialize} has settled — the connection gate's
|
|
174
|
+
* BRING-UP QUIET PERIOD (`membership-connection-gater.ts`), during which this
|
|
175
|
+
* node refuses every control connection in both directions.
|
|
176
|
+
*
|
|
177
|
+
* The invariant: the control database is built while this node holds zero
|
|
178
|
+
* control connections. Every same-party sibling connected in that window joins
|
|
179
|
+
* the Optimystic cohort the bring-up's block probes consult, and a sibling that
|
|
180
|
+
* has not yet replicated this node's `CadrePeer` row refuses them all — which
|
|
181
|
+
* fails `start()` outright and cannot be retried into convergence (writing the
|
|
182
|
+
* row that would clear the refusal needs the database being built).
|
|
183
|
+
*
|
|
184
|
+
* Cleared on FAILURE as well as success ({@link cleanup} clears it), so
|
|
185
|
+
* teardown is never gated.
|
|
186
|
+
*/
|
|
187
|
+
this.controlBringUpInFlight = false;
|
|
169
188
|
/** Map of strandId -> sAppConfig for sAppId filtering and management */
|
|
170
189
|
this.sAppConfigs = new Map();
|
|
171
190
|
/**
|
|
@@ -226,10 +245,12 @@ export class CadreNode {
|
|
|
226
245
|
/**
|
|
227
246
|
* In-memory delegate admission grants (see `delegate-admission.ts`): the
|
|
228
247
|
* strand-node transport peerIds this party's members have announced over the
|
|
229
|
-
* strand-addr RPC, admitted at the CONNECTION
|
|
230
|
-
* strand node can hold a circuit-relay reservation on this
|
|
231
|
-
*
|
|
232
|
-
*
|
|
248
|
+
* strand-addr RPC, admitted at the CONNECTION and RESERVATION levels so a
|
|
249
|
+
* member's NAT'd strand node can hold a circuit-relay reservation on this
|
|
250
|
+
* node (without spending the unauthorized-reservation budget). Consulted
|
|
251
|
+
* ONLY by {@link admitInboundControlConnection} and
|
|
252
|
+
* {@link admitControlRelayReservation}; the fail-closed per-stream gate
|
|
253
|
+
* ({@link authorizeInboundControlStream}) never honors it.
|
|
233
254
|
*/
|
|
234
255
|
this.delegateAdmission = new DelegateAdmissionStore();
|
|
235
256
|
/**
|
|
@@ -390,6 +411,7 @@ export class CadreNode {
|
|
|
390
411
|
this.config = config;
|
|
391
412
|
this.strandManager = new StrandInstanceManager();
|
|
392
413
|
this.enrollmentService = new EnrollmentService();
|
|
414
|
+
this.unauthorizedRelayReservations = new UnauthorizedReservationBudget(config.network?.unauthorizedRelayReservationCap);
|
|
393
415
|
// Create hibernation manager with callbacks
|
|
394
416
|
const hibernationCallbacks = {
|
|
395
417
|
onIdle: async (strandId) => this.handleStrandIdle(strandId),
|
|
@@ -501,16 +523,12 @@ export class CadreNode {
|
|
|
501
523
|
return;
|
|
502
524
|
}
|
|
503
525
|
log('Starting CadreNode for party: %s', this.config.controlNetwork.partyId);
|
|
504
|
-
// NOTE: the only direct `console.*` in this library — a boot-time operator warning, not a
|
|
505
|
-
// diagnostic trace (those use `debug`, which an operator never sees without `DEBUG=`). If a
|
|
506
|
-
// second such warning ever appears here, route both through a `CadreNodeEvents` entry the
|
|
507
|
-
// embedder surfaces instead of growing a console surface inside a library.
|
|
508
|
-
if (this.config.network?.announceAddrs && this.config.network.announceAddrs.length > 0) {
|
|
509
|
-
console.warn('network.announceAddrs is set but not yet supported (no upstream db-p2p option to apply it) ' +
|
|
510
|
-
'— this node will keep advertising its listen/relay addresses instead.');
|
|
511
|
-
}
|
|
512
526
|
try {
|
|
513
527
|
const tTotal = performance.now();
|
|
528
|
+
// Config pre-flight, inside the guarded region: resolving the listen addrs it reads
|
|
529
|
+
// throws on a malformed `relayAddrs` entry, and that failure belongs on the same
|
|
530
|
+
// logged-and-cleaned-up path as every other one in start().
|
|
531
|
+
this.warnIfAnnounceAddrsDiscardRelay();
|
|
514
532
|
// Install the WebRTC TURN-relay tracker BEFORE any libp2p bring-up, so it
|
|
515
533
|
// wraps globalThis.RTCPeerConnection before the control node can create one.
|
|
516
534
|
// Inert on Node.js (no RTCPeerConnection); disposed in cleanup().
|
|
@@ -529,6 +547,24 @@ export class CadreNode {
|
|
|
529
547
|
// closed before any network bring-up, and the retained dial targets are
|
|
530
548
|
// loaded before the first reconcile pass could consult them.
|
|
531
549
|
this.initializeBootstrapPeerStore();
|
|
550
|
+
// Arm the connection gate's BRING-UP QUIET PERIOD before the libp2p node
|
|
551
|
+
// exists, so no connection can form ahead of it: `@libp2p/bootstrap` emits
|
|
552
|
+
// its discovery events ~1 s after `libp2p.start()` and the connection
|
|
553
|
+
// manager auto-dials from there, which is INSIDE createControlNode below.
|
|
554
|
+
// The invariant being protected is "zero control connections during
|
|
555
|
+
// control-database bring-up" — see the field's doc and
|
|
556
|
+
// `membership-connection-gater.ts` → "The bring-up quiet period".
|
|
557
|
+
//
|
|
558
|
+
// NOTE: without this gate the bootstrap arm is a RACE, not a guarantee — its
|
|
559
|
+
// safety margin is bring-up duration versus @libp2p/bootstrap's 1 s discovery
|
|
560
|
+
// fuse, and bring-up duration is (raw-storage operations) × per-operation
|
|
561
|
+
// latency. The cold-start operation count and the 50-90 ms/op figure a loaded
|
|
562
|
+
// phone actually sees live in `control-database.ts`'s `loadSchema` note (and
|
|
563
|
+
// are pinned by `control-start-storage-op-budget.spec.ts`): at the top of that
|
|
564
|
+
// range bring-up is 9-15 s, far past the fuse. So if this gate is ever removed
|
|
565
|
+
// or bypassed, that arm reopens for exactly the nodes most likely to hit it —
|
|
566
|
+
// phones joining a party, whose membership row has not replicated yet.
|
|
567
|
+
this.controlBringUpInFlight = true;
|
|
532
568
|
// Create the control network libp2p node
|
|
533
569
|
let t0 = performance.now();
|
|
534
570
|
this.controlNode = await this.createControlNode();
|
|
@@ -549,6 +585,21 @@ export class CadreNode {
|
|
|
549
585
|
});
|
|
550
586
|
await this.controlDatabase.initialize();
|
|
551
587
|
timing('[start] controlDatabase.initialize: %dms', Math.round(performance.now() - t0));
|
|
588
|
+
// The database exists locally now, so the quiet period has done its job and
|
|
589
|
+
// this node can be in the conversation. A failure above never reaches here —
|
|
590
|
+
// `cleanup()` on the catch path opens the gate instead, so teardown (and a
|
|
591
|
+
// retry by the embedder) is not gated.
|
|
592
|
+
//
|
|
593
|
+
// NOTE: the window ends HERE, not at the end of start(), so the awaited control-DB
|
|
594
|
+
// work that follows — `strandWatcher.start()`'s first poll — can run against a
|
|
595
|
+
// cohort a connection just joined. Fine now: every block that poll reads was
|
|
596
|
+
// created locally by the initialize() above, so a refusing sibling has nothing to
|
|
597
|
+
// refuse. If a boot ever fails with `BlockUnavailableError` from BELOW this line,
|
|
598
|
+
// that assumption broke. Do not simply move this clear later without measuring:
|
|
599
|
+
// `handleStrandAdded` runs inside `strandWatcher.start()` and seeds each strand
|
|
600
|
+
// node from CONNECTED siblings (`resolveCohortSeed`), so a longer window trades a
|
|
601
|
+
// bring-up hazard for boot-time strand seeds that are always empty.
|
|
602
|
+
this.controlBringUpInFlight = false;
|
|
552
603
|
log('Control database initialized');
|
|
553
604
|
// Attach the per-stream gate to the control DB's membership hub, so every
|
|
554
605
|
// committed `CadrePeer` write re-materializes the authorized snapshot on its
|
|
@@ -614,12 +665,27 @@ export class CadreNode {
|
|
|
614
665
|
// lands the snapshot is empty, which the stream gate treats as cold
|
|
615
666
|
// start and admits.
|
|
616
667
|
void this.refreshMembershipGate('start');
|
|
668
|
+
// Reserve the configured relays LAST, so every step above ran against a
|
|
669
|
+
// cohort of one (see driveControlRelayReservation). Ahead of
|
|
670
|
+
// scheduleSelfRegistration, so this node's first published `CadrePeer` row
|
|
671
|
+
// already carries the `/p2p-circuit` address the reservation earns it.
|
|
672
|
+
// Throws on a relay that will not have us — start() is fail-fast for a node
|
|
673
|
+
// whose operator named a relay.
|
|
674
|
+
await this.driveControlRelayReservation();
|
|
617
675
|
// Schedule self-registration in background
|
|
618
676
|
this.scheduleSelfRegistration();
|
|
619
677
|
}
|
|
620
678
|
catch (error) {
|
|
621
679
|
log('Failed to start CadreNode: %o', error);
|
|
680
|
+
// `driveControlRelayReservation` throws AFTER `control:connected` was emitted,
|
|
681
|
+
// so a subscriber can have seen this node come up. Balance the edge rather than
|
|
682
|
+
// leaving it dangling — `cleanup()` has torn the node down either way, and the
|
|
683
|
+
// events are what an embedder drives its "am I connected" UI from.
|
|
684
|
+
const wasConnected = this._running;
|
|
622
685
|
await this.cleanup();
|
|
686
|
+
if (wasConnected) {
|
|
687
|
+
this.emit('control:disconnected', undefined);
|
|
688
|
+
}
|
|
623
689
|
throw error;
|
|
624
690
|
}
|
|
625
691
|
}
|
|
@@ -657,19 +723,7 @@ export class CadreNode {
|
|
|
657
723
|
return;
|
|
658
724
|
}
|
|
659
725
|
log('Stopping CadreNode');
|
|
660
|
-
// Stop the retry loop BEFORE tearing the node down, not after: `cleanup()`
|
|
661
|
-
// stops the control node partway through, and a tick that fires during it
|
|
662
|
-
// would dial and then poll a half-torn-down node for the whole reserve
|
|
663
|
-
// timeout — logging spurious dial failures and holding the process open.
|
|
664
|
-
this.relayReserveSupervisor?.stop();
|
|
665
|
-
this.relayReserveSupervisor = null;
|
|
666
726
|
await this.cleanup();
|
|
667
|
-
// Drop the rest of the relay-reservation posture: a stopped node holds no
|
|
668
|
-
// reservation, so a restarted instance must not report the previous run's
|
|
669
|
-
// `reserved`.
|
|
670
|
-
this.relayReserveAddrs = [];
|
|
671
|
-
this.relayReserveError = null;
|
|
672
|
-
this._running = false;
|
|
673
727
|
this.emit('control:disconnected', undefined);
|
|
674
728
|
log('CadreNode stopped');
|
|
675
729
|
}
|
|
@@ -868,6 +922,42 @@ export class CadreNode {
|
|
|
868
922
|
this.controlStorage = wrapStorageWithCache(resolved, 'control');
|
|
869
923
|
return this.controlStorage;
|
|
870
924
|
}
|
|
925
|
+
/**
|
|
926
|
+
* Warn the operator when `network.announceAddrs` will silently cost this node the
|
|
927
|
+
* relay reachability it also configured.
|
|
928
|
+
*
|
|
929
|
+
* A non-empty announce set REPLACES everything libp2p advertises, so the
|
|
930
|
+
* `/p2p-circuit` address earned by a relay reservation is dropped from the node's
|
|
931
|
+
* advertised addresses even though the reservation itself is still held — peers
|
|
932
|
+
* behind NAT stop being able to reach it through that relay. Not an error: an
|
|
933
|
+
* operator whose relay slot is decorative may genuinely want only the announced
|
|
934
|
+
* address, so this reports and proceeds.
|
|
935
|
+
*
|
|
936
|
+
* Keyed off the RESOLVED listen addrs rather than `relayAddrs` alone, so a
|
|
937
|
+
* hand-written `/p2p-circuit` entry in `listenAddrs` — the same reservation by the
|
|
938
|
+
* longer route — is caught too.
|
|
939
|
+
*
|
|
940
|
+
* NOTE: the only direct `console.*` in this library — a boot-time operator warning, not a
|
|
941
|
+
* diagnostic trace (those use `debug`, which an operator never sees without `DEBUG=`). If a
|
|
942
|
+
* second such warning ever appears here, route both through a `CadreNodeEvents` entry the
|
|
943
|
+
* embedder surfaces instead of growing a console surface inside a library.
|
|
944
|
+
*/
|
|
945
|
+
warnIfAnnounceAddrsDiscardRelay() {
|
|
946
|
+
const network = this.config.network;
|
|
947
|
+
if (!replacesAdvertisedAddrs(network)) {
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
const listensOnCircuit = (resolveListenAddrs(network) ?? [])
|
|
951
|
+
.some((addr) => addr.includes('/p2p-circuit'));
|
|
952
|
+
if (!listensOnCircuit) {
|
|
953
|
+
return;
|
|
954
|
+
}
|
|
955
|
+
console.warn('network.announceAddrs is set alongside a circuit-relay listener (from network.relayAddrs, ' +
|
|
956
|
+
'or a /p2p-circuit entry in network.listenAddrs). A non-empty announce set REPLACES every ' +
|
|
957
|
+
'address this node advertises, so the /p2p-circuit address earned from the relay reservation ' +
|
|
958
|
+
'will not be advertised and peers will stop reaching this node through that relay. ' +
|
|
959
|
+
'Use network.appendAnnounceAddrs instead to advertise an extra address without discarding the rest.');
|
|
960
|
+
}
|
|
871
961
|
/**
|
|
872
962
|
* Map this node's config onto the control network's libp2p node options.
|
|
873
963
|
*
|
|
@@ -879,10 +969,12 @@ export class CadreNode {
|
|
|
879
969
|
buildControlNodeOptions() {
|
|
880
970
|
const { controlNetwork, network, profile } = this.config;
|
|
881
971
|
const identityKey = this.identityKey;
|
|
882
|
-
|
|
883
|
-
//
|
|
884
|
-
//
|
|
885
|
-
|
|
972
|
+
// `'search'`: `network.relayAddrs` contributes the bare `/p2p-circuit` SEARCH
|
|
973
|
+
// entry, which opens no connection — the reservation is driven explicitly at
|
|
974
|
+
// the END of `start()`, so the control database is built while this node holds
|
|
975
|
+
// zero control connections. See `relay-addrs.ts` and `start()`.
|
|
976
|
+
const listenAddrs = resolveListenAddrs(network, 'search');
|
|
977
|
+
const enableRelay = this.relayServerEnabled();
|
|
886
978
|
// The control node's own storage, resolved once per runtime (see
|
|
887
979
|
// {@link resolveControlStorage}) rather than per call.
|
|
888
980
|
const controlStorageProvider = this.resolveControlStorage();
|
|
@@ -904,15 +996,25 @@ export class CadreNode {
|
|
|
904
996
|
arachnode: { enableRingZulu: profile === 'storage' },
|
|
905
997
|
...(identityKey && { privateKey: identityKey }),
|
|
906
998
|
...(network?.transports && { transports: network.transports }),
|
|
907
|
-
// Configured `listenAddrs`, plus
|
|
908
|
-
// `network.relayAddrs`
|
|
999
|
+
// Configured `listenAddrs`, plus the bare `/p2p-circuit` search listener when
|
|
1000
|
+
// `network.relayAddrs` is set — that listener registers the pending reservation
|
|
1001
|
+
// `driveControlRelayReservation` fills after bring-up.
|
|
909
1002
|
...(listenAddrs && { listenAddrs }),
|
|
1003
|
+
// What this node ADVERTISES, which is not the same question as what it binds.
|
|
1004
|
+
// Either field is present only when configured non-empty; a non-empty
|
|
1005
|
+
// `announceAddrs` replaces the advertised set (see warnIfAnnounceAddrsDiscardRelay).
|
|
1006
|
+
...resolveAnnounceAddrs(network),
|
|
910
1007
|
// The CONTROL node composes the membership admission gate onto any
|
|
911
|
-
// caller-supplied gater (deny from either wins on inbound
|
|
912
|
-
// hooks pass through). Strand
|
|
913
|
-
//
|
|
1008
|
+
// caller-supplied gater (deny from either wins on the inbound-encrypted
|
|
1009
|
+
// and relay-reservation hooks; all other hooks pass through). Strand
|
|
1010
|
+
// cohort nodes keep the raw configured gater (see
|
|
1011
|
+
// strand-instance-manager.ts) — their peers are legitimately
|
|
914
1012
|
// cross-party, so cadre membership must not gate them.
|
|
915
|
-
connectionGater: createMembershipConnectionGater({
|
|
1013
|
+
connectionGater: createMembershipConnectionGater({
|
|
1014
|
+
admitInbound: (remotePeerId) => this.admitInboundControlConnection(remotePeerId),
|
|
1015
|
+
admitRelayReservation: (remotePeerId) => this.admitControlRelayReservation(remotePeerId),
|
|
1016
|
+
bringUpInFlight: () => this.controlBringUpInFlight
|
|
1017
|
+
}, network?.connectionGater),
|
|
916
1018
|
// Fail-closed per-stream authorization for the four Optimystic control-DB
|
|
917
1019
|
// protocols — the members-only layer the connection gater's stranger
|
|
918
1020
|
// carve-outs cannot express (see authorizeInboundControlStream). Control
|
|
@@ -921,6 +1023,17 @@ export class CadreNode {
|
|
|
921
1023
|
};
|
|
922
1024
|
return nodeOptions;
|
|
923
1025
|
}
|
|
1026
|
+
/**
|
|
1027
|
+
* Does this node's CONTROL libp2p run the circuit-relay server? An explicit
|
|
1028
|
+
* `network.enableRelay` wins; otherwise storage-profile nodes default to on
|
|
1029
|
+
* (better connectivity/uptime). Shared by {@link buildControlNodeOptions}
|
|
1030
|
+
* (which configures the server from it) and
|
|
1031
|
+
* {@link admitInboundControlConnection} (whose deny/admit-for-relay branch
|
|
1032
|
+
* must agree with whether a reservation is even servable here).
|
|
1033
|
+
*/
|
|
1034
|
+
relayServerEnabled() {
|
|
1035
|
+
return this.config.network?.enableRelay ?? (this.config.profile === 'storage');
|
|
1036
|
+
}
|
|
924
1037
|
/**
|
|
925
1038
|
* Decide whether an inbound CONTROL-network connection from `remotePeerId`
|
|
926
1039
|
* should be admitted — the policy behind the connection gater wired in
|
|
@@ -930,7 +1043,7 @@ export class CadreNode {
|
|
|
930
1043
|
* `membership-connection-gater.ts` for the layer's rationale and the
|
|
931
1044
|
* stranger-open protocol allowlist).
|
|
932
1045
|
*
|
|
933
|
-
*
|
|
1046
|
+
* Returns `'admit'` when ANY of:
|
|
934
1047
|
* 1. a shared-baseline check admits ({@link admitControlPeerUnconditionally}
|
|
935
1048
|
* — not running / DB torn down, absent-or-empty trusted-owner anchor, or
|
|
936
1049
|
* configured bootstrap/relay infrastructure);
|
|
@@ -967,44 +1080,117 @@ export class CadreNode {
|
|
|
967
1080
|
* this node yet is denied even though the formation handler would have
|
|
968
1081
|
* accepted it, exactly like the unreplicated-membership-row case below.
|
|
969
1082
|
*
|
|
1083
|
+
* When every check falls through, the verdict depends on whether this node
|
|
1084
|
+
* runs the circuit-relay server ({@link relayServerEnabled}): without one,
|
|
1085
|
+
* `'deny'`; with one, `'admit-for-relay'` — a circuit-relay reservation is
|
|
1086
|
+
* established by the reserving peer DIALING the relay, so a connection deny
|
|
1087
|
+
* here kills the reservation, and that deny is NOT self-healing: an outbound
|
|
1088
|
+
* reconcile re-dial re-establishes a data link, but no outbound dial can
|
|
1089
|
+
* grant the remote peer a reservation, and a relay-only peer has no address
|
|
1090
|
+
* of its own to dial back — the reservation IS its address. The gater admits
|
|
1091
|
+
* such a connection, decides the reservation via
|
|
1092
|
+
* {@link admitControlRelayReservation}, and drops the connection if no
|
|
1093
|
+
* reservation is admitted in time (see `membership-connection-gater.ts` →
|
|
1094
|
+
* "The relay-reservation seam").
|
|
1095
|
+
*
|
|
970
1096
|
* NOTE: check 4/5 runs a control-DB read per inbound connection
|
|
971
1097
|
* (`listAuthorizedMembers`); connections are rare and cadres small, and this
|
|
972
1098
|
* layer is fail-open behind `ADMISSION_DECISION_TIMEOUT_MS`, so the live
|
|
973
1099
|
* read is safe here — unlike the per-stream gate, which must consult the
|
|
974
1100
|
* materialized {@link authorizedControlPeers} snapshot instead.
|
|
975
|
-
* NOTE: a sibling whose membership row has not yet
|
|
976
|
-
*
|
|
977
|
-
* either side's next outbound reconcile dial (outbound is never
|
|
978
|
-
* re-establishes the link — self-healing,
|
|
1101
|
+
* NOTE: on a relay-DISABLED node, a sibling whose membership row has not yet
|
|
1102
|
+
* replicated here is denied until the row converges (typically via the
|
|
1103
|
+
* owner); either side's next outbound reconcile dial (outbound is never
|
|
1104
|
+
* gated) re-establishes the DATA link — self-healing for data, visible as a
|
|
1105
|
+
* transient deny. That self-healing story never covered a reservation, which
|
|
1106
|
+
* is exactly why the relay-enabled path above exists.
|
|
979
1107
|
*/
|
|
980
1108
|
async admitInboundControlConnection(remotePeerId) {
|
|
981
1109
|
if (this.admitControlPeerUnconditionally(remotePeerId)) {
|
|
982
|
-
return
|
|
1110
|
+
return 'admit';
|
|
983
1111
|
}
|
|
984
1112
|
if (Date.now() < this.enrollmentWindowUntil) {
|
|
985
|
-
return
|
|
1113
|
+
return 'admit';
|
|
986
1114
|
}
|
|
987
1115
|
if (this.delegateAdmission.has(remotePeerId)) {
|
|
988
|
-
return
|
|
1116
|
+
return 'admit';
|
|
989
1117
|
}
|
|
990
1118
|
const authorized = await this.listAuthorizedMembers();
|
|
991
1119
|
if (authorized.length === 0) {
|
|
992
|
-
return
|
|
1120
|
+
return 'admit';
|
|
993
1121
|
}
|
|
994
1122
|
if (authorized.some((m) => m.peerId === remotePeerId)) {
|
|
995
|
-
return
|
|
1123
|
+
return 'admit';
|
|
996
1124
|
}
|
|
997
1125
|
try {
|
|
998
1126
|
if (await this.strandSolicitationService?.hasOutstandingInvitation()) {
|
|
999
|
-
return
|
|
1127
|
+
return 'admit';
|
|
1000
1128
|
}
|
|
1001
1129
|
}
|
|
1002
1130
|
catch (error) {
|
|
1003
1131
|
log('admitInboundControlConnection: outstanding-invitation check threw for %s — admitting (fail-open): %o', remotePeerId, error);
|
|
1004
|
-
return
|
|
1132
|
+
return 'admit';
|
|
1133
|
+
}
|
|
1134
|
+
if (this.relayServerEnabled()) {
|
|
1135
|
+
log('admitInboundControlConnection: admitting %s FOR RELAY ONLY — not an authorized member and no enrollment path open; the gater drops the connection unless a reservation is admitted', remotePeerId);
|
|
1136
|
+
return 'admit-for-relay';
|
|
1005
1137
|
}
|
|
1006
1138
|
log('admitInboundControlConnection: DENYING inbound from %s — not an authorized member and no enrollment path open', remotePeerId);
|
|
1007
|
-
return
|
|
1139
|
+
return 'deny';
|
|
1140
|
+
}
|
|
1141
|
+
/**
|
|
1142
|
+
* Should `remotePeerId` be granted a circuit-relay reservation slot on this
|
|
1143
|
+
* node's relay server? The policy behind the gater's
|
|
1144
|
+
* `denyInboundRelayReservation` hook (see `membership-connection-gater.ts` →
|
|
1145
|
+
* "The relay-reservation seam"); the circuit-relay server consults it per
|
|
1146
|
+
* RESERVE request, so it is never called on a node whose relay server is off.
|
|
1147
|
+
*
|
|
1148
|
+
* Admits when ANY of:
|
|
1149
|
+
* 1. a shared-baseline check admits ({@link admitControlPeerUnconditionally}
|
|
1150
|
+
* — not running / DB torn down, absent-or-empty trusted-owner anchor, or
|
|
1151
|
+
* configured bootstrap/relay infrastructure);
|
|
1152
|
+
* 2. the peer holds a live delegate admission grant — a member's strand
|
|
1153
|
+
* node reserving here (see `delegate-admission.ts`); never counted
|
|
1154
|
+
* against the unauthorized budget;
|
|
1155
|
+
* 3. the authorized-member set is empty (cold start) or the peer IS an
|
|
1156
|
+
* authorized member — members are never counted against the budget; or
|
|
1157
|
+
* 4. the peer takes (or already holds) a slot in the bounded
|
|
1158
|
+
* unauthorized-reservation budget — the boot-ordering window where a
|
|
1159
|
+
* genuine member reserves before its `CadrePeer` row replicates here.
|
|
1160
|
+
* Cap via `network.unauthorizedRelayReservationCap` (0 = refuse every
|
|
1161
|
+
* unauthorized reservation).
|
|
1162
|
+
*
|
|
1163
|
+
* Every uncounted admission also RELEASES the budget slot the peer may hold
|
|
1164
|
+
* from an earlier reservation taken while it was still unplaceable — a member
|
|
1165
|
+
* that boots, reserves, and only then has its row replicate here would
|
|
1166
|
+
* otherwise keep that slot spent for the rest of the entry's TTL.
|
|
1167
|
+
*
|
|
1168
|
+
* The connection gate's stranger carve-outs (an open enrollment window, an
|
|
1169
|
+
* outstanding formation invitation) deliberately do NOT extend here: they
|
|
1170
|
+
* exist so a stranger's SEED or FORMATION stream can ride a connection, and
|
|
1171
|
+
* neither needs relay capacity. A genuine invitee that does need a relay slot
|
|
1172
|
+
* takes one from the budget like any other unplaced peer, so an open window
|
|
1173
|
+
* never becomes an unbounded grant of this node's forwarding capacity.
|
|
1174
|
+
*/
|
|
1175
|
+
async admitControlRelayReservation(remotePeerId) {
|
|
1176
|
+
if (this.admitControlPeerUnconditionally(remotePeerId)) {
|
|
1177
|
+
return this.admitReservationUncounted(remotePeerId);
|
|
1178
|
+
}
|
|
1179
|
+
if (this.delegateAdmission.has(remotePeerId)) {
|
|
1180
|
+
return this.admitReservationUncounted(remotePeerId);
|
|
1181
|
+
}
|
|
1182
|
+
const authorized = await this.listAuthorizedMembers();
|
|
1183
|
+
if (authorized.length === 0 || authorized.some((m) => m.peerId === remotePeerId)) {
|
|
1184
|
+
return this.admitReservationUncounted(remotePeerId);
|
|
1185
|
+
}
|
|
1186
|
+
const admitted = this.unauthorizedRelayReservations.tryAdmit(remotePeerId);
|
|
1187
|
+
log('admitControlRelayReservation: %s %s under the unauthorized-reservation budget', admitted ? 'admitting' : 'REFUSING', remotePeerId);
|
|
1188
|
+
return admitted;
|
|
1189
|
+
}
|
|
1190
|
+
/** Admit a reservation on the peer's own merits, giving back any budget slot it still holds. */
|
|
1191
|
+
admitReservationUncounted(remotePeerId) {
|
|
1192
|
+
this.unauthorizedRelayReservations.release(remotePeerId);
|
|
1193
|
+
return true;
|
|
1008
1194
|
}
|
|
1009
1195
|
/**
|
|
1010
1196
|
* Hold the control-network inbound gate open for not-yet-authorized peers
|
|
@@ -1020,7 +1206,8 @@ export class CadreNode {
|
|
|
1020
1206
|
/**
|
|
1021
1207
|
* Record (or refresh) a delegate admission grant: `delegatePeerId` is the
|
|
1022
1208
|
* transport peerId of `announcerPeerId`'s strand-`strandId` node, admitted
|
|
1023
|
-
* at the CONNECTION
|
|
1209
|
+
* at the CONNECTION and RESERVATION levels (all a circuit-relay reservation
|
|
1210
|
+
* needs, without spending the unauthorized-reservation budget) for
|
|
1024
1211
|
* `DELEGATE_GRANT_TTL_MS`. Called by the strand-addr responder after its
|
|
1025
1212
|
* authorized-membership gate has passed; public so tests can drive the
|
|
1026
1213
|
* admission policy without a full strand launch. A re-announce for the same
|
|
@@ -1123,13 +1310,19 @@ export class CadreNode {
|
|
|
1123
1310
|
*
|
|
1124
1311
|
* The SOLE caller is {@link drainMembershipGate}, which serializes refreshes —
|
|
1125
1312
|
* so two reads can no longer settle out of order.
|
|
1313
|
+
*
|
|
1314
|
+
* Reads WITHOUT the transient-failure retry, deliberately: `ControlDatabase` drives
|
|
1315
|
+
* this listener with its write lock HELD (`notifyMembershipChanged`), so a retrying
|
|
1316
|
+
* read here would sleep its backoff holding the lock and stall every other local
|
|
1317
|
+
* writer. Nothing is lost — this refresh already keeps the previous snapshot on
|
|
1318
|
+
* failure and is re-driven by the next membership write and by the timed reconcile.
|
|
1126
1319
|
*/
|
|
1127
1320
|
async refreshAuthorizedControlPeers(reason) {
|
|
1128
1321
|
if (!this._running || !this.controlDatabase) {
|
|
1129
1322
|
return;
|
|
1130
1323
|
}
|
|
1131
1324
|
try {
|
|
1132
|
-
const members = await this.listAuthorizedMembers();
|
|
1325
|
+
const members = await this.listAuthorizedMembers(false);
|
|
1133
1326
|
this.authorizedControlPeers = new Set(members.map((m) => m.peerId));
|
|
1134
1327
|
log('refreshAuthorizedControlPeers(%s): %d authorized peer(s)', reason, this.authorizedControlPeers.size);
|
|
1135
1328
|
}
|
|
@@ -1411,6 +1604,20 @@ export class CadreNode {
|
|
|
1411
1604
|
reconcile('self:peer:update');
|
|
1412
1605
|
};
|
|
1413
1606
|
this.controlNode.addEventListener('self:peer:update', this.selfPeerUpdateHandler);
|
|
1607
|
+
// NOTE: a node AUTHORIZED AFTER IT BOOTED can wait a whole heartbeat (7.5 min)
|
|
1608
|
+
// to publish its address. `registerSelf` no-ops while a node has no readable
|
|
1609
|
+
// `CadrePeer` row, so its boot-time publish does nothing, and the two triggers
|
|
1610
|
+
// wired here are the only ones after that: `self:peer:update` needs an ADDRESS
|
|
1611
|
+
// CHANGE, and a relay-only node's addresses stop changing the moment its
|
|
1612
|
+
// reservation lands — inside `start()`, before this listener exists. Harmless
|
|
1613
|
+
// where a late-authorized node's addresses keep churning (NAT/relay rotation
|
|
1614
|
+
// fire the event anyway), and invisible while parties authorize members before
|
|
1615
|
+
// they boot. If late enrollment becomes the normal path — an invited phone is
|
|
1616
|
+
// exactly that — republish on the membership-change seam that already exists
|
|
1617
|
+
// (`ControlDatabase.setMembershipChangeListener`) rather than lengthening this
|
|
1618
|
+
// list of triggers. Measured while writing
|
|
1619
|
+
// `relay-only-control-addr.integration.ts` case 4, which drives `registerSelf()`
|
|
1620
|
+
// explicitly because neither trigger is dependable there.
|
|
1414
1621
|
this.recordRefreshTimer = setInterval(() => republish('heartbeat'), DEFAULT_PEER_RECORD_HEARTBEAT_MS);
|
|
1415
1622
|
this.recordRefreshTimer?.unref?.();
|
|
1416
1623
|
const reconcileMs = this.config.network?.controlCohort?.reconcileMs ?? DEFAULT_CONTROL_COHORT_RECONCILE_MS;
|
|
@@ -1485,6 +1692,10 @@ export class CadreNode {
|
|
|
1485
1692
|
* Survivors are returned signaling (`/p2p-circuit`) first, filtered to
|
|
1486
1693
|
* signaling-only when requested, as parsed `Multiaddr`s (unparsable addrs
|
|
1487
1694
|
* dropped). Any gate failure yields an empty array rather than throwing.
|
|
1695
|
+
*
|
|
1696
|
+
* Every returned address is normalized to terminate in `/p2p/<peerId>` — see
|
|
1697
|
+
* {@link normalizeDialAddrs} for why that invariant, not each dial site's own
|
|
1698
|
+
* handling, is what keeps a mixed list from taking a whole peer offline.
|
|
1488
1699
|
*/
|
|
1489
1700
|
async resolvePeerAddrs(peerId, opts = {}) {
|
|
1490
1701
|
if (!this.controlDatabase) {
|
|
@@ -1529,12 +1740,71 @@ export class CadreNode {
|
|
|
1529
1740
|
}
|
|
1530
1741
|
// Order signaling-first (the on-record order was what we verified above),
|
|
1531
1742
|
// optionally restrict to signaling addrs, then parse — dropping any addr
|
|
1532
|
-
// that does not parse as a multiaddr
|
|
1743
|
+
// that does not parse as a multiaddr — and finally normalize every survivor
|
|
1744
|
+
// onto `/p2p/<peerId>` so no caller ever sees a mixed list.
|
|
1533
1745
|
let addrs = orderSignalingFirst(record.addrs);
|
|
1534
1746
|
if (opts.signalingOnly) {
|
|
1535
1747
|
addrs = addrs.filter(isSignalingAddr);
|
|
1536
1748
|
}
|
|
1537
|
-
return this.parseMultiaddrs(addrs);
|
|
1749
|
+
return this.normalizeDialAddrs(this.parseMultiaddrs(addrs), peerId);
|
|
1750
|
+
}
|
|
1751
|
+
/**
|
|
1752
|
+
* Guarantee every address handed to a control-network dial terminates in
|
|
1753
|
+
* `/p2p/<peerId>`, with no duplicates — the invariant every dial path
|
|
1754
|
+
* downstream relies on.
|
|
1755
|
+
*
|
|
1756
|
+
* `libp2p.dial(addrs)` requires that the addresses in ONE dial either all name
|
|
1757
|
+
* a peer id or none do; a list mixing a circuit address (which ends in
|
|
1758
|
+
* `/p2p/<target>`) with a bare direct one (which ends in `/tcp/…`) makes that
|
|
1759
|
+
* call throw, and the whole peer is then silently skipped rather than one bad
|
|
1760
|
+
* address.
|
|
1761
|
+
*
|
|
1762
|
+
* Applied by ALL THREE sources of control-dial candidates, so no call site
|
|
1763
|
+
* invents its own rule and none can produce a mixed list: the signed record
|
|
1764
|
+
* ({@link resolvePeerAddrs}), the libp2p address book
|
|
1765
|
+
* ({@link peerStoreAddrs}), and a seed's retained owner addresses
|
|
1766
|
+
* ({@link bootstrapDialAddrs}).
|
|
1767
|
+
*
|
|
1768
|
+
* An address naming a DIFFERENT trailing peer id is dropped: it does not reach
|
|
1769
|
+
* `peerId`, so it does not belong in this peer's candidate list (libp2p's own
|
|
1770
|
+
* dial queue filters the same shape out one layer lower).
|
|
1771
|
+
*/
|
|
1772
|
+
normalizeDialAddrs(addrs, peerId) {
|
|
1773
|
+
// Keyed by the NORMALIZED string, so two entries differing only by the suffix
|
|
1774
|
+
// (`/ip4/…/tcp/4001` and `/ip4/…/tcp/4001/p2p/<peerId>`) collapse into one.
|
|
1775
|
+
// Normalization is what makes them equal; a surviving duplicate would cost a
|
|
1776
|
+
// real dial attempt — a whole slice of `dialWake`'s budget — on an address
|
|
1777
|
+
// already being tried.
|
|
1778
|
+
const out = new Map();
|
|
1779
|
+
for (const addr of addrs) {
|
|
1780
|
+
const bound = this.bindAddrToPeer(addr, peerId);
|
|
1781
|
+
if (bound) {
|
|
1782
|
+
out.set(bound.toString(), bound);
|
|
1783
|
+
}
|
|
1784
|
+
}
|
|
1785
|
+
return [...out.values()];
|
|
1786
|
+
}
|
|
1787
|
+
/**
|
|
1788
|
+
* One address bound to `peerId`, or `null` when it cannot be — logged either
|
|
1789
|
+
* way, never thrown, so every caller's list-shaping stays total.
|
|
1790
|
+
*
|
|
1791
|
+
* `withTrailingPeerId` encapsulates `/p2p/<peerId>`, which throws on a peer id
|
|
1792
|
+
* that does not parse. Unreachable for a `CadrePeer` row (the binding gate
|
|
1793
|
+
* above parsed it already), reachable for a seed-supplied one
|
|
1794
|
+
* ({@link bootstrapDialAddrs}).
|
|
1795
|
+
*/
|
|
1796
|
+
bindAddrToPeer(addr, peerId) {
|
|
1797
|
+
try {
|
|
1798
|
+
const bound = withTrailingPeerId(addr, peerId);
|
|
1799
|
+
if (!bound) {
|
|
1800
|
+
log('normalizeDialAddrs: dropping addr %s — it names a peer other than %s', addr.toString(), peerId);
|
|
1801
|
+
}
|
|
1802
|
+
return bound;
|
|
1803
|
+
}
|
|
1804
|
+
catch (error) {
|
|
1805
|
+
log('normalizeDialAddrs: cannot bind %s to %s: %o', addr.toString(), peerId, error);
|
|
1806
|
+
return null;
|
|
1807
|
+
}
|
|
1538
1808
|
}
|
|
1539
1809
|
/** Parse multiaddr strings, dropping (and logging) any that fail to parse. */
|
|
1540
1810
|
parseMultiaddrs(addrs) {
|
|
@@ -1851,6 +2121,16 @@ export class CadreNode {
|
|
|
1851
2121
|
* Cold-start fallback: the libp2p peerStore multiaddrs for `peerId` (seeded by
|
|
1852
2122
|
* {@link SeedBootstrapService.applySeed}). Returns `[]` on a missing entry or any
|
|
1853
2123
|
* parse/lookup failure — never throws.
|
|
2124
|
+
*
|
|
2125
|
+
* Normalized through {@link normalizeDialAddrs} for the same reason
|
|
2126
|
+
* {@link resolvePeerAddrs} is, and with more cause: the address book hands back
|
|
2127
|
+
* a list that is inherently MIXED. `@libp2p/peer-store`'s
|
|
2128
|
+
* `dedupeFilterAndSortAddresses` strips a trailing `/p2p/<peerId>` only when
|
|
2129
|
+
* that id is the address's FIRST `/p2p/` component, so a direct address
|
|
2130
|
+
* round-trips bare while a relayed one — whose first `/p2p/` names the relay —
|
|
2131
|
+
* keeps its suffix (the same asymmetry `peer-addr-book.ts`'s `addrKey` exists
|
|
2132
|
+
* to absorb). Feeding both to one `dial()` is exactly the
|
|
2133
|
+
* `InvalidParametersError` that skipped the whole peer.
|
|
1854
2134
|
*/
|
|
1855
2135
|
async peerStoreAddrs(peerId) {
|
|
1856
2136
|
if (!this.controlNode) {
|
|
@@ -1860,7 +2140,7 @@ export class CadreNode {
|
|
|
1860
2140
|
const peer = await this.controlNode.peerStore.get(peerIdFromString(peerId));
|
|
1861
2141
|
// Re-parse through the top-level multiaddr parser so the returned type matches
|
|
1862
2142
|
// resolvePeerAddrs (the peerStore bundles its own @multiformats/multiaddr copy).
|
|
1863
|
-
return this.parseMultiaddrs(peer.addresses.map((a) => a.multiaddr.toString()));
|
|
2143
|
+
return this.normalizeDialAddrs(this.parseMultiaddrs(peer.addresses.map((a) => a.multiaddr.toString())), peerId);
|
|
1864
2144
|
}
|
|
1865
2145
|
catch (error) {
|
|
1866
2146
|
log('reconcileControlCohort: peerStore lookup for %s failed: %o', peerId, error);
|
|
@@ -1949,6 +2229,13 @@ export class CadreNode {
|
|
|
1949
2229
|
if (!this._running || !this.controlNode) {
|
|
1950
2230
|
return;
|
|
1951
2231
|
}
|
|
2232
|
+
// NOTE: a connection this node still holds in `status: 'open'` after the remote
|
|
2233
|
+
// has already aborted it counts as connected here, so the cold-start retry stays
|
|
2234
|
+
// suppressed for that peer until the connection monitor's next ping notices the
|
|
2235
|
+
// abort — measured at ~9 s in `control-cohort-cold-start-retry.integration.ts`.
|
|
2236
|
+
// Bounded and self-healing today; it would start to matter if the connection
|
|
2237
|
+
// monitor's ping interval were lengthened, or if recovery ever had to meet a
|
|
2238
|
+
// deadline tighter than one ping interval.
|
|
1952
2239
|
if (connected.has(peerId)) {
|
|
1953
2240
|
continue;
|
|
1954
2241
|
}
|
|
@@ -1962,32 +2249,15 @@ export class CadreNode {
|
|
|
1962
2249
|
* Bind a bootstrap peer's seed addresses to its peer id, so the dial
|
|
1963
2250
|
* authenticates the peer it is aiming at rather than trusting whoever answers.
|
|
1964
2251
|
*
|
|
1965
|
-
*
|
|
1966
|
-
*
|
|
1967
|
-
*
|
|
1968
|
-
*
|
|
1969
|
-
* bare
|
|
2252
|
+
* The same {@link normalizeDialAddrs} rule the resolved and address-book paths
|
|
2253
|
+
* use, on the third and last source of control-dial candidates: an address that
|
|
2254
|
+
* already terminates in `/p2p/<id>` must carry THIS peer's id or it is dropped
|
|
2255
|
+
* (a seed that disagrees with itself is not a dial target); one that does not —
|
|
2256
|
+
* a bare listen addr, or a relay hop with the destination missing — gets the id
|
|
2257
|
+
* encapsulated; an unencapsulatable one is dropped rather than dialed bare.
|
|
1970
2258
|
*/
|
|
1971
2259
|
bootstrapDialAddrs(peerId, addrs) {
|
|
1972
|
-
|
|
1973
|
-
for (const addr of this.parseMultiaddrs(addrs)) {
|
|
1974
|
-
const embedded = trailingPeerId(addr);
|
|
1975
|
-
if (embedded === peerId) {
|
|
1976
|
-
out.push(addr);
|
|
1977
|
-
continue;
|
|
1978
|
-
}
|
|
1979
|
-
if (embedded !== null) {
|
|
1980
|
-
log('reconcileControlCohort(cold-start): addr %s names peer %s, not %s; dropping', addr.toString(), embedded, peerId);
|
|
1981
|
-
continue;
|
|
1982
|
-
}
|
|
1983
|
-
try {
|
|
1984
|
-
out.push(addr.encapsulate(`/p2p/${peerId}`));
|
|
1985
|
-
}
|
|
1986
|
-
catch (error) {
|
|
1987
|
-
log('reconcileControlCohort(cold-start): cannot bind %s to %s: %o', addr.toString(), peerId, error);
|
|
1988
|
-
}
|
|
1989
|
-
}
|
|
1990
|
-
return out;
|
|
2260
|
+
return this.normalizeDialAddrs(this.parseMultiaddrs(addrs), peerId);
|
|
1991
2261
|
}
|
|
1992
2262
|
/** Dial one cold-start bootstrap peer, best-effort. Returns whether it connected. */
|
|
1993
2263
|
async dialBootstrapPeer(peerId, addrs) {
|
|
@@ -2705,7 +2975,30 @@ export class CadreNode {
|
|
|
2705
2975
|
});
|
|
2706
2976
|
}
|
|
2707
2977
|
}
|
|
2978
|
+
/**
|
|
2979
|
+
* Return this node to its pre-{@link start} state. The ONE teardown — `stop()` and
|
|
2980
|
+
* a failed `start()` both come here, so anything a stopped node must not still be
|
|
2981
|
+
* doing belongs in this method and not in `stop()`. `stop()` adds only the
|
|
2982
|
+
* `control:disconnected` emit, which a never-connected start has no edge for.
|
|
2983
|
+
*/
|
|
2708
2984
|
async cleanup() {
|
|
2985
|
+
// Open the connection gate's bring-up quiet period unconditionally: teardown
|
|
2986
|
+
// must never be gated, and this is the path a FAILED start() takes.
|
|
2987
|
+
this.controlBringUpInFlight = false;
|
|
2988
|
+
// Stop the relay retry loop BEFORE tearing the node down, not after: the control
|
|
2989
|
+
// node is stopped partway through below, and a tick that fires during it would
|
|
2990
|
+
// dial and then poll a half-torn-down node for the whole reserve timeout —
|
|
2991
|
+
// logging spurious dial failures on a `.unref()`'d timer that nothing ever
|
|
2992
|
+
// clears. `driveControlRelayReservation` starts this supervisor from inside
|
|
2993
|
+
// `start()`, so a start that fails ON the reservation leaves one running unless
|
|
2994
|
+
// it is stopped here.
|
|
2995
|
+
this.relayReserveSupervisor?.stop();
|
|
2996
|
+
this.relayReserveSupervisor = null;
|
|
2997
|
+
// Drop the rest of the relay-reservation posture: a torn-down node holds no
|
|
2998
|
+
// reservation, so neither a restarted instance nor a caller inspecting a failed
|
|
2999
|
+
// start reports the previous attempt's `reserved`.
|
|
3000
|
+
this.relayReserveAddrs = [];
|
|
3001
|
+
this.relayReserveError = null;
|
|
2709
3002
|
// Resolve + clear any in-flight wake windows first, so an in-flight check-in
|
|
2710
3003
|
// or serviceWake unblocks and tears down cleanly rather than firing a stale
|
|
2711
3004
|
// window timer (or hanging) after the strand manager is stopped below.
|
|
@@ -2773,16 +3066,23 @@ export class CadreNode {
|
|
|
2773
3066
|
await this.controlNode.stop();
|
|
2774
3067
|
this.controlNode = null;
|
|
2775
3068
|
}
|
|
2776
|
-
// Release the control store's cache
|
|
2777
|
-
// node that write through it are down.
|
|
2778
|
-
//
|
|
2779
|
-
//
|
|
2780
|
-
// cache
|
|
3069
|
+
// Release the control store's claim on its cache LAST — after the database and
|
|
3070
|
+
// node that write through it are down. The wrapper counts holders, so this empties
|
|
3071
|
+
// and unregisters the cache only if no other scope still holds it. Dropping the
|
|
3072
|
+
// field is what lets a stop()/start() cycle re-resolve the provider against a live
|
|
3073
|
+
// cache instead of handing the restarted node a retired wrapper. Logged, never
|
|
3074
|
+
// thrown: a cache-bookkeeping failure must not abort a teardown.
|
|
2781
3075
|
if (this.controlStorage) {
|
|
2782
3076
|
const controlStorage = this.controlStorage;
|
|
2783
3077
|
this.controlStorage = null;
|
|
2784
3078
|
await disposeStorageCache(controlStorage).catch((err) => log('Failed to dispose control storage cache: %o', err));
|
|
2785
3079
|
}
|
|
3080
|
+
// LAST, so every step above still ran against a node that considered itself up
|
|
3081
|
+
// (several of them early-return while `_running` is false). A failed `start()`
|
|
3082
|
+
// that had already flipped it — the reservation drive throws after that point —
|
|
3083
|
+
// must not leave `isRunning` true over a torn-down node, which would report
|
|
3084
|
+
// healthy and make the embedder's retry a no-op ("already running").
|
|
3085
|
+
this._running = false;
|
|
2786
3086
|
}
|
|
2787
3087
|
// Hibernation callbacks
|
|
2788
3088
|
async handleStrandIdle(strandId) {
|
|
@@ -3406,11 +3706,25 @@ export class CadreNode {
|
|
|
3406
3706
|
}
|
|
3407
3707
|
/**
|
|
3408
3708
|
* The circuit relays this node's strand nodes would reserve through: the
|
|
3409
|
-
* union of
|
|
3410
|
-
* `network.listenAddrs` circuit entry
|
|
3411
|
-
*
|
|
3412
|
-
*
|
|
3413
|
-
*
|
|
3709
|
+
* union of CONFIGURED relays — `network.relayAddrs`, plus any hand-written
|
|
3710
|
+
* `network.listenAddrs` circuit entry, both of which a strand node inherits
|
|
3711
|
+
* verbatim — and the control node's own live `/p2p-circuit` multiaddrs (a
|
|
3712
|
+
* reservation this node discovered rather than configured).
|
|
3713
|
+
*
|
|
3714
|
+
* The CONFIGURED half reads `network.relayAddrs` and `network.listenAddrs`
|
|
3715
|
+
* directly rather than going through {@link resolveListenAddrs}: the control
|
|
3716
|
+
* node's resolution takes the `'search'` route, whose bare `/p2p-circuit` entry
|
|
3717
|
+
* names no relay, so a configured relay would be unannounceable here until this
|
|
3718
|
+
* node's own reservation landed — and the announce must be in place BEFORE a
|
|
3719
|
+
* strand node dials its reservation, since the relay's membership gate does not
|
|
3720
|
+
* know the strand's derived peerId (see delegate-admission.ts). Configuration is
|
|
3721
|
+
* authoritative; the live multiaddrs only ADD relays nobody configured.
|
|
3722
|
+
*
|
|
3723
|
+
* The `listenAddrs` half is now unreachable on a control node — `relay-addrs.ts`
|
|
3724
|
+
* rejects a hand-written `<relay>/p2p-circuit` listen entry outright, because the
|
|
3725
|
+
* bring-up quiet period denies the dial that listener makes from inside
|
|
3726
|
+
* `libp2p.start()`. Kept because it costs one spread and this is the one place
|
|
3727
|
+
* that would silently stop announcing if that rejection is ever relaxed.
|
|
3414
3728
|
*
|
|
3415
3729
|
* NOTE: a relay the STRAND node discovers on its own (autorelay — in neither
|
|
3416
3730
|
* source) gets no announcement, and a membership-gated one will deny it; fine
|
|
@@ -3418,7 +3732,8 @@ export class CadreNode {
|
|
|
3418
3732
|
*/
|
|
3419
3733
|
circuitRelayTargets() {
|
|
3420
3734
|
return extractCircuitRelayTargets([
|
|
3421
|
-
...(
|
|
3735
|
+
...relayCircuitAddrs(this.config.network?.relayAddrs ?? []),
|
|
3736
|
+
...(this.config.network?.listenAddrs ?? []),
|
|
3422
3737
|
...(this.controlNode?.getMultiaddrs().map(String) ?? [])
|
|
3423
3738
|
]);
|
|
3424
3739
|
}
|
|
@@ -3869,16 +4184,60 @@ export class CadreNode {
|
|
|
3869
4184
|
getControlNode() {
|
|
3870
4185
|
return this.controlNode;
|
|
3871
4186
|
}
|
|
4187
|
+
/**
|
|
4188
|
+
* The boot-path half of {@link reserveRelays}: reserve through every
|
|
4189
|
+
* `network.relayAddrs` entry, or fail `start()`.
|
|
4190
|
+
*
|
|
4191
|
+
* WHY IT RUNS HERE, AT THE END. `network.relayAddrs` used to resolve to a
|
|
4192
|
+
* CONFIGURED circuit listener, which libp2p dials from inside `libp2p.start()`
|
|
4193
|
+
* — so the relay was already a connected peer, and therefore already in this
|
|
4194
|
+
* node's Optimystic cohort, before `ControlDatabase.initialize()` ran. Building
|
|
4195
|
+
* that database is a long chain of cohort-consulting block probes (catalog
|
|
4196
|
+
* hydration, then one per table, then the indexes), and a sibling that has not
|
|
4197
|
+
* yet replicated this node's `CadrePeer` row correctly refuses every one of
|
|
4198
|
+
* them, so bring-up died on `BlockUnavailableError` — deterministically, on
|
|
4199
|
+
* every boot of a relay-only node. Retrying could not converge either: the
|
|
4200
|
+
* condition that clears the refusal is this node's own row reaching the
|
|
4201
|
+
* sibling, and writing that row needs the database the retry is building.
|
|
4202
|
+
*
|
|
4203
|
+
* So the invariant is ordering, not retrying: the control database is built
|
|
4204
|
+
* while this node holds ZERO control connections (a cohort of one, entirely
|
|
4205
|
+
* local), and only then does it reach out. Everything above in {@link start}
|
|
4206
|
+
* has completed by the time this runs.
|
|
4207
|
+
*
|
|
4208
|
+
* BUDGET: the supervisor's first attempt — `relay-reservation.ts`'s
|
|
4209
|
+
* `DEFAULT_RELAY_RESERVE_TIMEOUT_MS`, 10 s — is what `start()` waits on — deliberately the module default rather
|
|
4210
|
+
* than a boot-specific one. A healthy dial-plus-reserve is sub-second even over
|
|
4211
|
+
* a WAN, so 10 s is slack for a slow link; going much longer would make a dead
|
|
4212
|
+
* relay indistinguishable from a hung start, and much shorter would fail nodes
|
|
4213
|
+
* on links that were merely slow. The retries carry on in the background after
|
|
4214
|
+
* this resolves, exactly as they do for a {@link reserveRelays} caller.
|
|
4215
|
+
*/
|
|
4216
|
+
async driveControlRelayReservation() {
|
|
4217
|
+
const relayAddrs = this.config.network?.relayAddrs ?? [];
|
|
4218
|
+
if (relayAddrs.length === 0) {
|
|
4219
|
+
return;
|
|
4220
|
+
}
|
|
4221
|
+
const t0 = performance.now();
|
|
4222
|
+
const state = await this.reserveRelays([...relayAddrs]);
|
|
4223
|
+
timing('[start] reserveRelays: %dms', Math.round(performance.now() - t0));
|
|
4224
|
+
if (state.status !== 'reserved') {
|
|
4225
|
+
throw new RelayReservationFailedError(relayAddrs, state);
|
|
4226
|
+
}
|
|
4227
|
+
log('Reserved a relay slot: %o', state.circuitAddrs);
|
|
4228
|
+
}
|
|
3872
4229
|
/**
|
|
3873
4230
|
* Start keeping a relay reservation: dial the given relay(s) from the control
|
|
3874
4231
|
* node, ask the first one that answers for a reservation slot, wait until the
|
|
3875
4232
|
* resulting `/p2p-circuit` address makes this node dialable — and keep a
|
|
3876
4233
|
* supervisor running that re-drives whenever the reservation is later lost.
|
|
3877
4234
|
*
|
|
3878
|
-
*
|
|
3879
|
-
*
|
|
3880
|
-
*
|
|
3881
|
-
*
|
|
4235
|
+
* The FAIL-SOFT entry point, for a caller that discovers its relay at runtime
|
|
4236
|
+
* (a browser tab, a host UI). A node that names `network.relayAddrs` gets the
|
|
4237
|
+
* same drive from {@link driveControlRelayReservation} at the end of
|
|
4238
|
+
* {@link start}, which is fail-fast instead. Both fill the pending reservation
|
|
4239
|
+
* the bare `/p2p-circuit` search listener registered; calling this afterwards
|
|
4240
|
+
* simply replaces the supervisor with one over the new list.
|
|
3882
4241
|
*
|
|
3883
4242
|
* Resolves once the FIRST attempt has settled, exactly as it did when it drove
|
|
3884
4243
|
* once; the retries continue in the background until {@link stop} or the next
|
|
@@ -4011,6 +4370,10 @@ export class CadreNode {
|
|
|
4011
4370
|
* Push the multiaddrs that future invites should advertise. Pass `null` to
|
|
4012
4371
|
* revert to the libp2p-reported addresses (the default). The host calls this
|
|
4013
4372
|
* at spawn and on every NAT change.
|
|
4373
|
+
*
|
|
4374
|
+
* Entries need NOT carry a `/p2p/<peerId>` suffix — {@link resolveInviteAddresses}
|
|
4375
|
+
* appends this node's own before anything publishes or dials them (see
|
|
4376
|
+
* {@link normalizeSelfAddrs}). Passing them suffixed is equally fine.
|
|
4014
4377
|
*/
|
|
4015
4378
|
setInviteAddresses(addresses) {
|
|
4016
4379
|
this.latestInviteAddresses = addresses;
|
|
@@ -4018,17 +4381,59 @@ export class CadreNode {
|
|
|
4018
4381
|
}
|
|
4019
4382
|
/**
|
|
4020
4383
|
* Resolve the addresses to embed in invites. Prefers pushed addresses, then
|
|
4021
|
-
* any config-supplied resolver, then the libp2p-observed multiaddrs.
|
|
4384
|
+
* any config-supplied resolver, then the libp2p-observed multiaddrs. The two
|
|
4385
|
+
* app-supplied sources are normalized onto `/p2p/<self>` (see
|
|
4386
|
+
* {@link normalizeSelfAddrs}) so neither hook has to remember the suffix;
|
|
4387
|
+
* libp2p's own addresses already carry it.
|
|
4022
4388
|
*/
|
|
4023
4389
|
async resolveInviteAddresses() {
|
|
4024
4390
|
if (this.latestInviteAddresses !== null) {
|
|
4025
|
-
return this.latestInviteAddresses;
|
|
4391
|
+
return this.normalizeSelfAddrs(this.latestInviteAddresses);
|
|
4026
4392
|
}
|
|
4027
4393
|
if (this.config.network?.inviteAddressResolver) {
|
|
4028
|
-
return this.config.network.inviteAddressResolver();
|
|
4394
|
+
return this.normalizeSelfAddrs(await this.config.network.inviteAddressResolver());
|
|
4029
4395
|
}
|
|
4396
|
+
// Not normalized: this branch is libp2p's own output, which already
|
|
4397
|
+
// encapsulates the peer id into every address it reports. Only the two app
|
|
4398
|
+
// hooks above take arbitrary strings and therefore need the guarantee.
|
|
4030
4399
|
return this.getMultiaddrs();
|
|
4031
4400
|
}
|
|
4401
|
+
/**
|
|
4402
|
+
* Normalize addresses an app handed us for THIS node onto `/p2p/<self>`, the
|
|
4403
|
+
* same invariant {@link normalizeDialAddrs} enforces on the way out.
|
|
4404
|
+
*
|
|
4405
|
+
* `setInviteAddresses` (the admin API `PUT /admin/invite-addresses`) and
|
|
4406
|
+
* `network.inviteAddressResolver` both take arbitrary strings, and whatever
|
|
4407
|
+
* they return ends up in this node's published `CadrePeer` record — so ONE
|
|
4408
|
+
* unsuffixed entry from either hook is enough to give every sibling in the
|
|
4409
|
+
* party a mixed candidate list for this peer. Establishing the suffix here,
|
|
4410
|
+
* where those strings enter the system, means neither hook has to know the
|
|
4411
|
+
* rule; `cadre-host`'s `buildInviteAddresses` already appends it, and
|
|
4412
|
+
* re-normalizing an already-suffixed address is a no-op.
|
|
4413
|
+
*
|
|
4414
|
+
* Unparsable and other-peer-addressed entries are passed through untouched
|
|
4415
|
+
* rather than dropped: publication is not the place to police an address's
|
|
4416
|
+
* validity, and {@link resolvePeerAddrs} already drops both on the read side.
|
|
4417
|
+
* Before `start()` resolves an identity there is no peer id to append, so the
|
|
4418
|
+
* list is returned as given.
|
|
4419
|
+
*/
|
|
4420
|
+
normalizeSelfAddrs(addrs) {
|
|
4421
|
+
const selfPeerId = this.peerId?.toString();
|
|
4422
|
+
if (!selfPeerId) {
|
|
4423
|
+
return addrs;
|
|
4424
|
+
}
|
|
4425
|
+
return addrs.map((addr) => {
|
|
4426
|
+
let parsed;
|
|
4427
|
+
try {
|
|
4428
|
+
parsed = multiaddr(addr);
|
|
4429
|
+
}
|
|
4430
|
+
catch (error) {
|
|
4431
|
+
log('normalizeSelfAddrs: leaving unparsable multiaddr %s as-is: %o', addr, error);
|
|
4432
|
+
return addr;
|
|
4433
|
+
}
|
|
4434
|
+
return withTrailingPeerId(parsed, selfPeerId)?.toString() ?? addr;
|
|
4435
|
+
});
|
|
4436
|
+
}
|
|
4032
4437
|
/**
|
|
4033
4438
|
* Enumerate the cadre's `CadrePeer` membership — the ADDRESSABLE surface (see
|
|
4034
4439
|
* {@link isMember}). Rows whose stamp is retired in `CadreControl.Revocation`
|
|
@@ -4090,13 +4495,18 @@ export class CadreNode {
|
|
|
4090
4495
|
* legit member goes un-authorized on readers that only pin the new key. Full
|
|
4091
4496
|
* rotation handling (re-vouch on rotate) is the
|
|
4092
4497
|
* `flip-strand-membership-rotation-known-gap` work, not this predicate's.
|
|
4498
|
+
*
|
|
4499
|
+
* @param retry - Whether the underlying membership read may retry a transient cluster
|
|
4500
|
+
* failure. Only {@link refreshAuthorizedControlPeers} passes `false`, because it runs
|
|
4501
|
+
* as the control database's membership listener with that database's write lock held;
|
|
4502
|
+
* see its comment.
|
|
4093
4503
|
*/
|
|
4094
|
-
async listAuthorizedMembers() {
|
|
4504
|
+
async listAuthorizedMembers(retry = true) {
|
|
4095
4505
|
if (!this.controlDatabase) {
|
|
4096
4506
|
throw new Error('CadreNode must be started before listing members');
|
|
4097
4507
|
}
|
|
4098
4508
|
const selfPeerId = this.peerId?.toString();
|
|
4099
|
-
const rows = await this.controlDatabase.queryCadrePeers();
|
|
4509
|
+
const rows = await this.controlDatabase.queryCadrePeers(retry);
|
|
4100
4510
|
const authorized = rows
|
|
4101
4511
|
.filter(row => row.peerId !== selfPeerId && this.hasAnchoredVoucher(row))
|
|
4102
4512
|
.map(({ peerId, multiaddr }) => ({ peerId, multiaddr }));
|