# 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 (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 <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 (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`._
