@twinsearth/w2m-dsh-plugin 0.0.0-stage → 0.4.7
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/CHANGELOG.md +1309 -0
- package/LICENSE +21 -0
- package/PROTOCOL-v0.1.2.md +165 -0
- package/PROTOCOL-v0.3.0.md +454 -0
- package/PROTOCOL-v0.3.9.md +207 -0
- package/PROTOCOL-v0.4.0.md +255 -0
- package/PROTOCOL.md +339 -0
- package/README.md +665 -2
- package/bin/w2m-localside.mjs +303 -0
- package/bin/w2m-rabbit.mjs +396 -0
- package/bin/w2m-stun.mjs +231 -0
- package/cordis.patch.yml +94 -0
- package/deploy/caddy/Caddyfile.example +118 -0
- package/deploy/docker/Dockerfile +144 -0
- package/deploy/docker/README.md +338 -0
- package/deploy/docker/docker-compose.yml +179 -0
- package/deploy/nginx/w2m.conf.example +196 -0
- package/deploy/shared-server/README.md +118 -0
- package/deploy/shared-server/install.sh +255 -0
- package/deploy/systemd/w2m-rabbit.service +150 -0
- package/deploy/systemd/w2m-stun.service +69 -0
- package/deploy/windows/README.md +115 -0
- package/deploy/windows/install-service.ps1 +195 -0
- package/deploy/windows/w2m-agent-supervisor.mjs +205 -0
- package/deploy/windows/winsw/w2m-localside.xml +50 -0
- package/docs/DEPLOY.md +902 -0
- package/docs/LINT-AUDIT.md +492 -0
- package/docs/PIPELINE-DESIGN.md +95 -0
- package/docs/TROUBLESHOOTING.md +560 -0
- package/docs/UI-CARDS.md +81 -0
- package/lib/index.js +24 -0
- package/lib/tools.js +11 -0
- package/package.json +133 -4
- package/src/agent/agent.mjs +3317 -0
- package/src/agent/allowed-commands.mjs +116 -0
- package/src/agent/caps.mjs +437 -0
- package/src/agent/exec.mjs +356 -0
- package/src/agent/git.mjs +616 -0
- package/src/agent/identity.mjs +253 -0
- package/src/agent/p2p-node.mjs +1243 -0
- package/src/agent/p2p.mjs +605 -0
- package/src/agent/resolve.mjs +148 -0
- package/src/agent/spool.mjs +252 -0
- package/src/agent/stun.mjs +397 -0
- package/src/agent/url.mjs +127 -0
- package/src/plugin/auto-update.mjs +574 -0
- package/src/plugin/schedule.mjs +452 -0
- package/src/plugin/shared-config.mjs +264 -0
- package/src/plugin/tools.mjs +3891 -0
- package/src/plugin/update-install.mjs +764 -0
- package/src/plugin/update-source.mjs +686 -0
- package/src/relay/metrics.mjs +284 -0
- package/src/relay/peers.mjs +180 -0
- package/src/relay/persistence.mjs +207 -0
- package/src/relay/report.mjs +591 -0
- package/src/relay/server.mjs +1187 -0
- package/src/relay/state.mjs +2004 -0
- package/src/relay/stun-server.mjs +733 -0
- package/src/signing.mjs +359 -0
- 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
|
+
}
|