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.
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
mapbody 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 labels —termvsterminal.ssl_prereadmatches 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).