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:
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,10 +69,42 @@ 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') {
|
||||||
throw new EnrollError('enroll response missing hostContentSecret')
|
if (!allowMissingContentSecret) {
|
||||||
|
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,
|
||||||
@@ -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).
|
||||||
const unwrapped = unwrap(enroll.hostContentSecret, id)
|
// Native frp-client enroll has no content secret (empty sentinel) → nothing to unwrap/store.
|
||||||
ks.saveContentSecret(unwrapped)
|
if (enroll.hostContentSecret.length > 0) {
|
||||||
|
const unwrapped = unwrap(enroll.hostContentSecret, id)
|
||||||
|
ks.saveContentSecret(unwrapped)
|
||||||
|
}
|
||||||
return enroll
|
return enroll
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
Reference in New Issue
Block a user