talon-agent 5.20.1 → 5.22.0
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/README.md +2 -1
- package/package.json +2 -2
- package/src/cli/commands/mesh-audit.ts +89 -0
- package/src/cli/commands/mesh.ts +22 -1
- package/src/cli/index.ts +1 -1
- package/src/cli/status.ts +24 -1
- package/src/core/config/index.ts +12 -11
- package/src/core/engine/gateway-actions/fetch-url/guard.ts +4 -4
- package/src/core/engine/gateway-actions/fetch-url/index.ts +4 -3
- package/src/core/engine/gateway-actions/mesh.ts +2 -0
- package/src/core/engine/gateway-routes.ts +16 -0
- package/src/core/mesh/audit.ts +232 -0
- package/src/core/mesh/credentials/index.ts +1 -0
- package/src/core/mesh/credentials/store.ts +42 -1
- package/src/core/mesh/credentials/types.ts +21 -2
- package/src/core/mesh/devices/service.ts +103 -5
- package/src/core/mesh/links/bridge-links.ts +94 -9
- package/src/core/mesh/links/node-provision.ts +285 -41
- package/src/core/mesh/transfers/device-files.ts +110 -39
- package/src/core/mesh/transfers/transfers.ts +31 -15
- package/src/core/tools/ops/mesh.ts +24 -4
- package/src/core/weaver/weaver.ts +4 -1
- package/src/frontend/native/bridge/credentials/upgrade.ts +16 -3
- package/src/frontend/native/bridge/routes/host.ts +12 -2
- package/src/frontend/native/bridge/routes/pre-auth.ts +8 -2
- package/src/frontend/native/bridge/server.ts +8 -0
- package/src/frontend/native/bridge/tls.ts +15 -0
- package/src/frontend/native/index.ts +26 -0
- package/src/frontend/native/surface/handlers.ts +1 -1
- package/src/util/logging/turn-scope.ts +23 -2
|
@@ -9,14 +9,28 @@
|
|
|
9
9
|
* GET /node/install?provision=<token> → installer script (once)
|
|
10
10
|
* GET /node/binary?provision=<token> → the binary itself (once)
|
|
11
11
|
*
|
|
12
|
+
* A grant can also be minted WITHOUT a target ("auto"): the one-liner then
|
|
13
|
+
* reports the host's own `uname -s`/`uname -m` (or PowerShell's
|
|
14
|
+
* PROCESSOR_ARCHITECTURE) on the script fetch, and the caller resolves the
|
|
15
|
+
* binary and pins it into the grant before the script is rendered — so the
|
|
16
|
+
* script still embeds the digest and nothing about integrity changes.
|
|
17
|
+
*
|
|
12
18
|
* The routes must work pre-auth — the whole point is a host that holds no
|
|
13
19
|
* bridge credential yet — so the grant token IS the authorization, exactly
|
|
14
20
|
* like streamed-transfer tokens (transfers/transfers.ts): random 192-bit, single-use
|
|
15
21
|
* per leg, expiring unused. The script carries the bridge bearer token and
|
|
16
22
|
* pinned TLS fingerprint into the node's config, verifies the downloaded
|
|
17
|
-
* binary against the grant's digest
|
|
18
|
-
*
|
|
19
|
-
*
|
|
23
|
+
* binary against the grant's digest, installs it, and registers the boot
|
|
24
|
+
* service via `talon-node install`.
|
|
25
|
+
*
|
|
26
|
+
* Over HTTPS both fetches (script and binary) are pinned to the bridge's own
|
|
27
|
+
* key — curl `--pinnedpubkey`, a certificate SHA-256 check on Windows — so a
|
|
28
|
+
* man in the middle can neither swap the script nor read the grant's bearer
|
|
29
|
+
* credential off the wire. Plain HTTP has nothing to pin.
|
|
30
|
+
*
|
|
31
|
+
* Every value that lands in generated sh/PowerShell text is validated to a
|
|
32
|
+
* quote-safe alphabet at the edge ({@link checkBridgeUrl}, device names) and
|
|
33
|
+
* escaped for its quoting context here anyway.
|
|
20
34
|
*/
|
|
21
35
|
|
|
22
36
|
import { randomBytes } from "node:crypto";
|
|
@@ -41,13 +55,29 @@ export type NodeProvisionGrant = {
|
|
|
41
55
|
bearerToken: string;
|
|
42
56
|
/** Bridge TLS certificate fingerprint to pre-pin (absent over plain HTTP). */
|
|
43
57
|
fingerprint?: string;
|
|
58
|
+
/** Base64 SHA-256 of the bridge key's SPKI — curl's pin (absent over HTTP). */
|
|
59
|
+
spkiPin?: string;
|
|
44
60
|
createdAt: number;
|
|
45
61
|
scriptUsed: boolean;
|
|
46
62
|
binaryUsed: boolean;
|
|
47
63
|
};
|
|
48
64
|
|
|
65
|
+
/** The fields an auto grant is missing until the target host reports in. */
|
|
66
|
+
export type NodeProvisionTarget = Pick<
|
|
67
|
+
NodeProvisionGrant,
|
|
68
|
+
"goos" | "goarch" | "binaryPath" | "sha256" | "size" | "version"
|
|
69
|
+
>;
|
|
70
|
+
|
|
71
|
+
/** An auto grant before its target is known — everything but the binary. */
|
|
72
|
+
export type PendingNodeProvision = Omit<
|
|
73
|
+
NodeProvisionGrant,
|
|
74
|
+
keyof NodeProvisionTarget | "scriptUsed" | "binaryUsed"
|
|
75
|
+
>;
|
|
76
|
+
|
|
49
77
|
export class NodeProvisionStore {
|
|
50
78
|
private readonly grants = new Map<string, NodeProvisionGrant>();
|
|
79
|
+
/** Auto grants whose target host hasn't fetched the script yet. */
|
|
80
|
+
private readonly pending = new Map<string, PendingNodeProvision>();
|
|
51
81
|
|
|
52
82
|
constructor(private readonly ttlMs = GRANT_TTL_MS) {}
|
|
53
83
|
|
|
@@ -60,11 +90,7 @@ export class NodeProvisionStore {
|
|
|
60
90
|
this.sweep();
|
|
61
91
|
const full: NodeProvisionGrant = {
|
|
62
92
|
...grant,
|
|
63
|
-
|
|
64
|
-
// keep them to characters that can't break out of a quoted string.
|
|
65
|
-
...(grant.name
|
|
66
|
-
? { name: grant.name.replace(/[^\w .-]+/g, "").slice(0, 64) }
|
|
67
|
-
: {}),
|
|
93
|
+
...sanitizedName(grant.name),
|
|
68
94
|
token: randomBytes(24).toString("base64url"),
|
|
69
95
|
createdAt: Date.now(),
|
|
70
96
|
scriptUsed: false,
|
|
@@ -74,6 +100,48 @@ export class NodeProvisionStore {
|
|
|
74
100
|
return full;
|
|
75
101
|
}
|
|
76
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Mint an auto grant: no target yet. {@link pin} fills it in when the host
|
|
105
|
+
* fetches the script and reports its own os/arch.
|
|
106
|
+
*/
|
|
107
|
+
createAuto(
|
|
108
|
+
grant: Omit<PendingNodeProvision, "token" | "createdAt">,
|
|
109
|
+
): PendingNodeProvision {
|
|
110
|
+
this.sweep();
|
|
111
|
+
const pending: PendingNodeProvision = {
|
|
112
|
+
...grant,
|
|
113
|
+
...sanitizedName(grant.name),
|
|
114
|
+
token: randomBytes(24).toString("base64url"),
|
|
115
|
+
createdAt: Date.now(),
|
|
116
|
+
};
|
|
117
|
+
this.pending.set(pending.token, pending);
|
|
118
|
+
return pending;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Whether `token` is a live auto grant still waiting for its target. */
|
|
122
|
+
isPending(token: string): boolean {
|
|
123
|
+
this.sweep();
|
|
124
|
+
return this.pending.has(token);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Bind an auto grant to the binary for its host. It becomes an ordinary
|
|
129
|
+
* grant under the same token (same TTL clock); a second pin is a no-op.
|
|
130
|
+
*/
|
|
131
|
+
pin(token: string, target: NodeProvisionTarget): boolean {
|
|
132
|
+
this.sweep();
|
|
133
|
+
const pending = this.pending.get(token);
|
|
134
|
+
if (!pending) return false;
|
|
135
|
+
this.pending.delete(token);
|
|
136
|
+
this.grants.set(token, {
|
|
137
|
+
...pending,
|
|
138
|
+
...target,
|
|
139
|
+
scriptUsed: false,
|
|
140
|
+
binaryUsed: false,
|
|
141
|
+
});
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
|
|
77
145
|
/** Serve the installer script for a live grant — once. */
|
|
78
146
|
openScript(token: string): { script: string; filename: string } | null {
|
|
79
147
|
const grant = this.claim(token, "scriptUsed");
|
|
@@ -111,49 +179,216 @@ export class NodeProvisionStore {
|
|
|
111
179
|
for (const [token, grant] of this.grants) {
|
|
112
180
|
if (grant.createdAt < cutoff) this.grants.delete(token);
|
|
113
181
|
}
|
|
182
|
+
for (const [token, grant] of this.pending) {
|
|
183
|
+
if (grant.createdAt < cutoff) this.pending.delete(token);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Device names are injected into generated shell/PowerShell text — keep them
|
|
190
|
+
* to characters that can't break out of a quoted string.
|
|
191
|
+
*/
|
|
192
|
+
function sanitizedName(name: string | undefined): { name?: string } {
|
|
193
|
+
return name ? { name: name.replace(/[^\w .-]+/g, "").slice(0, 64) } : {};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Anything that could end, expand, or escape inside a quoted sh,
|
|
198
|
+
* PowerShell, or cmd.exe string: both quote kinds, `$`, backtick,
|
|
199
|
+
* backslash, `%` (cmd.exe expands `%VAR%` even inside quotes), and
|
|
200
|
+
* everything outside printable ASCII — whitespace, control characters, and
|
|
201
|
+
* the Unicode "smart" quotes PowerShell also treats as quotes.
|
|
202
|
+
*/
|
|
203
|
+
const UNSAFE_URL_CHAR = /["'`$\\%]|[^\x21-\x7e]/;
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Validate a bridge base URL before it is baked into installer scripts: an
|
|
207
|
+
* http(s) URL with a host, made only of characters that are inert in every
|
|
208
|
+
* quoting context the installers use. Returns the URL without trailing
|
|
209
|
+
* slashes, or why it was refused.
|
|
210
|
+
*/
|
|
211
|
+
export function checkBridgeUrl(
|
|
212
|
+
raw: string,
|
|
213
|
+
label = "bridge_url",
|
|
214
|
+
): string | { error: string } {
|
|
215
|
+
const url = raw.trim().replace(/\/+$/, "");
|
|
216
|
+
const refused = {
|
|
217
|
+
error: `${label} must be a plain http(s) URL (no quotes, $, backticks, backslashes, % or whitespace), got ${JSON.stringify(url)}.`,
|
|
218
|
+
};
|
|
219
|
+
if (UNSAFE_URL_CHAR.test(url)) return refused;
|
|
220
|
+
let parsed: URL;
|
|
221
|
+
try {
|
|
222
|
+
parsed = new URL(url);
|
|
223
|
+
} catch {
|
|
224
|
+
return refused;
|
|
114
225
|
}
|
|
226
|
+
const web = parsed.protocol === "http:" || parsed.protocol === "https:";
|
|
227
|
+
return web && parsed.hostname ? url : refused;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ── Quoting ──────────────────────────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
/** Escape for a double-quoted POSIX sh string. */
|
|
233
|
+
function sh(value: string): string {
|
|
234
|
+
return value.replace(/["$`\\]/g, "\\$&");
|
|
115
235
|
}
|
|
116
236
|
|
|
237
|
+
/** Escape for a double-quoted PowerShell string (backtick is the escape). */
|
|
238
|
+
function ps(value: string): string {
|
|
239
|
+
return value.replace(/["$`“”„]/g, "`$&");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Escape for a single-quoted PowerShell string (quotes double up). */
|
|
243
|
+
function psq(value: string): string {
|
|
244
|
+
return value.replace(/['‘’‚‛]/g, "$&$&");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Keep a value on one line — for `#` comment lines. */
|
|
248
|
+
function oneLine(value: string): string {
|
|
249
|
+
return value.replace(/[\r\n]+/g, " ");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ── Pinning ──────────────────────────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Pinned fetches for PowerShell, compiled with Add-Type so the certificate
|
|
256
|
+
* callback is a real .NET delegate: a script-block callback only works on
|
|
257
|
+
* the pipeline thread (Windows PowerShell 5.1's synchronous iwr), while
|
|
258
|
+
* pwsh 7's HttpClient calls it from the thread pool, where no runspace
|
|
259
|
+
* exists. HttpWebRequest's per-request callback exists on .NET Framework
|
|
260
|
+
* 4.5+ and every .NET Core, so one source serves both shells; -IgnoreWarnings
|
|
261
|
+
* because pwsh 7 would fail on WebRequest's obsolete warning.
|
|
262
|
+
*
|
|
263
|
+
* The check: the presented certificate's SHA-256 (BitConverter's `AB-CD-…`
|
|
264
|
+
* form) must equal `[TalonPin]::Pin` — the same certificate hash talon-node
|
|
265
|
+
* pins afterwards. An unset pin fails closed. The source holds no quotes,
|
|
266
|
+
* `$` or `%`, so it survives a PowerShell single-quoted string inside a
|
|
267
|
+
* cmd.exe (or PowerShell) double-quoted `-Command` argument.
|
|
268
|
+
*/
|
|
269
|
+
const PIN_TYPE_SOURCE =
|
|
270
|
+
"using System;using System.IO;using System.Net;using System.Security.Cryptography;" +
|
|
271
|
+
"public static class TalonPin{public static string Pin;" +
|
|
272
|
+
"static WebResponse Open(string u){var r=(HttpWebRequest)WebRequest.Create(u);" +
|
|
273
|
+
"r.ServerCertificateValidationCallback=(s,c,h,e)=>c!=null&&string.Equals(" +
|
|
274
|
+
"BitConverter.ToString(SHA256.Create().ComputeHash(c.GetRawCertData())),Pin,StringComparison.OrdinalIgnoreCase);" +
|
|
275
|
+
"return r.GetResponse();}" +
|
|
276
|
+
"public static string Get(string u){using(var w=Open(u))using(var t=new StreamReader(w.GetResponseStream()))return t.ReadToEnd();}" +
|
|
277
|
+
"public static void Save(string u,string p){using(var w=Open(u))using(var i=w.GetResponseStream())using(var o=File.Create(p))i.CopyTo(o);}}";
|
|
278
|
+
|
|
279
|
+
/** `abcd…` → `AB-CD-…`, the form BitConverter.ToString gives a hash. */
|
|
280
|
+
function dashedFingerprint(fingerprint: string): string {
|
|
281
|
+
return (fingerprint.match(/.{2}/g) ?? []).join("-").toUpperCase();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** PowerShell statements that load TalonPin and arm it with the pin. */
|
|
285
|
+
function psPinSetup(fingerprint: string): string {
|
|
286
|
+
return (
|
|
287
|
+
`if (-not ('TalonPin' -as [type])) { Add-Type -IgnoreWarnings -TypeDefinition '${psq(PIN_TYPE_SOURCE)}' }; ` +
|
|
288
|
+
`[TalonPin]::Pin = '${psq(dashedFingerprint(fingerprint))}'`
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* curl flags for a bridge fetch. With a pin, `-k` stays on purpose: the
|
|
294
|
+
* bridge cert is self-signed and curl still chain-verifies without `-k`,
|
|
295
|
+
* even when the pin matches — while `--pinnedpubkey` is enforced with or
|
|
296
|
+
* without `-k`. So the pin, not the chain, is the real check. Without one
|
|
297
|
+
* (plain HTTP, nothing to pin) the flags are unchanged.
|
|
298
|
+
*/
|
|
299
|
+
function curlFlags(spkiPin: string | undefined): string {
|
|
300
|
+
return spkiPin ? `-fsSk --pinnedpubkey "sha256//${sh(spkiPin)}"` : "-fsSk";
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ── Generated commands ───────────────────────────────────────────────────────
|
|
304
|
+
|
|
117
305
|
/** The command a human (or the model over SSH) runs on the target host. */
|
|
118
306
|
export function installOneLiner(grant: NodeProvisionGrant): string {
|
|
119
307
|
const url = `${grant.bridgeUrl}/node/install?provision=${grant.token}`;
|
|
120
|
-
if (grant.goos
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
//
|
|
308
|
+
if (grant.goos !== "windows") {
|
|
309
|
+
return `curl ${curlFlags(grant.spkiPin)} "${sh(url)}" | sh`;
|
|
310
|
+
}
|
|
311
|
+
if (grant.fingerprint) {
|
|
312
|
+
// No `$` anywhere, so the line means the same pasted into cmd.exe or a
|
|
313
|
+
// PowerShell prompt (whose double quotes would expand variables).
|
|
125
314
|
return (
|
|
126
315
|
`powershell -ExecutionPolicy Bypass -Command "` +
|
|
127
|
-
|
|
128
|
-
`
|
|
316
|
+
`${psPinSetup(grant.fingerprint)}; ` +
|
|
317
|
+
`iex ([TalonPin]::Get('${psq(url)}'))"`
|
|
129
318
|
);
|
|
130
319
|
}
|
|
131
|
-
|
|
320
|
+
// Plain HTTP: nothing to pin, and the trust-all callback is inert. The
|
|
321
|
+
// line is meant for cmd.exe, where $true survives the outer double quotes.
|
|
322
|
+
return (
|
|
323
|
+
`powershell -ExecutionPolicy Bypass -Command "` +
|
|
324
|
+
`[Net.ServicePointManager]::ServerCertificateValidationCallback={$true}; ` +
|
|
325
|
+
`iwr -UseBasicParsing '${psq(url)}' | Select-Object -ExpandProperty Content | iex"`
|
|
326
|
+
);
|
|
132
327
|
}
|
|
133
328
|
|
|
134
329
|
/**
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
330
|
+
* The two commands for an auto grant — one per shell family, pinned exactly
|
|
331
|
+
* like {@link installOneLiner}. Each appends its host's platform to the
|
|
332
|
+
* script fetch; the bridge maps `Linux`/`Darwin`, `x86_64`/`aarch64`/
|
|
333
|
+
* `armv7l`, and PowerShell's `AMD64`/`ARM64` through the same normalizers
|
|
334
|
+
* the tool arguments use.
|
|
335
|
+
*
|
|
336
|
+
* The PowerShell line carries one `$` (`$env:PROCESSOR_ARCHITECTURE`): from
|
|
337
|
+
* cmd.exe it reaches PowerShell verbatim, and from a PowerShell prompt the
|
|
338
|
+
* outer double quotes expand it first — the same value either way.
|
|
339
|
+
*/
|
|
340
|
+
export function autoInstallOneLiners(
|
|
341
|
+
grant: Pick<
|
|
342
|
+
NodeProvisionGrant,
|
|
343
|
+
"bridgeUrl" | "token" | "fingerprint" | "spkiPin"
|
|
344
|
+
>,
|
|
345
|
+
): { posix: string; windows: string } {
|
|
346
|
+
const url = `${grant.bridgeUrl}/node/install?provision=${grant.token}`;
|
|
347
|
+
const posix = `curl ${curlFlags(grant.spkiPin)} "${sh(url)}&os=$(uname -s)&arch=$(uname -m)" | sh`;
|
|
348
|
+
const psUrl = `('${psq(url)}&os=windows&arch=' + $env:PROCESSOR_ARCHITECTURE)`;
|
|
349
|
+
const windows = grant.fingerprint
|
|
350
|
+
? `powershell -ExecutionPolicy Bypass -Command "` +
|
|
351
|
+
`${psPinSetup(grant.fingerprint)}; ` +
|
|
352
|
+
`iex ([TalonPin]::Get${psUrl})"`
|
|
353
|
+
: `powershell -ExecutionPolicy Bypass -Command "` +
|
|
354
|
+
`[Net.ServicePointManager]::ServerCertificateValidationCallback={$true}; ` +
|
|
355
|
+
`iwr -UseBasicParsing ${psUrl} | Select-Object -ExpandProperty Content | iex"`;
|
|
356
|
+
return { posix, windows };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* What an auto grant serves when the host's platform has no build or the
|
|
361
|
+
* binary can't be resolved: a script that fails loudly. Valid as both POSIX
|
|
362
|
+
* sh and PowerShell, since which shell is piping it isn't known.
|
|
363
|
+
*/
|
|
364
|
+
export function installRefusalScript(reason: string): string {
|
|
365
|
+
const safe = reason.replace(/["'`$\\%\n\r]+/g, " ").slice(0, 300);
|
|
366
|
+
return `echo "talon-node install refused: ${safe}"\nexit 1\n`;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* POSIX installer: fetch the binary over the same grant (pinned like the
|
|
371
|
+
* script fetch), verify its digest, install it next to the node's config,
|
|
372
|
+
* and register the boot service. The certificate fingerprint is pre-pinned
|
|
373
|
+
* into the node's config for every connection after install.
|
|
140
374
|
*/
|
|
141
375
|
function shellInstaller(grant: NodeProvisionGrant): string {
|
|
142
|
-
const nameFlag = grant.name ? ` --name "${grant.name}"` : "";
|
|
376
|
+
const nameFlag = grant.name ? ` --name "${sh(grant.name)}"` : "";
|
|
143
377
|
const fpFlag = grant.fingerprint
|
|
144
|
-
? ` --fingerprint "${grant.fingerprint}"`
|
|
378
|
+
? ` --fingerprint "${sh(grant.fingerprint)}"`
|
|
145
379
|
: "";
|
|
380
|
+
const target = `${grant.version} (${grant.goos}/${grant.goarch})`;
|
|
146
381
|
return `#!/bin/sh
|
|
147
|
-
# talon-node installer — generated by Talon ${grant.version} for ${grant.goos}/${grant.goarch}
|
|
382
|
+
# talon-node installer — generated by Talon ${oneLine(`${grant.version} for ${grant.goos}/${grant.goarch}`)}
|
|
148
383
|
set -eu
|
|
149
|
-
BRIDGE="${grant.bridgeUrl}"
|
|
150
|
-
SHA="${grant.sha256}"
|
|
384
|
+
BRIDGE="${sh(grant.bridgeUrl)}"
|
|
385
|
+
SHA="${sh(grant.sha256)}"
|
|
151
386
|
if [ "$(id -u)" = "0" ]; then DEST_DIR="\${TALON_NODE_DIR:-/usr/local/bin}"; else DEST_DIR="\${TALON_NODE_DIR:-$HOME/.talon-node/bin}"; fi
|
|
152
387
|
mkdir -p "$DEST_DIR"
|
|
153
388
|
TMP="$(mktemp)"
|
|
154
389
|
trap 'rm -f "$TMP"' EXIT
|
|
155
|
-
echo "Downloading talon-node ${
|
|
156
|
-
curl
|
|
390
|
+
echo "Downloading talon-node ${sh(target)}..."
|
|
391
|
+
curl ${curlFlags(grant.spkiPin)} "$BRIDGE/node/binary?provision=${sh(grant.token)}" -o "$TMP"
|
|
157
392
|
if command -v sha256sum >/dev/null 2>&1; then GOT="$(sha256sum "$TMP" | cut -d' ' -f1)"; else GOT="$(shasum -a 256 "$TMP" | cut -d' ' -f1)"; fi
|
|
158
393
|
if [ "$GOT" != "$SHA" ]; then echo "talon-node download failed its checksum — refusing to install" >&2; exit 1; fi
|
|
159
394
|
BIN="$DEST_DIR/talon-node"
|
|
@@ -161,37 +396,46 @@ mv "$TMP" "$BIN"
|
|
|
161
396
|
chmod 0755 "$BIN"
|
|
162
397
|
trap - EXIT
|
|
163
398
|
echo "Installed $BIN"
|
|
164
|
-
"$BIN" install --bridge "$BRIDGE" --token "${grant.bearerToken}"${fpFlag}${nameFlag}
|
|
399
|
+
"$BIN" install --bridge "$BRIDGE" --token "${sh(grant.bearerToken)}"${fpFlag}${nameFlag}
|
|
165
400
|
echo "Done — this host is now on the mesh. Check with: $BIN status"
|
|
166
401
|
`;
|
|
167
402
|
}
|
|
168
403
|
|
|
404
|
+
/** The PowerShell lines that download the binary to `$bin`. */
|
|
405
|
+
function psBinaryFetch(grant: NodeProvisionGrant): string {
|
|
406
|
+
const url = `"$bridge/node/binary?provision=${ps(grant.token)}"`;
|
|
407
|
+
return grant.fingerprint
|
|
408
|
+
? `${psPinSetup(grant.fingerprint)}\n[TalonPin]::Save(${url}, $bin)`
|
|
409
|
+
: `[Net.ServicePointManager]::ServerCertificateValidationCallback = { $true }\n` +
|
|
410
|
+
`Invoke-WebRequest -UseBasicParsing ${url} -OutFile $bin`;
|
|
411
|
+
}
|
|
412
|
+
|
|
169
413
|
/**
|
|
170
|
-
* PowerShell installer (Windows PowerShell 5
|
|
171
|
-
* the installing user's %LOCALAPPDATA% and `talon-node install` registers
|
|
172
|
-
* boot task that runs as that same user (never SYSTEM — a SYSTEM task would
|
|
414
|
+
* PowerShell installer (Windows PowerShell 5.1 and pwsh 7). The binary lands
|
|
415
|
+
* in the installing user's %LOCALAPPDATA% and `talon-node install` registers
|
|
416
|
+
* a boot task that runs as that same user (never SYSTEM — a SYSTEM task would
|
|
173
417
|
* run a binary the user can rewrite). Registering a boot task needs an
|
|
174
418
|
* elevated PowerShell.
|
|
175
419
|
*/
|
|
176
420
|
function powershellInstaller(grant: NodeProvisionGrant): string {
|
|
177
|
-
const nameArg = grant.name ? `, "--name", "${grant.name}"` : "";
|
|
421
|
+
const nameArg = grant.name ? `, "--name", "${ps(grant.name)}"` : "";
|
|
178
422
|
const fpArg = grant.fingerprint
|
|
179
|
-
? `, "--fingerprint", "${grant.fingerprint}"`
|
|
423
|
+
? `, "--fingerprint", "${ps(grant.fingerprint)}"`
|
|
180
424
|
: "";
|
|
181
|
-
|
|
425
|
+
const target = `${grant.version} (${grant.goos}/${grant.goarch})`;
|
|
426
|
+
return `# talon-node installer — generated by Talon ${oneLine(`${grant.version} for ${grant.goos}/${grant.goarch}`)}
|
|
182
427
|
$ErrorActionPreference = "Stop"
|
|
183
|
-
|
|
184
|
-
$bridge = "${grant.bridgeUrl}"
|
|
428
|
+
$bridge = "${ps(grant.bridgeUrl)}"
|
|
185
429
|
$dest = Join-Path $env:LOCALAPPDATA "talon-node"
|
|
186
430
|
New-Item -ItemType Directory -Force -Path $dest | Out-Null
|
|
187
431
|
$bin = Join-Path $dest "talon-node.exe"
|
|
188
|
-
Write-Host "Downloading talon-node ${
|
|
189
|
-
|
|
432
|
+
Write-Host "Downloading talon-node ${ps(target)}..."
|
|
433
|
+
${psBinaryFetch(grant)}
|
|
190
434
|
$got = (Get-FileHash $bin -Algorithm SHA256).Hash.ToLower()
|
|
191
|
-
if ($got -ne "${grant.sha256}") { Remove-Item $bin; throw "talon-node download failed its checksum - refusing to install" }
|
|
435
|
+
if ($got -ne "${ps(grant.sha256)}") { Remove-Item $bin; throw "talon-node download failed its checksum - refusing to install" }
|
|
192
436
|
Write-Host "Installed $bin"
|
|
193
437
|
Write-Host "Registering the boot task to run as $env:USERDOMAIN\\$env:USERNAME (not SYSTEM)..."
|
|
194
|
-
& $bin install --bridge $bridge --token "${grant.bearerToken}"${fpArg}${nameArg}
|
|
438
|
+
& $bin install --bridge $bridge --token "${ps(grant.bearerToken)}"${fpArg}${nameArg}
|
|
195
439
|
Write-Host "Done - this host is now on the mesh. Check with: $bin status"
|
|
196
440
|
`;
|
|
197
441
|
}
|
|
@@ -15,10 +15,11 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { randomUUID } from "node:crypto";
|
|
18
|
-
import { mkdir, readFile, rm,
|
|
18
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
19
19
|
import { tmpdir } from "node:os";
|
|
20
20
|
import { basename, dirname, join, resolve } from "node:path";
|
|
21
21
|
import type { Readable } from "node:stream";
|
|
22
|
+
import { logDebug } from "../../../util/log.js";
|
|
22
23
|
import { dirs } from "../../../util/paths.js";
|
|
23
24
|
import {
|
|
24
25
|
formatBytes,
|
|
@@ -127,6 +128,14 @@ const MAX_CHUNKED_TRANSFER_BYTES = 64 * 1024 * 1024;
|
|
|
127
128
|
* Resolved lazily (not at module load) so a test that mocks `util/paths`
|
|
128
129
|
* doesn't hit its workspace binding before initialization.
|
|
129
130
|
*/
|
|
131
|
+
/**
|
|
132
|
+
* The opt-in rollback flag for install_apk / update_node. Sent only when set,
|
|
133
|
+
* so the default command shape is exactly what older devices already accept.
|
|
134
|
+
*/
|
|
135
|
+
function downgradeParam(allow: unknown): { allow_downgrade?: true } {
|
|
136
|
+
return allow === true ? { allow_downgrade: true } : {};
|
|
137
|
+
}
|
|
138
|
+
|
|
130
139
|
function pullDir(): string {
|
|
131
140
|
return resolve(dirs.workspace, "mesh-pull");
|
|
132
141
|
}
|
|
@@ -202,7 +211,7 @@ export class DeviceFiles {
|
|
|
202
211
|
// is normally already resolved — the grace window only catches a device
|
|
203
212
|
// that claims success without having streamed anything.
|
|
204
213
|
try {
|
|
205
|
-
const
|
|
214
|
+
const receipt = await Promise.race([
|
|
206
215
|
done,
|
|
207
216
|
new Promise<never>((_, rej) =>
|
|
208
217
|
setTimeout(
|
|
@@ -216,13 +225,83 @@ export class DeviceFiles {
|
|
|
216
225
|
).unref?.(),
|
|
217
226
|
),
|
|
218
227
|
]);
|
|
219
|
-
|
|
228
|
+
// The device hashed what it sent; the store hashed what arrived.
|
|
229
|
+
const mismatch = digestMismatch(
|
|
230
|
+
dispatched.result,
|
|
231
|
+
receipt.sha256,
|
|
232
|
+
`pull of ${remote} from ${target.name}`,
|
|
233
|
+
);
|
|
234
|
+
if (mismatch) {
|
|
235
|
+
await rm(dest, { force: true }).catch(() => {});
|
|
236
|
+
return {
|
|
237
|
+
error: `Integrity check failed pulling ${remote} from ${target.name}: ${mismatch}. The received copy was discarded.`,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
return { bytes: receipt.bytes };
|
|
220
241
|
} catch (err) {
|
|
221
242
|
this.transfers.cancel(token);
|
|
222
243
|
return { error: (err as Error).message };
|
|
223
244
|
}
|
|
224
245
|
}
|
|
225
246
|
|
|
247
|
+
/**
|
|
248
|
+
* Streamed daemon→device transfer: one command round trip, the body as a
|
|
249
|
+
* single raw HTTP response. The source's SHA-256 rides along so the device
|
|
250
|
+
* can refuse to rename a corrupted temp file into place; its reported
|
|
251
|
+
* digest is checked here too.
|
|
252
|
+
*/
|
|
253
|
+
private async pushViaStream(
|
|
254
|
+
target: DeviceInfo,
|
|
255
|
+
local: string,
|
|
256
|
+
remote: string,
|
|
257
|
+
): Promise<MeshToolResult> {
|
|
258
|
+
const started = Date.now();
|
|
259
|
+
// Hashing the source also sizes the budget (a local read, not a mesh
|
|
260
|
+
// round trip). A source we cannot read still gets a bounded, looser
|
|
261
|
+
// budget and no digest — this decides a timeout, not whether the
|
|
262
|
+
// transfer may start.
|
|
263
|
+
const source = await hashFile(local).catch(() => undefined);
|
|
264
|
+
const { token } = this.transfers.createPush(target.id, local);
|
|
265
|
+
const dispatched = await this.host.dispatchCommand(
|
|
266
|
+
target.id,
|
|
267
|
+
"download_file",
|
|
268
|
+
{ token, path: remote, ...(source && { sha256: source.sha256 }) },
|
|
269
|
+
streamTransferTimeoutMs(source?.size),
|
|
270
|
+
);
|
|
271
|
+
if ("error" in dispatched) {
|
|
272
|
+
this.transfers.cancel(token);
|
|
273
|
+
return { ok: false, text: dispatched.error };
|
|
274
|
+
}
|
|
275
|
+
if (!dispatched.result.ok) {
|
|
276
|
+
this.transfers.cancel(token);
|
|
277
|
+
return {
|
|
278
|
+
ok: false,
|
|
279
|
+
text:
|
|
280
|
+
dispatched.result.message ??
|
|
281
|
+
`${target.name} could not download ${local}.`,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
const mismatch =
|
|
285
|
+
source &&
|
|
286
|
+
digestMismatch(
|
|
287
|
+
dispatched.result,
|
|
288
|
+
source.sha256,
|
|
289
|
+
`push of ${local} to ${target.name}`,
|
|
290
|
+
);
|
|
291
|
+
if (mismatch) {
|
|
292
|
+
return {
|
|
293
|
+
ok: false,
|
|
294
|
+
text: `Integrity check failed pushing ${local} to ${remote} on ${target.name}: ${mismatch}.`,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
const bytes = dispatched.result.data?.bytesWritten;
|
|
298
|
+
const size = typeof bytes === "number" ? bytes : 0;
|
|
299
|
+
return {
|
|
300
|
+
ok: true,
|
|
301
|
+
text: `Pushed ${formatBytes(size)} to ${remote} on ${target.name} (streamed, ${transferRate(size, started)})`,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
226
305
|
/**
|
|
227
306
|
* Raw file bytes off a device — the structured primitive under both the
|
|
228
307
|
* human-readable tool below and the native read/edit path (which must not
|
|
@@ -381,40 +460,7 @@ export class DeviceFiles {
|
|
|
381
460
|
if ("error" in resolved) return { ok: false, text: resolved.error };
|
|
382
461
|
const target = resolved.target;
|
|
383
462
|
if (this.canStream(target, "download_file")) {
|
|
384
|
-
|
|
385
|
-
// The source is on this host, so sizing the budget costs a local stat
|
|
386
|
-
// rather than a mesh round trip. A source we cannot stat still gets a
|
|
387
|
-
// bounded (if looser) budget — this decides a timeout, not whether
|
|
388
|
-
// the transfer is allowed to start.
|
|
389
|
-
const localSize = await stat(local)
|
|
390
|
-
.then((s) => s.size)
|
|
391
|
-
.catch(() => undefined);
|
|
392
|
-
const { token } = this.transfers.createPush(target.id, local);
|
|
393
|
-
const dispatched = await this.host.dispatchCommand(
|
|
394
|
-
target.id,
|
|
395
|
-
"download_file",
|
|
396
|
-
{ token, path: remote },
|
|
397
|
-
streamTransferTimeoutMs(localSize),
|
|
398
|
-
);
|
|
399
|
-
if ("error" in dispatched) {
|
|
400
|
-
this.transfers.cancel(token);
|
|
401
|
-
return { ok: false, text: dispatched.error };
|
|
402
|
-
}
|
|
403
|
-
if (!dispatched.result.ok) {
|
|
404
|
-
this.transfers.cancel(token);
|
|
405
|
-
return {
|
|
406
|
-
ok: false,
|
|
407
|
-
text:
|
|
408
|
-
dispatched.result.message ??
|
|
409
|
-
`${target.name} could not download ${local}.`,
|
|
410
|
-
};
|
|
411
|
-
}
|
|
412
|
-
const bytes = dispatched.result.data?.bytesWritten;
|
|
413
|
-
const size = typeof bytes === "number" ? bytes : 0;
|
|
414
|
-
return {
|
|
415
|
-
ok: true,
|
|
416
|
-
text: `Pushed ${formatBytes(size)} to ${remote} on ${target.name} (streamed, ${transferRate(size, started)})`,
|
|
417
|
-
};
|
|
463
|
+
return this.pushViaStream(target, local, remote);
|
|
418
464
|
}
|
|
419
465
|
let data: Buffer;
|
|
420
466
|
try {
|
|
@@ -450,6 +496,7 @@ export class DeviceFiles {
|
|
|
450
496
|
query: unknown,
|
|
451
497
|
localApkPath: unknown,
|
|
452
498
|
remotePath?: unknown,
|
|
499
|
+
allowDowngrade?: unknown,
|
|
453
500
|
): Promise<MeshToolResult> {
|
|
454
501
|
const local =
|
|
455
502
|
typeof localApkPath === "string" && localApkPath.trim()
|
|
@@ -496,7 +543,7 @@ export class DeviceFiles {
|
|
|
496
543
|
const dispatched = await this.host.dispatchCommand(
|
|
497
544
|
target.id,
|
|
498
545
|
"install_apk",
|
|
499
|
-
{ path: remote, sha256 },
|
|
546
|
+
{ path: remote, sha256, ...downgradeParam(allowDowngrade) },
|
|
500
547
|
this.host.commandTimeoutMs,
|
|
501
548
|
);
|
|
502
549
|
if ("error" in dispatched) return { ok: false, text: dispatched.error };
|
|
@@ -535,6 +582,7 @@ export class DeviceFiles {
|
|
|
535
582
|
query: unknown,
|
|
536
583
|
localBinaryPath?: unknown,
|
|
537
584
|
remotePath?: unknown,
|
|
585
|
+
allowDowngrade?: unknown,
|
|
538
586
|
): Promise<MeshToolResult> {
|
|
539
587
|
await this.host.load();
|
|
540
588
|
const resolved = this.host.resolveDevice(query);
|
|
@@ -604,7 +652,7 @@ export class DeviceFiles {
|
|
|
604
652
|
const dispatched = await this.host.dispatchCommand(
|
|
605
653
|
target.id,
|
|
606
654
|
"update_node",
|
|
607
|
-
{ path: remote, sha256 },
|
|
655
|
+
{ path: remote, sha256, ...downgradeParam(allowDowngrade) },
|
|
608
656
|
this.host.commandTimeoutMs,
|
|
609
657
|
);
|
|
610
658
|
if ("error" in dispatched) return { ok: false, text: dispatched.error };
|
|
@@ -738,6 +786,29 @@ export class DeviceFiles {
|
|
|
738
786
|
}
|
|
739
787
|
}
|
|
740
788
|
|
|
789
|
+
/**
|
|
790
|
+
* Compare the SHA-256 a device reported for a streamed transfer with the one
|
|
791
|
+
* the daemon holds. Returns a description of a mismatch, or null when they
|
|
792
|
+
* agree. A device build that predates transfer digests reports none; the
|
|
793
|
+
* check is then skipped (with a debug line), never failed.
|
|
794
|
+
*/
|
|
795
|
+
function digestMismatch(
|
|
796
|
+
result: DeviceCommandResult,
|
|
797
|
+
expected: string,
|
|
798
|
+
what: string,
|
|
799
|
+
): string | null {
|
|
800
|
+
const reported = result.data?.sha256;
|
|
801
|
+
if (typeof reported !== "string" || !reported) {
|
|
802
|
+
logDebug(
|
|
803
|
+
"mesh",
|
|
804
|
+
`${what}: device reported no sha256 (older build) — skipping the integrity check`,
|
|
805
|
+
);
|
|
806
|
+
return null;
|
|
807
|
+
}
|
|
808
|
+
if (reported.toLowerCase() === expected.toLowerCase()) return null;
|
|
809
|
+
return `the device reported sha256 ${reported}, the daemon has ${expected}`;
|
|
810
|
+
}
|
|
811
|
+
|
|
741
812
|
/** "12.4 MB/s in 3.2s" — observability for streamed transfers. */
|
|
742
813
|
function transferRate(bytes: number, startedAtMs: number): string {
|
|
743
814
|
const seconds = Math.max((Date.now() - startedAtMs) / 1000, 0.001);
|