Files
web-terminal/deploy

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 (the why, risk register, milestones).
  • Per-host onboarding (repeatable): 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://<name>.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.wang8.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)

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

# 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 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:
    CA_DIR=/etc/relay/frp-client-ca bash deploy/scripts/issue-device-cert.sh <hostname> ./out-<hostname>
    # frpc uses out-<hostname>/<hostname>.{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)

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

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

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 into the existing map body — do not retype the existing *.term→8443 / default→10443 lines:

    frp.terminal.yaojia.wang  127.0.0.1:7000;   # exact wins over wildcard
    *.terminal.yaojia.wang    127.0.0.1:8470;
nginx -t && systemctl reload nginx

4. Deploy — hosts and clients

  • Per local host (S0/S1): follow frp/README.md — base-app env (BIND_HOST=127.0.0.1, ALLOWED_ORIGINS=https://<name>.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 <device>.p12 (AirDrop/Files) → app → Settings → 设备证书 → Import + passphrase → pair https://<name>.terminal.yaojia.wang. (Cert takes effect on the next connect, no relaunch.)
  • Desktop: double-click <device>.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):

P12_PASSWORD='...' bash deploy/scripts/issue-device-cert.sh <person-or-device> ./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://<name>.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-sessions200 JSON.
  3. WS attachattached 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):

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 2>/dev/null \
    | openssl x509 -noout -subject 2>/dev/null || echo "(passthrough / no cert at :443)"
done

6. Operational notes

  • Revoke a device:
    CA_DIR=/etc/relay/device-ca bash deploy/scripts/revoke-device.sh /path/<dev>.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)