Files
web-terminal/docs/PLAN_NATIVE_TUNNEL.md
Yaojia Wang 6b8269c1c1 docs: multi-tenant mTLS reverse-tunnel dev plan (VPS + server + native clients)
Generated via multi-agent workflow (3 planners → adversarial review → synthesis).
Covers frp tunnel on shared :443, mTLS device-cert auth, and client-cert
implementation for iOS/Android/desktop. Trust Model A (single-owner fleet) for v1.
2026-07-07 08:00:40 +02:00

341 lines
33 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 min1h) — 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 17 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: ~810h.** 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:** 12h first host, ~15 min after.
**S2 — Durable service packaging (the real work; ~1.52.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.52.5 days for env-injecting cross-OS templates + install script + 80%-coverage tests; ~15 min/host after.
**S3 — Survival & health validation (~12h)**
- 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.53.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; ~57 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; ~69 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.53.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`) — 48 wk, pulls in GPLv3.
**Track-C effort: iOS 57 d · Desktop 69 d · Android 2.53.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.52.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). *(~56h.)*
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.52 days VPS + ~45 dev-days iOS.** Everything else (authz plugin, S2 durable services, multi-host, Android, desktop remote mode) layers on afterward without reworking this core.