feat(bootstrap): caddy public ingress + DNS-01 TLS (T07)

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) <noreply@anthropic.com>
This commit is contained in:
Andreas Niemann 2026-06-30 21:54:12 +02:00
parent fa242e4e76
commit 6a7c28b54c
4 changed files with 168 additions and 4 deletions

View file

@ -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<string>;
}
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 };
}