# Zero-Touch Enrollment — Rollout & Integration Plan > **This is the EXECUTION plan** (deploy + wire + fill the gaps), grounded in the *current* > deployment reality. The DESIGN blueprint is `docs/PLAN_TUNNEL_AUTOMATION.md`; this doc says > what to actually do next, in order, to reach the product requirement. > > **Product requirement (operator, 2026-07-18):** ① 主机端启动即自动连服务器 + 自动管理证书(零手动装证书);② 客户端(手机)连接时自动处理好证书(零手动 .p12)。 --- ## 0. The honest constraint (read first) The **first** enrollment of each identity cannot be zero *human* actions: issuing a tunnel cert to an unknown machine/phone with no auth = anyone who reaches the endpoint gets a cert. So exactly **one authenticated human action at bootstrap is unavoidable**: paste a one-time pairing code (host) / log in once (phone). **Everything after** — renew, rotate, reboot-survival — is genuinely silent. > "Zero-touch" here honestly means **"one bootstrap tap, then never again"**, not "no human step ever." > Every step below is designed so that single tap is the *only* manual action for the product lifetime. --- ## 1. Current state (verified 2026-07-18) | Piece | State | |---|---| | `control-plane/` (PKI/CA + `/enroll` + `/device/enroll` + `/renew`) | **Built, 246 tests pass.** Loopback `127.0.0.1:8080`, fail-closed prod boot (needs real KMS CA material). | | `agent/` host agent (`pair --install`) | **Built, 267 tests pass.** launchd user-agent; keygen→CSR→enroll→frpc.toml→2 units→supervised frpc. | | iOS device-enroll library (SecureEnclave→CSR→cert) | **Built + unit-tested**, but **zero app callers** (no UI). | | Android / Desktop device-enroll | **None** (Android from scratch; Desktop deferred). | | Server **login route + `device:enroll` bearer mint** | **MISSING — hard blocker for the phone track.** `loginToAccountId` is a deny-all stub. | | VPS control-plane | **NOT running** (`:8080` down); code on VPS is the **older `feat/relay-phase1`** branch. | | VPS tunnel data path (frps + nginx:8470 mTLS + LE wildcard cert) | **UP** (from M1). LE `*.terminal.yaojia.wang` valid → 2026-10-05. | | My manual `mac1` host tunnel | **Live**, but static hand-issued certs — bypasses the control-plane. Control cert correctly from **frp-client-CA**, so CA lineage already matches the agent path (low migration risk). | **Bottom line:** the automated system is ~built but **not deployed and not wired**; the phone track has a real server-code hole (login/bearer). My `mac1` is a manual stand-in to be retired. --- ## 2. Architecture (target flows) **Host** (`web-terminal-agent pair --install`): generate P-256 key (never leaves host, 0600) → PKCS#10 CSR → `POST /enroll {code, machineId, agentPubkey, csr}` → CA returns frp-client leaf (`dNSName SAN = .terminal.yaojia.wang`) + caChain → SHA-256-verify-download pinned `frpc` v0.61.1 → write `frpc.toml` (mTLS control channel, `serverName=frp.terminal.yaojia.wang`, one http proxy → `127.0.0.1:PORT`) + base-app `ALLOWED_ORIGINS` → install **two launchd units** (base app + supervised agent) → reboot-durable. **Renew** silently at ~2/3 TTL via `POST /renew` (mTLS w/ current cert). **Phone**: one-time **account login** → short-lived `device:enroll` bearer → app generates a **non-exportable hardware key** (Secure Enclave / StrongBox) → hardware-signed CSR → `POST /device/enroll {csr, subdomain, deviceName, attestation?}` (Bearer) → CA verifies token + account-owns-subdomain (deny-by-default) + PoP + cap/rate-limit → returns device leaf + caChain → app stores identity against the hardware key, presents on the **already-wired mTLS path**. Silent renew via `POST /device/:id/renew`. **Three PKI roots (do not conflate):** frp-client-CA (P-256, host control channel, frps:7000) · device-CA (P-256, device data path, nginx:8470 `ssl_verify_client`) · relay agent-CA (Ed25519, legacy). --- ## 3. PRE-FLIGHT: deployment unknowns to verify (blockers before any build) Run against VPS `8.138.1.192`. **These gate feasibility — resolve before committing to the tracks.** 1. **CP CA material** — does production KMS/CA material exist for the *native* CAs the new CP needs (`NATIVE_FRP_CLIENT_CA_*`, `NATIVE_DEVICE_CA_*`, `CA_INTERMEDIATE_*`)? CP is **fail-closed**: no real material ⇒ it won't boot (or falls back to dev placeholder leaves = unusable). **This is the #1 risk.** The relay-phase1 deploy had `/etc/relay/ca/{root,intermediate}` + a P-256 device-CA + frp-client-CA on disk — confirm these can back the new CP's signer (KMS refs vs on-disk keys). 2. **CP branch/deploy** — the newer CP (with the enroll/renew endpoints as-tested) must be checked out & built on the VPS (currently `feat/relay-phase1`). Confirm PG + Redis reachable + migrated. 3. **Public `/enroll` reachability** — CP is loopback; an nginx vhost must proxy `/enroll`,`/device/enroll`, `/renew` over public TLS. `curl -i https:///enroll` should give a 4xx (validation), not 502. 4. **frps trusts `frp-client-CA`** on the `:7000` control channel (NOT device-CA). Inspect frps `trustedCaFile`. 5. **`manage` bearer + `CAPABILITY_SIGN_PUBKEY_B64`** set (else the verifier is `refuseAll` and no pairing code can be minted). 6. **`FRP_AUTH_TOKEN`** identical on agent side and frps. 7. **nginx :8470 dNSName→Host binding** deployed (njs `getCertSub` + `map` → 403 on mismatch) — the load-bearing isolation control for the device path. 8. **Login/`device:enroll` route** — expected: **absent today**; confirm (defines phone-track start). --- ## 4. TRACK A — Host auto-enroll (small–medium; mostly deploy + wire) **Goal:** fresh Mac → one pairing code → `pair --install` → auto-connect + self-managing cert, reboot-durable. | # | Task | Where | Effort | Notes / risk | |---|---|---|---|---| | A1 | Resolve pre-flight §3.1–3.6 (CA material, CP build, public `/enroll`, frps CA, manage bearer, token) | VPS | S–M | Gated by §3.1 KMS/CA material — the real unknown. | | A2 | Deploy + run the new control-plane in **production mode** (real CA material, PG/Redis, systemd unit) | VPS `control-plane/` | M | Replace the old relay-phase1 CP; keep loopback:8080. | | A3 | nginx vhost proxying `/enroll` `/device/enroll` `/renew` → 127.0.0.1:8080 over the LE wildcard TLS | `deploy/nginx` + VPS | S | Add to existing :443 SNI / a dedicated enroll host. | | A4 | Mint one **pairing code** (`POST /accounts/:id/pairing-codes`, `manage` bearer) | VPS | S | MVP = manually pasted code (RFC 8628 device-grant deferred). | | A5 | **Wire auto-renew into the native run-loop** — call `createCertRotator`/`renewCert` from `superviseNative` | `agent/src` (**only real host code gap**) | S | Logic already built+tested; today `superviseNative` only *monitors* freshness → cert dies at 24h. TDD it. | | A6 | Build the agent (`dist/cli.js`) + set env (`ENROLL_URL`,`TUNNEL_DOMAIN`,`TUNNEL_ZONE=terminal`,`FRP_AUTH_TOKEN`,`PORT`,`BIND_HOST=127.0.0.1`, dummy `RELAY_URL`) | Mac | S | | | A7 | Run `web-terminal-agent pair --install`; verify 2 launchd units + `https://.terminal.yaojia.wang` live via device cert | Mac | S | | | A8 | **Retire manual `mac1`** — bootout `com.webterm.tunnel.{baseapp,frpc}`, remove `frpc.toml`/certs | Mac | S | Only after A7 green. | | A9 | (optional) `machineId` dedup so reinstall keeps the same subdomain | `agent/src`, `control-plane/registry` | S | Deferred; cosmetic (`status` output). | **Deliverable A:** computer start → auto-connect + auto-managed/renewing cert; one pairing code at first setup, silent thereafter. **Blocking risk = §3.1 (production CA material).** --- ## 5. TRACK B — Phone auto-enroll (build-heavy; server hole first) **Goal:** phone → one login → device cert auto-issued to hardware key → connect, no `.p12`, ever. | # | Task | Where | Effort | Notes / risk | |---|---|---|---|---| | B1 | **Build + deploy the CP login route + `device:enroll` bearer mint** (`auth/session.ts mintDeviceEnrollToken`, add `/login` or `/auth`, add `enroll` to `CapabilityRightSchema`) | `control-plane/src/auth` | M | **HARD BLOCKER — do first; nothing in the phone flow is reachable without a bearer.** Even a single-operator credential seam unblocks it (full OIDC later). | | B2 | Bridge: after login, mint bearer + scope to the account's subdomain(s) (deny-by-default already enforced by CP) | `control-plane` | S | | | B3 | **iOS**: wire `KeychainClientIdentityStore.enroll` into an enrollment screen (login → bearer → CSR → `/device/enroll` → store SE identity → present on existing mTLS path) + rotation scheduler | `ios/` | M | Library exists + unit-tested; this is UI + flow wiring. | | B4 | **Android**: build the enroll library from scratch (StrongBox keygen + CSR + `/device/enroll` client) + present via existing `X509KeyManager` | `android/` | L | Mirror the iOS ClientTLS package; no existing code. | | B5 | (optional) scan-to-enroll: make the pairing QR carry a `device:enroll` credential (QR & device-enroll are disjoint today) | app + CP `pairing` | M | UX nicety; not required for correctness. | | B6 | Desktop (Electron) programmatic OS-keychain install | `desktop/src` | M | **Deferred** — Chromium only reads certs from the OS store; today assumes pre-install. | **Deliverable B:** phone → login once → connects with an auto-issued, hardware-bound, auto-renewing cert. --- ## 6. Deliberately deferred (layer on later, no rework) Attestation verifiers (Apple App Attest / Android Key-Attestation) — mandatory only before a 2nd distrusting tenant · signed X.509 **CRL** + VPS reload webhook (MVP relies on 24h passive revocation) · **B2** per-tenant name-constrained intermediates (gate for a 2nd customer) · RFC 8628 device-grant (approve-in-app) wrapping the same `/enroll` · Windows service · agent autoupdate · legacy `.p12` dual-trust migration window. --- ## 7. Suggested order & milestones 1. **Pre-flight §3** (esp. §3.1 CA material) — a spike; decides whether Track A is "deploy" or "also build a signer." 2. **Track A (A1–A8)** → **M-Host**: fresh Mac, one command, reboot-durable, self-renewing. *Ships the host half of the requirement.* 3. **B1 (login/bearer)** → unblocks everything phone-side. 4. **B3 (iOS)** → **M-Device-iOS**: iPhone auto-enrolls, no `.p12`. 5. **B4 (Android)** → **M-Device-Android**. 6. Later: attestation → CRL → B2 (per-tenant) = gate for a 2nd distrusting customer. ## 8. Top risks - **[R1] Production native-CA wiring in the new CP (§3.1)** — **RE-ASSESSED 2026-07-18: a real (small–medium) CODE task, the hard prerequisite for the VPS deploy.** The P-256 CA keys + certs exist on the VPS (`/etc/relay/{device-ca,frp-client-ca}/*.key.pem`), BUT: (1) `env.ts` does **not** define `NATIVE_FRP_CLIENT_CA_*` / `NATIVE_DEVICE_CA_*` (native-ca.ts:16 flags this as the "concrete follow-up"); (2) `buildNativeCas` exists but is **never called from `server.ts`** — the prod boot doesn't wire the native CAs at all (the 246 tests inject dev `inProcessP256CaSigner`s); (3) production wants a KMS resolver and there is no file-backed signer to load the on-disk PEM keys. **Task A2-prep (do before deploy):** add the `NATIVE_*` env vars + a file-backed `KmsResolver` (load PEM P-256 key → sign) + call `buildNativeCas` in `server.ts` and thread the CAs into the enroll services, TDD + reviewed. File-key custody (not KMS) is acceptable for the single-owner VPS (mirrors the existing `CA_INTERMEDIATE_KEY_PATH` file fallback); document the KMS upgrade as future. - **[R2] frps control-channel CA mismatch** — must be `frp-client-CA`, not `device-CA`; crossed = silent 1006/handshake failure (this class of bug already cost days in M1). - **[R3] nginx dNSName→Host binding** — the load-bearing isolation control; needs positive **and** negative tests (leaf-A must be refused for subdomain-B) even in MVP. - **[R4] Phone login trust seam (B1)** — the whole phone track hinges on a credential mechanism; a weak seam here is a security hole (it mints certs). Scope it deliberately even for single-operator. - **[R5] BIND_HOST loopback** — mandatory on every host; `0.0.0.0` re-opens an unauth shell on the LAN bypassing mTLS. The agent enforces it (S-GATE) — keep it enforced. --- _Grounded against `develop` + VPS `8.138.1.192` as of 2026-07-18. Design source: `docs/PLAN_TUNNEL_AUTOMATION.md`._