Custom dns records

This commit is contained in:
Owen
2026-10-28 11:01:29 -04:00
parent 2298ceb63a
commit cd39f2ecd6
21 changed files with 2146 additions and 9 deletions
+23
View File
@@ -1509,6 +1509,10 @@
"actionDeleteOrgDomain": "Delete Domain",
"actionGetDNSRecords": "Get DNS Records",
"actionRestartOrgDomain": "Restart Domain",
"actionListCustomDnsRecords": "List Custom DNS Records",
"actionCreateCustomDnsRecord": "Create Custom DNS Record",
"actionUpdateCustomDnsRecord": "Update Custom DNS Record",
"actionDeleteCustomDnsRecord": "Delete Custom DNS Record",
"actionCreateSite": "Create Site",
"actionDeleteSite": "Delete Site",
"actionGetSite": "Get Site",
@@ -3737,6 +3741,25 @@
"TTL": "TTL",
"howToAddRecords": "How to Add Records",
"dnsRecord": "DNS Records",
"customDnsRecords": "Custom DNS Records",
"customDnsRecordsDescription": "Additional records served by Pangolin's nameservers for this domain.",
"customDnsRecordsAdd": "Add Record",
"customDnsRecordsEmpty": "No custom DNS records yet.",
"customDnsRecordCreate": "Add DNS Record",
"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.",
"customDnsRecordPriority": "Priority",
"customDnsRecordWeight": "Weight",
"customDnsRecordPort": "Port",
"customDnsRecordCreated": "DNS record created",
"customDnsRecordUpdated": "DNS record updated",
"customDnsRecordSaveError": "Failed to save DNS record",
"customDnsRecordDeleted": "DNS record deleted",
"customDnsRecordDeleteError": "Failed to delete DNS record",
"customDnsRecordDeleteTitle": "Delete DNS Record",
"customDnsRecordDeleteQuestion": "Are you sure you want to delete the {type} record for {name}? It will stop resolving once cached answers expire.",
"customDnsRecordDeleteConfirm": "Delete Record",
"required": "Required",
"domainSettingsUpdated": "Domain settings updated successfully",
"orgOrDomainIdMissing": "Organization or Domain ID is missing",
+4
View File
@@ -134,6 +134,10 @@ export enum ActionsEnum {
createOrgDomain = "createOrgDomain",
deleteOrgDomain = "deleteOrgDomain",
restartOrgDomain = "restartOrgDomain",
listCustomDnsRecords = "listCustomDnsRecords",
createCustomDnsRecord = "createCustomDnsRecord",
updateCustomDnsRecord = "updateCustomDnsRecord",
deleteCustomDnsRecord = "deleteCustomDnsRecord",
createRemoteExitNode = "createRemoteExitNode",
updateRemoteExitNode = "updateRemoteExitNode",
getRemoteExitNode = "getRemoteExitNode",
+31
View File
@@ -41,6 +41,36 @@ export const dnsChallenge = pgTable("dnsChallenges", {
completed: boolean("completed").default(false)
});
// User-defined records served by the authoritative DNS server for a domain
// delegated to Pangolin (type "ns"). fullDomain is the lowercase FQDN without
// a trailing dot, precomputed so the DNS server can look records up directly.
export const customDnsRecords = pgTable(
"customDnsRecords",
{
customDnsRecordId: serial("customDnsRecordId").primaryKey(),
domainId: varchar("domainId")
.notNull()
.references(() => domains.domainId, { onDelete: "cascade" }),
name: varchar("name", { length: 255 }).notNull(), // relative to the domain, "@" for the apex
fullDomain: varchar("fullDomain", { length: 255 }).notNull(),
type: varchar("type", { length: 10 })
.$type<"A" | "AAAA" | "CNAME" | "MX" | "NS" | "SRV" | "TXT">()
.notNull(),
value: text("value").notNull(),
ttl: integer("ttl").notNull().default(300),
priority: integer("priority"), // MX and SRV
weight: integer("weight"), // SRV
port: integer("port"), // SRV
enabled: boolean("enabled").notNull().default(true),
createdAt: bigint("createdAt", { mode: "number" }).notNull(),
updatedAt: bigint("updatedAt", { mode: "number" }).notNull()
},
(t) => [
index("idx_customDnsRecords_fullDomain").on(t.fullDomain),
index("idx_customDnsRecords_domainId").on(t.domainId)
]
);
export const account = pgTable("account", {
accountId: serial("accountId").primaryKey(),
userId: varchar("userId")
@@ -701,6 +731,7 @@ export type Approval = InferSelectModel<typeof approvals>;
export type Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>;
export type DnsChallenge = InferSelectModel<typeof dnsChallenge>;
export type CustomDnsRecord = InferSelectModel<typeof customDnsRecords>;
export type Customer = InferSelectModel<typeof customers>;
export type Subscription = InferSelectModel<typeof subscriptions>;
export type SubscriptionItem = InferSelectModel<typeof subscriptionItems>;
+35
View File
@@ -37,6 +37,40 @@ export const dnsChallenge = sqliteTable("dnsChallenges", {
completed: integer("completed", { mode: "boolean" }).default(false)
});
// User-defined records served by the authoritative DNS server for a domain
// delegated to Pangolin (type "ns"). fullDomain is the lowercase FQDN without
// a trailing dot, precomputed so the DNS server can look records up directly.
export const customDnsRecords = sqliteTable(
"customDnsRecords",
{
customDnsRecordId: integer("customDnsRecordId").primaryKey({
autoIncrement: true
}),
domainId: text("domainId")
.notNull()
.references(() => domains.domainId, { onDelete: "cascade" }),
name: text("name").notNull(), // relative to the domain, "@" for the apex
fullDomain: text("fullDomain").notNull(),
type: text("type")
.$type<"A" | "AAAA" | "CNAME" | "MX" | "NS" | "SRV" | "TXT">()
.notNull(),
value: text("value").notNull(),
ttl: integer("ttl").notNull().default(300),
priority: integer("priority"), // MX and SRV
weight: integer("weight"), // SRV
port: integer("port"), // SRV
enabled: integer("enabled", { mode: "boolean" })
.notNull()
.default(true),
createdAt: integer("createdAt").notNull(),
updatedAt: integer("updatedAt").notNull()
},
(t) => [
index("idx_customDnsRecords_fullDomain").on(t.fullDomain),
index("idx_customDnsRecords_domainId").on(t.domainId)
]
);
export const account = sqliteTable("account", {
accountId: integer("accountId").primaryKey({ autoIncrement: true }),
userId: text("userId")
@@ -700,6 +734,7 @@ export type Approval = InferSelectModel<typeof approvals>;
export type Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>;
export type DnsChallenge = InferSelectModel<typeof dnsChallenge>;
export type CustomDnsRecord = InferSelectModel<typeof customDnsRecords>;
export type Customer = InferSelectModel<typeof customers>;
export type Subscription = InferSelectModel<typeof subscriptions>;
export type SubscriptionItem = InferSelectModel<typeof subscriptionItems>;
+4 -2
View File
@@ -25,7 +25,8 @@ export enum TierFeature {
WildcardSubdomain = "wildcardSubdomain",
NewtAutoUpdate = "newtAutoUpdate",
ResourcePolicies = "resourcePolicies",
RoleBasedSSHControls = "roleBasedSSHControls"
RoleBasedSSHControls = "roleBasedSSHControls",
CustomDnsRecords = "customDnsRecords" // handle downgrade by disabling custom DNS records
}
export const tierMatrix: Record<TierFeature, Tier[]> = {
@@ -68,5 +69,6 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
[TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.NewtAutoUpdate]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.ResourcePolicies]: ["tier3", "enterprise"],
[TierFeature.RoleBasedSSHControls]: ["tier3", "enterprise"]
[TierFeature.RoleBasedSSHControls]: ["tier3", "enterprise"],
[TierFeature.CustomDnsRecords]: ["tier1", "tier2", "tier3", "enterprise"]
};
+182 -4
View File
@@ -30,7 +30,8 @@ import {
sites,
targets,
dnsChallenge,
domains
domains,
customDnsRecords
} from "@server/db";
import { privateConfig as config } from "#private/lib/config";
import * as dnsResolver from "dns";
@@ -50,7 +51,18 @@ type DNSRecord = {
enabled: boolean;
};
type CustomRecord = {
type: string;
value: string;
ttl: number;
priority: number | null;
weight: number | null;
port: number | null;
};
const DNS_CACHE_TTL_SECONDS = 300;
// Kept short so edits to custom records propagate quickly
const CUSTOM_RECORD_CACHE_TTL_SECONDS = 60;
export class AuthoritativeDNSServer {
private cache: LocalCache = createLocalCache(10_000, DNS_CACHE_TTL_SECONDS);
@@ -162,6 +174,10 @@ export class AuthoritativeDNSServer {
const originalQueryName = queryName;
const queryType = question.type;
let baseDomain: string | null;
// True when baseDomain is the customer zone the queried name itself
// lives in (not a synthetic cname-extension lookup), i.e. when
// user-defined custom records may apply.
let isCustomerZone = false;
logger.info(
`DNS Query: ${queryName} ${queryType} from ${rinfo.address}:${rinfo.port}`
@@ -284,6 +300,7 @@ export class AuthoritativeDNSServer {
this.sendResponse(packet, [], rinfo, false, 3, []); // NXDOMAIN rcode=3 - not authoritative
return;
}
isCustomerZone = true;
}
// Special handling for _acme-challenge CNAME requests
@@ -338,6 +355,21 @@ 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) => ({
@@ -452,11 +484,42 @@ export class AuthoritativeDNSServer {
return;
}
// User-defined records come after everything Pangolin manages itself,
// so they can never shadow a resource, login page or redirect.
const customRecords = isCustomerZone
? await this.getCustomRecords(queryName, baseDomain!)
: [];
if (customRecords.length > 0) {
let matchingRecords = customRecords.filter(
(record) => record.type === queryType
);
// A CNAME answers queries for every other type at its name
if (matchingRecords.length === 0 && queryType !== "CNAME") {
matchingRecords = customRecords.filter(
(record) => record.type === "CNAME"
);
}
if (matchingRecords.length > 0) {
const answers = this.convertCustomRecordsToAnswers(
matchingRecords,
originalQueryName
);
logger.info(
`Returning ${answers.length} custom answers for ${originalQueryName} (${queryType})`
);
this.sendResponse(packet, answers, rinfo, true, 0, []);
return;
}
}
// Check if the domain exists (for NODATA vs NXDOMAIN)
const [domainExists, soaRecord] = await Promise.all([
const [resourceExists, soaRecord] = await Promise.all([
this.domainExists(queryName, baseDomain!),
this.getSOARecord(baseDomain!)
]);
const domainExists = resourceExists || customRecords.length > 0;
if (!domainExists) {
// NXDOMAIN: Name does not exist
@@ -1117,9 +1180,9 @@ export class AuthoritativeDNSServer {
}
case "MX":
// MX records need priority and exchange
// MX records need preference and exchange
return {
priority: record.priority,
preference: record.priority,
exchange: record.value
};
@@ -1146,6 +1209,121 @@ export class AuthoritativeDNSServer {
}
}
// User-defined records for `name`. Only served when the record's domain
// is the zone this query resolved to and is a verified NS delegation, so
// records can never surface under another org's zone or a domain whose
// ownership hasn't been proven. An exact name match takes precedence over
// a wildcard (RFC 4592).
private async getCustomRecords(
name: string,
zone: string
): Promise<CustomRecord[]> {
const normalized = name.replace(/\.$/, "").toLowerCase();
const cacheKey = `customRecords:${zone}:${normalized}`;
const cached = this.cache.get<CustomRecord[]>(cacheKey);
if (cached !== undefined) {
return cached;
}
return this.withInFlightDedup<CustomRecord[]>(cacheKey, async () => {
try {
const wildcardPattern = this.getWildcardPattern(normalized);
const names =
wildcardPattern && wildcardPattern !== normalized
? [normalized, wildcardPattern]
: [normalized];
const rows = await db
.select({
fullDomain: customDnsRecords.fullDomain,
type: customDnsRecords.type,
value: customDnsRecords.value,
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, names),
eq(customDnsRecords.enabled, true),
eq(domains.baseDomain, zone),
eq(domains.type, "ns"),
eq(domains.verified, true)
)
);
const exactRows = rows.filter(
(row) => row.fullDomain === normalized
);
const result: CustomRecord[] = (
exactRows.length > 0 ? exactRows : rows
).map(({ fullDomain, ...record }) => record);
this.cache.set(
cacheKey,
result,
CUSTOM_RECORD_CACHE_TTL_SECONDS
);
return result;
} catch (error) {
logger.error("Error querying custom DNS records:", error);
return [];
}
});
}
private convertCustomRecordsToAnswers(
records: CustomRecord[],
queryName: string
): dns.Answer[] {
return records.map((record) => {
let data: any;
switch (record.type) {
case "MX":
data = {
preference: record.priority ?? 0,
exchange: record.value
};
break;
case "SRV":
data = {
priority: record.priority ?? 0,
weight: record.weight ?? 0,
port: record.port ?? 0,
target: record.value
};
break;
case "TXT": {
// A single TXT character-string is at most 255 bytes;
// longer values are split across several strings, which
// clients concatenate. Values are printable ASCII only.
const chunks: string[] = [];
for (let i = 0; i < record.value.length; i += 255) {
chunks.push(record.value.slice(i, i + 255));
}
data = chunks;
break;
}
default:
data = record.value;
}
return {
name: queryName,
type: record.type,
ttl: record.ttl,
data
} as dns.Answer;
});
}
private sendResponse(
originalPacket: dns.Packet,
answers: dns.Answer[],
@@ -32,9 +32,11 @@ import {
siteProvisioningKeyOrg,
siteProvisioningKeys,
alertRules,
targetHealthCheck
targetHealthCheck,
customDnsRecords,
orgDomains
} from "@server/db";
import { and, eq, isNull } from "drizzle-orm";
import { and, eq, inArray, isNull } from "drizzle-orm";
/**
* Get the maximum allowed retention days for a given tier
@@ -336,6 +338,10 @@ async function disableFeature(
await disableStandaloneHealthChecks(orgId);
break;
case TierFeature.CustomDnsRecords:
await disableCustomDnsRecords(orgId);
break;
default:
logger.warn(
`Unknown feature ${feature} for org ${orgId}, skipping`
@@ -575,6 +581,25 @@ async function disableAlertingRules(orgId: string): Promise<void> {
logger.info(`Disabled all alert rules for org ${orgId}`);
}
async function disableCustomDnsRecords(orgId: string): Promise<void> {
// Records are kept (just stop being served) so re-subscribing restores
// them by re-enabling
await db
.update(customDnsRecords)
.set({ enabled: false, updatedAt: Date.now() })
.where(
inArray(
customDnsRecords.domainId,
db
.select({ domainId: orgDomains.domainId })
.from(orgDomains)
.where(eq(orgDomains.orgId, orgId))
)
);
logger.info(`Disabled all custom DNS records for org ${orgId}`);
}
async function disableStandaloneHealthChecks(orgId: string): Promise<void> {
await db
.update(targetHealthCheck)
@@ -0,0 +1,151 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, customDnsRecords } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
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 {
checkCustomDnsRecordConflicts,
customDnsRecordFieldsSchema,
getManageableDomain,
normalizeCustomDnsRecord
} from "./validation";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
domainId: z.string().nonempty()
});
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 a custom DNS record served by Pangolin's nameservers for a domain.",
tags: [OpenAPITags.Domain],
request: {
params: paramsSchema,
body: {
content: {
"application/json": {
schema: bodySchema
}
}
}
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function createCustomDnsRecord(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, domainId } = parsedParams.data;
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const domain = await getManageableDomain(orgId, domainId);
if (!domain.ok) {
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 conflict = await checkCustomDnsRecordConflicts(
domainId,
domain.baseDomain,
normalized.value
);
if (conflict) {
return next(createHttpError(HttpCode.CONFLICT, conflict));
}
const now = Date.now();
const [record] = await db
.insert(customDnsRecords)
.values({
domainId,
...normalized.value,
createdAt: now,
updatedAt: now
})
.returning();
return response<CreateOrUpdateCustomDnsRecordResponse>(res, {
data: record,
success: true,
error: false,
message: "Custom DNS record created successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -0,0 +1,124 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, customDnsRecords, orgDomains } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { and, eq } from "drizzle-orm";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
domainId: z.string().nonempty(),
customDnsRecordId: z.coerce.number<string>().int().positive()
});
registry.registerPath({
method: "delete",
path: "/org/{orgId}/domain/{domainId}/custom-dns-record/{customDnsRecordId}",
description: "Delete a custom DNS record from a domain.",
tags: [OpenAPITags.Domain],
request: {
params: paramsSchema
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function deleteCustomDnsRecord(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, domainId, customDnsRecordId } = parsedParams.data;
// Scope the record to a domain this org owns - the record id alone
// must never be enough to reach another org's records.
const [existing] = await db
.select({ customDnsRecordId: customDnsRecords.customDnsRecordId })
.from(customDnsRecords)
.innerJoin(
orgDomains,
eq(orgDomains.domainId, customDnsRecords.domainId)
)
.where(
and(
eq(customDnsRecords.customDnsRecordId, customDnsRecordId),
eq(customDnsRecords.domainId, domainId),
eq(orgDomains.orgId, orgId)
)
)
.limit(1);
if (!existing) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Custom DNS record with ID ${customDnsRecordId} not found`
)
);
}
await db
.delete(customDnsRecords)
.where(
and(
eq(customDnsRecords.customDnsRecordId, customDnsRecordId),
eq(customDnsRecords.domainId, domainId)
)
);
return response<null>(res, {
data: null,
success: true,
error: false,
message: "Custom DNS record deleted successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -0,0 +1,17 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
export * from "./createCustomDnsRecord";
export * from "./updateCustomDnsRecord";
export * from "./deleteCustomDnsRecord";
export * from "./listCustomDnsRecords";
@@ -0,0 +1,112 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, customDnsRecords, orgDomains } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
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";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
domainId: z.string().nonempty()
});
registry.registerPath({
method: "get",
path: "/org/{orgId}/domain/{domainId}/custom-dns-records",
description:
"List the custom DNS records served by Pangolin's nameservers for a domain.",
tags: [OpenAPITags.Domain],
request: {
params: paramsSchema
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function listCustomDnsRecords(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, domainId } = parsedParams.data;
// Records per domain are capped (MAX_CUSTOM_DNS_RECORDS_PER_DOMAIN),
// so no pagination is needed.
const rows = await db
.select({ record: customDnsRecords })
.from(customDnsRecords)
.innerJoin(
orgDomains,
eq(orgDomains.domainId, customDnsRecords.domainId)
)
.where(
and(
eq(customDnsRecords.domainId, domainId),
eq(orgDomains.orgId, orgId)
)
)
.orderBy(
asc(customDnsRecords.fullDomain),
asc(customDnsRecords.type),
asc(customDnsRecords.customDnsRecordId)
);
return response<ListCustomDnsRecordsResponse>(res, {
data: {
records: rows.map((r) => r.record)
},
success: true,
error: false,
message: "Custom DNS records retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -0,0 +1,185 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db, customDnsRecords } from "@server/db";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
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 {
checkCustomDnsRecordConflicts,
customDnsRecordFieldsSchema,
getManageableDomain,
normalizeCustomDnsRecord
} from "./validation";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
domainId: z.string().nonempty(),
customDnsRecordId: z.coerce.number<string>().int().positive()
});
const bodySchema = customDnsRecordFieldsSchema.partial();
registry.registerPath({
method: "post",
path: "/org/{orgId}/domain/{domainId}/custom-dns-record/{customDnsRecordId}",
description: "Update a custom DNS record for a domain.",
tags: [OpenAPITags.Domain],
request: {
params: paramsSchema,
body: {
content: {
"application/json": {
schema: bodySchema
}
}
}
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function updateCustomDnsRecord(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, domainId, customDnsRecordId } = parsedParams.data;
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const domain = await getManageableDomain(orgId, domainId);
if (!domain.ok) {
return next(createHttpError(domain.status, domain.error));
}
const [existing] = await db
.select()
.from(customDnsRecords)
.where(
and(
eq(customDnsRecords.customDnsRecordId, customDnsRecordId),
eq(customDnsRecords.domainId, domainId)
)
)
.limit(1);
if (!existing) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Custom DNS record with ID ${customDnsRecordId} not found`
)
);
}
// Validate the record as a whole, not just the changed fields, so a
// partial update can't leave it in an invalid state.
const normalized = normalizeCustomDnsRecord(
{
name: existing.name,
type: existing.type,
value: existing.value,
ttl: existing.ttl,
priority: existing.priority,
weight: existing.weight,
port: existing.port,
enabled: existing.enabled,
...parsedBody.data
},
domain.baseDomain
);
if (!normalized.ok) {
return next(
createHttpError(HttpCode.BAD_REQUEST, normalized.error)
);
}
const conflict = await checkCustomDnsRecordConflicts(
domainId,
domain.baseDomain,
normalized.value,
customDnsRecordId
);
if (conflict) {
return next(createHttpError(HttpCode.CONFLICT, conflict));
}
const [record] = await db
.update(customDnsRecords)
.set({
...normalized.value,
updatedAt: Date.now()
})
.where(
and(
eq(customDnsRecords.customDnsRecordId, customDnsRecordId),
eq(customDnsRecords.domainId, domainId)
)
)
.returning();
return response<CreateOrUpdateCustomDnsRecordResponse>(res, {
data: record,
success: true,
error: false,
message: "Custom DNS record updated successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
@@ -0,0 +1,459 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { isIPv4, isIPv6 } from "net";
import { z } from "zod";
import {
db,
customDnsRecords,
domains,
domainNamespaces,
loginPage,
orgDomains,
redirects,
resources
} from "@server/db";
import { and, eq, inArray, isNull } from "drizzle-orm";
import HttpCode from "@server/types/HttpCode";
import { privateConfig as config } from "#private/lib/config";
import {
CUSTOM_DNS_RECORD_TYPES,
CustomDnsRecordType
} 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;
export const MAX_TXT_VALUE_LENGTH = 1024;
export const MIN_TTL = 60;
export const MAX_TTL = 86400;
// Underscores are allowed so service labels like _dmarc, _sip._tcp and
// selector._domainkey can be used.
const LABEL_REGEX = /^[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?$/;
// Printable ASCII only - no control characters or non-ASCII bytes in TXT data.
const TXT_REGEX = /^[\x20-\x7e]+$/;
const uint16 = z.number().int().min(0).max(65535);
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
ttl: z.number().int().min(MIN_TTL).max(MAX_TTL),
priority: uint16.nullable().optional(),
weight: uint16.nullable().optional(),
port: uint16.nullable().optional(),
enabled: z.boolean()
});
export type CustomDnsRecordInput = z.infer<typeof customDnsRecordFieldsSchema>;
export type NormalizedCustomDnsRecord = {
name: string;
fullDomain: string;
type: CustomDnsRecordType;
value: string;
ttl: number;
priority: number | null;
weight: number | null;
port: number | null;
enabled: boolean;
};
type Result<T> = { ok: true; value: T } | { ok: false; error: string };
type ManageableDomainResult =
| { ok: true; domainId: string; baseDomain: string }
| { ok: false; status: number; error: string };
function isValidHostname(host: string): boolean {
if (host.length === 0 || host.length > 253 || !host.includes(".")) {
return false;
}
return host.split(".").every((label) => LABEL_REGEX.test(label));
}
function normalizeHostname(value: string): string {
return value.trim().toLowerCase().replace(/\.$/, "");
}
function isAtOrBelow(name: string, zone: string): boolean {
const normalizedZone = zone.toLowerCase().replace(/\.$/, "");
return name === normalizedZone || name.endsWith(`.${normalizedZone}`);
}
// Loads the domain and makes sure custom records may be managed on it: it
// must belong to the org, be delegated to our nameservers (type "ns"), and
// belong to this org alone so one org can never publish records under a
// domain another org also uses (e.g. a shared/provided namespace domain).
export async function getManageableDomain(
orgId: string,
domainId: string
): Promise<ManageableDomainResult> {
const [row] = await db
.select({
domainId: domains.domainId,
baseDomain: domains.baseDomain,
type: domains.type
})
.from(domains)
.innerJoin(orgDomains, eq(orgDomains.domainId, domains.domainId))
.where(and(eq(domains.domainId, domainId), eq(orgDomains.orgId, orgId)))
.limit(1);
if (!row) {
return {
ok: false,
status: HttpCode.NOT_FOUND,
error: `Domain with ID ${domainId} not found`
};
}
if (row.type !== "ns") {
return {
ok: false,
status: HttpCode.BAD_REQUEST,
error: "Custom DNS records can only be added to domains delegated to Pangolin's nameservers (NS domains)"
};
}
const [namespace] = await db
.select({ domainNamespaceId: domainNamespaces.domainNamespaceId })
.from(domainNamespaces)
.where(eq(domainNamespaces.domainId, domainId))
.limit(1);
const linkedOrgs = await db
.select({ orgId: orgDomains.orgId })
.from(orgDomains)
.where(eq(orgDomains.domainId, domainId));
if (namespace || linkedOrgs.length !== 1) {
return {
ok: false,
status: HttpCode.FORBIDDEN,
error: "Custom DNS records cannot be added to shared domains"
};
}
return {
ok: true,
domainId: row.domainId,
baseDomain: row.baseDomain.toLowerCase()
};
}
function normalizeName(
name: string,
baseDomain: string
): Result<{ name: string; fullDomain: string }> {
let relative = name.trim().toLowerCase().replace(/\.$/, "");
if (relative === "" || relative === "@" || relative === baseDomain) {
return { ok: true, value: { name: "@", fullDomain: baseDomain } };
}
// Accept the name typed as an FQDN within this domain as well
if (relative.endsWith(`.${baseDomain}`)) {
relative = relative.slice(0, -(baseDomain.length + 1));
}
const labels = relative.split(".");
for (let i = 0; i < labels.length; i++) {
const label = labels[i];
if (label === "*" && i === 0) {
continue;
}
if (!LABEL_REGEX.test(label)) {
return {
ok: false,
error: `Invalid record name "${name}". Labels may only contain letters, numbers, hyphens and underscores, and "*" is only allowed as the leftmost label.`
};
}
}
// ACME challenge names are served by Pangolin itself for certificate
// issuance - letting users write there would let them influence DNS-01
// validation.
if (labels.includes("_acme-challenge")) {
return {
ok: false,
error: "_acme-challenge records are managed by Pangolin and cannot be added"
};
}
const fullDomain = `${relative}.${baseDomain}`;
if (fullDomain.length > 253) {
return { ok: false, error: "Record name is too long" };
}
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();
if (!isIPv4(value)) {
return {
ok: false,
error: "A records require an IPv4 address"
};
}
break;
case "AAAA":
value = input.value.trim().toLowerCase();
if (!isIPv6(value)) {
return {
ok: false,
error: "AAAA records require an IPv6 address"
};
}
break;
case "CNAME":
case "NS":
case "MX":
case "SRV":
value = normalizeHostname(input.value);
if (!isValidHostname(value)) {
return {
ok: false,
error: `${input.type} records require a fully qualified hostname as the value`
};
}
break;
case "TXT":
value = input.value;
// Strip one pair of surrounding quotes, as commonly pasted from
// zone files and provider instructions
if (
value.length >= 2 &&
value.startsWith('"') &&
value.endsWith('"')
) {
value = value.slice(1, -1);
}
if (value.length === 0 || value.length > MAX_TXT_VALUE_LENGTH) {
return {
ok: false,
error: `TXT values must be between 1 and ${MAX_TXT_VALUE_LENGTH} characters`
};
}
if (!TXT_REGEX.test(value)) {
return {
ok: false,
error: "TXT values may only contain printable ASCII characters"
};
}
break;
}
if (input.type === "CNAME") {
if (isApex) {
return {
ok: false,
error: "CNAME records cannot be added at the root of the domain"
};
}
if (value === fullDomain) {
return {
ok: false,
error: "A CNAME record cannot point to itself"
};
}
}
if (input.type === "NS" && (isApex || isWildcard)) {
return {
ok: false,
error: "NS records can only be added for a specific subdomain, not the root or a wildcard"
};
}
if (input.type === "MX" || input.type === "SRV") {
if (input.priority == null) {
return {
ok: false,
error: `${input.type} records require a priority`
};
}
priority = input.priority;
}
if (input.type === "SRV") {
if (input.weight == null || input.port == null) {
return {
ok: false,
error: "SRV records require a weight and a port"
};
}
weight = input.weight;
port = input.port;
}
return {
ok: true,
value: {
name,
fullDomain,
type: input.type,
value,
ttl: input.ttl,
priority,
weight,
port,
enabled: input.enabled
}
};
}
// 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.
export async function checkCustomDnsRecordConflicts(
domainId: string,
baseDomain: string,
record: NormalizedCustomDnsRecord,
excludeRecordId?: number
): Promise<string | null> {
const { fullDomain, type } = record;
// Names the DNS server answers for itself (nameservers, cname/site
// extensions, configured zones) are never user-writable.
const dnsConfig = config.getRawConfig().dns;
const reservedZones = [
dnsConfig?.nameserver_name,
dnsConfig?.cname_extension,
dnsConfig?.site_extension,
...(dnsConfig?.cname_alternate_extensions ?? []),
...(dnsConfig?.zones ?? [])
].filter((z): z is string => !!z);
if (reservedZones.some((zone) => isAtOrBelow(fullDomain, zone))) {
return "This name is reserved and cannot be used";
}
// A name inside a more specific domain (e.g. sub.example.com added to
// Pangolin separately, possibly by another org) belongs to that zone,
// not this one.
const labels = fullDomain.split(".");
const baseLabelCount = baseDomain.split(".").length;
const childZoneCandidates: string[] = [];
for (let i = 0; i < labels.length - baseLabelCount; i++) {
childZoneCandidates.push(labels.slice(i).join("."));
}
if (childZoneCandidates.length > 0) {
const [childZone] = await db
.select({ domainId: domains.domainId })
.from(domains)
.where(inArray(domains.baseDomain, childZoneCandidates))
.limit(1);
if (childZone) {
return "This name belongs to a different domain in Pangolin";
}
}
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
) {
return `A domain can have at most ${MAX_CUSTOM_DNS_RECORDS_PER_DOMAIN} custom DNS records`;
}
const sameName = existing.filter((r) => r.fullDomain === fullDomain);
if (type === "CNAME" && sameName.length > 0) {
return "A CNAME record cannot be added to a name that already has other records";
}
if (type !== "CNAME" && sameName.some((r) => r.type === "CNAME")) {
return "This name already has a CNAME record, which cannot be combined with other records";
}
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";
}
// Pangolin answers address queries for resources, login pages and
// redirects itself, so a custom A/AAAA/CNAME there would never be served
// (or, for CNAME, would conflict with the managed addresses).
if (type === "A" || type === "AAAA" || type === "CNAME") {
const [resource] = await db
.select({ resourceId: resources.resourceId })
.from(resources)
.where(eq(resources.fullDomain, fullDomain))
.limit(1);
const [page] = await db
.select({ loginPageId: loginPage.loginPageId })
.from(loginPage)
.where(eq(loginPage.fullDomain, fullDomain))
.limit(1);
const [redirect] = await db
.select({ redirectId: redirects.redirectId })
.from(redirects)
.where(
and(
eq(redirects.domainId, domainId),
record.name === "@"
? isNull(redirects.subdomain)
: eq(redirects.subdomain, record.name)
)
)
.limit(1);
if (resource || page || redirect) {
return `This name is already in use by Pangolin, so ${type} records cannot be added to it`;
}
}
return null;
}
+45
View File
@@ -34,8 +34,10 @@ import * as eventStreamingDestination from "#private/routers/eventStreamingDesti
import * as alertRule from "#private/routers/alertRule";
import * as healthChecks from "#private/routers/healthChecks";
import * as policy from "#private/routers/policy";
import * as customDnsRecord from "#private/routers/customDnsRecord";
import {
verifyOrgAccess,
verifyDomainAccess,
verifyUserHasAction,
verifyUserIsServerAdmin,
verifySiteAccess,
@@ -263,6 +265,49 @@ authenticated.get(
domain.checkDomainNamespaceAvailability
);
authenticated.get(
"/org/:orgId/domain/:domainId/custom-dns-records",
verifyValidLicense,
verifyOrgAccess,
verifyDomainAccess,
verifyUserHasAction(ActionsEnum.listCustomDnsRecords),
customDnsRecord.listCustomDnsRecords
);
authenticated.put(
"/org/:orgId/domain/:domainId/custom-dns-record",
verifyValidLicense,
verifyValidSubscription(tierMatrix.customDnsRecords),
verifyOrgAccess,
verifyDomainAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.createCustomDnsRecord),
logActionAudit(ActionsEnum.createCustomDnsRecord),
customDnsRecord.createCustomDnsRecord
);
authenticated.post(
"/org/:orgId/domain/:domainId/custom-dns-record/:customDnsRecordId",
verifyValidLicense,
verifyValidSubscription(tierMatrix.customDnsRecords),
verifyOrgAccess,
verifyDomainAccess,
verifyUserHasAction(ActionsEnum.updateCustomDnsRecord),
logActionAudit(ActionsEnum.updateCustomDnsRecord),
customDnsRecord.updateCustomDnsRecord
);
authenticated.delete(
"/org/:orgId/domain/:domainId/custom-dns-record/:customDnsRecordId",
verifyValidLicense,
verifyValidSubscription(tierMatrix.customDnsRecords),
verifyOrgAccess,
verifyDomainAccess,
verifyUserHasAction(ActionsEnum.deleteCustomDnsRecord),
logActionAudit(ActionsEnum.deleteCustomDnsRecord),
customDnsRecord.deleteCustomDnsRecord
);
authenticated.put(
"/org/:orgId/remote-exit-node",
verifyValidLicense,
+45
View File
@@ -22,8 +22,10 @@ import * as eventStreamingDestination from "#private/routers/eventStreamingDesti
import * as alertRule from "#private/routers/alertRule";
import * as healthChecks from "#private/routers/healthChecks";
import * as resource from "#private/routers/resource";
import * as customDnsRecord from "#private/routers/customDnsRecord";
import {
verifyApiKeyHasAction,
verifyApiKeyDomainAccess,
verifyApiKeyIsRoot,
verifyApiKeyOrgAccess,
verifyApiKeyIdpAccess,
@@ -179,6 +181,49 @@ authenticated.get(
exportAiSessionLogs
);
authenticated.get(
"/org/:orgId/domain/:domainId/custom-dns-records",
verifyValidLicense,
verifyApiKeyOrgAccess,
verifyApiKeyDomainAccess,
verifyApiKeyHasAction(ActionsEnum.listCustomDnsRecords),
customDnsRecord.listCustomDnsRecords
);
authenticated.put(
"/org/:orgId/domain/:domainId/custom-dns-record",
verifyValidLicense,
verifyValidSubscription(tierMatrix.customDnsRecords),
verifyApiKeyOrgAccess,
verifyApiKeyDomainAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.createCustomDnsRecord),
logActionAudit(ActionsEnum.createCustomDnsRecord),
customDnsRecord.createCustomDnsRecord
);
authenticated.post(
"/org/:orgId/domain/:domainId/custom-dns-record/:customDnsRecordId",
verifyValidLicense,
verifyValidSubscription(tierMatrix.customDnsRecords),
verifyApiKeyOrgAccess,
verifyApiKeyDomainAccess,
verifyApiKeyHasAction(ActionsEnum.updateCustomDnsRecord),
logActionAudit(ActionsEnum.updateCustomDnsRecord),
customDnsRecord.updateCustomDnsRecord
);
authenticated.delete(
"/org/:orgId/domain/:domainId/custom-dns-record/:customDnsRecordId",
verifyValidLicense,
verifyValidSubscription(tierMatrix.customDnsRecords),
verifyApiKeyOrgAccess,
verifyApiKeyDomainAccess,
verifyApiKeyHasAction(ActionsEnum.deleteCustomDnsRecord),
logActionAudit(ActionsEnum.deleteCustomDnsRecord),
customDnsRecord.deleteCustomDnsRecord
);
authenticated.put(
"/org/:orgId/idp/oidc",
verifyValidLicense,
+33
View File
@@ -0,0 +1,33 @@
export const CUSTOM_DNS_RECORD_TYPES = [
"A",
"AAAA",
"CNAME",
"MX",
"NS",
"SRV",
"TXT"
] as const;
export type CustomDnsRecordType = (typeof CUSTOM_DNS_RECORD_TYPES)[number];
export type CustomDnsRecordRow = {
customDnsRecordId: number;
domainId: string;
name: string;
fullDomain: string;
type: CustomDnsRecordType;
value: string;
ttl: number;
priority: number | null;
weight: number | null;
port: number | null;
enabled: boolean;
createdAt: number;
updatedAt: number;
};
export type ListCustomDnsRecordsResponse = {
records: CustomDnsRecordRow[];
};
export type CreateOrUpdateCustomDnsRecordResponse = CustomDnsRecordRow;
+370
View File
@@ -0,0 +1,370 @@
"use client";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Button } from "@app/components/ui/button";
import { Input } from "@app/components/ui/input";
import { Textarea } from "@app/components/ui/textarea";
import { Switch } from "@app/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import {
CUSTOM_DNS_RECORD_TYPES,
CustomDnsRecordRow,
CustomDnsRecordType
} from "@server/routers/customDnsRecord/types";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
const FORM_ID = "custom-dns-record-form";
const VALUE_PLACEHOLDERS: Record<CustomDnsRecordType, string> = {
A: "192.0.2.1",
AAAA: "2001:db8::1",
CNAME: "target.example.net",
MX: "mail.example.net",
NS: "ns1.example.net",
SRV: "sip.example.net",
TXT: "v=spf1 include:example.net ~all"
};
const optionalUint16 = z.number().int().min(0).max(65535).optional();
const formSchema = z.object({
type: z.enum(CUSTOM_DNS_RECORD_TYPES),
name: z.string().max(253),
value: z.string().min(1),
ttl: z.number().int().min(60).max(86400),
priority: optionalUint16,
weight: optionalUint16,
port: optionalUint16,
enabled: z.boolean()
});
type FormValues = z.infer<typeof formSchema>;
type CustomDnsRecordCredenzaProps = {
open: boolean;
setOpen: (open: boolean) => void;
orgId: string;
domainId: string;
baseDomain: string;
// null to create a new record
record: CustomDnsRecordRow | null;
onSaved: () => void;
};
export default function CustomDnsRecordCredenza({
open,
setOpen,
orgId,
domainId,
baseDomain,
record,
onSaved
}: CustomDnsRecordCredenzaProps) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const [loading, setLoading] = useState(false);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
type: "A",
name: "",
value: "",
ttl: 300,
enabled: true
}
});
useEffect(() => {
if (!open) {
return;
}
form.reset({
type: record?.type ?? "A",
name: record?.name ?? "",
value: record?.value ?? "",
ttl: record?.ttl ?? 300,
priority: record?.priority ?? undefined,
weight: record?.weight ?? undefined,
port: record?.port ?? undefined,
enabled: record?.enabled ?? true
});
}, [open, record, form]);
const type = form.watch("type");
const needsPriority = type === "MX" || type === "SRV";
const isSrv = type === "SRV";
async function onSubmit(data: FormValues) {
setLoading(true);
const payload = {
type: data.type,
name: data.name,
value: data.value,
ttl: data.ttl,
priority: needsPriority ? (data.priority ?? null) : null,
weight: isSrv ? (data.weight ?? null) : null,
port: isSrv ? (data.port ?? null) : null,
enabled: data.enabled
};
try {
const res = record
? await api.post(
`/org/${orgId}/domain/${domainId}/custom-dns-record/${record.customDnsRecordId}`,
payload
)
: await api.put(
`/org/${orgId}/domain/${domainId}/custom-dns-record`,
payload
);
if (res.status === 200 || res.status === 201) {
toast({
title: record
? t("customDnsRecordUpdated")
: t("customDnsRecordCreated")
});
setOpen(false);
onSaved();
}
} catch (e) {
toast({
variant: "destructive",
title: t("customDnsRecordSaveError"),
description: formatAxiosError(e)
});
} finally {
setLoading(false);
}
}
const numberField = (
name: "ttl" | "priority" | "weight" | "port",
label: string
) => (
<FormField
control={form.control}
name={name}
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>{label}</FormLabel>
<FormControl>
<Input
type="number"
autoComplete="off"
name={field.name}
ref={field.ref}
onBlur={field.onBlur}
value={field.value ?? ""}
onChange={(e) => {
const v = e.target.value;
field.onChange(
v === "" ? undefined : Number(v)
);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
return (
<Credenza open={open} onOpenChange={setOpen}>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>
{record
? t("customDnsRecordEdit")
: t("customDnsRecordCreate")}
</CredenzaTitle>
<CredenzaDescription>
{t("customDnsRecordFormDescription", {
domain: baseDomain
})}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<Form {...form}>
<form
id={FORM_ID}
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
>
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>{t("type")}</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{CUSTOM_DNS_RECORD_TYPES.map(
(recordType) => (
<SelectItem
key={recordType}
value={recordType}
>
{recordType}
</SelectItem>
)
)}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("name")}</FormLabel>
<div className="flex items-center gap-2">
<FormControl>
<Input
autoComplete="off"
placeholder="@"
{...field}
/>
</FormControl>
<span className="text-sm text-muted-foreground whitespace-nowrap">
.{baseDomain}
</span>
</div>
<FormDescription>
{t(
"customDnsRecordNameDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="value"
render={({ field }) => (
<FormItem>
<FormLabel>{t("value")}</FormLabel>
<FormControl>
{type === "TXT" ? (
<Textarea
autoComplete="off"
placeholder={
VALUE_PLACEHOLDERS[type]
}
{...field}
/>
) : (
<Input
autoComplete="off"
placeholder={
VALUE_PLACEHOLDERS[type]
}
{...field}
/>
)}
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{needsPriority && (
<div className="flex gap-4">
{numberField(
"priority",
t("customDnsRecordPriority")
)}
{isSrv &&
numberField(
"weight",
t("customDnsRecordWeight")
)}
{isSrv &&
numberField(
"port",
t("customDnsRecordPort")
)}
</div>
)}
{numberField("ttl", t("TTL"))}
<FormField
control={form.control}
name="enabled"
render={({ field }) => (
<FormItem className="flex flex-row items-center gap-3 space-y-0">
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className="!mt-0">
{t("enabled")}
</FormLabel>
</FormItem>
)}
/>
</form>
</Form>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t("close")}</Button>
</CredenzaClose>
<Button
type="submit"
form={FORM_ID}
loading={loading}
disabled={loading}
>
{t("save")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}
+257
View File
@@ -0,0 +1,257 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Pencil, Plus, Trash2 } from "lucide-react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "@app/components/ui/table";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from "@app/components/ui/card";
import { Button } from "@app/components/ui/button";
import { Switch } from "@app/components/ui/switch";
import CopyToClipboard from "@app/components/CopyToClipboard";
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
import CustomDnsRecordCredenza from "@app/components/CustomDnsRecordCredenza";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { domainQueries } from "@app/lib/queries";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { CustomDnsRecordRow } from "@server/routers/customDnsRecord/types";
type Props = {
orgId: string;
domainId: string;
baseDomain: string;
};
function formatValue(record: CustomDnsRecordRow): string {
if (record.type === "MX") {
return `${record.priority} ${record.value}`;
}
if (record.type === "SRV") {
return `${record.priority} ${record.weight} ${record.port} ${record.value}`;
}
return record.value;
}
export default function CustomDnsRecordsTable({
orgId,
domainId,
baseDomain
}: Props) {
const t = useTranslations();
const api = createApiClient(useEnvContext());
const { isPaidUser } = usePaidStatus();
const isDisabled = !isPaidUser(tierMatrix.customDnsRecords);
const { data: records = [], refetch } = useQuery(
domainQueries.listCustomDnsRecords({ orgId, domainId })
);
const [editorOpen, setEditorOpen] = useState(false);
const [editingRecord, setEditingRecord] =
useState<CustomDnsRecordRow | null>(null);
const [deleteOpen, setDeleteOpen] = useState(false);
const [deletingRecord, setDeletingRecord] =
useState<CustomDnsRecordRow | null>(null);
function openEditor(record: CustomDnsRecordRow | null) {
setEditingRecord(record);
setEditorOpen(true);
}
async function toggleEnabled(record: CustomDnsRecordRow, enabled: boolean) {
try {
await api.post(
`/org/${orgId}/domain/${domainId}/custom-dns-record/${record.customDnsRecordId}`,
{ enabled }
);
refetch();
} catch (e) {
toast({
variant: "destructive",
title: t("customDnsRecordSaveError"),
description: formatAxiosError(e)
});
}
}
async function deleteRecord() {
if (!deletingRecord) {
return;
}
try {
await api.delete(
`/org/${orgId}/domain/${domainId}/custom-dns-record/${deletingRecord.customDnsRecordId}`
);
toast({ title: t("customDnsRecordDeleted") });
setDeleteOpen(false);
setDeletingRecord(null);
refetch();
} catch (e) {
toast({
variant: "destructive",
title: t("customDnsRecordDeleteError"),
description: formatAxiosError(e)
});
}
}
return (
<>
<PaidFeaturesAlert tiers={tierMatrix.customDnsRecords} />
<Card
className={
isDisabled ? "pointer-events-none opacity-50" : undefined
}
>
<CardHeader className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0 pb-4">
<div className="space-y-1">
<CardTitle>{t("customDnsRecords")}</CardTitle>
<CardDescription>
{t("customDnsRecordsDescription")}
</CardDescription>
</div>
<Button
onClick={() => openEditor(null)}
disabled={isDisabled}
>
<Plus className="h-4 w-4 mr-1" />
{t("customDnsRecordsAdd")}
</Button>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>{t("name")}</TableHead>
<TableHead>{t("type")}</TableHead>
<TableHead>{t("value")}</TableHead>
<TableHead>{t("TTL")}</TableHead>
<TableHead>{t("enabled")}</TableHead>
<TableHead className="text-right">
{t("actions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{records.length ? (
records.map((record) => (
<TableRow key={record.customDnsRecordId}>
<TableCell>
<CopyToClipboard
text={record.fullDomain}
displayText={record.fullDomain}
isLink={false}
/>
</TableCell>
<TableCell>{record.type}</TableCell>
<TableCell className="max-w-md break-all">
{formatValue(record)}
</TableCell>
<TableCell>{record.ttl}</TableCell>
<TableCell>
<Switch
checked={record.enabled}
disabled={isDisabled}
onCheckedChange={(checked) =>
toggleEnabled(
record,
checked
)
}
/>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="icon"
aria-label={t("edit")}
disabled={isDisabled}
onClick={() =>
openEditor(record)
}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
aria-label={t("delete")}
disabled={isDisabled}
onClick={() => {
setDeletingRecord(record);
setDeleteOpen(true);
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={6}
className="h-24 text-center"
>
{t("customDnsRecordsEmpty")}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
<CustomDnsRecordCredenza
open={editorOpen && !isDisabled}
setOpen={setEditorOpen}
orgId={orgId}
domainId={domainId}
baseDomain={baseDomain}
record={editingRecord}
onSaved={() => refetch()}
/>
{deletingRecord && !isDisabled && (
<ConfirmDeleteDialog
open={deleteOpen}
setOpen={(open) => {
setDeleteOpen(open);
if (!open) {
setDeletingRecord(null);
}
}}
string={deletingRecord.fullDomain}
title={t("customDnsRecordDeleteTitle")}
dialog={
<p>
{t("customDnsRecordDeleteQuestion", {
type: deletingRecord.type,
name: deletingRecord.fullDomain
})}
</p>
}
buttonText={t("customDnsRecordDeleteConfirm")}
onConfirm={deleteRecord}
/>
)}
</>
);
}
+12
View File
@@ -10,6 +10,7 @@ import RestartDomainButton from "@app/components/RestartDomainButton";
import RefreshButton from "@app/components/RefreshButton";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import DomainCertForm from "@app/components/DomainCertForm";
import CustomDnsRecordsTable from "@app/components/CustomDnsRecordsTable";
import { build } from "@server/build";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useTranslations } from "next-intl";
@@ -97,6 +98,17 @@ export default function DomainPageClient({
type={domain.type}
/>
{build !== "oss" &&
env.flags.usePangolinDns &&
!env.flags.disableEnterpriseFeatures &&
domain.type === "ns" && (
<CustomDnsRecordsTable
orgId={orgId}
domainId={domain.domainId}
baseDomain={domain.baseDomain}
/>
)}
{domain.type === "wildcard" && !domain.configManaged && (
<DomainCertForm
orgId={orgId}
+5 -1
View File
@@ -47,7 +47,11 @@ function getActionsCategories(root: boolean) {
[t("actionUpdateOrgDomain")]: "updateOrgDomain",
[t("actionDeleteOrgDomain")]: "deleteOrgDomain",
[t("actionGetDNSRecords")]: "getDNSRecords",
[t("actionRestartOrgDomain")]: "restartOrgDomain"
[t("actionRestartOrgDomain")]: "restartOrgDomain",
[t("actionListCustomDnsRecords")]: "listCustomDnsRecords",
[t("actionCreateCustomDnsRecord")]: "createCustomDnsRecord",
[t("actionUpdateCustomDnsRecord")]: "updateCustomDnsRecord",
[t("actionDeleteCustomDnsRecord")]: "deleteCustomDnsRecord"
},
Redirect: {
+25
View File
@@ -43,6 +43,7 @@ import type {
ListDomainsResponse
} from "@server/routers/domain";
import type { GetDomainResponse } from "@server/routers/domain/getDomain";
import type { ListCustomDnsRecordsResponse } from "@server/routers/customDnsRecord/types";
import { ListHealthChecksResponse } from "@server/routers/healthChecks/types";
import type { ListOrgLabelsResponse } from "@server/routers/labels/types";
import type { ListLauncherAiModelsResponse } from "@server/routers/launcher/listLauncherAiModels";
@@ -1878,6 +1879,30 @@ export const domainQueries = {
return res.data.data;
},
refetchInterval: durationToMs(10, "seconds")
}),
listCustomDnsRecords: ({
orgId,
domainId
}: {
orgId: string;
domainId: string;
}) =>
queryOptions({
queryKey: [
"ORG",
orgId,
"DOMAIN",
domainId,
"CUSTOM_DNS_RECORDS"
] as const,
queryFn: async ({ signal, meta }) => {
const res = await meta!.api.get<
AxiosResponse<ListCustomDnsRecordsResponse>
>(`/org/${orgId}/domain/${domainId}/custom-dns-records`, {
signal
});
return res.data.data.records;
}
})
};