mirror of
https://github.com/fosrl/pangolin.git
synced 2026-09-23 18:58:51 +02:00
Compare commits
8 Commits
f03d0cd47f
...
6a96f743aa
| Author | SHA1 | Date | |
|---|---|---|---|
| 6a96f743aa | |||
| b4f0b4e285 | |||
| 07c7501669 | |||
| 009bac64bf | |||
| 5e293e8364 | |||
| 1ba7fca798 | |||
| e7a9a19816 | |||
| fa117198a0 |
+6
-1
@@ -1947,6 +1947,8 @@
|
||||
"httpMethod": "Scheme",
|
||||
"selectHttpMethod": "Select scheme",
|
||||
"domainPickerSubdomainLabel": "Subdomain",
|
||||
"domainPickerWildcard": "Wildcard",
|
||||
"domainPickerWildcardPaidOnly": "Wildcard subdomains are a paid feature. Please upgrade to access this feature.",
|
||||
"domainPickerBaseDomainLabel": "Base Domain",
|
||||
"domainPickerSearchDomains": "Search domains...",
|
||||
"domainPickerNoDomainsFound": "No domains found",
|
||||
@@ -3173,5 +3175,8 @@
|
||||
"webhookUrlLabel": "URL",
|
||||
"webhookHeaderKeyPlaceholder": "Key",
|
||||
"webhookHeaderValuePlaceholder": "Value",
|
||||
"alertLabel": "Alert"
|
||||
"alertLabel": "Alert",
|
||||
"domainPickerWildcardSubdomainNotAllowed": "Wildcard subdomains are not allowed.",
|
||||
"domainPickerWildcardCertWarning": "Wildcard certificates must be configured separately in Traefik.",
|
||||
"domainPickerWildcardCertWarningLink": "Learn more"
|
||||
}
|
||||
|
||||
@@ -158,7 +158,8 @@ export const resources = pgTable("resources", {
|
||||
maintenanceMessage: text("maintenanceMessage"),
|
||||
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
|
||||
postAuthPath: text("postAuthPath"),
|
||||
health: varchar("health") // "healthy", "unhealthy"
|
||||
health: varchar("health"), // "healthy", "unhealthy"
|
||||
wildcard: boolean("wildcard").notNull().default(false)
|
||||
});
|
||||
|
||||
export const targets = pgTable("targets", {
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
ResourceHeaderAuthExtendedCompatibility,
|
||||
resourceHeaderAuthExtendedCompatibility
|
||||
} from "@server/db";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { and, eq, inArray, or, sql } from "drizzle-orm";
|
||||
|
||||
export type ResourceWithAuth = {
|
||||
resource: Resource | null;
|
||||
@@ -47,7 +47,17 @@ export type UserSessionWithUser = {
|
||||
export async function getResourceByDomain(
|
||||
domain: string
|
||||
): Promise<ResourceWithAuth | null> {
|
||||
const [result] = await db
|
||||
// Build wildcard domain variants to match against.
|
||||
// For a domain like "me.example.test.com", we want to match:
|
||||
// - "*.example.test.com" (subdomain wildcard)
|
||||
// - "*.test.com" (parent wildcard, i.e. just "*" subdomain on parent)
|
||||
const parts = domain.split(".");
|
||||
const wildcardCandidates: string[] = [];
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
wildcardCandidates.push(`*.${parts.slice(i).join(".")}`);
|
||||
}
|
||||
|
||||
const potentialResults = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.leftJoin(
|
||||
@@ -70,8 +80,29 @@ export async function getResourceByDomain(
|
||||
)
|
||||
)
|
||||
.innerJoin(orgs, eq(orgs.orgId, resources.orgId))
|
||||
.where(eq(resources.fullDomain, domain))
|
||||
.limit(1);
|
||||
.where(
|
||||
or(
|
||||
// Exact match
|
||||
eq(resources.fullDomain, domain),
|
||||
// Wildcard match: resource fullDomain is one of the wildcard candidates
|
||||
wildcardCandidates.length > 0
|
||||
? and(
|
||||
eq(resources.wildcard, true),
|
||||
inArray(resources.fullDomain, wildcardCandidates)
|
||||
)
|
||||
: sql`false`
|
||||
)
|
||||
);
|
||||
|
||||
if (!potentialResults.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Prefer exact match over wildcard match
|
||||
const exactMatch = potentialResults.find(
|
||||
(r) => r.resources?.fullDomain === domain
|
||||
);
|
||||
const result = exactMatch ?? potentialResults[0];
|
||||
|
||||
if (!result) {
|
||||
return null;
|
||||
|
||||
@@ -179,7 +179,8 @@ export const resources = sqliteTable("resources", {
|
||||
maintenanceMessage: text("maintenanceMessage"),
|
||||
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
|
||||
postAuthPath: text("postAuthPath"),
|
||||
health: text("health") // "healthy", "unhealthy"
|
||||
health: text("health"), // "healthy", "unhealthy"
|
||||
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false)
|
||||
});
|
||||
|
||||
export const targets = sqliteTable("targets", {
|
||||
|
||||
@@ -23,7 +23,8 @@ export enum TierFeature {
|
||||
HTTPPrivateResources = "httpPrivateResources", // handle downgrade by disabling HTTP private resources
|
||||
DomainNamespaces = "domainNamespaces", // handle downgrade by removing custom domain namespaces
|
||||
StandaloneHealthChecks = "standaloneHealthChecks",
|
||||
AlertingRules = "alertingRules"
|
||||
AlertingRules = "alertingRules",
|
||||
WildcardSubdomain = "wildcardSubdomain"
|
||||
}
|
||||
|
||||
export const tierMatrix: Record<TierFeature, Tier[]> = {
|
||||
@@ -64,5 +65,6 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
|
||||
[TierFeature.HTTPPrivateResources]: ["tier3", "enterprise"],
|
||||
[TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||
[TierFeature.StandaloneHealthChecks]: ["tier2", "tier3", "enterprise"],
|
||||
[TierFeature.AlertingRules]: ["tier2", "tier3", "enterprise"]
|
||||
[TierFeature.AlertingRules]: ["tier2", "tier3", "enterprise"],
|
||||
[TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"]
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
domains,
|
||||
domainNamespaces,
|
||||
orgDomains,
|
||||
Resource,
|
||||
resourceHeaderAuth,
|
||||
@@ -236,6 +237,7 @@ export async function updateProxyResources(
|
||||
fullDomain: http ? resourceData["full-domain"] : null,
|
||||
subdomain: domain ? domain.subdomain : null,
|
||||
domainId: domain ? domain.domainId : null,
|
||||
wildcard: domain ? domain.wildcard : false,
|
||||
enabled: resourceEnabled,
|
||||
sso: resourceData.auth?.["sso-enabled"] || false,
|
||||
skipToIdpId:
|
||||
@@ -683,6 +685,7 @@ export async function updateProxyResources(
|
||||
fullDomain: http ? resourceData["full-domain"] : null,
|
||||
subdomain: domain ? domain.subdomain : null,
|
||||
domainId: domain ? domain.domainId : null,
|
||||
wildcard: domain ? domain.wildcard : false,
|
||||
enabled: resourceEnabled,
|
||||
sso: resourceData.auth?.["sso-enabled"] || false,
|
||||
skipToIdpId: resourceData.auth?.["auto-login-idp"] || null,
|
||||
@@ -1152,7 +1155,9 @@ async function getDomainId(
|
||||
orgId: string,
|
||||
fullDomain: string,
|
||||
trx: Transaction
|
||||
): Promise<{ subdomain: string | null; domainId: string } | null> {
|
||||
): Promise<{ subdomain: string | null; domainId: string; wildcard: boolean } | null> {
|
||||
const isWildcardFullDomain = fullDomain.startsWith("*.");
|
||||
|
||||
const possibleDomains = await trx
|
||||
.select()
|
||||
.from(domains)
|
||||
@@ -1165,6 +1170,11 @@ async function getDomainId(
|
||||
}
|
||||
|
||||
const validDomains = possibleDomains.filter((domain) => {
|
||||
// Wildcard full-domains are not allowed on CNAME domains
|
||||
if (isWildcardFullDomain && domain.domains.type === "cname") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (domain.domains.type == "ns" || domain.domains.type == "wildcard") {
|
||||
return (
|
||||
fullDomain === domain.domains.baseDomain ||
|
||||
@@ -1182,6 +1192,21 @@ async function getDomainId(
|
||||
const domainSelection = validDomains[0].domains;
|
||||
const baseDomain = domainSelection.baseDomain;
|
||||
|
||||
// Wildcard full-domains are not allowed on namespace (provided/free) domains
|
||||
if (isWildcardFullDomain) {
|
||||
const [namespaceDomain] = await trx
|
||||
.select()
|
||||
.from(domainNamespaces)
|
||||
.where(eq(domainNamespaces.domainId, domainSelection.domainId))
|
||||
.limit(1);
|
||||
|
||||
if (namespaceDomain) {
|
||||
throw new Error(
|
||||
`Wildcard full-domains are not supported for provided or free domains: ${fullDomain}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// remove the base domain of the domain
|
||||
let subdomain = null;
|
||||
if (fullDomain != baseDomain) {
|
||||
@@ -1191,6 +1216,7 @@ async function getDomainId(
|
||||
// Return the first valid domain
|
||||
return {
|
||||
subdomain: subdomain,
|
||||
domainId: domainSelection.domainId
|
||||
domainId: domainSelection.domainId,
|
||||
wildcard: isWildcardFullDomain
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { z } from "zod";
|
||||
import { portRangeStringSchema } from "@server/lib/ip";
|
||||
import { MaintenanceSchema } from "#dynamic/lib/blueprints/MaintenanceSchema";
|
||||
import { isValidRegionId } from "@server/db/regions";
|
||||
import { wildcardSubdomainSchema } from "@server/lib/schemas";
|
||||
|
||||
export const SiteSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
@@ -319,6 +320,34 @@ export const ResourceSchema = z
|
||||
message:
|
||||
"Rules have conflicting or invalid priorities (must be unique, including auto-assigned ones)"
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(resource) => {
|
||||
const fullDomain = resource["full-domain"];
|
||||
if (!fullDomain || !fullDomain.includes("*")) return true;
|
||||
|
||||
// A wildcard full-domain must be of the form *.labels.basedomain
|
||||
// Extract the leftmost label(s) before the first non-wildcard segment.
|
||||
// e.g. "*.level1.example.com" → subdomain candidate is "*.level1"
|
||||
// We do this by finding the base domain: everything after the first
|
||||
// real (non-wildcard) dot-separated segment pair.
|
||||
//
|
||||
// Simple rule: split on ".", first token must be "*", rest must be
|
||||
// valid hostname labels, and there must be at least 2 remaining labels
|
||||
// (so the full domain has a real base domain).
|
||||
const parts = fullDomain.split(".");
|
||||
if (parts[0] !== "*") return false; // * must be the very first label
|
||||
if (parts.includes("*", 1)) return false; // no further wildcards
|
||||
if (parts.length < 3) return false; // need at least *.label.tld
|
||||
|
||||
const labelRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$|^[a-zA-Z0-9]$/;
|
||||
return parts.slice(1).every((label) => labelRegex.test(label));
|
||||
},
|
||||
{
|
||||
path: ["full-domain"],
|
||||
message:
|
||||
'Wildcard full-domain must have "*" as the leftmost label only, followed by at least two valid hostname labels (e.g. "*.example.com" or "*.level1.example.com"). Patterns like "*example.com" or "level2.*.example.com" are not supported.'
|
||||
}
|
||||
);
|
||||
|
||||
export function isTargetsOnlyResource(resource: any): boolean {
|
||||
|
||||
+152
-12
@@ -1,14 +1,16 @@
|
||||
import { db } from "@server/db";
|
||||
import { domains, orgDomains } from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { subdomainSchema } from "@server/lib/schemas";
|
||||
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 =
|
||||
| {
|
||||
success: true;
|
||||
fullDomain: string;
|
||||
subdomain: string | null;
|
||||
wildcard: boolean;
|
||||
}
|
||||
| {
|
||||
success: false;
|
||||
@@ -66,6 +68,62 @@ export async function validateAndConstructDomain(
|
||||
};
|
||||
}
|
||||
|
||||
// Detect wildcard subdomain request
|
||||
const isWildcard =
|
||||
subdomain !== undefined &&
|
||||
subdomain !== null &&
|
||||
subdomain.includes("*") &&
|
||||
domainRes.domains.type !== "cname";
|
||||
|
||||
// Wildcard subdomains are not allowed on CNAME domains
|
||||
if (isWildcard && domainRes.domains.type === "cname") {
|
||||
return {
|
||||
success: false,
|
||||
error: "Wildcard subdomains are not supported for CNAME domains. CNAME domains must use a specific hostname."
|
||||
};
|
||||
}
|
||||
|
||||
// Wildcard subdomains are not allowed on namespace (provided/free) domains
|
||||
if (isWildcard) {
|
||||
const [namespaceDomain] = await db
|
||||
.select()
|
||||
.from(domainNamespaces)
|
||||
.where(eq(domainNamespaces.domainId, domainId))
|
||||
.limit(1);
|
||||
|
||||
if (namespaceDomain) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Wildcard subdomains are not supported for provided or free domains. Use a specific subdomain instead."
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
if (!parsedWildcard.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: fromError(parsedWildcard.error).toString()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Construct full domain based on domain type
|
||||
let fullDomain = "";
|
||||
let finalSubdomain = subdomain;
|
||||
@@ -81,13 +139,16 @@ export async function validateAndConstructDomain(
|
||||
finalSubdomain = null; // CNAME domains don't use subdomains
|
||||
} else if (domainRes.domains.type === "wildcard") {
|
||||
if (subdomain !== undefined && subdomain !== null) {
|
||||
// Validate subdomain format for wildcard domains
|
||||
const parsedSubdomain = subdomainSchema.safeParse(subdomain);
|
||||
if (!parsedSubdomain.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: fromError(parsedSubdomain.error).toString()
|
||||
};
|
||||
if (!isWildcard) {
|
||||
// Validate regular subdomain format for wildcard domains
|
||||
const parsedSubdomain =
|
||||
subdomainSchema.safeParse(subdomain);
|
||||
if (!parsedSubdomain.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: fromError(parsedSubdomain.error).toString()
|
||||
};
|
||||
}
|
||||
}
|
||||
fullDomain = `${subdomain}.${domainRes.domains.baseDomain}`;
|
||||
} else {
|
||||
@@ -100,13 +161,14 @@ export async function validateAndConstructDomain(
|
||||
finalSubdomain = null;
|
||||
}
|
||||
|
||||
// Convert to lowercase
|
||||
// Convert to lowercase (preserve * as-is)
|
||||
fullDomain = fullDomain.toLowerCase();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
fullDomain,
|
||||
subdomain: finalSubdomain ?? null
|
||||
subdomain: finalSubdomain ?? null,
|
||||
wildcard: isWildcard
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
@@ -115,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 };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,41 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Validates a wildcard subdomain passed as the leftmost component of a full domain.
|
||||
*
|
||||
* The value represents everything to the left of the base domain, so when combined
|
||||
* with e.g. "example.com" it must produce a valid SSL-style wildcard hostname.
|
||||
*
|
||||
* Valid:
|
||||
* "*" → *.example.com
|
||||
* "*.level1" → *.level1.example.com
|
||||
*
|
||||
* Invalid:
|
||||
* "*example" → *example.com (no dot after *)
|
||||
* "level2.*.level1" → wildcard not in leftmost position
|
||||
* "*.level1.*" → multiple wildcards
|
||||
*/
|
||||
export const wildcardSubdomainSchema = z
|
||||
.string()
|
||||
.refine(
|
||||
(val) => {
|
||||
// Must start with "*."; the remainder (if any) must be valid hostname labels.
|
||||
// A bare "*" is also valid (becomes *.baseDomain directly).
|
||||
if (val === "*") return true;
|
||||
if (!val.startsWith("*.")) return false;
|
||||
const rest = val.slice(2); // everything after "*."
|
||||
// rest must not be empty, must not contain another "*",
|
||||
// and every label must be a valid hostname label.
|
||||
if (!rest || rest.includes("*")) return false;
|
||||
const labelRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$/;
|
||||
return rest.split(".").every((label) => labelRegex.test(label));
|
||||
},
|
||||
{
|
||||
message:
|
||||
'Invalid wildcard subdomain. The wildcard "*" must be the leftmost label followed by a dot and valid hostname labels (e.g. "*" or "*.level1"). Patterns like "*example", "level2.*.level1", or multiple wildcards are not supported.'
|
||||
}
|
||||
);
|
||||
|
||||
export const subdomainSchema = z
|
||||
.string()
|
||||
.regex(
|
||||
|
||||
@@ -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,
|
||||
@@ -100,6 +108,7 @@ export async function getTraefikConfig(
|
||||
headers: resources.headers,
|
||||
proxyProtocol: resources.proxyProtocol,
|
||||
proxyProtocolVersion: resources.proxyProtocolVersion,
|
||||
wildcard: resources.wildcard,
|
||||
|
||||
maintenanceModeEnabled: resources.maintenanceModeEnabled,
|
||||
maintenanceModeType: resources.maintenanceModeType,
|
||||
@@ -238,6 +247,7 @@ export async function getTraefikConfig(
|
||||
priority: priority, // may be null, we fallback later
|
||||
domainCertResolver: row.domainCertResolver,
|
||||
preferWildcardCert: row.preferWildcardCert,
|
||||
wildcard: row.wildcard,
|
||||
|
||||
maintenanceModeEnabled: row.maintenanceModeEnabled,
|
||||
maintenanceModeType: row.maintenanceModeType,
|
||||
@@ -275,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(
|
||||
@@ -376,7 +389,16 @@ export async function getTraefikConfig(
|
||||
...additionalMiddlewares
|
||||
];
|
||||
|
||||
let rule = `Host(\`${fullDomain}\`)`;
|
||||
let rule: string;
|
||||
if (resource.wildcard && fullDomain.startsWith("*.")) {
|
||||
// Convert *.foo.bar.com -> HostRegexp(`^[^.]+\.foo\.bar\.com$`)
|
||||
const escaped = fullDomain
|
||||
.slice(2) // remove leading "*."
|
||||
.replace(/\./g, "\\.");
|
||||
rule = `HostRegexp(\`^[^.]+\\.${escaped}$\`)`;
|
||||
} else {
|
||||
rule = `Host(\`${fullDomain}\`)`;
|
||||
}
|
||||
|
||||
// priority logic
|
||||
let priority: number;
|
||||
@@ -419,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;
|
||||
@@ -566,7 +589,7 @@ export async function getTraefikConfig(
|
||||
resource.ssl ? entrypointHttps : entrypointHttp
|
||||
],
|
||||
service: maintenanceServiceName,
|
||||
rule: `Host(\`${fullDomain}\`) && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`))`,
|
||||
rule: `${rule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`))`,
|
||||
priority: 2001,
|
||||
...(resource.ssl ? { tls } : {})
|
||||
};
|
||||
@@ -953,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}\`)`,
|
||||
@@ -977,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
|
||||
@@ -1012,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}\`)`,
|
||||
@@ -1024,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,
|
||||
|
||||
@@ -50,7 +50,7 @@ import {
|
||||
userOrgRoles,
|
||||
roles
|
||||
} from "@server/db";
|
||||
import { eq, and, inArray, isNotNull, ne } from "drizzle-orm";
|
||||
import { eq, and, inArray, isNotNull, ne, or, sql } from "drizzle-orm";
|
||||
import { response } from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
@@ -492,7 +492,15 @@ hybridRouter.get(
|
||||
);
|
||||
}
|
||||
|
||||
const [result] = await db
|
||||
// Build wildcard domain candidates for the requested domain.
|
||||
// e.g. "me.example.test.com" -> ["*.example.test.com", "*.test.com"]
|
||||
const domainParts = domain.split(".");
|
||||
const wildcardCandidates: string[] = [];
|
||||
for (let i = 1; i < domainParts.length; i++) {
|
||||
wildcardCandidates.push(`*.${domainParts.slice(i).join(".")}`);
|
||||
}
|
||||
|
||||
const potentialResults = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.leftJoin(
|
||||
@@ -515,10 +523,28 @@ hybridRouter.get(
|
||||
)
|
||||
)
|
||||
.innerJoin(orgs, eq(orgs.orgId, resources.orgId))
|
||||
.where(eq(resources.fullDomain, domain))
|
||||
.limit(1);
|
||||
.where(
|
||||
or(
|
||||
// Exact match
|
||||
eq(resources.fullDomain, domain),
|
||||
// Wildcard match
|
||||
wildcardCandidates.length > 0
|
||||
? and(
|
||||
eq(resources.wildcard, true),
|
||||
inArray(resources.fullDomain, wildcardCandidates)
|
||||
)
|
||||
: sql`false`
|
||||
)
|
||||
);
|
||||
|
||||
// Prefer exact match over wildcard match
|
||||
const exactMatch = potentialResults.find(
|
||||
(r) => r.resources?.fullDomain === domain
|
||||
);
|
||||
const result = exactMatch ?? potentialResults[0];
|
||||
|
||||
if (
|
||||
result &&
|
||||
await checkExitNodeOrg(
|
||||
remoteExitNode.exitNodeId,
|
||||
result.resources.orgId
|
||||
|
||||
@@ -6,7 +6,7 @@ import { fromError } from "zod-validation-error";
|
||||
import logger from "@server/logger";
|
||||
import { resourceAccessToken, resources, sessions } from "@server/db";
|
||||
import { db } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { and, eq, inArray, or, sql } from "drizzle-orm";
|
||||
import {
|
||||
createResourceSession,
|
||||
serializeResourceSessionCookie,
|
||||
@@ -65,11 +65,31 @@ export async function exchangeSession(
|
||||
|
||||
const clientIp = requestIp ? stripPortFromHost(requestIp) : undefined;
|
||||
|
||||
const [resource] = await db
|
||||
const parts = cleanHost.split(".");
|
||||
const wildcardCandidates: string[] = [];
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
wildcardCandidates.push(`*.${parts.slice(i).join(".")}`);
|
||||
}
|
||||
|
||||
const potentialResources = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
.where(eq(resources.fullDomain, cleanHost))
|
||||
.limit(1);
|
||||
.where(
|
||||
or(
|
||||
eq(resources.fullDomain, cleanHost),
|
||||
wildcardCandidates.length > 0
|
||||
? and(
|
||||
eq(resources.wildcard, true),
|
||||
inArray(resources.fullDomain, wildcardCandidates)
|
||||
)
|
||||
: sql`false`
|
||||
)
|
||||
);
|
||||
|
||||
const exactMatch = potentialResources.find(
|
||||
(r) => r.fullDomain === cleanHost
|
||||
);
|
||||
const resource = exactMatch ?? potentialResources[0];
|
||||
|
||||
if (!resource) {
|
||||
return next(
|
||||
|
||||
@@ -17,14 +17,15 @@ import createHttpError from "http-errors";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import logger from "@server/logger";
|
||||
import { subdomainSchema } from "@server/lib/schemas";
|
||||
import { subdomainSchema, wildcardSubdomainSchema } from "@server/lib/schemas";
|
||||
import config from "@server/lib/config";
|
||||
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";
|
||||
|
||||
const createResourceParamsSchema = z.strictObject({
|
||||
@@ -44,7 +45,10 @@ const createHttpResourceSchema = z
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.subdomain) {
|
||||
return subdomainSchema.safeParse(data.subdomain).success;
|
||||
return (
|
||||
subdomainSchema.safeParse(data.subdomain).success ||
|
||||
wildcardSubdomainSchema.safeParse(data.subdomain).success
|
||||
);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
@@ -198,6 +202,22 @@ async function createHttpResource(
|
||||
const subdomain = parsedBody.data.subdomain;
|
||||
const stickySession = parsedBody.data.stickySession;
|
||||
|
||||
// Wildcard subdomains are a paid feature
|
||||
if (subdomain && subdomain.includes("*")) {
|
||||
const isLicensed = await isLicensedOrSubscribed(
|
||||
orgId,
|
||||
tierMatrix.wildcardSubdomain
|
||||
);
|
||||
if (!isLicensed) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"Wildcard subdomains are not supported on your current plan. Please upgrade to access this feature."
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (build == "saas" && !isSubscribed(orgId!, tierMatrix.domainNamespaces)) {
|
||||
// grandfather in existing users
|
||||
const lastAllowedDate = new Date("2026-04-13");
|
||||
@@ -232,7 +252,7 @@ async function createHttpResource(
|
||||
return next(createHttpError(HttpCode.BAD_REQUEST, domainResult.error));
|
||||
}
|
||||
|
||||
const { fullDomain, subdomain: finalSubdomain } = domainResult;
|
||||
const { fullDomain, subdomain: finalSubdomain, wildcard } = domainResult;
|
||||
|
||||
logger.debug(`Full domain: ${fullDomain}`);
|
||||
|
||||
@@ -251,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) {
|
||||
@@ -299,7 +326,8 @@ async function createHttpResource(
|
||||
protocol: "tcp",
|
||||
ssl: true,
|
||||
stickySession: stickySession,
|
||||
postAuthPath: postAuthPath
|
||||
postAuthPath: postAuthPath,
|
||||
wildcard
|
||||
})
|
||||
.returning();
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@ export type GetResourceAuthInfoResponse = {
|
||||
sso: boolean;
|
||||
blockAccess: boolean;
|
||||
url: string;
|
||||
wildcard: boolean;
|
||||
fullDomain: string | null;
|
||||
whitelist: boolean;
|
||||
skipToIdpId: number | null;
|
||||
orgId: string;
|
||||
@@ -130,7 +132,9 @@ export async function getResourceAuthInfo(
|
||||
const headerAuthExtendedCompatibility =
|
||||
result?.resourceHeaderAuthExtendedCompatibility;
|
||||
|
||||
const url = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`;
|
||||
const url = resource.fullDomain
|
||||
? `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`
|
||||
: null;
|
||||
|
||||
return response<GetResourceAuthInfoResponse>(res, {
|
||||
data: {
|
||||
@@ -145,7 +149,9 @@ export async function getResourceAuthInfo(
|
||||
headerAuthExtendedCompatibility !== null,
|
||||
sso: resource.sso,
|
||||
blockAccess: resource.blockAccess,
|
||||
url,
|
||||
url: url ?? "",
|
||||
wildcard: resource.wildcard ?? false,
|
||||
fullDomain: resource.fullDomain,
|
||||
whitelist: resource.emailWhitelistEnabled,
|
||||
skipToIdpId: resource.skipToIdpId,
|
||||
orgId: resource.orgId,
|
||||
|
||||
@@ -16,12 +16,15 @@ import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import config from "@server/lib/config";
|
||||
import { tlsNameSchema } from "@server/lib/schemas";
|
||||
import { subdomainSchema } from "@server/lib/schemas";
|
||||
import {
|
||||
tlsNameSchema,
|
||||
subdomainSchema,
|
||||
wildcardSubdomainSchema
|
||||
} from "@server/lib/schemas";
|
||||
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";
|
||||
@@ -43,7 +46,7 @@ const updateHttpResourceBodySchema = z
|
||||
"niceId can only contain letters, numbers, and dashes"
|
||||
)
|
||||
.optional(),
|
||||
subdomain: subdomainSchema.nullable().optional(),
|
||||
subdomain: z.string().nullable().optional(),
|
||||
ssl: z.boolean().optional(),
|
||||
sso: z.boolean().optional(),
|
||||
blockAccess: z.boolean().optional(),
|
||||
@@ -73,7 +76,10 @@ const updateHttpResourceBodySchema = z
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.subdomain) {
|
||||
return subdomainSchema.safeParse(data.subdomain).success;
|
||||
return (
|
||||
subdomainSchema.safeParse(data.subdomain).success ||
|
||||
wildcardSubdomainSchema.safeParse(data.subdomain).success
|
||||
);
|
||||
}
|
||||
return true;
|
||||
},
|
||||
@@ -318,6 +324,22 @@ async function updateHttpResource(
|
||||
}
|
||||
}
|
||||
|
||||
// Wildcard subdomains are a paid feature
|
||||
if (updateData.subdomain && updateData.subdomain.includes("*")) {
|
||||
const isLicensed = await isLicensedOrSubscribed(
|
||||
resource.orgId,
|
||||
tierMatrix.wildcardSubdomain
|
||||
);
|
||||
if (!isLicensed) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"Wildcard subdomains are not supported on your current plan. Please upgrade to access this feature."
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (updateData.domainId) {
|
||||
const domainId = updateData.domainId;
|
||||
|
||||
@@ -362,7 +384,11 @@ async function updateHttpResource(
|
||||
);
|
||||
}
|
||||
|
||||
const { fullDomain, subdomain: finalSubdomain } = domainResult;
|
||||
const {
|
||||
fullDomain,
|
||||
subdomain: finalSubdomain,
|
||||
wildcard
|
||||
} = domainResult;
|
||||
|
||||
logger.debug(`Full domain: ${fullDomain}`);
|
||||
|
||||
@@ -384,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) {
|
||||
@@ -419,7 +455,7 @@ async function updateHttpResource(
|
||||
if (fullDomain && fullDomain !== resource.fullDomain) {
|
||||
await db
|
||||
.update(resources)
|
||||
.set({ fullDomain })
|
||||
.set({ fullDomain, wildcard })
|
||||
.where(eq(resources.resourceId, resource.resourceId));
|
||||
}
|
||||
|
||||
|
||||
@@ -346,6 +346,14 @@ export default async function migration() {
|
||||
ALTER TABLE "siteResources" DROP COLUMN "protocol";
|
||||
`);
|
||||
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "resources" ADD "health" varchar;
|
||||
`);
|
||||
|
||||
await db.execute(sql`
|
||||
ALTER TABLE "resources" ADD "wildcard" boolean DEFAULT false NOT NULL;
|
||||
`);
|
||||
|
||||
await db.execute(sql`COMMIT`);
|
||||
console.log("Migrated database");
|
||||
} catch (e) {
|
||||
|
||||
@@ -330,6 +330,17 @@ export default async function migration() {
|
||||
ALTER TABLE 'sites' ADD 'networkId' integer REFERENCES networks(networkId);
|
||||
`
|
||||
).run();
|
||||
db.prepare(
|
||||
`
|
||||
ALTER TABLE 'resources' ADD 'health' text;
|
||||
`
|
||||
).run();
|
||||
db.prepare(
|
||||
`
|
||||
ALTER TABLE 'resources' ADD 'wildcard' integer DEFAULT false NOT NULL;
|
||||
`
|
||||
).run();
|
||||
|
||||
})();
|
||||
|
||||
db.pragma("foreign_keys = ON");
|
||||
|
||||
@@ -670,6 +670,7 @@ export default function GeneralForm() {
|
||||
<div className="space-y-4">
|
||||
<div id="resource-domain-picker">
|
||||
<DomainPicker
|
||||
allowWildcard={true}
|
||||
key={resource.resourceId}
|
||||
orgId={orgId as string}
|
||||
cols={2}
|
||||
|
||||
@@ -1132,6 +1132,7 @@ export default function Page() {
|
||||
<SettingsSectionBody>
|
||||
<SettingsSectionForm>
|
||||
<DomainPicker
|
||||
allowWildcard={true}
|
||||
orgId={orgId as string}
|
||||
warnOnProvidedDomain={
|
||||
remoteExitNodes.length >= 1
|
||||
|
||||
@@ -106,10 +106,22 @@ export default async function ResourceAuthPage(props: {
|
||||
const redirectPort = new URL(searchParams.redirect).port;
|
||||
const serverResourceHostWithPort = `${serverResourceHost}:${redirectPort}`;
|
||||
|
||||
const wildcardMatchesRedirect = (wildcardDomain: string, host: string): boolean => {
|
||||
if (!wildcardDomain.startsWith("*.")) return false;
|
||||
const suffix = wildcardDomain.slice(1); // e.g. ".wildcard.owen.fosrl.io"
|
||||
return host.endsWith(suffix) && host.length > suffix.length;
|
||||
};
|
||||
|
||||
if (serverResourceHost === redirectHost) {
|
||||
redirectUrl = searchParams.redirect;
|
||||
} else if (serverResourceHostWithPort === redirectHost) {
|
||||
redirectUrl = searchParams.redirect;
|
||||
} else if (
|
||||
authInfo.wildcard &&
|
||||
authInfo.fullDomain &&
|
||||
wildcardMatchesRedirect(authInfo.fullDomain, redirectHost)
|
||||
) {
|
||||
redirectUrl = searchParams.redirect;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
+118
-42
@@ -27,6 +27,7 @@ import { cn } from "@/lib/cn";
|
||||
import {
|
||||
finalizeSubdomainSanitize,
|
||||
isValidSubdomainStructure,
|
||||
isWildcardSubdomain,
|
||||
sanitizeInputRaw,
|
||||
validateByDomainType
|
||||
} from "@/lib/subdomain-utils";
|
||||
@@ -45,6 +46,7 @@ import {
|
||||
Zap
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import { usePaidStatus } from "@/hooks/usePaidStatus";
|
||||
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { toUnicode } from "punycode";
|
||||
@@ -77,6 +79,7 @@ interface DomainPickerProps {
|
||||
subdomain?: string;
|
||||
fullDomain: string;
|
||||
baseDomain: string;
|
||||
wildcard?: boolean;
|
||||
} | null
|
||||
) => void;
|
||||
cols?: number;
|
||||
@@ -85,6 +88,7 @@ interface DomainPickerProps {
|
||||
defaultSubdomain?: string | null;
|
||||
defaultDomainId?: string | null;
|
||||
warnOnProvidedDomain?: boolean;
|
||||
allowWildcard?: boolean;
|
||||
}
|
||||
|
||||
export default function DomainPicker({
|
||||
@@ -95,23 +99,30 @@ export default function DomainPicker({
|
||||
defaultSubdomain,
|
||||
defaultFullDomain,
|
||||
defaultDomainId,
|
||||
warnOnProvidedDomain = false
|
||||
warnOnProvidedDomain = false,
|
||||
allowWildcard = false
|
||||
}: DomainPickerProps) {
|
||||
const { env } = useEnvContext();
|
||||
const { user } = useUserContext();
|
||||
const api = createApiClient({ env });
|
||||
const t = useTranslations();
|
||||
const { hasSaasSubscription } = usePaidStatus();
|
||||
const { hasSaasSubscription, isPaidUser } = usePaidStatus();
|
||||
|
||||
const requiresPaywall =
|
||||
build === "saas" &&
|
||||
!hasSaasSubscription(tierMatrix[TierFeature.DomainNamespaces]) &&
|
||||
new Date(user.dateCreated) > new Date("2026-04-13");
|
||||
|
||||
const wildcardAllowed =
|
||||
allowWildcard && isPaidUser(tierMatrix[TierFeature.WildcardSubdomain]);
|
||||
|
||||
const { data = [], isLoading: loadingDomains } = useQuery(
|
||||
orgQueries.domains({ orgId })
|
||||
);
|
||||
|
||||
// Wildcard mode is derived from the input itself — if the user types a
|
||||
// wildcard subdomain (e.g. *.foo) and allowWildcard is enabled, it's active.
|
||||
|
||||
if (!env.flags.usePangolinDns) {
|
||||
hideFreeDomain = true;
|
||||
}
|
||||
@@ -180,13 +191,16 @@ export default function DomainPicker({
|
||||
firstOrExistingDomain.type !== "cname"
|
||||
? defaultSubdomain?.trim() || undefined
|
||||
: undefined;
|
||||
const isWc =
|
||||
allowWildcard && !!sub && isWildcardSubdomain(sub);
|
||||
|
||||
onDomainChange?.({
|
||||
domainId: firstOrExistingDomain.domainId,
|
||||
type: "organization",
|
||||
subdomain: sub,
|
||||
fullDomain: sub ? `${sub}.${base}` : base,
|
||||
baseDomain: base
|
||||
baseDomain: base,
|
||||
wildcard: isWc
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -285,7 +299,8 @@ export default function DomainPicker({
|
||||
}, [userInput, debouncedCheckAvailability, selectedBaseDomain]);
|
||||
|
||||
const finalizeSubdomain = (sub: string, base: DomainOption): string => {
|
||||
const sanitized = finalizeSubdomainSanitize(sub);
|
||||
const wildcardMode = wildcardAllowed && isWildcardSubdomain(sub);
|
||||
const sanitized = finalizeSubdomainSanitize(sub, wildcardMode);
|
||||
|
||||
if (!sanitized) {
|
||||
toast({
|
||||
@@ -301,7 +316,8 @@ export default function DomainPicker({
|
||||
base.type === "provided-search"
|
||||
? "provided-search"
|
||||
: "organization",
|
||||
domainType: base.domainType
|
||||
domainType: base.domainType,
|
||||
allowWildcard: wildcardMode
|
||||
});
|
||||
|
||||
if (!ok) {
|
||||
@@ -330,7 +346,7 @@ export default function DomainPicker({
|
||||
};
|
||||
|
||||
const handleSubdomainChange = (value: string) => {
|
||||
const raw = sanitizeInputRaw(value);
|
||||
const raw = sanitizeInputRaw(value, allowWildcard);
|
||||
setSubdomainInput(raw);
|
||||
setSelectedProvidedDomain(null);
|
||||
|
||||
@@ -338,13 +354,15 @@ export default function DomainPicker({
|
||||
const fullDomain = raw
|
||||
? `${raw}.${selectedBaseDomain.domain}`
|
||||
: selectedBaseDomain.domain;
|
||||
const isWc = wildcardAllowed && isWildcardSubdomain(raw);
|
||||
|
||||
onDomainChange?.({
|
||||
domainId: selectedBaseDomain.domainId!,
|
||||
type: "organization",
|
||||
subdomain: raw || undefined,
|
||||
fullDomain,
|
||||
baseDomain: selectedBaseDomain.domain
|
||||
baseDomain: selectedBaseDomain.domain,
|
||||
wildcard: isWc
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -366,6 +384,17 @@ export default function DomainPicker({
|
||||
const handleBaseDomainSelect = (option: DomainOption) => {
|
||||
let sub = subdomainInput;
|
||||
|
||||
// If the selected domain doesn't support wildcards, strip any wildcard prefix.
|
||||
const supportsWildcard =
|
||||
wildcardAllowed &&
|
||||
option.type === "organization" &&
|
||||
option.domainType !== "cname";
|
||||
|
||||
if (!supportsWildcard && isWildcardSubdomain(sub)) {
|
||||
sub = sub.replace(/^\*\./, "");
|
||||
setSubdomainInput(sub);
|
||||
}
|
||||
|
||||
if (sub && sub.trim() !== "") {
|
||||
sub = finalizeSubdomain(sub, option) || "";
|
||||
setSubdomainInput(sub);
|
||||
@@ -389,6 +418,7 @@ export default function DomainPicker({
|
||||
}
|
||||
|
||||
const fullDomain = sub ? `${sub}.${option.domain}` : option.domain;
|
||||
const isWc = wildcardAllowed && !!sub && isWildcardSubdomain(sub);
|
||||
|
||||
if (option.type === "provided-search") {
|
||||
onDomainChange?.(null); // prevent the modal from closing with `<subdomain>.Free Provided domain`
|
||||
@@ -402,7 +432,8 @@ export default function DomainPicker({
|
||||
? sub || undefined
|
||||
: undefined,
|
||||
fullDomain,
|
||||
baseDomain: option.domain
|
||||
baseDomain: option.domain,
|
||||
wildcard: isWc
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -431,7 +462,9 @@ export default function DomainPicker({
|
||||
selectedBaseDomain.type === "provided-search"
|
||||
? "provided-search"
|
||||
: "organization",
|
||||
domainType: selectedBaseDomain.domainType
|
||||
domainType: selectedBaseDomain.domainType,
|
||||
allowWildcard:
|
||||
wildcardAllowed && isWildcardSubdomain(subdomainInput)
|
||||
})
|
||||
: true;
|
||||
|
||||
@@ -439,6 +472,7 @@ export default function DomainPicker({
|
||||
selectedBaseDomain &&
|
||||
selectedBaseDomain.type === "organization" &&
|
||||
selectedBaseDomain.domainType !== "cname";
|
||||
|
||||
const showProvidedDomainSearch =
|
||||
selectedBaseDomain?.type === "provided-search";
|
||||
|
||||
@@ -463,9 +497,11 @@ export default function DomainPicker({
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="subdomain-input">
|
||||
{t("domainPickerSubdomainLabel")}
|
||||
</Label>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="subdomain-input">
|
||||
{t("domainPickerSubdomainLabel")}
|
||||
</Label>
|
||||
</div>
|
||||
<Input
|
||||
id="subdomain-input"
|
||||
value={
|
||||
@@ -477,7 +513,9 @@ export default function DomainPicker({
|
||||
showProvidedDomainSearch
|
||||
? ""
|
||||
: showSubdomainInput
|
||||
? ""
|
||||
? wildcardAllowed
|
||||
? "*.level1 or level1"
|
||||
: ""
|
||||
: t("domainPickerNotAvailableForCname")
|
||||
}
|
||||
disabled={
|
||||
@@ -498,11 +536,34 @@ export default function DomainPicker({
|
||||
/>
|
||||
{showSubdomainInput &&
|
||||
subdomainInput &&
|
||||
!isValidSubdomainStructure(subdomainInput) && (
|
||||
!isValidSubdomainStructure(
|
||||
subdomainInput,
|
||||
wildcardAllowed &&
|
||||
isWildcardSubdomain(subdomainInput)
|
||||
) && (
|
||||
<p className="text-sm text-red-500">
|
||||
{t("domainPickerInvalidSubdomainStructure")}
|
||||
</p>
|
||||
)}
|
||||
{allowWildcard &&
|
||||
!wildcardAllowed &&
|
||||
showSubdomainInput &&
|
||||
isWildcardSubdomain(subdomainInput) && (
|
||||
<>
|
||||
<p className="text-sm text-red-500">
|
||||
{t(
|
||||
"domainPickerWildcardSubdomainNotAllowed"
|
||||
)}
|
||||
</p>
|
||||
<PaidFeaturesAlert
|
||||
tiers={
|
||||
tierMatrix[
|
||||
TierFeature.WildcardSubdomain
|
||||
]
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -592,23 +653,23 @@ export default function DomainPicker({
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{orgDomain.type ===
|
||||
"wildcard"
|
||||
? t(
|
||||
"domainPickerManual"
|
||||
)
|
||||
: (
|
||||
<>
|
||||
{orgDomain.type.toUpperCase()}{" "}
|
||||
•{" "}
|
||||
{orgDomain.verified
|
||||
? t(
|
||||
"domainPickerVerified"
|
||||
)
|
||||
: t(
|
||||
"domainPickerUnverified"
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
"wildcard" ? (
|
||||
t(
|
||||
"domainPickerManual"
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{orgDomain.type.toUpperCase()}{" "}
|
||||
•{" "}
|
||||
{orgDomain.verified
|
||||
? t(
|
||||
"domainPickerVerified"
|
||||
)
|
||||
: t(
|
||||
"domainPickerUnverified"
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<Check
|
||||
@@ -708,17 +769,17 @@ export default function DomainPicker({
|
||||
</div>
|
||||
|
||||
{requiresPaywall && !hideFreeDomain && (
|
||||
<Card className="mt-3 border-black-500/30 bg-linear-to-br from-black-500/10 via-background to-background overflow-hidden">
|
||||
<CardContent className="py-3 px-4">
|
||||
<div className="flex items-center gap-2.5 text-sm text-muted-foreground">
|
||||
<KeyRound className="size-4 shrink-0 text-black-500" />
|
||||
<span>
|
||||
{t("domainPickerFreeDomainsPaidFeature")}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<Card className="mt-3 border-black-500/30 bg-linear-to-br from-black-500/10 via-background to-background overflow-hidden">
|
||||
<CardContent className="py-3 px-4">
|
||||
<div className="flex items-center gap-2.5 text-sm text-muted-foreground">
|
||||
<KeyRound className="size-4 shrink-0 text-black-500" />
|
||||
<span>
|
||||
{t("domainPickerFreeDomainsPaidFeature")}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/*showProvidedDomainSearch && build === "saas" && (
|
||||
<Alert>
|
||||
@@ -845,6 +906,21 @@ export default function DomainPicker({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{selectedBaseDomain?.domainType === "wildcard" &&
|
||||
isWildcardSubdomain(subdomainInput) && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("domainPickerWildcardCertWarning")}{" "}
|
||||
<a
|
||||
href="https://docs.pangolin.net/self-host/advanced/wild-card-domains#setting-up-wildcard-certificates"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline text-primary"
|
||||
>
|
||||
{t("domainPickerWildcardCertWarningLink")}
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from "@app/components/ui/tooltip";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const TRIAL_DURATION_DAYS = 14;
|
||||
const TRIAL_DURATION_DAYS = 10;
|
||||
|
||||
export default function ShowTrialCard({
|
||||
isCollapsed
|
||||
|
||||
@@ -8,9 +8,7 @@ export function useSubscriptionStatusContext() {
|
||||
}
|
||||
const context = useContext(SubscriptionStatusContext);
|
||||
if (context === undefined) {
|
||||
throw new Error(
|
||||
"useSubscriptionStatusContext must be used within an SubscriptionStatusProvider"
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
@@ -5,16 +5,58 @@ export const MULTI_LABEL_RE = /^[\p{L}\p{N}-]+(\.[\p{L}\p{N}-]+)*$/u; // ns/wild
|
||||
export const SINGLE_LABEL_STRICT_RE =
|
||||
/^[\p{L}\p{N}](?:[\p{L}\p{N}-]*[\p{L}\p{N}])?$/u; // start/end alnum
|
||||
|
||||
export function sanitizeInputRaw(input: string): string {
|
||||
/**
|
||||
* A wildcard subdomain is either bare "*" or "*.label1.label2…" where every
|
||||
* label after the dot is a valid hostname label. This mirrors the shape that
|
||||
* the server's `wildcardSubdomainSchema` accepts.
|
||||
*/
|
||||
export const WILDCARD_SUBDOMAIN_RE =
|
||||
/^\*(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/;
|
||||
|
||||
export function isWildcardSubdomain(input: string): boolean {
|
||||
return WILDCARD_SUBDOMAIN_RE.test(input);
|
||||
}
|
||||
|
||||
export function sanitizeInputRaw(input: string, allowWildcard = false): string {
|
||||
if (!input) return "";
|
||||
// When wildcard mode is active, preserve a leading "* " / "*." prefix and
|
||||
// only sanitize the remainder so the user can type "*.level1" naturally.
|
||||
if (allowWildcard && input.startsWith("*")) {
|
||||
const rest = input.slice(1);
|
||||
const sanitizedRest = rest
|
||||
.toLowerCase()
|
||||
.normalize("NFC")
|
||||
.replace(/[^\p{L}\p{N}.-]/gu, "");
|
||||
return "*" + sanitizedRest;
|
||||
}
|
||||
return input
|
||||
.toLowerCase()
|
||||
.normalize("NFC") // normalize Unicode
|
||||
.replace(/[^\p{L}\p{N}.-]/gu, ""); // allow Unicode letters, numbers, dot, hyphen
|
||||
}
|
||||
|
||||
export function finalizeSubdomainSanitize(input: string): string {
|
||||
export function finalizeSubdomainSanitize(
|
||||
input: string,
|
||||
allowWildcard = false
|
||||
): string {
|
||||
if (!input) return "";
|
||||
|
||||
// If the input is a valid wildcard and the caller permits it, keep it as-is
|
||||
// (just lowercase the non-wildcard labels).
|
||||
if (allowWildcard && input.startsWith("*")) {
|
||||
const rest = input.slice(1); // everything after the leading "*"
|
||||
const sanitizedRest = rest
|
||||
.toLowerCase()
|
||||
.normalize("NFC")
|
||||
.replace(/[^\p{L}\p{N}.-]/gu, "")
|
||||
.replace(/\.{2,}/g, ".")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.replace(/(\.-)|(-\.)/g, ".");
|
||||
const candidate = "*" + sanitizedRest;
|
||||
// Return only if it still forms a valid wildcard after sanitizing
|
||||
return isWildcardSubdomain(candidate) ? candidate : "";
|
||||
}
|
||||
|
||||
return input
|
||||
.toLowerCase()
|
||||
.normalize("NFC")
|
||||
@@ -30,6 +72,7 @@ export function validateByDomainType(
|
||||
domainType: {
|
||||
type: "provided-search" | "organization";
|
||||
domainType?: "ns" | "cname" | "wildcard";
|
||||
allowWildcard?: boolean;
|
||||
}
|
||||
): boolean {
|
||||
if (!domainType) return false;
|
||||
@@ -46,6 +89,12 @@ export function validateByDomainType(
|
||||
domainType.domainType === "wildcard"
|
||||
) {
|
||||
if (subdomain === "") return true;
|
||||
|
||||
// Wildcard subdomain validation (only when caller opts in)
|
||||
if (domainType.allowWildcard && subdomain.startsWith("*")) {
|
||||
return isWildcardSubdomain(subdomain);
|
||||
}
|
||||
|
||||
if (!MULTI_LABEL_RE.test(subdomain)) return false;
|
||||
const labels = subdomain.split(".");
|
||||
return labels.every(
|
||||
@@ -57,10 +106,19 @@ export function validateByDomainType(
|
||||
return false;
|
||||
}
|
||||
|
||||
export const isValidSubdomainStructure = (input: string): boolean => {
|
||||
export const isValidSubdomainStructure = (
|
||||
input: string,
|
||||
allowWildcard = false
|
||||
): boolean => {
|
||||
if (!input) return false;
|
||||
|
||||
// A valid wildcard subdomain is structurally valid when the caller allows it
|
||||
if (allowWildcard && input.startsWith("*")) {
|
||||
return isWildcardSubdomain(input);
|
||||
}
|
||||
|
||||
const regex = /^(?!-)([\p{L}\p{N}-]{1,63})(?<!-)$/u;
|
||||
|
||||
if (!input) return false;
|
||||
if (input.includes("..")) return false;
|
||||
|
||||
return input.split(".").every((label) => regex.test(label));
|
||||
|
||||
Reference in New Issue
Block a user