@twinsearth/w2m-dsh-plugin 0.0.0-stage → 0.4.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1309 -0
- package/LICENSE +21 -0
- package/PROTOCOL-v0.1.2.md +165 -0
- package/PROTOCOL-v0.3.0.md +454 -0
- package/PROTOCOL-v0.3.9.md +207 -0
- package/PROTOCOL-v0.4.0.md +255 -0
- package/PROTOCOL.md +339 -0
- package/README.md +665 -2
- package/bin/w2m-localside.mjs +303 -0
- package/bin/w2m-rabbit.mjs +396 -0
- package/bin/w2m-stun.mjs +231 -0
- package/cordis.patch.yml +94 -0
- package/deploy/caddy/Caddyfile.example +118 -0
- package/deploy/docker/Dockerfile +144 -0
- package/deploy/docker/README.md +338 -0
- package/deploy/docker/docker-compose.yml +179 -0
- package/deploy/nginx/w2m.conf.example +196 -0
- package/deploy/shared-server/README.md +118 -0
- package/deploy/shared-server/install.sh +255 -0
- package/deploy/systemd/w2m-rabbit.service +150 -0
- package/deploy/systemd/w2m-stun.service +69 -0
- package/deploy/windows/README.md +115 -0
- package/deploy/windows/install-service.ps1 +195 -0
- package/deploy/windows/w2m-agent-supervisor.mjs +205 -0
- package/deploy/windows/winsw/w2m-localside.xml +50 -0
- package/docs/DEPLOY.md +902 -0
- package/docs/LINT-AUDIT.md +492 -0
- package/docs/PIPELINE-DESIGN.md +95 -0
- package/docs/TROUBLESHOOTING.md +560 -0
- package/docs/UI-CARDS.md +81 -0
- package/lib/index.js +24 -0
- package/lib/tools.js +11 -0
- package/package.json +133 -4
- package/src/agent/agent.mjs +3317 -0
- package/src/agent/allowed-commands.mjs +116 -0
- package/src/agent/caps.mjs +437 -0
- package/src/agent/exec.mjs +356 -0
- package/src/agent/git.mjs +616 -0
- package/src/agent/identity.mjs +253 -0
- package/src/agent/p2p-node.mjs +1243 -0
- package/src/agent/p2p.mjs +605 -0
- package/src/agent/resolve.mjs +148 -0
- package/src/agent/spool.mjs +252 -0
- package/src/agent/stun.mjs +397 -0
- package/src/agent/url.mjs +127 -0
- package/src/plugin/auto-update.mjs +574 -0
- package/src/plugin/schedule.mjs +452 -0
- package/src/plugin/shared-config.mjs +264 -0
- package/src/plugin/tools.mjs +3891 -0
- package/src/plugin/update-install.mjs +764 -0
- package/src/plugin/update-source.mjs +686 -0
- package/src/relay/metrics.mjs +284 -0
- package/src/relay/peers.mjs +180 -0
- package/src/relay/persistence.mjs +207 -0
- package/src/relay/report.mjs +591 -0
- package/src/relay/server.mjs +1187 -0
- package/src/relay/state.mjs +2004 -0
- package/src/relay/stun-server.mjs +733 -0
- package/src/signing.mjs +359 -0
- package/src/util/cli.mjs +70 -0
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* P2P transport for W2M: UDP hole punching plus a reliable, ordered channel on top.
|
|
3
|
+
*
|
|
4
|
+
* The relay exists because two machines behind NAT cannot dial each other. This
|
|
5
|
+
* module is the alternative: both sides send to the other's *reflexive* address at
|
|
6
|
+
* the same time, which opens both mappings, and the task envelope and its result
|
|
7
|
+
* then travel directly instead of through the relay.
|
|
8
|
+
*
|
|
9
|
+
* What is actually guaranteed, and what is not:
|
|
10
|
+
*
|
|
11
|
+
* * **Reliable, and message boundaries are preserved.** Fragments are individually
|
|
12
|
+
* acknowledged and retransmitted; a message is delivered only once every fragment
|
|
13
|
+
* has arrived. This is a from-scratch protocol, so the guarantees are exactly the
|
|
14
|
+
* ones implemented -- there is no TCP underneath to fall back on. Messages sent
|
|
15
|
+
* **one at a time** arrive in send order; concurrent `send()` calls may complete
|
|
16
|
+
* in either order, which is why the W2M integration awaits each send.
|
|
17
|
+
* * **Not a general transport.** One session, one peer, no congestion control
|
|
18
|
+
* beyond a fixed window. That is the right size for "deliver one task offer and
|
|
19
|
+
* return one result"; it is not a file-transfer protocol.
|
|
20
|
+
* * **Punching is not guaranteed to succeed.** A symmetric NAT gives a different
|
|
21
|
+
* mapped port per destination, so the address the peer was told about is not the
|
|
22
|
+
* address the peer's packets will come from. `punch()` reports failure with the
|
|
23
|
+
* attempts it made, and the caller falls back to the relay -- explicitly, never
|
|
24
|
+
* silently.
|
|
25
|
+
*
|
|
26
|
+
* Framing (all integers big-endian):
|
|
27
|
+
*
|
|
28
|
+
* u8 kind
|
|
29
|
+
* u32 session identifies this punch; rejects strays from an old one
|
|
30
|
+
* ... kind-specific body
|
|
31
|
+
*
|
|
32
|
+
* HELLO 1 — "I am here, are you?" repeated to every candidate while punching
|
|
33
|
+
* HELLO_ACK 2 — "I am here too", which is what confirms the path is bidirectional
|
|
34
|
+
* DATA 3 — u32 messageId, u32 fragmentIndex, u32 fragmentTotal, payload
|
|
35
|
+
* ACK 4 — u32 messageId, u32 fragmentIndex (selective, per fragment)
|
|
36
|
+
* DONE 5 — u32 messageId (receiver has every fragment)
|
|
37
|
+
* PING 6 — keeps the mapping open between messages
|
|
38
|
+
* PONG 7 — the answer to PING, and never a PING
|
|
39
|
+
* BYE 8 — the peer is closing
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { EventEmitter } from 'node:events';
|
|
43
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
44
|
+
|
|
45
|
+
/** Frame kinds. Exported so tests and callers can name them instead of using magic numbers. */
|
|
46
|
+
export const KIND = {
|
|
47
|
+
HELLO: 1,
|
|
48
|
+
HELLO_ACK: 2,
|
|
49
|
+
DATA: 3,
|
|
50
|
+
ACK: 4,
|
|
51
|
+
DONE: 5,
|
|
52
|
+
PING: 6,
|
|
53
|
+
PONG: 7,
|
|
54
|
+
BYE: 8,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** Protocol version, so a future framing change is detectable rather than corrupting. */
|
|
58
|
+
export const P2P_VERSION = 1;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Payload bytes per fragment.
|
|
62
|
+
*
|
|
63
|
+
* 1160 keeps a frame under 1200 bytes including the 13-byte header, which passes a
|
|
64
|
+
* 1280-byte IPv6 minimum MTU and the 1500-byte Ethernet MTU with room for a tunnel
|
|
65
|
+
* (WireGuard adds 60). A larger fragment is faster right up until it is silently
|
|
66
|
+
* dropped by a path that will not fragment.
|
|
67
|
+
*/
|
|
68
|
+
export const MAX_FRAGMENT_PAYLOAD = 1160;
|
|
69
|
+
|
|
70
|
+
/** Fragments in flight before waiting for acknowledgements. */
|
|
71
|
+
export const DEFAULT_WINDOW = 32;
|
|
72
|
+
|
|
73
|
+
/** First retransmit delay, and the ceiling it doubles up to. */
|
|
74
|
+
export const DEFAULT_RTO_MS = 300;
|
|
75
|
+
export const MAX_RTO_MS = 3000;
|
|
76
|
+
|
|
77
|
+
/** Retransmits per fragment before the message is declared undeliverable. */
|
|
78
|
+
export const DEFAULT_MAX_ATTEMPTS = 8;
|
|
79
|
+
|
|
80
|
+
/** Largest message accepted, so a hostile length cannot make us allocate without bound. */
|
|
81
|
+
export const MAX_MESSAGE_BYTES = 8 * 1024 * 1024;
|
|
82
|
+
|
|
83
|
+
const HEADER_BYTES = 5;
|
|
84
|
+
|
|
85
|
+
/** @param {number} kind @param {number} session @param {Buffer} [body] */
|
|
86
|
+
function frame(kind, session, body = Buffer.alloc(0)) {
|
|
87
|
+
const out = Buffer.alloc(HEADER_BYTES + body.length);
|
|
88
|
+
out.writeUInt8(kind, 0);
|
|
89
|
+
out.writeUInt32BE(session >>> 0, 1);
|
|
90
|
+
body.copy(out, HEADER_BYTES);
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** @param {Buffer} datagram */
|
|
95
|
+
function parseFrame(datagram) {
|
|
96
|
+
if (datagram.length < HEADER_BYTES) return null;
|
|
97
|
+
const kind = datagram.readUInt8(0);
|
|
98
|
+
const session = datagram.readUInt32BE(1);
|
|
99
|
+
return { kind, session, body: datagram.subarray(HEADER_BYTES) };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------------------------
|
|
103
|
+
// Punching
|
|
104
|
+
// ---------------------------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Derive a session id both peers can compute without another round trip.
|
|
108
|
+
*
|
|
109
|
+
* This exists because the session id is what makes a punch *one* punch: frames
|
|
110
|
+
* carrying a different id belong to a different attempt and must be ignored. Two
|
|
111
|
+
* peers that each invented their own id would each reject the other's HELLO, and
|
|
112
|
+
* the punch would time out while both sides insist they were sending.
|
|
113
|
+
*
|
|
114
|
+
* Callers pass inputs both sides already share -- the task id and the two machine
|
|
115
|
+
* ids sorted -- so no extra signalling is needed and the result is deterministic.
|
|
116
|
+
*
|
|
117
|
+
* @param {...(string|number)} parts
|
|
118
|
+
* @returns {number} A non-zero 32-bit session id.
|
|
119
|
+
*/
|
|
120
|
+
export function deriveSession(...parts) {
|
|
121
|
+
const hash = createHash('sha256').update(parts.map(String).join('\u0000')).digest();
|
|
122
|
+
const value = hash.readUInt32BE(0);
|
|
123
|
+
return value === 0 ? 1 : value;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Try to open a bidirectional path to one of `remote`'s candidate addresses.
|
|
128
|
+
*
|
|
129
|
+
* Both peers run this at the same time. Each sends HELLO to every candidate; the
|
|
130
|
+
* first peer to get through causes the other's NAT to accept, because the other's
|
|
131
|
+
* outbound HELLO already opened its own mapping in that direction.
|
|
132
|
+
*
|
|
133
|
+
* @param {object} options
|
|
134
|
+
* @param {import('node:dgram').Socket} options.socket Already bound, and the same socket STUN used.
|
|
135
|
+
* @param {Array<{address:string, port:number}>} options.remote
|
|
136
|
+
* @param {number} [options.session] Agreed id, usually from {@link deriveSession}.
|
|
137
|
+
* @param {boolean} [options.allowSessionAdoption] Adopt the initiator's session id
|
|
138
|
+
* instead of requiring one. Only valid for a peer that expects to be the
|
|
139
|
+
* responder; two peers that both adopt end up on different ids.
|
|
140
|
+
* @param {number} [options.timeoutMs]
|
|
141
|
+
* @param {number} [options.intervalMs]
|
|
142
|
+
* @returns {Promise<{ok:boolean, peer:{address:string,port:number}|null, session:number,
|
|
143
|
+
* attempts:number, rttMs:number|null, error:string|null}>}
|
|
144
|
+
*/
|
|
145
|
+
export async function punch(options) {
|
|
146
|
+
const { socket } = options;
|
|
147
|
+
if (!socket) throw new TypeError('punch: a bound UDP socket is required');
|
|
148
|
+
|
|
149
|
+
const explicitSession = options.session;
|
|
150
|
+
if (explicitSession === undefined && options.allowSessionAdoption !== true) {
|
|
151
|
+
// Refusing here rather than generating one is deliberate. If both peers generate
|
|
152
|
+
// their own, each discards the other's HELLO and the punch fails with a timeout
|
|
153
|
+
// that looks like a NAT problem -- the most expensive possible misdiagnosis.
|
|
154
|
+
throw new TypeError(
|
|
155
|
+
'punch: `session` is required (see deriveSession), or pass allowSessionAdoption: true ' +
|
|
156
|
+
'to accept the initiator\'s id when this peer is the responder',
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const remote = (options.remote ?? []).filter(
|
|
161
|
+
(c) => c && typeof c.address === 'string' && Number.isInteger(c.port) && c.port > 0,
|
|
162
|
+
);
|
|
163
|
+
if (remote.length === 0) {
|
|
164
|
+
return { ok: false, peer: null, session: explicitSession ?? 0, attempts: 0, rttMs: null, error: 'P2P_NO_CANDIDATES' };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const ownSession = explicitSession ?? randomBytes(4).readUInt32BE(0);
|
|
168
|
+
const timeoutMs = options.timeoutMs ?? 5000;
|
|
169
|
+
const intervalMs = options.intervalMs ?? 250;
|
|
170
|
+
const deadline = Date.now() + timeoutMs;
|
|
171
|
+
const startedAt = process.hrtime.bigint();
|
|
172
|
+
|
|
173
|
+
const hello = frame(KIND.HELLO, ownSession);
|
|
174
|
+
|
|
175
|
+
return new Promise((resolve) => {
|
|
176
|
+
let attempts = 0;
|
|
177
|
+
let timer = null;
|
|
178
|
+
let settled = false;
|
|
179
|
+
let agreed = ownSession;
|
|
180
|
+
|
|
181
|
+
const finish = (result) => {
|
|
182
|
+
if (settled) return;
|
|
183
|
+
settled = true;
|
|
184
|
+
clearTimeout(timer);
|
|
185
|
+
socket.removeListener('message', onMessage);
|
|
186
|
+
resolve(result);
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const onMessage = (datagram, rinfo) => {
|
|
190
|
+
const parsed = parseFrame(datagram);
|
|
191
|
+
if (!parsed) return;
|
|
192
|
+
|
|
193
|
+
if (parsed.kind === KIND.HELLO) {
|
|
194
|
+
if (explicitSession !== undefined && parsed.session !== explicitSession) return;
|
|
195
|
+
// Adopt the initiator's id when we have none of our own, and echo it back so
|
|
196
|
+
// the initiator sees its own id and accepts the answer.
|
|
197
|
+
agreed = parsed.session;
|
|
198
|
+
socket.send(frame(KIND.HELLO_ACK, agreed), rinfo.port, rinfo.address, () => {});
|
|
199
|
+
const rttMs = Number(process.hrtime.bigint() - startedAt) / 1e6;
|
|
200
|
+
finish({
|
|
201
|
+
ok: true,
|
|
202
|
+
peer: { address: rinfo.address, port: rinfo.port },
|
|
203
|
+
session: agreed,
|
|
204
|
+
attempts,
|
|
205
|
+
rttMs: Math.round(rttMs * 100) / 100,
|
|
206
|
+
error: null,
|
|
207
|
+
});
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (parsed.kind === KIND.HELLO_ACK) {
|
|
212
|
+
if (parsed.session !== agreed) return;
|
|
213
|
+
const rttMs = Number(process.hrtime.bigint() - startedAt) / 1e6;
|
|
214
|
+
finish({
|
|
215
|
+
ok: true,
|
|
216
|
+
peer: { address: rinfo.address, port: rinfo.port },
|
|
217
|
+
session: agreed,
|
|
218
|
+
attempts,
|
|
219
|
+
rttMs: Math.round(rttMs * 100) / 100,
|
|
220
|
+
error: null,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const tick = () => {
|
|
226
|
+
if (settled) return;
|
|
227
|
+
if (Date.now() >= deadline) {
|
|
228
|
+
finish({
|
|
229
|
+
ok: false,
|
|
230
|
+
peer: null,
|
|
231
|
+
session: agreed,
|
|
232
|
+
attempts,
|
|
233
|
+
rttMs: null,
|
|
234
|
+
error: `P2P_PUNCH_TIMEOUT: no answer from ${remote.length} candidate(s) in ${timeoutMs}ms`,
|
|
235
|
+
});
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
attempts += 1;
|
|
239
|
+
for (const candidate of remote) {
|
|
240
|
+
socket.send(hello, candidate.port, candidate.address, () => {});
|
|
241
|
+
}
|
|
242
|
+
timer = setTimeout(tick, intervalMs);
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
socket.on('message', onMessage);
|
|
246
|
+
tick();
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ---------------------------------------------------------------------------------------------
|
|
251
|
+
// Reliable channel
|
|
252
|
+
// ---------------------------------------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* A reliable, ordered message channel over a punched UDP path.
|
|
256
|
+
*
|
|
257
|
+
* Emits `message` (Buffer) for each delivered message, `close`, and `error`.
|
|
258
|
+
*/
|
|
259
|
+
export class P2PChannel extends EventEmitter {
|
|
260
|
+
/**
|
|
261
|
+
* @param {object} options
|
|
262
|
+
* @param {import('node:dgram').Socket} options.socket
|
|
263
|
+
* @param {{address:string, port:number}} options.peer
|
|
264
|
+
* @param {number} options.session
|
|
265
|
+
* @param {object} [options.tuning]
|
|
266
|
+
*/
|
|
267
|
+
constructor(options) {
|
|
268
|
+
super();
|
|
269
|
+
this.socket = options.socket;
|
|
270
|
+
this.peer = options.peer;
|
|
271
|
+
this.session = options.session;
|
|
272
|
+
this.tuning = {
|
|
273
|
+
window: options.tuning?.window ?? DEFAULT_WINDOW,
|
|
274
|
+
rtoMs: options.tuning?.rtoMs ?? DEFAULT_RTO_MS,
|
|
275
|
+
maxAttempts: options.tuning?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
/** messageId -> {fragments: Buffer[], received: Set<number>, total: number, timer} */
|
|
279
|
+
this.incoming = new Map();
|
|
280
|
+
/** messageId -> {fragments: Buffer[], acked: Set<number>, resolve, reject, attempts: Map} */
|
|
281
|
+
this.outgoing = new Map();
|
|
282
|
+
this.nextMessageId = 1;
|
|
283
|
+
this.closed = false;
|
|
284
|
+
|
|
285
|
+
this.onDatagram = this.onDatagram.bind(this);
|
|
286
|
+
this.socket.on('message', this.onDatagram);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** @param {Buffer} datagram */
|
|
290
|
+
onDatagram(datagram) {
|
|
291
|
+
const parsed = parseFrame(datagram);
|
|
292
|
+
if (!parsed || parsed.session !== this.session) return;
|
|
293
|
+
// A datagram from anywhere other than the punched peer is not part of this session.
|
|
294
|
+
// (The check is on the address we already validated; UDP source addresses are
|
|
295
|
+
// spoofable, which is why the session id is random and the payload is not trusted
|
|
296
|
+
// for anything but the task it is correlated with.)
|
|
297
|
+
|
|
298
|
+
if (parsed.kind === KIND.DATA) this.onData(parsed.body);
|
|
299
|
+
else if (parsed.kind === KIND.ACK) this.onAck(parsed.body);
|
|
300
|
+
// A PING must be answered with PONG, never with PING. Echoing the same kind back
|
|
301
|
+
// makes two peers bounce it between them forever, and the loop looks like healthy
|
|
302
|
+
// traffic while it saturates the mapping it was meant to keep alive.
|
|
303
|
+
else if (parsed.kind === KIND.PING) this.sendFrame(KIND.PONG, Buffer.alloc(0));
|
|
304
|
+
else if (parsed.kind === KIND.PONG) this.lastPongAtMs = Date.now();
|
|
305
|
+
else if (parsed.kind === KIND.BYE) this.close('peer-closed');
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** @param {Buffer} body */
|
|
309
|
+
onData(body) {
|
|
310
|
+
if (body.length < 12) return;
|
|
311
|
+
const messageId = body.readUInt32BE(0);
|
|
312
|
+
const index = body.readUInt32BE(4);
|
|
313
|
+
const total = body.readUInt32BE(8);
|
|
314
|
+
if (total === 0 || total > 100000 || index >= total) return;
|
|
315
|
+
const payload = body.subarray(12);
|
|
316
|
+
|
|
317
|
+
let entry = this.incoming.get(messageId);
|
|
318
|
+
if (!entry) {
|
|
319
|
+
entry = { fragments: new Array(total).fill(null), received: new Set(), total };
|
|
320
|
+
this.incoming.set(messageId, entry);
|
|
321
|
+
}
|
|
322
|
+
if (!entry.received.has(index)) {
|
|
323
|
+
entry.fragments[index] = Buffer.from(payload);
|
|
324
|
+
entry.received.add(index);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Acknowledge every fragment, including duplicates: a duplicate means our ACK
|
|
328
|
+
// was lost, and re-acking is cheaper than a retransmit storm.
|
|
329
|
+
const ackBody = Buffer.alloc(8);
|
|
330
|
+
ackBody.writeUInt32BE(messageId, 0);
|
|
331
|
+
ackBody.writeUInt32BE(index, 4);
|
|
332
|
+
this.sendFrame(KIND.ACK, ackBody);
|
|
333
|
+
|
|
334
|
+
if (entry.received.size === entry.total) {
|
|
335
|
+
this.incoming.delete(messageId);
|
|
336
|
+
const assembled = Buffer.concat(entry.fragments, entry.fragments.reduce((n, f) => n + f.length, 0));
|
|
337
|
+
const doneBody = Buffer.alloc(4);
|
|
338
|
+
doneBody.writeUInt32BE(messageId, 0);
|
|
339
|
+
this.sendFrame(KIND.DONE, doneBody);
|
|
340
|
+
this.emit('message', assembled);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** @param {Buffer} body */
|
|
345
|
+
onAck(body) {
|
|
346
|
+
if (body.length < 8) return;
|
|
347
|
+
const messageId = body.readUInt32BE(0);
|
|
348
|
+
const index = body.readUInt32BE(4);
|
|
349
|
+
const entry = this.outgoing.get(messageId);
|
|
350
|
+
if (!entry) return;
|
|
351
|
+
entry.acked.add(index);
|
|
352
|
+
if (entry.acked.size === entry.fragments.length) {
|
|
353
|
+
this.outgoing.delete(messageId);
|
|
354
|
+
clearTimeout(entry.timer);
|
|
355
|
+
entry.resolve();
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** @param {number} kind @param {Buffer} body */
|
|
360
|
+
sendFrame(kind, body) {
|
|
361
|
+
if (this.closed) return;
|
|
362
|
+
this.socket.send(frame(kind, this.session, body), this.peer.port, this.peer.address, () => {});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Send one message reliably.
|
|
367
|
+
*
|
|
368
|
+
* Resolves when the peer has acknowledged every fragment. Rejects only after
|
|
369
|
+
* `maxAttempts` retransmits of some fragment, with the fragment index in the
|
|
370
|
+
* message -- "the peer went away" and "the path broke on one fragment" need
|
|
371
|
+
* different responses from a caller.
|
|
372
|
+
*
|
|
373
|
+
* @param {Buffer|string} payload
|
|
374
|
+
* @returns {Promise<void>}
|
|
375
|
+
*/
|
|
376
|
+
send(payload) {
|
|
377
|
+
const buf = Buffer.isBuffer(payload) ? payload : Buffer.from(String(payload), 'utf8');
|
|
378
|
+
if (buf.length > MAX_MESSAGE_BYTES) {
|
|
379
|
+
return Promise.reject(new Error(`P2P_MESSAGE_TOO_LARGE: ${buf.length} > ${MAX_MESSAGE_BYTES}`));
|
|
380
|
+
}
|
|
381
|
+
if (this.closed) return Promise.reject(new Error('P2P_CHANNEL_CLOSED'));
|
|
382
|
+
|
|
383
|
+
const messageId = this.nextMessageId;
|
|
384
|
+
this.nextMessageId = (this.nextMessageId % 0xffffffff) + 1;
|
|
385
|
+
|
|
386
|
+
const fragments = [];
|
|
387
|
+
for (let offset = 0; offset < Math.max(buf.length, 1); offset += MAX_FRAGMENT_PAYLOAD) {
|
|
388
|
+
fragments.push(buf.subarray(offset, Math.min(offset + MAX_FRAGMENT_PAYLOAD, buf.length)));
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return new Promise((resolve, reject) => {
|
|
392
|
+
const entry = {
|
|
393
|
+
fragments,
|
|
394
|
+
acked: new Set(),
|
|
395
|
+
resolve,
|
|
396
|
+
reject,
|
|
397
|
+
attempts: new Map(),
|
|
398
|
+
};
|
|
399
|
+
this.outgoing.set(messageId, entry);
|
|
400
|
+
|
|
401
|
+
const pump = () => {
|
|
402
|
+
if (this.closed) return;
|
|
403
|
+
const pending = [];
|
|
404
|
+
for (let i = 0; i < fragments.length; i += 1) {
|
|
405
|
+
if (entry.acked.has(i)) continue;
|
|
406
|
+
pending.push(i);
|
|
407
|
+
if (pending.length >= this.tuning.window) break;
|
|
408
|
+
}
|
|
409
|
+
if (pending.length === 0) return;
|
|
410
|
+
|
|
411
|
+
for (const index of pending) {
|
|
412
|
+
const attempt = (entry.attempts.get(index) ?? 0) + 1;
|
|
413
|
+
entry.attempts.set(index, attempt);
|
|
414
|
+
if (attempt > this.tuning.maxAttempts) {
|
|
415
|
+
this.outgoing.delete(messageId);
|
|
416
|
+
reject(
|
|
417
|
+
new Error(
|
|
418
|
+
`P2P_FRAGMENT_UNACKED: fragment ${index}/${fragments.length} of message ${messageId} ` +
|
|
419
|
+
`after ${this.tuning.maxAttempts} attempts`,
|
|
420
|
+
),
|
|
421
|
+
);
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
const body = Buffer.alloc(12 + fragments[index].length);
|
|
425
|
+
body.writeUInt32BE(messageId, 0);
|
|
426
|
+
body.writeUInt32BE(index, 4);
|
|
427
|
+
body.writeUInt32BE(fragments.length, 8);
|
|
428
|
+
fragments[index].copy(body, 12);
|
|
429
|
+
this.sendFrame(KIND.DATA, body);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const worst = Math.max(...pending.map((i) => entry.attempts.get(i) ?? 1));
|
|
433
|
+
const delay = Math.min(this.tuning.rtoMs * 2 ** (worst - 1), MAX_RTO_MS);
|
|
434
|
+
// One timer per message, not one per channel: a shared field means a second
|
|
435
|
+
// concurrent send silently cancels the first one's retransmission schedule.
|
|
436
|
+
clearTimeout(entry.timer);
|
|
437
|
+
entry.timer = setTimeout(() => {
|
|
438
|
+
if (this.outgoing.has(messageId)) pump();
|
|
439
|
+
}, delay);
|
|
440
|
+
entry.timer.unref?.();
|
|
441
|
+
};
|
|
442
|
+
|
|
443
|
+
pump();
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** Stop the channel. The socket is NOT closed: the caller owns it. */
|
|
448
|
+
close(reason = 'closed') {
|
|
449
|
+
if (this.closed) return;
|
|
450
|
+
this.closed = true;
|
|
451
|
+
this.socket.removeListener('message', this.onDatagram);
|
|
452
|
+
for (const entry of this.outgoing.values()) {
|
|
453
|
+
clearTimeout(entry.timer);
|
|
454
|
+
entry.reject(new Error(`P2P_CHANNEL_CLOSED: ${reason}`));
|
|
455
|
+
}
|
|
456
|
+
this.outgoing.clear();
|
|
457
|
+
this.incoming.clear();
|
|
458
|
+
if (reason !== 'peer-closed') {
|
|
459
|
+
try {
|
|
460
|
+
this.sendFrame(KIND.BYE, Buffer.alloc(0));
|
|
461
|
+
} catch {
|
|
462
|
+
/* the path is already gone */
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
this.emit('close', reason);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* The responder's half of a punch: wait for a HELLO, answer it, hand back a channel.
|
|
471
|
+
*
|
|
472
|
+
* This is the mirror of {@link punch}, and it exists because the two ends of a punch do
|
|
473
|
+
* genuinely different things. The initiator knows the session and every candidate and
|
|
474
|
+
* has to keep sending; the responder knows nothing, waits, and learns both the session
|
|
475
|
+
* and the address from the HELLO that reaches it. That asymmetry is what lets a peer be
|
|
476
|
+
* dialled without any prior arrangement — and it is why adoption (`allowSessionAdoption`)
|
|
477
|
+
* exists in `punch()`: a responder that invented its own id would discard the very
|
|
478
|
+
* datagram it is waiting for.
|
|
479
|
+
*
|
|
480
|
+
* Two decisions worth stating:
|
|
481
|
+
*
|
|
482
|
+
* * **A claimed session is re-acknowledged, never adopted.** When the HELLO's session
|
|
483
|
+
* already has a live channel, the answer is another HELLO_ACK and no second channel:
|
|
484
|
+
* the initiator retransmits HELLOs while punching, so a lost ACK has to be
|
|
485
|
+
* answerable again, but two channels on one session would each assemble and deliver
|
|
486
|
+
* every payload and the peer would see each message twice. The caller owns that
|
|
487
|
+
* `claimed` set, because a claim has to outlive one call to this function.
|
|
488
|
+
* * **`rttMs` is `null`.** The responder never sent anything before the HELLO arrived,
|
|
489
|
+
* so there is no round trip to measure. Reporting the time spent waiting as an RTT
|
|
490
|
+
* would be a fabricated number in a field named for a measurement.
|
|
491
|
+
*
|
|
492
|
+
* @param {object} options
|
|
493
|
+
* @param {import('node:dgram').Socket} options.socket Already bound; the same socket the
|
|
494
|
+
* punch was aimed at, and the same one a channel will use.
|
|
495
|
+
* @param {number} [options.timeoutMs] How long to wait before giving up.
|
|
496
|
+
* @param {Set<number>|Map<number, unknown>} [options.claimed] Sessions that already have
|
|
497
|
+
* a live channel. Read, never written.
|
|
498
|
+
* @param {number} [options.intervalMs] Floor between re-acknowledgements of the same
|
|
499
|
+
* claimed session within one call, so a peer that sprays HELLOs cannot make the
|
|
500
|
+
* responder answer every single one. The default matches `punch()`'s send
|
|
501
|
+
* interval, so a retransmission is always answered; `0` disables the floor.
|
|
502
|
+
* @returns {Promise<{ok:boolean, channel:P2PChannel|null, peer:{address:string,port:number}|null,
|
|
503
|
+
* session:number, rttMs:number|null, error:string|null}>}
|
|
504
|
+
*/
|
|
505
|
+
export async function accept(options) {
|
|
506
|
+
const { socket } = options;
|
|
507
|
+
if (!socket) throw new TypeError('accept: a bound UDP socket is required');
|
|
508
|
+
|
|
509
|
+
const timeoutMs = options.timeoutMs ?? 5000;
|
|
510
|
+
const intervalMs = options.intervalMs ?? 250;
|
|
511
|
+
const claimed = options.claimed ?? null;
|
|
512
|
+
|
|
513
|
+
return new Promise((resolve) => {
|
|
514
|
+
let settled = false;
|
|
515
|
+
/** session -> last re-ack timestamp, so the floor is per session, not per socket. */
|
|
516
|
+
const reackedAt = new Map();
|
|
517
|
+
|
|
518
|
+
const finish = (result) => {
|
|
519
|
+
if (settled) return;
|
|
520
|
+
settled = true;
|
|
521
|
+
clearTimeout(timer);
|
|
522
|
+
socket.removeListener('message', onMessage);
|
|
523
|
+
socket.removeListener('close', onSocketClose);
|
|
524
|
+
resolve(result);
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
const timer = setTimeout(() => {
|
|
528
|
+
finish({
|
|
529
|
+
ok: false,
|
|
530
|
+
channel: null,
|
|
531
|
+
peer: null,
|
|
532
|
+
session: 0,
|
|
533
|
+
rttMs: null,
|
|
534
|
+
error: `P2P_ACCEPT_TIMEOUT: no HELLO within ${timeoutMs}ms`,
|
|
535
|
+
});
|
|
536
|
+
}, timeoutMs);
|
|
537
|
+
|
|
538
|
+
// A wait on a socket that goes away must end now, not at the end of the timeout:
|
|
539
|
+
// this is what makes a node's `close()` a barrier instead of a five-second stall.
|
|
540
|
+
const onSocketClose = () => {
|
|
541
|
+
finish({
|
|
542
|
+
ok: false,
|
|
543
|
+
channel: null,
|
|
544
|
+
peer: null,
|
|
545
|
+
session: 0,
|
|
546
|
+
rttMs: null,
|
|
547
|
+
error: 'P2P_SOCKET_CLOSED: the socket went away while waiting for a HELLO',
|
|
548
|
+
});
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
const onMessage = (datagram, rinfo) => {
|
|
552
|
+
const parsed = parseFrame(datagram);
|
|
553
|
+
// Only a HELLO is this function's business. DATA/ACK/PING/PONG belong to whichever
|
|
554
|
+
// channels are already listening; a second consumer of them would be a second
|
|
555
|
+
// delivery, which is the bug the `claimed` set exists to prevent.
|
|
556
|
+
if (!parsed || parsed.kind !== KIND.HELLO) return;
|
|
557
|
+
const session = parsed.session;
|
|
558
|
+
|
|
559
|
+
if (claimed !== null && claimed.has(session)) {
|
|
560
|
+
const now = Date.now();
|
|
561
|
+
const last = reackedAt.get(session) ?? Number.NEGATIVE_INFINITY;
|
|
562
|
+
if (now - last >= intervalMs) {
|
|
563
|
+
reackedAt.set(session, now);
|
|
564
|
+
socket.send(frame(KIND.HELLO_ACK, session), rinfo.port, rinfo.address, () => {});
|
|
565
|
+
}
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
socket.send(frame(KIND.HELLO_ACK, session), rinfo.port, rinfo.address, () => {});
|
|
570
|
+
finish({
|
|
571
|
+
ok: true,
|
|
572
|
+
channel: new P2PChannel({
|
|
573
|
+
socket,
|
|
574
|
+
peer: { address: rinfo.address, port: rinfo.port },
|
|
575
|
+
session,
|
|
576
|
+
}),
|
|
577
|
+
peer: { address: rinfo.address, port: rinfo.port },
|
|
578
|
+
session,
|
|
579
|
+
rttMs: null,
|
|
580
|
+
error: null,
|
|
581
|
+
});
|
|
582
|
+
};
|
|
583
|
+
|
|
584
|
+
socket.on('message', onMessage);
|
|
585
|
+
socket.once('close', onSocketClose);
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Punch, then return a reliable channel on the opened path.
|
|
591
|
+
*
|
|
592
|
+
* @param {object} options Same as {@link punch}, plus `tuning` for the channel.
|
|
593
|
+
* @returns {Promise<{ok:boolean, channel:P2PChannel|null, punch:object, error:string|null}>}
|
|
594
|
+
*/
|
|
595
|
+
export async function connect(options) {
|
|
596
|
+
const result = await punch(options);
|
|
597
|
+
if (!result.ok) return { ok: false, channel: null, punch: result, error: result.error };
|
|
598
|
+
const channel = new P2PChannel({
|
|
599
|
+
socket: options.socket,
|
|
600
|
+
peer: result.peer,
|
|
601
|
+
session: result.session,
|
|
602
|
+
tuning: options.tuning,
|
|
603
|
+
});
|
|
604
|
+
return { ok: true, channel, punch: result, error: null };
|
|
605
|
+
}
|