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

33 KiB
Raw Blame History

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.wangfrps 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 :7080frpc → 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.wang8.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):
    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.pemnginx -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:
    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):
    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-sessions200.
  • 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 buildnode 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.swiftSecPKCS12Import; 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.tsRemoteHost{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.onReceivedClientCertRequestreq.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 iOSMilestone 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):
    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 attachattached 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.24nginx -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 V0V5 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 --cert200; websocat attachattached. → 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.