diff --git a/.serena/.gitignore b/.serena/.gitignore new file mode 100644 index 0000000..14d86ad --- /dev/null +++ b/.serena/.gitignore @@ -0,0 +1 @@ +/cache diff --git a/.serena/project.yml b/.serena/project.yml new file mode 100644 index 0000000..5ea6a4b --- /dev/null +++ b/.serena/project.yml @@ -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" diff --git a/changelog.md b/changelog.md index 655cd33..2204b64 100644 --- a/changelog.md +++ b/changelog.md @@ -1,5 +1,15 @@ # 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 diff --git a/test/test.fixes.simple.ts b/test/test.fixes.simple.ts index c924727..3e61ba4 100644 --- a/test/test.fixes.simple.ts +++ b/test/test.fixes.simple.ts @@ -219,7 +219,7 @@ tap.test('Default primary nameserver with FQDN', async () => { const soaData = (soaAnswers[0] as any).data; console.log('✅ FQDN primary nameserver:', soaData.mname); - expect(soaData.mname).toEqual('ns.example.com.'); + expect(soaData.mname).toEqual('ns.example.com'); await stopServer(dnsServer); dnsServer = null; diff --git a/test/test.multiplerecords.ts b/test/test.multiplerecords.ts index e6ff970..1a36aa4 100644 --- a/test/test.multiplerecords.ts +++ b/test/test.multiplerecords.ts @@ -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 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('NS records:', dnsResponse.answers.map(a => (a as any).data)); - // CURRENT BEHAVIOR: Only returns 1 NS record due to the break statement - expect(dnsResponse.answers.length).toEqual(1); - expect((dnsResponse.answers[0] as any).data).toEqual('ns1.example.com'); + // Should return all registered NS records + expect(dnsResponse.answers.length).toEqual(2); + const nsData = dnsResponse.answers.map(a => (a as any).data).sort(); + expect(nsData).toEqual(['ns1.example.com', 'ns2.example.com']); await stopServer(dnsServer); 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 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('A records:', dnsResponse.answers.map(a => (a as any).data)); - // CURRENT BEHAVIOR: Only returns 1 A record, preventing round-robin DNS - expect(dnsResponse.answers.length).toEqual(1); - expect((dnsResponse.answers[0] as any).data).toEqual('10.0.0.1'); + // Should return all registered A records for round-robin DNS + expect(dnsResponse.answers.length).toEqual(3); + 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); 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 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('TXT records:', dnsResponse.answers.map(a => (a as any).data)); - // CURRENT BEHAVIOR: Only returns 1 TXT record instead of all 3 - expect(dnsResponse.answers.length).toEqual(1); - expect((dnsResponse.answers[0] as any).data[0]).toInclude('spf1'); + // Should return all registered TXT records + expect(dnsResponse.answers.length).toEqual(3); + 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); 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 udpPort = getUniqueUdpPort(); @@ -343,11 +348,11 @@ tap.test('should show the current workaround pattern', async () => { 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']; 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) => { const record = nsRecords[nsIndex % nsRecords.length]; 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('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(response2.answers.length).toEqual(1); expect((response1.answers[0] as any).data).toEqual('ns1.example.com'); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index b6785ee..4094a21 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -3,6 +3,6 @@ */ export const commitinfo = { name: '@push.rocks/smartdns', - version: '7.5.1', + 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.' } diff --git a/ts_client/classes.dnsclient.ts b/ts_client/classes.dnsclient.ts index 1dfa5c7..8eaa629 100644 --- a/ts_client/classes.dnsclient.ts +++ b/ts_client/classes.dnsclient.ts @@ -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 { Status: number; @@ -43,6 +49,9 @@ export interface IDnsJsonResponse { export class Smartdns { public dnsServerIp: string; public dnsServerPort: number; + private strategy: TResolutionStrategy = 'prefer-system'; + private allowDohFallback = true; + private timeoutMs: number | undefined; public dnsTypeMap: { [key: string]: number } = { A: 1, @@ -55,7 +64,12 @@ export class Smartdns { /** * 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 @@ -133,27 +147,111 @@ export class Smartdns { recordTypeArg: plugins.tsclass.network.TDnsRecordType, retriesCounterArg = 20 ): Promise { - 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 => { - const response = await plugins.smartrequest.request(requestUrl, { - method: 'GET', - headers: { - accept: 'application/dns-json', - }, - }); - const responseBody: IDnsJsonResponse = response.body; - if (responseBody?.Status !== 0 && counterArg < retriesCounterArg) { - await plugins.smartdelay.delayFor(500); - return getResponseBody(counterArg + 1); - } else { - return responseBody; + const trySystem = async (): Promise => { + // Prefer dns.lookup for A/AAAA so hosts file and OS resolver are honored + if (recordTypeArg === 'A' || recordTypeArg === 'AAAA') { + const family = recordTypeArg === 'A' ? 4 : 6; + const addresses = await new Promise<{ address: string }[]>((resolve, reject) => { + const timer = this.timeoutMs + ? setTimeout(() => reject(new Error('system lookup timeout')), this.timeoutMs) + : null; + plugins.dns.lookup( + recordNameArg, + { family, all: true }, + (err, result) => { + if (timer) clearTimeout(timer as any); + if (err) return reject(err); + resolve(result || []); + } + ); + }); + return addresses.map((a) => ({ + name: recordNameArg, + type: recordTypeArg, + dnsSecEnabled: false, + value: a.address, + })); } + if (recordTypeArg === 'TXT') { + const records = await new Promise((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 => { + 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 => { + 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; + }; + + 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) { if (dnsEntry.data.startsWith('"') && dnsEntry.data.endsWith('"')) { dnsEntry.data = dnsEntry.data.replace(/^"(.*)"$/, '$1'); diff --git a/ts_server/classes.dnsserver.ts b/ts_server/classes.dnsserver.ts index e648101..3ee01f0 100644 --- a/ts_server/classes.dnsserver.ts +++ b/ts_server/classes.dnsserver.ts @@ -15,6 +15,8 @@ export interface IDnsServerOptions { 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 { @@ -569,6 +571,7 @@ export class DnsServer { console.log(`Query for ${question.name} of type ${question.type}`); let answered = false; + let shouldSignRrset = true; // skip DNSSEC signing for synthetic/local answers const recordsForQuestion: DnsAnswer[] = []; // Handle DNSKEY queries if DNSSEC is requested @@ -582,9 +585,59 @@ export class DnsServer { }; recordsForQuestion.push(dnskeyAnswer); answered = true; + // 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 - for (const handlerEntry of this.handlers) { + if (!answered) { + for (const handlerEntry of this.handlers) { if ( plugins.minimatch.minimatch(question.name, handlerEntry.domainPattern) && handlerEntry.recordTypes.includes(question.type) @@ -602,6 +655,7 @@ export class DnsServer { // Continue processing other handlers to allow multiple records } } + } } } @@ -612,7 +666,7 @@ export class DnsServer { } // Group records by type for DNSSEC signing - if (dnssecRequested) { + if (dnssecRequested && shouldSignRrset) { const rrsetKey = `${question.name}:${question.type}`; rrsetMap.set(rrsetKey, recordsForQuestion); } @@ -1019,7 +1073,9 @@ export class DnsServer { } 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 len = Buffer.byteLength(label, 'utf8'); const buf = Buffer.alloc(1 + len); @@ -1027,6 +1083,7 @@ export class DnsServer { buf.write(label, 1); return buf; }); - return Buffer.concat([...buffers, Buffer.from([0])]); // Add root label + // Append exactly one root label + return Buffer.concat([...buffers, Buffer.from([0])]); } -} \ No newline at end of file +}