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.
- package/frontend/dist/assets/{index-BX15G8l-.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 +99 -0
- package/frontend/src/components/MobilePairing.tsx +83 -2
- 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/frontend/dist/index.html
CHANGED
|
@@ -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-
|
|
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>
|
package/frontend/src/api/mesh.ts
CHANGED
|
@@ -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
|
-
<
|
|
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
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
@@ -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
|
+
}
|