total-recall-brain 3.21.2 → 3.22.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/frontend/dist/assets/index-C7_Xpk27.css +1 -0
- package/frontend/dist/assets/{index-D0XeeXhG.js → index-DJjuZ3t3.js} +106 -106
- package/frontend/dist/index.html +2 -2
- package/frontend/src/App.spec.tsx +1 -1
- package/frontend/src/api/_base.ts +3 -3
- package/frontend/src/api/embeddings.ts +46 -0
- package/frontend/src/api/mesh.ts +47 -0
- package/frontend/src/api/network.spec.ts +9 -9
- package/frontend/src/api/network.ts +4 -4
- package/frontend/src/api/notifications.ts +5 -4
- package/frontend/src/api/webhooks.ts +1 -1
- package/frontend/src/components/BrainSelector.spec.tsx +4 -4
- package/frontend/src/components/BrainSelector.tsx +1 -1
- package/frontend/src/components/ContextualHelp.spec.tsx +14 -14
- package/frontend/src/components/ContextualHelp.tsx +3 -1
- package/frontend/src/components/DocumentTable.spec.tsx +1 -1
- package/frontend/src/components/EmbeddingProviderPanel.spec.tsx +127 -0
- package/frontend/src/components/EmbeddingProviderPanel.tsx +177 -0
- package/frontend/src/components/Graph3D.spec.tsx +4 -4
- package/frontend/src/components/Graph3D.tsx +3 -3
- package/frontend/src/components/MobilePairing.spec.tsx +1 -1
- package/frontend/src/components/MobilePairing.tsx +1 -1
- package/frontend/src/components/UsageChart.spec.tsx +4 -4
- package/frontend/src/components/UsageChart.tsx +1 -1
- package/frontend/src/components/VoiceInput.spec.tsx +6 -6
- package/frontend/src/components/VoiceInput.tsx +19 -4
- package/frontend/src/components/brand/BrandMark.spec.tsx +3 -4
- package/frontend/src/index.css +6 -0
- package/frontend/src/pages/AutomationsPage.spec.tsx +4 -4
- package/frontend/src/pages/ChatPage.spec.tsx +3 -3
- package/frontend/src/pages/CollabPage.spec.tsx +1 -2
- package/frontend/src/pages/DesignDocsPage.spec.tsx +1 -1
- package/frontend/src/pages/FilesPage.spec.tsx +2 -2
- package/frontend/src/pages/GraphPage.spec.tsx +1 -1
- package/frontend/src/pages/HealthPage.spec.tsx +2 -2
- package/frontend/src/pages/HelpPage.spec.tsx +5 -5
- package/frontend/src/pages/InboxPage.spec.tsx +4 -4
- package/frontend/src/pages/InstructionsPage.spec.tsx +5 -5
- package/frontend/src/pages/IntegrationsPage.spec.tsx +7 -7
- package/frontend/src/pages/MemoryPage.spec.tsx +2 -2
- package/frontend/src/pages/MeshPage.spec.tsx +111 -2
- package/frontend/src/pages/MeshPage.tsx +95 -11
- package/frontend/src/pages/NetworkPage.spec.tsx +7 -8
- package/frontend/src/pages/NetworkPage.tsx +12 -12
- package/frontend/src/pages/NotificationsPage.tsx +9 -9
- package/frontend/src/pages/OnboardingPage.spec.tsx +7 -7
- package/frontend/src/pages/OpenWikiPage.tsx +3 -3
- package/frontend/src/pages/RulesPage.tsx +3 -3
- package/frontend/src/pages/SecretsPage.spec.tsx +5 -5
- package/frontend/src/pages/SecretsPage.tsx +13 -14
- package/frontend/src/pages/SettingsPage.spec.tsx +6 -6
- package/frontend/src/pages/SettingsPage.tsx +2 -2
- package/frontend/src/pages/SkillsPage.spec.tsx +1 -2
- package/frontend/src/pages/SkillsPage.tsx +3 -3
- package/frontend/src/pages/TasksPage.spec.tsx +3 -3
- package/frontend/src/pages/UsagePage.spec.tsx +2 -2
- package/frontend/src/pages/UsagePage.tsx +1 -1
- package/frontend/src/pages/WebhooksPage.spec.tsx +1 -1
- package/frontend/src/pages/WebhooksPage.tsx +19 -19
- package/frontend/src/setupTests.ts +4 -4
- package/package.json +1 -1
- package/scaffold/.agent/skills/total-recall/SKILL.md +92 -0
- package/scaffold/.agent/skills/total-recall/modules/agents/agents.yml +15 -13
- package/scaffold/.agent/skills/total-recall/research-queue.jsonl +0 -1
- package/src/cli/secret.mjs +93 -12
- package/src/core/browser-session.mjs +185 -0
- package/src/core/daemon-loop.mjs +28 -1
- package/src/core/embeddings.mjs +52 -10
- package/src/core/headscale-client.mjs +227 -0
- package/src/core/mesh-enroll.mjs +439 -0
- package/src/core/ollama-embeddings.mjs +336 -0
- package/src/core/provider-catalog.mjs +175 -6
- package/src/core/provider-rotation-recipes.mjs +299 -0
- package/src/core/rotation-capability.mjs +189 -0
- package/src/core/secrets-rotate.mjs +192 -0
- package/src/server/rest.mjs +2 -0
- package/src/server/route-manifest.json +20 -0
- package/src/server/routes/embeddings.mjs +111 -0
- package/src/server/routes/headscale.mjs +21 -81
- package/src/server/routes/mesh.mjs +41 -0
- package/frontend/dist/assets/index-B4RASWbV.css +0 -1
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Automatic mesh node enrollment.
|
|
3
|
+
*
|
|
4
|
+
* Total Recall discovers mesh peers through the local Tailscale client, but a
|
|
5
|
+
* node that has never authenticated to the control server is invisible to the
|
|
6
|
+
* mesh. This module closes that gap: it reads local client state, and — when a
|
|
7
|
+
* Headscale credential is configured — mints a short-lived pre-auth key and
|
|
8
|
+
* brings the node up unattended. With no credential it still surfaces the
|
|
9
|
+
* interactive registration URL so the dashboard always has a working path.
|
|
10
|
+
*
|
|
11
|
+
* Portability rules (open source): no control-server URL, hostname, or device
|
|
12
|
+
* name is hardcoded. Everything is read from the local client, the operator's
|
|
13
|
+
* secret entry, or environment variables.
|
|
14
|
+
*/
|
|
15
|
+
import { spawnSync } from 'node:child_process';
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import os from 'node:os';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import crypto from 'node:crypto';
|
|
20
|
+
import { logger } from './logger.mjs';
|
|
21
|
+
import {
|
|
22
|
+
createHeadscalePreAuthKey,
|
|
23
|
+
describeHeadscaleAvailability,
|
|
24
|
+
headscaleUrlFromEnv,
|
|
25
|
+
normalizeControlUrl,
|
|
26
|
+
resolveHeadscaleUser,
|
|
27
|
+
} from './headscale-client.mjs';
|
|
28
|
+
|
|
29
|
+
/** Enrollment states surfaced to the API/UI. */
|
|
30
|
+
export const ENROLLMENT_STATES = Object.freeze({
|
|
31
|
+
ENROLLED: 'enrolled',
|
|
32
|
+
NEEDS_LOGIN: 'needs_login',
|
|
33
|
+
STOPPED: 'stopped',
|
|
34
|
+
CLIENT_UNAVAILABLE: 'client_unavailable',
|
|
35
|
+
UNKNOWN: 'unknown',
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/** macOS ships the CLI inside the app bundle; PATH-only lookup misses it. */
|
|
39
|
+
const FALLBACK_BINARIES = [
|
|
40
|
+
'/usr/local/bin/tailscale',
|
|
41
|
+
'/opt/homebrew/bin/tailscale',
|
|
42
|
+
'/Applications/Tailscale.app/Contents/MacOS/Tailscale',
|
|
43
|
+
'/usr/bin/tailscale',
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
const STATUS_TIMEOUT_MS = 5_000;
|
|
47
|
+
const UP_TIMEOUT_MS = 90_000;
|
|
48
|
+
const LOGIN_PROBE_TIMEOUT_MS = 15_000;
|
|
49
|
+
|
|
50
|
+
/** Auto-enrollment retry floor — a failing control server must not be hammered. */
|
|
51
|
+
const AUTO_RETRY_INTERVAL_MS = 10 * 60_000;
|
|
52
|
+
|
|
53
|
+
let lastAutoAttemptAt = 0;
|
|
54
|
+
let lastAutoResult = null;
|
|
55
|
+
|
|
56
|
+
/** Reset throttle state (tests, and after an explicit user-triggered enroll). */
|
|
57
|
+
export function resetAutoEnrollThrottle() {
|
|
58
|
+
lastAutoAttemptAt = 0;
|
|
59
|
+
lastAutoResult = null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Resolve the Tailscale CLI, honoring an explicit override first. */
|
|
63
|
+
export function resolveTailscaleBinary() {
|
|
64
|
+
const override = (process.env.TR_TAILSCALE_BIN || '').trim();
|
|
65
|
+
if (override) return override;
|
|
66
|
+
for (const candidate of FALLBACK_BINARIES) {
|
|
67
|
+
try {
|
|
68
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
69
|
+
} catch {
|
|
70
|
+
// permission errors fall through to PATH lookup
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return 'tailscale';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function runTailscale(args, { timeout = STATUS_TIMEOUT_MS } = {}) {
|
|
77
|
+
const result = spawnSync(resolveTailscaleBinary(), args, {
|
|
78
|
+
encoding: 'utf8',
|
|
79
|
+
timeout,
|
|
80
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
81
|
+
});
|
|
82
|
+
return {
|
|
83
|
+
ok: result.status === 0,
|
|
84
|
+
status: result.status,
|
|
85
|
+
stdout: result.stdout || '',
|
|
86
|
+
stderr: result.stderr || '',
|
|
87
|
+
error: result.error || null,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Raw `tailscale status --json`, or null when the client is unavailable. */
|
|
92
|
+
export function readTailscaleStatus() {
|
|
93
|
+
const res = runTailscale(['status', '--json']);
|
|
94
|
+
if (!res.stdout) return null;
|
|
95
|
+
try {
|
|
96
|
+
return JSON.parse(res.stdout);
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Local client preferences. `ControlURL` here is how we learn which control
|
|
104
|
+
* server this node already targets without hardcoding one.
|
|
105
|
+
*/
|
|
106
|
+
export function readTailscalePrefs() {
|
|
107
|
+
const res = runTailscale(['debug', 'prefs']);
|
|
108
|
+
if (!res.stdout) return null;
|
|
109
|
+
try {
|
|
110
|
+
return JSON.parse(res.stdout);
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function classifyState(status, prefs) {
|
|
117
|
+
if (!status) return ENROLLMENT_STATES.CLIENT_UNAVAILABLE;
|
|
118
|
+
const backend = String(status.BackendState || '');
|
|
119
|
+
if (backend === 'Running' && (status.TailscaleIPs || []).length) {
|
|
120
|
+
return ENROLLMENT_STATES.ENROLLED;
|
|
121
|
+
}
|
|
122
|
+
if (backend === 'NeedsLogin' || prefs?.LoggedOut === true) {
|
|
123
|
+
return ENROLLMENT_STATES.NEEDS_LOGIN;
|
|
124
|
+
}
|
|
125
|
+
if (backend === 'Stopped') return ENROLLMENT_STATES.STOPPED;
|
|
126
|
+
if (backend === 'Starting') return ENROLLMENT_STATES.UNKNOWN;
|
|
127
|
+
return ENROLLMENT_STATES.UNKNOWN;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Which control server should this node use?
|
|
132
|
+
* Explicit argument → environment → configured secret → whatever the local
|
|
133
|
+
* client already points at. Never a compiled-in default.
|
|
134
|
+
*/
|
|
135
|
+
export function resolveLoginServer({ explicit, secretUrl, prefs } = {}) {
|
|
136
|
+
const candidates = [explicit, headscaleUrlFromEnv(), secretUrl, prefs?.ControlURL];
|
|
137
|
+
for (const candidate of candidates) {
|
|
138
|
+
const value = normalizeControlUrl(candidate || '');
|
|
139
|
+
if (value) return value;
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Full enrollment picture for the API/UI: local client state, whether an
|
|
146
|
+
* unattended enrollment is possible, and the interactive URL if one is pending.
|
|
147
|
+
*/
|
|
148
|
+
export async function getEnrollmentStatus({ brainDir } = {}) {
|
|
149
|
+
const status = readTailscaleStatus();
|
|
150
|
+
const prefs = readTailscalePrefs();
|
|
151
|
+
const state = classifyState(status, prefs);
|
|
152
|
+
const headscale = await describeHeadscaleAvailability(brainDir);
|
|
153
|
+
const loginServer = resolveLoginServer({ secretUrl: headscale.url, prefs });
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
state,
|
|
157
|
+
enrolled: state === ENROLLMENT_STATES.ENROLLED,
|
|
158
|
+
backend_state: status?.BackendState || null,
|
|
159
|
+
auth_url: status?.AuthURL || null,
|
|
160
|
+
ips: status?.TailscaleIPs || [],
|
|
161
|
+
hostname: status?.Self?.HostName || os.hostname(),
|
|
162
|
+
login_server: loginServer,
|
|
163
|
+
can_auto_enroll: headscale.configured && Boolean(loginServer),
|
|
164
|
+
auto_enroll_blocked_reason: headscale.configured ? null : headscale.reason,
|
|
165
|
+
auto_enroll_enabled: autoEnrollEnabled(),
|
|
166
|
+
client_available: Boolean(status),
|
|
167
|
+
checked_at: new Date().toISOString(),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Operators can disable unattended enrollment entirely. */
|
|
172
|
+
export function autoEnrollEnabled() {
|
|
173
|
+
const raw = String(process.env.TR_MESH_AUTO_ENROLL ?? '').trim().toLowerCase();
|
|
174
|
+
if (raw === '0' || raw === 'false' || raw === 'off') return false;
|
|
175
|
+
return true;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Write the pre-auth key to a 0600 temp file so it can be passed as
|
|
180
|
+
* `--auth-key file:<path>`. A key in argv is visible to every process on the
|
|
181
|
+
* box via `ps`; a key in a mode-0600 temp file is not.
|
|
182
|
+
*/
|
|
183
|
+
function withAuthKeyFile(key, fn) {
|
|
184
|
+
const file = path.join(
|
|
185
|
+
fs.mkdtempSync(path.join(os.tmpdir(), 'tr-enroll-')),
|
|
186
|
+
'authkey',
|
|
187
|
+
);
|
|
188
|
+
try {
|
|
189
|
+
fs.writeFileSync(file, key, { mode: 0o600 });
|
|
190
|
+
return fn(file);
|
|
191
|
+
} finally {
|
|
192
|
+
try {
|
|
193
|
+
fs.rmSync(path.dirname(file), { recursive: true, force: true });
|
|
194
|
+
} catch {
|
|
195
|
+
// best effort — the key expires on its own
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Build `tailscale up` arguments.
|
|
202
|
+
*
|
|
203
|
+
* `tailscale up` refuses to run if any flag is passed and an *unmentioned*
|
|
204
|
+
* setting would change from its current value to the flag default. So whenever
|
|
205
|
+
* we pass a flag at all, we must re-state this node's existing settings.
|
|
206
|
+
* We restate them explicitly (rather than conditionally) so a preference can
|
|
207
|
+
* never silently flip: joining the mesh must not reconfigure the user's node.
|
|
208
|
+
*
|
|
209
|
+
* `reset: true` is the escape hatch for a setting we do not model — it clears
|
|
210
|
+
* unmentioned settings to defaults, so the flags below are still re-stated.
|
|
211
|
+
*/
|
|
212
|
+
export function buildUpArgs({ loginServer, prefs, authKeyFile, reset = false }) {
|
|
213
|
+
const args = ['up'];
|
|
214
|
+
const current = normalizeControlUrl(prefs?.ControlURL || '');
|
|
215
|
+
const target = normalizeControlUrl(loginServer || '');
|
|
216
|
+
const repointing = Boolean(target) && target !== current;
|
|
217
|
+
|
|
218
|
+
// No flags at all — resuming an already-configured node — keeps every
|
|
219
|
+
// setting untouched and sidesteps the complete-set rule entirely.
|
|
220
|
+
if (!repointing && !authKeyFile && !reset) return args;
|
|
221
|
+
|
|
222
|
+
// `--reset` clears every unmentioned setting to its default, and ControlURL
|
|
223
|
+
// is one of them: without restating it here, a reset silently repoints the
|
|
224
|
+
// node at Tailscale SaaS and the control server rejects the pre-auth key.
|
|
225
|
+
if (repointing || (reset && target)) args.push(`--login-server=${target}`);
|
|
226
|
+
if (reset) args.push('--reset');
|
|
227
|
+
|
|
228
|
+
if (prefs) {
|
|
229
|
+
args.push(`--accept-routes=${prefs.RouteAll === true}`);
|
|
230
|
+
args.push(`--accept-dns=${prefs.CorpDNS !== false}`);
|
|
231
|
+
if (prefs.ShieldsUp === true) args.push('--shields-up');
|
|
232
|
+
if (prefs.RunSSH === true) args.push('--ssh');
|
|
233
|
+
if (prefs.Hostname) args.push(`--hostname=${prefs.Hostname}`);
|
|
234
|
+
if (Array.isArray(prefs.AdvertiseRoutes) && prefs.AdvertiseRoutes.length) {
|
|
235
|
+
args.push(`--advertise-routes=${prefs.AdvertiseRoutes.join(',')}`);
|
|
236
|
+
}
|
|
237
|
+
if (Array.isArray(prefs.AdvertiseTags) && prefs.AdvertiseTags.length) {
|
|
238
|
+
args.push(`--advertise-tags=${prefs.AdvertiseTags.join(',')}`);
|
|
239
|
+
}
|
|
240
|
+
if (prefs.ExitNodeIP) args.push(`--exit-node=${prefs.ExitNodeIP}`);
|
|
241
|
+
if (prefs.ExitNodeAllowLANAccess === true) args.push('--exit-node-allow-lan-access');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (authKeyFile) args.push(`--auth-key=file:${authKeyFile}`);
|
|
245
|
+
return args;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Does this `tailscale up` failure mean "you didn't restate every setting"? */
|
|
249
|
+
export function isIncompleteSettingsError(stderr = '') {
|
|
250
|
+
return /requires mentioning all\s*\n?non-default flags|--reset/i.test(String(stderr));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Run `tailscale up`, retrying once with `--reset` if the client rejects the
|
|
255
|
+
* command for an unmodeled non-default setting.
|
|
256
|
+
*/
|
|
257
|
+
function runUpWithResetFallback({ loginServer, prefs, authKeyFile }) {
|
|
258
|
+
const first = runTailscale(buildUpArgs({ loginServer, prefs, authKeyFile }), {
|
|
259
|
+
timeout: UP_TIMEOUT_MS,
|
|
260
|
+
});
|
|
261
|
+
if (first.ok || !isIncompleteSettingsError(first.stderr)) {
|
|
262
|
+
return { ...first, usedReset: false };
|
|
263
|
+
}
|
|
264
|
+
// Without a known control server a reset would repoint the node to the
|
|
265
|
+
// default SaaS control plane — worse than failing.
|
|
266
|
+
if (!normalizeControlUrl(loginServer || '')) {
|
|
267
|
+
return { ...first, usedReset: false };
|
|
268
|
+
}
|
|
269
|
+
const retry = runTailscale(buildUpArgs({ loginServer, prefs, authKeyFile, reset: true }), {
|
|
270
|
+
timeout: UP_TIMEOUT_MS,
|
|
271
|
+
});
|
|
272
|
+
return { ...retry, usedReset: true };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Ask the client to start an interactive login and return the URL it prints.
|
|
277
|
+
* The probe is killed on timeout; the pending registration stays valid on the
|
|
278
|
+
* control server, and the URL is then also readable from `status --json`.
|
|
279
|
+
*/
|
|
280
|
+
export function requestInteractiveAuthUrl({ loginServer } = {}) {
|
|
281
|
+
const args = ['login'];
|
|
282
|
+
const target = normalizeControlUrl(loginServer || '');
|
|
283
|
+
if (target) args.push(`--login-server=${target}`);
|
|
284
|
+
|
|
285
|
+
const res = runTailscale(args, { timeout: LOGIN_PROBE_TIMEOUT_MS });
|
|
286
|
+
const combined = `${res.stdout}\n${res.stderr}`;
|
|
287
|
+
const match = combined.match(/https?:\/\/\S+/);
|
|
288
|
+
if (match) return match[0];
|
|
289
|
+
return readTailscaleStatus()?.AuthURL || null;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Bring this node onto the mesh.
|
|
294
|
+
*
|
|
295
|
+
* Unattended when a Headscale credential exists; otherwise returns the
|
|
296
|
+
* interactive URL for the user to approve. Never throws — enrollment runs on a
|
|
297
|
+
* daemon heartbeat and must not take the loop down.
|
|
298
|
+
*/
|
|
299
|
+
export async function enrollThisNode({ brainDir, loginServer: explicitServer, user, force } = {}) {
|
|
300
|
+
const before = await getEnrollmentStatus({ brainDir });
|
|
301
|
+
|
|
302
|
+
if (before.enrolled && !force) {
|
|
303
|
+
return { ok: true, changed: false, state: before.state, status: before, reason: 'already-enrolled' };
|
|
304
|
+
}
|
|
305
|
+
if (!before.client_available) {
|
|
306
|
+
return {
|
|
307
|
+
ok: false,
|
|
308
|
+
changed: false,
|
|
309
|
+
state: before.state,
|
|
310
|
+
status: before,
|
|
311
|
+
reason: 'tailscale-client-unavailable',
|
|
312
|
+
hint: 'Install Tailscale and start tailscaled, or set TR_TAILSCALE_BIN to the client path.',
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const prefs = readTailscalePrefs();
|
|
317
|
+
|
|
318
|
+
// Already authenticated, just not running — no key needed.
|
|
319
|
+
if (before.state === ENROLLMENT_STATES.STOPPED) {
|
|
320
|
+
const res = runTailscale(buildUpArgs({ prefs, loginServer: null }), { timeout: UP_TIMEOUT_MS });
|
|
321
|
+
const after = await getEnrollmentStatus({ brainDir });
|
|
322
|
+
return {
|
|
323
|
+
ok: res.ok && after.enrolled,
|
|
324
|
+
changed: after.enrolled,
|
|
325
|
+
state: after.state,
|
|
326
|
+
status: after,
|
|
327
|
+
method: 'resume',
|
|
328
|
+
reason: res.ok ? null : (res.stderr || '').trim().slice(0, 300) || 'tailscale-up-failed',
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const headscale = await describeHeadscaleAvailability(brainDir);
|
|
333
|
+
const target = resolveLoginServer({ explicit: explicitServer, secretUrl: headscale.url, prefs });
|
|
334
|
+
|
|
335
|
+
if (headscale.configured && target) {
|
|
336
|
+
try {
|
|
337
|
+
// Resolve once so the client can try id then name across API versions.
|
|
338
|
+
const userRef = await resolveHeadscaleUser(brainDir, user);
|
|
339
|
+
const keyUser = userRef?.name || user || 'default';
|
|
340
|
+
const { key } = await createHeadscalePreAuthKey({ userRef, ttlMinutes: 10 }, brainDir);
|
|
341
|
+
const res = withAuthKeyFile(key, (authKeyFile) =>
|
|
342
|
+
runUpWithResetFallback({ loginServer: target, prefs, authKeyFile }),
|
|
343
|
+
);
|
|
344
|
+
const after = await getEnrollmentStatus({ brainDir });
|
|
345
|
+
if (res.ok && after.enrolled) {
|
|
346
|
+
return {
|
|
347
|
+
ok: true,
|
|
348
|
+
changed: true,
|
|
349
|
+
state: after.state,
|
|
350
|
+
status: after,
|
|
351
|
+
method: 'preauth-key',
|
|
352
|
+
user: keyUser,
|
|
353
|
+
used_reset: res.usedReset === true,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
return {
|
|
357
|
+
ok: false,
|
|
358
|
+
changed: false,
|
|
359
|
+
state: after.state,
|
|
360
|
+
status: after,
|
|
361
|
+
method: 'preauth-key',
|
|
362
|
+
used_reset: res.usedReset === true,
|
|
363
|
+
reason: (res.stderr || '').trim().slice(0, 300) || 'tailscale-up-did-not-connect',
|
|
364
|
+
};
|
|
365
|
+
} catch (err) {
|
|
366
|
+
// Fall through to the interactive path — a broken API key should not
|
|
367
|
+
// leave the user with no way to enroll.
|
|
368
|
+
logger.info('mesh-enroll', `Pre-auth enrollment failed, falling back to interactive: ${err.message}`);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const authUrl = before.auth_url || requestInteractiveAuthUrl({ loginServer: target });
|
|
373
|
+
const after = await getEnrollmentStatus({ brainDir });
|
|
374
|
+
return {
|
|
375
|
+
ok: false,
|
|
376
|
+
changed: false,
|
|
377
|
+
state: after.state,
|
|
378
|
+
status: { ...after, auth_url: authUrl || after.auth_url },
|
|
379
|
+
method: 'interactive',
|
|
380
|
+
auth_url: authUrl || after.auth_url,
|
|
381
|
+
reason: headscale.configured ? 'awaiting-user-approval' : headscale.reason,
|
|
382
|
+
hint: headscale.configured
|
|
383
|
+
? 'Approve the registration URL to finish joining the mesh.'
|
|
384
|
+
: 'Add a Headscale API key (provider: headscale) with its control-server URL to enroll automatically.',
|
|
385
|
+
};
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Daemon entry point: enroll if needed, throttled, never throwing.
|
|
390
|
+
* Returns a skip descriptor when there is nothing to do.
|
|
391
|
+
*/
|
|
392
|
+
export async function ensureEnrolled({ brainDir, now = Date.now() } = {}) {
|
|
393
|
+
if (!autoEnrollEnabled()) {
|
|
394
|
+
return { skipped: true, reason: 'auto-enroll-disabled' };
|
|
395
|
+
}
|
|
396
|
+
if (lastAutoAttemptAt && now - lastAutoAttemptAt < AUTO_RETRY_INTERVAL_MS) {
|
|
397
|
+
return { skipped: true, reason: 'throttled', last_result: lastAutoResult };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
try {
|
|
401
|
+
const status = await getEnrollmentStatus({ brainDir });
|
|
402
|
+
if (status.enrolled) {
|
|
403
|
+
lastAutoResult = { ok: true, state: status.state };
|
|
404
|
+
return { skipped: true, reason: 'already-enrolled', state: status.state };
|
|
405
|
+
}
|
|
406
|
+
if (!status.client_available) {
|
|
407
|
+
lastAutoAttemptAt = now;
|
|
408
|
+
lastAutoResult = { ok: false, state: status.state };
|
|
409
|
+
return { skipped: true, reason: 'tailscale-client-unavailable', state: status.state };
|
|
410
|
+
}
|
|
411
|
+
if (!status.can_auto_enroll && status.state !== ENROLLMENT_STATES.STOPPED) {
|
|
412
|
+
// Nothing unattended is possible; the dashboard shows the interactive URL.
|
|
413
|
+
lastAutoAttemptAt = now;
|
|
414
|
+
lastAutoResult = { ok: false, state: status.state };
|
|
415
|
+
return {
|
|
416
|
+
skipped: true,
|
|
417
|
+
reason: status.auto_enroll_blocked_reason || 'no-automatic-path',
|
|
418
|
+
state: status.state,
|
|
419
|
+
auth_url: status.auth_url,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
lastAutoAttemptAt = now;
|
|
424
|
+
const result = await enrollThisNode({ brainDir });
|
|
425
|
+
lastAutoResult = { ok: result.ok, state: result.state };
|
|
426
|
+
logger.info(
|
|
427
|
+
'mesh-enroll',
|
|
428
|
+
result.ok
|
|
429
|
+
? `Node enrolled on the mesh via ${result.method || 'unknown'} (state=${result.state})`
|
|
430
|
+
: `Automatic enrollment incomplete: ${result.reason || 'unknown'} (state=${result.state})`,
|
|
431
|
+
);
|
|
432
|
+
return result;
|
|
433
|
+
} catch (err) {
|
|
434
|
+
lastAutoAttemptAt = now;
|
|
435
|
+
lastAutoResult = { ok: false, error: err.message };
|
|
436
|
+
logger.info('mesh-enroll', `Automatic enrollment error: ${err.message}`);
|
|
437
|
+
return { skipped: true, reason: 'error', error: err.message };
|
|
438
|
+
}
|
|
439
|
+
}
|