The ban logic is per source IP, so it only works where the daemon can see the real client. Behind Docker's default bridge networking every client is SNAT'd to the bridge gateway (a 172.16/12 address), so a single IP would stand in for the whole internet -- counting offenses against it would block everyone at once. Add is_bannable_address(): only globally-routable unicast addresses are tracked. Loopback, RFC1918 private, CGNAT (100.64/10), link-local, IPv6 unique-local, and multicast all return false. main.cpp decides trackability from the accepted endpoint and skips both the block check and offense recording for non-global sources. Net effect: banning works where the real IP is visible (FreeBSD jail via pf rdr; Docker with host networking) and is inert -- not catastrophic -- where it is not (Docker bridge). Document the Docker client-IP caveat: docker-compose.yml now defaults to host networking, with the rationale and alternatives in DOCKER.md.
130 lines
4.6 KiB
C++
130 lines
4.6 KiB
C++
#include <iostream>
|
|
|
|
#include <boost/asio/co_spawn.hpp>
|
|
#include <boost/asio/deferred.hpp>
|
|
#include <boost/asio/detached.hpp>
|
|
#include <boost/asio/io_context.hpp>
|
|
#include <boost/asio/ip/tcp.hpp>
|
|
#include <boost/asio/signal_set.hpp>
|
|
#include <boost/asio/steady_timer.hpp>
|
|
#include <boost/asio/write.hpp>
|
|
#include <chrono>
|
|
#include <cstdio>
|
|
|
|
#include "ban.hpp"
|
|
#include "handler.hpp"
|
|
|
|
using boost::asio::awaitable;
|
|
using boost::asio::co_spawn;
|
|
using boost::asio::deferred;
|
|
using boost::asio::detached;
|
|
using boost::asio::ip::tcp;
|
|
namespace this_coro = boost::asio::this_coro;
|
|
|
|
awaitable<std::string> dofinger(const std::string &username) {
|
|
co_return process(username);
|
|
}
|
|
|
|
awaitable<void> 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.
|
|
// 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;
|
|
}
|
|
|
|
char data[1024];
|
|
auto bytes_read =
|
|
co_await socket.async_read_some(boost::asio::buffer(data), deferred);
|
|
std::string username(data, bytes_read);
|
|
// Remove trailing \r\n characters
|
|
while (!username.empty() &&
|
|
(username.back() == '\r' || username.back() == '\n')) {
|
|
username.pop_back();
|
|
}
|
|
std::printf("finger request from %s for user '%s'\n",
|
|
client_addr.c_str(), username.c_str());
|
|
auto response = co_await dofinger(username);
|
|
|
|
// 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) {
|
|
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);
|
|
co_return;
|
|
}
|
|
co_await async_write(socket, boost::asio::buffer(response), deferred);
|
|
co_return;
|
|
} catch (std::exception &e) {
|
|
std::printf("echo exception: %s\n", e.what());
|
|
}
|
|
}
|
|
|
|
awaitable<void> listener(BanTracker &bans) {
|
|
auto executor = co_await this_coro::executor;
|
|
tcp::acceptor acceptor(executor, {tcp::v4(), 79});
|
|
for (;;) {
|
|
tcp::socket socket = co_await acceptor.async_accept(deferred);
|
|
boost::system::error_code ec;
|
|
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());
|
|
co_spawn(executor,
|
|
echo(std::move(socket), std::move(client_addr), trackable, 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<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() {
|
|
// 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(bans), detached);
|
|
co_spawn(io_context, sweeper(bans), detached);
|
|
|
|
io_context.run();
|
|
} catch (std::exception &e) {
|
|
std::printf("fatal exception: %s\n", e.what());
|
|
}
|
|
}
|