mirror of
https://github.com/fosrl/pangolin.git
synced 2026-10-29 15:09:07 +01:00
Adjust how multi record items are stored
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user