# 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://<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.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 <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)
```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://<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):
```bash
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-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 2>/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/<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) |
