Compare commits

...
2 Commits
Author SHA1 Message Date
waffle2k c33066d33e Migrate CI/deploy pipeline from GitHub Actions to Gitea Actions
docker-build-push / build-push-deploy (push) Successful in 33s
Build/test/push now runs as .gitea/workflows/docker-build-push.yml,
pushing to gitea.blairhaus.net/pmb/finger and auto-deploying to mammut
and bsd on every push to main, since GitHub is no longer in use.
Removes the now-dead .github workflows, .codecov.yml, and their README
badges.
2026-07-23 21:56:11 -07:00
waffle2k dcbcff98a6 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.
2026-07-23 21:24:55 -07:00
15 changed files with 1281 additions and 470 deletions
-26
View File
@@ -1,26 +0,0 @@
coverage:
status:
project:
default:
target: 80%
threshold: 5%
patch:
default:
target: 80%
threshold: 5%
comment:
layout: "reach,diff,flags,tree"
behavior: default
require_changes: false
ignore:
- "test_*.cpp"
- "builddir/**/*"
- "**/*.hpp" # Header files typically don't need coverage
flags:
unittests:
paths:
- handler.cpp
- main.cpp
+3
View File
@@ -2,6 +2,9 @@
builddir/ builddir/
testbuild/ testbuild/
# Rust port (separate build, own Dockerfile)
rust/
# Git # Git
.git/ .git/
.gitignore .gitignore
+56
View File
@@ -0,0 +1,56 @@
name: docker-build-push
on:
push:
branches: [main]
workflow_dispatch:
jobs:
build-push-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# act_runner bind-mounts the admin host's real Docker socket into every
# job container (Docker-outside-of-Docker, not a nested daemon), so
# `docker` here talks straight to the host's daemon — no separate
# dockerd to start. The Dockerfile's builder stage runs `meson test`,
# so a failing test fails this build before anything gets pushed.
- name: Build, test, and push image
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
SHA="${{ github.sha }}"
IMAGE="gitea.blairhaus.net/pmb/finger"
docker build -t "$IMAGE:$SHA" -t "$IMAGE:latest" .
printf '%s' "$REGISTRY_TOKEN" | docker login gitea.blairhaus.net -u pmb --password-stdin
docker push "$IMAGE:$SHA"
docker push "$IMAGE:latest"
docker logout gitea.blairhaus.net
echo "pushed $IMAGE:$SHA and $IMAGE:latest"
# Deploy: hop into the admin host itself (job containers can only reach
# host.docker.internal directly), then from there reuse admin's own
# already-configured `ssh mammut`/`ssh bsd` aliases to reach the two
# real deploy targets.
- name: Deploy to mammut and bsd
env:
ADMIN_HOST_SSH_KEY: ${{ secrets.ADMIN_HOST_SSH_KEY }}
run: |
mkdir -p ~/.ssh
printf '%s\n' "$ADMIN_HOST_SSH_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H host.docker.internal >> ~/.ssh/known_hosts 2>/dev/null
scp -i ~/.ssh/id_ed25519 update-fingerd.sh [email protected]:/tmp/update-fingerd.sh
ssh -i ~/.ssh/id_ed25519 [email protected] bash -s <<'EOF'
set -euo pipefail
ssh mammut "cd ~/finger && docker compose pull && docker compose up -d" < /dev/null
ssh bsd 'sh -s' < /tmp/update-fingerd.sh
rm -f /tmp/update-fingerd.sh
EOF
echo "deployed to mammut + bsd"
-179
View File
@@ -1,179 +0,0 @@
# GitHub Actions CI/CD Setup
This repository includes a comprehensive GitHub Actions workflow for C++20 compilation, testing, and code coverage reporting.
## Features
### 🔧 Multi-Platform Build & Test
- **Ubuntu Latest**: Primary development target with GCC
- **macOS Latest**: Cross-platform compatibility with Clang
- **Windows Latest**: Broader compatibility with GCC via vcpkg
### 🧪 Comprehensive Testing
- Runs all Google Test unit tests
- Validates security features (directory traversal protection)
- Fails build on any test failures
- Uploads test logs as artifacts
### 📊 Code Coverage Reporting
- **Coverage Tool**: gcov + lcov for detailed coverage analysis
- **Integration**: Automatic upload to Codecov.io
- **Reports**: HTML coverage reports as downloadable artifacts
- **Thresholds**: Configurable coverage targets (default: 80%)
- **PR Comments**: Automatic coverage change reporting
## Workflow Structure
```
CI Workflow
├── Build & Test Matrix (Ubuntu, macOS, Windows)
│ ├── Install Dependencies (Boost, Google Test)
│ ├── Meson Setup & Configure
│ ├── Compile with C++20
│ ├── Run Unit Tests
│ └── Upload Test Artifacts
└── Coverage Analysis (Ubuntu only)
├── Build with Coverage Flags
├── Run Tests with Coverage Collection
├── Generate lcov Reports
├── Upload to Codecov
└── Generate HTML Reports
```
## Setup Instructions
### 1. Repository Setup
1. Push this repository to GitHub
2. Update the badge URLs in `README.md`:
```markdown
[![CI](https://github.com/YOUR_USERNAME/YOUR_REPO_NAME/workflows/CI/badge.svg)](https://github.com/YOUR_USERNAME/YOUR_REPO_NAME/actions)
[![codecov](https://codecov.io/gh/YOUR_USERNAME/YOUR_REPO_NAME/branch/main/graph/badge.svg)](https://codecov.io/gh/YOUR_USERNAME/YOUR_REPO_NAME)
```
### 2. Codecov Integration
1. Visit [codecov.io](https://codecov.io) and sign in with GitHub
2. Add your repository to Codecov
3. No additional setup required - the workflow handles token-free uploads
### 3. Branch Protection (Optional)
Configure branch protection rules in GitHub:
- Require status checks to pass before merging
- Require branches to be up to date before merging
- Include the "Build and Test" and "Code Coverage" checks
## Configuration Files
### `.github/workflows/ci.yml`
Main CI/CD workflow with:
- Multi-platform build matrix
- Dependency management for each OS
- Test execution and artifact collection
- Coverage analysis and reporting
### `.codecov.yml`
Codecov configuration with:
- Coverage targets (80% project, 80% patch)
- File exclusions (test files, build directories)
- PR comment formatting
- Coverage flags for different components
### `.gitignore` Updates
Added coverage-related file exclusions:
- `*.gcda`, `*.gcno`, `*.gcov` - Coverage data files
- `coverage/` - Coverage report directories
- `coverage*.info` - lcov report files
## Workflow Triggers
The CI workflow runs on:
- **Push** to `main` and `develop` branches
- **Pull Requests** targeting `main` and `develop` branches
## Artifacts Generated
### Test Results
- Test logs from all platforms
- Available for 90 days after workflow completion
### Coverage Reports
- HTML coverage reports (viewable in browser)
- lcov data files for further analysis
- Codecov integration for web-based viewing
## Coverage Analysis
The coverage analysis focuses on:
- `handler.cpp` - Core business logic
- Security validation functions
- File I/O operations
- Excludes test files and system headers
### Coverage Thresholds
- **Project Coverage**: 80% minimum
- **Patch Coverage**: 80% minimum for new code
- **Threshold**: 5% tolerance for coverage changes
## Troubleshooting
### Common Issues
1. **Dependency Installation Failures**
- Check if package names are correct for the target OS
- Verify vcpkg installation on Windows
2. **Coverage Upload Failures**
- Coverage uploads are set to non-blocking (`fail_ci_if_error: false`)
- Check Codecov repository configuration
3. **Test Failures**
- Review test logs in the workflow artifacts
- Ensure all tests pass locally before pushing
### Local Testing
Test the workflow components locally:
```bash
# Standard build and test
meson setup builddir
meson compile -C builddir
meson test -C builddir --verbose
# Coverage build and test
meson setup builddir-coverage -Db_coverage=true
meson compile -C builddir-coverage
meson test -C builddir-coverage
```
## Customization
### Adding New Platforms
Extend the build matrix in `.github/workflows/ci.yml`:
```yaml
matrix:
os: [ubuntu-latest, macos-latest, windows-latest, ubuntu-20.04]
```
### Changing Coverage Targets
Modify `.codecov.yml`:
```yaml
coverage:
status:
project:
default:
target: 90% # Increase to 90%
```
### Adding Static Analysis
Add steps to the workflow for tools like:
- cppcheck
- clang-tidy
- AddressSanitizer/UBSan (already partially enabled)
## Security Considerations
The workflow includes security best practices:
- Pinned action versions (`@v4`, `@v3`)
- No secrets required for basic functionality
- Minimal permissions for workflow execution
- Static linking to reduce runtime dependencies
-138
View File
@@ -1,138 +0,0 @@
name: CI
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
jobs:
build-and-test:
name: Build and Test
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest]
include:
- os: ubuntu-latest
cc: gcc
cxx: g++
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v4
with:
python-version: '3.x'
- name: Install Meson and Ninja
run: |
python -m pip install --upgrade pip
pip install meson ninja
- name: Install dependencies (Ubuntu)
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt-get install -y libboost-system-dev libgtest-dev libgmock-dev build-essential
- name: Setup build directory
run: |
meson setup builddir
env:
CC: ${{ matrix.cc }}
CXX: ${{ matrix.cxx }}
- name: Compile
run: |
meson compile -C builddir
- name: Run tests
run: |
meson test -C builddir --verbose
- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: test-results-${{ matrix.os }}
path: builddir/meson-logs/
coverage:
name: Code Coverage
runs-on: ubuntu-latest
needs: build-and-test
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v4
with:
python-version: '3.x'
- name: Install Meson and Ninja
run: |
python -m pip install --upgrade pip
pip install meson ninja
- name: Install dependencies and coverage tools
run: |
sudo apt-get update
sudo apt-get install -y libboost-system-dev libgtest-dev libgmock-dev build-essential lcov
- name: Setup build directory with coverage
run: |
meson setup builddir -Db_coverage=true
env:
CC: gcc
CXX: g++
- name: Compile with coverage
run: |
meson compile -C builddir
- name: Run tests with coverage
run: |
meson test -C builddir --verbose
- name: Generate coverage report
run: |
# Create coverage directory
mkdir -p coverage
# Capture coverage data
lcov --capture --directory builddir --output-file coverage/coverage.info --ignore-errors mismatch,mismatch,unused
# Remove system headers and test files from coverage
lcov --remove coverage/coverage.info '/usr/*' '*/test_*' '*/gtest/*' --output-file coverage/coverage_filtered.info --ignore-errors mismatch,mismatch,unused
# Generate HTML report
genhtml coverage/coverage_filtered.info --output-directory coverage/html
# Display coverage summary
lcov --summary coverage/coverage_filtered.info --ignore-errors mismatch,mismatch,unused
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v3
with:
file: coverage/coverage_filtered.info
flags: unittests
name: codecov-umbrella
fail_ci_if_error: false
- name: Upload coverage HTML report
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: coverage/html/
- name: Coverage Summary
run: |
echo "## Coverage Report" >> $GITHUB_STEP_SUMMARY
echo "Coverage data has been uploaded to Codecov and HTML report is available as an artifact." >> $GITHUB_STEP_SUMMARY
lcov --summary coverage/coverage_filtered.info >> $GITHUB_STEP_SUMMARY
-124
View File
@@ -1,124 +0,0 @@
name: Build and Publish Docker Image
on:
push:
branches: [ "main" ]
tags: [ 'v*.*.*' ]
pull_request:
branches: [ "main" ]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
build-essential \
meson \
ninja-build \
pkg-config \
libboost-all-dev \
libgtest-dev \
libgmock-dev
- name: Setup build directory
run: meson setup builddir --buildtype=release
- name: Build project
run: meson compile -C builddir
- name: Run tests
run: meson test -C builddir
- name: Upload test results
uses: actions/upload-artifact@v4
if: always()
with:
name: test-results
path: builddir/meson-logs/testlog.txt
build-and-push-image:
runs-on: ubuntu-latest
needs: build-and-test
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha,prefix={{branch}}-
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Generate artifact attestation
if: github.event_name != 'pull_request'
uses: actions/attest-build-provenance@v1
with:
subject-name: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
subject-digest: ${{ steps.build.outputs.digest }}
push-to-registry: true
security-scan:
runs-on: ubuntu-latest
needs: build-and-push-image
if: github.event_name != 'pull_request'
permissions:
contents: read
security-events: write
steps:
- name: Run Trivy vulnerability scanner
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
format: 'sarif'
output: 'trivy-results.sarif'
- name: Upload Trivy scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: 'trivy-results.sarif'
-3
View File
@@ -1,8 +1,5 @@
# finger # finger
[![CI](https://github.com/waffle2k/finger/workflows/CI/badge.svg)](https://github.com/waffle2k/finger/actions)
[![codecov](https://codecov.io/gh/waffle2k/finger/branch/main/graph/badge.svg)](https://codecov.io/gh/waffle2k/finger)
A silly finger service written in c++20 A silly finger service written in c++20
# Compiling: # Compiling:
+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");
}
}