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, ["sh"], 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, "\\$&"); }