From 4ebadadfebc22ffeb00d7c997d3f18e7c654edaa Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 14 Jun 2026 02:21:35 +0000 Subject: [PATCH] refactor: share platform secret source helpers --- scripts/src/platform-infra-langbot.ts | 136 ++++++-------------------- scripts/src/platform-infra-n8n.ts | 65 ++++-------- scripts/src/secrets.ts | 41 +++++++- 3 files changed, 87 insertions(+), 155 deletions(-) diff --git a/scripts/src/platform-infra-langbot.ts b/scripts/src/platform-infra-langbot.ts index 7d210c9c..f424e1cf 100644 --- a/scripts/src/platform-infra-langbot.ts +++ b/scripts/src/platform-infra-langbot.ts @@ -1,10 +1,11 @@ import { createHash } from "node:crypto"; -import { existsSync, readFileSync } from "node:fs"; -import { isAbsolute, join } from "node:path"; +import { readFileSync } from "node:fs"; +import { isAbsolute } from "node:path"; import type { UniDeskConfig } from "./config"; import { rootPath } from "./config"; import { startJob } from "./jobs"; -import { applyPk01CaddyBlock } from "./platform-infra-public-service"; +import { applyPk01CaddyBlock, prepareFrpcSecret, shQuote, type FrpcSecretMaterial } from "./platform-infra-public-service"; +import { fingerprintSecretValues, parseEnvFile, readEnvSourceFile, readTextFile, redactRepoPath, requiredEnvValue } from "./secrets"; import { runSshCommandCapture, type SshCaptureResult } from "./ssh"; const configFile = rootPath("config", "platform-infra", "langbot.yaml"); @@ -129,16 +130,6 @@ interface SecretMaterial { valuesPrinted: false; } -interface FrpcSecretMaterial { - sourceRef: string; - sourcePath: string; - secretName: string; - secretKey: string; - frpcToml: string; - fingerprint: string; - valuesPrinted: false; -} - export function langBotHelp(): Record { return { command: "platform-infra langbot plan|apply|status|logs|validate|bootstrap-api-key|query", @@ -474,7 +465,14 @@ async function apply(config: UniDeskConfig, options: ApplyOptions): Promise { name: langbot.apiKey.name, keySourceRef: langbot.apiKey.sourceRef, keyName: langbot.apiKey.key, - fingerprint: fingerprintValues({ [langbot.apiKey.key]: secret.values.apiKey }, [langbot.apiKey.key]), + fingerprint: fingerprintSecretValues({ [langbot.apiKey.key]: secret.values.apiKey }, [langbot.apiKey.key]), valuesPrinted: false, }, database: { @@ -633,7 +631,7 @@ function query(options: QueryOptions): Record { baseUrl: target.publicExposure.publicBaseUrl, path: options.path, auth: "X-API-Key", - apiKeyFingerprint: fingerprintValues({ [langbot.apiKey.key]: secret.values.apiKey }, [langbot.apiKey.key]), + apiKeyFingerprint: fingerprintSecretValues({ [langbot.apiKey.key]: secret.values.apiKey }, [langbot.apiKey.key]), valuesPrinted: false, }, response: probe, @@ -992,17 +990,23 @@ export function readLangBotPostgresConninfo(): string { function prepareSecretMaterial(langbot: LangBotConfig): SecretMaterial { const root = secretRoot(langbot); - const dbSourcePath = join(root, langbot.runtime.database.sourceRef); - if (!existsSync(dbSourcePath)) throw new Error(`LangBot database source ${redactRepoPath(dbSourcePath)} is missing; run platform-db postgres apply --config config/platform-db/postgres-pk01.yaml --confirm first`); - const dbValues = parseEnvFile(readFileSync(dbSourcePath, "utf8")); + const dbSource = readEnvSourceFile({ + root, + sourceRef: langbot.runtime.database.sourceRef, + missingMessage: (sourcePath) => `LangBot database source ${redactRepoPath(sourcePath)} is missing; run platform-db postgres apply --config config/platform-db/postgres-pk01.yaml --confirm first`, + }); + const dbValues = dbSource.values; const dbUser = requiredEnvValue(dbValues, langbot.runtime.database.sourceKeys.user, langbot.runtime.database.sourceRef); const dbPassword = requiredEnvValue(dbValues, langbot.runtime.database.sourceKeys.password, langbot.runtime.database.sourceRef); const dbName = requiredEnvValue(dbValues, langbot.runtime.database.sourceKeys.dbName, langbot.runtime.database.sourceRef); if (dbUser !== langbot.runtime.database.user) throw new Error(`${langbot.runtime.database.sourceRef}.${langbot.runtime.database.sourceKeys.user} does not match langbot runtime.database.user`); if (dbName !== langbot.runtime.database.dbName) throw new Error(`${langbot.runtime.database.sourceRef}.${langbot.runtime.database.sourceKeys.dbName} does not match langbot runtime.database.dbName`); - const appSourcePath = join(root, langbot.runtime.secrets.appSourceRef); - if (!existsSync(appSourcePath)) throw new Error(`LangBot app secret source ${redactRepoPath(appSourcePath)} is missing; run bun scripts/cli.ts secrets sync --config config/secrets-distribution.yaml --scope platform-infra --confirm first`); - const appValues = parseEnvFile(readFileSync(appSourcePath, "utf8")); + const appSource = readEnvSourceFile({ + root, + sourceRef: langbot.runtime.secrets.appSourceRef, + missingMessage: (sourcePath) => `LangBot app secret source ${redactRepoPath(sourcePath)} is missing; run bun scripts/cli.ts secrets sync --config config/secrets-distribution.yaml --scope platform-infra --confirm first`, + }); + const appValues = appSource.values; const jwtSecret = requiredEnvValue(appValues, "LANGBOT_JWT_SECRET", langbot.runtime.secrets.appSourceRef); const apiKey = requiredEnvValue(appValues, langbot.apiKey.key, langbot.apiKey.sourceRef); const values = { @@ -1014,12 +1018,12 @@ function prepareSecretMaterial(langbot: LangBotConfig): SecretMaterial { }; return { dbSourceRef: langbot.runtime.database.sourceRef, - dbSourcePath: redactRepoPath(dbSourcePath), + dbSourcePath: dbSource.sourcePathRedacted, appSourceRef: langbot.runtime.secrets.appSourceRef, - appSourcePath: redactRepoPath(appSourcePath), + appSourcePath: appSource.sourcePathRedacted, action: "read", values, - fingerprint: fingerprintValues({ dbPassword, jwtSecret: values.jwtSecret, apiKey: values.apiKey }, ["dbPassword", "jwtSecret", "apiKey"]), + fingerprint: fingerprintSecretValues({ dbPassword, jwtSecret: values.jwtSecret, apiKey: values.apiKey }, ["dbPassword", "jwtSecret", "apiKey"]), valuesPrinted: false, }; } @@ -1040,44 +1044,13 @@ export function readLangBotSecretMaterial(): Record { const secret = prepareSecretMaterial(langbot); return { apiKey: secret.values.apiKey, - apiKeyFingerprint: fingerprintValues({ [langbot.apiKey.key]: secret.values.apiKey }, [langbot.apiKey.key]), + apiKeyFingerprint: fingerprintSecretValues({ [langbot.apiKey.key]: secret.values.apiKey }, [langbot.apiKey.key]), sourceRef: langbot.apiKey.sourceRef, keyName: langbot.apiKey.key, valuesPrinted: false, }; } -function prepareFrpcSecret(langbot: LangBotConfig, target: LangBotTarget): FrpcSecretMaterial { - const exposure = target.publicExposure; - const sourcePath = join(secretRoot(langbot), exposure.frpc.tokenSourceRef); - if (!existsSync(sourcePath)) throw new Error(`LangBot public exposure requires ${redactRepoPath(sourcePath)} with ${exposure.frpc.tokenSourceKey}`); - const values = parseEnvFile(readFileSync(sourcePath, "utf8")); - const token = requiredEnvValue(values, exposure.frpc.tokenSourceKey, exposure.frpc.tokenSourceRef); - const frpcToml = [ - `serverAddr = "${exposure.frpc.serverAddr}"`, - `serverPort = ${exposure.frpc.serverPort}`, - "loginFailExit = true", - `auth.token = "${escapeTomlString(token)}"`, - "", - "[[proxies]]", - `name = "${exposure.frpc.proxyName}"`, - 'type = "tcp"', - `localIP = "${exposure.frpc.localIP}"`, - `localPort = ${exposure.frpc.localPort}`, - `remotePort = ${exposure.frpc.remotePort}`, - "", - ].join("\n"); - return { - sourceRef: exposure.frpc.tokenSourceRef, - sourcePath: redactRepoPath(sourcePath), - secretName: exposure.frpc.secretName, - secretKey: exposure.frpc.secretKey, - frpcToml, - fingerprint: fingerprintValues({ token, frpcToml }, ["token", "frpcToml"]), - valuesPrinted: false, - }; -} - function dryRunScript(yaml: string, target: LangBotTarget): string { const encoded = Buffer.from(yaml, "utf8").toString("base64"); return ` @@ -1582,46 +1555,10 @@ function secretRoot(langbot: LangBotConfig): string { return isAbsolute(root) ? root : rootPath(root); } -function parseEnvFile(text: string): Record { - const result: Record = {}; - for (const rawLine of text.split(/\r?\n/u)) { - const line = rawLine.trim(); - if (line.length === 0 || line.startsWith("#")) continue; - const eq = line.indexOf("="); - if (eq <= 0) continue; - const key = line.slice(0, eq).trim(); - if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key)) continue; - result[key] = unquoteEnvValue(line.slice(eq + 1).trim()); - } - return result; -} - -function unquoteEnvValue(value: string): string { - if ((value.startsWith("'") && value.endsWith("'")) || (value.startsWith("\"") && value.endsWith("\""))) return value.slice(1, -1); - return value; -} - function sqlLiteral(value: string): string { return `'${value.replaceAll("'", "''")}'`; } -function requiredEnvValue(values: Record, key: string, sourceRef: string): string { - const value = values[key]; - if (value === undefined || value.length === 0) throw new Error(`${sourceRef} is missing required key ${key}`); - return value; -} - -function fingerprintValues(values: Record, keys: string[]): string { - const hash = createHash("sha256"); - for (const key of keys.slice().sort()) { - hash.update(key); - hash.update("\0"); - hash.update(values[key] ?? ""); - hash.update("\0"); - } - return `sha256:${hash.digest("hex")}`; -} - function parseJsonOutput(stdout: string): Record | null { const trimmed = stdout.trim(); if (trimmed.length === 0) return null; @@ -1655,23 +1592,10 @@ function redactText(text: string): string { .replace(/(["']?(?:token|password|secret|api[_-]?key|apikey|jwt[_-]?secret|database[_-]?url)["']?\s*[:=]\s*["']?)[^"',\s}]+(["']?)/giu, "$1$2"); } -function redactRepoPath(path: string): string { - const root = rootPath(); - return path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path; -} - function boolField(value: Record | null, key: string, fallback: boolean): boolean { return typeof value?.[key] === "boolean" ? value[key] : fallback; } -function escapeTomlString(value: string): string { - return value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\""); -} - -function shQuote(value: string): string { - return `'${value.replaceAll("'", "'\"'\"'")}'`; -} - function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); } diff --git a/scripts/src/platform-infra-n8n.ts b/scripts/src/platform-infra-n8n.ts index a25b4e13..781d2534 100644 --- a/scripts/src/platform-infra-n8n.ts +++ b/scripts/src/platform-infra-n8n.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto"; -import { existsSync, readFileSync } from "node:fs"; -import { isAbsolute, join } from "node:path"; +import { readFileSync } from "node:fs"; +import { isAbsolute } from "node:path"; import type { UniDeskConfig } from "./config"; import { rootPath } from "./config"; import { startJob } from "./jobs"; @@ -9,7 +9,6 @@ import { capture, compactCapture, dryRunManifestScript, - fingerprintValues, parseJsonOutput, prepareFrpcSecret, publicHttpProbe, @@ -21,6 +20,7 @@ import { type PublicServiceExposure, type PublicServiceTarget, } from "./platform-infra-public-service"; +import { fingerprintSecretValues, parseEnvFile, readEnvSourceFile, readTextFile, redactRepoPath, requiredEnvValue } from "./secrets"; const configFile = rootPath("config", "platform-infra", "n8n.yaml"); const configLabel = "config/platform-infra/n8n.yaml"; @@ -817,28 +817,34 @@ PY function prepareSecretMaterial(n8n: N8nConfig): SecretMaterial { const root = secretRoot(n8n); - const dbSourcePath = join(root, n8n.runtime.database.sourceRef); - if (!existsSync(dbSourcePath)) throw new Error(`n8n database source ${redactRepoPath(dbSourcePath)} is missing; run platform-db postgres apply --config config/platform-db/postgres-pk01.yaml --confirm first`); - const dbValues = parseEnvFile(readTextFile(dbSourcePath)); + const dbSource = readEnvSourceFile({ + root, + sourceRef: n8n.runtime.database.sourceRef, + missingMessage: (sourcePath) => `n8n database source ${redactRepoPath(sourcePath)} is missing; run platform-db postgres apply --config config/platform-db/postgres-pk01.yaml --confirm first`, + }); + const dbValues = dbSource.values; const dbUser = requiredEnvValue(dbValues, n8n.runtime.database.sourceKeys.user, n8n.runtime.database.sourceRef); const dbPassword = requiredEnvValue(dbValues, n8n.runtime.database.sourceKeys.password, n8n.runtime.database.sourceRef); const dbName = requiredEnvValue(dbValues, n8n.runtime.database.sourceKeys.dbName, n8n.runtime.database.sourceRef); if (dbUser !== n8n.runtime.database.user) throw new Error(`${n8n.runtime.database.sourceRef}.${n8n.runtime.database.sourceKeys.user} does not match n8n runtime.database.user`); if (dbName !== n8n.runtime.database.dbName) throw new Error(`${n8n.runtime.database.sourceRef}.${n8n.runtime.database.sourceKeys.dbName} does not match n8n runtime.database.dbName`); - const appSourcePath = join(root, n8n.runtime.secrets.appSourceRef); - if (!existsSync(appSourcePath)) throw new Error(`n8n app secret source ${redactRepoPath(appSourcePath)} is missing; run bun scripts/cli.ts secrets sync --config config/secrets-distribution.yaml --scope platform-infra --confirm first`); - const appValues = parseEnvFile(readTextFile(appSourcePath)); + const appSource = readEnvSourceFile({ + root, + sourceRef: n8n.runtime.secrets.appSourceRef, + missingMessage: (sourcePath) => `n8n app secret source ${redactRepoPath(sourcePath)} is missing; run bun scripts/cli.ts secrets sync --config config/secrets-distribution.yaml --scope platform-infra --confirm first`, + }); + const appValues = appSource.values; const encryptionKeyName = n8n.runtime.secrets.encryptionKey; const encryptionKey = requiredEnvValue(appValues, encryptionKeyName, n8n.runtime.secrets.appSourceRef); const values = { dbUser, dbPassword, dbName, encryptionKey }; return { dbSourceRef: n8n.runtime.database.sourceRef, - dbSourcePath: redactRepoPath(dbSourcePath), + dbSourcePath: dbSource.sourcePathRedacted, appSourceRef: n8n.runtime.secrets.appSourceRef, - appSourcePath: redactRepoPath(appSourcePath), + appSourcePath: appSource.sourcePathRedacted, action: "read", values, - fingerprint: fingerprintValues({ dbPassword, encryptionKey: values.encryptionKey }, ["dbPassword", "encryptionKey"]), + fingerprint: fingerprintSecretValues({ dbPassword, encryptionKey: values.encryptionKey }, ["dbPassword", "encryptionKey"]), valuesPrinted: false, }; } @@ -1038,41 +1044,6 @@ function secretRoot(n8n: N8nConfig): string { return isAbsolute(root) ? root : rootPath(root); } -function readTextFile(path: string): string { - if (!existsSync(path)) throw new Error(`required secret source ${redactRepoPath(path)} is missing`); - return readFileSync(path, "utf8"); -} - -function parseEnvFile(text: string): Record { - const result: Record = {}; - for (const rawLine of text.split(/\r?\n/u)) { - const line = rawLine.trim(); - if (line.length === 0 || line.startsWith("#")) continue; - const eq = line.indexOf("="); - if (eq <= 0) continue; - const key = line.slice(0, eq).trim(); - if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key)) continue; - result[key] = unquoteEnvValue(line.slice(eq + 1).trim()); - } - return result; -} - -function unquoteEnvValue(value: string): string { - if ((value.startsWith("'") && value.endsWith("'")) || (value.startsWith("\"") && value.endsWith("\""))) return value.slice(1, -1); - return value; -} - -function requiredEnvValue(values: Record, key: string, sourceRef: string): string { - const value = values[key]; - if (value === undefined || value.length === 0) throw new Error(`${sourceRef} is missing required key ${key}`); - return value; -} - -function redactRepoPath(path: string): string { - const root = rootPath(); - return path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path; -} - function boolField(value: Record | null, key: string, fallback: boolean): boolean { return typeof value?.[key] === "boolean" ? value[key] : fallback; } diff --git a/scripts/src/secrets.ts b/scripts/src/secrets.ts index 16087921..97360d61 100644 --- a/scripts/src/secrets.ts +++ b/scripts/src/secrets.ts @@ -98,6 +98,14 @@ interface DesiredSecret { fingerprint: string | null; } +export interface EnvSourceFileMaterial { + sourceRef: string; + sourcePath: string; + sourcePathRedacted: string; + values: Record; + valuesPrinted: false; +} + export function secretsHelp(): Record { return { command: "secrets plan|sync|status", @@ -673,7 +681,26 @@ function secretRoot(config: SecretDistributionConfig): string { return isAbsolute(root) ? root : rootPath(root); } -function parseEnvFile(text: string): Record { +export function readTextFile(path: string): string { + if (!existsSync(path)) throw new Error(`required secret source ${redactRepoPath(path)} is missing`); + return readFileSync(path, "utf8"); +} + +export function readEnvSourceFile(params: { root: string; sourceRef: string; missingMessage?: (sourcePath: string) => string }): EnvSourceFileMaterial { + const sourcePath = join(params.root, params.sourceRef); + if (!existsSync(sourcePath)) { + throw new Error(params.missingMessage?.(sourcePath) ?? `required secret source ${redactRepoPath(sourcePath)} is missing`); + } + return { + sourceRef: params.sourceRef, + sourcePath, + sourcePathRedacted: redactRepoPath(sourcePath), + values: parseEnvFile(readFileSync(sourcePath, "utf8")), + valuesPrinted: false, + }; +} + +export function parseEnvFile(text: string): Record { const result: Record = {}; for (const rawLine of text.split(/\r?\n/u)) { const line = rawLine.trim(); @@ -692,6 +719,16 @@ function unquoteEnvValue(value: string): string { return value; } +export function requiredEnvValue(values: Record, key: string, sourceRef: string): string { + const value = values[key]; + if (value === undefined || value.length === 0) throw new Error(`${sourceRef} is missing required key ${key}`); + return value; +} + +export function fingerprintSecretValues(values: Record, keys: string[]): string { + return fingerprintValues(values, keys); +} + function writeEnvFile(path: string, values: Record): void { mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); const lines = Object.keys(values).sort().map((key) => `${key}=${quoteEnv(values[key])}`); @@ -704,7 +741,7 @@ function quoteEnv(value: string): string { return `'${value.replaceAll("'", "'\"'\"'")}'`; } -function redactRepoPath(path: string): string { +export function redactRepoPath(path: string): string { const root = rootPath(); return path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path; }