import { readFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { parse } from "yaml"; /** * Structural guarantees of the reference deployment (ADR 0044), checked on the files themselves so * an edit cannot quietly weaken them. `scripts/smoke-deploy.sh` proves the same configuration * actually runs; this proves it still says what the documentation promises. */ interface ComposeService { build?: unknown; cap_add?: string[]; cap_drop?: string[]; command?: string[]; env_file?: string[]; environment?: Record; healthcheck?: { disable?: boolean }; image?: string; init?: boolean; networks?: Record; ports?: string[]; profiles?: string[]; read_only?: boolean; security_opt?: string[]; tmpfs?: string[]; } interface ComposeDocument { networks?: Record< string, { internal?: boolean; ipam?: { config?: { subnet?: string; ip_range?: string }[] } } >; services?: Record; } const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const deployDirectory = path.join(repositoryRoot, "deploy"); async function compose(file: string): Promise { // `merge: true` resolves the base file's `<<:` hardening anchors exactly as Compose does. return parse(await readFile(path.join(deployDirectory, file), "utf8"), { merge: true, }) as ComposeDocument; } async function text(file: string): Promise { return readFile(path.join(deployDirectory, file), "utf8"); } function envAssignments(source: string): Map { const assignments = new Map(); for (const line of source.split("\n")) { const match = /^([A-Z][A-Z0-9_]*)=(.*)$/u.exec(line); if (match?.[1]) { assignments.set(match[1], match[2] ?? ""); } } return assignments; } const DIGEST_PINNED = /^[^\s:@]+(?::[^\s@]+)?@sha256:[0-9a-f]{64}$/u; describe("reference deployment compose files", () => { it("keeps PostgreSQL on its internal network only, and publishes no port from the base", async () => { const base = await compose("compose.yaml"); const services = base.services ?? {}; expect(Object.keys(services).sort()).toEqual(["api", "migrate", "object-storage", "postgres"]); expect(Object.keys(services.postgres?.networks ?? {})).toEqual(["data"]); expect(base.networks?.data?.internal).toBe(true); expect(base.networks?.storage?.internal).toBe(true); for (const [name, service] of Object.entries(services)) { expect(service.ports, name).toBeUndefined(); } for (const overlay of ["compose.proxy.yaml", "compose.tunnel.yaml"]) { const document = await compose(overlay); expect(document.services?.postgres, overlay).toBeUndefined(); } }); it("pins every third-party image by digest and runs the API only from EKKLESIA_IMAGE", async () => { const documents = await Promise.all( ["compose.yaml", "compose.proxy.yaml", "compose.tunnel.yaml"].map(compose), ); for (const document of documents) { for (const [name, service] of Object.entries(document.services ?? {})) { expect(service.build, name).toBeUndefined(); if (service.image === undefined) { continue; } if (name === "api" || name === "migrate") { expect(service.image, name).toMatch(/^\$\{EKKLESIA_IMAGE:\?/u); } else { expect(service.image, name).toMatch(DIGEST_PINNED); } } } }); it("hardens api and migrate, keeps migrate operator-only, and never lets it apply by default", async () => { const services = (await compose("compose.yaml")).services ?? {}; for (const name of ["api", "migrate"]) { const service = services[name]; expect(service?.init, name).toBe(true); expect(service?.read_only, name).toBe(true); expect(service?.tmpfs, name).toEqual(["/tmp"]); expect(service?.cap_drop, name).toEqual(["ALL"]); expect(service?.security_opt, name).toContain("no-new-privileges:true"); expect(service?.environment?.NODE_EXTRA_CA_CERTS, name).toBe("/run/ekklesia/tls/ca.crt"); } const migrate = services.migrate; expect(migrate?.profiles).toEqual(["ops"]); expect(migrate?.healthcheck?.disable).toBe(true); expect(migrate?.command).toEqual(["node", "core/dist/cli/plan-migrations.js"]); expect(JSON.stringify(services)).not.toContain("apply-migrations"); }); it("serves PostgreSQL and object storage over TLS from the installation-private CA", async () => { const services = (await compose("compose.yaml")).services ?? {}; expect(services.postgres?.command).toEqual( expect.arrayContaining([ "ssl=on", "ssl_min_protocol_version=TLSv1.3", "hba_file=/etc/ekklesia/pg_hba.conf", ]), ); expect(services["object-storage"]?.command).toEqual( expect.arrayContaining(["-s3.port.https=8443"]), ); const hba = (await text("postgres/pg_hba.conf")) .split("\n") .filter((line) => line.trim() !== "" && !line.trim().startsWith("#")) .map((line) => line.trim().split(/\s+/u)); expect(hba).toEqual([ ["local", "all", "all", "trust"], ["hostssl", "all", "all", "all", "scram-sha-256"], ["hostnossl", "all", "all", "all", "reject"], ]); }); it("makes the proxy the API's only trusted peer, at a fixed address outside the dynamic range", async () => { const proxyOverlay = await compose("compose.proxy.yaml"); const proxy = proxyOverlay.services?.proxy; const proxyAddress = proxy?.networks?.edge?.ipv4_address; expect(proxyAddress).toBe("${EKKLESIA_PROXY_ADDRESS:-10.249.250.254}"); expect(proxyOverlay.services?.api?.environment).toEqual({ EKKLESIA_CLIENT_ADDRESS_HEADER: "x-forwarded-for", EKKLESIA_CLIENT_ADDRESS_MODE: "trusted-proxy", EKKLESIA_CLIENT_ADDRESS_TRUSTED_PROXIES: `${proxyAddress}/32`, }); expect(proxyOverlay.networks?.edge?.ipam?.config).toEqual([ { ip_range: "${EKKLESIA_EDGE_DYNAMIC_RANGE:-10.249.250.0/25}", subnet: "${EKKLESIA_EDGE_SUBNET:-10.249.250.0/24}", }, ]); expect(proxy?.cap_drop).toEqual(["ALL"]); expect(proxy?.cap_add).toEqual(["NET_BIND_SERVICE"]); const tunnel = (await compose("compose.tunnel.yaml")).services?.tunnel; expect(tunnel?.networks?.edge?.ipv4_address).toBe("${EKKLESIA_TUNNEL_ADDRESS:-10.249.250.253}"); expect(tunnel?.env_file).toEqual(["${EKKLESIA_ENV_DIR:-/etc/ekklesia}/tunnel.env"]); }); it("routes only the API's own paths, overwrites the forwarding header, and never ships local_certs", async () => { const caddyfile = await text("Caddyfile.example"); const directives = caddyfile .split("\n") .filter((line) => !line.trim().startsWith("#")) .join("\n"); expect(directives).toContain("@ekklesia path / /api/* /display/* /assets/*"); expect(directives).toContain("header_up X-Forwarded-For {client_ip}"); expect(directives).toContain("header_up -CF-Connecting-IP"); expect(directives).toContain("trusted_proxies static {$EKKLESIA_TUNNEL_ADDRESS}/32"); expect(directives).toContain("header_up Host {host}"); expect(directives).toContain("tls_trust_pool file /etc/caddy/ekklesia-ca.crt"); // Caddy's internal CA is a test-only substitution made by scripts/smoke-deploy.sh. expect(directives).not.toContain("local_certs"); expect(directives).not.toMatch(/tls\s+internal/u); }); }); describe("reference deployment env examples", () => { it("contain no secret values", async () => { const secretKeys = new Map([ ["env/ekklesia.env.example", ["EKKLESIA_IDENTITY_SECRET", "S3_ACCESS_KEY", "S3_SECRET_KEY"]], ["env/postgres.env.example", ["POSTGRES_PASSWORD"]], ["env/object-storage.env.example", ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"]], ["env/tunnel.env.example", ["TUNNEL_TOKEN"]], ]); for (const [file, keys] of secretKeys) { const assignments = envAssignments(await text(file)); for (const key of keys) { expect(assignments.get(key), `${file} ${key}`).toBe(""); } } const ekklesia = envAssignments(await text("env/ekklesia.env.example")); expect(ekklesia.get("DATABASE_URL")).toBe( "postgresql://:@postgres:5432/", ); expect(ekklesia.get("EKKLESIA_DATABASE_TLS_MODE")).toBe("verify-full"); expect(ekklesia.get("S3_ENDPOINT")).toBe("https://object-storage:8443"); }); it("names every variable Core's host configuration reads", async () => { // The single place executables read the environment (core/src/host/configuration.ts). A new // variable there without a line here would leave an operator guessing. const hostConfiguration = await readFile( path.join(repositoryRoot, "core", "src", "host", "configuration.ts"), "utf8", ); const read = new Set( [...hostConfiguration.matchAll(/environment\.([A-Z][A-Z0-9_]+)/gu)].map((match) => match[1]), ); const example = await text("env/ekklesia.env.example"); // Set by the image, not by an operator. read.delete("NODE_ENV"); // Guards against a pattern that silently stops matching and checks nothing. expect(read.size).toBeGreaterThan(20); for (const name of read) { expect(example, String(name)).toMatch(new RegExp(`^#? ?${name}=`, "mu")); } expect(example).toContain("EKKLESIA_MODULE_SECRET____="); }); });