@gotcos/glasses-server 6.88.0 → 6.89.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.
@@ -0,0 +1,536 @@
1
+ // 6.89.0 Tailscale self-heal: the Mac-side "nudge" (docs/tailscale-self-heal.md).
2
+ //
3
+ // Why: on 2026-10-22 the Mac restarted at 14:09 and the server answered on every address from
4
+ // 14:10:58, but the iPhone's Tailscale extension kept a stale tunnel. Tailscale's own ping got
5
+ // through, no COS request did, and only toggling Tailscale on the phone (15:09) fixed it. The goal
6
+ // is that a Mac restart, a wake from sleep, a Tailscale restart on the Mac or a network change heals
7
+ // without anyone touching Tailscale.
8
+ //
9
+ // What a nudge is: two bounded `tailscale ping`s at one peer, run with execFile from a fixed path,
10
+ // never a shell:
11
+ // 1. `ping -c 3 --timeout 5s <ip>`: a disco ping. It makes both ends redo path discovery, and it
12
+ // stops early once a direct path answers.
13
+ // 2. only after a pong, `ping --icmp -c 1 --timeout 5s <ip>`: a packet THROUGH WireGuard. The disco
14
+ // ping alone can succeed while the data path is stale (it does not use the tunnel, which is what
15
+ // 10/22 looked like); a data packet makes WireGuard start a fresh handshake when its session is
16
+ // stale.
17
+ // Whether this un-sticks the iOS extension in the field is NOT proven here; the field canary is.
18
+ //
19
+ // When (triggers), each needing the POSITIVE signal "tailscale status says this peer is Online"
20
+ // from a status read at most STATUS_FRESH_MS old. Absence of requests alone never nudges.
21
+ // boot 30 s after the server starts.
22
+ // wake the wall clock moved 20 s+ more than the monotonic clock (the Mac slept).
23
+ // network this Mac's IPv4 interfaces changed (Tailscale's utun included).
24
+ // tailscale Tailscale's own state changed: BackendState, this Mac's Tailscale IPs, or the peer
25
+ // byte counters went backwards (tailscaled restarted).
26
+ // silent a phone that made an authenticated request in the last 5 minutes, or sent meeting
27
+ // chunks in the last 15 minutes while a meeting is open, has made no request at all
28
+ // for 45 s, while its peer is Online.
29
+ // Event triggers (boot, wake, network, tailscale) aim at peers this process has seen authenticate in
30
+ // the last 12 hours, plus Online iOS and Android peers (after a restart the Mac remembers no one),
31
+ // at most 4, and skip any peer that has already made a request since the event.
32
+ //
33
+ // Caps: one nudge per peer per 60 s, doubling per nudge that brings no request (60, 120, 240, 480,
34
+ // then 900 s), reset by any request from that peer; 30 nudges an hour in all; one at a time.
35
+ // COS_TAILSCALE_NUDGE=0 (or false/off) stops everything, read every tick, no restart needed.
36
+ // No LLM, no disk write, no shell. Logs carry trigger, outcome and timing only, never an address.
37
+
38
+ import { execFile } from 'node:child_process'
39
+ import { accessSync, constants as fsConstants } from 'node:fs'
40
+ import { networkInterfaces } from 'node:os'
41
+ import { readSwitch } from './env-switch.js'
42
+ import { isTailscaleIpv4, normalizeRemoteIp } from './network-policy.js'
43
+
44
+ export const TAILSCALE_NUDGE_LIMITS = Object.freeze({
45
+ tickMs: 10_000,
46
+ bootDelayMs: 30_000,
47
+ wakeSettleMs: 15_000,
48
+ networkSettleMs: 10_000,
49
+ /** An event trigger that cannot run (Tailscale not Running yet) is dropped after this. */
50
+ eventExpiryMs: 120_000,
51
+ /** The monotonic clock stops while the Mac sleeps; a wall-clock lead past this is a wake. */
52
+ wakeGapMs: 20_000,
53
+ silenceMs: 45_000,
54
+ activeWindowMs: 5 * 60_000,
55
+ meetingWindowMs: 15 * 60_000,
56
+ knownPeerWindowMs: 12 * 60 * 60_000,
57
+ minIntervalMs: 60_000,
58
+ maxBackoffMs: 15 * 60_000,
59
+ maxPerHour: 30,
60
+ eventMaxPeers: 4,
61
+ statusPollMs: 60_000,
62
+ statusFreshMs: 15_000,
63
+ recoveredWithinMs: 90_000,
64
+ discoTimeoutMs: 20_000,
65
+ icmpTimeoutMs: 10_000,
66
+ statusTimeoutMs: 5_000,
67
+ binRecheckMs: 10 * 60_000,
68
+ maxTrackedPeers: 16,
69
+ })
70
+
71
+ /** Fixed places the Tailscale CLI lives. No PATH lookup and no override: the path is never input. */
72
+ export const TAILSCALE_BIN_CANDIDATES = Object.freeze([
73
+ '/Applications/Tailscale.app/Contents/MacOS/Tailscale',
74
+ '/opt/homebrew/bin/tailscale',
75
+ '/usr/local/bin/tailscale',
76
+ '/usr/bin/tailscale',
77
+ ])
78
+
79
+ export const DISCO_PING_ARGS = (ip: string): string[] => ['ping', '-c', '3', '--timeout', '5s', ip]
80
+ export const ICMP_PING_ARGS = (ip: string): string[] => ['ping', '--icmp', '-c', '1', '--timeout', '5s', ip]
81
+ export const STATUS_ARGS: readonly string[] = Object.freeze(['status', '--json'])
82
+
83
+ export type NudgeTrigger = 'boot' | 'wake' | 'network' | 'tailscale' | 'silent'
84
+ export type NudgeOutcome = 'pong_direct' | 'pong_relay' | 'no_pong' | 'timeout' | 'error' | 'unavailable'
85
+ export type DataPathOutcome = 'ok' | 'failed' | 'skipped'
86
+
87
+ export interface RunResult {
88
+ code: number | null
89
+ signal: string | null
90
+ killed: boolean
91
+ stdout: string
92
+ stderr: string
93
+ /** Set when the process could not be started at all (ENOENT, EACCES). */
94
+ spawnError?: string
95
+ elapsedMs: number
96
+ }
97
+ /** The one way this module runs Tailscale: a binary path and an argument array, never a shell. */
98
+ export type TailscaleRunner = (bin: string, args: readonly string[], timeoutMs: number) => Promise<RunResult>
99
+
100
+ export const execFileRunner: TailscaleRunner = (bin, args, timeoutMs) => new Promise(resolve => {
101
+ const started = Date.now()
102
+ execFile(bin, [...args], { timeout: timeoutMs, killSignal: 'SIGKILL', maxBuffer: 1024 * 1024, windowsHide: true, encoding: 'utf8' },
103
+ (error, stdout, stderr) => {
104
+ const err = error as (NodeJS.ErrnoException & { code?: number | string; killed?: boolean; signal?: string }) | null
105
+ const spawnError = err && typeof err.code === 'string' ? err.code : undefined
106
+ resolve({
107
+ code: err ? (typeof err.code === 'number' ? err.code : null) : 0,
108
+ signal: err?.signal ?? null,
109
+ killed: !!err?.killed,
110
+ stdout: String(stdout ?? ''),
111
+ stderr: String(stderr ?? ''),
112
+ ...(spawnError ? { spawnError } : {}),
113
+ elapsedMs: Date.now() - started,
114
+ })
115
+ })
116
+ })
117
+
118
+ export function resolveTailscaleBin(isExecutable: (p: string) => boolean = defaultIsExecutable): string | null {
119
+ for (const candidate of TAILSCALE_BIN_CANDIDATES) if (isExecutable(candidate)) return candidate
120
+ return null
121
+ }
122
+ function defaultIsExecutable(p: string): boolean {
123
+ try { accessSync(p, fsConstants.X_OK); return true } catch { return false }
124
+ }
125
+
126
+ export interface PeerStatus {
127
+ ip: string
128
+ online: boolean
129
+ os: string
130
+ rxBytes: number
131
+ txBytes: number
132
+ /** Epoch ms of the last WireGuard handshake, or 0 for none. */
133
+ lastHandshakeMs: number
134
+ }
135
+ export interface TailscaleStatus {
136
+ backendState: string
137
+ selfIps: string[]
138
+ peers: PeerStatus[]
139
+ at: number
140
+ }
141
+
142
+ /** Parse `tailscale status --json`. Returns null for anything that is not that shape. */
143
+ export function parseTailscaleStatus(text: string, at: number): TailscaleStatus | null {
144
+ let raw: any
145
+ try { raw = JSON.parse(text) } catch { return null }
146
+ if (!raw || typeof raw !== 'object' || typeof raw.BackendState !== 'string') return null
147
+ const v4 = (ips: unknown): string[] => Array.isArray(ips) ? ips.filter((ip): ip is string => typeof ip === 'string' && isTailscaleIpv4(ip)) : []
148
+ const peers: PeerStatus[] = []
149
+ const peerMap = raw.Peer && typeof raw.Peer === 'object' ? raw.Peer : {}
150
+ for (const p of Object.values(peerMap) as any[]) {
151
+ const ip = v4(p?.TailscaleIPs)[0]
152
+ if (!ip) continue
153
+ const hs = typeof p.LastHandshake === 'string' ? Date.parse(p.LastHandshake) : NaN
154
+ peers.push({
155
+ ip,
156
+ online: p.Online === true,
157
+ os: typeof p.OS === 'string' ? p.OS : '',
158
+ rxBytes: Number.isFinite(p.RxBytes) ? p.RxBytes : 0,
159
+ txBytes: Number.isFinite(p.TxBytes) ? p.TxBytes : 0,
160
+ lastHandshakeMs: Number.isFinite(hs) && hs > 0 ? hs : 0,
161
+ })
162
+ }
163
+ return { backendState: raw.BackendState, selfIps: v4(raw.Self?.TailscaleIPs).sort(), peers, at }
164
+ }
165
+
166
+ /** Disco ping output: "pong from <name> (<ip>) via <DERP(x) | ip:port | peer-relay(...)> in 91ms". */
167
+ export function classifyDiscoPing(result: RunResult): NudgeOutcome {
168
+ if (result.spawnError) return 'unavailable'
169
+ const pong = /pong from .* via (\S+) in [\d.]+ms/.exec(result.stdout)
170
+ if (pong) return /^(DERP|peer-relay)/i.test(pong[1]) ? 'pong_relay' : 'pong_direct'
171
+ if (result.killed || result.signal) return 'timeout'
172
+ if (/no reply|timed out|timeout/i.test(result.stdout + result.stderr)) return 'no_pong'
173
+ return result.code === 0 ? 'no_pong' : 'error'
174
+ }
175
+ export function classifyIcmpPing(result: RunResult): DataPathOutcome {
176
+ return !result.spawnError && /pong from .* via ICMP/i.test(result.stdout) ? 'ok' : 'failed'
177
+ }
178
+
179
+ /** Every external IPv4 interface address, sorted: changes when a network or the Tailscale tunnel changes. */
180
+ type InterfaceInfo = { address: string; family: string | number; internal: boolean }
181
+ export function networkFingerprint(nets: Record<string, InterfaceInfo[] | undefined> = networkInterfaces()): string {
182
+ const out: string[] = []
183
+ for (const [name, infos] of Object.entries(nets)) {
184
+ for (const info of infos ?? []) {
185
+ const family = typeof info.family === 'number' ? (info.family === 4 ? 'IPv4' : 'IPv6') : String(info.family)
186
+ if (family === 'IPv4' && !info.internal) out.push(`${name}=${info.address}`)
187
+ }
188
+ }
189
+ return out.sort().join(',')
190
+ }
191
+
192
+ /** Did Tailscale's own state change between two status reads? A failed read is not a change. */
193
+ export function tailscaleStateChanged(prev: TailscaleStatus | null, next: TailscaleStatus | null): boolean {
194
+ if (!prev || !next) return false
195
+ if (prev.backendState !== next.backendState) return true
196
+ if (prev.selfIps.join(',') !== next.selfIps.join(',')) return true
197
+ // tailscaled keeps per-peer byte counters for its lifetime: a total that went DOWN means it restarted.
198
+ const total = (s: TailscaleStatus) => s.peers.reduce((n, p) => n + p.rxBytes + p.txBytes, 0)
199
+ return total(next) < total(prev)
200
+ }
201
+
202
+ /** Seconds between nudges for a peer, given how many nudges in a row brought no request. */
203
+ export function backoffMs(consecutive: number): number {
204
+ if (consecutive <= 0) return 0
205
+ const { minIntervalMs, maxBackoffMs } = TAILSCALE_NUDGE_LIMITS
206
+ return Math.min(minIntervalMs * 2 ** (consecutive - 1), maxBackoffMs)
207
+ }
208
+
209
+ export function isMobileOs(os: string): boolean {
210
+ return /^(ios|android)$/i.test(os.trim())
211
+ }
212
+
213
+ interface PeerTrack {
214
+ /** Any /api request from this address, public routes included: proof the data path works. */
215
+ lastRequestAt: number
216
+ lastAuthenticatedAt: number
217
+ lastMeetingChunkAt: number
218
+ lastNudgeAt: number
219
+ /** Nudges since the last request from this peer. */
220
+ consecutive: number
221
+ }
222
+
223
+ export interface TailscaleNudgeDeps {
224
+ runner?: TailscaleRunner
225
+ now?: () => number
226
+ /** Monotonic milliseconds; it does not advance while the Mac sleeps. */
227
+ monotonic?: () => number
228
+ env?: () => NodeJS.ProcessEnv
229
+ resolveBin?: () => string | null
230
+ fingerprint?: () => string
231
+ activeMeetings?: () => number
232
+ log?: (line: string) => void
233
+ }
234
+
235
+ export interface TailscaleNudgeHealth {
236
+ enabled: boolean
237
+ /** null until the first look for the Tailscale CLI; false when it is not installed. */
238
+ available: boolean | null
239
+ lastAt: string | null
240
+ lastTrigger: NudgeTrigger | null
241
+ lastOutcome: NudgeOutcome | null
242
+ lastDataPath: DataPathOutcome | null
243
+ /** When a nudged peer next made a request, within 90 s of the nudge. */
244
+ lastRecoveredAt: string | null
245
+ nudgesLastHour: number
246
+ }
247
+
248
+ interface PendingEvent { trigger: Exclude<NudgeTrigger, 'silent'>; firedAt: number; dueAt: number }
249
+
250
+ export class TailscaleNudger {
251
+ private readonly runner: TailscaleRunner
252
+ private readonly now: () => number
253
+ private readonly monotonic: () => number
254
+ private readonly env: () => NodeJS.ProcessEnv
255
+ private readonly resolveBinFn: () => string | null
256
+ private readonly fingerprintFn: () => string
257
+ private readonly activeMeetings: () => number
258
+ private readonly log: (line: string) => void
259
+
260
+ private readonly peers = new Map<string, PeerTrack>()
261
+ private readonly hourLog: number[] = []
262
+ private pending: PendingEvent | null = null
263
+ private bin: string | null | undefined = undefined
264
+ private binCheckedAt = 0
265
+ private status: TailscaleStatus | null = null
266
+ private previousStatus: TailscaleStatus | null = null
267
+ private lastStatusAttemptAt = 0
268
+ private lastFingerprint: string | null = null
269
+ private lastTick: { wall: number; mono: number } | null = null
270
+ private busy = false
271
+ private timer: ReturnType<typeof setInterval> | null = null
272
+ private lastNudge: { at: number; ip: string; trigger: NudgeTrigger; outcome: NudgeOutcome; dataPath: DataPathOutcome } | null = null
273
+ private lastRecoveredAt: number | null = null
274
+
275
+ constructor(deps: TailscaleNudgeDeps = {}) {
276
+ this.runner = deps.runner ?? execFileRunner
277
+ this.now = deps.now ?? Date.now
278
+ this.monotonic = deps.monotonic ?? (() => Number(process.hrtime.bigint() / 1_000_000n))
279
+ this.env = deps.env ?? (() => process.env)
280
+ this.resolveBinFn = deps.resolveBin ?? (() => resolveTailscaleBin())
281
+ this.fingerprintFn = deps.fingerprint ?? (() => networkFingerprint())
282
+ this.activeMeetings = deps.activeMeetings ?? (() => 0)
283
+ this.log = deps.log ?? (line => console.log(line))
284
+ }
285
+
286
+ enabled(): boolean {
287
+ return readSwitch(this.env().COS_TAILSCALE_NUDGE, true)
288
+ }
289
+
290
+ /** Start the tick. The boot trigger is due 30 s from now. Timers are unref'd. */
291
+ start(): void {
292
+ if (this.timer) return
293
+ const at = this.now()
294
+ this.pending = { trigger: 'boot', firedAt: at, dueAt: at + TAILSCALE_NUDGE_LIMITS.bootDelayMs }
295
+ this.timer = setInterval(() => { void this.tick() }, TAILSCALE_NUDGE_LIMITS.tickMs)
296
+ this.timer.unref?.()
297
+ }
298
+
299
+ stop(): void {
300
+ if (this.timer) clearInterval(this.timer)
301
+ this.timer = null
302
+ }
303
+
304
+ /**
305
+ * Called for every /api request (before the token check) with the socket address. Only
306
+ * Tailscale addresses are tracked. Never throws.
307
+ */
308
+ noteRequest(remoteAddress: string | undefined | null, kind: { authenticated?: boolean; meetingChunk?: boolean } = {}): void {
309
+ try {
310
+ const ip = normalizeRemoteIp(remoteAddress ?? '')
311
+ if (!isTailscaleIpv4(ip)) return
312
+ const at = this.now()
313
+ let track = this.peers.get(ip)
314
+ if (!track) {
315
+ track = { lastRequestAt: 0, lastAuthenticatedAt: 0, lastMeetingChunkAt: 0, lastNudgeAt: 0, consecutive: 0 }
316
+ }
317
+ this.peers.delete(ip)
318
+ this.peers.set(ip, track)
319
+ while (this.peers.size > TAILSCALE_NUDGE_LIMITS.maxTrackedPeers) this.peers.delete(this.peers.keys().next().value as string)
320
+ if (track.consecutive > 0 && this.lastNudge?.ip === ip && at - this.lastNudge.at <= TAILSCALE_NUDGE_LIMITS.recoveredWithinMs
321
+ && (this.lastRecoveredAt === null || this.lastRecoveredAt < this.lastNudge.at)) {
322
+ this.lastRecoveredAt = at
323
+ this.log(`[tailscale-nudge] peer answered ${((at - this.lastNudge.at) / 1000).toFixed(1)}s after the ${this.lastNudge.trigger} nudge`)
324
+ }
325
+ track.lastRequestAt = at
326
+ track.consecutive = 0
327
+ if (kind.authenticated) track.lastAuthenticatedAt = at
328
+ if (kind.meetingChunk) track.lastMeetingChunkAt = at
329
+ } catch { /* observer only */ }
330
+ }
331
+
332
+ health(): TailscaleNudgeHealth {
333
+ const at = this.now()
334
+ return {
335
+ enabled: this.enabled(),
336
+ available: this.bin === undefined ? null : this.bin !== null,
337
+ lastAt: this.lastNudge ? new Date(this.lastNudge.at).toISOString() : null,
338
+ lastTrigger: this.lastNudge?.trigger ?? null,
339
+ lastOutcome: this.lastNudge?.outcome ?? null,
340
+ lastDataPath: this.lastNudge?.dataPath ?? null,
341
+ lastRecoveredAt: this.lastRecoveredAt !== null ? new Date(this.lastRecoveredAt).toISOString() : null,
342
+ nudgesLastHour: this.hourLog.filter(t => at - t < 3_600_000).length,
343
+ }
344
+ }
345
+
346
+ /** One decision pass. Public for tests; the interval calls it. Never throws, never overlaps. */
347
+ async tick(): Promise<void> {
348
+ if (this.busy) return
349
+ this.busy = true
350
+ try { await this.tickInner() } catch { /* a failed pass waits for the next tick */ } finally { this.busy = false }
351
+ }
352
+
353
+ private async tickInner(): Promise<void> {
354
+ const at = this.now()
355
+ const mono = this.monotonic()
356
+ const L = TAILSCALE_NUDGE_LIMITS
357
+ // Wake and network detection run even when switched off, so turning it on later starts clean.
358
+ if (this.lastTick && (at - this.lastTick.wall) - (mono - this.lastTick.mono) > L.wakeGapMs) {
359
+ this.raise('wake', at, L.wakeSettleMs)
360
+ }
361
+ this.lastTick = { wall: at, mono }
362
+ const fp = safe(this.fingerprintFn, '')
363
+ if (this.lastFingerprint !== null && fp !== this.lastFingerprint) this.raise('network', at, L.networkSettleMs)
364
+ this.lastFingerprint = fp
365
+
366
+ if (!this.enabled()) return
367
+ if (!this.ensureBin(at)) return
368
+
369
+ if (at - this.lastStatusAttemptAt >= L.statusPollMs) {
370
+ // Compared only on a successful read: a failed one leaves the last good status in place.
371
+ const before = this.status
372
+ const read = await this.readStatus(at)
373
+ if (read && tailscaleStateChanged(before, read)) this.raise('tailscale', at, L.networkSettleMs)
374
+ }
375
+
376
+ const pending = this.pending
377
+ if (pending && at >= pending.dueAt) {
378
+ await this.runEvent(pending, at)
379
+ return
380
+ }
381
+ await this.runSilent(at)
382
+ }
383
+
384
+ private raise(trigger: PendingEvent['trigger'], at: number, settleMs: number): void {
385
+ // A boot still waiting keeps its own due time; any later event replaces an earlier one.
386
+ if (this.pending?.trigger === 'boot' && trigger !== 'boot') {
387
+ this.pending = { trigger, firedAt: this.pending.firedAt, dueAt: Math.max(this.pending.dueAt, at + settleMs) }
388
+ return
389
+ }
390
+ this.pending = { trigger, firedAt: at, dueAt: at + settleMs }
391
+ }
392
+
393
+ private ensureBin(at: number): boolean {
394
+ if (this.bin === undefined || (this.bin === null && at - this.binCheckedAt >= TAILSCALE_NUDGE_LIMITS.binRecheckMs)) {
395
+ this.bin = safe(this.resolveBinFn, null)
396
+ this.binCheckedAt = at
397
+ }
398
+ return this.bin !== null
399
+ }
400
+
401
+ private async readStatus(at: number): Promise<TailscaleStatus | null> {
402
+ this.lastStatusAttemptAt = at
403
+ const result = await this.runner(this.bin as string, STATUS_ARGS, TAILSCALE_NUDGE_LIMITS.statusTimeoutMs)
404
+ if (result.spawnError === 'ENOENT') this.bin = null
405
+ const parsed = result.spawnError ? null : parseTailscaleStatus(result.stdout, at)
406
+ if (parsed) {
407
+ this.previousStatus = this.status
408
+ this.status = parsed
409
+ }
410
+ return parsed
411
+ }
412
+
413
+ /** A status no older than STATUS_FRESH_MS, read now if needed; null when Tailscale cannot say. */
414
+ private async freshStatus(at: number): Promise<TailscaleStatus | null> {
415
+ if (this.status && at - this.status.at <= TAILSCALE_NUDGE_LIMITS.statusFreshMs) return this.status
416
+ const before = this.status
417
+ const read = await this.readStatus(at)
418
+ if (read && tailscaleStateChanged(before, read)) this.raise('tailscale', at, TAILSCALE_NUDGE_LIMITS.networkSettleMs)
419
+ return read
420
+ }
421
+
422
+ private async runEvent(pending: PendingEvent, at: number): Promise<void> {
423
+ const L = TAILSCALE_NUDGE_LIMITS
424
+ const status = await this.freshStatus(at)
425
+ if (!status || status.backendState !== 'Running') {
426
+ if (at - pending.firedAt > L.eventExpiryMs) {
427
+ this.pending = null
428
+ this.log(`[tailscale-nudge] trigger=${pending.trigger} dropped: Tailscale ${status ? 'not running' : 'did not answer'} for ${Math.round((at - pending.firedAt) / 1000)}s`)
429
+ }
430
+ return
431
+ }
432
+ if (this.pending === pending) this.pending = null
433
+ const known = (ip: string) => {
434
+ const t = this.peers.get(ip)
435
+ return !!t && t.lastAuthenticatedAt > 0 && at - t.lastAuthenticatedAt <= L.knownPeerWindowMs
436
+ }
437
+ const candidates = status.peers
438
+ .filter(p => p.online && (known(p.ip) || isMobileOs(p.os)))
439
+ // Already back since the event: nothing to heal.
440
+ .filter(p => (this.peers.get(p.ip)?.lastRequestAt ?? 0) < pending.firedAt)
441
+ .sort((a, b) => Number(known(b.ip)) - Number(known(a.ip)))
442
+ .slice(0, L.eventMaxPeers)
443
+ for (const peer of candidates) {
444
+ if (!this.enabled()) return
445
+ await this.maybeNudge(peer, pending.trigger, status, at, 0)
446
+ }
447
+ }
448
+
449
+ private async runSilent(at: number): Promise<void> {
450
+ const L = TAILSCALE_NUDGE_LIMITS
451
+ const meetingOpen = safe(this.activeMeetings, 0) > 0
452
+ const silent: Array<[string, PeerTrack]> = []
453
+ for (const [ip, t] of this.peers) {
454
+ const expected = (t.lastAuthenticatedAt > 0 && at - t.lastAuthenticatedAt <= L.activeWindowMs)
455
+ || (meetingOpen && t.lastMeetingChunkAt > 0 && at - t.lastMeetingChunkAt <= L.meetingWindowMs)
456
+ // Only a peer the caps would let us nudge now: a status read for one we cannot nudge is waste.
457
+ if (expected && at - t.lastRequestAt >= L.silenceMs && this.allowed(ip, at)) silent.push([ip, t])
458
+ }
459
+ if (silent.length === 0) return
460
+ // Never on absence alone: only a peer that Tailscale itself reports Online is nudged.
461
+ const status = await this.freshStatus(at)
462
+ if (!status || status.backendState !== 'Running') return
463
+ for (const [ip, t] of silent) {
464
+ const peer = status.peers.find(p => p.ip === ip)
465
+ if (!peer || !peer.online) continue
466
+ if (!this.enabled()) return
467
+ await this.maybeNudge(peer, 'silent', status, at, at - t.lastRequestAt)
468
+ }
469
+ }
470
+
471
+ private allowed(ip: string, at: number): boolean {
472
+ const L = TAILSCALE_NUDGE_LIMITS
473
+ while (this.hourLog.length > 0 && at - this.hourLog[0] >= 3_600_000) this.hourLog.shift()
474
+ if (this.hourLog.length >= L.maxPerHour) return false
475
+ const t = this.peers.get(ip)
476
+ if (!t || t.lastNudgeAt === 0) return true
477
+ return at - t.lastNudgeAt >= Math.max(L.minIntervalMs, backoffMs(t.consecutive))
478
+ }
479
+
480
+ private async maybeNudge(peer: PeerStatus, trigger: NudgeTrigger, status: TailscaleStatus, at: number, silentMs: number): Promise<void> {
481
+ if (!this.allowed(peer.ip, at)) return
482
+ let track = this.peers.get(peer.ip)
483
+ if (!track) {
484
+ track = { lastRequestAt: 0, lastAuthenticatedAt: 0, lastMeetingChunkAt: 0, lastNudgeAt: 0, consecutive: 0 }
485
+ this.peers.set(peer.ip, track)
486
+ while (this.peers.size > TAILSCALE_NUDGE_LIMITS.maxTrackedPeers) this.peers.delete(this.peers.keys().next().value as string)
487
+ }
488
+ track.lastNudgeAt = at
489
+ track.consecutive += 1
490
+ this.hourLog.push(at)
491
+ const bin = this.bin as string
492
+ const disco = await this.runner(bin, DISCO_PING_ARGS(peer.ip), TAILSCALE_NUDGE_LIMITS.discoTimeoutMs)
493
+ const outcome = classifyDiscoPing(disco)
494
+ if (disco.spawnError === 'ENOENT') this.bin = null
495
+ let dataPath: DataPathOutcome = 'skipped'
496
+ let icmpMs = 0
497
+ if (outcome === 'pong_direct' || outcome === 'pong_relay') {
498
+ const icmp = await this.runner(bin, ICMP_PING_ARGS(peer.ip), TAILSCALE_NUDGE_LIMITS.icmpTimeoutMs)
499
+ dataPath = classifyIcmpPing(icmp)
500
+ icmpMs = icmp.elapsedMs
501
+ }
502
+ this.lastNudge = { at, ip: peer.ip, trigger, outcome, dataPath }
503
+ const prev = this.previousStatus?.peers.find(p => p.ip === peer.ip)
504
+ const rx = prev && this.previousStatus ? ` rxDelta=${peer.rxBytes - prev.rxBytes}/${Math.round((status.at - this.previousStatus.at) / 1000)}s` : ''
505
+ const hs = peer.lastHandshakeMs > 0 ? ` handshakeAgeS=${Math.round((status.at - peer.lastHandshakeMs) / 1000)}` : ' handshakeAgeS=none'
506
+ const failure = outcome === 'error' || outcome === 'timeout' || outcome === 'unavailable'
507
+ ? ` code=${disco.code} signal=${disco.signal ?? 'none'} killed=${disco.killed}${disco.spawnError ? ` spawn=${disco.spawnError}` : ''}`
508
+ : ''
509
+ this.log(`[tailscale-nudge] trigger=${trigger} outcome=${outcome} dataPath=${dataPath} pingMs=${disco.elapsedMs} icmpMs=${icmpMs}`
510
+ + `${silentMs > 0 ? ` silentS=${Math.round(silentMs / 1000)}` : ''}${hs}${rx} inRow=${track.consecutive} lastHour=${this.hourLog.length}${failure}`)
511
+ }
512
+ }
513
+
514
+ function safe<T>(fn: () => T, fallback: T): T {
515
+ try { return fn() } catch { return fallback }
516
+ }
517
+
518
+ let singleton: TailscaleNudger | null = null
519
+ /** The server's nudger. Created on first use so /api/health can read it before start(). */
520
+ export function getTailscaleNudger(): TailscaleNudger {
521
+ if (!singleton) singleton = new TailscaleNudger()
522
+ return singleton
523
+ }
524
+ /** index.ts installs the server's nudger with its real meeting probe, before any request is noted. */
525
+ export function installTailscaleNudger(deps: TailscaleNudgeDeps = {}): TailscaleNudger {
526
+ singleton?.stop()
527
+ singleton = new TailscaleNudger(deps)
528
+ return singleton
529
+ }
530
+ export function __setTailscaleNudgerForTests(nudger: TailscaleNudger | null): void {
531
+ singleton = nudger
532
+ }
533
+ /** `{ tailscaleNudge }` for /api/health. Public route: no address, no peer name, counts and times only. */
534
+ export function tailscaleNudgeHealthField(): { tailscaleNudge: TailscaleNudgeHealth } {
535
+ return { tailscaleNudge: getTailscaleNudger().health() }
536
+ }
@@ -75,7 +75,7 @@ import { liveCuesCapability } from '../lib/live-cues-capability.js'
75
75
  import { liveCuesV2Advertised } from '../lib/live-cues-v2.js'
76
76
  import { liveMeetingCapability } from '../lib/live-provisional.js'
77
77
  import { CUE_KINDS } from '../../shared/live-cues-v2.js'
78
- import { lanFirstCapabilities, lanFirstHttpPort, lanRoutesField } from '../lib/lan-first.js'
78
+ import { tailscaleNudgeHealthField } from '../lib/tailscale-nudge.js'
79
79
 
80
80
  /** 6.86.0: `capabilities.liveCues.kinds`, the cue kinds this server can write (additive; an older server omits it, which
81
81
  * is how a client knows to expect only reinforce and challenge). Which kinds a given meeting gets is in its arm answer. */
@@ -446,6 +446,8 @@ healthRouter.get('/health', async (_req, res) => {
446
446
  ...continueLiveHealthFields(),
447
447
  // 6.62.0 /qa (W6): spawn isolation, posture and Cursor model read counters.
448
448
  ...continueReadHealthFields(),
449
+ // 6.89.0 Tailscale self-heal: switch, last nudge and its outcome, count this hour. No address.
450
+ ...tailscaleNudgeHealthField(),
449
451
  // 6.62.0: ONE providers object per engine. `act` (plan 3.11): what each provider can do,
450
452
  // with a reason for every "no". `observe` (plan 1.10): what COS can see of its sessions.
451
453
  providers: mergeProviderSections(
@@ -548,9 +550,6 @@ healthRouter.get('/health', async (_req, res) => {
548
550
  tasks: {
549
551
  gate: taskBridgeAvailable() ? 'ready' : 'disabled',
550
552
  },
551
- // 6.88.0 LAN first (docs/lan-first.md): protocol flags only. This route is public, so
552
- // the addresses themselves are published only on authenticated /api/models.
553
- ...lanFirstCapabilities({ token: process.env.COS_API_TOKEN ?? '', serverInstanceId: getServerInstanceId() }),
554
553
  },
555
554
  // /api/health is intentionally unauthenticated for setup diagnostics.
556
555
  // Publish capability only; job counts, retention identities, subscriber
@@ -642,10 +641,6 @@ healthRouter.get('/models', async (req, res) => {
642
641
  // Key lanes (plan v4 3.1): `keyProfiles` only while a profile is active. Absent when off.
643
642
  ...keyProfilesModelsField(),
644
643
  serverInstanceId: getServerInstanceId(),
645
- // 6.88.0 LAN first (docs/lan-first.md): the addresses this Mac answers on, behind the
646
- // token. Absent when the switch is off, the instance id is not minted yet, or the
647
- // interface read failed; never a reason for this response to fail.
648
- ...lanRoutesModelsField(),
649
644
  // Minted here rather than on a route of its own: this response is already
650
645
  // authenticated, and the mint is stateless, so it costs nothing and stores
651
646
  // nothing.
@@ -741,16 +736,9 @@ healthRouter.get('/models', async (req, res) => {
741
736
  tasks: {
742
737
  gate: taskBridgeAvailable() ? 'ready' : 'disabled',
743
738
  },
744
- // 6.88.0 LAN first: the same helper as /api/health, so the two surfaces cannot disagree.
745
- ...lanFirstCapabilities({ token: apiToken, serverInstanceId: getServerInstanceId() }),
746
739
  },
747
740
  })
748
741
  })
749
- /** 6.88.0: `{ routes }` for /api/models, or nothing at all. lanRoutesField never throws. */
750
- function lanRoutesModelsField(): { routes?: NonNullable<ReturnType<typeof lanRoutesField>> } {
751
- const routes = lanRoutesField({ port: lanFirstHttpPort(), serverInstanceId: getServerInstanceId() })
752
- return routes ? { routes } : {}
753
- }
754
742
  /** 6.78.0: `{ steer }` for /api/models `durableQueryJobs`, or nothing at all. */
755
743
  export function steerCapabilityField(jobsEnabled: boolean): { steer?: { protocolVersion: 1; providers: ['claude'] } } {
756
744
  const steer = messagesSteerCapability({ jobsEnabled, cliSupported: claudeSteerSupportKnown() })