From 6782ebfbdb72c1b3c03644b3cd5d12a5d5fc1dd9 Mon Sep 17 00:00:00 2001 From: simpros <38320875+simpros@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:20:46 +0200 Subject: [PATCH 1/2] Preview TLS opt-in wildcard mode: one certificate per preview suffix (#276) --- .env.example | 5 + apps/server/src/app-deployment/labels.test.ts | 65 +++++++++++++ apps/server/src/app-deployment/labels.ts | 34 ++++++- .../app-deployment/workload-labels.test.ts | 22 +++++ apps/server/src/config.test.ts | 53 +++++++++++ apps/server/src/config.ts | 60 +++++++++++- .../src/http/deploy-services-request.test.ts | 92 +++++++++++++++++++ apps/server/src/http/deploy.ts | 41 +++++++++ apps/server/src/index.ts | 3 + compose.env.example | 5 + deploy/traefik/README.md | 10 +- docker-compose.yml | 1 + docs/operator-deploy.md | 32 ++++++- 13 files changed, 415 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index 7adfe728..fefa445d 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,11 @@ SPROUT_MAIL_PASSWORD= # Set ENTRYPOINTS to enable HTTPS routers; CERTRESOLVER is optional with that. # SPROUT_TRAEFIK_ENTRYPOINTS=https # SPROUT_TRAEFIK_CERTRESOLVER=letsencrypt +# Opt-in wildcard mode: routers declare tls.domains for their suffix so the +# DNS-01 resolver serves one wildcard per preview suffix (zero orders/deploy). +# Requires CERTRESOLVER (DNS-01 only, never HTTP-01). See docs/operator-deploy.md +# § "Wildcard preview certificate (DNS-01)". +# SPROUT_TRAEFIK_WILDCARD_TLS=false # Traefik forwardAuth (optional SSO). Both required together or both empty. # MIDDLEWARES is a single Traefik middleware name (no commas). diff --git a/apps/server/src/app-deployment/labels.test.ts b/apps/server/src/app-deployment/labels.test.ts index 48e70ac7..14ae6394 100644 --- a/apps/server/src/app-deployment/labels.test.ts +++ b/apps/server/src/app-deployment/labels.test.ts @@ -2,7 +2,10 @@ import { describe, expect, test } from "bun:test"; import { mergePreviewLabels, reservedKeyCollision, + traefikLabelKeys, traefikLabels, + wildcardDomains, + wildcardSuffix, } from "./labels.ts"; describe("traefikLabels", () => { @@ -110,6 +113,68 @@ describe("traefikLabels", () => { "Host(`pr-42.myapp.preview.example.com`) && PathPrefix(`/admin`)", ); }); + + test("mode off emits no tls.domains key", () => { + const tls = { entrypoints: "https", certResolver: "letsencrypt" }; + const labels = traefikLabels({ + routerName: "sprout-app-pr-1", + hostname: "pr-1.a.previews.example.com", + port: 8080, + tls, + }); + expect( + Object.keys(labels).filter((key) => key.includes("tls.domains")), + ).toEqual([]); + expect( + traefikLabelKeys({ routerName: "sprout-app-pr-1", tls }).sort(), + ).toEqual(Object.keys(labels).sort()); + }); + + test("mode on emits main/sans for the suffix", () => { + const labels = traefikLabels({ + routerName: "sprout-app-pr-42", + hostname: "pr-42.a.previews.example.com", + port: 8080, + tls: { + entrypoints: "https", + certResolver: "letsencrypt", + wildcard: true, + }, + }); + expect( + labels["traefik.http.routers.sprout-app-pr-42.tls.domains[0].main"], + ).toBe("*.a.previews.example.com"); + expect( + labels["traefik.http.routers.sprout-app-pr-42.tls.domains[0].sans"], + ).toBe("a.previews.example.com"); + }); + + test("suffix derivation matches the bootstrap shape", () => { + expect(wildcardSuffix("pr-42.a.previews.example.com")).toBe( + "a.previews.example.com", + ); + expect(wildcardDomains("pr-42.a.previews.example.com")).toEqual({ + main: "*.a.previews.example.com", + sans: "a.previews.example.com", + }); + }); + + test("short hostname throws instead of emitting a broken wildcard", () => { + for (const hostname of ["localhost", "example.com"]) { + expect(() => + traefikLabels({ + routerName: "sprout-app-pr-1", + hostname, + port: 8080, + tls: { + entrypoints: "https", + certResolver: "letsencrypt", + wildcard: true, + }, + }), + ).toThrow(hostname); + } + }); }); describe("mergePreviewLabels", () => { diff --git a/apps/server/src/app-deployment/labels.ts b/apps/server/src/app-deployment/labels.ts index 4ea6add8..f1c08a44 100644 --- a/apps/server/src/app-deployment/labels.ts +++ b/apps/server/src/app-deployment/labels.ts @@ -3,6 +3,8 @@ import type { PreviewLabels } from "@sprout/preview-env"; export type TraefikTls = { entrypoints: string; certResolver?: string; + /** Opt-in wildcard mode: routers declare tls.domains for their suffix. */ + wildcard?: boolean; }; export type TraefikForwardAuth = { @@ -21,6 +23,29 @@ export type TraefikPreviewForwardAuth = { address: string; }; +/** + * Suffix a wildcard covers: the hostname with its first label removed. + * A wildcard covers exactly one label, so `pr-42.a.previews.example.com` + * needs `*.a.previews.example.com`, not `*.previews.example.com`. + * The suffix needs at least two labels to be a usable domain set. + */ +export function wildcardSuffix(hostname: string): string { + const labels = hostname.split("."); + const suffix = labels.slice(1).join("."); + if (labels.length < 3 || suffix.split(".").length < 2) { + throw new Error( + `Cannot derive wildcard TLS suffix from hostname ${JSON.stringify(hostname)}: need at least three labels (got ${labels.length})`, + ); + } + return suffix; +} + +/** Domain set matching the wildcard-bootstrap shape: wildcard main + apex SAN. */ +export function wildcardDomains(hostname: string): { main: string; sans: string } { + const suffix = wildcardSuffix(hostname); + return { main: `*.${suffix}`, sans: suffix }; +} + const FORWARDAUTH_RESPONSE_HEADERS = "Remote-User,Remote-Email,Remote-Groups"; @@ -70,6 +95,13 @@ export function traefikLabels(input: { labels[`traefik.http.routers.${routerName}.tls.certresolver`] = tls.certResolver; } + if (tls.wildcard === true) { + const domains = wildcardDomains(hostname); + labels[`traefik.http.routers.${routerName}.tls.domains[0].main`] = + domains.main; + labels[`traefik.http.routers.${routerName}.tls.domains[0].sans`] = + domains.sans; + } } if (forwardAuth || basicAuth || previewForwardAuth) { const chain = [ @@ -123,7 +155,7 @@ export function traefikLabelKeys(input: { return Object.keys( traefikLabels({ routerName: input.routerName, - hostname: "localhost", + hostname: "placeholder.example.com", port: 1, tls: input.tls, forwardAuth: input.forwardAuth, diff --git a/apps/server/src/app-deployment/workload-labels.test.ts b/apps/server/src/app-deployment/workload-labels.test.ts index 34194c07..1471039f 100644 --- a/apps/server/src/app-deployment/workload-labels.test.ts +++ b/apps/server/src/app-deployment/workload-labels.test.ts @@ -68,4 +68,26 @@ describe("workload-labels", () => { ); expect(gatewayLabelKeys("r", { kind: "internal" })).toEqual([]); }); + + test("wildcard policy reserves the tls.domains keys on the emission", () => { + const wildcardPolicy = { + traefikTls: { + entrypoints: "websecure", + certResolver: "myresolver", + wildcard: true, + }, + }; + const routing = appRouting("pr-42.a.previews.example.com", wildcardPolicy); + const name = previewContainerName("myapp", 42); + const keys = gatewayLabelKeys(name, routing).sort(); + expect( + keys.filter((key) => key.includes("tls.domains")), + ).toEqual([ + `traefik.http.routers.${name}.tls.domains[0].main`, + `traefik.http.routers.${name}.tls.domains[0].sans`, + ]); + expect(keys).toEqual( + Object.keys(gatewayLabels(name, routing, 3000)).sort(), + ); + }); }); diff --git a/apps/server/src/config.test.ts b/apps/server/src/config.test.ts index 6f2740ee..e417a257 100644 --- a/apps/server/src/config.test.ts +++ b/apps/server/src/config.test.ts @@ -72,6 +72,7 @@ function clearGatewayEnv(): void { delete process.env.SPROUT_DASHBOARD_PASSWORD; delete process.env.SPROUT_TRAEFIK_ENTRYPOINTS; delete process.env.SPROUT_TRAEFIK_CERTRESOLVER; + delete process.env.SPROUT_TRAEFIK_WILDCARD_TLS; delete process.env.SPROUT_TRAEFIK_MIDDLEWARES; delete process.env.SPROUT_FORWARDAUTH_ADDRESS; delete process.env.SPROUT_PREVIEW_AUTH_SECRET; @@ -207,6 +208,58 @@ describe("loadConfig", () => { expect(config.traefikTls).toEqual({ entrypoints: "https" }); }); + test("wildcard flag on without certresolver fails boot naming the key", () => { + setRequiredEnv(); + process.env.SPROUT_TRAEFIK_ENTRYPOINTS = "https"; + process.env.SPROUT_TRAEFIK_WILDCARD_TLS = "true"; + expect(() => loadConfig()).toThrow("SPROUT_TRAEFIK_CERTRESOLVER"); + }); + + test("wildcard flag on with certresolver loads and widens the summary", () => { + setRequiredEnv(); + process.env.SPROUT_TRAEFIK_ENTRYPOINTS = "https"; + process.env.SPROUT_TRAEFIK_CERTRESOLVER = "letsencrypt"; + process.env.SPROUT_TRAEFIK_WILDCARD_TLS = "true"; + const config = loadConfig(); + expect(config.traefikTls).toEqual({ + entrypoints: "https", + certResolver: "letsencrypt", + wildcard: true, + }); + expect(configSummary(config).traefikTls).toBe( + "https (certresolver=letsencrypt, wildcard)", + ); + }); + + test("wildcard flag off keeps the existing summary line", () => { + setRequiredEnv(); + process.env.SPROUT_TRAEFIK_ENTRYPOINTS = "https"; + process.env.SPROUT_TRAEFIK_CERTRESOLVER = "letsencrypt"; + const config = loadConfig(); + expect(config.traefikTls).toEqual({ + entrypoints: "https", + certResolver: "letsencrypt", + }); + expect(configSummary(config).traefikTls).toBe( + "https (certresolver=letsencrypt)", + ); + }); + + test("wildcard flag rejects non-boolean values", () => { + setRequiredEnv(); + process.env.SPROUT_TRAEFIK_ENTRYPOINTS = "https"; + process.env.SPROUT_TRAEFIK_CERTRESOLVER = "letsencrypt"; + process.env.SPROUT_TRAEFIK_WILDCARD_TLS = "sometimes"; + expect(() => loadConfig()).toThrow("SPROUT_TRAEFIK_WILDCARD_TLS"); + }); + + test("wildcard flag without entrypoints fails boot naming entrypoints", () => { + setRequiredEnv(); + process.env.SPROUT_TRAEFIK_CERTRESOLVER = "letsencrypt"; + process.env.SPROUT_TRAEFIK_WILDCARD_TLS = "true"; + expect(() => loadConfig()).toThrow("SPROUT_TRAEFIK_ENTRYPOINTS"); + }); + test("loads Traefik forwardAuth policy when both middleware name and address are set", () => { setRequiredEnv(); process.env.SPROUT_TRAEFIK_MIDDLEWARES = "voidauth"; diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 5341a9a5..b2eece8b 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -58,6 +58,7 @@ export const OPTIONAL_STRING_ENV = [ "SPROUT_FORGE_HOSTS", "SPROUT_TRAEFIK_ENTRYPOINTS", "SPROUT_TRAEFIK_CERTRESOLVER", + "SPROUT_TRAEFIK_WILDCARD_TLS", "SPROUT_TRAEFIK_MIDDLEWARES", "SPROUT_FORWARDAUTH_ADDRESS", "SPROUT_TELEMETRY_ENDPOINT", @@ -462,12 +463,45 @@ function parsePreviewAuthConfig(): PreviewAuthConfig | undefined { return { secret, address }; } +function parseTraefikWildcardTls(): boolean { + const raw = process.env.SPROUT_TRAEFIK_WILDCARD_TLS?.trim() ?? ""; + if (raw === "") return false; + const normalized = raw.toLowerCase(); + const parsed = parseBooleanToken( + normalized, + ["on", "1", "true", "yes"], + ["off", "0", "false", "no"], + ); + if (parsed === undefined) { + throw new Error( + "Invalid SPROUT_TRAEFIK_WILDCARD_TLS: must be a boolean (true/false, 1/0, yes/no, on/off)", + ); + } + return parsed; +} + function parseTraefikTls(): TraefikTls | undefined { const entrypoints = optionalEnv("SPROUT_TRAEFIK_ENTRYPOINTS"); - if (entrypoints === "") return undefined; + const wildcard = parseTraefikWildcardTls(); + if (entrypoints === "") { + if (wildcard) { + throw new Error( + "Incomplete Traefik wildcard TLS configuration: missing SPROUT_TRAEFIK_ENTRYPOINTS (wildcard TLS needs HTTPS routers)", + ); + } + return undefined; + } const certResolver = optionalEnv("SPROUT_TRAEFIK_CERTRESOLVER"); - return certResolver === "" - ? { entrypoints } + if (wildcard && certResolver === "") { + throw new Error( + "Incomplete Traefik wildcard TLS configuration: missing SPROUT_TRAEFIK_CERTRESOLVER (wildcard certificates require DNS-01, never HTTP-01)", + ); + } + if (certResolver === "") { + return wildcard ? { entrypoints, wildcard: true } : { entrypoints }; + } + return wildcard + ? { entrypoints, certResolver, wildcard: true } : { entrypoints, certResolver }; } @@ -813,8 +847,24 @@ function telemetryAuthSummary(telemetry: TelemetryState): string { function formatTraefikTlsSummary(tls: TraefikTls | undefined): string { if (!tls) return "[unset]"; - if (tls.certResolver === undefined) return tls.entrypoints; - return `${tls.entrypoints} (certresolver=${tls.certResolver})`; + const base = + tls.certResolver === undefined + ? tls.entrypoints + : `${tls.entrypoints} (certresolver=${tls.certResolver})`; + if (tls.wildcard !== true) return base; + return tls.certResolver === undefined + ? `${tls.entrypoints} (wildcard)` + : `${tls.entrypoints} (certresolver=${tls.certResolver}, wildcard)`; +} + +/** Wildcard mode needs a DNS-01 resolver, which sprout cannot observe. */ +export function traefikWildcardWarning(config: Config): string | null { + if (config.traefikTls?.wildcard !== true) return null; + return ( + "preview wildcard TLS is on (SPROUT_TRAEFIK_WILDCARD_TLS): " + + "SPROUT_TRAEFIK_CERTRESOLVER must be a DNS-01 resolver — " + + 'see docs/operator-deploy.md § "Wildcard preview certificate (DNS-01)"' + ); } function formatTraefikForwardAuthSummary( diff --git a/apps/server/src/http/deploy-services-request.test.ts b/apps/server/src/http/deploy-services-request.test.ts index adda5018..d32ae6a9 100644 --- a/apps/server/src/http/deploy-services-request.test.ts +++ b/apps/server/src/http/deploy-services-request.test.ts @@ -3,6 +3,7 @@ import { resolveLabelCollisions } from "../app-deployment/label-collisions.ts"; import { resolvePreviewLabelsRequest, resolveServicesRequest, + resolveWildcardHostnameGate, } from "./deploy.ts"; describe("resolveServicesRequest", () => { @@ -313,4 +314,95 @@ describe("resolveLabelCollisions", () => { }), ).toEqual({ ok: true }); }); + + test("wildcard policy reserves the tls.domains keys", () => { + const wildcardPolicy = { + traefikTls: { + entrypoints: "websecure", + certResolver: "myresolver", + wildcard: true, + }, + }; + const router = "sprout-myapp-pr-42"; + expect( + resolveLabelCollisions({ + slug: "myapp", + prId: 42, + hostname, + labels: { + [`traefik.http.routers.${router}.tls.domains[0].main`]: + "*.evil.example.com", + }, + services: undefined, + policy: wildcardPolicy, + }), + ).toEqual({ + ok: false, + error: "reserved_preview_label", + detail: `preview.labels.traefik.http.routers.${router}.tls.domains[0].main collides with a gateway label`, + }); + expect( + resolveLabelCollisions({ + slug: "myapp", + prId: 42, + hostname, + labels: { + [`traefik.http.routers.${router}.tls.domains[0].main`]: + "*.evil.example.com", + }, + services: undefined, + policy, + }), + ).toEqual({ ok: true }); + }); +}); + +describe("resolveWildcardHostnameGate", () => { + test("off passes everything", () => { + expect( + resolveWildcardHostnameGate({ + hostname: "localhost", + services: [{ name: "api", image: "img:1", hostname: "x" }], + wildcard: false, + }), + ).toEqual({ ok: true }); + }); + + test("on passes multi-label app and service hostnames", () => { + expect( + resolveWildcardHostnameGate({ + hostname: "pr-42.a.previews.example.com", + services: [ + { name: "api", image: "img:1", hostname: "api-pr-42.a.previews.example.com" }, + ], + wildcard: true, + }), + ).toEqual({ ok: true }); + }); + + test("on fails a short app hostname with a named error", () => { + const result = resolveWildcardHostnameGate({ + hostname: "example.com", + services: undefined, + wildcard: true, + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBe("invalid_wildcard_hostname"); + expect(result.detail).toContain("example.com"); + } + }); + + test("on fails a short service hostname with a named error", () => { + const result = resolveWildcardHostnameGate({ + hostname: "pr-42.a.previews.example.com", + services: [{ name: "api", image: "img:1", hostname: "localhost" }], + wildcard: true, + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBe("invalid_wildcard_hostname"); + expect(result.detail).toContain("localhost"); + } + }); }); diff --git a/apps/server/src/http/deploy.ts b/apps/server/src/http/deploy.ts index 4ccf898b..835e8163 100644 --- a/apps/server/src/http/deploy.ts +++ b/apps/server/src/http/deploy.ts @@ -37,6 +37,7 @@ import { import { t } from "elysia"; import type { AuthContext } from "../auth/middleware.ts"; import type { SeedImageSpec } from "../app-deployment/seed.ts"; +import { wildcardSuffix } from "../app-deployment/labels.ts"; import { resolveLabelCollisions } from "../app-deployment/label-collisions.ts"; import { previewAccessForKeys } from "../app-deployment/preview-auth.ts"; import { @@ -526,6 +527,38 @@ export type PreviewQuery = { pr_id: string; }; +/** Wildcard mode needs a derivable suffix per routed hostname. */ +export function resolveWildcardHostnameGate(input: { + hostname: string; + services: PreviewServiceSpec[] | undefined; + wildcard: boolean; +}): + | { ok: true } + | { ok: false; error: string; detail: string } { + if (!input.wildcard) return { ok: true }; + const hostnames = [ + input.hostname, + ...(input.services ?? []) + .map((service) => service.hostname) + .filter((name): name is string => name !== undefined), + ]; + for (const name of hostnames) { + try { + wildcardSuffix(name); + } catch (err) { + return { + ok: false, + error: "invalid_wildcard_hostname", + detail: + err instanceof Error + ? err.message + : `Cannot derive wildcard TLS suffix from hostname ${JSON.stringify(name)}`, + }; + } + } + return { ok: true }; +} + /** Single 422 mapper for the request resolvers below. */ function unprocessable( set: { status?: number | string }, @@ -593,6 +626,14 @@ export function deploy( if (!services.ok) { return unprocessable(set, services); } + const wildcardGate = resolveWildcardHostnameGate({ + hostname, + services: services.value, + wildcard: deps.materialization.traefikTls?.wildcard === true, + }); + if (!wildcardGate.ok) { + return unprocessable(set, wildcardGate); + } const labels = resolvePreviewLabelsRequest(body); if (!labels.ok) { return unprocessable(set, labels); diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 39e4e881..e9213434 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -4,6 +4,7 @@ import { configSummary, governanceUnboundedWarning, loadConfig, + traefikWildcardWarning, } from "./config.ts"; import { createDockerEngineClient } from "./docker/engine.ts"; import { startServer } from "./http/app.ts"; @@ -22,6 +23,8 @@ const config = loadConfig(); console.log("sprout starting", configSummary(config)); const unbounded = governanceUnboundedWarning(config); if (unbounded) console.warn(unbounded); +const wildcardWarning = traefikWildcardWarning(config); +if (wildcardWarning) console.warn(wildcardWarning); const traces = createTraces(config); diff --git a/compose.env.example b/compose.env.example index c64d4afa..b4cc18bf 100644 --- a/compose.env.example +++ b/compose.env.example @@ -47,6 +47,11 @@ SPROUT_REGISTRY_AUTHS_JSON= # is optional with that. Example for Coolify-managed proxy: https + letsencrypt # SPROUT_TRAEFIK_ENTRYPOINTS=https # SPROUT_TRAEFIK_CERTRESOLVER=letsencrypt +# Opt-in wildcard mode: routers declare tls.domains for their suffix so the +# DNS-01 resolver serves one wildcard per preview suffix (zero orders/deploy). +# Requires CERTRESOLVER (DNS-01 only, never HTTP-01). See docs/operator-deploy.md +# § "Wildcard preview certificate (DNS-01)". +# SPROUT_TRAEFIK_WILDCARD_TLS=false # Traefik forwardAuth (optional SSO gate). Both must be set together (or both # empty). MIDDLEWARES is a single Traefik middleware name (no commas). Gateway diff --git a/deploy/traefik/README.md b/deploy/traefik/README.md index 9b118ffc..49568ef5 100644 --- a/deploy/traefik/README.md +++ b/deploy/traefik/README.md @@ -7,7 +7,15 @@ # coexistence: copy under a new key with # distinct storage) # wildcard-bootstrap.compose.yml — temporary router to order the wildcard once -# (required env via `${VAR:?…}`) +# (required env via `${VAR:?…}`). +# Prefer SPROUT_TRAEFIK_WILDCARD_TLS=true instead: +# preview routers then declare the same +# wildcard-main + apex-SAN domain set per +# suffix, so the resolver orders and renews +# one wildcard per preview suffix with zero +# hand-bootstrapped certificates to keep in sync. +# See docs/operator-deploy.md § "Wildcard preview +# certificate (DNS-01)" (opt-in wildcard mode). # # All values are placeholders. Fill DNS provider credentials and Traefik paths # from your environment — never commit secrets. diff --git a/docker-compose.yml b/docker-compose.yml index daf8f585..517563b4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -103,6 +103,7 @@ services: SPROUT_MAIL_FROM_DOMAIN: ${SPROUT_MAIL_FROM_DOMAIN:-} SPROUT_TRAEFIK_ENTRYPOINTS: ${SPROUT_TRAEFIK_ENTRYPOINTS:-} SPROUT_TRAEFIK_CERTRESOLVER: ${SPROUT_TRAEFIK_CERTRESOLVER:-} + SPROUT_TRAEFIK_WILDCARD_TLS: ${SPROUT_TRAEFIK_WILDCARD_TLS:-false} SPROUT_TRAEFIK_MIDDLEWARES: ${SPROUT_TRAEFIK_MIDDLEWARES:-} SPROUT_FORWARDAUTH_ADDRESS: ${SPROUT_FORWARDAUTH_ADDRESS:-} SPROUT_PREVIEW_AUTH_SECRET: ${SPROUT_PREVIEW_AUTH_SECRET:-} diff --git a/docs/operator-deploy.md b/docs/operator-deploy.md index bdd845bf..19013a56 100644 --- a/docs/operator-deploy.md +++ b/docs/operator-deploy.md @@ -290,6 +290,35 @@ should log a store hit / `No ACME certificate generation required` for the preview FQDN — not a new LE order. Wrong resolver name → per-host orders resume (the rate-limit failure mode). +#### Opt-in wildcard mode (gateway-declared domains) + +Off by default. Set `SPROUT_TRAEFIK_WILDCARD_TLS=true` and the gateway's +preview routers additionally declare +`tls.domains[0].main=*.` + `tls.domains[0].sans=`, where +`` is the workload's hostname with its first label removed +(`pr-42.a.previews.example.com` → `a.previews.example.com`). The requested +set equals the bootstrap compose's shape (wildcard main + apex SAN), so a +hand-bootstrapped wildcard is reused instead of duplicated, and companion +services under the same suffix emit the same set (Traefik deduplicates by +domain set — one order serves all of them). + +Prerequisites: + +- `SPROUT_TRAEFIK_CERTRESOLVER` must be set (boot fails without it), and + that resolver must use **DNS-01** — a wildcard can only be validated over + DNS-01, never over HTTP-01. Pointing this mode at an HTTP-01 resolver + leaves routers asking for a certificate the resolver can never issue + (boot logs a warning naming this section because the challenge type is + outside sprout's view). +- A wildcard covers exactly **one** label: a preview host two levels below + the zone (`pr-42.a.previews.example.com`) needs + `*.a.previews.example.com`, not `*.previews.example.com` — one wildcard + per adopter suffix, ordered once per suffix on first deploy. +- Hostnames too short to derive a suffix (fewer than two remaining labels) + fail the deploy instead of emitting a broken wildcard. + +With the flag off, emitted labels are byte-identical to today. + Ready-to-apply fragments (placeholders only): [`deploy/traefik/README.md`](../deploy/traefik/README.md). @@ -560,7 +589,8 @@ the DSN from the raw password in YAML. | `SPROUT_REGISTRY_AUTHS_JSON` | no | Per-host pull map `{"ghcr.io":{"username":"u","password":"p"},…}` | | `SPROUT_REGISTRY_USER` / `SPROUT_REGISTRY_PASSWORD` | no | Legacy single-registry fallback when image host is not in the map | | `SPROUT_TRAEFIK_ENTRYPOINTS` | no | Non-empty → HTTPS router labels; empty → HTTP-only | -| `SPROUT_TRAEFIK_CERTRESOLVER` | no | Optional certresolver name when entrypoints set | +| `SPROUT_TRAEFIK_CERTRESOLVER` | no | Optional certresolver name when entrypoints set (required with `SPROUT_TRAEFIK_WILDCARD_TLS=true`) | +| `SPROUT_TRAEFIK_WILDCARD_TLS` | no | `true` → preview routers declare `tls.domains` wildcard+apex for their suffix (DNS-01 resolver required); default `false` (labels unchanged) | | `SPROUT_TRAEFIK_MIDDLEWARES` | no | Single forwardAuth middleware name (no commas) | | `SPROUT_FORWARDAUTH_ADDRESS` | no | Traefik-reachable forwardAuth URL (required with middleware name) | | `SPROUT_PREVIEW_AUTH_SECRET` | no | HMAC root for `preview.auth: link` tokens (required with address) | From e794ebe065da41e0c84caad13af3954364aa76d8 Mon Sep 17 00:00:00 2001 From: simpros <38320875+simpros@users.noreply.github.com> Date: Sun, 4 Oct 2026 23:31:55 +0200 Subject: [PATCH 2/2] address review feedback on #277 - traefikLabelKeys derives wildcard domain keys structurally instead of validating a placeholder hostname (drops the hidden throw path) - TraefikTls models wildcard-implies-certResolver; dead arm removed - single parseBooleanEnv for the gateway boolean knobs - drop redundant suffix check and unreachable non-Error fallback --- apps/server/src/app-deployment/labels.ts | 58 +++++++++----- apps/server/src/config.ts | 96 ++++++++++++------------ apps/server/src/http/deploy.ts | 5 +- 3 files changed, 91 insertions(+), 68 deletions(-) diff --git a/apps/server/src/app-deployment/labels.ts b/apps/server/src/app-deployment/labels.ts index f1c08a44..12429b3b 100644 --- a/apps/server/src/app-deployment/labels.ts +++ b/apps/server/src/app-deployment/labels.ts @@ -1,11 +1,17 @@ import type { PreviewLabels } from "@sprout/preview-env"; -export type TraefikTls = { - entrypoints: string; - certResolver?: string; - /** Opt-in wildcard mode: routers declare tls.domains for their suffix. */ - wildcard?: boolean; -}; +/** Opt-in wildcard mode: routers declare tls.domains for their suffix, which needs a DNS-01 cert resolver. */ +export type TraefikTls = + | { + entrypoints: string; + certResolver?: string; + wildcard?: false; + } + | { + entrypoints: string; + certResolver: string; + wildcard: true; + }; export type TraefikForwardAuth = { middleware: string; @@ -31,13 +37,12 @@ export type TraefikPreviewForwardAuth = { */ export function wildcardSuffix(hostname: string): string { const labels = hostname.split("."); - const suffix = labels.slice(1).join("."); - if (labels.length < 3 || suffix.split(".").length < 2) { + if (labels.length < 3) { throw new Error( `Cannot derive wildcard TLS suffix from hostname ${JSON.stringify(hostname)}: need at least three labels (got ${labels.length})`, ); } - return suffix; + return labels.slice(1).join("."); } /** Domain set matching the wildcard-bootstrap shape: wildcard main + apex SAN. */ @@ -46,6 +51,14 @@ export function wildcardDomains(hostname: string): { main: string; sans: string return { main: `*.${suffix}`, sans: suffix }; } +/** Static key names for the wildcard domain set: values vary per suffix, names do not. */ +function tlsDomainKeys(routerName: string): string[] { + return [ + `traefik.http.routers.${routerName}.tls.domains[0].main`, + `traefik.http.routers.${routerName}.tls.domains[0].sans`, + ]; +} + const FORWARDAUTH_RESPONSE_HEADERS = "Remote-User,Remote-Email,Remote-Groups"; @@ -97,10 +110,9 @@ export function traefikLabels(input: { } if (tls.wildcard === true) { const domains = wildcardDomains(hostname); - labels[`traefik.http.routers.${routerName}.tls.domains[0].main`] = - domains.main; - labels[`traefik.http.routers.${routerName}.tls.domains[0].sans`] = - domains.sans; + const [mainKey, sansKey] = tlsDomainKeys(routerName); + labels[mainKey] = domains.main; + labels[sansKey] = domains.sans; } } if (forwardAuth || basicAuth || previewForwardAuth) { @@ -142,8 +154,10 @@ export function traefikLabels(input: { /** * Reserved key shapes without a port/hostname: key names never depend on - * the rule value or the loadbalancer port, so dummy values suffice. Built - * on traefikLabels so the set cannot drift from the emission. + * the rule value or the loadbalancer port, so dummy values suffice. The + * wildcard domain keys are appended structurally: their names never depend + * on a suffix value, so enumeration must not validate a placeholder + * hostname to discover them. */ export function traefikLabelKeys(input: { routerName: string; @@ -152,17 +166,27 @@ export function traefikLabelKeys(input: { basicAuth?: TraefikBasicAuth; previewForwardAuth?: TraefikPreviewForwardAuth; }): string[] { - return Object.keys( + const keys = Object.keys( traefikLabels({ routerName: input.routerName, hostname: "placeholder.example.com", port: 1, - tls: input.tls, + tls: + input.tls?.wildcard === true + ? { + entrypoints: input.tls.entrypoints, + certResolver: input.tls.certResolver, + } + : input.tls, forwardAuth: input.forwardAuth, basicAuth: input.basicAuth, previewForwardAuth: input.previewForwardAuth, }), ); + if (input.tls?.wildcard === true) { + keys.push(...tlsDomainKeys(input.routerName)); + } + return keys; } export type ServiceLabelSource = { diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index b2eece8b..019731dc 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -268,25 +268,45 @@ function parseBooleanToken( return undefined; } -function parseTelemetryFlag( +const BOOLEAN_TRUE_TOKENS = ["on", "1", "true", "yes"]; +const BOOLEAN_FALSE_TOKENS = ["off", "0", "false", "no"]; + +function parseBooleanEnv( raw: string | undefined, - defaultValue: string, + options: { + trueTokens: readonly string[]; + falseTokens: readonly string[]; + /** Substituted when raw is blank; a blank default means false. */ + defaultRaw?: string; + error: string; + }, ): boolean { const trimmed = raw?.trim() ?? ""; - const normalized = (trimmed === "" ? defaultValue : trimmed).toLowerCase(); + const effective = + trimmed === "" ? (options.defaultRaw?.trim() ?? "") : trimmed; + if (effective === "") return false; const parsed = parseBooleanToken( - normalized, - ["on", "1", "true", "yes"], - ["off", "0", "false", "no"], + effective.toLowerCase(), + options.trueTokens, + options.falseTokens, ); - if (parsed === undefined) { - throw new Error( - "Invalid SPROUT_TELEMETRY: must be a boolean (on/off, true/false, 1/0, yes/no)", - ); - } + if (parsed === undefined) throw new Error(options.error); return parsed; } +function parseTelemetryFlag( + raw: string | undefined, + defaultValue: string, +): boolean { + return parseBooleanEnv(raw, { + trueTokens: BOOLEAN_TRUE_TOKENS, + falseTokens: BOOLEAN_FALSE_TOKENS, + defaultRaw: defaultValue, + error: + "Invalid SPROUT_TELEMETRY: must be a boolean (on/off, true/false, 1/0, yes/no)", + }); +} + function parseTelemetryDestination(): { endpoint: string; auth: string; @@ -384,19 +404,12 @@ function optionalEnv( } function parseMailSecure(raw: string): boolean { - const normalized = raw.trim().toLowerCase(); - if (normalized === "" ) return false; - const parsed = parseBooleanToken( - normalized, - ["1", "true", "yes"], - ["0", "false", "no"], - ); - if (parsed === undefined) { - throw new Error( + return parseBooleanEnv(raw, { + trueTokens: ["1", "true", "yes"], + falseTokens: ["0", "false", "no"], + error: "Invalid SPROUT_MAIL_SECURE: must be a boolean (true/false, 1/0, yes/no)", - ); - } - return parsed; + }); } /** The enablement decision, computed once so off always names its reason. */ @@ -464,20 +477,12 @@ function parsePreviewAuthConfig(): PreviewAuthConfig | undefined { } function parseTraefikWildcardTls(): boolean { - const raw = process.env.SPROUT_TRAEFIK_WILDCARD_TLS?.trim() ?? ""; - if (raw === "") return false; - const normalized = raw.toLowerCase(); - const parsed = parseBooleanToken( - normalized, - ["on", "1", "true", "yes"], - ["off", "0", "false", "no"], - ); - if (parsed === undefined) { - throw new Error( + return parseBooleanEnv(process.env.SPROUT_TRAEFIK_WILDCARD_TLS, { + trueTokens: BOOLEAN_TRUE_TOKENS, + falseTokens: BOOLEAN_FALSE_TOKENS, + error: "Invalid SPROUT_TRAEFIK_WILDCARD_TLS: must be a boolean (true/false, 1/0, yes/no, on/off)", - ); - } - return parsed; + }); } function parseTraefikTls(): TraefikTls | undefined { @@ -498,7 +503,7 @@ function parseTraefikTls(): TraefikTls | undefined { ); } if (certResolver === "") { - return wildcard ? { entrypoints, wildcard: true } : { entrypoints }; + return { entrypoints }; } return wildcard ? { entrypoints, certResolver, wildcard: true } @@ -523,13 +528,12 @@ function parseTraefikForwardAuth(): TraefikForwardAuth | undefined { } function parseDashboardEnabled(raw: string | undefined): boolean { - const normalized = raw?.trim().toLowerCase() ?? ""; - if (normalized === "") return false; - if (["1", "true", "yes", "on"].includes(normalized)) return true; - if (["0", "false", "no", "off"].includes(normalized)) return false; - throw new Error( - "Invalid SPROUT_DASHBOARD_ENABLED: must be a boolean (true/false, 1/0, yes/no)", - ); + return parseBooleanEnv(raw, { + trueTokens: BOOLEAN_TRUE_TOKENS, + falseTokens: BOOLEAN_FALSE_TOKENS, + error: + "Invalid SPROUT_DASHBOARD_ENABLED: must be a boolean (true/false, 1/0, yes/no)", + }); } /** Opt-in read-only preview list. Enabled demands basic-auth credentials. */ @@ -852,9 +856,7 @@ function formatTraefikTlsSummary(tls: TraefikTls | undefined): string { ? tls.entrypoints : `${tls.entrypoints} (certresolver=${tls.certResolver})`; if (tls.wildcard !== true) return base; - return tls.certResolver === undefined - ? `${tls.entrypoints} (wildcard)` - : `${tls.entrypoints} (certresolver=${tls.certResolver}, wildcard)`; + return `${tls.entrypoints} (certresolver=${tls.certResolver}, wildcard)`; } /** Wildcard mode needs a DNS-01 resolver, which sprout cannot observe. */ diff --git a/apps/server/src/http/deploy.ts b/apps/server/src/http/deploy.ts index 835e8163..c836caac 100644 --- a/apps/server/src/http/deploy.ts +++ b/apps/server/src/http/deploy.ts @@ -549,10 +549,7 @@ export function resolveWildcardHostnameGate(input: { return { ok: false, error: "invalid_wildcard_hostname", - detail: - err instanceof Error - ? err.message - : `Cannot derive wildcard TLS suffix from hostname ${JSON.stringify(name)}`, + detail: (err as Error).message, }; } }