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