fix(dnsserver): Fix SOA record timeout issue by correcting RRSIG field formatting - Fixed RRSIG generation by using correct field name 'signersName' (not 'signerName') - Fixed label count calculation in RRSIG by filtering empty strings - Added SOA records to DNSSEC signing map for proper RRSIG generation - Added error logging and fallback values for RRSIG generation robustness - Updated test expectations to match corrected DNSSEC RRset signing behavior - Added comprehensive SOA test coverage including timeout, debug, and simple test scenarios
1026 lines
33 KiB
TypeScript
1026 lines
33 KiB
TypeScript
import * as plugins from './plugins.js';
|
|
import { DnsSec } from './classes.dnssec.js';
|
|
import * as dnsPacket from 'dns-packet';
|
|
|
|
export interface IDnsServerOptions {
|
|
httpsKey: string;
|
|
httpsCert: string;
|
|
httpsPort: number;
|
|
udpPort: number;
|
|
dnssecZone: string;
|
|
udpBindInterface?: string;
|
|
httpsBindInterface?: string;
|
|
// New options for independent manual socket control
|
|
manualUdpMode?: boolean;
|
|
manualHttpsMode?: boolean;
|
|
// Primary nameserver for SOA records (defaults to ns1.{dnssecZone})
|
|
primaryNameserver?: string;
|
|
}
|
|
|
|
export interface DnsAnswer {
|
|
name: string;
|
|
type: string;
|
|
class: string | number;
|
|
ttl: number;
|
|
data: any;
|
|
}
|
|
|
|
export interface IDnsHandler {
|
|
domainPattern: string;
|
|
recordTypes: string[];
|
|
handler: (question: dnsPacket.Question) => DnsAnswer | null;
|
|
}
|
|
|
|
// Define types for DNSSEC records if not provided
|
|
interface DNSKEYData {
|
|
flags: number;
|
|
algorithm: number;
|
|
key: Buffer;
|
|
}
|
|
|
|
// Let's Encrypt related interfaces
|
|
interface LetsEncryptOptions {
|
|
email?: string;
|
|
staging?: boolean;
|
|
certDir?: string;
|
|
}
|
|
|
|
export class DnsServer {
|
|
private udpServer: plugins.dgram.Socket;
|
|
private httpsServer: plugins.https.Server;
|
|
private handlers: IDnsHandler[] = [];
|
|
|
|
// DNSSEC related properties
|
|
private dnsSec: DnsSec;
|
|
private dnskeyRecord: DNSKEYData;
|
|
private keyTag: number;
|
|
|
|
// Track if servers are initialized
|
|
private udpServerInitialized: boolean = false;
|
|
private httpsServerInitialized: boolean = false;
|
|
|
|
constructor(private options: IDnsServerOptions) {
|
|
// Initialize DNSSEC
|
|
this.dnsSec = new DnsSec({
|
|
zone: options.dnssecZone,
|
|
algorithm: 'ECDSA', // You can change this based on your needs
|
|
keySize: 256,
|
|
days: 365,
|
|
});
|
|
|
|
// Generate DNSKEY and DS records
|
|
const { dnskeyRecord } = this.dnsSec.getDsAndKeyPair();
|
|
|
|
// Parse DNSKEY record into dns-packet format
|
|
this.dnskeyRecord = this.parseDNSKEYRecord(dnskeyRecord);
|
|
this.keyTag = this.computeKeyTag(this.dnskeyRecord);
|
|
}
|
|
|
|
/**
|
|
* Initialize servers without binding to ports
|
|
* This is called automatically by start() or can be called manually
|
|
*/
|
|
public initializeServers(): void {
|
|
this.initializeUdpServer();
|
|
this.initializeHttpsServer();
|
|
}
|
|
|
|
/**
|
|
* Initialize UDP server without binding
|
|
*/
|
|
public initializeUdpServer(): void {
|
|
if (this.udpServerInitialized) {
|
|
return;
|
|
}
|
|
|
|
// Create UDP socket without binding
|
|
const udpInterface = this.options.udpBindInterface || '0.0.0.0';
|
|
const socketType = this.isIPv6(udpInterface) ? 'udp6' : 'udp4';
|
|
this.udpServer = plugins.dgram.createSocket(socketType);
|
|
|
|
// Set up UDP message handler
|
|
this.udpServer.on('message', (msg, rinfo) => {
|
|
this.handleUdpMessage(msg, rinfo);
|
|
});
|
|
|
|
this.udpServer.on('error', (err) => {
|
|
console.error(`UDP Server error:\n${err.stack}`);
|
|
this.udpServer.close();
|
|
});
|
|
|
|
this.udpServerInitialized = true;
|
|
}
|
|
|
|
/**
|
|
* Initialize HTTPS server without binding
|
|
*/
|
|
public initializeHttpsServer(): void {
|
|
if (this.httpsServerInitialized) {
|
|
return;
|
|
}
|
|
|
|
// Create HTTPS server without listening
|
|
this.httpsServer = plugins.https.createServer(
|
|
{
|
|
key: this.options.httpsKey,
|
|
cert: this.options.httpsCert,
|
|
},
|
|
this.handleHttpsRequest.bind(this)
|
|
);
|
|
|
|
this.httpsServerInitialized = true;
|
|
}
|
|
|
|
/**
|
|
* Handle a raw TCP socket for HTTPS/DoH
|
|
* @param socket The TCP socket to handle
|
|
*/
|
|
public handleHttpsSocket(socket: plugins.net.Socket): void {
|
|
if (!this.httpsServer) {
|
|
this.initializeHttpsServer();
|
|
}
|
|
|
|
// Emit connection event on the HTTPS server
|
|
this.httpsServer.emit('connection', socket);
|
|
}
|
|
|
|
/**
|
|
* Handle a UDP message manually
|
|
* @param msg The DNS message buffer
|
|
* @param rinfo Remote address information
|
|
* @param responseCallback Optional callback to handle the response
|
|
*/
|
|
public handleUdpMessage(
|
|
msg: Buffer,
|
|
rinfo: plugins.dgram.RemoteInfo,
|
|
responseCallback?: (response: Buffer, rinfo: plugins.dgram.RemoteInfo) => void
|
|
): void {
|
|
try {
|
|
const request = dnsPacket.decode(msg);
|
|
const response = this.processDnsRequest(request);
|
|
const responseData = dnsPacket.encode(response);
|
|
|
|
if (responseCallback) {
|
|
// Use custom callback if provided
|
|
responseCallback(responseData, rinfo);
|
|
} else if (this.udpServer && !this.options.manualUdpMode) {
|
|
// Use the internal UDP server to send response
|
|
this.udpServer.send(responseData, rinfo.port, rinfo.address);
|
|
}
|
|
// In manual mode without callback, caller is responsible for sending response
|
|
} catch (err) {
|
|
console.error('Error processing UDP DNS request:', err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process a raw DNS packet and return the response
|
|
* This is useful for custom transport implementations
|
|
*/
|
|
public processRawDnsPacket(packet: Buffer): Buffer {
|
|
try {
|
|
const request = dnsPacket.decode(packet);
|
|
const response = this.processDnsRequest(request);
|
|
return dnsPacket.encode(response);
|
|
} catch (err) {
|
|
console.error('Error processing raw DNS packet:', err);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
public registerHandler(
|
|
domainPattern: string,
|
|
recordTypes: string[],
|
|
handler: (question: dnsPacket.Question) => DnsAnswer | null
|
|
): void {
|
|
this.handlers.push({ domainPattern, recordTypes, handler });
|
|
}
|
|
|
|
// Unregister a specific handler
|
|
public unregisterHandler(domainPattern: string, recordTypes: string[]): boolean {
|
|
const initialLength = this.handlers.length;
|
|
this.handlers = this.handlers.filter(handler =>
|
|
!(handler.domainPattern === domainPattern &&
|
|
recordTypes.every(type => handler.recordTypes.includes(type)))
|
|
);
|
|
return this.handlers.length < initialLength;
|
|
}
|
|
|
|
/**
|
|
* Retrieve SSL certificate for specified domains using Let's Encrypt
|
|
* @param domainNames Array of domain names to include in the certificate
|
|
* @param options Configuration options for Let's Encrypt
|
|
* @returns Object containing certificate, private key, and success status
|
|
*/
|
|
public async retrieveSslCertificate(
|
|
domainNames: string[],
|
|
options: LetsEncryptOptions = {}
|
|
): Promise<{ cert: string; key: string; success: boolean }> {
|
|
// Default options
|
|
const opts = {
|
|
email: options.email || 'admin@example.com',
|
|
staging: options.staging !== undefined ? options.staging : false,
|
|
certDir: options.certDir || './certs'
|
|
};
|
|
|
|
// Create certificate directory if it doesn't exist
|
|
if (!plugins.fs.existsSync(opts.certDir)) {
|
|
plugins.fs.mkdirSync(opts.certDir, { recursive: true });
|
|
}
|
|
|
|
// Filter domains this server is authoritative for
|
|
const authorizedDomains = this.filterAuthorizedDomains(domainNames);
|
|
|
|
if (authorizedDomains.length === 0) {
|
|
console.error('None of the provided domains are authorized for this DNS server');
|
|
return { cert: '', key: '', success: false };
|
|
}
|
|
|
|
console.log(`Retrieving SSL certificate for domains: ${authorizedDomains.join(', ')}`);
|
|
|
|
try {
|
|
// Allow for override in tests
|
|
// @ts-ignore - acmeClientOverride is added for testing purposes
|
|
const acmeClient = this.acmeClientOverride || await import('acme-client');
|
|
|
|
// Generate or load account key
|
|
const accountKeyPath = plugins.path.join(opts.certDir, 'account.key');
|
|
let accountKey: Buffer;
|
|
|
|
if (plugins.fs.existsSync(accountKeyPath)) {
|
|
accountKey = plugins.fs.readFileSync(accountKeyPath);
|
|
} else {
|
|
// Generate new account key
|
|
const { privateKey } = plugins.crypto.generateKeyPairSync('rsa', {
|
|
modulusLength: 2048,
|
|
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
|
privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
|
|
});
|
|
|
|
accountKey = Buffer.from(privateKey);
|
|
plugins.fs.writeFileSync(accountKeyPath, accountKey);
|
|
}
|
|
|
|
// Initialize ACME client
|
|
const client = new acmeClient.Client({
|
|
directoryUrl: opts.staging
|
|
? acmeClient.directory.letsencrypt.staging
|
|
: acmeClient.directory.letsencrypt.production,
|
|
accountKey: accountKey
|
|
});
|
|
|
|
// Create or update account
|
|
await client.createAccount({
|
|
termsOfServiceAgreed: true,
|
|
contact: [`mailto:${opts.email}`]
|
|
});
|
|
|
|
// Create order for certificate
|
|
const order = await client.createOrder({
|
|
identifiers: authorizedDomains.map(domain => ({
|
|
type: 'dns',
|
|
value: domain
|
|
}))
|
|
});
|
|
|
|
// Get authorizations
|
|
const authorizations = await client.getAuthorizations(order);
|
|
|
|
// Track handlers to clean up later
|
|
const challengeHandlers: { domain: string; pattern: string }[] = [];
|
|
|
|
// Process each authorization
|
|
for (const auth of authorizations) {
|
|
const domain = auth.identifier.value;
|
|
|
|
// Get DNS challenge
|
|
const challenge = auth.challenges.find((c: any) => c.type === 'dns-01');
|
|
if (!challenge) {
|
|
throw new Error(`No DNS-01 challenge found for ${domain}`);
|
|
}
|
|
|
|
// Get key authorization and DNS record value
|
|
const keyAuthorization = await client.getChallengeKeyAuthorization(challenge);
|
|
const recordValue = this.getDnsRecordValueForChallenge(keyAuthorization);
|
|
|
|
// Create challenge domain (where TXT record should be placed)
|
|
const challengeDomain = `_acme-challenge.${domain}`;
|
|
|
|
console.log(`Setting up TXT record for ${challengeDomain}: ${recordValue}`);
|
|
|
|
// Register handler for the TXT record
|
|
this.registerHandler(
|
|
challengeDomain,
|
|
['TXT'],
|
|
(question: dnsPacket.Question): DnsAnswer | null => {
|
|
if (question.name === challengeDomain && question.type === 'TXT') {
|
|
return {
|
|
name: question.name,
|
|
type: 'TXT',
|
|
class: 'IN',
|
|
ttl: 300,
|
|
data: [recordValue]
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
);
|
|
|
|
// Track the handler for cleanup
|
|
challengeHandlers.push({ domain, pattern: challengeDomain });
|
|
|
|
// Wait briefly for DNS propagation
|
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
|
|
// Complete the challenge
|
|
await client.completeChallenge(challenge);
|
|
|
|
// Wait for verification
|
|
await client.waitForValidStatus(challenge);
|
|
console.log(`Challenge for ${domain} validated successfully!`);
|
|
}
|
|
|
|
// Generate certificate key
|
|
const domainKeyPath = plugins.path.join(opts.certDir, `${authorizedDomains[0]}.key`);
|
|
const { privateKey } = plugins.crypto.generateKeyPairSync('rsa', {
|
|
modulusLength: 2048,
|
|
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
|
privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
|
|
});
|
|
|
|
plugins.fs.writeFileSync(domainKeyPath, privateKey);
|
|
|
|
// Create CSR
|
|
// Define an interface for the expected CSR result structure
|
|
interface CSRResult {
|
|
csr: Buffer;
|
|
}
|
|
|
|
// Use the forge.createCsr method and handle typing with a more direct approach
|
|
const csrResult = await acmeClient.forge.createCsr({
|
|
commonName: authorizedDomains[0],
|
|
altNames: authorizedDomains
|
|
}) as unknown as CSRResult;
|
|
|
|
// Finalize the order with the CSR
|
|
await client.finalizeOrder(order, csrResult.csr);
|
|
|
|
// Get certificate
|
|
const certificate = await client.getCertificate(order);
|
|
|
|
// Save certificate
|
|
const certPath = plugins.path.join(opts.certDir, `${authorizedDomains[0]}.cert`);
|
|
plugins.fs.writeFileSync(certPath, certificate);
|
|
|
|
// Update HTTPS server with new certificate
|
|
this.options.httpsCert = certificate;
|
|
this.options.httpsKey = privateKey;
|
|
|
|
// Restart HTTPS server with new certificate (only if not in manual HTTPS mode)
|
|
if (!this.options.manualHttpsMode) {
|
|
await this.restartHttpsServer();
|
|
}
|
|
|
|
// Clean up challenge handlers
|
|
for (const handler of challengeHandlers) {
|
|
this.unregisterHandler(handler.pattern, ['TXT']);
|
|
console.log(`Cleaned up challenge handler for ${handler.domain}`);
|
|
}
|
|
|
|
return {
|
|
cert: certificate,
|
|
key: privateKey,
|
|
success: true
|
|
};
|
|
} catch (error) {
|
|
console.error('Error retrieving SSL certificate:', error);
|
|
return { cert: '', key: '', success: false };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create DNS record value for the ACME challenge
|
|
*/
|
|
private getDnsRecordValueForChallenge(keyAuthorization: string): string {
|
|
// Create SHA-256 digest of the key authorization
|
|
const digest = plugins.crypto
|
|
.createHash('sha256')
|
|
.update(keyAuthorization)
|
|
.digest('base64')
|
|
.replace(/\+/g, '-')
|
|
.replace(/\//g, '_')
|
|
.replace(/=/g, '');
|
|
|
|
return digest;
|
|
}
|
|
|
|
/**
|
|
* Restart the HTTPS server with the new certificate
|
|
*/
|
|
private async restartHttpsServer(): Promise<void> {
|
|
return new Promise<void>((resolve, reject) => {
|
|
// First check if the server exists
|
|
if (!this.httpsServer) {
|
|
console.log('No HTTPS server to restart');
|
|
resolve();
|
|
return;
|
|
}
|
|
|
|
this.httpsServer.close(() => {
|
|
try {
|
|
// Validate certificate and key before trying to create the server
|
|
if (!this.options.httpsCert || !this.options.httpsKey) {
|
|
throw new Error('Missing certificate or key for HTTPS server');
|
|
}
|
|
|
|
// For testing, check if we have a mock certificate
|
|
if (this.options.httpsCert.includes('MOCK_CERTIFICATE')) {
|
|
console.log('Using mock certificate in test mode');
|
|
// In test mode with mock cert, we can use the original cert
|
|
// @ts-ignore - accessing acmeClientOverride for testing
|
|
if (this.acmeClientOverride) {
|
|
this.httpsServer = plugins.https.createServer(
|
|
{
|
|
key: this.options.httpsKey,
|
|
cert: this.options.httpsCert,
|
|
},
|
|
this.handleHttpsRequest.bind(this)
|
|
);
|
|
|
|
if (!this.options.manualHttpsMode) {
|
|
const httpsInterface = this.options.httpsBindInterface || '0.0.0.0';
|
|
this.httpsServer.listen(this.options.httpsPort, httpsInterface, () => {
|
|
console.log(`HTTPS DNS server restarted on ${httpsInterface}:${this.options.httpsPort} with test certificate`);
|
|
resolve();
|
|
});
|
|
} else {
|
|
resolve();
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Create the new server with the updated certificate
|
|
this.httpsServer = plugins.https.createServer(
|
|
{
|
|
key: this.options.httpsKey,
|
|
cert: this.options.httpsCert,
|
|
},
|
|
this.handleHttpsRequest.bind(this)
|
|
);
|
|
|
|
if (!this.options.manualHttpsMode) {
|
|
const httpsInterface = this.options.httpsBindInterface || '0.0.0.0';
|
|
this.httpsServer.listen(this.options.httpsPort, httpsInterface, () => {
|
|
console.log(`HTTPS DNS server restarted on ${httpsInterface}:${this.options.httpsPort} with new certificate`);
|
|
resolve();
|
|
});
|
|
} else {
|
|
resolve();
|
|
}
|
|
} catch (err) {
|
|
console.error('Error creating HTTPS server with new certificate:', err);
|
|
reject(err);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Filter domains to include only those the server is authoritative for
|
|
*/
|
|
public filterAuthorizedDomains(domainNames: string[]): string[] {
|
|
const authorizedDomains: string[] = [];
|
|
|
|
for (const domain of domainNames) {
|
|
// Handle wildcards (*.example.com)
|
|
if (domain.startsWith('*.')) {
|
|
const baseDomain = domain.substring(2);
|
|
if (this.isAuthorizedForDomain(baseDomain)) {
|
|
authorizedDomains.push(domain);
|
|
}
|
|
}
|
|
// Regular domains
|
|
else if (this.isAuthorizedForDomain(domain)) {
|
|
authorizedDomains.push(domain);
|
|
}
|
|
}
|
|
|
|
return authorizedDomains;
|
|
}
|
|
|
|
/**
|
|
* Validate if a string is a valid IP address (IPv4 or IPv6)
|
|
*/
|
|
private isValidIpAddress(ip: string): boolean {
|
|
// IPv4 pattern
|
|
const ipv4Pattern = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
|
|
// IPv6 pattern (simplified but more comprehensive)
|
|
const ipv6Pattern = /^(::1|::)$|^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/;
|
|
|
|
return ipv4Pattern.test(ip) || ipv6Pattern.test(ip);
|
|
}
|
|
|
|
/**
|
|
* Determine if an IP address is IPv6
|
|
*/
|
|
private isIPv6(ip: string): boolean {
|
|
return ip.includes(':');
|
|
}
|
|
|
|
/**
|
|
* Check if the server is authoritative for a domain
|
|
*/
|
|
private isAuthorizedForDomain(domain: string): boolean {
|
|
// Check if any handler matches this domain
|
|
for (const handler of this.handlers) {
|
|
if (plugins.minimatch.minimatch(domain, handler.domainPattern)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Also check if the domain is the DNSSEC zone itself
|
|
if (domain === this.options.dnssecZone || domain.endsWith(`.${this.options.dnssecZone}`)) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
public processDnsRequest(request: dnsPacket.Packet): dnsPacket.Packet {
|
|
const response: dnsPacket.Packet = {
|
|
type: 'response',
|
|
id: request.id,
|
|
flags:
|
|
dnsPacket.AUTHORITATIVE_ANSWER |
|
|
dnsPacket.RECURSION_AVAILABLE |
|
|
(request.flags & dnsPacket.RECURSION_DESIRED ? dnsPacket.RECURSION_DESIRED : 0),
|
|
questions: request.questions,
|
|
answers: [],
|
|
additionals: [],
|
|
};
|
|
|
|
const dnssecRequested = this.isDnssecRequested(request);
|
|
|
|
// Map to group records by type for proper DNSSEC RRset signing
|
|
const rrsetMap = new Map<string, DnsAnswer[]>();
|
|
|
|
for (const question of request.questions) {
|
|
console.log(`Query for ${question.name} of type ${question.type}`);
|
|
|
|
let answered = false;
|
|
const recordsForQuestion: DnsAnswer[] = [];
|
|
|
|
// Handle DNSKEY queries if DNSSEC is requested
|
|
if (dnssecRequested && question.type === 'DNSKEY' && question.name === this.options.dnssecZone) {
|
|
const dnskeyAnswer: DnsAnswer = {
|
|
name: question.name,
|
|
type: 'DNSKEY',
|
|
class: 'IN',
|
|
ttl: 3600,
|
|
data: this.dnskeyRecord,
|
|
};
|
|
recordsForQuestion.push(dnskeyAnswer);
|
|
answered = true;
|
|
} else {
|
|
// Collect all matching records from handlers
|
|
for (const handlerEntry of this.handlers) {
|
|
if (
|
|
plugins.minimatch.minimatch(question.name, handlerEntry.domainPattern) &&
|
|
handlerEntry.recordTypes.includes(question.type)
|
|
) {
|
|
const answer = handlerEntry.handler(question);
|
|
if (answer) {
|
|
// Ensure the answer has ttl and class
|
|
const dnsAnswer: DnsAnswer = {
|
|
...answer,
|
|
ttl: answer.ttl || 300,
|
|
class: answer.class || 'IN',
|
|
};
|
|
recordsForQuestion.push(dnsAnswer);
|
|
answered = true;
|
|
// Continue processing other handlers to allow multiple records
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add records to response and group by type for DNSSEC
|
|
if (recordsForQuestion.length > 0) {
|
|
for (const record of recordsForQuestion) {
|
|
response.answers.push(record as plugins.dnsPacket.Answer);
|
|
}
|
|
|
|
// Group records by type for DNSSEC signing
|
|
if (dnssecRequested) {
|
|
const rrsetKey = `${question.name}:${question.type}`;
|
|
rrsetMap.set(rrsetKey, recordsForQuestion);
|
|
}
|
|
}
|
|
|
|
if (!answered) {
|
|
console.log(`No handler found for ${question.name} of type ${question.type}`);
|
|
response.flags |= dnsPacket.AUTHORITATIVE_ANSWER;
|
|
const soaAnswer: DnsAnswer = {
|
|
name: question.name,
|
|
type: 'SOA',
|
|
class: 'IN',
|
|
ttl: 3600,
|
|
data: {
|
|
mname: this.options.primaryNameserver || `ns1.${this.options.dnssecZone}`,
|
|
rname: `hostmaster.${this.options.dnssecZone}`,
|
|
serial: Math.floor(Date.now() / 1000),
|
|
refresh: 3600,
|
|
retry: 600,
|
|
expire: 604800,
|
|
minimum: 86400,
|
|
},
|
|
};
|
|
response.answers.push(soaAnswer as plugins.dnsPacket.Answer);
|
|
|
|
// Add SOA record to DNSSEC signing map if DNSSEC is requested
|
|
if (dnssecRequested) {
|
|
const soaKey = `${question.name}:SOA`;
|
|
rrsetMap.set(soaKey, [soaAnswer]);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Sign RRsets if DNSSEC is requested
|
|
if (dnssecRequested) {
|
|
for (const [key, rrset] of rrsetMap) {
|
|
const [name, type] = key.split(':');
|
|
// Sign the entire RRset together
|
|
const rrsig = this.generateRRSIG(type, rrset, name);
|
|
response.answers.push(rrsig as plugins.dnsPacket.Answer);
|
|
}
|
|
}
|
|
|
|
return response;
|
|
}
|
|
|
|
private isDnssecRequested(request: dnsPacket.Packet): boolean {
|
|
if (!request.additionals) return false;
|
|
for (const additional of request.additionals) {
|
|
if (additional.type === 'OPT' && typeof additional.flags === 'number') {
|
|
// The DO bit is the 15th bit (0x8000)
|
|
if (additional.flags & 0x8000) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private generateRRSIG(
|
|
type: string,
|
|
rrset: DnsAnswer[],
|
|
name: string
|
|
): DnsAnswer {
|
|
// Prepare RRSIG data
|
|
const algorithm = this.dnsSec.getAlgorithmNumber();
|
|
const keyTag = this.keyTag;
|
|
const signerName = this.options.dnssecZone.endsWith('.') ? this.options.dnssecZone : `${this.options.dnssecZone}.`;
|
|
const inception = Math.floor(Date.now() / 1000) - 3600; // 1 hour ago
|
|
const expiration = inception + 86400; // Valid for 1 day
|
|
const ttl = rrset[0].ttl || 300;
|
|
|
|
// Serialize the RRset in canonical form
|
|
const rrsetBuffer = this.serializeRRset(rrset);
|
|
|
|
// Sign the RRset
|
|
const signature = this.dnsSec.signData(rrsetBuffer);
|
|
|
|
// Ensure all fields are defined
|
|
if (!signerName || !signature) {
|
|
console.error('RRSIG generation error - missing fields:', {
|
|
signerName,
|
|
signature: signature ? 'present' : 'missing',
|
|
algorithm,
|
|
keyTag,
|
|
type
|
|
});
|
|
}
|
|
|
|
// Construct the RRSIG record
|
|
const rrsig: DnsAnswer = {
|
|
name,
|
|
type: 'RRSIG',
|
|
class: 'IN',
|
|
ttl,
|
|
data: {
|
|
typeCovered: type, // dns-packet expects the string type
|
|
algorithm,
|
|
labels: name.split('.').filter(l => l.length > 0).length, // Fix label count
|
|
originalTTL: ttl,
|
|
expiration,
|
|
inception,
|
|
keyTag,
|
|
signersName: signerName || this.options.dnssecZone, // Note: signersName with 's'
|
|
signature: signature || Buffer.alloc(0), // Fallback to empty buffer
|
|
},
|
|
};
|
|
|
|
return rrsig;
|
|
}
|
|
|
|
private serializeRRset(rrset: DnsAnswer[]): Buffer {
|
|
// Implement canonical DNS RRset serialization as per RFC 4034 Section 6
|
|
const buffers: Buffer[] = [];
|
|
for (const rr of rrset) {
|
|
if (rr.type === 'OPT') {
|
|
continue; // Skip OPT records
|
|
}
|
|
|
|
const name = rr.name.endsWith('.') ? rr.name : rr.name + '.';
|
|
const nameBuffer = this.nameToBuffer(name.toLowerCase());
|
|
|
|
const typeValue = this.qtypeToNumber(rr.type);
|
|
const typeBuffer = Buffer.alloc(2);
|
|
typeBuffer.writeUInt16BE(typeValue, 0);
|
|
|
|
const classValue = this.classToNumber(rr.class);
|
|
const classBuffer = Buffer.alloc(2);
|
|
classBuffer.writeUInt16BE(classValue, 0);
|
|
|
|
const ttlValue = rr.ttl || 300;
|
|
const ttlBuffer = Buffer.alloc(4);
|
|
ttlBuffer.writeUInt32BE(ttlValue, 0);
|
|
|
|
// Serialize the data based on the record type
|
|
const dataBuffer = this.serializeRData(rr.type, rr.data);
|
|
|
|
const rdLengthBuffer = Buffer.alloc(2);
|
|
rdLengthBuffer.writeUInt16BE(dataBuffer.length, 0);
|
|
|
|
buffers.push(Buffer.concat([nameBuffer, typeBuffer, classBuffer, ttlBuffer, rdLengthBuffer, dataBuffer]));
|
|
}
|
|
return Buffer.concat(buffers);
|
|
}
|
|
|
|
private serializeRData(type: string, data: any): Buffer {
|
|
// Implement serialization for each record type you support
|
|
switch (type) {
|
|
case 'A':
|
|
return Buffer.from(data.split('.').map((octet: string) => parseInt(octet, 10)));
|
|
case 'AAAA':
|
|
// Handle IPv6 addresses
|
|
return Buffer.from(data.split(':').flatMap((segment: string) => {
|
|
const num = parseInt(segment, 16);
|
|
return [num >> 8, num & 0xff];
|
|
}));
|
|
case 'TXT':
|
|
// Handle TXT records for ACME challenges
|
|
if (Array.isArray(data)) {
|
|
// Combine all strings and encode as lengths and values
|
|
const buffers = data.map(str => {
|
|
const strBuf = Buffer.from(str);
|
|
const lenBuf = Buffer.alloc(1);
|
|
lenBuf.writeUInt8(strBuf.length, 0);
|
|
return Buffer.concat([lenBuf, strBuf]);
|
|
});
|
|
return Buffer.concat(buffers);
|
|
}
|
|
return Buffer.alloc(0);
|
|
case 'DNSKEY':
|
|
const dnskeyData: DNSKEYData = data;
|
|
return Buffer.concat([
|
|
Buffer.from([dnskeyData.flags >> 8, dnskeyData.flags & 0xff]),
|
|
Buffer.from([3]), // Protocol field, always 3
|
|
Buffer.from([dnskeyData.algorithm]),
|
|
dnskeyData.key,
|
|
]);
|
|
case 'NS':
|
|
// NS records contain domain names
|
|
return this.nameToBuffer(data);
|
|
case 'SOA':
|
|
// Implement SOA record serialization according to RFC 1035
|
|
const mname = this.nameToBuffer(data.mname);
|
|
const rname = this.nameToBuffer(data.rname);
|
|
const serial = Buffer.alloc(4);
|
|
serial.writeUInt32BE(data.serial, 0);
|
|
const refresh = Buffer.alloc(4);
|
|
refresh.writeUInt32BE(data.refresh, 0);
|
|
const retry = Buffer.alloc(4);
|
|
retry.writeUInt32BE(data.retry, 0);
|
|
const expire = Buffer.alloc(4);
|
|
expire.writeUInt32BE(data.expire, 0);
|
|
const minimum = Buffer.alloc(4);
|
|
minimum.writeUInt32BE(data.minimum, 0);
|
|
|
|
return Buffer.concat([mname, rname, serial, refresh, retry, expire, minimum]);
|
|
// Add cases for other record types as needed
|
|
default:
|
|
throw new Error(`Serialization for record type ${type} is not implemented.`);
|
|
}
|
|
}
|
|
|
|
private parseDNSKEYRecord(dnskeyRecord: string): DNSKEYData {
|
|
// Parse the DNSKEY record string into dns-packet format
|
|
const parts = dnskeyRecord.trim().split(/\s+/);
|
|
const flags = parseInt(parts[3], 10);
|
|
const algorithm = parseInt(parts[5], 10);
|
|
const publicKeyBase64 = parts.slice(6).join('');
|
|
const key = Buffer.from(publicKeyBase64, 'base64');
|
|
|
|
return {
|
|
flags,
|
|
algorithm,
|
|
key,
|
|
};
|
|
}
|
|
|
|
private computeKeyTag(dnskeyRecord: DNSKEYData): number {
|
|
// Compute key tag as per RFC 4034 Appendix B
|
|
const flags = dnskeyRecord.flags;
|
|
const algorithm = dnskeyRecord.algorithm;
|
|
const key = dnskeyRecord.key;
|
|
|
|
const dnskeyRdata = Buffer.concat([
|
|
Buffer.from([flags >> 8, flags & 0xff]),
|
|
Buffer.from([3]), // Protocol field, always 3
|
|
Buffer.from([algorithm]),
|
|
key,
|
|
]);
|
|
|
|
let acc = 0;
|
|
for (let i = 0; i < dnskeyRdata.length; i++) {
|
|
acc += (i & 1) ? dnskeyRdata[i] : dnskeyRdata[i] << 8;
|
|
}
|
|
acc += (acc >> 16) & 0xffff;
|
|
return acc & 0xffff;
|
|
}
|
|
|
|
private handleHttpsRequest(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void {
|
|
if (req.method === 'POST' && req.url === '/dns-query') {
|
|
let body: Buffer[] = [];
|
|
|
|
req.on('data', (chunk) => {
|
|
body.push(chunk);
|
|
}).on('end', () => {
|
|
const msg = Buffer.concat(body);
|
|
const request = dnsPacket.decode(msg);
|
|
const response = this.processDnsRequest(request);
|
|
const responseData = dnsPacket.encode(response);
|
|
res.writeHead(200, { 'Content-Type': 'application/dns-message' });
|
|
res.end(responseData);
|
|
});
|
|
} else {
|
|
res.writeHead(404);
|
|
res.end();
|
|
}
|
|
}
|
|
|
|
public async start(): Promise<void> {
|
|
// Initialize servers based on what's needed
|
|
if (!this.options.manualUdpMode) {
|
|
this.initializeUdpServer();
|
|
}
|
|
if (!this.options.manualHttpsMode) {
|
|
this.initializeHttpsServer();
|
|
}
|
|
|
|
// Handle different mode combinations
|
|
const udpManual = this.options.manualUdpMode || false;
|
|
const httpsManual = this.options.manualHttpsMode || false;
|
|
|
|
if (udpManual && httpsManual) {
|
|
console.log('DNS server started in full manual mode - ready to accept connections');
|
|
return;
|
|
} else if (udpManual && !httpsManual) {
|
|
console.log('DNS server started with manual UDP mode and automatic HTTPS binding');
|
|
} else if (!udpManual && httpsManual) {
|
|
console.log('DNS server started with automatic UDP binding and manual HTTPS mode');
|
|
}
|
|
|
|
// Validate interface addresses if provided
|
|
const udpInterface = this.options.udpBindInterface || '0.0.0.0';
|
|
const httpsInterface = this.options.httpsBindInterface || '0.0.0.0';
|
|
|
|
if (this.options.udpBindInterface && !this.isValidIpAddress(this.options.udpBindInterface)) {
|
|
throw new Error(`Invalid UDP bind interface: ${this.options.udpBindInterface}`);
|
|
}
|
|
|
|
if (this.options.httpsBindInterface && !this.isValidIpAddress(this.options.httpsBindInterface)) {
|
|
throw new Error(`Invalid HTTPS bind interface: ${this.options.httpsBindInterface}`);
|
|
}
|
|
|
|
const promises: Promise<void>[] = [];
|
|
|
|
// Bind UDP if not in manual UDP mode
|
|
if (!udpManual) {
|
|
const udpListeningDeferred = plugins.smartpromise.defer<void>();
|
|
promises.push(udpListeningDeferred.promise);
|
|
|
|
try {
|
|
this.udpServer.bind(this.options.udpPort, udpInterface, () => {
|
|
console.log(`UDP DNS server running on ${udpInterface}:${this.options.udpPort}`);
|
|
udpListeningDeferred.resolve();
|
|
});
|
|
} catch (err) {
|
|
console.error('Error starting UDP DNS server:', err);
|
|
udpListeningDeferred.reject(err);
|
|
}
|
|
}
|
|
|
|
// Bind HTTPS if not in manual HTTPS mode
|
|
if (!httpsManual) {
|
|
const httpsListeningDeferred = plugins.smartpromise.defer<void>();
|
|
promises.push(httpsListeningDeferred.promise);
|
|
|
|
try {
|
|
this.httpsServer.listen(this.options.httpsPort, httpsInterface, () => {
|
|
console.log(`HTTPS DNS server running on ${httpsInterface}:${this.options.httpsPort}`);
|
|
httpsListeningDeferred.resolve();
|
|
});
|
|
} catch (err) {
|
|
console.error('Error starting HTTPS DNS server:', err);
|
|
httpsListeningDeferred.reject(err);
|
|
}
|
|
}
|
|
|
|
if (promises.length > 0) {
|
|
await Promise.all(promises);
|
|
}
|
|
}
|
|
|
|
public async stop(): Promise<void> {
|
|
const doneUdp = plugins.smartpromise.defer<void>();
|
|
const doneHttps = plugins.smartpromise.defer<void>();
|
|
|
|
if (this.udpServer) {
|
|
this.udpServer.close(() => {
|
|
console.log('UDP DNS server stopped');
|
|
if (this.udpServer) {
|
|
this.udpServer.unref();
|
|
this.udpServer = null;
|
|
}
|
|
doneUdp.resolve();
|
|
});
|
|
} else {
|
|
doneUdp.resolve();
|
|
}
|
|
|
|
if (this.httpsServer) {
|
|
this.httpsServer.close(() => {
|
|
console.log('HTTPS DNS server stopped');
|
|
if (this.httpsServer) {
|
|
this.httpsServer.unref();
|
|
this.httpsServer = null;
|
|
}
|
|
doneHttps.resolve();
|
|
});
|
|
} else {
|
|
doneHttps.resolve();
|
|
}
|
|
|
|
await Promise.all([doneUdp.promise, doneHttps.promise]);
|
|
this.udpServerInitialized = false;
|
|
this.httpsServerInitialized = false;
|
|
}
|
|
|
|
// Helper methods
|
|
|
|
private qtypeToNumber(type: string): number {
|
|
const QTYPE_NUMBERS: { [key: string]: number } = {
|
|
'A': 1,
|
|
'NS': 2,
|
|
'CNAME': 5,
|
|
'SOA': 6,
|
|
'PTR': 12,
|
|
'MX': 15,
|
|
'TXT': 16,
|
|
'AAAA': 28,
|
|
'SRV': 33,
|
|
'DNSKEY': 48,
|
|
'RRSIG': 46,
|
|
// Add more as needed
|
|
};
|
|
return QTYPE_NUMBERS[type.toUpperCase()] || 0;
|
|
}
|
|
|
|
private classToNumber(cls: string | number): number {
|
|
const CLASS_NUMBERS: { [key: string]: number } = {
|
|
'IN': 1,
|
|
'CH': 3,
|
|
'HS': 4,
|
|
// Add more as needed
|
|
};
|
|
if (typeof cls === 'number') {
|
|
return cls;
|
|
}
|
|
return CLASS_NUMBERS[cls.toUpperCase()] || 1;
|
|
}
|
|
|
|
private nameToBuffer(name: string): Buffer {
|
|
const labels = name.split('.');
|
|
const buffers = labels.map(label => {
|
|
const len = Buffer.byteLength(label, 'utf8');
|
|
const buf = Buffer.alloc(1 + len);
|
|
buf.writeUInt8(len, 0);
|
|
buf.write(label, 1);
|
|
return buf;
|
|
});
|
|
return Buffer.concat([...buffers, Buffer.from([0])]); // Add root label
|
|
}
|
|
} |