From 153ffb4c47e4d1f091811b89ec4560e3382108b2 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 13 Jun 2026 14:04:10 +0000 Subject: [PATCH] fix: preserve PK01 Caddy managed blocks --- scripts/src/agentrun.ts | 58 +-- scripts/src/pk01-caddy.ts | 424 +++++++++++++++++++ scripts/src/platform-infra-langbot.ts | 96 +---- scripts/src/platform-infra-public-service.ts | 92 +--- scripts/src/platform-infra-sub2api-codex.ts | 71 +--- scripts/src/platform-infra.ts | 27 +- 6 files changed, 467 insertions(+), 301 deletions(-) create mode 100644 scripts/src/pk01-caddy.ts diff --git a/scripts/src/agentrun.ts b/scripts/src/agentrun.ts index 5959132b..e34e8179 100644 --- a/scripts/src/agentrun.ts +++ b/scripts/src/agentrun.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { spawnSync } from "node:child_process"; import { rootPath, type UniDeskConfig } from "./config"; import type { RenderedCliResult } from "./output"; +import { applyLocalCaddyManagedSite } from "./pk01-caddy"; import { runSshCommandCapture, type SshCaptureResult } from "./ssh"; import { runRemoteSshCommandCapture } from "./remote"; import { startJob } from "./jobs"; @@ -2506,37 +2507,20 @@ function applyAgentRunMasterCaddySite(exposure: AgentRunPublicExposure): Record< if (!caddy.enabled) return { ok: true, action: "disabled-by-yaml", valuesPrinted: false }; const pathValue = caddy.configPath; if (!existsSync(pathValue)) return { ok: false, error: "master-caddy-config-missing", path: pathValue, valuesPrinted: false }; - const before = readFileSync(pathValue, "utf8"); const desiredBlock = renderAgentRunCaddySiteBlock(caddy.domain, caddy.upstreamBaseUrl, caddy.responseHeaderTimeoutSeconds); - const existing = caddySiteBlock(before, caddy.domain); - const alreadyConfigured = existing === desiredBlock; - let backupPath: string | null = null; - let action = "kept-existing"; - if (!alreadyConfigured) { - const stamp = new Date().toISOString().replace(/[-:]/gu, "").replace(/\..*$/u, "Z"); - backupPath = `${pathValue}.bak-agentrun-https-${stamp}`; - copyFileSync(pathValue, backupPath); - const next = existing === null ? `${before.replace(/\s*$/u, "")}\n\n${desiredBlock}\n` : before.replace(existing, desiredBlock); - writeFileSync(pathValue, next, "utf8"); - chmodSync(pathValue, statSync(backupPath).mode & 0o777); - action = existing === null ? "added-site" : "updated-site"; - } - const validate = spawnSync("caddy", ["validate", "--config", pathValue, "--adapter", "caddyfile"], { encoding: "utf8" }); - const reload = validate.status === 0 && !alreadyConfigured ? spawnSync("systemctl", ["reload", caddy.serviceName], { encoding: "utf8" }) : null; - const active = spawnSync("systemctl", ["is-active", caddy.serviceName], { encoding: "utf8" }); - const ok = validate.status === 0 && (reload === null || reload.status === 0) && active.status === 0 && String(active.stdout).trim() === "active"; + const applied = applyLocalCaddyManagedSite({ + serviceId: "agentrun", + markerName: "agentrun", + configPath: pathValue, + serviceName: caddy.serviceName, + siteBlock: desiredBlock, + legacySiteDomain: caddy.domain, + }); return { - ok, - action, - path: pathValue, - backupPath, + ...applied, domain: caddy.domain, upstreamBaseUrl: caddy.upstreamBaseUrl, - serviceName: caddy.serviceName, responseHeaderTimeoutSeconds: caddy.responseHeaderTimeoutSeconds, - validate: { exitCode: validate.status, stdoutTail: String(validate.stdout).slice(-1000), stderrTail: String(validate.stderr).slice(-2000) }, - reload: reload === null ? null : { exitCode: reload.status, stdoutTail: String(reload.stdout).slice(-1000), stderrTail: String(reload.stderr).slice(-1000) }, - active: { exitCode: active.status, stdoutTail: String(active.stdout).slice(-1000), stderrTail: String(active.stderr).slice(-1000) }, valuesPrinted: false, }; } @@ -2557,24 +2541,6 @@ function renderAgentRunCaddySiteBlock(domain: string, upstreamBaseUrl: string, r }`; } -function caddySiteBlock(text: string, domain: string): string | null { - const startMatch = new RegExp(`(^|\\n)${escapeRegExp(domain)}\\s*\\{`, "u").exec(text); - if (startMatch === null) return null; - const start = startMatch.index + (startMatch[1] === "\n" ? 1 : 0); - const open = text.indexOf("{", start); - if (open < 0) return null; - let depth = 0; - for (let index = open; index < text.length; index += 1) { - const ch = text[index]; - if (ch === "{") depth += 1; - if (ch === "}") { - depth -= 1; - if (depth === 0) return text.slice(start, index + 1); - } - } - return null; -} - function frpsAllowPortExists(toml: string, port: number): boolean { const sections = toml.split(/(?=\[\[allowPorts\]\])/u); return sections.some((section) => { @@ -2585,10 +2551,6 @@ function frpsAllowPortExists(toml: string, port: number): boolean { }); } -function escapeRegExp(value: string): string { - return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); -} - async function triggerCurrent(config: UniDeskConfig, options: TriggerOptions): Promise> { if (options.node !== null || options.lane !== null) { const target = resolveAgentRunLaneTarget(options); diff --git a/scripts/src/pk01-caddy.ts b/scripts/src/pk01-caddy.ts new file mode 100644 index 00000000..9964a450 --- /dev/null +++ b/scripts/src/pk01-caddy.ts @@ -0,0 +1,424 @@ +import { chmodSync, copyFileSync, existsSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs"; +import { spawnSync, type SpawnSyncReturns } from "node:child_process"; +import { Buffer } from "node:buffer"; +import type { UniDeskConfig } from "./config"; +import { runSshCommandCapture, type SshCaptureResult } from "./ssh"; + +export interface Pk01CaddyBlockExposure { + enabled: boolean; + dns: { hostname: string }; + frpc: { remotePort: number }; + pk01: { + route: string; + caddyConfigPath: string; + caddyServiceName: string; + responseHeaderTimeoutSeconds: number; + }; +} + +export interface Pk01CaddyManagedBlockMarkers { + start: string; + end: string; +} + +export interface LocalCaddyManagedSiteOptions { + serviceId: string; + markerName: string; + configPath: string; + serviceName: string; + siteBlock: string; + legacySiteDomain?: string; +} + +export function caddyManagedBlockMarkers(markerName: string): Pk01CaddyManagedBlockMarkers { + return { + start: `# BEGIN unidesk managed ${markerName}`, + end: `# END unidesk managed ${markerName}`, + }; +} + +export function renderCaddyManagedBlock(markerName: string, siteBlock: string): string { + const markers = caddyManagedBlockMarkers(markerName); + return `${markers.start} +${siteBlock.trim()} +${markers.end} +`; +} + +export function renderSimpleReverseProxyCaddySiteBlock(params: { + hostname: string; + upstream: string; + responseHeaderTimeoutSeconds: number; +}): string { + return `${params.hostname} { + reverse_proxy ${params.upstream} { + transport http { + response_header_timeout ${params.responseHeaderTimeoutSeconds}s + } + } +}`; +} + +export async function applyPk01CaddyManagedBlock( + config: UniDeskConfig, + serviceId: string, + exposure: Pk01CaddyBlockExposure, + markers: Pk01CaddyManagedBlockMarkers, +): Promise> { + if (!exposure.enabled) return { ok: true, action: "not-enabled" }; + const block = renderCaddyManagedBlock( + markerNameFromMarkers(markers), + renderSimpleReverseProxyCaddySiteBlock({ + hostname: exposure.dns.hostname, + upstream: `127.0.0.1:${exposure.frpc.remotePort}`, + responseHeaderTimeoutSeconds: exposure.pk01.responseHeaderTimeoutSeconds, + }), + ); + const blockB64 = Buffer.from(block, "utf8").toString("base64"); + const script = ` +set -u +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +block="$tmp/${serviceId}.caddy" +next="$tmp/Caddyfile.next" +printf '%s' '${blockB64}' | base64 -d >"$block" +sudo python3 - ${shQuote(exposure.pk01.caddyConfigPath)} "$block" "$next" >"$tmp/update.out" 2>"$tmp/update.err" <<'PY' +${pk01CaddyApplyManagedBlockPython()} +PY +update_rc=$? +if [ "$update_rc" -eq 0 ]; then + sudo caddy fmt --overwrite "$next" >"$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 "$next" >"$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 install -m 0644 "$next" ${shQuote(exposure.pk01.caddyConfigPath)} >"$tmp/install.out" 2>"$tmp/install.err" + install_rc=$? +else + : >"$tmp/install.out"; : >"$tmp/install.err"; install_rc=1 +fi +if [ "$install_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" "$install_rc" "$reload_rc" "$tmp/update.out" "$tmp/update.err" "$tmp/fmt.out" "$tmp/fmt.err" "$tmp/validate.out" "$tmp/validate.err" "$tmp/install.out" "$tmp/install.err" "$tmp/reload.out" "$tmp/reload.err" <<'PY' +import json, sys +rcs = [int(value) for value in sys.argv[1:6]] +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[6]), "stderr": text(sys.argv[7])}, + "fmt": {"exitCode": rcs[1], "stdout": text(sys.argv[8]), "stderr": text(sys.argv[9])}, + "validate": {"exitCode": rcs[2], "stdout": text(sys.argv[10]), "stderr": text(sys.argv[11])}, + "install": {"exitCode": rcs[3], "stdout": text(sys.argv[12]), "stderr": text(sys.argv[13])}, + "reload": {"exitCode": rcs[4], "stdout": text(sys.argv[14]), "stderr": text(sys.argv[15])}, + }, +} +print(json.dumps(payload, ensure_ascii=False, indent=2)) +sys.exit(0 if payload["ok"] else 1) +PY +`; + const result = await runSshCommandCapture(config, exposure.pk01.route, ["script"], script); + const parsed = parseJsonOutput(result.stdout); + return parsed ?? { ok: false, capture: compactCapture(result, { full: true }) }; +} + +export function mergeDesiredCaddyfilePreservingManagedBlocksScript(params: { + desiredPath: string; + currentPath: string; + mergedPath: string; + outputPath: string; + errorPath: string; + legacySiteDomains?: string[]; +}): string { + const domains = params.legacySiteDomains ?? []; + return `sudo python3 - ${shQuote(params.desiredPath)} ${shQuote(params.currentPath)} ${shQuote(params.mergedPath)} ${domains.map(shQuote).join(" ")} >${shQuote(params.outputPath)} 2>${shQuote(params.errorPath)} <<'PY' +${pk01CaddyMergeManagedBlocksPython()} +PY`; +} + +export function applyLocalCaddyManagedSite(options: LocalCaddyManagedSiteOptions): Record { + const path = options.configPath; + if (!existsSync(path)) return { ok: false, error: "caddy-config-missing", path, valuesPrinted: false }; + const before = readFileSync(path, "utf8"); + const managedBlock = renderCaddyManagedBlock(options.markerName, options.siteBlock); + const migrated = options.legacySiteDomain === undefined ? before : removeLegacySiteBlocksOutsideManagedBlocks(before, options.legacySiteDomain); + const next = upsertManagedBlock(migrated, managedBlock); + const alreadyConfigured = next === before; + let backupPath: string | null = null; + let action = "kept-existing"; + const tmpPath = `${path}.unidesk-${safeFilePart(options.serviceId)}.tmp`; + let validate: SpawnSyncReturns; + let reload: SpawnSyncReturns | null = null; + if (!alreadyConfigured) { + writeFileSync(tmpPath, next, "utf8"); + chmodSync(tmpPath, statSync(path).mode & 0o777); + validate = spawnSync("caddy", ["validate", "--config", tmpPath, "--adapter", "caddyfile"], { encoding: "utf8" }); + if (validate.status === 0) { + const stamp = new Date().toISOString().replace(/[-:]/gu, "").replace(/\..*$/u, "Z"); + backupPath = `${path}.bak-${options.serviceId}-https-${stamp}`; + copyFileSync(path, backupPath); + writeFileSync(path, next, "utf8"); + chmodSync(path, statSync(backupPath).mode & 0o777); + action = before.includes(caddyManagedBlockMarkers(options.markerName).start) ? "updated-managed-block" : "added-managed-block"; + reload = spawnSync("systemctl", ["reload", options.serviceName], { encoding: "utf8" }); + } else { + action = "validate-failed-no-mutation"; + } + try { + unlinkSync(tmpPath); + } catch { + // best-effort cleanup only + } + } else { + validate = spawnSync("caddy", ["validate", "--config", path, "--adapter", "caddyfile"], { encoding: "utf8" }); + } + const active = spawnSync("systemctl", ["is-active", options.serviceName], { encoding: "utf8" }); + const ok = validate.status === 0 && (reload === null || reload.status === 0) && active.status === 0 && String(active.stdout).trim() === "active"; + return { + ok, + action, + path, + backupPath, + serviceId: options.serviceId, + markerName: options.markerName, + serviceName: options.serviceName, + alreadyConfigured, + validate: spawnSummary(validate, 1000, 2000), + reload: reload === null ? null : spawnSummary(reload, 1000, 1000), + active: spawnSummary(active, 1000, 1000), + valuesPrinted: false, + }; +} + +export function upsertManagedBlock(text: string, block: string): string { + const marker = parseManagedBlockMarker(block); + if (marker === null) throw new Error("managed Caddy block is missing UniDesk BEGIN/END markers"); + const pattern = managedBlockPattern(); + let matched = false; + const replaced = text.replace(pattern, (matchedText: string, name: string) => { + if (name !== marker) return matchedText; + matched = true; + return `${block.trim()}\n\n`; + }).replace(/\s+$/u, "\n"); + if (matched) return replaced; + return `${text.trimEnd()}\n\n${block.trim()}\n`; +} + +export function caddySiteBlock(text: string, domain: string): string | null { + const startMatch = new RegExp(`(^|\\n)${escapeRegExp(domain)}\\s*\\{`, "u").exec(text); + if (startMatch === null) return null; + const start = startMatch.index + (startMatch[1] === "\n" ? 1 : 0); + const open = text.indexOf("{", start); + if (open < 0) return null; + let depth = 0; + for (let index = open; index < text.length; index += 1) { + const ch = text[index]; + if (ch === "{") depth += 1; + if (ch === "}") { + depth -= 1; + if (depth === 0) return text.slice(start, index + 1); + } + } + return null; +} + +export function removeLegacySiteBlocksOutsideManagedBlocks(text: string, domain: string): string { + const pattern = managedBlockPattern(); + let cursor = 0; + let next = ""; + for (const match of text.matchAll(pattern)) { + const index = match.index ?? 0; + next += removeCaddySiteBlocks(text.slice(cursor, index), domain); + next += match[0]; + cursor = index + match[0].length; + } + next += removeCaddySiteBlocks(text.slice(cursor), domain); + return next; +} + +export function shQuote(value: string): string { + return `'${value.replaceAll("'", "'\"'\"'")}'`; +} + +export function parseJsonOutput(stdout: string): Record | null { + const trimmed = stdout.trim(); + if (!trimmed) return null; + const candidates = [trimmed, trimmed.slice(trimmed.indexOf("{"))].filter((item) => item.startsWith("{")); + for (const candidate of candidates) { + try { + const parsed = JSON.parse(candidate) as unknown; + if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) return parsed as Record; + } catch { + continue; + } + } + return null; +} + +export function compactCapture(result: SshCaptureResult, options: { full?: boolean } = {}): Record { + const full = options.full === true; + return { + exitCode: result.exitCode, + timedOut: result.timedOut, + stdoutTail: full || result.exitCode !== 0 ? result.stdout.slice(-8000) : "", + stderrTail: full || result.exitCode !== 0 ? result.stderr.slice(-4000) : "", + }; +} + +function pk01CaddyApplyManagedBlockPython(): string { + return String.raw`import pathlib +import re +import sys + +config_path = pathlib.Path(sys.argv[1]) +block_path = pathlib.Path(sys.argv[2]) +next_path = pathlib.Path(sys.argv[3]) +text = config_path.read_text(encoding="utf-8") if config_path.exists() else "" +block = block_path.read_text(encoding="utf-8").strip() + "\n" + +marker_match = re.match(r"(?s)^# BEGIN unidesk managed (?P[^\n]+)\n.*\n# END unidesk managed (?P=name)\n?$", block) +if marker_match is None: + raise SystemExit("block missing matching UniDesk managed markers") +marker_name = marker_match.group("name") +pattern = re.compile(r"(?ms)^# BEGIN unidesk managed (?P[^\n]+)\n(?P.*?)\n# END unidesk managed (?P=name)\n*") +matched = False + +def replace(match): + global matched + if match.group("name") != marker_name: + return match.group(0) + matched = True + return block.rstrip() + "\n\n" + +next_text = pattern.sub(replace, text).rstrip() + "\n" +if not matched: + next_text = text.rstrip() + "\n\n" + block +next_path.write_text(next_text, encoding="utf-8") +print("update" if matched else "append")`; +} + +export function pk01CaddyMergeManagedBlocksPython(): string { + return String.raw`import pathlib +import json +import re +import sys + +desired_path = pathlib.Path(sys.argv[1]) +current_path = pathlib.Path(sys.argv[2]) +merged_path = pathlib.Path(sys.argv[3]) +legacy_domains = sys.argv[4:] +desired = desired_path.read_text(encoding="utf-8") +current = current_path.read_text(encoding="utf-8") if current_path.exists() else "" +pattern = re.compile(r"(?ms)^# BEGIN unidesk managed (?P[^\n]+)\n(?P.*?)\n# END unidesk managed (?P=name)\n*") + +def caddy_site_block(text, domain): + match = re.search(r"(^|\n)" + re.escape(domain) + r"\s*\{", text) + if match is None: + return None + start = match.start() + (1 if match.group(1) == "\n" else 0) + open_index = text.find("{", start) + if open_index < 0: + return None + depth = 0 + for index in range(open_index, len(text)): + char = text[index] + if char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return text[start:index + 1] + return None + +def remove_site_blocks(text, domain): + while True: + block = caddy_site_block(text, domain) + if block is None: + return re.sub(r"\n{3,}", "\n\n", text) + text = text.replace(block, "") + +blocks = {} +order = [] +for source in (current, desired): + for match in pattern.finditer(source): + name = match.group("name") + if name not in blocks: + order.append(name) + blocks[name] = match.group(0).rstrip() + "\n" + +base = pattern.sub("", desired) +for domain in legacy_domains: + base = remove_site_blocks(base, domain) +base = base.rstrip() +managed = "\n\n".join(blocks[name].rstrip() for name in order) +next_text = base +if managed: + next_text = next_text + "\n\n" + managed +merged_path.write_text(next_text.rstrip() + "\n", encoding="utf-8") +print(json.dumps({"currentManagedBlocks": len(pattern.findall(current)), "desiredManagedBlocks": len(pattern.findall(desired)), "mergedManagedBlocks": len(blocks)}))`; +} + +function managedBlockPattern(): RegExp { + return /^# BEGIN unidesk managed ([^\n]+)\n(?:(?!^# END unidesk managed \1$).*\n)*# END unidesk managed \1\n*/gmu; +} + +function parseManagedBlockMarker(block: string): string | null { + const match = /^# BEGIN unidesk managed ([^\n]+)\n[\s\S]*\n# END unidesk managed \1\s*$/u.exec(block.trim()); + return match?.[1] ?? null; +} + +function removeCaddySiteBlocks(text: string, domain: string): string { + let next = text; + while (true) { + const block = caddySiteBlock(next, domain); + if (block === null) return next; + next = next.replace(block, "").replace(/\n{3,}/gu, "\n\n"); + } +} + +function markerNameFromMarkers(markers: Pk01CaddyManagedBlockMarkers): string { + const begin = "# BEGIN unidesk managed "; + const end = "# END unidesk managed "; + if (!markers.start.startsWith(begin) || !markers.end.startsWith(end)) throw new Error("invalid UniDesk managed Caddy markers"); + const startName = markers.start.slice(begin.length); + const endName = markers.end.slice(end.length); + if (startName !== endName) throw new Error("managed Caddy marker start/end mismatch"); + return startName; +} + +function safeFilePart(value: string): string { + return value.replace(/[^A-Za-z0-9_.-]/gu, "_"); +} + +function spawnSummary(result: SpawnSyncReturns, stdoutLimit: number, stderrLimit: number): Record { + return { + exitCode: result.status, + stdoutTail: String(result.stdout).slice(-stdoutLimit), + stderrTail: String(result.stderr).slice(-stderrLimit), + }; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); +} diff --git a/scripts/src/platform-infra-langbot.ts b/scripts/src/platform-infra-langbot.ts index b6ca15b4..7d210c9c 100644 --- a/scripts/src/platform-infra-langbot.ts +++ b/scripts/src/platform-infra-langbot.ts @@ -4,6 +4,7 @@ import { isAbsolute, join } from "node:path"; import type { UniDeskConfig } from "./config"; import { rootPath } from "./config"; import { startJob } from "./jobs"; +import { applyPk01CaddyBlock } from "./platform-infra-public-service"; import { runSshCommandCapture, type SshCaptureResult } from "./ssh"; const configFile = rootPath("config", "platform-infra", "langbot.yaml"); @@ -477,7 +478,7 @@ async function apply(config: UniDeskConfig, options: ApplyOptions): Promise> { - const exposure = target.publicExposure; - if (!exposure.enabled) return { ok: true, action: "not-enabled" }; - const block = `${caddyManagedStart} -${exposure.dns.hostname} { - reverse_proxy 127.0.0.1:${exposure.frpc.remotePort} { - transport http { - response_header_timeout ${exposure.pk01.responseHeaderTimeoutSeconds}s - } - } -} -${caddyManagedEnd} -`; - const blockB64 = Buffer.from(block, "utf8").toString("base64"); - const script = ` -set -u -tmp="$(mktemp -d)" -trap 'rm -rf "$tmp"' EXIT -block="$tmp/langbot.caddy" -printf '%s' '${blockB64}' | base64 -d >"$block" -sudo python3 - ${shQuote(exposure.pk01.caddyConfigPath)} "$block" ${shQuote(caddyManagedStart)} ${shQuote(caddyManagedEnd)} >"$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-langbot.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), - "hostname": "${exposure.dns.hostname}", - "remotePort": ${exposure.frpc.remotePort}, - "caddyConfigPath": "${exposure.pk01.caddyConfigPath}", - "service": "${exposure.pk01.caddyServiceName}", - "managedBlock": {"start": "${caddyManagedStart}", "end": "${caddyManagedEnd}"}, - "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 }) }; -} - function statusScript(langbot: LangBotConfig, target: LangBotTarget): string { return ` set -u diff --git a/scripts/src/platform-infra-public-service.ts b/scripts/src/platform-infra-public-service.ts index ccf05fb6..182f0105 100644 --- a/scripts/src/platform-infra-public-service.ts +++ b/scripts/src/platform-infra-public-service.ts @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; import { Buffer } from "node:buffer"; import type { UniDeskConfig } from "./config"; +import { applyPk01CaddyManagedBlock } from "./pk01-caddy"; import { runSshCommandCapture, type SshCaptureResult } from "./ssh"; export interface PublicServiceExposure { @@ -57,96 +58,7 @@ export async function applyPk01CaddyBlock( 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 }) }; + return await applyPk01CaddyManagedBlock(config, serviceId, exposure, markers); } export function prepareFrpcSecret(params: { diff --git a/scripts/src/platform-infra-sub2api-codex.ts b/scripts/src/platform-infra-sub2api-codex.ts index 3167c638..93a418ae 100644 --- a/scripts/src/platform-infra-sub2api-codex.ts +++ b/scripts/src/platform-infra-sub2api-codex.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import type { UniDeskConfig } from "./config"; import { rootPath } from "./config"; import type { RenderedCliResult } from "./output"; +import { applyLocalCaddyManagedSite } from "./pk01-caddy"; import { codexPoolSentinelSummary, codexPoolSentinelRuntimeImage, @@ -2872,55 +2873,21 @@ async function applyMasterCaddySite(pool: CodexPoolConfig): Promise | null = null; - if (validate.exitCode === 0 && !alreadyConfigured) { - reload = Bun.spawnSync(["systemctl", "reload", caddy.serviceName]); - } - const active = Bun.spawnSync(["systemctl", "is-active", caddy.serviceName]); - const ok = validate.exitCode === 0 && (reload === null || reload.exitCode === 0) && active.exitCode === 0 && String(active.stdout).trim() === "active"; + const applied = applyLocalCaddyManagedSite({ + serviceId: "sub2api-codex-pool", + markerName: "sub2api-codex-pool", + configPath: path, + serviceName: caddy.serviceName, + siteBlock: desiredBlock, + legacySiteDomain: caddy.domain, + }); return { - ok, - action, - path, - backupPath, + ...applied, domain: caddy.domain, upstreamBaseUrl: caddy.upstreamBaseUrl, - serviceName: caddy.serviceName, responseHeaderTimeoutSeconds: caddy.responseHeaderTimeoutSeconds, edgeRetry: caddy.edgeRetry, - validate: { - exitCode: validate.exitCode, - stdoutTail: Buffer.from(validate.stdout).toString("utf8").slice(-1000), - stderrTail: Buffer.from(validate.stderr).toString("utf8").slice(-2000), - }, - reload: reload === null ? null : { - exitCode: reload.exitCode, - stdoutTail: Buffer.from(reload.stdout).toString("utf8").slice(-1000), - stderrTail: Buffer.from(reload.stderr).toString("utf8").slice(-1000), - }, - active: { - exitCode: active.exitCode, - stdoutTail: Buffer.from(active.stdout).toString("utf8").slice(-1000), - stderrTail: Buffer.from(active.stderr).toString("utf8").slice(-1000), - }, valuesPrinted: false, }; } @@ -2961,24 +2928,6 @@ ${matchLines.join("\n")} `; } -function caddySiteBlock(text: string, domain: string): string | null { - const startMatch = new RegExp(`(^|\\n)${escapeRegExp(domain)}\\s*\\{`, "u").exec(text); - if (startMatch === null) return null; - const start = startMatch.index + (startMatch[1] === "\n" ? 1 : 0); - const open = text.indexOf("{", start); - if (open < 0) return null; - let depth = 0; - for (let index = open; index < text.length; index += 1) { - const ch = text[index]; - if (ch === "{") depth += 1; - if (ch === "}") { - depth -= 1; - if (depth === 0) return text.slice(start, index + 1); - } - } - return null; -} - function frpsAllowPortExists(toml: string, port: number): boolean { const sections = toml.split(/(?=\[\[allowPorts\]\])/u); return sections.some((section) => { diff --git a/scripts/src/platform-infra.ts b/scripts/src/platform-infra.ts index 61f79cf5..6054aa30 100644 --- a/scripts/src/platform-infra.ts +++ b/scripts/src/platform-infra.ts @@ -5,6 +5,7 @@ import type { UniDeskConfig } from "./config"; import { rootPath } from "./config"; import { startJob } from "./jobs"; import type { RenderedCliResult } from "./output"; +import { pk01CaddyMergeManagedBlocksPython, renderCaddyManagedBlock, renderSimpleReverseProxyCaddySiteBlock } from "./pk01-caddy"; import { runSshCommandCapture, type SshCaptureResult } from "./ssh"; const defaultTargetId = "D601"; @@ -16,6 +17,7 @@ const configPath = rootPath("config", "platform-infra", "sub2api.yaml"); const codexPoolConfigPath = rootPath("config", "platform-infra", "sub2api-codex-pool.yaml"); const repoRoot = rootPath(); const secretName = "sub2api-secrets"; +const sub2apiCaddyManagedMarker = "sub2api"; const requiredSecretKeys = ["POSTGRES_PASSWORD", "ADMIN_PASSWORD", "JWT_SECRET", "TOTP_ENCRYPTION_KEY"] as const; type DatabaseMode = "bundled" | "external-pending" | "external-active"; @@ -2448,8 +2450,15 @@ fi install_rc=1 if [ "$download_rc" -eq 0 ]; then sudo mkdir -p "$(dirname ${shQuote(exposure.pk01.caddyConfigPath)})" ${shQuote(exposure.pk01.caddyStorageDir)} /etc/systemd/system - sudo install -m 0644 "$caddyfile" ${shQuote(exposure.pk01.caddyConfigPath)} >"$install_out" 2>"$install_err" + merged_caddyfile="$tmp/Caddyfile.merged" + sudo python3 - "$caddyfile" ${shQuote(exposure.pk01.caddyConfigPath)} "$merged_caddyfile" ${shQuote(exposure.dns.hostname)} >"$install_out" 2>"$install_err" <<'PY' +${pk01CaddyMergeManagedBlocksPython()} +PY install_rc=$? + if [ "$install_rc" -eq 0 ]; then + sudo install -m 0644 "$merged_caddyfile" ${shQuote(exposure.pk01.caddyConfigPath)} >>"$install_out" 2>>"$install_err" + install_rc=$? + fi if [ "$install_rc" -eq 0 ]; then sudo install -m 0644 "$service_unit" /etc/systemd/system/${exposure.pk01.caddyServiceName}.service >>"$install_out" 2>>"$install_err" install_rc=$? @@ -2582,6 +2591,14 @@ PY function renderPk01Caddyfile(exposure: Sub2ApiPublicExposureConfig): string { const apexHost = baseDomain(exposure.dns.hostname); + const apiBlock = renderCaddyManagedBlock( + sub2apiCaddyManagedMarker, + renderSimpleReverseProxyCaddySiteBlock({ + hostname: exposure.dns.hostname, + upstream: `127.0.0.1:${exposure.frpc.remotePort}`, + responseHeaderTimeoutSeconds: exposure.pk01.responseHeaderTimeoutSeconds, + }), + ); return `{ email ${exposure.pk01.caddyEmail} storage file_system { @@ -2593,13 +2610,7 @@ ${apexHost}, www.${apexHost} { reverse_proxy 127.0.0.1:${exposure.pk01.pikanodeHttpHostPort} } -${exposure.dns.hostname} { - reverse_proxy 127.0.0.1:${exposure.frpc.remotePort} { - transport http { - response_header_timeout ${exposure.pk01.responseHeaderTimeoutSeconds}s - } - } -} +${apiBlock.trim()} `; }