// `serve --domain`: a pinned Caddy, run as a child, gets and renews the Let's Encrypt certificate and forwards with the real // Host and forwarded headers; the loopback bind never changes. The operator owns DNS and reachable ports 80 and 443. import { spawn, spawnSync, type ChildProcess } from "node:child_process"; import { createHash, randomBytes } from "node:crypto"; import { chmodSync, existsSync, mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { delimiter, join, resolve } from "node:path"; export const CADDY_VERSION = "2.11.4"; /** Release assets by platform-arch, with the sha512 from the release's checksums file. */ export const CADDY_ASSETS: Readonly> = Object.freeze({ "linux-x64": { name: `caddy_${CADDY_VERSION}_linux_amd64.tar.gz`, sha512: "8220d1f013b6f27510247b2360c9e0ca9f018feebd82515f07635318b34ff9777ccc8fd0b6e6f2486ce3a33fe389fbb7db12d05baa474f4587509fb4f5ebf1c9" }, "linux-arm64": { name: `caddy_${CADDY_VERSION}_linux_arm64.tar.gz`, sha512: "d5a7c423853c24a799765e0e8210d5c7c22a8f56ed37a3cae2fb9f58be138853c02b4efd6b59d576e6d8c7c0d30b9c1592deeaa6a536ff69bcca23b8c1ea709c" }, "darwin-x64": { name: `caddy_${CADDY_VERSION}_mac_amd64.tar.gz`, sha512: "e04eb10f9ce7e2e079bc9bff1bd5d3a3164888d1edbb1a49e5d15be4eab691b57e89ed36bb29c65ba43f1ba8d9279e0967b1003991c13fe4cb78384c3caf25de" }, "darwin-arm64": { name: `caddy_${CADDY_VERSION}_mac_arm64.tar.gz`, sha512: "3190ae0df98b59ab4b6021556fa35adc3c526a4f3e138776b0eaec8a037cc26121cbbb1ad53453f565551b47d37d5ba4755e2c2c3652256737fe2ce9e53c8ec0" }, }); const DOWNLOAD_TIMEOUT_MS = 180_000; export function caddyTarget(platform: NodeJS.Platform = process.platform, arch: string = process.arch): string { return `${platform}-${arch}`; } export function pinnedCaddyPath(dataDir: string, platform: NodeJS.Platform = process.platform, arch: string = process.arch): string { return join(dataDir, "caddy", `${CADDY_VERSION}-${caddyTarget(platform, arch)}`, platform === "win32" ? "caddy.exe" : "caddy"); } /** OMB_CADDY_PATH, then the pinned download, then a `caddy` on PATH. */ export function resolveCaddyBinary(options: { dataDir: string; env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform; arch?: string; exists?: (p: string) => boolean } ): string | null { const env = options.env ?? process.env; const exists = options.exists ?? existsSync; const platform = options.platform ?? process.platform; const override = env.OMB_CADDY_PATH?.trim(); if (override) return resolve(override) === override && exists(override) ? override : null; const pinned = pinnedCaddyPath(options.dataDir, platform, options.arch); if (exists(pinned)) return pinned; const executable = platform === "win32" ? "caddy.exe" : "caddy"; for (const part of (env.PATH ?? "").split(delimiter)) { const dir = part.trim(); if (!dir) continue; const candidate = join(dir, executable); if (exists(candidate)) return candidate; } return null; } export async function ensureCaddy(options: { dataDir: string; env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform; arch?: string; fetchImpl?: typeof fetch; asset?: { name: string; sha512: string; url?: string }; log?: (line: string) => void; }): Promise { const platform = options.platform ?? process.platform; const found = resolveCaddyBinary({ dataDir: options.dataDir, env: options.env, platform, arch: options.arch }); if (found) return found; if (platform === "win32") throw new Error("--domain is not available on Windows yet; put your own HTTPS proxy in front (docs/self-hosting.md)"); const target = caddyTarget(platform, options.arch); const asset: { name: string; sha512: string; url?: string } | undefined = options.asset ?? CADDY_ASSETS[target]; if (!asset) throw new Error(`Caddy publishes no build for ${target}`); const destination = pinnedCaddyPath(options.dataDir, platform, options.arch); const directory = join(destination, ".."); mkdirSync(directory, { recursive: true, mode: 0o700 }); const url = asset.url ?? `https://github.com/caddyserver/caddy/releases/download/v${CADDY_VERSION}/${asset.name}`; options.log?.(`downloading Caddy ${CADDY_VERSION} once (about 15 MB, digest pinned) into ${directory}`); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), DOWNLOAD_TIMEOUT_MS); timer.unref?.(); let body: Buffer; try { const response = await (options.fetchImpl ?? fetch)(url, { redirect: "follow", signal: controller.signal }); if (!response.ok) throw new Error(`the Caddy download failed (HTTP ${response.status})`); body = Buffer.from(await response.arrayBuffer()); } finally { clearTimeout(timer); } const digest = createHash("sha512").update(body).digest("hex"); if (digest !== asset.sha512) throw new Error("the Caddy download failed its SHA-512 check; nothing was installed"); const scratch = mkdtempSync(join(tmpdir(), "openmaus-caddy-")); try { const archive = join(scratch, asset.name); writeFileSync(archive, body, { mode: 0o600 }); const extracted = spawnSync("tar", ["-xzf", archive, "-C", scratch, "caddy"], { stdio: "ignore" }); if (extracted.status !== 0) throw new Error("the Caddy archive could not be extracted (is `tar` installed?)"); const staging = `${destination}.${randomBytes(6).toString("hex")}.part`; renameSync(join(scratch, "caddy"), staging); chmodSync(staging, 0o755); renameSync(staging, destination); } finally { rmSync(scratch, { recursive: true, force: true }); } return destination; } /** Certificates and state live under the data dir, so they survive restarts and upgrades. */ export function caddyfileFor(input: { domain: string; appPort: number; webhookPort: number }): string { return [ "# Written by openmausbot serve --domain. Edit the command, not this file.", "{", "\tadmin off", "\tlog {", "\t\tlevel WARN", "\t}", "}", "", `${input.domain} {`, "\tencode zstd gzip", "", "\t# Webhook ingress has its own per-hook secrets and is meant for third parties.", "\thandle /hooks/* {", `\t\treverse_proxy 127.0.0.1:${input.webhookPort}`, "\t}", "", "\thandle {", `\t\treverse_proxy 127.0.0.1:${input.appPort} {`, "\t\t\t# SSE: stream events as they arrive", "\t\t\tflush_interval -1", "\t\t}", "\t}", "}", "", ].join("\n"); } export interface RunningCaddy { configFile: string; exited: Promise; stop(): Promise; } /** Whether a Caddy exit was the ports refusing to bind (a privilege problem, not a config one). */ export function portPermissionRefused(output: string): boolean { return /permission denied|bind: operation not permitted|EACCES/i.test(output) && /:80|:443|listen/i.test(output); } export function portPermissionHint(binary: string): string { return process.platform === "linux" ? `Caddy may not bind ports 80 and 443 as this user. Grant it once with:\n sudo setcap 'cap_net_bind_service=+ep' ${binary}\nthen run the same command again. (A service unit can grant the same capability.)` : "Caddy may not bind ports 80 and 443 as this user. Run the server as an administrator, or put your own HTTPS proxy in front."; } export async function startCaddy(options: { binary: string; dataDir: string; domain: string; appPort: number; webhookPort: number; env?: NodeJS.ProcessEnv; log?: (line: string) => void; settleMs?: number; }): Promise { const home = join(options.dataDir, "caddy"); mkdirSync(join(home, "data"), { recursive: true, mode: 0o700 }); mkdirSync(join(home, "config"), { recursive: true, mode: 0o700 }); const configFile = join(home, "Caddyfile"); writeFileSync(configFile, caddyfileFor(options), { mode: 0o600 }); const child: ChildProcess = spawn(options.binary, ["run", "--config", configFile, "--adapter", "caddyfile"], { env: { ...(options.env ?? process.env), XDG_DATA_HOME: join(home, "data"), XDG_CONFIG_HOME: join(home, "config") }, stdio: ["ignore", "pipe", "pipe"], }); let output = ""; const receive = (chunk: Buffer) => { const text = chunk.toString("utf8"); if (output.length < 16_384) output += text; for (const line of text.split(/\r?\n/)) { if (!line.trim()) continue; // Caddy logs JSON; surface warnings and errors, keep the rest quiet. if (/"level":"(warn|error)"/.test(line) || /^\S+\s+(WARN|ERROR)/.test(line) || !line.startsWith("{")) options.log?.(`caddy: ${line.slice(0, 400)}`); } }; child.stdout?.on("data", receive); child.stderr?.on("data", receive); const exited = new Promise((done) => { child.once("error", () => done(1)); child.once("exit", (code) => done(code)); }); const settled = await Promise.race([exited.then((code) => ({ code })), new Promise<{ code: undefined }>((r) => setTimeout(() => r({ code: undefined }), options.settleMs ?? 1500))]); if (settled.code !== undefined) { const reason = portPermissionRefused(output) ? portPermissionHint(options.binary) : output.trim().split("\n").slice(-3).join("\n"); throw new Error(`Caddy stopped right after starting (exit ${settled.code}).\n${reason}`); } return { configFile, exited, stop: async () => { if (child.exitCode !== null || child.signalCode !== null) return; child.kill("SIGTERM"); await Promise.race([exited, new Promise((r) => setTimeout(r, 5_000))]); if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); }, }; } export function normalizeDomainOption(raw: string): string | { error: string } { const value = raw.trim().toLowerCase().replace(/^https?:\/\//, "").replace(/\/+$/, ""); const label = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/; if (!value || value.length > 253 || !value.includes(".") || /[\s/:@?#]/.test(value) || !value.split(".").every((part) => label.test(part))) { return { error: "--domain takes a bare hostname such as maus.example.com" }; } if (/(?:^|\.)(?:localhost|local|internal|invalid)$/.test(value)) return { error: "--domain needs a public domain name that resolves to this machine" }; return value; }