talon-agent 5.20.0 → 5.21.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.
@@ -253,7 +253,11 @@ export class DeviceCredentialStore {
253
253
  });
254
254
  }
255
255
  const records = this.activeRecordsFor(deviceId);
256
- for (const record of records) record.scopes = next;
256
+ const at = this.now();
257
+ for (const record of records) {
258
+ record.scopes = next;
259
+ record.scopesSetAt = at;
260
+ }
257
261
  if (records.length > 0) {
258
262
  await this.persist();
259
263
  this.emit(records.map((r) => r.id));
@@ -261,6 +265,29 @@ export class DeviceCredentialStore {
261
265
  return records.map(publicView);
262
266
  }
263
267
 
268
+ /**
269
+ * Move every live credential still holding exactly `from` to `to`, unless
270
+ * its scopes were set by hand — for when a default widens, so devices
271
+ * issued the old default get the new one. Returns how many changed.
272
+ */
273
+ async adoptDefaultScopes(
274
+ from: readonly MeshScope[],
275
+ to: readonly MeshScope[],
276
+ ): Promise<number> {
277
+ await this.load();
278
+ const was = normalizeScopes(from).join(",");
279
+ const next = normalizeScopes(to);
280
+ let changed = 0;
281
+ for (const record of this.records.values()) {
282
+ if (!this.isActive(record) || record.scopesSetAt !== undefined) continue;
283
+ if (record.scopes.join(",") !== was) continue;
284
+ record.scopes = [...next];
285
+ changed++;
286
+ }
287
+ if (changed > 0) await this.persist();
288
+ return changed;
289
+ }
290
+
264
291
  /** Subscribe to revocations (the bridge drops matching sessions). */
265
292
  onRevoked(listener: RevocationListener): () => void {
266
293
  this.listeners.add(listener);
@@ -313,11 +340,13 @@ export class DeviceCredentialStore {
313
340
  }
314
341
  const minted = mintCredentialToken();
315
342
  const at = this.now();
343
+ const scopesSetAt = this.handSetScopes(input.deviceId, scopes);
316
344
  const record: DeviceCredentialRecord = {
317
345
  id: minted.id,
318
346
  deviceId: input.deviceId,
319
347
  tokenHash: minted.tokenHash,
320
348
  scopes,
349
+ ...(scopesSetAt !== undefined ? { scopesSetAt } : {}),
321
350
  origin: input.origin,
322
351
  createdAt: at,
323
352
  ...(input.deviceId === null ? { expiresAt: at + UNBOUND_TTL_MS } : {}),
@@ -327,6 +356,18 @@ export class DeviceCredentialStore {
327
356
  return { token: minted.token, credential: publicView(record) };
328
357
  }
329
358
 
359
+ /** A re-issue keeps the mark of scopes an operator set by hand. */
360
+ private handSetScopes(
361
+ deviceId: string | null,
362
+ scopes: readonly MeshScope[],
363
+ ): number | undefined {
364
+ if (deviceId === null) return undefined;
365
+ const key = scopes.join(",");
366
+ return this.activeRecordsFor(deviceId).find(
367
+ (r) => r.scopesSetAt !== undefined && r.scopes.join(",") === key,
368
+ )?.scopesSetAt;
369
+ }
370
+
330
371
  private isActive(record: DeviceCredentialRecord): boolean {
331
372
  if (record.revokedAt !== undefined) return false;
332
373
  return record.expiresAt === undefined || this.now() < record.expiresAt;
@@ -7,7 +7,7 @@
7
7
  * the secret plus the metadata below; the plaintext exists once, in the reply
8
8
  * or link that hands it to the device.
9
9
  *
10
- * Scopes (least privilege — see docs/mesh-credentials.md):
10
+ * Scopes (see docs/mesh-credentials.md):
11
11
  *
12
12
  * device register/heartbeat/report as ITSELF, receive and answer its
13
13
  * own commands, move the files the daemon asked it to move.
@@ -22,10 +22,24 @@
22
22
  const MESH_SCOPES = ["device", "client", "operator"] as const;
23
23
  export type MeshScope = (typeof MESH_SCOPES)[number];
24
24
 
25
- /** What a paired or upgraded companion gets unless the operator widens it. */
25
+ /**
26
+ * What a paired or upgraded companion gets unless the operator narrows it
27
+ * (`native.companionScopes`): everything, as with the shared token.
28
+ */
26
29
  export const DEFAULT_COMPANION_SCOPES: readonly MeshScope[] = [
27
30
  "device",
28
31
  "client",
32
+ "operator",
33
+ ];
34
+
35
+ /**
36
+ * The companion default before `operator` joined it. Credentials still
37
+ * holding exactly this (and never set by hand) are moved to the current
38
+ * default on startup — see `DeviceCredentialStore.adoptDefaultScopes`.
39
+ */
40
+ export const FORMER_COMPANION_SCOPES: readonly MeshScope[] = [
41
+ "device",
42
+ "client",
29
43
  ];
30
44
 
31
45
  /** A headless talon-node is a device and nothing else. */
@@ -55,6 +69,11 @@ export type DeviceCredentialRecord = {
55
69
  /** Hex SHA-256 of the whole token. */
56
70
  tokenHash: string;
57
71
  scopes: MeshScope[];
72
+ /**
73
+ * When an operator last set this device's scopes by hand (`talon mesh
74
+ * scopes`). Such a credential keeps its scopes when a default changes.
75
+ */
76
+ scopesSetAt?: number;
58
77
  origin: CredentialOrigin;
59
78
  createdAt: number;
60
79
  lastUsedAt?: number;
@@ -50,6 +50,15 @@ import {
50
50
  type NodeBinaryResolver,
51
51
  } from "../links/node-binaries.js";
52
52
  import { MeshRegistry } from "./registry.js";
53
+ import {
54
+ auditErrorText,
55
+ auditIssuer,
56
+ hashCommandArgs,
57
+ MeshAuditLog,
58
+ type MeshAuditEntry,
59
+ type MeshAuditQuery,
60
+ } from "../audit.js";
61
+ import { logWarn } from "../../../util/log.js";
53
62
  import {
54
63
  DeviceCredentialStore,
55
64
  type CredentialAdminContext,
@@ -97,6 +106,12 @@ export type MeshServiceOptions = {
97
106
  * without one (tests, embedders) links carry the shared bridge token.
98
107
  */
99
108
  credentials?: DeviceCredentialStore;
109
+ /**
110
+ * Where every dispatched command is recorded (who, which device, which
111
+ * command, an args hash, outcome, duration). Absent (tests, embedders) =
112
+ * nothing is recorded.
113
+ */
114
+ audit?: MeshAuditLog;
100
115
  };
101
116
 
102
117
  const DEFAULT_FRESH_FIX_TIMEOUT_MS = 8_000;
@@ -143,6 +158,8 @@ export class MeshService {
143
158
  private loading: Promise<void> | null = null;
144
159
  /** Per-device credentials (null = shared-token-only mesh). */
145
160
  readonly credentials: DeviceCredentialStore | null;
161
+ /** The command audit (null = commands are not recorded). */
162
+ private readonly audit: MeshAuditLog | null;
146
163
  /** `native.legacySharedToken` as the bridge last reported it. */
147
164
  private legacySharedToken = true;
148
165
 
@@ -167,6 +184,7 @@ export class MeshService {
167
184
  resolveNode: this.resolveNode,
168
185
  });
169
186
  this.credentials = options.credentials ?? null;
187
+ this.audit = options.audit ?? null;
170
188
  const store = this.credentials;
171
189
  this.links = new BridgeLinks(
172
190
  this.resolveNode,
@@ -354,13 +372,76 @@ export class MeshService {
354
372
  /**
355
373
  * Push one command to a device and await its result (or time out). The
356
374
  * low-level primitive under every command tool; exposed for tests and
357
- * future tools.
375
+ * future tools. Every dispatch lands in the command audit.
358
376
  */
359
377
  sendCommand(
360
378
  device: DeviceInfo,
361
379
  name: string,
362
380
  params: Record<string, unknown> = {},
363
381
  timeoutMs = this.commandTimeoutMs,
382
+ ): Promise<DeviceCommandResult> {
383
+ const finishAudit = this.beginAudit(device, name, params);
384
+ const pending = this.deliverCommand(device, name, params, timeoutMs);
385
+ if (!finishAudit) return pending;
386
+ // The delivery promise only ever resolves, so this adds no rejection.
387
+ return pending.then((result) => {
388
+ finishAudit(result);
389
+ return result;
390
+ });
391
+ }
392
+
393
+ /**
394
+ * Open the audit record for one dispatch. The issuer is read now, while
395
+ * the issuing turn's scope is live. Returns the completion hook, or null
396
+ * when nothing is audited. Never throws: a broken audit must not stop a
397
+ * command.
398
+ */
399
+ private beginAudit(
400
+ device: DeviceInfo,
401
+ name: string,
402
+ params: Record<string, unknown>,
403
+ ): ((result: DeviceCommandResult) => void) | null {
404
+ const audit = this.audit;
405
+ if (!audit) return null;
406
+ try {
407
+ const startedAt = Date.now();
408
+ const base = {
409
+ time: new Date(startedAt).toISOString(),
410
+ issuer: auditIssuer(),
411
+ deviceId: device.id,
412
+ deviceName: device.name,
413
+ command: name,
414
+ argsHash: hashCommandArgs(params),
415
+ };
416
+ return (result) => {
417
+ try {
418
+ audit.record({
419
+ ...base,
420
+ ok: result.ok,
421
+ ...(result.ok ? {} : { error: auditErrorText(result.message) }),
422
+ durationMs: Date.now() - startedAt,
423
+ });
424
+ } catch (err) {
425
+ logWarn("mesh", `mesh.audit event=record_failed err=${String(err)}`);
426
+ }
427
+ };
428
+ } catch (err) {
429
+ logWarn("mesh", `mesh.audit event=record_failed err=${String(err)}`);
430
+ return null;
431
+ }
432
+ }
433
+
434
+ /** `talon mesh audit`: the newest recorded dispatches, oldest first. */
435
+ readAudit(query: MeshAuditQuery = {}): Promise<MeshAuditEntry[]> {
436
+ return this.audit ? this.audit.read(query) : Promise.resolve([]);
437
+ }
438
+
439
+ /** Deliver one command; resolves with its result, a timeout, or a loss. */
440
+ private deliverCommand(
441
+ device: DeviceInfo,
442
+ name: string,
443
+ params: Record<string, unknown>,
444
+ timeoutMs: number,
364
445
  ): Promise<DeviceCommandResult> {
365
446
  const command: DeviceCommand = {
366
447
  id: randomUUID(),
@@ -732,8 +813,14 @@ export class MeshService {
732
813
  query: unknown,
733
814
  localApkPath: unknown,
734
815
  remotePath?: unknown,
816
+ allowDowngrade?: unknown,
735
817
  ): Promise<MeshToolResult> {
736
- return this.files.updateDeviceApp(query, localApkPath, remotePath);
818
+ return this.files.updateDeviceApp(
819
+ query,
820
+ localApkPath,
821
+ remotePath,
822
+ allowDowngrade,
823
+ );
737
824
  }
738
825
 
739
826
  /** `update_node`: remote self-update for a headless talon-node. */
@@ -741,8 +828,14 @@ export class MeshService {
741
828
  query: unknown,
742
829
  localBinaryPath?: unknown,
743
830
  remotePath?: unknown,
831
+ allowDowngrade?: unknown,
744
832
  ): Promise<MeshToolResult> {
745
- return this.files.updateNodeBinary(query, localBinaryPath, remotePath);
833
+ return this.files.updateNodeBinary(
834
+ query,
835
+ localBinaryPath,
836
+ remotePath,
837
+ allowDowngrade,
838
+ );
746
839
  }
747
840
 
748
841
  // ── Provisioning + pairing (see links/bridge-links.ts) ────────────────────
@@ -1170,6 +1263,7 @@ let instance: MeshService | null = null;
1170
1263
  export function getMeshService(): MeshService {
1171
1264
  instance ??= new MeshService(undefined, {
1172
1265
  credentials: new DeviceCredentialStore(),
1266
+ audit: new MeshAuditLog(),
1173
1267
  });
1174
1268
  return instance;
1175
1269
  }
@@ -23,7 +23,11 @@ import {
23
23
  normalizeGoos,
24
24
  type NodeBinaryResolver,
25
25
  } from "./node-binaries.js";
26
- import { installOneLiner, NodeProvisionStore } from "./node-provision.js";
26
+ import {
27
+ checkBridgeUrl,
28
+ installOneLiner,
29
+ NodeProvisionStore,
30
+ } from "./node-provision.js";
27
31
  import {
28
32
  DEFAULT_COMPANION_SCOPES,
29
33
  NODE_SCOPES,
@@ -56,6 +60,8 @@ export type MeshBridgeInfo = {
56
60
  token?: string;
57
61
  /** TLS certificate SHA-256 (absent over plain HTTP). */
58
62
  fingerprint?: string;
63
+ /** Base64 SHA-256 of the TLS key's SPKI, curl's pin (absent over HTTP). */
64
+ spkiPin?: string;
59
65
  /**
60
66
  * The operator's `native.publicUrl`: what devices dial when the bind
61
67
  * address isn't reachable as-is (containers, NAT, proxies).
@@ -172,6 +178,7 @@ export class BridgeLinks {
172
178
  bridgeUrl: base,
173
179
  bearerToken: this.linkCredential(info.token, NODE_SCOPES, "install"),
174
180
  ...(info.fingerprint ? { fingerprint: info.fingerprint } : {}),
181
+ ...(info.spkiPin ? { spkiPin: info.spkiPin } : {}),
175
182
  });
176
183
  return {
177
184
  ok: true,
@@ -318,17 +325,17 @@ export class BridgeLinks {
318
325
  info: MeshBridgeInfo,
319
326
  explicit?: unknown,
320
327
  ): string | { error: string } {
328
+ // Both URLs end up inside generated installer scripts, so they are held
329
+ // to a quote-safe alphabet (checkBridgeUrl), not just "looks like a URL".
321
330
  if (typeof explicit === "string" && explicit.trim()) {
322
- const url = explicit.trim().replace(/\/+$/, "");
323
- if (!/^https?:\/\/\S+$/.test(url)) {
324
- return { error: `bridge_url must be an http(s) URL, got "${url}".` };
325
- }
326
- return url;
331
+ return checkBridgeUrl(explicit);
327
332
  }
328
333
  // Inside a container the "first external IPv4" is the container's own
329
334
  // bridge-network address, which no phone can reach — the operator's
330
335
  // public URL is the only right answer there.
331
- if (info.publicUrl) return info.publicUrl.trim().replace(/\/+$/, "");
336
+ if (info.publicUrl) {
337
+ return checkBridgeUrl(info.publicUrl, "native.publicUrl");
338
+ }
332
339
  let host = info.host;
333
340
  if (host === "0.0.0.0" || host === "::") {
334
341
  const external = firstExternalIPv4();
@@ -14,9 +14,17 @@
14
14
  * like streamed-transfer tokens (transfers/transfers.ts): random 192-bit, single-use
15
15
  * per leg, expiring unused. The script carries the bridge bearer token and
16
16
  * pinned TLS fingerprint into the node's config, verifies the downloaded
17
- * binary against the grant's digest (integrity is the script's own sha256
18
- * check, not TLS), installs it, and registers the boot service via
19
- * `talon-node install`.
17
+ * binary against the grant's digest, installs it, and registers the boot
18
+ * service via `talon-node install`.
19
+ *
20
+ * Over HTTPS both fetches (script and binary) are pinned to the bridge's own
21
+ * key — curl `--pinnedpubkey`, a certificate SHA-256 check on Windows — so a
22
+ * man in the middle can neither swap the script nor read the grant's bearer
23
+ * credential off the wire. Plain HTTP has nothing to pin.
24
+ *
25
+ * Every value that lands in generated sh/PowerShell text is validated to a
26
+ * quote-safe alphabet at the edge ({@link checkBridgeUrl}, device names) and
27
+ * escaped for its quoting context here anyway.
20
28
  */
21
29
 
22
30
  import { randomBytes } from "node:crypto";
@@ -41,6 +49,8 @@ export type NodeProvisionGrant = {
41
49
  bearerToken: string;
42
50
  /** Bridge TLS certificate fingerprint to pre-pin (absent over plain HTTP). */
43
51
  fingerprint?: string;
52
+ /** Base64 SHA-256 of the bridge key's SPKI — curl's pin (absent over HTTP). */
53
+ spkiPin?: string;
44
54
  createdAt: number;
45
55
  scriptUsed: boolean;
46
56
  binaryUsed: boolean;
@@ -114,46 +124,162 @@ export class NodeProvisionStore {
114
124
  }
115
125
  }
116
126
 
127
+ /**
128
+ * Anything that could end, expand, or escape inside a quoted sh,
129
+ * PowerShell, or cmd.exe string: both quote kinds, `$`, backtick,
130
+ * backslash, `%` (cmd.exe expands `%VAR%` even inside quotes), and
131
+ * everything outside printable ASCII — whitespace, control characters, and
132
+ * the Unicode "smart" quotes PowerShell also treats as quotes.
133
+ */
134
+ const UNSAFE_URL_CHAR = /["'`$\\%]|[^\x21-\x7e]/;
135
+
136
+ /**
137
+ * Validate a bridge base URL before it is baked into installer scripts: an
138
+ * http(s) URL with a host, made only of characters that are inert in every
139
+ * quoting context the installers use. Returns the URL without trailing
140
+ * slashes, or why it was refused.
141
+ */
142
+ export function checkBridgeUrl(
143
+ raw: string,
144
+ label = "bridge_url",
145
+ ): string | { error: string } {
146
+ const url = raw.trim().replace(/\/+$/, "");
147
+ const refused = {
148
+ error: `${label} must be a plain http(s) URL (no quotes, $, backticks, backslashes, % or whitespace), got ${JSON.stringify(url)}.`,
149
+ };
150
+ if (UNSAFE_URL_CHAR.test(url)) return refused;
151
+ let parsed: URL;
152
+ try {
153
+ parsed = new URL(url);
154
+ } catch {
155
+ return refused;
156
+ }
157
+ const web = parsed.protocol === "http:" || parsed.protocol === "https:";
158
+ return web && parsed.hostname ? url : refused;
159
+ }
160
+
161
+ // ── Quoting ──────────────────────────────────────────────────────────────────
162
+
163
+ /** Escape for a double-quoted POSIX sh string. */
164
+ function sh(value: string): string {
165
+ return value.replace(/["$`\\]/g, "\\$&");
166
+ }
167
+
168
+ /** Escape for a double-quoted PowerShell string (backtick is the escape). */
169
+ function ps(value: string): string {
170
+ return value.replace(/["$`“”„]/g, "`$&");
171
+ }
172
+
173
+ /** Escape for a single-quoted PowerShell string (quotes double up). */
174
+ function psq(value: string): string {
175
+ return value.replace(/['‘’‚‛]/g, "$&$&");
176
+ }
177
+
178
+ /** Keep a value on one line — for `#` comment lines. */
179
+ function oneLine(value: string): string {
180
+ return value.replace(/[\r\n]+/g, " ");
181
+ }
182
+
183
+ // ── Pinning ──────────────────────────────────────────────────────────────────
184
+
185
+ /**
186
+ * Pinned fetches for PowerShell, compiled with Add-Type so the certificate
187
+ * callback is a real .NET delegate: a script-block callback only works on
188
+ * the pipeline thread (Windows PowerShell 5.1's synchronous iwr), while
189
+ * pwsh 7's HttpClient calls it from the thread pool, where no runspace
190
+ * exists. HttpWebRequest's per-request callback exists on .NET Framework
191
+ * 4.5+ and every .NET Core, so one source serves both shells; -IgnoreWarnings
192
+ * because pwsh 7 would fail on WebRequest's obsolete warning.
193
+ *
194
+ * The check: the presented certificate's SHA-256 (BitConverter's `AB-CD-…`
195
+ * form) must equal `[TalonPin]::Pin` — the same certificate hash talon-node
196
+ * pins afterwards. An unset pin fails closed. The source holds no quotes,
197
+ * `$` or `%`, so it survives a PowerShell single-quoted string inside a
198
+ * cmd.exe (or PowerShell) double-quoted `-Command` argument.
199
+ */
200
+ const PIN_TYPE_SOURCE =
201
+ "using System;using System.IO;using System.Net;using System.Security.Cryptography;" +
202
+ "public static class TalonPin{public static string Pin;" +
203
+ "static WebResponse Open(string u){var r=(HttpWebRequest)WebRequest.Create(u);" +
204
+ "r.ServerCertificateValidationCallback=(s,c,h,e)=>c!=null&&string.Equals(" +
205
+ "BitConverter.ToString(SHA256.Create().ComputeHash(c.GetRawCertData())),Pin,StringComparison.OrdinalIgnoreCase);" +
206
+ "return r.GetResponse();}" +
207
+ "public static string Get(string u){using(var w=Open(u))using(var t=new StreamReader(w.GetResponseStream()))return t.ReadToEnd();}" +
208
+ "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);}}";
209
+
210
+ /** `abcd…` → `AB-CD-…`, the form BitConverter.ToString gives a hash. */
211
+ function dashedFingerprint(fingerprint: string): string {
212
+ return (fingerprint.match(/.{2}/g) ?? []).join("-").toUpperCase();
213
+ }
214
+
215
+ /** PowerShell statements that load TalonPin and arm it with the pin. */
216
+ function psPinSetup(fingerprint: string): string {
217
+ return (
218
+ `if (-not ('TalonPin' -as [type])) { Add-Type -IgnoreWarnings -TypeDefinition '${psq(PIN_TYPE_SOURCE)}' }; ` +
219
+ `[TalonPin]::Pin = '${psq(dashedFingerprint(fingerprint))}'`
220
+ );
221
+ }
222
+
223
+ /**
224
+ * curl flags for a bridge fetch. With a pin, `-k` stays on purpose: the
225
+ * bridge cert is self-signed and curl still chain-verifies without `-k`,
226
+ * even when the pin matches — while `--pinnedpubkey` is enforced with or
227
+ * without `-k`. So the pin, not the chain, is the real check. Without one
228
+ * (plain HTTP, nothing to pin) the flags are unchanged.
229
+ */
230
+ function curlFlags(spkiPin: string | undefined): string {
231
+ return spkiPin ? `-fsSk --pinnedpubkey "sha256//${sh(spkiPin)}"` : "-fsSk";
232
+ }
233
+
234
+ // ── Generated commands ───────────────────────────────────────────────────────
235
+
117
236
  /** The command a human (or the model over SSH) runs on the target host. */
118
237
  export function installOneLiner(grant: NodeProvisionGrant): string {
119
238
  const url = `${grant.bridgeUrl}/node/install?provision=${grant.token}`;
120
- if (grant.goos === "windows") {
121
- // -k has no iwr flag; trust-all callback covers the self-signed bridge
122
- // cert for the two fetches — the binary itself is digest-verified. The
123
- // one-liner is meant for cmd.exe / PowerShell, where $true survives the
124
- // outer double quotes verbatim.
239
+ if (grant.goos !== "windows") {
240
+ return `curl ${curlFlags(grant.spkiPin)} "${sh(url)}" | sh`;
241
+ }
242
+ if (grant.fingerprint) {
243
+ // No `$` anywhere, so the line means the same pasted into cmd.exe or a
244
+ // PowerShell prompt (whose double quotes would expand variables).
125
245
  return (
126
246
  `powershell -ExecutionPolicy Bypass -Command "` +
127
- `[Net.ServicePointManager]::ServerCertificateValidationCallback={$true}; ` +
128
- `iwr -UseBasicParsing '${url}' | Select-Object -ExpandProperty Content | iex"`
247
+ `${psPinSetup(grant.fingerprint)}; ` +
248
+ `iex ([TalonPin]::Get('${psq(url)}'))"`
129
249
  );
130
250
  }
131
- return `curl -fsSk "${url}" | sh`;
251
+ // Plain HTTP: nothing to pin, and the trust-all callback is inert. The
252
+ // line is meant for cmd.exe, where $true survives the outer double quotes.
253
+ return (
254
+ `powershell -ExecutionPolicy Bypass -Command "` +
255
+ `[Net.ServicePointManager]::ServerCertificateValidationCallback={$true}; ` +
256
+ `iwr -UseBasicParsing '${psq(url)}' | Select-Object -ExpandProperty Content | iex"`
257
+ );
132
258
  }
133
259
 
134
260
  /**
135
- * POSIX installer: fetch the binary over the same grant, verify its digest,
136
- * install it next to the node's config, and register the boot service. -k on
137
- * the fetches is deliberate — the bridge cert is self-signed; integrity
138
- * comes from the embedded sha256 and the fingerprint is pre-pinned into the
139
- * node's config for every connection after install.
261
+ * POSIX installer: fetch the binary over the same grant (pinned like the
262
+ * script fetch), verify its digest, install it next to the node's config,
263
+ * and register the boot service. The certificate fingerprint is pre-pinned
264
+ * into the node's config for every connection after install.
140
265
  */
141
266
  function shellInstaller(grant: NodeProvisionGrant): string {
142
- const nameFlag = grant.name ? ` --name "${grant.name}"` : "";
267
+ const nameFlag = grant.name ? ` --name "${sh(grant.name)}"` : "";
143
268
  const fpFlag = grant.fingerprint
144
- ? ` --fingerprint "${grant.fingerprint}"`
269
+ ? ` --fingerprint "${sh(grant.fingerprint)}"`
145
270
  : "";
271
+ const target = `${grant.version} (${grant.goos}/${grant.goarch})`;
146
272
  return `#!/bin/sh
147
- # talon-node installer — generated by Talon ${grant.version} for ${grant.goos}/${grant.goarch}
273
+ # talon-node installer — generated by Talon ${oneLine(`${grant.version} for ${grant.goos}/${grant.goarch}`)}
148
274
  set -eu
149
- BRIDGE="${grant.bridgeUrl}"
150
- SHA="${grant.sha256}"
275
+ BRIDGE="${sh(grant.bridgeUrl)}"
276
+ SHA="${sh(grant.sha256)}"
151
277
  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
278
  mkdir -p "$DEST_DIR"
153
279
  TMP="$(mktemp)"
154
280
  trap 'rm -f "$TMP"' EXIT
155
- echo "Downloading talon-node ${grant.version} (${grant.goos}/${grant.goarch})..."
156
- curl -fsSk "$BRIDGE/node/binary?provision=${grant.token}" -o "$TMP"
281
+ echo "Downloading talon-node ${sh(target)}..."
282
+ curl ${curlFlags(grant.spkiPin)} "$BRIDGE/node/binary?provision=${sh(grant.token)}" -o "$TMP"
157
283
  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
284
  if [ "$GOT" != "$SHA" ]; then echo "talon-node download failed its checksum — refusing to install" >&2; exit 1; fi
159
285
  BIN="$DEST_DIR/talon-node"
@@ -161,37 +287,46 @@ mv "$TMP" "$BIN"
161
287
  chmod 0755 "$BIN"
162
288
  trap - EXIT
163
289
  echo "Installed $BIN"
164
- "$BIN" install --bridge "$BRIDGE" --token "${grant.bearerToken}"${fpFlag}${nameFlag}
290
+ "$BIN" install --bridge "$BRIDGE" --token "${sh(grant.bearerToken)}"${fpFlag}${nameFlag}
165
291
  echo "Done — this host is now on the mesh. Check with: $BIN status"
166
292
  `;
167
293
  }
168
294
 
295
+ /** The PowerShell lines that download the binary to `$bin`. */
296
+ function psBinaryFetch(grant: NodeProvisionGrant): string {
297
+ const url = `"$bridge/node/binary?provision=${ps(grant.token)}"`;
298
+ return grant.fingerprint
299
+ ? `${psPinSetup(grant.fingerprint)}\n[TalonPin]::Save(${url}, $bin)`
300
+ : `[Net.ServicePointManager]::ServerCertificateValidationCallback = { $true }\n` +
301
+ `Invoke-WebRequest -UseBasicParsing ${url} -OutFile $bin`;
302
+ }
303
+
169
304
  /**
170
- * PowerShell installer (Windows PowerShell 5+ compatible). The binary lands in
171
- * the installing user's %LOCALAPPDATA% and `talon-node install` registers a
172
- * boot task that runs as that same user (never SYSTEM — a SYSTEM task would
305
+ * PowerShell installer (Windows PowerShell 5.1 and pwsh 7). The binary lands
306
+ * in the installing user's %LOCALAPPDATA% and `talon-node install` registers
307
+ * a boot task that runs as that same user (never SYSTEM — a SYSTEM task would
173
308
  * run a binary the user can rewrite). Registering a boot task needs an
174
309
  * elevated PowerShell.
175
310
  */
176
311
  function powershellInstaller(grant: NodeProvisionGrant): string {
177
- const nameArg = grant.name ? `, "--name", "${grant.name}"` : "";
312
+ const nameArg = grant.name ? `, "--name", "${ps(grant.name)}"` : "";
178
313
  const fpArg = grant.fingerprint
179
- ? `, "--fingerprint", "${grant.fingerprint}"`
314
+ ? `, "--fingerprint", "${ps(grant.fingerprint)}"`
180
315
  : "";
181
- return `# talon-node installer — generated by Talon ${grant.version} for ${grant.goos}/${grant.goarch}
316
+ const target = `${grant.version} (${grant.goos}/${grant.goarch})`;
317
+ return `# talon-node installer — generated by Talon ${oneLine(`${grant.version} for ${grant.goos}/${grant.goarch}`)}
182
318
  $ErrorActionPreference = "Stop"
183
- [Net.ServicePointManager]::ServerCertificateValidationCallback = { $true }
184
- $bridge = "${grant.bridgeUrl}"
319
+ $bridge = "${ps(grant.bridgeUrl)}"
185
320
  $dest = Join-Path $env:LOCALAPPDATA "talon-node"
186
321
  New-Item -ItemType Directory -Force -Path $dest | Out-Null
187
322
  $bin = Join-Path $dest "talon-node.exe"
188
- Write-Host "Downloading talon-node ${grant.version} (${grant.goos}/${grant.goarch})..."
189
- Invoke-WebRequest -UseBasicParsing "$bridge/node/binary?provision=${grant.token}" -OutFile $bin
323
+ Write-Host "Downloading talon-node ${ps(target)}..."
324
+ ${psBinaryFetch(grant)}
190
325
  $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" }
326
+ if ($got -ne "${ps(grant.sha256)}") { Remove-Item $bin; throw "talon-node download failed its checksum - refusing to install" }
192
327
  Write-Host "Installed $bin"
193
328
  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}
329
+ & $bin install --bridge $bridge --token "${ps(grant.bearerToken)}"${fpArg}${nameArg}
195
330
  Write-Host "Done - this host is now on the mesh. Check with: $bin status"
196
331
  `;
197
332
  }