diff --git a/DOCKER.md b/DOCKER.md index 60fcc3f..73bd465 100644 --- a/DOCKER.md +++ b/DOCKER.md @@ -114,6 +114,24 @@ client IP. In order of preference: Note: bans are in-memory, so they reset when the container restarts -- the same trade-off as any single-process deployment. +### Allowlisting a trusted front-end (`FINGER_BAN_ALLOWLIST`) + +Set `FINGER_BAN_ALLOWLIST` to a comma-separated list of client IPs that should +never be tracked or banned. This is for trusted aggregating front-ends: the +[`finger-web`](https://github.com/waffle2k/finger-web) proxy, for example, +funnels every federated lookup through a single IP, so a burst from any one of +*its* clients would otherwise be attributed to the proxy and ban it for +everyone. Per-client abuse protection for that path lives in the proxy (it rate +limits per real client IP), so the daemon should trust the proxy IP: + +```yaml +environment: + - FINGER_BAN_ALLOWLIST=203.0.113.10,2001:db8::10 +``` + +Addresses are matched verbatim against the connecting socket's address, so use +canonical forms. Leave it unset for a directly-exposed daemon. + ## Docker Architecture ### Multi-stage Build diff --git a/ban.cpp b/ban.cpp index 11bc257..e76a94a 100644 --- a/ban.cpp +++ b/ban.cpp @@ -1,6 +1,7 @@ #include "ban.hpp" #include +#include bool is_bannable_address(const boost::asio::ip::address &addr) { if (addr.is_loopback() || addr.is_unspecified() || addr.is_multicast()) { @@ -28,6 +29,27 @@ bool is_bannable_address(const boost::asio::ip::address &addr) { return true; } +std::unordered_set parse_ip_allowlist(std::string_view csv) { + std::unordered_set out; + std::size_t start = 0; + while (start <= csv.size()) { + const std::size_t comma = csv.find(',', start); + const std::size_t end = + (comma == std::string_view::npos) ? csv.size() : comma; + std::string_view tok = csv.substr(start, end - start); + const std::size_t a = tok.find_first_not_of(" \t\r\n"); + if (a != std::string_view::npos) { + const std::size_t b = tok.find_last_not_of(" \t\r\n"); + out.emplace(tok.substr(a, b - a + 1)); + } + if (comma == std::string_view::npos) { + break; + } + start = comma + 1; + } + return out; +} + 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 7963c21..a7f4ccc 100644 --- a/ban.hpp +++ b/ban.hpp @@ -5,7 +5,9 @@ #include #include #include +#include #include +#include // BanTracker records the timestamps of "offenses" -- requests that are // obviously not finger queries -- per client IP, over a rolling time window. @@ -73,3 +75,16 @@ private: // 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); + +// Parse a comma-separated list of IP addresses (the value of the +// FINGER_BAN_ALLOWLIST env var) into a set of address strings. Whitespace +// around each entry is trimmed and empty entries are skipped. The strings are +// matched verbatim against boost::asio's address().to_string() output, so use +// canonical forms (e.g. "147.182.255.203", "2a01:4f8:190:7447::2"). +// +// Allowlisting exists for trusted aggregating front-ends — notably the +// finger-web proxy, which funnels every federated lookup through one IP. Without +// it, a burst from any single client of the proxy is attributed to the proxy's +// IP and bans the proxy for everyone; per-client abuse protection for that path +// lives in the proxy instead. +std::unordered_set parse_ip_allowlist(std::string_view csv); diff --git a/docker-compose.yml b/docker-compose.yml index c0a71a8..1c2ef3f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -17,6 +17,14 @@ services: # Run as root to bind it. (Alternative: setcap cap_net_bind_service on the # binary in the image to keep it non-root.) user: "0:0" + # FINGER_BAN_ALLOWLIST: comma-separated client IPs that are never tracked or + # banned. Use it for trusted aggregating front-ends — e.g. the finger-web + # proxy, which funnels every federated lookup through one IP; without an + # allowlist a burst from any single client of the proxy is attributed to the + # proxy and bans it for everyone (per-client abuse protection for that path + # lives in the proxy). Leave unset for a directly-exposed daemon. + # environment: + # - FINGER_BAN_ALLOWLIST=203.0.113.10,2001:db8::10 volumes: - ./users:/var/finger/users restart: unless-stopped diff --git a/main.cpp b/main.cpp index d6e1c89..47cb1c6 100644 --- a/main.cpp +++ b/main.cpp @@ -11,6 +11,9 @@ #include #include #include +#include +#include +#include #include "ban.hpp" #include "handler.hpp" @@ -92,7 +95,8 @@ awaitable echo(tcp::socket socket, std::string client_addr, bool trackable } } -awaitable listener(BanTracker &bans) { +awaitable listener(BanTracker &bans, + const std::unordered_set &allowlist) { auto executor = co_await this_coro::executor; tcp::acceptor acceptor(executor, {tcp::v4(), 79}); for (;;) { @@ -101,7 +105,11 @@ awaitable listener(BanTracker &bans) { auto endpoint = socket.remote_endpoint(ec); std::string client_addr = ec ? std::string("unknown") : endpoint.address().to_string(); - bool trackable = !ec && is_bannable_address(endpoint.address()); + // Allowlisted IPs (trusted aggregating front-ends like the finger-web + // proxy) are never tracked, so their bursts neither block them nor count + // as offenses. + bool trackable = !ec && is_bannable_address(endpoint.address()) && + allowlist.find(client_addr) == allowlist.end(); co_spawn(executor, echo(std::move(socket), std::move(client_addr), trackable, bans), detached); @@ -126,10 +134,17 @@ int main() { boost::asio::io_context io_context(1); BanTracker bans; + const char *allow_env = std::getenv("FINGER_BAN_ALLOWLIST"); + const std::unordered_set allowlist = + parse_ip_allowlist(allow_env ? allow_env : ""); + for (const auto &ip : allowlist) { + std::printf("ban allowlist: %s (never tracked or blocked)\n", ip.c_str()); + } + boost::asio::signal_set signals(io_context, SIGINT, SIGTERM); signals.async_wait([&](auto, auto) { io_context.stop(); }); - co_spawn(io_context, listener(bans), detached); + co_spawn(io_context, listener(bans, allowlist), detached); co_spawn(io_context, sweeper(bans), detached); io_context.run(); diff --git a/test_ban.cpp b/test_ban.cpp index 585a299..87e39bb 100644 --- a/test_ban.cpp +++ b/test_ban.cpp @@ -119,6 +119,33 @@ TEST(BannableAddress, Ipv6Classification) { EXPECT_FALSE(bannable("fd12:3456::1")); // unique-local } +TEST(IpAllowlist, ParsesCommaSeparatedTrimmedEntries) { + auto a = parse_ip_allowlist("147.182.255.203, 10.0.0.1 ,\t2a01:4f8:190:7447::2"); + EXPECT_EQ(a.size(), 3u); + EXPECT_TRUE(a.count("147.182.255.203")); + EXPECT_TRUE(a.count("10.0.0.1")); + EXPECT_TRUE(a.count("2a01:4f8:190:7447::2")); +} + +TEST(IpAllowlist, SingleEntryNoCommas) { + auto a = parse_ip_allowlist("147.182.255.203"); + EXPECT_EQ(a.size(), 1u); + EXPECT_TRUE(a.count("147.182.255.203")); +} + +TEST(IpAllowlist, EmptyAndBlankYieldEmptySet) { + EXPECT_TRUE(parse_ip_allowlist("").empty()); + EXPECT_TRUE(parse_ip_allowlist(" ").empty()); + EXPECT_TRUE(parse_ip_allowlist(",, ,\t,").empty()); // only separators/blanks +} + +TEST(IpAllowlist, IgnoresEmptyEntriesBetweenCommas) { + auto a = parse_ip_allowlist("8.8.8.8,,9.9.9.9,"); + EXPECT_EQ(a.size(), 2u); + EXPECT_TRUE(a.count("8.8.8.8")); + EXPECT_TRUE(a.count("9.9.9.9")); +} + int main(int argc, char **argv) { ::testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS();