diff --git a/scripts/src/platform-infra-langbot.ts b/scripts/src/platform-infra-langbot.ts index f424e1cf..2ad9c01a 100644 --- a/scripts/src/platform-infra-langbot.ts +++ b/scripts/src/platform-infra-langbot.ts @@ -4,16 +4,26 @@ import { isAbsolute } from "node:path"; import type { UniDeskConfig } from "./config"; import { rootPath } from "./config"; import { startJob } from "./jobs"; -import { applyPk01CaddyBlock, prepareFrpcSecret, shQuote, type FrpcSecretMaterial } from "./platform-infra-public-service"; +import { + applyPk01CaddyBlock, + capture, + compactCapture, + dryRunManifestScript, + parseJsonOutput, + prepareFrpcSecret, + publicHttpProbe, + publicServicePolicyChecks, + redactText, + renderFrpcManifest, + 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"); const serviceName = "langbot"; const pluginRuntimeServiceName = "langbot-plugin-runtime"; const fieldManager = "unidesk-platform-langbot"; -const caddyManagedStart = "# BEGIN unidesk managed langbot"; -const caddyManagedEnd = "# END unidesk managed langbot"; interface LangBotConfig { version: number; @@ -452,7 +462,7 @@ async function apply(config: UniDeskConfig, options: ApplyOptions): Promise { const langbot = readLangBotConfig(); const target = resolveTarget(langbot, options.targetId); const secret = prepareSecretMaterial(langbot); - const probe = publicHttpProbe(target.publicExposure.publicBaseUrl, options.path, secret.values.apiKey); + const probe = publicHttpProbe(target.publicExposure.publicBaseUrl, options.path, { headers: [`X-API-Key: ${secret.values.apiKey}`] }); return { ok: probe.ok, action: "platform-infra-langbot-query", @@ -908,77 +918,13 @@ ${renderFrpcManifest(target)} `; } -function renderFrpcManifest(target: LangBotTarget): string { - const exposure = target.publicExposure; - if (!exposure.enabled) return ""; - return `--- -apiVersion: apps/v1 -kind: Deployment -metadata: - name: ${exposure.frpc.deploymentName} - namespace: ${target.namespace} - labels: - app.kubernetes.io/name: ${exposure.frpc.deploymentName} - app.kubernetes.io/component: tunnel - app.kubernetes.io/part-of: platform-infra - app.kubernetes.io/managed-by: unidesk - unidesk.ai/runtime-node: ${target.id} - unidesk.ai/public-hostname: ${exposure.dns.hostname} -spec: - replicas: 1 - selector: - matchLabels: - app.kubernetes.io/name: ${exposure.frpc.deploymentName} - app.kubernetes.io/component: tunnel - template: - metadata: - labels: - app.kubernetes.io/name: ${exposure.frpc.deploymentName} - app.kubernetes.io/component: tunnel - app.kubernetes.io/part-of: platform-infra - annotations: - unidesk.ai/public-base-url: "${exposure.publicBaseUrl}" - unidesk.ai/frp-server: "${exposure.frpc.serverAddr}:${exposure.frpc.serverPort}" - unidesk.ai/frp-remote-port: "${exposure.frpc.remotePort}" - spec: - containers: - - name: frpc - image: ${exposure.frpc.image} - imagePullPolicy: IfNotPresent - args: - - -c - - /etc/frp/frpc.toml - volumeMounts: - - name: frpc-config - mountPath: /etc/frp/frpc.toml - subPath: ${exposure.frpc.secretKey} - readOnly: true - volumes: - - name: frpc-config - secret: - secretName: ${exposure.frpc.secretName} -`; -} - function policyChecks(yaml: string, target: LangBotTarget): Array> { return [ - { name: "no-ingress", ok: !/^\s*kind:\s*Ingress\s*$/mu.test(yaml), detail: "LangBot public exposure must use PK01 Caddy+FRP, not Kubernetes Ingress." }, - { name: "no-nodeport-or-loadbalancer", ok: !/^\s*type:\s*(NodePort|LoadBalancer)\s*$/mu.test(yaml), detail: "Services must stay ClusterIP." }, - { name: "no-host-network", ok: !/^\s*hostNetwork:\s*true\s*$/mu.test(yaml), detail: "Pods must not use host network." }, - { name: "no-host-port", ok: !/^\s*hostPort:\s*[0-9]+\s*$/mu.test(yaml), detail: "Pods must not expose host ports." }, - { name: "no-cpu-memory-resources", ok: !/^\s*(cpu|memory):\s*/mu.test(yaml), detail: "No CPU/memory request or limit objects are rendered." }, + ...publicServicePolicyChecks(yaml, target, "LangBot"), { name: "no-box-docker-socket", ok: !/docker\.sock|langbot-box/u.test(yaml), detail: "LangBot Box is disabled by default and Docker socket is not mounted." }, - { name: "allow-all-network-policy", ok: hasAllowAllNetworkPolicy(yaml, target.namespace), detail: `NetworkPolicy/allow-all exists in ${target.namespace}.` }, ]; } -function hasAllowAllNetworkPolicy(yaml: string, namespaceName: string): boolean { - return yaml.split(/^---\s*$/mu).some((document) => /^\s*kind:\s*NetworkPolicy\s*$/mu.test(document) - && /^\s*name:\s*allow-all\s*$/mu.test(document) - && new RegExp(`^\\s*namespace:\\s*${escapeRegExp(namespaceName)}\\s*$`, "mu").test(document) - && /^\s*podSelector:\s*\{\}\s*$/mu.test(document)); -} - export function prepareLangBotSecretMaterial(): SecretMaterial { return prepareSecretMaterial(readLangBotConfig()); } @@ -1051,47 +997,6 @@ export function readLangBotSecretMaterial(): Record { }; } -function dryRunScript(yaml: string, target: LangBotTarget): string { - const encoded = Buffer.from(yaml, "utf8").toString("base64"); - return ` -set -u -tmp="$(mktemp -d)" -trap 'rm -rf "$tmp"' EXIT -manifest="$tmp/langbot.k8s.yaml" -printf '%s' '${encoded}' | base64 -d > "$manifest" -kubectl apply --dry-run=client -f "$manifest" >"$tmp/client.out" 2>"$tmp/client.err" -client_rc=$? -if kubectl get namespace ${target.namespace} >/dev/null 2>&1; then - kubectl apply --server-side --dry-run=server --field-manager=${fieldManager} -f "$manifest" >"$tmp/server.out" 2>"$tmp/server.err" - server_rc=$? - server_disposition=executed -else - : >"$tmp/server.err" - printf '%s\\n' 'server dry-run skipped because namespace does not exist yet' >"$tmp/server.out" - server_rc=0 - server_disposition=skipped-namespace-missing -fi -python3 - "$client_rc" "$server_rc" "$server_disposition" "$tmp/client.out" "$tmp/client.err" "$tmp/server.out" "$tmp/server.err" <<'PY' -import json, sys -client_rc, server_rc = int(sys.argv[1]), int(sys.argv[2]) -def text(path): - try: - return open(path, encoding="utf-8", errors="replace").read() - except FileNotFoundError: - return "" -payload = { - "ok": client_rc == 0 and server_rc == 0, - "target": "${target.id}", - "namespace": "${target.namespace}", - "clientDryRun": {"exitCode": client_rc, "stdout": text(sys.argv[4])[-4000:], "stderr": text(sys.argv[5])[-4000:]}, - "serverDryRun": {"exitCode": server_rc, "disposition": sys.argv[3], "stdout": text(sys.argv[6])[-4000:], "stderr": text(sys.argv[7])[-4000:]}, -} -print(json.dumps(payload, ensure_ascii=False, indent=2)) -sys.exit(0 if payload["ok"] else 1) -PY -`; -} - function applyScript(yaml: string, langbot: LangBotConfig, target: LangBotTarget, secret: SecretMaterial, frpc: FrpcSecretMaterial): string { const encodedManifest = Buffer.from(yaml, "utf8").toString("base64"); const dbPasswordB64 = Buffer.from(secret.values.dbPassword, "utf8").toString("base64"); @@ -1452,30 +1357,6 @@ PY `; } -function publicHttpProbe(baseUrl: string, path: string, apiKey: string | null): Record { - const url = `${baseUrl.replace(/\/+$/u, "")}${path}`; - const args = ["-fsS", "--connect-timeout", "10", "--max-time", "30", "-o", "-", "-w", "\n%{http_code}"]; - if (apiKey !== null) args.unshift("-H", `X-API-Key: ${apiKey}`); - args.push(url); - const result = Bun.spawnSync(["curl", ...args], { stdout: "pipe", stderr: "pipe" }); - const stdout = new TextDecoder().decode(result.stdout); - const stderr = new TextDecoder().decode(result.stderr); - const lines = stdout.split(/\r?\n/u); - const statusText = lines.pop() ?? ""; - const status = Number(statusText); - const body = lines.join("\n"); - return { - ok: result.exitCode === 0 && Number.isInteger(status) && status >= 200 && status < 500, - url, - status: Number.isInteger(status) ? status : null, - bodyBytes: Buffer.byteLength(body, "utf8"), - bodyPreview: redactText(body).slice(0, 2000), - stderrTail: redactText(stderr).slice(-2000), - apiKeyUsed: apiKey !== null, - valuesPrinted: false, - }; -} - function postgresConninfo(langbot: LangBotConfig, secret: SecretMaterial): string { const db = langbot.runtime.database; return `host=${db.host} port=${db.port} user=${db.user} dbname=${db.dbName} sslmode=${db.sslMode} connect_timeout=10`; @@ -1546,10 +1427,6 @@ function secretSummary(secret: SecretMaterial, frpc: FrpcSecretMaterial): Record }; } -async function capture(config: UniDeskConfig, route: string, args: string[], stdin: string): Promise { - return await runSshCommandCapture(config, route, args, stdin); -} - function secretRoot(langbot: LangBotConfig): string { const root = langbot.runtime.secrets.root; return isAbsolute(root) ? root : rootPath(root); @@ -1559,47 +1436,10 @@ function sqlLiteral(value: string): string { return `'${value.replaceAll("'", "''")}'`; } -function parseJsonOutput(stdout: string): Record | null { - const trimmed = stdout.trim(); - if (trimmed.length === 0) return null; - const start = trimmed.indexOf("{"); - const end = trimmed.lastIndexOf("}"); - if (start === -1 || end === -1 || end <= start) return null; - try { - const parsed = JSON.parse(trimmed.slice(start, end + 1)) as unknown; - return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed as Record : null; - } catch { - return null; - } -} - -function compactCapture(result: SshCaptureResult, options: { full?: boolean } = {}): Record { - const full = options.full ?? false; - return { - exitCode: result.exitCode, - stdoutBytes: Buffer.byteLength(result.stdout, "utf8"), - stderrBytes: Buffer.byteLength(result.stderr, "utf8"), - stdoutTail: full || result.exitCode !== 0 ? redactText(result.stdout).slice(-8000) : "", - stderrTail: full || result.exitCode !== 0 ? redactText(result.stderr).slice(-4000) : "", - }; -} - -function redactText(text: string): string { - return text - .replace(/lbk_[A-Za-z0-9_-]+/gu, "lbk_") - .replace(/(postgres(?:ql)?:\/\/)[^@\s"']+@/giu, "$1@") - .replace(/(Bearer\s+)[A-Za-z0-9._~+/=-]+/giu, "$1") - .replace(/(["']?(?:token|password|secret|api[_-]?key|apikey|jwt[_-]?secret|database[_-]?url)["']?\s*[:=]\s*["']?)[^"',\s}]+(["']?)/giu, "$1$2"); -} - function boolField(value: Record | null, key: string, fallback: boolean): boolean { return typeof value?.[key] === "boolean" ? value[key] : fallback; } -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); -} - function asRecord(value: unknown, path: string): Record { if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be a YAML object`); return value as Record; diff --git a/scripts/src/platform-infra-n8n.ts b/scripts/src/platform-infra-n8n.ts index 781d2534..5a765de4 100644 --- a/scripts/src/platform-infra-n8n.ts +++ b/scripts/src/platform-infra-n8n.ts @@ -26,8 +26,6 @@ const configFile = rootPath("config", "platform-infra", "n8n.yaml"); const configLabel = "config/platform-infra/n8n.yaml"; const serviceName = "n8n"; const fieldManager = "unidesk-platform-n8n"; -const caddyManagedStart = "# BEGIN unidesk managed n8n"; -const caddyManagedEnd = "# END unidesk managed n8n"; interface N8nConfig { version: number; @@ -292,7 +290,7 @@ async function apply(config: UniDeskConfig, options: ApplyOptions): Promise> { return await applyPk01CaddyManagedBlock(config, serviceId, exposure, markers); } @@ -227,9 +227,15 @@ export function compactCapture(result: SshCaptureResult, options: { full?: boole }; } -export function publicHttpProbe(baseUrl: string, path: string): Record { +export function publicHttpProbe(baseUrl: string, path: string, options: { headers?: string[] } = {}): Record { const url = `${baseUrl.replace(/\/+$/u, "")}${path}`; - const result = Bun.spawnSync(["curl", "-fsS", "--connect-timeout", "10", "--max-time", "30", "-o", "-", "-w", "\n%{http_code}", url], { stdout: "pipe", stderr: "pipe" }); + const args = ["-fsS", "--connect-timeout", "10", "--max-time", "30", "-o", "-", "-w", "\n%{http_code}"]; + for (const header of options.headers ?? []) { + args.unshift(header); + args.unshift("-H"); + } + args.push(url); + const result = Bun.spawnSync(["curl", ...args], { stdout: "pipe", stderr: "pipe" }); const stdout = new TextDecoder().decode(result.stdout); const stderr = new TextDecoder().decode(result.stderr); const lines = stdout.split(/\r?\n/u); @@ -241,16 +247,19 @@ export function publicHttpProbe(baseUrl: string, path: string): Record@") - .replace(/(N8N_ENCRYPTION_KEY|PASSWORD|SECRET|TOKEN|API_KEY|DATABASE_URL)([=:]\s*)[^\s,;]+/giu, "$1$2"); + .replace(/lbk_[A-Za-z0-9_-]+/gu, "lbk_") + .replace(/(postgres(?:ql)?:\/\/)[^@\s"']+@/giu, "$1@") + .replace(/(Bearer\s+)[A-Za-z0-9._~+/=-]+/giu, "$1") + .replace(/(["']?(?:N8N_ENCRYPTION_KEY|PASSWORD|SECRET|TOKEN|API[_-]?KEY|APIKEY|JWT[_-]?SECRET|DATABASE[_-]?URL)["']?\s*[:=]\s*["']?)[^"',\s}]+(["']?)/giu, "$1$2"); } export function fingerprintValues(values: Record, keys: string[]): string {