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.wangandfrp.terminal.yaojia.wang→8.138.1.192(confirmed resolving). - Aliyun DNS API creds for Let's Encrypt DNS-01:
Ali_Key/Ali_Secret. frpsbinary ≥ v0.52 (needsdisableCustomTLSFirstByte, ≥ v0.50). Mainland fetch may be blocked →scpfrom a laptop.acme.shinstalled on the VPS.- Coexistence rule (every step): never retype the existing
maplines; merge additively; deploy only vianginx -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) orscpthedeploy/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.tokenalone 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.tomlfromfrp/frpc.example.toml, and a per-host frp-client cert. Durable service packaging (S2) via the agentinstallCLI now injects that env into launchd/systemd units. - iOS: deliver
<device>.p12(AirDrop/Files) → app → Settings → 设备证书 → Import + passphrase → pairhttps://<name>.terminal.yaojia.wang. (Cert takes effect on the next connect, no relaunch.) - Desktop: double-click
<device>.p12into 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):
- no device cert → TLS handshake failure.
curl --cert dev.cert.pem --key dev.key.pem -sI …/live-sessions→200JSON.- WS
attach→attachedonwss://t1.terminal.yaojia.wang/term. - 2nd frpc with the token but no/other frp-client cert claiming
t1→ refused at control handshake. - revoked device cert (added to CRL) → rejected.
- first byte to
:443is0x16(tcpdump— provesdisableCustomTLSFirstByte). - 4-zone SNI oracle green + V0 coexistence preserved;
journalctl -u xray -u nginxclean.
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
:443mTLS; frps:7000/:7080and nginx:8470are loopback-only and must stay absent from the Aliyun security group (nmapthem closed). /live-sessions/:id/previewleaks 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
:22still 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 aGEOIP,CN → DIRECTrule, thenssh -o ProxyCommand="nc -X 5 -x 127.0.0.1:7890 %h %p"); or use the Aliyun web console. Thegit pushremote (root@8.138.1.192:/root/web-terminal.git) uses the same:22path 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) |