talon-agent 3.3.0 → 3.4.1
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/package.json +1 -1
- package/src/core/engine/gateway-actions/mesh.ts +13 -1
- package/src/core/mesh/node-binaries.ts +393 -0
- package/src/core/mesh/node-provision.ts +190 -0
- package/src/core/mesh/registry.ts +2 -0
- package/src/core/mesh/service.ts +230 -11
- package/src/core/mesh/types.ts +8 -0
- package/src/core/tools/mesh.ts +41 -2
- package/src/frontend/native/index.ts +12 -0
- package/src/frontend/native/server.ts +43 -0
- package/src/util/version.ts +16 -0
package/package.json
CHANGED
|
@@ -58,11 +58,23 @@ export const meshHandlers: SharedActionHandlers = {
|
|
|
58
58
|
body.remote_path,
|
|
59
59
|
),
|
|
60
60
|
// Remote self-update for a headless talon-node: push a new binary and have
|
|
61
|
-
// the node verify, swap, and restart into it.
|
|
61
|
+
// the node verify, swap, and restart into it. binary_path is optional —
|
|
62
|
+
// omitted, the daemon resolves the right build for the node's
|
|
63
|
+
// platform/arch itself (source build or verified release download).
|
|
62
64
|
update_node: (body) =>
|
|
63
65
|
getMeshService().updateNodeBinary(
|
|
64
66
|
body.device,
|
|
65
67
|
body.binary_path,
|
|
66
68
|
body.remote_path,
|
|
67
69
|
),
|
|
70
|
+
// Node provisioning: materialize a talon-node binary for any arch, and
|
|
71
|
+
// mint single-use bridge-served install links for fresh hosts.
|
|
72
|
+
get_node_binary: (body) => getMeshService().getNodeBinary(body.os, body.arch),
|
|
73
|
+
make_node_install_link: (body) =>
|
|
74
|
+
getMeshService().makeNodeInstallLink(
|
|
75
|
+
body.os,
|
|
76
|
+
body.arch,
|
|
77
|
+
body.name,
|
|
78
|
+
body.bridge_url,
|
|
79
|
+
),
|
|
68
80
|
};
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node-binary resolver — materializes a talon-node binary for any supported
|
|
3
|
+
* platform/arch, whatever shape this daemon was installed in.
|
|
4
|
+
*
|
|
5
|
+
* The old flow required a source checkout plus a Go toolchain (`update_node`
|
|
6
|
+
* only took a path the caller had built). This resolver keeps that path and
|
|
7
|
+
* adds two more, tried in order:
|
|
8
|
+
*
|
|
9
|
+
* 1. source build — running from a git checkout with Go on PATH: cross-
|
|
10
|
+
* compile apps/node for the requested target (always rebuilt, so a dev
|
|
11
|
+
* daemon ships its working-tree code, stamped <version>+<sha>).
|
|
12
|
+
* 2. cache — ~/.talon/node-bin/<talon-version>/talon-node-<os>-<arch>,
|
|
13
|
+
* re-verified against its recorded digest on every hit.
|
|
14
|
+
* 3. release download — the GitHub release matching THIS daemon's version
|
|
15
|
+
* publishes the full binary matrix plus a talon-node-SHA256SUMS
|
|
16
|
+
* manifest (built by .github/workflows/node.yml); fetch, verify the
|
|
17
|
+
* digest, and cache. This is what makes prebuilt installs (npm, deb,
|
|
18
|
+
* standalone binary) able to provision nodes at all.
|
|
19
|
+
*
|
|
20
|
+
* Version lock is structural: cache keys and release URLs both derive from
|
|
21
|
+
* `talonVersion()`, so a resolved binary always matches the daemon exactly.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { execFile } from "node:child_process";
|
|
25
|
+
import { createHash } from "node:crypto";
|
|
26
|
+
import {
|
|
27
|
+
chmod,
|
|
28
|
+
mkdir,
|
|
29
|
+
readdir,
|
|
30
|
+
readFile,
|
|
31
|
+
rename,
|
|
32
|
+
rm,
|
|
33
|
+
stat,
|
|
34
|
+
writeFile,
|
|
35
|
+
} from "node:fs/promises";
|
|
36
|
+
import { join, resolve } from "node:path";
|
|
37
|
+
import { TalonError } from "../errors.js";
|
|
38
|
+
import { getRepoRoot } from "../update/self-update.js";
|
|
39
|
+
import { dirs } from "../../util/paths.js";
|
|
40
|
+
import { talonVersion } from "../../util/version.js";
|
|
41
|
+
import type { DevicePlatform } from "./types.js";
|
|
42
|
+
|
|
43
|
+
/** A cross-compile target, named exactly as Go (and the release assets) do. */
|
|
44
|
+
export type NodeTarget = {
|
|
45
|
+
goos: "linux" | "darwin" | "windows";
|
|
46
|
+
goarch: string;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/** The supported matrix — mirrors apps/node/tools/build/main.go. */
|
|
50
|
+
export const NODE_TARGETS: readonly NodeTarget[] = [
|
|
51
|
+
{ goos: "linux", goarch: "amd64" },
|
|
52
|
+
{ goos: "linux", goarch: "arm64" },
|
|
53
|
+
{ goos: "linux", goarch: "arm" },
|
|
54
|
+
{ goos: "darwin", goarch: "amd64" },
|
|
55
|
+
{ goos: "darwin", goarch: "arm64" },
|
|
56
|
+
{ goos: "windows", goarch: "amd64" },
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
const SUMS_ASSET = "talon-node-SHA256SUMS";
|
|
60
|
+
const RELEASE_BASE = "https://github.com/dylanneve1/talon/releases/download";
|
|
61
|
+
const DOWNLOAD_TIMEOUT_MS = 120_000;
|
|
62
|
+
const BUILD_TIMEOUT_MS = 300_000;
|
|
63
|
+
|
|
64
|
+
export type ResolvedNodeBinary = {
|
|
65
|
+
path: string;
|
|
66
|
+
/** Full stamp the binary reports (release: the semver; build: semver+sha). */
|
|
67
|
+
version: string;
|
|
68
|
+
sha256: string;
|
|
69
|
+
size: number;
|
|
70
|
+
/** Which tier produced it. */
|
|
71
|
+
source: "source-build" | "cache" | "release";
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export type ResolveNodeBinaryOptions = {
|
|
75
|
+
/** Cache root (default ~/.talon/node-bin). */
|
|
76
|
+
cacheRoot?: string;
|
|
77
|
+
/** Source checkout root; null disables the build tier (default: detect). */
|
|
78
|
+
repoRoot?: string | null;
|
|
79
|
+
/** Release download (test seam). */
|
|
80
|
+
fetchImpl?: typeof fetch;
|
|
81
|
+
/** Daemon version override (test seam). */
|
|
82
|
+
version?: string;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/** The resolver's function shape — the seam MeshService accepts in tests. */
|
|
86
|
+
export type NodeBinaryResolver = (
|
|
87
|
+
goos: string,
|
|
88
|
+
goarch: string,
|
|
89
|
+
opts?: ResolveNodeBinaryOptions,
|
|
90
|
+
) => Promise<ResolvedNodeBinary>;
|
|
91
|
+
|
|
92
|
+
/** Release asset / on-disk name for one target. */
|
|
93
|
+
export function nodeAssetName(goos: string, goarch: string): string {
|
|
94
|
+
return `talon-node-${goos}-${goarch}${goos === "windows" ? ".exe" : ""}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Normalize a caller-supplied OS ("macos", "darwin", "Linux") to a matrix
|
|
99
|
+
* GOOS, or undefined for anything outside it.
|
|
100
|
+
*/
|
|
101
|
+
export function normalizeGoos(value: unknown): NodeTarget["goos"] | undefined {
|
|
102
|
+
const v = typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
103
|
+
if (v === "macos" || v === "darwin" || v === "osx") return "darwin";
|
|
104
|
+
if (v === "linux") return "linux";
|
|
105
|
+
if (v === "windows" || v === "win32") return "windows";
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Normalize a caller-supplied arch ("x86_64", "aarch64") to a Go arch. */
|
|
110
|
+
export function normalizeGoarch(value: unknown): string | undefined {
|
|
111
|
+
const v = typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
112
|
+
if (v === "amd64" || v === "x86_64" || v === "x64") return "amd64";
|
|
113
|
+
if (v === "arm64" || v === "aarch64") return "arm64";
|
|
114
|
+
if (v === "arm" || v === "armv7" || v === "armv7l") return "arm";
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Map a mesh platform onto a node GOOS (mobile platforms have no node). */
|
|
119
|
+
export function platformToGoos(
|
|
120
|
+
platform: DevicePlatform,
|
|
121
|
+
): NodeTarget["goos"] | undefined {
|
|
122
|
+
return normalizeGoos(platform === "macos" ? "darwin" : platform);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function isSupported(goos: string, goarch: string): boolean {
|
|
126
|
+
return NODE_TARGETS.some((t) => t.goos === goos && t.goarch === goarch);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Resolve a node binary for `goos`/`goarch`. Throws with an operator-facing
|
|
131
|
+
* message when every tier fails — callers surface it verbatim.
|
|
132
|
+
*/
|
|
133
|
+
export async function resolveNodeBinary(
|
|
134
|
+
goos: string,
|
|
135
|
+
goarch: string,
|
|
136
|
+
opts: ResolveNodeBinaryOptions = {},
|
|
137
|
+
): Promise<ResolvedNodeBinary> {
|
|
138
|
+
if (!isSupported(goos, goarch)) {
|
|
139
|
+
throw new TalonError(
|
|
140
|
+
`No talon-node target for ${goos}/${goarch}. Supported: ${NODE_TARGETS.map(
|
|
141
|
+
(t) => `${t.goos}/${t.goarch}`,
|
|
142
|
+
).join(", ")}.`,
|
|
143
|
+
{ reason: "bad_request" },
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
const version = opts.version ?? talonVersion();
|
|
147
|
+
const cacheRoot = opts.cacheRoot ?? resolve(dirs.root, "node-bin");
|
|
148
|
+
const repoRoot = opts.repoRoot === undefined ? getRepoRoot() : opts.repoRoot;
|
|
149
|
+
|
|
150
|
+
const errors: string[] = [];
|
|
151
|
+
|
|
152
|
+
if (repoRoot) {
|
|
153
|
+
try {
|
|
154
|
+
return await buildFromSource(repoRoot, goos, goarch, version, cacheRoot);
|
|
155
|
+
} catch (err) {
|
|
156
|
+
errors.push(`source build: ${(err as Error).message}`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const cached = await fromCache(cacheRoot, version, goos, goarch);
|
|
161
|
+
if (cached) return cached;
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
return await downloadFromRelease(
|
|
165
|
+
version,
|
|
166
|
+
goos,
|
|
167
|
+
goarch,
|
|
168
|
+
cacheRoot,
|
|
169
|
+
opts.fetchImpl ?? fetch,
|
|
170
|
+
);
|
|
171
|
+
} catch (err) {
|
|
172
|
+
errors.push(`release download: ${(err as Error).message}`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
throw new TalonError(
|
|
176
|
+
`Could not obtain talon-node ${goos}/${goarch} for Talon ${version} — ${errors.join("; ")}`,
|
|
177
|
+
{ reason: "unknown" },
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ── Tier 1: source build ────────────────────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
async function buildFromSource(
|
|
184
|
+
repoRoot: string,
|
|
185
|
+
goos: string,
|
|
186
|
+
goarch: string,
|
|
187
|
+
version: string,
|
|
188
|
+
cacheRoot: string,
|
|
189
|
+
): Promise<ResolvedNodeBinary> {
|
|
190
|
+
const nodeDir = join(repoRoot, "apps", "node");
|
|
191
|
+
await stat(join(nodeDir, "go.mod"));
|
|
192
|
+
// Dev builds land under their own key: they track the working tree, not a
|
|
193
|
+
// release, and are rebuilt on every resolve so they can never go stale.
|
|
194
|
+
const outDir = join(cacheRoot, "dev");
|
|
195
|
+
await mkdir(outDir, { recursive: true });
|
|
196
|
+
const dest = join(outDir, nodeAssetName(goos, goarch));
|
|
197
|
+
const stamp = `${version}+${await gitShortSha(repoRoot)}`;
|
|
198
|
+
await run(
|
|
199
|
+
"go",
|
|
200
|
+
[
|
|
201
|
+
"build",
|
|
202
|
+
"-trimpath",
|
|
203
|
+
"-ldflags",
|
|
204
|
+
`-s -w -X main.ldflagsVersion=${stamp}`,
|
|
205
|
+
"-o",
|
|
206
|
+
dest,
|
|
207
|
+
".",
|
|
208
|
+
],
|
|
209
|
+
nodeDir,
|
|
210
|
+
{ CGO_ENABLED: "0", GOOS: goos, GOARCH: goarch },
|
|
211
|
+
BUILD_TIMEOUT_MS,
|
|
212
|
+
);
|
|
213
|
+
const { sha256, size } = await hashFile(dest);
|
|
214
|
+
return { path: dest, version: stamp, sha256, size, source: "source-build" };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function gitShortSha(repoRoot: string): Promise<string> {
|
|
218
|
+
try {
|
|
219
|
+
return (
|
|
220
|
+
await run("git", ["rev-parse", "--short", "HEAD"], repoRoot, {}, 10_000)
|
|
221
|
+
).trim();
|
|
222
|
+
} catch {
|
|
223
|
+
return "dev";
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function run(
|
|
228
|
+
cmd: string,
|
|
229
|
+
args: string[],
|
|
230
|
+
cwd: string,
|
|
231
|
+
env: Record<string, string>,
|
|
232
|
+
timeoutMs: number,
|
|
233
|
+
): Promise<string> {
|
|
234
|
+
return new Promise((res, rej) => {
|
|
235
|
+
execFile(
|
|
236
|
+
cmd,
|
|
237
|
+
args,
|
|
238
|
+
{ cwd, env: { ...process.env, ...env }, timeout: timeoutMs },
|
|
239
|
+
(err, stdout, stderr) => {
|
|
240
|
+
if (err) {
|
|
241
|
+
rej(
|
|
242
|
+
new Error(
|
|
243
|
+
`${cmd} ${args[0]} failed: ${String(stderr || err.message)
|
|
244
|
+
.trim()
|
|
245
|
+
.slice(0, 500)}`,
|
|
246
|
+
),
|
|
247
|
+
);
|
|
248
|
+
} else {
|
|
249
|
+
res(String(stdout));
|
|
250
|
+
}
|
|
251
|
+
},
|
|
252
|
+
);
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ── Tier 2: cache ───────────────────────────────────────────────────────────
|
|
257
|
+
|
|
258
|
+
async function fromCache(
|
|
259
|
+
cacheRoot: string,
|
|
260
|
+
version: string,
|
|
261
|
+
goos: string,
|
|
262
|
+
goarch: string,
|
|
263
|
+
): Promise<ResolvedNodeBinary | null> {
|
|
264
|
+
const path = join(cacheRoot, version, nodeAssetName(goos, goarch));
|
|
265
|
+
let recorded: string;
|
|
266
|
+
try {
|
|
267
|
+
recorded = (await readFile(`${path}.sha256`, "utf8")).trim();
|
|
268
|
+
} catch {
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
try {
|
|
272
|
+
const { sha256, size } = await hashFile(path);
|
|
273
|
+
if (size > 0 && sha256 === recorded) {
|
|
274
|
+
return { path, version, sha256, size, source: "cache" };
|
|
275
|
+
}
|
|
276
|
+
} catch {
|
|
277
|
+
// Fall through — a corrupt entry is re-downloaded, not fatal.
|
|
278
|
+
}
|
|
279
|
+
await rm(path, { force: true }).catch(() => {});
|
|
280
|
+
await rm(`${path}.sha256`, { force: true }).catch(() => {});
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ── Tier 3: release download ────────────────────────────────────────────────
|
|
285
|
+
|
|
286
|
+
async function downloadFromRelease(
|
|
287
|
+
version: string,
|
|
288
|
+
goos: string,
|
|
289
|
+
goarch: string,
|
|
290
|
+
cacheRoot: string,
|
|
291
|
+
fetchImpl: typeof fetch,
|
|
292
|
+
): Promise<ResolvedNodeBinary> {
|
|
293
|
+
const asset = nodeAssetName(goos, goarch);
|
|
294
|
+
const base = `${RELEASE_BASE}/v${version}`;
|
|
295
|
+
const sums = parseSums(await fetchText(fetchImpl, `${base}/${SUMS_ASSET}`));
|
|
296
|
+
const expected = sums.get(asset);
|
|
297
|
+
if (!expected) {
|
|
298
|
+
throw new TalonError(
|
|
299
|
+
`${SUMS_ASSET} for v${version} has no entry for ${asset}`,
|
|
300
|
+
{ reason: "bad_request" },
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
const body = await fetchBytes(fetchImpl, `${base}/${asset}`);
|
|
304
|
+
const sha256 = createHash("sha256").update(body).digest("hex");
|
|
305
|
+
if (sha256 !== expected) {
|
|
306
|
+
throw new TalonError(
|
|
307
|
+
`digest mismatch for ${asset} (expected ${expected.slice(0, 12)}…, got ${sha256.slice(0, 12)}…) — refusing to cache`,
|
|
308
|
+
{ reason: "unknown" },
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
const dir = join(cacheRoot, version);
|
|
312
|
+
await mkdir(dir, { recursive: true });
|
|
313
|
+
const dest = join(dir, asset);
|
|
314
|
+
const tmp = `${dest}.tmp-${process.pid}`;
|
|
315
|
+
await writeFile(tmp, body);
|
|
316
|
+
await chmod(tmp, 0o755);
|
|
317
|
+
await rename(tmp, dest);
|
|
318
|
+
await writeFile(`${dest}.sha256`, `${sha256}\n`);
|
|
319
|
+
await pruneStaleVersions(cacheRoot, version);
|
|
320
|
+
return { path: dest, version, sha256, size: body.length, source: "release" };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Parse "<hex> <name>" manifest lines (the `sha256sum` format). */
|
|
324
|
+
function parseSums(text: string): Map<string, string> {
|
|
325
|
+
const out = new Map<string, string>();
|
|
326
|
+
for (const line of text.split("\n")) {
|
|
327
|
+
const m = /^([0-9a-f]{64})\s+\*?(\S+)\s*$/.exec(line.trim());
|
|
328
|
+
if (m) out.set(m[2]!, m[1]!);
|
|
329
|
+
}
|
|
330
|
+
return out;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function fetchText(
|
|
334
|
+
fetchImpl: typeof fetch,
|
|
335
|
+
url: string,
|
|
336
|
+
): Promise<string> {
|
|
337
|
+
const res = await fetchImpl(url, {
|
|
338
|
+
redirect: "follow",
|
|
339
|
+
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
|
|
340
|
+
});
|
|
341
|
+
if (!res.ok) {
|
|
342
|
+
throw new TalonError(`GET ${url}: HTTP ${res.status}`, {
|
|
343
|
+
reason: "network",
|
|
344
|
+
retryable: true,
|
|
345
|
+
status: res.status,
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
return res.text();
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async function fetchBytes(
|
|
352
|
+
fetchImpl: typeof fetch,
|
|
353
|
+
url: string,
|
|
354
|
+
): Promise<Buffer> {
|
|
355
|
+
const res = await fetchImpl(url, {
|
|
356
|
+
redirect: "follow",
|
|
357
|
+
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
|
|
358
|
+
});
|
|
359
|
+
if (!res.ok) {
|
|
360
|
+
throw new TalonError(`GET ${url}: HTTP ${res.status}`, {
|
|
361
|
+
reason: "network",
|
|
362
|
+
retryable: true,
|
|
363
|
+
status: res.status,
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
return Buffer.from(await res.arrayBuffer());
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Drop cached matrices for other Talon versions — they can never be used
|
|
370
|
+
* again (the resolver only serves version-exact binaries). Best-effort. */
|
|
371
|
+
async function pruneStaleVersions(
|
|
372
|
+
cacheRoot: string,
|
|
373
|
+
keep: string,
|
|
374
|
+
): Promise<void> {
|
|
375
|
+
try {
|
|
376
|
+
for (const entry of await readdir(cacheRoot)) {
|
|
377
|
+
if (entry === keep || entry === "dev") continue;
|
|
378
|
+
await rm(join(cacheRoot, entry), { recursive: true, force: true });
|
|
379
|
+
}
|
|
380
|
+
} catch {
|
|
381
|
+
// Cache hygiene only — never fail a resolve over it.
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async function hashFile(
|
|
386
|
+
path: string,
|
|
387
|
+
): Promise<{ sha256: string; size: number }> {
|
|
388
|
+
const data = await readFile(path);
|
|
389
|
+
return {
|
|
390
|
+
sha256: createHash("sha256").update(data).digest("hex"),
|
|
391
|
+
size: data.length,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NodeProvisionStore — one-time grants that turn a fresh host into a mesh
|
|
3
|
+
* node with a single command.
|
|
4
|
+
*
|
|
5
|
+
* The daemon mints a grant bound to one resolved binary (path + digest) and
|
|
6
|
+
* one target platform; the bridge serves two UNAUTHENTICATED but
|
|
7
|
+
* token-gated routes against it:
|
|
8
|
+
*
|
|
9
|
+
* GET /node/install?provision=<token> → installer script (once)
|
|
10
|
+
* GET /node/binary?provision=<token> → the binary itself (once)
|
|
11
|
+
*
|
|
12
|
+
* The routes must work pre-auth — the whole point is a host that holds no
|
|
13
|
+
* bridge credential yet — so the grant token IS the authorization, exactly
|
|
14
|
+
* like streamed-transfer tokens (transfers.ts): random 192-bit, single-use
|
|
15
|
+
* per leg, expiring unused. The script carries the bridge bearer token and
|
|
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`.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { randomBytes } from "node:crypto";
|
|
23
|
+
|
|
24
|
+
/** Unclaimed grants die after this long. */
|
|
25
|
+
const GRANT_TTL_MS = 30 * 60 * 1000;
|
|
26
|
+
|
|
27
|
+
export type NodeProvisionGrant = {
|
|
28
|
+
token: string;
|
|
29
|
+
goos: "linux" | "darwin" | "windows";
|
|
30
|
+
goarch: string;
|
|
31
|
+
/** Device name baked into the installer (optional — defaults to hostname). */
|
|
32
|
+
name?: string;
|
|
33
|
+
/** The resolved binary this grant serves. */
|
|
34
|
+
binaryPath: string;
|
|
35
|
+
sha256: string;
|
|
36
|
+
size: number;
|
|
37
|
+
version: string;
|
|
38
|
+
/** Bridge base URL as reachable from the target host. */
|
|
39
|
+
bridgeUrl: string;
|
|
40
|
+
/** Bridge bearer token the node will authenticate with. */
|
|
41
|
+
bearerToken: string;
|
|
42
|
+
/** Bridge TLS certificate fingerprint to pre-pin (absent over plain HTTP). */
|
|
43
|
+
fingerprint?: string;
|
|
44
|
+
createdAt: number;
|
|
45
|
+
scriptUsed: boolean;
|
|
46
|
+
binaryUsed: boolean;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export class NodeProvisionStore {
|
|
50
|
+
private readonly grants = new Map<string, NodeProvisionGrant>();
|
|
51
|
+
|
|
52
|
+
constructor(private readonly ttlMs = GRANT_TTL_MS) {}
|
|
53
|
+
|
|
54
|
+
create(
|
|
55
|
+
grant: Omit<
|
|
56
|
+
NodeProvisionGrant,
|
|
57
|
+
"token" | "createdAt" | "scriptUsed" | "binaryUsed"
|
|
58
|
+
>,
|
|
59
|
+
): NodeProvisionGrant {
|
|
60
|
+
this.sweep();
|
|
61
|
+
const full: NodeProvisionGrant = {
|
|
62
|
+
...grant,
|
|
63
|
+
// Device names are injected into generated shell/PowerShell text —
|
|
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
|
+
: {}),
|
|
68
|
+
token: randomBytes(24).toString("base64url"),
|
|
69
|
+
createdAt: Date.now(),
|
|
70
|
+
scriptUsed: false,
|
|
71
|
+
binaryUsed: false,
|
|
72
|
+
};
|
|
73
|
+
this.grants.set(full.token, full);
|
|
74
|
+
return full;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Serve the installer script for a live grant — once. */
|
|
78
|
+
openScript(token: string): { script: string; filename: string } | null {
|
|
79
|
+
const grant = this.claim(token, "scriptUsed");
|
|
80
|
+
if (!grant) return null;
|
|
81
|
+
return grant.goos === "windows"
|
|
82
|
+
? {
|
|
83
|
+
script: powershellInstaller(grant),
|
|
84
|
+
filename: "install-talon-node.ps1",
|
|
85
|
+
}
|
|
86
|
+
: { script: shellInstaller(grant), filename: "install-talon-node.sh" };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Resolve the binary leg of a live grant — once. */
|
|
90
|
+
openBinary(token: string): { path: string; size: number } | null {
|
|
91
|
+
const grant = this.claim(token, "binaryUsed");
|
|
92
|
+
if (!grant) return null;
|
|
93
|
+
return { path: grant.binaryPath, size: grant.size };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Expire-check + single-use latch for one leg of a grant. */
|
|
97
|
+
private claim(
|
|
98
|
+
token: string,
|
|
99
|
+
leg: "scriptUsed" | "binaryUsed",
|
|
100
|
+
): NodeProvisionGrant | null {
|
|
101
|
+
this.sweep();
|
|
102
|
+
const grant = this.grants.get(token);
|
|
103
|
+
if (!grant || grant[leg]) return null;
|
|
104
|
+
grant[leg] = true;
|
|
105
|
+
if (grant.scriptUsed && grant.binaryUsed) this.grants.delete(token);
|
|
106
|
+
return grant;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private sweep(): void {
|
|
110
|
+
const cutoff = Date.now() - this.ttlMs;
|
|
111
|
+
for (const [token, grant] of this.grants) {
|
|
112
|
+
if (grant.createdAt < cutoff) this.grants.delete(token);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The command a human (or the model over SSH) runs on the target host. */
|
|
118
|
+
export function installOneLiner(grant: NodeProvisionGrant): string {
|
|
119
|
+
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.
|
|
125
|
+
return (
|
|
126
|
+
`powershell -ExecutionPolicy Bypass -Command "` +
|
|
127
|
+
`[Net.ServicePointManager]::ServerCertificateValidationCallback={$true}; ` +
|
|
128
|
+
`iwr -UseBasicParsing '${url}' | Select-Object -ExpandProperty Content | iex"`
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
return `curl -fsSk "${url}" | sh`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
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.
|
|
140
|
+
*/
|
|
141
|
+
function shellInstaller(grant: NodeProvisionGrant): string {
|
|
142
|
+
const nameFlag = grant.name ? ` --name "${grant.name}"` : "";
|
|
143
|
+
const fpFlag = grant.fingerprint
|
|
144
|
+
? ` --fingerprint "${grant.fingerprint}"`
|
|
145
|
+
: "";
|
|
146
|
+
return `#!/bin/sh
|
|
147
|
+
# talon-node installer — generated by Talon ${grant.version} for ${grant.goos}/${grant.goarch}
|
|
148
|
+
set -eu
|
|
149
|
+
BRIDGE="${grant.bridgeUrl}"
|
|
150
|
+
SHA="${grant.sha256}"
|
|
151
|
+
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
|
+
mkdir -p "$DEST_DIR"
|
|
153
|
+
TMP="$(mktemp)"
|
|
154
|
+
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"
|
|
157
|
+
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
|
+
if [ "$GOT" != "$SHA" ]; then echo "talon-node download failed its checksum — refusing to install" >&2; exit 1; fi
|
|
159
|
+
BIN="$DEST_DIR/talon-node"
|
|
160
|
+
mv "$TMP" "$BIN"
|
|
161
|
+
chmod 0755 "$BIN"
|
|
162
|
+
trap - EXIT
|
|
163
|
+
echo "Installed $BIN"
|
|
164
|
+
"$BIN" install --bridge "$BRIDGE" --token "${grant.bearerToken}"${fpFlag}${nameFlag}
|
|
165
|
+
echo "Done — this host is now on the mesh. Check with: $BIN status"
|
|
166
|
+
`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** PowerShell installer (Windows PowerShell 5+ compatible). */
|
|
170
|
+
function powershellInstaller(grant: NodeProvisionGrant): string {
|
|
171
|
+
const nameArg = grant.name ? `, "--name", "${grant.name}"` : "";
|
|
172
|
+
const fpArg = grant.fingerprint
|
|
173
|
+
? `, "--fingerprint", "${grant.fingerprint}"`
|
|
174
|
+
: "";
|
|
175
|
+
return `# talon-node installer — generated by Talon ${grant.version} for ${grant.goos}/${grant.goarch}
|
|
176
|
+
$ErrorActionPreference = "Stop"
|
|
177
|
+
[Net.ServicePointManager]::ServerCertificateValidationCallback = { $true }
|
|
178
|
+
$bridge = "${grant.bridgeUrl}"
|
|
179
|
+
$dest = Join-Path $env:LOCALAPPDATA "talon-node"
|
|
180
|
+
New-Item -ItemType Directory -Force -Path $dest | Out-Null
|
|
181
|
+
$bin = Join-Path $dest "talon-node.exe"
|
|
182
|
+
Write-Host "Downloading talon-node ${grant.version} (${grant.goos}/${grant.goarch})..."
|
|
183
|
+
Invoke-WebRequest -UseBasicParsing "$bridge/node/binary?provision=${grant.token}" -OutFile $bin
|
|
184
|
+
$got = (Get-FileHash $bin -Algorithm SHA256).Hash.ToLower()
|
|
185
|
+
if ($got -ne "${grant.sha256}") { Remove-Item $bin; throw "talon-node download failed its checksum - refusing to install" }
|
|
186
|
+
Write-Host "Installed $bin"
|
|
187
|
+
& $bin install --bridge $bridge --token "${grant.bearerToken}"${fpArg}${nameArg}
|
|
188
|
+
Write-Host "Done - this host is now on the mesh. Check with: $bin status"
|
|
189
|
+
`;
|
|
190
|
+
}
|
|
@@ -237,10 +237,12 @@ function sanitizeDevice(
|
|
|
237
237
|
) {
|
|
238
238
|
return null;
|
|
239
239
|
}
|
|
240
|
+
const arch = stringField(body.arch)?.toLowerCase().slice(0, 16);
|
|
240
241
|
return {
|
|
241
242
|
id: id ?? "",
|
|
242
243
|
name: name ?? "",
|
|
243
244
|
platform: platform ?? "linux",
|
|
245
|
+
...(arch ? { arch } : {}),
|
|
244
246
|
appVersion: appVersion ?? "",
|
|
245
247
|
online: body.online === true,
|
|
246
248
|
lastSeen: numberField(body.lastSeen) ?? now,
|
package/src/core/mesh/service.ts
CHANGED
|
@@ -30,11 +30,20 @@ import { randomUUID } from "node:crypto";
|
|
|
30
30
|
import { createHash } from "node:crypto";
|
|
31
31
|
import { createReadStream } from "node:fs";
|
|
32
32
|
import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
33
|
-
import { tmpdir } from "node:os";
|
|
33
|
+
import { networkInterfaces, tmpdir } from "node:os";
|
|
34
34
|
import { basename, dirname, join, resolve } from "node:path";
|
|
35
35
|
import type { Readable } from "node:stream";
|
|
36
36
|
import { clampExecOutput } from "../../util/exec-output.js";
|
|
37
37
|
import { dirs } from "../../util/paths.js";
|
|
38
|
+
import {
|
|
39
|
+
NODE_TARGETS,
|
|
40
|
+
normalizeGoarch,
|
|
41
|
+
normalizeGoos,
|
|
42
|
+
platformToGoos,
|
|
43
|
+
resolveNodeBinary,
|
|
44
|
+
type NodeBinaryResolver,
|
|
45
|
+
} from "./node-binaries.js";
|
|
46
|
+
import { installOneLiner, NodeProvisionStore } from "./node-provision.js";
|
|
38
47
|
import { MeshRegistry } from "./registry.js";
|
|
39
48
|
import { TransferStore } from "./transfers.js";
|
|
40
49
|
import type {
|
|
@@ -72,6 +81,25 @@ export type MeshServiceOptions = {
|
|
|
72
81
|
pollIntervalMs?: number;
|
|
73
82
|
/** How long a device command waits for its result before timing out. */
|
|
74
83
|
commandTimeoutMs?: number;
|
|
84
|
+
/** Node-binary resolver override (tests — the real one builds/downloads). */
|
|
85
|
+
nodeBinaryResolver?: NodeBinaryResolver;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* What the native bridge tells the mesh about itself once it's listening —
|
|
90
|
+
* everything a generated node installer needs to point a fresh host here.
|
|
91
|
+
* Registered by the native frontend after server start; null when the
|
|
92
|
+
* bridge isn't running (provisioning tools then fail with a clear reason).
|
|
93
|
+
*/
|
|
94
|
+
export type MeshBridgeInfo = {
|
|
95
|
+
scheme: "http" | "https";
|
|
96
|
+
/** The bind host from config — may be a wildcard (0.0.0.0/::). */
|
|
97
|
+
host: string;
|
|
98
|
+
port: number;
|
|
99
|
+
/** Bearer token clients authenticate with (absent on open loopback). */
|
|
100
|
+
token?: string;
|
|
101
|
+
/** TLS certificate SHA-256 (absent over plain HTTP). */
|
|
102
|
+
fingerprint?: string;
|
|
75
103
|
};
|
|
76
104
|
|
|
77
105
|
const DEFAULT_FRESH_FIX_TIMEOUT_MS = 8_000;
|
|
@@ -144,6 +172,10 @@ export class MeshService {
|
|
|
144
172
|
private readonly receivedAt = new Map<string, number>();
|
|
145
173
|
/** One-time tokens arranging streamed (single-HTTP-request) transfers. */
|
|
146
174
|
private readonly transfers = new TransferStore();
|
|
175
|
+
/** One-time grants for bridge-served node installers. */
|
|
176
|
+
private readonly provision = new NodeProvisionStore();
|
|
177
|
+
private readonly resolveNode: NodeBinaryResolver;
|
|
178
|
+
private bridgeInfo: MeshBridgeInfo | null = null;
|
|
147
179
|
private readonly freshFixTimeoutMs: number;
|
|
148
180
|
private readonly pollIntervalMs: number;
|
|
149
181
|
private readonly commandTimeoutMs: number;
|
|
@@ -158,6 +190,12 @@ export class MeshService {
|
|
|
158
190
|
this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
159
191
|
this.commandTimeoutMs =
|
|
160
192
|
options.commandTimeoutMs ?? DEFAULT_COMMAND_TIMEOUT_MS;
|
|
193
|
+
this.resolveNode = options.nodeBinaryResolver ?? resolveNodeBinary;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The native bridge reports its reachable identity here (null on stop). */
|
|
197
|
+
setBridgeInfo(info: MeshBridgeInfo | null): void {
|
|
198
|
+
this.bridgeInfo = info;
|
|
161
199
|
}
|
|
162
200
|
|
|
163
201
|
/** Hydrate persisted devices/locations. Idempotent — safe to await from
|
|
@@ -923,19 +961,17 @@ export class MeshService {
|
|
|
923
961
|
*
|
|
924
962
|
* The binary is hashed here and the digest travels with the command; the
|
|
925
963
|
* node re-hashes the pushed file and refuses to swap on a mismatch, so a
|
|
926
|
-
* truncated transfer can never be installed.
|
|
927
|
-
*
|
|
964
|
+
* truncated transfer can never be installed.
|
|
965
|
+
*
|
|
966
|
+
* With no binary_path, the replacement is auto-resolved for the node's
|
|
967
|
+
* registered platform/arch (source build in a dev checkout, else the
|
|
968
|
+
* version-matched release download — see node-binaries.ts).
|
|
928
969
|
*/
|
|
929
970
|
async updateNodeBinary(
|
|
930
971
|
query: unknown,
|
|
931
|
-
localBinaryPath
|
|
972
|
+
localBinaryPath?: unknown,
|
|
932
973
|
remotePath?: unknown,
|
|
933
974
|
): Promise<MeshToolResult> {
|
|
934
|
-
const local =
|
|
935
|
-
typeof localBinaryPath === "string" && localBinaryPath.trim()
|
|
936
|
-
? resolve(dirs.workspace, localBinaryPath.trim())
|
|
937
|
-
: "";
|
|
938
|
-
if (!local) return { ok: false, text: "A local binary path is required." };
|
|
939
975
|
await this.load();
|
|
940
976
|
const resolved = this.resolveDevice(query);
|
|
941
977
|
if ("error" in resolved) return { ok: false, text: resolved.error };
|
|
@@ -947,6 +983,34 @@ export class MeshService {
|
|
|
947
983
|
};
|
|
948
984
|
}
|
|
949
985
|
|
|
986
|
+
let local: string;
|
|
987
|
+
let provenance = "";
|
|
988
|
+
if (typeof localBinaryPath === "string" && localBinaryPath.trim()) {
|
|
989
|
+
local = resolve(dirs.workspace, localBinaryPath.trim());
|
|
990
|
+
} else {
|
|
991
|
+
const goos = platformToGoos(target.platform);
|
|
992
|
+
if (!goos) {
|
|
993
|
+
return {
|
|
994
|
+
ok: false,
|
|
995
|
+
text: `${target.name} is a ${target.platform} device — update_node targets headless nodes only.`,
|
|
996
|
+
};
|
|
997
|
+
}
|
|
998
|
+
const goarch = normalizeGoarch(target.arch);
|
|
999
|
+
if (!goarch) {
|
|
1000
|
+
return {
|
|
1001
|
+
ok: false,
|
|
1002
|
+
text: `${target.name} has not advertised its CPU architecture (a node build from before arch reporting). Pass binary_path explicitly for this update — after it, the node advertises arch and future updates auto-resolve.`,
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
1005
|
+
try {
|
|
1006
|
+
const bin = await this.resolveNode(goos, goarch);
|
|
1007
|
+
local = bin.path;
|
|
1008
|
+
provenance = ` (auto-resolved ${bin.version} for ${goos}/${goarch} via ${bin.source})`;
|
|
1009
|
+
} catch (err) {
|
|
1010
|
+
return { ok: false, text: (err as Error).message };
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
|
|
950
1014
|
let sha256: string;
|
|
951
1015
|
let size: number;
|
|
952
1016
|
try {
|
|
@@ -989,12 +1053,145 @@ export class MeshService {
|
|
|
989
1053
|
return {
|
|
990
1054
|
ok: true,
|
|
991
1055
|
text:
|
|
992
|
-
`Pushed ${formatBytes(size)} and staged the update on ${target.name}. ` +
|
|
1056
|
+
`Pushed ${formatBytes(size)}${provenance} and staged the update on ${target.name}. ` +
|
|
993
1057
|
`${dispatched.result.message ?? "Restarting now."} ` +
|
|
994
1058
|
`Confirm with get_device_status once it reconnects (appVersion should change).`,
|
|
995
1059
|
};
|
|
996
1060
|
}
|
|
997
1061
|
|
|
1062
|
+
// ── Node provisioning ──────────────────────────────────────────────────────
|
|
1063
|
+
|
|
1064
|
+
/**
|
|
1065
|
+
* `get_node_binary`: materialize a talon-node binary for any supported
|
|
1066
|
+
* platform/arch on the daemon host — source build in a dev checkout, else
|
|
1067
|
+
* the digest-verified release download (cached under ~/.talon/node-bin).
|
|
1068
|
+
*/
|
|
1069
|
+
async getNodeBinary(os: unknown, arch: unknown): Promise<MeshToolResult> {
|
|
1070
|
+
const goos = normalizeGoos(os);
|
|
1071
|
+
const goarch = normalizeGoarch(arch);
|
|
1072
|
+
if (!goos || !goarch) {
|
|
1073
|
+
return { ok: false, text: unknownTargetText(os, arch) };
|
|
1074
|
+
}
|
|
1075
|
+
try {
|
|
1076
|
+
const bin = await this.resolveNode(goos, goarch);
|
|
1077
|
+
return {
|
|
1078
|
+
ok: true,
|
|
1079
|
+
text: `talon-node ${bin.version} for ${goos}/${goarch}: ${bin.path} (${formatBytes(bin.size)}, sha256 ${bin.sha256}, via ${bin.source})`,
|
|
1080
|
+
};
|
|
1081
|
+
} catch (err) {
|
|
1082
|
+
return { ok: false, text: (err as Error).message };
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/**
|
|
1087
|
+
* `make_node_install_link`: mint a single-use provisioning URL on the
|
|
1088
|
+
* bridge and return the one command that turns a fresh host into a mesh
|
|
1089
|
+
* node — it fetches the installer script, which downloads the (digest-
|
|
1090
|
+
* verified) binary from the same bridge, installs it, pre-pins the bridge
|
|
1091
|
+
* certificate, and registers the boot service.
|
|
1092
|
+
*/
|
|
1093
|
+
async makeNodeInstallLink(
|
|
1094
|
+
os: unknown,
|
|
1095
|
+
arch: unknown,
|
|
1096
|
+
name?: unknown,
|
|
1097
|
+
bridgeUrl?: unknown,
|
|
1098
|
+
): Promise<MeshToolResult> {
|
|
1099
|
+
const goos = normalizeGoos(os);
|
|
1100
|
+
const goarch = normalizeGoarch(arch);
|
|
1101
|
+
if (!goos || !goarch) {
|
|
1102
|
+
return { ok: false, text: unknownTargetText(os, arch) };
|
|
1103
|
+
}
|
|
1104
|
+
const info = this.bridgeInfo;
|
|
1105
|
+
if (!info) {
|
|
1106
|
+
return {
|
|
1107
|
+
ok: false,
|
|
1108
|
+
text: "The native bridge isn't running, so there is nothing for a new node to connect to. Enable the native frontend first.",
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
1111
|
+
if (!info.token) {
|
|
1112
|
+
return {
|
|
1113
|
+
ok: false,
|
|
1114
|
+
text: "The bridge has no bearer token (loopback-only bind), and nodes authenticate with one. Set native.host to a reachable address (a token is auto-minted) and restart.",
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
1117
|
+
const base = this.bridgeBaseUrl(info, bridgeUrl);
|
|
1118
|
+
if (typeof base !== "string") return { ok: false, text: base.error };
|
|
1119
|
+
let bin;
|
|
1120
|
+
try {
|
|
1121
|
+
bin = await this.resolveNode(goos, goarch);
|
|
1122
|
+
} catch (err) {
|
|
1123
|
+
return { ok: false, text: (err as Error).message };
|
|
1124
|
+
}
|
|
1125
|
+
const grant = this.provision.create({
|
|
1126
|
+
goos,
|
|
1127
|
+
goarch,
|
|
1128
|
+
...(typeof name === "string" && name.trim() ? { name: name.trim() } : {}),
|
|
1129
|
+
binaryPath: bin.path,
|
|
1130
|
+
sha256: bin.sha256,
|
|
1131
|
+
size: bin.size,
|
|
1132
|
+
version: bin.version,
|
|
1133
|
+
bridgeUrl: base,
|
|
1134
|
+
bearerToken: info.token,
|
|
1135
|
+
...(info.fingerprint ? { fingerprint: info.fingerprint } : {}),
|
|
1136
|
+
});
|
|
1137
|
+
return {
|
|
1138
|
+
ok: true,
|
|
1139
|
+
text: [
|
|
1140
|
+
`Run this on the new ${goos}/${goarch} host:`,
|
|
1141
|
+
"",
|
|
1142
|
+
` ${installOneLiner(grant)}`,
|
|
1143
|
+
"",
|
|
1144
|
+
`It installs talon-node ${bin.version} (sha256-verified against ${grant.sha256.slice(0, 12)}…), pins the bridge certificate, and registers a boot service — the host appears on the mesh within a minute.`,
|
|
1145
|
+
`Single-use link, expires in 30 minutes. The host must be able to reach ${base}.`,
|
|
1146
|
+
].join("\n"),
|
|
1147
|
+
};
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
/** GET /node/install — serve a grant's installer script (single-use). */
|
|
1151
|
+
openNodeInstall(token: string): { script: string; filename: string } | null {
|
|
1152
|
+
return this.provision.openScript(token);
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
/** GET /node/binary — serve a grant's binary (single-use). */
|
|
1156
|
+
openNodeBinary(token: string): { path: string; size: number } | null {
|
|
1157
|
+
return this.provision.openBinary(token);
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
/**
|
|
1161
|
+
* The bridge base URL a NEW host should dial: an explicit override wins;
|
|
1162
|
+
* otherwise derive from the bridge's bind. A wildcard bind maps to this
|
|
1163
|
+
* host's first external IPv4; a loopback bind is unreachable from other
|
|
1164
|
+
* machines, so it's an error rather than a link that can't work.
|
|
1165
|
+
*/
|
|
1166
|
+
private bridgeBaseUrl(
|
|
1167
|
+
info: MeshBridgeInfo,
|
|
1168
|
+
explicit?: unknown,
|
|
1169
|
+
): string | { error: string } {
|
|
1170
|
+
if (typeof explicit === "string" && explicit.trim()) {
|
|
1171
|
+
const url = explicit.trim().replace(/\/+$/, "");
|
|
1172
|
+
if (!/^https?:\/\/\S+$/.test(url)) {
|
|
1173
|
+
return { error: `bridge_url must be an http(s) URL, got "${url}".` };
|
|
1174
|
+
}
|
|
1175
|
+
return url;
|
|
1176
|
+
}
|
|
1177
|
+
let host = info.host;
|
|
1178
|
+
if (host === "0.0.0.0" || host === "::") {
|
|
1179
|
+
const external = firstExternalIPv4();
|
|
1180
|
+
if (!external) {
|
|
1181
|
+
return {
|
|
1182
|
+
error:
|
|
1183
|
+
"Could not determine this host's external address — pass bridge_url explicitly (the URL the new node should dial).",
|
|
1184
|
+
};
|
|
1185
|
+
}
|
|
1186
|
+
host = external;
|
|
1187
|
+
} else if (isLoopbackAddress(host)) {
|
|
1188
|
+
return {
|
|
1189
|
+
error: `The bridge is bound to loopback (${host}), which other machines can't reach. Set native.host to a reachable address, or pass bridge_url if a tunnel exposes it.`,
|
|
1190
|
+
};
|
|
1191
|
+
}
|
|
1192
|
+
return `${info.scheme}://${host}:${info.port}`;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
998
1195
|
/**
|
|
999
1196
|
* Chunked read of a remote file into a Buffer. Loops `read_file` with
|
|
1000
1197
|
* increasing offsets until the device reports EOF.
|
|
@@ -1243,7 +1440,7 @@ export class MeshService {
|
|
|
1243
1440
|
|
|
1244
1441
|
private deviceLine(device: DeviceInfo): string {
|
|
1245
1442
|
const parts = [
|
|
1246
|
-
`${device.name} [id: ${device.id}] (${device.platform})`,
|
|
1443
|
+
`${device.name} [id: ${device.id}] (${device.platform}${device.arch ? `/${device.arch}` : ""})`,
|
|
1247
1444
|
device.online ? "online" : "offline",
|
|
1248
1445
|
`last seen ${age(Date.now() - device.lastSeen)}`,
|
|
1249
1446
|
];
|
|
@@ -1445,6 +1642,28 @@ function requirePath(value: unknown): string | undefined {
|
|
|
1445
1642
|
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
1446
1643
|
}
|
|
1447
1644
|
|
|
1645
|
+
/** Error text for an os/arch pair outside the talon-node build matrix. */
|
|
1646
|
+
function unknownTargetText(os: unknown, arch: unknown): string {
|
|
1647
|
+
return `No talon-node target for os="${String(os)}", arch="${String(arch)}". Supported: ${NODE_TARGETS.map(
|
|
1648
|
+
(t) => `${t.goos}/${t.goarch}`,
|
|
1649
|
+
).join(", ")} (macos ≡ darwin, x86_64 ≡ amd64, aarch64 ≡ arm64).`;
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
/** First non-internal IPv4 on this host — the wildcard-bind fallback. */
|
|
1653
|
+
function firstExternalIPv4(): string | undefined {
|
|
1654
|
+
for (const list of Object.values(networkInterfaces())) {
|
|
1655
|
+
for (const iface of list ?? []) {
|
|
1656
|
+
if (!iface.internal && iface.family === "IPv4") return iface.address;
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
return undefined;
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
function isLoopbackAddress(host: string): boolean {
|
|
1663
|
+
const h = host.toLowerCase();
|
|
1664
|
+
return h === "localhost" || h === "::1" || h.startsWith("127.");
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1448
1667
|
/** Render an exec result: exit code headline + stdout/stderr blocks. */
|
|
1449
1668
|
function formatExecResult(
|
|
1450
1669
|
device: DeviceInfo,
|
package/src/core/mesh/types.ts
CHANGED
|
@@ -15,6 +15,13 @@ export type DeviceInfo = {
|
|
|
15
15
|
id: string;
|
|
16
16
|
name: string;
|
|
17
17
|
platform: DevicePlatform;
|
|
18
|
+
/**
|
|
19
|
+
* CPU architecture in Go spelling (amd64/arm64/arm), as talon-node
|
|
20
|
+
* advertises it. Lets the daemon pick the right release binary for
|
|
21
|
+
* update_node without being told. Absent for companion apps and node
|
|
22
|
+
* builds that predate arch reporting.
|
|
23
|
+
*/
|
|
24
|
+
arch?: string;
|
|
18
25
|
appVersion: string;
|
|
19
26
|
online: boolean;
|
|
20
27
|
/** Epoch milliseconds. */
|
|
@@ -83,6 +90,7 @@ export function toDeviceInfo(
|
|
|
83
90
|
id: value.id,
|
|
84
91
|
name: value.name,
|
|
85
92
|
platform: value.platform,
|
|
93
|
+
...(value.arch ? { arch: value.arch } : {}),
|
|
86
94
|
appVersion: value.appVersion,
|
|
87
95
|
online: now - value.lastSeen <= offlineAfterMs && value.online,
|
|
88
96
|
lastSeen: value.lastSeen,
|
package/src/core/tools/mesh.ts
CHANGED
|
@@ -188,13 +188,14 @@ export const meshTools: ToolDefinition[] = [
|
|
|
188
188
|
{
|
|
189
189
|
name: "update_node",
|
|
190
190
|
description:
|
|
191
|
-
"Remotely update a headless talon-node (Linux/macOS/Windows server on the mesh): stream a new node binary from the daemon and have the node verify its hash, atomically swap its own binary, and restart into it. On Linux/macOS this is an in-place execve so the mesh connection returns within seconds; the node re-hashes the pushed file and refuses a truncated or mismatched binary.
|
|
191
|
+
"Remotely update a headless talon-node (Linux/macOS/Windows server on the mesh): stream a new node binary from the daemon and have the node verify its hash, atomically swap its own binary, and restart into it. On Linux/macOS this is an in-place execve so the mesh connection returns within seconds; the node re-hashes the pushed file and refuses a truncated or mismatched binary. With no binary_path, the daemon auto-resolves the right build for the node's registered platform/arch (source build in a dev checkout, else the digest-verified release download matching this Talon version) — the normal call is just update_node(device). Confirm success with get_device_status afterwards (appVersion should change). This is the node counterpart of update_device — use update_device for Android companions.",
|
|
192
192
|
schema: {
|
|
193
193
|
device: deviceParam,
|
|
194
194
|
binary_path: z
|
|
195
195
|
.string()
|
|
196
|
+
.optional()
|
|
196
197
|
.describe(
|
|
197
|
-
"
|
|
198
|
+
"Optional explicit binary, relative to the workspace (or absolute), built for the node's OS/arch. Omit to auto-resolve.",
|
|
198
199
|
),
|
|
199
200
|
remote_path: z
|
|
200
201
|
.string()
|
|
@@ -206,4 +207,42 @@ export const meshTools: ToolDefinition[] = [
|
|
|
206
207
|
execute: (params, bridge) => bridge("update_node", params),
|
|
207
208
|
tag: "mesh",
|
|
208
209
|
},
|
|
210
|
+
{
|
|
211
|
+
name: "get_node_binary",
|
|
212
|
+
description:
|
|
213
|
+
"Materialize a talon-node binary (the headless mesh device) for any supported platform/arch on the daemon host and return its path, version, and sha256. Resolution order: built from source when running in a dev checkout with Go, else the cached copy, else the digest-verified download from this Talon version's GitHub release — so it works on prebuilt installs with no toolchain. Use it to stage a binary for manual deployment (scp, device_push_file); for onboarding a fresh host prefer make_node_install_link, and for updating an existing node just call update_node (which resolves automatically).",
|
|
214
|
+
schema: {
|
|
215
|
+
os: z.string().describe("Target OS: linux, macos/darwin, or windows."),
|
|
216
|
+
arch: z
|
|
217
|
+
.string()
|
|
218
|
+
.describe("Target arch: amd64/x86_64, arm64/aarch64, or arm."),
|
|
219
|
+
},
|
|
220
|
+
execute: (params, bridge) => bridge("get_node_binary", params),
|
|
221
|
+
tag: "mesh",
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
name: "make_node_install_link",
|
|
225
|
+
description:
|
|
226
|
+
"Mint a single-use install link served by this daemon's bridge and return the one command that attaches a fresh Linux/macOS/Windows host to the mesh as a headless talon-node. Running it on the host downloads the installer script and binary from the bridge (sha256-verified), installs talon-node, pre-pins the bridge TLS certificate, embeds the bearer token, and registers a boot service — no toolchain, package manager, or manual config on the host. The link expires in 30 minutes and each leg serves exactly once; the host only needs to reach the bridge URL. Requires the native bridge running on a non-loopback bind with a token.",
|
|
227
|
+
schema: {
|
|
228
|
+
os: z.string().describe("Host OS: linux, macos/darwin, or windows."),
|
|
229
|
+
arch: z
|
|
230
|
+
.string()
|
|
231
|
+
.describe("Host arch: amd64/x86_64, arm64/aarch64, or arm."),
|
|
232
|
+
name: z
|
|
233
|
+
.string()
|
|
234
|
+
.optional()
|
|
235
|
+
.describe(
|
|
236
|
+
"Device name to register with (default: the host's hostname).",
|
|
237
|
+
),
|
|
238
|
+
bridge_url: z
|
|
239
|
+
.string()
|
|
240
|
+
.optional()
|
|
241
|
+
.describe(
|
|
242
|
+
"Bridge base URL as reachable FROM the new host (e.g. https://100.64.0.7:19880). Default: derived from the bridge bind (wildcard binds use this host's first external IPv4).",
|
|
243
|
+
),
|
|
244
|
+
},
|
|
245
|
+
execute: (params, bridge) => bridge("make_node_install_link", params),
|
|
246
|
+
tag: "mesh",
|
|
247
|
+
},
|
|
209
248
|
];
|
|
@@ -1194,6 +1194,8 @@ export function createNativeFrontend(
|
|
|
1194
1194
|
completeCommand: (body) => mesh.completeCommand(body),
|
|
1195
1195
|
acceptFileUpload: (token, body) => mesh.acceptFileUpload(token, body),
|
|
1196
1196
|
openFileDownload: (token) => mesh.openFileDownload(token),
|
|
1197
|
+
openNodeInstall: (token) => mesh.openNodeInstall(token),
|
|
1198
|
+
openNodeBinary: (token) => mesh.openNodeBinary(token),
|
|
1197
1199
|
};
|
|
1198
1200
|
|
|
1199
1201
|
const nativeCfg = config.native ?? { port: 19880, host: "127.0.0.1" };
|
|
@@ -1278,6 +1280,15 @@ export function createNativeFrontend(
|
|
|
1278
1280
|
chats.restore();
|
|
1279
1281
|
await server.start();
|
|
1280
1282
|
const fingerprint = server.getFingerprint();
|
|
1283
|
+
// Tell the mesh how this bridge is reachable — everything a generated
|
|
1284
|
+
// node installer needs (make_node_install_link fails cleanly without it).
|
|
1285
|
+
mesh.setBridgeInfo({
|
|
1286
|
+
scheme: server.getScheme(),
|
|
1287
|
+
host: bridgeHost,
|
|
1288
|
+
port: server.getPort(),
|
|
1289
|
+
...(bridgeToken ? { token: bridgeToken } : {}),
|
|
1290
|
+
...(fingerprint ? { fingerprint } : {}),
|
|
1291
|
+
});
|
|
1281
1292
|
await writeBridgeDiscovery({
|
|
1282
1293
|
port: server.getPort(),
|
|
1283
1294
|
token: bridgeToken,
|
|
@@ -1297,6 +1308,7 @@ export function createNativeFrontend(
|
|
|
1297
1308
|
async stop() {
|
|
1298
1309
|
unregisterMeshTransport?.();
|
|
1299
1310
|
unregisterMeshTransport = null;
|
|
1311
|
+
mesh.setBridgeInfo(null);
|
|
1300
1312
|
await removeBridgeDiscovery();
|
|
1301
1313
|
await server.stop();
|
|
1302
1314
|
await gateway.stop();
|
|
@@ -142,6 +142,10 @@ export type BridgeServerHandlers = {
|
|
|
142
142
|
openFileDownload(
|
|
143
143
|
token: string,
|
|
144
144
|
): Promise<{ path: string; size: number } | null>;
|
|
145
|
+
/** Resolve a node-provisioning token to its installer script, or null. */
|
|
146
|
+
openNodeInstall(token: string): { script: string; filename: string } | null;
|
|
147
|
+
/** Resolve a node-provisioning token to the binary to stream, or null. */
|
|
148
|
+
openNodeBinary(token: string): { path: string; size: number } | null;
|
|
145
149
|
};
|
|
146
150
|
|
|
147
151
|
const SSE_PING_MS = 25_000;
|
|
@@ -377,6 +381,45 @@ export class BridgeServer {
|
|
|
377
381
|
});
|
|
378
382
|
}
|
|
379
383
|
|
|
384
|
+
// Node provisioning runs PRE-AUTH by design: the target host holds no
|
|
385
|
+
// bridge credential yet — the single-use grant token (minted by
|
|
386
|
+
// make_node_install_link, expiring, one serve per leg) is the entire
|
|
387
|
+
// authorization, the same trust model as streamed-transfer tokens.
|
|
388
|
+
if (
|
|
389
|
+
method === "GET" &&
|
|
390
|
+
(path === "/node/install" || path === "/node/binary")
|
|
391
|
+
) {
|
|
392
|
+
const token = url.searchParams.get("provision") ?? "";
|
|
393
|
+
const unknown = () =>
|
|
394
|
+
this.json(res, 404, {
|
|
395
|
+
ok: false,
|
|
396
|
+
error: "Unknown, expired, or already-used provisioning token",
|
|
397
|
+
});
|
|
398
|
+
if (!token) return unknown();
|
|
399
|
+
if (path === "/node/install") {
|
|
400
|
+
const install = this.handlers.openNodeInstall(token);
|
|
401
|
+
if (!install) return unknown();
|
|
402
|
+
res.writeHead(200, {
|
|
403
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
404
|
+
"Content-Disposition": `attachment; filename="${install.filename}"`,
|
|
405
|
+
...this.corsHeaders(),
|
|
406
|
+
});
|
|
407
|
+
res.end(install.script);
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
const binary = this.handlers.openNodeBinary(token);
|
|
411
|
+
if (!binary) return unknown();
|
|
412
|
+
res.writeHead(200, {
|
|
413
|
+
"Content-Type": "application/octet-stream",
|
|
414
|
+
"Content-Length": String(binary.size),
|
|
415
|
+
...this.corsHeaders(),
|
|
416
|
+
});
|
|
417
|
+
const stream = createReadStream(binary.path);
|
|
418
|
+
stream.on("error", () => res.destroy());
|
|
419
|
+
stream.pipe(res);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
|
|
380
423
|
if (auth !== "ok") {
|
|
381
424
|
return this.json(res, 401, { ok: false, error: "Unauthorized" });
|
|
382
425
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The daemon's own release version, as a runtime value.
|
|
3
|
+
*
|
|
4
|
+
* A JSON import (not an fs read) so every packaging shape agrees: tsx and
|
|
5
|
+
* Node resolve the file, and `bun build --compile` inlines it into the
|
|
6
|
+
* standalone binary, where no package.json exists on disk. This is the
|
|
7
|
+
* version the node-binary resolver keys caches and release-asset URLs on,
|
|
8
|
+
* so it must exactly match the published tag (publish.yml verifies that).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import pkg from "../../package.json" with { type: "json" };
|
|
12
|
+
|
|
13
|
+
/** Talon's semver (e.g. "3.3.0") — the release identity of this build. */
|
|
14
|
+
export function talonVersion(): string {
|
|
15
|
+
return pkg.version;
|
|
16
|
+
}
|