mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-30 20:52:40 +00:00
Adding guiderails
This commit is contained in:
@@ -3176,5 +3176,7 @@
|
|||||||
"webhookHeaderKeyPlaceholder": "Key",
|
"webhookHeaderKeyPlaceholder": "Key",
|
||||||
"webhookHeaderValuePlaceholder": "Value",
|
"webhookHeaderValuePlaceholder": "Value",
|
||||||
"alertLabel": "Alert",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { domains, orgDomains, domainNamespaces } from "@server/db";
|
import { domains, orgDomains, domainNamespaces, resources } from "@server/db";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and, like, not } from "drizzle-orm";
|
||||||
import { subdomainSchema, wildcardSubdomainSchema } from "@server/lib/schemas";
|
import { subdomainSchema, wildcardSubdomainSchema } from "@server/lib/schemas";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
|
import config from "./config";
|
||||||
|
|
||||||
export type DomainValidationResult =
|
export type DomainValidationResult =
|
||||||
| {
|
| {
|
||||||
@@ -71,7 +72,8 @@ export async function validateAndConstructDomain(
|
|||||||
const isWildcard =
|
const isWildcard =
|
||||||
subdomain !== undefined &&
|
subdomain !== undefined &&
|
||||||
subdomain !== null &&
|
subdomain !== null &&
|
||||||
subdomain.includes("*");
|
subdomain.includes("*") &&
|
||||||
|
domainRes.domains.type !== "cname";
|
||||||
|
|
||||||
// Wildcard subdomains are not allowed on CNAME domains
|
// Wildcard subdomains are not allowed on CNAME domains
|
||||||
if (isWildcard && domainRes.domains.type === "cname") {
|
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
|
// Validate wildcard subdomain format
|
||||||
if (isWildcard) {
|
if (isWildcard) {
|
||||||
const parsedWildcard = wildcardSubdomainSchema.safeParse(subdomain);
|
const parsedWildcard = wildcardSubdomainSchema.safeParse(subdomain);
|
||||||
@@ -125,7 +141,8 @@ export async function validateAndConstructDomain(
|
|||||||
if (subdomain !== undefined && subdomain !== null) {
|
if (subdomain !== undefined && subdomain !== null) {
|
||||||
if (!isWildcard) {
|
if (!isWildcard) {
|
||||||
// Validate regular subdomain format for wildcard domains
|
// Validate regular subdomain format for wildcard domains
|
||||||
const parsedSubdomain = subdomainSchema.safeParse(subdomain);
|
const parsedSubdomain =
|
||||||
|
subdomainSchema.safeParse(subdomain);
|
||||||
if (!parsedSubdomain.success) {
|
if (!parsedSubdomain.success) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,7 +33,15 @@ import {
|
|||||||
} from "drizzle-orm";
|
} from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import config from "@server/lib/config";
|
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 {
|
import {
|
||||||
sanitize,
|
sanitize,
|
||||||
encodePath,
|
encodePath,
|
||||||
@@ -277,7 +285,10 @@ export async function getTraefikConfig(
|
|||||||
mode: siteResources.mode
|
mode: siteResources.mode
|
||||||
})
|
})
|
||||||
.from(siteResources)
|
.from(siteResources)
|
||||||
.innerJoin(siteNetworks, eq(siteResources.networkId, siteNetworks.networkId))
|
.innerJoin(
|
||||||
|
siteNetworks,
|
||||||
|
eq(siteResources.networkId, siteNetworks.networkId)
|
||||||
|
)
|
||||||
.innerJoin(sites, eq(siteNetworks.siteId, sites.siteId))
|
.innerJoin(sites, eq(siteNetworks.siteId, sites.siteId))
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
@@ -430,7 +441,8 @@ export async function getTraefikConfig(
|
|||||||
config.getRawConfig().traefik.prefer_wildcard_cert;
|
config.getRawConfig().traefik.prefer_wildcard_cert;
|
||||||
|
|
||||||
const domainCertResolver = resource.domainCertResolver;
|
const domainCertResolver = resource.domainCertResolver;
|
||||||
const preferWildcardCert = resource.preferWildcardCert;
|
const preferWildcardCert =
|
||||||
|
resource.preferWildcardCert || resource.wildcard;
|
||||||
|
|
||||||
let resolverName: string | undefined;
|
let resolverName: string | undefined;
|
||||||
let preferWildcard: boolean | undefined;
|
let preferWildcard: boolean | undefined;
|
||||||
@@ -964,22 +976,17 @@ export async function getTraefikConfig(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Middleware that rewrites any path to /maintenance-screen
|
// Middleware that rewrites any path to /maintenance-screen
|
||||||
config_output.http.middlewares[
|
config_output.http.middlewares[siteResourceRewriteMiddlewareName] =
|
||||||
siteResourceRewriteMiddlewareName
|
{
|
||||||
] = {
|
replacePathRegex: {
|
||||||
replacePathRegex: {
|
regex: "^/(.*)",
|
||||||
regex: "^/(.*)",
|
replacement: "/private-maintenance-screen"
|
||||||
replacement: "/private-maintenance-screen"
|
}
|
||||||
}
|
};
|
||||||
};
|
|
||||||
|
|
||||||
// HTTP -> HTTPS redirect so the ACME challenge can be served
|
// HTTP -> HTTPS redirect so the ACME challenge can be served
|
||||||
config_output.http.routers[
|
config_output.http.routers[`${siteResourceRouterName}-redirect`] = {
|
||||||
`${siteResourceRouterName}-redirect`
|
entryPoints: [config.getRawConfig().traefik.http_entrypoint],
|
||||||
] = {
|
|
||||||
entryPoints: [
|
|
||||||
config.getRawConfig().traefik.http_entrypoint
|
|
||||||
],
|
|
||||||
middlewares: [redirectHttpsMiddlewareName],
|
middlewares: [redirectHttpsMiddlewareName],
|
||||||
service: siteResourceServiceName,
|
service: siteResourceServiceName,
|
||||||
rule: `Host(\`${fullDomain}\`)`,
|
rule: `Host(\`${fullDomain}\`)`,
|
||||||
@@ -988,9 +995,7 @@ export async function getTraefikConfig(
|
|||||||
|
|
||||||
// Determine TLS / cert-resolver configuration
|
// Determine TLS / cert-resolver configuration
|
||||||
let tls: any = {};
|
let tls: any = {};
|
||||||
if (
|
if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
|
||||||
!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns
|
|
||||||
) {
|
|
||||||
const domainParts = fullDomain.split(".");
|
const domainParts = fullDomain.split(".");
|
||||||
const wildCard =
|
const wildCard =
|
||||||
domainParts.length <= 2
|
domainParts.length <= 2
|
||||||
@@ -1023,9 +1028,7 @@ export async function getTraefikConfig(
|
|||||||
|
|
||||||
// HTTPS router - presence of this entry triggers cert generation
|
// HTTPS router - presence of this entry triggers cert generation
|
||||||
config_output.http.routers[siteResourceRouterName] = {
|
config_output.http.routers[siteResourceRouterName] = {
|
||||||
entryPoints: [
|
entryPoints: [config.getRawConfig().traefik.https_entrypoint],
|
||||||
config.getRawConfig().traefik.https_entrypoint
|
|
||||||
],
|
|
||||||
service: siteResourceServiceName,
|
service: siteResourceServiceName,
|
||||||
middlewares: [siteResourceRewriteMiddlewareName],
|
middlewares: [siteResourceRewriteMiddlewareName],
|
||||||
rule: `Host(\`${fullDomain}\`)`,
|
rule: `Host(\`${fullDomain}\`)`,
|
||||||
@@ -1035,9 +1038,7 @@ export async function getTraefikConfig(
|
|||||||
|
|
||||||
// Assets bypass router - lets Next.js static files load without rewrite
|
// Assets bypass router - lets Next.js static files load without rewrite
|
||||||
config_output.http.routers[`${siteResourceRouterName}-assets`] = {
|
config_output.http.routers[`${siteResourceRouterName}-assets`] = {
|
||||||
entryPoints: [
|
entryPoints: [config.getRawConfig().traefik.https_entrypoint],
|
||||||
config.getRawConfig().traefik.https_entrypoint
|
|
||||||
],
|
|
||||||
service: siteResourceServiceName,
|
service: siteResourceServiceName,
|
||||||
rule: `Host(\`${fullDomain}\`) && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`))`,
|
rule: `Host(\`${fullDomain}\`) && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`))`,
|
||||||
priority: 101,
|
priority: 101,
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { OpenAPITags, registry } from "@server/openApi";
|
|||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
|
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
|
||||||
import { getUniqueResourceName } from "@server/db/names";
|
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 { isSubscribed } from "#dynamic/lib/isSubscribed";
|
||||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
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
|
// Prevent creating resource with same domain as dashboard
|
||||||
const dashboardUrl = config.getRawConfig().app.dashboard_url;
|
const dashboardUrl = config.getRawConfig().app.dashboard_url;
|
||||||
if (dashboardUrl) {
|
if (dashboardUrl) {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
import { registry } from "@server/openApi";
|
import { registry } from "@server/openApi";
|
||||||
import { OpenAPITags } from "@server/openApi";
|
import { OpenAPITags } from "@server/openApi";
|
||||||
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
|
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 { build } from "@server/build";
|
||||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
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
|
// Prevent updating resource with same domain as dashboard
|
||||||
const dashboardUrl = config.getRawConfig().app.dashboard_url;
|
const dashboardUrl = config.getRawConfig().app.dashboard_url;
|
||||||
if (dashboardUrl) {
|
if (dashboardUrl) {
|
||||||
|
|||||||
@@ -906,6 +906,21 @@ export default function DomainPicker({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user