diff --git a/messages/en-US.json b/messages/en-US.json index e7d3426dd..f6e74b900 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,27 @@ "TTL": "TTL", "howToAddRecords": "How to Add Records", "dnsRecord": "DNS Records", + "customDnsRecords": "Custom DNS Records", + "customDnsRecordsAdd": "Add Record", + "customDnsRecordsSearch": "Search records...", + "customDnsRecordsUnverifiedTitle": "Records are not being served yet", + "customDnsRecordsUnverifiedDescription": "This domain hasn't been verified. You can add records now, but Pangolin's nameservers won't serve them until the domain's nameservers point to Pangolin and verification completes.", + "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.", + "customDnsRecordValueDescription": "Enter one value per line, e.g. both nameservers of an NS record.", + "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..916caba14 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(), + values: text("values").notNull(), // JSON array of strings, one resource record each + ttl: integer("ttl").notNull().default(300), + priority: integer("priority"), // MX and SRV + weight: integer("weight"), // SRV + port: integer("port"), // SRV + enabled: boolean("enabled").notNull().default(true), + createdAt: bigint("createdAt", { mode: "number" }).notNull(), + updatedAt: bigint("updatedAt", { mode: "number" }).notNull() + }, + (t) => [ + index("idx_customDnsRecords_fullDomain").on(t.fullDomain), + index("idx_customDnsRecords_domainId").on(t.domainId) + ] +); + export const account = pgTable("account", { accountId: serial("accountId").primaryKey(), userId: varchar("userId") @@ -701,6 +731,7 @@ export type Approval = InferSelectModel; 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..d8fb70cdf 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(), + values: text("values").notNull(), // JSON array of strings, one resource record each + ttl: integer("ttl").notNull().default(300), + priority: integer("priority"), // MX and SRV + weight: integer("weight"), // SRV + port: integer("port"), // SRV + enabled: integer("enabled", { mode: "boolean" }) + .notNull() + .default(true), + createdAt: integer("createdAt").notNull(), + updatedAt: integer("updatedAt").notNull() + }, + (t) => [ + index("idx_customDnsRecords_fullDomain").on(t.fullDomain), + index("idx_customDnsRecords_domainId").on(t.domainId) + ] +); + export const account = sqliteTable("account", { accountId: integer("accountId").primaryKey({ autoIncrement: true }), userId: text("userId") @@ -700,6 +734,7 @@ export type Approval = InferSelectModel; 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..44d3b3721 100644 --- a/server/private/lib/dns/server.ts +++ b/server/private/lib/dns/server.ts @@ -30,12 +30,14 @@ import { sites, targets, dnsChallenge, - domains + domains, + customDnsRecords } from "@server/db"; import { privateConfig as config } from "#private/lib/config"; import * as dnsResolver from "dns"; import logger from "@server/logger"; import { listExitNodes } from "../exitNodes"; +import { parseCustomDnsRecordValues } from "@server/routers/customDnsRecord/types"; import { rateLimitService } from "../rateLimit"; import license from "#private/license/license"; @@ -50,7 +52,18 @@ type DNSRecord = { enabled: boolean; }; +type CustomRecord = { + type: string; + value: string; + ttl: number; + priority: number | null; + weight: number | null; + port: number | null; +}; + const DNS_CACHE_TTL_SECONDS = 300; +// Kept short so edits to custom records propagate quickly +const CUSTOM_RECORD_CACHE_TTL_SECONDS = 60; export class AuthoritativeDNSServer { private cache: LocalCache = createLocalCache(10_000, DNS_CACHE_TTL_SECONDS); @@ -162,6 +175,10 @@ export class AuthoritativeDNSServer { const originalQueryName = queryName; const queryType = question.type; let baseDomain: string | null; + // True when baseDomain is the customer zone the queried name itself + // lives in (not a synthetic cname-extension lookup), i.e. when + // user-defined custom records may apply. + let isCustomerZone = false; logger.info( `DNS Query: ${queryName} ${queryType} from ${rinfo.address}:${rinfo.port}` @@ -284,6 +301,34 @@ export class AuthoritativeDNSServer { this.sendResponse(packet, [], rinfo, false, 3, []); // NXDOMAIN rcode=3 - not authoritative return; } + isCustomerZone = true; + } + + // A subdomain the user delegated elsewhere with NS records is no + // longer ours to answer for - refer the resolver to its nameservers + // for the cut and everything beneath it, ahead of any other handling. + if (isCustomerZone) { + const delegation = await this.findDelegation(queryName, baseDomain); + if (delegation) { + logger.info( + `Referring ${originalQueryName} (${queryType}) to delegated zone ${delegation.cut}` + ); + const [authorities, additionals] = await this.buildReferral( + delegation, + baseDomain + ); + // Referrals are non-authoritative (AA=0) with no answers + this.sendResponse( + packet, + [], + rinfo, + false, + 0, + authorities, + additionals + ); + return; + } } // Special handling for _acme-challenge CNAME requests @@ -452,11 +497,42 @@ export class AuthoritativeDNSServer { return; } + // User-defined records come after everything Pangolin manages itself, + // so they can never shadow a resource, login page or redirect. + const customRecords = isCustomerZone + ? await this.getCustomRecords(queryName, baseDomain!) + : []; + + if (customRecords.length > 0) { + let matchingRecords = customRecords.filter( + (record) => record.type === queryType + ); + // A CNAME answers queries for every other type at its name + if (matchingRecords.length === 0 && queryType !== "CNAME") { + matchingRecords = customRecords.filter( + (record) => record.type === "CNAME" + ); + } + + if (matchingRecords.length > 0) { + const answers = this.convertCustomRecordsToAnswers( + matchingRecords, + originalQueryName + ); + logger.info( + `Returning ${answers.length} custom answers for ${originalQueryName} (${queryType})` + ); + this.sendResponse(packet, answers, rinfo, true, 0, []); + return; + } + } + // Check if the domain exists (for NODATA vs NXDOMAIN) - const [domainExists, soaRecord] = await Promise.all([ + const [resourceExists, soaRecord] = await Promise.all([ this.domainExists(queryName, baseDomain!), this.getSOARecord(baseDomain!) ]); + const domainExists = resourceExists || customRecords.length > 0; if (!domainExists) { // NXDOMAIN: Name does not exist @@ -1117,9 +1193,9 @@ export class AuthoritativeDNSServer { } case "MX": - // MX records need priority and exchange + // MX records need preference and exchange return { - priority: record.priority, + preference: record.priority, exchange: record.value }; @@ -1146,13 +1222,262 @@ export class AuthoritativeDNSServer { } } + // User-defined records for `name`. Only served when the record's domain + // is the zone this query resolved to and is a verified NS delegation, so + // records can never surface under another org's zone or a domain whose + // ownership hasn't been proven. An exact name match takes precedence over + // a wildcard (RFC 4592). + private async getCustomRecords( + name: string, + zone: string + ): Promise { + 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, + values: customDnsRecords.values, + ttl: customDnsRecords.ttl, + priority: customDnsRecords.priority, + weight: customDnsRecords.weight, + port: customDnsRecords.port + }) + .from(customDnsRecords) + .innerJoin( + domains, + eq(domains.domainId, customDnsRecords.domainId) + ) + .where( + and( + inArray(customDnsRecords.fullDomain, names), + eq(customDnsRecords.enabled, true), + eq(domains.baseDomain, zone), + eq(domains.type, "ns"), + eq(domains.verified, true) + ) + ); + + const exactRows = rows.filter( + (row) => row.fullDomain === normalized + ); + // Each stored value is served as its own resource record + const result: CustomRecord[] = ( + exactRows.length > 0 ? exactRows : rows + ).flatMap(({ fullDomain, values, ...record }) => + parseCustomDnsRecordValues(values).map((value) => ({ + ...record, + value + })) + ); + + this.cache.set( + cacheKey, + result, + CUSTOM_RECORD_CACHE_TTL_SECONDS + ); + return result; + } catch (error) { + logger.error("Error querying custom DNS records:", error); + return []; + } + }); + } + + // Finds the user-defined delegation (NS records on a subdomain) that + // `name` falls at or beneath within `zone`. When cuts are nested the + // topmost one wins, since everything below it belongs to the child zone. + // Subject to the same verified-NS-zone scoping as getCustomRecords. + private async findDelegation( + name: string, + zone: string + ): Promise<{ cut: string; nameservers: CustomRecord[] } | null> { + const normalized = name.replace(/\.$/, "").toLowerCase(); + if (!normalized.endsWith(`.${zone}`)) { + return null; // the apex itself is never delegated + } + + const cacheKey = `delegation:${zone}:${normalized}`; + const cached = this.cache.get<{ + cut: string; + nameservers: CustomRecord[]; + } | null>(cacheKey); + if (cached !== undefined) { + return cached; + } + + return this.withInFlightDedup(cacheKey, async () => { + try { + // Every ancestor of the name below the zone apex, plus the + // name itself + const labels = normalized.split("."); + const zoneLabelCount = zone.split(".").length; + const candidates: string[] = []; + for (let i = 0; i < labels.length - zoneLabelCount; i++) { + candidates.push(labels.slice(i).join(".")); + } + + const rows = await db + .select({ + fullDomain: customDnsRecords.fullDomain, + type: customDnsRecords.type, + values: customDnsRecords.values, + ttl: customDnsRecords.ttl, + priority: customDnsRecords.priority, + weight: customDnsRecords.weight, + port: customDnsRecords.port + }) + .from(customDnsRecords) + .innerJoin( + domains, + eq(domains.domainId, customDnsRecords.domainId) + ) + .where( + and( + inArray(customDnsRecords.fullDomain, candidates), + eq(customDnsRecords.type, "NS"), + eq(customDnsRecords.enabled, true), + eq(domains.baseDomain, zone), + eq(domains.type, "ns"), + eq(domains.verified, true) + ) + ); + + let result: { + cut: string; + nameservers: CustomRecord[]; + } | null = null; + + if (rows.length > 0) { + const cut = rows.reduce((a, b) => + a.fullDomain.length <= b.fullDomain.length ? a : b + ).fullDomain; + const nameservers = rows + .filter((row) => row.fullDomain === cut) + .flatMap(({ fullDomain, values, ...record }) => + parseCustomDnsRecordValues(values).map((value) => ({ + ...record, + value + })) + ); + if (nameservers.length > 0) { + result = { cut, nameservers }; + } + } + + this.cache.set( + cacheKey, + result, + CUSTOM_RECORD_CACHE_TTL_SECONDS + ); + return result; + } catch (error) { + logger.error("Error looking up DNS delegation:", error); + return null; + } + }); + } + + // Builds the authority (NS) and additional (glue) sections of a referral + // to a delegated subdomain. Glue is only needed - and only included - + // for nameservers named inside the delegated subdomain itself, which a + // resolver could not otherwise find the address of. + private async buildReferral( + delegation: { cut: string; nameservers: CustomRecord[] }, + zone: string + ): Promise<[dns.Answer[], dns.Answer[]]> { + const authorities = this.convertCustomRecordsToAnswers( + delegation.nameservers, + delegation.cut + ); + + const additionals: dns.Answer[] = []; + for (const nameserver of delegation.nameservers) { + const target = nameserver.value; + if ( + target !== delegation.cut && + !target.endsWith(`.${delegation.cut}`) + ) { + continue; + } + const glue = (await this.getCustomRecords(target, zone)).filter( + (record) => record.type === "A" || record.type === "AAAA" + ); + additionals.push( + ...this.convertCustomRecordsToAnswers(glue, target) + ); + } + + return [authorities, additionals]; + } + + private convertCustomRecordsToAnswers( + records: CustomRecord[], + queryName: string + ): dns.Answer[] { + return records.map((record) => { + let data: any; + switch (record.type) { + case "MX": + data = { + preference: record.priority ?? 0, + exchange: record.value + }; + break; + case "SRV": + data = { + priority: record.priority ?? 0, + weight: record.weight ?? 0, + port: record.port ?? 0, + target: record.value + }; + break; + case "TXT": { + // A single TXT character-string is at most 255 bytes; + // longer values are split across several strings, which + // clients concatenate. Values are printable ASCII only. + const chunks: string[] = []; + for (let i = 0; i < record.value.length; i += 255) { + chunks.push(record.value.slice(i, i + 255)); + } + data = chunks; + break; + } + default: + data = record.value; + } + + return { + name: queryName, + type: record.type, + ttl: record.ttl, + data + } as dns.Answer; + }); + } + private sendResponse( originalPacket: dns.Packet, answers: dns.Answer[], rinfo: dgram.RemoteInfo, authoritative: boolean = true, rcode: number = 0, - authorities: dns.Answer[] = [] + authorities: dns.Answer[] = [], + additionals: dns.Answer[] = [] ): void { const response: dns.Packet = { id: originalPacket.id, @@ -1161,7 +1486,7 @@ export class AuthoritativeDNSServer { questions: originalPacket.questions, answers: answers, authorities: authorities, - additionals: [] + additionals: additionals }; // Set authoritative flag if this is an authoritative response 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..eb56bc98f --- /dev/null +++ b/server/private/routers/customDnsRecord/createCustomDnsRecord.ts @@ -0,0 +1,155 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, customDnsRecords } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { + CreateOrUpdateCustomDnsRecordResponse, + toCustomDnsRecordRow +} from "@server/routers/customDnsRecord/types"; +import { + checkCustomDnsRecordConflicts, + customDnsRecordFieldsSchema, + getManageableDomain, + normalizeCustomDnsRecord +} from "./validation"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty(), + domainId: z.string().nonempty() +}); + +const bodySchema = customDnsRecordFieldsSchema.extend({ + ttl: customDnsRecordFieldsSchema.shape.ttl.optional().default(300), + enabled: z.boolean().optional().default(true) +}); + +registry.registerPath({ + method: "put", + path: "/org/{orgId}/domain/{domainId}/custom-dns-record", + description: + "Create a custom DNS record served by Pangolin's nameservers for a domain. Each entry in `values` is served as a separate resource record (e.g. both nameservers of an NS record).", + tags: [OpenAPITags.Domain], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + +export async function createCustomDnsRecord( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, domainId } = parsedParams.data; + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const domain = await getManageableDomain(orgId, domainId); + if (!domain.ok) { + return next(createHttpError(domain.status, domain.error)); + } + + const normalized = normalizeCustomDnsRecord( + parsedBody.data, + domain.baseDomain + ); + if (!normalized.ok) { + return next( + createHttpError(HttpCode.BAD_REQUEST, normalized.error) + ); + } + + const conflict = await checkCustomDnsRecordConflicts( + domainId, + domain.baseDomain, + normalized.value + ); + if (conflict) { + return next(createHttpError(HttpCode.CONFLICT, conflict)); + } + + const now = Date.now(); + const [record] = await db + .insert(customDnsRecords) + .values({ + domainId, + ...normalized.value, + values: JSON.stringify(normalized.value.values), + createdAt: now, + updatedAt: now + }) + .returning(); + + return response(res, { + data: toCustomDnsRecordRow(record), + success: true, + error: false, + message: "Custom DNS record created successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} 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..1ac55cc21 --- /dev/null +++ b/server/private/routers/customDnsRecord/listCustomDnsRecords.ts @@ -0,0 +1,115 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, customDnsRecords, orgDomains } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { and, asc, eq } from "drizzle-orm"; +import { + ListCustomDnsRecordsResponse, + toCustomDnsRecordRow +} from "@server/routers/customDnsRecord/types"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty(), + domainId: z.string().nonempty() +}); + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/domain/{domainId}/custom-dns-records", + description: + "List the custom DNS records served by Pangolin's nameservers for a domain.", + tags: [OpenAPITags.Domain], + request: { + params: paramsSchema + }, + responses: { + 200: { + description: "Successful response", + content: { + "application/json": { + schema: z.object({ + data: z.record(z.string(), z.any()).nullable(), + success: z.boolean(), + error: z.boolean(), + message: z.string(), + status: z.number() + }) + } + } + } + } +}); + +export async function listCustomDnsRecords( + req: Request, + res: Response, + next: NextFunction +): Promise { + 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) => toCustomDnsRecordRow(r.record)) + }, + success: true, + error: false, + message: "Custom DNS records retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/customDnsRecord/updateCustomDnsRecord.ts b/server/private/routers/customDnsRecord/updateCustomDnsRecord.ts new file mode 100644 index 000000000..1f151d0f4 --- /dev/null +++ b/server/private/routers/customDnsRecord/updateCustomDnsRecord.ts @@ -0,0 +1,190 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, customDnsRecords } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { and, eq } from "drizzle-orm"; +import { + CreateOrUpdateCustomDnsRecordResponse, + parseCustomDnsRecordValues, + toCustomDnsRecordRow +} from "@server/routers/customDnsRecord/types"; +import { + checkCustomDnsRecordConflicts, + customDnsRecordFieldsSchema, + getManageableDomain, + normalizeCustomDnsRecord +} from "./validation"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty(), + domainId: z.string().nonempty(), + customDnsRecordId: z.coerce.number().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, + values: parseCustomDnsRecordValues(existing.values), + ttl: existing.ttl, + priority: existing.priority, + weight: existing.weight, + port: existing.port, + enabled: existing.enabled, + ...parsedBody.data + }, + domain.baseDomain + ); + if (!normalized.ok) { + return next( + createHttpError(HttpCode.BAD_REQUEST, normalized.error) + ); + } + + const conflict = await checkCustomDnsRecordConflicts( + domainId, + domain.baseDomain, + normalized.value, + customDnsRecordId + ); + if (conflict) { + return next(createHttpError(HttpCode.CONFLICT, conflict)); + } + + const [record] = await db + .update(customDnsRecords) + .set({ + ...normalized.value, + values: JSON.stringify(normalized.value.values), + updatedAt: Date.now() + }) + .where( + and( + eq(customDnsRecords.customDnsRecordId, customDnsRecordId), + eq(customDnsRecords.domainId, domainId) + ) + ) + .returning(); + + return response(res, { + data: toCustomDnsRecordRow(record), + success: true, + error: false, + message: "Custom DNS record updated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/customDnsRecord/validation.ts b/server/private/routers/customDnsRecord/validation.ts new file mode 100644 index 000000000..ceabbd2d6 --- /dev/null +++ b/server/private/routers/customDnsRecord/validation.ts @@ -0,0 +1,577 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025-2026 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { isIPv4, isIPv6 } from "net"; +import { z } from "zod"; +import { + db, + customDnsRecords, + domains, + domainNamespaces, + loginPage, + orgDomains, + redirects, + resources +} from "@server/db"; +import { and, eq, inArray, isNull } from "drizzle-orm"; +import HttpCode from "@server/types/HttpCode"; +import { privateConfig as config } from "#private/lib/config"; +import { + CUSTOM_DNS_RECORD_TYPES, + CustomDnsRecordType, + parseCustomDnsRecordValues +} from "@server/routers/customDnsRecord/types"; + +export const MAX_CUSTOM_DNS_RECORDS_PER_DOMAIN = 100; +// Caps the number of values (resource records) any single RRset the DNS +// server can be made to return, which bounds how much a spoofed UDP query +// can be amplified. +export const MAX_CUSTOM_DNS_VALUES_PER_RRSET = 10; +export const MAX_TXT_VALUE_LENGTH = 1024; +export const MIN_TTL = 60; +export const MAX_TTL = 86400; + +// Underscores are allowed so service labels like _dmarc, _sip._tcp and +// selector._domainkey can be used. +const LABEL_REGEX = /^[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?$/; +// Printable ASCII only - no control characters or non-ASCII bytes in TXT data. +const TXT_REGEX = /^[\x20-\x7e]+$/; + +const uint16 = z.number().int().min(0).max(65535); + +export const customDnsRecordFieldsSchema = z.strictObject({ + name: z.string().max(253), + type: z.enum(CUSTOM_DNS_RECORD_TYPES), + values: z + .array( + z + .string() + .min(1) + .max(MAX_TXT_VALUE_LENGTH + 2) // + surrounding quotes + ) + .min(1) + .max(MAX_CUSTOM_DNS_VALUES_PER_RRSET), + ttl: z.number().int().min(MIN_TTL).max(MAX_TTL), + priority: uint16.nullable().optional(), + weight: uint16.nullable().optional(), + port: uint16.nullable().optional(), + enabled: z.boolean() +}); + +export type CustomDnsRecordInput = z.infer; + +export type NormalizedCustomDnsRecord = { + name: string; + fullDomain: string; + type: CustomDnsRecordType; + values: string[]; + ttl: number; + priority: number | null; + weight: number | null; + port: number | null; + enabled: boolean; +}; + +type Result = { 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 } }; +} + +function normalizeValue( + type: CustomDnsRecordType, + rawValue: string +): Result { + switch (type) { + case "A": { + const value = rawValue.trim(); + if (!isIPv4(value)) { + return { + ok: false, + error: `"${value}" is not an IPv4 address` + }; + } + return { ok: true, value }; + } + case "AAAA": { + const value = rawValue.trim().toLowerCase(); + if (!isIPv6(value)) { + return { + ok: false, + error: `"${value}" is not an IPv6 address` + }; + } + return { ok: true, value }; + } + case "CNAME": + case "NS": + case "MX": + case "SRV": { + const value = normalizeHostname(rawValue); + if (!isValidHostname(value)) { + return { + ok: false, + error: `${type} records require fully qualified hostnames as values` + }; + } + return { ok: true, value }; + } + case "TXT": { + let value = rawValue; + // Strip one pair of surrounding quotes, as commonly pasted from + // zone files and provider instructions + if ( + value.length >= 2 && + value.startsWith('"') && + value.endsWith('"') + ) { + value = value.slice(1, -1); + } + if (value.length === 0 || value.length > MAX_TXT_VALUE_LENGTH) { + return { + ok: false, + error: `TXT values must be between 1 and ${MAX_TXT_VALUE_LENGTH} characters` + }; + } + if (!TXT_REGEX.test(value)) { + return { + ok: false, + error: "TXT values may only contain printable ASCII characters" + }; + } + return { ok: true, value }; + } + } +} + +// Validates and normalizes a record's fields without touching the database. +export function normalizeCustomDnsRecord( + input: CustomDnsRecordInput, + baseDomain: string +): Result { + const nameResult = normalizeName(input.name, baseDomain); + if (!nameResult.ok) { + return nameResult; + } + const { name, fullDomain } = nameResult.value; + const isApex = name === "@"; + const isWildcard = name.startsWith("*"); + + let priority: number | null = null; + let weight: number | null = null; + let port: number | null = null; + + const values: string[] = []; + for (const rawValue of input.values) { + const valueResult = normalizeValue(input.type, rawValue); + if (!valueResult.ok) { + return valueResult; + } + if (values.includes(valueResult.value)) { + return { + ok: false, + error: `Duplicate value "${valueResult.value}"` + }; + } + values.push(valueResult.value); + } + + if (input.type === "CNAME") { + if (isApex) { + return { + ok: false, + error: "CNAME records cannot be added at the root of the domain" + }; + } + if (values.length > 1) { + return { + ok: false, + error: "A CNAME record can only have one value" + }; + } + if (values[0] === fullDomain) { + return { + ok: false, + error: "A CNAME record cannot point to itself" + }; + } + } + + if (input.type === "NS" && (isApex || isWildcard)) { + return { + ok: false, + error: "NS records can only be added for a specific subdomain, not the root or a wildcard" + }; + } + + if (input.type === "MX" || input.type === "SRV") { + if (input.priority == null) { + return { + ok: false, + error: `${input.type} records require a priority` + }; + } + priority = input.priority; + } + + if (input.type === "SRV") { + if (input.weight == null || input.port == null) { + return { + ok: false, + error: "SRV records require a weight and a port" + }; + } + weight = input.weight; + port = input.port; + } + + return { + ok: true, + value: { + name, + fullDomain, + type: input.type, + values, + ttl: input.ttl, + priority, + weight, + port, + enabled: input.enabled + } + }; +} + +// Checks a normalized record against everything else that lives in DNS: +// infrastructure zones, other customers' zones, Pangolin-managed names and +// the domain's other custom records. Returns an error message, or null. +export async function checkCustomDnsRecordConflicts( + domainId: string, + baseDomain: string, + record: NormalizedCustomDnsRecord, + excludeRecordId?: number +): Promise { + const { fullDomain, type } = record; + + // Names the DNS server answers for itself (nameservers, cname/site + // extensions, configured zones) are never user-writable. + const dnsConfig = config.getRawConfig().dns; + const reservedZones = [ + dnsConfig?.nameserver_name, + dnsConfig?.cname_extension, + dnsConfig?.site_extension, + ...(dnsConfig?.cname_alternate_extensions ?? []), + ...(dnsConfig?.zones ?? []) + ].filter((z): z is string => !!z); + if (reservedZones.some((zone) => isAtOrBelow(fullDomain, zone))) { + return "This name is reserved and cannot be used"; + } + + // A name inside a more specific domain (e.g. sub.example.com added to + // Pangolin separately, possibly by another org) belongs to that zone, + // not this one. + const labels = fullDomain.split("."); + const baseLabelCount = baseDomain.split(".").length; + const childZoneCandidates: string[] = []; + for (let i = 0; i < labels.length - baseLabelCount; i++) { + childZoneCandidates.push(labels.slice(i).join(".")); + } + if (childZoneCandidates.length > 0) { + const [childZone] = await db + .select({ domainId: domains.domainId }) + .from(domains) + .where(inArray(domains.baseDomain, childZoneCandidates)) + .limit(1); + if (childZone) { + return "This name belongs to a different domain in Pangolin"; + } + } + + const existing = ( + await db + .select() + .from(customDnsRecords) + .where(eq(customDnsRecords.domainId, domainId)) + ).filter((r) => r.customDnsRecordId !== excludeRecordId); + + // existing excludes the record being updated, so this only trips when + // adding a new record would go over the limit + if (existing.length >= MAX_CUSTOM_DNS_RECORDS_PER_DOMAIN) { + return `A domain can have at most ${MAX_CUSTOM_DNS_RECORDS_PER_DOMAIN} custom DNS records`; + } + + const sameName = existing.filter((r) => r.fullDomain === fullDomain); + + if (type === "CNAME" && sameName.length > 0) { + return "A CNAME record cannot be added to a name that already has other records"; + } + if (type !== "CNAME" && sameName.some((r) => r.type === "CNAME")) { + return "This name already has a CNAME record, which cannot be combined with other records"; + } + + // One record per name and type (per priority/weight/port for MX and + // SRV) - more values go into that record rather than a second one, so + // the table shows the whole set in one place. + const sameRRset = sameName.filter((r) => r.type === type); + if ( + sameRRset.some( + (r) => + r.priority === record.priority && + r.weight === record.weight && + r.port === record.port + ) + ) { + return `A ${type} record for this name already exists. Edit it to add more values.`; + } + + // MX and SRV can still be split across several records (one per + // priority), so cap the values across all of them + const totalValues = + record.values.length + + sameRRset.reduce( + (sum, r) => sum + parseCustomDnsRecordValues(r.values).length, + 0 + ); + if (totalValues > MAX_CUSTOM_DNS_VALUES_PER_RRSET) { + return `A name can have at most ${MAX_CUSTOM_DNS_VALUES_PER_RRSET} ${type} values`; + } + + // Delegations: a subdomain with NS records is handed off to other + // nameservers, and the DNS server refers every query at or beneath it + // there. Nothing else may live at or beneath it except A/AAAA glue for + // nameservers named inside it, or it would silently stop resolving. + const isStrictlyBelow = (name: string, parent: string) => + name.endsWith(`.${parent}`); + const isGlue = + (type === "A" || type === "AAAA") && !record.name.startsWith("*"); + + const enclosingCut = existing.find( + (r) => r.type === "NS" && isStrictlyBelow(fullDomain, r.fullDomain) + ); + if (enclosingCut) { + if (type === "NS") { + return `This name is inside the delegated subdomain ${enclosingCut.fullDomain}, so it cannot be delegated again`; + } + if (!isGlue) { + return `This name is inside the delegated subdomain ${enclosingCut.fullDomain}, where only A and AAAA records for its nameservers can be added`; + } + } + + if (type === "NS") { + if (sameName.some((r) => r.type !== "NS")) { + return "A delegated subdomain can only have NS records. Remove the other records for this name first."; + } + if ( + existing.some( + (r) => + isStrictlyBelow(r.fullDomain, fullDomain) && + ((r.type !== "A" && r.type !== "AAAA") || + r.name.startsWith("*")) + ) + ) { + return "Records exist beneath this name that would stop resolving once it is delegated. Remove them first."; + } + + const isAtOrBelowCut = (name: string | null) => + !!name && + (name.toLowerCase() === fullDomain || + isStrictlyBelow(name.toLowerCase(), fullDomain)); + const resourceNames = await db + .select({ fullDomain: resources.fullDomain }) + .from(resources) + .where(eq(resources.domainId, domainId)); + const pageNames = await db + .select({ fullDomain: loginPage.fullDomain }) + .from(loginPage) + .where(eq(loginPage.domainId, domainId)); + const redirectNames = ( + await db + .select({ subdomain: redirects.subdomain }) + .from(redirects) + .where( + and( + eq(redirects.domainId, domainId), + isNull(redirects.resourceId) + ) + ) + ).map((r) => (r.subdomain ? `${r.subdomain}.${baseDomain}` : null)); + + if ( + [...resourceNames, ...pageNames].some((r) => + isAtOrBelowCut(r.fullDomain) + ) || + redirectNames.some(isAtOrBelowCut) + ) { + return "Pangolin resources use names at or beneath this subdomain, so it cannot be delegated"; + } + } else if (sameName.some((r) => r.type === "NS")) { + return "This name is delegated with NS records, so other records cannot be added to it"; + } + + // Pangolin answers address queries for resources, login pages and + // redirects itself, so a custom A/AAAA/CNAME there would never be served + // (or, for CNAME, would conflict with the managed addresses). + if (type === "A" || type === "AAAA" || type === "CNAME") { + const [resource] = await db + .select({ resourceId: resources.resourceId }) + .from(resources) + .where(eq(resources.fullDomain, fullDomain)) + .limit(1); + const [page] = await db + .select({ loginPageId: loginPage.loginPageId }) + .from(loginPage) + .where(eq(loginPage.fullDomain, fullDomain)) + .limit(1); + const [redirect] = await db + .select({ redirectId: redirects.redirectId }) + .from(redirects) + .where( + and( + eq(redirects.domainId, domainId), + record.name === "@" + ? isNull(redirects.subdomain) + : eq(redirects.subdomain, record.name) + ) + ) + .limit(1); + + if (resource || page || redirect) { + return `This name is already in use by Pangolin, so ${type} records cannot be added to it`; + } + } + + return null; +} 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..f9177f2de --- /dev/null +++ b/server/routers/customDnsRecord/types.ts @@ -0,0 +1,54 @@ +export const CUSTOM_DNS_RECORD_TYPES = [ + "A", + "AAAA", + "CNAME", + "MX", + "NS", + "SRV", + "TXT" +] as const; + +export type CustomDnsRecordType = (typeof CUSTOM_DNS_RECORD_TYPES)[number]; + +// One record as the user sees it: a name, type and settings with one or more +// values (e.g. both nameservers of an NS record). The DNS server answers with +// one resource record per value. +export type CustomDnsRecordRow = { + customDnsRecordId: number; + domainId: string; + name: string; + fullDomain: string; + type: CustomDnsRecordType; + values: string[]; + ttl: number; + priority: number | null; + weight: number | null; + port: number | null; + enabled: boolean; + createdAt: number; + updatedAt: number; +}; + +export type ListCustomDnsRecordsResponse = { + records: CustomDnsRecordRow[]; +}; + +export type CreateOrUpdateCustomDnsRecordResponse = CustomDnsRecordRow; + +// The values column holds a JSON-encoded string array +export function parseCustomDnsRecordValues(raw: string): string[] { + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) + ? parsed.filter((v): v is string => typeof v === "string") + : []; + } catch { + return []; + } +} + +export function toCustomDnsRecordRow( + row: Omit & { values: string } +): CustomDnsRecordRow { + return { ...row, values: parseCustomDnsRecordValues(row.values) }; +} diff --git a/src/components/CustomDnsRecordCredenza.tsx b/src/components/CustomDnsRecordCredenza.tsx new file mode 100644 index 000000000..f680a01e8 --- /dev/null +++ b/src/components/CustomDnsRecordCredenza.tsx @@ -0,0 +1,383 @@ +"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?.values.join("\n") ?? "", + 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); + // Each non-empty line is one value of the record + const values = data.value + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); + + const payload = { + type: data.type, + name: data.name, + values, + 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 !== "CNAME" ? ( +