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:
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user