Compare commits

...

13 Commits

Author SHA1 Message Date
1b9eefd70f 7.6.0 2025-09-12 17:32:03 +00:00
f29962a6dc feat(dnsserver): Return multiple matching records, improve DNSSEC RRset signing, add client resolution strategy and localhost handling, update tests 2025-09-12 17:32:03 +00:00
afd1c18496 7.5.1 2025-09-12 15:38:16 +00:00
0ea622aa8d fix(dependencies): Bump dependency versions and add pnpm workspace onlyBuiltDependencies 2025-09-12 15:38:16 +00:00
56a33dd7ae 7.5.0 2025-06-01 20:53:22 +00:00
9e5fae055f feat(dnssec): Add MX record DNSSEC support for proper serialization and authentication of mail exchange records 2025-06-01 20:53:22 +00:00
afdd6a6074 7.4.7 2025-05-30 19:49:34 +00:00
3d06131e04 fix(dnsserver): Update documentation to clarify the primaryNameserver option and SOA record behavior in the DNS server. The changes detail how the primaryNameserver configuration customizes the SOA mname, ensures proper DNSSEC signing for RRsets, and updates the configuration interface examples. 2025-05-30 19:49:34 +00:00
1811ebd4d4 7.4.6 2025-05-30 19:28:54 +00:00
e7ace9b596 7.4.5 2025-05-30 19:28:22 +00:00
f6175d1f2b 7.4.4
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
2025-05-30 19:27:37 +00:00
d67fbc87e2 7.4.3 2025-05-30 18:27:28 +00:00
b87cbbee5c feat(dnsserver): Enhance DNSSEC RRset signing and add configurable primary nameserver
- Fix DNSSEC to properly sign entire RRsets together instead of individual records
- Implement proper SOA record serialization according to RFC 1035
- Add primaryNameserver option to IDnsServerOptions for customizable SOA mname field
- Add comprehensive tests for DNSSEC RRset signing and SOA record handling
- Update documentation with v7.4.3 improvements

Co-Authored-By: User <user@example.com>
2025-05-30 18:20:55 +00:00
21 changed files with 4654 additions and 2311 deletions

1
.serena/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/cache

68
.serena/project.yml Normal file
View File

@@ -0,0 +1,68 @@
# language of the project (csharp, python, rust, java, typescript, go, cpp, or ruby)
# * For C, use cpp
# * For JavaScript, use typescript
# Special requirements:
# * csharp: Requires the presence of a .sln file in the project folder.
language: typescript
# whether to use the project's gitignore file to ignore files
# Added on 2025-04-07
ignore_all_files_in_gitignore: true
# list of additional paths to ignore
# same syntax as gitignore, so you can use * and **
# Was previously called `ignored_dirs`, please update your config if you are using that.
# Added (renamed) on 2025-04-07
ignored_paths: []
# whether the project is in read-only mode
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
# Added on 2025-04-18
read_only: false
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
# Below is the complete list of tools for convenience.
# To make sure you have the latest list of tools, and to view their descriptions,
# execute `uv run scripts/print_tool_overview.py`.
#
# * `activate_project`: Activates a project by name.
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
# * `create_text_file`: Creates/overwrites a file in the project directory.
# * `delete_lines`: Deletes a range of lines within a file.
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
# * `execute_shell_command`: Executes a shell command.
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
# * `initial_instructions`: Gets the initial instructions for the current project.
# Should only be used in settings where the system prompt cannot be set,
# e.g. in clients you have no control over, like Claude Desktop.
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
# * `insert_at_line`: Inserts content at a given line in a file.
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
# * `list_memories`: Lists memories in Serena's project-specific memory store.
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
# * `read_file`: Reads a file within the project directory.
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
# * `remove_project`: Removes a project from the Serena configuration.
# * `replace_lines`: Replaces a range of lines within a file with new content.
# * `replace_symbol_body`: Replaces the full definition of a symbol.
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
# * `search_for_pattern`: Performs a search for a pattern in the project.
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
# * `switch_modes`: Activates modes by providing a list of their names
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
excluded_tools: []
# initial prompt for the project. It will always be given to the LLM upon activating the project
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
project_name: "smartdns"

View File

@@ -1,5 +1,54 @@
# Changelog # Changelog
## 2025-09-12 - 7.6.0 - feat(dnsserver)
Return multiple matching records, improve DNSSEC RRset signing, add client resolution strategy and localhost handling, update tests
- Server: process all matching handlers for a question so multiple records (NS, A, TXT, etc.) are returned instead of stopping after the first match
- DNSSEC: sign entire RRsets together (single RRSIG per RRset) and ensure DNSKEY/DS generation and key-tag computation are handled correctly
- Server: built-in localhost handling (RFC 6761) with an enableLocalhostHandling option and synthetic answers for localhost/127.0.0.1 reverse lookups
- Server: improved SOA generation (primary nameserver handling), name serialization (trim trailing dot), and safer start/stop behavior
- Client: added resolution strategy options (doh | system | prefer-system), allowDohFallback and per-query timeout support; improved DoH and system lookup handling (proper TXT quoting and name trimming)
- Tests: updated expectations and test descriptions to reflect correct multi-record behavior and other fixes
## 2025-09-12 - 7.5.1 - fix(dependencies)
Bump dependency versions and add pnpm workspace onlyBuiltDependencies
- Bumped @push.rocks/smartenv from ^5.0.5 to ^5.0.13
- Bumped @git.zone/tsbuild from ^2.6.4 to ^2.6.8
- Bumped @git.zone/tstest from ^2.3.1 to ^2.3.7
- Added pnpm-workspace.yaml with onlyBuiltDependencies: [esbuild, mongodb-memory-server, puppeteer]
## 2025-06-01 - 7.5.0 - feat(dnssec)
Add MX record DNSSEC support for proper serialization and authentication of mail exchange records
- Serialize MX records by combining a 16-bit preference with the exchange domain name
- Enable DNSSEC signature generation for MX records to authenticate mail exchange data
- Update documentation to include the new MX record DNSSEC support in version v7.4.8
## 2025-05-30 - 7.4.7 - fix(dnsserver)
Update documentation to clarify the primaryNameserver option and SOA record behavior in the DNS server. The changes detail how the primaryNameserver configuration customizes the SOA mname, ensures proper DNSSEC signing for RRsets, and updates the configuration interface examples.
- Documented the primaryNameserver option in IDnsServerOptions with default behavior (ns1.{dnssecZone})
- Clarified SOA record generation including mname, rname, serial, and TTL fields
- Updated readme examples to demonstrate binding interfaces and proper DNS server configuration
## 2025-05-30 - 7.4.6 - docs(readme)
Document the primaryNameserver option and SOA record behavior in the DNS server documentation.
- Added comprehensive documentation for the primaryNameserver option in IDnsServerOptions
- Explained SOA record automatic generation and the role of the primary nameserver
- Clarified that only one nameserver is designated as primary in SOA records
- Updated the configuration options interface documentation with all available options
## 2025-05-30 - 7.4.3 - fix(dnsserver)
Fix DNSSEC RRset signing, SOA record timeout issues, and add configurable primary nameserver support.
- Fixed DNSSEC to sign entire RRsets together instead of individual records (one RRSIG per record type)
- Fixed SOA record serialization by implementing proper wire format encoding in serializeRData method
- Fixed RRSIG generation by using correct field names (signersName) and types (string typeCovered)
- Added configurable primary nameserver via primaryNameserver option in IDnsServerOptions
- Enhanced test coverage with comprehensive SOA and DNSSEC test scenarios
## 2025-05-30 - 7.4.2 - fix(dnsserver) ## 2025-05-30 - 7.4.2 - fix(dnsserver)
Enable multiple DNS record support by removing the premature break in processDnsRequest. Now the DNS server aggregates answers from all matching handlers for NS, A, and TXT records, and improves NS record serialization for DNSSEC. Enable multiple DNS record support by removing the premature break in processDnsRequest. Now the DNS server aggregates answers from all matching handlers for NS, A, and TXT records, and improves NS record serialization for DNSSEC.

View File

@@ -1,6 +1,6 @@
{ {
"name": "@push.rocks/smartdns", "name": "@push.rocks/smartdns",
"version": "7.4.2", "version": "7.6.0",
"private": false, "private": false,
"description": "A robust TypeScript library providing advanced DNS management and resolution capabilities including support for DNSSEC, custom DNS servers, and integration with various DNS providers.", "description": "A robust TypeScript library providing advanced DNS management and resolution capabilities including support for DNSSEC, custom DNS servers, and integration with various DNS providers.",
"exports": { "exports": {
@@ -44,7 +44,7 @@
"homepage": "https://code.foss.global/push.rocks/smartdns", "homepage": "https://code.foss.global/push.rocks/smartdns",
"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.13",
"@push.rocks/smartpromise": "^4.2.3", "@push.rocks/smartpromise": "^4.2.3",
"@push.rocks/smartrequest": "^2.1.0", "@push.rocks/smartrequest": "^2.1.0",
"@tsclass/tsclass": "^9.2.0", "@tsclass/tsclass": "^9.2.0",
@@ -56,9 +56,9 @@
"minimatch": "^10.0.1" "minimatch": "^10.0.1"
}, },
"devDependencies": { "devDependencies": {
"@git.zone/tsbuild": "^2.6.4", "@git.zone/tsbuild": "^2.6.8",
"@git.zone/tsrun": "^1.3.3", "@git.zone/tsrun": "^1.3.3",
"@git.zone/tstest": "^2.3.1", "@git.zone/tstest": "^2.3.7",
"@types/node": "^22.15.21" "@types/node": "^22.15.21"
}, },
"files": [ "files": [

4613
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

4
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,4 @@
onlyBuiltDependencies:
- esbuild
- mongodb-memory-server
- puppeteer

View File

@@ -104,8 +104,19 @@ The test suite demonstrates:
- Let's Encrypt integration requires proper domain authorization - Let's Encrypt integration requires proper domain authorization
- Handler patterns should be carefully designed to avoid open resolvers - Handler patterns should be carefully designed to avoid open resolvers
## Known Issues ## Recent Improvements (v7.4.3)
1. **DNSSEC RRSIG Generation**: When multiple records of the same type are returned, DNSSEC signing may encounter issues with the current implementation 1. **DNSSEC RRset Signing**: Fixed to properly sign entire RRsets together instead of individual records
2. **SOA Record Prefix**: The server hardcodes 'ns1.' prefix for SOA mname field which may not match actual nameserver names 2. **SOA Record Serialization**: Implemented proper SOA record encoding for DNSSEC compatibility
3. **Handler Deduplication**: If the same handler is registered multiple times, it will contribute duplicate records 3. **Configurable Primary Nameserver**: Added `primaryNameserver` option to customize SOA mname field
## Recent Improvements (v7.4.8)
1. **MX Record DNSSEC Support**: Implemented MX record serialization for DNSSEC signing
- MX records consist of a 16-bit preference value followed by the exchange domain name
- Properly serializes both components for DNSSEC signature generation
- Enables mail exchange records to be authenticated with DNSSEC
## Known Limitations
1. **Handler Deduplication**: If the same handler is registered multiple times, it will contribute duplicate records (this may be desired behavior for some use cases)

View File

@@ -198,7 +198,8 @@ const secureServer = new DnsServer({
httpsCert: 'path/to/cert.pem', httpsCert: 'path/to/cert.pem',
dnssecZone: 'example.com', dnssecZone: 'example.com',
udpBindInterface: '127.0.0.1', // Bind UDP to localhost only udpBindInterface: '127.0.0.1', // Bind UDP to localhost only
httpsBindInterface: '127.0.0.1' // Bind HTTPS to localhost only httpsBindInterface: '127.0.0.1', // Bind HTTPS to localhost only
primaryNameserver: 'ns1.example.com' // Optional: primary nameserver for SOA records (defaults to ns1.{dnssecZone})
}); });
// Register a handler for all subdomains of example.com // Register a handler for all subdomains of example.com
@@ -224,6 +225,35 @@ await dnsServer.start();
console.log('DNS Server started!'); console.log('DNS Server started!');
``` ```
### SOA Records and Primary Nameserver
The DNS server automatically generates SOA (Start of Authority) records for zones when no specific handler matches a query. The SOA record contains important zone metadata including the primary nameserver.
```typescript
const dnsServer = new DnsServer({
udpPort: 53,
httpsPort: 443,
httpsKey: 'path/to/key.pem',
httpsCert: 'path/to/cert.pem',
dnssecZone: 'example.com',
primaryNameserver: 'ns1.example.com' // Specify your actual primary nameserver
});
// Without primaryNameserver, the SOA mname defaults to 'ns1.{dnssecZone}'
// In this case, it would be 'ns1.example.com'
// The automatic SOA record includes:
// - mname: Primary nameserver (from primaryNameserver option)
// - rname: Responsible person email (hostmaster.{dnssecZone})
// - serial: Unix timestamp
// - refresh: 3600 (1 hour)
// - retry: 600 (10 minutes)
// - expire: 604800 (7 days)
// - minimum: 86400 (1 day)
```
**Important**: Even if you have multiple nameservers (NS records), only one is designated as the primary in the SOA record. All authoritative nameservers should return the same SOA record.
### DNSSEC Support ### DNSSEC Support
The DNS server includes comprehensive DNSSEC support with automatic key generation and record signing: The DNS server includes comprehensive DNSSEC support with automatic key generation and record signing:
@@ -314,9 +344,16 @@ The DNS server supports manual socket handling for advanced use cases like clust
```typescript ```typescript
export interface IDnsServerOptions { export interface IDnsServerOptions {
// ... standard options ... httpsKey: string; // Path or content of HTTPS private key
manualUdpMode?: boolean; // Handle UDP sockets manually httpsCert: string; // Path or content of HTTPS certificate
manualHttpsMode?: boolean; // Handle HTTPS sockets manually httpsPort: number; // Port for DNS-over-HTTPS
udpPort: number; // Port for standard UDP DNS
dnssecZone: string; // Zone name for DNSSEC signing
udpBindInterface?: string; // IP address to bind UDP socket (default: '0.0.0.0')
httpsBindInterface?: string; // IP address to bind HTTPS server (default: '0.0.0.0')
manualUdpMode?: boolean; // Handle UDP sockets manually
manualHttpsMode?: boolean; // Handle HTTPS sockets manually
primaryNameserver?: string; // Primary nameserver for SOA records (default: 'ns1.{dnssecZone}')
} }
``` ```

View File

@@ -1,97 +0,0 @@
# SmartDNS Improvement Plan
Command to reread CLAUDE.md: `cat /home/philkunz/.claude/CLAUDE.md`
## Critical Issue: Support Multiple DNS Records of Same Type
### Current Status: Planning
**Priority: HIGH** - This issue blocks proper DNS server operation and domain registration
### Problem Summary
The DNS server currently exits after finding the first matching handler for a query, preventing it from serving multiple records of the same type (e.g., multiple NS records, multiple A records for round-robin, multiple TXT records).
### Implementation Plan
#### Phase 1: Analysis and Testing ✅ COMPLETED
- [x] Create comprehensive test cases demonstrating the issue
- [x] Test with multiple NS records scenario
- [x] Test with multiple A records (round-robin) scenario
- [x] Test with multiple TXT records scenario
- [x] Document current behavior vs expected behavior
#### Phase 2: Core Fix Implementation ✅ COMPLETED
- [x] Remove the `break` statement in `processDnsRequest` method (line 609)
- [x] Ensure all matching handlers are processed
- [x] Accumulate all answers from matching handlers
- [x] Add NS record serialization for DNSSEC support
#### Phase 3: Handler Interface Enhancement (Optional)
- [ ] Consider allowing handlers to return arrays of records
- [ ] Update `IDnsHandler` interface to support `DnsAnswer | DnsAnswer[] | null`
- [ ] Update processing logic to handle array responses
- [ ] Maintain backward compatibility with existing handlers
#### Phase 4: Testing and Validation
- [ ] Test multiple NS records return correctly
- [ ] Test round-robin DNS with multiple A records
- [ ] Test multiple TXT records (SPF + DKIM + verification)
- [ ] Test DNSSEC signatures for multiple records
- [ ] Verify no regression in single-record scenarios
#### Phase 5: Documentation and Examples
- [ ] Update documentation with multiple record examples
- [ ] Add example for registering multiple NS records
- [ ] Add example for round-robin DNS setup
- [ ] Document best practices for handler registration
### Technical Details
#### Current Code Issue (ts_server/classes.dnsserver.ts:609)
```typescript
answered = true;
break; // <-- This prevents multiple handlers from contributing answers
```
#### Proposed Fix
```typescript
answered = true;
// Continue processing other handlers instead of breaking
```
### Success Criteria
- DNS queries return ALL matching records from ALL matching handlers
- Domain registration with multiple NS records succeeds
- Round-robin DNS works with multiple A records
- Multiple TXT records can be served for the same domain
- DNSSEC signatures are properly generated for all returned records
### Implementation Summary
#### What Was Fixed
1. **Core Issue Resolved**: Removed the `break` statement at line 609 in `processDnsRequest` that was preventing multiple handlers from contributing DNS answers
2. **NS Record Serialization**: Added NS record type support in `serializeRData` method for DNSSEC compatibility
3. **Result**: DNS server now correctly returns multiple records of the same type from different handlers
#### Test Results
- ✅ Multiple NS records now work (2+ nameservers returned)
- ✅ Round-robin DNS with multiple A records works
- ✅ Multiple TXT records (SPF, DKIM, verification) work
- ⚠️ DNSSEC RRSIG generation needs additional fixes for multiple record scenarios
#### Code Changes
```typescript
// Before (line 609):
answered = true;
break; // This was preventing multiple handlers from running
// After:
answered = true;
// Continue processing other handlers to allow multiple records
```
### Additional Improvements to Consider
1. Fix DNSSEC RRSIG generation for multiple records
2. Fix SOA record timeout issues
3. Make DNSSEC zone prefix configurable (remove hardcoded 'ns1.')
4. Improve error handling for edge cases
5. Consider handler interface enhancement to return arrays

123
test/example.primaryns.ts Normal file
View File

@@ -0,0 +1,123 @@
import * as smartdns from '../ts_server/index.js';
// Example: Using custom primary nameserver
async function exampleCustomNameserver() {
const dnsServer = new smartdns.DnsServer({
httpsKey: 'your-https-key',
httpsCert: 'your-https-cert',
httpsPort: 8443,
udpPort: 8053,
dnssecZone: 'example.com',
// Custom primary nameserver for SOA records
primaryNameserver: 'ns-primary.example.com',
});
// Register some handlers
dnsServer.registerHandler('example.com', ['NS'], (question) => {
return {
name: question.name,
type: 'NS',
class: 'IN',
ttl: 3600,
data: 'ns-primary.example.com',
};
});
dnsServer.registerHandler('example.com', ['NS'], (question) => {
return {
name: question.name,
type: 'NS',
class: 'IN',
ttl: 3600,
data: 'ns-secondary.example.com',
};
});
await dnsServer.start();
console.log('DNS server started with custom primary nameserver');
// SOA records will now use 'ns-primary.example.com' instead of 'ns1.example.com'
}
// Example: DNSSEC with multiple records (proper RRset signing)
async function exampleDnssecMultipleRecords() {
const dnsServer = new smartdns.DnsServer({
httpsKey: 'your-https-key',
httpsCert: 'your-https-cert',
httpsPort: 8443,
udpPort: 8053,
dnssecZone: 'secure.example.com',
});
// Register multiple A records for round-robin
const ips = ['192.168.1.10', '192.168.1.11', '192.168.1.12'];
for (const ip of ips) {
dnsServer.registerHandler('www.secure.example.com', ['A'], (question) => {
return {
name: question.name,
type: 'A',
class: 'IN',
ttl: 300,
data: ip,
};
});
}
await dnsServer.start();
console.log('DNS server started with DNSSEC and multiple A records');
// When queried with DNSSEC enabled, all 3 A records will be signed together
// as a single RRset with one RRSIG record (not 3 separate RRSIGs)
}
// Example: Multiple TXT records for various purposes
async function exampleMultipleTxtRecords() {
const dnsServer = new smartdns.DnsServer({
httpsKey: 'your-https-key',
httpsCert: 'your-https-cert',
httpsPort: 8443,
udpPort: 8053,
dnssecZone: 'example.com',
});
// SPF record
dnsServer.registerHandler('example.com', ['TXT'], (question) => {
return {
name: question.name,
type: 'TXT',
class: 'IN',
ttl: 3600,
data: ['v=spf1 include:_spf.google.com ~all'],
};
});
// DKIM record
dnsServer.registerHandler('example.com', ['TXT'], (question) => {
return {
name: question.name,
type: 'TXT',
class: 'IN',
ttl: 3600,
data: ['v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4...'],
};
});
// Domain verification
dnsServer.registerHandler('example.com', ['TXT'], (question) => {
return {
name: question.name,
type: 'TXT',
class: 'IN',
ttl: 3600,
data: ['google-site-verification=1234567890abcdef'],
};
});
await dnsServer.start();
console.log('DNS server started with multiple TXT records');
// All TXT records will be returned when queried
}
// Export examples for reference
export { exampleCustomNameserver, exampleDnssecMultipleRecords, exampleMultipleTxtRecords };

373
test/test.dnssec.rrset.ts Normal file
View File

@@ -0,0 +1,373 @@
import * as plugins from '../ts_server/plugins.js';
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { tapNodeTools } from '@git.zone/tstest/tapbundle_node';
import * as dnsPacket from 'dns-packet';
import * as dgram from 'dgram';
import * as smartdns from '../ts_server/index.js';
let dnsServer: smartdns.DnsServer;
// Port management for tests
let nextHttpsPort = 8500;
let nextUdpPort = 8501;
function getUniqueHttpsPort() {
return nextHttpsPort++;
}
function getUniqueUdpPort() {
return nextUdpPort++;
}
// Cleanup function for servers
async function stopServer(server: smartdns.DnsServer | null | undefined) {
if (!server) {
return;
}
try {
await server.stop();
} catch (e) {
console.log('Handled error when stopping server:', e.message || e);
}
}
tap.test('DNSSEC should sign entire RRset together, not individual records', async () => {
const httpsData = await tapNodeTools.createHttpsCert();
const udpPort = getUniqueUdpPort();
dnsServer = new smartdns.DnsServer({
httpsKey: httpsData.key,
httpsCert: httpsData.cert,
httpsPort: getUniqueHttpsPort(),
udpPort: udpPort,
dnssecZone: 'example.com',
});
// Register multiple NS record handlers
dnsServer.registerHandler('example.com', ['NS'], (question) => {
return {
name: question.name,
type: 'NS',
class: 'IN',
ttl: 3600,
data: 'ns1.example.com',
};
});
dnsServer.registerHandler('example.com', ['NS'], (question) => {
return {
name: question.name,
type: 'NS',
class: 'IN',
ttl: 3600,
data: 'ns2.example.com',
};
});
dnsServer.registerHandler('example.com', ['NS'], (question) => {
return {
name: question.name,
type: 'NS',
class: 'IN',
ttl: 3600,
data: 'ns3.example.com',
};
});
await dnsServer.start();
const client = dgram.createSocket('udp4');
// Create query with DNSSEC requested
const query = dnsPacket.encode({
type: 'query',
id: 1,
flags: dnsPacket.RECURSION_DESIRED,
questions: [
{
name: 'example.com',
type: 'NS',
class: 'IN',
},
],
additionals: [
{
name: '.',
type: 'OPT',
ttl: 0,
flags: 0x8000, // DO bit set for DNSSEC
data: Buffer.alloc(0),
} as any,
],
});
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, udpPort, 'localhost', (err) => {
if (err) {
reject(err);
client.close();
}
});
});
const dnsResponse = await responsePromise;
// Count NS and RRSIG records
const nsAnswers = dnsResponse.answers.filter(a => a.type === 'NS');
const rrsigAnswers = dnsResponse.answers.filter(a => a.type === 'RRSIG');
console.log('NS records returned:', nsAnswers.length);
console.log('RRSIG records returned:', rrsigAnswers.length);
// Should have 3 NS records and only 1 RRSIG for the entire RRset
expect(nsAnswers.length).toEqual(3);
expect(rrsigAnswers.length).toEqual(1);
// Verify RRSIG covers NS type
const rrsigData = (rrsigAnswers[0] as any).data;
expect(rrsigData.typeCovered).toEqual('NS');
await stopServer(dnsServer);
dnsServer = null;
});
tap.test('SOA records should be properly serialized and returned', async () => {
const httpsData = await tapNodeTools.createHttpsCert();
const udpPort = getUniqueUdpPort();
dnsServer = new smartdns.DnsServer({
httpsKey: httpsData.key,
httpsCert: httpsData.cert,
httpsPort: getUniqueHttpsPort(),
udpPort: udpPort,
dnssecZone: 'example.com',
});
await dnsServer.start();
const client = dgram.createSocket('udp4');
// Query for a non-existent subdomain to trigger SOA response
const query = dnsPacket.encode({
type: 'query',
id: 2,
flags: dnsPacket.RECURSION_DESIRED,
questions: [
{
name: 'nonexistent.example.com',
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, udpPort, 'localhost', (err) => {
if (err) {
reject(err);
client.close();
}
});
});
const dnsResponse = await responsePromise;
// Should have SOA record in response
const soaAnswers = dnsResponse.answers.filter(a => a.type === 'SOA');
expect(soaAnswers.length).toEqual(1);
const soaData = (soaAnswers[0] as any).data;
console.log('SOA record:', soaData);
expect(soaData.mname).toEqual('ns1.example.com');
expect(soaData.rname).toEqual('hostmaster.example.com');
expect(typeof soaData.serial).toEqual('number');
expect(soaData.refresh).toEqual(3600);
expect(soaData.retry).toEqual(600);
expect(soaData.expire).toEqual(604800);
expect(soaData.minimum).toEqual(86400);
await stopServer(dnsServer);
dnsServer = null;
});
tap.test('Primary nameserver should be configurable', async () => {
const httpsData = await tapNodeTools.createHttpsCert();
const udpPort = getUniqueUdpPort();
dnsServer = new smartdns.DnsServer({
httpsKey: httpsData.key,
httpsCert: httpsData.cert,
httpsPort: getUniqueHttpsPort(),
udpPort: udpPort,
dnssecZone: 'example.com',
primaryNameserver: 'custom-ns.example.com',
});
await dnsServer.start();
const client = dgram.createSocket('udp4');
// Query for SOA record
const query = dnsPacket.encode({
type: 'query',
id: 3,
flags: dnsPacket.RECURSION_DESIRED,
questions: [
{
name: 'example.com',
type: 'SOA',
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, udpPort, 'localhost', (err) => {
if (err) {
reject(err);
client.close();
}
});
});
const dnsResponse = await responsePromise;
// Should have SOA record with custom nameserver
const soaAnswers = dnsResponse.answers.filter(a => a.type === 'SOA');
expect(soaAnswers.length).toEqual(1);
const soaData = (soaAnswers[0] as any).data;
console.log('SOA mname:', soaData.mname);
// Should use the custom primary nameserver
expect(soaData.mname).toEqual('custom-ns.example.com');
await stopServer(dnsServer);
dnsServer = null;
});
tap.test('Multiple A records should have single RRSIG when DNSSEC is enabled', async () => {
const httpsData = await tapNodeTools.createHttpsCert();
const udpPort = getUniqueUdpPort();
dnsServer = new smartdns.DnsServer({
httpsKey: httpsData.key,
httpsCert: httpsData.cert,
httpsPort: getUniqueHttpsPort(),
udpPort: udpPort,
dnssecZone: 'example.com',
});
// Register multiple A records for round-robin
const ips = ['10.0.0.1', '10.0.0.2', '10.0.0.3'];
for (const ip of ips) {
dnsServer.registerHandler('www.example.com', ['A'], (question) => {
return {
name: question.name,
type: 'A',
class: 'IN',
ttl: 300,
data: ip,
};
});
}
await dnsServer.start();
const client = dgram.createSocket('udp4');
const query = dnsPacket.encode({
type: 'query',
id: 4,
flags: dnsPacket.RECURSION_DESIRED,
questions: [
{
name: 'www.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 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, udpPort, 'localhost', (err) => {
if (err) {
reject(err);
client.close();
}
});
});
const dnsResponse = await responsePromise;
const aAnswers = dnsResponse.answers.filter(a => a.type === 'A');
const rrsigAnswers = dnsResponse.answers.filter(a => a.type === 'RRSIG');
console.log('A records:', aAnswers.length);
console.log('RRSIG records:', rrsigAnswers.length);
// Should have 3 A records and only 1 RRSIG
expect(aAnswers.length).toEqual(3);
expect(rrsigAnswers.length).toEqual(1);
await stopServer(dnsServer);
dnsServer = null;
});
export default tap.start();

228
test/test.fixes.simple.ts Normal file
View File

@@ -0,0 +1,228 @@
import * as plugins from '../ts_server/plugins.js';
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { tapNodeTools } from '@git.zone/tstest/tapbundle_node';
import * as dnsPacket from 'dns-packet';
import * as dgram from 'dgram';
import * as smartdns from '../ts_server/index.js';
let dnsServer: smartdns.DnsServer;
// Port management for tests
let nextHttpsPort = 8600;
let nextUdpPort = 8601;
function getUniqueHttpsPort() {
return nextHttpsPort++;
}
function getUniqueUdpPort() {
return nextUdpPort++;
}
// Cleanup function for servers
async function stopServer(server: smartdns.DnsServer | null | undefined) {
if (!server) {
return;
}
try {
await server.stop();
} catch (e) {
console.log('Handled error when stopping server:', e.message || e);
}
}
tap.test('SOA records should be returned for non-existent domains', async () => {
const httpsData = await tapNodeTools.createHttpsCert();
const udpPort = getUniqueUdpPort();
dnsServer = new smartdns.DnsServer({
httpsKey: httpsData.key,
httpsCert: httpsData.cert,
httpsPort: getUniqueHttpsPort(),
udpPort: udpPort,
dnssecZone: 'example.com',
});
await dnsServer.start();
const client = dgram.createSocket('udp4');
const query = dnsPacket.encode({
type: 'query',
id: 1,
flags: dnsPacket.RECURSION_DESIRED,
questions: [
{
name: 'nonexistent.example.com',
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, udpPort, 'localhost', (err) => {
if (err) {
reject(err);
client.close();
}
});
});
const dnsResponse = await responsePromise;
console.log('✅ SOA response received');
const soaAnswers = dnsResponse.answers.filter(a => a.type === 'SOA');
expect(soaAnswers.length).toEqual(1);
const soaData = (soaAnswers[0] as any).data;
console.log('✅ SOA mname:', soaData.mname);
console.log('✅ SOA rname:', soaData.rname);
expect(soaData.mname).toEqual('ns1.example.com');
expect(soaData.rname).toEqual('hostmaster.example.com');
await stopServer(dnsServer);
dnsServer = null;
});
tap.test('Primary nameserver should be configurable', async () => {
const httpsData = await tapNodeTools.createHttpsCert();
const udpPort = getUniqueUdpPort();
dnsServer = new smartdns.DnsServer({
httpsKey: httpsData.key,
httpsCert: httpsData.cert,
httpsPort: getUniqueHttpsPort(),
udpPort: udpPort,
dnssecZone: 'example.com',
primaryNameserver: 'custom-ns.example.com',
});
await dnsServer.start();
const client = dgram.createSocket('udp4');
const query = dnsPacket.encode({
type: 'query',
id: 2,
flags: dnsPacket.RECURSION_DESIRED,
questions: [
{
name: 'nonexistent.example.com',
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, udpPort, 'localhost', (err) => {
if (err) {
reject(err);
client.close();
}
});
});
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('✅ Custom primary nameserver:', soaData.mname);
expect(soaData.mname).toEqual('custom-ns.example.com');
await stopServer(dnsServer);
dnsServer = null;
});
tap.test('Default primary nameserver with FQDN', async () => {
const httpsData = await tapNodeTools.createHttpsCert();
const udpPort = getUniqueUdpPort();
dnsServer = new smartdns.DnsServer({
httpsKey: httpsData.key,
httpsCert: httpsData.cert,
httpsPort: getUniqueHttpsPort(),
udpPort: udpPort,
dnssecZone: 'example.com',
primaryNameserver: 'ns.example.com.', // FQDN with trailing dot
});
await dnsServer.start();
const client = dgram.createSocket('udp4');
const query = dnsPacket.encode({
type: 'query',
id: 3,
flags: dnsPacket.RECURSION_DESIRED,
questions: [
{
name: 'nonexistent.example.com',
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, udpPort, 'localhost', (err) => {
if (err) {
reject(err);
client.close();
}
});
});
const dnsResponse = await responsePromise;
const soaAnswers = dnsResponse.answers.filter(a => a.type === 'SOA');
const soaData = (soaAnswers[0] as any).data;
console.log('✅ FQDN primary nameserver:', soaData.mname);
expect(soaData.mname).toEqual('ns.example.com');
await stopServer(dnsServer);
dnsServer = null;
});
export default tap.start();

View File

@@ -396,9 +396,9 @@ tap.test('should handle DNSSEC correctly with multiple records', async () => {
console.log('NS records:', nsAnswers.length); console.log('NS records:', nsAnswers.length);
console.log('RRSIG records:', rrsigAnswers.length); console.log('RRSIG records:', rrsigAnswers.length);
// With DNSSEC, each NS record should have an associated RRSIG // With DNSSEC RRset signing, all NS records share ONE RRSIG (entire RRset signed together)
expect(nsAnswers.length).toEqual(2); expect(nsAnswers.length).toEqual(2);
expect(rrsigAnswers.length).toEqual(2); expect(rrsigAnswers.length).toEqual(1);
await stopServer(dnsServer); await stopServer(dnsServer);
dnsServer = null; dnsServer = null;

View File

@@ -54,7 +54,7 @@ async function stopServer(server: smartdns.DnsServer | null | undefined) {
} }
} }
tap.test('should demonstrate the current limitation with multiple NS records', async () => { tap.test('should properly return multiple NS records', async () => {
const httpsData = await tapNodeTools.createHttpsCert(); const httpsData = await tapNodeTools.createHttpsCert();
const udpPort = getUniqueUdpPort(); const udpPort = getUniqueUdpPort();
@@ -131,15 +131,16 @@ tap.test('should demonstrate the current limitation with multiple NS records', a
console.log('Current behavior - NS records returned:', dnsResponse.answers.length); console.log('Current behavior - NS records returned:', dnsResponse.answers.length);
console.log('NS records:', dnsResponse.answers.map(a => (a as any).data)); console.log('NS records:', dnsResponse.answers.map(a => (a as any).data));
// CURRENT BEHAVIOR: Only returns 1 NS record due to the break statement // Should return all registered NS records
expect(dnsResponse.answers.length).toEqual(1); expect(dnsResponse.answers.length).toEqual(2);
expect((dnsResponse.answers[0] as any).data).toEqual('ns1.example.com'); const nsData = dnsResponse.answers.map(a => (a as any).data).sort();
expect(nsData).toEqual(['ns1.example.com', 'ns2.example.com']);
await stopServer(dnsServer); await stopServer(dnsServer);
dnsServer = null; dnsServer = null;
}); });
tap.test('should demonstrate the limitation with multiple A records (round-robin)', async () => { tap.test('should properly return multiple A records for round-robin DNS', async () => {
const httpsData = await tapNodeTools.createHttpsCert(); const httpsData = await tapNodeTools.createHttpsCert();
const udpPort = getUniqueUdpPort(); const udpPort = getUniqueUdpPort();
@@ -227,15 +228,16 @@ tap.test('should demonstrate the limitation with multiple A records (round-robin
console.log('Current behavior - A records returned:', dnsResponse.answers.length); console.log('Current behavior - A records returned:', dnsResponse.answers.length);
console.log('A records:', dnsResponse.answers.map(a => (a as any).data)); console.log('A records:', dnsResponse.answers.map(a => (a as any).data));
// CURRENT BEHAVIOR: Only returns 1 A record, preventing round-robin DNS // Should return all registered A records for round-robin DNS
expect(dnsResponse.answers.length).toEqual(1); expect(dnsResponse.answers.length).toEqual(3);
expect((dnsResponse.answers[0] as any).data).toEqual('10.0.0.1'); const aData = dnsResponse.answers.map(a => (a as any).data).sort();
expect(aData).toEqual(['10.0.0.1', '10.0.0.2', '10.0.0.3']);
await stopServer(dnsServer); await stopServer(dnsServer);
dnsServer = null; dnsServer = null;
}); });
tap.test('should demonstrate the limitation with multiple TXT records', async () => { tap.test('should properly return multiple TXT records', async () => {
const httpsData = await tapNodeTools.createHttpsCert(); const httpsData = await tapNodeTools.createHttpsCert();
const udpPort = getUniqueUdpPort(); const udpPort = getUniqueUdpPort();
@@ -323,15 +325,18 @@ tap.test('should demonstrate the limitation with multiple TXT records', async ()
console.log('Current behavior - TXT records returned:', dnsResponse.answers.length); console.log('Current behavior - TXT records returned:', dnsResponse.answers.length);
console.log('TXT records:', dnsResponse.answers.map(a => (a as any).data)); console.log('TXT records:', dnsResponse.answers.map(a => (a as any).data));
// CURRENT BEHAVIOR: Only returns 1 TXT record instead of all 3 // Should return all registered TXT records
expect(dnsResponse.answers.length).toEqual(1); expect(dnsResponse.answers.length).toEqual(3);
expect((dnsResponse.answers[0] as any).data[0]).toInclude('spf1'); const txtData = dnsResponse.answers.map(a => (a as any).data[0]).sort();
expect(txtData[0]).toInclude('google-site-verification');
expect(txtData[1]).toInclude('DKIM1');
expect(txtData[2]).toInclude('spf1');
await stopServer(dnsServer); await stopServer(dnsServer);
dnsServer = null; dnsServer = null;
}); });
tap.test('should show the current workaround pattern', async () => { tap.test('should rotate between records when using a single handler', async () => {
const httpsData = await tapNodeTools.createHttpsCert(); const httpsData = await tapNodeTools.createHttpsCert();
const udpPort = getUniqueUdpPort(); const udpPort = getUniqueUdpPort();
@@ -343,11 +348,11 @@ tap.test('should show the current workaround pattern', async () => {
dnssecZone: 'example.com', dnssecZone: 'example.com',
}); });
// WORKAROUND: Create an array to store NS records and return them from a single handler // Pattern: Create an array to store NS records and rotate through them
const nsRecords = ['ns1.example.com', 'ns2.example.com']; const nsRecords = ['ns1.example.com', 'ns2.example.com'];
let nsIndex = 0; let nsIndex = 0;
// This workaround still doesn't solve the problem because only one handler executes // This pattern rotates between records on successive queries
dnsServer.registerHandler('example.com', ['NS'], (question) => { dnsServer.registerHandler('example.com', ['NS'], (question) => {
const record = nsRecords[nsIndex % nsRecords.length]; const record = nsRecords[nsIndex % nsRecords.length];
nsIndex++; nsIndex++;
@@ -406,7 +411,7 @@ tap.test('should show the current workaround pattern', async () => {
console.log('First query NS:', (response1.answers[0] as any).data); console.log('First query NS:', (response1.answers[0] as any).data);
console.log('Second query NS:', (response2.answers[0] as any).data); console.log('Second query NS:', (response2.answers[0] as any).data);
// This workaround rotates between records but still only returns one at a time // This pattern rotates between records but returns one at a time per query
expect(response1.answers.length).toEqual(1); expect(response1.answers.length).toEqual(1);
expect(response2.answers.length).toEqual(1); expect(response2.answers.length).toEqual(1);
expect((response1.answers[0] as any).data).toEqual('ns1.example.com'); expect((response1.answers[0] as any).data).toEqual('ns1.example.com');

269
test/test.soa.debug.ts Normal file
View File

@@ -0,0 +1,269 @@
import * as plugins from '../ts_server/plugins.js';
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { tapNodeTools } from '@git.zone/tstest/tapbundle_node';
import * as dnsPacket from 'dns-packet';
import * as dgram from 'dgram';
import * as smartdns from '../ts_server/index.js';
let dnsServer: smartdns.DnsServer;
// Port management for tests
let nextHttpsPort = 8700;
let nextUdpPort = 8701;
function getUniqueHttpsPort() {
return nextHttpsPort++;
}
function getUniqueUdpPort() {
return nextUdpPort++;
}
// Cleanup function for servers
async function stopServer(server: smartdns.DnsServer | null | undefined) {
if (!server) {
return;
}
try {
await server.stop();
} catch (e) {
console.log('Handled error when stopping server:', e.message || e);
}
}
tap.test('Direct SOA query should work without timeout', async () => {
const httpsData = await tapNodeTools.createHttpsCert();
const udpPort = getUniqueUdpPort();
dnsServer = new smartdns.DnsServer({
httpsKey: httpsData.key,
httpsCert: httpsData.cert,
httpsPort: getUniqueHttpsPort(),
udpPort: udpPort,
dnssecZone: 'example.com',
});
// Register a SOA handler directly
dnsServer.registerHandler('example.com', ['SOA'], (question) => {
console.log('Direct SOA handler called for:', question.name);
return {
name: question.name,
type: 'SOA',
class: 'IN',
ttl: 3600,
data: {
mname: 'ns1.example.com',
rname: 'hostmaster.example.com',
serial: 2024010101,
refresh: 3600,
retry: 600,
expire: 604800,
minimum: 86400,
},
};
});
await dnsServer.start();
const client = dgram.createSocket('udp4');
const query = dnsPacket.encode({
type: 'query',
id: 1,
flags: dnsPacket.RECURSION_DESIRED,
questions: [
{
name: 'example.com',
type: 'SOA',
class: 'IN',
},
],
});
console.log('Sending SOA query for example.com');
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;
console.log('SOA response received:', dnsResponse.answers.length, 'answers');
const soaAnswers = dnsResponse.answers.filter(a => a.type === 'SOA');
expect(soaAnswers.length).toEqual(1);
const soaData = (soaAnswers[0] as any).data;
console.log('SOA data:', soaData);
expect(soaData.mname).toEqual('ns1.example.com');
expect(soaData.serial).toEqual(2024010101);
} catch (error) {
console.error('SOA query failed:', error);
throw error;
}
await stopServer(dnsServer);
dnsServer = null;
});
tap.test('SOA query with DNSSEC should work', async () => {
const httpsData = await tapNodeTools.createHttpsCert();
const udpPort = getUniqueUdpPort();
dnsServer = new smartdns.DnsServer({
httpsKey: httpsData.key,
httpsCert: httpsData.cert,
httpsPort: getUniqueHttpsPort(),
udpPort: udpPort,
dnssecZone: 'example.com',
});
await dnsServer.start();
const client = dgram.createSocket('udp4');
const query = dnsPacket.encode({
type: 'query',
id: 2,
flags: dnsPacket.RECURSION_DESIRED,
questions: [
{
name: 'nonexistent.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,
],
});
console.log('Sending query for nonexistent domain with DNSSEC');
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;
console.log('Response received with', dnsResponse.answers.length, 'answers');
const soaAnswers = dnsResponse.answers.filter(a => a.type === 'SOA');
console.log('SOA records found:', soaAnswers.length);
if (soaAnswers.length > 0) {
const soaData = (soaAnswers[0] as any).data;
console.log('SOA data:', soaData);
}
} catch (error) {
console.error('SOA query with DNSSEC failed:', error);
throw error;
}
await stopServer(dnsServer);
dnsServer = null;
});
tap.test('Test raw SOA serialization', async () => {
const httpsData = await tapNodeTools.createHttpsCert();
dnsServer = new smartdns.DnsServer({
httpsKey: httpsData.key,
httpsCert: httpsData.cert,
httpsPort: getUniqueHttpsPort(),
udpPort: getUniqueUdpPort(),
dnssecZone: 'example.com',
});
// Test the serializeRData method directly
const soaData = {
mname: 'ns1.example.com',
rname: 'hostmaster.example.com',
serial: 2024010101,
refresh: 3600,
retry: 600,
expire: 604800,
minimum: 86400,
};
try {
// @ts-ignore - accessing private method for testing
const serialized = dnsServer.serializeRData('SOA', soaData);
console.log('SOA serialized successfully, buffer length:', serialized.length);
expect(serialized.length).toBeGreaterThan(0);
// The buffer should contain the serialized domain names + 5 * 4 bytes for the numbers
// Domain names have variable length, but should be at least 20 bytes total
expect(serialized.length).toBeGreaterThan(20);
} catch (error) {
console.error('SOA serialization failed:', error);
throw error;
}
});
export default tap.start();

271
test/test.soa.final.ts Normal file
View File

@@ -0,0 +1,271 @@
import * as plugins from '../ts_server/plugins.js';
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { tapNodeTools } from '@git.zone/tstest/tapbundle_node';
import * as dnsPacket from 'dns-packet';
import * as dgram from 'dgram';
import * as smartdns from '../ts_server/index.js';
let dnsServer: smartdns.DnsServer;
// Port management for tests
let nextHttpsPort = 8900;
let nextUdpPort = 8901;
function getUniqueHttpsPort() {
return nextHttpsPort++;
}
function getUniqueUdpPort() {
return nextUdpPort++;
}
// Cleanup function for servers
async function stopServer(server: smartdns.DnsServer | null | undefined) {
if (!server) {
return;
}
try {
await server.stop();
} catch (e) {
console.log('Handled error when stopping server:', e.message || e);
}
}
tap.test('SOA records work for all scenarios', async () => {
const httpsData = await tapNodeTools.createHttpsCert();
const udpPort = getUniqueUdpPort();
dnsServer = new smartdns.DnsServer({
httpsKey: httpsData.key,
httpsCert: httpsData.cert,
httpsPort: getUniqueHttpsPort(),
udpPort: udpPort,
dnssecZone: 'example.com',
primaryNameserver: 'ns.example.com',
});
// Register SOA handler for the zone
dnsServer.registerHandler('example.com', ['SOA'], (question) => {
console.log('SOA handler called for:', question.name);
return {
name: question.name,
type: 'SOA',
class: 'IN',
ttl: 3600,
data: {
mname: 'ns.example.com',
rname: 'admin.example.com',
serial: 2024010101,
refresh: 3600,
retry: 600,
expire: 604800,
minimum: 86400,
},
};
});
// Register some other records
dnsServer.registerHandler('example.com', ['A'], (question) => {
return {
name: question.name,
type: 'A',
class: 'IN',
ttl: 300,
data: '192.168.1.1',
};
});
await dnsServer.start();
const client = dgram.createSocket('udp4');
// Test 1: Direct SOA query
console.log('\n--- Test 1: Direct SOA query ---');
const soaQuery = dnsPacket.encode({
type: 'query',
id: 1,
flags: dnsPacket.RECURSION_DESIRED,
questions: [
{
name: 'example.com',
type: 'SOA',
class: 'IN',
},
],
});
let response = await new Promise<dnsPacket.Packet>((resolve, reject) => {
const timeout = setTimeout(() => {
client.close();
reject(new Error('Query timed out'));
}, 2000);
client.on('message', (msg) => {
clearTimeout(timeout);
const dnsResponse = dnsPacket.decode(msg);
resolve(dnsResponse);
client.removeAllListeners();
});
client.on('error', (err) => {
clearTimeout(timeout);
reject(err);
client.close();
});
client.send(soaQuery, udpPort, 'localhost');
});
console.log('Direct SOA query response:', response.answers.length, 'answers');
expect(response.answers.length).toEqual(1);
expect(response.answers[0].type).toEqual('SOA');
// Test 2: Non-existent domain query (should get SOA in authority)
console.log('\n--- Test 2: Non-existent domain query ---');
const nxQuery = dnsPacket.encode({
type: 'query',
id: 2,
flags: dnsPacket.RECURSION_DESIRED,
questions: [
{
name: 'nonexistent.example.com',
type: 'A',
class: 'IN',
},
],
});
response = await new Promise<dnsPacket.Packet>((resolve, reject) => {
const timeout = setTimeout(() => {
client.close();
reject(new Error('Query timed out'));
}, 2000);
client.on('message', (msg) => {
clearTimeout(timeout);
const dnsResponse = dnsPacket.decode(msg);
resolve(dnsResponse);
client.removeAllListeners();
});
client.send(nxQuery, udpPort, 'localhost');
});
console.log('Non-existent query response:', response.answers.length, 'answers');
const soaAnswers = response.answers.filter(a => a.type === 'SOA');
expect(soaAnswers.length).toEqual(1);
// Test 3: SOA with DNSSEC
console.log('\n--- Test 3: SOA query with DNSSEC ---');
const dnssecQuery = dnsPacket.encode({
type: 'query',
id: 3,
flags: dnsPacket.RECURSION_DESIRED,
questions: [
{
name: 'example.com',
type: 'SOA',
class: 'IN',
},
],
additionals: [
{
name: '.',
type: 'OPT',
ttl: 0,
flags: 0x8000, // DO bit
data: Buffer.alloc(0),
} as any,
],
});
response = await new Promise<dnsPacket.Packet>((resolve, reject) => {
const timeout = setTimeout(() => {
client.close();
reject(new Error('Query timed out'));
}, 2000);
client.on('message', (msg) => {
clearTimeout(timeout);
const dnsResponse = dnsPacket.decode(msg);
resolve(dnsResponse);
client.removeAllListeners();
});
client.send(dnssecQuery, udpPort, 'localhost');
});
console.log('DNSSEC SOA query response:', response.answers.length, 'answers');
console.log('Answer types:', response.answers.map(a => a.type));
expect(response.answers.length).toEqual(2); // SOA + RRSIG
expect(response.answers.some(a => a.type === 'SOA')).toEqual(true);
expect(response.answers.some(a => a.type === 'RRSIG')).toEqual(true);
client.close();
await stopServer(dnsServer);
dnsServer = null;
});
tap.test('Configurable primary nameserver works correctly', async () => {
const httpsData = await tapNodeTools.createHttpsCert();
const udpPort = getUniqueUdpPort();
dnsServer = new smartdns.DnsServer({
httpsKey: httpsData.key,
httpsCert: httpsData.cert,
httpsPort: getUniqueHttpsPort(),
udpPort: udpPort,
dnssecZone: 'test.com',
primaryNameserver: 'master.test.com',
});
await dnsServer.start();
const client = dgram.createSocket('udp4');
const query = dnsPacket.encode({
type: 'query',
id: 1,
flags: dnsPacket.RECURSION_DESIRED,
questions: [
{
name: 'nonexistent.test.com',
type: 'A',
class: 'IN',
},
],
});
const response = await new Promise<dnsPacket.Packet>((resolve, reject) => {
const timeout = setTimeout(() => {
client.close();
reject(new Error('Query timed out'));
}, 2000);
client.on('message', (msg) => {
clearTimeout(timeout);
const dnsResponse = dnsPacket.decode(msg);
resolve(dnsResponse);
});
client.on('error', (err) => {
clearTimeout(timeout);
reject(err);
client.close();
});
client.send(query, udpPort, 'localhost');
});
const soaAnswers = response.answers.filter(a => a.type === 'SOA');
console.log('✅ Configured primary nameserver:', (soaAnswers[0] as any).data.mname);
expect((soaAnswers[0] as any).data.mname).toEqual('master.test.com');
client.close();
await stopServer(dnsServer);
dnsServer = null;
});
export default tap.start();

201
test/test.soa.simple.ts Normal file
View File

@@ -0,0 +1,201 @@
import * as plugins from '../ts_server/plugins.js';
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { tapNodeTools } from '@git.zone/tstest/tapbundle_node';
import * as dnsPacket from 'dns-packet';
import * as dgram from 'dgram';
import * as smartdns from '../ts_server/index.js';
let dnsServer: smartdns.DnsServer;
// Port management for tests
let nextHttpsPort = 8800;
let nextUdpPort = 8801;
function getUniqueHttpsPort() {
return nextHttpsPort++;
}
function getUniqueUdpPort() {
return nextUdpPort++;
}
// Cleanup function for servers
async function stopServer(server: smartdns.DnsServer | null | undefined) {
if (!server) {
return;
}
try {
await server.stop();
} catch (e) {
console.log('Handled error when stopping server:', e.message || e);
}
}
tap.test('Simple SOA query without DNSSEC', async () => {
const httpsData = await tapNodeTools.createHttpsCert();
const udpPort = getUniqueUdpPort();
dnsServer = new smartdns.DnsServer({
httpsKey: httpsData.key,
httpsCert: httpsData.cert,
httpsPort: getUniqueHttpsPort(),
udpPort: udpPort,
dnssecZone: 'example.com',
});
await dnsServer.start();
const client = dgram.createSocket('udp4');
// Query for a non-existent domain WITHOUT DNSSEC
const query = dnsPacket.encode({
type: 'query',
id: 1,
flags: dnsPacket.RECURSION_DESIRED,
questions: [
{
name: 'nonexistent.example.com',
type: 'A',
class: 'IN',
},
],
});
const responsePromise = new Promise<dnsPacket.Packet>((resolve, reject) => {
const timeout = setTimeout(() => {
client.close();
reject(new Error('Query timed out'));
}, 2000);
client.on('message', (msg) => {
clearTimeout(timeout);
try {
const dnsResponse = dnsPacket.decode(msg);
resolve(dnsResponse);
} catch (e) {
reject(e);
}
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();
}
});
});
const dnsResponse = await responsePromise;
console.log('✅ SOA response without DNSSEC received');
const soaAnswers = dnsResponse.answers.filter(a => a.type === 'SOA');
expect(soaAnswers.length).toEqual(1);
const soaData = (soaAnswers[0] as any).data;
console.log('✅ SOA data:', soaData.mname);
await stopServer(dnsServer);
dnsServer = null;
});
tap.test('Direct SOA query without DNSSEC', async () => {
const httpsData = await tapNodeTools.createHttpsCert();
const udpPort = getUniqueUdpPort();
dnsServer = new smartdns.DnsServer({
httpsKey: httpsData.key,
httpsCert: httpsData.cert,
httpsPort: getUniqueHttpsPort(),
udpPort: udpPort,
dnssecZone: 'example.com',
});
// Register direct SOA handler
dnsServer.registerHandler('example.com', ['SOA'], (question) => {
return {
name: question.name,
type: 'SOA',
class: 'IN',
ttl: 3600,
data: {
mname: 'ns1.example.com',
rname: 'hostmaster.example.com',
serial: 2024010101,
refresh: 3600,
retry: 600,
expire: 604800,
minimum: 86400,
},
};
});
await dnsServer.start();
const client = dgram.createSocket('udp4');
const query = dnsPacket.encode({
type: 'query',
id: 2,
flags: dnsPacket.RECURSION_DESIRED,
questions: [
{
name: 'example.com',
type: 'SOA',
class: 'IN',
},
],
});
const responsePromise = new Promise<dnsPacket.Packet>((resolve, reject) => {
const timeout = setTimeout(() => {
client.close();
reject(new Error('Query timed out'));
}, 2000);
client.on('message', (msg) => {
clearTimeout(timeout);
try {
const dnsResponse = dnsPacket.decode(msg);
resolve(dnsResponse);
} catch (e) {
reject(e);
}
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();
}
});
});
const dnsResponse = await responsePromise;
console.log('✅ Direct SOA query succeeded');
const soaAnswers = dnsResponse.answers.filter(a => a.type === 'SOA');
expect(soaAnswers.length).toEqual(1);
await stopServer(dnsServer);
dnsServer = null;
});
export default tap.start();

224
test/test.soa.timeout.ts Normal file
View File

@@ -0,0 +1,224 @@
import * as plugins from '../ts_server/plugins.js';
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { tapNodeTools } from '@git.zone/tstest/tapbundle_node';
import * as dnsPacket from 'dns-packet';
import * as dgram from 'dgram';
import { execSync } from 'child_process';
import * as smartdns from '../ts_server/index.js';
let dnsServer: smartdns.DnsServer;
// Port management for tests
const testPort = 8753;
// Cleanup function for servers
async function stopServer(server: smartdns.DnsServer | null | undefined) {
if (!server) {
return;
}
try {
await server.stop();
} catch (e) {
console.log('Handled error when stopping server:', e.message || e);
}
}
tap.test('Test SOA timeout with real dig command', async (tools) => {
const httpsData = await tapNodeTools.createHttpsCert();
dnsServer = new smartdns.DnsServer({
httpsKey: httpsData.key,
httpsCert: httpsData.cert,
httpsPort: 8752,
udpPort: testPort,
dnssecZone: 'example.com',
});
await dnsServer.start();
console.log(`DNS server started on port ${testPort}`);
// Test with dig command
try {
console.log('Testing SOA query with dig...');
const result = execSync(`dig @localhost -p ${testPort} example.com SOA +timeout=3`, { encoding: 'utf8' });
console.log('Dig SOA query result:', result);
// Check if we got an answer section
expect(result).toInclude('ANSWER SECTION');
expect(result).toInclude('SOA');
} catch (error) {
console.error('Dig command failed:', error.message);
throw error;
}
// Test nonexistent domain SOA
try {
console.log('Testing nonexistent domain SOA query with dig...');
const result = execSync(`dig @localhost -p ${testPort} nonexistent.example.com A +timeout=3`, { encoding: 'utf8' });
console.log('Dig nonexistent query result:', result);
// Should get AUTHORITY section with SOA
expect(result).toInclude('AUTHORITY SECTION');
} catch (error) {
console.error('Dig nonexistent query failed:', error.message);
throw error;
}
await stopServer(dnsServer);
dnsServer = null;
});
tap.test('Test SOA with DNSSEC timing', async () => {
const httpsData = await tapNodeTools.createHttpsCert();
const udpPort = 8754;
dnsServer = new smartdns.DnsServer({
httpsKey: httpsData.key,
httpsCert: httpsData.cert,
httpsPort: 8755,
udpPort: udpPort,
dnssecZone: 'example.com',
});
await dnsServer.start();
const client = dgram.createSocket('udp4');
// Test with DNSSEC enabled
const query = dnsPacket.encode({
type: 'query',
id: 1,
flags: dnsPacket.RECURSION_DESIRED,
questions: [
{
name: 'nonexistent.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 nonexistent domain...');
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`));
}, 3000);
client.on('message', (msg) => {
clearTimeout(timeout);
const elapsed = Date.now() - startTime;
console.log(`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);
const elapsed = Date.now() - startTime;
console.error(`Error after ${elapsed}ms:`, err);
reject(err);
client.close();
});
client.send(query, udpPort, 'localhost', (err) => {
if (err) {
clearTimeout(timeout);
reject(err);
client.close();
}
});
});
try {
const dnsResponse = await responsePromise;
console.log('Response details:');
console.log('- Answers:', dnsResponse.answers.length);
console.log('- Answer types:', dnsResponse.answers.map(a => a.type));
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);
}
} catch (error) {
console.error('DNSSEC SOA query failed:', error);
throw error;
}
await stopServer(dnsServer);
dnsServer = null;
});
tap.test('Check DNSSEC signing performance for SOA', async () => {
const httpsData = await tapNodeTools.createHttpsCert();
dnsServer = new smartdns.DnsServer({
httpsKey: httpsData.key,
httpsCert: httpsData.cert,
httpsPort: 8756,
udpPort: 8757,
dnssecZone: 'example.com',
});
// Time SOA serialization
const soaData = {
mname: 'ns1.example.com',
rname: 'hostmaster.example.com',
serial: 2024010101,
refresh: 3600,
retry: 600,
expire: 604800,
minimum: 86400,
};
console.log('Testing SOA serialization performance...');
const serializeStart = Date.now();
try {
// @ts-ignore - accessing private method for testing
const serialized = dnsServer.serializeRData('SOA', soaData);
const serializeTime = Date.now() - serializeStart;
console.log(`SOA serialization took ${serializeTime}ms`);
// Test DNSSEC signing
const signStart = Date.now();
// @ts-ignore - accessing private property
const signature = dnsServer.dnsSec.signData(serialized);
const signTime = Date.now() - signStart;
console.log(`DNSSEC signing took ${signTime}ms`);
expect(serializeTime).toBeLessThan(100); // Should be fast
expect(signTime).toBeLessThan(500); // Signing can take longer but shouldn't timeout
} catch (error) {
console.error('Performance test failed:', error);
throw error;
}
});
export default tap.start();

View File

@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@push.rocks/smartdns', name: '@push.rocks/smartdns',
version: '7.4.2', version: '7.6.0',
description: 'A robust TypeScript library providing advanced DNS management and resolution capabilities including support for DNSSEC, custom DNS servers, and integration with various DNS providers.' description: 'A robust TypeScript library providing advanced DNS management and resolution capabilities including support for DNSSEC, custom DNS servers, and integration with various DNS providers.'
} }

View File

@@ -22,7 +22,13 @@ export const makeNodeProcessUseDnsProvider = (providerArg: TDnsProvider) => {
} }
}; };
export interface ISmartDnsConstructorOptions {} export type TResolutionStrategy = 'doh' | 'system' | 'prefer-system';
export interface ISmartDnsConstructorOptions {
strategy?: TResolutionStrategy; // default: 'prefer-system'
allowDohFallback?: boolean; // allow fallback to DoH if system fails (default: true)
timeoutMs?: number; // optional per-query timeout
}
export interface IDnsJsonResponse { export interface IDnsJsonResponse {
Status: number; Status: number;
@@ -43,6 +49,9 @@ export interface IDnsJsonResponse {
export class Smartdns { export class Smartdns {
public dnsServerIp: string; public dnsServerIp: string;
public dnsServerPort: number; public dnsServerPort: number;
private strategy: TResolutionStrategy = 'prefer-system';
private allowDohFallback = true;
private timeoutMs: number | undefined;
public dnsTypeMap: { [key: string]: number } = { public dnsTypeMap: { [key: string]: number } = {
A: 1, A: 1,
@@ -55,7 +64,12 @@ export class Smartdns {
/** /**
* constructor for class dnsly * constructor for class dnsly
*/ */
constructor(optionsArg: ISmartDnsConstructorOptions) {} constructor(optionsArg: ISmartDnsConstructorOptions) {
this.strategy = optionsArg?.strategy || 'prefer-system';
this.allowDohFallback =
optionsArg?.allowDohFallback === undefined ? true : optionsArg.allowDohFallback;
this.timeoutMs = optionsArg?.timeoutMs;
}
/** /**
* check a dns record until it has propagated to Google DNS * check a dns record until it has propagated to Google DNS
@@ -133,27 +147,111 @@ export class Smartdns {
recordTypeArg: plugins.tsclass.network.TDnsRecordType, recordTypeArg: plugins.tsclass.network.TDnsRecordType,
retriesCounterArg = 20 retriesCounterArg = 20
): 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 trySystem = async (): Promise<plugins.tsclass.network.IDnsRecord[]> => {
const returnArray: plugins.tsclass.network.IDnsRecord[] = []; // Prefer dns.lookup for A/AAAA so hosts file and OS resolver are honored
const getResponseBody = async (counterArg = 0): Promise<IDnsJsonResponse> => { if (recordTypeArg === 'A' || recordTypeArg === 'AAAA') {
const response = await plugins.smartrequest.request(requestUrl, { const family = recordTypeArg === 'A' ? 4 : 6;
method: 'GET', const addresses = await new Promise<{ address: string }[]>((resolve, reject) => {
headers: { const timer = this.timeoutMs
accept: 'application/dns-json', ? setTimeout(() => reject(new Error('system lookup timeout')), this.timeoutMs)
}, : null;
}); plugins.dns.lookup(
const responseBody: IDnsJsonResponse = response.body; recordNameArg,
if (responseBody?.Status !== 0 && counterArg < retriesCounterArg) { { family, all: true },
await plugins.smartdelay.delayFor(500); (err, result) => {
return getResponseBody(counterArg + 1); if (timer) clearTimeout(timer as any);
} else { if (err) return reject(err);
return responseBody; resolve(result || []);
}
);
});
return addresses.map((a) => ({
name: recordNameArg,
type: recordTypeArg,
dnsSecEnabled: false,
value: a.address,
}));
} }
if (recordTypeArg === 'TXT') {
const records = await new Promise<string[][]>((resolve, reject) => {
const timer = this.timeoutMs
? setTimeout(() => reject(new Error('system resolveTxt timeout')), this.timeoutMs)
: null;
plugins.dns.resolveTxt(recordNameArg, (err, res) => {
if (timer) clearTimeout(timer as any);
if (err) return reject(err);
resolve(res || []);
});
});
return records.map((chunks) => ({
name: recordNameArg,
type: 'TXT',
dnsSecEnabled: false,
value: chunks.join(''),
}));
}
return [];
}; };
const responseBody = await getResponseBody();
if (!responseBody.Answer || !typeof responseBody.Answer[Symbol.iterator]) { const tryDoh = async (): Promise<plugins.tsclass.network.IDnsRecord[]> => {
const requestUrl = `https://cloudflare-dns.com/dns-query?name=${recordNameArg}&type=${recordTypeArg}&do=1`;
const returnArray: plugins.tsclass.network.IDnsRecord[] = [];
const getResponseBody = async (counterArg = 0): Promise<IDnsJsonResponse> => {
const response = await plugins.smartrequest.request(requestUrl, {
method: 'GET',
headers: {
accept: 'application/dns-json',
},
timeout: this.timeoutMs,
});
const responseBody: IDnsJsonResponse = response.body;
if (responseBody?.Status !== 0 && counterArg < retriesCounterArg) {
await plugins.smartdelay.delayFor(500);
return getResponseBody(counterArg + 1);
} else {
return responseBody;
}
};
const responseBody = await getResponseBody();
if (!responseBody || !responseBody.Answer || !typeof (responseBody.Answer as any)[Symbol.iterator]) {
return returnArray;
}
for (const dnsEntry of responseBody.Answer) {
if (typeof dnsEntry.data === 'string' && dnsEntry.data.startsWith('"') && dnsEntry.data.endsWith('"')) {
dnsEntry.data = dnsEntry.data.replace(/^\"(.*)\"$/, '$1');
}
if (dnsEntry.name.endsWith('.')) {
dnsEntry.name = dnsEntry.name.substring(0, dnsEntry.name.length - 1);
}
returnArray.push({
name: dnsEntry.name,
type: this.convertDnsTypeNumberToTypeName(dnsEntry.type),
dnsSecEnabled: !!responseBody.AD,
value: dnsEntry.data,
});
}
return returnArray; return returnArray;
};
try {
if (this.strategy === 'system') {
return await trySystem();
}
if (this.strategy === 'doh') {
return await tryDoh();
}
// prefer-system
try {
const sysRes = await trySystem();
if (sysRes.length > 0) return sysRes;
return this.allowDohFallback ? await tryDoh() : [];
} catch (err) {
return this.allowDohFallback ? await tryDoh() : [];
}
} catch (finalErr) {
return [];
} }
}
for (const dnsEntry of responseBody.Answer) { for (const dnsEntry of responseBody.Answer) {
if (dnsEntry.data.startsWith('"') && dnsEntry.data.endsWith('"')) { if (dnsEntry.data.startsWith('"') && dnsEntry.data.endsWith('"')) {
dnsEntry.data = dnsEntry.data.replace(/^"(.*)"$/, '$1'); dnsEntry.data = dnsEntry.data.replace(/^"(.*)"$/, '$1');

View File

@@ -13,6 +13,10 @@ export interface IDnsServerOptions {
// New options for independent manual socket control // New options for independent manual socket control
manualUdpMode?: boolean; manualUdpMode?: boolean;
manualHttpsMode?: boolean; manualHttpsMode?: boolean;
// Primary nameserver for SOA records (defaults to ns1.{dnssecZone})
primaryNameserver?: string;
// Local handling for RFC 6761 localhost (default: true)
enableLocalhostHandling?: boolean;
} }
export interface DnsAnswer { export interface DnsAnswer {
@@ -559,11 +563,16 @@ export class DnsServer {
}; };
const dnssecRequested = this.isDnssecRequested(request); 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) { for (const question of request.questions) {
console.log(`Query for ${question.name} of type ${question.type}`); console.log(`Query for ${question.name} of type ${question.type}`);
let answered = false; let answered = false;
let shouldSignRrset = true; // skip DNSSEC signing for synthetic/local answers
const recordsForQuestion: DnsAnswer[] = [];
// Handle DNSKEY queries if DNSSEC is requested // Handle DNSKEY queries if DNSSEC is requested
if (dnssecRequested && question.type === 'DNSKEY' && question.name === this.options.dnssecZone) { if (dnssecRequested && question.type === 'DNSKEY' && question.name === this.options.dnssecZone) {
@@ -574,40 +583,92 @@ export class DnsServer {
ttl: 3600, ttl: 3600,
data: this.dnskeyRecord, data: this.dnskeyRecord,
}; };
response.answers.push(dnskeyAnswer as plugins.dnsPacket.Answer); recordsForQuestion.push(dnskeyAnswer);
// Sign the DNSKEY RRset
const rrsig = this.generateRRSIG('DNSKEY', [dnskeyAnswer], question.name);
response.answers.push(rrsig as plugins.dnsPacket.Answer);
answered = true; answered = true;
continue; // DNSKEY is signable, keep shouldSignRrset true
} else {
// Built-in handling for localhost and reverse localhost (RFC 6761)
const enableLocal = this.options.enableLocalhostHandling !== false;
if (enableLocal) {
const qnameLower = (question.name || '').toLowerCase();
const qnameTrimmed = qnameLower.endsWith('.') ? qnameLower.slice(0, -1) : qnameLower;
// localhost forward lookups
if (qnameTrimmed === 'localhost') {
if (question.type === 'A') {
recordsForQuestion.push({
name: question.name,
type: 'A',
class: 'IN',
ttl: 0,
data: '127.0.0.1',
});
answered = true;
shouldSignRrset = false;
} else if (question.type === 'AAAA') {
recordsForQuestion.push({
name: question.name,
type: 'AAAA',
class: 'IN',
ttl: 0,
data: '::1',
});
answered = true;
shouldSignRrset = false;
}
}
// Reverse lookup for 127.0.0.1
if (!answered) {
const reverseLocalhostV4 = '1.0.0.127.in-addr.arpa';
if (qnameTrimmed === reverseLocalhostV4 && question.type === 'PTR') {
recordsForQuestion.push({
name: question.name,
type: 'PTR',
class: 'IN',
ttl: 0,
data: 'localhost.',
});
answered = true;
shouldSignRrset = false;
}
}
}
// Collect all matching records from handlers
if (!answered) {
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
}
}
}
}
} }
for (const handlerEntry of this.handlers) { // Add records to response and group by type for DNSSEC
if ( if (recordsForQuestion.length > 0) {
plugins.minimatch.minimatch(question.name, handlerEntry.domainPattern) && for (const record of recordsForQuestion) {
handlerEntry.recordTypes.includes(question.type) response.answers.push(record as plugins.dnsPacket.Answer);
) { }
const answer = handlerEntry.handler(question);
if (answer) { // Group records by type for DNSSEC signing
// Ensure the answer has ttl and class if (dnssecRequested && shouldSignRrset) {
const dnsAnswer: DnsAnswer = { const rrsetKey = `${question.name}:${question.type}`;
...answer, rrsetMap.set(rrsetKey, recordsForQuestion);
ttl: answer.ttl || 300,
class: answer.class || 'IN',
};
response.answers.push(dnsAnswer as plugins.dnsPacket.Answer);
if (dnssecRequested) {
// Sign the answer RRset
const rrsig = this.generateRRSIG(question.type, [dnsAnswer], question.name);
response.answers.push(rrsig as plugins.dnsPacket.Answer);
}
answered = true;
// Continue processing other handlers to allow multiple records
}
} }
} }
@@ -620,7 +681,7 @@ export class DnsServer {
class: 'IN', class: 'IN',
ttl: 3600, ttl: 3600,
data: { data: {
mname: `ns1.${this.options.dnssecZone}`, mname: this.options.primaryNameserver || `ns1.${this.options.dnssecZone}`,
rname: `hostmaster.${this.options.dnssecZone}`, rname: `hostmaster.${this.options.dnssecZone}`,
serial: Math.floor(Date.now() / 1000), serial: Math.floor(Date.now() / 1000),
refresh: 3600, refresh: 3600,
@@ -630,6 +691,22 @@ export class DnsServer {
}, },
}; };
response.answers.push(soaAnswer as plugins.dnsPacket.Answer); 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);
} }
} }
@@ -667,6 +744,17 @@ export class DnsServer {
// Sign the RRset // Sign the RRset
const signature = this.dnsSec.signData(rrsetBuffer); 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 // Construct the RRSIG record
const rrsig: DnsAnswer = { const rrsig: DnsAnswer = {
@@ -675,15 +763,15 @@ export class DnsServer {
class: 'IN', class: 'IN',
ttl, ttl,
data: { data: {
typeCovered: type, // Changed to type string typeCovered: type, // dns-packet expects the string type
algorithm, algorithm,
labels: name.split('.').length - 1, labels: name.split('.').filter(l => l.length > 0).length, // Fix label count
originalTTL: ttl, originalTTL: ttl,
expiration, expiration,
inception, inception,
keyTag, keyTag,
signerName, signersName: signerName || this.options.dnssecZone, // Note: signersName with 's'
signature: signature, signature: signature || Buffer.alloc(0), // Fallback to empty buffer
}, },
}; };
@@ -760,9 +848,27 @@ export class DnsServer {
// NS records contain domain names // NS records contain domain names
return this.nameToBuffer(data); return this.nameToBuffer(data);
case 'SOA': case 'SOA':
// Implement SOA record serialization if needed // Implement SOA record serialization according to RFC 1035
// For now, return an empty buffer or handle as needed const mname = this.nameToBuffer(data.mname);
return Buffer.alloc(0); 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]);
case 'MX':
// MX records contain preference (16-bit) and exchange (domain name)
const preference = Buffer.alloc(2);
preference.writeUInt16BE(data.preference, 0);
const exchange = this.nameToBuffer(data.exchange);
return Buffer.concat([preference, exchange]);
// Add cases for other record types as needed // Add cases for other record types as needed
default: default:
throw new Error(`Serialization for record type ${type} is not implemented.`); throw new Error(`Serialization for record type ${type} is not implemented.`);
@@ -967,7 +1073,9 @@ export class DnsServer {
} }
private nameToBuffer(name: string): Buffer { private nameToBuffer(name: string): Buffer {
const labels = name.split('.'); // Trim trailing dot to avoid double-root
const trimmed = name.endsWith('.') ? name.slice(0, -1) : name;
const labels = trimmed.split('.').filter(l => l.length > 0);
const buffers = labels.map(label => { const buffers = labels.map(label => {
const len = Buffer.byteLength(label, 'utf8'); const len = Buffer.byteLength(label, 'utf8');
const buf = Buffer.alloc(1 + len); const buf = Buffer.alloc(1 + len);
@@ -975,6 +1083,7 @@ export class DnsServer {
buf.write(label, 1); buf.write(label, 1);
return buf; return buf;
}); });
return Buffer.concat([...buffers, Buffer.from([0])]); // Add root label // Append exactly one root label
return Buffer.concat([...buffers, Buffer.from([0])]);
} }
} }