# `Mob.DNS`
[🔗](https://github.com/genericjam/mob/blob/master/lib/mob/dns.ex#L1)

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)

# `error_reason`

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

The error shapes `resolve/1` can return.

# `host`

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

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

# `configure_pure_beam`

```elixir
@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.

## No resolv.conf (Android)

`:inet_db` watches `/etc/resolv.conf` and, every few seconds on the next
lookup, replaces its nameservers with the file's. Where the file doesn't
exist (Android, or any platform without one) it replaces them with none,
so seeded nameservers used to vanish within 5 s and every lookup after
that was `:nxdomain`. When the watched file is missing, this stops the
watch before seeding. The nameservers already configured are kept. A
resolv.conf that exists (iOS simulator, host) is still watched and
followed.

## 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`

```elixir
@spec preresolve([host()]) :: %{
  required(host()) =&gt; {: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`

```elixir
@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?`

```elixir
@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.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
