Compare commits

...

4 Commits

9 changed files with 287 additions and 57 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,22 @@
# Changelog
## 2025-09-12 - 7.6.1 - fix(classes.dnsclient)
Remove redundant DOH response parsing in getRecords to avoid duplicate processing and clean up client code
- Removed a duplicated/extra iteration that parsed DNS-over-HTTPS (DoH) answers in ts_client/classes.dnsclient.ts.
- Prevents double-processing or incorrect return behavior from Smartdns.getRecords when using DoH providers.
- Changes affect the Smartdns client implementation (ts_client/classes.dnsclient.ts).
## 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

View File

@@ -1,6 +1,6 @@
{
"name": "@push.rocks/smartdns",
"version": "7.5.1",
"version": "7.6.1",
"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.",
"exports": {

View File

@@ -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;

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 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');

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@push.rocks/smartdns',
version: '7.5.1',
version: '7.6.1',
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 {
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,44 +147,112 @@ export class Smartdns {
recordTypeArg: plugins.tsclass.network.TDnsRecordType,
retriesCounterArg = 20
): 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',
},
});
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<plugins.tsclass.network.IDnsRecord[]> => {
// 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<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;
}
for (const dnsEntry of responseBody.Answer) {
if (dnsEntry.data.startsWith('"') && dnsEntry.data.endsWith('"')) {
dnsEntry.data = dnsEntry.data.replace(/^"(.*)"$/, '$1');
};
try {
if (this.strategy === 'system') {
return await trySystem();
}
if (dnsEntry.name.endsWith('.')) {
dnsEntry.name = dnsEntry.name.substring(0, dnsEntry.name.length - 1);
if (this.strategy === 'doh') {
return await tryDoh();
}
returnArray.push({
name: dnsEntry.name,
type: this.convertDnsTypeNumberToTypeName(dnsEntry.type),
dnsSecEnabled: responseBody.AD,
value: dnsEntry.data,
});
// 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 [];
}
// console.log(responseBody);
return returnArray;
}
/**
* gets a record using nodejs dns resolver

View File

@@ -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])]);
}
}
}