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]>
255 lines
9.4 KiB
JavaScript
255 lines
9.4 KiB
JavaScript
// 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);
|
|
}
|