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.
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.
From a clone, which is the whole build:
git clone https://github.com/johan-becker/reachdoc
cd reachdoc
go build ./cmd/reachdocOr straight into GOBIN:
go install github.com/johan-becker/reachdoc/cmd/reachdoc@latestGo 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
"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.| 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. |
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 gotchasA 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.
| 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 |
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.
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 $?
1Honoured 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 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.
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.
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.
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.
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.
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
hostandport(as query parameters, or substituted for{host}and{port}in the URL) and answers either JSON{"open": true, "detail": "..."}or one plain-text word:openorclosed.
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.comBoth forms work; --external without --external-prober is a usage error rather than
a silently chosen default.
-
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-rangesexists 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,iptablesandnftneed 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-ipis 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 inside100.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.
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.
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.