@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,1187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* W2M Rabbit relay — node:http / node:https server.
|
|
3
|
+
*
|
|
4
|
+
* PROTOCOL.md §1 endpoints, §3 SSE, §7 errors
|
|
5
|
+
* PROTOCOL-v0.1.2.md §3 deployment params, §4 /healthz, §5 operator token,
|
|
6
|
+
* §6 pair rate limit, §7 persistence, §8 SSE hardening.
|
|
7
|
+
*
|
|
8
|
+
* Zero third-party dependencies: only `node:` builtins.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import http from 'node:http';
|
|
12
|
+
import https from 'node:https';
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import os from 'node:os';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { randomBytes } from 'node:crypto';
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
MAX_FRAME_BYTES,
|
|
20
|
+
PROTOCOL_VERSION,
|
|
21
|
+
ProtocolError,
|
|
22
|
+
RabbitState,
|
|
23
|
+
rfc3339,
|
|
24
|
+
} from './state.mjs';
|
|
25
|
+
import { Persistence } from './persistence.mjs';
|
|
26
|
+
import { PeerRegistry } from './peers.mjs';
|
|
27
|
+
import { aggregateTask, buildReport } from './report.mjs';
|
|
28
|
+
import { renderMetrics, METRICS_CONTENT_TYPE } from './metrics.mjs';
|
|
29
|
+
// v0.3.0 §9: request signing. This import is the entire point of the module --
|
|
30
|
+
// until the HTTP layer calls it, HMAC support does not exist for any real request.
|
|
31
|
+
import { DEFAULT_SKEW_SECONDS, NonceCache, verifyRequest } from '../signing.mjs';
|
|
32
|
+
|
|
33
|
+
export const DEFAULT_SSE_KEEPALIVE_MS = 15_000; // §3.2: every 15s a `: keepalive` line
|
|
34
|
+
export const DEFAULT_SWEEP_INTERVAL_MS = 1_000;
|
|
35
|
+
export const DEFAULT_PAIR_RATE_LIMIT_PER_MINUTE = 5; // v0.1.2 §6
|
|
36
|
+
export const RATE_LIMIT_WINDOW_MS = 60_000;
|
|
37
|
+
export const DEFAULT_STATE_DIR = path.join(os.homedir(), '.w2m');
|
|
38
|
+
|
|
39
|
+
/* ------------------------------------------------------------------ */
|
|
40
|
+
/* helpers */
|
|
41
|
+
/* ------------------------------------------------------------------ */
|
|
42
|
+
|
|
43
|
+
/** v0.1.2 §3: `/w2m/` → `/w2m`; '' / '/' → '/'. Never returns a trailing slash. */
|
|
44
|
+
export function normalizeBasePath(value) {
|
|
45
|
+
if (value === undefined || value === null || value === '') return '/';
|
|
46
|
+
let p = String(value).trim();
|
|
47
|
+
if (p === '' || p === '/') return '/';
|
|
48
|
+
if (!p.startsWith('/')) p = `/${p}`;
|
|
49
|
+
p = p.replace(/\/{2,}/g, '/').replace(/\/+$/, '');
|
|
50
|
+
return p === '' ? '/' : p;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function normalizePath(pathname) {
|
|
54
|
+
if (!pathname) return '/';
|
|
55
|
+
return pathname.length > 1 ? pathname.replace(/\/+$/, '') : pathname;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function sendJson(res, status, body, extraHeaders = {}) {
|
|
59
|
+
if (res.writableEnded) return undefined;
|
|
60
|
+
const payload = Buffer.from(JSON.stringify(body), 'utf8');
|
|
61
|
+
res.writeHead(status, {
|
|
62
|
+
'content-type': 'application/json; charset=utf-8',
|
|
63
|
+
'content-length': payload.length,
|
|
64
|
+
'cache-control': 'no-store',
|
|
65
|
+
...extraHeaders,
|
|
66
|
+
});
|
|
67
|
+
return res.end(payload);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function sendError(res, err) {
|
|
71
|
+
if (err instanceof ProtocolError) {
|
|
72
|
+
const body = err.toBody();
|
|
73
|
+
// §6 just says the 429 "carries retry_after_seconds" without pinning the
|
|
74
|
+
// location, so it is emitted both inside error.detail (the §7 shape) and as a
|
|
75
|
+
// top-level convenience field. Both are additive.
|
|
76
|
+
const retryAfter = err.detail?.retry_after_seconds;
|
|
77
|
+
if (typeof retryAfter === 'number') body.retry_after_seconds = retryAfter;
|
|
78
|
+
sendJson(res, err.status, body, err.headers ?? {});
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
sendJson(res, 500, {
|
|
82
|
+
error: {
|
|
83
|
+
code: 'INTERNAL',
|
|
84
|
+
message: err?.message ? String(err.message) : 'internal error',
|
|
85
|
+
detail: {},
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function bearerToken(req) {
|
|
91
|
+
const header = req.headers.authorization;
|
|
92
|
+
if (typeof header !== 'string') return null;
|
|
93
|
+
const m = /^Bearer\s+(.+)$/i.exec(header.trim());
|
|
94
|
+
return m ? m[1].trim() : null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function firstHeaderValue(value) {
|
|
98
|
+
const raw = Array.isArray(value) ? value[0] : value;
|
|
99
|
+
if (typeof raw !== 'string') return null;
|
|
100
|
+
const first = raw.split(',')[0].trim();
|
|
101
|
+
return first === '' ? null : first;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Read a request body with a hard byte cap.
|
|
106
|
+
* @returns {Promise<{tooLarge?:boolean, buffer?:Buffer, aborted?:boolean}>}
|
|
107
|
+
*/
|
|
108
|
+
function readBody(req, limit) {
|
|
109
|
+
return new Promise((resolve) => {
|
|
110
|
+
const declared = Number(req.headers['content-length']);
|
|
111
|
+
if (Number.isFinite(declared) && declared > limit) {
|
|
112
|
+
req.resume();
|
|
113
|
+
resolve({ tooLarge: true });
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const chunks = [];
|
|
117
|
+
let total = 0;
|
|
118
|
+
let settled = false;
|
|
119
|
+
const finish = (value) => {
|
|
120
|
+
if (settled) return;
|
|
121
|
+
settled = true;
|
|
122
|
+
resolve(value);
|
|
123
|
+
};
|
|
124
|
+
req.on('data', (chunk) => {
|
|
125
|
+
if (settled) return;
|
|
126
|
+
total += chunk.length;
|
|
127
|
+
if (total > limit) {
|
|
128
|
+
finish({ tooLarge: true });
|
|
129
|
+
req.resume();
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
chunks.push(chunk);
|
|
133
|
+
});
|
|
134
|
+
req.on('end', () => finish({ buffer: Buffer.concat(chunks) }));
|
|
135
|
+
req.on('error', () => finish({ aborted: true }));
|
|
136
|
+
req.on('aborted', () => finish({ aborted: true }));
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Read the raw body bytes with a hard byte cap.
|
|
142
|
+
*
|
|
143
|
+
* v0.3.0 §9: the body is read exactly ONCE per request and the bytes are kept,
|
|
144
|
+
* because an HMAC covers the exact received octets -- re-serialising parsed JSON
|
|
145
|
+
* would produce different bytes (key order, whitespace) and break every signature.
|
|
146
|
+
*/
|
|
147
|
+
async function readRawBody(req, limit) {
|
|
148
|
+
const body = await readBody(req, limit);
|
|
149
|
+
if (body.tooLarge) {
|
|
150
|
+
throw new ProtocolError('FRAME_TOO_LARGE', `request frame exceeds ${limit} bytes`, { limit_bytes: limit });
|
|
151
|
+
}
|
|
152
|
+
if (body.aborted) throw new ProtocolError('BAD_REQUEST', 'request aborted before the body was complete');
|
|
153
|
+
return body.buffer;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Parse an already-read body buffer as a JSON object. */
|
|
157
|
+
function parseJsonBody(buffer) {
|
|
158
|
+
const raw = buffer === null || buffer === undefined ? '' : buffer.toString('utf8');
|
|
159
|
+
if (raw.trim() === '') return {};
|
|
160
|
+
try {
|
|
161
|
+
const parsed = JSON.parse(raw);
|
|
162
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
163
|
+
throw new ProtocolError('BAD_REQUEST', 'request body must be a JSON object');
|
|
164
|
+
}
|
|
165
|
+
return parsed;
|
|
166
|
+
} catch (err) {
|
|
167
|
+
if (err instanceof ProtocolError) throw err;
|
|
168
|
+
throw new ProtocolError('BAD_REQUEST', 'request body is not valid JSON', { parse_error: String(err.message) });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Methods that can carry a body the signature must cover. */
|
|
173
|
+
function methodHasBody(method) {
|
|
174
|
+
return method === 'POST' || method === 'PUT' || method === 'PATCH' || method === 'DELETE';
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/* ------------------------------------------------------------------ */
|
|
178
|
+
/* request signing (v0.3.0 §9) */
|
|
179
|
+
/* ------------------------------------------------------------------ */
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Operator-facing hints. The `code` says what happened; the hint says what to do
|
|
183
|
+
* about it, because "signature mismatch" without a direction is a support ticket.
|
|
184
|
+
*/
|
|
185
|
+
const SIGNATURE_HINTS = Object.freeze({
|
|
186
|
+
SIGNATURE_REQUIRED: 'this relay runs with --require-signature; sign the request (PROTOCOL-v0.3.0.md §9)',
|
|
187
|
+
SIGNATURE_INCOMPLETE: 'all three of X-W2M-Signature, X-W2M-Timestamp and X-W2M-Nonce must be present together',
|
|
188
|
+
SIGNATURE_MISMATCH: 'the signature matches no configured secret — check for a stale or half-rotated secret',
|
|
189
|
+
SIGNATURE_EXPIRED: 'the timestamp is outside the accepted clock-skew window — check the sending machine clock',
|
|
190
|
+
SIGNATURE_REPLAY: 'this nonce was already used — generate a fresh nonce for every request',
|
|
191
|
+
SIGNATURE_BAD_TIMESTAMP: 'X-W2M-Timestamp must be whole unix seconds',
|
|
192
|
+
SIGNATURE_BAD_NONCE: 'X-W2M-Nonce must be at least 8 characters',
|
|
193
|
+
SIGNING_NOT_CONFIGURED:
|
|
194
|
+
'the relay has --require-signature enabled but no --signing-secret configured; '
|
|
195
|
+
+ 'this is a RELAY configuration error, not a client problem',
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
const SIGNATURE_PROTOCOL_CODES = new Set(Object.keys(SIGNATURE_HINTS));
|
|
199
|
+
|
|
200
|
+
/** Turn a `verifyRequest` rejection into the §7 error envelope.
|
|
201
|
+
* `ProtocolError` resolves the HTTP status from `ERROR_STATUS`, so the mapping
|
|
202
|
+
* table lives in exactly one place (state.mjs) instead of being duplicated here. */
|
|
203
|
+
function signatureError(decision) {
|
|
204
|
+
const code = SIGNATURE_PROTOCOL_CODES.has(decision.code) ? decision.code : 'SIGNATURE_MISMATCH';
|
|
205
|
+
return new ProtocolError(code, decision.error, {
|
|
206
|
+
code,
|
|
207
|
+
reason: decision.error,
|
|
208
|
+
hint: SIGNATURE_HINTS[code] ?? null,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Read an optional secret, failing loudly on a value that is present but unusable.
|
|
214
|
+
*
|
|
215
|
+
* `signing.mjs` calls a silently-disabled secret the failure mode it exists to
|
|
216
|
+
* prevent. Treating `signingSecret: 12345` (or `{}`) as "not configured" would do
|
|
217
|
+
* exactly that at the wiring layer, so it is an error instead.
|
|
218
|
+
*/
|
|
219
|
+
function readSecretValue(value, label) {
|
|
220
|
+
if (value === undefined || value === null || value === '') return null;
|
|
221
|
+
if (typeof value !== 'string') {
|
|
222
|
+
throw new ProtocolError('BAD_REQUEST',
|
|
223
|
+
`${label} must be a non-empty string when provided (got ${typeof value}); `
|
|
224
|
+
+ 'refusing to silently run without the signing you asked for', { option: label, received_type: typeof value });
|
|
225
|
+
}
|
|
226
|
+
return value;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/* ------------------------------------------------------------------ */
|
|
230
|
+
/* pair rate limiting (v0.1.2 §6) */
|
|
231
|
+
/* ------------------------------------------------------------------ */
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Per-IP sliding window. Successful pairs count too, otherwise a working pairing
|
|
235
|
+
* code could be used to flush the failure budget.
|
|
236
|
+
*/
|
|
237
|
+
export class SlidingWindowRateLimiter {
|
|
238
|
+
constructor({ limit = DEFAULT_PAIR_RATE_LIMIT_PER_MINUTE, windowMs = RATE_LIMIT_WINDOW_MS, now = Date.now } = {}) {
|
|
239
|
+
this.limit = limit;
|
|
240
|
+
this.windowMs = windowMs;
|
|
241
|
+
this._now = now;
|
|
242
|
+
/** @type {Map<string, number[]>} */
|
|
243
|
+
this.buckets = new Map();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
get windowSeconds() { return Math.round(this.windowMs / 1000); }
|
|
247
|
+
|
|
248
|
+
/** Records one attempt and reports whether it is allowed. */
|
|
249
|
+
hit(key) {
|
|
250
|
+
if (this.limit <= 0) {
|
|
251
|
+
return { allowed: true, retry_after_seconds: 0, limit: 0, window_seconds: this.windowSeconds, remaining: Infinity };
|
|
252
|
+
}
|
|
253
|
+
const now = this._now();
|
|
254
|
+
const cutoff = now - this.windowMs;
|
|
255
|
+
let hits = this.buckets.get(key);
|
|
256
|
+
if (!hits) {
|
|
257
|
+
hits = [];
|
|
258
|
+
this.buckets.set(key, hits);
|
|
259
|
+
}
|
|
260
|
+
while (hits.length > 0 && hits[0] <= cutoff) hits.shift();
|
|
261
|
+
|
|
262
|
+
if (hits.length >= this.limit) {
|
|
263
|
+
const retryMs = hits[0] + this.windowMs - now;
|
|
264
|
+
return {
|
|
265
|
+
allowed: false,
|
|
266
|
+
retry_after_seconds: Math.max(1, Math.ceil(retryMs / 1000)),
|
|
267
|
+
limit: this.limit,
|
|
268
|
+
window_seconds: this.windowSeconds,
|
|
269
|
+
remaining: 0,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
hits.push(now);
|
|
273
|
+
return {
|
|
274
|
+
allowed: true,
|
|
275
|
+
retry_after_seconds: 0,
|
|
276
|
+
limit: this.limit,
|
|
277
|
+
window_seconds: this.windowSeconds,
|
|
278
|
+
remaining: this.limit - hits.length,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Drop idle buckets so a long-running relay cannot leak memory. */
|
|
283
|
+
prune() {
|
|
284
|
+
const cutoff = this._now() - this.windowMs;
|
|
285
|
+
for (const [key, hits] of this.buckets) {
|
|
286
|
+
while (hits.length > 0 && hits[0] <= cutoff) hits.shift();
|
|
287
|
+
if (hits.length === 0) this.buckets.delete(key);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
reset() { this.buckets.clear(); }
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/* ------------------------------------------------------------------ */
|
|
295
|
+
/* server */
|
|
296
|
+
/* ------------------------------------------------------------------ */
|
|
297
|
+
|
|
298
|
+
export class RelayServer {
|
|
299
|
+
constructor(options = {}) {
|
|
300
|
+
this.options = options;
|
|
301
|
+
this.basePath = normalizeBasePath(options.basePath);
|
|
302
|
+
this.trustProxy = options.trustProxy === true;
|
|
303
|
+
this.frameLimitBytes = options.frameLimitBytes ?? MAX_FRAME_BYTES;
|
|
304
|
+
this.keepaliveMs = options.keepaliveMs ?? DEFAULT_SSE_KEEPALIVE_MS;
|
|
305
|
+
this.sweepIntervalMs = options.sweepIntervalMs ?? DEFAULT_SWEEP_INTERVAL_MS;
|
|
306
|
+
this.pairRateLimitPerMinute = Number.isFinite(options.pairRateLimitPerMinute)
|
|
307
|
+
? options.pairRateLimitPerMinute : DEFAULT_PAIR_RATE_LIMIT_PER_MINUTE;
|
|
308
|
+
|
|
309
|
+
// §3: `logger: null` silences. The default writes to STDERR on purpose —
|
|
310
|
+
// stdout belongs to protocol output (e.g. the CLI's `--json` line), and mixing
|
|
311
|
+
// prose into it breaks `| ConvertFrom-Json` / `| jq`.
|
|
312
|
+
this.logger = options.logger === undefined
|
|
313
|
+
? ((msg) => process.stderr.write(`${msg}\n`))
|
|
314
|
+
: options.logger;
|
|
315
|
+
this.startupMessages = [];
|
|
316
|
+
|
|
317
|
+
/* ---- persistence (§7) ---- */
|
|
318
|
+
const rawStateDir = options.stateDir ?? (typeof options.state === 'string' ? options.state : null);
|
|
319
|
+
const persistEnabled = options.persist !== false && rawStateDir !== '';
|
|
320
|
+
const stateDir = persistEnabled
|
|
321
|
+
? path.resolve(rawStateDir === null || rawStateDir === undefined ? DEFAULT_STATE_DIR : String(rawStateDir))
|
|
322
|
+
: null;
|
|
323
|
+
this.persistence = new Persistence({
|
|
324
|
+
dir: stateDir,
|
|
325
|
+
now: () => (this.state ? this.state.nowMs() : Date.now()),
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
/* ---- state ---- */
|
|
329
|
+
const injected = options.state && typeof options.state === 'object' ? options.state : null;
|
|
330
|
+
this.state = injected ?? new RabbitState({ ...options, persistence: this.persistence });
|
|
331
|
+
this.state.setPersistence(this.persistence);
|
|
332
|
+
|
|
333
|
+
// v0.3.9: P2P signalling. Ephemeral by design -- see src/relay/peers.mjs.
|
|
334
|
+
this.peers = options.peers ?? new PeerRegistry({ nowMs: () => this.state.nowMs() });
|
|
335
|
+
|
|
336
|
+
this.relayId = this.state.relayId;
|
|
337
|
+
this._startedAtMs = this.state.nowMs();
|
|
338
|
+
|
|
339
|
+
/* ---- startup recovery (§7) ---- */
|
|
340
|
+
if (this.persistence.enabled) {
|
|
341
|
+
const snapshot = this.persistence.loadDevices();
|
|
342
|
+
this.persistence.devicesMissing = snapshot.missing;
|
|
343
|
+
const ledger = this.persistence.loadLedger();
|
|
344
|
+
const revived = this.state.restore({ devices: snapshot.devices, ledger });
|
|
345
|
+
this.persistence.revivedDevices = revived.revived_devices;
|
|
346
|
+
this.persistence.revivedTasks = revived.revived_tasks;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/* ---- operator token (§5) ---- */
|
|
350
|
+
this._initOperatorToken(options);
|
|
351
|
+
|
|
352
|
+
/* ---- v0.3.0 §9 request signing ---- */
|
|
353
|
+
this.signingSecrets = [
|
|
354
|
+
readSecretValue(options.signingSecret, 'signingSecret'),
|
|
355
|
+
readSecretValue(options.signingSecretPrevious, 'signingSecretPrevious'),
|
|
356
|
+
].filter((s) => s !== null); // newest first: keyIndex 0 = current, 1 = previous
|
|
357
|
+
this.signingConfigured = this.signingSecrets.length > 0;
|
|
358
|
+
this.requireSignature = options.requireSignature === true;
|
|
359
|
+
/**
|
|
360
|
+
* v0.3.3: whether `GET /metrics` exists.
|
|
361
|
+
*
|
|
362
|
+
* Defaults to off. A monitoring endpoint that appears without being asked for is a surface nobody
|
|
363
|
+
* chose, and the operator can turn it on with `--metrics` when a scraper exists.
|
|
364
|
+
*/
|
|
365
|
+
this.metricsEnabled = options.metrics === true;
|
|
366
|
+
this.signatureSkewSeconds = Number.isFinite(options.signatureSkewSeconds)
|
|
367
|
+
? options.signatureSkewSeconds : DEFAULT_SKEW_SECONDS;
|
|
368
|
+
// One cache per relay instance: replay defence is stateful, and two relays
|
|
369
|
+
// sharing a cache would let a nonce blocked on one be replayed on the other.
|
|
370
|
+
this.nonceCache = options.nonceCache ?? new NonceCache({
|
|
371
|
+
max: options.nonceCacheMax ?? 20_000,
|
|
372
|
+
retentionMs: options.nonceCacheRetentionMs ?? 10 * 60_000,
|
|
373
|
+
});
|
|
374
|
+
this.nonceRetentionMs = options.nonceCacheRetentionMs ?? 10 * 60_000;
|
|
375
|
+
|
|
376
|
+
/* ---- /v1/pair rate limit (§6) ---- */
|
|
377
|
+
this.pairLimiter = new SlidingWindowRateLimiter({
|
|
378
|
+
limit: this.pairRateLimitPerMinute,
|
|
379
|
+
windowMs: RATE_LIMIT_WINDOW_MS,
|
|
380
|
+
now: () => this.state.nowMs(),
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
/* ---- transport ---- */
|
|
384
|
+
this.tlsCertPath = options.tlsCert ?? null;
|
|
385
|
+
this.tlsKeyPath = options.tlsKey ?? null;
|
|
386
|
+
if ((this.tlsCertPath && !this.tlsKeyPath) || (!this.tlsCertPath && this.tlsKeyPath)) {
|
|
387
|
+
throw new ProtocolError('BAD_REQUEST', 'tlsCert and tlsKey must be provided together', {
|
|
388
|
+
tls_cert: this.tlsCertPath, tls_key: this.tlsKeyPath,
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
const handler = (req, res) => {
|
|
392
|
+
this.handle(req, res).catch((err) => this.reportError(req, res, err));
|
|
393
|
+
};
|
|
394
|
+
if (this.tlsCertPath) {
|
|
395
|
+
let cert;
|
|
396
|
+
let key;
|
|
397
|
+
try {
|
|
398
|
+
cert = fs.readFileSync(this.tlsCertPath);
|
|
399
|
+
key = fs.readFileSync(this.tlsKeyPath);
|
|
400
|
+
} catch (err) {
|
|
401
|
+
// Never silently fall back to plain HTTP: that would be a security downgrade.
|
|
402
|
+
throw new ProtocolError('BAD_REQUEST', `cannot read TLS material: ${err.message}`, {
|
|
403
|
+
tls_cert: this.tlsCertPath, tls_key: this.tlsKeyPath,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
this.server = https.createServer({ cert, key }, handler);
|
|
407
|
+
this.scheme = 'https';
|
|
408
|
+
} else {
|
|
409
|
+
this.server = http.createServer(handler);
|
|
410
|
+
this.scheme = 'http';
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// v0.1.2 BUG-1: an externally supplied code must be REGISTERED, not merely
|
|
414
|
+
// assigned — otherwise the banner advertises a code /v1/pair does not know.
|
|
415
|
+
this.pairingCode = typeof options.pairingCode === 'string' && options.pairingCode.trim() !== ''
|
|
416
|
+
? this.state.registerPairingCode(options.pairingCode, { ttlMs: options.pairingCodeTtlMs })
|
|
417
|
+
: this.state.createPairingCode();
|
|
418
|
+
this.subscribers = new Set();
|
|
419
|
+
this.host = null;
|
|
420
|
+
this.port = null;
|
|
421
|
+
this._sweepTimer = null;
|
|
422
|
+
|
|
423
|
+
// SSE connections are long-lived: never let Node time the request out.
|
|
424
|
+
this.server.requestTimeout = options.requestTimeoutMs ?? 0;
|
|
425
|
+
this.server.headersTimeout = options.headersTimeoutMs ?? 60_000;
|
|
426
|
+
|
|
427
|
+
this._buildStartupMessages();
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Diagnostics sink. `logger: null` is the documented "silent" value, and a
|
|
432
|
+
* caller-supplied logger may throw — neither may break a request. (A direct
|
|
433
|
+
* `this.logger(...)` call after `res.writeHead()` once threw a TypeError that
|
|
434
|
+
* surfaced as "Cannot write headers after they are sent to the client".)
|
|
435
|
+
*/
|
|
436
|
+
_log(message) {
|
|
437
|
+
if (typeof this.logger !== 'function') return;
|
|
438
|
+
try {
|
|
439
|
+
this.logger(message);
|
|
440
|
+
} catch { /* a broken logger must never break the relay */ }
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Last-resort error path for a request whose handler rejected.
|
|
445
|
+
* Once the status line is on the wire (SSE, streamed report) the response can no
|
|
446
|
+
* longer be rewritten — the only honest action is to end the stream, loudly.
|
|
447
|
+
*/
|
|
448
|
+
reportError(req, res, err) {
|
|
449
|
+
if (res.headersSent) {
|
|
450
|
+
this._log(`[w2m-rabbit] !! error after the response had started on ${req.method} ${req.url}: `
|
|
451
|
+
+ `${err?.stack ?? err}`);
|
|
452
|
+
try { res.end(); } catch { /* already gone */ }
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
sendError(res, err);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
_initOperatorToken(options) {
|
|
459
|
+
const wantsRequired = options.operatorTokenRequired !== false;
|
|
460
|
+
const explicit = options.operatorToken;
|
|
461
|
+
const persisted = this.persistence.enabled ? this.persistence.readOperatorToken() : null;
|
|
462
|
+
|
|
463
|
+
if (!wantsRequired || explicit === '') {
|
|
464
|
+
this.operatorToken = null;
|
|
465
|
+
this.operatorTokenRequired = false;
|
|
466
|
+
this.operatorTokenSource = 'disabled';
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
this.operatorTokenRequired = true;
|
|
470
|
+
if (typeof explicit === 'string' && explicit.length > 0) {
|
|
471
|
+
this.operatorToken = explicit;
|
|
472
|
+
this.operatorTokenSource = 'option';
|
|
473
|
+
this.persistence.writeOperatorToken(explicit);
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
if (persisted) {
|
|
477
|
+
// §5.1 + §7: keep the token stable across restarts, otherwise every restart
|
|
478
|
+
// would silently invalidate the plugin's configured operatorToken.
|
|
479
|
+
this.operatorToken = persisted;
|
|
480
|
+
this.operatorTokenSource = 'restored';
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
this.operatorToken = randomBytes(32).toString('hex');
|
|
484
|
+
this.operatorTokenSource = 'generated';
|
|
485
|
+
this.persistence.writeOperatorToken(this.operatorToken);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
_buildStartupMessages() {
|
|
489
|
+
const m = this.startupMessages;
|
|
490
|
+
m.push(`[w2m-rabbit] relay_id=${this.relayId}`);
|
|
491
|
+
m.push(`[w2m-rabbit] base_path=${this.basePath}${this.basePath === '/' ? ' (root)' : ''}`);
|
|
492
|
+
m.push(`[w2m-rabbit] trust_proxy=${this.trustProxy}${this.trustProxy ? '' : ' (X-Forwarded-* ignored)'}`);
|
|
493
|
+
m.push(`[w2m-rabbit] pair_rate_limit=${this.pairRateLimitPerMinute === 0 ? 'disabled' : `${this.pairRateLimitPerMinute}/min/IP`}`);
|
|
494
|
+
// §9: report the posture, never the material.
|
|
495
|
+
if (this.signingConfigured) {
|
|
496
|
+
m.push(`[w2m-rabbit] request signing=ON (${this.signingSecrets.length} secret(s)`
|
|
497
|
+
+ `${this.signingSecrets.length > 1 ? ', rotation active' : ''})`
|
|
498
|
+
+ `${this.requireSignature ? ' - REQUIRED: unsigned /v1/* is rejected with 401' : ' - optional: unsigned requests are still accepted'}`);
|
|
499
|
+
} else if (this.requireSignature) {
|
|
500
|
+
m.push('[w2m-rabbit] !! CONFIGURATION ERROR: --require-signature is set but no --signing-secret was provided. '
|
|
501
|
+
+ 'Every /v1/* request will fail with 500 SIGNING_NOT_CONFIGURED (this is the RELAY\'s fault, not the client\'s). '
|
|
502
|
+
+ 'Either set --signing-secret or drop --require-signature.');
|
|
503
|
+
} else {
|
|
504
|
+
m.push('[w2m-rabbit] request signing=off (no signing secret configured; behaviour is identical to v0.2.3)');
|
|
505
|
+
}
|
|
506
|
+
if (this.persistence.enabled) {
|
|
507
|
+
m.push(`[w2m-rabbit] persistence=on dir=${this.persistence.dir} `
|
|
508
|
+
+ `revived_devices=${this.persistence.revivedDevices} revived_tasks=${this.persistence.revivedTasks}`);
|
|
509
|
+
} else {
|
|
510
|
+
m.push('[w2m-rabbit] persistence=off (in-memory only; devices and tasks are lost on restart)');
|
|
511
|
+
}
|
|
512
|
+
if (this.persistence.devicesCorrupt) {
|
|
513
|
+
m.push('[w2m-rabbit] !! WARNING: devices.json was CORRUPT — started from an EMPTY device table; '
|
|
514
|
+
+ 'every machine must pair again. The damaged file was left untouched.');
|
|
515
|
+
}
|
|
516
|
+
for (const w of this.persistence.warnings) {
|
|
517
|
+
m.push(`[w2m-rabbit] !! ${w.file}: ${w.message}`);
|
|
518
|
+
}
|
|
519
|
+
if (this.operatorTokenRequired) {
|
|
520
|
+
m.push(`[w2m-rabbit] operator token (${this.operatorTokenSource}) - required for POST /v1/task:`);
|
|
521
|
+
m.push(`[w2m-rabbit] ${this.operatorToken}`);
|
|
522
|
+
if (this.persistence.operatorTokenFile) {
|
|
523
|
+
m.push(`[w2m-rabbit] stored at ${this.persistence.operatorTokenFile} (0600)`);
|
|
524
|
+
}
|
|
525
|
+
} else {
|
|
526
|
+
m.push('[w2m-rabbit] !! WARNING: OPERATOR TOKEN DISABLED (--operator-token "") - '
|
|
527
|
+
+ 'any device that can reach this relay can dispatch tasks to the whole group. '
|
|
528
|
+
+ 'Only acceptable on a trusted LAN / for debugging; /v1/task then requires a device token.');
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
get url() {
|
|
533
|
+
if (this.host === null || this.port === null) return null;
|
|
534
|
+
const host = this.host.includes(':') ? `[${this.host}]` : this.host;
|
|
535
|
+
const prefix = this.basePath === '/' ? '' : this.basePath;
|
|
536
|
+
return `${this.scheme}://${host}:${this.port}${prefix}`;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
listen({ host = '127.0.0.1', port = 0 } = {}) {
|
|
540
|
+
return new Promise((resolve, reject) => {
|
|
541
|
+
const onError = (err) => reject(err);
|
|
542
|
+
this.server.once('error', onError);
|
|
543
|
+
this.server.listen(port, host, () => {
|
|
544
|
+
this.server.removeListener('error', onError);
|
|
545
|
+
const addr = this.server.address();
|
|
546
|
+
this.host = addr.address;
|
|
547
|
+
this.port = addr.port;
|
|
548
|
+
this._sweepTimer = setInterval(() => {
|
|
549
|
+
this.maintenance();
|
|
550
|
+
}, this.sweepIntervalMs);
|
|
551
|
+
this._sweepTimer.unref?.();
|
|
552
|
+
if (this.logger) {
|
|
553
|
+
this._log(`[w2m-rabbit] listening on ${this.url} pairing code: ${this.pairingCode}`);
|
|
554
|
+
for (const message of this.startupMessages) this._log(message);
|
|
555
|
+
}
|
|
556
|
+
resolve({
|
|
557
|
+
host: this.host,
|
|
558
|
+
port: this.port,
|
|
559
|
+
url: this.url,
|
|
560
|
+
pairingCode: this.pairingCode,
|
|
561
|
+
relayId: this.relayId,
|
|
562
|
+
basePath: this.basePath,
|
|
563
|
+
operatorToken: this.operatorToken,
|
|
564
|
+
operatorTokenRequired: this.operatorTokenRequired,
|
|
565
|
+
scheme: this.scheme,
|
|
566
|
+
});
|
|
567
|
+
});
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
sweep() {
|
|
572
|
+
return this.state.sweepExpired();
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
close() {
|
|
576
|
+
return new Promise((resolve) => {
|
|
577
|
+
if (this._sweepTimer) clearInterval(this._sweepTimer);
|
|
578
|
+
this._sweepTimer = null;
|
|
579
|
+
// v0.4.0: a deferred P2P offer is a pending timer, and a closed relay must not fire one.
|
|
580
|
+
this.state.clearPendingOffers?.();
|
|
581
|
+
for (const sub of [...this.subscribers]) this._closeSubscriber(sub);
|
|
582
|
+
this.server.close(() => resolve());
|
|
583
|
+
this.server.closeAllConnections?.();
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/* ---------------- proxy awareness (v0.1.2 §3) ---------------- */
|
|
588
|
+
|
|
589
|
+
/** `X-Forwarded-For` first hop is honoured ONLY under `trustProxy`. */
|
|
590
|
+
clientIp(req) {
|
|
591
|
+
if (this.trustProxy) {
|
|
592
|
+
const forwarded = firstHeaderValue(req.headers['x-forwarded-for']);
|
|
593
|
+
if (forwarded) return forwarded;
|
|
594
|
+
}
|
|
595
|
+
return req.socket?.remoteAddress ?? 'unknown';
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** `X-Forwarded-Proto` is honoured ONLY under `trustProxy`. */
|
|
599
|
+
effectiveScheme(req) {
|
|
600
|
+
if (this.trustProxy) {
|
|
601
|
+
const proto = firstHeaderValue(req.headers['x-forwarded-proto']);
|
|
602
|
+
if (proto) return proto.toLowerCase();
|
|
603
|
+
}
|
|
604
|
+
return this.scheme;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/* ---------------- request dispatch ---------------- */
|
|
608
|
+
|
|
609
|
+
async handle(req, res) {
|
|
610
|
+
let url;
|
|
611
|
+
try {
|
|
612
|
+
url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
|
|
613
|
+
} catch {
|
|
614
|
+
throw new ProtocolError('BAD_REQUEST', 'malformed request URL');
|
|
615
|
+
}
|
|
616
|
+
const rawPath = url.pathname;
|
|
617
|
+
const method = (req.method ?? 'GET').toUpperCase();
|
|
618
|
+
|
|
619
|
+
// §3: strip the deployment prefix, then route. `/healthz` also answers at the
|
|
620
|
+
// root because health checks are usually pointed straight at the container.
|
|
621
|
+
// `stripped` keeps the literal request path (no trailing-slash normalisation)
|
|
622
|
+
// because that is what a signature covers; `path` is the normalised route key.
|
|
623
|
+
let stripped;
|
|
624
|
+
if (this.basePath === '/') {
|
|
625
|
+
stripped = rawPath;
|
|
626
|
+
} else if (rawPath === this.basePath) {
|
|
627
|
+
stripped = '/';
|
|
628
|
+
} else if (rawPath.startsWith(`${this.basePath}/`)) {
|
|
629
|
+
stripped = rawPath.slice(this.basePath.length);
|
|
630
|
+
} else if (rawPath === '/healthz') {
|
|
631
|
+
stripped = '/healthz';
|
|
632
|
+
} else {
|
|
633
|
+
// Outside the prefix: an explicit 404, never a gateway-style 502.
|
|
634
|
+
throw new ProtocolError('NOT_FOUND', `path is outside the configured base path ${this.basePath}`, {
|
|
635
|
+
path: rawPath, base_path: this.basePath,
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
const path = normalizePath(stripped);
|
|
639
|
+
const signedPath = `${stripped}${url.search}`;
|
|
640
|
+
|
|
641
|
+
// v0.3.0 §9: read the body ONCE, before routing. An HMAC covers the exact
|
|
642
|
+
// received bytes, so no route may consume the stream first and re-serialise.
|
|
643
|
+
const rawBody = methodHasBody(method) ? await readRawBody(req, this.frameLimitBytes) : null;
|
|
644
|
+
const takeJson = () => parseJsonBody(rawBody);
|
|
645
|
+
|
|
646
|
+
/* ---- public endpoints ---- */
|
|
647
|
+
// `/healthz` and `/v1/pair` are permanently exempt from signature checks:
|
|
648
|
+
// the first is an operations probe that must not need credentials, the second
|
|
649
|
+
// is the bootstrap step that happens before the two sides share a secret.
|
|
650
|
+
if (method === 'GET' && path === '/healthz') {
|
|
651
|
+
return sendJson(res, 200, this.healthBody(req));
|
|
652
|
+
}
|
|
653
|
+
if (method === 'POST' && path === '/v1/pair') {
|
|
654
|
+
this.enforcePairRateLimit(req);
|
|
655
|
+
const result = this.state.pair(takeJson());
|
|
656
|
+
return sendJson(res, 200, result);
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* v0.3.3 `/metrics`: machine-readable fleet state for a monitoring system.
|
|
660
|
+
*
|
|
661
|
+
* Token-free, like `/healthz` and for the same reason: a scraper that needs the operator token
|
|
662
|
+
* would put that token in a Prometheus config file, which is a credential in a place nobody
|
|
663
|
+
* reviews. It is also exempt from signature checks for the same reason -- there is no secret to
|
|
664
|
+
* sign with on the scraper side.
|
|
665
|
+
*
|
|
666
|
+
* Exposed only when the relay was asked for it (`--metrics`). A monitoring endpoint that appears
|
|
667
|
+
* by default is a surface nobody chose, and the whole point of the defaults elsewhere is that the
|
|
668
|
+
* relay binds nothing the operator did not ask for.
|
|
669
|
+
*/
|
|
670
|
+
if (method === 'GET' && path === '/metrics') {
|
|
671
|
+
if (!this.metricsEnabled) {
|
|
672
|
+
// 404 rather than 403: a disabled endpoint should look absent, not forbidden. A 403 tells a
|
|
673
|
+
// scanner the surface exists.
|
|
674
|
+
throw new ProtocolError('NOT_FOUND', 'metrics are not enabled on this relay', { hint: 'start the relay with --metrics' });
|
|
675
|
+
}
|
|
676
|
+
const body = renderMetrics(this.state, { nowMs: this.state.nowMs() });
|
|
677
|
+
res.writeHead(200, {
|
|
678
|
+
'content-type': METRICS_CONTENT_TYPE,
|
|
679
|
+
'content-length': Buffer.byteLength(body, 'utf8'),
|
|
680
|
+
'cache-control': 'no-store',
|
|
681
|
+
});
|
|
682
|
+
return res.end(body);
|
|
683
|
+
}
|
|
684
|
+
// §5: dispatching tasks needs the operator token, not a device token.
|
|
685
|
+
if (method === 'POST' && path === '/v1/task') {
|
|
686
|
+
const principal = this.authorizeTaskDispatch(req);
|
|
687
|
+
this.enforceSignature(req, { method, signedPath, body: rawBody });
|
|
688
|
+
const body = takeJson();
|
|
689
|
+
if (principal.machine_id && !body.created_by) body.created_by = principal.machine_id;
|
|
690
|
+
const result = this.state.createTask(body);
|
|
691
|
+
return sendJson(res, 200, result);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/* ---- everything below requires `Authorization: Bearer <device_token>` ---- */
|
|
695
|
+
const token = bearerToken(req);
|
|
696
|
+
const device = token ? this.state.authenticate(token) : null;
|
|
697
|
+
if (!device) {
|
|
698
|
+
throw new ProtocolError('UNAUTHORIZED', 'missing or invalid device_token', {});
|
|
699
|
+
}
|
|
700
|
+
this.state.touchDevice(device.machine_id);
|
|
701
|
+
|
|
702
|
+
// v0.3.0 §9: writes are always checked (tolerating unsigned unless required);
|
|
703
|
+
// reads are only checked when the relay was explicitly told to require
|
|
704
|
+
// signatures. See `enforceSignature` for the reasoning.
|
|
705
|
+
this.enforceSignature(req, { method, signedPath, body: rawBody });
|
|
706
|
+
|
|
707
|
+
if (method === 'GET' && path === '/v1/stream') {
|
|
708
|
+
return this.handleStream(req, res, url, device);
|
|
709
|
+
}
|
|
710
|
+
if (method === 'POST' && path === '/v1/heartbeat') {
|
|
711
|
+
const body = takeJson();
|
|
712
|
+
body.machine_id = body.machine_id ?? device.machine_id;
|
|
713
|
+
const result = this.state.heartbeat(body);
|
|
714
|
+
return sendJson(res, 200, result);
|
|
715
|
+
}
|
|
716
|
+
if (method === 'POST' && path === '/v1/result') {
|
|
717
|
+
const body = takeJson();
|
|
718
|
+
const result = this.state.submitResult(body);
|
|
719
|
+
const { _instanceKey, ...clean } = result;
|
|
720
|
+
return sendJson(res, 200, clean);
|
|
721
|
+
}
|
|
722
|
+
if (method === 'GET' && path === '/v1/devices') {
|
|
723
|
+
return sendJson(res, 200, {
|
|
724
|
+
protocol_version: PROTOCOL_VERSION,
|
|
725
|
+
rabbit_time: this.state.nowIso(),
|
|
726
|
+
devices: this.state.listDevices(),
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
if (method === 'GET' && path === '/v1/tasks') {
|
|
730
|
+
const rawLimit = url.searchParams.get('limit');
|
|
731
|
+
let limit = 50;
|
|
732
|
+
if (rawLimit !== null) {
|
|
733
|
+
limit = Number(rawLimit);
|
|
734
|
+
if (!Number.isInteger(limit) || limit < 1) {
|
|
735
|
+
throw new ProtocolError('BAD_REQUEST', 'limit must be a positive integer', { limit: rawLimit });
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
return sendJson(res, 200, {
|
|
739
|
+
protocol_version: PROTOCOL_VERSION,
|
|
740
|
+
rabbit_time: this.state.nowIso(),
|
|
741
|
+
tasks: this.state.listTasks({ limit }),
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
const taskMatch = /^\/v1\/tasks\/([^/]+)$/.exec(path);
|
|
746
|
+
const reportMatch = /^\/v1\/tasks\/([^/]+)\/report$/.exec(path);
|
|
747
|
+
// v0.3.0: cross-machine RTT status. The plugin may run on a different machine
|
|
748
|
+
// than the agent, so this has to come from the relay, not from a local file.
|
|
749
|
+
const agentStatusMatch = /^\/v1\/agents\/([^/]+)\/status$/.exec(path);
|
|
750
|
+
// v0.3.9: P2P candidate rendezvous. `/announce` is checked before the read route
|
|
751
|
+
// so that a machine literally named "announce" cannot shadow it.
|
|
752
|
+
const peerReadMatch = /^\/v1\/peer\/([^/]+)$/.exec(path);
|
|
753
|
+
|
|
754
|
+
/* ---- v0.3.9 P2P signalling ---- */
|
|
755
|
+
if (method === 'POST' && path === '/v1/peer/announce') {
|
|
756
|
+
const body = takeJson();
|
|
757
|
+
try {
|
|
758
|
+
// `device.machine_id`, never `body.machine_id`: a paired machine may only
|
|
759
|
+
// announce addresses for itself, or it could aim a peer's punch at a third party.
|
|
760
|
+
const result = this.peers.announce(device.machine_id, body);
|
|
761
|
+
return sendJson(res, 200, {
|
|
762
|
+
protocol_version: PROTOCOL_VERSION,
|
|
763
|
+
rabbit_time: this.state.nowIso(),
|
|
764
|
+
...result,
|
|
765
|
+
});
|
|
766
|
+
} catch (error) {
|
|
767
|
+
throw new ProtocolError('BAD_REQUEST', error.message, { code: error.code ?? 'BAD_ANNOUNCEMENT' });
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
if (method === 'GET' && peerReadMatch) {
|
|
772
|
+
const machineId = decodeURIComponent(peerReadMatch[1]);
|
|
773
|
+
const entry = this.peers.get(machineId);
|
|
774
|
+
if (!entry) {
|
|
775
|
+
// A named 404 rather than an empty list: "this machine has not announced" and
|
|
776
|
+
// "this machine announced nothing reachable" call for different responses, and
|
|
777
|
+
// an empty candidate list would send the caller into a punch that cannot work.
|
|
778
|
+
throw new ProtocolError('NOT_FOUND', `no live P2P announcement for ${machineId}`, {
|
|
779
|
+
machine_id: machineId,
|
|
780
|
+
hint: 'the peer must POST /v1/peer/announce first, and announcements expire',
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
return sendJson(res, 200, {
|
|
784
|
+
protocol_version: PROTOCOL_VERSION,
|
|
785
|
+
rabbit_time: this.state.nowIso(),
|
|
786
|
+
peer: entry,
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
if (method === 'GET' && agentStatusMatch) {
|
|
791
|
+
const machineId = decodeURIComponent(agentStatusMatch[1]);
|
|
792
|
+
const status = this.state.deviceStatus(machineId);
|
|
793
|
+
if (!status) {
|
|
794
|
+
throw new ProtocolError('NOT_FOUND', 'unknown machine_id', { machine_id: machineId });
|
|
795
|
+
}
|
|
796
|
+
return sendJson(res, 200, {
|
|
797
|
+
protocol_version: PROTOCOL_VERSION,
|
|
798
|
+
rabbit_time: this.state.nowIso(),
|
|
799
|
+
...status,
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
if (method === 'GET' && reportMatch) {
|
|
804
|
+
const taskId = decodeURIComponent(reportMatch[1]);
|
|
805
|
+
this.state.sweepExpired();
|
|
806
|
+
const format = url.searchParams.get('format') ?? 'json';
|
|
807
|
+
const report = buildReport(this.state, taskId, { format });
|
|
808
|
+
if (res.writableEnded) return undefined;
|
|
809
|
+
res.writeHead(200, {
|
|
810
|
+
'content-type': report.contentType,
|
|
811
|
+
'content-length': Buffer.byteLength(report.body, 'utf8'),
|
|
812
|
+
'cache-control': 'no-store',
|
|
813
|
+
});
|
|
814
|
+
return res.end(report.body);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
if (method === 'GET' && taskMatch) {
|
|
818
|
+
const taskId = decodeURIComponent(taskMatch[1]);
|
|
819
|
+
this.state.sweepExpired();
|
|
820
|
+
const task = this.state.requireTask(taskId);
|
|
821
|
+
const aggregate = aggregateTask(this.state, taskId);
|
|
822
|
+
return sendJson(res, 200, {
|
|
823
|
+
protocol_version: PROTOCOL_VERSION,
|
|
824
|
+
rabbit_time: this.state.nowIso(),
|
|
825
|
+
task: this.state.taskSummary(task),
|
|
826
|
+
leases: [...task.leases.values()],
|
|
827
|
+
aggregate,
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
throw new ProtocolError('NOT_FOUND', `no route for ${method} ${path}`, { method, path });
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/** §4: the operations answer — "is the relay there, did it restart, are my machines connected". */
|
|
835
|
+
healthBody(req) {
|
|
836
|
+
return {
|
|
837
|
+
...this.state.stats(),
|
|
838
|
+
// v0.3.9: how many machines currently have a live P2P announcement. Ephemeral,
|
|
839
|
+
// so this drops to 0 by itself when nobody is refreshing.
|
|
840
|
+
...this.peers.stats(),
|
|
841
|
+
ok: true,
|
|
842
|
+
protocol_version: PROTOCOL_VERSION,
|
|
843
|
+
rabbit_time: this.state.nowIso(),
|
|
844
|
+
uptime_ms: this.state.nowMs() - this._startedAtMs,
|
|
845
|
+
relay_id: this.relayId,
|
|
846
|
+
started_at: rfc3339(this._startedAtMs),
|
|
847
|
+
effective_scheme: this.effectiveScheme(req),
|
|
848
|
+
base_path: this.basePath,
|
|
849
|
+
operator_token_required: this.operatorTokenRequired,
|
|
850
|
+
pair_rate_limit: this.pairRateLimitPerMinute,
|
|
851
|
+
// v0.3.0: fleet-wide latency. Every numeric field is null (never 0) when no
|
|
852
|
+
// machine has reported a FRESH measurement.
|
|
853
|
+
rtt: this.state.rttSummary(),
|
|
854
|
+
// v0.3.0 §9: signature status only — never the secret, not even a prefix.
|
|
855
|
+
signing: {
|
|
856
|
+
configured: this.signingConfigured,
|
|
857
|
+
required: this.requireSignature,
|
|
858
|
+
previous_secret_accepted: this.signingSecrets.length > 1,
|
|
859
|
+
skew_seconds: this.signatureSkewSeconds,
|
|
860
|
+
},
|
|
861
|
+
persistence: this.persistence.describe(),
|
|
862
|
+
};
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/* ---------------- request signing (v0.3.0 §9) ---------------- */
|
|
866
|
+
|
|
867
|
+
/**
|
|
868
|
+
* Should this request be signature-checked at all?
|
|
869
|
+
*
|
|
870
|
+
* RULING (see PROTOCOL-v0.3.0.md §9.4): every WRITE is checked; READS are
|
|
871
|
+
* checked only when `--require-signature` is on.
|
|
872
|
+
*
|
|
873
|
+
* Reads are not checked in the default modes because unsigned reads are
|
|
874
|
+
* accepted anyway, so "verify when a signature happens to be present" would be
|
|
875
|
+
* theatre -- an attacker simply omits the headers -- while adding a brand new
|
|
876
|
+
* way for a poll or an SSE reconnect to fail. What a read leaks is topology,
|
|
877
|
+
* which the device token already gates; what a write can do is change state,
|
|
878
|
+
* which is what replay and tampering actually threaten.
|
|
879
|
+
*/
|
|
880
|
+
shouldCheckSignature(method) {
|
|
881
|
+
if (this.requireSignature) return true; // explicit hard mode: nothing is exempt
|
|
882
|
+
const isRead = method === 'GET' || method === 'HEAD' || method === 'OPTIONS';
|
|
883
|
+
return !isRead;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/** Run `verifyRequest` and throw the mapped §7 error on rejection. */
|
|
887
|
+
enforceSignature(req, { method, signedPath, body }) {
|
|
888
|
+
if (!this.shouldCheckSignature(method)) return { skipped: true };
|
|
889
|
+
const decision = this.verifySignature(req, { method, signedPath, body });
|
|
890
|
+
if (decision.ok !== true) throw signatureError(decision);
|
|
891
|
+
return decision;
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
/**
|
|
895
|
+
* Thin adapter over the frozen `signing.mjs` contract.
|
|
896
|
+
*
|
|
897
|
+
* `path` is the ROUTED path with the query string, NOT the raw request URL: with
|
|
898
|
+
* `--base-path /w2m` the same client must produce the same signature whether a
|
|
899
|
+
* proxy passes `/w2m/v1/result` through or strips it to `/v1/result`. The routed
|
|
900
|
+
* path is the only value both sides agree on in all three deployment forms, so
|
|
901
|
+
* clients sign `/v1/result` and never `https://host/w2m/v1/result`.
|
|
902
|
+
*/
|
|
903
|
+
verifySignature(req, { method, signedPath, body }) {
|
|
904
|
+
return verifyRequest({
|
|
905
|
+
headers: req.headers, // plain Node object; signing.mjs reads it case-insensitively
|
|
906
|
+
method,
|
|
907
|
+
path: signedPath,
|
|
908
|
+
body: body ?? '',
|
|
909
|
+
secrets: this.signingSecrets,
|
|
910
|
+
nonces: this.nonceCache,
|
|
911
|
+
nowMs: this.state.nowMs(),
|
|
912
|
+
skewSeconds: this.signatureSkewSeconds,
|
|
913
|
+
required: this.requireSignature,
|
|
914
|
+
});
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/** Periodic maintenance, called by the sweep timer. */
|
|
918
|
+
maintenance() {
|
|
919
|
+
const expired = this.sweep();
|
|
920
|
+
this.pairLimiter.prune();
|
|
921
|
+
this.peers.sweep();
|
|
922
|
+
// Keep the nonce cache from growing without bound in a long-running process.
|
|
923
|
+
this.nonceCache.prune(this.nonceRetentionMs, this.state.nowMs());
|
|
924
|
+
return expired;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/* ---------------- auth helpers ---------------- */
|
|
928
|
+
|
|
929
|
+
/** §6: every /v1/pair attempt counts, success included. */
|
|
930
|
+
enforcePairRateLimit(req) {
|
|
931
|
+
if (this.pairRateLimitPerMinute <= 0) return;
|
|
932
|
+
const ip = this.clientIp(req);
|
|
933
|
+
const verdict = this.pairLimiter.hit(ip);
|
|
934
|
+
if (verdict.allowed) return;
|
|
935
|
+
const err = new ProtocolError(
|
|
936
|
+
'RATE_LIMITED',
|
|
937
|
+
`too many /v1/pair attempts from ${ip}: limit is ${verdict.limit} per ${verdict.window_seconds}s`,
|
|
938
|
+
{
|
|
939
|
+
retry_after_seconds: verdict.retry_after_seconds,
|
|
940
|
+
limit: verdict.limit,
|
|
941
|
+
window_seconds: verdict.window_seconds,
|
|
942
|
+
scope: 'pair',
|
|
943
|
+
},
|
|
944
|
+
);
|
|
945
|
+
err.headers = { 'retry-after': String(verdict.retry_after_seconds) };
|
|
946
|
+
throw err;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* §5: `POST /v1/task` needs the operator token.
|
|
951
|
+
* - required (default): operator token, else 401 OPERATOR_REQUIRED
|
|
952
|
+
* - disabled (`--operator-token ''`): falls back to v1 semantics, i.e. a paired device token
|
|
953
|
+
*/
|
|
954
|
+
authorizeTaskDispatch(req) {
|
|
955
|
+
const token = bearerToken(req);
|
|
956
|
+
if (this.operatorTokenRequired) {
|
|
957
|
+
if (!token) {
|
|
958
|
+
throw new ProtocolError(
|
|
959
|
+
'OPERATOR_REQUIRED',
|
|
960
|
+
'POST /v1/task requires the operator token. A device_token is machine credentials and cannot '
|
|
961
|
+
+ `dispatch tasks; read the operator token from ${this.persistence.operatorTokenFile ?? '<state>/operator-token.txt'} `
|
|
962
|
+
+ 'or pass --operator-token / W2M_OPERATOR_TOKEN.',
|
|
963
|
+
{ operator_token_file: this.persistence.operatorTokenFile ?? null },
|
|
964
|
+
);
|
|
965
|
+
}
|
|
966
|
+
if (token !== this.operatorToken) {
|
|
967
|
+
const asDevice = this.state.authenticate(token);
|
|
968
|
+
throw new ProtocolError(
|
|
969
|
+
'OPERATOR_REQUIRED',
|
|
970
|
+
asDevice
|
|
971
|
+
? 'this is a device_token (machine credentials); dispatching tasks requires the operator_token — '
|
|
972
|
+
+ 'they are two different credentials'
|
|
973
|
+
: 'invalid operator token: it does not match the relay operator token',
|
|
974
|
+
{
|
|
975
|
+
device_token_detected: Boolean(asDevice),
|
|
976
|
+
machine_id: asDevice?.machine_id ?? null,
|
|
977
|
+
operator_token_file: this.persistence.operatorTokenFile ?? null,
|
|
978
|
+
},
|
|
979
|
+
);
|
|
980
|
+
}
|
|
981
|
+
return { role: 'operator', machine_id: null };
|
|
982
|
+
}
|
|
983
|
+
const device = token ? this.state.authenticate(token) : null;
|
|
984
|
+
if (!device) {
|
|
985
|
+
throw new ProtocolError(
|
|
986
|
+
'UNAUTHORIZED',
|
|
987
|
+
'the operator token requirement is disabled on this relay, but /v1/task still requires a valid device_token',
|
|
988
|
+
{},
|
|
989
|
+
);
|
|
990
|
+
}
|
|
991
|
+
return { role: 'device', machine_id: device.machine_id };
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/* ---------------- SSE (§3) ---------------- */
|
|
995
|
+
|
|
996
|
+
handleStream(req, res, url, device) {
|
|
997
|
+
const machineId = device.machine_id;
|
|
998
|
+
const requested = url.searchParams.get('machine_id');
|
|
999
|
+
if (requested !== null && requested !== machineId) {
|
|
1000
|
+
throw new ProtocolError('BAD_REQUEST', 'machine_id does not match the device_token', {
|
|
1001
|
+
machine_id: requested, token_machine_id: machineId,
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
let from = null;
|
|
1006
|
+
const seqParam = url.searchParams.get('seq');
|
|
1007
|
+
const lastEventId = req.headers['last-event-id'];
|
|
1008
|
+
if (seqParam !== null) {
|
|
1009
|
+
from = Number(seqParam);
|
|
1010
|
+
if (!Number.isInteger(from) || from < 1) {
|
|
1011
|
+
throw new ProtocolError('BAD_REQUEST', 'seq must be a positive integer', { seq: seqParam });
|
|
1012
|
+
}
|
|
1013
|
+
} else if (typeof lastEventId === 'string' && lastEventId.trim() !== '') {
|
|
1014
|
+
const last = Number(lastEventId);
|
|
1015
|
+
if (!Number.isInteger(last) || last < 0) {
|
|
1016
|
+
throw new ProtocolError('BAD_REQUEST', 'Last-Event-ID must be a non-negative integer', { last_event_id: lastEventId });
|
|
1017
|
+
}
|
|
1018
|
+
from = last + 1;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
// §8.5: these two headers are the fix for "connects but no events" behind
|
|
1022
|
+
// nginx-style proxies and tunnels, which buffer event streams by default.
|
|
1023
|
+
res.writeHead(200, {
|
|
1024
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
1025
|
+
'cache-control': 'no-cache, no-transform',
|
|
1026
|
+
connection: 'keep-alive',
|
|
1027
|
+
'x-accel-buffering': 'no',
|
|
1028
|
+
});
|
|
1029
|
+
if (typeof res.flushHeaders === 'function') res.flushHeaders();
|
|
1030
|
+
|
|
1031
|
+
const sub = { machineId, res, write: null, timer: null, closed: false };
|
|
1032
|
+
|
|
1033
|
+
const writeEvent = (event) => {
|
|
1034
|
+
if (res.writableEnded) return;
|
|
1035
|
+
res.write(`id: ${event.seq}\nevent: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
|
|
1036
|
+
};
|
|
1037
|
+
|
|
1038
|
+
let live = false;
|
|
1039
|
+
const queue = [];
|
|
1040
|
+
const deliver = (entry) => {
|
|
1041
|
+
if (entry.target && entry.target !== machineId) return;
|
|
1042
|
+
writeEvent(entry.event);
|
|
1043
|
+
};
|
|
1044
|
+
const unsubscribe = this.state.onEvent((entry) => {
|
|
1045
|
+
if (!live) { queue.push(entry); return; }
|
|
1046
|
+
deliver(entry);
|
|
1047
|
+
});
|
|
1048
|
+
|
|
1049
|
+
// §3.1: the first frame MUST be `ready`; §8.3 adds `relay_id`.
|
|
1050
|
+
const readySeq = this.state.reserveSeq();
|
|
1051
|
+
writeEvent({
|
|
1052
|
+
type: 'ready',
|
|
1053
|
+
seq: readySeq,
|
|
1054
|
+
protocol_version: PROTOCOL_VERSION,
|
|
1055
|
+
rabbit_time: this.state.nowIso(),
|
|
1056
|
+
machine_id: machineId,
|
|
1057
|
+
relay_id: this.relayId,
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
if (from !== null) {
|
|
1061
|
+
const oldest = this.state.oldestBufferedSeq();
|
|
1062
|
+
const lastBuffered = this.state.lastBufferedSeq();
|
|
1063
|
+
// `fromFuture` is judged against EVERY seq this process has allocated (a client
|
|
1064
|
+
// can legitimately hold a `ready` seq, which is not buffered), while the
|
|
1065
|
+
// reported window bounds describe the replay ring the client can actually use.
|
|
1066
|
+
const lastSeq = this.state.lastSeq();
|
|
1067
|
+
// Two distinct ways for a cursor to be out of sync with THIS process:
|
|
1068
|
+
//
|
|
1069
|
+
// from < oldest the cursor is older than the replay window (buffer rot)
|
|
1070
|
+
// from > lastSeq+1 the cursor cannot exist here at all. Sequence numbers
|
|
1071
|
+
// restart at 1 in every process, so a cursor from a
|
|
1072
|
+
// previous relay process is AHEAD of a freshly started
|
|
1073
|
+
// relay. This was the silent case: `13 < 1` is false, the
|
|
1074
|
+
// client got no notice, `replayEntries(13)` matched
|
|
1075
|
+
// nothing, and the agent then reset its cursor to the
|
|
1076
|
+
// `ready` seq -- stepping over every event it had missed.
|
|
1077
|
+
const tooOld = from < oldest && oldest > 1;
|
|
1078
|
+
const fromFuture = from > lastSeq + 1;
|
|
1079
|
+
if (tooOld || fromFuture) {
|
|
1080
|
+
// §8.4: tell the client where to re-align, not just that replay failed.
|
|
1081
|
+
writeEvent({
|
|
1082
|
+
type: 'notice',
|
|
1083
|
+
seq: this.state.reserveSeq(),
|
|
1084
|
+
rabbit_time: this.state.nowIso(),
|
|
1085
|
+
level: 'warn',
|
|
1086
|
+
code: 'REPLAY_TRUNCATED',
|
|
1087
|
+
message: tooOld
|
|
1088
|
+
? `requested seq ${from} is older than the buffer head ${oldest}`
|
|
1089
|
+
: `requested seq ${from} is beyond this process' last seq ${lastSeq}: the cursor came from a `
|
|
1090
|
+
+ 'previous relay process, whose sequence numbering restarted at 1',
|
|
1091
|
+
machine_id: machineId,
|
|
1092
|
+
requested_seq: from,
|
|
1093
|
+
oldest_available_seq: oldest,
|
|
1094
|
+
last_available_seq: lastBuffered,
|
|
1095
|
+
relay_id: this.relayId,
|
|
1096
|
+
reason: tooOld ? 'buffer_truncated' : 'cursor_ahead_of_relay',
|
|
1097
|
+
});
|
|
1098
|
+
}
|
|
1099
|
+
// A cursor from a previous process is meaningless here, so honouring it
|
|
1100
|
+
// literally (replay from `from`) would deliver NOTHING and also lose every
|
|
1101
|
+
// event that fell into the gap -- not only offers, but `task.cancel`,
|
|
1102
|
+
// `notice`, `peer.*`. Re-align to the head of the window instead: the client
|
|
1103
|
+
// asked to be resynchronised, and the ring is exactly what we still have.
|
|
1104
|
+
const replayFrom = fromFuture ? oldest : from;
|
|
1105
|
+
for (const entry of this.state.replayEntries(replayFrom)) deliver(entry);
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
live = true;
|
|
1109
|
+
for (const entry of queue) deliver(entry);
|
|
1110
|
+
queue.length = 0;
|
|
1111
|
+
|
|
1112
|
+
// §8.6: re-state work this machine was handed while its stream was down.
|
|
1113
|
+
//
|
|
1114
|
+
// An offer is a single event; if the stream is not connected at that instant
|
|
1115
|
+
// -- after a relay restart, a tunnel drop, or any interruption -- the event
|
|
1116
|
+
// is lost and the lease waits out its whole window before being swept. The
|
|
1117
|
+
// machine is meanwhile online and idle. Re-delivering on connect is what
|
|
1118
|
+
// makes a reconnect recover work instead of silently losing it.
|
|
1119
|
+
//
|
|
1120
|
+
// Only unclaimed leases are re-offered; `running` is excluded so a task that
|
|
1121
|
+
// has already begun cannot be handed out a second time and executed twice.
|
|
1122
|
+
const redelivered = this.state.redeliverPendingOffers(machineId);
|
|
1123
|
+
if (redelivered.count > 0) {
|
|
1124
|
+
this._log(`[w2m-rabbit] re-offered ${redelivered.count} pending task(s) to ${machineId} on reconnect`);
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
// §3.2: `: keepalive` comment every 15s.
|
|
1128
|
+
sub.timer = setInterval(() => {
|
|
1129
|
+
if (res.writableEnded) return;
|
|
1130
|
+
res.write(': keepalive\n\n');
|
|
1131
|
+
}, this.keepaliveMs);
|
|
1132
|
+
sub.timer.unref?.();
|
|
1133
|
+
|
|
1134
|
+
sub.write = writeEvent;
|
|
1135
|
+
sub.unsubscribe = unsubscribe;
|
|
1136
|
+
this.subscribers.add(sub);
|
|
1137
|
+
device.streams = (device.streams ?? 0) + 1;
|
|
1138
|
+
// v0.3.0: relay-observed reconnect counter. A first attach is not a reconnect,
|
|
1139
|
+
// so the reported `reconnect_attempts` is (attaches - 1).
|
|
1140
|
+
this.state.noteStreamConnect(machineId);
|
|
1141
|
+
|
|
1142
|
+
const onClose = () => this._closeSubscriber(sub);
|
|
1143
|
+
req.on('close', onClose);
|
|
1144
|
+
res.on('close', onClose);
|
|
1145
|
+
res.on('error', onClose);
|
|
1146
|
+
return undefined;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
_closeSubscriber(sub) {
|
|
1150
|
+
if (!sub || sub.closed) return;
|
|
1151
|
+
sub.closed = true;
|
|
1152
|
+
this.subscribers.delete(sub);
|
|
1153
|
+
if (sub.timer) clearInterval(sub.timer);
|
|
1154
|
+
sub.timer = null;
|
|
1155
|
+
sub.unsubscribe?.();
|
|
1156
|
+
const device = this.state.devices.get(sub.machineId);
|
|
1157
|
+
if (device) {
|
|
1158
|
+
device.streams = Math.max(0, (device.streams ?? 1) - 1);
|
|
1159
|
+
if (device.streams === 0) this.state.setOnline(sub.machineId, false, 'stream_closed');
|
|
1160
|
+
}
|
|
1161
|
+
if (!sub.res.writableEnded) {
|
|
1162
|
+
try { sub.res.end(); } catch { /* already gone */ }
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
/** Convenience factory. */
|
|
1168
|
+
export function createRelayServer(options = {}) {
|
|
1169
|
+
return new RelayServer(options);
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
/** Create + listen in one call. */
|
|
1173
|
+
export async function startRelayServer(options = {}) {
|
|
1174
|
+
const relay = createRelayServer(options);
|
|
1175
|
+
await relay.listen(options);
|
|
1176
|
+
return relay;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
export {
|
|
1180
|
+
Persistence,
|
|
1181
|
+
ProtocolError,
|
|
1182
|
+
RabbitState,
|
|
1183
|
+
MAX_FRAME_BYTES,
|
|
1184
|
+
PROTOCOL_VERSION,
|
|
1185
|
+
rfc3339,
|
|
1186
|
+
};
|
|
1187
|
+
export default createRelayServer;
|