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>
156 lines
5.8 KiB
TypeScript
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')
|
|
}
|