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,254 @@
|
||||
// HTTP assertions for scripts/smoke-deploy.sh, run inside a disposable container on the reference
|
||||
// deployment's `edge` network: every request goes through the proxy by its public hostname, exactly
|
||||
// as a browser's would. Not a test of the API in isolation, and never run against a real
|
||||
// installation.
|
||||
//
|
||||
// node deploy-smoke-client.mjs edge an ordinary, untrusted peer of the proxy
|
||||
// node deploy-smoke-client.mjs tunnel the Cloudflare Tunnel connector's fixed address
|
||||
// node deploy-smoke-client.mjs session the initial owner signing in and out through the proxy
|
||||
//
|
||||
// Output is one line per check; any failed check exits non-zero. Nothing printed carries a
|
||||
// credential, cookie, signed URL, or response body beyond the fixed shapes asserted here.
|
||||
|
||||
const mode = process.argv[2];
|
||||
const hostname = required("SMOKE_HOSTNAME");
|
||||
const origin = `https://${hostname}`;
|
||||
|
||||
function required(name) {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
throw new Error(`${name} is required.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
let failures = 0;
|
||||
|
||||
function check(condition, description) {
|
||||
console.log(`${condition ? "ok " : "FAIL"} ${description}`);
|
||||
if (!condition) {
|
||||
failures += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/** A sign-in attempt that can only fail authentication; what matters is whether the API's
|
||||
* per-client limiter (3 attempts per 10 seconds) counts it against the expected client. */
|
||||
async function signInAttempt(headers = {}) {
|
||||
const response = await fetch(`${origin}/api/auth/sign-in/email`, {
|
||||
body: JSON.stringify({ email: "[email protected]", password: "definitely-not-the-password" }),
|
||||
headers: { "content-type": "application/json", origin, ...headers },
|
||||
method: "POST",
|
||||
});
|
||||
await response.arrayBuffer();
|
||||
return response.status;
|
||||
}
|
||||
|
||||
async function edgeChecks() {
|
||||
const ready = await fetch(`${origin}/api/v1/health/ready`);
|
||||
check(
|
||||
ready.status === 200 && (await ready.text()) === '{"status":"ready"}',
|
||||
"GET /api/v1/health/ready through the proxy is 200 ready",
|
||||
);
|
||||
|
||||
const page = await fetch(`${origin}/`);
|
||||
const html = await page.text();
|
||||
check(page.status === 200, "GET / through the proxy is 200");
|
||||
check(
|
||||
page.headers.get("content-type") === "text/html; charset=utf-8" &&
|
||||
html.includes('id="ekklesia-host"') &&
|
||||
html.includes('src="/assets/ekklesia-dashboard/app.js"'),
|
||||
"GET / is the Dashboard HTML",
|
||||
);
|
||||
check(
|
||||
page.headers.get("x-frame-options") === "DENY" &&
|
||||
page.headers.get("content-security-policy") === "frame-ancestors 'none'" &&
|
||||
page.headers.get("referrer-policy") === "no-referrer",
|
||||
"the Dashboard page keeps its security headers through the proxy",
|
||||
);
|
||||
|
||||
const script = await fetch(`${origin}/assets/ekklesia-dashboard/app.js`);
|
||||
await script.arrayBuffer();
|
||||
check(script.status === 200, "GET /assets/ekklesia-dashboard/app.js is 200");
|
||||
|
||||
const outside = await fetch(`${origin}/favicon.ico`);
|
||||
check(
|
||||
outside.status === 404 && !(outside.headers.get("content-type") ?? "").includes("json"),
|
||||
"a path the API does not serve is refused by the proxy, never forwarded",
|
||||
);
|
||||
|
||||
const unknownApi = await fetch(`${origin}/api/v1/does-not-exist`);
|
||||
check(
|
||||
unknownApi.status === 404 && (await unknownApi.text()) === '{"code":"NOT_FOUND"}',
|
||||
"an unknown /api/* path reaches the API and gets its sanitized 404",
|
||||
);
|
||||
|
||||
await storageDeliveryCheck();
|
||||
|
||||
// Every caller-supplied forwarding header rotates on every attempt. If any of them reached the
|
||||
// API's client-address resolution, each attempt would land in a fresh limiter bucket and the
|
||||
// fourth would not be limited.
|
||||
const statuses = [];
|
||||
for (let attempt = 1; attempt <= 4; attempt += 1) {
|
||||
statuses.push(
|
||||
await signInAttempt({
|
||||
"cf-connecting-ip": `198.51.100.${attempt}`,
|
||||
forwarded: `for=198.51.100.${attempt + 10}`,
|
||||
"true-client-ip": `198.51.100.${attempt + 20}`,
|
||||
"x-ekklesia-client-address": `198.51.100.${attempt + 50}`,
|
||||
"x-forwarded-for": `198.51.100.${attempt + 30}`,
|
||||
"x-real-ip": `198.51.100.${attempt + 40}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
check(
|
||||
statuses.slice(0, 3).every((status) => status !== 429 && status < 500) && statuses[3] === 429,
|
||||
`spoofed forwarding headers do not choose the client address (statuses ${statuses.join(",")})`,
|
||||
);
|
||||
}
|
||||
|
||||
async function storageDeliveryCheck() {
|
||||
// Core's own provider, from the image, exactly as the API composes it: it reaches object-storage
|
||||
// over HTTPS on the private network and signs read URLs for the storage hostname.
|
||||
const { createS3StorageProvider, createStorageProviderConfig } = await import(
|
||||
"/opt/ekklesia/core/dist/storage/index.js"
|
||||
);
|
||||
const config = createStorageProviderConfig(
|
||||
{
|
||||
accessKey: required("S3_ACCESS_KEY"),
|
||||
bucketPrefix: "ekklesia-smoke",
|
||||
deliveryEndpoint: `https://${required("SMOKE_STORAGE_HOSTNAME")}`,
|
||||
endpoint: "https://object-storage:8443",
|
||||
forcePathStyle: true,
|
||||
region: "us-east-1",
|
||||
secretKey: required("S3_SECRET_KEY"),
|
||||
},
|
||||
{ environment: "production" },
|
||||
);
|
||||
const provider = createS3StorageProvider(config);
|
||||
try {
|
||||
const bucket = "ekklesia-smoke-delivery";
|
||||
const body = new TextEncoder().encode("delivered through the proxy\n");
|
||||
await provider.ensureBucket(bucket);
|
||||
await provider.put(bucket, "smoke/object.txt", body, {
|
||||
contentLength: body.byteLength,
|
||||
contentType: "text/plain",
|
||||
});
|
||||
const readUrl = await provider.createReadUrl(bucket, "smoke/object.txt", {
|
||||
expiresInSeconds: 300,
|
||||
});
|
||||
const url = new URL(readUrl.url);
|
||||
check(
|
||||
url.origin === `https://${required("SMOKE_STORAGE_HOSTNAME")}`,
|
||||
"a signed read URL names the storage hostname",
|
||||
);
|
||||
const delivered = await fetch(url);
|
||||
const text = await delivered.text();
|
||||
check(
|
||||
delivered.status === 200 && text === "delivered through the proxy\n",
|
||||
"a signed read URL verifies through the proxy (original Host preserved)",
|
||||
);
|
||||
check(
|
||||
(delivered.headers.get("cache-control") ?? "").startsWith("private"),
|
||||
"a delivered object is Cache-Control: private",
|
||||
);
|
||||
await provider.delete(bucket, "smoke/object.txt");
|
||||
} finally {
|
||||
await provider.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function tunnelChecks() {
|
||||
// From the connector's fixed address, CF-Connecting-IP is the client: one visitor is limited on
|
||||
// its fourth attempt, and a different visitor is not limited by the first one's attempts.
|
||||
const first = [];
|
||||
for (let attempt = 1; attempt <= 4; attempt += 1) {
|
||||
first.push(await signInAttempt({ "cf-connecting-ip": "203.0.113.10" }));
|
||||
}
|
||||
check(
|
||||
first.slice(0, 3).every((status) => status !== 429 && status < 500) && first[3] === 429,
|
||||
`the connector's CF-Connecting-IP identifies the visitor (statuses ${first.join(",")})`,
|
||||
);
|
||||
const second = await signInAttempt({ "cf-connecting-ip": "203.0.113.11" });
|
||||
check(
|
||||
second !== 429 && second < 500,
|
||||
`a second visitor behind the connector has its own limiter bucket (status ${second})`,
|
||||
);
|
||||
}
|
||||
|
||||
/** The deployed browser authentication contract (launch checklist item 9), as the initial owner
|
||||
* created by the image's own CLI (item 10). */
|
||||
async function sessionChecks() {
|
||||
for (const path of [
|
||||
"/api/auth/get-session",
|
||||
"/api/auth/list-sessions",
|
||||
"/api/auth/update-session",
|
||||
"/api/auth/change-password",
|
||||
]) {
|
||||
const response = await fetch(`${origin}${path}`, {
|
||||
method: path.endsWith("get-session") || path.endsWith("list-sessions") ? "GET" : "POST",
|
||||
headers: { origin },
|
||||
});
|
||||
check(
|
||||
response.status === 404 && (await response.text()) === '{"code":"NOT_FOUND"}',
|
||||
`${path} is unavailable through the proxy`,
|
||||
);
|
||||
}
|
||||
|
||||
const signIn = await fetch(`${origin}/api/auth/sign-in/email`, {
|
||||
body: JSON.stringify({
|
||||
email: required("SMOKE_OWNER_EMAIL"),
|
||||
password: required("SMOKE_OWNER_PASSWORD"),
|
||||
}),
|
||||
headers: { "content-type": "application/json", origin },
|
||||
method: "POST",
|
||||
});
|
||||
const signInBody = await signIn.text();
|
||||
const setCookie = signIn.headers.get("set-cookie") ?? "";
|
||||
check(
|
||||
signIn.status === 200 && signInBody === '{"ok":true}',
|
||||
'sign-in returns the fixed {"ok":true}',
|
||||
);
|
||||
const attributes = setCookie.toLowerCase();
|
||||
check(
|
||||
attributes.includes("httponly") &&
|
||||
attributes.includes("secure") &&
|
||||
attributes.includes("samesite=lax") &&
|
||||
!attributes.includes("domain="),
|
||||
"the session cookie is host-only, HttpOnly, Secure, and SameSite=Lax",
|
||||
);
|
||||
const cookie = setCookie.split(";")[0] ?? "";
|
||||
|
||||
const principal = await fetch(`${origin}/api/v1/principal`, { headers: { cookie, origin } });
|
||||
const principalBody = await principal.json();
|
||||
check(
|
||||
principal.status === 200 && principalBody.kind === "authenticated",
|
||||
"the session resolves an authenticated principal through the proxy",
|
||||
);
|
||||
|
||||
const signOut = await fetch(`${origin}/api/auth/sign-out`, {
|
||||
body: "{}",
|
||||
headers: { "content-type": "application/json", cookie, origin },
|
||||
method: "POST",
|
||||
});
|
||||
await signOut.arrayBuffer();
|
||||
const after = await fetch(`${origin}/api/v1/principal`, { headers: { cookie, origin } });
|
||||
check(
|
||||
signOut.status === 200 && (await after.json()).kind === "anonymous",
|
||||
"sign-out ends the session",
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === "edge") {
|
||||
await edgeChecks();
|
||||
} else if (mode === "tunnel") {
|
||||
await tunnelChecks();
|
||||
} else if (mode === "session") {
|
||||
await sessionChecks();
|
||||
} else {
|
||||
throw new Error('Mode must be "edge", "tunnel", or "session".');
|
||||
}
|
||||
|
||||
if (failures > 0) {
|
||||
console.log(`${failures} check(s) failed.`);
|
||||
process.exit(1);
|
||||
}
|
||||
Reference in New Issue
Block a user