mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-26 10:43:09 +00:00
Pass one at getting it into the db
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { db } from "@server/db";
|
||||
import { domains, orgDomains } from "@server/db";
|
||||
import { domains, orgDomains, domainNamespaces } from "@server/db";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { subdomainSchema } from "@server/lib/schemas";
|
||||
import { subdomainSchema, wildcardSubdomainSchema } from "@server/lib/schemas";
|
||||
import { fromError } from "zod-validation-error";
|
||||
|
||||
export type DomainValidationResult =
|
||||
@@ -9,6 +9,7 @@ export type DomainValidationResult =
|
||||
success: true;
|
||||
fullDomain: string;
|
||||
subdomain: string | null;
|
||||
wildcard: boolean;
|
||||
}
|
||||
| {
|
||||
success: false;
|
||||
@@ -66,6 +67,47 @@ export async function validateAndConstructDomain(
|
||||
};
|
||||
}
|
||||
|
||||
// Detect wildcard subdomain request
|
||||
const isWildcard =
|
||||
subdomain !== undefined &&
|
||||
subdomain !== null &&
|
||||
subdomain.includes("*");
|
||||
|
||||
// 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."
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 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 +123,15 @@ 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 +144,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: isWildcard ? "*" : (finalSubdomain ?? null),
|
||||
wildcard: isWildcard
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user