A terminal UI that diagnoses your network connectivity and tells you where the connection breaks in plain English — not just a wall of tool output.
Runs on Linux, macOS, and Windows. The project is network-doctor; the
installed binary is netdoc.
The network-doctor
package builds from source:
yay -S network-doctor # or: paru -S network-doctorOr by hand, without an AUR helper:
git clone https://aur.archlinux.org/network-doctor.git
cd network-doctor
makepkg -siInstalling with Homebrew avoids Gatekeeper's "unverified developer" prompt:
brew tap heymaikol/tap
brew install --cask network-doctorDownload a prebuilt binary from the latest release, or install with Go 1.26+:
go install github.com/heymaikol/network-doctor@latest(go install names the binary network-doctor after the module; rename it to
netdoc if you like.) Check what you're running with netdoc --version.
Or build from a clone:
git clone https://github.com/heymaikol/network-doctor
cd network-doctor
go build -o netdoc .Probes form a dependency graph with independent branches, so an unrelated failure never hides a working one:
- Direct-egress path (independent of DNS):
Interface → Internet (TCP egress). Always runs, so "DNS is down but the internet is up" is diagnosable. - Proxy-egress path (independent of both):
Interface → Internet (env proxy). The native probes deliberately bypass proxies, so this row reports the environment-configured proxy separately — a proxy-only corporate network reads as "online via proxy" instead of offline. - Plain HTTP path:
Interface → DNS → HTTP :80. - Selected target path:
Interface → DNS → TCP → TLS → HTTPSfor secure web targets, or the applicable protocol row for other ports.
Each row is one of five states: ✓ Pass, ! Warn (reachable but degraded — high latency, some addresses failing, ambiguous source interface), ✗ Fail, ⊘ Skip (a prerequisite failed), or – N/A (doesn't apply — e.g. DNS on an IP literal). A Warn never counts as a failure.
| Probe | Passes when | Notes |
|---|---|---|
| Interface | A non-loopback interface is up and running | |
| Internet (TCP egress) | A TCP connect to well-known anycast :443 endpoints succeeds |
IPv4 and IPv6 probed independently in parallel; either family passes, both are reported |
| Internet (env proxy) | The HTTPS_PROXY/HTTP_PROXY proxy grants a CONNECT tunnel |
N/A when no proxy is configured; honors NO_PROXY |
| DNS | The host resolves to an IPv4 or IPv6 address (system resolution) | IP-literal targets are N/A; all A/AAAA records are retained |
| TCP | A TCP connect to the target port succeeds | races A/AAAA records Happy-Eyeballs style (RFC 8305), pins the winner |
| TLS | The TLS handshake (SNI + cert verification) succeeds | bad/expired cert, clock skew, or MITM → Fail |
| HTTP | Port 80 returns any HTTP response (incl. 3xx/4xx/5xx) | Independent HEAD after DNS, redirects off, proxy off |
| HTTPS | The selected TLS port returns any HTTP response | HEAD against the TLS-validated IP, redirects off, proxy off |
| SSH/SMTP banner | TCP connects (banner read best-effort) | bounded read; connected but silent → Warn (not a failure) |
RTT is measured from the TCP-connect handshake (no ICMP, no root). The source IP
and interface are read from the winning connection's LocalAddr, with a
UDP-connect fallback (sends no packets) for path identity on failure. Every probe
is bounded by a 4-second timeout.
netdoc # generic local + internet diagnosis
netdoc github.com # diagnose the path to a host (→ HTTP + TLS + HTTPS)
netdoc github.com:22 # port selects the protocol rows (→ SSH banner)
netdoc https://host:80 # explicit scheme selects the protocol (→ TLS + HTTPS on :80)
netdoc --json host # headless: one JSON report on stdout (scripts, CI, bug reports)--timeout overrides the per-check probe timeout; see netdoc --help
for the default.
The target parser has two independent axes: the port (explicit :port >
scheme default > 443) and the protocol rows (an explicit http/https
scheme wins; otherwise inferred from the port — 443/8443→HTTP+TLS+HTTPS, 80→HTTP,
22→SSH, 25/587→SMTP). Hosts are validated against a strict allowlist; IPv6
literals are accepted bare (::1) or bracketed with a port ([::1]:443).
| Key | Action |
|---|---|
↑/↓ (k/j) |
select a probe row, or a device in the network map |
v |
run a LAN scan and show a network map of the local private /24 (unprivileged nmap) |
enter |
set the selected map device as the new target, or open the current tool job's output |
y (viewer) |
copy the viewer's retained full output (up to 5,000 lines) |
r |
restart — opens a prompt to edit the netdoc arguments (enter runs, esc backs out) |
y / w |
yank / write (copy / save) a report of the chain plus the completed tool output |
q |
quit |
Each row in the diagnosis is evidence; when you want proof, run a real tool as a cancellable streaming job (one at a time). The contextual toolbox shows the tools available for the current target with their hotkeys — missing binaries are greyed out with an install hint. Output is bounded and sanitized (no terminal-escape injection from a hostile server); the last 15 output lines are included in reports after a tool finishes.
The same hotkeys map to each OS's built-in tools:
| Key | Linux | macOS | Windows |
|---|---|---|---|
i |
ip route |
netstat -rn |
route print -4 |
s |
ss -tunp |
netstat -an -p tcp |
netstat -ano |
p |
ping -c 4 -W 2 |
ping -c 4 |
ping -n 4 -w 2000 |
d |
dig +time=2 +tries=1 |
dig +time=2 +tries=1 |
nslookup |
c |
curl … -w '…' (concise summary) |
same | curl.exe (bypasses the PowerShell 5.1 curl alias) |
c (SSH target) |
ssh -v -o BatchMode=yes … (bounded banner/handshake check) |
same | same |
c (SMTP target) |
openssl s_client -starttls smtp |
same | same |
t |
traceroute -w 2 -q 1 -m 20 |
same | tracert -w 2000 -h 20 |
m |
mtr --report --report-cycles 5 |
same (via brew) | pathping -h 20 -q 5 -p 100 -w 500 (own 90 s budget) |
n |
nmap -sT -T2 -Pn (the explicit target port, else top 100) |
same | same |
n is gated behind an explicit confirmation before its active probe runs. It
uses a plain connect scan with polite timing and no version/OS detection. v
runs host discovery immediately, without raw sockets or root, and caps the scope
at the source address's /24.
The c slot is protocol-aware: HTTP(S) and unknown-port targets get curl,
while SSH (port 22) and SMTP (ports 25/587) targets get a protocol-appropriate
handshake probe — never an HTTPS-oriented curl line. The SSH check uses a
throwaway known-hosts file (no prompts, no writes) and runs a bare exit if a
key happens to authenticate.
The routes/sockets tools are target-independent; the rest need a host. Tools are run with an argument slice (never a shell string), in their own process group on Unix (cancel kills descendants too), and without privilege escalation. The displayed command is copy-pasteable in a POSIX shell (Linux/macOS) or PowerShell (Windows; cmd.exe paste is not supported).
--toolbox [<host>] opens straight into the toolbox without auto-running the
chain (press r to run it). With no host, only the target-independent tools are
offered.
--json runs the same probe DAG headless — no TUI — and prints one JSON
document to stdout:
{
"version": "1.2.3",
"target": {"host": "github.com", "port": 443, "protocol": "tls+http"},
"checks": [
{"id": "dns", "name": "DNS github.com", "status": "PASS", "detail": "github.com → 140.82.113.3", "addrs": ["140.82.113.3"]}
],
"summary": "All checks passed — github.com:443 looks healthy.",
"ok": true
}status is one of PASS, WARN, FAIL, SKIP, N/A. target is null
in generic (no-target) mode. Optional per-check fields (fix, addrs,
selected_ip, source, iface, network, attempts) are omitted when
empty. Field names and the status vocabulary are stable — safe to script
against. Exit codes follow the table below (ok: false ⇒ exit 1).
| Situation | Exit |
|---|---|
| Chain completed, no failed row (Skips allowed) | 0 |
| Any failed row | 1 |
| Quit before the chain finished | 1 |
| Bad arguments / validation reject | 2 |
netdoc github.com || echo "path to github is broken"All probes, the diagnosis engine, and the TUI are pure Go and identical on
Linux, macOS, and Windows. The platform-specific garnish (default gateway,
Wi-Fi SSID) uses the kernel directly on Linux (/proc/net/route, wireless
ioctl) and the OS's built-in commands elsewhere (route/networksetup on
macOS, route print/netsh wlan on Windows); when those fail the fields
degrade to empty rather than failing a probe.
Windows localization caveat: console tools emit the OEM code page, so
non-ASCII localized text in raw tool output shows as visible ? replacement
characters. Everything load-bearing (route table cells, the untranslated SSID
label, nslookup addresses) is parsed locale-independently.
Implemented: native DAG probes + diagnosis engine + two-pane UI, cancellable
streaming tool jobs (ping/dig/curl/traceroute/mtr/ss/ip/nmap) +
a scrollable output viewer + --toolbox mode, the Warn state, proxy-aware
diagnosis, --json output, and report copy/save.
Still to come: mtr-parsed route quality and multiple concurrent jobs.
Bubble Tea, Bubbles, and Lip Gloss.
go test ./... # unit + DAG scheduler + parser + diagnosis
go test -race ./... # concurrency
go test -fuzz=FuzzSanitize -fuzztime=10s # terminal-escape sanitizerThe code is split by responsibility:
main.goowns CLI arguments, process I/O, and application startup.internal/diagnosticowns target parsing, native probes, per-OS route/SSID lookups, and verdict logic without depending on terminal presentation.internal/uiowns Bubble Tea state, rendering, and tool jobs.internal/textsafesanitizes untrusted remote and subprocess text shared by both layers.
The UI depends on diagnostics; diagnostics do not depend on the UI. Add network
semantics under internal/diagnostic and interaction or rendering behavior under
internal/ui.
