@twinsearth/w2m-dsh-plugin 0.6.3 → 0.8.2
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 +1176 -0
- package/HANDOFF.md +173 -0
- package/README.md +9 -2
- package/bin/w2m-localside.mjs +325 -308
- package/bin/w2m-rabbit.mjs +6 -0
- package/bin/w2m-stun.mjs +0 -0
- package/deploy/mac/mac-self-update.mjs +257 -0
- package/deploy/server/README.md +109 -0
- package/deploy/server/deploy-w2m.sh +161 -0
- package/deploy/server/w2m-localside.service +31 -0
- package/deploy/server/w2m-rabbit.service +26 -0
- package/deploy/server/w2m-stun.service +26 -0
- package/docs/ACCOUNT-FLEET-DESIGN.md +369 -0
- package/package.json +34 -22
- package/src/agent/agent.mjs +24 -2
- package/src/chatops/account.mjs +401 -0
- package/src/chatops/actions.mjs +227 -0
- package/src/chatops/approval-broker.mjs +320 -0
- package/src/chatops/auth.mjs +376 -0
- package/src/chatops/bridge.mjs +344 -0
- package/src/chatops/channel.mjs +192 -0
- package/src/chatops/commands.mjs +409 -0
- package/src/chatops/config-route.mjs +332 -0
- package/src/chatops/device-signing.mjs +218 -0
- package/src/chatops/durability.mjs +202 -0
- package/src/chatops/file-safety.mjs +282 -0
- package/src/chatops/fleet-join.mjs +164 -0
- package/src/chatops/fleet-scope.mjs +176 -0
- package/src/chatops/fleet-wiring.mjs +258 -0
- package/src/chatops/fleet.mjs +272 -0
- package/src/chatops/handoff.mjs +258 -0
- package/src/chatops/ilink-fleet-bridge.mjs +197 -0
- package/src/chatops/ilink-protocol.mjs +165 -0
- package/src/chatops/ilink-transport.mjs +168 -0
- package/src/chatops/ilink.mjs +423 -0
- package/src/chatops/index.mjs +440 -0
- package/src/chatops/outbound-queue.mjs +282 -0
- package/src/chatops/policy.mjs +356 -0
- package/src/chatops/run-command.mjs +308 -0
- package/src/plugin/tools.mjs +1 -1
- package/src/relay/server.mjs +96 -0
- package/src/relay/state.mjs +2279 -2011
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/run` — dispatch one command from a chat window to the fleet (v0.7.12).
|
|
3
|
+
*
|
|
4
|
+
* ## Why an explicit prefix, and not "every message is a command"
|
|
5
|
+
*
|
|
6
|
+
* The alternative is treating any incoming message as a command, which makes a typo a fleet-wide
|
|
7
|
+
* execution and makes ordinary conversation dangerous. A prefix means **sending a message is never
|
|
8
|
+
* an action**; acting requires saying so. That is the same reason the other commands here are
|
|
9
|
+
* prefixed, and it is the property most of these tests are about.
|
|
10
|
+
*
|
|
11
|
+
* ## What this module is, and what it is not
|
|
12
|
+
*
|
|
13
|
+
* It **parses** a `/run` line and **renders** a verdict. It does not talk to the relay — that is
|
|
14
|
+
* `fleet.mjs`, whose executor is injected as `deps.run`. Keeping the two apart is what makes the
|
|
15
|
+
* parsing and the rendering testable without a live fleet, and it is where the mistakes in this
|
|
16
|
+
* kind of feature actually live: quoting, refusing empty commands, deciding what a refusal reads
|
|
17
|
+
* like when a human is holding a phone.
|
|
18
|
+
*
|
|
19
|
+
* ## The argv question, which is the real design decision
|
|
20
|
+
*
|
|
21
|
+
* `/run node --version` has to become `['node', '--version']`. Splitting on whitespace is wrong the
|
|
22
|
+
* moment a path has a space, and invoking a shell is wrong always — the fleet's units take an
|
|
23
|
+
* **argv array** and never a shell string, deliberately, because a shell here would turn every
|
|
24
|
+
* chat message into arbitrary command execution on every machine.
|
|
25
|
+
*
|
|
26
|
+
* So quoting is honoured for the simple cases (single and double, no escapes), and anything the
|
|
27
|
+
* parser cannot be sure about is a **named refusal** rather than a guess. A wrong guess executes
|
|
28
|
+
* something on a fleet; a refusal costs one retry.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** Why a `/run` line was not dispatched. */
|
|
32
|
+
export const RUN_REFUSAL = Object.freeze({
|
|
33
|
+
EMPTY: 'empty_command',
|
|
34
|
+
UNBALANCED_QUOTE: 'unbalanced_quote',
|
|
35
|
+
SHELL_METACHARACTER: 'shell_metacharacter',
|
|
36
|
+
TOO_LONG: 'too_long',
|
|
37
|
+
NOT_PERMITTED: 'not_permitted',
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
/** A command long enough to be a mistake, and long enough to be worth refusing before dispatch. */
|
|
41
|
+
export const MAX_ARGS = 64;
|
|
42
|
+
export const MAX_ARG_LENGTH = 4096;
|
|
43
|
+
|
|
44
|
+
/** Characters that only mean something to a shell, and so indicate a caller expecting one. */
|
|
45
|
+
const SHELL_METACHARACTERS = ['|', '&', ';', '>', '<', '`', '$', '\n', '\\'];
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Split a `/run` argument string into an argv array.
|
|
49
|
+
*
|
|
50
|
+
* @param {string} input Everything after `/run`.
|
|
51
|
+
* @returns {{ok: true, argv: string[]} | {ok: false, reason: string, text: string}}
|
|
52
|
+
*/
|
|
53
|
+
export function parseRun(input) {
|
|
54
|
+
const raw = typeof input === 'string' ? input.trim() : '';
|
|
55
|
+
if (raw === '') {
|
|
56
|
+
return { ok: false, reason: RUN_REFUSAL.EMPTY, text: '用法:/run <命令>,例如 /run node --version' };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const argv = [];
|
|
60
|
+
let current = '';
|
|
61
|
+
let quote = null;
|
|
62
|
+
let started = false;
|
|
63
|
+
|
|
64
|
+
for (const ch of raw) {
|
|
65
|
+
if (quote !== null) {
|
|
66
|
+
if (ch === quote) { quote = null; continue; }
|
|
67
|
+
current += ch;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (ch === '"' || ch === "'") { quote = ch; started = true; continue; }
|
|
71
|
+
if (ch === ' ' || ch === '\t' || ch === '\r') {
|
|
72
|
+
// A quoted empty argument ("") is a real argument, which is why `started` exists rather than
|
|
73
|
+
// a length check: `echo "" x` is two arguments, not one.
|
|
74
|
+
if (started || current !== '') { argv.push(current); current = ''; started = false; }
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
current += ch;
|
|
78
|
+
started = true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (quote !== null) {
|
|
82
|
+
return {
|
|
83
|
+
ok: false,
|
|
84
|
+
reason: RUN_REFUSAL.UNBALANCED_QUOTE,
|
|
85
|
+
text: `引号没有闭合(缺少 ${quote})。把整条命令用同一对引号包起来,或去掉引号。`,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
if (started || current !== '') argv.push(current);
|
|
89
|
+
|
|
90
|
+
if (argv.length === 0) {
|
|
91
|
+
return { ok: false, reason: RUN_REFUSAL.EMPTY, text: '用法:/run <命令>,例如 /run node --version' };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A shell metacharacter is refused rather than passed through.
|
|
96
|
+
*
|
|
97
|
+
* The fleet takes an argv array and never a shell string, so `|` or `&&` would arrive as a
|
|
98
|
+
* literal argument and quietly do something other than what was typed. Refusing says so; passing
|
|
99
|
+
* it through produces a command that runs and does the wrong thing.
|
|
100
|
+
*/
|
|
101
|
+
const offending = argv.find((a) => SHELL_METACHARACTERS.some((m) => a.includes(m)));
|
|
102
|
+
if (offending !== undefined) {
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
reason: RUN_REFUSAL.SHELL_METACHARACTER,
|
|
106
|
+
text:
|
|
107
|
+
`命令里含有 shell 元字符(${SHELL_METACHARACTERS.filter((m) => offending.includes(m)).join(' ')})。`
|
|
108
|
+
+ '编队接收的是 argv 数组、不是 shell 字符串,所以管道和重定向不会生效。'
|
|
109
|
+
+ '请分别运行,或把逻辑放进一个脚本。',
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (argv.length > MAX_ARGS) {
|
|
114
|
+
return { ok: false, reason: RUN_REFUSAL.TOO_LONG, text: `参数太多(${argv.length} > ${MAX_ARGS})。` };
|
|
115
|
+
}
|
|
116
|
+
const long = argv.find((a) => a.length > MAX_ARG_LENGTH);
|
|
117
|
+
if (long !== undefined) {
|
|
118
|
+
return { ok: false, reason: RUN_REFUSAL.TOO_LONG, text: `单个参数过长(${long.length} > ${MAX_ARG_LENGTH})。` };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return { ok: true, argv };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Parse the flags `/run` accepts alongside the command.
|
|
126
|
+
*
|
|
127
|
+
* Kept separate from `parseRun` because the command itself is opaque: `/run node --timeout 5` is a
|
|
128
|
+
* node invocation whose argument happens to look like a flag, and pulling it out would run a
|
|
129
|
+
* different command than the one typed. Flags are only recognised **before** the command.
|
|
130
|
+
*/
|
|
131
|
+
export function parseRunFlags(args) {
|
|
132
|
+
const flags = { mode: 'replicate', timeoutMs: null, machines: null, ask: false };
|
|
133
|
+
const rest = [...args];
|
|
134
|
+
while (rest.length > 0 && rest[0].startsWith('--')) {
|
|
135
|
+
const flag = rest.shift();
|
|
136
|
+
/**
|
|
137
|
+
* `--ask` switches the argument from a command to a **request in words**.
|
|
138
|
+
*
|
|
139
|
+
* This is the "C" form, and the flag is what keeps it from weakening the "A" form: an explicit
|
|
140
|
+
* marker means natural language is never *guessed* at, so a command that happens to read like
|
|
141
|
+
* a sentence is still dispatched as a command, and a sentence is never dispatched.
|
|
142
|
+
*/
|
|
143
|
+
if (flag === '--ask' || flag === '-a') { flags.ask = true; continue; }
|
|
144
|
+
if (flag === '--on' || flag === '--machine') {
|
|
145
|
+
const value = rest.shift();
|
|
146
|
+
if (value !== undefined) flags.machines = value.split(',').map((s) => s.trim()).filter((s) => s !== '');
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (flag === '--timeout') {
|
|
150
|
+
const value = Number(rest.shift());
|
|
151
|
+
if (Number.isFinite(value) && value > 0) flags.timeoutMs = Math.min(value, 3_600_000);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (flag === '--mode') {
|
|
155
|
+
const value = rest.shift();
|
|
156
|
+
if (['replicate', 'split', 'broadcast', 'pipeline'].includes(value)) flags.mode = value;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
// An unrecognised leading `--flag` is handed back to the command: it may be a real argument
|
|
160
|
+
// (`node --version`), and swallowing it would run something else.
|
|
161
|
+
rest.unshift(flag);
|
|
162
|
+
break;
|
|
163
|
+
}
|
|
164
|
+
return { flags, command: rest.join(' ') };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The two forms, as **privilege levels** rather than two spellings of one action.
|
|
169
|
+
*
|
|
170
|
+
* | form | who | what it does | capability |
|
|
171
|
+
* |---|---|---|---|
|
|
172
|
+
* | `/run <argv>` | expert | dispatches those arguments, uninterpreted | `fleet.dispatch` |
|
|
173
|
+
* | `/run --ask <words>` | beginner | asks; the agent answers, and **proposes** a command | `operate` |
|
|
174
|
+
*
|
|
175
|
+
* The asymmetry is the point. A beginner describing what they want is *not* executing anything: the
|
|
176
|
+
* request goes to a session, the session answers in prose, and if it wants something run it emits
|
|
177
|
+
* `/run <argv>` — which still needs `fleet.dispatch` to actually happen. So a low-privilege window
|
|
178
|
+
* can ask freely and cannot cause a fleet execution by asking.
|
|
179
|
+
*
|
|
180
|
+
* That is why the two are not one command with a flag for the *syntax*: they are two capabilities,
|
|
181
|
+
* and a window that may do one must not thereby be able to do the other.
|
|
182
|
+
*/
|
|
183
|
+
export const RUN_FORM = Object.freeze({
|
|
184
|
+
/** Expert: dispatch exactly these arguments. */
|
|
185
|
+
DISPATCH: 'dispatch',
|
|
186
|
+
/** Beginner: state what you want; the agent interprets and proposes. */
|
|
187
|
+
ASK: 'ask',
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
/** Which capability each form needs. Names match `commands.mjs`'s `CAPABILITY`. */
|
|
191
|
+
export const FORM_CAPABILITY = Object.freeze({
|
|
192
|
+
[RUN_FORM.DISPATCH]: 'fleet.dispatch',
|
|
193
|
+
[RUN_FORM.ASK]: 'operate',
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Which capability a form requires.
|
|
198
|
+
*
|
|
199
|
+
* Returns the capability rather than checking it, so the check stays in the router where every
|
|
200
|
+
* other command's check lives — "authorisation is checked before the handler runs, never inside
|
|
201
|
+
* it", which is what stops a handler becoming reachable by forgetting its own guard.
|
|
202
|
+
*/
|
|
203
|
+
export function requiredCapability(form) {
|
|
204
|
+
return FORM_CAPABILITY[form] ?? null;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Decide what one `/run` line is asking for.
|
|
209
|
+
*
|
|
210
|
+
* Two forms, and the distinction is **declared, not inferred**:
|
|
211
|
+
*
|
|
212
|
+
* * **A — `/run <argv>`** dispatches exactly those arguments. Nothing interprets them.
|
|
213
|
+
* * **C — `/run --ask <words>`** is a request the session's agent reads. The agent may answer in
|
|
214
|
+
* prose, and if it wants something run it must emit `/run <argv>` — which the human then sees
|
|
215
|
+
* as a command before it happens, and which needs the higher capability to execute.
|
|
216
|
+
*
|
|
217
|
+
* The reason `--ask` is a flag rather than a heuristic: an inferred distinction gets it wrong in
|
|
218
|
+
* both directions, and the direction that matters is dispatching a sentence as if it were a
|
|
219
|
+
* program. Declaring the form means the dangerous interpretation never happens by accident — and
|
|
220
|
+
* here it also means the *privilege level* is declared, not guessed from how the text reads.
|
|
221
|
+
*
|
|
222
|
+
* @returns {{kind: 'dispatch', argv: string[], flags: object, capability: string}
|
|
223
|
+
* | {kind: 'ask', text: string, flags: object, capability: string}
|
|
224
|
+
* | {kind: 'refused', reason: string, text: string}}
|
|
225
|
+
*/
|
|
226
|
+
export function parseInvocation(input) {
|
|
227
|
+
const raw = typeof input === 'string' ? input.trim() : '';
|
|
228
|
+
if (raw === '') {
|
|
229
|
+
return { kind: 'refused', reason: RUN_REFUSAL.EMPTY, text: '用法:/run <命令>,或 /run --ask <用一句话描述你要做什么>' };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const { flags, command } = parseRunFlags(raw.split(/\s+/));
|
|
233
|
+
|
|
234
|
+
if (flags.ask) {
|
|
235
|
+
if (command.trim() === '') {
|
|
236
|
+
return {
|
|
237
|
+
kind: 'refused',
|
|
238
|
+
reason: RUN_REFUSAL.EMPTY,
|
|
239
|
+
text: '用法:/run --ask <用一句话描述你要做什么>,例如 /run --ask 看看各机器的 node 版本',
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
return { kind: 'ask', text: command.trim(), flags, capability: FORM_CAPABILITY[RUN_FORM.ASK] };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const parsed = parseRun(command);
|
|
246
|
+
if (!parsed.ok) return { kind: 'refused', reason: parsed.reason, text: parsed.text };
|
|
247
|
+
return { kind: 'dispatch', argv: parsed.argv, flags, capability: FORM_CAPABILITY[RUN_FORM.DISPATCH] };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** A one-line summary of what was dispatched, for the acknowledgement. */
|
|
251
|
+
export function describeDispatch({ argv, mode, machines }) {
|
|
252
|
+
const parts = [`mode=${mode}`];
|
|
253
|
+
if (Array.isArray(machines) && machines.length > 0) parts.push(`on=${machines.join(',')}`);
|
|
254
|
+
return `${argv.join(' ')} (${parts.join(' ')})`;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Render a fleet verdict for a phone screen.
|
|
259
|
+
*
|
|
260
|
+
* Two constraints shape this, and both come from where the text is read rather than from the data:
|
|
261
|
+
* a verdict can name a dozen machines, and a chat message is not a terminal. So the per-machine
|
|
262
|
+
* detail is **capped**, the aggregate verdict leads, and a truncation is **stated** — a list that
|
|
263
|
+
* silently stops at ten reads as "ten machines" rather than "ten shown".
|
|
264
|
+
*/
|
|
265
|
+
export function renderVerdict({ taskId, verdict, machines, maxMachines = 10 }) {
|
|
266
|
+
const lines = [`判定:${verdict}`, `任务:${taskId}`];
|
|
267
|
+
const rows = Array.isArray(machines) ? machines : [];
|
|
268
|
+
if (rows.length === 0) {
|
|
269
|
+
lines.push('(还没有逐机结果)');
|
|
270
|
+
return { text: lines.join('\n'), truncated: 0 };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const shown = rows.slice(0, maxMachines);
|
|
274
|
+
lines.push('', '逐机结果:');
|
|
275
|
+
for (const m of shown) {
|
|
276
|
+
const name = m.machine_name ?? m.machine_id ?? '?';
|
|
277
|
+
const outcome = m.outcome ?? m.state ?? '?';
|
|
278
|
+
const code = m.exit_code === null || m.exit_code === undefined ? '' : ` exit=${m.exit_code}`;
|
|
279
|
+
const why = m.refusal_reason ? ` (${m.refusal_reason})` : '';
|
|
280
|
+
lines.push(` ${name}: ${outcome}${code}${why}`);
|
|
281
|
+
}
|
|
282
|
+
const truncated = rows.length - shown.length;
|
|
283
|
+
if (truncated > 0) lines.push(` …另有 ${truncated} 台未显示`);
|
|
284
|
+
return { text: lines.join('\n'), truncated };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Render the failure of a dispatch that never became a task.
|
|
289
|
+
*
|
|
290
|
+
* `fleet.mjs` reports `NO_ONLINE_DEVICE` for several different situations, and the useful sentence
|
|
291
|
+
* differs: "no machine is paired" and "the machines are paired but silent" call for different
|
|
292
|
+
* actions from whoever is holding the phone.
|
|
293
|
+
*/
|
|
294
|
+
export function renderDispatchFailure({ code, reason, detail }) {
|
|
295
|
+
if (code === 'NO_ONLINE_DEVICE') {
|
|
296
|
+
if (reason === 'requester_has_no_fleet') {
|
|
297
|
+
return '这个 relay 已绑定舰队,而发起方不属于任何舰队,所以不能派发。';
|
|
298
|
+
}
|
|
299
|
+
if (reason === 'target_outside_fleet') {
|
|
300
|
+
return '指定的机器不在这条舰队里。';
|
|
301
|
+
}
|
|
302
|
+
return '没有可用的在线设备。请确认每台机器上的 Localside agent 都在运行。';
|
|
303
|
+
}
|
|
304
|
+
if (code === 'COMMAND_NOT_ALLOWED') {
|
|
305
|
+
return `这条命令不在目标机器的 allow-list 里${detail ? `:${detail}` : ''}。`;
|
|
306
|
+
}
|
|
307
|
+
return `派发失败:${code ?? '未知错误'}${detail ? ` — ${detail}` : ''}`;
|
|
308
|
+
}
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -177,7 +177,7 @@ const MAX_STAGES = 16;
|
|
|
177
177
|
* `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
|
|
178
178
|
* overrides it at runtime for tests and packagers.
|
|
179
179
|
*/
|
|
180
|
-
const PLUGIN_VERSION = '
|
|
180
|
+
const PLUGIN_VERSION = '__W2M_PLUGIN_VERSION__';
|
|
181
181
|
|
|
182
182
|
/** How long any single HTTP call may take before it is aborted, in milliseconds. */
|
|
183
183
|
const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
|
package/src/relay/server.mjs
CHANGED
|
@@ -29,6 +29,11 @@ import { renderMetrics, METRICS_CONTENT_TYPE } from './metrics.mjs';
|
|
|
29
29
|
// v0.3.0 §9: request signing. This import is the entire point of the module --
|
|
30
30
|
// until the HTTP layer calls it, HMAC support does not exist for any real request.
|
|
31
31
|
import { DEFAULT_SKEW_SECONDS, NonceCache, verifyRequest } from '../signing.mjs';
|
|
32
|
+
// v0.7.11: per-device Ed25519 verification, tried before the shared secret.
|
|
33
|
+
import {
|
|
34
|
+
SIGNATURE_HEADER as DEVICE_SIGNATURE_HEADER,
|
|
35
|
+
verifyDeviceSignature,
|
|
36
|
+
} from '../chatops/device-signing.mjs';
|
|
32
37
|
|
|
33
38
|
export const DEFAULT_SSE_KEEPALIVE_MS = 15_000; // §3.2: every 15s a `: keepalive` line
|
|
34
39
|
export const DEFAULT_SWEEP_INTERVAL_MS = 1_000;
|
|
@@ -127,6 +132,19 @@ function firstHeaderValue(value) {
|
|
|
127
132
|
return first === '' ? null : first;
|
|
128
133
|
}
|
|
129
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Read one header case-insensitively from a plain Node headers object.
|
|
137
|
+
*
|
|
138
|
+
* `signing.mjs` does this internally, but the device-key path needs it before deciding whether that
|
|
139
|
+
* module applies at all, so it is needed here too. Node lower-cases incoming header names, and a
|
|
140
|
+
* test that builds a request by hand may not, so both spellings are tried.
|
|
141
|
+
*/
|
|
142
|
+
function readHeader(headers, name) {
|
|
143
|
+
if (headers === null || headers === undefined) return null;
|
|
144
|
+
const found = Object.keys(headers).find((k) => k.toLowerCase() === name);
|
|
145
|
+
return found === undefined ? null : firstHeaderValue(headers[found]);
|
|
146
|
+
}
|
|
147
|
+
|
|
130
148
|
/**
|
|
131
149
|
* Read a request body with a hard byte cap.
|
|
132
150
|
* @returns {Promise<{tooLarge?:boolean, buffer?:Buffer, aborted?:boolean}>}
|
|
@@ -973,7 +991,37 @@ export class RelayServer {
|
|
|
973
991
|
* path is the only value both sides agree on in all three deployment forms, so
|
|
974
992
|
* clients sign `/v1/result` and never `https://host/w2m/v1/result`.
|
|
975
993
|
*/
|
|
994
|
+
/**
|
|
995
|
+
* Verify a request's signature (v0.7.11).
|
|
996
|
+
*
|
|
997
|
+
* Two mechanisms, and the order between them is the whole design:
|
|
998
|
+
*
|
|
999
|
+
* **1. Per-device Ed25519, when the sender is a device with a stored public key.** This is the
|
|
1000
|
+
* one that proves *which* device signed. It is tried **first** because a device key is strictly
|
|
1001
|
+
* stronger evidence than a shared secret, and because a device that registered a key must not
|
|
1002
|
+
* fall back to the weaker mechanism by accident.
|
|
1003
|
+
*
|
|
1004
|
+
* **2. HMAC over a shared secret, otherwise.** Every device that paired before v0.7.10 arrives
|
|
1005
|
+
* here — which is every device today — and nothing about its behaviour changes.
|
|
1006
|
+
*
|
|
1007
|
+
* ## Why this converges with no second deployment
|
|
1008
|
+
*
|
|
1009
|
+
* A device that has not been updated sends no signature header, so mechanism 1 finds no key and
|
|
1010
|
+
* mechanism 2 answers exactly as before. A device that *has* been updated sends a signature, its
|
|
1011
|
+
* public key is on file from pairing, and it is verified with it. So a fleet updates one machine
|
|
1012
|
+
* at a time and each machine gets the strongest mechanism available **to it** the moment it
|
|
1013
|
+
* starts sending one — no flag, no rollout step, no coordination.
|
|
1014
|
+
*
|
|
1015
|
+
* ## What this deliberately does not do
|
|
1016
|
+
*
|
|
1017
|
+
* It does not **require** a device key. `requireSignature` still means "a signature must be
|
|
1018
|
+
* present", and either mechanism satisfies it. Making a *device key* mandatory is a separate,
|
|
1019
|
+
* later switch, because turning it on today would refuse all four machines at once.
|
|
1020
|
+
*/
|
|
976
1021
|
verifySignature(req, { method, signedPath, body }) {
|
|
1022
|
+
const viaDevice = this.verifyWithDeviceKey(req, { method, signedPath, body });
|
|
1023
|
+
if (viaDevice !== null) return viaDevice;
|
|
1024
|
+
|
|
977
1025
|
return verifyRequest({
|
|
978
1026
|
headers: req.headers, // plain Node object; signing.mjs reads it case-insensitively
|
|
979
1027
|
method,
|
|
@@ -987,6 +1035,54 @@ export class RelayServer {
|
|
|
987
1035
|
});
|
|
988
1036
|
}
|
|
989
1037
|
|
|
1038
|
+
/**
|
|
1039
|
+
* Try the per-device Ed25519 path.
|
|
1040
|
+
*
|
|
1041
|
+
* Returns `null` when the mechanism does not apply — no signature presented, or the signing
|
|
1042
|
+
* machine is not a device whose public key we hold — so the caller falls through. Returns a
|
|
1043
|
+
* decision when it *does* apply, **including when it fails**: a device that registered a key and
|
|
1044
|
+
* then presented a bad signature must not get a second chance through the shared secret, since
|
|
1045
|
+
* that would make the stronger mechanism optional for anyone able to produce a malformed one.
|
|
1046
|
+
*/
|
|
1047
|
+
verifyWithDeviceKey(req, { method, signedPath, body }) {
|
|
1048
|
+
const header = readHeader(req.headers, DEVICE_SIGNATURE_HEADER);
|
|
1049
|
+
if (typeof header !== 'string' || header === '') return null;
|
|
1050
|
+
|
|
1051
|
+
const machineId = this.machineIdForRequest(req);
|
|
1052
|
+
if (machineId === null) return null;
|
|
1053
|
+
const publicKey = this.state.devices.get(machineId)?.public_key;
|
|
1054
|
+
if (typeof publicKey !== 'string' || publicKey === '') return null;
|
|
1055
|
+
|
|
1056
|
+
const decision = verifyDeviceSignature({
|
|
1057
|
+
headers: req.headers,
|
|
1058
|
+
publicKeyPem: publicKey,
|
|
1059
|
+
method,
|
|
1060
|
+
path: signedPath,
|
|
1061
|
+
body: body ?? '',
|
|
1062
|
+
nowMs: this.state.nowMs(),
|
|
1063
|
+
skewSeconds: this.signatureSkewSeconds,
|
|
1064
|
+
// `accept`, not `remember` -- the name this class actually exposes. It reports whether the
|
|
1065
|
+
// nonce was new, which is exactly the contract `verifyDeviceSignature` expects.
|
|
1066
|
+
nonces: (nonce, at) => this.nonceCache.accept(nonce, at),
|
|
1067
|
+
});
|
|
1068
|
+
if (decision.ok) return { ...decision, machine_id: machineId, keyIndex: null };
|
|
1069
|
+
return decision;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
/**
|
|
1073
|
+
* Which machine a request claims to be, from either credential.
|
|
1074
|
+
*
|
|
1075
|
+
* An **operator token is not a machine**, so it resolves to `null` and the device path does not
|
|
1076
|
+
* apply — consistent with `authorizeTaskDispatch`, which reports `machine_id: null` for an
|
|
1077
|
+
* operator. That is why an operator-token request keeps using the shared secret.
|
|
1078
|
+
*/
|
|
1079
|
+
machineIdForRequest(req) {
|
|
1080
|
+
const token = bearerToken(req);
|
|
1081
|
+
if (!token) return null;
|
|
1082
|
+
if (this.operatorToken !== null && token === this.operatorToken) return null;
|
|
1083
|
+
return this.state.authenticate(token)?.machine_id ?? null;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
990
1086
|
/** Periodic maintenance, called by the sweep timer. */
|
|
991
1087
|
maintenance() {
|
|
992
1088
|
const expired = this.sweep();
|