mirror of
https://github.com/fosrl/pangolin.git
synced 2026-10-29 06:59:06 +01:00
Merge pull request #4019 from fosrl/dns-records
Support additional DNS records on pangolin delegated domains
This commit is contained in:
@@ -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,27 @@
|
||||
"TTL": "TTL",
|
||||
"howToAddRecords": "How to Add Records",
|
||||
"dnsRecord": "DNS Records",
|
||||
"customDnsRecords": "Custom DNS Records",
|
||||
"customDnsRecordsAdd": "Add Record",
|
||||
"customDnsRecordsSearch": "Search records...",
|
||||
"customDnsRecordsUnverifiedTitle": "Records are not being served yet",
|
||||
"customDnsRecordsUnverifiedDescription": "This domain hasn't been verified. You can add records now, but Pangolin's nameservers won't serve them until the domain's nameservers point to Pangolin and verification completes.",
|
||||
"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.",
|
||||
"customDnsRecordValueDescription": "Enter one value per line, e.g. both nameservers of an NS record.",
|
||||
"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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
values: text("values").notNull(), // JSON array of strings, one resource record each
|
||||
ttl: integer("ttl").notNull().default(300),
|
||||
priority: integer("priority"), // MX and SRV
|
||||
weight: integer("weight"), // SRV
|
||||
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>;
|
||||
|
||||
@@ -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(),
|
||||
values: text("values").notNull(), // JSON array of strings, one resource record each
|
||||
ttl: integer("ttl").notNull().default(300),
|
||||
priority: integer("priority"), // MX and SRV
|
||||
weight: integer("weight"), // SRV
|
||||
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>;
|
||||
|
||||
@@ -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"]
|
||||
};
|
||||
|
||||
@@ -30,12 +30,14 @@ import {
|
||||
sites,
|
||||
targets,
|
||||
dnsChallenge,
|
||||
domains
|
||||
domains,
|
||||
customDnsRecords
|
||||
} from "@server/db";
|
||||
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";
|
||||
|
||||
@@ -50,7 +52,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 +175,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 +301,34 @@ export class AuthoritativeDNSServer {
|
||||
this.sendResponse(packet, [], rinfo, false, 3, []); // NXDOMAIN rcode=3 - not authoritative
|
||||
return;
|
||||
}
|
||||
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
|
||||
@@ -452,11 +497,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 +1193,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,13 +1222,262 @@ 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,
|
||||
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, 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
|
||||
);
|
||||
// Each stored value is served as its own resource record
|
||||
const result: CustomRecord[] = (
|
||||
exactRows.length > 0 ? exactRows : rows
|
||||
).flatMap(({ fullDomain, values, ...record }) =>
|
||||
parseCustomDnsRecordValues(values).map((value) => ({
|
||||
...record,
|
||||
value
|
||||
}))
|
||||
);
|
||||
|
||||
this.cache.set(
|
||||
cacheKey,
|
||||
result,
|
||||
CUSTOM_RECORD_CACHE_TTL_SECONDS
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error("Error querying custom DNS records:", error);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 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
|
||||
): 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[],
|
||||
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,
|
||||
@@ -1161,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
|
||||
|
||||
@@ -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,155 @@
|
||||
/*
|
||||
* 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,
|
||||
toCustomDnsRecordRow
|
||||
} 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. 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,
|
||||
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,
|
||||
values: JSON.stringify(normalized.value.values),
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
})
|
||||
.returning();
|
||||
|
||||
return response<CreateOrUpdateCustomDnsRecordResponse>(res, {
|
||||
data: toCustomDnsRecordRow(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,115 @@
|
||||
/*
|
||||
* 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,
|
||||
toCustomDnsRecordRow
|
||||
} 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) => toCustomDnsRecordRow(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,190 @@
|
||||
/*
|
||||
* 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,
|
||||
parseCustomDnsRecordValues,
|
||||
toCustomDnsRecordRow
|
||||
} 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,
|
||||
values: parseCustomDnsRecordValues(existing.values),
|
||||
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,
|
||||
values: JSON.stringify(normalized.value.values),
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(customDnsRecords.customDnsRecordId, customDnsRecordId),
|
||||
eq(customDnsRecords.domainId, domainId)
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
|
||||
return response<CreateOrUpdateCustomDnsRecordResponse>(res, {
|
||||
data: toCustomDnsRecordRow(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,577 @@
|
||||
/*
|
||||
* 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,
|
||||
parseCustomDnsRecordValues
|
||||
} from "@server/routers/customDnsRecord/types";
|
||||
|
||||
export const MAX_CUSTOM_DNS_RECORDS_PER_DOMAIN = 100;
|
||||
// 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;
|
||||
|
||||
// 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),
|
||||
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(),
|
||||
port: uint16.nullable().optional(),
|
||||
enabled: z.boolean()
|
||||
});
|
||||
|
||||
export type CustomDnsRecordInput = z.infer<typeof customDnsRecordFieldsSchema>;
|
||||
|
||||
export type NormalizedCustomDnsRecord = {
|
||||
name: string;
|
||||
fullDomain: string;
|
||||
type: CustomDnsRecordType;
|
||||
values: 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 } };
|
||||
}
|
||||
|
||||
function normalizeValue(
|
||||
type: CustomDnsRecordType,
|
||||
rawValue: string
|
||||
): Result<string> {
|
||||
switch (type) {
|
||||
case "A": {
|
||||
const value = rawValue.trim();
|
||||
if (!isIPv4(value)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `"${value}" is not an IPv4 address`
|
||||
};
|
||||
}
|
||||
return { ok: true, value };
|
||||
}
|
||||
case "AAAA": {
|
||||
const value = rawValue.trim().toLowerCase();
|
||||
if (!isIPv6(value)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `"${value}" is not an IPv6 address`
|
||||
};
|
||||
}
|
||||
return { ok: true, value };
|
||||
}
|
||||
case "CNAME":
|
||||
case "NS":
|
||||
case "MX":
|
||||
case "SRV": {
|
||||
const value = normalizeHostname(rawValue);
|
||||
if (!isValidHostname(value)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `${type} records require fully qualified hostnames as values`
|
||||
};
|
||||
}
|
||||
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 (
|
||||
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"
|
||||
};
|
||||
}
|
||||
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") {
|
||||
if (isApex) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "CNAME records cannot be added at the root of the domain"
|
||||
};
|
||||
}
|
||||
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"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
values,
|
||||
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);
|
||||
|
||||
// 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`;
|
||||
}
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
// 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.some(
|
||||
(r) =>
|
||||
r.priority === record.priority &&
|
||||
r.weight === record.weight &&
|
||||
r.port === record.port
|
||||
)
|
||||
) {
|
||||
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
|
||||
// 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
export const CUSTOM_DNS_RECORD_TYPES = [
|
||||
"A",
|
||||
"AAAA",
|
||||
"CNAME",
|
||||
"MX",
|
||||
"NS",
|
||||
"SRV",
|
||||
"TXT"
|
||||
] as const;
|
||||
|
||||
export type CustomDnsRecordType = (typeof CUSTOM_DNS_RECORD_TYPES)[number];
|
||||
|
||||
// One record as the user sees it: a name, type and settings with one or more
|
||||
// values (e.g. both nameservers of an NS record). The DNS server answers with
|
||||
// one resource record per value.
|
||||
export type CustomDnsRecordRow = {
|
||||
customDnsRecordId: number;
|
||||
domainId: string;
|
||||
name: string;
|
||||
fullDomain: string;
|
||||
type: CustomDnsRecordType;
|
||||
values: 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;
|
||||
|
||||
// The values column holds a JSON-encoded string array
|
||||
export function parseCustomDnsRecordValues(raw: string): string[] {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((v): v is string => typeof v === "string")
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function toCustomDnsRecordRow(
|
||||
row: Omit<CustomDnsRecordRow, "values"> & { values: string }
|
||||
): CustomDnsRecordRow {
|
||||
return { ...row, values: parseCustomDnsRecordValues(row.values) };
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
"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?.values.join("\n") ?? "",
|
||||
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);
|
||||
// Each non-empty line is one value of the record
|
||||
const values = data.value
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
|
||||
const payload = {
|
||||
type: data.type,
|
||||
name: data.name,
|
||||
values,
|
||||
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 !== "CNAME" ? (
|
||||
<Textarea
|
||||
autoComplete="off"
|
||||
placeholder={
|
||||
VALUE_PLACEHOLDERS[type]
|
||||
}
|
||||
{...field}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
autoComplete="off"
|
||||
placeholder={
|
||||
VALUE_PLACEHOLDERS[type]
|
||||
}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
{type !== "CNAME" && (
|
||||
<FormDescription>
|
||||
{t(
|
||||
"customDnsRecordValueDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
)}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { AlertTriangle, ArrowUpDown, MoreHorizontal } from "lucide-react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||
import { DataTable, ExtendedColumnDef } from "@app/components/ui/data-table";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { Switch } from "@app/components/ui/switch";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
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;
|
||||
// The DNS server only serves custom records for verified domains
|
||||
verified: boolean;
|
||||
};
|
||||
|
||||
function formatValue(record: CustomDnsRecordRow, value: string): string {
|
||||
if (record.type === "MX") {
|
||||
return `${record.priority} ${value}`;
|
||||
}
|
||||
if (record.type === "SRV") {
|
||||
return `${record.priority} ${record.weight} ${record.port} ${value}`;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export default function CustomDnsRecordsTable({
|
||||
orgId,
|
||||
domainId,
|
||||
baseDomain,
|
||||
verified
|
||||
}: Props) {
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
const isDisabled = !isPaidUser(tierMatrix.customDnsRecords);
|
||||
|
||||
const {
|
||||
data: records = [],
|
||||
isRefetching,
|
||||
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)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ExtendedColumnDef<CustomDnsRecordRow>[] = [
|
||||
{
|
||||
accessorKey: "fullDomain",
|
||||
enableHiding: false,
|
||||
friendlyName: t("name"),
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("name")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const fullDomain = row.original.fullDomain;
|
||||
return (
|
||||
<CopyToClipboard
|
||||
text={fullDomain}
|
||||
displayText={fullDomain}
|
||||
isLink={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "type",
|
||||
friendlyName: t("type"),
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("type")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return <Badge variant="secondary">{row.original.type}</Badge>;
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "values",
|
||||
friendlyName: t("value"),
|
||||
header: () => <span className="p-3">{t("value")}</span>,
|
||||
cell: ({ row }) => {
|
||||
const record = row.original;
|
||||
return (
|
||||
<div className="flex flex-col gap-1 max-w-md">
|
||||
{record.values.map((value) => (
|
||||
<span key={value} className="break-all">
|
||||
{formatValue(record, value)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "ttl",
|
||||
friendlyName: t("TTL"),
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("TTL")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "enabled",
|
||||
friendlyName: t("enabled"),
|
||||
header: () => <span className="p-3">{t("enabled")}</span>,
|
||||
cell: ({ row }) => {
|
||||
const record = row.original;
|
||||
return (
|
||||
<Switch
|
||||
checked={record.enabled}
|
||||
disabled={isDisabled}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleEnabled(record, checked)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
enableHiding: false,
|
||||
header: () => <span className="p-3"></span>,
|
||||
cell: ({ row }) => {
|
||||
const record = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">
|
||||
{t("openMenu")}
|
||||
</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
disabled={isDisabled}
|
||||
onClick={() => {
|
||||
setDeletingRecord(record);
|
||||
setDeleteOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">
|
||||
{t("delete")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={isDisabled}
|
||||
onClick={() => openEditor(record)}
|
||||
>
|
||||
{t("edit")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PaidFeaturesAlert tiers={tierMatrix.customDnsRecords} />
|
||||
|
||||
{!verified && (
|
||||
<Alert variant="warning">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>
|
||||
{t("customDnsRecordsUnverifiedTitle")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("customDnsRecordsUnverifiedDescription")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{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}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CustomDnsRecordCredenza
|
||||
open={editorOpen && !isDisabled}
|
||||
setOpen={setEditorOpen}
|
||||
orgId={orgId}
|
||||
domainId={domainId}
|
||||
baseDomain={baseDomain}
|
||||
record={editingRecord}
|
||||
onSaved={() => refetch()}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={records}
|
||||
persistPageSize="custom-dns-records-table"
|
||||
title={t("customDnsRecords")}
|
||||
searchPlaceholder={t("customDnsRecordsSearch")}
|
||||
searchColumn="fullDomain"
|
||||
addButtonText={t("customDnsRecordsAdd")}
|
||||
onAdd={() => openEditor(null)}
|
||||
addButtonDisabled={isDisabled}
|
||||
onRefresh={refetch}
|
||||
isRefreshing={isRefetching}
|
||||
enableColumnVisibility={true}
|
||||
stickyLeftColumn="fullDomain"
|
||||
stickyRightColumn="actions"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,18 @@ 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}
|
||||
verified={domain.verified}
|
||||
/>
|
||||
)}
|
||||
|
||||
{domain.type === "wildcard" && !domain.configManaged && (
|
||||
<DomainCertForm
|
||||
orgId={orgId}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user