total-recall-brain 3.25.5 → 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.
@@ -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 listenHosts = Array.isArray(opts.listenHosts)
25
- ? opts.listenHosts
26
- : deriveListenHosts(meshIp);
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
- function deriveListenHosts(meshIp) {
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,
@@ -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
- bind: { host: '127.0.0.1', port: 3000, allow_public_bind: false },
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
  };
@@ -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) {