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
+4 -2
View File
@@ -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();
}
+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 * 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<DNSRecord[] | null> {
// 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<void> {
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) => {
+46
View File
@@ -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;