libwebsockets
Lightweight C library for HTML5 websockets
Loading...
Searching...
No Matches
Asynchronous DNS

Introduction

Lws now features optional asynchronous, ie, nonblocking recursive DNS resolution done on the event loop, enable -DLWS_WITH_SYS_ASYNC_DNS=1 at cmake to build it in.

Description

The default libc name resolution is via libc getaddrinfo(), which is blocking, possibly for quite long periods (seconds). If you are taking care about latency, but want to create outgoing connections, you can't tolerate this exception from the rule that everything in lws is nonblocking.

Lws' asynchronous DNS resolver creates a caching name resolver that directly queries the configured nameserver itself over UDP, from the event loop.

It supports both ipv4 / A records and ipv6 / AAAA records (see later for a description about how). Multiple servers are supported over UDP :53, and the nameservers are autodiscovered on linux, macos, windows, android and freertos.

Other features

  • lws-style paranoid response parsing
  • random unique tid per question, and a random source port per query, to increase difficulty of poisoning (see "Spoofing" below)
  • it's really integrated with the lws event loop, it does not spawn threads or use the libc resolver, and of course no blocking at all
  • platform-specific server address capturing (from /etc/resolv.conf on linux, the SystemConfiguration dynamic store on macOS, windows apis on windows), with changes watched and distributed via SMD
  • LRU caching
  • piggybacking (multiple requests before the first completes go on a list on the first request, not spawn multiple requests)
  • observes TTL in cache
  • TTL and timeout use lws_sul timers on the event loop
  • Uses CNAME resolution inside the same response if present, otherwise recurses to resolve the CNAME (up to 3 deep)
  • ipv6 pieces only built if cmake LWS_IPV6 enabled
  • Multi-server support: automatically parses multiple nameservers from /etc/resolv.conf.
  • Adaptive server selection: tracks DNS server response latencies using exponential weighted moving averages.
  • Parallel broadsiding: initially sends queries to all configured servers simultaneously to determine the fastest responder.
  • Round-robin fallback: utilizes the fastest server for subsequent queries or round-robins between equally performant servers.

Api

If enabled at cmake, the async DNS implementation is used automatically for lws client connections. It's also possible to call it directly, see the api-test-async-dns example for how.

The Api follows that of getaddrinfo() but results are not created on the heap. Instead a single, const cached copy of the addrinfo struct chain is reference-counted, with lws_async_dns_freeaddrinfo() provided to deduct from the reference count. Cached items with a nonzero reference count can't be destroyed from the cache, so it's safe to keep a pointer to the results and iterate through them.

Names under localhost

"localhost" and any name under it, eg "other.localhost", are loopback (RFC 6761 6.3). lws answers A and AAAA for them itself, after the hosts file, an A query with both 127.0.0.1 and ::1 as the hosts file would answer it, and never asks a nameserver about them, whichever resolver is in use: with libc getaddrinfo() they are looked up as "localhost", the one such name every platform knows. lws_host_is_localhost() tells if a host is one of them.

Search list

A name with fewer dots than the resolver's ndots (1 unless the platform says otherwise) is tried under each domain of the platform resolver's search list in turn, and then as given; a name with at least ndots dots is tried as given first, and then under each search domain. That is the order libc uses, so a name resolves to the same thing through lws as it does through getaddrinfo(). A name ending in a dot is absolute and is only tried as given.

NXDOMAIN for one candidate moves the query on to the next, and so does SERVFAIL, as a resolver may refuse a name it can't route rather than deny it; the last candidate's answer is what the requester gets. The result is cached against the name as asked for, so the next lookup of it doesn't search again, and the requester's callback is told the name it asked for.

On unix, the list is the Search line of /etc/resolv.conf, or the domain line if there is no Search (the last of either wins), up to six domains, and ndots is from options ndots:N. The LOCALDOMAIN env var replaces the file's list, as it does for libc. On windows it's the primary DNS suffix from GetNetworkParams(). Android and freertos have no search list. Like the nameservers, the list is reloaded when the platform's configuration changes, see below; it's exposed by lws_plat_asyncdns_get_search().

Dealing with IPv4 and IPv6

DNS is a very old standard that has some quirks... one of them is that multiple queries are not supported in one packet, even though the protocol suggests it is. This creates problems on ipv6 enabled systems, where it may prefer to have AAAA results, but the server may only have A records.

To square the circle, for ipv4 only systems (LWS_IPV6=0) the resolver requests only A records. For ipv6-capable systems, it always requests first A and then immediately afterwards AAAA records.

The A and AAAA questions are asked with their own tids, each a full 16 random bits, and a response is matched to the question by its tid.

The first response to come back is parsed, and a cache entry made... it leaves a note in the query about the address of the last struct addrinfo record. When the second response comes, a second allocation is made, but not added to the logical cache... instead it's chained on to the first cache entry and the struct addrinfo linked-list from the first cache entry is extended into the second one. At the time the second result arrives, the query is destroyed and the cached results provided on the result callback.

Spoofing

An off-path attacker who wants to poison the cache has to get its forged response accepted before the real one arrives, so everything about the query it can't see has to be guessed (RFC 5452):

  • each question has a tid of 16 random bits, unique among the queries in flight, and a response must repeat the question exactly
  • each query asks from a UDP socket of its own, bound to a source port picked at random in 32768 - 65535 (LWS_CAUDP_RANDOM_PORT), rather than the OS' choice, which may be predictable on some platforms. A response is only taken on the socket its query asked from, and the socket is closed when the query is done.

The number of sockets is bounded, so a flood of lookups can't use up the fds: up to the fds table size / 16 (at least 2, at most 32) are opened before queries start sharing the least used socket to their nameserver. A socket that has taken 16 queries in its life is passed over while there is another, so the ports keep turning over, but a lookup is never refused because every socket has: it shares the least used one. A context sized for a small client (lws_context_info_defaults()) gets the 2 its fds table reserves for the resolver (LWS_FD_LIMIT_INTERNAL_ADNS), a server sized to the process fd limit gets 32. When no new socket can be opened, eg, the process is out of fds, a query shares one if there is one, or fails.

Recursion

Where CNAMEs are returned, DNS servers may take two approaches... if the CNAME is also resolved by the same server and so it knows what it should resolve to, it may provide the CNAME resolution in the same response packet.

In the case the CNAME is actually resolved by a different name server, the server with the CNAME does not have the information to hand to also resolve the CNAME in the same response. So it just leaves it for the client to sort out.

The lws implementation can deal with both of these, first it "recurses" (it does not recurse on the process stack but uses its own manual stack) to look for results in the same packet that told it about the CNAME. If there are no results, it resets the query to look instead for the CNAME, and restarts it. It allows this to happen for 3 CNAME deep.

At the end, either way, the cached result is set using the original query name and the results from the last CNAME in the chain.

A lookup that validates DNSSEC (see below) doesn't use the target's records from the packet that brought the CNAME, since they are signed, if at all, by the target's zone. It first validates the CNAME itself, as an RRset of the queried name, and only then restarts the query for the target, whose answers have to validate on their own. An unsigned CNAME fails a validating lookup.

DNSSEC Support

Async DNS supports DNSSEC validation of responses. This is optional and provides robust protection against DNS spoofing or injection of forged results.

To enable DNSSEC features, build with -DLWS_WITH_SYS_ASYNC_DNS_DNSSEC=1 in CMake. This will automatically enable the LWS_WITH_GENCRYPTO required dependency.

Once enabled, clients can strictly enforce DNSSEC validation globally via the config struct or explicitly over context:

lws_async_dns_dnssec_set_mode(context, LWS_ADNS_DNSSEC_REQUIRE);

When validation is set to LWS_ADNS_DNSSEC_REQUIRE, queries failing to authenticate computationally with upstream Trust Anchors (or those lacking RRSIG/DNSKEY records entirely) will be explicitly rejected by the resolver and not propagate to callbacks or connections. But some domains inherently lack DNSSEC. For situations where strict DNSSEC is globally mandated, but a small handful of known-unsigned destinations must be reached, clients can explicitly set the LWS_ADNS_INDICATE_LACKS_DNSSEC bitflag natively on integer qtype lookups. This allows the resolver to tolerate missing records explicitly for that singular lookup, while strictly required globally.

Independent of the context mode, a single lookup can be made to validate by ORing LWS_ADNS_WANT_DNSSEC into its qtype. Only a lookup that validated reports LWS_ADNS_DNSSEC_VALID in the callback's n, including when a later lookup is served the same records from the cache; a lookup that has to validate is never served records that were cached by one that didn't.

A lookup that validates keeps only the records of the type it asked for, which are exactly the RRset its RRSIG covers: other records the response carried, including the RRSIGs themselves, are not cached, so they can't be found later, eg, with lws_async_dns_get_rr_cache(), as if they had been validated. An RRset of more than 16 records fails to validate.

The TTLs on the wire aren't covered by the signatures, so a validated answer is only cached, and served as validated, until the earliest of its RRSIG's expiration, its RRSIG's Original TTL from when it was validated, and the expiry of the signer zone's authenticated keys (for an answer reached through a CNAME, the CNAME's too). Any answer, validated or not, is cached for at most a day whatever TTL it came with.

Chain of trust

An answer's RRSIG is only checked with a key of the signer's zone once that zone's keys are themselves authenticated, the same way a validating recursive resolver does it:

  • the zone's DS RRset is fetched, and must be signed by an authenticated key of the zone that signed it (its parent), which is authenticated the same way in turn, up to the root
  • the zone's DNSKEY RRset is fetched, and must be signed by a key in it that one of those DS records vouches for; for the root, one of the trust anchors

The chain is walked once per zone, and the authenticated keys are kept until the earlier of their signatures' expiry and their TTL, so later lookups signed by the same zones only cost the RRSIG check. A zone that fails to authenticate is also remembered for 10s, so a broken chain is not walked again for every lookup that needs it.

Delegations to unsigned zones are not proven with NSEC / NSEC3: anything under a zone without DS records fails to validate. The supported algorithms are RSASHA256, RSASHA512, ECDSAP256SHA256 and ECDSAP384SHA384, with DS digests of SHA-256 or SHA-384.

The ICANN root KSK DS records are built in as the trust anchors. They can be replaced, eg, for a private DNS hierarchy, with

lws_async_dns_dnssec_set_root_anchors(context, anchors, count);

which also forgets any keys authenticated under the previous anchors, and stops cached answers validated with them being served as validated; minimal-examples-lowlevel/api-tests/api-test-dnssec-chain does this to test the validator against a signed hierarchy of its own.

Network configuration changes and failover

Since lws async DNS natively talks to the DNS servers over UDP, it doesn't automatically adapt when the OS routing table or network configuration changes (e.g., when a laptop is unplugged from ethernet and moves to wlan, losing access to the previous local DNS server).

To handle this gracefully, there are these mechanisms:

  1. Reactive Failover: The adaptive server selection tracks response latencies using lws_adapt. If all active DNS servers consecutively fail to respond or experience severe timeouts (e.g., >10s tracking averages), the lws async DNS system will infer the network configuration has changed and will automatically flush its active nameserver list. It then re-queries the system (e.g., via GetNetworkParams on Windows or __system_property_get on Android) to learn the new servers. This occurs automatically.
  2. Platform DNS server watching: every platform exposes its current effective DNS server list through the platform primitive (/etc/resolv.conf on linux, system properties on android, the SystemConfiguration dynamic store on macOS, GetNetworkParams on windows). Lws watches for changes in that list: natively pushed from the macOS dynamic store on a dispatch queue (this also sees per-service and split-DNS resolver sets that /etc/resolv.conf does not mirror), and by a lightweight poll (~3s, overridable with the LWS_ASYNCDNS_WATCH_MS env var for testing) everywhere else. When the effective list, or the search list, changes, lws publishes them on SMD class LWSSMDCL_DNS as JSON like {"ns":["192.168.1.1","1.1.1.1"],"Search":["lan"],"ndots":1}, at context creation and on every subsequent change; that event both drives an internal requery-and-reload of the resolver's server list (the message is a trigger, the platform is re-queried for the authoritative list) and is available to user SMD observers, including in other processes via the secure streams proxy. Enabling LWS_WITH_SYS_ASYNC_DNS selects LWS_WITH_SYS_SMD accordingly.
  3. Boot synchronization: with LWS_WITH_SYS_STATE, system state transitions hold at LWS_SYSTATE_DNS until the resolver has acquired at least one usable DNS server from the platform, or user code pinned one (via info.async_dns_servers or lws_async_dns_server_add()). This synchronizes user code that waits for LWS_SYSTATE_OPERATIONAL against being able to actually perform DNS lookups.
  4. Explicit API: Applications can explicitly tell lws to drop its currently tracked DNS servers and refresh its understanding of the network environment by calling the public API:
lws_async_dns_server_reload(context);

This is particularly useful on Android, where native C code cannot easily hook onto Java ConnectivityManager link change broadcasts. Android applications can listen to onLinkPropertiesChanged() natively in Kotlin/Java and call a JNI function that executes lws_async_dns_server_reload() out-of-band to proactively trigger the failover.

For testing, the LWS_ASYNCDNS_RESOLV_CONF env var redirects resolv.conf-shaped platform discovery to an arbitrary file path, so tests can drive discovery and change detection through a scratch file they control.