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>
This commit is contained in:
Yaojia Wang
2026-07-10 16:11:13 +02:00
parent 31054450fc
commit e7f3bd05f0
79 changed files with 9920 additions and 385 deletions

100
agent/test/frpcToml.test.ts Normal file
View File

@@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest'
import { buildNativeFrpcToml, type NativeFrpcOptions } from '../src/transport/frpcToml.js'
const BASE: NativeFrpcOptions = {
subdomain: 'alice',
localPort: 3000,
authToken: 'super-secret-token',
certFile: '/home/alice/.web-terminal-agent/frpc.cert.pem',
keyFile: '/home/alice/.web-terminal-agent/frpc.key.pem',
trustedCaFile: '/home/alice/.web-terminal-agent/frps-ctrl-ca.pem',
}
describe('buildNativeFrpcToml (B2h)', () => {
it('emits the native-tunnel server/port/tls keys (PLAN_NATIVE_TUNNEL §4)', () => {
const toml = buildNativeFrpcToml(BASE)
expect(toml).toContain('serverAddr = "8.138.1.192"')
expect(toml).toContain('serverPort = 443')
expect(toml).toContain('transport.tls.enable = true')
expect(toml).toContain('transport.tls.serverName = "frp.terminal.yaojia.wang"')
expect(toml).toContain('transport.tls.disableCustomTLSFirstByte = true')
expect(toml).toContain(`transport.tls.certFile = "${BASE.certFile}"`)
expect(toml).toContain(`transport.tls.keyFile = "${BASE.keyFile}"`)
expect(toml).toContain(`transport.tls.trustedCaFile = "${BASE.trustedCaFile}"`)
expect(toml).toContain('auth.method = "token"')
expect(toml).toContain('auth.token = "super-secret-token"')
})
it('emits a well-formed [[proxies]] http block bound to loopback', () => {
const toml = buildNativeFrpcToml(BASE)
expect(toml).toContain('[[proxies]]')
expect(toml).toContain('type = "http"')
expect(toml).toContain('subdomain = "alice"')
expect(toml).toContain('localIP = "127.0.0.1"')
expect(toml).toContain('localPort = 3000')
// the proxy block comes after the server/tls preamble
expect(toml.indexOf('[[proxies]]')).toBeGreaterThan(toml.indexOf('serverAddr'))
})
it('does NOT emit the retired v0.8 [common]/tls_enable shape', () => {
const toml = buildNativeFrpcToml(BASE)
expect(toml).not.toContain('[common]')
expect(toml).not.toContain('tls_enable')
})
it('honours a configurable serverAddr (default 8.138.1.192)', () => {
expect(buildNativeFrpcToml(BASE)).toContain('serverAddr = "8.138.1.192"')
expect(buildNativeFrpcToml({ ...BASE, serverAddr: '10.9.8.7' })).toContain(
'serverAddr = "10.9.8.7"',
)
})
it('THROWS when localIP is non-loopback (anti-SSRF hard invariant)', () => {
expect(() => buildNativeFrpcToml({ ...BASE, localIP: '10.0.0.5' })).toThrow(/loopback/i)
expect(() => buildNativeFrpcToml({ ...BASE, localIP: '0.0.0.0' })).toThrow(/loopback/i)
expect(() => buildNativeFrpcToml({ ...BASE, localIP: '192.168.1.9' })).toThrow(/loopback/i)
})
it('accepts the three explicit loopback localIP forms', () => {
for (const ip of ['127.0.0.1', '::1', 'localhost']) {
expect(() => buildNativeFrpcToml({ ...BASE, localIP: ip })).not.toThrow()
}
})
it('rejects an empty or non-label-safe subdomain', () => {
expect(() => buildNativeFrpcToml({ ...BASE, subdomain: '' })).toThrow(/subdomain/i)
expect(() => buildNativeFrpcToml({ ...BASE, subdomain: 'has space' })).toThrow(/subdomain/i)
expect(() => buildNativeFrpcToml({ ...BASE, subdomain: '-bad' })).toThrow(/subdomain/i)
expect(() => buildNativeFrpcToml({ ...BASE, subdomain: 'bad-' })).toThrow(/subdomain/i)
expect(() => buildNativeFrpcToml({ ...BASE, subdomain: 'a'.repeat(64) })).toThrow(/subdomain/i)
expect(() => buildNativeFrpcToml({ ...BASE, subdomain: 'a/b' })).toThrow(/subdomain/i)
})
it('rejects an out-of-range or non-integer localPort', () => {
expect(() => buildNativeFrpcToml({ ...BASE, localPort: 0 })).toThrow(/port/i)
expect(() => buildNativeFrpcToml({ ...BASE, localPort: 70000 })).toThrow(/port/i)
expect(() => buildNativeFrpcToml({ ...BASE, localPort: 3000.5 })).toThrow(/port/i)
expect(() => buildNativeFrpcToml({ ...BASE, localPort: -1 })).toThrow(/port/i)
})
it('rejects missing keystore paths and an empty auth token', () => {
expect(() => buildNativeFrpcToml({ ...BASE, certFile: '' })).toThrow(/certFile/i)
expect(() => buildNativeFrpcToml({ ...BASE, keyFile: '' })).toThrow(/keyFile/i)
expect(() => buildNativeFrpcToml({ ...BASE, trustedCaFile: '' })).toThrow(/trustedCaFile/i)
expect(() => buildNativeFrpcToml({ ...BASE, authToken: '' })).toThrow(/token/i)
})
it('escapes backslashes and quotes in Windows-style paths (valid TOML basic string)', () => {
const winCert = 'C:\\Users\\alice\\.web-terminal-agent\\frpc.cert.pem'
const toml = buildNativeFrpcToml({ ...BASE, certFile: winCert })
expect(toml).toContain(
'transport.tls.certFile = "C:\\\\Users\\\\alice\\\\.web-terminal-agent\\\\frpc.cert.pem"',
)
})
it('rejects control characters in paths/token (TOML-injection guard)', () => {
expect(() => buildNativeFrpcToml({ ...BASE, authToken: 'a\nb' })).toThrow()
expect(() => buildNativeFrpcToml({ ...BASE, certFile: 'a\nb' })).toThrow()
expect(() => buildNativeFrpcToml({ ...BASE, keyFile: 'a"b\nq' })).toThrow()
})
})