Compare commits
2
Commits
da4fa18525
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c33066d33e | ||
|
|
dcbcff98a6 |
@@ -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
|
||||
@@ -2,6 +2,9 @@
|
||||
builddir/
|
||||
testbuild/
|
||||
|
||||
# Rust port (separate build, own Dockerfile)
|
||||
rust/
|
||||
|
||||
# Git
|
||||
.git/
|
||||
.gitignore
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
[](https://github.com/YOUR_USERNAME/YOUR_REPO_NAME/actions)
|
||||
[](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
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -1,8 +1,5 @@
|
||||
# finger
|
||||
|
||||
[](https://github.com/waffle2k/finger/actions)
|
||||
[](https://codecov.io/gh/waffle2k/finger)
|
||||
|
||||
A silly finger service written in c++20
|
||||
|
||||
# Compiling:
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
target/
|
||||
.git/
|
||||
.gitignore
|
||||
README.md
|
||||
*.md
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
*~
|
||||
@@ -0,0 +1 @@
|
||||
/target
|
||||
Generated
+152
@@ -0,0 +1,152 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "finger"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.189"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"wasi",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
|
||||
dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
"mio",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"windows-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "finger"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[[bin]]
|
||||
name = "finger"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1", features = ["rt", "macros", "net", "io-util", "time", "signal"] }
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
@@ -0,0 +1,42 @@
|
||||
# Multi-stage build for the Rust finger service
|
||||
# Build stage
|
||||
FROM rust:slim AS builder
|
||||
|
||||
WORKDIR /app
|
||||
COPY . .
|
||||
|
||||
RUN cargo build --release
|
||||
RUN cargo test --release
|
||||
|
||||
# Runtime stage — match the C++ image's base so OS-level overhead (syscalls,
|
||||
# libc) is comparable between the two for benchmarking.
|
||||
FROM ubuntu:24.04
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
netcat-openbsd \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& (userdel -r ubuntu 2>/dev/null || true) \
|
||||
&& groupadd -g 1000 finger \
|
||||
&& useradd -m -u 1000 -g finger -s /bin/sh finger
|
||||
|
||||
COPY --from=builder /app/target/release/finger /usr/local/bin/finger
|
||||
RUN chmod +x /usr/local/bin/finger
|
||||
|
||||
RUN mkdir -p /var/finger/users && \
|
||||
chown -R finger:finger /var/finger
|
||||
|
||||
USER finger
|
||||
|
||||
EXPOSE 79
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD nc -w 1 127.0.0.1 79 < /dev/null || exit 1
|
||||
|
||||
LABEL org.opencontainers.image.title="finger"
|
||||
LABEL org.opencontainers.image.description="A silly finger service written in Rust"
|
||||
LABEL org.opencontainers.image.source="https://github.com/waffle2k/finger"
|
||||
LABEL org.opencontainers.image.licenses="MIT"
|
||||
|
||||
CMD ["finger"]
|
||||
+368
@@ -0,0 +1,368 @@
|
||||
//! Tracks the timestamps of "offenses" -- requests that are obviously not
|
||||
//! finger queries -- per client IP, over a rolling time window. When an IP
|
||||
//! has more than `threshold` offenses still inside the window, it is blocked
|
||||
//! and its connections are dropped. Offense timestamps older than the window
|
||||
//! are pruned, so a blocked IP automatically frees itself once its old
|
||||
//! offenses age out.
|
||||
//!
|
||||
//! Time is passed in by the caller as a `Duration` since an arbitrary,
|
||||
//! caller-chosen reference point (in `main`, elapsed time since process
|
||||
//! start) rather than read internally, so the logic stays deterministic and
|
||||
//! unit-testable: tests pick a synthetic base far from zero so subtracting
|
||||
//! the window never underflows, mirroring the reference C++ implementation's
|
||||
//! use of an offset `steady_clock::time_point`.
|
||||
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::net::IpAddr;
|
||||
use std::time::Duration;
|
||||
|
||||
pub type Time = Duration;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Config {
|
||||
pub threshold: i32,
|
||||
pub window: Duration,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Config {
|
||||
threshold: 3,
|
||||
window: Duration::from_secs(24 * 3600),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct OffenseResult {
|
||||
pub count: i32,
|
||||
pub blocked: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct BanTracker {
|
||||
cfg: Config,
|
||||
offenders: HashMap<String, VecDeque<Time>>,
|
||||
}
|
||||
|
||||
impl BanTracker {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn with_config(cfg: Config) -> Self {
|
||||
BanTracker {
|
||||
cfg,
|
||||
offenders: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// True if `ip` currently has more than `threshold` offenses inside the
|
||||
/// rolling window. Does not mutate state.
|
||||
pub fn is_blocked(&self, ip: &str, now: Time) -> bool {
|
||||
match self.offenders.get(ip) {
|
||||
None => false,
|
||||
Some(ts) => count_in_window(ts, now, self.cfg.window) > self.cfg.threshold,
|
||||
}
|
||||
}
|
||||
|
||||
/// Record one offense from `ip` at `now`. Prunes that IP's expired
|
||||
/// timestamps, appends this one, and reports the in-window count and
|
||||
/// whether it is now blocked.
|
||||
pub fn record_offense(&mut self, ip: &str, now: Time) -> OffenseResult {
|
||||
let ts = self.offenders.entry(ip.to_string()).or_default();
|
||||
prune(ts, now, self.cfg.window);
|
||||
ts.push_back(now);
|
||||
let count = ts.len() as i32;
|
||||
OffenseResult {
|
||||
count,
|
||||
blocked: count > self.cfg.threshold,
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop timestamps older than the window across all IPs, removing any IP
|
||||
/// left with no offenses. Safe to call periodically to keep the map
|
||||
/// bounded.
|
||||
pub fn sweep(&mut self, now: Time) {
|
||||
self.offenders.retain(|_, ts| {
|
||||
prune(ts, now, self.cfg.window);
|
||||
!ts.is_empty()
|
||||
});
|
||||
}
|
||||
|
||||
/// Number of tracked IPs (for introspection and tests).
|
||||
#[allow(dead_code)]
|
||||
pub fn tracked(&self) -> usize {
|
||||
self.offenders.len()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn config(&self) -> &Config {
|
||||
&self.cfg
|
||||
}
|
||||
}
|
||||
|
||||
fn prune(ts: &mut VecDeque<Time>, now: Time, window: Duration) {
|
||||
let cutoff = now.saturating_sub(window);
|
||||
while let Some(&front) = ts.front() {
|
||||
if front <= cutoff {
|
||||
ts.pop_front();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Count timestamps that fall within (now - window, now]. The deque is kept
|
||||
// in ascending order, so the in-window entries are always a suffix.
|
||||
fn count_in_window(ts: &VecDeque<Time>, now: Time, window: Duration) -> i32 {
|
||||
let cutoff = now.saturating_sub(window);
|
||||
let mut count = 0;
|
||||
for &t in ts.iter().rev() {
|
||||
if t > cutoff {
|
||||
count += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
/// Whether a client address is meaningful to track and ban. Only globally
|
||||
/// routable unicast addresses qualify. Loopback, RFC1918 private, CGNAT
|
||||
/// (100.64/10), link-local, IPv6 unique-local, and multicast addresses all
|
||||
/// return false.
|
||||
///
|
||||
/// This matters because the daemon can only ban what it can see: behind
|
||||
/// Docker's default bridge networking every external client is SNAT'd to the
|
||||
/// bridge gateway (a 172.16/12 address), so banning per source IP would
|
||||
/// collapse all clients into one and block everyone. Skipping non-global
|
||||
/// addresses makes banning correct where the real client IP is visible (e.g.
|
||||
/// the FreeBSD jail, where pf rdr preserves it) and inert where it is not
|
||||
/// (Docker bridge), with no deployment-specific configuration.
|
||||
pub fn is_bannable_address(addr: IpAddr) -> bool {
|
||||
match addr {
|
||||
IpAddr::V4(v4) => {
|
||||
if v4.is_loopback() || v4.is_unspecified() || v4.is_multicast() {
|
||||
return false;
|
||||
}
|
||||
let a = u32::from(v4);
|
||||
if (a & 0xFF00_0000) == 0x0A00_0000 {
|
||||
return false; // 10.0.0.0/8
|
||||
}
|
||||
if (a & 0xFFF0_0000) == 0xAC10_0000 {
|
||||
return false; // 172.16.0.0/12
|
||||
}
|
||||
if (a & 0xFFFF_0000) == 0xC0A8_0000 {
|
||||
return false; // 192.168.0.0/16
|
||||
}
|
||||
if (a & 0xFFFF_0000) == 0xA9FE_0000 {
|
||||
return false; // 169.254.0.0/16 link-local
|
||||
}
|
||||
if (a & 0xFFC0_0000) == 0x6440_0000 {
|
||||
return false; // 100.64.0.0/10 CGNAT / Tailscale
|
||||
}
|
||||
true
|
||||
}
|
||||
IpAddr::V6(v6) => {
|
||||
if v6.is_loopback() || v6.is_unspecified() || v6.is_multicast() {
|
||||
return false;
|
||||
}
|
||||
let segments = v6.segments();
|
||||
if (segments[0] & 0xffc0) == 0xfe80 {
|
||||
return false; // fe80::/10 link-local
|
||||
}
|
||||
if ((segments[0] >> 8) as u8 & 0xFE) == 0xFC {
|
||||
return false; // fc00::/7 unique-local
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a comma-separated list of IP addresses (the value of the
|
||||
/// FINGER_BAN_ALLOWLIST env var) into a set of address strings. Whitespace
|
||||
/// around each entry is trimmed and empty entries are skipped.
|
||||
///
|
||||
/// Allowlisting exists for trusted aggregating front-ends -- notably the
|
||||
/// finger-web proxy, which funnels every federated lookup through one IP.
|
||||
/// Without it, a burst from any single client of the proxy is attributed to
|
||||
/// the proxy's IP and bans the proxy for everyone; per-client abuse
|
||||
/// protection for that path lives in the proxy instead.
|
||||
pub fn parse_ip_allowlist(csv: &str) -> HashSet<String> {
|
||||
csv.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Work well away from zero so that subtracting the window never
|
||||
// underflows and the base offset is unambiguous.
|
||||
fn base() -> Time {
|
||||
Duration::from_secs(1000 * 3600)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_ip_is_not_blocked() {
|
||||
let bt = BanTracker::new();
|
||||
assert!(!bt.is_blocked("1.2.3.4", base()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocks_only_after_more_than_threshold() {
|
||||
let mut bt = BanTracker::new(); // default threshold = 3, so block on the 4th failure
|
||||
assert!(!bt.record_offense("1.2.3.4", base()).blocked); // 1
|
||||
assert!(!bt.record_offense("1.2.3.4", base()).blocked); // 2
|
||||
assert!(!bt.record_offense("1.2.3.4", base()).blocked); // 3
|
||||
assert!(!bt.is_blocked("1.2.3.4", base()));
|
||||
let r = bt.record_offense("1.2.3.4", base()); // 4
|
||||
assert!(r.blocked);
|
||||
assert_eq!(r.count, 4);
|
||||
assert!(bt.is_blocked("1.2.3.4", base()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracks_each_ip_independently() {
|
||||
let mut bt = BanTracker::new();
|
||||
for _ in 0..4 {
|
||||
bt.record_offense("1.1.1.1", base());
|
||||
}
|
||||
assert!(bt.is_blocked("1.1.1.1", base()));
|
||||
assert!(!bt.is_blocked("2.2.2.2", base()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offenses_age_out_of_rolling_window() {
|
||||
let mut bt = BanTracker::new();
|
||||
// Four failures spread over a couple of hours -> blocked.
|
||||
for i in 0..4 {
|
||||
bt.record_offense("1.2.3.4", base() + Duration::from_secs(i * 3600));
|
||||
}
|
||||
assert!(bt.is_blocked("1.2.3.4", base() + Duration::from_secs(3 * 3600)));
|
||||
|
||||
// 24h after the first failure, that one drops out of the window:
|
||||
// only 3 remain, so the IP is no longer blocked.
|
||||
assert!(!bt.is_blocked(
|
||||
"1.2.3.4",
|
||||
base() + Duration::from_secs(24 * 3600 + 60)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn window_boundary_is_exclusive_at_cutoff() {
|
||||
let mut bt = BanTracker::new();
|
||||
// Exactly window-old timestamps are pruned (cutoff is inclusive of <=).
|
||||
bt.record_offense("1.2.3.4", base());
|
||||
let r = bt.record_offense("1.2.3.4", base() + Duration::from_secs(24 * 3600));
|
||||
assert_eq!(r.count, 1); // the base() entry was pruned before appending
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sweep_removes_fully_expired_ip() {
|
||||
let mut bt = BanTracker::new();
|
||||
for _ in 0..4 {
|
||||
bt.record_offense("1.2.3.4", base());
|
||||
}
|
||||
assert_eq!(bt.tracked(), 1);
|
||||
bt.sweep(base() + Duration::from_secs(24 * 3600 + 60)); // all offenses aged out
|
||||
assert_eq!(bt.tracked(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sweep_keeps_still_active_ip() {
|
||||
let mut bt = BanTracker::new();
|
||||
for _ in 0..4 {
|
||||
bt.record_offense("1.2.3.4", base());
|
||||
}
|
||||
bt.sweep(base() + Duration::from_secs(3600)); // still inside the window
|
||||
assert_eq!(bt.tracked(), 1);
|
||||
assert!(bt.is_blocked("1.2.3.4", base() + Duration::from_secs(3600)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn respects_custom_config() {
|
||||
let mut bt = BanTracker::with_config(Config {
|
||||
threshold: 1,
|
||||
window: Duration::from_secs(3600),
|
||||
});
|
||||
assert!(!bt.record_offense("9.9.9.9", base()).blocked); // 1, not > 1
|
||||
assert!(bt.record_offense("9.9.9.9", base()).blocked); // 2 > 1
|
||||
assert!(bt.is_blocked("9.9.9.9", base()));
|
||||
assert!(!bt.is_blocked("9.9.9.9", base() + Duration::from_secs(3600 + 60))); // window elapsed
|
||||
}
|
||||
|
||||
fn bannable(ip: &str) -> bool {
|
||||
is_bannable_address(ip.parse().unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn global_ipv4_is_bannable() {
|
||||
assert!(bannable("8.8.8.8"));
|
||||
assert!(bannable("192.184.167.198")); // a real scanner seen in the logs
|
||||
assert!(bannable("1.2.3.4"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_and_local_ipv4_are_not_bannable() {
|
||||
assert!(!bannable("127.0.0.1")); // loopback
|
||||
assert!(!bannable("10.1.2.3")); // 10/8
|
||||
assert!(!bannable("172.20.0.1")); // Docker bridge gateway (172.16/12)
|
||||
assert!(!bannable("172.31.255.1"));
|
||||
assert!(!bannable("192.168.1.104")); // the finger jail's own LAN IP
|
||||
assert!(!bannable("169.254.10.1")); // link-local
|
||||
assert!(!bannable("224.0.0.1")); // multicast
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cgnat_range_is_not_bannable() {
|
||||
assert!(!bannable("100.64.0.1")); // bottom of 100.64/10 (CGNAT/Tailscale)
|
||||
assert!(!bannable("100.127.255.1")); // top of the range
|
||||
assert!(bannable("100.63.255.1")); // just below the range -> public
|
||||
assert!(bannable("100.128.0.1")); // just above the range -> public
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipv6_classification() {
|
||||
assert!(bannable("2001:4860:4860::8888")); // global
|
||||
assert!(!bannable("::1")); // loopback
|
||||
assert!(!bannable("fe80::1")); // link-local
|
||||
assert!(!bannable("fc00::1")); // unique-local
|
||||
assert!(!bannable("fd12:3456::1")); // unique-local
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowlist_parses_comma_separated_trimmed_entries() {
|
||||
let a = parse_ip_allowlist("147.182.255.203, 10.0.0.1 ,\t2a01:4f8:190:7447::2");
|
||||
assert_eq!(a.len(), 3);
|
||||
assert!(a.contains("147.182.255.203"));
|
||||
assert!(a.contains("10.0.0.1"));
|
||||
assert!(a.contains("2a01:4f8:190:7447::2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowlist_single_entry_no_commas() {
|
||||
let a = parse_ip_allowlist("147.182.255.203");
|
||||
assert_eq!(a.len(), 1);
|
||||
assert!(a.contains("147.182.255.203"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowlist_empty_and_blank_yield_empty_set() {
|
||||
assert!(parse_ip_allowlist("").is_empty());
|
||||
assert!(parse_ip_allowlist(" ").is_empty());
|
||||
assert!(parse_ip_allowlist(",, ,\t,").is_empty()); // only separators/blanks
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowlist_ignores_empty_entries_between_commas() {
|
||||
let a = parse_ip_allowlist("8.8.8.8,,9.9.9.9,");
|
||||
assert_eq!(a.len(), 2);
|
||||
assert!(a.contains("8.8.8.8"));
|
||||
assert!(a.contains("9.9.9.9"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
//! Resolves a finger username to a response: the contents of that user's
|
||||
//! plan file if one exists and is readable, or the username echoed back
|
||||
//! unchanged otherwise. Input is validated first so a request can never walk
|
||||
//! outside the configured plan-file directory.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub trait FilesystemWrapper {
|
||||
fn exists(&self, path: &Path) -> bool;
|
||||
fn read_file(&self, path: &Path) -> String;
|
||||
}
|
||||
|
||||
pub struct RealFilesystemWrapper;
|
||||
|
||||
impl FilesystemWrapper for RealFilesystemWrapper {
|
||||
fn exists(&self, path: &Path) -> bool {
|
||||
path.exists()
|
||||
}
|
||||
|
||||
fn read_file(&self, path: &Path) -> String {
|
||||
let raw = match std::fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return String::new(),
|
||||
};
|
||||
if raw.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
// Plan files are returned over the finger protocol, which expects
|
||||
// CRLF line endings; normalise whatever the file used to LF-joined
|
||||
// lines terminated by a single CRLF.
|
||||
let body = raw.lines().collect::<Vec<_>>().join("\n");
|
||||
format!("{body}\r\n")
|
||||
}
|
||||
}
|
||||
|
||||
pub const BASE_PATH: &str = "/var/finger/users/";
|
||||
|
||||
pub fn process(username: &str) -> String {
|
||||
let fs = RealFilesystemWrapper;
|
||||
process_with(username, &fs, Path::new(BASE_PATH))
|
||||
}
|
||||
|
||||
pub fn process_with(username: &str, fs: &dyn FilesystemWrapper, basepath: &Path) -> String {
|
||||
const TRAVERSAL_PATTERNS: [&str; 10] = [
|
||||
"../",
|
||||
"..\\",
|
||||
"%2e%2e%2f",
|
||||
"%2e%2e%5c",
|
||||
"%2E%2E%2F",
|
||||
"%2E%2E%5C",
|
||||
"..%2f",
|
||||
"..%5c",
|
||||
"..%2F",
|
||||
"..%5C",
|
||||
];
|
||||
if TRAVERSAL_PATTERNS.iter().any(|p| username.contains(p)) {
|
||||
return "InvalidInput: Directory traversal detected in username\r\n".to_string();
|
||||
}
|
||||
if username.contains('/') {
|
||||
return "InvalidInput: Path detected in username\r\n".to_string();
|
||||
}
|
||||
|
||||
// Plan-file lookup is case-insensitive: normalise the requested name to
|
||||
// lower-case so e.g. "Pete" resolves the on-disk "pete" plan. Plan
|
||||
// filenames are always lower-case; the original spelling is still echoed
|
||||
// back below when no plan exists. Lower-casing is ASCII-only to match
|
||||
// the byte-wise ::tolower behavior of the reference implementation.
|
||||
let lookup = username.to_ascii_lowercase();
|
||||
let plan_path: PathBuf = basepath.join(lookup);
|
||||
|
||||
if !fs.exists(&plan_path) {
|
||||
return username.to_string();
|
||||
}
|
||||
|
||||
let content = fs.read_file(&plan_path);
|
||||
if content.is_empty() {
|
||||
return username.to_string();
|
||||
}
|
||||
|
||||
content
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
#[test]
|
||||
fn valid_username_simple() {
|
||||
assert_eq!(process("john"), "john");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_username_with_numbers() {
|
||||
assert_eq!(process("user123"), "user123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_username_with_underscore() {
|
||||
assert_eq!(process("user_name"), "user_name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_username_with_hyphen() {
|
||||
assert_eq!(process("user-name"), "user-name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_username_empty_string() {
|
||||
assert_eq!(process(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directory_traversal_basic_dot_dot_slash() {
|
||||
let result = process("user../file");
|
||||
assert!(result.starts_with("InvalidInput:"));
|
||||
assert!(result.contains("Directory traversal detected"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directory_traversal_basic_dot_dot_backslash() {
|
||||
let result = process("user..\\file");
|
||||
assert!(result.starts_with("InvalidInput:"));
|
||||
assert!(result.contains("Directory traversal detected"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directory_traversal_url_encoded_lowercase_2e2e2f() {
|
||||
let result = process("user%2e%2e%2ffile");
|
||||
assert!(result.starts_with("InvalidInput:"));
|
||||
assert!(result.contains("Directory traversal detected"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directory_traversal_url_encoded_lowercase_2e2e5c() {
|
||||
let result = process("user%2e%2e%5cfile");
|
||||
assert!(result.starts_with("InvalidInput:"));
|
||||
assert!(result.contains("Directory traversal detected"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directory_traversal_url_encoded_uppercase_2e2e2f() {
|
||||
let result = process("user%2E%2E%2Ffile");
|
||||
assert!(result.starts_with("InvalidInput:"));
|
||||
assert!(result.contains("Directory traversal detected"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directory_traversal_url_encoded_uppercase_2e2e5c() {
|
||||
let result = process("user%2E%2E%5Cfile");
|
||||
assert!(result.starts_with("InvalidInput:"));
|
||||
assert!(result.contains("Directory traversal detected"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directory_traversal_mixed_dot_dot_2f() {
|
||||
let result = process("user..%2ffile");
|
||||
assert!(result.starts_with("InvalidInput:"));
|
||||
assert!(result.contains("Directory traversal detected"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directory_traversal_mixed_dot_dot_5c() {
|
||||
let result = process("user..%5cfile");
|
||||
assert!(result.starts_with("InvalidInput:"));
|
||||
assert!(result.contains("Directory traversal detected"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directory_traversal_mixed_dot_dot_2f_upper() {
|
||||
let result = process("user..%2Ffile");
|
||||
assert!(result.starts_with("InvalidInput:"));
|
||||
assert!(result.contains("Directory traversal detected"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directory_traversal_mixed_dot_dot_5c_upper() {
|
||||
let result = process("user..%5Cfile");
|
||||
assert!(result.starts_with("InvalidInput:"));
|
||||
assert!(result.contains("Directory traversal detected"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_detection_forward_slash() {
|
||||
let result = process("user/name");
|
||||
assert!(result.starts_with("InvalidInput:"));
|
||||
assert!(result.contains("Path detected"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_detection_forward_slash_at_start() {
|
||||
let result = process("/username");
|
||||
assert!(result.starts_with("InvalidInput:"));
|
||||
assert!(result.contains("Path detected"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_detection_forward_slash_at_end() {
|
||||
let result = process("username/");
|
||||
assert!(result.starts_with("InvalidInput:"));
|
||||
assert!(result.contains("Path detected"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_dot() {
|
||||
assert_eq!(process("."), ".");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_dot_without_slash() {
|
||||
assert_eq!(process(".."), "..");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contains_dot_but_not_traversal() {
|
||||
assert_eq!(process("user.name"), "user.name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backslash_without_dots() {
|
||||
assert_eq!(process("user\\name"), "user\\name");
|
||||
}
|
||||
|
||||
// --- Fake-filesystem tests (mirrors test_handler_mock.cpp) ---
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeFilesystem {
|
||||
files: HashMap<PathBuf, String>,
|
||||
}
|
||||
|
||||
impl FakeFilesystem {
|
||||
fn with_file(path: impl Into<PathBuf>, content: impl Into<String>) -> Self {
|
||||
let mut files = HashMap::new();
|
||||
files.insert(path.into(), content.into());
|
||||
FakeFilesystem { files }
|
||||
}
|
||||
}
|
||||
|
||||
impl FilesystemWrapper for FakeFilesystem {
|
||||
fn exists(&self, path: &Path) -> bool {
|
||||
self.files.contains_key(path)
|
||||
}
|
||||
|
||||
fn read_file(&self, path: &Path) -> String {
|
||||
self.files.get(path).cloned().unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_with_file_exists() {
|
||||
let base = Path::new("/var/finger/users/");
|
||||
let fs = FakeFilesystem::with_file(base.join("testuser"), "Mock file content\r\n");
|
||||
assert_eq!(
|
||||
process_with("testuser", &fs, base),
|
||||
"Mock file content\r\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_with_file_not_found() {
|
||||
let base = Path::new("/var/finger/users/");
|
||||
let fs = FakeFilesystem::default();
|
||||
assert_eq!(process_with("nonexistentuser", &fs, base), "nonexistentuser");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_with_empty_file() {
|
||||
let base = Path::new("/var/finger/users/");
|
||||
let fs = FakeFilesystem::with_file(base.join("emptyfileuser"), "");
|
||||
assert_eq!(process_with("emptyfileuser", &fs, base), "emptyfileuser");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_lowercases_username_for_lookup() {
|
||||
let base = Path::new("/var/finger/users/");
|
||||
let fs = FakeFilesystem::with_file(base.join("pete"), "Just another hacker.\r\n");
|
||||
assert_eq!(
|
||||
process_with("Pete", &fs, base),
|
||||
"Just another hacker.\r\n"
|
||||
);
|
||||
}
|
||||
|
||||
// --- Real-filesystem tests (mirrors test_handler_real_filesystem.cpp) ---
|
||||
|
||||
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
struct TempDir(PathBuf);
|
||||
|
||||
impl TempDir {
|
||||
fn new() -> Self {
|
||||
let n = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"finger_rs_test_{}_{n}",
|
||||
std::process::id()
|
||||
));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
TempDir(dir)
|
||||
}
|
||||
|
||||
fn path(&self) -> &Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn write_file(dir: &Path, name: &str, content: &str) {
|
||||
std::fs::write(dir.join(name), content).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_fs_exists_with_real_file() {
|
||||
let dir = TempDir::new();
|
||||
write_file(dir.path(), "testuser", "Test content");
|
||||
let fs = RealFilesystemWrapper;
|
||||
assert!(fs.exists(&dir.path().join("testuser")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_fs_exists_with_nonexistent_file() {
|
||||
let dir = TempDir::new();
|
||||
let fs = RealFilesystemWrapper;
|
||||
assert!(!fs.exists(&dir.path().join("nonexistent")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_fs_read_file_with_simple_content() {
|
||||
let dir = TempDir::new();
|
||||
write_file(dir.path(), "simple", "Hello, World!");
|
||||
let fs = RealFilesystemWrapper;
|
||||
assert_eq!(
|
||||
fs.read_file(&dir.path().join("simple")),
|
||||
"Hello, World!\r\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_fs_read_file_with_multiline_content() {
|
||||
let dir = TempDir::new();
|
||||
write_file(dir.path(), "multiline", "Line 1\nLine 2\nLine 3");
|
||||
let fs = RealFilesystemWrapper;
|
||||
assert_eq!(
|
||||
fs.read_file(&dir.path().join("multiline")),
|
||||
"Line 1\nLine 2\nLine 3\r\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_fs_read_file_with_empty_file() {
|
||||
let dir = TempDir::new();
|
||||
write_file(dir.path(), "empty", "");
|
||||
let fs = RealFilesystemWrapper;
|
||||
assert_eq!(fs.read_file(&dir.path().join("empty")), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_fs_read_file_nonexistent_file() {
|
||||
let dir = TempDir::new();
|
||||
let fs = RealFilesystemWrapper;
|
||||
assert_eq!(fs.read_file(&dir.path().join("nonexistent")), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_with_existing_user_file() {
|
||||
let dir = TempDir::new();
|
||||
write_file(
|
||||
dir.path(),
|
||||
"johndoe",
|
||||
"John Doe\nSoftware Engineer\nLoves Rust",
|
||||
);
|
||||
let fs = RealFilesystemWrapper;
|
||||
assert_eq!(
|
||||
process_with("johndoe", &fs, dir.path()),
|
||||
"John Doe\nSoftware Engineer\nLoves Rust\r\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_with_nonexistent_user_file() {
|
||||
let dir = TempDir::new();
|
||||
let fs = RealFilesystemWrapper;
|
||||
assert_eq!(process_with("nonexistentuser", &fs, dir.path()), "nonexistentuser");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_with_empty_user_file() {
|
||||
let dir = TempDir::new();
|
||||
write_file(dir.path(), "emptyuser", "");
|
||||
let fs = RealFilesystemWrapper;
|
||||
assert_eq!(process_with("emptyuser", &fs, dir.path()), "emptyuser");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_with_file_containing_only_newlines() {
|
||||
let dir = TempDir::new();
|
||||
write_file(dir.path(), "newlineuser", "\n\n\n");
|
||||
let fs = RealFilesystemWrapper;
|
||||
// Line-by-line reading means the trailing empty line after the final
|
||||
// \n is not read as a separate line, resulting in "\n\n\r\n".
|
||||
assert_eq!(process_with("newlineuser", &fs, dir.path()), "\n\n\r\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_directory_traversal_protection_with_real_fs() {
|
||||
let dir = TempDir::new();
|
||||
let result = process_with("../secret", &RealFilesystemWrapper, dir.path());
|
||||
assert!(result.starts_with("InvalidInput:"));
|
||||
assert!(result.contains("Directory traversal detected"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_with_custom_base_path() {
|
||||
let dir = TempDir::new();
|
||||
let custom_base = dir.path().join("custom_users");
|
||||
std::fs::create_dir_all(&custom_base).unwrap();
|
||||
write_file(&custom_base, "customuser", "Custom base path user");
|
||||
let fs = RealFilesystemWrapper;
|
||||
assert_eq!(
|
||||
process_with("customuser", &fs, &custom_base),
|
||||
"Custom base path user\r\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_fs_read_file_with_special_characters() {
|
||||
let dir = TempDir::new();
|
||||
let content = "User with special chars: \u{e0}\u{e1}\u{e2}\u{e3}\u{e4}\u{e5}\u{e6}\u{e7}\u{e8}\u{e9}\u{ea}\u{eb}";
|
||||
write_file(dir.path(), "specialuser", content);
|
||||
let fs = RealFilesystemWrapper;
|
||||
assert_eq!(
|
||||
fs.read_file(&dir.path().join("specialuser")),
|
||||
format!("{content}\r\n")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
mod ban;
|
||||
mod handler;
|
||||
|
||||
use ban::{BanTracker, is_bannable_address, parse_ip_allowlist};
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
/// True once a response actually came from a plan file, rather than being
|
||||
/// the username echoed back unchanged or an InvalidInput rejection. A
|
||||
/// "failure" (the negation) is timestamped against the client IP by the
|
||||
/// caller; enough failures within the rolling window trips the ban.
|
||||
fn is_plan_served(response: &str, username: &str) -> bool {
|
||||
response != username && !response.starts_with("InvalidInput:")
|
||||
}
|
||||
|
||||
fn trim_trailing_crlf(s: &mut String) {
|
||||
while matches!(s.chars().last(), Some('\r') | Some('\n')) {
|
||||
s.pop();
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_connection(
|
||||
mut socket: TcpStream,
|
||||
client_addr: String,
|
||||
trackable: bool,
|
||||
bans: Arc<Mutex<BanTracker>>,
|
||||
start: Instant,
|
||||
) {
|
||||
let now = start.elapsed();
|
||||
|
||||
// An IP that has racked up too many failed lookups (scanners, username
|
||||
// guessers, non-finger junk) is dropped without being read or answered.
|
||||
// Only globally-routable addresses are tracked: behind Docker's bridge
|
||||
// every client is SNAT'd to the gateway, so banning there would block
|
||||
// everyone at once (see is_bannable_address()).
|
||||
if trackable && bans.lock().unwrap().is_blocked(&client_addr, now) {
|
||||
println!("finger drop from {client_addr}: blocked");
|
||||
return;
|
||||
}
|
||||
|
||||
let mut data = [0u8; 1024];
|
||||
let bytes_read = match socket.read(&mut data).await {
|
||||
// Client hung up before sending a request: health checks (which
|
||||
// connect and immediately close), port scanners, and reset
|
||||
// connections all land here. This is normal -- don't log it as an
|
||||
// error.
|
||||
Ok(0) | Err(_) => return,
|
||||
Ok(n) => n,
|
||||
};
|
||||
|
||||
let mut username = String::from_utf8_lossy(&data[..bytes_read]).into_owned();
|
||||
trim_trailing_crlf(&mut username);
|
||||
|
||||
println!("finger request from {client_addr} for user '{username}'");
|
||||
let response = handler::process(&username);
|
||||
|
||||
// A "failure" is simply any request that does not resolve to a readable
|
||||
// plan file: an unknown user, rejected input, or non-finger junk. Each
|
||||
// failure is timestamped against the client IP; once an IP exceeds the
|
||||
// threshold within the rolling window, the is_blocked() check above
|
||||
// starts dropping its connections. This also frustrates username
|
||||
// guessing.
|
||||
if !is_plan_served(&response, &username) {
|
||||
if trackable {
|
||||
let res = bans.lock().unwrap().record_offense(&client_addr, now);
|
||||
let suffix = if res.blocked { " -- now blocked" } else { "" };
|
||||
println!(
|
||||
"finger miss from {client_addr} for '{username}' ({} failures in window){suffix}",
|
||||
res.count
|
||||
);
|
||||
} else {
|
||||
println!("finger miss from {client_addr} for '{username}' (not tracked)");
|
||||
}
|
||||
// Best-effort reply; ignore write errors (the client may have
|
||||
// already gone away).
|
||||
let _ = socket.write_all(b"No plan found\r\n").await;
|
||||
return;
|
||||
}
|
||||
|
||||
let _ = socket.write_all(response.as_bytes()).await;
|
||||
}
|
||||
|
||||
async fn listener(
|
||||
bans: Arc<Mutex<BanTracker>>,
|
||||
allowlist: Arc<HashSet<String>>,
|
||||
start: Instant,
|
||||
) -> std::io::Result<()> {
|
||||
let acceptor = TcpListener::bind(("0.0.0.0", 79)).await?;
|
||||
loop {
|
||||
let (socket, peer) = acceptor.accept().await?;
|
||||
let client_addr = peer.ip().to_string();
|
||||
// Allowlisted IPs (trusted aggregating front-ends like the
|
||||
// finger-web proxy) are never tracked, so their bursts neither block
|
||||
// them nor count as offenses.
|
||||
let trackable = is_bannable_address(peer.ip()) && !allowlist.contains(&client_addr);
|
||||
let bans = bans.clone();
|
||||
tokio::spawn(handle_connection(socket, client_addr, trackable, bans, start));
|
||||
}
|
||||
}
|
||||
|
||||
// Periodically prune offense records that have aged out of the window so
|
||||
// the tracker's memory stays bounded even for IPs that never reconnect.
|
||||
async fn sweeper(bans: Arc<Mutex<BanTracker>>, start: Instant) {
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(600));
|
||||
interval.tick().await; // first tick fires immediately; skip it
|
||||
loop {
|
||||
interval.tick().await;
|
||||
bans.lock().unwrap().sweep(start.elapsed());
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_shutdown_signal() {
|
||||
let ctrl_c = tokio::signal::ctrl_c();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||
.expect("failed to install SIGTERM handler");
|
||||
tokio::select! {
|
||||
_ = ctrl_c => {}
|
||||
_ = term.recv() => {}
|
||||
}
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = ctrl_c.await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main(flavor = "current_thread")]
|
||||
async fn main() {
|
||||
let start = Instant::now();
|
||||
let bans = Arc::new(Mutex::new(BanTracker::new()));
|
||||
|
||||
let allow_env = std::env::var("FINGER_BAN_ALLOWLIST").unwrap_or_default();
|
||||
let allowlist = Arc::new(parse_ip_allowlist(&allow_env));
|
||||
for ip in allowlist.iter() {
|
||||
println!("ban allowlist: {ip} (never tracked or blocked)");
|
||||
}
|
||||
|
||||
tokio::spawn(sweeper(bans.clone(), start));
|
||||
|
||||
tokio::select! {
|
||||
res = listener(bans, allowlist, start) => {
|
||||
if let Err(e) = res {
|
||||
println!("fatal exception: {e}");
|
||||
}
|
||||
}
|
||||
_ = wait_for_shutdown_signal() => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn plan_served_when_response_differs_and_not_invalid() {
|
||||
assert!(is_plan_served("Out to lunch.\r\n", "pete"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_not_served_when_response_echoes_username() {
|
||||
assert!(!is_plan_served("pete", "pete"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_not_served_on_invalid_input() {
|
||||
assert!(!is_plan_served(
|
||||
"InvalidInput: Path detected in username\r\n",
|
||||
"user/name"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trims_trailing_cr_and_lf() {
|
||||
let mut s = String::from("pete\r\n");
|
||||
trim_trailing_crlf(&mut s);
|
||||
assert_eq!(s, "pete");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trims_bare_lf_only() {
|
||||
let mut s = String::from("pete\n");
|
||||
trim_trailing_crlf(&mut s);
|
||||
assert_eq!(s, "pete");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaves_string_without_trailing_crlf_untouched() {
|
||||
let mut s = String::from("pete");
|
||||
trim_trailing_crlf(&mut s);
|
||||
assert_eq!(s, "pete");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user