Files
web-terminal/docs/PLAN_ZERO_TOUCH_ROLLOUT.md

166 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

# 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 <CODE> --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 <CODE> --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 = <sub>.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-host>/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 (smallmedium; 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.13.6 (CA material, CP build, public `/enroll`, frps CA, manage bearer, token) | VPS | SM | 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 <CODE> --install`; verify 2 launchd units + `https://<sub>.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 (A1A8)****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 (smallmedium)
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`._