Adjust how multi record items are stored

This commit is contained in:
Owen
2026-10-28 13:42:59 -04:00
parent a44e1c927d
commit c64fd9cce6
12 changed files with 459 additions and 183 deletions
+3 -1
View File
@@ -3744,11 +3744,13 @@
"customDnsRecords": "Custom DNS Records", "customDnsRecords": "Custom DNS Records",
"customDnsRecordsAdd": "Add Record", "customDnsRecordsAdd": "Add Record",
"customDnsRecordsSearch": "Search records...", "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", "customDnsRecordCreate": "Add DNS Record",
"customDnsRecordEdit": "Edit DNS Record", "customDnsRecordEdit": "Edit DNS Record",
"customDnsRecordFormDescription": "This record will be served for {domain}. Changes may take a few minutes to propagate.", "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.", "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", "customDnsRecordPriority": "Priority",
"customDnsRecordWeight": "Weight", "customDnsRecordWeight": "Weight",
"customDnsRecordPort": "Port", "customDnsRecordPort": "Port",
+1 -1
View File
@@ -56,7 +56,7 @@ export const customDnsRecords = pgTable(
type: varchar("type", { length: 10 }) type: varchar("type", { length: 10 })
.$type<"A" | "AAAA" | "CNAME" | "MX" | "NS" | "SRV" | "TXT">() .$type<"A" | "AAAA" | "CNAME" | "MX" | "NS" | "SRV" | "TXT">()
.notNull(), .notNull(),
value: text("value").notNull(), values: text("values").notNull(), // JSON array of strings, one resource record each
ttl: integer("ttl").notNull().default(300), ttl: integer("ttl").notNull().default(300),
priority: integer("priority"), // MX and SRV priority: integer("priority"), // MX and SRV
weight: integer("weight"), // SRV weight: integer("weight"), // SRV
+1 -1
View File
@@ -54,7 +54,7 @@ export const customDnsRecords = sqliteTable(
type: text("type") type: text("type")
.$type<"A" | "AAAA" | "CNAME" | "MX" | "NS" | "SRV" | "TXT">() .$type<"A" | "AAAA" | "CNAME" | "MX" | "NS" | "SRV" | "TXT">()
.notNull(), .notNull(),
value: text("value").notNull(), values: text("values").notNull(), // JSON array of strings, one resource record each
ttl: integer("ttl").notNull().default(300), ttl: integer("ttl").notNull().default(300),
priority: integer("priority"), // MX and SRV priority: integer("priority"), // MX and SRV
weight: integer("weight"), // SRV weight: integer("weight"), // SRV
+166 -19
View File
@@ -37,6 +37,7 @@ import { privateConfig as config } from "#private/lib/config";
import * as dnsResolver from "dns"; import * as dnsResolver from "dns";
import logger from "@server/logger"; import logger from "@server/logger";
import { listExitNodes } from "../exitNodes"; import { listExitNodes } from "../exitNodes";
import { parseCustomDnsRecordValues } from "@server/routers/customDnsRecord/types";
import { rateLimitService } from "../rateLimit"; import { rateLimitService } from "../rateLimit";
import license from "#private/license/license"; import license from "#private/license/license";
@@ -303,6 +304,33 @@ export class AuthoritativeDNSServer {
isCustomerZone = true; 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 // Special handling for _acme-challenge CNAME requests
if ( if (
(queryType === "TXT" || queryType === "CNAME") && (queryType === "TXT" || queryType === "CNAME") &&
@@ -355,21 +383,6 @@ export class AuthoritativeDNSServer {
// Handle NS queries // Handle NS queries
if (queryType === "NS") { if (queryType === "NS") {
logger.info(`Handling NS query for ${queryName}`); 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); const nsRecords = await this.getNSRecords(baseDomain);
if (nsRecords.length > 0) { if (nsRecords.length > 0) {
const answers = nsRecords.map((record) => ({ const answers = nsRecords.map((record) => ({
@@ -1238,7 +1251,7 @@ export class AuthoritativeDNSServer {
.select({ .select({
fullDomain: customDnsRecords.fullDomain, fullDomain: customDnsRecords.fullDomain,
type: customDnsRecords.type, type: customDnsRecords.type,
value: customDnsRecords.value, values: customDnsRecords.values,
ttl: customDnsRecords.ttl, ttl: customDnsRecords.ttl,
priority: customDnsRecords.priority, priority: customDnsRecords.priority,
weight: customDnsRecords.weight, weight: customDnsRecords.weight,
@@ -1262,9 +1275,15 @@ export class AuthoritativeDNSServer {
const exactRows = rows.filter( const exactRows = rows.filter(
(row) => row.fullDomain === normalized (row) => row.fullDomain === normalized
); );
// Each stored value is served as its own resource record
const result: CustomRecord[] = ( const result: CustomRecord[] = (
exactRows.length > 0 ? exactRows : rows exactRows.length > 0 ? exactRows : rows
).map(({ fullDomain, ...record }) => record); ).flatMap(({ fullDomain, values, ...record }) =>
parseCustomDnsRecordValues(values).map((value) => ({
...record,
value
}))
);
this.cache.set( this.cache.set(
cacheKey, 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( private convertCustomRecordsToAnswers(
records: CustomRecord[], records: CustomRecord[],
queryName: string queryName: string
@@ -1330,7 +1476,8 @@ export class AuthoritativeDNSServer {
rinfo: dgram.RemoteInfo, rinfo: dgram.RemoteInfo,
authoritative: boolean = true, authoritative: boolean = true,
rcode: number = 0, rcode: number = 0,
authorities: dns.Answer[] = [] authorities: dns.Answer[] = [],
additionals: dns.Answer[] = []
): void { ): void {
const response: dns.Packet = { const response: dns.Packet = {
id: originalPacket.id, id: originalPacket.id,
@@ -1339,7 +1486,7 @@ export class AuthoritativeDNSServer {
questions: originalPacket.questions, questions: originalPacket.questions,
answers: answers, answers: answers,
authorities: authorities, authorities: authorities,
additionals: [] additionals: additionals
}; };
// Set authoritative flag if this is an authoritative response // Set authoritative flag if this is an authoritative response
@@ -20,14 +20,14 @@ import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { CreateCustomDnsRecordsResponse } from "@server/routers/customDnsRecord/types"; import {
CreateOrUpdateCustomDnsRecordResponse,
toCustomDnsRecordRow
} from "@server/routers/customDnsRecord/types";
import { import {
checkCustomDnsRecordConflicts, checkCustomDnsRecordConflicts,
customDnsRecordFieldsSchema, customDnsRecordFieldsSchema,
customDnsRecordValueSchema,
getManageableDomain, getManageableDomain,
MAX_CUSTOM_DNS_RECORDS_PER_RRSET,
NormalizedCustomDnsRecord,
normalizeCustomDnsRecord normalizeCustomDnsRecord
} from "./validation"; } from "./validation";
@@ -36,31 +36,16 @@ const paramsSchema = z.strictObject({
domainId: z.string().nonempty() domainId: z.string().nonempty()
}); });
// Either `value` for a single record, or `values` to create several records const bodySchema = customDnsRecordFieldsSchema.extend({
// with the same name and settings at once (e.g. a pair of NS records). ttl: customDnsRecordFieldsSchema.shape.ttl.optional().default(300),
const bodySchema = customDnsRecordFieldsSchema enabled: z.boolean().optional().default(true)
.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"
}
);
registry.registerPath({ registry.registerPath({
method: "put", method: "put",
path: "/org/{orgId}/domain/{domainId}/custom-dns-record", path: "/org/{orgId}/domain/{domainId}/custom-dns-record",
description: 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], tags: [OpenAPITags.Domain],
request: { request: {
params: paramsSchema, params: paramsSchema,
@@ -123,57 +108,42 @@ export async function createCustomDnsRecord(
return next(createHttpError(domain.status, domain.error)); return next(createHttpError(domain.status, domain.error));
} }
const { value, values, ...fields } = parsedBody.data; const normalized = normalizeCustomDnsRecord(
parsedBody.data,
// Validate every record (against the existing ones and each other) domain.baseDomain
// before inserting any, so a bad value doesn't leave a partial batch. );
const toInsert: NormalizedCustomDnsRecord[] = []; if (!normalized.ok) {
for (const recordValue of values ?? [value!]) { return next(
const normalized = normalizeCustomDnsRecord( createHttpError(HttpCode.BAD_REQUEST, normalized.error)
{ ...fields, value: recordValue },
domain.baseDomain
); );
if (!normalized.ok) { }
return next(
createHttpError(HttpCode.BAD_REQUEST, normalized.error)
);
}
const conflict = await checkCustomDnsRecordConflicts( const conflict = await checkCustomDnsRecordConflicts(
domainId, domainId,
domain.baseDomain, domain.baseDomain,
normalized.value, normalized.value
undefined, );
toInsert if (conflict) {
); return next(createHttpError(HttpCode.CONFLICT, conflict));
if (conflict) {
return next(createHttpError(HttpCode.CONFLICT, conflict));
}
toInsert.push(normalized.value);
} }
const now = Date.now(); const now = Date.now();
const records = await db const [record] = await db
.insert(customDnsRecords) .insert(customDnsRecords)
.values( .values({
toInsert.map((record) => ({ domainId,
domainId, ...normalized.value,
...record, values: JSON.stringify(normalized.value.values),
createdAt: now, createdAt: now,
updatedAt: now updatedAt: now
})) })
)
.returning(); .returning();
return response<CreateCustomDnsRecordsResponse>(res, { return response<CreateOrUpdateCustomDnsRecordResponse>(res, {
data: { records }, data: toCustomDnsRecordRow(record),
success: true, success: true,
error: false, error: false,
message: message: "Custom DNS record created successfully",
records.length === 1
? "Custom DNS record created successfully"
: "Custom DNS records created successfully",
status: HttpCode.CREATED status: HttpCode.CREATED
}); });
} catch (error) { } catch (error) {
@@ -21,7 +21,10 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { and, asc, eq } from "drizzle-orm"; 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({ const paramsSchema = z.strictObject({
orgId: z.string().nonempty(), orgId: z.string().nonempty(),
@@ -96,7 +99,7 @@ export async function listCustomDnsRecords(
return response<ListCustomDnsRecordsResponse>(res, { return response<ListCustomDnsRecordsResponse>(res, {
data: { data: {
records: rows.map((r) => r.record) records: rows.map((r) => toCustomDnsRecordRow(r.record))
}, },
success: true, success: true,
error: false, error: false,
@@ -21,7 +21,11 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { UpdateCustomDnsRecordResponse } from "@server/routers/customDnsRecord/types"; import {
CreateOrUpdateCustomDnsRecordResponse,
parseCustomDnsRecordValues,
toCustomDnsRecordRow
} from "@server/routers/customDnsRecord/types";
import { import {
checkCustomDnsRecordConflicts, checkCustomDnsRecordConflicts,
customDnsRecordFieldsSchema, customDnsRecordFieldsSchema,
@@ -129,7 +133,7 @@ export async function updateCustomDnsRecord(
{ {
name: existing.name, name: existing.name,
type: existing.type, type: existing.type,
value: existing.value, values: parseCustomDnsRecordValues(existing.values),
ttl: existing.ttl, ttl: existing.ttl,
priority: existing.priority, priority: existing.priority,
weight: existing.weight, weight: existing.weight,
@@ -159,6 +163,7 @@ export async function updateCustomDnsRecord(
.update(customDnsRecords) .update(customDnsRecords)
.set({ .set({
...normalized.value, ...normalized.value,
values: JSON.stringify(normalized.value.values),
updatedAt: Date.now() updatedAt: Date.now()
}) })
.where( .where(
@@ -169,8 +174,8 @@ export async function updateCustomDnsRecord(
) )
.returning(); .returning();
return response<UpdateCustomDnsRecordResponse>(res, { return response<CreateOrUpdateCustomDnsRecordResponse>(res, {
data: record, data: toCustomDnsRecordRow(record),
success: true, success: true,
error: false, error: false,
message: "Custom DNS record updated successfully", message: "Custom DNS record updated successfully",
@@ -28,13 +28,15 @@ import HttpCode from "@server/types/HttpCode";
import { privateConfig as config } from "#private/lib/config"; import { privateConfig as config } from "#private/lib/config";
import { import {
CUSTOM_DNS_RECORD_TYPES, CUSTOM_DNS_RECORD_TYPES,
CustomDnsRecordType CustomDnsRecordType,
parseCustomDnsRecordValues
} from "@server/routers/customDnsRecord/types"; } from "@server/routers/customDnsRecord/types";
export const MAX_CUSTOM_DNS_RECORDS_PER_DOMAIN = 100; export const MAX_CUSTOM_DNS_RECORDS_PER_DOMAIN = 100;
// Caps the size of any single RRset the DNS server can be made to return, // Caps the number of values (resource records) any single RRset the DNS
// which bounds how much a spoofed UDP query can be amplified. // server can be made to return, which bounds how much a spoofed UDP query
export const MAX_CUSTOM_DNS_RECORDS_PER_RRSET = 10; // can be amplified.
export const MAX_CUSTOM_DNS_VALUES_PER_RRSET = 10;
export const MAX_TXT_VALUE_LENGTH = 1024; export const MAX_TXT_VALUE_LENGTH = 1024;
export const MIN_TTL = 60; export const MIN_TTL = 60;
export const MAX_TTL = 86400; export const MAX_TTL = 86400;
@@ -47,15 +49,18 @@ const TXT_REGEX = /^[\x20-\x7e]+$/;
const uint16 = z.number().int().min(0).max(65535); 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({ export const customDnsRecordFieldsSchema = z.strictObject({
name: z.string().max(253), name: z.string().max(253),
type: z.enum(CUSTOM_DNS_RECORD_TYPES), 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), ttl: z.number().int().min(MIN_TTL).max(MAX_TTL),
priority: uint16.nullable().optional(), priority: uint16.nullable().optional(),
weight: uint16.nullable().optional(), weight: uint16.nullable().optional(),
@@ -69,7 +74,7 @@ export type NormalizedCustomDnsRecord = {
name: string; name: string;
fullDomain: string; fullDomain: string;
type: CustomDnsRecordType; type: CustomDnsRecordType;
value: string; values: string[];
ttl: number; ttl: number;
priority: number | null; priority: number | null;
weight: number | null; weight: number | null;
@@ -207,57 +212,46 @@ function normalizeName(
return { ok: true, value: { name: relative, fullDomain } }; return { ok: true, value: { name: relative, fullDomain } };
} }
// Validates and normalizes a record's fields without touching the database. function normalizeValue(
export function normalizeCustomDnsRecord( type: CustomDnsRecordType,
input: CustomDnsRecordInput, rawValue: string
baseDomain: string ): Result<string> {
): Result<NormalizedCustomDnsRecord> { switch (type) {
const nameResult = normalizeName(input.name, baseDomain); case "A": {
if (!nameResult.ok) { const value = rawValue.trim();
return nameResult;
}
const { name, fullDomain } = nameResult.value;
const isApex = name === "@";
const isWildcard = name.startsWith("*");
let value: string;
let priority: number | null = null;
let weight: number | null = null;
let port: number | null = null;
switch (input.type) {
case "A":
value = input.value.trim();
if (!isIPv4(value)) { if (!isIPv4(value)) {
return { return {
ok: false, ok: false,
error: "A records require an IPv4 address" error: `"${value}" is not an IPv4 address`
}; };
} }
break; return { ok: true, value };
case "AAAA": }
value = input.value.trim().toLowerCase(); case "AAAA": {
const value = rawValue.trim().toLowerCase();
if (!isIPv6(value)) { if (!isIPv6(value)) {
return { return {
ok: false, ok: false,
error: "AAAA records require an IPv6 address" error: `"${value}" is not an IPv6 address`
}; };
} }
break; return { ok: true, value };
}
case "CNAME": case "CNAME":
case "NS": case "NS":
case "MX": case "MX":
case "SRV": case "SRV": {
value = normalizeHostname(input.value); const value = normalizeHostname(rawValue);
if (!isValidHostname(value)) { if (!isValidHostname(value)) {
return { return {
ok: false, ok: false,
error: `${input.type} records require a fully qualified hostname as the value` error: `${type} records require fully qualified hostnames as values`
}; };
} }
break; return { ok: true, value };
case "TXT": }
value = input.value; case "TXT": {
let value = rawValue;
// Strip one pair of surrounding quotes, as commonly pasted from // Strip one pair of surrounding quotes, as commonly pasted from
// zone files and provider instructions // zone files and provider instructions
if ( if (
@@ -279,7 +273,41 @@ export function normalizeCustomDnsRecord(
error: "TXT values may only contain printable ASCII characters" 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<NormalizedCustomDnsRecord> {
const nameResult = normalizeName(input.name, baseDomain);
if (!nameResult.ok) {
return nameResult;
}
const { name, fullDomain } = nameResult.value;
const isApex = name === "@";
const isWildcard = name.startsWith("*");
let priority: number | null = null;
let weight: number | null = null;
let port: number | null = null;
const values: string[] = [];
for (const rawValue of input.values) {
const valueResult = normalizeValue(input.type, rawValue);
if (!valueResult.ok) {
return valueResult;
}
if (values.includes(valueResult.value)) {
return {
ok: false,
error: `Duplicate value "${valueResult.value}"`
};
}
values.push(valueResult.value);
} }
if (input.type === "CNAME") { if (input.type === "CNAME") {
@@ -289,7 +317,13 @@ export function normalizeCustomDnsRecord(
error: "CNAME records cannot be added at the root of the domain" 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 { return {
ok: false, ok: false,
error: "A CNAME record cannot point to itself" error: "A CNAME record cannot point to itself"
@@ -331,7 +365,7 @@ export function normalizeCustomDnsRecord(
name, name,
fullDomain, fullDomain,
type: input.type, type: input.type,
value, values,
ttl: input.ttl, ttl: input.ttl,
priority, priority,
weight, weight,
@@ -343,15 +377,12 @@ export function normalizeCustomDnsRecord(
// Checks a normalized record against everything else that lives in DNS: // Checks a normalized record against everything else that lives in DNS:
// infrastructure zones, other customers' zones, Pangolin-managed names and // infrastructure zones, other customers' zones, Pangolin-managed names and
// the domain's other custom records. `pending` holds records being created // the domain's other custom records. Returns an error message, or null.
// alongside this one in the same request, which count as existing. Returns
// an error message, or null.
export async function checkCustomDnsRecordConflicts( export async function checkCustomDnsRecordConflicts(
domainId: string, domainId: string,
baseDomain: string, baseDomain: string,
record: NormalizedCustomDnsRecord, record: NormalizedCustomDnsRecord,
excludeRecordId?: number, excludeRecordId?: number
pending: NormalizedCustomDnsRecord[] = []
): Promise<string | null> { ): Promise<string | null> {
const { fullDomain, type } = record; const { fullDomain, type } = record;
@@ -389,20 +420,16 @@ export async function checkCustomDnsRecordConflicts(
} }
} }
const existing = [ const existing = (
...( await db
await db .select()
.select() .from(customDnsRecords)
.from(customDnsRecords) .where(eq(customDnsRecords.domainId, domainId))
.where(eq(customDnsRecords.domainId, domainId)) ).filter((r) => r.customDnsRecordId !== excludeRecordId);
).filter((r) => r.customDnsRecordId !== excludeRecordId),
...pending
];
if ( // existing excludes the record being updated, so this only trips when
excludeRecordId === undefined && // adding a new record would go over the limit
existing.length >= MAX_CUSTOM_DNS_RECORDS_PER_DOMAIN 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`; 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"; 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); 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 ( if (
sameRRset.some( sameRRset.some(
(r) => (r) =>
r.value === record.value &&
r.priority === record.priority && r.priority === record.priority &&
r.weight === record.weight && r.weight === record.weight &&
r.port === record.port 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 // Pangolin answers address queries for resources, login pages and
+22 -5
View File
@@ -10,13 +10,16 @@ export const CUSTOM_DNS_RECORD_TYPES = [
export type CustomDnsRecordType = (typeof CUSTOM_DNS_RECORD_TYPES)[number]; 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 = { export type CustomDnsRecordRow = {
customDnsRecordId: number; customDnsRecordId: number;
domainId: string; domainId: string;
name: string; name: string;
fullDomain: string; fullDomain: string;
type: CustomDnsRecordType; type: CustomDnsRecordType;
value: string; values: string[];
ttl: number; ttl: number;
priority: number | null; priority: number | null;
weight: number | null; weight: number | null;
@@ -30,8 +33,22 @@ export type ListCustomDnsRecordsResponse = {
records: CustomDnsRecordRow[]; records: CustomDnsRecordRow[];
}; };
export type CreateCustomDnsRecordsResponse = { export type CreateOrUpdateCustomDnsRecordResponse = CustomDnsRecordRow;
records: 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<CustomDnsRecordRow, "values"> & { values: string }
): CustomDnsRecordRow {
return { ...row, values: parseCustomDnsRecordValues(row.values) };
}
+5 -5
View File
@@ -113,7 +113,7 @@ export default function CustomDnsRecordCredenza({
form.reset({ form.reset({
type: record?.type ?? "A", type: record?.type ?? "A",
name: record?.name ?? "", name: record?.name ?? "",
value: record?.value ?? "", value: record?.values.join("\n") ?? "",
ttl: record?.ttl ?? 300, ttl: record?.ttl ?? 300,
priority: record?.priority ?? undefined, priority: record?.priority ?? undefined,
weight: record?.weight ?? undefined, weight: record?.weight ?? undefined,
@@ -128,7 +128,7 @@ export default function CustomDnsRecordCredenza({
async function onSubmit(data: FormValues) { async function onSubmit(data: FormValues) {
setLoading(true); 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 const values = data.value
.split("\n") .split("\n")
.map((line) => line.trim()) .map((line) => line.trim())
@@ -137,7 +137,7 @@ export default function CustomDnsRecordCredenza({
const payload = { const payload = {
type: data.type, type: data.type,
name: data.name, name: data.name,
...(record ? { value: data.value } : { values }), values,
ttl: data.ttl, ttl: data.ttl,
priority: needsPriority ? (data.priority ?? null) : null, priority: needsPriority ? (data.priority ?? null) : null,
weight: isSrv ? (data.weight ?? null) : null, weight: isSrv ? (data.weight ?? null) : null,
@@ -296,7 +296,7 @@ export default function CustomDnsRecordCredenza({
<FormItem> <FormItem>
<FormLabel>{t("value")}</FormLabel> <FormLabel>{t("value")}</FormLabel>
<FormControl> <FormControl>
{!record || type === "TXT" ? ( {type !== "CNAME" ? (
<Textarea <Textarea
autoComplete="off" autoComplete="off"
placeholder={ placeholder={
@@ -314,7 +314,7 @@ export default function CustomDnsRecordCredenza({
/> />
)} )}
</FormControl> </FormControl>
{!record && ( {type !== "CNAME" && (
<FormDescription> <FormDescription>
{t( {t(
"customDnsRecordValueDescription" "customDnsRecordValueDescription"
+31 -10
View File
@@ -3,7 +3,8 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useState } from "react"; import { useState } from "react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { ArrowUpDown, MoreHorizontal } from "lucide-react"; import { AlertTriangle, ArrowUpDown, MoreHorizontal } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { DataTable, ExtendedColumnDef } from "@app/components/ui/data-table"; import { DataTable, ExtendedColumnDef } from "@app/components/ui/data-table";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { Badge } from "@app/components/ui/badge"; import { Badge } from "@app/components/ui/badge";
@@ -30,22 +31,25 @@ type Props = {
orgId: string; orgId: string;
domainId: string; domainId: string;
baseDomain: string; baseDomain: string;
// The DNS server only serves custom records for verified domains
verified: boolean;
}; };
function formatValue(record: CustomDnsRecordRow): string { function formatValue(record: CustomDnsRecordRow, value: string): string {
if (record.type === "MX") { if (record.type === "MX") {
return `${record.priority} ${record.value}`; return `${record.priority} ${value}`;
} }
if (record.type === "SRV") { if (record.type === "SRV") {
return `${record.priority} ${record.weight} ${record.port} ${record.value}`; return `${record.priority} ${record.weight} ${record.port} ${value}`;
} }
return record.value; return value;
} }
export default function CustomDnsRecordsTable({ export default function CustomDnsRecordsTable({
orgId, orgId,
domainId, domainId,
baseDomain baseDomain,
verified
}: Props) { }: Props) {
const t = useTranslations(); const t = useTranslations();
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
@@ -157,14 +161,19 @@ export default function CustomDnsRecordsTable({
} }
}, },
{ {
accessorKey: "value", accessorKey: "values",
friendlyName: t("value"), friendlyName: t("value"),
header: () => <span className="p-3">{t("value")}</span>, header: () => <span className="p-3">{t("value")}</span>,
cell: ({ row }) => { cell: ({ row }) => {
const record = row.original;
return ( return (
<span className="block max-w-md break-all"> <div className="flex flex-col gap-1 max-w-md">
{formatValue(row.original)} {record.values.map((value) => (
</span> <span key={value} className="break-all">
{formatValue(record, value)}
</span>
))}
</div>
); );
} }
}, },
@@ -250,6 +259,18 @@ export default function CustomDnsRecordsTable({
<> <>
<PaidFeaturesAlert tiers={tierMatrix.customDnsRecords} /> <PaidFeaturesAlert tiers={tierMatrix.customDnsRecords} />
{!verified && (
<Alert variant="warning">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>
{t("customDnsRecordsUnverifiedTitle")}
</AlertTitle>
<AlertDescription>
{t("customDnsRecordsUnverifiedDescription")}
</AlertDescription>
</Alert>
)}
{deletingRecord && !isDisabled && ( {deletingRecord && !isDisabled && (
<ConfirmDeleteDialog <ConfirmDeleteDialog
open={deleteOpen} open={deleteOpen}
+1
View File
@@ -106,6 +106,7 @@ export default function DomainPageClient({
orgId={orgId} orgId={orgId}
domainId={domain.domainId} domainId={domain.domainId}
baseDomain={domain.baseDomain} baseDomain={domain.baseDomain}
verified={domain.verified}
/> />
)} )}