diff --git a/README.md b/README.md index 78e2b08..f90f164 100644 --- a/README.md +++ b/README.md @@ -59,3 +59,13 @@ and execute `docker compose up -d` # Setting your status within the `./users` directory, create a file named after the user you wish to have a response. That's it! + +# Abuse protection +Most traffic on port 79 is not finger at all -- HTTP and SIP probes, TLS +handshakes, and username-guessing scanners. None of these resolve to a plan +file, so the daemon treats any request that fails to read a plan as an +"offense" and timestamps it against the source IP. When an IP records more than +3 failures within a rolling 24-hour window, its connections are dropped +(without being read or answered) until those failures age back out of the +window. Legitimate lookups that hit a real plan never count against an IP. All +state is in-memory; thresholds live in `BanTracker::Config` (`ban.hpp`). diff --git a/ban.cpp b/ban.cpp new file mode 100644 index 0000000..2b665c9 --- /dev/null +++ b/ban.cpp @@ -0,0 +1,55 @@ +#include "ban.hpp" + +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. +int count_in_window(const std::deque &ts, + BanTracker::clock::time_point now, + BanTracker::clock::duration window) { + const auto cutoff = now - window; + int count = 0; + for (auto it = ts.rbegin(); it != ts.rend() && *it > cutoff; ++it) { + ++count; + } + return count; +} +} // namespace + +bool BanTracker::is_blocked(const std::string &ip, clock::time_point now) const { + auto it = offenders_.find(ip); + if (it == offenders_.end()) { + return false; + } + return count_in_window(it->second, now, cfg_.window) > cfg_.threshold; +} + +BanTracker::OffenseResult +BanTracker::record_offense(const std::string &ip, clock::time_point now) { + auto &ts = offenders_[ip]; + const auto cutoff = now - cfg_.window; + + // Drop this IP's timestamps that have aged out of the window. + while (!ts.empty() && ts.front() <= cutoff) { + ts.pop_front(); + } + + ts.push_back(now); + + const int count = static_cast(ts.size()); + return {count, count > cfg_.threshold}; +} + +void BanTracker::sweep(clock::time_point now) { + const auto cutoff = now - cfg_.window; + for (auto it = offenders_.begin(); it != offenders_.end();) { + auto &ts = it->second; + while (!ts.empty() && ts.front() <= cutoff) { + ts.pop_front(); + } + if (ts.empty()) { + it = offenders_.erase(it); + } else { + ++it; + } + } +} diff --git a/ban.hpp b/ban.hpp new file mode 100644 index 0000000..c2d4b64 --- /dev/null +++ b/ban.hpp @@ -0,0 +1,60 @@ +#pragma once + +#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. +// When an IP has more than `threshold` offenses still inside the window, it is +// blocked and its connections are dropped. Offense timestamps older than the +// window are pruned, so a blocked IP automatically frees itself once its old +// offenses age out. +// +// All state is in-memory: the daemon runs a single io_context thread, so every +// call happens on the same thread and no locking is required. Time is passed +// in as a steady_clock time_point rather than read internally, so the logic is +// deterministic and unit-testable. +class BanTracker { +public: + using clock = std::chrono::steady_clock; + + struct Config { + int threshold = 3; // block when offenses exceed this + clock::duration window = std::chrono::hours(24); // rolling window length + }; + + struct OffenseResult { + int count; // offenses within the window, including this one + bool blocked; // true if the IP is now blocked (count > threshold) + }; + + BanTracker() = default; + explicit BanTracker(Config cfg) : cfg_(cfg) {} + + // True if ip currently has more than `threshold` offenses inside the rolling + // window. Does not mutate state. + bool is_blocked(const std::string &ip, clock::time_point now) const; + + // Record one offense from ip at `now`. Prunes that IP's expired timestamps, + // appends this one, and reports the in-window count and whether it is now + // blocked. + OffenseResult record_offense(const std::string &ip, clock::time_point now); + + // Drop timestamps older than the window across all IPs, removing any IP left + // with no offenses. Safe to call periodically to keep the map bounded. + void sweep(clock::time_point now); + + // Number of tracked IPs (for introspection and tests). + std::size_t tracked() const { return offenders_.size(); } + + const Config &config() const { return cfg_; } + +private: + Config cfg_{}; + // Per-IP offense timestamps, kept in ascending order (steady_clock is + // monotonic, so appends are always newest-last). + std::unordered_map> offenders_; +}; diff --git a/main.cpp b/main.cpp index 390b4f2..86f4fe9 100644 --- a/main.cpp +++ b/main.cpp @@ -6,9 +6,12 @@ #include #include #include +#include #include +#include #include +#include "ban.hpp" #include "handler.hpp" using boost::asio::awaitable; @@ -22,8 +25,18 @@ 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, + 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)) { + std::printf("finger drop from %s: blocked\n", client_addr.c_str()); + co_return; + } + char data[1024]; auto bytes_read = co_await socket.async_read_some(boost::asio::buffer(data), deferred); @@ -36,8 +49,19 @@ awaitable echo(tcp::socket socket, std::string client_addr) { std::printf("finger request from %s for user '%s'\n", client_addr.c_str(), username.c_str()); auto response = co_await dofinger(username); - if (response.compare(std::string(username)) == 0) { - // No plan found + + // A "failure" is simply any request that does not resolve to a readable + // plan file: an unknown user, rejected input, or non-finger junk. Each + // failure is timestamped against the client IP; once an IP exceeds the + // threshold within the rolling window, the is_blocked() check above starts + // dropping its connections. This also frustrates username guessing. + 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" : ""); co_await async_write( socket, boost::asio::buffer(std::string("No plan found\r\n")), deferred); @@ -50,7 +74,7 @@ awaitable echo(tcp::socket socket, std::string client_addr) { } } -awaitable listener() { +awaitable listener(BanTracker &bans) { auto executor = co_await this_coro::executor; tcp::acceptor acceptor(executor, {tcp::v4(), 79}); for (;;) { @@ -59,21 +83,34 @@ awaitable listener() { 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)), + co_spawn(executor, echo(std::move(socket), std::move(client_addr), bans), detached); } } +// Periodically prune offense records that have aged out of the window so the +// tracker's memory stays bounded even for IPs that never reconnect. +awaitable sweeper(BanTracker &bans) { + boost::asio::steady_timer timer(co_await this_coro::executor); + for (;;) { + timer.expires_after(std::chrono::minutes(10)); + co_await timer.async_wait(deferred); + bans.sweep(std::chrono::steady_clock::now()); + } +} + int main() { // Line-buffer stdout so docker logs / tail -f see entries in real time. std::setvbuf(stdout, nullptr, _IOLBF, 0); try { boost::asio::io_context io_context(1); + BanTracker bans; boost::asio::signal_set signals(io_context, SIGINT, SIGTERM); signals.async_wait([&](auto, auto) { io_context.stop(); }); - co_spawn(io_context, listener(), detached); + co_spawn(io_context, listener(bans), detached); + co_spawn(io_context, sweeper(bans), detached); io_context.run(); } catch (std::exception &e) { diff --git a/meson.build b/meson.build index ab94a94..7c38986 100644 --- a/meson.build +++ b/meson.build @@ -14,7 +14,7 @@ gtest_dep = dependency('gtest', main : true, required : true) gmock_dep = dependency('gmock', main : true, required : true) executable('finger', - 'main.cpp','handler.cpp', + 'main.cpp','handler.cpp','ban.cpp', dependencies : [boost_dep, threads_dep], install : true) @@ -33,7 +33,13 @@ test_real_fs_exe = executable('test_handler_real_filesystem', 'test_handler_real_filesystem.cpp', 'handler.cpp', dependencies : [boost_dep, threads_dep, gtest_dep, gmock_dep]) +# Ban tracker test executable +test_ban_exe = executable('test_ban', + 'test_ban.cpp', 'ban.cpp', + dependencies : [boost_dep, threads_dep, gtest_dep, gmock_dep]) + # Register the tests test('handler_tests', test_exe) test('handler_mock_tests', test_mock_exe) test('handler_real_filesystem_tests', test_real_fs_exe) +test('ban_tests', test_ban_exe) diff --git a/test_ban.cpp b/test_ban.cpp new file mode 100644 index 0000000..abd6d57 --- /dev/null +++ b/test_ban.cpp @@ -0,0 +1,89 @@ +#include "ban.hpp" +#include + +using namespace std::chrono_literals; +using clock_t_ = BanTracker::clock; + +// Work well away from the steady_clock epoch so that subtracting the window +// never underflows and default-constructed time_points are unambiguous. +static const clock_t_::time_point kBase = clock_t_::time_point{} + 1000h; + +TEST(BanTracker, UnknownIpIsNotBlocked) { + BanTracker bt; + EXPECT_FALSE(bt.is_blocked("1.2.3.4", kBase)); +} + +TEST(BanTracker, BlocksOnlyAfterMoreThanThreshold) { + BanTracker bt; // default threshold = 3, so block on the 4th failure + EXPECT_FALSE(bt.record_offense("1.2.3.4", kBase).blocked); // 1 + EXPECT_FALSE(bt.record_offense("1.2.3.4", kBase).blocked); // 2 + EXPECT_FALSE(bt.record_offense("1.2.3.4", kBase).blocked); // 3 + EXPECT_FALSE(bt.is_blocked("1.2.3.4", kBase)); + auto r = bt.record_offense("1.2.3.4", kBase); // 4 + EXPECT_TRUE(r.blocked); + EXPECT_EQ(r.count, 4); + EXPECT_TRUE(bt.is_blocked("1.2.3.4", kBase)); +} + +TEST(BanTracker, TracksEachIpIndependently) { + BanTracker bt; + for (int i = 0; i < 4; ++i) { + bt.record_offense("1.1.1.1", kBase); + } + EXPECT_TRUE(bt.is_blocked("1.1.1.1", kBase)); + EXPECT_FALSE(bt.is_blocked("2.2.2.2", kBase)); +} + +TEST(BanTracker, OffensesAgeOutOfRollingWindow) { + BanTracker bt; + // Four failures spread over a couple of hours -> blocked. + for (int i = 0; i < 4; ++i) { + bt.record_offense("1.2.3.4", kBase + i * 1h); + } + EXPECT_TRUE(bt.is_blocked("1.2.3.4", kBase + 3h)); + + // 24h after the first failure, that one drops out of the window: only 3 + // remain, so the IP is no longer blocked. + EXPECT_FALSE(bt.is_blocked("1.2.3.4", kBase + 24h + 1min)); +} + +TEST(BanTracker, WindowBoundaryIsExclusiveAtCutoff) { + BanTracker bt; + // Exactly window-old timestamps are pruned (cutoff is inclusive of <=). + bt.record_offense("1.2.3.4", kBase); + auto r = bt.record_offense("1.2.3.4", kBase + 24h); + EXPECT_EQ(r.count, 1); // the kBase entry was pruned before appending +} + +TEST(BanTracker, SweepRemovesFullyExpiredIp) { + BanTracker bt; + for (int i = 0; i < 4; ++i) { + bt.record_offense("1.2.3.4", kBase); + } + EXPECT_EQ(bt.tracked(), 1u); + bt.sweep(kBase + 24h + 1min); // all offenses aged out + EXPECT_EQ(bt.tracked(), 0u); +} + +TEST(BanTracker, SweepKeepsStillActiveIp) { + BanTracker bt; + for (int i = 0; i < 4; ++i) { + bt.record_offense("1.2.3.4", kBase); + } + bt.sweep(kBase + 1h); // still inside the window + EXPECT_EQ(bt.tracked(), 1u); + EXPECT_TRUE(bt.is_blocked("1.2.3.4", kBase + 1h)); +} + +TEST(BanTracker, RespectsCustomConfig) { + BanTracker bt(BanTracker::Config{/*threshold=*/1, /*window=*/1h}); + EXPECT_FALSE(bt.record_offense("9.9.9.9", kBase).blocked); // 1, not > 1 + EXPECT_TRUE(bt.record_offense("9.9.9.9", kBase).blocked); // 2 > 1 + EXPECT_TRUE(bt.is_blocked("9.9.9.9", kBase)); + EXPECT_FALSE(bt.is_blocked("9.9.9.9", kBase + 1h + 1min)); // window elapsed +} + +int main(int argc, char **argv) { + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +}