diff --git a/apps/dokploy/__test__/compose/domain/host-rule-format.test.ts b/apps/dokploy/__test__/compose/domain/host-rule-format.test.ts index 097c916ea..c4b3a48fc 100644 --- a/apps/dokploy/__test__/compose/domain/host-rule-format.test.ts +++ b/apps/dokploy/__test__/compose/domain/host-rule-format.test.ts @@ -136,6 +136,51 @@ describe("Host rule format regression tests", () => { }); }); + describe("Wildcard domain support (Traefik v3)", () => { + /** + * Traefik v3 requires HostRegexp for wildcard domains. + * @see https://doc.traefik.io/traefik/v3.0/routing/routers/ + */ + it("should generate HostRegexp rule for wildcard domain", async () => { + const labels = await createDomainLabels( + "test-app", + { ...baseDomain, host: "*.example.com" }, + "web", + ); + const ruleLabel = labels.find((l) => l.includes(".rule=")); + + expect(ruleLabel).toBeDefined(); + // Should use HostRegexp instead of Host for wildcards + expect(ruleLabel).toContain("HostRegexp(`^.+\\.example\\.com$`)"); + expect(ruleLabel).not.toContain("Host(`*.example.com`)"); + }); + + it("should generate HostRegexp rule for nested wildcard domain", async () => { + const labels = await createDomainLabels( + "test-app", + { ...baseDomain, host: "*.app.example.com" }, + "web", + ); + const ruleLabel = labels.find((l) => l.includes(".rule=")); + + expect(ruleLabel).toBeDefined(); + expect(ruleLabel).toContain("HostRegexp(`^.+\\.app\\.example\\.com$`)"); + }); + + it("should combine HostRegexp with PathPrefix for wildcard domain", async () => { + const labels = await createDomainLabels( + "test-app", + { ...baseDomain, host: "*.example.com", path: "/api" }, + "web", + ); + const ruleLabel = labels.find((l) => l.includes(".rule=")); + + expect(ruleLabel).toBeDefined(); + expect(ruleLabel).toContain("HostRegexp(`^.+\\.example\\.com$`)"); + expect(ruleLabel).toContain("PathPrefix(`/api`)"); + }); + }); + describe("Edge cases for domain names", () => { const domainCases = [ { name: "simple domain", host: "example.com" }, diff --git a/apps/dokploy/__test__/traefik/host-rule.test.ts b/apps/dokploy/__test__/traefik/host-rule.test.ts new file mode 100644 index 000000000..e0e447dd8 --- /dev/null +++ b/apps/dokploy/__test__/traefik/host-rule.test.ts @@ -0,0 +1,106 @@ +import { + generateTraefikHostRule, + isWildcardDomain, +} from "@dokploy/server/utils/traefik/host-rule"; +import { describe, expect, it } from "vitest"; + +/** + * Tests for Traefik host rule generation. + * + * Traefik v3 changed the syntax for wildcard routing: + * - Regular domains use: Host(`domain.com`) + * - Wildcard domains use: HostRegexp(`^.+\.domain\.com$`) + * + * @see https://doc.traefik.io/traefik/v3.0/routing/routers/ + * @see https://community.traefik.io/t/how-to-create-a-router-rule-for-a-wildcard/19850 + */ +describe("generateTraefikHostRule", () => { + describe("regular domains", () => { + it("should generate Host rule for simple domain", () => { + const rule = generateTraefikHostRule("example.com"); + expect(rule).toBe("Host(`example.com`)"); + }); + + it("should generate Host rule for subdomain", () => { + const rule = generateTraefikHostRule("app.example.com"); + expect(rule).toBe("Host(`app.example.com`)"); + }); + + it("should generate Host rule for deep subdomain", () => { + const rule = generateTraefikHostRule("api.v1.example.com"); + expect(rule).toBe("Host(`api.v1.example.com`)"); + }); + + it("should generate Host rule for localhost", () => { + const rule = generateTraefikHostRule("localhost"); + expect(rule).toBe("Host(`localhost`)"); + }); + + it("should generate Host rule for IP address", () => { + const rule = generateTraefikHostRule("192.168.1.100"); + expect(rule).toBe("Host(`192.168.1.100`)"); + }); + + it("should generate Host rule for hyphenated domain", () => { + const rule = generateTraefikHostRule("my-app.example-host.com"); + expect(rule).toBe("Host(`my-app.example-host.com`)"); + }); + }); + + describe("wildcard domains", () => { + it("should generate HostRegexp rule for wildcard domain", () => { + const rule = generateTraefikHostRule("*.example.com"); + expect(rule).toBe("HostRegexp(`^.+\\.example\\.com$`)"); + }); + + it("should generate HostRegexp rule for wildcard subdomain", () => { + const rule = generateTraefikHostRule("*.app.example.com"); + expect(rule).toBe("HostRegexp(`^.+\\.app\\.example\\.com$`)"); + }); + + it("should escape dots in base domain for regex", () => { + const rule = generateTraefikHostRule("*.my.multi.level.domain.com"); + expect(rule).toBe("HostRegexp(`^.+\\.my\\.multi\\.level\\.domain\\.com$`)"); + }); + + it("should handle hyphenated wildcard domain", () => { + const rule = generateTraefikHostRule("*.my-app.example.com"); + expect(rule).toBe("HostRegexp(`^.+\\.my-app\\.example\\.com$`)"); + }); + }); + + describe("edge cases", () => { + it("should not treat domain with asterisk in the middle as wildcard", () => { + // Only *.domain.com is a wildcard, not a*b.domain.com + const rule = generateTraefikHostRule("test*.example.com"); + expect(rule).toBe("Host(`test*.example.com`)"); + }); + + it("should not treat domain ending with asterisk as wildcard", () => { + const rule = generateTraefikHostRule("example.com*"); + expect(rule).toBe("Host(`example.com*`)"); + }); + }); +}); + +describe("isWildcardDomain", () => { + it("should return true for wildcard domain", () => { + expect(isWildcardDomain("*.example.com")).toBe(true); + }); + + it("should return true for nested wildcard", () => { + expect(isWildcardDomain("*.app.example.com")).toBe(true); + }); + + it("should return false for regular domain", () => { + expect(isWildcardDomain("example.com")).toBe(false); + }); + + it("should return false for subdomain", () => { + expect(isWildcardDomain("app.example.com")).toBe(false); + }); + + it("should return false for domain with asterisk in middle", () => { + expect(isWildcardDomain("a*b.example.com")).toBe(false); + }); +}); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index f28711dbf..81900bf15 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -117,6 +117,7 @@ export * from "./utils/tracking/hubspot"; export * from "./utils/traefik/application"; export * from "./utils/traefik/domain"; export * from "./utils/traefik/file-types"; +export * from "./utils/traefik/host-rule"; export * from "./utils/traefik/middleware"; export * from "./utils/traefik/redirect"; export * from "./utils/traefik/security"; diff --git a/packages/server/src/utils/docker/domain.ts b/packages/server/src/utils/docker/domain.ts index 2272f364e..eca8c54b6 100644 --- a/packages/server/src/utils/docker/domain.ts +++ b/packages/server/src/utils/docker/domain.ts @@ -11,6 +11,7 @@ import { cloneGiteaRepository } from "../providers/gitea"; import { cloneGithubRepository } from "../providers/github"; import { cloneGitlabRepository } from "../providers/gitlab"; import { getCreateComposeFileCommand } from "../providers/raw"; +import { generateTraefikHostRule } from "../traefik/host-rule"; import { randomizeDeployableSpecificationFile } from "./collision"; import { randomizeSpecificationFile } from "./compose"; import type { @@ -263,8 +264,9 @@ export const createDomainLabels = ( internalPath, } = domain; const routerName = `${appName}-${uniqueConfigKey}-${entrypoint}`; + const hostRule = generateTraefikHostRule(host); const labels = [ - `traefik.http.routers.${routerName}.rule=Host(\`${host}\`)${path && path !== "/" ? ` && PathPrefix(\`${path}\`)` : ""}`, + `traefik.http.routers.${routerName}.rule=${hostRule}${path && path !== "/" ? ` && PathPrefix(\`${path}\`)` : ""}`, `traefik.http.routers.${routerName}.entrypoints=${entrypoint}`, `traefik.http.services.${routerName}.loadbalancer.server.port=${port}`, `traefik.http.routers.${routerName}.service=${routerName}`, diff --git a/packages/server/src/utils/traefik/domain.ts b/packages/server/src/utils/traefik/domain.ts index 68095fa80..f78b25d7c 100644 --- a/packages/server/src/utils/traefik/domain.ts +++ b/packages/server/src/utils/traefik/domain.ts @@ -10,6 +10,7 @@ import { writeTraefikConfigRemote, } from "./application"; import type { FileConfig, HttpRouter } from "./file-types"; +import { generateTraefikHostRule } from "./host-rule"; import { createPathMiddlewares, removePathMiddlewares } from "./middleware"; export const manageDomain = async (app: ApplicationNested, domain: Domain) => { @@ -114,8 +115,9 @@ export const createRouterConfig = async ( const { host, path, https, uniqueConfigKey, internalPath, stripPath } = domain; + const hostRule = generateTraefikHostRule(host); const routerConfig: HttpRouter = { - rule: `Host(\`${host}\`)${path !== null && path !== "/" ? ` && PathPrefix(\`${path}\`)` : ""}`, + rule: `${hostRule}${path !== null && path !== "/" ? ` && PathPrefix(\`${path}\`)` : ""}`, service: `${appName}-service-${uniqueConfigKey}`, middlewares: [], entryPoints: [entryPoint], diff --git a/packages/server/src/utils/traefik/host-rule.ts b/packages/server/src/utils/traefik/host-rule.ts new file mode 100644 index 000000000..92c4b249f --- /dev/null +++ b/packages/server/src/utils/traefik/host-rule.ts @@ -0,0 +1,32 @@ +/** + * Generates a Traefik routing rule for the given host. + * + * For regular domains: uses Host(`domain.com`) + * For wildcard domains (*.domain.com): uses HostRegexp(`.+\.domain\.com`) + * + * Traefik v3 changed the syntax for wildcard routing: + * - v2 used: HostRegexp({subdomain:[a-z]+}.domain.com) + * - v3 uses: HostRegexp(`.+\.domain\.com`) + * + * @see https://doc.traefik.io/traefik/v3.0/routing/routers/ + * @see https://community.traefik.io/t/how-to-create-a-router-rule-for-a-wildcard/19850 + */ +export const generateTraefikHostRule = (host: string): string => { + if (host.startsWith("*.")) { + // Wildcard domain: *.example.com -> HostRegexp(`.+\.example\.com`) + const baseDomain = host.slice(2); // Remove "*." + // Escape dots in the domain for regex + const escapedDomain = baseDomain.replace(/\./g, "\\."); + return `HostRegexp(\`^.+\\.${escapedDomain}$\`)`; + } + + // Regular domain: use Host() matcher + return `Host(\`${host}\`)`; +}; + +/** + * Checks if the given host is a wildcard domain (starts with *.) + */ +export const isWildcardDomain = (host: string): boolean => { + return host.startsWith("*."); +};