diff --git a/apps/dokploy/__test__/traefik/traefik.test.ts b/apps/dokploy/__test__/traefik/traefik.test.ts index bcbc74623..9121dc8a1 100644 --- a/apps/dokploy/__test__/traefik/traefik.test.ts +++ b/apps/dokploy/__test__/traefik/traefik.test.ts @@ -275,3 +275,51 @@ test("CertificateType on websecure entrypoint", async () => { expect(router.tls?.certResolver).toBe("letsencrypt"); }); + +/** IDN/Punycode */ + +test("Internationalized domain name is converted to punycode", async () => { + const router = await createRouterConfig( + baseApp, + { ...baseDomain, host: "тест.рф" }, + "web", + ); + + // тест.рф in punycode is xn--e1aybc.xn--p1ai + expect(router.rule).toContain("Host(`xn--e1aybc.xn--p1ai`)"); + expect(router.rule).not.toContain("тест.рф"); +}); + +test("ASCII domain remains unchanged", async () => { + const router = await createRouterConfig( + baseApp, + { ...baseDomain, host: "example.com" }, + "web", + ); + + expect(router.rule).toContain("Host(`example.com`)"); +}); + +test("Russian Cyrillic label with .ru TLD is converted to punycode", async () => { + const router = await createRouterConfig( + baseApp, + { ...baseDomain, host: "сайт.ru" }, + "web", + ); + + // сайт in punycode is xn--80aswg + expect(router.rule).toContain("Host(`xn--80aswg.ru`)"); + expect(router.rule).not.toContain("сайт"); +}); + +test("Subdomain with Russian IDN TLD converts non-ASCII part to punycode", async () => { + const router = await createRouterConfig( + baseApp, + { ...baseDomain, host: "app.тест.рф" }, + "web", + ); + + // app stays ASCII, тест.рф becomes xn--e1aybc.xn--p1ai + expect(router.rule).toContain("Host(`app.xn--e1aybc.xn--p1ai`)"); + expect(router.rule).not.toContain("тест.рф"); +}); diff --git a/packages/server/src/utils/traefik/domain.ts b/packages/server/src/utils/traefik/domain.ts index 68095fa80..97400b1b9 100644 --- a/packages/server/src/utils/traefik/domain.ts +++ b/packages/server/src/utils/traefik/domain.ts @@ -104,6 +104,20 @@ export const removeDomain = async ( } }; +/** + * Converts an internationalized domain name (IDN) to ASCII punycode format. + * Traefik requires domain names in ASCII format, so non-ASCII characters + * must be converted (e.g., "тест.рф" → "xn--e1aybc.xn--p1ai"). + */ +const toPunycode = (host: string): string => { + try { + return new URL(`http://${host}`).hostname; + } catch { + // If URL parsing fails, return the original host + return host; + } +}; + export const createRouterConfig = async ( app: ApplicationNested, domain: Domain, @@ -114,8 +128,9 @@ export const createRouterConfig = async ( const { host, path, https, uniqueConfigKey, internalPath, stripPath } = domain; + const punycodeHost = toPunycode(host); const routerConfig: HttpRouter = { - rule: `Host(\`${host}\`)${path !== null && path !== "/" ? ` && PathPrefix(\`${path}\`)` : ""}`, + rule: `Host(\`${punycodeHost}\`)${path !== null && path !== "/" ? ` && PathPrefix(\`${path}\`)` : ""}`, service: `${appName}-service-${uniqueConfigKey}`, middlewares: [], entryPoints: [entryPoint],