diff --git a/rust/.dockerignore b/rust/.dockerignore new file mode 100644 index 0000000..e0bafcc --- /dev/null +++ b/rust/.dockerignore @@ -0,0 +1,8 @@ +target/ +.git/ +.gitignore +README.md +*.md +Dockerfile +.dockerignore +*~ diff --git a/rust/.gitignore b/rust/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/rust/.gitignore @@ -0,0 +1 @@ +/target diff --git a/rust/Cargo.lock b/rust/Cargo.lock new file mode 100644 index 0000000..8ec16c6 --- /dev/null +++ b/rust/Cargo.lock @@ -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", +] diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 0000000..f5a4948 --- /dev/null +++ b/rust/Cargo.toml @@ -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 diff --git a/rust/Dockerfile b/rust/Dockerfile new file mode 100644 index 0000000..f680d0b --- /dev/null +++ b/rust/Dockerfile @@ -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"] diff --git a/rust/src/ban.rs b/rust/src/ban.rs new file mode 100644 index 0000000..3ba75e7 --- /dev/null +++ b/rust/src/ban.rs @@ -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>, +} + +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