feat(dnsserver): Return multiple matching records, improve DNSSEC RRset signing, add client resolution strategy and localhost handling, update tests

This commit is contained in:
2025-09-12 17:32:03 +00:00
parent afd1c18496
commit f29962a6dc
8 changed files with 281 additions and 42 deletions

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,27 +147,111 @@ 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;
};
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');