Add FINGER_BAN_ALLOWLIST to exempt trusted front-end IPs from banning
CI / Build and Test (gcc, g++, ubuntu-latest) (push) Failing after 5m2s
CI / Code Coverage (push) Skipped
Build and Publish Docker Image / build-and-test (push) Failing after 6m13s
Build and Publish Docker Image / build-and-push-image (push) Skipped
Build and Publish Docker Image / security-scan (push) Skipped
CI / Build and Test (gcc, g++, ubuntu-latest) (push) Failing after 5m2s
CI / Code Coverage (push) Skipped
Build and Publish Docker Image / build-and-test (push) Failing after 6m13s
Build and Publish Docker Image / build-and-push-image (push) Skipped
Build and Publish Docker Image / security-scan (push) Skipped
The per-IP ban tracker treats every globally-routable client equally, but an aggregating front-end like the finger-web proxy funnels the whole internet's federated lookups through a single IP. A burst from any one client of the proxy (or a load test) is then attributed to the proxy's IP and, once it crosses the failure threshold, the daemon blocks the proxy — taking out finger lookups for everyone. Per-client abuse protection for the proxied path belongs in the proxy (which now rate-limits per real client IP), so the daemon should trust it. Add a FINGER_BAN_ALLOWLIST env var (comma-separated IPs). Allowlisted addresses are marked non-trackable in the listener, so their connections are never blocked and never recorded as offenses. Unset = unchanged behaviour. - parse_ip_allowlist() in ban.cpp (trims entries, skips blanks) + unit tests - listener() consults the set when computing 'trackable' - documented in docker-compose.yml and DOCKER.md
This commit is contained in:
@@ -114,6 +114,24 @@ client IP. In order of preference:
|
|||||||
Note: bans are in-memory, so they reset when the container restarts -- the same
|
Note: bans are in-memory, so they reset when the container restarts -- the same
|
||||||
trade-off as any single-process deployment.
|
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
|
## Docker Architecture
|
||||||
|
|
||||||
### Multi-stage Build
|
### Multi-stage Build
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#include "ban.hpp"
|
#include "ban.hpp"
|
||||||
|
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
bool is_bannable_address(const boost::asio::ip::address &addr) {
|
bool is_bannable_address(const boost::asio::ip::address &addr) {
|
||||||
if (addr.is_loopback() || addr.is_unspecified() || addr.is_multicast()) {
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::unordered_set<std::string> parse_ip_allowlist(std::string_view csv) {
|
||||||
|
std::unordered_set<std::string> 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 {
|
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.
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
#include <deque>
|
#include <deque>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
#include <unordered_map>
|
#include <unordered_map>
|
||||||
|
#include <unordered_set>
|
||||||
|
|
||||||
// BanTracker records the timestamps of "offenses" -- requests that are
|
// BanTracker records the timestamps of "offenses" -- requests that are
|
||||||
// obviously not finger queries -- per client IP, over a rolling time window.
|
// 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
|
// where pf rdr preserves it) and inert where it is not (Docker bridge), with no
|
||||||
// deployment-specific configuration.
|
// deployment-specific configuration.
|
||||||
bool is_bannable_address(const boost::asio::ip::address &addr);
|
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<std::string> parse_ip_allowlist(std::string_view csv);
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ services:
|
|||||||
# Run as root to bind it. (Alternative: setcap cap_net_bind_service on the
|
# Run as root to bind it. (Alternative: setcap cap_net_bind_service on the
|
||||||
# binary in the image to keep it non-root.)
|
# binary in the image to keep it non-root.)
|
||||||
user: "0:0"
|
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:
|
volumes:
|
||||||
- ./users:/var/finger/users
|
- ./users:/var/finger/users
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
#include <boost/asio/write.hpp>
|
#include <boost/asio/write.hpp>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <string>
|
||||||
|
#include <unordered_set>
|
||||||
|
|
||||||
#include "ban.hpp"
|
#include "ban.hpp"
|
||||||
#include "handler.hpp"
|
#include "handler.hpp"
|
||||||
@@ -92,7 +95,8 @@ awaitable<void> echo(tcp::socket socket, std::string client_addr, bool trackable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
awaitable<void> listener(BanTracker &bans) {
|
awaitable<void> listener(BanTracker &bans,
|
||||||
|
const std::unordered_set<std::string> &allowlist) {
|
||||||
auto executor = co_await this_coro::executor;
|
auto executor = co_await this_coro::executor;
|
||||||
tcp::acceptor acceptor(executor, {tcp::v4(), 79});
|
tcp::acceptor acceptor(executor, {tcp::v4(), 79});
|
||||||
for (;;) {
|
for (;;) {
|
||||||
@@ -101,7 +105,11 @@ 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();
|
||||||
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,
|
co_spawn(executor,
|
||||||
echo(std::move(socket), std::move(client_addr), trackable, bans),
|
echo(std::move(socket), std::move(client_addr), trackable, bans),
|
||||||
detached);
|
detached);
|
||||||
@@ -126,10 +134,17 @@ int main() {
|
|||||||
boost::asio::io_context io_context(1);
|
boost::asio::io_context io_context(1);
|
||||||
BanTracker bans;
|
BanTracker bans;
|
||||||
|
|
||||||
|
const char *allow_env = std::getenv("FINGER_BAN_ALLOWLIST");
|
||||||
|
const std::unordered_set<std::string> 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);
|
boost::asio::signal_set signals(io_context, SIGINT, SIGTERM);
|
||||||
signals.async_wait([&](auto, auto) { io_context.stop(); });
|
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);
|
co_spawn(io_context, sweeper(bans), detached);
|
||||||
|
|
||||||
io_context.run();
|
io_context.run();
|
||||||
|
|||||||
@@ -119,6 +119,33 @@ TEST(BannableAddress, Ipv6Classification) {
|
|||||||
EXPECT_FALSE(bannable("fd12:3456::1")); // unique-local
|
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) {
|
int main(int argc, char **argv) {
|
||||||
::testing::InitGoogleTest(&argc, argv);
|
::testing::InitGoogleTest(&argc, argv);
|
||||||
return RUN_ALL_TESTS();
|
return RUN_ALL_TESTS();
|
||||||
|
|||||||
Reference in New Issue
Block a user