diff --git a/messages/en-US.json b/messages/en-US.json index ba0f1c004..97b19552d 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3176,5 +3176,7 @@ "webhookHeaderKeyPlaceholder": "Key", "webhookHeaderValuePlaceholder": "Value", "alertLabel": "Alert", - "domainPickerWildcardSubdomainNotAllowed": "Wildcard subdomains are not allowed." + "domainPickerWildcardSubdomainNotAllowed": "Wildcard subdomains are not allowed.", + "domainPickerWildcardCertWarning": "Wildcard certificates must be configured separately in Traefik.", + "domainPickerWildcardCertWarningLink": "Learn more" } diff --git a/server/lib/domainUtils.ts b/server/lib/domainUtils.ts index b147e79b8..138ffc74f 100644 --- a/server/lib/domainUtils.ts +++ b/server/lib/domainUtils.ts @@ -1,8 +1,9 @@ import { db } from "@server/db"; -import { domains, orgDomains, domainNamespaces } from "@server/db"; -import { eq, and } from "drizzle-orm"; +import { domains, orgDomains, domainNamespaces, resources } from "@server/db"; +import { eq, and, like, not } from "drizzle-orm"; import { subdomainSchema, wildcardSubdomainSchema } from "@server/lib/schemas"; import { fromError } from "zod-validation-error"; +import config from "./config"; export type DomainValidationResult = | { @@ -71,7 +72,8 @@ export async function validateAndConstructDomain( const isWildcard = subdomain !== undefined && subdomain !== null && - subdomain.includes("*"); + subdomain.includes("*") && + domainRes.domains.type !== "cname"; // Wildcard subdomains are not allowed on CNAME domains if (isWildcard && domainRes.domains.type === "cname") { @@ -97,6 +99,20 @@ export async function validateAndConstructDomain( } } + if ( + isWildcard && + domainRes.domains.type == "wildcard" && + !( + domainRes.domains.preferWildcardCert || + config.getRawConfig().traefik.prefer_wildcard_cert + ) + ) { + return { + success: false, + error: "Wildcard domains are not supported without configuring certificate resolver for wildcard certs and marking it as prefered." + }; + } + // Validate wildcard subdomain format if (isWildcard) { const parsedWildcard = wildcardSubdomainSchema.safeParse(subdomain); @@ -125,7 +141,8 @@ export async function validateAndConstructDomain( if (subdomain !== undefined && subdomain !== null) { if (!isWildcard) { // Validate regular subdomain format for wildcard domains - const parsedSubdomain = subdomainSchema.safeParse(subdomain); + const parsedSubdomain = + subdomainSchema.safeParse(subdomain); if (!parsedSubdomain.success) { return { success: false, @@ -160,3 +177,81 @@ export async function validateAndConstructDomain( }; } } + +/** + * Checks whether a given fullDomain conflicts with any existing wildcard resources, + * or (if the fullDomain is itself a wildcard) whether any existing resources would + * be matched by it. + * + * @param fullDomain - The fully-constructed domain to check (may contain a leading `*`) + * @param excludeResourceId - Optional resource ID to exclude from the check (for updates) + * @returns An object with `conflict: true` and a human-readable `message`, or `conflict: false` + */ +export async function checkWildcardDomainConflict( + fullDomain: string, + excludeResourceId?: number +): Promise<{ conflict: false } | { conflict: true; message: string }> { + const isWildcard = fullDomain.startsWith("*."); + + if (isWildcard) { + // e.g. fullDomain = "*.example.com" → suffix = ".example.com" + const suffix = fullDomain.slice(1); // ".example.com" + + // Find any existing non-wildcard resource whose fullDomain ends with this suffix + // e.g. "test.example.com" or "foo.example.com" + const conflicting = await db + .select({ + resourceId: resources.resourceId, + fullDomain: resources.fullDomain + }) + .from(resources) + .where(like(resources.fullDomain, `%${suffix}`)); + + const matches = conflicting.filter( + (r) => + !r.fullDomain!.startsWith("*.") && + r.fullDomain!.endsWith(suffix) && + (excludeResourceId === undefined || + r.resourceId !== excludeResourceId) + ); + + if (matches.length > 0) { + return { + conflict: true, + message: `Wildcard domain ${fullDomain} conflicts with existing resource(s): ${matches.map((r) => r.fullDomain).join(", ")}` + }; + } + } else { + // Specific domain — check if any existing wildcard would match it. + // e.g. fullDomain = "test.example.com" + // We look for a wildcard "*.example.com" which means fullDomain ends with ".example.com" + const dotIndex = fullDomain.indexOf("."); + if (dotIndex !== -1) { + const suffix = fullDomain.slice(dotIndex); // ".example.com" + const wildcardPattern = `*.${fullDomain.slice(dotIndex + 1)}`; // "*.example.com" + + const conflicting = await db + .select({ + resourceId: resources.resourceId, + fullDomain: resources.fullDomain + }) + .from(resources) + .where(eq(resources.fullDomain, wildcardPattern)); + + const matches = conflicting.filter( + (r) => + excludeResourceId === undefined || + r.resourceId !== excludeResourceId + ); + + if (matches.length > 0) { + return { + conflict: true, + message: `Domain ${fullDomain} conflicts with existing wildcard resource(s): ${matches.map((r) => r.fullDomain).join(", ")}` + }; + } + } + } + + return { conflict: false }; +} diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index dba2cbffb..6427bec4f 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -33,7 +33,15 @@ import { } from "drizzle-orm"; import logger from "@server/logger"; import config from "@server/lib/config"; -import { orgs, resources, sites, siteNetworks, siteResources, Target, targets } from "@server/db"; +import { + orgs, + resources, + sites, + siteNetworks, + siteResources, + Target, + targets +} from "@server/db"; import { sanitize, encodePath, @@ -277,7 +285,10 @@ export async function getTraefikConfig( mode: siteResources.mode }) .from(siteResources) - .innerJoin(siteNetworks, eq(siteResources.networkId, siteNetworks.networkId)) + .innerJoin( + siteNetworks, + eq(siteResources.networkId, siteNetworks.networkId) + ) .innerJoin(sites, eq(siteNetworks.siteId, sites.siteId)) .where( and( @@ -430,7 +441,8 @@ export async function getTraefikConfig( config.getRawConfig().traefik.prefer_wildcard_cert; const domainCertResolver = resource.domainCertResolver; - const preferWildcardCert = resource.preferWildcardCert; + const preferWildcardCert = + resource.preferWildcardCert || resource.wildcard; let resolverName: string | undefined; let preferWildcard: boolean | undefined; @@ -964,22 +976,17 @@ export async function getTraefikConfig( }; // Middleware that rewrites any path to /maintenance-screen - config_output.http.middlewares[ - siteResourceRewriteMiddlewareName - ] = { - replacePathRegex: { - regex: "^/(.*)", - replacement: "/private-maintenance-screen" - } - }; + config_output.http.middlewares[siteResourceRewriteMiddlewareName] = + { + replacePathRegex: { + regex: "^/(.*)", + replacement: "/private-maintenance-screen" + } + }; // HTTP -> HTTPS redirect so the ACME challenge can be served - config_output.http.routers[ - `${siteResourceRouterName}-redirect` - ] = { - entryPoints: [ - config.getRawConfig().traefik.http_entrypoint - ], + config_output.http.routers[`${siteResourceRouterName}-redirect`] = { + entryPoints: [config.getRawConfig().traefik.http_entrypoint], middlewares: [redirectHttpsMiddlewareName], service: siteResourceServiceName, rule: `Host(\`${fullDomain}\`)`, @@ -988,9 +995,7 @@ export async function getTraefikConfig( // Determine TLS / cert-resolver configuration let tls: any = {}; - if ( - !privateConfig.getRawPrivateConfig().flags.use_pangolin_dns - ) { + if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) { const domainParts = fullDomain.split("."); const wildCard = domainParts.length <= 2 @@ -1023,9 +1028,7 @@ export async function getTraefikConfig( // HTTPS router - presence of this entry triggers cert generation config_output.http.routers[siteResourceRouterName] = { - entryPoints: [ - config.getRawConfig().traefik.https_entrypoint - ], + entryPoints: [config.getRawConfig().traefik.https_entrypoint], service: siteResourceServiceName, middlewares: [siteResourceRewriteMiddlewareName], rule: `Host(\`${fullDomain}\`)`, @@ -1035,9 +1038,7 @@ export async function getTraefikConfig( // Assets bypass router - lets Next.js static files load without rewrite config_output.http.routers[`${siteResourceRouterName}-assets`] = { - entryPoints: [ - config.getRawConfig().traefik.https_entrypoint - ], + entryPoints: [config.getRawConfig().traefik.https_entrypoint], service: siteResourceServiceName, rule: `Host(\`${fullDomain}\`) && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`))`, priority: 101, diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index d3ace5adc..85e607211 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -23,7 +23,7 @@ import { OpenAPITags, registry } from "@server/openApi"; import { build } from "@server/build"; import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; import { getUniqueResourceName } from "@server/db/names"; -import { validateAndConstructDomain } from "@server/lib/domainUtils"; +import { validateAndConstructDomain, checkWildcardDomainConflict } from "@server/lib/domainUtils"; import { isSubscribed } from "#dynamic/lib/isSubscribed"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; @@ -271,6 +271,13 @@ async function createHttpResource( ); } + const wildcardConflict = await checkWildcardDomainConflict(fullDomain); + if (wildcardConflict.conflict) { + return next( + createHttpError(HttpCode.CONFLICT, wildcardConflict.message) + ); + } + // Prevent creating resource with same domain as dashboard const dashboardUrl = config.getRawConfig().app.dashboard_url; if (dashboardUrl) { diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 7c6a46d8f..0a7052dce 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -24,7 +24,7 @@ import { import { registry } from "@server/openApi"; import { OpenAPITags } from "@server/openApi"; import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; -import { validateAndConstructDomain } from "@server/lib/domainUtils"; +import { validateAndConstructDomain, checkWildcardDomainConflict } from "@server/lib/domainUtils"; import { build } from "@server/build"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; @@ -410,6 +410,16 @@ async function updateHttpResource( ); } + const wildcardConflict = await checkWildcardDomainConflict( + fullDomain, + resource.resourceId + ); + if (wildcardConflict.conflict) { + return next( + createHttpError(HttpCode.CONFLICT, wildcardConflict.message) + ); + } + // Prevent updating resource with same domain as dashboard const dashboardUrl = config.getRawConfig().app.dashboard_url; if (dashboardUrl) { diff --git a/src/components/DomainPicker.tsx b/src/components/DomainPicker.tsx index 73926448b..1c7a117a3 100644 --- a/src/components/DomainPicker.tsx +++ b/src/components/DomainPicker.tsx @@ -906,6 +906,21 @@ export default function DomainPicker({ )} )} + {selectedBaseDomain?.domainType === "wildcard" && + isWildcardSubdomain(subdomainInput) && ( +

+ {t("domainPickerWildcardCertWarning")}{" "} + + {t("domainPickerWildcardCertWarningLink")} + + . +

+ )} ); }