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.
This commit is contained in:
pmb
2026-06-15 16:38:07 -07:00
parent 946c2b9e01
commit 54650af252
6 changed files with 153 additions and 10 deletions
+44
View File
@@ -64,6 +64,50 @@ docker run -d \
ghcr.io/waffle2k/finger:latest ghcr.io/waffle2k/finger:latest
``` ```
## Abuse protection & client IPs (important)
The daemon bans source IPs that rack up repeated failed lookups (scanners, SIP/
HTTP probes, username guessers) -- see the "Abuse protection" section in the
main [README.md](README.md). That protection is **per source IP**, so it only
works if the container can see the *real* client IP.
Under Docker's **default bridge networking this is not the case**: published
ports are NAT'd so every external client arrives with the bridge gateway as its
source (e.g. `172.20.0.1`). The daemon would see one IP for the entire internet.
By design it treats private/RFC1918 addresses as untrackable, so rather than
blocking everyone at once, banning simply becomes **inert** under bridge
networking.
To make abuse protection actually work in Docker, give the container the real
client IP. In order of preference:
1. **Host networking (recommended).** Add `network_mode: host` to the service
(and drop the `ports:` mapping -- it's ignored). The daemon then binds the
host's port 79 directly and sees real client IPs. Non-root bind of port 79
still works because Docker grants `CAP_NET_BIND_SERVICE` by default. This is
what `docker-compose.yml` in this repo now uses.
```yaml
services:
finger:
image: ghcr.io/waffle2k/finger:latest
network_mode: host
volumes:
- ./users:/var/finger/users
restart: unless-stopped
```
2. **macvlan network.** Give the container its own IP on the LAN. More setup,
but keeps the container off host networking.
3. **Disable the userland proxy host-wide** (`/etc/docker/daemon.json`:
`{"userland-proxy": false}`, then restart dockerd). iptables DNAT then
preserves the source IP on published ports. This is a host-wide change that
restarts every container on the host -- avoid it on busy multi-service hosts.
Note: bans are in-memory, so they reset when the container restarts -- the same
trade-off as any single-process deployment.
## Docker Architecture ## Docker Architecture
### Multi-stage Build ### Multi-stage Build
+28
View File
@@ -1,5 +1,33 @@
#include "ban.hpp" #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 { namespace {
// Count timestamps that fall within (now - window, now]. The deque is kept in // Count timestamps that fall within (now - window, now]. The deque is kept in
// ascending order, so the in-window entries are always a suffix. // ascending order, so the in-window entries are always a suffix.
+15
View File
@@ -1,5 +1,6 @@
#pragma once #pragma once
#include <boost/asio/ip/address.hpp>
#include <chrono> #include <chrono>
#include <cstddef> #include <cstddef>
#include <deque> #include <deque>
@@ -58,3 +59,17 @@ private:
// monotonic, so appends are always newest-last). // monotonic, so appends are always newest-last).
std::unordered_map<std::string, std::deque<clock::time_point>> offenders_; std::unordered_map<std::string, std::deque<clock::time_point>> offenders_;
}; };
// Whether a client address is meaningful to track and ban. Only globally
// routable unicast addresses qualify. Loopback, RFC1918 private, CGNAT
// (100.64/10), link-local, IPv6 unique-local, and multicast addresses all
// return false.
//
// This matters because the daemon can only ban what it can see: behind Docker's
// default bridge networking every external client is SNAT'd to the bridge
// gateway (a 172.16/12 address), so banning per source IP would collapse all
// clients into one and block everyone. Skipping non-global addresses makes
// banning correct where the real client IP is visible (e.g. the FreeBSD jail,
// where pf rdr preserves it) and inert where it is not (Docker bridge), with no
// deployment-specific configuration.
bool is_bannable_address(const boost::asio::ip::address &addr);
+13 -3
View File
@@ -3,8 +3,15 @@ version: '3.8'
services: services:
finger: finger:
build: . build: .
ports: # IMPORTANT: host networking is what lets the daemon's abuse protection
- "79:79" # work. Under Docker's default bridge networking every external client is
# SNAT'd to the bridge gateway (a 172.16/12 address), so the daemon sees a
# single source IP for everyone -- the per-IP ban logic can't tell clients
# apart and (by design) treats that private address as untrackable, leaving
# banning inert. Host networking exposes the real client IP, so repeat
# offenders actually get blocked. (Non-root bind of port 79 still works:
# Docker grants CAP_NET_BIND_SERVICE by default.)
network_mode: host
volumes: volumes:
- ./users:/var/finger/users - ./users:/var/finger/users
restart: unless-stopped restart: unless-stopped
@@ -15,7 +22,10 @@ services:
retries: 3 retries: 3
start_period: 40s start_period: 40s
# Example using published image instead of building locally # Bridge-networking alternative (quick local testing only). NOTE: with this
# mode the daemon only ever sees the bridge gateway IP, so abuse protection
# is effectively disabled. Prefer host networking above for any public-facing
# deployment.
# finger: # finger:
# image: ghcr.io/waffle2k/finger:latest # image: ghcr.io/waffle2k/finger:latest
# ports: # ports:
+17 -7
View File
@@ -25,14 +25,17 @@ awaitable<std::string> dofinger(const std::string &username) {
co_return process(username); co_return process(username);
} }
awaitable<void> echo(tcp::socket socket, std::string client_addr, awaitable<void> echo(tcp::socket socket, std::string client_addr, bool trackable,
BanTracker &bans) { BanTracker &bans) {
try { try {
auto now = std::chrono::steady_clock::now(); auto now = std::chrono::steady_clock::now();
// An IP that has racked up too many failed lookups (scanners, username // An IP that has racked up too many failed lookups (scanners, username
// guessers, non-finger junk) is dropped without being read or answered. // guessers, non-finger junk) is dropped without being read or answered.
if (bans.is_blocked(client_addr, now)) { // Only globally-routable addresses are tracked: behind Docker's bridge
// every client is SNAT'd to the gateway, so banning there would block
// everyone at once (see is_bannable_address()).
if (trackable && bans.is_blocked(client_addr, now)) {
std::printf("finger drop from %s: blocked\n", client_addr.c_str()); std::printf("finger drop from %s: blocked\n", client_addr.c_str());
co_return; co_return;
} }
@@ -58,10 +61,15 @@ awaitable<void> echo(tcp::socket socket, std::string client_addr,
bool plan_served = bool plan_served =
response != username && response.rfind("InvalidInput:", 0) != 0; response != username && response.rfind("InvalidInput:", 0) != 0;
if (!plan_served) { if (!plan_served) {
auto res = bans.record_offense(client_addr, now); if (trackable) {
std::printf("finger miss from %s for '%s' (%d failures in window)%s\n", auto res = bans.record_offense(client_addr, now);
client_addr.c_str(), username.c_str(), res.count, std::printf("finger miss from %s for '%s' (%d failures in window)%s\n",
res.blocked ? " -- now blocked" : ""); client_addr.c_str(), username.c_str(), res.count,
res.blocked ? " -- now blocked" : "");
} else {
std::printf("finger miss from %s for '%s' (not tracked)\n",
client_addr.c_str(), username.c_str());
}
co_await async_write( co_await async_write(
socket, boost::asio::buffer(std::string("No plan found\r\n")), socket, boost::asio::buffer(std::string("No plan found\r\n")),
deferred); deferred);
@@ -83,7 +91,9 @@ awaitable<void> listener(BanTracker &bans) {
auto endpoint = socket.remote_endpoint(ec); auto endpoint = socket.remote_endpoint(ec);
std::string client_addr = std::string client_addr =
ec ? std::string("unknown") : endpoint.address().to_string(); ec ? std::string("unknown") : endpoint.address().to_string();
co_spawn(executor, echo(std::move(socket), std::move(client_addr), bans), bool trackable = !ec && is_bannable_address(endpoint.address());
co_spawn(executor,
echo(std::move(socket), std::move(client_addr), trackable, bans),
detached); detached);
} }
} }
+36
View File
@@ -1,4 +1,5 @@
#include "ban.hpp" #include "ban.hpp"
#include <boost/asio/ip/address.hpp>
#include <gtest/gtest.h> #include <gtest/gtest.h>
using namespace std::chrono_literals; using namespace std::chrono_literals;
@@ -83,6 +84,41 @@ TEST(BanTracker, RespectsCustomConfig) {
EXPECT_FALSE(bt.is_blocked("9.9.9.9", kBase + 1h + 1min)); // window elapsed EXPECT_FALSE(bt.is_blocked("9.9.9.9", kBase + 1h + 1min)); // window elapsed
} }
static bool bannable(const char *ip) {
return is_bannable_address(boost::asio::ip::make_address(ip));
}
TEST(BannableAddress, GlobalIpv4IsBannable) {
EXPECT_TRUE(bannable("8.8.8.8"));
EXPECT_TRUE(bannable("192.184.167.198")); // a real scanner seen in the logs
EXPECT_TRUE(bannable("1.2.3.4"));
}
TEST(BannableAddress, PrivateAndLocalIpv4AreNotBannable) {
EXPECT_FALSE(bannable("127.0.0.1")); // loopback
EXPECT_FALSE(bannable("10.1.2.3")); // 10/8
EXPECT_FALSE(bannable("172.20.0.1")); // Docker bridge gateway (172.16/12)
EXPECT_FALSE(bannable("172.31.255.1"));
EXPECT_FALSE(bannable("192.168.1.104")); // the finger jail's own LAN IP
EXPECT_FALSE(bannable("169.254.10.1")); // link-local
EXPECT_FALSE(bannable("224.0.0.1")); // multicast
}
TEST(BannableAddress, CgnatRangeIsNotBannable) {
EXPECT_FALSE(bannable("100.64.0.1")); // bottom of 100.64/10 (CGNAT/Tailscale)
EXPECT_FALSE(bannable("100.127.255.1")); // top of the range
EXPECT_TRUE(bannable("100.63.255.1")); // just below the range -> public
EXPECT_TRUE(bannable("100.128.0.1")); // just above the range -> public
}
TEST(BannableAddress, Ipv6Classification) {
EXPECT_TRUE(bannable("2001:4860:4860::8888")); // global
EXPECT_FALSE(bannable("::1")); // loopback
EXPECT_FALSE(bannable("fe80::1")); // link-local
EXPECT_FALSE(bannable("fc00::1")); // unique-local
EXPECT_FALSE(bannable("fd12:3456::1")); // unique-local
}
int main(int argc, char **argv) { int main(int argc, char **argv) {
::testing::InitGoogleTest(&argc, argv); ::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS(); return RUN_ALL_TESTS();