Close the ports ufw doesn't cover
portguard is a bash script that runs from cron every minute. It connects to each listening TCP port the way another machine on your network would, and blocks every port that answers.
Why ufw misses them
Docker running as root publishes a port through its own firewall rules, and traffic on those rules skips ufw. A port can be reachable from the network while ufw status lists nothing for it.
Reading rules does not settle whether a port is reachable, so portguard does not read them. It opens a real connection from a separate network namespace and goes by whether the port answers.
What runs every minute
- Lists the TCP ports with a listener on the host.
- Probes each one from a throwaway network namespace, so the firewall treats the probe as a LAN host.
- Adds an nftables drop rule for new inbound connections to every port that answered.
- Writes a syslog alert and sends a critical desktop notification for each port it blocked.
Install
Download portguard and portguard.cron from the releases page, then copy them into place as root. Set ALLOWED_PORTS before the cron job goes in: the default list is empty, so the first run would block every reachable port, including SSH.
# the script: 0755, owned by root sudo install -m 0755 -o root -g root portguard /usr/local/sbin/portguard sudoedit /usr/local/sbin/portguard # set ALLOWED_PORTS first, e.g. "22" for SSH: an empty list blocks SSH too # the cron job: 0644, owned by root sudo install -m 0644 -o root -g root portguard.cron /etc/cron.d/portguard
To undo a block, delete the table portguard created:
sudo nft delete table inet portguard
To keep a port open on purpose, add its number to ALLOWED_PORTS in /usr/local/sbin/portguard. This does not reopen a port portguard already blocked: delete the table first. That clears all blocks, and the next run blocks the others again.
What it does not cover
- TCP only. UDP listeners are not probed.
- ufw rules that allow only certain source networks, interfaces or destination addresses are not simulated.
- Docker's routed gateway mode is not simulated.
- IPv6 must be enabled on the host: the probe adds an IPv6 address, and if that fails the run alerts and checks no port at all.
- A listener bound to a single interface (IPv4 or IPv6, for example
SO_BINDTODEVICEor a%eth0scope) is logged, not probed. - A port that is already blocked stays blocked when you add it to
ALLOWED_PORTS; delete the table to clear all blocks.
Licensed under Apache-2.0. To report a security problem, write to bb@cocode.dk.