Network Doctor

Network Doctor reference

This file is the authoritative reference for netdoc’s probes, flags, and output contracts, the detail that changes in the same commit as the code beside it. README.md is the landing page; the wiki’s How Network Doctor Works and Understanding Your Diagnosis explain why these probes are built this way and what a result means for you.

How it diagnoses

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 down but internet up” stays diagnosable.
  • QUIC path: Interface → QUIC / UDP 443. It resolves the fixed connectivity endpoint itself and completes a real QUIC handshake, so a successful local UDP send cannot masquerade as reachability.
  • Proxy-egress path (independent of both): Interface → Internet (env proxy). Native probes deliberately bypass proxies, so this row reports the environment-configured proxy separately: a proxy-only corporate network reads “online via proxy” rather than offline.
  • Public-DNS path (independent of system DNS): Interface → DNS (public). Failing to reach the third-party resolver is N/A; answers for explicitly absolute inputs can warn about split DNS or filtering but never fail the run on their own. --public-dns picks a different resolver, and --public-dns "" removes the row.
  • Encrypted-DNS path (independent of both plaintext DNS rows): Interface → DNS (encrypted DoH/DoT). Plain DNS and encrypted DNS are separate network capabilities, so this row hangs off the interface rather than under the DNS row: a network can carry ordinary DNS while blocking DoH and DoT.
  • Wi-Fi metadata path: Interface → Wi-Fi network. SSID discovery runs beside network checks, so slow OS lookup never delays them.
  • Plain HTTP path: Interface → DNS → HTTP :80.
  • Selected target path: Interface → DNS → TCP → TLS → HTTPS for secure web targets, or applicable protocol row for other ports.
  • Path-MTU branch (hangs off connect, not off any protocol): TCP → Path MTU. Black hole breaks SSH and SMTP exactly as thoroughly as TLS.

Public DNS never applies the machine’s DNS search suffixes. An explicitly absolute hostname, including printer., is queried as written. A relative multi-label hostname such as server.internal is queried only as server.internal.. An unqualified relative name such as printer gets N/A without contacting any public resolver. IDNs use the existing ASCII canonicalization before these rules apply; IP literals still need no DNS. The no-target check queries connectivitycheck.gstatic.com. publicly. System resolution keeps its normal search and hosts-file behavior, and local overrides are never credited as public DNS answers.

Only explicitly absolute inputs support comparisons with system resolution. Rooting a relative public query does not prove which name the system resolved, so its addresses and negative answers remain standalone observations. This also limits resolver counterfactuals and untested endpoint alternatives. Even matching name scope does not prove identical wire questions or which system resolver or local source supplied an address. A private suffix supplied in the input is still sent as part of that exact name; use --public-dns "" to disable the second opinion when that is unwanted.

Each row lands in one of five states: ✓ Pass, ! Warn (reachable but degraded: high latency, some addresses failing, an ambiguous source interface), ✗ Fail, ⊘ Skip (a prerequisite failed), or – N/A (doesn’t apply, e.g. DNS on an IP literal). 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 a fixed pair of well-known anycast :443 reference endpoints succeeds IPv4 and IPv6 probed independently in parallel; either family passes, both are reported. This row samples those reference destinations rather than the internet: a failure is evidence about the paths to them, and the diagnosis only widens it where nothing else in the run reached a public destination directly
QUIC / UDP 443 A certificate-validated QUIC handshake negotiates HTTP/3 with the fixed connectivity endpoint A timeout says only that this endpoint did not complete the UDP/443 exchange; browsers and applications normally fall back to TCP
Internet (env proxy) The HTTPS proxy from the environment, HTTPS_PROXY or else ALL_PROXY, grants a tunnel HTTP CONNECT; socks5 resolves locally and sends an address, while socks5h sends the hostname for proxy-side DNS; HTTP_PROXY alone is N/A because this probe follows Go HTTPS proxy selection and does not fall back to the plain-HTTP proxy setting; N/A also when no HTTPS proxy is configured or NO_PROXY exempts the probe host
DNS The host resolves to an IPv4 or IPv6 address (system resolution) IP-literal targets are N/A; all A/AAAA records are retained
DNS (public) A direct query to Google Public DNS provides a second opinion Queries 8.8.8.8, and 2001:4860:4860::8888 when the first cannot be reached, so an IPv6-only host still gets a second opinion; the row names the resolver that answered. N/A when outbound DNS is unavailable, and when this machine answers the name without DNS at all, since a hosts-file entry is a local override rather than a second opinion; disagreement is Warn, not Fail, and the warning names the comparison prefix (the /16 for IPv4, /32 for IPv6, the same masking answersAgree uses, and no claim about allocation or ownership) each side’s answers masked to, so a reader can see which prefixes the comparison actually found disjoint; --public-dns pins one resolver or removes the row
DNS (encrypted DoH/DoT) A correlated DNS response arrives over DoH or over DoT NOERROR or NXDOMAIN passes; a standard-query resolver error warns without claiming the transport is blocked; it never falls back to port 53
TCP A TCP connect to the target port succeeds races A/AAAA records Happy-Eyeballs style (RFC 8305), pins the winner
Path MTU the peer acknowledges some of a 24 KiB write finds evidence consistent with an MTU/PMTU black hole, never a Fail, see below
TLS The TLS handshake (SNI + cert verification) succeeds certificate time, hostname, issuer, protocol, timeout, early-close, and TCP failures receive stable JSON causes
HTTP Port 80 returns any HTTP response (incl. 3xx/4xx/5xx) Independent HEAD after DNS, redirects off, proxy off; a close or reset before any response fails with cause connection_closed or connection_reset, and response bytes that cannot be read as HTTP fail with cause invalid_response
HTTPS The selected TLS port returns any HTTP response HEAD against the TLS-validated IP, redirects off, proxy off; a close or reset before any response fails with cause connection_closed or connection_reset, and response bytes that cannot be read as HTTP fail with cause invalid_response
SSH/SMTP banner TCP connects (banner read best-effort) bounded read; connected but silent → Warn (not a failure); an SMTP target on port 465 is implicit TLS (RFC 8314), so it adds the TLS row and reads the greeting over a verified TLS connection

Path MTU without root

A path MTU smaller than the local interface’s, on a path that also filters the ICMP that would say so, breaks TCP the moment either side sends a real packet, the classic tunnel/VPN/PPPoE mystery that ping and curl can’t diagnose, and confirming it normally means raw sockets and the DF flag.

The Path MTU row finds it from an ordinary socket instead. The TCP handshake is the control: SYN/SYN-ACK are small enough to cross a narrowed link, so a completed connect already proves small packets arrive. The probe then writes 24 KiB and asks the kernel how much of it the peer has acknowledged, the only proof of forward progress an ordinary socket can offer, and a strict one, since TCP fills segments from the front of the payload.

  • some of the payload is acknowledged → full-size packets cross, Pass, with the connection’s TCP MSS when the OS exposes it,
  • the whole payload is written and none of it is acknowledged before the deadline → Warn, naming the evidence and an MSS/MTU experiment,
  • the peer hangs up first → N/A: inconclusive, and it will not guess,
  • the platform cannot read the send queue and the write was accepted → N/A: the local kernel took the bytes, which is not evidence they crossed.

A completed write is deliberately not the test: Linux treats the send-buffer size as an accounting hint rather than a ceiling, so an 8 KiB send buffer can swallow a 24 KiB write whole without a byte reaching the wire. Linux and macOS read the socket’s outstanding send queue directly instead. Windows exposes no equivalent query, so it cannot tell a delivered payload from a buffered one and never reports Pass: an accepted write is N/A, naming what it could not verify. A write that stalls against the small send buffer is still a Warn there, and the TLS/HTTP timeouts beside it still show up.

It deliberately never Fails: a peer that accepts the connection and then stops accepting data stalls the write the same way, and a normal socket cannot discover the exact path MTU when ICMP feedback is filtered. The row reports bytes written and acknowledged, the TCP MSS when available, and the local interface MTU as context, never as a measured path MTU. Only when both this write and a protocol exchange time out does the overall verdict identify a probable network-path problem; certificate and other immediate protocol failures remain service failures.

The 24 KiB payload is self-labelling filler, not guaranteed harmless to an application; TLS targets get a record header in front so the TLS server reads it instead of resetting on the first byte. Unknown-protocol targets omit this probe by default; --check path_mtu explicitly permits the payload and narrows the run to PMTU and its prerequisites, following the usual --check semantics. Known-protocol defaults retain it. A profile’s inherited check list is not an explicit PMTU request. This is the only probe that sends bulk data: when selected, under --watch that is 24 KiB once every 5 seconds.

Three other fixed third-party endpoints are involved, all bounded by the normal per-probe timeout and ignoring configured proxies. The Internet (TCP egress) row’s captive-portal check sends one small plain-HTTP GET with no body to each of two independently operated connectivity endpoints on every pass. They run concurrently with each other and with the row’s TCP dials, share that one probe deadline, and are never retried. connectivitycheck.gstatic.com/generate_204 answers 204 with no content on a clean path; www.msftconnecttest.com/connecttest.txt answers 200 carrying the payload Microsoft Connect Test, and only that many bytes are read, so an arbitrary 200 is not accepted as a clean answer. Neither follows redirects: an interception normally announces itself as the 302 that would otherwise be chased to a sign-in page. One endpoint answering something other than what it documents is not enough for the captive-portal diagnosis. A block aimed at that one name, that provider’s own outage, a hijacked DNS answer for it, and a portal all look identical from a single observation, so a lone discrepancy degrades the row to a Warn that names what was seen and stops there. Both endpoints intercepted on the same pass is corroborated HTTP interception: that is what fails the row, carries the portal object, and supports the captive_portal finding. An endpoint that could not be reached at all is the absence of an observation rather than evidence of trouble: it never corroborates the other endpoint, and it never turns otherwise-working direct egress into a failure. QUIC / UDP 443 reuses the connectivitycheck.gstatic.com host for a certificate-validated QUIC/HTTP-3 handshake on UDP/443 with no application data, so a timeout there says only that this endpoint didn’t complete the exchange, not that a firewall was responsible. DNS (encrypted DoH/DoT) dials cloudflare-dns.com directly at 1.1.1.1/2606:4700:4700::1111, sending one A query for connectivitycheck.gstatic.com per pass over each transport: an RFC 8484 POST with DNS ID 0 to /dns-query, and an RFC 7858 length-framed query with a random DNS ID on port 853. It proves a correlated DNS exchange, not a particular answer: it checks the DNS header and echoed question (except where a valid FORMERR/NOTIMP response may omit it) but does not parse answer records or test the user’s configured resolver. NOERROR/NXDOMAIN pass even with no answer records; a standard-query error such as SERVFAIL warns rather than fails, since the resolver was reached but didn’t complete the query. Either transport succeeding is enough, both are tried concurrently, and it never falls back to plain DNS. These endpoints, the fixed anycast addresses the direct-egress row dials, the automatic second-opinion resolver, and the compiled-in host the proxy row tunnels to are exactly what --no-reference-egress removes.

No verdict depends on ICMP: a failed ping proves nothing and a successful one proves less than a TCP connect, so RTT is measured from the TCP-connect handshake instead, with no ICMP and no root. ping remains available as a drill-down tool. The source IP and interface are read from the winning connection’s LocalAddr, with a UDP-connect fallback (which sends no packets) for path identity on failure. Every probe is bounded by a 4-second timeout.

Sibling address retry

After a target connection wins a same-family address race, netdoc may retry one canceled sibling from the system resolver’s answers. It uses the same source binding and target port, with a one-second individual dial budget only when that full interval remains inside the target probe’s timeout. A success that lands before a sibling starts triggers no retry, and the total stays within the 16-attempt cap.

The canceled attempt remains non-failure evidence. A separate refusal or dial timeout can earn partial_endpoint_reachability alongside a successful address in that same family, while parent-probe cancellation never earns that finding. This is a bounded observation of the endpoint at that time, not proof of a permanent outage. Reports and snapshots retain both attempts with their existing cause and aborted fields.

Platform support

All probes, the diagnosis engine, and the TUI are pure Go and identical on Linux, macOS, and Windows. Platform-specific garnish (the default gateway, the Wi-Fi SSID) degrades to empty rather than failing the probe when the OS lookup fails. netdoc-sim and Challenge Mode are the exception: their backend is Linux namespaces and there is no other one, so macOS and Windows run the published image on a Linux container runtime rather than a port. netdoc itself needs no container anywhere.

Usage details

The command forms, in one place:

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 ssh://host:2222  # explicit scheme keeps SSH on a nonstandard port
netdoc --json host      # headless: one JSON report on stdout (scripts, CI, bug reports)
netdoc --save incident.ndoc host  # headless: save the finished run as a snapshot file
netdoc --support support.ndoc host  # headless: save a sanitized snapshot for sharing
netdoc --compare good.ndoc bad.ndoc  # headless: report what changed between two snapshots
netdoc --watch host     # TUI: re-run continuously and track intermittent failures
netdoc --json --watch host  # headless: one JSON report per line, until interrupted
netdoc --profile list   # describe the built-in service profiles
netdoc --profile github # GitHub web, API, and both SSH paths
netdoc --profile ssh server.example.com  # SSH path, route, MTU, and banner evidence
netdoc --list-checks        # list the stable IDs accepted by --check and --skip
netdoc --check dns,target_tcp,tls example.com  # run only these IDs and their prerequisites
netdoc --skip internet_tcp,quic_udp_443 example.com  # omit these probe branches
netdoc --no-reference-egress host  # reach only the target and this machine's own network config
netdoc --via server host  # run the checks on an SSH host and show the result here
netdoc --two-sided --via server host  # run here and there, then localize the difference
netdoc --two-sided here.ndoc there.ndoc  # two saved runs: which machine a failure belongs to
netdoc --iface wg0 host # bind probe traffic to wg0's source address
netdoc --public-dns 9.9.9.9 host  # take the second opinion from Quad9 instead
netdoc --no-history host          # don't read or save the target history file
netdoc --peer-listen 192.168.1.20:4242  # wait for one directly reachable peer
netdoc --peer-connect             # paste its temporary pairing string when prompted

--timeout overrides the per-check probe timeout; see netdoc --help for the default. It takes a positive Go duration that is a whole number of milliseconds, because milliseconds are the precision the snapshot option and the remote request can carry, and a setting accepted here has to mean the same thing on the machine that probes and in the artifact that records it. A finer value such as 500us or 1.9ms is a usage error (exit 2) reported before any probe or SSH connection starts, rather than a budget quietly rounded to something else. It stays a per-check budget under --via, where the acquisition additionally carries an overall bound derived from it; see Time limits. --watch starts another pass five seconds after each run. In the TUI and in --watch without --profile, a passing check can be answered from its measurement on an earlier pass for up to a minute, so a stable pass runs only the checks it cannot reuse. On Linux, a route, address, link, policy-rule, or nexthop change reported by netlink refuses every reused check, so the next pass measures them again; other platforms rely on the minute alone. If a pass that reused a check finds a status or cause changed, or finds that the routes measured by the checks that always run have changed, that pass is discarded and the next one, which measures every check, is shown instead. After a pass that is not OK, every check runs until the network is OK again, and at least one pass a minute measures every check. With --profile, every pass runs every check. The TUI labels a reused check with the time it was measured, and builds incident timelines only from passes that measured every check; a healthy pass that a route change landed during is left out as well, while a failing one is kept. In the TUI it shows the last 20 states plus a failure count for every check, and with --json it streams the same report on stdout, one compact JSON object per line, until the process is interrupted. Those lines carry an extra ts field (RFC 3339, UTC) and are otherwise the one-shot report unchanged, except that a reused check reports ms as 0 and carries no marker yet; one-shot output stays pretty-printed, with no ts.

The TUI also reconstructs bounded incidents. A transition from a working pass to a failing pass opens one incident. Further failing passes increase its pass count and retain only semantic state changes, rather than opening another incident each time. The first later working or degraded pass closes it as a recovery. The existing per-check strip retains the last 20 outcomes. Incident reconstruction separately retains at most 10 incidents and at most eight state changes inside one incident. Press i to inspect the latest incident, left/right to select another retained incident, and w there to save the selected incident as .ndoc. A target restart clears the incident timeline because the new endpoint is a new Watch session. Retest (R) does not: it asks the same question again against the same target and the same run configuration, so the per-check strip and the incident timeline carry on across it rather than starting over.

The onset and recovery views use the same canonical snapshot conversion and semantic comparison as --save and --compare. A path change observed in the same pass as a failure is reported as a temporal coincidence. It is not called the cause unless the saved diagnosis carries independent causal evidence for that conclusion. Cancelling Watch during a failure leaves the incident active; it never invents a recovery that was not observed.

--check accepts comma-separated stable probe IDs and limits the run to those probes plus the prerequisite closure from the existing DAG. --skip removes IDs and any dependent probes whose prerequisites are then unavailable. Both flags are repeatable and combine by union; their argument order never changes row order. Unknown or empty IDs are rejected with exit 2, before diagnostics start, and the error lists every valid stable ID. A known ID that does not apply to the current target is harmless: --check may select no rows, while --skip changes nothing. Omitted probes do not run and do not appear in the TUI or JSON. The same selection policy is retained by --watch, TUI restarts, and target changes.

--list-checks prints every stable probe ID accepted by --check and --skip, followed by its human-readable base name, and exits 0 without running probes. The order is the same deterministic inventory order used to validate selections. The command cannot be combined with a target or another operational flag; such a combination exits 2 instead of silently ignoring the extra argument. As elsewhere, --help and --version retain their normal immediate behavior.

--no-reference-egress makes no connections to netdoc’s own built-in reference services. Everything netdoc contacts on its own account leaves the probe DAG for that run: the fixed anycast :443 endpoints behind the direct-egress row, both captive-portal observation points, the automatic second-opinion resolver, the fixed QUIC/HTTP-3 endpoint, the encrypted-DNS provider, the compiled-in host the proxy row asks a proxy to tunnel to, and the compiled-in hostname a run with no target would otherwise resolve just to have a DNS question to ask. Checks that depended on a removed row go with it, through the same closure --skip walks; the removed rows are absent from the TUI and from the JSON report rather than reported as skipped.

What still happens is everything with a destination someone other than netdoc chose. The target you named is resolved and connected to. An explicitly selected --profile still contacts the endpoints that define it, because choosing the profile is what named them; it just stops dragging the generic reference rows along behind each component. This machine’s own configuration is still used: the system resolver answers the target’s name, and the routing table still chooses the path. The proxy row is not the exception it looks like. An HTTP(S)_PROXY is this machine’s configuration, but the only destination netdoc ever asks a proxy to tunnel to is its own compiled-in connectivity host, so that row goes with the mode; no other check has ever routed through a proxy, with or without this flag. A --via destination is still reached, and the remote run applies the same policy. This is not an offline mode and not a LAN-only mode. The distinction it draws is provenance, not whether an address belongs to a third party: a public host you asked about is your traffic, and a public host netdoc picked for you is not.

Two options can name a prohibited destination explicitly, and the two answers differ because the provenance does. --public-dns 9.9.9.9 names the second-opinion resolver, which makes that row’s destination the user’s choice, so it survives the mode; without the flag the row is netdoc’s own automatic pick and does not. --check naming one of the removed probes is a contradiction rather than an override: netdoc exits 2, names both halves, and runs nothing. Flag order does not enter into it, and no later selector can put a removed row back. --skip composes normally, since it only ever removes more. Peer mode rejects the flag outright: it runs no probe DAG at all, only authenticated traffic between two endpoints you established yourself, so there is nothing there for the mode to remove. In a targetless run the mode leaves very little: with no endpoint to ask about and no reference service to substitute for one, what remains is the local interface and link state.

--via runs the checks on an SSH destination rather than on the local machine; see Remote diagnosis over SSH. It is headless, so it needs no terminal, and an ordinary remote run cannot be combined with --toolbox, --watch, --compare, or peer mode. With --two-sided, it instead acquires one local and one remote ordinary run for the same target; that form’s complete flag matrix is under Two-sided diagnosis.

--iface binds IPv4 and IPv6 probe connections and DNS lookups to the interface’s first usable address of the matching family. IPv4-only and IPv6-only interfaces use only their available family; pass an exact local IP to restrict probes to that address’s family.

The path drill-downs follow the same selection where the native tool supports it. On Linux and macOS, ping, traceroute, mtr, and curl use the selected interface name, or the selected address when --iface names an exact IP. Windows tools bind by source address instead: pathping and curl.exe use the resolved address, while ping and tracert can do so only for IPv6 destinations. curl.exe cannot bind by interface name, but can use that interface’s resolved source address.

Address-only binding follows the literal target’s family first, then the address selected by the target TCP check. With neither, a single-family selection is unambiguous and may be used; a dual-stack selection is left unbound rather than guessing. A missing matching source family is always left unbound.

Left deliberately unchanged and unbound: dig and nslookup, which query the system resolver (often a loopback stub) rather than the target; the nmap connect scan, whose -S is a spoofing option rather than documented connect-scan binding; and the SSH and SMTP handshake checks. Local-state tools (ip route, ss, netstat, route print) open no sockets. The LAN scan already follows the selection through the probe source address from which it derives its subnet.

--public-dns sets the resolver behind the second-opinion DNS row. Left alone, the row is automatic: it queries Google Public DNS on IPv4 (8.8.8.8) and falls back to the same operator’s IPv6 resolver (2001:4860:4860::8888) when the first cannot be reached at all, so a host with only IPv6 keeps the second opinion instead of losing the row to an address it cannot dial. A resolver that answered, refused, or said the name does not exist has answered the row, so only an unreachable one is retried, and the fallback attempt is bounded by a share of the same probe budget rather than an extra one. The row is labelled DNS (public) while the choice is open and names the resolver that answered once it has. Passing the flag pins one resolver instead: --public-dns 8.8.8.8 queries 8.8.8.8 and nothing else, including when it is unreachable, because an address the user typed is a choice rather than a preference. It takes an IP literal, IPv4 or IPv6, and nothing else; a hostname or any other word is rejected with exit 2, since resolving it would go through the very resolver the row exists to cross-check. An empty value (--public-dns "" or --public-dns=) removes the check: the row is absent from the TUI and from the JSON report, and no query is sent, which is what a strict egress policy or a privacy requirement needs. The value applies to --watch and --json runs and to target switches inside the TUI. Under --via the resolver address crosses the wire as typed, and whether it was typed at all crosses beside it, so the remote machine decides the automatic fallback for the address families it actually has rather than the caller’s. Note that this flag governs only the DNS second opinion; the direct-egress row still connects to fixed anycast :443 endpoints, the captive-portal check still reaches connectivitycheck.gstatic.com and www.msftconnecttest.com, and the encrypted-DNS row still reaches cloudflare-dns.com. --no-reference-egress is the flag that covers all of them at once; naming a resolver here keeps this one row alive under it, because then the resolver is your choice rather than netdoc’s. A plaintext resolver IP is not a DoH/DoT provider, so --public-dns is never reinterpreted as one.

The target parser has two independent axes: port (explicit :port > scheme default > 443) and protocol rows (an explicit http/https/ssh/smtp scheme wins; otherwise it is 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). An IPv6 link-local literal must name the interface it is reached through, as a zone: fe80::1%eth0, [fe80::1%eth0]:22, or ssh://[fe80::1%25eth0] (the RFC 6874 URL spelling; a plain % is accepted there too). Windows writes the zone as a number, such as %12. The bare link-local address is rejected, since without its zone it names no particular link. The zone only scopes the connection: it is not used for DNS, TLS server names, or HTTP Host headers, and support snapshots drop it.

The TUI’s default key bindings:

Key Action
space the Actions menu: everything the run can do right now, with shortcuts where available; ↑/↓ select, enter runs, esc closes
↑/↓ (k/j) select a probe row, or a device or service in the network map
a expand the checks a finished run collapsed, and collapse them again
e show why the selected diagnosis follows from the observed checks, and return to normal details
D open the selected check’s complete evidence; q or esc returns to the same check
i in Watch Mode, inspect recorded incidents; use left/right to choose one, and w to save it as .ndoc
v show the cached LAN map, or confirm and run discovery when no LAN scan exists
enter open the selected map device, then diagnose one of the services it answers on, or open the current tool job’s output
/ (viewer) filter the viewer to matching lines (enter commits, esc clears it, a second esc leaves)
home/end, pgup/pgdn (viewer) jump to top/bottom (end re-enables follow) or page through the output
y / w (viewer) copy / save the viewer’s retained output (up to 5,000 lines; respects its filter)
r restart with a new target
R retest: rerun the same checks on the same target, after acting on the remediation the answer block shows
S SSH login: a form for username, key, and password, then hands the terminal to ssh (hinted only once the SSH banner check passes, but usable against any target)
tab switch between running tool jobs
esc cancel the focused job only (tab picks which), or leave an opened device on the map; q is the stop-everything path
y / w yank / write (copy / save locally) a reviewable report of the chain plus every tool job
T pick a colour theme, previewed as you move; enter keeps it, esc restores the one you had
? full-screen key cheatsheet; any key closes it
q quit (cancels running jobs first, then exits)

--keys vim adds gg/G for first/last, ctrl+b/ctrl+f for page up/down, and ctrl+u/ctrl+d for half-page up/down; the existing keys continue to work.

The bottom bar is not a short copy of that table. It carries only what the region on screen needs: how to move in it, what the primary key does to the selected row, and how to leave it, followed by a fixed tail of space (Actions), ? (help) and q (quit). Every other binding above stays live and stays unadvertised, because the Actions menu lists what applies right now and ? lists everything. On a terminal too narrow for one row the bar wraps rather than truncating, so no chip is ever silently dropped, and under height pressure it is replaced by a shorter bar that keeps only the current region’s navigation rather than being cut.

The ? cheatsheet is sectioned by the same groups the Actions menu sorts into (Move, This run, This network, Drill down, Report, Session), then the output viewer’s own keys. The drill-down tools are listed under Drill down rather than mixed into the built-in actions, so the installed binaries are visibly a separate group from the keys the TUI itself owns.

space opens the Actions menu: the actions and drill-down tools that apply to the current state, labelled with their active-preset key when they have one. Up/down or j/k move, enter runs the selected row, esc closes, and any shortcut pressed while it is open runs exactly as it does outside it, the confirmation gate on the active scans included. Rescan network is deliberately menu-only and appears after a LAN scan finishes. The rows are generated from the same action and tool definitions used by dispatch, so menu selection keeps stable action identity even as rows appear or disappear.

The rows are grouped by what the action acts on, under headings in a fixed order: This run (the diagnosis and its evidence: full output, switch/cancel job, expand checks, explain why, incidents, retest), This network (the network map, open device, diagnose service, rescan network, SSH login), Drill down (the installed tools), Report (copy, save) and Session (restart, theme, help, quit). The order is the distance from the question the menu answers, “what do I do about this run?”, so the next troubleshooting step is always above the housekeeping. Within a group the rows keep the cheatsheet’s order, so state can move a row between groups but never inside one. The headings are drawn from the rows, so an empty group prints nothing, they are never selectable, and enter only ever runs an action. A group whose rows are scrolled out of view still labels the window it opens in; on a terminal too short for the labels to explain more rows than they cost, the menu drops them and spends every line on actions. It is discovery only: no probe, diagnosis, report, or exit code changes with it.

T opens the theme picker: up/down or j/k move through the built-in themes and apply the highlighted one at once, enter keeps it, and esc restores the theme that was active when the picker opened. It is presentation only, so no probe, diagnosis, status, report, snapshot, or exit code changes with it, and every status keeps the glyph and word it always had. The built-ins are terminal (the default, the 16 ANSI colours, which follow your terminal’s own palette), harbor, ember, contrast (high contrast), and monochrome (colour-free, with bold and faint emphasis). There is no --theme flag: the picker is the way to choose one. NO_COLOR=1 netdoc ... disables colour for a run; an empty NO_COLOR value does not.

The chosen theme persists as a single name in $XDG_CONFIG_HOME/netdoc/theme (normally ~/.config/netdoc/theme) on Linux, ~/Library/Application Support/netdoc/theme on macOS, or %AppData%\netdoc\theme on Windows. It is convenience state: a missing file, an unreadable one, and an unknown or obsolete name all mean the default theme, and a preference that cannot be written never stops a run. --no-history does not affect it, since that flag is about the targets you type. Exit netdoc and delete the file to go back to the default.

The TUI saves up to 50 recent targets between sessions in $XDG_CONFIG_HOME/netdoc/history (normally ~/.config/netdoc/history) on Linux, ~/Library/Application Support/netdoc/history on macOS, or %AppData%\netdoc\history on Windows. --no-history turns that off for one run: the file is neither read nor written, so the targets you type stay in that session only. It leaves an existing file untouched, so exit netdoc and delete it to clear what is already saved.

Exit codes

Situation Exit
Chain completed, no failed row (Skips allowed) 0
Peer tests completed with authenticated traffic passing both ways 0
Any failed row 1
Peer diagnostic failed, stayed incomplete, or the session errored 1
--two-sided, saved or live: no comparable check failed on either machine 0
--two-sided, saved or live: a failure was placed, or the evidence could not place one 1
--two-sided, saved or live: the two snapshots observed different targets 2
--two-sided: the two snapshots do not establish one target, because a support pseudonym is on either side and the rest of the target agrees 2
Live --two-sided --via: SSH or remote protocol acquisition failed 2
Live --two-sided --via: the local run errored 1
Live --two-sided --via: a user interrupt cancels both acquisitions 1
--two-sided --route-a or --route-b: an unreadable or invalid topology file, or one file named for both sides 2
--explain: an explanation was printed, whether or not the path is broken 0
--explain: a destination that is not an IP address, an unreadable or invalid topology file, a --fail that names a node or interface the file does not describe, a --fail-ospf that names an adjacency no control-plane OSPF neighbor record holds or that is recorded in several routing domains, or a flag that cannot be combined with it 2
--import-frr-ospf: every capture accepted and every neighbor record mapped; this is not a health result 0
--import-frr-ospf: a capture refused or a neighbor record left unmapped; with --write-topology, nothing is written 1
--import-tunnels: every modeled node has a usable ip -d -j link capture, and no candidate is refused or left unproven; with --write-topology, the base topology is written 0
--import-tunnels: a candidate refused or left unproven, such as a gre or ipip device or a WireGuard endpoint, or a modeled node with no usable capture; with --write-topology, nothing is written 1
--import-tunnels: an invalid manifest, an unreadable or unsafe base topology or capture, a flag that cannot be combined with it, or a topology that cannot be written 2
--import-frr-ospf: an invalid manifest, an unsafe or unreadable capture, a flag that cannot be combined with it, or a topology that cannot be written 2
--import-frr-bgp: every capture valid; this is not a health result, and an Active or Idle peer is reported as observed 0
--import-frr-bgp: an invalid manifest, a capture or transcript that cannot be used, a manifest that names another command, FRR version, VRF, or source_node, a flag that cannot be combined with it, a --withdraw-bgp selector that is malformed or that the node’s route table does not describe, a --fail-bgp-session selector that is malformed or that names a peer neither capture of the node names, or output that cannot be written 2
Quit before the chain finished 1
Bad arguments, pairing-input reject, validation reject, or no terminal for the TUI 2
--via: SSH failed, no usable netdoc on the SSH host, a remote protocol mismatch, or an acquisition that outlasted its overall bound 2

The per-situation table for --via is below, in the remote section.

Service profiles

--profile turns a service troubleshooting question into a finite plan of ordinary Network Doctor runs:

netdoc --profile github
netdoc --profile ssh server.example.com
netdoc --profile smtp mail.example.com
netdoc --profile web status.example.com
netdoc --profile list

Profiles are typed orchestration, not protocol implementations. Every component goes through ParseTarget, BuildProbesFromSources, ProbeSelection, the normal dependency closure and timeout runner, Interpret, the causal-evidence and counterfactual passes, remediation, JSON report conversion, and snapshot conversion. Profile code opens no socket and duplicates no DNS, TCP, TLS, HTTP, SSH, SMTP, routing, VPN, or path-MTU logic.

Built-in plans

The initial registry has four lowercase, version-1 profiles. Its order is stable and is also the component output order.

Profile Target Components
github Forbidden; the endpoints are canonical web: HTTPS at github.com:443; api: HTTPS at api.github.com:443; ssh: SSH banner at github.com:22; ssh-alt: SSH banner at ssh.github.com:443
ssh Required One requested SSH endpoint, using port 22 when omitted, with its DNS, route, TCP, path-MTU, and banner evidence
smtp Required SMTP banner at the requested port, or relay port 25 when omitted, read inside TLS when that port is 465; an independent SMTP banner on submission port 587. When 587 is primary, port 25 is the comparison path
web Required Certificate-validated HTTPS at the requested port, or 443 when omitted; an independent plain HTTP response on port 80

GitHub’s web and API components issue the existing bounded, redirect-free HTTP HEAD checks. The profile does not claim a Git repository operation or an authenticated API request. The SMTP profile does not claim STARTTLS support because that primitive does not exist in the ordinary engine; port 465 reuses the ordinary TLS check before reading the greeting. These limits keep the profile answers tied to evidence the current diagnostic layer can actually collect.

Each component’s minimum selection contains its service probe plus the existing direct-Internet, environment-proxy, public-DNS, and path-MTU context. The dependency closure supplies interface, target DNS, TCP, and TLS where needed. This context lets the component’s existing findings distinguish a local egress problem, proxy-only network, resolver problem, route or VPN path, target port failure, path-MTU evidence, TLS failure, and application-layer failure without profile-specific networking code.

Aggregate result

The component status comes from the service check and its ordinary report. A component is PASS when its service check and report are healthy, WARN when the path works with degraded evidence, FAIL when the component run fails, and SKIP when selection removed the service check or a prerequisite made it unavailable.

The aggregate is PASS only when every component passes. It is WARN when at least one component still works and another is unavailable, skipped, or degraded. It is FAIL when no component completes its service check. A working declared fallback, currently GitHub’s SSH-over-443 path, produces the stable <profile>_fallback_available finding; an unavailable fallback beside a working primary produces <profile>_fallback_unavailable; other partial and total failures use <profile>_partial_reachability and <profile>_unreachable. Findings carry stable affected and working component IDs. The detailed cause remains the ordinary report finding and causal evidence inside each component.

Every one of those conclusions is derived, never chosen. A netdoc.profile.v1 artifact carries each component’s complete ordinary run nested under the component that names it, so the envelope a script reads and the evidence it would read next are both in the file, and the format holds them to each other: a component status has to be the reading of its own nested run, and the aggregate status, the finding ID, and the affected and working component lists have to be what those component statuses add up to. An artifact where they disagree is refused when it is written and when it is read, because there is no third place for a later reader to look to find out which half was the run.

The rule is written once, in the package that owns the file format, and the producer calls the same function rather than a second copy of it. The aggregate summary is the exception: it is a sentence built from component labels, it is checked only for being present, and rewording it is not a format change.

Human output lists every component and endpoint, then the aggregate verdict. For affected components it shows the existing finding ID and the check rows that support it. It does not replace the evidence model with profile prose.

The JSON shape keeps profile state as data and embeds each unchanged ordinary report:

{
  "schema": "netdoc.profile-report.v1",
  "profile": "smtp",
  "profile_version": 1,
  "title": "SMTP",
  "description": "Tests SMTP relay and message-submission banner paths independently.",
  "components": [
    {
      "id": "smtp",
      "label": "SMTP service on port 25",
      "target": {"host": "mail.example.com", "port": 25, "protocol": "smtp"},
      "focus": "smtp_banner",
      "status": "FAIL",
      "report": {"version": "1.2.3", "target": {"host": "mail.example.com", "port": 25, "protocol": "smtp"}, "checks": [], "summary": "…", "verdict": "service", "ok": false}
    },
    {
      "id": "submission",
      "label": "Message submission on port 587",
      "target": {"host": "mail.example.com", "port": 587, "protocol": "smtp"},
      "focus": "smtp_banner",
      "status": "PASS",
      "report": {"version": "1.2.3", "target": {"host": "mail.example.com", "port": 587, "protocol": "smtp"}, "checks": [], "summary": "…", "verdict": "ok", "ok": true}
    }
  ],
  "aggregate": {
    "status": "WARN",
    "summary": "Some SMTP components are unavailable or degraded while others work.",
    "finding": {"id": "smtp_partial_reachability", "affected_components": ["smtp"], "working_components": ["submission"]}
  },
  "ok": true
}

Branch on schema, profile, profile_version, component id and status, and aggregate finding id. Component report contains the stable ordinary check, finding, causal-evidence, counterfactual, route, and remediation fields; derived sentences remain display text.

Flags and execution modes

  • --json emits one netdoc.profile-report.v1 object. Non-profile JSON stays the existing report.Report byte shape.
  • --save writes a netdoc.profile.v1 .ndoc containing the identity and aggregate plus one complete netdoc.snapshot.v1 per component.
  • --support applies support-v1 locally with one pseudonym mapping shared by all embedded snapshots. Combined --json output remains full fidelity, the same as an ordinary support run.
  • --watch is supported with --json and streams one compact profile object per pass with ts. A profile has no multi-pane TUI, so profile watch without --json is rejected. As with ordinary remote execution, --watch and --via cannot be combined.
  • --via sends one ordinary remote protocol version 1 request per component. Targets and selections travel as JSON data, never shell text. The remote binary does not need to know the profile name, so a version-1 remote worker that predates profiles can still run the component plans. Reports and snapshots are interpreted on the remote OS and aggregated locally.
  • Remote components may overlap, but only over a transport that cannot ask a question. A profile with a --via destination first reads that destination’s effective SSH configuration with ssh -G. A destination configured with a ProxyJump or a ProxyCommand keeps that path and is acquired one component at a time: netdoc never connects around a configured proxy, because that path may be the required route or the audited one, and a jump host is a second SSH connection with questions of its own. This costs one extra evaluation of your SSH configuration per pass, which matters if it contains a Match exec or a KnownHostsCommand.
  • A destination that read as direct is then acquired with ProxyJump and ProxyCommand pinned to none for that pass. ssh evaluates your configuration once per invocation, so without that pin a Match exec that decided differently on a later component could start a jump child or a proxy program, and neither of those inherits the options below. The pin only ever applies to a destination your own configuration already reached directly; a configured proxy is never pinned away, and an accelerated acquisition that fails hands the work back to the ordinary transport, which reads and honors your configuration as it stands at that moment, proxy included. A nondeterministic Match exec can therefore still make one pass’s accelerated attempt use the route the preflight read rather than a later one.
  • For a destination reached without a proxy, the first component is acquired alone with OpenSSH options that refuse every interactive authentication instead of prompting for it: a password, a keyboard-interactive or MFA challenge, a key passphrase, and an unknown host-key confirmation are refused, only public-key authentication is offered at all, and an authentication agent, a FIDO security key, a PKCS#11 smart card, GSSAPI, and adding a used key to an agent are not used at all. What is left is an unencrypted private key on disk. When that first component lands, the rest run concurrently over the same refusing transport, so the pass costs one connection setup plus the slowest of the others rather than the sum of all of them. Every other setup falls back to acquiring one component at a time exactly as before, at the cost of one refused attempt on the first component, so a prompt is never competing with another for the terminal. Results stay in registry order either way.
  • --iface, --public-dns, and --timeout apply independently to every component. Interface names are resolved on the machine that probes, as in every ordinary local or remote run.
  • A profile’s check list is its minimum plan. Explicit --check IDs are added to every component, with duplicates removed. --skip takes precedence over the profile minimum and explicit checks, then the normal dependency-removal rules apply.
  • Profiles are headless and never read or write TUI target history. --no-history is accepted and therefore redundant rather than becoming an incompatibility.
  • --toolbox and --keys require the single-run TUI and are rejected with a profile. Peer mode is a different live two-machine protocol and is also rejected. --compare and offline --two-sided accept only single-run snapshots and reject the separate profile artifact schema. Live --two-sided --via rejects --profile because a profile is a multi-target plan rather than one target observed from two vantage points.

Target requirements are checked before any probe starts. github rejects a positional target rather than silently replacing a canonical endpoint. The other profiles reject a missing target. A scheme that conflicts with the profile, such as https://host under --profile ssh, is rejected rather than reinterpreted. Profile endpoints are finite, deterministic, documented, and limited to the selected service, so a plan cannot become a port scan.

Peer diagnosis

Peer mode is a separate, headless two-ended diagnosis. It does not run the ordinary target DAG twice or concatenate two reports. One authenticated control connection coordinates independent TCP, TLS, and application-payload attempts in both directions, exchanges those observations as data, and runs one combined truth table on each machine.

It answers one half of the two-machine question: the path between the two machines. For the other half, a target that fails from one machine and works from the other, see two-sided diagnosis, which reads two ordinary runs. Those runs can already be saved, or the live form can acquire them locally and over SSH before applying the same reading.

Pairing workflow

The listening machine binds one exact local unicast address. Port zero asks the kernel to select a temporary port:

netdoc --peer-listen 192.168.1.20:0

To offer both families, repeat the flag once:

netdoc \
  --peer-listen 192.168.1.20:4242 \
  --peer-listen '[2001:db8:1234::20]:4242'

An IPv6 link-local address must carry the interface scope that makes it reachable, so the bare address is rejected. Windows writes that scope as a number, such as %12:

netdoc --peer-listen '[fe80::20%eth0]:4242'

The listener prints each direct endpoint and one ndp1. pairing string. On the other machine, run:

netdoc --peer-connect

Paste the string at the Temporary pairing string: prompt. On a terminal the prompt disables echo. The credential is read from standard input, never argv, the target history, a file, or an environment variable. A script may pipe one line to the prompt, but then owns the security of that pipe.

--iface may accompany --peer-connect; it supplies the connector’s source address and reverse listener address for each available family. The listening form already names exact bind addresses, so combining --iface with --peer-listen is rejected. Peer mode cannot be combined with a target, --watch, --toolbox, --check, --skip, --no-reference-egress, --public-dns, --no-history, --keys, or --save. It does not enter the TUI.

The listener waits at most five minutes for one authenticated control session. After connection, the complete exchange is bounded to one minute. --timeout sets each dial, TLS handshake, frame read, and write budget as it does for an ordinary probe, but peer mode caps it at 30 seconds. The two control reads that wait for peer evidence allow three times that budget because the peer must finish its bounded connection, TLS, and application phases first; the one-minute session limit remains the hard cap. Ctrl+C and SIGTERM cancel pending accepts, dials, handshakes, reads, and writes and close all listeners and connections.

Authentication and encryption

Every listening run generates, with crypto/rand:

  • a fresh Ed25519 self-signed server certificate,
  • a fresh 256-bit session token,
  • fresh request nonces and fixed-payload challenges.

The pairing string contains protocol version 1, its expiration, at most one IPv4 and one IPv6 direct endpoint, the certificate’s SHA-256 pin, and the token. It uses only URL-safe printable characters and is easy to paste, but it is a secret until the session ends. Do not post it in a ticket or chat archive.

TLS 1.3 encrypts every control and probe connection. The connector accepts only the exact pinned certificate, and the listener compares the decoded token in constant time before accepting a control or probe request. Each authenticated request needs a fresh 128-bit nonce; repeats are rejected. One listener accepts one control connection, no more than eight concurrent connections, and no more than eight authenticated nonces. Excess sockets are closed without ending the pending session. A wrong token does not consume the one valid session. A stopped or expired listener makes an old pairing string useless.

An unauthenticated client can temporarily occupy all eight TLS handshake slots until the per-operation timeout. A legitimate connection is rejected while all slots are occupied, but the listener remains alive and accepts it after a slot expires or closes. This availability limit prevents unbounded socket and goroutine growth; authentication and diagnostic state remain unaffected.

The token and certificate pin never appear in the peer result, ordinary report, or returned error. Listener readiness and the pairing string go to stderr in both output modes so stdout contains only the final result. Stderr is therefore a deliberate secret-bearing pairing channel for that invocation and must be handled accordingly.

Wire protocol

Protocol version 1 uses TLS-protected messages framed by a four-byte big-endian length followed by UTF-8 JSON. JSON was chosen because it is deterministic for the defined structs, inspectable in tests, and does not add a serialization dependency. Go gob is not used.

Every message carries version and a fixed type:

Type Direction Purpose
hello connector to listener authenticate the control connection, echo a random challenge, and offer the connector’s bounded reverse endpoints
hello_ok listener to connector prove the listener completed the authenticated application exchange and identify itself
probe / probe_ok either test direction authenticate one family-specific connection and echo a fixed 32-byte random payload
evidence each side once exchange at most two structural observations, one per family
done connector to listener confirm both sides received the evidence before closing

Frames are limited to 16 KiB. Endpoint and peer-name fields are bounded, endpoint offers and evidence collections contain at most two entries, each session has a fixed message sequence, and JSON with unknown fields, trailing values, malformed encoding, an unknown type, or a different version is rejected. Reads allocate only after checking the frame length. Peer strings are sanitized before display. There is no message for a filename, command, target port range, shell input, configuration change, or arbitrary diagnostic action.

The listener makes at most one bounded TLS probe per address family to the port offered by the authenticated connector. It replaces the offered IP with the source IP actually observed on an authenticated connection in that family. If it has not observed that family, it records peer_address_unverified and does not dial the offered address. A peer therefore cannot select another host as a probe target.

Version 1 is intended to remain compatible across releases. A release may add optional result fields without changing the wire. Any incompatible message or semantic change requires a new protocol version and pairing prefix; version 1 peers reject it instead of attempting a downgrade. There is no cross-version negotiation in the first protocol.

Evidence and diagnoses

The authenticated control channel is recorded separately from the diagnostic attempts. Each independent attempt records:

  • semantic direction (listener_to_connector or connector_to_listener),
  • IPv4 or IPv6,
  • actual source and destination socket addresses where known,
  • PASS, FAIL, or N/A,
  • cross-platform cause such as connection_refused, timeout, or unreachable,
  • whether TCP connected,
  • whether the pinned TLS peer authenticated,
  • whether the 32-byte application payload completed,
  • elapsed milliseconds.

This supports the following combined diagnosis IDs:

ID What the evidence proves
peer_bidirectional_ok authenticated small application traffic passed in both directions
peer_directional_failure at least one direction passed and the other failed; wording names the traffic direction, not a device firewall
peer_symmetric_failure the independent bounded attempts failed in both directions while the control channel remained available
peer_address_family_asymmetry one tested family carried peer traffic while the other tested family did not
peer_listener_local_only the failed destination for the tested family was definitively bound only to loopback
peer_application_failure TCP and pinned TLS succeeded but the fixed application payload did not
peer_security_failure TCP connected but the endpoint did not authenticate as the paired TLS peer
peer_incomplete_evidence too little independent evidence exists to distinguish a directional or endpoint failure

connection_refused proves that the tested address actively refused the TCP connection. It does not distinguish no listener from an active rejecting filter. A timeout or unreachable result does not distinguish endpoint filtering, routing, address translation, a stale address, or another reachability failure. Directional and address-family diagnoses therefore set ambiguous: true and list the remaining explanations. They never claim a firewall, NAT, or routing root cause without independent proof.

Peer version 1 exchanges only the fixed 32-byte challenge plus its small protocol messages. It does not reuse the ordinary 24 KiB Path MTU probe because the peer protocol’s own reader changes the control evidence that probe relies on. It cannot diagnose “small succeeds, large stalls” and makes no MTU or PMTU claim. It also does no traceroute orchestration, packet capture, raw sockets, port scanning, remote shell, or repair.

Direct-connect and privacy limits

There is no Network Doctor service, hosted rendezvous, relay, account, telemetry, UPnP, NAT-PMP, or automatic firewall change. The connector must reach at least one address printed by --peer-listen. If the listener is behind NAT, its operator must already have a directly reachable address and port; peer mode does not create one. The reverse test uses the connector’s temporary listener and the source address the listener actually observed for that family. An unobserved family is not dialed. A NAT or stateful filter may still prevent that new reverse connection. The result says so without pretending to know which device or rule caused it. A relay could later carry the same evidence messages without changing their semantics, but version 1 implements no relay transport.

The peers exchange their sanitized host names, the temporary listener addresses they offer, the actual local and remote socket addresses seen during the session, address families, phase outcomes, and timing. A reverse address that could not be verified is recorded as N/A with cause peer_address_unverified. This can reveal private addresses, public NAT addresses, interface choices, and host names. Nothing is sent anywhere except the paired direct endpoints, and no telemetry exists, but review a saved result before sharing it.

Peer JSON output

--json in peer mode emits netdoc.peer.v1, not the ordinary report schema:

{
  "schema": "netdoc.peer.v1",
  "version": "1.2.3",
  "protocol_version": 1,
  "local": {
    "role": "connector",
    "name": "machine-b",
    "listen_addresses": ["192.168.1.21:53122"],
    "observed_address": "192.168.1.21:49018"
  },
  "remote": {
    "role": "listener",
    "name": "machine-a",
    "listen_addresses": ["192.168.1.20:4242"],
    "observed_address": "192.168.1.20:4242"
  },
  "channel": {
    "established": true,
    "family": "ipv4",
    "local": "192.168.1.21:49018",
    "remote": "192.168.1.20:4242",
    "ms": 3
  },
  "observations": [
    {
      "direction": "listener_to_connector",
      "family": "ipv4",
      "source": "192.168.1.20:49410",
      "destination": "192.168.1.21:53122",
      "status": "PASS",
      "tcp_connected": true,
      "tls_authenticated": true,
      "application_traffic": true,
      "payload_bytes": 32,
      "ms": 2
    },
    {
      "direction": "listener_to_connector",
      "family": "ipv6",
      "status": "N/A",
      "cause": "family_unavailable",
      "tcp_connected": false,
      "tls_authenticated": false,
      "application_traffic": false,
      "payload_bytes": 0,
      "ms": 0
    },
    {
      "direction": "connector_to_listener",
      "family": "ipv4",
      "source": "192.168.1.21:49022",
      "destination": "192.168.1.20:4242",
      "status": "PASS",
      "tcp_connected": true,
      "tls_authenticated": true,
      "application_traffic": true,
      "payload_bytes": 32,
      "ms": 2
    },
    {
      "direction": "connector_to_listener",
      "family": "ipv6",
      "status": "N/A",
      "cause": "family_unavailable",
      "tcp_connected": false,
      "tls_authenticated": false,
      "application_traffic": false,
      "payload_bytes": 0,
      "ms": 0
    }
  ],
  "diagnosis": {
    "id": "peer_bidirectional_ok",
    "verdict": "ok",
    "summary": "Authenticated peer traffic succeeds in both directions.",
    "evidence": ["listener_to_connector/ipv4", "connector_to_listener/ipv4"],
    "ambiguous": false
  },
  "ok": true
}

observations is always ordered listener-to-connector IPv4, listener-to- connector IPv6, connector-to-listener IPv4, connector-to-listener IPv6. An unavailable family has an explicit N/A observation with cause: "family_unavailable". The two machines reverse only local and remote; directions keep their semantic names. Field names, ordering rules, status/cause values, diagnosis IDs, and the meaning of ok are stable for this schema. ok is true only when small authenticated traffic passes in both directions. A diagnostic failure exits 1; bad peer CLI arguments or an unreadable pairing input exit 2. Existing ordinary exit meanings and JSON fields are unchanged.

Drill-down tools

Press e after a completed diagnosis to replace the focused Details panel with its causal explanation. It opens with the confidence in the answer, then separates observations supporting it, observations that rule out or contradict alternatives, and relevant checks that were not evaluated. Press e again for the ordinary row details. The full key cheatsheet behind ? is generated from the same binding, so custom key presets cannot make dispatch and help disagree.

Each diagnosis row is evidence; when you want proof beyond the built-in observations, run the real tools as cancellable streaming jobs: several run at once, and tab switches between the live ones. The Actions menu shows the tools available for the current target with their hotkeys and omits missing binaries. Output is bounded and sanitized (no terminal-escape injection from a hostile server); reports include version/OS metadata plus each job’s command, status, duration, and last 15 output lines. Review your local copy before sharing, since tool evidence may contain sensitive data.

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 -Pn --host-timeout 110s (the explicit target port, else nmap’s default top 1000) same same

n and fresh LAN discovery are gated behind an explicit confirmation before their active probes run. n uses a plain connect scan with nmap’s default timing and no version/OS detection. The first v, or Actions menu Rescan network, runs host discovery without raw sockets or root and caps its scope at the current source address’s /24. Later v presses only show or hide the newest cached map.

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 rather than an HTTPS-oriented curl line. The SSH check uses a throwaway known-hosts file (no prompts, no writes) and disables authentication with PreferredAuthentications=none, stopping after the banner and key exchange.

The routes and sockets tools are target-independent; the rest need a host. Tools run with an argument slice (never a shell string), in their own process group on Unix (so cancelling 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>] starts without auto-running the chain (press space for the Actions menu or r to run the checks). With no host, only the target-independent tools are offered.

Local devices

v answers “I cannot reach the printer” in two steps, so neither one has to be known in advance. With no earlier LAN scan it confirms and runs unprivileged nmap -sn across the source address’s /24; afterward it shows or hides the newest cached map without probing again. A cached map is a snapshot, not live network state, and the line under the panel title says which it is: Live scan while a sweep runs, Cached snapshot once one has finished, followed by how long ago it was captured, the fixed capture instant, and the outcome when the scan did not complete. While a sweep runs, and after one fails, is canceled, or times out, the panel also names the earlier successful scan still in the job ring. The map’s help bar routes to a fresh one: space actions: rescan. Use Actions menu Rescan network for a fresh measurement. Use Retest after changing networks so the diagnostic source and LAN context are rebuilt together.

Discovery finds a device only if it accepts or refuses a TCP connect on port 80 or 443. This machine is listed separately rather than as a device to diagnose, and an address gains a name when mDNS, reverse DNS, or an ssh_config alias supplies one. Failed, canceled, and timed-out snapshots keep any observed hosts visible and label them as partial results; the devices on screen are always the displayed scan’s own, never an earlier scan’s carried forward. Earlier scan output remains in the normal bounded job ring until ordinary eviction.

enter on a device opens it and asks what it answers on: one round of ordinary TCP connects, run in parallel inside a single probe timeout, against a fixed list of fifteen ports (21, 22, 23, 53, 80, 443, 445, 515, 631, 2049, 3389, 5900, 8080, 8443, 9100). This is a chooser, not a scan: the list is fixed rather than a range, and nothing is sent after the connect, so the name beside a port (“IPP”, “JetDirect”) is that port’s registered service name and not a claim about what the device is.

enter on a service makes it the target and runs the normal checks against it, so a printer picked off the map becomes 192.168.1.23:631 instead of an HTTPS check against a port it never had. Ports whose protocol netdoc probes add their rows (http, https, ssh); the rest stop at the TCP rung, which is all a connect can honestly report. esc returns to the device list, and r still accepts a port typed by hand for a service the list does not carry.

When nothing answers, the panel says which of the two things happened: refused connections show the device is on the network with nothing listening on those ports, while silence leaves powered off, gone, and dropping traffic all open. A target on this machine’s own network is also diagnosed as one: a failed connect to it is never explained by the state of internet egress, which it does not use.

SSH login

S logs in to the current target, the machine the checks are about, so it needs one (r sets it). The form asks for the three things that are yours rather than the target’s: tab moves between fields, ←/→ picks the key, enter connects, and esc backs out.

╭────────────────────────────────────────────────────╮
│ SSH login to 192.168.1.50:2222                     │
│   Username  mplaczek                               │
│ ▸ Key       id_rsa  (3 of 4)  ←/→                  │
│   Password  *******                                │
╰────────────────────────────────────────────────────╯

Unlike the drill-down tools, this is not a bounded job: netdoc suspends itself and gives ssh the real terminal, so the session is fully interactive and anything the form left blank (a key passphrase, a host-key check, a 2FA code) is asked by ssh on screen. When the session ends the TUI comes back with ssh’s stderr in the job pane, so a Permission denied is still readable afterwards instead of being painted over.

The form fills in ssh options, nothing more:

Field Effect
(host) the target, plus -p when it named a non-default port
Username -l <login>, not login@host, so a name starting with - stays a name
Key -i <path> -o IdentitiesOnly=yes, so a loaded agent can’t spend the server’s auth attempts before this key is tried
Password see below

The key list is the private keys in ~/.ssh, each recognized by its .pub half sitting next to it, and no key file is ever opened or parsed. none (the first entry) leaves key selection to ssh, i.e. to the agent and ssh_config, which is also where a key kept outside ~/.ssh belongs.

The typed password is echoed as dots and never reaches a command line, notice, or report. It is passed to ssh through the environment (SSH_ASKPASS), where ssh re-executes netdoc as its askpass helper and reads the secret from the helper’s stdout. What that buys: the secret stays out of argv, which every process on the machine can read, and out of your shell history, though not out of memory. The password field is cleared once connecting starts, but by then the value has been copied into ssh’s environment, where it stays until ssh exits and is inherited by the whole subtree ssh starts: ProxyCommand, LocalCommand, and ProxyJump’s own ssh. On Linux that environment is readable through /proc by your own processes and by root; anything your ssh_config runs sees it too. -o NumberOfPasswordPrompts=1 stops ssh re-offering a rejected one.

On Windows, this password handoff requires OpenSSH_for_Windows 8.6p1 or newer. Network Doctor checks ssh -V before connecting; an older or unrecognized client leaves the form open with an explanation instead of launching ssh with an ignored password. Leave the password field blank on that client to have ssh ask on the terminal.

Because forced askpass routes every prompt to the helper, the helper answers only password and passphrase prompts and refuses the rest. So the first connection to an unknown host, where ssh asks you to verify the host key, needs one run with the password field blank, which puts that question back on your terminal where it belongs.

The whole ssh subtree inherits the askpass setting, so with ProxyJump in your ssh_config the jump host’s ssh asks the helper too. The prompt names the machine it is asking for (user@host's password:), and the helper answers only when that host matches the target, so the jump host’s prompt goes back to your terminal. Prompts naming no host (a key passphrase, a PAM keyboard-interactive Password:) are still answered on a direct connection, where only one machine can be asking; refusing those would break ordinary logins. Add a proxy and they name no host and could be either end, so the helper refuses all of them. Wording is no help there, since keyboard-interactive text is written by the far end, so a jump host can call its question a passphrase, and that run wants the password field blank anyway, which puts the prompt back on your terminal. All of this depends on netdoc being able to read your resolved config (ssh -G); when that lookup fails there is no way to tell whose prompt is whose, so password-assisted login is refused outright and the form says so.

JSON output

--json runs the same probe DAG headless, with 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", "ms": 12, "detail": "github.com → 140.82.113.3 (resolver tried: 127.0.0.53)", "addrs": ["140.82.113.3"], "resolver_targets": ["127.0.0.53:53"]}
  ],
  "summary": "All checks passed. github.com:443 looks healthy.",
  "verdict": "ok",
  "ok": true
}

status is one of PASS, WARN, FAIL, SKIP, N/A, or INCOMPLETE. target is null in generic (no-target) mode. ms is the check’s wall time truncated to milliseconds but floored at 1, so 0 means the check never ran. Optional per-check fields (cause, address_families, fix, addrs, resolver_targets, selected_ip, source, iface, network, portal, attempts, routes, connect_cleartext) are omitted when empty. resolver_targets lists the DNS service addresses recorded from Go’s resolver Dial hook as they were dialed; a single lookup’s addresses are sorted and deduplicated, but dns_public’s failover across resolvers appends each attempt’s list in dial order, so the field is not guaranteed globally sorted or unique. It proves that those servers were dialed, not which one returned a response or supplied any particular address: Go resolves A and AAAA as independent queries that fail over independently, so one hostname lookup can be answered by more than one server. It is absent when a lookup was satisfied without dialing DNS, including a hosts-file result reached before any query. The detail text follows the same rule and credits the answer to no server, however few were dialed: one reads resolver tried: … and two or more read resolvers tried: …. A single dialed server is not provenance either, because a host configured hosts: dns files reads its hosts file after a query that came back with nothing, so a lookup there can dial one server, be answered by none, and still return addresses. internet_tcp.address_families records the independently tested IPv4 and IPv6 state as reachable or unreachable. target_tcp.address_families records the same states for a dual-stack target after comparison with the host’s independent family paths. Under --iface or an explicit source address, a family the selected source has no address for is never dialed and its key is omitted, meaning untested rather than unreachable. A target family is also omitted when the target publishes it but the host did not prove a usable local path for it. A selection leaving no usable family at all reports the whole egress row as N/A, as the QUIC and encrypted-DNS rows already do. A configured egress family whose path fails while the other succeeds warns with ipv4_unreachable or ipv6_unreachable. Failed QUIC, encrypted-DNS, proxy, and TLS checks populate cause so automation can distinguish failure stages without parsing detail; QUIC uses timeout or quic_handshake_failure, encrypted DNS uses timeout or encrypted_dns_unavailable, while TLS values include certificate_expired, certificate_not_yet_valid, hostname_mismatch, untrusted_issuer, tls_handshake_failure, tcp_unreachable, timeout, and connection_closed. A failed HTTP or HTTPS check records invalid_response when the transport received the first byte of a response and still could not read an acceptable HTTP response from it, which covers a status line that does not parse, headers over the probe’s size limit, and a response cut off by a close, a reset, or the deadline. With no response byte received, a clean close records connection_closed and a reset records connection_reset. Silence until the deadline records no cause. Either way a deadline still marks the check as timed out once its exchange began on its own connection, so a stall after some bytes arrived, or during the HTTPS TLS handshake, counts toward the path-MTU correlation, while a deadline that expires during the check’s own dial does not. Failed direct egress may use no_default_route, gateway_unreachable, selected_path_failed, or preferred_route_failed, read from the local routing and neighbor tables on Linux, macOS, and Windows. When both families fail, the cause comes from a family that has default routes to describe: a host with no IPv4 default route at all is not reported as routeless when the IPv6 routing it actually uses is what broke. macOS route entries carry no preference metric, so preferred_route_failed comes only from Linux and Windows, and gateway_unreachable needs a neighbor cache entry that exists and shows the next hop unresolved, so an unseen next hop leaves selection-only metadata rather than a gateway failure. The legacy IDs selected_path_failed and preferred_route_failed mean a default route or a metric-preferred default was present after reference connectivity failed. Neither establishes a failed route, a healthy gateway, a working alternate, or the location of the outage; they do not independently justify local_egress_failure. Within one family, observed gateway failure takes precedence over metric preference. Across failed families, a local defect in one does not explain an unknown failure in another: selection-only or unavailable classification keeps the overall result unlocalized. Both families having localizing evidence, or a sole attempted family having it, preserves localization. Unlocalized findings retain observed unreachable and tunneled routes as context without raising causal confidence. The portal object marks HTTP interception corroborated by both fixed connectivity endpoints, and includes redirect_url only when one of those responses supplied a valid HTTP(S) sign-in URL; where both did, the first endpoint in netdoc’s fixed order wins, so the value never depends on which request finished first. The app displays that URL but never opens it. A single endpoint answering unexpectedly leaves the object absent and warns instead; that row’s cause is connectivity_check_unexpected_response when the discrepancy is the whole warning, and its detail says which part of the answer differed, an unexpected status or an unexpected response body under the documented status. A row that is also slow, losing addresses, or missing an address family is describing the path itself and records the path’s cause or none at all. connect_cleartext is true on a working proxy_connect row whose environment proxy used a plaintext HTTP CONNECT, meaning the destination hostname reached the proxy without TLS on the client-to-proxy hop; it describes the proxy transport the configuration selected and makes no claim that anything on the path read the name. It is recorded only for a tunnel that succeeded. It is omitted when the observation was not recorded, which covers an https:// proxy that wrapped the CONNECT in TLS, a SOCKS5 proxy, a non-proxy row, a proxy the run never reached, and a CONNECT the proxy refused after the destination hostname was already on the wire. An absent field never means a TLS hop was confirmed, and never means that no cleartext hostname was sent. The observation never changes the row’s status: a successful row keeps the status it earned, which is PASS unless an existing dial warning such as high latency or an ambiguous source interface already made it WARN. The fact is carried as this additive observation and, for a person, as a hedged fix line that names the exposed hop without asserting an https:// endpoint the probe did not test; that line is shown on the successful row even though ordinary passing rows print no fix. Field names and the status vocabulary are stable, so they are safe to script against. Exit codes follow the table in Usage details (ok: false ⇒ exit 1).

preferred_path_failed_alternate_reachable is a separate, measured cause on internet_tcp: the reference TCP connections failed through the preferred default path while this run connected to the target through a distinct, lower-preference default path in the same address family. It requires known main-table routing decisions and a target route matching the successful socket source. Linux supplies this evidence for IPv4 and IPv6; platforms without the required table knowledge retain the legacy causes. The target may use a more-specific route through the alternate gateway. This proves that the alternate reaches that target, not that changing defaults will restore all connectivity or that the upstream failure has been localized. No additional network probes are sent.

Route evidence reflects platform measurement capabilities. Linux’s IPv6 default-route classifier has no neighbor-failure callback. Darwin checks gateway resolution only with exactly one unscoped default. An unavailable observation leaves localization uncertain; it is not evidence that the gateway works. Linux ARP and Windows incomplete-neighbor state are interpreted after reference connection failure and can still reflect resolution in progress. Cross-builds check compilation, not native macOS or Windows runtime behavior.

A machine-readable JSON Schema for this document is published at schema/report-v1.schema.json, in Draft 2020-12. It describes the contract above as it already is rather than proposing a replacement for it: the report --json prints, the same object --via prints for a run performed on another machine, and each --watch line, which is that document plus ts. It deliberately permits properties it does not list, because the report is additive within v1 and a consumer pinned to the file has to keep validating against a newer netdoc. It freezes an enumeration only where this page publishes a closed vocabulary (status, verdict, confidence, the causal-evidence kind, observation and reason, the address-family states, and a route’s family, tunnel and reason); the identities documented as growing over time, meaning finding and remediation ids, check ids, failure causes and tunnel kinds, stay open strings. Network Doctor validates its own reports against the file and checks every serialized field and every frozen vocabulary against it, so the published schema cannot fall behind the producer. The other machine-readable outputs are separate contracts the file does not describe: the profile aggregate (which carries reports of this shape inside it), a comparison, a two-sided reading, peer output, and the .ndoc snapshot.

INCOMPLETE is the one status value no probe can return, and it carries the same meaning here as it does in a snapshot. The check was in the run’s check set but never reported a result at all, which is what a cancelled or interrupted run leaves behind. It is not a SKIP: nothing decided to leave it out, the run ended first. An INCOMPLETE row is never a pass, and a report holding one is never "ok": true, so the absence of an observation can never be read as a passing check. The row itself is still emitted, so a reader can see that the check existed and was not reached.

routes is the operating system’s own route decision for each destination the check was about, one entry per address, since route selection is per address and not per hostname. netdoc asks the kernel the same question ip route get asks and records the answer; it does not read a routing table and re-run the selection itself, and it never dumps one. The lookups are unprivileged and bounded: the general Internet endpoints on the iface row, each recorded resolver target on the dns row, and each resolved target address on the target_tcp row. They describe netdoc’s own flows: under --iface or an explicit source address every dial in the run leaves from a chosen local address, and the lookup carries that source too on the platforms whose route API accepts one, so a rule selecting on source address is resolved the same way it would be for the real connection. Linux passes it to the FIB lookup and Windows passes it to GetBestRoute2; a macOS routing socket takes no source, so the answer there is the machine’s own decision for the destination, as it is for any unbound run.

Each entry carries destination, family, and whatever the platform supplied: interface, gateway (absent when the destination is on-link), source, prefix (the route entry that matched), metric, table, table_known, interface_mtu, tunnel, tunnel_kind, unreachable, reason, and competing. An absent field means the platform did not supply it and is never to be read as zero, which is why metric is omitted rather than written as 0 where none was reported. routes itself is absent on a platform netdoc cannot ask, which is not the same as having no route: "unreachable": true is how “no route” is said.

tunnel is tunnel when the operating system itself names the device an encapsulating kind (tunnel_kind then carries which: wireguard, tun, gre, ppp, and so on), likely when the link only has the shape of one, and direct for an ordinary interface. It is absent when nothing classified the interface, which reads as unknown and not as direct. There is no list of VPN product names anywhere in netdoc; a product that installs an ordinary Ethernet device is deliberately not detected rather than guessed at.

table names the routing table or routing domain the decision came from, and table_known is whether the platform named one at all. The two are needed together because the main table is written as an absent table: an operating system that says “this came from the main table” and one that says nothing about routing domains both leave the field out, and only table_known tells them apart. Linux reports the table the FIB lookup resolved the destination in, so table_known is true there and a policy table keeps its number, which is the whole point of recording it. macOS and Windows expose no routing domain in their route APIs, so table_known is absent on both and a reader must take that as unknown rather than as the main table. A snapshot written before the field existed has no table knowledge either, which is the same unknown. A table the operating system consults on its own is not a routing domain anything selected, and comparisons read it as the ordinary case: a Linux machine with no policy routing at all resolves its own addresses in local and everything else in main, so localhost lands in a different table per family, and treating that as a difference would report one on every dual-stack host. A selected table outside that set is evidence that the kernel resolved this flow through another routing domain, and it is not evidence of which rule, firewall mark, VRF, or application policy sent it there: a route lookup does not report that, and netdoc does not read policy rules on any platform.

prefix is the route entry the operating system said it matched, and it is present only where the platform reports one. macOS and Windows do; Linux does not. A Linux route lookup echoes the length the query asked about rather than the length of the entry it matched, so every answer to a host lookup comes back at the full address length whatever the table holds. Reading that back as a matched prefix would report every destination as a host route, so the field stays absent there rather than carrying a number the kernel never meant. Everything that follows from a named entry follows only on the platforms that name one: competing and a lower_metric reason need both the entry and a preference metric, so they come from Windows alone today, and even there the list is the other default routes that were available rather than an enumeration of every route that could have covered the destination. netdoc reads no policy rules on any platform.

reason is the only derived field, and it is what answers where prefix cannot. It is drawn from the route entry where the platform names one, and otherwise from comparing this destination’s decision with the decision the same kernel gave for general Internet traffic, which is two answers from the operating system rather than a guess about its table. Those two kinds of evidence do not support the same claims, and the vocabulary keeps them apart. default_route (the destination matched the default route), more_specific_than_default (a narrower entry than a default route matched it, which is the split-tunnel case at the level of the routing table), host_route, and lower_metric are statements about which entry won, and are reported only where the platform named it. same_path_as_default (this destination leaves by the same interface and next hop as general Internet traffic, and out of the same routing table where the platform named one) and differs_from_default_path (it leaves by a different interface, a different next hop, or a different named routing table) are the weaker answers available where the entry is unnamed, and they say nothing about why. That distinction is not pedantry: a policy rule, a separate routing table, a VRF, source-specific routing, or a multipath route choosing another next hop each send one destination down a different path with no narrower prefix anywhere in it, and a rule pointing at a table whose winning entry is itself a default route is indistinguishable from here. on_link (no next hop between here and the destination) and no_route are read from the decision itself on every platform. interface_mtu is the selected link’s own MTU and is never the end-to-end path MTU, which only the path_mtu row measures.

Resolver failures can add dns_timeout or dns_temporary_failure; a target TCP check whose every attempted address explicitly refuses the connection adds connection_refused; a peer that accepted TCP before resetting adds connection_reset. Each target attempt can add the same stable connection cause. aborted: true means cancellation or the enclosing probe deadline ended that attempt, so it is not evidence that the individual address failed. These are optional additions to the existing check objects.

fix is the one-line remedy netdoc prints as that row’s Fix: line, on a Warn row as well as a failed one, and is omitted where there is nothing useful to suggest. Some hints name the thing to change by whatever the host OS calls it. A name-resolution failure directs Linux users to /etc/resolv.conf and DNS settings, while macOS points at System Settings and Windows at ipconfig /all. Others, such as the certificate and routing hints, carry no OS-specific wording at all.

failed_stage names the first check that failed (dns, target_tcp, tls, …) and is omitted when none did, which is enough to route a bug report without reading any prose.

--json --watch prints the same document once per pass, compacted onto a single line (NDJSON), five seconds apart, until the process is interrupted, for the intermittent failure you can’t sit and watch:

netdoc --json --watch github.com | jq -c 'select(.ok | not) | {ts, failed_stage, summary}'

Those lines add a ts field (RFC 3339, UTC) saying when the pass ran; nothing else changes. The exit code is the last completed pass’s.

verdict is the summary as a machine-readable class, for the question a script actually asks: is my network broken, or is theirs?

verdict Meaning
ok Every check passed
degraded Everything asked for works, but some rung is impaired: high latency, a proxy-only network, direct egress blocked while the target is fine
dns The name did not resolve
network The path is unavailable: the link is down, there’s no egress, or the target is unreachable with nothing else proving the network usable
service The path works, the far end does not: TCP refused while the general internet is reachable, or TLS/HTTP/banner failing on top of a good connection
incomplete A check has no result (the chain did not finish)

The network/service split is decided by evidence, not guesswork: an unreachable target is only blamed on the service when direct egress independently succeeded. With no working egress to compare against, netdoc says network rather than accusing a host it never reached.

Diagnosis findings

verdict says what kind of problem this is. findings says which problem it is.

Every run is interpreted once, and that one interpretation produces the summary, the verdict, the row the app puts your cursor on, and this array. They cannot disagree with each other, because nothing reconstructs the diagnosis a second time.

"findings": [
  {
    "id": "tls_certificate_expired",
    "focus": "tls",
    "confidence": "high",
    "evidence": ["tls", "target_tcp"],
    "causal_evidence": [
      {"kind": "support", "check": "tls", "observation": "cause"},
      {"kind": "support", "check": "target_tcp", "observation": "status_pass"},
      {"kind": "ruled_out", "check": "target_tcp", "observation": "status_pass", "candidate": "tls_tcp_unreachable"}
    ],
    "remediation": {
      "id": "renew_certificate",
      "action": "Renew the certificate, or check this machine's clock",
      "why": "The handshake was rejected because the certificate is outside its validity window. Either it really has expired, or this machine's clock runs far enough ahead to make a valid one look expired.",
      "steps": [
        "Compare the validity dates on the TLS row with today's date.",
        "Confirm this machine's clock before blaming the certificate."
      ],
      "command": ["timedatectl", "status"],
      "expect": "A certificate whose validity window covers now, on a machine whose clock is right."
    }
  }
]

id is a stable identity to branch on, and never changes when the English sentence beside it is reworded. focus is the check id whose detail and fix belong to this finding, so the row’s own hint stays on the row that wrote it rather than being restated here. confidence is how strongly the observations support this finding as an explanation, described in Diagnosis confidence. evidence remains the original compatibility list of observed check IDs, focus first. causal_evidence is its typed form and may also name a relevant check that was not evaluated. A value identifies a member of a structured observation, such as ipv6 or one attempted address. counterfactual names the variable that changed and the observed alternatives, each of which references causal evidence already carried by the finding. remediation is what to do about the finding, described below.

Each causal item has a kind, a check ID in check, and, when an observation exists, its typed identity in observation. The value itself remains on the referenced check. This keeps a measured fact separate from what that fact means for a diagnosis.

kind Meaning Additional field
support The observation directly supports the selected diagnosis none
contradiction The observation weakens a named alternative but does not exclude it candidate diagnosis ID
ruled_out Independent positive evidence excludes a named alternative candidate diagnosis ID
not_evaluated The check supplied no observation and remains unknown for this run reason

The observation vocabulary is status_pass, status_warn, status_fail, status_skip, status_not_applicable, cause, dns_answers, dns_not_found, captive_portal, timeout, clock_offset, status_downgraded, family_reachable, family_failed, address_succeeded, address_failed, route_tunneled, route_direct, route_unreachable, route_path_differs, route_next_hop_differs, route_table_differs, route_family_split, and route_interface_mtu. Not-evaluated reasons are prerequisite_failed, not_selected, not_applicable, and incomplete. A skipped, not-applicable, missing, or incomplete check is never enough to rule out a cause. An absent causal_evidence field means this producer did not record an explanation; it does not mean the listed alternatives were tested.

What a value means comes from the observation beside it. Five of them name a recorded IP address: dns_answers, address_succeeded, address_failed, route_unreachable, and route_next_hop_differs. Those are read as addresses, so one address spelled two ways is one address, both when an item is matched to the check it cites and when two artifacts are compared. The rest name something else and are read as the text they are: a family for cause, family_reachable and family_failed, and an interface for route_tunneled, route_direct, route_path_differs and route_interface_mtu. Because route_next_hop_differs is a claim about two addresses, two spellings of one router do not establish it.

That reading is also what makes one causal-evidence item the same item as another. An item’s identity is all of its fields, with the value read the way its observation says, so a finding carries each claim once however it spells it, and a counterfactual alternative naming a fact the finding already carries names it whatever spelling either side used. Alternatives themselves stay the ordered records the run observed: two of them may name one value, and nothing reads two of them as one.

The eight route_* observations describe the path a row’s own traffic took, and each is a fact about that one row: which interface it left by, whether that interface encapsulates, and whether the operating system had a route at all. None of them is a verdict. A tunnel is not a fault, and no diagnosis is reached because one exists; route observations only ever attach to a conclusion the checks had already proved on their own. Where the interesting fact is that two rows took different paths, it is recorded as two observations, one on each row, because a single item must be checkable against the row that carries it: route_path_differs on the dns row names the target’s interface in value, and a split tunnel appears as route_tunneled on one row beside route_direct on the other. Two flows can leave by one interface and still be routed apart, which is what the other two path observations are for. route_next_hop_differs is this row being handed to a router other than the one named in value, down what is otherwise the same link, and it is reported only where both paths have a next hop, since “on-link here, through the router there” is the ordinary shape of a destination on the local network rather than a routing decision worth reporting. route_table_differs is the kernel having resolved this row’s destination in a named routing table other than the ones it consults by itself, which is evidence that another routing domain was selected and never evidence of what selected it; it carries no value, because the fact is about this row’s own table and the other side is either an ordinary table or unknown. Both of those read exactly as strongly as the row’s tunnel state: where the operating system named the device kind, the app says the traffic left through that tunnel, and where only the link’s shape suggested it, the app says the link has the shape of one, since a mobile broadband modem has the same shape. route_direct is the absence of any such classification and never a proof that nothing encapsulates the path, which is why a VPN presenting an ordinary Ethernet device is not detected. A resolver on loopback records no path at all: a local stub is reached over lo by definition, so it would always look like a different path while its own upstream path, the one that matters, stays invisible. route_family_split is one target’s two families taking materially different routes, compared on the dimensions that are not family-scoped: the interface and a routing domain something selected. A next hop and a source address are different in IPv4 than in IPv6 on every dual-stack host, so neither can be a split. route_interface_mtu reports that the selected link is narrower than the one general traffic uses; it is the link’s own MTU, never a measured path MTU.

The same interpretation branch creates the diagnosis and its causal items. There is no pass that receives a final diagnosis ID and reconstructs plausible reasons afterwards. The ordered array is deterministic, rejects duplicates in snapshots, and keeps every relationship tied to the check observation that actually occurred.

The array is omitted when the run reached no specific conclusion: everything passed, the run is still going, nothing was selected, or the only impairment is on a row no diagnosis is about. Those runs answer with summary and verdict alone rather than being given an identity the probes did not support. The primary finding remains first and supplies the headline. Additional counterfactual findings can preserve a separate partial impairment proved by the same run.

id What netdoc concluded
no_usable_interface No interface is up, so the link is down
captive_portal Traffic is intercepted by a sign-in portal
offline Neither DNS nor direct egress to the reference endpoints is working
direct_egress_blocked Direct TCP egress to the reference endpoints failed while something else still works, and nothing in the run reached a public destination directly
reference_egress_unreachable The reference endpoints are unreachable while the endpoint under test answered a direct connection, so egress itself works
direct_egress_degraded Direct egress carries traffic but is impaired
proxy_only_network Only the environment proxy has egress
local_egress_failure Local routing state classified this machine’s own path as broken, so nothing beyond it was fairly tested
probable_path_mtu_problem A protocol exchange and a bulk write both stall, which is what a path MTU black hole looks like
system_dns_failure The configured resolver fails on a name public DNS resolves
dns_name_not_found Neither resolver has A/AAAA records for the name
dns_failure Name resolution is failing, with no second opinion separating the two above
dns_disagreement System and public DNS answer with different networks
encrypted_dns_unavailable DoH/DoT cannot complete a verified exchange while plain DNS works
quic_unavailable UDP/443 fails while TCP/443 works, so applications fall back
proxy_failure The configured environment proxy check failed
tcp_connection_refused Every connection attempt was explicitly refused
target_unreachable The target does not answer though DNS and the internet work
local_device_unreachable A device on the local network is silent while this machine’s network works
reachability_untested The target is silent and direct egress was not checked, so neither end can be blamed
reachability_unlocalized The target and the reference endpoints were both silent and nothing observed where the path breaks
ipv4_target_unreachable The target works over IPv6 while its IPv4 alternatives fail despite an independently proved IPv4 path
ipv6_target_unreachable The target works over IPv4 while its IPv6 alternatives fail despite an independently proved IPv6 path
partial_endpoint_reachability An attempted resolved address fails while another address for the same target and port succeeds
uncorroborated_endpoint_failure Every address a failed check tried came from the system resolver alone, while independent DNS answered the same name in the same family with addresses this run never tried
tls_certificate_expired The certificate is outside its validity window
tls_certificate_not_yet_valid The certificate’s start date has not arrived
tls_hostname_mismatch The certificate is for a different name
tls_untrusted_issuer The certificate is signed by a CA this machine does not trust
tls_clock_skew A measured clock offset explains the certificate rejection
tls_timeout The TLS check spent its budget without completing, during its own connection attempt or the handshake
tls_connection_closed The peer closed or reset during the handshake
tls_tcp_unreachable The TLS dial itself could not reach the port
tls_handshake_failure The handshake failed with no cause the client could classify
https_no_response TLS completes but no HTTPS response arrives
http_no_response No HTTP response arrives
http_connection_closed The HTTP or HTTPS endpoint closed or reset the connection before sending any response
invalid_http_response The HTTP or HTTPS endpoint answered, but the response could not be read as HTTP
service_banner_failure The port accepts TCP but the banner check failed
service_banner_missing The port accepts TCP and sent no banner
selected_dns_check_failed A --check/--skip selection left a failed DNS row no other case explains
selected_service_check_failed The same, for a service row
selected_network_check_failed The same, for a network row

The TLS identities are drawn from the same classification the cause field publishes, so a finding stays as precise as the handshake was, and the sentence in summary follows that same classification rather than offering certificate causes for a handshake that timed out or was closed. tls_certificate_expired and tls_certificate_not_yet_valid say the rejection was judged against this machine’s clock until a measured clock offset settles that alternative, either as tls_clock_skew or by ruling the clock out. tls_handshake_failure is what an unclassifiable handshake gets: it stays deliberately broad rather than making a specific accusation.

Nothing here claims a wrong default route, a missing subnet route, or an operator’s intent, because no probe proves those. id values are added over time; treat an unrecognized one as “some specific problem” and fall back to verdict.

Diagnosis confidence

Every finding carries a confidence: how strongly the run’s observations support that finding as an explanation of what is happening, given the alternatives netdoc was able to evaluate.

It is not a probability, and none of these words stands in for a number. It is also not the severity, not the verdict, not a check’s status, not how bad the network is, and not a count of how many checks passed. Being certain a service is down is not the same as knowing why, and this field is about the why.

confidence What it claims
high A probe observed the specific condition that separates this conclusion from its alternatives, and nothing relevant is left open
medium The best explanation the run supports, with an ambiguity remaining: an inference no probe observed directly, an alternative weakened but not excluded, or an observation the run could not make
low A real basis for naming the failure, but the evidence identifies the rung rather than the cause, and several causes stay equally consistent with it
insufficient_evidence No causal claim is warranted at all. This is what an explicitly untested or unexplained run gets, and it is deliberately not high confidence in knowing nothing

Two things about a finding decide it. The first is what kind of claim the identity makes, which never varies between runs: captive_portal and tcp_connection_refused name conditions a probe can observe outright, while probable_path_mtu_problem is a correlation, dns_failure names a rung with several live causes, and reachability_untested says in its own name that the distinguishing observation was never made. The second is what this run’s causal_evidence left standing. A ruled_out item excludes an alternative; a contradiction only weakens one, so a candidate that is contradicted and never ruled out is still a live explanation and keeps a finding off high. Presence is what counts, never quantity: a second ruled-out alternative buys nothing the first did not.

target_unreachable and local_device_unreachable have low confidence: reference egress cannot distinguish target filtering, routing or return-path failure from peer silence. This does not weaken the reachability observation. An explicit refusal remains an observed rejection, and successful versus failed family/address attempts remain observed contrasts; neither identifies the ultimate device or policy responsible. Protocol-stage stalls retain medium when a working connection narrows the failure to the later exchange.

A few identities need one named exclusion on the record before they can reach high, because their supporting observation is not by itself the differential. tls_certificate_expired and tls_certificate_not_yet_valid are read against this machine’s own clock, so a clock that is wrong produces either of them from a perfectly good certificate, and netdoc has a separate tls_clock_skew for that case. Unless the run took a clock reading and ruled the skew out, those two stay at medium: a run that never looked must not read like one that looked and settled it.

A not_evaluated item is read by its reason rather than treated as a hole. prerequisite_failed and not_applicable describe checks that did not run because of the failure being diagnosed, so a TLS row skipped behind a dead resolver says nothing about the resolver and never lowers its confidence. not_selected and incomplete are the opposite: an observation that could have separated two explanations was simply not taken, and that caps the claim.

Confidence is descriptive metadata. It is computed last, once counterfactual and route evidence have been attached, and nothing in the engine reads it back: it can never change which finding wins, the ordering, the verdict, the focused row, the remediation, or the exit code.

It appears as confidence on each finding in --json, in the diagnosis block of a .ndoc snapshot (including --support artifacts, where it survives redaction because the vocabulary is closed), and in the TUI under e, above the evidence it qualifies. A snapshot written before the field existed has no confidence, which means the producer recorded no assessment rather than a weak one. New values are not added silently; the vocabulary is closed for this schema.

The four categories are meant to be calibrated against the growing real-world field corpus, which can record an expected confidence beside its expected findings. Today that corpus is small, so the values are a stated policy rather than a measured one, and nothing here claims that high has an established hit rate yet.

Remediation

A finding says what is wrong. Its remediation says what to do about it, as data rather than a sentence to be parsed out of fix.

"remediation": {
  "id": "restore_default_route",
  "action": "Restore a default route",
  "why": "Nothing in the routing table leads off this network, so traffic for the internet has nowhere to go. That is usually DHCP not completing, a VPN that dropped its route, or a static configuration with no gateway.",
  "steps": [
    "Renew the DHCP lease, or reconnect the VPN that installs the route.",
    "On a static configuration, check that the gateway is set and sits on this machine's own subnet."
  ],
  "command": ["ip", "route"],
  "expect": "A default route (0.0.0.0/0 or ::/0) pointing at the gateway."
}

id is a stable identity to branch on, from the table below. action is the next step in one line, why is what makes it the right one given the evidence this run collected, steps breaks it down in the order worth trying, and expect is what success looks like, so a script or a person knows whether a rerun is worth it yet. why, steps, command and expect are omitted when empty, as every other optional field is; id and action are always present.

command is an argv array, never a shell string, and netdoc never runs it. It is offered for you to read and run yourself. Every command in the table is a read-only inspection of local state (routes, links, neighbors, resolvers, the clock, interface MTUs), chosen per OS: Linux gets ip route, macOS netstat -rn, Windows route print -4. None of them changes configuration, none escalates privilege, and none carries the target inside it, because investigating the target itself is what the drill-down tools are for. A remediation with nothing local worth inspecting has no command at all.

The advice is chosen from the finding’s id and, where the focused check classified its failure further, that check’s stable cause. So one conclusion can lead to several remediations: offline with no_default_route says to restore the route, with gateway_unreachable says to get the gateway answering, and with selected_path_failed suggests investigating the gateway and upstream without claiming where the break is. preferred_route_failed suggests measuring both paths before changing route preference. A cause netdoc does not have specific advice for keeps the conclusion’s general answer rather than losing the remediation.

Where netdoc genuinely cannot tell two causes apart, the remediation says what to investigate instead of presenting a guess as fact: narrow_tls_failure lists what is still possible rather than naming one, and rerun_with_egress_check says the run itself was too narrow to blame either end.

In the TUI the action and its command appear in the answer block at the top of the screen, under the plain-language answer and its Technical: line. The rest of the remediation (why, steps and expect) appears in the Details section of the row the diagnosis focuses. R reruns the identical checks against the same target once you have acted on it.

id Next action
bring_up_link Bring a network interface up
sign_in_to_portal Sign in to the network
check_local_path Check this machine’s own path off the network
restore_default_route Restore a default route
reach_gateway Get the default gateway answering
check_router_uplink Check the router’s own uplink
fix_preferred_route Check the interface holding the preferred default route
fix_local_egress_first Fix this machine’s connection before judging the target
use_proxy_path Send the traffic the way this network allows
check_proxy_config Check the configured proxy
check_proxy_reachable Check that the proxy itself is up
check_proxy_resolution Check name resolution on the proxy
check_proxy_egress Check what the proxy is allowed to reach
restore_ipv4_egress Restore IPv4 egress, or accept an IPv6-only path
restore_ipv6_egress Restore IPv6 egress, or accept an IPv4-only path
read_egress_warning Read what the egress row is warning about
recheck_connectivity_endpoint Read what the connectivity endpoint answered
fix_system_resolver Fix or replace the configured resolver
check_the_name Check the name itself
check_name_resolution Check name resolution on this machine
confirm_split_dns Confirm which of the two DNS answers is right
choose_encrypted_dns Decide whether encrypted DNS has to work here
expect_tcp_fallback Expect the TCP fallback, and open UDP/443 if speed matters
start_the_service Start the service, or check the port
test_the_untried_address Test one of the addresses this run never tried
trace_the_path Work out where the packets stop
check_the_device Check the local device itself
rerun_with_egress_check Rerun with the general connectivity check included
lower_path_mtu Try a lower MTU on the path
renew_certificate Renew the certificate, or check this machine’s clock
await_certificate_validity Check the clock, then the certificate’s start date
set_the_clock Set this machine’s clock
match_certificate_name Connect with the name the certificate is for
resolve_untrusted_issuer Find out who signed the certificate
check_tls_path Check the path before blaming the service
check_tls_termination Check what is terminating the handshake
retry_tls_dial Retest, since the TLS check could not open its own connection
narrow_tls_failure Narrow the handshake failure down by hand
check_application_layer Check the service on top of a working connection
check_banner_service Check the service behind the open port
identify_listener Confirm which service is on that port
rerun_full_chain Rerun without the check selection

remediation is additive: it appears alongside the fields findings have always carried, and fix on each check is unchanged. A row’s fix is still the one-line hint that row wrote about itself, with the certificate dates and measurements only that probe held; the remediation is the finished diagnosis’s answer for the run. New id values are added over time, so treat an unrecognized one as “some specific advice” and show action and steps rather than branching on it.

Plain-language answer

The TUI leads a finished run with the same diagnosis retold for a reader with no networking vocabulary: a headline saying whether anything is wrong and which part of the connection it is in, a sentence or two on what that means, and a Try first: step where the evidence supports one. Under it, marked Technical:, is the diagnosis’s own sentence, followed by the remediation, the next tool, and the evidence line. A failing run also names the key that saves a report to send to someone who can help.

It is a view of the finding, not a second diagnosis. It is chosen from the finding’s id alone, with two refinements from the run’s own evidence: a name lookup failure says whether direct egress worked beside it, and points at the connection first when egress failed too. A finding whose confidence is low or insufficient_evidence says so in plain words, and failed checks the finding neither rests on nor explains as consequences are counted rather than hidden. A healthy run says that no obvious problem was found, which is not a promise that every application works. The first step is omitted where nothing is worth trying, such as a working fallback or a fault on the far end.

It is interactive text only. The JSON report, saved reports, snapshots, comparisons and exit codes do not carry it and are unchanged by it. On a terminal too short for both the plain lines and the checks, the plain lines yield first and the headline and the technical answer stay.

Diagnostic snapshots

--save file runs the checks headless and writes a diagnostic snapshot of the finished run, conventionally with a .ndoc extension:

netdoc --save incident.ndoc github.com      # just the artifact
netdoc --json --save incident.ndoc github.com  # the report on stdout too
netdoc --support support.ndoc github.com    # sanitized for sharing

A snapshot is one finished run, recorded so it can be reopened later without probing anything again: the target as you typed it and as netdoc parsed it, the run settings, every check with its status, timings and evidence, and the diagnosis that was drawn from them. It is meant for the failure you cannot reproduce on demand and for comparing two runs. Use --support instead when preparing a file to share.

--save implies headless operation, so it needs no terminal, and it is independent of --json: give both to get the usual report on stdout as well. It cannot be combined with --watch (a snapshot is a finished run, and a watch never finishes) or with --toolbox. The snapshot never changes the diagnosis or the exit code: a run that fails still exits 1 and still saves. A destination whose directory does not exist is rejected with exit 2 before any probe runs, and a write that fails once attempted also exits 2, since the artifact the run was for does not exist. The file is written through a temporary file in the same directory and renamed into place, so a failed write leaves any previous snapshot intact rather than a truncated one.

Snapshot format and versioning

The file is JSON, indented and newline-terminated, so it reads in a pager and diffs line by line. Encoding is deterministic: the same run always produces the same bytes.

{
  "schema": "netdoc.snapshot.v1",
  "created_at": "2026-01-02T03:04:05Z",
  "tool": {"version": "1.2.3", "os": "linux", "arch": "amd64"},
  "target": {"raw": "example.com", "host": "example.com", "port": 443, "protocol": "tls+http", "port_explicit": false},
  "options": {"probe_timeout_ms": 4000, "public_dns": "8.8.8.8", "public_dns_auto": true},
  "checks": [
    {
      "id": "dns", "name": "DNS example.com", "deps": ["iface"],
      "status": "PASS", "ran": true, "duration_ms": 12,
      "detail": "example.com resolved to 2 addresses",
      "observed": {"addresses": ["93.184.216.34", "2606:2800:220:1:248:1893:25c8:1946"], "resolver_targets": ["192.0.2.53:53", "[2001:db8::53]:53"]}
    }
  ],
  "diagnosis": {"verdict": "network", "summary": "…", "blamed": "internet_tcp", "failed_stage": "target_tcp", "findings": [{"id": "local_egress_failure", "verdict": "network", "summary": "…", "focus": "internet_tcp", "evidence": ["internet_tcp", "target_tcp", "dns"], "causal_evidence": [{"kind": "support", "check": "internet_tcp", "observation": "status_downgraded"}]}]},
  "ok": false
}

schema is the first field and the artifact’s identity. A reader must check it before anything else and refuse a schema it does not know, rather than guessing at the fields underneath. The version moves only for a change an older reader would misread, such as a renamed field, a changed unit, or a changed meaning; adding an optional field is not that, so decoders ignore keys they do not recognize and treat every absent optional field as “not known” rather than as zero.

A support snapshot adds "redaction": {"sanitized": true, "policy": "support-v1"}. Absence means a full-fidelity snapshot. Readers use this metadata rather than guessing from placeholder-looking values. The field is an additive v1 extension, so the schema stays netdoc.snapshot.v1 and older v1 files continue to load unchanged.

options records only the settings that decide what the probes did: the per-check timeout as an exact whole-millisecond count, which is the only precision --timeout accepts, so probe_timeout_ms is the run’s own value and not a rounding of it (0 means an artifact that does not record one, which is what a file written before the field existed decodes to), the second-opinion resolver (an IP address, empty when --public-dns "" switched that row off), the --check/--skip selection in the order it was given, and the --iface binding netdoc resolved. public_dns_auto is present and true when the run named no resolver, which is what let that row fall back to the other address family; it is an additive v1 field, absent when the resolver was named, so public_dns keeps the address-or-empty meaning every v1 reader already has. check, skip, and source are absent when the run used none of them. Flags that only affect presentation are not recorded, because a comparison never reads them.

Every check the run built appears in checks, including the ones that never produced an answer, and status says on its own which is which. It is always present and never empty. Alongside the PASS, WARN, FAIL, SKIP, and N/A used everywhere else, there is one more value that no probe can return, the same one JSON output uses:

  • SKIP is a check deliberately left out, because a prerequisite failed.
  • INCOMPLETE is a check that never reported at all, which is what a cancelled or interrupted run leaves behind. Nothing decided to leave it out; the run ended first.

An INCOMPLETE row is never a pass, and a run holding one is never "ok": true. That is enforced when the file is written rather than left to readers to remember, so a snapshot in which absent evidence could be read as a passing check does not exist.

The rest of a row’s identity and the run’s outcome are enforced the same way, and by the reader as well as the writer, so a hand-edited file is refused rather than read as something no run produced. Every check carries a non-empty id, no id appears twice, and status is one of the six values above and nothing else. ok is not an opinion the file holds beside its rows: it is true exactly when no row is FAIL and none is INCOMPLETE, so a run cannot call itself clean beside a failure or call itself broken with nothing in it that broke. Duplicate check ids matter most of the set, because different readers resolve them differently, and a file that can be read as two different runs is not evidence of either.

deps is the dependency graph the run executed, recorded per row so a reader can rebuild it without a live probe list, and it is enforced as a graph. Every entry names a check in the same file, no row lists the same dependency twice, and no row waits on itself, directly or through the rows it waits on: a probe runs only once everything it waits on has a result, so none of those could have been scheduled. The order of checks is the order the graph was built, and it is deliberately not required to be topological, because a row may name a dependency that appears further down the list and still be a row that ran. A dependency naming a check this build has never heard of is additive vocabulary, not a broken file, the same as a finding id.

ran answers a narrower question, and only that one: did the probe body execute. A skipped row and an incomplete row both report ran: false, so ran is not how a reader tells them apart; status is.

Both readers hold ran to the status beside it, because the two are not independent facts about a run. A PASS, WARN, or FAIL row carries an outcome a probe body measured, so it ran. A SKIP row was recorded when a prerequisite failed and an INCOMPLETE row was never reached at all, so neither did. N/A is deliberately outside the rule: a probe reports it from inside its own body after looking, and a netdoc that ruled a row inapplicable before calling it would still be writing a valid v1 file. A row that did not run also reports duration_ms: 0, because ran is that duration read as a yes or no. The reverse is not required, since a probe body faster than the unit can round to zero.

dns_name_scope on a JSON public-DNS row, and observed.dns_name_scope in a snapshot, records absolute or rooted_relative. Absence means unknown legacy query-name provenance, not a newly rooted relative lookup. The field contains no hostname and survives support redaction. Replay suppresses comparisons and comparison-derived warnings for relative or unknown scope, while preserving standalone observations. An unsanitized legacy target ending in a root dot can establish absolute scope; redacted target spelling cannot. Historical stored diagnosis text is unchanged and is never replay evidence.

observed.resolver_targets holds the same attempted DNS service addresses as the JSON report. It is additive and optional, so an older snapshot with no field makes no resolver-provenance claim. observed.routes holds the same per-destination route decisions the JSON report documents above, in the same shape and with the same rule that an absent field is unknown rather than zero. It is an additive optional field: a snapshot written before it existed simply has none, and every reader treats that as “this run recorded no route decisions”, never as “there was no route”. observed.connect_cleartext mirrors the JSON report’s connect_cleartext: true on a working proxy_connect row whose environment proxy used a plaintext HTTP CONNECT, and absent otherwise. It is additive and optional in the same way, it survives --support sanitization because a boolean names nobody, and an absent field never means a TLS hop to the proxy was confirmed or that no cleartext hostname was sent. Nothing stores a routing table; the entries are the answers to the bounded set of lookups the run already had to make.

Within a check, observed is what the probe measured and derived is what the cross-probe reasoning pass concluded about that row afterwards. Two fields live under derived. status_downgraded marks an observed egress failure that was relaxed to a Warn because another path carried traffic off this network on the same run: the endpoint under test answered a direct connection at a public address, or the environment proxy tunnelled. Its presence says the row’s status is a conclusion rather than a reading. A destination reached without leaving the network, such as a device on the LAN or a peer in 100.64.0.0/10, never earns one, and neither does the resolver answering. answer_comparison is on the dns_public row and records what that pass concluded when it compared the two resolvers’ answers: agree or disagree, and absent when there was nothing to compare. It is stored because the comparison is made from the addresses the resolvers returned, and a --support artifact holds only pseudonyms of those, so a reader that recomputed it would be asking a different question than the run did. Absent never means the answers agreed, and an artifact written before the field existed has none. A row can therefore carry derived without its status having been rewritten. cause, verdict, and the finding IDs use exactly the vocabularies documented for JSON output and diagnosis findings.

The diagnosis has to resolve against those rows. Every finding carries a non-empty id, no id appears twice, and a non-empty blamed, failed_stage, finding focus, or evidence entry names a check that is actually in the file. failed_stage is derived the way ok is: it is the first FAIL row in run order, and it is absent exactly when no row failed. Where a finding carries both evidence and causal_evidence, the first has to be the compatibility projection of the second, meaning the unique observed check ids in reasoning order with the not_evaluated entries left out, so an old consumer and a new one cannot be told different things about what a conclusion rests on. A snapshot written before typed evidence existed carries only evidence and is not asked to invent the rest. A finding id this build has never heard of is additive vocabulary, not a broken file, and is not refused. A support, contradiction, or ruled_out entry is a claim about what a row observed, so it has to name a row that ran: a status and an observation can still line up on a row whose probe body never executed, and a claim built from that would rest on nothing measured. The not_evaluated entries are the exception and keep their own meaning, because saying a row was never evaluated is the one claim that does name an unmeasured row.

Findings also persist their optional causal_evidence and counterfactual fields. They record the interpretation made during the incident rather than asking a future Network Doctor version to reinterpret old observations. Attempt cause and aborted fields retain whether an address supplied usable failure evidence. These are additive v1 fields, so existing .ndoc files remain loadable and the schema stays netdoc.snapshot.v1. Loading an older file without the fields leaves them absent; the decoder never invents historical relationships. New files validate that every observed relationship points to a matching check fact, that counterfactual alternatives reference evidence the finding carries, that not-evaluated reasons match the check state, and that no item is duplicated.

An .ndoc saved from the Watch incident viewer adds an optional incident field to the same v1 snapshot. The root snapshot is the first failing pass. The incident holds its start and optional recovery time, the number of failing passes, the last working or degraded run before onset when one exists, the latest distinct failing run when the failure evolved, and the first recovered run. These nested states are complete canonical snapshots. Differences are not stored beside them; readers derive onset and recovery changes with the normal snapshot comparison rules, so stored answers cannot drift from stored evidence.

The incident field is an additive v1 extension, so the schema remains netdoc.snapshot.v1. Older files continue to load with no incident information, and no reader may infer history that was never recorded. New incident artifacts validate UTC RFC 3339 timestamps, chronology, failing and non-failing state roles, and the one-level nesting bound. An active incident has no recovery time or recovery state. Incident export remains bounded to the onset plus at most the pre-failure, latest distinct failing, and recovered snapshots.

Remediation text is deliberately not stored. Fix advice lives on the check rows as fix, and the structured next action is regenerable from a finding’s id together with tool.os, so a snapshot does not carry a second copy to fall out of step.

What a snapshot contains, and what it does not

A snapshot holds only what the run already gathered. Nothing is collected for the file’s benefit: no packet capture, no scan of unrelated interfaces or routes, no environment variables, no configuration files, and nothing is uploaded anywhere.

Treat the file as network information about the machine that produced it. It deliberately retains, because a diagnosis is not readable without them:

  • resolved IP addresses, the address netdoc selected, and per-attempt errors
  • the local source address and interface name probes used
  • the connected Wi-Fi network name (SSID), on the ssid row
  • the target hostname as typed, which may be a private or internal name
  • the proxy host and port from HTTPS_PROXY/ALL_PROXY, where one applies to HTTPS
  • the second-opinion resolver, and the local clock’s offset from a connectivity endpoint’s, which is read only from a response that matched what that endpoint documents
  • per-destination route decisions under observed.routes: the interface chosen, the next hop, the local source address, the matched prefix, the routing table’s name and whether the platform named one, and the interfaces of any competing default routes. These describe the local network’s shape, and a tunnel’s device kind says a VPN is in use

It contains no credentials. Userinfo in a target is rejected by the parser before a run starts, and the proxy rows record only the proxy’s host and port, never the username or password from a proxy URL. Probe text is sanitized on the way out of the runner, so a hostile hostname or server banner lands in the file as inert bytes.

Support snapshots

--support file runs the same headless diagnosis as --save, then applies the support-v1 policy to the structured snapshot before the normal .ndoc encoder writes it:

netdoc --support support.ndoc github.com
jq '.redaction, .target, .checks' support.ndoc

Creation is local-only. Network Doctor does not upload, submit, encrypt, or send the file. The user chooses whether and how to share it. --support can be combined with --json, but not with --save, --watch, --toolbox, or peer mode. It prints one confirmation on standard error after the sanitized file is written. The policy applies to the file rather than to the stream: a combined --json run still prints the ordinary full-fidelity report on standard output, because that stream is live output for scripts rather than something prepared for sharing. Share the .ndoc, not a captured report.

The policy sanitizes the target spelling and hostname, Wi-Fi SSIDs, interface and custom route-table names, local names and search domains found in text, filesystem paths, URL hosts, paths, queries and userinfo, credential-bearing headers and values, private-key blocks, and certificate names found in derived text. The machine’s own hostname and account name are pseudonymized too. Those two are in no field of the format and have no shape a pattern could match, so they are read from the system and registered before the snapshot is walked; a machine answering to both buildbox.corp and buildbox gets one alias for the two, and a name that identifies a role rather than a person or a machine (root, localhost, admin) is left alone. It applies to check names, details, fixes, attempt errors, diagnosis and finding summaries, captive-portal URLs, and every snapshot nested in an incident. Passwords, bearer or basic credentials, cookies, proxy credentials, tokens, and private keys are replaced rather than copied. A credential value is read to its closing quote or bracket, then on to the next space. Where the usual quoting conventions disagree about where a quoted value ends, as a doubled quote or a backslash before a quote can make them, the later end is used, so a few words after such a value can be redacted with it.

Causal-evidence values and counterfactual alternative values are not rewritten as text. Each one is whatever the observation or counterfactual variable beside it declares it to be, and that declaration decides the namespace it is replaced in, never the value’s own spelling. An address-valued observation (dns_answers, address_succeeded, address_failed, route_unreachable, route_next_hop_differs) and a resolved_address alternative are replaced in the address namespace. An interface-valued observation (route_tunneled, route_direct, route_path_differs, route_interface_mtu) is replaced in the interface namespace, and stays there even when the operating system named that device something that reads as an IP address. One value is one pseudonym throughout the artifact, so an evidence value that also appears in a recorded row reads the same on both. A value need not appear on the row the evidence cites: route_next_hop_differs and route_path_differs name the other next hop or the other path, and each of those is aliased in its own namespace whether or not the artifact records it anywhere else. An address family, and a dns_resolver or address_family alternative, stays the word it was, because each is a closed vocabulary the format defines rather than anything the local network chose. An observation or variable this build does not know, and a value outside a vocabulary that build does define, is aliased: nothing here can prove such a string carries no identity, so it is not published. This is what keeps a sanitized artifact checkable, since evidence whose value no longer matches the row it names is refused by the encoder.

Pseudonyms are assigned in stable traversal order within one artifact. The same hostname, SSID, interface, route table, path, address, or prefix therefore gets the same alias everywhere in that file, including incident before, during, and recovered states. Different originals get different aliases. No original-to- alias map, salt, key, or reversible secret is stored, and aliases are not meant to be stable across separately created support files. That last point is a property readers have to honor rather than a caveat they may weigh: --compare and --two-sided treat a pseudonym as a name owned by its own artifact, and neither claims an identity from one. See pseudonyms are per artifact.

IP addresses follow an explicit policy:

  • Public second-opinion DNS resolver addresses remain exact because resolver choice is diagnostic evidence and does not identify the local endpoint.
  • All other IPv4 and IPv6 addresses are replaced with valid reserved pseudonyms. Separate ranges preserve whether an original was loopback, link-local, private, multicast, or public. Equality is preserved, so a gateway that is also a resolver or an address repeated across DNS, attempts, routes, and incident states still compares as one host.
  • Route prefixes receive distinct repeatable pseudonyms. Default routes remain 0.0.0.0/0 or ::/0. Prefix lengths of at least /16 for IPv4 and /48 for IPv6 are retained; broader prefixes are narrowed to those bounds so distinct aliases do not collapse.
  • Collision searches are bounded to 65,536 attempts. If no safe address alias is found, the address is erased as <address-redacted> in structured observations and text. Erasure does not assert address identity or locality; replay rejects erased endpoint evidence. Literal targets, configured DNS resolvers, and source bindings cannot carry this marker, so saving fails validation instead. An exhausted route-prefix namespace omits the optional prefix. Successful aliases still preserve class, equality, and distinctness.
  • Ports, address-family results, route selection reasons, tunnel state and device kind, MTU, metrics, and whether routes or paths agree remain intact.
  • A field the format declares as an address that does not hold one is written as <address-redacted> rather than passed to the text pass. The text pass keeps what it does not recognize, which is right for a sentence and wrong for a field that should have held an address.

The sanitizer constructs a new snapshot field by field. New schema fields are therefore omitted from support output until they are deliberately classified, rather than inheriting full-fidelity behavior by accident. Ordinary --save artifacts and live output are not changed.

That classification is enforced rather than remembered. A test walks the schema by reflection, puts a distinct sentinel in every string it declares, sanitizes, and fails on any sentinel that survives serialization without being listed in one of two sets: the fields kept verbatim because they are a closed vocabulary, a build fact, or a timestamp, and the fields that hold a human sentence and are rewritten by pattern. A field added to the format later belongs to neither set, so it fails the test until somebody decides which it is.

To see that a file was prepared for sharing, read its redaction object, or hand it to --compare, which names each side as full or sanitized:

jq .redaction support.ndoc
netdoc --compare support.ndoc support.ndoc

Support snapshots intentionally retain exact capture times, Network Doctor version, operating system and architecture, selected probe IDs, ports, durations, statuses, causes, findings, and the broad network relationships above. They remain loadable and comparable like normal .ndoc files. The file is readable JSON, so inspect it with less, an editor, or jq before sharing when working in a particularly sensitive environment. Free-form probe errors can originate in operating-system and network libraries; support-v1 covers known identifiers plus credential, URL, path, address, hostname, and certificate patterns, but cannot assign meaning to an arbitrary opaque string that carries no identifying syntax. That includes a word a space has already separated from its credential key, such as the second word of an unquoted passphrase. The one such word a library is known to quote on its own, the status code Go’s HTTP client reads from a malformed status line, is replaced in the error that quotes it.

Comparing two snapshots

--compare reads two saved snapshots and reports what changed between them. The two files are arguments, the earlier or known-good run first:

netdoc --compare good.ndoc bad.ndoc         # the table
netdoc --compare --json good.ndoc bad.ndoc  # the same comparison, machine-readable

It runs no probes and opens no socket. Everything reported comes out of the two artifacts, so a comparison works long after the fact and on a machine that has never seen either network. It is headless, needs no terminal, and cannot be combined with any flag that describes a run netdoc would perform (--toolbox, --watch, --save, --support, --check, --skip, --no-reference-egress, --iface, --public-dns, --no-history, --keys, --timeout, and either peer flag). Those settings are already recorded in the files.

Exit 0 means the two snapshots describe the same diagnostic state, 1 means they differ, and 2 means an argument or an artifact was unusable. A snapshot that records a failed run is not itself an error here: the question --compare answers is whether anything moved, not whether the network is healthy.

What counts as a difference

The comparison is semantic, not a JSON diff. Every field it reads is named in the implementation on purpose, which is what lets it ignore the parts of a snapshot that change on their own, and what decides which collections are ordered:

Compared How
Target host, ip, port, protocol, and port_explicit, then raw on its own, so “the same host, entered differently” and “a different host” are separate answers
Tool version, os, and arch, because a diagnosis and its advice are chosen per platform
Run settings the probe timeout, the second-opinion resolver, the --iface binding, and the --check/--skip selection as sets
Diagnosis ok, verdict, blamed, failed_stage, the findings as a set keyed by id, the first finding as the primary conclusion, and within each finding its verdict, focus, confidence, ordered evidence and causal_evidence, and its counterfactual: whether the finding carries one at all, the variable it held, and the alternatives with their values, outcomes and evidence
Checks membership, status, cause, ran, and deps in order
Reasoning derived.status_downgraded, so an inferred outcome and a measured one are not read as the same state, and derived.answer_comparison, so two runs whose resolvers went from agreeing to disagreeing are not read as the same state either
Evidence everything under observed: resolved addresses, resolver targets tried, and connection attempts as sets, and the selected address, source address, interface, SSID, second-opinion resolver, per-family reachability, captive-portal state, and timeout flags as values
Routes observed.routes keyed by destination address, so a destination present on one side only is reported as such rather than as a changed path; then that entry’s matched prefix, interface, next hop, source, metric, table, tunnel state and kind, selection reason, interface MTU, and competing routes
Paths the derived reading of those routes: the target’s interface, matched route, selection reason and tunnel state, the general Internet interface, every resolver target interface, and whether all DNS and application paths agree

The paths section is derived from the routes both snapshots already carry rather than stored a second time, so the two can never describe different runs, and two files written by different netdoc versions are read the same way. It answers the question a person asks first, in normalized words rather than an operating system’s route syntax:

target interface changed from wlan0 to wg0
target matched route changed from 0.0.0.0/0 to 10.20.0.0/16
target route selection changed from same_path_as_default to differs_from_default_path
target tunnel state changed from direct to tunnel
DNS and application traffic changed from same path to different paths

The matched route is reported only where both snapshots recorded one, which on Linux is neither of them. The line above it carries the same news there, in the weaker words that platform can support: a destination that stops taking the way out general traffic takes is a changed selection whether or not the entry behind it can be named.

A split between DNS and application traffic is reported as a difference and nothing more. Split DNS is a design as often as it is a fault, and the comparison says what moved, never whose fault it is.

Absence is never collapsed into a zero. SKIP, N/A, and INCOMPLETE stay three different things that happened to a row. A resolver that answered with no records is not the same as one that did not answer. A captive portal that advertised no sign-in URL is not the same as no portal, so interception is its own field rather than something inferred from an empty URL. A second opinion switched off with --public-dns "" is reported as a removal, not as a change to nothing.

What is intentionally ignored

These change between two runs of a machine that did not change, so treating them as differences would bury the ones that matter:

  • created_at, the capture time. It is shown in the header of the report as context, and is never a difference.
  • duration_ms on a check and on a connection attempt. Both are the measurement itself.
  • detail and fix on a check, summary on the diagnosis and on a finding. All four are derived sentences that quote measurements (“in 41ms”), and the format documents them as never parsed back. status and cause are the machine-readable form of what they say, and those are compared.
  • name on a check, which is display text built from the probe and the target.
  • The order of resolved addresses, connection attempts, findings, and the --check/--skip selection. Order there is the resolver’s, the dialer’s, or the order you typed, and it is not the shape of anything. Order is kept where it carries meaning: a check’s deps, a finding’s evidence, and its causal_evidence are compared as ordered lists.
  • Sub-second movement in clock_offset_ms. The offset is compared at whole-second resolution, which keeps the sign and the magnitude the diagnosis reasons about and drops the jitter of a clock that is fine.

An optional field added to a future snapshot version is not compared until it is named here, which is deliberate: a new field cannot start producing differences on its own. For the diagnosis, where a silent omission would let two contradictory conclusions compare as one, that decision is forced rather than left to notice: a parity test enumerates every field of a finding, its causal evidence and its counterfactual, and fails until each one is listed as compared or as ignored prose, with the classification checked against the comparison rather than taken on trust.

Structured values are compared structurally. A causal-evidence row names the observation member it cites, which is routinely an IPv6 address, so joining its fields into one string with a separator would let two different rows compare equal wherever the separator can fall inside a value.

Ordering and determinism

The same two files always produce the same bytes and the same text. Nothing is sorted after the fact and no map decides what gets printed: differences come out in section order (target, tool, run settings, diagnosis, checks), and within the check section in the order the later run executed its probes, followed by any check only the earlier run had, in that run’s order. Set membership is the one thing sorted, so that a resolver’s answer order cannot reach the output.

Version compatibility

Both files go through the same decoder every other reader uses, so the schema rule is stated once. A file whose schema is not the one this build reads, including a future version, is refused by name with exit 2 rather than half understood, and so is a file that is not JSON or that holds a check row with no status. There is one schema version today, so there is no cross-version comparison to perform; when there is one, it will be the decoder’s migration, not a second copy of the rules inside --compare.

Different targets

Comparing snapshots of two different endpoints is allowed. A snapshot keeps the typed spelling next to the parsed host precisely so a comparison can tell “the same host, entered differently” from “a different host”, and refusing the second case would throw away an answer it is equipped to give. It is not allowed to be quiet about it: when the two runs did not observe the same endpoint, the report says so above the table, because every row underneath then describes two different things. The machine-readable form carries the same fact as same_target. A generic run against a targeted one is reported as one change rather than as a field-by-field list of a target only one side ever had.

Identity is read semantically, not spelled. Two runs name one endpoint when their port and protocol agree and their hosts are the same name: a hostname is matched the way DNS reads it, ignoring case and a terminal root dot, so example.com, EXAMPLE.COM and example.com. are one target, and an IP literal is matched as an address, so the compressed and expanded spellings of one IPv6 address are one target too. An address family is not a spelling, so an IPv4 address and its IPv4-mapped IPv6 form stay two targets. The spellings themselves are still reported as changes, since what each run recorded is a real difference between the two files; it is same_target that reads through them.

There is a third answer, and it belongs to support artifacts: a pair where both runs named a target and at least one of the two names is a pseudonym establishes neither. same_target is false, and the report says the identity is unestablished rather than claiming two endpoints. See pseudonyms are per artifact.

That third answer is reached only when the rest of the target agrees. Sanitization records the port, the protocol, whether the run had a target at all, and whether that target was an IP literal rather than a name, all verbatim. If any of those differ the two endpoints differ, and the pair is reported and refused as different targets even though both files are sanitized.

Interpretation

The report states direct facts about the two readings and stops there. “The DNS resolver changed from X to Y”, “the DNS check changed from PASS to FAIL”, “traffic used wg0 instead of wlan0” are all statements about what the files say. --compare does not claim that one of them caused another. A status move between PASS, WARN, and FAIL is marked better or worse, which is a comparison of two outcomes and not a cause; SKIP, N/A, and INCOMPLETE have no rank, so a move to or from one of them carries no direction rather than a guessed one.

Machine-readable comparison

--json prints the comparison as its own versioned document, separate from the snapshot schema it reads because scripts consume it:

{
  "schema": "netdoc.comparison.v1",
  "before": {"created_at": "2026-03-04T05:06:07Z", "tool": {"version": "1.2.3", "os": "linux", "arch": "amd64"},
             "target": "github.com:443 tls+http", "verdict": "ok", "summary": "…", "ok": true},
  "after":  {"created_at": "2026-03-04T18:22:41Z", "tool": {"version": "1.2.3", "os": "linux", "arch": "amd64"},
             "target": "github.com:443 tls+http", "verdict": "dns", "summary": "…", "ok": false},
  "same_target": true,
  "checks": [
    {"id": "iface", "before": "PASS", "after": "PASS", "kind": "unchanged", "differs": true},
    {"id": "dns", "before": "PASS", "after": "FAIL", "kind": "changed", "direction": "worse", "differs": true}
  ],
  "changes": [
    {"section": "diagnosis", "path": "diagnosis.verdict", "label": "verdict", "kind": "changed",
     "before": "ok", "after": "dns", "summary": "verdict changed from ok to dns"},
    {"section": "check", "check": "iface", "path": "checks.iface.observed.interface",
     "label": "iface interface", "kind": "changed", "before": "wlan0", "after": "wg0",
     "summary": "iface interface changed from wlan0 to wg0"}
  ],
  "caveats": []
}

path is a difference’s stable identity, spelled as the field path inside the snapshot, so two runs name the same difference the same way and a script can key on it. kind is added, removed, or changed, and it is what says whether an empty before or after is an absent value: an added change always has an empty before and a non-empty after, and changed has neither empty. Both keys are always present, so an empty string is a value rather than a key a reader has to interpret.

checks is every check in either snapshot, unchanged rows included, so the same document answers “what stayed the same”. Its kind is about the status alone, while differs is true whenever anything about that check moved, including evidence underneath a status that held. direction is better or worse where the outcomes have an ordering, and absent otherwise.

A side carries "sanitized": true when that artifact was written by --support, read from the file’s own redaction metadata rather than guessed from values that look like placeholders. The key is absent on a full-fidelity snapshot. The text report grows a Fidelity row saying full or sanitized for each side, but only when one of them is: on two ordinary snapshots it would say the same word twice on every comparison anybody ever runs. Comparing two support artifacts is meaningful for everything redaction leaves alone, and comparing a support artifact against a full-fidelity one is not, for the names and addresses redaction replaces: the same host is a hostname on one side and a pseudonym on the other, so those values may differ for that reason alone and a difference among them is not an identity change. Values redaction leaves alone, including an address it retains, still compare as themselves.

caveats states what the rows are worth, and is prose for a person that is never parsed back, the same rule the two-sided reading’s caveats follow. It is empty on a full-fidelity pair. When either side is sanitized it names the one limit that applies to every value below at once: pseudonyms are per artifact. The text report prints the same sentences last, under Caveats:.

Pseudonyms are per artifact

--support builds a fresh pseudonym mapping for each artifact it writes, which is the property that makes a support snapshot safe to hand over: nothing in the file is a stable identifier, and two files cannot be joined on one. The cost is that an alias is a name inside its own artifact and nowhere else. Two files sanitized separately can give one endpoint two aliases, and can give two unrelated endpoints the same alias, because the counters restart per file.

So across two separately sanitized artifacts, for every value redaction replaces (target host and IP literal, resolved and selected addresses, resolvers and resolver targets, connection-attempt addresses, route destinations, gateways, sources, prefixes and table names, interface names, SSIDs, portal URLs, and the derived paths read off those routes), a match establishes nothing and a mismatch establishes nothing. netdoc will not claim otherwise, and there is deliberately no way to make it: a stable hash or a shared secret-free identifier would be the joinable identity --support exists not to write.

What survives sanitization as itself keeps its full meaning across two artifacts, including as evidence that two endpoints are different: two sanitized artifacts whose targets differ in port, in protocol, in presence, or in name-versus-IP-literal shape are reported as different targets, not as unestablished. It is most of what a reading rests on: statuses, causes and cause families, the target’s port and protocol, whether a run had a target or a source binding at all, address families, timeouts, clock offsets, route metrics and link MTUs, tunnel states, portal presence, verdicts and finding IDs, and the derived conclusions the run recorded because they could not be recomputed from pseudonyms. That is why comparing two support artifacts is still worth doing.

One artifact is one redaction pass, so values inside it keep their full meaning against each other: a profile’s components and an incident’s before, during and recovered phases are one vocabulary and are read as one.

--compare and --two-sided take two files and cannot tell one file read twice from two files whose aliases agree, because a support artifact deliberately carries no identifier that would say. So netdoc --compare run.ndoc run.ndoc on a support artifact reports every row and every check as usual, finds no differences, and exits 0, and it still withholds same_target. That is the conservative end of the same rule: the alternative is a claim the files do not carry, and giving them one would give every other pair one too.

An empty changes array is the machine-readable form of “no meaningful differences”, and it is what exit 0 means.

What a comparison cannot tell you yet

A snapshot holds only what the run gathered, so a comparison can only report what is in there. Today that leaves some questions unanswerable from two .ndoc files:

  • Answer provenance. The snapshot records each resolver target Go tried (observed.resolver_targets), but the resolver API does not reveal which target returned a response or supplied a particular A or AAAA address, nor whether any of them did: where the host resolves hosts: dns files, an answer that no server supplied can still follow a query. A single recorded target is therefore an attempt like any other. The second-opinion row is the one place this had to be settled rather than recorded, because a dns_public answer is spent as proof that an independent resolver resolved the name: that row resolves the name a second time with every DNS dial refused, and reports N/A instead of an answer whenever the machine can answer without DNS. So dns_public addresses are the public resolver’s, while dns addresses are whatever this machine resolves the name to, from whichever source.
  • Routes. Route decisions are captured and compared field by field: observed.routes holds what the operating system answered for each destination a check looked up, and the comparison reads the interface, next hop, source address, matched prefix, metric, routing table and whether one was named, tunnel state and kind, selection reason, interface MTU, competing defaults, and whether the kernel had no route at all. What is not captured is the routing table. Only the destinations the run already had to look up are in there, so a route change that leaves every one of those decisions unchanged is invisible, and a new or withdrawn route elsewhere in the table is never seen as such. Entries are matched by destination address, so two runs that resolved a name to different addresses report an added route and a removed one rather than a single route that moved. A field the platform never supplies is absent on both sides and compares as nothing: Linux names no matched prefix, and competing default routes come from Windows alone, so the same change carries less detail on some machines than on others.
  • The proxy. The proxy host and port appear in the proxy row’s detail, which is derived text and not compared. A proxy change registers as that row’s status or cause moving, not as the proxy itself.
  • Path MTU. The path-MTU row records its outcome, not a measured MTU number, so there is no MTU value to compare.
  • VPN and tunnel state as such. There is a tunnel field. Each route entry carries tunnel and tunnel_kind, both are compared, and the paths section compares the target’s tunnel state on its own, so traffic moving onto or off an encapsulating interface does register. What is missing is the VPN itself: tunnel_kind is the operating system’s name for the device kind, such as wireguard, tun, or gre, and never a product, a profile, a server, or a tunnel’s far end. netdoc keeps no list of VPN product names, so one that installs an ordinary Ethernet device is not classified at all. Absent means nothing classified the interface rather than direct, so a run on a platform that classified nothing and a run that did differ in that field, which is a difference in what was observed and not in the path.

Adding fields to the snapshot for the sake of a fuller comparison is deliberately not done here. Each one is a change to a published format, and a comparison is worth more trustworthy than complete.

Two-sided diagnosis

--two-sided asks which vantage point observed a failure. It has two ways to obtain the same pair of canonical snapshots.

The live form starts the same ordinary diagnosis on this machine and an SSH-accessible machine, as close together in time as practical, then applies the existing two-sided reading:

netdoc --two-sided --via other-host github.com
netdoc --two-sided --via other-host --json github.com
netdoc --two-sided --via other-host                    # two generic diagnoses

Side A is always the local run. Side B is always the --via run. Human output labels them LOCAL (SIDE A) and with the sanitized SSH destination as side B. Labels are presentation metadata and never affect localization.

The artifact form remains unchanged:

netdoc --save here.ndoc github.com                     # this machine
netdoc --via other-host --save there.ndoc github.com   # the other machine
netdoc --two-sided here.ndoc there.ndoc                # where is it broken?

The two files are arguments, this machine’s run first. This form runs no probes and opens no socket: both machines already did their probing, and this is the reading of what they recorded. The second file can come from anywhere a snapshot comes from, whether that is --via over SSH or a run somebody did by hand and sent you, so the reading works even when the other machine is one you cannot log in to.

Live acquisition does not introduce another report or reasoning model. The local run builds its ordinary report and netdoc.snapshot.v1 artifact through the normal path. The remote worker returns the same two canonical artifacts over remote protocol version 1. Network Doctor passes those snapshots directly to compare.TwoSidedSnapshots; it never parses rendered prose. The two runs start concurrently so changing network conditions have less time to weaken the differential. They retain independent probe dependency graphs and bounded timeouts.

Exit 0 means no comparable check failed on either machine, 1 means a failure was placed or the evidence could not place one, and 2 means an argument or artifact was unusable, the effective targets differed, or live remote acquisition failed. A remote diagnostic FAIL is a completed measurement and is localized normally; it is not confused with an SSH or protocol failure. Both forms are headless and need no terminal.

Flags in live two-sided mode

The artifact form accepts only --json and the optional --route-a and --route-b topology files, because every other run setting is already recorded in its two files. The route files are described under Route context from topology files. The live compatibility decisions below are enforced before either diagnosis starts:

Flag Live behavior
--via HOST Required to select live acquisition. With two file arguments, it is rejected with an instruction to remove --via rather than guessed to be an artifact reading.
--json Accepted. Emits the existing netdoc.twosided.v1 document, not either ordinary report.
--check, --skip Accepted. The same validated probe selection and dependency rules apply locally and remotely.
--no-reference-egress Accepted. Both runs come from the same settings, so neither side contacts a built-in reference service.
--timeout Accepted. The same positive whole-millisecond per-probe timeout is sent to both runs.
--public-dns Accepted. The same validated resolver IP, including an empty value that disables the row, applies to both runs. Left unset, each machine chooses its own automatic fallback family.
--iface Rejected. One local interface name or source IP cannot honestly identify corresponding interfaces on two different machines. Use two separately saved snapshots when each side needs its own binding; the offline reading reports a differing binding as a caveat.
--profile Rejected. A profile is a multi-target plan, while two-sided localization requires one equivalent target per vantage point.
--save, --support Rejected. Each names one output path and neither can represent the two input snapshots without inventing a new artifact contract. Save or sanitize two ordinary runs separately and use the artifact form instead.
--watch Rejected. Watch is a repeated session, not one matched pair of completed runs.
--compare Rejected. Comparison asks what changed between saved states; two-sided diagnosis asks where a same-target differential appears.
--peer-listen, --peer-connect Rejected. Peer mode measures direct authenticated traffic between Network Doctor endpoints, not their independent paths to an external target.
--route-a, --route-b Rejected. Route context binds to the two saved snapshots of the offline form, and the live form has no topology input.
--toolbox, --no-history, --keys Rejected. They belong to the interactive TUI and have no meaning in this headless orchestration path.

--help and --version retain their normal immediate behavior. A user interrupt cancels both acquisitions and exits 1. A remote SSH, worker, or protocol error cancels the local run, emits no two-sided result, and exits 2; cancelling the SSH process also closes the worker channel so remote probes do not continue unnecessarily.

Why it is a separate command from --compare

The two readings draw opposite conclusions from the same row moving. A check that is PASS in one file and FAIL in the other is a change over time to a comparison and a difference between two vantage points to a localization, and a reader has to have said which they meant. --compare deliberately never claims that one reading caused another; --two-sided exists to say which side the evidence puts a failure on, which is a claim, so the two are separate commands with separate schemas and separate exit-code meanings.

There is one rule --two-sided has that --compare does not: two snapshots of different targets are refused with exit 2. A comparison of two endpoints is a question with an answer, and a localization across two endpoints is not, because a row that failed against one host and passed against another says nothing about which machine is at fault. Two generic runs with no target are one question asked from two places, so those are read.

Target matching compares the logical host, port and protocol. Identical or overlapping DNS answers do not prove identical endpoint selection; disjoint answers can fully explain differing outcomes. Missing DNS evidence proves neither agreement nor disagreement. Even an IP literal or a common contacted address leaves endpoint policy, backend selection and capture-time changes possible. side locates the observation, never excludes an endpoint cause.

Support artifacts have a rule beside it. Sanitization renames the target, and the new name belongs to the one file that assigned it, so a pair where both runs named a target and either side is sanitized cannot show the one endpoint this reading is premised on. That pair is refused with exit 2 as well, and the refusal says the identity is unestablished rather than claiming two endpoints, because pseudonyms are per artifact in both directions: two files agreeing on a name is not evidence that the endpoints agree, and two files disagreeing is not evidence that they differ.

Two generic runs have no target to rename, so a support artifact reaches the localization there and is read with a caveat. Everywhere else, a two-sided reading needs two full-fidelity snapshots, and --compare is what reads a sanitized pair.

What it reads, and what it refuses to read

Only rows both machines measured are read. PASS, WARN, and FAIL are measured outcomes; SKIP, N/A, and INCOMPLETE are three different reasons nothing was measured, and none of them is evidence about a machine. A check present in only one file is not comparable either. Every unread row is marked comparable: false and counted in the caveats, so a reading never quietly rests on a row that was not there.

WARN is a row that worked, the same reading the snapshot’s own ok field and netdoc’s exit code take. A warned row is not a failure to place.

Placements

ID side What the evidence proves
two_sided_no_failure none no check that both machines measured failed on either of them
two_sided_one_side_fails a or b every failed check passes from the other machine; failure was observed only from this vantage, without locating its cause
two_sided_one_side_fails_more a or b some checks fail from both machines and one has additional observed failures; their causes remain open
two_sided_shared_failure shared every failed check fails from both machines, which places it on neither one in particular
two_sided_divergent_failures both each machine fails checks the other passes, which is two findings rather than one failure to place
two_sided_no_comparable_checks unknown no check produced a measured outcome on both machines

What a placement does and does not prove

“Observed only from side A” identifies a difference in outcomes. Side A’s own network state, its path, a different selected endpoint, and endpoint policy can all explain it, so placement does not choose among them. Every placement except an unqualified pass carries ambiguous: true.

It never claims a firewall, router, NAT, VPN, or host is responsible. Two snapshots cannot establish that: they record what each machine observed, not what any device in between did. A --two-sided reading is a statement about which machine, never about which box.

It also cannot see anything the snapshots do not carry, which is the same list as what a comparison cannot tell you yet.

Caveats

Conditions that weaken the reading are reported as caveats rather than left for the reader to notice. They are prose for a person and are not parsed back:

  • A gap between the two captures. Anything that changed in between is inside the evidence. Runs started together produce no caveat; the gap is reported from a minute upward, in minutes, hours, or days.
  • Settings the two runs did not share. Different probe timeouts (a timed-out row may be a shorter budget rather than a slower path), different second-opinion resolvers (the public DNS rows attempted different servers), or a different --check/--skip selection. The selection is compared as a set, so a reordering is not a caveat.
  • Source bindings that did not constrain the two runs alike. One run bound its probes to a chosen local source with --iface while the other used the machine’s own routing choice, so a row that fails on one side may follow from that binding rather than from the machine. Two runs that were both bound are also reported when their bindings did not cover the same address families, because a source with no address for a family means that family was never attempted rather than unreachable. The binding is read as policy and never as identity: probes bind by the resolved addresses, and two machines have different interface names and different local addresses by construction, the same way they have different hostnames, so differing values are not a caveat and neither the names nor the addresses appear in the caveat text. This reading is where independently bound snapshots meet, since live --two-sided --via rejects --iface.
  • Rows only one side measured, counted.
  • Mixed fidelity, when one side is a sanitized --support artifact and the other is full fidelity, because the names and addresses are then not comparable. This arises only for two generic runs: on a targeted run, sanitization renames the target itself, so the pair fails the same-target rule above and is refused before any row is read.

Two machines is the premise, so a different operating system, architecture, or netdoc build on the other side is the expected case and is never a caveat.

Route context from topology files

--route-a FILE and --route-b FILE add the routing context of one side to the offline form. FILE is a topology in the route-path explanation format, and it is explained only against the side it names:

netdoc --two-sided here.ndoc there.ndoc --route-a here-topology.json

A file binds to its side only when its own records agree with that side’s run. The reading checks each condition below in order and reports the first one that fails, so an unbound file says why and explains nothing:

  • The side is not a support artifact. Sanitization renames its addresses, so they cannot be matched to a topology.
  • The side is not a generic run, which has no target to explain a route to.
  • The side has a target, and it is an IP literal with no interface zone. A name resolves on each machine, so no single destination is bound, and a zoned target cannot be tied to an interface. An IPv6 link-local target needs an interface the file cannot name.
  • The file has a source.address with no interface zone, in the same address family as the target. A missing address, or one that differs in family or carries a zone, does not bind.
  • The side recorded exactly one source address for the target, in its route decision or in the probe that reached it, and that address equals the file’s source.address. Several recorded sources, or none, do not bind.
  • The side’s route for the target is in the main routing table. A table name is never main: Linux spells main as an empty name, so a named table such as table 100, the kernel’s table 253 spelled default, and local do not bind. A legacy artifact’s table name is refused even though it lacks the knowledge bit.
  • The file’s source is in the default VRF, which names the main routing table. A snapshot never reports a VRF, so a source in any other VRF cannot be tied to the side’s routing table and does not bind. A side that recorded no routing table binds only to a default source, and its routing table is then listed as not compared.
  • source.address belongs to exactly one node and VRF in the file, and it is the node and VRF the file’s source declares. Zero or several owners do not bind.
  • Every row in the file, its observations, checks, boundaries, and tunnels, was collected within 24 hours of that side’s capture. Outside that window the rows may describe a different network.
  • The side recorded one route for the target, and that route agrees with the file’s forwarding decision at its source. Only agreement binds. The side’s next hop must be one of the file’s addressed next hops, so an equal-cost alternative binds. A recorded prefix must match the file’s matched prefix, when the side records one. Interface names are never compared, because they are local to each machine.
  • A disagreement or missing evidence does not bind. A route the side marks unreachable conflicts with a forwarded route in the file. A different next hop conflicts only when every next hop in the file names an address that can be matched, and a different prefix conflicts once the next hops agree. An on-link route conflicts under the same condition as a different next hop. Otherwise the comparison is not comparable: the side recorded no next hop, or a link-local one, several routes for the target, a file decision that is not a forward, or a file next hop with no matchable address (interface-only or IPv6 link-local) that the side’s next hop could belong to.

Binding is consistency, not identity. Node and VRF names are local to the file and cannot be checked against either machine, and a reused private address or a NAT translation can match a topology that describes another network. The flag is the assertion that the file belongs to that side.

A bound side prints its source and destination, the fields it compared with the file and the fields it did not, then an explanation with labeled parts. The expected path is the control plane’s prediction. The forwarding path is the recorded FIB. The return path is derived from the recorded FIB rows, and no reply was observed. A symmetric result means the two directions cross the same routers in reverse order, not that a reply arrives. Asymmetry is classified as in the route-path explanation. Checks recorded in the file are labeled as recorded elsewhere, since this reading measured none of them. Drift is not part of the context.

Route context never changes the placement, the diagnosis, or the exit code, which still comes from the placement alone. An unreadable or invalid file, or one file named for both sides, exits 2. The route files are refused with --via, with live mode, and with every other mode, including --version, --list-checks, and --profile list. Only --help exits earlier, while flags are parsed.

Topology output prints the file’s node and VRF names and its addresses unredacted. Review a reading before attaching it to a bug report.

How it relates to peer mode and --via

Two machines produce six kinds of observation. netdoc gathers them with commands that do not overlap, and then reads two of the groups together:

Observation Where it comes from
this machine’s own network state, and this machine to the target an ordinary local run
the other machine’s own network state, and the other machine to the target the same ordinary run over there, with --via as the transport from here
this machine to the other machine, and the other machine back peer mode
Reading Command
what changed between saved states, usually on one machine at different times --compare A.ndoc B.ndoc
where a failure against the target is, given the first two observation rows --two-sided A.ndoc B.ndoc, or acquire both live with --two-sided --via HOST
whether the path between the two machines fails in one direction or both peer mode’s own combined diagnosis

Peer mode and --two-sided answer different halves of “is the failure on this machine, on the other machine, or between them”. Peer mode measures the path between the two machines directly, with live authenticated traffic in both directions, and is the only one of the three that can prove directional asymmetry. --two-sided reads two finished ordinary diagnoses and places a failure against a target on one vantage point or the other. Live two-sided acquisition happens over SSH, but SSH is only the control transport and contributes no peer-mode observation. Neither mode reads the other’s output, and neither is a substitute for it: peer mode makes no claim about an external target, and --two-sided makes no claim about the direct path between the Network Doctor endpoints.

Offline --two-sided A.ndoc B.ndoc needs no reachability and opens no connection. Live --two-sided --via HOST needs the same one-way SSH access as ordinary --via, but no pairing string, reverse listener, relay, or direct peer reachability. Peer mode needs direct peer reachability because that path is exactly what it measures.

Machine-readable two-sided reading

--json prints the reading as its own versioned document, separate from the snapshot and comparison schemas because scripts consume it:

{
  "schema": "netdoc.twosided.v1",
  "a": {"created_at": "2026-03-04T05:06:07Z", "tool": {"version": "1.2.3", "os": "linux", "arch": "amd64"},
        "target": "github.com:443 tls+http", "verdict": "service", "summary": "…", "ok": false},
  "b": {"created_at": "2026-03-04T05:06:09Z", "tool": {"version": "1.2.3", "os": "windows", "arch": "amd64"},
        "target": "github.com:443 tls+http", "verdict": "ok", "summary": "…", "ok": true},
  "same_target": true,
  "checks": [
    {"id": "iface", "a": "PASS", "b": "PASS", "comparable": true},
    {"id": "target_tcp", "a": "FAIL", "b": "PASS", "comparable": true},
    {"id": "ssid", "a": "N/A", "b": "N/A", "comparable": false}
  ],
  "caveats": ["1 check did not produce a measured outcome on both machines and was not read."],
  "diagnosis": {
    "id": "two_sided_one_side_fails",
    "side": "a",
    "summary": "Every failed check passes from side B, so failures were observed only from side A. This does not locate the cause on that side or exclude an endpoint-specific cause.",
    "evidence": ["target_tcp"],
    "ambiguous": true,
    "alternatives": ["side A's own network state", "side A's path to the endpoint",
                     "endpoint-specific failure, including different servers behind the same name or different treatment by address or policy",
                     "the endpoint or the path changing between the two runs"]
  }
}

checks is every check in either snapshot, in the order side A executed them, followed by the ones only side B had. comparable is what says whether the placement was allowed to read the row. Field names, the side and ID vocabularies, and the meaning of the exit code are stable for this schema. caveats and summary are derived sentences and are never parsed back; branch on diagnosis.id, diagnosis.side, and diagnosis.evidence. same_target is always true in a document that exists at all, since the other case is refused, and it is carried so the document is self-describing.

route_paths appears only when --route-a or --route-b was given. It holds one entry per named file, a before b, with side, status (bound or unbound), and reason when unbound. A bound entry also has destination, source, basis, and explanation. basis names the kind of evidence each part rests on, from a closed set: expected is control_plane_prediction, forwarding is recorded_fib, return is recorded_fib_prediction, and checks is recorded_elsewhere. explanation is the object --explain --json prints for that flow, without drift or ospf. reason is prose, so branch on status. A bound entry also has first_hop (always agrees), compared, and not_compared, which name the fields checked against the file and the fields left unchecked, from the closed set source, next_hop, prefix, routing_table, and interface. interface is always unchecked, prefix is unchecked when the side recorded none, and routing_table is unchecked when the side recorded no routing table. An unbound entry has first_hop only when the forwarding comparison ran, with conflicts or not_comparable. Without route files the document is unchanged.

Live acquisition emits this exact schema. It does not add ordinary diagnosis confidence: two-sided epistemic limits remain represented by diagnosis.ambiguous, alternatives, and caveats, so this orchestration feature requires no schema version change.

Route path explanation

--explain says how traffic to one destination should leave the network that a topology file describes, and where the forwarding table and the control plane disagree about it. It is headless and runs no probe: the file is the only evidence, and nothing on this machine or on the network is read or contacted.

netdoc --explain topology.json 10.20.40.8
netdoc --explain --json topology.json 10.20.40.8

The destination must be an IP address. A name is refused rather than resolved, because the resolver that answered would decide which route is under test.

Topology file

The file is one JSON object. Unknown fields are refused, so a misspelled key cannot drop evidence without a word, and the file is capped at 1 MiB.

  • version must be 1.
  • source gives the node and vrf the path starts from. default names the main routing table, and it is the only VRF a snapshot can be bound to. Its optional address is the source address the destination replies to. When it is set, the explanation also walks the return route and compares it with the forward route. See Asymmetry below.
  • observations are recorded tables. Each names its source, collected_at (RFC 3339), plane, node, and vrf, and may carry interfaces, neighbors, and routes. The planes are intended, configured, control, fib, and observed. The expected path reads the control plane, then configured. The forwarding path reads only fib. Routes in intended are compared with the FIB, as described under Drift below. Intended interfaces and neighbors own no address and name no neighbor. Rows in observed are validated, then not read by this explanation. Observed data-plane results are recorded as checks, not as an observed plane. Builds before this change accept intended rows and ignore them, so they print no drift.
  • routes_complete says whether the observation lists every route its table holds. Only a complete table proves that no route exists. A partial table leaves a missing route unknown, and nothing is invented to fill the gap.
  • neighbors_complete is optional and defaults to false. It says the observation lists every neighbor record the reporter holds for its node, VRF, and plane. The key names no protocol, so a complete inventory that also carries BGP or LLDP records is not a complete OSPF state. A missing record in a complete inventory shows only that the reporter omitted it. It does not show that a link or an adjacency is down. Only the OSPF section reads this key, and only to decide whether a missing OSPF neighbor is evidence, as described under OSPF below. The route-path walks never read it.
  • attributes is optional on routes, interfaces, and neighbors. Each entry is {"key": ..., "value": ...}, and the key is namespaced, such as ospf.area. Both fields must contain text other than whitespace. An empty or whitespace-only value is refused, and an entry with any other field is refused. One key may repeat with different values. Every value is kept, in sorted order, and none is chosen, so a reader that expects one value must treat the repeats as conflicting. An empty list means the same as an absent one. The route-path walks never read attributes, so they change no path or route finding. The OSPF section reads the ospf.* keys described under OSPF below, and no other key. Builds before this change refuse a file that uses attributes or neighbors_complete, because they do not know those keys. The version stays 1, so a file without them reads the same in every build.
  • checks are results already recorded for one segment: node, vrf, interface, destination, and result, which is pass or fail, with the same source and collected_at as an observation. An optional next_hop names the next-hop address the check exercised, and a named check applies to that segment only. A check without next_hop applies to its interface when the interface carries one next hop, and the named and unnamed checks on that segment combine: a pass beside a failure is conflicting. When the interface carries several next hops, an unnamed check is attributed to none of them: each segment on the interface is unattributed, and a limitation says so. A named failure or conflict on such a segment still reports as itself.
  • boundaries are optional. Each names a policy boundary recorded on a node in one routing domain: node, vrf, kind (stateful_firewall, nat, or tunnel), source, and collected_at. A boundary covers the whole node within its routing domain. It changes only the asymmetry assessment, never the forward or return walk. Builds before this change refuse a file that uses source.address or boundaries, because they do not know those keys.
  • tunnels are optional. Each records one tunnel interface and the underlay it rides: node, vrf, and interface name the tunnel interface, remote is the underlay address its encapsulated packets are sent to, and underlay_vrf is the routing domain in which node looks remote up. Each also has the same source and collected_at as an observation. Every key is required, so neither the tunnel interface nor the underlay routing domain is ever inferred from a name or a default. remote is one IP address with no prefix length and no zone, spelled as the model stores it, so an IPv4-mapped IPv6 spelling is refused, and so is an unspecified, loopback, link-local, multicast, or broadcast address. A record says the interface sends all of its overlay traffic to that one remote, so it describes a single-remote tunnel only. A second record for the same node, routing domain, and interface is refused, whether it repeats the remote or names another, and a multipoint interface, such as a WireGuard device with several peers, cannot be recorded. A configured, control, or fib observation must describe the record’s node in its vrf, and one of them must list interface or name it as the interface of a route’s next hop, or the file is refused. So a mistyped node, routing domain, or interface never reads as a tunnel that no recorded segment rides, which would leave the real tunnel’s overlay none under an underlay failure. An interface that only an intended or observed row names does not count. A record whose interface the evidence names but no fib route leaves by is kept. No recorded segment leaves by it, so it affects only a next hop that names no interface at its node and routing domain, as described under Failure simulation below. underlay_vrf and remote are not checked this way, because an underlay the file cannot walk to remote already leaves the overlay unknown. A tunnel boundary is unrelated: it marks a whole node for the asymmetry assessment and names no interface or remote. Only failure simulation reads tunnels, as described under Failure simulation below. The explanation’s walks treat a tunnel interface as any other link. Builds before this change refuse a file that uses tunnels, because they do not know the key, so such a file is never read as one without its tunnels.

Explanation output

The explanation has these parts:

  • The expected path, from the control plane and the configured routes.
  • The forwarding path, from the FIB.
  • Findings where the two disagree, and where a recorded check failed. A failure names the recorded check it rests on, with that check’s source and collected_at.
  • Failure regions. A failed check names one segment, but the tool does not pick one hop as the cause. It lists every candidate segment the failure could lie in.
  • Asymmetry, when the file names source.address: whether the return route crosses different routers, and what, if anything, makes that difference relevant.
  • Drift, when the file holds intended routes: where the FIB differs from intent, and what that difference does to delivery.
  • OSPF, when the file carries ospf.* attributes that yield a finding: what the recorded neighbor states, areas, and router IDs say about each adjacency, and how strongly each reading holds.

Limitations list each decision the file could not make, and the walk it came from.

Ownership decides a local hop. The walk stops at that node, and the limitation names any route that the walk’s planes give there and that contradicts ownership, including an unproven partial-FIB route. A missing route and an on-link route are not named, since a main table normally omits local addresses and a connected route covers them. A route with no next hops is named as forwarding unknown, because it does not show where traffic goes. When another node also lists the destination, the limitation names both claims and their sources. The walk stops at its own owner and does not follow the other, so without that note the conflict would appear in neither walk.

A walk stops at the destination, at a node the file does not describe, at a loop, or at 32 hops or 512 node visits, whichever comes first. A route can list more than 64 next hops. Its decision and comparison read all of them, but the walk follows only the first 64 at that node. When the expected or the forwarding walk is cut this way or stopped by a bound, the explanation is marked truncated. Drift marks its own walks in drift.truncated, as described under Drift.

Asymmetry

When the file names source.address, the explanation also walks the return route. It starts at the one routing domain that owns the destination, follows that domain’s FIB toward the source address, and compares the routers it crosses with the forward route. Symmetric means the return retraces the forward route in reverse, router by router, over the same link between each pair of routers. The same routers visited in another order are asymmetric, not symmetric, and so are the same routers joined by another link between them. The return must end at the source node in its routing domain. A return that ends anywhere else is not this flow’s reply, so it is unknown. Both routes end at the node that owns their target. That ownership is the file’s claim, and the limitations name any FIB route that contradicts it. When the destination is an address on the source node itself, there is no path to compare. The explanation then has no asymmetry section and no asymmetry JSON field, even when source.address is set. The assessment is one of:

  • symmetric: the return is the forward route reversed, with the same routers in the same routing domains, over the same links between them.
  • asymmetric_benign: the return differs from the forward route, whether through different routers, the same routers in another order, or the same routers over another link between them, and no concern holds. Asymmetry alone is not a fault, and the text says so.
  • asymmetric_risk: at least one concern holds. A boundary that exactly one direction crosses is a concern, as is a vrf_crossing where the directions use different routing domains, and a recorded_failure on a segment of either direction. A concern says what to check. It does not name a cause.
  • unknown: one direction is not proven, or the link at one hop cannot be named. The reason names the hop that stopped it, for example an unresolved or ambiguous next hop, a partial FIB, a loop, a discard, a missing route, an ECMP branch, several owners of the destination, a return that ends somewhere other than the source, or a link end the file does not name. Missing return evidence is always reported as unknown, never as asymmetry.

A return route needs the FIB of the destination’s node and of each router after it, so a destination with no recorded FIB leaves the return unknown. ECMP on either direction is also unknown, because the file cannot say which alternative a flow takes. When the destination is owned by several nodes, the return has no single start, so it is unknown. A link is named at each hop only when the egress interface holds a prefix that contains the next hop, and the router on the far side has exactly one interface holding that next hop address, on the same subnet the egress holds. An unnumbered interface, an address held by two interfaces, or a far address on a different subnet than the egress, such as a loopback /32, leaves the link unnamed, so the pair is unknown. A loopback is caught only when its address sits outside the egress subnet. The file records no interface type, so a loopback numbered inside the egress subnet still counts as the far end. A named link is a subnet both ends hold, which can be a shared segment. It is not proof of a cable. Neighbor records are not used to name a link. Without source.address, the explanation has no asymmetry section and its output is unchanged.

Drift

When an intended observation lists routes or sets routes_complete, the explanation walks once more. That walk, the intended walk, follows intent where an intended table decides the destination, and the FIB everywhere else. At each node on it where intent decides, the intended decision is compared with the FIB decision, both by longest match. Next hops compare as sets, and a next hop with no interface matches any interface on the same gateway. Metrics, origins, and attributes are not compared. A node where intent is silent is not compared.

Each node is judged by the intent at that node alone. Its intended reach follows intent at the node and the FIB after it, and its FIB reach follows the FIB from the node. Intent at another node never changes a node’s level. Delivery means every branch of a walk ends at the node that owns the destination, with no recorded check other than a pass. The level is one of, from least to most severe:

  • none: intent decides at one node or more, and at each a complete intended table and the FIB decide the same on the same prefix. No finding is listed.
  • benign: the same decision on another prefix, or different forwarding while the FIB still delivers, both from the node and from the source.
  • unknown: intent decides at no node, so compared is 0, as when the only intended tables are partial and list no route for the destination. The level is also unknown when either side does not decide (a partial FIB, conflicting rows, or a missing table), a partial intended table could hide a more specific route, delivery from the source is not proven, or the effect on delivery is not proven, as with a next hop that no modeled node owns. A partial intended table never proves anything, even where the FIB has no route: neither its decision nor a walk through it counts as delivery. When intent drops the destination, through no_route or discard, and the FIB forwards it, the result is unknown, not benign, because a policy mismatch is not evidence of harmless drift.
  • redundancy_lost: the destination is delivered from the source, and either the FIB keeps some of the intended ECMP next hops and adds none, or, at a node off the FIB path from the source, the FIB from that node fails where intent at that node delivers.
  • reachability_lost: at a node on the FIB path from the source, the FIB from the node fails (no route in a complete table, a discard, or a loop, on any branch) where intent at that node, with the FIB after it, delivers.

Intent at several nodes can deliver together where intent at any one of them does not. When the FIB path from the source fails, the intended walk delivers, and no node is reachability_lost on its own, one reachability_lost finding at the source records the loss without naming a node.

The drift level is the worst finding. When the intended walk or the FIB walk from the source stops at a bound, including the 64 next-hop cut, drift.truncated is set and the level is at least unknown. The expected walk does not count, since drift does not read it. A walk that stopped at a bound never proves delivery. A recorded check failure leaves delivery unknown, and is still reported as fib_forwarding_failed. Each finding lists its facts: every intended and FIB row at the node, with source, collected_at, the plane, whether its table is complete, and the route it lists for the decision’s prefix, or none.

Redundancy rests on one assumption: a route’s next_hops names every ECMP leg. A collector that reports one leg of several makes the route read as redundancy_lost. A backup route from another origin reads as conflicting, which is unknown. Since a next hop with no interface matches any interface, one FIB next hop with no interface covers two intended legs to the same gateway on different interfaces, and the node reads as a match, not as redundancy_lost.

Drift appears only in --explain output and never changes the exit code, which stays 0. Without intended routes, the explanation has no drift section and its output is unchanged.

Failure simulation

--fail NODE simulates router NODE becoming unavailable, and --fail NODE:INTERFACE simulates that interface of NODE going down, in every routing domain that lists it. The text before the first colon is the node, so a node whose name holds a colon cannot be named. The flag adds a failure section after the explanation:

netdoc --explain topology.json 10.20.40.8 --fail r2
netdoc --explain --json topology.json 10.20.40.8 --fail r1:eth1

The simulation walks the recorded FIB only, and reads control routes only for their origins and tunnels only for the underlay each names, as described below. For the destination, and for every address an interface in the file holds, it walks forwarding from the source with the failure applied. When that walk meets the failure, it walks again without it. Each node stops using a next hop whose segment crosses the failure, and nothing else changes. No route is recomputed, no path is added, and no convergence or timing is predicted.

  • A failed router forwards nothing and owns nothing, so a segment toward an address it holds crosses the failure.
  • A down interface cuts every segment that leaves its node through it, and every segment from another node that arrives on it. A segment arrives on it when the link is named as under Asymmetry: the far address is held on that one interface of the failed node, on a subnet the egress also holds. An address held on the down interface is unknown when the walk reaches its node another way, or its node is the source. Whether a node still answers for an address on a down interface depends on its platform, which the file does not record: Linux keeps the address local and delivers it, and many routers withdraw it. An address its node also holds on another interface is unknown too, because the evidence does not say which interface delivers it.

Delivery before and after is read as Drift reads it: every branch ends at the node that owns the destination, with no recorded check other than a pass. A cut leg is removed rather than counted as a drop, as long as another leg at the same node survives. Each destination gets one impact:

  • none: no recorded branch crosses the failure, the forwarding before it is proven, whether it delivers or not, and no hop on it forwards by a BGP route, as described below. These destinations are counted under unchanged and are not listed.
  • redundancy_lost: delivery was proven, some branches cross the failure, every branch that survives still delivers, no hop that survives forwards by a BGP route, and every cut leg leaves the failed node by its own down interface. The listed walk shows the surviving path with the rows it rests on, and each cut leg as failed.
  • reachability_lost: delivery was proven, and after the failure some branch from the source reaches a node with no surviving next hop, so the flows on that branch are dropped. A branch that survives at the source and loses every next hop further on counts too. Other branches may still deliver, so the loss can be partial; the listed walk shows which applies. No OSPF or BGP route may decide anywhere on that branch, unless the failed router alone holds the destination, as described below.
  • unknown: the evidence cannot rate the change. The forwarding before the failure does not prove delivery, so the true path may cross the failure, or it does not deliver at all. The surviving forwarding does not prove delivery. The evidence cannot say whether a segment crosses the failure, because the failed node names no interface for the next hop, or the far address is held on several interfaces, on another subnet, or nowhere. The destination is an address on the down interface. Or every next hop at a node is cut while a less specific route also matches there: whether that route takes over depends on the cut route being withdrawn, which the file does not record. Or every surviving branch delivers, but a leg is cut at a node whose own link stays up. Or every branch that loses its next hops passes a node with an OSPF or BGP route to the destination. Or a hop that still forwards toward it selects a BGP route. Or a segment rides a recorded tunnel whose underlay meets the failure, does not prove delivery, or forwards by a BGP route. The last four are described below.

Only a node whose own interface is down is taken to stop selecting a next hop through it. --fail NODE:INTERFACE models an administrative down, as ip link set dev INTERFACE down does on Linux, which marks each route leg through the interface dead, so lookups stop selecting it. A carrier loss is a different fault: Linux IPv4 marks such a leg linkdown and still selects it unless net.ipv4.conf.*.ignore_routes_with_linkdown is set, and the file records neither the cause nor the setting. A leg cut anywhere else, toward a failed router or onto a down far end, leaves its own node’s link up, as a neighbor across a switch or bridge sees it, so nothing in the file tells that node to stop hashing flows onto the leg. --fail NODE stands for a router that stops forwarding and answering, and how its neighbors notice, if they do, is not in the file. So a router failure, and a down interface reached from another node, are never rated redundancy_lost: when every surviving branch delivers and some leg is cut at a node other than NODE, the destination is unknown, and its reason names that node. reachability_lost keeps its meaning, since a branch with no surviving next hop drops its flows whichever node notices.

A router or interface failure can make OSPF or BGP compute its routes again, and the simulation computes none. So a branch that loses every next hop counts toward reachability_lost only when no node on it, from the source to the node left with no next hop, holds an OSPF or BGP route to the destination: neither the fib rows its decision rests on nor any control route of that node and routing domain that covers the destination names origin ospf or bgp. Such a route can take the place of the cut forwarding once its protocol learns of the failure, and whether one does is not in the file. When every branch that loses its next hops has one, the destination is unknown, and its reason names the first such node and the rows that tie it to the protocol; the listed walk still shows each cut segment and the rows it rests on. A destination that only the failed router holds stays reachability_lost, because no path a protocol computes reaches a router that is gone. A down interface leaves its router up, so this exception never applies to it. Origins are the names adapters wrote, compared for equality, and only ospf and bgp are read as a routing protocol’s. A route that another protocol installed under another name, or that a collector names kernel, reads as a static route does.

A router or interface failure can also make BGP withdraw or replace a route whose recorded forwarding never meets the failure. A BGP route rests on a session and on resolving its next hop, and either can run over the failed router or interface when no forwarding branch does, for instance through a route reflector, an iBGP peer, or the path of a multihop session. The file records no BGP session, peer, or next-hop resolution for a fib route, and --explain does not model BGP withdrawal. So a destination that would be none or redundancy_lost is unknown when some hop of the listed walk that still forwards toward it selects a route whose fib rows name origin bgp. Its reason names the first such node, its routing domain, the selected prefix, and those rows, and the listed walk is the recorded one, with each cut segment as before. Only the route each hop selects for the destination counts: a bgp route for another prefix, one a longer match shadows, one in another routing domain, on a node the walk does not reach, or on a branch the failure cuts, and one only on the control plane change nothing. The rule applies whatever fails, even a router that forwards nothing on the walk, because nothing in the file could show that a BGP route does not depend on it; --fail-ospf reads a bgp segment the same way. OSPF needs no such rule: removing a router or link leaves the cost of every path that avoids it unchanged, so, as long as the records are current, SPF still selects the recorded paths that avoid the failure. Origins are compared as above, so a route BGP installed that a collector names kernel reads as static. A fib route with no bgp row whose gateway resolves through a BGP route, such as a static route to a BGP-learned address, reads by its recorded next hop: the FIB records no recursive resolution, so the simulator does not infer that such a route depends on BGP.

reachability_lost does not mean that every path is gone. With ECMP, one branch can lose forwarding further on while another still delivers, and only the flows hashed onto the lost branch are dropped. The loss is partial when some branch of the listed walk still ends in a local decision, and complete when every branch ends in failed, no_route, discard, or loop. Any other mix leaves open how much delivery survives. Either way, only recorded forwarding is counted. A routing protocol may still converge onto a path the file does not hold, such as an unequal-cost backup that is in no FIB. Record that path as an ECMP leg, or capture the tables after the failure, to have it counted.

A tunnel interface going down and a tunnel losing its underlay are different faults. --fail NODE:INTERFACE on a tunnel interface is an administrative down of the tunnel device at that end, read as any interface is: each leg through it is cut, a segment arriving on the far end’s tunnel interface is cut, and a destination left with no next hop is reachability_lost. When the underlay fails instead, the tunnel device stays up, and a WireGuard device has no carrier to lose, so the overlay forwarding keeps selecting the tunnel leg while the encapsulated packets may be dropped somewhere in the underlay. Without a tunnels record, nothing in the file ties a tunnel interface to an underlay, so the walk reads the tunnel as a direct link, and an underlay failure leaves the overlay none.

With a record, each segment that leaves by the recorded interface, in the recorded routing domain, depends on the record’s underlay. The simulation walks the recorded FIB from that node in underlay_vrf toward remote, with the failure applied. The tunnel is taken to avoid the failure only when that walk never meets the failure, proves delivery to remote, and selects no route whose fib rows name origin bgp. Otherwise the overlay segment ends in an unknown leaf whose reason names the tunnel, its underlay, and either the first underlay segment cut or the BGP route the underlay forwards by, and whose evidence is the record. When the failure takes down the interface on which the tunnel’s remote node holds the destination, the reason also says so, as a condition that applies only if the traffic crosses the tunnel, and the evidence adds that node’s configuration. The walk does not follow the far side, so the rating does not change. Under a router or interface failure, a BGP route selected inside the underlay carries the same control-plane uncertainty as one selected on the ordinary walk. --fail-ospf already reads such a segment as unknown. When no segment is cut, the reason names that unknown segment and its own reason, instead of saying the underlay meets the failure. This holds for any route the underlay forwards by, not only BGP. --fail-next-hop does not apply the rule. An underlay loss alone never makes an overlay destination reachability_lost, even when the underlay is certainly cut: an endpoint may move, and a routing protocol may restore the underlay, and the file records neither. The check is conservative wherever the evidence is thin:

  • An underlay that does not prove delivery, because a table is partial, remote is held by no modeled node, or underlay_vrf has no table, leaves the overlay segment unknown whatever fails, since the true underlay path may cross the failure.
  • An underlay that rides a tunnel itself, the recorded one included, is unknown. A tunnel met during an underlay walk is not followed, so the check never recurses.
  • A segment that leaves by the recorded interface toward a node other than the one holding remote contradicts the single remote, as a multipoint interface would, and is unknown.
  • A segment that names no interface at the tunnel’s node and routing domain may leave by the tunnel, so it is checked the same way, unless it goes to a node other than the one holding remote.

The underlay walk spends the node budget of the walk it serves, so the simulation’s bounds hold. A record changes nothing in another routing domain, and nothing for a segment that leaves by another named interface. The underlay is read in the forward direction only: the reverse underlay, which carries the overlay’s return traffic, is not walked. A failed tunnel peer is still read by the overlay walk itself, so --fail on the far router cuts the overlay segment as it cuts any segment toward a failed router.

The failure must name a node that a configured, control, or fib observation describes, and an interface that the node lists or names on a route. Anything else exits 2, so a mistyped name never reads as a failure that affects nothing. The source node cannot fail, because nothing would leave it, but its interfaces can.

A simulation considers at most 256 destinations: the one given first, then the held addresses in address order. It stops at 16384 node visits across all of its walks. Either bound sets truncated, and a destination past it is not considered. A walk stopped by the explanation’s own bounds sets truncated too. Once the walks already listed hold 512 hops, each later destination keeps its impact and reason, and its walk is omitted, so the output stays near the size of two walks. An omitted walk sets truncated too, but such a destination was considered and rated. The text keeps these apart: one line says when destinations past a bound were not considered, one says when walks stopped at a bound of their own, which never proves delivery, and one counts the destinations listed with their walks omitted. The failure section never changes the explanation above it, and the exit code stays 0. Without --fail, the output is unchanged.

--fail-ospf NODE:INTERFACE:NEIGHBOR simulates losing the OSPF adjacency that NODE records on INTERFACE toward NEIGHBOR. The node ends at the first colon and the neighbor starts after the last, so the interface name may hold colons and the two node names may not. The two flags cannot be combined, because a simulation removes one element.

netdoc --explain topology.json 10.20.40.8 --fail-ospf r1:eth1:r2

An adjacency loss is not a link failure. The interface stays up, so a connected or static route across it keeps forwarding, and so does the node’s own address on it. Only OSPF next hops across the adjacency’s link stop, and the rest of the walk is the same as for an interface. The adjacency must be named by a control neighbor on NODE whose local_interface is INTERFACE, whose remote_node is NEIGHBOR, and that carries at least one ospf.* attribute. An expected neighbor on the configured or intended plane names no adjacency. The routing domain is the one those records are in; records in several domains exit 2, and so does an adjacency no record names.

The records prove the link’s transit dependency on the adjacency only when INTERFACE reports ospf.network_type point-to-point, every record for the neighbor gives one remote_addr and ospf.state full, at most one remote_interface, and no other OSPF neighbor is recorded on INTERFACE. A router lists a point-to-point link to its neighbor in its router-LSA only while that neighbor is full (RFC 2328 12.4.1.1), and SPF uses a link between two routers only when both list it (16.1). So once the adjacency is gone, SPF cannot use the link as a transit edge in either direction, and the simulation removes each recorded OSPF next hop across it. That is a structural dependency in the recorded topology, under the simulation’s assumption that the records are current. It is not a prediction of the FIB after the loss: the router may still advertise the link’s stub network whatever the neighbor’s state, so the link’s own subnet can stay reachable through either end, and what OSPF installs in place of a removed next hop is not computed. On any other network type a router can forward to a neighbor it holds no full adjacency with, and losing the adjacency to the designated router drops the whole network, so which next hops depend on one adjacency is not in the evidence. When the dependency is not proven, adjacency.reason says why, and every OSPF next hop across the link is unknown.

A segment crosses the adjacency’s link when it leaves NODE in that routing domain through INTERFACE, or arrives on INTERFACE from another node, as a down interface reads it. Each such segment is decided by the origins of the route it belongs to:

  • Only connected and static: it keeps forwarding.
  • Only ospf, with the transit dependency proven: it is cut as failed. A segment leaving NODE must name the neighbor’s recorded address, and a segment arriving must come from NEIGHBOR; anything else contradicts the records and is unknown.
  • Any other origin, or ospf beside another origin: unknown, because the file does not say whether that route depends on the adjacency.
  • A segment of a bgp route is unknown wherever it is, even off the link, because its session or next-hop resolution may run over the adjacency, and --explain does not model BGP withdrawal.

No SPF is run, so an adjacency loss is never rated reachability_lost. Where a branch is left with no surviving recorded next hop, OSPF would compute its routes again, and whether another path takes over is not in the file, so that destination is unknown. redundancy_lost means some legs are cut and every branch that survives delivers without crossing the link. Removing a link makes no path shorter and leaves the cost of every path that avoids it unchanged, so under the same assumption SPF still selects the recorded paths that avoid it. That too is a reading of the recorded structure, not of the FIB after the loss. none means no recorded branch crosses the link and none forwards by BGP.

--fail-next-hop NODE[@VRF]:ADDRESS[%INTERFACE] simulates NODE losing one next hop: every leg of NODE’s recorded FIB, in one routing domain, whose next-hop address is ADDRESS, and whose interface is INTERFACE when one is named. It cannot be combined with --fail or --fail-ospf.

netdoc --explain topology.json 10.20.40.8 --fail-next-hop r1:10.0.1.2
netdoc --explain topology.json 10.20.40.8 --fail-next-hop r1:10.0.1.2%eth0
netdoc --explain topology.json 10.20.40.8 --fail-next-hop r1@blue:10.0.1.2
netdoc --explain topology.json 2001:db8:40::8 --fail-next-hop r1:2001:db8:1::2
netdoc --explain --json topology.json 2001:db8:40::8 --fail-next-hop r1@blue:fe80::1%swp1

The delimiters are a convention of this flag. The node ends at the first :, so an IPv6 address keeps its colons. A routing domain follows the node after the first @. The interface starts at the first % after the colon, the separator an IPv6 zone uses, and runs to the end, so an interface name may hold : and %, as in r1:10.0.1.2%Gi0/0:1. A node whose name holds : or @, or a routing domain whose name holds :, cannot be named. The address takes no brackets and no zone, and an IPv4-mapped IPv6 spelling names the IPv4 next hop, which is how the topology stores it.

Only fib rows of NODE can select a next hop, because the simulation walks only the FIB. Some fib route of NODE must name ADDRESS as a next hop. A control, configured, or intended row does not count, and neither does an on-link route that reaches ADDRESS as its destination. Without @VRF, the routing domain is the one whose rows name ADDRESS, and several exit 2 and list them. Without %INTERFACE, the rows may name ADDRESS on one interface, beside legs that name none, and two or more interfaces exit 2 and list them. With %INTERFACE, a row must name ADDRESS on that interface, and a row that names no interface does not count. A link-local ADDRESS always needs %INTERFACE, since it names a neighbor only together with its link. Anything else exits 2, so a mistyped selector never reads as a loss that affects nothing.

A next-hop loss is narrower than any other failure. The interface stays up, so other next hops and on-link destinations on it keep forwarding, and NODE keeps its own address on it. The node that holds ADDRESS stays up, keeps its addresses, and keeps forwarding for every other node. The same address in another routing domain of NODE, on another interface when INTERFACE is named, or on another node, is another next hop and keeps forwarding. No route is withdrawn, recomputed, or added. The origin of a route does not matter: the recorded FIB leg is cut whatever installed it, and whether a protocol would notice the loss is not predicted. A next-hop loss removes the recorded FIB next hop directly. It does not simulate an OSPF adjacency loss or a BGP session loss, and it does not assume that OSPF, or any other protocol, has computed its forwarding entries again. So a branch left with no recorded next hop is reachability_lost even when its route is ospf or bgp, where --fail and --fail-ospf rate it unknown, and the BGP rule under --fail does not apply. The loss is simulated, not observed.

  • A leg of NODE that names ADDRESS, on INTERFACE when one is named, is cut as failed.
  • A leg of NODE that names ADDRESS and no interface, when INTERFACE is named, is unknown, since it may or may not be the leg on that interface.
  • An on-link leg of NODE whose destination is ADDRESS itself is unknown. Whether the next hop still answers for its own address once it stops forwarding depends on why it stopped, which the file does not record.

A next-hop loss is never rated redundancy_lost. When some legs at NODE are cut and every surviving branch delivers, the destination is unknown: the link stays up, so nothing in the file says that NODE stops selecting the lost leg, and traffic hashed onto it may be dropped. On Linux, whether a route lookup still selects such a leg depends on the node’s settings and role, for instance net.ipv4.fib_multipath_use_neigh and whether IPv6 forwarding is on, and the file records neither. reachability_lost keeps its meaning: a branch reaches NODE where every leg for the destination is cut, and no less specific route also matches there. Another branch that still delivers makes the loss partial, as above. A routing protocol, BFD, or an operator may later remove or replace the route, and that is not predicted. none means no recorded branch uses a cut leg. unknown also follows the rules above for unproven forwarding and a shadowed route, and for an ADDRESS that no modeled node, or more than one, holds.

In JSON, failure holds failure (node, and interface for an interface; for an OSPF adjacency also vrf and ospf_neighbor; for a next hop also vrf, next_hop in canonical form, and interface only when the selector named one), adjacency for an OSPF adjacency only (neighbor_addr, remote_interface, proven for the transit dependency above, reason when it is not proven, and evidence, the control rows read), gateway for a next hop only (owners, the nodes and routing domains that hold its address, empty when none does, and evidence, the fib rows that name it), source, considered, unchanged, affected, and truncated. Each affected entry, in address order, has destination, owners (the nodes and routing domains that hold it, when any do), impact, reason, and after, which is the forwarding walk with the failure applied, as a hop. after is absent when the walk is omitted. A cut segment ends in a hop with an empty node and the decision kind failed. A segment whose crossing cannot be decided ends in an unknown decision that says why. A failure with next_hop is a next-hop loss, one with ospf_neighbor an OSPF adjacency loss, one with only interface an interface, and one with neither a router.

Failure from several sources

--sources LIST takes one of the failure flags above and simulates that failure from each source in LIST, a comma-separated set of NODE[@VRF] names, or from every modeled routing domain with all. A name that matches several routing domains of one node exits 2 and lists them, so name one as NODE@VRF. A name listed twice counts once. Under all, a router failure leaves out the failed router’s own routing domains, since nothing leaves a failed source, and the text lists each one as excluded. Naming the failed router exits 2.

netdoc --explain topology.json 10.20.40.8 --fail r2 --sources all
netdoc --explain --json topology.json 10.20.40.8 --fail-ospf r1:eth1:r2 --sources r2,r1

Each source gets the simulation described above, unchanged, so its ratings, reasons, and bounds on destinations and node visits are its own. The run then groups the affected ratings by owner. A rating is one source’s verdict on one destination, so a destination that two sources reach is counted twice. An affected node counts a rating once, even when two of its routing domains hold the destination. An affected routing domain counts it once for each domain that holds it. A destination no modeled node holds counts only as unowned. The JSON summary lists nodes and domains, each with its counts per impact, and gives the totals of ratings, unowned, and unchanged. The text repeats those counts in its Blast radius line, and names the nodes and domains in the lines after it.

The run adds one bound of its own: it simulates at most 64 sources, in canonical order, and skipped counts the rest. Every source keeps its own bounds from the simulation above, so the work of a run is at most 64 of those simulations. The walks listed for all sources share one listing bound, which is the one a single simulation has. A walk that starts before that bound is listed whole, so the listing can pass it by one walk. A walk past it is omitted, and its destination keeps its impact and reason. Either a skipped source or an omitted walk sets truncated.

The JSON object holds blast in place of failure, with failure, scope, summary, truncated, skipped, excluded, and sources. Each entry of sources has the fields of the simulation above, and scope carries the recorded-model boundary. The convergence banner stays in both forms.

OSPF

When the file carries ospf.* attributes on neighbors or interfaces, the explanation adds an OSPF section after the route-path text. The section reads every adjacency and interface in the file, not only the nodes on the walked path. A route with origin ospf, or OSPF route attributes, never adds one. Without a finding the section is absent, and the text and JSON keep their existing bytes. The section reads these keys:

  • ospf.state on a control neighbor: the state a reporter gives for its side of one adjacency. The vocabulary is down, attempt, init, 2-way, exstart, exchange, loading, and full, compared without regard to case. Any other value is reported as unreadable and is never read as FULL.
  • ospf.area on a configured interface gives its configured area. On a control neighbor, every source must agree on one area. 0 and 0.0.0.0 name the same area. A value that is neither a number nor a dotted address is unreadable.
  • ospf.effective_area on a control interface is the dotted area the node reports for that interface. It is read only for a link that the intended plane declares. Each end’s sources must report one area. Two different areas, or a value that is not a dotted area, make that end ambiguous. No value is chosen, and the newest timestamp does not decide. The FRR import writes it only for a plain dotted area, as described under FRR OSPF import.
  • ospf.router_id on a control neighbor is the peer’s router ID as the reporter gives it. On a control interface it is the router ID the node reports for itself. The protocol writes a router ID as a 32-bit identifier in dotted IPv4 form, so an IPv6 address is unreadable and confirms nothing.
  • ospf.cost on a control interface is the cost FRR reports for it, an integer from 0 to 65535. Zero is a valid cost. An absent, null, malformed, or out-of-range cost is not written, and the capture’s notes say why.
  • ospf.network_type on a control interface is the network type from FRR’s networkType field: point-to-point, broadcast, nbma, point-to-multipoint, virtual-link, or loopback. ospfIfType is a different field and is never read as the network type. Any other value, including Null, is not written.
  • ospf.interface_state on a control interface is the interface’s own state: dependupon, down, loopback, waiting, point-to-point, drother, backup, or dr. It is not a neighbor’s state, and a neighbor’s DR or BDR role does not set it.
  • ospf.passive on a control interface is true or false. FRR reports a passive interface with timerPassiveIface true and no hello timer, and an active one with a hello timer and no timerPassiveIface. A timerPassiveIface of true beside any timerHelloInMsecs key is held. No passive value is written, and the name’s other facts are withheld too, as What the result does not establish describes. A null, malformed, or false timerPassiveIface gives no passive value, and a hello timer that is not an integer from 0 to 65535000 establishes neither state.

An adjacency is identified by node, VRF, local interface, and remote node. A source may leave the remote interface empty, and that record names the same adjacency as any remote interface. Two different remote interfaces on one adjacency are a conflict, and no state is reported for it. A state is reported only when every record for the adjacency reads one state. A readable state beside a missing or unreadable state is incomplete, so no state is reported. Two readable, different states are a conflict. A link is two records that name each other on the same VRF and interfaces, with the same remote interfaces. A router ID never names a node. It can only confirm or contradict the peer’s own records.

A link is declared when the intended plane names it: a neighbor with a remote interface, with or without any ospf.* key. A link that both ends declare is compared by the effective areas of its two interfaces. If each end reads one area and the areas differ, the reading is area_mismatch. If an end has no usable effective area, the link is incomplete_attributes, except when neither end has any effective area, when the link says nothing. A link that only one end declares is reported only when its two ends read different areas, under declared_link_one_sided. One declaration does not confirm the link, so that kind is never area_mismatch. Completeness flags and timestamps decide none of these readings.

A link that both ends declare is also read for a passive interface. The reading needs at least one end whose control-plane record reports ospf.passive true, with no unreadable or conflicting value at that end. When both ends report true, both are named and the reading still holds. A conflicting or unreadable end is never named. It also needs no OSPF record that names the pair on either end, whatever its remote interface. The result is a missing_neighbor finding of strength consistent_with. It says that passive OSPF is consistent with the expected adjacency not forming. It claims no absence, no cause, and no effect on forwarding. Its evidence rows carry passive with their source and collection time. A completeness flag does not decide it.

An expected neighbor is an ospf.* neighbor record in the configured or intended plane. Its absence is checked only against the control inventory of the node that names it, and only an OSPF record in that inventory shows presence. A generic record, such as LLDP, names a link. It never satisfies an expected OSPF neighbor. The completeness flag says the neighbor list is complete. It does not say that every OSPF source was collected.

Each finding has one of these strengths and kinds:

Kind Strength Meaning
neighbor_state reported One state for one neighbor, from its reporter. full does not prove that traffic flows. A state before FULL names no cause. 2-way is normal between non-designated routers on a broadcast network.
neighbor_state conflicting Two states for one neighbor, from one reporter or several. Neither is chosen, and the newest timestamp does not decide.
attribute_conflict conflicting Two area values or two router IDs for one identity or interface, two remote interfaces for one adjacency, or a peer that reports a router ID other than the one named.
incomplete_attributes unknown A record has no state, or a state, area, or router ID that cannot be read. A router ID must be dotted IPv4. A readable state beside a missing or unreadable one is incomplete, and no state is reported for that neighbor. An end of a link that both ends declare has no usable effective area.
router_id_unconfirmed unknown A neighbor reports a router ID that the peer’s own interfaces do not report.
missing_neighbor consistent_with The node’s neighbor list is marked complete and holds OSPF records, but none for an expected neighbor. The flag does not show that every OSPF source was collected. The adjacency is not called down.
missing_neighbor unknown The absence cannot be checked: there is no control-plane inventory, the inventory is partial, a complete neighbor list holds no OSPF record, the inventory names another remote interface for the expected neighbor, or the expected neighbor names no remote interface.
missing_neighbor conflicting One source expects a neighbor that a complete neighbor list with OSPF records lacks.
missing_neighbor consistent_with A link that both ends declare has an end whose control plane reports ospf.passive true, and no OSPF record names the pair on either end. Passive OSPF is consistent with the expected adjacency not forming. No absence is claimed, and no cause is named.
area_mismatch consistent_with The configured areas on the two ends of a link that both sides report as control-plane records differ. This fits a failed adjacency. It names no cause.
area_mismatch consistent_with Both ends declare a link, each reads one effective area, and the areas differ. No OSPF neighbor record reports the link. This fits a failed adjacency. The declaration is user-supplied, and the captures may have been taken at different times.
area_mismatch unknown As above, but an OSPF neighbor record reports the link. Both areas cannot hold at once, so no area is chosen.
declared_link_one_sided unknown One end declares a link, and the effective areas at its two ends differ. The declaration does not confirm the link. No area is chosen.

Each text block gives the finding, the source and collection time of every evidence row, and the limit of the conclusion. In JSON, each finding has kind, strength, node, vrf, interface, peer, and peer_interface where they apply, plus detail, limit, and evidence. Each evidence row names its source, collected_at, and plane, the state, area, effective area, or router ID it recorded, and a note where the row shows an absence or a link.

The fib and observed planes are not read by this section.

Machine-readable explanation

--json prints one object with source, destination, expected and forwarding (each a hop), findings, failure_regions, limitations, and truncated. When the file names source.address, it also carries asymmetry, with assessment, reason, return_to, forward_route and return_route (each a list of steps, present only when that direction is proven), concerns (each with kind, direction as forward, return, or both, node, vrf, detail, and evidence), and return, the complete return walk as an explanation in its own right. return is present only when the destination has one owning routing domain to start from. When the file holds intended routes, it also carries drift, with level, compared (the nodes where intent decides), truncated, intended (the intended walk, as a hop), and findings. Each finding has level, node, vrf, detail, the intended and forwarding decisions, and facts. A fact names source, collected_at, plane, and complete, plus origin, prefix, next_hops, and discard when the row lists a route. A step names node, vrf, interface, next_hop, and outcome. The last step is the node that owns the destination, and it has no interface. A hop carries node, vrf, and decision, and carries via (the segment the walk crossed) and next (the hops after it) when the walk goes on. A segment names from and vrf, and interface where it has one, and carries outcome: none when no check applies, pass, fail, conflicting when recorded checks disagree, or unattributed as described under Topology file. Its checks lists the recorded checks behind the outcome, each with source and collected_at. For an unattributed segment, they are the checks that could not be placed on it. A decision carries kind and proven and reason, and carries basis, prefix, shadowed (the less specific prefixes that matched and lost), next_hops, evidence, and agreement where they apply. A local decision, where the destination is an address on the node, names in basis and evidence the plane and rows that list that address, and proven is always false, since interface lists are partial and no complete table backs ownership. A no_route decision lists in evidence the complete tables that prove the absence, each with absent set to true. A hop that stops at a node the file does not describe, such as an unresolved or on-link next hop, has an empty node and vrf, and its via names the segment that led there.

When the file carries OSPF evidence that yields a finding, the JSON also carries ospf, with findings, as described under OSPF below. Without such a finding the field is absent. With --fail, --fail-ospf, or --fail-next-hop, it also carries failure, as described under Failure simulation. With --sources, it carries blast in place of failure, as described under Failure from several sources.

Decision kinds are forward, local, discard, no_route, unknown, conflicting, loop, truncated, unresolved, ambiguous, and on_link, and failed within failure only. Finding kinds are control_route_not_in_fib, fib_differs_from_control, fib_forwarding_failed, conflicting_evidence, and loop. Drift levels are none, benign, unknown, redundancy_lost, and reachability_lost.

What it cannot show

  • It collects nothing. Control-plane and FIB tables, interface addresses, and check results come only from the file, so the explanation is as current as the file.
  • It runs no traceroute and no probe, so it cannot confirm a route beyond what the file records.
  • It explains one destination per run.
  • A failure simulation removes one router, interface, OSPF adjacency, or next hop of one node from the recorded FIB. It recomputes no route, runs no SPF, and predicts no convergence or timing. It does not model BGP withdrawal, a policy change, or a tunnel as a failure of its own (the BGP import simulates a prefix withdrawal against FRR’s BGP table only, never against a FIB), and an OSPF adjacency loss never says which alternate path OSPF would compute.
  • A next-hop loss reads no neighbor cache, no sysctl, and no BFD session, so it never says whether a node stops selecting a lost next hop beside a surviving one. Such a destination is always unknown.
  • It reads OSPF neighbor states, areas, and router IDs as the sources recorded them. It runs no SPF, parses no LSDB, and compares no metrics, so it never says why a route was chosen or whether an adjacency is down.
  • A recorded check whose next_hop matches no next hop of the route is not used, and the explanation does not say so.
  • It does not model NAT translations, firewall rules, or connection state. A boundaries entry records where such a device sits, not what it does to a packet.
  • Two-sided diagnosis reads the asymmetry only as route context from a bound --route-a or --route-b file, and never as a placement. Its snapshots record only the outbound decision on each side, so they carry no return route of their own.
  • A bound two-sided reading cannot see a source-based policy rule that the kernel refuses. When the kernel rejects a route lookup that names the source, the probe repeats it without the source and records that answer, which is usually the main table’s. The snapshot does not mark the fallback, so a bound reading compares that answer as the flow’s own route. Where the platform records a routing table, that table is compared too, and where it records none, the table is listed as not compared.

FRR OSPF import

--import-frr-ospf reads FRR OSPF output that was captured earlier and reports what it holds. It is offline. It runs no probe, opens no socket, and contacts no router. It does not run vtysh or collect anything itself, so the captures are the only evidence, and the result is as current as they are.

netdoc --import-frr-ospf manifest.json
netdoc --import-frr-ospf manifest.json --json
netdoc --import-frr-ospf manifest.json --write-topology topology.json

Manifest

The manifest is one JSON object. The import refuses duplicate keys at any depth, keys it does not know, keys that differ from a known key only in case, values of the wrong type, null where a value is required, and data after the object. The manifest is read whole and may be at most 1 MiB.

  • version must be 1.
  • source_node is optional. It names the node the topology is written from, and it must match the node of a capture. A report alone needs no source_node.
  • captures is an array of at most 256 objects. Each object has all seven keys below.
Key Meaning
file The capture, as a path relative to the manifest’s directory. An absolute path, or one that leaves that directory, is refused. It must name a regular file, not a symbolic link.
source A label for the capture in the report. Unique within the manifest.
node The router the capture came from.
vrf Must be default, the only VRF these commands read.
frr_version Must be 10.7.0.
command show ip ospf interface json or show ip ospf neighbor detail json, or one of the LSDB comparison commands: show ip ospf json, show ip ospf database detail json, or show ip ospf route json.
collected_at An RFC 3339 time with an offset, such as 2026-10-09T12:00:00Z.

Two captures may not name the same file, directly or through a hard link.

Each capture is the JSON output of its command, unframed: the echoed command line and the vty prompt are removed. A capture that still holds either is refused. A leading echoed line is reported as not JSON output, and a trailing prompt as a JSON syntax error. Each capture is at most 1 MiB, and all captures together are at most 32 MiB.

The declared fields are the manifest’s claim. netdoc checks the FRR version, the command, and the VRF. It does not check which router produced a capture, and the report labels every declared field as declared, not verified.

A node with any LSDB command needs exactly two show ip ospf json captures, one show ip ospf database detail json, and one show ip ospf route json. A manifest that gives such a node any other set is refused with exit 2. Two show ip ospf json captures of one node are accepted only as the before and after pair, taken at different times. Any other repeated command for one node is refused as a duplicate.

Import exit codes

Situation Exit
Every capture accepted and every neighbor record mapped 0
A capture refused, including an LSDB capture, or a neighbor record left unmapped. With --write-topology, nothing is written. 1
Invalid manifest, unsafe or unreadable capture, a flag that cannot be combined with the import, or a topology that cannot be written 2

Exit 0 is not a health result. It means the captures were read and each neighbor record was mapped. It does not mean the network is healthy, that OSPF is complete on every router, or that every router was captured. OSPF findings and LSDB findings do not change the exit code. Read the LSDB comparison for them.

Report

The text report names the result, lists each capture with its declared fields and its status (with the reason for a refusal), lists each neighbor record with its state and whether it was mapped, then the OSPF findings, and then lists what the import does not read and what the result does not establish. With LSDB captures, it also has an OSPF LSDB comparison block. Each node has its guard result, then each finding with its limit, then the facts its captures reported. The facts are the process state, the LSDB and its reconciliation, the advertisements, the calculated routes, and the unsupported counts. Each list shows its first 20 items and says how many it left out. The JSON report has every item.

The OSPF findings are the ones --explain gives for the same evidence, as the OSPF section describes, read over every accepted capture. They are reported for an incomplete import too, so exit 1 still shows them. A refused capture and an unmapped neighbor record write no evidence, so they give no finding, and what they report is unknown, not absent. When either exists, the block ends with a not analyzed line that counts them, and it is shown even when the accepted captures give no finding, with the line no finding from the accepted captures. Otherwise a report with no finding, such as one from interface captures alone, has no block. The JSON ospf key is present only when there is a finding.

An accepted show ip ospf interface json capture reports an observed local OSPF router ID only when every interface record provides the same usable IPv4 ID. In JSON, this value appears as captures[].router_id. A missing, invalid, unspecified, or conflicting ID is omitted rather than guessed. Conflicting IDs may produce a capture note. A neighbor record’s router ID identifies the neighbor, not the local router. These identities are reported by FRR, not independently verified.

--json prints one object. Its version is 1.

  • version, protocol (always "ospf"), manifest, complete, and source_node (when the manifest names one).
  • neighbors_complete and routes_complete, which are always false.
  • counts: captures, accepted, refused, records, mapped, and unmapped.
  • captures: each with file, declared (source, node, vrf, frr_version, command, and collected_at), accepted, reason when refused, empty (accepted with no neighbor or interface records), interfaces, neighbors, optional router_id for a unanimous usable local identity on an accepted interface capture, and notes when present.
  • records: each neighbor record with source, node, vrf, local_interface, neighbor_address, router_id when present, state, area, mapped, remote_node and remote_interface when mapped, and reason when present.
  • ospf: the OSPF findings, in the shape --explain prints. It is present only when there is a finding.
  • ospf_lsdb: the LSDB comparison. It is present only when the manifest has LSDB captures. Each node also names the source and declared collection time of its LSDB and route captures (lsdb_read, routes_read) and lists its unsupported counts (unsupported).
  • not_imported and limitations: the same lines the text report prints.
  • topology: requested, written, path when --write-topology was given, and reason when the topology was not written.

Topology output

--write-topology FILE writes the imported topology in the --explain format. It is written only when every capture was accepted and every neighbor record was mapped, so an incomplete import leaves no file. The file holds the source_node in the default VRF, the OSPF neighbors and interfaces from the captures, and neighbors_complete and routes_complete both false. --explain reads it and reports the route as unknown, because the topology carries no routes. It still shows the OSPF findings.

--write-topology needs source_node in the manifest, and the manifest must hold a default-VRF show ip ospf interface json capture for that node.

LSDB captures do not change the file. The same neighbor captures written with and without the LSDB captures give the same bytes.

The file is written to a temporary name in the target’s directory and published by hard link, so an existing file is never replaced. A filesystem without hard links, such as some FAT, SMB, and network filesystems, refuses the write with exit 2. There is no fallback to a rename or a copy. The temporary file is removed after each write attempt, including a failed one. A crash or a kill can still leave it behind. The file is not synced to disk before it is published.

LSDB comparison

With LSDB captures, the import compares one router’s OSPF link-state database with its calculated OSPF routes. The comparison is a second reading of the same router. It writes nothing to the topology and adds no observation. It does not reach the route path or --explain.

Each router needs four captures, taken in this order:

Role Command Holds
A show ip ospf json Process state before the bracket: router ID, timers, SPF counters, and per-area LSA counts and checksums.
B show ip ospf database detail json The link-state database, one entry per LSA.
E show ip ospf route json The calculated OSPF routes.
D show ip ospf json Process state after the bracket.

A and D bracket B and E. The comparison runs only when the checks below show OSPF unchanged between A and D.

The guard decides whether a node is compared. It fails, and the node gets only ospf_comparison_unverified, when any of these holds:

  • A capture of the bracket is refused or missing, or A and D do not both report a router ID. FRR 10.7.0 prints 0.0.0.0 as the router ID of an OSPF instance that has not taken one, and the report reads that as no router ID, not as a router. An absent instance prints no JSON body, and that capture is refused.
  • The router ID changed between A and D.
  • The holdtime maximum or the SPF delay changed between A and D. The report names the timer and both values. A timer change is a configuration change, so the report does not say that SPF ran.
  • A holdtime maximum or an SPF delay is above 600000 ms in A or D. That is the largest value timers throttle spf accepts (FRR 10.7.0, ospfd/ospf_vty.c). The report names each such timer, and no bound is formed from it.
  • The window from A to D does not exceed the bound. The bound is the largest of three values: holdtime plus SPF delay read from A, the same sum read from D, and one second.
  • The area inventory, an area’s SPF counter, or an area’s LSA count or checksum changed between A and D. The AS-external count or checksum changed too.

A failed guard names each failed check in the report. The guard never passes on a partial bracket.

A node gets the findings below, whatever its guard result. Each finding names its kind, its strength, and a limit that says what the finding does not establish. lsdb_prefix_not_calculated comes only from a node whose guard passed.

Kind Strength Meaning
lsdb_prefix_not_calculated consistent_with The LSDB advertises a prefix, in a router LSA stub link or an AS-external LSA, and the calculated routes have no route for it.
lsdb_incomplete unknown A capture was refused, the calculated route table is empty or not captured, the LSDB holds content the reader does not recognize, its router ID differs from the process state, or its counts disagree with the process state. Nothing is concluded about the prefixes in that scope.
ospf_comparison_unverified unknown The guard failed. No prefix is compared for the node.

The comparison compares the prefixes of router LSA stub links and AS-external LSAs. It skips an LSA at MaxAge, a self-originated AS-external LSA, and an AS-external LSA with the metric LSInfinity, as FRR’s route calculation does (ospfd/ospf_ase.c). Network, summary, ASBR summary, and NSSA LSAs are read and reported, not compared. Transit, point-to-point, and virtual links in a router LSA carry no prefix.

Findings say what the captures show. They name no cause, and they do not say that a route is installed, forwarded, or missing from the kernel. The advertising router of a finding is a router ID. It is not mapped to a node.

The text report also shows the facts each node’s captures reported, and marks them as reported, not verified. The process state lists the router ID, the holdtime maximum, the SPF delay, the AS-external count and checksum, and each area’s SPF counter and LSA counts. The LSDB block lists each area and type with its LSDB count and checksum, the process count, and whether they match. It then lists each advertisement with its advertising router, sequence, age, and prefixes. The prefixes of one advertisement stop at five. The calculated routes list each prefix, route type, cost, area, and next-hop count. The text names the layer each fact comes from. These are the LSDB and the calculated OSPF route table, not the RIB or the kernel forwarding table. A node whose captures were not read says not read for that section. Legacy reports with no LSDB captures show none of this.

The JSON report adds the next hops of each calculated route. forwarding_hops lists each address hop by its address, its interface, and the routers that advertised it. Two routers that advertise the same address and interface give one hop with two advertisers. direct_interfaces lists each interface the route reaches with no next-hop address. next_hops is still the raw count of nexthops entries FRR printed, including any entry the reader could not use. next_hop_status says how completely the entries were read:

  • complete: every entry is identified and none repeats. forwarding_hop_count is exact. It is the number of forwarding_hops entries plus the number of direct_interfaces entries.
  • explicitly_empty: FRR printed an empty list. The count is 0.
  • incomplete: at least one entry has no usable address or interface. The listed hops are a lower bound, and the count is omitted.
  • ambiguous: an entry repeats another, or a key matches ip, via, advertisedRouter, or directlyAttachedTo only after case folding. The count is omitted. An entry with such a key contributes no hop.

A route is ECMP when its exact forwarding_hop_count is 2 or more. That describes what OSPF calculated. It does not show that the RIB or the kernel uses those hops. An incomplete or ambiguous route gives no ECMP answer. metric_status is unrepresentable when a printed cost, or the type2_cost of an N E2 route, is above 4294967295. FRR 10.7.0 cannot print such a value. The route stays in the report.

The text report still prints the raw next_hops count. For a route with one forwarding hop and two advertisers, the text says 2 and the JSON says 1.

The comparison reads the default VRF only. Opaque LSAs are counted as unsupported and not decoded. A capture is limited to 1 MiB, like any other capture.

What the import does not read

The report lists these, so a reader does not take the topology for the whole network.

  • Routes: the RIB and FIB are not read, so the topology has no routes.
  • The OSPF link-state database is not read, unless the manifest has LSDB captures. Then it is read for the LSDB comparison only, and it is not written to the topology.
  • With LSDB captures, the OSPF route table (show ip ospf route json) is read too, for the LSDB comparison only. It does not feed the topology or the route path.
  • Areas are read per neighbor record and reported, but not written to the topology. An interface’s plain dotted area is written as ospf.effective_area, unless it is qualified with [Stub] or [NSSA], reads (incomplete), or is missing, or unless a neighbor record on that interface names a local address that the interface record does not give, or the record may describe another OSPF interface on the name, as the OSPF section explains. The capture’s notes say why.
  • Only the primary interface address is used. Secondary addresses are not read.
  • No other FRR output field is read. The interface fields read are ipAddress, ipAddressPrefixlen, routerId, area, cost, mtuBytes, networkType, state, timerPassiveIface, timerHelloInMsecs, drAddress, and bdrAddress. mtuBytes is written as ospf.mtu on the control interface, an integer from 1 to 65536, where 65536 is the Linux loopback MTU. An absent mtuBytes writes nothing. A null, malformed, or out-of-range value writes nothing, and the capture’s notes say why. No finding reads ospf.mtu. drAddress and bdrAddress are checked against the interface’s subnet to detect a record that may describe another interface. They are never written. ospfIfType is not read.
  • The source address is not set, so the topology claims no address for the source node.
  • No checks or boundaries are written.

What the result does not establish

  • A neighbor record with no router ID (noNbrID) is never mapped, even when its address is unique.
  • A captured interface with no usable ipAddress, either absent, null, not IPv4, or unspecified, multicast, or broadcast such as 0.0.0.0, leaves address ownership unknown for the whole import. No neighbor record maps while that holds, even one whose address is unique.
  • An interface’s ospf.effective_area is the one area FRR printed for its name. FRR keeps one record per name, so a second OSPF area on the same interface is not seen. When a neighbor contradicts that record, the name’s area and its other facts are withheld, and the neighbor record stays unmapped as before. The same facts are also withheld when the record has timerPassiveIface true beside a timerHelloInMsecs key, or when a DR or BDR address is a valid IPv4 address outside the interface’s subnet. An absent, null, malformed, or 0.0.0.0 DR or BDR address is not checked against the subnet. Without a usable prefix length the subnet check is skipped, and a note says so. The withheld keys are the area, ospf.cost, ospf.network_type, ospf.interface_state, ospf.passive, and ospf.mtu. Such a record may describe another OSPF interface on the name, so the withholding is a caution and not a confirmed fault. The report says which case applied.
  • Neighbors and routes are never claimed complete.
  • An LSDB finding does not establish a cause, a failure, or a forwarding state. A passing guard does not establish that OSPF is healthy.
  • A record that is not mapped keeps its state, address, and reason in the report, so the reader sees what FRR reported and why it was not used.

Tunnel import from captures

--import-tunnels reads the tunnel captures a manifest names and adds the tunnels they prove to the base topology the manifest names. It is offline. It runs no probe and contacts no device, so the captures are the only evidence.

netdoc --import-tunnels manifest.json [--write-topology topology.json] [--json]

The manifest is JSON with "version": 1, a "topology" that names the base topology file, and a "captures" array. Each capture names its file, a source label, a node, a command, and a collected_at time with an offset. The command is one of:

  • ip -d -j link: one node’s device list from ip -d -j link. It is the only source of device kinds, masters, and underlay devices, so a node needs exactly one.
  • ip -j rule show: one node’s IPv4 policy rules. The command lists IPv4 rules only, which is the family GRE and IPIP outer packets use, and the only family a WireGuard record is proven for. GRE, IPIP and WireGuard devices all need their node’s rules. A node has at most one capture of them, and two are refused.
  • ip -j addr show: one node’s addresses. It is read only to show whether a kernel fallback device has an address. It never proves a tunnel. A node has at most one capture of it, and a node with a fallback device needs one.
  • ip -4 -j route show table all and ip -6 -j route show table all: one node’s IPv4 and IPv6 routes, in every table. They are read only to show whether a route names a kernel fallback device. A node has at most one capture of each, and a node with a fallback device needs one of each.
  • wg show all dump: that node’s WireGuard dump.
  • ip -j route get fibmatch <endpoint>: the full FIB route matched for that node’s WireGuard peer endpoint. The command is spelled exactly that way, with the endpoint written as a lowercase address with no zone, no prefix, and no other argument. Ordinary ip -j route get output is not accepted because it can show only the selected ECMP nexthop. Any other spelling is refused as a command the import does not read. A node has at most one capture for an endpoint.

Paths are relative to the manifest’s directory. A symbolic link, a path that climbs out of that directory, and two captures of one file are refused.

Original wg show all dump files contain interface private keys and peer preshared keys; treat them as sensitive. Optional live-test evidence export redacts those fields while retaining the dump’s tab-separated rows and columns.

What the import does with each device:

  • A gre or configured IPv4 ipip device is recorded only when its underlay routing table is proven. The device and its underlay device must sit in the default routing domain, the device must carry no fwmark, and the node’s ip -j rule show capture must list exactly the kernel default IPv4 rules: priority 0 to local, 32766 to main, and 32767 to default, each listed once and selecting source all. Each rule must carry its priority, src, and table keys exactly once, spelled exactly so. A rule with a key missing, repeated, or spelled in another case, or with a null or non-integer priority, refuses the capture rather than being read around. Those rules send every IPv4 lookup to the main table, so the table is known without any other fact. Any other rule, a missing or repeated rules capture, a VRF, or a mark refuses the device. The record keeps the source and collection time of both captures it rests on, the ip -d -j link capture and the ip -j rule show capture.
  • Configured IPIP uses the same additive tunnels record and underlay proof as GRE. remote any, IPv6 remotes, missing or ambiguous underlay devices, foreign namespaces, externally controlled devices, VRFs, fwmarks and unsupported policy rules remain refused. This proof identifies the routing domain, not a unique physical forwarding path. The inert kernel fallback tunl0 behavior below is unchanged. Live before/after fault observations and reproducible provenance are in ipip_acceptance_live.
  • A kernel fallback device (gre0, gretap0, erspan0, or tunl0) is listed under inert devices, and makes no record, only when all of these hold. Its link type, zero link address, and info data are exactly what the kernel gives an inactive fallback of its kind, with netns-immutable set and no other info data attribute. It is down, has no address, and is neither enslaved nor linked to another device or namespace. No route on IPv4 or IPv6 names it, directly or through a nexthop, and no route uses a nexthop object. The node’s policy rules are the kernel default set. The node needs one address capture and one capture of each route command, and the address capture must list the same devices, indices, link addresses, and up states as the ip -d -j link capture. Every capture of the node must have a collection time, and all of them must fall within 250 milliseconds of one another, from the earliest to the latest. A wider spread refuses the node’s fallbacks. The bound narrows the window in which a change could go unseen, but the captures are not one atomic snapshot, so a change inside the window is not excluded. Any other fallback is refused with the reason, and so is every fallback of a node whose evidence is missing or contradictory.
  • A WireGuard interface is recorded only when its endpoint’s underlay lookup is proven. The interface must carry exactly one peer with an IPv4 endpoint, its fwmark must be off, and it must sit in the default routing domain. Its node’s IPv4 policy rules must be the kernel default set. One ip -j route get fibmatch <endpoint> capture must show the full FIB route covering the endpoint, as a single unicast path out of the main table through an egress device that the same ip -d -j link capture lists in the default routing domain. The egress device must not be the tunnel itself and must not be a VRF. Multiple nexthops and a route that names an unresolved nexthop object do not prove uniqueness; the endpoint remains unproven and the lookup capture is refused. Ordinary ip -j route get output is not sufficient because it can show one selected device from an ECMP route without showing the complete nexthop set. Other refusal cases include a route type other than unicast, a table other than main, a dead or linkdown flag, a destination prefix that does not cover the endpoint, and an error field that is not the integer 0. The record keeps the format every record has. The report adds, for each WireGuard record, the source and collection time of the dump (endpoint_source, endpoint_collected_at), of the lookup (lookup_source, lookup_collected_at, lookup_command), and the egress_interface, beside the device and rules captures. An endpoint with no matching FIB lookup capture is reported as unproven; this is separate from an ambiguous or unresolved nexthop capture, which is also left unproven and refused. Multi-peer devices, devices with no peer or no endpoint, and fwmark-steered devices are refused.
  • An IPv6 WireGuard endpoint is refused, with any lookup captured for it. The rules capture lists IPv4 rules only, so the table an IPv6 lookup ends in is not shown, and a lookup capture cannot stand in for it.
  • A FIB route capture that is malformed, is one of several for the same endpoint, names an endpoint no usable WireGuard interface of its node reports, or belongs to an interface that was refused, is refused with its reason and source. It is never skipped.
  • A device of kind vrf, bridge, bond, vlan, macvlan, ipvlan, veth, or dummy, and a plain ether or loopback device with no kind, is accepted and carries nothing into the topology. Any other kind is refused by kind, unless it is a kernel fallback listed above as inert. A name is never read as a kind.
  • A capture of a node the base topology does not describe is refused, so a mistyped node name is never a clean import.
  • Keys in a dump are never kept, so none reaches the report, the topology, or an error.
  • A device, address, or route object is refused when any key repeats in it, at any depth. It is also refused when a key the import reads is spelled in another case, such as IFNAME for ifname. Otherwise a key the import does not read is left alone, whatever its spelling. A policy rule is stricter: a key it does not read refuses the capture.

Every modeled node of the base topology needs a usable ip -d -j link capture. A node is modeled when the base holds a configured, control, or FIB observation for it. Intended and observed observations do not make a node modeled. A node with no capture, or whose capture is refused, leaves the import incomplete. A capture that shows no device inventory, such as JSON null or [], is refused, so it never reads as a node with no devices.

Exit 0 means every modeled node has a usable capture and no candidate was refused or left unproven. A complete import writes the base topology with its tunnel records added. The kernel creates fallback tunnel devices (gre0, gretap0, erspan0, and tunl0) once their modules load. The lab saw them in every fresh network namespace; real hosts were not tested. A node whose fallbacks are all inert completes an import, and each one is listed as inert. A fallback that is not inert is refused, so such a node never completes an import, and --write-topology writes no file. Exit 1 means the import is incomplete: something was refused or left unproven, or a modeled node has no usable capture. With --write-topology nothing is written. Exit 2 means the manifest, the base topology, a capture, or the output cannot be used.

--write-topology FILE publishes a complete import by hard link, so it never replaces an existing file. A path that already exists is refused, and that includes the base topology’s own path.

--json prints one report object. Its fields are version, manifest, base_topology, complete, tunnels, unproven, refused, inert_fallbacks, nodes_without_capture, not_imported, limitations, and topology. The topology object holds requested, written, path, and reason. nodes_without_capture lists each modeled node with no usable capture, including one whose capture was refused; that refusal is also under refused. inert_fallbacks lists each kernel fallback device the captures show inert, with its node, interface, kind, the sources that show it, and the from and until collection times that bound them. An inert device is a fact about the captures, and it is never a record in the topology. The text output reports the same results.

FRR BGP summary import

--import-frr-bgp reads the BGP summary that FRR printed for each router and reports each router’s ID, local AS, and peer sessions, with each session state as FRR printed it. A show ip bgp json route table, when the manifest names one, adds that router’s learned routes. It is offline. It runs no probe, opens no socket, and contacts no router, and it writes no topology.

netdoc --import-frr-bgp manifest.json
netdoc --import-frr-bgp manifest.json --json

The manifest is the version 1 format of the OSPF import. Every capture must declare "frr_version": "10.7.0" and "vrf": "default", and its "command" must be show ip bgp summary json or show ip bgp json. Each node may have one summary capture and one route table, and when both print a router ID and local AS, they must agree. A source_node is refused, since a BGP import writes no topology.

Unlike the OSPF import, a BGP capture is the raw VTY transcript, byte for byte, as FRR printed it. Its framing is checked: the command echo and CRLF come first, then one JSON object, then CRLF and the prompt of the declared node, such as r1> . Nothing may follow the prompt. A capture with a missing prompt, a blank line before or after the object, or another node’s prompt is refused. A route table may have spaces between its closing brace and that CRLF, since FRR prints one there; no other byte may. Each capture is at most 1 MiB.

The import reads routerId and as from ipv4Unicast, and from each entry of peers it reads localAs, remoteAs, and state. The entries are keyed by peer address. peerState is not read, since FRR prints OK there, and that is not a session state. A key that differs from a known one only in case is refused, and a key repeated at any depth is refused. An AS number must be an integer from 1 to 4294967294, because 0 and 4294967295 are reserved. A session state is shown verbatim as non-empty plain text. A peers object with no entries is observed empty, and the report says so.

Each peer also gives one finding under findings, in the shape the OSPF findings use: kind session_state, strength reported, the node, vrf, and peer, a detail that repeats FRR’s state and the remote AS, a limit, and evidence naming the capture’s source and collected_at. The limit is written for the six states RFC 4271 names: Idle, Connect, Active, OpenSent, OpenConfirm, and Established. States are matched exactly as printed. Any other state text, such as Down or a state with a qualifier, is shown as printed, and its limit claims no meaning or cause, so it neither confirms nor denies that the session is down. None of the five RFC 4271 states other than Established is reported as down, and none names a cause. Established does not show that any route was exchanged. Every limit says that the state alone does not show whether the peer is reachable, and that the far end is not identified: the summary names a peer by address and maps no address to a node, even when another capture holds that address. No finding compares AS numbers between peers or reports a one-sided session. The text report lists the findings in a BGP findings block before Not read:. A capture with an empty peer collection gives no finding. Findings never change the exit code.

Each capture names the family of the section it read as observed_family: IPv4 unicast, the family of ipv4Unicast. This is the section FRR printed, not a family any peer negotiated, and no peer carries a family in the report.

A show ip bgp json capture is read as the router’s BGP table. Its routerId, localAS, and vrfName are read when FRR prints them, and a printed vrfName must be default. A path is learned only when its peerId names a peer, the address FRR recorded for the session that sent the path. A peerId of (unspec), an absent peerId, or an unspecified address is never learned, and such paths are counted instead. The JSON report lists each route table under route_tables, and each learned route under that table’s learned_routes, with its prefix, peer, path_from (FRR’s pathFrom, as printed), as_path, bgp_origin (the BGP ORIGIN attribute), and next_hops, with prefixes sorted by address and prefix length and paths within each prefix retaining FRR’s order. Each route table also reports its prefixes, no_peer_paths, and unknown_peer_paths. The text report lists 20 learned routes per table and says how many more there are; the JSON report keeps all of them. FRR’s valid, bestpath, multipath, used, and selectionReason fields are not read. A path’s optional stale key must be true or false, and any other value is refused, as is a key that differs from a known key, stale included, only in case. Only a simulated prefix withdrawal or a simulated session loss reports it.

The report version stays 1. The route_tables key is written only when the manifest names a route table. The findings key is written only when a summary names a peer, and the BGP findings block is the only text it adds.

Exit 0 means every capture was valid. It does not mean a session is up. Exit 2 means the manifest, a capture, or the output cannot be used. When the manifest or a capture cannot be used, nothing reaches standard output. When standard output fails during the write, it may already hold part of the report.

What the import does not establish:

  • Without a route table, no route is claimed. Summary pfxRcd counts are not read, and they identify no route.
  • A node with no route table has its routes not read. A missing table does not show that a route was withdrawn, and an empty table is observed empty at its collection time, not withdrawn.
  • A learned route is a path in FRR’s BGP table at the table’s collection time. It is not a kernel-installed or FIB route, and it is not the path FRR selected as best: prefixes are sorted by address and prefix length, while paths within each prefix retain FRR’s order; neither ordering indicates route preference.
  • A learned route names the peer FRR recorded for it. It does not show that the session was Established. A table and a summary have separate collection times, and netdoc does not check that they describe one moment.
  • The BGP origin names no protocol, so the protocol that first carried a route is not identified beyond FRR’s BGP table. pathFrom is FRR’s label and is not checked against the session type.
  • The observed family is the ipv4Unicast section. It does not show which address families any session negotiated.
  • A node or peer that no capture names is unknown, not down.
  • A session state is FRR’s value at the capture’s collection time, not a current one.
  • Which router produced a capture is the manifest’s claim. The checks are the prompt in the transcript and, for a route table, that a printed router ID and local AS agree with its node’s summary.

Simulated prefix withdrawal

--withdraw-bgp NODE:PEER:PREFIX adds to the BGP import a simulated withdrawal of PREFIX by PEER, read against NODE’s recorded show ip bgp json table. It sends nothing, and it reads nothing beyond the manifest’s captures.

netdoc --import-frr-bgp manifest.json --withdraw-bgp r1:10.0.1.2:10.10.2.0/24
netdoc --import-frr-bgp manifest.json --withdraw-bgp r1:10.0.1.2:10.10.2.0/24 --json

The node ends at the first colon and the prefix starts after the last, so the peer between them may be an IPv6 address. The peer is matched exactly against the address FRR printed as each path’s peerId. The prefix must be a canonical IPv4 prefix, such as 10.10.2.0/24, because the table is IPv4 unicast in the default VRF.

The simulation removes every path for PREFIX in NODE’s table whose peerId is PEER, and changes nothing else. Add-path identifiers are not read, so several paths from one peer are removed together. Each path left for the prefix is one of these kinds:

  • learned: from another peer that NODE’s summary shows Established, or from any other peer when the manifest names no summary for NODE.
  • no_peer: FRR recorded no peer ("peerId": "(unspec)"), as for a prefix the router originates itself.
  • contradicted: from a peer that NODE’s summary lists in another state, or does not list. This means only that the summary does not show that path’s peer Established; the report does not call it a disagreement.
  • unattributed: its peerId is absent or an unspecified address, so it may be PEER’s own path.

Each path, removed or remaining, also carries stale_flag, what its stale key recorded: recorded_true, recorded_false, or not_recorded. The flag never changes a kind, and what set it is not read.

The outcome rates the prefix at NODE only:

  • paths_remain: at least one remaining path is learned or no_peer and not recorded_true. It means only that such a path is listed in the recorded table, a candidate FRR listed. It does not establish that the path is valid, that FRR would select it, that it would be installed, or how traffic is forwarded.
  • unknown: paths remain, but none counts. The reason names each group without claiming a cause: paths recorded stale, since FRR may still remove them and what it later does is not in the evidence; paths that name no peer, since any of them may be PEER’s own; and paths from peers the summary does not show Established, since the captures do not show why the table lists them. A path is named in the first group that fits.
  • no_recorded_path: the table lists no other path for the prefix. Another peer may still send one once the withdrawal propagates, which no capture shows, so this is not reported as a route loss.

A prefix withdrawal is not a session loss. The report counts PEER’s learned paths for other prefixes as peer_other_paths and leaves them in place, and it shows PEER’s state as NODE’s summary printed it. A state other than Established, or a summary that does not list PEER, is reported as observed, with a note that the captures do not show why the table lists PEER’s paths. It is not called a disagreement between the captures, and it does not change the outcome. Other prefixes are not rated, because aggregation, conditional advertisement, policy, and next hops that resolve through the withdrawn prefix are not in the evidence.

forwarding_impact is always unknown. The capture is FRR’s BGP table: the import reads no zebra RIB, kernel FIB, or topology, and it ties no BGP path to a forwarding entry, so a withdrawal is never rated reachability_lost or redundancy_lost. --explain reads no BGP table, and --withdraw-bgp cannot be combined with it.

The JSON report gains a withdrawal object with node, vrf, family, peer, prefix, outcome, reason, removed, remaining, peer_session_state, peer_other_paths, forwarding_impact, forwarding_reason, and evidence. peer_session_state is absent when NODE’s summary does not list PEER or the manifest names no summary for NODE. evidence names the table capture, then the summary capture when there is one. Each path in removed and remaining carries its kind, its peer when it names one, its session_state when NODE’s summary lists that peer, stale_flag, path_from, as_path, bgp_origin, and next_hops, and the paths keep FRR’s order. The report’s limitations gains the withdrawal’s limitations. The report version stays 1, and without the flag the report is unchanged. The text report adds a Prefix withdrawal block before BGP findings. It lists 20 paths of each list and says how many more there are; the JSON report keeps all of them.

--withdraw-bgp exits 2 and writes nothing to standard output when the selector is malformed, when it is used without --import-frr-bgp, when the manifest names no route table for NODE, or when NODE’s table lists no path for PREFIX from PEER. A mistyped selector therefore never reads as a withdrawal that changes nothing. When some of the prefix’s paths name no peer, the refusal says so, since one of them may be PEER’s.

The captures in testdata/frr/10.7.0/bgp/withdraw record a live FRR 10.7.0 run in which a peer withdrew prefixes while both sessions stayed Established. The tests check each prediction against the table FRR printed after the withdrawal. That run has no peer that sends a path only after another is withdrawn, so it shows that the table arithmetic matches FRR, not that no_recorded_path is final.

Simulated BGP session loss

--fail-bgp-session NODE:PEER adds to the BGP import a hypothetical loss of NODE’s session with PEER, read against NODE’s recorded show ip bgp json table. It sends nothing, and it reads nothing beyond the manifest’s captures.

netdoc --import-frr-bgp manifest.json --fail-bgp-session r1:10.0.1.2
netdoc --import-frr-bgp manifest.json --fail-bgp-session r1:10.0.1.2 --json

The simulation assumes that every path NODE’s table attributes to PEER, for every prefix, is removed, and that nothing else in the table changes. Every outcome rests on that assumption, and the report states it in both text and JSON. It does not claim that NODE would remove the paths, when it would, or what forwarding does afterwards.

It needs --import-frr-bgp, not --explain. A topology file records forwarding entries and OSPF adjacencies, and a forwarding entry may record that its origin is BGP, but it holds no BGP session, peer, or path to remove; --fail-ospf rates a route through such an entry unknown. The BGP import is the only mode that reads NODE’s BGP table and summary, so the outcomes are the import’s (paths_remain, unknown, no_recorded_path), not the --explain outcomes reachability_lost and redundancy_lost. It cannot be combined with --withdraw-bgp, since a run simulates one failure.

The node ends at the first colon and the peer is the rest, so the peer may be an IPv6 address. A zone is refused. The peer is matched exactly against each path’s peerId and the summary’s peer keys, so ::ffff:10.0.1.2 does not match 10.0.1.2.

Each prefix with at least one path from PEER is rated. Its other paths have the withdrawal’s kinds: learned, no_peer, contradicted, and unattributed. Each path also carries the withdrawal’s stale_flag.

  • paths_remain: at least one remaining path is learned or no_peer and not recorded_true. It is a candidate FRR listed. Whether it is valid, which path FRR would select, and whether it would be installed are not read.
  • unknown: paths remain, but none counts. The reason names each group without claiming a cause: paths recorded stale, since what FRR later does with them is not in the evidence; paths that name no peer, since any of them may be PEER’s own; and paths from peers the summary does not show Established. A path is named in the first group that fits.
  • no_recorded_path: the table lists no other path for the prefix. Another peer may still send one, which no capture shows, so this is not reported as a route loss.

PEER’s session state is reported only as the summary observed it, in peer_session_state (FRR’s text, verbatim) and peer_session_reading:

  • established: the summary printed Established. The loss of that session is assumed, not observed.
  • not_established: the summary printed another state, such as Active or Idle (Admin). This is not read as an established session being lost. When the table still attributes paths to PEER, the report says the captures do not show why, and when any of them is recorded stale, it says the captures do not show what set the flag.
  • not_listed: the summary does not list PEER, while the table attributes paths to it. The captures do not show why, and they may describe different moments.
  • not_captured: the manifest names no summary for NODE. Other peers’ paths are then learned, as for a withdrawal.

The reading never changes an outcome. A stale flag is not read as proof that a session failed: FRR 10.7.0 printed it on paths it kept after a peer with graceful restart lost its session, and it may set it in procedures the lab did not exercise.

stale_retention is always unknown: whether NODE would keep PEER’s paths as stale, and for how long, depends on the graceful-restart and long-lived graceful-restart capabilities the session negotiated and on how it ends, and no capture read shows either. forwarding_impact is always unknown, for the reasons given for a withdrawal.

When the summary lists PEER and the table attributes no path to it, the result is empty with exit 0, and removed_reason says this is what the table recorded, not a finding that PEER advertised nothing. A prefix with no path from PEER but a path that names no peer is listed in unattributed_prefixes and not rated, and removed_reason notes such prefixes.

The JSON report gains a session_loss object with node, vrf, family, peer, assumption, peer_session_state (absent when the summary does not list PEER or there is no summary), peer_session_reading, peer_session_reason, removed_reason, stale_retention, stale_retention_reason, prefixes, unattributed_prefixes, forwarding_impact, forwarding_reason, and evidence. Each entry of prefixes has prefix, outcome, reason, removed, and remaining, and each path is the withdrawal’s path object. Prefixes are in address order, and paths keep FRR’s order. The report’s limitations gains the session-loss limitations. The report version stays 1, and without the flag the report is unchanged. The text report adds a BGP session loss block before BGP findings, with observed:, assumed:, and recorded: lines, the stale flags of the removed paths, and the outcome counts. It lists 20 prefixes, 20 paths of each list, and 20 unattributed prefixes, and says how many more there are; the JSON report keeps all of them.

--fail-bgp-session exits 2 and writes nothing to standard output when the selector is malformed or empty, when it is used without --import-frr-bgp or with --withdraw-bgp, when the manifest names no route table for NODE, or when neither NODE’s table nor its summary names PEER. That last refusal says whether a summary was read, and how many of the table’s paths name no peer, since one of them may be PEER’s.

The captures in testdata/frr/10.7.0/bgp/session record three live FRR 10.7.0 runs in which r1 lost its session with r2: r2’s bgpd killed under FRR defaults, the same with graceful restart configured at r2, and an administrative shutdown at r1. The tests check each prediction against the tables FRR printed afterwards. Where FRR removed the paths, the remaining paths matched the prediction. With graceful restart, FRR first kept every one of r2’s paths, each recorded stale, beside an Active summary, and removed them when the restart time ran out; the administrative shutdown removed them at once with the same configuration. These runs show the table arithmetic for one session loss of each kind, not when FRR removes paths in general, propagation, or forwarding.

Remote diagnosis over SSH

--via <ssh-destination> performs the diagnosis on another machine and presents it here:

netdoc --via server example.com
netdoc --via server --json --check dns,target_tcp example.com
netdoc --via server --save remote.ndoc example.com
netdoc --two-sided --via server example.com

It is remote execution, not remote monitoring: one connection and one answer for an ordinary target run. A profile uses one such connection per component and aggregates the completed answers. Live two-sided diagnosis pairs one such remote run with one concurrently started local ordinary run, then reads their canonical snapshots locally. There is no daemon, no agent, no installation step, and no persistent state on either machine.

How it runs

The local netdoc starts the system ssh client with a fixed argument list:

ssh -T <destination> netdoc --remote-worker

No local shell is involved: ssh is executed directly with those arguments as separate argv elements, never as a command string. The destination is passed through exactly as typed, so ~/.ssh/config aliases, user@host, ssh:// URLs, ports, IdentityFile, ProxyJump, and agent authentication behave the way they do for a direct ssh invocation. netdoc reads no SSH configuration itself and reimplements none of it. Password, passphrase, and host-key prompts still reach your terminal, because ssh reads those from the terminal rather than from the pipe the protocol uses.

--remote-worker puts the far end in worker mode. It is not a flag: it is recognized before any flag parsing, accepts nothing alongside it, and appears in no help output, because it is one netdoc talking to another. That is also what keeps the remote command line a fixed string: the target, the timeout, the probe selection, and every other setting travel as structured data on standard input, so no user text is ever spelled into a command a remote shell will read. The same invocation is therefore correct whether the SSH host’s default shell is POSIX sh, cmd.exe, or PowerShell.

The -T is deliberate. The exchange is a byte protocol on a pipe, and a pseudo-terminal would be free to translate line endings inside it.

The remote protocol

One JSON request in, one JSON response out, over the SSH session’s standard input and output.

The request carries the run settings as the user spelled them: target, --iface, --public-dns, --timeout, --check, and --skip. timeout_ms has held whole milliseconds since protocol 1, which is why restricting --timeout to that precision rather than adding a field keeps all three version pairings exact: a current netdoc sends only values an older worker already reconstructs unchanged, an older netdoc’s whole-millisecond value is honored here as it always was, and neither side approximates the other’s budget. A timeout_ms that no time.Duration can hold is refused before it is converted, so an out-of-range number cannot wrap into a small positive timeout. Nothing is pre-resolved locally, because the machine that will run the probes is the one that has to resolve it. Probe restrictions also travel inside skip: --no-reference-egress is resolved here into the probe IDs it removes, and an unknown-protocol target adds path_mtu unless explicitly requested with --check path_mtu. This keeps older workers from transmitting the PMTU payload by default. That is deliberate: skip has been honored since protocol 1, whereas a new request field would be ignored by any remote netdoc old enough not to know it, and a safety guarantee that a mismatched pair silently drops is worse than no flag at all. --iface is the clearest case for an ordinary remote run: it names an interface on the SSH host, so the local machine has no business resolving it against its own NICs. Live two-sided mode reuses this protocol unchanged for its remote half, but rejects --iface because the same spelling cannot also select a corresponding local interface honestly.

The response carries the two artifacts netdoc already publishes, built on the far end by the ordinary code paths:

  • the --json report, exactly as that machine would print it, and
  • the .ndoc snapshot, stamped with that machine’s build, operating system, and architecture.

Both are versioned external contracts in their own right, which is why the SSH protocol does not define a third schema over the probe structs. The envelope adds only what neither artifact says: which protocol version is being spoken, which build answered, and whether the remote could run the request at all.

The diagnosis is reached on the machine that probed, and never recomputed locally. This matters: the remediation for a finding is chosen by operating system, so a Windows host’s advice is not a Linux client’s to rewrite. The local side transports, presents, and decides the exit code, which is what a local run does with its own results.

What the local side does check is that the two artifacts describe the same run, before either of them reaches anything else. They are spent separately: the report decides what is printed and what the process exits with, and the snapshot is what --save writes to a file for a later comparison, so a pair that disagreed would leave a machine exiting 0 over a stored artifact recording a failure, with no way for a later reader to tell which half was the run. The snapshot is held to the .ndoc rules, which nothing has applied to it yet because it arrived inside the envelope rather than as a file, and then the overlap is compared: the build that answered, the target, ok, the checks in order with their statuses and causes, the verdict, the first failed check, and each finding’s identity, focus, confidence, evidence, typed causal evidence and counterfactual. A disagreement is reported as a protocol failure naming the field and both readings of it.

Only the overlap, and only its machine-readable part. The two formats are different contracts on purpose: the report carries remediation the snapshot does not, the snapshot carries observations and run options the report does not, and the derived sentences in both are documented as never parsed back. Nor does this check rely on refusing unknown fields. Both artifacts are decoded into the fields this build knows, so a newer remote’s additions are already gone before the comparison runs, and protocol 1 stays forward compatible with them.

The envelope’s protocol and error fields are fixed for all time, at every version. A netdoc that cannot speak the version it was handed still has to be able to say so in a way the version that asked understands, and that is only true if those two keys never move. The version itself moves for a change an existing peer would misread; adding an optional field is not that, and neither side rejects unknown keys.

Standard output is the protocol

On the SSH host, worker-mode standard output carries one JSON object and nothing else: no banners, no progress, no logs, no terminal formatting, no human-readable text of any kind. Anything meant for a person goes to standard error, where ssh delivers it to the caller.

Locally, the same discipline holds in the other direction. The provenance line naming the SSH host and the remote build goes to standard error, so netdoc --via server --json host leaves standard output byte-for-byte the report a local --json run leaves in a pipe. Live --two-sided --json instead writes only the existing netdoc.twosided.v1 result assembled after both ordinary artifacts are complete.

Exit codes

The exit code is netdoc’s, not ssh’s. A completed protocol exchange is a transport success, whatever the diagnosis concluded, and the worker exits 0 even when every check failed. The local side then applies the ordinary contract:

Situation Exit
The remote run completed with no failed row 0
The remote run had a failed row 1
The local run was interrupted before the answer arrived 1
ssh could not open the connection 2
No usable netdoc ran on the SSH host 2
The two ends do not speak the same remote protocol 2
The acquisition outlasted the overall bound below 2
The remote refused the request (a rejected target, an unknown probe ID) 2

The separation is the point. “The network you asked about is broken” and “I could not reach the machine to ask” are opposite conclusions, and a script must never confuse them. Each 2 says which one it was and quotes what the remote itself said.

The remote executable

The SSH host must already have a netdoc on the PATH of a non-interactive SSH session, new enough to know worker mode. netdoc does not install it, copy it, or modify anything on the far end.

NETDOC_VIA_COMMAND overrides the program to start. It is read on the local machine, never sent anywhere, and is for an installation that is not on that PATH, which is common enough on Windows and on macOS under a non-login shell. The value must be a plain path: letters, digits, and . _ - / \ : + @ ~, with no whitespace and no shell punctuation. That restriction is what lets one value mean the same thing to every remote shell, and a path containing a space is refused rather than guessed at.

When the remote produces no usable response, the error names the likely causes and quotes the remote’s own standard error, which is what distinguishes a missing executable from one too old to know the flag:

netdoc: -via: server: netdoc did not run on the SSH host (ssh exited 2)
  flag provided but not defined: -remote-worker
  run 'netdoc --help' for usage
Install netdoc on the SSH host and make sure it is on the PATH of a
non-interactive SSH session, or set NETDOC_VIA_COMMAND to its full path.
The netdoc there also has to be new enough to know --remote-worker.

Time limits

--timeout is the per-check probe budget and nothing else. It means on the SSH host exactly what it means locally: how long one check may spend before it gives up. It is not the time the whole remote run is allowed, because one diagnosis runs several checks one after another.

The acquisition has its own overall bound on top of that, because every part of it can stop making progress without anything failing: the SSH connection, the remote worker’s startup, the request on the wire, and the wait for the first response. A stalled transport that only a Ctrl-C could end is not an acceptable outcome for a script, so netdoc ends it itself.

That bound is derived from --timeout rather than fixed, so a deliberately larger probe budget always buys a proportionally larger remote run and can never be cut short by it. It is five probe budgets, plus one minute: five because that is the deepest chain of checks one pass can run one after another (the probe graph runs everything else concurrently), and the minute covers everything that is not probing, which is opening the connection, authenticating, starting the remote netdoc, the exchange itself, and teardown. With the default 4s timeout the ceiling is 1m20s; with --timeout 30s it is 3m30s.

Reaching it ends the local ssh process and reports an acquisition failure that names the destination, and it exits 2. It is not a diagnosis of the remote network: nothing was measured, and a run that had already produced a complete response is still returned even if the SSH session is slow to close afterwards. It is also not an interruption, which is the user’s own Ctrl-C and still exits 1. A profile stops at the component that timed out and reports that component; live --two-sided treats it as the remote acquisition failure it already knows, cancels the local side, and exits 2.

Cancellation

Interrupting a --via run terminates the local ssh process, which drops the channel, which closes the worker’s standard input on the far end. The worker treats the end of that stream as the caller having gone away and cancels the running probes, so a cancelled run does not leave a diagnosis finishing on a machine nobody is listening to. The local process exits 1, the code quitting before the chain finished already spends.

Live two-sided diagnosis gives the local and remote acquisitions one cancellation context. An interrupt stops both. A remote transport or protocol failure also stops the local probe set and exits 2 without producing a localization. A completed remote report containing FAIL rows is evidence, not a transport failure, so it remains available to the two-sided engine.

What crosses the wire, and what does not

The request holds only the run settings, all of which the user typed. The response holds the report and the snapshot, which is the same information a local run would leave on this machine anyway. Nothing else is collected, and no file on either machine is read on the strength of a path the other end supplied.

--support sanitization runs locally, on the snapshot that came back. That is deliberate: which artifact is safe to share is a decision about what leaves your machine, and it is the same decision, applied the same way, as for a local run. As locally, only the file is sanitized; a combined --json run still prints the full-fidelity report.

Both ends bound what they will read from the other, and neither trusts the other’s text: a response is one JSON object within a size cap, anything after it is a protocol violation, and every string that reaches a terminal is stripped of escape sequences and control characters first.

Limits

  • One connection per ordinary run and per profile component. Live two-sided diagnosis uses one connection for its remote half. --watch is not combinable with --via, because a watch that reconnected on every pass is a different feature.
  • There is no live progress. The probes run on the far end and the answer arrives when the run is finished, which is why --via is headless rather than a remote terminal UI.
  • The probe IDs given to --check and --skip are validated locally as well as remotely, so a probe ID that exists only on a newer remote netdoc is rejected before the connection opens.
  • The drill-down tools, the LAN map, and the SSH login form are terminal UI features and are not part of a remote run.

Route-policy change simulation

--change-route-policy NODE:VRF:import|export:PEER:POLICY with --explain attaches a recorded policy at one node, VRF, direction, and peer. It runs no commands against a router and sends no traffic:

netdoc --explain topology.json 10.10.2.2 --change-route-policy r1:default:import:10.0.3.3:R3-IN
netdoc --explain topology.json 10.10.2.2 --change-route-policy r1:default:import:10.0.3.3:R3-IN --json

The authoritative producer was chosen and validated before implementation: the genuine FRR 10.7.0 policy lab. Policy semantics come from enable-mode show route-map json and show ip prefix-list json. Running configuration is audit context only. The producer records the source identity, exact commands, timestamps, and SHA-256 checksums, with raw transcripts preserved. Checksums verify integrity; they do not authenticate the operator’s assertions about the source or time.

The topology’s optional route_policies array contains objects with node, vrf, direction, peer, policy, and captures. Each capture contains source, collected_at, frr_version, command, sha256, and raw. raw is the entire unmodified transcript encoded as a JSON string, including its CRLF and prompt. sha256 is the lowercase SHA-256 of those decoded raw bytes. Every capture needs a distinct source and command. Exactly these four commands are required:

Command Purpose Prompt
show route-map json Candidate policy rules NODE#
show ip prefix-list json Candidate exact IPv4 prefix-list matches NODE#
show ip bgp neighbors PEER json Baseline peer, session, and local identity NODE>
show ip bgp json Complete baseline candidate table and selected paths NODE>

The policy captures must be collected after the baseline table and neighbor captures. Router IDs, local AS, peer address, and default VRF must reconcile. The baseline peer must be Established with no path policy; existing-policy replacement is refused. Its ipv4Unicast.acceptedPrefixCounter must be a nonnegative integer equal to the number of distinct IPv4 prefixes with paths from that peer in the table. Multiple paths for one prefix count once. Missing, null, malformed, overflowing, or contradictory counts are refused for import and export alike. A zero count with no incoming paths supports export with unknown impact; import with no recorded peer path is refused.

Count agreement is a consistency check, not proof of route identities, attributes, or an atomic snapshot. Per-prefix ratings describe only recorded candidates. unrecorded_after reports unknown effects for candidates not proven by these sequential captures. Forwarding remains unknown even if all recorded candidates are unchanged, unless an import policy permits every prefix without assigning attributes. Export stays unknown in either case.

Capture identity, malformed or duplicate JSON keys, missing clauses, wrong checksums, incomplete tables, unknown peers, stale paths, ambiguous bestpaths, and unsupported actions all fail closed with exit

  1. A duplicate policy selector is also refused. The replay topology is a complete example built from the lab’s independently captured kernel tables and raw FRR transcripts. Its interface addresses come from the kernel’s connected route source addresses; it does not infer a FIB from BGP.

The supported subset is ordered terminal permit/deny rules, at most one exact IPv4 prefix-list match per rule, and at most one canonical weight assignment on a permit rule. An empty match is unconditional. Prefix-list entries use first-match order, with exact prefix equality and an implicit deny; route maps also end in an implicit deny. Prefix length ranges, community or AS-path matches, calls, jumps, and unknown semantic fields are refused. The verified producer supports FRR 10.7.0 and the default IPv4 unicast VRF only. Other VRFs, IPv6 peers, tunnel dependencies, multiple sources, and combinations with other failure selectors are refused.

The result’s failure.policy records each prefix’s selected peer before the change and whether its selection afterward is unchanged or unknown. A candidate changed by import policy needs BGP bestpath recomputation; an export change needs remote protocol recomputation. Neither is run here. A deny does not prove loss of the route, and an available candidate never becomes an invented replacement. Export keeps local selections unchanged and reports advertisement impact unknown for every baseline prefix, including identity policies. No per-peer advertisement eligibility or remote recomputation is inferred from a local bestpath. outcome and affected carry this uncertainty in JSON.

Where a policy could change a candidate, a forwarding walk through the applying node stops at unknown. This intentionally includes prefixes whose forwarding relationship to BGP has not been proven. It adds no BGP-to-FIB correlation from issue #390. Other nodes keep their recorded forwarding, including paths that never visit the applying node. No route, check, or observation in the input is changed. Both text and JSON report identical selections and destination accounting: considered equals unchanged plus affected. A run without the new selector keeps the legacy report bytes, including when valid policy evidence is present. The new flag is added to help and shipped completion/man surfaces.