# Native mTLS Reverse-Tunnel — Master Development Plan

> **Destination:** `docs/PLAN_NATIVE_TUNNEL.md`
> **Status:** planning (synthesized from three reviewed track-plans: VPS, Server, Clients)
> **Deployed baseline:** VPS `8.138.1.192` (Ubuntu 24.04, nginx 1.24, `:443` shared via `ssl_preread` stream — existing `*.term.yaojia.wang`→relay:8443 and `default`→xray:10443 preserved verbatim). Wildcard DNS `*.terminal.yaojia.wang → 8.138.1.192` is confirmed resolving.

## 1. Overview

We are building a **multi-tenant reverse tunnel** so native iOS / Android / desktop clients can reach a **base web-terminal server** running on the user's *local* machine(s) from the public internet, relayed through the user's VPS. The base app speaks its own WS protocol (`attach`/`attached`, `/live-sessions`) — **not** the E2E relay — so the already-deployed browser relay cannot serve these clients; this is a parallel, independent ingress.

The tool is **frp**. Each local host runs `frpc`, which dials the VPS on `:443` and exposes its loopback base app (`127.0.0.1:3000`) as `<name>.terminal.yaojia.wang`. On the VPS everything multiplexes onto the single open port `:443`, SNI-routed by the existing nginx `stream` (`ssl_preread`, TLS passthrough), coexisting with the relay and xray-Reality.

**The base app has no login/auth of its own.** Therefore **mTLS client-certificate authentication is the one and only security gate**, and it must be airtight. Two SNI routes are added:

- **`frp.terminal.yaojia.wang` → `frps` control `:7000`** (TLS passthrough). Guarded by frps **control-channel mTLS** (a per-host *frp-client CA*) **plus** a token — a leaked token alone must not let an attacker register a subdomain.
- **`*.terminal.yaojia.wang` → nginx `:8470`** — a loopback TLS-terminating server that presents a publicly-trusted **LE wildcard cert**, enforces **`ssl_verify_client on`** against a **device CA** (with a CRL), then reverse-proxies (WS-upgrade) to the frps vhost `:7080` → `frpc` → local base app.

```
                          Public internet  (only :443 open in Aliyun SG)
                                     │
                                     ▼
                      ┌───────────────────────────────────┐
                      │ nginx stream  :443  ssl_preread    │  (TLS passthrough, no term)
                      │ map $ssl_preread_server_name →     │
                      └───────────────────────────────────┘
             ┌───────────────┬───────────────────┬────────────────┬─────────────┐
             │ frp.terminal  │ *.terminal        │ *.term         │ default     │
             │ .yaojia.wang  │ .yaojia.wang      │ .yaojia.wang   │ (fallthru)  │
             ▼               ▼                   ▼                ▼
      127.0.0.1:7000   127.0.0.1:8470      127.0.0.1:8443   127.0.0.1:10443
      ┌──────────┐     ┌───────────────┐   ┌──────────┐     ┌──────────┐
      │  frps    │     │ nginx :8470   │   │ E2E relay│     │  xray    │  ← EXISTING,
      │ control  │     │ TLS-term +    │   │ (browser)│     │ Reality  │    preserved
      │ mTLS+tok │     │ mTLS device-CA│   └──────────┘     └──────────┘    verbatim
      │  :7000   │     │ + CRL         │
      └────┬─────┘     │ proxy→ :7080  │
   control │           └──────┬────────┘
   channel │                  │ vhost HTTP (WS upgrade)
           │                  ▼
           │           ┌──────────────┐
           └──────────►│ frps vhost   │  routes by Host → subdomain
                       │   :7080      │
                       └──────┬───────┘
                              │  frp tunnel (frpc dials OUT :443, frp-TLS)
                              ▼
          ══════════════ user's LOCAL machine ══════════════
                       ┌──────────────┐
                       │    frpc      │  subdomain=<name>, localPort=3000
                       └──────┬───────┘
                              ▼
                       ┌──────────────┐
                       │  base app    │  BIND_HOST=127.0.0.1 :3000  (loopback ONLY)
                       │ ALLOWED_ORIGINS = https://<name>.terminal.yaojia.wang
                       └──────────────┘
                              ▲
        native client ────────┘   iOS / Android / desktop present a DEVICE CERT
        https://<name>.terminal.yaojia.wang   (mTLS) → speak base WS /term + /live-sessions
```

**Trust model (declare in writing before shipping — this changes what "airtight" means):**

- **Model A — single-owner fleet** (matches the GOAL: "the user's LOCAL machine(s)"). Every device cert and host belongs to one operator. A single shared device-CA = "the closed set of my devices"; cross-host reach between your own machines is not a violation. **This is the v1 target.**
- **Model B — true multi-tenant** (distrusting third parties enroll). A single shared device-CA gives **zero cross-tenant isolation** (any valid cert reaches any subdomain). Model B is **out of scope for v1** and must not ship on the shared-CA design; §5-R3 records the mitigation path.

---

## 2. The three tracks

### Track V — VPS (`8.138.1.192`)

Coexistence rule for every phase: never retype the existing `map` lines; merge additively; deploy only via `nginx -t && systemctl reload nginx`; re-run the coexistence oracle after each phase.

**V0 — Preflight & snapshot (~20 min)**
- [ ] `cp /etc/nginx/stream-relay.conf{,.bak.$(date +%s)}`; `nginx -T > /root/nginx-dump.pre.txt`; `ss -ltnp | sort > /root/ports.pre.txt`.
- [ ] Capture the **exact** current `map` body (`hostnames; *.term.yaojia.wang→8443; default→10443`) and `nginx -v` (expect 1.24 → no standalone `http2` directive is legal).
- [ ] `dig +short {foo,frp}.terminal.yaojia.wang` → `8.138.1.192`.
- [ ] Capture "before" oracle: relay 200 + xray real-MS cert (`s_client`/`curl` probes).
- **Verify:** all reads succeed, `.bak` exists. **Risk:** none.

**V1 — frps: control `:7000` (mTLS) + vhost `:7080`, dedicated user, systemd (~1h)**
- [ ] Install `frps` ≥ v0.52 (`disableCustomTLSFirstByte` exists ≥ v0.50). Mainland fetch may be blocked → `scp` from laptop.
- [ ] Dedicated unprivileged `frp` user (loopback ports need no root) + systemd hardening (`NoNewPrivileges`, `ProtectSystem=strict`, `ReadWritePaths=/var/log/frp`).
- [ ] `/etc/relay/frp/frps.toml` (0600, `frp:frp`):
  ```toml
  bindAddr = "127.0.0.1"
  bindPort = 7000
  vhostHTTPPort = 7080
  auth.method = "token"
  auth.token = "<FRP_TOKEN>"            # openssl rand -hex 32 → /etc/relay/secrets.env
  subDomainHost = "terminal.yaojia.wang"
  transport.tls.force = true            # control-channel mTLS — token is NOT the sole gate
  transport.tls.certFile = "/etc/relay/frp-tls/frps-ctrl.cert.pem"
  transport.tls.keyFile  = "/etc/relay/frp-tls/frps-ctrl.key.pem"
  transport.tls.trustedCaFile = "/etc/relay/frp-client-ca/frp-client-ca.cert.pem"
  log.to = "/var/log/frp/frps.log"
  log.level = "info"
  ```
- **Verify:** both ports loopback-bound; `journalctl -u frps` clean; `curl -s -H 'Host: probe.terminal.yaojia.wang' http://127.0.0.1:7080/` → frp 404 "no proxy". **Risk:** binary fetch (scp); token/cert never committed.

**V1b — Registration-authorization decision (~30 min–1h) — closes the subdomain-takeover hole**
- [ ] **Model A (v1):** control-channel mTLS from V1 is the accepted gate. Issue **frp-client certs per host** (revoke one host by rotating the `frp-client-ca` trust without touching device certs). Document: the frp-client cert + token is a trusted secret whose leak compromises all hosts.
- [ ] **Model B (deferred):** add an frps `[[httpPlugins]]` NewProxy authz delegate (reuse `term-relay/frp-scaffold/plugin-hook.ts`) mapping client-cert CN → allowed subdomain, rejecting mismatches.
- **Verify:** an `frpc` with a valid token but **no** frp-client cert is refused at the control handshake; (Model B) host-A's cert claiming `subdomain=hostB` is refused.

**V2 — Device client-CA + issuance + revocation (~2h)**
- [ ] `deploy/scripts/gen-device-ca.sh`: EC **P-256** self-signed CA (`CA:TRUE`, `keyCertSign,cRLSign`, 3650d) → `/etc/relay/device-ca/` (0700), idempotent, separate trust root from agent-CA/web-PKI. Parametrize to also scaffold `frp-client-ca` for V1.
- [ ] `deploy/scripts/issue-device-cert.sh`: P-256 leaf, `EKU=clientAuth`, 825d; emits `.p12` (`openssl pkcs12 -export -legacy` for iOS/Android) **and** `.pem`/`.key.pem`/`.fullchain.pem` (desktop/curl). Appends CN/serial to `issued.log`.
- [ ] `deploy/scripts/revoke-device.sh`: `openssl ca` revoke → regenerate `/etc/relay/device-ca/crl.pem` → `nginx -s reload`. **Initialize an empty CRL now** so V4 can reference it.
- **Verify:** `openssl verify -CAfile device-ca.cert.pem test-device.pem` → OK; p12 lists the leaf; revoke → CRL lists the serial. **Risk:** p12 import quirks (mitigate `-legacy`); doc "P-256, never Ed25519".

**V3 — Wildcard server cert via LE DNS-01 (~1h) — LE is mainline (hard iOS prerequisite)**
- [ ] `acme.sh --dns dns_ali -d '*.terminal.yaojia.wang' -d terminal.yaojia.wang` → install `/etc/relay/frp-tls/fullchain.pem` + `privkey.pem` (0600), `--reloadcmd 'nginx -s reload'`. Needs `Ali_Key`/`Ali_Secret`. Separate cert from `*.term` (different parent label — do not try to cover both).
- **Verify:** SAN shows `*.terminal.yaojia.wang`; `curl https://<name>.terminal.yaojia.wang` from a stock machine validates with **no `-k`**. **Risk:** Aliyun DNS API creds/propagation — if unavailable, iOS bring-up stalls (self-signed fails on iOS).

**V4 — nginx `:8470` = TLS-term + mTLS + WS proxy (~1h)**
- [ ] `/etc/nginx/conf.d/frp-mtls.conf`:
  ```nginx
  map $http_upgrade $connection_upgrade { default upgrade; '' close; }

  server {
    listen 127.0.0.1:8470 ssl;            # NO `http2 off;` — invalid on nginx 1.24
    server_name ~^.+\.terminal\.yaojia\.wang$;

    ssl_certificate     /etc/relay/frp-tls/fullchain.pem;
    ssl_certificate_key /etc/relay/frp-tls/privkey.pem;

    # --- the data-path security gate ---
    ssl_verify_client on;                 # ON, never `optional`
    ssl_client_certificate /etc/relay/device-ca/device-ca.cert.pem;
    ssl_verify_depth 1;
    ssl_crl /etc/relay/device-ca/crl.pem; # revocation from day one

    location / {
      proxy_pass http://127.0.0.1:7080;
      proxy_http_version 1.1;
      proxy_set_header Host $host;
      proxy_set_header Upgrade $http_upgrade;
      proxy_set_header Connection $connection_upgrade;
      proxy_set_header Origin https://$host;         # needed for Android/curl; redundant-harmless for iOS
      proxy_set_header X-Client-Cert-CN $ssl_client_s_dn;
      proxy_buffering off;
      proxy_read_timeout 3600s;                      # prevents idle-WS proxy kill (default 60s)
      proxy_send_timeout 3600s;
    }
  }
  ```
- **Verify (loopback, before touching the stream), with `test-device`:** no-cert → TLS handshake failure (must fail); `--cert/--key` → frp 404 (reaches frps); revoked cert (added to CRL) → handshake failure.

**V5 — stream SNI additions (~30 min)**
- [ ] Merge into the **captured** `map` body (do not retype the existing two lines):
  ```nginx
  frp.terminal.yaojia.wang  127.0.0.1:7000;   # exact wins over wildcard
  *.terminal.yaojia.wang    127.0.0.1:8470;
  # existing *.term.yaojia.wang → 8443  and  default → 10443  preserved verbatim
  ```
- [ ] `nginx -t && systemctl reload nginx`.
- **Verify:** 4-way `s_client -servername` matrix (frp control bytes / `*.terminal` LE cert / relay cert / xray real-MS cert). **Risk:** map typo → matrix + `.bak` + reload gate.

**V6 — End-to-end + acceptance (~1.5h)** — test `frpc` → local base app; full acceptance matrix (see M1). **Verify:** items 1–7 in Milestone M1.

**V7 — Per-host runbook + committable template (~45 min)**
- [ ] `deploy/frp/frpc.example.toml` (no secrets) + short runbook: `BIND_HOST=127.0.0.1`, exact `ALLOWED_ORIGINS`, secure P12/cert delivery (scp not email), `revoke-device.sh` procedure, the frp-client trust-boundary note.

**Track-V effort: ~8–10h.** New files touch nothing existing (all loopback listeners + two additive `map` lines).

---

### Track S — Server / host packaging (base app — **zero `src/` changes**)

> Headline (verified): a working tunnel needs **zero base-app code changes** — env-extensible `ALLOWED_ORIGINS`, no `Host` reliance, PTY⊥WS decoupling. But "working" ≠ "safe": the tunnel MUST NOT be switched on until **S-GATE** is green.

**S-GATE — Preconditions (BLOCKING; before `frpc` starts on ANY host)**
- [ ] **Declare the trust model** (Model A / Model B) in the deploy doc — one line. v1 = Model A.
- [ ] **Prove mTLS is enforced / cert-free is rejected** (owned by Track V): `curl -sI https://<name>.terminal.yaojia.wang/live-sessions` **with no cert** → TLS failure. If it returns `200`, **STOP — the shell is world-open.**
- [ ] **Prove a client can present a cert** (owned by Track C): `curl --cert dev.crt --key dev.key -sI …/live-sessions` → `200`.
- **Effort:** 0.5h + cross-track wait. **Risk if skipped:** unauthenticated public root shell.

**S0 — Base-app config per host (no code)**
- [ ] Run the **compiled** app: `npm ci && npm run build` → `node dist/server.js` (`build:web` only if this host also serves the browser cockpit).
  | Var | Value | Note |
  |---|---|---|
  | `PORT` | `3000` | frpc dials `127.0.0.1:3000`; desktop-embedded may drift off 3000 — pin it (S2). |
  | `BIND_HOST` | `127.0.0.1` | **Mandatory.** Default is `0.0.0.0` (`src/config.ts:37`) → leaving it default exposes an **unauth'd shell on the LAN**, bypassing mTLS entirely. |
  | `ALLOWED_ORIGINS` | `https://<name>.terminal.yaojia.wang` | Bare host. `:443` also matches (URL normalizes it away on both sides; iOS omits it) — **not** a failure mode. A *non-default* port would mismatch. |
  | `SHELL_PATH` | `/bin/zsh` · `/bin/bash` · `powershell.exe` | per-OS |
  | `IDLE_TTL` | ≥ `86400` (raise for multi-day walk-away) | reaps only after detached **and** silent past TTL |
  | `USE_TMUX` | `1` on *nix, `0`/unset on Windows | cross-restart PTY survival |
- **Origin note:** the client must send *some* `Origin` matching `ALLOWED_ORIGINS` or it 401s — a **functional** contract, not an authz boundary (a native client can send any Origin). Already satisfied: `HostEndpoint.originHeader` and the server's `isOriginAllowed` both normalize via WHATWG URL.
- **Verify:** `lsof -iTCP:3000 -sTCP:LISTEN` shows `127.0.0.1:3000` (not `*`); Origin-gate curl pair (101 good / 401 bogus). **Effort:** 0.5h/host.

**S1 — frpc on the host**
- [ ] `frpc.toml` (see M1 for the full block): `type=http`, unique `subdomain=<name>`, `transport.tls.serverName=frp.terminal.yaojia.wang`, `disableCustomTLSFirstByte=true`, frp-client cert/key (V1 control mTLS), `loginFailExit=false`, token `chmod 600`.
- [ ] Keep a **name registry** (frps rejects dup subdomains; while a host's frpc is offline anyone with the token could claim its subdomain — closed in Model A by the frp-client cert).
- **Verify:** frpc log `start proxy success`; S-GATE curl-with-cert → 200; kill frpc → app + live PTY survive → restart → client reattaches with replay. **Effort:** 1–2h first host, ~15 min after.

**S2 — Durable service packaging (the real work; ~1.5–2.5 days)**
- [ ] Port the *shape* of `agent/src/service/{launchd,systemd,install}.ts`, but note the gaps: the **launchd writer has no `<EnvironmentVariables>`** and the **systemd writer has no `EnvironmentFile`** — extend both to inject S0 env. `agent/src/service/originConfig.ts` writes the **wrong zone** (`.term.` not `.terminal.`) — parameterize the zone or write fresh.
- [ ] Two supervised user-scoped units per host (app + frpc), never root (keep the `RootRefusedError` pattern). Linux: `loginctl enable-linger`. Windows: WinSW/nssm, `USE_TMUX=0`.
- [ ] **Desktop-embedded hosts:** `pickFreePort` can drift off 3000 → **pin the port**; inject `ALLOWED_ORIGINS` (today `buildServerEnv` only relays ambient env). The Electron app must be **always-running** for the embedded server to exist → for walk-away hosts, prefer the standalone `node dist/server.js` service over the GUI.
- **Verify:** reboot → both units up, tunnel serves within ~30s; `kill -9` each → supervisor restarts. **Effort:** 1.5–2.5 days for env-injecting cross-OS templates + install script + 80%-coverage tests; ~15 min/host after.

**S3 — Survival & health validation (~1–2h)**
- Verified guarantees hold: PTY⊥WS (`src/server.ts:841` close→`detachWs`, never kill), idle reaper reaps only detached+silent (`:686`), client backoff-reconnect with `sessionId` replay.
- [ ] **Idle-WS timeout (was missing):** the server sends **no** WS pings. Fixed cross-track by V4's `proxy_read_timeout 3600s` **and** client periodic pings (iOS `URLSessionWebSocketTask.sendPing`).
- **Verify matrix:** (a) kill frpc mid-session → PTY alive, reattach on restart; (b) `IDLE_TTL=60` → detached+silent reaped, active not; (c) **idle 5 min, tunnel up → WS not dropped**; (d) reboot VPS → frpc re-establishes, client resumes.

**S4 — mTLS scoping (Model A: accept; Model B: conditional-CRITICAL)**
- [ ] Multi-tenant cert→host authorization (CRITICAL in B, N/A in A): per-tenant client-CA selected by `$host`/SNI, or `map $host → $expected_ou` reject on mismatch.
- Notes for the record: `/hook*` is tunnel-reachable (`isLoopback()` sees frpc's `127.0.0.1`) and `/live-sessions/:id/preview` (`:323`) **leaks scrollback** to any cert-holder with no Origin guard — sets Model B's blast radius (a foreign valid cert reads your terminals, not just opens new ones). Accept in Model A.

**Track-S effort: ~2.5–3.5 days** (S2 tooling + S3 validation) + a blocking wait on V and C tracks for the S-GATE proof. Zero `src/` changes.

---

### Track C — Native clients (present the device cert)

**Client preconditions (gate on these; mostly infra):** P-A `BIND_HOST=127.0.0.1` on every host; P-B frps `:7080`/`:7000` loopback-only & absent from the Aliyun SG (`nmap … -p 7000,7080` closed); P-C LE wildcard live (self-signed fails iOS ATS + Chromium + Android default TLS); P-D mTLS `on` not `optional`, trust anchor = device-CA only; P-E scope decision (A/B).

#### C-iOS — do first (client exists; ~5–7 dev-days)

**iOS-1 · `ClientTLS` leaf SPM package** (`ios/Packages/ClientTLS/`, imports `Security`/`Foundation`)
- [ ] `ClientIdentity.swift` — `@unchecked Sendable` wrapper over `SecIdentity` + issuer chain.
- [ ] `PKCS12Importer.swift` — `SecPKCS12Import`; map `errSecAuthFailed`→`.wrongPassphrase`, `errSecDecode`→`.corruptFile`, `errSecPkg`→`.unsupported`.
- [ ] `KeychainClientIdentityStore.swift` — persist raw `.p12` + passphrase under `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`, re-import at launch.
- [ ] `MutualTLSChallengeResponder.swift` — pure `resolve(challenge, identity:)`: `ClientCertificate`+identity → `.useCredential(URLCredential(identity:…, persistence:.forSession))`; `ClientCertificate`+no identity → `.cancelAuthenticationChallenge` (clean, classifiable error); `ServerTrust` → `.performDefaultHandling` (LE validated by system).
- **Verify:** `swift test --package-path Packages/ClientTLS` — responder truth table (3 challenge types × identity present/absent) + import of a CI-generated fixture `.p12` incl. wrong-passphrase.

**iOS-2 · Wire responder into both transports**
- [ ] `URLSessionTermTransport`/`WSConnection`: add `init(identity:)`; implement **`urlSession(_:task:didReceive:completionHandler:)`** (task-level receives connection-level challenges) delegating to the responder; keep `didCompleteWithError` intact. *(Note: the current transport delegate has no challenge handler at all — this is net-new.)*
- [ ] `URLSessionHTTPTransport`: build the session with an `NSObject` `ClientTLSSessionDelegate` implementing the session-level `urlSession(_:didReceive:completionHandler:)`. Apply to both `production()` and `SessionThumbnail.liveTransport` (`SessionThumbnail.swift:242`).
- [ ] `AppEnvironment.production()`: load identity from the store, inject into both transports + probe closure.
- **Verify:** `xcodebuild test`; `FakeChallenge` unit test asserts each transport returns the credential iff an identity is set.

**iOS-3 · Install UX + pairing guard + reworded warning**
- [ ] `ClientCertScreen.swift` — `.fileImporter([.pkcs12])` + secure passphrase field → import → store → show issuer CN + **expiry** + "replace certificate" (rotation). Settings entry "Device certificate."
- [ ] `PairingViewModel`: for a `.terminal.yaojia.wang` host, (a) block probe if no identity installed ("先安装本设备证书"); (b) **replace the `publicHostBlocking` copy** for tunnel hosts (the current "anyone who can reach the port gets a shell" is false under mTLS and deters the intended flow). Keep the blocking tier for genuinely public non-tunnel hosts.
- [ ] Add a `.clientCertRejected` classification (nginx-rejected mTLS surfaces as `NSURLErrorSecureConnectionFailed`/reset — currently mis-classified `.tlsFailure` "server cert invalid"). Copy: "本设备证书无效或已吊销，请重新导入。" *(`PairingError.swift` already maps `NSURLErrorClientCertificateRequired/Rejected` — primed.)*
- **Install UX:** deliver `device.p12` (AirDrop/Files) → WebTerm → Settings → Device certificate → Import + passphrase (in-app, app-sandboxed; not a config profile).
- **Verify:** XCUITest import fixture → pair `https://<name>.terminal.yaojia.wang` → probe → attach. Manual on-device against the real VPS; confirm nginx logs `$ssl_client_verify=SUCCESS`.

#### C-Desktop — do second (Electron; ~6–9 dev-days) — reverses `DESKTOP_PLAN.md §0` (record the deviation)

**D-1 · Remote-host mode**
- [ ] `desktop/src/remote-hosts.ts` — `RemoteHost{id,name,url}`, persisted via `settings-store.ts` (extend `DesktopPrefs`).
- [ ] `window.ts`/`main.ts`: allow a `BrowserWindow` to load `https://<name>.terminal.yaojia.wang/`; **relax the `will-navigate` block to exactly the selected remote origin** (keep it locked to that one origin; keep the embedded local server running — this box is also its own tunnel source). Tray host-picker: "This machine (local)" ↔ each remote host.
- **Verify:** `npm run typecheck`; switch to a remote host, confirm page + `wss://` load; confirm the origin lock still blocks elsewhere.

**D-2 · mTLS via OS store (the only viable path)**
- [ ] `main.ts`: `app.on('select-client-certificate', (event, wc, url, list, cb) => { event.preventDefault(); const cert = pickByIssuer(list, DEVICE_CA_CN); cb(cert) })`. **`event.preventDefault()` is mandatory** (else Electron auto-picks `list[0]`). Empty/no-match → clear "device certificate not installed" dialog, never silent `cb()`.
- **Drop** the "app reads `.p12`" idea — infeasible: Chromium sources client certs **only** from the OS store; `setCertificateVerifyProc` is server-side only. The `.p12` must live in the login keychain / Personal store. Document the macOS private-key ACL "Always Allow" one-time prompt.
- **Install UX:** macOS double-click `.p12` → login keychain; Windows → Personal store; app auto-selects by issuer.
- **Verify:** end-to-end connect; nginx logs a verified cert; empty keychain → dialog, not a hang.

**D-3 · Local `frpc` supervision (coordinate with Track V/S)**
- [ ] Optionally spawn/monitor `frpc` from `main.ts` and set the embedded server's `ALLOWED_ORIGINS=https://<name>.terminal.yaojia.wang` (additive via `config.ts:214`). Embedded server already defaults `BIND_HOST=127.0.0.1` (`server-config.ts:17`) — satisfies P-A **unless `lanSharing` is enabled** (document: re-opens the unauth'd LAN path).
- **Verify:** desktop-launched frpc registers; host reachable from an off-LAN client.

#### C-Android — do last (no `android/` exists; WebView MVP ~2.5–3.5 wk)

Pick **WebView shell** (mirrors the desktop pattern; xterm.js in `public/` is the terminal; avoids the Termux GPLv3 entanglement of full-native).
- [ ] **C-1 Shell:** Kotlin, single Activity, hardened `WebView` (JS on, file access off, one allowed origin). Verify: loads a base app.
- [ ] **C-2 mTLS:** `WebViewClient.onReceivedClientCertRequest` → `req.proceed(privateKey, chain)` from `KeyStore.getInstance("PKCS12")`; import via SAF (`ACTION_OPEN_DOCUMENT`) + passphrase; `.p12` app-private, key wrapped by Android Keystore. Verify: connect to `<name>.terminal.yaojia.wang`; nginx logs verified cert; wrong/absent → clean error UI.
- [ ] **C-3 Host mgmt:** multi-host list (DataStore/Room), add/switch/remove.
- [ ] **C-4 Notifications + deep links:** reuse the ntfy bridge (matches existing infra) rather than FCM; `webterminal://` intent filter.
- Defer full-native (Compose + terminal-view + OkHttp `X509KeyManager`) — 4–8 wk, pulls in GPLv3.

**Track-C effort: iOS 5–7 d · Desktop 6–9 d · Android 2.5–3.5 wk**, gated on preconditions.

---

## 3. Cross-track dependencies & build order

**Dependency graph**
- `Track V (V0→V6)` proves the server-side gate → **unblocks S-GATE #2** and the entire tunnel-enable step.
- `Track C iOS-1/2` (first client cert support) → **unblocks S-GATE #3**.
- `Track S S-GATE` gates `S1` (frpc on any host) on **both** V's cert-rejection proof and C's cert-presentation proof.
- `V3 (LE wildcard)` is a **hard prerequisite** for all three clients (P-C) — do it early.
- `V4 proxy_read_timeout` + client pings jointly fix idle-WS kill (S3).
- Desktop `D-3` and Android depend on the per-host runbook `V7` + service tooling `S2`.

**Concrete build ORDER**
1. **V0 → V5** (VPS: frps + device-CA + LE + nginx mTLS + SNI routes). Fully self-verifiable with `curl --cert` / `websocat`.
2. **V6** end-to-end with a throwaway `frpc` + a spare base app on the VPS/laptop → **Milestone M1** (mTLS proven with `curl`, no client yet).
3. In parallel once M1 is green: **C-iOS (iOS-1 → iOS-3)** and **S0/S1** on the first real local host.
4. **First real end-to-end from iOS** → **Milestone M2**.
5. **S2** durable service tooling → **S-GATE** repeatable per host → onboard a 2nd/3rd host → **Milestone M3**.
6. **C-Android** WebView MVP → **Milestone M4**.
7. **C-Desktop** remote mode + OS-store mTLS → **Milestone M5**.

---

## 4. Milestones

- **M1 — VPS tunnel up + one local host + mTLS proven with ONE device (curl) end-to-end.** Acceptance matrix on `t1.terminal.yaojia.wang` (base app `BIND_HOST=127.0.0.1 ALLOWED_ORIGINS=https://t1.terminal.yaojia.wang PORT=3000`; `frpc.toml`):
  ```toml
  serverAddr="8.138.1.192"; serverPort=443; auth.token="<FRP_TOKEN>"
  transport.tls.enable=true
  transport.tls.serverName="frp.terminal.yaojia.wang"
  transport.tls.disableCustomTLSFirstByte=true          # CRITICAL for ssl_preread
  transport.tls.certFile="<host-frp-client.pem>"        # frps control mTLS
  transport.tls.keyFile="<host-frp-client.key.pem>"
  transport.tls.trustedCaFile="/etc/.../frps-ctrl-ca.pem"
  [[proxies]]
  name="t1"; type="http"; localIP="127.0.0.1"; localPort=3000; subdomain="t1"
  ```
  1. no device cert → TLS handshake failure. 2. `--cert test-device.pem --key …` → `200` JSON from `/live-sessions`. 3. WS `attach`→`attached` on `wss://t1.terminal.yaojia.wang/term`. 4. **H1 negative:** a 2nd frpc with the token but no/other frp-client cert claiming `t1` → refused at control. 5. revoked device cert → rejected (CRL). 6. first byte to `:443` is `0x16` (`tcpdump`, proves `disableCustomTLSFirstByte`). 7. 4-zone SNI + V0 coexistence oracle green; `journalctl -u xray -u nginx` clean.
- **M2 — iOS mTLS end-to-end.** Real iPhone with an imported `device.p12` pairs `https://t1.terminal.yaojia.wang`, probes `/live-sessions`, attaches `/term`, and drives a live shell; nginx logs `$ssl_client_verify=SUCCESS`; idle-5-min WS stays up.
- **M3 — Multi-host.** Two+ real local hosts (`h1`, `h2`) each running the durable service (S2) + frpc; each reachable at its own subdomain; onboarding a new host = runbook only (no VPS change); revoke one device removes its access fleet-wide.
- **M4 — Android WebView MVP.** Android device imports `.p12`, connects to a host over mTLS, manages ≥2 hosts, receives an ntfy push → deep-links into a session.
- **M5 — Desktop remote mode.** Electron app switches to a remote host, selects the device cert from the OS keychain, connects over `wss://` + mTLS; origin lock still blocks foreign navigation.

---

## 5. Risk register

| # | Risk | Sev | Mitigation |
|---|---|---|---|
| **R1** | **mTLS client-cert support is net-new on all three clients** (iOS transport has no `didReceive challenge`; Android/desktop from-scratch). Until it ships, no client can reach a tunneled host. **Critical-path blocker.** | **CRITICAL** | Do iOS first (smallest, exists) to prove the whole path; VPS side is fully verifiable now with `curl --cert`/`websocat` independent of clients. LE wildcard (V3) de-scopes clients to only the client-cert branch. |
| **R2** | **Base-app public exposure — mTLS is the ONLY gate and is bypassable.** `BIND_HOST` default `0.0.0.0` serves an **unauth'd shell on the LAN**; frps `:7080`/`:7000` off-loopback or a single `ssl_verify_client optional`/misconfig = every session world-open; `/live-sessions/:id/preview` leaks scrollback to any cert-holder. | **CRITICAL** | Mandatory `BIND_HOST=127.0.0.1` (S0, P-A); frps loopback + absent from Aliyun SG (P-B, verify `nmap`); `ssl_verify_client on` never `optional` (P-D); **S-GATE blocks all tunnel-enable** until cert-free rejection is proven; CRL from day one (V2/V4). |
| **R3** | **frps registration = subdomain takeover / active MITM.** Shared token isn't bound to a subdomain → any token-holder registers any subdomain and MITMs a valid-cert client. | HIGH | Control-channel mTLS (V1, per-host frp-client CA) so a leaked token alone is insufficient; NewProxy authz plugin pins cert→subdomain for Model B (V1b). |
| **R4** | **No cross-tenant isolation under one device-CA** (Model B). | HIGH (B only) | Declare **Model A** for v1; Model B needs per-tenant CA / `$host`→OU map + per-host frp token. Do not ship B on shared-CA. |
| **R5** | **Self-signed server cert is a hard iOS blocker** (URLSession has no tap-to-accept). | HIGH | LE DNS-01 wildcard is mainline (V3) — system-trusted on all three platforms. |
| **R6** | **Idle-WS proxy kill** (~60s nginx default, no app-level pings) → reconnect churn. | HIGH | V4 `proxy_read_timeout/proxy_send_timeout 3600s` + client periodic WS pings; explicit idle-5-min test (S3, M2). |
| **R7** | **`http2 off;` invalid on nginx 1.24** → `nginx -t` fails, reload aborted. | HIGH (deploy) | Omit the directive (HTTP/2 off by default); pin at V0 via `nginx -v`. |
| **R8** | **No revocation** → one lost device = fleet access until full CA rotation. | HIGH | `ssl_crl` + `revoke-device.sh` from day one (V2); client re-import/rotate path (iOS-3, D-2, C-2). |
| **R9** | **Desktop-embedded weak substrate** — dynamic port drift off 3000; GUI must stay running. | MED | Pin the port + inject `ALLOWED_ORIGINS`; prefer standalone `node dist/server.js` service for walk-away hosts. |
| **R10** | **Env-injection missing** in `launchd`/`systemd` writers; `originConfig.ts` uses wrong zone. | MED | Treat S2 as real work (~1.5–2.5 d), not copy-paste; parameterize the zone. |
| **R11** | **Cert-provisioning ops burden** grows with device count. | MED | Small enrollment helper wrapping `issue-device-cert.sh`; secure `.p12` delivery (scp/AirDrop, not email). |
| **R12** | **LE DNS-01 depends on Aliyun API creds/propagation.** | MED | Provision `Ali_Key`/`Ali_Secret` early; if unavailable, iOS bring-up stalls — sequence V3 before M2. |

---

## 6. MVP fast-path — shortest route to ONE device securely reaching ONE host

Goal: an iPhone opens a shell on your laptop through the VPS, with mTLS as the only gate, in the fewest steps. **Model A. Defer** the NewProxy authz plugin, multi-host tooling, Android, desktop, and durable service packaging.

1. **VPS `V0`→`V5`** but minimal: install frps with **control mTLS + token** (V1), `gen-device-ca.sh` + `issue-device-cert.sh` + empty CRL (V2), **LE wildcard** (V3, non-negotiable for iOS), nginx `:8470` mTLS (V4), two SNI `map` lines (V5). *(~5–6h.)*
2. **`V6`/M1 with curl:** run one throwaway base app + `frpc name=t1` on the laptop (`BIND_HOST=127.0.0.1`, `ALLOWED_ORIGINS=https://t1.terminal.yaojia.wang`). Prove: no-cert → reject; `curl --cert` → `200`; `websocat` `attach`→`attached`. **→ M1.**
3. **iOS client cert only:** build `ClientTLS` (iOS-1) + wire the two transports (iOS-2) + a minimal import screen (iOS-3, skip the reworded-warning polish for now). Import `device.p12`, pair `https://t1.terminal.yaojia.wang`, attach. **→ M2.**

**Fast-path total: ~1.5–2 days VPS + ~4–5 dev-days iOS.** Everything else (authz plugin, S2 durable services, multi-host, Android, desktop remote mode) layers on afterward without reworking this core.