Packages
mob
0.6.10
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.
## 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 **iOS** it doesn't — the iOS app sandbox forbids
`execve` of any binary the app didn't get a special pass for, and
there's no equivalent of Android's `lib*.so` escape hatch.
Result: `:inet.getaddr/2` (and therefore Req, Finch, Mint,
ReqLLM, and basically every Elixir HTTP library) fails on iOS
the moment a request hits a hostname rather than a literal IP.
This module side-steps the problem by calling Darwin's
`getaddrinfo` directly via a NIF, then seeding `:inet_db` with
the result so subsequent BEAM-level lookups for the same host
succeed from the in-process file table.
Android isn't affected — `mob_beam.zig` ships `inet_gethost` as
`libinet_gethost.so` in `jniLibs/`, which the SELinux policy
allows to `execve`. The NIF here would work on Android too but
isn't wired up by default; the BEAM path is already functional
there.
## How to use it
### Recommended (set-and-forget): `configure_pure_beam/1`
At app startup, flip BEAM's lookup chain from the broken `:native`
path to `[:file, :dns]` and seed fallback nameservers. After this,
every `:inet.getaddr/2` resolves via raw DNS queries from inside
BEAM (no port program, no `execve`), and the usual HTTP libraries
just work:
def on_start do
Mob.DNS.configure_pure_beam()
# …rest of startup…
end
Defaults to Google + Cloudflare DNS. Override via opt if your
network requires it. See `configure_pure_beam/1` for details and
trade-offs vs the Apple-resolver-via-NIF path below.
### Per-host (when Apple-resolver semantics matter): `resolve/1`
For hostnames that need iOS's resolver — VPN-pushed DNS, `.local`
/ mDNS, search-domain expansion, captive portals — call
`resolve/1` for each one. Idempotent and cheap; safe to call
alongside `configure_pure_beam/0` (the `:file` lookup runs first,
so manually-resolved entries win over the `:dns` fallback).
{:ok, _ip} = Mob.DNS.resolve("internal.corp.local")
For a small fixed set, `preresolve/1` does the whole list at
once:
Mob.DNS.preresolve([
"internal.corp.local",
"service.local"
])
Both paths compose. The recommended pattern is `configure_pure_beam`
at startup as the default, then `resolve/1` only for the OS-resolver
specials.
## 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 on iOS. Only `:inet`-mediated lookups (which covers
almost all Elixir HTTP libraries) need our help.
- **iOS only effectively.** On Android and host (dev, macOS,
Linux) the NIF works but is unnecessary; BEAM's built-in path
is fine there.
## 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.
## 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