total-recall-brain 3.22.0 → 3.23.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/bin/total-recall.mjs +2 -0
- package/frontend/src/api/headscale.ts +68 -1
- package/frontend/src/api/mesh.spec.ts +27 -0
- package/frontend/src/api/mesh.ts +85 -0
- package/frontend/src/components/EmbeddingProviderPanel.tsx +21 -3
- package/frontend/src/pages/MeshPage.spec.tsx +164 -0
- package/frontend/src/pages/MeshPage.tsx +294 -6
- package/frontend/src/pages/NetworkPage.spec.tsx +19 -4
- package/frontend/src/pages/OnboardingPage.spec.tsx +14 -2
- package/frontend/src/pages/SkillsPage.spec.tsx +1 -1
- package/package.json +2 -1
- package/scaffold/.agent/skills/total-recall/references/api-reference.md +1 -1
- package/scaffold/.agent/skills/total-recall/references/cli-reference.md +2 -1
- package/scaffold/.agent/skills/total-recall/skills-registry/index.yaml +6 -2574
- package/src/cli/mesh.mjs +416 -0
- package/src/cli/repo-expert-generate.mjs +5 -1
- package/src/cli/reset-password.mjs +75 -6
- package/src/cli/secret.mjs +250 -7
- package/src/core/browser-session.mjs +68 -13
- package/src/core/headscale-client.mjs +87 -0
- package/src/core/mesh-access.mjs +261 -0
- package/src/core/mesh-enroll.mjs +74 -32
- package/src/core/mesh.mjs +164 -12
- package/src/core/schema.mjs +36 -0
- package/src/core/secrets-keychain.mjs +83 -0
- package/src/core/secrets-rekey.mjs +0 -0
- package/src/core/secrets-store.mjs +26 -6
- package/src/core/tailscale-cli.mjs +69 -0
- package/src/server/index.mjs +23 -4
- package/src/server/pid-lock.mjs +64 -0
- package/src/server/route-inventory.mjs +62 -0
- package/src/server/route-manifest.json +20 -0
- package/src/server/routes/headscale.mjs +32 -0
- package/src/server/routes/mesh.mjs +118 -0
package/src/cli/secret.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* npx total-recall secret set <key> <value> [--provider name] [--scope global|project]
|
|
6
6
|
* npx total-recall secret get <key> # prints value (use carefully)
|
|
7
7
|
* npx total-recall secret list
|
|
8
|
-
* npx total-recall secret rotate <key>
|
|
8
|
+
* npx total-recall secret rotate <key> # prompts for the value
|
|
9
9
|
* npx total-recall secret delete <key>
|
|
10
10
|
* npx total-recall secret audit [--limit N]
|
|
11
11
|
* npx total-recall secret usage [--days N]
|
|
@@ -60,6 +60,19 @@ import { listProviders, getProvider } from '../core/provider-catalog.mjs';
|
|
|
60
60
|
import { resolveBrainDir, parseLayerFlag } from './agent-dir.mjs';
|
|
61
61
|
import fs from 'node:fs';
|
|
62
62
|
import path from 'node:path';
|
|
63
|
+
import { execFileSync } from 'node:child_process';
|
|
64
|
+
import os from 'node:os';
|
|
65
|
+
import {
|
|
66
|
+
generateSecretsMasterPassword,
|
|
67
|
+
readMasterPasswordFromCarrier,
|
|
68
|
+
rekeySecretsTransaction,
|
|
69
|
+
} from '../core/secrets-rekey.mjs';
|
|
70
|
+
import {
|
|
71
|
+
DEFAULT_KEYCHAIN_SERVICE,
|
|
72
|
+
keychainAvailable,
|
|
73
|
+
readKeychainPassword,
|
|
74
|
+
} from '../core/secrets-keychain.mjs';
|
|
75
|
+
import { agentDir as configuredAgentDir, getActiveBrains } from '../core/config.mjs';
|
|
63
76
|
|
|
64
77
|
// Reads a secret value from stdin (fd 0) so it never has to appear as a CLI
|
|
65
78
|
// argument — CLI args land in shell history, `ps aux`, and any transcript
|
|
@@ -71,6 +84,75 @@ function readStdinValue() {
|
|
|
71
84
|
return raw.endsWith('\n') ? raw.slice(0, -1) : raw;
|
|
72
85
|
}
|
|
73
86
|
|
|
87
|
+
/**
|
|
88
|
+
* Read the OS clipboard. Keeps a pasted credential inside this process rather
|
|
89
|
+
* than routing it through argv, shell history, or an agent transcript.
|
|
90
|
+
*
|
|
91
|
+
* @returns {string}
|
|
92
|
+
*/
|
|
93
|
+
function readSystemClipboard() {
|
|
94
|
+
const cmds =
|
|
95
|
+
process.platform === 'darwin'
|
|
96
|
+
? [['pbpaste', []]]
|
|
97
|
+
: process.platform === 'win32'
|
|
98
|
+
? [['powershell', ['-NoProfile', '-Command', 'Get-Clipboard']]]
|
|
99
|
+
: [
|
|
100
|
+
['wl-paste', ['--no-newline']],
|
|
101
|
+
['xclip', ['-selection', 'clipboard', '-o']],
|
|
102
|
+
['xsel', ['--clipboard', '--output']],
|
|
103
|
+
];
|
|
104
|
+
for (const [cmd, args] of cmds) {
|
|
105
|
+
try {
|
|
106
|
+
return execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
107
|
+
} catch {
|
|
108
|
+
// try the next clipboard tool
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return '';
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Read a credential without echoing it and without it ever becoming an argv
|
|
116
|
+
* element. `argv` is world-readable via `ps`, lands in shell history, and is
|
|
117
|
+
* captured verbatim by any agent driving this CLI. Falls back to reading one
|
|
118
|
+
* line from stdin when the input is piped rather than a terminal.
|
|
119
|
+
*
|
|
120
|
+
* @param {string} label
|
|
121
|
+
* @returns {Promise<string>}
|
|
122
|
+
*/
|
|
123
|
+
function readSecretValue(label) {
|
|
124
|
+
return new Promise((resolve, reject) => {
|
|
125
|
+
const input = process.stdin;
|
|
126
|
+
if (!input.isTTY) {
|
|
127
|
+
resolve(readStdinValue());
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
process.stdout.write(label);
|
|
131
|
+
input.setRawMode(true);
|
|
132
|
+
input.resume();
|
|
133
|
+
input.setEncoding('utf8');
|
|
134
|
+
let buf = '';
|
|
135
|
+
const onData = (ch) => {
|
|
136
|
+
if (ch === '\n' || ch === '\r' || ch === '\u0004') {
|
|
137
|
+
input.setRawMode(false);
|
|
138
|
+
input.pause();
|
|
139
|
+
input.removeListener('data', onData);
|
|
140
|
+
process.stdout.write('\n');
|
|
141
|
+
resolve(buf.trim());
|
|
142
|
+
} else if (ch === '\u0003') {
|
|
143
|
+
input.setRawMode(false);
|
|
144
|
+
process.stdout.write('\n');
|
|
145
|
+
process.exit(130);
|
|
146
|
+
} else if (ch === '\u007f' || ch === '\b') {
|
|
147
|
+
buf = buf.slice(0, -1);
|
|
148
|
+
} else {
|
|
149
|
+
buf += ch;
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
input.on('data', onData);
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
74
156
|
function printHelp() {
|
|
75
157
|
console.log(`
|
|
76
158
|
total-recall secret — Secrets store (separate from memory vault)
|
|
@@ -84,7 +166,18 @@ function printHelp() {
|
|
|
84
166
|
generate <key> Generate a random secret for outside integrations
|
|
85
167
|
get <key> Print secret value (audited)
|
|
86
168
|
list List keys + metadata only (no values)
|
|
87
|
-
rotate <key>
|
|
169
|
+
rotate <key> Replace value and mark rotated. Prompts for the value;
|
|
170
|
+
--stdin reads fd0, --from-clipboard reads the OS
|
|
171
|
+
clipboard (use the browser you are already signed
|
|
172
|
+
into). The value never appears in argv or output.
|
|
173
|
+
rekey Rotate the secrets-store master password transactionally.
|
|
174
|
+
The replacement is generated in memory by default;
|
|
175
|
+
--stdin accepts a coordinated replacement without argv.
|
|
176
|
+
--launch-agent/--env-file/--keychain select persistent
|
|
177
|
+
carriers. The macOS Keychain entry is included
|
|
178
|
+
automatically when present, because a shell profile
|
|
179
|
+
that reads it would otherwise be left on the retired
|
|
180
|
+
password; --no-keychain opts out.
|
|
88
181
|
delete <key> Remove a secret
|
|
89
182
|
audit [--limit N] Recent set/get/rotate events (no values)
|
|
90
183
|
usage [--days N] Sum usage.jsonl for last N days (default 1)
|
|
@@ -232,6 +325,11 @@ function parseArgs(args) {
|
|
|
232
325
|
remotePort: 22,
|
|
233
326
|
restartCmd: null,
|
|
234
327
|
stdin: false,
|
|
328
|
+
launchAgents: [],
|
|
329
|
+
envFiles: [],
|
|
330
|
+
keychainServices: [],
|
|
331
|
+
keychain: true,
|
|
332
|
+
storePaths: [],
|
|
235
333
|
};
|
|
236
334
|
if (!args.length || args[0] === '--help' || args[0] === '-h') {
|
|
237
335
|
out.help = true;
|
|
@@ -382,6 +480,9 @@ function parseArgs(args) {
|
|
|
382
480
|
case '--replace-all':
|
|
383
481
|
out.replaceAll = true;
|
|
384
482
|
break;
|
|
483
|
+
case '--from-clipboard':
|
|
484
|
+
out.fromClipboard = true;
|
|
485
|
+
break;
|
|
385
486
|
case '--export-env':
|
|
386
487
|
out.exportEnv = true;
|
|
387
488
|
break;
|
|
@@ -434,6 +535,21 @@ function parseArgs(args) {
|
|
|
434
535
|
case '--stdin':
|
|
435
536
|
out.stdin = true;
|
|
436
537
|
break;
|
|
538
|
+
case '--launch-agent':
|
|
539
|
+
out.launchAgents.push(args[++i]);
|
|
540
|
+
break;
|
|
541
|
+
case '--env-file':
|
|
542
|
+
out.envFiles.push(args[++i]);
|
|
543
|
+
break;
|
|
544
|
+
case '--keychain':
|
|
545
|
+
out.keychainServices.push(args[++i]);
|
|
546
|
+
break;
|
|
547
|
+
case '--no-keychain':
|
|
548
|
+
out.keychain = false;
|
|
549
|
+
break;
|
|
550
|
+
case '--store':
|
|
551
|
+
out.storePaths.push(args[++i]);
|
|
552
|
+
break;
|
|
437
553
|
case '--help':
|
|
438
554
|
case '-h':
|
|
439
555
|
out.help = true;
|
|
@@ -466,11 +582,11 @@ export default async function secretCli(argv) {
|
|
|
466
582
|
}
|
|
467
583
|
|
|
468
584
|
if (opts.stdin) {
|
|
469
|
-
if (!['set', 'rotate'].includes(opts.command)) {
|
|
470
|
-
console.error('❌ --stdin is only valid with `set` or `
|
|
585
|
+
if (!['set', 'rotate', 'rekey'].includes(opts.command)) {
|
|
586
|
+
console.error('❌ --stdin is only valid with `set`, `rotate`, or `rekey`');
|
|
471
587
|
process.exit(1);
|
|
472
588
|
}
|
|
473
|
-
if (!opts.key) {
|
|
589
|
+
if (opts.command !== 'rekey' && !opts.key) {
|
|
474
590
|
console.error(`Usage: total-recall secret ${opts.command} <key> --stdin`);
|
|
475
591
|
process.exit(1);
|
|
476
592
|
}
|
|
@@ -482,6 +598,102 @@ export default async function secretCli(argv) {
|
|
|
482
598
|
}
|
|
483
599
|
|
|
484
600
|
try {
|
|
601
|
+
if (opts.command === 'rekey') {
|
|
602
|
+
const carriers = [
|
|
603
|
+
...opts.launchAgents.filter(Boolean).map((carrierPath) => ({
|
|
604
|
+
path: path.resolve(carrierPath),
|
|
605
|
+
type: 'launchd-plist',
|
|
606
|
+
allowInsert: true,
|
|
607
|
+
})),
|
|
608
|
+
...opts.envFiles.filter(Boolean).map((carrierPath) => ({
|
|
609
|
+
path: path.resolve(carrierPath),
|
|
610
|
+
type: 'env',
|
|
611
|
+
allowInsert: true,
|
|
612
|
+
})),
|
|
613
|
+
];
|
|
614
|
+
|
|
615
|
+
if (!carriers.length && process.platform === 'darwin') {
|
|
616
|
+
const launchAgentsDir = path.join(os.homedir(), 'Library', 'LaunchAgents');
|
|
617
|
+
if (fs.existsSync(launchAgentsDir)) {
|
|
618
|
+
for (const entry of fs.readdirSync(launchAgentsDir)) {
|
|
619
|
+
if (!entry.endsWith('.plist')) continue;
|
|
620
|
+
const carrierPath = path.join(launchAgentsDir, entry);
|
|
621
|
+
const source = fs.readFileSync(carrierPath, 'utf8');
|
|
622
|
+
if (source.includes('<key>TR_SECRETS_PASSWORD</key>')) {
|
|
623
|
+
carriers.push({ path: carrierPath, type: 'launchd-plist' });
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// A shell profile that reads the password from the Keychain is the most
|
|
630
|
+
// common macOS arrangement, and it is invisible to a file-carrier scan.
|
|
631
|
+
// Left out, a rotation succeeds and every interactive shell breaks at
|
|
632
|
+
// once — so the entry is picked up by default when it exists.
|
|
633
|
+
const keychainCarriers = opts.keychainServices.filter(Boolean).map((service) => ({ service }));
|
|
634
|
+
if (!keychainCarriers.length && opts.keychain !== false && keychainAvailable()) {
|
|
635
|
+
if (readKeychainPassword({ service: DEFAULT_KEYCHAIN_SERVICE })) {
|
|
636
|
+
keychainCarriers.push({ service: DEFAULT_KEYCHAIN_SERVICE });
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
if (!carriers.length && !keychainCarriers.length) {
|
|
641
|
+
console.error(
|
|
642
|
+
'❌ No persistent master-password carrier found. Use --launch-agent <path>, --env-file <path> or --keychain <service>.',
|
|
643
|
+
);
|
|
644
|
+
process.exit(1);
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
const currentPasswords = [
|
|
648
|
+
...carriers.map((carrier) => readMasterPasswordFromCarrier(carrier)),
|
|
649
|
+
...keychainCarriers.map((carrier) => readKeychainPassword(carrier)),
|
|
650
|
+
].filter(Boolean);
|
|
651
|
+
const oldPassword = currentPasswords[0] || process.env.TR_SECRETS_PASSWORD;
|
|
652
|
+
if (!oldPassword) {
|
|
653
|
+
console.error('❌ The current master password is unavailable from the selected carriers.');
|
|
654
|
+
process.exit(1);
|
|
655
|
+
}
|
|
656
|
+
if (currentPasswords.some((value) => value !== oldPassword)) {
|
|
657
|
+
console.error('❌ Selected credential carriers do not contain the same current master password.');
|
|
658
|
+
console.error(' Every carrier must hold the same password before one can replace it.');
|
|
659
|
+
process.exit(1);
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const defaultBrainDirs = [
|
|
663
|
+
configuredAgentDir,
|
|
664
|
+
brainDir,
|
|
665
|
+
...Object.values(getActiveBrains()).map((brain) => brain?.brainDir),
|
|
666
|
+
].filter(Boolean);
|
|
667
|
+
const legacyAgentDirs = defaultBrainDirs
|
|
668
|
+
.filter(
|
|
669
|
+
(dir) =>
|
|
670
|
+
path.basename(dir) === 'total-recall' && path.basename(path.dirname(dir)) === 'skills',
|
|
671
|
+
)
|
|
672
|
+
.map((dir) => path.dirname(path.dirname(dir)));
|
|
673
|
+
const storePaths = opts.storePaths.length
|
|
674
|
+
? opts.storePaths.map((storePath) => path.resolve(storePath))
|
|
675
|
+
: [...new Set([...defaultBrainDirs, ...legacyAgentDirs].map((dir) => resolveSecretsPath(dir)))].filter(
|
|
676
|
+
(storePath) => fs.existsSync(storePath),
|
|
677
|
+
);
|
|
678
|
+
const newPassword = opts.value || generateSecretsMasterPassword();
|
|
679
|
+
const result = await rekeySecretsTransaction({
|
|
680
|
+
storePaths,
|
|
681
|
+
carriers,
|
|
682
|
+
keychainCarriers,
|
|
683
|
+
oldPassword,
|
|
684
|
+
newPassword,
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
console.log(`\n ✅ Secrets master password rotated transactionally`);
|
|
688
|
+
console.log(` stores re-encrypted and verified: ${result.stores.length}`);
|
|
689
|
+
console.log(` persistent carriers updated: ${result.carriers.length}`);
|
|
690
|
+
console.log(` keychain entries updated: ${result.keychain.length}`);
|
|
691
|
+
console.log(` retired password rejected: yes`);
|
|
692
|
+
console.log(` replacement value was not printed`);
|
|
693
|
+
console.log(` restart affected services so they load the new credential\n`);
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
|
|
485
697
|
if (opts.command === 'set') {
|
|
486
698
|
if (!opts.key || opts.value == null) {
|
|
487
699
|
console.error('Usage: total-recall secret set <key> <value>');
|
|
@@ -918,8 +1130,39 @@ export default async function secretCli(argv) {
|
|
|
918
1130
|
}
|
|
919
1131
|
|
|
920
1132
|
if (opts.command === 'rotate') {
|
|
921
|
-
if (!opts.key
|
|
922
|
-
console.error('Usage: total-recall secret rotate <key>
|
|
1133
|
+
if (!opts.key) {
|
|
1134
|
+
console.error('Usage: total-recall secret rotate <key> [--export-env|--export-all|--remote <name>]');
|
|
1135
|
+
console.error(' The new value is read from a prompt or stdin — never from argv.');
|
|
1136
|
+
process.exit(1);
|
|
1137
|
+
}
|
|
1138
|
+
// A credential passed as an argument is disclosed the moment it is typed:
|
|
1139
|
+
// argv is readable by any user via `ps`, is saved to shell history, and is
|
|
1140
|
+
// captured verbatim by any agent driving this CLI. Read it from a hidden
|
|
1141
|
+
// prompt (or stdin when piped) so it stays in this process's memory.
|
|
1142
|
+
if (!opts.value && opts.fromClipboard) {
|
|
1143
|
+
// Read the OS clipboard inside this process. This lets the operator use
|
|
1144
|
+
// the browser they are already signed into — copy the new credential
|
|
1145
|
+
// there, and the value travels clipboard -> Node -> secrets.enc without
|
|
1146
|
+
// ever passing through argv, a terminal echo, or an agent transcript.
|
|
1147
|
+
opts.value = readSystemClipboard();
|
|
1148
|
+
if (!opts.value) {
|
|
1149
|
+
console.error('Error: clipboard is empty — copy the new credential first.');
|
|
1150
|
+
process.exit(1);
|
|
1151
|
+
}
|
|
1152
|
+
console.log(` 📋 Read ${opts.value.length} chars from the clipboard (value not shown).`);
|
|
1153
|
+
}
|
|
1154
|
+
if (!opts.value) {
|
|
1155
|
+
opts.value = await readSecretValue(`New value for ${opts.key}: `);
|
|
1156
|
+
} else if (!opts.fromClipboard) {
|
|
1157
|
+
console.error(
|
|
1158
|
+
`\n ⚠️ WARNING: the value was passed as a command-line argument.\n` +
|
|
1159
|
+
` It is now in your shell history and was visible to any user\n` +
|
|
1160
|
+
` running \`ps\` on this machine. Treat it as already disclosed\n` +
|
|
1161
|
+
` and rotate again using the prompt: total-recall secret rotate ${opts.key}\n`,
|
|
1162
|
+
);
|
|
1163
|
+
}
|
|
1164
|
+
if (!opts.value) {
|
|
1165
|
+
console.error('Error: empty value — aborting rotation.');
|
|
923
1166
|
process.exit(1);
|
|
924
1167
|
}
|
|
925
1168
|
if (opts.remote) {
|
|
@@ -90,17 +90,42 @@ export async function launchRotationContext(brainDir, opts = {}) {
|
|
|
90
90
|
// headless first-run would simply hang on an invisible challenge.
|
|
91
91
|
const headless = opts.headless === true;
|
|
92
92
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
93
|
+
const base = {
|
|
94
|
+
headless,
|
|
95
|
+
viewport: { width: 1440, height: 900 },
|
|
96
|
+
args: ['--disable-blink-features=AutomationControlled', '--no-first-run'],
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// Playwright's bundled Chromium is not available on every host — it has
|
|
100
|
+
// dropped support for older macOS versions, and the pinned build is often
|
|
101
|
+
// simply not downloaded. A rotation that dies here strands the operator with
|
|
102
|
+
// live, burned credentials, so fall back to any locally installed Chrome or
|
|
103
|
+
// Edge before giving up.
|
|
104
|
+
const attempts = [
|
|
105
|
+
{ label: 'bundled chromium', options: base },
|
|
106
|
+
{ label: 'system chrome', options: { ...base, channel: 'chrome' } },
|
|
107
|
+
{ label: 'system edge', options: { ...base, channel: 'msedge' } },
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
const failures = [];
|
|
111
|
+
for (const attempt of attempts) {
|
|
112
|
+
try {
|
|
113
|
+
const context = await chromium.launchPersistentContext(profileDir, attempt.options);
|
|
114
|
+
context.setDefaultTimeout(opts.timeoutMs ?? 30_000);
|
|
115
|
+
return { ok: true, context, profileDir, browser: attempt.label };
|
|
116
|
+
} catch (err) {
|
|
117
|
+
failures.push(`${attempt.label}: ${err.message.split('\n')[0]}`);
|
|
118
|
+
}
|
|
103
119
|
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
ok: false,
|
|
123
|
+
error:
|
|
124
|
+
`failed to launch a browser profile. Tried ${attempts.length} browsers —\n ` +
|
|
125
|
+
failures.join('\n ') +
|
|
126
|
+
'\nInstall one with: npx playwright install chromium (or install Google Chrome).',
|
|
127
|
+
profileDir,
|
|
128
|
+
};
|
|
104
129
|
}
|
|
105
130
|
|
|
106
131
|
/**
|
|
@@ -128,16 +153,46 @@ export async function openConsole(context, recipe) {
|
|
|
128
153
|
* @param {{ signed_in?: string, signed_out?: string }} recipe
|
|
129
154
|
* @returns {Promise<boolean>}
|
|
130
155
|
*/
|
|
131
|
-
export async function isAuthenticated(page, recipe) {
|
|
156
|
+
export async function isAuthenticated(page, recipe, opts = {}) {
|
|
132
157
|
// A visible signed-out marker (login form, "Sign in" CTA) is decisive.
|
|
133
158
|
if (recipe.signed_out) {
|
|
134
159
|
const out = await page.locator(recipe.signed_out).first().isVisible().catch(() => false);
|
|
135
160
|
if (out) return false;
|
|
136
161
|
}
|
|
162
|
+
|
|
137
163
|
if (recipe.signed_in) {
|
|
138
|
-
|
|
164
|
+
// Consoles are SPA-heavy: the positive marker frequently renders a second
|
|
165
|
+
// or two after DOMContentLoaded. Give it a real wait instead of a single
|
|
166
|
+
// instantaneous probe.
|
|
167
|
+
const appeared = await page
|
|
168
|
+
.locator(recipe.signed_in)
|
|
169
|
+
.first()
|
|
170
|
+
.waitFor({ state: 'visible', timeout: opts.markerTimeoutMs ?? 8000 })
|
|
171
|
+
.then(() => true)
|
|
172
|
+
.catch(() => false);
|
|
173
|
+
if (appeared) return true;
|
|
174
|
+
|
|
175
|
+
// The positive marker never showed and no login form matched. Selectors go
|
|
176
|
+
// stale constantly, so fall back to the one signal every console agrees on:
|
|
177
|
+
// an unauthenticated request gets redirected to a login URL.
|
|
178
|
+
return !looksLikeLoginUrl(page.url());
|
|
139
179
|
}
|
|
140
|
-
|
|
180
|
+
|
|
181
|
+
return !looksLikeLoginUrl(page.url());
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Provider-agnostic sign-in detection. Consoles redirect anonymous visitors to
|
|
186
|
+
* a login route rather than returning an error status, so the landed URL is a
|
|
187
|
+
* far more durable signal than any per-provider CSS selector.
|
|
188
|
+
*
|
|
189
|
+
* @param {string} url
|
|
190
|
+
* @returns {boolean}
|
|
191
|
+
*/
|
|
192
|
+
export function looksLikeLoginUrl(url) {
|
|
193
|
+
return /\/(login|log-in|sign[_-]?in|signin|auth\/login|sessions\/new|u\/login)(\/|\?|#|$)/i.test(
|
|
194
|
+
String(url || ''),
|
|
195
|
+
);
|
|
141
196
|
}
|
|
142
197
|
|
|
143
198
|
/**
|
|
@@ -225,3 +225,90 @@ export async function createHeadscalePreAuthKey(options = {}, brainDir) {
|
|
|
225
225
|
}
|
|
226
226
|
throw lastError || new Error('Headscale did not return a pre-auth key');
|
|
227
227
|
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Read the control server's ACL policy.
|
|
231
|
+
*
|
|
232
|
+
* Requires the server to run with `policy.mode: database` — in `file` mode the
|
|
233
|
+
* policy lives on the control server's disk and the API cannot manage it.
|
|
234
|
+
*
|
|
235
|
+
* Headscale returns a 500 (not a 404) when database mode is on but the policy
|
|
236
|
+
* table is still empty, so an unset policy is normalised to `policy: null`
|
|
237
|
+
* rather than thrown. That distinction matters: "no policy yet" is the expected
|
|
238
|
+
* first-run state, whereas a real transport or auth failure must still surface.
|
|
239
|
+
*/
|
|
240
|
+
export async function getHeadscalePolicy(brainDir) {
|
|
241
|
+
try {
|
|
242
|
+
const data = await headscaleFetch('/api/v1/policy', {}, brainDir);
|
|
243
|
+
return {
|
|
244
|
+
policy: data?.policy ?? null,
|
|
245
|
+
updatedAt: data?.updatedAt ?? null,
|
|
246
|
+
configured: Boolean(data?.policy),
|
|
247
|
+
raw: data,
|
|
248
|
+
};
|
|
249
|
+
} catch (err) {
|
|
250
|
+
const detail = String(err.detail || '');
|
|
251
|
+
const emptyPolicyTable =
|
|
252
|
+
err.status === 404 ||
|
|
253
|
+
(err.status === 500 && /policy|not found|no such/i.test(detail));
|
|
254
|
+
if (emptyPolicyTable) {
|
|
255
|
+
return { policy: null, updatedAt: null, configured: false, unset: true, raw: null };
|
|
256
|
+
}
|
|
257
|
+
if (err.status === 400 && /database/i.test(detail)) {
|
|
258
|
+
const modeError = new Error(
|
|
259
|
+
'Headscale is running in file policy mode; set `policy.mode: database` to manage the policy over the API',
|
|
260
|
+
);
|
|
261
|
+
modeError.status = err.status;
|
|
262
|
+
modeError.detail = err.detail;
|
|
263
|
+
modeError.code = 'POLICY_MODE_FILE';
|
|
264
|
+
throw modeError;
|
|
265
|
+
}
|
|
266
|
+
throw err;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Replace the control server's ACL policy.
|
|
272
|
+
*
|
|
273
|
+
* `policy` may be an object (serialised here) or an already-formatted
|
|
274
|
+
* HuJSON/JSON string. Headscale validates server-side and rejects the write if
|
|
275
|
+
* the policy's own `tests` block fails, so a bad policy never lands.
|
|
276
|
+
*/
|
|
277
|
+
export async function setHeadscalePolicy(policy, brainDir) {
|
|
278
|
+
if (!policy) throw new Error('A policy is required');
|
|
279
|
+
const serialised = typeof policy === 'string' ? policy : JSON.stringify(policy, null, 2);
|
|
280
|
+
|
|
281
|
+
const data = await headscaleFetch(
|
|
282
|
+
'/api/v1/policy',
|
|
283
|
+
{ method: 'PUT', body: JSON.stringify({ policy: serialised }) },
|
|
284
|
+
brainDir,
|
|
285
|
+
);
|
|
286
|
+
return { policy: data?.policy ?? serialised, updatedAt: data?.updatedAt ?? null, raw: data };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Build a policy that lets every mesh member reach every other member, and use
|
|
291
|
+
* Tailscale SSH between them as any non-root user.
|
|
292
|
+
*
|
|
293
|
+
* This is deliberately the simple single-operator shape: one person's own
|
|
294
|
+
* machines, all equally trusted. It is NOT appropriate for a multi-tenant
|
|
295
|
+
* tailnet — headscale membership is the trust boundary, so anything admitted to
|
|
296
|
+
* the mesh inherits this access. Narrow `src`/`dst` before adding a node you do
|
|
297
|
+
* not control.
|
|
298
|
+
*
|
|
299
|
+
* root is excluded via `autogroup:nonroot`: SSH-ing straight to root over the
|
|
300
|
+
* mesh removes the audit trail that makes per-user access worth having.
|
|
301
|
+
*/
|
|
302
|
+
export function buildMeshSshPolicy({ allowRoot = false } = {}) {
|
|
303
|
+
return {
|
|
304
|
+
acls: [{ action: 'accept', src: ['*'], dst: ['*:*'] }],
|
|
305
|
+
ssh: [
|
|
306
|
+
{
|
|
307
|
+
action: 'accept',
|
|
308
|
+
src: ['autogroup:member'],
|
|
309
|
+
dst: ['autogroup:self'],
|
|
310
|
+
users: allowRoot ? ['autogroup:nonroot', 'root'] : ['autogroup:nonroot'],
|
|
311
|
+
},
|
|
312
|
+
],
|
|
313
|
+
};
|
|
314
|
+
}
|