fix(agent): native enroll + launchd install real-deploy shakedown

Fixes found deploying `pair --install` against the live control-plane:
- native enroll tolerates hostContentSecret:null and converts the base64-DER
  cert + caChain[] the CP returns into PEM for frpc/keystore
- launchd/systemd units use an absolute node (process.execPath) + a PATH with
  /usr/local/bin (bare `node` died with EX_CONFIG 78)
- the agent unit now carries ENROLL_URL/RELAY_URL/STATE_DIR/LOCAL_TARGET_URL
  (`run` loadConfig()-validates them; missing → supervisor exited 1)
281 tests pass. Follow-up: add unit tests for the native-enroll tolerance +
agent-unit env (only install node-path is covered so far).
This commit is contained in:
Yaojia Wang
2026-07-18 15:46:20 +02:00
parent 6e04eb0661
commit 9f7f5c0c54
4 changed files with 76 additions and 14 deletions

View File

@@ -99,7 +99,7 @@ async function enrollNative(
id: AgentIdentity, id: AgentIdentity,
ks: Keystore, ks: Keystore,
): Promise<NativeEnrollResult> { ): Promise<NativeEnrollResult> {
const enroll = await redeemPairingCode(cfg.enrollUrl, code, id, ks) const enroll = await redeemPairingCode(cfg.enrollUrl, code, id, ks, { allowMissingContentSecret: true })
return { hostId: enroll.hostId, subdomain: enroll.subdomain } return { hostId: enroll.hostId, subdomain: enroll.subdomain }
} }

View File

@@ -53,6 +53,12 @@ export interface RedeemOptions {
readonly agentToken?: string readonly agentToken?: string
readonly unwrapContentSecret?: UnwrapContentSecret readonly unwrapContentSecret?: UnwrapContentSecret
readonly subject?: string readonly subject?: string
/**
* Native frp-client enroll has NO E2E content secret — the control-plane returns
* `hostContentSecret: null`. When true, tolerate its absence (skip unwrap + storage); the plain
* frpc byte tunnel needs no content key. Defaults false so the legacy relay path still requires it.
*/
readonly allowMissingContentSecret?: boolean
} }
interface EnrollResponseJson { interface EnrollResponseJson {
@@ -63,11 +69,43 @@ interface EnrollResponseJson {
hostContentSecret: string // base64url over the wire hostContentSecret: string // base64url over the wire
} }
function parseEnrollResult(json: unknown): EnrollResult { /**
* base64(DER) → PEM. The native /enroll returns the leaf + CA certs as base64-encoded DER; the
* keystore + frpc need PEM files, so wrap the base64 body at 64 columns in CERTIFICATE armor.
*/
function derBase64ToPem(derBase64: string, label = 'CERTIFICATE'): string {
const body = derBase64.replace(/\s+/g, '')
const lines = body.match(/.{1,64}/g) ?? []
return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`
}
function parseEnrollResult(json: unknown, allowMissingContentSecret = false): EnrollResult {
const j = json as Partial<EnrollResponseJson> const j = json as Partial<EnrollResponseJson>
if (typeof j.hostContentSecret !== 'string') { if (typeof j.hostContentSecret !== 'string') {
if (!allowMissingContentSecret) {
throw new EnrollError('enroll response missing hostContentSecret') throw new EnrollError('enroll response missing hostContentSecret')
} }
// Native frp-client enroll: cert = base64(DER) string, caChain = base64(DER) string[], no content
// key. The keystore + frpc need PEM, so convert here. Empty secret sentinel is never stored.
const caChain: unknown = j.caChain
if (
typeof j.hostId !== 'string' ||
typeof j.subdomain !== 'string' ||
typeof j.cert !== 'string' ||
!Array.isArray(caChain) ||
caChain.length === 0 ||
!caChain.every((c) => typeof c === 'string')
) {
throw new EnrollError('enroll response missing required fields')
}
return {
hostId: j.hostId,
subdomain: j.subdomain,
cert: derBase64ToPem(j.cert),
caChain: (caChain as string[]).map((c) => derBase64ToPem(c)).join(''),
hostContentSecret: new Uint8Array(0),
}
}
const candidate = { const candidate = {
hostId: j.hostId, hostId: j.hostId,
subdomain: j.subdomain, subdomain: j.subdomain,
@@ -128,10 +166,13 @@ export async function redeemPairingCode(
throw new EnrollError(`enroll response was not JSON: ${(err as Error).message}`) throw new EnrollError(`enroll response was not JSON: ${(err as Error).message}`)
} }
const enroll = parseEnrollResult(json) const enroll = parseEnrollResult(json, opts.allowMissingContentSecret ?? false)
ks.saveCert(enroll.cert, enroll.caChain) ks.saveCert(enroll.cert, enroll.caChain)
// FIX 3: unwrap in-process, persist ONLY the unwrapped secret (wrapped bytes never stored). // FIX 3: unwrap in-process, persist ONLY the unwrapped secret (wrapped bytes never stored).
// Native frp-client enroll has no content secret (empty sentinel) → nothing to unwrap/store.
if (enroll.hostContentSecret.length > 0) {
const unwrapped = unwrap(enroll.hostContentSecret, id) const unwrapped = unwrap(enroll.hostContentSecret, id)
ks.saveContentSecret(unwrapped) ks.saveContentSecret(unwrapped)
}
return enroll return enroll
} }

View File

@@ -11,6 +11,7 @@
* - FIX M-host-2service: base-app env (BIND_HOST/ALLOWED_ORIGINS/PORT/…) is routed to the * - FIX M-host-2service: base-app env (BIND_HOST/ALLOWED_ORIGINS/PORT/…) is routed to the
* base-app unit ONLY; the agent unit (which supervises frpc) never carries it. * base-app unit ONLY; the agent unit (which supervises frpc) never carries it.
*/ */
import { dirname } from 'node:path'
import type { AgentConfig } from '../config/agentConfig.js' import type { AgentConfig } from '../config/agentConfig.js'
import { import {
agentLabel, agentLabel,
@@ -202,13 +203,31 @@ export async function installService(
// so nothing is ever written for a rejected install. // so nothing is ever written for a rejected install.
const baseAppEnv = resolveBaseAppEnv(cfg, options) const baseAppEnv = resolveBaseAppEnv(cfg, options)
const bin = deps.binPath() const bin = deps.binPath()
const baseAppExec = options.baseAppExec ?? DEFAULT_BASE_APP_EXEC const rawExec = options.baseAppExec ?? DEFAULT_BASE_APP_EXEC
// launchd/systemd start with a minimal PATH that excludes /usr/local/bin (where a nvm/brew `node`
// symlink usually lives), so a bare `node` program dies with EX_CONFIG(78). Use the absolute node
// (process.execPath) and export a PATH so the units — and the base app's tmux/node-pty/frpc
// subprocesses — resolve their tools.
const nodePath = process.execPath
const unitPath = `${dirname(nodePath)}:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin`
const baseAppExec = rawExec[0] === 'node' ? [nodePath, ...rawExec.slice(1)] : rawExec
const baseAppEnvWithPath = { ...baseAppEnv, PATH: unitPath }
// The agent unit runs `run`, which loadConfig()-validates ENROLL_URL(https)/RELAY_URL(wss) up front
// (fail-fast). Without these in the unit env the supervisor exits 1 on every launch — so inject the
// agent's own runtime config (NOT the base-app env) alongside PATH.
const agentEnv: Record<string, string> = {
PATH: unitPath,
ENROLL_URL: cfg.enrollUrl,
RELAY_URL: cfg.relayUrl,
STATE_DIR: cfg.stateDir,
LOCAL_TARGET_URL: cfg.localTargetUrl,
}
if (platform === 'launchd') { if (platform === 'launchd') {
const baseAppPath = launchdPlistPath(deps.homedir(), baseAppLabel()) const baseAppPath = launchdPlistPath(deps.homedir(), baseAppLabel())
deps.writeFile(baseAppPath, buildLaunchdPlist(baseAppExec, baseAppEnv, baseAppLabel())) deps.writeFile(baseAppPath, buildLaunchdPlist(baseAppExec, baseAppEnvWithPath, baseAppLabel()))
const agentPath = launchdPlistPath(deps.homedir(), agentLabel()) const agentPath = launchdPlistPath(deps.homedir(), agentLabel())
deps.writeFile(agentPath, buildLaunchdPlist([bin, 'run'], {}, agentLabel())) deps.writeFile(agentPath, buildLaunchdPlist([nodePath, bin, 'run'], agentEnv, agentLabel()))
for (const path of [baseAppPath, agentPath]) { for (const path of [baseAppPath, agentPath]) {
const { cmd, args } = launchdLoadCommand(path) const { cmd, args } = launchdLoadCommand(path)
await deps.runCommand(cmd, args) await deps.runCommand(cmd, args)
@@ -217,8 +236,8 @@ export async function installService(
} }
const baseAppOptions: SystemdUnitOptions = options.envFile const baseAppOptions: SystemdUnitOptions = options.envFile
? { env: baseAppEnv, envFile: options.envFile } ? { env: baseAppEnvWithPath, envFile: options.envFile }
: { env: baseAppEnv } : { env: baseAppEnvWithPath }
const baseAppPath = systemdUnitPath(deps.homedir(), baseAppUnitName()) const baseAppPath = systemdUnitPath(deps.homedir(), baseAppUnitName())
deps.writeFile( deps.writeFile(
baseAppPath, baseAppPath,
@@ -227,7 +246,7 @@ export async function installService(
const agentPath = systemdUnitPath(deps.homedir(), agentUnitName()) const agentPath = systemdUnitPath(deps.homedir(), agentUnitName())
deps.writeFile( deps.writeFile(
agentPath, agentPath,
buildSystemdUnit(`${bin} run`, deps.username(), {}, 'web-terminal host agent (frpc supervisor)'), buildSystemdUnit(`${nodePath} ${bin} run`, deps.username(), { env: agentEnv }, 'web-terminal host agent (frpc supervisor)'),
) )
for (const unit of [baseAppUnitName(), agentUnitName()]) { for (const unit of [baseAppUnitName(), agentUnitName()]) {
const { cmd, args } = systemdEnableCommand(unit) const { cmd, args } = systemdEnableCommand(unit)

View File

@@ -81,8 +81,10 @@ describe('installService — two distinct units (FIX M-host-2service)', () => {
expect(d.writes).toHaveLength(2) expect(d.writes).toHaveLength(2)
const baseApp = unitWith(d, baseAppUnitName()) const baseApp = unitWith(d, baseAppUnitName())
const agent = unitWith(d, agentUnitName()) const agent = unitWith(d, agentUnitName())
// agent unit supervises frpc via `<bin> run`; base-app runs the node server (loopback) // agent unit supervises frpc via `<node> <bin> run` (absolute node so a minimal service PATH
expect(agent).toContain('ExecStart=/usr/local/bin/web-terminal-agent run') // that lacks /usr/local/bin can't fail with EX_CONFIG); base-app runs the node server (loopback)
expect(agent).toContain('/usr/local/bin/web-terminal-agent run')
expect(agent).toMatch(/ExecStart=\S*node\S* \/usr\/local\/bin\/web-terminal-agent run/)
expect(baseApp).toContain('ExecStart=') expect(baseApp).toContain('ExecStart=')
expect(baseApp).toContain('server.js') expect(baseApp).toContain('server.js')
expect(baseApp).not.toContain('web-terminal-agent run') expect(baseApp).not.toContain('web-terminal-agent run')