mirror of
https://github.com/fosrl/pangolin.git
synced 2026-10-29 15:09:07 +01:00
Standalone dns server
This commit is contained in:
@@ -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"]
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user