2026-09-09 11:58:27 +00:00
2022-07-27 08:59:29 +02:00
2026-09-09 11:58:27 +00:00
2026-09-09 11:58:27 +00:00
2026-09-09 11:58:27 +00:00

@push.rocks/smartdns

A TypeScript-first DNS toolkit powered by high-performance Rust binaries — covering everything from simple record lookups to running a full authoritative DNS server with DNSSEC, DNS-over-TCP, DNS-over-HTTPS, and automatic Let's Encrypt certificates.

Issue Reporting and Security

For reporting bugs, issues, or security vulnerabilities, please visit community.foss.global/. This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a code.foss.global/ account to submit Pull Requests directly.

Install

pnpm install @push.rocks/smartdns

Architecture at a Glance 🏗️

smartdns ships as three entry points that you can import independently:

Entry point What it does
@push.rocks/smartdns/client DNS resolution & record queries (UDP, DoH, system resolver)
@push.rocks/smartdns/server Full DNS server — UDP, TCP, DoH, DNSSEC, ACME
@push.rocks/smartdns Convenience re-export of both modules

Both the client and the server delegate performance-critical work to compiled Rust binaries that ship with the package:

  • rustdns — The server binary: network I/O, packet parsing, DNSSEC signing
  • rustdns-client — The client binary: UDP wire-format queries, RFC 8484 DoH resolution

TypeScript retains the public API, handler registration, ACME orchestration, and strategy routing. Communication between TypeScript and Rust happens over stdin/stdout JSON IPC via @push.rocks/smartrust.

                          ┌─────────────────────────┐
                          │    Your Application      │
                          └────────┬────────────────┘
                                   │
              ┌────────────────────┼─────────────────────┐
              ▼                                          ▼
   ┌──────────────────┐                      ┌──────────────────┐
   │  Smartdns Client  │                      │    DnsServer      │
   │  (TypeScript API) │                      │  (TypeScript API) │
   └────────┬─────────┘                      └────────┬─────────┘
            │                                          │
    ┌───────┼────────┐                     ┌───────────┤
    ▼       ▼        ▼                     ▼           ▼
 system   Rust     Rust              Rust binary    TS Handlers
 (Node)   UDP      DoH              (rustdns)      (minimatch)
           │        │                    │
           ▼        ▼                    ▼
      rustdns-client              UDP / TCP / HTTPS listeners
      (IPC binary)               DNSSEC signing

Usage

Quick Start

// DNS client — resolve records
import { Smartdns } from '@push.rocks/smartdns/client';

const dns = new Smartdns({});
const records = await dns.getRecordsA('example.com');
console.log(records);

// DNS server — serve records
import { DnsServer } from '@push.rocks/smartdns/server';

const server = new DnsServer({
  udpPort: 5333,
  httpsPort: 8443,
  httpsKey: '...pem...',
  httpsCert: '...pem...',
  dnssecZone: 'example.com',
});

server.registerHandler('*.example.com', ['A'], (question) => ({
  name: question.name,
  type: 'A',
  class: 'IN',
  ttl: 300,
  data: '192.168.1.100',
}));

await server.start();

Or import from the unified entry point:

import { dnsClientMod, dnsServerMod } from '@push.rocks/smartdns';

const client = new dnsClientMod.Smartdns({});
const server = new dnsServerMod.DnsServer({ /* ... */ });

🔍 DNS Client

The Smartdns class resolves DNS records using a configurable strategy that combines the system resolver, raw UDP queries, and DNS-over-HTTPS — all backed by a Rust binary for the wire-format transports.

Constructor Options

interface ISmartDnsConstructorOptions {
  strategy?: 'doh' | 'udp' | 'system' | 'prefer-system' | 'prefer-udp'; // default: 'prefer-system'
  allowDohFallback?: boolean; // fallback to DoH when system fails (default: true)
  timeoutMs?: number; // per-query timeout in milliseconds
}

Resolution Strategies

Strategy Behavior
prefer-system 🏠 Try the Node DNS resolver first, then fall back to Rust DoH.
system 🏠 Use only the Node.js system resolver. No Rust binary needed.
doh 🌐 Use only DNS-over-HTTPS (RFC 8484 wire format via Cloudflare). Rust-powered.
udp Use only raw UDP queries to upstream resolver (Cloudflare 1.1.1.1). Rust-powered.
prefer-udp Try Rust UDP first, fall back to Rust DoH if UDP fails.

The Rust binary (rustdns-client) is spawned lazily — only on the first query that needs it. This means system-only usage incurs zero Rust overhead.

Querying Records

const dns = new Smartdns({ strategy: 'prefer-udp' });

// Type-specific helpers
const aRecords    = await dns.getRecordsA('example.com');
const aaaaRecords = await dns.getRecordsAAAA('example.com');
const txtRecords  = await dns.getRecordsTxt('example.com');

// Generic query — supports A, AAAA, CNAME, MX, TXT, NS, SOA, PTR, SRV
const mxRecords = await dns.getRecords('example.com', 'MX');

// Nameserver lookup
const nameservers = await dns.getNameServers('example.com');

Strict Query Outcomes (v7.12+)

Use queryRecords() when an operational failure must not look like a missing DNS record:

const result = await dns.queryRecords('example.com', 'TXT');

switch (result.status) {
  case 'success':
    console.log(result.records, result.transport);
    break;
  case 'missing':
    console.log(result.missingReason); // 'nodata' | 'nxdomain'
    break;
  case 'error':
    console.error(result.error.code, result.error.message);
    break;
}

console.log(result.attempts); // ordered system/udp/doh evidence

Strict queries support A, AAAA, CNAME, MX, NS, PTR, SOA, SRV, and TXT. Their behavior is:

Status Meaning
success The authoritative final attempt returned one or more requested records.
missing The final attempt proved nodata or nxdomain.
error The final attempt failed due to timeout, transport, protocol, server, unsupported-input, or internal errors.

system, udp, and doh perform exactly one attempt. prefer-system and prefer-udp perform their preferred attempt first and, when enabled, at most one DoH fallback after either a missing result or an error. The fallback result is authoritative and attempts retains both outcomes. Set allowDohFallback: false to prohibit that second attempt.

When timeoutMs is set, every strict system attempt owns an isolated Node resolver. SmartDNS returns DNS_QUERY_TIMEOUT at the deadline and cancels the underlying native query, so timed-out lookups do not continue accumulating in the background. Isolated resolvers inherit servers selected through makeNodeProcessUseDnsProvider().

The strict system path uses Node's explicit DNS resolvers (resolve4, resolve6, resolveTxt, resolveMx, resolveNs, resolveCname, resolvePtr, resolveSrv, and resolveSoa). This makes DNS response semantics observable and intentionally differs from the legacy A/AAAA path.

Legacy Query Compatibility

getRecords(), getRecordsA(), getRecordsAAAA(), and getRecordsTxt() retain their established array-returning contract. They return [] for both missing records and resolver failures. Legacy system A/AAAA resolution continues to use dns.lookup, preserving NSS and hosts-file behavior. Use these methods for compatibility and queryRecords() when state must remain distinguishable.

Every query returns an array of IDnsRecord:

interface IDnsRecord {
  name: string;
  type: string;          // 'A', 'AAAA', 'TXT', 'MX', etc.
  dnsSecEnabled: boolean; // true if upstream AD flag was set
  value: string;
}

DNSSEC Detection 🔐

When using doh, udp, or prefer-udp strategies, the Rust binary sends queries with the EDNS0 DO (DNSSEC OK) bit set and reports the AD (Authenticated Data) flag from the upstream response:

const dns = new Smartdns({ strategy: 'udp' });
const records = await dns.getRecordsA('cloudflare.com');
console.log(records[0].dnsSecEnabled); // true — upstream validated DNSSEC

Checking DNS Propagation

Wait for a specific record to appear — essential after making DNS changes:

const propagated = await dns.checkUntilAvailable(
  'example.com',
  'TXT',
  'verification=abc123',
  50,   // max check cycles (default: 50)
  500   // interval in ms (default: 500)
);

if (propagated) {
  console.log('Record is live!');
}

The method alternates between system resolver and the configured strategy on each cycle for maximum coverage.

Configuring the System DNS Provider

Override the global Node.js DNS resolver for all subsequent lookups:

import { makeNodeProcessUseDnsProvider } from '@push.rocks/smartdns/client';

makeNodeProcessUseDnsProvider('cloudflare'); // 1.1.1.1 / 1.0.0.1
makeNodeProcessUseDnsProvider('google');     // 8.8.8.8 / 8.8.4.4

Cleanup

When you're done with a Smartdns instance (especially one using Rust strategies), await terminate() when later work must not overlap the Rust child process:

const dns = new Smartdns({ strategy: 'udp' });
// ... do queries ...
await dns.terminate(); // confirms rustdns-client process closure

If termination fails, terminate() rejects and retains the Rust bridge so the caller can retry cleanup. destroy() remains available as a fire-and-forget compatibility API.


🖥️ DNS Server

The DnsServer class runs a production-capable authoritative DNS server backed by a Rust binary. It supports standard UDP and TCP DNS (port 53), DNS-over-HTTPS, DNSSEC signing, and automated Let's Encrypt certificates.

Server Options

interface IDnsServerOptions {
  udpPort: number;               // Port for UDP DNS queries
  tcpPort?: number;              // Port for TCP DNS queries (defaults to udpPort)
  httpsPort: number;             // Port for DNS-over-HTTPS
  httpsKey: string;              // PEM private key (path or content)
  httpsCert: string;             // PEM certificate (path or content)
  dnssecZone: string;            // Zone for DNSSEC signing
  authoritativeZones?: string[]; // Omitted defaults to [dnssecZone]; [] claims no zone
  primaryNameserver?: string;    // SOA mname field (default: 'ns1.{dnssecZone}')
  udpBindInterface?: string;     // IP to bind UDP (default: '0.0.0.0')
  tcpBindInterface?: string;     // IP to bind TCP (defaults to udpBindInterface)
  httpsBindInterface?: string;   // IP to bind HTTPS (default: '0.0.0.0')
  manualUdpMode?: boolean;       // Don't auto-bind UDP socket
  manualTcpMode?: boolean;       // Don't auto-bind TCP listener
  manualHttpsMode?: boolean;     // Don't auto-bind HTTPS server
  enableLocalhostHandling?: boolean; // RFC 6761 localhost (default: true)
}

Basic Server

import { DnsServer } from '@push.rocks/smartdns/server';

const server = new DnsServer({
  udpPort: 5333,
  httpsPort: 8443,
  httpsKey: '...pem...',
  httpsCert: '...pem...',
  dnssecZone: 'example.com',
});

// Register handlers
server.registerHandler('example.com', ['A'], (question) => ({
  name: question.name,
  type: 'A',
  class: 'IN',
  ttl: 300,
  data: '93.184.215.14',
}));

server.registerHandler('example.com', ['TXT'], (question) => ({
  name: question.name,
  type: 'TXT',
  class: 'IN',
  ttl: 300,
  data: 'v=spf1 include:_spf.example.com ~all',
}));

await server.start();
// DNS Server started (UDP: 0.0.0.0:5333, TCP: 0.0.0.0:5333, HTTPS: 0.0.0.0:8443)

Calling start() again while this DnsServer is running is an idempotent no-op. After a confirmed stop(), the same instance can be started again with a fresh Rust process.

Handler System 🎯

Handlers use glob patterns (via minimatch) to match incoming query names. Multiple handlers can contribute records to the same response.

Registering a handler does not make the server authoritative for the matched names. Authority comes solely from authoritativeZones (falling back to dnssecZone): list every zone you serve there, or queries for it are withheld. A default-authority handler whose match falls outside every configured zone is suppressed and reported on the refused event; register with { authority: 'non-authoritative' } to deliberately serve out-of-zone names without the aa bit or DNSSEC signatures.

Replace the live authority set without restarting UDP, TCP, or HTTPS listeners:

server.setAuthoritativeZones(['example.com', 'example.net']);
server.setAuthoritativeZones([]); // explicitly claim no zone

The constructor copies its supplied array, and setAuthoritativeZones() validates and copies replacements. Omitting authoritativeZones from the constructor retains the backward-compatible [dnssecZone] default; passing or setting an empty array does not fall back. DNSKEY answers for dnssecZone pass through the same live authority decision, so removing the last zone also removes the last DNSSEC authority claim. RFC 6761 localhost handling is the deliberate exception: while enabled, built-in localhost A/AAAA answers remain authoritative even when the configured zone set is empty.

// Exact domain
server.registerHandler('example.com', ['A'], handler);

// All subdomains
server.registerHandler('*.example.com', ['A'], handler);

// Specific pattern
server.registerHandler('db-*.internal.example.com', ['A'], (question) => {
  const id = question.name.match(/db-(\d+)/)?.[1];
  return {
    name: question.name,
    type: 'A',
    class: 'IN',
    ttl: 60,
    data: `10.0.1.${id}`,
  };
});

// Catch-all — '*' is anchored in no configured zone, so it must be
// registered non-authoritative or its out-of-zone answers are suppressed
server.registerHandler('*', ['A'], (question) => ({
  name: question.name,
  type: 'A',
  class: 'IN',
  ttl: 300,
  data: '127.0.0.1',
}), { authority: 'non-authoritative' });

// Multiple record types
server.registerHandler('example.com', ['MX'], (question) => ({
  name: question.name,
  type: 'MX',
  class: 'IN',
  ttl: 300,
  data: { preference: 10, exchange: 'mail.example.com' },
}));

// Unregister a handler: registerHandler returns a handle identifying
// exactly that registration
const registration = server.registerHandler('example.com', ['A'], handler);
registration.unregister();

// Precise teardown also works by id or owner label, and listHandlers()
// exposes the current set
server.registerHandler('example.com', ['TXT'], handler, { owner: 'acme-module' });
server.unregisterHandlersByOwner('acme-module');

// The coarse form removes EVERY handler on the pattern/type pair
server.unregisterHandler('example.com', ['A']);

Negative answers are authoritative only inside configured zones: a name inside an authoritative zone that provably does not exist yields NXDOMAIN with the zone SOA in the AUTHORITY section; a name that exists — a covered name queried for a type no handler serves, the zone apex, or an empty non-terminal with covered names beneath it — yields NODATA with the SOA in AUTHORITY; names outside every configured zone are answered REFUSED.

DNSSEC

DNSSEC is enabled automatically when you set the dnssecZone option. The Rust backend handles:

  • Key generation — ECDSA P-256 (algorithm 13) by default
  • DNSKEY / DS record generation
  • RRSIG signing for authoritative answers when the query requests DNSSEC
  • NSEC records for authenticated denial of existence
const server = new DnsServer({
  udpPort: 53,
  httpsPort: 443,
  httpsKey: '...',
  httpsCert: '...',
  dnssecZone: 'secure.example.com',
});

// Just register handlers as usual — signing is automatic
server.registerHandler('secure.example.com', ['A'], (q) => ({
  name: q.name,
  type: 'A',
  class: 'IN',
  ttl: 300,
  data: '10.0.0.1',
}));

await server.start();

Supported algorithms: ECDSAP256SHA256 (13), ED25519 (15), RSASHA256 (8).

SOA Records

The server carries the zone SOA in the AUTHORITY section of negative answers (NXDOMAIN and NODATA), and the zone apex answers its own SOA even without a registered SOA handler. Customize the primary nameserver:

const server = new DnsServer({
  // ...
  dnssecZone: 'example.com',
  primaryNameserver: 'ns1.example.com', // defaults to 'ns1.{dnssecZone}'
});

// Generated SOA includes:
// mname:   ns1.example.com
// rname:   hostmaster.example.com
// serial:  captured once at server construction, stable for the process lifetime
// refresh: 3600, retry: 600, expire: 604800, minimum: 86400

Let's Encrypt Integration 🔒

Built-in ACME DNS-01 challenge support for automatic SSL certificates. Challenge TXT handlers are registered at default authority, so DNS-01 validation only completes for domains inside authoritativeZones, or inside dnssecZone when authoritativeZones is omitted:

const server = new DnsServer({
  udpPort: 53,
  httpsPort: 443,
  httpsKey: '/path/to/key.pem',
  httpsCert: '/path/to/cert.pem',
  dnssecZone: 'example.com',
});

await server.start();

const result = await server.retrieveSslCertificate(
  ['example.com', 'www.example.com'],
  {
    email: 'admin@example.com',
    staging: false,
    certDir: './certs',
  }
);

if (result.success) {
  console.log('Certificate installed!');
  // The server automatically:
  // 1. Registers temporary _acme-challenge TXT handlers
  // 2. Completes DNS-01 validation
  // 3. Updates the HTTPS server with the new cert
  // 4. Cleans up challenge handlers
}

Interface Binding

Restrict the server to specific network interfaces:

// Localhost only — great for development
const server = new DnsServer({
  // ...
  udpBindInterface: '127.0.0.1',
  tcpBindInterface: '127.0.0.1',
  httpsBindInterface: '127.0.0.1',
});

// Different interfaces per protocol
const server = new DnsServer({
  // ...
  udpBindInterface: '192.168.1.100',
  tcpBindInterface: '192.168.1.100',
  httpsBindInterface: '10.0.0.50',
});

Manual Socket Handling 🔧

For clustering, load balancing, or custom transports, take control of socket management:

import { DnsServer } from '@push.rocks/smartdns/server';
import * as dgram from 'dgram';

// Manual UDP mode — you control the socket
const server = new DnsServer({
  // ...
  manualUdpMode: true,
});

await server.start(); // TCP and HTTPS auto-bind, UDP does not

const socket = dgram.createSocket('udp4');
socket.on('message', (msg, rinfo) => {
  server.handleUdpMessage(msg, rinfo, (response, responseRinfo) => {
    socket.send(response, responseRinfo.port, responseRinfo.address);
  });
});
socket.bind(5353);

Full manual mode (all transports):

const server = new DnsServer({
  // ...
  manualUdpMode: true,
  manualTcpMode: true,
  manualHttpsMode: true,
});

await server.start(); // No UDP, TCP, or HTTPS listener binds automatically

After start() resolves, an upstream proxy may terminate TLS and hand the resulting cleartext HTTP socket to SmartDNS. The handler detects HTTP/1.1 and prior-knowledge HTTP/2 (not h2c Upgrade) and accepts RFC 8484 requests on /dns-query. /resolve remains an alias for the already-shipped dcrouter route:

const handleTlsTerminatedSocket = (socket: import('node:net').Socket) => {
  server.handleHttpSocket(socket);
};

GET requests require an unpadded base64url dns query parameter. POST requests require Content-Type: application/dns-message. DNS messages are limited to 65,535 bytes, headers to 16 KiB, HTTP/2 sessions to 100 concurrent streams, and header/request/session lifetimes are bounded. Successful responses use application/dns-message; malformed or unsupported requests receive an explicit 4xx response, internal handler failures receive 500, and unavailable DNS processing receives 503. Every error response uses text/plain; charset=utf-8.

handleHttpSocket() must only receive a TLS-terminated cleartext stream. The legacy handleHttpsSocket() does not terminate encrypted TLS in Rust mode and closes the socket.

Process individual DNS packets directly:

// Asynchronous (via Rust bridge — includes DNSSEC signing)
const response = await server.processRawDnsPacketAsync(packetBuffer);

// When the response goes out as a UDP datagram, pass 'udp' so it is
// truncated (TC bit) to the client's advertised EDNS0 payload size
const udpResponse = await server.processRawDnsPacketAsync(packetBuffer, 'udp');

Manual TCP callers must handle DNS-over-TCP's two-byte length prefix themselves and pass only the raw DNS packet payload to processRawDnsPacketAsync().

At most 256 processRawDnsPacketAsync() calls may be pending at once. Additional calls reject before transport writes with a RustBridgeRequestError whose code is ERR_RUST_BRIDGE_REQUEST_LIMIT; control and callback commands remain available while packet work is saturated. New packet calls are also rejected while start(), stop(), or a certificate update is queued or running.

Load Balancing Example

import * as dgram from 'dgram';
import * as os from 'os';

const numCPUs = os.cpus().length;

for (let i = 0; i < numCPUs; i++) {
  const socket = dgram.createSocket({ type: 'udp4', reuseAddr: true });

  socket.on('message', (msg, rinfo) => {
    server.handleUdpMessage(msg, rinfo, (response, rinfo) => {
      socket.send(response, rinfo.port, rinfo.address);
    });
  });

  socket.bind(53);
}

Stopping the Server

await server.stop();

stop() is serialized with start() and resolves only after the Rust process has closed and its bound sockets are released. If confirmed termination fails, the call rejects and retains ownership so a later stop() can retry cleanup. It also destroys every HTTP/1.1 socket and HTTP/2 replay stream handed to handleHttpSocket(); the upstream proxy/listener remains caller-owned and must be stopped separately.


Managed private DNS views

ManagedDnsServer from @push.rocks/smartdns/server is a separate Linux-native DNS engine for a trusted workload/network controller. It has no public-zone handlers, localhost shortcut, DNSSEC signing, DoH listener, cache, image pull or system-resolver fallback. Existing DnsServer and DnsClient behavior remains available independently.

import { ManagedDnsServer } from '@push.rocks/smartdns/server';

const dns = new ManagedDnsServer({
  ownerId: 'node:one',
  bindAddress: '10.100.0.1',
  port: 53,
  revisionFloor: null, // Restore the persisted accepted receipt after a restart.
  forbiddenUpstreamAddresses: ['10.100.0.2'],
});
const clock = await dns.getClockEvidence();
const deadline = clock.boottimeMs + 60_000;
await dns.start();
const receipt = await dns.applySnapshot({
  ownerId: 'node:one',
  revision: 1,
  sourceContentHash: 'a'.repeat(64), // Use the real authenticated projection hash.
  configuration: {
    bootId: clock.bootId,
    validUntilBoottimeMs: deadline,
    blockedSuffixes: [],
    clients: [{ address: '10.100.1.2', viewId: 'network:one' }],
    views: [{
      id: 'network:one',
      zones: ['network-one.internal'],
      negativeTtl: 1,
      forwarding: null,
      names: [{
        name: 'api.network-one.internal',
        records: [{
          type: 'A', data: '10.100.2.3', ttl: 5,
          validUntilBoottimeMs: deadline,
        }],
      }],
    }],
  },
});
// Persist the complete accepted input and receipt using your persistence owner.
// Verify getStatus().accepted and .active before acknowledging application.
await dns.stop();

For an installed or compiled node bundle, pass a second, process-only option: new ManagedDnsServer(configuration, { binaryPath: '/opt/node/bin/rustdns' }). binaryPath must be absolute and is never serialized into a DNS snapshot or native listener configuration. It selects that exact executable with an empty fallback list and no system PATH search; a missing path fails even when package or local build binaries exist. The installer remains responsible for verifying the artifact and protecting its path. Without this option the existing npm package lookup is preserved, including static Linux musl binaries.

The caller authenticates the configuration, enforces the kernel binding between each workload/interface and source IP, and persists complete snapshots plus the highest revision/contentHash receipt. The engine does not authenticate a claimed IP supplied in configuration, install firewall policy, allocate addresses, check workload readiness, persist application data or publish public DNS/ingress records. A DNS answer grants no packet access. TCP and UDP views use the kernel-observed peer address; untrusted EDNS Client Subnet and all client EDNS options are rejected. Unknown sources receive REFUSED before any lookup or forwarding. The raw IPC processPacket path is unavailable while a managed listener is running, so it cannot fabricate membership context.

A snapshot replaces the complete set of views and client bindings atomically. Each client IP selects exactly one view; an owner can build a union view for a workload with several explicitly authorized networks. Names are case-insensitive FQDNs with optional trailing dots. Different views can declare the same name with different records. Duplicate names or client IPs in one snapshot, names outside that view's authority, duplicate records, malformed addresses and out-of-bound TTLs/collections reject the entire replacement. The caller defines search suffixes in workload configuration; the engine never guesses a default network or expands a bare name into several networks.

Managed authoritative records are A, AAAA and PTR. Every declared name exists even with an empty RRset: missing query types and expired endpoint records produce NOERROR/NODATA with a negative SOA. Missing or unauthorized private names produce NXDOMAIN. PTR targets must belong to the same view's configured authority. Private CNAME/MX/SRV synthesis is intentionally absent. Multiple eligible addresses are returned as one RRset; oversized UDP answers set TC so the client can retry TCP. TTL zero is preserved. Every positive and negative TTL is clipped to native remaining authority; record deadlines can withdraw individual endpoints earlier.

Revisions are nonnegative JavaScript safe integers, scoped to the exact immutable ownerId. Older revisions and same-revision different content reject. Exact replay is idempotent and never renews validity. configuration: null explicitly withdraws all views while retaining the revision fence. The receipt hash is SHA-256 over Rust's typed camel-case snapshot serialization in declared field order, preserving array order and input strings; it covers owner, revision, parent hash, complete configuration and deadline. It is an identity receipt, not a signature. Persist the exact submitted snapshot together with the receipt; do not reconstruct an approximately equivalent JSON document. revisionFloor allows the exact accepted snapshot to be reinstalled in a fresh process and rejects older/conflicting input before any query can see it. Durable fencing across process lifetimes is the persistence owner's responsibility.

getClockEvidence() reads Linux boot identity and suspend-inclusive CLOCK_BOOTTIME. The complete snapshot must name this boot and an absolute native deadline, greater than the current native time and at most 24 hours ahead. Endpoint deadlines cannot exceed the snapshot. The caller should choose its own shorter policy lease. Query/send paths check native validity and accepted revision, including after forwarding completes; expiry produces SERVFAIL without a JavaScript timer. Same-boot process recovery can reinstall an unexpired persisted snapshot, including names never previously queried. Node reboot changes the boot ID: the caller must independently establish trusted time and authenticate a bounded new boot deadline. The engine never converts wall-clock time or grants a fresh lease merely because it restarted. An expired or wrong-boot snapshot cannot be installed into a fresh engine.

Forwarding is opt-in per view, to explicit IP-literal upstream endpoints and allowed DNS suffixes ('.' explicitly permits all external names outside privacy barriers). The listener address and forbiddenUpstreamAddresses cannot be upstreams, including IPv4-mapped IPv6 aliases. The caller must supply every other local resolver address and prevent upstream forwarding cycles outside this process. No host resolver, public fallback or upstream hostname lookup is used. The engine keeps .internal, localhost/local/home.arpa, private reverse space, bare names, configured blocked suffixes and every configured authoritative zone out of forwarding, including zones visible only in another view. Response aliases are inspected before returning them; private targets, cycles, excessive depth, unrelated answers and mismatched question/transaction IDs are rejected. Client EDNS/ECS/DNSSEC options are never sent upstream. The recursive upstream receives only the permitted external question; this engine never follows a private alias.

Forwarding supports A, AAAA, NS, CNAME, SOA, PTR, MX, TXT and SRV questions and their known wire record shapes. Unknown or DNSSEC-specific response records fail closed; there is no opaque RDATA pass-through. The reusable protocol parser expands compressed name-bearing RDATA before re-encoding. AXFR, IXFR and ANY are refused. Forwarded positive TTLs are capped by maxTtl; negative TTLs also use the view's negativeTtl; both are clipped to remaining snapshot validity. UDP truncation retries the same upstream over TCP. One overall deadline (maximum 10 seconds), at most four upstreams, 1,024 allowed suffixes per view, 512 response records, 256 UDP jobs and 128 TCP connections bound forwarding work. A response arriving after replacement or expiry cannot answer under its old authority.

start() binds both transports before admission. stop() closes admission, joins pending facade calls and all native query/connection work, and confirms child termination. Failed cleanup remains owned and retryable. A transport-ambiguous snapshot update prevents further mutations until cleanup; callers must reconcile their persisted intent. Each facade instance is single-use: construct a new one with the persisted revision floor after a stop. Stdin closure stops the native listeners when their parent dies. IPC commands have a 16 MiB allocation ceiling; snapshots have an 8 MiB encoded ceiling.

The managed engine tests qualify these local protocol, view, revision and lifecycle behaviors. A platform using it still owns persistent SmartData/SmartDB recovery, workload interface isolation, authenticated cross-node configuration, endpoint readiness, trusted reboot-time recovery, CRI settings and complete node packaging.

Native distribution

The npm package ships rustdns and rustdns-client as static Linux amd64 and arm64 musl binaries. The default bridges select the matching musl asset first. Managed DNS requires Linux for its boot identity and suspend-inclusive clock. Installer-owned bundles can select an exact absolute server binary path as shown above; carry third-party-notices.md and the complete notices/ directory beside separately distributed executables.

Builds use the Rust 1.95.0 pin and locked Cargo graph. Linux cross-build hosts need musl-gcc, clang for ring's arm64 freestanding C objects, and aarch64-linux-gnu-gcc as the arm64 linker. Rust's musl target supplies its static runtime. TLS explicitly selects the same ring provider installed at process startup, retaining TLS 1.2/1.3, logging and certificate verification. pnpm build produces both architectures and rejects dynamic Linux executables; its SHA-256-bound .tsrust-build.json sidecars identify each artifact's source.

🦀 Rust Crate Structure

The Rust workspace (rust/crates/) contains five crates:

Crate Purpose
rustdns Server binary — IPC management loop, handler callback routing
rustdns-client Client binary — stateless UDP/DoH query proxy
rustdns-protocol DNS wire format parsing, encoding, and RDATA decode/encode
rustdns-server Async UDP + TCP + HTTPS servers (tokio, hyper, rustls)
rustdns-dnssec ECDSA/ED25519 key generation and RRset signing

Pre-compiled binaries for linux_amd64 and linux_arm64 are included in dist_rust/. Cross-compilation is handled by @git.zone/tsrust.


🧪 Testing

# Run all tests
pnpm test

# Run specific test file
tstest test/test.client.ts --verbose
tstest test/test.server.ts --verbose

Example test:

import { expect, tap } from '@git.zone/tstest/tapbundle';
import { Smartdns } from '@push.rocks/smartdns/client';

tap.test('resolve A records via UDP', async () => {
  const dns = new Smartdns({ strategy: 'udp' });
  const records = await dns.getRecordsA('google.com');
  expect(records).toBeArray();
  expect(records[0]).toHaveProperty('type', 'A');
  expect(records[0]).toHaveProperty('value');
  dns.destroy();
});

tap.test('detect DNSSEC via DoH', async () => {
  const dns = new Smartdns({ strategy: 'doh' });
  const records = await dns.getRecordsA('cloudflare.com');
  expect(records[0].dnsSecEnabled).toBeTrue();
  dns.destroy();
});

export default tap.start();

This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the license file.

Please note: The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.

Trademarks

This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.

Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.

Company Information

Task Venture Capital GmbH Registered at District Court Bremen HRB 35230 HB, Germany

For any legal inquiries or further information, please contact us via email at hello@task.vc.

By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.

S
Description
DNS client and server implementation, supporting both https and udp.
https://push.rocks/smartdns Readme
2 MiB
Languages
HTML 50.9%
TypeScript 27.2%
Rust 21.8%