import { createHash } from "node:crypto"; import { Buffer } from "node:buffer"; import type { UniDeskConfig } from "./config"; import { runSshCommandCapture, type SshCaptureResult } from "./ssh"; export interface PublicServiceExposure { enabled: boolean; publicBaseUrl: string; dns: { hostname: string; expectedA: string; resolvers: string[] }; frpc: { deploymentName: string; secretName: string; secretKey: string; image: string; serverAddr: string; serverPort: number; proxyName: string; remotePort: number; localIP: string; localPort: number; tokenSourceRef: string; tokenSourceKey: string; }; pk01: { route: string; caddyConfigPath: string; caddyServiceName: string; responseHeaderTimeoutSeconds: number; }; } export interface PublicServiceTarget { id: string; route: string; namespace: string; replicas: number; publicExposure: PublicServiceExposure; } export interface FrpcSecretMaterial { sourceRef: string; sourcePath: string; secretName: string; secretKey: string; frpcToml: string; fingerprint: string; valuesPrinted: false; } export async function capture(config: UniDeskConfig, route: string, args: string[], stdin: string): Promise { return await runSshCommandCapture(config, route, args, stdin); } export async function applyPk01CaddyBlock( config: UniDeskConfig, serviceId: string, exposure: PublicServiceExposure, markers: { start: string; end: string }, ): Promise> { if (!exposure.enabled) return { ok: true, action: "not-enabled" }; const block = `${markers.start} ${exposure.dns.hostname} { reverse_proxy 127.0.0.1:${exposure.frpc.remotePort} { transport http { response_header_timeout ${exposure.pk01.responseHeaderTimeoutSeconds}s } } } ${markers.end} `; const blockB64 = Buffer.from(block, "utf8").toString("base64"); const script = ` set -u tmp="$(mktemp -d)" trap 'rm -rf "$tmp"' EXIT block="$tmp/${serviceId}.caddy" printf '%s' '${blockB64}' | base64 -d >"$block" sudo python3 - ${shQuote(exposure.pk01.caddyConfigPath)} "$block" ${shQuote(markers.start)} ${shQuote(markers.end)} >"$tmp/update.out" 2>"$tmp/update.err" <<'PY' import pathlib import sys config_path = pathlib.Path(sys.argv[1]) block_path = pathlib.Path(sys.argv[2]) start = sys.argv[3] end = sys.argv[4] text = config_path.read_text(encoding="utf-8") if config_path.exists() else "" block = block_path.read_text(encoding="utf-8").strip() + "\\n" if start in text and end in text: before = text.split(start, 1)[0].rstrip() tail = text.split(end, 1)[1].lstrip() next_text = before + "\\n\\n" + block + "\\n" + tail action = "update" else: next_text = text.rstrip() + "\\n\\n" + block action = "append" tmp = config_path.with_suffix(config_path.suffix + ".unidesk-${serviceId}.tmp") tmp.write_text(next_text, encoding="utf-8") tmp.replace(config_path) print(action) PY update_rc=$? if [ "$update_rc" -eq 0 ]; then sudo caddy fmt --overwrite ${shQuote(exposure.pk01.caddyConfigPath)} >"$tmp/fmt.out" 2>"$tmp/fmt.err" fmt_rc=$? else : >"$tmp/fmt.out"; : >"$tmp/fmt.err"; fmt_rc=1 fi if [ "$fmt_rc" -eq 0 ]; then sudo caddy validate --config ${shQuote(exposure.pk01.caddyConfigPath)} >"$tmp/validate.out" 2>"$tmp/validate.err" validate_rc=$? else : >"$tmp/validate.out"; : >"$tmp/validate.err"; validate_rc=1 fi if [ "$validate_rc" -eq 0 ]; then sudo systemctl reload ${shQuote(exposure.pk01.caddyServiceName)} >"$tmp/reload.out" 2>"$tmp/reload.err" || sudo systemctl restart ${shQuote(exposure.pk01.caddyServiceName)} >>"$tmp/reload.out" 2>>"$tmp/reload.err" reload_rc=$? else : >"$tmp/reload.out"; : >"$tmp/reload.err"; reload_rc=1 fi python3 - "$update_rc" "$fmt_rc" "$validate_rc" "$reload_rc" "$tmp/update.out" "$tmp/update.err" "$tmp/fmt.out" "$tmp/fmt.err" "$tmp/validate.out" "$tmp/validate.err" "$tmp/reload.out" "$tmp/reload.err" <<'PY' import json, sys rcs = [int(value) for value in sys.argv[1:5]] def text(path): try: return open(path, encoding="utf-8", errors="replace").read()[-3000:] except FileNotFoundError: return "" payload = { "ok": all(rc == 0 for rc in rcs), "serviceId": "${serviceId}", "hostname": "${exposure.dns.hostname}", "remotePort": ${exposure.frpc.remotePort}, "caddyConfigPath": "${exposure.pk01.caddyConfigPath}", "service": "${exposure.pk01.caddyServiceName}", "managedBlock": {"start": "${markers.start}", "end": "${markers.end}"}, "steps": { "update": {"exitCode": rcs[0], "stdout": text(sys.argv[5]), "stderr": text(sys.argv[6])}, "fmt": {"exitCode": rcs[1], "stdout": text(sys.argv[7]), "stderr": text(sys.argv[8])}, "validate": {"exitCode": rcs[2], "stdout": text(sys.argv[9]), "stderr": text(sys.argv[10])}, "reload": {"exitCode": rcs[3], "stdout": text(sys.argv[11]), "stderr": text(sys.argv[12])}, }, } print(json.dumps(payload, ensure_ascii=False, indent=2)) sys.exit(0 if payload["ok"] else 1) PY `; const result = await capture(config, exposure.pk01.route, ["script"], script); const parsed = parseJsonOutput(result.stdout); return parsed ?? { ok: false, capture: compactCapture(result, { full: true }) }; } export function prepareFrpcSecret(params: { secretRoot: string; exposure: PublicServiceExposure; sourcePathRedactor: (path: string) => string; parseEnvFile: (text: string) => Record; requiredEnvValue: (values: Record, key: string, sourceRef: string) => string; readTextFile: (path: string) => string; }): FrpcSecretMaterial { const { exposure } = params; const sourcePath = `${params.secretRoot.replace(/\/+$/u, "")}/${exposure.frpc.tokenSourceRef}`; const values = params.parseEnvFile(params.readTextFile(sourcePath)); const token = params.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: params.sourcePathRedactor(sourcePath), secretName: exposure.frpc.secretName, secretKey: exposure.frpc.secretKey, frpcToml, fingerprint: fingerprintValues({ token, frpcToml }, ["token", "frpcToml"]), valuesPrinted: false, }; } export function renderFrpcManifest(target: PublicServiceTarget): 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} `; } export function publicServicePolicyChecks(yaml: string, target: PublicServiceTarget, serviceName: string): Array> { return [ { name: "no-ingress", ok: !/^\s*kind:\s*Ingress\s*$/mu.test(yaml), detail: `${serviceName} 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." }, { name: "allow-all-network-policy", ok: hasAllowAllNetworkPolicy(yaml, target.namespace), detail: `NetworkPolicy/allow-all exists in ${target.namespace}.` }, ]; } export function dryRunManifestScript(params: { yaml: string; target: PublicServiceTarget; fieldManager: string; manifestName: string }): string { const encoded = Buffer.from(params.yaml, "utf8").toString("base64"); return ` set -u tmp="$(mktemp -d)" trap 'rm -rf "$tmp"' EXIT manifest="$tmp/${params.manifestName}.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 ${params.target.namespace} >/dev/null 2>&1; then kubectl apply --server-side --dry-run=server --field-manager=${params.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": "${params.target.id}", "namespace": "${params.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 `; } export 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; } } export 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) : "", }; } export function publicHttpProbe(baseUrl: string, path: 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 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: body.slice(0, 2000), stderrTail: redactText(stderr).slice(-2000), valuesPrinted: false, }; } export function redactText(text: string): string { return text .replace(/postgres(?:ql)?:\/\/[^@\s]+@/giu, "postgresql://@") .replace(/(N8N_ENCRYPTION_KEY|PASSWORD|SECRET|TOKEN|API_KEY|DATABASE_URL)([=:]\s*)[^\s,;]+/giu, "$1$2"); } export 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")}`; } export function shQuote(value: string): string { return `'${value.replaceAll("'", "'\"'\"'")}'`; } export function escapeTomlString(value: string): string { return value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\""); } 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)); } function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); }