Block abusive IPs after repeated failed plan lookups
Port 79 mostly attracts HTTP/SIP probes, TLS handshakes, and username guessers -- none of which resolve to a plan file. Treat any request that fails to read a plan as an "offense" and timestamp it against the source IP. Add BanTracker (ban.hpp/ban.cpp): a per-IP rolling-window offender list. When an IP has more than 3 offenses still inside a 24h window, its connections are dropped without being read or answered; timestamps older than the window are pruned so a blocked IP frees itself automatically. State is in-memory (single io_context thread, no locking); the clock is injected for testability. A periodic sweeper keeps the map bounded. Legitimate lookups that hit a real plan never count, which also frustrates username enumeration. Unit tests in test_ban.cpp.
This commit is contained in:
@@ -59,3 +59,13 @@ and execute `docker compose up -d`
|
|||||||
|
|
||||||
# Setting your status
|
# Setting your status
|
||||||
within the `./users` directory, create a file named after the user you wish to have a response. That's it!
|
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`).
|
||||||
|
|||||||
@@ -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<BanTracker::clock::time_point> &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<int>(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <chrono>
|
||||||
|
#include <cstddef>
|
||||||
|
#include <deque>
|
||||||
|
#include <string>
|
||||||
|
#include <unordered_map>
|
||||||
|
|
||||||
|
// 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<std::string, std::deque<clock::time_point>> offenders_;
|
||||||
|
};
|
||||||
@@ -6,9 +6,12 @@
|
|||||||
#include <boost/asio/io_context.hpp>
|
#include <boost/asio/io_context.hpp>
|
||||||
#include <boost/asio/ip/tcp.hpp>
|
#include <boost/asio/ip/tcp.hpp>
|
||||||
#include <boost/asio/signal_set.hpp>
|
#include <boost/asio/signal_set.hpp>
|
||||||
|
#include <boost/asio/steady_timer.hpp>
|
||||||
#include <boost/asio/write.hpp>
|
#include <boost/asio/write.hpp>
|
||||||
|
#include <chrono>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
|
|
||||||
|
#include "ban.hpp"
|
||||||
#include "handler.hpp"
|
#include "handler.hpp"
|
||||||
|
|
||||||
using boost::asio::awaitable;
|
using boost::asio::awaitable;
|
||||||
@@ -22,8 +25,18 @@ awaitable<std::string> dofinger(const std::string &username) {
|
|||||||
co_return process(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,
|
||||||
|
BanTracker &bans) {
|
||||||
try {
|
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];
|
char data[1024];
|
||||||
auto bytes_read =
|
auto bytes_read =
|
||||||
co_await socket.async_read_some(boost::asio::buffer(data), deferred);
|
co_await socket.async_read_some(boost::asio::buffer(data), deferred);
|
||||||
@@ -36,8 +49,19 @@ awaitable<void> echo(tcp::socket socket, std::string client_addr) {
|
|||||||
std::printf("finger request from %s for user '%s'\n",
|
std::printf("finger request from %s for user '%s'\n",
|
||||||
client_addr.c_str(), username.c_str());
|
client_addr.c_str(), username.c_str());
|
||||||
auto response = co_await dofinger(username);
|
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(
|
co_await async_write(
|
||||||
socket, boost::asio::buffer(std::string("No plan found\r\n")),
|
socket, boost::asio::buffer(std::string("No plan found\r\n")),
|
||||||
deferred);
|
deferred);
|
||||||
@@ -50,7 +74,7 @@ awaitable<void> echo(tcp::socket socket, std::string client_addr) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
awaitable<void> listener() {
|
awaitable<void> listener(BanTracker &bans) {
|
||||||
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 (;;) {
|
||||||
@@ -59,21 +83,34 @@ awaitable<void> listener() {
|
|||||||
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();
|
||||||
co_spawn(executor, echo(std::move(socket), std::move(client_addr)),
|
co_spawn(executor, echo(std::move(socket), std::move(client_addr), bans),
|
||||||
detached);
|
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<void> 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() {
|
int main() {
|
||||||
// Line-buffer stdout so docker logs / tail -f see entries in real time.
|
// Line-buffer stdout so docker logs / tail -f see entries in real time.
|
||||||
std::setvbuf(stdout, nullptr, _IOLBF, 0);
|
std::setvbuf(stdout, nullptr, _IOLBF, 0);
|
||||||
try {
|
try {
|
||||||
boost::asio::io_context io_context(1);
|
boost::asio::io_context io_context(1);
|
||||||
|
BanTracker bans;
|
||||||
|
|
||||||
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(), detached);
|
co_spawn(io_context, listener(bans), detached);
|
||||||
|
co_spawn(io_context, sweeper(bans), detached);
|
||||||
|
|
||||||
io_context.run();
|
io_context.run();
|
||||||
} catch (std::exception &e) {
|
} catch (std::exception &e) {
|
||||||
|
|||||||
+7
-1
@@ -14,7 +14,7 @@ gtest_dep = dependency('gtest', main : true, required : true)
|
|||||||
gmock_dep = dependency('gmock', main : true, required : true)
|
gmock_dep = dependency('gmock', main : true, required : true)
|
||||||
|
|
||||||
executable('finger',
|
executable('finger',
|
||||||
'main.cpp','handler.cpp',
|
'main.cpp','handler.cpp','ban.cpp',
|
||||||
dependencies : [boost_dep, threads_dep],
|
dependencies : [boost_dep, threads_dep],
|
||||||
install : true)
|
install : true)
|
||||||
|
|
||||||
@@ -33,7 +33,13 @@ test_real_fs_exe = executable('test_handler_real_filesystem',
|
|||||||
'test_handler_real_filesystem.cpp', 'handler.cpp',
|
'test_handler_real_filesystem.cpp', 'handler.cpp',
|
||||||
dependencies : [boost_dep, threads_dep, gtest_dep, gmock_dep])
|
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
|
# Register the tests
|
||||||
test('handler_tests', test_exe)
|
test('handler_tests', test_exe)
|
||||||
test('handler_mock_tests', test_mock_exe)
|
test('handler_mock_tests', test_mock_exe)
|
||||||
test('handler_real_filesystem_tests', test_real_fs_exe)
|
test('handler_real_filesystem_tests', test_real_fs_exe)
|
||||||
|
test('ban_tests', test_ban_exe)
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
#include "ban.hpp"
|
||||||
|
#include <gtest/gtest.h>
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user