@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.
- package/CHANGELOG.md +1288 -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 +13 -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,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;
|