diff --git a/CHANGELOG.md b/CHANGELOG.md index 621a016..580a667 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ ## Changelog +### v0.42.0 — real Let's Encrypt cert: wildcard proactive issuance (2026-06-11) + +The base-infra traefik obtained **no** real cert (acme.json empty) — both routers relied on the +websecure entrypoint-default `certResolver`, which does not trigger proactive DNS-01 issuance, so +everything ran on traefik's self-signed default (masked externally by the tunnel's `noTLSVerify`). +This blocks LAN-direct (a LAN client TLS-handshakes straight to traefik and needs the real cert). + +- **`internal/infra/templates/traefik.yml.tmpl`** — the websecure entrypoint's `http.tls` now declares + `domains: [{main: "*.", sans: [""]}]` so traefik **proactively obtains the wildcard + `*.` + apex at startup** (via Cloudflare DNS-01). Every router then serves the real cert by + SNI match — no per-app `certresolver` labels to forget, cert ready before the first client connects. + Gated on `.CFAPIToken` (wildcards require DNS-01; HTTP-01 can't issue them). +- **`infra.TraefikData`** gains a `Domain` field; **`stacks.ensureTraefik`** now wires + `Domain: cfg.Customer.Domain` into `RenderTraefik` (previously unset). +- Validated staging→prod on guest 9201 (Fake LE → real LE), then GATE: `felhom.` + + `files.` return `200 0` (real cert, TLS verify OK) from a real LAN host. + ### v0.41.2 — fix controller-route auto-connect + dead dashboard cross-drive block (2026-06-11) Two fixes found while live-validating v0.41.1 routing on guest 9201: diff --git a/controller/internal/infra/infra.go b/controller/internal/infra/infra.go index 0759857..0bd16c5 100644 --- a/controller/internal/infra/infra.go +++ b/controller/internal/infra/infra.go @@ -40,8 +40,11 @@ type FileSpec struct { } // TraefikData is the per-customer input for the traefik stack. ACMEEmail empty → no Let's Encrypt -// (traefik serves self-signed); CFAPIToken empty → HTTP-01 instead of Cloudflare DNS-01, and no .env. +// (traefik serves self-signed); CFAPIToken empty → HTTP-01 instead of Cloudflare DNS-01, and no .env +// (and no wildcard — HTTP-01 can't issue wildcards). Domain drives the wildcard proactive-issuance +// SAN (`*.` + apex) when DNS-01 is in use. type TraefikData struct { + Domain string ACMEEmail string CFAPIToken string } diff --git a/controller/internal/infra/infra_test.go b/controller/internal/infra/infra_test.go index e68bb26..6f70c81 100644 --- a/controller/internal/infra/infra_test.go +++ b/controller/internal/infra/infra_test.go @@ -25,9 +25,9 @@ func allRendered(t *testing.T) []string { t.Helper() var out []string for _, td := range []TraefikData{ - {ACMEEmail: "admin@example.com", CFAPIToken: "cf-api-tok"}, - {ACMEEmail: "admin@example.com"}, // email, no CF token → HTTP-01 - {}, // token-less / LAN-only + {Domain: "example.com", ACMEEmail: "admin@example.com", CFAPIToken: "cf-api-tok"}, + {Domain: "example.com", ACMEEmail: "admin@example.com"}, // email, no CF token → HTTP-01 + {Domain: "example.com"}, // token-less / LAN-only } { files, err := RenderTraefik(td) if err != nil { @@ -64,7 +64,7 @@ func TestNoLatestTagSurvives(t *testing.T) { } func TestTraefikWithCloudflareToken(t *testing.T) { - files, err := RenderTraefik(TraefikData{ACMEEmail: "admin@example.com", CFAPIToken: "cf-api-tok"}) + files, err := RenderTraefik(TraefikData{Domain: "example.com", ACMEEmail: "admin@example.com", CFAPIToken: "cf-api-tok"}) if err != nil { t.Fatal(err) } @@ -75,6 +75,10 @@ func TestTraefikWithCloudflareToken(t *testing.T) { if !strings.Contains(yml, "dnsChallenge") || !strings.Contains(yml, "provider: cloudflare") { t.Error("expected Cloudflare DNS-01 challenge when CF API token set") } + // Wildcard proactive issuance (DNS-01 path): the entrypoint must request *. + apex. + if !strings.Contains(yml, `main: "*.example.com"`) || !strings.Contains(yml, `- "example.com"`) { + t.Errorf("expected wildcard domains block (*.example.com + apex) on the DNS-01 path:\n%s", yml) + } if strings.Contains(yml, "httpChallenge") { t.Error("HTTP-01 must NOT appear when a CF API token is set") } @@ -117,6 +121,9 @@ func TestTraefikEmailNoCloudflareToken(t *testing.T) { if strings.Contains(yml, "dnsChallenge") { t.Error("DNS-01 must NOT appear without a CF token") } + if strings.Contains(yml, "main: \"*.") { + t.Error("wildcard domains block must NOT appear on the HTTP-01 path (wildcards need DNS-01)") + } if _, ok := files[".env"]; ok { t.Error("no .env should be emitted without a CF API token") } diff --git a/controller/internal/infra/templates/traefik.yml.tmpl b/controller/internal/infra/templates/traefik.yml.tmpl index fd00199..d777ca6 100644 --- a/controller/internal/infra/templates/traefik.yml.tmpl +++ b/controller/internal/infra/templates/traefik.yml.tmpl @@ -19,6 +19,15 @@ entryPoints: http: tls: certResolver: letsencrypt +{{- if .CFAPIToken}} + # Wildcard proactive issuance (DNS-01 only — HTTP-01 can't do wildcards): traefik obtains + # *. (+ apex) at startup, so every router serves the real cert by SNI match with no + # per-app labels and the cert is ready before the first client connects. + domains: + - main: "*.{{.Domain}}" + sans: + - "{{.Domain}}" +{{- end}} {{- end}} providers: diff --git a/controller/internal/stacks/infra.go b/controller/internal/stacks/infra.go index 8df00d5..47e50ba 100644 --- a/controller/internal/stacks/infra.go +++ b/controller/internal/stacks/infra.go @@ -92,6 +92,7 @@ func (m *Manager) ensureTraefik(dir string) error { return fmt.Errorf("chmod acme.json: %w", err) } files, err := infra.RenderTraefik(infra.TraefikData{ + Domain: m.cfg.Customer.Domain, ACMEEmail: m.cfg.Customer.Email, CFAPIToken: m.cfg.Infrastructure.CFAPIToken, })