feat(rustdns-client): add Rust DNS client binary and TypeScript IPC bridge to enable UDP and DoH resolution, RDATA decoding, and DNSSEC AD/rcode support

This commit is contained in:
2026-02-11 13:02:37 +00:00
parent 9d4db39741
commit 368430d199
24 changed files with 2805 additions and 863 deletions

View File

@@ -1,4 +1,5 @@
import * as plugins from './plugins.js';
import { RustDnsClientBridge } from './classes.rustdnsclientbridge.js';
export type TDnsProvider = 'google' | 'cloudflare';
@@ -22,7 +23,7 @@ export const makeNodeProcessUseDnsProvider = (providerArg: TDnsProvider) => {
}
};
export type TResolutionStrategy = 'doh' | 'system' | 'prefer-system';
export type TResolutionStrategy = 'doh' | 'udp' | 'system' | 'prefer-system' | 'prefer-udp';
export interface ISmartDnsConstructorOptions {
strategy?: TResolutionStrategy; // default: 'prefer-system'
@@ -30,40 +31,28 @@ export interface ISmartDnsConstructorOptions {
timeoutMs?: number; // optional per-query timeout
}
export interface IDnsJsonResponse {
Status: number;
TC: boolean;
RD: boolean;
RA: boolean;
AD: boolean;
CD: boolean;
Question: Array<{ name: string; type: number }>;
Answer: Array<{ name: string; type: number; TTL: number; data: string }>;
Additional: [];
Comment: string;
}
/**
* class dnsly offers methods for working with dns from a dns provider like Google DNS
* Smartdns offers methods for working with DNS resolution.
* Supports system resolver, UDP wire-format, and DoH (DNS-over-HTTPS) via a Rust binary.
*/
export class Smartdns {
public dnsServerIp: string;
public dnsServerPort: number;
private strategy: TResolutionStrategy = 'prefer-system';
private allowDohFallback = true;
private timeoutMs: number | undefined;
private rustBridge: RustDnsClientBridge | null = null;
public dnsTypeMap: { [key: string]: number } = {
A: 1,
AAAA: 28,
NS: 2,
CNAME: 5,
SOA: 6,
PTR: 12,
MX: 15,
TXT: 16,
AAAA: 28,
SRV: 33,
};
/**
* constructor for class dnsly
*/
constructor(optionsArg: ISmartDnsConstructorOptions) {
this.strategy = optionsArg?.strategy || 'prefer-system';
this.allowDohFallback =
@@ -71,12 +60,15 @@ export class Smartdns {
this.timeoutMs = optionsArg?.timeoutMs;
}
private getRustBridge(): RustDnsClientBridge {
if (!this.rustBridge) {
this.rustBridge = new RustDnsClientBridge();
}
return this.rustBridge;
}
/**
* check a dns record until it has propagated to Google DNS
* should be considerably fast
* @param recordNameArg
* @param recordTypeArg
* @param expectedValue
* check a dns record until it has propagated
*/
public async checkUntilAvailable(
recordNameArg: string,
@@ -107,7 +99,6 @@ export class Smartdns {
return await doCheck();
}
} catch (err) {
// console.log(err);
await plugins.smartdelay.delayFor(intervalArg);
return await doCheck();
}
@@ -185,7 +176,7 @@ export class Smartdns {
});
return records.map((chunks) => ({
name: recordNameArg,
type: 'TXT',
type: 'TXT' as plugins.tsclass.network.TDnsRecordType,
dnsSecEnabled: false,
value: chunks.join(''),
}));
@@ -193,44 +184,22 @@ export class Smartdns {
return [];
};
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;
const tryRust = async (protocol: 'udp' | 'doh'): Promise<plugins.tsclass.network.IDnsRecord[]> => {
const bridge = this.getRustBridge();
const result = await bridge.resolve(
recordNameArg,
recordTypeArg,
protocol,
undefined,
undefined,
this.timeoutMs
);
return result.answers.map((answer) => ({
name: answer.name,
type: this.convertDnsTypeNameToCanonical(answer.type) || recordTypeArg,
dnsSecEnabled: result.adFlag,
value: answer.value,
}));
};
try {
@@ -238,21 +207,32 @@ export class Smartdns {
return await trySystem();
}
if (this.strategy === 'doh') {
return await tryDoh();
return await tryRust('doh');
}
// prefer-system
if (this.strategy === 'udp') {
return await tryRust('udp');
}
if (this.strategy === 'prefer-udp') {
try {
const udpRes = await tryRust('udp');
if (udpRes.length > 0) return udpRes;
return await tryRust('doh');
} catch (err) {
return await tryRust('doh');
}
}
// prefer-system (default)
try {
const sysRes = await trySystem();
if (sysRes.length > 0) return sysRes;
return this.allowDohFallback ? await tryDoh() : [];
return this.allowDohFallback ? await tryRust('doh') : [];
} catch (err) {
return this.allowDohFallback ? await tryDoh() : [];
return this.allowDohFallback ? await tryRust('doh') : [];
}
} catch (finalErr) {
return [];
}
}
/**
* gets a record using nodejs dns resolver
@@ -308,4 +288,25 @@ export class Smartdns {
}
return null;
}
/**
* Convert a DNS type string from Rust (e.g. "A", "AAAA") to the canonical TDnsRecordType.
*/
private convertDnsTypeNameToCanonical(typeName: string): plugins.tsclass.network.TDnsRecordType | null {
const upper = typeName.toUpperCase();
if (upper in this.dnsTypeMap) {
return upper as plugins.tsclass.network.TDnsRecordType;
}
return null;
}
/**
* Destroy the Rust client bridge and free resources.
*/
public destroy(): void {
if (this.rustBridge) {
this.rustBridge.kill();
this.rustBridge = null;
}
}
}

View File

@@ -0,0 +1,168 @@
import * as plugins from './plugins.js';
// IPC command map for type-safe bridge communication
export type TClientDnsCommands = {
resolve: {
params: IResolveParams;
result: IResolveResult;
};
ping: {
params: Record<string, never>;
result: { pong: boolean };
};
};
export interface IResolveParams {
name: string;
recordType: string;
protocol: 'udp' | 'doh';
serverAddr?: string;
dohUrl?: string;
timeoutMs?: number;
}
export interface IClientDnsAnswer {
name: string;
type: string;
ttl: number;
value: string;
}
export interface IResolveResult {
answers: IClientDnsAnswer[];
adFlag: boolean;
rcode: number;
}
/**
* Bridge to the Rust DNS client binary via smartrust IPC.
*/
export class RustDnsClientBridge {
private bridge: InstanceType<typeof plugins.smartrust.RustBridge<TClientDnsCommands>>;
private spawnPromise: Promise<boolean> | null = null;
constructor() {
const packageDir = plugins.path.resolve(
plugins.path.dirname(new URL(import.meta.url).pathname),
'..'
);
// Determine platform suffix for dist_rust binaries (matches tsrust naming)
const platformSuffix = getPlatformSuffix();
const localPaths: string[] = [];
// dist_rust/ candidates (tsrust cross-compiled output, platform-specific)
if (platformSuffix) {
localPaths.push(plugins.path.join(packageDir, 'dist_rust', `rustdns-client_${platformSuffix}`));
}
// dist_rust/ without suffix (native build)
localPaths.push(plugins.path.join(packageDir, 'dist_rust', 'rustdns-client'));
// Local dev build paths
localPaths.push(plugins.path.join(packageDir, 'rust', 'target', 'release', 'rustdns-client'));
localPaths.push(plugins.path.join(packageDir, 'rust', 'target', 'debug', 'rustdns-client'));
this.bridge = new plugins.smartrust.RustBridge<TClientDnsCommands>({
binaryName: 'rustdns-client',
cliArgs: ['--management'],
requestTimeoutMs: 30_000,
readyTimeoutMs: 10_000,
localPaths,
searchSystemPath: false,
});
this.bridge.on('stderr', (line: string) => {
if (line.trim()) {
console.log(`[rustdns-client] ${line}`);
}
});
}
/**
* Lazily spawn the Rust binary. Only spawns once, caches the promise.
*/
public async ensureSpawned(): Promise<void> {
if (!this.spawnPromise) {
this.spawnPromise = this.bridge.spawn();
}
const ok = await this.spawnPromise;
if (!ok) {
this.spawnPromise = null;
throw new Error('Failed to spawn rustdns-client binary');
}
}
/**
* Resolve a DNS query through the Rust binary.
*/
public async resolve(
name: string,
recordType: string,
protocol: 'udp' | 'doh',
serverAddr?: string,
dohUrl?: string,
timeoutMs?: number
): Promise<IResolveResult> {
await this.ensureSpawned();
const params: IResolveParams = {
name,
recordType,
protocol,
};
if (serverAddr) params.serverAddr = serverAddr;
if (dohUrl) params.dohUrl = dohUrl;
if (timeoutMs) params.timeoutMs = timeoutMs;
return this.bridge.sendCommand('resolve', params);
}
/**
* Ping the Rust binary for health check.
*/
public async ping(): Promise<boolean> {
await this.ensureSpawned();
const result = await this.bridge.sendCommand('ping', {} as Record<string, never>);
return result.pong;
}
/**
* Kill the Rust process.
*/
public kill(): void {
this.bridge.kill();
this.spawnPromise = null;
}
/**
* Whether the bridge is running.
*/
public get running(): boolean {
return this.bridge.running;
}
}
/**
* Get the tsrust platform suffix for the current platform.
*/
function getPlatformSuffix(): string | null {
const platform = process.platform;
const arch = process.arch;
const platformMap: Record<string, string> = {
'linux': 'linux',
'darwin': 'macos',
'win32': 'windows',
};
const archMap: Record<string, string> = {
'x64': 'amd64',
'arm64': 'arm64',
};
const p = platformMap[platform];
const a = archMap[arch];
if (p && a) {
return `${p}_${a}`;
}
return null;
}

View File

@@ -1 +1,2 @@
export * from './classes.dnsclient.js';
export * from './classes.rustdnsclientbridge.js';

View File

@@ -6,12 +6,20 @@ const dns: typeof dnsType = await smartenvInstance.getSafeNodeModule('dns');
export { dns };
// node native scope
import * as path from 'path';
import { EventEmitter } from 'events';
export { path };
export const events = { EventEmitter };
// pushrocks scope
import * as smartdelay from '@push.rocks/smartdelay';
import * as smartpromise from '@push.rocks/smartpromise';
import * as smartrequest from '@push.rocks/smartrequest';
import * as smartrust from '@push.rocks/smartrust';
export { smartdelay, smartenv, smartpromise, smartrequest };
export { smartdelay, smartenv, smartpromise, smartrequest, smartrust };
import * as tsclass from '@tsclass/tsclass';

94
ts_client/readme.md Normal file
View File

@@ -0,0 +1,94 @@
# @push.rocks/smartdns/client
DNS client module for `@push.rocks/smartdns` — provides DNS record resolution via system resolver, raw UDP wire-format queries, and DNS-over-HTTPS (RFC 8484), with UDP and DoH powered by a Rust binary for performance.
## Import
```typescript
import { Smartdns, makeNodeProcessUseDnsProvider } from '@push.rocks/smartdns/client';
```
## Architecture
The client routes queries through one of three backends depending on the configured strategy:
- **System** — Uses Node.js `dns` module (`dns.lookup` / `dns.resolveTxt`). Honors `/etc/hosts`. No external binary.
- **UDP** — Sends raw DNS wire-format queries to upstream resolvers (default: Cloudflare 1.1.1.1) via the `rustdns-client` Rust binary over IPC.
- **DoH** — Sends RFC 8484 wire-format POST requests to a DoH endpoint (default: `https://cloudflare-dns.com/dns-query`) via the same Rust binary.
The Rust binary is spawned **lazily** — only when the first UDP or DoH query is made. The binary stays alive for connection pooling (DoH) and is killed via `destroy()`.
## Classes & Functions
### `Smartdns`
The main DNS client class. Supports five resolution strategies:
| Strategy | Behavior |
|---|---|
| `prefer-system` | Try OS resolver first, fall back to Rust DoH |
| `system` | Use only Node.js system resolver |
| `doh` | Use only Rust DoH (RFC 8484 wire format) |
| `udp` | Use only Rust UDP to upstream resolver |
| `prefer-udp` | Try Rust UDP first, fall back to Rust DoH |
```typescript
const dns = new Smartdns({
strategy: 'prefer-udp',
allowDohFallback: true,
timeoutMs: 5000,
});
```
#### Key Methods
| Method | Description |
|---|---|
| `getRecordsA(domain)` | Resolve A records (IPv4) |
| `getRecordsAAAA(domain)` | Resolve AAAA records (IPv6) |
| `getRecordsTxt(domain)` | Resolve TXT records |
| `getRecords(domain, type, retries?)` | Generic query — supports A, AAAA, CNAME, MX, TXT, NS, SOA, PTR, SRV |
| `getNameServers(domain)` | Resolve NS records |
| `checkUntilAvailable(domain, type, value, cycles?, interval?)` | Poll until a record propagates |
| `destroy()` | Kill the Rust client binary and free resources |
All query methods return `IDnsRecord[]`:
```typescript
interface IDnsRecord {
name: string;
type: string;
dnsSecEnabled: boolean; // true if upstream AD flag was set
value: string;
}
```
### `RustDnsClientBridge`
Low-level IPC bridge to the `rustdns-client` binary. Used internally by `Smartdns` — typically not imported directly. Provides:
- `ensureSpawned()` — lazy spawn of the Rust binary
- `resolve(name, type, protocol, ...)` — send a resolve command via IPC
- `ping()` — health check
- `kill()` — terminate the binary
### `makeNodeProcessUseDnsProvider(provider)`
Configures the global Node.js DNS resolver to use a specific provider:
```typescript
makeNodeProcessUseDnsProvider('cloudflare'); // 1.1.1.1
makeNodeProcessUseDnsProvider('google'); // 8.8.8.8
```
## Supported Record Types
A, AAAA, CNAME, MX, TXT, NS, SOA, PTR, SRV
## Dependencies
- `@push.rocks/smartrust` — TypeScript-to-Rust IPC bridge
- `@push.rocks/smartrequest` — HTTP client (used by legacy paths)
- `@push.rocks/smartdelay` — delay utility for retry logic
- `@push.rocks/smartpromise` — deferred promise helper
- `@tsclass/tsclass` — DNS record type definitions