Packages
mob
0.7.4
0.7.20
0.7.19
0.7.18
0.7.17
0.7.16
0.7.15
0.7.14
0.7.13
0.7.12
0.7.11
0.7.10
0.7.9
0.7.8
0.7.7
0.7.6
0.7.5
0.7.4
0.7.3
0.7.2
0.7.1
0.7.0
0.6.26
0.6.25
0.6.24
0.6.23
0.6.22
0.6.21
0.6.20
0.6.19
0.6.18
0.6.17
0.6.16
0.6.15
0.6.14
0.6.13
0.6.12
0.6.11
0.6.10
0.6.9
0.6.8
0.6.7
0.6.6
0.6.5
0.6.2
0.6.1
0.6.0
0.5.18
0.5.17
0.5.16
0.5.15
0.5.14
0.5.11
0.5.10
0.5.7
0.5.6
0.5.5
0.5.4
0.5.3
0.5.2
0.5.1
0.5.0
0.4.0
0.3.10
0.3.9
0.3.8
0.3.7
0.3.6
0.3.5
0.3.4
0.3.3
0.3.2
0.3.1
0.3.0
0.2.0
0.1.0
BEAM-on-device mobile framework for Elixir
Current section
Files
Jump to
Current section
Files
lib/mob/dns.ex
defmodule Mob.DNS do
@moduledoc """
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)
"""
@typedoc "Hostname to resolve. Latin-1 only — we're not in a domain that uses IDN."
@type host :: String.t() | charlist()
@typedoc "The error shapes `resolve/1` can return."
@type error_reason ::
:badarg
| :nxdomain
| :timeout
| :no_address
| :nif_not_loaded
| {:gai, integer()}
@doc """
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.
"""
@spec resolve(host()) :: {:ok, :inet.ip4_address()} | {:error, error_reason()}
def resolve(host) when is_binary(host), do: resolve(String.to_charlist(host))
def resolve(host) when is_list(host) do
case safe_nif_call(host) do
{:ok, {_, _, _, _} = ip} ->
:inet_db.add_host(ip, [host])
ensure_file_lookup_first()
{:ok, ip}
{:error, _} = err ->
err
end
end
@doc """
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}
}
"""
@spec preresolve([host()]) :: %{host() => {:ok, :inet.ip4_address()} | {:error, error_reason()}}
def preresolve(hosts) when is_list(hosts) do
Map.new(hosts, fn host -> {host, resolve(host)} end)
end
@doc """
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: [])
"""
@spec configure_pure_beam([{:nameservers, [:inet.ip_address()]}]) :: :ok
def configure_pure_beam(opts \\ []) do
nameservers = Keyword.get(opts, :nameservers, [{8, 8, 8, 8}, {1, 1, 1, 1}])
set_lookup_chain([:file, :dns])
Enum.each(nameservers, &add_ns_if_missing/1)
:ok
end
@doc """
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.
"""
@spec resolved?(host()) :: boolean()
def resolved?(host) when is_binary(host), do: resolved?(String.to_charlist(host))
def resolved?(host) when is_list(host) do
case :inet.gethostbyname(host) do
{:ok, _} -> true
{:error, _} -> false
end
end
# ── internals ─────────────────────────────────────────────────────────
# Wrap the NIF call so we surface a structured error when running
# outside the device (host tests, IEx on the Mac before any deploy).
# Without this rescue, callers get an UndefinedFunctionError that's
# hard to interpret.
defp safe_nif_call(host) do
:mob_nif.resolve_ipv4(host)
rescue
UndefinedFunctionError -> {:error, :nif_not_loaded}
ErlangError -> {:error, :nif_not_loaded}
end
# `:inet_db.set_lookup/1` controls the order BEAM tries lookup
# methods. Default on iOS includes `:native` (the broken
# `inet_gethost` path). We push `:file` to the front so seeded
# entries are found first. Idempotent: only modifies if `:file`
# isn't already in front.
defp ensure_file_lookup_first do
current = :inet_db.res_option(:lookup)
case current do
[:file | _] ->
:ok
_ ->
with_file = [:file | List.delete(current, :file)]
:inet_db.set_lookup(with_file)
:ok
end
end
# Set the lookup chain to exactly `chain` if it isn't already.
# Used by `configure_pure_beam/1` to flip to `[:file, :dns]`.
defp set_lookup_chain(chain) do
if :inet_db.res_option(:lookup) != chain do
:inet_db.set_lookup(chain)
end
:ok
end
# Add a nameserver to `:inet_db` if not already configured.
# `:inet_db.res_option(:nameservers)` returns `[{ip, port}]`;
# `add_ns/1` adds at default port 53.
defp add_ns_if_missing(ns) do
existing = :inet_db.res_option(:nameservers)
unless Enum.any?(existing, fn {ip, _port} -> ip == ns end) do
:inet_db.add_ns(ns)
end
:ok
end
end