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
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:
@@ -0,0 +1,8 @@
|
|||||||
|
target/
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
README.md
|
||||||
|
*.md
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
*~
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
Generated
+152
@@ -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",
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user