Support multiple records where required

This commit is contained in:
Owen
2026-10-28 11:45:30 -04:00
parent 14c68e0355
commit a44e1c927d
6 changed files with 109 additions and 49 deletions
@@ -20,11 +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 { CreateOrUpdateCustomDnsRecordResponse } from "@server/routers/customDnsRecord/types";
import { CreateCustomDnsRecordsResponse } from "@server/routers/customDnsRecord/types";
import {
checkCustomDnsRecordConflicts,
customDnsRecordFieldsSchema,
customDnsRecordValueSchema,
getManageableDomain,
MAX_CUSTOM_DNS_RECORDS_PER_RRSET,
NormalizedCustomDnsRecord,
normalizeCustomDnsRecord
} from "./validation";
@@ -33,16 +36,31 @@ const paramsSchema = z.strictObject({
domainId: z.string().nonempty()
});
const bodySchema = customDnsRecordFieldsSchema.extend({
ttl: customDnsRecordFieldsSchema.shape.ttl.optional().default(300),
enabled: z.boolean().optional().default(true)
});
// 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"
}
);
registry.registerPath({
method: "put",
path: "/org/{orgId}/domain/{domainId}/custom-dns-record",
description:
"Create a custom DNS record served by Pangolin's nameservers for a domain.",
"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.",
tags: [OpenAPITags.Domain],
request: {
params: paramsSchema,
@@ -105,41 +123,57 @@ export async function createCustomDnsRecord(
return next(createHttpError(domain.status, domain.error));
}
const normalized = normalizeCustomDnsRecord(
parsedBody.data,
domain.baseDomain
);
if (!normalized.ok) {
return next(
createHttpError(HttpCode.BAD_REQUEST, normalized.error)
);
}
const { value, values, ...fields } = parsedBody.data;
const conflict = await checkCustomDnsRecordConflicts(
domainId,
domain.baseDomain,
normalized.value
);
if (conflict) {
return next(createHttpError(HttpCode.CONFLICT, conflict));
// 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
);
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 now = Date.now();
const [record] = await db
const records = await db
.insert(customDnsRecords)
.values({
domainId,
...normalized.value,
createdAt: now,
updatedAt: now
})
.values(
toInsert.map((record) => ({
domainId,
...record,
createdAt: now,
updatedAt: now
}))
)
.returning();
return response<CreateOrUpdateCustomDnsRecordResponse>(res, {
data: record,
return response<CreateCustomDnsRecordsResponse>(res, {
data: { records },
success: true,
error: false,
message: "Custom DNS record created successfully",
message:
records.length === 1
? "Custom DNS record created successfully"
: "Custom DNS records created successfully",
status: HttpCode.CREATED
});
} catch (error) {
@@ -21,7 +21,7 @@ import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { and, eq } from "drizzle-orm";
import { CreateOrUpdateCustomDnsRecordResponse } from "@server/routers/customDnsRecord/types";
import { UpdateCustomDnsRecordResponse } from "@server/routers/customDnsRecord/types";
import {
checkCustomDnsRecordConflicts,
customDnsRecordFieldsSchema,
@@ -169,7 +169,7 @@ export async function updateCustomDnsRecord(
)
.returning();
return response<CreateOrUpdateCustomDnsRecordResponse>(res, {
return response<UpdateCustomDnsRecordResponse>(res, {
data: record,
success: true,
error: false,
@@ -47,13 +47,15 @@ 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: z
.string()
.min(1)
.max(MAX_TXT_VALUE_LENGTH + 2), // + surrounding quotes
value: customDnsRecordValueSchema,
ttl: z.number().int().min(MIN_TTL).max(MAX_TTL),
priority: uint16.nullable().optional(),
weight: uint16.nullable().optional(),
@@ -341,12 +343,15 @@ 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. Returns an error message, or null.
// 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.
export async function checkCustomDnsRecordConflicts(
domainId: string,
baseDomain: string,
record: NormalizedCustomDnsRecord,
excludeRecordId?: number
excludeRecordId?: number,
pending: NormalizedCustomDnsRecord[] = []
): Promise<string | null> {
const { fullDomain, type } = record;
@@ -384,12 +389,15 @@ export async function checkCustomDnsRecordConflicts(
}
}
const existing = (
await db
.select()
.from(customDnsRecords)
.where(eq(customDnsRecords.domainId, domainId))
).filter((r) => r.customDnsRecordId !== excludeRecordId);
const existing = [
...(
await db
.select()
.from(customDnsRecords)
.where(eq(customDnsRecords.domainId, domainId))
).filter((r) => r.customDnsRecordId !== excludeRecordId),
...pending
];
if (
excludeRecordId === undefined &&