import { spawnSync, type SpawnSyncReturns } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname } from "node:path"; import type { UniDeskConfig } from "./config"; import { rootPath } from "./config"; import type { RenderedCliResult } from "./output"; import { applyPk01CaddySiteBlock, caddyManagedBlockMarkers } from "./pk01-caddy"; import { asRecord, booleanField, integerField, parseOpsApplyOptions, parseOpsCommonOptions, readYamlRecord, recordField, stringField, } from "./platform-infra-ops-library"; const configPath = rootPath("config", "platform-infra", "webterm.yaml"); const serviceName = "webterm"; interface WebtermConfig { defaults: { targetId: string }; targets: WebtermTarget[]; } interface WebtermTarget { id: string; enabled: boolean; runtime: { mode: "host-docker"; workDir: string; composePath: string; composeProject: string; serviceName: string; containerName: string; image: string; envFile: string; hostPort: number; containerPort: number; privileged: boolean; pid: "host" | "container"; sourceMounts: Array<{ source: string; target: string; readOnly: boolean }>; environment: Record; }; 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; }; }; } export async function runWebtermCommand(config: UniDeskConfig, args: string[]): Promise | RenderedCliResult> { const [action = "plan", ...rest] = args; if (action === "plan") { const result = plan(parseCommon(rest).targetId); return wantsFull(rest) ? result : renderPlan(result); } if (action === "apply") { const options = parseOpsApplyOptions(rest); const result = await apply(config, options.targetId, options.confirm); return options.full || options.raw ? result : renderApply(result); } if (action === "status") { const options = parseCommon(rest); const result = status(options.targetId); return options.full || options.raw ? result : renderStatus(result); } if (action === "validate") { const options = parseCommon(rest); const result = validate(options.targetId); return options.full || options.raw ? result : renderValidate(result); } return { ok: false, error: "unsupported-webterm-command", args, help: webtermHelp(), }; } export function webtermHelp(): Record { return { command: "platform-infra webterm plan|apply|status|validate", examples: [ "bun scripts/cli.ts platform-infra webterm plan", "bun scripts/cli.ts platform-infra webterm apply --dry-run", "bun scripts/cli.ts platform-infra webterm apply --confirm", "bun scripts/cli.ts platform-infra webterm status", "bun scripts/cli.ts platform-infra webterm validate", ], configPath, description: "Deploy the host-Docker Web Terminal from YAML and expose it through PK01 Caddy HTTPS without touching the existing 7682 service.", }; } function plan(targetId: string | null): Record { const target = resolveTarget(targetId); const compose = renderCompose(target); return { ok: true, service: serviceName, configPath, target: targetSummary(target), compose: { path: target.runtime.composePath, project: target.runtime.composeProject, service: target.runtime.serviceName, container: target.runtime.containerName, fingerprint: fingerprint(compose), hostPort: target.runtime.hostPort, containerPort: target.runtime.containerPort, }, publicExposure: exposureSummary(target), policy: policyChecks(target, compose), next: { dryRun: "bun scripts/cli.ts platform-infra webterm apply --dry-run", apply: "bun scripts/cli.ts platform-infra webterm apply --confirm", status: "bun scripts/cli.ts platform-infra webterm status", validate: "bun scripts/cli.ts platform-infra webterm validate", }, valuesPrinted: false, }; } async function apply(config: UniDeskConfig, targetId: string | null, confirm: boolean): Promise> { const target = resolveTarget(targetId); const compose = renderCompose(target); const policy = policyChecks(target, compose); const failedPolicy = policy.filter((item) => item.ok === false); if (!confirm) { return { ok: failedPolicy.length === 0, mode: "dry-run", target: targetSummary(target), compose: { path: target.runtime.composePath, fingerprint: fingerprint(compose), preview: compose }, publicExposure: exposureSummary(target), policy, valuesPrinted: false, }; } if (failedPolicy.length > 0) { return { ok: false, mode: "policy-blocked", target: targetSummary(target), policy, valuesPrinted: false, }; } mkdirSync(dirname(target.runtime.composePath), { recursive: true }); writeFileSync(target.runtime.composePath, compose, { encoding: "utf8", mode: 0o644 }); const composeResult = spawnSync("docker", ["compose", "-f", target.runtime.composePath, "-p", target.runtime.composeProject, "up", "-d", "--force-recreate"], { cwd: target.runtime.workDir, encoding: "utf8", }); const localProbe = localHttpProbeWithRetry(target.runtime.hostPort, "/login", 10, 500); const caddy = target.publicExposure.enabled ? await applyPk01CaddySiteBlock( config, serviceName, caddyExposure(target), renderWebtermCaddySiteBlock({ hostname: target.publicExposure.dns.hostname, upstream: `${target.publicExposure.upstream.host}:${target.publicExposure.upstream.port}`, responseHeaderTimeoutSeconds: target.publicExposure.pk01.responseHeaderTimeoutSeconds, }), caddyManagedBlockMarkers(serviceName), ) : { ok: true, action: "not-enabled" }; const publicProbe = target.publicExposure.enabled ? publicHttpProbeWithRetry(target.publicExposure.publicBaseUrl, "/login", 6, 1000) : { ok: true, skipped: true }; return { ok: composeResult.status === 0 && localProbe.ok === true && caddy.ok !== false && publicProbe.ok === true, mode: "confirmed", target: targetSummary(target), compose: { path: target.runtime.composePath, project: target.runtime.composeProject, fingerprint: fingerprint(compose), up: commandSummary(composeResult), }, localProbe, pk01Caddy: caddy, publicProbe, valuesPrinted: false, }; } function status(targetId: string | null): Record { const target = resolveTarget(targetId); const inspect = spawnSync("docker", ["inspect", target.runtime.containerName], { encoding: "utf8" }); const ps = spawnSync("docker", ["ps", "--filter", `name=^/${target.runtime.containerName}$`, "--format", "{{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}"], { encoding: "utf8" }); const localProbe = localHttpProbe(target.runtime.hostPort, "/login"); const publicProbe = target.publicExposure.enabled ? publicHttpProbe(target.publicExposure.publicBaseUrl, "/login") : { ok: true, skipped: true }; return { ok: ps.status === 0 && ps.stdout.trim().length > 0 && localProbe.ok === true && publicProbe.ok === true, target: targetSummary(target), compose: { path: target.runtime.composePath, exists: existsSync(target.runtime.composePath), fingerprint: existsSync(target.runtime.composePath) ? fingerprint(readFileSync(target.runtime.composePath, "utf8")) : null, }, container: { exists: inspect.status === 0, ps: ps.stdout.trim(), }, localProbe, publicProbe, valuesPrinted: false, }; } function validate(targetId: string | null): Record { const target = resolveTarget(targetId); const dns = dnsProbe(target.publicExposure.dns.hostname); const localProbe = localHttpProbe(target.runtime.hostPort, "/login"); const publicProbe = target.publicExposure.enabled ? publicHttpProbe(target.publicExposure.publicBaseUrl, "/login") : { ok: true, skipped: true }; return { ok: localProbe.ok === true && publicProbe.ok === true && dns.addresses.includes(target.publicExposure.dns.expectedA), target: targetSummary(target), dns, localProbe, publicProbe, valuesPrinted: false, }; } function readWebtermConfig(): WebtermConfig { const yaml = readYamlRecord(configPath, "platform-infra-webterm"); const defaults = recordField(yaml, "defaults", "webterm"); const targetsRaw = yaml.targets; if (!Array.isArray(targetsRaw)) throw new Error("webterm.targets must be an array"); return { defaults: { targetId: stringField(defaults, "targetId", "webterm.defaults") }, targets: targetsRaw.map((item, index) => parseTarget(asRecord(item, `webterm.targets[${index}]`), `webterm.targets[${index}]`)), }; } function parseTarget(record: Record, path: string): WebtermTarget { const runtime = recordField(record, "runtime", path); const exposure = recordField(record, "publicExposure", path); const dns = recordField(exposure, "dns", `${path}.publicExposure`); const upstream = recordField(exposure, "upstream", `${path}.publicExposure`); const pk01 = recordField(exposure, "pk01", `${path}.publicExposure`); const environment = parseEnvironment(recordField(runtime, "environment", `${path}.runtime`), `${path}.runtime.environment`); const sourceMounts = parseSourceMounts(runtime.sourceMounts, `${path}.runtime.sourceMounts`); const target: WebtermTarget = { id: stringField(record, "id", path), enabled: booleanField(record, "enabled", path), runtime: { mode: parseLiteral(stringField(runtime, "mode", `${path}.runtime`), "host-docker", `${path}.runtime.mode`), workDir: stringField(runtime, "workDir", `${path}.runtime`), composePath: stringField(runtime, "composePath", `${path}.runtime`), composeProject: stringField(runtime, "composeProject", `${path}.runtime`), serviceName: stringField(runtime, "serviceName", `${path}.runtime`), containerName: stringField(runtime, "containerName", `${path}.runtime`), image: stringField(runtime, "image", `${path}.runtime`), envFile: stringField(runtime, "envFile", `${path}.runtime`), hostPort: portField(runtime, "hostPort", `${path}.runtime`), containerPort: portField(runtime, "containerPort", `${path}.runtime`), privileged: booleanField(runtime, "privileged", `${path}.runtime`), pid: parsePid(stringField(runtime, "pid", `${path}.runtime`), `${path}.runtime.pid`), sourceMounts, environment, }, publicExposure: { enabled: booleanField(exposure, "enabled", `${path}.publicExposure`), publicBaseUrl: stringField(exposure, "publicBaseUrl", `${path}.publicExposure`), dns: { hostname: stringField(dns, "hostname", `${path}.publicExposure.dns`), expectedA: stringField(dns, "expectedA", `${path}.publicExposure.dns`), resolvers: stringArray(dns.resolvers, `${path}.publicExposure.dns.resolvers`), }, upstream: { host: stringField(upstream, "host", `${path}.publicExposure.upstream`), port: portField(upstream, "port", `${path}.publicExposure.upstream`), }, pk01: { route: stringField(pk01, "route", `${path}.publicExposure.pk01`), caddyConfigPath: stringField(pk01, "caddyConfigPath", `${path}.publicExposure.pk01`), caddyServiceName: stringField(pk01, "caddyServiceName", `${path}.publicExposure.pk01`), responseHeaderTimeoutSeconds: integerField(pk01, "responseHeaderTimeoutSeconds", `${path}.publicExposure.pk01`), }, }, }; if (target.runtime.hostPort === 7682) throw new Error(`${path}.runtime.hostPort must not reuse existing 7682`); if (target.runtime.containerName === "web-terminal-7682") throw new Error(`${path}.runtime.containerName must not reuse web-terminal-7682`); if (!target.publicExposure.publicBaseUrl.startsWith("https://")) throw new Error(`${path}.publicExposure.publicBaseUrl must be https`); return target; } function resolveTarget(targetId: string | null): WebtermTarget { const config = readWebtermConfig(); const id = targetId ?? config.defaults.targetId; const target = config.targets.find((item) => item.id === id); if (target === undefined) throw new Error(`unknown webterm target: ${id}`); return target; } function renderCompose(target: WebtermTarget): string { const envLines = Object.entries(target.runtime.environment) .map(([key, value]) => ` ${key}: ${quoteYaml(value)}`) .join("\n"); const volumeLines = target.runtime.sourceMounts .map((mount) => ` - ${quoteYaml(`${mount.source}:${mount.target}${mount.readOnly ? ":ro" : ""}`)}`) .join("\n"); return `name: ${target.runtime.composeProject} services: ${target.runtime.serviceName}: image: ${target.runtime.image} container_name: ${target.runtime.containerName} restart: unless-stopped ports: - "${target.runtime.hostPort}:${target.runtime.containerPort}" privileged: ${target.runtime.privileged ? "true" : "false"} pid: ${target.runtime.pid} env_file: - ${target.runtime.envFile} volumes: ${volumeLines} environment: ${envLines} `; } function policyChecks(target: WebtermTarget, compose: string): Array & { ok: boolean }> { return [ { name: "enabled", ok: target.enabled, detail: "target must be enabled" }, { name: "no-7682-host-port", ok: target.runtime.hostPort !== 7682, detail: "existing 7682 service must not be reused" }, { name: "no-7682-container", ok: target.runtime.containerName !== "web-terminal-7682", detail: "existing 7682 container must not be reused" }, { name: "compose-path-7683", ok: target.runtime.composePath.includes("7683"), detail: target.runtime.composePath }, { name: "public-https", ok: target.publicExposure.publicBaseUrl.startsWith("https://"), detail: target.publicExposure.publicBaseUrl }, { name: "compose-renders-7683", ok: compose.includes(`"${target.runtime.hostPort}:${target.runtime.containerPort}"`), detail: `${target.runtime.hostPort}:${target.runtime.containerPort}` }, { name: "source-mounts-present", ok: target.runtime.sourceMounts.length >= 3, detail: `mounts=${target.runtime.sourceMounts.length}` }, ]; } function caddyExposure(target: WebtermTarget): Parameters[2] { return { enabled: target.publicExposure.enabled, dns: { hostname: target.publicExposure.dns.hostname }, frpc: { remotePort: target.publicExposure.upstream.port }, pk01: target.publicExposure.pk01, }; } function renderWebtermCaddySiteBlock(params: { hostname: string; upstream: string; responseHeaderTimeoutSeconds: number; }): string { return `${params.hostname} { reverse_proxy ${params.upstream} { flush_interval -1 stream_close_delay 5m transport http { response_header_timeout ${params.responseHeaderTimeoutSeconds}s } } }`; } function targetSummary(target: WebtermTarget): Record { return { id: target.id, enabled: target.enabled, mode: target.runtime.mode, workDir: target.runtime.workDir, composeProject: target.runtime.composeProject, containerName: target.runtime.containerName, image: target.runtime.image, sourceMounts: target.runtime.sourceMounts.map((mount) => `${mount.source}->${mount.target}${mount.readOnly ? ":ro" : ""}`), hostPort: target.runtime.hostPort, containerPort: target.runtime.containerPort, }; } function exposureSummary(target: WebtermTarget): Record { return { enabled: target.publicExposure.enabled, publicBaseUrl: target.publicExposure.publicBaseUrl, hostname: target.publicExposure.dns.hostname, expectedA: target.publicExposure.dns.expectedA, upstream: `${target.publicExposure.upstream.host}:${target.publicExposure.upstream.port}`, pk01Route: target.publicExposure.pk01.route, marker: caddyManagedBlockMarkers(serviceName).start, }; } function localHttpProbe(port: number, path: string): Record { return httpProbe(`http://127.0.0.1:${port}`, path); } function publicHttpProbe(baseUrl: string, path: string): Record { return httpProbe(baseUrl, path); } function localHttpProbeWithRetry(port: number, path: string, attempts: number, delayMs: number): Record { return httpProbeWithRetry(() => localHttpProbe(port, path), attempts, delayMs); } function publicHttpProbeWithRetry(baseUrl: string, path: string, attempts: number, delayMs: number): Record { return httpProbeWithRetry(() => publicHttpProbe(baseUrl, path), attempts, delayMs); } 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), }; } 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 commandSummary(result: SpawnSyncReturns): Record { return { exitCode: result.status, stdoutTail: result.status === 0 ? result.stdout.slice(-2000) : result.stdout.slice(-4000), stderrTail: result.status === 0 ? "" : result.stderr.slice(-4000), }; } function parseCommon(args: string[]): ReturnType { return parseOpsCommonOptions(args); } function wantsFull(args: string[]): boolean { return args.includes("--full") || args.includes("--raw"); } function parseEnvironment(record: Record, path: string): Record { const result: Record = {}; for (const [key, value] of Object.entries(record)) { if (!/^[A-Z_][A-Z0-9_]*$/u.test(key)) throw new Error(`${path}.${key} must be an env key`); if (typeof value !== "string") throw new Error(`${path}.${key} must be a string`); result[key] = value; } return result; } function parseSourceMounts(value: unknown, path: string): WebtermTarget["runtime"]["sourceMounts"] { if (!Array.isArray(value)) throw new Error(`${path} must be an array`); return value.map((item, index) => { const record = asRecord(item, `${path}[${index}]`); return { source: stringField(record, "source", `${path}[${index}]`), target: stringField(record, "target", `${path}[${index}]`), readOnly: booleanField(record, "readOnly", `${path}[${index}]`), }; }); } function parseLiteral(value: string, expected: T, path: string): T { if (value !== expected) throw new Error(`${path} must be ${expected}`); return expected; } function parsePid(value: string, path: string): "host" | "container" { if (value !== "host" && value !== "container") throw new Error(`${path} must be host or container`); return value; } 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 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 quoteYaml(value: string): string { return JSON.stringify(value); } 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 target = rec(result.target); const exposure = rec(result.publicExposure); const failed = arr(result.policy).filter((item) => item.ok === false); const lines = [ "PLATFORM-INFRA WEBTERM PLAN", ...table(["FIELD", "VALUE", "DETAIL", "VALUE"], [ ["TARGET", str(target.id), "mode", str(target.mode)], ["PORT", str(target.hostPort), "container", str(target.containerName)], ["IMAGE", str(target.image), "compose", str(rec(result.compose).path)], ["PUBLIC", bool(exposure.enabled), "url", str(exposure.publicBaseUrl)], ["UPSTREAM", str(exposure.upstream), "pk01", str(exposure.pk01Route)], ["POLICY", failed.length === 0 ? "ok" : `failed=${failed.length}`, "valuesPrinted", "false"], ]), "", "NEXT", " dry-run: bun scripts/cli.ts platform-infra webterm apply --dry-run", " apply: bun scripts/cli.ts platform-infra webterm apply --confirm", " status: bun scripts/cli.ts platform-infra webterm status", ]; return rendered(result, "platform-infra webterm plan", lines); } function renderApply(result: Record): RenderedCliResult { const target = rec(result.target); const compose = rec(result.compose); const localProbe = rec(result.localProbe); const publicProbe = rec(result.publicProbe); const caddy = rec(result.pk01Caddy); const lines = [ "PLATFORM-INFRA WEBTERM APPLY", ...table(["TARGET", "PORT", "MODE", "STATUS"], [[str(target.id), str(target.hostPort), str(result.mode), result.ok === false ? "failed" : "ok"]]), "", "COMPOSE", ...table(["PATH", "PROJECT", "UP"], [[str(compose.path), str(target.composeProject), str(rec(compose.up).exitCode, "-")]]), "", "CHECKS", ...table(["CHECK", "OK", "DETAIL"], [ ["local", bool(localProbe.ok), str(localProbe.url, "-")], ["pk01Caddy", bool(caddy.ok), str(caddy.action, str(caddy.hostname, "-"))], ["public", bool(publicProbe.ok), str(publicProbe.url, "-")], ]), "", "NEXT", " status: bun scripts/cli.ts platform-infra webterm status", " validate: bun scripts/cli.ts platform-infra webterm validate", ]; return rendered(result, "platform-infra webterm apply", lines); } function renderStatus(result: Record): RenderedCliResult { const target = rec(result.target); const container = rec(result.container); const localProbe = rec(result.localProbe); const publicProbe = rec(result.publicProbe); const lines = [ "PLATFORM-INFRA WEBTERM STATUS", ...table(["TARGET", "PORT", "CONTAINER", "STATUS"], [[str(target.id), str(target.hostPort), str(target.containerName), result.ok === false ? "failed" : "ok"]]), "", "CONTAINER", str(container.ps, "-"), "", "CHECKS", ...table(["CHECK", "OK", "DETAIL"], [ ["local", bool(localProbe.ok), str(localProbe.url, "-")], ["public", bool(publicProbe.ok), str(publicProbe.url, "-")], ]), ]; return rendered(result, "platform-infra webterm status", lines); } function renderValidate(result: Record): RenderedCliResult { const dns = rec(result.dns); const localProbe = rec(result.localProbe); const publicProbe = rec(result.publicProbe); const lines = [ "PLATFORM-INFRA WEBTERM VALIDATE", ...table(["CHECK", "OK", "DETAIL"], [ ["dns", bool(dns.ok), values(dns.addresses).map(String).join(",") || "-"], ["local", bool(localProbe.ok), str(localProbe.url, "-")], ["public", bool(publicProbe.ok), str(publicProbe.url, "-")], ]), ]; return rendered(result, "platform-infra webterm 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)]; }