Compare commits

..
12 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
pmb da4fa18525 Add FINGER_BAN_ALLOWLIST to exempt trusted front-end IPs from banning
CI / Build and Test (gcc, g++, ubuntu-latest) (push) Failing after 5m2s
CI / Code Coverage (push) Skipped
Build and Publish Docker Image / build-and-test (push) Failing after 6m13s
Build and Publish Docker Image / build-and-push-image (push) Skipped
Build and Publish Docker Image / security-scan (push) Skipped
The per-IP ban tracker treats every globally-routable client equally, but an
aggregating front-end like the finger-web proxy funnels the whole internet's
federated lookups through a single IP. A burst from any one client of the proxy
(or a load test) is then attributed to the proxy's IP and, once it crosses the
failure threshold, the daemon blocks the proxy — taking out finger lookups for
everyone. Per-client abuse protection for the proxied path belongs in the proxy
(which now rate-limits per real client IP), so the daemon should trust it.

Add a FINGER_BAN_ALLOWLIST env var (comma-separated IPs). Allowlisted addresses
are marked non-trackable in the listener, so their connections are never blocked
and never recorded as offenses. Unset = unchanged behaviour.

- parse_ip_allowlist() in ban.cpp (trims entries, skips blanks) + unit tests
- listener() consults the set when computing 'trackable'
- documented in docker-compose.yml and DOCKER.md
2026-06-17 10:44:47 -07:00
pmb b1e7f5229b docs(docker): run as root under host networking to bind port 79
Host networking shares the host net namespace, so the host's
privileged-port rule applies and the image's non-root user cannot bind 79
-- the daemon fails to listen silently. Add user: "0:0" to the compose and
correct the earlier (wrong) claim that non-root bind still works. Note
setcap as the non-root alternative.
2026-06-15 16:54:47 -07:00
pmb 011f8c4838 Stop logging normal client disconnects as exceptions
Clients that connect and close without sending a request -- health checks
(nc ... < /dev/null), port scanners, reset connections -- made
async_read_some throw eof, which the catch block logged as
"echo exception: End of file [asio.misc:2 ...]", spamming the logs.

Read with as_tuple so the error comes back as an error_code instead of an
exception: on any read error just return quietly. Writes likewise use
as_tuple and ignore errors (best-effort reply). The try/catch remains only
as a backstop for genuinely unexpected exceptions.
2026-06-15 16:43:06 -07:00
pmb 54650af252 Only track bannable (globally-routable) source IPs
The ban logic is per source IP, so it only works where the daemon can see
the real client. Behind Docker's default bridge networking every client is
SNAT'd to the bridge gateway (a 172.16/12 address), so a single IP would
stand in for the whole internet -- counting offenses against it would block
everyone at once.

Add is_bannable_address(): only globally-routable unicast addresses are
tracked. Loopback, RFC1918 private, CGNAT (100.64/10), link-local, IPv6
unique-local, and multicast all return false. main.cpp decides trackability
from the accepted endpoint and skips both the block check and offense
recording for non-global sources. Net effect: banning works where the real
IP is visible (FreeBSD jail via pf rdr; Docker with host networking) and is
inert -- not catastrophic -- where it is not (Docker bridge).

Document the Docker client-IP caveat: docker-compose.yml now defaults to
host networking, with the rationale and alternatives in DOCKER.md.
2026-06-15 16:38:07 -07:00
pmb 946c2b9e01 Block abusive IPs after repeated failed plan lookups
Port 79 mostly attracts HTTP/SIP probes, TLS handshakes, and username
guessers -- none of which resolve to a plan file. Treat any request that
fails to read a plan as an "offense" and timestamp it against the source
IP.

Add BanTracker (ban.hpp/ban.cpp): a per-IP rolling-window offender list.
When an IP has more than 3 offenses still inside a 24h window, its
connections are dropped without being read or answered; timestamps older
than the window are pruned so a blocked IP frees itself automatically.
State is in-memory (single io_context thread, no locking); the clock is
injected for testability. A periodic sweeper keeps the map bounded.

Legitimate lookups that hit a real plan never count, which also
frustrates username enumeration. Unit tests in test_ban.cpp.
2026-06-15 16:23:58 -07:00
waffle2k 268ededc19 Make username lookup case-insensitive
finger [email protected] (or any mixed-case name) failed because the plan
path was built from the raw username while plan files are lower-case on
disk. Lower-case the requested name before resolving the plan path; the
original spelling is still echoed back when no plan file exists. Add a mock
test asserting Pete -> .../pete.
2026-06-15 15:47:55 -07:00
pmb 84fc383137 docs(freebsd): capture stdout to /var/log/fingerd.log via daemon -o
Without -o the daemon(8) wrapper redirects stdout to /dev/null, so
the printf-based logging is invisible on BSD.
2026-05-16 21:42:09 -07:00
pmb 35d0f21051 Revert syslog logging back to stdout
Stdout works in both contexts: docker logs captures it directly,
and on FreeBSD daemon(8) / the Bastille jail rc script can
redirect or pipe it to syslog as needed. Going through syslog
from inside the daemon required openlog/closelog and a libc
dependency that broke portability with no real benefit.
2026-05-16 21:32:42 -07:00
pmb 0d5414e03d fix: switch runtime stage to ubuntu to match builder glibc
The Alpine/musl runtime can't exec our glibc-linked binary —
'exec /usr/local/bin/finger: no such file or directory'. The
recent syslog change pulls in fortify-source _chk symbols and
fcntl64 that musl doesn't provide even with gcompat. Matching
the runtime base to the builder (ubuntu:24.04) makes the image
reliably runnable; ship netcat-openbsd for the healthcheck.
2026-05-16 21:23:57 -07:00
pmb 676d1700e1 fix: healthcheck — use IPv4 and busybox-compatible nc
The Alpine runtime stage uses BusyBox nc, which has no -z flag, and
'localhost' resolves to ::1 while finger only binds 0.0.0.0:79. Both
combined caused every healthcheck to fail (35k+ failing streak on
mammut). Use '-w 1 127.0.0.1 79 < /dev/null' instead.
2026-05-16 21:06:05 -07:00
26 changed files with 1870 additions and 504 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/
testbuild/
# Rust port (separate build, own Dockerfile)
rust/
# Git
.git/
.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'
+68
View File
@@ -64,6 +64,74 @@ docker run -d \
ghcr.io/waffle2k/finger:latest
```
## Abuse protection & client IPs (important)
The daemon bans source IPs that rack up repeated failed lookups (scanners, SIP/
HTTP probes, username guessers) -- see the "Abuse protection" section in the
main [README.md](README.md). That protection is **per source IP**, so it only
works if the container can see the *real* client IP.
Under Docker's **default bridge networking this is not the case**: published
ports are NAT'd so every external client arrives with the bridge gateway as its
source (e.g. `172.20.0.1`). The daemon would see one IP for the entire internet.
By design it treats private/RFC1918 addresses as untrackable, so rather than
blocking everyone at once, banning simply becomes **inert** under bridge
networking.
To make abuse protection actually work in Docker, give the container the real
client IP. In order of preference:
1. **Host networking (recommended).** Add `network_mode: host` to the service
(and drop the `ports:` mapping -- it's ignored). The daemon then binds the
host's port 79 directly and sees real client IPs. This is what
`docker-compose.yml` in this repo uses.
Note: under host networking the container shares the host network namespace,
which uses the host's privileged-port rule -- so the image's non-root user
(UID 1000) **cannot bind port 79** and the daemon fails to listen silently.
Either run as root (`user: "0:0"`, as below) or `setcap
cap_net_bind_service=+ep` on the binary in the image to keep it non-root.
```yaml
services:
finger:
image: ghcr.io/waffle2k/finger:latest
network_mode: host
user: "0:0" # bind privileged port 79 under host networking
volumes:
- ./users:/var/finger/users
restart: unless-stopped
```
2. **macvlan network.** Give the container its own IP on the LAN. More setup,
but keeps the container off host networking.
3. **Disable the userland proxy host-wide** (`/etc/docker/daemon.json`:
`{"userland-proxy": false}`, then restart dockerd). iptables DNAT then
preserves the source IP on published ports. This is a host-wide change that
restarts every container on the host -- avoid it on busy multi-service hosts.
Note: bans are in-memory, so they reset when the container restarts -- the same
trade-off as any single-process deployment.
### Allowlisting a trusted front-end (`FINGER_BAN_ALLOWLIST`)
Set `FINGER_BAN_ALLOWLIST` to a comma-separated list of client IPs that should
never be tracked or banned. This is for trusted aggregating front-ends: the
[`finger-web`](https://github.com/waffle2k/finger-web) proxy, for example,
funnels every federated lookup through a single IP, so a burst from any one of
*its* clients would otherwise be attributed to the proxy and ban it for
everyone. Per-client abuse protection for that path lives in the proxy (it rate
limits per real client IP), so the daemon should trust the proxy IP:
```yaml
environment:
- FINGER_BAN_ALLOWLIST=203.0.113.10,2001:db8::10
```
Addresses are matched verbatim against the connecting socket's address, so use
canonical forms. Leave it unset for a directly-exposed daemon.
## Docker Architecture
### Multi-stage Build
+13 -8
View File
@@ -29,14 +29,19 @@ RUN meson compile -C builddir
# Run tests to ensure quality
RUN meson test -C builddir
# Runtime stage - minimal Alpine Linux
FROM alpine:latest
# Runtime stage match builder's glibc (Alpine/musl is incompatible
# with our dynamically linked binary, esp. fortify _chk symbols).
FROM ubuntu:24.04
# Install runtime dependencies (if any)
RUN apk add --no-cache \
libstdc++ \
&& addgroup -g 1000 finger \
&& adduser -D -s /bin/sh -u 1000 -G finger finger
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
libstdc++6 \
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 the compiled binary from builder stage
COPY --from=builder /app/builddir/finger /usr/local/bin/finger
@@ -56,7 +61,7 @@ EXPOSE 79
# Add health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD nc -z localhost 79 || exit 1
CMD nc -w 1 127.0.0.1 79 < /dev/null || exit 1
# Set metadata labels
LABEL org.opencontainers.image.title="finger"
+1 -1
View File
@@ -51,7 +51,7 @@ Create `/usr/local/etc/rc.d/fingerd`:
name="fingerd"
rcvar="fingerd_enable"
command="/usr/sbin/daemon"
command_args="-f -p /var/run/fingerd.pid /usr/local/bin/finger"
command_args="-f -p /var/run/fingerd.pid -o /var/log/fingerd.log /usr/local/bin/finger"
pidfile="/var/run/fingerd.pid"
# procname must be the full path so rc.subr can match it against ps output
procname="/usr/local/bin/finger"
+10 -3
View File
@@ -1,8 +1,5 @@
# 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
# Compiling:
@@ -59,3 +56,13 @@ and execute `docker compose up -d`
# Setting your status
within the `./users` directory, create a file named after the user you wish to have a response. That's it!
# Abuse protection
Most traffic on port 79 is not finger at all -- HTTP and SIP probes, TLS
handshakes, and username-guessing scanners. None of these resolve to a plan
file, so the daemon treats any request that fails to read a plan as an
"offense" and timestamps it against the source IP. When an IP records more than
3 failures within a rolling 24-hour window, its connections are dropped
(without being read or answered) until those failures age back out of the
window. Legitimate lookups that hit a real plan never count against an IP. All
state is in-memory; thresholds live in `BanTracker::Config` (`ban.hpp`).
+105
View File
@@ -0,0 +1,105 @@
#include "ban.hpp"
#include <cstdint>
#include <string>
bool is_bannable_address(const boost::asio::ip::address &addr) {
if (addr.is_loopback() || addr.is_unspecified() || addr.is_multicast()) {
return false;
}
if (addr.is_v4()) {
const std::uint32_t a = addr.to_v4().to_uint();
if ((a & 0xFF000000u) == 0x0A000000u) return false; // 10.0.0.0/8
if ((a & 0xFFF00000u) == 0xAC100000u) return false; // 172.16.0.0/12
if ((a & 0xFFFF0000u) == 0xC0A80000u) return false; // 192.168.0.0/16
if ((a & 0xFFFF0000u) == 0xA9FE0000u) return false; // 169.254.0.0/16 link-local
if ((a & 0xFFC00000u) == 0x64400000u) return false; // 100.64.0.0/10 CGNAT / Tailscale
return true;
}
// IPv6: drop link-local (fe80::/10) and unique-local (fc00::/7).
const auto v6 = addr.to_v6();
if (v6.is_link_local()) {
return false;
}
if ((v6.to_bytes()[0] & 0xFEu) == 0xFCu) {
return false;
}
return true;
}
std::unordered_set<std::string> parse_ip_allowlist(std::string_view csv) {
std::unordered_set<std::string> out;
std::size_t start = 0;
while (start <= csv.size()) {
const std::size_t comma = csv.find(',', start);
const std::size_t end =
(comma == std::string_view::npos) ? csv.size() : comma;
std::string_view tok = csv.substr(start, end - start);
const std::size_t a = tok.find_first_not_of(" \t\r\n");
if (a != std::string_view::npos) {
const std::size_t b = tok.find_last_not_of(" \t\r\n");
out.emplace(tok.substr(a, b - a + 1));
}
if (comma == std::string_view::npos) {
break;
}
start = comma + 1;
}
return out;
}
namespace {
// Count timestamps that fall within (now - window, now]. The deque is kept in
// ascending order, so the in-window entries are always a suffix.
int count_in_window(const std::deque<BanTracker::clock::time_point> &ts,
BanTracker::clock::time_point now,
BanTracker::clock::duration window) {
const auto cutoff = now - window;
int count = 0;
for (auto it = ts.rbegin(); it != ts.rend() && *it > cutoff; ++it) {
++count;
}
return count;
}
} // namespace
bool BanTracker::is_blocked(const std::string &ip, clock::time_point now) const {
auto it = offenders_.find(ip);
if (it == offenders_.end()) {
return false;
}
return count_in_window(it->second, now, cfg_.window) > cfg_.threshold;
}
BanTracker::OffenseResult
BanTracker::record_offense(const std::string &ip, clock::time_point now) {
auto &ts = offenders_[ip];
const auto cutoff = now - cfg_.window;
// Drop this IP's timestamps that have aged out of the window.
while (!ts.empty() && ts.front() <= cutoff) {
ts.pop_front();
}
ts.push_back(now);
const int count = static_cast<int>(ts.size());
return {count, count > cfg_.threshold};
}
void BanTracker::sweep(clock::time_point now) {
const auto cutoff = now - cfg_.window;
for (auto it = offenders_.begin(); it != offenders_.end();) {
auto &ts = it->second;
while (!ts.empty() && ts.front() <= cutoff) {
ts.pop_front();
}
if (ts.empty()) {
it = offenders_.erase(it);
} else {
++it;
}
}
}
+90
View File
@@ -0,0 +1,90 @@
#pragma once
#include <boost/asio/ip/address.hpp>
#include <chrono>
#include <cstddef>
#include <deque>
#include <string>
#include <string_view>
#include <unordered_map>
#include <unordered_set>
// BanTracker records 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.
//
// All state is in-memory: the daemon runs a single io_context thread, so every
// call happens on the same thread and no locking is required. Time is passed
// in as a steady_clock time_point rather than read internally, so the logic is
// deterministic and unit-testable.
class BanTracker {
public:
using clock = std::chrono::steady_clock;
struct Config {
int threshold = 3; // block when offenses exceed this
clock::duration window = std::chrono::hours(24); // rolling window length
};
struct OffenseResult {
int count; // offenses within the window, including this one
bool blocked; // true if the IP is now blocked (count > threshold)
};
BanTracker() = default;
explicit BanTracker(Config cfg) : cfg_(cfg) {}
// True if ip currently has more than `threshold` offenses inside the rolling
// window. Does not mutate state.
bool is_blocked(const std::string &ip, clock::time_point now) const;
// 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.
OffenseResult record_offense(const std::string &ip, clock::time_point now);
// 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.
void sweep(clock::time_point now);
// Number of tracked IPs (for introspection and tests).
std::size_t tracked() const { return offenders_.size(); }
const Config &config() const { return cfg_; }
private:
Config cfg_{};
// Per-IP offense timestamps, kept in ascending order (steady_clock is
// monotonic, so appends are always newest-last).
std::unordered_map<std::string, std::deque<clock::time_point>> offenders_;
};
// 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.
bool is_bannable_address(const boost::asio::ip::address &addr);
// 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. The strings are
// matched verbatim against boost::asio's address().to_string() output, so use
// canonical forms (e.g. "147.182.255.203", "2a01:4f8:190:7447::2").
//
// 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.
std::unordered_set<std::string> parse_ip_allowlist(std::string_view csv);
+27 -4
View File
@@ -3,19 +3,42 @@ version: '3.8'
services:
finger:
build: .
ports:
- "79:79"
# IMPORTANT: host networking is what lets the daemon's abuse protection
# work. Under Docker's default bridge networking every external client is
# SNAT'd to the bridge gateway (a 172.16/12 address), so the daemon sees a
# single source IP for everyone -- the per-IP ban logic can't tell clients
# apart and (by design) treats that private address as untrackable, leaving
# banning inert. Host networking exposes the real client IP, so repeat
# offenders actually get blocked.
network_mode: host
# Under host networking the container shares the host net namespace, which
# uses the host's privileged-port rule -- so the image's non-root user
# (UID 1000) cannot bind port 79 and the daemon fails to listen silently.
# Run as root to bind it. (Alternative: setcap cap_net_bind_service on the
# binary in the image to keep it non-root.)
user: "0:0"
# FINGER_BAN_ALLOWLIST: comma-separated client IPs that are never tracked or
# banned. Use it for trusted aggregating front-ends — e.g. the finger-web
# proxy, which funnels every federated lookup through one IP; without an
# allowlist a burst from any single client of the proxy is attributed to the
# proxy and bans it for everyone (per-client abuse protection for that path
# lives in the proxy). Leave unset for a directly-exposed daemon.
# environment:
# - FINGER_BAN_ALLOWLIST=203.0.113.10,2001:db8::10
volumes:
- ./users:/var/finger/users
restart: unless-stopped
healthcheck:
test: ["CMD", "nc", "-z", "localhost", "79"]
test: ["CMD-SHELL", "nc -w 1 127.0.0.1 79 < /dev/null || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
# Example using published image instead of building locally
# Bridge-networking alternative (quick local testing only). NOTE: with this
# mode the daemon only ever sees the bridge gateway IP, so abuse protection
# is effectively disabled. Prefer host networking above for any public-facing
# deployment.
# finger:
# image: ghcr.io/waffle2k/finger:latest
# ports:
+11 -1
View File
@@ -1,4 +1,6 @@
#include "handler.hpp"
#include <algorithm>
#include <cctype>
#include <filesystem>
#include <fstream>
#include <string_view>
@@ -65,8 +67,16 @@ std::string process(const std::string &username, const IFilesystemWrapper &fs,
return std::string("InvalidInput: ") + e.what() + std::string("\r\n");
}
// 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.
std::string lookup = username;
std::transform(lookup.begin(), lookup.end(), lookup.begin(),
[](unsigned char c) { return std::tolower(c); });
// Attempt to open the plan file (if any) and return the contents as a string
std::filesystem::path planPath = basepath / username;
std::filesystem::path planPath = basepath / lookup;
// Check if the plan file exists using the filesystem wrapper
if (!fs.exists(planPath)) {
+90 -19
View File
@@ -1,15 +1,21 @@
#include <iostream>
#include <boost/asio/as_tuple.hpp>
#include <boost/asio/co_spawn.hpp>
#include <boost/asio/deferred.hpp>
#include <boost/asio/detached.hpp>
#include <boost/asio/io_context.hpp>
#include <boost/asio/ip/tcp.hpp>
#include <boost/asio/signal_set.hpp>
#include <boost/asio/steady_timer.hpp>
#include <boost/asio/write.hpp>
#include <chrono>
#include <cstdio>
#include <syslog.h>
#include <cstdlib>
#include <string>
#include <unordered_set>
#include "ban.hpp"
#include "handler.hpp"
using boost::asio::awaitable;
@@ -23,35 +29,74 @@ awaitable<std::string> dofinger(const std::string &username) {
co_return process(username);
}
awaitable<void> echo(tcp::socket socket, std::string client_addr) {
awaitable<void> echo(tcp::socket socket, std::string client_addr, bool trackable,
BanTracker &bans) {
try {
auto now = std::chrono::steady_clock::now();
// 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.is_blocked(client_addr, now)) {
std::printf("finger drop from %s: blocked\n", client_addr.c_str());
co_return;
}
char data[1024];
auto bytes_read =
co_await socket.async_read_some(boost::asio::buffer(data), deferred);
auto [read_ec, bytes_read] = co_await socket.async_read_some(
boost::asio::buffer(data), boost::asio::as_tuple(deferred));
if (read_ec) {
// 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 exception.
co_return;
}
std::string username(data, bytes_read);
// Remove trailing \r\n characters
while (!username.empty() &&
(username.back() == '\r' || username.back() == '\n')) {
username.pop_back();
}
syslog(LOG_INFO, "finger request from %s for user '%s'",
client_addr.c_str(), username.c_str());
std::printf("finger request from %s for user '%s'\n",
client_addr.c_str(), username.c_str());
auto response = co_await dofinger(username);
if (response.compare(std::string(username)) == 0) {
// No plan found
co_await async_write(
socket, boost::asio::buffer(std::string("No plan found\r\n")),
deferred);
// 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.
bool plan_served =
response != username && response.rfind("InvalidInput:", 0) != 0;
if (!plan_served) {
if (trackable) {
auto res = bans.record_offense(client_addr, now);
std::printf("finger miss from %s for '%s' (%d failures in window)%s\n",
client_addr.c_str(), username.c_str(), res.count,
res.blocked ? " -- now blocked" : "");
} else {
std::printf("finger miss from %s for '%s' (not tracked)\n",
client_addr.c_str(), username.c_str());
}
// Best-effort reply; ignore write errors (the client may have already
// gone away).
co_await async_write(socket,
boost::asio::buffer(std::string("No plan found\r\n")),
boost::asio::as_tuple(deferred));
co_return;
}
co_await async_write(socket, boost::asio::buffer(response), deferred);
co_await async_write(socket, boost::asio::buffer(response),
boost::asio::as_tuple(deferred));
co_return;
} catch (std::exception &e) {
syslog(LOG_ERR, "echo exception: %s", e.what());
std::printf("echo exception: %s\n", e.what());
}
}
awaitable<void> listener() {
awaitable<void> listener(BanTracker &bans,
const std::unordered_set<std::string> &allowlist) {
auto executor = co_await this_coro::executor;
tcp::acceptor acceptor(executor, {tcp::v4(), 79});
for (;;) {
@@ -60,24 +105,50 @@ awaitable<void> listener() {
auto endpoint = socket.remote_endpoint(ec);
std::string client_addr =
ec ? std::string("unknown") : endpoint.address().to_string();
co_spawn(executor, echo(std::move(socket), std::move(client_addr)),
// Allowlisted IPs (trusted aggregating front-ends like the finger-web
// proxy) are never tracked, so their bursts neither block them nor count
// as offenses.
bool trackable = !ec && is_bannable_address(endpoint.address()) &&
allowlist.find(client_addr) == allowlist.end();
co_spawn(executor,
echo(std::move(socket), std::move(client_addr), trackable, bans),
detached);
}
}
// Periodically prune offense records that have aged out of the window so the
// tracker's memory stays bounded even for IPs that never reconnect.
awaitable<void> sweeper(BanTracker &bans) {
boost::asio::steady_timer timer(co_await this_coro::executor);
for (;;) {
timer.expires_after(std::chrono::minutes(10));
co_await timer.async_wait(deferred);
bans.sweep(std::chrono::steady_clock::now());
}
}
int main() {
openlog("fingerd", LOG_PID, LOG_DAEMON);
// Line-buffer stdout so docker logs / tail -f see entries in real time.
std::setvbuf(stdout, nullptr, _IOLBF, 0);
try {
boost::asio::io_context io_context(1);
BanTracker bans;
const char *allow_env = std::getenv("FINGER_BAN_ALLOWLIST");
const std::unordered_set<std::string> allowlist =
parse_ip_allowlist(allow_env ? allow_env : "");
for (const auto &ip : allowlist) {
std::printf("ban allowlist: %s (never tracked or blocked)\n", ip.c_str());
}
boost::asio::signal_set signals(io_context, SIGINT, SIGTERM);
signals.async_wait([&](auto, auto) { io_context.stop(); });
co_spawn(io_context, listener(), detached);
co_spawn(io_context, listener(bans, allowlist), detached);
co_spawn(io_context, sweeper(bans), detached);
io_context.run();
} catch (std::exception &e) {
syslog(LOG_ERR, "fatal exception: %s", e.what());
std::printf("fatal exception: %s\n", e.what());
}
closelog();
}
+7 -1
View File
@@ -14,7 +14,7 @@ gtest_dep = dependency('gtest', main : true, required : true)
gmock_dep = dependency('gmock', main : true, required : true)
executable('finger',
'main.cpp','handler.cpp',
'main.cpp','handler.cpp','ban.cpp',
dependencies : [boost_dep, threads_dep],
install : true)
@@ -33,7 +33,13 @@ test_real_fs_exe = executable('test_handler_real_filesystem',
'test_handler_real_filesystem.cpp', 'handler.cpp',
dependencies : [boost_dep, threads_dep, gtest_dep, gmock_dep])
# Ban tracker test executable
test_ban_exe = executable('test_ban',
'test_ban.cpp', 'ban.cpp',
dependencies : [boost_dep, threads_dep, gtest_dep, gmock_dep])
# Register the tests
test('handler_tests', test_exe)
test('handler_mock_tests', test_mock_exe)
test('handler_real_filesystem_tests', test_real_fs_exe)
test('ban_tests', test_ban_exe)
+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");
}
}
+152
View File
@@ -0,0 +1,152 @@
#include "ban.hpp"
#include <boost/asio/ip/address.hpp>
#include <gtest/gtest.h>
using namespace std::chrono_literals;
using clock_t_ = BanTracker::clock;
// Work well away from the steady_clock epoch so that subtracting the window
// never underflows and default-constructed time_points are unambiguous.
static const clock_t_::time_point kBase = clock_t_::time_point{} + 1000h;
TEST(BanTracker, UnknownIpIsNotBlocked) {
BanTracker bt;
EXPECT_FALSE(bt.is_blocked("1.2.3.4", kBase));
}
TEST(BanTracker, BlocksOnlyAfterMoreThanThreshold) {
BanTracker bt; // default threshold = 3, so block on the 4th failure
EXPECT_FALSE(bt.record_offense("1.2.3.4", kBase).blocked); // 1
EXPECT_FALSE(bt.record_offense("1.2.3.4", kBase).blocked); // 2
EXPECT_FALSE(bt.record_offense("1.2.3.4", kBase).blocked); // 3
EXPECT_FALSE(bt.is_blocked("1.2.3.4", kBase));
auto r = bt.record_offense("1.2.3.4", kBase); // 4
EXPECT_TRUE(r.blocked);
EXPECT_EQ(r.count, 4);
EXPECT_TRUE(bt.is_blocked("1.2.3.4", kBase));
}
TEST(BanTracker, TracksEachIpIndependently) {
BanTracker bt;
for (int i = 0; i < 4; ++i) {
bt.record_offense("1.1.1.1", kBase);
}
EXPECT_TRUE(bt.is_blocked("1.1.1.1", kBase));
EXPECT_FALSE(bt.is_blocked("2.2.2.2", kBase));
}
TEST(BanTracker, OffensesAgeOutOfRollingWindow) {
BanTracker bt;
// Four failures spread over a couple of hours -> blocked.
for (int i = 0; i < 4; ++i) {
bt.record_offense("1.2.3.4", kBase + i * 1h);
}
EXPECT_TRUE(bt.is_blocked("1.2.3.4", kBase + 3h));
// 24h after the first failure, that one drops out of the window: only 3
// remain, so the IP is no longer blocked.
EXPECT_FALSE(bt.is_blocked("1.2.3.4", kBase + 24h + 1min));
}
TEST(BanTracker, WindowBoundaryIsExclusiveAtCutoff) {
BanTracker bt;
// Exactly window-old timestamps are pruned (cutoff is inclusive of <=).
bt.record_offense("1.2.3.4", kBase);
auto r = bt.record_offense("1.2.3.4", kBase + 24h);
EXPECT_EQ(r.count, 1); // the kBase entry was pruned before appending
}
TEST(BanTracker, SweepRemovesFullyExpiredIp) {
BanTracker bt;
for (int i = 0; i < 4; ++i) {
bt.record_offense("1.2.3.4", kBase);
}
EXPECT_EQ(bt.tracked(), 1u);
bt.sweep(kBase + 24h + 1min); // all offenses aged out
EXPECT_EQ(bt.tracked(), 0u);
}
TEST(BanTracker, SweepKeepsStillActiveIp) {
BanTracker bt;
for (int i = 0; i < 4; ++i) {
bt.record_offense("1.2.3.4", kBase);
}
bt.sweep(kBase + 1h); // still inside the window
EXPECT_EQ(bt.tracked(), 1u);
EXPECT_TRUE(bt.is_blocked("1.2.3.4", kBase + 1h));
}
TEST(BanTracker, RespectsCustomConfig) {
BanTracker bt(BanTracker::Config{/*threshold=*/1, /*window=*/1h});
EXPECT_FALSE(bt.record_offense("9.9.9.9", kBase).blocked); // 1, not > 1
EXPECT_TRUE(bt.record_offense("9.9.9.9", kBase).blocked); // 2 > 1
EXPECT_TRUE(bt.is_blocked("9.9.9.9", kBase));
EXPECT_FALSE(bt.is_blocked("9.9.9.9", kBase + 1h + 1min)); // window elapsed
}
static bool bannable(const char *ip) {
return is_bannable_address(boost::asio::ip::make_address(ip));
}
TEST(BannableAddress, GlobalIpv4IsBannable) {
EXPECT_TRUE(bannable("8.8.8.8"));
EXPECT_TRUE(bannable("192.184.167.198")); // a real scanner seen in the logs
EXPECT_TRUE(bannable("1.2.3.4"));
}
TEST(BannableAddress, PrivateAndLocalIpv4AreNotBannable) {
EXPECT_FALSE(bannable("127.0.0.1")); // loopback
EXPECT_FALSE(bannable("10.1.2.3")); // 10/8
EXPECT_FALSE(bannable("172.20.0.1")); // Docker bridge gateway (172.16/12)
EXPECT_FALSE(bannable("172.31.255.1"));
EXPECT_FALSE(bannable("192.168.1.104")); // the finger jail's own LAN IP
EXPECT_FALSE(bannable("169.254.10.1")); // link-local
EXPECT_FALSE(bannable("224.0.0.1")); // multicast
}
TEST(BannableAddress, CgnatRangeIsNotBannable) {
EXPECT_FALSE(bannable("100.64.0.1")); // bottom of 100.64/10 (CGNAT/Tailscale)
EXPECT_FALSE(bannable("100.127.255.1")); // top of the range
EXPECT_TRUE(bannable("100.63.255.1")); // just below the range -> public
EXPECT_TRUE(bannable("100.128.0.1")); // just above the range -> public
}
TEST(BannableAddress, Ipv6Classification) {
EXPECT_TRUE(bannable("2001:4860:4860::8888")); // global
EXPECT_FALSE(bannable("::1")); // loopback
EXPECT_FALSE(bannable("fe80::1")); // link-local
EXPECT_FALSE(bannable("fc00::1")); // unique-local
EXPECT_FALSE(bannable("fd12:3456::1")); // unique-local
}
TEST(IpAllowlist, ParsesCommaSeparatedTrimmedEntries) {
auto a = parse_ip_allowlist("147.182.255.203, 10.0.0.1 ,\t2a01:4f8:190:7447::2");
EXPECT_EQ(a.size(), 3u);
EXPECT_TRUE(a.count("147.182.255.203"));
EXPECT_TRUE(a.count("10.0.0.1"));
EXPECT_TRUE(a.count("2a01:4f8:190:7447::2"));
}
TEST(IpAllowlist, SingleEntryNoCommas) {
auto a = parse_ip_allowlist("147.182.255.203");
EXPECT_EQ(a.size(), 1u);
EXPECT_TRUE(a.count("147.182.255.203"));
}
TEST(IpAllowlist, EmptyAndBlankYieldEmptySet) {
EXPECT_TRUE(parse_ip_allowlist("").empty());
EXPECT_TRUE(parse_ip_allowlist(" ").empty());
EXPECT_TRUE(parse_ip_allowlist(",, ,\t,").empty()); // only separators/blanks
}
TEST(IpAllowlist, IgnoresEmptyEntriesBetweenCommas) {
auto a = parse_ip_allowlist("8.8.8.8,,9.9.9.9,");
EXPECT_EQ(a.size(), 2u);
EXPECT_TRUE(a.count("8.8.8.8"));
EXPECT_TRUE(a.count("9.9.9.9"));
}
int main(int argc, char **argv) {
::testing::InitGoogleTest(&argc, argv);
return RUN_ALL_TESTS();
}
+15
View File
@@ -64,6 +64,21 @@ TEST_F(ProcessMockTest, ProcessWithEmptyFile) {
EXPECT_EQ(result, "emptyfileuser");
}
// Username lookup is case-insensitive: a mixed-case request is lowercased
// before the plan-file path is built, so "Pete" reads .../pete.
TEST_F(ProcessMockTest, ProcessLowercasesUsernameForLookup) {
using ::testing::Return;
const std::filesystem::path base{"/var/finger/users/"};
EXPECT_CALL(*mock_filesystem, exists(base / "pete"))
.WillOnce(Return(true));
EXPECT_CALL(*mock_filesystem, read_file(base / "pete"))
.WillOnce(Return("Just another hacker.\r\n"));
std::string result = process("Pete", *mock_filesystem, base);
EXPECT_EQ(result, "Just another hacker.\r\n");
}
// Test showing multiple expectations
TEST_F(ProcessMockTest, MultipleFileOperations) {
using ::testing::_;