Compare commits
10
Commits
6d782366f3
...
da4fa18525
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da4fa18525 | ||
|
|
b1e7f5229b | ||
|
|
011f8c4838 | ||
|
|
54650af252 | ||
|
|
946c2b9e01 | ||
|
|
268ededc19 | ||
|
|
84fc383137 | ||
|
|
35d0f21051 | ||
|
|
0d5414e03d | ||
|
|
676d1700e1 |
@@ -64,6 +64,74 @@ docker run -d \
|
||||
ghcr.io/waffle2k/finger:latest
|
||||
```
|
||||
|
||||
## Abuse protection & client IPs (important)
|
||||
|
||||
The daemon bans source IPs that rack up repeated failed lookups (scanners, SIP/
|
||||
HTTP probes, username guessers) -- see the "Abuse protection" section in the
|
||||
main [README.md](README.md). That protection is **per source IP**, so it only
|
||||
works if the container can see the *real* client IP.
|
||||
|
||||
Under Docker's **default bridge networking this is not the case**: published
|
||||
ports are NAT'd so every external client arrives with the bridge gateway as its
|
||||
source (e.g. `172.20.0.1`). The daemon would see one IP for the entire internet.
|
||||
By design it treats private/RFC1918 addresses as untrackable, so rather than
|
||||
blocking everyone at once, banning simply becomes **inert** under bridge
|
||||
networking.
|
||||
|
||||
To make abuse protection actually work in Docker, give the container the real
|
||||
client IP. In order of preference:
|
||||
|
||||
1. **Host networking (recommended).** Add `network_mode: host` to the service
|
||||
(and drop the `ports:` mapping -- it's ignored). The daemon then binds the
|
||||
host's port 79 directly and sees real client IPs. This is what
|
||||
`docker-compose.yml` in this repo uses.
|
||||
|
||||
Note: under host networking the container shares the host network namespace,
|
||||
which uses the host's privileged-port rule -- so the image's non-root user
|
||||
(UID 1000) **cannot bind port 79** and the daemon fails to listen silently.
|
||||
Either run as root (`user: "0:0"`, as below) or `setcap
|
||||
cap_net_bind_service=+ep` on the binary in the image to keep it non-root.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
finger:
|
||||
image: ghcr.io/waffle2k/finger:latest
|
||||
network_mode: host
|
||||
user: "0:0" # bind privileged port 79 under host networking
|
||||
volumes:
|
||||
- ./users:/var/finger/users
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
2. **macvlan network.** Give the container its own IP on the LAN. More setup,
|
||||
but keeps the container off host networking.
|
||||
|
||||
3. **Disable the userland proxy host-wide** (`/etc/docker/daemon.json`:
|
||||
`{"userland-proxy": false}`, then restart dockerd). iptables DNAT then
|
||||
preserves the source IP on published ports. This is a host-wide change that
|
||||
restarts every container on the host -- avoid it on busy multi-service hosts.
|
||||
|
||||
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
|
||||
|
||||
+13
-8
@@ -29,14 +29,19 @@ RUN meson compile -C builddir
|
||||
# Run tests to ensure quality
|
||||
RUN meson test -C builddir
|
||||
|
||||
# Runtime stage - minimal Alpine Linux
|
||||
FROM alpine:latest
|
||||
# Runtime stage — match builder's glibc (Alpine/musl is incompatible
|
||||
# with our dynamically linked binary, esp. fortify _chk symbols).
|
||||
FROM ubuntu:24.04
|
||||
|
||||
# Install runtime dependencies (if any)
|
||||
RUN apk add --no-cache \
|
||||
libstdc++ \
|
||||
&& addgroup -g 1000 finger \
|
||||
&& adduser -D -s /bin/sh -u 1000 -G finger finger
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libstdc++6 \
|
||||
netcat-openbsd \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& (userdel -r ubuntu 2>/dev/null || true) \
|
||||
&& groupadd -g 1000 finger \
|
||||
&& useradd -m -u 1000 -g finger -s /bin/sh finger
|
||||
|
||||
# Copy the compiled binary from builder stage
|
||||
COPY --from=builder /app/builddir/finger /usr/local/bin/finger
|
||||
@@ -56,7 +61,7 @@ EXPOSE 79
|
||||
|
||||
# Add health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD nc -z localhost 79 || exit 1
|
||||
CMD nc -w 1 127.0.0.1 79 < /dev/null || exit 1
|
||||
|
||||
# Set metadata labels
|
||||
LABEL org.opencontainers.image.title="finger"
|
||||
|
||||
+1
-1
@@ -51,7 +51,7 @@ Create `/usr/local/etc/rc.d/fingerd`:
|
||||
name="fingerd"
|
||||
rcvar="fingerd_enable"
|
||||
command="/usr/sbin/daemon"
|
||||
command_args="-f -p /var/run/fingerd.pid /usr/local/bin/finger"
|
||||
command_args="-f -p /var/run/fingerd.pid -o /var/log/fingerd.log /usr/local/bin/finger"
|
||||
pidfile="/var/run/fingerd.pid"
|
||||
# procname must be the full path so rc.subr can match it against ps output
|
||||
procname="/usr/local/bin/finger"
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
#include "ban.hpp"
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
bool is_bannable_address(const boost::asio::ip::address &addr) {
|
||||
if (addr.is_loopback() || addr.is_unspecified() || addr.is_multicast()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (addr.is_v4()) {
|
||||
const std::uint32_t a = addr.to_v4().to_uint();
|
||||
if ((a & 0xFF000000u) == 0x0A000000u) return false; // 10.0.0.0/8
|
||||
if ((a & 0xFFF00000u) == 0xAC100000u) return false; // 172.16.0.0/12
|
||||
if ((a & 0xFFFF0000u) == 0xC0A80000u) return false; // 192.168.0.0/16
|
||||
if ((a & 0xFFFF0000u) == 0xA9FE0000u) return false; // 169.254.0.0/16 link-local
|
||||
if ((a & 0xFFC00000u) == 0x64400000u) return false; // 100.64.0.0/10 CGNAT / Tailscale
|
||||
return true;
|
||||
}
|
||||
|
||||
// IPv6: drop link-local (fe80::/10) and unique-local (fc00::/7).
|
||||
const auto v6 = addr.to_v6();
|
||||
if (v6.is_link_local()) {
|
||||
return false;
|
||||
}
|
||||
if ((v6.to_bytes()[0] & 0xFEu) == 0xFCu) {
|
||||
return false;
|
||||
}
|
||||
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 {
|
||||
// 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,90 @@
|
||||
#pragma once
|
||||
|
||||
#include <boost/asio/ip/address.hpp>
|
||||
#include <chrono>
|
||||
#include <cstddef>
|
||||
#include <deque>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <unordered_map>
|
||||
#include <unordered_set>
|
||||
|
||||
// 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_;
|
||||
};
|
||||
|
||||
// Whether a client address is meaningful to track and ban. Only globally
|
||||
// routable unicast addresses qualify. Loopback, RFC1918 private, CGNAT
|
||||
// (100.64/10), link-local, IPv6 unique-local, and multicast addresses all
|
||||
// return false.
|
||||
//
|
||||
// This matters because the daemon can only ban what it can see: behind Docker's
|
||||
// default bridge networking every external client is SNAT'd to the bridge
|
||||
// gateway (a 172.16/12 address), so banning per source IP would collapse all
|
||||
// clients into one and block everyone. Skipping non-global addresses makes
|
||||
// banning correct where the real client IP is visible (e.g. the FreeBSD jail,
|
||||
// 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<std::string> parse_ip_allowlist(std::string_view csv);
|
||||
+27
-4
@@ -3,19 +3,42 @@ version: '3.8'
|
||||
services:
|
||||
finger:
|
||||
build: .
|
||||
ports:
|
||||
- "79:79"
|
||||
# IMPORTANT: host networking is what lets the daemon's abuse protection
|
||||
# work. Under Docker's default bridge networking every external client is
|
||||
# SNAT'd to the bridge gateway (a 172.16/12 address), so the daemon sees a
|
||||
# single source IP for everyone -- the per-IP ban logic can't tell clients
|
||||
# apart and (by design) treats that private address as untrackable, leaving
|
||||
# banning inert. Host networking exposes the real client IP, so repeat
|
||||
# offenders actually get blocked.
|
||||
network_mode: host
|
||||
# Under host networking the container shares the host net namespace, which
|
||||
# uses the host's privileged-port rule -- so the image's non-root user
|
||||
# (UID 1000) cannot bind port 79 and the daemon fails to listen silently.
|
||||
# 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
|
||||
healthcheck:
|
||||
test: ["CMD", "nc", "-z", "localhost", "79"]
|
||||
test: ["CMD-SHELL", "nc -w 1 127.0.0.1 79 < /dev/null || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
# Example using published image instead of building locally
|
||||
# Bridge-networking alternative (quick local testing only). NOTE: with this
|
||||
# mode the daemon only ever sees the bridge gateway IP, so abuse protection
|
||||
# is effectively disabled. Prefer host networking above for any public-facing
|
||||
# deployment.
|
||||
# finger:
|
||||
# image: ghcr.io/waffle2k/finger:latest
|
||||
# ports:
|
||||
|
||||
+11
-1
@@ -1,4 +1,6 @@
|
||||
#include "handler.hpp"
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <string_view>
|
||||
@@ -65,8 +67,16 @@ std::string process(const std::string &username, const IFilesystemWrapper &fs,
|
||||
return std::string("InvalidInput: ") + e.what() + std::string("\r\n");
|
||||
}
|
||||
|
||||
// Plan-file lookup is case-insensitive: normalise the requested name to
|
||||
// lower-case so e.g. "Pete" resolves the on-disk "pete" plan. Plan filenames
|
||||
// are always lower-case; the original spelling is still echoed back below
|
||||
// when no plan exists.
|
||||
std::string lookup = username;
|
||||
std::transform(lookup.begin(), lookup.end(), lookup.begin(),
|
||||
[](unsigned char c) { return std::tolower(c); });
|
||||
|
||||
// Attempt to open the plan file (if any) and return the contents as a string
|
||||
std::filesystem::path planPath = basepath / username;
|
||||
std::filesystem::path planPath = basepath / lookup;
|
||||
|
||||
// Check if the plan file exists using the filesystem wrapper
|
||||
if (!fs.exists(planPath)) {
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
#include <iostream>
|
||||
|
||||
#include <boost/asio/as_tuple.hpp>
|
||||
#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 <syslog.h>
|
||||
#include <cstdlib>
|
||||
#include <string>
|
||||
#include <unordered_set>
|
||||
|
||||
#include "ban.hpp"
|
||||
#include "handler.hpp"
|
||||
|
||||
using boost::asio::awaitable;
|
||||
@@ -23,35 +29,74 @@ awaitable<std::string> dofinger(const std::string &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, 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);
|
||||
auto [read_ec, bytes_read] = co_await socket.async_read_some(
|
||||
boost::asio::buffer(data), boost::asio::as_tuple(deferred));
|
||||
if (read_ec) {
|
||||
// Client hung up before sending a request: health checks (which connect
|
||||
// and immediately close), port scanners, and reset connections all land
|
||||
// here. This is normal -- don't log it as an exception.
|
||||
co_return;
|
||||
}
|
||||
std::string username(data, bytes_read);
|
||||
// Remove trailing \r\n characters
|
||||
while (!username.empty() &&
|
||||
(username.back() == '\r' || username.back() == '\n')) {
|
||||
username.pop_back();
|
||||
}
|
||||
syslog(LOG_INFO, "finger request from %s for user '%s'",
|
||||
client_addr.c_str(), username.c_str());
|
||||
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
|
||||
co_await async_write(
|
||||
socket, boost::asio::buffer(std::string("No plan found\r\n")),
|
||||
deferred);
|
||||
|
||||
// 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());
|
||||
}
|
||||
// Best-effort reply; ignore write errors (the client may have already
|
||||
// gone away).
|
||||
co_await async_write(socket,
|
||||
boost::asio::buffer(std::string("No plan found\r\n")),
|
||||
boost::asio::as_tuple(deferred));
|
||||
co_return;
|
||||
}
|
||||
co_await async_write(socket, boost::asio::buffer(response), deferred);
|
||||
co_await async_write(socket, boost::asio::buffer(response),
|
||||
boost::asio::as_tuple(deferred));
|
||||
co_return;
|
||||
} catch (std::exception &e) {
|
||||
syslog(LOG_ERR, "echo exception: %s", e.what());
|
||||
std::printf("echo exception: %s\n", e.what());
|
||||
}
|
||||
}
|
||||
|
||||
awaitable<void> listener() {
|
||||
awaitable<void> listener(BanTracker &bans,
|
||||
const std::unordered_set<std::string> &allowlist) {
|
||||
auto executor = co_await this_coro::executor;
|
||||
tcp::acceptor acceptor(executor, {tcp::v4(), 79});
|
||||
for (;;) {
|
||||
@@ -60,24 +105,50 @@ awaitable<void> 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)),
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
// 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() {
|
||||
openlog("fingerd", LOG_PID, LOG_DAEMON);
|
||||
// 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;
|
||||
|
||||
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);
|
||||
signals.async_wait([&](auto, auto) { io_context.stop(); });
|
||||
|
||||
co_spawn(io_context, listener(), detached);
|
||||
co_spawn(io_context, listener(bans, allowlist), detached);
|
||||
co_spawn(io_context, sweeper(bans), detached);
|
||||
|
||||
io_context.run();
|
||||
} catch (std::exception &e) {
|
||||
syslog(LOG_ERR, "fatal exception: %s", e.what());
|
||||
std::printf("fatal exception: %s\n", e.what());
|
||||
}
|
||||
closelog();
|
||||
}
|
||||
|
||||
+7
-1
@@ -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)
|
||||
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
#include "ban.hpp"
|
||||
#include <boost/asio/ip/address.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
|
||||
}
|
||||
|
||||
static bool bannable(const char *ip) {
|
||||
return is_bannable_address(boost::asio::ip::make_address(ip));
|
||||
}
|
||||
|
||||
TEST(BannableAddress, GlobalIpv4IsBannable) {
|
||||
EXPECT_TRUE(bannable("8.8.8.8"));
|
||||
EXPECT_TRUE(bannable("192.184.167.198")); // a real scanner seen in the logs
|
||||
EXPECT_TRUE(bannable("1.2.3.4"));
|
||||
}
|
||||
|
||||
TEST(BannableAddress, PrivateAndLocalIpv4AreNotBannable) {
|
||||
EXPECT_FALSE(bannable("127.0.0.1")); // loopback
|
||||
EXPECT_FALSE(bannable("10.1.2.3")); // 10/8
|
||||
EXPECT_FALSE(bannable("172.20.0.1")); // Docker bridge gateway (172.16/12)
|
||||
EXPECT_FALSE(bannable("172.31.255.1"));
|
||||
EXPECT_FALSE(bannable("192.168.1.104")); // the finger jail's own LAN IP
|
||||
EXPECT_FALSE(bannable("169.254.10.1")); // link-local
|
||||
EXPECT_FALSE(bannable("224.0.0.1")); // multicast
|
||||
}
|
||||
|
||||
TEST(BannableAddress, CgnatRangeIsNotBannable) {
|
||||
EXPECT_FALSE(bannable("100.64.0.1")); // bottom of 100.64/10 (CGNAT/Tailscale)
|
||||
EXPECT_FALSE(bannable("100.127.255.1")); // top of the range
|
||||
EXPECT_TRUE(bannable("100.63.255.1")); // just below the range -> public
|
||||
EXPECT_TRUE(bannable("100.128.0.1")); // just above the range -> public
|
||||
}
|
||||
|
||||
TEST(BannableAddress, Ipv6Classification) {
|
||||
EXPECT_TRUE(bannable("2001:4860:4860::8888")); // global
|
||||
EXPECT_FALSE(bannable("::1")); // loopback
|
||||
EXPECT_FALSE(bannable("fe80::1")); // link-local
|
||||
EXPECT_FALSE(bannable("fc00::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) {
|
||||
::testing::InitGoogleTest(&argc, argv);
|
||||
return RUN_ALL_TESTS();
|
||||
}
|
||||
@@ -64,6 +64,21 @@ TEST_F(ProcessMockTest, ProcessWithEmptyFile) {
|
||||
EXPECT_EQ(result, "emptyfileuser");
|
||||
}
|
||||
|
||||
// Username lookup is case-insensitive: a mixed-case request is lowercased
|
||||
// before the plan-file path is built, so "Pete" reads .../pete.
|
||||
TEST_F(ProcessMockTest, ProcessLowercasesUsernameForLookup) {
|
||||
using ::testing::Return;
|
||||
const std::filesystem::path base{"/var/finger/users/"};
|
||||
|
||||
EXPECT_CALL(*mock_filesystem, exists(base / "pete"))
|
||||
.WillOnce(Return(true));
|
||||
EXPECT_CALL(*mock_filesystem, read_file(base / "pete"))
|
||||
.WillOnce(Return("Just another hacker.\r\n"));
|
||||
|
||||
std::string result = process("Pete", *mock_filesystem, base);
|
||||
EXPECT_EQ(result, "Just another hacker.\r\n");
|
||||
}
|
||||
|
||||
// Test showing multiple expectations
|
||||
TEST_F(ProcessMockTest, MultipleFileOperations) {
|
||||
using ::testing::_;
|
||||
|
||||
Reference in New Issue
Block a user