# `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** — `inet_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)

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

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

Idempotent — calling for the same host twice is harmless.

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*
