@serfab/cadre-core 0.9.0 → 0.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/README.md +255 -253
- package/dist/bootstrap-peer-store-file.d.ts +13 -0
- package/dist/bootstrap-peer-store-file.js +42 -0
- package/dist/bootstrap-peer-store-file.js.map +1 -0
- package/dist/bootstrap-peer-store.d.ts +73 -0
- package/dist/bootstrap-peer-store.js +128 -0
- package/dist/bootstrap-peer-store.js.map +1 -0
- package/dist/cadre-node.d.ts +1121 -81
- package/dist/cadre-node.js +2276 -262
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-authorization.d.ts +93 -0
- package/dist/control-authorization.js +57 -0
- package/dist/control-authorization.js.map +1 -0
- package/dist/control-cohort.d.ts +26 -0
- package/dist/control-cohort.js +26 -0
- package/dist/control-cohort.js.map +1 -1
- package/dist/control-database.d.ts +959 -54
- package/dist/control-database.js +1421 -148
- package/dist/control-database.js.map +1 -1
- package/dist/control-formation-recorder.d.ts +90 -10
- package/dist/control-formation-recorder.js +130 -14
- package/dist/control-formation-recorder.js.map +1 -1
- package/dist/control-schema.d.ts +1 -1
- package/dist/control-schema.js +651 -72
- package/dist/control-schema.js.map +1 -1
- package/dist/control-stream.d.ts +38 -4
- package/dist/control-stream.js +70 -4
- package/dist/control-stream.js.map +1 -1
- package/dist/control-write-retry.d.ts +161 -0
- package/dist/control-write-retry.js +412 -0
- package/dist/control-write-retry.js.map +1 -0
- package/dist/delegate-admission.d.ts +134 -0
- package/dist/delegate-admission.js +244 -0
- package/dist/delegate-admission.js.map +1 -0
- package/dist/device-token.d.ts +11 -11
- package/dist/device-token.js +14 -13
- package/dist/device-token.js.map +1 -1
- package/dist/ed25519-key.d.ts +26 -0
- package/dist/ed25519-key.js +56 -1
- package/dist/ed25519-key.js.map +1 -1
- package/dist/file-durable-slot.d.ts +20 -0
- package/dist/file-durable-slot.js +53 -0
- package/dist/file-durable-slot.js.map +1 -0
- package/dist/formation-approval.d.ts +157 -0
- package/dist/formation-approval.js +375 -0
- package/dist/formation-approval.js.map +1 -0
- package/dist/fs-atomic.d.ts +38 -0
- package/dist/fs-atomic.js +97 -0
- package/dist/fs-atomic.js.map +1 -0
- package/dist/identity-key.d.ts +49 -0
- package/dist/identity-key.js +81 -0
- package/dist/identity-key.js.map +1 -0
- package/dist/index.d.ts +17 -7
- package/dist/index.js +40 -6
- package/dist/index.js.map +1 -1
- package/dist/key-store-file.d.ts +2 -24
- package/dist/key-store-file.js +10 -77
- package/dist/key-store-file.js.map +1 -1
- package/dist/membership-connection-gater.d.ts +146 -0
- package/dist/membership-connection-gater.js +180 -0
- package/dist/membership-connection-gater.js.map +1 -0
- package/dist/node-local-snapshot.d.ts +109 -0
- package/dist/node-local-snapshot.js +182 -0
- package/dist/node-local-snapshot.js.map +1 -0
- package/dist/peer-addr-book.d.ts +92 -0
- package/dist/peer-addr-book.js +220 -0
- package/dist/peer-addr-book.js.map +1 -0
- package/dist/peer-authorization.d.ts +135 -18
- package/dist/peer-authorization.js +152 -21
- package/dist/peer-authorization.js.map +1 -1
- package/dist/peer-record.d.ts +21 -8
- package/dist/peer-record.js +26 -10
- package/dist/peer-record.js.map +1 -1
- package/dist/relay-addrs.d.ts +45 -0
- package/dist/relay-addrs.js +103 -0
- package/dist/relay-addrs.js.map +1 -0
- package/dist/relay-reservation.d.ts +266 -0
- package/dist/relay-reservation.js +613 -0
- package/dist/relay-reservation.js.map +1 -0
- package/dist/seed-bootstrap.d.ts +228 -56
- package/dist/seed-bootstrap.js +340 -198
- package/dist/seed-bootstrap.js.map +1 -1
- package/dist/seed-trust-policy.d.ts +47 -21
- package/dist/seed-trust-policy.js +24 -15
- package/dist/seed-trust-policy.js.map +1 -1
- package/dist/strand-addr-protocol.d.ts +42 -13
- package/dist/strand-addr-protocol.js +58 -52
- package/dist/strand-addr-protocol.js.map +1 -1
- package/dist/strand-backfill.d.ts +109 -0
- package/dist/strand-backfill.js +279 -0
- package/dist/strand-backfill.js.map +1 -0
- package/dist/strand-cohort.d.ts +4 -37
- package/dist/strand-cohort.js +4 -15
- package/dist/strand-cohort.js.map +1 -1
- package/dist/strand-database.d.ts +11 -16
- package/dist/strand-database.js +19 -7
- package/dist/strand-database.js.map +1 -1
- package/dist/strand-formation-manager.d.ts +36 -7
- package/dist/strand-formation-manager.js +115 -15
- package/dist/strand-formation-manager.js.map +1 -1
- package/dist/strand-formation-protocol.d.ts +103 -4
- package/dist/strand-formation-protocol.js +232 -4
- package/dist/strand-formation-protocol.js.map +1 -1
- package/dist/strand-instance-manager.d.ts +88 -19
- package/dist/strand-instance-manager.js +178 -34
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-member-registry.d.ts +5 -5
- package/dist/strand-member-registry.js +18 -10
- package/dist/strand-member-registry.js.map +1 -1
- package/dist/strand-membership-writer.d.ts +484 -88
- package/dist/strand-membership-writer.js +751 -128
- package/dist/strand-membership-writer.js.map +1 -1
- package/dist/strand-solicitation.d.ts +99 -33
- package/dist/strand-solicitation.js +100 -52
- package/dist/strand-solicitation.js.map +1 -1
- package/dist/strand-transport-key.d.ts +40 -0
- package/dist/strand-transport-key.js +56 -0
- package/dist/strand-transport-key.js.map +1 -0
- package/dist/strand-wake-protocol.d.ts +9 -7
- package/dist/strand-wake-protocol.js +22 -46
- package/dist/strand-wake-protocol.js.map +1 -1
- package/dist/strand-watcher.d.ts +17 -2
- package/dist/strand-watcher.js +47 -3
- package/dist/strand-watcher.js.map +1 -1
- package/dist/trusted-owner-store-file.d.ts +12 -0
- package/dist/trusted-owner-store-file.js +39 -0
- package/dist/trusted-owner-store-file.js.map +1 -0
- package/dist/trusted-owner-store.d.ts +64 -0
- package/dist/trusted-owner-store.js +126 -0
- package/dist/trusted-owner-store.js.map +1 -0
- package/dist/types.d.ts +307 -44
- package/dist/types.js +13 -0
- package/dist/types.js.map +1 -1
- package/package.json +20 -8
- package/src/arachnode-stub.ts +159 -159
- package/src/bootstrap-peer-store-file.ts +44 -0
- package/src/bootstrap-peer-store.ts +172 -0
- package/src/cadre-node.ts +5158 -2981
- package/src/canonical-json.ts +30 -30
- package/src/control-authorization.ts +114 -0
- package/src/control-cohort.ts +130 -103
- package/src/control-database.ts +2406 -948
- package/src/control-formation-recorder.ts +305 -151
- package/src/control-schema.ts +651 -72
- package/src/control-stream.ts +205 -128
- package/src/control-write-retry.ts +483 -0
- package/src/delegate-admission.ts +302 -0
- package/src/device-token.ts +94 -89
- package/src/diagnostics/connection-path.ts +277 -277
- package/src/diagnostics/webrtc-turn-tracker.ts +222 -222
- package/src/ed25519-key.ts +66 -1
- package/src/enrollment.ts +199 -199
- package/src/file-durable-slot.ts +61 -0
- package/src/formation-approval.ts +604 -0
- package/src/fs-atomic.ts +107 -0
- package/src/hibernation-manager.ts +437 -437
- package/src/identity-key.ts +109 -0
- package/src/index.ts +137 -5
- package/src/key-store-file.ts +132 -203
- package/src/key-store.ts +99 -99
- package/src/membership-connection-gater.ts +204 -0
- package/src/node-local-snapshot.ts +260 -0
- package/src/peer-addr-book.ts +250 -0
- package/src/peer-authorization.ts +259 -81
- package/src/peer-record.ts +164 -146
- package/src/push-credentials.ts +84 -84
- package/src/push-node.ts +64 -64
- package/src/push-notifier-apns.ts +247 -247
- package/src/push-notifier-fcm.ts +220 -220
- package/src/push-notifier-shared.ts +28 -28
- package/src/push-notifier.ts +52 -52
- package/src/relay-addrs.ts +111 -0
- package/src/relay-reservation.ts +823 -0
- package/src/seed-bootstrap.ts +409 -209
- package/src/seed-trust-policy.ts +140 -107
- package/src/strand-addr-protocol.ts +441 -410
- package/src/strand-backfill.ts +366 -0
- package/src/strand-cohort.ts +38 -77
- package/src/strand-database.ts +202 -204
- package/src/strand-formation-manager.ts +491 -368
- package/src/strand-formation-protocol.ts +692 -391
- package/src/strand-instance-manager.ts +201 -46
- package/src/strand-member-key.ts +39 -39
- package/src/strand-member-registry.ts +182 -177
- package/src/strand-membership-writer.ts +938 -146
- package/src/strand-solicitation.ts +472 -407
- package/src/strand-transport-key.ts +58 -0
- package/src/strand-wake-payload.ts +32 -32
- package/src/strand-wake-protocol.ts +314 -331
- package/src/strand-watcher.ts +314 -251
- package/src/trusted-owner-store-file.ts +41 -0
- package/src/trusted-owner-store.ts +171 -0
- package/src/types.ts +1309 -1032
|
@@ -1,222 +1,222 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* webrtc-turn-tracker.ts — observe whether ICE selected a TURN relay candidate
|
|
3
|
-
* for each WebRTC session, so a TURN-relayed `/webrtc` connection can be
|
|
4
|
-
* reclassified `relayed`/`webrtc-turn` instead of silently counted `direct`.
|
|
5
|
-
*
|
|
6
|
-
* `@libp2p/webrtc` keeps the underlying `RTCPeerConnection` private and never
|
|
7
|
-
* surfaces it on the libp2p `Connection`, so the multiaddr classifier
|
|
8
|
-
* (`connection-path.ts`) cannot see the ICE candidate types. This tracker hooks
|
|
9
|
-
* `globalThis.RTCPeerConnection` at install time: every connection that reaches
|
|
10
|
-
* `connectionState === 'connected'` is inspected via `getStats()`, and the
|
|
11
|
-
* relay/not-relay verdict for the selected candidate pair is pushed onto a FIFO
|
|
12
|
-
* queue. {@link CadreNode} drains that queue on each `connection:open` to tag the
|
|
13
|
-
* matching peer (see {@link consume}).
|
|
14
|
-
*
|
|
15
|
-
* The correlation between a queued settlement and a `connection:open` is
|
|
16
|
-
* timing-based and best-effort — it degrades gracefully (unknown → treated as not
|
|
17
|
-
* relayed, a safe default that never produces a false `relayed`). It is inert on
|
|
18
|
-
* Node.js / any runtime with no `RTCPeerConnection`.
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
import debug from 'debug';
|
|
22
|
-
|
|
23
|
-
const log = debug('sereus:cadre:turn-tracker');
|
|
24
|
-
|
|
25
|
-
/** A single observed WebRTC settlement: when it connected and whether ICE relayed. */
|
|
26
|
-
interface SettlementEntry {
|
|
27
|
-
settledAtMs: number;
|
|
28
|
-
isRelay: boolean;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Hard cap on the FIFO queue. In steady state every settlement is consumed by a
|
|
33
|
-
* `connection:open`, so the queue stays tiny; the cap only bounds a pathological
|
|
34
|
-
* leak (settlements that never get a matching consume).
|
|
35
|
-
*/
|
|
36
|
-
const MAX_QUEUE = 64;
|
|
37
|
-
|
|
38
|
-
export class TurnRelayTracker {
|
|
39
|
-
private readonly queue: SettlementEntry[] = [];
|
|
40
|
-
private OriginalRTCPeerConnection: typeof RTCPeerConnection | undefined;
|
|
41
|
-
/** One settlement record per connection; guards against repeated `connected` events. */
|
|
42
|
-
private readonly observed = new WeakSet<RTCPeerConnection>();
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Wrap `globalThis.RTCPeerConnection` with a subclass that records each
|
|
46
|
-
* connection's TURN-relay verdict when it settles. Idempotent. A NOP when no
|
|
47
|
-
* `RTCPeerConnection` exists (Node.js / no WebRTC polyfill) — the tracker stays
|
|
48
|
-
* inert and {@link consume} always returns `null`.
|
|
49
|
-
*/
|
|
50
|
-
install(): void {
|
|
51
|
-
if (this.OriginalRTCPeerConnection) {
|
|
52
|
-
return; // already installed
|
|
53
|
-
}
|
|
54
|
-
const Original = globalThis.RTCPeerConnection;
|
|
55
|
-
if (typeof Original !== 'function') {
|
|
56
|
-
log('install: no globalThis.RTCPeerConnection — tracker inert (Node.js / no polyfill)');
|
|
57
|
-
return;
|
|
58
|
-
}
|
|
59
|
-
this.OriginalRTCPeerConnection = Original;
|
|
60
|
-
|
|
61
|
-
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
62
|
-
const tracker = this;
|
|
63
|
-
class TrackedRTCPeerConnection extends Original {
|
|
64
|
-
constructor(...args: ConstructorParameters<typeof RTCPeerConnection>) {
|
|
65
|
-
super(...args);
|
|
66
|
-
try {
|
|
67
|
-
this.addEventListener('connectionstatechange', () => {
|
|
68
|
-
try {
|
|
69
|
-
if (this.connectionState === 'connected' && !tracker.observed.has(this)) {
|
|
70
|
-
tracker.observed.add(this);
|
|
71
|
-
void tracker.recordSettlement(this);
|
|
72
|
-
}
|
|
73
|
-
} catch (error) {
|
|
74
|
-
log('connectionstatechange handler failed: %o', error);
|
|
75
|
-
}
|
|
76
|
-
});
|
|
77
|
-
} catch (error) {
|
|
78
|
-
log('install: failed to attach connectionstatechange listener: %o', error);
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
globalThis.RTCPeerConnection = TrackedRTCPeerConnection as typeof RTCPeerConnection;
|
|
84
|
-
log('install: wrapped globalThis.RTCPeerConnection');
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Pop the most recent settled entry within `windowMs` ms of now, returning its
|
|
89
|
-
* relay verdict (`true`/`false`); `null` when the queue has no matching entry.
|
|
90
|
-
* Also prunes entries older than the window (they can never match a later
|
|
91
|
-
* consume). Synchronous and safe to call from a `connection:open` handler.
|
|
92
|
-
*/
|
|
93
|
-
consume(windowMs: number): boolean | null {
|
|
94
|
-
const now = Date.now();
|
|
95
|
-
let result: boolean | null = null;
|
|
96
|
-
// Walk newest → oldest; the most recent in-window settlement is the best
|
|
97
|
-
// correlation for a just-opened connection.
|
|
98
|
-
for (let i = this.queue.length - 1; i >= 0; i--) {
|
|
99
|
-
if (now - this.queue[i]!.settledAtMs <= windowMs) {
|
|
100
|
-
result = this.queue[i]!.isRelay;
|
|
101
|
-
this.queue.splice(i, 1);
|
|
102
|
-
break;
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
this.pruneOlderThan(now - windowMs);
|
|
106
|
-
return result;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/** Restore the original `RTCPeerConnection` and clear the queue. Idempotent. */
|
|
110
|
-
dispose(): void {
|
|
111
|
-
if (this.OriginalRTCPeerConnection) {
|
|
112
|
-
globalThis.RTCPeerConnection = this.OriginalRTCPeerConnection;
|
|
113
|
-
this.OriginalRTCPeerConnection = undefined;
|
|
114
|
-
}
|
|
115
|
-
this.queue.length = 0;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/**
|
|
119
|
-
* Inspect a settled connection's stats and queue its TURN-relay verdict. Any
|
|
120
|
-
* failure (stats API absent, rejected promise, malformed report) is caught and
|
|
121
|
-
* recorded as `isRelay: false` — the safe default (unknown → not relayed) keeps
|
|
122
|
-
* the connection counted rather than dropping it.
|
|
123
|
-
*/
|
|
124
|
-
private async recordSettlement(pc: RTCPeerConnection): Promise<void> {
|
|
125
|
-
// Defaults to not-relayed (the safe "unknown" verdict); only the success path
|
|
126
|
-
// below overrides it, so a getStats failure leaves it false.
|
|
127
|
-
let isRelay = false;
|
|
128
|
-
try {
|
|
129
|
-
const report = await pc.getStats();
|
|
130
|
-
isRelay = selectedCandidatePairIsRelay(statsToMap(report));
|
|
131
|
-
} catch (error) {
|
|
132
|
-
log('recordSettlement: getStats failed, keeping not-relayed default: %o', error);
|
|
133
|
-
}
|
|
134
|
-
this.queue.push({ settledAtMs: Date.now(), isRelay });
|
|
135
|
-
if (this.queue.length > MAX_QUEUE) {
|
|
136
|
-
this.queue.shift();
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/** Drop queued entries settled before `cutoffMs` (the queue is in push order). */
|
|
141
|
-
private pruneOlderThan(cutoffMs: number): void {
|
|
142
|
-
while (this.queue.length > 0 && this.queue[0]!.settledAtMs < cutoffMs) {
|
|
143
|
-
this.queue.shift();
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/** Materialise an `RTCStatsReport` into a plain map keyed by stats id. */
|
|
149
|
-
function statsToMap(report: RTCStatsReport): Map<string, RTCStats> {
|
|
150
|
-
const map = new Map<string, RTCStats>();
|
|
151
|
-
report.forEach((value: RTCStats, key: string) => {
|
|
152
|
-
map.set(key, value);
|
|
153
|
-
});
|
|
154
|
-
return map;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* Whether the selected ICE candidate pair used a TURN relay candidate on either
|
|
159
|
-
* end. The pair is the transport's `selectedCandidatePairId` when present, else
|
|
160
|
-
* the nominated succeeded pair, else any succeeded pair.
|
|
161
|
-
*/
|
|
162
|
-
function selectedCandidatePairIsRelay(stats: Map<string, RTCStats>): boolean {
|
|
163
|
-
const pair = findSelectedPair(stats);
|
|
164
|
-
if (!pair) {
|
|
165
|
-
return false;
|
|
166
|
-
}
|
|
167
|
-
const local = pair.localCandidateId ? stats.get(pair.localCandidateId) : undefined;
|
|
168
|
-
const remote = pair.remoteCandidateId ? stats.get(pair.remoteCandidateId) : undefined;
|
|
169
|
-
return isRelayCandidate(local) || isRelayCandidate(remote);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
/**
|
|
173
|
-
* Minimal shape of a local/remote ICE candidate stats entry. Declared locally
|
|
174
|
-
* rather than relying on a DOM `RTCIceCandidateStats` lib type (absent in some
|
|
175
|
-
* TS lib versions); only `candidateType` is read.
|
|
176
|
-
*/
|
|
177
|
-
interface IceCandidateStats extends RTCStats {
|
|
178
|
-
candidateType?: string;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
function isRelayCandidate(stat: RTCStats | undefined): boolean {
|
|
182
|
-
return stat?.type === 'local-candidate' || stat?.type === 'remote-candidate'
|
|
183
|
-
? (stat as IceCandidateStats).candidateType === 'relay'
|
|
184
|
-
: false;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
function findSelectedPair(stats: Map<string, RTCStats>): RTCIceCandidatePairStats | undefined {
|
|
188
|
-
const selectedId = selectedPairIdFromTransport(stats);
|
|
189
|
-
if (selectedId) {
|
|
190
|
-
const stat = stats.get(selectedId);
|
|
191
|
-
if (stat?.type === 'candidate-pair') {
|
|
192
|
-
return stat as RTCIceCandidatePairStats;
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
let succeeded: RTCIceCandidatePairStats | undefined;
|
|
196
|
-
for (const stat of stats.values()) {
|
|
197
|
-
if (stat.type !== 'candidate-pair') {
|
|
198
|
-
continue;
|
|
199
|
-
}
|
|
200
|
-
const pair = stat as RTCIceCandidatePairStats;
|
|
201
|
-
if (pair.state !== 'succeeded') {
|
|
202
|
-
continue;
|
|
203
|
-
}
|
|
204
|
-
if (pair.nominated) {
|
|
205
|
-
return pair; // nominated succeeded pair wins outright
|
|
206
|
-
}
|
|
207
|
-
succeeded ??= pair;
|
|
208
|
-
}
|
|
209
|
-
return succeeded;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
function selectedPairIdFromTransport(stats: Map<string, RTCStats>): string | undefined {
|
|
213
|
-
for (const stat of stats.values()) {
|
|
214
|
-
if (stat.type === 'transport') {
|
|
215
|
-
const id = (stat as RTCTransportStats).selectedCandidatePairId;
|
|
216
|
-
if (typeof id === 'string') {
|
|
217
|
-
return id;
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
return undefined;
|
|
222
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* webrtc-turn-tracker.ts — observe whether ICE selected a TURN relay candidate
|
|
3
|
+
* for each WebRTC session, so a TURN-relayed `/webrtc` connection can be
|
|
4
|
+
* reclassified `relayed`/`webrtc-turn` instead of silently counted `direct`.
|
|
5
|
+
*
|
|
6
|
+
* `@libp2p/webrtc` keeps the underlying `RTCPeerConnection` private and never
|
|
7
|
+
* surfaces it on the libp2p `Connection`, so the multiaddr classifier
|
|
8
|
+
* (`connection-path.ts`) cannot see the ICE candidate types. This tracker hooks
|
|
9
|
+
* `globalThis.RTCPeerConnection` at install time: every connection that reaches
|
|
10
|
+
* `connectionState === 'connected'` is inspected via `getStats()`, and the
|
|
11
|
+
* relay/not-relay verdict for the selected candidate pair is pushed onto a FIFO
|
|
12
|
+
* queue. {@link CadreNode} drains that queue on each `connection:open` to tag the
|
|
13
|
+
* matching peer (see {@link consume}).
|
|
14
|
+
*
|
|
15
|
+
* The correlation between a queued settlement and a `connection:open` is
|
|
16
|
+
* timing-based and best-effort — it degrades gracefully (unknown → treated as not
|
|
17
|
+
* relayed, a safe default that never produces a false `relayed`). It is inert on
|
|
18
|
+
* Node.js / any runtime with no `RTCPeerConnection`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import debug from 'debug';
|
|
22
|
+
|
|
23
|
+
const log = debug('sereus:cadre:turn-tracker');
|
|
24
|
+
|
|
25
|
+
/** A single observed WebRTC settlement: when it connected and whether ICE relayed. */
|
|
26
|
+
interface SettlementEntry {
|
|
27
|
+
settledAtMs: number;
|
|
28
|
+
isRelay: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Hard cap on the FIFO queue. In steady state every settlement is consumed by a
|
|
33
|
+
* `connection:open`, so the queue stays tiny; the cap only bounds a pathological
|
|
34
|
+
* leak (settlements that never get a matching consume).
|
|
35
|
+
*/
|
|
36
|
+
const MAX_QUEUE = 64;
|
|
37
|
+
|
|
38
|
+
export class TurnRelayTracker {
|
|
39
|
+
private readonly queue: SettlementEntry[] = [];
|
|
40
|
+
private OriginalRTCPeerConnection: typeof RTCPeerConnection | undefined;
|
|
41
|
+
/** One settlement record per connection; guards against repeated `connected` events. */
|
|
42
|
+
private readonly observed = new WeakSet<RTCPeerConnection>();
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Wrap `globalThis.RTCPeerConnection` with a subclass that records each
|
|
46
|
+
* connection's TURN-relay verdict when it settles. Idempotent. A NOP when no
|
|
47
|
+
* `RTCPeerConnection` exists (Node.js / no WebRTC polyfill) — the tracker stays
|
|
48
|
+
* inert and {@link consume} always returns `null`.
|
|
49
|
+
*/
|
|
50
|
+
install(): void {
|
|
51
|
+
if (this.OriginalRTCPeerConnection) {
|
|
52
|
+
return; // already installed
|
|
53
|
+
}
|
|
54
|
+
const Original = globalThis.RTCPeerConnection;
|
|
55
|
+
if (typeof Original !== 'function') {
|
|
56
|
+
log('install: no globalThis.RTCPeerConnection — tracker inert (Node.js / no polyfill)');
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
this.OriginalRTCPeerConnection = Original;
|
|
60
|
+
|
|
61
|
+
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
62
|
+
const tracker = this;
|
|
63
|
+
class TrackedRTCPeerConnection extends Original {
|
|
64
|
+
constructor(...args: ConstructorParameters<typeof RTCPeerConnection>) {
|
|
65
|
+
super(...args);
|
|
66
|
+
try {
|
|
67
|
+
this.addEventListener('connectionstatechange', () => {
|
|
68
|
+
try {
|
|
69
|
+
if (this.connectionState === 'connected' && !tracker.observed.has(this)) {
|
|
70
|
+
tracker.observed.add(this);
|
|
71
|
+
void tracker.recordSettlement(this);
|
|
72
|
+
}
|
|
73
|
+
} catch (error) {
|
|
74
|
+
log('connectionstatechange handler failed: %o', error);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
} catch (error) {
|
|
78
|
+
log('install: failed to attach connectionstatechange listener: %o', error);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
globalThis.RTCPeerConnection = TrackedRTCPeerConnection as typeof RTCPeerConnection;
|
|
84
|
+
log('install: wrapped globalThis.RTCPeerConnection');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Pop the most recent settled entry within `windowMs` ms of now, returning its
|
|
89
|
+
* relay verdict (`true`/`false`); `null` when the queue has no matching entry.
|
|
90
|
+
* Also prunes entries older than the window (they can never match a later
|
|
91
|
+
* consume). Synchronous and safe to call from a `connection:open` handler.
|
|
92
|
+
*/
|
|
93
|
+
consume(windowMs: number): boolean | null {
|
|
94
|
+
const now = Date.now();
|
|
95
|
+
let result: boolean | null = null;
|
|
96
|
+
// Walk newest → oldest; the most recent in-window settlement is the best
|
|
97
|
+
// correlation for a just-opened connection.
|
|
98
|
+
for (let i = this.queue.length - 1; i >= 0; i--) {
|
|
99
|
+
if (now - this.queue[i]!.settledAtMs <= windowMs) {
|
|
100
|
+
result = this.queue[i]!.isRelay;
|
|
101
|
+
this.queue.splice(i, 1);
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
this.pruneOlderThan(now - windowMs);
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Restore the original `RTCPeerConnection` and clear the queue. Idempotent. */
|
|
110
|
+
dispose(): void {
|
|
111
|
+
if (this.OriginalRTCPeerConnection) {
|
|
112
|
+
globalThis.RTCPeerConnection = this.OriginalRTCPeerConnection;
|
|
113
|
+
this.OriginalRTCPeerConnection = undefined;
|
|
114
|
+
}
|
|
115
|
+
this.queue.length = 0;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Inspect a settled connection's stats and queue its TURN-relay verdict. Any
|
|
120
|
+
* failure (stats API absent, rejected promise, malformed report) is caught and
|
|
121
|
+
* recorded as `isRelay: false` — the safe default (unknown → not relayed) keeps
|
|
122
|
+
* the connection counted rather than dropping it.
|
|
123
|
+
*/
|
|
124
|
+
private async recordSettlement(pc: RTCPeerConnection): Promise<void> {
|
|
125
|
+
// Defaults to not-relayed (the safe "unknown" verdict); only the success path
|
|
126
|
+
// below overrides it, so a getStats failure leaves it false.
|
|
127
|
+
let isRelay = false;
|
|
128
|
+
try {
|
|
129
|
+
const report = await pc.getStats();
|
|
130
|
+
isRelay = selectedCandidatePairIsRelay(statsToMap(report));
|
|
131
|
+
} catch (error) {
|
|
132
|
+
log('recordSettlement: getStats failed, keeping not-relayed default: %o', error);
|
|
133
|
+
}
|
|
134
|
+
this.queue.push({ settledAtMs: Date.now(), isRelay });
|
|
135
|
+
if (this.queue.length > MAX_QUEUE) {
|
|
136
|
+
this.queue.shift();
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Drop queued entries settled before `cutoffMs` (the queue is in push order). */
|
|
141
|
+
private pruneOlderThan(cutoffMs: number): void {
|
|
142
|
+
while (this.queue.length > 0 && this.queue[0]!.settledAtMs < cutoffMs) {
|
|
143
|
+
this.queue.shift();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Materialise an `RTCStatsReport` into a plain map keyed by stats id. */
|
|
149
|
+
function statsToMap(report: RTCStatsReport): Map<string, RTCStats> {
|
|
150
|
+
const map = new Map<string, RTCStats>();
|
|
151
|
+
report.forEach((value: RTCStats, key: string) => {
|
|
152
|
+
map.set(key, value);
|
|
153
|
+
});
|
|
154
|
+
return map;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Whether the selected ICE candidate pair used a TURN relay candidate on either
|
|
159
|
+
* end. The pair is the transport's `selectedCandidatePairId` when present, else
|
|
160
|
+
* the nominated succeeded pair, else any succeeded pair.
|
|
161
|
+
*/
|
|
162
|
+
function selectedCandidatePairIsRelay(stats: Map<string, RTCStats>): boolean {
|
|
163
|
+
const pair = findSelectedPair(stats);
|
|
164
|
+
if (!pair) {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
const local = pair.localCandidateId ? stats.get(pair.localCandidateId) : undefined;
|
|
168
|
+
const remote = pair.remoteCandidateId ? stats.get(pair.remoteCandidateId) : undefined;
|
|
169
|
+
return isRelayCandidate(local) || isRelayCandidate(remote);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Minimal shape of a local/remote ICE candidate stats entry. Declared locally
|
|
174
|
+
* rather than relying on a DOM `RTCIceCandidateStats` lib type (absent in some
|
|
175
|
+
* TS lib versions); only `candidateType` is read.
|
|
176
|
+
*/
|
|
177
|
+
interface IceCandidateStats extends RTCStats {
|
|
178
|
+
candidateType?: string;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function isRelayCandidate(stat: RTCStats | undefined): boolean {
|
|
182
|
+
return stat?.type === 'local-candidate' || stat?.type === 'remote-candidate'
|
|
183
|
+
? (stat as IceCandidateStats).candidateType === 'relay'
|
|
184
|
+
: false;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function findSelectedPair(stats: Map<string, RTCStats>): RTCIceCandidatePairStats | undefined {
|
|
188
|
+
const selectedId = selectedPairIdFromTransport(stats);
|
|
189
|
+
if (selectedId) {
|
|
190
|
+
const stat = stats.get(selectedId);
|
|
191
|
+
if (stat?.type === 'candidate-pair') {
|
|
192
|
+
return stat as RTCIceCandidatePairStats;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
let succeeded: RTCIceCandidatePairStats | undefined;
|
|
196
|
+
for (const stat of stats.values()) {
|
|
197
|
+
if (stat.type !== 'candidate-pair') {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
const pair = stat as RTCIceCandidatePairStats;
|
|
201
|
+
if (pair.state !== 'succeeded') {
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (pair.nominated) {
|
|
205
|
+
return pair; // nominated succeeded pair wins outright
|
|
206
|
+
}
|
|
207
|
+
succeeded ??= pair;
|
|
208
|
+
}
|
|
209
|
+
return succeeded;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function selectedPairIdFromTransport(stats: Map<string, RTCStats>): string | undefined {
|
|
213
|
+
for (const stat of stats.values()) {
|
|
214
|
+
if (stat.type === 'transport') {
|
|
215
|
+
const id = (stat as RTCTransportStats).selectedCandidatePairId;
|
|
216
|
+
if (typeof id === 'string') {
|
|
217
|
+
return id;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return undefined;
|
|
222
|
+
}
|
package/src/ed25519-key.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { toString as uint8ArrayToString } from 'uint8arrays';
|
|
1
|
+
import { toString as uint8ArrayToString, fromString as uint8ArrayFromString } from 'uint8arrays';
|
|
2
2
|
import { getPublicKey } from '@optimystic/quereus-plugin-crypto';
|
|
3
3
|
import type { PrivateKey } from '@libp2p/interface';
|
|
4
4
|
|
|
@@ -67,3 +67,68 @@ export function ed25519KeyPairFromLibp2p(privateKey: PrivateKey): Ed25519KeyPair
|
|
|
67
67
|
export function ed25519PublicKeyFromPrivate(privateKeyB64: string): string {
|
|
68
68
|
return getPublicKey(privateKeyB64, 'ed25519', 'base64url', 'base64url') as string;
|
|
69
69
|
}
|
|
70
|
+
|
|
71
|
+
/** Longest rejected key value echoed back in an error — a real key is 43 base64url chars. */
|
|
72
|
+
const REJECTED_VALUE_ECHO_LIMIT = 64;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Render a rejected value for an error message, capped: owner keys now reach this check
|
|
76
|
+
* from remote-supplied fields (a `CadreInvite`'s `ownerKeys`, a donation request's
|
|
77
|
+
* `ownerKeys`), so an unbounded echo would let a peer turn one junk string into a
|
|
78
|
+
* megabyte of log line.
|
|
79
|
+
*/
|
|
80
|
+
function describeRejected(value: string): string {
|
|
81
|
+
return value.length <= REJECTED_VALUE_ECHO_LIMIT
|
|
82
|
+
? value
|
|
83
|
+
: `${value.slice(0, REJECTED_VALUE_ECHO_LIMIT)}… (${value.length} chars)`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Reject a value that isn't shaped like a base64url-encoded 32-byte Ed25519 public key
|
|
88
|
+
* before it reaches the control database, where a malformed key would otherwise sit
|
|
89
|
+
* indistinguishable from a real one until some later, unrelated signature check fails.
|
|
90
|
+
*
|
|
91
|
+
* Trims first, reusing the blank-value message for an empty result so that case keeps its
|
|
92
|
+
* existing wording instead of a confusing base64url error. Every rejection names the offending
|
|
93
|
+
* value (capped, see {@link describeRejected}) — with several keys in one batch, the message is
|
|
94
|
+
* the only thing that says WHICH one. Does not check the decoded bytes
|
|
95
|
+
* are a valid point on the Ed25519 curve — a well-formed-but-off-curve key still fails
|
|
96
|
+
* signature verification later exactly like any other wrong key.
|
|
97
|
+
*
|
|
98
|
+
* **This rule is restated once outside this package**, in `validateOwnerKey`
|
|
99
|
+
* (`packages/cadre-provider/src/server/owner-key-validation.ts`): cadre-provider declares no
|
|
100
|
+
* `workspace:` dependencies and validates `POST /containers` pins over `uint8arrays`
|
|
101
|
+
* directly. Change the rule here and change it there — the copies are kept in step by
|
|
102
|
+
* hand, since neither package can import the other's. Each side pins its own copy to the
|
|
103
|
+
* same accept/reject table (`test/ed25519-key.spec.ts` here,
|
|
104
|
+
* `create-container-owner-keys.test.ts` there), so a change fails the suite of the
|
|
105
|
+
* package it was made in and lands the editor on this pointer.
|
|
106
|
+
*
|
|
107
|
+
* @param value - Candidate base64url-encoded Ed25519 public key.
|
|
108
|
+
* @param label - Human-readable name of the field, used in error messages.
|
|
109
|
+
* @returns The trimmed value, so callers write the same bytes they validated.
|
|
110
|
+
*/
|
|
111
|
+
export function requireEd25519PublicKeyB64(value: string, label: string): string {
|
|
112
|
+
const trimmed = value.trim();
|
|
113
|
+
if (trimmed.length === 0) {
|
|
114
|
+
throw new Error(`A ${label} is required (received an empty or whitespace-only value)`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let decoded: Uint8Array;
|
|
118
|
+
try {
|
|
119
|
+
decoded = uint8ArrayFromString(trimmed, 'base64url');
|
|
120
|
+
} catch (error) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`A ${label} must be a base64url-encoded Ed25519 public key (could not decode "${describeRejected(trimmed)}" as base64url)`,
|
|
123
|
+
{ cause: error },
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (decoded.length !== 32) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`A ${label} must be a base64url-encoded 32-byte Ed25519 public key ("${describeRejected(trimmed)}" decoded to ${decoded.length} bytes)`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return trimmed;
|
|
134
|
+
}
|