diff --git a/config/frontend.yaml b/config/frontend.yaml index 8fef5eda..ea4fc089 100644 --- a/config/frontend.yaml +++ b/config/frontend.yaml @@ -6,3 +6,21 @@ displayTime: timeZone: Asia/Shanghai locale: zh-CN label: 北京时间 +publicExposure: + enabled: true + publicBaseUrl: https://unidesk.pikapython.com + dns: + hostname: unidesk.pikapython.com + expectedA: 82.156.23.220 + resolvers: [1.1.1.1, 8.8.8.8, 223.5.5.5, 114.114.114.114] + upstream: + host: 152.53.229.148 + port: 18081 + pk01: + route: PK01 + caddyConfigPath: /etc/caddy/Caddyfile + caddyServiceName: caddy + responseHeaderTimeoutSeconds: 30 + probes: + healthPath: /health + rootPath: / diff --git a/scripts/cli.ts b/scripts/cli.ts index 107f2f88..e11b9f83 100644 --- a/scripts/cli.ts +++ b/scripts/cli.ts @@ -455,6 +455,20 @@ async function main(): Promise { return; } + if (top === "frontend") { + const { runFrontendPublicExposureCommand } = await import("./src/frontend-public-exposure"); + const result = await runFrontendPublicExposureCommand(readConfig(), args.slice(1)); + const ok = (result as { ok?: unknown }).ok !== false; + if (isRenderedCliResult(result)) { + emitText(result.renderedText, result.command || commandName); + if (!ok) process.exitCode = 1; + return; + } + emitJson(commandName, result, ok); + if (!ok) process.exitCode = 1; + return; + } + const config = readConfig(); const autoRemoteCiPublishPlan = autoRemoteCiPublishUserServiceDryRunPlan(config, args); if (autoRemoteCiPublishPlan.enabled && autoRemoteCiPublishPlan.host !== null) { diff --git a/scripts/src/frontend-public-exposure.ts b/scripts/src/frontend-public-exposure.ts new file mode 100644 index 00000000..2aa09e9e --- /dev/null +++ b/scripts/src/frontend-public-exposure.ts @@ -0,0 +1,450 @@ +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)]; +}