Files
finger/rust/src/main.rs
T
waffle2k dcbcff98a6
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
Add Rust port of the finger daemon
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.
2026-07-23 21:24:55 -07:00

198 lines
6.4 KiB
Rust

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");
}
}