total-recall-brain 3.25.6 → 3.26.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-DSxCyPAx.js → index-Bl3I11B0.js} +85 -85
- package/frontend/dist/index.html +1 -1
- package/frontend/src/api/mesh.ts +32 -0
- package/frontend/src/components/MobilePairing.spec.tsx +89 -0
- package/frontend/src/components/MobilePairing.tsx +72 -1
- package/frontend/src/components/TailnetEnroll.spec.tsx +29 -1
- package/frontend/src/components/TailnetEnroll.tsx +85 -3
- package/package.json +1 -1
- package/src/core/bound-hosts.mjs +64 -0
- package/src/core/mesh-late-bind.mjs +105 -0
- package/src/core/pairing.mjs +49 -10
- package/src/core/registration-watch.mjs +193 -0
- package/src/core/secrets-store.mjs +8 -0
- package/src/server/auth.mjs +11 -1
- package/src/server/index.mjs +35 -0
- package/src/server/route-manifest.json +12 -0
- package/src/server/routes/auth.mjs +31 -0
- package/src/server/routes/mesh.mjs +55 -0
- package/src/server/routes/secrets.mjs +1 -0
- package/src/server/routes/system.mjs +5 -0
package/src/core/pairing.mjs
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Mobile pairing endpoints — reachable URLs for QR codes.
|
|
3
3
|
* Never hardcodes personal hostnames; derives from live interfaces + server port.
|
|
4
|
+
*
|
|
5
|
+
* `listenHosts` MUST be the addresses the server actually bound. This used to be
|
|
6
|
+
* derived as "loopback plus whatever getMeshIp() says right now", which is a
|
|
7
|
+
* guess that silently fabricates a binding: a brain that lost the boot race
|
|
8
|
+
* against the mesh client is listening on loopback alone, yet the derivation
|
|
9
|
+
* claimed the mesh address too. The card then recommended that address and drew
|
|
10
|
+
* a QR code for it, with no warning, because every reachability check below
|
|
11
|
+
* reads these hosts. One machine served that dead QR for a week.
|
|
12
|
+
*
|
|
13
|
+
* When the caller cannot observe the real bindings the result is still returned,
|
|
14
|
+
* but flagged `listen_hosts_source: 'derived'` and `reachable_from_other_devices:
|
|
15
|
+
* null` — unknown, which the UI must not render as working.
|
|
4
16
|
*/
|
|
5
17
|
import { listLocalInterfaces, isLanIpv4, isOverlayIpv4 } from './network-interfaces.mjs';
|
|
6
18
|
import { getMeshIp } from './mesh.mjs';
|
|
@@ -21,9 +33,9 @@ export function buildPairingInfo(opts = {}) {
|
|
|
21
33
|
? opts.listInterfaces()
|
|
22
34
|
: listLocalInterfaces();
|
|
23
35
|
const meshIp = opts.meshIp !== undefined ? opts.meshIp : getMeshIp();
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
36
|
+
const observedHosts = Array.isArray(opts.listenHosts) && opts.listenHosts.length > 0;
|
|
37
|
+
const listenHosts = observedHosts ? opts.listenHosts : deriveListenHosts();
|
|
38
|
+
const listenHostsSource = observedHosts ? 'actual' : 'derived';
|
|
27
39
|
|
|
28
40
|
/** @type {Array<{ kind: string, label: string, ip: string, url: string, interface?: string, recommended: boolean, reachable_hint: string }>} */
|
|
29
41
|
const endpoints = [];
|
|
@@ -41,6 +53,8 @@ export function buildPairingInfo(opts = {}) {
|
|
|
41
53
|
interface: ifaceName || null,
|
|
42
54
|
recommended: !!recommended,
|
|
43
55
|
reachable_hint: hint,
|
|
56
|
+
// Filled in below, once listenHosts is known to be observed or guessed.
|
|
57
|
+
listening: null,
|
|
44
58
|
});
|
|
45
59
|
};
|
|
46
60
|
|
|
@@ -143,10 +157,32 @@ export function buildPairingInfo(opts = {}) {
|
|
|
143
157
|
warnings.push(`Mesh IP ${meshIp} is present but may not be the active listen address.`);
|
|
144
158
|
}
|
|
145
159
|
|
|
160
|
+
// Only claim a per-endpoint verdict when the bindings were observed. A guess
|
|
161
|
+
// that renders as a green tick is worse than no tick at all.
|
|
162
|
+
for (const e of endpoints) {
|
|
163
|
+
e.listening = observedHosts ? isListening(e.ip) : null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const reachable = observedHosts
|
|
167
|
+
? listenHosts.some((h) => h !== '127.0.0.1' && h !== '::1' && h !== 'localhost')
|
|
168
|
+
: null;
|
|
169
|
+
|
|
170
|
+
if (reachable === false) {
|
|
171
|
+
// The headline problem, stated first: no QR on this card can work.
|
|
172
|
+
warnings.unshift(
|
|
173
|
+
`This brain is listening on loopback only (${listenHosts.join(', ')}), so no other `
|
|
174
|
+
+ 'device can open its UI — a phone scanning any code here will fail to connect. '
|
|
175
|
+
+ 'This usually means the mesh client was still starting when the brain launched. '
|
|
176
|
+
+ 'Restart the brain once the mesh is up, or set a bind host, and this resolves.',
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
146
180
|
return {
|
|
147
181
|
port,
|
|
148
182
|
protocol,
|
|
149
183
|
listen_hosts: listenHosts,
|
|
184
|
+
listen_hosts_source: listenHostsSource,
|
|
185
|
+
reachable_from_other_devices: reachable,
|
|
150
186
|
preferred_url: preferred?.url || null,
|
|
151
187
|
endpoints,
|
|
152
188
|
warnings,
|
|
@@ -154,15 +190,18 @@ export function buildPairingInfo(opts = {}) {
|
|
|
154
190
|
};
|
|
155
191
|
}
|
|
156
192
|
|
|
157
|
-
|
|
193
|
+
/**
|
|
194
|
+
* Last-resort guess for callers that cannot observe the real sockets.
|
|
195
|
+
*
|
|
196
|
+
* Deliberately does NOT include the mesh address. `getMeshIp()` reporting one
|
|
197
|
+
* means the mesh client is up *now* — it says nothing about whether this
|
|
198
|
+
* process bound it, and assuming so is exactly the bug this module documents.
|
|
199
|
+
* Only an explicitly configured host is added, because that one was at least
|
|
200
|
+
* requested.
|
|
201
|
+
*/
|
|
202
|
+
function deriveListenHosts() {
|
|
158
203
|
const hosts = new Set(['127.0.0.1']);
|
|
159
|
-
if (meshIp) hosts.add(meshIp);
|
|
160
204
|
const envHost = process.env.HOST || process.env.TR_HOST;
|
|
161
205
|
if (envHost) hosts.add(envHost);
|
|
162
|
-
// Production dual-bind pattern in server/index.mjs
|
|
163
|
-
if (meshIp && meshIp !== '127.0.0.1') {
|
|
164
|
-
hosts.add(meshIp);
|
|
165
|
-
hosts.add('127.0.0.1');
|
|
166
|
-
}
|
|
167
206
|
return [...hosts];
|
|
168
207
|
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* registration-watch — arm the server to approve the next device that asks.
|
|
3
|
+
*
|
|
4
|
+
* The pairing UI was built backwards: it minted a key, drew a QR nobody can
|
|
5
|
+
* scan (no Tailscale client scans QR codes to authenticate — tailscale#8267,
|
|
6
|
+
* #13377, #14184 are still open feature requests), and asked the user to carry
|
|
7
|
+
* a 40-character secret from a laptop to a phone by hand.
|
|
8
|
+
*
|
|
9
|
+
* The direction that actually works is the reverse. The device generates a
|
|
10
|
+
* registration id and waits; the server approves it. Done as a watcher this is
|
|
11
|
+
* a single button: arm it, sign in on the phone, and it connects with nothing
|
|
12
|
+
* typed or copied anywhere.
|
|
13
|
+
*
|
|
14
|
+
* The constraint is that headscale exposes no pending-registration API — the
|
|
15
|
+
* entry lives in an in-memory cache and is visible only in the server's log
|
|
16
|
+
* output. So watch mode needs a log source, and where none is configured this
|
|
17
|
+
* module reports `unavailable` rather than pretending: the UI then falls back
|
|
18
|
+
* to pasting the id by hand.
|
|
19
|
+
*
|
|
20
|
+
* Nothing here names a host. The log command comes from configuration.
|
|
21
|
+
*/
|
|
22
|
+
import { execFile } from 'node:child_process';
|
|
23
|
+
import { logger } from './logger.mjs';
|
|
24
|
+
|
|
25
|
+
/** headscale prints the id in the register URL; this is the only shape we need. */
|
|
26
|
+
const AUTH_ID = /hskey-authreq-[A-Za-z0-9._-]+/g;
|
|
27
|
+
|
|
28
|
+
const DEFAULT_TTL_MS = 5 * 60 * 1000;
|
|
29
|
+
const POLL_MS = 2000;
|
|
30
|
+
// The registration cache entry only lives while the client holds the request
|
|
31
|
+
// open, so a slow poll is the difference between catching it and missing it.
|
|
32
|
+
const MAX_TTL_MS = 15 * 60 * 1000;
|
|
33
|
+
|
|
34
|
+
/** @type {{state: string, since: number, expiresAt: number, id: string|null, node: object|null, error: string|null, timer: any}|null} */
|
|
35
|
+
let current = null;
|
|
36
|
+
// The terminal result must outlive the watch. The UI learns the outcome by
|
|
37
|
+
// polling, so discarding it on stop meant a device could join and the page
|
|
38
|
+
// would only ever see 'idle' -- a success nobody is told about.
|
|
39
|
+
let last = null;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Where to read headscale's log output from.
|
|
43
|
+
*
|
|
44
|
+
* Resolution order, all configuration — never a hardcoded host:
|
|
45
|
+
* 1. TR_HEADSCALE_LOG_CMD — an explicit shell command
|
|
46
|
+
* 2. the `headscale_log_command` field on the stored headscale API key
|
|
47
|
+
*
|
|
48
|
+
* @returns {Promise<{cmd: string} | null>}
|
|
49
|
+
*/
|
|
50
|
+
export async function resolveLogSource(brainDir, { findMeta } = {}) {
|
|
51
|
+
const fromEnv = (process.env.TR_HEADSCALE_LOG_CMD || '').trim();
|
|
52
|
+
if (fromEnv) return { cmd: fromEnv, origin: 'TR_HEADSCALE_LOG_CMD' };
|
|
53
|
+
try {
|
|
54
|
+
const lookup = findMeta || (await import('./headscale-client.mjs')).findHeadscaleKeyMeta;
|
|
55
|
+
const meta = await lookup(brainDir);
|
|
56
|
+
const cmd = (meta?.headscale_log_command || '').trim();
|
|
57
|
+
if (cmd) return { cmd, origin: 'headscale key config' };
|
|
58
|
+
} catch {
|
|
59
|
+
/* not configured */
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Run the configured log command and return its output. */
|
|
65
|
+
function readLog(cmd, { timeoutMs = 8000 } = {}) {
|
|
66
|
+
return new Promise((resolve) => {
|
|
67
|
+
// Shell form is required: operators configure things like
|
|
68
|
+
// `ssh host 'docker logs --since 2m headscale'`.
|
|
69
|
+
execFile('/bin/sh', ['-c', cmd], { timeout: timeoutMs, maxBuffer: 8 * 1024 * 1024 }, (err, stdout, stderr) => {
|
|
70
|
+
if (err && !stdout) return resolve({ ok: false, error: (stderr || err.message || '').slice(0, 300) });
|
|
71
|
+
resolve({ ok: true, text: `${stdout || ''}\n${stderr || ''}` });
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Newest registration id in a chunk of log text, or null. */
|
|
77
|
+
export function latestAuthId(text) {
|
|
78
|
+
const found = String(text || '').match(AUTH_ID);
|
|
79
|
+
return found && found.length ? found[found.length - 1] : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function getWatchStatus() {
|
|
83
|
+
if (!current) return last || { state: 'idle' };
|
|
84
|
+
const { timer, ...rest } = current;
|
|
85
|
+
return { ...rest, remaining_ms: Math.max(0, current.expiresAt - Date.now()) };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Forget the last terminal result, so a new watch starts from a clean slate. */
|
|
89
|
+
export function clearWatchResult() {
|
|
90
|
+
last = null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function stopWatch(state = 'stopped') {
|
|
94
|
+
if (!current) {
|
|
95
|
+
const prior = last || { state: 'idle' };
|
|
96
|
+
last = null;
|
|
97
|
+
return prior.state === 'idle' ? prior : { ...prior, state: 'stopped' };
|
|
98
|
+
}
|
|
99
|
+
clearTimeout(current.timer);
|
|
100
|
+
const { timer, ...rest } = current;
|
|
101
|
+
const final = { ...rest, state, remaining_ms: 0 };
|
|
102
|
+
current = null;
|
|
103
|
+
// Keep registered/expired so the UI can report it; a manual stop is not news.
|
|
104
|
+
last = state === 'stopped' ? null : final;
|
|
105
|
+
return final;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Arm the watcher.
|
|
110
|
+
*
|
|
111
|
+
* @param {object} opts
|
|
112
|
+
* @param {string} opts.brainDir
|
|
113
|
+
* @param {number} [opts.ttlMs]
|
|
114
|
+
* @param {(id: string) => Promise<object>} opts.register approve one id
|
|
115
|
+
* @param {() => Promise<{cmd: string}|null>} [opts.logSource]
|
|
116
|
+
*/
|
|
117
|
+
export async function startWatch({ brainDir, ttlMs, register, logSource } = {}) {
|
|
118
|
+
if (current) return { ...getWatchStatus(), already_running: true };
|
|
119
|
+
last = null;
|
|
120
|
+
|
|
121
|
+
const resolve = logSource || (() => resolveLogSource(brainDir));
|
|
122
|
+
const source = await resolve();
|
|
123
|
+
if (!source) {
|
|
124
|
+
return {
|
|
125
|
+
state: 'unavailable',
|
|
126
|
+
error:
|
|
127
|
+
'Watch mode needs to read the headscale server log, and no log source is configured. '
|
|
128
|
+
+ 'headscale has no pending-registration API, so there is nowhere else to see a device waiting. '
|
|
129
|
+
+ 'Set TR_HEADSCALE_LOG_CMD (for example a command that tails the headscale service on its host), '
|
|
130
|
+
+ 'or paste the code from the device instead.',
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const ttl = Math.min(Math.max(Number(ttlMs) || DEFAULT_TTL_MS, 30_000), MAX_TTL_MS);
|
|
135
|
+
// Ids already in the log are from earlier attempts. headscale has forgotten
|
|
136
|
+
// them, so approving one always fails -- and worse, it would consume the
|
|
137
|
+
// watch and report success on a device that never connected.
|
|
138
|
+
const seed = await readLog(source.cmd);
|
|
139
|
+
const seen = new Set();
|
|
140
|
+
if (seed.ok) {
|
|
141
|
+
for (const id of String(seed.text).match(AUTH_ID) || []) seen.add(id);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
current = {
|
|
145
|
+
state: 'watching',
|
|
146
|
+
since: Date.now(),
|
|
147
|
+
expiresAt: Date.now() + ttl,
|
|
148
|
+
id: null,
|
|
149
|
+
node: null,
|
|
150
|
+
error: seed.ok ? null : `log source failed: ${seed.error}`,
|
|
151
|
+
source: source.origin,
|
|
152
|
+
ignored_existing: seen.size,
|
|
153
|
+
timer: null,
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const tick = async () => {
|
|
157
|
+
if (!current || current.state !== 'watching') return;
|
|
158
|
+
if (Date.now() >= current.expiresAt) {
|
|
159
|
+
logger.info('registration-watch', 'watch expired with no new device');
|
|
160
|
+
stopWatch('expired');
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
const out = await readLog(source.cmd);
|
|
164
|
+
if (out.ok) {
|
|
165
|
+
const ids = (String(out.text).match(AUTH_ID) || []).filter((id) => !seen.has(id));
|
|
166
|
+
if (ids.length) {
|
|
167
|
+
const id = ids[ids.length - 1];
|
|
168
|
+
seen.add(id);
|
|
169
|
+
current.id = id;
|
|
170
|
+
try {
|
|
171
|
+
const node = await register(id);
|
|
172
|
+
current.node = node;
|
|
173
|
+
logger.info('registration-watch', `approved ${node?.name || 'device'} from watch`);
|
|
174
|
+
stopWatch('registered');
|
|
175
|
+
return;
|
|
176
|
+
} catch (err) {
|
|
177
|
+
// A failure here is usually the cache having already dropped it.
|
|
178
|
+
// Keep watching: the next sign-in attempt produces a fresh id.
|
|
179
|
+
current.error = err?.message || 'register failed';
|
|
180
|
+
logger.warn('registration-watch', `approve failed for ${id}: ${current.error}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
} else {
|
|
184
|
+
current.error = `log source failed: ${out.error}`;
|
|
185
|
+
}
|
|
186
|
+
current.timer = setTimeout(tick, POLL_MS);
|
|
187
|
+
current.timer.unref?.();
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
current.timer = setTimeout(tick, POLL_MS);
|
|
191
|
+
current.timer.unref?.();
|
|
192
|
+
return getWatchStatus();
|
|
193
|
+
}
|
|
@@ -314,6 +314,11 @@ export function mergeSecretMeta(prev = {}, patch = {}) {
|
|
|
314
314
|
assign('notes', (v) => (v == null || v === '' ? null : String(v)));
|
|
315
315
|
assign('project_path', (v) => (v == null || v === '' ? null : String(v)));
|
|
316
316
|
assign('headscale_url', (v) => (v == null || v === '' ? null : String(v)));
|
|
317
|
+
// Command that prints the headscale server log. Watch mode needs it because
|
|
318
|
+
// headscale exposes no pending-registration API -- a device waiting to be
|
|
319
|
+
// approved is visible only in the log. Kept as config so no host is ever
|
|
320
|
+
// named in code.
|
|
321
|
+
assign('headscale_log_command', (v) => (v == null || v === '' ? null : String(v)));
|
|
317
322
|
// Live provider account / usage / subscription tracking (provider-account-sync)
|
|
318
323
|
assign('tracking_exempt', (v) => !!v);
|
|
319
324
|
assign('tracking_status', (v) => {
|
|
@@ -523,6 +528,7 @@ export async function listSecretsMeta(brainDir) {
|
|
|
523
528
|
updated_at: m.updated_at || null,
|
|
524
529
|
rotated_at: m.rotated_at || null,
|
|
525
530
|
headscale_url: m.headscale_url || null,
|
|
531
|
+
headscale_log_command: m.headscale_log_command || null,
|
|
526
532
|
usage_30d: usage,
|
|
527
533
|
tiers: catalog?.tiers || [],
|
|
528
534
|
// Provider account/usage tracking (must be ok or exempt — else error)
|
|
@@ -762,6 +768,8 @@ export async function setSecret(brainDir, key, value, opts = {}) {
|
|
|
762
768
|
monthly_cap_usd: opts.monthly_cap_usd !== undefined ? opts.monthly_cap_usd : prev.monthly_cap_usd,
|
|
763
769
|
api_docs_url: opts.api_docs_url !== undefined ? opts.api_docs_url : prev.api_docs_url,
|
|
764
770
|
headscale_url: opts.headscale_url !== undefined ? opts.headscale_url : prev.headscale_url,
|
|
771
|
+
headscale_log_command:
|
|
772
|
+
opts.headscale_log_command !== undefined ? opts.headscale_log_command : prev.headscale_log_command,
|
|
765
773
|
rotate_every_days: opts.rotate_every_days !== undefined ? opts.rotate_every_days : prev.rotate_every_days,
|
|
766
774
|
auto_rotate: opts.auto_rotate !== undefined ? opts.auto_rotate : prev.auto_rotate,
|
|
767
775
|
notes: opts.notes !== undefined ? opts.notes : prev.notes,
|
package/src/server/auth.mjs
CHANGED
|
@@ -36,7 +36,17 @@ export function loadSecurityConfig() {
|
|
|
36
36
|
dashboard: {},
|
|
37
37
|
api: { pats: [], allow_static_pats: false },
|
|
38
38
|
network: { require_https: true, public_health: false, allowed_origins: [], trusted_proxies: [] },
|
|
39
|
-
|
|
39
|
+
// No `host` here, deliberately. This default used to say '127.0.0.1',
|
|
40
|
+
// which `resolveServerHost()` cannot tell apart from a user who typed
|
|
41
|
+
// that address on purpose — configuredHost wins over meshIp, so a mesh
|
|
42
|
+
// address was discovered and then thrown away. Every install without a
|
|
43
|
+
// config file bound loopback only, permanently and silently: the brain
|
|
44
|
+
// answered fine on its own console and was invisible to every other
|
|
45
|
+
// device, while the pairing card went on recommending the mesh URL.
|
|
46
|
+
// Leaving it unset lets the precedence do its job — explicit config,
|
|
47
|
+
// else mesh, else loopback. Public binds are still gated by
|
|
48
|
+
// allow_public_bind below.
|
|
49
|
+
bind: { port: 3000, allow_public_bind: false },
|
|
40
50
|
rate_limits: { api_requests_per_minute: 1200 },
|
|
41
51
|
sandbox: { enabled: false }
|
|
42
52
|
};
|
package/src/server/index.mjs
CHANGED
|
@@ -783,6 +783,12 @@ const serverSecurityConfig = loadSecurityConfig();
|
|
|
783
783
|
const PORT = configPort || serverSecurityConfig.bind?.port || 3000;
|
|
784
784
|
import { getMeshIp } from '../core/mesh.mjs';
|
|
785
785
|
import { resolveServerHost } from '../core/network-bind.mjs';
|
|
786
|
+
import {
|
|
787
|
+
registerBoundHost,
|
|
788
|
+
getBoundHosts,
|
|
789
|
+
isReachableFromOtherDevices,
|
|
790
|
+
} from '../core/bound-hosts.mjs';
|
|
791
|
+
import { startMeshBindWatch } from '../core/mesh-late-bind.mjs';
|
|
786
792
|
|
|
787
793
|
const meshIp = getMeshIp();
|
|
788
794
|
const bindResolution = resolveServerHost({
|
|
@@ -835,10 +841,37 @@ try {
|
|
|
835
841
|
|
|
836
842
|
const server = app.listen(PORT, HOST, () => {
|
|
837
843
|
setupUpgradeHandler(server);
|
|
844
|
+
registerBoundHost(HOST);
|
|
838
845
|
if (HOST !== configuredHost) {
|
|
839
846
|
logger.error("server", `Refusing public bind '${configuredHost}' in production. Bound to ${HOST}.`);
|
|
840
847
|
}
|
|
841
848
|
logger.info("server", `Total Recall Brain v3.0.0 is listening on http://${HOST}:${PORT}`);
|
|
849
|
+
|
|
850
|
+
// Only meaningful once the socket is up: asking before the callback fires
|
|
851
|
+
// reads an empty set, which is "unknown", so the watch never armed.
|
|
852
|
+
if (isReachableFromOtherDevices() === false) {
|
|
853
|
+
logger.warn(
|
|
854
|
+
'server',
|
|
855
|
+
`Bound to loopback only — no other device can reach this brain at :${PORT}. `
|
|
856
|
+
+ 'Watching for a mesh address in case the mesh client is still starting.',
|
|
857
|
+
);
|
|
858
|
+
startMeshBindWatch({
|
|
859
|
+
getMeshIp,
|
|
860
|
+
boundHosts: getBoundHosts,
|
|
861
|
+
logger,
|
|
862
|
+
// Never fatal: loopback is already serving, so a failure here is the
|
|
863
|
+
// status quo rather than an outage.
|
|
864
|
+
bind: (ip) =>
|
|
865
|
+
new Promise((resolve, reject) => {
|
|
866
|
+
const extra = app.listen(PORT, ip, () => {
|
|
867
|
+
setupUpgradeHandler(extra);
|
|
868
|
+
registerBoundHost(ip);
|
|
869
|
+
resolve();
|
|
870
|
+
});
|
|
871
|
+
extra.once('error', reject);
|
|
872
|
+
}),
|
|
873
|
+
});
|
|
874
|
+
}
|
|
842
875
|
});
|
|
843
876
|
server.on('error', (err) => {
|
|
844
877
|
logger.error('server', `Failed to bind ${HOST}:${PORT}: ${err.message}. Exiting.`);
|
|
@@ -850,6 +883,7 @@ server.on('error', (err) => {
|
|
|
850
883
|
if (HOST !== '127.0.0.1' && HOST !== '0.0.0.0') {
|
|
851
884
|
const localServer = app.listen(PORT, '127.0.0.1', () => {
|
|
852
885
|
setupUpgradeHandler(localServer);
|
|
886
|
+
registerBoundHost('127.0.0.1');
|
|
853
887
|
logger.info("server", `Total Recall Brain v3.0.0 is ALSO listening on http://127.0.0.1:${PORT}`);
|
|
854
888
|
});
|
|
855
889
|
localServer.on('error', (err) => {
|
|
@@ -859,6 +893,7 @@ if (HOST !== '127.0.0.1' && HOST !== '0.0.0.0') {
|
|
|
859
893
|
});
|
|
860
894
|
}
|
|
861
895
|
|
|
896
|
+
|
|
862
897
|
logger.info("server", "┌─────────────────────────────────────────────┐");
|
|
863
898
|
logger.info("server", "│ Total Recall Brain v3.0.0 │");
|
|
864
899
|
logger.info("server", "│ │");
|
|
@@ -355,6 +355,18 @@
|
|
|
355
355
|
"method": "POST",
|
|
356
356
|
"path": "/api/mesh/register-node"
|
|
357
357
|
},
|
|
358
|
+
{
|
|
359
|
+
"method": "DELETE",
|
|
360
|
+
"path": "/api/mesh/watch"
|
|
361
|
+
},
|
|
362
|
+
{
|
|
363
|
+
"method": "GET",
|
|
364
|
+
"path": "/api/mesh/watch"
|
|
365
|
+
},
|
|
366
|
+
{
|
|
367
|
+
"method": "POST",
|
|
368
|
+
"path": "/api/mesh/watch"
|
|
369
|
+
},
|
|
358
370
|
{
|
|
359
371
|
"method": "GET",
|
|
360
372
|
"path": "/api/network/audit"
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import express from 'express';
|
|
2
2
|
import {
|
|
3
3
|
requireAuth,
|
|
4
|
+
isLocalRequest,
|
|
4
5
|
loadSecurityConfig,
|
|
5
6
|
loginHandler,
|
|
6
7
|
logoutHandler,
|
|
7
8
|
changePasswordHandler,
|
|
8
9
|
} from '../auth.mjs';
|
|
10
|
+
import { logger } from '../../core/logger.mjs';
|
|
9
11
|
|
|
10
12
|
export const authRouter = express.Router();
|
|
11
13
|
|
|
@@ -22,11 +24,40 @@ authRouter.get("/auth/status", (req, res) => {
|
|
|
22
24
|
res.json({ configured });
|
|
23
25
|
});
|
|
24
26
|
|
|
27
|
+
/**
|
|
28
|
+
* First-run: claim an unconfigured brain by setting its dashboard password.
|
|
29
|
+
*
|
|
30
|
+
* Unauthenticated by necessity -- there is no credential to present yet. That
|
|
31
|
+
* makes "who may call this" the only control, and it must be the machine the
|
|
32
|
+
* brain runs on.
|
|
33
|
+
*
|
|
34
|
+
* This was safe by accident for as long as a brain with no security.yml bound
|
|
35
|
+
* loopback only. Once such a brain binds its mesh address, every device on the
|
|
36
|
+
* tailnet can reach this route, and the first one to call it owns the brain --
|
|
37
|
+
* including a phone that later gets lost, or any node someone else was invited
|
|
38
|
+
* onto. A first-run window is exactly when there is nothing else to stop them.
|
|
39
|
+
*
|
|
40
|
+
* Headless hosts set the password over the CLI, which is already the documented
|
|
41
|
+
* path for a machine with no browser on it.
|
|
42
|
+
*/
|
|
25
43
|
authRouter.post("/auth/setup", async (req, res) => {
|
|
26
44
|
const config = loadSecurityConfig();
|
|
27
45
|
const alreadyConfigured = !!config.dashboard?.password_hash;
|
|
28
46
|
if (alreadyConfigured) {
|
|
29
47
|
return res.status(400).json({ error: "Dashboard password is already configured" });
|
|
30
48
|
}
|
|
49
|
+
if (!isLocalRequest(req)) {
|
|
50
|
+
logger.warn(
|
|
51
|
+
'auth',
|
|
52
|
+
`Refused remote first-run setup from ${req.ip || req.socket?.remoteAddress || 'unknown'} `
|
|
53
|
+
+ '— an unconfigured brain may only be claimed from the machine it runs on.',
|
|
54
|
+
);
|
|
55
|
+
return res.status(403).json({
|
|
56
|
+
error:
|
|
57
|
+
'First-run setup must come from the machine running this brain. '
|
|
58
|
+
+ 'On a headless host, set the password with the CLI instead: '
|
|
59
|
+
+ 'npx total-recall reset-password',
|
|
60
|
+
});
|
|
61
|
+
}
|
|
31
62
|
return changePasswordHandler(req, res);
|
|
32
63
|
});
|
|
@@ -372,6 +372,61 @@ router.post('/api/mesh/register-node', requireAuth, requireScope('config:write')
|
|
|
372
372
|
}
|
|
373
373
|
});
|
|
374
374
|
|
|
375
|
+
/**
|
|
376
|
+
* Watch mode: arm the server to approve the next device that asks.
|
|
377
|
+
*
|
|
378
|
+
* POST /api/mesh/watch arm (body: { ttlMinutes? })
|
|
379
|
+
* GET /api/mesh/watch status
|
|
380
|
+
* DELETE /api/mesh/watch disarm
|
|
381
|
+
*
|
|
382
|
+
* This is the flow that actually works. The device generates a registration id
|
|
383
|
+
* and waits; the server approves it. Nothing is typed, copied or scanned —
|
|
384
|
+
* which matters because no Tailscale client can scan a QR to authenticate, and
|
|
385
|
+
* carrying a 40-character key from a laptop to a phone by hand is not a flow
|
|
386
|
+
* anyone completes.
|
|
387
|
+
*/
|
|
388
|
+
router.post('/api/mesh/watch', requireAuth, requireScope('config:write'), async (req, res) => {
|
|
389
|
+
try {
|
|
390
|
+
const { startWatch } = await import('../../core/registration-watch.mjs');
|
|
391
|
+
const ttlMinutes = Number(req.body?.ttlMinutes);
|
|
392
|
+
const result = await startWatch({
|
|
393
|
+
brainDir: BRAIN_DIR,
|
|
394
|
+
ttlMs: Number.isFinite(ttlMinutes) ? ttlMinutes * 60_000 : undefined,
|
|
395
|
+
register: async (authId) => {
|
|
396
|
+
const user = await resolveHeadscaleUser(BRAIN_DIR);
|
|
397
|
+
const q = `?user=${encodeURIComponent(user)}&key=${encodeURIComponent(authId)}`;
|
|
398
|
+
const out = await headscaleFetch(`/api/v1/node/register${q}`, { method: 'POST' }, BRAIN_DIR);
|
|
399
|
+
const parsed = typeof out === 'string' ? JSON.parse(out) : out;
|
|
400
|
+
const node = parsed?.node || null;
|
|
401
|
+
return { id: node?.id ?? null, name: node?.givenName || node?.name || null, ip_addresses: node?.ipAddresses || [] };
|
|
402
|
+
},
|
|
403
|
+
});
|
|
404
|
+
// `unavailable` is a real answer, not a failure: it tells the UI to offer
|
|
405
|
+
// the manual paste box instead of a button that cannot work here.
|
|
406
|
+
res.status(result.state === 'unavailable' ? 409 : 200).json(result);
|
|
407
|
+
} catch (err) {
|
|
408
|
+
res.status(500).json({ state: 'error', error: err.message || 'could not start watch' });
|
|
409
|
+
}
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
router.get('/api/mesh/watch', requireAuth, requireScope('config:read'), async (_req, res) => {
|
|
413
|
+
try {
|
|
414
|
+
const { getWatchStatus } = await import('../../core/registration-watch.mjs');
|
|
415
|
+
res.json(getWatchStatus());
|
|
416
|
+
} catch (err) {
|
|
417
|
+
res.status(500).json({ state: 'error', error: err.message });
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
router.delete('/api/mesh/watch', requireAuth, requireScope('config:write'), async (_req, res) => {
|
|
422
|
+
try {
|
|
423
|
+
const { stopWatch } = await import('../../core/registration-watch.mjs');
|
|
424
|
+
res.json(stopWatch());
|
|
425
|
+
} catch (err) {
|
|
426
|
+
res.status(500).json({ state: 'error', error: err.message });
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
|
|
375
430
|
/**
|
|
376
431
|
* This host's I/O capability profile for agent UI generation
|
|
377
432
|
* (screen, touch, mic, speaker, keyboard, camera, headless, …).
|
|
@@ -599,6 +599,7 @@ router.post('/api/secrets', requireAuth, requireScope('keys:write', 'config:writ
|
|
|
599
599
|
monthly_cap_usd: body.monthly_cap_usd ?? catalog?.default_monthly_cap_usd,
|
|
600
600
|
api_docs_url: body.api_docs_url || catalog?.docs_url,
|
|
601
601
|
headscale_url: body.headscale_url,
|
|
602
|
+
headscale_log_command: body.headscale_log_command,
|
|
602
603
|
rotate_every_days: body.rotate_every_days,
|
|
603
604
|
auto_rotate: body.auto_rotate,
|
|
604
605
|
notes: body.notes,
|
|
@@ -126,12 +126,17 @@ router.get('/api/pairing', requireAuth, requireScope('config:read'), async (_req
|
|
|
126
126
|
try {
|
|
127
127
|
const { buildPairingInfo } = await import('../../core/pairing.mjs');
|
|
128
128
|
const { getMeshIp } = await import('../../core/mesh.mjs');
|
|
129
|
+
const { getBoundHosts } = await import('../../core/bound-hosts.mjs');
|
|
129
130
|
const protocol = _req.secure || String(_req.headers['x-forwarded-proto'] || '').includes('https')
|
|
130
131
|
? 'https'
|
|
131
132
|
: 'http';
|
|
133
|
+
// The addresses this process actually bound — not a guess. Deriving them
|
|
134
|
+
// let the card recommend, and draw a QR code for, an address nothing was
|
|
135
|
+
// listening on, while reporting no problem at all.
|
|
132
136
|
const info = buildPairingInfo({
|
|
133
137
|
protocol,
|
|
134
138
|
meshIp: getMeshIp(),
|
|
139
|
+
listenHosts: getBoundHosts(),
|
|
135
140
|
});
|
|
136
141
|
res.json(info);
|
|
137
142
|
} catch (err) {
|