Mob.DNS (mob v0.6.26)

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 Androidinet_gethost does run (mob ships it as libinet_gethost.so, allowed to execve by the apk_data_file SELinux label), but Bionic's getaddrinfo from the execve'd child process returns :nxdomain for hostnames the same app's in-process HTTPS stack resolves fine. The Android emulator happens not to hit this (its DNS proxy at 10.0.2.3 is reachable to anything), so the fault doesn't show in the simulator. 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.

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. If a backend's IP changes mid-session, the cached entry will be stale — call resolve/1 again to refresh.
  • 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.

Idempotent — calling for the same host twice is harmless.

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.