From 3dd46e48eca90f2dea4f2267d669d7b8a2c04545 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 28 Oct 2026 15:41:31 -0400 Subject: [PATCH] Standalone dns server --- Dockerfile.dns | 71 +++++++++++++++++++++++ server/private/dns.ts | 6 +- server/private/dnsServer.ts | 53 +++++++++++++++++ server/private/lib/dns/server.ts | 27 ++++++--- server/private/lib/exitNodes/exitNodes.ts | 46 +++++++++++++++ 5 files changed, 193 insertions(+), 10 deletions(-) create mode 100644 Dockerfile.dns create mode 100644 server/private/dnsServer.ts diff --git a/Dockerfile.dns b/Dockerfile.dns new file mode 100644 index 000000000..ad91ef7af --- /dev/null +++ b/Dockerfile.dns @@ -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"] diff --git a/server/private/dns.ts b/server/private/dns.ts index d77f789ac..6d8e2e457 100644 --- a/server/private/dns.ts +++ b/server/private/dns.ts @@ -16,13 +16,15 @@ import { privateConfig } from "#private/lib/config"; let dnsServer: AuthoritativeDNSServer | undefined; -export async function startDnsServer() { +export async function startDnsServer( + options: { requireLicense?: boolean } = {} +) { const dnsConfig = privateConfig.getRawPrivateConfig().dns; if (!dnsConfig || !dnsConfig.enabled) { return; } - dnsServer = new AuthoritativeDNSServer(dnsConfig.listen_port); + dnsServer = new AuthoritativeDNSServer(dnsConfig.listen_port, options); await dnsServer.start(); } diff --git a/server/private/dnsServer.ts b/server/private/dnsServer.ts new file mode 100644 index 000000000..d8280b591 --- /dev/null +++ b/server/private/dnsServer.ts @@ -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); +}); diff --git a/server/private/lib/dns/server.ts b/server/private/lib/dns/server.ts index 26f0907d5..bdf4c9222 100644 --- a/server/private/lib/dns/server.ts +++ b/server/private/lib/dns/server.ts @@ -36,7 +36,7 @@ import { import { privateConfig as config } from "#private/lib/config"; import * as dnsResolver from "dns"; import logger from "@server/logger"; -import { listExitNodes } from "../exitNodes"; +import { listExitNodesAcrossRegions } from "../exitNodes/exitNodes"; import { parseCustomDnsRecordValues } from "@server/routers/customDnsRecord/types"; import { matchCustomRecords, @@ -44,7 +44,6 @@ import { type CustomZoneRecord } from "./customRecordMatch"; import { rateLimitService } from "../rateLimit"; -import license from "#private/license/license"; type DNSRecord = { id: number; @@ -89,6 +88,7 @@ export class AuthoritativeDNSServer { // set refresh and read from memory here. private isLicensed: boolean = false; private licenseRefreshInterval: NodeJS.Timeout | null = null; + private requireLicense: boolean; // Cache for per-queryName zone resolution and SOA records private authoritativeDomainCache: LocalCache = createLocalCache( @@ -100,8 +100,9 @@ export class AuthoritativeDNSServer { DNS_CACHE_TTL_SECONDS ); - constructor(port: number) { + constructor(port: number, options: { requireLicense?: boolean } = {}) { this.port = port; + this.requireLicense = options.requireLicense ?? true; this.server = dgram.createSocket("udp4"); this.setupServer(); @@ -965,7 +966,7 @@ export class AuthoritativeDNSServer { queryType: dns.RecordType ): Promise { // 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) { logger.warn(`No exit nodes found for orgId: ${orgId}`); @@ -1549,6 +1550,12 @@ export class AuthoritativeDNSServer { private async refreshLicenseStatus(): Promise { 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([ "personal", "tier2", @@ -1568,10 +1575,14 @@ export class AuthoritativeDNSServer { ); }, 60_000); - await this.refreshLicenseStatus(); - this.licenseRefreshInterval = setInterval(() => { - this.refreshLicenseStatus(); - }, 60_000); + if (this.requireLicense) { + await this.refreshLicenseStatus(); + this.licenseRefreshInterval = setInterval(() => { + this.refreshLicenseStatus(); + }, 60_000); + } else { + this.isLicensed = true; + } return new Promise((resolve, reject) => { this.server.bind(this.port, (err?: Error) => { diff --git a/server/private/lib/exitNodes/exitNodes.ts b/server/private/lib/exitNodes/exitNodes.ts index 63f9f71fc..5778f94be 100644 --- a/server/private/lib/exitNodes/exitNodes.ts +++ b/server/private/lib/exitNodes/exitNodes.ts @@ -333,6 +333,52 @@ export async function listExitNodes( 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 = { exitNodeId: number; latencyMs: number;