Skip to content

How Network Doctor Works

heymaikol edited this page Aug 18, 2026 · 2 revisions

How Network Doctor Works

The design has one governing idea: an unrelated failure must never hide a working path. Almost everything else follows from it.

A naive troubleshooter is a checklist run top to bottom, stopping at the first error. That produces the classic useless answer, "DNS failed", when DNS is broken and your Wi-Fi is fine and the target is perfectly reachable by IP. You learn one fact and lose three.

Network Doctor instead runs a dependency graph with independent branches. Only a genuine prerequisite blocks a probe. Everything else runs anyway.

The probe graph

graph LR
  IF["Interface"]

  IF --> INET["Internet<br/>(TCP egress)"]
  IF --> QUIC["QUIC / UDP 443"]
  IF --> PROXY["Internet<br/>(env proxy)"]
  IF --> EDNS["DNS<br/>(encrypted DoH/DoT)"]
  IF --> PUB["DNS<br/>(public resolver)"]
  IF --> WIFI["Wi-Fi network"]
  IF --> DNS["DNS"]

  DNS --> TCP["TCP (target)"]
  DNS --> HTTP["HTTP :80"]
  TCP --> PMTU["Path MTU"]
  TCP --> TLS["TLS"]
  TLS --> HTTPS["HTTPS"]
  TCP --> BANNER["SSH / SMTP banner"]
Loading

Read the fan-out from Interface as the important part. Six of those branches are deliberately independent of DNS, which is what keeps "DNS is down but the internet is up" a diagnosable state rather than a dead end.

Which rows actually appear depends on the target. With no target you get the generic branches only. With a target, the protocol rows are chosen by the port and scheme: 443/8443 gets TLS + HTTPS + a plain HTTP row, 80 gets HTTP, 22 gets an SSH banner, 25/587 gets an SMTP banner.

The exhaustive probe table, with what each row passes on and its caveats, is in the README's How it diagnoses section, which is kept beside the code.

Why each branch is separate

Each of these splits exists because two things that look alike are actually different faults with different fixes.

Direct egress vs. proxy egress. The native probes deliberately bypass any configured proxy. So a corporate network where nothing works except through HTTPS_PROXY reports "online via proxy" rather than "offline": direct egress fails, the proxy row passes, and the summary says so.

System DNS vs. public DNS. The second-opinion row queries a fixed public resolver directly. If it disagrees with your system resolver, that is evidence of split-horizon DNS or filtering, reported as a Warn, never a Fail, because disagreement is not by itself an error. If the public resolver cannot be reached at all, the row is N/A rather than a failure.

Plaintext DNS vs. encrypted DNS. Encrypted DNS hangs off the interface, not off the plaintext DNS row, because they are separate network capabilities. A network can happily carry port-53 DNS while blocking DoH and DoT, which is exactly the shape of "my terminal resolves fine but my browser cannot." The row never falls back to port 53; falling back would answer a different question.

TCP vs. QUIC. TCP/443 working says nothing about UDP/443. The QUIC row completes a real, certificate-validated HTTP/3 handshake, so a successful local UDP send cannot masquerade as reachability. When TCP works and QUIC does not, the verdict is degraded, not broken: browsers fall back to TCP, and the symptom is usually slowness rather than an outage.

Path MTU hangs off the TCP connect, not off any protocol row. An MTU black hole breaks SSH and SMTP exactly as thoroughly as it breaks TLS, so attaching it to TLS would have made it invisible on the other three-quarters of targets.

Five states, and why Warn is not a failure

State Meaning Counts as failure?
PASS Worked. No
WARN Worked, but impaired. No
FAIL Did not work. Yes
SKIP A prerequisite failed, so it was never attempted. No
N/A Does not apply to this target. No

WARN earns its own state because "high latency", "IPv6 is down but IPv4 works", and "your resolvers disagree" are all real findings that should not turn a green run red. A monitoring script that treats every non-PASS as an outage would page someone at 3am because a public resolver was slow.

SKIP and N/A are also deliberately distinct. SKIP means we could not ask: DNS failed, so there was no address to connect to. N/A means the question does not apply: you gave it an IP literal, so there is no name to resolve. Collapsing them would lose the difference between a blocked path and an irrelevant one.

Two ideas that make the diagnosis trustworthy

No verdict depends on ICMP

Plenty of healthy hosts drop ping. A failed ping proves nothing, and a successful one proves less than a TCP connect. So round-trip time is measured from the TCP connect handshake instead, with no ICMP, no raw sockets, no root.

ping is still available as a drill-down tool, where it is evidence for a human to interpret rather than an input to the verdict.

network vs. service is decided by evidence

The most valuable thing the tool tells you is whose problem it is, and it refuses to guess. An unreachable target is blamed on the service only when direct egress independently succeeded, that is, only when there is proof the network works. With no working egress to compare against, the verdict is network, because accusing a host you never reached would be a guess.

See Understanding Your Diagnosis for what each verdict means in practice.

Path MTU without root

This is the probe most worth understanding, because it finds a fault the usual tools cannot see at all.

The symptom. TCP connects fine, then the connection dies the moment either side sends a real packet. This is the classic tunnel / VPN / PPPoE mystery: a path MTU smaller than your interface's, on a path that also filters the ICMP that would have told you so. ping and curl cannot distinguish it from a hung server, and confirming it normally means raw sockets and the DF flag.

The trick. The TCP handshake is the control. SYN and SYN-ACK are small enough to cross a narrowed link, so a completed connect already proves small packets get through. The probe then writes 24 KiB and asks the kernel how much of it the peer has acknowledged.

Acknowledgement is the only proof of forward progress an ordinary socket can offer, and it is a strict one: TCP fills segments from the front of the payload, so nothing can be acknowledged unless a full-size packet crossed.

Observation Result
Some of the payload acknowledged Pass: full-size packets cross
Whole payload written, none acknowledged before the deadline Warn: names the evidence and an MSS/MTU experiment
Peer hangs up first N/A: inconclusive, and it will not guess

Why a completed write is deliberately not the test. Linux treats the send buffer size as an accounting hint rather than a ceiling, so a socket reporting an 8 KiB buffer will still swallow a 24 KiB write whole without a byte reaching the wire, which is precisely what a black hole looks like from userspace. Linux and macOS therefore read the socket's outstanding send queue directly. Windows exposes no equivalent query and falls back to inferring delivery from the send buffer, and the row says so: on Windows this probe can miss a black hole, though the TLS/HTTP timeouts beside it still show up.

It never Fails, on purpose. A peer that accepts a connection and then stops reading stalls the write the same way, and a normal socket cannot discover the exact path MTU when ICMP is filtered. So the row reports what it saw: bytes written, bytes acknowledged, the TCP MSS when the OS exposes it, and the local interface MTU as context, never as a measured path MTU. Only when this write and a protocol exchange both time out does the overall verdict call it a probable network-path problem.

This is the only probe that sends bulk data: 24 KiB per pass, of inert self-labelling filler. Under --watch that is 24 KiB every five seconds.

What it contacts, and what it does not

Three fixed third-party endpoints are involved, and it is reasonable to want to know about them:

Endpoint Used by What is sent
Well-known anycast :443 addresses Internet (TCP egress) A TCP connect, IPv4 and IPv6 independently
connectivitycheck.gstatic.com (Google) Internet egress / captive portal, and QUIC One small plain-HTTP GET, no body; and a QUIC handshake to UDP/443 with no application data
cloudflare-dns.com at 1.1.1.1 / 2606:4700:4700::1111 (Cloudflare) DNS (encrypted DoH/DoT) One A query per transport, per pass

Notes that matter:

  • The public-resolver row uses 8.8.8.8 by default. --public-dns changes it, and --public-dns "" removes the row entirely, so no query is sent, and the row is absent from both the TUI and the JSON. That is the switch for a strict egress policy or a privacy requirement.
  • --public-dns governs only the DNS second opinion. It is never reinterpreted as a DoH/DoT provider, and it does not change the egress or captive-portal endpoints.
  • Every probe is bounded by a per-probe timeout (--timeout overrides it; netdoc --help prints the default).
  • All of these ignore configured proxies, deliberately, because an environment proxy carrying the encrypted-DNS request would prove nothing about encrypted DNS.

Where next

Clone this wiki locally