import { spawnSync } from "node:child_process"; import type { UniDeskConfig } from "./config"; import { rootPath } from "./config"; import type { RenderedCliResult } from "./output"; import { applyPk01CaddySiteBlock, caddyManagedBlockMarkers, renderSimpleReverseProxyCaddySiteBlock } from "./pk01-caddy"; import { asRecord, booleanField, integerField, parseOpsApplyOptions, parseOpsCommonOptions, readYamlRecord, recordField, stringField, } from "./platform-infra-ops-library"; const configPath = rootPath("config", "frontend.yaml"); const configLabel = "config/frontend.yaml"; const serviceName = "unidesk-frontend"; interface FrontendExposureConfig { metadata: { name: string }; publicExposure: { enabled: boolean; publicBaseUrl: string; dns: { hostname: string; expectedA: string; resolvers: string[] }; upstream: { host: string; port: number }; pk01: { route: string; caddyConfigPath: string; caddyServiceName: string; responseHeaderTimeoutSeconds: number; }; probes: { healthPath: string; rootPath: string }; }; } export async function runFrontendPublicExposureCommand(config: UniDeskConfig, args: string[]): Promise | RenderedCliResult> { const [scope = "public-exposure", action = "plan", ...rest] = args; if (scope !== "public-exposure") return { ok: false, error: "unsupported-frontend-command", args, help: frontendPublicExposureHelp() }; if (action === "plan") { const result = plan(); return wantsFull(rest) ? result : renderPlan(result); } if (action === "apply") { const options = parseOpsApplyOptions(rest); const result = await apply(config, options.confirm); return options.full || options.raw ? result : renderApply(result); } if (action === "status") { const options = parseCommon(rest); const result = status(); return options.full || options.raw ? result : renderStatus(result); } if (action === "validate") { const options = parseCommon(rest); const result = validate(); return options.full || options.raw ? result : renderValidate(result); } return { ok: false, error: "unsupported-frontend-public-exposure-command", args, help: frontendPublicExposureHelp() }; } export function frontendPublicExposureHelp(): Record { return { command: "frontend public-exposure plan|apply|status|validate", examples: [ "bun scripts/cli.ts frontend public-exposure plan", "bun scripts/cli.ts frontend public-exposure apply --dry-run", "bun scripts/cli.ts frontend public-exposure apply --confirm", "bun scripts/cli.ts frontend public-exposure status", "bun scripts/cli.ts frontend public-exposure validate", ], configPath: configLabel, description: "Expose the YAML-declared UniDesk frontend through PK01 Caddy HTTPS using a managed Caddy block.", }; } function plan(): Record { const frontend = readFrontendExposureConfig(); const exposure = frontend.publicExposure; return { ok: true, service: serviceName, configPath: configLabel, metadata: frontend.metadata, publicExposure: exposureSummary(frontend), caddy: { marker: caddyManagedBlockMarkers(serviceName).start, siteBlockFingerprint: fingerprint(renderCaddySiteBlock(frontend)), siteBlockPreview: renderCaddySiteBlock(frontend), }, policy: policyChecks(frontend), next: { dryRun: "bun scripts/cli.ts frontend public-exposure apply --dry-run", apply: "bun scripts/cli.ts frontend public-exposure apply --confirm", status: "bun scripts/cli.ts frontend public-exposure status", validate: "bun scripts/cli.ts frontend public-exposure validate", }, valuesPrinted: false, publicBaseUrl: exposure.publicBaseUrl, }; } async function apply(config: UniDeskConfig, confirm: boolean): Promise> { const frontend = readFrontendExposureConfig(); const policy = policyChecks(frontend); const failedPolicy = policy.filter((item) => item.ok === false); if (!confirm) { return { ok: failedPolicy.length === 0, mode: "dry-run", publicExposure: exposureSummary(frontend), caddy: { marker: caddyManagedBlockMarkers(serviceName).start, siteBlockFingerprint: fingerprint(renderCaddySiteBlock(frontend)), siteBlockPreview: renderCaddySiteBlock(frontend), }, policy, valuesPrinted: false, }; } if (failedPolicy.length > 0) { return { ok: false, mode: "policy-blocked", publicExposure: exposureSummary(frontend), policy, valuesPrinted: false }; } const exposure = frontend.publicExposure; const upstreamProbe = httpProbe(upstreamBaseUrl(frontend), exposure.probes.healthPath); const caddy = exposure.enabled ? await applyPk01CaddySiteBlock( config, serviceName, { enabled: exposure.enabled, dns: { hostname: exposure.dns.hostname }, frpc: { remotePort: exposure.upstream.port }, pk01: exposure.pk01, }, renderCaddySiteBlock(frontend), caddyManagedBlockMarkers(serviceName), ) : { ok: true, action: "not-enabled" }; const publicHealthProbe = exposure.enabled ? httpProbeWithRetry(() => httpProbe(exposure.publicBaseUrl, exposure.probes.healthPath), 8, 1000) : { ok: true, skipped: true }; const publicRootProbe = exposure.enabled ? httpProbeWithRetry(() => httpProbe(exposure.publicBaseUrl, exposure.probes.rootPath), 4, 1000) : { ok: true, skipped: true }; return { ok: upstreamProbe.ok === true && caddy.ok !== false && publicHealthProbe.ok === true && publicRootProbe.ok === true, mode: "confirmed", publicExposure: exposureSummary(frontend), upstreamProbe, pk01Caddy: caddy, publicHealthProbe, publicRootProbe, valuesPrinted: false, }; } function status(): Record { const frontend = readFrontendExposureConfig(); const exposure = frontend.publicExposure; const upstreamHealthProbe = httpProbe(upstreamBaseUrl(frontend), exposure.probes.healthPath); const publicHealthProbe = exposure.enabled ? httpProbe(exposure.publicBaseUrl, exposure.probes.healthPath) : { ok: true, skipped: true }; const publicRootProbe = exposure.enabled ? httpProbe(exposure.publicBaseUrl, exposure.probes.rootPath) : { ok: true, skipped: true }; return { ok: upstreamHealthProbe.ok === true && publicHealthProbe.ok === true && publicRootProbe.ok === true, publicExposure: exposureSummary(frontend), upstreamHealthProbe, publicHealthProbe, publicRootProbe, valuesPrinted: false, }; } function validate(): Record { const frontend = readFrontendExposureConfig(); const exposure = frontend.publicExposure; const dns = dnsProbe(exposure.dns.hostname); const upstreamHealthProbe = httpProbe(upstreamBaseUrl(frontend), exposure.probes.healthPath); const publicHealthProbe = exposure.enabled ? httpProbe(exposure.publicBaseUrl, exposure.probes.healthPath) : { ok: true, skipped: true }; const publicRootProbe = exposure.enabled ? httpProbe(exposure.publicBaseUrl, exposure.probes.rootPath) : { ok: true, skipped: true }; return { ok: dns.addresses.includes(exposure.dns.expectedA) && upstreamHealthProbe.ok === true && publicHealthProbe.ok === true && publicRootProbe.ok === true, publicExposure: exposureSummary(frontend), dns, upstreamHealthProbe, publicHealthProbe, publicRootProbe, valuesPrinted: false, }; } function readFrontendExposureConfig(): FrontendExposureConfig { const yaml = readYamlRecord(configPath, "UniDeskFrontendConfig"); const metadata = recordField(yaml, "metadata", "frontend"); const exposure = recordField(yaml, "publicExposure", "frontend"); const dns = recordField(exposure, "dns", "frontend.publicExposure"); const upstream = recordField(exposure, "upstream", "frontend.publicExposure"); const pk01 = recordField(exposure, "pk01", "frontend.publicExposure"); const probes = recordField(exposure, "probes", "frontend.publicExposure"); const config: FrontendExposureConfig = { metadata: { name: stringField(metadata, "name", "frontend.metadata") }, publicExposure: { enabled: booleanField(exposure, "enabled", "frontend.publicExposure"), publicBaseUrl: stringField(exposure, "publicBaseUrl", "frontend.publicExposure"), dns: { hostname: stringField(dns, "hostname", "frontend.publicExposure.dns"), expectedA: stringField(dns, "expectedA", "frontend.publicExposure.dns"), resolvers: stringArray(dns.resolvers, "frontend.publicExposure.dns.resolvers"), }, upstream: { host: stringField(upstream, "host", "frontend.publicExposure.upstream"), port: portField(upstream, "port", "frontend.publicExposure.upstream"), }, pk01: { route: stringField(pk01, "route", "frontend.publicExposure.pk01"), caddyConfigPath: stringField(pk01, "caddyConfigPath", "frontend.publicExposure.pk01"), caddyServiceName: stringField(pk01, "caddyServiceName", "frontend.publicExposure.pk01"), responseHeaderTimeoutSeconds: integerField(pk01, "responseHeaderTimeoutSeconds", "frontend.publicExposure.pk01"), }, probes: { healthPath: pathField(probes, "healthPath", "frontend.publicExposure.probes"), rootPath: pathField(probes, "rootPath", "frontend.publicExposure.probes"), }, }, }; validateFrontendExposureConfig(config); return config; } function validateFrontendExposureConfig(config: FrontendExposureConfig): void { const exposure = config.publicExposure; const url = new URL(exposure.publicBaseUrl); if (url.protocol !== "https:") throw new Error(`${configLabel}.publicExposure.publicBaseUrl must use https://`); if (url.hostname !== exposure.dns.hostname) throw new Error(`${configLabel}.publicExposure.publicBaseUrl hostname must match publicExposure.dns.hostname`); if (!/^[0-9.]+$/u.test(exposure.dns.expectedA)) throw new Error(`${configLabel}.publicExposure.dns.expectedA must be an IPv4 address`); if (exposure.pk01.responseHeaderTimeoutSeconds < 1) throw new Error(`${configLabel}.publicExposure.pk01.responseHeaderTimeoutSeconds must be >= 1`); } function policyChecks(config: FrontendExposureConfig): Array & { ok: boolean }> { const exposure = config.publicExposure; return [ { name: "enabled", ok: exposure.enabled, detail: "publicExposure.enabled must be true for public closeout" }, { name: "public-https", ok: exposure.publicBaseUrl.startsWith("https://"), detail: exposure.publicBaseUrl }, { name: "dns-matches-url", ok: new URL(exposure.publicBaseUrl).hostname === exposure.dns.hostname, detail: exposure.dns.hostname }, { name: "pk01-managed-caddy", ok: exposure.pk01.route.length > 0 && exposure.pk01.caddyConfigPath.startsWith("/"), detail: `${exposure.pk01.route}:${exposure.pk01.caddyConfigPath}` }, { name: "upstream-declared", ok: exposure.upstream.host.length > 0 && exposure.upstream.port > 0, detail: `${exposure.upstream.host}:${exposure.upstream.port}` }, ]; } function renderCaddySiteBlock(config: FrontendExposureConfig): string { const exposure = config.publicExposure; return renderSimpleReverseProxyCaddySiteBlock({ hostname: exposure.dns.hostname, upstream: `${exposure.upstream.host}:${exposure.upstream.port}`, responseHeaderTimeoutSeconds: exposure.pk01.responseHeaderTimeoutSeconds, }); } function exposureSummary(config: FrontendExposureConfig): Record { const exposure = config.publicExposure; return { enabled: exposure.enabled, publicBaseUrl: exposure.publicBaseUrl, hostname: exposure.dns.hostname, expectedA: exposure.dns.expectedA, resolvers: exposure.dns.resolvers, upstream: `${exposure.upstream.host}:${exposure.upstream.port}`, pk01Route: exposure.pk01.route, marker: caddyManagedBlockMarkers(serviceName).start, probes: exposure.probes, owner: configLabel, }; } function upstreamBaseUrl(config: FrontendExposureConfig): string { return `http://${config.publicExposure.upstream.host}:${config.publicExposure.upstream.port}`; } function httpProbeWithRetry(run: () => Record, attempts: number, delayMs: number): Record { let latest = run(); for (let attempt = 1; attempt < attempts && latest.ok !== true; attempt += 1) { spawnSync("sleep", [(delayMs / 1000).toFixed(3)]); latest = run(); } return { ...latest, attempts }; } function httpProbe(baseUrl: string, path: string): Record { const url = `${baseUrl.replace(/\/+$/u, "")}${path}`; const result = spawnSync("curl", ["-fsS", "-L", "--connect-timeout", "5", "--max-time", "15", "-o", "/dev/null", "-w", "%{http_code}", url], { encoding: "utf8" }); const status = Number(result.stdout.trim()); return { ok: result.status === 0 && Number.isInteger(status) && status >= 200 && status < 500, url, status: Number.isInteger(status) ? status : null, exitCode: result.status, stderrTail: result.status === 0 ? "" : result.stderr.slice(-1000), valuesPrinted: false, }; } function dnsProbe(hostname: string): Record & { addresses: string[] } { const result = spawnSync("getent", ["hosts", hostname], { encoding: "utf8" }); const addresses = result.stdout .split(/\r?\n/u) .map((line) => line.trim().split(/\s+/u)[0] ?? "") .filter((value) => /^\d+\.\d+\.\d+\.\d+$/u.test(value)); return { ok: result.status === 0 && addresses.length > 0, hostname, addresses, exitCode: result.status }; } function parseCommon(args: string[]): ReturnType { return parseOpsCommonOptions(args); } function wantsFull(args: string[]): boolean { return args.includes("--full") || args.includes("--raw"); } function portField(record: Record, key: string, path: string): number { const value = integerField(record, key, path); if (value < 1 || value > 65535) throw new Error(`${path}.${key} must be a TCP port`); return value; } function pathField(record: Record, key: string, path: string): string { const value = stringField(record, key, path); if (!value.startsWith("/")) throw new Error(`${path}.${key} must start with /`); return value; } function stringArray(value: unknown, path: string): string[] { if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.length === 0)) throw new Error(`${path} must be an array of strings`); return value as string[]; } function fingerprint(value: string): string { const result = spawnSync("sha256sum", { input: value, encoding: "utf8" }); return `sha256:${(result.stdout.split(/\s+/u)[0] ?? "").trim()}`; } function renderPlan(result: Record): RenderedCliResult { const exposure = rec(result.publicExposure); const failed = arr(result.policy).filter((item) => item.ok === false); const lines = [ "FRONTEND PUBLIC-EXPOSURE PLAN", ...table(["FIELD", "VALUE", "DETAIL", "VALUE"], [ ["CONFIG", str(result.configPath), "owner", str(exposure.owner)], ["PUBLIC", bool(exposure.enabled), "url", str(exposure.publicBaseUrl)], ["DNS", str(exposure.hostname), "expectedA", str(exposure.expectedA)], ["UPSTREAM", str(exposure.upstream), "pk01", str(exposure.pk01Route)], ["CADDY", str(exposure.marker), "block", str(rec(result.caddy).siteBlockFingerprint)], ["POLICY", failed.length === 0 ? "ok" : `failed=${failed.length}`, "valuesPrinted", "false"], ]), "", "NEXT", " dry-run: bun scripts/cli.ts frontend public-exposure apply --dry-run", " apply: bun scripts/cli.ts frontend public-exposure apply --confirm", " status: bun scripts/cli.ts frontend public-exposure status", ]; return rendered(result, "frontend public-exposure plan", lines); } function renderApply(result: Record): RenderedCliResult { const exposure = rec(result.publicExposure); const upstreamProbe = rec(result.upstreamProbe); const publicHealthProbe = rec(result.publicHealthProbe); const publicRootProbe = rec(result.publicRootProbe); const caddy = rec(result.pk01Caddy); const lines = [ "FRONTEND PUBLIC-EXPOSURE APPLY", ...table(["PUBLIC", "MODE", "STATUS"], [[str(exposure.publicBaseUrl), str(result.mode), result.ok === false ? "failed" : "ok"]]), "", "CHECKS", ...table(["CHECK", "OK", "DETAIL"], [ ["upstream-health", bool(upstreamProbe.ok), str(upstreamProbe.url, "-")], ["pk01-caddy", bool(caddy.ok), str(caddy.action, str(caddy.hostname, "-"))], ["public-health", bool(publicHealthProbe.ok), str(publicHealthProbe.url, "-")], ["public-root", bool(publicRootProbe.ok), str(publicRootProbe.url, "-")], ]), "", "NEXT", " status: bun scripts/cli.ts frontend public-exposure status", " validate: bun scripts/cli.ts frontend public-exposure validate", ]; return rendered(result, "frontend public-exposure apply", lines); } function renderStatus(result: Record): RenderedCliResult { const exposure = rec(result.publicExposure); const upstreamHealthProbe = rec(result.upstreamHealthProbe); const publicHealthProbe = rec(result.publicHealthProbe); const publicRootProbe = rec(result.publicRootProbe); const lines = [ "FRONTEND PUBLIC-EXPOSURE STATUS", ...table(["PUBLIC", "UPSTREAM", "STATUS"], [[str(exposure.publicBaseUrl), str(exposure.upstream), result.ok === false ? "failed" : "ok"]]), "", "CHECKS", ...table(["CHECK", "OK", "DETAIL"], [ ["upstream-health", bool(upstreamHealthProbe.ok), str(upstreamHealthProbe.url, "-")], ["public-health", bool(publicHealthProbe.ok), str(publicHealthProbe.url, "-")], ["public-root", bool(publicRootProbe.ok), str(publicRootProbe.url, "-")], ]), ]; return rendered(result, "frontend public-exposure status", lines); } function renderValidate(result: Record): RenderedCliResult { const dns = rec(result.dns); const upstreamHealthProbe = rec(result.upstreamHealthProbe); const publicHealthProbe = rec(result.publicHealthProbe); const publicRootProbe = rec(result.publicRootProbe); const lines = [ "FRONTEND PUBLIC-EXPOSURE VALIDATE", ...table(["CHECK", "OK", "DETAIL"], [ ["dns", bool(dns.ok), values(dns.addresses).map(String).join(",") || "-"], ["upstream-health", bool(upstreamHealthProbe.ok), str(upstreamHealthProbe.url, "-")], ["public-health", bool(publicHealthProbe.ok), str(publicHealthProbe.url, "-")], ["public-root", bool(publicRootProbe.ok), str(publicRootProbe.url, "-")], ]), ]; return rendered(result, "frontend public-exposure validate", lines); } function rendered(result: Record, command: string, lines: string[]): RenderedCliResult { return { ok: result.ok !== false, command, renderedText: lines.join("\n"), contentType: "text/plain" }; } function rec(value: unknown): Record { return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; } function arr(value: unknown): Record[] { return Array.isArray(value) ? value.map(rec) : []; } function values(value: unknown): unknown[] { return Array.isArray(value) ? value : []; } function str(value: unknown, fallback = "-"): string { return value === undefined || value === null || value === "" ? fallback : String(value); } function bool(value: unknown): string { return value === true ? "yes" : value === false ? "no" : "-"; } function table(headers: string[], rows: string[][]): string[] { const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => row[index]?.length ?? 0))); const renderRow = (row: string[]) => row.map((cell, index) => cell.padEnd(widths[index] ?? cell.length)).join(" ").trimEnd(); return [renderRow(headers), ...rows.map(renderRow)]; }