@serfab/cadre-core 1.10.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cadre-node.d.ts +0 -27
- package/dist/cadre-node.js +2 -94
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-schema.js +1066 -1066
- package/dist/diagnostics/connection-path.d.ts +1 -18
- package/dist/diagnostics/connection-path.js +1 -20
- package/dist/diagnostics/connection-path.js.map +1 -1
- package/dist/identity-key.d.ts +0 -30
- package/dist/identity-key.js +7 -40
- package/dist/identity-key.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +6 -4
- package/dist/index.js.map +1 -1
- package/dist/relay-stun.d.ts +24 -0
- package/dist/relay-stun.js +51 -0
- package/dist/relay-stun.js.map +1 -0
- package/dist/seed-bootstrap.js +4 -4
- package/dist/strand-membership-writer.js +32 -32
- package/package.json +2 -2
- package/src/cadre-node.ts +8654 -8753
- package/src/control-authorization.ts +117 -117
- package/src/control-cohort.ts +128 -128
- package/src/control-formation-recorder.ts +353 -353
- package/src/control-read-retry.ts +216 -216
- package/src/control-schema.ts +1078 -1078
- package/src/control-write-retry.ts +567 -567
- package/src/diagnostics/connection-path.ts +1 -29
- package/src/formation-approval.ts +610 -610
- package/src/hibernation-manager.ts +638 -638
- package/src/identity-key.ts +7 -62
- package/src/index.ts +644 -647
- package/src/invitation-bootstrap.ts +40 -40
- package/src/joined-strand-store.ts +498 -498
- package/src/link-budget.ts +684 -684
- package/src/membership-connection-gater.ts +570 -570
- package/src/node-local-snapshot.ts +298 -298
- package/src/peer-dial.ts +315 -315
- package/src/peer-join-backfill.ts +710 -710
- package/src/pending-join-runner.ts +735 -735
- package/src/relay-reservation.ts +1121 -1121
- package/src/relay-server.ts +98 -98
- package/src/relay-stun.ts +58 -0
- package/src/seed-bootstrap.ts +1448 -1448
- package/src/strand-addr-protocol.ts +547 -547
- package/src/strand-database.ts +287 -287
- package/src/strand-first-sync-gate.ts +391 -391
- package/src/strand-formation-deadlines.ts +227 -227
- package/src/strand-formation-manager.ts +725 -725
- package/src/strand-fret-addrs.ts +145 -145
- package/src/strand-instance-manager.ts +1789 -1789
- package/src/strand-membership-writer.ts +1974 -1974
- package/src/strand-network-state-file.ts +40 -40
- package/src/strand-network-state.ts +243 -243
- package/src/strand-revocation-enforcer.ts +654 -654
- package/src/strand-solicitation.ts +532 -532
- package/src/strand-wake-protocol.ts +401 -401
- package/src/types.ts +2222 -2222
- package/dist/diagnostics/webrtc-turn-tracker.d.ts +0 -50
- package/dist/diagnostics/webrtc-turn-tracker.js +0 -196
- package/dist/diagnostics/webrtc-turn-tracker.js.map +0 -1
- package/src/diagnostics/webrtc-turn-tracker.ts +0 -222
|
@@ -1,570 +1,570 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Control-network inbound admission gates (defense-in-depth): the
|
|
3
|
-
* encrypted-connection checkpoint and the circuit-relay reservation checkpoint.
|
|
4
|
-
*
|
|
5
|
-
* Layer 2 of the membership enforcement chain: the PRIMARY gates are per-stream
|
|
6
|
-
* — every sensitive sereus control protocol rejects a peer that fails the
|
|
7
|
-
* voucher-anchored membership check (wake and strand-addr via
|
|
8
|
-
* `CadreNode.isAuthorizedMember`; the Optimystic control-DB protocols via the
|
|
9
|
-
* materialized-snapshot gate `CadreNode.authorizeInboundControlStream`), and
|
|
10
|
-
* the replicated rows an outsider *can* still write are disbelieved at read
|
|
11
|
-
* time. This module adds the opportunistic connection-level layer on top: a
|
|
12
|
-
* peer this node can positively determine is NOT authorized is refused at the
|
|
13
|
-
* encrypted-connection checkpoint, before any protocol negotiation, so a
|
|
14
|
-
* known-nothing outsider is never even in the conversation — EXCEPT on a node
|
|
15
|
-
* that runs the circuit-relay server, where the refusal moves to the
|
|
16
|
-
* relay-reservation checkpoint instead (see "The relay-reservation seam").
|
|
17
|
-
*
|
|
18
|
-
* ## The stranger allowlist (the ONE place it is defined)
|
|
19
|
-
*
|
|
20
|
-
* A libp2p connection gater decides per CONNECTION, before protocols are
|
|
21
|
-
* negotiated, so "allow seed, deny repo" cannot be expressed here — instead the
|
|
22
|
-
* policy admits a connection whenever a legitimate stranger interaction could be
|
|
23
|
-
* riding it, and the per-stream gates take over. The complete set of protocols a
|
|
24
|
-
* NOT-yet-authorized peer may legitimately speak on a control node is:
|
|
25
|
-
*
|
|
26
|
-
* - `/sereus/seed/1.0.0` ({@link SEED_PROTOCOL}) — enrollment seed delivery.
|
|
27
|
-
* An owner dials a brand-new node to seed it (the new node has no members
|
|
28
|
-
* yet, so its gate is inert), and an invited phone dials the owner before it
|
|
29
|
-
* is authorized (admitted via the enrollment window `CadreNode.createInvite`
|
|
30
|
-
* opens). The handler's own trust decision is the anchored seed-trust policy.
|
|
31
|
-
* - `/sereus/formation/1.0.0` ({@link FORMATION_PROTOCOL}) — cross-party
|
|
32
|
-
* strand formation via open invitations. Stranger-facing BY DESIGN: the
|
|
33
|
-
* initiator is another party, and its token is only checkable inside the
|
|
34
|
-
* protocol. The exemption is therefore keyed on EXPECTATION of a stranger,
|
|
35
|
-
* not capability to serve one: stranger denial is suspended only while this
|
|
36
|
-
* node has at least one UNEXPIRED, NOT-FULLY-CONSUMED open invitation
|
|
37
|
-
* outstanding (`StrandSolicitationService.hasOutstandingInvitation` — the
|
|
38
|
-
* tokens this process minted or published, plus any still-redeemable
|
|
39
|
-
* `FormationInvite` row the usage recorder can see). Registering the
|
|
40
|
-
* responder does NOT suspend it, so every node — each registers one at
|
|
41
|
-
* `CadreNode.start` — keeps a live gate. The
|
|
42
|
-
* handler's own trust decision remains the per-token check, which is
|
|
43
|
-
* strictly finer than this one: a peer admitted here can still be rejected
|
|
44
|
-
* in-protocol for a bogus or spent token.
|
|
45
|
-
*
|
|
46
|
-
* There is one further connection-level carve-out, which is NOT a protocol
|
|
47
|
-
* exemption and needs no stranger window:
|
|
48
|
-
*
|
|
49
|
-
* - **Announced delegate peers** (`delegate-admission.ts`). A member's strand
|
|
50
|
-
* node runs as a separate libp2p identity whose peerId no sibling can
|
|
51
|
-
* recompute. Before starting a strand node, a member's control node
|
|
52
|
-
* announces that peerId over the already-authenticated strand-addr RPC, and
|
|
53
|
-
* the receiver holds a short-lived grant for it
|
|
54
|
-
* (`CadreNode.grantDelegateAdmission`). The grant admits the CONNECTION,
|
|
55
|
-
* admits the peer's RESERVATION at the seam below (without spending the
|
|
56
|
-
* unauthorized budget), and nothing else — it is deliberately invisible to
|
|
57
|
-
* the per-stream gate, so a delegate still gets refused on every
|
|
58
|
-
* members-only protocol.
|
|
59
|
-
*
|
|
60
|
-
* ## The relay-reservation seam
|
|
61
|
-
*
|
|
62
|
-
* A circuit-relay reservation is established by the reserving peer DIALING the
|
|
63
|
-
* relay, so at the relay it is an inbound connection — and killing that
|
|
64
|
-
* connection kills the reservation. For a genuine member whose `CadrePeer` row
|
|
65
|
-
* has not yet replicated to this node, a connection-level deny here is NOT
|
|
66
|
-
* self-healing the way a data connection's is: an outbound reconcile dial
|
|
67
|
-
* re-establishes a data link, but no outbound dial can grant the REMOTE peer a
|
|
68
|
-
* reservation, and a relay-only peer has no address of its own to dial back —
|
|
69
|
-
* the reservation IS its address. Boot ordering makes the window ordinary (a
|
|
70
|
-
* node's circuit listener runs inside `libp2p.start()`, before its own row
|
|
71
|
-
* could have replicated anywhere), and a stalled replication makes it
|
|
72
|
-
* unbounded. So the relay would be answering a RELAY question ("may this peer
|
|
73
|
-
* use my forwarding capacity?") with a MEMBERSHIP answer, at a checkpoint where
|
|
74
|
-
* a wrong answer is unrecoverable.
|
|
75
|
-
*
|
|
76
|
-
* On a node whose relay server is enabled the two questions are separated:
|
|
77
|
-
*
|
|
78
|
-
* - The policy returns `'admit-for-relay'` instead of `'deny'`, and the
|
|
79
|
-
* connection is ADMITTED. The fail-closed per-stream gates still refuse such
|
|
80
|
-
* a peer every members-only protocol, so it gains identify/ping and the
|
|
81
|
-
* relay hop protocol, nothing else.
|
|
82
|
-
* - The reservation itself is decided at libp2p's
|
|
83
|
-
* `denyInboundRelayReservation` hook (the circuit-relay server consults it
|
|
84
|
-
* per RESERVE request): the policy admits members, delegates and configured
|
|
85
|
-
* infra outright, and admits peers it cannot place only within a bounded
|
|
86
|
-
* budget ({@link UnauthorizedReservationBudget}) — a member whose row is in
|
|
87
|
-
* flight always finds a slot under any sane cap, while outsiders cannot
|
|
88
|
-
* annex the party's relay capacity. COUNT and lifetime are the only bounds
|
|
89
|
-
* on such a peer: a party-run relay forwards without libp2p's per-connection
|
|
90
|
-
* data and duration limit by default (`relay-server.ts`), so what a granted
|
|
91
|
-
* slot carries is not capped.
|
|
92
|
-
* - An `'admit-for-relay'` connection that is NOT reserving is dropped: it has
|
|
93
|
-
* {@link RELAY_ADMISSION_RESERVE_DEADLINE_MS} to get a reservation ADMITTED
|
|
94
|
-
* at that hook, after which the gate CLOSES the underlying connection so
|
|
95
|
-
* both ends let go of the socket. The guarantee above thus weakens on
|
|
96
|
-
* relay-enabled nodes from "never in the conversation" to "in it briefly,
|
|
97
|
-
* and can speak nothing".
|
|
98
|
-
*
|
|
99
|
-
* The deadline clears on reservation ADMISSION, not on the reservation's own
|
|
100
|
-
* success: this gate cannot observe the server-side `reserve()` outcome, so a
|
|
101
|
-
* peer whose admitted reservation is then refused for server capacity keeps its
|
|
102
|
-
* connection until either side closes it — bounded and mute, so harmless.
|
|
103
|
-
*
|
|
104
|
-
* Everything else a control node handles — the Optimystic control-DB protocols
|
|
105
|
-
* (`/optimystic/control-<party>/{repo,cluster,sync,block-transfer}/…`), wake
|
|
106
|
-
* (`/sereus/strand-wake/1.0.0`), and strand-addr (`/sereus/strand-addr/1.0.0`)
|
|
107
|
-
* — is members-only, and every one of them enforces that per-stream. Wake and
|
|
108
|
-
* strand-addr check `isAuthorizedMember` inside their handlers; the four
|
|
109
|
-
* control-DB protocols are gated by the fail-closed
|
|
110
|
-
* `CadreNode.authorizeInboundControlStream` (wired as libp2p's
|
|
111
|
-
* `authorizeInboundStream` upstream hook), which judges each inbound stream
|
|
112
|
-
* against the MATERIALIZED authorized-peer snapshot — synchronous and
|
|
113
|
-
* in-memory, because a live DB read from inside the gate would deadlock into
|
|
114
|
-
* mutual denial. The two layers complement, not duplicate: this connection
|
|
115
|
-
* gate is fail-open over a live DB read (deny only on positive proof of an
|
|
116
|
-
* outsider), the stream gate is fail-closed over the snapshot and has NO
|
|
117
|
-
* stranger carve-outs — an enrollment window admits a stranger's connection
|
|
118
|
-
* for seed delivery, yet its repo streams are still refused.
|
|
119
|
-
*
|
|
120
|
-
* ## The bring-up quiet period
|
|
121
|
-
*
|
|
122
|
-
* One state is decided BEFORE any of the above, and is not about the remote peer
|
|
123
|
-
* at all: while this node's control-database bring-up is in flight
|
|
124
|
-
* (`InboundAdmissionPolicy.bringUpInFlight`), the gate denies BOTH directions —
|
|
125
|
-
* `denyDialPeer` and `denyInboundEncryptedConnection` — and then opens.
|
|
126
|
-
*
|
|
127
|
-
* The invariant it protects is "`ControlDatabase.initialize()` runs while this
|
|
128
|
-
* node holds ZERO control connections". Building that database is a long chain
|
|
129
|
-
* of cohort-consulting block probes, and every connected same-party sibling is in
|
|
130
|
-
* the cohort those probes consult. A sibling that has not yet replicated this
|
|
131
|
-
* node's `CadrePeer` row correctly refuses them at its own fail-closed per-stream
|
|
132
|
-
* gate, so ONE connection in this window turns bring-up into
|
|
133
|
-
* `BlockUnavailableError` and `start()` rejects. Retrying cannot converge: the
|
|
134
|
-
* condition that would clear the refusal is this node's own row reaching the
|
|
135
|
-
* sibling, and writing that row needs the database the retry is building. So the
|
|
136
|
-
* only fix is to not be in the conversation yet — which is what this window is.
|
|
137
|
-
*
|
|
138
|
-
* The ordering is arranged so nothing SHOULD open a connection here anyway:
|
|
139
|
-
* `network.relayAddrs` resolves to a listener that dials nothing and reserves
|
|
140
|
-
* after bring-up (`relay-addrs.ts`), and the control-cohort reconcile pass is
|
|
141
|
-
* scheduled post-start. This window is what makes that a property rather than an
|
|
142
|
-
* accident of ordering — the live case it actually catches is
|
|
143
|
-
* `controlNetwork.bootstrapNodes`, where `@libp2p/bootstrap` emits its discovery
|
|
144
|
-
* events ~1 s after `libp2p.start()` and the connection manager auto-dials from
|
|
145
|
-
* there, which is a race bring-up wins only while raw-storage latency stays low.
|
|
146
|
-
*
|
|
147
|
-
* A denial here costs a retry, not a partition: libp2p's connection manager
|
|
148
|
-
* re-dials on its auto-dial cadence, the reservation supervisor re-drives, and a
|
|
149
|
-
* denied inbound peer reconnects. The window opens on bring-up FAILURE too (via
|
|
150
|
-
* `CadreNode.cleanup`), so teardown is never gated.
|
|
151
|
-
*
|
|
152
|
-
* ## Fail-open, deliberately
|
|
153
|
-
*
|
|
154
|
-
* Outside that window this layer only ever denies on a POSITIVE determination of
|
|
155
|
-
* "unauthorized outsider while no stranger path is open". Any error, missing
|
|
156
|
-
* dependency, or ambiguous state admits the connection (and the reservation) and
|
|
157
|
-
* defers to the fail-closed stream gates — a DB hiccup must not partition a
|
|
158
|
-
* legitimate cadre.
|
|
159
|
-
*/
|
|
160
|
-
|
|
161
|
-
import debug from 'debug';
|
|
162
|
-
import type { ConnectionGater, PeerId, MultiaddrConnection } from '@libp2p/interface';
|
|
163
|
-
import { SEED_PROTOCOL } from './seed-bootstrap.js';
|
|
164
|
-
import { FORMATION_PROTOCOL } from './strand-formation-protocol.js';
|
|
165
|
-
import { withDeadline } from './control-stream.js';
|
|
166
|
-
import { PARTY_RELAY_RESERVATION_TTL_MS } from './relay-server.js';
|
|
167
|
-
import { ADMISSION_DECISION_TIMEOUT_MS, relayAdmissionReserveDeadlineMs } from './link-budget.js';
|
|
168
|
-
|
|
169
|
-
const log = debug('sereus:cadre:connection-gater');
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* The protocols a not-yet-authorized peer may legitimately speak on a control
|
|
173
|
-
* node (see the module doc above for why each is open and where its own trust
|
|
174
|
-
* decision lives). Exported as the single reference point so the allowlist
|
|
175
|
-
* cannot silently drift across modules.
|
|
176
|
-
*/
|
|
177
|
-
export const STRANGER_OPEN_PROTOCOLS: readonly string[] = [SEED_PROTOCOL, FORMATION_PROTOCOL];
|
|
178
|
-
|
|
179
|
-
/**
|
|
180
|
-
* How long `CadreNode.createInvite` holds the inbound gate open for strangers
|
|
181
|
-
* when the invite carries no explicit expiry: the invitee must dial in before
|
|
182
|
-
* it is authorized, and an expiry-less invite gives the gate no bound of its
|
|
183
|
-
* own. Redeeming after this window still works once the owner re-mints (or the
|
|
184
|
-
* caller re-opens the window via `CadreNode.openEnrollmentWindow`).
|
|
185
|
-
*/
|
|
186
|
-
export const DEFAULT_ENROLLMENT_WINDOW_MS = 30 * 60 * 1000;
|
|
187
|
-
|
|
188
|
-
/**
|
|
189
|
-
* How long an `'admit-for-relay'` connection may exist without a relay
|
|
190
|
-
* reservation being ADMITTED at the `denyInboundRelayReservation` hook, after
|
|
191
|
-
* which the gate closes the underlying connection. A reserving client asks for
|
|
192
|
-
* its slot immediately after the connection upgrades (`relay-reservation.ts`
|
|
193
|
-
* dials and requests in one drive; a strand node's configured circuit listener
|
|
194
|
-
* does the same from inside its own `listen()`), so a connection idle past this
|
|
195
|
-
* deadline is not reserving.
|
|
196
|
-
*
|
|
197
|
-
* Derived from the link ({@link relayAdmissionReserveDeadlineMs}): one link round trip for the
|
|
198
|
-
* request to arrive plus one admission decision on it. This is the value at the default
|
|
199
|
-
* declaration; `CadreNode` passes the one derived from the relay's OWN declaration, because the
|
|
200
|
-
* relay is the machine that decides.
|
|
201
|
-
*/
|
|
202
|
-
export const RELAY_ADMISSION_RESERVE_DEADLINE_MS = relayAdmissionReserveDeadlineMs();
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* Bound on the graceful close of an expired relay-only connection. It exists
|
|
206
|
-
* because `AbstractMultiaddrConnection.close()` awaits an `idle`/`drain` event
|
|
207
|
-
* when the connection still has unsent bytes, and that wait has no timeout of
|
|
208
|
-
* its own — an unsignalled one never ends. This gate writes nothing to a
|
|
209
|
-
* stranger, so the wait is not reachable today; the bound is here so a timer
|
|
210
|
-
* callback can never hold an unending await.
|
|
211
|
-
*
|
|
212
|
-
* Applied through {@link withDeadline}, not `AbortSignal.timeout`: the latter is
|
|
213
|
-
* not reliably present on React Native/Hermes, which loads this same module.
|
|
214
|
-
*/
|
|
215
|
-
// eslint-disable-next-line no-restricted-syntax -- link-independent: bounds a local connection close inside a timer callback, and expiry aborts the connection instead
|
|
216
|
-
export const RELAY_ADMISSION_CLOSE_TIMEOUT_MS = 2_000;
|
|
217
|
-
|
|
218
|
-
/**
|
|
219
|
-
* Default cap on concurrent relay reservations held by peers the membership
|
|
220
|
-
* check could not place (see {@link UnauthorizedReservationBudget}). Small on
|
|
221
|
-
* purpose: it exists for the handful of genuine members whose rows are still
|
|
222
|
-
* in flight, not as public relay capacity. Overridable per node via
|
|
223
|
-
* `network.unauthorizedRelayReservationCap` (0 refuses every unauthorized
|
|
224
|
-
* reservation — the strict pre-seam posture).
|
|
225
|
-
*
|
|
226
|
-
* NOTE: this cap shares the relay server's own reservation store, whose
|
|
227
|
-
* party-run default size is `PARTY_RELAY_MAX_RESERVATIONS` (128,
|
|
228
|
-
* `relay-server.ts`); unplaced peers may therefore occupy up to this many of
|
|
229
|
-
* those slots, and the rest are what members and delegates compete for. The two
|
|
230
|
-
* are kept apart by hand — if this is raised, or an embedder shrinks the store
|
|
231
|
-
* through `network.relayServerInit.reservations.maxReservations`, keep this one
|
|
232
|
-
* well under the store's, or a fleet of unplaceable peers can crowd genuine
|
|
233
|
-
* members out of the server's own store (which the gate cannot override).
|
|
234
|
-
*/
|
|
235
|
-
export const MAX_UNAUTHORIZED_RELAY_RESERVATIONS = 8;
|
|
236
|
-
|
|
237
|
-
/**
|
|
238
|
-
* The connection-level outcome of the admission policy:
|
|
239
|
-
* - `'admit'` — a peer with a legitimate claim on the connection (member,
|
|
240
|
-
* delegate, infra, open stranger window, or any fail-open state).
|
|
241
|
-
* - `'deny'` — positively an outsider, and this node runs no relay server, so
|
|
242
|
-
* the connection can carry nothing legitimate.
|
|
243
|
-
* - `'admit-for-relay'` — positively an outsider (or an unreplicated member —
|
|
244
|
-
* indistinguishable), but this node runs the relay server: admit the
|
|
245
|
-
* connection so a reservation can be asked for, decide at the reservation
|
|
246
|
-
* seam, and drop the connection if no reservation is admitted in time.
|
|
247
|
-
*/
|
|
248
|
-
export type InboundConnectionVerdict = 'admit' | 'deny' | 'admit-for-relay';
|
|
249
|
-
|
|
250
|
-
/**
|
|
251
|
-
* The admission decisions the gate defers to — implemented by
|
|
252
|
-
* `CadreNode.admitInboundControlConnection` /
|
|
253
|
-
* `CadreNode.admitControlRelayReservation` (enrollment windows, anchor state,
|
|
254
|
-
* bootstrap infra, delegate grants, the authorized-member set, the
|
|
255
|
-
* unauthorized-reservation budget). Kept injectable so the gater's
|
|
256
|
-
* composition/fail-open behavior is unit-testable without a full node.
|
|
257
|
-
*/
|
|
258
|
-
export interface InboundAdmissionPolicy {
|
|
259
|
-
/** Verdict on an inbound encrypted connection from this peer. */
|
|
260
|
-
admitInbound(remotePeerId: string): Promise<InboundConnectionVerdict> | InboundConnectionVerdict;
|
|
261
|
-
/** Should this peer be granted a circuit-relay reservation slot? */
|
|
262
|
-
admitRelayReservation(remotePeerId: string): Promise<boolean> | boolean;
|
|
263
|
-
/**
|
|
264
|
-
* True while this node's control-database bring-up is in flight — the
|
|
265
|
-
* {@link createMembershipConnectionGater} quiet period (see the module doc).
|
|
266
|
-
* Peer-independent, unlike the two decisions above, and SYNCHRONOUS: it is
|
|
267
|
-
* read on the outbound-dial path, where an await would be a new stall.
|
|
268
|
-
*
|
|
269
|
-
* Optional — a policy that omits it is never quiet, which is the right default
|
|
270
|
-
* for every caller that is not a booting `CadreNode`.
|
|
271
|
-
*/
|
|
272
|
-
bringUpInFlight?(): boolean;
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
/**
|
|
276
|
-
* Bounded budget of concurrent relay reservations for peers the membership
|
|
277
|
-
* check could not place. `tryAdmit` is the whole surface: an already-admitted
|
|
278
|
-
* peer refreshes its entry (a reservation refresh never double-counts), a new
|
|
279
|
-
* peer takes a free slot or is refused. Entries expire after `ttlMs`, which
|
|
280
|
-
* `CadreNode` passes as the relay server's own resolved `reservationTtl`
|
|
281
|
-
* (`relay-server.ts`): the server holds an unrefreshed reservation exactly that
|
|
282
|
-
* long, and a live reserver re-requests (re-hitting the admission hook,
|
|
283
|
-
* refreshing its entry) well before expiry — so the live entry count tracks the
|
|
284
|
-
* server's own occupancy without this module reaching into the server's
|
|
285
|
-
* reservation store. Injectable `now` keeps expiry testable without fake
|
|
286
|
-
* timers. Authorized members and delegates are never run through this — the
|
|
287
|
-
* policy admits them before consulting the budget, and `release`s the slot such
|
|
288
|
-
* a peer took while it was still unplaceable, so the boot-ordering window a
|
|
289
|
-
* member passes through costs the budget nothing once its row lands.
|
|
290
|
-
*/
|
|
291
|
-
export class UnauthorizedReservationBudget {
|
|
292
|
-
private readonly admitted = new Map<string, number>();
|
|
293
|
-
|
|
294
|
-
constructor(
|
|
295
|
-
/** Most peers admitted at once — `network.unauthorizedRelayReservationCap`, resolved. */
|
|
296
|
-
readonly cap: number = MAX_UNAUTHORIZED_RELAY_RESERVATIONS,
|
|
297
|
-
private readonly ttlMs: number = PARTY_RELAY_RESERVATION_TTL_MS
|
|
298
|
-
) {}
|
|
299
|
-
|
|
300
|
-
/** Number of live (unexpired at last prune) entries — test/diagnostic surface. */
|
|
301
|
-
get size(): number {
|
|
302
|
-
return this.admitted.size;
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
/** Admit (or refresh) `remotePeerId` if a slot is free; false when the cap is spent. */
|
|
306
|
-
tryAdmit(remotePeerId: string, now: number = Date.now()): boolean {
|
|
307
|
-
this.prune(now);
|
|
308
|
-
if (!this.admitted.has(remotePeerId) && this.admitted.size >= this.cap) {
|
|
309
|
-
log('Unauthorized-reservation budget spent (%d/%d) — refusing %s', this.admitted.size, this.cap, remotePeerId);
|
|
310
|
-
return false;
|
|
311
|
-
}
|
|
312
|
-
this.admitted.set(remotePeerId, now + this.ttlMs);
|
|
313
|
-
return true;
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
/**
|
|
317
|
-
* Give back the slot `remotePeerId` holds, if any — called when the peer has
|
|
318
|
-
* become admissible on its own merits (its membership row replicated, or a
|
|
319
|
-
* delegate grant landed), so the slot it took during the boot-ordering window
|
|
320
|
-
* does not stay spent for the remaining TTL on a peer that no longer needs it.
|
|
321
|
-
*/
|
|
322
|
-
release(remotePeerId: string): void {
|
|
323
|
-
this.admitted.delete(remotePeerId);
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
private prune(now: number): void {
|
|
327
|
-
for (const [peerId, expiresAt] of this.admitted) {
|
|
328
|
-
if (expiresAt <= now) {
|
|
329
|
-
this.admitted.delete(peerId);
|
|
330
|
-
}
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
/**
|
|
336
|
-
* Build the control node's connection gater: the caller-supplied gater (if any)
|
|
337
|
-
* with membership admission composed onto `denyInboundEncryptedConnection` —
|
|
338
|
-
* the earliest checkpoint where the remote's authenticated PeerId is known —
|
|
339
|
-
* and reservation admission composed onto `denyInboundRelayReservation` — the
|
|
340
|
-
* hook the circuit-relay server consults per RESERVE request (inert on a node
|
|
341
|
-
* whose relay server is off; libp2p never calls it there).
|
|
342
|
-
*
|
|
343
|
-
* `denyDialPeer` is composed too, but ONLY for the bring-up quiet period (see
|
|
344
|
-
* the module doc): outside that window outbound dials are never gated by
|
|
345
|
-
* membership — this node decides for itself who to talk to.
|
|
346
|
-
*
|
|
347
|
-
* Composition semantics: every hook of `base` is preserved as-is; on the three
|
|
348
|
-
* composed hooks a deny from EITHER the base gater or the admission policy
|
|
349
|
-
* denies. A policy error — or a decision slower than `decisionTimeoutMs` —
|
|
350
|
-
* takes the fail-open outcome (connection admitted / reservation admitted, see
|
|
351
|
-
* module doc); the base gater's verdict is still honored first.
|
|
352
|
-
*
|
|
353
|
-
* An `'admit-for-relay'` verdict admits the connection and arms a
|
|
354
|
-
* `reserveDeadlineMs` timer against it; the timer is disarmed when a
|
|
355
|
-
* reservation for that peer is ADMITTED at the reservation hook, and closes the
|
|
356
|
-
* underlying `MultiaddrConnection` when it fires first.
|
|
357
|
-
*
|
|
358
|
-
* NOTE: `base` is spread, so a gater passed as a CLASS INSTANCE would lose its
|
|
359
|
-
* prototype methods; every caller in this repo (and libp2p's own default)
|
|
360
|
-
* supplies a plain object. If a class-based gater ever shows up, delegate
|
|
361
|
-
* per-hook instead of spreading.
|
|
362
|
-
*
|
|
363
|
-
* Deny timing, as observed by the denied dialer: noise negotiates the muxer in
|
|
364
|
-
* the security handshake's early data, so the DIALER's upgrade may complete
|
|
365
|
-
* (its `dial()` resolves) before this receiver-side hook runs. The deny then
|
|
366
|
-
* aborts the receiver's upgrade — the receiver never registers the connection
|
|
367
|
-
* and never creates its muxer, so no protocol can ever be negotiated — and the
|
|
368
|
-
* dialer sees its "open" connection close moments later.
|
|
369
|
-
*
|
|
370
|
-
* Control node only: strand cohort nodes legitimately connect cross-party
|
|
371
|
-
* peers, so cadre membership never gates them. An OPEN strand's node receives
|
|
372
|
-
* the raw configured gater unchanged; a CLOSED strand's node composes its own
|
|
373
|
-
* revoked-peer denial onto it instead — a deny-list on positive revocation
|
|
374
|
-
* evidence, not this module's membership admission — see
|
|
375
|
-
* `strand-revocation-enforcer.ts`.
|
|
376
|
-
*/
|
|
377
|
-
export function createMembershipConnectionGater(
|
|
378
|
-
policy: InboundAdmissionPolicy,
|
|
379
|
-
base?: ConnectionGater,
|
|
380
|
-
decisionTimeoutMs: number = ADMISSION_DECISION_TIMEOUT_MS,
|
|
381
|
-
reserveDeadlineMs: number = RELAY_ADMISSION_RESERVE_DEADLINE_MS
|
|
382
|
-
): ConnectionGater {
|
|
383
|
-
const pendingReservations = new PendingReserveDeadlines(reserveDeadlineMs);
|
|
384
|
-
const quiet = (): boolean => policy.bringUpInFlight?.() ?? false;
|
|
385
|
-
return {
|
|
386
|
-
...base,
|
|
387
|
-
denyDialPeer: async (peerId: PeerId): Promise<boolean> => {
|
|
388
|
-
if (await base?.denyDialPeer?.(peerId)) {
|
|
389
|
-
return true;
|
|
390
|
-
}
|
|
391
|
-
if (quiet()) {
|
|
392
|
-
log('Control-database bring-up in flight — refusing the outbound dial to %s', peerId.toString());
|
|
393
|
-
return true;
|
|
394
|
-
}
|
|
395
|
-
return false;
|
|
396
|
-
},
|
|
397
|
-
denyInboundEncryptedConnection: async (peerId: PeerId, maConn: MultiaddrConnection): Promise<boolean> => {
|
|
398
|
-
if (await base?.denyInboundEncryptedConnection?.(peerId, maConn)) {
|
|
399
|
-
return true;
|
|
400
|
-
}
|
|
401
|
-
if (quiet()) {
|
|
402
|
-
log('Control-database bring-up in flight — refusing the inbound connection from %s', peerId.toString());
|
|
403
|
-
return true;
|
|
404
|
-
}
|
|
405
|
-
const remotePeerId = peerId.toString();
|
|
406
|
-
let verdict: InboundConnectionVerdict;
|
|
407
|
-
try {
|
|
408
|
-
verdict = await decideWithinDeadline(
|
|
409
|
-
() => policy.admitInbound(remotePeerId), 'admit', decisionTimeoutMs, `admitInbound(${remotePeerId})`
|
|
410
|
-
);
|
|
411
|
-
} catch (error) {
|
|
412
|
-
log('admitInbound threw for %s — admitting (fail-open; stream gates decide): %o', remotePeerId, error);
|
|
413
|
-
return false;
|
|
414
|
-
}
|
|
415
|
-
if (verdict === 'admit-for-relay') {
|
|
416
|
-
pendingReservations.arm(remotePeerId, maConn);
|
|
417
|
-
return false;
|
|
418
|
-
}
|
|
419
|
-
return verdict === 'deny';
|
|
420
|
-
},
|
|
421
|
-
denyInboundRelayReservation: async (peerId: PeerId): Promise<boolean> => {
|
|
422
|
-
if (await base?.denyInboundRelayReservation?.(peerId)) {
|
|
423
|
-
return true;
|
|
424
|
-
}
|
|
425
|
-
const remotePeerId = peerId.toString();
|
|
426
|
-
let admitted: boolean;
|
|
427
|
-
try {
|
|
428
|
-
admitted = await decideWithinDeadline(
|
|
429
|
-
() => policy.admitRelayReservation(remotePeerId), true, decisionTimeoutMs, `admitRelayReservation(${remotePeerId})`
|
|
430
|
-
);
|
|
431
|
-
} catch (error) {
|
|
432
|
-
log('admitRelayReservation threw for %s — admitting (fail-open): %o', remotePeerId, error);
|
|
433
|
-
admitted = true;
|
|
434
|
-
}
|
|
435
|
-
if (admitted) {
|
|
436
|
-
pendingReservations.disarm(remotePeerId);
|
|
437
|
-
}
|
|
438
|
-
return !admitted;
|
|
439
|
-
}
|
|
440
|
-
};
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
/**
|
|
444
|
-
* Run one admission decision under `timeoutMs`, resolving to `fallback` (the
|
|
445
|
-
* fail-open outcome) if it has not settled in time — see
|
|
446
|
-
* {@link ADMISSION_DECISION_TIMEOUT_MS} for why an unbounded await is not safe
|
|
447
|
-
* on the connection hook. The timer is always cleared so a decided call never
|
|
448
|
-
* holds the event loop open. Exported for the strand revoked-peer gate
|
|
449
|
-
* (`strand-revocation-enforcer.ts`), which bounds its connection hooks with the
|
|
450
|
-
* same fail-open contract.
|
|
451
|
-
*/
|
|
452
|
-
export async function decideWithinDeadline<T>(
|
|
453
|
-
decide: () => Promise<T> | T,
|
|
454
|
-
fallback: T,
|
|
455
|
-
timeoutMs: number,
|
|
456
|
-
what: string
|
|
457
|
-
): Promise<T> {
|
|
458
|
-
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
459
|
-
try {
|
|
460
|
-
return await Promise.race([
|
|
461
|
-
Promise.resolve().then(decide),
|
|
462
|
-
new Promise<T>((resolve) => {
|
|
463
|
-
timer = setTimeout(() => {
|
|
464
|
-
log('%s exceeded %dms — taking the fail-open outcome (stream gates decide)', what, timeoutMs);
|
|
465
|
-
resolve(fallback);
|
|
466
|
-
}, timeoutMs);
|
|
467
|
-
})
|
|
468
|
-
]);
|
|
469
|
-
} finally {
|
|
470
|
-
clearTimeout(timer);
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
/** One armed not-reserving deadline: the connection it will close, and its timer. */
|
|
475
|
-
interface PendingReserveDeadline {
|
|
476
|
-
maConn: MultiaddrConnection;
|
|
477
|
-
timer: ReturnType<typeof setTimeout>;
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
/**
|
|
481
|
-
* The not-reserving deadlines for `'admit-for-relay'` connections, keyed by
|
|
482
|
-
* remote peerId (a Set per peer — one peer can hold several in-flight
|
|
483
|
-
* connections). `arm` starts a timer that closes the connection; `disarm`
|
|
484
|
-
* (called when a reservation for that peer is admitted) cancels every pending
|
|
485
|
-
* timer for the peer. Timers are unref'd so an armed deadline never holds a
|
|
486
|
-
* process open, and a timer that fires against an already-closed connection is
|
|
487
|
-
* a no-op (`close()` returns at once on any status but `open`).
|
|
488
|
-
*
|
|
489
|
-
* Disarming is final for the connections it cancelled: a peer that reserved
|
|
490
|
-
* once and then lets its reservation lapse keeps a mute connection (the
|
|
491
|
-
* fail-closed stream gates refuse it every members-only protocol) until either
|
|
492
|
-
* side closes it. Bounded — only a peer the reservation policy already admitted
|
|
493
|
-
* can reach that state, so unplaceable peers are bounded by the budget cap.
|
|
494
|
-
*/
|
|
495
|
-
class PendingReserveDeadlines {
|
|
496
|
-
private readonly byPeer = new Map<string, Set<PendingReserveDeadline>>();
|
|
497
|
-
|
|
498
|
-
constructor(private readonly deadlineMs: number) {}
|
|
499
|
-
|
|
500
|
-
arm(remotePeerId: string, maConn: MultiaddrConnection): void {
|
|
501
|
-
const entry: PendingReserveDeadline = {
|
|
502
|
-
maConn,
|
|
503
|
-
timer: setTimeout(() => this.expire(remotePeerId, entry), this.deadlineMs)
|
|
504
|
-
};
|
|
505
|
-
(entry.timer as { unref?: () => void }).unref?.();
|
|
506
|
-
let entries = this.byPeer.get(remotePeerId);
|
|
507
|
-
if (!entries) {
|
|
508
|
-
entries = new Set();
|
|
509
|
-
this.byPeer.set(remotePeerId, entries);
|
|
510
|
-
}
|
|
511
|
-
entries.add(entry);
|
|
512
|
-
log('Admitted %s for relay only — dropping the connection unless a reservation is admitted within %dms', remotePeerId, this.deadlineMs);
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
disarm(remotePeerId: string): void {
|
|
516
|
-
const entries = this.byPeer.get(remotePeerId);
|
|
517
|
-
if (!entries) {
|
|
518
|
-
return;
|
|
519
|
-
}
|
|
520
|
-
this.byPeer.delete(remotePeerId);
|
|
521
|
-
for (const entry of entries) {
|
|
522
|
-
clearTimeout(entry.timer);
|
|
523
|
-
}
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
private expire(remotePeerId: string, entry: PendingReserveDeadline): void {
|
|
527
|
-
const entries = this.byPeer.get(remotePeerId);
|
|
528
|
-
entries?.delete(entry);
|
|
529
|
-
if (entries?.size === 0) {
|
|
530
|
-
this.byPeer.delete(remotePeerId);
|
|
531
|
-
}
|
|
532
|
-
log('Relay-only admission expired for %s — no reservation admitted within %dms, closing the connection', remotePeerId, this.deadlineMs);
|
|
533
|
-
void this.drop(remotePeerId, entry.maConn);
|
|
534
|
-
}
|
|
535
|
-
|
|
536
|
-
/**
|
|
537
|
-
* End an expired relay-only connection: a bounded graceful close, with
|
|
538
|
-
* `abort()` only as the escalation. The order cannot be reversed — `abort()`
|
|
539
|
-
* marks the connection `aborted`, and `close()` returns immediately on any
|
|
540
|
-
* status that is not `open`, so aborting first would make the close a silent
|
|
541
|
-
* no-op. Closing a connection the peer already dropped returns at once
|
|
542
|
-
* without throwing, so the fallback stays unreached in the ordinary case.
|
|
543
|
-
*/
|
|
544
|
-
private async drop(remotePeerId: string, maConn: MultiaddrConnection): Promise<void> {
|
|
545
|
-
try {
|
|
546
|
-
await withDeadline(
|
|
547
|
-
RELAY_ADMISSION_CLOSE_TIMEOUT_MS,
|
|
548
|
-
`relay-only close for ${remotePeerId}`,
|
|
549
|
-
(signal) => maConn.close({ signal })
|
|
550
|
-
);
|
|
551
|
-
return;
|
|
552
|
-
} catch (error) {
|
|
553
|
-
log('Closing the expired relay-only connection from %s failed — aborting: %o', remotePeerId, error);
|
|
554
|
-
}
|
|
555
|
-
// NOTE: this fallback cannot actually free a WebSocket. `@libp2p/websockets`'
|
|
556
|
-
// `sendReset()` calls `websocket.close(1006)`, and 1006 is a reserved code
|
|
557
|
-
// RFC 6455 forbids an endpoint from sending, so `ws` throws and
|
|
558
|
-
// `AbstractMessageStream.abort()` swallows it — the local end flips to
|
|
559
|
-
// `aborted` while the socket stays up. Measured against
|
|
560
|
-
// @libp2p/websockets@10.1.3 (see tickets/blocked/report-libp2p-websockets-abort-close-code.md).
|
|
561
|
-
// `MultiaddrConnection` exposes no way down to the raw socket, so this is as
|
|
562
|
-
// far as this layer can go. Revisit — drop the fallback, or go back to a
|
|
563
|
-
// plain `abort()` — once that transport sends a legal reset code.
|
|
564
|
-
try {
|
|
565
|
-
maConn.abort(new Error(`relay-only admission expired: no relay reservation admitted within ${this.deadlineMs}ms`));
|
|
566
|
-
} catch (error) {
|
|
567
|
-
log('Aborting the expired relay-only connection from %s threw: %o', remotePeerId, error);
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Control-network inbound admission gates (defense-in-depth): the
|
|
3
|
+
* encrypted-connection checkpoint and the circuit-relay reservation checkpoint.
|
|
4
|
+
*
|
|
5
|
+
* Layer 2 of the membership enforcement chain: the PRIMARY gates are per-stream
|
|
6
|
+
* — every sensitive sereus control protocol rejects a peer that fails the
|
|
7
|
+
* voucher-anchored membership check (wake and strand-addr via
|
|
8
|
+
* `CadreNode.isAuthorizedMember`; the Optimystic control-DB protocols via the
|
|
9
|
+
* materialized-snapshot gate `CadreNode.authorizeInboundControlStream`), and
|
|
10
|
+
* the replicated rows an outsider *can* still write are disbelieved at read
|
|
11
|
+
* time. This module adds the opportunistic connection-level layer on top: a
|
|
12
|
+
* peer this node can positively determine is NOT authorized is refused at the
|
|
13
|
+
* encrypted-connection checkpoint, before any protocol negotiation, so a
|
|
14
|
+
* known-nothing outsider is never even in the conversation — EXCEPT on a node
|
|
15
|
+
* that runs the circuit-relay server, where the refusal moves to the
|
|
16
|
+
* relay-reservation checkpoint instead (see "The relay-reservation seam").
|
|
17
|
+
*
|
|
18
|
+
* ## The stranger allowlist (the ONE place it is defined)
|
|
19
|
+
*
|
|
20
|
+
* A libp2p connection gater decides per CONNECTION, before protocols are
|
|
21
|
+
* negotiated, so "allow seed, deny repo" cannot be expressed here — instead the
|
|
22
|
+
* policy admits a connection whenever a legitimate stranger interaction could be
|
|
23
|
+
* riding it, and the per-stream gates take over. The complete set of protocols a
|
|
24
|
+
* NOT-yet-authorized peer may legitimately speak on a control node is:
|
|
25
|
+
*
|
|
26
|
+
* - `/sereus/seed/1.0.0` ({@link SEED_PROTOCOL}) — enrollment seed delivery.
|
|
27
|
+
* An owner dials a brand-new node to seed it (the new node has no members
|
|
28
|
+
* yet, so its gate is inert), and an invited phone dials the owner before it
|
|
29
|
+
* is authorized (admitted via the enrollment window `CadreNode.createInvite`
|
|
30
|
+
* opens). The handler's own trust decision is the anchored seed-trust policy.
|
|
31
|
+
* - `/sereus/formation/1.0.0` ({@link FORMATION_PROTOCOL}) — cross-party
|
|
32
|
+
* strand formation via open invitations. Stranger-facing BY DESIGN: the
|
|
33
|
+
* initiator is another party, and its token is only checkable inside the
|
|
34
|
+
* protocol. The exemption is therefore keyed on EXPECTATION of a stranger,
|
|
35
|
+
* not capability to serve one: stranger denial is suspended only while this
|
|
36
|
+
* node has at least one UNEXPIRED, NOT-FULLY-CONSUMED open invitation
|
|
37
|
+
* outstanding (`StrandSolicitationService.hasOutstandingInvitation` — the
|
|
38
|
+
* tokens this process minted or published, plus any still-redeemable
|
|
39
|
+
* `FormationInvite` row the usage recorder can see). Registering the
|
|
40
|
+
* responder does NOT suspend it, so every node — each registers one at
|
|
41
|
+
* `CadreNode.start` — keeps a live gate. The
|
|
42
|
+
* handler's own trust decision remains the per-token check, which is
|
|
43
|
+
* strictly finer than this one: a peer admitted here can still be rejected
|
|
44
|
+
* in-protocol for a bogus or spent token.
|
|
45
|
+
*
|
|
46
|
+
* There is one further connection-level carve-out, which is NOT a protocol
|
|
47
|
+
* exemption and needs no stranger window:
|
|
48
|
+
*
|
|
49
|
+
* - **Announced delegate peers** (`delegate-admission.ts`). A member's strand
|
|
50
|
+
* node runs as a separate libp2p identity whose peerId no sibling can
|
|
51
|
+
* recompute. Before starting a strand node, a member's control node
|
|
52
|
+
* announces that peerId over the already-authenticated strand-addr RPC, and
|
|
53
|
+
* the receiver holds a short-lived grant for it
|
|
54
|
+
* (`CadreNode.grantDelegateAdmission`). The grant admits the CONNECTION,
|
|
55
|
+
* admits the peer's RESERVATION at the seam below (without spending the
|
|
56
|
+
* unauthorized budget), and nothing else — it is deliberately invisible to
|
|
57
|
+
* the per-stream gate, so a delegate still gets refused on every
|
|
58
|
+
* members-only protocol.
|
|
59
|
+
*
|
|
60
|
+
* ## The relay-reservation seam
|
|
61
|
+
*
|
|
62
|
+
* A circuit-relay reservation is established by the reserving peer DIALING the
|
|
63
|
+
* relay, so at the relay it is an inbound connection — and killing that
|
|
64
|
+
* connection kills the reservation. For a genuine member whose `CadrePeer` row
|
|
65
|
+
* has not yet replicated to this node, a connection-level deny here is NOT
|
|
66
|
+
* self-healing the way a data connection's is: an outbound reconcile dial
|
|
67
|
+
* re-establishes a data link, but no outbound dial can grant the REMOTE peer a
|
|
68
|
+
* reservation, and a relay-only peer has no address of its own to dial back —
|
|
69
|
+
* the reservation IS its address. Boot ordering makes the window ordinary (a
|
|
70
|
+
* node's circuit listener runs inside `libp2p.start()`, before its own row
|
|
71
|
+
* could have replicated anywhere), and a stalled replication makes it
|
|
72
|
+
* unbounded. So the relay would be answering a RELAY question ("may this peer
|
|
73
|
+
* use my forwarding capacity?") with a MEMBERSHIP answer, at a checkpoint where
|
|
74
|
+
* a wrong answer is unrecoverable.
|
|
75
|
+
*
|
|
76
|
+
* On a node whose relay server is enabled the two questions are separated:
|
|
77
|
+
*
|
|
78
|
+
* - The policy returns `'admit-for-relay'` instead of `'deny'`, and the
|
|
79
|
+
* connection is ADMITTED. The fail-closed per-stream gates still refuse such
|
|
80
|
+
* a peer every members-only protocol, so it gains identify/ping and the
|
|
81
|
+
* relay hop protocol, nothing else.
|
|
82
|
+
* - The reservation itself is decided at libp2p's
|
|
83
|
+
* `denyInboundRelayReservation` hook (the circuit-relay server consults it
|
|
84
|
+
* per RESERVE request): the policy admits members, delegates and configured
|
|
85
|
+
* infra outright, and admits peers it cannot place only within a bounded
|
|
86
|
+
* budget ({@link UnauthorizedReservationBudget}) — a member whose row is in
|
|
87
|
+
* flight always finds a slot under any sane cap, while outsiders cannot
|
|
88
|
+
* annex the party's relay capacity. COUNT and lifetime are the only bounds
|
|
89
|
+
* on such a peer: a party-run relay forwards without libp2p's per-connection
|
|
90
|
+
* data and duration limit by default (`relay-server.ts`), so what a granted
|
|
91
|
+
* slot carries is not capped.
|
|
92
|
+
* - An `'admit-for-relay'` connection that is NOT reserving is dropped: it has
|
|
93
|
+
* {@link RELAY_ADMISSION_RESERVE_DEADLINE_MS} to get a reservation ADMITTED
|
|
94
|
+
* at that hook, after which the gate CLOSES the underlying connection so
|
|
95
|
+
* both ends let go of the socket. The guarantee above thus weakens on
|
|
96
|
+
* relay-enabled nodes from "never in the conversation" to "in it briefly,
|
|
97
|
+
* and can speak nothing".
|
|
98
|
+
*
|
|
99
|
+
* The deadline clears on reservation ADMISSION, not on the reservation's own
|
|
100
|
+
* success: this gate cannot observe the server-side `reserve()` outcome, so a
|
|
101
|
+
* peer whose admitted reservation is then refused for server capacity keeps its
|
|
102
|
+
* connection until either side closes it — bounded and mute, so harmless.
|
|
103
|
+
*
|
|
104
|
+
* Everything else a control node handles — the Optimystic control-DB protocols
|
|
105
|
+
* (`/optimystic/control-<party>/{repo,cluster,sync,block-transfer}/…`), wake
|
|
106
|
+
* (`/sereus/strand-wake/1.0.0`), and strand-addr (`/sereus/strand-addr/1.0.0`)
|
|
107
|
+
* — is members-only, and every one of them enforces that per-stream. Wake and
|
|
108
|
+
* strand-addr check `isAuthorizedMember` inside their handlers; the four
|
|
109
|
+
* control-DB protocols are gated by the fail-closed
|
|
110
|
+
* `CadreNode.authorizeInboundControlStream` (wired as libp2p's
|
|
111
|
+
* `authorizeInboundStream` upstream hook), which judges each inbound stream
|
|
112
|
+
* against the MATERIALIZED authorized-peer snapshot — synchronous and
|
|
113
|
+
* in-memory, because a live DB read from inside the gate would deadlock into
|
|
114
|
+
* mutual denial. The two layers complement, not duplicate: this connection
|
|
115
|
+
* gate is fail-open over a live DB read (deny only on positive proof of an
|
|
116
|
+
* outsider), the stream gate is fail-closed over the snapshot and has NO
|
|
117
|
+
* stranger carve-outs — an enrollment window admits a stranger's connection
|
|
118
|
+
* for seed delivery, yet its repo streams are still refused.
|
|
119
|
+
*
|
|
120
|
+
* ## The bring-up quiet period
|
|
121
|
+
*
|
|
122
|
+
* One state is decided BEFORE any of the above, and is not about the remote peer
|
|
123
|
+
* at all: while this node's control-database bring-up is in flight
|
|
124
|
+
* (`InboundAdmissionPolicy.bringUpInFlight`), the gate denies BOTH directions —
|
|
125
|
+
* `denyDialPeer` and `denyInboundEncryptedConnection` — and then opens.
|
|
126
|
+
*
|
|
127
|
+
* The invariant it protects is "`ControlDatabase.initialize()` runs while this
|
|
128
|
+
* node holds ZERO control connections". Building that database is a long chain
|
|
129
|
+
* of cohort-consulting block probes, and every connected same-party sibling is in
|
|
130
|
+
* the cohort those probes consult. A sibling that has not yet replicated this
|
|
131
|
+
* node's `CadrePeer` row correctly refuses them at its own fail-closed per-stream
|
|
132
|
+
* gate, so ONE connection in this window turns bring-up into
|
|
133
|
+
* `BlockUnavailableError` and `start()` rejects. Retrying cannot converge: the
|
|
134
|
+
* condition that would clear the refusal is this node's own row reaching the
|
|
135
|
+
* sibling, and writing that row needs the database the retry is building. So the
|
|
136
|
+
* only fix is to not be in the conversation yet — which is what this window is.
|
|
137
|
+
*
|
|
138
|
+
* The ordering is arranged so nothing SHOULD open a connection here anyway:
|
|
139
|
+
* `network.relayAddrs` resolves to a listener that dials nothing and reserves
|
|
140
|
+
* after bring-up (`relay-addrs.ts`), and the control-cohort reconcile pass is
|
|
141
|
+
* scheduled post-start. This window is what makes that a property rather than an
|
|
142
|
+
* accident of ordering — the live case it actually catches is
|
|
143
|
+
* `controlNetwork.bootstrapNodes`, where `@libp2p/bootstrap` emits its discovery
|
|
144
|
+
* events ~1 s after `libp2p.start()` and the connection manager auto-dials from
|
|
145
|
+
* there, which is a race bring-up wins only while raw-storage latency stays low.
|
|
146
|
+
*
|
|
147
|
+
* A denial here costs a retry, not a partition: libp2p's connection manager
|
|
148
|
+
* re-dials on its auto-dial cadence, the reservation supervisor re-drives, and a
|
|
149
|
+
* denied inbound peer reconnects. The window opens on bring-up FAILURE too (via
|
|
150
|
+
* `CadreNode.cleanup`), so teardown is never gated.
|
|
151
|
+
*
|
|
152
|
+
* ## Fail-open, deliberately
|
|
153
|
+
*
|
|
154
|
+
* Outside that window this layer only ever denies on a POSITIVE determination of
|
|
155
|
+
* "unauthorized outsider while no stranger path is open". Any error, missing
|
|
156
|
+
* dependency, or ambiguous state admits the connection (and the reservation) and
|
|
157
|
+
* defers to the fail-closed stream gates — a DB hiccup must not partition a
|
|
158
|
+
* legitimate cadre.
|
|
159
|
+
*/
|
|
160
|
+
|
|
161
|
+
import debug from 'debug';
|
|
162
|
+
import type { ConnectionGater, PeerId, MultiaddrConnection } from '@libp2p/interface';
|
|
163
|
+
import { SEED_PROTOCOL } from './seed-bootstrap.js';
|
|
164
|
+
import { FORMATION_PROTOCOL } from './strand-formation-protocol.js';
|
|
165
|
+
import { withDeadline } from './control-stream.js';
|
|
166
|
+
import { PARTY_RELAY_RESERVATION_TTL_MS } from './relay-server.js';
|
|
167
|
+
import { ADMISSION_DECISION_TIMEOUT_MS, relayAdmissionReserveDeadlineMs } from './link-budget.js';
|
|
168
|
+
|
|
169
|
+
const log = debug('sereus:cadre:connection-gater');
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The protocols a not-yet-authorized peer may legitimately speak on a control
|
|
173
|
+
* node (see the module doc above for why each is open and where its own trust
|
|
174
|
+
* decision lives). Exported as the single reference point so the allowlist
|
|
175
|
+
* cannot silently drift across modules.
|
|
176
|
+
*/
|
|
177
|
+
export const STRANGER_OPEN_PROTOCOLS: readonly string[] = [SEED_PROTOCOL, FORMATION_PROTOCOL];
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* How long `CadreNode.createInvite` holds the inbound gate open for strangers
|
|
181
|
+
* when the invite carries no explicit expiry: the invitee must dial in before
|
|
182
|
+
* it is authorized, and an expiry-less invite gives the gate no bound of its
|
|
183
|
+
* own. Redeeming after this window still works once the owner re-mints (or the
|
|
184
|
+
* caller re-opens the window via `CadreNode.openEnrollmentWindow`).
|
|
185
|
+
*/
|
|
186
|
+
export const DEFAULT_ENROLLMENT_WINDOW_MS = 30 * 60 * 1000;
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* How long an `'admit-for-relay'` connection may exist without a relay
|
|
190
|
+
* reservation being ADMITTED at the `denyInboundRelayReservation` hook, after
|
|
191
|
+
* which the gate closes the underlying connection. A reserving client asks for
|
|
192
|
+
* its slot immediately after the connection upgrades (`relay-reservation.ts`
|
|
193
|
+
* dials and requests in one drive; a strand node's configured circuit listener
|
|
194
|
+
* does the same from inside its own `listen()`), so a connection idle past this
|
|
195
|
+
* deadline is not reserving.
|
|
196
|
+
*
|
|
197
|
+
* Derived from the link ({@link relayAdmissionReserveDeadlineMs}): one link round trip for the
|
|
198
|
+
* request to arrive plus one admission decision on it. This is the value at the default
|
|
199
|
+
* declaration; `CadreNode` passes the one derived from the relay's OWN declaration, because the
|
|
200
|
+
* relay is the machine that decides.
|
|
201
|
+
*/
|
|
202
|
+
export const RELAY_ADMISSION_RESERVE_DEADLINE_MS = relayAdmissionReserveDeadlineMs();
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Bound on the graceful close of an expired relay-only connection. It exists
|
|
206
|
+
* because `AbstractMultiaddrConnection.close()` awaits an `idle`/`drain` event
|
|
207
|
+
* when the connection still has unsent bytes, and that wait has no timeout of
|
|
208
|
+
* its own — an unsignalled one never ends. This gate writes nothing to a
|
|
209
|
+
* stranger, so the wait is not reachable today; the bound is here so a timer
|
|
210
|
+
* callback can never hold an unending await.
|
|
211
|
+
*
|
|
212
|
+
* Applied through {@link withDeadline}, not `AbortSignal.timeout`: the latter is
|
|
213
|
+
* not reliably present on React Native/Hermes, which loads this same module.
|
|
214
|
+
*/
|
|
215
|
+
// eslint-disable-next-line no-restricted-syntax -- link-independent: bounds a local connection close inside a timer callback, and expiry aborts the connection instead
|
|
216
|
+
export const RELAY_ADMISSION_CLOSE_TIMEOUT_MS = 2_000;
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Default cap on concurrent relay reservations held by peers the membership
|
|
220
|
+
* check could not place (see {@link UnauthorizedReservationBudget}). Small on
|
|
221
|
+
* purpose: it exists for the handful of genuine members whose rows are still
|
|
222
|
+
* in flight, not as public relay capacity. Overridable per node via
|
|
223
|
+
* `network.unauthorizedRelayReservationCap` (0 refuses every unauthorized
|
|
224
|
+
* reservation — the strict pre-seam posture).
|
|
225
|
+
*
|
|
226
|
+
* NOTE: this cap shares the relay server's own reservation store, whose
|
|
227
|
+
* party-run default size is `PARTY_RELAY_MAX_RESERVATIONS` (128,
|
|
228
|
+
* `relay-server.ts`); unplaced peers may therefore occupy up to this many of
|
|
229
|
+
* those slots, and the rest are what members and delegates compete for. The two
|
|
230
|
+
* are kept apart by hand — if this is raised, or an embedder shrinks the store
|
|
231
|
+
* through `network.relayServerInit.reservations.maxReservations`, keep this one
|
|
232
|
+
* well under the store's, or a fleet of unplaceable peers can crowd genuine
|
|
233
|
+
* members out of the server's own store (which the gate cannot override).
|
|
234
|
+
*/
|
|
235
|
+
export const MAX_UNAUTHORIZED_RELAY_RESERVATIONS = 8;
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The connection-level outcome of the admission policy:
|
|
239
|
+
* - `'admit'` — a peer with a legitimate claim on the connection (member,
|
|
240
|
+
* delegate, infra, open stranger window, or any fail-open state).
|
|
241
|
+
* - `'deny'` — positively an outsider, and this node runs no relay server, so
|
|
242
|
+
* the connection can carry nothing legitimate.
|
|
243
|
+
* - `'admit-for-relay'` — positively an outsider (or an unreplicated member —
|
|
244
|
+
* indistinguishable), but this node runs the relay server: admit the
|
|
245
|
+
* connection so a reservation can be asked for, decide at the reservation
|
|
246
|
+
* seam, and drop the connection if no reservation is admitted in time.
|
|
247
|
+
*/
|
|
248
|
+
export type InboundConnectionVerdict = 'admit' | 'deny' | 'admit-for-relay';
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* The admission decisions the gate defers to — implemented by
|
|
252
|
+
* `CadreNode.admitInboundControlConnection` /
|
|
253
|
+
* `CadreNode.admitControlRelayReservation` (enrollment windows, anchor state,
|
|
254
|
+
* bootstrap infra, delegate grants, the authorized-member set, the
|
|
255
|
+
* unauthorized-reservation budget). Kept injectable so the gater's
|
|
256
|
+
* composition/fail-open behavior is unit-testable without a full node.
|
|
257
|
+
*/
|
|
258
|
+
export interface InboundAdmissionPolicy {
|
|
259
|
+
/** Verdict on an inbound encrypted connection from this peer. */
|
|
260
|
+
admitInbound(remotePeerId: string): Promise<InboundConnectionVerdict> | InboundConnectionVerdict;
|
|
261
|
+
/** Should this peer be granted a circuit-relay reservation slot? */
|
|
262
|
+
admitRelayReservation(remotePeerId: string): Promise<boolean> | boolean;
|
|
263
|
+
/**
|
|
264
|
+
* True while this node's control-database bring-up is in flight — the
|
|
265
|
+
* {@link createMembershipConnectionGater} quiet period (see the module doc).
|
|
266
|
+
* Peer-independent, unlike the two decisions above, and SYNCHRONOUS: it is
|
|
267
|
+
* read on the outbound-dial path, where an await would be a new stall.
|
|
268
|
+
*
|
|
269
|
+
* Optional — a policy that omits it is never quiet, which is the right default
|
|
270
|
+
* for every caller that is not a booting `CadreNode`.
|
|
271
|
+
*/
|
|
272
|
+
bringUpInFlight?(): boolean;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Bounded budget of concurrent relay reservations for peers the membership
|
|
277
|
+
* check could not place. `tryAdmit` is the whole surface: an already-admitted
|
|
278
|
+
* peer refreshes its entry (a reservation refresh never double-counts), a new
|
|
279
|
+
* peer takes a free slot or is refused. Entries expire after `ttlMs`, which
|
|
280
|
+
* `CadreNode` passes as the relay server's own resolved `reservationTtl`
|
|
281
|
+
* (`relay-server.ts`): the server holds an unrefreshed reservation exactly that
|
|
282
|
+
* long, and a live reserver re-requests (re-hitting the admission hook,
|
|
283
|
+
* refreshing its entry) well before expiry — so the live entry count tracks the
|
|
284
|
+
* server's own occupancy without this module reaching into the server's
|
|
285
|
+
* reservation store. Injectable `now` keeps expiry testable without fake
|
|
286
|
+
* timers. Authorized members and delegates are never run through this — the
|
|
287
|
+
* policy admits them before consulting the budget, and `release`s the slot such
|
|
288
|
+
* a peer took while it was still unplaceable, so the boot-ordering window a
|
|
289
|
+
* member passes through costs the budget nothing once its row lands.
|
|
290
|
+
*/
|
|
291
|
+
export class UnauthorizedReservationBudget {
|
|
292
|
+
private readonly admitted = new Map<string, number>();
|
|
293
|
+
|
|
294
|
+
constructor(
|
|
295
|
+
/** Most peers admitted at once — `network.unauthorizedRelayReservationCap`, resolved. */
|
|
296
|
+
readonly cap: number = MAX_UNAUTHORIZED_RELAY_RESERVATIONS,
|
|
297
|
+
private readonly ttlMs: number = PARTY_RELAY_RESERVATION_TTL_MS
|
|
298
|
+
) {}
|
|
299
|
+
|
|
300
|
+
/** Number of live (unexpired at last prune) entries — test/diagnostic surface. */
|
|
301
|
+
get size(): number {
|
|
302
|
+
return this.admitted.size;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Admit (or refresh) `remotePeerId` if a slot is free; false when the cap is spent. */
|
|
306
|
+
tryAdmit(remotePeerId: string, now: number = Date.now()): boolean {
|
|
307
|
+
this.prune(now);
|
|
308
|
+
if (!this.admitted.has(remotePeerId) && this.admitted.size >= this.cap) {
|
|
309
|
+
log('Unauthorized-reservation budget spent (%d/%d) — refusing %s', this.admitted.size, this.cap, remotePeerId);
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
this.admitted.set(remotePeerId, now + this.ttlMs);
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Give back the slot `remotePeerId` holds, if any — called when the peer has
|
|
318
|
+
* become admissible on its own merits (its membership row replicated, or a
|
|
319
|
+
* delegate grant landed), so the slot it took during the boot-ordering window
|
|
320
|
+
* does not stay spent for the remaining TTL on a peer that no longer needs it.
|
|
321
|
+
*/
|
|
322
|
+
release(remotePeerId: string): void {
|
|
323
|
+
this.admitted.delete(remotePeerId);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private prune(now: number): void {
|
|
327
|
+
for (const [peerId, expiresAt] of this.admitted) {
|
|
328
|
+
if (expiresAt <= now) {
|
|
329
|
+
this.admitted.delete(peerId);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Build the control node's connection gater: the caller-supplied gater (if any)
|
|
337
|
+
* with membership admission composed onto `denyInboundEncryptedConnection` —
|
|
338
|
+
* the earliest checkpoint where the remote's authenticated PeerId is known —
|
|
339
|
+
* and reservation admission composed onto `denyInboundRelayReservation` — the
|
|
340
|
+
* hook the circuit-relay server consults per RESERVE request (inert on a node
|
|
341
|
+
* whose relay server is off; libp2p never calls it there).
|
|
342
|
+
*
|
|
343
|
+
* `denyDialPeer` is composed too, but ONLY for the bring-up quiet period (see
|
|
344
|
+
* the module doc): outside that window outbound dials are never gated by
|
|
345
|
+
* membership — this node decides for itself who to talk to.
|
|
346
|
+
*
|
|
347
|
+
* Composition semantics: every hook of `base` is preserved as-is; on the three
|
|
348
|
+
* composed hooks a deny from EITHER the base gater or the admission policy
|
|
349
|
+
* denies. A policy error — or a decision slower than `decisionTimeoutMs` —
|
|
350
|
+
* takes the fail-open outcome (connection admitted / reservation admitted, see
|
|
351
|
+
* module doc); the base gater's verdict is still honored first.
|
|
352
|
+
*
|
|
353
|
+
* An `'admit-for-relay'` verdict admits the connection and arms a
|
|
354
|
+
* `reserveDeadlineMs` timer against it; the timer is disarmed when a
|
|
355
|
+
* reservation for that peer is ADMITTED at the reservation hook, and closes the
|
|
356
|
+
* underlying `MultiaddrConnection` when it fires first.
|
|
357
|
+
*
|
|
358
|
+
* NOTE: `base` is spread, so a gater passed as a CLASS INSTANCE would lose its
|
|
359
|
+
* prototype methods; every caller in this repo (and libp2p's own default)
|
|
360
|
+
* supplies a plain object. If a class-based gater ever shows up, delegate
|
|
361
|
+
* per-hook instead of spreading.
|
|
362
|
+
*
|
|
363
|
+
* Deny timing, as observed by the denied dialer: noise negotiates the muxer in
|
|
364
|
+
* the security handshake's early data, so the DIALER's upgrade may complete
|
|
365
|
+
* (its `dial()` resolves) before this receiver-side hook runs. The deny then
|
|
366
|
+
* aborts the receiver's upgrade — the receiver never registers the connection
|
|
367
|
+
* and never creates its muxer, so no protocol can ever be negotiated — and the
|
|
368
|
+
* dialer sees its "open" connection close moments later.
|
|
369
|
+
*
|
|
370
|
+
* Control node only: strand cohort nodes legitimately connect cross-party
|
|
371
|
+
* peers, so cadre membership never gates them. An OPEN strand's node receives
|
|
372
|
+
* the raw configured gater unchanged; a CLOSED strand's node composes its own
|
|
373
|
+
* revoked-peer denial onto it instead — a deny-list on positive revocation
|
|
374
|
+
* evidence, not this module's membership admission — see
|
|
375
|
+
* `strand-revocation-enforcer.ts`.
|
|
376
|
+
*/
|
|
377
|
+
export function createMembershipConnectionGater(
|
|
378
|
+
policy: InboundAdmissionPolicy,
|
|
379
|
+
base?: ConnectionGater,
|
|
380
|
+
decisionTimeoutMs: number = ADMISSION_DECISION_TIMEOUT_MS,
|
|
381
|
+
reserveDeadlineMs: number = RELAY_ADMISSION_RESERVE_DEADLINE_MS
|
|
382
|
+
): ConnectionGater {
|
|
383
|
+
const pendingReservations = new PendingReserveDeadlines(reserveDeadlineMs);
|
|
384
|
+
const quiet = (): boolean => policy.bringUpInFlight?.() ?? false;
|
|
385
|
+
return {
|
|
386
|
+
...base,
|
|
387
|
+
denyDialPeer: async (peerId: PeerId): Promise<boolean> => {
|
|
388
|
+
if (await base?.denyDialPeer?.(peerId)) {
|
|
389
|
+
return true;
|
|
390
|
+
}
|
|
391
|
+
if (quiet()) {
|
|
392
|
+
log('Control-database bring-up in flight — refusing the outbound dial to %s', peerId.toString());
|
|
393
|
+
return true;
|
|
394
|
+
}
|
|
395
|
+
return false;
|
|
396
|
+
},
|
|
397
|
+
denyInboundEncryptedConnection: async (peerId: PeerId, maConn: MultiaddrConnection): Promise<boolean> => {
|
|
398
|
+
if (await base?.denyInboundEncryptedConnection?.(peerId, maConn)) {
|
|
399
|
+
return true;
|
|
400
|
+
}
|
|
401
|
+
if (quiet()) {
|
|
402
|
+
log('Control-database bring-up in flight — refusing the inbound connection from %s', peerId.toString());
|
|
403
|
+
return true;
|
|
404
|
+
}
|
|
405
|
+
const remotePeerId = peerId.toString();
|
|
406
|
+
let verdict: InboundConnectionVerdict;
|
|
407
|
+
try {
|
|
408
|
+
verdict = await decideWithinDeadline(
|
|
409
|
+
() => policy.admitInbound(remotePeerId), 'admit', decisionTimeoutMs, `admitInbound(${remotePeerId})`
|
|
410
|
+
);
|
|
411
|
+
} catch (error) {
|
|
412
|
+
log('admitInbound threw for %s — admitting (fail-open; stream gates decide): %o', remotePeerId, error);
|
|
413
|
+
return false;
|
|
414
|
+
}
|
|
415
|
+
if (verdict === 'admit-for-relay') {
|
|
416
|
+
pendingReservations.arm(remotePeerId, maConn);
|
|
417
|
+
return false;
|
|
418
|
+
}
|
|
419
|
+
return verdict === 'deny';
|
|
420
|
+
},
|
|
421
|
+
denyInboundRelayReservation: async (peerId: PeerId): Promise<boolean> => {
|
|
422
|
+
if (await base?.denyInboundRelayReservation?.(peerId)) {
|
|
423
|
+
return true;
|
|
424
|
+
}
|
|
425
|
+
const remotePeerId = peerId.toString();
|
|
426
|
+
let admitted: boolean;
|
|
427
|
+
try {
|
|
428
|
+
admitted = await decideWithinDeadline(
|
|
429
|
+
() => policy.admitRelayReservation(remotePeerId), true, decisionTimeoutMs, `admitRelayReservation(${remotePeerId})`
|
|
430
|
+
);
|
|
431
|
+
} catch (error) {
|
|
432
|
+
log('admitRelayReservation threw for %s — admitting (fail-open): %o', remotePeerId, error);
|
|
433
|
+
admitted = true;
|
|
434
|
+
}
|
|
435
|
+
if (admitted) {
|
|
436
|
+
pendingReservations.disarm(remotePeerId);
|
|
437
|
+
}
|
|
438
|
+
return !admitted;
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Run one admission decision under `timeoutMs`, resolving to `fallback` (the
|
|
445
|
+
* fail-open outcome) if it has not settled in time — see
|
|
446
|
+
* {@link ADMISSION_DECISION_TIMEOUT_MS} for why an unbounded await is not safe
|
|
447
|
+
* on the connection hook. The timer is always cleared so a decided call never
|
|
448
|
+
* holds the event loop open. Exported for the strand revoked-peer gate
|
|
449
|
+
* (`strand-revocation-enforcer.ts`), which bounds its connection hooks with the
|
|
450
|
+
* same fail-open contract.
|
|
451
|
+
*/
|
|
452
|
+
export async function decideWithinDeadline<T>(
|
|
453
|
+
decide: () => Promise<T> | T,
|
|
454
|
+
fallback: T,
|
|
455
|
+
timeoutMs: number,
|
|
456
|
+
what: string
|
|
457
|
+
): Promise<T> {
|
|
458
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
459
|
+
try {
|
|
460
|
+
return await Promise.race([
|
|
461
|
+
Promise.resolve().then(decide),
|
|
462
|
+
new Promise<T>((resolve) => {
|
|
463
|
+
timer = setTimeout(() => {
|
|
464
|
+
log('%s exceeded %dms — taking the fail-open outcome (stream gates decide)', what, timeoutMs);
|
|
465
|
+
resolve(fallback);
|
|
466
|
+
}, timeoutMs);
|
|
467
|
+
})
|
|
468
|
+
]);
|
|
469
|
+
} finally {
|
|
470
|
+
clearTimeout(timer);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/** One armed not-reserving deadline: the connection it will close, and its timer. */
|
|
475
|
+
interface PendingReserveDeadline {
|
|
476
|
+
maConn: MultiaddrConnection;
|
|
477
|
+
timer: ReturnType<typeof setTimeout>;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* The not-reserving deadlines for `'admit-for-relay'` connections, keyed by
|
|
482
|
+
* remote peerId (a Set per peer — one peer can hold several in-flight
|
|
483
|
+
* connections). `arm` starts a timer that closes the connection; `disarm`
|
|
484
|
+
* (called when a reservation for that peer is admitted) cancels every pending
|
|
485
|
+
* timer for the peer. Timers are unref'd so an armed deadline never holds a
|
|
486
|
+
* process open, and a timer that fires against an already-closed connection is
|
|
487
|
+
* a no-op (`close()` returns at once on any status but `open`).
|
|
488
|
+
*
|
|
489
|
+
* Disarming is final for the connections it cancelled: a peer that reserved
|
|
490
|
+
* once and then lets its reservation lapse keeps a mute connection (the
|
|
491
|
+
* fail-closed stream gates refuse it every members-only protocol) until either
|
|
492
|
+
* side closes it. Bounded — only a peer the reservation policy already admitted
|
|
493
|
+
* can reach that state, so unplaceable peers are bounded by the budget cap.
|
|
494
|
+
*/
|
|
495
|
+
class PendingReserveDeadlines {
|
|
496
|
+
private readonly byPeer = new Map<string, Set<PendingReserveDeadline>>();
|
|
497
|
+
|
|
498
|
+
constructor(private readonly deadlineMs: number) {}
|
|
499
|
+
|
|
500
|
+
arm(remotePeerId: string, maConn: MultiaddrConnection): void {
|
|
501
|
+
const entry: PendingReserveDeadline = {
|
|
502
|
+
maConn,
|
|
503
|
+
timer: setTimeout(() => this.expire(remotePeerId, entry), this.deadlineMs)
|
|
504
|
+
};
|
|
505
|
+
(entry.timer as { unref?: () => void }).unref?.();
|
|
506
|
+
let entries = this.byPeer.get(remotePeerId);
|
|
507
|
+
if (!entries) {
|
|
508
|
+
entries = new Set();
|
|
509
|
+
this.byPeer.set(remotePeerId, entries);
|
|
510
|
+
}
|
|
511
|
+
entries.add(entry);
|
|
512
|
+
log('Admitted %s for relay only — dropping the connection unless a reservation is admitted within %dms', remotePeerId, this.deadlineMs);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
disarm(remotePeerId: string): void {
|
|
516
|
+
const entries = this.byPeer.get(remotePeerId);
|
|
517
|
+
if (!entries) {
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
this.byPeer.delete(remotePeerId);
|
|
521
|
+
for (const entry of entries) {
|
|
522
|
+
clearTimeout(entry.timer);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
private expire(remotePeerId: string, entry: PendingReserveDeadline): void {
|
|
527
|
+
const entries = this.byPeer.get(remotePeerId);
|
|
528
|
+
entries?.delete(entry);
|
|
529
|
+
if (entries?.size === 0) {
|
|
530
|
+
this.byPeer.delete(remotePeerId);
|
|
531
|
+
}
|
|
532
|
+
log('Relay-only admission expired for %s — no reservation admitted within %dms, closing the connection', remotePeerId, this.deadlineMs);
|
|
533
|
+
void this.drop(remotePeerId, entry.maConn);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* End an expired relay-only connection: a bounded graceful close, with
|
|
538
|
+
* `abort()` only as the escalation. The order cannot be reversed — `abort()`
|
|
539
|
+
* marks the connection `aborted`, and `close()` returns immediately on any
|
|
540
|
+
* status that is not `open`, so aborting first would make the close a silent
|
|
541
|
+
* no-op. Closing a connection the peer already dropped returns at once
|
|
542
|
+
* without throwing, so the fallback stays unreached in the ordinary case.
|
|
543
|
+
*/
|
|
544
|
+
private async drop(remotePeerId: string, maConn: MultiaddrConnection): Promise<void> {
|
|
545
|
+
try {
|
|
546
|
+
await withDeadline(
|
|
547
|
+
RELAY_ADMISSION_CLOSE_TIMEOUT_MS,
|
|
548
|
+
`relay-only close for ${remotePeerId}`,
|
|
549
|
+
(signal) => maConn.close({ signal })
|
|
550
|
+
);
|
|
551
|
+
return;
|
|
552
|
+
} catch (error) {
|
|
553
|
+
log('Closing the expired relay-only connection from %s failed — aborting: %o', remotePeerId, error);
|
|
554
|
+
}
|
|
555
|
+
// NOTE: this fallback cannot actually free a WebSocket. `@libp2p/websockets`'
|
|
556
|
+
// `sendReset()` calls `websocket.close(1006)`, and 1006 is a reserved code
|
|
557
|
+
// RFC 6455 forbids an endpoint from sending, so `ws` throws and
|
|
558
|
+
// `AbstractMessageStream.abort()` swallows it — the local end flips to
|
|
559
|
+
// `aborted` while the socket stays up. Measured against
|
|
560
|
+
// @libp2p/websockets@10.1.3 (see tickets/blocked/report-libp2p-websockets-abort-close-code.md).
|
|
561
|
+
// `MultiaddrConnection` exposes no way down to the raw socket, so this is as
|
|
562
|
+
// far as this layer can go. Revisit — drop the fallback, or go back to a
|
|
563
|
+
// plain `abort()` — once that transport sends a legal reset code.
|
|
564
|
+
try {
|
|
565
|
+
maConn.abort(new Error(`relay-only admission expired: no relay reservation admitted within ${this.deadlineMs}ms`));
|
|
566
|
+
} catch (error) {
|
|
567
|
+
log('Aborting the expired relay-only connection from %s threw: %o', remotePeerId, error);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|