The ban logic is per source IP, so it only works where the daemon can see
the real client. Behind Docker's default bridge networking every client is
SNAT'd to the bridge gateway (a 172.16/12 address), so a single IP would
stand in for the whole internet -- counting offenses against it would block
everyone at once.
Add is_bannable_address(): only globally-routable unicast addresses are
tracked. Loopback, RFC1918 private, CGNAT (100.64/10), link-local, IPv6
unique-local, and multicast all return false. main.cpp decides trackability
from the accepted endpoint and skips both the block check and offense
recording for non-global sources. Net effect: banning works where the real
IP is visible (FreeBSD jail via pf rdr; Docker with host networking) and is
inert -- not catastrophic -- where it is not (Docker bridge).
Document the Docker client-IP caveat: docker-compose.yml now defaults to
host networking, with the rationale and alternatives in DOCKER.md.
Port 79 mostly attracts HTTP/SIP probes, TLS handshakes, and username
guessers -- none of which resolve to a plan file. Treat any request that
fails to read a plan as an "offense" and timestamp it against the source
IP.
Add BanTracker (ban.hpp/ban.cpp): a per-IP rolling-window offender list.
When an IP has more than 3 offenses still inside a 24h window, its
connections are dropped without being read or answered; timestamps older
than the window are pruned so a blocked IP frees itself automatically.
State is in-memory (single io_context thread, no locking); the clock is
injected for testability. A periodic sweeper keeps the map bounded.
Legitimate lookups that hit a real plan never count, which also
frustrates username enumeration. Unit tests in test_ban.cpp.
Stdout works in both contexts: docker logs captures it directly,
and on FreeBSD daemon(8) / the Bastille jail rc script can
redirect or pipe it to syslog as needed. Going through syslog
from inside the daemon required openlog/closelog and a libc
dependency that broke portability with no real benefit.