Files
web-terminal/deploy/nginx/stream-sni-additions.md
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

2.7 KiB

Stream SNI additions (Native mTLS tunnel · V5)

Two SNI routes must be merged into the existing stream { map $ssl_preread_server_name … } block on the VPS (/etc/nginx/stream-relay.conf). They add the frp control channel and the *.terminal data path to the single shared :443 ssl_preread listener.

This is a MERGE, not a file to ship. Do not drop a full stream conf here — that would clobber the live one. Only the two map body lines below are new.

The two additive lines

Add these inside the existing map body (exact match wins over wildcard, so order is not load-bearing, but keep the exact frp.terminal line above the *.terminal wildcard for clarity):

frp.terminal.yaojia.wang  127.0.0.1:7000;   # frps control channel (TLS passthrough)
*.terminal.yaojia.wang    127.0.0.1:8470;   # nginx :8470 TLS-term + device-CA mTLS (frp-mtls.conf)

Later addition — expired-leaf recovery (recover-mtls.conf)

recover.terminal.yaojia.wang  127.0.0.1:8472;   # expired-leaf re-issue (recover-mtls.conf)

Order IS load-bearing for this one: it must sit ABOVE the *.terminal.yaojia.wang wildcard, the same way enroll.terminal.yaojia.wang does. hostnames gives exact matches priority over wildcards in nginx's map, so a correctly-placed line wins regardless — but keep the exact-match lines grouped above the wildcard so a future editor cannot mis-read the intent. No DNS work is needed: the zone is already served by the wildcard record *.terminal.yaojia.wang → <vps>.

Coexistence warning (do NOT retype the existing lines)

The existing map body already contains — and MUST be preserved verbatim:

# ... existing hostnames directive ...
*.term.yaojia.wang   127.0.0.1:8443;    # E2E browser relay  — DO NOT RETYPE / REORDER
default              127.0.0.1:10443;   # xray Reality        — DO NOT RETYPE / REORDER
  • *.term.yaojia.wang (browser relay) and *.terminal.yaojia.wang (this tunnel) are different parent labelsterm vs terminal. ssl_preread matches the full SNI, so they never collide.
  • Re-type nothing you did not author. Capture the current body first (nginx -T | sed -n '/map \$ssl_preread_server_name/,/}/p'), then append only the two lines above.

Deploy gate

cp /etc/nginx/stream-relay.conf{,.bak.$(date +%s)}   # snapshot first
# ...edit: append the two lines into the map body...
nginx -t && systemctl reload nginx                   # NEVER reload on a failed -t

After reload, run the 4-zone SNI oracle (openssl s_client -servername … for frp.terminal / <name>.terminal / <name>.term / default) to confirm all four routes still resolve to the right backend (PLAN V5 / M1 #7).