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
|
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
|
## Docker Architecture
|
||||||
|
|
||||||
### Multi-stage Build
|
### Multi-stage Build
|
||||||
|
|||||||
+13
-8
@@ -29,14 +29,19 @@ RUN meson compile -C builddir
|
|||||||
# Run tests to ensure quality
|
# Run tests to ensure quality
|
||||||
RUN meson test -C builddir
|
RUN meson test -C builddir
|
||||||
|
|
||||||
# Runtime stage - minimal Alpine Linux
|
# Runtime stage — match builder's glibc (Alpine/musl is incompatible
|
||||||
FROM alpine:latest
|
# with our dynamically linked binary, esp. fortify _chk symbols).
|
||||||
|
FROM ubuntu:24.04
|
||||||
|
|
||||||
# Install runtime dependencies (if any)
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
RUN apk add --no-cache \
|
|
||||||
libstdc++ \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
&& addgroup -g 1000 finger \
|
libstdc++6 \
|
||||||
&& adduser -D -s /bin/sh -u 1000 -G finger finger
|
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 the compiled binary from builder stage
|
||||||
COPY --from=builder /app/builddir/finger /usr/local/bin/finger
|
COPY --from=builder /app/builddir/finger /usr/local/bin/finger
|
||||||
@@ -56,7 +61,7 @@ EXPOSE 79
|
|||||||
|
|
||||||
# Add health check
|
# Add health check
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
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
|
# Set metadata labels
|
||||||
LABEL org.opencontainers.image.title="finger"
|
LABEL org.opencontainers.image.title="finger"
|
||||||
|
|||||||
+1
-1
@@ -51,7 +51,7 @@ Create `/usr/local/etc/rc.d/fingerd`:
|
|||||||
name="fingerd"
|
name="fingerd"
|
||||||
rcvar="fingerd_enable"
|
rcvar="fingerd_enable"
|
||||||
command="/usr/sbin/daemon"
|
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"
|
pidfile="/var/run/fingerd.pid"
|
||||||
# procname must be the full path so rc.subr can match it against ps output
|
# procname must be the full path so rc.subr can match it against ps output
|
||||||
procname="/usr/local/bin/finger"
|
procname="/usr/local/bin/finger"
|
||||||
|
|||||||
@@ -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,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:
|
services:
|
||||||
finger:
|
finger:
|
||||||
build: .
|
build: .
|
||||||
ports:
|
# IMPORTANT: host networking is what lets the daemon's abuse protection
|
||||||
- "79:79"
|
# 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:
|
volumes:
|
||||||
- ./users:/var/finger/users
|
- ./users:/var/finger/users
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "nc", "-z", "localhost", "79"]
|
test: ["CMD-SHELL", "nc -w 1 127.0.0.1 79 < /dev/null || exit 1"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 40s
|
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:
|
# finger:
|
||||||
# image: ghcr.io/waffle2k/finger:latest
|
# image: ghcr.io/waffle2k/finger:latest
|
||||||
# ports:
|
# ports:
|
||||||
|
|||||||
+11
-1
@@ -1,4 +1,6 @@
|
|||||||
#include "handler.hpp"
|
#include "handler.hpp"
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cctype>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include <string_view>
|
#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");
|
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
|
// 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
|
// Check if the plan file exists using the filesystem wrapper
|
||||||
if (!fs.exists(planPath)) {
|
if (!fs.exists(planPath)) {
|
||||||
|
|||||||
@@ -1,15 +1,21 @@
|
|||||||
#include <iostream>
|
#include <iostream>
|
||||||
|
|
||||||
|
#include <boost/asio/as_tuple.hpp>
|
||||||
#include <boost/asio/co_spawn.hpp>
|
#include <boost/asio/co_spawn.hpp>
|
||||||
#include <boost/asio/deferred.hpp>
|
#include <boost/asio/deferred.hpp>
|
||||||
#include <boost/asio/detached.hpp>
|
#include <boost/asio/detached.hpp>
|
||||||
#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 <syslog.h>
|
#include <cstdlib>
|
||||||
|
#include <string>
|
||||||
|
#include <unordered_set>
|
||||||
|
|
||||||
|
#include "ban.hpp"
|
||||||
#include "handler.hpp"
|
#include "handler.hpp"
|
||||||
|
|
||||||
using boost::asio::awaitable;
|
using boost::asio::awaitable;
|
||||||
@@ -23,35 +29,74 @@ 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, bool trackable,
|
||||||
|
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.
|
||||||
|
// 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];
|
char data[1024];
|
||||||
auto bytes_read =
|
auto [read_ec, bytes_read] = co_await socket.async_read_some(
|
||||||
co_await socket.async_read_some(boost::asio::buffer(data), deferred);
|
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);
|
std::string username(data, bytes_read);
|
||||||
// Remove trailing \r\n characters
|
// Remove trailing \r\n characters
|
||||||
while (!username.empty() &&
|
while (!username.empty() &&
|
||||||
(username.back() == '\r' || username.back() == '\n')) {
|
(username.back() == '\r' || username.back() == '\n')) {
|
||||||
username.pop_back();
|
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());
|
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
|
||||||
co_await async_write(
|
// plan file: an unknown user, rejected input, or non-finger junk. Each
|
||||||
socket, boost::asio::buffer(std::string("No plan found\r\n")),
|
// failure is timestamped against the client IP; once an IP exceeds the
|
||||||
deferred);
|
// 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_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;
|
co_return;
|
||||||
} catch (std::exception &e) {
|
} 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;
|
auto executor = co_await this_coro::executor;
|
||||||
tcp::acceptor acceptor(executor, {tcp::v4(), 79});
|
tcp::acceptor acceptor(executor, {tcp::v4(), 79});
|
||||||
for (;;) {
|
for (;;) {
|
||||||
@@ -60,24 +105,50 @@ 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)),
|
// 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);
|
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() {
|
||||||
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 {
|
try {
|
||||||
boost::asio::io_context io_context(1);
|
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);
|
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, allowlist), detached);
|
||||||
|
co_spawn(io_context, sweeper(bans), detached);
|
||||||
|
|
||||||
io_context.run();
|
io_context.run();
|
||||||
} catch (std::exception &e) {
|
} 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)
|
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)
|
||||||
|
|||||||
+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");
|
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 showing multiple expectations
|
||||||
TEST_F(ProcessMockTest, MultipleFileOperations) {
|
TEST_F(ProcessMockTest, MultipleFileOperations) {
|
||||||
using ::testing::_;
|
using ::testing::_;
|
||||||
|
|||||||
Reference in New Issue
Block a user