@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,397 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* STUN (RFC 5389) — reflexive address discovery for the W2M P2P transport.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists: the relay is a star. Every task offer and every result crosses
|
|
5
|
+
* it, which is what makes the design work through NAT without opening a port —
|
|
6
|
+
* and also what makes the relay a bandwidth bottleneck and a single point of
|
|
7
|
+
* failure. A direct path needs each side to learn the *public* address the other
|
|
8
|
+
* can reach it on, and that is the one thing a host cannot work out alone.
|
|
9
|
+
*
|
|
10
|
+
* Scope and honesty:
|
|
11
|
+
*
|
|
12
|
+
* * **IPv4 only.** The deployment material targets an IPv4 relay; IPv6 needs its
|
|
13
|
+
* own socket family and is deliberately not half-implemented here.
|
|
14
|
+
* * **No third-party dependency.** The project's rule is `node:` builtins only,
|
|
15
|
+
* so this is a from-scratch implementation of the two messages actually
|
|
16
|
+
* needed: Binding Request and Binding Success Response. No authentication
|
|
17
|
+
* attributes (MESSAGE-INTEGRITY) — a public STUN server needs none, and
|
|
18
|
+
* implementing them badly would be worse than not offering them.
|
|
19
|
+
* * **It reports evidence, not a verdict.** NAT type is inferred from whether
|
|
20
|
+
* several servers agree on the mapped port, which distinguishes
|
|
21
|
+
* endpoint-independent mapping (hole punching works) from endpoint-dependent
|
|
22
|
+
* mapping (it usually does not). That inference is stated with the
|
|
23
|
+
* observations behind it, because "symmetric NAT" is a conclusion a caller
|
|
24
|
+
* must be able to check.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import dgram from 'node:dgram';
|
|
28
|
+
import { randomBytes } from 'node:crypto';
|
|
29
|
+
|
|
30
|
+
/** RFC 5389 §6: the fixed magic cookie that makes STUN demultiplexable. */
|
|
31
|
+
export const STUN_MAGIC_COOKIE = 0x2112a442;
|
|
32
|
+
|
|
33
|
+
export const STUN_BINDING_REQUEST = 0x0001;
|
|
34
|
+
export const STUN_BINDING_SUCCESS = 0x0101;
|
|
35
|
+
|
|
36
|
+
const ATTR_MAPPED_ADDRESS = 0x0001;
|
|
37
|
+
const ATTR_XOR_MAPPED_ADDRESS = 0x0020;
|
|
38
|
+
const ATTR_ERROR_CODE = 0x0009;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Public STUN servers used by default.
|
|
42
|
+
*
|
|
43
|
+
* Two different operators on purpose: the NAT mapping can only be classified by
|
|
44
|
+
* comparing what *different* destinations see, and two hostnames that resolve to
|
|
45
|
+
* the same anycast address would defeat that.
|
|
46
|
+
*/
|
|
47
|
+
export const DEFAULT_STUN_SERVERS = [
|
|
48
|
+
'stun.cloudflare.com:3478',
|
|
49
|
+
'stun.l.google.com:19302',
|
|
50
|
+
'stun.nextcloud.com:443',
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
/** Default per-query timeout. Public STUN answers in tens of milliseconds; 2 s is generous. */
|
|
54
|
+
export const DEFAULT_STUN_TIMEOUT_MS = 2000;
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------------------------
|
|
57
|
+
// Wire format
|
|
58
|
+
// ---------------------------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Build a Binding Request.
|
|
62
|
+
*
|
|
63
|
+
* @param {Buffer} [transactionId] 12 bytes; generated when omitted.
|
|
64
|
+
* @returns {{bytes: Buffer, transactionId: Buffer}}
|
|
65
|
+
*/
|
|
66
|
+
export function encodeBindingRequest(transactionId = randomBytes(12)) {
|
|
67
|
+
if (!Buffer.isBuffer(transactionId) || transactionId.length !== 12) {
|
|
68
|
+
throw new TypeError('STUN transaction id must be a 12-byte Buffer');
|
|
69
|
+
}
|
|
70
|
+
const header = Buffer.alloc(20);
|
|
71
|
+
header.writeUInt16BE(STUN_BINDING_REQUEST, 0);
|
|
72
|
+
header.writeUInt16BE(0, 2); // no attributes
|
|
73
|
+
header.writeUInt32BE(STUN_MAGIC_COOKIE, 4);
|
|
74
|
+
transactionId.copy(header, 8);
|
|
75
|
+
return { bytes: header, transactionId };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Parse the attributes of one STUN message.
|
|
80
|
+
*
|
|
81
|
+
* @param {Buffer} buf
|
|
82
|
+
* @returns {{type:number, transactionId:Buffer, attributes:Array<{type:number, value:Buffer}>,
|
|
83
|
+
* mappedAddress:{address:string,port:number,family:number}|null,
|
|
84
|
+
* errorCode:{code:number,reason:string}|null}|null}
|
|
85
|
+
* `null` when the buffer is not a STUN message at all.
|
|
86
|
+
*/
|
|
87
|
+
export function decodeMessage(buf) {
|
|
88
|
+
if (!Buffer.isBuffer(buf) || buf.length < 20) return null;
|
|
89
|
+
if (buf.readUInt32BE(4) !== STUN_MAGIC_COOKIE) return null;
|
|
90
|
+
// RFC 5389 §6: the two most significant bits must be zero.
|
|
91
|
+
if ((buf[0] & 0xc0) !== 0) return null;
|
|
92
|
+
|
|
93
|
+
const type = buf.readUInt16BE(0);
|
|
94
|
+
const length = buf.readUInt16BE(2);
|
|
95
|
+
const transactionId = buf.subarray(8, 20);
|
|
96
|
+
const end = Math.min(20 + length, buf.length);
|
|
97
|
+
|
|
98
|
+
const attributes = [];
|
|
99
|
+
let offset = 20;
|
|
100
|
+
while (offset + 4 <= end) {
|
|
101
|
+
const attrType = buf.readUInt16BE(offset);
|
|
102
|
+
const attrLen = buf.readUInt16BE(offset + 2);
|
|
103
|
+
const value = buf.subarray(offset + 4, Math.min(offset + 4 + attrLen, end));
|
|
104
|
+
attributes.push({ type: attrType, value });
|
|
105
|
+
// Attributes are padded to a 4-byte boundary.
|
|
106
|
+
offset += 4 + attrLen + ((4 - (attrLen % 4)) % 4);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let mappedAddress = null;
|
|
110
|
+
let errorCode = null;
|
|
111
|
+
for (const attr of attributes) {
|
|
112
|
+
if (attr.type === ATTR_XOR_MAPPED_ADDRESS) {
|
|
113
|
+
mappedAddress = decodeAddress(attr.value, transactionId, true);
|
|
114
|
+
} else if (attr.type === ATTR_MAPPED_ADDRESS && mappedAddress === null) {
|
|
115
|
+
// Only a fallback: the XOR form is the one RFC 5389 defines, the plain form is
|
|
116
|
+
// RFC 3489 legacy that some servers still send.
|
|
117
|
+
mappedAddress = decodeAddress(attr.value, transactionId, false);
|
|
118
|
+
} else if (attr.type === ATTR_ERROR_CODE && attr.value.length >= 4) {
|
|
119
|
+
errorCode = {
|
|
120
|
+
code: (attr.value[2] & 0x07) * 100 + attr.value[3],
|
|
121
|
+
reason: attr.value.subarray(4).toString('utf8'),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { type, transactionId, attributes, mappedAddress, errorCode };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Decode MAPPED-ADDRESS / XOR-MAPPED-ADDRESS.
|
|
131
|
+
*
|
|
132
|
+
* @param {Buffer} value
|
|
133
|
+
* @param {Buffer} transactionId
|
|
134
|
+
* @param {boolean} xor
|
|
135
|
+
* @returns {{address:string, port:number, family:number}|null}
|
|
136
|
+
*/
|
|
137
|
+
function decodeAddress(value, transactionId, xor) {
|
|
138
|
+
if (value.length < 4) return null;
|
|
139
|
+
// RFC 5389 §15.2 / §15.1: the first byte is reserved (x) and the SECOND byte is
|
|
140
|
+
// Family. Reading value[0] as the family is the obvious mistake, and it fails
|
|
141
|
+
// silently -- every address decodes to `null`, which is indistinguishable from a
|
|
142
|
+
// server that sent no address at all. Caught by querying a real STUN server after
|
|
143
|
+
// a self-written fake server had happily agreed with the wrong offset.
|
|
144
|
+
const family = value[1];
|
|
145
|
+
let port = value.readUInt16BE(2);
|
|
146
|
+
let address;
|
|
147
|
+
if (family === 0x01) {
|
|
148
|
+
if (value.length < 8) return null;
|
|
149
|
+
const raw = Buffer.from(value.subarray(4, 8));
|
|
150
|
+
if (xor) {
|
|
151
|
+
const mask = Buffer.alloc(4);
|
|
152
|
+
mask.writeUInt32BE(STUN_MAGIC_COOKIE, 0);
|
|
153
|
+
for (let i = 0; i < 4; i += 1) raw[i] ^= mask[i];
|
|
154
|
+
}
|
|
155
|
+
address = `${raw[0]}.${raw[1]}.${raw[2]}.${raw[3]}`;
|
|
156
|
+
} else if (family === 0x02) {
|
|
157
|
+
if (value.length < 20) return null;
|
|
158
|
+
const raw = Buffer.from(value.subarray(4, 20));
|
|
159
|
+
if (xor) {
|
|
160
|
+
const mask = Buffer.concat([Buffer.from([0x21, 0x12, 0xa4, 0x42]), transactionId]);
|
|
161
|
+
for (let i = 0; i < 16; i += 1) raw[i] ^= mask[i];
|
|
162
|
+
}
|
|
163
|
+
const groups = [];
|
|
164
|
+
for (let i = 0; i < 16; i += 2) groups.push(raw.readUInt16BE(i).toString(16));
|
|
165
|
+
address = groups.join(':');
|
|
166
|
+
} else {
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (xor) port ^= STUN_MAGIC_COOKIE >>> 16;
|
|
171
|
+
return { address, port, family };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------------------------
|
|
175
|
+
// Socket
|
|
176
|
+
// ---------------------------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Bind a UDP socket on an ephemeral port.
|
|
180
|
+
*
|
|
181
|
+
* The socket is returned *unclosed on purpose*: its source port is what STUN
|
|
182
|
+
* reports, and the whole point of querying STUN first is that the mapping stays
|
|
183
|
+
* open for the hole punch that follows. Closing it would discard the mapping the
|
|
184
|
+
* NAT just created.
|
|
185
|
+
*
|
|
186
|
+
* @param {{address?:string, port?:number}} [options]
|
|
187
|
+
* @returns {Promise<{socket:import('node:dgram').Socket, local:{address:string,port:number}}>}
|
|
188
|
+
*/
|
|
189
|
+
export function bindUdpSocket(options = {}) {
|
|
190
|
+
const address = options.address ?? '0.0.0.0';
|
|
191
|
+
const port = options.port ?? 0;
|
|
192
|
+
return new Promise((resolve, reject) => {
|
|
193
|
+
const socket = dgram.createSocket('udp4');
|
|
194
|
+
const onError = (error) => {
|
|
195
|
+
socket.removeListener('listening', onListening);
|
|
196
|
+
try {
|
|
197
|
+
socket.close();
|
|
198
|
+
} catch {
|
|
199
|
+
/* already closed */
|
|
200
|
+
}
|
|
201
|
+
reject(error);
|
|
202
|
+
};
|
|
203
|
+
const onListening = () => {
|
|
204
|
+
socket.removeListener('error', onError);
|
|
205
|
+
const bound = socket.address();
|
|
206
|
+
resolve({ socket, local: { address: bound.address, port: bound.port } });
|
|
207
|
+
};
|
|
208
|
+
socket.once('error', onError);
|
|
209
|
+
socket.once('listening', onListening);
|
|
210
|
+
socket.bind(port, address);
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** `"host:port"` → `{host, port}`. Rejects anything that is not exactly that. */
|
|
215
|
+
export function parseServer(spec) {
|
|
216
|
+
const text = String(spec).trim();
|
|
217
|
+
const idx = text.lastIndexOf(':');
|
|
218
|
+
if (idx <= 0) throw new TypeError(`STUN server must be "host:port", got ${JSON.stringify(spec)}`);
|
|
219
|
+
const host = text.slice(0, idx);
|
|
220
|
+
const port = Number(text.slice(idx + 1));
|
|
221
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
222
|
+
throw new TypeError(`STUN server port out of range in ${JSON.stringify(spec)}`);
|
|
223
|
+
}
|
|
224
|
+
return { host, port, spec: text };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Send one Binding Request and wait for the matching response.
|
|
229
|
+
*
|
|
230
|
+
* @param {import('node:dgram').Socket} socket
|
|
231
|
+
* @param {string} server `"host:port"`
|
|
232
|
+
* @param {{timeoutMs?:number, transactionId?:Buffer}} [options]
|
|
233
|
+
* @returns {Promise<{ok:boolean, server:string, reflexive:{address:string,port:number}|null,
|
|
234
|
+
* rttMs:number|null, error:string|null}>}
|
|
235
|
+
*/
|
|
236
|
+
export function stunQuery(socket, server, options = {}) {
|
|
237
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_STUN_TIMEOUT_MS;
|
|
238
|
+
const { host, port, spec } = parseServer(server);
|
|
239
|
+
const { bytes, transactionId } = encodeBindingRequest(options.transactionId);
|
|
240
|
+
|
|
241
|
+
return new Promise((resolve) => {
|
|
242
|
+
let settled = false;
|
|
243
|
+
const startedAt = process.hrtime.bigint();
|
|
244
|
+
|
|
245
|
+
const finish = (result) => {
|
|
246
|
+
if (settled) return;
|
|
247
|
+
settled = true;
|
|
248
|
+
clearTimeout(timer);
|
|
249
|
+
socket.removeListener('message', onMessage);
|
|
250
|
+
resolve(result);
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const onMessage = (message) => {
|
|
254
|
+
const decoded = decodeMessage(message);
|
|
255
|
+
if (!decoded) return; // not STUN; some other service answered on that port
|
|
256
|
+
if (!decoded.transactionId.equals(transactionId)) return; // not our transaction
|
|
257
|
+
if (decoded.type !== STUN_BINDING_SUCCESS) {
|
|
258
|
+
finish({
|
|
259
|
+
ok: false,
|
|
260
|
+
server: spec,
|
|
261
|
+
reflexive: null,
|
|
262
|
+
rttMs: null,
|
|
263
|
+
error: decoded.errorCode
|
|
264
|
+
? `STUN_ERROR_${decoded.errorCode.code}: ${decoded.errorCode.reason}`
|
|
265
|
+
: `STUN_UNEXPECTED_TYPE_0x${decoded.type.toString(16)}`,
|
|
266
|
+
});
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (!decoded.mappedAddress) {
|
|
270
|
+
finish({ ok: false, server: spec, reflexive: null, rttMs: null, error: 'STUN_NO_MAPPED_ADDRESS' });
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
const rttMs = Number(process.hrtime.bigint() - startedAt) / 1e6;
|
|
274
|
+
finish({
|
|
275
|
+
ok: true,
|
|
276
|
+
server: spec,
|
|
277
|
+
reflexive: { address: decoded.mappedAddress.address, port: decoded.mappedAddress.port },
|
|
278
|
+
rttMs: Math.round(rttMs * 100) / 100,
|
|
279
|
+
error: null,
|
|
280
|
+
});
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const timer = setTimeout(() => {
|
|
284
|
+
finish({ ok: false, server: spec, reflexive: null, rttMs: null, error: 'STUN_TIMEOUT' });
|
|
285
|
+
}, timeoutMs);
|
|
286
|
+
|
|
287
|
+
socket.on('message', onMessage);
|
|
288
|
+
socket.send(bytes, port, host, (error) => {
|
|
289
|
+
if (error) {
|
|
290
|
+
finish({ ok: false, server: spec, reflexive: null, rttMs: null, error: `STUN_SEND_${error.code ?? 'FAILED'}` });
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Query several servers and classify the NAT behaviour from what they agree on.
|
|
298
|
+
*
|
|
299
|
+
* The classification that matters for hole punching is **mapping behaviour**:
|
|
300
|
+
*
|
|
301
|
+
* * every server reports the same public port → endpoint-independent mapping
|
|
302
|
+
* ("cone"), which is what makes a punched hole reusable;
|
|
303
|
+
* * different ports per destination → endpoint-dependent mapping ("symmetric"),
|
|
304
|
+
* where the port a peer was told about is not the port the peer will see.
|
|
305
|
+
*
|
|
306
|
+
* It is deliberately *not* called a full NAT-type detection: filtering behaviour
|
|
307
|
+
* (address/port-restricted cone) would need a second host to probe from, and
|
|
308
|
+
* claiming to detect what was not probed is exactly the kind of unverified
|
|
309
|
+
* certainty this project refuses elsewhere.
|
|
310
|
+
*
|
|
311
|
+
* @param {object} options
|
|
312
|
+
* @param {import('node:dgram').Socket} options.socket
|
|
313
|
+
* @param {string[]} [options.servers]
|
|
314
|
+
* @param {number} [options.timeoutMs]
|
|
315
|
+
* @returns {Promise<{ok:boolean, reflexive:{address:string,port:number}|null,
|
|
316
|
+
* mapping:'none'|'endpoint-independent'|'endpoint-dependent'|'unknown',
|
|
317
|
+
* local:{address:string,port:number}, servers:Array<object>, error:string|null}>}
|
|
318
|
+
*/
|
|
319
|
+
export async function discoverReflexive(options = {}) {
|
|
320
|
+
const { socket } = options;
|
|
321
|
+
if (!socket) throw new TypeError('discoverReflexive: a bound UDP socket is required');
|
|
322
|
+
const servers = options.servers?.length ? options.servers : DEFAULT_STUN_SERVERS;
|
|
323
|
+
const local = socket.address();
|
|
324
|
+
|
|
325
|
+
// Sequential, not parallel: the mapping comparison is only meaningful if each
|
|
326
|
+
// observation is attributable to one destination, and a burst of identical
|
|
327
|
+
// requests from one socket to three servers invites a NAT to coalesce them.
|
|
328
|
+
const results = [];
|
|
329
|
+
for (const server of servers) {
|
|
330
|
+
// A server we cannot even parse is a configuration error, not a probe result.
|
|
331
|
+
try {
|
|
332
|
+
parseServer(server);
|
|
333
|
+
} catch (error) {
|
|
334
|
+
results.push({ ok: false, server, reflexive: null, rttMs: null, error: error.message });
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
results.push(await stunQuery(socket, server, { timeoutMs: options.timeoutMs }));
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const answered = results.filter((r) => r.ok);
|
|
341
|
+
if (answered.length === 0) {
|
|
342
|
+
return {
|
|
343
|
+
ok: false,
|
|
344
|
+
reflexive: null,
|
|
345
|
+
mapping: 'unknown',
|
|
346
|
+
local: { address: local.address, port: local.port },
|
|
347
|
+
servers: results,
|
|
348
|
+
error: results.every((r) => r.error === 'STUN_TIMEOUT' || r.error?.startsWith('STUN_SEND_'))
|
|
349
|
+
? 'STUN_UNREACHABLE: no configured STUN server answered'
|
|
350
|
+
: 'STUN_NO_ANSWER',
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const ports = new Set(answered.map((r) => r.reflexive.port));
|
|
355
|
+
const addresses = new Set(answered.map((r) => r.reflexive.address));
|
|
356
|
+
const first = answered[0].reflexive;
|
|
357
|
+
|
|
358
|
+
let mapping;
|
|
359
|
+
if (addresses.size === 1 && addresses.has(local.address) && ports.has(local.port)) {
|
|
360
|
+
// The server saw the address the socket is already bound to: nothing is translating.
|
|
361
|
+
// This one IS decidable from a single answer -- the server reported our own socket
|
|
362
|
+
// address, which no translator would do. (Bound to 0.0.0.0 the local address is
|
|
363
|
+
// "0.0.0.0", so this cannot fire by accident behind a NAT.)
|
|
364
|
+
mapping = 'none';
|
|
365
|
+
} else if (answered.length < 2) {
|
|
366
|
+
// Ordering matters: `ports.size === 1` is trivially true for a single answer, so
|
|
367
|
+
// checking it first makes every one-server probe look endpoint-independent. Saying
|
|
368
|
+
// either mapping from one observation would be a guess, so we say neither.
|
|
369
|
+
mapping = 'unknown';
|
|
370
|
+
} else if (ports.size === 1) {
|
|
371
|
+
mapping = 'endpoint-independent';
|
|
372
|
+
} else {
|
|
373
|
+
mapping = 'endpoint-dependent';
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return {
|
|
377
|
+
ok: true,
|
|
378
|
+
reflexive: { address: first.address, port: first.port },
|
|
379
|
+
mapping,
|
|
380
|
+
local: { address: local.address, port: local.port },
|
|
381
|
+
servers: results,
|
|
382
|
+
error: null,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Whether a mapping behaviour can plausibly be punched.
|
|
388
|
+
*
|
|
389
|
+
* `unknown` returns `true` on purpose: one unreachable STUN server must not be
|
|
390
|
+
* the reason a direct path is never even attempted. The punch itself is the test.
|
|
391
|
+
*
|
|
392
|
+
* @param {string} mapping
|
|
393
|
+
* @returns {boolean}
|
|
394
|
+
*/
|
|
395
|
+
export function mappingSupportsPunching(mapping) {
|
|
396
|
+
return mapping !== 'endpoint-dependent';
|
|
397
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base-address handling for W2M clients (v0.1.2 §2).
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS FILE EXISTS
|
|
5
|
+
*
|
|
6
|
+
* The v1 client built every endpoint with the two-argument URL constructor:
|
|
7
|
+
*
|
|
8
|
+
* new URL('/v1/stream', rabbitUrl)
|
|
9
|
+
*
|
|
10
|
+
* That call treats the first argument as an absolute path, so it *discards*
|
|
11
|
+
* whatever path `rabbitUrl` had:
|
|
12
|
+
*
|
|
13
|
+
* new URL('/v1/stream', 'https://h/w2m') -> 'https://h/v1/stream'
|
|
14
|
+
* ^^^^ /w2m gone
|
|
15
|
+
*
|
|
16
|
+
* Every request 404s as soon as the relay sits behind a sub-path, which is the
|
|
17
|
+
* normal situation for a Cloudflare Tunnel / ngrok / nginx deployment. The same
|
|
18
|
+
* trap applies to `path.posix.join('https://h', '/v1')`, which collapses the
|
|
19
|
+
* `//` in the scheme into a single slash.
|
|
20
|
+
*
|
|
21
|
+
* The whole fix is therefore: normalise the base once, then concatenate. A
|
|
22
|
+
* base is an opaque prefix, not something to be re-parsed per request.
|
|
23
|
+
*
|
|
24
|
+
* `rabbitUrl` rules (v0.1.2 §2):
|
|
25
|
+
* * must parse as a URL and use http/https;
|
|
26
|
+
* * must NOT carry a query string or a fragment (a prefix belongs in the
|
|
27
|
+
* path: `https://host/w2m`, not `https://host?prefix=/w2m`);
|
|
28
|
+
* * trailing slashes are stripped, so `https://host/w2m/` and
|
|
29
|
+
* `https://host/w2m` are the same base;
|
|
30
|
+
* * a sub-path is preserved verbatim.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** Error code attached to every rejection from this module. */
|
|
34
|
+
export const RABBIT_URL_INVALID = 'RABBIT_URL_INVALID';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Build a rejection that always names `rabbitUrl` and the reason.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} message
|
|
40
|
+
* @returns {TypeError}
|
|
41
|
+
*/
|
|
42
|
+
function invalid(message) {
|
|
43
|
+
const error = new TypeError(message);
|
|
44
|
+
error.code = RABBIT_URL_INVALID;
|
|
45
|
+
return error;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Validate and normalise a `rabbitUrl` into a base address.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} rabbitUrl e.g. `https://w2m.example.com/w2m/`
|
|
52
|
+
* @returns {string} Base without any trailing slash, e.g. `https://w2m.example.com/w2m`
|
|
53
|
+
* @throws {TypeError} with `code === 'RABBIT_URL_INVALID'` when unusable.
|
|
54
|
+
*/
|
|
55
|
+
export function resolveBaseUrl(rabbitUrl) {
|
|
56
|
+
if (typeof rabbitUrl !== 'string' || rabbitUrl.trim() === '') {
|
|
57
|
+
throw invalid(
|
|
58
|
+
'rabbitUrl is required: pass a base address such as http://127.0.0.1:8787 or https://w2m.example.com/w2m',
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
const raw = rabbitUrl.trim();
|
|
62
|
+
|
|
63
|
+
// Single-argument parse: this is URL *validation*, not path resolution.
|
|
64
|
+
let parsed;
|
|
65
|
+
try {
|
|
66
|
+
parsed = new URL(raw);
|
|
67
|
+
} catch {
|
|
68
|
+
throw invalid(`rabbitUrl is not a valid URL: ${JSON.stringify(raw)}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
72
|
+
throw invalid(
|
|
73
|
+
`rabbitUrl must use http or https, got ${parsed.protocol.replace(':', '')}:// in ${JSON.stringify(raw)}`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// A literal `?`/`#` anywhere in a base address is a mistake, including the
|
|
78
|
+
// degenerate bare `https://host/path?` that `parsed.search` reports as empty.
|
|
79
|
+
if (parsed.search !== '' || raw.includes('?')) {
|
|
80
|
+
throw invalid(
|
|
81
|
+
`rabbitUrl must not contain a query string: ${JSON.stringify(raw)}; put the deployment prefix in the path instead, e.g. https://host/w2m`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
if (parsed.hash !== '' || raw.includes('#')) {
|
|
85
|
+
throw invalid(
|
|
86
|
+
`rabbitUrl must not contain a fragment: ${JSON.stringify(raw)}; put the deployment prefix in the path instead, e.g. https://host/w2m`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Strip every trailing slash. `new URL('http://h')` yields pathname '/', so
|
|
91
|
+
// this also collapses a bare origin back to `http://h`.
|
|
92
|
+
const path = parsed.pathname.replace(/\/+$/, '');
|
|
93
|
+
return `${parsed.protocol}//${parsed.host}${path}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Join a normalised base with an endpoint path by plain concatenation.
|
|
98
|
+
*
|
|
99
|
+
* The base is never re-parsed, so a sub-path survives; the path is never
|
|
100
|
+
* re-parsed either, so `//` in the scheme survives.
|
|
101
|
+
*
|
|
102
|
+
* @param {string} base Result of {@link resolveBaseUrl}.
|
|
103
|
+
* @param {string} path Endpoint path, with or without a leading slash.
|
|
104
|
+
* @returns {string}
|
|
105
|
+
*/
|
|
106
|
+
export function joinUrl(base, path) {
|
|
107
|
+
if (typeof base !== 'string' || base === '') {
|
|
108
|
+
throw new TypeError('joinUrl: base must be a non-empty string (use resolveBaseUrl first)');
|
|
109
|
+
}
|
|
110
|
+
// Tolerate a base that still carries a trailing slash, so this helper cannot
|
|
111
|
+
// produce `//v1/...` for a caller that skipped normalisation.
|
|
112
|
+
const cleanBase = base.replace(/\/+$/, '');
|
|
113
|
+
const suffix = path === null || path === undefined ? '' : String(path);
|
|
114
|
+
if (suffix === '') return cleanBase;
|
|
115
|
+
return cleanBase + (suffix.startsWith('/') ? suffix : `/${suffix}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Convenience: normalise a base and build one endpoint URL from it.
|
|
120
|
+
*
|
|
121
|
+
* @param {string} rabbitUrl
|
|
122
|
+
* @param {string} path
|
|
123
|
+
* @returns {string}
|
|
124
|
+
*/
|
|
125
|
+
export function endpointUrl(rabbitUrl, path) {
|
|
126
|
+
return joinUrl(resolveBaseUrl(rabbitUrl), path);
|
|
127
|
+
}
|