feat(rustdns-client): add Rust DNS client binary and TypeScript IPC bridge to enable UDP and DoH resolution, RDATA decoding, and DNSSEC AD/rcode support

This commit is contained in:
2026-02-11 13:02:37 +00:00
parent 9d4db39741
commit 368430d199
24 changed files with 2805 additions and 863 deletions

View File

@@ -4,12 +4,12 @@ import * as smartdns from '../ts_client/index.js';
let testDnsClient: smartdns.Smartdns;
tap.test('should create an instance of Dnsly', async () => {
tap.test('should create an instance of Smartdns', async () => {
testDnsClient = new smartdns.Smartdns({});
expect(testDnsClient).toBeInstanceOf(smartdns.Smartdns);
});
tap.test('should get an A DNS Record', async () => {
tap.test('should get an A DNS Record (system)', async () => {
const records = await testDnsClient.getRecordsA('google.com');
expect(records).toBeInstanceOf(Array);
expect(records.length).toBeGreaterThan(0);
@@ -19,7 +19,7 @@ tap.test('should get an A DNS Record', async () => {
expect(records[0]).toHaveProperty('dnsSecEnabled');
});
tap.test('should get an AAAA Record', async () => {
tap.test('should get an AAAA Record (system)', async () => {
const records = await testDnsClient.getRecordsAAAA('google.com');
expect(records).toBeInstanceOf(Array);
expect(records.length).toBeGreaterThan(0);
@@ -29,7 +29,7 @@ tap.test('should get an AAAA Record', async () => {
expect(records[0]).toHaveProperty('dnsSecEnabled');
});
tap.test('should get a txt record', async () => {
tap.test('should get a txt record (system)', async () => {
const records = await testDnsClient.getRecordsTxt('google.com');
expect(records).toBeInstanceOf(Array);
expect(records.length).toBeGreaterThan(0);
@@ -39,7 +39,7 @@ tap.test('should get a txt record', async () => {
expect(records[0]).toHaveProperty('dnsSecEnabled');
});
tap.test('should, get a mx record for a domain', async () => {
tap.test('should get a mx record for a domain (system)', async () => {
const res = await testDnsClient.getRecords('bleu.de', 'MX');
console.log(res);
});
@@ -52,13 +52,13 @@ tap.test('should check until DNS is available', async () => {
}
});
tap.test('should check until DNS is available an return false if it fails', async () => {
tap.test('should check until DNS is available and return false if it fails', async () => {
return expect(
await testDnsClient.checkUntilAvailable('google.com', 'TXT', 'this-txt-record-does-not-exist')
).toBeFalse();
});
tap.test('should check until DNS is available an return false if it fails', async () => {
tap.test('should check until DNS is available and return false if it fails', async () => {
return expect(
await testDnsClient.checkUntilAvailable('nonexistent.example.com', 'TXT', 'sometext_txt2')
).toBeFalse();
@@ -69,10 +69,79 @@ tap.test('should get name server for hostname', async () => {
console.log(result);
});
tap.test('should detect dns sec', async () => {
const result = await testDnsClient.getRecordsA('lossless.com');
tap.test('should detect DNSSEC via DoH (Rust)', async () => {
const dohClient = new smartdns.Smartdns({ strategy: 'doh' });
const result = await dohClient.getRecordsA('lossless.com');
console.log(result[0]);
expect(result[0].dnsSecEnabled).toBeTrue();
dohClient.destroy();
});
// ── New tests for UDP and Rust-based resolution ──────────────────
tap.test('should resolve A record via UDP (Rust)', async () => {
const udpClient = new smartdns.Smartdns({ strategy: 'udp' });
const records = await udpClient.getRecordsA('google.com');
expect(records).toBeInstanceOf(Array);
expect(records.length).toBeGreaterThan(0);
expect(records[0]).toHaveProperty('name', 'google.com');
expect(records[0]).toHaveProperty('type', 'A');
expect(records[0]).toHaveProperty('value');
console.log('UDP A record:', records[0]);
udpClient.destroy();
});
tap.test('should resolve AAAA record via UDP (Rust)', async () => {
const udpClient = new smartdns.Smartdns({ strategy: 'udp' });
const records = await udpClient.getRecordsAAAA('google.com');
expect(records).toBeInstanceOf(Array);
expect(records.length).toBeGreaterThan(0);
expect(records[0]).toHaveProperty('type', 'AAAA');
console.log('UDP AAAA record:', records[0]);
udpClient.destroy();
});
tap.test('should resolve TXT record via DoH (Rust)', async () => {
const dohClient = new smartdns.Smartdns({ strategy: 'doh' });
const records = await dohClient.getRecordsTxt('google.com');
expect(records).toBeInstanceOf(Array);
expect(records.length).toBeGreaterThan(0);
expect(records[0]).toHaveProperty('type', 'TXT');
expect(records[0]).toHaveProperty('value');
console.log('DoH TXT record:', records[0]);
dohClient.destroy();
});
tap.test('should resolve with prefer-udp strategy', async () => {
const client = new smartdns.Smartdns({ strategy: 'prefer-udp' });
const records = await client.getRecordsA('google.com');
expect(records).toBeInstanceOf(Array);
expect(records.length).toBeGreaterThan(0);
expect(records[0]).toHaveProperty('type', 'A');
console.log('prefer-udp A record:', records[0]);
client.destroy();
});
tap.test('should detect DNSSEC AD flag via UDP (Rust)', async () => {
const udpClient = new smartdns.Smartdns({ strategy: 'udp' });
const records = await udpClient.getRecordsA('lossless.com');
expect(records.length).toBeGreaterThan(0);
// Note: AD flag from upstream depends on upstream resolver behavior
// Cloudflare 1.1.1.1 sets AD for DNSSEC-signed domains
console.log('UDP DNSSEC:', records[0]);
udpClient.destroy();
});
tap.test('should cleanup via destroy()', async () => {
const client = new smartdns.Smartdns({ strategy: 'udp' });
// Trigger bridge spawn
await client.getRecordsA('google.com');
// Destroy should not throw
client.destroy();
});
tap.test('cleanup default client', async () => {
testDnsClient.destroy();
});
export default tap.start();

View File

@@ -247,4 +247,115 @@ tap.test('SOA query with DNSSEC should work', async () => {
dnsServer = null;
});
tap.test('SOA serialization produces correct wire format', async () => {
const httpsData = await tapNodeTools.createHttpsCert();
const udpPort = getUniqueUdpPort();
dnsServer = new smartdns.DnsServer({
httpsKey: httpsData.key,
httpsCert: httpsData.cert,
httpsPort: getUniqueHttpsPort(),
udpPort: udpPort,
dnssecZone: 'roundtrip.example.com',
});
// Register a handler with specific SOA data we can verify round-trips correctly
const expectedSoa = {
mname: 'ns1.roundtrip.example.com',
rname: 'admin.roundtrip.example.com',
serial: 2025020101,
refresh: 7200,
retry: 1800,
expire: 1209600,
minimum: 43200,
};
dnsServer.registerHandler('roundtrip.example.com', ['SOA'], (question) => {
return {
name: question.name,
type: 'SOA',
class: 'IN',
ttl: 3600,
data: expectedSoa,
};
});
await dnsServer.start();
const client = dgram.createSocket('udp4');
// Plain UDP query without DNSSEC to test pure SOA serialization
const query = dnsPacket.encode({
type: 'query',
id: 3,
flags: dnsPacket.RECURSION_DESIRED,
questions: [
{
name: 'roundtrip.example.com',
type: 'SOA',
class: 'IN',
},
],
});
console.log('Sending plain SOA query for serialization round-trip test');
const responsePromise = new Promise<dnsPacket.Packet>((resolve, reject) => {
const timeout = setTimeout(() => {
client.close();
reject(new Error('Query timed out after 5 seconds'));
}, 5000);
client.on('message', (msg) => {
clearTimeout(timeout);
try {
const dnsResponse = dnsPacket.decode(msg);
resolve(dnsResponse);
} catch (e) {
reject(new Error(`Failed to decode response: ${e.message}`));
}
client.close();
});
client.on('error', (err) => {
clearTimeout(timeout);
reject(err);
client.close();
});
client.send(query, udpPort, 'localhost', (err) => {
if (err) {
clearTimeout(timeout);
reject(err);
client.close();
}
});
});
try {
const dnsResponse = await responsePromise;
const soaAnswers = dnsResponse.answers.filter(a => a.type === 'SOA');
expect(soaAnswers.length).toEqual(1);
const soaData = (soaAnswers[0] as any).data;
console.log('Round-trip SOA data:', soaData);
// Verify all 7 SOA fields survived the full round-trip:
// handler → Rust encode_soa → wire → dns-packet decode
expect(soaData.mname).toEqual(expectedSoa.mname);
expect(soaData.rname).toEqual(expectedSoa.rname);
expect(soaData.serial).toEqual(expectedSoa.serial);
expect(soaData.refresh).toEqual(expectedSoa.refresh);
expect(soaData.retry).toEqual(expectedSoa.retry);
expect(soaData.expire).toEqual(expectedSoa.expire);
expect(soaData.minimum).toEqual(expectedSoa.minimum);
} catch (error) {
console.error('SOA serialization round-trip test failed:', error);
throw error;
}
await stopServer(dnsServer);
dnsServer = null;
});
export default tap.start();

View File

@@ -108,14 +108,19 @@ tap.test('Test SOA with DNSSEC timing', async () => {
const soaAnswers = dnsResponse.answers.filter(a => a.type === 'SOA');
const rrsigAnswers = dnsResponse.answers.filter(a => a.type === 'RRSIG');
console.log('- SOA records:', soaAnswers.length);
console.log('- RRSIG records:', rrsigAnswers.length);
// With the fix, SOA should have its RRSIG
if (soaAnswers.length > 0) {
expect(rrsigAnswers.length).toBeGreaterThan(0);
}
// Must have exactly 1 SOA for the zone
expect(soaAnswers.length).toEqual(1);
// Must have at least 1 RRSIG covering the SOA
expect(rrsigAnswers.length).toBeGreaterThan(0);
// Verify RRSIG covers SOA type
const rrsigData = (rrsigAnswers[0] as any).data;
expect(rrsigData.typeCovered).toEqual('SOA');
} catch (error) {
console.error('DNSSEC SOA query failed:', error);
throw error;
@@ -125,4 +130,108 @@ tap.test('Test SOA with DNSSEC timing', async () => {
dnsServer = null;
});
tap.test('DNSSEC signing completes within reasonable time', async () => {
const httpsData = await tapNodeTools.createHttpsCert();
const udpPort = 8756;
dnsServer = new smartdns.DnsServer({
httpsKey: httpsData.key,
httpsCert: httpsData.cert,
httpsPort: 8757,
udpPort: udpPort,
dnssecZone: 'perf.example.com',
});
// No handlers registered — server returns SOA for nonexistent domain
await dnsServer.start();
const client = dgram.createSocket('udp4');
const query = dnsPacket.encode({
type: 'query',
id: 2,
flags: dnsPacket.RECURSION_DESIRED,
questions: [
{
name: 'nonexistent.perf.example.com',
type: 'A',
class: 'IN',
},
],
additionals: [
{
name: '.',
type: 'OPT',
ttl: 0,
flags: 0x8000, // DO bit set for DNSSEC
data: Buffer.alloc(0),
} as any,
],
});
const startTime = Date.now();
console.log('Sending DNSSEC query for performance test...');
const responsePromise = new Promise<dnsPacket.Packet>((resolve, reject) => {
const timeout = setTimeout(() => {
client.close();
const elapsed = Date.now() - startTime;
reject(new Error(`Query timed out after ${elapsed}ms — exceeds 2s budget`));
}, 2000);
client.on('message', (msg) => {
clearTimeout(timeout);
const elapsed = Date.now() - startTime;
console.log(`DNSSEC response received in ${elapsed}ms`);
try {
const dnsResponse = dnsPacket.decode(msg);
resolve(dnsResponse);
} catch (e) {
reject(new Error(`Failed to decode response: ${e.message}`));
}
client.close();
});
client.on('error', (err) => {
clearTimeout(timeout);
reject(err);
client.close();
});
client.send(query, udpPort, 'localhost', (err) => {
if (err) {
clearTimeout(timeout);
reject(err);
client.close();
}
});
});
try {
const dnsResponse = await responsePromise;
const elapsed = Date.now() - startTime;
// Response must arrive within 2 seconds (generous for CI)
expect(elapsed).toBeLessThan(2000);
// Verify correctness: SOA + RRSIG present
const soaAnswers = dnsResponse.answers.filter(a => a.type === 'SOA');
const rrsigAnswers = dnsResponse.answers.filter(a => a.type === 'RRSIG');
expect(soaAnswers.length).toEqual(1);
expect(rrsigAnswers.length).toBeGreaterThan(0);
const rrsigData = (rrsigAnswers[0] as any).data;
expect(rrsigData.typeCovered).toEqual('SOA');
console.log(`DNSSEC signing performance OK: ${elapsed}ms`);
} catch (error) {
console.error('DNSSEC performance test failed:', error);
throw error;
}
await stopServer(dnsServer);
dnsServer = null;
});
export default tap.start();