From 82b1e79f637f6541e520394ce2218b94c8aeeb3e Mon Sep 17 00:00:00 2001 From: samirasidd Date: Tue, 20 Oct 2026 16:30:57 +0600 Subject: [PATCH 01/37] ci: run existing unit tests --- package.json | 3 ++- scripts/run-tests.mjs | 43 +++++++++++++++++++++++++++++++++++++++++++ server/lib/ip.test.ts | 1 + 3 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 scripts/run-tests.mjs diff --git a/package.json b/package.json index 4ebb8b298..b5cf45f2d 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,8 @@ "email": "email dev --dir server/emails/templates --port 3005", "build:cli": "node esbuild.mjs -e cli/index.ts -o dist/cli.mjs", "format:check": "prettier --check .", - "format": "prettier --write ." + "format": "prettier --write .", + "test": "node scripts/run-tests.mjs" }, "dependencies": { "@asteasolutions/zod-to-openapi": "9.1.0", diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs new file mode 100644 index 000000000..f9c21f1cf --- /dev/null +++ b/scripts/run-tests.mjs @@ -0,0 +1,43 @@ +import { spawnSync } from "node:child_process"; +import { readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; + +// Test files that fail today. Give each a reason, fix it, then remove it. +const knownFailing = new Map([ + [ + "server/setup/migrationsSqlite.backup.test.ts", + "migration 1.24.0 fails: missing resources table in seeded db" + ], + [ + "server/lib/traefik/traefikConfig.test.ts", + "config undefined on import (logger circular dependency)" + ] +]); +const skipDirs = new Set(["node_modules", ".next", "dist", ".git"]); + +function find(dir) { + return readdirSync(dir).flatMap((name) => { + if (skipDirs.has(name) || name.startsWith("private")) return []; + const p = join(dir, name); + if (statSync(p).isDirectory()) return find(p); + return p.endsWith(".test.ts") ? [p] : []; + }); +} + +const files = find("."); +const failed = []; +for (const f of files) { + if (knownFailing.has(f)) { + console.log(`\n== SKIPPED ${f}: ${knownFailing.get(f)}`); + continue; + } + console.log(`\n== ${f}`); + const r = spawnSync("npx", ["tsx", f], { stdio: "inherit", shell: true }); + if (r.status !== 0) failed.push(f); +} +const ran = files.length - knownFailing.size; +console.log(`\n${ran - failed.length}/${ran} test files passed`); +if (failed.length) { + console.log("FAILED:\n" + failed.join("\n")); + process.exit(1); +} diff --git a/server/lib/ip.test.ts b/server/lib/ip.test.ts index 70436e05a..cf0f45ff6 100644 --- a/server/lib/ip.test.ts +++ b/server/lib/ip.test.ts @@ -1,3 +1,4 @@ +import "../extendZod"; import { cidrToRange, findNextAvailableCidr } from "./ip"; import { assertEquals } from "@test/assert"; From d225930f21c02a488dabd3def0128635f4dda7f2 Mon Sep 17 00:00:00 2001 From: samirasidd Date: Wed, 21 Oct 2026 01:26:15 +0600 Subject: [PATCH 02/37] ci: add unit test step to workflow --- .github/workflows/test.yml | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bcfe95ee0..09fe0e398 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,12 +19,6 @@ jobs: - name: Install Node uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: - # Pin to the version the app actually ships on (Dockerfile / - # Dockerfile.dev both use 24.18.1; .nvmrc pins the 24 major). - # A bare '24' floats to the latest 24.x; Node >= 24.19.0 added - # node::ObjectWrap cleanup hooks that crash better-sqlite3 on teardown - # ("Assertion failed: (env) != nullptr" in RemoveEnvironmentCleanupHook), - # which intermittently kills `npm run dev` in the step below. node-version: '24.18.1' - name: Copy config file @@ -48,6 +42,9 @@ jobs: - name: Test with tsc run: npx tsc --noEmit + - name: Run unit tests + run: npm test + - name: Start app in background run: | nohup npm run dev > app.log 2>&1 & @@ -63,9 +60,6 @@ jobs: echo "App is up" exit 0 fi - # Fail fast (and show why) if the process is gone. Read the PID - # safely: if it's missing/empty (e.g. nohup never started), treat - # that as "not running" instead of passing a bad arg to kill. pid="$(cat app.pid 2>/dev/null || true)" if [ -z "$pid" ] || ! kill -0 "$pid" 2>/dev/null; then echo "App process exited before becoming available. Output:" From aa63a1d47bbc02aaf2fd3914921cd3c67f089575 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Pila=C5=99?= <33516559+jnko266@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:50:38 +0200 Subject: [PATCH 03/37] Fix for #3931 --- server/routers/auth/securityKey.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/server/routers/auth/securityKey.ts b/server/routers/auth/securityKey.ts index 906474e9b..fe18a81d7 100644 --- a/server/routers/auth/securityKey.ts +++ b/server/routers/auth/securityKey.ts @@ -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: { From 4c63ea0d7645cb4805eed10192ad3c8ceeb0151e Mon Sep 17 00:00:00 2001 From: samirasidd Date: Thu, 22 Oct 2026 12:16:18 +0600 Subject: [PATCH 04/37] ci: restore workflow comments, keep only the new step --- .github/workflows/test.yml | 9 +++++++++ scripts/run-tests.mjs | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 09fe0e398..efa373b50 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,6 +19,12 @@ jobs: - name: Install Node uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: + # Pin to the version the app actually ships on (Dockerfile / + # Dockerfile.dev both use 24.18.1; .nvmrc pins the 24 major). + # A bare '24' floats to the latest 24.x; Node >= 24.19.0 added + # node::ObjectWrap cleanup hooks that crash better-sqlite3 on teardown + # ("Assertion failed: (env) != nullptr" in RemoveEnvironmentCleanupHook), + # which intermittently kills `npm run dev` in the step below. node-version: '24.18.1' - name: Copy config file @@ -60,6 +66,9 @@ jobs: echo "App is up" exit 0 fi + # Fail fast (and show why) if the process is gone. Read the PID + # safely: if it's missing/empty (e.g. nohup never started), treat + # that as "not running" instead of passing a bad arg to kill. pid="$(cat app.pid 2>/dev/null || true)" if [ -z "$pid" ] || ! kill -0 "$pid" 2>/dev/null; then echo "App process exited before becoming available. Output:" diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs index f9c21f1cf..3fd867aeb 100644 --- a/scripts/run-tests.mjs +++ b/scripts/run-tests.mjs @@ -32,7 +32,7 @@ for (const f of files) { continue; } console.log(`\n== ${f}`); - const r = spawnSync("npx", ["tsx", f], { stdio: "inherit", shell: true }); + const r = spawnSync("npx", ["tsx", f], { stdio: "inherit", shell: process.platform === "win32" }); if (r.status !== 0) failed.push(f); } const ran = files.length - knownFailing.size; From a0fe03fad951a04d99c71e05e802de401dabea46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Pila=C5=99?= <33516559+jnko266@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:50:38 +0200 Subject: [PATCH 05/37] Fix for #3931 --- server/routers/auth/securityKey.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/server/routers/auth/securityKey.ts b/server/routers/auth/securityKey.ts index 906474e9b..fe18a81d7 100644 --- a/server/routers/auth/securityKey.ts +++ b/server/routers/auth/securityKey.ts @@ -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: { From 3c76f42685db9da9e105582ff7b8334ba25f5798 Mon Sep 17 00:00:00 2001 From: ASPCODES Date: Sat, 24 Oct 2026 00:23:40 +0530 Subject: [PATCH 06/37] fix: add enabled filter to private resources list endpoint --- .../siteResource/listAllSiteResourcesByOrg.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts index e60363c05..90495bea2 100644 --- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts +++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts @@ -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( From 56aa4c7fa8117319b2825a164883b97e4982e978 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 26 Oct 2026 10:13:50 -0400 Subject: [PATCH 07/37] Fix #4002 --- server/nextServer.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/server/nextServer.ts b/server/nextServer.ts index 0a54c1e2a..27bc9d12d 100644 --- a/server/nextServer.ts +++ b/server/nextServer.ts @@ -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) => { From a88e9cae8084796f70e4688a366be475b0d9c93a Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 26 Oct 2026 10:23:38 -0400 Subject: [PATCH 08/37] update ios model identifiers --- server/db/ios_models.json | 1 + 1 file changed, 1 insertion(+) diff --git a/server/db/ios_models.json b/server/db/ios_models.json index 43dfe5687..6cc19d3d9 100644 --- a/server/db/ios_models.json +++ b/server/db/ios_models.json @@ -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", From 4b6f1cf2869e1c25366b0fbf9f711da328257844 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 26 Oct 2026 10:48:38 -0400 Subject: [PATCH 09/37] Fix #4003 --- src/components/PublicResourcesTable.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/components/PublicResourcesTable.tsx b/src/components/PublicResourcesTable.tsx index 1122fedc6..1f71e810c 100644 --- a/src/components/PublicResourcesTable.tsx +++ b/src/components/PublicResourcesTable.tsx @@ -118,6 +118,9 @@ const booleanSearchFilterSchema = z const RESOURCE_STATUS_HISTORY_DAYS = 30; +// Modes whose targets can have health checks, so health and uptime apply +const HEALTH_CHECK_MODES = ["http", "tcp", "udp"]; + export default function PublicResourcesTable({ resources, orgId, @@ -149,9 +152,12 @@ export default function PublicResourcesTable({ const [isRefreshing, startTransition] = useTransition(); const [isNavigatingToAddPage, startNavigation] = useTransition(); - // Only http resources show an uptime bar, so don't ask for the others + // Only health-checked resources show an uptime bar, so don't ask for the others const statusHistoryResourceIds = useMemo( - () => resources.filter((r) => r.mode === "http").map((r) => r.id), + () => + resources + .filter((r) => HEALTH_CHECK_MODES.includes(r.mode || "")) + .map((r) => r.id), [resources] ); @@ -391,7 +397,7 @@ export default function PublicResourcesTable({ ), cell: ({ row }) => { const resourceRow = row.original; - if (resourceRow.mode !== "http") { + if (!HEALTH_CHECK_MODES.includes(resourceRow.mode || "")) { return -; } return ( @@ -422,7 +428,7 @@ export default function PublicResourcesTable({ header: () => {t("uptime30d")}, cell: ({ row }) => { const resourceRow = row.original; - if (resourceRow.mode !== "http") { + if (!HEALTH_CHECK_MODES.includes(resourceRow.mode || "")) { return -; } return ( From 79acfb6c17a8d06fa15921b5f49fd0d3c605f214 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 26 Oct 2026 11:56:56 -0400 Subject: [PATCH 10/37] Pin traefik more specific --- install/config/docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/install/config/docker-compose.yml b/install/config/docker-compose.yml index 05b2fbe8c..9ec616b82 100644 --- a/install/config/docker-compose.yml +++ b/install/config/docker-compose.yml @@ -50,7 +50,7 @@ services: - 80:80{{end}} traefik: - image: docker.io/traefik:v3.7 + image: docker.io/traefik:v3.7.16 container_name: traefik restart: unless-stopped {{if .InstallGerbil}}network_mode: service:gerbil # Ports appear on the gerbil service{{end}}{{if not .InstallGerbil}} From 5c517e1841d66444a41aa5990203966a31e3b6b4 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 26 Oct 2026 11:57:59 -0400 Subject: [PATCH 11/37] Revert to working version --- config/traefik/traefik_config.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/traefik/traefik_config.yml b/config/traefik/traefik_config.yml index 6d98463e3..71cb6179c 100644 --- a/config/traefik/traefik_config.yml +++ b/config/traefik/traefik_config.yml @@ -11,7 +11,7 @@ experimental: plugins: badger: moduleName: github.com/fosrl/badger - version: v1.8.0 + version: v1.7.0 log: level: INFO format: common From 42d672cfc6b415b04683bf7f25e8c0bdb2c69a46 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 27 Oct 2026 14:47:07 -0400 Subject: [PATCH 12/37] Include orgId in the filter for the policy --- server/routers/resource/updateResource.ts | 63 +++++++++++++++++++---- 1 file changed, 53 insertions(+), 10 deletions(-) diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index c78b4bdf9..1b4fc2c47 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -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) From a85b633ca0f09f810b104353679dae7d43865315 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 27 Oct 2026 14:57:11 -0400 Subject: [PATCH 13/37] Include the resource in the cache --- server/routers/badger/verifySession.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index 748164dc8..5d67fd1f1 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -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 ) { From f4410cdbebe296ac2f3f60f089e91e755045140f Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 27 Oct 2026 15:10:08 -0400 Subject: [PATCH 14/37] Sanitize values before including in treafik config --- server/lib/blueprints/types.ts | 26 +++++++-- server/lib/domainUtils.ts | 10 ++++ server/lib/schemas.ts | 11 ++++ server/lib/traefik/browserGateway.ts | 3 +- server/lib/traefik/getTraefikConfig.ts | 9 ++- server/lib/traefik/rule.test.ts | 57 ++++++++++++++++++- server/lib/traefik/rule.ts | 34 +++++++++-- server/lib/traefik/siteResourceAlias.ts | 7 ++- .../private/lib/traefik/getTraefikConfig.ts | 11 ++-- server/routers/redirect/createRedirect.ts | 3 +- server/routers/redirect/updateRedirect.ts | 3 +- server/routers/redirect/validation.ts | 3 +- server/routers/target/createTarget.ts | 3 +- server/routers/target/updateTarget.ts | 3 +- 14 files changed, 157 insertions(+), 26 deletions(-) diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index 1c13187f1..a74c08212 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -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 diff --git a/server/lib/domainUtils.ts b/server/lib/domainUtils.ts index ffd452f36..33143e044 100644 --- a/server/lib/domainUtils.ts +++ b/server/lib/domainUtils.ts @@ -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; diff --git a/server/lib/schemas.ts b/server/lib/schemas.ts index 813849d52..21f19b2cd 100644 --- a/server/lib/schemas.ts +++ b/server/lib/schemas.ts @@ -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" + }); diff --git a/server/lib/traefik/browserGateway.ts b/server/lib/traefik/browserGateway.ts index 4537bd6cc..8e647f3c5 100644 --- a/server/lib/traefik/browserGateway.ts +++ b/server/lib/traefik/browserGateway.ts @@ -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({ diff --git a/server/lib/traefik/getTraefikConfig.ts b/server/lib/traefik/getTraefikConfig.ts index a6e76464a..1060d6bd4 100644 --- a/server/lib/traefik/getTraefikConfig.ts +++ b/server/lib/traefik/getTraefikConfig.ts @@ -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. diff --git a/server/lib/traefik/rule.test.ts b/server/lib/traefik/rule.test.ts index 47e21f85a..a7f9884c2 100644 --- a/server/lib/traefik/rule.test.ts +++ b/server/lib/traefik/rule.test.ts @@ -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 { diff --git a/server/lib/traefik/rule.ts b/server/lib/traefik/rule.ts index e304e9ef9..cf46dc8e7 100644 --- a/server/lib/traefik/rule.ts +++ b/server/lib/traefik/rule.ts @@ -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; } diff --git a/server/lib/traefik/siteResourceAlias.ts b/server/lib/traefik/siteResourceAlias.ts index 3c7392064..b74123ff7 100644 --- a/server/lib/traefik/siteResourceAlias.ts +++ b/server/lib/traefik/siteResourceAlias.ts @@ -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 }; diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index 77b099c2b..54886262a 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -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 }; } diff --git a/server/routers/redirect/createRedirect.ts b/server/routers/redirect/createRedirect.ts index de682e740..23c1dc994 100644 --- a/server/routers/redirect/createRedirect.ts +++ b/server/routers/redirect/createRedirect.ts @@ -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(), diff --git a/server/routers/redirect/updateRedirect.ts b/server/routers/redirect/updateRedirect.ts index 31038a74c..f22080cbe 100644 --- a/server/routers/redirect/updateRedirect.ts +++ b/server/routers/redirect/updateRedirect.ts @@ -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(), diff --git a/server/routers/redirect/validation.ts b/server/routers/redirect/validation.ts index fa304fd9e..1804dbef4 100644 --- a/server/routers/redirect/validation.ts +++ b/server/routers/redirect/validation.ts @@ -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 { diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index fbcaa8439..101be10d9 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -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() diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts index e1ca95f5f..da1ab41ca 100644 --- a/server/routers/target/updateTarget.ts +++ b/server/routers/target/updateTarget.ts @@ -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() From 0f09fecfa2eda5212cf74b0e1d4f4d0bd1e688b4 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 27 Oct 2026 15:23:44 -0400 Subject: [PATCH 15/37] Fix #3950 --- .../public/ProxyResourceTargetsForm.tsx | 47 ++++++++++--------- .../PolicyAccessRulesSection.tsx | 6 ++- 2 files changed, 31 insertions(+), 22 deletions(-) diff --git a/src/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm.tsx b/src/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm.tsx index 04a92ace6..482cbc97e 100644 --- a/src/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm.tsx +++ b/src/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm.tsx @@ -40,6 +40,7 @@ import type { ResourceContextType } from "@app/contexts/resourceContext"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient } from "@app/lib/api"; +import { cn } from "@app/lib/cn"; import { formatAxiosError } from "@app/lib/api/formatAxiosError"; import { DockerManager, DockerState } from "@app/lib/docker"; import { orgQueries, resourceQueries, aiProviderQueries } from "@app/lib/queries"; @@ -959,28 +960,32 @@ export const ProxyResourceTargetsForm = forwardRef< - {hasTargets && ( -
-
- {addTargetButton} - {!disableAdvancedMode && ( -
- - -
- )} -
+ {/* The empty state (and its add button) is hidden on mobile, so always show the add button there */} +
+
+ {addTargetButton} + {hasTargets && !disableAdvancedMode && ( +
+ + +
+ )}
- )} +
{build === "saas" && !isAiProvider && targets.length > 1 && diff --git a/src/components/resource-policy/PolicyAccessRulesSection.tsx b/src/components/resource-policy/PolicyAccessRulesSection.tsx index bd735f9b5..ea12e9bef 100644 --- a/src/components/resource-policy/PolicyAccessRulesSection.tsx +++ b/src/components/resource-policy/PolicyAccessRulesSection.tsx @@ -37,6 +37,7 @@ import { import { UseFormReturn, useForm, useWatch } from "react-hook-form"; import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider"; import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { cn } from "@app/lib/cn"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { resourceQueries } from "@app/lib/queries"; import { useQuery } from "@tanstack/react-query"; @@ -174,7 +175,10 @@ function PolicyAccessRulesSectionLayout({ resourceOverlayMode={resourceOverlayMode} emptyStateAction={addRuleButton} /> - {hasRules && addRuleButton} + {/* The empty state (and its add button) is hidden on mobile, so always show the add button there */} +
+ {addRuleButton} +
)}
From e0c31467a04bf547ec881db0aa5bd9a267d4c2b5 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 28 Oct 2026 10:01:20 -0400 Subject: [PATCH 16/37] Fix swagger getting pruned from container build --- trace-deps.mjs | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/trace-deps.mjs b/trace-deps.mjs index db3e7110e..198841d74 100644 --- a/trace-deps.mjs +++ b/trace-deps.mjs @@ -53,8 +53,21 @@ if (process.env.TRACE_VERBOSE) { ); } +// Packages whose static assets are served from disk at runtime (e.g. via +// express.static) rather than required, so the tracer cannot see them +const assetPackages = ["swagger-ui-dist"]; +const files = new Set(fileList); +for (const pkg of assetPackages) { + const pkgDir = path.join("node_modules", pkg); + for (const entry of fs.readdirSync(path.join(base, pkgDir), { + recursive: true + })) { + if (!entry.endsWith(".map")) files.add(path.join(pkgDir, entry)); + } +} + let count = 0; -for (const file of fileList) { +for (const file of files) { if (!file.startsWith("node_modules/")) continue; const src = path.join(base, file); From e6507746297182e66a8c54a615810bf4e39fa471 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 28 Oct 2026 10:07:52 -0400 Subject: [PATCH 17/37] Fix api key lockout on getting users --- server/routers/user/getOrgUser.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/server/routers/user/getOrgUser.ts b/server/routers/user/getOrgUser.ts index 34732b8d8..974f77fc6 100644 --- a/server/routers/user/getOrgUser.ts +++ b/server/routers/user/getOrgUser.ts @@ -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 From 2298ceb63adc3e4fa5bda5088a45cfe13b482186 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 28 Oct 2026 10:11:01 -0400 Subject: [PATCH 18/37] Update ironrdp Ref #3715 --- package-lock.json | 16 ++++++++-------- package.json | 4 ++-- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index 40363dc8d..ac89aeba3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,8 +11,8 @@ "dependencies": { "@asteasolutions/zod-to-openapi": "9.1.0", "@aws-sdk/client-s3": "3.1152.0", - "@devolutions/iron-remote-desktop": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz", - "@devolutions/iron-remote-desktop-rdp": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.1.tgz", + "@devolutions/iron-remote-desktop": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.1.tgz", + "@devolutions/iron-remote-desktop-rdp": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.2.tgz", "@headlessui/react": "2.2.10", "@hookform/resolvers": "5.9.1", "@monaco-editor/react": "4.7.0", @@ -827,14 +827,14 @@ "license": "MIT" }, "node_modules/@devolutions/iron-remote-desktop": { - "version": "0.0.0", - "resolved": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz", - "integrity": "sha512-9o7PkCw9fdvGTPs0hgsUJG10QleGgcdsSCw1ekLpUOlVXtWCuiuPH+0bPDFhLWxqbVA+8pyVhwqdOI+t1T3TNA==" + "version": "0.0.1", + "resolved": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.1.tgz", + "integrity": "sha512-WWS0D/5op8/+HEwKuXrDOt5TYtMaWqqNGER8Ut8GaSOdKZCSaGlOSAwBh492t3ESy0BD454GdmcsXxCYwR3ufQ==" }, "node_modules/@devolutions/iron-remote-desktop-rdp": { - "version": "0.0.1", - "resolved": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.1.tgz", - "integrity": "sha512-sIHllJ6rJfAgClsUFjJfVGFjIxm6ZQRyGfinwCzHYgwnHAMFE+Kvm0YzR9tg811Fhjm5F6gNUSLAll+7Eh0Deg==" + "version": "0.0.2", + "resolved": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.2.tgz", + "integrity": "sha512-n4m2FMmeAtsYt0wVXd/8JvV85Sd5/nwt2aPoFp9NBV7prx8/YCU+QGNX1ht8Qfey/YgUagKEz0V9uAzChl4IrA==" }, "node_modules/@dotenvx/dotenvx": { "version": "2.37.0", diff --git a/package.json b/package.json index 4b59f2804..e3dc22148 100644 --- a/package.json +++ b/package.json @@ -35,8 +35,8 @@ "dependencies": { "@asteasolutions/zod-to-openapi": "9.1.0", "@aws-sdk/client-s3": "3.1152.0", - "@devolutions/iron-remote-desktop": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz", - "@devolutions/iron-remote-desktop-rdp": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.1.tgz", + "@devolutions/iron-remote-desktop": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.1.tgz", + "@devolutions/iron-remote-desktop-rdp": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.2.tgz", "@headlessui/react": "2.2.10", "@hookform/resolvers": "5.9.1", "@monaco-editor/react": "4.7.0", From cd39f2ecd6a78285c25457d300b6a4d8bfcb90e7 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 28 Oct 2026 10:46:42 -0400 Subject: [PATCH 19/37] Custom dns records --- messages/en-US.json | 23 + server/auth/actions.ts | 4 + server/db/pg/schema/privateSchema.ts | 31 ++ server/db/sqlite/schema/privateSchema.ts | 35 ++ server/lib/billing/tierMatrix.ts | 6 +- server/private/lib/dns/server.ts | 186 ++++++- .../routers/billing/featureLifecycle.ts | 29 +- .../customDnsRecord/createCustomDnsRecord.ts | 151 ++++++ .../customDnsRecord/deleteCustomDnsRecord.ts | 124 +++++ .../private/routers/customDnsRecord/index.ts | 17 + .../customDnsRecord/listCustomDnsRecords.ts | 112 +++++ .../customDnsRecord/updateCustomDnsRecord.ts | 185 +++++++ .../routers/customDnsRecord/validation.ts | 459 ++++++++++++++++++ server/private/routers/external.ts | 45 ++ server/private/routers/integration.ts | 45 ++ server/routers/customDnsRecord/types.ts | 33 ++ src/components/CustomDnsRecordCredenza.tsx | 370 ++++++++++++++ src/components/CustomDnsRecordsTable.tsx | 257 ++++++++++ src/components/DomainPageClient.tsx | 12 + src/components/PermissionsSelectBox.tsx | 6 +- src/lib/queries.ts | 25 + 21 files changed, 2146 insertions(+), 9 deletions(-) create mode 100644 server/private/routers/customDnsRecord/createCustomDnsRecord.ts create mode 100644 server/private/routers/customDnsRecord/deleteCustomDnsRecord.ts create mode 100644 server/private/routers/customDnsRecord/index.ts create mode 100644 server/private/routers/customDnsRecord/listCustomDnsRecords.ts create mode 100644 server/private/routers/customDnsRecord/updateCustomDnsRecord.ts create mode 100644 server/private/routers/customDnsRecord/validation.ts create mode 100644 server/routers/customDnsRecord/types.ts create mode 100644 src/components/CustomDnsRecordCredenza.tsx create mode 100644 src/components/CustomDnsRecordsTable.tsx diff --git a/messages/en-US.json b/messages/en-US.json index e7d3426dd..c2ae4f42b 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1509,6 +1509,10 @@ "actionDeleteOrgDomain": "Delete Domain", "actionGetDNSRecords": "Get DNS Records", "actionRestartOrgDomain": "Restart Domain", + "actionListCustomDnsRecords": "List Custom DNS Records", + "actionCreateCustomDnsRecord": "Create Custom DNS Record", + "actionUpdateCustomDnsRecord": "Update Custom DNS Record", + "actionDeleteCustomDnsRecord": "Delete Custom DNS Record", "actionCreateSite": "Create Site", "actionDeleteSite": "Delete Site", "actionGetSite": "Get Site", @@ -3737,6 +3741,25 @@ "TTL": "TTL", "howToAddRecords": "How to Add Records", "dnsRecord": "DNS Records", + "customDnsRecords": "Custom DNS Records", + "customDnsRecordsDescription": "Additional records served by Pangolin's nameservers for this domain.", + "customDnsRecordsAdd": "Add Record", + "customDnsRecordsEmpty": "No custom DNS records yet.", + "customDnsRecordCreate": "Add DNS Record", + "customDnsRecordEdit": "Edit DNS Record", + "customDnsRecordFormDescription": "This record will be served for {domain}. Changes may take a few minutes to propagate.", + "customDnsRecordNameDescription": "Use @ for the root of the domain or * as the first label for a wildcard.", + "customDnsRecordPriority": "Priority", + "customDnsRecordWeight": "Weight", + "customDnsRecordPort": "Port", + "customDnsRecordCreated": "DNS record created", + "customDnsRecordUpdated": "DNS record updated", + "customDnsRecordSaveError": "Failed to save DNS record", + "customDnsRecordDeleted": "DNS record deleted", + "customDnsRecordDeleteError": "Failed to delete DNS record", + "customDnsRecordDeleteTitle": "Delete DNS Record", + "customDnsRecordDeleteQuestion": "Are you sure you want to delete the {type} record for {name}? It will stop resolving once cached answers expire.", + "customDnsRecordDeleteConfirm": "Delete Record", "required": "Required", "domainSettingsUpdated": "Domain settings updated successfully", "orgOrDomainIdMissing": "Organization or Domain ID is missing", diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 2cdbef8d3..157d79d08 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -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", diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index bd79fc7f4..cd3b2cc26 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -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(), + value: text("value").notNull(), + 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; export type Limit = InferSelectModel; export type Account = InferSelectModel; export type DnsChallenge = InferSelectModel; +export type CustomDnsRecord = InferSelectModel; export type Customer = InferSelectModel; export type Subscription = InferSelectModel; export type SubscriptionItem = InferSelectModel; diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index 8db5bf99d..4f8db913c 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -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(), + value: text("value").notNull(), + 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; export type Limit = InferSelectModel; export type Account = InferSelectModel; export type DnsChallenge = InferSelectModel; +export type CustomDnsRecord = InferSelectModel; export type Customer = InferSelectModel; export type Subscription = InferSelectModel; export type SubscriptionItem = InferSelectModel; diff --git a/server/lib/billing/tierMatrix.ts b/server/lib/billing/tierMatrix.ts index b4d0fb501..ac41b3d94 100644 --- a/server/lib/billing/tierMatrix.ts +++ b/server/lib/billing/tierMatrix.ts @@ -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 = { @@ -68,5 +69,6 @@ export const tierMatrix: Record = { [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"] }; diff --git a/server/private/lib/dns/server.ts b/server/private/lib/dns/server.ts index c4272d31c..34cc0860b 100644 --- a/server/private/lib/dns/server.ts +++ b/server/private/lib/dns/server.ts @@ -30,7 +30,8 @@ import { sites, targets, dnsChallenge, - domains + domains, + customDnsRecords } from "@server/db"; import { privateConfig as config } from "#private/lib/config"; import * as dnsResolver from "dns"; @@ -50,7 +51,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 +174,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 +300,7 @@ export class AuthoritativeDNSServer { this.sendResponse(packet, [], rinfo, false, 3, []); // NXDOMAIN rcode=3 - not authoritative return; } + isCustomerZone = true; } // Special handling for _acme-challenge CNAME requests @@ -338,6 +355,21 @@ export class AuthoritativeDNSServer { // Handle NS queries if (queryType === "NS") { logger.info(`Handling NS query for ${queryName}`); + // A user-defined NS record delegating a subdomain takes + // precedence over our own nameservers for that name + if (isCustomerZone && normalizedQueryName !== baseDomain) { + const customNsRecords = ( + await this.getCustomRecords(queryName, baseDomain) + ).filter((record) => record.type === "NS"); + if (customNsRecords.length > 0) { + const answers = this.convertCustomRecordsToAnswers( + customNsRecords, + originalQueryName + ); + this.sendResponse(packet, answers, rinfo, true, 0, []); + return; + } + } const nsRecords = await this.getNSRecords(baseDomain); if (nsRecords.length > 0) { const answers = nsRecords.map((record) => ({ @@ -452,11 +484,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 +1180,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,6 +1209,121 @@ 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 { + const normalized = name.replace(/\.$/, "").toLowerCase(); + const cacheKey = `customRecords:${zone}:${normalized}`; + + const cached = this.cache.get(cacheKey); + if (cached !== undefined) { + return cached; + } + + return this.withInFlightDedup(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, + value: customDnsRecords.value, + 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 + ); + const result: CustomRecord[] = ( + exactRows.length > 0 ? exactRows : rows + ).map(({ fullDomain, ...record }) => record); + + this.cache.set( + cacheKey, + result, + CUSTOM_RECORD_CACHE_TTL_SECONDS + ); + return result; + } catch (error) { + logger.error("Error querying custom DNS records:", error); + return []; + } + }); + } + + 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[], diff --git a/server/private/routers/billing/featureLifecycle.ts b/server/private/routers/billing/featureLifecycle.ts index 84ee0eba0..eec62e19e 100644 --- a/server/private/routers/billing/featureLifecycle.ts +++ b/server/private/routers/billing/featureLifecycle.ts @@ -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 { logger.info(`Disabled all alert rules for org ${orgId}`); } +async function disableCustomDnsRecords(orgId: string): Promise { + // 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 { await db .update(targetHealthCheck) diff --git a/server/private/routers/customDnsRecord/createCustomDnsRecord.ts b/server/private/routers/customDnsRecord/createCustomDnsRecord.ts new file mode 100644 index 000000000..df1c6aa33 --- /dev/null +++ b/server/private/routers/customDnsRecord/createCustomDnsRecord.ts @@ -0,0 +1,151 @@ +/* + * 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 } 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.", + 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 { + 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, + createdAt: now, + updatedAt: now + }) + .returning(); + + return response(res, { + data: 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") + ); + } +} diff --git a/server/private/routers/customDnsRecord/deleteCustomDnsRecord.ts b/server/private/routers/customDnsRecord/deleteCustomDnsRecord.ts new file mode 100644 index 000000000..698979159 --- /dev/null +++ b/server/private/routers/customDnsRecord/deleteCustomDnsRecord.ts @@ -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().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 { + 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(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") + ); + } +} diff --git a/server/private/routers/customDnsRecord/index.ts b/server/private/routers/customDnsRecord/index.ts new file mode 100644 index 000000000..2bc0acd54 --- /dev/null +++ b/server/private/routers/customDnsRecord/index.ts @@ -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"; diff --git a/server/private/routers/customDnsRecord/listCustomDnsRecords.ts b/server/private/routers/customDnsRecord/listCustomDnsRecords.ts new file mode 100644 index 000000000..ea2244bc7 --- /dev/null +++ b/server/private/routers/customDnsRecord/listCustomDnsRecords.ts @@ -0,0 +1,112 @@ +/* + * 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 } 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 { + 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(res, { + data: { + records: rows.map((r) => 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") + ); + } +} diff --git a/server/private/routers/customDnsRecord/updateCustomDnsRecord.ts b/server/private/routers/customDnsRecord/updateCustomDnsRecord.ts new file mode 100644 index 000000000..cd22d3095 --- /dev/null +++ b/server/private/routers/customDnsRecord/updateCustomDnsRecord.ts @@ -0,0 +1,185 @@ +/* + * 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 } 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().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 { + 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, + value: existing.value, + 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, + updatedAt: Date.now() + }) + .where( + and( + eq(customDnsRecords.customDnsRecordId, customDnsRecordId), + eq(customDnsRecords.domainId, domainId) + ) + ) + .returning(); + + return response(res, { + data: 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") + ); + } +} diff --git a/server/private/routers/customDnsRecord/validation.ts b/server/private/routers/customDnsRecord/validation.ts new file mode 100644 index 000000000..8a7ce3c32 --- /dev/null +++ b/server/private/routers/customDnsRecord/validation.ts @@ -0,0 +1,459 @@ +/* + * 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 +} from "@server/routers/customDnsRecord/types"; + +export const MAX_CUSTOM_DNS_RECORDS_PER_DOMAIN = 100; +// Caps the size of 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_RECORDS_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), + value: z + .string() + .min(1) + .max(MAX_TXT_VALUE_LENGTH + 2), // + surrounding quotes + 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; + +export type NormalizedCustomDnsRecord = { + name: string; + fullDomain: string; + type: CustomDnsRecordType; + value: string; + ttl: number; + priority: number | null; + weight: number | null; + port: number | null; + enabled: boolean; +}; + +type Result = { 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 { + 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 } }; +} + +// Validates and normalizes a record's fields without touching the database. +export function normalizeCustomDnsRecord( + input: CustomDnsRecordInput, + baseDomain: string +): Result { + const nameResult = normalizeName(input.name, baseDomain); + if (!nameResult.ok) { + return nameResult; + } + const { name, fullDomain } = nameResult.value; + const isApex = name === "@"; + const isWildcard = name.startsWith("*"); + + let value: string; + let priority: number | null = null; + let weight: number | null = null; + let port: number | null = null; + + switch (input.type) { + case "A": + value = input.value.trim(); + if (!isIPv4(value)) { + return { + ok: false, + error: "A records require an IPv4 address" + }; + } + break; + case "AAAA": + value = input.value.trim().toLowerCase(); + if (!isIPv6(value)) { + return { + ok: false, + error: "AAAA records require an IPv6 address" + }; + } + break; + case "CNAME": + case "NS": + case "MX": + case "SRV": + value = normalizeHostname(input.value); + if (!isValidHostname(value)) { + return { + ok: false, + error: `${input.type} records require a fully qualified hostname as the value` + }; + } + break; + case "TXT": + value = input.value; + // 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" + }; + } + break; + } + + if (input.type === "CNAME") { + if (isApex) { + return { + ok: false, + error: "CNAME records cannot be added at the root of the domain" + }; + } + if (value === 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, + value, + 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 { + 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); + + if ( + excludeRecordId === undefined && + 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"; + } + + const sameRRset = sameName.filter((r) => r.type === type); + if (sameRRset.length >= MAX_CUSTOM_DNS_RECORDS_PER_RRSET) { + return `A name can have at most ${MAX_CUSTOM_DNS_RECORDS_PER_RRSET} ${type} records`; + } + if ( + sameRRset.some( + (r) => + r.value === record.value && + r.priority === record.priority && + r.weight === record.weight && + r.port === record.port + ) + ) { + return "An identical record already exists"; + } + + // 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; +} diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index cad76b80d..d8ad93259 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -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, diff --git a/server/private/routers/integration.ts b/server/private/routers/integration.ts index 9d92d3481..7742a5004 100644 --- a/server/private/routers/integration.ts +++ b/server/private/routers/integration.ts @@ -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, diff --git a/server/routers/customDnsRecord/types.ts b/server/routers/customDnsRecord/types.ts new file mode 100644 index 000000000..2ffdef01e --- /dev/null +++ b/server/routers/customDnsRecord/types.ts @@ -0,0 +1,33 @@ +export const CUSTOM_DNS_RECORD_TYPES = [ + "A", + "AAAA", + "CNAME", + "MX", + "NS", + "SRV", + "TXT" +] as const; + +export type CustomDnsRecordType = (typeof CUSTOM_DNS_RECORD_TYPES)[number]; + +export type CustomDnsRecordRow = { + customDnsRecordId: number; + domainId: string; + name: string; + fullDomain: string; + type: CustomDnsRecordType; + value: 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; diff --git a/src/components/CustomDnsRecordCredenza.tsx b/src/components/CustomDnsRecordCredenza.tsx new file mode 100644 index 000000000..486b6b7e0 --- /dev/null +++ b/src/components/CustomDnsRecordCredenza.tsx @@ -0,0 +1,370 @@ +"use client"; + +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import { Button } from "@app/components/ui/button"; +import { Input } from "@app/components/ui/input"; +import { Textarea } from "@app/components/ui/textarea"; +import { Switch } from "@app/components/ui/switch"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@app/components/ui/select"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { + CUSTOM_DNS_RECORD_TYPES, + CustomDnsRecordRow, + CustomDnsRecordType +} from "@server/routers/customDnsRecord/types"; +import { useTranslations } from "next-intl"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { zodResolver } from "@hookform/resolvers/zod"; + +const FORM_ID = "custom-dns-record-form"; + +const VALUE_PLACEHOLDERS: Record = { + A: "192.0.2.1", + AAAA: "2001:db8::1", + CNAME: "target.example.net", + MX: "mail.example.net", + NS: "ns1.example.net", + SRV: "sip.example.net", + TXT: "v=spf1 include:example.net ~all" +}; + +const optionalUint16 = z.number().int().min(0).max(65535).optional(); + +const formSchema = z.object({ + type: z.enum(CUSTOM_DNS_RECORD_TYPES), + name: z.string().max(253), + value: z.string().min(1), + ttl: z.number().int().min(60).max(86400), + priority: optionalUint16, + weight: optionalUint16, + port: optionalUint16, + enabled: z.boolean() +}); + +type FormValues = z.infer; + +type CustomDnsRecordCredenzaProps = { + open: boolean; + setOpen: (open: boolean) => void; + orgId: string; + domainId: string; + baseDomain: string; + // null to create a new record + record: CustomDnsRecordRow | null; + onSaved: () => void; +}; + +export default function CustomDnsRecordCredenza({ + open, + setOpen, + orgId, + domainId, + baseDomain, + record, + onSaved +}: CustomDnsRecordCredenzaProps) { + const t = useTranslations(); + const api = createApiClient(useEnvContext()); + const [loading, setLoading] = useState(false); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + type: "A", + name: "", + value: "", + ttl: 300, + enabled: true + } + }); + + useEffect(() => { + if (!open) { + return; + } + form.reset({ + type: record?.type ?? "A", + name: record?.name ?? "", + value: record?.value ?? "", + ttl: record?.ttl ?? 300, + priority: record?.priority ?? undefined, + weight: record?.weight ?? undefined, + port: record?.port ?? undefined, + enabled: record?.enabled ?? true + }); + }, [open, record, form]); + + const type = form.watch("type"); + const needsPriority = type === "MX" || type === "SRV"; + const isSrv = type === "SRV"; + + async function onSubmit(data: FormValues) { + setLoading(true); + const payload = { + type: data.type, + name: data.name, + value: data.value, + ttl: data.ttl, + priority: needsPriority ? (data.priority ?? null) : null, + weight: isSrv ? (data.weight ?? null) : null, + port: isSrv ? (data.port ?? null) : null, + enabled: data.enabled + }; + + try { + const res = record + ? await api.post( + `/org/${orgId}/domain/${domainId}/custom-dns-record/${record.customDnsRecordId}`, + payload + ) + : await api.put( + `/org/${orgId}/domain/${domainId}/custom-dns-record`, + payload + ); + + if (res.status === 200 || res.status === 201) { + toast({ + title: record + ? t("customDnsRecordUpdated") + : t("customDnsRecordCreated") + }); + setOpen(false); + onSaved(); + } + } catch (e) { + toast({ + variant: "destructive", + title: t("customDnsRecordSaveError"), + description: formatAxiosError(e) + }); + } finally { + setLoading(false); + } + } + + const numberField = ( + name: "ttl" | "priority" | "weight" | "port", + label: string + ) => ( + ( + + {label} + + { + const v = e.target.value; + field.onChange( + v === "" ? undefined : Number(v) + ); + }} + /> + + + + )} + /> + ); + + return ( + + + + + {record + ? t("customDnsRecordEdit") + : t("customDnsRecordCreate")} + + + {t("customDnsRecordFormDescription", { + domain: baseDomain + })} + + + +
+ + ( + + {t("type")} + + + + )} + /> + ( + + {t("name")} +
+ + + + + .{baseDomain} + +
+ + {t( + "customDnsRecordNameDescription" + )} + + +
+ )} + /> + ( + + {t("value")} + + {type === "TXT" ? ( +