server/caddy.test.ts
Rama main · réplica de lectura del repositorio original.
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { CADDY_ASSETS, CADDY_VERSION, caddyfileFor, ensureCaddy, normalizeDomainOption, pinnedCaddyPath, portPermissionRefused, resolveCaddyBinary, startCaddy } from "./caddy.ts";
import { removeTempDir } from "./testing/cleanup.ts";
const posix = process.platform !== "win32";
describe("the managed Caddy", () => {
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "omb-caddy-"));
});
afterEach(() => removeTempDir(dir));
it("pins one release per platform with a sha512 from the release's checksums", () => {
for (const [target, asset] of Object.entries(CADDY_ASSETS)) {
expect(asset.name).toContain(CADDY_VERSION);
expect(asset.sha512, target).toMatch(/^[0-9a-f]{128}$/);
}
expect(pinnedCaddyPath(dir, "linux", "x64")).toBe(join(dir, "caddy", `${CADDY_VERSION}-linux-x64`, "caddy"));
});
it("writes the Docker stack's Caddyfile with this server's ports and no admin API", () => {
const file = caddyfileFor({ domain: "maus.example.com", appPort: 18799, webhookPort: 18800 });
expect(file).toContain("maus.example.com {");
expect(file).toContain("reverse_proxy 127.0.0.1:18799");
expect(file).toContain("handle /hooks/*");
expect(file).toContain("reverse_proxy 127.0.0.1:18800");
expect(file).toContain("flush_interval -1");
expect(file).toContain("admin off");
});
it("resolves OMB_CADDY_PATH, then the pinned download, then PATH", () => {
const pinned = pinnedCaddyPath(dir, "linux", "x64");
const onPath = join(dir, "bin", "caddy");
const exists = (p: string) => p === pinned || p === onPath || p === join(dir, "custom");
expect(resolveCaddyBinary({ dataDir: dir, env: { OMB_CADDY_PATH: join(dir, "custom") }, platform: "linux", arch: "x64", exists })).toBe(join(dir, "custom"));
expect(resolveCaddyBinary({ dataDir: dir, env: { OMB_CADDY_PATH: "relative/caddy" }, platform: "linux", arch: "x64", exists })).toBeNull();
expect(resolveCaddyBinary({ dataDir: dir, env: { PATH: join(dir, "bin") }, platform: "linux", arch: "x64", exists })).toBe(pinned);
expect(resolveCaddyBinary({ dataDir: join(dir, "elsewhere"), env: { PATH: join(dir, "bin") }, platform: "linux", arch: "x64", exists })).toBe(onPath);
expect(resolveCaddyBinary({ dataDir: join(dir, "elsewhere"), env: { PATH: "" }, platform: "linux", arch: "x64", exists })).toBeNull();
});
it("takes a bare public hostname for --domain", () => {
expect(normalizeDomainOption(" HTTPS://Maus.Example.com/ ")).toBe("maus.example.com");
expect(normalizeDomainOption("maus.example.com:443")).toEqual({ error: expect.stringContaining("bare hostname") });
expect(normalizeDomainOption("localhost")).toEqual({ error: expect.stringContaining("bare hostname") });
expect(normalizeDomainOption("box.internal")).toEqual({ error: expect.stringContaining("public domain") });
expect(normalizeDomainOption("nodots")).toEqual({ error: expect.stringContaining("bare hostname") });
});
it.skipIf(!posix)("downloads a pinned archive once, verifies its digest, and extracts the binary", async () => {
const src = join(dir, "src");
mkdirSync(src);
writeFileSync(join(src, "caddy"), "#!/bin/sh\necho fake caddy\n", { mode: 0o755 });
const archive = join(dir, "caddy_test_linux_amd64.tar.gz");
expect(spawnSync("tar", ["-czf", archive, "-C", src, "caddy"]).status).toBe(0);
const bytes = readFileSync(archive);
const sha512 = createHash("sha512").update(bytes).digest("hex");
let fetched = 0;
const fetchImpl = (async () => {
fetched += 1;
return new Response(bytes, { status: 200 });
}) as unknown as typeof fetch;
const dataDir = join(dir, "data");
const binary = await ensureCaddy({ dataDir, env: { PATH: "" }, platform: "linux", arch: "x64", asset: { name: "caddy_test_linux_amd64.tar.gz", sha512, url: "https://stub.invalid/caddy.tgz" }, fetchImpl });
expect(binary).toBe(pinnedCaddyPath(dataDir, "linux", "x64"));
expect(existsSync(binary)).toBe(true);
expect(statSync(binary).mode & 0o111).not.toBe(0);
expect(fetched).toBe(1);
expect(await ensureCaddy({ dataDir, env: { PATH: "" }, platform: "linux", arch: "x64", fetchImpl })).toBe(binary);
expect(fetched).toBe(1);
const other = join(dir, "data2");
await expect(ensureCaddy({ dataDir: other, env: { PATH: "" }, platform: "linux", arch: "x64", asset: { name: "caddy_test_linux_amd64.tar.gz", sha512: "0".repeat(128), url: "https://stub.invalid/x" }, fetchImpl })).rejects.toThrow(/SHA-512/);
expect(existsSync(pinnedCaddyPath(other, "linux", "x64"))).toBe(false);
});
it.skipIf(!posix)("runs Caddy with the written Caddyfile and stops it; a port refusal becomes the privilege hint", async () => {
const fake = join(dir, "fake-caddy");
writeFileSync(fake, `#!/bin/sh\necho "$@" > "${join(dir, "args.txt")}"\nexec sleep 300\n`, { mode: 0o755 });
chmodSync(fake, 0o755);
const running = await startCaddy({ binary: fake, dataDir: dir, domain: "maus.example.com", appPort: 18799, webhookPort: 18800, settleMs: 300 });
try {
// the stand-in writes its arguments from a shell that may start slowly under load
await expect.poll(() => existsSync(join(dir, "args.txt")), { timeout: 5_000 }).toBe(true);
expect(readFileSync(join(dir, "args.txt"), "utf8").trim()).toBe(`run --config ${running.configFile} --adapter caddyfile`);
expect(readFileSync(running.configFile, "utf8")).toContain("reverse_proxy 127.0.0.1:18799");
expect(statSync(running.configFile).mode & 0o077).toBe(0);
} finally {
await running.stop();
}
expect(await running.exited).not.toBeUndefined();
const refusing = join(dir, "refusing-caddy");
writeFileSync(refusing, "#!/bin/sh\necho 'Error: loading initial config: listen tcp :443: bind: permission denied' >&2\nexit 1\n", { mode: 0o755 });
await expect(startCaddy({ binary: refusing, dataDir: join(dir, "d2"), domain: "maus.example.com", appPort: 1, webhookPort: 2, settleMs: 2000 })).rejects.toThrow(/ports 80 and 443/);
expect(portPermissionRefused("listen tcp :443: bind: permission denied")).toBe(true);
expect(portPermissionRefused("some other error")).toBe(false);
});
});