/** * 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 `.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://.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') }