mirror of
https://github.com/fosrl/pangolin.git
synced 2026-10-29 23:19:08 +01:00
Custom dns records
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user