# Native mTLS Reverse-Tunnel — Deployment Guide Master runbook for exposing native iOS / Android / desktop clients to a **local** web-terminal host through the user's VPS (`8.138.1.192`), gated **only** by device-certificate mTLS. - **Design / rationale:** [`docs/PLAN_NATIVE_TUNNEL.md`](../docs/PLAN_NATIVE_TUNNEL.md) (the *why*, risk register, milestones). - **Per-host onboarding (repeatable):** [`frp/README.md`](./frp/README.md) (V7 checklist). - **This file:** what shipped, and the **one-time VPS deploy** end-to-end. > **Trust model A — single-owner fleet.** Every device cert and host belongs to one operator. The > 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. --- ## 1. Architecture Everything multiplexes onto the single open port `:443`, SNI-routed by the existing nginx `stream` (`ssl_preread`, TLS **passthrough**), coexisting untouched with the browser relay and xray-Reality. ``` Public internet ── :443 (only open port) ──► nginx stream ssl_preread (no TLS term) map $ssl_preread_server_name: frp.terminal.yaojia.wang → 127.0.0.1:7000 frps control (mTLS + token) *.terminal.yaojia.wang → 127.0.0.1:8470 nginx TLS-term + device-CA mTLS + CRL ─► :7080 frps vhost *.term.yaojia.wang → 127.0.0.1:8443 E2E browser relay (EXISTING, preserved) default → 127.0.0.1:10443 xray Reality (EXISTING, preserved) frps vhost :7080 ──(frp tunnel; frpc dials OUT :443)──► LOCAL host: frpc → base app 127.0.0.1:3000 BIND_HOST=127.0.0.1 (loopback ONLY) native client (iOS/Android/desktop) presents a DEVICE CERT → https://.terminal.yaojia.wang ``` **Why mTLS is non-negotiable:** the base app has no login of its own. The device client-cert is the one and only gate. Two invariants make it airtight: `ssl_verify_client on` (never `optional`) at `:8470`, and `BIND_HOST=127.0.0.1` on every host (default `0.0.0.0` would serve an unauth'd shell on the LAN, bypassing mTLS entirely). --- ## 2. What shipped — 2026-07-07 (branch `feat/relay-phase1`) Four file-disjoint tracks implemented via multi-agent build→review, then two HIGH review findings closed. **Zero `src/` (base-app core) changes** — a working tunnel needs none. All self-verified green. | Track | Commit | What | Verified | |---|---|---|---| | **VPS scripts** (V2/V7) | `5337281` | device CA + issue + revoke scripts, frps/frpc templates, nginx `:8470` mTLS conf, SNI-merge doc | `gen→issue→openssl verify→revoke→CRL` green in tmp dir (OpenSSL 3.0.18) | | **iOS client mTLS** (C-iOS) | `e38e6d1` | `ClientTLS` SPM package (identity / PKCS12 import / keychain / challenge-responder), both transports take a lazy identity provider (no relaunch after install), `设备证书` install screen | ClientTLS 14/14 · SessionCore 93/93 · app `BUILD SUCCEEDED` | | **Desktop** (D-1/D-2) | `bb09495` | remote-host mode (origin-locked nav) + `select-client-certificate` from OS keychain | `npm run typecheck` clean | | **Host packaging** (S2) | `d0c249c` | launchd/systemd env injection wired through the `install` CLI; `BIND_HOST` defaults `127.0.0.1`; systemd control-char rejection | agent build clean · vitest 166/166 | Adversarial review: Desktop + VPS scripts passed clean; the iOS "install screen unreachable" and "S2 env-injection not wired to the CLI" HIGH findings were both closed and re-verified. **Not yet done:** the **live VPS deploy** below (touches production nginx/xray/relay — done serially by an operator, not by an agent). The clients cannot reach a tunneled host until §3 is complete. --- ## 3. Deploy — one-time VPS setup (V0 → V5) **Prerequisites** - DNS: `*.terminal.yaojia.wang` and `frp.terminal.yaojia.wang` → `8.138.1.192` (confirmed resolving). - Aliyun DNS API creds for Let's Encrypt DNS-01: `Ali_Key` / `Ali_Secret`. - `frps` binary ≥ v0.52 (needs `disableCustomTLSFirstByte`, ≥ v0.50). Mainland fetch may be blocked → `scp` from a laptop. - `acme.sh` installed on the VPS. - **Coexistence rule (every step):** never retype the existing `map` lines; merge additively; deploy only via `nginx -t && systemctl reload nginx`; re-run the SNI oracle (§5) after each nginx change. > Repo scripts referenced below are run **from the repo root**. Push the repo to the VPS first > (`git push root@8.138.1.192:/root/web-terminal.git feat/relay-phase1`) or `scp` the `deploy/` tree. ### V0 — snapshot (reversible safety net) ```bash cp /etc/nginx/stream-relay.conf{,.bak.$(date +%s)} nginx -T > /root/nginx-dump.pre.txt ss -ltnp | sort > /root/ports.pre.txt nginx -v # expect 1.24 → do NOT add `http2 off;` (invalid on 1.24) dig +short foo.terminal.yaojia.wang frp.terminal.yaojia.wang # both → 8.138.1.192 ``` ### V1 — frps: control `:7000` (mTLS) + vhost `:7080` ```bash # dedicated unprivileged user (loopback ports need no root) useradd -r -s /usr/sbin/nologin frp 2>/dev/null || true install -d -o frp -g frp -m 750 /etc/relay/frp /var/log/frp # config from the template — set the token, keep everything else install -m 600 -o frp -g frp deploy/frp/frps.toml.example /etc/relay/frp/frps.toml openssl rand -hex 32 >> /etc/relay/secrets.env # FRP_TOKEN — paste into frps.toml auth.token # frps.toml already sets: bindAddr 127.0.0.1 / bindPort 7000 / vhostHTTPPort 7080 / # subDomainHost terminal.yaojia.wang / transport.tls.force = true (control-channel mTLS) # systemd unit: NoNewPrivileges / ProtectSystem=strict / ReadWritePaths=/var/log/frp, User=frp systemctl enable --now frps journalctl -u frps -n30 --no-pager # clean start curl -s -H 'Host: probe.terminal.yaojia.wang' http://127.0.0.1:7080/ # → frp 404 "no proxy" = vhost up ``` ### V1b + V2 — the two CAs (control-channel + device data-path) Separate trust roots from Let's Encrypt and from the agent-enrollment CA. ```bash bash deploy/scripts/gen-device-ca.sh --kind frp-client # → /etc/relay/frp-client-ca (frpc control mTLS) bash deploy/scripts/gen-device-ca.sh # → /etc/relay/device-ca (KIND=device) + EMPTY crl.pem ``` - `frps.toml transport.tls.trustedCaFile` → `/etc/relay/frp-client-ca/frp-client-ca.cert.pem`. - Issue the frps control **server** cert + each host's frp-client cert (V1b) from the frp-client CA: ```bash CA_DIR=/etc/relay/frp-client-ca bash deploy/scripts/issue-device-cert.sh ./out- # frpc uses out-/.{cert,key}.pem — ignore the .p12 for control mTLS ``` - **Effect:** a leaked `auth.token` alone can no longer register a subdomain (needs a valid frp-client cert too) — closes the subdomain-takeover / MITM hole (R3). ### V3 — Let's Encrypt wildcard server cert (hard iOS prerequisite — self-signed fails iOS ATS) ```bash export Ali_Key=... Ali_Secret=... acme.sh --issue --dns dns_ali -d '*.terminal.yaojia.wang' -d terminal.yaojia.wang install -d -m 700 /etc/relay/frp-tls acme.sh --install-cert -d '*.terminal.yaojia.wang' \ --fullchain-file /etc/relay/frp-tls/fullchain.pem \ --key-file /etc/relay/frp-tls/privkey.pem \ --reloadcmd 'nginx -s reload' openssl x509 -in /etc/relay/frp-tls/fullchain.pem -noout -text | grep -A1 'Subject Alternative Name' # → *.terminal.yaojia.wang (separate cert from *.term — different parent label; don't try to cover both) ``` ### V4 — nginx `:8470` = TLS-term + device-CA mTLS + CRL + WS proxy ```bash cp deploy/nginx/frp-mtls.conf /etc/nginx/conf.d/frp-mtls.conf nginx -t # frp-mtls.conf enforces: ssl_verify_client ON (never optional), ssl_client_certificate = device-CA, # ssl_crl = device-ca/crl.pem (revocation day one), WS upgrade, proxy_read/send_timeout 3600s # (idle-WS kill fix), proxy_pass 127.0.0.1:7080. NO `http2 off;`. ``` **Loopback verify before touching the stream** (issue a throwaway device cert with V2's issue script): ```bash curl -sI --resolve x.terminal.yaojia.wang:8470:127.0.0.1 https://x.terminal.yaojia.wang:8470/ # no cert → TLS handshake FAILURE (must fail) curl -sI --cert dev.cert.pem --key dev.key.pem ... # → frp 404 (reaches frps) = gate works ``` ### V5 — stream SNI additions (the only edit to the existing shared conf) Edit `/etc/nginx/stream-relay.conf`, **merge** the two lines from [`nginx/stream-sni-additions.md`](./nginx/stream-sni-additions.md) into the existing `map` body — do **not** retype the existing `*.term`→8443 / `default`→10443 lines: ```nginx frp.terminal.yaojia.wang 127.0.0.1:7000; # exact wins over wildcard *.terminal.yaojia.wang 127.0.0.1:8470; ``` ```bash nginx -t && systemctl reload nginx ``` --- ## 4. Deploy — hosts and clients - **Per local host (S0/S1):** follow [`frp/README.md`](./frp/README.md) — base-app env (`BIND_HOST=127.0.0.1`, `ALLOWED_ORIGINS=https://.terminal.yaojia.wang`, `PORT=3000`), `frpc.toml` from `frp/frpc.example.toml`, and a per-host frp-client cert. Durable service packaging (S2) via the agent `install` CLI now injects that env into launchd/systemd units. - **iOS:** deliver `.p12` (AirDrop/Files) → app → Settings → **设备证书** → Import + passphrase → pair `https://.terminal.yaojia.wang`. (Cert takes effect on the next connect, no relaunch.) - **Desktop:** double-click `.p12` into the login keychain (macOS) / Personal store (Windows); the app auto-selects it by issuer CN. Switch to the remote host in the tray picker. - **Android:** WebView shell — not yet built (Track C-Android, deferred). Issue a device cert (data-path mTLS) and deliver it **securely** (never email): ```bash P12_PASSWORD='...' bash deploy/scripts/issue-device-cert.sh ./out-dev # → out-dev/<...>.p12 (iOS/Android) + <...>.cert.pem/.key.pem/.fullchain.pem (desktop/curl) ``` --- ## 5. Verify — S-GATE + M1 acceptance **S-GATE (BLOCKING — before any `frpc` starts on any host):** with **no** client cert, `curl -sI https://.terminal.yaojia.wang/live-sessions` must be a **TLS failure**. If it returns `200`, **STOP — the shell is world-open.** **M1 acceptance matrix** (`t1.terminal.yaojia.wang`, base app `BIND_HOST=127.0.0.1`, throwaway frpc): 1. no device cert → TLS handshake failure. 2. `curl --cert dev.cert.pem --key dev.key.pem -sI …/live-sessions` → `200` JSON. 3. WS `attach`→`attached` on `wss://t1.terminal.yaojia.wang/term`. 4. 2nd frpc with the token but no/other frp-client cert claiming `t1` → refused at control handshake. 5. revoked device cert (added to CRL) → rejected. 6. first byte to `:443` is `0x16` (`tcpdump` — proves `disableCustomTLSFirstByte`). 7. 4-zone SNI oracle green + V0 coexistence preserved; `journalctl -u xray -u nginx` clean. **SNI oracle** (run after every nginx change — proves coexistence): ```bash for sni in frp.terminal.yaojia.wang t1.terminal.yaojia.wang x.term.yaojia.wang www.microsoft.com; do echo "== $sni =="; openssl s_client -connect 8.138.1.192:443 -servername "$sni" /dev/null \ | openssl x509 -noout -subject 2>/dev/null || echo "(passthrough / no cert at :443)" done ``` --- ## 6. Operational notes - **Revoke a device:** ```bash CA_DIR=/etc/relay/device-ca bash deploy/scripts/revoke-device.sh /path/.cert.pem # copy the regenerated /etc/relay/device-ca/crl.pem to the VPS, then ON THE VPS: nginx -t && nginx -s reload ``` - **Never** port-forward this to the public internet beyond `:443` mTLS; frps `:7000`/`:7080` and nginx `:8470` are **loopback-only** and must stay absent from the Aliyun security group (`nmap` them closed). - **`/live-sessions/:id/preview` leaks scrollback** to any valid cert-holder (no Origin guard). Accepted under Model A (all certs are yours); it sets Model B's blast radius. - **SSH to the mainland VPS is intermittently DPI-throttled** (banner-exchange timeout while bare TCP `:22` still connects — the box and key are fine, the SSH *protocol path* is throttled). Workarounds: retry later; route SSH through an overseas proxy node (e.g. flip ClashX to **Global** mode so the CN IP isn't matched by a `GEOIP,CN → DIRECT` rule, then `ssh -o ProxyCommand="nc -X 5 -x 127.0.0.1:7890 %h %p"`); or use the Aliyun web console. The `git push` remote (`root@8.138.1.192:/root/web-terminal.git`) uses the same `:22` path and is affected identically. ## 7. File map (this directory) | Path | Role | |---|---| | `scripts/gen-device-ca.sh` | EC P-256 CA (`--kind device` \| `frp-client`), openssl `ca` db, empty CRL | | `scripts/issue-device-cert.sh` | P-256 clientAuth leaf → `.p12` (`-legacy`) + `.pem/.key/.fullchain` | | `scripts/revoke-device.sh` | revoke by cert or `--serial` → regenerate `crl.pem` | | `frp/frps.toml.example` | frps control `:7000` (mTLS+token) + vhost `:7080` | | `frp/frpc.example.toml` | per-host: dials `:443`, `disableCustomTLSFirstByte=true`, exposes `127.0.0.1:3000` | | `frp/README.md` | per-host onboarding checklist (V7) | | `nginx/frp-mtls.conf` | `:8470` TLS-term + `ssl_verify_client on` + `ssl_crl` + WS proxy → `:7080` | | `nginx/stream-sni-additions.md` | the two additive stream SNI `map` lines (merge-only) |