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
Executable
+41
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
# Validates the reference deployment's Compose files (ADR 0044) in each supported combination:
|
||||
# base, base + proxy, and base + proxy + tunnel. Uses only the committed example env files, copied
|
||||
# to a temporary directory, so it needs no secret and starts nothing.
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
repository_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$repository_root/deploy"
|
||||
|
||||
work="$(mktemp -d)"
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
|
||||
for name in ekklesia postgres object-storage tunnel; do
|
||||
cp "env/${name}.env.example" "$work/${name}.env"
|
||||
done
|
||||
cp env/compose.env.example "$work/compose.env"
|
||||
cat >>"$work/compose.env" <<EOF_ENV
|
||||
EKKLESIA_IMAGE=registry.example.org/ekklesia:0.0.0
|
||||
EKKLESIA_ENV_DIR=${work}
|
||||
EKKLESIA_HOSTNAME=ekklesia.example.org
|
||||
EKKLESIA_STORAGE_HOSTNAME=storage.ekklesia.example.org
|
||||
[email protected]
|
||||
EOF_ENV
|
||||
|
||||
combinations=(
|
||||
"compose.yaml"
|
||||
"compose.yaml compose.proxy.yaml"
|
||||
"compose.yaml compose.proxy.yaml compose.tunnel.yaml"
|
||||
)
|
||||
for combination in "${combinations[@]}"; do
|
||||
arguments=()
|
||||
for file in $combination; do
|
||||
arguments+=(--file "$file")
|
||||
done
|
||||
if ! docker compose --env-file "$work/compose.env" "${arguments[@]}" --profile ops config --quiet; then
|
||||
echo "Reference deployment Compose configuration is invalid: ${combination}" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "valid: ${combination}"
|
||||
done
|
||||
@@ -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);
|
||||
}
|
||||
Executable
+348
@@ -0,0 +1,348 @@
|
||||
#!/usr/bin/env bash
|
||||
# End-to-end smoke test of the reference deployment (ADR 0044): deploy/compose.yaml with
|
||||
# deploy/compose.proxy.yaml, a platform-only image, and a disposable PostgreSQL and SeaweedFS that
|
||||
# exist only for this run. CI runs it; it is safe to run on a workstation.
|
||||
#
|
||||
# bash scripts/smoke-deploy.sh builds the image first
|
||||
# EKKLESIA_SMOKE_IMAGE=<image> bash scripts/smoke-deploy.sh
|
||||
#
|
||||
# TEST-ONLY: the proxy runs with Caddy's internal CA (`local_certs`) instead of ACME, because a test
|
||||
# run has no public hostname. Never use that option in an installation.
|
||||
#
|
||||
# Follows the documented first boot: TLS material, secrets, data services, an explicit migration
|
||||
# apply against this run's own fresh database, then the API and proxy. Everything it starts, and
|
||||
# every file it writes, is removed on exit.
|
||||
#
|
||||
# Bind mounts are resolved by the Docker daemon, on its host. When this script runs inside a CI job
|
||||
# container that drives the host's Docker, its own files are not at those paths, so every file a
|
||||
# container mounts is staged under an identical path in the Docker host's /tmp first.
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
repository_root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$repository_root"
|
||||
|
||||
for command in docker openssl awk; do
|
||||
command -v "$command" >/dev/null || {
|
||||
echo "smoke-deploy: $command is required." >&2
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
docker compose version >/dev/null 2>&1 || {
|
||||
echo "smoke-deploy: Docker Compose is required." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# The pinned PostgreSQL image doubles as a root shell for file ownership, so the run needs neither
|
||||
# root nor sudo on the host.
|
||||
postgres_image="postgres:18.4-alpine3.24@sha256:9a8afca54e7861fd90fab5fdf4c42477a6b1cb7d293595148e674e0a3181de15"
|
||||
|
||||
run_id="$(openssl rand -hex 4)"
|
||||
project="ekklesia-deploy-smoke-${run_id}"
|
||||
work="$(mktemp -d)"
|
||||
image="${EKKLESIA_SMOKE_IMAGE:-}"
|
||||
built_image=""
|
||||
hostname="ekklesia.smoke.test"
|
||||
storage_hostname="storage.smoke.test"
|
||||
|
||||
# A per-run edge subnet, so concurrent runs and the host's own networks do not collide. Fixed
|
||||
# addresses come from the upper half, outside Docker's dynamic range, exactly as documented.
|
||||
octet=$((RANDOM % 200 + 20))
|
||||
edge_subnet="10.249.${octet}.0/24"
|
||||
edge_range="10.249.${octet}.0/25"
|
||||
proxy_address="10.249.${octet}.254"
|
||||
tunnel_address="10.249.${octet}.253"
|
||||
# A test-only client address, also outside the dynamic range, so its limiter bucket is its own.
|
||||
session_client_address="10.249.${octet}.252"
|
||||
|
||||
# daemon_work: where the Docker daemon sees this run's mounted files. The same directory as `work`
|
||||
# on a workstation; a staged copy on the Docker host when this script runs in a job container.
|
||||
if [[ -f /.dockerenv ]]; then
|
||||
daemon_work="/tmp/${project}"
|
||||
docker run --rm --entrypoint mkdir -v /tmp:/host-tmp "$postgres_image" -p "/host-tmp/${project}"
|
||||
# stage <path relative to work>: copy it to the same relative path under daemon_work, keeping
|
||||
# numeric ownership and modes.
|
||||
stage() {
|
||||
tar -C "$work" -cf - "$1" |
|
||||
docker run --rm -i --entrypoint tar -v "$daemon_work:/w" "$postgres_image" -xpf - -C /w
|
||||
}
|
||||
else
|
||||
daemon_work="$work"
|
||||
stage() { :; }
|
||||
fi
|
||||
|
||||
compose=(
|
||||
docker compose
|
||||
--project-name "$project"
|
||||
--project-directory "$daemon_work/deploy"
|
||||
--env-file "$work/compose.env"
|
||||
--file deploy/compose.yaml
|
||||
--file deploy/compose.proxy.yaml
|
||||
)
|
||||
|
||||
cleanup() {
|
||||
local status=$?
|
||||
trap - EXIT INT TERM
|
||||
if [[ -f "$work/compose.env" ]]; then
|
||||
"${compose[@]}" --profile ops down --volumes --remove-orphans --timeout 10 >/dev/null 2>&1 || status=1
|
||||
fi
|
||||
# Some files are owned by the PostgreSQL and SeaweedFS uids by now; remove them as root, in the
|
||||
# same image that set that ownership. In a job container the daemon cannot see `work` (and the
|
||||
# job itself runs as root), so only the staged copy on the Docker host needs this.
|
||||
if [[ "$daemon_work" == "$work" ]]; then
|
||||
docker run --rm --entrypoint rm -v "$work:/work" "$postgres_image" -rf /work/tls /work/env \
|
||||
>/dev/null 2>&1 || true
|
||||
else
|
||||
docker run --rm --entrypoint rm -v /tmp:/host-tmp "$postgres_image" -rf "/host-tmp/${project}" \
|
||||
>/dev/null 2>&1 || true
|
||||
fi
|
||||
rm -rf "$work" || true
|
||||
if [[ -n "$built_image" ]]; then
|
||||
docker image rm --force "$built_image" >/dev/null 2>&1 || true
|
||||
fi
|
||||
exit "$status"
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
step() {
|
||||
echo "==> $*"
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "smoke-deploy: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
if [[ -z "$image" ]]; then
|
||||
step "Building the platform-only image"
|
||||
built_image="ekklesia-deploy-smoke:${run_id}"
|
||||
DOCKER_BUILDKIT=1 docker build --quiet --file deploy/Dockerfile --tag "$built_image" . >/dev/null
|
||||
image="$built_image"
|
||||
fi
|
||||
|
||||
# The compose project directory: its relative bind mount (postgres/pg_hba.conf) resolves here.
|
||||
cp -R deploy "$work/deploy"
|
||||
stage deploy
|
||||
|
||||
step "Creating the installation-private CA and server certificates"
|
||||
init_status=0
|
||||
sh deploy/scripts/init-tls.sh "$work/tls" >/dev/null 2>&1 || init_status=$?
|
||||
if ((init_status != 0 && init_status != 77)); then
|
||||
fail "init-tls.sh failed to create TLS material."
|
||||
fi
|
||||
stage tls
|
||||
# The root-only ownership pass, exactly as an operator would re-run the script with root.
|
||||
ownership_output="$(
|
||||
docker run --rm --entrypoint sh -v "$daemon_work/tls:/tls" \
|
||||
-v "$daemon_work/deploy/scripts:/scripts:ro" "$postgres_image" /scripts/init-tls.sh /tls
|
||||
)" || fail "init-tls.sh could not apply ownership."
|
||||
database_ca_line="$(grep '^EKKLESIA_DATABASE_CA=' <<<"$ownership_output")" ||
|
||||
fail "init-tls.sh did not print the EKKLESIA_DATABASE_CA line."
|
||||
# A second run must create nothing and still succeed.
|
||||
rerun_output="$(
|
||||
docker run --rm --entrypoint sh -v "$daemon_work/tls:/tls" \
|
||||
-v "$daemon_work/deploy/scripts:/scripts:ro" "$postgres_image" /scripts/init-tls.sh /tls
|
||||
)" || fail "init-tls.sh failed on a second run."
|
||||
grep -q 'nothing to create' <<<"$rerun_output" || fail "init-tls.sh is not idempotent."
|
||||
|
||||
step "Writing env files from the examples, with freshly generated secrets"
|
||||
secrets="$(sh deploy/scripts/gen-secrets.sh)"
|
||||
secret() {
|
||||
awk -F= -v key="$1" '$1 == key { sub(/^[^=]*=/, ""); print; exit }' <<<"$secrets"
|
||||
}
|
||||
postgres_password="$(secret POSTGRES_PASSWORD)"
|
||||
s3_access_key="$(secret S3_ACCESS_KEY)"
|
||||
s3_secret_key="$(secret S3_SECRET_KEY)"
|
||||
identity_secret="$(secret EKKLESIA_IDENTITY_SECRET)"
|
||||
|
||||
mkdir -p "$work/env"
|
||||
# set_env <file> <key> <value>: replaces an uncommented KEY= line, keeping the value byte-exact.
|
||||
set_env() {
|
||||
KEY="$2" VALUE="$3" awk '
|
||||
BEGIN { found = 0 }
|
||||
index($0, ENVIRON["KEY"] "=") == 1 { print ENVIRON["KEY"] "=" ENVIRON["VALUE"]; found = 1; next }
|
||||
{ print }
|
||||
END { if (!found) print ENVIRON["KEY"] "=" ENVIRON["VALUE"] }
|
||||
' "$1" >"$1.next"
|
||||
mv "$1.next" "$1"
|
||||
}
|
||||
for name in ekklesia postgres object-storage; do
|
||||
cp "deploy/env/${name}.env.example" "$work/env/${name}.env"
|
||||
done
|
||||
ekklesia_env="$work/env/ekklesia.env"
|
||||
set_env "$ekklesia_env" DATABASE_URL "postgresql://ekklesia:${postgres_password}@postgres:5432/ekklesia"
|
||||
set_env "$ekklesia_env" EKKLESIA_DATABASE_CA "${database_ca_line#EKKLESIA_DATABASE_CA=}"
|
||||
set_env "$ekklesia_env" EKKLESIA_IDENTITY_BASE_URL "https://${hostname}"
|
||||
set_env "$ekklesia_env" EKKLESIA_IDENTITY_TRUSTED_ORIGINS "https://${hostname}"
|
||||
set_env "$ekklesia_env" EKKLESIA_IDENTITY_SECRET "$identity_secret"
|
||||
set_env "$ekklesia_env" S3_DELIVERY_ENDPOINT "https://${storage_hostname}"
|
||||
set_env "$ekklesia_env" S3_ACCESS_KEY "$s3_access_key"
|
||||
set_env "$ekklesia_env" S3_SECRET_KEY "$s3_secret_key"
|
||||
set_env "$work/env/postgres.env" POSTGRES_PASSWORD "$postgres_password"
|
||||
set_env "$work/env/object-storage.env" AWS_ACCESS_KEY_ID "$s3_access_key"
|
||||
set_env "$work/env/object-storage.env" AWS_SECRET_ACCESS_KEY "$s3_secret_key"
|
||||
chmod 600 "$work"/env/*.env
|
||||
|
||||
# TEST-ONLY: Caddy's internal CA instead of ACME. Inserted as the first global option.
|
||||
awk '!done && $0 == "{" { print; print "\tlocal_certs"; done = 1; next } { print }' \
|
||||
deploy/Caddyfile.example >"$work/Caddyfile"
|
||||
grep -q $'^\tlocal_certs$' "$work/Caddyfile" || fail "could not enable local_certs for the test proxy."
|
||||
stage Caddyfile
|
||||
|
||||
cat >"$work/compose.env" <<EOF
|
||||
EKKLESIA_IMAGE=${image}
|
||||
EKKLESIA_ENV_DIR=${work}/env
|
||||
EKKLESIA_TLS_DIR=${daemon_work}/tls
|
||||
EKKLESIA_HOSTNAME=${hostname}
|
||||
EKKLESIA_STORAGE_HOSTNAME=${storage_hostname}
|
||||
[email protected]
|
||||
EKKLESIA_CADDYFILE=${daemon_work}/Caddyfile
|
||||
EKKLESIA_PUBLISH_ADDRESS=127.0.0.1
|
||||
EKKLESIA_HTTP_PORT=0
|
||||
EKKLESIA_HTTPS_PORT=0
|
||||
EKKLESIA_EDGE_SUBNET=${edge_subnet}
|
||||
EKKLESIA_EDGE_DYNAMIC_RANGE=${edge_range}
|
||||
EKKLESIA_PROXY_ADDRESS=${proxy_address}
|
||||
EKKLESIA_TUNNEL_ADDRESS=${tunnel_address}
|
||||
EOF
|
||||
|
||||
step "Starting PostgreSQL and object storage"
|
||||
"${compose[@]}" up --detach --wait --wait-timeout 180 postgres object-storage >/dev/null 2>&1 ||
|
||||
fail "PostgreSQL and object storage did not become healthy."
|
||||
|
||||
step "Planning, then explicitly applying, migrations against this run's fresh database"
|
||||
"${compose[@]}" run --rm --no-deps migrate >/dev/null || fail "migrate (plan) failed."
|
||||
"${compose[@]}" run --rm --no-deps migrate node core/dist/cli/apply-migrations.js >/dev/null ||
|
||||
fail "migrate (apply) failed."
|
||||
|
||||
step "Starting the API and the proxy"
|
||||
"${compose[@]}" up --detach --wait --wait-timeout 180 >/dev/null 2>&1 ||
|
||||
fail "the API and proxy did not become healthy."
|
||||
|
||||
# Caddy creates its internal CA on first start. The test clients trust it, plus the installation CA
|
||||
# for their direct connection to object-storage.
|
||||
for _ in $(seq 1 30); do
|
||||
if "${compose[@]}" exec -T proxy cat /data/caddy/pki/authorities/local/root.crt \
|
||||
>"$work/caddy-root.crt" 2>/dev/null && [[ -s "$work/caddy-root.crt" ]]; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
[[ -s "$work/caddy-root.crt" ]] || fail "the test proxy's internal CA did not appear."
|
||||
cat "$work/caddy-root.crt" "$work/tls/ca.crt" >"$work/client-ca.pem"
|
||||
cp scripts/lib/deploy-smoke-client.mjs "$work/client.mjs"
|
||||
stage client-ca.pem
|
||||
stage client.mjs
|
||||
|
||||
edge_network="${project}_edge"
|
||||
client() {
|
||||
local mode="$1"
|
||||
shift
|
||||
docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges \
|
||||
--network "$edge_network" "$@" \
|
||||
--add-host "${hostname}:${proxy_address}" --add-host "${storage_hostname}:${proxy_address}" \
|
||||
-e NODE_EXTRA_CA_CERTS=/smoke/client-ca.pem -e SMOKE_HOSTNAME="$hostname" \
|
||||
-e SMOKE_STORAGE_HOSTNAME="$storage_hostname" \
|
||||
-e S3_ACCESS_KEY="$s3_access_key" -e S3_SECRET_KEY="$s3_secret_key" \
|
||||
-v "$daemon_work/client.mjs:/smoke/client.mjs:ro" \
|
||||
-v "$daemon_work/client-ca.pem:/smoke/client-ca.pem:ro" \
|
||||
--entrypoint node "$image" /smoke/client.mjs "$mode"
|
||||
}
|
||||
|
||||
step "Checking the API, Dashboard, storage delivery, and client-address chain through the proxy"
|
||||
client edge || fail "edge checks failed."
|
||||
|
||||
step "Checking the tunnel connector's client-address path from its fixed address"
|
||||
client tunnel --ip "$tunnel_address" || fail "tunnel checks failed."
|
||||
|
||||
step "Creating the initial owner with the image's own CLI, then signing in and out through the proxy"
|
||||
owner_email="[email protected]"
|
||||
owner_password="$(openssl rand -hex 16)"
|
||||
printf '%s' "$owner_password" |
|
||||
"${compose[@]}" run --rm --no-deps -T migrate node core/dist/cli/create-initial-owner.js \
|
||||
--email "$owner_email" >/dev/null || fail "creating the initial owner failed."
|
||||
echo "ok the initial owner was created from the image (password on standard input)"
|
||||
client session --ip "$session_client_address" \
|
||||
-e SMOKE_OWNER_EMAIL="$owner_email" -e SMOKE_OWNER_PASSWORD="$owner_password" ||
|
||||
fail "session checks failed."
|
||||
|
||||
step "Proving the API's database connection is certificate-verified TLS"
|
||||
api_container="$("${compose[@]}" ps -q api)"
|
||||
api_data_address="$(
|
||||
docker inspect --format "{{(index .NetworkSettings.Networks \"${project}_data\").IPAddress}}" \
|
||||
"$api_container"
|
||||
)"
|
||||
[[ -n "$api_data_address" ]] || fail "could not find the API's database-network address."
|
||||
psql_as_superuser() {
|
||||
"${compose[@]}" exec -T postgres psql -U ekklesia -d ekklesia -Atc "$1"
|
||||
}
|
||||
tls_connections="$(psql_as_superuser "SELECT count(*) FROM pg_stat_ssl s JOIN pg_stat_activity a USING (pid) WHERE a.client_addr = '${api_data_address}' AND s.ssl AND s.version = 'TLSv1.3'")"
|
||||
plain_connections="$(psql_as_superuser "SELECT count(*) FROM pg_stat_ssl s JOIN pg_stat_activity a USING (pid) WHERE a.client_addr = '${api_data_address}' AND NOT s.ssl")"
|
||||
((tls_connections > 0)) || fail "the API holds no TLS 1.3 database connection."
|
||||
((plain_connections == 0)) || fail "the API holds a database connection without TLS."
|
||||
echo "ok the API's database connections are TLS 1.3 (${tls_connections}), none plaintext"
|
||||
|
||||
# PostgreSQL itself refuses a network connection without TLS.
|
||||
if "${compose[@]}" exec -T -e PGPASSWORD="$postgres_password" postgres \
|
||||
psql "host=postgres user=ekklesia dbname=ekklesia sslmode=disable" -Atc 'SELECT 1' >/dev/null 2>&1; then
|
||||
fail "PostgreSQL accepted a plaintext network connection."
|
||||
fi
|
||||
echo "ok PostgreSQL refuses a plaintext network connection"
|
||||
|
||||
# The server cannot see whether a client verified it, so verification is proven by refusal: the
|
||||
# same command fails against a CA that did not issue the certificate, and against a host the
|
||||
# certificate does not name.
|
||||
openssl ecparam -name prime256v1 -genkey -noout -out "$work/other-ca.key" 2>/dev/null
|
||||
openssl req -x509 -new -sha256 -days 1 -key "$work/other-ca.key" -out "$work/other-ca.crt" \
|
||||
-subj "/CN=Unrelated CA" 2>/dev/null
|
||||
# expect_connection_refused <description> <compose run arguments...>: the command must fail, and fail
|
||||
# at the connection, not on a configuration error that would prove nothing about verification.
|
||||
expect_connection_refused() {
|
||||
local description="$1"
|
||||
shift
|
||||
local output
|
||||
if output="$("${compose[@]}" run --rm --no-deps "$@" 2>&1)"; then
|
||||
fail "the database connection accepted ${description}."
|
||||
fi
|
||||
grep -q 'MIGRATION_CONNECTION_FAILED' <<<"$output" ||
|
||||
fail "rejecting ${description} failed for a reason other than the connection."
|
||||
}
|
||||
expect_connection_refused "a certificate from a CA it was not given" \
|
||||
-e EKKLESIA_DATABASE_CA="$(cat "$work/other-ca.crt")" migrate
|
||||
echo "ok the database connection rejects a certificate its CA did not issue"
|
||||
postgres_address="$(
|
||||
docker inspect --format "{{(index .NetworkSettings.Networks \"${project}_data\").IPAddress}}" \
|
||||
"$("${compose[@]}" ps -q postgres)"
|
||||
)"
|
||||
expect_connection_refused "a certificate for a different host name" \
|
||||
-e DATABASE_URL="postgresql://ekklesia:${postgres_password}@${postgres_address}:5432/ekklesia" migrate
|
||||
echo "ok the database connection rejects a certificate that does not name the host"
|
||||
"${compose[@]}" run --rm --no-deps migrate >/dev/null || fail "the control connection failed."
|
||||
echo "ok the same connection succeeds with the installation CA and the name postgres"
|
||||
|
||||
step "Running Doctor from the image"
|
||||
doctor_output="$("${compose[@]}" run --rm --no-deps migrate node core/dist/cli/doctor.js 2>&1)" ||
|
||||
fail "Doctor reported the installation not ready."
|
||||
grep -q 'Result: READY' <<<"$doctor_output" || fail "Doctor did not report READY."
|
||||
grep -q 'TLS required with certificate and hostname verification (explicit private CA supplied)' \
|
||||
<<<"$doctor_output" || fail "Doctor did not report verify-full with the private CA."
|
||||
grep -q '✓ Object storage reachable' <<<"$doctor_output" ||
|
||||
fail "Doctor could not reach object storage over HTTPS."
|
||||
for value in "$postgres_password" "$s3_secret_key" "$identity_secret"; do
|
||||
if grep -qF -- "$value" <<<"$doctor_output"; then
|
||||
fail "Doctor output contains a secret."
|
||||
fi
|
||||
done
|
||||
echo "ok Doctor: READY, verify-full with the private CA, storage reachable, no secret printed"
|
||||
|
||||
api_logs="$("${compose[@]}" logs --no-color api 2>&1)"
|
||||
for value in "$postgres_password" "$s3_secret_key" "$identity_secret" "$owner_password"; do
|
||||
if grep -qF -- "$value" <<<"$api_logs"; then
|
||||
fail "the API's logs contain a secret."
|
||||
fi
|
||||
done
|
||||
restarts="$(docker inspect --format '{{.RestartCount}}' "$api_container")"
|
||||
((restarts == 0)) || fail "the API restarted ${restarts} time(s)."
|
||||
echo "ok the API logged no secret and never restarted"
|
||||
|
||||
echo "Reference deployment smoke test passed."
|
||||
Reference in New Issue
Block a user