Compare commits

..
10 Commits
Author SHA1 Message Date
pmb da4fa18525 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
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
2026-06-17 10:44:47 -07:00
pmb b1e7f5229b docs(docker): run as root under host networking to bind port 79
Host networking shares the host net namespace, so the host's
privileged-port rule applies and the image's non-root user cannot bind 79
-- the daemon fails to listen silently. Add user: "0:0" to the compose and
correct the earlier (wrong) claim that non-root bind still works. Note
setcap as the non-root alternative.
2026-06-15 16:54:47 -07:00
pmb 011f8c4838 Stop logging normal client disconnects as exceptions
Clients that connect and close without sending a request -- health checks
(nc ... < /dev/null), port scanners, reset connections -- made
async_read_some throw eof, which the catch block logged as
"echo exception: End of file [asio.misc:2 ...]", spamming the logs.

Read with as_tuple so the error comes back as an error_code instead of an
exception: on any read error just return quietly. Writes likewise use
as_tuple and ignore errors (best-effort reply). The try/catch remains only
as a backstop for genuinely unexpected exceptions.
2026-06-15 16:43:06 -07:00
pmb 54650af252 Only track bannable (globally-routable) source IPs
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.
2026-06-15 16:38:07 -07:00
pmb 946c2b9e01 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.
2026-06-15 16:23:58 -07:00
waffle2k 268ededc19 Make username lookup case-insensitive
finger [email protected] (or any mixed-case name) failed because the plan
path was built from the raw username while plan files are lower-case on
disk. Lower-case the requested name before resolving the plan path; the
original spelling is still echoed back when no plan file exists. Add a mock
test asserting Pete -> .../pete.
2026-06-15 15:47:55 -07:00
pmb 84fc383137 docs(freebsd): capture stdout to /var/log/fingerd.log via daemon -o
Without -o the daemon(8) wrapper redirects stdout to /dev/null, so
the printf-based logging is invisible on BSD.
2026-05-16 21:42:09 -07:00
pmb 35d0f21051 Revert syslog logging back to stdout
Stdout works in both contexts: docker logs captures it directly,
and on FreeBSD daemon(8) / the Bastille jail rc script can
redirect or pipe it to syslog as needed. Going through syslog
from inside the daemon required openlog/closelog and a libc
dependency that broke portability with no real benefit.
2026-05-16 21:32:42 -07:00
pmb 0d5414e03d fix: switch runtime stage to ubuntu to match builder glibc
The Alpine/musl runtime can't exec our glibc-linked binary —
'exec /usr/local/bin/finger: no such file or directory'. The
recent syslog change pulls in fortify-source _chk symbols and
fcntl64 that musl doesn't provide even with gcompat. Matching
the runtime base to the builder (ubuntu:24.04) makes the image
reliably runnable; ship netcat-openbsd for the healthcheck.
2026-05-16 21:23:57 -07:00
pmb 676d1700e1 fix: healthcheck — use IPv4 and busybox-compatible nc
The Alpine runtime stage uses BusyBox nc, which has no -z flag, and
'localhost' resolves to ::1 while finger only binds 0.0.0.0:79. Both
combined caused every healthcheck to fail (35k+ failing streak on
mammut). Use '-w 1 127.0.0.1 79 < /dev/null' instead.
2026-05-16 21:06:05 -07:00
12 changed files with 589 additions and 34 deletions
+68
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+10
View File
@@ -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`).
+105
View File
@@ -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;
}
}
}
+90
View File
@@ -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
View File
@@ -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
View File
@@ -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)) {
+89 -18
View File
@@ -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'",
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
View File
@@ -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
View File
@@ -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();
}
+15
View File
@@ -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::_;