Add Rust port of the finger daemon
CI / Build and Test (gcc, g++, ubuntu-latest) (push) Failing after 31s
CI / Code Coverage (push) Skipped
Build and Publish Docker Image / build-and-test (push) Failing after 1m7s
Build and Publish Docker Image / build-and-push-image (push) Skipped
Build and Publish Docker Image / security-scan (push) Skipped

Tokio-based reimplementation in rust/, mirroring the C++ handler and
ban-tracker logic (directory-traversal checks, case-insensitive plan
lookup, rolling-window IP ban tracking, allowlist parsing) along with
its full test suite. Includes a matching multi-stage Dockerfile.
This commit is contained in:
waffle2k
2026-07-23 21:24:55 -07:00
parent da4fa18525
commit dcbcff98a6
8 changed files with 1222 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
target/
.git/
.gitignore
README.md
*.md
Dockerfile
.dockerignore
*~
+1
View File
@@ -0,0 +1 @@
/target
+152
View File
@@ -0,0 +1,152 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "finger"
version = "0.1.0"
dependencies = [
"tokio",
]
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "mio"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
dependencies = [
"libc",
"wasi",
"windows-sys",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "socket2"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "tokio"
version = "1.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
dependencies = [
"bytes",
"libc",
"mio",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"tokio-macros",
"windows-sys",
]
[[package]]
name = "tokio-macros"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "finger"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "finger"
path = "src/main.rs"
[dependencies]
tokio = { version = "1", features = ["rt", "macros", "net", "io-util", "time", "signal"] }
[profile.release]
lto = true
+42
View File
@@ -0,0 +1,42 @@
# Multi-stage build for the Rust finger service
# Build stage
FROM rust:slim AS builder
WORKDIR /app
COPY . .
RUN cargo build --release
RUN cargo test --release
# Runtime stage — match the C++ image's base so OS-level overhead (syscalls,
# libc) is comparable between the two for benchmarking.
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
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 --from=builder /app/target/release/finger /usr/local/bin/finger
RUN chmod +x /usr/local/bin/finger
RUN mkdir -p /var/finger/users && \
chown -R finger:finger /var/finger
USER finger
EXPOSE 79
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD nc -w 1 127.0.0.1 79 < /dev/null || exit 1
LABEL org.opencontainers.image.title="finger"
LABEL org.opencontainers.image.description="A silly finger service written in Rust"
LABEL org.opencontainers.image.source="https://github.com/waffle2k/finger"
LABEL org.opencontainers.image.licenses="MIT"
CMD ["finger"]
+368
View File
@@ -0,0 +1,368 @@
//! Tracks 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.
//!
//! Time is passed in by the caller as a `Duration` since an arbitrary,
//! caller-chosen reference point (in `main`, elapsed time since process
//! start) rather than read internally, so the logic stays deterministic and
//! unit-testable: tests pick a synthetic base far from zero so subtracting
//! the window never underflows, mirroring the reference C++ implementation's
//! use of an offset `steady_clock::time_point`.
use std::collections::{HashMap, HashSet, VecDeque};
use std::net::IpAddr;
use std::time::Duration;
pub type Time = Duration;
#[derive(Clone, Copy)]
pub struct Config {
pub threshold: i32,
pub window: Duration,
}
impl Default for Config {
fn default() -> Self {
Config {
threshold: 3,
window: Duration::from_secs(24 * 3600),
}
}
}
pub struct OffenseResult {
pub count: i32,
pub blocked: bool,
}
#[derive(Default)]
pub struct BanTracker {
cfg: Config,
offenders: HashMap<String, VecDeque<Time>>,
}
impl BanTracker {
pub fn new() -> Self {
Self::default()
}
#[allow(dead_code)]
pub fn with_config(cfg: Config) -> Self {
BanTracker {
cfg,
offenders: HashMap::new(),
}
}
/// True if `ip` currently has more than `threshold` offenses inside the
/// rolling window. Does not mutate state.
pub fn is_blocked(&self, ip: &str, now: Time) -> bool {
match self.offenders.get(ip) {
None => false,
Some(ts) => count_in_window(ts, now, self.cfg.window) > self.cfg.threshold,
}
}
/// 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.
pub fn record_offense(&mut self, ip: &str, now: Time) -> OffenseResult {
let ts = self.offenders.entry(ip.to_string()).or_default();
prune(ts, now, self.cfg.window);
ts.push_back(now);
let count = ts.len() as i32;
OffenseResult {
count,
blocked: count > self.cfg.threshold,
}
}
/// 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.
pub fn sweep(&mut self, now: Time) {
self.offenders.retain(|_, ts| {
prune(ts, now, self.cfg.window);
!ts.is_empty()
});
}
/// Number of tracked IPs (for introspection and tests).
#[allow(dead_code)]
pub fn tracked(&self) -> usize {
self.offenders.len()
}
#[allow(dead_code)]
pub fn config(&self) -> &Config {
&self.cfg
}
}
fn prune(ts: &mut VecDeque<Time>, now: Time, window: Duration) {
let cutoff = now.saturating_sub(window);
while let Some(&front) = ts.front() {
if front <= cutoff {
ts.pop_front();
} else {
break;
}
}
}
// Count timestamps that fall within (now - window, now]. The deque is kept
// in ascending order, so the in-window entries are always a suffix.
fn count_in_window(ts: &VecDeque<Time>, now: Time, window: Duration) -> i32 {
let cutoff = now.saturating_sub(window);
let mut count = 0;
for &t in ts.iter().rev() {
if t > cutoff {
count += 1;
} else {
break;
}
}
count
}
/// 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.
pub fn is_bannable_address(addr: IpAddr) -> bool {
match addr {
IpAddr::V4(v4) => {
if v4.is_loopback() || v4.is_unspecified() || v4.is_multicast() {
return false;
}
let a = u32::from(v4);
if (a & 0xFF00_0000) == 0x0A00_0000 {
return false; // 10.0.0.0/8
}
if (a & 0xFFF0_0000) == 0xAC10_0000 {
return false; // 172.16.0.0/12
}
if (a & 0xFFFF_0000) == 0xC0A8_0000 {
return false; // 192.168.0.0/16
}
if (a & 0xFFFF_0000) == 0xA9FE_0000 {
return false; // 169.254.0.0/16 link-local
}
if (a & 0xFFC0_0000) == 0x6440_0000 {
return false; // 100.64.0.0/10 CGNAT / Tailscale
}
true
}
IpAddr::V6(v6) => {
if v6.is_loopback() || v6.is_unspecified() || v6.is_multicast() {
return false;
}
let segments = v6.segments();
if (segments[0] & 0xffc0) == 0xfe80 {
return false; // fe80::/10 link-local
}
if ((segments[0] >> 8) as u8 & 0xFE) == 0xFC {
return false; // fc00::/7 unique-local
}
true
}
}
}
/// 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.
///
/// 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.
pub fn parse_ip_allowlist(csv: &str) -> HashSet<String> {
csv.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
// Work well away from zero so that subtracting the window never
// underflows and the base offset is unambiguous.
fn base() -> Time {
Duration::from_secs(1000 * 3600)
}
#[test]
fn unknown_ip_is_not_blocked() {
let bt = BanTracker::new();
assert!(!bt.is_blocked("1.2.3.4", base()));
}
#[test]
fn blocks_only_after_more_than_threshold() {
let mut bt = BanTracker::new(); // default threshold = 3, so block on the 4th failure
assert!(!bt.record_offense("1.2.3.4", base()).blocked); // 1
assert!(!bt.record_offense("1.2.3.4", base()).blocked); // 2
assert!(!bt.record_offense("1.2.3.4", base()).blocked); // 3
assert!(!bt.is_blocked("1.2.3.4", base()));
let r = bt.record_offense("1.2.3.4", base()); // 4
assert!(r.blocked);
assert_eq!(r.count, 4);
assert!(bt.is_blocked("1.2.3.4", base()));
}
#[test]
fn tracks_each_ip_independently() {
let mut bt = BanTracker::new();
for _ in 0..4 {
bt.record_offense("1.1.1.1", base());
}
assert!(bt.is_blocked("1.1.1.1", base()));
assert!(!bt.is_blocked("2.2.2.2", base()));
}
#[test]
fn offenses_age_out_of_rolling_window() {
let mut bt = BanTracker::new();
// Four failures spread over a couple of hours -> blocked.
for i in 0..4 {
bt.record_offense("1.2.3.4", base() + Duration::from_secs(i * 3600));
}
assert!(bt.is_blocked("1.2.3.4", base() + Duration::from_secs(3 * 3600)));
// 24h after the first failure, that one drops out of the window:
// only 3 remain, so the IP is no longer blocked.
assert!(!bt.is_blocked(
"1.2.3.4",
base() + Duration::from_secs(24 * 3600 + 60)
));
}
#[test]
fn window_boundary_is_exclusive_at_cutoff() {
let mut bt = BanTracker::new();
// Exactly window-old timestamps are pruned (cutoff is inclusive of <=).
bt.record_offense("1.2.3.4", base());
let r = bt.record_offense("1.2.3.4", base() + Duration::from_secs(24 * 3600));
assert_eq!(r.count, 1); // the base() entry was pruned before appending
}
#[test]
fn sweep_removes_fully_expired_ip() {
let mut bt = BanTracker::new();
for _ in 0..4 {
bt.record_offense("1.2.3.4", base());
}
assert_eq!(bt.tracked(), 1);
bt.sweep(base() + Duration::from_secs(24 * 3600 + 60)); // all offenses aged out
assert_eq!(bt.tracked(), 0);
}
#[test]
fn sweep_keeps_still_active_ip() {
let mut bt = BanTracker::new();
for _ in 0..4 {
bt.record_offense("1.2.3.4", base());
}
bt.sweep(base() + Duration::from_secs(3600)); // still inside the window
assert_eq!(bt.tracked(), 1);
assert!(bt.is_blocked("1.2.3.4", base() + Duration::from_secs(3600)));
}
#[test]
fn respects_custom_config() {
let mut bt = BanTracker::with_config(Config {
threshold: 1,
window: Duration::from_secs(3600),
});
assert!(!bt.record_offense("9.9.9.9", base()).blocked); // 1, not > 1
assert!(bt.record_offense("9.9.9.9", base()).blocked); // 2 > 1
assert!(bt.is_blocked("9.9.9.9", base()));
assert!(!bt.is_blocked("9.9.9.9", base() + Duration::from_secs(3600 + 60))); // window elapsed
}
fn bannable(ip: &str) -> bool {
is_bannable_address(ip.parse().unwrap())
}
#[test]
fn global_ipv4_is_bannable() {
assert!(bannable("8.8.8.8"));
assert!(bannable("192.184.167.198")); // a real scanner seen in the logs
assert!(bannable("1.2.3.4"));
}
#[test]
fn private_and_local_ipv4_are_not_bannable() {
assert!(!bannable("127.0.0.1")); // loopback
assert!(!bannable("10.1.2.3")); // 10/8
assert!(!bannable("172.20.0.1")); // Docker bridge gateway (172.16/12)
assert!(!bannable("172.31.255.1"));
assert!(!bannable("192.168.1.104")); // the finger jail's own LAN IP
assert!(!bannable("169.254.10.1")); // link-local
assert!(!bannable("224.0.0.1")); // multicast
}
#[test]
fn cgnat_range_is_not_bannable() {
assert!(!bannable("100.64.0.1")); // bottom of 100.64/10 (CGNAT/Tailscale)
assert!(!bannable("100.127.255.1")); // top of the range
assert!(bannable("100.63.255.1")); // just below the range -> public
assert!(bannable("100.128.0.1")); // just above the range -> public
}
#[test]
fn ipv6_classification() {
assert!(bannable("2001:4860:4860::8888")); // global
assert!(!bannable("::1")); // loopback
assert!(!bannable("fe80::1")); // link-local
assert!(!bannable("fc00::1")); // unique-local
assert!(!bannable("fd12:3456::1")); // unique-local
}
#[test]
fn allowlist_parses_comma_separated_trimmed_entries() {
let a = parse_ip_allowlist("147.182.255.203, 10.0.0.1 ,\t2a01:4f8:190:7447::2");
assert_eq!(a.len(), 3);
assert!(a.contains("147.182.255.203"));
assert!(a.contains("10.0.0.1"));
assert!(a.contains("2a01:4f8:190:7447::2"));
}
#[test]
fn allowlist_single_entry_no_commas() {
let a = parse_ip_allowlist("147.182.255.203");
assert_eq!(a.len(), 1);
assert!(a.contains("147.182.255.203"));
}
#[test]
fn allowlist_empty_and_blank_yield_empty_set() {
assert!(parse_ip_allowlist("").is_empty());
assert!(parse_ip_allowlist(" ").is_empty());
assert!(parse_ip_allowlist(",, ,\t,").is_empty()); // only separators/blanks
}
#[test]
fn allowlist_ignores_empty_entries_between_commas() {
let a = parse_ip_allowlist("8.8.8.8,,9.9.9.9,");
assert_eq!(a.len(), 2);
assert!(a.contains("8.8.8.8"));
assert!(a.contains("9.9.9.9"));
}
}
+440
View File
@@ -0,0 +1,440 @@
//! Resolves a finger username to a response: the contents of that user's
//! plan file if one exists and is readable, or the username echoed back
//! unchanged otherwise. Input is validated first so a request can never walk
//! outside the configured plan-file directory.
use std::path::{Path, PathBuf};
pub trait FilesystemWrapper {
fn exists(&self, path: &Path) -> bool;
fn read_file(&self, path: &Path) -> String;
}
pub struct RealFilesystemWrapper;
impl FilesystemWrapper for RealFilesystemWrapper {
fn exists(&self, path: &Path) -> bool {
path.exists()
}
fn read_file(&self, path: &Path) -> String {
let raw = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return String::new(),
};
if raw.is_empty() {
return String::new();
}
// Plan files are returned over the finger protocol, which expects
// CRLF line endings; normalise whatever the file used to LF-joined
// lines terminated by a single CRLF.
let body = raw.lines().collect::<Vec<_>>().join("\n");
format!("{body}\r\n")
}
}
pub const BASE_PATH: &str = "/var/finger/users/";
pub fn process(username: &str) -> String {
let fs = RealFilesystemWrapper;
process_with(username, &fs, Path::new(BASE_PATH))
}
pub fn process_with(username: &str, fs: &dyn FilesystemWrapper, basepath: &Path) -> String {
const TRAVERSAL_PATTERNS: [&str; 10] = [
"../",
"..\\",
"%2e%2e%2f",
"%2e%2e%5c",
"%2E%2E%2F",
"%2E%2E%5C",
"..%2f",
"..%5c",
"..%2F",
"..%5C",
];
if TRAVERSAL_PATTERNS.iter().any(|p| username.contains(p)) {
return "InvalidInput: Directory traversal detected in username\r\n".to_string();
}
if username.contains('/') {
return "InvalidInput: Path detected in username\r\n".to_string();
}
// 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. Lower-casing is ASCII-only to match
// the byte-wise ::tolower behavior of the reference implementation.
let lookup = username.to_ascii_lowercase();
let plan_path: PathBuf = basepath.join(lookup);
if !fs.exists(&plan_path) {
return username.to_string();
}
let content = fs.read_file(&plan_path);
if content.is_empty() {
return username.to_string();
}
content
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
#[test]
fn valid_username_simple() {
assert_eq!(process("john"), "john");
}
#[test]
fn valid_username_with_numbers() {
assert_eq!(process("user123"), "user123");
}
#[test]
fn valid_username_with_underscore() {
assert_eq!(process("user_name"), "user_name");
}
#[test]
fn valid_username_with_hyphen() {
assert_eq!(process("user-name"), "user-name");
}
#[test]
fn valid_username_empty_string() {
assert_eq!(process(""), "");
}
#[test]
fn directory_traversal_basic_dot_dot_slash() {
let result = process("user../file");
assert!(result.starts_with("InvalidInput:"));
assert!(result.contains("Directory traversal detected"));
}
#[test]
fn directory_traversal_basic_dot_dot_backslash() {
let result = process("user..\\file");
assert!(result.starts_with("InvalidInput:"));
assert!(result.contains("Directory traversal detected"));
}
#[test]
fn directory_traversal_url_encoded_lowercase_2e2e2f() {
let result = process("user%2e%2e%2ffile");
assert!(result.starts_with("InvalidInput:"));
assert!(result.contains("Directory traversal detected"));
}
#[test]
fn directory_traversal_url_encoded_lowercase_2e2e5c() {
let result = process("user%2e%2e%5cfile");
assert!(result.starts_with("InvalidInput:"));
assert!(result.contains("Directory traversal detected"));
}
#[test]
fn directory_traversal_url_encoded_uppercase_2e2e2f() {
let result = process("user%2E%2E%2Ffile");
assert!(result.starts_with("InvalidInput:"));
assert!(result.contains("Directory traversal detected"));
}
#[test]
fn directory_traversal_url_encoded_uppercase_2e2e5c() {
let result = process("user%2E%2E%5Cfile");
assert!(result.starts_with("InvalidInput:"));
assert!(result.contains("Directory traversal detected"));
}
#[test]
fn directory_traversal_mixed_dot_dot_2f() {
let result = process("user..%2ffile");
assert!(result.starts_with("InvalidInput:"));
assert!(result.contains("Directory traversal detected"));
}
#[test]
fn directory_traversal_mixed_dot_dot_5c() {
let result = process("user..%5cfile");
assert!(result.starts_with("InvalidInput:"));
assert!(result.contains("Directory traversal detected"));
}
#[test]
fn directory_traversal_mixed_dot_dot_2f_upper() {
let result = process("user..%2Ffile");
assert!(result.starts_with("InvalidInput:"));
assert!(result.contains("Directory traversal detected"));
}
#[test]
fn directory_traversal_mixed_dot_dot_5c_upper() {
let result = process("user..%5Cfile");
assert!(result.starts_with("InvalidInput:"));
assert!(result.contains("Directory traversal detected"));
}
#[test]
fn path_detection_forward_slash() {
let result = process("user/name");
assert!(result.starts_with("InvalidInput:"));
assert!(result.contains("Path detected"));
}
#[test]
fn path_detection_forward_slash_at_start() {
let result = process("/username");
assert!(result.starts_with("InvalidInput:"));
assert!(result.contains("Path detected"));
}
#[test]
fn path_detection_forward_slash_at_end() {
let result = process("username/");
assert!(result.starts_with("InvalidInput:"));
assert!(result.contains("Path detected"));
}
#[test]
fn single_dot() {
assert_eq!(process("."), ".");
}
#[test]
fn double_dot_without_slash() {
assert_eq!(process(".."), "..");
}
#[test]
fn contains_dot_but_not_traversal() {
assert_eq!(process("user.name"), "user.name");
}
#[test]
fn backslash_without_dots() {
assert_eq!(process("user\\name"), "user\\name");
}
// --- Fake-filesystem tests (mirrors test_handler_mock.cpp) ---
#[derive(Default)]
struct FakeFilesystem {
files: HashMap<PathBuf, String>,
}
impl FakeFilesystem {
fn with_file(path: impl Into<PathBuf>, content: impl Into<String>) -> Self {
let mut files = HashMap::new();
files.insert(path.into(), content.into());
FakeFilesystem { files }
}
}
impl FilesystemWrapper for FakeFilesystem {
fn exists(&self, path: &Path) -> bool {
self.files.contains_key(path)
}
fn read_file(&self, path: &Path) -> String {
self.files.get(path).cloned().unwrap_or_default()
}
}
#[test]
fn process_with_file_exists() {
let base = Path::new("/var/finger/users/");
let fs = FakeFilesystem::with_file(base.join("testuser"), "Mock file content\r\n");
assert_eq!(
process_with("testuser", &fs, base),
"Mock file content\r\n"
);
}
#[test]
fn process_with_file_not_found() {
let base = Path::new("/var/finger/users/");
let fs = FakeFilesystem::default();
assert_eq!(process_with("nonexistentuser", &fs, base), "nonexistentuser");
}
#[test]
fn process_with_empty_file() {
let base = Path::new("/var/finger/users/");
let fs = FakeFilesystem::with_file(base.join("emptyfileuser"), "");
assert_eq!(process_with("emptyfileuser", &fs, base), "emptyfileuser");
}
#[test]
fn process_lowercases_username_for_lookup() {
let base = Path::new("/var/finger/users/");
let fs = FakeFilesystem::with_file(base.join("pete"), "Just another hacker.\r\n");
assert_eq!(
process_with("Pete", &fs, base),
"Just another hacker.\r\n"
);
}
// --- Real-filesystem tests (mirrors test_handler_real_filesystem.cpp) ---
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
struct TempDir(PathBuf);
impl TempDir {
fn new() -> Self {
let n = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!(
"finger_rs_test_{}_{n}",
std::process::id()
));
std::fs::create_dir_all(&dir).unwrap();
TempDir(dir)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn write_file(dir: &Path, name: &str, content: &str) {
std::fs::write(dir.join(name), content).unwrap();
}
#[test]
fn real_fs_exists_with_real_file() {
let dir = TempDir::new();
write_file(dir.path(), "testuser", "Test content");
let fs = RealFilesystemWrapper;
assert!(fs.exists(&dir.path().join("testuser")));
}
#[test]
fn real_fs_exists_with_nonexistent_file() {
let dir = TempDir::new();
let fs = RealFilesystemWrapper;
assert!(!fs.exists(&dir.path().join("nonexistent")));
}
#[test]
fn real_fs_read_file_with_simple_content() {
let dir = TempDir::new();
write_file(dir.path(), "simple", "Hello, World!");
let fs = RealFilesystemWrapper;
assert_eq!(
fs.read_file(&dir.path().join("simple")),
"Hello, World!\r\n"
);
}
#[test]
fn real_fs_read_file_with_multiline_content() {
let dir = TempDir::new();
write_file(dir.path(), "multiline", "Line 1\nLine 2\nLine 3");
let fs = RealFilesystemWrapper;
assert_eq!(
fs.read_file(&dir.path().join("multiline")),
"Line 1\nLine 2\nLine 3\r\n"
);
}
#[test]
fn real_fs_read_file_with_empty_file() {
let dir = TempDir::new();
write_file(dir.path(), "empty", "");
let fs = RealFilesystemWrapper;
assert_eq!(fs.read_file(&dir.path().join("empty")), "");
}
#[test]
fn real_fs_read_file_nonexistent_file() {
let dir = TempDir::new();
let fs = RealFilesystemWrapper;
assert_eq!(fs.read_file(&dir.path().join("nonexistent")), "");
}
#[test]
fn process_with_existing_user_file() {
let dir = TempDir::new();
write_file(
dir.path(),
"johndoe",
"John Doe\nSoftware Engineer\nLoves Rust",
);
let fs = RealFilesystemWrapper;
assert_eq!(
process_with("johndoe", &fs, dir.path()),
"John Doe\nSoftware Engineer\nLoves Rust\r\n"
);
}
#[test]
fn process_with_nonexistent_user_file() {
let dir = TempDir::new();
let fs = RealFilesystemWrapper;
assert_eq!(process_with("nonexistentuser", &fs, dir.path()), "nonexistentuser");
}
#[test]
fn process_with_empty_user_file() {
let dir = TempDir::new();
write_file(dir.path(), "emptyuser", "");
let fs = RealFilesystemWrapper;
assert_eq!(process_with("emptyuser", &fs, dir.path()), "emptyuser");
}
#[test]
fn process_with_file_containing_only_newlines() {
let dir = TempDir::new();
write_file(dir.path(), "newlineuser", "\n\n\n");
let fs = RealFilesystemWrapper;
// Line-by-line reading means the trailing empty line after the final
// \n is not read as a separate line, resulting in "\n\n\r\n".
assert_eq!(process_with("newlineuser", &fs, dir.path()), "\n\n\r\n");
}
#[test]
fn process_directory_traversal_protection_with_real_fs() {
let dir = TempDir::new();
let result = process_with("../secret", &RealFilesystemWrapper, dir.path());
assert!(result.starts_with("InvalidInput:"));
assert!(result.contains("Directory traversal detected"));
}
#[test]
fn process_with_custom_base_path() {
let dir = TempDir::new();
let custom_base = dir.path().join("custom_users");
std::fs::create_dir_all(&custom_base).unwrap();
write_file(&custom_base, "customuser", "Custom base path user");
let fs = RealFilesystemWrapper;
assert_eq!(
process_with("customuser", &fs, &custom_base),
"Custom base path user\r\n"
);
}
#[test]
fn real_fs_read_file_with_special_characters() {
let dir = TempDir::new();
let content = "User with special chars: \u{e0}\u{e1}\u{e2}\u{e3}\u{e4}\u{e5}\u{e6}\u{e7}\u{e8}\u{e9}\u{ea}\u{eb}";
write_file(dir.path(), "specialuser", content);
let fs = RealFilesystemWrapper;
assert_eq!(
fs.read_file(&dir.path().join("specialuser")),
format!("{content}\r\n")
);
}
}
+197
View File
@@ -0,0 +1,197 @@
mod ban;
mod handler;
use ban::{BanTracker, is_bannable_address, parse_ip_allowlist};
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
/// True once a response actually came from a plan file, rather than being
/// the username echoed back unchanged or an InvalidInput rejection. A
/// "failure" (the negation) is timestamped against the client IP by the
/// caller; enough failures within the rolling window trips the ban.
fn is_plan_served(response: &str, username: &str) -> bool {
response != username && !response.starts_with("InvalidInput:")
}
fn trim_trailing_crlf(s: &mut String) {
while matches!(s.chars().last(), Some('\r') | Some('\n')) {
s.pop();
}
}
async fn handle_connection(
mut socket: TcpStream,
client_addr: String,
trackable: bool,
bans: Arc<Mutex<BanTracker>>,
start: Instant,
) {
let now = start.elapsed();
// 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.lock().unwrap().is_blocked(&client_addr, now) {
println!("finger drop from {client_addr}: blocked");
return;
}
let mut data = [0u8; 1024];
let bytes_read = match socket.read(&mut data).await {
// 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
// error.
Ok(0) | Err(_) => return,
Ok(n) => n,
};
let mut username = String::from_utf8_lossy(&data[..bytes_read]).into_owned();
trim_trailing_crlf(&mut username);
println!("finger request from {client_addr} for user '{username}'");
let response = handler::process(&username);
// A "failure" is simply any request that does not resolve to a readable
// plan file: an unknown user, rejected input, or non-finger junk. Each
// failure is timestamped against the client IP; once an IP exceeds the
// threshold within the rolling window, the is_blocked() check above
// starts dropping its connections. This also frustrates username
// guessing.
if !is_plan_served(&response, &username) {
if trackable {
let res = bans.lock().unwrap().record_offense(&client_addr, now);
let suffix = if res.blocked { " -- now blocked" } else { "" };
println!(
"finger miss from {client_addr} for '{username}' ({} failures in window){suffix}",
res.count
);
} else {
println!("finger miss from {client_addr} for '{username}' (not tracked)");
}
// Best-effort reply; ignore write errors (the client may have
// already gone away).
let _ = socket.write_all(b"No plan found\r\n").await;
return;
}
let _ = socket.write_all(response.as_bytes()).await;
}
async fn listener(
bans: Arc<Mutex<BanTracker>>,
allowlist: Arc<HashSet<String>>,
start: Instant,
) -> std::io::Result<()> {
let acceptor = TcpListener::bind(("0.0.0.0", 79)).await?;
loop {
let (socket, peer) = acceptor.accept().await?;
let client_addr = peer.ip().to_string();
// Allowlisted IPs (trusted aggregating front-ends like the
// finger-web proxy) are never tracked, so their bursts neither block
// them nor count as offenses.
let trackable = is_bannable_address(peer.ip()) && !allowlist.contains(&client_addr);
let bans = bans.clone();
tokio::spawn(handle_connection(socket, client_addr, trackable, bans, start));
}
}
// Periodically prune offense records that have aged out of the window so
// the tracker's memory stays bounded even for IPs that never reconnect.
async fn sweeper(bans: Arc<Mutex<BanTracker>>, start: Instant) {
let mut interval = tokio::time::interval(Duration::from_secs(600));
interval.tick().await; // first tick fires immediately; skip it
loop {
interval.tick().await;
bans.lock().unwrap().sweep(start.elapsed());
}
}
async fn wait_for_shutdown_signal() {
let ctrl_c = tokio::signal::ctrl_c();
#[cfg(unix)]
{
let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("failed to install SIGTERM handler");
tokio::select! {
_ = ctrl_c => {}
_ = term.recv() => {}
}
}
#[cfg(not(unix))]
{
let _ = ctrl_c.await;
}
}
#[tokio::main(flavor = "current_thread")]
async fn main() {
let start = Instant::now();
let bans = Arc::new(Mutex::new(BanTracker::new()));
let allow_env = std::env::var("FINGER_BAN_ALLOWLIST").unwrap_or_default();
let allowlist = Arc::new(parse_ip_allowlist(&allow_env));
for ip in allowlist.iter() {
println!("ban allowlist: {ip} (never tracked or blocked)");
}
tokio::spawn(sweeper(bans.clone(), start));
tokio::select! {
res = listener(bans, allowlist, start) => {
if let Err(e) = res {
println!("fatal exception: {e}");
}
}
_ = wait_for_shutdown_signal() => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plan_served_when_response_differs_and_not_invalid() {
assert!(is_plan_served("Out to lunch.\r\n", "pete"));
}
#[test]
fn plan_not_served_when_response_echoes_username() {
assert!(!is_plan_served("pete", "pete"));
}
#[test]
fn plan_not_served_on_invalid_input() {
assert!(!is_plan_served(
"InvalidInput: Path detected in username\r\n",
"user/name"
));
}
#[test]
fn trims_trailing_cr_and_lf() {
let mut s = String::from("pete\r\n");
trim_trailing_crlf(&mut s);
assert_eq!(s, "pete");
}
#[test]
fn trims_bare_lf_only() {
let mut s = String::from("pete\n");
trim_trailing_crlf(&mut s);
assert_eq!(s, "pete");
}
#[test]
fn leaves_string_without_trailing_crlf_untouched() {
let mut s = String::from("pete");
trim_trailing_crlf(&mut s);
assert_eq!(s, "pete");
}
}