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.
33 KiB
Native mTLS Reverse-Tunnel — Master Development Plan
Destination:
docs/PLAN_NATIVE_TUNNEL.mdStatus: planning (synthesized from three reviewed track-plans: VPS, Server, Clients) Deployed baseline: VPS8.138.1.192(Ubuntu 24.04, nginx 1.24,:443shared viassl_prereadstream — existing*.term.yaojia.wang→relay:8443 anddefault→xray:10443 preserved verbatim). Wildcard DNS*.terminal.yaojia.wang → 8.138.1.192is 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→frpscontrol: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, enforcesssl_verify_client onagainst 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
mapbody (hostnames; *.term.yaojia.wang→8443; default→10443) andnginx -v(expect 1.24 → no standalonehttp2directive is legal). dig +short {foo,frp}.terminal.yaojia.wang→8.138.1.192.- Capture "before" oracle: relay 200 + xray real-MS cert (
s_client/curlprobes). - Verify: all reads succeed,
.bakexists. Risk: none.
V1 — frps: control :7000 (mTLS) + vhost :7080, dedicated user, systemd (~1h)
- Install
frps≥ v0.52 (disableCustomTLSFirstByteexists ≥ v0.50). Mainland fetch may be blocked →scpfrom laptop. - Dedicated unprivileged
frpuser (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 frpsclean;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-catrust 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 (reuseterm-relay/frp-scaffold/plugin-hook.ts) mapping client-cert CN → allowed subdomain, rejecting mismatches. - Verify: an
frpcwith a valid token but no frp-client cert is refused at the control handshake; (Model B) host-A's cert claimingsubdomain=hostBis 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 scaffoldfrp-client-cafor V1.deploy/scripts/issue-device-cert.sh: P-256 leaf,EKU=clientAuth, 825d; emits.p12(openssl pkcs12 -export -legacyfor iOS/Android) and.pem/.key.pem/.fullchain.pem(desktop/curl). Appends CN/serial toissued.log.deploy/scripts/revoke-device.sh:openssl carevoke → 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'. NeedsAli_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.wangfrom 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
mapbody (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 -servernamematrix (frp control bytes /*.terminalLE 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, exactALLOWED_ORIGINS, secure P12/cert delivery (scp not email),revoke-device.shprocedure, 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, noHostreliance, 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-sessionswith no cert → TLS failure. If it returns200, 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:webonly if this host also serves the browser cockpit).Var Value Note PORT3000frpc dials 127.0.0.1:3000; desktop-embedded may drift off 3000 — pin it (S2).BIND_HOST127.0.0.1Mandatory. 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_ORIGINShttps://<name>.terminal.yaojia.wangBare host. :443also 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.exeper-OS IDLE_TTL≥ 86400(raise for multi-day walk-away)reaps only after detached and silent past TTL USE_TMUX1on *nix,0/unset on Windowscross-restart PTY survival - Origin note: the client must send some
OriginmatchingALLOWED_ORIGINSor it 401s — a functional contract, not an authz boundary (a native client can send any Origin). Already satisfied:HostEndpoint.originHeaderand the server'sisOriginAllowedboth normalize via WHATWG URL. - Verify:
lsof -iTCP:3000 -sTCP:LISTENshows127.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, uniquesubdomain=<name>,transport.tls.serverName=frp.terminal.yaojia.wang,disableCustomTLSFirstByte=true, frp-client cert/key (V1 control mTLS),loginFailExit=false, tokenchmod 600.- Keep a name registry (frps rejects dup subdomains; while a host's frpc is offline anyone with the token could claim its subdomain — closed in Model A by the frp-client cert).
- Verify: frpc log
start proxy success; S-GATE curl-with-cert → 200; kill frpc → app + live PTY survive → restart → client reattaches with replay. Effort: 1–2h first host, ~15 min after.
S2 — Durable service packaging (the real work; ~1.5–2.5 days)
- Port the shape of
agent/src/service/{launchd,systemd,install}.ts, but note the gaps: the launchd writer has no<EnvironmentVariables>and the systemd writer has noEnvironmentFile— extend both to inject S0 env.agent/src/service/originConfig.tswrites 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
RootRefusedErrorpattern). Linux:loginctl enable-linger. Windows: WinSW/nssm,USE_TMUX=0. - Desktop-embedded hosts:
pickFreePortcan drift off 3000 → pin the port; injectALLOWED_ORIGINS(todaybuildServerEnvonly relays ambient env). The Electron app must be always-running for the embedded server to exist → for walk-away hosts, prefer the standalonenode dist/server.jsservice over the GUI. - Verify: reboot → both units up, tunnel serves within ~30s;
kill -9each → 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:841close→detachWs, never kill), idle reaper reaps only detached+silent (:686), client backoff-reconnect withsessionIdreplay. - Idle-WS timeout (was missing): the server sends no WS pings. Fixed cross-track by V4's
proxy_read_timeout 3600sand client periodic pings (iOSURLSessionWebSocketTask.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, ormap $host → $expected_oureject on mismatch. - Notes for the record:
/hook*is tunnel-reachable (isLoopback()sees frpc's127.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 Sendablewrapper overSecIdentity+ issuer chain.PKCS12Importer.swift—SecPKCS12Import; maperrSecAuthFailed→.wrongPassphrase,errSecDecode→.corruptFile,errSecPkg→.unsupported.KeychainClientIdentityStore.swift— persist raw.p12+ passphrase underkSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, re-import at launch.MutualTLSChallengeResponder.swift— pureresolve(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.p12incl. wrong-passphrase.
iOS-2 · Wire responder into both transports
URLSessionTermTransport/WSConnection: addinit(identity:); implementurlSession(_:task:didReceive:completionHandler:)(task-level receives connection-level challenges) delegating to the responder; keepdidCompleteWithErrorintact. (Note: the current transport delegate has no challenge handler at all — this is net-new.)URLSessionHTTPTransport: build the session with anNSObjectClientTLSSessionDelegateimplementing the session-levelurlSession(_:didReceive:completionHandler:). Apply to bothproduction()andSessionThumbnail.liveTransport(SessionThumbnail.swift:242).AppEnvironment.production(): load identity from the store, inject into both transports + probe closure.- Verify:
xcodebuild test;FakeChallengeunit 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.wanghost, (a) block probe if no identity installed ("先安装本设备证书"); (b) replace thepublicHostBlockingcopy 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
.clientCertRejectedclassification (nginx-rejected mTLS surfaces asNSURLErrorSecureConnectionFailed/reset — currently mis-classified.tlsFailure"server cert invalid"). Copy: "本设备证书无效或已吊销,请重新导入。" (PairingError.swiftalready mapsNSURLErrorClientCertificateRequired/Rejected— primed.) - Install UX: deliver
device.p12(AirDrop/Files) → WebTerm → Settings → Device certificate → Import + passphrase (in-app, app-sandboxed; not a config profile). - Verify: XCUITest import fixture → pair
https://<name>.terminal.yaojia.wang→ probe → attach. Manual on-device against the real VPS; confirm nginx logs$ssl_client_verify=SUCCESS.
C-Desktop — do second (Electron; ~6–9 dev-days) — reverses DESKTOP_PLAN.md §0 (record the deviation)
D-1 · Remote-host mode
desktop/src/remote-hosts.ts—RemoteHost{id,name,url}, persisted viasettings-store.ts(extendDesktopPrefs).window.ts/main.ts: allow aBrowserWindowto loadhttps://<name>.terminal.yaojia.wang/; relax thewill-navigateblock 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-pickslist[0]). Empty/no-match → clear "device certificate not installed" dialog, never silentcb().- Drop the "app reads
.p12" idea — infeasible: Chromium sources client certs only from the OS store;setCertificateVerifyProcis server-side only. The.p12must 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
frpcfrommain.tsand set the embedded server'sALLOWED_ORIGINS=https://<name>.terminal.yaojia.wang(additive viaconfig.ts:214). Embedded server already defaultsBIND_HOST=127.0.0.1(server-config.ts:17) — satisfies P-A unlesslanSharingis 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)fromKeyStore.getInstance("PKCS12"); import via SAF (ACTION_OPEN_DOCUMENT) + passphrase;.p12app-private, key wrapped by Android Keystore. Verify: connect to<name>.terminal.yaojia.wang; nginx logs verified cert; wrong/absent → clean error UI. - C-3 Host mgmt: multi-host list (DataStore/Room), add/switch/remove.
- C-4 Notifications + deep links: reuse the ntfy bridge (matches existing infra) rather than FCM;
webterminal://intent filter. - Defer full-native (Compose + terminal-view + OkHttp
X509KeyManager) — 4–8 wk, pulls in GPLv3.
Track-C effort: iOS 5–7 d · Desktop 6–9 d · Android 2.5–3.5 wk, gated on preconditions.
3. Cross-track dependencies & build order
Dependency graph
Track V (V0→V6)proves the server-side gate → unblocks S-GATE #2 and the entire tunnel-enable step.Track C iOS-1/2(first client cert support) → unblocks S-GATE #3.Track S S-GATEgatesS1(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-3and Android depend on the per-host runbookV7+ service toolingS2.
Concrete build ORDER
- V0 → V5 (VPS: frps + device-CA + LE + nginx mTLS + SNI routes). Fully self-verifiable with
curl --cert/websocat. - V6 end-to-end with a throwaway
frpc+ a spare base app on the VPS/laptop → Milestone M1 (mTLS proven withcurl, no client yet). - In parallel once M1 is green: C-iOS (iOS-1 → iOS-3) and S0/S1 on the first real local host.
- First real end-to-end from iOS → Milestone M2.
- S2 durable service tooling → S-GATE repeatable per host → onboard a 2nd/3rd host → Milestone M3.
- C-Android WebView MVP → Milestone M4.
- 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 appBIND_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"- no device cert → TLS handshake failure. 2.
--cert test-device.pem --key …→200JSON from/live-sessions. 3. WSattach→attachedonwss://t1.terminal.yaojia.wang/term. 4. H1 negative: a 2nd frpc with the token but no/other frp-client cert claimingt1→ refused at control. 5. revoked device cert → rejected (CRL). 6. first byte to:443is0x16(tcpdump, provesdisableCustomTLSFirstByte). 7. 4-zone SNI + V0 coexistence oracle green;journalctl -u xray -u nginxclean.
- no device cert → TLS handshake failure. 2.
- M2 — iOS mTLS end-to-end. Real iPhone with an imported
device.p12pairshttps://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.
- VPS
V0→V5but 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:8470mTLS (V4), two SNImaplines (V5). (~5–6h.) V6/M1 with curl: run one throwaway base app +frpc name=t1on the laptop (BIND_HOST=127.0.0.1,ALLOWED_ORIGINS=https://t1.terminal.yaojia.wang). Prove: no-cert → reject;curl --cert→200;websocatattach→attached. → M1.- 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). Importdevice.p12, pairhttps://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.