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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+166 -19
View File
@@ -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
@@ -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<CreateCustomDnsRecordsResponse>(res, {
data: { records },
return response<CreateOrUpdateCustomDnsRecordResponse>(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) {
@@ -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<ListCustomDnsRecordsResponse>(res, {
data: {
records: rows.map((r) => r.record)
records: rows.map((r) => toCustomDnsRecordRow(r.record))
},
success: true,
error: false,
@@ -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<UpdateCustomDnsRecordResponse>(res, {
data: record,
return response<CreateOrUpdateCustomDnsRecordResponse>(res, {
data: toCustomDnsRecordRow(record),
success: true,
error: false,
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 {
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<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 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<string> {
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<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") {
@@ -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<string | null> {
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
+22 -5
View File
@@ -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<CustomDnsRecordRow, "values"> & { values: string }
): CustomDnsRecordRow {
return { ...row, values: parseCustomDnsRecordValues(row.values) };
}