Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
65 changes: 65 additions & 0 deletions apps/server/src/app-deployment/labels.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import { describe, expect, test } from "bun:test";
import {
mergePreviewLabels,
reservedKeyCollision,
traefikLabelKeys,
traefikLabels,
wildcardDomains,
wildcardSuffix,
} from "./labels.ts";

describe("traefikLabels", () => {
Expand Down Expand Up @@ -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", () => {
Expand Down
74 changes: 65 additions & 9 deletions apps/server/src/app-deployment/labels.ts
Original file line number Diff line number Diff line change
@@ -1,9 +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, 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;
Expand All @@ -21,6 +29,36 @@ 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(".");
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 labels.slice(1).join(".");
}

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

/** 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";

Expand Down Expand Up @@ -70,6 +108,12 @@ export function traefikLabels(input: {
labels[`traefik.http.routers.${routerName}.tls.certresolver`] =
tls.certResolver;
}
if (tls.wildcard === true) {
const domains = wildcardDomains(hostname);
const [mainKey, sansKey] = tlsDomainKeys(routerName);
labels[mainKey] = domains.main;
labels[sansKey] = domains.sans;
}
}
if (forwardAuth || basicAuth || previewForwardAuth) {
const chain = [
Expand Down Expand Up @@ -110,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;
Expand All @@ -120,17 +166,27 @@ export function traefikLabelKeys(input: {
basicAuth?: TraefikBasicAuth;
previewForwardAuth?: TraefikPreviewForwardAuth;
}): string[] {
return Object.keys(
const keys = Object.keys(
traefikLabels({
routerName: input.routerName,
hostname: "localhost",
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 = {
Expand Down
22 changes: 22 additions & 0 deletions apps/server/src/app-deployment/workload-labels.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
);
});
});
53 changes: 53 additions & 0 deletions apps/server/src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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";
Expand Down
Loading
Loading