@gotcos/glasses-server 7.0.27 → 7.0.30
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/CHANGELOG.md +93 -0
- package/docs/web-key-verifier-contract.md +34 -8
- package/package.json +1 -1
- package/server/index.ts +29 -6
- package/server/lib/provider-failure-text.ts +4 -1
- package/server/lib/tailnet-self-name.ts +57 -10
- package/server/lib/web-key-authority.ts +1 -1
- package/server/lib/web-key-registry.ts +1 -1
- package/server/lib/web-key-signer-app.ts +25 -6
- package/server/lib/web-key-signer-tls.ts +800 -0
- package/server/routes/web-key.ts +31 -2
- package/server/web-control/.manifest.json +16 -16
- package/server/web-control/app.css +58 -2
- package/server/web-control/app.js +478 -51
- package/server/web-control/control.css +32 -2
- package/server/web-control/index.html +46 -5
- package/server/web-control/panes/cos-chat.css +5 -1
- package/server/web-control/panes/cos-chat.js +9 -3
- package/server/web-control/panes/meetings.css +4 -0
- package/server/web-control/panes/meetings.js +25 -6
- package/server/web-control/panes/memories.js +2 -2
- package/server/web-control/panes/messages.js +7 -2
- package/server/web-control/panes/settings.js +1 -1
- package/server/web-control/panes/speakers.js +28 -3
- package/server/web-control/panes/today-actions.js +2 -2
- package/server/web-control/panes/work.js +1 -1
- package/server/web-control/shell.js +2 -1
- package/server/web-control/theme.js +169 -2
- package/server/web-signer/.manifest.json +2 -2
- package/server/web-signer/signer-proof.js +18 -3
- package/server/web-signer/signer.js +4 -2
- package/shared/web-turn-envelope.ts +33 -8
|
@@ -0,0 +1,800 @@
|
|
|
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
|
+
'stored_readable',
|
|
182
|
+
] as const)
|
|
183
|
+
|
|
184
|
+
export type SignerTlsReason = typeof SIGNER_TLS_REASONS[number]
|
|
185
|
+
|
|
186
|
+
export interface SignerTlsStatus {
|
|
187
|
+
/**
|
|
188
|
+
* 3144 presents a publicly trusted certificate for this Mac's tailnet name: it chains, link by link, to one
|
|
189
|
+
* of Node's bundled public roots (Mozilla's list, `tls.rootCertificates`), it is inside its validity window,
|
|
190
|
+
* and its key is the one served. No other pair is ever served by SNI (SR-01, review 2026-11-10).
|
|
191
|
+
*/
|
|
192
|
+
trusted: boolean
|
|
193
|
+
/** `ok` when trusted; otherwise why not. */
|
|
194
|
+
reason: SignerTlsReason
|
|
195
|
+
/** The name the served certificate covers, or null. */
|
|
196
|
+
host: string | null
|
|
197
|
+
notAfter: string | null
|
|
198
|
+
/** The last check's failure, or null after a clean check (a renewal can fail while a pair still serves). */
|
|
199
|
+
lastError: SignerTlsReason | null
|
|
200
|
+
checkedAt: string | null
|
|
201
|
+
nextCheckAt: string | null
|
|
202
|
+
/**
|
|
203
|
+
* SR-08: why the last read of the stored pair was refused (a `stored_*` hygiene reason, or the pair's own
|
|
204
|
+
* check: `cert_untrusted`, `cert_invalid`, `cert_expired`, `cert_not_yet_valid`), or null when the stored
|
|
205
|
+
* pair was taken, is absent, or only lasts less long than the one serving.
|
|
206
|
+
*/
|
|
207
|
+
storedRefused: SignerTlsReason | null
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export interface SignerTlsPair {
|
|
211
|
+
host: string
|
|
212
|
+
certPem: string
|
|
213
|
+
keyPem: string
|
|
214
|
+
notAfterMs: number
|
|
215
|
+
context: SecureContext
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export type Timer = { cancel: () => void }
|
|
219
|
+
|
|
220
|
+
export interface SignerTlsDeps {
|
|
221
|
+
/** <data dir>/web-key-tls. Created only when a pair is first stored. */
|
|
222
|
+
dir: string
|
|
223
|
+
/** This Mac's tailnet name (index.ts: tailnetSelf.refresh()). Null: unknown. */
|
|
224
|
+
selfDnsName: () => Promise<string | null>
|
|
225
|
+
/** Default signerTlsCliRunner. Every call gets signerTlsCliEnv() as the child's env. */
|
|
226
|
+
runner?: SignerTlsRunner
|
|
227
|
+
resolveBin?: () => string | null
|
|
228
|
+
now?: () => number
|
|
229
|
+
log?: (line: string) => void
|
|
230
|
+
/** Test seam for the check timer. */
|
|
231
|
+
schedule?: (fn: () => void, ms: number) => Timer
|
|
232
|
+
/** SR-01 test seam: the public roots a pair must chain to. Default: Node's bundled roots (signerTlsPublicRoots). */
|
|
233
|
+
roots?: readonly X509Certificate[]
|
|
234
|
+
/** SR-08 test seam: the owner every stored file must have. Default: this process's uid. */
|
|
235
|
+
ownerUid?: number | null
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const defaultSchedule = (fn: () => void, ms: number): Timer => {
|
|
239
|
+
const t = setTimeout(fn, ms)
|
|
240
|
+
t.unref?.()
|
|
241
|
+
return { cancel: () => clearTimeout(t) }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** The cooldown after `consecutive` failed checks (1 = the first failure). */
|
|
245
|
+
export function signerTlsRetryMs(consecutive: number): number {
|
|
246
|
+
const n = Math.max(1, Math.floor(consecutive))
|
|
247
|
+
return Math.min(SIGNER_TLS_RETRY_BASE_MS * 2 ** (n - 1), SIGNER_TLS_RETRY_MAX_MS)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Whether a pair with this notAfter is due for renewal at `now`. */
|
|
251
|
+
export function signerTlsRenewalDue(notAfterMs: number, now: number): boolean {
|
|
252
|
+
return notAfterMs - now < SIGNER_TLS_RENEW_BEFORE_MS
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const PEM_BLOCK = /-----BEGIN ([A-Z0-9 ]+)-----\r?\n[A-Za-z0-9+/=\r\n]+?-----END \1-----/g
|
|
256
|
+
|
|
257
|
+
/** The most issuer links followed from the leaf to a bundled root. */
|
|
258
|
+
export const SIGNER_TLS_MAX_CHAIN = 6
|
|
259
|
+
|
|
260
|
+
/** Parse root certificates, skipping any that do not parse. */
|
|
261
|
+
export function parseRootPems(pems: readonly string[]): X509Certificate[] {
|
|
262
|
+
const out: X509Certificate[] = []
|
|
263
|
+
for (const pem of pems) {
|
|
264
|
+
try { out.push(new X509Certificate(pem)) } catch { /* not a certificate: never a root */ }
|
|
265
|
+
}
|
|
266
|
+
return out
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
let bundledRoots: readonly X509Certificate[] | null = null
|
|
270
|
+
/** SR-01: Node's bundled public roots (Mozilla's list, `tls.rootCertificates`), parsed once. */
|
|
271
|
+
export function signerTlsPublicRoots(): readonly X509Certificate[] {
|
|
272
|
+
if (!bundledRoots) bundledRoots = Object.freeze(parseRootPems(rootCertificates))
|
|
273
|
+
return bundledRoots
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function validAt(cert: X509Certificate, now: number): boolean {
|
|
277
|
+
const from = Date.parse(cert.validFrom)
|
|
278
|
+
const to = Date.parse(cert.validTo)
|
|
279
|
+
return Number.isFinite(from) && Number.isFinite(to) && from <= now && now < to
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function issuedBy(child: X509Certificate, issuer: X509Certificate): boolean {
|
|
283
|
+
try { return child.checkIssued(issuer) && child.verify(issuer.publicKey) } catch { return false }
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* SR-01: whether `chain[0]` chains to one of `roots`, link by link. Each link is issued by the next
|
|
288
|
+
* (`checkIssued`: names, key identifiers, key usage) and its signature verifies with that issuer's key;
|
|
289
|
+
* every certificate on the path below the root is inside its validity window at `now`; every issuer below
|
|
290
|
+
* the root is a CA. The order the chain arrives in is not trusted: each step looks for the issuer among
|
|
291
|
+
* the certificates not yet used. A root's own validity is not checked (a trust anchor, as in browsers).
|
|
292
|
+
*/
|
|
293
|
+
export function chainsToRoot(chain: readonly X509Certificate[], roots: readonly X509Certificate[], now: number): boolean {
|
|
294
|
+
if (chain.length === 0) return false
|
|
295
|
+
let cur = chain[0]
|
|
296
|
+
const unused = chain.slice(1)
|
|
297
|
+
for (let step = 0; step <= SIGNER_TLS_MAX_CHAIN; step++) {
|
|
298
|
+
if (!validAt(cur, now)) return false
|
|
299
|
+
if (roots.some(root => issuedBy(cur, root))) return true
|
|
300
|
+
const next = unused.findIndex(c => c.ca && issuedBy(cur, c))
|
|
301
|
+
if (next < 0) return false
|
|
302
|
+
cur = unused.splice(next, 1)[0]
|
|
303
|
+
}
|
|
304
|
+
return false
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export type SignerTlsPairRefusal = 'cert_invalid' | 'cert_expired' | 'cert_not_yet_valid' | 'cert_untrusted'
|
|
308
|
+
export type SignerTlsPairCheck = { ok: true; pair: SignerTlsPair } | { ok: false; reason: SignerTlsPairRefusal }
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Validate a cert chain plus key for `name` (or, with name null, for the *.ts.net name the leaf covers),
|
|
312
|
+
* the same way for every source: the fetch, the stored pair at boot, at each check and in the pickup.
|
|
313
|
+
* Refused with a reason: no single key or no certificate, the leaf does not name the host, or the key is
|
|
314
|
+
* not the leaf's (`cert_invalid`); past notAfter (`cert_expired`); before notBefore (`cert_not_yet_valid`);
|
|
315
|
+
* not chaining to `roots`, Node's bundled public roots by default (`cert_untrusted`). Never throws.
|
|
316
|
+
*/
|
|
317
|
+
export function checkSignerTlsPair(text: string, name: string | null, now: number, roots: readonly X509Certificate[] = signerTlsPublicRoots()): SignerTlsPairCheck {
|
|
318
|
+
const refuse = (reason: SignerTlsPairRefusal): SignerTlsPairCheck => ({ ok: false, reason })
|
|
319
|
+
try {
|
|
320
|
+
const certs: string[] = []
|
|
321
|
+
const keys: string[] = []
|
|
322
|
+
for (const m of text.matchAll(PEM_BLOCK)) {
|
|
323
|
+
if (m[1] === 'CERTIFICATE') certs.push(m[0])
|
|
324
|
+
else if (/(^| )PRIVATE KEY$/.test(m[1])) keys.push(m[0])
|
|
325
|
+
}
|
|
326
|
+
if (certs.length === 0 || keys.length !== 1) return refuse('cert_invalid')
|
|
327
|
+
const leaf = new X509Certificate(certs[0])
|
|
328
|
+
const host = name ?? leafTailnetName(leaf)
|
|
329
|
+
if (!host || normalizeTailscaleDnsName(host) !== host) return refuse('cert_invalid')
|
|
330
|
+
if (leaf.checkHost(host, { subject: 'never' })?.toLowerCase() !== host) return refuse('cert_invalid')
|
|
331
|
+
const notAfterMs = Date.parse(leaf.validTo)
|
|
332
|
+
if (!Number.isFinite(notAfterMs) || notAfterMs <= now) return refuse('cert_expired')
|
|
333
|
+
// SR-01: never before the leaf's notBefore.
|
|
334
|
+
const notBeforeMs = Date.parse(leaf.validFrom)
|
|
335
|
+
if (!Number.isFinite(notBeforeMs) || notBeforeMs > now) return refuse('cert_not_yet_valid')
|
|
336
|
+
// SR-01: the key is the leaf's. createSecureContext alone does not refuse an EC certificate with an RSA
|
|
337
|
+
// key (probe 2026-11-11 on Node 24.1: no throw), so compare the public keys' SPKI bytes.
|
|
338
|
+
const spki = createPublicKey(createPrivateKey(keys[0])).export({ type: 'spki', format: 'der' })
|
|
339
|
+
if (!spki.equals(leaf.publicKey.export({ type: 'spki', format: 'der' }))) return refuse('cert_invalid')
|
|
340
|
+
// SR-01: publicly trusted: the leaf chains, link by link, to one of the roots.
|
|
341
|
+
if (!chainsToRoot(certs.map(c => new X509Certificate(c)), roots, now)) return refuse('cert_untrusted')
|
|
342
|
+
const certPem = `${certs.join('\n')}\n`
|
|
343
|
+
const keyPem = `${keys[0]}\n`
|
|
344
|
+
// Throws when the key does not belong to the leaf: a torn or swapped pair never serves.
|
|
345
|
+
const context = createSecureContext({ cert: certPem, key: keyPem })
|
|
346
|
+
return { ok: true, pair: { host, certPem, keyPem, notAfterMs, context } }
|
|
347
|
+
} catch {
|
|
348
|
+
return refuse('cert_invalid')
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** checkSignerTlsPair without the reason: the pair, or null. */
|
|
353
|
+
export function parseSignerTlsPair(text: string, name: string | null, now: number, roots?: readonly X509Certificate[]): SignerTlsPair | null {
|
|
354
|
+
const checked = checkSignerTlsPair(text, name, now, roots)
|
|
355
|
+
return checked.ok ? checked.pair : null
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function leafTailnetName(leaf: X509Certificate): string | null {
|
|
359
|
+
const san = leaf.subjectAltName ?? ''
|
|
360
|
+
for (const part of san.split(',')) {
|
|
361
|
+
const m = /^\s*DNS:(.+)$/.exec(part)
|
|
362
|
+
const n = m ? normalizeTailscaleDnsName(m[1]) : null
|
|
363
|
+
if (n) return n
|
|
364
|
+
}
|
|
365
|
+
return null
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function classifyCertFailure(r: RunResult): SignerTlsReason {
|
|
369
|
+
if (r.spawnError) return r.spawnError === MAXBUFFER ? 'tailscale_output_too_large' : 'tailscale_cli_missing'
|
|
370
|
+
if (r.killed || r.signal) return 'tailscale_cli_timeout'
|
|
371
|
+
const text = `${r.stderr}\n${r.stdout.includes('PRIVATE KEY') ? '' : r.stdout}`
|
|
372
|
+
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'
|
|
373
|
+
if (/invalid domain|must be one of/i.test(text)) return 'cert_domain_mismatch'
|
|
374
|
+
return 'tailscale_cert_failed'
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function firstLine(s: string): string {
|
|
378
|
+
return (s.split('\n').find(l => l.trim()) ?? '').replace(/[^\x20-\x7e]/g, '').slice(0, 160)
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
type StoredRefusal = 'stored_symlink' | 'stored_not_file' | 'stored_owner' | 'stored_writable' | 'stored_too_large' | 'stored_readable'
|
|
382
|
+
type StoredRead = { kind: 'none' } | { kind: 'refused'; reason: StoredRefusal } | { kind: 'ok'; text: string }
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* SR-08: the owner and mode rules shared by the folder and each file.
|
|
386
|
+
*
|
|
387
|
+
* `secret` (security review 2026-11-12, finding 5): the folder and the private key may carry no group or other
|
|
388
|
+
* bits at all, read and search included, so a key another user can read is never served. The certificate is
|
|
389
|
+
* public and the fetch state holds counts only, so there only write bits are refused. This server has written
|
|
390
|
+
* the folder 0700, the key 0600, the state 0600 and the certificate 0644 since the store was added (d4f3ee6,
|
|
391
|
+
* 2026-11-09), so no pair it wrote is refused by this rule.
|
|
392
|
+
*/
|
|
393
|
+
function ownerOrModeRefusal(uid: number, mode: number, ownerUid: number | null, secret = false): StoredRefusal | null {
|
|
394
|
+
if (ownerUid !== null && uid !== ownerUid) return 'stored_owner'
|
|
395
|
+
if ((mode & 0o022) !== 0) return 'stored_writable'
|
|
396
|
+
if (secret && (mode & 0o077) !== 0) return 'stored_readable'
|
|
397
|
+
return null
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* SR-08: the stored-pair folder: absent (nothing stored), refused, or fine. Not a symlink, a directory,
|
|
402
|
+
* owned by this server's user, and not writable by group or others.
|
|
403
|
+
*/
|
|
404
|
+
function storedDirRefusal(dir: string, ownerUid: number | null): 'none' | StoredRefusal | null {
|
|
405
|
+
const st = lstatSync(dir, { throwIfNoEntry: false })
|
|
406
|
+
if (!st) return 'none'
|
|
407
|
+
if (st.isSymbolicLink()) return 'stored_symlink'
|
|
408
|
+
if (!st.isDirectory()) return 'stored_not_file'
|
|
409
|
+
return ownerOrModeRefusal(st.uid, st.mode, ownerUid, true)
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* SR-08: read one stored file, or refuse it. Opened with O_NOFOLLOW and judged on the open descriptor
|
|
414
|
+
* (no swap between the check and the read): a regular file, owned by this server's user, not writable by
|
|
415
|
+
* group or others, at most SIGNER_TLS_STORED_MAX_BYTES (read with that cap, never the whole file).
|
|
416
|
+
*/
|
|
417
|
+
function readStoredFile(path: string, ownerUid: number | null, secret = false): StoredRead {
|
|
418
|
+
const pre = lstatSync(path, { throwIfNoEntry: false })
|
|
419
|
+
if (!pre) return { kind: 'none' }
|
|
420
|
+
if (pre.isSymbolicLink()) return { kind: 'refused', reason: 'stored_symlink' }
|
|
421
|
+
let fd: number
|
|
422
|
+
try {
|
|
423
|
+
fd = openSync(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK)
|
|
424
|
+
} catch (error) {
|
|
425
|
+
const code = (error as NodeJS.ErrnoException).code
|
|
426
|
+
if (code === 'ENOENT') return { kind: 'none' }
|
|
427
|
+
if (code === 'ELOOP') return { kind: 'refused', reason: 'stored_symlink' }
|
|
428
|
+
throw error
|
|
429
|
+
}
|
|
430
|
+
try {
|
|
431
|
+
const st = fstatSync(fd)
|
|
432
|
+
if (!st.isFile()) return { kind: 'refused', reason: 'stored_not_file' }
|
|
433
|
+
const refusal = ownerOrModeRefusal(st.uid, st.mode, ownerUid, secret)
|
|
434
|
+
if (refusal) return { kind: 'refused', reason: refusal }
|
|
435
|
+
if (st.size > SIGNER_TLS_STORED_MAX_BYTES) return { kind: 'refused', reason: 'stored_too_large' }
|
|
436
|
+
const buf = Buffer.alloc(SIGNER_TLS_STORED_MAX_BYTES + 1)
|
|
437
|
+
let total = 0
|
|
438
|
+
for (;;) {
|
|
439
|
+
const n = readSync(fd, buf, total, buf.length - total, total)
|
|
440
|
+
if (n <= 0) break
|
|
441
|
+
total += n
|
|
442
|
+
if (total > SIGNER_TLS_STORED_MAX_BYTES) return { kind: 'refused', reason: 'stored_too_large' }
|
|
443
|
+
}
|
|
444
|
+
return { kind: 'ok', text: buf.subarray(0, total).toString('utf8') }
|
|
445
|
+
} finally {
|
|
446
|
+
closeSync(fd)
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** SR-05: what survives a restart. */
|
|
451
|
+
interface FetchState {
|
|
452
|
+
v: 1
|
|
453
|
+
/** The UTC day `certCalls` counts (YYYY-MM-DD). */
|
|
454
|
+
day: string
|
|
455
|
+
certCalls: number
|
|
456
|
+
lastCertAt: number | null
|
|
457
|
+
failures: number
|
|
458
|
+
cooldownUntil: number | null
|
|
459
|
+
lastError: SignerTlsReason | null
|
|
460
|
+
}
|
|
461
|
+
const utcDay = (ms: number): string => new Date(ms).toISOString().slice(0, 10)
|
|
462
|
+
const finiteOrNull = (v: unknown): number | null => (typeof v === 'number' && Number.isFinite(v) ? v : null)
|
|
463
|
+
|
|
464
|
+
export class SignerTls {
|
|
465
|
+
private readonly now: () => number
|
|
466
|
+
private readonly runner: SignerTlsRunner
|
|
467
|
+
private readonly resolveBin: () => string | null
|
|
468
|
+
private readonly log: (line: string) => void
|
|
469
|
+
private readonly schedule: (fn: () => void, ms: number) => Timer
|
|
470
|
+
private readonly roots: readonly X509Certificate[]
|
|
471
|
+
private readonly ownerUid: number | null
|
|
472
|
+
private current: SignerTlsPair | null = null
|
|
473
|
+
private storedRefused: SignerTlsReason | null = null
|
|
474
|
+
private name: string | null = null
|
|
475
|
+
private lastError: SignerTlsReason | null = null
|
|
476
|
+
private checkedAt: number | null = null
|
|
477
|
+
private nextCheckAt: number | null = null
|
|
478
|
+
private failures = 0
|
|
479
|
+
private cooldownUntil: number | null = null
|
|
480
|
+
private inflight: Promise<SignerTlsStatus> | null = null
|
|
481
|
+
private timer: Timer | null = null
|
|
482
|
+
private stopped = false
|
|
483
|
+
/** The stored files' `stat` signature the prompt pickup last read (null: never read). */
|
|
484
|
+
private pickupSeen: string | null = null
|
|
485
|
+
/** SR-04: when a status read last started a re-check, and whether the next check may skip the cooldown. */
|
|
486
|
+
private lastRecheckAt: number | null = null
|
|
487
|
+
private bypassCooldown = false
|
|
488
|
+
/** SR-05: the saved breaker and day's count. */
|
|
489
|
+
private fetchState: FetchState
|
|
490
|
+
|
|
491
|
+
constructor(private readonly deps: SignerTlsDeps) {
|
|
492
|
+
this.now = deps.now ?? Date.now
|
|
493
|
+
this.runner = deps.runner ?? signerTlsCliRunner
|
|
494
|
+
this.resolveBin = deps.resolveBin ?? (() => resolveTailscaleBin())
|
|
495
|
+
this.log = deps.log ?? (line => console.log(line))
|
|
496
|
+
this.schedule = deps.schedule ?? defaultSchedule
|
|
497
|
+
this.roots = deps.roots ?? signerTlsPublicRoots()
|
|
498
|
+
this.ownerUid = deps.ownerUid !== undefined ? deps.ownerUid : (typeof process.getuid === 'function' ? process.getuid() : null)
|
|
499
|
+
this.fetchState = { v: 1, day: utcDay(this.now()), certCalls: 0, lastCertAt: null, failures: 0, cooldownUntil: null, lastError: null }
|
|
500
|
+
this.loadFetchState()
|
|
501
|
+
this.loadStored()
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** For `https.createServer({ SNICallback })` on 3144. Unmatched: the server's default (mkcert) context. */
|
|
505
|
+
readonly sniCallback = (servername: string, cb: (err: Error | null, ctx?: SecureContext) => void): void => {
|
|
506
|
+
if (!this.sniMatches(this.current, servername)) this.pickUpStored()
|
|
507
|
+
// Read again after the pickup: a pair stored just now serves in this same handshake.
|
|
508
|
+
const pair = this.current
|
|
509
|
+
if (pair && this.sniMatches(pair, servername)) {
|
|
510
|
+
cb(null, pair.context)
|
|
511
|
+
return
|
|
512
|
+
}
|
|
513
|
+
cb(null, undefined)
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
private sniMatches(pair: SignerTlsPair | null, servername: string): boolean {
|
|
517
|
+
return pair !== null && typeof servername === 'string' && servername.toLowerCase() === pair.host && this.now() < pair.notAfterMs
|
|
518
|
+
// SR-09: and only for this Mac's current name once it is known (after a rename, never the old name's pair).
|
|
519
|
+
&& (this.name === null || this.name === pair.host)
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** The pair being served, for tests and diagnostics. */
|
|
523
|
+
served(): SignerTlsPair | null { return this.current }
|
|
524
|
+
|
|
525
|
+
/** A trusted pair serves: unexpired, and for this Mac's tailnet name once that is known. */
|
|
526
|
+
private live(at: number): boolean {
|
|
527
|
+
const pair = this.current
|
|
528
|
+
return pair !== null && at < pair.notAfterMs && (this.name === null || this.name === pair.host)
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* The prompt pickup (see the header). Only while nothing trusted serves. Two `stat` calls; the files
|
|
533
|
+
* are read and validated only when their signature changed since the last pickup, so repeated
|
|
534
|
+
* handshakes or status reads cost no parsing. Scoped to this Mac's name like the check's re-read, and
|
|
535
|
+
* never a step back (loadStored). Never a CLI call, never a write, never throws.
|
|
536
|
+
*/
|
|
537
|
+
private pickUpStored(): void {
|
|
538
|
+
try {
|
|
539
|
+
if (this.live(this.now())) return
|
|
540
|
+
const sig = this.storedSignature()
|
|
541
|
+
if (sig === null || sig === this.pickupSeen) return
|
|
542
|
+
this.pickupSeen = sig
|
|
543
|
+
const before = this.current
|
|
544
|
+
this.loadStored(this.name)
|
|
545
|
+
const pair = this.current
|
|
546
|
+
if (pair !== null && pair !== before) this.log(`[signer-tls] serving a stored certificate on 3144 until ${new Date(pair.notAfterMs).toISOString()}`)
|
|
547
|
+
else this.log(`[signer-tls] a stored certificate was not taken: ${this.storedRefused ?? 'not newer than the one serving'}`)
|
|
548
|
+
} catch { /* the next handshake, status read or check tries again */ }
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** 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). */
|
|
552
|
+
private storedSignature(): string | null {
|
|
553
|
+
const parts: string[] = []
|
|
554
|
+
for (const file of [SIGNER_TLS_CERT_FILE, SIGNER_TLS_KEY_FILE]) {
|
|
555
|
+
const st = lstatSync(join(this.deps.dir, file), { bigint: true, throwIfNoEntry: false })
|
|
556
|
+
if (!st) return null
|
|
557
|
+
parts.push(`${st.dev}:${st.ino}:${st.size}:${st.mtimeNs}:${st.ctimeNs}`)
|
|
558
|
+
}
|
|
559
|
+
return parts.join('|')
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** Also the prompt pickup's second trigger: while nothing trusted serves, a status read looks at the stored files first. */
|
|
563
|
+
status(): SignerTlsStatus {
|
|
564
|
+
this.pickUpStored()
|
|
565
|
+
const pair = this.current
|
|
566
|
+
const at = this.now()
|
|
567
|
+
const live = this.live(at)
|
|
568
|
+
let reason: SignerTlsReason = 'ok'
|
|
569
|
+
if (!live) reason = this.lastError ?? (pair && at >= pair.notAfterMs ? 'cert_expired' : 'pending')
|
|
570
|
+
return {
|
|
571
|
+
trusted: live,
|
|
572
|
+
reason,
|
|
573
|
+
host: pair ? pair.host : null,
|
|
574
|
+
notAfter: pair ? new Date(pair.notAfterMs).toISOString() : null,
|
|
575
|
+
lastError: this.lastError,
|
|
576
|
+
checkedAt: this.checkedAt === null ? null : new Date(this.checkedAt).toISOString(),
|
|
577
|
+
nextCheckAt: this.nextCheckAt === null ? null : new Date(this.nextCheckAt).toISOString(),
|
|
578
|
+
storedRefused: this.storedRefused,
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* SR-04: what the authenticated status route calls (index.ts). When the last check failed only because
|
|
584
|
+
* HTTPS Certificates was off for the tailnet or the tailnet name was unknown, both fixed by the user outside
|
|
585
|
+
* this server, start one check now instead of waiting out the backoff (up to SIGNER_TLS_RETRY_MAX_MS): at
|
|
586
|
+
* most once per SIGNER_TLS_RECHECK_FLOOR_MS, never while a check runs, never after stop(). It does not wait
|
|
587
|
+
* for that check; the next read shows its result. Any other failure keeps the breaker as it is.
|
|
588
|
+
*/
|
|
589
|
+
statusRead(): SignerTlsStatus {
|
|
590
|
+
try {
|
|
591
|
+
const at = this.now()
|
|
592
|
+
if (!this.stopped && this.inflight === null && this.lastError !== null && RECHECK_ON_READ.has(this.lastError)
|
|
593
|
+
&& (this.lastRecheckAt === null || at - this.lastRecheckAt >= SIGNER_TLS_RECHECK_FLOOR_MS)) {
|
|
594
|
+
this.lastRecheckAt = at
|
|
595
|
+
this.bypassCooldown = true
|
|
596
|
+
void this.check()
|
|
597
|
+
}
|
|
598
|
+
} catch { /* the timer checks again */ }
|
|
599
|
+
return this.status()
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/** The boot check, then one every interval (or after the cooldown while failing). */
|
|
603
|
+
start(): void {
|
|
604
|
+
this.stopped = false
|
|
605
|
+
void this.check()
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
stop(): void {
|
|
609
|
+
this.stopped = true
|
|
610
|
+
this.timer?.cancel()
|
|
611
|
+
this.timer = null
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** One check (single-flight). Never throws. */
|
|
615
|
+
check(): Promise<SignerTlsStatus> {
|
|
616
|
+
if (this.inflight) return this.inflight
|
|
617
|
+
this.inflight = this.runCheck()
|
|
618
|
+
.catch(() => { this.fail('tailscale_cert_failed') })
|
|
619
|
+
.then(() => {
|
|
620
|
+
try { this.reschedule() } catch { /* the next boot checks again */ }
|
|
621
|
+
return this.status()
|
|
622
|
+
})
|
|
623
|
+
.finally(() => { this.inflight = null })
|
|
624
|
+
return this.inflight
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
private reschedule(): void {
|
|
628
|
+
this.timer?.cancel()
|
|
629
|
+
this.timer = null
|
|
630
|
+
if (this.stopped) return
|
|
631
|
+
const at = this.now()
|
|
632
|
+
const next = this.failures > 0 && this.cooldownUntil !== null ? this.cooldownUntil : at + SIGNER_TLS_CHECK_INTERVAL_MS
|
|
633
|
+
this.nextCheckAt = next
|
|
634
|
+
this.timer = this.schedule(() => { void this.check() }, Math.max(0, next - at))
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
private succeed(storeFailed: boolean): void {
|
|
638
|
+
this.failures = 0
|
|
639
|
+
this.cooldownUntil = null
|
|
640
|
+
this.lastError = storeFailed ? 'cert_store_failed' : null
|
|
641
|
+
this.checkedAt = this.now()
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
private fail(reason: SignerTlsReason, facts = ''): void {
|
|
645
|
+
const at = this.now()
|
|
646
|
+
this.failures += 1
|
|
647
|
+
this.cooldownUntil = at + signerTlsRetryMs(this.failures)
|
|
648
|
+
this.lastError = reason
|
|
649
|
+
this.checkedAt = at
|
|
650
|
+
this.log(`[signer-tls] no trusted certificate: ${reason}${facts ? ` (${facts})` : ''}; next try in ${Math.round(signerTlsRetryMs(this.failures) / 1000)}s`)
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
private async runCheck(): Promise<void> {
|
|
654
|
+
// SR-04: a re-check a status read started may skip the cooldown, once.
|
|
655
|
+
const bypass = this.bypassCooldown
|
|
656
|
+
this.bypassCooldown = false
|
|
657
|
+
let name: string | null = null
|
|
658
|
+
try { name = normalizeTailscaleDnsName(await this.deps.selfDnsName()) } catch { name = null }
|
|
659
|
+
if (!name) { this.fail('tailnet_name_unknown'); return }
|
|
660
|
+
this.name = name
|
|
661
|
+
// A pair stored by another process (e.g. COS Control's helper, if this LaunchAgent's CLI cannot
|
|
662
|
+
// answer) is picked up here, validated, without a restart and without a CLI call.
|
|
663
|
+
this.loadStored(name)
|
|
664
|
+
const at = this.now()
|
|
665
|
+
const pair = this.current
|
|
666
|
+
if (pair && pair.host === name && !signerTlsRenewalDue(pair.notAfterMs, at)) { this.succeed(false); return }
|
|
667
|
+
// The breaker: no CLI call inside the cooldown after a failure.
|
|
668
|
+
if (this.cooldownUntil !== null && at < this.cooldownUntil && !bypass) return
|
|
669
|
+
const bin = this.resolveBin()
|
|
670
|
+
if (!bin) { this.fail('tailscale_cli_missing'); return }
|
|
671
|
+
// The minimal env with SHLVL=1 for both calls (signerTlsCliEnv): never this process's env.
|
|
672
|
+
const cliEnv = signerTlsCliEnv()
|
|
673
|
+
|
|
674
|
+
const st = await this.runner(bin, SIGNER_TLS_STATUS_ARGS, SIGNER_TLS_STATUS_TIMEOUT_MS, cliEnv)
|
|
675
|
+
if (st.spawnError) { this.fail(st.spawnError === MAXBUFFER ? 'tailscale_output_too_large' : 'tailscale_cli_missing', `spawn ${st.spawnError}`); return }
|
|
676
|
+
if (st.killed || st.signal) { this.fail('tailscale_cli_timeout', `status killed=${st.killed} signal=${st.signal ?? ''} ${st.elapsedMs}ms`); return }
|
|
677
|
+
const parsed = st.code === 0 ? parseTailscaleStatus(st.stdout, this.now()) : null
|
|
678
|
+
if (!parsed) {
|
|
679
|
+
// The CLI's "GUI failed to start" answer (exit 0, see signerTlsCliEnv) gets its own reason, so the status names it.
|
|
680
|
+
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)}`)
|
|
681
|
+
return
|
|
682
|
+
}
|
|
683
|
+
if (parsed.backendState !== 'Running') { this.fail('tailscale_not_running', parsed.backendState); return }
|
|
684
|
+
const domains = parsed.certDomains ?? []
|
|
685
|
+
if (domains.length === 0) { this.fail('tailnet_https_certs_off'); return }
|
|
686
|
+
if (!domains.includes(name)) { this.fail('cert_domain_mismatch'); return }
|
|
687
|
+
|
|
688
|
+
// SR-05: at most SIGNER_TLS_CERT_CALLS_PER_DAY cert calls per UTC day. The call is counted and SAVED before
|
|
689
|
+
// it runs, so a crash during the call still counts; if the count cannot be saved, no call is made.
|
|
690
|
+
const today = utcDay(this.now())
|
|
691
|
+
if (this.fetchState.day !== today) this.fetchState = { ...this.fetchState, day: today, certCalls: 0 }
|
|
692
|
+
if (this.fetchState.certCalls >= SIGNER_TLS_CERT_CALLS_PER_DAY) { this.fail('cert_daily_cap', `${this.fetchState.certCalls} today`); return }
|
|
693
|
+
this.fetchState = { ...this.fetchState, certCalls: this.fetchState.certCalls + 1, lastCertAt: this.now() }
|
|
694
|
+
if (!this.saveFetchState()) { this.fail('cert_store_failed', 'fetch state not saved; no cert call'); return }
|
|
695
|
+
|
|
696
|
+
const r = await this.runner(bin, SIGNER_TLS_CERT_ARGS(name), SIGNER_TLS_CERT_TIMEOUT_MS, cliEnv)
|
|
697
|
+
if (r.spawnError || r.killed || r.signal || r.code !== 0) {
|
|
698
|
+
this.fail(classifyCertFailure(r), `cert code=${r.code} killed=${r.killed} signal=${r.signal ?? ''} ${r.elapsedMs}ms ${firstLine(r.stderr)}`)
|
|
699
|
+
this.recordCertOutcome()
|
|
700
|
+
return
|
|
701
|
+
}
|
|
702
|
+
// SR-01: the same checks as a stored pair, including the public-root chain; the refusal names why.
|
|
703
|
+
const checked = checkSignerTlsPair(r.stdout, name, this.now(), this.roots)
|
|
704
|
+
if (!checked.ok) { this.fail(checked.reason); this.recordCertOutcome(); return }
|
|
705
|
+
const fresh = checked.pair
|
|
706
|
+
let storeFailed = false
|
|
707
|
+
try { this.store(fresh) } catch { storeFailed = true }
|
|
708
|
+
// The hot swap: the next 3144 handshake reads this pair.
|
|
709
|
+
this.current = fresh
|
|
710
|
+
this.succeed(storeFailed)
|
|
711
|
+
this.recordCertOutcome()
|
|
712
|
+
this.log(`[signer-tls] serving a trusted certificate on 3144 until ${new Date(fresh.notAfterMs).toISOString()}${storeFailed ? ' (not saved: cert_store_failed)' : ''}`)
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
private store(pair: SignerTlsPair): void {
|
|
716
|
+
mkdirSync(this.deps.dir, { recursive: true, mode: 0o700 })
|
|
717
|
+
chmodSync(this.deps.dir, 0o700)
|
|
718
|
+
// Key first: a crash between the two writes leaves a pair that fails to load (key mismatch) and is fetched again.
|
|
719
|
+
durableAtomicWriteFileSync(join(this.deps.dir, SIGNER_TLS_KEY_FILE), pair.keyPem, { mode: SIGNER_TLS_KEY_MODE })
|
|
720
|
+
durableAtomicWriteFileSync(join(this.deps.dir, SIGNER_TLS_CERT_FILE), pair.certPem, { mode: SIGNER_TLS_CERT_MODE })
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* SR-05: restore the day's cert-call count and, while it has not run out, the breaker (failures, cooldown,
|
|
725
|
+
* last error), so a restart or a crash loop cannot call `tailscale cert` sooner than the backoff allows.
|
|
726
|
+
* Read with the same hygiene as the pair (SR-08). Missing, refused or malformed: nothing restored.
|
|
727
|
+
*/
|
|
728
|
+
private loadFetchState(): void {
|
|
729
|
+
try {
|
|
730
|
+
if (storedDirRefusal(this.deps.dir, this.ownerUid) !== null) return
|
|
731
|
+
const read = readStoredFile(join(this.deps.dir, SIGNER_TLS_STATE_FILE), this.ownerUid)
|
|
732
|
+
if (read.kind !== 'ok') return
|
|
733
|
+
const raw = JSON.parse(read.text) as Record<string, unknown>
|
|
734
|
+
if (!raw || raw.v !== 1 || typeof raw.day !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(raw.day)) return
|
|
735
|
+
const certCalls = typeof raw.certCalls === 'number' && Number.isInteger(raw.certCalls) && raw.certCalls >= 0 ? raw.certCalls : 0
|
|
736
|
+
const failures = typeof raw.failures === 'number' && Number.isInteger(raw.failures) && raw.failures >= 0 ? raw.failures : 0
|
|
737
|
+
const cooldownUntil = finiteOrNull(raw.cooldownUntil)
|
|
738
|
+
const lastError = typeof raw.lastError === 'string' && (SIGNER_TLS_REASONS as readonly string[]).includes(raw.lastError) ? raw.lastError as SignerTlsReason : null
|
|
739
|
+
this.fetchState = { v: 1, day: raw.day, certCalls, lastCertAt: finiteOrNull(raw.lastCertAt), failures, cooldownUntil, lastError }
|
|
740
|
+
if (failures > 0 && cooldownUntil !== null && cooldownUntil > this.now()) {
|
|
741
|
+
this.failures = failures
|
|
742
|
+
this.cooldownUntil = cooldownUntil
|
|
743
|
+
this.lastError = lastError
|
|
744
|
+
}
|
|
745
|
+
} catch { /* nothing restored */ }
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/** SR-05: save the state (0600, atomic) in the pair's folder (0700). False when it cannot be written. */
|
|
749
|
+
private saveFetchState(): boolean {
|
|
750
|
+
try {
|
|
751
|
+
mkdirSync(this.deps.dir, { recursive: true, mode: 0o700 })
|
|
752
|
+
chmodSync(this.deps.dir, 0o700)
|
|
753
|
+
durableAtomicWriteFileSync(join(this.deps.dir, SIGNER_TLS_STATE_FILE), `${JSON.stringify(this.fetchState)}\n`, { mode: 0o600 })
|
|
754
|
+
return true
|
|
755
|
+
} catch {
|
|
756
|
+
return false
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/** SR-05: after a cert call, save how it ended (the breaker as it now stands). */
|
|
761
|
+
private recordCertOutcome(): void {
|
|
762
|
+
this.fetchState = { ...this.fetchState, failures: this.failures, cooldownUntil: this.cooldownUntil, lastError: this.lastError }
|
|
763
|
+
this.saveFetchState()
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
/** With a name: only a pair for exactly that name. Without (at construction): the *.ts.net name the leaf covers. */
|
|
767
|
+
private loadStored(name: string | null = null): void {
|
|
768
|
+
try {
|
|
769
|
+
// SR-08: the folder, then each file, pass the hygiene checks before anything is read.
|
|
770
|
+
const dir = storedDirRefusal(this.deps.dir, this.ownerUid)
|
|
771
|
+
if (dir === 'none') { this.storedRefused = null; return }
|
|
772
|
+
if (dir !== null) { this.storedRefused = dir; return }
|
|
773
|
+
const cert = readStoredFile(join(this.deps.dir, SIGNER_TLS_CERT_FILE), this.ownerUid)
|
|
774
|
+
const key = readStoredFile(join(this.deps.dir, SIGNER_TLS_KEY_FILE), this.ownerUid, true)
|
|
775
|
+
if (cert.kind === 'none' || key.kind === 'none') { this.storedRefused = null; return }
|
|
776
|
+
if (cert.kind === 'refused') { this.storedRefused = cert.reason; return }
|
|
777
|
+
if (key.kind === 'refused') { this.storedRefused = key.reason; return }
|
|
778
|
+
const checked = checkSignerTlsPair(`${cert.text}\n${key.text}`, name, this.now(), this.roots)
|
|
779
|
+
if (!checked.ok) { this.storedRefused = checked.reason; return }
|
|
780
|
+
this.storedRefused = null
|
|
781
|
+
const pair = checked.pair
|
|
782
|
+
// Never a step back: only when nothing serves, the serving pair names another host, or this one lasts longer.
|
|
783
|
+
if (pair && (this.current === null || this.current.host !== pair.host || pair.notAfterMs > this.current.notAfterMs)) this.current = pair
|
|
784
|
+
} catch { /* nothing stored that serves: the check fetches */ }
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* index.ts's one door: a SignerTls only with COS_WEB_KEY_AUTHORITY on AND the 3144 listener planned.
|
|
790
|
+
* Otherwise null, and nothing is read, written or run.
|
|
791
|
+
*/
|
|
792
|
+
export function createSignerTlsForBoot(opts: { authorityEnabled: boolean; signerListener: boolean }, deps: SignerTlsDeps): SignerTls | null {
|
|
793
|
+
if (opts.authorityEnabled !== true || opts.signerListener !== true) return null
|
|
794
|
+
return new SignerTls(deps)
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/** The 3144 server's TLS options: the mkcert pair as the default, plus SNI selection when a SignerTls exists. */
|
|
798
|
+
export function signerHttpsOptions(base: { cert: Buffer | string; key: Buffer | string }, tls: SignerTls | null): HttpsServerOptions {
|
|
799
|
+
return tls ? { ...base, SNICallback: tls.sniCallback } : { ...base }
|
|
800
|
+
}
|