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.
- package/README.md +2 -1
- package/package.json +1 -1
- 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/engine/gateway.ts +7 -1
- 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 +97 -3
- package/src/core/mesh/links/bridge-links.ts +14 -7
- package/src/core/mesh/links/node-provision.ts +171 -36
- 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 +12 -0
- package/src/core/weaver/weaver.ts +4 -1
- package/src/frontend/native/bridge/credentials/upgrade.ts +16 -3
- 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/util/logging/turn-scope.ts +23 -2
|
@@ -253,7 +253,11 @@ export class DeviceCredentialStore {
|
|
|
253
253
|
});
|
|
254
254
|
}
|
|
255
255
|
const records = this.activeRecordsFor(deviceId);
|
|
256
|
-
|
|
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 (
|
|
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
|
-
/**
|
|
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(
|
|
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(
|
|
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 {
|
|
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
|
-
|
|
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)
|
|
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
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
//
|
|
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
|
-
|
|
128
|
-
`
|
|
247
|
+
`${psPinSetup(grant.fingerprint)}; ` +
|
|
248
|
+
`iex ([TalonPin]::Get('${psq(url)}'))"`
|
|
129
249
|
);
|
|
130
250
|
}
|
|
131
|
-
|
|
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
|
|
136
|
-
* install it next to the node's config,
|
|
137
|
-
*
|
|
138
|
-
*
|
|
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 ${
|
|
156
|
-
curl
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 ${
|
|
189
|
-
|
|
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
|
}
|