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:
Yaojia Wang
2026-07-10 16:11:13 +02:00
parent 31054450fc
commit e7f3bd05f0
79 changed files with 9920 additions and 385 deletions

View File

@@ -33,10 +33,20 @@ describe('AgentConfig validation', () => {
expect(isLoopbackWsUrl('ws://127.0.0.1:3000')).toBe(true)
expect(isLoopbackWsUrl('ws://localhost:3000')).toBe(true)
expect(isLoopbackWsUrl('ws://127.5.5.5:3000')).toBe(true)
expect(isLoopbackWsUrl('ws://[::1]:3000')).toBe(true)
expect(isLoopbackWsUrl('ws://10.0.0.5:3000')).toBe(false)
expect(isLoopbackWsUrl('wss://127.0.0.1:3000')).toBe(false)
})
it('REGRESSION: rejects a crafted suffixed-hostname target (anti-SSRF bypass)', () => {
// Hostname, not a loopback literal — the outbound dial would DNS-resolve and connect out.
expect(isLoopbackWsUrl('ws://127.0.0.1.attacker.example.com:3000/x')).toBe(false)
expect(isLoopbackWsUrl('ws://127.evil.net:3000')).toBe(false)
expect(() =>
AgentConfigSchema.parse({ ...base, localTargetUrl: 'ws://127.0.0.1.attacker.example.com:3000' }),
).toThrow()
})
it('loadAgentConfig fails fast on a missing relayUrl', () => {
expect(() => loadAgentConfig({} as NodeJS.ProcessEnv, {})).toThrow()
})

View File

@@ -4,6 +4,7 @@ import type { Keystore } from '../src/keys/keystore.js'
import type { AgentIdentity } from '../src/keys/identity.js'
import type { EnrollResult } from 'relay-contracts'
import { CliUsageError, parseArgs, runCli, type CliDeps } from '../src/cli.js'
import type { InstallOptions } from '../src/service/install.js'
const CFG: AgentConfig = {
relayUrl: 'wss://relay/agent',
@@ -14,8 +15,9 @@ const CFG: AgentConfig = {
hostId: 'h-1',
}
function fakeIdentity(): AgentIdentity {
function fakeIdentity(alg: AgentIdentity['alg'] = 'ed25519'): AgentIdentity {
return {
alg,
publicKey: new Uint8Array(32),
enrollFpr: 'fpr',
sign: () => new Uint8Array(64),
@@ -24,10 +26,10 @@ function fakeIdentity(): AgentIdentity {
}
}
function fakeKeystore(enrolled: boolean): Keystore {
function fakeKeystore(enrolled: boolean, alg: AgentIdentity['alg'] = 'ed25519'): Keystore {
return {
saveIdentity: vi.fn(),
loadIdentity: () => (enrolled ? fakeIdentity() : null),
loadIdentity: () => (enrolled ? fakeIdentity(alg) : null),
saveCert: vi.fn(),
loadCert: () => (enrolled ? { certPem: 'C', caChainPem: 'CA' } : null),
saveContentSecret: vi.fn(),
@@ -35,12 +37,19 @@ function fakeKeystore(enrolled: boolean): Keystore {
}
}
const NATIVE_OPTIONS: InstallOptions = {
env: { BIND_HOST: '127.0.0.1', PORT: '3000' },
domain: 'yaojia.wang',
zone: 'terminal',
}
function deps(overrides: Partial<CliDeps> = {}, enrolled = false): { d: CliDeps; out: string[] } {
const out: string[] = []
const d: CliDeps = {
loadConfig: () => CFG,
openKeystore: () => fakeKeystore(enrolled),
generateIdentity: fakeIdentity,
generateIdentity: () => fakeIdentity('ed25519'),
generateP256Identity: () => fakeIdentity('p256'),
redeem: async (): Promise<EnrollResult> => ({
hostId: 'h-1',
subdomain: 'host-42',
@@ -48,8 +57,13 @@ function deps(overrides: Partial<CliDeps> = {}, enrolled = false): { d: CliDeps;
caChain: 'CA',
hostContentSecret: new Uint8Array([1]),
}),
enrollNative: async () => ({ hostId: 'h-1', subdomain: 'host-42' }),
provisionFrpc: async () => '/opt/frpc',
writeFrpcConfig: vi.fn(),
nativeConfigExists: () => false,
runTunnel: async () => 0,
resolveInstallOptions: () => ({ env: { BIND_HOST: '127.0.0.1' } }),
superviseFrpc: async () => 0,
resolveInstallOptions: () => NATIVE_OPTIONS,
installService: vi.fn(async () => {}),
uninstallService: vi.fn(async () => {}),
print: (l) => out.push(l),
@@ -80,7 +94,7 @@ describe('parseArgs (T5)', () => {
})
})
describe('runCli (T5)', () => {
describe('runCli — legacy relay pair (no --install)', () => {
it('pair happy path calls redeem and prints no secrets', async () => {
const { d, out } = deps()
const code = await runCli(parseArgs(['pair', 'ABCD']), d)
@@ -88,23 +102,97 @@ describe('runCli (T5)', () => {
expect(out.join('\n')).toContain('host-42')
expect(out.join('\n')).not.toContain('PEM')
})
})
it('pair --install installs the service with the resolved options', async () => {
const options = { env: { BIND_HOST: '127.0.0.1', PORT: '3000' } }
const install = vi.fn(async () => {})
const { d } = deps({ resolveInstallOptions: () => options, installService: install })
await runCli(parseArgs(['pair', 'ABCD', '--install']), d)
expect(install).toHaveBeenCalledOnce()
expect(install).toHaveBeenCalledWith(CFG, options)
describe('runCli — native pair --install onboard (B5)', () => {
it('wires keygen(P-256)→enroll→provisionFrpc→write toml+env→install both→print URL, in order', async () => {
const calls: string[] = []
const enrolledIds: AgentIdentity[] = []
const generateP256Identity = vi.fn(() => {
calls.push('keygen')
return fakeIdentity('p256')
})
const enrollNative = vi.fn(async (_cfg: AgentConfig, _code: string, id: AgentIdentity) => {
calls.push('enroll')
enrolledIds.push(id)
return { hostId: 'h-1', subdomain: 'host-42' }
})
const provisionFrpc = vi.fn(async () => {
calls.push('provision')
return '/opt/frpc'
})
const writeFrpcConfig = vi.fn(() => {
calls.push('writeConfig')
})
const installService = vi.fn(async () => {
calls.push('install')
})
const { d, out } = deps({
generateP256Identity,
enrollNative,
provisionFrpc,
writeFrpcConfig,
installService,
})
const code = await runCli(parseArgs(['pair', 'ABCD-1234', '--install']), d)
expect(code).toBe(0)
expect(calls).toEqual(['keygen', 'enroll', 'provision', 'writeConfig', 'install'])
// CSR is built from a P-256 identity (FIX H-host-2)
expect(enrolledIds[0]!.alg).toBe('p256')
// enroll seam received the pairing code
expect(enrollNative).toHaveBeenCalledWith(CFG, 'ABCD-1234', expect.anything(), expect.anything())
// both units installed with the resolved options (env routed to base-app by installService)
expect(installService).toHaveBeenCalledWith(CFG, NATIVE_OPTIONS)
// frpc.toml written for the returned subdomain
expect(writeFrpcConfig).toHaveBeenCalledWith(CFG, 'host-42')
// prints the final tunnel URL
expect(out.join('\n')).toContain('https://host-42.terminal.yaojia.wang')
})
it('does NOT use the legacy relay redeem path on --install', async () => {
const redeem = vi.fn()
const { d } = deps({ redeem: redeem as unknown as CliDeps['redeem'] })
await runCli(parseArgs(['pair', 'ABCD', '--install']), d)
expect(redeem).not.toHaveBeenCalled()
})
it('saves the freshly generated P-256 identity before enrolling', async () => {
const ks = fakeKeystore(false)
const { d } = deps({ openKeystore: () => ks })
await runCli(parseArgs(['pair', 'ABCD', '--install']), d)
expect(ks.saveIdentity).toHaveBeenCalled()
})
it('rejects a native install whose zone is not `terminal` (FIX L-host-zone)', async () => {
const { d } = deps({
resolveInstallOptions: () => ({ env: { BIND_HOST: '127.0.0.1' }, domain: 'yaojia.wang', zone: 'term' }),
})
await expect(runCli(parseArgs(['pair', 'ABCD', '--install']), d)).rejects.toThrow(/terminal/)
})
it('rejects a native install with no TUNNEL_DOMAIN (cannot form the origin)', async () => {
const { d } = deps({ resolveInstallOptions: () => ({ env: { BIND_HOST: '127.0.0.1' } }) })
await expect(runCli(parseArgs(['pair', 'ABCD', '--install']), d)).rejects.toBeInstanceOf(CliUsageError)
})
it('prints no key/cert material during --install (INV9)', async () => {
const { d, out } = deps()
await runCli(parseArgs(['pair', 'ABCD', '--install']), d)
const joined = out.join('\n')
expect(joined).not.toContain('PEM')
expect(joined).not.toContain('CA')
})
})
describe('runCli — install / run / status', () => {
it('install threads the resolved InstallOptions into installService (S2 env injection)', async () => {
const options = { env: { BIND_HOST: '127.0.0.1' }, domain: 'yaojia.wang', zone: 'terminal' }
const install = vi.fn(async () => {})
const { d } = deps({ resolveInstallOptions: () => options, installService: install })
const { d } = deps({ resolveInstallOptions: () => NATIVE_OPTIONS, installService: install })
const code = await runCli(parseArgs(['install']), d)
expect(code).toBe(0)
expect(install).toHaveBeenCalledWith(CFG, options)
expect(install).toHaveBeenCalledWith(CFG, NATIVE_OPTIONS)
})
it('run before pairing fails fast', async () => {
@@ -112,6 +200,48 @@ describe('runCli (T5)', () => {
await expect(runCli({ command: 'run', flags: {} }, d)).rejects.toBeInstanceOf(CliUsageError)
})
it('legacy run (Ed25519 identity, no frpc.toml) drives runTunnel — not frpc supervision', async () => {
const runTunnel = vi.fn(async () => 0)
const superviseFrpc = vi.fn(async () => 0)
const { d } = deps(
{ runTunnel, superviseFrpc, nativeConfigExists: () => false },
true, // enrolled with the default Ed25519 identity
)
const code = await runCli({ command: 'run', flags: {} }, d)
expect(code).toBe(0)
expect(runTunnel).toHaveBeenCalledWith(CFG, expect.anything())
expect(superviseFrpc).not.toHaveBeenCalled()
})
it('native run (P-256 identity + frpc.toml) supervises frpc — not the legacy relay', async () => {
const runTunnel = vi.fn(async () => 0)
const superviseFrpc = vi.fn(async () => 0)
const { d } = deps({
openKeystore: () => fakeKeystore(true, 'p256'),
nativeConfigExists: () => true,
runTunnel,
superviseFrpc,
})
const code = await runCli({ command: 'run', flags: {} }, d)
expect(code).toBe(0)
expect(superviseFrpc).toHaveBeenCalledWith(CFG, expect.anything())
expect(runTunnel).not.toHaveBeenCalled()
})
it('native identity but missing frpc.toml falls back to the legacy relay path', async () => {
const runTunnel = vi.fn(async () => 0)
const superviseFrpc = vi.fn(async () => 0)
const { d } = deps({
openKeystore: () => fakeKeystore(true, 'p256'),
nativeConfigExists: () => false,
runTunnel,
superviseFrpc,
})
await runCli({ command: 'run', flags: {} }, d)
expect(runTunnel).toHaveBeenCalled()
expect(superviseFrpc).not.toHaveBeenCalled()
})
it('status prints no key/cert material (INV9)', async () => {
const { d, out } = deps({}, true)
await runCli({ command: 'status', flags: {} }, d)

View File

@@ -1,8 +1,52 @@
import { describe, expect, it } from 'vitest'
import { X509Certificate } from 'node:crypto'
import { generateIdentity } from '../src/keys/identity.js'
import { X509Certificate, createPublicKey, verify } from 'node:crypto'
import { generateIdentity, generateP256Identity } from '../src/keys/identity.js'
import { buildCsr } from '../src/enroll/csr.js'
// --- minimal DER reader (test-only) — walks the PKCS#10 outer SEQUENCE into its 3 children -------
interface Tlv {
readonly tag: number
/** the full tag+length+value bytes (what was signed, for the CertificationRequestInfo). */
readonly tlv: Uint8Array
readonly content: Uint8Array
}
function readTlv(buf: Uint8Array, off: number): { node: Tlv; next: number } {
const tag = buf[off]!
let i = off + 1
const first = buf[i]!
let len: number
if (first < 0x80) {
len = first
i += 1
} else {
const n = first & 0x7f
len = 0
for (let k = 0; k < n; k++) len = (len << 8) | buf[i + 1 + k]!
i += 1 + n
}
return { node: { tag, tlv: buf.subarray(off, i + len), content: buf.subarray(i, i + len) }, next: i + len }
}
function pemToDer(pem: string): Uint8Array {
const b64 = pem.replace(/-----[A-Z ]+-----/g, '').replace(/\s+/g, '')
return new Uint8Array(Buffer.from(b64, 'base64'))
}
/** The three children of the PKCS#10 outer SEQUENCE: [certificationRequestInfo, sigAlg, signature]. */
function csrChildren(pem: string): readonly Tlv[] {
const der = pemToDer(pem)
const outer = readTlv(der, 0).node
const children: Tlv[] = []
let p = 0
while (p < outer.content.length) {
const { node, next } = readTlv(outer.content, p)
children.push(node)
p = next
}
return children
}
describe('PKCS#10 CSR (T4)', () => {
it('emits a PEM CERTIFICATE REQUEST', () => {
const csr = buildCsr(generateIdentity(), 'host-42.term.example.com')
@@ -30,3 +74,50 @@ describe('PKCS#10 CSR (T4)', () => {
expect(typeof X509Certificate).toBe('function')
})
})
describe('P-256 PKCS#10 CSR (FIX H-host-2)', () => {
it('emits a PEM CERTIFICATE REQUEST for a P-256 identity', () => {
const csr = buildCsr(generateP256Identity(), 'alice.terminal.yaojia.wang')
expect(csr).toContain('-----BEGIN CERTIFICATE REQUEST-----')
expect(csr).toContain('-----END CERTIFICATE REQUEST-----')
expect(csr).not.toContain('PRIVATE KEY')
})
it('round-trips as a valid PKCS#10: signatureAlgorithm is ecdsa-with-SHA256', () => {
const csr = buildCsr(generateP256Identity(), 'alice.terminal.yaojia.wang')
const [, sigAlg] = csrChildren(csr)
// sigAlg = SEQUENCE { OID 1.2.840.10045.4.3.2 } (no parameters, RFC 5758 §3.2)
const oidTlv = readTlv(sigAlg!.content, 0).node
expect(Array.from(oidTlv.content)).toEqual([0x2a, 0x86, 0x48, 0xce, 0x3d, 0x04, 0x03, 0x02])
// no parameters: the sigAlg SEQUENCE holds ONLY the OID.
expect(oidTlv.tlv.length).toBe(sigAlg!.content.length)
})
it('self-signature verifies over the CertificationRequestInfo (verifyCsrPoPEc semantics)', () => {
const id = generateP256Identity()
const csr = buildCsr(id, 'alice.terminal.yaojia.wang')
const [reqInfo, , sigVal] = csrChildren(csr)
// signatureValue BIT STRING content = 0x00 (unused bits) || DER ECDSA-Sig-Value.
expect(sigVal!.tag).toBe(0x03)
const signature = sigVal!.content.subarray(1)
const pub = createPublicKey({ key: Buffer.from(id.publicKey), format: 'der', type: 'spki' })
// Verify over the EXACT CertificationRequestInfo bytes that buildCsr signed.
expect(verify('sha256', reqInfo!.tlv, pub, signature)).toBe(true)
// Tampering with the signed body breaks PoP.
const tampered = Uint8Array.from(reqInfo!.tlv)
tampered[tampered.length - 1] = tampered[tampered.length - 1]! ^ 0xff
expect(verify('sha256', tampered, pub, signature)).toBe(false)
})
it('embeds the identity EC SPKI verbatim as the CSR subjectPublicKeyInfo', () => {
const id = generateP256Identity()
const csr = buildCsr(id, 'alice.terminal.yaojia.wang')
const [reqInfo] = csrChildren(csr)
// requestInfo = SEQUENCE { version, name, spki, [0] attributes }; the spki is the 3rd child.
const inner = reqInfo!.content
const version = readTlv(inner, 0)
const name = readTlv(inner, version.next)
const spki = readTlv(inner, name.next).node
expect(Buffer.from(spki.tlv).equals(Buffer.from(id.publicKey))).toBe(true)
})
})

103
agent/test/deps.test.ts Normal file
View File

@@ -0,0 +1,103 @@
/**
* B4/H4 wiring test — closes the frpc-log capture loop that `HealthReport.healthy` depends on.
*
* Regression guarded: nothing used to WRITE `<stateDir>/frpc.log`, so `readFrpcLog` always returned
* '' and `frpcProxyStarted` was permanently false — health could never be true. This exercises the
* real production path end-to-end: `createFileLoggingSpawn` (the spawn `superviseNative` injects)
* tees a child's stdout into the exact file `readFrpcLog` scans, and `frpcProxyStarted` detects the
* "start proxy success" line. Writer path and reader path share `frpcLogPath`, so they can't diverge.
*/
import { afterEach, describe, expect, it } from 'vitest'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createFileLoggingSpawn, type FrpcChild } from '../src/transport/frpSupervise.js'
import { frpcLogPath, readFrpcLog } from '../src/cli/deps.js'
import { frpcProxyStarted } from '../src/health/probe.js'
const SUCCESS_LINE = '[web-terminal] start proxy success'
/** Poll `predicate` until true or `timeoutMs` elapses (real IO flush is async). */
async function waitFor(predicate: () => boolean, timeoutMs = 5000): Promise<boolean> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (predicate()) return true
await new Promise((r) => setTimeout(r, 25))
}
return predicate()
}
/** Write an executable fake `frpc` (node shebang) that repeatedly prints the success line to stdout. */
function writeFakeFrpc(dir: string): string {
const bin = join(dir, 'fake-frpc.mjs')
const script =
`#!${process.execPath}\n` +
`process.stdout.write(${JSON.stringify(`${SUCCESS_LINE}\n`)})\n` +
`setInterval(() => process.stdout.write(${JSON.stringify(`${SUCCESS_LINE}\n`)}), 100)\n`
writeFileSync(bin, script, { mode: 0o755 })
return bin
}
describe('B4/H4 frpc-log capture wiring (createFileLoggingSpawn ↔ readFrpcLog)', () => {
const dirs: string[] = []
let child: FrpcChild | null = null
afterEach(() => {
child?.kill()
child = null
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true })
})
it('tees the frpc child stdout into the file readFrpcLog scans → proxyStarted becomes true', async () => {
const dir = mkdtempSync(join(tmpdir(), 'frpclog-'))
dirs.push(dir)
// Before any child runs, the log is empty and the proxy is not started.
expect(readFrpcLog(dir)).toBe('')
expect(frpcProxyStarted(readFrpcLog(dir))).toBe(false)
// Spawn via the SAME factory superviseNative injects, pointed at the SAME path it reads.
const bin = writeFakeFrpc(dir)
const spawn = createFileLoggingSpawn(frpcLogPath(dir))
child = spawn(bin, join(dir, 'frpc.toml'))
const detected = await waitFor(() => frpcProxyStarted(readFrpcLog(dir)))
expect(detected).toBe(true)
expect(readFrpcLog(dir)).toContain('start proxy success')
})
it('createFileLoggingSpawn truncates a stale log so a dead childs success line is not reused', async () => {
const dir = mkdtempSync(join(tmpdir(), 'frpclog-'))
dirs.push(dir)
// Simulate a leftover log from a previous (now dead) frpc that had succeeded.
writeFileSync(frpcLogPath(dir), `${SUCCESS_LINE}\n`)
expect(frpcProxyStarted(readFrpcLog(dir))).toBe(true)
// A fresh spawn whose child never prints the line must NOT keep reporting the stale success.
const bin = join(dir, 'silent-frpc.mjs')
writeFileSync(bin, `#!${process.execPath}\nsetInterval(() => {}, 100)\n`, { mode: 0o755 })
const spawn = createFileLoggingSpawn(frpcLogPath(dir))
child = spawn(bin, join(dir, 'frpc.toml'))
// Truncate-on-spawn clears the stale line; the silent child adds none.
const cleared = await waitFor(() => readFrpcLog(dir) === '')
expect(cleared).toBe(true)
expect(frpcProxyStarted(readFrpcLog(dir))).toBe(false)
})
})
describe('frpcLogPath / readFrpcLog (deterministic)', () => {
it('frpcLogPath joins frpc.log under the state dir', () => {
expect(frpcLogPath('/state/x')).toBe(join('/state/x', 'frpc.log'))
})
it('readFrpcLog returns "" when the log file does not exist', () => {
const dir = mkdtempSync(join(tmpdir(), 'frpclog-'))
try {
expect(readFrpcLog(dir)).toBe('')
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
})

View File

@@ -0,0 +1,148 @@
import { describe, expect, it, vi } from 'vitest'
import { createLogger } from '../src/log/logger.js'
import { createBackoff } from '../src/transport/backoff.js'
import {
STABLE_RUN_MS,
superviseFrpc,
type FrpcChild,
type SpawnFrpc,
} from '../src/transport/frpSupervise.js'
const silentLogger = createLogger('error', () => {})
/**
* A fake frpc child whose exit is driven from the test. `kill()` models a real process: it dies,
* firing the exit handler (so the supervisor's `stop()` — which kills the live child — can unblock).
* exit/kill fire the handler at most once.
*/
function makeChild(): { child: FrpcChild; exit: (code: number | null) => void; killed: boolean } {
let onExit: ((code: number | null) => void) | null = null
let alive = true
const state = { killed: false }
const fire = (code: number | null): void => {
if (!alive) return
alive = false
onExit?.(code)
}
return {
child: {
onExit: (cb) => {
onExit = cb
},
isAlive: () => alive,
kill: () => {
state.killed = true
fire(null)
},
},
exit: (code) => fire(code),
get killed() {
return state.killed
},
}
}
describe('superviseFrpc (B4/H4 — restart-on-exit backoff)', () => {
it('spawns the frpc binary with -c <toml> via the child seam', async () => {
const spawn: SpawnFrpc = vi.fn(() => makeChild().child)
superviseFrpc('/opt/agent/bin/frpc', '/state/frpc.toml', {
spawn,
sleep: async () => {},
logger: silentLogger,
})
await Promise.resolve()
expect(spawn).toHaveBeenCalledWith('/opt/agent/bin/frpc', '/state/frpc.toml')
})
it('restarts the child on exit, backing off 1s → 2s → 4s', async () => {
const children = [makeChild(), makeChild(), makeChild(), makeChild()]
let n = 0
const spawn: SpawnFrpc = () => children[n++]!.child
const sleeps: number[] = []
// now() fixed so no run counts as "stable" ⇒ backoff monotonically increases.
const handle = superviseFrpc('/frpc', '/toml', {
spawn,
backoff: createBackoff(),
sleep: async (ms) => {
sleeps.push(ms)
},
logger: silentLogger,
now: () => 1000,
})
// Crash three times; each crash schedules the next spawn after the growing backoff.
for (let i = 0; i < 3; i += 1) {
children[i]!.exit(1)
await Promise.resolve()
await Promise.resolve()
}
expect(sleeps).toEqual([1000, 2000, 4000])
await handle.stop()
})
it('resets the backoff after a run that stayed up past the stability window', async () => {
const children = [makeChild(), makeChild(), makeChild()]
let n = 0
const spawn: SpawnFrpc = () => children[n++]!.child
const sleeps: number[] = []
let clock = 0
const handle = superviseFrpc('/frpc', '/toml', {
spawn,
backoff: createBackoff(),
sleep: async (ms) => {
sleeps.push(ms)
},
logger: silentLogger,
now: () => clock,
})
// First run crashes instantly ⇒ backoff 1s.
children[0]!.exit(1)
await Promise.resolve()
await Promise.resolve()
// Second run stays up past STABLE_RUN_MS before dying ⇒ backoff resets to 1s (not 2s).
clock += STABLE_RUN_MS + 1
children[1]!.exit(1)
await Promise.resolve()
await Promise.resolve()
expect(sleeps).toEqual([1000, 1000])
await handle.stop()
})
it('stop() halts the loop and kills the live child; done resolves 0', async () => {
const c = makeChild()
const spawn: SpawnFrpc = () => c.child
const handle = superviseFrpc('/frpc', '/toml', {
spawn,
sleep: async () => {},
logger: silentLogger,
})
await Promise.resolve()
expect(handle.isChildAlive()).toBe(true)
// stop() kills the child; that fires the exit handler and the loop observes `stopped` → breaks.
const stopping = handle.stop()
c.exit(null)
await expect(stopping).resolves.toBeUndefined()
await expect(handle.done).resolves.toBe(0)
expect(c.killed).toBe(true)
})
it('does not restart after stop (no spawn past shutdown)', async () => {
const first = makeChild()
let n = 0
const spawn: SpawnFrpc = vi.fn(() => (n++ === 0 ? first.child : makeChild().child))
const handle = superviseFrpc('/frpc', '/toml', {
spawn,
sleep: async () => {},
logger: silentLogger,
})
await Promise.resolve()
const stopping = handle.stop()
first.exit(0)
await stopping
expect(spawn).toHaveBeenCalledTimes(1)
})
})

View File

@@ -0,0 +1,416 @@
import { describe, expect, it } from 'vitest'
import { createHash } from 'node:crypto'
import { gzipSync } from 'node:zlib'
import {
detectFrpcPlatform,
FRPC_PLATFORMS,
FRPC_RELEASES,
provisionFrpc,
type FrpcPlatform,
type FrpcReleaseRef,
type ProvisionFrpcDeps,
} from '../src/provision/frpcBinary.js'
import {
extractTarFileByBasename,
extractFrpcBinary,
TarExtractError,
} from '../src/provision/untar.js'
function sha256Hex(data: Uint8Array): string {
return createHash('sha256').update(data).digest('hex')
}
// ---------------------------------------------------------------------------
// In-test tar/gzip fixture builder — hand-builds real USTAR blocks so the
// extractor is exercised against the SAME on-disk layout frp ships (dir entry
// + regular files), with zero network access.
// ---------------------------------------------------------------------------
const TAR_BLOCK = 512
const TYPE_FILE = '0'
const TYPE_DIR = '5'
interface TarEntrySpec {
readonly name: string
readonly content: Uint8Array
readonly typeflag?: string
}
function writeOctalField(block: Uint8Array, offset: number, len: number, value: number): void {
const s = value.toString(8).padStart(len - 1, '0')
block.set(new TextEncoder().encode(s), offset)
block[offset + len - 1] = 0 // NUL terminator
}
function tarHeader(name: string, size: number, typeflag: string): Uint8Array {
const block = new Uint8Array(TAR_BLOCK)
const enc = new TextEncoder()
block.set(enc.encode(name).subarray(0, 100), 0)
writeOctalField(block, 100, 8, 0o755) // mode
writeOctalField(block, 108, 8, 0) // uid
writeOctalField(block, 116, 8, 0) // gid
writeOctalField(block, 124, 12, size) // size
writeOctalField(block, 136, 12, 0) // mtime
block[156] = typeflag.charCodeAt(0)
block.set(enc.encode('ustar'), 257) // magic "ustar\0"
block[263] = 0x30 // version "00"
block[264] = 0x30
// checksum: fields spaces during compute, then written as 6 octal + NUL + space
for (let i = 148; i < 156; i++) block[i] = 0x20
let sum = 0
for (let i = 0; i < TAR_BLOCK; i++) sum += block[i] ?? 0
block.set(enc.encode(sum.toString(8).padStart(6, '0')), 148)
block[154] = 0
block[155] = 0x20
return block
}
function concatBytes(parts: readonly Uint8Array[]): Uint8Array {
const total = parts.reduce((n, p) => n + p.length, 0)
const out = new Uint8Array(total)
let off = 0
for (const p of parts) {
out.set(p, off)
off += p.length
}
return out
}
function makeTar(entries: readonly TarEntrySpec[]): Uint8Array {
const parts: Uint8Array[] = []
for (const e of entries) {
parts.push(tarHeader(e.name, e.content.length, e.typeflag ?? TYPE_FILE))
parts.push(e.content)
const pad = (TAR_BLOCK - (e.content.length % TAR_BLOCK)) % TAR_BLOCK
if (pad > 0) parts.push(new Uint8Array(pad))
}
parts.push(new Uint8Array(TAR_BLOCK * 2)) // end-of-archive: two zero blocks
return concatBytes(parts)
}
function makeTarGz(entries: readonly TarEntrySpec[]): Uint8Array {
return new Uint8Array(gzipSync(makeTar(entries)))
}
const FRPC_BYTES = new Uint8Array([0x7f, 0x45, 0x4c, 0x46, 0xde, 0xad, 0xbe, 0xef]) // fake "ELF" frpc
const FRPS_BYTES = new Uint8Array([0x7f, 0x45, 0x4c, 0x46, 0x00, 0x11, 0x22, 0x33]) // decoy frps
/** A realistic frp archive layout: dir entry + frpc + decoy frps + LICENSE. */
function realisticFrpTarGz(prefix = 'frp_0.61.1_darwin_arm64'): Uint8Array {
return makeTarGz([
{ name: `${prefix}/`, content: new Uint8Array(0), typeflag: TYPE_DIR },
{ name: `${prefix}/frps`, content: FRPS_BYTES },
{ name: `${prefix}/frpc`, content: FRPC_BYTES },
{ name: `${prefix}/LICENSE`, content: new TextEncoder().encode('MIT') },
])
}
interface FakeFsState {
writes: Map<string, Uint8Array>
renames: Array<{ from: string; to: string }>
removed: string[]
chmods: Array<{ path: string; mode: number }>
mkdirs: string[]
}
function makeFakeDeps(bytesByUrl: Record<string, Uint8Array>): {
deps: ProvisionFrpcDeps
state: FakeFsState
fetchedUrls: string[]
} {
const state: FakeFsState = {
writes: new Map(),
renames: [],
removed: [],
chmods: [],
mkdirs: [],
}
const fetchedUrls: string[] = []
const deps: ProvisionFrpcDeps = {
fetch: async (url) => {
fetchedUrls.push(url)
const bytes = bytesByUrl[url]
if (!bytes) throw new Error(`test: no fixture bytes for ${url}`)
return bytes
},
fs: {
mkdir: async (dir) => {
state.mkdirs.push(dir)
},
writeFile: async (path, data) => {
state.writes.set(path, data)
},
rename: async (from, to) => {
state.renames.push({ from, to })
const data = state.writes.get(from)
if (data) {
state.writes.delete(from)
state.writes.set(to, data)
}
},
chmod: async (path, mode) => {
state.chmods.push({ path, mode })
},
rm: async (path) => {
state.removed.push(path)
state.writes.delete(path)
},
},
}
return { deps, state, fetchedUrls }
}
function releaseOverride(
platform: FrpcPlatform,
ref: FrpcReleaseRef,
): Record<FrpcPlatform, FrpcReleaseRef> {
return { ...FRPC_RELEASES, [platform]: ref }
}
const BIN_DIR = '/opt/wt/bin'
const BIN_PATH = '/opt/wt/bin/frpc'
describe('detectFrpcPlatform (B3)', () => {
it('maps darwin/linux × arm64/amd64 (node x64 → amd64)', () => {
expect(detectFrpcPlatform('darwin', 'arm64')).toBe('darwin-arm64')
expect(detectFrpcPlatform('darwin', 'x64')).toBe('darwin-amd64')
expect(detectFrpcPlatform('linux', 'arm64')).toBe('linux-arm64')
expect(detectFrpcPlatform('linux', 'x64')).toBe('linux-amd64')
})
it('returns null for unsupported platform/arch', () => {
expect(detectFrpcPlatform('win32', 'x64')).toBeNull()
expect(detectFrpcPlatform('linux', 'ia32')).toBeNull()
expect(detectFrpcPlatform('freebsd', 'arm64')).toBeNull()
expect(detectFrpcPlatform('darwin', 'mips')).toBeNull()
})
})
describe('FRPC_RELEASES pinning', () => {
it('pins a release per supported platform (https url, semver, 64-hex sha256)', () => {
expect([...FRPC_PLATFORMS].sort()).toEqual([
'darwin-amd64',
'darwin-arm64',
'linux-amd64',
'linux-arm64',
])
for (const platform of FRPC_PLATFORMS) {
const ref = FRPC_RELEASES[platform]
expect(ref.url.startsWith('https://')).toBe(true)
expect(ref.url.endsWith('.tar.gz')).toBe(true)
expect(ref.version).toMatch(/^\d+\.\d+\.\d+$/)
expect(ref.sha256).toMatch(/^[0-9a-f]{64}$/)
}
})
it('pins REAL (non-placeholder) frp v0.61.1 checksums', () => {
for (const platform of FRPC_PLATFORMS) {
expect(FRPC_RELEASES[platform].sha256).not.toBe('0'.repeat(64))
}
})
})
describe('extractTarFileByBasename (tar path-traversal safe extractor)', () => {
it('returns the bytes of the first regular file whose basename matches', () => {
const tar = makeTar([
{ name: 'frp_x/', content: new Uint8Array(0), typeflag: TYPE_DIR },
{ name: 'frp_x/frps', content: FRPS_BYTES },
{ name: 'frp_x/frpc', content: FRPC_BYTES },
])
expect(extractTarFileByBasename(tar, 'frpc')).toEqual(FRPC_BYTES)
expect(extractTarFileByBasename(tar, 'frps')).toEqual(FRPS_BYTES)
})
it('does NOT match a directory entry that shares the basename', () => {
const tar = makeTar([
{ name: 'frpc/', content: new Uint8Array(0), typeflag: TYPE_DIR },
{ name: 'frp_x/frpc', content: FRPC_BYTES },
])
expect(extractTarFileByBasename(tar, 'frpc')).toEqual(FRPC_BYTES)
})
it('throws when no matching file entry exists', () => {
const tar = makeTar([{ name: 'frp_x/frps', content: FRPS_BYTES }])
expect(() => extractTarFileByBasename(tar, 'frpc')).toThrow(TarExtractError)
})
it('REJECTS a matching entry whose name contains a ".." traversal segment', () => {
const tar = makeTar([{ name: 'frp_x/../../../tmp/frpc', content: FRPC_BYTES }])
expect(() => extractTarFileByBasename(tar, 'frpc')).toThrow(/unsafe|traversal|\.\./i)
})
it('REJECTS a matching entry with an absolute path name', () => {
const tar = makeTar([{ name: '/etc/frpc', content: FRPC_BYTES }])
expect(() => extractTarFileByBasename(tar, 'frpc')).toThrow(/unsafe|absolute/i)
})
it('throws on a corrupt (truncated) tar rather than reading out of bounds', () => {
const full = makeTar([{ name: 'frp_x/frpc', content: FRPC_BYTES }])
const truncated = full.subarray(0, TAR_BLOCK + 4) // header + partial content
expect(() => extractTarFileByBasename(truncated, 'frpc')).toThrow(TarExtractError)
})
})
describe('extractFrpcBinary (gunzip + extract)', () => {
it('gunzips then extracts the inner frpc bytes', () => {
expect(extractFrpcBinary(realisticFrpTarGz())).toEqual(FRPC_BYTES)
})
it('throws on non-gzip input', () => {
expect(() => extractFrpcBinary(new Uint8Array([1, 2, 3, 4]))).toThrow(TarExtractError)
})
})
describe('provisionFrpc (B3 verify-download + extract discipline)', () => {
it('selects the arch URL, VERIFIES the archive, and places the INNER frpc (not the archive)', async () => {
const archive = realisticFrpTarGz()
const url = 'https://example.test/frp-darwin-arm64.tar.gz'
const releases = releaseOverride('darwin-arm64', {
version: '0.61.1',
url,
sha256: sha256Hex(archive),
})
const { deps, state, fetchedUrls } = makeFakeDeps({ [url]: archive })
const result = await provisionFrpc(
{ platform: 'darwin', arch: 'arm64', binDir: BIN_DIR, releases },
deps,
)
expect(fetchedUrls).toEqual([url])
expect(result.binPath).toBe(BIN_PATH)
expect(result.version).toBe('0.61.1')
expect(result.platform).toBe('darwin-arm64')
// atomic place: renamed into the final path, made executable
expect(state.renames.some((r) => r.to === BIN_PATH)).toBe(true)
expect(state.chmods.some((c) => (c.mode & 0o111) !== 0)).toBe(true)
// the placed file is the EXTRACTED frpc binary, NOT the .tar.gz archive
const placed = state.writes.get(BIN_PATH)
expect(placed).toEqual(FRPC_BYTES)
expect(placed).not.toEqual(archive)
})
it('ignores the decoy frps entry and places frpc even when frps precedes it', async () => {
const archive = makeTarGz([
{ name: 'frp_x/frps', content: FRPS_BYTES },
{ name: 'frp_x/frpc', content: FRPC_BYTES },
])
const url = 'https://example.test/frp.tar.gz'
const releases = releaseOverride('linux-amd64', {
version: '0.61.1',
url,
sha256: sha256Hex(archive),
})
const { deps, state } = makeFakeDeps({ [url]: archive })
await provisionFrpc({ platform: 'linux', arch: 'x64', binDir: BIN_DIR, releases }, deps)
expect(state.writes.get(BIN_PATH)).toEqual(FRPC_BYTES)
})
it('REJECTS on sha256 mismatch and places NO binary (temp cleaned up, no extraction)', async () => {
const archive = realisticFrpTarGz()
const url = 'https://example.test/frp-linux-amd64.tar.gz'
const releases = releaseOverride('linux-amd64', {
version: '0.61.1',
url,
sha256: 'f'.repeat(64), // deliberately wrong
})
const { deps, state } = makeFakeDeps({ [url]: archive })
await expect(
provisionFrpc({ platform: 'linux', arch: 'x64', binDir: BIN_DIR, releases }, deps),
).rejects.toThrow(/sha-?256|hash|integrity|mismatch/i)
expect(state.renames).toEqual([])
expect(state.writes.has(BIN_PATH)).toBe(false)
expect(state.removed.length).toBeGreaterThan(0) // unverified temp removed
})
it('never places or execs before verifying (mismatch leaves nothing executable)', async () => {
const archive = realisticFrpTarGz()
const url = 'https://example.test/bad.tar.gz'
const releases = releaseOverride('linux-arm64', {
version: '0.61.1',
url,
sha256: '0'.repeat(64),
})
const { deps, state } = makeFakeDeps({ [url]: archive })
await expect(
provisionFrpc({ platform: 'linux', arch: 'arm64', binDir: BIN_DIR, releases }, deps),
).rejects.toThrow()
expect(state.chmods.every((c) => c.path !== BIN_PATH)).toBe(true)
})
it('throws and places nothing when the verified archive has NO frpc entry', async () => {
const archive = makeTarGz([
{ name: 'frp_x/frps', content: FRPS_BYTES },
{ name: 'frp_x/LICENSE', content: new TextEncoder().encode('MIT') },
])
const url = 'https://example.test/no-frpc.tar.gz'
const releases = releaseOverride('darwin-amd64', {
version: '0.61.1',
url,
sha256: sha256Hex(archive),
})
const { deps, state } = makeFakeDeps({ [url]: archive })
await expect(
provisionFrpc({ platform: 'darwin', arch: 'x64', binDir: BIN_DIR, releases }, deps),
).rejects.toThrow(/extract|frpc|tar/i)
expect(state.renames).toEqual([])
expect(state.writes.has(BIN_PATH)).toBe(false)
expect(state.removed.length).toBeGreaterThan(0) // temp removed on extraction failure
})
it('REJECTS a traversal frpc entry and never writes outside binDir', async () => {
const archive = makeTarGz([
{ name: 'frp_x/../../../tmp/frpc', content: FRPC_BYTES },
])
const url = 'https://example.test/evil.tar.gz'
const releases = releaseOverride('linux-amd64', {
version: '0.61.1',
url,
sha256: sha256Hex(archive),
})
const { deps, state } = makeFakeDeps({ [url]: archive })
await expect(
provisionFrpc({ platform: 'linux', arch: 'x64', binDir: BIN_DIR, releases }, deps),
).rejects.toThrow()
// nothing placed, and every write that ever happened stayed inside binDir
expect(state.writes.has(BIN_PATH)).toBe(false)
expect(state.renames).toEqual([])
expect([...state.writes.keys()].every((p) => p.startsWith(`${BIN_DIR}/`))).toBe(true)
expect(state.removed.every((p) => p.startsWith(`${BIN_DIR}/`))).toBe(true)
})
it('REJECTS a hash-matching but corrupt (non-gzip) archive after the gate', async () => {
const garbage = new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7]) // hashes fine, not a gzip
const url = 'https://example.test/corrupt.tar.gz'
const releases = releaseOverride('darwin-arm64', {
version: '0.61.1',
url,
sha256: sha256Hex(garbage),
})
const { deps, state } = makeFakeDeps({ [url]: garbage })
await expect(
provisionFrpc({ platform: 'darwin', arch: 'arm64', binDir: BIN_DIR, releases }, deps),
).rejects.toThrow(/extract|gzip|tar/i)
expect(state.writes.has(BIN_PATH)).toBe(false)
expect(state.removed.length).toBeGreaterThan(0)
})
it('throws a clear error on an unsupported platform (nothing fetched)', async () => {
const { deps, fetchedUrls } = makeFakeDeps({})
await expect(
provisionFrpc({ platform: 'win32', arch: 'x64', binDir: BIN_DIR }, deps),
).rejects.toThrow(/unsupported|platform/i)
expect(fetchedUrls).toEqual([])
})
})

100
agent/test/frpcToml.test.ts Normal file
View File

@@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest'
import { buildNativeFrpcToml, type NativeFrpcOptions } from '../src/transport/frpcToml.js'
const BASE: NativeFrpcOptions = {
subdomain: 'alice',
localPort: 3000,
authToken: 'super-secret-token',
certFile: '/home/alice/.web-terminal-agent/frpc.cert.pem',
keyFile: '/home/alice/.web-terminal-agent/frpc.key.pem',
trustedCaFile: '/home/alice/.web-terminal-agent/frps-ctrl-ca.pem',
}
describe('buildNativeFrpcToml (B2h)', () => {
it('emits the native-tunnel server/port/tls keys (PLAN_NATIVE_TUNNEL §4)', () => {
const toml = buildNativeFrpcToml(BASE)
expect(toml).toContain('serverAddr = "8.138.1.192"')
expect(toml).toContain('serverPort = 443')
expect(toml).toContain('transport.tls.enable = true')
expect(toml).toContain('transport.tls.serverName = "frp.terminal.yaojia.wang"')
expect(toml).toContain('transport.tls.disableCustomTLSFirstByte = true')
expect(toml).toContain(`transport.tls.certFile = "${BASE.certFile}"`)
expect(toml).toContain(`transport.tls.keyFile = "${BASE.keyFile}"`)
expect(toml).toContain(`transport.tls.trustedCaFile = "${BASE.trustedCaFile}"`)
expect(toml).toContain('auth.method = "token"')
expect(toml).toContain('auth.token = "super-secret-token"')
})
it('emits a well-formed [[proxies]] http block bound to loopback', () => {
const toml = buildNativeFrpcToml(BASE)
expect(toml).toContain('[[proxies]]')
expect(toml).toContain('type = "http"')
expect(toml).toContain('subdomain = "alice"')
expect(toml).toContain('localIP = "127.0.0.1"')
expect(toml).toContain('localPort = 3000')
// the proxy block comes after the server/tls preamble
expect(toml.indexOf('[[proxies]]')).toBeGreaterThan(toml.indexOf('serverAddr'))
})
it('does NOT emit the retired v0.8 [common]/tls_enable shape', () => {
const toml = buildNativeFrpcToml(BASE)
expect(toml).not.toContain('[common]')
expect(toml).not.toContain('tls_enable')
})
it('honours a configurable serverAddr (default 8.138.1.192)', () => {
expect(buildNativeFrpcToml(BASE)).toContain('serverAddr = "8.138.1.192"')
expect(buildNativeFrpcToml({ ...BASE, serverAddr: '10.9.8.7' })).toContain(
'serverAddr = "10.9.8.7"',
)
})
it('THROWS when localIP is non-loopback (anti-SSRF hard invariant)', () => {
expect(() => buildNativeFrpcToml({ ...BASE, localIP: '10.0.0.5' })).toThrow(/loopback/i)
expect(() => buildNativeFrpcToml({ ...BASE, localIP: '0.0.0.0' })).toThrow(/loopback/i)
expect(() => buildNativeFrpcToml({ ...BASE, localIP: '192.168.1.9' })).toThrow(/loopback/i)
})
it('accepts the three explicit loopback localIP forms', () => {
for (const ip of ['127.0.0.1', '::1', 'localhost']) {
expect(() => buildNativeFrpcToml({ ...BASE, localIP: ip })).not.toThrow()
}
})
it('rejects an empty or non-label-safe subdomain', () => {
expect(() => buildNativeFrpcToml({ ...BASE, subdomain: '' })).toThrow(/subdomain/i)
expect(() => buildNativeFrpcToml({ ...BASE, subdomain: 'has space' })).toThrow(/subdomain/i)
expect(() => buildNativeFrpcToml({ ...BASE, subdomain: '-bad' })).toThrow(/subdomain/i)
expect(() => buildNativeFrpcToml({ ...BASE, subdomain: 'bad-' })).toThrow(/subdomain/i)
expect(() => buildNativeFrpcToml({ ...BASE, subdomain: 'a'.repeat(64) })).toThrow(/subdomain/i)
expect(() => buildNativeFrpcToml({ ...BASE, subdomain: 'a/b' })).toThrow(/subdomain/i)
})
it('rejects an out-of-range or non-integer localPort', () => {
expect(() => buildNativeFrpcToml({ ...BASE, localPort: 0 })).toThrow(/port/i)
expect(() => buildNativeFrpcToml({ ...BASE, localPort: 70000 })).toThrow(/port/i)
expect(() => buildNativeFrpcToml({ ...BASE, localPort: 3000.5 })).toThrow(/port/i)
expect(() => buildNativeFrpcToml({ ...BASE, localPort: -1 })).toThrow(/port/i)
})
it('rejects missing keystore paths and an empty auth token', () => {
expect(() => buildNativeFrpcToml({ ...BASE, certFile: '' })).toThrow(/certFile/i)
expect(() => buildNativeFrpcToml({ ...BASE, keyFile: '' })).toThrow(/keyFile/i)
expect(() => buildNativeFrpcToml({ ...BASE, trustedCaFile: '' })).toThrow(/trustedCaFile/i)
expect(() => buildNativeFrpcToml({ ...BASE, authToken: '' })).toThrow(/token/i)
})
it('escapes backslashes and quotes in Windows-style paths (valid TOML basic string)', () => {
const winCert = 'C:\\Users\\alice\\.web-terminal-agent\\frpc.cert.pem'
const toml = buildNativeFrpcToml({ ...BASE, certFile: winCert })
expect(toml).toContain(
'transport.tls.certFile = "C:\\\\Users\\\\alice\\\\.web-terminal-agent\\\\frpc.cert.pem"',
)
})
it('rejects control characters in paths/token (TOML-injection guard)', () => {
expect(() => buildNativeFrpcToml({ ...BASE, authToken: 'a\nb' })).toThrow()
expect(() => buildNativeFrpcToml({ ...BASE, certFile: 'a\nb' })).toThrow()
expect(() => buildNativeFrpcToml({ ...BASE, keyFile: 'a"b\nq' })).toThrow()
})
})

View File

@@ -1,10 +1,13 @@
import { describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { createPublicKey, verify } from 'node:crypto'
import {
computeEnrollFpr,
generateIdentity,
generateP256Identity,
identityFromPrivatePem,
p256IdentityFromPrivatePem,
verifySignature,
} from '../src/keys/identity.js'
@@ -50,4 +53,53 @@ describe('AgentIdentity (INV4)', () => {
// No function exports raw private key bytes onto the network surface.
expect(src).not.toMatch(/exportPrivateRaw|privateKeyBytes|toRawPrivate/)
})
it('keeps the Ed25519 alg tag (no P-256 regression)', () => {
expect(generateIdentity().alg).toBe('ed25519')
})
})
describe('P-256 AgentIdentity (FIX H-host-2 — native frp-client key)', () => {
it('generates distinct EC P-256 keypairs tagged alg=p256', () => {
const a = generateP256Identity()
const b = generateP256Identity()
expect(a.alg).toBe('p256')
expect(Buffer.from(a.publicKey).equals(Buffer.from(b.publicKey))).toBe(false)
// publicKey is the EC SubjectPublicKeyInfo DER (outer SEQUENCE), importable as a public key.
expect(a.publicKey[0]).toBe(0x30)
const pub = createPublicKey({ key: Buffer.from(a.publicKey), format: 'der', type: 'spki' })
expect(pub.asymmetricKeyType).toBe('ec')
expect(pub.asymmetricKeyDetails?.namedCurve).toBe('prime256v1')
})
it('sign produces a DER ECDSA signature that verifies under SHA-256', () => {
const id = generateP256Identity()
const msg = new TextEncoder().encode('certificationRequestInfo-bytes')
const sig = id.sign(msg)
const pub = createPublicKey({ key: Buffer.from(id.publicKey), format: 'der', type: 'spki' })
expect(verify('sha256', msg, pub, sig)).toBe(true)
expect(verify('sha256', new TextEncoder().encode('tampered'), pub, sig)).toBe(false)
})
it('enrollFpr is deterministic base64url(SHA-256(spki))', () => {
const id = generateP256Identity()
expect(computeEnrollFpr(id.publicKey)).toBe(id.enrollFpr)
expect(id.enrollFpr).not.toMatch(/[+/=]/) // base64url alphabet only
})
it('reloads the same P-256 identity from PEM (keystore load path)', () => {
const id = generateP256Identity()
const pem = id.exportPrivatePkcs8Pem()
const reloaded = p256IdentityFromPrivatePem(pem)
expect(reloaded.alg).toBe('p256')
expect(Buffer.from(reloaded.publicKey).equals(Buffer.from(id.publicKey))).toBe(true)
expect(reloaded.enrollFpr).toBe(id.enrollFpr)
})
it('security: P-256 identity exposes no raw private-key getter', () => {
const id = generateP256Identity()
expect('privateKey' in id).toBe(false)
// the PEM export is the only serialization surface; it is a PRIVATE key PEM (0600 keystore only).
expect(id.exportPrivatePkcs8Pem()).toContain('PRIVATE KEY')
})
})

View File

@@ -1,15 +1,18 @@
import { describe, expect, it } from 'vitest'
import type { AgentConfig } from '../src/config/agentConfig.js'
import {
BindHostError,
RootRefusedError,
assertNativeZone,
buildInstallOptions,
detectPlatform,
installService,
normalizeBindHost,
uninstallService,
type InstallDeps,
} from '../src/service/install.js'
import { buildLaunchdPlist } from '../src/service/launchd.js'
import { buildSystemdUnit } from '../src/service/systemd.js'
import { agentLabel, baseAppLabel, buildLaunchdPlist } from '../src/service/launchd.js'
import { agentUnitName, baseAppUnitName, buildSystemdUnit } from '../src/service/systemd.js'
const CFG: AgentConfig = {
relayUrl: 'wss://relay/agent',
@@ -20,7 +23,12 @@ const CFG: AgentConfig = {
hostId: 'h-1',
}
function deps(uid = 501): InstallDeps & { writes: Array<[string, string]>; runs: Array<[string, readonly string[]]> } {
type FakeDeps = InstallDeps & {
writes: Array<[string, string]>
runs: Array<[string, readonly string[]]>
}
function deps(uid = 501): FakeDeps {
const writes: Array<[string, string]> = []
const runs: Array<[string, readonly string[]]> = []
return {
@@ -37,6 +45,13 @@ function deps(uid = 501): InstallDeps & { writes: Array<[string, string]>; runs:
}
}
/** The unit content whose path contains `needle` (e.g. `'base-app'`, `'agent'`). */
function unitWith(d: FakeDeps, needle: string): string {
const hit = d.writes.find(([path]) => path.includes(needle))
if (!hit) throw new Error(`no unit written whose path contains '${needle}'`)
return hit[1]
}
describe('detectPlatform (T17)', () => {
it('maps darwin→launchd, linux→systemd, else null', () => {
expect(detectPlatform('darwin')).toBe('launchd')
@@ -45,143 +60,157 @@ describe('detectPlatform (T17)', () => {
})
})
describe('installService (T17)', () => {
describe('installService — least privilege (T17)', () => {
it('refuses to install as root (negative, least privilege)', async () => {
await expect(installService(CFG, 'systemd', deps(0))).rejects.toBeInstanceOf(RootRefusedError)
})
it('systemd: writes a run-as-user unit and enables it', async () => {
it('root refusal emits nothing', async () => {
const d = deps(0)
await expect(installService(CFG, 'systemd', d)).rejects.toBeInstanceOf(RootRefusedError)
expect(d.writes).toHaveLength(0)
expect(d.runs).toHaveLength(0)
})
})
describe('installService — two distinct units (FIX M-host-2service)', () => {
it('systemd: writes a base-app unit AND an agent unit, both run-as-user, both enabled', async () => {
const d = deps()
await installService(CFG, 'systemd', d)
const [, unit] = d.writes[0]!
expect(unit).toContain('ExecStart=/usr/local/bin/web-terminal-agent run')
expect(unit).toContain('User=alice')
expect(unit).not.toContain('User=root')
expect(unit).toContain('Restart=on-failure')
expect(d.runs[0]![0]).toBe('systemctl')
// exactly two units
expect(d.writes).toHaveLength(2)
const baseApp = unitWith(d, baseAppUnitName())
const agent = unitWith(d, agentUnitName())
// agent unit supervises frpc via `<bin> run`; base-app runs the node server (loopback)
expect(agent).toContain('ExecStart=/usr/local/bin/web-terminal-agent run')
expect(baseApp).toContain('ExecStart=')
expect(baseApp).toContain('server.js')
expect(baseApp).not.toContain('web-terminal-agent run')
// both least-privilege + restart-on-failure
for (const unit of [baseApp, agent]) {
expect(unit).toContain('User=alice')
expect(unit).not.toContain('User=root')
expect(unit).toContain('Restart=on-failure')
}
// both enabled
expect(d.runs.every(([cmd]) => cmd === 'systemctl')).toBe(true)
expect(d.runs).toHaveLength(2)
})
it('launchd: writes a plist with ProgramArguments run + KeepAlive', async () => {
it('launchd: writes a base-app plist AND an agent plist, both with KeepAlive, both loaded', async () => {
const d = deps()
await installService(CFG, 'launchd', d)
const [path, plist] = d.writes[0]!
expect(path).toContain('LaunchAgents')
expect(plist).toContain('<string>run</string>')
expect(plist).toContain('<key>KeepAlive</key>')
expect(d.runs[0]![0]).toBe('launchctl')
expect(d.writes).toHaveLength(2)
const baseApp = unitWith(d, baseAppLabel())
const agent = unitWith(d, agentLabel())
expect(agent).toContain('<string>run</string>')
expect(baseApp).toContain('server.js')
expect(baseApp).not.toContain('<string>run</string>')
for (const plist of [baseApp, agent]) {
expect(plist).toContain('<key>KeepAlive</key>')
}
expect(d.writes.every(([path]) => path.includes('LaunchAgents'))).toBe(true)
expect(d.runs.every(([cmd]) => cmd === 'launchctl')).toBe(true)
expect(d.runs).toHaveLength(2)
})
it('uninstall unloads cleanly', async () => {
it('routes base-app env to the base-app unit ONLY (never onto the agent unit)', async () => {
const d = deps()
await installService(CFG, 'systemd', d, { env: { BIND_HOST: '127.0.0.1', PORT: '3000' } })
const baseApp = unitWith(d, baseAppUnitName())
const agent = unitWith(d, agentUnitName())
expect(baseApp).toContain('Environment="BIND_HOST=127.0.0.1"')
expect(baseApp).toContain('Environment="PORT=3000"')
// the agent unit must NOT carry the base-app env
expect(agent).not.toContain('BIND_HOST')
expect(agent).not.toContain('PORT=3000')
})
it('uninstall tears down BOTH units (launchd unload)', async () => {
const d = deps()
await uninstallService('launchd', d)
expect(d.runs[0]).toEqual(['launchctl', ['unload', '/home/alice/Library/LaunchAgents/com.web-terminal.agent.plist']])
const targets = d.runs.map(([, args]) => args[args.length - 1])
expect(d.runs.every(([cmd]) => cmd === 'launchctl')).toBe(true)
expect(targets.some((t) => t?.includes(baseAppLabel()))).toBe(true)
expect(targets.some((t) => t?.includes(agentLabel()))).toBe(true)
})
it('uninstall tears down BOTH units (systemd disable)', async () => {
const d = deps()
await uninstallService('systemd', d)
const units = d.runs.map(([, args]) => args[args.length - 1])
expect(d.runs.every(([cmd]) => cmd === 'systemctl')).toBe(true)
expect(units).toContain(baseAppUnitName())
expect(units).toContain(agentUnitName())
})
})
const TUNNEL_ENV = {
BIND_HOST: '127.0.0.1',
ALLOWED_ORIGINS: 'https://t1.terminal.yaojia.wang',
PORT: '3000',
} as const
describe('BIND_HOST loopback S-GATE (FIX C-host-1, CRITICAL)', () => {
it('normalizeBindHost defaults an absent value to loopback', () => {
expect(normalizeBindHost(undefined)).toBe('127.0.0.1')
expect(normalizeBindHost('')).toBe('127.0.0.1')
})
describe('env injection into the writers (PLAN_NATIVE_TUNNEL S2)', () => {
it('launchd: default (no options) omits the EnvironmentVariables block', async () => {
it('normalizeBindHost accepts loopback forms (127.0.0.0/8, ::1, localhost)', () => {
expect(normalizeBindHost('127.0.0.1')).toBe('127.0.0.1')
expect(normalizeBindHost('127.0.0.2')).toBe('127.0.0.2')
expect(normalizeBindHost('::1')).toBe('::1')
expect(normalizeBindHost('localhost')).toBe('localhost')
})
it('normalizeBindHost REJECTS 0.0.0.0 and other non-loopback values', () => {
expect(() => normalizeBindHost('0.0.0.0')).toThrow(BindHostError)
expect(() => normalizeBindHost('192.168.1.10')).toThrow(BindHostError)
expect(() => normalizeBindHost('::')).toThrow(BindHostError)
})
it('REGRESSION: rejects a suffixed hostname that merely starts with 127. (S-GATE bypass)', () => {
// These are hostnames, not loopback literals — Node would DNS-resolve them before bind().
expect(() => normalizeBindHost('127.0.0.1.attacker.example.com')).toThrow(BindHostError)
expect(() => normalizeBindHost('127.evil.net')).toThrow(BindHostError)
expect(() => normalizeBindHost('127.0.0.1x')).toThrow(BindHostError)
expect(() => buildInstallOptions({ BIND_HOST: '127.0.0.1.attacker.example.com' })).toThrow(
BindHostError,
)
})
it('buildInstallOptions throws on BIND_HOST=0.0.0.0 (fail-closed at env read)', () => {
expect(() => buildInstallOptions({ BIND_HOST: '0.0.0.0' })).toThrow(BindHostError)
})
it('NEGATIVE: installService with BIND_HOST=0.0.0.0 throws AND emits nothing', async () => {
const d = deps()
await installService(CFG, 'launchd', d)
const [, plist] = d.writes[0]!
expect(plist).not.toContain('EnvironmentVariables')
await expect(
installService(CFG, 'systemd', d, { env: { BIND_HOST: '0.0.0.0', PORT: '3000' } }),
).rejects.toBeInstanceOf(BindHostError)
expect(d.writes).toHaveLength(0)
expect(d.runs).toHaveLength(0)
})
it('launchd: injects a sorted, XML-escaped EnvironmentVariables dict', async () => {
it('NEGATIVE (launchd): a 0.0.0.0 install emits no plist', async () => {
const d = deps()
await installService(CFG, 'launchd', d, { env: TUNNEL_ENV })
const [, plist] = d.writes[0]!
expect(plist).toContain('<key>EnvironmentVariables</key>')
expect(plist).toContain('<key>BIND_HOST</key>')
expect(plist).toContain('<string>127.0.0.1</string>')
// keys are sorted (ALLOWED_ORIGINS before BIND_HOST before PORT)
expect(plist.indexOf('ALLOWED_ORIGINS')).toBeLessThan(plist.indexOf('BIND_HOST'))
expect(plist.indexOf('BIND_HOST')).toBeLessThan(plist.indexOf('>PORT<'))
await expect(
installService(CFG, 'launchd', d, { env: { BIND_HOST: '0.0.0.0' } }),
).rejects.toBeInstanceOf(BindHostError)
expect(d.writes).toHaveLength(0)
})
it('launchd: escapes XML-significant characters in env values', () => {
const plist = buildLaunchdPlist('/bin/agent', { X: `a&b<c>d"e'f` })
expect(plist).toContain('<string>a&amp;b&lt;c&gt;d&quot;e&apos;f</string>')
expect(plist).not.toContain('a&b<c>d')
})
it('systemd: default (no options) omits Environment lines', async () => {
it('the emitted base-app unit can NEVER contain BIND_HOST=0.0.0.0 (normalized when absent)', async () => {
const d = deps()
await installService(CFG, 'systemd', d)
const [, unit] = d.writes[0]!
expect(unit).not.toContain('Environment')
})
it('systemd: emits sorted, quoted Environment= lines from the env map', async () => {
const d = deps()
await installService(CFG, 'systemd', d, { env: TUNNEL_ENV })
const [, unit] = d.writes[0]!
expect(unit).toContain('Environment="BIND_HOST=127.0.0.1"')
expect(unit).toContain('Environment="PORT=3000"')
expect(unit.indexOf('ALLOWED_ORIGINS')).toBeLessThan(unit.indexOf('BIND_HOST'))
})
it('systemd: emits EnvironmentFile= (before inline Environment) when a path is given', async () => {
const d = deps()
await installService(CFG, 'systemd', d, { env: TUNNEL_ENV, envFile: '/etc/web-terminal.env' })
const [, unit] = d.writes[0]!
expect(unit).toContain('EnvironmentFile=/etc/web-terminal.env')
expect(unit.indexOf('EnvironmentFile=')).toBeLessThan(unit.indexOf('Environment='))
})
it('systemd: escapes backslash and double-quote in Environment values', () => {
const unit = buildSystemdUnit('/bin/agent', 'alice', { env: { X: 'a"b\\c' } })
expect(unit).toContain('Environment="X=a\\"b\\\\c"')
await installService(CFG, 'systemd', d, { env: { PORT: '3000' } })
const baseApp = unitWith(d, baseAppUnitName())
expect(baseApp).toContain('Environment="BIND_HOST=127.0.0.1"')
expect(baseApp).not.toContain('0.0.0.0')
})
})
describe('tunnel-origin derivation (PLAN_NATIVE_TUNNEL S2)', () => {
it('merges https://<subdomain>.<zone>.<domain> into ALLOWED_ORIGINS when domain is given', async () => {
const d = deps()
await installService(CFG, 'launchd', d, { domain: 'yaojia.wang', zone: 'terminal' })
const [, plist] = d.writes[0]!
expect(plist).toContain('<key>ALLOWED_ORIGINS</key>')
expect(plist).toContain('<string>https://host-42.terminal.yaojia.wang</string>')
})
it('defaults to the `term` zone when only a domain is supplied', async () => {
const d = deps()
await installService(CFG, 'launchd', d, { domain: 'yaojia.wang' })
const [, plist] = d.writes[0]!
expect(plist).toContain('<string>https://host-42.term.yaojia.wang</string>')
})
it('preserves a caller-provided ALLOWED_ORIGINS and appends the tunnel origin', async () => {
const d = deps()
await installService(CFG, 'systemd', d, {
env: { ALLOWED_ORIGINS: 'https://keep.me' },
domain: 'yaojia.wang',
zone: 'terminal',
})
const [, unit] = d.writes[0]!
expect(unit).toContain('https://keep.me,https://host-42.terminal.yaojia.wang')
})
it('does not derive an origin when the config has no subdomain', async () => {
const d = deps()
await installService({ ...CFG, subdomain: null }, 'launchd', d, { domain: 'yaojia.wang' })
const [, plist] = d.writes[0]!
expect(plist).not.toContain('ALLOWED_ORIGINS')
})
})
describe('buildInstallOptions — env → InstallOptions (PLAN_NATIVE_TUNNEL S0/S2)', () => {
describe('buildInstallOptions — env → InstallOptions (S0/S2 + S-GATE)', () => {
it('defaults BIND_HOST to loopback so a tunnel install is never LAN-exposed (S0/R2)', () => {
const options = buildInstallOptions({})
expect(options.env).toEqual({ BIND_HOST: '127.0.0.1' })
})
it('honours an explicit BIND_HOST and passes through the S0 base-app env vars', () => {
it('honours an explicit loopback BIND_HOST and passes through the S0 base-app env vars', () => {
const options = buildInstallOptions({
BIND_HOST: '127.0.0.2',
PORT: '3000',
@@ -189,6 +218,8 @@ describe('buildInstallOptions — env → InstallOptions (PLAN_NATIVE_TUNNEL S0/
IDLE_TTL: '86400',
USE_TMUX: '1',
ALLOWED_ORIGINS: 'https://keep.me',
SCROLLBACK_BYTES: '2097152',
MAX_PAYLOAD_BYTES: '1048576',
})
expect(options.env).toEqual({
BIND_HOST: '127.0.0.2',
@@ -197,6 +228,17 @@ describe('buildInstallOptions — env → InstallOptions (PLAN_NATIVE_TUNNEL S0/
IDLE_TTL: '86400',
USE_TMUX: '1',
ALLOWED_ORIGINS: 'https://keep.me',
SCROLLBACK_BYTES: '2097152',
MAX_PAYLOAD_BYTES: '1048576',
})
})
it('AG3: passes SCROLLBACK_BYTES and MAX_PAYLOAD_BYTES through as base-app config', () => {
const options = buildInstallOptions({ SCROLLBACK_BYTES: '2097152', MAX_PAYLOAD_BYTES: '1048576' })
expect(options.env).toEqual({
BIND_HOST: '127.0.0.1',
SCROLLBACK_BYTES: '2097152',
MAX_PAYLOAD_BYTES: '1048576',
})
})
@@ -212,7 +254,11 @@ describe('buildInstallOptions — env → InstallOptions (PLAN_NATIVE_TUNNEL S0/
})
it('lets TUNNEL_ZONE override the origin zone and carries AGENT_ENV_FILE through', () => {
const options = buildInstallOptions({ TUNNEL_DOMAIN: 'yaojia.wang', TUNNEL_ZONE: 'term', AGENT_ENV_FILE: '/etc/wt.env' })
const options = buildInstallOptions({
TUNNEL_DOMAIN: 'yaojia.wang',
TUNNEL_ZONE: 'term',
AGENT_ENV_FILE: '/etc/wt.env',
})
expect(options.zone).toBe('term')
expect(options.envFile).toBe('/etc/wt.env')
})
@@ -224,46 +270,151 @@ describe('buildInstallOptions — env → InstallOptions (PLAN_NATIVE_TUNNEL S0/
})
})
describe('install CLI seam end-to-end — resolved env reaches the units (PLAN_NATIVE_TUNNEL S2)', () => {
// Env the operator would export before `web-terminal-agent install` on a tunnel host.
const ENV = { PORT: '3000', SHELL_PATH: '/bin/zsh', TUNNEL_DOMAIN: 'yaojia.wang' } as const
it('launchd: the plist carries loopback BIND_HOST + the derived tunnel ALLOWED_ORIGINS', async () => {
const d = deps()
await installService(CFG, 'launchd', d, buildInstallOptions(ENV))
const [, plist] = d.writes[0]!
expect(plist).toContain('<key>EnvironmentVariables</key>')
expect(plist).toContain('<key>BIND_HOST</key>')
expect(plist).toContain('<string>127.0.0.1</string>')
expect(plist).toContain('<string>https://host-42.terminal.yaojia.wang</string>')
expect(plist).toContain('<key>PORT</key>')
expect(plist).not.toContain('0.0.0.0')
describe('tunnel-origin derivation into the base-app unit (FIX L-host-zone)', () => {
it('assertNativeZone accepts `terminal` and rejects `term`/undefined', () => {
expect(() => assertNativeZone('terminal')).not.toThrow()
expect(() => assertNativeZone('term')).toThrow(/terminal/)
expect(() => assertNativeZone(undefined)).toThrow(/terminal/)
})
it('systemd: the unit carries loopback BIND_HOST + the derived tunnel ALLOWED_ORIGINS', async () => {
it('merges https://<sub>.terminal.<domain> into the base-app ALLOWED_ORIGINS', async () => {
const d = deps()
await installService(CFG, 'systemd', d, buildInstallOptions(ENV))
const [, unit] = d.writes[0]!
expect(unit).toContain('Environment="BIND_HOST=127.0.0.1"')
expect(unit).toContain('Environment="ALLOWED_ORIGINS=https://host-42.terminal.yaojia.wang"')
expect(unit).toContain('Environment="PORT=3000"')
expect(unit).not.toContain('0.0.0.0')
await installService(CFG, 'launchd', d, { domain: 'yaojia.wang', zone: 'terminal' })
const baseApp = unitWith(d, baseAppLabel())
expect(baseApp).toContain('<key>ALLOWED_ORIGINS</key>')
expect(baseApp).toContain('<string>https://host-42.terminal.yaojia.wang</string>')
})
it('preserves a caller-provided ALLOWED_ORIGINS and appends the tunnel origin', async () => {
const d = deps()
await installService(CFG, 'systemd', d, {
env: { ALLOWED_ORIGINS: 'https://keep.me' },
domain: 'yaojia.wang',
zone: 'terminal',
})
const baseApp = unitWith(d, baseAppUnitName())
expect(baseApp).toContain('https://keep.me,https://host-42.terminal.yaojia.wang')
})
it('does not derive an origin when the config has no subdomain', async () => {
const d = deps()
await installService({ ...CFG, subdomain: null }, 'launchd', d, {
domain: 'yaojia.wang',
zone: 'terminal',
})
const baseApp = unitWith(d, baseAppLabel())
expect(baseApp).not.toContain('ALLOWED_ORIGINS')
})
})
describe('systemd env value hardening (LOW: control-char injection)', () => {
it('rejects a newline in an env value so it cannot inject a [Service] directive', () => {
expect(() => buildSystemdUnit('/bin/agent', 'alice', { env: { X: 'a\nExecStartPre=/x' } })).toThrow(
describe('install CLI seam end-to-end — resolved env reaches the base-app unit (S2)', () => {
const ENV = { PORT: '3000', SHELL_PATH: '/bin/zsh', TUNNEL_DOMAIN: 'yaojia.wang' } as const
it('launchd: the base-app plist carries loopback BIND_HOST + the derived tunnel ALLOWED_ORIGINS', async () => {
const d = deps()
await installService(CFG, 'launchd', d, buildInstallOptions(ENV))
const baseApp = unitWith(d, baseAppLabel())
expect(baseApp).toContain('<key>BIND_HOST</key>')
expect(baseApp).toContain('<string>127.0.0.1</string>')
expect(baseApp).toContain('<string>https://host-42.terminal.yaojia.wang</string>')
expect(baseApp).toContain('<key>PORT</key>')
expect(baseApp).not.toContain('0.0.0.0')
})
it('systemd: the base-app unit carries loopback BIND_HOST + the derived tunnel ALLOWED_ORIGINS', async () => {
const d = deps()
await installService(CFG, 'systemd', d, buildInstallOptions(ENV))
const baseApp = unitWith(d, baseAppUnitName())
expect(baseApp).toContain('Environment="BIND_HOST=127.0.0.1"')
expect(baseApp).toContain('Environment="ALLOWED_ORIGINS=https://host-42.terminal.yaojia.wang"')
expect(baseApp).toContain('Environment="PORT=3000"')
expect(baseApp).not.toContain('0.0.0.0')
})
it('systemd: emits EnvironmentFile= (before inline Environment) on the base-app unit', async () => {
const d = deps()
await installService(CFG, 'systemd', d, {
env: { BIND_HOST: '127.0.0.1', PORT: '3000' },
envFile: '/etc/web-terminal.env',
})
const baseApp = unitWith(d, baseAppUnitName())
expect(baseApp).toContain('EnvironmentFile=/etc/web-terminal.env')
expect(baseApp.indexOf('EnvironmentFile=')).toBeLessThan(baseApp.indexOf('Environment='))
})
})
describe('unit writers — escaping & control-char hardening', () => {
it('launchd: escapes XML-significant characters in env values', () => {
const plist = buildLaunchdPlist(['/bin/agent', 'run'], { X: `a&b<c>d"e'f` })
expect(plist).toContain('<string>a&amp;b&lt;c&gt;d&quot;e&apos;f</string>')
expect(plist).not.toContain('a&b<c>d')
})
it('launchd: injects a sorted, XML-escaped EnvironmentVariables dict', () => {
const plist = buildLaunchdPlist(['/bin/agent', 'run'], {
BIND_HOST: '127.0.0.1',
ALLOWED_ORIGINS: 'https://a',
PORT: '3000',
})
expect(plist).toContain('<key>EnvironmentVariables</key>')
expect(plist.indexOf('ALLOWED_ORIGINS')).toBeLessThan(plist.indexOf('BIND_HOST'))
expect(plist.indexOf('BIND_HOST')).toBeLessThan(plist.indexOf('>PORT<'))
})
it('launchd: no env → no EnvironmentVariables block', () => {
const plist = buildLaunchdPlist(['/bin/agent', 'run'])
expect(plist).not.toContain('EnvironmentVariables')
})
it('systemd: escapes backslash and double-quote in Environment values', () => {
const unit = buildSystemdUnit('/bin/agent run', 'alice', { env: { X: 'a"b\\c' } })
expect(unit).toContain('Environment="X=a\\"b\\\\c"')
})
it('systemd: rejects a newline in an env value (no [Service] directive injection)', () => {
expect(() =>
buildSystemdUnit('/bin/agent run', 'alice', { env: { X: 'a\nExecStartPre=/x' } }),
).toThrow(/control character/)
})
it('systemd: rejects a carriage return in an env value', () => {
expect(() => buildSystemdUnit('/bin/agent run', 'alice', { env: { X: 'a\rb' } })).toThrow(
/control character/,
)
})
it('rejects a carriage return in an env value', () => {
expect(() => buildSystemdUnit('/bin/agent', 'alice', { env: { X: 'a\rb' } })).toThrow(/control character/)
it('AG2: rejects a newline in the ExecStart command (no [Service] directive injection)', () => {
expect(() =>
buildSystemdUnit('/bin/agent run\nExecStartPre=/x', 'alice'),
).toThrow(/control character/)
})
it('still accepts ordinary values with quotes and backslashes', () => {
const unit = buildSystemdUnit('/bin/agent', 'alice', { env: { X: 'a"b\\c' } })
expect(unit).toContain('Environment="X=a\\"b\\\\c"')
it('AG2: rejects a newline in the User field', () => {
expect(() => buildSystemdUnit('/bin/agent run', 'alice\nExecStartPre=/x')).toThrow(
/control character/,
)
})
it('AG2: rejects a newline in the Description field', () => {
expect(() =>
buildSystemdUnit('/bin/agent run', 'alice', {}, 'desc\n[Service]\nExecStartPre=/x'),
).toThrow(/control character/)
})
it('AG2: rejects a newline in the EnvironmentFile path', () => {
expect(() =>
buildSystemdUnit('/bin/agent run', 'alice', { envFile: '/etc/x.env\nExecStartPre=/y' }),
).toThrow(/control character/)
})
it('AG2: rejects a newline in an Environment KEY (not just the value)', () => {
expect(() =>
buildSystemdUnit('/bin/agent run', 'alice', { env: { 'X\nExecStartPre=/y': 'v' } }),
).toThrow(/control character/)
})
it('systemd: default (no env) omits Environment lines', () => {
const unit = buildSystemdUnit('/bin/agent run', 'alice')
expect(unit).not.toContain('Environment')
})
})

View File

@@ -2,7 +2,8 @@ import { afterEach, describe, expect, it } from 'vitest'
import { mkdtempSync, mkdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { generateIdentity } from '../src/keys/identity.js'
import { createPublicKey, generateKeyPairSync, verify } from 'node:crypto'
import { generateIdentity, generateP256Identity } from '../src/keys/identity.js'
import { KeystoreError, openKeystore } from '../src/keys/keystore.js'
const dirs: string[] = []
@@ -67,4 +68,50 @@ describe('Keystore (INV4/INV5)', () => {
mkdirSync(dir, { mode: 0o755 })
expect(() => openKeystore(dir).saveIdentity(generateIdentity())).toThrow(KeystoreError)
})
it('keeps the Ed25519 identity byte-identical across save/load (no P-256 regression)', () => {
const dir = freshDir()
const ks = openKeystore(dir)
const id = generateIdentity()
ks.saveIdentity(id)
const reloaded = ks.loadIdentity()
expect(reloaded!.alg).toBe('ed25519')
expect(Buffer.from(reloaded!.publicKey).equals(Buffer.from(id.publicKey))).toBe(true)
expect(reloaded!.enrollFpr).toBe(id.enrollFpr)
})
it('FIX H-host-2: round-trips a P-256 frp-client identity (alg + usable signing key)', () => {
const dir = freshDir()
const ks = openKeystore(dir)
const id = generateP256Identity()
ks.saveIdentity(id)
expect(mode(join(dir, 'agent.key.pem'))).toBe(0o600)
const reloaded = ks.loadIdentity()
expect(reloaded).not.toBeNull()
// loadIdentity() branched on the stored key's alg discriminant → P-256, not Ed25519.
expect(reloaded!.alg).toBe('p256')
// EC SubjectPublicKeyInfo DER (outer SEQUENCE) preserved exactly.
expect(reloaded!.publicKey[0]).toBe(0x30)
expect(Buffer.from(reloaded!.publicKey).equals(Buffer.from(id.publicKey))).toBe(true)
expect(reloaded!.enrollFpr).toBe(id.enrollFpr)
// The reloaded key still signs: a DER ECDSA signature that verifies under the original pubkey.
const msg = new TextEncoder().encode('csr-bytes')
const sig = reloaded!.sign(msg)
const pub = createPublicKey({ key: Buffer.from(id.publicKey), format: 'der', type: 'spki' })
expect(verify('sha256', msg, pub, sig)).toBe(true)
})
it('AG1: rejects a stored EC key on a non-P256 curve (e.g. secp384r1) with a clear error', () => {
const dir = freshDir()
const ks = openKeystore(dir)
// Plant a valid PKCS#8 EC key on the WRONG curve directly at the key path — `ec` alone must not
// be mistaken for P-256; loadIdentity must assert the named curve and fail closed.
const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'secp384r1' })
const pem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString()
writeFileSync(join(dir, 'agent.key.pem'), pem, { mode: 0o600 })
expect(() => ks.loadIdentity()).toThrow(KeystoreError)
expect(() => ks.loadIdentity()).toThrow(/prime256v1|P-256|curve/)
})
})

View File

@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest'
import { isLoopbackHostLiteral } from '../src/net/loopbackLiteral.js'
describe('isLoopbackHostLiteral — strict loopback-literal check (FIX C-host-1 / anti-SSRF)', () => {
it('accepts exact loopback literals', () => {
expect(isLoopbackHostLiteral('localhost')).toBe(true)
expect(isLoopbackHostLiteral('::1')).toBe(true)
expect(isLoopbackHostLiteral('[::1]')).toBe(true)
})
it('accepts any well-formed IPv4 in 127.0.0.0/8', () => {
expect(isLoopbackHostLiteral('127.0.0.1')).toBe(true)
expect(isLoopbackHostLiteral('127.0.0.2')).toBe(true)
expect(isLoopbackHostLiteral('127.5.5.5')).toBe(true)
expect(isLoopbackHostLiteral('127.255.255.255')).toBe(true)
})
it('rejects non-loopback IPs and wildcards', () => {
expect(isLoopbackHostLiteral('0.0.0.0')).toBe(false)
expect(isLoopbackHostLiteral('192.168.1.10')).toBe(false)
expect(isLoopbackHostLiteral('10.0.0.5')).toBe(false)
expect(isLoopbackHostLiteral('::')).toBe(false)
})
it('REJECTS suffixed hostnames that merely start with 127. (the S-GATE bypass)', () => {
expect(isLoopbackHostLiteral('127.0.0.1.attacker.example.com')).toBe(false)
expect(isLoopbackHostLiteral('127.evil.net')).toBe(false)
expect(isLoopbackHostLiteral('127.0.0.1.evil.example.com')).toBe(false)
expect(isLoopbackHostLiteral('127.0.0.1x')).toBe(false)
})
it('rejects malformed / non-dotted-quad partials and out-of-range octets', () => {
expect(isLoopbackHostLiteral('127.1')).toBe(false)
expect(isLoopbackHostLiteral('127.0.0.256')).toBe(false)
expect(isLoopbackHostLiteral('0127.0.0.1')).toBe(false)
expect(isLoopbackHostLiteral('')).toBe(false)
expect(isLoopbackHostLiteral('127')).toBe(false)
})
})

216
agent/test/probe.test.ts Normal file
View File

@@ -0,0 +1,216 @@
import { describe, expect, it, vi } from 'vitest'
import {
DEFAULT_CERT_RENEW_WINDOW_MS,
certIsFresh,
frpcProxyStarted,
probeLoopbackBaseApp,
renderHealthStatus,
runHealthProbe,
startHealthMonitor,
type HealthProbeSeams,
type HealthReport,
type IntervalTimer,
} from '../src/health/probe.js'
/** All-passing seams; each test overrides exactly one to prove sub-check independence. */
function healthySeams(over: Partial<HealthProbeSeams> = {}): HealthProbeSeams {
return {
isFrpcAlive: () => true,
probeBaseApp: async () => true,
readFrpcLog: () => 'proxy [web-terminal] start proxy success',
certNotAfter: () => new Date('2026-08-01T00:00:00Z'),
now: () => new Date('2026-07-08T00:00:00Z'),
...over,
}
}
describe('frpcProxyStarted (log scan)', () => {
it('detects the frpc start-proxy-success line', () => {
expect(frpcProxyStarted('2026/07/08 [I] [proxy_manager] start proxy success')).toBe(true)
})
it('is false before frpc reports success', () => {
expect(frpcProxyStarted('login to server success\nstart proxy ...')).toBe(false)
expect(frpcProxyStarted('')).toBe(false)
})
})
describe('certIsFresh (near-expiry check)', () => {
const now = new Date('2026-07-08T00:00:00Z')
it('is fresh when notAfter is beyond the renewal window', () => {
const notAfter = new Date(now.getTime() + DEFAULT_CERT_RENEW_WINDOW_MS + 60_000)
expect(certIsFresh(notAfter, now, DEFAULT_CERT_RENEW_WINDOW_MS)).toBe(true)
})
it('is NOT fresh when notAfter is inside the renewal window', () => {
const notAfter = new Date(now.getTime() + DEFAULT_CERT_RENEW_WINDOW_MS - 60_000)
expect(certIsFresh(notAfter, now, DEFAULT_CERT_RENEW_WINDOW_MS)).toBe(false)
})
it('treats a missing cert (null notAfter) as not fresh', () => {
expect(certIsFresh(null, now, DEFAULT_CERT_RENEW_WINDOW_MS)).toBe(false)
})
})
describe('probeLoopbackBaseApp (loopback-only)', () => {
it('targets 127.0.0.1:PORT and returns true on an ok response', async () => {
const fetchImpl = vi.fn(async (url: string) => ({ ok: url.includes('127.0.0.1:3000') }))
await expect(probeLoopbackBaseApp(3000, fetchImpl)).resolves.toBe(true)
expect(fetchImpl).toHaveBeenCalledWith('http://127.0.0.1:3000/')
})
it('returns false on a non-ok response', async () => {
await expect(probeLoopbackBaseApp(3000, async () => ({ ok: false }))).resolves.toBe(false)
})
it('swallows a rejected fetch (a probe never throws)', async () => {
await expect(
probeLoopbackBaseApp(3000, async () => {
throw new Error('ECONNREFUSED')
}),
).resolves.toBe(false)
})
it('rejects an out-of-range port without fetching', async () => {
const fetchImpl = vi.fn(async () => ({ ok: true }))
await expect(probeLoopbackBaseApp(0, fetchImpl)).resolves.toBe(false)
await expect(probeLoopbackBaseApp(70000, fetchImpl)).resolves.toBe(false)
expect(fetchImpl).not.toHaveBeenCalled()
})
})
describe('runHealthProbe (aggregate verdict)', () => {
it('is healthy when all four sub-checks pass', async () => {
const report = await runHealthProbe(healthySeams())
expect(report).toEqual<HealthReport>({
frpcAlive: true,
baseAppReachable: true,
proxyStarted: true,
certFresh: true,
healthy: true,
})
})
it('is unhealthy if frpc is dead', async () => {
const report = await runHealthProbe(healthySeams({ isFrpcAlive: () => false }))
expect(report.frpcAlive).toBe(false)
expect(report.healthy).toBe(false)
})
it('is unhealthy if the base app is unreachable', async () => {
const report = await runHealthProbe(healthySeams({ probeBaseApp: async () => false }))
expect(report.baseAppReachable).toBe(false)
expect(report.healthy).toBe(false)
})
it('is unhealthy if the proxy never started', async () => {
const report = await runHealthProbe(healthySeams({ readFrpcLog: () => 'connecting...' }))
expect(report.proxyStarted).toBe(false)
expect(report.healthy).toBe(false)
})
it('is unhealthy if the cert is near expiry', async () => {
const report = await runHealthProbe(
healthySeams({
certNotAfter: () => new Date('2026-07-08T01:00:00Z'), // 1h out, inside 8h window
}),
)
expect(report.certFresh).toBe(false)
expect(report.healthy).toBe(false)
})
})
describe('renderHealthStatus (INV9 — non-secret only)', () => {
const report: HealthReport = {
frpcAlive: true,
baseAppReachable: true,
proxyStarted: true,
certFresh: true,
healthy: true,
}
it('prints subdomain, host id, expiry date, and flags', () => {
const lines = renderHealthStatus(
{ subdomain: 'alice', hostId: 'h-1', certNotAfter: new Date('2026-08-01T00:00:00Z') },
report,
)
const joined = lines.join('\n')
expect(joined).toContain('subdomain: alice')
expect(joined).toContain('host_id: h-1')
expect(joined).toContain('cert_expiry: 2026-08-01T00:00:00.000Z')
expect(joined).toContain('healthy: true')
})
it('leaks NO key/cert/token/CSR material', () => {
const lines = renderHealthStatus(
{ subdomain: 'alice', hostId: 'h-1', certNotAfter: new Date('2026-08-01T00:00:00Z') },
report,
)
const joined = lines.join('\n')
expect(joined).not.toMatch(/PRIVATE KEY|BEGIN CERTIFICATE|BEGIN CERTIFICATE REQUEST/)
expect(joined.toLowerCase()).not.toMatch(/token|secret|csr|pem/)
})
it('renders (none)/(unknown) placeholders when identifiers are absent', () => {
const lines = renderHealthStatus({ subdomain: null, hostId: null, certNotAfter: null }, report)
const joined = lines.join('\n')
expect(joined).toContain('subdomain: (none)')
expect(joined).toContain('cert_expiry: (unknown)')
})
})
describe('startHealthMonitor (periodic)', () => {
function fakeTimer(): { timer: IntervalTimer; fire: () => void; cleared: boolean } {
let cb: (() => void) | null = null
const state = { cleared: false }
return {
timer: {
setInterval: (fn) => {
cb = fn
return 1
},
clearInterval: () => {
state.cleared = true
},
},
fire: () => cb?.(),
get cleared() {
return state.cleared
},
}
}
it('runs the probe on each tick and reports it', async () => {
const report: HealthReport = {
frpcAlive: true,
baseAppReachable: true,
proxyStarted: true,
certFresh: true,
healthy: true,
}
const probe = vi.fn(async () => report)
const seen: HealthReport[] = []
const ft = fakeTimer()
const monitor = startHealthMonitor(probe, (r) => seen.push(r), { timer: ft.timer })
ft.fire()
await Promise.resolve()
await Promise.resolve()
expect(probe).toHaveBeenCalledTimes(1)
expect(seen).toEqual([report])
monitor.stop()
expect(ft.cleared).toBe(true)
})
it('swallows a rejected probe (monitor never crashes)', async () => {
const ft = fakeTimer()
const onReport = vi.fn()
startHealthMonitor(async () => Promise.reject(new Error('boom')), onReport, { timer: ft.timer })
ft.fire()
await Promise.resolve()
await Promise.resolve()
expect(onReport).not.toHaveBeenCalled()
})
})