feat(deploy): add the reference deployment Compose project
CI / verify (pull_request) Successful in 54s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 1m48s
CI / image-build (pull_request) Successful in 27s
CI / deploy-smoke (pull_request) Successful in 42s
CI / verify (pull_request) Successful in 54s
CI / infrastructure-smoke (pull_request) Successful in 21s
CI / migration-smoke (pull_request) Successful in 1m48s
CI / image-build (pull_request) Successful in 27s
CI / deploy-smoke (pull_request) Successful in 42s
Implement ADR 0044's reference deployment as a base Compose file plus overlays, so an installation that already runs a proxy can use the base alone. - compose.yaml: api, postgres, object-storage, and the operator-only migrate (profile ops, plan by default). No published port. api and migrate run init, read-only, tmpfs /tmp, cap_drop ALL, no-new-privileges. postgres is only on the internal data network. - TLS everywhere from one installation-private CA: PostgreSQL TLS 1.3 only, with pg_hba rejecting every non-TLS connection, and the API on verify-full via EKKLESIA_DATABASE_CA (PEM text). SeaweedFS serves S3 over HTTPS (ADR 0030 requires https in production), trusted by the API through NODE_EXTRA_CA_CERTS. No Core change. - compose.proxy.yaml + Caddyfile.example: Caddy pinned by digest, ACME HTTP-01 (DNS-01 documented), routes /, /api/*, /display/*, /assets/* to api and refuses everything else; the storage hostname preserves Host. The API trusts exactly the proxy's fixed /32 on an edge network whose dynamic range excludes the fixed addresses; Caddy overwrites X-Forwarded-For and strips caller-supplied forwarding headers. - compose.tunnel.yaml: cloudflared pinned by digest, token from its own env file; Caddy honors CF-Connecting-IP only from its fixed address. - Four per-service root-owned env files, examples with no values; scripts/init-tls.sh (idempotent, never overwrites a key) and scripts/gen-secrets.sh (prints only). - CI: pnpm deploy:config in ci:verify for all three combinations, and a deploy-smoke job (scripts/smoke-deploy.sh) proving readiness, the Dashboard, signed storage delivery, the client-address chain, initial owner sign-in/out, verify-full database TLS, and Doctor through the proxy, against disposable services. tests/deploy.test.ts pins the structural guarantees. - ADR 0044 (still Proposed) amended to match: API-to-storage HTTPS, DNS-01 needs a custom Caddy build and tunnel-only needs DNS-01, the tunnel's origin TLS settings live in the Cloudflare dashboard, port 80 serves HTTP-01, and secrets are one env file per service. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
b593abe7a5
commit
167b4c4f14
@@ -0,0 +1,231 @@
|
||||
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<string, string>;
|
||||
healthcheck?: { disable?: boolean };
|
||||
image?: string;
|
||||
init?: boolean;
|
||||
networks?: Record<string, { ipv4_address?: string } | null>;
|
||||
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<string, ComposeService>;
|
||||
}
|
||||
|
||||
const repositoryRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const deployDirectory = path.join(repositoryRoot, "deploy");
|
||||
|
||||
async function compose(file: string): Promise<ComposeDocument> {
|
||||
// `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<string> {
|
||||
return readFile(path.join(deployDirectory, file), "utf8");
|
||||
}
|
||||
|
||||
function envAssignments(source: string): Map<string, string> {
|
||||
const assignments = new Map<string, string>();
|
||||
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://<user>:<password>@postgres:5432/<database>",
|
||||
);
|
||||
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__<MODULE_ID>__<KEY>=");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user