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:
parent
fa242e4e76
commit
6a7c28b54c
4 changed files with 168 additions and 4 deletions
144
bootstrap/components/proxy.ts
Normal file
144
bootstrap/components/proxy.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue