diff --git a/messages/en-US.json b/messages/en-US.json index a57d623fc..7231bd0d7 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -3748,6 +3748,7 @@ "customDnsRecordEdit": "Edit DNS Record", "customDnsRecordFormDescription": "This record will be served for {domain}. Changes may take a few minutes to propagate.", "customDnsRecordNameDescription": "Use @ for the root of the domain or * as the first label for a wildcard.", + "customDnsRecordValueDescription": "Enter one value per line to add multiple records with this name, e.g. two nameservers.", "customDnsRecordPriority": "Priority", "customDnsRecordWeight": "Weight", "customDnsRecordPort": "Port", diff --git a/server/private/routers/customDnsRecord/createCustomDnsRecord.ts b/server/private/routers/customDnsRecord/createCustomDnsRecord.ts index df1c6aa33..0db8e04b9 100644 --- a/server/private/routers/customDnsRecord/createCustomDnsRecord.ts +++ b/server/private/routers/customDnsRecord/createCustomDnsRecord.ts @@ -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(res, { - data: record, + return response(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) { diff --git a/server/private/routers/customDnsRecord/updateCustomDnsRecord.ts b/server/private/routers/customDnsRecord/updateCustomDnsRecord.ts index cd22d3095..549f3a15c 100644 --- a/server/private/routers/customDnsRecord/updateCustomDnsRecord.ts +++ b/server/private/routers/customDnsRecord/updateCustomDnsRecord.ts @@ -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(res, { + return response(res, { data: record, success: true, error: false, diff --git a/server/private/routers/customDnsRecord/validation.ts b/server/private/routers/customDnsRecord/validation.ts index 8a7ce3c32..ee7fee4a1 100644 --- a/server/private/routers/customDnsRecord/validation.ts +++ b/server/private/routers/customDnsRecord/validation.ts @@ -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 { 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 && diff --git a/server/routers/customDnsRecord/types.ts b/server/routers/customDnsRecord/types.ts index 2ffdef01e..3d0537ecc 100644 --- a/server/routers/customDnsRecord/types.ts +++ b/server/routers/customDnsRecord/types.ts @@ -30,4 +30,8 @@ export type ListCustomDnsRecordsResponse = { records: CustomDnsRecordRow[]; }; -export type CreateOrUpdateCustomDnsRecordResponse = CustomDnsRecordRow; +export type CreateCustomDnsRecordsResponse = { + records: CustomDnsRecordRow[]; +}; + +export type UpdateCustomDnsRecordResponse = CustomDnsRecordRow; diff --git a/src/components/CustomDnsRecordCredenza.tsx b/src/components/CustomDnsRecordCredenza.tsx index 486b6b7e0..1d90a50c1 100644 --- a/src/components/CustomDnsRecordCredenza.tsx +++ b/src/components/CustomDnsRecordCredenza.tsx @@ -128,10 +128,16 @@ export default function CustomDnsRecordCredenza({ async function onSubmit(data: FormValues) { setLoading(true); + // When creating, each non-empty line becomes its own record + const values = data.value + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); + const payload = { type: data.type, name: data.name, - value: data.value, + ...(record ? { value: data.value } : { values }), ttl: data.ttl, priority: needsPriority ? (data.priority ?? null) : null, weight: isSrv ? (data.weight ?? null) : null, @@ -290,7 +296,7 @@ export default function CustomDnsRecordCredenza({ {t("value")} - {type === "TXT" ? ( + {!record || type === "TXT" ? (