Skip to main content

DNS lookup

Verum ships a pure-Verum DNS client (RFC 1035 over UDP + TCP fallback) whose protocol implementation does not require libc getaddrinfo. The underlying host interpreter socket path may use libc; generated AOT transport must obey the strict target boundary. The client participates in the async runtime and respects cancellation, timeouts, and structured concurrency.

Forward resolution​

mount core.net.*;

async fn print_ips(host: &Text) using [Network] {
match lookup_host_async(host).await {
Result.Ok(addrs) => {
for a in &addrs { print(f"{host} -> {a}"); }
}
Result.Err(e) => eprint(f"dns: {e:?}"),
}
}

Output:

example.com -> 93.184.216.34
example.com -> 2606:2800:220:1:248:1893:25c8:1946

lookup_host_async(host) returns all A + AAAA records as a List<IpAddr>. The family-specific forms are SYNCHRONOUS — there is no lookup_host_v4_async / lookup_host_v6_async:

lookup_host_v4(host) // Result<List<Ipv4Addr>, DnsError>
lookup_host_v6(host) // Result<List<Ipv6Addr>, DnsError>

The whole async surface of core.net.dns is three free functions — lookup_host_async, lookup_addr_async, resolve_async. Everything else, including every Resolver method, is synchronous.

Reverse resolution (PTR)​

let addr = IpAddress.V4(Ipv4Addr.new(1, 1, 1, 1));
match lookup_addr_async(&addr).await {
Result.Ok(name) => print(f"{addr} -> {name}"),
Result.Err(e) => eprint(f"dns: {e:?}"),
}
// Output: 1.1.1.1 -> one.one.one.one

PTR lookups are rate-limited by most public resolvers. Retry with backoff if you need bulk reverse resolution — see Resilience.

Resolve for connect()​

let addrs: List<SocketAddr> =
resolve_async("example.com", 443).await?;

let stream = TcpStream.connect_addr_async(&addrs[0]).await?;

TcpStream.connect_async is already generic over anything implementing ToSocketAddrs, so in most code you never call resolve_async directly:

// Implicit DNS + connect:
let stream = TcpStream.connect_async("example.com:443").await?;

Behind the scenes, ToSocketAddrs resolves the name, tries each resulting address in order (IPv6 first by default), and returns the first successful socket.

Happy-eyeballs connect​

Not shipped

Happy-eyeballs (RFC 8305 — racing IPv4 and IPv6 with a head start for v6) is not implemented. Neither TcpStream.connect_happy_eyeballs_async nor HappyEyeballsOptions exists in core/.

What ships today tries the resolved addresses in order, IPv6 first, and returns the first socket that connects:

// Synchronous, and async; both resolve through `ToSocketAddrs`
// and walk the address list sequentially.
let stream = TcpStream.connect(("example.com", 443))?;
let stream = TcpStream.connect_async(("example.com", 443)).await?;

The practical difference is the failure case: a sequential walk pays the full connect timeout on an unreachable IPv6 address before it tries IPv4, which is the latency happy-eyeballs exists to remove. If you need the racing behaviour now, run the two connects under select yourself.

Custom resolver​

Use a Resolver when you need:

  • A specific set of nameservers.
  • A non-default timeout or retry budget.
  • A separate cache from the rest of your process.
  • Record types beyond A/AAAA.
let resolver = Resolver.new()
.nameserver_ip(Ipv4Addr.new(1, 1, 1, 1)) // Cloudflare
.nameserver_ip(Ipv4Addr.new(8, 8, 8, 8)) // Google
.timeout_ms(3_000)
.retries(2)
.use_tcp(false) // UDP first, TCP fallback on truncation
.search_domain("corp.internal")
.ndots(1);

The full builder is new, system, nameserver, nameserver_ip, timeout_ms, retries, search_domain, ndots, use_tcp — that is all of it. There is no DNSSEC validation and no per-resolver cache.

Record types​

Resolver's query methods are SYNCHRONOUS. The module's entire async surface is three free functions — lookup_host_async, lookup_addr_async, resolve_async — and none of them takes a Resolver. Call the methods below directly, or move the call off the executor yourself.

// A + AAAA together — there is no separate lookup_a / lookup_aaaa
let ips = resolver.lookup("example.com")?; // List<IpAddr>

// MX — priority paired with the exchange host
let mxs = resolver.lookup_mx("example.com")?; // List<(Int, Text)>
for (priority, exchange) in &mxs { print(f"{priority} {exchange}"); }

// TXT
let txts = resolver.lookup_txt("_dmarc.example.com")?; // List<Text>

// SRV — ONE name, already assembled; not (service, proto, domain)
let srv = resolver.lookup_srv("_imap._tcp.example.com")?;
// List<(Int, Int, Int, Text)> — priority, weight, port, target

// NS
let ns = resolver.lookup_ns("example.com")?; // List<Text>

// PTR — reverse
let name = resolver.lookup_ptr(&addr)?; // Text

CNAME and SOA have no dedicated method. Reach them through query, which is the general form:

Arbitrary query type​

let entries = resolver.query("example.com", DnsRecordType.A)?;
// List<DnsRecordEntry> — each is { record: DnsRecord, ttl: Int }

for entry in &entries {
print(f"ttl={entry.ttl}");
match entry.record {
DnsRecord.A(addr) => print(f"A {addr}"),
DnsRecord.AAAA(addr) => print(f"AAAA {addr}"),
DnsRecord.CNAME(name) => print(f"CNAME {name}"),
DnsRecord.MX { priority, exchange } => print(f"MX {priority} {exchange}"),
DnsRecord.TXT(text) => print(f"TXT {text}"),
DnsRecord.NS(host) => print(f"NS {host}"),
DnsRecord.PTR(name) => print(f"PTR {name}"),
DnsRecord.SRV { priority, weight, port, target }
=> print(f"SRV {target}:{port}"),
DnsRecord.SOA { mname, serial, .. } => print(f"SOA {mname} {serial}"),
}
}

DnsRecordType is A | AAAA | CNAME | MX | TXT | NS | PTR | SRV | SOA | ANY. Note the record carries a TTL beside it — the entry is the pair, not the record alone.

Caching​

There is no cache

grep -ci cache core/net/dns.vr → 0. Every query goes to a nameserver.

This section previously documented cache_clear(), cache_invalidate(host), cache_stats() with a hit_rate, a Resolver.new().cache_capacity(0) builder for deterministic tests, and a "~50 ns (a hash-map lookup)" figure for a cache hit. None of those names exists, so none of that was measured — a performance number attached to an absent mechanism is the clearest possible sign a page has drifted from its library.

If you need caching, hold the results yourself: lookup returns a List<IpAddr> and query returns List<DnsRecordEntry> whose ttl field is the value a cache would key its expiry on.

DNS-over-HTTPS (DoH)​

Not shipped

DnsTransport and with_transport do not exist, and the file has no HTTPS side at all:

grep -rlE 'DnsTransport|with_transport' core/ --include='*.vr' | wc -l # 0
grep -cEi 'doh|dns-query|https' core/net/dns.vr # 0

Resolver is real (grep -n 'type Resolver' core/net/dns.vr); its builder is nameserver, nameserver_ip, timeout_ms, retries, search_domain, ndots and use_tcp — plain UDP with a TCP fallback. The shape below is what a DoH transport would look like.

A DoH transport would look like the shape below. It is not callable: neither DnsTransport nor with_transport exists, and the sentence that used to stand under it — that DoH reuses the process's HTTPS pool for connection-level authentication — was a claim about a mechanism with no code behind it.

// SHAPE ONLY — does not compile. Nothing here is declared.
let resolver = Resolver.new()
.with_transport(DnsTransport.Https {
endpoint: url#"https://cloudflare-dns.com/dns-query",
ech: true, // Encrypted Client Hello
});

What Resolver does offer is plain UDP with a TCP fallback: .nameserver(&addr), .nameserver_ip(ip), .timeout_ms(ms), .retries(n), .search_domain(d), .ndots(n), .use_tcp(bool).

Validation helpers​

Avoid firing a DNS query for something that is already an IP literal or an obviously invalid name:

fn should_resolve(input: &Text) -> Bool {
!is_ip_address(input) &&
is_valid_domain(input) &&
input.len() <= 253 // max FQDN length
}

if should_resolve(host) {
lookup_host_async(host).await
} else if is_ip_address(host) {
Result.Ok([parse_ip(host)?]) // already an IP
} else {
Result.Err(DnsError.InvalidName)
}

Bulk resolution with backpressure​

Resolving thousands of hostnames? Bound concurrency with a Semaphore and a nursery:

async fn resolve_many(hosts: &List<Text>, concurrency: Int)
-> Map<Text, List<IpAddress>>
using [Network, IO]
{
let sem = Semaphore.new(concurrency);
let mut out = Map.new();

nursery(on_error: wait_all) {
for host in hosts {
let sem2 = sem.clone();
let host2 = host.clone();
spawn async move {
let _permit = sem2.acquire().await;
let ips = lookup_host_async(&host2).await
.unwrap_or_else(|_| List.new());
out.insert(host2, ips);
};
}
}
out
}

DNS errors​

DnsError variantMeaning
NoRecordsThe query succeeded but returned no records.
NxDomainThe nameserver says the domain does not exist.
ServFailNameserver transient failure. Retry.
RefusedNameserver refused to answer (policy).
TimeoutNo answer within the configured timeout.
InvalidNameSyntactically invalid hostname.
InvalidResponseMalformed response from the server.
DnssecValidationFailedDNSSEC enabled and validation failed.
Transport(e)Underlying I/O error.

Testing​

Not shipped

MockResolver does not exist — zero declarations in core/ — and Resolver is not a context, so the provide Resolver = mock in { … } this section used to show could not have been written either. Both halves of the example named machinery with no code behind it.

Resolver is a value, so a test points one at a nameserver it controls rather than substituting the type:

// A resolver aimed at a fixture nameserver on the loopback. `retries`
// and `timeout_ms` keep a test from hanging when the fixture is down.
let resolver = Resolver.new()
.nameserver_ip(Ipv4Addr.new(127, 0, 0, 1))
.timeout_ms(200)
.retries(0)
.use_tcp(false);

match resolver.lookup(&"example.com") {
Result.Ok(ips) => assert(ips.len() > 0),
// Run against a loopback with nothing listening and the error is
// NOT `Timeout` — measured. Match the variant you actually expect
// rather than assuming the timeout is what a dead port produces.
Result.Err(err) => print(f"lookup failed: {err}"),
}

The free functions (lookup_host, lookup_addr, resolve) read the system configuration and cannot be pointed anywhere, so a test that must not touch the network uses a Resolver and its methods.

See also​