total-recall-brain 3.41.3 → 3.41.5
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/bin/total-recall.mjs +12 -2
- package/docs/reference/cli-reference.md +18 -0
- package/frontend/dist/assets/{index-DFenQQEQ.js → index-WT3gs-x6.js} +75 -70
- package/frontend/dist/index.html +1 -1
- package/package.json +7 -5
- package/scaffold/.agent/skills/total-recall/plugins/auth/package-lock.json +2 -2
- package/scaffold/.agent/skills/total-recall/plugins/auth/package.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/auth/plugin.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/cli-secret.mjs +48 -0
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/login-projections.mjs +42 -0
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/mesh-secrets.mjs +18 -0
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/routes/secrets.mjs +5 -2
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-carriers.mjs +76 -0
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-prune.mjs +37 -0
- package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-sync.mjs +53 -17
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/package-lock.json +3114 -36
- package/scaffold/.agent/skills/total-recall/plugins/code-quality/package.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package-lock.json +3114 -36
- package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/embeddings/package-lock.json +3131 -28
- package/scaffold/.agent/skills/total-recall/plugins/embeddings/package.json +6 -2
- package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package-lock.json +3132 -29
- package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/package.json +5 -1
- package/scaffold/.agent/skills/total-recall/plugins/mesh/package-lock.json +3164 -73
- package/scaffold/.agent/skills/total-recall/plugins/mesh/package.json +5 -3
- package/scaffold/.agent/skills/total-recall/plugins/mesh/plugin.json +1 -1
- package/scaffold/.agent/skills/total-recall/plugins/mesh/src/cli/grants.mjs +7 -3
- package/scaffold/.agent/skills/total-recall/plugins/mesh/src/cli/mesh.mjs +61 -38
- package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/leader-election.mjs +57 -2
- package/scaffold/.agent/skills/total-recall/plugins/mesh/src/core/mesh.mjs +220 -36
- package/scaffold/.agent/skills/total-recall/plugins/mesh/src/server/routes/mesh.mjs +7 -2
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/README.md +65 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/capability/BRICK.md +59 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/capability/index.mjs +534 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/cli.mjs +139 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/package-lock.json +5937 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/package.json +26 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/plugin.json +376 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/skills/meta-harness/SKILL.md +37 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/adapters/dsh.mjs +446 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/alerts.mjs +23 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/coordinator.mjs +398 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/documentation.mjs +172 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/executor.mjs +82 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/learner.mjs +90 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/router.mjs +415 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/runtime.mjs +36 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/setup.mjs +35 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/tracker.mjs +133 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/worker.mjs +233 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/ui/DESIGN.md +32 -0
- package/scaffold/.agent/skills/total-recall/plugins/meta-harness/ui/card.js +845 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/capability/BRICK.md +64 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/capability/index.mjs +250 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/cli.mjs +619 -41
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/package-lock.json +3134 -31
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/package.json +9 -3
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/plugin.json +413 -14
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/config.mjs +129 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/generate.mjs +159 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/graph.mjs +197 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/lint.mjs +153 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/mesh-vaults.mjs +261 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/refresh.mjs +502 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/search.mjs +114 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/token.mjs +96 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/upstream-watcher.mjs +375 -0
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/ui/DESIGN.md +35 -22
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/ui/card.js +1225 -364
- package/scaffold/.agent/skills/total-recall/plugins/openwiki/vendor/openwiki.mjs +8987 -0
- package/scaffold/.agent/skills/total-recall/plugins/security/package-lock.json +3114 -83
- package/scaffold/.agent/skills/total-recall/plugins/security/package.json +5 -1
- package/src/cli/compact.mjs +108 -0
- package/src/cli/config.mjs +3 -0
- package/src/cli/connect.mjs +12 -116
- package/src/cli/context.mjs +52 -23
- package/src/cli/forget.mjs +2 -0
- package/src/cli/harness.mjs +0 -1
- package/src/cli/plugin/create.mjs +56 -3
- package/src/cli/plugin/index.mjs +15 -3
- package/src/cli/plugin/monetization.mjs +232 -12
- package/src/cli/preflight.mjs +161 -0
- package/src/cli/release.mjs +386 -0
- package/src/core/cli-commands.mjs +4 -0
- package/src/core/context-compactor.mjs +143 -0
- package/src/core/context-compiler.mjs +8 -1
- package/src/core/default-plugins.mjs +1 -1
- package/src/core/meta-harness.mjs +16 -38
- package/src/core/plugin-dependencies.mjs +6 -1
- package/src/core/preflight-gate.mjs +77 -0
- package/src/core/preflight-receipt.mjs +123 -0
- package/src/core/runtime.mjs +0 -3
- package/src/core/surface.mjs +39 -2
- package/src/server/invoke-durability.mjs +39 -0
- package/src/server/invoke-write-child.mjs +25 -0
- package/src/server/routes/invoke.mjs +7 -0
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tr-plugin-mesh",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Headscale and Tailscale SSH control-server mesh administration and node discovery",
|
|
6
6
|
"scripts": {
|
|
7
|
-
"test": "node --test test/*.test.mjs && vitest run"
|
|
7
|
+
"test": "node --test test/*.test.mjs && vitest run",
|
|
8
|
+
"ssss": "ssss"
|
|
8
9
|
},
|
|
9
10
|
"license": "MIT",
|
|
10
11
|
"dependencies": {
|
|
11
|
-
"express": "^5.2.1"
|
|
12
|
+
"express": "^5.2.1",
|
|
13
|
+
"@gregiteen/ssss-cli": "0.14.4"
|
|
12
14
|
},
|
|
13
15
|
"files": [
|
|
14
16
|
"cli.mjs",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"$schema": "https://github.com/gregiteen/total-recall/blob/main/metadata.plugin.schema.json",
|
|
3
3
|
"id": "mesh",
|
|
4
4
|
"name": "Headscale",
|
|
5
|
-
"version": "0.2.
|
|
5
|
+
"version": "0.2.6",
|
|
6
6
|
"description": "Control-server mesh administration, Tailscale SSH, and peer node discovery.",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"requires": [
|
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
* `mesh grants` — which enrolled machines may read which secret keys.
|
|
3
3
|
*
|
|
4
4
|
* mesh grants list
|
|
5
|
-
* mesh grants add (--node <name> | --tag <tag:x> | --user <login>) KEY
|
|
5
|
+
* mesh grants add (--node <name> | --tag <tag:x> | --user <login>) (KEY | --repo <name>)...
|
|
6
6
|
* mesh grants remove (--node <name> | --tag <tag:x> | --user <login>) [KEY...]
|
|
7
7
|
*
|
|
8
|
-
* Keys may end in `*` to match a prefix.
|
|
8
|
+
* Keys may end in `*` to match a prefix. `--repo <name>` grants every secret whose `repos`
|
|
9
|
+
* metadata names that repository (stored as `repo:<name>`, resolved on the leader). Prefer it to
|
|
10
|
+
* `*`: a node that leaks then exposes only what its repositories use. Nothing is granted by default.
|
|
9
11
|
*/
|
|
10
12
|
import { grantKeys, loadPolicy, revokeKeys, savePolicy, policyPath } from '../core/mesh-identity.mjs';
|
|
11
13
|
|
|
@@ -15,13 +17,14 @@ function parse(args) {
|
|
|
15
17
|
for (let i = 0; i < args.length; i += 1) {
|
|
16
18
|
const arg = args[i];
|
|
17
19
|
if (arg === '--node' || arg === '--tag' || arg === '--user') selector[arg.slice(2)] = args[(i += 1)];
|
|
20
|
+
else if (arg === '--repo') keys.push(`repo:${args[(i += 1)]}`);
|
|
18
21
|
else if (arg.startsWith('--')) throw new Error(`Unknown option ${arg}`);
|
|
19
22
|
else keys.push(arg);
|
|
20
23
|
}
|
|
21
24
|
return { selector, keys };
|
|
22
25
|
}
|
|
23
26
|
|
|
24
|
-
export async function grantsCli(args, brainDir, out = console.log) {
|
|
27
|
+
export async function grantsCli(args, brainDir, out = console.log, warn = console.error) {
|
|
25
28
|
const action = args[0] || 'list';
|
|
26
29
|
if (action === 'list') {
|
|
27
30
|
out(JSON.stringify({ path: policyPath(brainDir), ...loadPolicy(brainDir) }));
|
|
@@ -29,6 +32,7 @@ export async function grantsCli(args, brainDir, out = console.log) {
|
|
|
29
32
|
}
|
|
30
33
|
if (action !== 'add' && action !== 'remove') throw new Error('Usage: mesh grants list|add|remove');
|
|
31
34
|
const { selector, keys } = parse(args.slice(1));
|
|
35
|
+
if (action === 'add' && keys.includes('*')) warn('Warning: "*" gives this machine every secret; if it leaks, every secret must be rotated. Prefer --repo <name> or named keys.');
|
|
32
36
|
const current = loadPolicy(brainDir);
|
|
33
37
|
const next = action === 'add' ? grantKeys(current, selector, keys) : revokeKeys(current, selector, keys);
|
|
34
38
|
savePolicy(brainDir, next);
|
|
@@ -26,9 +26,10 @@ import {
|
|
|
26
26
|
buildMeshSshPolicy,
|
|
27
27
|
} from '../core/headscale-client.mjs';
|
|
28
28
|
import {
|
|
29
|
-
|
|
29
|
+
isSelfNode,
|
|
30
30
|
listEnrichedMeshNodes,
|
|
31
31
|
meshVaultRoot,
|
|
32
|
+
resolveMeshNode,
|
|
32
33
|
setMeshNodeAccess,
|
|
33
34
|
} from '../core/mesh.mjs';
|
|
34
35
|
import {
|
|
@@ -51,6 +52,9 @@ function printHelp() {
|
|
|
51
52
|
nodes List mesh nodes and how to reach each one
|
|
52
53
|
ssh <node> [cmd…] Open a session using the node's recorded access
|
|
53
54
|
exec <node> <cmd…> Execute a command on a node non-interactively (--json)
|
|
55
|
+
leader [--json] Show the elected leader and its server port
|
|
56
|
+
leader pin <ip> [--port <n>]
|
|
57
|
+
Pin the leader node and the port its server answers on
|
|
54
58
|
access <node> Show resolved access for a node
|
|
55
59
|
access <node> --user <u> [--port <n>] [--host <h>] [--identity <path>]
|
|
56
60
|
Record how to reach a node
|
|
@@ -368,22 +372,19 @@ export default async function meshCli(argv = []) {
|
|
|
368
372
|
return;
|
|
369
373
|
}
|
|
370
374
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
const data = await headscaleFetch(
|
|
378
|
-
'/api/v1/node', {}, brainDir,
|
|
379
|
-
);
|
|
380
|
-
registry = data?.nodes || [];
|
|
381
|
-
} catch {}
|
|
382
|
-
node = findMeshNode(target, vaultRoot, registry);
|
|
375
|
+
let node;
|
|
376
|
+
try {
|
|
377
|
+
node = await resolveMeshNode(target, { vaultRoot, brainDir });
|
|
378
|
+
} catch (err) {
|
|
379
|
+
fail(err.message, 'Run `total-recall mesh nodes` to list them.');
|
|
380
|
+
return;
|
|
383
381
|
}
|
|
384
382
|
|
|
385
|
-
if (
|
|
386
|
-
fail(
|
|
383
|
+
if (isSelfNode(node)) {
|
|
384
|
+
fail(
|
|
385
|
+
`"${node.hostname}" is this machine — the mesh never tunnels a node into itself.`,
|
|
386
|
+
'Run the command locally; `mesh ssh`/`mesh exec` address peers.',
|
|
387
|
+
);
|
|
387
388
|
return;
|
|
388
389
|
}
|
|
389
390
|
|
|
@@ -416,7 +417,7 @@ export default async function meshCli(argv = []) {
|
|
|
416
417
|
|
|
417
418
|
try {
|
|
418
419
|
const { execMeshCommand } = await import('../core/mesh.mjs');
|
|
419
|
-
const res = await execMeshCommand(target, cmd, { vaultRoot });
|
|
420
|
+
const res = await execMeshCommand(target, cmd, { vaultRoot, brainDir });
|
|
420
421
|
if (jsonMode) {
|
|
421
422
|
console.log(JSON.stringify(res, null, 2));
|
|
422
423
|
} else {
|
|
@@ -446,14 +447,21 @@ export default async function meshCli(argv = []) {
|
|
|
446
447
|
if (target === 'discover') {
|
|
447
448
|
const { discoverNodeAccess } = await import('../core/mesh-discover.mjs');
|
|
448
449
|
const wanted = args.find((a) => !a.startsWith('-'));
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
450
|
+
let targets;
|
|
451
|
+
if (wanted) {
|
|
452
|
+
try {
|
|
453
|
+
targets = [await resolveMeshNode(wanted, { vaultRoot, brainDir })];
|
|
454
|
+
} catch (err) {
|
|
455
|
+
fail(err.message, 'Run `total-recall mesh nodes` to list them.');
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
} else {
|
|
459
|
+
targets = listEnrichedMeshNodes(vaultRoot).filter(
|
|
460
|
+
(n) => !n.self && n.ip && n.online !== false && !n.access?.verified_at,
|
|
461
|
+
);
|
|
462
|
+
}
|
|
454
463
|
if (!targets.length) {
|
|
455
|
-
console.log(
|
|
456
|
-
if (wanted) process.exitCode = 1;
|
|
464
|
+
console.log('Every online node already has a verified login.');
|
|
457
465
|
return;
|
|
458
466
|
}
|
|
459
467
|
let missing = 0;
|
|
@@ -545,9 +553,11 @@ export default async function meshCli(argv = []) {
|
|
|
545
553
|
const identity = readOption('--identity');
|
|
546
554
|
|
|
547
555
|
if (!user && !port && !host && !identity) {
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
556
|
+
let node;
|
|
557
|
+
try {
|
|
558
|
+
node = await resolveMeshNode(target, { vaultRoot, brainDir });
|
|
559
|
+
} catch (err) {
|
|
560
|
+
fail(err.message, 'Run `total-recall mesh nodes` to list them.');
|
|
551
561
|
return;
|
|
552
562
|
}
|
|
553
563
|
console.log(JSON.stringify(resolveNodeAccess(node), null, 2));
|
|
@@ -560,13 +570,12 @@ export default async function meshCli(argv = []) {
|
|
|
560
570
|
if (host) patch.ssh_host = host;
|
|
561
571
|
if (identity) patch.identity_file = identity;
|
|
562
572
|
|
|
563
|
-
const result = await setMeshNodeAccess(target, patch, { vaultRoot });
|
|
573
|
+
const result = await setMeshNodeAccess(target, patch, { vaultRoot, brainDir });
|
|
564
574
|
if (!result.written) {
|
|
575
|
+
const unresolved = result.reason === 'node-not-found' || result.reason === 'node-ambiguous';
|
|
565
576
|
fail(
|
|
566
|
-
`Could not record access for "${target}".`,
|
|
567
|
-
|
|
568
|
-
? 'Run `total-recall mesh nodes` to see known nodes.'
|
|
569
|
-
: 'The vault write was rejected.',
|
|
577
|
+
unresolved ? result.error : `Could not record access for "${target}".`,
|
|
578
|
+
unresolved ? 'Run `total-recall mesh nodes` to see known nodes.' : 'The vault write was rejected.',
|
|
570
579
|
);
|
|
571
580
|
return;
|
|
572
581
|
}
|
|
@@ -591,7 +600,19 @@ export default async function meshCli(argv = []) {
|
|
|
591
600
|
}
|
|
592
601
|
|
|
593
602
|
if (command === 'leader') {
|
|
594
|
-
const { getLeaderInfo, isLeader } = await import('../core/leader-election.mjs');
|
|
603
|
+
const { getLeaderInfo, isLeader, setLeaderPin } = await import('../core/leader-election.mjs');
|
|
604
|
+
if (args[0] === 'pin') {
|
|
605
|
+
const ip = args[1];
|
|
606
|
+
const portIndex = args.indexOf('--port');
|
|
607
|
+
const port = portIndex >= 0 ? args[portIndex + 1] : undefined;
|
|
608
|
+
if (!ip || ip.startsWith('-')) {
|
|
609
|
+
fail('Usage: total-recall mesh leader pin <mesh-ip> [--port <leader server port>]');
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
const pinned = setLeaderPin({ ip, port });
|
|
613
|
+
console.log(`Leader pinned to ${pinned.leaderIp}${pinned.leaderPort ? `:${pinned.leaderPort}` : ''} in ${pinned.file}`);
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
595
616
|
const leaderInfo = await getLeaderInfo();
|
|
596
617
|
const thisIsLeader = await isLeader();
|
|
597
618
|
if (args.includes('--json')) {
|
|
@@ -602,7 +623,7 @@ export default async function meshCli(argv = []) {
|
|
|
602
623
|
console.log('No leader elected (no mesh nodes online).');
|
|
603
624
|
return;
|
|
604
625
|
}
|
|
605
|
-
console.log(`👑 Current Cluster Leader: ${leaderInfo.hostname} (${leaderInfo.ip})`);
|
|
626
|
+
console.log(`👑 Current Cluster Leader: ${leaderInfo.hostname} (${leaderInfo.ip}${leaderInfo.port ? `:${leaderInfo.port}` : ''})`);
|
|
606
627
|
console.log(` Strategy: ${leaderInfo.strategy}`);
|
|
607
628
|
console.log(` Local node: ${thisIsLeader ? 'LEADER (this node runs cluster workloads)' : 'FOLLOWER'}`);
|
|
608
629
|
return;
|
|
@@ -611,18 +632,20 @@ export default async function meshCli(argv = []) {
|
|
|
611
632
|
if (command === 'ping' || command === 'latency') {
|
|
612
633
|
const target = args.find((a) => !a.startsWith('-'));
|
|
613
634
|
const isJson = args.includes('--json');
|
|
614
|
-
const { getMeshPeers
|
|
635
|
+
const { getMeshPeers } = await import('../core/mesh.mjs');
|
|
615
636
|
const { throttledFetch } = await import('../core/throttled-fetch.mjs');
|
|
616
637
|
|
|
617
638
|
const port = Number(process.env.TR_SERVER_PORT || process.env.PORT || 3000);
|
|
618
639
|
|
|
619
640
|
if (target) {
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
641
|
+
let node;
|
|
642
|
+
try {
|
|
643
|
+
node = await resolveMeshNode(target, { vaultRoot, brainDir });
|
|
644
|
+
} catch (err) {
|
|
645
|
+
fail(err.message, 'Run `total-recall mesh nodes` to list them.');
|
|
623
646
|
return;
|
|
624
647
|
}
|
|
625
|
-
if (node
|
|
648
|
+
if (isSelfNode(node)) {
|
|
626
649
|
if (isJson) {
|
|
627
650
|
console.log(JSON.stringify({ node: node.hostname, ip: node.ip, latency_ms: 0, self: true, reachable: true }, null, 2));
|
|
628
651
|
} else {
|
|
@@ -60,6 +60,58 @@ export function leaderPin(brain = brainDir) {
|
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
function validPort(value) {
|
|
64
|
+
const port = Number(value);
|
|
65
|
+
return Number.isInteger(port) && port > 0 && port <= 65535 ? port : null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Port the pinned leader's Total Recall server answers on.
|
|
70
|
+
*
|
|
71
|
+
* Followers used to reach the leader on their OWN configured port, which only
|
|
72
|
+
* works when every node happens to use the same one. The droplet serves on 3900
|
|
73
|
+
* while devices default to 3000, so every follower's secrets sync failed with a
|
|
74
|
+
* bare "fetch failed" once a minute, forever. The pin names the port alongside
|
|
75
|
+
* the IP (`TR_LEADER_PORT`, or `leaderPort` in config/leader.json).
|
|
76
|
+
*/
|
|
77
|
+
export function leaderPort(brain = brainDir) {
|
|
78
|
+
const raw = String(process.env.TR_LEADER_PORT || '').trim();
|
|
79
|
+
if (raw) return validPort(raw);
|
|
80
|
+
try {
|
|
81
|
+
const file = path.join(brain, 'config', 'leader.json');
|
|
82
|
+
if (!fs.existsSync(file)) return null;
|
|
83
|
+
return validPort(JSON.parse(fs.readFileSync(file, 'utf8'))?.leaderPort);
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Record the operator pin in <brain>/config/leader.json, keeping any note.
|
|
91
|
+
* The env vars still win when the supervisor sets them.
|
|
92
|
+
*/
|
|
93
|
+
export function setLeaderPin({ ip, port } = {}, brain = brainDir) {
|
|
94
|
+
const octets = String(ip || '').split('.').map(Number);
|
|
95
|
+
if (octets.length !== 4 || octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
|
96
|
+
throw new Error(`Leader pin needs a mesh IPv4 address, got "${ip}"`);
|
|
97
|
+
}
|
|
98
|
+
if (port !== undefined && port !== null && !validPort(port)) throw new Error(`Invalid leader port "${port}"`);
|
|
99
|
+
const file = path.join(brain, 'config', 'leader.json');
|
|
100
|
+
let current = {};
|
|
101
|
+
try { current = JSON.parse(fs.readFileSync(file, 'utf8')) || {}; } catch {}
|
|
102
|
+
const next = { ...current, leaderIp: String(ip).trim(), updated_at: new Date().toISOString() };
|
|
103
|
+
if (port !== undefined && port !== null) next.leaderPort = validPort(port);
|
|
104
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
105
|
+
const temporary = `${file}.${process.pid}.tmp`;
|
|
106
|
+
fs.writeFileSync(temporary, `${JSON.stringify(next, null, 2)}\n`);
|
|
107
|
+
fs.renameSync(temporary, file);
|
|
108
|
+
return { file, ...next };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function withPort(info, port) {
|
|
112
|
+
return port ? { ...info, port } : info;
|
|
113
|
+
}
|
|
114
|
+
|
|
63
115
|
function compareNodes(a, b) {
|
|
64
116
|
const ipOrder = String(a.ip || '').localeCompare(String(b.ip || ''), undefined, { numeric: true });
|
|
65
117
|
return ipOrder || String(a.hostname || '').localeCompare(String(b.hostname || ''));
|
|
@@ -77,11 +129,14 @@ export async function getLeaderInfo() {
|
|
|
77
129
|
if (pin) {
|
|
78
130
|
const pinned = nodes.find((node) => node.ip === pin);
|
|
79
131
|
if (!pinned) return null;
|
|
80
|
-
return
|
|
132
|
+
return withPort(
|
|
133
|
+
{ hostname: pinned.hostname, ip: pinned.ip, strategy: 'pinned-mesh-ip' },
|
|
134
|
+
leaderPort() || validPort(pinned.brain_port),
|
|
135
|
+
);
|
|
81
136
|
}
|
|
82
137
|
|
|
83
138
|
const leader = [...nodes].sort(compareNodes)[0];
|
|
84
|
-
return { hostname: leader.hostname, ip: leader.ip, strategy: 'lowest-mesh-ip' };
|
|
139
|
+
return withPort({ hostname: leader.hostname, ip: leader.ip, strategy: 'lowest-mesh-ip' }, validPort(leader.brain_port));
|
|
85
140
|
}
|
|
86
141
|
|
|
87
142
|
/**
|
|
@@ -86,7 +86,13 @@ export function meshNodeKey(hostname) {
|
|
|
86
86
|
function normalizeNode(node, self = false) {
|
|
87
87
|
return {
|
|
88
88
|
hostname: normalizeHostname(node?.DNSName) || node?.HostName || null,
|
|
89
|
+
// The name the machine reports for itself, which is what the control
|
|
90
|
+
// server lists it under. It differs from the DNS name whenever the node was
|
|
91
|
+
// renamed (a droplet listed as `portfolio-site` answers as `cloud.mesh…`),
|
|
92
|
+
// and it is not unique, so it is an identity to match, never a key.
|
|
93
|
+
os_hostname: node?.HostName || null,
|
|
89
94
|
ip: node?.TailscaleIPs?.[0] || null,
|
|
95
|
+
ips: Array.isArray(node?.TailscaleIPs) ? node.TailscaleIPs : [],
|
|
90
96
|
online: !!node?.Online,
|
|
91
97
|
self,
|
|
92
98
|
os: node?.OS || null,
|
|
@@ -226,7 +232,9 @@ function enrichPeerWithEntity(peer, ent, extra = {}) {
|
|
|
226
232
|
: [];
|
|
227
233
|
return {
|
|
228
234
|
hostname: peer.hostname,
|
|
235
|
+
os_hostname: peer.os_hostname || null,
|
|
229
236
|
ip: peer.ip || ent?.ip || null,
|
|
237
|
+
ips: Array.isArray(peer.ips) && peer.ips.length ? peer.ips : [peer.ip || ent?.ip].filter(Boolean),
|
|
230
238
|
lan_ip: peer.lan_ip || ent?.lan_ip || null,
|
|
231
239
|
brain_port: ent?.brain_port || null,
|
|
232
240
|
online: !!peer.online,
|
|
@@ -308,47 +316,178 @@ function findEntityForSelf(self, vaultRoot) {
|
|
|
308
316
|
return entityForPeer(self, entities, otherHosts);
|
|
309
317
|
}
|
|
310
318
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
const wanted = String(nameOrAddress || '').trim();
|
|
314
|
-
if (!wanted) return null;
|
|
315
|
-
const key = meshNodeKey(wanted);
|
|
316
|
-
const nodes = listEnrichedMeshNodes(vaultRoot);
|
|
319
|
+
const isAddressLike = (value) => value.includes(':') || /^\d{1,3}(\.\d{1,3}){3}$/.test(value);
|
|
320
|
+
const cleanName = (value) => String(value).trim().replace(/\.$/, '').toLowerCase();
|
|
317
321
|
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
322
|
+
function meshNodeError(code, message) {
|
|
323
|
+
const err = new Error(message);
|
|
324
|
+
err.code = code;
|
|
325
|
+
return err;
|
|
326
|
+
}
|
|
321
327
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
328
|
+
/** A control-server entry no live peer or entity accounts for. */
|
|
329
|
+
function registryOnlyNode(entry) {
|
|
330
|
+
const addresses = (entry.ipAddresses || []).map(String);
|
|
331
|
+
const name = entry.givenName || entry.name || null;
|
|
332
|
+
return {
|
|
333
|
+
hostname: name,
|
|
334
|
+
os_hostname: entry.name || null,
|
|
335
|
+
ip: addresses[0] || null,
|
|
336
|
+
ips: addresses,
|
|
337
|
+
lan_ip: null,
|
|
338
|
+
online: entry.online === true,
|
|
339
|
+
self: false,
|
|
340
|
+
title: name,
|
|
341
|
+
has_entity: false,
|
|
342
|
+
vault_only: false,
|
|
343
|
+
registry_only: true,
|
|
344
|
+
registry_id: entry.id == null ? null : String(entry.id),
|
|
345
|
+
access: null,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
325
348
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
349
|
+
/**
|
|
350
|
+
* Every node reference can be resolved against, each with the identities an
|
|
351
|
+
* operator might type: what the local daemon reports (DNS name, the machine's
|
|
352
|
+
* own hostname, every address), what the vault records (title, LAN address),
|
|
353
|
+
* and what the control server lists (`name`, `givenName`, `id`).
|
|
354
|
+
*
|
|
355
|
+
* Registry entries join a node by a shared address. An entry nothing claims
|
|
356
|
+
* becomes a node of its own, so a machine only the control server knows about
|
|
357
|
+
* still resolves; it carries no access, which callers report as such.
|
|
358
|
+
*/
|
|
359
|
+
function meshNodeCandidates(nodes = [], registry = null) {
|
|
360
|
+
const entries = Array.isArray(registry) ? registry : [];
|
|
361
|
+
const claimed = new Set();
|
|
362
|
+
const identities = (node, entry) => {
|
|
363
|
+
const names = [node.hostname, node.os_hostname, node.title, entry?.name, entry?.givenName].filter(Boolean);
|
|
364
|
+
return {
|
|
365
|
+
addresses: new Set(
|
|
366
|
+
[node.ip, node.lan_ip, ...(node.ips || []), ...(entry?.ipAddresses || [])]
|
|
367
|
+
.filter(Boolean)
|
|
368
|
+
.map((v) => String(v).toLowerCase()),
|
|
369
|
+
),
|
|
370
|
+
names: new Set(names.map(cleanName)),
|
|
371
|
+
keys: new Set(names.map(meshNodeKey).filter(Boolean)),
|
|
372
|
+
id: entry?.id == null || entry.id === '' ? null : String(entry.id),
|
|
373
|
+
};
|
|
374
|
+
};
|
|
329
375
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
const
|
|
333
|
-
(
|
|
334
|
-
(r.name && meshNodeKey(r.name) === key)
|
|
376
|
+
const candidates = nodes.map((node) => {
|
|
377
|
+
const own = new Set([node.ip, ...(node.ips || [])].filter(Boolean).map(String));
|
|
378
|
+
const index = entries.findIndex(
|
|
379
|
+
(e, i) => !claimed.has(i) && (e.ipAddresses || []).some((ip) => own.has(String(ip))),
|
|
335
380
|
);
|
|
336
|
-
if (
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
381
|
+
if (index !== -1) claimed.add(index);
|
|
382
|
+
return { node, ids: identities(node, index === -1 ? null : entries[index]) };
|
|
383
|
+
});
|
|
384
|
+
entries.forEach((entry, i) => {
|
|
385
|
+
if (claimed.has(i)) return;
|
|
386
|
+
const node = registryOnlyNode(entry);
|
|
387
|
+
candidates.push({ node, ids: identities(node, entry) });
|
|
388
|
+
});
|
|
389
|
+
return candidates;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Resolve a node reference against every identity `mesh nodes` can print.
|
|
394
|
+
*
|
|
395
|
+
* Precedence, first tier with a match wins: exact address, exact whole name,
|
|
396
|
+
* first-label key (never for an address, whose first label is shared by every
|
|
397
|
+
* node on the range), then the control server's node id.
|
|
398
|
+
*
|
|
399
|
+
* A machine's own hostname is not unique (two phones both report `localhost`),
|
|
400
|
+
* so a reference that matches two machines in the deciding tier is refused
|
|
401
|
+
* rather than resolved to whichever came first: `exec` would otherwise run on
|
|
402
|
+
* the wrong host. A vault-only record that shares a match with a live or
|
|
403
|
+
* registered node is set aside first — Headscale reassigns a freed address, so
|
|
404
|
+
* a stale record at that address describes the machine that held it before.
|
|
405
|
+
*
|
|
406
|
+
* Pure: callers supply the nodes and, optionally, the registry.
|
|
407
|
+
*
|
|
408
|
+
* @returns {object|null} the matching node; throws MESH_NODE_AMBIGUOUS
|
|
409
|
+
*/
|
|
410
|
+
export function matchMeshNode(nameOrAddress, nodes = [], registry = null) {
|
|
411
|
+
const wanted = String(nameOrAddress || '').trim();
|
|
412
|
+
if (!wanted) return null;
|
|
413
|
+
const lower = wanted.toLowerCase();
|
|
414
|
+
const exact = cleanName(wanted);
|
|
415
|
+
const key = isAddressLike(wanted) ? '' : meshNodeKey(wanted);
|
|
416
|
+
const candidates = meshNodeCandidates(nodes, registry);
|
|
417
|
+
|
|
418
|
+
const tiers = [
|
|
419
|
+
(ids) => ids.addresses.has(lower),
|
|
420
|
+
(ids) => ids.names.has(exact),
|
|
421
|
+
(ids) => !!key && ids.keys.has(key),
|
|
422
|
+
(ids) => ids.id === wanted,
|
|
423
|
+
];
|
|
424
|
+
|
|
425
|
+
for (const matches of tiers) {
|
|
426
|
+
const hits = candidates.filter((c) => matches(c.ids));
|
|
427
|
+
if (!hits.length) continue;
|
|
428
|
+
const current = hits.filter((c) => !c.node.vault_only);
|
|
429
|
+
const pool = current.length ? current : hits;
|
|
430
|
+
if (pool.length > 1) {
|
|
431
|
+
const names = pool.map((c) => `${c.node.hostname || '?'} (${c.node.ip || 'no address'})`);
|
|
432
|
+
throw meshNodeError(
|
|
433
|
+
'MESH_NODE_AMBIGUOUS',
|
|
434
|
+
`"${wanted}" matches more than one mesh node: ${names.join(', ')}. Use its address or full name.`,
|
|
435
|
+
);
|
|
346
436
|
}
|
|
437
|
+
return pool[0].node;
|
|
347
438
|
}
|
|
348
|
-
|
|
349
439
|
return null;
|
|
350
440
|
}
|
|
351
441
|
|
|
442
|
+
/** Find an enriched node (live + vault, plus `registry` when given) synchronously. */
|
|
443
|
+
export function findMeshNode(nameOrAddress, vaultRoot = meshVaultRoot(), registry = null) {
|
|
444
|
+
return matchMeshNode(nameOrAddress, listEnrichedMeshNodes(vaultRoot), registry);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async function fetchMeshRegistry(brainDir) {
|
|
448
|
+
const { headscaleFetch } = await import('./headscale-client.mjs');
|
|
449
|
+
const data = await headscaleFetch('/api/v1/node', {}, brainDir);
|
|
450
|
+
return Array.isArray(data?.nodes) ? data.nodes : [];
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* The one resolver every command that takes a node goes through.
|
|
455
|
+
*
|
|
456
|
+
* Local discovery and the vault answer first, so a name this machine already
|
|
457
|
+
* knows never needs the control-server credential. Only on a miss is the
|
|
458
|
+
* control server asked — that is where registry-only names and node ids live.
|
|
459
|
+
* When it cannot be asked, the not-found error says why, so a locked secrets
|
|
460
|
+
* store is never reported as a machine that does not exist.
|
|
461
|
+
*
|
|
462
|
+
* @param {string} nameOrAddress
|
|
463
|
+
* @param {{vaultRoot?: string, brainDir?: string, nodes?: object[], fetchRegistry?: Function}} [options]
|
|
464
|
+
* @returns {Promise<object>} the node; throws MESH_NODE_NOT_FOUND or MESH_NODE_AMBIGUOUS
|
|
465
|
+
*/
|
|
466
|
+
export async function resolveMeshNode(nameOrAddress, options = {}) {
|
|
467
|
+
const wanted = String(nameOrAddress || '').trim();
|
|
468
|
+
if (!wanted) throw meshNodeError('MESH_NODE_NOT_FOUND', 'A mesh node name or address is required.');
|
|
469
|
+
const nodes = options.nodes || listEnrichedMeshNodes(options.vaultRoot || meshVaultRoot());
|
|
470
|
+
|
|
471
|
+
const local = matchMeshNode(wanted, nodes);
|
|
472
|
+
if (local) return local;
|
|
473
|
+
|
|
474
|
+
let registry = null;
|
|
475
|
+
let registryError = null;
|
|
476
|
+
try {
|
|
477
|
+
registry = await (options.fetchRegistry || fetchMeshRegistry)(options.brainDir);
|
|
478
|
+
} catch (err) {
|
|
479
|
+
registryError = err;
|
|
480
|
+
}
|
|
481
|
+
const found = registry ? matchMeshNode(wanted, nodes, registry) : null;
|
|
482
|
+
if (found) return found;
|
|
483
|
+
|
|
484
|
+
throw meshNodeError(
|
|
485
|
+
'MESH_NODE_NOT_FOUND',
|
|
486
|
+
`No mesh node matches "${wanted}".` +
|
|
487
|
+
(registryError ? ` The control server could not be asked: ${registryError.message}` : ''),
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
|
|
352
491
|
/**
|
|
353
492
|
* Record how to reach a node.
|
|
354
493
|
*
|
|
@@ -358,8 +497,14 @@ export function findMeshNode(nameOrAddress, vaultRoot = meshVaultRoot(), registr
|
|
|
358
497
|
*/
|
|
359
498
|
export async function setMeshNodeAccess(nameOrAddress, accessPatch, options = {}) {
|
|
360
499
|
const vaultRoot = options.vaultRoot || meshVaultRoot();
|
|
361
|
-
|
|
362
|
-
|
|
500
|
+
let node;
|
|
501
|
+
try {
|
|
502
|
+
node = await resolveMeshNode(nameOrAddress, { vaultRoot, brainDir: options.brainDir });
|
|
503
|
+
} catch (err) {
|
|
504
|
+
if (err.code === 'MESH_NODE_NOT_FOUND') return { written: false, reason: 'node-not-found', error: err.message };
|
|
505
|
+
if (err.code === 'MESH_NODE_AMBIGUOUS') return { written: false, reason: 'node-ambiguous', error: err.message };
|
|
506
|
+
throw err;
|
|
507
|
+
}
|
|
363
508
|
|
|
364
509
|
const entities = listMeshNodeEntities(vaultRoot);
|
|
365
510
|
const otherHosts = new Set(
|
|
@@ -679,11 +824,50 @@ export async function patchOwnMeshNode(options = {}) {
|
|
|
679
824
|
* @param {boolean} [options.loginShell=true]
|
|
680
825
|
* @returns {Promise<{ node: string, hostname: string, ip: string, exitCode: number, success: boolean, stdout: string, stderr: string }>}
|
|
681
826
|
*/
|
|
827
|
+
/**
|
|
828
|
+
* Is `node` the machine running this command?
|
|
829
|
+
*
|
|
830
|
+
* Every host reaches itself through its own sshd, so `mesh ssh`/`mesh exec`
|
|
831
|
+
* aimed at self is a misconfiguration, not a remote call: it burns an auth
|
|
832
|
+
* round-trip, fails under `BatchMode` when this host holds no key for its own
|
|
833
|
+
* account, and hides the real intent (work that belongs to the local machine).
|
|
834
|
+
*
|
|
835
|
+
* Identity comes only from what the local daemon and registry report: the
|
|
836
|
+
* `self` flag, an exact address, or a whole hostname under any of its MagicDNS
|
|
837
|
+
* forms. Short labels are compared for HOSTNAMES ONLY — applied to an address
|
|
838
|
+
* they would compare the first octet, which every node on a shared range has
|
|
839
|
+
* in common, and match unrelated peers. Never key on a device name, a tailnet
|
|
840
|
+
* domain or an address range.
|
|
841
|
+
*/
|
|
842
|
+
export function isSelfNode(node, selfOverride) {
|
|
843
|
+
if (!node) return false;
|
|
844
|
+
if (node.self) return true;
|
|
845
|
+
const self = selfOverride === undefined ? getMeshSelf() : selfOverride;
|
|
846
|
+
if (!self) return false;
|
|
847
|
+
|
|
848
|
+
const isAddress = (value) => value.includes(':') || /^\d{1,3}(\.\d{1,3}){3}$/.test(value);
|
|
849
|
+
const clean = (v) => String(v || '').replace(/\.$/, '').toLowerCase();
|
|
850
|
+
const selfValues = [self.hostname, self.ip, self.name].filter(Boolean).map(clean);
|
|
851
|
+
const selfAddresses = new Set(selfValues.filter(isAddress));
|
|
852
|
+
const selfNames = new Set(selfValues.filter((v) => !isAddress(v)));
|
|
853
|
+
const selfLabels = new Set([...selfNames].map((v) => v.split('.')[0]));
|
|
854
|
+
|
|
855
|
+
return [node.hostname, node.ip, node.name].filter(Boolean).map(clean).some((candidate) => {
|
|
856
|
+
if (selfAddresses.has(candidate)) return true;
|
|
857
|
+
if (selfNames.has(candidate)) return true;
|
|
858
|
+
// A hostname may arrive fully qualified or bare; an address may not.
|
|
859
|
+
return !isAddress(candidate) && selfLabels.has(candidate.split('.')[0]);
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
|
|
682
863
|
export async function execMeshCommand(target, command, options = {}) {
|
|
683
864
|
const { vaultRoot = meshVaultRoot(), timeoutMs = 60_000, loginShell = true } = options;
|
|
684
|
-
const node =
|
|
685
|
-
if (
|
|
686
|
-
throw new Error(
|
|
865
|
+
const node = await resolveMeshNode(target, { vaultRoot, brainDir: options.brainDir });
|
|
866
|
+
if (isSelfNode(node)) {
|
|
867
|
+
throw new Error(
|
|
868
|
+
`"${node.hostname}" is this machine, and the mesh never tunnels a node into itself. `
|
|
869
|
+
+ 'Run the command locally; the mesh CLI addresses peers.',
|
|
870
|
+
);
|
|
687
871
|
}
|
|
688
872
|
const resolved = resolveNodeAccess(node);
|
|
689
873
|
if (!resolved.complete) {
|
|
@@ -172,9 +172,14 @@ router.post('/api/mesh/access', requireAuth, requireScope('config:write'), async
|
|
|
172
172
|
const result = await setMeshNodeAccess(target, patch, { vaultRoot: meshVaultRoot() });
|
|
173
173
|
if (!result.written) {
|
|
174
174
|
const notFound = result.reason === 'node-not-found';
|
|
175
|
-
|
|
175
|
+
const ambiguous = result.reason === 'node-ambiguous';
|
|
176
|
+
res.status(notFound ? 404 : ambiguous ? 409 : 500).json({
|
|
176
177
|
success: false,
|
|
177
|
-
message: notFound
|
|
178
|
+
message: notFound
|
|
179
|
+
? `No mesh node matches "${target}".`
|
|
180
|
+
: ambiguous
|
|
181
|
+
? result.error
|
|
182
|
+
: 'The vault write was rejected.',
|
|
178
183
|
reason: result.reason || null,
|
|
179
184
|
});
|
|
180
185
|
return;
|