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:
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
103
agent/test/deps.test.ts
Normal 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 child’s 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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
148
agent/test/frpSupervise.test.ts
Normal file
148
agent/test/frpSupervise.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
416
agent/test/frpcBinary.test.ts
Normal file
416
agent/test/frpcBinary.test.ts
Normal 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
100
agent/test/frpcToml.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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&b<c>d"e'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&b<c>d"e'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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
|
||||
39
agent/test/loopbackLiteral.test.ts
Normal file
39
agent/test/loopbackLiteral.test.ts
Normal 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
216
agent/test/probe.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user