feat(smartdns): Add DNS Server and DNSSEC tools with comprehensive unit tests

This commit is contained in:
Philipp Kunz 2024-09-18 19:28:28 +02:00
parent 2cfecab96f
commit 5c06ae1edb
10 changed files with 1038 additions and 389 deletions

95
changelog.md Normal file
View File

@ -0,0 +1,95 @@
# Changelog
## 2024-09-18 - 6.1.0 - feat(smartdns)
Add DNS Server and DNSSEC tools with comprehensive unit tests
- Updated package dependencies to the latest versions
- Introduced DnsServer class for handling DNS requests over both HTTPS and UDP with support for custom handlers
- Added DnsSec class for generating and managing DNSSEC keys and DS records
- Implemented unit tests for DnsServer and Smartdns classes
## 2024-06-02 - 6.0.0 - server/client
Main description here
- **Breaking Change:** Move from client only to server + client exports.
## 2024-03-30 - 5.0.4 - maintenance
Range contains relevant changes
- Switch to new org scheme
## 2023-04-08 - 5.0.4 - core
Main description here
- Core update
- Fixes applied to the system
## 2022-07-27 - 5.0.0 - core
Update contains relevant changes
- **Breaking Change:** Major update and core changes
- Fixes and updates applied
## 2022-07-27 - 4.0.11 - core
Range contains relevant changes
- **Breaking Change:** Core update and changes applied
## 2021-08-24 - 4.0.10 - core
Range contains relevant changes
- Fixes applied to the core functionalities
## 2021-01-23 - 4.0.8 - core
Range contains relevant changes
- Updates and fixes to the core components
## 2020-08-05 - 4.0.4 - core
Range contains relevant changes
- Multiple core fixes applied
## 2020-02-15 - 4.0.0 - core
Main description here
- Core updates
- Fixes applied across the system
## 2020-02-15 - 3.0.8 - core
Core updates with major changes
- **Breaking Change:** Now uses Google DNS HTTPS API and handles DNSSEC validation
## 2019-01-07 - 3.0.6 - core
Range contains relevant changes
- Fixes and updates applied to the core
## 2018-05-13 - 3.0.4 - core
Range contains relevant changes
- Fixes applied, including `fix .checkUntilAvailable` error
## 2018-05-13 - 3.0.0 - ci
Main description here
- CI changes and updates to the access level and global packages
## 2017-07-31 - 2.0.10 - package
Update to new package name and improved record retrieval
- **Breaking Change:** Package name update and record retrieval improvements
## 2017-01-27 - 2.0.1 - maintenance
Multiple fixes and merges
## 2017-01-27 - 2.0.0 - core
Fix typings and update to better API
## 2016-11-15 - 1.0.7 - initial
Initial setup and improvements
- Initial deployment
- README improvements

View File

@ -36,18 +36,21 @@
"dependencies": { "dependencies": {
"@push.rocks/smartdelay": "^3.0.1", "@push.rocks/smartdelay": "^3.0.1",
"@push.rocks/smartenv": "^5.0.5", "@push.rocks/smartenv": "^5.0.5",
"@push.rocks/smartpromise": "^4.0.2", "@push.rocks/smartpromise": "^4.0.4",
"@push.rocks/smartrequest": "^2.0.15", "@push.rocks/smartrequest": "^2.0.15",
"@tsclass/tsclass": "^4.0.54", "@tsclass/tsclass": "^4.1.2",
"@types/dns-packet": "^5.6.5", "@types/dns-packet": "^5.6.5",
"dns-packet": "^5.6.1" "@types/elliptic": "^6.4.18",
"dns-packet": "^5.6.1",
"elliptic": "^6.5.7",
"minimatch": "^10.0.1"
}, },
"devDependencies": { "devDependencies": {
"@git.zone/tsbuild": "^2.1.66", "@git.zone/tsbuild": "^2.1.84",
"@git.zone/tsrun": "^1.2.44", "@git.zone/tsrun": "^1.2.49",
"@git.zone/tstest": "^1.0.77", "@git.zone/tstest": "^1.0.77",
"@push.rocks/tapbundle": "^5.0.8", "@push.rocks/tapbundle": "^5.2.0",
"@types/node": "^20.13.0" "@types/node": "^22.5.5"
}, },
"files": [ "files": [
"ts/**/*", "ts/**/*",

878
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,163 @@
import { expect, tap } from '@push.rocks/tapbundle';
import { tapNodeTools } from '@push.rocks/tapbundle/node';
import * as dnsPacket from 'dns-packet';
import * as https from 'https';
import * as dgram from 'dgram';
import * as smartdns from '../ts_server/index.js';
let dnsServer: smartdns.DnsServer;
tap.test('should create an instance of DnsServer', async () => {
// Use valid options
const httpsData = await tapNodeTools.createHttpsCert();
dnsServer = new smartdns.DnsServer({
httpsKey: httpsData.key,
httpsCert: httpsData.cert,
httpsPort: 8080,
udpPort: 8081,
});
expect(dnsServer).toBeInstanceOf(smartdns.DnsServer);
});
tap.test('should start the server', async () => {
await dnsServer.start();
// @ts-ignore
expect(dnsServer.httpsServer).toBeDefined();
});
tap.test('lets add a handler', async () => {
dnsServer.registerHandler('*.bleu.de', ['A'], (question) => {
return {
name: question.name,
type: 'A',
class: 'IN',
ttl: 300,
data: '127.0.0.1',
};
});
// @ts-ignore
const response = dnsServer.processDnsRequest({
type: 'query',
id: 1,
flags: 0,
questions: [
{
name: 'dnsly_a.bleu.de',
type: 'A',
class: 'IN',
},
],
answers: [],
});
expect(response.answers[0]).toEqual({
name: 'dnsly_a.bleu.de',
type: 'A',
class: 'IN',
ttl: 300,
data: '127.0.0.1',
});
});
tap.test('lets query over https', async () => {
const query = dnsPacket.encode({
type: 'query',
id: 2,
flags: dnsPacket.RECURSION_DESIRED,
questions: [
{
name: 'dnsly_a.bleu.de',
type: 'A',
class: 'IN',
},
],
});
const response = await fetch('https://localhost:8080/dns-query', {
method: 'POST',
body: query,
headers: {
'Content-Type': 'application/dns-message',
}
});
expect(response.status).toEqual(200);
const responseData = await response.arrayBuffer();
const dnsResponse = dnsPacket.decode(Buffer.from(responseData));
console.log(dnsResponse.answers[0]);
expect(dnsResponse.answers[0]).toEqual({
name: 'dnsly_a.bleu.de',
type: 'A',
class: 'IN',
ttl: 300,
flush: false,
data: '127.0.0.1',
});
});
tap.test('lets query over udp', async () => {
const client = dgram.createSocket('udp4');
const query = dnsPacket.encode({
type: 'query',
id: 3,
flags: dnsPacket.RECURSION_DESIRED,
questions: [
{
name: 'dnsly_a.bleu.de',
type: 'A',
class: 'IN',
},
],
});
const responsePromise = new Promise<dnsPacket.Packet>((resolve, reject) => {
client.on('message', (msg) => {
const dnsResponse = dnsPacket.decode(msg);
resolve(dnsResponse);
client.close();
});
client.on('error', (err) => {
reject(err);
client.close();
});
client.send(query, 8081, 'localhost', (err) => {
if (err) {
reject(err);
client.close();
}
});
});
const dnsResponse = await responsePromise;
console.log(dnsResponse.answers[0]);
expect(dnsResponse.answers[0]).toEqual({
name: 'dnsly_a.bleu.de',
type: 'A',
class: 'IN',
ttl: 300,
flush: false,
data: '127.0.0.1',
});
});
tap.test('should run for a while', async (toolsArg) => {
await toolsArg.delayFor(1000);
});
tap.test('should stop the server', async () => {
await dnsServer.stop();
// @ts-ignore
expect(dnsServer.httpsServer).toBeFalsy();
});
await tap.start();

View File

@ -24,7 +24,7 @@ export const makeNodeProcessUseDnsProvider = (providerArg: TDnsProvider) => {
export interface ISmartDnsConstructorOptions {} export interface ISmartDnsConstructorOptions {}
export interface IGoogleDNSHTTPSResponse { export interface IDnsJsonResponse {
Status: number; Status: number;
TC: boolean; TC: boolean;
RD: boolean; RD: boolean;
@ -135,14 +135,14 @@ export class Smartdns {
): Promise<plugins.tsclass.network.IDnsRecord[]> { ): Promise<plugins.tsclass.network.IDnsRecord[]> {
const requestUrl = `https://cloudflare-dns.com/dns-query?name=${recordNameArg}&type=${recordTypeArg}&do=1`; const requestUrl = `https://cloudflare-dns.com/dns-query?name=${recordNameArg}&type=${recordTypeArg}&do=1`;
const returnArray: plugins.tsclass.network.IDnsRecord[] = []; const returnArray: plugins.tsclass.network.IDnsRecord[] = [];
const getResponseBody = async (counterArg = 0): Promise<IGoogleDNSHTTPSResponse> => { const getResponseBody = async (counterArg = 0): Promise<IDnsJsonResponse> => {
const response = await plugins.smartrequest.request(requestUrl, { const response = await plugins.smartrequest.request(requestUrl, {
method: 'GET', method: 'GET',
headers: { headers: {
accept: 'application/dns-json', accept: 'application/dns-json',
}, },
}); });
const responseBody: IGoogleDNSHTTPSResponse = response.body; const responseBody: IDnsJsonResponse = response.body;
if (responseBody?.Status !== 0 && counterArg < retriesCounterArg) { if (responseBody?.Status !== 0 && counterArg < retriesCounterArg) {
await plugins.smartdelay.delayFor(500); await plugins.smartdelay.delayFor(500);
return getResponseBody(counterArg++); return getResponseBody(counterArg++);

View File

@ -16,7 +16,3 @@ export { smartdelay, smartenv, smartpromise, smartrequest };
import * as tsclass from '@tsclass/tsclass'; import * as tsclass from '@tsclass/tsclass';
export { tsclass }; export { tsclass };
// third party scope
const dns2 = smartenvInstance.getSafeNodeModule('dns2');
export { dns2 };

View File

@ -7,59 +7,61 @@ interface IDnsServerOptions {
udpPort: number; udpPort: number;
} }
class DnsServer { interface IDnsHandler {
domainPattern: string;
recordTypes: string[];
handler: (question: plugins.dnsPacket.Question) => plugins.dnsPacket.Answer | null;
}
export class DnsServer {
private udpServer: plugins.dgram.Socket; private udpServer: plugins.dgram.Socket;
private httpsServer: plugins.https.Server; private httpsServer: plugins.https.Server;
private handlers: IDnsHandler[] = [];
constructor(private options: IDnsServerOptions) { constructor(private options: IDnsServerOptions) {}
this.udpServer = plugins.dgram.createSocket('udp4');
this.setupUdpServer();
this.httpsServer = plugins.https.createServer( public registerHandler(
{ domainPattern: string,
key: plugins.fs.readFileSync(options.httpsKey), recordTypes: string[],
cert: plugins.fs.readFileSync(options.httpsCert) handler: (question: plugins.dnsPacket.Question) => plugins.dnsPacket.Answer | null
}, ): void {
this.handleHttpsRequest.bind(this) this.handlers.push({ domainPattern, recordTypes, handler });
);
} }
private setupUdpServer(): void { private processDnsRequest(request: plugins.dnsPacket.Packet): plugins.dnsPacket.Packet {
this.udpServer.on('message', (msg, rinfo) => { const response: plugins.dnsPacket.Packet = {
const request = plugins.dnsPacket.decode(msg); type: 'response',
const response = { id: request.id,
type: 'response' as const, flags: plugins.dnsPacket.RECURSION_DESIRED | plugins.dnsPacket.RECURSION_AVAILABLE,
id: request.id, questions: request.questions,
flags: plugins.dnsPacket.RECURSION_DESIRED | plugins.dnsPacket.RECURSION_AVAILABLE, answers: [],
questions: request.questions, };
answers: [] as plugins.dnsPacket.Answer[]
};
const question = request.questions[0]; for (const question of request.questions) {
console.log(`UDP query for ${question.name} of type ${question.type}`); console.log(`Query for ${question.name} of type ${question.type}`);
if (question.type === 'A') { let answered = false;
response.answers.push({
name: question.name, for (const handlerEntry of this.handlers) {
type: 'A', if (
class: 'IN', plugins.minimatch.minimatch(question.name, handlerEntry.domainPattern) &&
ttl: 300, handlerEntry.recordTypes.includes(question.type)
data: '127.0.0.1' ) {
}); const answer = handlerEntry.handler(question);
if (answer) {
response.answers.push(answer);
answered = true;
break;
}
}
} }
const responseData = plugins.dnsPacket.encode(response); if (!answered) {
this.udpServer.send(responseData, rinfo.port, rinfo.address); console.log(`No handler found for ${question.name} of type ${question.type}`);
}); }
}
this.udpServer.on('error', (err) => { return response;
console.error(`UDP Server error:\n${err.stack}`);
this.udpServer.close();
});
this.udpServer.bind(this.options.udpPort, '0.0.0.0', () => {
console.log(`UDP DNS server running on port ${this.options.udpPort}`);
});
} }
private handleHttpsRequest(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void { private handleHttpsRequest(req: plugins.http.IncomingMessage, res: plugins.http.ServerResponse): void {
@ -71,27 +73,7 @@ class DnsServer {
}).on('end', () => { }).on('end', () => {
const msg = Buffer.concat(body); const msg = Buffer.concat(body);
const request = plugins.dnsPacket.decode(msg); const request = plugins.dnsPacket.decode(msg);
const response = { const response = this.processDnsRequest(request);
type: 'response' as const,
id: request.id,
flags: plugins.dnsPacket.RECURSION_DESIRED | plugins.dnsPacket.RECURSION_AVAILABLE,
questions: request.questions,
answers: [] as plugins.dnsPacket.Answer[]
};
const question = request.questions[0];
console.log(`DoH query for ${question.name} of type ${question.type}`);
if (question.type === 'A') {
response.answers.push({
name: question.name,
type: 'A',
class: 'IN',
ttl: 300,
data: '127.0.0.1'
});
}
const responseData = plugins.dnsPacket.encode(response); const responseData = plugins.dnsPacket.encode(response);
res.writeHead(200, { 'Content-Type': 'application/dns-message' }); res.writeHead(200, { 'Content-Type': 'application/dns-message' });
res.end(responseData); res.end(responseData);
@ -102,19 +84,64 @@ class DnsServer {
} }
} }
public start(): void { public async start(): Promise<void> {
this.httpsServer.listen(this.options.httpsPort, () => { this.httpsServer = plugins.https.createServer(
console.log(`DoH server running on port ${this.options.httpsPort}`); {
key: this.options.httpsKey,
cert: this.options.httpsCert,
},
this.handleHttpsRequest.bind(this)
);
this.udpServer = plugins.dgram.createSocket('udp4');
this.udpServer.on('message', (msg, rinfo) => {
const request = plugins.dnsPacket.decode(msg);
const response = this.processDnsRequest(request);
const responseData = plugins.dnsPacket.encode(response);
this.udpServer.send(responseData, rinfo.port, rinfo.address);
}); });
this.udpServer.on('error', (err) => {
console.error(`UDP Server error:\n${err.stack}`);
this.udpServer.close();
});
const udpListeningDeferred = plugins.smartpromise.defer<void>();
const httpsListeningDeferred = plugins.smartpromise.defer<void>();
try {
this.udpServer.bind(this.options.udpPort, '0.0.0.0', () => {
console.log(`UDP DNS server running on port ${this.options.udpPort}`);
udpListeningDeferred.resolve();
});
this.httpsServer.listen(this.options.httpsPort, () => {
console.log(`HTTPS DNS server running on port ${this.options.httpsPort}`);
httpsListeningDeferred.resolve();
});
} catch (err) {
console.error('Error starting DNS server:', err);
process.exit(1);
}
await Promise.all([udpListeningDeferred.promise, httpsListeningDeferred.promise]);
} }
public stop(): void { public async stop(): Promise<void> {
const doneUdp = plugins.smartpromise.defer<void>();
const doneHttps = plugins.smartpromise.defer<void>();
this.udpServer.close(() => { this.udpServer.close(() => {
console.log('UDP DNS server stopped'); console.log('UDP DNS server stopped');
this.udpServer.unref();
this.udpServer = null;
doneUdp.resolve();
}); });
this.httpsServer.close(() => { this.httpsServer.close(() => {
console.log('HTTPS DNS server stopped'); console.log('HTTPS DNS server stopped');
this.httpsServer.unref();
this.httpsServer = null;
doneHttps.resolve();
}); });
await Promise.all([doneUdp.promise, doneHttps.promise]);
} }
} }

View File

@ -0,0 +1,83 @@
import * as plugins from './plugins.js';
interface DnssecZone {
zone: string;
algorithm: string;
keySize: number;
days: number;
}
interface DnssecKeyPair {
private: string;
public: string;
}
class DnsSec {
private zone: DnssecZone;
private keyPair: DnssecKeyPair;
private ec: any; // declare the ec instance
constructor(zone: DnssecZone) {
this.zone = zone;
this.ec = new plugins.elliptic.ec('secp256k1'); // Create an instance of the secp256k1 curve
this.keyPair = this.generateKeyPair();
}
private generateKeyPair(): DnssecKeyPair {
const key = this.ec.genKeyPair();
const privatePem = key.getPrivate().toString('hex'); // get private key in hex format
// @ts-ignore
const publicPem = key.getPublic().toString('hex'); // get public key in hex format
return {
private: privatePem,
public: publicPem
};
}
private formatPEM(pem: string, type: string): string {
const start = `-----BEGIN ${type}-----`;
const end = `-----END ${type}-----`;
const formatted = [start];
for (let i = 0; i < pem.length; i += 64) {
formatted.push(pem.slice(i, i + 64));
}
formatted.push(end);
return formatted.join('\n');
}
public getDSRecord(): string {
const publicPem = this.keyPair.public;
const publicKey = this.ec.keyFromPublic(publicPem); // Create a public key from the publicPEM
const digest = publicKey.getPublic(); // get public point
return `DS {id} 8 {algorithm} {digest} {hash-algorithm}\n`
.replace('{id}', '256') // zone hash
.replace('{algorithm}', this.getAlgorithm())
.replace('{digest}', `0x${digest.getX()}${digest.getY()}`)
.replace('{hash-algorithm}', '2');
}
private getAlgorithm(): string {
switch (this.zone.algorithm) {
case 'ECDSA':
return '8';
case 'ED25519':
return '15';
case 'RSA':
return '1';
default:
throw new Error(`Unsupported algorithm: ${this.zone.algorithm}`);
}
}
public getKeyPair(): DnssecKeyPair {
return this.keyPair;
}
public getDsAndKeyPair(): [DnssecKeyPair, string] {
const dsRecord = this.getDSRecord();
return [this.keyPair, dsRecord];
}
}

View File

@ -0,0 +1 @@
export * from './classes.dnsserver.js';

View File

@ -1,3 +1,4 @@
// node native
import fs from 'fs'; import fs from 'fs';
import http from 'http'; import http from 'http';
import https from 'https'; import https from 'https';
@ -10,8 +11,20 @@ export {
dgram, dgram,
} }
import * as dnsPacket from 'dns-packet'; // @push.rocks scope
import * as smartpromise from '@push.rocks/smartpromise';
export { export {
dnsPacket smartpromise,
}
// third party
import * as elliptic from 'elliptic';
import * as dnsPacket from 'dns-packet';
import * as minimatch from 'minimatch';
export {
dnsPacket,
elliptic,
minimatch,
} }