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:
@@ -64,6 +64,50 @@ docker run -d \
|
||||
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
|
||||
|
||||
### Multi-stage Build
|
||||
|
||||
@@ -1,5 +1,33 @@
|
||||
#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.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
#include <boost/asio/ip/address.hpp>
|
||||
#include <chrono>
|
||||
#include <cstddef>
|
||||
#include <deque>
|
||||
@@ -58,3 +59,17 @@ private:
|
||||
// monotonic, so appends are always newest-last).
|
||||
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
@@ -3,8 +3,15 @@ version: '3.8'
|
||||
services:
|
||||
finger:
|
||||
build: .
|
||||
ports:
|
||||
- "79:79"
|
||||
# IMPORTANT: host networking is what lets the daemon's abuse protection
|
||||
# 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:
|
||||
- ./users:/var/finger/users
|
||||
restart: unless-stopped
|
||||
@@ -15,7 +22,10 @@ services:
|
||||
retries: 3
|
||||
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:
|
||||
# image: ghcr.io/waffle2k/finger:latest
|
||||
# ports:
|
||||
|
||||
@@ -25,14 +25,17 @@ awaitable<std::string> dofinger(const std::string &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) {
|
||||
try {
|
||||
auto now = std::chrono::steady_clock::now();
|
||||
|
||||
// An IP that has racked up too many failed lookups (scanners, username
|
||||
// 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());
|
||||
co_return;
|
||||
}
|
||||
@@ -58,10 +61,15 @@ awaitable<void> echo(tcp::socket socket, std::string client_addr,
|
||||
bool plan_served =
|
||||
response != username && response.rfind("InvalidInput:", 0) != 0;
|
||||
if (!plan_served) {
|
||||
if (trackable) {
|
||||
auto res = bans.record_offense(client_addr, now);
|
||||
std::printf("finger miss from %s for '%s' (%d failures in window)%s\n",
|
||||
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(
|
||||
socket, boost::asio::buffer(std::string("No plan found\r\n")),
|
||||
deferred);
|
||||
@@ -83,7 +91,9 @@ awaitable<void> listener(BanTracker &bans) {
|
||||
auto endpoint = socket.remote_endpoint(ec);
|
||||
std::string client_addr =
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "ban.hpp"
|
||||
#include <boost/asio/ip/address.hpp>
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
|
||||
Reference in New Issue
Block a user