# 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 `.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=, localPort=3000 └──────┬───────┘ ▼ ┌──────────────┐ │ base app │ BIND_HOST=127.0.0.1 :3000 (loopback ONLY) │ ALLOWED_ORIGINS = https://.terminal.yaojia.wang └──────────────┘ ▲ native client ────────┘ iOS / Android / desktop present a DEVICE CERT https://.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 = "" # 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://.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://.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://.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=`, `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 ``** 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://.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://.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://.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 `.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="" transport.tls.enable=true transport.tls.serverName="frp.terminal.yaojia.wang" transport.tls.disableCustomTLSFirstByte=true # CRITICAL for ssl_preread transport.tls.certFile="" # frps control mTLS transport.tls.keyFile="" 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.