server/caddy.ts
Rama main · réplica de lectura del repositorio original.
// `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<Record<string, { name: string; sha512: string }>> = 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<string> {
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<number | null>;
stop(): Promise<void>;
}
/** 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<RunningCaddy> {
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<number | null>((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;
}