/** * Pinned frpc binary provisioner — TASK B3 (PLAN_TUNNEL_AUTOMATION §3.2 / §5 supply-chain). * * Mirrors the `dist/buildBinary.ts` verify-download discipline: detect OS/arch, select a PINNED * release `{ version, url, sha256 }`, download the artifact TO DISK (a temp file), verify its * SHA-256 against the pin BEFORE the binary is placed or executed, then place it atomically * (temp -> rename) with the exec bit. On hash mismatch NOTHING is placed and the temp is removed. * * Non-negotiable (§5): never `curl | sh` a streamed secret, never disable TLS verification (no `-k`) * — the default fetch enforces `https:` and there is no insecure escape hatch. * * frp's GitHub releases ship a `.tar.gz` whose payload is `frp___/{frpc,frps,...}` — * a host cannot exec a `.tar.gz`. So AFTER the archive hash matches its pin (and only then) the bytes * are handed to the path-traversal-hardened extractor in `untar.ts`, which pulls out ONLY the inner * `frpc`; that verified binary is what gets placed. Extraction failure (no `frpc`, corrupt gzip/tar, * unsafe entry name, oversize) places nothing and throws `FrpcProvisionError`. * * The network fetch and the filesystem are INJECTABLE seams (`ProvisionFrpcDeps`) so tests exercise * URL/arch selection, hash-match extraction+placement, hash-mismatch rejection, extraction failures, * and unsupported-platform errors with no network access. */ import { createHash, randomBytes } from 'node:crypto' import { chmod, mkdir, rename, rm, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { extractFrpcBinary } from './untar.js' export type FrpcPlatform = 'darwin-arm64' | 'darwin-amd64' | 'linux-arm64' | 'linux-amd64' export const FRPC_PLATFORMS: readonly FrpcPlatform[] = [ 'darwin-arm64', 'darwin-amd64', 'linux-arm64', 'linux-amd64', ] /** A pinned frpc release artifact for one platform. `sha256` is lowercase hex over the artifact. */ export interface FrpcReleaseRef { readonly version: string readonly url: string readonly sha256: string } const FRPC_VERSION = '0.61.1' const RELEASE_BASE = `https://github.com/fatedier/frp/releases/download/v${FRPC_VERSION}` /** * The pinned release map. URLs point at the real frp v0.61.1 assets and the `sha256` fields are the * REAL published digests from `frp_sha256_checksums.txt` (verified against the downloaded * darwin_arm64 archive on 2026-07-09). To bump frp: update `FRPC_VERSION` and paste the new digests * from that release's checksum file. Tests inject their own release map. */ export const FRPC_RELEASES: Readonly> = { 'darwin-arm64': { version: FRPC_VERSION, url: `${RELEASE_BASE}/frp_${FRPC_VERSION}_darwin_arm64.tar.gz`, sha256: '3e65f13a17a284bd6013e6bb6856bc2720074cea6094cc446c1f4c3932154c2d', }, 'darwin-amd64': { version: FRPC_VERSION, url: `${RELEASE_BASE}/frp_${FRPC_VERSION}_darwin_amd64.tar.gz`, sha256: '403a0ee5e92f083a863d984b7af1e9d70ba2aaa28e87f42f1fe085adf76b8491', }, 'linux-arm64': { version: FRPC_VERSION, url: `${RELEASE_BASE}/frp_${FRPC_VERSION}_linux_arm64.tar.gz`, sha256: 'af6366f2b43920ebfe6235dba6060770399ed1fb18601e5818552bd46a7621f8', }, 'linux-amd64': { version: FRPC_VERSION, url: `${RELEASE_BASE}/frp_${FRPC_VERSION}_linux_amd64.tar.gz`, sha256: 'bff260b68ca7b1461182a46c4f34e9709ba32764eed30a15dd94ac97f50a2c40', }, } /** Node `os.arch()` values mapped to frp's arch tokens. */ const ARCH_MAP: Readonly> = { arm64: 'arm64', x64: 'amd64', } const SUPPORTED_OS: ReadonlySet = new Set(['darwin', 'linux']) const BIN_NAME = 'frpc' const TMP_NAME = 'frpc.download.tmp' const EXEC_MODE = 0o755 const DIR_MODE = 0o700 /** Map `os.platform()` + `os.arch()` to a supported `FrpcPlatform`, or `null` if unsupported. */ export function detectFrpcPlatform(platform: string, arch: string): FrpcPlatform | null { const mappedArch = ARCH_MAP[arch] if (!SUPPORTED_OS.has(platform) || mappedArch === undefined) return null const key = `${platform}-${mappedArch}` as FrpcPlatform return FRPC_PLATFORMS.includes(key) ? key : null } /** Injectable network fetch: returns the artifact bytes for `url`. */ export type FrpcFetch = (url: string) => Promise /** Injectable filesystem seam (all async, mirrors `node:fs/promises`). */ export interface FrpcFsDeps { mkdir(dir: string): Promise writeFile(path: string, data: Uint8Array): Promise rename(from: string, to: string): Promise chmod(path: string, mode: number): Promise rm(path: string): Promise } export interface ProvisionFrpcDeps { readonly fetch: FrpcFetch readonly fs: FrpcFsDeps /** Override the digest function (default: node:crypto SHA-256, lowercase hex). */ readonly computeSha256?: (data: Uint8Array) => string } export interface ProvisionFrpcOptions { /** `os.platform()` (e.g. `'darwin'`, `'linux'`). */ readonly platform: string /** `os.arch()` (e.g. `'arm64'`, `'x64'`). */ readonly arch: string /** Directory the verified `frpc` binary is placed into. */ readonly binDir: string /** Release map to select from; defaults to the pinned `FRPC_RELEASES`. */ readonly releases?: Readonly> } export interface ProvisionResult { readonly binPath: string readonly version: string readonly platform: FrpcPlatform } /** A verify-download failure (unsupported platform, fetch error, or integrity mismatch). */ export class FrpcProvisionError extends Error { constructor(message: string) { super(message) this.name = 'FrpcProvisionError' } } function defaultSha256(data: Uint8Array): string { return createHash('sha256').update(data).digest('hex') } /** Default HTTPS fetch — enforces `https:` (never `-k`, never plain http) and a 2xx status. */ async function defaultFetch(url: string): Promise { let parsed: URL try { parsed = new URL(url) } catch { throw new FrpcProvisionError(`invalid frpc download URL: ${url}`) } if (parsed.protocol !== 'https:') { throw new FrpcProvisionError(`frpc download refuses non-https URL: ${url}`) } let res: Response try { res = await fetch(url) } catch (err: unknown) { // Surface transport failures (DNS, refused, TLS) through the SAME typed error as every other // failure path, so callers (e.g. the autoupdate rollback path) can pattern-match uniformly. throw new FrpcProvisionError( `frpc download failed: ${err instanceof Error ? err.message : 'network error'} for ${url}`, ) } if (!res.ok) { throw new FrpcProvisionError(`frpc download failed: HTTP ${res.status} for ${url}`) } return new Uint8Array(await res.arrayBuffer()) } const defaultFsDeps: FrpcFsDeps = { mkdir: async (dir) => { await mkdir(dir, { recursive: true, mode: DIR_MODE }) }, writeFile: async (path, data) => { await writeFile(path, data, { mode: 0o600 }) }, rename: async (from, to) => { await rename(from, to) }, chmod: async (path, mode) => { await chmod(path, mode) }, rm: async (path) => { await rm(path, { force: true }) }, } function defaultDeps(): ProvisionFrpcDeps { return { fetch: defaultFetch, fs: defaultFsDeps, computeSha256: defaultSha256 } } /** * Download + verify + extract + place the pinned frpc binary for the current platform. * * Order (verify-before-extract-before-exec): detect platform -> select pin -> fetch archive -> * write the unverified archive to a per-invocation temp -> SHA-256 verify against the pin. On * mismatch: remove the temp and throw (no extraction, nothing placed). On match: gunzip+untar to * pull ONLY the inner `frpc` (path-traversal-safe), overwrite the temp with that verified binary, * chmod exec, and atomic-rename into place. Any extraction failure removes the temp and throws. * Nothing is ever placed or made executable before the digest matches the pin. */ export async function provisionFrpc( opts: ProvisionFrpcOptions, deps: ProvisionFrpcDeps = defaultDeps(), ): Promise { const platform = detectFrpcPlatform(opts.platform, opts.arch) if (platform === null) { throw new FrpcProvisionError( `unsupported platform for frpc: ${opts.platform}/${opts.arch} (supported: ${FRPC_PLATFORMS.join(', ')})`, ) } const releases = opts.releases ?? FRPC_RELEASES const release = releases[platform] if (release === undefined) { throw new FrpcProvisionError(`no pinned frpc release for platform ${platform}`) } const computeSha256 = deps.computeSha256 ?? defaultSha256 // Per-invocation-unique temp path: two concurrent provisions (e.g. overlapping autoupdate polls) // must never write/rename through the same temp file (TOCTOU). Same path is used for write+rm+rename. const tmpPath = join(opts.binDir, `${TMP_NAME}.${process.pid}.${randomBytes(6).toString('hex')}`) const binPath = join(opts.binDir, BIN_NAME) const bytes = await deps.fetch(release.url) await deps.fs.mkdir(opts.binDir) await deps.fs.writeFile(tmpPath, bytes) const digest = computeSha256(bytes).toLowerCase() const expected = release.sha256.toLowerCase() if (digest !== expected) { await deps.fs.rm(tmpPath) throw new FrpcProvisionError( `frpc SHA-256 mismatch for ${platform}: expected ${expected}, got ${digest} — nothing placed`, ) } // Verified — extract ONLY the inner `frpc` from the archive. The extractor selects by basename and // rejects any unsafe entry name, so nothing derived from the archive can escape binDir. On any // extraction failure the (still-archive) temp is removed and nothing is placed. let frpcBytes: Uint8Array try { frpcBytes = extractFrpcBinary(bytes) } catch (err: unknown) { await deps.fs.rm(tmpPath) const detail = err instanceof Error ? err.message : 'unknown error' throw new FrpcProvisionError( `frpc extraction failed for ${platform}: ${detail} — nothing placed`, ) } // Overwrite the temp with the verified extracted binary, grant exec, then promote atomically. await deps.fs.writeFile(tmpPath, frpcBytes) await deps.fs.chmod(tmpPath, EXEC_MODE) await deps.fs.rename(tmpPath, binPath) return { binPath, version: release.version, platform } }