// 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 }; }