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

2.1 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)

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).