@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,733 @@
1
+ /**
2
+ * STUN (RFC 5389) Binding responder — the server half of `src/agent/stun.mjs`.
3
+ *
4
+ * Why this exists: every machine that wants a direct path has to learn the
5
+ * *public* address a peer can reach it on, and a host cannot work that out alone.
6
+ * A reflexive address is also the input to the NAT-mapping classification the hole
7
+ * punch depends on: comparing what two *different* destinations report is the only
8
+ * way to tell endpoint-independent mapping (a punch works) from endpoint-dependent
9
+ * mapping (it usually does not). Running our own responder on the shared server is
10
+ * what makes that comparison available on a deployment that has no second public
11
+ * STUN server, and it keeps the answer on infrastructure the project controls.
12
+ *
13
+ * Scope and honesty:
14
+ *
15
+ * * **IPv4 first, IPv6 tolerated.** The socket is `udp4` because the deployment
16
+ * material is IPv4 (see `src/agent/stun.mjs`). An IPv6 datagram can never
17
+ * arrive on it, but the address encoder writes both families from the RFC
18
+ * rather than assuming, so a `udp6` socket later needs no encoder change.
19
+ * * **No authentication.** No MESSAGE-INTEGRITY and no credentials: a Binding
20
+ * request carries no secret worth protecting, and a half-implemented
21
+ * authentication scheme would be worse than none. That also means this server
22
+ * must not be treated as a reflexion amplifier for arbitrary targets: it only
23
+ * ever answers the source address a request came from, which is what makes it
24
+ * a STUN server rather than a packet reflector.
25
+ * * **Robustness is the feature.** The single failure that matters here is a
26
+ * malformed datagram escaping the socket handler: an unhandled exception in a
27
+ * `'message'` listener takes the whole relay host down, so a request that
28
+ * cannot be read is counted and dropped, never thrown from.
29
+ * * **Not a full ICE/NAT-behaviour server.** No CHANGE-REQUEST / RESPONSE-ORIGIN
30
+ * / OTHER-ADDRESS (RFC 5780), so this server cannot itself classify filtering
31
+ * behaviour. Only the mapping observation is served, and that is stated.
32
+ */
33
+
34
+ import dgram from 'node:dgram';
35
+
36
+ /** RFC 5389 §6: the fixed magic cookie that makes STUN demultiplexable. */
37
+ export const STUN_MAGIC_COOKIE = 0x2112a442;
38
+
39
+ export const STUN_BINDING_REQUEST = 0x0001;
40
+ export const STUN_BINDING_SUCCESS = 0x0101;
41
+ export const STUN_BINDING_ERROR = 0x0111;
42
+
43
+ export const ATTR_MAPPED_ADDRESS = 0x0001;
44
+ export const ATTR_ERROR_CODE = 0x0009;
45
+ export const ATTR_UNKNOWN_ATTRIBUTES = 0x000a;
46
+ export const ATTR_XOR_MAPPED_ADDRESS = 0x0020;
47
+ export const ATTR_USERNAME = 0x0006;
48
+ export const ATTR_SOFTWARE = 0x8022;
49
+
50
+ /** 400 Bad Request, the only error this responder produces (see §15.6 for the layout). */
51
+ const ERROR_CODE_BAD_REQUEST = 400;
52
+ const ERROR_REASON_BAD_REQUEST = 'Bad Request';
53
+
54
+ /** Default SOFTWARE string; overridable per instance. */
55
+ export const DEFAULT_SOFTWARE = 'w2m-stun/1';
56
+
57
+ /**
58
+ * Comprehension-required attributes (0x0000–0x7FFF) this responder understands.
59
+ *
60
+ * Anything else in that range is, by RFC 5389 §7.3.1, a message the server must
61
+ * refuse rather than answer while ignoring a field the client said it needs.
62
+ * The list is deliberately the *modern* set: RESPONSE-ADDRESS and CHANGE-REQUEST
63
+ * are RFC 3489 legacy and are unknown here, so a classic client asking for them
64
+ * gets a 400 instead of a silent answer to a question it did not ask.
65
+ */
66
+ const KNOWN_COMPREHENSION_REQUIRED = new Set([ATTR_MAPPED_ADDRESS, ATTR_USERNAME, ATTR_XOR_MAPPED_ADDRESS]);
67
+
68
+ /**
69
+ * Parser and dispatcher outcomes, mapped onto the `stats().dropped` counter that
70
+ * means the same thing. A code missing from this table is still counted in
71
+ * `stats().errors` — the breakdown is a convenience, the total is the contract.
72
+ */
73
+ const DROP_BUCKET = Object.freeze({
74
+ TOO_SHORT: 'short',
75
+ BAD_FIRST_BITS: 'first_bits',
76
+ BAD_MAGIC_COOKIE: 'magic_cookie',
77
+ LENGTH_MISMATCH: 'length',
78
+ TRUNCATED_ATTRIBUTE: 'truncated_attribute',
79
+ unsupported_transport: 'unsupported_transport',
80
+ });
81
+
82
+ // ---------------------------------------------------------------------------------------------
83
+ // Time
84
+ // ---------------------------------------------------------------------------------------------
85
+
86
+ /**
87
+ * RFC 3339 with milliseconds stripped — the same shape the relay reports
88
+ * (`rfc3339` in `state.mjs`), so a log line and a status line read alike.
89
+ *
90
+ * @param {number} ms
91
+ * @returns {string}
92
+ */
93
+ function rfc3339(ms) {
94
+ return new Date(ms).toISOString().replace(/\.\d{3}Z$/, 'Z');
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------------------------
98
+ // Wire format — encoders
99
+ //
100
+ // Written from the RFC and kept in the relay half of the tree on purpose: the
101
+ // client in `src/agent/` must stay able to disagree with this file, otherwise
102
+ // the byte-level test stops being evidence and becomes a tautology.
103
+ // ---------------------------------------------------------------------------------------------
104
+
105
+ /**
106
+ * Encode a 4-byte attribute header.
107
+ *
108
+ * @param {number} type
109
+ * @param {number} length
110
+ * @returns {Buffer}
111
+ */
112
+ function encodeAttributeHeader(type, length) {
113
+ const header = Buffer.alloc(4);
114
+ header.writeUInt16BE(type, 0);
115
+ header.writeUInt16BE(length, 2);
116
+ return header;
117
+ }
118
+
119
+ /**
120
+ * Bytes of padding that must follow an attribute value of `length` bytes.
121
+ *
122
+ * RFC 5389 §15: attributes are padded to a 4-byte boundary, and the padding is
123
+ * part of the message. It is trivial to leave out — the result still parses
124
+ * correctly under any reader that makes the same assumption, which is exactly how
125
+ * it survives a self-written test.
126
+ *
127
+ * @param {number} length
128
+ * @returns {number}
129
+ */
130
+ function paddingFor(length) {
131
+ return (4 - (length % 4)) % 4;
132
+ }
133
+
134
+ /**
135
+ * Encode MAPPED-ADDRESS / XOR-MAPPED-ADDRESS.
136
+ *
137
+ * Layout (RFC 5389 §15.1/§15.2): one reserved byte, then Family, then the port,
138
+ * then the address. The reserved byte coming *first* is what makes "read
139
+ * `value[0]` as the family" the classic silent bug — see the note in
140
+ * `src/agent/stun.mjs`, where a fake server agreed with the wrong offset and only
141
+ * a real one disagreed.
142
+ *
143
+ * @param {string} address Dotted-quad IPv4 or colon-separated IPv6.
144
+ * @param {number} port
145
+ * @param {Buffer} transactionId 12 bytes; the XOR mask beyond the cookie.
146
+ * @param {boolean} xor
147
+ * @returns {Buffer|null} `null` when the address cannot be encoded.
148
+ */
149
+ function encodeAddressValue(address, port, transactionId, xor) {
150
+ const family = address.includes(':') ? 0x02 : 0x01;
151
+ const raw = family === 0x01 ? parseIpv4(address) : parseIpv6(address);
152
+ if (raw === null) return null;
153
+
154
+ const value = Buffer.alloc(family === 0x01 ? 8 : 20);
155
+ value.writeUInt8(0x00, 0); // reserved, MUST be zero
156
+ value.writeUInt8(family, 1);
157
+ // RFC 5389 §15.2: XOR the port with the top 16 bits of the cookie. (XOR is
158
+ // symmetric, so the same expression decodes — the client's decoder relies on
159
+ // exactly that, see `decodeAddress` in `src/agent/stun.mjs`.)
160
+ value.writeUInt16BE((xor ? port ^ (STUN_MAGIC_COOKIE >>> 16) : port) & 0xffff, 2);
161
+ // The v6 mask is the cookie followed by the transaction id (RFC 5389 §15.2).
162
+ const mask = xor ? Buffer.concat([Buffer.from([0x21, 0x12, 0xa4, 0x42]), transactionId]) : null;
163
+ for (let i = 0; i < raw.length; i += 1) {
164
+ const byte = mask ? raw[i] ^ mask[i] : raw[i];
165
+ value.writeUInt8(byte & 0xff, 4 + i);
166
+ }
167
+ return value;
168
+ }
169
+
170
+ /**
171
+ * Parse a dotted-quad IPv4 address.
172
+ *
173
+ * @param {string} address
174
+ * @returns {number[]|null} Four octets, or `null` when it is not an IPv4 literal.
175
+ */
176
+ function parseIpv4(address) {
177
+ const parts = address.split('.');
178
+ if (parts.length !== 4) return null;
179
+ const octets = [];
180
+ for (const part of parts) {
181
+ if (!/^\d{1,3}$/.test(part)) return null;
182
+ const value = Number(part);
183
+ if (value > 255) return null;
184
+ octets.push(value);
185
+ }
186
+ return octets;
187
+ }
188
+
189
+ /**
190
+ * Parse a textual IPv6 address into 16 bytes, `::` compression and a trailing
191
+ * IPv4-mapped quad (`::ffff:127.0.0.1`) included.
192
+ *
193
+ * @param {string} address
194
+ * @returns {number[]|null} Sixteen bytes, or `null` when it is not an IPv6 literal.
195
+ */
196
+ function parseIpv6(address) {
197
+ const halves = address.split('::');
198
+ if (halves.length > 2) return null;
199
+ const split = (part) => (part === '' ? [] : part.split(':'));
200
+ const head = split(halves[0]);
201
+ const tail = halves.length === 2 ? split(halves[1]) : [];
202
+ const groups = [...head, ...tail];
203
+ if (halves.length === 1) {
204
+ if (groups.length !== 8) return null; // a bare address must be exactly 8 groups
205
+ } else {
206
+ // '::' stands for at least one zero group, so the explicit groups must leave room for it.
207
+ if (groups.length >= 8) return null;
208
+ // A dotted quad is the final 32 bits, so it counts as two groups.
209
+ const omitted = groups.length - (dottedQuadAt(groups, groups.length - 1) ? 1 : 0);
210
+ groups.splice(head.length, 0, ...Array(8 - omitted).fill('0'));
211
+ }
212
+ if (groups.length !== 8) return null;
213
+
214
+ const bytes = [];
215
+ for (let i = 0; i < groups.length; i += 1) {
216
+ const group = groups[i];
217
+ const isLast = i === groups.length - 1;
218
+ if (dottedQuadAt(groups, i)) {
219
+ const octets = parseIpv4(group);
220
+ if (octets === null) return null;
221
+ bytes.push(...octets);
222
+ continue;
223
+ }
224
+ if (!/^[0-9a-fA-F]{1,4}$/.test(group)) return null;
225
+ const value = Number.parseInt(group, 16);
226
+ bytes.push((value >> 8) & 0xff, value & 0xff);
227
+ if (isLast && bytes.length !== 16) return null;
228
+ }
229
+ return bytes.length === 16 ? bytes : null;
230
+ }
231
+
232
+ /**
233
+ * Whether the group at `index` is a trailing dotted quad rather than a hex group.
234
+ *
235
+ * @param {string[]} groups
236
+ * @param {number} index
237
+ * @returns {boolean}
238
+ */
239
+ function dottedQuadAt(groups, index) {
240
+ return index === groups.length - 1 && typeof groups[index] === 'string' && groups[index].includes('.');
241
+ }
242
+
243
+ /**
244
+ * Encode an ERROR-CODE attribute (RFC 5389 §15.6).
245
+ *
246
+ * The code is split across two bytes: three reserved bits, then the class in the
247
+ * low three bits of byte 2 and the number in byte 3. Writing `code` as a uint16
248
+ * is the mistake this layout invites, and it produces [0, 0, 1, 144] for 400 —
249
+ * a number no client on the other side will recognise.
250
+ *
251
+ * @param {number} code
252
+ * @param {string} reason
253
+ * @returns {Buffer}
254
+ */
255
+ function encodeErrorCodeValue(code, reason) {
256
+ const text = Buffer.from(reason, 'utf8');
257
+ const value = Buffer.alloc(4 + text.length);
258
+ value.writeUInt16BE(0, 0); // reserved, MUST be zero
259
+ value.writeUInt8(0x07 & Math.floor(code / 100), 2);
260
+ value.writeUInt8(code % 100, 3);
261
+ text.copy(value, 4);
262
+ return value;
263
+ }
264
+
265
+ /**
266
+ * Encode UNKNOWN-ATTRIBUTES (RFC 5389 §15.9): the attribute *types* the client
267
+ * required and we do not implement.
268
+ *
269
+ * Sent alongside the 400 because "Bad Request" alone leaves a client guessing
270
+ * which field caused it — the whole complaint RFC 5389 §7.3.1 makes about
271
+ * answering a request that carried an unread mandatory attribute.
272
+ *
273
+ * @param {number[]} types
274
+ * @returns {Buffer}
275
+ */
276
+ function encodeUnknownAttributesValue(types) {
277
+ const value = Buffer.alloc(types.length * 2);
278
+ types.forEach((type, i) => value.writeUInt16BE(type, i * 2));
279
+ return value;
280
+ }
281
+
282
+ // ---------------------------------------------------------------------------------------------
283
+ // Wire format — parser
284
+ // ---------------------------------------------------------------------------------------------
285
+
286
+ /**
287
+ * Read one STUN message.
288
+ *
289
+ * Every read is length-checked *before* it happens: the point of this function is
290
+ * that a hostile or truncated datagram produces a value, never an exception, so
291
+ * the socket handler above it cannot be crashed by input.
292
+ *
293
+ * @param {Buffer} buf
294
+ * @returns {{ok:true, type:number, transactionId:Buffer, attributes:Array<{type:number, value:Buffer}>}
295
+ * |{ok:false, code:'TOO_SHORT'|'BAD_FIRST_BITS'|'BAD_MAGIC_COOKIE'|'LENGTH_MISMATCH'|'TRUNCATED_ATTRIBUTE'}}
296
+ */
297
+ export function parseStunMessage(buf) {
298
+ if (!Buffer.isBuffer(buf) || buf.length < 20) return { ok: false, code: 'TOO_SHORT' };
299
+ // RFC 5389 §6: the two most significant bits of every STUN message are zero.
300
+ // A datagram where they are set is not STUN (it is how the multiplexed
301
+ // protocols sharing port 3478 are told apart), so it is dropped.
302
+ if ((buf[0] & 0xc0) !== 0) return { ok: false, code: 'BAD_FIRST_BITS' };
303
+ if (buf.readUInt32BE(4) !== STUN_MAGIC_COOKIE) return { ok: false, code: 'BAD_MAGIC_COOKIE' };
304
+
305
+ const type = buf.readUInt16BE(0);
306
+ const length = buf.readUInt16BE(2);
307
+ // The message length excludes the 20-byte header and everything in a STUN
308
+ // message is a multiple of 4 bytes (attributes are padded), so an odd length
309
+ // or one that does not match the datagram is malformed by construction.
310
+ if (length % 4 !== 0 || 20 + length !== buf.length) return { ok: false, code: 'LENGTH_MISMATCH' };
311
+
312
+ const attributes = [];
313
+ let offset = 20;
314
+ while (offset < buf.length) {
315
+ if (offset + 4 > buf.length) return { ok: false, code: 'TRUNCATED_ATTRIBUTE' };
316
+ const attrType = buf.readUInt16BE(offset);
317
+ const attrLength = buf.readUInt16BE(offset + 2);
318
+ const padded = attrLength + ((4 - (attrLength % 4)) % 4);
319
+ // The padding is part of the message: an attribute whose pad byte is missing
320
+ // is a truncated datagram, not an attribute that ends early.
321
+ if (offset + 4 + padded > buf.length) return { ok: false, code: 'TRUNCATED_ATTRIBUTE' };
322
+ attributes.push({ type: attrType, value: buf.subarray(offset + 4, offset + 4 + attrLength) });
323
+ offset += 4 + padded;
324
+ }
325
+
326
+ return { ok: true, type, transactionId: buf.subarray(8, 20), attributes };
327
+ }
328
+
329
+ // ---------------------------------------------------------------------------------------------
330
+ // Server
331
+ // ---------------------------------------------------------------------------------------------
332
+
333
+ /**
334
+ * Create a STUN Binding responder.
335
+ *
336
+ * The factory owns exactly one `udp4` socket. Nothing is bound until `start()`,
337
+ * `stop()` is idempotent, and `start()` after `stop()` is supported: a server
338
+ * that leaks its socket on a restart is a server that cannot be reconfigured
339
+ * without dropping the process.
340
+ *
341
+ * @param {object} [options]
342
+ * @param {string} [options.host] Bind address. Default `'0.0.0.0'` — a STUN server
343
+ * bound to loopback answers nobody, which is the opposite of its job.
344
+ * @param {number} [options.port] Default 3478, the IANA STUN port. `0` asks the OS
345
+ * for a free port (used by tests and by anyone behind a port-forward).
346
+ * @param {string} [options.software] Value of the SOFTWARE attribute and of `stats().software`.
347
+ * @param {() => number} [options.nowMs] Clock injection, for tests.
348
+ * @returns {{start:()=>Promise<{ok:boolean,host:string|null,port:number|null,error:string|null}>,
349
+ * stop:()=>Promise<void>, address:()=>({address:string,port:number}|null),
350
+ * stats:()=>object, parseCount:()=>number}}
351
+ */
352
+ export function createStunServer({
353
+ host = '0.0.0.0',
354
+ port = 3478,
355
+ software = DEFAULT_SOFTWARE,
356
+ nowMs,
357
+ log = () => {},
358
+ } = {}) {
359
+ const clock = typeof nowMs === 'function' ? nowMs : () => Date.now();
360
+ const softwareName = typeof software === 'string' && software.trim() !== '' ? software : DEFAULT_SOFTWARE;
361
+ const note = typeof log === 'function' ? log : () => {};
362
+
363
+ /** @type {import('node:dgram').Socket|null} */
364
+ let socket = null;
365
+ let startedAtMs = null;
366
+ let starting = null;
367
+ let stopping = null;
368
+
369
+ const counters = {
370
+ requests: 0,
371
+ responses: 0,
372
+ errors: 0,
373
+ /** Why datagrams were refused, as counters rather than prose — a debug log needs the shape. */
374
+ dropped: {
375
+ short: 0,
376
+ first_bits: 0,
377
+ magic_cookie: 0,
378
+ length: 0,
379
+ truncated_attribute: 0,
380
+ unsupported_transport: 0,
381
+ },
382
+ by_type: {},
383
+ last_request_at: null,
384
+ last_error: null,
385
+ };
386
+
387
+ /**
388
+ * Build the success response for one Binding request.
389
+ *
390
+ * Attribute order is fixed (XOR-MAPPED-ADDRESS, MAPPED-ADDRESS, SOFTWARE) so the
391
+ * byte-level test in `test/stun-server.test.mjs` can assert an exact frozen
392
+ * buffer: a response whose content is right but whose layout drifts is a
393
+ * response no frozen vector can protect.
394
+ *
395
+ * @param {Buffer} transactionId
396
+ * @param {{address:string, port:number}} peer
397
+ * @returns {Buffer}
398
+ */
399
+ function buildSuccess(transactionId, peer) {
400
+ const xor = encodeAddressValue(peer.address, peer.port, transactionId, true);
401
+ const plain = encodeAddressValue(peer.address, peer.port, transactionId, false);
402
+ // Without a MAPPED-ADDRESS there is nothing to report, and an empty success
403
+ // response would be a lie dressed as an answer.
404
+ if (xor === null && plain === null) return null;
405
+ const parts = [];
406
+ if (xor) parts.push(encodeAttributeHeader(ATTR_XOR_MAPPED_ADDRESS, xor.length), xor);
407
+ if (plain) parts.push(encodeAttributeHeader(ATTR_MAPPED_ADDRESS, plain.length), plain);
408
+ const sw = Buffer.from(softwareName, 'utf8');
409
+ parts.push(encodeAttributeHeader(ATTR_SOFTWARE, sw.length), sw);
410
+
411
+ const body = Buffer.concat(parts);
412
+ const header = Buffer.alloc(20);
413
+ header.writeUInt16BE(STUN_BINDING_SUCCESS, 0);
414
+ header.writeUInt16BE(body.length, 2);
415
+ header.writeUInt32BE(STUN_MAGIC_COOKIE, 4);
416
+ transactionId.copy(header, 8);
417
+ return Buffer.concat([header, body]);
418
+ }
419
+
420
+ /**
421
+ * Build the 400 response for a request carrying an unknown comprehension-required
422
+ * attribute.
423
+ *
424
+ * @param {Buffer} transactionId
425
+ * @param {number[]} unknownTypes
426
+ * @returns {Buffer}
427
+ */
428
+ function buildBadRequest(transactionId, unknownTypes) {
429
+ const error = encodeErrorCodeValue(ERROR_CODE_BAD_REQUEST, ERROR_REASON_BAD_REQUEST);
430
+ // The padding is not decoration and not optional: RFC 5389 §15 requires every
431
+ // attribute to start on a 4-byte boundary. `Bad Request` is 11 bytes, so the
432
+ // value is 15 and the *next* attribute needs one byte of padding — omitting it
433
+ // produces a response that still walks correctly under a same-assumption
434
+ // decoder and is wrong on the wire. The Binding success path never showed the
435
+ // bug because all three of its values are already multiples of 4.
436
+ const parts = [
437
+ encodeAttributeHeader(ATTR_ERROR_CODE, error.length),
438
+ error,
439
+ Buffer.alloc(paddingFor(error.length)),
440
+ ];
441
+ if (unknownTypes.length > 0) {
442
+ const unknown = encodeUnknownAttributesValue(unknownTypes);
443
+ parts.push(encodeAttributeHeader(ATTR_UNKNOWN_ATTRIBUTES, unknown.length), unknown);
444
+ }
445
+ const sw = Buffer.from(softwareName, 'utf8');
446
+ parts.push(encodeAttributeHeader(ATTR_SOFTWARE, sw.length), sw);
447
+
448
+ const body = Buffer.concat(parts);
449
+ const header = Buffer.alloc(20);
450
+ header.writeUInt16BE(STUN_BINDING_ERROR, 0);
451
+ header.writeUInt16BE(body.length, 2);
452
+ header.writeUInt32BE(STUN_MAGIC_COOKIE, 4);
453
+ transactionId.copy(header, 8);
454
+ return Buffer.concat([header, body]);
455
+ }
456
+
457
+ /**
458
+ * Decide what one datagram deserves.
459
+ *
460
+ * @param {Buffer} message
461
+ * @param {{address:string, port:number}} peer
462
+ * @returns {{disposition:string, reply:Buffer|null, type?:number, unknown?:number[]}}
463
+ */
464
+ function handleRequest(message, peer) {
465
+ const parsed = parseStunMessage(message);
466
+ if (!parsed.ok) return { disposition: parsed.code, reply: null };
467
+ return { ...handleParsed(parsed, peer), type: parsed.type };
468
+ }
469
+
470
+ /**
471
+ * Apply the RFC 5389 §7.3.1 checks to an already-parsed message.
472
+ *
473
+ * @param {{type:number, transactionId:Buffer, attributes:Array<{type:number, value:Buffer}>}} parsed
474
+ * @param {{address:string, port:number}} peer
475
+ * @returns {{disposition:string, reply:Buffer|null, unknown?:number[]}}
476
+ */
477
+ function handleParsed(parsed, peer) {
478
+ const { type, transactionId, attributes } = parsed;
479
+ // Only a request is answered. `type` packs class and method into interleaved
480
+ // bits (RFC 5389 §6), so the class is extracted rather than compared: a
481
+ // *response* arriving here is either a stray datagram from some other endpoint
482
+ // or a reflection attempt aimed at a third party, and replying to a response
483
+ // is how a loop starts.
484
+ const messageClass = ((type >> 4) & 0x01) | ((type >> 7) & 0x02);
485
+ if (messageClass !== 0x00) {
486
+ return { disposition: 'not_a_request', reply: null };
487
+ }
488
+ if (type !== STUN_BINDING_REQUEST) {
489
+ // An unknown *method* is an error in RFC 5389 §7.3.1 terms, but a method this
490
+ // responder does not implement is not a Binding request; it is counted and
491
+ // dropped rather than answered with a Binding error that would claim the
492
+ // transaction was understood.
493
+ return { disposition: `unhandled_method_0x${type.toString(16).padStart(4, '0')}`, reply: null };
494
+ }
495
+
496
+ const unknown = [];
497
+ for (const attribute of attributes) {
498
+ if (!KNOWN_COMPREHENSION_REQUIRED.has(attribute.type) && attribute.type < 0x8000)
499
+ unknown.push(attribute.type);
500
+ }
501
+ if (unknown.length > 0) {
502
+ // Counted per attribute type as well as per request: an operator watching a
503
+ // new client fail needs to know *which* field it insisted on.
504
+ for (const unknownType of unknown)
505
+ counters.by_type[unknownType] = (counters.by_type[unknownType] ?? 0) + 1;
506
+ const reply = buildBadRequest(transactionId, unknown);
507
+ return { disposition: 'unknown_comprehension_required', unknown, reply };
508
+ }
509
+
510
+ // The port is taken from the datagram, never from an attribute: this is the
511
+ // observation the client cannot make for itself, and the only place it can
512
+ // come from is `rinfo`. `buildSuccess` returns `null` only when the source
513
+ // address cannot be encoded as an address attribute at all, in which case the
514
+ // honest answer is nothing rather than a success response with no address in
515
+ // it — a client reads that as `STUN_NO_MAPPED_ADDRESS` instead of as a peer.
516
+ const reply = buildSuccess(transactionId, peer);
517
+ if (reply === null) return { disposition: 'unsupported_transport', reply: null };
518
+ return { disposition: 'binding_success', reply };
519
+ }
520
+
521
+ /**
522
+ * A post-bind socket error. Asynchronous (ICMP port-unreachable on some
523
+ * platforms) and recorded, never rethrown: a dead socket must not take the
524
+ * process with it.
525
+ *
526
+ * @param {NodeJS.ErrnoException} error
527
+ */
528
+ function onSocketError(error) {
529
+ counters.errors += 1;
530
+ counters.last_error = `SOCKET_ERROR: ${error.code ?? error.message}`;
531
+ }
532
+
533
+ /**
534
+ * `dgram` message handler. Nothing in here may throw: an exception in a socket
535
+ * listener is an uncaught exception, i.e. the relay host dies because someone
536
+ * sent it a bad packet.
537
+ */
538
+ function onMessage(message, rinfo) {
539
+ counters.requests += 1;
540
+ counters.last_request_at = rfc3339(clock());
541
+
542
+ let outcome;
543
+ let peer = null;
544
+ try {
545
+ // `rinfo.address` can carry a scope id for a link-local v6 address; only the
546
+ // address itself belongs in the attribute.
547
+ peer = { address: String(rinfo.address).split('%')[0], port: rinfo.port };
548
+ outcome = handleRequest(message, peer);
549
+ } catch (error) {
550
+ // Reached only by a bug, since the parser is total. Counted and swallowed
551
+ // anyway: the alternative is a crash loop.
552
+ counters.errors += 1;
553
+ counters.last_error = `PARSE_FAILED: ${error?.message ?? String(error)}`;
554
+ return;
555
+ }
556
+ if (peer === null) return;
557
+
558
+ if (outcome.type !== undefined) {
559
+ const key = `0x${outcome.type.toString(16).padStart(4, '0')}`;
560
+ counters.by_type[key] = (counters.by_type[key] ?? 0) + 1;
561
+ }
562
+
563
+ // Observers are handed a frozen-shaped object rather than the raw datagram:
564
+ // a caller that logs one line per request needs the *decision*, and the
565
+ // request bytes may contain anything at all.
566
+ try {
567
+ note({
568
+ event: 'stun-request',
569
+ disposition: outcome.disposition,
570
+ type: outcome.type === undefined ? null : `0x${outcome.type.toString(16).padStart(4, '0')}`,
571
+ from: `${peer.address}:${peer.port}`,
572
+ unknown: outcome.unknown ?? [],
573
+ });
574
+ } catch {
575
+ // A broken observer is not allowed to break the responder.
576
+ }
577
+
578
+ if (outcome.reply === null) {
579
+ counters.errors += 1;
580
+ const bucket = counters.dropped[DROP_BUCKET[outcome.disposition]];
581
+ if (bucket !== undefined) counters.dropped[DROP_BUCKET[outcome.disposition]] += 1;
582
+ counters.last_error = outcome.disposition;
583
+ return;
584
+ }
585
+
586
+ if (!socket) return;
587
+ socket.send(outcome.reply, rinfo.port, rinfo.address, (error) => {
588
+ if (error) {
589
+ counters.errors += 1;
590
+ counters.last_error = `SEND_FAILED: ${error.code ?? error.message}`;
591
+ return;
592
+ }
593
+ counters.responses += 1;
594
+ });
595
+ }
596
+
597
+ return {
598
+ /**
599
+ * Bind the socket.
600
+ *
601
+ * Never throws: a taken port is a returned `{ok:false, error}` naming
602
+ * `EADDRINUSE`, because the caller is a CLI whose whole job at that moment is
603
+ * to print one clear line and exit non-zero.
604
+ *
605
+ * @returns {Promise<{ok:boolean, host:string|null, port:number|null, error:string|null}>}
606
+ */
607
+ async start() {
608
+ if (socket)
609
+ return { ok: true, host: socket.address().address, port: socket.address().port, error: null };
610
+ if (starting) return starting;
611
+ starting = new Promise((resolve) => {
612
+ const candidate = dgram.createSocket('udp4');
613
+ const settle = (result) => {
614
+ starting = null;
615
+ resolve(result);
616
+ };
617
+ candidate.once('error', onBindError);
618
+ candidate.once('listening', onListening);
619
+
620
+ /** A bind failure leaves a socket that will never be usable. */
621
+ function onBindError(error) {
622
+ candidate.removeListener('listening', onListening);
623
+ candidate.removeListener('error', onBindError);
624
+ // Close it so the port is genuinely free for the retry the CLI tells the
625
+ // user to make.
626
+ try {
627
+ candidate.close();
628
+ } catch {
629
+ /* never bound: close() throws EBADF on some platforms, nothing to do */
630
+ }
631
+ counters.last_error = `START_FAILED: ${error.code ?? error.message}`;
632
+ settle({ ok: false, host: null, port: null, error: error.code ?? error.message });
633
+ }
634
+
635
+ function onListening() {
636
+ candidate.removeListener('error', onBindError);
637
+ candidate.removeListener('listening', onListening);
638
+ candidate.on('error', onSocketError);
639
+ candidate.on('message', onMessage);
640
+ socket = candidate;
641
+ startedAtMs = clock();
642
+ settle({
643
+ ok: true,
644
+ host: candidate.address().address,
645
+ port: candidate.address().port,
646
+ error: null,
647
+ });
648
+ }
649
+
650
+ candidate.bind(port, host);
651
+ });
652
+ return starting;
653
+ },
654
+
655
+ /**
656
+ * Close the socket. Idempotent, and never throws: every exit path in the CLI
657
+ * runs through here, including the one after a failed `start()`.
658
+ *
659
+ * @returns {Promise<void>}
660
+ */
661
+ async stop() {
662
+ if (stopping) return stopping;
663
+ const current = socket;
664
+ socket = null;
665
+ startedAtMs = null;
666
+ if (!current) return undefined;
667
+ stopping = new Promise((resolve) => {
668
+ let settled = false;
669
+ const done = () => {
670
+ if (settled) return;
671
+ settled = true;
672
+ stopping = null;
673
+ resolve();
674
+ };
675
+ try {
676
+ current.removeAllListeners('message');
677
+ // `close()` on a socket that was never bound throws, and its callback
678
+ // never fires in that case, so both paths have to resolve.
679
+ current.close(done);
680
+ } catch {
681
+ done();
682
+ }
683
+ });
684
+ return stopping;
685
+ },
686
+
687
+ /** The bound address, or `null` while the server is not listening. */
688
+ address() {
689
+ if (!socket) return null;
690
+ const bound = socket.address();
691
+ return { address: bound.address, port: bound.port };
692
+ },
693
+
694
+ /**
695
+ * A plain snapshot: counters, why requests were refused, and when the server
696
+ * started. `responses` lags `requests` by the datagrams still in flight, which
697
+ * is why the counters are reported as observations rather than as a ratio.
698
+ *
699
+ * @returns {{requests:number, responses:number, errors:number, started_at:string|null,
700
+ * software:string, dropped:object, by_type:object, last_request_at:string|null,
701
+ * last_error:string|null, running:boolean}}
702
+ */
703
+ stats() {
704
+ return {
705
+ requests: counters.requests,
706
+ responses: counters.responses,
707
+ errors: counters.errors,
708
+ started_at: startedAtMs === null ? null : rfc3339(startedAtMs),
709
+ software: softwareName,
710
+ dropped: { ...counters.dropped },
711
+ by_type: { ...counters.by_type },
712
+ last_request_at: counters.last_request_at,
713
+ last_error: counters.last_error,
714
+ running: socket !== null,
715
+ };
716
+ },
717
+
718
+ /**
719
+ * How many datagrams have been parsed since the process started.
720
+ *
721
+ * Same number as `stats().requests`, exposed separately because `parseCount`
722
+ * is the name the contract fixes and a caller may reasonably read it while
723
+ * `stats()` is being rendered.
724
+ *
725
+ * @returns {number}
726
+ */
727
+ parseCount() {
728
+ return counters.requests;
729
+ },
730
+ };
731
+ }
732
+
733
+ export default createStunServer;