Files
web-terminal/agent/src/transport/frpcToml.ts
Yaojia Wang e7f3bd05f0 feat(tunnel): zero-touch tunnel enrollment — control-plane PKI, host agent, iOS, nginx isolation
Customers install one command / log in once; hardware-generated keys never leave the
device; CSRs return certs + subdomain; frpc + base-app run as durable services. No .p12,
no manual cert import. Implements the MVP fast-path of docs/PLAN_TUNNEL_AUTOMATION.md.

Control-plane / PKI (control-plane/):
- ca/x509-assembler.ts: single KMS-signed real X.509 issuance primitive (Ed25519 + P-256)
- ca/csr-ec.ts: P-256 PKCS#10 proof-of-possession (verifyCsrPoPEc) + CSR-key routing
- ca/frpclient-issue.ts, ca/device-issue.ts: P-256 frp-client + device leaf signers
- ca/rotate.ts + api/renew.ts: real-X.509 /renew + /device/:id/renew (mTLS current cert)
- registry/devices.ts: device registry + per-account cap/rate-limit
- auth/session.ts: device:enroll capability token mint/verify
- api/device-enroll.ts: POST /device/enroll (ownership-gated, deny-by-default)
- pairing/native-redeem.ts + shared gateAndConsumePairingCode; api/provision.ts native arm
- boot/native-ca.ts + main.ts: wire two P-256 CAs + issuers + routers (dev / KMS fail-fast)

Contracts: relay-contracts enroll right; relay-auth SPIFFE /device/ arm + spiffeIdFor(kind)

Host agent (agent/):
- transport/frpcToml.ts; provision/frpcBinary.ts + untar.ts (verify-download + traversal-safe extract)
- keys P-256 keygen/CSR/loadIdentity; service two-unit install + BIND_HOST loopback S-GATE
- net/loopbackLiteral.ts strict guard; health/probe.ts + transport/frpSupervise.ts; cli pair --install

iOS (ios/Packages/ClientTLS): SecureEnclaveKey + CertificateSigningRequest + DeviceEnrollmentClient
+ Keychain enroll refactor (SecKey/Security.framework end-to-end, avoids the -25300 trap)

Isolation (deploy/nginx): njs/getCertSub.js SAN parser + zone-anchored map -> 403

Verified: 758 tests green (control-plane 246, agent 267, relay-auth 133, relay-contracts 85,
iOS ClientTLS 27), all tsc clean; real nginx+njs docker 403/200/400; Swift CSR accepted by
the real control-plane verifier; frpc extract byte-identical to `tar -xO`. Cross-validation
caught + fixed 5 real defects (1 critical, 4 high). Remaining = infra (KMS, nginx deploy,
VPS frps, physical iPhone) per PROGRESS_LOG runbook.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-10 16:11:13 +02:00

156 lines
5.8 KiB
TypeScript

/**
* Native-tunnel frpc.toml writer — TASK B2h (PLAN_TUNNEL_AUTOMATION §3.2 / PLAN_NATIVE_TUNNEL §4).
*
* Emits the frp v0.61 TOML that a host's `frpc` presents to the VPS:
* - dials `serverAddr:443`, SNI-routed by nginx `ssl_preread` to frps :7000;
* - control-channel mTLS (frp-client cert/key + trusted CA) + shared token;
* - one `[[proxies]]` of `type = "http"` exposing the loopback base app as `<sub>.terminal...`.
*
* SUPERSEDES the retired v0.8 `[common]/tls_enable` shape in `frpScaffold.ts` (do not reuse that
* grammar — this is the native-tunnel writer).
*
* ANTI-SSRF (hard invariant): `localIP` MUST be loopback. frpc forwards ONLY to the local base app,
* never an arbitrary target — a non-loopback `localIP` would turn the tunnel into an open proxy.
* All inputs are validated at this boundary (fail-fast, clear messages); no `console.log`.
*/
const DEFAULT_SERVER_ADDR = '8.138.1.192'
const SERVER_PORT = 443
const TLS_SERVER_NAME = 'frp.terminal.yaojia.wang'
const DEFAULT_LOCAL_IP = '127.0.0.1'
const MIN_PORT = 1
const MAX_PORT = 65535
const MAX_LABEL_LEN = 63
const DEL_CHAR_CODE = 0x7f
const FIRST_PRINTABLE_CODE = 0x20
/** Loopback forms accepted for `localIP` (anti-SSRF allowlist). */
const LOOPBACK_IPS: readonly string[] = ['127.0.0.1', '::1', 'localhost']
/** RFC 1035 DNS label: 1-63 chars, alnum, internal hyphens only (no leading/trailing hyphen). */
const LABEL_RE = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/
export interface NativeFrpcOptions {
/** Subdomain label -> reachable at `https://<subdomain>.terminal.yaojia.wang`. Label-safe. */
readonly subdomain: string
/** Loopback port of the local base app frpc forwards to (1-65535). */
readonly localPort: number
/** Shared frps auth token (kept out of logs; the file itself is chmod 600 by the caller). */
readonly authToken: string
/** Keystore path to this host's frp-client leaf cert (control-channel mTLS). */
readonly certFile: string
/** Keystore path to the matching private key. */
readonly keyFile: string
/** Keystore path to the CA that signed the frps control cert (server verification). */
readonly trustedCaFile: string
/** VPS address; defaults to the deployed relay `8.138.1.192`. */
readonly serverAddr?: string
/** Local forward target IP; MUST be loopback. Defaults to `127.0.0.1`. */
readonly localIP?: string
}
/** A validation failure at the frpc.toml boundary. */
export class FrpcTomlError extends Error {
constructor(message: string) {
super(message)
this.name = 'FrpcTomlError'
}
}
/** True iff `value` contains an ASCII control character (C0 range or DEL). */
function hasControlChar(value: string): boolean {
for (let i = 0; i < value.length; i += 1) {
const code = value.charCodeAt(i)
if (code < FIRST_PRINTABLE_CODE || code === DEL_CHAR_CODE) return true
}
return false
}
/** Non-empty, control-char-free path/secret at the boundary. */
function assertPresent(field: string, value: string): void {
if (typeof value !== 'string' || value.length === 0) {
throw new FrpcTomlError(`${field} is required`)
}
if (hasControlChar(value)) {
throw new FrpcTomlError(`${field} contains control characters`)
}
}
/** Escape a value for a TOML basic (double-quoted) string: backslash + quote (Windows paths). */
function tomlBasicString(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
}
function assertLoopback(localIP: string): void {
if (!LOOPBACK_IPS.includes(localIP)) {
throw new FrpcTomlError(
`localIP "${localIP}" is not loopback — frpc must forward only to the local base app (anti-SSRF)`,
)
}
}
function assertSubdomain(subdomain: string): void {
if (typeof subdomain !== 'string' || subdomain.length === 0) {
throw new FrpcTomlError('subdomain is required')
}
if (subdomain.length > MAX_LABEL_LEN || !LABEL_RE.test(subdomain)) {
throw new FrpcTomlError(
`subdomain "${subdomain}" is not a valid DNS label (alnum + internal hyphens, 1-63 chars)`,
)
}
}
function assertLocalPort(localPort: number): void {
if (!Number.isInteger(localPort) || localPort < MIN_PORT || localPort > MAX_PORT) {
throw new FrpcTomlError(
`localPort must be an integer in ${MIN_PORT}-${MAX_PORT}, got ${localPort}`,
)
}
}
/**
* Build a validated frp v0.61 `frpc.toml` for the native mTLS tunnel. Throws `FrpcTomlError` on any
* invalid input (fail-fast). The returned string is the full config file contents.
*/
export function buildNativeFrpcToml(opts: NativeFrpcOptions): string {
assertSubdomain(opts.subdomain)
assertLocalPort(opts.localPort)
assertPresent('authToken', opts.authToken)
assertPresent('certFile', opts.certFile)
assertPresent('keyFile', opts.keyFile)
assertPresent('trustedCaFile', opts.trustedCaFile)
const serverAddr = opts.serverAddr ?? DEFAULT_SERVER_ADDR
assertPresent('serverAddr', serverAddr)
const localIP = opts.localIP ?? DEFAULT_LOCAL_IP
assertLoopback(localIP)
const lines: readonly string[] = [
`serverAddr = "${tomlBasicString(serverAddr)}"`,
`serverPort = ${SERVER_PORT}`,
'',
'auth.method = "token"',
`auth.token = "${tomlBasicString(opts.authToken)}"`,
'',
'# control-channel mTLS: present this host frp-client cert; verify the frps control cert',
'transport.tls.enable = true',
`transport.tls.serverName = "${TLS_SERVER_NAME}"`,
'transport.tls.disableCustomTLSFirstByte = true',
`transport.tls.certFile = "${tomlBasicString(opts.certFile)}"`,
`transport.tls.keyFile = "${tomlBasicString(opts.keyFile)}"`,
`transport.tls.trustedCaFile = "${tomlBasicString(opts.trustedCaFile)}"`,
'',
'loginFailExit = false',
'',
'[[proxies]]',
`name = "${opts.subdomain}"`,
'type = "http"',
`localIP = "${localIP}"`,
`localPort = ${opts.localPort}`,
`subdomain = "${opts.subdomain}"`,
'',
]
return lines.join('\n')
}