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>
260 lines
10 KiB
TypeScript
260 lines
10 KiB
TypeScript
/**
|
|
* 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_<ver>_<os>_<arch>/{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<Record<FrpcPlatform, FrpcReleaseRef>> = {
|
|
'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<Record<string, 'arm64' | 'amd64'>> = {
|
|
arm64: 'arm64',
|
|
x64: 'amd64',
|
|
}
|
|
const SUPPORTED_OS: ReadonlySet<string> = 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<Uint8Array>
|
|
|
|
/** Injectable filesystem seam (all async, mirrors `node:fs/promises`). */
|
|
export interface FrpcFsDeps {
|
|
mkdir(dir: string): Promise<void>
|
|
writeFile(path: string, data: Uint8Array): Promise<void>
|
|
rename(from: string, to: string): Promise<void>
|
|
chmod(path: string, mode: number): Promise<void>
|
|
rm(path: string): Promise<void>
|
|
}
|
|
|
|
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<Record<FrpcPlatform, FrpcReleaseRef>>
|
|
}
|
|
|
|
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<Uint8Array> {
|
|
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<ProvisionResult> {
|
|
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 }
|
|
}
|