Mob.DNS (mob v0.9.4)

Copy Markdown View Source

Hostname → IP resolution that works around BEAM's broken DNS path on iOS and physical Android devices.

Why this exists

BEAM resolves hostnames by spawning an external helper called inet_gethost (a port program). On macOS, Linux, Windows that works fine. On mobile it doesn't, for two distinct reasons:

  • iOS — the app sandbox forbids execve of any binary the app didn't get a special pass for. inet_gethost never runs.
  • Physical Android (some devices) — inet_gethost does run (mob ships it as libinet_gethost.so, allowed to execve by the apk_data_file SELinux label), but on some devices Bionic's getaddrinfo from the execve'd child process returns :nxdomain for hostnames the same app's in-process HTTPS stack resolves fine. Confirmed on a Moto G Power 5G 2024 (Android 14): app-uid TCP-by-IP succeeds, but BEAM's :inet.getaddr/2 fails with :nxdomain. A Moto G Power 2021 (Android 11) resolves fine through the same path, as does the Android emulator (its DNS proxy at 10.0.2.3 is reachable to anything) — so don't rely on it.

In either case :inet.getaddr/2 (and therefore Req, Finch, Mint, ReqLLM, and basically every Elixir HTTP library) fails the moment a request hits a hostname rather than a literal IP.

This module side-steps the problem by calling the OS resolver (getaddrinfo) in-process via a NIF — same address space, same uid as the rest of the app — then seeding :inet_db with the result so subsequent BEAM-level lookups for the same host succeed from the in-process file table.

How to use it

Robust everywhere, incl. cellular: resolve/1 · preresolve/1

resolve/1 calls the OS's getaddrinfo via a NIF — Darwin's on iOS, Bionic's on Android — then seeds :inet_db's :file table, so subsequent :inet.getaddr/2 lookups (Req / Finch / Mint) find the result. Because it uses the OS resolver, it works wherever the OS does — including iOS cellular (carrier's DNS) and physical Android devices where the forked inet_gethost path is broken. This is the recommended path on both platforms.

Preresolve your known hosts at startup — that's all most apps need:

def on_start do
  Mob.DNS.preresolve(["api.example.com", "cdn.example.com"])
  # …rest of startup…
end

For a host not known until request time, call resolve/1 just before the request. Idempotent and cheap.

General fallback (WiFi-friendly): configure_pure_beam/1

Flips the lookup chain to [:file, :dns] and seeds nameservers so any hostname resolves via raw DNS from inside BEAM — useful when you can't enumerate hosts up front:

def on_start do
  if :mob_nif.platform() == :ios, do: Mob.DNS.configure_pure_beam()
end

Two caveats that make this the fallback, not the default:

  • Don't reset the chain to include :native on iOS — exec'ing inet_gethost there is fatal, it crashes the BEAM. On physical Android :native is non-fatal but unreliable (returns :nxdomain for hostnames that do resolve in-process); Mob.DNS.resolve/1 is the recommended path there too.
  • It can't resolve on cellular by default. Its default nameservers are public (Google / Cloudflare), which carriers commonly block → :nxdomain. iOS exposes no reliable API to read the carrier's resolvers, so there's nothing dependable to seed instead. On cellular, prefer preresolve/resolve above, or pass :nameservers you know are reachable.

The two compose: :file is consulted before :dns, so anything you resolve/1 wins over the configure_pure_beam fallback.

Scope and limitations

  • IPv4 only. Most cloud endpoints serve A records; IPv6 is a follow-up if it becomes useful.
  • One IP per host. If the hostname has multiple A records, the first one is used. BEAM caches the result; failover isn't automatic. If your endpoint cycles IPs frequently you may need to re-resolve.
  • No automatic refresh. Mappings stay in :inet_db until the BEAM exits or the host is resolved again. Call resolve/1 after a network change (or when a backend's IP may have moved): the fresh answer replaces the host's previous address, and other hosts seeded through resolve/1 on a shared address keep theirs.
  • Don't hand-edit seeded hosts with :inet_db.del_host(ip). It removes the address for every hostname seeded under it — with a DNS sinkhole answering 0.0.0.0 for many names, that unseeds all of them. Re-resolve instead.
  • Doesn't help raw NIF networking. If a third-party NIF calls libc getaddrinfo itself, it never goes through BEAM's DNS layer and doesn't need (or benefit from) this fix — it already works. Only :inet-mediated lookups (which covers almost all Elixir HTTP libraries) need our help.
  • Background-app network restrictions still apply. Android's App Standby / battery optimizer can block all outbound network from a backgrounded app, including TCP-by-IP — resolving a name won't help if the OS is silently dropping the connect(). Use a foreground service or keep the app foregrounded for sustained DNS / connectivity.
  • Host dev (Mac, Linux) doesn't need this. The NIF isn't loaded off-device; callers get {:error, :nif_not_loaded} and should fall back to BEAM's normal path (which works on dev).

Errors

{:ok, {a, b, c, d}}              # success
{:error, :badarg}                # host arg invalid
{:error, :nxdomain}              # no such hostname
{:error, :timeout}               # resolver TRY_AGAIN
{:error, :no_address}            # resolved but no IPv4
{:error, {:gai, code}}           # raw getaddrinfo error code
{:error, :nif_not_loaded}        # called off-device (host tests)

Summary

Types

The error shapes resolve/1 can return.

Hostname to resolve. Latin-1 only — we're not in a domain that uses IDN.

Functions

Configure BEAM's DNS path so :inet.getaddr/2 (and Req / Finch / Mint / gen_tcp:connect/3 with a hostname) works without per-host setup.

Resolve a list of hostnames. Returns a map of host → result so the caller can see which ones failed.

Resolve host to an IPv4 address and seed :inet_db so subsequent :inet.getaddr/2 lookups (and thus Req / Finch / Mint) find it.

True when host is already seeded in :inet_db.

Types

error_reason()

@type error_reason() ::
  :badarg
  | :nxdomain
  | :timeout
  | :no_address
  | :nif_not_loaded
  | {:gai, integer()}

The error shapes resolve/1 can return.

host()

@type host() :: String.t() | charlist()

Hostname to resolve. Latin-1 only — we're not in a domain that uses IDN.

Functions

configure_pure_beam(opts \\ [])

@spec configure_pure_beam([{:nameservers, [:inet.ip_address()]}]) :: :ok

Configure BEAM's DNS path so :inet.getaddr/2 (and Req / Finch / Mint / gen_tcp:connect/3 with a hostname) works without per-host setup.

Sets the lookup chain to [:file, :dns] and seeds fallback nameservers. Both ops are idempotent.

Why

BEAM's default :native lookup spawns inet_gethost, which iOS refuses to execve. The :dns lookup, by contrast, performs raw UDP/TCP DNS queries from inside BEAM via gen_udp / gen_tcp — no port program, no fork. iOS doesn't block sockets, so the :dns path Just Works.

After calling this, the whole :inet-mediated HTTP stack stops needing a per-host resolve/1 call. :file stays first in the chain so any host you do resolve/1 manually still wins — the two paths compose.

When NOT to default to this

Reach for per-host resolve/1 (which uses libc getaddrinfo via the NIF, going through Apple's resolver) when you need any of:

  • VPN-pushed DNS for internal hostnames
  • .local / mDNS service discovery
  • Search-domain expansion (single-label hostnames like https://api/)
  • Captive-portal-aware lookup
  • Encrypted-DNS-at-OS-level (DoH / DoT configured in iOS Settings)

These all require Apple's resolver, which only the NIF path consults. The pure-BEAM :dns path queries whatever nameservers you seed and nothing else.

Cellular caveat

This won't resolve on cellular with the defaults: the seeded public resolvers (8.8.8.8 / 1.1.1.1) are commonly blocked by carriers → :nxdomain. iOS exposes no reliable API to read the carrier's resolvers, so there's nothing dependable to seed instead. For hosts you can name, prefer preresolve/1 / resolve/1 (they use the OS resolver and work on cellular); otherwise pass :nameservers you know are reachable.

Opts

  • :nameservers — list of nameserver IP tuples (IPv4 or IPv6). Defaults to [{8, 8, 8, 8}, {1, 1, 1, 1}] (Google + Cloudflare). Pass any list, including [] to skip seeding (e.g. if your app's :kernel env already configures them). Common alternatives:

    • [{9, 9, 9, 9}] — Quad9 (privacy-leaning, no logging)
    • [{10, 0, 0, 1}, {10, 0, 0, 2}] — your corporate resolvers

Idempotent

Calling this twice is a no-op on the second call — duplicate nameservers aren't added, the lookup chain isn't reordered.

Examples

# Default — most apps need nothing more
Mob.DNS.configure_pure_beam()

# Override the fallback nameservers
Mob.DNS.configure_pure_beam(nameservers: [{9, 9, 9, 9}])

# Set the lookup chain but skip nameserver seeding
Mob.DNS.configure_pure_beam(nameservers: [])

preresolve(hosts)

@spec preresolve([host()]) :: %{
  required(host()) => {:ok, :inet.ip4_address()} | {:error, error_reason()}
}

Resolve a list of hostnames. Returns a map of host → result so the caller can see which ones failed.

Useful at app startup for the known-fixed set of backends your app talks to.

%{
  "api.example.com" => {:ok, {93, 184, 216, 34}},
  "auth.example.com" => {:error, :nxdomain}
}

resolve(host)

@spec resolve(host()) :: {:ok, :inet.ip4_address()} | {:error, error_reason()}

Resolve host to an IPv4 address and seed :inet_db so subsequent :inet.getaddr/2 lookups (and thus Req / Finch / Mint) find it.

The fresh answer replaces whatever IPv4 address host was seeded with before, so calling it again after a network change (Wi-Fi → cellular, a DNS sinkhole → a real resolver) refreshes the mapping. Other hosts seeded through resolve/1 that share the old or new address keep theirs; concurrent resolve/1 calls are serialised. Writes your own code makes with :inet_db.add_host(ip, names) at the same moment are not coordinated with it. On {:error, _} the existing mapping, if any, is left in place.

See module doc for usage, scope, and error shapes.

resolved?(host)

@spec resolved?(host()) :: boolean()

True when host is already seeded in :inet_db.

Useful for short-circuiting in caller code that wants to avoid an unnecessary NIF call — but resolve/1 is idempotent, so calling it again is also fine.