Compare commits
12
Commits
6d782366f3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c33066d33e | ||
|
|
dcbcff98a6 | ||
|
|
da4fa18525 | ||
|
|
b1e7f5229b | ||
|
|
011f8c4838 | ||
|
|
54650af252 | ||
|
|
946c2b9e01 | ||
|
|
268ededc19 | ||
|
|
84fc383137 | ||
|
|
35d0f21051 | ||
|
|
0d5414e03d | ||
|
|
676d1700e1 |
@@ -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/
|
builddir/
|
||||||
testbuild/
|
testbuild/
|
||||||
|
|
||||||
|
# Rust port (separate build, own Dockerfile)
|
||||||
|
rust/
|
||||||
|
|
||||||
# Git
|
# Git
|
||||||
.git/
|
.git/
|
||||||
.gitignore
|
.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'
|
|
||||||
@@ -64,6 +64,74 @@ docker run -d \
|
|||||||
ghcr.io/waffle2k/finger:latest
|
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
|
## Docker Architecture
|
||||||
|
|
||||||
### Multi-stage Build
|
### Multi-stage Build
|
||||||
|
|||||||
+13
-8
@@ -29,14 +29,19 @@ RUN meson compile -C builddir
|
|||||||
# Run tests to ensure quality
|
# Run tests to ensure quality
|
||||||
RUN meson test -C builddir
|
RUN meson test -C builddir
|
||||||
|
|
||||||
# Runtime stage - minimal Alpine Linux
|
# Runtime stage — match builder's glibc (Alpine/musl is incompatible
|
||||||
FROM alpine:latest
|
# with our dynamically linked binary, esp. fortify _chk symbols).
|
||||||
|
FROM ubuntu:24.04
|
||||||
|
|
||||||
# Install runtime dependencies (if any)
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
RUN apk add --no-cache \
|
|
||||||
libstdc++ \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
&& addgroup -g 1000 finger \
|
libstdc++6 \
|
||||||
&& adduser -D -s /bin/sh -u 1000 -G finger finger
|
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 the compiled binary from builder stage
|
||||||
COPY --from=builder /app/builddir/finger /usr/local/bin/finger
|
COPY --from=builder /app/builddir/finger /usr/local/bin/finger
|
||||||
@@ -56,7 +61,7 @@ EXPOSE 79
|
|||||||
|
|
||||||
# Add health check
|
# Add health check
|
||||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
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
|
# Set metadata labels
|
||||||
LABEL org.opencontainers.image.title="finger"
|
LABEL org.opencontainers.image.title="finger"
|
||||||
|
|||||||
+1
-1
@@ -51,7 +51,7 @@ Create `/usr/local/etc/rc.d/fingerd`:
|
|||||||
name="fingerd"
|
name="fingerd"
|
||||||
rcvar="fingerd_enable"
|
rcvar="fingerd_enable"
|
||||||
command="/usr/sbin/daemon"
|
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"
|
pidfile="/var/run/fingerd.pid"
|
||||||
# procname must be the full path so rc.subr can match it against ps output
|
# procname must be the full path so rc.subr can match it against ps output
|
||||||
procname="/usr/local/bin/finger"
|
procname="/usr/local/bin/finger"
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
# finger
|
# finger
|
||||||
|
|
||||||
[](https://github.com/waffle2k/finger/actions)
|
|
||||||
[](https://codecov.io/gh/waffle2k/finger)
|
|
||||||
|
|
||||||
A silly finger service written in c++20
|
A silly finger service written in c++20
|
||||||
|
|
||||||
# Compiling:
|
# Compiling:
|
||||||
@@ -59,3 +56,13 @@ and execute `docker compose up -d`
|
|||||||
|
|
||||||
# Setting your status
|
# Setting your status
|
||||||
within the `./users` directory, create a file named after the user you wish to have a response. That's it!
|
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`).
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -3,19 +3,42 @@ version: '3.8'
|
|||||||
services:
|
services:
|
||||||
finger:
|
finger:
|
||||||
build: .
|
build: .
|
||||||
ports:
|
# IMPORTANT: host networking is what lets the daemon's abuse protection
|
||||||
- "79:79"
|
# 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:
|
volumes:
|
||||||
- ./users:/var/finger/users
|
- ./users:/var/finger/users
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "nc", "-z", "localhost", "79"]
|
test: ["CMD-SHELL", "nc -w 1 127.0.0.1 79 < /dev/null || exit 1"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 40s
|
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:
|
# finger:
|
||||||
# image: ghcr.io/waffle2k/finger:latest
|
# image: ghcr.io/waffle2k/finger:latest
|
||||||
# ports:
|
# ports:
|
||||||
|
|||||||
+11
-1
@@ -1,4 +1,6 @@
|
|||||||
#include "handler.hpp"
|
#include "handler.hpp"
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cctype>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include <string_view>
|
#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");
|
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
|
// 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
|
// Check if the plan file exists using the filesystem wrapper
|
||||||
if (!fs.exists(planPath)) {
|
if (!fs.exists(planPath)) {
|
||||||
|
|||||||
@@ -1,15 +1,21 @@
|
|||||||
#include <iostream>
|
#include <iostream>
|
||||||
|
|
||||||
|
#include <boost/asio/as_tuple.hpp>
|
||||||
#include <boost/asio/co_spawn.hpp>
|
#include <boost/asio/co_spawn.hpp>
|
||||||
#include <boost/asio/deferred.hpp>
|
#include <boost/asio/deferred.hpp>
|
||||||
#include <boost/asio/detached.hpp>
|
#include <boost/asio/detached.hpp>
|
||||||
#include <boost/asio/io_context.hpp>
|
#include <boost/asio/io_context.hpp>
|
||||||
#include <boost/asio/ip/tcp.hpp>
|
#include <boost/asio/ip/tcp.hpp>
|
||||||
#include <boost/asio/signal_set.hpp>
|
#include <boost/asio/signal_set.hpp>
|
||||||
|
#include <boost/asio/steady_timer.hpp>
|
||||||
#include <boost/asio/write.hpp>
|
#include <boost/asio/write.hpp>
|
||||||
|
#include <chrono>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <syslog.h>
|
#include <cstdlib>
|
||||||
|
#include <string>
|
||||||
|
#include <unordered_set>
|
||||||
|
|
||||||
|
#include "ban.hpp"
|
||||||
#include "handler.hpp"
|
#include "handler.hpp"
|
||||||
|
|
||||||
using boost::asio::awaitable;
|
using boost::asio::awaitable;
|
||||||
@@ -23,35 +29,74 @@ awaitable<std::string> dofinger(const std::string &username) {
|
|||||||
co_return process(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 {
|
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];
|
char data[1024];
|
||||||
auto bytes_read =
|
auto [read_ec, bytes_read] = co_await socket.async_read_some(
|
||||||
co_await socket.async_read_some(boost::asio::buffer(data), deferred);
|
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);
|
std::string username(data, bytes_read);
|
||||||
// Remove trailing \r\n characters
|
// Remove trailing \r\n characters
|
||||||
while (!username.empty() &&
|
while (!username.empty() &&
|
||||||
(username.back() == '\r' || username.back() == '\n')) {
|
(username.back() == '\r' || username.back() == '\n')) {
|
||||||
username.pop_back();
|
username.pop_back();
|
||||||
}
|
}
|
||||||
syslog(LOG_INFO, "finger request from %s for user '%s'",
|
std::printf("finger request from %s for user '%s'\n",
|
||||||
client_addr.c_str(), username.c_str());
|
client_addr.c_str(), username.c_str());
|
||||||
auto response = co_await dofinger(username);
|
auto response = co_await dofinger(username);
|
||||||
if (response.compare(std::string(username)) == 0) {
|
|
||||||
// No plan found
|
// A "failure" is simply any request that does not resolve to a readable
|
||||||
co_await async_write(
|
// plan file: an unknown user, rejected input, or non-finger junk. Each
|
||||||
socket, boost::asio::buffer(std::string("No plan found\r\n")),
|
// failure is timestamped against the client IP; once an IP exceeds the
|
||||||
deferred);
|
// 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_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;
|
co_return;
|
||||||
} catch (std::exception &e) {
|
} 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;
|
auto executor = co_await this_coro::executor;
|
||||||
tcp::acceptor acceptor(executor, {tcp::v4(), 79});
|
tcp::acceptor acceptor(executor, {tcp::v4(), 79});
|
||||||
for (;;) {
|
for (;;) {
|
||||||
@@ -60,24 +105,50 @@ awaitable<void> listener() {
|
|||||||
auto endpoint = socket.remote_endpoint(ec);
|
auto endpoint = socket.remote_endpoint(ec);
|
||||||
std::string client_addr =
|
std::string client_addr =
|
||||||
ec ? std::string("unknown") : endpoint.address().to_string();
|
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);
|
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() {
|
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 {
|
try {
|
||||||
boost::asio::io_context io_context(1);
|
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);
|
boost::asio::signal_set signals(io_context, SIGINT, SIGTERM);
|
||||||
signals.async_wait([&](auto, auto) { io_context.stop(); });
|
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();
|
io_context.run();
|
||||||
} catch (std::exception &e) {
|
} catch (std::exception &e) {
|
||||||
syslog(LOG_ERR, "fatal exception: %s", e.what());
|
std::printf("fatal exception: %s\n", e.what());
|
||||||
}
|
}
|
||||||
closelog();
|
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-1
@@ -14,7 +14,7 @@ gtest_dep = dependency('gtest', main : true, required : true)
|
|||||||
gmock_dep = dependency('gmock', main : true, required : true)
|
gmock_dep = dependency('gmock', main : true, required : true)
|
||||||
|
|
||||||
executable('finger',
|
executable('finger',
|
||||||
'main.cpp','handler.cpp',
|
'main.cpp','handler.cpp','ban.cpp',
|
||||||
dependencies : [boost_dep, threads_dep],
|
dependencies : [boost_dep, threads_dep],
|
||||||
install : true)
|
install : true)
|
||||||
|
|
||||||
@@ -33,7 +33,13 @@ test_real_fs_exe = executable('test_handler_real_filesystem',
|
|||||||
'test_handler_real_filesystem.cpp', 'handler.cpp',
|
'test_handler_real_filesystem.cpp', 'handler.cpp',
|
||||||
dependencies : [boost_dep, threads_dep, gtest_dep, gmock_dep])
|
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
|
# Register the tests
|
||||||
test('handler_tests', test_exe)
|
test('handler_tests', test_exe)
|
||||||
test('handler_mock_tests', test_mock_exe)
|
test('handler_mock_tests', test_mock_exe)
|
||||||
test('handler_real_filesystem_tests', test_real_fs_exe)
|
test('handler_real_filesystem_tests', test_real_fs_exe)
|
||||||
|
test('ban_tests', test_ban_exe)
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+152
@@ -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();
|
||||||
|
}
|
||||||
@@ -64,6 +64,21 @@ TEST_F(ProcessMockTest, ProcessWithEmptyFile) {
|
|||||||
EXPECT_EQ(result, "emptyfileuser");
|
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 showing multiple expectations
|
||||||
TEST_F(ProcessMockTest, MultipleFileOperations) {
|
TEST_F(ProcessMockTest, MultipleFileOperations) {
|
||||||
using ::testing::_;
|
using ::testing::_;
|
||||||
|
|||||||
Reference in New Issue
Block a user