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>
144 lines
4.3 KiB
TypeScript
144 lines
4.3 KiB
TypeScript
// 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 };
|
|
}
|