diff --git a/DOCKER.md b/DOCKER.md index 4b9801a..60ffdd9 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -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 diff --git a/ban.cpp b/ban.cpp index 2b665c9..11bc257 100644 --- a/ban.cpp +++ b/ban.cpp @@ -1,5 +1,33 @@ #include "ban.hpp" +#include + +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. diff --git a/ban.hpp b/ban.hpp index c2d4b64..7963c21 100644 --- a/ban.hpp +++ b/ban.hpp @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -58,3 +59,17 @@ private: // monotonic, so appends are always newest-last). std::unordered_map> 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); diff --git a/docker-compose.yml b/docker-compose.yml index 2e6ba5d..199b99d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/main.cpp b/main.cpp index 86f4fe9..94082b4 100644 --- a/main.cpp +++ b/main.cpp @@ -25,14 +25,17 @@ awaitable dofinger(const std::string &username) { co_return process(username); } -awaitable echo(tcp::socket socket, std::string client_addr, +awaitable 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 echo(tcp::socket socket, std::string client_addr, bool plan_served = response != username && response.rfind("InvalidInput:", 0) != 0; if (!plan_served) { - 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" : ""); + 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 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); } } diff --git a/test_ban.cpp b/test_ban.cpp index abd6d57..585a299 100644 --- a/test_ban.cpp +++ b/test_ban.cpp @@ -1,4 +1,5 @@ #include "ban.hpp" +#include #include 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();