diff --git a/messages/en-US.json b/messages/en-US.json index 7231bd0d7..f6e74b900 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3744,11 +3744,13 @@ "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 to add multiple records with this name, e.g. two nameservers.", + "customDnsRecordValueDescription": "Enter one value per line, e.g. both nameservers of an NS record.", "customDnsRecordPriority": "Priority", "customDnsRecordWeight": "Weight", "customDnsRecordPort": "Port", diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index cd3b2cc26..916caba14 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -56,7 +56,7 @@ export const customDnsRecords = pgTable( type: varchar("type", { length: 10 }) .$type<"A" | "AAAA" | "CNAME" | "MX" | "NS" | "SRV" | "TXT">() .notNull(), - value: text("value").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 diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index 4f8db913c..d8fb70cdf 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -54,7 +54,7 @@ export const customDnsRecords = sqliteTable( type: text("type") .$type<"A" | "AAAA" | "CNAME" | "MX" | "NS" | "SRV" | "TXT">() .notNull(), - value: text("value").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 diff --git a/server/private/lib/dns/server.ts b/server/private/lib/dns/server.ts index 34cc0860b..44d3b3721 100644 --- a/server/private/lib/dns/server.ts +++ b/server/private/lib/dns/server.ts @@ -37,6 +37,7 @@ 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"; @@ -303,6 +304,33 @@ export class AuthoritativeDNSServer { 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 if ( (queryType === "TXT" || queryType === "CNAME") && @@ -355,21 +383,6 @@ 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) => ({ @@ -1238,7 +1251,7 @@ export class AuthoritativeDNSServer { .select({ fullDomain: customDnsRecords.fullDomain, type: customDnsRecords.type, - value: customDnsRecords.value, + values: customDnsRecords.values, ttl: customDnsRecords.ttl, priority: customDnsRecords.priority, weight: customDnsRecords.weight, @@ -1262,9 +1275,15 @@ export class AuthoritativeDNSServer { 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 - ).map(({ fullDomain, ...record }) => record); + ).flatMap(({ fullDomain, values, ...record }) => + parseCustomDnsRecordValues(values).map((value) => ({ + ...record, + value + })) + ); this.cache.set( cacheKey, @@ -1279,6 +1298,133 @@ export class AuthoritativeDNSServer { }); } + // 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 @@ -1330,7 +1476,8 @@ export class AuthoritativeDNSServer { 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, @@ -1339,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/customDnsRecord/createCustomDnsRecord.ts b/server/private/routers/customDnsRecord/createCustomDnsRecord.ts index 0db8e04b9..eb56bc98f 100644 --- a/server/private/routers/customDnsRecord/createCustomDnsRecord.ts +++ b/server/private/routers/customDnsRecord/createCustomDnsRecord.ts @@ -20,14 +20,14 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -import { CreateCustomDnsRecordsResponse } from "@server/routers/customDnsRecord/types"; +import { + CreateOrUpdateCustomDnsRecordResponse, + toCustomDnsRecordRow +} from "@server/routers/customDnsRecord/types"; import { checkCustomDnsRecordConflicts, customDnsRecordFieldsSchema, - customDnsRecordValueSchema, getManageableDomain, - MAX_CUSTOM_DNS_RECORDS_PER_RRSET, - NormalizedCustomDnsRecord, normalizeCustomDnsRecord } from "./validation"; @@ -36,31 +36,16 @@ const paramsSchema = z.strictObject({ domainId: z.string().nonempty() }); -// Either `value` for a single record, or `values` to create several records -// with the same name and settings at once (e.g. a pair of NS records). -const bodySchema = customDnsRecordFieldsSchema - .extend({ - value: customDnsRecordValueSchema.optional(), - values: z - .array(customDnsRecordValueSchema) - .min(1) - .max(MAX_CUSTOM_DNS_RECORDS_PER_RRSET) - .optional(), - ttl: customDnsRecordFieldsSchema.shape.ttl.optional().default(300), - enabled: z.boolean().optional().default(true) - }) - .refine( - (body) => (body.value === undefined) !== (body.values === undefined), - { - message: "Exactly one of value or values must be provided" - } - ); +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 one or more custom DNS records served by Pangolin's nameservers for a domain. Pass `values` instead of `value` to create several records with the same name and settings.", + "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, @@ -123,57 +108,42 @@ export async function createCustomDnsRecord( return next(createHttpError(domain.status, domain.error)); } - const { value, values, ...fields } = parsedBody.data; - - // Validate every record (against the existing ones and each other) - // before inserting any, so a bad value doesn't leave a partial batch. - const toInsert: NormalizedCustomDnsRecord[] = []; - for (const recordValue of values ?? [value!]) { - const normalized = normalizeCustomDnsRecord( - { ...fields, value: recordValue }, - domain.baseDomain + const normalized = normalizeCustomDnsRecord( + parsedBody.data, + domain.baseDomain + ); + if (!normalized.ok) { + return next( + createHttpError(HttpCode.BAD_REQUEST, normalized.error) ); - if (!normalized.ok) { - return next( - createHttpError(HttpCode.BAD_REQUEST, normalized.error) - ); - } + } - const conflict = await checkCustomDnsRecordConflicts( - domainId, - domain.baseDomain, - normalized.value, - undefined, - toInsert - ); - if (conflict) { - return next(createHttpError(HttpCode.CONFLICT, conflict)); - } - - toInsert.push(normalized.value); + const conflict = await checkCustomDnsRecordConflicts( + domainId, + domain.baseDomain, + normalized.value + ); + if (conflict) { + return next(createHttpError(HttpCode.CONFLICT, conflict)); } const now = Date.now(); - const records = await db + const [record] = await db .insert(customDnsRecords) - .values( - toInsert.map((record) => ({ - domainId, - ...record, - createdAt: now, - updatedAt: now - })) - ) + .values({ + domainId, + ...normalized.value, + values: JSON.stringify(normalized.value.values), + createdAt: now, + updatedAt: now + }) .returning(); - return response(res, { - data: { records }, + return response(res, { + data: toCustomDnsRecordRow(record), success: true, error: false, - message: - records.length === 1 - ? "Custom DNS record created successfully" - : "Custom DNS records created successfully", + message: "Custom DNS record created successfully", status: HttpCode.CREATED }); } catch (error) { diff --git a/server/private/routers/customDnsRecord/listCustomDnsRecords.ts b/server/private/routers/customDnsRecord/listCustomDnsRecords.ts index ea2244bc7..1ac55cc21 100644 --- a/server/private/routers/customDnsRecord/listCustomDnsRecords.ts +++ b/server/private/routers/customDnsRecord/listCustomDnsRecords.ts @@ -21,7 +21,10 @@ 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"; +import { + ListCustomDnsRecordsResponse, + toCustomDnsRecordRow +} from "@server/routers/customDnsRecord/types"; const paramsSchema = z.strictObject({ orgId: z.string().nonempty(), @@ -96,7 +99,7 @@ export async function listCustomDnsRecords( return response(res, { data: { - records: rows.map((r) => r.record) + records: rows.map((r) => toCustomDnsRecordRow(r.record)) }, success: true, error: false, diff --git a/server/private/routers/customDnsRecord/updateCustomDnsRecord.ts b/server/private/routers/customDnsRecord/updateCustomDnsRecord.ts index 549f3a15c..1f151d0f4 100644 --- a/server/private/routers/customDnsRecord/updateCustomDnsRecord.ts +++ b/server/private/routers/customDnsRecord/updateCustomDnsRecord.ts @@ -21,7 +21,11 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { and, eq } from "drizzle-orm"; -import { UpdateCustomDnsRecordResponse } from "@server/routers/customDnsRecord/types"; +import { + CreateOrUpdateCustomDnsRecordResponse, + parseCustomDnsRecordValues, + toCustomDnsRecordRow +} from "@server/routers/customDnsRecord/types"; import { checkCustomDnsRecordConflicts, customDnsRecordFieldsSchema, @@ -129,7 +133,7 @@ export async function updateCustomDnsRecord( { name: existing.name, type: existing.type, - value: existing.value, + values: parseCustomDnsRecordValues(existing.values), ttl: existing.ttl, priority: existing.priority, weight: existing.weight, @@ -159,6 +163,7 @@ export async function updateCustomDnsRecord( .update(customDnsRecords) .set({ ...normalized.value, + values: JSON.stringify(normalized.value.values), updatedAt: Date.now() }) .where( @@ -169,8 +174,8 @@ export async function updateCustomDnsRecord( ) .returning(); - return response(res, { - data: record, + return response(res, { + data: toCustomDnsRecordRow(record), success: true, error: false, message: "Custom DNS record updated successfully", diff --git a/server/private/routers/customDnsRecord/validation.ts b/server/private/routers/customDnsRecord/validation.ts index ee7fee4a1..ceabbd2d6 100644 --- a/server/private/routers/customDnsRecord/validation.ts +++ b/server/private/routers/customDnsRecord/validation.ts @@ -28,13 +28,15 @@ import HttpCode from "@server/types/HttpCode"; import { privateConfig as config } from "#private/lib/config"; import { CUSTOM_DNS_RECORD_TYPES, - CustomDnsRecordType + CustomDnsRecordType, + parseCustomDnsRecordValues } 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; +// 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; @@ -47,15 +49,18 @@ const TXT_REGEX = /^[\x20-\x7e]+$/; const uint16 = z.number().int().min(0).max(65535); -export const customDnsRecordValueSchema = z - .string() - .min(1) - .max(MAX_TXT_VALUE_LENGTH + 2); // + surrounding quotes - export const customDnsRecordFieldsSchema = z.strictObject({ name: z.string().max(253), type: z.enum(CUSTOM_DNS_RECORD_TYPES), - value: customDnsRecordValueSchema, + 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(), @@ -69,7 +74,7 @@ export type NormalizedCustomDnsRecord = { name: string; fullDomain: string; type: CustomDnsRecordType; - value: string; + values: string[]; ttl: number; priority: number | null; weight: number | null; @@ -207,57 +212,46 @@ function normalizeName( 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(); +function normalizeValue( + type: CustomDnsRecordType, + rawValue: string +): Result { + switch (type) { + case "A": { + const value = rawValue.trim(); if (!isIPv4(value)) { return { ok: false, - error: "A records require an IPv4 address" + error: `"${value}" is not an IPv4 address` }; } - break; - case "AAAA": - value = input.value.trim().toLowerCase(); + return { ok: true, value }; + } + case "AAAA": { + const value = rawValue.trim().toLowerCase(); if (!isIPv6(value)) { return { ok: false, - error: "AAAA records require an IPv6 address" + error: `"${value}" is not an IPv6 address` }; } - break; + return { ok: true, value }; + } case "CNAME": case "NS": case "MX": - case "SRV": - value = normalizeHostname(input.value); + case "SRV": { + const value = normalizeHostname(rawValue); if (!isValidHostname(value)) { return { ok: false, - error: `${input.type} records require a fully qualified hostname as the value` + error: `${type} records require fully qualified hostnames as values` }; } - break; - case "TXT": - value = input.value; + 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 ( @@ -279,7 +273,41 @@ export function normalizeCustomDnsRecord( error: "TXT values may only contain printable ASCII characters" }; } - break; + 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") { @@ -289,7 +317,13 @@ export function normalizeCustomDnsRecord( error: "CNAME records cannot be added at the root of the domain" }; } - if (value === fullDomain) { + 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" @@ -331,7 +365,7 @@ export function normalizeCustomDnsRecord( name, fullDomain, type: input.type, - value, + values, ttl: input.ttl, priority, weight, @@ -343,15 +377,12 @@ export function normalizeCustomDnsRecord( // 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. `pending` holds records being created -// alongside this one in the same request, which count as existing. Returns -// an error message, or null. +// the domain's other custom records. Returns an error message, or null. export async function checkCustomDnsRecordConflicts( domainId: string, baseDomain: string, record: NormalizedCustomDnsRecord, - excludeRecordId?: number, - pending: NormalizedCustomDnsRecord[] = [] + excludeRecordId?: number ): Promise { const { fullDomain, type } = record; @@ -389,20 +420,16 @@ export async function checkCustomDnsRecordConflicts( } } - const existing = [ - ...( - await db - .select() - .from(customDnsRecords) - .where(eq(customDnsRecords.domainId, domainId)) - ).filter((r) => r.customDnsRecordId !== excludeRecordId), - ...pending - ]; + 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 - ) { + // 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`; } @@ -415,20 +442,103 @@ export async function checkCustomDnsRecordConflicts( 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.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"; + 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 diff --git a/server/routers/customDnsRecord/types.ts b/server/routers/customDnsRecord/types.ts index 3d0537ecc..f9177f2de 100644 --- a/server/routers/customDnsRecord/types.ts +++ b/server/routers/customDnsRecord/types.ts @@ -10,13 +10,16 @@ export const CUSTOM_DNS_RECORD_TYPES = [ 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; - value: string; + values: string[]; ttl: number; priority: number | null; weight: number | null; @@ -30,8 +33,22 @@ export type ListCustomDnsRecordsResponse = { records: CustomDnsRecordRow[]; }; -export type CreateCustomDnsRecordsResponse = { - records: CustomDnsRecordRow[]; -}; +export type CreateOrUpdateCustomDnsRecordResponse = CustomDnsRecordRow; -export type UpdateCustomDnsRecordResponse = 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 index 1d90a50c1..f680a01e8 100644 --- a/src/components/CustomDnsRecordCredenza.tsx +++ b/src/components/CustomDnsRecordCredenza.tsx @@ -113,7 +113,7 @@ export default function CustomDnsRecordCredenza({ form.reset({ type: record?.type ?? "A", name: record?.name ?? "", - value: record?.value ?? "", + value: record?.values.join("\n") ?? "", ttl: record?.ttl ?? 300, priority: record?.priority ?? undefined, weight: record?.weight ?? undefined, @@ -128,7 +128,7 @@ export default function CustomDnsRecordCredenza({ async function onSubmit(data: FormValues) { setLoading(true); - // When creating, each non-empty line becomes its own record + // Each non-empty line is one value of the record const values = data.value .split("\n") .map((line) => line.trim()) @@ -137,7 +137,7 @@ export default function CustomDnsRecordCredenza({ const payload = { type: data.type, name: data.name, - ...(record ? { value: data.value } : { values }), + values, ttl: data.ttl, priority: needsPriority ? (data.priority ?? null) : null, weight: isSrv ? (data.weight ?? null) : null, @@ -296,7 +296,7 @@ export default function CustomDnsRecordCredenza({ {t("value")} - {!record || type === "TXT" ? ( + {type !== "CNAME" ? (