diff --git a/agent/src/cli/deps.ts b/agent/src/cli/deps.ts index 05b7c86..c2616c4 100644 --- a/agent/src/cli/deps.ts +++ b/agent/src/cli/deps.ts @@ -99,7 +99,7 @@ async function enrollNative( id: AgentIdentity, ks: Keystore, ): Promise { - 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 } } diff --git a/agent/src/enroll/pair.ts b/agent/src/enroll/pair.ts index 9a06fdc..3dffb3e 100644 --- a/agent/src/enroll/pair.ts +++ b/agent/src/enroll/pair.ts @@ -53,6 +53,12 @@ export interface RedeemOptions { readonly agentToken?: string readonly unwrapContentSecret?: UnwrapContentSecret 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 { @@ -63,10 +69,42 @@ interface EnrollResponseJson { 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 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 = { hostId: j.hostId, @@ -128,10 +166,13 @@ export async function redeemPairingCode( 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) // FIX 3: unwrap in-process, persist ONLY the unwrapped secret (wrapped bytes never stored). - const unwrapped = unwrap(enroll.hostContentSecret, id) - ks.saveContentSecret(unwrapped) + // 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) + ks.saveContentSecret(unwrapped) + } return enroll } diff --git a/agent/src/service/install.ts b/agent/src/service/install.ts index f859c9c..adae58e 100644 --- a/agent/src/service/install.ts +++ b/agent/src/service/install.ts @@ -11,6 +11,7 @@ * - 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. */ +import { dirname } from 'node:path' import type { AgentConfig } from '../config/agentConfig.js' import { agentLabel, @@ -202,13 +203,31 @@ export async function installService( // so nothing is ever written for a rejected install. const baseAppEnv = resolveBaseAppEnv(cfg, options) 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 = { + PATH: unitPath, + ENROLL_URL: cfg.enrollUrl, + RELAY_URL: cfg.relayUrl, + STATE_DIR: cfg.stateDir, + LOCAL_TARGET_URL: cfg.localTargetUrl, + } if (platform === 'launchd') { 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()) - deps.writeFile(agentPath, buildLaunchdPlist([bin, 'run'], {}, agentLabel())) + deps.writeFile(agentPath, buildLaunchdPlist([nodePath, bin, 'run'], agentEnv, agentLabel())) for (const path of [baseAppPath, agentPath]) { const { cmd, args } = launchdLoadCommand(path) await deps.runCommand(cmd, args) @@ -217,8 +236,8 @@ export async function installService( } const baseAppOptions: SystemdUnitOptions = options.envFile - ? { env: baseAppEnv, envFile: options.envFile } - : { env: baseAppEnv } + ? { env: baseAppEnvWithPath, envFile: options.envFile } + : { env: baseAppEnvWithPath } const baseAppPath = systemdUnitPath(deps.homedir(), baseAppUnitName()) deps.writeFile( baseAppPath, @@ -227,7 +246,7 @@ export async function installService( const agentPath = systemdUnitPath(deps.homedir(), agentUnitName()) deps.writeFile( 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()]) { const { cmd, args } = systemdEnableCommand(unit) diff --git a/agent/test/install.test.ts b/agent/test/install.test.ts index bd8331d..8ccbcd3 100644 --- a/agent/test/install.test.ts +++ b/agent/test/install.test.ts @@ -81,8 +81,10 @@ describe('installService — two distinct units (FIX M-host-2service)', () => { expect(d.writes).toHaveLength(2) const baseApp = unitWith(d, baseAppUnitName()) const agent = unitWith(d, agentUnitName()) - // agent unit supervises frpc via ` run`; base-app runs the node server (loopback) - expect(agent).toContain('ExecStart=/usr/local/bin/web-terminal-agent run') + // agent unit supervises frpc via ` run` (absolute node so a minimal service PATH + // 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('server.js') expect(baseApp).not.toContain('web-terminal-agent run')