Skip to content

reachdoc banner

Go CI Dependencies License

reachdoc is a command-line diagnostic for one question: why can't anyone reach my self-hosted service? It walks the seventeen steps a packet takes from the process on this machine out to the public internet and back in through DNS, TLS and HTTP, stops at the first step that is actually broken, and prints one verdict with the command that fixes it.

It also names the service it is diagnosing (--preset nextcloud), writes the remediation out as a script it will never run (--fix-script), watches for a problem that comes and goes (--watch 30s), and teaches the check behind any verdict at length (--explain path_mtu). One line for cron is --quiet; the whole run for a script is --json, with a schema documented below.

I wrote it after spending an evening on a port forward that could never have worked, because the line had quietly been moved behind carrier-grade NAT. It is a single Go binary that depends on nothing but the standard library.

1. The problem

A friend says your Jellyfin does not load. You check, and it works — from your laptop, on your own network. Now you have a list of suspects and no order to work through them in: the bind address, the host firewall, the router's port forward, the DNS record, the certificate, the ISP. Each tool you reach for answers one narrow question. ss -tlnp sees the socket but not the router. curl from the LAN sees the router but not the carrier. dig sees the record but not whether it is stale. A public port checker sees a closed port and cannot say which of the six layers closed it.

The order matters, because these failures cascade. A service bound to 127.0.0.1 fails every later check too, and a screen full of red hides the one line that mattered. reachdoc walks the path in packet order, stops at the first real breakage, marks everything after it as not run, and names the cause once:

MOST LIKELY CAUSE
  Local listener
  Port 8096 is bound to loopback only, so nothing outside this machine can reach it.

The most common causes it was built to name, in the order they occur: a loopback-only bind, a host firewall that never got a rule, an IPv6 firewall nobody wrote, a port forward pointing at the machine that used to hold this DHCP lease, carrier-grade NAT, a router that will not turn a packet around from inside its own LAN, a path MTU black hole that lets small pages load and hangs large ones, a stale DNS record, a name that is really a CDN's, an expired certificate, and an AAAA record pointing into an IPv6 network that has no route. Section 4 is the carrier-grade NAT case in full; section 6 is the four that look like something else entirely.

2. Install

From a clone, which is the whole build:

git clone https://github.com/johan-becker/reachdoc
cd reachdoc
go build ./cmd/reachdoc

Or straight into GOBIN:

go install github.com/johan-becker/reachdoc/cmd/reachdoc@latest

Go 1.23 or newer, and nothing else. go.mod has no require block, there is no go.sum and no vendor directory; the whole program is the standard library and the code in this repository. Every push to main, and every pull request, cross-compiles the tree across ten OS/architecture pairs — linux/amd64, linux/arm64, linux/arm, darwin/amd64, darwin/arm64, windows/amd64, windows/arm64, freebsd/amd64, openbsd/amd64 and netbsd/amd64 — and runs the tests on Linux, macOS and Windows.

Run it on the machine that serves the service. reachdoc reads local sockets, the local firewall and the local routing table. Pointed at an IP address that is not one of this machine's own, every step that reads local state — the listening socket, both firewalls, the LAN address, the packet size, the NAT situation, the router's port mappings, the public address and the IPv6 situation — skips itself and says so, and the steps that can still tell the truth from here (DNS, TLS, HTTP) run as usual. That is deliberate: those local answers would be about the wrong machine.

3. A real run

A service is up, the port is right, and nobody outside can reach it. This is the transcript of reachdoc against a python3 -m http.server 8096 --bind 127.0.0.1, verbatim:

$ reachdoc http://127.0.0.1:8096
reachdoc 0.2.0  https://github.com/johan-becker/reachdoc
Diagnosing http://127.0.0.1:8096/

 -  Service preset              No service preset was named.
 x  Local listener              Port 8096 is bound to loopback only, so nothing outside this
                                machine can reach it.
        Found: 127.0.0.1:8096 (Python, pid 64914)
        A socket bound to 127.0.0.1 or ::1 accepts connections from this machine and from
        nothing else: not from another machine on your LAN, and not from the internet.
        This is the most common self-hosting mistake by a wide margin. No port forward, firewall
        rule or DNS record can work around it, because the packets never leave the loopback
        interface.
        fix
          1. Change the service's listen address from 127.0.0.1 to 0.0.0.0 (all IPv4 interfaces)
             or :: (all interfaces), then restart it. The setting is usually called bind,
             bind_address, listen, listen_address, host or HTTP_HOST.
          2. Docker publishes to loopback when the host address is given explicitly: -p
             127.0.0.1:8096:8096 only listens locally. Use -p 8096:8096 instead.
          3. If binding to loopback is deliberate (the usual reason is that a reverse proxy sits
             in front), point reachdoc at the reverse proxy's port instead: that is the port the
             outside world actually connects to.
 .  Local firewall              Not run: an earlier step already failed (Local listener).
 .  Host interfaces             Not run: an earlier step already failed (Local listener).
 .  IPv6 firewall               Not run: an earlier step already failed (Local listener).
 .  LAN reachability            Not run: an earlier step already failed (Local listener).
 .  Default gateway             Not run: an earlier step already failed (Local listener).
 .  Path MTU and MSS            Not run: an earlier step already failed (Local listener).
 .  NAT and CGNAT               Not run: an earlier step already failed (Local listener).
 .  Automatic port mapping      Not run: an earlier step already failed (Local listener).
 .  Public IP                   Not run: an earlier step already failed (Local listener).
 .  External port reachability  Not run: an earlier step already failed (Local listener).
 .  Hairpin NAT                 Not run: an earlier step already failed (Local listener).
 .  DNS                         Not run: an earlier step already failed (Local listener).
 .  Reverse proxy or CDN        Not run: an earlier step already failed (Local listener).
 .  TLS certificate             Not run: an earlier step already failed (Local listener).
 .  HTTP response               Not run: an earlier step already failed (Local listener).
 .  IPv6 and dual stack         Not run: an earlier step already failed (Local listener).

------------------------------------------------------------------------------------------------
MOST LIKELY CAUSE
  Local listener
  Port 8096 is bound to loopback only, so nothing outside this machine can reach it.

  What to do
    1. Change the service's listen address from 127.0.0.1 to 0.0.0.0 (all IPv4 interfaces) or ::
       (all interfaces), then restart it. The setting is usually called bind, bind_address,
       listen, listen_address, host or HTTP_HOST.
    2. Docker publishes to loopback when the host address is given explicitly: -p
       127.0.0.1:8096:8096 only listens locally. Use -p 8096:8096 instead.
    3. If binding to loopback is deliberate (the usual reason is that a reverse proxy sits in
       front), point reachdoc at the reverse proxy's port instead: that is the port the outside
       world actually connects to.

18 checks, 1 failed, 1 skipped, 16 not run  (exit 1)

The tally counts all eighteen steps, the preset included: one failure, one skip (no preset was named) and the sixteen steps behind the failure.

Nothing after step 1 ran, because nothing after step 1 could have told the truth. Pass --continue to run the whole chain anyway; the verdict still names the first failure.

Glyphs are + pass, ! warn, x fail, - skipped (the step does not apply) and . not run (an earlier step failed). Passing steps print their summary only; --verbose prints their supporting detail as well.

4. Carrier-grade NAT, the check this exists for

If your ISP has put your line behind carrier-grade NAT, the router's WAN address is in 100.64.0.0/10 (RFC 6598), your public IPv4 address is shared with many other customers, and no port forward on your side can ever work. Every hour spent on UPnP, a different router or a firewall rule is wasted. Most guides never mention it, and the router's status page shows the address without a word of explanation.

reachdoc detects it from three angles: the WAN address you hand it, this host's own egress address, and the address a public endpoint echoes back. Step 8 of a run where the WAN address was supplied with --wan-ip — the exact command is under the transcript, because it matters here:

 x  NAT and CGNAT               This line is behind carrier-grade NAT, so no port forward can
                                ever make it reachable over IPv4.
        This host's IPv4 source address towards the internet: 192.168.1.25
        The IPv4 address the internet sees: 203.0.113.10 (reported by --public-ip)
        The router's WAN address, as given by --wan-ip: 100.72.4.9
        The router's WAN address 100.72.4.9 is inside 100.64.0.0/10, the range reserved for
        carrier-grade NAT (RFC 6598).
        An address in 100.64.0.0/10 belongs to the ISP's own internal network, not to you. Your
        line shares one public IPv4 address with many other customers, and the ISP's equipment
        decides where inbound packets go.
        Nothing on your side changes this: not a port forward, not UPnP or NAT-PMP, not a
        different router, not disabling the firewall. Inbound IPv4 connections are dropped
        before they ever reach your line.
        The tell-tale sign is a router whose WAN address looks private but is not one of the
        familiar 192.168/10./172.16 ranges.
        fix
          1. Ask the ISP to move you off CGNAT and give you a public IPv4 address. Many will do
             it for free on request; some charge a small monthly fee. The words that work are
             "please give me a public IPv4 address, not CGNAT".
          2. Or reach the service over IPv6, which has no carrier NAT at all: publish an AAAA
             record and allow the port inbound in the router's IPv6 firewall. IPv6 has no port
             forwarding, only a firewall rule.
          3. Or use a relay, where the connection is made outbound from your side and met in the
             middle: a wireguard tunnel to a cheap VPS you control, Tailscale/headscale with
             Funnel, or Cloudflare Tunnel.
          4. Do not spend another evening on port forwarding, UPnP, or a new router. None of
             them can change what the carrier does.

That excerpt comes from reachdoc --wan-ip 100.72.4.9 --public-ip 203.0.113.10 --compare-resolver=off http://192.168.1.25:8096 on a line that is not behind CGNAT: --wan-ip supplies the number a CGNAT router would show, and --public-ip pins the echoed address so this README does not contain my own. On a line that really is behind CGNAT, neither flag is needed — the same verdict comes out of the discovered addresses, and --wan-ip only distinguishes carrier-grade NAT from an ordinary double NAT.

The classifier behind it is table-tested across the whole address space in both families, including ::ffff:100.64.0.0 style IPv4-in-IPv6 forms and both edges of RFC 6598. When IPv6 works end to end, CGNAT is reported as a warning rather than a failure, because IPv6 is the way out of it.

5. The seventeen checks, and the preset in front of them

Step 0 is --preset, which is not a network check at all: it names the service being diagnosed, supplies its default port and scheme, and carries the failures peculiar to that service. Section 5.1 covers it. The seventeen after it walk the packet.

# Step What it establishes
1 Local listener Whether anything is listening on the port, and on which address. Separates 127.0.0.1/::1 from 0.0.0.0/:: from one specific address, and names the process. Reads /proc/net/tcp on Linux, lsof or netstat elsewhere.
2 Local firewall Reads ufw, firewalld, nftables, iptables, pf, the macOS application firewall or Windows advfirewall, and turns what it finds into the exact command that opens the port. Warns rather than fails when it cannot be sure.
3 Host interfaces Enumerates every address and classifies it: loopback, link-local, RFC 1918 / RFC 4193 private, CGNAT, documentation, 6to4, Teredo, NAT64, public.
4 IPv6 firewall The other ruleset. iptables and ip6tables are two lists with two policies, and one family's success never stands in for the other's.
5 LAN reachability Connects to the service on the host's own LAN address. A refusal (no socket) and a timeout (something dropping packets) are different diagnoses and get different fixes.
6 Default gateway Finds the default route and checks the router answers.
7 Path MTU and MSS How big a packet may be on the way out, the MSS that implies, and any smaller MTU the kernel has already cached. Names 1492 as PPPoE, and explains the black hole where TCP connects but large responses hang.
8 NAT and CGNAT Compares this host's egress address, the router's WAN address and the address the internet sees, and reports direct, single NAT, double NAT or carrier-grade NAT.
9 Automatic port mapping Asks the router over UPnP, NAT-PMP and PCP whether it already forwards this port, and to which machine. Read-only: reachdoc never creates a mapping.
10 Public IP What the internet sees for this line, over IPv4 and IPv6.
11 External port reachability Opt-in. Asks a prober you name to connect to the port from outside.
12 Hairpin NAT Connects to this line's own public address from inside the LAN — the "works on mobile data, fails at home" case. Never a failure: it stops nobody on the internet.
13 DNS Resolves A and AAAA, compares them with the discovered public addresses, follows the CNAME chain, reports TTLs, and spots stale records and split-horizon answers.
14 Reverse proxy or CDN Places the resolved addresses in an offline table of published proxy ranges. Behind Cloudflare or a CDN, a port probe measures the edge and not you, and everything below reads differently.
15 TLS certificate Completes the handshake, verifies the chain and the name, reports days to expiry (a warning under 21 days) and the negotiated version.
16 HTTP response Makes the request a visitor makes, follows and reports the whole redirect chain, reads HSTS, and catches the "redirects to HTTPS but TLS is broken" loop.
17 IPv6 and dual stack AAAA published with no IPv6 route, IPv6-only reachability, and whether IPv6 offers a way past a carrier NAT.

Steps 15 and 16 are skipped for a plain TCP target, steps 13 and 14 for an address literal, and step 11 unless --external is given. A skipped step says so and never counts as a pass.

5.1 Service presets

Every step above answers can a packet get there. --preset answers the question that comes next every single time: the packet arrived, the service answered it, and the service refused the request. Those failures look exactly like unreachability and are none of it.

reachdoc --preset nextcloud cloud.example.com     # 443, https, and the trusted_domains trap
reachdoc --preset jellyfin media.example.com      # 8096, http, and the published server URL
reachdoc --list-presets                           # all of them
$ reachdoc --list-presets
reachdoc knows these services. Name one with --preset.

  gitea            3000, 22          http   Gitea or Forgejo, where the web port and the SSH port are two separate problems
                   also known as forgejo
  home-assistant   8123              http   Home Assistant, which is unusually strict about what is in front of it
                   also known as homeassistant, hass
  immich           2283              http   Immich photo library, served from one container port
  jellyfin         8096, 8920        http   Jellyfin media server: 8096 plain HTTP, 8920 its own HTTPS
  minecraft        25565             tcp    Minecraft: Java Edition, TCP 25565
  nextcloud        443, 80           https  Nextcloud, usually behind a reverse proxy that terminates TLS
  plex             32400             http   Plex Media Server, which has its own opinion about remote access
  vaultwarden      443, 80, 8080     https  Vaultwarden, which browsers refuse to run outside a secure context
                   also known as bitwarden

Each one knows its default ports and the failures peculiar to it: the
configuration mistakes that answer the packet and refuse the request.
--preset-file FILE adds your own, and overrides a built-in of the same name.
--preset Ports What it knows
nextcloud 443, 80 trusted_domains — an unlisted Host header gets "Access through untrusted domain" instead of the login page. overwriteprotocol, trusted_proxies.
jellyfin 8096, 8920 The published server URL handed to remote clients, WebSocket upgrades through the proxy, known proxies.
immich 2283 nginx's 1 MB client_max_body_size — everything works and every video upload fails. The mobile app's server endpoint.
home-assistant 8123 use_x_forwarded_for and trusted_proxies; without both, every request through a proxy is a 400. external_url.
plex 32400 Plex's own relay makes a broken port forward look like a working one, at a few megabits. Remote Access, custom access URLs.
vaultwarden 443, 80, 8080 Browsers refuse the crypto APIs outside a secure context, so over plain HTTP the login silently fails. DOMAIN, WebSockets.
gitea 3000, 22 ROOT_URL — a perfect web interface handing out unusable clone URLs. SSH is a second port and a second forward.
minecraft 25565 An empty server-ip=, the _minecraft._tcp SRV record, and that Bedrock is UDP and out of scope.

A preset is a default, never an override: a port or a scheme you typed always wins, so --preset jellyfin media.example.com:9096 diagnoses 9096 and says that 9096 is not one of Jellyfin's ports. The step warns when the port is unexpected or when a TLS service is being diagnosed over plain HTTP; otherwise it passes, and its advice appears under the verdict when nothing else in the chain is broken — which is precisely when the service's own configuration is the last suspect standing.

That cuts both ways: a preset warning never becomes the verdict while anything on the network has warned. Step 0 is not a network check, and an opinion about a port number must not bury a firewall that could not be read.

--preset-file FILE adds your own, and replaces a built-in of the same name:

{
  "presets": [
    {
      "name": "paperless",
      "aliases": ["paperless-ngx"],
      "ports": [8000],
      "scheme": "http",
      "summary": "Paperless-ngx document archive",
      "gotchas": ["PAPERLESS_URL has to name the external address, or every upload is rejected as a CSRF failure while the site itself works."],
      "fix": ["Set PAPERLESS_URL=https://docs.example.com in docker-compose.yml, then restart."]
    }
  ]
}

A preset with no gotchas is rejected: knowing a port number is not worth a flag.

6. Four failures that look like something else

Section 4 is one check written out in full, because carrier-grade NAT is the one that wastes a whole evening. These four waste the next four, and they share a shape: something works. The port answers. The page loads. The certificate is valid. And the service is still broken, for a reason no single-purpose tool reports, because each of them lives in a part of the path nobody thinks to look at.

Every transcript below is verbatim, from a run on the machine this page was written on. --public-ip 203.0.113.10 stands in for a real public address throughout, so that this README does not contain mine.

6.1 The port forward you cannot see (step 9)

A port forward is configured once and invisible afterwards. It lives in the router, not on the host, and nothing on the host can be asked about it — which is why "check your port forward" is such useless advice. You already looked. It looked right.

Step 9 asks the router itself, over the three protocols routers implement for exactly this purpose: UPnP IGD (an SSDP M-SEARCH, then the device description, then SOAP), NAT-PMP (RFC 6886) and PCP (RFC 6887). Eight answers are worth telling apart, and the step tells them apart:

What it reports What it means
The router forwards TCP port 8096 to this host. the forward exists and points here; the cause is somewhere else
The router forwards TCP port 8096 to 192.168.1.42, which is not this machine. the DHCP lease moved and the forward did not — the second most common cause of this whole problem
The router forwards external TCP port 8096 to internal port 80, not 8096. the ports are paired wrongly, which from outside looks exactly like no forward at all
The router holds a mapping for TCP port 8096 but has it disabled. it exists, and it is switched off
The router offers automatic port mapping and holds no mapping for TCP port 8096. the fixable case: the router will make one when something asks
The router speaks automatic port mapping but has it switched off, so nothing can open a port by asking. the feature is disabled in the router's own settings
The router answers automatic port mapping, but cannot say whether this port is forwarded. it answered, and its mapping table could not be read
No automatic port-mapping protocol answered on this network. nothing to read here, which says nothing about a static forward

This network gives the last of those:

$ reachdoc --preset jellyfin --wan-ip 203.0.113.10 --public-ip 203.0.113.10 --compare-resolver=off 192.168.1.25
 !  Automatic port mapping      No automatic port-mapping protocol answered on this network.
        nat-pmp: no answer.
        The gateway did not answer NAT-PMP.
        no answer from 192.168.1.1:5351
        pcp: no answer.
        The gateway did not answer PCP.
        no answer from 192.168.1.1:5351
        Nothing replied to NAT-PMP, PCP or UPnP. Many routers ship with all three off, and some
        networks -- student residences, offices, hotels -- deliberately do not offer them.
        This says nothing about whether a forward exists: a static one configured by hand works
        perfectly well without any of these protocols.

(The two fix lines that follow in the real output are cut from that block, because they name the LAN address of the machine it ran on.)

It asks; it never tells. GetExternalIPAddress and GetSpecificPortMappingEntry, NAT-PMP's external-address request, PCP's ANNOUNCE with a zero lifetime — every operation reachdoc issues is a read, and there is no code path to AddPortMapping at all. A NAT-PMP mapping request differs from an address request by two bytes, so a test inspects every datagram and every SOAPAction that leaves the process and fails the build if one of them would change the router.

6.2 The packet that is too big (step 7)

This one does not look like a network failure at all. TCP connects. The handshake completes. Small pages load. A photo will not, a download stalls at zero, and a page with a long list spins forever. Every tool reports the service as up, and by every measure they take, it is.

The mechanism is a link on the path that carries smaller packets than the sender assumes, and a firewall somewhere that swallows the ICMP which would have said so. Step 7 does not measure the path MTU — measuring means don't-fragment probes and a raw socket, which this program deliberately does not have. It reads the two numbers the kernel already holds: the MTU of the interface packets leave by, and any smaller MTU path MTU discovery has already cached for the route. The second is evidence rather than inference, because the kernel only writes one down after something has already sent a packet that was too big.

--verbose prints the whole of a passing step, including the paragraph the check exists to deliver:

$ reachdoc --continue --verbose --resolver 1.1.1.1 --compare-resolver=off --public-ip 203.0.113.10 https://www.microsoft.com
 +  Path MTU and MSS            The link out of this host carries full-size 1500 byte packets,
                                implying a TCP MSS of 1460.
        Packets leave by en0, whose MTU is 1500 bytes.
        The routing table reports an MTU of 1500 for the route to the internet (route -n get
        192.0.2.1).
        A 1500 byte MTU implies a TCP MSS of 1460 over IPv4 and 1440 over IPv6.
        That is an ordinary ethernet link.
        This host also has utun0 at 1380 bytes, utun3 at 1000 bytes and utun4 at 1280 bytes.
        Traffic routed through one of those carries a smaller MTU than the figure above.
        Nothing on this host reduces the packet size. That does not rule out a smaller link
        further along -- a PPPoE router two hops away is invisible from here -- and the symptom
        to watch for is a service that connects, answers small requests, and hangs on large
        ones.
        The failure this causes does not look like a network failure. TCP connects, small pages
        load, and any response bigger than one segment stops dead: a large photo, a file
        download, a page with a long list. The too-big packet should come back as an ICMP
        "fragmentation needed", and a firewall that blocks all ICMP swallows it, so the sender
        never finds out and retransmits the same packet until the connection times out. That is
        a path MTU black hole.

1492 is named as PPPoE, 1480 as a 6in4 tunnel, 1472 as GRE and 1420 as WireGuard, because "1492" means "PPPoE" to about one reader in fifty. Below 1500 the step warns and prints the remedy: an MSS clamp on the router that owns the small link — sudo iptables -t mangle -A FORWARD -p tcp --syn -j TCPMSS --clamp-mss-to-pmtu, or the nft equivalent — together with the warning that blocking ICMP harder is the intuitive reaction and the wrong one, since path MTU discovery is carried by ICMP type 3 code 4 and ICMPv6 type 2.

6.3 The name that is not yours (steps 13 and 14)

A name behind Cloudflare, Fastly, CloudFront, Google's load balancer or Akamai resolves to the proxy's addresses and not to yours. A port probe against those addresses measures the edge, which answers on 443 for every customer it has, running origin or not. reachdoc used to call that a stale DNS record. It was wrong, and everything below it in the report was wrong with it.

Step 14 places every resolved address in an offline table of published proxy ranges, and insists that all of them fall inside one provider before it calls a name proxied: a single coincidental match would rewrite the meaning of half the report. Against a name that really is behind one:

$ reachdoc --continue --resolver 1.1.1.1 --compare-resolver=off --public-ip 203.0.113.10 https://www.cloudflare.com
 !  Reverse proxy or CDN        www.cloudflare.com resolves into Cloudflare's address ranges, so
                                the internet never connects to this line directly.
        Cloudflare answers on 104.16.124.96, 104.16.123.96, 2606:4700::6810:7b60,
        2606:4700::6810:7c60, and opens its own connection to your origin behind the scenes.
        Nothing about www.cloudflare.com tells you the origin's address, and nothing you can
        measure against it tells you whether the origin is up.
        A port probe against 104.16.124.96 is meaningless: Cloudflare answers on 443 for every
        one of its customers, running origin or not.
        That changes how the rest of this report reads. The certificate below is the proxy's,
        not yours. The HTTP answer below may be the proxy's own error page rather than your
        service's. And a DNS record pointing away from this line's public address is correct
        here, rather than stale.
        An orange cloud in the Cloudflare dashboard means the record is proxied. Grey it out to
        publish your own address instead.
        This line's own public address is 203.0.113.10. That is the address the proxy has to be
        able to reach, and the one the checks above are about.
        reachdoc places addresses with a built-in table of published ranges. It is a snapshot: a
        range added after this release will not be recognised, and --proxy-ranges FILE adds your
        own.
        fix
          1. Diagnose the origin, not the edge: run reachdoc on the machine that serves
             www.cloudflare.com, against its own address and port -- reachdoc --port 443
             203.0.113.10.
          2. If Cloudflare reports a 5xx of its own, that is the origin failing, not the proxy:
             Cloudflare's 521 means the origin refused the connection, 522 means it timed out
             (almost always the origin's firewall dropping the proxy's addresses), 523 means it
             was unreachable and 525 means the TLS handshake with the origin failed.
          3. If the origin's firewall only allows Cloudflare's ranges, check the list is
             current. A provider that adds a range locks itself out of an origin that has not
             updated.
          4. If you meant to publish your own address instead, turn the proxy off for this
             record. An orange cloud in the Cloudflare dashboard means the record is proxied.
             Grey it out to publish your own address instead.

The verdict then changes what the steps around it say, where the reader is looking: the DNS step calls the record proxied rather than stale, the certificate is the proxy's, and an HTTP 5xx may be the proxy's own error page. The table is a snapshot of published ranges, and --proxy-ranges FILE puts your own entries in front of it — which is also how a reverse proxy of your own gets recognised.

6.4 The other firewall, and the turn the router will not make (steps 4 and 12)

iptables and ip6tables are two rulesets with two policies, and the IPv6 one is the half nobody writes. IPv6 has no port forwarding at all, so the router's firewall is the entire inbound path: an open IPv4 forward beside a closed IPv6 firewall is an ordinary configuration, and a silent one. Step 4 reads the IPv6 ruleset as its own status, and neither family's verdict is allowed to stand in for the other's. On a host with no global IPv6 address it says that, rather than inheriting the IPv4 answer:

$ reachdoc --preset jellyfin --wan-ip 203.0.113.10 --public-ip 203.0.113.10 --compare-resolver=off 192.168.1.25
 -  IPv6 firewall               This host has no globally routable IPv6 address, so there is no
                                inbound IPv6 path to filter.

Step 12 is the complaint that is always misread: it works from my phone on mobile data and not from my laptop at home. A packet sent from inside the LAN to this line's own public address has to be turned around by the router, translated twice and handed back in, and many consumer routers simply drop it.

$ reachdoc --preset jellyfin --wan-ip 203.0.113.10 --public-ip 203.0.113.10 --compare-resolver=off 192.168.1.25
 !  Hairpin NAT                 203.0.113.10:8096 could not be reached from inside the LAN,
                                which from here is either a missing port forward or a router
                                that does not hairpin.
        203.0.113.10:8096 from inside the LAN - timed out (packets are being dropped, not
        rejected)
        A packet sent from inside the LAN to this line's own public address has to be turned
        around by the router, translated twice, and handed back in. Many consumer routers simply
        drop it. That is called missing NAT loopback, or missing hairpinning.
        It affects only clients on this network. Everyone on the internet, and everyone on
        mobile data, is unaffected -- which is exactly why it survives so long: the one person
        who tests it is standing on the one network where it cannot work.
        Those two look identical from inside the network and have opposite fixes, and no test
        run on this side of the router can tell them apart.

Both of those come from the same run as the transcript in 6.1, and both are cut before their fix lines for the same reason.

Hairpin NAT is never a failure, because a missing hairpin stops nobody: calling it one would send people to rebuild a port forward that is already correct. From inside the LAN the two possible causes are indistinguishable, and the step says so rather than guessing — unless --external has already reached the port from outside during the same run, in which case the diagnosis is exact: the forward is right, do not touch it, and give the LAN a split-horizon answer instead.

7. How it works

The chain

Each check is a value with one method, and the runner is a loop over an ordered slice:

type Check interface {
    ID() string
    Title() string
    Run(ctx context.Context, env *Env) Result
}

A Result carries a status, a one-line summary, detail lines, a numbered fix, an error and a Facts map that becomes the JSON. The runner stops at the first StatusFail and marks every remaining check blocked_by that check's id, unless --continue is set. Checks hand findings to later checks through a small shared State — the resolved addresses, the discovered public addresses, the NAT situation — so step 12 can ask what step 11 saw and step 17 what step 13 resolved, without repeating the query.

Everything external is an interface

Env holds the target, the clock, a handful of options, the shared State — and otherwise nothing but interfaces: Resolver (twice: the primary and the second-opinion one), Dialer, HTTPDoer, InterfaceLister, ListenerLister, FirewallInspector, GatewayFinder, EgressAddrer, PublicIPDiscoverer, TLSProber, ExternalProber, PortMapper and PathMTUReader. Two more sit a layer below: Commander, through which every system implementation that shells out to lsof, nft, ip route or route does it, and PacketExchanger, which is the only thing in the UPnP, NAT-PMP and PCP code that touches a socket — the codecs above it are pure functions run against recorded bytes. cmd/reachdoc builds one Env through a factory that wires the real implementations from internal/netprobe; every test builds one that wires internal/netprobe/fake. No test opens a socket, resolves a name, reads the routing table or runs a firewall command — not by convention but because there is no path from a check to the operating system that does not go through that struct.

Where a real protocol is needed, the tests build it rather than mock it away: the DNS codec is exercised against hand-built wire bytes including compression pointers, the TLS prober completes a real handshake over net.Pipe, the NAT-PMP, PCP, SSDP and SOAP codecs run against recorded router bytes — a real FRITZ!Box SSDP reply, a nested IGD device description, the 714 NoSuchEntryInArray fault — and the certificate tests issue certificates at run time against the suite's fixed clock, so no fixture can expire.

A DNS client, because the resolver hides the diagnosis

net.Resolver answers with addresses. A stale-record diagnosis needs the TTL, and a misdirected name needs the CNAME chain, and the standard resolver reports neither. So internal/netprobe speaks DNS itself: dns.go is a query and reply codec with compression-pointer support, and resolver.go is the client — UDP with a TCP retry when the reply comes back truncated, transaction-id checking, and CNAME following bounded against pointer loops and chains longer than eight links. Given --resolver, step 13 prints what it actually read off the wire — here against a name whose answer comes from a CDN, which is why the summary reads the way it does:

$ reachdoc --continue --verbose --resolver 1.1.1.1 --compare-resolver=off --public-ip 203.0.113.10 https://www.microsoft.com
 +  DNS                         www.microsoft.com resolves into Akamai's address ranges rather
                                than to this line's own address, which is what a proxied record
                                looks like.
        www.microsoft.com is an alias for www.microsoft.com-c-3.edgekey.net (TTL 1 hour(s))
        www.microsoft.com-c-3.edgekey.net is an alias for e13678.dscb.akamaiedge.net (TTL 15
        minute(s))
        A: 23.217.49.217 (TTL 20 second(s))
        AAAA: 2a02:26f0:11a:398::356e and 2a02:26f0:11a:39f::356e (TTL 20 second(s))
        That is not a stale record: the proxy answers on its own addresses and opens its own
        connection to your origin. The reverse-proxy step below explains what it means for the
        rest of this report.
        The TTL is 20 second(s), so changes propagate quickly.

Without --resolver the system resolver is used, and the report says plainly that no TTL is available and how to get one.

A TLS prober that separates the certificate failures

"Certificate error" is not a diagnosis. The prober completes the handshake with verification turned off, keeps the presented chain, and then verifies it itself, which is what lets the check tell expired from not-yet-valid from wrong-name from self-signed from an untrusted issuer from a chain that stops at the leaf — six outcomes with six different fixes. Against a local service holding a certificate made by openssl req -x509:

$ reachdoc --public-ip 203.0.113.10 --compare-resolver=off https://192.168.1.25:8443
 x  TLS certificate             The certificate is self-signed, so no browser will trust it.
        Presented by media.example.com, issued by media.example.com
        Valid from 2026-08-22T16:18:45Z to 2027-08-22T16:18:45Z
        Names on the certificate: media.example.com and 192.168.1.25
        Negotiated TLS 1.3 with TLS_AES_128_GCM_SHA256
        A self-signed certificate encrypts the connection but proves nothing, and every client
        shows a full-page warning before letting anyone past it.
        This is what a service ships with by default, and what a reverse proxy serves when it
        has no certificate configured for this name.
        error: x509: certificate signed by unknown authority
        fix
          1. No public authority issues certificates for an address, so nothing can make
             192.168.1.25 trusted as it stands.
          2. Point a DNS name at 192.168.1.25, obtain a certificate for that name (certbot,
             Caddy or Traefik will), and connect to the service by name.
          3. If this stays inside a LAN, issue the certificate from your own CA with the address
             in its IP subject alternative names and install that CA on every client.

Layout

Path Contents
cmd/reachdoc Flags, wiring, exit codes. The only place that constructs real implementations.
internal/checks The seventeen checks, the ordered chain, the short-circuiting runner, the target parser, the NAT classifier's decision table.
internal/netprobe The interfaces, their system implementations (/proc/net/tcp, lsof, netstat, route, ufw, firewalld, nft, iptables, pfctl, netsh), the DNS wire client, the TLS prober, the UPnP/NAT-PMP/PCP codecs and the address classifier.
internal/netprobe/fake The fakes every test wires.
internal/report The human report, the verdict, and the versioned JSON document.

8. Reference

Target

reachdoc media.example.com:8096                # host and port
reachdoc https://media.example.com             # a URL: adds the TLS and HTTP steps
reachdoc --port 22 192.168.1.50                # an address and an explicit port
reachdoc --preset jellyfin media.example.com   # the service's own port, and its gotchas

A bare name with no port means https://name:443. Ports 80 and 8080 imply HTTP, 443 and 8443 imply HTTPS; any other port is checked as plain TCP with the TLS and HTTP steps skipped. Pass a URL to be explicit.

Flags

Flag Default Meaning
--port PORT from the target TCP port to diagnose, overriding any port in the target
--timeout BUDGET 5s budget for each individual network operation
--json off write the report as JSON with a stable schema
--fix-script off print the remediation as a commented shell script instead of the report
--watch INTERVAL off re-run on an interval and print only the transitions (minimum 5s)
--explain CHECK print the long form of one check and exit
--quiet off print only the final verdict, on one line
--verbose off show the supporting detail of checks that passed too
--continue off run every check instead of stopping at the first failure
--external off ask an external prober to connect from outside; needs --external-prober
--external-prober URL none the prober to ask
--resolver SERVER system resolve through this DNS server (host[:port]) and report TTLs
--compare-resolver SERVER 1.1.1.1:53 second opinion, to spot split horizon; off disables it
--wan-ip ADDRESS none the WAN address from the router's status page
--public-ip ADDRESS discovered your public address(es), comma separated, instead of discovering them
--preset NAME none the service being diagnosed: its default port and scheme, and the failures peculiar to it
--preset-file FILE none JSON file of your own service presets, replacing built-ins by name
--list-presets print the known presets and exit
--proxy-ranges FILE none JSON file of extra reverse-proxy or CDN ranges, consulted before the built-in table
--color MODE auto auto, always or never; NO_COLOR overrides it
--no-color off the same as --color=never
--version print the version and exit

Reverse proxy ranges

Step 14 places the resolved addresses in a table of published proxy and CDN ranges. The built-in table covers Cloudflare, Fastly, Amazon CloudFront, Google's load balancer and Akamai, and it is a snapshot: a range added after the release you are running will not be recognised. --proxy-ranges FILE puts your own entries in front of it, which is also how a reverse proxy of your own gets recognised:

{
  "providers": [
    {
      "name": "the VPS in Frankfurt",
      "note": "My own front end; the origin firewall only lets it in.",
      "origin_errors": "nginx logs `upstream timed out` in /var/log/nginx/error.log.",
      "prefixes": ["203.0.113.0/24", "2001:db8:1::/48"]
    }
  ]
}

origin_errors is optional and says how that front end reports a failure of the origin rather than of itself. Cloudflare's 521, 522, 523 and 525 are Cloudflare's; no other provider in the table emits them, so a provider that defines nothing here gets generic advice instead of another company's status codes.

An unreadable or malformed file is a usage error (exit 3) before any check runs, rather than a silently empty table.

Output modes

Exactly one of these at a time; giving two is a usage error rather than a silent precedence rule.

Mode What goes to stdout
(default) the ordered report and one verdict
--quiet the verdict alone, on one line: fail NAT and CGNAT: This line is behind carrier-grade NAT…
--json the whole run, with the schema below
--fix-script the remediation as a commented script, never executed
--watch INTERVAL only the transitions, as they happen

Colour and the marked glyphs (✓ ! ✗ – ·) are on when stdout is a terminal. They are off — and the report falls back to the ASCII + ! x - . — when stdout is a pipe or a file, when NO_COLOR is set to anything, or when --no-color or --color=never is given. --color=always turns both back on for a pipe; NO_COLOR still wins over it.

--quiet is the mode written for cron: one line on stdout, and the diagnosis in the exit code.

$ reachdoc --quiet --wan-ip 100.72.4.9 --public-ip 203.0.113.10 --compare-resolver=off http://192.168.1.25:8096
fail  NAT and CGNAT: This line is behind carrier-grade NAT, so no port forward can ever make it reachable over IPv4.
$ echo $?
1

Exit codes

Honoured exactly, and identical in every output mode. They are an interface; a test asserts that each mode returns the same code for the same run.

Code Meaning
0 nothing reachdoc can check is wrong
1 at least one check failed
2 warnings only, or the run was interrupted before it finished
3 reachdoc was invoked wrongly and nothing ran

An interrupted run never exits 0: it proves nothing about the steps it did not reach.

JSON

--json writes one document whose shape is fixed by schema_version. Fields are added, never removed or repurposed, without incrementing it — so a consumer that reads a field today keeps reading it.

Field Type Meaning
tool string always "reachdoc"
version string the release that produced the document
schema_version number 1; incremented only if a field is removed or changes meaning
started_at string RFC 3339, UTC
duration_ms number how long the whole run took
target object what was diagnosed
results array one entry per check, in chain order
verdict object the single answer
summary object the counts
exit_code number the same code the process returned

target:

Field Type Meaning
raw string exactly what was typed
scheme string http, https or tcp
host string a hostname or an IP literal, unbracketed
port number the TCP port
path string the request path used by the HTTP step

results[]:

Field Type Present
id string always — the stable machine name, and the argument to --explain
title string always
status string always — pass, warn, fail, skip or blocked
summary string always — prose, and may be reworded between releases
duration_ms number always
facts object when the check ran; the part worth scripting against
detail array of string when there is supporting detail
fix array of string when there is remediation
error string when an underlying error was reported
blocked_by string when status is blocked: the id that stopped the chain, or interrupted

verdict: status, cause, and check_id, title and fix when a check is named. summary: total, pass, warn, fail, skip, blocked.

Because facts, detail, fix, error and blocked_by are omitted when empty, a skipped or blocked result has no facts at all and .results[] | .facts.x is null for it. Script against .status first.

facts, per check

Keys are per check and are part of the schema on the same terms as everything else: added, never removed or repurposed.

id Keys
service preset, ports, scheme, tls_expected
local_listener port, bind_scope (none/loopback/wildcard/specific/unknown), listeners, listening_socket_count, source, loopback_probe
local_firewall tools, port_allowed (tool → yes/no/unknown)
host_interfaces addresses, global_ipv6, link_local_addresses, interface_count
ipv6_firewall tools, port_allowed, ipv4_verdict, ipv6_verdict
lan_reachability port, attempts (address → connected/refused/timeout/unreachable)
default_gateway gateways, answered (the subset that actually answered a probe)
path_mtu interface, interface_mtu, route_mtu, mtu, mss, mss_ipv6
nat ipv4 and ipv6, each with situation (direct/single-nat/double-nat/cgnat/unknown), egress, wan, public and their _class
port_mapping services, then one object per service (upnp, nat-pmp, pcp) with available, refused, external_ip, mapping, table_readable, error
public_ip ipv4, ipv4_source, ipv6, ipv6_source
external_port host, port, endpoint, open
hairpin_nat public_ipv4, port, outcome
dns host, resolver, a, aaaa, ttl, cname_chain, matches_public_ipv4, matches_public_ipv6, proxied_by, compare_resolver, compare_a
proxied_dns proxied_ipv4, proxied_ipv6, provider
tls server_name, proxied_by, version, cipher_suite, alpn, subject, issuer, not_before, not_after, san, chain_length, days_until_expiry, handshake_error
http status, final_url, redirect_count, chain, loop, hsts, server, proxied_by
dual_stack aaaa_records, host_ipv6, ipv6_default_route, public_ipv6

summary and detail are prose and may be reworded between releases. id, status and the facts keys are not.

$ reachdoc --json ... | jq -r '.results[] | "\(.status)\t\(.id)"'
skip	service
pass	local_listener
warn	local_firewall
pass	host_interfaces
skip	ipv6_firewall
pass	lan_reachability
pass	default_gateway
pass	path_mtu
fail	nat
blocked	port_mapping
blocked	public_ip
blocked	external_port
blocked	hairpin_nat
blocked	dns
blocked	proxied_dns
blocked	tls
blocked	http
blocked	dual_stack
$ reachdoc --json ... | jq '{tool, version, schema_version, target, summary, exit_code}'
{
  "tool": "reachdoc",
  "version": "0.2.0",
  "schema_version": 1,
  "target": {
    "raw": "http://192.168.1.25:8096",
    "scheme": "http",
    "host": "192.168.1.25",
    "port": 8096,
    "path": "/"
  },
  "summary": {
    "total": 18,
    "pass": 5,
    "warn": 1,
    "fail": 1,
    "skip": 2,
    "blocked": 9
  },
  "exit_code": 1
}
$ reachdoc --json ... | jq '.results[8].facts'
{
  "ipv4": {
    "egress": "192.168.1.25",
    "egress_class": "private",
    "public": "203.0.113.10",
    "public_class": "documentation",
    "situation": "cgnat",
    "wan": "100.72.4.9",
    "wan_class": "cgnat"
  },
  "ipv6": {
    "situation": "unknown"
  }
}

status is one of pass, warn, fail, skip, blocked. A blocked result also carries blocked_by with the id of the check that stopped the chain.

Three ways to use the diagnosis

--fix-script

The report already contains the exact ufw, firewall-cmd, nft, pfctl and netsh invocations for the firewall this host actually runs. --fix-script collects them into a commented script for the detected OS — POSIX sh everywhere, PowerShell on Windows — with the summary of each failing step above its commands.

reachdoc does not execute a line of it, and will not. Printing instead of applying is the whole safety model. Every command in that file changes something, and a diagnostic that applied its own conclusions would eventually apply a wrong one to somebody's production host at two in the morning. Read it, then run it yourself.

$ reachdoc --fix-script --wan-ip 100.72.4.9 --public-ip 203.0.113.10 --compare-resolver=off http://192.168.1.25:8096
#!/bin/sh
# reachdoc 0.2.0 -- remediation for http://192.168.1.25:8096/
# Generated from the run started at 2026-08-22T16:17:47Z.
# Produced by: reachdoc --fix-script --wan-ip 100.72.4.9 --public-ip 203.0.113.10 --compare-resolver=off http://192.168.1.25:8096
#
# reachdoc did not run any of this, and will not. It is a diagnostic: it reads state and
# never changes it. Printing the remediation instead of applying it is deliberate, and
# it is the only reason this is safe to generate at all.
#
# Read every line before you run it. These commands change firewall rules, router
# configuration and service settings; several need root; and a command that fixes one
# host can lock you out of another.
#
# Lines beginning with the comment character are reachdoc's own advice, kept in place so
# this script explains itself. Everything else is a command, exactly as reachdoc would
# have printed it in the report.

set -eu

PORT='8096'
HOST='192.168.1.25'

# ==========================================================================
# 1. Local firewall  [WARN]
# The local firewall state for port 8096 could not be established with certainty.
# ==========================================================================

# Re-run with sudo for a definite answer, or check the rule list by hand using the
# commands below.

# Add to /etc/pf.conf: pass in proto tcp from any to any port 8096

sudo pfctl -f /etc/pf.conf

# ==========================================================================
# 2. NAT and CGNAT  [FAIL]
# This line is behind carrier-grade NAT, so no port forward can ever make it reachable
# over IPv4.
# ==========================================================================

# Ask the ISP to move you off CGNAT and give you a public IPv4 address. Many will do it
# for free on request; some charge a small monthly fee. The words that work are "please
# give me a public IPv4 address, not CGNAT".

# Or reach the service over IPv6, which has no carrier NAT at all: publish an AAAA
# record and allow the port inbound in the router's IPv6 firewall. IPv6 has no port
# forwarding, only a firewall rule.

# Or use a relay, where the connection is made outbound from your side and met in the
# middle: a wireguard tunnel to a cheap VPS you control, Tailscale/headscale with
# Funnel, or Cloudflare Tunnel.

# Do not spend another evening on port forwarding, UPnP, or a new router. None of them
# can change what the carrier does.

# End of the remediation. Re-run reachdoc afterwards: a fix that was not the cause
# leaves the verdict unchanged, and that is worth knowing before you change anything
# else.

Deciding which line is a command and which is a sentence matters more than it looks — every line of the carrier-grade NAT section above is advice rather than an instruction, and every one of them comes out commented. "Docker publishes to loopback when the host address is given explicitly" begins with the name of a program, so the test is a whitelist of first words — case-sensitive for shell binaries, and anything ending in a full stop is prose. Being wrong that way round leaves a command commented out; being wrong the other way round runs a sentence. A generated script is checked with sh -n in the test suite, because the point of a script rather than a list is that it can be run once it has been read.

--watch INTERVAL

A single run cannot diagnose an intermittent problem. A link that flaps, a DHCP lease that moves at four in the morning, a firewall rule some other tool rewrites, an ISP that drops the session nightly — running reachdoc by hand catches those only by luck.

--watch re-runs the whole chain on an interval and prints only what changed. The baseline goes out once; after that, one line per check whose status actually moved. A quiet screen is itself the finding.

$ reachdoc --watch 5s --color=never --public-ip 203.0.113.10 --compare-resolver=off http://127.0.0.1:8096
reachdoc 0.2.0  watching http://127.0.0.1:8096/ every 5s. Only changes are printed.
18:16:12  baseline         fail             18 checks, 1 failed, 1 skipped, 16 not run
18:16:47  local_listener   fail -> pass     Port 8096 is listening on all interfaces.
18:16:47  verdict          fail -> warn     The local firewall state for port 8096 could not be established with certainty.
^C
18:16:50  stopped after 4 runs and 2 transitions.

That is a real transcript: the service was started between the first run and the third. A check that becomes blocked is deliberately not a transition — the check before it failed, that failure is already on the line above, and sixteen pass -> blocked lines would bury it. The minimum interval is 5 seconds, because comparing a run against one that has not finished reports the interval rather than the network.

--explain CHECK

The report has room for one sentence and a numbered fix. --explain is the rest: what the step establishes, why the failure it looks for is hard to see from the inside, and what a pass does not prove.

$ reachdoc --explain path_mtu
Path MTU and MSS  (path_mtu)
------------------------------------------------------------------------------------------------

This is the failure that does not look like a network failure at all. TCP connects, the
handshake completes, small responses arrive, and anything larger than one segment stops dead. A
photo will not load. A file download stalls at zero. A page with a long list spins forever.
Every diagnostic reports the service as up, because by every measure they take it is.

The mechanism: some link on the path carries smaller packets than the sender assumes. PPPoE, the
usual DSL encapsulation, carries 1492 rather than 1500. A WireGuard tunnel carries 1420. The
router that has to drop the oversized packet is supposed to answer with an ICMP "fragmentation
needed" -- ICMPv6 "packet too big" in IPv6 -- and the sender then lowers its segment size and
carries on. That is path MTU discovery, and it is the one piece of ICMP that TCP genuinely
depends on. A firewall configured to block all ICMP swallows it. The sender never finds out,
retransmits the same too-big packet, and the connection dies of a timeout. That is a black hole.

The fix is an MSS clamp on the router that owns the small link, so that no connection ever
negotiates a segment the path cannot carry. The other fix, the one that solves it everywhere
rather than one link at a time, is to stop blocking ICMP type 3 code 4 and ICMPv6 type 2.

reachdoc reads rather than measures: measuring means sending don't-fragment probes, which needs
a raw socket. A cached path MTU below the interface MTU is real evidence, because the kernel
only writes one down after something has already sent a packet that was too big.

Every check in the chain has one, and a test fails the build if a check is added without it. The ids are the ones in --json: reachdoc --explain nat, --explain hairpin_nat, --explain proxied_dns.

9. What leaves your machine

reachdoc is a network tool pointed at your own infrastructure, so it is explicit about every packet it sends anywhere but your own network.

What is sent, and where Turn it off with
Public address discovery (on by default) An HTTPS GET to api.ipify.org (api6.ipify.org for IPv6), icanhazip.com or ifconfig.co, which answer with the address they see. Nothing about your service is sent. --public-ip ADDRESS
Second-opinion DNS (on by default) One DNS query for your target's name to 1.1.1.1:53, to compare with the system resolver's answer. --compare-resolver=off
External port probe (off by default) Your public address and the port number, to the prober you name. omit --external

--public-ip ADDRESS turns discovery off rather than pinning one family: give it one address and reachdoc asks nobody, for either family.

Everything else happens between this host and its own network — including step 9, which holds a short conversation with the router itself: an SSDP M-SEARCH to the LAN multicast group, a NAT-PMP external-address request and a PCP ANNOUNCE to the default gateway, and, if the router answers, two UPnP SOAP calls to read its external address and its mapping for this one port.

reachdoc never changes anything: no firewall rules, no UPnP or NAT-PMP mappings, no configuration files. Every system command it runs is a read-only status query, and every router operation it issues is a read-only one — GetExternalIPAddress and GetSpecificPortMappingEntry, never AddPortMapping. A NAT-PMP mapping request differs from an address request by two bytes, so there is a test that inspects every datagram that leaves the process and fails the build if one of them would change anything. It also ignores HTTP_PROXY and HTTPS_PROXY: a proxy would measure the path to the proxy rather than the path to your service, and would disclose the target to a party that is not in the table above.

There is no default external prober, on purpose: reachdoc will not pick a third party to tell about your open port. --external-prober takes any HTTP endpoint that follows this contract, which is a few lines on a VPS you already own:

An external prober endpoint receives GET requests with host and port (as query parameters, or substituted for {host} and {port} in the URL) and answers either JSON {"open": true, "detail": "..."} or one plain-text word: open or closed.

reachdoc --external --external-prober 'https://probe.example.net/check' https://media.example.com
reachdoc --external --external-prober 'https://probe.example.net/{host}/{port}' https://media.example.com

Both forms work; --external without --external-prober is a usage error rather than a silently chosen default.

10. Accuracy and limits

  • It must run on the host that serves the service. Step 1 looks for a listening socket on this machine. Pointed at an address that is not this machine's, every step that reads local state skips — the listening socket, both firewalls, the LAN address, the packet size, the NAT situation, the router's mappings, the public address, the hairpin and the IPv6 situation, which is steps 1, 2, 4, 5, 7, 8, 9, 10, 12 and 17 — because they would otherwise report on the wrong host. The rest of the chain runs. A hostname target is assumed to be this host, because that is what reachdoc is for; nothing can check that assumption from here.

  • TCP only. A UDP service (WireGuard, DNS, game servers) is out of scope; nothing in the chain probes UDP as a service. The exceptions are the router conversation in step 9 and reachdoc's own DNS queries in step 13 (UDP first, TCP on truncation), both UDP by protocol and read-only by construction.

  • reachdoc does not measure a path MTU, it reads one. Measuring means sending don't-fragment probes of decreasing size, which needs a raw socket or a platform-specific socket option; this program has neither build tags nor privileges. Step 7 reports the MTU of the link packets leave by and any smaller MTU the kernel has already cached for the route, which is real evidence — a cached value only appears once something has already sent a packet that was too big — and hands over the don't-fragment ping for the rest. A PPPoE router two hops away is invisible from here.

  • The proxy range table is a snapshot, not a lookup. Step 14 recognises the ranges it shipped with. A provider that announces a new range is unrecognised until the table is updated, and --proxy-ranges exists so nobody has to wait for a release. It insists that every resolved address falls inside one provider before it calls a name proxied, because a single coincidental match would rewrite the meaning of half the report.

  • reachdoc cannot read the router's IPv6 firewall. Step 4 reports this host's own IPv6 ruleset, which is the near half. IPv6 has no port forwarding, so the router's firewall is the entire remaining path, and it belongs to the router.

  • Firewall inspection degrades to a warning. pfctl, iptables and nft need root to list rules. An unprivileged run reports what it could not read rather than guessing — pf: pf state could not be determined, plus the command to check by hand:

     !  Local firewall              The local firewall state for port 8096 could not be established
                                    with certainty.
            pf: pf state could not be determined.
            macos-application-firewall: The macOS application firewall is off.
            fix
              1. Re-run with sudo for a definite answer, or check the rule list by hand using the
                 commands below.
              2. Add to /etc/pf.conf: pass in proto tcp from any to any port 8096
              3. sudo pfctl -f /etc/pf.conf

    reachdoc warns; it never guesses that a firewall is open, and it never asserts that one is closed from a ruleset it could only partly read.

  • --wan-ip is the only way to tell double NAT from carrier-grade NAT. reachdoc cannot log in to your router. Without it, CGNAT is still detected whenever this host or the echoed public address is inside 100.64.0.0/10.

  • The outside-in view is only as good as the prober you name. Without --external, nothing checks the port from the internet; the chain reasons from what it can see from the inside.

  • Split-horizon detection compares two resolvers, not the world. A record that is wrong only at a third resolver, or only in one region, is invisible from here.

  • The IPv6 steps need a working IPv6 stack on this host. On an IPv4-only line reachdoc reports that AAAA records lead somewhere it cannot follow, which is exactly what a visitor with IPv6 experiences, but it cannot test the path itself.

  • A pass is not a promise. reachdoc checks the path it can see. It cannot see your ISP's inbound filtering, a hosting provider's security group, or a reverse proxy in another datacenter.

11. Development

gofmt -l . && go vet ./... && go build ./... && go test -race ./...

That is the whole toolchain: no linter to install, no code generation, no build tags. CI runs exactly that command on Linux, macOS and Windows against Go 1.23 and current stable, and adds four jobs whose only purpose is to keep this page honest:

Job What it would catch
standard library only a require in go.mod, a go.sum, a vendor directory, or any non-standard-library package in the import graph
tests with no network a test that opens a socket: the suite runs inside an empty network namespace with GOPROXY=off
cross-compile a platform-specific build break on one of the ten OS/architecture pairs listed above
banner and README a malformed or non-self-contained docs/assets/banner.svg, a README that no longer opens with it, or a relative link pointing at a file that does not exist

Statement coverage, from go test -cover ./...:

Package Statement coverage
cmd/reachdoc 91.5 %
internal/checks 95.0 %
internal/netprobe 83.2 %
internal/report 97.7 %

12,409 lines of Go and 11,166 lines of tests: 505 test functions and 362 subtests, table-driven throughout. The suite covers, by name, the loopback bind, CGNAT in both families, double NAT, a stale DNS record, a split-horizon answer, an expired certificate, a wrong-SAN certificate, a redirect loop, AAAA without a route, and the short-circuit behaviour when an early check fails — and, since the expansion, a UPnP mapping pointing at the wrong machine, a PPPoE 1492 link, a name resolving into Cloudflare's ranges, an open IPv4 policy beside a closed IPv6 one, a router that will not hairpin, and every service preset's own gotchas.

Six of those tests exist to stop a whole class of mistake rather than a single bug. One inspects every datagram and SOAPAction the port mapper sends and fails if any of them would change the router. One parses the generated --fix-script with sh -n. One asserts that every check in the chain has an --explain entry, so the chain cannot grow a step nobody can be taught. And three hold this page and the built-in help against the program: every flag reachdoc accepts is mentioned in this README, every check in the chain appears in --help with its id and its title, and the help lists them in chain order — which it can, because it prints the chain rather than a copy of it.

Contributions are welcome — CONTRIBUTING.md has the commit conventions and what a pull request is expected to carry. Vulnerabilities go to SECURITY.md, not to the issue tracker. Participation is governed by the Code of Conduct, and all notable changes are recorded in CHANGELOG.md.

12. License

reachdoc is open source, under the Apache License, Version 2.0 — the unmodified licence text, as published by the Apache Software Foundation. It is an OSI-approved open-source licence, and the full text is in LICENSE.

What you may do, without asking anyone:

  • use reachdoc for anything, at any organisation size, in production or out of it, commercially or not;
  • modify it, fork it, and distribute your changes;
  • bundle it inside a closed-source product, or offer it as a hosted, managed, embedded or resold service.

What the licence asks in return, when you redistribute:

  • keep the copyright, patent, trade mark and attribution notices that are already in the source;
  • include a copy of the licence, and pass on the NOTICE file with any distribution that carries one;
  • state prominently, in any file you changed, that you changed it.

There is a patent grant, and it is mutual. Section 3 gives every user a perpetual, worldwide, royalty-free patent licence from every contributor — and withdraws it from anyone who sues over patents in the software.

There is no organisation-size threshold, no separate commercial licence to buy and no future change of terms to wait for: the grant is irrevocable and it applies to everyone, now.

Contributions are taken under the Developer Certificate of Origin, with a Signed-off-by line on every commit. There is no CLA: under section 5, a contribution is licensed under Apache-2.0 on the same terms as the rest of the project by default, and contributors keep their copyright — see CONTRIBUTING.md.

Copyright © 2026 Johan Becker. "reachdoc" and its logo are unregistered trade marks of Johan Becker; the code licence grants no rights in them — see TRADEMARKS.md.

About

Why can't anyone reach my self-hosted service? One command walks the whole path to the internet and names the first thing actually broken — including the CGNAT your ISP never told you about.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages