Files
finger/ban.cpp
T
pmb 54650af252 Only track bannable (globally-routable) source IPs
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.
2026-06-15 16:38:07 -07:00

84 lines
2.4 KiB
C++

#include "ban.hpp"
#include <cstdint>
bool is_bannable_address(const boost::asio::ip::address &addr) {
if (addr.is_loopback() || addr.is_unspecified() || addr.is_multicast()) {
return false;
}
if (addr.is_v4()) {
const std::uint32_t a = addr.to_v4().to_uint();
if ((a & 0xFF000000u) == 0x0A000000u) return false; // 10.0.0.0/8
if ((a & 0xFFF00000u) == 0xAC100000u) return false; // 172.16.0.0/12
if ((a & 0xFFFF0000u) == 0xC0A80000u) return false; // 192.168.0.0/16
if ((a & 0xFFFF0000u) == 0xA9FE0000u) return false; // 169.254.0.0/16 link-local
if ((a & 0xFFC00000u) == 0x64400000u) return false; // 100.64.0.0/10 CGNAT / Tailscale
return true;
}
// IPv6: drop link-local (fe80::/10) and unique-local (fc00::/7).
const auto v6 = addr.to_v6();
if (v6.is_link_local()) {
return false;
}
if ((v6.to_bytes()[0] & 0xFEu) == 0xFCu) {
return false;
}
return true;
}
namespace {
// Count timestamps that fall within (now - window, now]. The deque is kept in
// ascending order, so the in-window entries are always a suffix.
int count_in_window(const std::deque<BanTracker::clock::time_point> &ts,
BanTracker::clock::time_point now,
BanTracker::clock::duration window) {
const auto cutoff = now - window;
int count = 0;
for (auto it = ts.rbegin(); it != ts.rend() && *it > cutoff; ++it) {
++count;
}
return count;
}
} // namespace
bool BanTracker::is_blocked(const std::string &ip, clock::time_point now) const {
auto it = offenders_.find(ip);
if (it == offenders_.end()) {
return false;
}
return count_in_window(it->second, now, cfg_.window) > cfg_.threshold;
}
BanTracker::OffenseResult
BanTracker::record_offense(const std::string &ip, clock::time_point now) {
auto &ts = offenders_[ip];
const auto cutoff = now - cfg_.window;
// Drop this IP's timestamps that have aged out of the window.
while (!ts.empty() && ts.front() <= cutoff) {
ts.pop_front();
}
ts.push_back(now);
const int count = static_cast<int>(ts.size());
return {count, count > cfg_.threshold};
}
void BanTracker::sweep(clock::time_point now) {
const auto cutoff = now - cfg_.window;
for (auto it = offenders_.begin(); it != offenders_.end();) {
auto &ts = it->second;
while (!ts.empty() && ts.front() <= cutoff) {
ts.pop_front();
}
if (ts.empty()) {
it = offenders_.erase(it);
} else {
++it;
}
}
}