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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
168
ts_client/classes.rustdnsclientbridge.ts
Normal file
168
ts_client/classes.rustdnsclientbridge.ts
Normal 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;
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
export * from './classes.dnsclient.js';
|
||||
export * from './classes.rustdnsclientbridge.js';
|
||||
|
||||
@@ -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
94
ts_client/readme.md
Normal 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
|
||||
Reference in New Issue
Block a user