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.
@@ -17,7 +17,7 @@
17
17
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
18
18
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
19
19
  <title>Total Recall — Portable Memory</title>
20
- <script type="module" crossorigin src="/assets/index-BX15G8l-.js"></script>
20
+ <script type="module" crossorigin src="/assets/index-Bl3I11B0.js"></script>
21
21
  <link rel="stylesheet" crossorigin href="/assets/index-C7_Xpk27.css">
22
22
  </head>
23
23
  <body>
@@ -303,6 +303,38 @@ export async function registerNode(authId: string, user?: string): Promise<Regis
303
303
  return data as RegisteredNode;
304
304
  }
305
305
 
306
+ export interface WatchStatus {
307
+ state: 'idle' | 'watching' | 'registered' | 'expired' | 'stopped' | 'unavailable' | 'error';
308
+ id?: string | null;
309
+ node?: { id: string | null; name: string | null; ip_addresses: string[] } | null;
310
+ error?: string | null;
311
+ remaining_ms?: number;
312
+ source?: string;
313
+ }
314
+
315
+ /** Arm the server to approve the next device that signs in. */
316
+ export async function startWatch(ttlMinutes?: number): Promise<WatchStatus> {
317
+ const res = await apiFetch(`${API_BASE}/api/mesh/watch`, {
318
+ method: 'POST',
319
+ headers: { 'Content-Type': 'application/json' },
320
+ body: JSON.stringify(ttlMinutes ? { ttlMinutes } : {}),
321
+ });
322
+ // 409 means watch mode is not available here — a real answer the UI acts on,
323
+ // not an error to throw away.
324
+ const data = await res.json().catch(() => ({}));
325
+ if (!res.ok && res.status !== 409) throw new Error(data.error || `watch failed: ${res.status}`);
326
+ return data as WatchStatus;
327
+ }
328
+
329
+ export async function getWatchStatus(): Promise<WatchStatus> {
330
+ return get('/api/mesh/watch');
331
+ }
332
+
333
+ export async function stopWatch(): Promise<WatchStatus> {
334
+ const res = await apiFetch(`${API_BASE}/api/mesh/watch`, { method: 'DELETE' });
335
+ return (await res.json().catch(() => ({ state: 'idle' }))) as WatchStatus;
336
+ }
337
+
306
338
  export async function fetchEnrollmentStatus(): Promise<EnrollmentStatus> {
307
339
  return get('/api/mesh/enrollment');
308
340
  }
@@ -51,4 +51,103 @@ describe('MobilePairing', () => {
51
51
  expect(screen.getByTestId('pairing-qr')).toBeTruthy();
52
52
  expect(screen.getByTestId('pairing-warnings')).toBeTruthy();
53
53
  });
54
+ it('sends you to enrol the device instead of hand-waving "log into your tailnet"', async () => {
55
+ // The old copy said "Log into your tailnet/headscale server" -- five words
56
+ // covering an iOS flow that needs the alternate coordination server, a
57
+ // keychain reset and server-side approval. Scanning a 100.64.x.x QR from a
58
+ // phone that has not joined just times out.
59
+ render(<MobilePairing />);
60
+ expect(await screen.findByText(/Add a device to the tailnet/i)).toBeTruthy();
61
+ expect(screen.getByText(/Reset Keychain/i)).toBeTruthy();
62
+ });
63
+
64
+ const pairingResponse = (over = {}) => ({
65
+ ok: true,
66
+ json: async () => ({
67
+ port: 3000,
68
+ protocol: 'http',
69
+ preferred_url: 'http://100.64.0.2:3000',
70
+ listen_hosts: ['127.0.0.1'],
71
+ listen_hosts_source: 'actual',
72
+ reachable_from_other_devices: false,
73
+ warnings: ['This brain is listening on loopback only'],
74
+ endpoints: [
75
+ {
76
+ kind: 'mesh',
77
+ label: 'Tailscale / mesh',
78
+ ip: '100.64.0.2',
79
+ url: 'http://100.64.0.2:3000',
80
+ recommended: true,
81
+ reachable_hint: 'Tailscale',
82
+ listening: false,
83
+ },
84
+ ],
85
+ ...over,
86
+ }),
87
+ });
88
+
89
+ it('says no device can reach the brain instead of drawing a QR that fails', async () => {
90
+ // The Mac Mini shipped this card for a week: brain bound loopback-only
91
+ // after losing the boot race to the mesh client, while the card happily
92
+ // recommended a mesh URL nothing had ever listened on.
93
+ vi.mocked(apiFetch).mockResolvedValue(pairingResponse() as never);
94
+
95
+ render(<MobilePairing />);
96
+
97
+ const alert = await screen.findByTestId('pairing-unreachable');
98
+ expect(alert.textContent).toMatch(/No other device can reach this brain/i);
99
+ // Naming the cause is what turns an error into something a user can act on.
100
+ expect(alert.textContent).toMatch(/still starting when the brain launched/i);
101
+ expect(alert.textContent).toMatch(/restart the brain/i);
102
+ // And the code itself must not look scannable.
103
+ expect(screen.getByTestId('pairing-qr-dead')).toBeTruthy();
104
+ });
105
+
106
+ it('flags the specific address that is not listening', async () => {
107
+ vi.mocked(apiFetch).mockResolvedValue(pairingResponse() as never);
108
+ render(<MobilePairing />);
109
+ await screen.findByTestId('pairing-unreachable');
110
+ expect(screen.getByTestId('pairing-endpoint-select').textContent).toMatch(/not listening/i);
111
+ });
112
+
113
+ it('stays quiet when the brain is genuinely reachable', async () => {
114
+ vi.mocked(apiFetch).mockResolvedValue(
115
+ pairingResponse({
116
+ preferred_url: 'http://100.64.0.6:3000',
117
+ listen_hosts: ['100.64.0.6', '127.0.0.1'],
118
+ reachable_from_other_devices: true,
119
+ warnings: [],
120
+ endpoints: [
121
+ {
122
+ kind: 'mesh',
123
+ label: 'Tailscale / mesh',
124
+ ip: '100.64.0.6',
125
+ url: 'http://100.64.0.6:3000',
126
+ recommended: true,
127
+ reachable_hint: 'Tailscale',
128
+ listening: true,
129
+ },
130
+ ],
131
+ }) as never,
132
+ );
133
+ render(<MobilePairing />);
134
+ await waitFor(() => {
135
+ expect(screen.getByTestId('pairing-url').textContent).toContain('100.64.0.6');
136
+ });
137
+ expect(screen.queryByTestId('pairing-unreachable')).toBeNull();
138
+ expect(screen.queryByTestId('pairing-qr-dead')).toBeNull();
139
+ });
140
+
141
+ it('does not cry wolf when the server could not observe its own sockets', async () => {
142
+ // `null` is unknown. Rendering unknown as a red banner would put a false
143
+ // alarm on every install whose page load beat the listen callback.
144
+ vi.mocked(apiFetch).mockResolvedValue(
145
+ pairingResponse({ reachable_from_other_devices: null, listen_hosts_source: 'derived' }) as never,
146
+ );
147
+ render(<MobilePairing />);
148
+ await waitFor(() => {
149
+ expect(screen.getByTestId('pairing-url')).toBeTruthy();
150
+ });
151
+ expect(screen.queryByTestId('pairing-unreachable')).toBeNull();
152
+ });
54
153
  });
@@ -10,6 +10,8 @@ export interface PairingEndpoint {
10
10
  interface?: string | null;
11
11
  recommended: boolean;
12
12
  reachable_hint: string;
13
+ /** null when the server could not observe its own sockets. */
14
+ listening?: boolean | null;
13
15
  }
14
16
 
15
17
  export interface PairingInfo {
@@ -19,6 +21,10 @@ export interface PairingInfo {
19
21
  endpoints: PairingEndpoint[];
20
22
  warnings: string[];
21
23
  listen_hosts: string[];
24
+ /** 'actual' = observed sockets. 'derived' = a guess; do not render as fact. */
25
+ listen_hosts_source?: 'actual' | 'derived';
26
+ /** null means unknown — must not be shown as either working or broken. */
27
+ reachable_from_other_devices?: boolean | null;
22
28
  }
23
29
 
24
30
  export function MobilePairing() {
@@ -67,6 +73,8 @@ export function MobilePairing() {
67
73
  ],
68
74
  warnings: ['Could not load live network endpoints from the brain.'],
69
75
  listen_hosts: [],
76
+ listen_hosts_source: 'derived',
77
+ reachable_from_other_devices: null,
70
78
  });
71
79
  } finally {
72
80
  setLoading(false);
@@ -78,6 +86,9 @@ export function MobilePairing() {
78
86
  }, []);
79
87
 
80
88
  const selected = info?.endpoints?.find((e) => e.url === selectedUrl) || null;
89
+ // Observed, not guessed. `undefined`/`null` means the server could not tell
90
+ // us — that is not the same as broken, so it must not raise this banner.
91
+ const unreachable = info?.reachable_from_other_devices === false;
81
92
  const isLoopback =
82
93
  selected?.kind === 'loopback' ||
83
94
  selectedUrl.includes('127.0.0.1') ||
@@ -111,6 +122,39 @@ export function MobilePairing() {
111
122
  Wi‑Fi, or <strong>Tailscale/mesh</strong> if the phone is on your tailnet.
112
123
  </p>
113
124
 
125
+ {unreachable && (
126
+ <div
127
+ role="alert"
128
+ data-testid="pairing-unreachable"
129
+ style={{
130
+ marginBottom: 16,
131
+ padding: '12px 14px',
132
+ borderRadius: 8,
133
+ border: '1px solid #ef4444',
134
+ background: 'rgba(239, 68, 68, 0.08)',
135
+ color: 'var(--text-primary)',
136
+ fontSize: 13,
137
+ lineHeight: 1.55,
138
+ }}
139
+ >
140
+ <div style={{ fontWeight: 700, marginBottom: 4 }}>
141
+ No other device can reach this brain
142
+ </div>
143
+ <div style={{ color: 'var(--text-secondary)' }}>
144
+ It is listening on{' '}
145
+ <code>{info?.listen_hosts?.join(', ') || 'loopback'}</code> only, so every code on this
146
+ card is a dead address — a phone that scans one will time out no matter how it is
147
+ connected. This normally means the mesh client was still starting when the brain
148
+ launched, so the brain never got a mesh address to bind.
149
+ <div style={{ marginTop: 6 }}>
150
+ <strong>Fix:</strong> restart the brain now that the mesh is up. It re-checks for a
151
+ mesh address on its own for the first few minutes after launch, so a restart is
152
+ usually all this needs.
153
+ </div>
154
+ </div>
155
+ </div>
156
+ )}
157
+
114
158
  {loading && (
115
159
  <div data-testid="pairing-loading" style={{ color: 'var(--text-tertiary)', fontSize: 13 }}>
116
160
  Detecting reachable addresses…
@@ -149,7 +193,33 @@ export function MobilePairing() {
149
193
  data-testid="pairing-qr"
150
194
  >
151
195
  {selectedUrl ? (
152
- <QRCodeSVG value={selectedUrl} size={150} level="M" includeMargin />
196
+ <div style={{ position: 'relative' }}>
197
+ {/* Greyed rather than hidden: the address is still the right one
198
+ to reach for once the bind is fixed. */}
199
+ <div style={{ opacity: unreachable ? 0.15 : 1 }}>
200
+ <QRCodeSVG value={selectedUrl} size={150} level="M" includeMargin />
201
+ </div>
202
+ {unreachable && (
203
+ <div
204
+ data-testid="pairing-qr-dead"
205
+ style={{
206
+ position: 'absolute',
207
+ inset: 0,
208
+ display: 'flex',
209
+ alignItems: 'center',
210
+ justifyContent: 'center',
211
+ textAlign: 'center',
212
+ padding: 8,
213
+ color: '#b91c1c',
214
+ fontSize: 12,
215
+ fontWeight: 700,
216
+ lineHeight: 1.4,
217
+ }}
218
+ >
219
+ Nothing is listening on this address
220
+ </div>
221
+ )}
222
+ </div>
153
223
  ) : (
154
224
  <div style={{ width: 150, height: 150 }} />
155
225
  )}
@@ -173,6 +243,7 @@ export function MobilePairing() {
173
243
  <option key={ep.url} value={ep.url}>
174
244
  {ep.label}
175
245
  {ep.recommended ? ' ★' : ''} — {ep.url}
246
+ {ep.listening === false ? ' (not listening)' : ''}
176
247
  </option>
177
248
  ))}
178
249
  </select>
@@ -243,7 +314,17 @@ export function MobilePairing() {
243
314
 
244
315
  <div style={{ marginTop: 16, padding: '12px 14px', background: 'rgba(59, 130, 246, 0.06)', borderRadius: 8, border: '1px solid rgba(59, 130, 246, 0.15)', fontSize: 12, color: 'var(--text-secondary)', lineHeight: 1.5 }}>
245
316
  <div style={{ color: 'var(--text-primary)', fontWeight: 600, marginBottom: 4 }}>📱 How to connect your phone:</div>
246
- <div><strong>Option A (WireGuard / Mesh — Recommended):</strong> Install the standard <em>Tailscale</em> app on your phone (iOS App Store / Google Play). Log into your tailnet/headscale server. Scan the <code>100.64.x.x</code> QR code above.</div>
317
+ <div>
318
+ <strong>Option A (WireGuard / Mesh — Recommended):</strong> The device has to be <em>on the tailnet</em>
319
+ before a <code>100.64.x.x</code> address can reach anything — scanning this QR from a phone that has not
320
+ joined will simply time out. Join it first in{' '}
321
+ <strong>Add a device to the tailnet</strong>, directly below, which has the per-platform steps.
322
+ <div style={{ marginTop: 4, opacity: 0.85 }}>
323
+ Short version: <strong>Android / ChromeOS</strong> take an enrollment key directly. <strong>iOS</strong>
324
+ {' '}cannot — it needs the alternate coordination server set in iOS Settings, <em>Reset Keychain</em> turned
325
+ on, and then approval from that card. Once joined, come back and scan the <code>100.64.x.x</code> code.
326
+ </div>
327
+ </div>
247
328
  <div style={{ marginTop: 4 }}><strong>Option B (Local Home Wi-Fi):</strong> Check <strong>Allow Public Bind</strong> in <em>Network & Binding</em> below, restart the server, and scan your local Wi-Fi LAN QR code (e.g. <code>192.168.x.x</code>).</div>
248
329
  </div>
249
330
  </div>
@@ -6,8 +6,12 @@ vi.mock('../api/mesh', () => ({
6
6
  fetchNodes: vi.fn(),
7
7
  fetchEnrollmentStatus: vi.fn(),
8
8
  mintPreAuthKey: vi.fn(),
9
+ registerNode: vi.fn(),
10
+ startWatch: vi.fn(),
11
+ getWatchStatus: vi.fn(),
12
+ stopWatch: vi.fn(),
9
13
  }));
10
- import { fetchNodes, fetchEnrollmentStatus, mintPreAuthKey } from '../api/mesh';
14
+ import { fetchNodes, fetchEnrollmentStatus, mintPreAuthKey, startWatch } from '../api/mesh';
11
15
 
12
16
  const node = (over = {}) => ({
13
17
  hostname: 'laptop', ip: '100.64.0.3', online: true, self: false, os: 'macOS', ...over,
@@ -150,4 +154,28 @@ describe('TailnetEnroll', () => {
150
154
  expect(screen.getByText(/Crostini/i)).toBeTruthy();
151
155
  });
152
156
 
157
+ it('leads with a watch button rather than a key to carry between devices', async () => {
158
+ // No Tailscale client can scan a QR to authenticate, and nobody types a
159
+ // 40-character secret from a laptop into a phone. The device generates the
160
+ // id and waits; the server approves it.
161
+ vi.mocked(startWatch).mockResolvedValue({ state: 'watching', remaining_ms: 300000 } as never);
162
+ render(<TailnetEnroll />);
163
+ const btn = await screen.findByTestId('watch-start');
164
+ fireEvent.click(btn);
165
+ await waitFor(() => expect(screen.getByTestId('watch-active')).toBeTruthy());
166
+ expect(startWatch).toHaveBeenCalled();
167
+ });
168
+
169
+ it('says watch mode is unavailable instead of offering a button that cannot work', async () => {
170
+ vi.mocked(startWatch).mockResolvedValue({
171
+ state: 'unavailable',
172
+ error: 'no log source is configured.',
173
+ } as never);
174
+ render(<TailnetEnroll />);
175
+ fireEvent.click(await screen.findByTestId('watch-start'));
176
+ await waitFor(() => expect(screen.getByTestId('watch-unavailable')).toBeTruthy());
177
+ // and the manual path is still reachable
178
+ expect(screen.getByTestId('enroll-approve')).toBeTruthy();
179
+ });
180
+
153
181
  });
@@ -1,6 +1,7 @@
1
1
  import { useEffect, useState } from 'react';
2
2
  import { QRCodeSVG } from 'qrcode.react';
3
- import { fetchNodes, fetchEnrollmentStatus, mintPreAuthKey, registerNode } from '../api/mesh';
3
+ import { fetchNodes, fetchEnrollmentStatus, mintPreAuthKey, registerNode, startWatch, getWatchStatus, stopWatch } from '../api/mesh';
4
+ import type { WatchStatus } from '../api/mesh';
4
5
  import type { MeshNode, PreAuthKey } from '../api/mesh';
5
6
 
6
7
  /**
@@ -28,6 +29,7 @@ export function TailnetEnroll() {
28
29
  const [authId, setAuthId] = useState('');
29
30
  const [approving, setApproving] = useState(false);
30
31
  const [approved, setApproved] = useState<string | null>(null);
32
+ const [watch, setWatch] = useState<WatchStatus | null>(null);
31
33
 
32
34
  useEffect(() => {
33
35
  fetchNodes()
@@ -64,6 +66,34 @@ export function TailnetEnroll() {
64
66
  }
65
67
  };
66
68
 
69
+ // Poll while armed. The server holds the watch; this only mirrors it, so a
70
+ // page reload cannot orphan a running watch.
71
+ useEffect(() => {
72
+ if (watch?.state !== 'watching') return undefined;
73
+ const id = setInterval(async () => {
74
+ try {
75
+ const st = await getWatchStatus();
76
+ setWatch(st);
77
+ if (st.state === 'registered') setNodes(await fetchNodes().catch(() => nodes));
78
+ } catch { /* transient */ }
79
+ }, 2000);
80
+ return () => clearInterval(id);
81
+ }, [watch?.state, nodes]);
82
+
83
+ const arm = async () => {
84
+ setError(null);
85
+ setApproved(null);
86
+ try {
87
+ setWatch(await startWatch(5));
88
+ } catch (e) {
89
+ setError(e instanceof Error ? e.message : 'could not start watching');
90
+ }
91
+ };
92
+
93
+ const disarm = async () => {
94
+ try { setWatch(await stopWatch()); } catch { setWatch(null); }
95
+ };
96
+
67
97
  const approve = async () => {
68
98
  setApproving(true);
69
99
  setError(null);
@@ -136,11 +166,63 @@ export function TailnetEnroll() {
136
166
  existed, finishing that required SSH and a CLI -- on the machine you
137
167
  are not holding.
138
168
  */}
169
+ {/*
170
+ The primary action. The device generates the id and waits; the server
171
+ approves it. Nothing is typed, copied or scanned — which matters
172
+ because no Tailscale client scans QR codes to authenticate, and moving
173
+ a 40-character key from a laptop to a phone by hand is not a flow
174
+ anyone finishes.
175
+ */}
139
176
  <div
177
+ data-testid="enroll-watch"
178
+ style={{ margin: '0 0 12px', padding: 12, borderRadius: 8, border: '1px solid var(--border)' }}
179
+ >
180
+ <strong style={{ fontSize: 12 }}>Add a device</strong>
181
+ <p style={{ fontSize: 12, opacity: 0.75, margin: '4px 0 8px' }}>
182
+ Press this, then sign in on the device. It joins on its own — nothing to copy.
183
+ </p>
184
+
185
+ {watch?.state === 'watching' ? (
186
+ <div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
187
+ <span data-testid="watch-active" style={{ fontSize: 12, color: '#10b981' }}>
188
+ ● Watching — sign in on the device now
189
+ {typeof watch.remaining_ms === 'number'
190
+ ? ` (${Math.ceil(watch.remaining_ms / 1000)}s left)`
191
+ : ''}
192
+ </span>
193
+ <button type="button" onClick={disarm} data-testid="watch-stop">Stop</button>
194
+ </div>
195
+ ) : (
196
+ <button type="button" onClick={arm} data-testid="watch-start">
197
+ Watch for a device
198
+ </button>
199
+ )}
200
+
201
+ {watch?.state === 'registered' && (
202
+ <p data-testid="watch-registered" style={{ fontSize: 12, color: '#10b981', margin: '8px 0 0' }}>
203
+ {watch.node?.name || 'Device'} joined the tailnet
204
+ {watch.node?.ip_addresses?.[0] ? ` (${watch.node.ip_addresses[0]})` : ''}.
205
+ </p>
206
+ )}
207
+ {watch?.state === 'expired' && (
208
+ <p data-testid="watch-expired" style={{ fontSize: 12, color: '#f59e0b', margin: '8px 0 0' }}>
209
+ Nothing signed in before the watch ran out. Press it again when the device is ready.
210
+ </p>
211
+ )}
212
+ {watch?.state === 'unavailable' && (
213
+ <p data-testid="watch-unavailable" style={{ fontSize: 12, color: '#f59e0b', margin: '8px 0 0' }}>
214
+ {watch.error} Use the manual box below in the meantime.
215
+ </p>
216
+ )}
217
+ </div>
218
+
219
+ <details
140
220
  data-testid="enroll-approve"
141
221
  style={{ margin: '0 0 12px', padding: 12, borderRadius: 8, border: '1px solid var(--border)' }}
142
222
  >
143
- <strong style={{ fontSize: 12 }}>Device waiting for approval?</strong>
223
+ <summary style={{ cursor: 'pointer' }}>
224
+ <strong style={{ fontSize: 12 }}>Device waiting for approval? (manual)</strong>
225
+ </summary>
144
226
  <p style={{ fontSize: 12, opacity: 0.75, margin: '4px 0 8px' }}>
145
227
  If a device shows “run the command below in the headscale server”, paste that whole line
146
228
  (or just the <code>hskey-authreq-…</code> part) here.
@@ -173,7 +255,7 @@ export function TailnetEnroll() {
173
255
  {approved}
174
256
  </p>
175
257
  )}
176
- </div>
258
+ </details>
177
259
 
178
260
  <details open style={{ fontSize: 12 }}>
179
261
  <summary style={{ cursor: 'pointer', fontWeight: 600 }}>On a phone (iOS)</summary>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "total-recall-brain",
3
- "version": "3.25.5",
3
+ "version": "3.26.0",
4
4
  "description": "Personal AI system — database-free, SSSS-powered intelligence engine deployable to any POSIX host.",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -0,0 +1,64 @@
1
+ /**
2
+ * bound-hosts — what the server ACTUALLY bound, as opposed to what it hoped to.
3
+ *
4
+ * The pairing card renders a QR code of the URL it believes a phone can reach.
5
+ * It used to work that URL out by derivation: loopback, plus whatever
6
+ * `getMeshIp()` reported at the moment the card was opened. That derivation is
7
+ * wrong on any machine where the mesh came up AFTER the brain did.
8
+ *
9
+ * The Mac Mini is the worked example. Its brain started 2026-08-13 while
10
+ * Tailscale was still starting, so `getMeshIp()` returned null and the bind
11
+ * fell back to loopback only. A week later Tailscale answers perfectly, so the
12
+ * card derived `listen_hosts = [127.0.0.1, 100.64.0.2]`, recommended the mesh
13
+ * URL, and drew a QR for an address nothing has ever listened on -- while
14
+ * reporting no warning, because every check downstream was reading the guess.
15
+ *
16
+ * A socket knows its own address. Registering each successful listen here means
17
+ * the card reports observed truth, and "no other device can reach this brain"
18
+ * becomes something the UI can state instead of something the user discovers.
19
+ */
20
+
21
+ /** @type {Set<string>} */
22
+ const bound = new Set();
23
+
24
+ const LOOPBACK = new Set(['127.0.0.1', '::1', 'localhost']);
25
+
26
+ /** Record an address the server has successfully bound. */
27
+ export function registerBoundHost(host) {
28
+ if (host === null || host === undefined || host === '') return;
29
+ bound.add(String(host));
30
+ }
31
+
32
+ /** Forget an address (a listener closed, or a late bind was replaced). */
33
+ export function unregisterBoundHost(host) {
34
+ bound.delete(String(host));
35
+ }
36
+
37
+ /** Every address currently listening. Order is insertion order. */
38
+ export function getBoundHosts() {
39
+ return [...bound];
40
+ }
41
+
42
+ /** Test seam — module state is process-wide by design. */
43
+ export function resetBoundHosts() {
44
+ bound.clear();
45
+ }
46
+
47
+ export function isLoopbackHost(host) {
48
+ return LOOPBACK.has(String(host));
49
+ }
50
+
51
+ /**
52
+ * Is this brain reachable from any device other than the one it runs on?
53
+ *
54
+ * `0.0.0.0` / `::` count as reachable: they accept from every interface.
55
+ * An empty set means nothing has registered yet -- unknown, not unreachable,
56
+ * so callers must not render that as a failure.
57
+ *
58
+ * @returns {boolean|null} null when nothing is known yet
59
+ */
60
+ export function isReachableFromOtherDevices() {
61
+ const hosts = getBoundHosts();
62
+ if (!hosts.length) return null;
63
+ return hosts.some((h) => !isLoopbackHost(h));
64
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * mesh-late-bind — bind the mesh address when the mesh shows up late.
3
+ *
4
+ * `resolveServerHost()` runs once, during startup, and whatever it decides is
5
+ * permanent for the life of the process. At boot that is a race the brain can
6
+ * lose: if the mesh client is still starting, `getMeshIp()` returns null, the
7
+ * bind falls back to loopback, and the node is unreachable from every other
8
+ * device until somebody restarts it by hand.
9
+ *
10
+ * Nothing reports this. The node looks healthy from its own console, answers
11
+ * `/health` on loopback, and is simply absent from the tailnet. One machine sat
12
+ * that way for a week.
13
+ *
14
+ * So: when the brain ends up loopback-only, watch for a mesh address and bind
15
+ * it the moment one exists. Polling is the honest mechanism here -- the mesh
16
+ * client offers no readiness event to subscribe to.
17
+ */
18
+
19
+ const DEFAULT_INTERVAL_MS = 15_000;
20
+ const DEFAULT_MAX_WAIT_MS = 10 * 60 * 1000;
21
+
22
+ /**
23
+ * @param {object} opts
24
+ * @param {() => (string|null)} opts.getMeshIp
25
+ * @param {(ip: string) => Promise<void>} opts.bind resolves once listening
26
+ * @param {() => string[]} opts.boundHosts addresses already listening
27
+ * @param {number} [opts.intervalMs]
28
+ * @param {number} [opts.maxWaitMs] give up after this long; 0 disables the cap
29
+ * @param {{info: Function, warn: Function}} [opts.logger]
30
+ * @returns {{stop: () => void, promise: Promise<string|null>}}
31
+ */
32
+ export function startMeshBindWatch({
33
+ getMeshIp,
34
+ bind,
35
+ boundHosts,
36
+ intervalMs = DEFAULT_INTERVAL_MS,
37
+ maxWaitMs = DEFAULT_MAX_WAIT_MS,
38
+ logger,
39
+ } = {}) {
40
+ let timer = null;
41
+ let stopped = false;
42
+ let settle;
43
+ const promise = new Promise((resolve) => { settle = resolve; });
44
+ const startedAt = Date.now();
45
+
46
+ const stop = () => {
47
+ if (stopped) return;
48
+ stopped = true;
49
+ if (timer) clearTimeout(timer);
50
+ timer = null;
51
+ settle(null);
52
+ };
53
+
54
+ const schedule = () => {
55
+ if (stopped) return;
56
+ timer = setTimeout(tick, intervalMs);
57
+ // Never keep the process alive for this; it is opportunistic repair.
58
+ timer.unref?.();
59
+ };
60
+
61
+ const tick = async () => {
62
+ if (stopped) return;
63
+ if (maxWaitMs > 0 && Date.now() - startedAt >= maxWaitMs) {
64
+ logger?.warn?.(
65
+ 'server',
66
+ 'Mesh address never appeared; this brain stays reachable only on loopback. '
67
+ + 'Other devices cannot open its UI until the mesh client is running and the brain restarts.',
68
+ );
69
+ stop();
70
+ return;
71
+ }
72
+
73
+ let ip = null;
74
+ try {
75
+ ip = getMeshIp();
76
+ } catch {
77
+ ip = null;
78
+ }
79
+
80
+ if (!ip || boundHosts().includes(ip)) {
81
+ schedule();
82
+ return;
83
+ }
84
+
85
+ try {
86
+ await bind(ip);
87
+ logger?.info?.(
88
+ 'server',
89
+ `Mesh address ${ip} appeared after startup — now also listening there, `
90
+ + 'so other devices on the mesh can reach this brain.',
91
+ );
92
+ stopped = true;
93
+ if (timer) clearTimeout(timer);
94
+ timer = null;
95
+ settle(ip);
96
+ } catch (err) {
97
+ // Port taken by another process, address not ready yet: keep trying.
98
+ logger?.warn?.('server', `Late mesh bind to ${ip} failed: ${err?.message || err}`);
99
+ schedule();
100
+ }
101
+ };
102
+
103
+ schedule();
104
+ return { stop, promise };
105
+ }