@gotcos/glasses-server 7.0.18 → 7.0.29

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,790 @@
1
+ // A publicly trusted certificate for the phone signer origin, https://<MagicDNS name>:3144 (QA
2
+ // 2026-11-09 blocker B2; plan PLAN_web_turn_authority Phase 0 and Decision 1). Exists ONLY with
3
+ // COS_WEB_KEY_AUTHORITY on and the 3144 listener planned (createSignerTlsForBoot); off, nothing here
4
+ // runs: no Tailscale call, no file, no change to any listener.
5
+ //
6
+ // WHY. 3144 served the mkcert pair from server/certs/, whose SAN has no ts.net name, so iPhone Safari
7
+ // had no secure context (no passkey, no WebCrypto key) and COS Control's signer-file audit failed its
8
+ // hostname check. Tailscale's HTTPS Certificates feature issues a Let's Encrypt certificate for this
9
+ // Mac's own tailnet name. That is the phone origin Miles chose.
10
+ //
11
+ // WHAT IT DOES.
12
+ // - Obtains the pair with `tailscale cert --cert-file - --key-file - <name>`. Both to STDOUT, not
13
+ // to file paths: the macOS GUI build's CLI runs in an app sandbox ("this binary's filesystem
14
+ // writes go to $HOME/Library/Containers/<app>/Data", read from the 1.102.2 binary), so a path
15
+ // under the data dir would not receive the files. The server parses the PEM blocks and writes the
16
+ // pair itself, atomically, into <data dir>/web-key-tls/ (dir 0700, key 0600, cert 0644).
17
+ // - Asks `tailscale status --json --peers=false` first (read only, bounded; SR-03). Empty CertDomains means HTTPS
18
+ // Certificates is off for the tailnet: no cert call is made, and the status says so
19
+ // (`tailnet_https_certs_off`). A name missing from CertDomains: `cert_domain_mismatch`.
20
+ // - Serves the pair on 3144 by SNI: a handshake naming exactly the certificate's host gets it; any
21
+ // other name, no name or an IP keeps the mkcert pair (the server's default context). Replacing
22
+ // the pair swaps the context the next handshake reads: no restart, no listener change.
23
+ // - Checks at boot and every SIGNER_TLS_CHECK_INTERVAL_MS (daily); renews when fewer than
24
+ // SIGNER_TLS_RENEW_BEFORE_MS (30 days) remain. A valid pair with more left calls nothing.
25
+ // - Each check first re-reads the stored pair: a longer-lived, valid pair for this name written there
26
+ // by hand or by another process is served from then on, no restart and no CLI call.
27
+ // - The prompt pickup (7.0.20), the fallback if this server's own fetch fails: a pair written into
28
+ // the dir by hand (`tailscale cert` in Terminal) or by another process. While nothing trusted
29
+ // serves, every 3144 handshake and every status read checks the two stored files with `stat` and
30
+ // re-reads them only when they changed (inode, size, mtime, ctime). So such a pair is served at the
31
+ // next handshake or status read, with no restart, no CLI call and no write, instead of at the next
32
+ // check (up to SIGNER_TLS_RETRY_MAX_MS away while failing). While a trusted pair serves, nothing
33
+ // extra runs. What to write: CONTRACT_stored_pair.md (phone-key build, 2026-11-10).
34
+ // - Every CLI call gets a minimal explicit env with SHLVL=1 (signerTlsCliEnv, 7.0.20), never this
35
+ // process's env: without SHLVL the macOS CLI under the LaunchAgent answers "The Tailscale GUI failed
36
+ // to start" (exit 0) and no certificate is fetched. That answer is reported as
37
+ // `tailscale_cli_gui_unavailable`.
38
+ // - Every CLI call has a timeout. Consecutive failures back off SIGNER_TLS_RETRY_BASE_MS doubling
39
+ // to SIGNER_TLS_RETRY_MAX_MS; no call is made inside the cooldown (the breaker). One check at a
40
+ // time. Never throws.
41
+ // - Logs reason codes, exit facts and the expiry date. Never stdout (it carries the private key).
42
+ //
43
+ // NOT HERE: 3143 keeps its single mkcert pair (no SNI selection): Even Hub and every existing client
44
+ // see exactly the certificate they saw before.
45
+
46
+ import { execFile } from 'node:child_process'
47
+ import { closeSync, constants as fsConstants, fstatSync, lstatSync, mkdirSync, chmodSync, openSync, readSync } from 'node:fs'
48
+ import { join } from 'node:path'
49
+ import { X509Certificate, createPrivateKey, createPublicKey } from 'node:crypto'
50
+ import { createSecureContext, rootCertificates, type SecureContext } from 'node:tls'
51
+ import type { ServerOptions as HttpsServerOptions } from 'node:https'
52
+ import { durableAtomicWriteFileSync } from './atomic-fs.js'
53
+ import { normalizeTailscaleDnsName } from './network-policy.js'
54
+ import {
55
+ parseTailscaleStatus,
56
+ resolveTailscaleBin,
57
+ type RunResult,
58
+ } from './tailscale-nudge.js'
59
+
60
+ const DAY_MS = 24 * 60 * 60_000
61
+ /** Renew when fewer than this remain before notAfter. */
62
+ export const SIGNER_TLS_RENEW_BEFORE_MS = 30 * DAY_MS
63
+ /** How often a healthy pair is re-checked (also the first check's cadence after a success). */
64
+ export const SIGNER_TLS_CHECK_INTERVAL_MS = DAY_MS
65
+ /** First retry after a failed check; doubles per consecutive failure. */
66
+ export const SIGNER_TLS_RETRY_BASE_MS = 60_000
67
+ /** The longest cooldown between failed checks. */
68
+ export const SIGNER_TLS_RETRY_MAX_MS = 60 * 60_000
69
+ /** SR-04: the least time between two re-checks started by status reads. */
70
+ export const SIGNER_TLS_RECHECK_FLOOR_MS = 15_000
71
+ /** SR-04: the failures a user fixes outside this server, which a status read may re-check at once. */
72
+ const RECHECK_ON_READ: ReadonlySet<string> = new Set(['tailnet_https_certs_off', 'tailnet_name_unknown'])
73
+ export const SIGNER_TLS_STATUS_TIMEOUT_MS = 5_000
74
+ /** ACME issuance through tailscaled can take tens of seconds. */
75
+ export const SIGNER_TLS_CERT_TIMEOUT_MS = 90_000
76
+ export const SIGNER_TLS_DIR = 'web-key-tls'
77
+ export const SIGNER_TLS_CERT_FILE = 'tailscale-cert.pem'
78
+ export const SIGNER_TLS_KEY_FILE = 'tailscale-key.pem'
79
+ export const SIGNER_TLS_KEY_MODE = 0o600
80
+ export const SIGNER_TLS_CERT_MODE = 0o644
81
+ /** SR-05: the breaker and the day's cert-call count, saved beside the pair (0600) so they survive a restart. */
82
+ export const SIGNER_TLS_STATE_FILE = 'fetch-state.json'
83
+ /** SR-05: the most `tailscale cert` calls per UTC day (Let's Encrypt allows 5 duplicate certificates a week). */
84
+ export const SIGNER_TLS_CERT_CALLS_PER_DAY = 3
85
+ /** SR-08: the most bytes read from either stored file (a real chain plus key is about 4 KiB). */
86
+ export const SIGNER_TLS_STORED_MAX_BYTES = 64 * 1024
87
+ /**
88
+ * SR-03: the signer's own status call, without peers. CertDomains, BackendState and Self are top-level and
89
+ * stay in the answer (checked 2026-11-11 on Tailscale 1.102 for macOS: 0 peers, CertDomains present), so a
90
+ * tailnet with hundreds of devices cannot push the answer past the 1 MiB output cap.
91
+ */
92
+ export const SIGNER_TLS_STATUS_ARGS: readonly string[] = Object.freeze(['status', '--json', '--peers=false'])
93
+ /** Node's error code when a child's output passes maxBuffer (SR-03): never "Tailscale is not installed". */
94
+ const MAXBUFFER = 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER'
95
+ /** Both outputs to stdout ("-"), see the header. The name is a normalised *.ts.net name, never a flag. */
96
+ export const SIGNER_TLS_CERT_ARGS = (name: string): string[] => ['cert', '--cert-file', '-', '--key-file', '-', name]
97
+
98
+ /** The only variables of this process's env a Tailscale CLI call here may see (each only if set), plus SHLVL. */
99
+ export const SIGNER_TLS_CLI_ENV_KEYS: readonly string[] = Object.freeze(['HOME', 'PATH', 'TMPDIR', 'USER', 'LANG'])
100
+
101
+ /**
102
+ * The explicit, minimal env for this module's Tailscale CLI calls (status and cert): HOME, PATH, TMPDIR,
103
+ * USER and LANG when set, and SHLVL=1. Never this process's env: it carries the .env values (the API token,
104
+ * provider keys), which the Tailscale child has no need for.
105
+ *
106
+ * WHY SHLVL (probe 2026-11-10, operations/personal/wk46_2026/phone_key_build_20261110/
107
+ * probe_tailscale_launchd/RESULT.md): the macsys Tailscale binary is the GUI app's own executable, and it
108
+ * decides whether it runs as the CLI from SHLVL. A child of the LaunchAgent's node has none, so it tries to
109
+ * be the GUI and prints "The Tailscale GUI failed to start (Tailscale.CLIError error 3)" with exit 0 in
110
+ * 0.04 s; with SHLVL=1 (and only HOME and PATH besides) it answers the JSON in 0.03 s.
111
+ *
112
+ * Only this module's calls get it. tailscale-nudge has the same gap and is left unchanged: fixing it changes
113
+ * behaviour for everyone and needs Miles's go (side finding, COORD 2026-11-10 12:03).
114
+ */
115
+ export function signerTlsCliEnv(base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
116
+ const env: NodeJS.ProcessEnv = {}
117
+ for (const key of SIGNER_TLS_CLI_ENV_KEYS) {
118
+ const value = base[key]
119
+ if (typeof value === 'string' && value !== '') env[key] = value
120
+ }
121
+ env.SHLVL = '1'
122
+ return env
123
+ }
124
+
125
+ /** A runner for this module's CLI calls: like tailscale-nudge's TailscaleRunner, plus the env the child gets. */
126
+ export type SignerTlsRunner = (bin: string, args: readonly string[], timeoutMs: number, env: NodeJS.ProcessEnv) => Promise<RunResult>
127
+
128
+ /**
129
+ * The default runner: a binary path and an argument array (never a shell), with `env` as the child's whole
130
+ * env. The result mapping is tailscale-nudge's execFileRunner's, copied so that file stays byte-identical.
131
+ */
132
+ export const signerTlsCliRunner: SignerTlsRunner = (bin, args, timeoutMs, env) => new Promise(resolve => {
133
+ const started = Date.now()
134
+ execFile(bin, [...args], { timeout: timeoutMs, killSignal: 'SIGKILL', maxBuffer: 1024 * 1024, windowsHide: true, encoding: 'utf8', env },
135
+ (error, stdout, stderr) => {
136
+ const err = error as (NodeJS.ErrnoException & { code?: number | string; killed?: boolean; signal?: string }) | null
137
+ const spawnError = err && typeof err.code === 'string' ? err.code : undefined
138
+ resolve({
139
+ code: err ? (typeof err.code === 'number' ? err.code : null) : 0,
140
+ signal: err?.signal ?? null,
141
+ killed: !!err?.killed,
142
+ stdout: String(stdout ?? ''),
143
+ stderr: String(stderr ?? ''),
144
+ ...(spawnError ? { spawnError } : {}),
145
+ elapsedMs: Date.now() - started,
146
+ })
147
+ })
148
+ })
149
+ /** What the macOS CLI prints, with exit 0, when it cannot reach the GUI app (no SHLVL; see signerTlsCliEnv). */
150
+ const CLI_GUI_UNAVAILABLE = /Tailscale GUI failed to start|Tailscale\.CLIError error 3\b/i
151
+
152
+ /**
153
+ * SR-11: every reason the status can carry, in one list. The type is derived from it, and COS Control's
154
+ * golden fixture is generated from it (Control Tests/fixtures/web-key/gen_control_golden.mts), so Control's
155
+ * sentences cannot drift from the server. Adding or renaming one means regenerating that fixture.
156
+ */
157
+ export const SIGNER_TLS_REASONS = Object.freeze([
158
+ 'ok',
159
+ 'pending',
160
+ 'tailnet_name_unknown',
161
+ 'tailnet_https_certs_off',
162
+ 'cert_domain_mismatch',
163
+ 'tailscale_not_running',
164
+ 'tailscale_cli_missing',
165
+ 'tailscale_cli_timeout',
166
+ 'tailscale_cli_gui_unavailable',
167
+ 'tailscale_output_too_large',
168
+ 'tailscale_status_failed',
169
+ 'tailscale_cert_failed',
170
+ 'cert_invalid',
171
+ 'cert_expired',
172
+ 'cert_not_yet_valid',
173
+ 'cert_untrusted',
174
+ 'cert_store_failed',
175
+ 'cert_daily_cap',
176
+ 'stored_symlink',
177
+ 'stored_not_file',
178
+ 'stored_owner',
179
+ 'stored_writable',
180
+ 'stored_too_large',
181
+ ] as const)
182
+
183
+ export type SignerTlsReason = typeof SIGNER_TLS_REASONS[number]
184
+
185
+ export interface SignerTlsStatus {
186
+ /**
187
+ * 3144 presents a publicly trusted certificate for this Mac's tailnet name: it chains, link by link, to one
188
+ * of Node's bundled public roots (Mozilla's list, `tls.rootCertificates`), it is inside its validity window,
189
+ * and its key is the one served. No other pair is ever served by SNI (SR-01, review 2026-11-10).
190
+ */
191
+ trusted: boolean
192
+ /** `ok` when trusted; otherwise why not. */
193
+ reason: SignerTlsReason
194
+ /** The name the served certificate covers, or null. */
195
+ host: string | null
196
+ notAfter: string | null
197
+ /** The last check's failure, or null after a clean check (a renewal can fail while a pair still serves). */
198
+ lastError: SignerTlsReason | null
199
+ checkedAt: string | null
200
+ nextCheckAt: string | null
201
+ /**
202
+ * SR-08: why the last read of the stored pair was refused (a `stored_*` hygiene reason, or the pair's own
203
+ * check: `cert_untrusted`, `cert_invalid`, `cert_expired`, `cert_not_yet_valid`), or null when the stored
204
+ * pair was taken, is absent, or only lasts less long than the one serving.
205
+ */
206
+ storedRefused: SignerTlsReason | null
207
+ }
208
+
209
+ export interface SignerTlsPair {
210
+ host: string
211
+ certPem: string
212
+ keyPem: string
213
+ notAfterMs: number
214
+ context: SecureContext
215
+ }
216
+
217
+ export type Timer = { cancel: () => void }
218
+
219
+ export interface SignerTlsDeps {
220
+ /** <data dir>/web-key-tls. Created only when a pair is first stored. */
221
+ dir: string
222
+ /** This Mac's tailnet name (index.ts: tailnetSelf.refresh()). Null: unknown. */
223
+ selfDnsName: () => Promise<string | null>
224
+ /** Default signerTlsCliRunner. Every call gets signerTlsCliEnv() as the child's env. */
225
+ runner?: SignerTlsRunner
226
+ resolveBin?: () => string | null
227
+ now?: () => number
228
+ log?: (line: string) => void
229
+ /** Test seam for the check timer. */
230
+ schedule?: (fn: () => void, ms: number) => Timer
231
+ /** SR-01 test seam: the public roots a pair must chain to. Default: Node's bundled roots (signerTlsPublicRoots). */
232
+ roots?: readonly X509Certificate[]
233
+ /** SR-08 test seam: the owner every stored file must have. Default: this process's uid. */
234
+ ownerUid?: number | null
235
+ }
236
+
237
+ const defaultSchedule = (fn: () => void, ms: number): Timer => {
238
+ const t = setTimeout(fn, ms)
239
+ t.unref?.()
240
+ return { cancel: () => clearTimeout(t) }
241
+ }
242
+
243
+ /** The cooldown after `consecutive` failed checks (1 = the first failure). */
244
+ export function signerTlsRetryMs(consecutive: number): number {
245
+ const n = Math.max(1, Math.floor(consecutive))
246
+ return Math.min(SIGNER_TLS_RETRY_BASE_MS * 2 ** (n - 1), SIGNER_TLS_RETRY_MAX_MS)
247
+ }
248
+
249
+ /** Whether a pair with this notAfter is due for renewal at `now`. */
250
+ export function signerTlsRenewalDue(notAfterMs: number, now: number): boolean {
251
+ return notAfterMs - now < SIGNER_TLS_RENEW_BEFORE_MS
252
+ }
253
+
254
+ const PEM_BLOCK = /-----BEGIN ([A-Z0-9 ]+)-----\r?\n[A-Za-z0-9+/=\r\n]+?-----END \1-----/g
255
+
256
+ /** The most issuer links followed from the leaf to a bundled root. */
257
+ export const SIGNER_TLS_MAX_CHAIN = 6
258
+
259
+ /** Parse root certificates, skipping any that do not parse. */
260
+ export function parseRootPems(pems: readonly string[]): X509Certificate[] {
261
+ const out: X509Certificate[] = []
262
+ for (const pem of pems) {
263
+ try { out.push(new X509Certificate(pem)) } catch { /* not a certificate: never a root */ }
264
+ }
265
+ return out
266
+ }
267
+
268
+ let bundledRoots: readonly X509Certificate[] | null = null
269
+ /** SR-01: Node's bundled public roots (Mozilla's list, `tls.rootCertificates`), parsed once. */
270
+ export function signerTlsPublicRoots(): readonly X509Certificate[] {
271
+ if (!bundledRoots) bundledRoots = Object.freeze(parseRootPems(rootCertificates))
272
+ return bundledRoots
273
+ }
274
+
275
+ function validAt(cert: X509Certificate, now: number): boolean {
276
+ const from = Date.parse(cert.validFrom)
277
+ const to = Date.parse(cert.validTo)
278
+ return Number.isFinite(from) && Number.isFinite(to) && from <= now && now < to
279
+ }
280
+
281
+ function issuedBy(child: X509Certificate, issuer: X509Certificate): boolean {
282
+ try { return child.checkIssued(issuer) && child.verify(issuer.publicKey) } catch { return false }
283
+ }
284
+
285
+ /**
286
+ * SR-01: whether `chain[0]` chains to one of `roots`, link by link. Each link is issued by the next
287
+ * (`checkIssued`: names, key identifiers, key usage) and its signature verifies with that issuer's key;
288
+ * every certificate on the path below the root is inside its validity window at `now`; every issuer below
289
+ * the root is a CA. The order the chain arrives in is not trusted: each step looks for the issuer among
290
+ * the certificates not yet used. A root's own validity is not checked (a trust anchor, as in browsers).
291
+ */
292
+ export function chainsToRoot(chain: readonly X509Certificate[], roots: readonly X509Certificate[], now: number): boolean {
293
+ if (chain.length === 0) return false
294
+ let cur = chain[0]
295
+ const unused = chain.slice(1)
296
+ for (let step = 0; step <= SIGNER_TLS_MAX_CHAIN; step++) {
297
+ if (!validAt(cur, now)) return false
298
+ if (roots.some(root => issuedBy(cur, root))) return true
299
+ const next = unused.findIndex(c => c.ca && issuedBy(cur, c))
300
+ if (next < 0) return false
301
+ cur = unused.splice(next, 1)[0]
302
+ }
303
+ return false
304
+ }
305
+
306
+ export type SignerTlsPairRefusal = 'cert_invalid' | 'cert_expired' | 'cert_not_yet_valid' | 'cert_untrusted'
307
+ export type SignerTlsPairCheck = { ok: true; pair: SignerTlsPair } | { ok: false; reason: SignerTlsPairRefusal }
308
+
309
+ /**
310
+ * Validate a cert chain plus key for `name` (or, with name null, for the *.ts.net name the leaf covers),
311
+ * the same way for every source: the fetch, the stored pair at boot, at each check and in the pickup.
312
+ * Refused with a reason: no single key or no certificate, the leaf does not name the host, or the key is
313
+ * not the leaf's (`cert_invalid`); past notAfter (`cert_expired`); before notBefore (`cert_not_yet_valid`);
314
+ * not chaining to `roots`, Node's bundled public roots by default (`cert_untrusted`). Never throws.
315
+ */
316
+ export function checkSignerTlsPair(text: string, name: string | null, now: number, roots: readonly X509Certificate[] = signerTlsPublicRoots()): SignerTlsPairCheck {
317
+ const refuse = (reason: SignerTlsPairRefusal): SignerTlsPairCheck => ({ ok: false, reason })
318
+ try {
319
+ const certs: string[] = []
320
+ const keys: string[] = []
321
+ for (const m of text.matchAll(PEM_BLOCK)) {
322
+ if (m[1] === 'CERTIFICATE') certs.push(m[0])
323
+ else if (/(^| )PRIVATE KEY$/.test(m[1])) keys.push(m[0])
324
+ }
325
+ if (certs.length === 0 || keys.length !== 1) return refuse('cert_invalid')
326
+ const leaf = new X509Certificate(certs[0])
327
+ const host = name ?? leafTailnetName(leaf)
328
+ if (!host || normalizeTailscaleDnsName(host) !== host) return refuse('cert_invalid')
329
+ if (leaf.checkHost(host, { subject: 'never' })?.toLowerCase() !== host) return refuse('cert_invalid')
330
+ const notAfterMs = Date.parse(leaf.validTo)
331
+ if (!Number.isFinite(notAfterMs) || notAfterMs <= now) return refuse('cert_expired')
332
+ // SR-01: never before the leaf's notBefore.
333
+ const notBeforeMs = Date.parse(leaf.validFrom)
334
+ if (!Number.isFinite(notBeforeMs) || notBeforeMs > now) return refuse('cert_not_yet_valid')
335
+ // SR-01: the key is the leaf's. createSecureContext alone does not refuse an EC certificate with an RSA
336
+ // key (probe 2026-11-11 on Node 24.1: no throw), so compare the public keys' SPKI bytes.
337
+ const spki = createPublicKey(createPrivateKey(keys[0])).export({ type: 'spki', format: 'der' })
338
+ if (!spki.equals(leaf.publicKey.export({ type: 'spki', format: 'der' }))) return refuse('cert_invalid')
339
+ // SR-01: publicly trusted: the leaf chains, link by link, to one of the roots.
340
+ if (!chainsToRoot(certs.map(c => new X509Certificate(c)), roots, now)) return refuse('cert_untrusted')
341
+ const certPem = `${certs.join('\n')}\n`
342
+ const keyPem = `${keys[0]}\n`
343
+ // Throws when the key does not belong to the leaf: a torn or swapped pair never serves.
344
+ const context = createSecureContext({ cert: certPem, key: keyPem })
345
+ return { ok: true, pair: { host, certPem, keyPem, notAfterMs, context } }
346
+ } catch {
347
+ return refuse('cert_invalid')
348
+ }
349
+ }
350
+
351
+ /** checkSignerTlsPair without the reason: the pair, or null. */
352
+ export function parseSignerTlsPair(text: string, name: string | null, now: number, roots?: readonly X509Certificate[]): SignerTlsPair | null {
353
+ const checked = checkSignerTlsPair(text, name, now, roots)
354
+ return checked.ok ? checked.pair : null
355
+ }
356
+
357
+ function leafTailnetName(leaf: X509Certificate): string | null {
358
+ const san = leaf.subjectAltName ?? ''
359
+ for (const part of san.split(',')) {
360
+ const m = /^\s*DNS:(.+)$/.exec(part)
361
+ const n = m ? normalizeTailscaleDnsName(m[1]) : null
362
+ if (n) return n
363
+ }
364
+ return null
365
+ }
366
+
367
+ function classifyCertFailure(r: RunResult): SignerTlsReason {
368
+ if (r.spawnError) return r.spawnError === MAXBUFFER ? 'tailscale_output_too_large' : 'tailscale_cli_missing'
369
+ if (r.killed || r.signal) return 'tailscale_cli_timeout'
370
+ const text = `${r.stderr}\n${r.stdout.includes('PRIVATE KEY') ? '' : r.stdout}`
371
+ if (/does not support getting TLS certs|HTTPS cert(ificate)?s? (support )?(is |are )?not enabled|not enabled\/configured/i.test(text)) return 'tailnet_https_certs_off'
372
+ if (/invalid domain|must be one of/i.test(text)) return 'cert_domain_mismatch'
373
+ return 'tailscale_cert_failed'
374
+ }
375
+
376
+ function firstLine(s: string): string {
377
+ return (s.split('\n').find(l => l.trim()) ?? '').replace(/[^\x20-\x7e]/g, '').slice(0, 160)
378
+ }
379
+
380
+ type StoredRefusal = 'stored_symlink' | 'stored_not_file' | 'stored_owner' | 'stored_writable' | 'stored_too_large'
381
+ type StoredRead = { kind: 'none' } | { kind: 'refused'; reason: StoredRefusal } | { kind: 'ok'; text: string }
382
+
383
+ /** SR-08: the owner and mode rules shared by the folder and each file. */
384
+ function ownerOrModeRefusal(uid: number, mode: number, ownerUid: number | null): StoredRefusal | null {
385
+ if (ownerUid !== null && uid !== ownerUid) return 'stored_owner'
386
+ if ((mode & 0o022) !== 0) return 'stored_writable'
387
+ return null
388
+ }
389
+
390
+ /**
391
+ * SR-08: the stored-pair folder: absent (nothing stored), refused, or fine. Not a symlink, a directory,
392
+ * owned by this server's user, and not writable by group or others.
393
+ */
394
+ function storedDirRefusal(dir: string, ownerUid: number | null): 'none' | StoredRefusal | null {
395
+ const st = lstatSync(dir, { throwIfNoEntry: false })
396
+ if (!st) return 'none'
397
+ if (st.isSymbolicLink()) return 'stored_symlink'
398
+ if (!st.isDirectory()) return 'stored_not_file'
399
+ return ownerOrModeRefusal(st.uid, st.mode, ownerUid)
400
+ }
401
+
402
+ /**
403
+ * SR-08: read one stored file, or refuse it. Opened with O_NOFOLLOW and judged on the open descriptor
404
+ * (no swap between the check and the read): a regular file, owned by this server's user, not writable by
405
+ * group or others, at most SIGNER_TLS_STORED_MAX_BYTES (read with that cap, never the whole file).
406
+ */
407
+ function readStoredFile(path: string, ownerUid: number | null): StoredRead {
408
+ const pre = lstatSync(path, { throwIfNoEntry: false })
409
+ if (!pre) return { kind: 'none' }
410
+ if (pre.isSymbolicLink()) return { kind: 'refused', reason: 'stored_symlink' }
411
+ let fd: number
412
+ try {
413
+ fd = openSync(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK)
414
+ } catch (error) {
415
+ const code = (error as NodeJS.ErrnoException).code
416
+ if (code === 'ENOENT') return { kind: 'none' }
417
+ if (code === 'ELOOP') return { kind: 'refused', reason: 'stored_symlink' }
418
+ throw error
419
+ }
420
+ try {
421
+ const st = fstatSync(fd)
422
+ if (!st.isFile()) return { kind: 'refused', reason: 'stored_not_file' }
423
+ const refusal = ownerOrModeRefusal(st.uid, st.mode, ownerUid)
424
+ if (refusal) return { kind: 'refused', reason: refusal }
425
+ if (st.size > SIGNER_TLS_STORED_MAX_BYTES) return { kind: 'refused', reason: 'stored_too_large' }
426
+ const buf = Buffer.alloc(SIGNER_TLS_STORED_MAX_BYTES + 1)
427
+ let total = 0
428
+ for (;;) {
429
+ const n = readSync(fd, buf, total, buf.length - total, total)
430
+ if (n <= 0) break
431
+ total += n
432
+ if (total > SIGNER_TLS_STORED_MAX_BYTES) return { kind: 'refused', reason: 'stored_too_large' }
433
+ }
434
+ return { kind: 'ok', text: buf.subarray(0, total).toString('utf8') }
435
+ } finally {
436
+ closeSync(fd)
437
+ }
438
+ }
439
+
440
+ /** SR-05: what survives a restart. */
441
+ interface FetchState {
442
+ v: 1
443
+ /** The UTC day `certCalls` counts (YYYY-MM-DD). */
444
+ day: string
445
+ certCalls: number
446
+ lastCertAt: number | null
447
+ failures: number
448
+ cooldownUntil: number | null
449
+ lastError: SignerTlsReason | null
450
+ }
451
+ const utcDay = (ms: number): string => new Date(ms).toISOString().slice(0, 10)
452
+ const finiteOrNull = (v: unknown): number | null => (typeof v === 'number' && Number.isFinite(v) ? v : null)
453
+
454
+ export class SignerTls {
455
+ private readonly now: () => number
456
+ private readonly runner: SignerTlsRunner
457
+ private readonly resolveBin: () => string | null
458
+ private readonly log: (line: string) => void
459
+ private readonly schedule: (fn: () => void, ms: number) => Timer
460
+ private readonly roots: readonly X509Certificate[]
461
+ private readonly ownerUid: number | null
462
+ private current: SignerTlsPair | null = null
463
+ private storedRefused: SignerTlsReason | null = null
464
+ private name: string | null = null
465
+ private lastError: SignerTlsReason | null = null
466
+ private checkedAt: number | null = null
467
+ private nextCheckAt: number | null = null
468
+ private failures = 0
469
+ private cooldownUntil: number | null = null
470
+ private inflight: Promise<SignerTlsStatus> | null = null
471
+ private timer: Timer | null = null
472
+ private stopped = false
473
+ /** The stored files' `stat` signature the prompt pickup last read (null: never read). */
474
+ private pickupSeen: string | null = null
475
+ /** SR-04: when a status read last started a re-check, and whether the next check may skip the cooldown. */
476
+ private lastRecheckAt: number | null = null
477
+ private bypassCooldown = false
478
+ /** SR-05: the saved breaker and day's count. */
479
+ private fetchState: FetchState
480
+
481
+ constructor(private readonly deps: SignerTlsDeps) {
482
+ this.now = deps.now ?? Date.now
483
+ this.runner = deps.runner ?? signerTlsCliRunner
484
+ this.resolveBin = deps.resolveBin ?? (() => resolveTailscaleBin())
485
+ this.log = deps.log ?? (line => console.log(line))
486
+ this.schedule = deps.schedule ?? defaultSchedule
487
+ this.roots = deps.roots ?? signerTlsPublicRoots()
488
+ this.ownerUid = deps.ownerUid !== undefined ? deps.ownerUid : (typeof process.getuid === 'function' ? process.getuid() : null)
489
+ this.fetchState = { v: 1, day: utcDay(this.now()), certCalls: 0, lastCertAt: null, failures: 0, cooldownUntil: null, lastError: null }
490
+ this.loadFetchState()
491
+ this.loadStored()
492
+ }
493
+
494
+ /** For `https.createServer({ SNICallback })` on 3144. Unmatched: the server's default (mkcert) context. */
495
+ readonly sniCallback = (servername: string, cb: (err: Error | null, ctx?: SecureContext) => void): void => {
496
+ if (!this.sniMatches(this.current, servername)) this.pickUpStored()
497
+ // Read again after the pickup: a pair stored just now serves in this same handshake.
498
+ const pair = this.current
499
+ if (pair && this.sniMatches(pair, servername)) {
500
+ cb(null, pair.context)
501
+ return
502
+ }
503
+ cb(null, undefined)
504
+ }
505
+
506
+ private sniMatches(pair: SignerTlsPair | null, servername: string): boolean {
507
+ return pair !== null && typeof servername === 'string' && servername.toLowerCase() === pair.host && this.now() < pair.notAfterMs
508
+ // SR-09: and only for this Mac's current name once it is known (after a rename, never the old name's pair).
509
+ && (this.name === null || this.name === pair.host)
510
+ }
511
+
512
+ /** The pair being served, for tests and diagnostics. */
513
+ served(): SignerTlsPair | null { return this.current }
514
+
515
+ /** A trusted pair serves: unexpired, and for this Mac's tailnet name once that is known. */
516
+ private live(at: number): boolean {
517
+ const pair = this.current
518
+ return pair !== null && at < pair.notAfterMs && (this.name === null || this.name === pair.host)
519
+ }
520
+
521
+ /**
522
+ * The prompt pickup (see the header). Only while nothing trusted serves. Two `stat` calls; the files
523
+ * are read and validated only when their signature changed since the last pickup, so repeated
524
+ * handshakes or status reads cost no parsing. Scoped to this Mac's name like the check's re-read, and
525
+ * never a step back (loadStored). Never a CLI call, never a write, never throws.
526
+ */
527
+ private pickUpStored(): void {
528
+ try {
529
+ if (this.live(this.now())) return
530
+ const sig = this.storedSignature()
531
+ if (sig === null || sig === this.pickupSeen) return
532
+ this.pickupSeen = sig
533
+ const before = this.current
534
+ this.loadStored(this.name)
535
+ const pair = this.current
536
+ if (pair !== null && pair !== before) this.log(`[signer-tls] serving a stored certificate on 3144 until ${new Date(pair.notAfterMs).toISOString()}`)
537
+ else this.log(`[signer-tls] a stored certificate was not taken: ${this.storedRefused ?? 'not newer than the one serving'}`)
538
+ } catch { /* the next handshake, status read or check tries again */ }
539
+ }
540
+
541
+ /** inode, size, mtime and ctime of both stored files (lstat: a link's own change); null when either is missing (any other error throws to the caller's catch). */
542
+ private storedSignature(): string | null {
543
+ const parts: string[] = []
544
+ for (const file of [SIGNER_TLS_CERT_FILE, SIGNER_TLS_KEY_FILE]) {
545
+ const st = lstatSync(join(this.deps.dir, file), { bigint: true, throwIfNoEntry: false })
546
+ if (!st) return null
547
+ parts.push(`${st.dev}:${st.ino}:${st.size}:${st.mtimeNs}:${st.ctimeNs}`)
548
+ }
549
+ return parts.join('|')
550
+ }
551
+
552
+ /** Also the prompt pickup's second trigger: while nothing trusted serves, a status read looks at the stored files first. */
553
+ status(): SignerTlsStatus {
554
+ this.pickUpStored()
555
+ const pair = this.current
556
+ const at = this.now()
557
+ const live = this.live(at)
558
+ let reason: SignerTlsReason = 'ok'
559
+ if (!live) reason = this.lastError ?? (pair && at >= pair.notAfterMs ? 'cert_expired' : 'pending')
560
+ return {
561
+ trusted: live,
562
+ reason,
563
+ host: pair ? pair.host : null,
564
+ notAfter: pair ? new Date(pair.notAfterMs).toISOString() : null,
565
+ lastError: this.lastError,
566
+ checkedAt: this.checkedAt === null ? null : new Date(this.checkedAt).toISOString(),
567
+ nextCheckAt: this.nextCheckAt === null ? null : new Date(this.nextCheckAt).toISOString(),
568
+ storedRefused: this.storedRefused,
569
+ }
570
+ }
571
+
572
+ /**
573
+ * SR-04: what the authenticated status route calls (index.ts). When the last check failed only because
574
+ * HTTPS Certificates was off for the tailnet or the tailnet name was unknown, both fixed by the user outside
575
+ * this server, start one check now instead of waiting out the backoff (up to SIGNER_TLS_RETRY_MAX_MS): at
576
+ * most once per SIGNER_TLS_RECHECK_FLOOR_MS, never while a check runs, never after stop(). It does not wait
577
+ * for that check; the next read shows its result. Any other failure keeps the breaker as it is.
578
+ */
579
+ statusRead(): SignerTlsStatus {
580
+ try {
581
+ const at = this.now()
582
+ if (!this.stopped && this.inflight === null && this.lastError !== null && RECHECK_ON_READ.has(this.lastError)
583
+ && (this.lastRecheckAt === null || at - this.lastRecheckAt >= SIGNER_TLS_RECHECK_FLOOR_MS)) {
584
+ this.lastRecheckAt = at
585
+ this.bypassCooldown = true
586
+ void this.check()
587
+ }
588
+ } catch { /* the timer checks again */ }
589
+ return this.status()
590
+ }
591
+
592
+ /** The boot check, then one every interval (or after the cooldown while failing). */
593
+ start(): void {
594
+ this.stopped = false
595
+ void this.check()
596
+ }
597
+
598
+ stop(): void {
599
+ this.stopped = true
600
+ this.timer?.cancel()
601
+ this.timer = null
602
+ }
603
+
604
+ /** One check (single-flight). Never throws. */
605
+ check(): Promise<SignerTlsStatus> {
606
+ if (this.inflight) return this.inflight
607
+ this.inflight = this.runCheck()
608
+ .catch(() => { this.fail('tailscale_cert_failed') })
609
+ .then(() => {
610
+ try { this.reschedule() } catch { /* the next boot checks again */ }
611
+ return this.status()
612
+ })
613
+ .finally(() => { this.inflight = null })
614
+ return this.inflight
615
+ }
616
+
617
+ private reschedule(): void {
618
+ this.timer?.cancel()
619
+ this.timer = null
620
+ if (this.stopped) return
621
+ const at = this.now()
622
+ const next = this.failures > 0 && this.cooldownUntil !== null ? this.cooldownUntil : at + SIGNER_TLS_CHECK_INTERVAL_MS
623
+ this.nextCheckAt = next
624
+ this.timer = this.schedule(() => { void this.check() }, Math.max(0, next - at))
625
+ }
626
+
627
+ private succeed(storeFailed: boolean): void {
628
+ this.failures = 0
629
+ this.cooldownUntil = null
630
+ this.lastError = storeFailed ? 'cert_store_failed' : null
631
+ this.checkedAt = this.now()
632
+ }
633
+
634
+ private fail(reason: SignerTlsReason, facts = ''): void {
635
+ const at = this.now()
636
+ this.failures += 1
637
+ this.cooldownUntil = at + signerTlsRetryMs(this.failures)
638
+ this.lastError = reason
639
+ this.checkedAt = at
640
+ this.log(`[signer-tls] no trusted certificate: ${reason}${facts ? ` (${facts})` : ''}; next try in ${Math.round(signerTlsRetryMs(this.failures) / 1000)}s`)
641
+ }
642
+
643
+ private async runCheck(): Promise<void> {
644
+ // SR-04: a re-check a status read started may skip the cooldown, once.
645
+ const bypass = this.bypassCooldown
646
+ this.bypassCooldown = false
647
+ let name: string | null = null
648
+ try { name = normalizeTailscaleDnsName(await this.deps.selfDnsName()) } catch { name = null }
649
+ if (!name) { this.fail('tailnet_name_unknown'); return }
650
+ this.name = name
651
+ // A pair stored by another process (e.g. COS Control's helper, if this LaunchAgent's CLI cannot
652
+ // answer) is picked up here, validated, without a restart and without a CLI call.
653
+ this.loadStored(name)
654
+ const at = this.now()
655
+ const pair = this.current
656
+ if (pair && pair.host === name && !signerTlsRenewalDue(pair.notAfterMs, at)) { this.succeed(false); return }
657
+ // The breaker: no CLI call inside the cooldown after a failure.
658
+ if (this.cooldownUntil !== null && at < this.cooldownUntil && !bypass) return
659
+ const bin = this.resolveBin()
660
+ if (!bin) { this.fail('tailscale_cli_missing'); return }
661
+ // The minimal env with SHLVL=1 for both calls (signerTlsCliEnv): never this process's env.
662
+ const cliEnv = signerTlsCliEnv()
663
+
664
+ const st = await this.runner(bin, SIGNER_TLS_STATUS_ARGS, SIGNER_TLS_STATUS_TIMEOUT_MS, cliEnv)
665
+ if (st.spawnError) { this.fail(st.spawnError === MAXBUFFER ? 'tailscale_output_too_large' : 'tailscale_cli_missing', `spawn ${st.spawnError}`); return }
666
+ if (st.killed || st.signal) { this.fail('tailscale_cli_timeout', `status killed=${st.killed} signal=${st.signal ?? ''} ${st.elapsedMs}ms`); return }
667
+ const parsed = st.code === 0 ? parseTailscaleStatus(st.stdout, this.now()) : null
668
+ if (!parsed) {
669
+ // The CLI's "GUI failed to start" answer (exit 0, see signerTlsCliEnv) gets its own reason, so the status names it.
670
+ this.fail(CLI_GUI_UNAVAILABLE.test(`${st.stdout}\n${st.stderr}`) ? 'tailscale_cli_gui_unavailable' : 'tailscale_status_failed', `status code=${st.code} ${firstLine(st.stderr)}`)
671
+ return
672
+ }
673
+ if (parsed.backendState !== 'Running') { this.fail('tailscale_not_running', parsed.backendState); return }
674
+ const domains = parsed.certDomains ?? []
675
+ if (domains.length === 0) { this.fail('tailnet_https_certs_off'); return }
676
+ if (!domains.includes(name)) { this.fail('cert_domain_mismatch'); return }
677
+
678
+ // SR-05: at most SIGNER_TLS_CERT_CALLS_PER_DAY cert calls per UTC day. The call is counted and SAVED before
679
+ // it runs, so a crash during the call still counts; if the count cannot be saved, no call is made.
680
+ const today = utcDay(this.now())
681
+ if (this.fetchState.day !== today) this.fetchState = { ...this.fetchState, day: today, certCalls: 0 }
682
+ if (this.fetchState.certCalls >= SIGNER_TLS_CERT_CALLS_PER_DAY) { this.fail('cert_daily_cap', `${this.fetchState.certCalls} today`); return }
683
+ this.fetchState = { ...this.fetchState, certCalls: this.fetchState.certCalls + 1, lastCertAt: this.now() }
684
+ if (!this.saveFetchState()) { this.fail('cert_store_failed', 'fetch state not saved; no cert call'); return }
685
+
686
+ const r = await this.runner(bin, SIGNER_TLS_CERT_ARGS(name), SIGNER_TLS_CERT_TIMEOUT_MS, cliEnv)
687
+ if (r.spawnError || r.killed || r.signal || r.code !== 0) {
688
+ this.fail(classifyCertFailure(r), `cert code=${r.code} killed=${r.killed} signal=${r.signal ?? ''} ${r.elapsedMs}ms ${firstLine(r.stderr)}`)
689
+ this.recordCertOutcome()
690
+ return
691
+ }
692
+ // SR-01: the same checks as a stored pair, including the public-root chain; the refusal names why.
693
+ const checked = checkSignerTlsPair(r.stdout, name, this.now(), this.roots)
694
+ if (!checked.ok) { this.fail(checked.reason); this.recordCertOutcome(); return }
695
+ const fresh = checked.pair
696
+ let storeFailed = false
697
+ try { this.store(fresh) } catch { storeFailed = true }
698
+ // The hot swap: the next 3144 handshake reads this pair.
699
+ this.current = fresh
700
+ this.succeed(storeFailed)
701
+ this.recordCertOutcome()
702
+ this.log(`[signer-tls] serving a trusted certificate on 3144 until ${new Date(fresh.notAfterMs).toISOString()}${storeFailed ? ' (not saved: cert_store_failed)' : ''}`)
703
+ }
704
+
705
+ private store(pair: SignerTlsPair): void {
706
+ mkdirSync(this.deps.dir, { recursive: true, mode: 0o700 })
707
+ chmodSync(this.deps.dir, 0o700)
708
+ // Key first: a crash between the two writes leaves a pair that fails to load (key mismatch) and is fetched again.
709
+ durableAtomicWriteFileSync(join(this.deps.dir, SIGNER_TLS_KEY_FILE), pair.keyPem, { mode: SIGNER_TLS_KEY_MODE })
710
+ durableAtomicWriteFileSync(join(this.deps.dir, SIGNER_TLS_CERT_FILE), pair.certPem, { mode: SIGNER_TLS_CERT_MODE })
711
+ }
712
+
713
+ /**
714
+ * SR-05: restore the day's cert-call count and, while it has not run out, the breaker (failures, cooldown,
715
+ * last error), so a restart or a crash loop cannot call `tailscale cert` sooner than the backoff allows.
716
+ * Read with the same hygiene as the pair (SR-08). Missing, refused or malformed: nothing restored.
717
+ */
718
+ private loadFetchState(): void {
719
+ try {
720
+ if (storedDirRefusal(this.deps.dir, this.ownerUid) !== null) return
721
+ const read = readStoredFile(join(this.deps.dir, SIGNER_TLS_STATE_FILE), this.ownerUid)
722
+ if (read.kind !== 'ok') return
723
+ const raw = JSON.parse(read.text) as Record<string, unknown>
724
+ if (!raw || raw.v !== 1 || typeof raw.day !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(raw.day)) return
725
+ const certCalls = typeof raw.certCalls === 'number' && Number.isInteger(raw.certCalls) && raw.certCalls >= 0 ? raw.certCalls : 0
726
+ const failures = typeof raw.failures === 'number' && Number.isInteger(raw.failures) && raw.failures >= 0 ? raw.failures : 0
727
+ const cooldownUntil = finiteOrNull(raw.cooldownUntil)
728
+ const lastError = typeof raw.lastError === 'string' && (SIGNER_TLS_REASONS as readonly string[]).includes(raw.lastError) ? raw.lastError as SignerTlsReason : null
729
+ this.fetchState = { v: 1, day: raw.day, certCalls, lastCertAt: finiteOrNull(raw.lastCertAt), failures, cooldownUntil, lastError }
730
+ if (failures > 0 && cooldownUntil !== null && cooldownUntil > this.now()) {
731
+ this.failures = failures
732
+ this.cooldownUntil = cooldownUntil
733
+ this.lastError = lastError
734
+ }
735
+ } catch { /* nothing restored */ }
736
+ }
737
+
738
+ /** SR-05: save the state (0600, atomic) in the pair's folder (0700). False when it cannot be written. */
739
+ private saveFetchState(): boolean {
740
+ try {
741
+ mkdirSync(this.deps.dir, { recursive: true, mode: 0o700 })
742
+ chmodSync(this.deps.dir, 0o700)
743
+ durableAtomicWriteFileSync(join(this.deps.dir, SIGNER_TLS_STATE_FILE), `${JSON.stringify(this.fetchState)}\n`, { mode: 0o600 })
744
+ return true
745
+ } catch {
746
+ return false
747
+ }
748
+ }
749
+
750
+ /** SR-05: after a cert call, save how it ended (the breaker as it now stands). */
751
+ private recordCertOutcome(): void {
752
+ this.fetchState = { ...this.fetchState, failures: this.failures, cooldownUntil: this.cooldownUntil, lastError: this.lastError }
753
+ this.saveFetchState()
754
+ }
755
+
756
+ /** With a name: only a pair for exactly that name. Without (at construction): the *.ts.net name the leaf covers. */
757
+ private loadStored(name: string | null = null): void {
758
+ try {
759
+ // SR-08: the folder, then each file, pass the hygiene checks before anything is read.
760
+ const dir = storedDirRefusal(this.deps.dir, this.ownerUid)
761
+ if (dir === 'none') { this.storedRefused = null; return }
762
+ if (dir !== null) { this.storedRefused = dir; return }
763
+ const cert = readStoredFile(join(this.deps.dir, SIGNER_TLS_CERT_FILE), this.ownerUid)
764
+ const key = readStoredFile(join(this.deps.dir, SIGNER_TLS_KEY_FILE), this.ownerUid)
765
+ if (cert.kind === 'none' || key.kind === 'none') { this.storedRefused = null; return }
766
+ if (cert.kind === 'refused') { this.storedRefused = cert.reason; return }
767
+ if (key.kind === 'refused') { this.storedRefused = key.reason; return }
768
+ const checked = checkSignerTlsPair(`${cert.text}\n${key.text}`, name, this.now(), this.roots)
769
+ if (!checked.ok) { this.storedRefused = checked.reason; return }
770
+ this.storedRefused = null
771
+ const pair = checked.pair
772
+ // Never a step back: only when nothing serves, the serving pair names another host, or this one lasts longer.
773
+ if (pair && (this.current === null || this.current.host !== pair.host || pair.notAfterMs > this.current.notAfterMs)) this.current = pair
774
+ } catch { /* nothing stored that serves: the check fetches */ }
775
+ }
776
+ }
777
+
778
+ /**
779
+ * index.ts's one door: a SignerTls only with COS_WEB_KEY_AUTHORITY on AND the 3144 listener planned.
780
+ * Otherwise null, and nothing is read, written or run.
781
+ */
782
+ export function createSignerTlsForBoot(opts: { authorityEnabled: boolean; signerListener: boolean }, deps: SignerTlsDeps): SignerTls | null {
783
+ if (opts.authorityEnabled !== true || opts.signerListener !== true) return null
784
+ return new SignerTls(deps)
785
+ }
786
+
787
+ /** The 3144 server's TLS options: the mkcert pair as the default, plus SNI selection when a SignerTls exists. */
788
+ export function signerHttpsOptions(base: { cert: Buffer | string; key: Buffer | string }, tls: SignerTls | null): HttpsServerOptions {
789
+ return tls ? { ...base, SNICallback: tls.sniCallback } : { ...base }
790
+ }