From 6a7c28b54c2db5e3552079fcce99de362f9b7d95 Mon Sep 17 00:00:00 2001 From: Andreas Niemann Date: Tue, 30 Jun 2026 21:54:12 +0200 Subject: [PATCH] feat(bootstrap): caddy public ingress + DNS-01 TLS (T07) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit foundation-caddy — the only public ingress (80/443 published), automatic TLS via Let's Encrypt DNS-01 over Cloudflare. Standard caddy:2 lacks the DNS plugin, so the egg builds a custom image on the VM (containers/caddy-cloudflare/Dockerfile: xcaddy + caddy-dns/cloudflare@v0.2.4, base digests pinned) via a remote.Command (ADR-007) whose stdout image id the container runs. The Caddyfile carries no secrets — the CF token is read from the container env ({env.CF_API_TOKEN}) — and is rendered + bind-mounted from the host. Routes forge -> Forgejo:3000 and s3 -> RustFS:9000; Vault is intentionally not proxied publicly (CONTRACT_003 "restricted"). Live on cx33 Helsinki: certs obtained for forge + s3; https://forge.olsitec.net = 502 (Forgejo lands in T08) and https://s3.olsitec.net = 403 (RustFS), both over valid Let's Encrypt certs (DNS-01). Acceptance T07 met. Co-Authored-By: Claude Opus 4.8 (1M context) --- VERSIONS | 5 +- bootstrap/components/proxy.ts | 144 +++++++++++++++++++++++++ bootstrap/index.ts | 8 +- containers/caddy-cloudflare/Dockerfile | 15 +++ 4 files changed, 168 insertions(+), 4 deletions(-) create mode 100644 bootstrap/components/proxy.ts create mode 100644 containers/caddy-cloudflare/Dockerfile diff --git a/VERSIONS b/VERSIONS index bfd5a53..92fd0fb 100644 --- a/VERSIONS +++ b/VERSIONS @@ -56,7 +56,10 @@ # CONTAINER IMAGES (CONTRACT_003 §3.2 — every container the egg runs) # Format: IMAGE_=:@sha256: # ----------------------------------------------------------------------------- -IMAGE_CADDY=caddy:2.10@sha256:PIN_DIGEST +# Caddy: the egg runs a CUSTOM build with the Cloudflare DNS-01 plugin (standard +# caddy:2 lacks it) — recipe + pinned base digests + module version live in +# containers/caddy-cloudflare/Dockerfile. This is the pinned FINAL base it derives. +IMAGE_CADDY=caddy:2.10@sha256:c3d7ee5d2b11f9dc54f947f68a734c84e9c9666c92c88a7f30b9cba5da182adb IMAGE_FORGEJO=codeberg.org/forgejo/forgejo:11@sha256:PIN_DIGEST IMAGE_POSTGRES=postgres:17@sha256:5c855ad7b85e68e48a62f34662853f38b57c1c1d80f3a927ab58034fd6d31c5e IMAGE_VAULT=hashicorp/vault:1.18@sha256:750bb37c1638fa194ab37053a81618c61bb0491ddec6fccac87c07a8e6cd8166 diff --git a/bootstrap/components/proxy.ts b/bootstrap/components/proxy.ts new file mode 100644 index 0000000..05bd788 --- /dev/null +++ b/bootstrap/components/proxy.ts @@ -0,0 +1,144 @@ +// components/proxy.ts (T07) +// +// foundation-caddy — the ONLY public ingress (CONTRACT_003 §3.2): 80/443 published, +// automatic TLS via Let's Encrypt DNS-01 over Cloudflare (PLAN-002 §9.4). Standard +// caddy:2 lacks the DNS plugin, so the egg runs a custom xcaddy build +// (containers/caddy-cloudflare/Dockerfile) compiled on the VM (ADR-007); its image +// id is what the container runs. The Caddyfile (no secrets — the CF token is read +// from the container env) is rendered here and bind-mounted from the host. +// +// Vault is deliberately NOT proxied publicly (CONTRACT_003 "restricted"): it's +// reached internally over foundation-net / docker-exec. forge -> Forgejo (502 until +// T08), s3 -> RustFS. +import * as fs from "fs"; +import * as path from "path"; +import * as pulumi from "@pulumi/pulumi"; +import * as docker from "@pulumi/docker"; +import * as command from "@pulumi/command"; +import { DeployCtx } from "../lib/context"; +import { vmConnection } from "../lib/remote"; + +const DOCKERFILE = fs.readFileSync( + path.resolve( + __dirname, + "..", + "..", + "containers", + "caddy-cloudflare", + "Dockerfile", + ), + "utf8", +); + +// Dockerfile arrives on stdin; build logs -> stderr; the built image id -> stdout +// (so it becomes the container's image, recreated only when the Dockerfile changes). +const BUILD = `set -eu +docker build --provenance=false --sbom=false -t foundation-caddy:cf - >&2 +docker image inspect foundation-caddy:cf --format '{{.Id}}'`; + +const HOST_CADDYFILE = "/srv/foundation/caddy/Caddyfile"; +const WRITE_CADDYFILE = `set -eu +mkdir -p /srv/foundation/caddy +cat > ${HOST_CADDYFILE}`; + +export interface ProxyOutputs { + container: docker.Container; + imageId: pulumi.Output; +} + +function renderCaddyfile(ctx: DeployCtx): string { + const { cfg } = ctx; + const site = (host: string, upstream: string) => + `${host} { + tls { + dns cloudflare {env.CF_API_TOKEN} + resolvers 1.1.1.1 + } + reverse_proxy ${upstream} +}`; + return `{ + email ${cfg.tls.acmeEmail} +} + +${site(cfg.hosts.forge, "foundation-forgejo:3000")} + +${site(cfg.hosts.s3, "foundation-rustfs:9000")} +`; +} + +export function deployProxy(ctx: DeployCtx): ProxyOutputs { + const { provider, network } = ctx; + const conn = vmConnection(ctx); + + // 1) Build the custom Caddy+Cloudflare image on the VM. + const build = new command.remote.Command("foundation-caddy-build", { + connection: conn, + create: BUILD, + update: BUILD, + stdin: DOCKERFILE, + addPreviousOutputInEnv: false, + triggers: [DOCKERFILE], + }); + const imageId = build.stdout.apply((s) => s.trim()); + + // 2) Render + write the Caddyfile to the host (bind-mounted into the container). + const caddyfile = renderCaddyfile(ctx); + const writeCaddyfile = new command.remote.Command("foundation-caddy-config", { + connection: conn, + create: WRITE_CADDYFILE, + update: WRITE_CADDYFILE, + stdin: caddyfile, + addPreviousOutputInEnv: false, + triggers: [caddyfile], + }); + + // 3) Volumes: ACME certs/account (data) + autosave config (CONTRACT_003 §3.4). + const dataVol = new docker.Volume( + "foundation-caddy-data", + { name: "foundation-caddy-data" }, + { provider, retainOnDelete: true }, // ACME account/certs — avoid re-issue churn + ); + const configVol = new docker.Volume( + "foundation-caddy-config", + { name: "foundation-caddy-config" }, + { provider }, + ); + + const cfToken = new pulumi.Config("foundation").requireSecret( + "cloudflareApiToken", + ); + + const container = new docker.Container( + "foundation-caddy", + { + name: "foundation-caddy", + image: imageId, + hostname: "foundation-caddy", + restart: "unless-stopped", + envs: [pulumi.interpolate`CF_API_TOKEN=${cfToken}`], + ports: [ + { internal: 80, external: 80 }, + { internal: 443, external: 443 }, + ], + volumes: [ + { volumeName: dataVol.name, containerPath: "/data" }, + { volumeName: configVol.name, containerPath: "/config" }, + { + hostPath: HOST_CADDYFILE, + containerPath: "/etc/caddy/Caddyfile", + readOnly: true, + }, + ], + networksAdvanced: [{ name: network.name, aliases: ["foundation-caddy"] }], + logDriver: "json-file", + logOpts: { "max-size": "10m", "max-file": "3" }, + }, + { + provider, + dependsOn: [network, build, writeCaddyfile], + deleteBeforeReplace: true, + }, + ); + + return { container, imageId }; +} diff --git a/bootstrap/index.ts b/bootstrap/index.ts index e8a5702..c7a5c80 100644 --- a/bootstrap/index.ts +++ b/bootstrap/index.ts @@ -16,6 +16,7 @@ import { import { deployPostgres } from "./components/postgres"; import { deployRustfs } from "./components/rustfs"; import { deployVault } from "./components/vault"; +import { deployProxy } from "./components/proxy"; const cfg = loadConfig(); @@ -44,9 +45,9 @@ const vault = deployVault(ctx); const vaultCreds = writeCredentialsToVault(ctx, credentials, vault); // ============================================================================= // PHASE 6 — FORGE (depends on: credentials, GATE A) -// T07 caddy · T08 forgejo · T10 runner +// T07 caddy ✓ · T08 forgejo · T10 runner // ----------------------------------------------------------------------------- -// const proxy = deployProxy(ctx); +const proxy = deployProxy(ctx); // const forgejo = deployForgejo(ctx, { postgres, rustfs, credentials, proxy }); // --- GATE B: Forgejo healthy → handover (T11) + runner registration (T10). // const runner = deployRunner(ctx, { forgejo, credentials }); @@ -56,7 +57,8 @@ const vaultCreds = writeCredentialsToVault(ctx, credentials, vault); // vaultCreds (T06) is a gate for Forgejo (T08) — it has no output to export yet. void vaultCreds; -export const phase = "T06-credentials"; // data-plane + creds-in-Vault +export const phase = "T07-caddy"; // data-plane + creds + public ingress (Caddy) +export const caddyImageId = proxy.imageId; export const networkName = network.name; export const vmTarget = `${cfg.vm.user}@${cfg.vm.host}`; export const postgresEndpoint = postgres.endpoint; diff --git a/containers/caddy-cloudflare/Dockerfile b/containers/caddy-cloudflare/Dockerfile new file mode 100644 index 0000000..e9b836a --- /dev/null +++ b/containers/caddy-cloudflare/Dockerfile @@ -0,0 +1,15 @@ +# Custom Caddy image with the Cloudflare DNS-01 plugin (standard caddy:2 lacks it, +# and DNS-01 is how the egg gets TLS before/without HTTP reachability — PLAN-002 +# §9.4). Built on the VM by components/proxy.ts (ADR-007); the resulting image id +# is what the foundation-caddy container runs. +# +# Determinism (D5): base images pinned by digest, DNS module pinned by version. +# Bump = edit this file → proxy.ts's build command rebuilds (Dockerfile is a trigger). +# builder caddy:2.10-builder sha256:01668408cc26e2e00c9d067c30cb43b2ba14ad1f2808beda55503cb2a31f59dc +# final caddy:2.10 sha256:c3d7ee5d2b11f9dc54f947f68a734c84e9c9666c92c88a7f30b9cba5da182adb +# module github.com/caddy-dns/cloudflare v0.2.4 (libdns/cloudflare v0.2.2) +FROM caddy@sha256:01668408cc26e2e00c9d067c30cb43b2ba14ad1f2808beda55503cb2a31f59dc AS builder +RUN xcaddy build --with github.com/caddy-dns/cloudflare@v0.2.4 + +FROM caddy@sha256:c3d7ee5d2b11f9dc54f947f68a734c84e9c9666c92c88a7f30b9cba5da182adb +COPY --from=builder /usr/bin/caddy /usr/bin/caddy