Merge pull request #4021 from fosrl/dev

1.25.3-s.1
This commit is contained in:
Owen Schwartz
2026-10-28 14:18:01 -04:00
committed by GitHub
68 changed files with 3518 additions and 138 deletions
+4
View File
@@ -134,6 +134,10 @@ export enum ActionsEnum {
createOrgDomain = "createOrgDomain",
deleteOrgDomain = "deleteOrgDomain",
restartOrgDomain = "restartOrgDomain",
listCustomDnsRecords = "listCustomDnsRecords",
createCustomDnsRecord = "createCustomDnsRecord",
updateCustomDnsRecord = "updateCustomDnsRecord",
deleteCustomDnsRecord = "deleteCustomDnsRecord",
createRemoteExitNode = "createRemoteExitNode",
updateRemoteExitNode = "updateRemoteExitNode",
getRemoteExitNode = "getRemoteExitNode",
+1
View File
@@ -168,6 +168,7 @@
"iPhone18,5": "iPhone 17e",
"iPhone19,2": "iPhone 18 Pro",
"iPhone19,3": "iPhone 18 Pro Max",
"iPhone19,4": "iPhone Duo",
"iPhone19,7": "iPhone 18 Pro Max",
"iPod1,1": "iPod touch Original",
"iPod2,1": "iPod touch 2nd",
+31
View File
@@ -41,6 +41,36 @@ export const dnsChallenge = pgTable("dnsChallenges", {
completed: boolean("completed").default(false)
});
// User-defined records served by the authoritative DNS server for a domain
// delegated to Pangolin (type "ns"). fullDomain is the lowercase FQDN without
// a trailing dot, precomputed so the DNS server can look records up directly.
export const customDnsRecords = pgTable(
"customDnsRecords",
{
customDnsRecordId: serial("customDnsRecordId").primaryKey(),
domainId: varchar("domainId")
.notNull()
.references(() => domains.domainId, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(), // relative to the domain, "@" for the apex
fullDomain: varchar("fullDomain", { length: 255 }).notNull(),
type: varchar("type", { length: 10 })
.$type<"A" | "AAAA" | "CNAME" | "MX" | "NS" | "SRV" | "TXT">()
.notNull(),
values: text("values").notNull(), // JSON array of strings, one resource record each
ttl: integer("ttl").notNull().default(300),
priority: integer("priority"), // MX and SRV
weight: integer("weight"), // SRV
port: integer("port"), // SRV
enabled: boolean("enabled").notNull().default(true),
createdAt: bigint("createdAt", { mode: "number" }).notNull(),
updatedAt: bigint("updatedAt", { mode: "number" }).notNull()
},
(t) => [
index("idx_customDnsRecords_fullDomain").on(t.fullDomain),
index("idx_customDnsRecords_domainId").on(t.domainId)
]
);
export const account = pgTable("account", {
accountId: serial("accountId").primaryKey(),
userId: varchar("userId")
@@ -701,6 +731,7 @@ export type Approval = InferSelectModel<typeof approvals>;
export type Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>;
export type DnsChallenge = InferSelectModel<typeof dnsChallenge>;
export type CustomDnsRecord = InferSelectModel<typeof customDnsRecords>;
export type Customer = InferSelectModel<typeof customers>;
export type Subscription = InferSelectModel<typeof subscriptions>;
export type SubscriptionItem = InferSelectModel<typeof subscriptionItems>;
+35
View File
@@ -37,6 +37,40 @@ export const dnsChallenge = sqliteTable("dnsChallenges", {
completed: integer("completed", { mode: "boolean" }).default(false)
});
// User-defined records served by the authoritative DNS server for a domain
// delegated to Pangolin (type "ns"). fullDomain is the lowercase FQDN without
// a trailing dot, precomputed so the DNS server can look records up directly.
export const customDnsRecords = sqliteTable(
"customDnsRecords",
{
customDnsRecordId: integer("customDnsRecordId").primaryKey({
autoIncrement: true
}),
domainId: text("domainId")
.notNull()
.references(() => domains.domainId, { onDelete: "cascade" }),
name: text("name").notNull(), // relative to the domain, "@" for the apex
fullDomain: text("fullDomain").notNull(),
type: text("type")
.$type<"A" | "AAAA" | "CNAME" | "MX" | "NS" | "SRV" | "TXT">()
.notNull(),
values: text("values").notNull(), // JSON array of strings, one resource record each
ttl: integer("ttl").notNull().default(300),
priority: integer("priority"), // MX and SRV
weight: integer("weight"), // SRV
port: integer("port"), // SRV
enabled: integer("enabled", { mode: "boolean" })
.notNull()
.default(true),
createdAt: integer("createdAt").notNull(),
updatedAt: integer("updatedAt").notNull()
},
(t) => [
index("idx_customDnsRecords_fullDomain").on(t.fullDomain),
index("idx_customDnsRecords_domainId").on(t.domainId)
]
);
export const account = sqliteTable("account", {
accountId: integer("accountId").primaryKey({ autoIncrement: true }),
userId: text("userId")
@@ -700,6 +734,7 @@ export type Approval = InferSelectModel<typeof approvals>;
export type Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>;
export type DnsChallenge = InferSelectModel<typeof dnsChallenge>;
export type CustomDnsRecord = InferSelectModel<typeof customDnsRecords>;
export type Customer = InferSelectModel<typeof customers>;
export type Subscription = InferSelectModel<typeof subscriptions>;
export type SubscriptionItem = InferSelectModel<typeof subscriptionItems>;
+4 -2
View File
@@ -25,7 +25,8 @@ export enum TierFeature {
WildcardSubdomain = "wildcardSubdomain",
NewtAutoUpdate = "newtAutoUpdate",
ResourcePolicies = "resourcePolicies",
RoleBasedSSHControls = "roleBasedSSHControls"
RoleBasedSSHControls = "roleBasedSSHControls",
CustomDnsRecords = "customDnsRecords" // handle downgrade by disabling custom DNS records
}
export const tierMatrix: Record<TierFeature, Tier[]> = {
@@ -68,5 +69,6 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
[TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.NewtAutoUpdate]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.ResourcePolicies]: ["tier3", "enterprise"],
[TierFeature.RoleBasedSSHControls]: ["tier3", "enterprise"]
[TierFeature.RoleBasedSSHControls]: ["tier3", "enterprise"],
[TierFeature.CustomDnsRecords]: ["tier1", "tier2", "tier3", "enterprise"]
};
+22 -4
View File
@@ -6,7 +6,7 @@ import { MtlsSchema } from "#dynamic/lib/blueprints/MtlsSchema";
import { resourceModeSupportsMtls } from "@server/lib/mtlsModes";
import { isValidRegionId } from "@server/db/regions";
import { isValidHttpMethodList } from "@server/lib/validators";
import { wildcardSubdomainSchema } from "@server/lib/schemas";
import { routePathSchema, wildcardSubdomainSchema } from "@server/lib/schemas";
import config from "@server/lib/config";
import {
aiBudgetEnforcementSchema,
@@ -91,7 +91,7 @@ export const TargetSchema = z.object({
port: z.int().min(1).max(65535),
enabled: z.boolean().optional().default(true),
"internal-port": z.int().min(1).max(65535).optional(),
path: z.string().optional(),
path: routePathSchema.optional(),
"path-match": z.enum(["exact", "prefix", "regex"]).optional().nullable(),
healthcheck: TargetHealthCheckSchema.optional(),
rewritePath: z.string().optional(), // deprecated alias
@@ -328,6 +328,24 @@ export const AuthDaemonSchema = z
}
);
// A full-domain must be made of hostname labels ("*" is allowed as a label;
// where it may appear is checked by the resource schemas). Anything else
// would end up in a Traefik Host() rule and certificate requests verbatim.
const fullDomainSchema = z
.string()
.refine(
(val) =>
val === "" ||
val
.split(".")
.every(
(label) =>
label === "*" ||
/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$/.test(label)
),
{ message: "full-domain must be a valid hostname" }
);
// Schema for individual resource
export const PublicResourceSchema = z
.object({
@@ -341,7 +359,7 @@ export const PublicResourceSchema = z
policy: z.string().optional(),
ssl: z.boolean().optional(),
scheme: z.enum(["http", "https"]).optional(),
"full-domain": z.string().optional(),
"full-domain": fullDomainSchema.optional(),
"proxy-port": z.int().min(1).max(65535).optional(),
enabled: z.boolean().optional(),
targets: z.array(TargetSchema.nullable()).optional().default([]),
@@ -713,7 +731,7 @@ export const PrivateResourceSchema = z
"tcp-ports": portRangeStringSchema.optional().default("*"),
"udp-ports": portRangeStringSchema.optional().default("*"),
"disable-icmp": z.boolean().optional().default(false),
"full-domain": z.string().optional(),
"full-domain": fullDomainSchema.optional(),
ssl: z.boolean().optional(),
scheme: z.enum(["http", "https"]).optional().nullable(),
alias: z
+10
View File
@@ -124,6 +124,16 @@ export async function validateAndConstructDomain(
if (domainRes.domains.type === "ns") {
if (subdomain) {
if (!isWildcard) {
const parsedSubdomain =
subdomainSchema.safeParse(subdomain);
if (!parsedSubdomain.success) {
return {
success: false,
error: fromError(parsedSubdomain.error).toString()
};
}
}
fullDomain = `${subdomain}.${domainRes.domains.baseDomain}`;
} else {
fullDomain = domainRes.domains.baseDomain;
+1
View File
@@ -1,3 +1,4 @@
import "../extendZod";
import { cidrToRange, findNextAvailableCidr } from "./ip";
import { assertEquals } from "@test/assert";
+11
View File
@@ -62,3 +62,14 @@ export const privateNamespaceSubdomainSchema = z
.min(1, "Namespace subdomain must be at least 1 character long")
.max(32, "Namespace subdomain must be at most 32 characters long")
.transform((val) => val.toLowerCase());
/**
* A path matched by a Traefik router (target path, redirect match path).
* Backticks and control characters are never valid in a URL path and are
* rejected so the value can't break out of its literal in the router rule.
*/
export const routePathSchema = z
.string()
.refine((val) => !/[`\u0000-\u001f\u007f]/.test(val), {
message: "Path cannot contain backticks or control characters"
});
+2 -1
View File
@@ -1,5 +1,6 @@
import config from "@server/lib/config";
import { applyMtlsConfig } from "./mtls";
import { buildHostRule } from "./rule";
import { sanitize } from "./utils";
export type BrowserGatewayResourceRow = {
@@ -174,7 +175,7 @@ export function buildBrowserGatewayConfig(params: {
...additionalMiddlewares
];
const hostRule = `Host(\`${fullDomain}\`)`;
const hostRule = buildHostRule(fullDomain);
// Build TLS config
let tls = resolveTls({
+7 -2
View File
@@ -50,7 +50,12 @@ import {
import { applyPathRewriteMiddleware } from "./middleware";
import { applyMtlsConfig, getMtlsCaCertificatesByResource } from "./mtls";
import { buildRedirectConfig, RedirectRouteRow } from "./redirect";
import { appendPathMatch, buildHostRule, computeRoutePriority } from "./rule";
import {
appendPathMatch,
buildHostRule,
computeRoutePriority,
ruleValue
} from "./rule";
import { buildSiteResourceAliasCertPlaceholders } from "./siteResourceAlias";
import { TargetWithSite } from "./types";
import { encodePath, sanitize, validatePathRewriteConfig } from "./utils";
@@ -857,7 +862,7 @@ export async function getTraefikConfig(
const srKey = `inference-sr${sr.siteResourceId}`;
const routerName = `${srKey}-router`;
const serviceName = `${srKey}-service`;
const rule = `Host(\`${fullDomain}\`) && ClientIP(\`${exitNode.address}\`)`; // restrict to coming from the exit node ip range that the client is connected to
const rule = `${buildHostRule(fullDomain)} && ClientIP(${ruleValue(exitNode.address)})`; // restrict to coming from the exit node ip range that the client is connected to
// siteResource aliases don't have a per-domain cert resolver
// stored, so always fall back to the global defaults.
+56 -1
View File
@@ -1,8 +1,11 @@
import { assertEquals } from "../../../test/assert";
import {
appendPathMatch,
buildHostRule,
computeRedirectPriority,
computeRoutePriority,
matchesPath
matchesPath,
ruleValue
} from "./rule";
function runTests() {
@@ -128,6 +131,58 @@ function runTests() {
);
console.log("All priority tests passed!");
console.log("Running rule quoting tests...");
// Ordinary values keep the backtick form
assertEquals(ruleValue("/api"), "`/api`", "plain value");
assertEquals(
buildHostRule("app.example.com"),
"Host(`app.example.com`)",
"plain host"
);
assertEquals(
buildHostRule("*.example.com", true),
"HostRegexp(`^[^.]+\\.example\\.com$`)",
"wildcard host"
);
assertEquals(
appendPathMatch("Host(`a.com`)", "/x", "exact"),
"Host(`a.com`) && Path(`/x`)",
"plain exact path"
);
assertEquals(
appendPathMatch("Host(`a.com`)", "^/v[0-9]+/", "regex"),
"Host(`a.com`) && PathRegexp(`^/v[0-9]+/`)",
"plain regex path"
);
// A backtick must not be able to close the literal and inject matchers
const injectedPath = "/a`) || Host(`victim.example.com`) && PathPrefix(`/";
assertEquals(
appendPathMatch("Host(`attacker.example.com`)", injectedPath, "exact"),
'Host(`attacker.example.com`) && Path("/a`) || Host(`victim.example.com`) && PathPrefix(`/")',
"injected exact path stays one literal"
);
assertEquals(
appendPathMatch("Host(`a.com`)", "`) || HostRegexp(`.+", "regex"),
'Host(`a.com`) && PathRegexp("`) || HostRegexp(`.+")',
"injected regex path stays one literal"
);
assertEquals(
buildHostRule("x`) || Host(`victim.example.com"),
'Host("x`) || Host(`victim.example.com")',
"injected host stays one literal"
);
// Double-quoted form escapes quotes, backslashes and control characters
assertEquals(
ruleValue('`"\\\n'),
'"`\\"\\\\\\n"',
"escapes in quoted form"
);
console.log("All rule quoting tests passed!");
}
try {
+29 -5
View File
@@ -1,3 +1,27 @@
// Characters that cannot appear inside a Go raw (backtick) string literal:
// the backtick itself would terminate it, and control characters are
// rejected by the scanner Traefik uses to parse rules.
const UNSAFE_RAW_RULE_VALUE = /[`\u0000-\u001f\u007f]/;
/**
* Quote a value for use as a matcher argument in a Traefik rule.
*
* Every value interpolated into a rule must go through this. Rules are
* parsed as Go expressions, so a value containing a backtick could otherwise
* close its literal and inject arbitrary matchers (e.g. a Host() clause for
* another org's domain). Ordinary values keep the conventional backtick form;
* anything else is emitted as a double-quoted Go string literal, which
* Traefik unquotes back to exactly the original value.
*/
export function ruleValue(value: string): string {
if (!UNSAFE_RAW_RULE_VALUE.test(value)) {
return `\`${value}\``;
}
// JSON string escapes are a subset of Go's interpreted string escapes.
// Lone surrogates are replaced since Go rejects them in \u escapes.
return JSON.stringify(value.toWellFormed());
}
// Build the Host()/HostRegexp() Traefik rule for a resource's domain.
// Wildcard resources match any single subdomain via HostRegexp.
export function buildHostRule(
@@ -7,9 +31,9 @@ export function buildHostRule(
if (wildcard && fullDomain.startsWith("*.")) {
// Convert *.foo.bar.com -> HostRegexp(`^[^.]+\.foo\.bar\.com$`)
const escaped = fullDomain.slice(2).replace(/\./g, "\\.");
return `HostRegexp(\`^[^.]+\\.${escaped}$\`)`;
return `HostRegexp(${ruleValue(`^[^.]+\\.${escaped}$`)})`;
}
return `Host(\`${fullDomain}\`)`;
return `Host(${ruleValue(fullDomain)})`;
}
// Append a path-matching clause to a Traefik rule based on the resource's
@@ -27,11 +51,11 @@ export function appendPathMatch(
}
if (pathMatchType === "exact") {
return `${rule} && Path(\`${p}\`)`;
return `${rule} && Path(${ruleValue(p)})`;
} else if (pathMatchType === "prefix") {
return `${rule} && PathPrefix(\`${p}\`)`;
return `${rule} && PathPrefix(${ruleValue(p)})`;
} else if (pathMatchType === "regex") {
return `${rule} && PathRegexp(\`${path}\`)`; // this is the raw path because it's a regex
return `${rule} && PathRegexp(${ruleValue(path)})`; // this is the raw path because it's a regex
}
return rule;
}
+4 -3
View File
@@ -1,4 +1,5 @@
import config from "@server/lib/config";
import { buildHostRule } from "./rule";
export type SiteResourceAliasRow = {
siteResourceId: number;
@@ -82,7 +83,7 @@ export function buildSiteResourceAliasCertPlaceholders(params: {
entryPoints: [config.getRawConfig().traefik.http_entrypoint],
middlewares: [redirectHttpsMiddlewareName],
service: siteResourceServiceName,
rule: `Host(\`${fullDomain}\`)`,
rule: buildHostRule(fullDomain),
priority: 100
};
@@ -97,7 +98,7 @@ export function buildSiteResourceAliasCertPlaceholders(params: {
entryPoints: [config.getRawConfig().traefik.https_entrypoint],
service: siteResourceServiceName,
middlewares: [siteResourceRewriteMiddlewareName],
rule: `Host(\`${fullDomain}\`)`,
rule: buildHostRule(fullDomain),
priority: 100,
tls
};
@@ -106,7 +107,7 @@ export function buildSiteResourceAliasCertPlaceholders(params: {
config_output.http.routers[`${siteResourceRouterName}-assets`] = {
entryPoints: [config.getRawConfig().traefik.https_entrypoint],
service: siteResourceServiceName,
rule: `Host(\`${fullDomain}\`) && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
rule: `${buildHostRule(fullDomain)} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
priority: 101,
tls
};
+1 -3
View File
@@ -2,7 +2,6 @@ import next from "next";
import express from "express";
import fs from "fs";
import path from "path";
import { parse } from "url";
import logger from "@server/logger";
import config from "@server/lib/config";
import { stripDuplicateSesions } from "./middlewares/stripDuplicateSessions";
@@ -43,8 +42,7 @@ export async function createNextServer() {
nextServer.use(stripDuplicateSesions);
nextServer.all("/{*splat}", (req, res) => {
const parsedUrl = parse(req.url!, true);
return handle(req, res, parsedUrl);
return handle(req, res);
});
nextServer.listen(nextPort, (err?: any) => {
+331 -6
View File
@@ -30,12 +30,14 @@ import {
sites,
targets,
dnsChallenge,
domains
domains,
customDnsRecords
} from "@server/db";
import { privateConfig as config } from "#private/lib/config";
import * as dnsResolver from "dns";
import logger from "@server/logger";
import { listExitNodes } from "../exitNodes";
import { parseCustomDnsRecordValues } from "@server/routers/customDnsRecord/types";
import { rateLimitService } from "../rateLimit";
import license from "#private/license/license";
@@ -50,7 +52,18 @@ type DNSRecord = {
enabled: boolean;
};
type CustomRecord = {
type: string;
value: string;
ttl: number;
priority: number | null;
weight: number | null;
port: number | null;
};
const DNS_CACHE_TTL_SECONDS = 300;
// Kept short so edits to custom records propagate quickly
const CUSTOM_RECORD_CACHE_TTL_SECONDS = 60;
export class AuthoritativeDNSServer {
private cache: LocalCache = createLocalCache(10_000, DNS_CACHE_TTL_SECONDS);
@@ -162,6 +175,10 @@ export class AuthoritativeDNSServer {
const originalQueryName = queryName;
const queryType = question.type;
let baseDomain: string | null;
// True when baseDomain is the customer zone the queried name itself
// lives in (not a synthetic cname-extension lookup), i.e. when
// user-defined custom records may apply.
let isCustomerZone = false;
logger.info(
`DNS Query: ${queryName} ${queryType} from ${rinfo.address}:${rinfo.port}`
@@ -284,6 +301,34 @@ export class AuthoritativeDNSServer {
this.sendResponse(packet, [], rinfo, false, 3, []); // NXDOMAIN rcode=3 - not authoritative
return;
}
isCustomerZone = true;
}
// A subdomain the user delegated elsewhere with NS records is no
// longer ours to answer for - refer the resolver to its nameservers
// for the cut and everything beneath it, ahead of any other handling.
if (isCustomerZone) {
const delegation = await this.findDelegation(queryName, baseDomain);
if (delegation) {
logger.info(
`Referring ${originalQueryName} (${queryType}) to delegated zone ${delegation.cut}`
);
const [authorities, additionals] = await this.buildReferral(
delegation,
baseDomain
);
// Referrals are non-authoritative (AA=0) with no answers
this.sendResponse(
packet,
[],
rinfo,
false,
0,
authorities,
additionals
);
return;
}
}
// Special handling for _acme-challenge CNAME requests
@@ -452,11 +497,42 @@ export class AuthoritativeDNSServer {
return;
}
// User-defined records come after everything Pangolin manages itself,
// so they can never shadow a resource, login page or redirect.
const customRecords = isCustomerZone
? await this.getCustomRecords(queryName, baseDomain!)
: [];
if (customRecords.length > 0) {
let matchingRecords = customRecords.filter(
(record) => record.type === queryType
);
// A CNAME answers queries for every other type at its name
if (matchingRecords.length === 0 && queryType !== "CNAME") {
matchingRecords = customRecords.filter(
(record) => record.type === "CNAME"
);
}
if (matchingRecords.length > 0) {
const answers = this.convertCustomRecordsToAnswers(
matchingRecords,
originalQueryName
);
logger.info(
`Returning ${answers.length} custom answers for ${originalQueryName} (${queryType})`
);
this.sendResponse(packet, answers, rinfo, true, 0, []);
return;
}
}
// Check if the domain exists (for NODATA vs NXDOMAIN)
const [domainExists, soaRecord] = await Promise.all([
const [resourceExists, soaRecord] = await Promise.all([
this.domainExists(queryName, baseDomain!),
this.getSOARecord(baseDomain!)
]);
const domainExists = resourceExists || customRecords.length > 0;
if (!domainExists) {
// NXDOMAIN: Name does not exist
@@ -1117,9 +1193,9 @@ export class AuthoritativeDNSServer {
}
case "MX":
// MX records need priority and exchange
// MX records need preference and exchange
return {
priority: record.priority,
preference: record.priority,
exchange: record.value
};
@@ -1146,13 +1222,262 @@ export class AuthoritativeDNSServer {
}
}
// User-defined records for `name`. Only served when the record's domain
// is the zone this query resolved to and is a verified NS delegation, so
// records can never surface under another org's zone or a domain whose
// ownership hasn't been proven. An exact name match takes precedence over
// a wildcard (RFC 4592).
private async getCustomRecords(
name: string,
zone: string
): Promise<CustomRecord[]> {
const normalized = name.replace(/\.$/, "").toLowerCase();
const cacheKey = `customRecords:${zone}:${normalized}`;
const cached = this.cache.get<CustomRecord[]>(cacheKey);
if (cached !== undefined) {
return cached;
}
return this.withInFlightDedup<CustomRecord[]>(cacheKey, async () => {
try {
const wildcardPattern = this.getWildcardPattern(normalized);
const names =
wildcardPattern && wildcardPattern !== normalized
? [normalized, wildcardPattern]
: [normalized];
const rows = await db
.select({
fullDomain: customDnsRecords.fullDomain,
type: customDnsRecords.type,
values: customDnsRecords.values,
ttl: customDnsRecords.ttl,
priority: customDnsRecords.priority,
weight: customDnsRecords.weight,
port: customDnsRecords.port
})
.from(customDnsRecords)
.innerJoin(
domains,
eq(domains.domainId, customDnsRecords.domainId)
)
.where(
and(
inArray(customDnsRecords.fullDomain, names),
eq(customDnsRecords.enabled, true),
eq(domains.baseDomain, zone),
eq(domains.type, "ns"),
eq(domains.verified, true)
)
);
const exactRows = rows.filter(
(row) => row.fullDomain === normalized
);
// Each stored value is served as its own resource record
const result: CustomRecord[] = (
exactRows.length > 0 ? exactRows : rows
).flatMap(({ fullDomain, values, ...record }) =>
parseCustomDnsRecordValues(values).map((value) => ({
...record,
value
}))
);
this.cache.set(
cacheKey,
result,
CUSTOM_RECORD_CACHE_TTL_SECONDS
);
return result;
} catch (error) {
logger.error("Error querying custom DNS records:", error);
return [];
}
});
}
// Finds the user-defined delegation (NS records on a subdomain) that
// `name` falls at or beneath within `zone`. When cuts are nested the
// topmost one wins, since everything below it belongs to the child zone.
// Subject to the same verified-NS-zone scoping as getCustomRecords.
private async findDelegation(
name: string,
zone: string
): Promise<{ cut: string; nameservers: CustomRecord[] } | null> {
const normalized = name.replace(/\.$/, "").toLowerCase();
if (!normalized.endsWith(`.${zone}`)) {
return null; // the apex itself is never delegated
}
const cacheKey = `delegation:${zone}:${normalized}`;
const cached = this.cache.get<{
cut: string;
nameservers: CustomRecord[];
} | null>(cacheKey);
if (cached !== undefined) {
return cached;
}
return this.withInFlightDedup(cacheKey, async () => {
try {
// Every ancestor of the name below the zone apex, plus the
// name itself
const labels = normalized.split(".");
const zoneLabelCount = zone.split(".").length;
const candidates: string[] = [];
for (let i = 0; i < labels.length - zoneLabelCount; i++) {
candidates.push(labels.slice(i).join("."));
}
const rows = await db
.select({
fullDomain: customDnsRecords.fullDomain,
type: customDnsRecords.type,
values: customDnsRecords.values,
ttl: customDnsRecords.ttl,
priority: customDnsRecords.priority,
weight: customDnsRecords.weight,
port: customDnsRecords.port
})
.from(customDnsRecords)
.innerJoin(
domains,
eq(domains.domainId, customDnsRecords.domainId)
)
.where(
and(
inArray(customDnsRecords.fullDomain, candidates),
eq(customDnsRecords.type, "NS"),
eq(customDnsRecords.enabled, true),
eq(domains.baseDomain, zone),
eq(domains.type, "ns"),
eq(domains.verified, true)
)
);
let result: {
cut: string;
nameservers: CustomRecord[];
} | null = null;
if (rows.length > 0) {
const cut = rows.reduce((a, b) =>
a.fullDomain.length <= b.fullDomain.length ? a : b
).fullDomain;
const nameservers = rows
.filter((row) => row.fullDomain === cut)
.flatMap(({ fullDomain, values, ...record }) =>
parseCustomDnsRecordValues(values).map((value) => ({
...record,
value
}))
);
if (nameservers.length > 0) {
result = { cut, nameservers };
}
}
this.cache.set(
cacheKey,
result,
CUSTOM_RECORD_CACHE_TTL_SECONDS
);
return result;
} catch (error) {
logger.error("Error looking up DNS delegation:", error);
return null;
}
});
}
// Builds the authority (NS) and additional (glue) sections of a referral
// to a delegated subdomain. Glue is only needed - and only included -
// for nameservers named inside the delegated subdomain itself, which a
// resolver could not otherwise find the address of.
private async buildReferral(
delegation: { cut: string; nameservers: CustomRecord[] },
zone: string
): Promise<[dns.Answer[], dns.Answer[]]> {
const authorities = this.convertCustomRecordsToAnswers(
delegation.nameservers,
delegation.cut
);
const additionals: dns.Answer[] = [];
for (const nameserver of delegation.nameservers) {
const target = nameserver.value;
if (
target !== delegation.cut &&
!target.endsWith(`.${delegation.cut}`)
) {
continue;
}
const glue = (await this.getCustomRecords(target, zone)).filter(
(record) => record.type === "A" || record.type === "AAAA"
);
additionals.push(
...this.convertCustomRecordsToAnswers(glue, target)
);
}
return [authorities, additionals];
}
private convertCustomRecordsToAnswers(
records: CustomRecord[],
queryName: string
): dns.Answer[] {
return records.map((record) => {
let data: any;
switch (record.type) {
case "MX":
data = {
preference: record.priority ?? 0,
exchange: record.value
};
break;
case "SRV":
data = {
priority: record.priority ?? 0,
weight: record.weight ?? 0,
port: record.port ?? 0,
target: record.value
};
break;
case "TXT": {
// A single TXT character-string is at most 255 bytes;
// longer values are split across several strings, which
// clients concatenate. Values are printable ASCII only.
const chunks: string[] = [];
for (let i = 0; i < record.value.length; i += 255) {
chunks.push(record.value.slice(i, i + 255));
}
data = chunks;
break;
}
default:
data = record.value;
}
return {
name: queryName,
type: record.type,
ttl: record.ttl,
data
} as dns.Answer;
});
}
private sendResponse(
originalPacket: dns.Packet,
answers: dns.Answer[],
rinfo: dgram.RemoteInfo,
authoritative: boolean = true,
rcode: number = 0,
authorities: dns.Answer[] = []
authorities: dns.Answer[] = [],
additionals: dns.Answer[] = []
): void {
const response: dns.Packet = {
id: originalPacket.id,
@@ -1161,7 +1486,7 @@ export class AuthoritativeDNSServer {
questions: originalPacket.questions,
answers: answers,
authorities: authorities,
additionals: []
additionals: additionals
};
// Set authoritative flag if this is an authoritative response
@@ -71,7 +71,8 @@ import {
import {
appendPathMatch,
buildHostRule,
computeRoutePriority
computeRoutePriority,
ruleValue
} from "@server/lib/traefik/rule";
import { buildSiteResourceAliasCertPlaceholders } from "@server/lib/traefik/siteResourceAlias";
import { ResourceWithTargets } from "@server/lib/traefik/types";
@@ -1189,7 +1190,7 @@ export async function getTraefikConfig(
const srKey = `inference-sr${sr.siteResourceId}`;
const routerName = `${srKey}-router`;
const serviceName = `${srKey}-service`;
const rule = `Host(\`${fullDomain}\`) && ClientIP(\`${exitNode.address}\`)`; // restrict to coming from the exit node ip range that the client is connected to
const rule = `${buildHostRule(fullDomain)} && ClientIP(${ruleValue(exitNode.address)})`; // restrict to coming from the exit node ip range that the client is connected to
let tls: any = {};
if (!pangolinCertModeEnabled) {
@@ -1345,7 +1346,7 @@ export async function getTraefikConfig(
],
middlewares: [landingRateLimitMiddlewareName],
service: "landing-service",
rule: `Host(\`${fullDomain}\`) && (PathRegexp(\`^/auth/resource/[^/]+$\`) || PathRegexp(\`^/auth/idp/[0-9]+/oidc/callback\`) || PathPrefix(\`/_next\`) || Path(\`/auth/org\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
rule: `${buildHostRule(fullDomain)} && (PathRegexp(\`^/auth/resource/[^/]+$\`) || PathRegexp(\`^/auth/idp/[0-9]+/oidc/callback\`) || PathPrefix(\`/_next\`) || Path(\`/auth/org\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
priority: 203,
tls: tls
};
@@ -1367,7 +1368,7 @@ export async function getTraefikConfig(
redirectToRootMiddlewareName
],
service: "landing-service",
rule: `Host(\`${fullDomain}\`)`,
rule: buildHostRule(fullDomain),
priority: 202,
tls: tls
};
@@ -1379,7 +1380,7 @@ export async function getTraefikConfig(
],
middlewares: [redirectHttpsMiddlewareName],
service: "landing-service",
rule: `Host(\`${fullDomain}\`)`,
rule: buildHostRule(fullDomain),
priority: 201
};
}
@@ -32,9 +32,11 @@ import {
siteProvisioningKeyOrg,
siteProvisioningKeys,
alertRules,
targetHealthCheck
targetHealthCheck,
customDnsRecords,
orgDomains
} from "@server/db";
import { and, eq, isNull } from "drizzle-orm";
import { and, eq, inArray, isNull } from "drizzle-orm";
/**
* Get the maximum allowed retention days for a given tier
@@ -336,6 +338,10 @@ async function disableFeature(
await disableStandaloneHealthChecks(orgId);
break;
case TierFeature.CustomDnsRecords:
await disableCustomDnsRecords(orgId);
break;
default:
logger.warn(
`Unknown feature ${feature} for org ${orgId}, skipping`
@@ -575,6 +581,25 @@ async function disableAlertingRules(orgId: string): Promise<void> {
logger.info(`Disabled all alert rules for org ${orgId}`);
}
async function disableCustomDnsRecords(orgId: string): Promise<void> {
// Records are kept (just stop being served) so re-subscribing restores
// them by re-enabling
await db
.update(customDnsRecords)
.set({ enabled: false, updatedAt: Date.now() })
.where(
inArray(
customDnsRecords.domainId,
db
.select({ domainId: orgDomains.domainId })
.from(orgDomains)
.where(eq(orgDomains.orgId, orgId))
)
);
logger.info(`Disabled all custom DNS records for org ${orgId}`);
}
async function disableStandaloneHealthChecks(orgId: string): Promise<void> {
await db
.update(targetHealthCheck)
@@ -0,0 +1,155 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, customDnsRecords } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import {
CreateOrUpdateCustomDnsRecordResponse,
toCustomDnsRecordRow
} from "@server/routers/customDnsRecord/types";
import {
checkCustomDnsRecordConflicts,
customDnsRecordFieldsSchema,
getManageableDomain,
normalizeCustomDnsRecord
} from "./validation";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
domainId: z.string().nonempty()
});
const bodySchema = customDnsRecordFieldsSchema.extend({
ttl: customDnsRecordFieldsSchema.shape.ttl.optional().default(300),
enabled: z.boolean().optional().default(true)
});
registry.registerPath({
method: "put",
path: "/org/{orgId}/domain/{domainId}/custom-dns-record",
description:
"Create a custom DNS record served by Pangolin's nameservers for a domain. Each entry in `values` is served as a separate resource record (e.g. both nameservers of an NS record).",
tags: [OpenAPITags.Domain],
request: {
params: paramsSchema,
body: {
content: {
"application/json": {
schema: bodySchema
}
}
}
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function createCustomDnsRecord(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, domainId } = parsedParams.data;
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const domain = await getManageableDomain(orgId, domainId);
if (!domain.ok) {
return next(createHttpError(domain.status, domain.error));
}
const normalized = normalizeCustomDnsRecord(
parsedBody.data,
domain.baseDomain
);
if (!normalized.ok) {
return next(
createHttpError(HttpCode.BAD_REQUEST, normalized.error)
);
}
const conflict = await checkCustomDnsRecordConflicts(
domainId,
domain.baseDomain,
normalized.value
);
if (conflict) {
return next(createHttpError(HttpCode.CONFLICT, conflict));
}
const now = Date.now();
const [record] = await db
.insert(customDnsRecords)
.values({
domainId,
...normalized.value,
values: JSON.stringify(normalized.value.values),
createdAt: now,
updatedAt: now
})
.returning();
return response<CreateOrUpdateCustomDnsRecordResponse>(res, {
data: toCustomDnsRecordRow(record),
success: true,
error: false,
message: "Custom DNS record created successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -0,0 +1,124 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, customDnsRecords, orgDomains } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { and, eq } from "drizzle-orm";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
domainId: z.string().nonempty(),
customDnsRecordId: z.coerce.number<string>().int().positive()
});
registry.registerPath({
method: "delete",
path: "/org/{orgId}/domain/{domainId}/custom-dns-record/{customDnsRecordId}",
description: "Delete a custom DNS record from a domain.",
tags: [OpenAPITags.Domain],
request: {
params: paramsSchema
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function deleteCustomDnsRecord(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, domainId, customDnsRecordId } = parsedParams.data;
// Scope the record to a domain this org owns - the record id alone
// must never be enough to reach another org's records.
const [existing] = await db
.select({ customDnsRecordId: customDnsRecords.customDnsRecordId })
.from(customDnsRecords)
.innerJoin(
orgDomains,
eq(orgDomains.domainId, customDnsRecords.domainId)
)
.where(
and(
eq(customDnsRecords.customDnsRecordId, customDnsRecordId),
eq(customDnsRecords.domainId, domainId),
eq(orgDomains.orgId, orgId)
)
)
.limit(1);
if (!existing) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Custom DNS record with ID ${customDnsRecordId} not found`
)
);
}
await db
.delete(customDnsRecords)
.where(
and(
eq(customDnsRecords.customDnsRecordId, customDnsRecordId),
eq(customDnsRecords.domainId, domainId)
)
);
return response<null>(res, {
data: null,
success: true,
error: false,
message: "Custom DNS record deleted successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -0,0 +1,17 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
export * from "./createCustomDnsRecord";
export * from "./updateCustomDnsRecord";
export * from "./deleteCustomDnsRecord";
export * from "./listCustomDnsRecords";
@@ -0,0 +1,115 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, customDnsRecords, orgDomains } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { and, asc, eq } from "drizzle-orm";
import {
ListCustomDnsRecordsResponse,
toCustomDnsRecordRow
} from "@server/routers/customDnsRecord/types";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
domainId: z.string().nonempty()
});
registry.registerPath({
method: "get",
path: "/org/{orgId}/domain/{domainId}/custom-dns-records",
description:
"List the custom DNS records served by Pangolin's nameservers for a domain.",
tags: [OpenAPITags.Domain],
request: {
params: paramsSchema
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function listCustomDnsRecords(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, domainId } = parsedParams.data;
// Records per domain are capped (MAX_CUSTOM_DNS_RECORDS_PER_DOMAIN),
// so no pagination is needed.
const rows = await db
.select({ record: customDnsRecords })
.from(customDnsRecords)
.innerJoin(
orgDomains,
eq(orgDomains.domainId, customDnsRecords.domainId)
)
.where(
and(
eq(customDnsRecords.domainId, domainId),
eq(orgDomains.orgId, orgId)
)
)
.orderBy(
asc(customDnsRecords.fullDomain),
asc(customDnsRecords.type),
asc(customDnsRecords.customDnsRecordId)
);
return response<ListCustomDnsRecordsResponse>(res, {
data: {
records: rows.map((r) => toCustomDnsRecordRow(r.record))
},
success: true,
error: false,
message: "Custom DNS records retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -0,0 +1,190 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, customDnsRecords } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { and, eq } from "drizzle-orm";
import {
CreateOrUpdateCustomDnsRecordResponse,
parseCustomDnsRecordValues,
toCustomDnsRecordRow
} from "@server/routers/customDnsRecord/types";
import {
checkCustomDnsRecordConflicts,
customDnsRecordFieldsSchema,
getManageableDomain,
normalizeCustomDnsRecord
} from "./validation";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
domainId: z.string().nonempty(),
customDnsRecordId: z.coerce.number<string>().int().positive()
});
const bodySchema = customDnsRecordFieldsSchema.partial();
registry.registerPath({
method: "post",
path: "/org/{orgId}/domain/{domainId}/custom-dns-record/{customDnsRecordId}",
description: "Update a custom DNS record for a domain.",
tags: [OpenAPITags.Domain],
request: {
params: paramsSchema,
body: {
content: {
"application/json": {
schema: bodySchema
}
}
}
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function updateCustomDnsRecord(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, domainId, customDnsRecordId } = parsedParams.data;
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const domain = await getManageableDomain(orgId, domainId);
if (!domain.ok) {
return next(createHttpError(domain.status, domain.error));
}
const [existing] = await db
.select()
.from(customDnsRecords)
.where(
and(
eq(customDnsRecords.customDnsRecordId, customDnsRecordId),
eq(customDnsRecords.domainId, domainId)
)
)
.limit(1);
if (!existing) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Custom DNS record with ID ${customDnsRecordId} not found`
)
);
}
// Validate the record as a whole, not just the changed fields, so a
// partial update can't leave it in an invalid state.
const normalized = normalizeCustomDnsRecord(
{
name: existing.name,
type: existing.type,
values: parseCustomDnsRecordValues(existing.values),
ttl: existing.ttl,
priority: existing.priority,
weight: existing.weight,
port: existing.port,
enabled: existing.enabled,
...parsedBody.data
},
domain.baseDomain
);
if (!normalized.ok) {
return next(
createHttpError(HttpCode.BAD_REQUEST, normalized.error)
);
}
const conflict = await checkCustomDnsRecordConflicts(
domainId,
domain.baseDomain,
normalized.value,
customDnsRecordId
);
if (conflict) {
return next(createHttpError(HttpCode.CONFLICT, conflict));
}
const [record] = await db
.update(customDnsRecords)
.set({
...normalized.value,
values: JSON.stringify(normalized.value.values),
updatedAt: Date.now()
})
.where(
and(
eq(customDnsRecords.customDnsRecordId, customDnsRecordId),
eq(customDnsRecords.domainId, domainId)
)
)
.returning();
return response<CreateOrUpdateCustomDnsRecordResponse>(res, {
data: toCustomDnsRecordRow(record),
success: true,
error: false,
message: "Custom DNS record updated successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -0,0 +1,577 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { isIPv4, isIPv6 } from "net";
import { z } from "zod";
import {
db,
customDnsRecords,
domains,
domainNamespaces,
loginPage,
orgDomains,
redirects,
resources
} from "@server/db";
import { and, eq, inArray, isNull } from "drizzle-orm";
import HttpCode from "@server/types/HttpCode";
import { privateConfig as config } from "#private/lib/config";
import {
CUSTOM_DNS_RECORD_TYPES,
CustomDnsRecordType,
parseCustomDnsRecordValues
} from "@server/routers/customDnsRecord/types";
export const MAX_CUSTOM_DNS_RECORDS_PER_DOMAIN = 100;
// Caps the number of values (resource records) any single RRset the DNS
// server can be made to return, which bounds how much a spoofed UDP query
// can be amplified.
export const MAX_CUSTOM_DNS_VALUES_PER_RRSET = 10;
export const MAX_TXT_VALUE_LENGTH = 1024;
export const MIN_TTL = 60;
export const MAX_TTL = 86400;
// Underscores are allowed so service labels like _dmarc, _sip._tcp and
// selector._domainkey can be used.
const LABEL_REGEX = /^[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?$/;
// Printable ASCII only - no control characters or non-ASCII bytes in TXT data.
const TXT_REGEX = /^[\x20-\x7e]+$/;
const uint16 = z.number().int().min(0).max(65535);
export const customDnsRecordFieldsSchema = z.strictObject({
name: z.string().max(253),
type: z.enum(CUSTOM_DNS_RECORD_TYPES),
values: z
.array(
z
.string()
.min(1)
.max(MAX_TXT_VALUE_LENGTH + 2) // + surrounding quotes
)
.min(1)
.max(MAX_CUSTOM_DNS_VALUES_PER_RRSET),
ttl: z.number().int().min(MIN_TTL).max(MAX_TTL),
priority: uint16.nullable().optional(),
weight: uint16.nullable().optional(),
port: uint16.nullable().optional(),
enabled: z.boolean()
});
export type CustomDnsRecordInput = z.infer<typeof customDnsRecordFieldsSchema>;
export type NormalizedCustomDnsRecord = {
name: string;
fullDomain: string;
type: CustomDnsRecordType;
values: string[];
ttl: number;
priority: number | null;
weight: number | null;
port: number | null;
enabled: boolean;
};
type Result<T> = { ok: true; value: T } | { ok: false; error: string };
type ManageableDomainResult =
| { ok: true; domainId: string; baseDomain: string }
| { ok: false; status: number; error: string };
function isValidHostname(host: string): boolean {
if (host.length === 0 || host.length > 253 || !host.includes(".")) {
return false;
}
return host.split(".").every((label) => LABEL_REGEX.test(label));
}
function normalizeHostname(value: string): string {
return value.trim().toLowerCase().replace(/\.$/, "");
}
function isAtOrBelow(name: string, zone: string): boolean {
const normalizedZone = zone.toLowerCase().replace(/\.$/, "");
return name === normalizedZone || name.endsWith(`.${normalizedZone}`);
}
// Loads the domain and makes sure custom records may be managed on it: it
// must belong to the org, be delegated to our nameservers (type "ns"), and
// belong to this org alone so one org can never publish records under a
// domain another org also uses (e.g. a shared/provided namespace domain).
export async function getManageableDomain(
orgId: string,
domainId: string
): Promise<ManageableDomainResult> {
const [row] = await db
.select({
domainId: domains.domainId,
baseDomain: domains.baseDomain,
type: domains.type
})
.from(domains)
.innerJoin(orgDomains, eq(orgDomains.domainId, domains.domainId))
.where(and(eq(domains.domainId, domainId), eq(orgDomains.orgId, orgId)))
.limit(1);
if (!row) {
return {
ok: false,
status: HttpCode.NOT_FOUND,
error: `Domain with ID ${domainId} not found`
};
}
if (row.type !== "ns") {
return {
ok: false,
status: HttpCode.BAD_REQUEST,
error: "Custom DNS records can only be added to domains delegated to Pangolin's nameservers (NS domains)"
};
}
const [namespace] = await db
.select({ domainNamespaceId: domainNamespaces.domainNamespaceId })
.from(domainNamespaces)
.where(eq(domainNamespaces.domainId, domainId))
.limit(1);
const linkedOrgs = await db
.select({ orgId: orgDomains.orgId })
.from(orgDomains)
.where(eq(orgDomains.domainId, domainId));
if (namespace || linkedOrgs.length !== 1) {
return {
ok: false,
status: HttpCode.FORBIDDEN,
error: "Custom DNS records cannot be added to shared domains"
};
}
return {
ok: true,
domainId: row.domainId,
baseDomain: row.baseDomain.toLowerCase()
};
}
function normalizeName(
name: string,
baseDomain: string
): Result<{ name: string; fullDomain: string }> {
let relative = name.trim().toLowerCase().replace(/\.$/, "");
if (relative === "" || relative === "@" || relative === baseDomain) {
return { ok: true, value: { name: "@", fullDomain: baseDomain } };
}
// Accept the name typed as an FQDN within this domain as well
if (relative.endsWith(`.${baseDomain}`)) {
relative = relative.slice(0, -(baseDomain.length + 1));
}
const labels = relative.split(".");
for (let i = 0; i < labels.length; i++) {
const label = labels[i];
if (label === "*" && i === 0) {
continue;
}
if (!LABEL_REGEX.test(label)) {
return {
ok: false,
error: `Invalid record name "${name}". Labels may only contain letters, numbers, hyphens and underscores, and "*" is only allowed as the leftmost label.`
};
}
}
// ACME challenge names are served by Pangolin itself for certificate
// issuance - letting users write there would let them influence DNS-01
// validation.
if (labels.includes("_acme-challenge")) {
return {
ok: false,
error: "_acme-challenge records are managed by Pangolin and cannot be added"
};
}
const fullDomain = `${relative}.${baseDomain}`;
if (fullDomain.length > 253) {
return { ok: false, error: "Record name is too long" };
}
return { ok: true, value: { name: relative, fullDomain } };
}
function normalizeValue(
type: CustomDnsRecordType,
rawValue: string
): Result<string> {
switch (type) {
case "A": {
const value = rawValue.trim();
if (!isIPv4(value)) {
return {
ok: false,
error: `"${value}" is not an IPv4 address`
};
}
return { ok: true, value };
}
case "AAAA": {
const value = rawValue.trim().toLowerCase();
if (!isIPv6(value)) {
return {
ok: false,
error: `"${value}" is not an IPv6 address`
};
}
return { ok: true, value };
}
case "CNAME":
case "NS":
case "MX":
case "SRV": {
const value = normalizeHostname(rawValue);
if (!isValidHostname(value)) {
return {
ok: false,
error: `${type} records require fully qualified hostnames as values`
};
}
return { ok: true, value };
}
case "TXT": {
let value = rawValue;
// Strip one pair of surrounding quotes, as commonly pasted from
// zone files and provider instructions
if (
value.length >= 2 &&
value.startsWith('"') &&
value.endsWith('"')
) {
value = value.slice(1, -1);
}
if (value.length === 0 || value.length > MAX_TXT_VALUE_LENGTH) {
return {
ok: false,
error: `TXT values must be between 1 and ${MAX_TXT_VALUE_LENGTH} characters`
};
}
if (!TXT_REGEX.test(value)) {
return {
ok: false,
error: "TXT values may only contain printable ASCII characters"
};
}
return { ok: true, value };
}
}
}
// Validates and normalizes a record's fields without touching the database.
export function normalizeCustomDnsRecord(
input: CustomDnsRecordInput,
baseDomain: string
): Result<NormalizedCustomDnsRecord> {
const nameResult = normalizeName(input.name, baseDomain);
if (!nameResult.ok) {
return nameResult;
}
const { name, fullDomain } = nameResult.value;
const isApex = name === "@";
const isWildcard = name.startsWith("*");
let priority: number | null = null;
let weight: number | null = null;
let port: number | null = null;
const values: string[] = [];
for (const rawValue of input.values) {
const valueResult = normalizeValue(input.type, rawValue);
if (!valueResult.ok) {
return valueResult;
}
if (values.includes(valueResult.value)) {
return {
ok: false,
error: `Duplicate value "${valueResult.value}"`
};
}
values.push(valueResult.value);
}
if (input.type === "CNAME") {
if (isApex) {
return {
ok: false,
error: "CNAME records cannot be added at the root of the domain"
};
}
if (values.length > 1) {
return {
ok: false,
error: "A CNAME record can only have one value"
};
}
if (values[0] === fullDomain) {
return {
ok: false,
error: "A CNAME record cannot point to itself"
};
}
}
if (input.type === "NS" && (isApex || isWildcard)) {
return {
ok: false,
error: "NS records can only be added for a specific subdomain, not the root or a wildcard"
};
}
if (input.type === "MX" || input.type === "SRV") {
if (input.priority == null) {
return {
ok: false,
error: `${input.type} records require a priority`
};
}
priority = input.priority;
}
if (input.type === "SRV") {
if (input.weight == null || input.port == null) {
return {
ok: false,
error: "SRV records require a weight and a port"
};
}
weight = input.weight;
port = input.port;
}
return {
ok: true,
value: {
name,
fullDomain,
type: input.type,
values,
ttl: input.ttl,
priority,
weight,
port,
enabled: input.enabled
}
};
}
// Checks a normalized record against everything else that lives in DNS:
// infrastructure zones, other customers' zones, Pangolin-managed names and
// the domain's other custom records. Returns an error message, or null.
export async function checkCustomDnsRecordConflicts(
domainId: string,
baseDomain: string,
record: NormalizedCustomDnsRecord,
excludeRecordId?: number
): Promise<string | null> {
const { fullDomain, type } = record;
// Names the DNS server answers for itself (nameservers, cname/site
// extensions, configured zones) are never user-writable.
const dnsConfig = config.getRawConfig().dns;
const reservedZones = [
dnsConfig?.nameserver_name,
dnsConfig?.cname_extension,
dnsConfig?.site_extension,
...(dnsConfig?.cname_alternate_extensions ?? []),
...(dnsConfig?.zones ?? [])
].filter((z): z is string => !!z);
if (reservedZones.some((zone) => isAtOrBelow(fullDomain, zone))) {
return "This name is reserved and cannot be used";
}
// A name inside a more specific domain (e.g. sub.example.com added to
// Pangolin separately, possibly by another org) belongs to that zone,
// not this one.
const labels = fullDomain.split(".");
const baseLabelCount = baseDomain.split(".").length;
const childZoneCandidates: string[] = [];
for (let i = 0; i < labels.length - baseLabelCount; i++) {
childZoneCandidates.push(labels.slice(i).join("."));
}
if (childZoneCandidates.length > 0) {
const [childZone] = await db
.select({ domainId: domains.domainId })
.from(domains)
.where(inArray(domains.baseDomain, childZoneCandidates))
.limit(1);
if (childZone) {
return "This name belongs to a different domain in Pangolin";
}
}
const existing = (
await db
.select()
.from(customDnsRecords)
.where(eq(customDnsRecords.domainId, domainId))
).filter((r) => r.customDnsRecordId !== excludeRecordId);
// existing excludes the record being updated, so this only trips when
// adding a new record would go over the limit
if (existing.length >= MAX_CUSTOM_DNS_RECORDS_PER_DOMAIN) {
return `A domain can have at most ${MAX_CUSTOM_DNS_RECORDS_PER_DOMAIN} custom DNS records`;
}
const sameName = existing.filter((r) => r.fullDomain === fullDomain);
if (type === "CNAME" && sameName.length > 0) {
return "A CNAME record cannot be added to a name that already has other records";
}
if (type !== "CNAME" && sameName.some((r) => r.type === "CNAME")) {
return "This name already has a CNAME record, which cannot be combined with other records";
}
// One record per name and type (per priority/weight/port for MX and
// SRV) - more values go into that record rather than a second one, so
// the table shows the whole set in one place.
const sameRRset = sameName.filter((r) => r.type === type);
if (
sameRRset.some(
(r) =>
r.priority === record.priority &&
r.weight === record.weight &&
r.port === record.port
)
) {
return `A ${type} record for this name already exists. Edit it to add more values.`;
}
// MX and SRV can still be split across several records (one per
// priority), so cap the values across all of them
const totalValues =
record.values.length +
sameRRset.reduce(
(sum, r) => sum + parseCustomDnsRecordValues(r.values).length,
0
);
if (totalValues > MAX_CUSTOM_DNS_VALUES_PER_RRSET) {
return `A name can have at most ${MAX_CUSTOM_DNS_VALUES_PER_RRSET} ${type} values`;
}
// Delegations: a subdomain with NS records is handed off to other
// nameservers, and the DNS server refers every query at or beneath it
// there. Nothing else may live at or beneath it except A/AAAA glue for
// nameservers named inside it, or it would silently stop resolving.
const isStrictlyBelow = (name: string, parent: string) =>
name.endsWith(`.${parent}`);
const isGlue =
(type === "A" || type === "AAAA") && !record.name.startsWith("*");
const enclosingCut = existing.find(
(r) => r.type === "NS" && isStrictlyBelow(fullDomain, r.fullDomain)
);
if (enclosingCut) {
if (type === "NS") {
return `This name is inside the delegated subdomain ${enclosingCut.fullDomain}, so it cannot be delegated again`;
}
if (!isGlue) {
return `This name is inside the delegated subdomain ${enclosingCut.fullDomain}, where only A and AAAA records for its nameservers can be added`;
}
}
if (type === "NS") {
if (sameName.some((r) => r.type !== "NS")) {
return "A delegated subdomain can only have NS records. Remove the other records for this name first.";
}
if (
existing.some(
(r) =>
isStrictlyBelow(r.fullDomain, fullDomain) &&
((r.type !== "A" && r.type !== "AAAA") ||
r.name.startsWith("*"))
)
) {
return "Records exist beneath this name that would stop resolving once it is delegated. Remove them first.";
}
const isAtOrBelowCut = (name: string | null) =>
!!name &&
(name.toLowerCase() === fullDomain ||
isStrictlyBelow(name.toLowerCase(), fullDomain));
const resourceNames = await db
.select({ fullDomain: resources.fullDomain })
.from(resources)
.where(eq(resources.domainId, domainId));
const pageNames = await db
.select({ fullDomain: loginPage.fullDomain })
.from(loginPage)
.where(eq(loginPage.domainId, domainId));
const redirectNames = (
await db
.select({ subdomain: redirects.subdomain })
.from(redirects)
.where(
and(
eq(redirects.domainId, domainId),
isNull(redirects.resourceId)
)
)
).map((r) => (r.subdomain ? `${r.subdomain}.${baseDomain}` : null));
if (
[...resourceNames, ...pageNames].some((r) =>
isAtOrBelowCut(r.fullDomain)
) ||
redirectNames.some(isAtOrBelowCut)
) {
return "Pangolin resources use names at or beneath this subdomain, so it cannot be delegated";
}
} else if (sameName.some((r) => r.type === "NS")) {
return "This name is delegated with NS records, so other records cannot be added to it";
}
// Pangolin answers address queries for resources, login pages and
// redirects itself, so a custom A/AAAA/CNAME there would never be served
// (or, for CNAME, would conflict with the managed addresses).
if (type === "A" || type === "AAAA" || type === "CNAME") {
const [resource] = await db
.select({ resourceId: resources.resourceId })
.from(resources)
.where(eq(resources.fullDomain, fullDomain))
.limit(1);
const [page] = await db
.select({ loginPageId: loginPage.loginPageId })
.from(loginPage)
.where(eq(loginPage.fullDomain, fullDomain))
.limit(1);
const [redirect] = await db
.select({ redirectId: redirects.redirectId })
.from(redirects)
.where(
and(
eq(redirects.domainId, domainId),
record.name === "@"
? isNull(redirects.subdomain)
: eq(redirects.subdomain, record.name)
)
)
.limit(1);
if (resource || page || redirect) {
return `This name is already in use by Pangolin, so ${type} records cannot be added to it`;
}
}
return null;
}
+45
View File
@@ -34,8 +34,10 @@ import * as eventStreamingDestination from "#private/routers/eventStreamingDesti
import * as alertRule from "#private/routers/alertRule";
import * as healthChecks from "#private/routers/healthChecks";
import * as policy from "#private/routers/policy";
import * as customDnsRecord from "#private/routers/customDnsRecord";
import {
verifyOrgAccess,
verifyDomainAccess,
verifyUserHasAction,
verifyUserIsServerAdmin,
verifySiteAccess,
@@ -263,6 +265,49 @@ authenticated.get(
domain.checkDomainNamespaceAvailability
);
authenticated.get(
"/org/:orgId/domain/:domainId/custom-dns-records",
verifyValidLicense,
verifyOrgAccess,
verifyDomainAccess,
verifyUserHasAction(ActionsEnum.listCustomDnsRecords),
customDnsRecord.listCustomDnsRecords
);
authenticated.put(
"/org/:orgId/domain/:domainId/custom-dns-record",
verifyValidLicense,
verifyValidSubscription(tierMatrix.customDnsRecords),
verifyOrgAccess,
verifyDomainAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.createCustomDnsRecord),
logActionAudit(ActionsEnum.createCustomDnsRecord),
customDnsRecord.createCustomDnsRecord
);
authenticated.post(
"/org/:orgId/domain/:domainId/custom-dns-record/:customDnsRecordId",
verifyValidLicense,
verifyValidSubscription(tierMatrix.customDnsRecords),
verifyOrgAccess,
verifyDomainAccess,
verifyUserHasAction(ActionsEnum.updateCustomDnsRecord),
logActionAudit(ActionsEnum.updateCustomDnsRecord),
customDnsRecord.updateCustomDnsRecord
);
authenticated.delete(
"/org/:orgId/domain/:domainId/custom-dns-record/:customDnsRecordId",
verifyValidLicense,
verifyValidSubscription(tierMatrix.customDnsRecords),
verifyOrgAccess,
verifyDomainAccess,
verifyUserHasAction(ActionsEnum.deleteCustomDnsRecord),
logActionAudit(ActionsEnum.deleteCustomDnsRecord),
customDnsRecord.deleteCustomDnsRecord
);
authenticated.put(
"/org/:orgId/remote-exit-node",
verifyValidLicense,
+45
View File
@@ -22,8 +22,10 @@ import * as eventStreamingDestination from "#private/routers/eventStreamingDesti
import * as alertRule from "#private/routers/alertRule";
import * as healthChecks from "#private/routers/healthChecks";
import * as resource from "#private/routers/resource";
import * as customDnsRecord from "#private/routers/customDnsRecord";
import {
verifyApiKeyHasAction,
verifyApiKeyDomainAccess,
verifyApiKeyIsRoot,
verifyApiKeyOrgAccess,
verifyApiKeyIdpAccess,
@@ -179,6 +181,49 @@ authenticated.get(
exportAiSessionLogs
);
authenticated.get(
"/org/:orgId/domain/:domainId/custom-dns-records",
verifyValidLicense,
verifyApiKeyOrgAccess,
verifyApiKeyDomainAccess,
verifyApiKeyHasAction(ActionsEnum.listCustomDnsRecords),
customDnsRecord.listCustomDnsRecords
);
authenticated.put(
"/org/:orgId/domain/:domainId/custom-dns-record",
verifyValidLicense,
verifyValidSubscription(tierMatrix.customDnsRecords),
verifyApiKeyOrgAccess,
verifyApiKeyDomainAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.createCustomDnsRecord),
logActionAudit(ActionsEnum.createCustomDnsRecord),
customDnsRecord.createCustomDnsRecord
);
authenticated.post(
"/org/:orgId/domain/:domainId/custom-dns-record/:customDnsRecordId",
verifyValidLicense,
verifyValidSubscription(tierMatrix.customDnsRecords),
verifyApiKeyOrgAccess,
verifyApiKeyDomainAccess,
verifyApiKeyHasAction(ActionsEnum.updateCustomDnsRecord),
logActionAudit(ActionsEnum.updateCustomDnsRecord),
customDnsRecord.updateCustomDnsRecord
);
authenticated.delete(
"/org/:orgId/domain/:domainId/custom-dns-record/:customDnsRecordId",
verifyValidLicense,
verifyValidSubscription(tierMatrix.customDnsRecords),
verifyApiKeyOrgAccess,
verifyApiKeyDomainAccess,
verifyApiKeyHasAction(ActionsEnum.deleteCustomDnsRecord),
logActionAudit(ActionsEnum.deleteCustomDnsRecord),
customDnsRecord.deleteCustomDnsRecord
);
authenticated.put(
"/org/:orgId/idp/oidc",
verifyValidLicense,
+1
View File
@@ -230,6 +230,7 @@ export async function startRegistration(
rpID,
userID: isoBase64URL.toBuffer(user.userId),
userName: user.email || user.username,
userDisplayName: user.name || user.email || user.username, // Safari fails to register a passkey with empty display name - #3931
attestationType: "none",
excludeCredentials,
authenticatorSelection: {
+11 -2
View File
@@ -720,7 +720,7 @@ export async function verifyResourceSession(
);
if (resourceSessionToken) {
const sessionCacheKey = `session:${resourceSessionToken}`;
const sessionCacheKey = `session:${resource.resourceId}:${resourceSessionToken}`;
let resourceSession: ResourceSession | null | undefined =
localCache.get(sessionCacheKey);
@@ -736,6 +736,14 @@ export async function verifyResourceSession(
}
}
// sessions are scoped to a single resource; never honor one issued for another
if (
resourceSession &&
resourceSession.resourceId !== resource.resourceId
) {
resourceSession = null;
}
if (resourceSession?.isRequestToken) {
logger.debug(
"Resource not allowed because session is a temporary request token"
@@ -1245,7 +1253,7 @@ async function allowAccessToken(
);
if (resourceSessionToken) {
const sessionCacheKey = `session:${resourceSessionToken}`;
const sessionCacheKey = `session:${resource.resourceId}:${resourceSessionToken}`;
let resourceSession: ResourceSession | null | undefined =
localCache.get(sessionCacheKey);
@@ -1262,6 +1270,7 @@ async function allowAccessToken(
if (
resourceSession &&
resourceSession.resourceId === resource.resourceId &&
!resourceSession.isRequestToken &&
resourceSession.accessTokenId === tokenItem.accessTokenId
) {
+54
View File
@@ -0,0 +1,54 @@
export const CUSTOM_DNS_RECORD_TYPES = [
"A",
"AAAA",
"CNAME",
"MX",
"NS",
"SRV",
"TXT"
] as const;
export type CustomDnsRecordType = (typeof CUSTOM_DNS_RECORD_TYPES)[number];
// One record as the user sees it: a name, type and settings with one or more
// values (e.g. both nameservers of an NS record). The DNS server answers with
// one resource record per value.
export type CustomDnsRecordRow = {
customDnsRecordId: number;
domainId: string;
name: string;
fullDomain: string;
type: CustomDnsRecordType;
values: string[];
ttl: number;
priority: number | null;
weight: number | null;
port: number | null;
enabled: boolean;
createdAt: number;
updatedAt: number;
};
export type ListCustomDnsRecordsResponse = {
records: CustomDnsRecordRow[];
};
export type CreateOrUpdateCustomDnsRecordResponse = CustomDnsRecordRow;
// The values column holds a JSON-encoded string array
export function parseCustomDnsRecordValues(raw: string): string[] {
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed)
? parsed.filter((v): v is string => typeof v === "string")
: [];
} catch {
return [];
}
}
export function toCustomDnsRecordRow(
row: Omit<CustomDnsRecordRow, "values"> & { values: string }
): CustomDnsRecordRow {
return { ...row, values: parseCustomDnsRecordValues(row.values) };
}
+2 -1
View File
@@ -7,6 +7,7 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { subdomainSchema } from "@server/lib/schemas";
import { OpenAPITags, registry } from "@server/openApi";
import { and, eq } from "drizzle-orm";
import {
@@ -35,7 +36,7 @@ const bodySchema = z
name: z.string().nonempty(),
resourceId: z.number().int().positive().optional().nullable(),
domainId: z.string().nonempty().optional().nullable(),
subdomain: z.string().nonempty().optional().nullable(),
subdomain: subdomainSchema.optional().nullable(),
destinationHost: redirectDestinationHostSchema,
pathMatchType: redirectPathMatchTypeSchema.optional(),
matchPath: redirectMatchPathSchema.optional().nullable(),
+2 -1
View File
@@ -7,6 +7,7 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { subdomainSchema } from "@server/lib/schemas";
import { OpenAPITags, registry } from "@server/openApi";
import { and, eq, ne } from "drizzle-orm";
import {
@@ -36,7 +37,7 @@ const bodySchema = z.strictObject({
niceId: redirectNiceIdSchema.optional(),
resourceId: z.number().int().positive().optional().nullable(),
domainId: z.string().nonempty().optional().nullable(),
subdomain: z.string().nonempty().optional().nullable(),
subdomain: subdomainSchema.optional().nullable(),
destinationHost: redirectDestinationHostSchema.optional(),
pathMatchType: redirectPathMatchTypeSchema.optional(),
matchPath: redirectMatchPathSchema.optional().nullable(),
+2 -1
View File
@@ -1,6 +1,7 @@
import { z } from "zod";
import { isValidDomain } from "@server/lib/validators";
import { build } from "@server/build";
import { routePathSchema } from "@server/lib/schemas";
export const redirectNiceIdSchema = z
.string()
@@ -20,7 +21,7 @@ export const redirectRewritePathTypeSchema = z.enum([
"stripPrefix"
]);
export const redirectMatchPathSchema = z.string().nonempty();
export const redirectMatchPathSchema = routePathSchema.nonempty();
export function isValidRegex(pattern: string): boolean {
try {
+53 -10
View File
@@ -416,6 +416,26 @@ export async function updateResource(
}
}
// a resource may only be assigned a shared (global) policy from its own org
async function getAssignableResourcePolicy(
resourcePolicyId: number,
orgId: string
) {
const [policy] = await db
.select()
.from(resourcePolicies)
.where(
and(
eq(resourcePolicies.resourcePolicyId, resourcePolicyId),
eq(resourcePolicies.orgId, orgId),
eq(resourcePolicies.scope, "global")
)
)
.limit(1);
return policy;
}
async function clearResourceSpecificSettings(
resourceId: number,
orgId: string,
@@ -513,16 +533,10 @@ async function updateHttpResource(
);
}
const [existingPolicy] = await db
.select()
.from(resourcePolicies)
.where(
eq(
resourcePolicies.resourcePolicyId,
updateData.resourcePolicyId
)
)
.limit(1);
const existingPolicy = await getAssignableResourcePolicy(
updateData.resourcePolicyId,
resource.orgId
);
if (!existingPolicy) {
return next(
@@ -920,6 +934,35 @@ async function updateRawResource(
const updateData = parsedBody.data;
let updatedResource: Resource | null = null;
if (updateData.resourcePolicyId != null) {
const isLicensed = await isLicensedOrSubscribed(
resource.orgId,
tierMatrix.resourcePolicies
);
if (!isLicensed) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Resource policies are not supported on your current plan. Please upgrade to access this feature."
)
);
}
const existingPolicy = await getAssignableResourcePolicy(
updateData.resourcePolicyId,
resource.orgId
);
if (!existingPolicy) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource policy with ID ${updateData.resourcePolicyId} not found`
)
);
}
}
const [existingResource] = await db
.select()
.from(resources)
@@ -97,6 +97,15 @@ const listAllSiteResourcesByOrgQuerySchema = z.strictObject({
enum: ["pending", "approved"],
description: "Filter by site resource status"
}),
enabled: z
.enum(["true", "false"])
.transform((v) => v === "true")
.optional()
.catch(undefined)
.openapi({
type: "boolean",
description: "Filter site resources based on enabled status"
}),
labels: z
.preprocess((val) => {
if (val === undefined || val === null || val === "") {
@@ -322,6 +331,7 @@ export async function listAllSiteResourcesByOrg(
order,
siteId,
status,
enabled,
labels: labelFilter
} = parsedQuery.data;
@@ -399,6 +409,10 @@ export async function listAllSiteResourcesByOrg(
conditions.push(eq(siteResources.status, status));
}
if (typeof enabled !== "undefined") {
conditions.push(eq(siteResources.enabled, enabled));
}
if (labelFilter && labelFilter.length > 0) {
conditions.push(
inArray(
+2 -1
View File
@@ -1,5 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { routePathSchema } from "@server/lib/schemas";
import {
db,
statusHistory,
@@ -76,7 +77,7 @@ const createTargetSchema = z
hcTlsServerName: z.string().optional().nullable(),
hcHealthyThreshold: z.int().positive().min(1).optional().nullable(),
hcUnhealthyThreshold: z.int().positive().min(1).optional().nullable(),
path: z.string().optional().nullable(),
path: routePathSchema.optional().nullable(),
pathMatchType: z
.enum(["exact", "prefix", "regex"])
.optional()
+2 -1
View File
@@ -1,5 +1,6 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { routePathSchema } from "@server/lib/schemas";
import { db, targetHealthCheck } from "@server/db";
import { aiProviders, newts, resources, sites, targets } from "@server/db";
import { eq } from "drizzle-orm";
@@ -54,7 +55,7 @@ const updateTargetBodySchema = z
hcTlsServerName: z.string().optional().nullable(),
hcHealthyThreshold: z.int().positive().min(1).optional().nullable(),
hcUnhealthyThreshold: z.int().positive().min(1).optional().nullable(),
path: z.string().optional().nullable(),
path: routePathSchema.optional().nullable(),
pathMatchType: z
.enum(["exact", "prefix", "regex"])
.optional()
+4 -2
View File
@@ -117,7 +117,9 @@ export async function getOrgUser(
const { orgId, userId } = parsedParams.data;
if (!req.userOrg) {
// API keys never have a userOrg; their org access and action are
// already checked by verifyApiKeyOrgAccess / verifyApiKeyHasAction
if (req.user && !req.userOrg) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
@@ -150,7 +152,7 @@ export async function getOrgUser(
);
}
if (req.user && user.userId !== req.userOrg.userId) {
if (req.user && user.userId !== req.user.userId) {
const hasPermission = await checkUserActionPermission(
ActionsEnum.getOrgUser,
req