feat(deploy): device client-CA + issuance/revocation + frp/nginx mTLS templates (V2/V7)

Committable VPS artifacts for the native reverse-tunnel (no live-ops, no secrets):
- gen-device-ca.sh: EC P-256 self-signed device CA (CA:TRUE pathlen:0, keyCertSign+cRLSign),
  openssl ca db + empty CRL, idempotent, --kind frp-client scaffolds the control-channel CA.
- issue-device-cert.sh: P-256 leaf, EKU=clientAuth only, 825d, emits .p12 (-legacy) + .pem/.key/.fullchain,
  copy_extensions=none prevents CSR-injected serverAuth/CA:TRUE; CN traversal guard.
- revoke-device.sh: openssl ca revoke -> regenerate crl.pem (revocation enforced via -crl_check).
- frp/{frps.toml.example,frpc.example.toml}: control-channel mTLS+token, disableCustomTLSFirstByte=true.
- nginx/frp-mtls.conf: :8470 TLS-term, ssl_verify_client on + device-CA + ssl_crl, WS-upgrade proxy to :7080.
- nginx/stream-sni-additions.md: the two additive SNI map lines (merge-only, coexistence-safe).
Verified: gen->issue->openssl verify->revoke->CRL chain green in a tmp dir (OpenSSL 3.0.18).
This commit is contained in:
Yaojia Wang
2026-07-07 09:42:12 +02:00
parent 6b8269c1c1
commit 5337281e85
8 changed files with 573 additions and 0 deletions

73
deploy/frp/README.md Normal file
View File

@@ -0,0 +1,73 @@
# Native mTLS Tunnel — per-host runbook (V7)
Bring one local host onto the tunnel so native clients (iOS / Android / desktop) can reach its base
app at `https://<name>.terminal.yaojia.wang` over the VPS, gated **only** by device-certificate mTLS.
See `docs/PLAN_NATIVE_TUNNEL.md` for the full design; this is the short operational checklist.
**Trust model: A — single-owner fleet** (all devices/hosts are the operator's). A shared device-CA
means "the closed set of my devices"; there is **no cross-tenant isolation** — do not enroll
distrusting third parties on this CA (that is Model B, out of scope for v1).
## Files here
| File | Install target on the VPS | Purpose |
|---|---|---|
| `frps.toml.example` | `/etc/relay/frp/frps.toml` (0600) | frps control `:7000` + vhost `:7080` |
| `frpc.example.toml` | on each LOCAL host (0600) | dials the VPS, exposes `127.0.0.1:3000` |
| `../nginx/frp-mtls.conf` | `/etc/nginx/conf.d/frp-mtls.conf` | `:8470` TLS-term + device-CA mTLS + WS proxy |
| `../nginx/stream-sni-additions.md` | (merge into the live stream map) | the two additive SNI routes |
## One-time VPS setup (summarised — see PLAN V1V5)
1. **frps** (`frps.toml.example``/etc/relay/frp/frps.toml`): set `auth.token` = `openssl rand -hex 32`.
2. **frp-client CA** (control-channel mTLS): `bash deploy/scripts/gen-device-ca.sh --kind frp-client`.
3. **device CA** (data-path mTLS): `bash deploy/scripts/gen-device-ca.sh` (KIND=device default).
4. **LE wildcard** `*.terminal.yaojia.wang` (V3) → `/etc/relay/frp-tls/{fullchain,privkey}.pem`.
5. **nginx** `:8470` (`frp-mtls.conf`) + the two stream SNI lines (`stream-sni-additions.md`), then
`nginx -t && systemctl reload nginx`.
## Onboard a host (the repeatable part)
1. **frp-client cert** for this host (control mTLS), issued from the frp-client CA:
```bash
CA_DIR=/etc/relay/frp-client-ca bash deploy/scripts/issue-device-cert.sh <name> ./out-<name>
# use out-<name>/<name>.cert.pem + <name>.key.pem for frpc.certFile/keyFile (ignore the .p12)
```
2. **frpc.toml** on the host from `frpc.example.toml`: set `subdomain=<name>`, the shared
`auth.token`, and the frp-client cert/key paths; `chmod 600 frpc.toml`.
3. **Base app** on the host — the tunnel is safe ONLY with these:
| Var | Value | Why |
|---|---|---|
| `BIND_HOST` | `127.0.0.1` | **Mandatory.** Default `0.0.0.0` exposes an unauth'd shell on the LAN, bypassing mTLS. |
| `PORT` | `3000` | frpc dials `127.0.0.1:3000`. |
| `ALLOWED_ORIGINS` | `https://<name>.terminal.yaojia.wang` | bare host; `:443` also matches (normalized). |
| `IDLE_TTL` | `≥ 86400` | walk-away survival. |
| `USE_TMUX` | `1` (*nix) / `0` (Windows) | cross-restart PTY survival. |
4. **Device cert** for whoever will connect (data-path mTLS), issued from the device CA:
```bash
P12_PASSWORD='...' bash deploy/scripts/issue-device-cert.sh <person-or-device> ./out-dev
# deliver out-dev/<...>.p12 SECURELY: scp / AirDrop / Files — NEVER email.
```
5. **Verify** (M1): no-cert → TLS handshake failure; `curl --cert …/<dev>.cert.pem --key …/<dev>.key.pem
-sI https://<name>.terminal.yaojia.wang/live-sessions` → `200`.
## Revoke a device
```bash
CA_DIR=/etc/relay/device-ca bash deploy/scripts/revoke-device.sh /path/to/<dev>.cert.pem
# copy the regenerated /etc/relay/device-ca/crl.pem to the VPS, then ON THE VPS:
nginx -t && nginx -s reload
```
The revoked cert is rejected fleet-wide the moment nginx reloads the CRL.
## Trust-boundary note (Model A)
- The **frp-client cert + token is a trusted secret**: leaking a host's frp-client cert **and** the
shared token lets an attacker register a subdomain (subdomain-takeover / MITM). Keep both `chmod 600`;
deliver over scp, never email. Revoke a compromised host by rotating the `frp-client-ca` trust
(re-issue the remaining hosts) — device certs are unaffected.
- The **device cert** is the data-path gate. Under Model A any valid device cert can reach any
subdomain and can read `/live-sessions/:id/preview` scrollback — acceptable because every device is
yours. Do not extend this CA to third parties without moving to Model B (per-tenant CA).
- **P-256, never Ed25519** for both CAs (nginx `ssl_verify_client` + iOS client-cert compatibility).

View File

@@ -0,0 +1,31 @@
# frpc.toml — per-host client (Native mTLS tunnel · V7 / M1 / S1).
#
# Copy to the LOCAL host that runs the base app, fill the <PLACEHOLDERS>, chmod 600.
# TEMPLATE ONLY — NO REAL SECRETS committed. frpc dials OUT to the VPS :443; nothing is opened
# inbound on the local host.
#
# frps ≥ v0.52 required (`disableCustomTLSFirstByte` exists ≥ v0.50) — it makes frpc send a real TLS
# ClientHello first byte (0x16) so the VPS nginx `ssl_preread` stream can SNI-route it (PLAN M1 #6).
serverAddr = "8.138.1.192"
serverPort = 443
auth.method = "token"
auth.token = "<FRP_TOKEN>" # same token as frps.toml (/etc/relay/secrets.env)
# --- control-channel TLS: present THIS host's frp-client cert to frps; verify the frps server cert ---
transport.tls.enable = true
transport.tls.serverName = "frp.terminal.yaojia.wang" # SNI the stream routes to frps :7000
transport.tls.disableCustomTLSFirstByte = true # CRITICAL for ssl_preread (real ClientHello first)
transport.tls.certFile = "<host-frp-client.cert.pem>" # issued by gen-device-ca.sh --kind frp-client + issue-device-cert.sh
transport.tls.keyFile = "<host-frp-client.key.pem>" # (0600)
transport.tls.trustedCaFile = "<frps-ctrl-ca.cert.pem>" # CA that signed frps-ctrl.cert.pem (V1)
loginFailExit = false # keep retrying if the VPS/frps is briefly down
[[proxies]]
name = "<name>" # unique per host; frps rejects duplicate subdomains
type = "http"
localIP = "127.0.0.1" # base app must bind loopback (BIND_HOST=127.0.0.1)
localPort = 3000
subdomain = "<name>" # reachable at https://<name>.terminal.yaojia.wang

View File

@@ -0,0 +1,29 @@
# frps.toml — VPS control server (Native mTLS tunnel · V1).
#
# Install target on the VPS: /etc/relay/frp/frps.toml (chmod 600, owned frp:frp).
# This is a TEMPLATE with PLACEHOLDERS ONLY — NO REAL SECRETS are committed.
#
# auth.token: replace <FRP_TOKEN> with `openssl rand -hex 32`, stored in /etc/relay/secrets.env
# (0600). The token is NOT the sole gate — control-channel mTLS below is required too,
# so a leaked token alone cannot register a subdomain (PLAN R3 / V1b, Model A).
#
# Both ports are LOOPBACK-only (nginx stream terminates/routes :443 → these; they must stay absent
# from the Aliyun security group — PLAN P-B).
bindAddr = "127.0.0.1"
bindPort = 7000 # control channel (SNI frp.terminal.yaojia.wang → here, TLS passthrough)
vhostHTTPPort = 7080 # vhost HTTP (nginx :8470 proxies verified requests here by Host)
auth.method = "token"
auth.token = "<FRP_TOKEN>"
subDomainHost = "terminal.yaojia.wang"
# --- control-channel mTLS: token is NOT the sole gate (per-host frp-client CA) ---
transport.tls.force = true
transport.tls.certFile = "/etc/relay/frp-tls/frps-ctrl.cert.pem" # frps control SERVER cert (V1)
transport.tls.keyFile = "/etc/relay/frp-tls/frps-ctrl.key.pem" # (0600)
transport.tls.trustedCaFile = "/etc/relay/frp-client-ca/frp-client-ca.cert.pem" # verifies each host's frpc cert
log.to = "/var/log/frp/frps.log"
log.level = "info"