@twinsearth/w2m-dsh-plugin 0.0.0-stage → 0.4.6

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.
Files changed (60) hide show
  1. package/CHANGELOG.md +1288 -0
  2. package/LICENSE +21 -0
  3. package/PROTOCOL-v0.1.2.md +165 -0
  4. package/PROTOCOL-v0.3.0.md +454 -0
  5. package/PROTOCOL-v0.3.9.md +207 -0
  6. package/PROTOCOL-v0.4.0.md +255 -0
  7. package/PROTOCOL.md +339 -0
  8. package/README.md +665 -2
  9. package/bin/w2m-localside.mjs +303 -0
  10. package/bin/w2m-rabbit.mjs +396 -0
  11. package/bin/w2m-stun.mjs +231 -0
  12. package/cordis.patch.yml +94 -0
  13. package/deploy/caddy/Caddyfile.example +118 -0
  14. package/deploy/docker/Dockerfile +144 -0
  15. package/deploy/docker/README.md +338 -0
  16. package/deploy/docker/docker-compose.yml +179 -0
  17. package/deploy/nginx/w2m.conf.example +196 -0
  18. package/deploy/shared-server/README.md +118 -0
  19. package/deploy/shared-server/install.sh +255 -0
  20. package/deploy/systemd/w2m-rabbit.service +150 -0
  21. package/deploy/systemd/w2m-stun.service +69 -0
  22. package/deploy/windows/README.md +115 -0
  23. package/deploy/windows/install-service.ps1 +195 -0
  24. package/deploy/windows/w2m-agent-supervisor.mjs +205 -0
  25. package/deploy/windows/winsw/w2m-localside.xml +50 -0
  26. package/docs/DEPLOY.md +902 -0
  27. package/docs/LINT-AUDIT.md +492 -0
  28. package/docs/PIPELINE-DESIGN.md +95 -0
  29. package/docs/TROUBLESHOOTING.md +560 -0
  30. package/docs/UI-CARDS.md +81 -0
  31. package/lib/index.js +13 -0
  32. package/lib/tools.js +11 -0
  33. package/package.json +133 -4
  34. package/src/agent/agent.mjs +3317 -0
  35. package/src/agent/allowed-commands.mjs +116 -0
  36. package/src/agent/caps.mjs +437 -0
  37. package/src/agent/exec.mjs +356 -0
  38. package/src/agent/git.mjs +616 -0
  39. package/src/agent/identity.mjs +253 -0
  40. package/src/agent/p2p-node.mjs +1243 -0
  41. package/src/agent/p2p.mjs +605 -0
  42. package/src/agent/resolve.mjs +148 -0
  43. package/src/agent/spool.mjs +252 -0
  44. package/src/agent/stun.mjs +397 -0
  45. package/src/agent/url.mjs +127 -0
  46. package/src/plugin/auto-update.mjs +574 -0
  47. package/src/plugin/schedule.mjs +452 -0
  48. package/src/plugin/shared-config.mjs +264 -0
  49. package/src/plugin/tools.mjs +3891 -0
  50. package/src/plugin/update-install.mjs +764 -0
  51. package/src/plugin/update-source.mjs +686 -0
  52. package/src/relay/metrics.mjs +284 -0
  53. package/src/relay/peers.mjs +180 -0
  54. package/src/relay/persistence.mjs +207 -0
  55. package/src/relay/report.mjs +591 -0
  56. package/src/relay/server.mjs +1187 -0
  57. package/src/relay/state.mjs +2004 -0
  58. package/src/relay/stun-server.mjs +733 -0
  59. package/src/signing.mjs +359 -0
  60. package/src/util/cli.mjs +70 -0
@@ -0,0 +1,1243 @@
1
+ /**
2
+ * P2PNode — one UDP socket, its rendezvous, and nothing else.
3
+ *
4
+ * v0.4.0 makes the direct path the default way two machines talk, with the relay kept
5
+ * as the fallback. `src/agent/p2p.mjs` supplies the primitives (punching, the reliable
6
+ * channel) and `src/agent/stun.mjs` supplies reflexive discovery. What was missing is
7
+ * the piece that *owns* them for a whole process lifetime: bind one socket, discover
8
+ * the address a peer can reach us on, publish that address through the relay, punch out
9
+ * on demand, and accept punches that arrive — with every failure named and none of them
10
+ * fatal, because the relay path is always still there.
11
+ *
12
+ * Mode semantics. These are the contract, not an implementation detail, so they are
13
+ * stated here and asserted in `test/p2p-node.test.mjs`:
14
+ *
15
+ * | mode | dispatcher (plugin) | executor (Localside) |
16
+ * | -------- | --------------------------------------------------------------------- | --------------------------------------------------------------------------- |
17
+ * | `auto` | punch and push the offer directly; the relay's offer stays a fallback | accepts an offer from either path; a duplicate `task_id`+`attempt` already seen is never executed twice |
18
+ * | `direct` | same as `auto`, but a failed punch is not hidden | refuses an offer that did not arrive over the direct path: `refused` / `P2P_UNAVAILABLE` |
19
+ * | `relay` | never dials, and no UDP socket is bound at all | identical to v0.3.9 |
20
+ *
21
+ * `relay` is a total absence of the direct path, not a degraded version of it: this
22
+ * class is the only thing in the agent that creates a UDP socket, so `start()` on a
23
+ * `relay` node returns before it touches `dgram` and nothing is bound. That is what
24
+ * makes the setting usable as a mechanical check rather than a promise
25
+ * (`test/outbound-only.test.mjs` leans on exactly this).
26
+ *
27
+ * What this node deliberately does **not** do:
28
+ *
29
+ * * **It does not parse payloads.** A channel carries opaque bytes; the W2M
30
+ * convention is that the bytes are the relay's own JSON frames (`task.offer`,
31
+ * `task.result`, `result.ack`), but the node has no opinion about them. The
32
+ * moment it understood a frame it would need to know about tasks, and the task
33
+ * layer is where transport and scheduling policy belong.
34
+ * * **It does not decide.** A failed punch is reported with its named code and the
35
+ * caller falls back to the relay — explicitly. There is no silent retry over the
36
+ * relay from inside `dial()`, because "which path won" is a fact the result
37
+ * envelope has to state (§6 of the v0.4.0 contract).
38
+ *
39
+ * Failures never throw out of `start()`, `announceNow()`, `dial()` or `close()`. The
40
+ * one exception is the constructor, which rejects a mode, a missing `machineId` or a
41
+ * bad tuning key with a `TypeError`: a node configured wrongly is a bug in the caller,
42
+ * and that is worth failing at the call site rather than 20 seconds later.
43
+ */
44
+
45
+ import { EventEmitter } from 'node:events';
46
+ import { networkInterfaces } from 'node:os';
47
+
48
+ import { accept, connect, deriveSession } from './p2p.mjs';
49
+ import {
50
+ bindUdpSocket as bindUdpSocketDefault,
51
+ discoverReflexive as discoverReflexiveDefault,
52
+ } from './stun.mjs';
53
+
54
+ /**
55
+ * The three modes, in one place, frozen. The relay validates `p2p.mode` against this
56
+ * same list (§5), and the mode is echoed verbatim into result envelopes, so the
57
+ * strings are wire values rather than local labels.
58
+ */
59
+ export const P2P_MODES = Object.freeze(['auto', 'direct', 'relay']);
60
+
61
+ /** The default is the direct path with the relay kept as the fallback. */
62
+ export const DEFAULT_P2P_MODE = 'auto';
63
+
64
+ /**
65
+ * Validate a mode. Loud and total: anything that is not exactly one of
66
+ * {@link P2P_MODES} is a configuration error with a named reason, never a silent
67
+ * fallback to the default.
68
+ *
69
+ * **Case-insensitive matching is deliberately not allowed.** `auto` and `AUTO` look
70
+ * like the same word, but this value is compared with `===` by the relay's request
71
+ * validator and is copied into the stored task aggregate and the report; accepting a
72
+ * variant here would put a value on the wire that the receiving end refuses, turning a
73
+ * typo into a mysterious `BAD_REQUEST` several layers away. The rejection names the
74
+ * canonical spelling instead.
75
+ *
76
+ * Whitespace is treated the same way and for the same reason — `' auto '` came from a
77
+ * config file or an environment variable, and the surrounding bytes are part of the
78
+ * value whether the caller meant them or not.
79
+ *
80
+ * @param {unknown} value
81
+ * @returns {{ok:true, mode:string}|{ok:false, reason:string}} `reason` always begins
82
+ * with a `P2P_MODE_*` code followed by `: ` and a message naming the offending value.
83
+ */
84
+ export function normalizeP2PMode(value) {
85
+ if (value === undefined || value === null) {
86
+ return { ok: false, reason: `P2P_MODE_MISSING: the mode is required (one of ${P2P_MODES.join(', ')})` };
87
+ }
88
+ if (typeof value !== 'string') {
89
+ return {
90
+ ok: false,
91
+ reason: `P2P_MODE_NOT_A_STRING: expected one of ${P2P_MODES.join(', ')}, got ${typeof value}`,
92
+ };
93
+ }
94
+ if (value === '') {
95
+ return {
96
+ ok: false,
97
+ reason: `P2P_MODE_EMPTY: the mode is an empty string (one of ${P2P_MODES.join(', ')})`,
98
+ };
99
+ }
100
+ if (P2P_MODES.includes(value)) return { ok: true, mode: value };
101
+
102
+ const trimmed = value.trim();
103
+ if (trimmed !== value && P2P_MODES.includes(trimmed)) {
104
+ return {
105
+ ok: false,
106
+ reason: `P2P_MODE_WHITESPACE: ${JSON.stringify(value)} has surrounding whitespace; write ${JSON.stringify(trimmed)}`,
107
+ };
108
+ }
109
+ if (P2P_MODES.includes(trimmed.toLowerCase())) {
110
+ return {
111
+ ok: false,
112
+ reason: `P2P_MODE_NOT_LOWERCASE: ${JSON.stringify(value)} is not lower-case; write ${JSON.stringify(trimmed.toLowerCase())}`,
113
+ };
114
+ }
115
+ return {
116
+ ok: false,
117
+ reason: `P2P_MODE_UNKNOWN: ${JSON.stringify(value)} is not one of ${P2P_MODES.join(', ')}`,
118
+ };
119
+ }
120
+
121
+ /**
122
+ * Validate a **fixed** local UDP port for the punch socket.
123
+ *
124
+ * The node binds an ephemeral port by default, and that is the right default for a machine that
125
+ * only ever *dials*: nothing has to reach it, so nothing has to be predictable. It is the wrong
126
+ * one for a machine that must **accept** a punch through a firewall, because the port changes on
127
+ * every restart and the `ufw allow <port>/udp` rule has to be re-pointed each time; until it is,
128
+ * every dispatch falls back to the relay. This helper is how a caller states a stable port, and
129
+ * both the CLI and the agent go through it so the two cannot disagree about what is legal.
130
+ *
131
+ * `null`/`undefined` means "an ephemeral port" and is the default. A numeric string is accepted
132
+ * (an environment variable arrives as one) but anything non-canonical -- `'0x10'`, `'1e3'`,
133
+ * `'abc'`, `''` -- is refused rather than coerced, because `Number()` would happily turn two of
134
+ * those into a port nobody wrote.
135
+ *
136
+ * `0` is refused on purpose even though {@link P2PNode}'s own `bindPort` accepts it as
137
+ * "ephemeral": an operator who *writes* `0` is asking for a fixed port and would silently get a
138
+ * random one, which is exactly the failure this setting exists to remove.
139
+ *
140
+ * @param {unknown} value
141
+ * @returns {{ok:true, port:number|null}|{ok:false, reason:string}} `reason` always begins with a
142
+ * `P2P_PORT_*` code followed by `: ` and a message naming the offending value.
143
+ */
144
+ export function normalizeP2PPort(value) {
145
+ if (value === undefined || value === null) return { ok: true, port: null };
146
+
147
+ let candidate = value;
148
+ if (typeof candidate === 'string') {
149
+ const text = candidate.trim();
150
+ if (text === '') {
151
+ return {
152
+ ok: false,
153
+ reason: 'P2P_PORT_EMPTY: the port is an empty string (omit the setting for an ephemeral port)',
154
+ };
155
+ }
156
+ if (!/^\d+$/.test(text)) {
157
+ return {
158
+ ok: false,
159
+ reason: `P2P_PORT_NOT_AN_INTEGER: ${JSON.stringify(candidate)} is not an integer in 1..65535 (omit the setting for an ephemeral port)`,
160
+ };
161
+ }
162
+ candidate = Number(text);
163
+ }
164
+
165
+ if (typeof candidate !== 'number' || !Number.isInteger(candidate)) {
166
+ return {
167
+ ok: false,
168
+ reason: `P2P_PORT_NOT_AN_INTEGER: ${String(value)} is not an integer in 1..65535 (omit the setting for an ephemeral port)`,
169
+ };
170
+ }
171
+ if (candidate === 0) {
172
+ return {
173
+ ok: false,
174
+ reason:
175
+ 'P2P_PORT_ZERO: 0 selects an ephemeral port, which changes on every restart and has to be re-allowed through the firewall; give a fixed port in 1..65535, or omit the setting',
176
+ };
177
+ }
178
+ if (candidate < 1 || candidate > 65535) {
179
+ return { ok: false, reason: `P2P_PORT_OUT_OF_RANGE: ${candidate} is not in 1..65535` };
180
+ }
181
+ return { ok: true, port: candidate };
182
+ }
183
+
184
+ /**
185
+ * The one server every W2M machine can rendezvous through.
186
+ *
187
+ * It hosts the relay, a STUN server and a reverse proxy on the same host, which is why
188
+ * its STUN entry leads {@link PUBLIC_STUN_SERVERS}: a machine that can reach the relay
189
+ * can reach this STUN server, so the reflexive address it reports is measured over the
190
+ * same path the punch will use. The public servers behind it are there for the day the
191
+ * shared host is unreachable.
192
+ */
193
+ export const SHARED_SERVER = Object.freeze({
194
+ host: '202.182.123.154',
195
+ rabbitUrl: 'http://202.182.123.154:8787',
196
+ stun: '202.182.123.154:3478',
197
+ });
198
+
199
+ /**
200
+ * Public fallbacks, used only after the shared server.
201
+ *
202
+ * Two different operators on purpose: NAT mapping can only be classified by comparing
203
+ * what *different* destinations see, and two hostnames that resolve to the same anycast
204
+ * address would defeat that comparison.
205
+ */
206
+ export const PUBLIC_STUN_SERVERS = Object.freeze([
207
+ 'stun.cloudflare.com:3478',
208
+ 'stun.l.google.com:19302',
209
+ 'stun.nextcloud.com:443',
210
+ ]);
211
+
212
+ /**
213
+ * Build a STUN server list: the shared server first, then the caller's own entries,
214
+ * then the public fallbacks, de-duplicated while keeping the first occurrence.
215
+ *
216
+ * The shared server is *prepended* rather than replacing the defaults because the
217
+ * ordering is the whole point: `discoverReflexive` queries sequentially and reports the
218
+ * **first** answer as the reflexive address, so the server that shares a path with the
219
+ * relay is asked before three public ones that may be on another continent.
220
+ *
221
+ * @param {string[]|string} [extra] Extra servers, or a comma-separated string (the
222
+ * shape `W2M_STUN_SERVERS` and `--stun-servers` arrive in).
223
+ * @returns {string[]}
224
+ */
225
+ export function stunServersWithShared(extra = []) {
226
+ const list = typeof extra === 'string' ? extra.split(',') : extra;
227
+ if (!Array.isArray(list)) {
228
+ throw new TypeError(
229
+ 'stunServersWithShared: extra must be an array of "host:port" strings or a comma-separated string',
230
+ );
231
+ }
232
+ const out = [];
233
+ for (const entry of [SHARED_SERVER.stun, ...list, ...PUBLIC_STUN_SERVERS]) {
234
+ if (entry === undefined || entry === null) continue;
235
+ const spec = String(entry).trim();
236
+ if (spec === '' || out.includes(spec)) continue;
237
+ out.push(spec);
238
+ }
239
+ return out;
240
+ }
241
+
242
+ /**
243
+ * Lifetimes and timeouts, in one frozen place so a caller can quote them and the
244
+ * agent, the plugin and the CLI cannot drift apart.
245
+ *
246
+ * `announceTtlMs`/`refreshMs` are deliberately three refreshes per TTL: two consecutive
247
+ * failures still leave a live announcement on the relay, which is what stops a blip in
248
+ * one HTTP request from making a machine unreachable to its peers.
249
+ */
250
+ export const P2P_DEFAULTS = Object.freeze({
251
+ announceTtlMs: 60_000,
252
+ refreshMs: 20_000,
253
+ punchTimeoutMs: 5_000,
254
+ acceptTimeoutMs: 5_000,
255
+ maxCandidates: 8,
256
+ });
257
+
258
+ /** Mapping behaviours `discoverReflexive` can report, plus `null` for "never measured". */
259
+ const MAPPINGS = Object.freeze(['none', 'endpoint-independent', 'endpoint-dependent', 'unknown']);
260
+
261
+ /** The relay route this node announces on. A path, not a URL: the caller owns the base. */
262
+ const ANNOUNCE_PATH = '/v1/peer/announce';
263
+
264
+ /** Tuning keys a caller may override, so a typo cannot silently do nothing. */
265
+ const TUNING_KEYS = Object.freeze(Object.keys(P2P_DEFAULTS));
266
+
267
+ /** @param {unknown} error @returns {string} */
268
+ function messageOf(error) {
269
+ if (error instanceof Error) return error.message;
270
+ return String(error);
271
+ }
272
+
273
+ /**
274
+ * Is `value` a dotted-quad IPv4 with no leading zeros?
275
+ *
276
+ * Duplicated from `src/relay/peers.mjs` on purpose: the agent must not import a relay
277
+ * module (the direct path exists to bypass the relay), and the rule is not a local
278
+ * preference — it is the relay's own admission rule for a candidate. Validating here
279
+ * turns "the relay answered 400 and refused the whole announcement" into a local,
280
+ * named decision, which is the difference between a diagnosable candidate list and a
281
+ * punch at an address that was never usable.
282
+ *
283
+ * @param {unknown} value
284
+ * @returns {boolean}
285
+ */
286
+ function isIpv4(value) {
287
+ if (typeof value !== 'string') return false;
288
+ const match = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(value);
289
+ if (!match) return false;
290
+ for (let i = 1; i <= 4; i += 1) {
291
+ const octet = Number(match[i]);
292
+ if (!Number.isInteger(octet) || octet < 0 || octet > 255) return false;
293
+ if (match[i].length > 1 && match[i].startsWith('0')) return false;
294
+ }
295
+ return true;
296
+ }
297
+
298
+ /**
299
+ * Is `address` an address only the local network can reach?
300
+ *
301
+ * RFC 1918 space plus link-local. Loopback and the wildcard are excluded: announcing
302
+ * `127.0.0.1` to a peer tells it to punch itself, and announcing `0.0.0.0` gives it no
303
+ * address at all — both would burn one of eight candidate slots on something that
304
+ * cannot work.
305
+ *
306
+ * @param {string} address
307
+ * @returns {boolean}
308
+ */
309
+ function isPrivateIpv4(address) {
310
+ if (!isIpv4(address)) return false;
311
+ const [a, b] = address.split('.').map(Number);
312
+ if (a === 10) return true;
313
+ if (a === 172 && b >= 16 && b <= 31) return true;
314
+ if (a === 192 && b === 168) return true;
315
+ return a === 169 && b === 254;
316
+ }
317
+
318
+ /** @param {unknown} value @returns {{address:string, port:number}|null} */
319
+ function usableCandidate(value) {
320
+ if (value === null || typeof value !== 'object') return null;
321
+ const address = value.address;
322
+ const port = value.port;
323
+ // The wildcard is refused even though it is syntactically valid: a peer cannot send
324
+ // to it, and the relay's validator would happily store it.
325
+ if (!isIpv4(address) || address === '0.0.0.0') return null;
326
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
327
+ return { address, port };
328
+ }
329
+
330
+ /**
331
+ * The private IPv4 addresses of this machine, in interface order.
332
+ *
333
+ * Needed because a socket bound to the wildcard reports `0.0.0.0` for its own address,
334
+ * which is not a destination. A punch that has to fall back to the local network needs
335
+ * the addresses the socket is actually reachable at, and those live on the interfaces.
336
+ *
337
+ * @returns {string[]}
338
+ */
339
+ function privateInterfaceAddresses() {
340
+ const out = [];
341
+ for (const entries of Object.values(networkInterfaces())) {
342
+ for (const entry of entries ?? []) {
343
+ // `family` is 'IPv4' on modern Node and 4 on ancient ones; both are cheap to accept.
344
+ const family = entry.family;
345
+ if (family !== 'IPv4' && family !== 4) continue;
346
+ if (entry.internal || !isPrivateIpv4(entry.address)) continue;
347
+ if (!out.includes(entry.address)) out.push(entry.address);
348
+ }
349
+ }
350
+ return out;
351
+ }
352
+
353
+ /**
354
+ * A node's own P2P lifecycle: one UDP socket, one announcement, one accept loop.
355
+ *
356
+ * Events:
357
+ *
358
+ * * `'announce'` `{candidates, mapping, reflexive}` — the relay accepted an
359
+ * announcement. Emitted for the attempt that produced it, so a caller can wait for
360
+ * it instead of polling `status`, and emitted for a *degraded* announcement too (no
361
+ * reflexive address, so `reflexive` is null): the LAN candidate did reach the relay,
362
+ * and `status.announce_ok` is where "complete" is answered.
363
+ * * `'message'` `{from, channel, peer, session, payload}` — a payload arrived on a
364
+ * live channel. `from` is the peer's machine id when this node dialled it, and
365
+ * `null` for a channel that arrived inbound: the HELLO frame carries a session, not
366
+ * an identity, and the node refuses to guess one from an address.
367
+ * * `'error'` (Error) — an error was recorded. **Only emitted when a listener is
368
+ * attached.** Node's default is that an unhandled `'error'` event throws, which
369
+ * would turn a routine relay outage into a crashed host; every error is in
370
+ * `status.last_error` regardless, so a listenerless node loses nothing but the
371
+ * timing.
372
+ *
373
+ * `close()` is idempotent and `start()` may be called again afterwards: a node that
374
+ * reconnects binds a fresh socket rather than keeping one bound to a port the OS may
375
+ * since have handed to someone else. Everything is a returned object — `start()`,
376
+ * `announceNow()`, `dial()` and `close()` never throw.
377
+ */
378
+ export class P2PNode extends EventEmitter {
379
+ /**
380
+ * @param {object} options
381
+ * @param {string} [options.mode] One of {@link P2P_MODES}; anything else throws.
382
+ * @param {string|null} [options.rabbitUrl] Base URL of the relay. `null` means no
383
+ * signalling at all, which is legal only in `relay` mode: an `auto`/`direct` node
384
+ * that cannot announce would be silently unreachable, so `start()` refuses it by
385
+ * name instead.
386
+ * @param {string} options.machineId This machine's id, from `device.json`.
387
+ * @param {(path:string, body:object)=>Promise<object>} [options.postJson] Injected
388
+ * transport (`{ok, status, json, error}`). Required by `auto`/`direct`.
389
+ * @param {(path:string)=>Promise<object>} [options.getJson] Same, for reads.
390
+ * @param {string[]|string} [options.stunServers] The complete server list, in query
391
+ * order; a comma-separated string is accepted. Not merged with the shared server —
392
+ * a caller who names servers means those servers.
393
+ * @param {Partial<typeof P2P_DEFAULTS>} [options.tuning] Overrides. Unknown keys
394
+ * throw rather than being ignored.
395
+ * @param {(line:string)=>void} [options.log] Informational lines (no `console`).
396
+ * @param {()=>number} [options.nowMs] Clock, so a caller can make time deterministic.
397
+ * @param {(options:object)=>Promise<object>} [options.discover] Reflexive discovery;
398
+ * defaults to `discoverReflexive` from `./stun.mjs`. Injectable because a test
399
+ * needs a deterministic answer and a *specific* failure (see the STUN-unreachable
400
+ * case) without a network.
401
+ * @param {(options?:object)=>Promise<{socket:object, local:{address:string,port:number}}>} [options.bindUdpSocket]
402
+ * Socket binder; defaults to `bindUdpSocket` from `./stun.mjs`. Injectable so a
403
+ * test can count binds — the assertion behind "`relay` binds no socket at all".
404
+ * It is called with `{address, port}` taken from `bindAddress` and `bindPort`
405
+ * below, so a caller can state the exact local endpoint.
406
+ * @param {string} [options.bindAddress] Local address the punch socket binds,
407
+ * default `'0.0.0.0'` (every interface). A machine that must accept a punch from a
408
+ * peer on one network can narrow this, and the wildcard default is what makes the
409
+ * socket reachable on whichever interface the punch arrives at.
410
+ * @param {number} [options.bindPort] Local UDP port the punch socket binds,
411
+ * `0` (default) meaning an ephemeral port. A **stable** port is what lets a
412
+ * firewall rule survive a restart: with an ephemeral port the operator has to
413
+ * re-allow the new port after every start, and until that is done every dispatch
414
+ * falls back to the relay. Must be an integer in `0..65535`; `0` is the only
415
+ * spelling that means "let the OS choose". Note that a port already in use makes
416
+ * `start()` return a `P2P_BIND_FAILED`, which is reported and never fatal.
417
+ */
418
+ constructor(options = {}) {
419
+ super();
420
+ const {
421
+ mode = DEFAULT_P2P_MODE,
422
+ rabbitUrl = null,
423
+ machineId,
424
+ postJson = null,
425
+ getJson = null,
426
+ stunServers = stunServersWithShared(),
427
+ tuning = {},
428
+ log = () => {},
429
+ nowMs = () => Date.now(),
430
+ discover = discoverReflexiveDefault,
431
+ bindUdpSocket = bindUdpSocketDefault,
432
+ bindAddress = '0.0.0.0',
433
+ bindPort = 0,
434
+ } = options;
435
+
436
+ const normalized = normalizeP2PMode(mode);
437
+ if (!normalized.ok) throw new TypeError(`P2PNode: ${normalized.reason}`);
438
+ this.mode = normalized.mode;
439
+
440
+ if (typeof machineId !== 'string' || machineId === '') {
441
+ throw new TypeError('P2PNode: machineId is required (the machine id from device.json)');
442
+ }
443
+ this.machineId = machineId;
444
+ this.rabbitUrl = rabbitUrl;
445
+
446
+ for (const [name, value] of [
447
+ ['postJson', postJson],
448
+ ['getJson', getJson],
449
+ ['log', log],
450
+ ['nowMs', nowMs],
451
+ ['discover', discover],
452
+ ['bindUdpSocket', bindUdpSocket],
453
+ ]) {
454
+ if (value !== null && typeof value !== 'function') {
455
+ throw new TypeError(`P2PNode: ${name} must be a function`);
456
+ }
457
+ }
458
+ this.postJson = postJson;
459
+ this.getJson = getJson;
460
+ this.log = log;
461
+ this.nowMs = nowMs;
462
+ this.discover = discover;
463
+ this.bindUdpSocket = bindUdpSocket;
464
+
465
+ // The local endpoint is validated here rather than left to `dgram`, for the same reason the
466
+ // mode is: a caller that wrote a nonsense address or a port outside the range has a bug at the
467
+ // call site, and `socket.bind()` would report it as an opaque `EINVAL` twenty seconds later.
468
+ if (typeof bindAddress !== 'string' || bindAddress.trim() === '') {
469
+ throw new TypeError('P2PNode: bindAddress must be a non-empty local address string');
470
+ }
471
+ if (!Number.isInteger(bindPort) || bindPort < 0 || bindPort > 65535) {
472
+ throw new TypeError(
473
+ `P2PNode: bindPort must be an integer in 0..65535 (0 means an ephemeral port), got ${JSON.stringify(bindPort)}`,
474
+ );
475
+ }
476
+ /** @type {string} Local address passed to the binder. */
477
+ this.bindAddress = bindAddress;
478
+ /** @type {number} Local port passed to the binder; 0 means an ephemeral one. */
479
+ this.bindPort = bindPort;
480
+
481
+ const unknownTuning = Object.keys(tuning).filter((key) => !TUNING_KEYS.includes(key));
482
+ if (unknownTuning.length > 0) {
483
+ throw new TypeError(
484
+ `P2PNode: unknown tuning key(s) ${unknownTuning.join(', ')}; known keys are ${TUNING_KEYS.join(', ')}`,
485
+ );
486
+ }
487
+ this.tuning = {};
488
+ for (const key of TUNING_KEYS) {
489
+ const value = tuning[key] ?? P2P_DEFAULTS[key];
490
+ if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
491
+ throw new TypeError(`P2PNode: tuning.${key} must be a positive number, got ${JSON.stringify(value)}`);
492
+ }
493
+ this.tuning[key] = value;
494
+ }
495
+
496
+ const servers = typeof stunServers === 'string' ? stunServers.split(',') : stunServers;
497
+ const serverList = Array.isArray(servers)
498
+ ? servers.map((spec) => String(spec).trim()).filter((spec) => spec !== '')
499
+ : [];
500
+ if (serverList.length === 0) {
501
+ throw new TypeError('P2PNode: stunServers must be a non-empty list of "host:port" strings');
502
+ }
503
+ this.stunServers = serverList;
504
+
505
+ /** @type {import('node:dgram').Socket|null} Bound in start(), closed in close(). */
506
+ this.socket = null;
507
+ /** @type {{address:string, port:number}|null} */
508
+ this.local = null;
509
+ /** @type {{address:string, port:number}|null} */
510
+ this.reflexive = null;
511
+ /** @type {string|null} */
512
+ this.mapping = null;
513
+ /** @type {Array<{address:string, port:number}>} What the relay currently holds for us. */
514
+ this.candidates = [];
515
+ /** @type {string|null} */
516
+ this.announcedAt = null;
517
+ this.announceOk = false;
518
+ this.announceFailures = 0;
519
+ /** @type {string|null} */
520
+ this.lastAnnounceError = null;
521
+ this.punchesOut = 0;
522
+ this.punchesIn = 0;
523
+ this.dialFailures = 0;
524
+ /** @type {string|null} */
525
+ this.lastError = null;
526
+
527
+ /** @type {Map<number, import('./p2p.mjs').P2PChannel>} Live channels, by session. */
528
+ this.channels = new Map();
529
+ /**
530
+ * Sessions with a live channel. Handed to `accept()` so a repeated HELLO for a
531
+ * session that is already open is re-acknowledged instead of being turned into a
532
+ * second channel for the same session — two channels on one session would each
533
+ * deliver every payload, and the peer would see every message twice.
534
+ */
535
+ this.claimed = new Set();
536
+
537
+ this.started = false;
538
+ /** @type {Promise<object>|null} In-flight start, so two callers cannot bind two sockets. */
539
+ this.starting = null;
540
+ /** @type {number} The generation `starting` belongs to; a stale one is not shared. */
541
+ this.startingGeneration = -1;
542
+ /** @type {object|null} In-flight announce, shared by concurrent callers. */
543
+ this.announcing = null;
544
+ /** @type {NodeJS.Timeout|null} */
545
+ this.refreshTimer = null;
546
+ /** @type {symbol|null} Identifies the current run; a stale loop/announce is ignored. */
547
+ this.runToken = null;
548
+ /** @type {Promise<void>|null} */
549
+ this.acceptLoop = null;
550
+ /**
551
+ * Bumped by every `close()`. A `start()` that was mid-bind when the node closed
552
+ * compares it afterwards, so a bind that lands late is closed instead of coming up
553
+ * behind the caller's back — a socket and a timer no one owns is exactly the leak
554
+ * `relay` mode exists to avoid.
555
+ */
556
+ this.generation = 0;
557
+ }
558
+
559
+ /** Is the direct path enabled at all? `relay` is the only mode where it is not. */
560
+ isEnabled() {
561
+ return this.mode !== 'relay';
562
+ }
563
+
564
+ /**
565
+ * The node's public state. A plain object, rebuilt on every read: no caller can hold a
566
+ * reference into the node's internals and mutate them.
567
+ *
568
+ * @returns {{mode:string, enabled:boolean, running:boolean, machine_id:string,
569
+ * rabbit_url:string|null, local:{address:string,port:number}|null,
570
+ * reflexive:{address:string,port:number}|null, mapping:string|null,
571
+ * candidates:Array<{address:string,port:number}>, announced_at:string|null,
572
+ * announce_ok:boolean, announce_failures:number, last_announce_error:string|null,
573
+ * punches_out:number, punches_in:number, dial_failures:number, last_error:string|null}}
574
+ */
575
+ get status() {
576
+ return {
577
+ mode: this.mode,
578
+ enabled: this.isEnabled(),
579
+ running: this.started,
580
+ machine_id: this.machineId,
581
+ rabbit_url: this.rabbitUrl,
582
+ local: this.local === null ? null : { ...this.local },
583
+ reflexive: this.reflexive === null ? null : { ...this.reflexive },
584
+ mapping: this.mapping,
585
+ candidates: this.candidates.map((candidate) => ({ ...candidate })),
586
+ announced_at: this.announcedAt,
587
+ announce_ok: this.announceOk,
588
+ announce_failures: this.announceFailures,
589
+ last_announce_error: this.lastAnnounceError,
590
+ punches_out: this.punchesOut,
591
+ punches_in: this.punchesIn,
592
+ dial_failures: this.dialFailures,
593
+ last_error: this.lastError,
594
+ };
595
+ }
596
+
597
+ /**
598
+ * Record an error: `status.last_error` always, the `'error'` event only if somebody
599
+ * is listening. See the class comment for why the emit is guarded.
600
+ *
601
+ * @param {string} message A named code and a reason.
602
+ */
603
+ reportError(message) {
604
+ this.lastError = message;
605
+ this.log(`p2p: ${message}`);
606
+ if (this.listenerCount('error') > 0) this.emit('error', new Error(message));
607
+ }
608
+
609
+ /**
610
+ * Bring the node up: bind the socket, start the accept loop, and start announcing.
611
+ *
612
+ * The first announcement is *kicked off*, not awaited. Awaiting it would make every
613
+ * caller pay for the STUN sweep — four servers, each with its own timeout when the
614
+ * network drops UDP — before the agent could connect its stream, and nothing about
615
+ * binding a socket depends on the answer. A caller that needs the announcement has
616
+ * two honest ways to wait for it: the `'announce'` event, or `status.announced_at`.
617
+ *
618
+ * @returns {Promise<{ok:boolean, enabled:boolean, error:string|null}>} `enabled` is
619
+ * false only for `relay`; a failure here is reported, never thrown.
620
+ */
621
+ async start() {
622
+ if (!this.isEnabled()) {
623
+ // No socket, no loop, no timer. Not "started with the direct path off" — off.
624
+ return { ok: true, enabled: false, error: null };
625
+ }
626
+ if (this.started) return { ok: true, enabled: true, error: null };
627
+ const generation = this.generation;
628
+ if (this.starting !== null && this.startingGeneration === generation) return this.starting;
629
+
630
+ const attempt = this.performStart();
631
+ const shared = attempt.finally(() => {
632
+ // Only the start that is still current clears the slot, or a `close()`/restart
633
+ // landing in between would cancel a newer start's sharing.
634
+ if (this.starting === shared) this.starting = null;
635
+ });
636
+ this.starting = shared;
637
+ this.startingGeneration = generation;
638
+ return shared;
639
+ }
640
+
641
+ /** @returns {Promise<{ok:boolean, enabled:boolean, error:string|null}>} */
642
+ async performStart() {
643
+ if (typeof this.rabbitUrl !== 'string' || this.rabbitUrl === '' || typeof this.postJson !== 'function') {
644
+ // Loud, and before binding anything: a socket that can never be announced is a
645
+ // half-functioning node, and half-functioning is the failure mode this codebase
646
+ // refuses everywhere else.
647
+ const missing = typeof this.postJson !== 'function' ? 'postJson' : 'rabbitUrl';
648
+ const error = `P2P_NO_SIGNALLING: ${missing} is missing, so mode "${this.mode}" could never announce its candidates`;
649
+ this.reportError(error);
650
+ return { ok: false, enabled: true, error };
651
+ }
652
+
653
+ const generation = this.generation;
654
+ let bound;
655
+ try {
656
+ // The documented endpoint: `{address: '0.0.0.0', port: 0}` unless the caller stated a port
657
+ // (and/or an address), which is what lets a firewall rule outlive a restart.
658
+ bound = await this.bindUdpSocket({ address: this.bindAddress, port: this.bindPort });
659
+ } catch (error) {
660
+ const reason = `P2P_BIND_FAILED: ${messageOf(error)}`;
661
+ this.reportError(reason);
662
+ return { ok: false, enabled: true, error: reason };
663
+ }
664
+
665
+ const socket = bound.socket;
666
+ const local = { address: bound.local.address, port: bound.local.port };
667
+ if (this.generation !== generation) {
668
+ // `close()` ran while the bind was in flight. The socket is real and unowned, so it
669
+ // is closed here rather than left holding a port behind a node the caller believes
670
+ // is stopped.
671
+ try {
672
+ socket.close();
673
+ } catch {
674
+ /* the bind failed after all */
675
+ }
676
+ const error = 'P2P_START_CANCELLED: the node was closed while its socket was being bound';
677
+ this.log(`p2p: ${error}`);
678
+ return { ok: false, enabled: true, error };
679
+ }
680
+ // A socket-level error (ICMP port unreachable after a punch at a dead port is the
681
+ // usual one on Windows) is delivered on the socket itself. Without a listener it
682
+ // would be an unhandled 'error' event, i.e. a thrown exception inside the runtime's
683
+ // own emit — so it is listened for, recorded, and otherwise ignored: a transient
684
+ // ICMP error does not make the socket unusable.
685
+ socket.on('error', (error) => {
686
+ this.reportError(`P2P_SOCKET_ERROR: ${messageOf(error)}`);
687
+ });
688
+
689
+ this.socket = socket;
690
+ this.local = local;
691
+ this.started = true;
692
+ this.runToken = Symbol('p2p-run');
693
+
694
+ this.refreshTimer = setInterval(() => {
695
+ // `announceNow()` is written not to reject; the catch is here so a future edit
696
+ // cannot turn a timer callback into an uncaught exception.
697
+ this.announceNow().catch((error) => this.reportError(`P2P_ANNOUNCE_THREW: ${messageOf(error)}`));
698
+ }, this.tuning.refreshMs);
699
+ // Never keep a process alive: an agent that has finished its work must be able to
700
+ // exit without waiting for the next refresh.
701
+ this.refreshTimer.unref?.();
702
+
703
+ this.acceptLoop = this.runAcceptLoop(this.runToken);
704
+ this.announceNow().catch((error) => this.reportError(`P2P_ANNOUNCE_THREW: ${messageOf(error)}`));
705
+
706
+ this.log(
707
+ `p2p: ${this.mode} node on ${local.address}:${local.port}, announcing through ${this.rabbitUrl}`,
708
+ );
709
+ return { ok: true, enabled: true, error: null };
710
+ }
711
+
712
+ /**
713
+ * Discover this machine's reachable addresses and publish them through the relay.
714
+ *
715
+ * STUN failing is **not** fatal. One unreachable STUN server must not stop a LAN
716
+ * punch: when there is no reflexive address the local candidate is announced alone,
717
+ * and the degradation is reported rather than hidden — `ok:false`, `announce_ok:false`
718
+ * and the STUN error in `error`/`status.last_announce_error`, while the announcement
719
+ * itself did reach the relay. Only a set with *no* usable address at all is refused,
720
+ * because the relay rejects an empty candidate list and a machine that announced
721
+ * nothing is worse than one that says it cannot.
722
+ *
723
+ * @returns {Promise<{ok:boolean, candidates:Array<{address:string,port:number}>,
724
+ * mapping:string|null, reflexive:{address:string,port:number}|null, error:string|null}>}
725
+ * `ok` means "this announcement is complete and live": the relay accepted it *and*
726
+ * a reflexive address was measured. It always equals `status.announce_ok`.
727
+ */
728
+ async announceNow() {
729
+ const socket = this.socket;
730
+ if (!this.isEnabled()) {
731
+ return {
732
+ ok: false,
733
+ candidates: [],
734
+ mapping: null,
735
+ reflexive: null,
736
+ error: 'P2P_DISABLED: mode "relay" never announces (the relay path is the only path)',
737
+ };
738
+ }
739
+ if (socket === null || !this.started) {
740
+ return {
741
+ ok: false,
742
+ candidates: [],
743
+ mapping: null,
744
+ reflexive: null,
745
+ error: 'P2P_NODE_NOT_RUNNING: call start() before announcing',
746
+ };
747
+ }
748
+ // Concurrent announces (a manual refresh landing on a timer tick) share one attempt
749
+ // rather than racing: two POSTs would double the relay's load and let the older
750
+ // answer overwrite the newer status.
751
+ if (this.announcing !== null) return this.announcing;
752
+ const run = this.runToken;
753
+ const attempt = this.performAnnounce(socket, run);
754
+ const shared = attempt.finally(() => {
755
+ // Only the attempt that is still current clears the slot: a `close()` (or a restart)
756
+ // may already have replaced it, and nulling the newer one would let a third caller
757
+ // start a duplicate request.
758
+ if (this.announcing === shared) this.announcing = null;
759
+ });
760
+ this.announcing = shared;
761
+ return shared;
762
+ }
763
+
764
+ /**
765
+ * @param {import('node:dgram').Socket} socket
766
+ * @param {symbol|null} run
767
+ * @returns {Promise<{ok:boolean, candidates:Array<{address:string,port:number}>,
768
+ * mapping:string|null, reflexive:{address:string,port:number}|null, error:string|null}>}
769
+ */
770
+ async performAnnounce(socket, run) {
771
+ let discovery = null;
772
+ try {
773
+ discovery = await this.discover({ socket, servers: [...this.stunServers] });
774
+ } catch (error) {
775
+ discovery = {
776
+ ok: false,
777
+ reflexive: null,
778
+ mapping: null,
779
+ error: `STUN_DISCOVERY_THREW: ${messageOf(error)}`,
780
+ };
781
+ }
782
+
783
+ const reflexive = usableCandidate(discovery?.reflexive);
784
+ const discoveryOk = discovery?.ok === true && reflexive !== null;
785
+ const discoveryError = discoveryOk
786
+ ? null
787
+ : typeof discovery?.error === 'string' && discovery.error !== ''
788
+ ? discovery.error
789
+ : 'STUN_NO_REFLEXIVE_ADDRESS: no discovery answer carried a usable address';
790
+ const mapping = MAPPINGS.includes(discovery?.mapping)
791
+ ? discovery.mapping
792
+ : discoveryOk
793
+ ? 'unknown'
794
+ : null;
795
+
796
+ const candidates = this.usableCandidates(reflexive);
797
+
798
+ if (candidates.length === 0) {
799
+ const error = `P2P_NO_CANDIDATES: nothing usable to announce (${discoveryError})`;
800
+ this.publishAnnouncement({
801
+ run,
802
+ socket,
803
+ candidates: [],
804
+ mapping,
805
+ reflexive,
806
+ ok: false,
807
+ error,
808
+ posted: false,
809
+ });
810
+ return { ok: false, candidates: [], mapping, reflexive, error };
811
+ }
812
+
813
+ const body = {
814
+ candidates: candidates.map((candidate) => ({ ...candidate })),
815
+ nat: { mapping },
816
+ ttl_ms: this.tuning.announceTtlMs,
817
+ };
818
+
819
+ let response = null;
820
+ let postError = null;
821
+ try {
822
+ response = await this.postJson(ANNOUNCE_PATH, body);
823
+ } catch (error) {
824
+ postError = `P2P_ANNOUNCE_FAILED: ${messageOf(error)}`;
825
+ }
826
+ const posted = response?.ok === true;
827
+ if (!posted && postError === null) postError = describeAnnounceFailure(response);
828
+
829
+ // A POST failure is the headline when there is one: the announcement is not live at
830
+ // all. Otherwise a discovery failure is what makes this announcement incomplete.
831
+ const error = !posted ? postError : discoveryOk ? null : discoveryError;
832
+ const ok = posted && discoveryOk;
833
+
834
+ this.publishAnnouncement({ run, socket, candidates, mapping, reflexive, ok, error, posted });
835
+ return {
836
+ ok,
837
+ candidates: candidates.map((candidate) => ({ ...candidate })),
838
+ mapping,
839
+ reflexive: reflexive === null ? null : { ...reflexive },
840
+ error,
841
+ };
842
+ }
843
+
844
+ /**
845
+ * Write one finished attempt into the node's state.
846
+ *
847
+ * Skipped when the run has already ended (a `close()` or a restart happened while the
848
+ * request was in flight): the caller still gets the result, but a closed node must not
849
+ * claim a live announcement or hold a candidate list whose NAT mapping is gone.
850
+ *
851
+ * @param {object} attempt
852
+ */
853
+ publishAnnouncement(attempt) {
854
+ // The attempt's outcome is decided, so the shared slot is released *before* anything is
855
+ // published: a caller woken by the `'announce'` event must start a fresh attempt rather
856
+ // than join a finished one. (The promise-level guard in `announceNow()` only stops a
857
+ // stale attempt from clearing a newer one; this is what makes "the event fired" mean
858
+ // "the attempt is over".)
859
+ this.announcing = null;
860
+ if (this.socket !== attempt.socket || this.runToken !== attempt.run) return;
861
+
862
+ this.reflexive = attempt.reflexive;
863
+ this.mapping = attempt.mapping;
864
+ if (attempt.posted) {
865
+ // What the relay holds for us, not what we measured: a failed POST leaves the
866
+ // previous announcement live until its TTL expires.
867
+ this.candidates = attempt.candidates;
868
+ this.announcedAt = new Date(this.nowMs()).toISOString();
869
+ // Emitted whenever the relay accepted the announcement — a degraded one (no
870
+ // reflexive address, `reflexive: null`) included, because the LAN candidate is
871
+ // live and a caller waiting for "we are announced" is right to proceed.
872
+ this.emit('announce', {
873
+ candidates: attempt.candidates.map((candidate) => ({ ...candidate })),
874
+ mapping: attempt.mapping,
875
+ reflexive: attempt.reflexive === null ? null : { ...attempt.reflexive },
876
+ });
877
+ }
878
+ this.announceOk = attempt.ok;
879
+ if (attempt.ok) {
880
+ this.lastAnnounceError = null;
881
+ return;
882
+ }
883
+ this.announceFailures += 1;
884
+ this.lastAnnounceError = attempt.error;
885
+ if (attempt.posted) {
886
+ // Degraded, not broken: the LAN candidate is live, the reflexive address is not.
887
+ // Recorded in `status`, deliberately not emitted as an `'error'`: the node is
888
+ // working, and only the caller knows whether a missing public address matters.
889
+ this.lastError = attempt.error;
890
+ this.log(`p2p: announced without a reflexive address (${attempt.error})`);
891
+ return;
892
+ }
893
+ this.reportError(attempt.error);
894
+ }
895
+
896
+ /**
897
+ * The addresses to announce: the reflexive one first, then the local network ones,
898
+ * de-duplicated and capped.
899
+ *
900
+ * @param {{address:string, port:number}|null} reflexive
901
+ * @returns {Array<{address:string, port:number}>}
902
+ */
903
+ usableCandidates(reflexive) {
904
+ const out = [];
905
+ const seen = new Set();
906
+ const push = (candidate) => {
907
+ if (candidate === null || out.length >= this.tuning.maxCandidates) return;
908
+ const key = `${candidate.address}:${candidate.port}`;
909
+ if (seen.has(key)) return;
910
+ seen.add(key);
911
+ out.push(candidate);
912
+ };
913
+ push(reflexive);
914
+ for (const candidate of this.localCandidates()) push(candidate);
915
+ return out;
916
+ }
917
+
918
+ /**
919
+ * The socket's own address when it is a private IPv4, or — for the usual wildcard
920
+ * bind, whose reported address is `0.0.0.0` and therefore not a destination — this
921
+ * machine's private interface addresses, all on the socket's port.
922
+ *
923
+ * @returns {Array<{address:string, port:number}>}
924
+ */
925
+ localCandidates() {
926
+ const local = this.local;
927
+ if (local === null || !Number.isInteger(local.port) || local.port <= 0) return [];
928
+ const addresses = [];
929
+ if (isPrivateIpv4(local.address)) addresses.push(local.address);
930
+ else if (local.address === '0.0.0.0') addresses.push(...privateInterfaceAddresses());
931
+ return addresses.map((address) => ({ address, port: local.port }));
932
+ }
933
+
934
+ /**
935
+ * Punch to a peer and return a channel on the opened path.
936
+ *
937
+ * Every failure is a returned `{ok:false, error}` with a named code; nothing throws,
938
+ * including for a machine that has never announced (404 → `P2P_NO_CANDIDATES`) or one
939
+ * that has announced an address nothing answers at (`P2P_PUNCH_TIMEOUT`).
940
+ *
941
+ * @param {string} machineId The peer's machine id.
942
+ * @param {object} [options]
943
+ * @param {number} [options.session] Agreed session id. Derived from the two machine
944
+ * ids when omitted — the responder adopts whatever the HELLO carries, so only the
945
+ * initiator has to pick one, and a caller with a task id should pass one derived
946
+ * from it so two concurrent tasks do not share a session.
947
+ * @param {number} [options.timeoutMs] Punch deadline; default `tuning.punchTimeoutMs`.
948
+ * @param {number} [options.acceptTimeoutMs] Accepted for interface symmetry with
949
+ * {@link accept} and unused here: the initiator waits for the HELLO_ACK, which is
950
+ * the punch itself, and never waits to be accepted.
951
+ * @returns {Promise<{ok:boolean, channel:import('./p2p.mjs').P2PChannel|null,
952
+ * peer:{address:string,port:number}|null, session:number, error:string|null,
953
+ * attempts:number, rttMs:number|null, peersAnnounced:number}>} `peersAnnounced` is
954
+ * how many candidate addresses the relay held for that machine.
955
+ */
956
+ async dial(machineId, options = {}) {
957
+ const empty = {
958
+ ok: false,
959
+ channel: null,
960
+ peer: null,
961
+ session: 0,
962
+ error: null,
963
+ attempts: 0,
964
+ rttMs: null,
965
+ peersAnnounced: 0,
966
+ };
967
+
968
+ if (!this.isEnabled()) {
969
+ return { ...empty, error: 'P2P_DISABLED: mode "relay" never dials (the relay path is the only path)' };
970
+ }
971
+ if (this.socket === null || !this.started) {
972
+ return { ...empty, error: 'P2P_NODE_NOT_RUNNING: call start() before dial()' };
973
+ }
974
+ if (typeof machineId !== 'string' || machineId === '') {
975
+ return { ...empty, error: "P2P_BAD_MACHINE_ID: dial() needs the peer's machine id" };
976
+ }
977
+ if (typeof this.getJson !== 'function') {
978
+ return { ...empty, error: "P2P_NO_GET_JSON: dial() needs getJson to read the peer's candidates" };
979
+ }
980
+
981
+ let session = options.session;
982
+ if (session === undefined) {
983
+ // Sorted so the value would still be identical if the other end ever derived it
984
+ // too; only this end needs to, because the responder adopts the HELLO's id.
985
+ session = deriveSession('p2p-dial', ...[this.machineId, machineId].sort());
986
+ } else if (!Number.isInteger(session) || session < 1 || session > 0xffffffff) {
987
+ return {
988
+ ...empty,
989
+ error: `P2P_BAD_SESSION: ${JSON.stringify(options.session)} is not an integer in 1..4294967295`,
990
+ };
991
+ }
992
+
993
+ const socket = this.socket;
994
+ const fail = (error, extra = {}) => {
995
+ this.dialFailures += 1;
996
+ this.reportError(error);
997
+ return { ...empty, session, error, ...extra };
998
+ };
999
+
1000
+ let lookup = null;
1001
+ try {
1002
+ lookup = await this.getJson(`/v1/peer/${encodeURIComponent(machineId)}`);
1003
+ } catch (error) {
1004
+ return fail(`P2P_LOOKUP_FAILED: ${messageOf(error)}`);
1005
+ }
1006
+ if (this.socket !== socket || !this.started) {
1007
+ return { ...empty, session, error: 'P2P_NODE_NOT_RUNNING: the node closed while dialling' };
1008
+ }
1009
+ if (lookup?.ok !== true) {
1010
+ if (lookup?.status === 404) {
1011
+ // A 404 is the relay saying "this machine has not announced" — an ordinary state
1012
+ // (the peer may be relay-only, or between refreshes), and it has its own code.
1013
+ return fail(`P2P_NO_CANDIDATES: the relay holds no live announcement for ${machineId}`);
1014
+ }
1015
+ return fail(`P2P_LOOKUP_FAILED: ${describeLookupFailure(lookup)}`);
1016
+ }
1017
+
1018
+ const announced = Array.isArray(lookup.json?.peer?.candidates) ? lookup.json.peer.candidates : [];
1019
+ const remote = [];
1020
+ for (const candidate of announced) {
1021
+ const usable = usableCandidate(candidate);
1022
+ if (usable === null) continue;
1023
+ if (remote.some((entry) => entry.address === usable.address && entry.port === usable.port)) continue;
1024
+ remote.push(usable);
1025
+ if (remote.length >= this.tuning.maxCandidates) break;
1026
+ }
1027
+ if (remote.length === 0) {
1028
+ return fail(`P2P_NO_CANDIDATES: ${machineId} announced no usable address`);
1029
+ }
1030
+
1031
+ let outcome = null;
1032
+ try {
1033
+ outcome = await connect({
1034
+ socket,
1035
+ remote,
1036
+ session,
1037
+ timeoutMs: options.timeoutMs ?? this.tuning.punchTimeoutMs,
1038
+ });
1039
+ } catch (error) {
1040
+ // `punch()` sends synchronously, and `dgram` throws from `send()` on a socket that
1041
+ // was closed underneath it — a close() racing a dial, in practice. dial() promises
1042
+ // never to throw, so that one is caught here and named like every other failure.
1043
+ return fail(`P2P_PUNCH_FAILED: ${messageOf(error)}`, {
1044
+ attempts: 0,
1045
+ rttMs: null,
1046
+ peersAnnounced: announced.length,
1047
+ });
1048
+ }
1049
+ if (!outcome.ok) {
1050
+ return fail(outcome.error ?? 'P2P_PUNCH_FAILED', {
1051
+ attempts: outcome.punch?.attempts ?? 0,
1052
+ rttMs: outcome.punch?.rttMs ?? null,
1053
+ peersAnnounced: announced.length,
1054
+ });
1055
+ }
1056
+
1057
+ this.punchesOut += 1;
1058
+ this.adoptChannel(outcome.channel, { from: machineId, session: outcome.channel.session });
1059
+ return {
1060
+ ok: true,
1061
+ channel: outcome.channel,
1062
+ peer: { ...outcome.channel.peer },
1063
+ session: outcome.channel.session,
1064
+ error: null,
1065
+ attempts: outcome.punch.attempts,
1066
+ rttMs: outcome.punch.rttMs,
1067
+ peersAnnounced: announced.length,
1068
+ };
1069
+ }
1070
+
1071
+ /**
1072
+ * Wire one channel into the node: deliver its payloads as `'message'` events, claim
1073
+ * its session, and drop the claim when it closes.
1074
+ *
1075
+ * The claim is released on close on purpose. `claimed` means "this session has a live
1076
+ * channel"; a broken path has to be re-punchable with the same session, and holding
1077
+ * the claim after the channel is gone would silently refuse the peer's retry.
1078
+ *
1079
+ * @param {import('./p2p.mjs').P2PChannel} channel
1080
+ * @param {{from:string|null, session:number}} details
1081
+ */
1082
+ adoptChannel(channel, details) {
1083
+ this.channels.set(details.session, channel);
1084
+ this.claimed.add(details.session);
1085
+ channel.on('message', (payload) => {
1086
+ this.emit('message', {
1087
+ from: details.from,
1088
+ channel,
1089
+ peer: { ...channel.peer },
1090
+ session: details.session,
1091
+ payload,
1092
+ });
1093
+ });
1094
+ channel.on('close', () => {
1095
+ if (this.channels.get(details.session) === channel) this.channels.delete(details.session);
1096
+ this.claimed.delete(details.session);
1097
+ });
1098
+ // P2PChannel documents an `'error'` event. Listening is not optional bookkeeping:
1099
+ // an unhandled `'error'` emit would throw, and the whole point of this node is that
1100
+ // a transport problem is a recorded fact rather than a crashing process.
1101
+ channel.on('error', (error) => {
1102
+ this.reportError(`P2P_CHANNEL_ERROR: ${messageOf(error)}`);
1103
+ });
1104
+ }
1105
+
1106
+ /**
1107
+ * Wait for inbound HELLOs, forever, one `accept()` at a time.
1108
+ *
1109
+ * `accept()` resolves on a HELLO or on its own timeout; a timeout is the ordinary way
1110
+ * this loop spends its life. The loop ends when the node is closed (the token stops
1111
+ * matching) or restarted (the socket is closed under it, which ends the wait
1112
+ * immediately instead of leaving a timer behind).
1113
+ *
1114
+ * @param {symbol} token
1115
+ * @returns {Promise<void>}
1116
+ */
1117
+ async runAcceptLoop(token) {
1118
+ while (this.started && this.runToken === token && this.socket !== null) {
1119
+ let result = null;
1120
+ try {
1121
+ result = await accept({
1122
+ socket: this.socket,
1123
+ timeoutMs: this.tuning.acceptTimeoutMs,
1124
+ claimed: this.claimed,
1125
+ });
1126
+ } catch (error) {
1127
+ // `accept()` throws only for a socket it was not given, which the loop condition
1128
+ // already rules out. Caught anyway: a bug here would otherwise become an
1129
+ // unhandled rejection from an endless loop, which is the worst of both worlds.
1130
+ this.reportError(`P2P_ACCEPT_THREW: ${messageOf(error)}`);
1131
+ return;
1132
+ }
1133
+ if (!result.ok) {
1134
+ if (result.error?.startsWith('P2P_SOCKET_CLOSED')) return;
1135
+ continue;
1136
+ }
1137
+ if (!this.started || this.runToken !== token) {
1138
+ // The node was closed while the HELLO was in flight. The channel is real but it
1139
+ // has no owner, so it is closed rather than leaked.
1140
+ result.channel.close('node-closed');
1141
+ return;
1142
+ }
1143
+ this.punchesIn += 1;
1144
+ // `from` is null: a HELLO carries a session, not a machine id, and inventing an
1145
+ // identity from a source address would be exactly the kind of unverified claim
1146
+ // this transport refuses elsewhere. The caller correlates by session if it needs to.
1147
+ this.adoptChannel(result.channel, { from: null, session: result.session });
1148
+ }
1149
+ }
1150
+
1151
+ /**
1152
+ * Stop the node. Idempotent, and `start()` may be called again afterwards.
1153
+ *
1154
+ * Restartability is a choice, not an accident: the alternative (a permanent tombstone)
1155
+ * would force a caller that wants to reconnect to build a second node and re-wire
1156
+ * every listener, and would leave `start()` failing for a reason the caller cannot
1157
+ * act on. A restarted node binds a *new* socket — on a new ephemeral port, unless `bindPort`
1158
+ * pinned one — and the old mapping is gone with the old socket, which is why the announcement is
1159
+ * reset too rather than left to look live.
1160
+ *
1161
+ * @returns {Promise<void>}
1162
+ */
1163
+ async close() {
1164
+ // Invalidate the run first: anything already in flight (an announce, an accept
1165
+ // wait) will see a different token and stop instead of publishing into a dead node.
1166
+ const socket = this.socket;
1167
+ this.runToken = null;
1168
+ this.started = false;
1169
+ this.socket = null;
1170
+ // A bind that is still in flight belongs to the previous generation and closes itself.
1171
+ this.generation += 1;
1172
+ // An announce that is still in flight is dropped from the shared slot: its result is
1173
+ // already discarded by the token check above, and a later `start()` must not join it.
1174
+ this.announcing = null;
1175
+
1176
+ if (this.refreshTimer !== null) {
1177
+ clearInterval(this.refreshTimer);
1178
+ this.refreshTimer = null;
1179
+ }
1180
+
1181
+ for (const channel of [...this.channels.values()]) {
1182
+ try {
1183
+ channel.close('node-closed');
1184
+ } catch {
1185
+ /* the path is already gone */
1186
+ }
1187
+ }
1188
+ this.channels.clear();
1189
+ this.claimed.clear();
1190
+
1191
+ if (socket !== null) {
1192
+ try {
1193
+ socket.close();
1194
+ } catch {
1195
+ /* already closed */
1196
+ }
1197
+ }
1198
+ // `accept()` finishes on the socket's `close` event, so this is a real barrier and
1199
+ // not a wait for the accept timeout.
1200
+ await this.acceptLoop?.catch(() => {});
1201
+ this.acceptLoop = null;
1202
+
1203
+ this.local = null;
1204
+ this.reflexive = null;
1205
+ this.mapping = null;
1206
+ this.candidates = [];
1207
+ this.announcedAt = null;
1208
+ this.announceOk = false;
1209
+ this.lastAnnounceError = null;
1210
+ }
1211
+ }
1212
+
1213
+ /**
1214
+ * The relay's answer when it refused an announcement, as one named line.
1215
+ *
1216
+ * @param {object|null} response
1217
+ * @returns {string}
1218
+ */
1219
+ function describeAnnounceFailure(response) {
1220
+ const code = response?.json?.error?.code;
1221
+ if (typeof response?.error === 'string' && response.error !== '') {
1222
+ return `P2P_ANNOUNCE_FAILED: ${response.error}`;
1223
+ }
1224
+ if (Number.isInteger(response?.status) && response.status > 0) {
1225
+ return `P2P_ANNOUNCE_FAILED: HTTP ${response.status}${typeof code === 'string' ? ` ${code}` : ''}`;
1226
+ }
1227
+ return `P2P_ANNOUNCE_FAILED: the relay did not accept the announcement${typeof code === 'string' ? ` (${code})` : ''}`;
1228
+ }
1229
+
1230
+ /**
1231
+ * The relay's answer when it refused a candidate lookup.
1232
+ *
1233
+ * @param {object|null} response
1234
+ * @returns {string}
1235
+ */
1236
+ function describeLookupFailure(response) {
1237
+ const code = response?.json?.error?.code;
1238
+ if (typeof response?.error === 'string' && response.error !== '') return response.error;
1239
+ if (Number.isInteger(response?.status) && response.status > 0) {
1240
+ return `HTTP ${response.status}${typeof code === 'string' ? ` ${code}` : ''}`;
1241
+ }
1242
+ return `no usable answer from the relay${typeof code === 'string' ? ` (${code})` : ''}`;
1243
+ }