js/net/dns

js/net/dns.ts

fino:net/dns — async DNS resolution via the RFC 1035 wire protocol.

DNS protocol specification: https://www.rfc-editor.org/rfc/rfc1035

This module implements DNS resolution entirely in JS by speaking the DNS wire protocol directly over UDP sockets (from fino:socket) and falls back to DNS-over-TCP when a UDP response is marked truncated. It reads nameservers from /etc/resolv.conf via fino:file. No blocking libc calls are made — the entire resolution path is async and event-loop driven.

Why not use libc getaddrinfo?

getaddrinfo(3) is the standard libc function for DNS resolution, but it blocks the calling thread. Calling it from Fino would freeze the entire event loop for the duration of the DNS query — potentially hundreds of milliseconds. Using raw sockets lets us await readiness asynchronously via loop.readable() / loop.writable(), keeping the process responsive to other I/O while the query is in flight.

DNS wire protocol overview (RFC 1035)

A DNS query packet is:

Header (12 bytes, big-endian): [0-1 ID — 16-bit transaction ID (random per query) [2-3 Flags — 0x0100 = standard query with recursion desired [4-5 QDCOUNT — number of questions (always 1) [6-7 ANCOUNT — answer count (0 in queries) [8-9 NSCOUNT — authority count (0 in queries) [10-11 ARCOUNT — additional count (0 in queries)

Question section: QNAME — domain name in wire format (length-prefixed labels, null-terminated) QTYPE — record type (A=1, AAAA=28, MX=15, etc.) QCLASS — always IN=1 (Internet)

Wire-format domain names encode each label (dot-separated part) as a length byte followed by the label's bytes. "example.com" becomes: [7 'e','x','a','m','p','l','e' [3 'c','o','m' [0 Non-ASCII query labels are normalized to IDNA-style A-labels before wire encoding, so "café.example" is sent as "xn--caf-dma.example".

DNS responses use compression pointers to avoid repeating domain names: a two-byte sequence starting with bits 11xxxxxx is a pointer to an earlier offset in the message. The internal decoder follows these pointers recursively, with a hop limit to prevent infinite loops from malformed responses.

Resource record parsing

Each answer/authority/additional record is: NAME — domain name (may use compression) TYPE — u16 big-endian CLASS — u16 big-endian (always 1) TTL — u32 big-endian (seconds) RDLENGTH— u16 big-endian (length of RDATA) RDATA — variable; format depends on TYPE

parseResourceRecord() handles A, AAAA, CNAME, NS, PTR, MX, TXT, SOA, and SRV, plus DNSSEC DS, DNSKEY, RRSIG, NSEC, NSEC3, and NSEC3PARAM records. Unknown types return raw bytes. Every parsed record also preserves exact rawData RDATA bytes for canonicalization and future validator use.

DNSSEC wire support

Passing { dnssec: true } to new Resolver() or lookup() makes queries include an EDNS(0) OPT pseudo-record with the DNSSEC OK (DO) bit and a conservative 1232-byte UDP payload size. Responses parse EDNS metadata and DNSSEC record payloads locally. Signed positive answers are validated from embedded IANA root trust anchors. Bogus or indeterminate signed data, broken DS/DNSKEY chains, expired signatures, unsupported-only signatures, and missing denial proofs reject with code: 'EDNSSEC'. Provably insecure unsigned delegations are accepted after a signed DS-negative proof. The resolver never trusts upstream AD bits as proof. Unsupported DNSSEC signing algorithms still reject unless another supported signature validates.

CNAME chain following

For A and AAAA queries, resolve() follows CNAME chains automatically (up to 10 hops) by re-querying the CNAME target if the response contains no direct A/AAAA records. This is how most real resolvers work — the CNAME leads to the actual IP.

Nameserver discovery

On first use, #loadServers() reads /etc/resolv.conf and parses nameserver <ip> lines. If the file is unreadable or has no nameserver lines, it falls back to Google's public DNS servers (8.8.8.8, 8.8.4.4). Callers can override nameservers with resolver.setServers(['1.1.1.1']). Search domains, ndots, rotate, sortlist, TTL-return APIs, and the broader Node dns.Resolver option matrix are intentionally outside this release baseline; this resolver sends explicit absolute names supplied by callers.

Release validation keeps live resolver behavior behind an explicit FINO_DNS_LIVE=1 test lane so the normal suite stays deterministic. That lane should exercise at least one public recursive resolver and one user-configured resolver from setServers(). The transport implementation intentionally speaks classic DNS over UDP with TCP fallback for truncated responses; DNS-over-TLS and DNS-over-HTTPS are not implemented by this module.

TTL and cache policy

Parsed resource records preserve the authoritative TTL from the packet, but ordinary Resolver lookups do not cache answers by TTL. Every resolve() call sends a fresh query unless it follows an in-response CNAME chain. The only cache maintained by this module is the DNSSEC delegation cache used while validating signed chains, and that cache stores DNSKEY/DS chain state separately from user-visible answer records.

IPv6 formatting via sock.decodeAddr

Rather than implementing IPv6 string formatting from scratch (handling ::, leading zeros, etc.), formatIPv6() builds a synthetic sockaddr_in6 buffer and passes it to sock.decodeAddr(), which calls inet_ntop(3). This gives correct RFC 5952 compressed output (e.g. 2001:db8::1) without extra code.

Query timeout and retry

Each query races loop.readable(lp, fd) against loop.timeout(lp, ms). If the timeout fires first, we close the socket and try the next nameserver. After all servers have been tried retries times without a valid response, we throw an ETIMEOUT error.

Contributing

import { Resolver } from 'fino:net/dns';

const resolver = new Resolver({ timeout: 1_000, retries: 2 });
resolver.setServers(['1.1.1.1', '8.8.8.8']);
const addresses = await resolver.resolve('example.com', 'A');

Types

type DnsServerFamily = 'ipv4' | 'ipv6'

Address family for a DNS nameserver endpoint.

The resolver uses this value to choose the socket family when sending UDP queries or opening TCP fallback connections. ipv4 addresses are dotted decimal literals, and ipv6 addresses are numeric IPv6 literals.

import type { DnsServerFamily } from 'fino:net/dns';

const family: DnsServerFamily = 'ipv4';

type DnsRecordData = string | string[] | MxRecord | SoaRecord | SrvRecord | DsRecord | DnskeyRecord | RrsigRecord | NsecRecord | Nsec3Record | Nsec3ParamRecord | Uint8Array | null

Decoded DNS record payload for supported record types; unknown data is raw bytes and malformed or empty RDATA can surface as null.

const records = await new Resolver().resolve('example.com', 'TXT');
for (const data of records) {
  if (Array.isArray(data)) console.log(data.join(''));
}

Interfaces

interface DnsServer {

Parsed DNS nameserver endpoint used by Resolver.

Resolver.setServers() accepts string forms such as 1.1.1.1, [2606:4700:4700::1111]:53, or 8.8.8.8:53 and normalizes them into this endpoint shape internally. Resolver.getServers() returns the string form again for compatibility with familiar DNS resolver APIs.

import type { DnsServer } from 'fino:net/dns';

const server: DnsServer = { ip: '1.1.1.1', family: 'ipv4', port: 53 };

Properties

ip: string

Numeric IPv4 or IPv6 address literal.

Host names are not accepted here; recursive resolver endpoints must already be concrete addresses.

family: DnsServerFamily

Socket address family for ip.

The family controls whether the resolver opens an IPv4 or IPv6 UDP/TCP socket for this endpoint.

port: number

UDP and TCP DNS port.

Classic DNS uses 53. The same port is used for the initial UDP query and TCP fallback when a response is truncated.

interface MxRecord {

Mail-exchanger DNS record payload returned for MX lookups.

priority is the preference value from the DNS response; lower numbers are preferred. exchange is the mail host name exactly as decoded from the DNS packet and is not resolved to an address automatically.

import { Resolver } from 'fino:net/dns';

const resolver = new Resolver();
const [mx] = await resolver.resolve('example.com', 'MX');
console.log(mx.priority, mx.exchange);

Properties

priority: number

MX preference value; lower values should be tried first.

const best = records.sort((a, b) => a.priority - b.priority)[0];
exchange: string

Mail exchanger host name. Resolve it separately with A or AAAA if needed.

const addr = await resolver.resolve(mx.exchange, 'A');

interface SoaRecord {

Start-of-authority DNS record payload returned for SOA lookups.

Timing fields are seconds from the authoritative record. Values are not interpreted or clamped by the resolver.

const [soa] = await new Resolver().resolve('example.com', 'SOA');
console.log(soa.nsname, soa.serial);

Properties

nsname: string

Primary authoritative nameserver for the zone.

console.log(soa.nsname);
hostmaster: string

Responsible mailbox encoded as a DNS name.

console.log(soa.hostmaster);
serial: number

Zone serial number used by secondary nameservers.

console.log(soa.serial);
refresh: number

Suggested refresh interval in seconds.

console.log(soa.refresh);
retry: number

Suggested retry interval in seconds.

console.log(soa.retry);
expire: number

Zone expiry interval in seconds.

console.log(soa.expire);
minttl: number

Minimum TTL field from the SOA record.

console.log(soa.minttl);

interface SrvRecord {

Service-location DNS record payload returned for SRV lookups.

The resolver returns records in wire order; callers that need RFC 2782 selection should sort/group by priority and apply weighted selection.

const records = await new Resolver().resolve('_xmpp-server._tcp.example.com', 'SRV');
for (const srv of records) console.log(srv.name, srv.port);

Properties

priority: number

SRV priority; lower values are preferred.

const firstPriority = srv.priority;
weight: number

SRV weight used for load distribution within a priority group.

console.log(srv.weight);
port: number

TCP or UDP port advertised by the service.

console.log(srv.port);
name: string

Target host name for the service.

const addrs = await resolver.resolve(srv.name, 'AAAA');

interface DsRecord {

Delegation signer DNSSEC record payload.

DS records bind a child zone DNSKEY to its parent zone. The digest is the raw hash from wire format; callers that display it commonly hex-encode it.

import { Resolver } from 'fino:net/dns';

const [ds] = await new Resolver({ dnssec: true }).resolve('example.com', 'DS');
const hex = Array.from(ds.digest, (b) => b.toString(16).padStart(2, '0')).join('');
console.log(ds.keyTag, ds.algorithm, ds.digestType, hex);

Properties

keyTag: number

Key tag of the child DNSKEY this DS record identifies.

The value is copied from the wire record and is used with algorithm and digestType to select the matching DNSKEY during validation.

algorithm: number

DNSSEC algorithm number for the referenced child DNSKEY.

Algorithm numbers follow the IANA DNS Security Algorithm registry.

digestType: number

Digest algorithm number used to hash the child DNSKEY owner name and key.

Common values include SHA-1 and SHA-256, but unsupported digest algorithms remain visible here so callers can inspect the delegation.

digest: Uint8Array

Raw DS digest bytes from RDATA.

Display tools usually hex-encode this value. The resolver keeps bytes unchanged for DNSSEC chain validation.

interface DnskeyRecord {

DNSSEC DNSKEY record payload.

DNSKEY records publish a zone's public signing keys. The resolver parses the raw key material and uses it with DS and RRSIG records when DNSSEC validation is enabled.

import { Resolver } from 'fino:net/dns';

const keys = await new Resolver({ dnssec: true }).resolve('example.com', 'DNSKEY');
for (const key of keys) {
  const isKsk = (key.flags & 0x0001) !== 0;
  console.log(key.algorithm, key.publicKey.byteLength, isKsk ? 'KSK' : 'ZSK');
}

Properties

flags: number

DNSKEY flags field.

This includes bits such as Zone Key and Secure Entry Point. The resolver exposes the numeric field unchanged.

protocol: number

DNSKEY protocol field.

DNSSEC uses protocol value 3; other values remain visible for malformed or experimental records.

algorithm: number

DNSSEC algorithm number for this key.

publicKey: Uint8Array

Raw public key bytes from RDATA.

The encoding is algorithm-specific and is not converted to a WebCrypto key by the parser.

interface RrsigRecord {

DNSSEC RRSIG record payload.

RRSIG records authenticate an RRset. Time fields are Unix epoch seconds from the wire record; the parser does not convert them to Date objects.

import { Resolver } from 'fino:net/dns';

const [sig] = await new Resolver({ dnssec: true }).resolve('example.com', 'RRSIG');
console.log(sig.typeCovered, sig.keyTag, new Date(sig.expiration * 1000).toISOString());

Properties

typeCovered: number

Numeric record type covered by this signature.

algorithm: number

DNSSEC algorithm number used by the signing key.

labels: number

Number of labels in the original signed owner name.

This is used by DNSSEC wildcard handling.

originalTtl: number

Original TTL of the signed RRset in seconds.

expiration: number

Signature expiration time as Unix epoch seconds.

inception: number

Signature inception time as Unix epoch seconds.

keyTag: number

Key tag of the DNSKEY that produced this signature.

signerName: string

Signer name from RDATA.

signature: Uint8Array

Raw signature bytes.

The byte format depends on algorithm.

interface NsecRecord {

DNSSEC NSEC authenticated-denial record payload.

NSEC records prove non-existence by naming the next existing owner and a type bitmap for the current owner.

import { Resolver, RECORD_TYPES } from 'fino:net/dns';

const [nsec] = await new Resolver({ dnssec: true }).resolve('example.com', 'NSEC');
console.log(nsec.nextDomainName, nsec.types.includes(RECORD_TYPES.A));

Properties

nextDomainName: string

Next existing owner name in canonical order.

types: number[]

Numeric RR types present at the NSEC owner name.

interface Nsec3Record {

DNSSEC NSEC3 authenticated-denial record payload.

NSEC3 records prove non-existence with hashed owner names. The parser exposes salt and hashed owner bytes directly so validation code can recompute the covered interval.

import { Resolver } from 'fino:net/dns';

const [nsec3] = await new Resolver({ dnssec: true }).resolve('example.com', 'NSEC3');
const optOut = (nsec3.flags & 0x01) !== 0;
console.log(nsec3.hashAlgorithm, nsec3.iterations, nsec3.salt.byteLength, optOut);

Properties

hashAlgorithm: number

Hash algorithm number used for owner names.

flags: number

NSEC3 flags field.

This may include the opt-out bit for insecure delegations.

iterations: number

Number of additional hash iterations.

salt: Uint8Array

Salt bytes used by the NSEC3 hash, or an empty array when no salt is set.

nextHashedOwnerName: Uint8Array

Next hashed owner name bytes from RDATA.

types: number[]

Numeric RR types present at the original owner name.

interface Nsec3ParamRecord {

DNSSEC NSEC3PARAM record payload.

NSEC3PARAM records publish the hash parameters a zone uses for NSEC3 authenticated denial.

import { Resolver } from 'fino:net/dns';

const [params] = await new Resolver({ dnssec: true }).resolve('example.com', 'NSEC3PARAM');
console.log(params.hashAlgorithm, params.iterations, params.salt.byteLength);

Properties

hashAlgorithm: number

Hash algorithm number used for NSEC3 owner hashes.

flags: number

NSEC3 parameter flags.

iterations: number

Number of additional hash iterations.

salt: Uint8Array

Salt bytes used by the zone, or an empty array when no salt is configured.

interface DnsEdnsMetadata {

EDNS(0) metadata parsed from an OPT pseudo-record.

EDNS extends the DNS header with a larger UDP payload size, an extended response code, a version, and feature flags such as DNSSEC OK. This metadata is present only when the response contains an OPT record, and it surfaces on the edns field of a parsed DnsResponse.

import type { DnsResponse } from 'fino:net/dns';

function reportEdns(response: DnsResponse) {
  if (!response.edns) return;
  console.log(response.edns.udpPayloadSize, response.edns.dnssecOk);
}

Properties

udpPayloadSize: number

UDP payload size advertised by the responder.

dnssecOk: boolean

Whether the DNSSEC OK bit was set.

A true value means the peer was willing to send DNSSEC records; it is not proof that the answer was validated.

extendedRcode: number

Extended response-code bits from the OPT record.

version: number

EDNS version number.

flags: number

Raw EDNS flags field.

interface DnsResourceRecord {

Decoded DNS resource record from a response section.

type is the numeric QTYPE, ttl is seconds, and data follows the shape documented by DnsRecordData. The resolver does not cache by TTL.

const answers = await new Resolver().resolve('example.com', 'A');
for (const answer of answers) console.log(answer);

Properties

name: string

Owner name for this record.

console.log(record.name);
type: number

Numeric DNS record type, such as RECORD_TYPES.A.

if (record.type === RECORD_TYPES.A) console.log(record.data);
ttl: number

Record TTL in seconds.

console.log(`cacheable for ${record.ttl}s`);
classCode: number

DNS class code with protocol-specific high bits masked off.

For ordinary IN records this is 1. mDNS responses may set the high cache-flush bit in the wire class field; this property stores only the actual class value.

cacheFlush: boolean

Whether the mDNS cache-flush bit was set in the RR class field.

rawData: Uint8Array

Exact RDATA bytes from the packet.

data: DnsRecordData

Parsed record payload, or raw bytes for unsupported record types.

if (record.data instanceof Uint8Array) console.log(record.data.byteLength);

interface DnsResponse {

Parsed DNS response packet.

rcode exposes the low four bits of the DNS flags field. A non-zero value is converted to an error by Resolver.resolve, but parser tests can inspect it directly.

const records = await new Resolver().resolve('example.com', 'MX');
console.log(records);

Properties

id: number

Transaction ID copied from the DNS header.

if (response.id !== queryId) throw new Error('spoofed response');
flags: number

Raw DNS flags field.

const authoritative = Boolean(response.flags & 0x0400);
rcode: number

DNS response code; zero means no DNS-layer error.

if (response.rcode === 3) console.log('not found');
truncated: boolean

True when the DNS server marked the UDP response as truncated.

if (response.truncated) console.log('retry over TCP if needed');
answers: DnsResourceRecord[]

Answer section records.

for (const answer of response.answers) console.log(answer.data);
authorities: DnsResourceRecord[]

Authority section records.

console.log(response.authorities.length);
additionals: DnsResourceRecord[]

Additional section records.

console.log(response.additionals.length);
edns?: DnsEdnsMetadata

Parsed EDNS(0) metadata when the response contains an OPT pseudo-RR.

Omitted for classic DNS responses without EDNS.

interface ResolverOptions {

Resolver timeout and retry controls.

Defaults are timeout: 5000 milliseconds and retries: 2. The timeout is applied per query attempt; after all servers and retries fail, resolution throws an ETIMEOUT error with the hostname attached.

const resolver = new Resolver({ timeout: 1000, retries: 1 });

Properties

timeout?: number

Per-attempt timeout in milliseconds.

const resolver = new Resolver({ timeout: 750 });
retries?: number

Number of retry rounds across the configured nameserver list.

const resolver = new Resolver({ retries: 3 });
dnssec?: boolean

Enable local DNSSEC validation and request DNSSEC records with EDNS(0) DO.

interface LookupOptions {

Address-family preference for lookup.

Omit family to prefer IPv4 first and then fall back to IPv6. Passing 4 or 6 queries only that family and throws if no address is found.

const result = await lookup('example.com', { family: 6 });

Properties

family?: 4 | 6

Requested address family, or omitted for IPv4-then-IPv6 fallback.

await lookup('example.com', { family: 4 });
dnssec?: boolean

Enable local DNSSEC validation for this lookup.

interface LookupResult {

Primary address returned by lookup.

The address string is already formatted for the family. IPv6 addresses use system inet_ntop formatting.

const { address, family } = await lookup('example.com');
console.log(`${address} is IPv${family}`);

Properties

address: string

IP address string.

console.log(result.address);
family: 4 | 6

Address family for address.

if (result.family === 6) console.log('IPv6');

Constants

const RECORD_TYPES

Map from supported DNS record type names to numeric QTYPE values.

These constants are useful when building or parsing packets manually. Unknown record types can still be parsed, but public resolution is limited to these keys.

const aaaa = RECORD_TYPES.AAAA;
console.log(aaaa);

Properties

A
NS
CNAME
SOA
PTR
MX
TXT
AAAA
SRV
DS
RRSIG
NSEC
DNSKEY
NSEC3
NSEC3PARAM

Classes

class Resolver {

DNS resolver with configurable nameservers, timeout, and retries.

The resolver lazily reads /etc/resolv.conf on first use, falls back to public IPv4 DNS servers if none are found, follows CNAME chains for A and AAAA lookups up to a fixed hop limit, and retries over TCP when a UDP response has the DNS truncated bit set. Set dnssec: true to request DNSSEC records with EDNS(0) DO and validate signed answers from the root trust anchor before returning them.

import { Resolver } from 'fino:net/dns';

const resolver = new Resolver({ timeout: 1500 });
const addrs = await resolver.resolve('example.com', 'A');

Constructors

constructor(opts: ResolverOptions = {})

Create a resolver.

timeout defaults to 5000 ms and retries defaults to 2. Nameservers are loaded lazily, so constructing a resolver performs no I/O.

const resolver = new Resolver({ timeout: 1000, retries: 1 });

Methods

getServers(): string[]

Return the current list of nameserver IP addresses.

If setServers() has not been called and /etc/resolv.conf has not been loaded yet, this returns the built-in fallback server list. Non-default ports are formatted as ip:port for IPv4 or [ip]:port for IPv6.

const resolver = new Resolver();
console.log(resolver.getServers());
setServers(servers: string[]): void

Override the nameserver list. Each entry is an IPv4 or IPv6 address string, optionally with a port: '1.1.1.1', '1.1.1.1:5353', '[::1:5353'.

Passing an empty array throws. The entries are trusted as IP literals and used for future queries; existing in-flight queries are not cancelled.

const resolver = new Resolver();
resolver.setServers(['1.1.1.1', '[2606:4700:4700::1111]:53']);
async resolve(hostname: string, rrtype: RecordTypeName = 'A'): Promise<DnsRecordData[]>

Resolve a hostname for the given record type.

Supported record names are the keys of RECORD_TYPES. A and AAAA lookups follow CNAME chains up to 10 hops. DNS-layer errors throw with code and hostname properties; no-answer responses resolve to an empty array.

const resolver = new Resolver();
const addresses = await resolver.resolve('example.com', 'A');
async resolve4(hostname: string)

Resolve IPv4 A records for a hostname.

Returns an empty array when the name exists but has no A records. Throws on DNS errors, timeout, malformed packets, or excessive CNAME hops.

const addrs = await new Resolver().resolve4('example.com');
async resolve6(hostname: string)

Resolve IPv6 AAAA records for a hostname.

The returned strings are formatted through inet_ntop; no zone IDs are added. Throws on DNS errors or timeout.

const addrs = await new Resolver().resolve6('example.com');
async resolveMx(hostname: string)

Resolve MX records for a hostname.

Records are returned in DNS response order, not sorted by priority.

const mx = await new Resolver().resolveMx('example.com');
async resolveTxt(hostname: string)

Resolve TXT records for a hostname.

Each DNS TXT record is returned as an array of character strings because a single TXT record can contain multiple length-prefixed strings.

const txt = await new Resolver().resolveTxt('example.com');
async resolveNs(hostname: string)

Resolve authoritative nameserver records for a hostname.

const ns = await new Resolver().resolveNs('example.com');
async resolveSrv(hostname: string)

Resolve SRV service-location records.

The resolver does not perform weighted target selection; callers should apply SRV priority and weight rules themselves.

const srv = await new Resolver().resolveSrv('_xmpp-server._tcp.example.com');
async resolveSoa(hostname: string)

Resolve SOA records for a zone name.

Most zones return one SOA record, but the return shape is still an array to match the generic resolver API.

const [soa] = await new Resolver().resolveSoa('example.com');
async resolveCname(hostname: string)

Resolve CNAME records for a hostname.

This returns only CNAME answers; it does not follow the target to A or AAAA addresses.

const aliases = await new Resolver().resolveCname('www.example.com');
async reverse(ip: string): Promise<DnsRecordData[]>

Reverse DNS lookup.

Converts IPv4 or IPv6 addresses to the appropriate PTR query name and resolves PTR records. Invalid IP strings are not fully validated before the query name is built.

const names = await new Resolver().reverse('8.8.8.8');

Functions

async function lookup(hostname: string, opts: LookupOptions = {}): Promise<LookupResult>

Look up the primary address for a hostname.

This module-level helper lazily creates a shared Resolver. IP literals are returned without DNS I/O. When family is omitted, this implementation queries IPv4. Missing records throw ENOTFOUND; malformed address responses throw ENODATA.

import { lookup } from 'fino:net/dns';

const { address, family } = await lookup('example.com', { family: 4 });