whyopen

module
v0.3.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 30, 2026 License: Apache-2.0

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 a facts document preserves what the collector understood when it ran, so those two rules still carry no decoded payload 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. 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.

  • RESULT is reachable (a packet from the internet zone reaches this socket), filtered (some rule or bind address stops it), or unknown (whyopen cannot decide, see below). Rows are printed worst first: reachable on top, then unknown, then filtered, so an open port is the first thing on screen.
  • WHY is one line; whyopen check --explain PORT prints 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:5432 shows up as filtered here because the bind address itself keeps it off the wire, before any nftables rule is even consulted.

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. ct state and set lookups do, in every shape a captured firewalld-style ruleset produced: ct state established,related accept and ct state { established, related } accept compile to two different netlink shapes and both resolve, as do an anonymous set (tcp dport { 22, 80 }) and a named one (tcp dport @allowed). What still reports unknown there is a numeric range (tcp dport 1024-2048) or an interval-flagged set, undecoded because no captured ruleset has yet shown one; and, deliberately, a set whyopen will not read as a flat membership test: a map or verdict map, a concatenated key type, or a set whose elements the facts document does not carry.
  • The xt recent extension decodes only the three check_set bit patterns captured from a live kernel, which is what ufw limit ssh emits. A --remove rule was never captured, so it still reports unknown rather than being guessed at from the pattern the other three follow.

One known gap points the other way, reporting filtered where the port may be open: whyopen reads only the global forwarding toggles (net.ipv4.ip_forward, net.ipv6.conf.all.forwarding), so a host that leaves those off but enables forwarding on one interface (net.ipv4.conf.<if>.forwarding=1) is reported as not forwarding at all.

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 their presence (a non-empty /proc/net/ip_tables_names or /proc/net/ip6_tables_names, which only the legacy kernel modules create) and prints a warning saying every verdict may be incomplete. firewalld's own zone configuration is not read, but the nftables ruleset its backend writes is: the expressions such a ruleset emits were captured and are decoded (see decision 0004), though whyopen has been tested against a firewalld-shaped ruleset applied by hand, not against the daemon itself.
  • 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 back unknown, the reason and a warnings block both say why, and check exits 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.

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]
                                               report what is reachable, and why
whyopen policy init [-o FILE] [-facts FILE]    write a policy from what is reachable now
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.

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, 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.

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 one test, against a real Docker daemon. It mutates the host it runs on. Read test/integration/README.md before running it.

Design

Design: docs/superpowers/specs/2026-08-28-whyopen-design.md

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.
report
Package report turns verdicts into something a human reads at 2am.
Package report turns verdicts into something a human reads at 2am.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL