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:
155
agent/src/transport/frpcToml.ts
Normal file
155
agent/src/transport/frpcToml.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* 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')
|
||||
}
|
||||
Reference in New Issue
Block a user