@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.
Files changed (60) hide show
  1. package/CHANGELOG.md +1309 -0
  2. package/LICENSE +21 -0
  3. package/PROTOCOL-v0.1.2.md +165 -0
  4. package/PROTOCOL-v0.3.0.md +454 -0
  5. package/PROTOCOL-v0.3.9.md +207 -0
  6. package/PROTOCOL-v0.4.0.md +255 -0
  7. package/PROTOCOL.md +339 -0
  8. package/README.md +665 -2
  9. package/bin/w2m-localside.mjs +303 -0
  10. package/bin/w2m-rabbit.mjs +396 -0
  11. package/bin/w2m-stun.mjs +231 -0
  12. package/cordis.patch.yml +94 -0
  13. package/deploy/caddy/Caddyfile.example +118 -0
  14. package/deploy/docker/Dockerfile +144 -0
  15. package/deploy/docker/README.md +338 -0
  16. package/deploy/docker/docker-compose.yml +179 -0
  17. package/deploy/nginx/w2m.conf.example +196 -0
  18. package/deploy/shared-server/README.md +118 -0
  19. package/deploy/shared-server/install.sh +255 -0
  20. package/deploy/systemd/w2m-rabbit.service +150 -0
  21. package/deploy/systemd/w2m-stun.service +69 -0
  22. package/deploy/windows/README.md +115 -0
  23. package/deploy/windows/install-service.ps1 +195 -0
  24. package/deploy/windows/w2m-agent-supervisor.mjs +205 -0
  25. package/deploy/windows/winsw/w2m-localside.xml +50 -0
  26. package/docs/DEPLOY.md +902 -0
  27. package/docs/LINT-AUDIT.md +492 -0
  28. package/docs/PIPELINE-DESIGN.md +95 -0
  29. package/docs/TROUBLESHOOTING.md +560 -0
  30. package/docs/UI-CARDS.md +81 -0
  31. package/lib/index.js +24 -0
  32. package/lib/tools.js +11 -0
  33. package/package.json +133 -4
  34. package/src/agent/agent.mjs +3317 -0
  35. package/src/agent/allowed-commands.mjs +116 -0
  36. package/src/agent/caps.mjs +437 -0
  37. package/src/agent/exec.mjs +356 -0
  38. package/src/agent/git.mjs +616 -0
  39. package/src/agent/identity.mjs +253 -0
  40. package/src/agent/p2p-node.mjs +1243 -0
  41. package/src/agent/p2p.mjs +605 -0
  42. package/src/agent/resolve.mjs +148 -0
  43. package/src/agent/spool.mjs +252 -0
  44. package/src/agent/stun.mjs +397 -0
  45. package/src/agent/url.mjs +127 -0
  46. package/src/plugin/auto-update.mjs +574 -0
  47. package/src/plugin/schedule.mjs +452 -0
  48. package/src/plugin/shared-config.mjs +264 -0
  49. package/src/plugin/tools.mjs +3891 -0
  50. package/src/plugin/update-install.mjs +764 -0
  51. package/src/plugin/update-source.mjs +686 -0
  52. package/src/relay/metrics.mjs +284 -0
  53. package/src/relay/peers.mjs +180 -0
  54. package/src/relay/persistence.mjs +207 -0
  55. package/src/relay/report.mjs +591 -0
  56. package/src/relay/server.mjs +1187 -0
  57. package/src/relay/state.mjs +2004 -0
  58. package/src/relay/stun-server.mjs +733 -0
  59. package/src/signing.mjs +359 -0
  60. package/src/util/cli.mjs +70 -0
@@ -0,0 +1,359 @@
1
+ /**
2
+ * Request signing for the W2M relay link (v0.3.0 §9).
3
+ *
4
+ * ## What this defends against, and what it does not
5
+ *
6
+ * A bearer token is a bearer token: anyone who can read it once can replay it. Over a link that
7
+ * crosses the public internet — which is the whole point of v0.1.2 onward — three concrete things
8
+ * become reachable that a private LAN made theoretical:
9
+ *
10
+ * 1. **Replay.** A captured `POST /v1/task` re-sent later dispatches the same work again. The
11
+ * idempotency key makes a *duplicate* harmless, but a replay with a fresh task id is a new task.
12
+ * 2. **Tampering by a middlebox.** Anything that terminates the connection can rewrite a body. TLS
13
+ * prevents this where TLS exists; the Tailscale and tunnel deployments are exactly where it may
14
+ * not be end-to-end.
15
+ * 3. **A leaked log.** A signature that covers the body plus a monotonic timestamp turns "we have
16
+ * your token" into "you have at most one window to use it".
17
+ *
18
+ * It is deliberately **not** end-to-end encryption and not a replacement for TLS. The body is still
19
+ * readable by anyone who can see the bytes; this proves *who sent it* and *that it is unchanged*,
20
+ * and it bounds replay. Saying otherwise would be the kind of security claim that gets people hurt.
21
+ *
22
+ * ## Why a shared secret and not asymmetric keys
23
+ *
24
+ * Every machine already holds a per-device bearer token issued at pairing, and the relay already
25
+ * holds a copy. Deriving the signing key from that means **no new secret to distribute, store or
26
+ * rotate separately** — the existing pairing ceremony is the whole key exchange. The cost is that
27
+ * the relay can forge a device's signature, which is acceptable here because the relay is already
28
+ * trusted to route that device's work.
29
+ *
30
+ * ## Wire format
31
+ *
32
+ * ```
33
+ * X-W2M-Signature: v1=<hex hmac>
34
+ * X-W2M-Timestamp: <unix seconds>
35
+ * X-W2M-Nonce: <opaque, 16+ chars>
36
+ * ```
37
+ *
38
+ * The signed string is newline-separated and covers the method, path, timestamp, nonce and the exact
39
+ * body bytes, so a signature cannot be moved to another endpoint, another method, another moment, or
40
+ * another payload.
41
+ *
42
+ * ## Compatibility
43
+ *
44
+ * Signing is **optional on both sides**. An unsigned request is accepted exactly as before, so a
45
+ * v0.2.3 machine keeps working against a v0.3.0 relay and vice versa. What changes is that a relay
46
+ * with a secret configured can *require* signatures; that is opt-in, and the failure is loud.
47
+ */
48
+
49
+ import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
50
+
51
+ /** Header carrying the signature. */
52
+ export const SIGNATURE_HEADER = 'x-w2m-signature';
53
+
54
+ /** Header carrying the signing timestamp, in whole seconds. */
55
+ export const TIMESTAMP_HEADER = 'x-w2m-timestamp';
56
+
57
+ /** Header carrying the replay nonce. */
58
+ export const NONCE_HEADER = 'x-w2m-nonce';
59
+
60
+ /** Signature format version, so the scheme can change without ambiguity. */
61
+ export const SIGNATURE_VERSION = 'v1';
62
+
63
+ /**
64
+ * Accepted skew between the signer's clock and the verifier's, in seconds.
65
+ *
66
+ * Two minutes is chosen against the constraint that matters here: the machines may sit in different
67
+ * time zones and one of them may be behind a tunnelled, jittery link. A window tighter than the
68
+ * worst observed clock drift produces intermittent authentication failures, which people then
69
+ * "fix" by turning signing off — the outcome this module exists to prevent. Nothing in the protocol
70
+ * needs a tighter bound, because the nonce cache is what actually stops replay.
71
+ */
72
+ export const DEFAULT_SKEW_SECONDS = 120;
73
+
74
+ /** Errors carry a code so a caller can distinguish "unsigned" from "forged". */
75
+ export class SignatureError extends Error {
76
+ /**
77
+ * @param {string} code - Machine-readable reason.
78
+ * @param {string} message - Human-readable explanation.
79
+ * @param {object} [detail] - Extra context for a log.
80
+ */
81
+ constructor(code, message, detail = {}) {
82
+ super(message);
83
+ this.name = 'SignatureError';
84
+ this.code = code;
85
+ this.detail = detail;
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Normalize a secret into a key buffer.
91
+ *
92
+ * Rejects anything that is not a non-empty string: a `undefined` secret silently disabling signing
93
+ * is the failure mode this whole module is written to avoid, so callers get an error instead.
94
+ *
95
+ * @param {unknown} secret - Shared secret.
96
+ * @returns {Buffer} Key material.
97
+ * @throws {SignatureError} `BAD_SECRET` when the secret is unusable.
98
+ */
99
+ export function normalizeSecret(secret) {
100
+ if (typeof secret !== 'string') {
101
+ throw new SignatureError('BAD_SECRET', `signing secret must be a string, got ${typeof secret}`);
102
+ }
103
+ if (secret.length === 0) {
104
+ throw new SignatureError('BAD_SECRET', 'signing secret must not be empty');
105
+ }
106
+ return Buffer.from(secret, 'utf8');
107
+ }
108
+
109
+ /**
110
+ * Build the canonical string that gets signed.
111
+ *
112
+ * Exported because a signature bug is nearly impossible to find from the outside: both sides must
113
+ * agree byte-for-byte, and a test that can print this is worth more than a test that only asserts
114
+ * "it failed".
115
+ *
116
+ * @param {object} input - Request facts.
117
+ * @param {string} input.method - HTTP method, any case.
118
+ * @param {string} input.path - Request path *including* the query string, if any.
119
+ * @param {number} input.timestamp - Unix seconds.
120
+ * @param {string} input.nonce - Replay nonce.
121
+ * @param {string|Buffer} [input.body] - Exact body bytes; empty for no body.
122
+ * @returns {string} The canonical string.
123
+ */
124
+ export function canonicalString({ method, path, timestamp, nonce, body = '' }) {
125
+ const bodyBytes = Buffer.isBuffer(body) ? body : Buffer.from(body ?? '', 'utf8');
126
+ // The body is hashed rather than embedded so the canonical form stays small and log-safe; the
127
+ // hash still binds every byte, which is the property that matters.
128
+ const bodyHash = createHmac('sha256', Buffer.alloc(0)).update(bodyBytes).digest('hex');
129
+ return [
130
+ SIGNATURE_VERSION,
131
+ String(method ?? '').toUpperCase(),
132
+ String(path ?? ''),
133
+ String(timestamp),
134
+ String(nonce),
135
+ bodyHash,
136
+ ].join('\n');
137
+ }
138
+
139
+ /**
140
+ * Compute a signature.
141
+ *
142
+ * @param {object} input - Signing input plus `secret`.
143
+ * @param {string} input.secret - Shared secret.
144
+ * @param {string} input.method - HTTP method.
145
+ * @param {string} input.path - Path including query.
146
+ * @param {number} input.timestamp - Unix seconds.
147
+ * @param {string} input.nonce - Replay nonce.
148
+ * @param {string|Buffer} [input.body] - Exact body bytes.
149
+ * @returns {string} `v1=<hex>`.
150
+ */
151
+ export function signRequest({ secret, method, path, timestamp, nonce, body = '' }) {
152
+ const key = normalizeSecret(secret);
153
+ const canonical = canonicalString({ method, path, timestamp, nonce, body });
154
+ const mac = createHmac('sha256', key).update(canonical).digest('hex');
155
+ return `${SIGNATURE_VERSION}=${mac}`;
156
+ }
157
+
158
+ /**
159
+ * Create an independent signer bound to one secret.
160
+ *
161
+ * The returned `headers()` is what a client calls per request. Keeping the nonce generator injectable
162
+ * is what makes replay tests deterministic instead of flaky.
163
+ *
164
+ * @param {object} input - Signer options.
165
+ * @param {string} input.secret - Shared secret.
166
+ * @param {() => number} [input.now] - Clock in *milliseconds*; defaults to `Date.now`.
167
+ * @param {() => string} [input.makeNonce] - Nonce source.
168
+ * @returns {{headers: (req: {method: string, path: string, body?: string|Buffer}) => Record<string,string>}}
169
+ */
170
+ export function createSigner({ secret, now = Date.now, makeNonce = () => randomBytes(16).toString('hex') }) {
171
+ normalizeSecret(secret);
172
+ return {
173
+ headers({ method, path, body = '' }) {
174
+ const timestamp = Math.floor(now() / 1000);
175
+ const nonce = makeNonce();
176
+ return {
177
+ [SIGNATURE_HEADER]: signRequest({ secret, method, path, timestamp, nonce, body }),
178
+ [TIMESTAMP_HEADER]: String(timestamp),
179
+ [NONCE_HEADER]: nonce,
180
+ };
181
+ },
182
+ };
183
+ }
184
+
185
+ /**
186
+ * Bounded nonce cache that refuses a nonce it has already accepted.
187
+ *
188
+ * Replay defence has to be *stateful*, or a captured request is valid for the whole skew window --
189
+ * which would make the window the actual security boundary and the timestamp decorative. The cache
190
+ * is bounded so a long-running relay cannot be grown into memory exhaustion by a flood of requests,
191
+ * and it evicts oldest-first because the oldest entries are the ones about to fall outside the skew
192
+ * window anyway.
193
+ */
194
+ export class NonceCache {
195
+ /**
196
+ * @param {object} [options] - Cache options.
197
+ * @param {number} [options.max] - Maximum retained nonces.
198
+ * @param {number} [options.retentionMs] - How long a nonce is remembered.
199
+ */
200
+ constructor({ max = 20_000, retentionMs = 10 * 60_000 } = {}) {
201
+ /** @type {Map<string, number>} nonce -> accepted-at milliseconds */
202
+ this.seen = new Map();
203
+ this.max = max;
204
+ this.retentionMs = retentionMs;
205
+ }
206
+
207
+ /**
208
+ * Record a nonce, reporting whether it had already been used.
209
+ *
210
+ * @param {string} nonce - The nonce.
211
+ * @param {number} nowMs - Current time in milliseconds.
212
+ * @returns {boolean} True when this nonce is new, false when it is a replay.
213
+ */
214
+ accept(nonce, nowMs) {
215
+ if (this.seen.has(nonce)) return false;
216
+ this.seen.set(nonce, nowMs);
217
+ // Expire by age first, then by count. Age alone is not enough: a flood inside the retention
218
+ // window would grow the map without bound, which is a memory-exhaustion vector.
219
+ this.prune(this.retentionMs, nowMs);
220
+ while (this.seen.size > this.max) {
221
+ const oldest = this.seen.keys().next();
222
+ if (oldest.done) break;
223
+ this.seen.delete(oldest.value);
224
+ }
225
+ return true;
226
+ }
227
+
228
+ /**
229
+ * Drop entries older than `olderThanMs`, relying on insertion order being chronological.
230
+ *
231
+ * @param {number} olderThanMs - Age beyond which a nonce is forgotten.
232
+ * @param {number} nowMs - Current time in milliseconds.
233
+ */
234
+ prune(olderThanMs, nowMs) {
235
+ for (const [nonce, at] of this.seen) {
236
+ if (nowMs - at > olderThanMs) this.seen.delete(nonce);
237
+ else break; // insertion order is chronological
238
+ }
239
+ }
240
+
241
+ /** Current size, for diagnostics. */
242
+ get size() {
243
+ return this.seen.size;
244
+ }
245
+ }
246
+
247
+ /**
248
+ * Verify a signed request.
249
+ *
250
+ * Returns a decision rather than throwing, because the caller needs to log *why* and because a
251
+ * rejection here is an expected event on a public link, not a programming error. It throws only for
252
+ * a caller mistake (an unusable key list).
253
+ *
254
+ * @param {object} input - Verification input.
255
+ * @param {Headers|Record<string,string>} input.headers - Incoming headers, case-insensitively read.
256
+ * @param {string} input.method - HTTP method.
257
+ * @param {string} input.path - Path including query.
258
+ * @param {string|Buffer} [input.body] - Exact received body bytes.
259
+ * @param {string|string[]} input.secrets - Accepted secrets, newest first (rotation support).
260
+ * @param {NonceCache} input.nonces - Replay cache.
261
+ * @param {number} [input.nowMs] - Current time in milliseconds.
262
+ * @param {number} [input.skewSeconds] - Accepted clock skew.
263
+ * @param {boolean} [input.required] - When true, an unsigned request is rejected.
264
+ * @returns {{ok: true, signed: boolean, keyIndex: number|null}|{ok: false, code: string, error: string}}
265
+ */
266
+ export function verifyRequest({
267
+ headers,
268
+ method,
269
+ path,
270
+ body = '',
271
+ secrets,
272
+ nonces,
273
+ nowMs = Date.now(),
274
+ skewSeconds = DEFAULT_SKEW_SECONDS,
275
+ required = false,
276
+ }) {
277
+ const list = (Array.isArray(secrets) ? secrets : [secrets]).filter(
278
+ (s) => typeof s === 'string' && s.length > 0,
279
+ );
280
+ if (list.length === 0) {
281
+ // No configured secret means this relay cannot verify anything. If it also requires signatures,
282
+ // that is a configuration error on the relay, and it must not be reported as a client failure.
283
+ if (required) {
284
+ return { ok: false, code: 'SIGNING_NOT_CONFIGURED', error: 'signatures are required but no signing secret is configured' };
285
+ }
286
+ return { ok: true, signed: false, keyIndex: null };
287
+ }
288
+
289
+ const read = (name) => {
290
+ if (typeof headers?.get === 'function') return headers.get(name);
291
+ const found = Object.keys(headers ?? {}).find((k) => k.toLowerCase() === name);
292
+ return found === undefined ? null : headers[found];
293
+ };
294
+
295
+ const presented = read(SIGNATURE_HEADER);
296
+ const timestampRaw = read(TIMESTAMP_HEADER);
297
+ const nonce = read(NONCE_HEADER);
298
+
299
+ if (!presented && !timestampRaw && !nonce) {
300
+ // Unsigned but structurally fine: this is the v0.2.3 client, and it keeps working unless the
301
+ // relay was explicitly told to insist.
302
+ if (required) {
303
+ return { ok: false, code: 'SIGNATURE_REQUIRED', error: 'this relay requires a signed request' };
304
+ }
305
+ return { ok: true, signed: false, keyIndex: null };
306
+ }
307
+ if (!presented || !timestampRaw || !nonce) {
308
+ // A partially-signed request is never a legitimate client: it is either a broken proxy or an
309
+ // attacker probing which header is checked. Refuse rather than fall back to "unsigned".
310
+ return {
311
+ ok: false,
312
+ code: 'SIGNATURE_INCOMPLETE',
313
+ error: `a signed request must carry all of ${SIGNATURE_HEADER}, ${TIMESTAMP_HEADER} and ${NONCE_HEADER}`,
314
+ };
315
+ }
316
+
317
+ const timestamp = Number(timestampRaw);
318
+ if (!Number.isFinite(timestamp)) {
319
+ return { ok: false, code: 'SIGNATURE_BAD_TIMESTAMP', error: `timestamp is not a number: ${JSON.stringify(timestampRaw)}` };
320
+ }
321
+ const skew = Math.abs(Math.floor(nowMs / 1000) - timestamp);
322
+ if (skew > skewSeconds) {
323
+ // Clock skew and replay look the same here; the message says which bound was exceeded so an
324
+ // operator can tell a drifting clock from an attack.
325
+ return {
326
+ ok: false,
327
+ code: 'SIGNATURE_EXPIRED',
328
+ error: `timestamp is ${skew}s from local time, outside the ±${skewSeconds}s window`,
329
+ };
330
+ }
331
+ if (nonce.length < 8) {
332
+ return { ok: false, code: 'SIGNATURE_BAD_NONCE', error: 'nonce is too short to be unique' };
333
+ }
334
+
335
+ // Check the nonce *after* the cheap structural checks but before the HMAC compare, and only claim
336
+ // it once a signature has actually matched -- otherwise an attacker could burn nonces to make a
337
+ // legitimate request look like a replay.
338
+ const expected = list.map((secret) =>
339
+ signRequest({ secret, method, path, timestamp, nonce, body }),
340
+ );
341
+ let matchedIndex = -1;
342
+ for (let i = 0; i < expected.length; i += 1) {
343
+ const a = Buffer.from(expected[i], 'utf8');
344
+ const b = Buffer.from(String(presented), 'utf8');
345
+ // timingSafeEqual throws on a length mismatch, which would leak length as an oracle; guard first.
346
+ if (a.length === b.length && timingSafeEqual(a, b)) {
347
+ matchedIndex = i;
348
+ break;
349
+ }
350
+ }
351
+ if (matchedIndex === -1) {
352
+ return { ok: false, code: 'SIGNATURE_MISMATCH', error: 'signature does not match any configured secret' };
353
+ }
354
+
355
+ if (!nonces.accept(nonce, nowMs)) {
356
+ return { ok: false, code: 'SIGNATURE_REPLAY', error: 'this nonce has already been used' };
357
+ }
358
+ return { ok: true, signed: true, keyIndex: matchedIndex };
359
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Shared helpers for the W2M command-line entry points.
3
+ *
4
+ * Kept deliberately small and dependency-free: the interesting decisions here
5
+ * are only "where does state live" and "how do we print an error", and both
6
+ * have to behave identically on Windows and macOS.
7
+ */
8
+
9
+ import { homedir } from 'node:os';
10
+ import { isAbsolute, join, resolve } from 'node:path';
11
+
12
+ /**
13
+ * Resolve the directory a W2M process keeps its durable state in.
14
+ *
15
+ * Precedence, highest first:
16
+ * 1. an explicit `--state` value;
17
+ * 2. `<DSH_HOME>/xclient/<kind>`;
18
+ * 3. `~/.dsh/xclient/<kind>` when `DSH_HOME` is not set.
19
+ *
20
+ * `DSH_HOME` is honoured rather than assumed so that a machine running two
21
+ * throwaway profiles for testing does not share one device identity with the
22
+ * profile the user actually works in -- sharing it would silently make two
23
+ * "machines" claim the same `machine_id`.
24
+ *
25
+ * @param {string|undefined} explicit Value of `--state`, if given.
26
+ * @param {string} kind Sub-directory name, e.g. `'rabbit'` or `'localside'`.
27
+ * @returns {string} Absolute path.
28
+ */
29
+ export function resolveStateDir(explicit, kind) {
30
+ if (explicit && explicit.trim() !== '') {
31
+ return isAbsolute(explicit) ? explicit : resolve(explicit);
32
+ }
33
+ const home = process.env.DSH_HOME && process.env.DSH_HOME.trim() !== ''
34
+ ? process.env.DSH_HOME
35
+ : join(homedir(), '.dsh');
36
+ return join(home, 'xclient', kind);
37
+ }
38
+
39
+ /**
40
+ * Format anything throwable as a single human-readable line.
41
+ *
42
+ * `String(error)` on a rejected fetch prints `TypeError: fetch failed`, which
43
+ * hides the actual cause in `error.cause`; the CLI's whole job when it fails is
44
+ * to say what went wrong, so the cause is unwrapped here.
45
+ *
46
+ * @param {unknown} error
47
+ * @returns {string}
48
+ */
49
+ export function formatError(error) {
50
+ if (error === null || error === undefined) return 'unknown error';
51
+ if (typeof error === 'string') return error;
52
+ if (!(error instanceof Error)) {
53
+ try {
54
+ return JSON.stringify(error);
55
+ } catch {
56
+ return String(error);
57
+ }
58
+ }
59
+ const parts = [`${error.name}: ${error.message}`];
60
+ let cause = error.cause;
61
+ const seen = new Set([error]);
62
+ while (cause instanceof Error && !seen.has(cause)) {
63
+ seen.add(cause);
64
+ parts.push(`caused by ${cause.name}: ${cause.message}`);
65
+ if (typeof cause.code === 'string') parts.push(`(${cause.code})`);
66
+ cause = cause.cause;
67
+ }
68
+ if (typeof error.code === 'string') parts.push(`[${error.code}]`);
69
+ return parts.join(' ');
70
+ }