Compare commits

..

11 Commits

Author SHA1 Message Date
Yaojia Wang
7db7be456c chore: track .claude/settings.json for the worktree base ref
Some checks failed
ios / package-tests (APIClient) (push) Has been cancelled
ios / package-tests (HostRegistry) (push) Has been cancelled
ios / package-tests (SessionCore) (push) Has been cancelled
ios / package-tests (WireProtocol) (push) Has been cancelled
ios / testsupport-tests (push) Has been cancelled
ios / app-tests (push) Has been cancelled
ios / ipad-tests (push) Has been cancelled
ios / integration-tests (push) Has been cancelled
ios / ui-test (push) Has been cancelled
ios / ios17-floor-tests (push) Has been cancelled
relay-tripwire / cross-tenant-tripwire (push) Has been cancelled
`worktree.baseRef: head` is the setting CLAUDE.md's session workflow depends on:
without it EnterWorktree defaults to `fresh` and branches from `origin/main`,
which trails `develop` by ~75 commits. Shared config, so it belongs in the repo —
personal overrides still go in the ignored `.claude/settings.local.json`.
2026-07-29 11:14:12 +02:00
Yaojia Wang
1137090626 chore: adopt one-worktree-per-session workflow
Sessions that change files now work in their own git worktree and merge back to
`develop`, instead of developing directly in the main checkout. Documents the
tool's actual behaviour rather than the assumed one: `EnterWorktree` prefixes the
branch as `worktree-<name>`, branches from the last commit (so uncommitted edits
do not carry over), and `ExitWorktree({action: "remove"})` deletes the branch
with the directory — hence the merge must happen before the cleanup.

Base ref is pinned to `head` in `.claude/settings.json` because `develop` runs
well ahead of `origin/main`, and the default `fresh` would branch from the stale
release trunk.

Also ignores `.claude/worktrees/`, which lives on disk per session and is never
committed.
2026-07-29 11:13:26 +02:00
Yaojia Wang
553a00c32f docs(progress): log the three leftover fixes + live verification 2026-07-29 10:45:46 +02:00
Yaojia Wang
d92caedaee Merge fix-tunnel-leftovers: gitignore-swallowed source, host identifiers in logs, device recovery
Closes the three leftovers called out after the renewal-deadlock fix: a source
file the blanket `dist/` ignore rule kept out of every checkout, tunnel logs
that named no host (including all 6380 warnings during the outage), and the
missing phone-track counterpart to /recover.
2026-07-29 10:41:49 +02:00
Yaojia Wang
2a602d5289 fix(tunnel): close the three leftovers from the renewal-deadlock fix
1. `.gitignore` swallowed a source file. `agent/src/dist/buildBinary.ts` is the
   packaging config, not build output, but the blanket `dist/` rule meant it was
   never committed — a fresh clone could neither typecheck `agent/src/index.ts`
   nor import it from the committed `agent/test/buildBinary.test.ts`. Re-included
   the DIRECTORY first (git does not descend into an excluded one, so un-ignoring
   just the file would not have worked) and committed the file. Verified build
   output under `agent/dist/`, `dist/`, `public/build/` is still ignored.

2. Tunnel logs named no host. `pair` learned hostId/subdomain from the enroll
   response and threw them away, so the long-running `run` process logged
   `{"subdomain":null,"hostId":null}` — including all 6380 warnings during the
   8-day outage, at exactly the moment you want to know which host. New
   `config/hostRecord.ts` persists them at enrol; `resolveHostIdentity` resolves
   config > record > the subdomain embedded in the leaf's SPIFFE SAN, so hosts
   enrolled before the record existed get an identifier back without re-pairing.

3. The phone track had no recovery path. Added `POST /device/:id/recover`,
   mirroring the host route: cert in the body, device-CA path validation, SPIFFE
   parse, `notBefore` never graced, and the full registry check (active + same
   account + `:id` matches the cert + same key) — only `notAfter` is relaxed.

Verified: agent 300/300, control-plane 296/296, tsc clean on both.
NOTE: the iOS/Android clients are not wired to call /device/:id/recover yet —
the server capability exists, the client-side trigger does not.
2026-07-29 10:40:15 +02:00
Yaojia Wang
befe677759 Merge fix-cert-renew-deadlock: break the expired-leaf renewal deadlock
The tunnel had been down 8 days because /renew is mTLS-authenticated by the very
leaf it renews, so a lapsed leaf could never renew itself. Recovery now runs over
plain HTTPS with the expired cert in the body (CSR self-signature still proves key
possession), bounded by a 30-day grace; past that the agent stops retrying and
names the re-pair. Verified live end-to-end: an 8-day-expired leaf self-healed and
the tunnel returned HTTP 200 with no human action.
2026-07-29 10:09:35 +02:00
Yaojia Wang
7064a39bf1 chore: drop accidentally committed node_modules symlinks
They were local worktree conveniences (symlinks into the main checkout so vitest
could resolve the file: workspace deps), swept in by a broad `git add agent`.
2026-07-29 10:09:28 +02:00
Yaojia Wang
0970c623eb docs(progress): log the expired-leaf renewal deadlock fix + live self-heal proof 2026-07-29 10:08:58 +02:00
Yaojia Wang
5509c81eee fix(tunnel): recover an expired leaf over plain HTTPS, not mTLS
Correction to the previous commit. Its recovery vhost could not work: nginx
will not forward an expired client certificate in ANY `ssl_verify_client` mode.
`optional` answers a bare `400 The SSL certificate error` before any location
runs, and `optional_no_ca` only tolerates CHAIN failures — see nginx's
`ngx_ssl_verify_error_optional()`, which covers self-signed / unknown-issuer /
unverifiable-leaf and NOT `X509_V_ERR_CERT_HAS_EXPIRED`. Verified live against
the deployed :8472 server, which rejected the real expired leaf.

So recovery drops mTLS instead of trying to bend it:

- agent: `buildTlsOptions` and the mTLS renew transport go back to being
  strictly fail-closed on expiry — the relaxation is gone from the TLS layer
  entirely. The rotator now decides per attempt: valid → mTLS `/renew`,
  expired-inside-grace → plain `/recover`, expired-beyond-grace → terminal
  `onExhausted` with no request issued at all.
- new `recoverCert` POSTs `{cert, csr}` with no client certificate. Possession
  of the private key is still proven: the CSR is self-signed by it and the host
  signer already enforces CSR PoP plus `CSR key == registered key`, so a
  replayed (public) cert without the key yields at most a certificate the
  attacker cannot authenticate with.
- control-plane: `/renew` is strict again (grace 0, matching the terminator).
  The grace lives on the new `POST /recover`, which reads the cert from the BODY
  and ignores the `x-client-cert` header, then runs the identical trust
  pipeline: X.509 path validation to the frp-client-CA anchors, SPIFFE parse,
  `notBefore` (never graced), registry `active` + account match. Revocation
  still bites.
- deploy: no new vhost, no new SNI, no DNS. One `location = /recover` merged
  into the existing enroll vhost, documented in
  `deploy/nginx/enroll-recover-location.md` with the nginx source citation.

The load-bearing new test is "a self-signed cert with a FORGED SPIFFE SAN is
refused → 401": nginx no longer validates the chain on this path, so that
assertion is what keeps `/recover` from being a cert vending machine.

Verified: agent 289/289, control-plane 290/290, tsc clean on both.
2026-07-29 09:52:17 +02:00
Yaojia Wang
f3f4d8baa6 fix(tunnel): break the expired-leaf renewal deadlock
`POST /renew` is authenticated by mTLS with the very leaf it renews, so once
that leaf lapsed the host could never renew it and the tunnel stayed down until
an operator re-paired by hand. Production hit exactly this: the Mac slept
through its 8h renewal window, the 24h leaf expired, and the agent then logged
`client certificate has expired; renew before dialling` 6380 times over 8 days
without recovering.

Three layers independently refused an expired leaf, so all three had to move:

- agent `buildTlsOptions` gains an opt-in `expiredGraceMs`. Absent/0 keeps the
  historical fail-closed behaviour, and the TUNNEL dial never passes it — only
  the renew transport does. Past the window it throws the new
  `CertExpiredBeyondGraceError`.
- agent rotator routes an already-expired leaf to a separate recovery endpoint
  and treats "beyond grace" as TERMINAL: report once via `onExhausted`, stop
  retrying, and name the fix (re-pair) instead of spamming warnings forever.
- control-plane `assertPresentedCertTrusted` grants a bounded grace on
  `notAfter` only. Chain validation, SPIFFE identity, `notBefore`, and the
  registry `active`/account checks are all unchanged, so revocation still bites.
- new `deploy/nginx/recover-mtls.conf` (:8472). The strict enroll vhost cannot
  host this: under `ssl_verify_client optional` nginx answers a bare 400 as soon
  as a presented cert fails verification, so an expired leaf never reaches the
  location — and the directive is server-level, not per-location.

Grace defaults to 30 days on both ends and is configurable (`RECOVER_URL`,
`expiredRenewGraceMs`). The honest trade is recorded in the code: a stale stolen
leaf stays reusable for the window, which widens an existing exposure (an
unexpired stolen leaf already renews indefinitely) rather than opening a new one.

Verified: agent 296/296, control-plane 286/286, tsc clean on both.
2026-07-29 09:41:03 +02:00
Yaojia Wang
b1bc50ccd1 fix(session): give the PTY a UTF-8 locale so tmux stops mangling CJK
A launchd/Finder-launched server has no LANG/LC_* at all, so tmux — which
decides per client whether the terminal is UTF-8 capable purely from those
variables — fell back to its non-UTF-8 mode and rewrote the byte stream
server-side, before it ever reached xterm:

  - every wide character became `_`  (中文测试 → ________)
  - `✓` / `⏺` / emoji became `_`
  - `═║╔╗` was downgraded to DEC Special Graphics (ESC ( 0), so rounded
    corners vanished and boxes rendered as loose horizontal lines

The `-l` login shell cannot fix this: tmux is the PTY's root process and has
already made its decision by the time the shell sources ~/.zprofile.

withUtf8Locale() fills a locale in on the spawn env, and only when needed —
an existing UTF-8 LC_ALL / LC_CTYPE / LANG (zh_CN.UTF-8, ja_JP.UTF-8, …) is
left untouched; only a missing or non-UTF-8 charset is replaced.

Existing tmux sessions do not need recreating: re-attaching with a UTF-8
client restores correct output (already-scrolled `_` stays mangled).
2026-07-29 08:09:48 +02:00
19 changed files with 1432 additions and 26 deletions

5
.claude/settings.json Normal file
View File

@@ -0,0 +1,5 @@
{
"worktree": {
"baseRef": "head"
}
}

10
.gitignore vendored
View File

@@ -3,6 +3,13 @@ node_modules/
# build output
dist/
# EXCEPTION: `agent/src/dist/` holds SOURCE (the packaging config for the distributable binary),
# not build output. The blanket `dist/` above swallowed it, so it was never committed — a fresh
# clone was missing it and could neither typecheck `agent/src/index.ts` nor import it from the
# committed `agent/test/buildBinary.test.ts`. The directory must be re-included FIRST: git does not
# descend into an excluded directory, so un-ignoring only the file inside it would not work.
!agent/src/dist/
!agent/src/dist/**
public/build/
desktop/build/
desktop/dist-app/
@@ -10,6 +17,9 @@ desktop/dist-app/
# local Claude Code settings (not shared)
.claude/settings.local.json
# per-session git worktrees (EnterWorktree) — live on disk, never committed
.claude/worktrees/
# logs / OS cruft
*.log
npm-debug.log*

View File

@@ -16,6 +16,26 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
**Language decision: TypeScript (`.ts`), not `.js`** — ARCHITECTURE §0 records this divergence from TECH_DOC's original `.js` filenames. Wherever the two docs conflict, ARCHITECTURE wins on *how* (it was cross-validated and corrected); TECH_DOC wins on *why/scope*.
## Session Workflow: One Worktree per Session (MANDATORY)
**Every session that changes files works in its own git worktree, and merges back to `develop` when the work is done.** Don't develop directly on `develop` in the main checkout (the one exception is a change to this workflow itself — the rule can't bootstrap inside its own worktree).
1. **Start of session** — before touching any file, call the `EnterWorktree` tool with a task-descriptive name (e.g. `EnterWorktree({name: "fix-cjk-locale"})`). It creates `.claude/worktrees/<name>/` on branch **`worktree-<name>`** (the tool prefixes it — the name you pass is *not* the branch name) and moves the session's cwd into it. Do all work there.
- Base ref is `head` (configured in `.claude/settings.json``worktree.baseRef`), so the worktree branches from the **current `develop` HEAD**, not `origin/main`. This matters: `develop` runs ~75 commits ahead of `origin/main`, so the default `fresh` base ref would silently produce a badly stale worktree. `develop` is the working trunk; `main` is the release branch.
- It branches from the last **commit**, so uncommitted edits sitting in the main checkout do **not** carry over. Commit or stash them first if the task needs them.
- Read-only sessions (answering a question, inspecting a remote host) don't need a worktree — only create one when files will change.
2. **During the session** — commit inside the worktree as normal (conventional-commit format, see the global git-workflow rule). Tests/`tsc` run against the worktree copy, so concurrent sessions never collide on the working tree; each holds a `locked` worktree of its own, so never `git worktree remove` a directory this session didn't create.
3. **End of session — merge back.** Commit everything in the worktree first, then, **in this order**:
```bash
# 1. ExitWorktree({action: "keep"}) → cwd returns to the main checkout, branch survives
git merge --no-ff worktree-<name> # 2. from the main checkout, on develop
git worktree remove .claude/worktrees/<name> && git branch -d worktree-<name> # 3. clean up
```
**Order matters:** `ExitWorktree({action: "remove"})` deletes the branch along with the directory, so calling it before the merge throws the work away. (It does refuse when commits aren't yet on `develop` — a safety net, not a plan.) `keep` is also the right call whenever the work is unfinished and the session should be resumable.
4. **Do not merge to `main`** as part of this flow — `main` is promoted from `develop` separately.
Subagents dispatched with `isolation: worktree` (PLAN §4) get their own throwaway worktrees on top of this — that is a separate, nested mechanism and does not replace the session-level worktree.
## Development Workflow: Plan & Progress Log (MANDATORY)
Work proceeds against a **phased plan** and is tracked in a **progress log that acts as cross-session memory**. A new Claude instance must be able to read the log and know exactly where things stand. Follow these rules:

View File

@@ -18,13 +18,18 @@
*/
import { request as httpsRequest } from 'node:https'
import type { AgentConfig } from '../config/agentConfig.js'
import { resolveHostIdentity } from '../config/hostRecord.js'
import type { Keystore } from '../keys/keystore.js'
import type { Logger } from '../log/logger.js'
import type { TimerLike } from '../transport/seams.js'
import { createBackoff } from '../transport/backoff.js'
import { buildTlsOptions, type CertParser, type TlsClientOptions } from '../transport/dial.js'
import { DEFAULT_CERT_RENEW_WINDOW_MS } from '../health/probe.js'
import { createCertRotator, type CertRotator } from './rotation.js'
import {
createCertRotator,
type CertExpiredBeyondGraceError,
type CertRotator,
} from './rotation.js'
/** Non-secret message from an unknown thrown value (never serializes cert/key material). */
function errorMessage(err: unknown): string {
@@ -135,6 +140,8 @@ export function createMtlsFetch(
// SYSTEM roots — its host (the LE-fronted control-plane) is publicly trusted; pinning the private
// enroll caChain here fails with "unable to get local issuer certificate". So drop `ca` (absent →
// node uses the default roots); rejectUnauthorized stays true.
// Deliberately still fail-closed on an EXPIRED leaf: nginx would refuse to forward it anyway, so
// a lapsed leaf is routed to the plain `/recover` endpoint by the rotator instead of through here.
const full = buildTlsOptions(ks, { ...(opts.certParser ? { certParser: opts.certParser } : {}) })
const tls: TlsClientOptions = { cert: full.cert, key: full.key, rejectUnauthorized: full.rejectUnauthorized }
const reqInit: MtlsRequestInit = {
@@ -195,6 +202,16 @@ export function wireAutoRenew(
error: errorMessage(err),
})
})
// Terminal: the grace window is spent, so every further attempt is guaranteed to fail. Say so once,
// at error level, naming the fix — and deliberately do NOT stop the supervisor: `pair` writes fresh
// cert files that the restart-on-exit frpc child picks up without a manual service restart.
rotator.onExhausted((err) => {
logger.log('error', 'frp-client cert expired beyond recovery grace — run `web-terminal-agent pair <CODE>` to re-pair this host', {
...meta,
expiredForMs: err.expiredForMs,
graceMs: err.graceMs,
})
})
rotator.start()
return { stop: () => rotator.stop() }
}
@@ -205,6 +222,10 @@ export function wireAutoRenew(
export interface NativeAutoRenewOpts {
readonly mtlsRequest?: MtlsRequest
readonly certParser?: CertParser
/** Window in which an already-expired leaf may still be recovered via `/recover`. */
readonly expiredGraceMs?: number
/** Plain (NON-mTLS) fetch for the `/recover` call; unset ⇒ global fetch. */
readonly recoverFetchImpl?: typeof fetch
readonly timer?: TimerLike
readonly renewBeforeMs?: number
readonly retryBaseMs?: number
@@ -235,6 +256,8 @@ export function startNativeAutoRenew(
})
const rotator = createCertRotator(cfg, id, ks, {
fetchImpl,
...(opts.expiredGraceMs !== undefined ? { expiredGraceMs: opts.expiredGraceMs } : {}),
...(opts.recoverFetchImpl ? { recoverFetchImpl: opts.recoverFetchImpl } : {}),
renewBeforeMs: opts.renewBeforeMs ?? DEFAULT_CERT_RENEW_WINDOW_MS,
...(opts.timer ? { timer: opts.timer } : {}),
...(opts.now ? { now: opts.now } : {}),
@@ -243,5 +266,8 @@ export function startNativeAutoRenew(
? { retryBackoff: createBackoff({ baseMs: opts.retryBaseMs, jitter: false }) }
: {}),
})
return wireAutoRenew(rotator, hooks, logger, { subdomain: cfg.subdomain, hostId: cfg.hostId })
// Prefer the resolved identity (config > enrolment record > leaf SPIFFE SAN) so renewal warnings
// actually name the host — `cfg` alone is null on every install that predates the record.
const ids = resolveHostIdentity(cfg, () => ks.loadCert()?.certPem ?? null)
return wireAutoRenew(rotator, hooks, logger, ids)
}

View File

@@ -19,6 +19,30 @@ import { certResponseToPem } from './pem.js'
export const DEFAULT_RENEW_BEFORE_MS = 5 * 60_000 // renew 5 min before expiry
/**
* How long after `notAfter` a lapsed leaf may still be swapped for a fresh one (30 days).
*
* `/renew` is mTLS-authenticated by the very leaf it renews, so a lapsed leaf cannot renew itself —
* a deadlock that bricked a host for 8 days in production (the laptop slept through its renewal
* window, then the agent logged `client certificate has expired` 6380 times and never recovered).
* Inside this window the agent switches to the `/recover` endpoint instead; past it, only a re-pair
* can help and the rotator says so once and stops.
*/
export const DEFAULT_EXPIRED_RENEW_GRACE_MS = 30 * 24 * 60 * 60 * 1000
/** The leaf lapsed longer ago than the recovery grace allows ⇒ operator must re-pair this host. */
export class CertExpiredBeyondGraceError extends Error {
constructor(
/** How long ago the leaf expired (ms) — non-secret, safe to log. */
readonly expiredForMs: number,
/** The grace window that was exceeded (ms). */
readonly graceMs: number,
) {
super('client certificate expired beyond the recovery grace window; re-pair required')
this.name = 'CertExpiredBeyondGraceError'
}
}
export interface CertRotator {
start(): void
stop(): void
@@ -26,6 +50,11 @@ export interface CertRotator {
onRevoked(cb: () => void): void
/** A renewal attempt failed (network/HTTP, NOT a 403 revoke). The rotator retries with backoff. */
onError(cb: (err: unknown) => void): void
/**
* TERMINAL: the leaf expired past the renewal grace window, so no future attempt can succeed. The
* rotator has stopped; recovery requires an operator re-pair.
*/
onExhausted(cb: (err: CertExpiredBeyondGraceError) => void): void
}
export type RenewOutcome = 'rotated' | 'revoked'
@@ -35,6 +64,23 @@ export function renewalUrlFor(cfg: AgentConfig): string {
return cfg.enrollUrl.replace(/\/enroll$/, '/renew')
}
/**
* Recovery route for a leaf that has ALREADY EXPIRED — a sibling PATH on the same enroll host.
*
* It cannot be `/renew`, because nginx will not forward an expired client certificate at all: under
* `ssl_verify_client optional` it answers a bare `400 Bad Request`, and `optional_no_ca` does not
* help either — nginx only tolerates CHAIN errors there (`ngx_ssl_verify_error_optional` covers
* self-signed / unknown-issuer / unverifiable-leaf, NOT `X509_V_ERR_CERT_HAS_EXPIRED`). So recovery
* drops mTLS entirely: it is a plain HTTPS POST carrying the expired cert in the BODY. Nothing is
* lost by that — the accompanying CSR is self-signed by the same private key, and the control-plane
* signer already enforces CSR proof-of-possession plus `CSR key == registered key`, so possession is
* proven exactly as the TLS handshake used to prove it.
*/
export function recoveryUrlFor(cfg: AgentConfig): string {
if (cfg.recoverUrl != null && cfg.recoverUrl.length > 0) return cfg.recoverUrl
return cfg.enrollUrl.replace(/\/enroll$/, '/recover')
}
/** Ms until (validTo renewBeforeMs), clamped to ≥ 0. */
export function computeRenewDelayMs(
certPem: string,
@@ -56,9 +102,10 @@ export async function renewCert(
id: AgentIdentity,
ks: Keystore,
fetchImpl: typeof fetch,
opts: { url?: string } = {},
): Promise<RenewOutcome> {
const csr = buildCsr(id, cfg.subdomain ?? 'web-terminal-agent')
const res = await fetchImpl(renewalUrlFor(cfg), {
const res = await fetchImpl(opts.url ?? renewalUrlFor(cfg), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ csr }),
@@ -73,6 +120,34 @@ export async function renewCert(
return 'rotated'
}
/**
* One recovery round-trip for an EXPIRED leaf: plain HTTPS (no client cert — see `recoveryUrlFor`)
* POSTing the expired cert alongside a fresh CSR over the SAME key. Same outcome contract as
* `renewCert`: 'rotated' installs the new leaf, 403 ⇒ 'revoked', anything else throws to the retry.
*/
export async function recoverCert(
cfg: AgentConfig,
id: AgentIdentity,
ks: Keystore,
fetchImpl: typeof fetch,
url: string = recoveryUrlFor(cfg),
): Promise<RenewOutcome> {
const certs = ks.loadCert()
if (certs === null) throw new Error('cannot recover without the expired leaf on disk')
const csr = buildCsr(id, cfg.subdomain ?? 'web-terminal-agent')
const res = await fetchImpl(url, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ cert: certs.certPem, csr }),
})
if (res.status === 403) return 'revoked'
if (!res.ok) throw new Error(`cert recovery failed: HTTP ${res.status}`)
const json = (await res.json()) as { cert?: unknown; caChain?: unknown }
const { certPem, caChainPem } = certResponseToPem(json.cert, json.caChain)
ks.saveCert(certPem, caChainPem)
return 'rotated'
}
export function createCertRotator(
cfg: AgentConfig,
id: AgentIdentity,
@@ -85,6 +160,10 @@ export function createCertRotator(
parseCert?: (pem: string) => Date
/** Backoff policy for retrying a FAILED renewal (default 1s→30s). Reset after a success. */
retryBackoff?: BackoffPolicy
/** Plain (NON-mTLS) fetch used only for the expired-leaf `/recover` call. */
recoverFetchImpl?: typeof fetch
/** Window in which an expired leaf may still be recovered. 0 ⇒ no recovery at all. */
expiredGraceMs?: number
} = {},
): CertRotator {
const renewBeforeMs = opts.renewBeforeMs ?? DEFAULT_RENEW_BEFORE_MS
@@ -96,12 +175,15 @@ export function createCertRotator(
clearInterval: (h) => clearInterval(h as ReturnType<typeof setInterval>),
}
const doFetch = opts.fetchImpl ?? fetch
const recoverFetch = opts.recoverFetchImpl ?? fetch
const expiredGraceMs = opts.expiredGraceMs ?? DEFAULT_EXPIRED_RENEW_GRACE_MS
const now = opts.now ?? (() => new Date())
const retryBackoff = opts.retryBackoff ?? createBackoff({ jitter: true })
let handle: unknown = null
let rotatedCb: (() => void) | null = null
let revokedCb: (() => void) | null = null
let errorCb: ((err: unknown) => void) | null = null
let exhaustedCb: ((err: CertExpiredBeyondGraceError) => void) | null = null
function schedule(): void {
const certs = ks.loadCert()
@@ -110,8 +192,34 @@ export function createCertRotator(
handle = timer.setTimeout(runRenewal, delay)
}
/**
* How this attempt must be made, derived from how stale the leaf on disk is:
* `normal` — still valid ⇒ ordinary mTLS `/renew`;
* `recover` — expired but inside the grace window ⇒ plain `/recover` with the cert in the body;
* `exhausted` — expired past the grace window ⇒ nothing can succeed; only an operator re-pair.
*/
function attemptPlan(): { mode: 'normal' | 'recover' | 'exhausted'; expiredForMs: number } {
const certs = ks.loadCert()
if (certs === null) return { mode: 'normal', expiredForMs: 0 }
const expiredForMs = now().getTime() - parseCert(certs.certPem).getTime()
if (expiredForMs <= 0) return { mode: 'normal', expiredForMs: 0 }
return { mode: expiredForMs > expiredGraceMs ? 'exhausted' : 'recover', expiredForMs }
}
function runRenewal(): void {
void renewCert(cfg, id, ks, doFetch)
const plan = attemptPlan()
if (plan.mode === 'exhausted') {
// Terminal: report ONCE and arm nothing. The old code retried forever, which is how a single
// real failure turned into 6380 identical warnings that buried the signal.
handle = null
exhaustedCb?.(new CertExpiredBeyondGraceError(plan.expiredForMs, expiredGraceMs))
return
}
const attempt =
plan.mode === 'recover'
? recoverCert(cfg, id, ks, recoverFetch)
: renewCert(cfg, id, ks, doFetch)
void attempt
.then((outcome) => {
if (outcome === 'revoked') {
revokedCb?.()
@@ -147,5 +255,8 @@ export function createCertRotator(
onError(cb): void {
errorCb = cb
},
onExhausted(cb): void {
exhaustedCb = cb
},
}
}

View File

@@ -13,6 +13,7 @@ import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { CliDeps, NativeEnrollResult } from '../cli.js'
import type { AgentConfig } from '../config/agentConfig.js'
import { resolveHostIdentity, saveHostRecord } from '../config/hostRecord.js'
import { loadAgentConfig } from '../config/agentConfig.js'
import { openKeystore } from '../keys/keystore.js'
import { generateIdentity, generateP256Identity } from '../keys/identity.js'
@@ -100,6 +101,9 @@ async function enrollNative(
ks: Keystore,
): Promise<NativeEnrollResult> {
const enroll = await redeemPairingCode(cfg.enrollUrl, code, id, ks, { allowMissingContentSecret: true })
// Write the identifiers down: the long-running `run` process has no other way to learn them, and
// without them every log line from the tunnel reads `{"subdomain":null,"hostId":null}`.
saveHostRecord(cfg.stateDir, { hostId: enroll.hostId, subdomain: enroll.subdomain })
return { hostId: enroll.hostId, subdomain: enroll.subdomain }
}
@@ -184,7 +188,8 @@ function superviseNative(cfg: AgentConfig, ks: Keystore): Promise<number> {
}),
(report) => {
// INV9: only non-secret identifiers (subdomain/host id/expiry date) + boolean flags are logged.
const ids = { subdomain: cfg.subdomain, hostId: cfg.hostId, certNotAfter: certNotAfter(ks) }
const host = resolveHostIdentity(cfg, () => ks.loadCert()?.certPem ?? null)
const ids = { ...host, certNotAfter: certNotAfter(ks) }
for (const line of renderHealthStatus(ids, report)) logger.log('info', line)
},
)

View File

@@ -18,6 +18,13 @@ export interface AgentConfig {
readonly localTargetUrl: string
readonly subdomain: string | null
readonly hostId: string | null
/**
* Renewal endpoint used ONLY when the current leaf has already expired (the strict `/renew` vhost
* rejects an expired client cert before it reaches the control-plane). Optional: when unset it is
* derived from `enrollUrl` by swapping the `enroll.` label for `recover.` — see
* `certs/rotation.ts` `recoveryRenewalUrlFor`.
*/
readonly recoverUrl?: string | null | undefined
}
/**
@@ -59,6 +66,11 @@ export const AgentConfigSchema = z
.refine(isLoopbackWsUrl, 'localTargetUrl must be a ws:// loopback URL (anti-SSRF)'),
subdomain: z.string().min(1).nullable(),
hostId: z.string().min(1).nullable(),
recoverUrl: z
.string()
.refine((u) => hasScheme(u, 'https:'), 'recoverUrl must be an https:// URL')
.nullable()
.optional(),
})
.strict()
.readonly()
@@ -85,6 +97,7 @@ export function loadAgentConfig(
localTargetUrl: argv.localTargetUrl ?? env.LOCAL_TARGET_URL ?? DEFAULT_LOCAL_TARGET,
subdomain: argv.subdomain ?? env.SUBDOMAIN ?? null,
hostId: argv.hostId ?? env.HOST_ID ?? null,
recoverUrl: argv.recoverUrl ?? env.RECOVER_URL ?? null,
}
return AgentConfigSchema.parse(merged)
}

View File

@@ -0,0 +1,97 @@
/**
* Enrolment identifiers (`hostId` / `subdomain`) persisted next to the keystore.
*
* WHY: `pair --install` learns both from the control-plane's enroll response, but nothing ever wrote
* them down — so the long-running `run` process had `cfg.subdomain === null` and `cfg.hostId === null`
* and every log line came out as `{"subdomain":null,"hostId":null}`. When the tunnel broke in
* production, 6380 warnings named no host at all, which is exactly the moment you want them to.
*
* They are NOT secrets (the subdomain is a public DNS label), so this is a plain JSON file — kept in
* `stateDir` only because that is the one directory the agent already owns on every platform.
*
* Legacy installs enrolled before this existed have no record. Their leaf still carries the
* subdomain in its SPIFFE URI SAN, so `resolveHostIdentity` recovers it from there rather than
* forcing a re-pair just to get an identifier back into the logs.
*/
import { X509Certificate } from 'node:crypto'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import type { AgentConfig } from './agentConfig.js'
const RECORD_FILE = 'host.json'
const DIR_MODE = 0o700
/** Non-secret identifiers assigned by the control-plane at enrolment. */
export interface HostRecord {
readonly hostId: string | null
readonly subdomain: string | null
}
/** A non-empty string, or null — anything else on disk is treated as absent (never trusted). */
function stringOrNull(value: unknown): string | null {
return typeof value === 'string' && value.length > 0 ? value : null
}
/** Persist the enrolment identifiers into `stateDir`. Overwrites any previous record. */
export function saveHostRecord(stateDir: string, record: HostRecord): void {
if (!existsSync(stateDir)) mkdirSync(stateDir, { recursive: true, mode: DIR_MODE })
writeFileSync(join(stateDir, RECORD_FILE), `${JSON.stringify(record, null, 2)}\n`)
}
/**
* Read the persisted identifiers, or null if this host has none. A missing, unreadable, or
* malformed file is reported as "no record" — this feeds a logging path and must never throw into
* the run loop.
*/
export function loadHostRecord(stateDir: string): HostRecord | null {
const path = join(stateDir, RECORD_FILE)
if (!existsSync(path)) return null
try {
const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
if (typeof parsed !== 'object' || parsed === null) return null
const rec = parsed as Record<string, unknown>
return { hostId: stringOrNull(rec['hostId']), subdomain: stringOrNull(rec['subdomain']) }
} catch {
return null
}
}
/** SPIFFE IDs issued for hosts end in `/host/<subdomain>` (see relay-auth `spiffeIdFor`). */
const SPIFFE_HOST_RE = /URI:(spiffe:\/\/[^\s,]*\/host\/([^\s,/]+))/
/**
* Recover the subdomain from a leaf's SPIFFE URI SAN, or null if it carries none. Parse failures
* are null, never throws — a legacy install with a damaged cert must still start.
*/
export function subdomainFromCertPem(certPem: string): string | null {
try {
const san = new X509Certificate(certPem).subjectAltName ?? ''
const match = SPIFFE_HOST_RE.exec(san)
return match?.[2] ?? null
} catch {
return null
}
}
/**
* Resolve the identifiers to log for this host, most authoritative first:
* 1. explicit config (argv/env `SUBDOMAIN` / `HOST_ID`) — an operator override always wins;
* 2. the enrolment record written by `pair`;
* 3. the subdomain embedded in the stored leaf (legacy installs; yields no hostId).
*
* `readCertPem` is injected so this stays a pure decision over a supplied cert.
*/
export function resolveHostIdentity(
cfg: AgentConfig,
readCertPem: () => string | null,
): HostRecord {
const record = loadHostRecord(cfg.stateDir)
const subdomain =
cfg.subdomain ??
record?.subdomain ??
((): string | null => {
const pem = readCertPem()
return pem === null ? null : subdomainFromCertPem(pem)
})()
return { hostId: cfg.hostId ?? record?.hostId ?? null, subdomain }
}

45
agent/src/dist/buildBinary.ts vendored Normal file
View File

@@ -0,0 +1,45 @@
/**
* Static-binary build spec — PLAN_RELAY_AGENT T16 (EXPLORE §6 distribution rank 2). Produces a
* `bun --compile` spec for a one-`curl | sh` install. `npx web-terminal-agent` stays the MVP path
* (rank 1). The bundle EXCLUDES dev/test deps and any terminal parser (INV11 re-check at the
* package boundary — the agent is a byte-shuttle, never an ANSI interpreter).
*/
export type BinaryTarget = 'darwin-arm64' | 'darwin-x64' | 'linux-x64' | 'linux-arm64'
export const BINARY_TARGETS: readonly BinaryTarget[] = [
'darwin-arm64',
'darwin-x64',
'linux-x64',
'linux-arm64',
]
export interface BuildSpec {
readonly tool: 'bun'
readonly entry: string
readonly target: BinaryTarget
readonly bunTarget: string // bun's --target triple
readonly outfile: string
readonly minify: true
/** Package-name substrings that must NOT appear in the bundle graph (INV11 tripwire). */
readonly forbiddenDeps: readonly string[]
}
const BUN_TRIPLE: Readonly<Record<BinaryTarget, string>> = {
'darwin-arm64': 'bun-darwin-arm64',
'darwin-x64': 'bun-darwin-x64',
'linux-x64': 'bun-linux-x64',
'linux-arm64': 'bun-linux-arm64',
}
/** Build the `bun --compile` spec for a target triple. Entry is the CLI. */
export function buildBinaryConfig(target: BinaryTarget): BuildSpec {
return {
tool: 'bun',
entry: 'src/cli.ts',
target,
bunTarget: BUN_TRIPLE[target],
outfile: `dist/web-terminal-agent-${target}`,
minify: true,
forbiddenDeps: ['xterm', 'ansi', 'vt100'],
}
}

View File

@@ -0,0 +1,126 @@
import { describe, expect, it } from 'vitest'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { AgentConfig } from '../src/config/agentConfig.js'
import {
loadHostRecord,
resolveHostIdentity,
saveHostRecord,
subdomainFromCertPem,
} from '../src/config/hostRecord.js'
const CFG: AgentConfig = {
relayUrl: 'wss://relay/agent',
enrollUrl: 'https://enroll.terminal.example.com/enroll',
stateDir: '/tmp/x',
localTargetUrl: 'ws://127.0.0.1:3000',
subdomain: null,
hostId: null,
}
function tmpState(): string {
return mkdtempSync(join(tmpdir(), 'wta-hr-'))
}
/** A real frp-client leaf as issued by the control-plane (SPIFFE URI SAN carries the subdomain). */
const LEAF_PEM = `-----BEGIN CERTIFICATE-----
MIIB1TCCAXygAwIBAgIUUj+CZ+6p29yI59VpyrekwSp9tAgwCgYIKoZIzj0EAwIw
EDEOMAwGA1UEAwwFaDdmZDgwHhcNMjYwNzI5MDgzNzIyWhcNMzYwNzI2MDgzNzIy
WjAQMQ4wDAYDVQQDDAVoN2ZkODBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABACg
xWQCQuxawnkkPZIgagEFtG0oBiuron4SSw3U1Q0FwCSH3BJep1MJtIuEQU3HfM4N
6Tk5kW4MWuIM8sNriiqjgbMwgbAwDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMC
B4AwEwYDVR0lBAwwCgYIKwYBBQUHAwIwXAYDVR0RBFUwU4IaaDdmZDgudGVybWlu
YWwuZXhhbXBsZS5jb22GNXNwaWZmZTovL3JlbGF5LmV4YW1wbGUuY29tL2FjY291
bnQvYWNjLTEyMy9ob3N0L2g3ZmQ4MB0GA1UdDgQWBBSy/SJwjH/lm8TaY5Yk/TF+
wpg78TAKBggqhkjOPQQDAgNHADBEAiAjq1o5xpk+iF55uVfdyLP/a9OC09O0mN4P
YRk8x5MFaQIgYC3GTWqkwu0azrdffKl6jX0stbG+oM+0Cx2Cn7wy27c=
-----END CERTIFICATE-----`
describe('host record persistence', () => {
it('round-trips the enrolment identifiers through stateDir', () => {
const dir = tmpState()
saveHostRecord(dir, { hostId: 'h-1', subdomain: 'h7fd8' })
expect(loadHostRecord(dir)).toEqual({ hostId: 'h-1', subdomain: 'h7fd8' })
rmSync(dir, { recursive: true, force: true })
})
it('returns null when nothing was ever enrolled here', () => {
const dir = tmpState()
expect(loadHostRecord(dir)).toBeNull()
rmSync(dir, { recursive: true, force: true })
})
it('treats a corrupt record as absent rather than throwing (never crashes the run loop)', () => {
const dir = tmpState()
writeFileSync(join(dir, 'host.json'), '{not json')
expect(loadHostRecord(dir)).toBeNull()
rmSync(dir, { recursive: true, force: true })
})
it('ignores a record whose fields are the wrong shape', () => {
const dir = tmpState()
writeFileSync(join(dir, 'host.json'), JSON.stringify({ hostId: 42, subdomain: [] }))
expect(loadHostRecord(dir)).toEqual({ hostId: null, subdomain: null })
rmSync(dir, { recursive: true, force: true })
})
})
describe('subdomainFromCertPem', () => {
it('reads the subdomain out of the leaf SPIFFE URI SAN', () => {
expect(subdomainFromCertPem(LEAF_PEM)).toBe('h7fd8')
})
it('returns null for a certificate with no SPIFFE SAN', () => {
expect(subdomainFromCertPem('-----BEGIN CERTIFICATE-----\nnope\n-----END CERTIFICATE-----')).toBeNull()
})
it('returns null for garbage instead of throwing', () => {
expect(subdomainFromCertPem('not a cert')).toBeNull()
})
})
describe('resolveHostIdentity precedence', () => {
it('keeps explicit config (env/argv) over everything else', () => {
const dir = tmpState()
saveHostRecord(dir, { hostId: 'from-file', subdomain: 'from-file' })
const out = resolveHostIdentity(
{ ...CFG, stateDir: dir, subdomain: 'from-env', hostId: 'from-env' },
() => LEAF_PEM,
)
expect(out).toEqual({ hostId: 'from-env', subdomain: 'from-env' })
rmSync(dir, { recursive: true, force: true })
})
it('falls back to the persisted enrolment record', () => {
const dir = tmpState()
saveHostRecord(dir, { hostId: 'h-1', subdomain: 'h7fd8' })
expect(resolveHostIdentity({ ...CFG, stateDir: dir }, () => null)).toEqual({
hostId: 'h-1',
subdomain: 'h7fd8',
})
rmSync(dir, { recursive: true, force: true })
})
/**
* Hosts enrolled before the record existed have no `host.json`. Their leaf still carries the
* subdomain, so they get an identifier in the logs without needing a re-pair.
*/
it('falls back to the leaf SPIFFE SAN when there is no record (legacy installs)', () => {
const dir = tmpState()
expect(resolveHostIdentity({ ...CFG, stateDir: dir }, () => LEAF_PEM)).toEqual({
hostId: null,
subdomain: 'h7fd8',
})
rmSync(dir, { recursive: true, force: true })
})
it('yields nulls when nothing is known (unenrolled host)', () => {
const dir = tmpState()
expect(resolveHostIdentity({ ...CFG, stateDir: dir }, () => null)).toEqual({
hostId: null,
subdomain: null,
})
rmSync(dir, { recursive: true, force: true })
})
})

View File

@@ -27,6 +27,7 @@ import {
type MtlsRequest,
} from '../src/certs/nativeRenew.js'
import { FakeTimer } from './fixtures/fakes.js'
import { CertExpiredBeyondGraceError } from '../src/certs/rotation.js'
const CFG: AgentConfig = {
relayUrl: 'wss://relay/agent',
@@ -109,11 +110,21 @@ describe('createMtlsFetch (A5)', () => {
describe('wireAutoRenew (A5)', () => {
function fakeRotator(): {
rotator: CertRotator
fire: { rotated?: () => void; revoked?: () => void; error?: (e: unknown) => void }
fire: {
rotated?: () => void
revoked?: () => void
error?: (e: unknown) => void
exhausted?: (e: CertExpiredBeyondGraceError) => void
}
start: ReturnType<typeof vi.fn>
stop: ReturnType<typeof vi.fn>
} {
const fire: { rotated?: () => void; revoked?: () => void; error?: (e: unknown) => void } = {}
const fire: {
rotated?: () => void
revoked?: () => void
error?: (e: unknown) => void
exhausted?: (e: CertExpiredBeyondGraceError) => void
} = {}
const start = vi.fn()
const stop = vi.fn()
const rotator: CertRotator = {
@@ -128,6 +139,9 @@ describe('wireAutoRenew (A5)', () => {
onError: (cb) => {
fire.error = cb
},
onExhausted: (cb) => {
fire.exhausted = cb
},
}
return { rotator, fire, start, stop }
}
@@ -277,3 +291,60 @@ describe('startNativeAutoRenew (A5 end-to-end)', () => {
rmSync(dir, { recursive: true, force: true })
})
})
/**
* The mTLS renew transport stays STRICT about expiry: nginx refuses to forward an expired client
* cert at all, so an expired leaf must be routed to the plain `/recover` endpoint by the rotator
* rather than smuggled through this transport.
*/
describe('createMtlsFetch stays fail-closed on an expired leaf', () => {
it('refuses to present a lapsed leaf (recovery is the rotator\'s job, not this transport\'s)', async () => {
const { dir, ks } = enrolledKs()
let called = 0
const request: MtlsRequest = async () => {
called += 1
return { status: 201, body: '{}' }
}
const f = createMtlsFetch(ks, {
request,
certParser: () => ({ validTo: new Date(Date.now() - 86_400_000) }),
})
await expect(f('https://cp.example.com/renew', { method: 'POST' })).rejects.toThrow(
/expired/i,
)
expect(called).toBe(0)
rmSync(dir, { recursive: true, force: true })
})
})
describe('wireAutoRenew exhausted routing', () => {
it('logs an actionable re-pair alarm and leaves the supervisor running', () => {
const fire: { exhausted?: (e: CertExpiredBeyondGraceError) => void } = {}
const rotator: CertRotator = {
start: vi.fn(),
stop: vi.fn(),
onRotated: () => {},
onRevoked: () => {},
onError: () => {},
onExhausted: (cb) => {
fire.exhausted = cb
},
}
const restartChild = vi.fn()
const stop = vi.fn()
const lines: string[] = []
wireAutoRenew(rotator, { restartChild, stop }, createLogger('info', (l) => lines.push(l)), {
subdomain: 'h7fd8',
hostId: 'h-1',
})
fire.exhausted?.(new CertExpiredBeyondGraceError(40 * 86_400_000, 30 * 86_400_000))
const alarm = lines.find((l) => /re-pair/i.test(l))
expect(alarm).toBeDefined()
expect(alarm).toContain('h7fd8')
// The supervisor keeps running: a later `pair` writes fresh cert files that the restarting
// frpc child picks up. Tearing down here would make recovery need a manual restart too.
expect(stop).not.toHaveBeenCalled()
expect(restartChild).not.toHaveBeenCalled()
})
})

View File

@@ -8,9 +8,12 @@ import { openKeystore } from '../src/keys/keystore.js'
import {
computeRenewDelayMs,
createCertRotator,
recoverCert,
recoveryUrlFor,
renewCert,
renewalUrlFor,
} from '../src/certs/rotation.js'
import { CertExpiredBeyondGraceError } from '../src/certs/rotation.js'
import { createBackoff } from '../src/transport/backoff.js'
import { FakeTimer } from './fixtures/fakes.js'
@@ -159,3 +162,149 @@ describe('createCertRotator (T13)', () => {
rmSync(dir, { recursive: true, force: true })
})
})
/**
* Expired-leaf recovery (production deadlock, 2026-07): `/renew` is mTLS-authenticated by the leaf
* it renews, so a lapsed leaf can never renew itself — and nginx will not even forward an expired
* client cert. Recovery is therefore a PLAIN POST to `/recover` carrying the expired cert in the
* body, plus a terminal signal once the grace window is spent so the agent stops retrying forever.
*/
describe('expired-leaf recovery', () => {
const HOUR = 3_600_000
it('derives /recover as a sibling PATH on the same enroll host', () => {
expect(recoveryUrlFor({ ...CFG, enrollUrl: 'https://enroll.terminal.example.com/enroll' })).toBe(
'https://enroll.terminal.example.com/recover',
)
})
it('honours an explicit recoverUrl over the derivation', () => {
expect(recoveryUrlFor({ ...CFG, recoverUrl: 'https://elsewhere.example.com/recover' })).toBe(
'https://elsewhere.example.com/recover',
)
})
it('recoverCert posts the EXPIRED cert plus a CSR, with no client cert, and installs the result', async () => {
const { dir, ks } = enrolledKs()
let seenUrl = ''
let seenBody: { cert?: string; csr?: string } = {}
const fetchImpl = (async (u: string, init: { body: string }) => {
seenUrl = u
seenBody = JSON.parse(init.body)
return jsonRes(201, { cert: 'FRESHCERT', caChain: ['FRESHCA'] })
}) as unknown as typeof fetch
const outcome = await recoverCert(
{ ...CFG, enrollUrl: 'https://enroll.terminal.example.com/enroll' },
ks.loadIdentity()!,
ks,
fetchImpl,
)
expect(outcome).toBe('rotated')
expect(seenUrl).toBe('https://enroll.terminal.example.com/recover')
expect(seenBody.cert).toBe('OLDCERT') // the lapsed leaf travels in the BODY, not the TLS layer
expect(seenBody.csr).toBeTruthy()
expect(ks.loadCert()!.certPem).toContain('FRESHCERT')
rmSync(dir, { recursive: true, force: true })
})
it('a still-valid leaf uses the mTLS /renew fetch, never the recovery one', async () => {
const { dir, ks } = enrolledKs()
const timer = new FakeTimer()
let renewCalls = 0
let recoverCalls = 0
const rotator = createCertRotator(CFG, ks.loadIdentity()!, ks, {
timer,
renewBeforeMs: 1000,
fetchImpl: (async () => {
renewCalls += 1
return jsonRes(200, { cert: 'NEWCERT', caChain: ['NEWCA'] })
}) as unknown as typeof fetch,
recoverFetchImpl: (async () => {
recoverCalls += 1
return jsonRes(200, {})
}) as unknown as typeof fetch,
now: () => new Date(0),
parseCert: () => new Date(2000), // valid at now=0
})
rotator.start()
timer.advance(1000)
await flush()
expect(renewCalls).toBe(1)
expect(recoverCalls).toBe(0)
rotator.stop()
rmSync(dir, { recursive: true, force: true })
})
it('an expired leaf INSIDE the grace window switches to the recovery fetch', async () => {
const { dir, ks } = enrolledKs()
const timer = new FakeTimer()
let renewCalls = 0
let recoverCalls = 0
const rotator = createCertRotator(CFG, ks.loadIdentity()!, ks, {
timer,
renewBeforeMs: 1000,
expiredGraceMs: 30 * 24 * HOUR,
fetchImpl: (async () => {
renewCalls += 1
return jsonRes(200, {})
}) as unknown as typeof fetch,
recoverFetchImpl: (async () => {
recoverCalls += 1
return jsonRes(201, { cert: 'NEWCERT', caChain: ['NEWCA'] })
}) as unknown as typeof fetch,
now: () => new Date(8 * 24 * HOUR), // 8 days after the leaf lapsed
parseCert: () => new Date(0),
})
let rotated = 0
rotator.onRotated(() => {
rotated += 1
})
rotator.start()
timer.advance(0)
await flush()
expect(recoverCalls).toBe(1)
expect(renewCalls).toBe(0)
expect(rotated).toBe(1)
rotator.stop()
rmSync(dir, { recursive: true, force: true })
})
it('BEYOND the grace window it fires onExhausted, issues no request, and stops retrying', async () => {
const { dir, ks } = enrolledKs()
const timer = new FakeTimer()
let requests = 0
const countingFetch = (async () => {
requests += 1
return jsonRes(201, {})
}) as unknown as typeof fetch
const rotator = createCertRotator(CFG, ks.loadIdentity()!, ks, {
timer,
renewBeforeMs: 1000,
expiredGraceMs: 30 * 24 * HOUR,
retryBackoff: createBackoff({ baseMs: 500, jitter: false }),
fetchImpl: countingFetch,
recoverFetchImpl: countingFetch,
now: () => new Date(31 * 24 * HOUR), // 31 days stale ⇒ past a 30-day grace
parseCert: () => new Date(0),
})
const errors: unknown[] = []
let exhausted: CertExpiredBeyondGraceError | null = null
rotator.onError((e) => errors.push(e))
rotator.onExhausted((e) => {
exhausted = e
})
rotator.start()
timer.advance(0)
await flush()
expect(exhausted).toBeInstanceOf(CertExpiredBeyondGraceError)
expect(requests).toBe(0) // nothing is even attempted — it cannot succeed
expect(errors).toHaveLength(0)
// Terminal: no retry armed. Retrying forever is what produced 6380 identical warnings.
timer.advance(60_000)
await flush()
expect(requests).toBe(0)
rmSync(dir, { recursive: true, force: true })
})
})

View File

@@ -51,12 +51,29 @@ export const DEFAULT_RENEW_RATE_WINDOW_MS = 60 * 60 * 1000
export const RENEW_RATE_MAX_IDENTITIES = 10_000
/** Max base64 length of a submitted CSR (a P-256 PKCS#10 is well under 1 KB; this is generous slack). */
export const MAX_CSR_B64_LEN = 8192
/** Max wire length of a body-carried certificate (a P-256 leaf PEM is ~1 KB; generous slack). */
export const MAX_PRESENTED_CERT_LEN = 16384
/**
* Default header the mTLS terminator forwards the verified client cert in (base64 DER). The terminator
* MUST set this from `$ssl_client_cert` AND strip any client-supplied copy — a client can never provide
* its own current cert. Production wiring can swap in a socket-peer-cert resolver instead.
*/
export const DEFAULT_CLIENT_CERT_HEADER = 'x-client-cert'
/**
* How long after `notAfter` a presented leaf may still authenticate its OWN re-issuance (30 days).
*
* `/renew` is authenticated by the very leaf it renews, so a strict expiry check means a host whose
* leaf lapsed can never renew it — it is bricked until an operator re-pairs (this happened: a laptop
* slept through its renewal window and the tunnel stayed down for 8 days). Accepting a recently
* expired leaf FOR RE-ISSUANCE ONLY breaks that deadlock.
*
* Nothing else is relaxed: the leaf must still chain to the CA anchor set, its SPIFFE identity must
* still resolve to a registry record that is `active` and account-consistent, and `notBefore` is NOT
* graced. The residual risk is that a stale stolen leaf stays usable for this window — an attacker
* holding an unexpired stolen leaf can already renew indefinitely, so this widens an existing
* exposure rather than opening a new one. Set to 0 to restore the strict behaviour.
*/
export const DEFAULT_EXPIRED_RENEW_GRACE_MS = 30 * 24 * 60 * 60 * 1000
/** Uniform reject: 401 = no/invalid current cert (unauthenticated); 403 = cert valid but not allowed. */
export class RenewRejectError extends Error {
@@ -170,10 +187,18 @@ export function headerPresentedCert(headerName: string = DEFAULT_CLIENT_CERT_HEA
const raw = req.headers[name]
const value = Array.isArray(raw) ? raw[0] : raw
if (typeof value !== 'string' || value.length === 0) return null
return certWireToDer(value)
},
}
}
/**
* Normalize a certificate carried over the wire to raw DER. Accepts base64 DER, PEM, and nginx's
* header-safe `$ssl_client_escaped_cert` (URL-encoded PEM): URL-decode if escaped, then strip PEM
* armor + whitespace to recover the base64 body. Returns null on anything unparseable.
*/
export function certWireToDer(value: string): Uint8Array | null {
try {
// The terminator may forward the verified cert as base64 DER, OR as PEM — including nginx's
// header-safe `$ssl_client_escaped_cert` (URL-encoded PEM). Normalize all three to raw DER:
// URL-decode if escaped, then strip PEM armor + whitespace to recover the base64 DER body.
let s = value.includes('%') ? safeDecodeUri(value) : value
if (s.includes('BEGIN CERTIFICATE')) {
s = s.replace(/-----[^-]+-----/g, '').replace(/\s+/g, '')
@@ -183,8 +208,6 @@ export function headerPresentedCert(headerName: string = DEFAULT_CLIENT_CERT_HEA
} catch {
return null
}
},
}
}
/** URL-decode `value`, returning it unchanged if it is not valid percent-encoding. */
@@ -207,6 +230,7 @@ function assertPresentedCertTrusted(
der: Uint8Array,
caAnchorsDer: readonly Uint8Array[],
nowMs: number,
expiredGraceMs: number,
): void {
let leaf: NodeX509Certificate
let anchors: NodeX509Certificate[]
@@ -220,7 +244,10 @@ function assertPresentedCertTrusted(
const notBefore = new Date(leaf.validFrom).getTime()
const notAfter = new Date(leaf.validTo).getTime()
if (Number.isNaN(notBefore) || Number.isNaN(notAfter)) throw new RenewRejectError(401)
if (nowMs < notBefore || nowMs > notAfter) throw new RenewRejectError(401)
// `notBefore` is never graced — a not-yet-valid cert is nonsense, not a recoverable lapse. Only
// `notAfter` gets the bounded renewal grace (see DEFAULT_EXPIRED_RENEW_GRACE_MS).
if (nowMs < notBefore) throw new RenewRejectError(401)
if (nowMs > notAfter + Math.max(0, expiredGraceMs)) throw new RenewRejectError(401)
}
/**
@@ -234,7 +261,11 @@ function assertPresentedCertTrusted(
function parsePresentedCertIdentity(
der: Uint8Array,
expectedKind: SpiffeKind,
verify?: { readonly caAnchorsDer: readonly Uint8Array[]; readonly nowMs: number },
verify?: {
readonly caAnchorsDer: readonly Uint8Array[]
readonly nowMs: number
readonly expiredGraceMs: number
},
): CertIdentity {
let leaf: x509.X509Certificate
try {
@@ -242,7 +273,8 @@ function parsePresentedCertIdentity(
} catch {
throw new RenewRejectError(401)
}
if (verify !== undefined) assertPresentedCertTrusted(der, verify.caAnchorsDer, verify.nowMs)
if (verify !== undefined)
assertPresentedCertTrusted(der, verify.caAnchorsDer, verify.nowMs, verify.expiredGraceMs)
const san = leaf.getExtension(x509.SubjectAlternativeNameExtension)
const uri = san?.names.toJSON().find((n) => n.type === 'url')?.value
if (uri === undefined) throw new RenewRejectError(401)
@@ -262,6 +294,13 @@ function parsePresentedCertIdentity(
}
const RenewBodySchema = z.object({ csr: z.string().min(1).max(MAX_CSR_B64_LEN) }).strict()
/** `/recover` additionally carries the EXPIRED leaf itself (base64 DER or PEM). */
const RecoverBodySchema = z
.object({
cert: z.string().min(1).max(MAX_PRESENTED_CERT_LEN),
csr: z.string().min(1).max(MAX_CSR_B64_LEN),
})
.strict()
export interface RenewDeps {
readonly hosts: HostRegistry
@@ -279,6 +318,11 @@ export interface RenewDeps {
readonly hostCaAnchorsDer?: readonly Uint8Array[]
/** device-CA anchor DER(s) — same role for the DEVICE renew path. */
readonly deviceCaAnchorsDer?: readonly Uint8Array[]
/**
* Window after `notAfter` in which a presented leaf may still authenticate its own re-issuance.
* Defaults to `DEFAULT_EXPIRED_RENEW_GRACE_MS`; 0 restores the strict fail-closed behaviour.
*/
readonly expiredRenewGraceMs?: number
}
/** Map any thrown error to a uniform HTTP reject — never leak which internal check failed. */
@@ -298,6 +342,9 @@ function sendError(reply: FastifyReply, err: unknown): void {
export function buildRenewRouter(deps: RenewDeps): FastifyPluginAsync {
const presentedCert = deps.presentedCert ?? headerPresentedCert()
const rateLimiter = deps.rateLimiter ?? createRenewRateLimiter()
// `/renew` stays STRICT (0): the mTLS terminator would never forward an expired cert to it anyway.
// The grace belongs to `/recover`, the route built for exactly that case.
const recoverGraceMs = deps.expiredRenewGraceMs ?? DEFAULT_EXPIRED_RENEW_GRACE_MS
return async (app) => {
app.post('/renew', async (req, reply) => {
@@ -307,7 +354,9 @@ export function buildRenewRouter(deps: RenewDeps): FastifyPluginAsync {
const identity = parsePresentedCertIdentity(
der,
'host',
deps.hostCaAnchorsDer ? { caAnchorsDer: deps.hostCaAnchorsDer, nowMs: Date.now() } : undefined,
deps.hostCaAnchorsDer
? { caAnchorsDer: deps.hostCaAnchorsDer, nowMs: Date.now(), expiredGraceMs: 0 }
: undefined,
)
rateLimiter.check(`host:${identity.accountId}:${identity.id}`)
@@ -333,6 +382,105 @@ export function buildRenewRouter(deps: RenewDeps): FastifyPluginAsync {
}
})
/**
* Expired-leaf recovery. The lapsed cert arrives in the BODY because no TLS terminator will
* forward an expired client certificate (nginx `optional` → bare 400; `optional_no_ca` tolerates
* only chain errors, never `X509_V_ERR_CERT_HAS_EXPIRED`). That makes this route the SOLE
* verifier, so it runs the identical trust pipeline as `/renew` — real X.509 path validation to
* the frp-client-CA anchors, SPIFFE parse, `notBefore`, registry `active` + account match — and
* differs ONLY in allowing a bounded overrun on `notAfter`.
*
* Possession of the private key is still proven: the CSR is self-signed by it, and the host
* signer's delegated gate enforces CSR PoP plus `CSR key == registered key`. A replayed cert
* without the key therefore yields, at most, a certificate the attacker cannot authenticate with.
* The header channel is deliberately IGNORED here — on this route only the body speaks.
*/
app.post('/recover', async (req, reply) => {
try {
const body = RecoverBodySchema.parse(req.body)
const der = certWireToDer(body.cert)
if (der === null) throw new RenewRejectError(401)
const identity = parsePresentedCertIdentity(
der,
'host',
deps.hostCaAnchorsDer
? {
caAnchorsDer: deps.hostCaAnchorsDer,
nowMs: Date.now(),
expiredGraceMs: recoverGraceMs,
}
: undefined,
)
rateLimiter.check(`recover:${identity.accountId}:${identity.id}`)
const host = await deps.hosts.getHostBySubdomain(identity.id)
if (host === null || host.status === 'revoked' || host.accountId !== identity.accountId) {
throw new RenewRejectError(403)
}
const leaf = await deps.renewer.renewHostLeaf(
host.hostId,
identity.publicKeySpki,
decodeCsrWire(body.csr),
)
await reply.code(201).send({
cert: bytesToBase64(leaf.cert),
caChain: leaf.caChain.map((c) => bytesToBase64(c)),
notAfter: leaf.notAfter.toISOString(),
})
} catch (err) {
sendError(reply, err)
}
})
/**
* Device counterpart of `/recover` (phone track). Same reason it exists, same shape: the lapsed
* cert arrives in the BODY because no terminator forwards an expired client certificate, so this
* route is the sole verifier — device-CA path validation, SPIFFE parse, `notBefore`, and the full
* registry consistency check (active + same account + `:id` matches the cert + same key). Only
* the `notAfter` bound is relaxed, by `expiredRenewGraceMs`.
*/
app.post('/device/:id/recover', async (req, reply) => {
try {
const body = RecoverBodySchema.parse(req.body)
const der = certWireToDer(body.cert)
if (der === null) throw new RenewRejectError(401)
const identity = parsePresentedCertIdentity(
der,
'device',
deps.deviceCaAnchorsDer
? {
caAnchorsDer: deps.deviceCaAnchorsDer,
nowMs: Date.now(),
expiredGraceMs: recoverGraceMs,
}
: undefined,
)
const id = (req.params as { id: string }).id
rateLimiter.check(`device-recover:${identity.id}`)
const record = await deps.devices.getDevice(id)
if (
record === null ||
record.status === 'revoked' ||
record.accountId !== identity.accountId ||
identity.id !== id ||
!timingSafeEqualBytes(record.ecPubkeySpki, identity.publicKeySpki)
) {
throw new RenewRejectError(403)
}
const leaf = await deps.renewer.renewDeviceLeaf(id, decodeCsrWire(body.csr))
await reply.code(201).send({
cert: bytesToBase64(leaf.cert),
caChain: leaf.caChain.map((c) => bytesToBase64(c)),
notAfter: leaf.notAfter.toISOString(),
})
} catch (err) {
sendError(reply, err)
}
})
app.post('/device/:id/renew', async (req, reply) => {
try {
const der = presentedCert.presentedCertDer(req)
@@ -340,7 +488,9 @@ export function buildRenewRouter(deps: RenewDeps): FastifyPluginAsync {
const identity = parsePresentedCertIdentity(
der,
'device',
deps.deviceCaAnchorsDer ? { caAnchorsDer: deps.deviceCaAnchorsDer, nowMs: Date.now() } : undefined,
deps.deviceCaAnchorsDer
? { caAnchorsDer: deps.deviceCaAnchorsDer, nowMs: Date.now(), expiredGraceMs: 0 }
: undefined,
)
const id = (req.params as { id: string }).id
rateLimiter.check(`device:${identity.id}`)

View File

@@ -502,6 +502,8 @@ describe('CP6c POST /renew — presented current-cert chain + expiry verificatio
headers: { 'x-client-cert': certHeader(expired) },
payload: { csr: b64Csr(ctx.csr) },
})
// STRICT on this route: nginx would never forward an expired client cert here anyway, so the
// deadlock escape hatch lives on `/recover` (below) and NOT on the mTLS renewal path.
expect(res.statusCode).toBe(401)
})
@@ -521,3 +523,281 @@ describe('CP6c POST /renew — presented current-cert chain + expiry verificatio
expect(res.statusCode).toBe(401)
})
})
/**
* CP6d · POST /recover — the expired-leaf escape hatch.
*
* Unlike `/renew` this route is NOT mTLS-authenticated: nginx cannot forward an expired client cert
* (under `ssl_verify_client optional` it 400s, and `optional_no_ca` only tolerates CHAIN errors, not
* `X509_V_ERR_CERT_HAS_EXPIRED`). The lapsed cert therefore arrives in the BODY, and the
* control-plane becomes the sole verifier. These tests pin down that nothing except the `notAfter`
* bound was relaxed — chain, SPIFFE, `notBefore`, and registry status all still decide.
*/
describe('CP6d POST /recover — expired-leaf recovery', () => {
async function mintLeaf(
ctx: HostCtx,
notBefore: Date,
notAfter: Date,
signer = ctx.ca.caSigner,
): Promise<Uint8Array> {
const spiffe = `spiffe://relay.terminal.yaojia.wang/account/${ctx.accountId}/host/${ctx.subdomain}`
return assembleCertificate({
subjectPublicKey: ctx.spki,
subject: `CN=${ctx.subdomain}`,
issuer: ctx.ca.caCert.subjectName,
serialNumber: Uint8Array.from([0x0b]),
notBefore,
notAfter,
extensions: [
new x509.SubjectAlternativeNameExtension([
{ type: 'dns', value: `${ctx.subdomain}.terminal.yaojia.wang` },
{ type: 'url', value: spiffe },
]),
new x509.BasicConstraintsExtension(false, undefined, true),
new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature, true),
new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage.clientAuth]),
],
signer,
sigAlg: 'ecdsa-p256',
})
}
const b64 = (der: Uint8Array): string => Buffer.from(der).toString('base64')
async function post(ctx: HostCtx, cert: Uint8Array, extra?: Partial<RenewDeps>) {
const app = appWith(hostDeps(ctx, { hostCaAnchorsDer: [ctx.ca.caDer], ...extra }))
await app.ready()
return app.inject({
method: 'POST',
url: '/recover',
payload: { cert: b64(cert), csr: b64Csr(ctx.csr) },
})
}
test('a leaf expired INSIDE the grace window is re-issued → 201 (breaks the deadlock)', async () => {
const ctx = await hostCtx('alice')
const now = Date.now()
const res = await post(ctx, await mintLeaf(ctx, new Date(now - 9 * DAY_MS), new Date(now - 8 * DAY_MS)))
expect(res.statusCode).toBe(201)
expect(JSON.parse(res.payload).cert).toBeTruthy()
})
test('a leaf expired BEYOND the grace window is refused → 401', async () => {
const ctx = await hostCtx('alice')
const now = Date.now()
const res = await post(ctx, await mintLeaf(ctx, new Date(now - 60 * DAY_MS), new Date(now - 31 * DAY_MS)))
expect(res.statusCode).toBe(401)
})
test('grace 0 disables recovery entirely → 401', async () => {
const ctx = await hostCtx('alice')
const now = Date.now()
const res = await post(
ctx,
await mintLeaf(ctx, new Date(now - 2 * DAY_MS), new Date(now - DAY_MS)),
{ expiredRenewGraceMs: 0 },
)
expect(res.statusCode).toBe(401)
})
/**
* THE load-bearing test for this route. nginx no longer validates the chain here, so a forged
* self-signed cert carrying a correct-looking SPIFFE SAN must be rejected by the control-plane
* alone. If this ever goes green-to-red, `/recover` becomes an unauthenticated cert vending machine.
*/
test('a self-signed cert with a FORGED SPIFFE SAN is refused → 401', async () => {
const ctx = await hostCtx('alice')
const rogueCa = await makeP256Ca('rogue-CA')
const now = Date.now()
const rogue = await mintLeaf(
ctx,
new Date(now - 9 * DAY_MS),
new Date(now - 8 * DAY_MS),
rogueCa.caSigner,
)
const res = await post(ctx, rogue)
expect(res.statusCode).toBe(401)
})
test('recovery NEVER bypasses revocation — revoked host → 403', async () => {
const ctx = await hostCtx('alice')
const now = Date.now()
const cert = await mintLeaf(ctx, new Date(now - 9 * DAY_MS), new Date(now - 8 * DAY_MS))
const host = await ctx.hosts.getHostBySubdomain('alice')
await ctx.hosts.setHostStatus(host!.hostId, 'revoked')
const res = await post(ctx, cert)
expect(res.statusCode).toBe(403)
})
test('grace covers notAfter ONLY — a not-yet-valid leaf is refused → 401', async () => {
const ctx = await hostCtx('alice')
const now = Date.now()
const res = await post(ctx, await mintLeaf(ctx, new Date(now + DAY_MS), new Date(now + 2 * DAY_MS)))
expect(res.statusCode).toBe(401)
})
test('a still-valid leaf may also use /recover → 201', async () => {
const ctx = await hostCtx('alice')
const res = await post(ctx, ctx.currentCertDer)
expect(res.statusCode).toBe(201)
})
test('a missing cert field is rejected → 400 (uniform schema reject, same as a missing csr)', async () => {
const ctx = await hostCtx('alice')
const app = appWith(hostDeps(ctx, { hostCaAnchorsDer: [ctx.ca.caDer] }))
await app.ready()
const res = await app.inject({ method: 'POST', url: '/recover', payload: { csr: b64Csr(ctx.csr) } })
// The module answers with uniform rejects that never say which check failed: 400 for a malformed
// body, 401 for a cert that parses but is not trusted, 403 for a trusted cert that is not allowed.
expect(res.statusCode).toBe(400)
})
/**
* The header is the mTLS terminator's channel. On `/recover` the cert comes from the body, so a
* client-supplied header must not be able to substitute an identity.
*/
test('an x-client-cert HEADER cannot override the body identity → 401', async () => {
const ctx = await hostCtx('alice')
const rogueCa = await makeP256Ca('rogue-CA')
const now = Date.now()
const rogue = await mintLeaf(ctx, new Date(now - 9 * DAY_MS), new Date(now - 8 * DAY_MS), rogueCa.caSigner)
const app = appWith(hostDeps(ctx, { hostCaAnchorsDer: [ctx.ca.caDer] }))
await app.ready()
const res = await app.inject({
method: 'POST',
url: '/recover',
headers: { 'x-client-cert': certHeader(ctx.currentCertDer) },
payload: { cert: b64(rogue), csr: b64Csr(ctx.csr) },
})
expect(res.statusCode).toBe(401)
})
})
/**
* CP6e · POST /device/:id/recover — the phone-track counterpart of `/recover`.
*
* A device certificate hits the identical deadlock as a host leaf: `/device/:id/renew` is
* mTLS-authenticated by the cert being renewed, and nginx refuses to forward an expired client cert
* in any mode. Without this route a phone whose cert lapsed has to be re-enrolled by hand.
*/
describe('CP6e POST /device/:id/recover — expired device-cert recovery', () => {
async function mintDeviceLeaf(
ctx: DeviceCtx,
accountId: string,
notBefore: Date,
notAfter: Date,
signer = ctx.ca.caSigner,
): Promise<Uint8Array> {
const spiffe = `spiffe://relay.terminal.yaojia.wang/account/${accountId}/device/${ctx.deviceId}`
return assembleCertificate({
subjectPublicKey: ctx.spki,
subject: `CN=web-terminal-device`,
issuer: ctx.ca.caCert.subjectName,
serialNumber: Uint8Array.from([0x0d]),
notBefore,
notAfter,
extensions: [
new x509.SubjectAlternativeNameExtension([
{ type: 'dns', value: `alice.${ZONE}` },
{ type: 'url', value: spiffe },
]),
new x509.BasicConstraintsExtension(false, undefined, true),
new x509.KeyUsagesExtension(x509.KeyUsageFlags.digitalSignature, true),
new x509.ExtendedKeyUsageExtension([x509.ExtendedKeyUsage.clientAuth]),
],
signer,
sigAlg: 'ecdsa-p256',
})
}
const b64 = (der: Uint8Array): string => Buffer.from(der).toString('base64')
async function post(ctx: DeviceCtx, cert: Uint8Array, extra?: Partial<RenewDeps>) {
const app = appWith({ ...deviceDeps(ctx), deviceCaAnchorsDer: [ctx.ca.caDer], ...extra })
await app.ready()
return app.inject({
method: 'POST',
url: `/device/${ctx.deviceId}/recover`,
payload: { cert: b64(cert), csr: b64Csr(ctx.csr) },
})
}
test('a device cert expired INSIDE the grace window is re-issued → 201', async () => {
const ctx = await deviceCtx('alice')
const rec = await ctx.devices.getDevice(ctx.deviceId)
const now = Date.now()
const res = await post(
ctx,
await mintDeviceLeaf(ctx, rec!.accountId, new Date(now - 9 * DAY_MS), new Date(now - 8 * DAY_MS)),
)
expect(res.statusCode).toBe(201)
expect(JSON.parse(res.payload).cert).toBeTruthy()
})
test('a device cert expired BEYOND the grace window is refused → 401', async () => {
const ctx = await deviceCtx('alice')
const rec = await ctx.devices.getDevice(ctx.deviceId)
const now = Date.now()
const res = await post(
ctx,
await mintDeviceLeaf(ctx, rec!.accountId, new Date(now - 60 * DAY_MS), new Date(now - 31 * DAY_MS)),
)
expect(res.statusCode).toBe(401)
})
/** Same load-bearing check as the host route: nginx validates nothing on this path. */
test('a self-signed cert with a FORGED device SPIFFE SAN is refused → 401', async () => {
const ctx = await deviceCtx('alice')
const rogueCa = await makeP256Ca('rogue-CA')
const rec = await ctx.devices.getDevice(ctx.deviceId)
const now = Date.now()
const res = await post(
ctx,
await mintDeviceLeaf(
ctx,
rec!.accountId,
new Date(now - 9 * DAY_MS),
new Date(now - 8 * DAY_MS),
rogueCa.caSigner,
),
)
expect(res.statusCode).toBe(401)
})
test('recovery NEVER bypasses revocation — revoked device → 403', async () => {
const ctx = await deviceCtx('alice')
const rec = await ctx.devices.getDevice(ctx.deviceId)
const now = Date.now()
const cert = await mintDeviceLeaf(ctx, rec!.accountId, new Date(now - 9 * DAY_MS), new Date(now - 8 * DAY_MS))
await ctx.devices.setDeviceStatus(ctx.deviceId, 'revoked')
const res = await post(ctx, cert)
expect(res.statusCode).toBe(403)
})
test('the path :id must match the cert identity — no cross-device smuggling → 403', async () => {
const ctx = await deviceCtx('alice')
const rec = await ctx.devices.getDevice(ctx.deviceId)
const now = Date.now()
const cert = await mintDeviceLeaf(ctx, rec!.accountId, new Date(now - 9 * DAY_MS), new Date(now - 8 * DAY_MS))
const app = appWith({ ...deviceDeps(ctx), deviceCaAnchorsDer: [ctx.ca.caDer] })
await app.ready()
const res = await app.inject({
method: 'POST',
url: `/device/someone-else/recover`,
payload: { cert: b64(cert), csr: b64Csr(ctx.csr) },
})
expect(res.statusCode).toBe(403)
})
test('grace 0 disables device recovery entirely → 401', async () => {
const ctx = await deviceCtx('alice')
const rec = await ctx.devices.getDevice(ctx.deviceId)
const now = Date.now()
const res = await post(
ctx,
await mintDeviceLeaf(ctx, rec!.accountId, new Date(now - 2 * DAY_MS), new Date(now - DAY_MS)),
{ expiredRenewGraceMs: 0 },
)
expect(res.statusCode).toBe(401)
})
})

View File

@@ -0,0 +1,107 @@
# Enroll vhost addition — `/recover` (expired-leaf recovery)
One `location` block to **merge into the existing** enroll vhost on the VPS
(`/etc/nginx/conf.d/enroll.conf`, the `127.0.0.1:8471` server). No new server, no new SNI route, no
DNS work.
> **This is a MERGE, not a file to ship.** The enroll vhost also carries `/enroll`, `/device/enroll`,
> `/auth/login`, `/crl/`, `/renew` and `/device/:id/renew` — do not replace it.
## Why the route exists
`POST /renew` is mTLS-authenticated by the very leaf it renews, so once that leaf lapses the host can
never renew it and the tunnel stays down until an operator re-pairs. That is not hypothetical: a
laptop slept through its 8h renewal window, its 24h leaf expired, and the agent then logged
`client certificate has expired; renew before dialling` 6380 times over 8 days without recovering.
## Why it cannot be fixed on `/renew` itself
**nginx will not forward an expired client certificate, under any `ssl_verify_client` mode.**
- `optional` → nginx answers a bare `400 The SSL certificate error` as soon as verification fails.
The request never reaches the `location`, so no `if ($ssl_client_verify …)` can rescue it.
- `optional_no_ca` → does **not** help either. It only tolerates *chain* failures; see nginx's
`ngx_ssl_verify_error_optional()`, which covers `DEPTH_ZERO_SELF_SIGNED_CERT`,
`SELF_SIGNED_CERT_IN_CHAIN`, `UNABLE_TO_GET_ISSUER_CERT_LOCALLY` and
`UNABLE_TO_VERIFY_LEAF_SIGNATURE` — and **not** `X509_V_ERR_CERT_HAS_EXPIRED`.
- `ssl_verify_client` is a `server`-level directive, so it cannot be relaxed per-location anyway.
So recovery drops mTLS: `/recover` takes **no client certificate**, and the lapsed cert travels in
the request body instead.
## Why that is still authenticated
A certificate is public, so the body alone proves nothing — possession of the **private key** does,
and it is still proven end to end:
- the accompanying CSR is **self-signed by that key**, and the host signer's delegated gate enforces
CSR proof-of-possession plus `CSR key == registered key` (`control-plane/src/ca/csr.ts`
`verifyCsrPoP`);
- `control-plane/src/api/renew.ts` runs the *same* trust pipeline as `/renew` — real X.509 path
validation to the frp-client-CA anchors, SPIFFE SAN parse, `notBefore`, and a registry lookup
requiring an `active`, account-consistent host — differing **only** in a bounded overrun allowance
on `notAfter` (`DEFAULT_EXPIRED_RENEW_GRACE_MS`, 30 days).
Worst case for a replayed cert without the key: the attacker receives a certificate they cannot
authenticate with. Revocation still bites, via registry status.
## The blocks to add
Two routes, same rationale: `/recover` re-issues an expired **host** frp-client leaf,
`/device/:id/recover` does the same for an expired **device** cert (phone track).
```nginx
# Expired-leaf recovery: NO client cert (see enroll-recover-location.md). The control-plane is
# the sole verifier; strip any client-supplied cert header so only the body can speak.
location = /recover {
limit_req zone=renew_recover burst=5 nodelay;
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header x-client-cert "";
proxy_read_timeout 60s;
}
# Same, for an expired DEVICE cert. Must sit ABOVE the `~ ^/device/[^/]+/renew$` mTLS location
# only if that regex could also match `/recover` — it cannot, but keep them adjacent so the pair
# is obvious to the next editor.
location ~ ^/device/[^/]+/recover$ {
limit_req zone=renew_recover burst=5 nodelay;
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header x-client-cert "";
proxy_read_timeout 60s;
}
```
And once, in the `http` context (top of `enroll.conf` is fine) — the route is reachable
pre-authentication and costs the control-plane an X.509 path validation, so bound it. The
control-plane additionally rate-limits per identity (`createRenewRateLimiter`, 30/hour):
```nginx
limit_req_zone $binary_remote_addr zone=renew_recover:1m rate=10r/m;
```
## Deploy gate
```bash
cp /etc/nginx/conf.d/enroll.conf{,.bak.$(date +%s)} # snapshot first
# ...merge the block above...
nginx -t && systemctl restart nginx # NEVER reload on a failed -t
```
`restart`, not `reload`: a graceful reload has been observed keeping old workers alive for seconds,
which makes post-deploy verification race the change.
## Verify
```bash
# no cert needed — an in-grace expired leaf is re-issued
curl -sS --resolve enroll.terminal.yaojia.wang:443:<vps> \
-X POST -H 'content-type: application/json' \
-d "{\"cert\":\"$(base64 -w0 expired.cert.pem)\",\"csr\":\"$(base64 -w0 new.csr.der)\"}" \
https://enroll.terminal.yaojia.wang/recover # → 201 {cert,caChain,notAfter}
# a forged self-signed cert with a correct-looking SPIFFE SAN must still be refused
# → 401 (this is the load-bearing check; nginx is no longer validating the chain here)
```

View File

@@ -24,6 +24,59 @@
> 新会话读到的第一块。保持准确,只描述"此刻"。
### 🧹 [x] 补完上一条留下的三个遗留(2026-07-29,紧接死锁修复之后)
- **① `.gitignore` 把源码吞了**: `agent/src/dist/buildBinary.ts` 是打包配置(源码),却被通配的 `dist/` 规则排除,**从未提交**——全新 clone 既过不了 `agent/src/index.ts` 的类型检查,也导入不了已提交的 `agent/test/buildBinary.test.ts`。修:**先解除目录排除**(`!agent/src/dist/` + `!agent/src/dist/**`)再提交文件 —— git **不会进入被排除的目录**,所以只否定文件名是无效的(实测确认)。反向验证 `agent/dist/``dist/``public/build/``desktop/dist-app/` 仍被忽略。
- **② 隧道日志不带主机标识**: `pair` 从 enroll 响应拿到 hostId/subdomain 后直接丢弃,长跑的 `run` 进程无从得知,所以每条日志都是 `{"subdomain":null,"hostId":null}` —— 包括 8 天故障期那 6380 条告警,恰恰是最需要知道"是哪台机器"的时候。修:新增 `agent/src/config/hostRecord.ts`(`saveHostRecord`/`loadHostRecord`/`subdomainFromCertPem`/`resolveHostIdentity`),enroll 时落 `host.json`;解析优先级 **config(env/argv) > 记录文件 > 叶证书 SPIFFE SAN**。第三档是给**记录文件出现之前入网的老主机**的:它们的证书里本来就有子域名,因此**无需重新 pair** 就能把标识找回来。接入点:`certs/nativeRenew.ts`(续期告警)与 `cli/deps.ts`(健康状态行)。
- **③ 手机轨没有恢复路径**: 新增 `POST /device/:id/recover`,与主机版同构 —— 证书走 body、device-CA 路径验证、SPIFFE 解析、`notBefore` **绝不宽限**,外加完整 registry 一致性检查(active + 同账户 + `:id` 与证书一致 + 同 key),**只放宽 `notAfter`**。nginx 在 enroll vhost 加 `location ~ ^/device/[^/]+/recover$`
- **验证**: 单测 agent **300/300**、control-plane **296/296**,两侧 `tsc` 干净(TDD 先 RED 后 GREEN)。
- **线上实测**:`/device/xyz/recover` 已能抵达 CP(返回 CP 的 `{"error":"rejected"}` JSON 而非 nginx 页面);回归 —— 伪造主机证书打 `/recover` 仍 401、过期证书打 `/renew` 仍被 nginx 拦为 400、`/enroll` 免证书路径未受影响。
- **标识实测(本机 h7fd8)**:写入 `host.json` 后日志出现 `subdomain: h7fd8` + `host_id: 7190ecd4-…`;**把 `host.json` 移走**后重启,新一跳仍能从证书 SPIFFE SAN 恢复出 `subdomain: h7fd8`(`host_id: (none)`,证书里本就没有)——老装机回退路径实盘确认。隧道端到端 **HTTP 200** 不受影响。
- **遗留**: **iOS / Android 客户端尚未接线去调用 `/device/:id/recover`** —— 服务端能力已具备,客户端触发还没有。设备证书过期时目前仍需重新 enroll。
- **commit**: `2a602d5`(合并 `d92caed`)。
### 🔥 [x] 隧道断了 8 天 —— 证书过期后**自动续期死锁**,已修 + 已上线(2026-07-29)
- **现象**: 用户问"远端 relay 是否在运行"。服务端**全绿**(nginx/frps/cp-zte/panel-zte/xray/pg/redis 全 active,LE 通配证书有效至 2026-10-05,`*.terminal.yaojia.wang` 通配 DNS 正常),但 **frps `:7000` 已建立连接数 = 0**,`h7fd8.terminal.yaojia.wang` 打不通。Mac 上 frpc 在跑,每 20s 重试一次,全部 `connect to server error: EOF`(frps 在 mTLS 阶段拒)。
- **根因(死锁)**: Mac 的 frp-client 叶证书 **2026-07-21 13:55 UTC 过期**(TTL 仅 ~24h)。`POST /renew` 是**用它自己要续的那张证书做 mTLS 鉴权**的 —— 证书一过期就再也换不了证书。触发链:续期窗口(T-8h)那天 Mac 在睡眠,醒来时 DNS 未就绪 → 24 次 `getaddrinfo ENOTFOUND enroll.terminal.yaojia.wang` 错过窗口 → 过期后改报 `client certificate has expired; renew before dialling`,**重复 6380 次、8 天、永不自愈**(agent.log 涨到 3MB)。
- **三层都 fail-closed**,只改一层没用:① agent `transport/dial.ts` `buildTlsOptions``CertExpiredError`,连 socket 都不开;② nginx `enroll.conf``location = /renew` 要求 `$ssl_client_verify = SUCCESS`;③ CP `api/renew.ts` `assertPresentedCertTrusted` 再查一次有效期 → 401。
- **两个假设被实测推翻(重要,别再踩)**:
1. `ssl_verify_client optional` 在客户端证书**验证失败时直接返回裸 400**(`400 The SSL certificate error`),请求**根本到不了 location** —— 所以任何 `if ($ssl_client_verify …)` 都救不了。
2. `optional_no_ca` **也不行**:它只容忍**链**错误。见 nginx 的 `ngx_ssl_verify_error_optional()` —— 只含 `DEPTH_ZERO_SELF_SIGNED_CERT` / `SELF_SIGNED_CERT_IN_CHAIN` / `UNABLE_TO_GET_ISSUER_CERT_LOCALLY` / `UNABLE_TO_VERIFY_LEAF_SIGNATURE`,**不含 `X509_V_ERR_CERT_HAS_EXPIRED`**。已在 VPS 上用真过期证书打 :8472 实测确认。结论:**nginx 在任何模式下都不会转发过期客户端证书**。
- **最终方案(用户选定:30 天宽限窗口 + 超窗回落配对码)**: 恢复路径**放弃 mTLS**,改为**不带客户端证书的普通 HTTPS POST,过期证书放进 body**。不丢鉴权 —— **CSR 是用同一把私钥自签的**,而签名网关本来就强制 CSR PoP(`ca/csr.ts` `verifyCsrPoP`)+ `CSR key == 注册 key`,持有私钥的证明与原来 TLS 握手给的完全等价。重放一张(公开的)证书而没有私钥,最多拿到一张**自己用不了**的证书。
- **agent**: `dial.ts` 与 mTLS renew 通道**保持严格 fail-closed**(放宽已从 TLS 层完全移除)。改由 rotator 逐次决策:有效 → mTLS `/renew`;过期但在宽限内 → 普通 `/recover`;超出宽限 → 终态 `onExhausted`,**一次告警后停止重试**(不再发任何请求),日志直接给出 `web-terminal-agent pair <CODE>`。新增 `recoverCert()``recoveryUrlFor()``DEFAULT_EXPIRED_RENEW_GRACE_MS`(30d)、`CertExpiredBeyondGraceError`;`AgentConfig` 加可选 `recoverUrl`(env `RECOVER_URL`)。
- **CP**: `/renew` **恢复严格**(grace 0,与终端器行为一致)。宽限只给新增的 `POST /recover` —— 证书从 **body** 读、**忽略 `x-client-cert` 头**,然后跑**与 `/renew` 完全相同**的信任链:frp-client-CA 锚点做真 X.509 路径验证 → SPIFFE 解析 → `notBefore`(**永不宽限**)→ registry `active` + 账户一致。**吊销照样生效**。
- **deploy**: 不需要新 vhost / 新 SNI / 新 DNS —— 只往现有 enroll vhost 加一个 `location = /recover`(+ `limit_req` 10r/m)。文档 `deploy/nginx/enroll-recover-location.md`(含 nginx 源码引证)。
- **验证**:
- 单测:agent **289/289**、control-plane **290/290**,两侧 `tsc --noEmit` 干净。TDD 全程先 RED 后 GREEN。
- 承重测试:**「山寨 CA 签的、SPIFFE SAN 与真证书逐字节相同的伪造证书 → 401」**。这条最关键 —— 这条路径上 nginx 已不再验链,它一旦变红,`/recover` 就成了无鉴权发证机。已在**线上实测 401**。
- **线上端到端自愈实测**:把那张真·过期 8 天的证书塞回 keystore → 重启 agent → 日志 `frp-client cert rotated; restarting frpc onto the fresh leaf` → 证书自动换成新的 24h 叶(Jul30 08:06)→ frpc `login to server success` / `start proxy success` → 设备证书走完整链路 `:443 → SNI → :8470 mTLS → frps → frpc → Mac base app` 拿到 **HTTP 200**(返回本机真实 session 列表)→ 健康探针 `healthy: true`。**全程零人工介入** —— 正是生产卡死 8 天的那个场景。
- 回归:`/renew` 对过期证书仍 400(nginx 拦住,未放宽);4 区 SNI 探针(frp/通配/enroll/xray-Reality)全部未受影响。
- **应急恢复(修复前先做的)**: 用 frp-client CA 给 h7fd8 补签了一张 7 天过桥证书(同一把私钥、同子域名、同 SPIFFE SAN,经 `openssl ca` 登记进 index.txt 因此可吊销),先把隧道救活。自愈生效后它已被换成 CP 正常签发的 24h 叶。
- **遗留 / 待办**:
- **`agent/src/dist/buildBinary.ts` 是源码却被 `.gitignore``dist/` 规则吞掉,从未提交**(既有问题,与本次无关):任何全新 clone/worktree 都缺它 → `agent/src/index.ts` 类型检查失败、已提交的 `test/buildBinary.test.ts` 直接导入失败。建议加 `!agent/src/dist/` 例外。
- agent 日志里 `subdomain: (none)` / `host_id: (none)`:enroll 后未把 subdomain/hostId 落进配置,导致告警缺主机标识(既有问题)。
- 手机轨(device)目前**没有** `/recover` 对应路径,设备证书过期仍需重新 enroll;CP 侧宽限代码是通用的,补一条路由即可。
- 24h TTL + 单次定时续期 + 笔记本睡眠 的组合仍偏脆,宽限窗口是兜底而非根治。
- **文件**: `agent/src/certs/rotation.ts` · `agent/src/certs/nativeRenew.ts` · `agent/src/config/agentConfig.ts` · `control-plane/src/api/renew.ts` · `deploy/nginx/enroll-recover-location.md`(新) · 各自测试。
- **commit**: `f3f4d8b`(第一版,方向对但 nginx 那条路走不通)+ `5509c81`(实测后改为 body 传证书,最终版)。
### 🐛 [x] Mac 版中文全变 `_` + 框线渲染错乱 —— PTY 缺 UTF-8 locale(2026-07-28)
- **现象**(用户截图,Mac 版):Claude Code TUI 里所有中文变成 `_`;banner/输入框只剩零散横线,圆角边框消失。
- **根因**(实测确认,不是字体也不是前端):`src/session/session.ts` spawn PTY 时用 `env: {...process.env}`,而**服务端由 launchd/Finder 启动,环境里根本没有 `LANG`/`LC_*`**(`~/Library/LaunchAgents/com.web-terminal.base-app.plist``EnvironmentVariables` 只有 PATH/PORT/BIND_HOST)。tmux 逐 client 从 `LC_ALL`/`LC_CTYPE`/`LANG` 判断 UTF-8 能力,全空 → 进入**非 UTF-8 模式**,在**服务端就把字节改写掉**:宽字符→`_``✓ ⏺` 等→`_``═║╔╗` 降级成 DEC Special Graphics(`ESC ( 0` + `qxlk`)。所以前端 xterm 收到的已经是残骸,改字体/renderer 救不回来。
- 用户 `~/.zshrc` 里其实**有** `LANG=sv_SE.UTF-8`,但没用:tmux 是 PTY 的根进程,它做完 UTF-8 判定之后 shell 才 source profile。登录 shell(`-l`)救不了 tmux。
- node-pty 探针实证:无 LANG → `MARK ________ ==BOX== \e(0qxlk`;有 LANG → `MARK 中文测试 ==BOX== ═║╔╗ ✓`
- **修复**: 新增 `src/session/locale.ts``withUtf8Locale(env)`,在 `createSession()` 的 spawn env 上先过一道。POSIX 优先级 `LC_ALL > LC_CTYPE > LANG`;**已是 UTF-8 就原样不动**(用户的 `zh_CN.UTF-8`/`ja_JP.UTF-8` 保留),只在缺失或非 UTF-8(`C`/`POSIX`/`ISO8859-1`)时补 `en_US.UTF-8`(并清掉会盖过它的非 UTF-8 `LC_ALL`/`LC_CTYPE`)。
- **验证**:
- `test/locale.test.ts` 9 例(TDD:先 RED 再 GREEN);`tsc --noEmit` 干净。
- 端到端:用 `env -u LANG -u LC_ALL -u LC_CTYPE` 复现 launchd 环境跑 server → 新建 session → `中文测试:你好,世界!`/`🎉 ✅`/`╭─╮ ═║╔╗ █▓▒░ ⏺ ⎿` **DOM 里逐字符核对全部正确**(修复前同一条命令得到 `________`)。
- 之后 `npm run build` + `launchctl kickstart` 让真正的 launchd 单元(仍无 LANG)接管,再复验一次通过。
- 回归:`test/integration/server.test.ts`(含 ⑥ CJK ring-buffer replay、H1 tmux 重启存活)+ git-ops + worktree + projects 共 **107 例全绿**。(全量 79 文件并跑时有 4 例超时 flake,单跑全过,与本改动无关。)
- 存量 tmux session **无需重建**:探针证明用带 LANG 的新 client 重新 attach 一个"非 UTF-8 模式下创建"的旧 session,新输出即恢复正常(只有已滚进 scrollback 的旧 `_` 救不回)。
- **遗留**: `/Applications/Web Terminal.app/Contents/Resources/dist/` 里打包的是 7/18 的旧代码,**仍未修**——桌面 Electron 版要 `cd desktop && npm run dist:mac` 重打包(或就地补 `dist/session/*.js`,注意 adhoc 签名会失效)。仓库内 `dist/` 与 launchd base-app 已修好。
- **文件**: `src/session/locale.ts`(新)· `src/session/session.ts`(spawn env)· `test/locale.test.ts`(新)。
### 🔐 零接触隧道 Enrollment 产品化 + Control Panel(2026-07-18~23,当前活跃)
- **需求**: 用户要"本机 app 一跑就自动连服务器、自动管证书;手机连接时自动搞定证书"——即**产品级零接触**(引导时一次动作,之后自动签发+续期,永不再管)。诚实边界:第一次签发无法零人类动作(否则谁碰端点谁拿证书),所以"引导一次,之后免管"。

64
src/session/locale.ts Normal file
View File

@@ -0,0 +1,64 @@
/**
* src/session/locale.ts — guarantee the spawned PTY has a UTF-8 locale.
*
* The bug this fixes: when the server is launched from Finder / launchd (the Mac
* desktop app, or the launchd-managed tunnel agent) the process environment has
* NO `LANG` / `LC_*` at all — unlike a shell started from Terminal.app. tmux
* decides per client whether the terminal is UTF-8 capable purely from those
* variables, so with none set it falls back to its non-UTF-8 mode and:
*
* - rewrites every wide character to `_` → `中文测试` renders as `________`
* - rewrites other non-Latin-1 glyphs to `_` (`✓`, `⏺`, emoji …)
* - downgrades double-line box drawing (`═║╔╗`) to DEC Special Graphics
* (`ESC ( 0` + `qxlk`), i.e. single lines
*
* That mangling happens on the server side, *before* the bytes reach xterm.js,
* so no amount of frontend font work can recover it. The `-l` login shell can't
* fix it either: tmux is the PTY's root process and has already made its
* decision by the time the shell sources `~/.zprofile`.
*
* Policy: leave the environment alone whenever the effective charset is already
* UTF-8 — a user who picked `zh_CN.UTF-8` or `ja_JP.UTF-8` keeps it. Only a
* missing or non-UTF-8 charset is replaced. Precedence follows POSIX:
* `LC_ALL` > `LC_CTYPE` > `LANG`.
*/
/** Fallback locale when the environment specifies no UTF-8 charset. */
export const DEFAULT_UTF8_LOCALE = 'en_US.UTF-8';
/** Matches the charset suffix of a locale: `.UTF-8`, `.utf8`, or a bare `UTF-8`. */
const UTF8_PATTERN = /utf-?8$/i;
/** Environment mapping as node-pty / process.env exposes it. */
type Env = Readonly<Record<string, string | undefined>>;
function isUtf8(value: string | undefined): boolean {
return value !== undefined && value !== '' && UTF8_PATTERN.test(value);
}
/**
* Return a copy of `env` that is guaranteed to select a UTF-8 charset.
*
* Existing values are never rewritten — if the effective locale is already
* UTF-8 the env comes back unchanged (aside from being a fresh object).
*/
export function withUtf8Locale(env: Env): Record<string, string | undefined> {
// LC_ALL outranks everything; LC_CTYPE outranks LANG for character handling.
// If whichever one is in effect is already UTF-8, there is nothing to do.
const effective =
env.LC_ALL !== undefined && env.LC_ALL !== ''
? env.LC_ALL
: env.LC_CTYPE !== undefined && env.LC_CTYPE !== ''
? env.LC_CTYPE
: env.LANG;
if (isUtf8(effective)) return { ...env };
// Setting LANG (the lowest-precedence variable) is the least invasive fix, but
// it only takes effect if nothing above it is overriding — so clear a
// non-UTF-8 LC_ALL / LC_CTYPE that would otherwise win.
const next: Record<string, string | undefined> = { ...env, LANG: DEFAULT_UTF8_LOCALE };
if (env.LC_ALL !== undefined && env.LC_ALL !== '') delete next.LC_ALL;
if (env.LC_CTYPE !== undefined && env.LC_CTYPE !== '') delete next.LC_CTYPE;
return next;
}

View File

@@ -39,6 +39,7 @@ import { WS_OPEN } from '../types.js';
import { serialize } from '../protocol.js';
import { createRingBuffer } from './ring-buffer.js';
import { tmuxName, killSession } from './tmux.js';
import { withUtf8Locale } from './locale.js';
/** Send a server message to `ws` only if it is OPEN (M5). Never throws on a
* closed socket; forwarding to a dead ws is simply a no-op. */
@@ -105,8 +106,11 @@ export function createSession(
cols: dims.cols,
rows: dims.rows,
cwd: cwd ?? cfg.homeDir,
// A Finder/launchd-launched server has NO LANG/LC_* — which puts tmux in its
// non-UTF-8 mode and turns every CJK/wide character into `_` before the bytes
// ever reach xterm. withUtf8Locale() fills one in (and only then).
env: {
...process.env,
...withUtf8Locale(process.env),
WEBTERM_SESSION: id,
WEBTERM_HOOK_URL: `http://127.0.0.1:${cfg.port}/hook`,
// B2: statusLine scripts POST telemetry here; WEBTERM_NTFY_* vars forwarded

70
test/locale.test.ts Normal file
View File

@@ -0,0 +1,70 @@
import { describe, it, expect } from 'vitest';
import { withUtf8Locale, DEFAULT_UTF8_LOCALE } from '../src/session/locale.js';
/**
* locale.ts — UTF-8 locale guarantee for the spawned PTY.
*
* Why this exists: when the server is launched from Finder / launchd (the Mac
* desktop app) the process env has NO `LANG`/`LC_*`. tmux then runs in its
* non-UTF-8 mode and rewrites every wide character to `_` and downgrades
* double-line box drawing (`═║╔╗`) to DEC Special Graphics single lines — so all
* CJK output in the Claude Code TUI is destroyed before it ever reaches xterm.
*/
describe('withUtf8Locale', () => {
it('adds LANG when no locale variable is set at all', () => {
const out = withUtf8Locale({ PATH: '/usr/bin' });
expect(out.LANG).toBe(DEFAULT_UTF8_LOCALE);
expect(out.PATH).toBe('/usr/bin');
});
it('keeps a UTF-8 LANG the user already has', () => {
const out = withUtf8Locale({ LANG: 'zh_CN.UTF-8' });
expect(out.LANG).toBe('zh_CN.UTF-8');
});
it('accepts lowercase / hyphen-less spellings of utf8', () => {
expect(withUtf8Locale({ LANG: 'en_US.utf8' }).LANG).toBe('en_US.utf8');
expect(withUtf8Locale({ LANG: 'C.UTF8' }).LANG).toBe('C.UTF8');
});
it('replaces a non-UTF-8 LANG (C / POSIX / latin1) with the default', () => {
expect(withUtf8Locale({ LANG: 'C' }).LANG).toBe(DEFAULT_UTF8_LOCALE);
expect(withUtf8Locale({ LANG: 'POSIX' }).LANG).toBe(DEFAULT_UTF8_LOCALE);
expect(withUtf8Locale({ LANG: 'en_US.ISO8859-1' }).LANG).toBe(DEFAULT_UTF8_LOCALE);
});
it('respects LC_ALL — it outranks LANG, so a UTF-8 LC_ALL is enough', () => {
const out = withUtf8Locale({ LC_ALL: 'ja_JP.UTF-8', LANG: 'C' });
expect(out.LC_ALL).toBe('ja_JP.UTF-8');
expect(out.LANG).toBe('C'); // untouched — LC_ALL already decides the charset
});
it('respects a UTF-8 LC_CTYPE (macOS Terminal.app sets only this)', () => {
const out = withUtf8Locale({ LC_CTYPE: 'UTF-8' });
expect(out.LC_CTYPE).toBe('UTF-8');
expect(out.LANG).toBeUndefined();
});
it('overrides LANG when LC_CTYPE is present but not UTF-8', () => {
const out = withUtf8Locale({ LC_CTYPE: 'C' });
expect(out.LANG).toBe(DEFAULT_UTF8_LOCALE);
});
it('does not mutate the input env (immutability)', () => {
const input = { PATH: '/usr/bin' };
const out = withUtf8Locale(input);
expect(input).toEqual({ PATH: '/usr/bin' });
expect(out).not.toBe(input);
});
it('ignores an empty-string locale (unset in practice)', () => {
expect(withUtf8Locale({ LANG: '' }).LANG).toBe(DEFAULT_UTF8_LOCALE);
expect(withUtf8Locale({ LC_ALL: '' }).LANG).toBe(DEFAULT_UTF8_LOCALE);
});
});