diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..005f1a0 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,233 @@ +# 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) |