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:
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user