README
¶
whyopen
whyopen answers one question: what is actually reachable from the
internet on this Linux host, and which nftables rule decides that. Run it
and read the table.
$ sudo whyopen check
RESULT PORT FAMILY OWNER BIND WHY
reachable 80/tcp IPv4 nginx.service 0.0.0.0 via 203.0.113.10: delivered locally, so the input hook decides
reachable 80/tcp IPv6 nginx.service :: via 2001:db8::10: delivered locally, so the input hook decides
reachable 443/tcp IPv4 nginx.service 0.0.0.0 via 203.0.113.10: delivered locally, so the input hook decides
reachable 443/tcp IPv6 nginx.service :: via 2001:db8::10: delivered locally, so the input hook decides
unknown 22/tcp IPv4 ssh.service 0.0.0.0 rule 104966 in filter/ufw-user-input uses an expression whyopen cannot resolve
unknown 22/tcp IPv6 ssh.service :: rule 65113 in filter/ufw6-user-input uses an expression whyopen cannot resolve
filtered 4319/tcp IPv4 node :: fell through to the drop policy of filter/INPUT
filtered 5432/tcp IPv4 postgresql@16-main.service 127.0.0.1 bound to 127.0.0.1, which does not match any host address the internet can reach
filtered 8888/tcp IPv4 search-1 127.0.0.1 bound to 127.0.0.1, which does not match any host address the internet can reach
That is a real run against an Ubuntu 24.04 host with UFW and Docker, 275
rules across 89 chains, with service and container names generalised. That
exact snapshot is committed as testdata/facts/ufw-docker-host.json and the
verdicts above are asserted by the test suite.
The two unknown rows on port 22 date the snapshot rather than describe
whyopen today. It was captured before whyopen decoded the xt recent
extension that ufw limit ssh uses, and that build discarded the payload
it could not read, so those two rules carry nothing to decode and whyopen
still declines to guess about them. A current run against a live UFW host
decodes them and resolves port 22, which the integration suite asserts
against a real kernel. Snapshots taken from v0.6.0 on keep the payload of
every xt extension, whether whyopen decoded it or not, so a later build
with a better decoder re-reads them from the document and says on stderr
how many it read differently. The verdicts above are as
collected, not edited to show what a fresh run would produce; only the owner
names are generalised, as noted above, and the table shows nine of the
thirty-nine rows.
-
RESULTisreachable(a packet from the internet zone reaches this socket),filtered(some rule or bind address stops it), orunknown(whyopen cannot decide, see below). Rows are printed worst first:reachableon top, thenunknown, thenfiltered, so an open port is the first thing on screen. -
WHYis one line;whyopen check --explain PORTprints the full ordered path of nftables rules that produced it, table by table, chain by chain, with an nft-like rendering of every rule that mattered. -
A Docker container published with
-p 127.0.0.1:5432:5432shows up asfilteredhere because the bind address itself keeps it off the wire, before any nftables rule is even consulted. -
reachableon a published port means the rules do not stop the packet, not that something is listening inside the container. whyopen cannot see into another network namespace, so a publish with nothing behind it reads the same as a live one.check --probe-fromis what tells the two apart. -
A port this host forwards to another machine is a row too, even though nothing here listens on it. A router or a VM host writing
tcp dport 8080 dnat to 192.0.2.50:80has no socket and no Docker publish for 8080, and whyopen used to report nothing at all for it, which reads as "not exposed". Such a row is owned by its destination:reachable 8080/tcp IPv4 forwarded to 192.0.2.50:80 - via 203.0.113.10: DNAT to 192.0.2.50:80, then the forward hook; whyopen cannot see 192.0.2.50, so this says the packet is forwarded there, not that anything answersA rewrite whyopen cannot reduce to named ports, one with no port constraint at all or one matching a range, is reported in the warnings block instead of as a row, naming the rule so you can go look at it. Decision 0014 is why it works that way.
What unknown means
unknown is a first-class verdict, not an error. whyopen returns it whenever
it cannot follow the packet with confidence instead of guessing: an
unreadable iptables-nft compatibility match it does not decode, an nftables
expression it has no decoder for, a DNAT target that lands on no interface
subnet it knows about, a host with no global unicast address in that family,
a jump loop past its depth bound. An unknown result means "go look by
hand," not "this is fine."
Known gaps that produce unknown today:
- Native nftables expressions whyopen has no decoder for. UFW and Docker
reach the kernel through iptables-nft, so their rules arrive as
compatibility expressions; a hand-written nft ruleset or a firewalld
host uses native ones instead, and only some of those decode. Both are
exercised against a real kernel: CI runs a real firewalld, and a chain
of the rules hardening guides tell people to write, asserting the
verdicts rather than only that the expressions parsed.
ct stateand set lookups do, in every shape a captured firewalld-style ruleset produced:ct state established,related acceptandct state { established, related } acceptcompile to two different netlink shapes and both resolve, as do an anonymous set (tcp dport { 22, 80 }) and a named one (tcp dport @allowed). Ranges do too, in every shape a capture found them in:tcp dport 1024-2048, which the kernel compiles to two ordered comparisons rather than to a range expression at all, its negation, which is the form that does produce one, and a range inside a set, named or anonymous, including one reaching the top of the port range. What whyopen still refuses there is a set it will not read as a membership test at all: a map or verdict map, a concatenated key type, a set whose elements the facts document does not carry, or an element layout no capture has produced. - A base chain on the ingress hook. It runs before prerouting, sees
raw frames rather than the IP-level context whyopen evaluates in, and
can drop a packet before any rule whyopen walks, so a port whose traffic
arrives on one of that chain's devices reports
unknown. The hook is per device and whyopen reads which devices, so a chain on another interface leaves your other ports alone. If it cannot read them, it falls back to treating the chain as seeing everything and says so in the reason. The egress hook is not treated this way: it acts on the reply, which this model does not follow, the same reason the output hook is never walked. - The
xt recentextension decodes the fourcheck_setbit patterns captured from a live kernel, which covers every modeiptables -m recentcan be written with. Any other value staysunknown: the decoder matches what was captured and does not extrapolate from it.
Forwarding is read per interface (net.ipv4.conf.<if>.forwarding) as well
as globally, because the kernel consults the device a packet arrived on:
a host that leaves net.ipv4.ip_forward at 0 and forwards on one
interface really does forward, and used to be reported as filtered. That
was the one known gap pointing the other way, reporting filtered where
the port may be open, and there is no other known gap in that direction.
If you find one, it is the most serious kind of bug this tool can have.
Requirements
- Linux only, with an nftables ruleset. UFW and Docker's DNAT rules both go
through nftables today and are covered. Rules written to the
iptables-legacy backend are invisible to whyopen, which reads only
nftables; it detects that the backend is in use (a non-empty
/proc/net/ip_tables_namesor/proc/net/ip6_tables_names, which only the legacy kernel modules create) and warns that every verdict may be incomplete. It does not claim rules exist there: that file lists tables that have been registered, which happens as soon as anything loads the module, and whyopen cannot tell from/procwhich of them carry rules. firewalld's own zone configuration is not read, but the nftables ruleset its backend writes is, and CI runs whyopen against a real running firewalld: one job installs the daemon, asserts that every native expression it emits decodes, and checks that a portfirewall-cmdwas told to open reportsreachablewhile one it was not reportsfiltered. That job is what found firewalld'sct status dnatand its reverse-pathfibrule, neither of which a hand-written imitation had produced; both decode now, and the job fails if the daemon emits anything else whyopen cannot read. - Must run as root (or at least with
CAP_NET_ADMIN) to list the ruleset over netlink and to attribute every listening socket to a process. Run unprivileged and whyopen still lists every listener it can find, but it cannot read the ruleset, so it refuses to guess: every verdict comes backunknown, the reason and awarningsblock both say why, andcheckexits 3 (a tool error, not a clean run) so cron and CI notice. - whyopen is read-only. It never creates, changes, or deletes an
nftables rule, a socket, or anything else on the host. It only reads.
The single exception sends nothing to this host either:
whyopen probeopens ordinary TCP connections to a target you name, and runs only when you ask for it by name (probe, orcheck --probe-from). It connects and closes; it changes nothing anywhere.
Install
Download a release archive for your architecture (amd64 or arm64) from the
releases page, extract it,
and put whyopen on your PATH. Debian and Ubuntu users can instead grab
the .deb package; Fedora, RHEL and openSUSE users the .rpm. Both install
whyopen to /usr/bin.
If you have Go installed:
go install github.com/MemorManeo/whyopen/cmd/whyopen@latest
Either way, whyopen needs root (or CAP_NET_ADMIN) to read the nftables
ruleset; see Requirements above.
Usage
whyopen collect [-o FILE] snapshot this host into a facts document
whyopen check [-facts FILE] [-explain PORT] [-policy FILE] [-json]
[-probe-from ssh://HOST]
report what is reachable, and why
whyopen policy init [-o FILE] [-facts FILE] write a policy from what is reachable now
whyopen probe -target IP -ports SPEC [-json] connect to a host and report what answers
whyopen version print the build version
whyopen collect writes a portable JSON snapshot (a "facts document") of
the host's addresses, listening sockets, nftables ruleset, and Docker
publishes. whyopen check evaluates that snapshot (or collects a fresh one
if -facts is not given) and prints the verdict table. Passing a facts
document to check -facts lets you evaluate a snapshot taken elsewhere,
or replay a bug report, without re-collecting.
whyopen check -explain PORT prints the full rule path for one port: every
base chain hit, in traversal order, with the handle and an nft-like
rendering of each rule.
Checking the model against reality: probe
Everything else in whyopen concludes what the kernel would do with a packet by reading rules. This finds out by sending one.
$ whyopen probe -target 203.0.113.10 -ports 22,80,443,3000
PORT STATE DETAIL
22/tcp open
80/tcp open
443/tcp open
3000/tcp filtered
open completed a handshake. closed was answered with a reset, so the
packet reached the host's TCP stack or a rule that rejects rather than
drops, which is not the same as no answer at all. filtered got no answer
before the timeout. error means whyopen could not find out (no route,
for instance) and is never read as evidence about the port.
Probing your own host from itself proves little, because the packet may never leave the machine. The point is to probe from somewhere else:
whyopen check --probe-from ssh://vantage.example
That asks vantage.example to probe this host's global address, on every
TCP port something here is listening on, and folds the answers into the
verdict set. The probe is authoritative for TCP: it found out, the model
concluded. UDP is left model-only, because a TCP probe says nothing about
it and an unanswered UDP probe says almost nothing about anything. It
needs whyopen installed on that machine, and your ssh config decides the
key, the port and the rest.
The disagreements are the reason to run it:
probe from ssh://vantage.example: 1 port(s) where the model and reality disagree
PORT FAMILY MODELLED PROBED WHAT THAT MEANS
3000/tcp IPv4 filtered open the port is open and whyopen read the ruleset as
closing it, so the model is missing something
Each direction means something different, and the table says which. The
model saying filtered where the probe gets in means whyopen is missing
something: treat the port as open and the model as wrong. The model saying
reachable where nothing answers means something between the probe and
this host stops it, a provider firewall or a cloud security group, and
nothing on this host will show it. An unknown that a probe resolves is
not a disagreement at all: it is the case probing exists for.
Reality reaches the policy: a port the probe found open is a violation if the policy does not allow it, whatever the ruleset was read to mean. A probe that could not run at all is a tool error (exit 3), never a quiet fall back to the model, because a run that silently did not check reality looks exactly like one that did.
--json
whyopen check --json writes the verdict set as a versioned document
instead of a table, so the tool composes with whatever reads it:
$ whyopen check --json | jq '.verdicts[] | select(.result=="reachable") | "\(.port)/\(.proto)"'
"80/tcp"
"443/tcp"
The document carries schema_version, the build that produced it, the
hostname, the zone, every verdict, any collection warnings, and the policy
result when --policy was given, so a reader gets the judgement along
with the verdicts rather than having to re-implement it. schema_version
is its own number, not the facts document's: one describes what was
collected, the other what was concluded.
The ordered rule path is the expensive half of the document, so it is
included only under --explain PORT, which narrows the document to that
port exactly as it narrows the text output. Each hit carries the table,
chain, hook, handle and an nft-like rendering of the rule. The exit code
is the same in either output mode.
Guardrail: the policy file
whyopen check --policy whyopen.yaml turns the report into a pass or a
fail that cron and CI can act on. The file says which ports may be
reachable from the internet:
version: 1
zones:
internet:
allow:
- 22/tcp
- 443/tcp
fail_on_unknown: true
| exit | meaning |
|---|---|
| 0 | every reachable port is allowed |
| 1 | a violation: something reachable the policy does not allow |
| 2 | unknown verdicts, or a forward whyopen could not reduce to ports, and fail_on_unknown is set |
| 3 | tool error: unreadable ruleset, missing privilege, unreadable policy, bad arguments |
A run with both a violation and an unknown exits 1, because a violation
is something whyopen concluded and an unknown is something it could not.
An entry carries no address family, so 443/tcp allows the port over
IPv4 and IPv6 alike, and it is per protocol: allowing 22/tcp says
nothing about 22/udp. Anything allowed but not reachable is reported as
a stale expectation and never fails the run, since the host is not less
safe than the policy asked for.
There is no implicit discovery. Without --policy no policy is consulted
and check behaves exactly as it did before, and whyopen never picks up
a file from the working directory: the file that decides whether your run
passes should not depend on where you were standing. Anything in it
whyopen does not understand, an unknown key, another zone name, a port
range, a version other than 1, is an error that exits 3 rather than a
line quietly ignored into a false green.
whyopen policy init writes the file from what is reachable right now,
so adopting it is one command and an edit. It prints to stdout, takes
-o FILE to write one, and refuses to overwrite an existing file,
because a policy carries edits that a facts document does not. It never
seeds a port whyopen could not resolve into the allow list, and names
those in a comment instead: an allow list is for ports you decided to
open, not for ports nobody could account for. The file it generates sets
fail_on_unknown: true, so on a host with unresolved ports the next
check --policy exits 2. That is deliberate. A guardrail that ignores
what it cannot see is a false green.
The same flag covers what never became a row at all. A rewrite that
forwards every port, or a range of them, cannot be allowed by an entry
and cannot raise a violation, because whyopen names no port for it; with
fail_on_unknown set it exits 2 and the policy block says which warning
to go read. Without the flag it stays a warning and changes no exit
code.
Compatibility
1.0 means these are promises, and breaking one takes a major version.
The command line. The subcommands, their flags, and above all the exit
codes: 0 clean, 1 a policy violation, 2 what whyopen could not resolve
with fail_on_unknown set, 3 a tool error. Anything scripted against
those keeps working.
The facts document. Its schema_version is 1. It moves only when a
reader needs new code to read a document safely: a field removed, renamed,
or changed in meaning. Adding an optional field never moves it, so a
reader must treat a field it does not find as "the collecting build did
not record this", never as a fact about the host. whyopen reads every
version up to its own and refuses a newer one. The rule and its reasoning
are in
decision 0010.
The verdict document written by check --json carries its own
schema_version, on the same rule. It is a separate number from the facts
document's: one describes what was collected, the other what was
concluded.
The policy file at version: 1, including which shapes are refused. A
policy that whyopen accepts today it will accept at 1.x.
What is deliberately not promised:
- The reason strings. They are prose for a human at 2am and they get
reworded. Match on
result, never onreason. - The table layout. Use
--jsonif something other than a person is reading it. - The Go packages. Everything is under
internal/on purpose. - That a given port keeps its verdict. A better decoder turns an
unknowninto an answer, and that is the tool improving rather than a contract breaking.unknownmeans "whyopen could not tell", not "this port is special", and nothing should be pinned to it.
Redact before you share
A facts document is a full inventory of the host: its hostname, every
network interface and IP address (including private ranges), every
listening socket with its owning process and systemd unit, and every
Docker container's name and published ports. Treat it like you would a
nft list ruleset dump or a netstat output: redact it before attaching
it to a bug report or pasting it anywhere outside your own infrastructure.
Tests
go test ./... runs the unit suite and needs no privileges.
There is a second, root-requiring tier under test/integration/, behind
the integration build tag, which exercises whyopen against a real kernel
in throwaway network namespaces and, in two tests, against a real Docker
daemon. It mutates the host it runs on. Read
test/integration/README.md before running
it. Every correctness claim in this README that could be checked against a
kernel is checked by that tier, and CI fails if it skips rather than runs.
Why it decides what it decides
docs/decisions/ is the record: what was chosen, what
was rejected, and what evidence settled it. Several of them exist because
reading the kernel disagreed with reading the documentation, which is why
the byte layouts in this tool were captured from a live kernel rather than
taken from a header file.
The original design is
docs/superpowers/specs/2026-08-28-whyopen-design.md. It is kept as the
origin rather than as current truth: where the decisions above disagree
with it, they are what shipped.
Directories
¶
| Path | Synopsis |
|---|---|
|
cmd
|
|
|
whyopen
command
Command whyopen reports which ports on this host are reachable from the internet and which nftables rules decide that.
|
Command whyopen reports which ports on this host are reachable from the internet and which nftables rules decide that. |
|
internal
|
|
|
collect
Package collect snapshots a host into a facts.Facts document.
|
Package collect snapshots a host into a facts.Facts document. |
|
facts
Package facts is the serializable snapshot of a host that whyopen reasons about.
|
Package facts is the serializable snapshot of a host that whyopen reasons about. |
|
model
Package model evaluates whether a packet from a given source zone reaches a listener, using only a facts snapshot.
|
Package model evaluates whether a packet from a given source zone reaches a listener, using only a facts snapshot. |
|
policy
Package policy compares a verdict set against what the operator said they wanted, which is what turns `whyopen check` from a report into a guardrail cron and CI can act on.
|
Package policy compares a verdict set against what the operator said they wanted, which is what turns `whyopen check` from a report into a guardrail cron and CI can act on. |
|
probe
Package probe sends real TCP connections at a host and reports what came back.
|
Package probe sends real TCP connections at a host and reports what came back. |
|
report
Package report turns verdicts into something a human reads at 2am.
|
Package report turns verdicts into something a human reads at 2am. |