Standalone dns server

This commit is contained in:
Owen
2026-10-28 15:41:31 -04:00
parent 0be847ce9e
commit 3dd46e48ec
5 changed files with 193 additions and 10 deletions
+71
View File
@@ -0,0 +1,71 @@
# Standalone image that runs only the authoritative DNS server
# (server/private/dnsServer.ts) against an existing Pangolin database.
#
# Build: docker build -f Dockerfile.dns -t pangolin-dns .
# Run: docker run -v ./config:/app/config -p 53:53/udp pangolin-dns
#
# The mounted config directory needs the same config.yml (database
# connection) and privateConfig.yml (dns section with
# enabled: true) as the Pangolin server whose database it reads.
# Mirror of docker.io/library/node, kept in sync by
# .github/workflows/mirror-node.yml (avoids Docker Hub / ECR pull rate limits)
ARG NODE_IMAGE=ghcr.io/fosrl/node:24.22.1-slim
FROM ${NODE_IMAGE} AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
COPY package*.json .npmrc ./
RUN npm ci
COPY . .
# The DNS server is private code, so only saas/enterprise builds include it
ARG BUILD=enterprise
ARG DATABASE=pg
RUN npm run set:$DATABASE && \
npm run set:$BUILD && \
mkdir -p dist && \
node esbuild.mjs -e server/private/dnsServer.ts -o dist/dns.mjs && \
test -f dist/dns.mjs
# Copy only the node_modules files the bundle can load
RUN npm prune --omit=dev && \
node trace-deps.mjs -o /out dist/dns.mjs
FROM ${NODE_IMAGE} AS runner
WORKDIR /app
RUN apt-get update && apt-get install -y tzdata && rm -rf /var/lib/apt/lists/*
COPY --from=builder /out ./
COPY --from=builder /app/dist/dns.mjs ./dist/dns.mjs
COPY --from=builder /app/package.json ./package.json
ENV ENVIRONMENT=prod
ENV NODE_ENV=production
EXPOSE 53/udp
ARG VERSION="dev"
ARG REVISION=""
ARG CREATED=""
LABEL org.opencontainers.image.source="https://github.com/fosrl/pangolin" \
org.opencontainers.image.url="https://github.com/fosrl/pangolin" \
org.opencontainers.image.documentation="https://docs.pangolin.net" \
org.opencontainers.image.vendor="Fossorial" \
org.opencontainers.image.licenses="Fossorial Commercial License" \
org.opencontainers.image.title="Pangolin DNS" \
org.opencontainers.image.description="Standalone Pangolin authoritative DNS server" \
org.opencontainers.image.version="${VERSION}" \
org.opencontainers.image.revision="${REVISION}" \
org.opencontainers.image.created="${CREATED}"
CMD ["node", "--disable-warning=ExperimentalWarning", "--enable-source-maps", "dist/dns.mjs"]
+4 -2
View File
@@ -16,13 +16,15 @@ import { privateConfig } from "#private/lib/config";
let dnsServer: AuthoritativeDNSServer | undefined; let dnsServer: AuthoritativeDNSServer | undefined;
export async function startDnsServer() { export async function startDnsServer(
options: { requireLicense?: boolean } = {}
) {
const dnsConfig = privateConfig.getRawPrivateConfig().dns; const dnsConfig = privateConfig.getRawPrivateConfig().dns;
if (!dnsConfig || !dnsConfig.enabled) { if (!dnsConfig || !dnsConfig.enabled) {
return; return;
} }
dnsServer = new AuthoritativeDNSServer(dnsConfig.listen_port); dnsServer = new AuthoritativeDNSServer(dnsConfig.listen_port, options);
await dnsServer.start(); await dnsServer.start();
} }
+53
View File
@@ -0,0 +1,53 @@
/*
* 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.
*/
// Standalone entry point that runs only the authoritative DNS server against
// an existing Pangolin database, without the API, dashboard or schedulers.
// Migrations are left to the main Pangolin server that owns the database.
import "@server/extendZod";
import logger from "@server/logger";
import { privateConfig } from "#private/lib/config";
import { rateLimitService } from "#private/lib/rateLimit";
import { startDnsServer, stopDnsServer } from "./dns";
async function start() {
if (!privateConfig.getRawPrivateConfig().dns?.enabled) {
throw new Error(
"DNS server is not enabled - set dns.enabled: true in the private config"
);
}
await startDnsServer({ requireLicense: false });
let stopping = false;
const shutdown = async () => {
if (stopping) return;
stopping = true;
try {
await stopDnsServer();
await rateLimitService.cleanup();
} catch (error) {
logger.error("Error during DNS server shutdown:", error);
}
process.exit(0);
};
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
}
start().catch((error) => {
logger.error("Failed to start DNS server:", error);
process.exit(1);
});
+19 -8
View File
@@ -36,7 +36,7 @@ import {
import { privateConfig as config } from "#private/lib/config"; import { privateConfig as config } from "#private/lib/config";
import * as dnsResolver from "dns"; import * as dnsResolver from "dns";
import logger from "@server/logger"; import logger from "@server/logger";
import { listExitNodes } from "../exitNodes"; import { listExitNodesAcrossRegions } from "../exitNodes/exitNodes";
import { parseCustomDnsRecordValues } from "@server/routers/customDnsRecord/types"; import { parseCustomDnsRecordValues } from "@server/routers/customDnsRecord/types";
import { import {
matchCustomRecords, matchCustomRecords,
@@ -44,7 +44,6 @@ import {
type CustomZoneRecord type CustomZoneRecord
} from "./customRecordMatch"; } from "./customRecordMatch";
import { rateLimitService } from "../rateLimit"; import { rateLimitService } from "../rateLimit";
import license from "#private/license/license";
type DNSRecord = { type DNSRecord = {
id: number; id: number;
@@ -89,6 +88,7 @@ export class AuthoritativeDNSServer {
// set refresh and read from memory here. // set refresh and read from memory here.
private isLicensed: boolean = false; private isLicensed: boolean = false;
private licenseRefreshInterval: NodeJS.Timeout | null = null; private licenseRefreshInterval: NodeJS.Timeout | null = null;
private requireLicense: boolean;
// Cache for per-queryName zone resolution and SOA records // Cache for per-queryName zone resolution and SOA records
private authoritativeDomainCache: LocalCache = createLocalCache( private authoritativeDomainCache: LocalCache = createLocalCache(
@@ -100,8 +100,9 @@ export class AuthoritativeDNSServer {
DNS_CACHE_TTL_SECONDS DNS_CACHE_TTL_SECONDS
); );
constructor(port: number) { constructor(port: number, options: { requireLicense?: boolean } = {}) {
this.port = port; this.port = port;
this.requireLicense = options.requireLicense ?? true;
this.server = dgram.createSocket("udp4"); this.server = dgram.createSocket("udp4");
this.setupServer(); this.setupServer();
@@ -965,7 +966,7 @@ export class AuthoritativeDNSServer {
queryType: dns.RecordType queryType: dns.RecordType
): Promise<DNSRecord[] | null> { ): Promise<DNSRecord[] | null> {
// TODO: EVENTUALLY WE SHOULD PICK THE CLOSEST EXIT NODE TO THE USER BASED ON LATENCY // TODO: EVENTUALLY WE SHOULD PICK THE CLOSEST EXIT NODE TO THE USER BASED ON LATENCY
const exitNodesList = await listExitNodes(orgId, true); const exitNodesList = await listExitNodesAcrossRegions(orgId, true);
if (!exitNodesList || exitNodesList.length === 0) { if (!exitNodesList || exitNodesList.length === 0) {
logger.warn(`No exit nodes found for orgId: ${orgId}`); logger.warn(`No exit nodes found for orgId: ${orgId}`);
@@ -1549,6 +1550,12 @@ export class AuthoritativeDNSServer {
private async refreshLicenseStatus(): Promise<void> { private async refreshLicenseStatus(): Promise<void> {
try { try {
// Imported lazily so the standalone DNS server, which runs
// without a license, never loads the license module (it touches
// the database and starts phoning home as soon as it is imported)
const { default: license } = await import(
"#private/license/license"
);
this.isLicensed = await license.hasTier([ this.isLicensed = await license.hasTier([
"personal", "personal",
"tier2", "tier2",
@@ -1568,10 +1575,14 @@ export class AuthoritativeDNSServer {
); );
}, 60_000); }, 60_000);
await this.refreshLicenseStatus(); if (this.requireLicense) {
this.licenseRefreshInterval = setInterval(() => { await this.refreshLicenseStatus();
this.refreshLicenseStatus(); this.licenseRefreshInterval = setInterval(() => {
}, 60_000); this.refreshLicenseStatus();
}, 60_000);
} else {
this.isLicensed = true;
}
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.server.bind(this.port, (err?: Error) => { this.server.bind(this.port, (err?: Error) => {
+46
View File
@@ -333,6 +333,52 @@ export async function listExitNodes(
return exitNodesList; return exitNodesList;
} }
// Like listExitNodes, but gerbil nodes are not restricted to this server's
// region, so a caller that prefers same-region nodes (e.g. the DNS server)
// can still fall back to another region when this one has none online.
export async function listExitNodesAcrossRegions(
orgId: string,
filterOnline = false
) {
const allExitNodes = await db
.select({
exitNodeId: exitNodes.exitNodeId,
endpoint: exitNodes.endpoint,
online: exitNodes.online,
type: exitNodes.type,
region: exitNodes.region
})
.from(exitNodes)
.leftJoin(
exitNodeOrgs,
eq(exitNodes.exitNodeId, exitNodeOrgs.exitNodeId)
)
.where(
or(
eq(exitNodes.type, "gerbil"),
and(
eq(exitNodes.type, "remoteExitNode"),
eq(exitNodeOrgs.orgId, orgId)
)
)
);
const onlineNodes = allExitNodes.filter(
(node) => !filterOnline || node.online
);
const remoteExitNodesList = onlineNodes.filter(
(node) => node.type === "remoteExitNode"
);
const gerbilExitNodes = onlineNodes.filter(
(node) => node.type === "gerbil"
);
// An org's own remote exit nodes take precedence over the cloud ones
return remoteExitNodesList.length > 0
? remoteExitNodesList
: gerbilExitNodes;
}
export type ExitNodePingResult = { export type ExitNodePingResult = {
exitNodeId: number; exitNodeId: number;
latencyMs: number; latencyMs: number;