dig works ≠ your process resolves. gai shows the actual decision path
the OS walks to turn a name into an IP — /etc/hosts, mDNS, systemd-resolved,
DNS — and cross-checks it against an independent DNS query to catch where
that path silently diverges.
No process interception. No LD_PRELOAD, no eBPF, no ptrace. gai reads the
same configuration the OS reads (nsswitch.conf, resolv.conf, gai.conf,
/etc/hosts, systemd-resolved's D-Bus state, one-shot mDNS) and models the
NSS decision path glibc's resolver would walk for that configuration — not
every possible resolver a process might actually be running (see
"Resolver runtime detection" below for the one case this flags explicitly).
$ gai doctor testhost.local
[gai] Simulating name resolution for "testhost.local"...
(reality check via 212.227.123.16, 212.227.123.17, systemd-resolved stub: true)
RESOLUTION PATH (simulated):
1. Files FOUND 10.0.0.1
DIAGNOSIS:
┌─ ISSUE ──────────────────────────────────────────────────────────────────┐
│ The OS chain and a direct DNS query disagree: 10.0.0.1 vs (none). │
│ Something earlier in the chain (files/mdns) is answering instead of DNS. │
└──────────────────────────────────────────────────────────────────────────┘
testhost.local was added to /etc/hosts. The chain halted there — DNS was
never asked. A direct, independent DNS query (against systemd-resolved's
real per-link nameservers, not the 127.0.0.53 stub) confirms it: nobody
out there has heard of this name. gai shows both sides instead of leaving
you to guess which one lied.
In a real terminal the ISSUE/NOTE/OK panel above is color-accented
(red/yellow/green) by severity, and each resolution step gets its own
FOUND/NOT FOUND/SKIPPED tag. Piped output or NO_COLOR=1 falls back to the
plain boxed text shown here.
gai explain <name> # walk the resolution path, no verdict
gai doctor <name> [--binary <path>] # walk the path + diagnose discrepancies
gai why <name> [--binary <path>] # alias for doctor
--binary is optional: point it at the binary that will actually be doing
the resolving, and gai flags it if it's a statically linked Go binary
(which bypasses NSS entirely — see "Resolver runtime detection" below).
curl -fsSL https://raw.githubusercontent.com/casablanque-code/gai/main/install.sh | sudo bash
Pulls the latest x86_64-unknown-linux-musl binary from
Releases. Pin a version with
... | sudo bash -s -- v0.5.0.
install.sh needs root because it writes into a system directory — it does
not take a custom output path or filename:
- Installs to
/usr/local/bin/gai(fixed location, not configurable). - Sets mode
0755(rwxr-xr-x) viainstall -m 0755, same as any other system binary — nochmodneeded afterward. - Prints the install path and runs
gai --versionat the end so you can confirm it landed correctly.
To install somewhere else, download the release tarball yourself and move
the extracted gai binary wherever you like — install.sh is a convenience
wrapper for the common case, not a general-purpose installer.
cargo install gai-inspector # installs a binary named `gai`
cargo build --release --package gai-inspector
/etc/nsswitch.conf: parses thehosts:line and walks it exactly like glibc's NSS dispatcher — including[STATUS=action]criteria such as[NOTFOUND=return], the rule that silently stops a name from ever reaching DNS./etc/resolv.conf: detects the systemd-resolved stub (127.0.0.53) instead of reporting it as "the DNS server" — because it isn't one.- systemd-resolved (D-Bus): when the stub is in play, queries
org.freedesktop.resolve1.Manager'sDNSproperty for the real per-link nameservers hiding behind it. - mDNS: a genuine one-shot A/AAAA-record query on
224.0.0.251:5353(5353/ff02::fbfor IPv6) for themdns4_minimal/mdns4/mdns6_minimal/mdns6NSS sources — but only for names ending in.local, matching real nss-mdns: any other name is reportedSkippedwithout ever touching the network, so an ordinary internet domain can't get misclassified as tripping the[NOTFOUND=return]trap above. - Reality check: an independent DNS query — with
/etc/hostslookup explicitly disabled on the resolver — so it can never spuriously agree with a Files-based result just because both happened to read the same file. - Resolver runtime detection (best-effort, opt-in via
--binary <path>): flags statically linked Go binaries, which ship their own pure-Go resolver and bypass NSS entirely — simulatingnsswitch.conffor such a binary would be simulating the wrong thing. Example:gai doctor example.com --binary /usr/local/bin/my-go-app.
| Crate | Published as | What it is |
|---|---|---|
gai-core |
gai-core |
Pure parsing + simulation logic, no I/O, unit-tested against fixtures |
gai-probe |
gai-probe |
All real I/O: DNS queries, /etc/hosts reads, systemd-resolved D-Bus, mDNS |
gai (package gai-inspector) |
gai-inspector |
The CLI |
See CHANGELOG.md for what's shipped. scripts/release.sh <version> bumps the workspace version, regenerates Cargo.lock, commits,
tags, and pushes in one step.
Linux only. macOS (scutil/mDNSResponder) and Windows (DNS Client/LLMNR/
NetBIOS) are architecturally different enough that they're deliberately
out of scope for now rather than bolted on.
Per-link/split-DNS domain routing (systemd-resolved routing a specific
search domain to a specific link's nameserver — e.g. a VPN's private
zone) is also not yet implemented: gai queries global-scope (ifindex 0) nameservers only. A name that only resolves via a link-scoped
resolver correctly reports NOT FOUND rather than a false positive, but
won't actually resolve — this is a real gap, not a silent lie, and it's
the next thing on deck.
MIT — see LICENSE.