@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,396 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* w2m-rabbit — start the W2M relay (Rabbit).
|
|
3
|
+
*
|
|
4
|
+
* The relay is a plain `node:http` server with no third-party dependencies, so
|
|
5
|
+
* it can run on any machine that has Node: a laptop, a small VPS, or whichever
|
|
6
|
+
* of your machines happens to be on all the time.
|
|
7
|
+
*
|
|
8
|
+
* Design notes that matter at this layer:
|
|
9
|
+
*
|
|
10
|
+
* * The pairing code is the only credential that grants admission, it is shown
|
|
11
|
+
* once, and it is consumed on first successful use. A relay that started
|
|
12
|
+
* silently accepting unknown devices would be a relay anyone on the LAN could
|
|
13
|
+
* join.
|
|
14
|
+
* * State lives in `--state`, and `state.json` records the pairing code so that
|
|
15
|
+
* restarting the relay does not silently invalidate every paired machine.
|
|
16
|
+
* * Nothing about a project is read here. The relay never holds a working copy
|
|
17
|
+
* and never holds a model credential.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { parseArgs } from 'node:util';
|
|
21
|
+
import { createRelayServer } from '../src/relay/server.mjs';
|
|
22
|
+
import { resolveStateDir, formatError } from '../src/util/cli.mjs';
|
|
23
|
+
|
|
24
|
+
const USAGE = `w2m-rabbit — W2M relay (Rabbit)
|
|
25
|
+
|
|
26
|
+
Usage:
|
|
27
|
+
w2m-rabbit [options]
|
|
28
|
+
|
|
29
|
+
Deployment:
|
|
30
|
+
--host <addr> Bind address. Default 127.0.0.1. Use 0.0.0.0 to accept
|
|
31
|
+
other machines (see README, "Security").
|
|
32
|
+
--port <n> Port. Default 8787. 0 asks the OS for a free port.
|
|
33
|
+
--base-path <p> Prefix the relay is mounted under, e.g. /w2m, for a
|
|
34
|
+
reverse proxy or tunnel that forwards the prefix
|
|
35
|
+
through unchanged. Default /. [env W2M_BASE_PATH]
|
|
36
|
+
--trust-proxy Believe X-Forwarded-Proto / X-Forwarded-For. Enable ONLY
|
|
37
|
+
behind a proxy you control: otherwise a client can forge
|
|
38
|
+
its address and bypass the pairing rate limit.
|
|
39
|
+
--tls-cert <file> Terminate TLS here. Requires --tls-key.
|
|
40
|
+
--tls-key <file> Private key for --tls-cert.
|
|
41
|
+
--pair-rate-limit <n> Pairing attempts per IP per minute. Default 5. 0 = off.
|
|
42
|
+
|
|
43
|
+
Credentials and state:
|
|
44
|
+
--operator-token <t> Token that authorises SENDING work. Generated and
|
|
45
|
+
written to <state>/operator-token.txt if omitted.
|
|
46
|
+
Pass an empty string to remove the requirement.
|
|
47
|
+
[env W2M_OPERATOR_TOKEN]
|
|
48
|
+
--state <dir> State directory. Default <DSH_HOME>/xclient/rabbit.
|
|
49
|
+
--no-persist Keep everything in memory; forget devices and tasks on
|
|
50
|
+
restart.
|
|
51
|
+
--pairing-code <c> Use a fixed pairing code instead of a generated one.
|
|
52
|
+
|
|
53
|
+
Request signing (v0.3.0):
|
|
54
|
+
--signing-secret <s> Shared secret for HMAC-signed requests. Unset means the relay does
|
|
55
|
+
not verify signatures at all, which is the v0.2.3 behaviour.
|
|
56
|
+
Pass an empty string to switch signing off explicitly.
|
|
57
|
+
[env W2M_SIGNING_SECRET]
|
|
58
|
+
--signing-secret-previous <s>
|
|
59
|
+
The outgoing secret during a rotation. Both are accepted, so machines
|
|
60
|
+
can be moved over one at a time instead of all at once.
|
|
61
|
+
Requires --signing-secret.
|
|
62
|
+
--require-signature Reject unsigned requests with 401 SIGNATURE_REQUIRED. Only takes
|
|
63
|
+
effect once a secret is configured; the relay refuses to start if
|
|
64
|
+
this is set without one, rather than silently verifying nothing.
|
|
65
|
+
--signature-skew <s> Allowed clock difference in seconds. Default 120.
|
|
66
|
+
|
|
67
|
+
Observability:
|
|
68
|
+
--metrics Serve GET /metrics in Prometheus text format. Off by default, and
|
|
69
|
+
token-free like /healthz, so a scraper never needs a credential in
|
|
70
|
+
its own config file.
|
|
71
|
+
|
|
72
|
+
Tuning:
|
|
73
|
+
--heartbeat-ms <n> Expected heartbeat interval. Default 10000.
|
|
74
|
+
--grace-ms <n> Extra grace before a silent lease is expired. Default 30000.
|
|
75
|
+
--json Print machine-readable status lines instead of prose.
|
|
76
|
+
-h, --help Show this help.
|
|
77
|
+
|
|
78
|
+
Exit codes:
|
|
79
|
+
0 stopped normally (SIGINT/SIGTERM)
|
|
80
|
+
2 bad usage
|
|
81
|
+
3 failed to bind or to start
|
|
82
|
+
`;
|
|
83
|
+
|
|
84
|
+
function fail(message, code = 2) {
|
|
85
|
+
process.stderr.write(`w2m-rabbit: ${message}\n`);
|
|
86
|
+
process.exit(code);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let parsed;
|
|
90
|
+
try {
|
|
91
|
+
parsed = parseArgs({
|
|
92
|
+
options: {
|
|
93
|
+
host: { type: 'string' },
|
|
94
|
+
port: { type: 'string' },
|
|
95
|
+
state: { type: 'string' },
|
|
96
|
+
'pairing-code': { type: 'string' },
|
|
97
|
+
'heartbeat-ms': { type: 'string' },
|
|
98
|
+
'grace-ms': { type: 'string' },
|
|
99
|
+
'base-path': { type: 'string' },
|
|
100
|
+
'trust-proxy': { type: 'boolean', default: false },
|
|
101
|
+
'tls-cert': { type: 'string' },
|
|
102
|
+
'tls-key': { type: 'string' },
|
|
103
|
+
'pair-rate-limit': { type: 'string' },
|
|
104
|
+
'operator-token': { type: 'string' },
|
|
105
|
+
// v0.3.0 request signing. `--signing-secret ''` switches signing off explicitly.
|
|
106
|
+
'signing-secret': { type: 'string' },
|
|
107
|
+
'signing-secret-previous': { type: 'string' },
|
|
108
|
+
'require-signature': { type: 'boolean', default: false },
|
|
109
|
+
metrics: { type: 'boolean', default: false },
|
|
110
|
+
'signature-skew': { type: 'string' },
|
|
111
|
+
'no-persist': { type: 'boolean', default: false },
|
|
112
|
+
json: { type: 'boolean', default: false },
|
|
113
|
+
help: { type: 'boolean', short: 'h', default: false },
|
|
114
|
+
},
|
|
115
|
+
allowPositionals: false,
|
|
116
|
+
});
|
|
117
|
+
} catch (error) {
|
|
118
|
+
fail(`${error.message}\n\n${USAGE}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const { values } = parsed;
|
|
122
|
+
if (values.help) {
|
|
123
|
+
process.stdout.write(USAGE);
|
|
124
|
+
process.exit(0);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const port = values.port === undefined ? 8787 : Number(values.port);
|
|
128
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
129
|
+
fail(`--port must be an integer in 0..65535, got ${JSON.stringify(values.port)}`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const heartbeatMs = values['heartbeat-ms'] === undefined ? undefined : Number(values['heartbeat-ms']);
|
|
133
|
+
if (heartbeatMs !== undefined && (!Number.isFinite(heartbeatMs) || heartbeatMs <= 0)) {
|
|
134
|
+
fail(`--heartbeat-ms must be a positive number, got ${JSON.stringify(values['heartbeat-ms'])}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const graceMs = values['grace-ms'] === undefined ? undefined : Number(values['grace-ms']);
|
|
138
|
+
if (graceMs !== undefined && (!Number.isFinite(graceMs) || graceMs < 0)) {
|
|
139
|
+
fail(`--grace-ms must be a non-negative number, got ${JSON.stringify(values['grace-ms'])}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/* ---------- v0.1.2 deployment options ---------- */
|
|
143
|
+
|
|
144
|
+
// A base path is normalised to either '/' or '/prefix' with no trailing slash,
|
|
145
|
+
// so the router can compare one canonical form instead of guessing.
|
|
146
|
+
const rawBasePath = values['base-path'] ?? process.env.W2M_BASE_PATH ?? '/';
|
|
147
|
+
let basePath = String(rawBasePath).trim();
|
|
148
|
+
if (basePath === '' || basePath === '/') {
|
|
149
|
+
basePath = '/';
|
|
150
|
+
} else {
|
|
151
|
+
if (!basePath.startsWith('/')) basePath = `/${basePath}`;
|
|
152
|
+
basePath = basePath.replace(/\/+$/, '');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const tlsCert = values['tls-cert'];
|
|
156
|
+
const tlsKey = values['tls-key'];
|
|
157
|
+
if ((tlsCert && !tlsKey) || (!tlsCert && tlsKey)) {
|
|
158
|
+
fail('--tls-cert and --tls-key must be given together');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const pairRateLimit = values['pair-rate-limit'] === undefined
|
|
162
|
+
? undefined
|
|
163
|
+
: Number(values['pair-rate-limit']);
|
|
164
|
+
if (pairRateLimit !== undefined && (!Number.isInteger(pairRateLimit) || pairRateLimit < 0)) {
|
|
165
|
+
fail(`--pair-rate-limit must be a non-negative integer, got ${JSON.stringify(values['pair-rate-limit'])}`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Distinguish "not supplied" (generate one) from "supplied empty" (explicitly
|
|
169
|
+
// turn the requirement off). `parseArgs` gives the same value for both, so the
|
|
170
|
+
// presence of the flag is checked separately -- silently treating `''` as
|
|
171
|
+
// "generate one" would leave a user who asked for no operator token with one
|
|
172
|
+
// they do not know.
|
|
173
|
+
const operatorTokenFlag = process.argv.some((a) => a === '--operator-token' || a.startsWith('--operator-token='));
|
|
174
|
+
const operatorTokenValue = values['operator-token'] ?? process.env.W2M_OPERATOR_TOKEN;
|
|
175
|
+
const operatorToken = operatorTokenValue === undefined
|
|
176
|
+
? undefined
|
|
177
|
+
: (String(operatorTokenValue) === '' ? null : String(operatorTokenValue));
|
|
178
|
+
const operatorTokenRequired = !(operatorTokenFlag && operatorToken === null);
|
|
179
|
+
|
|
180
|
+
/* ---------- v0.3.0 request signing ---------- */
|
|
181
|
+
|
|
182
|
+
// `''` means "explicitly off", the same convention as --operator-token. A *non-string* value is a
|
|
183
|
+
// configuration error and must not become "signing disabled": silently losing signature
|
|
184
|
+
// verification because a flag was misspelled is precisely the failure the signing module is written
|
|
185
|
+
// to prevent, and it is invisible from the client side.
|
|
186
|
+
/**
|
|
187
|
+
* Resolve one signing secret from a flag, falling back to an environment variable.
|
|
188
|
+
*
|
|
189
|
+
* @param {unknown} flagValue - Value from `--signing-secret*`.
|
|
190
|
+
* @param {string} envName - Environment variable to fall back to.
|
|
191
|
+
* @param {string} label - Flag name for the error message.
|
|
192
|
+
* @returns {string|undefined} The secret, or undefined when not configured.
|
|
193
|
+
*/
|
|
194
|
+
function resolveSigningSecret(flagValue, envName, label) {
|
|
195
|
+
const raw = flagValue ?? process.env[envName];
|
|
196
|
+
if (raw === undefined) return undefined;
|
|
197
|
+
if (typeof raw !== 'string') {
|
|
198
|
+
fail(
|
|
199
|
+
`${label} must be a string; got ${typeof raw}. Signing is never silently disabled -- ` +
|
|
200
|
+
'pass an empty string to switch it off deliberately.',
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
if (raw === '') return undefined; // explicit opt-out
|
|
204
|
+
return raw;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const signingSecret = resolveSigningSecret(values['signing-secret'], 'W2M_SIGNING_SECRET', '--signing-secret');
|
|
208
|
+
const signingSecretPrevious = resolveSigningSecret(
|
|
209
|
+
values['signing-secret-previous'],
|
|
210
|
+
'W2M_SIGNING_SECRET_PREVIOUS',
|
|
211
|
+
'--signing-secret-previous',
|
|
212
|
+
);
|
|
213
|
+
const requireSignature = values['require-signature'] === true;
|
|
214
|
+
|
|
215
|
+
// Requiring a signature with no secret to verify against is a contradiction, and the relay must say
|
|
216
|
+
// so at startup rather than accept every request and report 500 on each one.
|
|
217
|
+
if (requireSignature && signingSecret === undefined) {
|
|
218
|
+
fail(
|
|
219
|
+
'--require-signature needs --signing-secret (or W2M_SIGNING_SECRET). Refusing to start: ' +
|
|
220
|
+
'every request would be rejected as a server misconfiguration instead of being verified.',
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
if (signingSecretPrevious !== undefined && signingSecret === undefined) {
|
|
224
|
+
fail('--signing-secret-previous only makes sense alongside --signing-secret. Refusing to start.');
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const skewRaw = values['signature-skew'];
|
|
228
|
+
const signatureSkewSeconds = skewRaw === undefined ? undefined : Number(skewRaw);
|
|
229
|
+
if (signatureSkewSeconds !== undefined && (!Number.isFinite(signatureSkewSeconds) || signatureSkewSeconds <= 0)) {
|
|
230
|
+
fail(`--signature-skew must be a positive number of seconds, got ${JSON.stringify(skewRaw)}`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const stateDir = resolveStateDir(values.state, 'rabbit');
|
|
234
|
+
// Diagnostics go to stderr, data goes to stdout. `--json` promises one machine
|
|
235
|
+
// readable line on stdout, so the pairing-code rotation notice must not land
|
|
236
|
+
// there and break `| jq` / `| ConvertFrom-Json`.
|
|
237
|
+
const log = (line) => process.stdout.write(`${line}\n`);
|
|
238
|
+
const diag = (line) => process.stderr.write(`${line}\n`);
|
|
239
|
+
|
|
240
|
+
let relay;
|
|
241
|
+
let listenInfo;
|
|
242
|
+
let pairingCode;
|
|
243
|
+
try {
|
|
244
|
+
relay = createRelayServer({
|
|
245
|
+
stateDir,
|
|
246
|
+
pairingCode: values['pairing-code'],
|
|
247
|
+
heartbeatIntervalMs: heartbeatMs,
|
|
248
|
+
leaseGraceMs: graceMs,
|
|
249
|
+
// v0.1.2 deployment options
|
|
250
|
+
basePath,
|
|
251
|
+
trustProxy: Boolean(values['trust-proxy']),
|
|
252
|
+
tlsCert: tlsCert ?? null,
|
|
253
|
+
tlsKey: tlsKey ?? null,
|
|
254
|
+
operatorToken,
|
|
255
|
+
operatorTokenRequired,
|
|
256
|
+
pairRateLimitPerMinute: pairRateLimit,
|
|
257
|
+
persist: !values['no-persist'],
|
|
258
|
+
// v0.3.0 request signing. Undefined means "not configured", which the relay treats as
|
|
259
|
+
// "do not verify" -- byte-for-byte the v0.2.3 behaviour.
|
|
260
|
+
signingSecret,
|
|
261
|
+
signingSecretPrevious,
|
|
262
|
+
requireSignature,
|
|
263
|
+
metrics: values.metrics === true,
|
|
264
|
+
signatureSkewSeconds,
|
|
265
|
+
});
|
|
266
|
+
// The pairing code rotates on every successful pairing, so the CLI has to
|
|
267
|
+
// follow it rather than print it once: an operator pairing a second machine
|
|
268
|
+
// needs the *current* code, not the one that was already consumed.
|
|
269
|
+
//
|
|
270
|
+
// Listeners receive the buffered *entry* (`{seq, event, target}`), not the
|
|
271
|
+
// event itself -- `entry.event` is the frame that goes on the wire.
|
|
272
|
+
relay.state.onEvent((entry) => {
|
|
273
|
+
const event = entry?.event ?? entry;
|
|
274
|
+
if (event?.type === 'notice' && event.code === 'PAIRING_CODE_ROTATED' && event.pairing_code) {
|
|
275
|
+
pairingCode = event.pairing_code;
|
|
276
|
+
// In --json mode this must not touch stdout: a rotation would otherwise
|
|
277
|
+
// corrupt the very output the flag exists to produce.
|
|
278
|
+
const out = values.json ? diag : log;
|
|
279
|
+
out('');
|
|
280
|
+
out(' Pairing code rotated after a successful pairing.');
|
|
281
|
+
out(` 新的配对码 / NEW PAIRING CODE: ${pairingCode}`);
|
|
282
|
+
out('');
|
|
283
|
+
}
|
|
284
|
+
});
|
|
285
|
+
listenInfo = await relay.listen({
|
|
286
|
+
host: values.host ?? '127.0.0.1',
|
|
287
|
+
port,
|
|
288
|
+
});
|
|
289
|
+
pairingCode = listenInfo.pairingCode;
|
|
290
|
+
} catch (error) {
|
|
291
|
+
fail(formatError(error), 3);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const boundPort = listenInfo.port;
|
|
295
|
+
const boundHost = listenInfo.host;
|
|
296
|
+
const scheme = listenInfo.scheme ?? (tlsCert ? 'https' : 'http');
|
|
297
|
+
// Prefer what the relay reports (it knows whether TLS is active); fall back to
|
|
298
|
+
// the bound address so a 0.0.0.0 bind is not advertised as a usable URL.
|
|
299
|
+
const publicUrl = listenInfo.publicUrl ?? `${scheme}://${boundHost === '0.0.0.0' ? '<this-host>' : boundHost}:${boundPort}${basePath === '/' ? '' : basePath}`;
|
|
300
|
+
const resolvedOperatorToken = listenInfo.operatorToken ?? operatorToken ?? null;
|
|
301
|
+
|
|
302
|
+
if (values.json) {
|
|
303
|
+
log(JSON.stringify({
|
|
304
|
+
event: 'listening',
|
|
305
|
+
url: listenInfo.url,
|
|
306
|
+
publicUrl,
|
|
307
|
+
host: boundHost,
|
|
308
|
+
port: boundPort,
|
|
309
|
+
scheme,
|
|
310
|
+
basePath,
|
|
311
|
+
stateDir,
|
|
312
|
+
persist: !values['no-persist'],
|
|
313
|
+
trustProxy: Boolean(values['trust-proxy']),
|
|
314
|
+
pairRateLimit: pairRateLimit ?? 5,
|
|
315
|
+
operatorTokenRequired,
|
|
316
|
+
// The token itself is deliberately absent. This line is the one people pipe
|
|
317
|
+
// into a log or a file, and a credential written there is a credential
|
|
318
|
+
// leaked; it is printed to stderr and stored at
|
|
319
|
+
// <stateDir>/operator-token.txt (0600), which is where a secret belongs.
|
|
320
|
+
operatorTokenPresent: Boolean(operatorTokenRequired && resolvedOperatorToken),
|
|
321
|
+
pairingCode,
|
|
322
|
+
relayId: listenInfo.relayId ?? null,
|
|
323
|
+
protocolVersion: 1,
|
|
324
|
+
}));
|
|
325
|
+
} else {
|
|
326
|
+
log('');
|
|
327
|
+
log(' W2M relay (Rabbit) is listening.');
|
|
328
|
+
log('');
|
|
329
|
+
log(` URL ${publicUrl}`);
|
|
330
|
+
log(` Bind ${boundHost}:${boundPort}${scheme === 'https' ? ' (TLS)' : ''}`);
|
|
331
|
+
if (basePath !== '/') log(` Base path ${basePath}`);
|
|
332
|
+
log(` State ${stateDir}${values['no-persist'] ? ' (persistence DISABLED)' : ''}`);
|
|
333
|
+
log(` Protocol v1 relay id ${listenInfo.relayId ?? '(none)'}`);
|
|
334
|
+
|
|
335
|
+
if (values['trust-proxy']) {
|
|
336
|
+
log('');
|
|
337
|
+
log(' NOTE: --trust-proxy is ON. The relay believes X-Forwarded-For and');
|
|
338
|
+
log(' X-Forwarded-Proto. Only safe behind a proxy you control.');
|
|
339
|
+
}
|
|
340
|
+
if (boundHost === '0.0.0.0' && scheme === 'http') {
|
|
341
|
+
log('');
|
|
342
|
+
log(' NOTE: bound to 0.0.0.0 over plain HTTP. Fine on a Tailscale/WireGuard');
|
|
343
|
+
log(' address (already encrypted); NOT fine on a public interface --');
|
|
344
|
+
log(' use --tls-cert/--tls-key or a TLS-terminating proxy there.');
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
log('');
|
|
348
|
+
if (operatorTokenRequired && resolvedOperatorToken) {
|
|
349
|
+
log(' ┌────────────────────────────────────────────────────────────┐');
|
|
350
|
+
log(' │ OPERATOR TOKEN (needed to SEND instructions, keep secret) │');
|
|
351
|
+
log(` │ ${String(resolvedOperatorToken).padEnd(56)}│`);
|
|
352
|
+
log(' └────────────────────────────────────────────────────────────┘');
|
|
353
|
+
log(` Also written to ${stateDir}/operator-token.txt`);
|
|
354
|
+
log(' Device tokens cannot send work; this is what authorises it.');
|
|
355
|
+
} else if (!operatorTokenRequired) {
|
|
356
|
+
log(' ⚠ OPERATOR TOKEN REQUIREMENT IS OFF (--operator-token "").');
|
|
357
|
+
log(' Any paired device can send work to any other. Trusted LAN only.');
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
log('');
|
|
361
|
+
if (pairingCode) {
|
|
362
|
+
log(' ┌──────────────────────────────────────────────┐');
|
|
363
|
+
log(` │ 配对码 / PAIRING CODE: ${String(pairingCode).padEnd(19)}│`);
|
|
364
|
+
log(' └──────────────────────────────────────────────┘');
|
|
365
|
+
log('');
|
|
366
|
+
log(' Run this on every machine you want in the group:');
|
|
367
|
+
log('');
|
|
368
|
+
log(` w2m-localside --rabbit ${listenInfo.url} --pair ${pairingCode} \\`);
|
|
369
|
+
log(' --project <path-to-project> --name <machine-name>');
|
|
370
|
+
log('');
|
|
371
|
+
log(' The code rotates after each successful pairing; the new one is');
|
|
372
|
+
log(' printed here, so you can pair the next machine without restarting.');
|
|
373
|
+
}
|
|
374
|
+
if (boundHost === '0.0.0.0') {
|
|
375
|
+
log(' NOTE: bound to 0.0.0.0 -- other machines on your network can reach this');
|
|
376
|
+
log(' relay. It carries no TLS and no authentication beyond device tokens.');
|
|
377
|
+
log('');
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
let stopping = false;
|
|
382
|
+
async function shutdown(signal) {
|
|
383
|
+
if (stopping) return;
|
|
384
|
+
stopping = true;
|
|
385
|
+
if (!values.json) log(`\n ${signal} received, shutting down...`);
|
|
386
|
+
try {
|
|
387
|
+
await relay.close();
|
|
388
|
+
} catch (error) {
|
|
389
|
+
process.stderr.write(`w2m-rabbit: error while closing: ${formatError(error)}\n`);
|
|
390
|
+
process.exit(1);
|
|
391
|
+
}
|
|
392
|
+
process.exit(0);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
process.on('SIGINT', () => { void shutdown('SIGINT'); });
|
|
396
|
+
process.on('SIGTERM', () => { void shutdown('SIGTERM'); });
|
package/bin/w2m-stun.mjs
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* w2m-stun — run the W2M STUN responder (RFC 5389 Binding server).
|
|
3
|
+
*
|
|
4
|
+
* This is the other half of `src/agent/stun.mjs`. Every machine that wants a
|
|
5
|
+
* direct path must learn the *public* address a peer can reach it on, and the
|
|
6
|
+
* port a NAT hands out cannot be guessed from the inside: a host sees its own
|
|
7
|
+
* `192.168.x.y`, the peer must be told the translated address. That translation is
|
|
8
|
+
* the one fact only an outside observer can supply, which is what this process is.
|
|
9
|
+
*
|
|
10
|
+
* Design notes that matter at this layer:
|
|
11
|
+
*
|
|
12
|
+
* * **UDP only, and stateless.** Each datagram is answered from the address it
|
|
13
|
+
* arrived on and from nothing else. Nothing is stored, so there is no state for
|
|
14
|
+
* an attacker to exhaust and no session to hijack.
|
|
15
|
+
* * **Not a reflector.** A Binding request is answered to its *source*, never to
|
|
16
|
+
* an address carried in the message, so this server cannot be aimed at a third
|
|
17
|
+
* party. Binding responses arriving here are dropped, not answered — answering a
|
|
18
|
+
* response is how a loop starts.
|
|
19
|
+
* * **The single bug that matters is a crash on malformed input.** A malformed
|
|
20
|
+
* datagram is counted and dropped; nothing in the responder throws out of the
|
|
21
|
+
* socket handler, because an uncaught exception here would take down the whole
|
|
22
|
+
* relay host with it.
|
|
23
|
+
* * **A taken port must be loud.** The default port is 3478 and it is a
|
|
24
|
+
* well-known one: the most likely failure is another STUN server already
|
|
25
|
+
* holding it, and that has to be a clear line and a non-zero exit, not a
|
|
26
|
+
* half-started process.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { parseArgs } from 'node:util';
|
|
30
|
+
import { createStunServer, DEFAULT_SOFTWARE } from '../src/relay/stun-server.mjs';
|
|
31
|
+
import { formatError } from '../src/util/cli.mjs';
|
|
32
|
+
|
|
33
|
+
const USAGE = `w2m-stun — W2M STUN responder (RFC 5389 Binding server)
|
|
34
|
+
|
|
35
|
+
Usage:
|
|
36
|
+
w2m-stun [options]
|
|
37
|
+
|
|
38
|
+
What it is for:
|
|
39
|
+
A machine behind NAT cannot see the public address and port its packets come
|
|
40
|
+
from — only the far side can. This server reports that translation back to the
|
|
41
|
+
caller, which is what lets two machines exchange reachable addresses and punch
|
|
42
|
+
a direct path instead of routing every task through the relay.
|
|
43
|
+
|
|
44
|
+
Deployment:
|
|
45
|
+
--host <addr> Bind address. Default 0.0.0.0, because a STUN server
|
|
46
|
+
bound to loopback answers nobody. It must be reachable
|
|
47
|
+
on UDP from the machines that will use it, and the port
|
|
48
|
+
must be forwarded if this host is itself behind NAT.
|
|
49
|
+
--port <n> UDP port. Default 3478 (the IANA STUN port). 0 asks the
|
|
50
|
+
OS for a free port.
|
|
51
|
+
--software <name> Value of the SOFTWARE attribute. Default ${DEFAULT_SOFTWARE}.
|
|
52
|
+
Printed to clients; keep secrets out of it.
|
|
53
|
+
|
|
54
|
+
Observability:
|
|
55
|
+
--log-level <level> info | debug. Default info. "debug" prints one line per
|
|
56
|
+
request, including every datagram that was dropped and
|
|
57
|
+
why — the only way to tell "the NAT is blocking UDP"
|
|
58
|
+
apart from "something is answering but not with STUN".
|
|
59
|
+
|
|
60
|
+
-h, --help Show this help.
|
|
61
|
+
|
|
62
|
+
Protocol:
|
|
63
|
+
0x0001 Binding request -> 0x0101 Binding success, with XOR-MAPPED-ADDRESS
|
|
64
|
+
(0x0020), MAPPED-ADDRESS (0x0001) and SOFTWARE
|
|
65
|
+
(0x8022). The 96-bit transaction id is echoed.
|
|
66
|
+
0x0001 with an unknown comprehension-required attribute
|
|
67
|
+
-> 0x0111 Binding error, ERROR-CODE 400.
|
|
68
|
+
Anything else -> dropped silently: UDP has no error channel worth
|
|
69
|
+
using here, and a wrong magic cookie is not STUN.
|
|
70
|
+
|
|
71
|
+
Notes:
|
|
72
|
+
* IPv4 only (the socket is udp4), no authentication, no TLS: the answer is the
|
|
73
|
+
source address of the request, which any STUN client can ask for.
|
|
74
|
+
* This server reports the mapping a NAT applied. It does not test filtering
|
|
75
|
+
behaviour (RFC 5780 CHANGE-REQUEST), so a client's "endpoint-independent"
|
|
76
|
+
verdict here is a statement about mapping only.
|
|
77
|
+
|
|
78
|
+
Exit codes:
|
|
79
|
+
0 stopped normally (SIGINT/SIGTERM)
|
|
80
|
+
2 bad usage
|
|
81
|
+
3 failed to bind (port already in use?)
|
|
82
|
+
`;
|
|
83
|
+
|
|
84
|
+
function fail(message, code = 2) {
|
|
85
|
+
process.stderr.write(`w2m-stun: ${message}\n`);
|
|
86
|
+
process.exit(code);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let parsed;
|
|
90
|
+
try {
|
|
91
|
+
parsed = parseArgs({
|
|
92
|
+
options: {
|
|
93
|
+
host: { type: 'string' },
|
|
94
|
+
port: { type: 'string' },
|
|
95
|
+
software: { type: 'string' },
|
|
96
|
+
'log-level': { type: 'string' },
|
|
97
|
+
help: { type: 'boolean', short: 'h', default: false },
|
|
98
|
+
},
|
|
99
|
+
allowPositionals: false,
|
|
100
|
+
});
|
|
101
|
+
} catch (error) {
|
|
102
|
+
fail(`${error.message}\n\n${USAGE}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const { values } = parsed;
|
|
106
|
+
if (values.help) {
|
|
107
|
+
process.stdout.write(USAGE);
|
|
108
|
+
process.exit(0);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const host = values.host ?? '0.0.0.0';
|
|
112
|
+
|
|
113
|
+
const port = values.port === undefined ? 3478 : Number(values.port);
|
|
114
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
115
|
+
fail(`--port must be an integer in 0..65535, got ${JSON.stringify(values.port)}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// An empty --software is a configuration error rather than "use the default":
|
|
119
|
+
// the attribute is what a client sees when it asks who answered, and silently
|
|
120
|
+
// substituting a name the operator did not choose makes a deployment
|
|
121
|
+
// indistinguishable from the library default.
|
|
122
|
+
const software = values.software ?? DEFAULT_SOFTWARE;
|
|
123
|
+
if (String(software).trim() === '') {
|
|
124
|
+
fail('--software must not be empty; omit it to use the default');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const logLevel = values['log-level'] ?? 'info';
|
|
128
|
+
if (logLevel !== 'info' && logLevel !== 'debug') {
|
|
129
|
+
fail(`--log-level must be "info" or "debug", got ${JSON.stringify(logLevel)}`);
|
|
130
|
+
}
|
|
131
|
+
const debug = logLevel === 'debug';
|
|
132
|
+
|
|
133
|
+
// Data goes to stdout, diagnostics to stderr, as everywhere else in this project.
|
|
134
|
+
const log = (line) => process.stdout.write(`${line}\n`);
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* One line per request, in `--log-level debug` only.
|
|
138
|
+
*
|
|
139
|
+
* The disposition is the interesting field: "binding_success" is a working
|
|
140
|
+
* client, and every other value names the exact reason a datagram went
|
|
141
|
+
* unanswered, which is what an operator needs to tell a blocked port from a
|
|
142
|
+
* non-STUN client.
|
|
143
|
+
*
|
|
144
|
+
* @param {object} entry
|
|
145
|
+
*/
|
|
146
|
+
function logRequest(entry) {
|
|
147
|
+
if (!debug) return;
|
|
148
|
+
const unknown = entry.unknown?.length
|
|
149
|
+
? ` unknown=${entry.unknown.map((t) => `0x${t.toString(16)}`).join(',')}`
|
|
150
|
+
: '';
|
|
151
|
+
const at = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
152
|
+
log(
|
|
153
|
+
` ${at} ${entry.from.padEnd(21)} ${String(entry.type ?? '-').padEnd(6)} ${entry.disposition}${unknown}`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
let server;
|
|
158
|
+
let listen;
|
|
159
|
+
const startedAt = Date.now();
|
|
160
|
+
try {
|
|
161
|
+
server = createStunServer({ host, port, software, log: logRequest });
|
|
162
|
+
listen = await server.start();
|
|
163
|
+
} catch (error) {
|
|
164
|
+
fail(formatError(error), 3);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (!listen.ok) {
|
|
168
|
+
// EADDRINUSE deserves its own sentence: it is the one failure an operator will
|
|
169
|
+
// actually hit, and the fix (find the other listener, or pick another port) is
|
|
170
|
+
// not obvious from "EADDRINUSE".
|
|
171
|
+
if (listen.error === 'EADDRINUSE') {
|
|
172
|
+
fail(
|
|
173
|
+
`UDP port ${port} is already in use on ${host} — another STUN server or another ` +
|
|
174
|
+
`w2m-stun is holding it. Stop it, or choose a different --port.`,
|
|
175
|
+
3,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
fail(`failed to bind ${host}:${port} — ${listen.error}`, 3);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const bound = server.address() ?? { address: listen.host, port: listen.port };
|
|
182
|
+
log('');
|
|
183
|
+
log(' W2M STUN responder is listening.');
|
|
184
|
+
log('');
|
|
185
|
+
log(` Bind udp4 ${bound.address}:${bound.port}`);
|
|
186
|
+
log(` Software ${software}`);
|
|
187
|
+
log(' Answers Binding requests (0x0001) -> 0x0101 with XOR-MAPPED-ADDRESS,');
|
|
188
|
+
log(' MAPPED-ADDRESS and SOFTWARE. Anything else is dropped.');
|
|
189
|
+
log('');
|
|
190
|
+
log(' NAT translation:');
|
|
191
|
+
log(' A machine behind NAT cannot see the public address and port its packets');
|
|
192
|
+
log(
|
|
193
|
+
` leave from. Point clients at ${bound.address === '0.0.0.0' ? '<this-host>' : bound.address}:${bound.port} (UDP) and`,
|
|
194
|
+
);
|
|
195
|
+
log(' this server reports the translated address back, which is what lets two');
|
|
196
|
+
log(' machines punch a direct path instead of relaying every task.');
|
|
197
|
+
if (bound.address === '0.0.0.0') {
|
|
198
|
+
log('');
|
|
199
|
+
log(' NOTE: bound to 0.0.0.0 — reachable from anywhere that can route UDP to this');
|
|
200
|
+
log(' host. There is no authentication and no state: every request is');
|
|
201
|
+
log(' answered on the address it came from, and nothing else is possible.');
|
|
202
|
+
}
|
|
203
|
+
log('');
|
|
204
|
+
log(` Log level ${logLevel}${debug ? '' : ' (--log-level debug prints one line per request)'}`);
|
|
205
|
+
log('');
|
|
206
|
+
|
|
207
|
+
let stopping = false;
|
|
208
|
+
async function shutdown(signal) {
|
|
209
|
+
if (stopping) return;
|
|
210
|
+
stopping = true;
|
|
211
|
+
const stats = server.stats();
|
|
212
|
+
log(
|
|
213
|
+
`\n ${signal} received, shutting down. ` +
|
|
214
|
+
`requests=${stats.requests} responses=${stats.responses} errors=${stats.errors} ` +
|
|
215
|
+
`uptime=${Math.round((Date.now() - startedAt) / 100) / 10}s`,
|
|
216
|
+
);
|
|
217
|
+
try {
|
|
218
|
+
await server.stop();
|
|
219
|
+
} catch (error) {
|
|
220
|
+
process.stderr.write(`w2m-stun: error while closing: ${formatError(error)}\n`);
|
|
221
|
+
process.exit(1);
|
|
222
|
+
}
|
|
223
|
+
process.exit(0);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
process.on('SIGINT', () => {
|
|
227
|
+
void shutdown('SIGINT');
|
|
228
|
+
});
|
|
229
|
+
process.on('SIGTERM', () => {
|
|
230
|
+
void shutdown('SIGTERM');
|
|
231
|
+
});
|