feat!: universal redesign — drop Minecraft stack, single-crate architecture

- remove Java plugins (velocity/paper), dashboard, all MC-specific code
  (handshake, death_code, varint, hostname-HMAC); available in history pre-v0.2
- merge crates/* into one package with src/bin/{rampart,rampart-manager,rampart-cli}
- ProtocolHandler trait + registry (no implementations yet), universal PoW kept
- XDP: universal L3/L4 filter (xdp/core/) + pluggable hook API (xdp/hooks/),
  fix IPv6 saddr bug; clang build verified
- docs: bilingual knowledge base (docs/kb/: attacks x4, defense-levels,
  practice x3), rewrite README/architecture for universal concept
- TODO.md v4.0: <=300-line module limit, competitor benchmark section (ref/)
- deploy/CI/docs cleanup: no MC references, new binary names

cargo build/clippy(-D warnings)/test green (55 tests)
This commit is contained in:
loki5512344 2026-08-24 01:50:22 +02:00
parent 0b53ed720b
commit 15f474486a
Signed by: boba
GPG key ID: 253067914055423B
179 changed files with 5044 additions and 11519 deletions

View file

@ -25,8 +25,13 @@ jobs:
- run: cargo fmt --all --check
- run: cargo clippy --all-targets --all-features -- -D warnings
- run: cargo check --all-features
- run: cargo build --features xdp --bin rampart-core
- run: test -f target/debug/rampart-core
- name: XDP — build binaries
run: cargo build --features xdp --bins
- name: XDP — smoke-check BPF compilation (clang)
run: |
mkdir -p target/xdp
clang -O2 -g -target bpf -c xdp/core/universal_filter.c -o target/xdp/universal_filter.o
test -f target/xdp/universal_filter.o
rust-test:
name: Rust — test
@ -46,54 +51,13 @@ jobs:
with:
command: check
java-build:
name: Java — build plugins
runs-on: ubuntu-latest
defaults:
run:
working-directory: plugins
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: "21"
distribution: "temurin"
cache: "gradle"
- run: ./gradlew build
- uses: actions/upload-artifact@v4
with:
name: rampart-java-plugins
path: |
plugins/velocity/build/libs/*.jar
plugins/paper/build/libs/*.jar
dashboard-build:
name: Dashboard — build
runs-on: ubuntu-latest
defaults:
run:
working-directory: dashboard
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
cache-dependency-path: dashboard/package-lock.json
- run: npm ci
- run: npm run build
docker:
name: Docker — build images
name: Docker — build edge image
runs-on: ubuntu-latest
needs: [rust-test, java-build]
needs: [rust-test]
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: actions/download-artifact@v4
with:
name: rampart-java-plugins
path: plugins/
- name: Build edge
uses: docker/build-push-action@v6
with:
@ -101,19 +65,3 @@ jobs:
file: deploy/docker/Dockerfile.edge
load: true
tags: rampart/edge:ci
- name: Build velocity
uses: docker/build-push-action@v6
with:
context: .
file: deploy/docker/Dockerfile.velocity
load: true
tags: rampart/velocity:ci
- name: Download Paper jar
run: bash deploy/docker/download-paper.sh
- name: Build paper
uses: docker/build-push-action@v6
with:
context: .
file: deploy/docker/Dockerfile.paper
load: true
tags: rampart/paper:ci

20
.gitignore vendored
View file

@ -34,24 +34,7 @@ Thumbs.db
*.so
*.d
# Java / Gradle
plugins/*/build/
plugins/*/.gradle/
plugins/*/bin/
plugins/*/.classpath
plugins/*/.project
plugins/*/.settings/
plugins/*/.factorypath
plugins/.gradle/
plugins/.project
plugins/.settings/
plugins/build/
*.jar
!plugins/gradle/wrapper/gradle-wrapper.jar
# Dashboard build
dashboard/dist/
dashboard/node_modules/
# Eclipse / IDE
.classpath
@ -62,6 +45,3 @@ bin/
# Reference projects (cloned for research)
ref/
# Paper jar cache
deploy/docker/downloads/

102
Cargo.lock generated
View file

@ -333,56 +333,6 @@ dependencies = [
"libc",
]
[[package]]
name = "crossbeam"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1137cd7e7fc0fb5d3c5a8678be38ec56e819125d8d7907411fe24ccb943faca8"
dependencies = [
"crossbeam-channel",
"crossbeam-deque",
"crossbeam-epoch",
"crossbeam-queue",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-channel"
version = "0.5.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-queue"
version = "0.3.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.22"
@ -427,7 +377,6 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
"crypto-common",
"subtle",
]
[[package]]
@ -639,15 +588,6 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hmac"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
dependencies = [
"digest",
]
[[package]]
name = "http"
version = "1.4.2"
@ -1349,29 +1289,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rampart-cli"
version = "0.2.0"
dependencies = [
"anyhow",
"clap",
"reqwest",
"serde",
"serde_json",
"tokio",
"tracing",
]
[[package]]
name = "rampart-core"
version = "0.2.0"
name = "rampart"
version = "0.3.0-dev"
dependencies = [
"anyhow",
"axum",
"chrono",
"crossbeam",
"clap",
"dashmap",
"futures",
"hex",
"hmac",
"jsonwebtoken",
"libbpf-rs",
"libc",
"maxminddb",
@ -1388,26 +1316,6 @@ dependencies = [
"tokio",
"tokio-splice",
"toml",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "rampart-manager"
version = "0.2.0"
dependencies = [
"anyhow",
"axum",
"chrono",
"dashmap",
"jsonwebtoken",
"prometheus",
"redis",
"serde",
"serde_json",
"subtle",
"thiserror 2.0.19",
"tokio",
"tower-http",
"tracing",
"tracing-subscriber",

View file

@ -1,14 +1,24 @@
[workspace]
resolver = "2"
members = ["crates/rampart-core", "crates/rampart-manager", "crates/rampart-cli"]
[workspace.package]
version = "0.2.0"
[package]
name = "rampart"
version = "0.3.0-dev"
edition = "2024"
license = "GPL-3.0-only"
authors = ["loki"]
description = "Universal L3/L4/L7 network protection platform"
[workspace.lints.clippy]
[[bin]]
name = "rampart"
path = "src/bin/rampart.rs"
[[bin]]
name = "rampart-manager"
path = "src/bin/rampart-manager.rs"
[[bin]]
name = "rampart-cli"
path = "src/bin/rampart-cli.rs"
[lints.clippy]
# Deny — критически важные для безопасности и стабильности
type_complexity = "allow"
unwrap_used = "deny"
@ -19,9 +29,19 @@ print_stderr = "deny"
wildcard_imports = "deny"
exit = "deny"
# expect разрешён — используется в prometheus метриках при старте
# cast разрешён — неизбежен в сетевом/MC протоколе
# cast разрешён — неизбежен в сетевых протоколах
[workspace.dependencies]
[features]
default = ["store-redis"]
store-redis = ["dep:redis"]
geoip = ["dep:maxminddb"]
xdp = ["dep:libbpf-rs", "dep:libc"]
io-uring = ["dep:tokio-splice"]
# Резерв под реализацию HTTP-протокольного обработчика (модель plugin-by-feature).
# Реализаций пока нет: реестр протоколов пуст и edge-нода требует явного флага.
protocol-http = []
[dependencies]
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
@ -30,16 +50,24 @@ tracing-subscriber = { version = "0.3", features = ["json", "env-filter"] }
thiserror = "2"
anyhow = "1"
dashmap = "6"
crossbeam = "0.8"
hex = "0.4"
sha2 = "0.10"
hmac = "0.12"
subtle = "2"
socket2 = "0.5"
socket2 = { version = "0.5", features = ["all"] }
futures = "0.3"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
prometheus = { version = "0.14", features = ["process"] }
toml = "0.8"
rand = { version = "0.8", default-features = false, features = ["std", "std_rng"] }
clap = { version = "4", features = ["derive"] }
chrono = { version = "0.4", features = ["serde"] }
axum = "0.8"
tower-http = { version = "0.6", features = ["cors"] }
jsonwebtoken = "9"
redis = { version = "0.27", optional = true, features = ["tokio-comp", "connection-manager"] }
maxminddb = { version = "0.30", optional = true }
tokio-splice = { version = "0.2", optional = true }
libbpf-rs = { version = "0.24", optional = true }
libc = { version = "0.2", optional = true }

View file

@ -2,13 +2,10 @@
CARGO = cargo
TARGET_DIR = target
GRADLE = ./gradlew
.PHONY: all build test check fmt clippy clean release
.PHONY: deny audit ebpf
.PHONY: plugins paper velocity
.PHONY: all build release test check check-all fmt fmt-check clippy clean deny audit
.PHONY: ebpf ebpf-clean
.PHONY: docker docker-build docker-up docker-down docker-logs
.PHONY: dash dash-dev dash-build
.PHONY: ci ci-full
all: check test build
@ -52,37 +49,16 @@ audit:
ebpf:
@echo "Building XDP/eBPF filter..."
cd xdp && clang -O2 -target bpf -c xdp_filter.c -o xdp_filter.o 2>/dev/null || \
mkdir -p $(TARGET_DIR)/xdp && \
clang -O2 -g -target bpf -c xdp/core/universal_filter.c -o $(TARGET_DIR)/xdp/universal_filter.o 2>/dev/null || \
echo "WARNING: clang+bpf not installed, skipping eBPF build"
ebpf-clean:
rm -f xdp/xdp_filter.o
# --- Java Plugins ---
plugins:
cd plugins && $(GRADLE) build
paper:
cd plugins && $(GRADLE) :paper:build
velocity:
cd plugins && $(GRADLE) :velocity:build
# --- Dashboard ---
dash-install:
cd dashboard && npm ci
dash-dev:
cd dashboard && npm run dev
dash-build:
cd dashboard && npm run build
rm -f $(TARGET_DIR)/xdp/universal_filter.o
# --- Docker ---
docker-build: plugins
docker-build:
docker compose -f deploy/docker-compose.yml build
docker-up: docker-build
@ -102,5 +78,5 @@ checkstyle: fmt-check clippy
ci: checkstyle test build deny
@echo "✓ CI passed"
ci-full: ci plugins dash-build
ci-full: ci ebpf
@echo "✓ Full CI passed"

224
README.md
View file

@ -10,13 +10,12 @@
# Rampart
6-layer DDoS protection for Minecraft servers.
Universal network protection platform (L3/L4/L7 DDoS filtering framework)
![Rust](https://img.shields.io/badge/Rust-000000?style=flat-square&logo=rust&logoColor=white)
![Java](https://img.shields.io/badge/Java_21-ED8B00?style=flat-square&logo=openjdk&logoColor=white)
![eBPF](https://img.shields.io/badge/eBPF/XDP-FF6C37?style=flat-square&logo=linux&logoColor=white)
![eBPF](https://img.shields.io/badge/C/eBPF%20XDP-FF6C37?style=flat-square&logo=linux&logoColor=white)
![License](https://img.shields.io/badge/license-GPLv3-blue?style=flat-square&logo=gnu&logoColor=white)
![Version](https://img.shields.io/badge/version-0.2.0-green?style=flat-square)
![Version](https://img.shields.io/badge/version-0.3.0--dev-green?style=flat-square)
![Status](https://img.shields.io/badge/status-development-yellow?style=flat-square)
[English](#english) | [Русский](#russian)
@ -31,95 +30,96 @@
### Overview
Rampart filters traffic at kernel level (XDP/eBPF), network level (PoW challenge), and application level (Rust + Java) before it reaches game servers.
Rampart filters traffic at three levels before it reaches your service:
### 6-Layer Architecture
- **Kernel level** — XDP/eBPF drops unwanted packets before they reach the Linux TCP stack;
- **Network level** — PoW challenge raises the cost of connection floods for any TCP protocol;
- **Application level** — Rust userspace core performs L7 handshake analysis, rate limiting and reputation checks.
Protocol-specific logic lives in **modular protocol plugins**, so the same platform protects VDS, web services, and game servers alike.
### Architecture
```
Layer 1: XDP/eBPF (C) TCP state machine, SYN throttle, blacklist, UDP drop
Layer 2: PoW Challenge (Rust) SHA256 hashcash, dynamic difficulty, anti-handshake-flood
⚠️ OFF by default: the current text-challenge protocol is incompatible with
vanilla clients, which cannot solve it — enable only with a client mod.
Layer 3: Rust Core MC handshake parse, HMAC sign, rate limit, death code
Layer 4: Velocity (Java) Domain whitelist, HMAC verify, physics check, CAPTCHA
Layer 5: Paper Agent (Java) Redis heartbeat, auto-registration
Layer 6: Traffic Intel EWMA thresholds, 168h profiling, reputation
┌────────────────────────────────────────────────────────────────────┐
│ Layer 1: Kernel / XDP (C) │
│ universal TCP state machine · SYN throttle · CIDR black/white │
│ lists · UDP policy · pluggable BPF protocol hooks │
├────────────────────────────────────────────────────────────────────┤
│ Layer 2: Universal PoW Challenge (Rust) │
│ SHA256 hashcash · dynamic difficulty │
│ works over any TCP protocol ⚠️ OFF by default │
├────────────────────────────────────────────────────────────────────┤
│ Layer 3: Userspace Core (Rust) │
│ L7 handshake analysis · rate limit · HMAC · death-code patterns │
├────────────────────────────────────────────────────────────────────┤
│ Layer 4: Protocol Plugins (feature crates) │
│ minecraft (first plugin) · http (planned) · grpc (planned) │
└────────────────────────────────────────────────────────────────────┘
Traffic Intel (EWMA thresholds, profiling, reputation)
runs across all layers
```
```
Атакующий → [XDP/eBPF] → [PoW] → [Rust Core] → [Velocity] → Game Server
1 2 3 4
Attacker → [XDP/eBPF] → [PoW] → [Userspace Core] → [Plugin] → Your Service
1 2 3 4
```
### Components
| Component | Role | Stack |
|-----------|------|-------|
| **rampart-core** | Edge node - layers 2+3 | Rust (tokio, socket2, prometheus) |
| **rampart-manager** | Management API + Redis sync | Rust (axum, jsonwebtoken, redis) |
| **rampart-core** | Edge engine: XDP loader, PoW challenge, L7 filtering, traffic intel | Rust (tokio, libbpf) + C (XDP) |
| **rampart-manager** | Management API + Redis sync | Rust (axum, redis) |
| **rampart-cli** | CLI tool for operators | Rust (clap) |
| **velocity-plugin** | Layer 4 - domain, HMAC, physics, router | Java 21 (Velocity API) |
| **paper-plugin** | Layer 5 - Redis heartbeat, auto-reg | Java 21 (Paper API) |
| **dashboard** | Web UI - servers, blacklist, nodes | React + Vite + TypeScript |
| **protocol plugins** | Protocol-aware filtering as feature crates | Rust |
| ↳ `minecraft` | First plugin (MC handshake analysis) | Rust |
| ↳ `http`, `grpc` | Planned | Rust |
| **docs/kb** | Bilingual knowledge base: attack anatomy, defense levels, practice guides | Markdown |
### Performance
Tested on Hetzner CX31 (4 vCPU, 8GB, KVM), Ubuntu 22.04, kernel 5.15
| Mode | New conn/s | Active conn | CPU |
|------|-----------|-------------|-----|
| 4 core, epoll | 80k | 200k | ~65% |
| 4 core, io_uring | 110k | 260k | ~48% |
| XDP drop (generic) | 3-5M pps | - | ~25% |
| XDP drop (native) | 15-20M pps | - | ~15% |
Note: Real L7 throughput (handshake + HMAC + rate limit): ~60-70k conn/s (epoll), ~85-95k (io_uring).
#### VDS stress test (2026-08-04) — edge-only, loopback
VDS 2 vCPU / 3.8GB / Ubuntu 22.04, Docker bridge. Edge-only (слои 1–3), без Redis/Velocity/Paper.
Атака маскировалась под обычный трафик: 100 source IP, валидные Minecraft handshake.
Подробности: [load-test-report.md](docs/research/load-test-report.md), скрипты: [deploy/test/stress](deploy/test/stress).
Confirmed numbers only — VDS stress test ([load-test-report.md](docs/research/load-test-report.md)), edge-only setup on loopback, 2 vCPU:
| Scenario | Result |
|----------|--------|
| Raw L7 throughput (valid handshake → HMAC → backend) | ~4k conn/s proxied, 100% (121.5k/30s; edge CPU ~179%, 2 cores) |
| Defense vs masked 100-IP flood (default 5 pps/IP) | **99.6% blocked** (528 allowed vs 119,376 blocked), CPU ~32% |
| Legit clients during attack | 5/5 OK, RTT 2.2–5.8ms |
| SYN flood (no XDP) | 0 impact — handled by kernel |
| Active connections | 300 held trivially (CPU ~0%, 7MB); limit is backend/fd, not edge |
| Raw L7 throughput | ~4k conn/s proxied |
| Masked 100-IP handshake flood (default 5 pps/IP) | **99.6% blocked**, legit clients OK |
| SYN flood without XDP | 0 impact — handled by the kernel |
Full benchmark suite in progress.
### Quick Start
```bash
# Build Rust components
# Build
cargo build --release
# Create config
mkdir -p /etc/rampart
rampart config init > /etc/rampart/config.toml
# Run edge node
./target/release/rampart-core --config /etc/rampart/config.toml
# Java plugins
cd plugins && ./gradlew build
```
### Documentation
| File | Description |
| Path | Description |
|------|-------------|
| [architecture](docs/research/architecture.md) | 6-layer architecture, components, ADRs |
| [anti-bot](docs/research/anti-bot.md) | Bot detection, PoW, fingerprinting, known issues |
| [ebpf](docs/research/ebpf.md) | XDP/eBPF: TCP state machine, maps, fixes |
| [ddos](docs/research/ddos.md) | Attack vectors, L3/L4/L7, AI bots |
| [deployment](docs/deployment.md) | Step-by-step deployment guide |
| [configuration](docs/configuration.md) | Configuration examples |
| [networking](docs/research/networking.md) | WireGuard, BGP Anycast, QUIC |
| [runbook](docs/runbook.md) | Operations runbook |
| [disaster_recovery](docs/disaster_recovery.md) | Failover scenarios |
| [troubleshooting](docs/troubleshooting.md) | FAQ and diagnostics |
| [docs/kb/](docs/kb/) | Knowledge base: attack anatomy, defense levels, practice guides |
| [docs/research/architecture.md](docs/research/architecture.md) | Layered architecture, ADRs, migration notes |
| [docs/research/load-test-report.md](docs/research/load-test-report.md) | VDS stress test report (2026-08-04) |
| [docs/research/](docs/research/) | Research notes: eBPF, anti-bot, DDoS vectors, networking |
| [docs/deployment.md](docs/deployment.md) | Deployment guide |
| [docs/configuration.md](docs/configuration.md) | Configuration reference |
| [docs/runbook.md](docs/runbook.md) | Operations runbook |
### Roadmap
- Stabilize the protocol plugin API
- BPF hook modules for deep protocol parsing in XDP
- Terminal UI (ratatui TUI)
- HTTP protocol plugin
---
@ -129,86 +129,96 @@ cd plugins && ./gradlew build
### Обзор
Rampart — 6-слойная система DDoS-защиты для Minecraft. Фильтрует трафик на уровне ядра (XDP/eBPF), уровне сети (PoW), уровне приложений (Rust) и уровне прокси (Velocity).
Rampart фильтрует трафик на трёх уровнях до того, как он дойдёт до вашего сервиса:
### 6 слоёв защиты
- **Уровень ядра** — XDP/eBPF отбрасывает нежелательные пакеты до того, как они попадут в TCP-стек Linux;
- **Сетевой уровень** — PoW-challenge повышает стоимость флуда соединений для любого TCP-протокола;
- **Прикладной уровень** — userspace-ядро на Rust выполняет анализ L7-handshake, rate limiting и проверку репутации.
Логика, специфичная для протоколов, вынесена в **модульные протокол-плагины** — одна платформа защищает VDS, веб-сервисы и игровые серверы.
### Архитектура
```
Слой 1: XDP/eBPF (C) TCP state machine, SYN throttle, blacklist, UDP drop
Слой 2: PoW Challenge (Rust) SHA256 hashcash, dynamic difficulty
⚠️ ВЫКЛЮЧЕН по умолчанию: текущий text-challenge несовместим с ванильными
клиентами (они не умеют его решать) — включать только с клиентским модом.
Слой 3: Rust Core MC handshake, HMAC sign, rate limit, death code
Слой 4: Velocity (Java) Domain whitelist, HMAC verify, physics, CAPTCHA
Слой 5: Paper Agent (Java) Redis heartbeat, auto-registration
Слой 6: Traffic Intel EWMA thresholds, 168h profiling, reputation
┌────────────────────────────────────────────────────────────────────┐
│ Слой 1: Ядро / XDP (C) │
│ универсальный TCP state machine · SYN throttle · CIDR black/ │
│ white списки · UDP policy · подключаемые BPF протокол-хуки │
├────────────────────────────────────────────────────────────────────┤
│ Слой 2: Универсальный PoW Challenge (Rust) │
│ SHA256 hashcash · dynamic difficulty │
│ работает поверх любого TCP-протокола ⚠️ ВЫКЛЮЧЕН по умолчанию │
├────────────────────────────────────────────────────────────────────┤
│ Слой 3: Userspace Core (Rust) │
│ L7 handshake analysis · rate limit · HMAC · death-code паттерны │
├────────────────────────────────────────────────────────────────────┤
│ Слой 4: Протокол-плагины (feature crates) │
│ minecraft (первый плагин) · http (в планах) · grpc (в планах) │
└────────────────────────────────────────────────────────────────────┘
Traffic Intel (EWMA thresholds, профилирование, репутация)
работает поперёк всех слоёв
```
```
Атакующий → [XDP] → [PoW] → [Rust] → [Velocity] → Game Server
1 2 3 4
Атакующий → [XDP/eBPF] → [PoW] → [Userspace Core] → [Плагин] → Ваш сервис
1 2 3 4
```
### Компоненты
| Компонент | Роль | Технологии |
|-----------|------|------------|
| **Edge нода** | Слои 1-3: XDP + PoW + фильтрация | Rust + XDP/eBPF |
| **Manager** | Слой 6: API + мониторинг | Rust (Axum) |
| **Velocity** | Слой 4: прокси, верификация | Java 21 |
| **Paper Agent** | Слой 5: регистрация сервера | Java 21 |
| **Dashboard** | Web UI | React + TypeScript |
| **rampart-core** | Edge-движок: XDP loader, PoW challenge, L7-фильтрация, traffic intel | Rust (tokio, libbpf) + C (XDP) |
| **rampart-manager** | Management API + Redis sync | Rust (axum, redis) |
| **rampart-cli** | CLI для операторов | Rust (clap) |
| **Протокол-плагины** | Протоколозависимая фильтрация в виде feature crates | Rust |
| ↳ `minecraft` | Первый плагин (анализ MC-handshake) | Rust |
| ↳ `http`, `grpc` | В планах | Rust |
| **docs/kb** | Двуязычная база знаний: анатомия атак, уровни защиты, практические руководства | Markdown |
### Защита от атак
### Производительность
| Атака | Метод защиты | Слой |
|-------|-------------|------|
| SYN flood | XDP дроп + SYN throttle | 1 |
| Handshake flood | PoW challenge + rate limit | 2+3 |
| Slow Loris | Timeout 5 сек | 3 |
| VarInt overflow | Строгий bounds check | 3 |
| Death code | Auto-ban по малициозным пакетам | 3 |
| Direct IP | Domain whitelist | 4 |
| Подмена hostname | HMAC-SHA256 подпись | 3+4 |
| Боты (физика) | Falling check + Vehicle check | 4 |
| AI-боты | PoW (CPU cost) + reputation | 2+6 |
Только подтверждённые числа — VDS stress test ([load-test-report.md](docs/research/load-test-report.md)), edge-only на loopback, 2 vCPU:
> **Примечание:** Layer 2 (PoW) **выключен по умолчанию** (`pow.enabled = false`) из-за
> несовместимости с ванильными клиентами: текстовый challenge отправляется до handshake,
> и ванильный клиент не умеет его решать — при включении никто не сможет зайти.
> Включать только после появления клиентского мода или PoW, совместимого с протоколом Minecraft.
| Сценарий | Результат |
|----------|-----------|
| Raw L7 пропускная способность | ~4k conn/s проксировано |
| Маскированный flood с 100 IP (default 5 pps/IP) | **99.6% заблокировано**, легитимные клиенты в порядке |
| SYN flood без XDP | 0 влияния — обрабатывается ядром |
Полный набор бенчмарков в процессе подготовки.
### Быстрый старт
```bash
# Сборка Rust компонентов
# Сборка
cargo build --release
# Создание конфига
mkdir -p /etc/rampart
rampart config init > /etc/rampart/config.toml
# Запуск edge ноды
./target/release/rampart-core --config /etc/rampart/config.toml
# Сборка Java плагинов
cd plugins && ./gradlew build
```
### Документация
| Файл | Описание |
| Путь | Описание |
|------|----------|
| [architecture](docs/research/architecture.md) | 6-слойная архитектура, компоненты, ADR |
| [anti-bot](docs/research/anti-bot.md) | Антибот: PoW, fingerprinting, известные проблемы |
| [ebpf](docs/research/ebpf.md) | XDP/eBPF: TCP state machine, карты, исправления |
| [ddos](docs/research/ddos.md) | Векторы атак, L3/L4/L7, AI-боты |
| [deployment](docs/deployment.md) | Пошаговый деплой |
| [configuration](docs/configuration.md) | Примеры конфигов |
| [networking](docs/research/networking.md) | WireGuard, BGP, QUIC |
| [runbook](docs/runbook.md) | Инструкции для админа |
| [disaster_recovery](docs/disaster_recovery.md) | Failover сценарии |
| [troubleshooting](docs/troubleshooting.md) | FAQ и диагностика |
| [docs/kb/](docs/kb/) | База знаний: анатомия атак, уровни защиты, практические руководства |
| [docs/research/architecture.md](docs/research/architecture.md) | Слоистая архитектура, ADR, миграционные заметки |
| [docs/research/load-test-report.md](docs/research/load-test-report.md) | Отчёт по VDS stress test (2026-08-04) |
| [docs/research/](docs/research/) | Research-заметки: eBPF, антибот, векторы DDoS, сети |
| [docs/deployment.md](docs/deployment.md) | Руководство по деплою |
| [docs/configuration.md](docs/configuration.md) | Справочник конфигурации |
| [docs/runbook.md](docs/runbook.md) | Операционный runbook |
### Roadmap
- Стабилизация API протокол-плагинов
- BPF hook модули для глубокого парсинга протоколов в XDP
- Терминальный интерфейс (ratatui TUI)
- HTTP протокол-плагин
---

409
TODO.md
View file

@ -1,6 +1,10 @@
# Rampart — Development TODO & Roadmap
> Живой документ. Философия: **KISS → DRY → SOLID → YAGNI**.
>
> v0.3.0-dev — big-bang редизайн: из Minecraft-специфичной защиты в **универсальную
> L3/L4/L7 платформу сетевой защиты**. MC-код, Java-плагины и dashboard удалены
> (доступны в git-истории до тега v0.2.0).
---
@ -8,7 +12,7 @@
### KISS
- Не добавляй абстракцию до третьего повторения.
- Функция ≤ 60 строк, модуль ≤ 500 строк.
- **Функция ≤ 60 строк, модуль ≤ 300 строк** (жёсткий лимит; больше — декомпозиция).
- Не используй generics где хватит `&str` и `Vec<u8>`.
### DRY
@ -16,18 +20,18 @@
### SOLID (Rust)
- **S**: один файл = одна ответственность
- **O**: расширяй через трейты
- **O**: расширяй через трейты (`ProtocolHandler`, `Filter`, `StateStore`)
- **L**: `dyn Filter` — любая реализация без side effects
- **I**: маленькие трейты вместо одного `ShieldTrait`
- **D**: core зависит от `trait StateStore`, не от Redis
- **I**: маленькие трейты вместо одного God-trait
- **D**: core зависит от trait-ов, не от Redis/ClickHouse напрямую
### YAGNI
- Не пиши BGP до v0.6, K8s Operator до v0.5
- Не добавляй feature flag если фича не готова
- Не пиши BGP до v0.6, WASM-плагины до стабилизации compile-time API.
- Не добавляй feature flag если фича не готова.
### Rust-специфичные
1. `unwrap()` — только в main() и тестах
2. `unsafe` — только в xdp/, комментарий обязателен
2. `unsafe` — только в xdp/, комментарий `// SAFETY:` обязателен
3. `clone()` осознанно, профилируй hot path
4. Блокирующие операции → `spawn_blocking`
5. Логи: `tracing::info!` / `debug!` / `error!`
@ -35,287 +39,190 @@
---
## 1. Текущее состояние (v0.2+)
### ✅ Готово
| Компонент | Статус |
|-----------|--------|
| **rampart-core** (Rust Edge) | ~85% — работает: TCP listener, handshake parse, HMAC sign, rate limit, death code (8 паттернов), blacklist (DashMap + TTL), Redis sync, Prometheus metrics, graceful shutdown |
| **rampart-manager** (Rust API) | ~80% — работает: JWT auth, CRUD blacklist, servers/nodes list, heartbeat мониторинг |
| **rampart-cli** (Rust CLI) | ~40% — 3/6 команд (status, doctor, blacklist list/add) |
| **velocity-plugin** (Java) | ~90% — domain whitelist, HMAC verify (constant-time), Redis server registry (delta-sync), TPS-aware load balancer (circuit breaker < 12 TPS) |
| **paper-plugin** (Java) | ~90% — Redis heartbeat (TPS/online/sec), auto-registration, graceful shutdown |
| **dashboard** (React/TS) | ~85% — login, Servers/Blacklist/Nodes таблицы, auto-refresh, dark theme |
| **CI/CD** | GitHub Actions (Rust check+test+clippy+deny + Java build + Dashboard build + Docker), Makefile, deny.toml |
| **Docs** | ~80% — architecture, anti-bot, ebpf, ddos, deployment, configuration |
| **Ref analysis** | Проанализированы Sonar, LimboFilter, AtomGuard, Infrarust, MC-XDP-eBPF, PowGo |
| **ref/ в .gitignore** | Добавлено |
### ❌ Не начато / частично
| Компонент | Статус |
|-----------|--------|
| **Аудит-фикс v0.3** | **открыт** — все P0/P1/P2 из code review 2026-08 (см. сек. 2) |
| **GeoIP/ASN reputation** | 0% — enum есть, реализации нет |
| **Bloom filter blacklist** | 0% |
### ⚠️ Ревизия статусов (после аудита 2026-08)
Прошлые строки «XDP 0%» / «PoW 0%» были устаревшими: код уже написан, но **не докатан**.
Реальное состояние (подробности — в сек. 2):
| Компонент | Реальность |
|-----------|------------|
| **xdp_filter.c + loader** | ~написан, но: не собирается в Docker/CI (feature `xdp` не в default), баги IPv6 (`daddr` вместо `saddr`), dead-код в `DIRECT_READ_LOGIN` |
| **PoW (Layer 2)** | ~написан, но **ломает ванильных клиентов** — по умолчанию никто не войдёт (P0) |
| **Layer 6 (Traffic Intel)** | написан, но **не подключён** ни в один hot path (мёртвый код) |
| **ClickHouse + Grafana** | врайтер написан, вызовов `push()` нет — мёртвый код |
| **Velocity physics** | написан, но **фейк**: не читает позиции, проверка по времени между событиями |
| **CAPTCHA** | написан, но `challenge()` нигде не вызывается — мёртвый код |
---
## 2. Аудит-фикс v0.3 (code review 2026-08) — закрыть до релиза
> Полный список минусов из ревью. Философия: «мёртвый код = баг», «по умолчанию безопасно».
### P0 — Showstopper (блокируют релиз)
- [x] **PoW совместимость с ванильными клиентами.** Решение (a): **PoW off по умолчанию** (`config.rs`), код PoW сохранён, включение только с клиентским модом или MC-совместимым PoW. README + docs + `deploy/config/edge.toml` обновлены. Follow-up (клиентский мод / PoW поверх MC) — в backlog.
- [x] **`API_PASSWORD` без дефолта.** Fail-fast при старте (нет env или `changeme` → ошибка), constant-time сравнение (`subtle`), rate-limit 5/60с на `/api/v1/auth/login` (429), `CorsLayer::permissive()` → `CORS_ORIGIN` из env. Неверный пароль → 401.
- [x] **Чтение полного кадра.** `read_full_frame()` в `tunnel.rs`: накопление по varint-длине, лимит 8192, таймаут, EOF/ошибки → death-code path.
- [x] **IPv6.** Полная поддержка: rate-limit/blacklist/whitelist переведены на `IpAddr` (DashMap<IpAddr>), `redis.rs` парсит через `IpAddr::parse`, whitelist валидируется на старте. Попутно исправлен overflow-panic в redis.rs (octet > 255). XDP остаётся IPv4-only (задокументировано).
### P1 — Безопасность
- [ ] **HMAC**: nonce/timestamp + TTL в подпись; реализовать ротацию ключей (dual-key) и задействовать `key_rotation_interval_secs` (сейчас мёртвый конфиг). Детерминированная подпись = вечная утечка.
- [ ] **RateLimiter**: TTL-эвикция idle bucket'ов (фоновый sweep) + cap размера карты — иначе ботнет съест память.
- [ ] **Blacklist**: вызывать `clear_expired()` по таймеру (сейчас мёртвый код).
- [ ] **Redis IP-parse**: валидировать октеты ≤ 255 (`redis.rs:95`) — сейчас `(ip_u32<<8)|octet` с октетом >255 даёт **panic** в debug.
- [ ] **JWT**: валидация ролей/audience, secret ≥ 32 байт, rate-limit на login.
### P1 — Целостность слоёв
- [ ] **XDP в Docker/CI**: собирать `rampart-core --features xdp`, clang+libbpf в builder-образ, smoke-attach в CI.
- [ ] **Синхронизация blacklist Rust ↔ XDP**: `XdpFilter::ban_ip` вызывать при death-code бане; TTL из конфига, не хардкод 300с.
- [ ] **Подключить Layer 6** (`AttackDetector`/`IpReputation`/`TrafficProfiler`) в hot path: метрики, reputation-скоринг, auto-ban.
- [ ] **Подключить ClickHouse**: реальные `push()` из hot path + flush task + таблица (сейчас мёртвый код).
- [ ] **CAPTCHA**: вызвать `challenge()` на входе ИЛИ удалить (сейчас мёртвый код; `markVerified`/`verifiedPlayers` пишутся, но не читаются).
- [ ] **`routeServer(domain)`**: реализовать доменную маршрутизацию по `ServerInfo` (сейчас параметр игнорируется, только round-robin).
### P2 — Баги и долг
- [ ] **XDP IPv6**: `src_ip = ip6->daddr` (`xdp_filter.c:141`) → исправить на `saddr`; иначе whitelist/blacklist/flow-ключи по чужому IP.
- [ ] **XDP seq-трекинг**: пересмотреть `expected_seq`; убрать dead-код в `DIRECT_READ_LOGIN` (`login_consumed < (end-cursor)` всегда false).
- [ ] **Порядок фильтров**: rate limit ДО PoW (сейчас PoW-работа тратится на rate-limited IP); убрать двойной `check()` на соединение (съедает 2 токена).
- [ ] **`std::sync::Mutex` в async** (`DifficultyAdjuster` в `tunnel.rs`) → `tokio::sync::Mutex`/атомика; whitelist-сравнение по строке → пре-парс IP/CIDR.
- [ ] **`replace_hostname`**: проверка длины подписанного hostname ≤ 255 (добавка сигнатуры выбивает длинные домены).
- [ ] **Physics**: переделать на реальные данные позиций или удалить фейковый falling check; «re-verify» должен реально что-то проверять, а не дисконнектить.
- [ ] **Redis**: `KEYS` → `SCAN` (manager + `ServerRegistry`), TTL на ключи серверов (иначе мусор копится), **reconnect** pubsub-подписчика (сейчас умирает навсегда).
### P2 — Мёртвый код / конфиг
- [ ] Удалить или использовать: `max_connections_per_ip`, `rate_limit_status_pps`, `logging.level/format`, `ACTIVE_CONNECTIONS`, `BLACKLIST_SIZE`, `io-uring`/`tokio-splice`, `ClickHouseWriter` без вызовов.
- [ ] **Manager blacklist**: хранить reason/created/expires, применять `duration_secs` (сейчас фабрикуются фейковые поля).
- [ ] **CLI**: `drain`/`emergency` из заглушек → реальная логика или явный `unimplemented`.
- [ ] **README**: убрать неподтверждённые цифры (io_uring 110k, XDP 15–20M pps), привести в соответствие коду и TODO.
**DoD этапа 0:** все P0 закрыты, P1/P2 закрыты или явно задекларированы как «позже с issue», `cargo test` + `cargo clippy -D warnings` + Java build + Docker (с XDP) зелёные.
---
## 3. Anti-Regression — как не допускать
> Каждая фича обязана пройти чеклист ниже. Мёртвый код, «бумажные слои» и дефолт-секреты = reject на ревью.
### Правила
1. **No dead code**: каждый `pub` в prod-модуле имеет вызов вне `#[cfg(test)]`. Если компонент не вызывается — он не существует (CAPTCHA, ClickHouse, Layer 6).
2. **Config field = потребитель**: нет конфиг-поля без использования. Добавил поле — сразу потребитель (или не добавляй).
3. **Метрика регистрируется → обновляется**: каждый Gauge/Counter имеет единственного «writer»; ревью проверяет, что `inc`/`set` реально вызываются.
4. **Feature flag = сборка в CI**: любое `feature` собирается в CI (`--all-features` уже есть) и в Docker-образе. «Фича не в образе» = фичи нет.
5. **По умолчанию безопасно**: нет дефолтных секретов/паролей; отсутствие обязательного env = fail-fast, а не warn.
6. **Интеграционный тест на слой**: PoW+handshake (симуляция ванильного клиента), XDP attach smoke, Redis sync, router по домену.
7. **Парсеры читают полный кадр**: никогда «один read» для MC-пакета; неполный кадр = accumulate или отказ, но не молчаливый drop валидного клиента.
8. **Listener/Handler = вызывается**: новый Java-listener или Rust-модуль подключается в `main`/plugin `onEnable`, иначе reject.
9. **CI guardrails** (добавить в `.github/workflows/ci.yml`):
- [ ] `cargo clippy --all-targets --all-features -- -D warnings`
- [ ] `cargo test` (уже есть) + сборка XDP (`clang -target bpf`) + Docker build с `--features xdp`
- [ ] grep-проверка отсутствия дефолт-секретов: `changeme`, `password = "` в коде/конфигах
- [ ] Java build (уже есть) + `./gradlew test`
10. **README/TODO не врут**: каждое заявленное число/слой имеет ссылку на код или тест. Нет — не пишем.
---
## 4. 6-слойная архитектура (план)
## 1. Целевая структура (v0.3)
```
Layer 1: XDP/eBPF (C) TCP state machine, SYN throttle, blacklist, UDP drop
Layer 2: PoW Challenge (Rust) SHA256 hashcash, dynamic difficulty
Layer 3: Rust Core (Rust) MC handshake, HMAC sign, rate limit, death code
Layer 4: Velocity (Java) Domain whitelist, HMAC verify, physics, CAPTCHA
Layer 5: Paper Agent (Java) Redis heartbeat, auto-registration
Layer 6: Traffic Intel (Rust) EWMA thresholds, 168h profiling, reputation
guard/
├── Cargo.toml # ОДИН пакет rampart, features = ["protocol-http", ...]
├── src/
│ ├── bin/{rampart, rampart-manager, rampart-cli}.rs
│ ├── engine/ # listener, tunnel (generic TCP proxy), challenge (PoW)
│ ├── filter/ # blacklist, rate_limit, geo — trait Filter
│ ├── traffic/ # EWMA, detector, profiler, reputation, alert
│ ├── store/ # redis (+ trait StateStore)
│ ├── manager/ # api/, auth/, sync/
│ ├── cli/ # команды CLI
│ └── protocol/ # trait ProtocolHandler + registry (реализаций пока 0)
├── xdp/
│ ├── core/ # universal_filter.c + maps/stats/config/common.h
│ └── hooks/hook_api.h # контракт подключаемых BPF-протокол-хуков
├── tests/ # интеграционные
└── docs/ # kb/ (knowledge base) + research/ + ops-доки
```
---
## 1a. Статус после редизайна (2026-08-24)
## 5. Этапы разработки
### Этап 4: XDP/eBPF — Layer 1 (сейчас)
Цель: Написать полноценный XDP фильтр с TCP state machine, исправив баги Minecraft-XDP-eBPF.
- [x] **Изучен reference Minecraft-XDP-eBPF:**
- Найден **TCP handshake deadlock** (pure ACK drop)
- Найден **VarInt sign extension UB**
- Найдена **stale conntrack на RST/FIN**
- Найден **IPv6 bypass**
- Найдена **отсутствие LRU на player map**
- [x] `xdp/xdp_filter.c` — TCP state machine (465 строк):
- AWAIT_ACK → AWAIT_MC_HANDSHAKE → AWAIT_LOGIN → VERIFIED
- **Исправление:** pure ACK → PASS, не DROP
- **Исправление:** RST/FIN → удалять conntrack entry
- [x] `xdp/maps.h` — 6 BPF maps:
- `conntrack_map` (LRU_HASH, 16384)
- `player_connection_map` (LRU_HASH, 65535) — **LRU, не plain HASH**
- `connection_throttle` (LRU_HASH, 65535) — SYN throttle per-IP
- `blacklist_map` (LPM_TRIE, 100000) — CIDR blacklist
- `whitelist_map` (LPM_TRIE, 1000) — CIDR whitelist
- `stats_map` (PERCPU_ARRAY) — счетчики для Prometheus
- [x] `xdp/protocol.h` — парсеры Minecraft на C
- [x] `xdp/varint.h` — VarInt (без sign extension UB)
- [x] `xdp/config.h` — Runtime-конфигурация (volatile const)
- [x] **Rust loader** (`crates/rampart-core/src/xdp/mod.rs`):
- Загрузка .o через libbpf-rs
- Attach XDP к интерфейсу через `bpf_xdp_attach`
- `ban_ip` / `unban_ip` / `get_stats` методы
- [x] `build.rs` — компиляция .c → .o (clang -target bpf)
- [ ] Пропатчить глобальные переменные из config.toml
- [ ] Чтение ringbuf → blacklist events
- [ ] BPF stats → Prometheus интеграция
- [ ] **Тесты:**
- `hping3 -S --flood` → XDP дропает, CPU < 30%
- `iperf3` UDP flood → XDP дропает
- TCP handshake проверка: Minecraft клиент коннектится без задержки
**DoD:** SYN flood 1M pps дропается в XDP, TCP handshake без deadlock, CPU < 30%, CI собирает xdp_filter.o
| Что | Статус |
|-----|--------|
| plugins/ velocity+paper, dashboard/ | ✅ удалены (git-история) |
| crates/* → единый пакет `rampart` + src/bin | ✅ сделано |
| MC-код (handshake, death_code, varint, hostname-HMAC) | ✅ удалён полностью |
| PoW как универсальный hashcash (`engine/challenge.rs`) | ✅ сохранён |
| XDP: universal L3/L4 фильтр + hooks API | ✅ код готов, clang build OK |
| IPv6 баг в XDP (daddr→saddr) | ✅ исправлен |
| Knowledge Base docs/kb (attacks, defense-levels, practice) | ✅ написана, двуязычная |
| README + architecture.md под новую концепцию | ✅ переписаны |
| cargo build / clippy -D warnings / test | ✅ зелёные |
---
### Этап 2b: PoW Challenge — Layer 2 (после XDP)
## 2. Ближайшие задачи (v0.3)
- [ ] Challenge generator: случайный token + timestamp + difficulty
- [ ] Dynamic difficulty: 4 (спокойно) → 12 (атака) по CPS
- [ ] Nonce verification: SHA256(challenge + nonce) prefix check
- [ ] Одноразовый challenge (token + timestamp, max 30 сек)
- [ ] Интеграция в rampart-core: PoW перед HMAC handshake
- [ ] Тесты: PoW solver timing, nonce replay защита, dynamic adjustment
### Subnet-level detection (ботнет с ротацией IP)
- [ ] **XDP**: карта `prefix_stats` (LRU_HASH, ключ /24 v4 | /64 v6) — счётчики SYN/pps
per-префикс рядом с per-IP (референс: caddy-mitigator CIDR promotion, lnvps_fw carpet-bomb).
- [ ] **Detector**: префикс превышает порог при том что отдельные IP под лимитом
→ распределённая атака → флаг подсети.
- [ ] **Мягкая эскалация для подсетей**: monitor → strict limits → challenge → блок.
Хард-бан /24 только через challenge (CGNAT: за одним /24 легитимно живут сотни людей).
- [ ] Блок самой подсети — уже умеем: `blacklist_map` это LPM trie (CIDR из коробки).
**DoD:** Edge требует PoW перед handshake, бот не может флудить >50 handshake/сек
### Движок без протоколов — сделать полезным
- [ ] **Первый протокол-плагин**: `protocol-http` (feature) — минимальный HTTP/1.1
handshake-анализ (request line, заголовки, размер), чтобы edge-нода заработала
для веб-сервисов.
- [ ] **TCP-proxy режим**: generic upstream forwarding за ProtocolHandler
(tunnel.rs уже generic — проверить интеграцию).
- [ ] **Fail-fast сообщение** при пустом registry — улучшить текст подсказки сборки.
---
### Подключение мёртвого интеллекта (правило: «мёртвый код = баг»)
- [ ] Layer Traffic Intel подключить в hot path: AttackDetector/IpReputation →
метрики + auto-ban (сейчас не вызывается).
- [ ] Blacklist: `clear_expired()` по таймеру.
- [ ] RateLimiter: TTL-эвикция idle bucket'ов + cap карты.
### Этап 4b: Velocity Physics — Layer 4 (после PoW)
### Безопасность (перенос из аудита v0.3, актуальное)
- [ ] Rate limiter на login endpoint manager API (5/60с).
- [ ] JWT: валидация ролей/audience, secret ≥ 32 байт.
- [ ] Redis: `KEYS` → `SCAN`, reconnect pubsub-подписчика.
- [ ] Falling check (pre-computed cache: `(0.98^t-1)*3.92`, 128 ticks)
- **Исправление:** checkY() без fast-forward, сброс ignoredTicks
- [ ] Protocol check (Transaction, SetHeldItem, ArmAnimation)
- [ ] Vehicle check (Boat gravity + Minecart gravity)
- [ ] CAPTCHA (Map item или PoW как fallback)
- [ ] HMAC fingerprint (не hashCode!) для verified DB
- [ ] Idempotent finishVerification() (нет race condition)
### XDP
- [ ] Verifier-проверка на реальном ядре (в контейнере нет CAP_BPF — компиляция OK,
загрузка не проверялась).
- [ ] Rust loader (`src/xdp/`): пути к xdp/core/universal_filter.c, patch глобалов
G_* из config.toml, ringbuf events → blacklist.
- [ ] Smoke-test attach в CI (VM runner с CAP_BPF).
---
### Документация
- [ ] docs/deployment.md, configuration.md, runbook.md — переписать под новую структуру
(сейчас упоминают старые крейты/MC).
- [ ] docs/kb/README.md — индекс KB со ссылками на все статьи.
- [ ] TUI (ratatui): live-метрики из Prometheus endpoint (planned, v0.4).
### Этап 6: Traffic Intelligence — Layer 6
- [ ] 168-hour traffic profiling (per-hour-slot baseline)
- [ ] EWMA adaptive thresholds (правильная variance формула)
- [ ] Z-Score anomaly detection (3 consecutive minutes)
- [ ] Attack detection (CPS/PPS thresholds)
- [ ] Reputation system (IP score -100..+100)
- [ ] Discord webhook на атаки
---
### Этап 5: Observability
- [ ] ClickHouse writer (batch, раз в сек, буфер 1000)
- [ ] Grafana dashboard JSON
- [ ] Страница Attack Log в dashboard
---
### Этап 6b: Scale + HA
- [ ] NATS JetStream (blacklist, drain, audit)
- [ ] mTLS между всеми компонентами (rustls)
- [ ] Auto-discovery edge нод
- [ ] rampart-cli: `drain`, `wg sync`, `add-node`
---
### Этап 7: Polish
## 3. Backlog
- [ ] protocol-gRPC plugin (после http)
- [ ] BPF hook #1 реальный: HTTP поверх XDP (rate-limit до userspace)
- [ ] GeoIP/ASN reputation (enum есть, реализации нет)
- [ ] Bloom filter для blacklist
- [ ] io_uring runtime (feature flag)
- [ ] Zero-copy splice после handshake
- [ ] SLSA Level 3: signed releases, reproducible builds
- [ ] secret rotation (dual-key HMAC)
- [ ] ML anomaly detection (Isolation Forest)
- [ ] Fuzzing парсеров (`cargo-fuzz`)
- [ ] BGP Anycast (AS + /24)
---
## 6. Backlog
## 3a. Бенчмарк-конкуренты: чем превзойти
- [ ] Bedrock / RakNet (UDP модуль)
- [ ] Plugin API через WASM (как Infrarust)
- [ ] BGP Anycast (требует AS + /24)
- [ ] ML anomaly detection (Isolation Forest — многомерный, не univariate)
- [ ] Fuzzing для handshake parser (`cargo-fuzz`)
- [ ] Chaos engineering (random node kills)
> Все репо склонированы в `ref/` (gitignored). Анализ issues/PRs проведён 2026-08-24
> через gh по трекерам конкурентов. Ниже — выжимка «что у них болит и что берём».
### Карта конкурентов
| Проект | Что это | Похож на Rampart тем, что | Что взять |
|--------|---------|---------------------------|-----------|
| [eBPFsentinel](https://github.com/ebpfsentinel/ebpfsentinel) | Rust, один бинарник: firewall+IDS+DDoS через XDP/TC/uprobe | Ближайший аналог, та же архитектура | Rootless BPF token (kernel 6.9+); tail-call цепочки; MITRE-теги алертов; Swagger UI |
| [CrabShield](https://github.com/aleksgrim/crab-shield) | Rust + XDP, гибрид L7→L3 | «Умный юзерспейс, кара в ядре» | Static musl бинарник; reaper истекающих банов; (log-tailing НЕ брать — хрупко) |
| [lnvps_fw](https://github.com/LNVPS/api) | XDP+TC защита VDS | Прямо наша ниша | ⭐ SYN-proxy в XDP; port learning из TC egress; лестница PORT_FILTER→SYN_PROXY→SOURCE_BLOCK + spoof gate; netns+veth harness |
| [Oubliette](https://f0o.dev/projects/2026/04/oubliette/) | Linerate scrubber | Решает нашу боль с PoW | ⭐ RST-challenge: SYN-ACK с неверным ACK → спуф молчит, живой клиент шлёт RST → whitelist. Совместимо с любым клиентом |
| [Couic](https://github.com/fcsc-fr/couic) (CERT Франции) | XDP-фаервол + REST API | Наш manager API | Anti-lockout; теги+TTL записей; OpenAPI spec; синк инстансов; fail2ban-интеграция |
| [gamemann/XDP-Firewall](https://github.com/gamemann/XDP-Firewall) (~830★) | Классический C XDP-фаервол | Референс по XDP | Pinned maps для внешнего управления; их issues = карта граблей верификатора |
| [gen0sec/synapse](https://github.com/gen0sec/synapse) | NDR: eBPF + JA4-фингерпринты + ratatui TUI | TUI как наш план | JA4+/JA4T фингерпринтинг (бан по отпечатку); fallback-цепочка XDP→nftables→iptables |
### Топ-10 выводов из их issues/PRs (приоритет)
1. **Диагностика окружения при старте** — проверять ядро/BTF/driver NIC до загрузки,
человекочитаемый вердикт. ≈80% issues XDP-Firewall — про attach на неподдерживаемом
окружении (#70/#71/#9/#44). Печатать режим (native/generic) честно.
2. **Эскалационная лестница защиты** (lnvps_fw): pass-all steady state → PORT_FILTER →
SYN_PROXY (tail-call, keyed cookie, ротация секрета) → SOURCE_BLOCK только со spoof-gate.
3. **RST-challenge** (Oubliette) вместо мёртвого текстового PoW — универсально совместимо.
4. **REST API + OpenAPI + pinned maps**: динамические IP-списки без перекомпиляции —
самый частый feature request (#79/#77/#78 у gamemann); web-панель так и не сделана автором = свободная ниша.
5. **Anti-lockout + per-port баны**: слепой XDP_DROP по IP = self-lockout по SSH
(crab-shield docs). Whitelist обязателен, но не единственная защита.
6. **LRU во всех data-path maps** — иначе silent default-deny под атакой (netshield DD-003).
Per-src-IP rate limit не работает против spoofed flood 50–100 Mpps (gamemann #45) —
нужны per-port/per-subnet/flow агрегаты.
7. **netns+veth тестовый харнесс** + eBPF test_run тесты в CI (подтверждено в 2 проектах,
отсутствует у всех) — наше конкурентное преимущество в надёжности.
8. **IPv6-паритет с первого дня** + VLAN/QinQ парсинг (issue #75 висит годами).
9. **Rootless BPF token как опция**, fallback CAP_BPF для ядер 5.15+ — не повторять жёсткий
floor 6.9+ (ebpfsentinel отсёк enterprise) и не требовать root (crab-shield).
10. **События атак наружу с первого дня**: poll-and-persist, дедуп алертов на переходе
состояния (урок LNVPS #331 — отложили = дыра в продукте).
### Наши козыри (чем превзошли уже)
- Двуязыная Knowledge Base (docs/kb/) — educational killer-feature, нет ни у одного конкурента
- Один Rust-бинарник без C-зависимостей сборки (класс сегфолтов/libbpf-hell gamemann исключён)
- Честные бенчмарки: цифры только с отчётами, методология опубликована
- Модульный лимит ≤300 строк + no-dead-code политика в CI
---
## 7. Definition of Done
## 4. Anti-Regression — правила приёмки
1. **No dead code**: каждый `pub` имеет вызова вне `#[cfg(test)]`.
2. **Config field = потребитель**: нет поля без использования.
3. **Метрика регистрируется → обновляется**: единственный writer на каждую метрику.
4. **Feature flag = сборка в CI**: `--all-features` зелёный, иначе фичи нет.
5. **По умолчанию безопасно**: нет дефолтных секретов; отсутствие обязательного env = fail-fast.
6. **Интеграционный тест на слой**: config parse, filter logic, registry fail-fast (есть);
новый слой = новый тест.
7. **Модуль ≤ 300 строк**: CI-гейт через grep/wc скрипт или ревью.
8. **CI guardrails**: `cargo clippy --all-targets -- -D warnings`, `cargo test`,
clang-build xdp/core/universal_filter.c, grep на `changeme`.
9. **README/TODO не врут**: каждое число имеет ссылку на тест или отчёт.
## 5. Definition of Done
```
☐ cargo check / cargo test проходят
☐ cargo clippy -- -D warnings — 0 warnings
☐ cargo clippy --all-targets -- -D warnings — 0 warnings
☐ cargo fmt --check проходит
☐ Ни один модуль не превышает 300 строк
☐ Unit тесты покрывают happy path + 2+ error cases
☐ Интеграционный тест проходит (PoW+handshake, XDP smoke, Redis sync)
☐ Нет мёртвого кода: каждый pub-модуль/конфиг-поле/метрика имеют потребителя
☐ Нет дефолтных секретов/паролей (grep-чек в CI)
☐ Docker-образ собирает те же features, что CI (включая XDP)
☐ README соответствует коду (нет «бумажных» цифр/слоёв)
☐ Нет мёртвого кода: pub без вызовов, конфиг-поле без потребителя, метрика без writer
☐ Нет дефолтных секретов
☐ README соответствует коду
☐ Документация обновлена
☐ CI зелёный
```
---
## 8. Anti-Patterns
## 6. Anti-Patterns
```
❌ Тесты после кода. Пиши до (TDD) или вместе.
❌ Коммиты в main напрямую. Только PR.
❌ TODO в коде без issue. TODO = баг.
❌ Оптимизация без профиля.
❌ Зависимость ради 1 функции.
❌ async где хватит sync.
❌ Секреты в репозитории. Используй .env + SOPS.
❌ Игнор compiler warnings.
❌ Тесты после кода. Пиши вместе.
❌ Модуль > 300 строк — сигнал декомпозировать немедленно.
❌ TODO в коде без issue.
❌ Мёртвый код: pub без вызовов, конфиг-поле без потребителя, метрика без writer.
❌ «Бумажный слой»: фича в README/архитектуре, которой нет в коде или она не вызывается.
❌ Дефолтный секрет: `changeme`/`password="..."` в коде или конфиге.
❌ Парсер за «один read» — MC-пакет может прийти фрагментами.
❌ Feature flag, который не собирается в Docker/CI — фичи нет.
❌ «Бумажный слой»: фича описана, но не вызывается.
❌ Дефолтный секрет.
❌ Парсер за «один read» — TCP-поток приходит фрагментами.
❌ Feature flag, который не собирается в CI.
```
> Статус секций 5–8: план на будущее. Актуальный приоритет — **Аудит-фикс v0.3 (сек. 2)**: закрыть P0/P1/P2 до релиза.
---
*Версия: 3.0 | Обновлён: август 2026 (аудит-фикс v0.3)*
*Версия: 4.0 | Обновлён: 2026-08-24 (universal redesign)*

View file

@ -8,11 +8,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
let manifest_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR")?);
let xdp_dir = manifest_dir.join("../../xdp");
let xdp_dir = manifest_dir.join("xdp");
let out_dir = PathBuf::from(std::env::var("OUT_DIR")?);
let src = xdp_dir.join("xdp_filter.c");
let dst = out_dir.join("xdp_filter.o");
let src = xdp_dir.join("core/universal_filter.c");
let dst = out_dir.join("universal_filter.o");
println!("cargo:rerun-if-changed={}", src.display());
@ -30,6 +30,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
"-o",
dst.to_str().ok_or("dst path is not valid UTF-8")?,
&format!("-I{}", xdp_dir.display()),
&format!("-I{}", xdp_dir.join("core").display()),
&format!("-I{}", xdp_dir.join("hooks").display()),
&format!("-I/usr/include/{}-linux-gnu", arch),
])
.status()?;

View file

@ -1,17 +0,0 @@
[package]
name = "rampart-cli"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
tokio.workspace = true
serde.workspace = true
serde_json.workspace = true
tracing.workspace = true
anyhow.workspace = true
clap.workspace = true
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }

View file

@ -1,73 +0,0 @@
#![allow(clippy::print_stdout, clippy::print_stderr)]
use clap::{Parser, Subcommand};
mod commands;
#[derive(Parser)]
#[command(name = "rampart", about = "Rampart CLI")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Show overall system status
Status,
/// Run full diagnostics
Doctor,
/// Get/set configuration
Config {
#[arg(required = false)]
key: Option<String>,
#[arg(required = false)]
value: Option<String>,
},
/// Manage blacklist
Blacklist {
#[command(subcommand)]
action: BlacklistAction,
},
/// Emergency mode
Emergency {
#[arg(value_enum)]
mode: EmergencyMode,
},
/// Gracefully drain a node
Drain { node: String },
}
#[derive(Subcommand)]
enum BlacklistAction {
Add { target: String, reason: Option<String> },
Remove { target: String },
List,
}
#[derive(clap::ValueEnum, Clone)]
enum EmergencyMode {
Enable,
Disable,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Status => commands::status::run().await,
Commands::Doctor => commands::doctor::run().await,
Commands::Config { key, value } => commands::config::run(key, value).await,
Commands::Blacklist { action } => match action {
BlacklistAction::Add { target, reason } => commands::blacklist::add(target, reason).await,
BlacklistAction::Remove { target } => commands::blacklist::remove(target).await,
BlacklistAction::List => commands::blacklist::list().await,
},
Commands::Emergency { mode } => match mode {
EmergencyMode::Enable => commands::emergency::enable().await,
EmergencyMode::Disable => commands::emergency::disable().await,
},
Commands::Drain { node } => commands::drain::run(&node).await,
}
}

View file

@ -1,43 +0,0 @@
[package]
name = "rampart-core"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
tokio.workspace = true
serde.workspace = true
serde_json.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
thiserror.workspace = true
anyhow.workspace = true
dashmap.workspace = true
crossbeam.workspace = true
hex.workspace = true
sha2.workspace = true
rand.workspace = true
hmac.workspace = true
subtle.workspace = true
socket2 = { workspace = true, features = ["all"] }
prometheus.workspace = true
toml.workspace = true
futures.workspace = true
chrono = { workspace = true, features = ["serde"] }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
redis = { version = "0.27", optional = true, features = ["tokio-comp"] }
maxminddb = { version = "0.30", optional = true }
tokio-splice = { version = "0.2", optional = true }
libbpf-rs = { version = "0.24", optional = true }
libc = { version = "0.2", optional = true }
[features]
default = ["store-redis"]
store-redis = ["dep:redis"]
geoip = ["dep:maxminddb"]
xdp = ["dep:libbpf-rs", "dep:libc"]
io-uring = ["dep:tokio-splice"]

View file

@ -1,153 +0,0 @@
use hmac::{Hmac, Mac};
use sha2::Sha256;
use std::time::{SystemTime, UNIX_EPOCH};
use subtle::ConstantTimeEq;
type HmacSha256 = Hmac<Sha256>;
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
fn hmac_hex(key: &[u8], message: &[u8]) -> String {
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
mac.update(message);
hex::encode(mac.finalize().into_bytes())
}
fn derive_key(secret: &[u8], bucket: u64) -> Vec<u8> {
let msg = format!("rampart-key-{bucket}");
let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts any key length");
mac.update(msg.as_bytes());
mac.finalize().into_bytes().to_vec()
}
/// Подписывает hostname-поле: `domain\0shield\0<ts>\0<sig>`.
///
/// `sig` = HMAC-SHA256(derived_key, "domain|ts"), где
/// `derived_key` = HMAC-SHA256(master_secret, "rampart-key-{bucket}"), bucket = ts / rotation_secs.
pub fn sign_hostname(raw: &str, secret: &[u8], rotation_secs: u64) -> String {
let rotation_secs = rotation_secs.max(1);
let domain = raw.split('\0').next().unwrap_or(raw);
let ts = now_secs();
let bucket = ts / rotation_secs;
let key = derive_key(secret, bucket);
let sig = hmac_hex(&key, format!("{domain}|{ts}").as_bytes());
format!("{domain}\0shield\0{ts}\0{sig}")
}
/// Проверяет подпись hostname-поля по спецификации.
///
/// Парсит `domain\0shield\0<ts>\0<sig>`, проверяет `0 <= now - ts <= ttl_secs` и
/// сравнивает сигнатуру constant-time для bucket из `{ts_bucket, ts_bucket - 1}`.
pub fn verify_hostname(raw: &str, secret: &[u8], rotation_secs: u64, ttl_secs: u64) -> bool {
let rotation_secs = rotation_secs.max(1);
let mut parts = raw.split('\0');
let (Some(domain), Some(tag), Some(ts_str), Some(sig)) = (parts.next(), parts.next(), parts.next(), parts.next())
else {
return false;
};
if tag != "shield" || parts.next().is_some() {
return false;
}
let ts: u64 = match ts_str.parse() {
Ok(t) => t,
Err(_) => return false,
};
let now = now_secs();
if now < ts || now - ts > ttl_secs {
return false;
}
if sig.len() != 64 {
return false;
}
let bucket = ts / rotation_secs;
for candidate in [bucket, bucket.saturating_sub(1)] {
let key = derive_key(secret, candidate);
let expected = hmac_hex(&key, format!("{domain}|{ts}").as_bytes());
if expected.as_bytes().ct_eq(sig.as_bytes()).into() {
return true;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
const SECRET: &[u8] = b"test_secret_32_bytes_long_here!!";
fn build_signed(secret: &[u8], domain: &str, ts: u64, rotation_secs: u64) -> String {
let bucket = ts / rotation_secs.max(1);
let key = derive_key(secret, bucket);
let sig = hmac_hex(&key, format!("{domain}|{ts}").as_bytes());
format!("{domain}\0shield\0{ts}\0{sig}")
}
#[test]
fn test_sign_verify_roundtrip() {
let signed = sign_hostname("play.example.com", SECRET, 3600);
assert!(verify_hostname(&signed, SECRET, 3600, 60));
}
#[test]
fn test_sign_format() {
let signed = sign_hostname("play.example.com\0ignored", SECRET, 3600);
let parts: Vec<&str> = signed.split('\0').collect();
assert_eq!(parts.len(), 4);
assert_eq!(parts[0], "play.example.com");
assert_eq!(parts[1], "shield");
assert_eq!(parts[3].len(), 64);
assert!(parts[3].chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn test_verify_tampered_domain() {
let signed = sign_hostname("play.example.com", SECRET, 3600);
let tampered = signed.replace("play.example.com", "play.example.co");
assert!(!verify_hostname(&tampered, SECRET, 3600, 60));
}
#[test]
fn test_verify_wrong_secret() {
let signed = sign_hostname("play.example.com", SECRET, 3600);
let wrong = b"wrong_secret_32_bytes_long_here!!!";
assert!(!verify_hostname(&signed, wrong, 3600, 60));
}
#[test]
fn test_verify_expired_ts() {
let old_ts = now_secs().saturating_sub(120);
let signed = build_signed(SECRET, "play.example.com", old_ts, 3600);
assert!(!verify_hostname(&signed, SECRET, 3600, 60));
}
#[test]
fn test_verify_accepts_previous_bucket() {
let rotation = 10u64;
let now = now_secs();
let prev_bucket_ts = (now / rotation).saturating_sub(1) * rotation + 5;
let signed = build_signed(SECRET, "play.example.com", prev_bucket_ts, rotation);
assert!(verify_hostname(&signed, SECRET, rotation, 60));
}
#[test]
fn test_verify_rejects_tampered_ts() {
let ts = now_secs();
let signed = build_signed(SECRET, "play.example.com", ts, 3600);
let parts: Vec<&str> = signed.split('\0').collect();
let tampered = format!("{}\0{}\0{}\0{}", parts[0], parts[1], ts.saturating_sub(1), parts[3]);
assert!(!verify_hostname(&tampered, SECRET, 3600, 60));
}
#[test]
fn test_verify_garbage_input() {
assert!(!verify_hostname("", SECRET, 3600, 60));
assert!(!verify_hostname("no-separators", SECRET, 3600, 60));
assert!(!verify_hostname("a\0shield\0bad\0short", SECRET, 3600, 60));
}
}

View file

@ -1 +0,0 @@
pub mod hmac;

View file

@ -1,207 +0,0 @@
use crate::proxy::handshake::{read_string, read_varint};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeathCode {
EmptyPacket,
PacketTooShort,
InvalidPacketId,
NegativeProtocolVersion,
NonCanonicalVarint,
NullByteInHostname,
UnprintableHostname,
MalformedPacket,
}
impl DeathCode {
pub fn as_str(&self) -> &'static str {
match self {
Self::EmptyPacket => "empty_packet",
Self::PacketTooShort => "packet_too_short",
Self::InvalidPacketId => "invalid_packet_id",
Self::NegativeProtocolVersion => "negative_protocol_version",
Self::NonCanonicalVarint => "non_canonical_varint",
Self::NullByteInHostname => "null_byte_in_hostname",
Self::UnprintableHostname => "unprintable_hostname",
Self::MalformedPacket => "malformed_packet",
}
}
}
pub fn detect(buf: &[u8]) -> Option<DeathCode> {
if buf.is_empty() {
return Some(DeathCode::EmptyPacket);
}
if buf.len() < 3 {
return Some(DeathCode::PacketTooShort);
}
let (packet_len, after_len) = match read_varint(buf, 0) {
Ok(r) => r,
Err(_) => return Some(DeathCode::MalformedPacket),
};
if !is_canonical_varint(buf, 0) {
return Some(DeathCode::NonCanonicalVarint);
}
if packet_len <= 0 || (after_len + packet_len as usize) > buf.len() {
return Some(DeathCode::MalformedPacket);
}
let (packet_id, after_id) = match read_varint(buf, after_len) {
Ok(r) => r,
Err(_) => return Some(DeathCode::MalformedPacket),
};
if !is_canonical_varint(buf, after_len) {
return Some(DeathCode::NonCanonicalVarint);
}
if packet_id != 0x00 {
return Some(DeathCode::InvalidPacketId);
}
let (_protocol_version, after_pv) = match read_varint(buf, after_id) {
Ok(r) => r,
Err(_) => return Some(DeathCode::MalformedPacket),
};
if !is_canonical_varint(buf, after_id) {
return Some(DeathCode::NonCanonicalVarint);
}
let (server_address, _) = match read_string(buf, after_pv) {
Ok(r) => r,
Err(_) => return Some(DeathCode::MalformedPacket),
};
if !is_canonical_varint(buf, after_pv) {
return Some(DeathCode::NonCanonicalVarint);
}
if server_address.contains('\0') {
return Some(DeathCode::NullByteInHostname);
}
if !server_address.chars().all(|c| c.is_ascii_graphic() || c == '.') {
return Some(DeathCode::UnprintableHostname);
}
None
}
fn is_canonical_varint(buf: &[u8], start: usize) -> bool {
let mut value: u32 = 0;
let mut shift = 0;
let mut bytes_used = 0;
for (i, &byte) in buf[start..].iter().enumerate() {
if i >= 5 {
return false;
}
bytes_used = i + 1;
value |= ((byte & 0x7F) as u32) << shift;
shift += 7;
if (byte & 0x80) == 0 {
break;
}
}
if bytes_used >= 5 {
return false;
}
let min_varint = |val: u32| -> usize {
if val == 0 {
return 1;
}
let mut bits = 32 - val.leading_zeros();
let mut bytes = 0;
while bits > 0 {
bytes += 1;
bits = bits.saturating_sub(7);
}
bytes.max(1)
};
bytes_used == min_varint(value)
}
#[cfg(test)]
mod tests {
use super::*;
fn write_varint(buf: &mut Vec<u8>, mut value: i32) {
loop {
if (value & !0x7F) == 0 {
buf.push(value as u8);
return;
}
buf.push((value as u8 & 0x7F) | 0x80);
value >>= 7;
}
}
fn build_handshake_raw(hostname: &str) -> Vec<u8> {
let addr = hostname.as_bytes();
let mut buf = Vec::new();
buf.push(0x00);
write_varint(&mut buf, 765);
write_varint(&mut buf, addr.len() as i32);
buf.extend_from_slice(addr);
buf.extend_from_slice(&[0x63, 0xDD]);
buf.push(0x02);
let len = buf.len() as i32;
let mut pkt = Vec::new();
write_varint(&mut pkt, len);
pkt.extend_from_slice(&buf);
pkt
}
#[test]
fn test_valid_handshake() {
let pkt = build_handshake_raw("play.example.com");
assert_eq!(detect(&pkt), None);
}
#[test]
fn test_empty_packet() {
assert_eq!(detect(&[]), Some(DeathCode::EmptyPacket));
}
#[test]
fn test_null_byte_in_hostname() {
let pkt = build_handshake_raw("play.example.com\0extra");
assert_eq!(detect(&pkt), Some(DeathCode::NullByteInHostname));
}
#[test]
fn test_non_canonical_varint() {
let pkt: Vec<u8> = vec![0x08, 0x00, 0x80, 0x00, 0x02, b'e', b'x', 0x63, 0xDD, 0x02];
assert_eq!(detect(&pkt), Some(DeathCode::NonCanonicalVarint));
}
#[test]
fn test_invalid_packet_id() {
let addr = b"play.example.com";
let mut buf = Vec::new();
write_varint(&mut buf, 1);
buf.extend_from_slice(addr);
buf.extend_from_slice(&[0x63, 0xDD]);
buf.push(0x02);
let len = buf.len() as i32;
let mut pkt = Vec::new();
write_varint(&mut pkt, len);
pkt.extend_from_slice(&buf);
assert_eq!(detect(&pkt), Some(DeathCode::InvalidPacketId));
}
#[test]
fn test_unprintable_hostname() {
let pkt = build_handshake_raw("play\x01example.com");
assert_eq!(detect(&pkt), Some(DeathCode::UnprintableHostname));
}
}

View file

@ -1,4 +0,0 @@
pub mod blacklist;
pub mod death_code;
pub mod geo;
pub mod rate_limit;

View file

@ -1,9 +0,0 @@
pub mod config;
pub mod crypto;
pub mod filter;
pub mod metrics;
pub mod pow;
pub mod proxy;
pub mod store;
pub mod traffic;
pub mod xdp;

View file

@ -1,224 +0,0 @@
use rampart_core::config::Config;
use rampart_core::filter::blacklist::Blacklist;
use rampart_core::filter::rate_limit::RateLimiter;
use rampart_core::metrics;
use rampart_core::pow::difficulty::DifficultyAdjuster;
use rampart_core::proxy::listener::ProxyListener;
use rampart_core::store::clickhouse::{ClickHouseEvent, ClickHouseWriter};
use rampart_core::traffic::detector::{AttackDetector, AttackStatus};
use rampart_core::traffic::reputation::IpReputation;
use rampart_core::xdp::XdpFilter;
use std::collections::HashSet;
use std::net::IpAddr;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::sync::watch;
use tracing_subscriber::EnvFilter;
fn attack_status_value(status: AttackStatus) -> i64 {
match status {
AttackStatus::Normal => 0,
AttackStatus::Suspicious => 1,
AttackStatus::UnderAttack => 2,
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env().add_directive("rampart_core=info".parse()?))
.init();
let config_path = std::env::var("RAMPART_CONFIG").unwrap_or_else(|_| "/etc/rampart/config.toml".to_string());
let config = Config::from_file(&config_path)?;
let whitelist = build_whitelist(&config)?;
let config = Arc::new(config);
let rate_limiter = Arc::new(RateLimiter::new(
config.limits.rate_limit_login_pps,
config.limits.rate_limit_burst,
));
let blacklist = Arc::new(Blacklist::new());
let reputation = Arc::new(IpReputation::new());
let detector = Arc::new(Mutex::new(AttackDetector::new()));
let allowed_1s = Arc::new(AtomicU64::new(0));
let (shutdown_tx, shutdown_rx) = watch::channel(false);
let sig_tx = shutdown_tx.clone();
tokio::spawn(async move {
wait_for_signal().await;
tracing::info!("shutdown signal received, draining connections...");
let _ = sig_tx.send(true);
tokio::time::sleep(Duration::from_secs(5)).await;
tracing::info!("shutdown timeout reached, exiting");
std::process::exit(0);
});
#[cfg(feature = "store-redis")]
if let Some(redis_url) = &config.store.redis_url
&& !redis_url.is_empty()
{
let bl = blacklist.clone();
let sd = shutdown_rx.clone();
let url = redis_url.clone();
tokio::spawn(async move {
let client = match redis::Client::open(url.as_str()) {
Ok(c) => c,
Err(e) => {
tracing::warn!("Invalid redis_url: {e}, blacklist sync disabled");
return;
},
};
rampart_core::store::start_blacklist_sync(&client, bl, sd).await;
});
}
if config.metrics.enabled {
let metrics_addr = format!("0.0.0.0:{}", config.metrics.port);
tracing::info!("Metrics server listening on {metrics_addr}");
tokio::spawn(async move {
metrics::run_metrics_server(&metrics_addr).await;
});
}
let clickhouse: Option<Arc<tokio::sync::Mutex<ClickHouseWriter>>> = match &config.store.clickhouse_url {
Some(url) if !url.is_empty() => {
let writer = Arc::new(tokio::sync::Mutex::new(ClickHouseWriter::new(url)));
rampart_core::store::clickhouse::start_flush_task(writer.clone(), shutdown_rx.clone());
Some(writer)
},
_ => None,
};
#[cfg(feature = "xdp")]
let xdp_filter: Option<Arc<Mutex<XdpFilter>>> = if config.xdp.enabled {
use rampart_core::xdp::XdpMetrics;
let filter = XdpFilter::new(&config.xdp.interface);
let shared = Arc::new(Mutex::new(filter));
shared.lock().expect("xdp lock poisoned").load()?;
let xdp_metrics = XdpMetrics::register()?;
let sd = shutdown_rx.clone();
let shared_thread = shared.clone();
std::thread::spawn(move || {
while !*sd.borrow() {
let guard = match shared_thread.lock() {
Ok(g) => g,
Err(_) => break,
};
guard.drain_events();
if let Ok(stats) = guard.get_stats() {
xdp_metrics.update(&stats);
}
drop(guard);
std::thread::sleep(Duration::from_secs(5));
}
if let Ok(mut guard) = shared_thread.lock() {
guard.unload().ok();
}
});
Some(shared)
} else {
None
};
#[cfg(not(feature = "xdp"))]
let xdp_filter: Option<Arc<Mutex<XdpFilter>>> = None;
let rl = rate_limiter.clone();
let bl = blacklist.clone();
let det = detector.clone();
let a1s = allowed_1s.clone();
let ch = clickhouse.clone();
let mut sd = shutdown_rx.clone();
tokio::spawn(async move {
let mut sec_tick = tokio::time::interval(Duration::from_secs(1));
let mut min_tick = tokio::time::interval(Duration::from_secs(60));
sec_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
min_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut was_under_attack = false;
loop {
tokio::select! {
biased;
_ = sd.changed() => {
if *sd.borrow() {
return;
}
}
_ = sec_tick.tick() => {
let pps = a1s.swap(0, Ordering::Relaxed) as f64;
let status = det.lock().expect("detector lock poisoned").analyze(pps);
metrics::ATTACK_STATUS.set(attack_status_value(status));
if status == AttackStatus::UnderAttack {
if !was_under_attack {
was_under_attack = true;
tracing::info!(pps, "attack detected: under attack");
if let Some(writer) = &ch {
let event = ClickHouseEvent {
timestamp: chrono::Utc::now(),
event_type: "attack".to_string(),
ip: String::new(),
data_float: pps,
data_int: 0,
data_string: "under_attack".to_string(),
};
if let Err(e) = writer.lock().await.push(event).await {
tracing::debug!("clickhouse push error: {e}");
}
}
}
} else if was_under_attack {
was_under_attack = false;
}
}
_ = min_tick.tick() => {
rl.sweep();
bl.clear_expired();
}
}
}
});
tracing::info!("Rampart edge starting on {}:{}", config.bind.address, config.bind.port);
tracing::info!("Backend: {}:{}", config.backend.address, config.backend.port);
let adjuster = Arc::new(Mutex::new(DifficultyAdjuster::default()));
let listener = ProxyListener::new(
config,
rate_limiter,
blacklist,
adjuster,
whitelist,
reputation,
xdp_filter,
clickhouse,
allowed_1s,
);
listener.run(shutdown_rx).await
}
fn build_whitelist(config: &Config) -> anyhow::Result<Arc<HashSet<IpAddr>>> {
let mut set = HashSet::with_capacity(config.whitelist.len());
for entry in &config.whitelist {
let ip: IpAddr = match entry.parse() {
Ok(ip) => ip,
Err(_) => anyhow::bail!("invalid whitelist entry: {entry}"),
};
set.insert(ip);
}
Ok(Arc::new(set))
}
async fn wait_for_signal() {
let ctrl_c = tokio::signal::ctrl_c();
let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("failed to install SIGTERM handler");
tokio::select! {
_ = ctrl_c => {}
_ = term.recv() => {}
}
}

View file

@ -1,30 +0,0 @@
use rand::RngCore;
use std::time::Instant;
pub struct Challenge {
pub token: [u8; 32],
pub created_at: Instant,
pub difficulty: u8,
pub used: bool,
}
impl Challenge {
pub fn generate(difficulty: u8) -> Self {
let mut token = [0u8; 32];
rand::thread_rng().fill_bytes(&mut token);
Self {
token,
created_at: Instant::now(),
difficulty,
used: false,
}
}
pub fn is_expired(&self) -> bool {
self.created_at.elapsed().as_secs() >= 30
}
pub fn challenge_string(&self) -> String {
hex::encode(self.token)
}
}

View file

@ -1,68 +0,0 @@
use crate::metrics;
use std::collections::VecDeque;
use std::time::Instant;
pub struct DifficultyAdjuster {
window: VecDeque<Instant>,
min: u8,
max: u8,
current: u8,
}
impl DifficultyAdjuster {
pub fn new(min: u8, max: u8) -> Self {
Self {
window: VecDeque::new(),
min: min.max(4),
max: max.min(10),
current: min.max(4),
}
}
pub fn record_connection(&mut self) {
let now = Instant::now();
self.window.push_back(now);
while let Some(&t) = self.window.front() {
if now.duration_since(t).as_secs() >= 1 {
self.window.pop_front();
} else {
break;
}
}
let new_diff = self.compute_difficulty();
if self.current != new_diff {
tracing::info!(
old = self.current,
new = new_diff,
window = self.window.len(),
"pow: difficulty adjusted"
);
self.current = new_diff;
metrics::POW_CURRENT_DIFFICULTY.set(self.current as i64);
}
}
pub fn current_difficulty(&self) -> u8 {
metrics::POW_CURRENT_DIFFICULTY.set(self.current as i64);
self.current
}
fn compute_difficulty(&self) -> u8 {
let cps = self.window.len();
if cps > 500 {
self.max.max(self.min)
} else if cps > 200 {
8
} else if cps > 50 {
6
} else {
self.min
}
}
}
impl Default for DifficultyAdjuster {
fn default() -> Self {
Self::new(4, 16)
}
}

View file

@ -1,4 +0,0 @@
pub mod challenge;
pub mod difficulty;
pub mod solver;
pub mod verifier;

View file

@ -1,17 +0,0 @@
use sha2::{Digest, Sha256};
const ALLOWED: &[u8] = b"0123";
pub fn solve(challenge: &str, difficulty: u8) -> Option<String> {
let d = difficulty as usize;
for nonce in 0..u64::MAX {
let nonce_str = nonce.to_string();
let input = format!("{challenge}{nonce_str}");
let hash = Sha256::digest(input.as_bytes());
let hex_hash = hex::encode(hash);
if hex_hash.as_bytes().iter().take(d).all(|c| ALLOWED.contains(c)) {
return Some(nonce_str);
}
}
None
}

View file

@ -1,31 +0,0 @@
use crate::pow::challenge::Challenge;
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
const ALLOWED: [u8; 4] = *b"0123";
pub fn verify(challenge: &mut Challenge, nonce: &str) -> bool {
if challenge.used {
return false;
}
if challenge.is_expired() {
return false;
}
if nonce.len() > 64 {
return false;
}
let input = format!("{}{}", challenge.challenge_string(), nonce);
let hash = Sha256::digest(input.as_bytes());
let hex_hash = hex::encode(hash);
let d = challenge.difficulty as usize;
let ok = hex_hash.as_bytes().iter().take(d).all(|c| {
let r = c.ct_eq(&ALLOWED[0]) | c.ct_eq(&ALLOWED[1]) | c.ct_eq(&ALLOWED[2]) | c.ct_eq(&ALLOWED[3]);
r.unwrap_u8() == 1
});
if !ok {
return false;
}
challenge.used = true;
true
}

View file

@ -1,215 +0,0 @@
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ParseError {
#[error("Incomplete packet: {0}")]
Incomplete(&'static str),
#[error("VarInt too big (>5 bytes)")]
VarIntTooBig,
#[error("VarInt overflow")]
VarIntOverflow,
#[error("Invalid UTF-8 in string")]
InvalidUtf8,
#[error("String too long: {0}")]
StringTooLong(usize),
#[error("Hostname too long")]
HostnameTooLong,
#[error("Not a handshake packet: id={0}")]
NotHandshake(i32),
}
#[derive(Debug, Clone, PartialEq)]
pub enum NextState {
Status,
Login,
Unknown(i32),
}
#[derive(Debug, Clone)]
pub struct McHandshake {
pub protocol_version: i32,
pub server_address: String,
pub server_port: u16,
pub next_state: NextState,
}
impl McHandshake {
pub fn parse(buf: &[u8]) -> Result<Self, ParseError> {
let mut pos;
let (_, after_len) = read_varint(buf, 0)?;
pos = after_len;
let (packet_id, after_id) = read_varint(buf, pos)?;
pos = after_id;
if packet_id != 0x00 {
return Err(ParseError::NotHandshake(packet_id));
}
let (protocol_version, after_pv) = read_varint(buf, pos)?;
pos = after_pv;
let (server_address, after_addr) = read_string(buf, pos)?;
pos = after_addr;
if server_address.len() > 255 {
return Err(ParseError::HostnameTooLong);
}
if pos + 2 > buf.len() {
return Err(ParseError::Incomplete("missing port"));
}
let server_port = u16::from_be_bytes([buf[pos], buf[pos + 1]]);
pos += 2;
let (next_state_raw, _) = read_varint(buf, pos)?;
let next_state = match next_state_raw {
1 => NextState::Status,
2 => NextState::Login,
n => NextState::Unknown(n),
};
Ok(McHandshake {
protocol_version,
server_address,
server_port,
next_state,
})
}
pub fn is_login(&self) -> bool {
self.next_state == NextState::Login
}
}
pub fn read_varint(buf: &[u8], start: usize) -> Result<(i32, usize), ParseError> {
let mut value: i32 = 0;
let mut shift = 0;
for (i, &byte) in buf[start..].iter().enumerate() {
if i >= 5 {
return Err(ParseError::VarIntTooBig);
}
let segment = (byte & 0x7F) as i32;
if shift >= 32 || (shift == 28 && segment > 0x0F) {
return Err(ParseError::VarIntOverflow);
}
value |= segment << shift;
shift += 7;
if (byte & 0x80) == 0 {
return Ok((value, start + i + 1));
}
}
Err(ParseError::Incomplete("varint"))
}
pub fn read_string(buf: &[u8], start: usize) -> Result<(String, usize), ParseError> {
let (len, after_len) = read_varint(buf, start)?;
if !(0..=32767).contains(&len) {
return Err(ParseError::StringTooLong(len as usize));
}
let end = after_len + len as usize;
if end > buf.len() {
return Err(ParseError::Incomplete("string data"));
}
let s = std::str::from_utf8(&buf[after_len..end])
.map_err(|_| ParseError::InvalidUtf8)?
.to_string();
Ok((s, end))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_varint_zero() {
let buf = vec![0x00];
assert_eq!(read_varint(&buf, 0).expect("varint should parse"), (0, 1));
}
#[test]
fn test_varint_single() {
let buf = vec![0x7F];
assert_eq!(read_varint(&buf, 0).expect("varint should parse"), (127, 1));
}
#[test]
fn test_varint_multi() {
let buf = vec![0x80, 0x01];
assert_eq!(read_varint(&buf, 0).expect("varint should parse"), (128, 2));
}
#[test]
fn test_varint_max() {
let buf = vec![0xFF, 0xFF, 0xFF, 0xFF, 0x07];
assert_eq!(read_varint(&buf, 0).expect("varint should parse"), (i32::MAX, 5));
}
#[test]
fn test_varint_overflow() {
let buf = vec![0xFF, 0xFF, 0xFF, 0xFF, 0x10];
assert!(matches!(read_varint(&buf, 0), Err(ParseError::VarIntOverflow)));
}
#[test]
fn test_varint_incomplete() {
let buf = vec![0x80];
assert!(matches!(read_varint(&buf, 0), Err(ParseError::Incomplete(_))));
}
#[test]
fn test_handshake_login() {
let addr = b"play.example.com";
let mut buf = Vec::new();
buf.extend_from_slice(&[0x00]);
buf.push(0x00);
write_varint(&mut buf, 765);
write_varint(&mut buf, addr.len() as i32);
buf.extend_from_slice(addr);
buf.extend_from_slice(&[0x63, 0xDD]);
buf.push(0x02);
let len = (buf.len() - 1) as u8;
buf[0] = len;
let hs = McHandshake::parse(&buf).expect("valid login handshake should parse");
assert_eq!(hs.protocol_version, 765);
assert_eq!(hs.server_address, "play.example.com");
assert_eq!(hs.server_port, 25565);
assert!(hs.is_login());
}
fn write_varint(buf: &mut Vec<u8>, mut value: i32) {
loop {
if (value & !0x7F) == 0 {
buf.push(value as u8);
return;
}
buf.push((value as u8 & 0x7F) | 0x80);
value = (value >> 7) & (i32::MAX >> 6);
}
}
#[test]
fn test_handshake_status() {
let addr = b"play.example";
let mut buf = Vec::new();
// packet length will be set below
buf.push(0x00);
buf.push(0x00); // packet ID
buf.push(0x02); // protocol version 2
write_varint(&mut buf, addr.len() as i32);
buf.extend_from_slice(addr);
buf.extend_from_slice(&[0x63, 0xDD]); // port 25565
buf.push(0x01); // next state = status
let len = (buf.len() - 1) as u8;
buf[0] = len;
let hs = McHandshake::parse(&buf).expect("valid status handshake should parse");
assert_eq!(hs.protocol_version, 2);
assert_eq!(hs.server_address, "play.example");
assert_eq!(hs.server_port, 25565);
assert_eq!(hs.next_state, NextState::Status);
}
}

View file

@ -1,155 +0,0 @@
use crate::config::Config;
use crate::filter::blacklist::Blacklist;
use crate::filter::rate_limit::RateLimiter;
use crate::pow::difficulty::DifficultyAdjuster;
use crate::proxy::tunnel::ConnectionHandler;
use crate::store::clickhouse::ClickHouseWriter;
use crate::traffic::reputation::IpReputation;
use crate::xdp::XdpFilter;
use socket2::{Domain, Socket, Type};
use std::collections::HashSet;
use std::net::IpAddr;
use std::sync::atomic::AtomicU64;
use std::sync::{Arc, Mutex};
use tokio::net::TcpListener;
use tokio::sync::Mutex as TokioMutex;
use tokio::sync::watch;
pub struct ProxyListener {
config: Arc<Config>,
rate_limiter: Arc<RateLimiter>,
blacklist: Arc<Blacklist>,
adjuster: Arc<Mutex<DifficultyAdjuster>>,
whitelist: Arc<HashSet<IpAddr>>,
reputation: Arc<IpReputation>,
xdp: Option<Arc<Mutex<XdpFilter>>>,
clickhouse: Option<Arc<TokioMutex<ClickHouseWriter>>>,
allowed_1s: Arc<AtomicU64>,
}
impl ProxyListener {
#[allow(clippy::too_many_arguments)]
pub fn new(
config: Arc<Config>,
rate_limiter: Arc<RateLimiter>,
blacklist: Arc<Blacklist>,
adjuster: Arc<Mutex<DifficultyAdjuster>>,
whitelist: Arc<HashSet<IpAddr>>,
reputation: Arc<IpReputation>,
xdp: Option<Arc<Mutex<XdpFilter>>>,
clickhouse: Option<Arc<TokioMutex<ClickHouseWriter>>>,
allowed_1s: Arc<AtomicU64>,
) -> Self {
Self {
config,
rate_limiter,
blacklist,
adjuster,
whitelist,
reputation,
xdp,
clickhouse,
allowed_1s,
}
}
pub async fn run(&self, shutdown: watch::Receiver<bool>) -> anyhow::Result<()> {
let addr = format!("{}:{}", self.config.bind.address, self.config.bind.port).parse::<std::net::SocketAddr>()?;
let workers = self.config.workers.count.max(1);
let mut handles = Vec::with_capacity(workers);
for _ in 0..workers {
let listener = build_listener(addr)?;
let config = self.config.clone();
let rate_limiter = self.rate_limiter.clone();
let blacklist = self.blacklist.clone();
let adjuster = self.adjuster.clone();
let whitelist = self.whitelist.clone();
let reputation = self.reputation.clone();
let xdp = self.xdp.clone();
let clickhouse = self.clickhouse.clone();
let allowed_1s = self.allowed_1s.clone();
let shutdown = shutdown.clone();
handles.push(tokio::spawn(accept_loop(
listener,
config,
rate_limiter,
blacklist,
adjuster,
whitelist,
reputation,
xdp,
clickhouse,
allowed_1s,
shutdown,
)));
}
for h in handles {
h.await??;
}
Ok(())
}
}
fn build_listener(addr: std::net::SocketAddr) -> anyhow::Result<TcpListener> {
let socket = Socket::new(Domain::IPV4, Type::STREAM, None)?;
socket.set_reuse_port(true)?;
socket.set_reuse_address(true)?;
socket.set_nonblocking(true)?;
socket.bind(&addr.into())?;
socket.listen(65535)?;
Ok(TcpListener::from_std(socket.into())?)
}
#[allow(clippy::too_many_arguments)]
async fn accept_loop(
listener: TcpListener,
config: Arc<Config>,
rate_limiter: Arc<RateLimiter>,
blacklist: Arc<Blacklist>,
adjuster: Arc<Mutex<DifficultyAdjuster>>,
whitelist: Arc<HashSet<IpAddr>>,
reputation: Arc<IpReputation>,
xdp: Option<Arc<Mutex<XdpFilter>>>,
clickhouse: Option<Arc<TokioMutex<ClickHouseWriter>>>,
allowed_1s: Arc<AtomicU64>,
mut shutdown: watch::Receiver<bool>,
) -> anyhow::Result<()> {
loop {
tokio::select! {
biased;
_ = shutdown.changed() => {
if *shutdown.borrow() {
tracing::info!("shutdown signal received, stopping accept loop");
return Ok(());
}
}
result = listener.accept() => {
let (stream, peer_addr) = match result {
Ok(conn) => conn,
Err(e) => {
tracing::error!("accept error: {e}");
continue;
}
};
let handler = ConnectionHandler::new(
config.clone(),
rate_limiter.clone(),
blacklist.clone(),
adjuster.clone(),
whitelist.clone(),
reputation.clone(),
xdp.clone(),
clickhouse.clone(),
allowed_1s.clone(),
);
tokio::spawn(async move {
if let Err(e) = handler.handle(stream, peer_addr).await {
tracing::debug!("connection from {peer_addr}: {e}");
}
});
}
}
}
}

View file

@ -1,4 +0,0 @@
pub mod handshake;
pub mod listener;
pub mod pow;
pub mod tunnel;

View file

@ -1,37 +0,0 @@
use crate::pow::challenge::Challenge;
use std::net::IpAddr;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::time::{Duration, timeout};
pub async fn handle_pow(stream: &mut TcpStream, peer_ip: IpAddr, difficulty: u8) -> anyhow::Result<bool> {
if difficulty == 0 {
tracing::debug!("pow: difficulty 0, skipping for {peer_ip}");
return Ok(true);
}
let mut challenge = Challenge::generate(difficulty);
let challenge_str = challenge.challenge_string();
let line = format!("{challenge_str}\n");
stream.write_all(line.as_bytes()).await?;
let mut buf = [0u8; 65];
let n = timeout(Duration::from_secs(10), stream.read(&mut buf)).await??;
if n == 0 {
tracing::debug!("pow: no response from {peer_ip}");
return Ok(false);
}
let nonce = std::str::from_utf8(&buf[..n.min(64)]).unwrap_or("").trim();
if nonce.is_empty() || nonce.len() > 64 {
tracing::debug!("pow: invalid nonce from {peer_ip}");
return Ok(false);
}
let valid = crate::pow::verifier::verify(&mut challenge, nonce);
tracing::debug!(
"pow: verification {} for {peer_ip}",
if valid { "passed" } else { "failed" }
);
Ok(valid)
}

View file

@ -1,362 +0,0 @@
use crate::config::Config;
use crate::crypto::hmac;
use crate::filter::blacklist::Blacklist;
use crate::filter::death_code;
use crate::filter::rate_limit::RateLimiter;
use crate::metrics;
use crate::pow::difficulty::DifficultyAdjuster;
use crate::proxy::handshake::{McHandshake, ParseError, read_varint};
use crate::proxy::pow::handle_pow;
use crate::store::clickhouse::{ClickHouseEvent, ClickHouseWriter};
use crate::traffic::reputation::IpReputation;
use crate::xdp::XdpFilter;
use std::collections::HashSet;
use std::net::IpAddr;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
use tokio::sync::Mutex as TokioMutex;
const MAX_FRAME_SIZE: usize = 8192;
const READ_CHUNK_SIZE: usize = 512;
pub struct ConnectionHandler {
config: Arc<Config>,
rate_limiter: Arc<RateLimiter>,
blacklist: Arc<Blacklist>,
adjuster: Arc<Mutex<DifficultyAdjuster>>,
whitelist: Arc<HashSet<IpAddr>>,
reputation: Arc<IpReputation>,
xdp: Option<Arc<Mutex<XdpFilter>>>,
clickhouse: Option<Arc<TokioMutex<ClickHouseWriter>>>,
allowed_1s: Arc<AtomicU64>,
}
impl ConnectionHandler {
#[allow(clippy::too_many_arguments)]
pub fn new(
config: Arc<Config>,
rate_limiter: Arc<RateLimiter>,
blacklist: Arc<Blacklist>,
adjuster: Arc<Mutex<DifficultyAdjuster>>,
whitelist: Arc<HashSet<IpAddr>>,
reputation: Arc<IpReputation>,
xdp: Option<Arc<Mutex<XdpFilter>>>,
clickhouse: Option<Arc<TokioMutex<ClickHouseWriter>>>,
allowed_1s: Arc<AtomicU64>,
) -> Self {
Self {
config,
rate_limiter,
blacklist,
adjuster,
whitelist,
reputation,
xdp,
clickhouse,
allowed_1s,
}
}
pub async fn handle(&self, mut client: TcpStream, peer_addr: std::net::SocketAddr) -> anyhow::Result<()> {
let peer_ip = peer_addr.ip();
if self.blacklist.is_blocked(peer_ip) {
metrics::CONNECTIONS_TOTAL.with_label_values(&["blocked"]).inc();
return Ok(());
}
let pow_config = &self.config.pow;
if pow_config.enabled && pow_config.difficulty > 0 && !self.whitelist.contains(&peer_ip) {
self.adjuster
.lock()
.expect("adjuster lock poisoned")
.record_connection();
let diff = self
.adjuster
.lock()
.expect("adjuster lock poisoned")
.current_difficulty();
let result = handle_pow(&mut client, peer_ip, diff).await?;
if !result {
metrics::POW_CHALLENGES_TOTAL.with_label_values(&["failed"]).inc();
tracing::debug!("pow: failed for {peer_ip}, dropping connection");
return Ok(());
}
metrics::POW_CHALLENGES_TOTAL.with_label_values(&["passed"]).inc();
metrics::POW_CURRENT_DIFFICULTY.set(diff as i64);
} else if pow_config.enabled && pow_config.difficulty > 0 {
metrics::POW_CHALLENGES_TOTAL.with_label_values(&["skipped"]).inc();
metrics::POW_CURRENT_DIFFICULTY.set(pow_config.difficulty as i64);
}
if !self.rate_limiter.check(peer_ip) {
self.block_rate_limit(peer_ip).await;
return Ok(());
}
let timeout = Duration::from_secs(self.config.limits.handshake_timeout_secs);
let mut buf: Vec<u8> = Vec::new();
match read_full_frame(&mut client, timeout, &mut buf).await {
Ok(false) => return Ok(()),
Ok(true) => {},
Err(e) => {
tracing::debug!("read error from {peer_addr}: {e}");
metrics::CONNECTIONS_TOTAL.with_label_values(&["blocked"]).inc();
self.handle_death_code(peer_addr, &buf).await;
return Ok(());
},
}
let parsed = McHandshake::parse(&buf);
match parsed {
Ok(handshake) => {
if !self.rate_limiter.check(peer_ip) {
self.block_rate_limit(peer_ip).await;
return Ok(());
}
metrics::CONNECTIONS_TOTAL.with_label_values(&["allowed"]).inc();
self.allowed_1s.fetch_add(1, Ordering::Relaxed);
self.reputation.record_good(peer_ip);
let backend_addr = format!("{}:{}", self.config.backend.address, self.config.backend.port);
let mut backend = TcpStream::connect(&backend_addr).await?;
let signed = hmac::sign_hostname(
&handshake.server_address,
self.config.hmac.secret.as_bytes(),
self.config.hmac.key_rotation_interval_secs,
);
let modified = replace_hostname(&buf, &handshake.server_address, &signed)?;
backend.write_all(&modified).await?;
tokio::io::copy_bidirectional(&mut client, &mut backend).await?;
},
Err(e) => {
tracing::debug!("parse error from {peer_addr}: {e}");
metrics::CONNECTIONS_TOTAL.with_label_values(&["blocked"]).inc();
self.handle_death_code(peer_addr, &buf).await;
},
}
Ok(())
}
async fn block_rate_limit(&self, ip: IpAddr) {
metrics::RATE_LIMIT_HITS.with_label_values(&["hit"]).inc();
metrics::CONNECTIONS_TOTAL.with_label_values(&["blocked"]).inc();
self.reputation.record_bad(ip);
if self.reputation.score(ip) < -40 {
let duration_secs = self.config.death_code.ban_duration_secs;
self.blacklist
.add(ip, Duration::from_secs(duration_secs), "low_reputation");
self.xdp_ban(ip, duration_secs);
self.push_event("block", ip, "low_reputation").await;
tracing::info!("low reputation ban {ip}: rate-limit abuse");
}
}
async fn handle_death_code(&self, peer_addr: std::net::SocketAddr, buf: &[u8]) {
if !self.config.death_code.enabled {
return;
}
if let Some(code) = death_code::detect(buf) {
let duration_secs = self.config.death_code.ban_duration_secs;
let ip = peer_addr.ip();
self.blacklist
.add(ip, Duration::from_secs(duration_secs), code.as_str());
self.reputation.record_bad(ip);
self.xdp_ban(ip, duration_secs);
self.push_event("ban", ip, code.as_str()).await;
metrics::DEATH_CODE_BANS_TOTAL.with_label_values(&[code.as_str()]).inc();
tracing::info!("death code ban {peer_addr}: {}", code.as_str());
}
}
fn xdp_ban(&self, ip: IpAddr, duration_secs: u64) {
let Some(xdp) = &self.xdp else {
return;
};
let IpAddr::V4(ip_v4) = ip else {
return;
};
match xdp.lock().expect("xdp lock poisoned").ban_ip(ip_v4, duration_secs) {
Ok(()) => tracing::debug!("xdp ban {ip_v4} for {duration_secs}s"),
Err(e) => tracing::warn!("xdp ban failed for {ip_v4}: {e}"),
}
}
async fn push_event(&self, event_type: &str, ip: IpAddr, data_string: &str) {
let Some(writer) = &self.clickhouse else {
return;
};
let event = ClickHouseEvent {
timestamp: chrono::Utc::now(),
event_type: event_type.to_string(),
ip: ip.to_string(),
data_float: 0.0,
data_int: 0,
data_string: data_string.to_string(),
};
if let Err(e) = writer.lock().await.push(event).await {
tracing::debug!("clickhouse push error: {e}");
}
}
}
async fn read_full_frame(client: &mut TcpStream, timeout: Duration, buf: &mut Vec<u8>) -> anyhow::Result<bool> {
let mut chunk = [0u8; READ_CHUNK_SIZE];
let first = tokio::time::timeout(timeout, client.read(&mut chunk)).await??;
if first == 0 {
return Ok(false);
}
buf.extend_from_slice(&chunk[..first]);
let total_len = loop {
match read_varint(buf, 0) {
Ok((packet_len, after_len)) => break after_len + packet_len as usize,
Err(ParseError::Incomplete(_)) => {
if buf.len() >= 5 {
anyhow::bail!("length varint incomplete after {} bytes", buf.len());
}
let n = tokio::time::timeout(timeout, client.read(&mut chunk)).await??;
if n == 0 {
anyhow::bail!("connection closed while reading packet length");
}
buf.extend_from_slice(&chunk[..n]);
},
Err(e) => anyhow::bail!("invalid packet length varint: {e}"),
}
};
if total_len > MAX_FRAME_SIZE {
anyhow::bail!("frame too large: {total_len} bytes (max {MAX_FRAME_SIZE})");
}
while buf.len() < total_len {
let n = tokio::time::timeout(timeout, client.read(&mut chunk)).await??;
if n == 0 {
anyhow::bail!("connection closed while reading frame body");
}
buf.extend_from_slice(&chunk[..n]);
}
buf.truncate(total_len);
Ok(true)
}
fn replace_hostname(original: &[u8], _old_hostname: &str, new_hostname: &str) -> anyhow::Result<Vec<u8>> {
let (packet_len, after_packet_len) =
read_varint(original, 0).map_err(|_| anyhow::anyhow!("corrupt packet length"))?;
let mut pos = after_packet_len;
let (_packet_id, after_id) = read_varint(original, pos).map_err(|_| anyhow::anyhow!("corrupt packet id"))?;
pos = after_id;
let (_protocol_version, after_pv) =
read_varint(original, pos).map_err(|_| anyhow::anyhow!("corrupt protocol version"))?;
pos = after_pv;
let (old_host_len, host_field_start) =
read_varint(original, pos).map_err(|_| anyhow::anyhow!("corrupt hostname length"))?;
let host_data_end = host_field_start + old_host_len as usize;
let old_field_size = host_data_end - pos;
let new_hostname_bytes = new_hostname.as_bytes();
let new_len_field_bytes = varint_bytes(new_hostname_bytes.len() as i32);
let new_field_size = new_len_field_bytes.len() + new_hostname_bytes.len();
let size_diff = new_field_size as isize - old_field_size as isize;
let new_packet_len = (packet_len as isize + size_diff) as i32;
let cap = original.len().wrapping_add(size_diff as usize);
let mut result = Vec::with_capacity(cap);
result.extend_from_slice(&varint_bytes(new_packet_len));
result.extend_from_slice(&original[after_packet_len..pos]);
result.extend_from_slice(&new_len_field_bytes);
result.extend_from_slice(new_hostname_bytes);
result.extend_from_slice(&original[host_data_end..]);
Ok(result)
}
fn varint_bytes(mut value: i32) -> Vec<u8> {
let mut result = Vec::with_capacity(5);
loop {
if (value & !0x7F) == 0 {
result.push(value as u8);
return result;
}
result.push((value as u8 & 0x7F) | 0x80);
value >>= 7;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn build_test_packet(hostname: &str) -> Vec<u8> {
let addr = hostname.as_bytes();
let mut buf = Vec::new();
buf.push(0x00);
buf.extend_from_slice(&varint_bytes(765));
buf.extend_from_slice(&varint_bytes(addr.len() as i32));
buf.extend_from_slice(addr);
buf.extend_from_slice(&[0x63, 0xDD]);
buf.push(0x02);
let len = buf.len() as i32;
let mut pkt = varint_bytes(len);
pkt.extend_from_slice(&buf);
pkt
}
#[test]
fn test_replace_hostname_basic() {
let pkt = build_test_packet("play.example.com");
let new_hostname = "play.example.com\0shield\0abcdef1234567890";
let modified = replace_hostname(&pkt, "play.example.com", new_hostname).expect("should replace hostname");
assert!(modified.len() > pkt.len());
let parsed = McHandshake::parse(&modified).expect("signed hostname should parse");
assert_eq!(parsed.server_address, new_hostname);
}
#[test]
fn test_replace_hostname_shorter() {
let pkt = build_test_packet("very.long.hostname.example.com");
let new_hostname = "short.com";
let modified =
replace_hostname(&pkt, "very.long.hostname.example.com", new_hostname).expect("should replace hostname");
assert!(modified.len() < pkt.len());
let parsed = McHandshake::parse(&modified).expect("short hostname should parse");
assert_eq!(parsed.server_address, new_hostname);
}
#[test]
fn test_replace_hostname_preserves_port_and_protocol() {
let pkt = build_test_packet("mc.example.com");
let new_hostname = "mc.example.com\0shield\x00deadbeef";
let modified = replace_hostname(&pkt, "mc.example.com", new_hostname).expect("should replace hostname");
let parsed = McHandshake::parse(&modified).expect("signed hostname should parse");
assert_eq!(parsed.server_port, 25565);
assert_eq!(parsed.protocol_version, 765);
assert!(parsed.is_login());
}
#[test]
fn test_varint_roundtrip() {
let cases = vec![0, 1, 127, 128, 255, 65535, 1000000, i32::MAX];
for val in cases {
let bytes = varint_bytes(val);
let (decoded, _) = read_varint(&bytes, 0).expect("varint should parse");
assert_eq!(decoded, val, "roundtrip failed for {val}");
}
}
}

View file

@ -1,31 +0,0 @@
#[cfg(feature = "store-redis")]
pub mod redis;
#[cfg(feature = "store-redis")]
pub use redis::start_blacklist_sync;
pub mod clickhouse;
#[allow(async_fn_in_trait)]
pub trait StateStore: Send + Sync {
async fn get(&self, key: &str) -> anyhow::Result<Option<String>>;
async fn set(&self, key: &str, value: &str) -> anyhow::Result<()>;
async fn del(&self, key: &str) -> anyhow::Result<()>;
async fn publish(&self, channel: &str, message: &str) -> anyhow::Result<()>;
}
pub struct NoopStore;
impl StateStore for NoopStore {
async fn get(&self, _key: &str) -> anyhow::Result<Option<String>> {
Ok(None)
}
async fn set(&self, _key: &str, _value: &str) -> anyhow::Result<()> {
Ok(())
}
async fn del(&self, _key: &str) -> anyhow::Result<()> {
Ok(())
}
async fn publish(&self, _channel: &str, _message: &str) -> anyhow::Result<()> {
Ok(())
}
}

View file

@ -1,5 +0,0 @@
pub mod alert;
pub mod detector;
pub mod ewma;
pub mod profiler;
pub mod reputation;

View file

@ -1,25 +0,0 @@
[package]
name = "rampart-manager"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
tokio.workspace = true
serde.workspace = true
serde_json.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
thiserror.workspace = true
anyhow.workspace = true
dashmap.workspace = true
subtle.workspace = true
prometheus.workspace = true
axum = "0.8"
tower-http = { version = "0.6", features = ["cors"] }
jsonwebtoken = "9"
redis = { version = "0.27", features = ["tokio-comp", "connection-manager"] }
chrono = { version = "0.4", features = ["serde"] }

View file

@ -1 +0,0 @@

View file

@ -1,91 +0,0 @@
use axum::{
Router,
http::HeaderValue,
middleware,
routing::{get, post},
};
use dashmap::DashMap;
use std::{net::IpAddr, sync::Arc, time::Instant};
use tower_http::cors::CorsLayer;
use tracing_subscriber::EnvFilter;
mod api;
mod auth;
mod sync;
pub struct AppState {
pub redis_client: redis::Client,
pub jwt_secret: String,
pub jwt_audience: String,
pub jwt_expiration: u64,
pub api_password: String,
pub login_limiter: DashMap<IpAddr, (Instant, u32)>,
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::from_default_env().add_directive("rampart_manager=info".parse()?))
.init();
let redis_url = std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379/0".to_string());
let redis_client = redis::Client::open(redis_url)?;
let jwt_secret = std::env::var("JWT_SECRET").map_err(|_| anyhow::anyhow!("JWT_SECRET must be set"))?;
if jwt_secret.len() < 32 {
return Err(anyhow::anyhow!("JWT_SECRET must be at least 32 bytes"));
}
let jwt_audience = std::env::var("JWT_AUDIENCE").unwrap_or_else(|_| "rampart".to_string());
let jwt_expiration = std::env::var("JWT_EXPIRATION_SECS")
.unwrap_or_else(|_| "86400".to_string())
.parse::<u64>()
.map_err(|_| anyhow::anyhow!("JWT_EXPIRATION_SECS must be a valid u64"))?;
let api_password = std::env::var("API_PASSWORD").map_err(|_| anyhow::anyhow!("API_PASSWORD must be set"))?;
if api_password == "changeme" {
return Err(anyhow::anyhow!("API_PASSWORD must not be the default 'changeme'"));
}
let state = Arc::new(AppState {
redis_client,
jwt_secret,
jwt_audience,
jwt_expiration,
api_password,
login_limiter: DashMap::new(),
});
tokio::spawn(sync::heartbeat::start_heartbeat_check(state.clone()));
let public = Router::new()
.route("/api/v1/health", get(api::health::health_check))
.route("/api/v1/auth/login", post(api::auth::login));
let protected = Router::new()
.route("/api/v1/servers", get(api::servers::list_servers))
.route(
"/api/v1/blacklist",
get(api::blacklist::list_blacklist).post(api::blacklist::add_blacklist),
)
.route("/api/v1/nodes", get(api::nodes::list_nodes))
.route_layer(middleware::from_fn(auth::auth_middleware));
let cors = match std::env::var("CORS_ORIGIN") {
Ok(origin) if origin.is_empty() || origin == "*" => CorsLayer::new().allow_origin(tower_http::cors::Any),
Ok(origin) => CorsLayer::new().allow_origin(HeaderValue::from_str(&origin)?),
Err(_) => CorsLayer::new().allow_origin(HeaderValue::from_static("http://localhost:5173")),
};
let app = Router::new()
.merge(public)
.merge(protected)
.layer(cors)
.with_state(state);
let addr = "0.0.0.0:8080";
tracing::info!("Manager API listening on {addr}");
let listener = tokio::net::TcpListener::bind(addr).await?;
let app = app.into_make_service_with_connect_info::<std::net::SocketAddr>();
axum::serve(listener, app).await?;
Ok(())
}

View file

@ -1,12 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Rampart Manager</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load diff

View file

@ -1,22 +0,0 @@
{
"name": "rampart-dashboard",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^4.3.0",
"typescript": "^5.6.0",
"vite": "^6.0.0"
}
}

View file

@ -1,378 +0,0 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--bg-primary: #1a1a2e;
--bg-secondary: #16213e;
--bg-card: #1f2b47;
--bg-sidebar: #0f3460;
--text-primary: #e0e0e0;
--text-secondary: #a0a0b0;
--accent: #e94560;
--accent-hover: #ff6b81;
--green: #2ecc71;
--red: #e74c3c;
--border: #2a3a5c;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen,
Ubuntu, Cantarell, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
}
#root {
min-height: 100vh;
}
/* Login */
.login-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: var(--bg-primary);
}
.login-card {
background: var(--bg-card);
padding: 2.5rem;
border-radius: 12px;
width: 100%;
max-width: 400px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.login-card h1 {
text-align: center;
margin-bottom: 0.5rem;
color: var(--accent);
font-size: 1.8rem;
}
.login-card p {
text-align: center;
color: var(--text-secondary);
margin-bottom: 1.5rem;
font-size: 0.9rem;
}
.login-card input {
width: 100%;
padding: 0.75rem;
margin-bottom: 1rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 1rem;
outline: none;
transition: border-color 0.2s;
}
.login-card input:focus {
border-color: var(--accent);
}
.login-card button {
width: 100%;
padding: 0.75rem;
background: var(--accent);
color: white;
border: none;
border-radius: 6px;
font-size: 1rem;
cursor: pointer;
transition: background 0.2s;
}
.login-card button:hover {
background: var(--accent-hover);
}
.login-card button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.login-error {
background: rgba(231, 76, 60, 0.15);
color: var(--red);
padding: 0.75rem;
border-radius: 6px;
margin-bottom: 1rem;
font-size: 0.85rem;
text-align: center;
}
/* Layout */
.layout {
display: flex;
min-height: 100vh;
}
.sidebar {
width: 240px;
background: var(--bg-sidebar);
padding: 1.5rem;
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.sidebar h2 {
color: var(--accent);
font-size: 1.3rem;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border);
}
.sidebar nav {
display: flex;
flex-direction: column;
gap: 0.25rem;
flex: 1;
}
.sidebar nav button {
background: none;
border: none;
color: var(--text-secondary);
padding: 0.75rem 1rem;
text-align: left;
font-size: 0.95rem;
cursor: pointer;
border-radius: 6px;
transition: all 0.2s;
}
.sidebar nav button:hover {
background: rgba(233, 69, 96, 0.1);
color: var(--text-primary);
}
.sidebar nav button.active {
background: rgba(233, 69, 96, 0.2);
color: var(--accent);
}
.sidebar .logout-btn {
margin-top: auto;
background: none;
border: 1px solid var(--border);
color: var(--text-secondary);
padding: 0.75rem;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s;
}
.sidebar .logout-btn:hover {
border-color: var(--accent);
color: var(--accent);
}
.main-content {
flex: 1;
padding: 2rem;
overflow-y: auto;
}
.main-content h1 {
font-size: 1.5rem;
margin-bottom: 1.5rem;
color: var(--text-primary);
}
/* Tables */
.table-container {
background: var(--bg-card);
border-radius: 10px;
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
thead {
background: var(--bg-secondary);
}
th {
padding: 0.85rem 1rem;
text-align: left;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary);
border-bottom: 1px solid var(--border);
}
td {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border);
font-size: 0.9rem;
}
tbody tr:nth-child(even) {
background: rgba(255, 255, 255, 0.02);
}
tbody tr:hover {
background: rgba(255, 255, 255, 0.04);
}
/* Status badges */
.status-badge {
display: inline-flex;
align-items: center;
gap: 0.4rem;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.status-dot.online {
background: var(--green);
box-shadow: 0 0 6px rgba(46, 204, 113, 0.5);
}
.status-dot.offline {
background: var(--red);
box-shadow: 0 0 6px rgba(231, 76, 60, 0.5);
}
/* Loading spinner */
.spinner {
display: flex;
align-items: center;
justify-content: center;
padding: 3rem;
}
.spinner::after {
content: '';
width: 36px;
height: 36px;
border: 3px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Error message */
.error-msg {
background: rgba(231, 76, 60, 0.1);
color: var(--red);
padding: 0.75rem 1rem;
border-radius: 6px;
margin-bottom: 1rem;
font-size: 0.85rem;
}
/* Success message */
.success-msg {
background: rgba(46, 204, 113, 0.1);
color: var(--green);
padding: 0.75rem 1rem;
border-radius: 6px;
margin-bottom: 1rem;
font-size: 0.85rem;
}
/* Blacklist form */
.blacklist-form {
background: var(--bg-card);
padding: 1.5rem;
border-radius: 10px;
margin-bottom: 1.5rem;
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: flex-end;
}
.blacklist-form input,
.blacklist-form select {
padding: 0.6rem 0.75rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 0.9rem;
outline: none;
min-width: 140px;
flex: 1;
}
.blacklist-form input:focus,
.blacklist-form select:focus {
border-color: var(--accent);
}
.blacklist-form button {
padding: 0.6rem 1.25rem;
background: var(--accent);
color: white;
border: none;
border-radius: 6px;
font-size: 0.9rem;
cursor: pointer;
white-space: nowrap;
transition: background 0.2s;
}
.blacklist-form button:hover {
background: var(--accent-hover);
}
.blacklist-form button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Responsive */
@media (max-width: 768px) {
.layout {
flex-direction: column;
}
.sidebar {
width: 100%;
padding: 1rem;
}
.sidebar nav {
flex-direction: row;
flex-wrap: wrap;
}
.sidebar .logout-btn {
margin-top: 0.5rem;
}
.main-content {
padding: 1rem;
}
.blacklist-form {
flex-direction: column;
}
.blacklist-form input,
.blacklist-form select,
.blacklist-form button {
width: 100%;
}
}

View file

@ -1,17 +0,0 @@
import { useState } from 'react'
import Login from './components/Login'
import Layout from './components/Layout'
function App() {
const [token, setToken] = useState<string | null>(
() => sessionStorage.getItem('rampart_token')
)
if (!token) {
return <Login onLogin={(t) => setToken(t)} />
}
return <Layout />
}
export default App

View file

@ -1,122 +0,0 @@
const BASE = 'http://localhost:8080/api/v1'
function getToken(): string | null {
return sessionStorage.getItem('rampart_token')
}
function setToken(token: string): void {
sessionStorage.setItem('rampart_token', token)
}
function clearToken(): void {
sessionStorage.removeItem('rampart_token')
}
async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
const token = getToken()
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
const res = await fetch(`${BASE}${path}`, { ...options, headers })
if (res.status === 401) {
clearToken()
window.location.reload()
throw new Error('Unauthorized')
}
if (!res.ok) {
const text = await res.text()
throw new Error(text || res.statusText)
}
return res.json()
}
export interface HealthResponse {
status: string
}
export interface LoginResponse {
token: string
}
export interface Server {
name: string
server_type: string
ip: string
port: number
status: string
online_players: number
max_players: number
tps: number
last_heartbeat: string
}
export interface BlacklistEntry {
target: string
type: string
reason: string
created: string
expires: string
}
export interface Node {
id: string
role: string
ip: string
status: string
last_heartbeat: string
}
export async function login(password: string): Promise<LoginResponse> {
const res = await fetch(`${BASE}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
})
if (!res.ok) {
const text = await res.text()
throw new Error(text || res.statusText)
}
const data: LoginResponse = await res.json()
setToken(data.token)
return data
}
export function logout(): void {
clearToken()
window.location.reload()
}
export async function fetchServers(): Promise<Server[]> {
return apiFetch<Server[]>('/servers')
}
export async function fetchBlacklist(): Promise<BlacklistEntry[]> {
return apiFetch<BlacklistEntry[]>('/blacklist')
}
export async function addBlacklist(
target: string,
type: string,
reason: string,
durationSecs: number
): Promise<void> {
await apiFetch('/blacklist', {
method: 'POST',
body: JSON.stringify({ target, type, reason, duration_secs: durationSecs }),
})
}
export async function fetchNodes(): Promise<Node[]> {
return apiFetch<Node[]>('/nodes')
}
export async function fetchHealth(): Promise<HealthResponse> {
return apiFetch<HealthResponse>('/health')
}

View file

@ -1,137 +0,0 @@
import { useState, useEffect, FormEvent } from 'react'
import { fetchBlacklist, addBlacklist, type BlacklistEntry } from '../api'
function Blacklist() {
const [entries, setEntries] = useState<BlacklistEntry[]>([])
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
const [target, setTarget] = useState('')
const [reason, setReason] = useState('')
const [duration, setDuration] = useState('3600')
const [adding, setAdding] = useState(false)
const [addError, setAddError] = useState('')
const [addSuccess, setAddSuccess] = useState('')
useEffect(() => {
let cancelled = false
async function load() {
try {
const data = await fetchBlacklist()
if (!cancelled) {
setEntries(data)
setError('')
}
} catch (err: unknown) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Failed to load blacklist')
}
} finally {
if (!cancelled) setLoading(false)
}
}
load()
const interval = setInterval(load, 30000)
return () => {
cancelled = true
clearInterval(interval)
}
}, [])
async function handleAdd(e: FormEvent) {
e.preventDefault()
setAddError('')
setAddSuccess('')
setAdding(true)
try {
await addBlacklist(target, 'ip', reason, parseInt(duration, 10))
setAddSuccess(`Added ${target} to blacklist`)
setTarget('')
setReason('')
setDuration('3600')
const data = await fetchBlacklist()
setEntries(data)
} catch (err: unknown) {
setAddError(err instanceof Error ? err.message : 'Failed to add entry')
} finally {
setAdding(false)
}
}
if (loading) return <div className="spinner" />
return (
<>
<h1>Blacklist</h1>
<form className="blacklist-form" onSubmit={handleAdd}>
<input
type="text"
placeholder="IP Address"
value={target}
onChange={(e) => setTarget(e.target.value)}
required
/>
<input
type="text"
placeholder="Reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
required
/>
<input
type="number"
placeholder="Duration (seconds)"
value={duration}
onChange={(e) => setDuration(e.target.value)}
min={1}
required
/>
<button type="submit" disabled={adding}>
{adding ? 'Adding...' : 'Add to Blacklist'}
</button>
</form>
{addError && <div className="error-msg">{addError}</div>}
{addSuccess && <div className="success-msg">{addSuccess}</div>}
{error && <div className="error-msg">{error}</div>}
<div className="table-container">
<table>
<thead>
<tr>
<th>Target</th>
<th>Type</th>
<th>Reason</th>
<th>Created</th>
<th>Expires</th>
</tr>
</thead>
<tbody>
{entries.length === 0 && (
<tr>
<td colSpan={5} style={{ textAlign: 'center', padding: '2rem', color: 'var(--text-secondary)' }}>
No blacklist entries
</td>
</tr>
)}
{entries.map((e, i) => (
<tr key={i}>
<td>{e.target}</td>
<td>{e.type}</td>
<td>{e.reason}</td>
<td>{new Date(e.created).toLocaleString()}</td>
<td>{new Date(e.expires).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)
}
export default Blacklist

View file

@ -1,53 +0,0 @@
import { useState } from 'react'
import { logout } from '../api'
import Servers from './Servers'
import Blacklist from './Blacklist'
import Nodes from './Nodes'
type Page = 'servers' | 'blacklist' | 'nodes'
const navItems: { key: Page; label: string }[] = [
{ key: 'servers', label: 'Servers' },
{ key: 'blacklist', label: 'Blacklist' },
{ key: 'nodes', label: 'Nodes' },
]
function Layout() {
const [page, setPage] = useState<Page>('servers')
function renderPage() {
switch (page) {
case 'servers':
return <Servers />
case 'blacklist':
return <Blacklist />
case 'nodes':
return <Nodes />
}
}
return (
<div className="layout">
<aside className="sidebar">
<h2>Rampart Manager</h2>
<nav>
{navItems.map((item) => (
<button
key={item.key}
className={page === item.key ? 'active' : ''}
onClick={() => setPage(item.key)}
>
{item.label}
</button>
))}
</nav>
<button className="logout-btn" onClick={logout}>
Logout
</button>
</aside>
<main className="main-content">{renderPage()}</main>
</div>
)
}
export default Layout

View file

@ -1,48 +0,0 @@
import { useState, FormEvent } from 'react'
import { login } from '../api'
interface LoginProps {
onLogin: (token: string) => void
}
function Login({ onLogin }: LoginProps) {
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
async function handleSubmit(e: FormEvent) {
e.preventDefault()
setError('')
setLoading(true)
try {
const res = await login(password)
onLogin(res.token)
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Login failed')
} finally {
setLoading(false)
}
}
return (
<div className="login-container">
<form className="login-card" onSubmit={handleSubmit}>
<h1>Rampart</h1>
<p>Manager Dashboard</p>
{error && <div className="login-error">{error}</div>}
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoFocus
/>
<button type="submit" disabled={loading || !password}>
{loading ? 'Logging in...' : 'Login'}
</button>
</form>
</div>
)
}
export default Login

View file

@ -1,82 +0,0 @@
import { useState, useEffect } from 'react'
import { fetchNodes, type Node } from '../api'
function Nodes() {
const [nodes, setNodes] = useState<Node[]>([])
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
async function load() {
try {
const data = await fetchNodes()
if (!cancelled) {
setNodes(data)
setError('')
}
} catch (err: unknown) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Failed to load nodes')
}
} finally {
if (!cancelled) setLoading(false)
}
}
load()
const interval = setInterval(load, 15000)
return () => {
cancelled = true
clearInterval(interval)
}
}, [])
if (loading) return <div className="spinner" />
return (
<>
<h1>Edge Nodes</h1>
{error && <div className="error-msg">{error}</div>}
<div className="table-container">
<table>
<thead>
<tr>
<th>ID</th>
<th>Role</th>
<th>IP</th>
<th>Status</th>
<th>Last Heartbeat</th>
</tr>
</thead>
<tbody>
{nodes.length === 0 && (
<tr>
<td colSpan={5} style={{ textAlign: 'center', padding: '2rem', color: 'var(--text-secondary)' }}>
No nodes found
</td>
</tr>
)}
{nodes.map((n) => (
<tr key={n.id}>
<td>{n.id}</td>
<td>{n.role}</td>
<td>{n.ip}</td>
<td>
<span className="status-badge">
<span className={`status-dot ${n.status === 'online' ? 'online' : 'offline'}`} />
{n.status}
</span>
</td>
<td>{new Date(n.last_heartbeat).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)
}
export default Nodes

View file

@ -1,86 +0,0 @@
import { useState, useEffect } from 'react'
import { fetchServers, type Server } from '../api'
function Servers() {
const [servers, setServers] = useState<Server[]>([])
const [error, setError] = useState('')
const [loading, setLoading] = useState(true)
useEffect(() => {
let cancelled = false
async function load() {
try {
const data = await fetchServers()
if (!cancelled) {
setServers(data)
setError('')
}
} catch (err: unknown) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Failed to load servers')
}
} finally {
if (!cancelled) setLoading(false)
}
}
load()
const interval = setInterval(load, 10000)
return () => {
cancelled = true
clearInterval(interval)
}
}, [])
if (loading) return <div className="spinner" />
return (
<>
<h1>Servers</h1>
{error && <div className="error-msg">{error}</div>}
<div className="table-container">
<table>
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>IP:Port</th>
<th>Status</th>
<th>Online/Max</th>
<th>TPS</th>
<th>Last Heartbeat</th>
</tr>
</thead>
<tbody>
{servers.length === 0 && (
<tr>
<td colSpan={7} style={{ textAlign: 'center', padding: '2rem', color: 'var(--text-secondary)' }}>
No servers found
</td>
</tr>
)}
{servers.map((s) => (
<tr key={s.name}>
<td>{s.name}</td>
<td>{s.server_type}</td>
<td>{s.ip}:{s.port}</td>
<td>
<span className="status-badge">
<span className={`status-dot ${s.status === 'online' ? 'online' : 'offline'}`} />
{s.status}
</span>
</td>
<td>{s.online_players}/{s.max_players}</td>
<td>{s.tps.toFixed(1)}</td>
<td>{new Date(s.last_heartbeat).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
</div>
</>
)
}
export default Servers

View file

@ -1,10 +0,0 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './App.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View file

@ -1 +0,0 @@
/// <reference types="vite/client" />

View file

@ -1,21 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View file

@ -1 +0,0 @@
{"root":["./src/App.tsx","./src/api.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/Blacklist.tsx","./src/components/Layout.tsx","./src/components/Login.tsx","./src/components/Nodes.tsx","./src/components/Servers.tsx"],"version":"5.9.3"}

View file

@ -1,7 +0,0 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View file

@ -1,19 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View file

@ -1 +0,0 @@
{"root":["./vite.config.ts"],"version":"5.9.3"}

View file

@ -1,6 +0,0 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
})

View file

@ -1,39 +1,44 @@
# Rampart edge — единый конфиг (схема: src/config/sections.rs)
[bind]
address = "0.0.0.0"
port = 25565
[backend]
address = "velocity"
port = 25577
[hmac]
secret = "test_secret_32_bytes_long_for_integration_test"
# Generic TCP-бэкенды за edge-нодой (addr:port)
upstreams = ["127.0.0.1:25566"]
[workers]
count = 2
count = 4
[limits]
handshake_timeout_secs = 5
max_connections_per_ip = 10
rate_limit_status_pps = 2
rate_limit_login_pps = 5
rate_limit_burst = 10
rate_limit_pps = 5.0
rate_limit_burst = 10.0
[ban]
ban_duration_secs = 3600
[store]
redis_url = "redis://redis:6379/0"
redis_url = ""
blacklist_cache_ttl_secs = 300
clickhouse_url = ""
[xdp]
enabled = false
interface = "eth0"
[logging]
level = "debug"
level = "info"
format = "text"
[metrics]
enabled = true
port = 9090
[xdp]
enabled = false
[pow]
enabled = false
difficulty = 4
whitelist = ["127.0.0.1", "::1"]

View file

@ -1,2 +0,0 @@
velocity:
enabled: false

View file

@ -1,7 +0,0 @@
server-port=25566
online-mode=false
motd=Rampart Test Server
max-players=20
spawn-protection=0
difficulty=peaceful
gamemode=creative

View file

@ -1,12 +0,0 @@
bind = "0.0.0.0:25577"
motd = "Rampart Test"
online-mode = false
show-max-players = 100
player-info-forwarding-mode = "NONE"
try-compressions-on-connect = false
[servers]
paper = "paper:25566"
[forced-hosts]
"play.example.com" = "paper"

View file

@ -44,6 +44,7 @@ services:
dockerfile: deploy/docker/Dockerfile.edge
ports:
- "25565:25565"
- "9090:9090"
environment:
RAMPART_CONFIG: /etc/rampart/config.toml
volumes:
@ -51,28 +52,3 @@ services:
depends_on:
redis:
condition: service_healthy
velocity:
build:
context: ../..
dockerfile: deploy/docker/Dockerfile.velocity
environment:
RAMPART_HMAC_SECRET: test_secret_32_bytes_long_for_integration_test
RAMPART_ALLOWED_DOMAINS: play.example.com
ports:
- "25577:25577"
depends_on:
- paper
paper:
build:
context: ../..
dockerfile: deploy/docker/Dockerfile.paper
environment:
EULA: "true"
RAMPART_HMAC_SECRET: test_secret_32_bytes_long_for_integration_test
RAMPART_ALLOWED_DOMAINS: play.example.com
ports:
- "25566:25566"
depends_on:
- redis

View file

@ -3,19 +3,25 @@ FROM rust:slim-bookworm AS builder
RUN apt-get update && apt-get install -y pkg-config libssl-dev clang libelf-dev libbpf-dev linux-libc-dev make && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY Cargo.toml Cargo.lock rustfmt.toml ./
COPY crates/ ./crates/
COPY Cargo.toml Cargo.lock rustfmt.toml clippy.toml deny.toml build.rs ./
COPY src/ ./src/
COPY xdp/ ./xdp/
RUN cargo build --release --features xdp --bin rampart-core && \
cp target/release/rampart-core /app/rampart-core && \
strip /app/rampart-core
# Smoke-check: BPF-программа компилируется clang'ом (не грузится, только сборка)
RUN if command -v clang >/dev/null; then \
mkdir -p /app/target/xdp && \
clang -O2 -g -target bpf -c xdp/core/universal_filter.c -o /app/target/xdp/universal_filter.o; \
fi
RUN cargo build --release --features xdp --bin rampart && \
cp target/release/rampart /app/rampart && \
strip /app/rampart
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates libelf1 && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/rampart-core /usr/local/bin/rampart-core
COPY --from=builder /app/rampart /usr/local/bin/rampart
EXPOSE 25565 9090
ENTRYPOINT ["rampart-core"]
ENTRYPOINT ["rampart"]

View file

@ -1,18 +0,0 @@
FROM eclipse-temurin:21-jre-noble
ARG PAPER_VERSION=1.21
ARG PAPER_BUILD=latest
ARG PAPER_JAR=deploy/docker/downloads/paper.jar
# The Paper server jar is downloaded on the CI runner by deploy/docker/download-paper.sh
# because the fill.papermc.io API is unreliably served inside the BuildKit container.
COPY ${PAPER_JAR} /opt/paper.jar
COPY deploy/config/server.properties /opt/server.properties
COPY deploy/config/paper-global.yml /opt/paper-global.yml
COPY plugins/paper/build/libs/rampart-paper-*.jar /opt/plugins/
EXPOSE 25566
WORKDIR /opt
CMD ["java", "-jar", "/opt/paper.jar", "--nogui"]

View file

@ -1,5 +1,5 @@
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates libc6 && rm -rf /var/lib/apt/lists/*
COPY rampart-core /usr/local/bin/rampart-core
COPY rampart /usr/local/bin/rampart
EXPOSE 25565 9090
ENTRYPOINT ["rampart-core"]
ENTRYPOINT ["rampart"]

View file

@ -1,27 +0,0 @@
FROM eclipse-temurin:21-jre-noble
ARG VELOCITY_VERSION=4.0.0
ARG VELOCITY_BUILD=6
RUN apt-get update && apt-get install -y curl jq && rm -rf /var/lib/apt/lists/*
# Download Velocity
RUN VERSION="${VELOCITY_VERSION}" && \
BUILD="${VELOCITY_BUILD}" && \
if [ "${BUILD}" = "latest" ]; then \
BUILD=$(curl -s -H "User-Agent: rampart/1.0.0 (https://github.com/loki5512344/rampart)" \
"https://fill.papermc.io/v3/projects/velocity/versions/${VERSION}/builds" | \
jq -r 'first(.[] | select(.channel == "STABLE") | .id) // empty'); \
fi && \
URL=$(curl -s -H "User-Agent: rampart/1.0.0 (https://github.com/loki5512344/rampart)" \
"https://fill.papermc.io/v3/projects/velocity/versions/${VERSION}/builds" | \
jq -r --arg b "${BUILD}" 'first(.[] | select(.id == ($b | tonumber)) | .downloads."server:default".url) // empty') && \
curl -fsSLo /opt/velocity.jar "${URL}"
COPY deploy/config/velocity.toml /opt/velocity.toml
COPY plugins/velocity/build/libs/rampart-velocity-*.jar /opt/plugins/
EXPOSE 25577
WORKDIR /opt
CMD ["java", "-jar", "/opt/velocity.jar", "/opt/velocity.toml"]

View file

@ -1,32 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
PAPER_VERSION="${PAPER_VERSION:-1.21}"
PAPER_BUILD="${PAPER_BUILD:-latest}"
OUT="${PAPER_OUT:-deploy/docker/downloads/paper.jar}"
UA="rampart/1.0.0 (https://github.com/loki5512344/rampart)"
mkdir -p "$(dirname "${OUT}")"
if [ "${PAPER_BUILD}" = "latest" ]; then
PAPER_BUILD="$(curl -fsSL -H "User-Agent: ${UA}" \
"https://fill.papermc.io/v3/projects/paper/versions/${PAPER_VERSION}/builds" \
| jq -r 'first(.[] | select(.channel == "STABLE") | .id) // empty')"
fi
URL="$(curl -fsSL -H "User-Agent: ${UA}" \
"https://fill.papermc.io/v3/projects/paper/versions/${PAPER_VERSION}/builds" \
| jq -r --arg b "${PAPER_BUILD}" 'first(.[] | select(.id == ($b | tonumber)) | .downloads."server:default".url) // empty')"
if [ -z "${URL}" ]; then
echo "error: no Paper download URL found for version ${PAPER_VERSION} build ${PAPER_BUILD}" >&2
exit 1
fi
echo "Downloading Paper ${PAPER_VERSION} build ${PAPER_BUILD}"
case "${URL}" in
http*) JAR_URL="${URL}" ;;
*) JAR_URL="https://fill.papermc.io${URL}" ;;
esac
curl -fsSLo "${OUT}" -H "User-Agent: ${UA}" "${JAR_URL}"
echo "Saved ${OUT}"

View file

@ -8,113 +8,66 @@
```
attacker ──┐
├── rampart-edge ── backend
│ (XDP отключён в тестах,
│ используется userspace-only режим)
│
mclient ───┘ (Minecraft клиент для теста легитимных коннектов)
├── rampart-edge ── backend (TCP echo stub)
legit ─────┘ (XDP отключён в тестах,
используется userspace-only режим)
```
## Быстрый старт
```bash
# 1. Сеть
docker network create rampart-test
# 2. Backend (Minecraft сервер или заглушка)
docker run -d --name backend --network rampart-test itzg/minecraft-server
# 3. Rampart edge
docker run -d --name rampart --network rampart-test \
-e RAMPART_CONFIG=/etc/rampart/config.toml \
-v ./config.test.toml:/etc/rampart/config.toml \
rampart-core
# 4. Аттакер (MHDDoS)
docker run -d --name attacker --network rampart-test \
--cap-add=NET_RAW --cap-add=NET_ADMIN \
python:3.11 bash -c "while true; do sleep 10; done"
# 5. Легитимный клиент (mclient.py)
docker run -d --name mclient --network rampart-test \
python:3.11 python mclient.py --target rampart:25565
# 1. Сборка + сеть + backend + edge + attacker
bash deploy/test/run_test.sh
```
Скрипт поднимает:
- `backend` — TCP echo-заглушка на 25566;
- `rampart` — edge-нода с конфигом `config.test.toml`;
- `attacker` — контейнер с python3 для запуска `stress/flood.py`.
## Сценарии тестирования
### 1. SYN flood
### 1. TCP connection flood (CPS)
```bash
docker exec attacker python3 /ref/MHDDoS/start.py SYN 172.x.x.x:25565 60 100
docker exec attacker python3 /flood.py --target rampart --port 25565 \
--mode connect --duration 30 --threads 50
```
Ожидание: Rampart XDP дропает SYN-пакеты после превышения throttle.
Метрика: `rampart_xdp_syn_throttle` растёт, CPU < 30%.
### 2. TCP connection flood (CPS)
```bash
docker exec attacker python3 /ref/MHDDoS/start.py CPS 172.x.x.x:25565 60 100
```
Ожидание: Rampart rate-limiter блокирует >50 conn/s с одного IP.
Ожидание: rate-limiter блокирует превышение лимита коннектов с одного IP.
Метрика: `rampart_rate_limit_hits` растёт.
### 3. Minecraft handshake flood
### 2. Slowloris (медленные соединения)
```bash
docker exec attacker python3 /ref/MHDDoS/start.py MINECRAFT 172.x.x.x:25565 60 100
docker exec attacker python3 /flood.py --target rampart --port 25565 \
--mode slowloris --duration 30 --threads 200
```
Ожидание: Layer 2 PoW требует решения хэш-задачи.
Метрика: `rampart_pow_challenges_total{result="failed"}` растёт.
### 4. Сложный ботнет (MHDDoS MCBOT)
```bash
docker exec attacker python3 /ref/MHDDoS/start.py MCBOT 172.x.x.x:25565 60 50
```
Ожидание: Physics check детектирует неестественное движение.
Требует: PhysicsCheckListener активен.
### 5. DNS amplification
```bash
docker exec attacker python3 /ref/MHDDoS/start.py DNS 172.x.x.x:53 60 100
```
Ожидание: XDP дропает UDP не на порты 25565-25575.
Метрика: `rampart_xdp_dropped` растёт.
### 6. Slowloris (L7)
```bash
docker exec attacker python3 /ref/MHDDoS/start.py SLOW http://172.x.x.x:9090 60 100
```
Ожидание: Таймаут чтения закрывает соединение.
Ожидание: таймаут чтения (`limits.handshake_timeout_secs`) закрывает соединения.
Метрика: `rampart_connections_total{result="blocked"}` растёт.
### 7. HTTP flood через cloudscraper (имитация CFB)
### 3. SYN flood (raw, требует hping3 или отдельный контейнер)
```bash
docker exec attacker python3 /ref/MHDDoS/start.py CFB http://172.x.x.x:9090 60 100
hping3 -S --flood -p 25565 <EDGE_IP>
```
Ожидание: L7 rate-limiter блокирует >100 req/s с одного IP.
Метрика: `rampart_rate_limit_hits` растёт.
Ожидание: обрабатывается kernel'ом; при включённом XDP — дропается в ядре.
## Легитимный тест (mclient.py)
## Легитимный клиент
Тест должен проходить: Rampart пропускает нормальный Minecraft handshake.
Во время атаки параллельно проверяем, что обычные клиенты проходят:
```bash
python3 deploy/test/mclient.py --target rampart:25565 --username test_player
python3 deploy/test/stress/legit.py --target localhost --port 25565
```
Ожидание: HMAC verified, соединение проксируется на backend.
## Метрики
Все метрики на http://localhost:9090/metrics:
```
rampart_xdp_total
rampart_xdp_passed
rampart_xdp_dropped
rampart_xdp_syn_throttle
rampart_xdp_verified
rampart_connections_total{result="allowed|blocked"}
rampart_rate_limit_hits{action="hit"}
rampart_rate_limit_hits
rampart_pow_challenges_total{result="passed|failed|skipped"}
rampart_pow_current_difficulty
rampart_attack_status
```
Grafana: http://localhost:3000 (admin/admin)
ClickHouse: http://localhost:8123 (для долгосрочных метрик)
ClickHouse (опционально): http://localhost:8123 — долгосрочное хранение событий.
Grafana: http://localhost:3000 (admin/admin) — дашборды из `deploy/grafana/`.

View file

@ -3,43 +3,36 @@ address = "0.0.0.0"
port = 25565
[backend]
address = "backend"
port = 25565
upstreams = ["backend:25566"]
[hmac]
secret = "test-secret-for-local-dev-only"
[worker]
[workers]
count = 4
[limits]
rate_limit_login_pps = 50
rate_limit_burst = 100
handshake_timeout_secs = 5
max_connections_per_ip = 10
rate_limit_pps = 50.0
rate_limit_burst = 100.0
[store]
redis_url = ""
blacklist_cache_ttl_secs = 300
clickhouse_url = "http://clickhouse:8123"
[xdp]
enabled = false
interface = "eth0"
[death_code]
enabled = true
[minecraft]
ping_timeout_ms = 5000
handshake_timeout_ms = 10000
[metrics]
enabled = true
port = 9090
[logging]
level = "debug"
format = "text"
[pow]
enabled = true
enabled = false
difficulty = 4
whitelist = ["127.0.0.1", "::1"]

View file

@ -1,73 +0,0 @@
#!/usr/bin/env python3
"""Minecraft handshake client for testing Rampart."""
import argparse
import socket
import struct
import time
def pack_varint(value):
buf = []
while True:
byte = value & 0x7F
value >>= 7
if value:
byte |= 0x80
buf.append(byte)
if not value:
break
return bytes(buf)
def make_handshake(host, port, protocol=767):
packet = bytearray()
packet.extend(pack_varint(protocol))
packet.extend(pack_varint(len(host)))
packet.extend(host.encode())
packet.extend(struct.pack(">H", port))
packet.extend(pack_varint(2))
length = pack_varint(len(packet))
return length + bytes(packet)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--target", default="localhost:25565")
parser.add_argument("--username", default="test_bot")
args = parser.parse_args()
host, port_str = args.target.split(":")
port = int(port_str)
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(10)
sock.connect((host, port))
sock.sendall(make_handshake(host, port))
data = sock.recv(4096)
if data:
print(f"Got response: {data.hex()}")
# If PoW challenge -> receive challenge, solve, send nonce
if b"challenge" in data:
print("PoW challenge received")
challenge = data.decode().strip()
for nonce in range(1000000):
import hashlib
h = hashlib.sha256(f"{challenge}{nonce}".encode()).hexdigest()
if h.startswith("0000"):
sock.sendall(str(nonce).encode())
resp = sock.recv(4096)
print(f"PoW ok, handshake: {resp.hex()}")
break
else:
print(f"Handshake response: {data.hex()}")
else:
print("No response (blocked)")
sock.close()
if __name__ == "__main__":
main()

View file

@ -4,50 +4,37 @@ set -euo pipefail
NET="rampart-test"
DIR="$(cd "$(dirname "$0")" && pwd)"
echo "=== Сборка rampart ==="
cargo build --release --bin rampart
echo "=== Создание сети ==="
docker network create "$NET" 2>/dev/null || true
echo "=== ClickHouse ==="
docker rm -f clickhouse 2>/dev/null || true
docker run -d --name clickhouse --network "$NET" \
-v "$DIR/../clickhouse/schema.sql:/docker-entrypoint-initdb.d/schema.sql" \
-p 8123:8123 \
clickhouse/clickhouse-server:latest
echo "=== Grafana ==="
docker rm -f grafana 2>/dev/null || true
docker run -d --name grafana --network "$NET" \
-p 3000:3000 \
-e GF_INSTALL_PLUGINS=grafana-clickhouse-datasource \
grafana/grafana:latest
echo "=== Backend (Minecraft stub) ==="
echo "=== Backend stub (TCP echo) ==="
docker rm -f backend 2>/dev/null || true
# simple TCP echo server as placeholder
docker run -d --name backend --network "$NET" \
alpine sh -c "apk add socat && socat TCP-LISTEN:25565,fork EXEC:'cat'"
alpine sh -c "apk add socat && socat TCP-LISTEN:25566,fork EXEC:'cat'"
echo "=== Rampart Edge ==="
docker rm -f rampart 2>/dev/null || true
TMPDIR=$(mktemp -d)
cp "$DIR/../../target/release/rampart-core" "$TMPDIR/"
cp "$DIR/../../target/release/rampart" "$TMPDIR/"
cp "$DIR/../docker/Dockerfile.test" "$TMPDIR/Dockerfile"
docker build -t rampart-core "$TMPDIR"
docker build -t rampart "$TMPDIR"
rm -rf "$TMPDIR"
docker run -d --name rampart --network "$NET" \
--cap-add=NET_ADMIN \
-p 25565:25565 -p 9090:9090 \
-e RAMPART_CONFIG=/etc/rampart/config.toml \
-v "$DIR/config.test.toml:/etc/rampart/config.toml" \
rampart-core
rampart
echo "=== Attacker (MHDDoS) ==="
echo "=== Attacker ==="
docker rm -f attacker 2>/dev/null || true
docker run -d --name attacker --network "$NET" \
--cap-add=NET_RAW --cap-add=NET_ADMIN \
-v "$DIR/../../ref/MHDDoS:/ref/MHDDoS" \
python:3.11 bash -c "
cd /ref/MHDDoS && pip install -r requirements.txt -q && \
-v "$DIR/stress/flood.py:/flood.py:ro" \
debian:bookworm-slim bash -c "
apt-get update -qq && apt-get install -y -qq python3 iproute2 >/dev/null && \
while true; do sleep 10; done
"
@ -55,10 +42,10 @@ echo ""
echo "=== Готово ==="
echo "Rampart edge: localhost:25565"
echo "Metrics: http://localhost:9090/metrics"
echo "Grafana: http://localhost:3000 (admin/admin)"
echo "ClickHouse: http://localhost:8123"
echo ""
echo "Пример атаки:"
echo " docker exec attacker python3 /ref/MHDDoS/start.py TCP rampart:25565 60 100"
echo " docker exec attacker python3 /flood.py --target rampart --port 25566 --mode connect --duration 30 --threads 50"
echo "Легитимный клиент:"
echo " python3 $DIR/stress/legit.py --target localhost --port 25565"
echo ""
echo "Для остановки: docker rm -f rampart backend attacker clickhouse grafana"
echo "Для остановки: docker rm -f rampart backend attacker"

View file

@ -1,237 +0,0 @@
#!/usr/bin/env python3
"""DDoS simulation: 100 IPs from attacker container, 3 legit clients from host."""
import subprocess, time, re, sys
TARGET_IP = "172.18.0.6"
TARGET_PORT = 25565
METRICS_URL = "http://localhost:9090/metrics"
DURATION = 30
NUM_IPS = 100
def metrics():
try:
import urllib.request
data = urllib.request.urlopen(METRICS_URL, timeout=5).read().decode()
result = {}
for line in data.splitlines():
if line.startswith("rampart_"):
parts = line.split()
if len(parts) >= 2:
result[parts[0]] = parts[-1]
return result
except:
return {}
def print_metrics(label, m):
print(f" [{label}]", end="")
for k, v in sorted(m.items()):
print(f" {k}={v}", end="")
print()
ALLOWED = set("0123")
def solve_pow(challenge, difficulty):
import hashlib
t0 = time.time()
for n in range(20_000_000):
h = hashlib.sha256(f"{challenge}{n}".encode()).hexdigest()
if all(c in ALLOWED for c in h[:difficulty]):
return n, time.time() - t0
return None, time.time() - t0
def legit_client(client_id, delay):
import socket, hashlib, struct
time.sleep(delay)
m = metrics()
diff = int(m.get("rampart_pow_current_difficulty", "4"))
try:
s = socket.socket()
s.settimeout(10)
s.connect(("localhost", TARGET_PORT))
data = s.recv(4096).decode().strip()
nonce, solve_t = solve_pow(data, diff)
if nonce is None:
print(f" [legit#{client_id}] FAILED to solve PoW (diff={diff})")
s.close()
return
s.sendall(f"{nonce}\n".encode())
time.sleep(0.1)
# MC handshake
def wv(v):
b = bytearray()
while True:
byte = v & 0x7F
v >>= 7
if v:
byte |= 0x80
b.append(byte)
if not v:
break
return bytes(b)
host = "localhost"
hs = bytearray()
hs.extend(wv(0))
hs.extend(wv(767))
hs.extend(wv(len(host)))
hs.extend(host.encode())
hs.extend(struct.pack(">H", 25565))
hs.extend(wv(2))
s.sendall(wv(len(hs)) + bytes(hs))
time.sleep(0.1)
name = f"test_{client_id}"
login = bytearray()
login.extend(wv(0))
login.extend(wv(len(name)))
login.extend(name.encode())
s.sendall(wv(len(login)) + bytes(login))
resp = s.recv(4096)
status = "OK" if resp else "no_resp"
print(
f" [legit#{client_id}] ✅ diff={diff} solve={solve_t:.3f}s status={status}"
)
except Exception as e:
print(f" [legit#{client_id}] ❌ diff={diff} error={e}")
finally:
try:
s.close()
except:
pass
def run_flood_in_attacker():
"""Run the 100-IP flood inside the attacker container."""
print("[setup] Launching flood inside attacker container...")
import os, tempfile
script = """
import socket, threading, time, struct
TARGET = ("172.18.0.6", 25565)
DURATION = 30
NUM_IPS = 100
sent = 0
lock = threading.Lock()
def wv(v):
b = bytearray()
while True:
byte = v & 0x7F
v >>= 7
if v: byte |= 0x80
b.append(byte)
if not v: break
return bytes(b)
host = "localhost"
handshake = wv(0) + wv(767) + wv(len(host)) + host.encode() + struct.pack(">H", 25565) + wv(2)
end = time.time() + DURATION
def flood():
global sent
while time.time() < end:
try:
s = socket.socket()
s.settimeout(5)
s.connect(TARGET)
s.sendall(wv(len(handshake)) + handshake)
with lock: sent += 1
s.close()
except: pass
threads = [threading.Thread(target=flood) for _ in range(NUM_IPS)]
for t in threads: t.start()
for t in threads: t.join()
print(f"FLOOD_DONE:{sent}")
"""
tmp = tempfile.mktemp(suffix=".py")
with open(tmp, "w") as f:
f.write(script)
subprocess.run(f'docker cp "{tmp}" attacker:/tmp/flood.py', shell=True, check=True)
os.unlink(tmp)
result = subprocess.run(
f"docker exec attacker python3 /tmp/flood.py",
shell=True,
capture_output=True,
text=True,
timeout=DURATION + 20,
)
for line in result.stdout.splitlines():
if "FLOOD_DONE" in line:
return int(line.split(":")[1])
print(" [flood] stdout:", result.stdout[-300:])
print(" [flood] stderr:", result.stderr[-300:])
return 0
# ── Main ──
print("=" * 60)
print("Rampart DDoS Simulation — 100 IP Handshake Flood + Legit Clients")
print("=" * 60)
before = metrics()
print_metrics("BEFORE", before)
# Launch flood in attacker container
flood_total = run_flood_in_attacker()
# Launch legit clients during flood
import threading
legit3 = threading.Thread(target=legit_client, args=(3, 25))
legit2 = threading.Thread(target=legit_client, args=(2, 15))
legit1 = threading.Thread(target=legit_client, args=(1, 5))
legit1.start()
time.sleep(0.1)
legit2.start()
time.sleep(0.1)
legit3.start()
# Poll metrics during attack
for i in range(DURATION // 5):
time.sleep(5)
m = metrics()
print_metrics(f"t={(i + 1) * 5}s", m)
legit1.join()
legit2.join()
legit3.join()
time.sleep(2)
after = metrics()
print_metrics("AFTER", after)
# Summary
print()
print("=" * 60)
print("SUMMARY")
print("=" * 60)
diff = lambda k: int(after.get(k, "0")) - int(before.get(k, "0"))
print(f" Total handshakes sent: {flood_total}")
print(f" CPS: {flood_total // DURATION}")
print(
f" PoW challenges failed: +{diff('rampart_pow_challenges_total{result="failed"}')}"
)
print(
f" PoW challenges passed: +{diff('rampart_pow_challenges_total{result="passed"}')}"
)
print(
f" Connections allowed: +{diff('rampart_connections_total{result="allowed"}')}"
)
print(
f" Connections blocked: +{diff('rampart_connections_total{result="blocked"}')}"
)
print(
f" PoW difficulty (start): {before.get('rampart_pow_current_difficulty', '?')}"
)
print(f" PoW difficulty (end): {after.get('rampart_pow_current_difficulty', '?')}")

View file

@ -1,6 +1,6 @@
# Стресс-тест Rampart на VDS (без Redis/Velocity/Paper)
# Стресс-тест Rampart на VDS (без Redis/ClickHouse)
Проверяет edge ноду (слои 1–3) на реальном VDS. Не требует Redis, Velocity, Paper или ClickHouse — только `rampart-core` и stub-бэкенд (socat echo) в одном контейнере.
Проверяет edge-ноду (слои 1–3) на реальном VDS. Не требует Redis, ClickHouse или внешних сервисов — только бинарь `rampart` и stub-бэкенд (socat echo) в одном контейнере.
## Топология
@ -9,7 +9,7 @@
│ docker bridge 172.30.0.0/24 │
│ │
│ rampart-edge (172.30.0.2) │
│ ├─ rampart-core :25565 (bind 0.0.0.0)│
│ ├─ rampart :25565 (bind 0.0.0.0)│
│ ├─ socat echo :25566 (127.0.0.1) │ ← stub бэкенд
│ └─ metrics :9090 │
│ │
@ -26,7 +26,7 @@
```bash
# 1. На VDS: клонировать репозиторий, собрать бинарь
git clone https://github.com/loki5512344/rampart.git && cd rampart
cargo build --release --bin rampart-core
cargo build --release --bin rampart
# 2. Запустить весь цикл (setup + 4 фазы)
# run-stress.sh сам найдёт бинарь (target/release), а папку — по себе (переменная DIR опциональна)
@ -40,21 +40,19 @@ bash run-stress.sh
| Фаза | Конфиг | Что делает | Проверяет |
|------|--------|-----------|-----------|
| A | `edge-high.toml` (лимиты 100k) | флуд валидными handshake с 100 IP | сырую пропускную способность L7 |
| B | `edge-defense.toml` (дефолт 5 pps/IP) | та же атака | rate limit + reputation ban, доступность легитимных клиентов |
| A | `edge-high.toml` (лимиты 100k) | TCP connect flood со 100 IP | сырую пропускную способность L4 |
| B | `edge-defense.toml` (дефолтные лимиты) | та же атака | rate limit + reputation ban, доступность легитимных клиентов |
| C | — | SYN flood hping3 (rand-source) | поведение без XDP (обрабатывает kernel) |
| D | `edge-high.toml` | 300 keepalive-коннектов | удержание активных соединений |
Во время фаз A и B параллельно подключается `legit.py` (легитимные MC клиенты), меряющий RTT — доказывает, что реальные игроки проходят во время атаки.
Во время фаз A и B параллельно подключается `legit.py` (generic TCP-клиенты), меряющий RTT — доказывает, что реальные клиенты проходят во время атаки.
## Атака маскируется под обычный трафик
## Атаки
`flood.py` шлёт **валидные** Minecraft handshake (протокол 767, packet id 0x00) со случайными hostname из пула (`play.example.com`, `mc.example.com`, ...) и ждёт ответ бэкенда — на уровне L7 флуд неотличим от легитимного клиента. Различие даёт только per-IP rate limit и репутация.
`flood.py` открывает чистые TCP-соединения (connect / slowloris / keepalive режимы) со случайных source IP. Никакой протокольной маскировки нет: на уровне L3/L4 флуд неотличим от легитимного трафика. Различие даёт только per-IP rate limit и репутация.
## Метрики для сбора
```bash
curl -s http://127.0.0.1:9090/metrics | grep -E 'rampart_(connections_total|pow_challenges|attack_status)'
curl -s http://127.0.0.1:9090/metrics | grep -E 'rampart_(connections_total|rate_limit_hits|attack_status)'
```
См. [load-test-report.md](../../research/load-test-report.md) — результаты прогона на VDS (2026-08-04).

View file

@ -0,0 +1,4 @@
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 hping3 iproute2 ca-certificates && rm -rf /var/lib/apt/lists/*
CMD ["sleep", "infinity"]

View file

@ -3,11 +3,7 @@ address = "0.0.0.0"
port = 25565
[backend]
address = "127.0.0.1"
port = 25566
[hmac]
secret = "test_secret_32_bytes_long_for_integration_test"
upstreams = ["127.0.0.1:25566"]
[workers]
count = 2
@ -15,22 +11,21 @@ count = 2
[limits]
handshake_timeout_secs = 5
max_connections_per_ip = 10
rate_limit_status_pps = 2
rate_limit_login_pps = 5
rate_limit_burst = 10
rate_limit_pps = 2.0
rate_limit_burst = 10.0
[ban]
ban_duration_secs = 3600
[store]
redis_url = ""
blacklist_cache_ttl_secs = 300
clickhouse_url = ""
[xdp]
enabled = false
interface = "eth0"
[death_code]
enabled = true
ban_duration_secs = 3600
[metrics]
enabled = true
port = 9090

View file

@ -3,11 +3,7 @@ address = "0.0.0.0"
port = 25565
[backend]
address = "127.0.0.1"
port = 25566
[hmac]
secret = "test_secret_32_bytes_long_for_integration_test"
upstreams = ["127.0.0.1:25566"]
[workers]
count = 2
@ -15,22 +11,18 @@ count = 2
[limits]
handshake_timeout_secs = 5
max_connections_per_ip = 100000
rate_limit_status_pps = 100000
rate_limit_login_pps = 100000
rate_limit_burst = 100000
rate_limit_pps = 100000.0
rate_limit_burst = 100000.0
[store]
redis_url = ""
blacklist_cache_ttl_secs = 300
clickhouse_url = ""
[xdp]
enabled = false
interface = "eth0"
[death_code]
enabled = true
ban_duration_secs = 3600
[metrics]
enabled = true
port = 9090

View file

@ -1,6 +1,6 @@
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends socat ca-certificates && rm -rf /var/lib/apt/lists/*
COPY rampart-core /usr/local/bin/rampart-core
COPY rampart /usr/local/bin/rampart
COPY start.sh /start.sh
RUN chmod +x /start.sh
EXPOSE 25565 9090

View file

@ -1,63 +1,24 @@
#!/usr/bin/env python3
"""Много-IP Minecraft stress generator, маскирующийся под обычный трафик.
"""Много-IP TCP stress generator.
Отправляет валидные MC handshake с рандомными hostname из разных source IP
(--ips-start..--ips-end должны быть назначены на eth0 контейнера).
Открывает TCP-соединения с разных source IP (--ips-start..--ips-end должны
быть назначены на eth0 контейнера). Без маскировки под конкретный протокол:
флуд неотличим от обычных TCP-коннектов на уровне L3/L4, различие даёт
только per-IP rate limit и репутация на edge.
Modes:
connect - TCP connect + close (conn/s flood)
handshake - валидный MC handshake + ждём ответ backend (маскировка под клиента)
status - status-ping handshake (state 1)
slowloris - connect + 1 байт + hold
keepalive - connect + handshake + держим соединение
slowloris - connect + 1 байт + hold (медленные соединения)
keepalive - connect + держим соединение открытым
"""
import argparse
import random
import socket
import struct
import threading
import time
from datetime import datetime
HOSTS = [
"play.example.com",
"mc.example.com",
"lobby.example.com",
"hub.example.com",
"survival.example.com",
"skyblock.example.com",
"bedwars.example.com",
"minigames.example.com",
"vip.example.com",
]
PROTOCOL = 767
def pack_varint(value):
buf = []
while True:
byte = value & 0x7F
value >>= 7
if value:
byte |= 0x80
buf.append(byte)
if not value:
break
return bytes(buf)
def make_handshake(state=2, host=None):
host = host or random.choice(HOSTS)
pkt = bytearray()
pkt.extend(pack_varint(0)) # packet ID 0x00
pkt.extend(pack_varint(PROTOCOL))
pkt.extend(pack_varint(len(host)))
pkt.extend(host.encode())
pkt.extend(struct.pack(">H", 25565))
pkt.extend(pack_varint(state))
return pack_varint(len(pkt)) + bytes(pkt)
def parse_args():
p = argparse.ArgumentParser()
@ -65,8 +26,8 @@ def parse_args():
p.add_argument("--port", type=int, default=25565)
p.add_argument(
"--mode",
choices=["connect", "handshake", "status", "slowloris", "keepalive"],
default="handshake",
choices=["connect", "slowloris", "keepalive"],
default="connect",
)
p.add_argument("--duration", type=int, default=30)
p.add_argument("--threads", type=int, default=100)
@ -92,31 +53,25 @@ def main():
s.bind((src, 0))
s.settimeout(args.timeout)
s.connect((args.target, args.port))
if args.mode == "handshake":
s.sendall(make_handshake())
try:
s.recv(1)
except socket.timeout:
pass
elif args.mode == "status":
s.sendall(make_handshake(state=1))
try:
s.recv(1)
except socket.timeout:
pass
if args.mode == "connect":
pass
elif args.mode == "slowloris":
s.sendall(b"\x01")
time.sleep(30)
try:
s.sendall(b"\x01")
time.sleep(30)
except OSError:
pass
elif args.mode == "keepalive":
s.sendall(make_handshake())
time.sleep(30)
try:
time.sleep(30)
except OSError:
pass
with stats["lock"]:
stats["sent"] += 1
s.close()
if args.mode == "connect":
s.close()
except OSError:
pass
except Exception:
pass
threads = [
threading.Thread(target=worker, daemon=True) for _ in range(args.threads)

View file

@ -1,36 +1,11 @@
#!/usr/bin/env python3
"""Легитимный Minecraft клиент: подключается ВО ВРЕМЯ DDoS и меряет RTT."""
"""Generic TCP-клиент: подключается ВО ВРЕМЯ DDoS и меряет RTT."""
import argparse
import socket
import struct
import time
def pack_varint(value):
buf = []
while True:
byte = value & 0x7F
value >>= 7
if value:
byte |= 0x80
buf.append(byte)
if not value:
break
return bytes(buf)
def make_handshake(host="play.example.com", port=25565, state=2):
pkt = bytearray()
pkt.extend(pack_varint(0))
pkt.extend(pack_varint(767))
pkt.extend(pack_varint(len(host)))
pkt.extend(host.encode())
pkt.extend(struct.pack(">H", port))
pkt.extend(pack_varint(state))
return pack_varint(len(pkt)) + bytes(pkt)
def main():
p = argparse.ArgumentParser()
p.add_argument("--target", default="127.0.0.1")
@ -47,7 +22,7 @@ def main():
try:
s = socket.create_connection((args.target, args.port), timeout=args.timeout)
s.settimeout(args.timeout)
s.sendall(make_handshake())
# Ждём первый байт от edge (PoW challenge / upstream-ответ).
resp = s.recv(1)
rtt = (time.time() - t0) * 1000
ok = len(resp) > 0

View file

@ -48,8 +48,8 @@ run_flood_phase() {
local ba bb bp
ba=$(mget 'rampart_connections_total{result="allowed"}')
bb=$(mget 'rampart_connections_total{result="blocked"}')
bp=$(mget 'rampart_pow_challenges_total{result="failed"}')
echo "baseline: allowed=$ba blocked=$bb pow_fail=$bp"
bp=$(mget 'rampart_rate_limit_hits')
echo "baseline: allowed=$ba blocked=$bb rate_limit_hits=$bp"
docker exec rampart-attacker python3 /flood.py \
--target "$EDGE_IP" --port 25565 --mode "$mode" \
@ -68,10 +68,10 @@ run_flood_phase() {
local a bl p s cpu
a=$(mget 'rampart_connections_total{result="allowed"}')
bl=$(mget 'rampart_connections_total{result="blocked"}')
p=$(mget 'rampart_pow_challenges_total{result="failed"}')
p=$(mget 'rampart_rate_limit_hits')
s=$(mget 'rampart_attack_status ')
cpu=$(edge_cpu)
echo " [t=${i}x5s] status=$s cpu=$cpu allowed=+$((a - ba)) blocked=+$((bl - bb)) pow_fail=+$((p - bp))"
echo " [t=${i}x5s] status=$s cpu=$cpu allowed=+$((a - ba)) blocked=+$((bl - bb)) rate_limit_hits=+$((p - bp))"
done
wait "$flood_pid" || true
@ -80,14 +80,14 @@ run_flood_phase() {
local ea eb ep es ec
ea=$(mget 'rampart_connections_total{result="allowed"}')
eb=$(mget 'rampart_connections_total{result="blocked"}')
ep=$(mget 'rampart_pow_challenges_total{result="failed"}')
ep=$(mget 'rampart_rate_limit_hits')
es=$(mget 'rampart_attack_status ')
ec=$(edge_cpu)
echo "--- итог фазы $phase ---"
echo " attack_status=$es cpu=$ec"
echo " allowed: $((ea - ba)) (+$(( (ea - ba) / duration ))/s)"
echo " blocked: $((eb - bb)) (+$(( (eb - bb) / duration ))/s)"
echo " pow_fail: $((ep - bp))"
echo " rate_limit_hits: $((ep - bp))"
echo "--- легитимные клиенты во время фазы ---"
grep -E '^\[phase' /tmp/legit_$phase.log || true
echo ""
@ -101,12 +101,12 @@ docker network create --subnet "$SUB" "$NET" >/dev/null
# Подготовка контекстов: бинарь ищем в target/release репозитория
mkdir -p "$DIR/edge-ctx" "$DIR/attacker-ctx"
cp "$DIR/flood.py" "$DIR/attacker-ctx/flood.py" 2>/dev/null || true
if [ ! -f "$DIR/edge-ctx/rampart-core" ]; then
for p in "$DIR/rampart-core" "$DIR/repo/target/release/rampart-core" "$DIR/../target/release/rampart-core"; do
if [ -f "$p" ]; then cp "$p" "$DIR/edge-ctx/rampart-core"; break; fi
if [ ! -f "$DIR/edge-ctx/rampart" ]; then
for p in "$DIR/rampart" "$DIR/repo/target/release/rampart" "$DIR/../../target/release/rampart"; do
if [ -f "$p" ]; then cp "$p" "$DIR/edge-ctx/rampart"; break; fi
done
fi
[ -f "$DIR/edge-ctx/rampart-core" ] || { echo "ERROR: rampart-core не найден. Собери: cargo build --release --bin rampart-core" >&2; exit 1; }
[ -f "$DIR/edge-ctx/rampart" ] || { echo "ERROR: rampart не найден. Собери: cargo build --release --bin rampart" >&2; exit 1; }
docker build -q -f "$DIR/edge.Dockerfile" -t rampart-edge "$DIR/edge-ctx"
docker build -q -f "$DIR/attacker.Dockerfile" -t rampart-attacker "$DIR/attacker-ctx"
@ -124,34 +124,30 @@ docker exec rampart-attacker sh -c '
echo ""
echo "############### PHASE A: СЫРАЯ ПРОПУСКНАЯ СПОСОБНОСТЬ ###############"
echo "############### (лимиты сняты: 100k pps, 100 src IP, валидные handshake) ###############"
run_flood_phase A edge-high.toml 30 handshake 100 "$IPS_END" "raw throughput, 100 IP flood, valid handshake"
echo "############### (лимиты сняты: 100k conn/s, 100 src IP, TCP connect flood) ###############"
run_flood_phase A edge-high.toml 30 connect 100 "$IPS_END" "raw throughput, 100 IP TCP connect flood"
echo ""
echo "############### PHASE B: ЗАЩИТА (дефолтные лимиты 5 pps/IP) ###############"
echo "############### PHASE B: ЗАЩИТА (дефолтные лимиты 2 pps/IP) ###############"
echo "############### (та же атака, но теперь edge режет по IP; легитимные клиенты заходят) ###############"
run_flood_phase B edge-defense.toml 30 handshake 100 "$IPS_END" "defense, rate limit 5pps/IP + reputation bans"
run_flood_phase B edge-defense.toml 30 connect 100 "$IPS_END" "defense, rate limit + reputation bans"
echo ""
echo "############### PHASE C: SYN flood ###############"
echo "=============================================================="
echo "ФАЗА C: SYN flood hping3 (rand-source, 20s)"
echo "=============================================================="
ba=$(mget 'rampart_connections_total{result="allowed"}')
bb=$(mget 'rampart_connections_total{result="blocked"}')
timeout 20 docker exec rampart-attacker hping3 -S --flood --rand-source -p 25565 "$EDGE_IP" || true
sleep 2
ea=$(mget 'rampart_connections_total{result="allowed"}')
eb=$(mget 'rampart_connections_total{result="blocked"}')
es=$(mget 'rampart_attack_status ')
echo " attack_status=$es cpu=$(edge_cpu) allowed=+$((ea - ba)) blocked=+$((eb - bb))"
echo " attack_status=$es cpu=$(edge_cpu)"
echo " (SYN flood обрабатывается kernel'ом/XDP, L7 edge почти не задет)"
echo ""
echo ""
echo "############### PHASE D: активные соединения (keepalive) ###############"
echo "=============================================================="
echo "ФАЗА D: 300 keepalive коннектов (валидный handshake, держим открытым)"
echo "ФАЗА D: 300 keepalive коннектов (держим открытыми)"
echo "=============================================================="
start_edge edge-high.toml
ba=$(mget 'rampart_connections_total{result="allowed"}')
@ -166,7 +162,7 @@ echo ""
echo "=============================================================="
echo "ИТОГОВЫЙ СВОД"
echo "=============================================================="
edge_metrics | grep -E 'connections_total|pow_challenges|attack_status'
edge_metrics | grep -E 'connections_total|rate_limit_hits|attack_status'
echo ""
echo "CPU/память контейнеров:"
docker stats --no-stream --format 'table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}' rampart-edge rampart-attacker

View file

@ -1,4 +1,5 @@
#!/bin/sh
# Stub backend (TCP echo) на 127.0.0.1:25566 для стресс-теста edge ноды.
socat TCP-LISTEN:25566,fork,reuseaddr,bind=127.0.0.1 EXEC:'cat' &
exec rampart-core --config /etc/rampart/config.toml
export RAMPART_CONFIG=/etc/rampart/config.toml
exec rampart

View file

@ -1,5 +1,3 @@
version: "3.9"
services:
redis:
image: redis:7-alpine
@ -16,21 +14,6 @@ services:
retries: 5
restart: unless-stopped
nats:
image: nats:2-alpine
container_name: rampart-nats
ports:
- "4222:4222"
command: -js -c /etc/nats/nats.conf
volumes:
- nats-data:/data
healthcheck:
test: ["CMD", "nats", "server", "check"]
interval: 10s
timeout: 5s
retries: 3
restart: unless-stopped
clickhouse:
image: clickhouse/clickhouse-server:24-alpine
container_name: rampart-clickhouse
@ -39,7 +22,7 @@ services:
- "9000:9000" # Native TCP
volumes:
- clickhouse-data:/var/lib/clickhouse
- ./clickhouse-init:/docker-entrypoint-initdb.d
- ./deploy/clickhouse/schema.sql:/docker-entrypoint-initdb.d/schema.sql:ro
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8123/ping"]
interval: 10s
@ -47,19 +30,6 @@ services:
retries: 5
restart: unless-stopped
prometheus:
image: prom/prometheus:latest
container_name: rampart-prometheus
ports:
- "9090:9090"
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
restart: unless-stopped
grafana:
image: grafana/grafana:latest
container_name: rampart-grafana
@ -67,14 +37,16 @@ services:
- "3000:3000"
environment:
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD:-admin}
- GF_INSTALL_PLUGINS=grafana-clickhouse-datasource
volumes:
- grafana-data:/var/lib/grafana
- ./dashboards:/etc/grafana/provisioning/dashboards
- ./deploy/grafana/datasources:/etc/grafana/provisioning/datasources:ro
- ./deploy/grafana/dashboards:/etc/grafana/provisioning/dashboards:ro
depends_on:
- clickhouse
restart: unless-stopped
volumes:
redis-data:
nats-data:
clickhouse-data:
prometheus-data:
grafana-data:

View file

@ -1,8 +1,10 @@
# API Reference - Rampart Manager
> REST API для управления Rampart.
> Base URL: `https://manager.rampart.internal/api/v1`
> Авторизация: Bearer JWT (получить через `/api/v1/auth/login`)
> REST API управления. Base URL: `http://MANAGER:8080`
> Авторизация: Bearer JWT (получить через `/api/v1/auth/login`).
Реализованные маршруты — см. src/bin/rampart-manager.rs. Это полный список:
ничего сверх перечисленного здесь API не предоставляет.
---
@ -10,408 +12,110 @@
### `POST /api/v1/auth/login`
Получение JWT токена.
```json
// Request
{
"password": "changeme"
}
{"password": "значение API_PASSWORD"}
// Response 200
{
"token": "eyJhbGciOiJIUzI1NiIs..."
}
{"token": "eyJhbGciOiJIUzI1NiIs..."}
// 401 → неверный пароль; 429 → больше 5 попыток за минуту с одного IP
```
Все последующие запросы:
Все защищённые запросы:
```
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
Authorization: Bearer <token>
```
Токен живёт `JWT_EXPIRATION_SECS` секунд (дефолт 86400).
---
## Nodes
## Health (публичный)
### `GET /api/v1/nodes`
Список всех зарегистрированных нод.
### `GET /api/v1/health`
```json
// Response 200
{
"nodes": [
{
"id": "edge-eu-1",
"role": "edge",
"ip": "10.0.100.1",
"public_ip": "45.200.10.1",
"status": "online",
"version": "0.4.0",
"uptime_secs": 86400,
"metrics": {
"connections_per_sec": 1200,
"active_connections": 45000,
"cpu_percent": 45.2,
"memory_mb": 512
},
"last_heartbeat": "2026-07-19T10:30:00Z"
}
]
}
```
### `GET /api/v1/nodes/{id}`
Детальная информация о ноде.
### `POST /api/v1/nodes`
Регистрация новой ноды (или через авто-discovery).
```json
// Request
{
"name": "edge-us-2",
"role": "edge",
"public_ip": "45.200.20.5",
"wg_public_key": "<BASE64_KEY>"
}
// Response 201
{
"id": "edge-us-2",
"wg_config": "https://manager/api/v1/nodes/edge-us-2/wg-config",
"tls_cert": "https://manager/api/v1/nodes/edge-us-2/cert"
}
```
### `POST /api/v1/nodes/{id}/drain`
Вывести ноду из ротации (graceful shutdown).
```json
// Response 200
{
"status": "draining",
"active_connections_before": 45000,
"estimated_seconds": 30
}
```
---
## Blacklist
### `GET /api/v1/blacklist`
Список забаненных IP/ASN.
| Параметр | Тип | По умолчанию | Описание |
|----------|-----|-------------|----------|
| `page` | int | 1 | Пагинация |
| `per_page` | int | 100 | Элементов на странице |
| `reason` | string | - | Фильтр по причине |
| `search` | string | - | Поиск по IP/ASN |
```json
// Response 200
{
"items": [
{
"target": "1.2.3.4",
"type": "ip", // ip | asn | cidr
"reason": "rate_limit",
"created_by": "admin",
"created_at": "2026-07-19T10:00:00Z",
"expires_at": "2026-07-20T10:00:00Z",
"hits": 1500
}
],
"total": 42,
"page": 1,
"per_page": 100
}
```
### `POST /api/v1/blacklist`
Добавить IP/ASN/CIDR в блэклист.
```json
// Request
{
"target": "1.2.3.4",
"type": "ip", // ip | asn | cidr
"reason": "manual_ban",
"duration_secs": 3600 // null = навсегда
}
// Response 201
{
"status": "added",
"target": "1.2.3.4",
"propagated_to_nodes": 2,
"expires_at": "2026-07-19T11:00:00Z"
}
```
### `DELETE /api/v1/blacklist/{id}`
Удалить запись из блэклиста.
```json
// Response 200
{
"status": "removed",
"target": "1.2.3.4"
}
{"status": "healthy", "version": "0.3.0-dev"}
```
---
## Servers
### `GET /api/v1/servers`
### `GET /api/v1/servers` 🔒
Список зарегистрированных game серверов.
Список серверов из Redis-реестра (`rampart:servers:*`).
```json
// Response 200
{
"servers": [
{
"name": "survival-01",
"type": "survival",
"ip": "10.0.2.1",
"port": 25565,
"status": "online",
"proxy": "velocity-01",
"online": 42,
"max_players": 100,
"tps": 19.8,
"mspt": 25.3,
"ram_used_mb": 2048,
"ram_max_mb": 8192,
"last_heartbeat": "2026-07-19T10:30:00Z"
}
{"name": "app-01", "type": "tcp", "ip": "10.0.2.1", "port": 25566, "status": "online"}
]
}
```
### `GET /api/v1/servers/{name}`
Детальная информация о сервере.
### `DELETE /api/v1/servers/{name}`
Принудительно удалить сервер из registry.
Redis недоступен → пустой список.
---
## Challenges (v0.5+)
## Blacklist
### `GET /api/v1/challenges/status`
Статус challenge системы.
### `GET /api/v1/blacklist` 🔒
```json
// Response 200
{
"enabled": true,
"mode": "auto",
"active_challenges": 15,
"passed_last_hour": 1200,
"failed_last_hour": 45,
"current_type": "timing"
}
```
### `POST /api/v1/challenges/rotate`
Принудительно сменить тип challenge.
```json
// Request
{
"type": "map_captcha" // timing | map_captcha | behavioral | contextual
}
// Response 200
{
"status": "rotated",
"previous_type": "timing",
"new_type": "map_captcha",
"rotated_at": "2026-07-19T10:30:00Z"
}
```
---
## Metrics
### `GET /api/v1/metrics/summary`
Сводка метрик за период.
| Параметр | Тип | По умолчанию | Описание |
|----------|-----|-------------|----------|
| `since` | ISO8601 | -24h | Начало периода |
| `until` | ISO8601 | now | Конец периода |
```json
// Response 200
{
"total_connections": 5200000,
"blocked": 45000,
"allowed": 5155000,
"active_connections": 85000,
"top_attackers": [
{"ip": "5.5.5.5", "hits": 12000, "country": "NL"},
{"ip": "6.6.6.6", "hits": 8000, "country": "RU"}
"items": [
{"target": "1.2.3.4", "type": "ip", "reason": "manual_ban",
"created_at": "...", "expires_at": null}
],
"top_countries": [
{"country": "US", "connections": 2000000},
{"country": "DE", "connections": 1000000}
"total": 42
}
```
Источник: Redis set `rampart:blacklist`. Пагинации и фильтров нет.
### `POST /api/v1/blacklist` 🔒
```json
// Request
{"target": "1.2.3.4", "type": "ip", "reason": "manual_ban", "duration_secs": 3600}
// Response 200/201 — запись добавлена в Redis set
```
Edge-ноды подхватывают ban при следующей синхронизации блэклиста.
---
## Nodes
### `GET /api/v1/nodes` 🔒
Список зарегистрированных edge-нод из Redis (`rampart:nodes:*`, heartbeat).
```json
{
"nodes": [
{"id": "edge-eu-1", "role": "edge", "ip": "10.0.100.1",
"status": "online", "last_heartbeat": "..."}
]
}
```
---
## Health
## Не реализовано
### `GET /api/v1/health`
Честно, на текущий момент в API **нет**:
```json
// Response 200
{
"status": "healthy",
"version": "0.4.0",
"uptime_secs": 604800,
"components": {
"redis": "healthy",
"nats": "healthy",
"clickhouse": "healthy",
"edge_nodes": {"online": 2, "offline": 0},
"velocity_nodes": {"online": 3, "offline": 1},
"game_servers": {"online": 45, "offline": 2}
}
}
```
- удаления записей blacklist (только add/list);
- drain нод через API (есть только CLI `rampart-cli drain`);
- webhooks, metrics summary, challenge management;
- регистрации нод через `POST /api/v1/nodes`;
- rate limiting на сами запросы API (кроме login).
---
## Webhooks
### `POST /api/v1/webhooks`
Настройка webhook для событий.
```json
// Request
{
"url": "https://discord.com/api/webhooks/...",
"events": ["blacklist.added", "node.down", "attack.detected"],
"secret": "optional_hmac_secret"
}
// Response 201
{
"id": "wh_abc123",
"status": "active"
}
```
### Payload пример (blacklist.added)
```json
{
"event": "blacklist.added",
"timestamp": "2026-07-19T10:30:00Z",
"data": {
"target": "1.2.3.4",
"reason": "rate_limit",
"added_by": "auto"
}
}
```
---
## OpenAPI Spec
Полная OpenAPI 3.0 спецификация: `docs/api/openapi.yaml`
```yaml
openapi: "3.0.3"
info:
title: Rampart Manager API
version: "0.4.0"
servers:
- url: https://manager.rampart.internal/api/v1
paths:
/nodes:
get:
summary: List all nodes
security:
- bearerAuth: []
responses:
'200':
description: Node list
/blacklist:
post:
summary: Add to blacklist
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
target:
type: string
type:
type: string
enum: [ip, asn, cidr]
reason:
type: string
duration_secs:
type: integer
responses:
'201':
description: Added
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
```
---
## Rate Limiting
API имеет rate limiting: **60 запросов в минуту** на один JWT токен.
```json
// Response 429
{
"error": "rate_limit_exceeded",
"retry_after_secs": 30
}
```
Headers:
```
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 42
X-RateLimit-Reset: 1626688800
```
---
*Версия: 1.0 | Июль 2026*
*Версия: 2.0 | Август 2026*

View file

@ -1,30 +1,9 @@
# Configuration - Rampart
> Примеры конфигурационных файлов для всех компонентов.
> Единый конфиг edge-ноды (`config.toml`) и переменные окружения Manager.
> Схема конфига: `src/config/sections.rs` — единственный источник правды.
## Как конфиги связывают компоненты
```
Edge config.toml Manager (env)
bind.address JWT_SECRET
bind.port API_PASSWORD
backend.address ───→ REDIS_URL
hmac.secret ←──────┐ CLICKHOUSE_URL
store.redis_url ────┤
│
Velocity (env) │ Paper (env)
RAMPART_HMAC_SECRET┤ RAMPART_HMAC_SECRET
RAMPART_ALLOWED_ │ RAMPART_REDIS_URL
DOMAINS │ RAMPART_SERVER_NAME
RAMPART_REDIS_URL ─┘ RAMPART_SERVER_IP
```
HMAC secret должен быть ОДИНАКОВЫМ на Edge, Velocity и Paper.
Redis URL - одинаковым на всех компонентах.
---
## 1. Edge Node (`config.toml`)
## 1. Edge (`config.toml`, путь задаётся `RAMPART_CONFIG`)
```toml
[bind]
@ -32,249 +11,80 @@ address = "0.0.0.0"
port = 25565
[backend]
# Velocity нода или HAProxy
address = "10.0.0.2"
port = 25565
[hmac]
# Минимум 32 байта. Сгенерировать: openssl rand -hex 32
secret = "CHANGE_ME_32_BYTES_LONG_HERE_ABCDEF123456"
# Список TCP-бэкендов (addr:port). Обязателен непустой, адреса валидируются.
upstreams = ["127.0.0.1:25566"]
[workers]
# Количество воркеров = количество vCPU
count = 4
[xdp]
# Опционально, требует kernel 5.10+
enabled = false
interface = "eth0"
[pow]
# PoW Challenge (Layer 2). ВЫКЛЮЧЕН по умолчанию (P0-4):
# текстовый challenge отправляется до handshake и несовместим с ванильными
# MC-клиентами — они не умеют его решать, и при enabled=true никто не сможет
# зайти на сервер. Включать только после появления клиентского мода или PoW,
# совместимого с протоколом Minecraft.
enabled = false
difficulty = 4
[limits]
# Максимум времени на получение handshake (Slowloris защита)
handshake_timeout_secs = 5
# Максимум одновременных соединений с одного IP
max_connections_per_ip = 10
# Лимит коннектов в секунду с одного IP (Status ping)
rate_limit_status_pps = 2
# Лимит коннектов в секунду с одного IP (Login)
rate_limit_login_pps = 5
# Лимит burst
rate_limit_burst = 10
handshake_timeout_secs = 5 # таймаут чтения (slowloris-защита)
max_connections_per_ip = 10 # одновременные соединения с одного IP
rate_limit_pps = 5.0 # лимит коннектов/сек с одного IP
rate_limit_burst = 10.0 # burst
[ban]
ban_duration_secs = 3600
[store]
# v0.2+: Redis для синхронизации блэклиста
redis_url = "redis://:password@10.0.0.1:6379/0"
# TTL кэша блэклиста (локально)
redis_url = "" # пусто = синхронизация блэклиста выключена
blacklist_cache_ttl_secs = 300
clickhouse_url = "" # пусто = запись событий выключена
[xdp]
enabled = false # требует сборки --features xdp, kernel 5.10+
interface = "eth0"
[logging]
level = "info" # trace, debug, info, warn, error
format = "json" # json или text
level = "info" # trace, debug, info, warn, error
format = "text" # text или json
[metrics]
enabled = true
port = 9090
[quic]
# v0.4+: QUIC канал к Manager (опционально)
enabled = false
connect = "10.0.0.1:7777"
[pow]
enabled = false # PoW challenge, OFF по умолчанию
difficulty = 4
whitelist = ["127.0.0.1", "::1"] # только IP-адреса, валидируются при парсинге
```
---
Все секции опциональны — при отсутствии используются дефолты из таблицы выше.
## 2. Velocity Plugin
## 2. Manager (переменные окружения)
Плагин конфигурируется через переменные окружения (совпадают с edge node).
| Переменная | Обязательна | По умолчанию | Описание |
|------------|-------------|--------------|----------|
| `REDIS_URL` | нет | `redis://127.0.0.1:6379/0` | Хранилище состояния |
| `JWT_SECRET` | да | — | Минимум 32 байта |
| `JWT_AUDIENCE` | нет | `rampart` | Audience JWT |
| `JWT_EXPIRATION_SECS` | нет | `86400` | Время жизни токена |
| `API_PASSWORD` | да | — | Пароль для `/api/v1/auth/login`; значение `changeme` запрещено |
| `CORS_ORIGIN` | нет | `http://localhost:5173` | CORS; `*` или пусто — разрешить все |
Manager всегда слушает `0.0.0.0:8080`.
## 3. Feature flags сборки (Cargo.toml)
| Feature | По умолчанию | Что включает |
|---------|--------------|--------------|
| `store-redis` | ✅ | Redis-клиент и синхронизация блэклиста |
| `geoip` | ❌ | GeoIP lookup (maxminddb) |
| `xdp` | ❌ | Загрузка XDP-программы через libbpf-rs |
| `io-uring` | ❌ | splice/io_uring I/O |
| `protocol-http` | ❌ | Резерв под HTTP-плагин (пока пустой) |
Пример: `cargo build --release --features xdp --bin rampart`.
## 4. BPF/XDP программа
Исходник: `xdp/core/universal_filter.c`. Smoke-check компиляции:
```bash
# Обязательно: HMAC секрет (должен совпадать с edge нодой)
RAMPART_HMAC_SECRET="CHANGE_ME_32_BYTES_LONG_HERE_ABCDEF123456"
# Опционально: список разрешённых доменов (через запятую)
RAMPART_ALLOWED_DOMAINS="play.example.com,mc.example.com,example.com"
```
Установка:
```
# Сборка
cd plugins && ./gradlew :velocity:build
# Копирование в Velocity
cp velocity/build/libs/rampart-velocity-*.jar /opt/velocity/plugins/
# Рестарт
systemctl restart velocity
```
Плагин делает:
- **DomainCheck**: блокирует direct IP-коннекты, пропускает только домены из whitelist
- **HmacCheck**: верифицирует HMAC-SHA256 подпись в hostname (`\0shield\0<sig>`)
---
## 3. Paper Plugin
Конфигурация - через переменные окружения:
```bash
# Обязательно: HMAC секрет (должен совпадать с edge нодой)
RAMPART_HMAC_SECRET="CHANGE_ME_32_BYTES_LONG_HERE_ABCDEF123456"
```
Установка:
```
cd plugins && ./gradlew :paper:build
cp paper/build/libs/rampart-paper-*.jar /opt/paper/plugins/
```
Плагин делает:
- **HmacCheck**: резервная верификация HMAC-подписи на случай прямого коннекта (в обход Velocity)
---
## 4. Manager (`manager.toml`)
```toml
[bind]
address = "0.0.0.0"
port = 8080
[tls]
cert = "/etc/rampart/tls/manager.crt"
key = "/etc/rampart/tls/manager.key"
ca = "/etc/rampart/tls/ca.crt"
[auth]
jwt_secret = "CHANGE_ME_JWT_SECRET_HERE"
jwt_expiry_hours = 24
[redis]
url = "redis://:password@127.0.0.1:6379/0"
pool_size = 10
[nats]
# v0.4+: NATS для критических событий
urls = ["nats://127.0.0.1:4222"]
[clickhouse]
url = "http://127.0.0.1:8123"
db = "rampart"
batch_size = 1000
flush_interval_secs = 1
[quic]
# v0.4+: QUIC сервер для edge нод
bind = "0.0.0.0:7777"
[limits]
api_rate_per_minute = 60
clang -O2 -g -target bpf -c xdp/core/universal_filter.c -o /tmp/universal_filter.o
```
---
## 5. HAProxy (`haproxy.cfg`)
```haproxy
global
maxconn 100000
log /dev/log local0
defaults
mode tcp
timeout connect 3s
timeout client 30s
timeout server 30s
option tcplog
frontend minecraft_in
bind *:25565
mode tcp
# Только от edge нод
acl is_edge src 10.0.100.0/24
tcp-request connection reject if !is_edge
default_backend velocity_pool
backend velocity_pool
mode tcp
balance leastconn
option tcp-check
server vel1 10.0.0.2:25565 check inter 3s rise 2 fall 3
server vel2 10.0.0.3:25565 check inter 3s rise 2 fall 3
server vel3 10.0.0.4:25565 check inter 3s rise 2 fall 3
```
---
## 6. Prometheus (`prometheus.yml`)
```yaml
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: 'rampart-edge'
static_configs:
- targets:
- '10.0.100.1:9090'
- '10.0.100.2:9090'
- job_name: 'rampart-manager'
static_configs:
- targets: ['10.0.0.1:9090']
- job_name: 'rampart-velocity'
static_configs:
- targets:
- '10.0.0.2:9091'
- '10.0.0.3:9091'
- job_name: 'paper-servers'
file_sd_configs:
- files: ['/etc/prometheus/game_servers.json']
refresh_interval: 30s
```
---
## 7. WireGuard (`wg0.conf`)
```ini
[Interface]
Address = 10.0.0.1/16
PrivateKey = <MANAGER_PRIVATE_KEY>
ListenPort = 51820
MTU = 1420
[Peer]
# Edge EU
PublicKey = <EDGE_EU_PUBLIC_KEY>
AllowedIPs = 10.0.100.1/32
[Peer]
# Edge US
PublicKey = <EDGE_US_PUBLIC_KEY>
AllowedIPs = 10.0.100.2/32
[Peer]
# Velocity 1
PublicKey = <VEL1_PUBLIC_KEY>
AllowedIPs = 10.0.0.2/32
```
---
*Версия: 1.0 | Июль 2026*
*Версия: 2.0 | Август 2026*

View file

@ -1,368 +1,110 @@
# Deployment - Rampart
> Как поднять Rampart с нуля. v0.1-v0.7.
> Как поднять Rampart с нуля.
---
## 1. Требования
## 1. Компоненты
### Минимальные (v0.1)
- 2 × VDS (KVM): Edge нода + Manager/Redis
- Ubuntu 22.04+, kernel 5.10+
- Rust toolchain (rustup)
- Docker + docker compose (для Manager)
| Бинарь | Роль | Порт по умолчанию |
|--------|------|-------------------|
| `rampart` | Edge-нода: XDP + PoW + userspace-фильтр | 25565 (+ metrics 9090) |
| `rampart-manager` | REST API управления | 8080 |
| `rampart-cli` | Управление через Manager API | — |
### Полный стек (v0.4+)
- Edge: Debian 12 / Ubuntu 22.04, kernel 5.10+ (6.0+ для полного XDP)
- Manager: любая VDS с Docker
- Java 21 (для Velocity плагинов)
- WireGuard (между нодами)
## 2. Требования
## Схема деплоя
### Edge
- Ubuntu 22.04+ / Debian 12, kernel 5.10+
- KVM или bare metal (OpenVZ/LXC — XDP не работает)
- Rust toolchain для сборки
```
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Edge нода │────→│ Load │────→│ Velocity │
│ 25565/TCP │ │ Balancer │ │ кластер │
│ XDP + Rust │ │ HAProxy │ │ x20 нод │
└──────────────┘ └──────────────┘ └──────┬───────┘
│
┌────────────────────────────┤
▼ ▼
┌──────────────┐ ┌──────────────┐
│ Hub x100 │ │ Game │
│ (лобби) │ │ серверы │
│ │ │ x300+ │
└──────────────┘ └──────────────┘
│ │
└──────────┬──────────────┘
▼
┌──────────────────┐
│ Manager нода │
│ API :8080 │
│ Redis │
│ WireGuard Hub │
└──────────────────┘
```
Для XDP дополнительно: `libelf-dev libbpf-dev clang linux-libc-dev`.
Все соединения через WireGuard (10.0.0.0/16).
Game серверы НЕ имеют публичных IP - только WG.
Edge - единственная точка входа из интернета.
### Manager
- Любая VDS с Docker (Redis обязателен)
---
## 2. Быстрый старт - v0.1 (локально)
### Шаг 1: Edge нода
## 3. Быстрый старт — Edge
```bash
# На свежей Ubuntu 22.04 VDS
# Сборка
git clone https://github.com/loki5512344/rampart.git && cd rampart
cargo build --release --bin rampart
# Установка Rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source "$HOME/.cargo/env"
rustup default stable
# Установка
sudo cp target/release/rampart /usr/local/bin/
# Установка зависимостей
sudo apt-get update
sudo apt-get install -y build-essential pkg-config libssl-dev
# Клонирование и сборка
git clone https://github.com/yourname/rampart.git
cd rampart
# Сборка edge ноды
cargo build --release --bin rampart-core
# Создание конфига
mkdir -p /etc/rampart
cat > /etc/rampart/config.toml << 'EOF'
[bind]
address = "0.0.0.0"
port = 25565
[backend]
address = "127.0.0.1"
port = 25566
[hmac]
secret = "CHANGE_ME_32_BYTES_LONG_HERE"
[workers]
count = 4
[limits]
handshake_timeout_secs = 5
max_connections_per_ip = 10
EOF
# Конфиг
sudo mkdir -p /etc/rampart
sudo cp deploy/config/edge.toml /etc/rampart/config.toml
# Отредактируй backend.upstreams под свои бэкенды
# systemd unit
cat > /etc/systemd/system/rampart-edge.service << 'EOF'
cat > /etc/systemd/system/rampart.service << 'EOF'
[Unit]
Description=Rampart Edge Node
After=network.target
[Service]
Type=simple
ExecStart=/usr/local/bin/rampart-core --config /etc/rampart/config.toml
Environment=RAMPART_CONFIG=/etc/rampart/config.toml
ExecStart=/usr/local/bin/rampart
Restart=always
RestartSec=5
LimitNOFILE=65535
User=nobody
Group=nogroup
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable --now rampart-edge
systemctl enable --now rampart
# Проверка
journalctl -u rampart-edge -f
journalctl -u rampart -f
```
### Шаг 2: Manager (docker compose)
> ⚠️ В ядре нет ни одной реализации протокольного обработчика: реестр протоколов
> пуст, и `rampart` при старте требует feature `protocol-http` или внешний
> ProtocolHandler (см. src/bin/rampart.rs). До появления плагинов edge-нода
> запускается только в исследовательских целях.
## 4. Быстрый старт — Manager
```bash
# На отдельной VDS или той же
# Установка Docker
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker $USER
# Клонирование
git clone https://github.com/yourname/rampart.git
cd rampart
# Запуск инфраструктуры
docker compose up -d
# Проверка
docker compose ps
curl http://localhost:9090/api/health
export REDIS_URL="redis://127.0.0.1:6379/0"
export JWT_SECRET="$(openssl rand -hex 32)" # минимум 32 байта
export API_PASSWORD="не-changeme" # дефолт запрещён
cargo run --release --bin rampart-manager
# API на 0.0.0.0:8080
```
### Шаг 3: Velocity плагин
Инфраструктура (Redis, ClickHouse, Grafana):
```bash
# На Velocity ноде
# Сборка плагина
cd plugins/velocity
mvn clean package
# Полученный JAR: target/rampart-velocity-*.jar
# Копируем в папку плагинов Velocity
cp target/rampart-velocity-*.jar /opt/velocity/plugins/
# Настройка
cat >> /opt/velocity/velocity.toml << 'EOF'
[rampart]
# Включаем HMAC проверку
hmac_secret = "CHANGE_ME_32_BYTES_LONG_HERE"
# Домены разрешённые для подключения
allowed_domains = ["play.example.com", "mc.example.com"]
# Redis (опционально, для v0.2+)
redis_url = "redis://:password@10.0.0.1:6379/0"
EOF
# Рестарт Velocity
systemctl restart velocity
docker compose up -d redis clickhouse grafana
```
### Шаг 4: Проверка
## 5. Docker
```bash
# Статус edge ноды
rampart status
# Образ edge
docker build -f deploy/docker/Dockerfile.edge -t rampart-edge .
# Диагностика
rampart doctor
# Полный стек edge + инфраструктура
docker compose -f deploy/docker-compose.yml up -d
```
# Проверка что порт слушается
ss -tlnp | grep 25565
## 6. Проверка после деплоя
# Тест подключения Minecraft клиента
# Открой MC → Multiplayer → play.example.com:25565
```bash
☐ systemctl status rampart
☐ ss -tlnp | grep 25565
☐ curl http://localhost:9090/metrics | grep rampart_
☐ rampart-cli status # через Manager API
☐ rampart-cli doctor # диагностика
```
---
## 3. WireGuard сеть
### Hub-and-Spoke на Manager
```bash
# На Manager ноде (WireGuard Hub)
# Установка
sudo apt-get install -y wireguard
# Генерация ключей
wg genkey | tee /etc/wireguard/manager.key | wg pubkey > /etc/wireguard/manager.pub
# Конфиг Hub
cat > /etc/wireguard/wg0.conf << 'EOF'
[Interface]
Address = 10.0.0.1/16
PrivateKey = <MANAGER_PRIVATE_KEY>
ListenPort = 51820
# Edge нода будет добавлена позже
EOF
systemctl enable --now wg-quick@wg0
```
### Добавление spoke ноды (через CLI)
```bash
# На Manager: генерируем конфиг для edge ноды
rampart wg add-node --role edge --name edge-eu-1 --public-ip 45.200.10.1
# Полученный конфиг:
# /etc/rampart/wg-configs/edge-eu-1/wg0.conf
# Копируем на edge ноду
scp /etc/rampart/wg-configs/edge-eu-1/wg0.conf root@45.200.10.1:/etc/wireguard/
# На edge ноде: запускаем
ssh root@45.200.10.1 'systemctl enable --now wg-quick@wg0'
# Проверка
ping 10.0.0.1 # Manager должен ответить
```
---
## 4. Полный production deploy (v0.4+)
### Edge нода с XDP
```bash
# Проверка совместимости
systemd-detect-virt # должно быть kvm или none
ethtool -i eth0 # драйвер: i40e, mlx5, virtio
# Установка XDP зависимостей
sudo apt-get install -y libbpf-dev clang llvm linux-headers-$(uname -r)
# Сборка с XDP
cargo build --release --features xdp
# Настройка sysctl для DDoS защиты
cat > /etc/sysctl.d/99-rampart.conf << 'EOF'
net.ipv4.tcp_syncookies = 1
net.ipv4.tcp_max_syn_backlog = 65535
net.ipv4.tcp_synack_retries = 2
net.ipv4.tcp_syn_retries = 2
net.ipv4.icmp_echo_ignore_all = 1
net.core.somaxconn = 65535
net.core.netdev_max_backlog = 65535
net.ipv4.tcp_tw_reuse = 1
net.ipv4.ip_local_port_range = 1024 65535
EOF
sysctl -p /etc/sysctl.d/99-rampart.conf
```
### Monitoring стек
```yaml
# /opt/rampart/docker-compose.monitoring.yml
# Дополнение к основному compose
services:
victoria-metrics:
image: victoriametrics/victoria-metrics:latest
ports:
- "8428:8428" # remote_write endpoint
command:
- '--storageDataPath=/storage'
- '--retentionPeriod=3'
volumes:
- vm-data:/storage
parca:
image: ghcr.io/parca-dev/parca:latest
ports:
- "7070:7070"
```
---
## 5. CI/CD pipeline
```yaml
# .github/workflows/deploy.yml
name: Deploy
on:
push:
tags:
- 'v*'
jobs:
build-edge:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: cargo build --release --features xdp
- uses: actions/upload-artifact@v4
with:
name: rampart-edge
path: target/release/rampart-core
deploy-edge:
needs: build-edge
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
- run: |
scp rampart-core root@${EDGE_HOST}:/usr/local/bin/
ssh root@${EDGE_HOST} 'systemctl restart rampart-edge'
```
---
## 6. Firewall (резюме)
```bash
# Быстрая настройка для edge ноды
sudo ./scripts/firewall.sh
# Проверка
sudo iptables -L -n -v
```
Полные правила в [networking.md](research/networking.md).
---
## 7. Checklist после деплоя
```
☐ Edge нода запущена: systemctl status rampart-edge
☐ Порты слушаются: ss -tlnp | grep 25565
☐ WireGuard работает: wg show
☐ Manager API отвечает: curl http://localhost:9090/api/health
☐ Redis доступен: redis-cli ping
☐ ClickHouse пишет: curl http://localhost:8123/ping
☐ Velocity плагин загружен: /plugins/rampart-velocity-*.jar
☐ Реальный MC клиент заходит
☐ Prometheus метрики: curl http://localhost:9090/metrics
```
---
## 8. Troubleshooting
| Симптом | Причина | Решение |
|---------|---------|---------|
| Edge не стартует | Порт занят | `ss -tlnp \| grep 25565`, смени порт |
| Velocity не подключается | Не совпадает HMAC secret | Проверь `config.toml` и `velocity.toml` |
| XDP не загружается | OpenVZ / old kernel | `systemd-detect-virt`, проверь `uname -r` |
| Redis connection refused | Не настроен firewall | `iptables -A INPUT -p tcp --dport 6379 -s 10.0.0.0/16 -j ACCEPT` |
| ClickHouse не пишет | Нет таблицы | Выполни CREATE TABLE из `observability.md` |
---
*Версия: 1.0 | Июль 2026*
*Версия: 2.0 | Август 2026*

View file

@ -1,76 +1,55 @@
# Disaster Recovery - Rampart
> Что делать когда что-то пошло не так.
> Что делать, когда что-то пошло не так.
## Схема failover
```
Redis упал:
Edge: продолжает с локальным кэшем
Velocity: продолжает с последним кэшем серверов
Manager: API не работает → рестарт Redis, рестарт Manager
Edge: продолжает автономно на локальном кэше блэклиста
Manager: API отдаёт пустые списки / ошибки → рестарт Redis
Manager упал:
Edge: продолжает автономно
Velocity: читает Redis напрямую
Dashboard: недоступен → рестарт Manager
Edge: продолжает фильтрацию (фильтрация не зависит от Manager)
Управление (CLI/API) недоступно до восстановления
Edge нода упала:
Игроки на ней теряют коннект
При реконнекте → BGP/DNS → другая Edge нода
Если Edge одна → все офлайн
Клиенты за ней теряют соединение
При нескольких edge → DNS round-robin на живые
Если edge одна — сервис недоступен
Полный сбой дата-центра:
Edge ноды в других ДЦ продолжают работу
Игроки на живых серверах продолжают играть
Новые регистрации/баны не синхронизируются до восстановления
Edge в других ДЦ продолжают работу
Баны/ноды не синхронизируются до восстановления Redis
```
Все компоненты кроме Manager продолжают работать в degraded mode.
Manager - единственная single point of failure (без Redis Sentinel).
---
## 1. Redis упал
### Симптомы
- Velocity не видит новые серверы
- Edge не синхронизирует блэклист
- Manager API возвращает 500
- Edge перестал синхронизировать блэклист
- Manager API отдаёт пустые списки nodes/servers/blacklist
### Влияние
- **Edge:** Продолжает работать с локальным кэшем блэклиста. Новые баны не синхронизируются между нодами.
- **Velocity:** Продолжает работать с последним кэшем server registry. Новые серверы недоступны до восстановления Redis.
- **Manager:** API не работает.
- **Edge:** работает с локальным кэшем; новые баны не распространяются между нодами.
- **Manager:** данные из Redis недоступны.
### Действия
```bash
# 1. Проверка Redis
redis-cli ping
systemctl status redis
redis-cli ping || systemctl restart redis
# 2. Если Redis завис - рестарт
systemctl restart redis
# 3. Если Redis навсегда умер - поднять новый
# Убедись что пароль совпадает с конфигами
docker run -d --name rampart-redis \
-p 6379:6379 \
# Поднять новый (пароль должен совпадать):
docker run -d --name rampart-redis -p 6379:6379 \
redis:7-alpine redis-server --requirepass "$REDIS_PASSWORD"
# 4. Перезапустить Manager (он переподключится)
systemctl restart rampart-manager
# 5. Edge ноды переподключатся автоматически (retry в драйвере Redis)
# Если не переподключились - рестарт:
systemctl restart rampart-edge
# 6. Velocity переподключится с задержкой до 5 сек
systemctl restart rampart-manager # переподключится
# Edge переподключается сам (retry в драйвере); иначе:
systemctl restart rampart
```
### Предотвращение
- Redis Sentinel для HA (3 ноды)
- AOF + RDB persistence включены
- Регулярные бэкапы: `redis-cli SAVE`
@ -79,245 +58,92 @@ systemctl restart rampart-edge
## 2. Manager упал
### Симптомы
- API не отвечает
- Blacklist изменения не применяются
- Edge heartbeat пропадает
- `rampart-cli` команды падают с ошибкой соединения
- `/api/v1/health` не отвечает
### Влияние
- **Edge:** Продолжает работать автономно. Локальный блэклист активен.
- **Velocity:** Продолжает работать. Server registry из Redis доступен.
- **Dashboard:** Недоступен.
- Edge продолжает фильтровать трафик автономно.
### Действия
```bash
# 1. Проверка
systemctl status rampart-manager
journalctl -u rampart-manager -n 50 --no-pager
# 2. Рестарт
systemctl restart rampart-manager
# 3. Если не стартует - проверить логи
# Не стартует? Частая причина — env:
# JWT_SECRET < 32 байт или API_PASSWORD=changeme → процесс выходит с ошибкой.
journalctl -u rampart-manager -e | grep ERROR
# 4. Если проблема в конфиге
# Откатить последние изменения конфига
git checkout HEAD~1 -- config/manager.toml
systemctl restart rampart-manager
```
### Предотвращение
- systemd `Restart=always`
- Мониторинг: Prometheus alert `EdgeNodeDown`
- Два Manager в active/passive (v0.6+)
---
## 3. Edge нода упала
### Симптомы
- Игроки на этой ноде теряют соединение
- Prometheus alert: `EdgeNodeDown`
- Метрики перестали приходить
### Влияние
- Игроки, подключённые через эту ноду, дисконнектятся
- При переподключении → попадают на другую edge ноду
- Если edge нода одна → **все игроки офлайн**
- Клиенты теряют соединение, метрики :9090 пропали
### Действия
```bash
# 1. Проверка
systemctl status rampart-edge
journalctl -u rampart-edge -n 50 --no-pager
systemctl status rampart
journalctl -u rampart -n 50 --no-pager
# 2. Если OOM kill
dmesg | grep -i "oom\|rampart"
dmesg | grep -i "oom\|rampart" # OOM kill?
systemctl restart rampart
# 3. Рестарт
systemctl restart rampart-edge
# 4. Если не стартует - проверить конфиг
rampart doctor
# 5. Если аппаратная проблема - переключить DNS на другую edge ноду
# (при нескольких edge нодах)
# Не стартует — проверь конфиг и логи (см. runbook.md §2)
```
### Предотвращение
- Минимум 2 edge ноды
- DNS round-robin или BGP Anycast
- Минимум 2 edge ноды + DNS round-robin
- systemd `Restart=always`
- `rampart drain` для graceful maintenance
- Несколько edge нод за одним DNS-именем
---
## 4. ClickHouse упал
### Симптомы
- Attack log не пишется
- Dashboard по блокировкам пустой
### Влияние
- **Edge / Velocity / Manager:** Продолжают работать. Потеря аналитики.
- Данные не теряются (буферизация в Manager на 1 секунду с батчем до 1000).
Только потеря аналитики: события буферизуются и пишутся асинхронно,
фильтрация не зависит от ClickHouse.
### Действия
```bash
# 1. Проверка
systemctl status clickhouse-server
curl http://localhost:8123/ping
# 2. Рестарт
systemctl restart clickhouse-server
# 3. Если долго восстанавливается - проверить диск
df -h /var/lib/clickhouse
# 4. Если диск полон - почистить старые партиции
clickhouse-client --query "ALTER TABLE rampart.blocked DROP PARTITION '2025-01'"
curl http://localhost:8123/ping || systemctl restart clickhouse-server
df -h /var/lib/clickhouse # диск полон?
```
### Предотвращение
- TTL на таблицах (90 дней автоочистка)
- ClickHouse Cloud или Cluster (v0.6+)
- Alertmanager при заполнении диска > 80%
---
## 5. Root CA key скомпрометирован
### Симптомы
- Вы знаете что ключ утек
- Подозрительные сертификаты в сети
### Влияние
- **Полная компрометация mTLS:** Атакующий может выпустить сертификаты для любой ноды
### Действия
```bash
# 1. НЕМЕДЛЕННО: Сгенерировать новый Root CA
rampart pki init --root-ca rampart-ca-v2 --force
# 2. Выпустить новые сертификаты для ВСЕХ нод
for node in edge-eu-1 edge-us-1 vel-1 manager; do
rampart pki issue --ca edge-ca --name "$node" \
--ip "$(dig +short $node.rampart.internal)" \
--san "$node.rampart.internal" \
--output "/etc/rampart/pki/$node/"
done
# 3. Разослать новые сертификаты на все ноды
rampart pki sync --all-nodes
# 4. Перезапустить все сервисы (с новыми сертификатами)
rampart restart --all
# 5. Отозвать старый Root CA
rampart pki revoke --ca rampart-ca-v1
# 6. Расследовать утечку
# - Проверить кто имел доступ к ключу
# - Проверить логи доступа
# - Сменить все пароли
```
### Предотвращение
- Root CA ключ хранить **вне серверов** (на YubiKey или в Vault)
- Использовать Intermediate CA для повседневной работы
- Audit лог доступа к CA ключу
---
## 6. Полный сбой инфраструктуры
### Ситуация
Упали нода Manager + Redis + NATS одновременно (например, отключили дата-центр).
### Влияние
- Все edge ноды продолжают работать автономно
- Блэклист не синхронизируется
- Server registry не обновляется
- **Игроки продолжают играть на уже запущенных серверах**
### Восстановление
## 5. Полный сбой инфраструктуры
```bash
# 1. Поднять Manager на новой VDS
docker compose up -d
docker compose up -d redis && cargo run --release --bin rampart-manager
# 2. Восстановить Redis из бэкапа
redis-cli --pipe < /backup/rampart-redis-$(date +%Y-%m-%d).rdb
# 3. Edge ноды и Velocity переподключатся автоматически
# (они реконнектятся с экспоненциальной задержкой: 1s, 2s, 4s, 8s... max 60s)
# 3. Edge ноды переподключатся автоматически
# 4. Проверить что всё синхронизировалось
rampart doctor
```
### Предотвращение
- Бэкапы Redis: ежедневно, хранить 30 дней
- Terraform для быстрого поднятия инфраструктуры
- DNS записи с низким TTL (60 сек)
---
## 7. DDoS на Manager/Redis
### Симптомы
- Manager API не отвечает
- Redis latency > 1 секунды
- CPU Manager 100%
### Действия
```bash
# 1. Изолировать Manager - закрыть все порты кроме WireGuard
iptables -P INPUT DROP
iptables -A INPUT -i lo -j ACCEPT
iptables -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
iptables -A INPUT -p udp --dport 51820 -j ACCEPT
iptables -A INPUT -j DROP
# 2. Если DDoS идёт на публичный IP - отключить его
# (Оставить только WireGuard туннель)
# 3. Edge ноды переживут без Manager несколько часов
# (они кэшируют блэклист локально)
# 4. Проверить
rampart-cli doctor
```
---
## 8. Cheatsheet быстрых команд
## 6. Cheatsheet быстрых команд
```bash
# Рестарт всего
systemctl restart rampart-edge rampart-manager redis clickhouse-server
# Проверка здоровья всей системы
rampart doctor
# Последние 50 строк логов edge
journalctl -u rampart-edge -n 50 -f
# CPU/memory edge
htop -p $(pgrep -d',' rampart-edge)
# Трафик на интерфейсе
systemctl restart rampart rampart-manager redis clickhouse-server
journalctl -u rampart -n 50 -f
htop -p $(pgrep -d',' rampart)
iftop -i eth0
# Статистика Redis
redis-cli info stats | grep -E "total_connections|total_commands|rejected"
# Активные соединения
ss -s | grep TCP
```
---
*Версия: 1.0 | Июль 2026*
*Версия: 2.0 | Август 2026*

44
docs/kb/README.md Normal file
View file

@ -0,0 +1,44 @@
# Rampart Knowledge Base
> Теоретическая база и практика защиты от DDoS, на которой построена платформа.
> Статьи двуязычные: в каждой есть разделы `## English` и `## Русский`.
## English
### Attacks — how they work
- [SYN Flood](./attacks/syn-flood.md) — the classic TCP half-open exhaustion attack; backlog mechanics, why one packet costs the server memory.
- [HTTP Flood](./attacks/http-flood.md) — application-layer floods with syntactically perfect requests; why L3/L4 defense is powerless.
- [Slowloris & Slow Attacks](./attacks/slowloris.md) — exhausting connection slots with connections that never finish; no bandwidth needed.
- [UDP Amplification](./attacks/udp-amplification.md) — reflection and amplification factors; DNS/NTP/Memcached abuse.
### Defense fundamentals
- [Defense Levels](./defense-levels.md) — where to filter a packet: kernel (XDP) vs userspace trade-offs, full packet path through the Linux stack, and why Rampart uses hybrid kernel fast-path + userspace smart-path.
### Practice
- [Kernel Tuning](./practice/kernel-tuning.md) — sysctl parameters that matter under flood (`tcp_max_syn_backlog`, somaxconn, backlog queues), ready-to-adapt config.
- [NIC Tuning](./practice/nic-tuning.md) — ring buffers, IRQ affinity, offloads, RPS/XPS for high pps workloads.
- [Stress Testing](./practice/stress-testing.md) — methodology and tooling for load/attack simulation against your own infra.
---
## Русский
### Атаки — как они работают
- [SYN Flood](./attacks/syn-flood.md) — классическая атака на полуоткрытые соединения; механика backlog'а, почему один пакет стоит серверу памяти.
- [HTTP Flood](./attacks/http-flood.md) — L7-флуд синтаксически корректными запросами; почему защита уровня L3/L4 бессильна.
- [Slowloris и медленные атаки](./attacks/slowloris.md) — исчерпание слотов соединений соединениями, которые никогда не завершаются; полоса не нужна.
- [UDP-амплификация](./attacks/udp-amplification.md) — рефлексия и коэффициенты усиления; злоупотребление DNS/NTP/Memcached.
### Основы защиты
- [Уровни фильтрации](./defense-levels.md) — где дропать пакет: компромиссы ядра (XDP) и userspace, полный путь пакета через сетевой стек Linux и почему Rampart использует гибрид kernel fast-path + userspace smart-path.
### Практика
- [Тюнинг ядра](./practice/kernel-tuning.md) — значимые под флудом параметры sysctl (`tcp_max_syn_backlog`, somaxconn, очереди), готовый конфиг для адаптации.
- [Тюнинг NIC](./practice/nic-tuning.md) — ring buffers, привязка прерываний, offload'ы, RPS/XPS для высоких pps.
- [Стресс-тестирование](./practice/stress-testing.md) — методика и инструменты нагрузочной/атакующей симуляции на своей инфраструктуре.

View file

@ -0,0 +1,233 @@
# HTTP Flood: Application-Layer Attack That Looks Like Traffic
> Knowledge Base · Rampart attack fundamentals · Related: [defense-levels.md](../defense-levels.md), [slowloris.md](./slowloris.md), PoW details in [anti-bot research](../../research/anti-bot.md)
## English
## 1. What an L7 flood is
An HTTP flood sends requests that are **syntactically perfect** — valid TCP, valid TLS, valid HTTP — at a rate or cost profile designed to exhaust application resources. There is nothing anomalous about any individual request; the anomaly is statistical (volume, distribution, cost) rather than protocol-level.
Common shapes:
- **GET flood on expensive endpoints** — hammering pages that trigger database joins, search, report generation, or uncached rendering. 100 req/s against `/search?q=...` hurts more than 10,000 req/s against a static asset.
- **POST flood** — form submissions, API writes: each request costs the backend not just CPU but writes, locks, and downstream calls.
- **Cache-busting** — appending a unique query string to every request (`/?t=<random>`, `?utm_<random>=1`) so every response misses the cache and hits origin. The same nominal traffic suddenly multiplies its backend load several-fold.
- **Legitimate-looking traffic** — full TLS with proper SNI, plausible User-Agent headers (or real headless browsers), cookies honored, human-like pacing. Distributed across residential proxies, it defeats naive "is this a datacenter IP" checks.
## 2. Why L3/L4 defense is powerless here
Everything below the application sees a stream of perfectly normal conversations:
```mermaid
flowchart TD
R[Requests arrive] --> X{XDP / kernel filters}
X -- "TCP handshake valid<br/>no flag anomalies<br/>rate may be under per-IP limits" --> P[Connections accepted]
P --> H{L7 engine:<br/>parse HTTP}
H -- "requests are VALID.<br/>The attack lives in semantics:<br/>which endpoint, what cost,<br/>what pattern" --> Q{Decision needs app context}
subgraph useless ["What L3/L4 can see"]
B1[src IP]
B2[packet rate]
B3[TCP flags]
end
subgraph needed ["What the decision actually requires"]
N1[endpoint cost model]
N2[per-session behavior]
N3[cross-request patterns]
N4[reputation history]
end
style Q fill:#f5e6c8
```
Concretely:
- Dropping by rate alone punishes legitimate bursts (a page load fires dozens of requests).
- The attacker's packets pass every sanity check because they *are* sane.
- Per-IP limiting helps only until the botnet spreads across thousands of residential-proxy IPs.
The only layer that can tell `/` from `/expensive-report` and a browser session from a script is one that has parsed the HTTP exchange and holds session state. That is why an L7 flood is decided in userspace — the kernel already lost the information needed for the verdict.
## 3. Defense
### 3.1 Challenge-response gating
Before expensive logic runs, the edge issues a cheap challenge that must be solved before the real request is served. Legitimate browsers solve it transparently; scripts pay a cost. This converts "free requests" into "paid requests" without touching honest users.
### 3.2 SHA256 hashcash proof-of-work with dynamic difficulty
Rampart's core anti-flood mechanism (Layer 2 of the stack). Principle:
1. Edge generates a random per-request challenge token + timestamp + current difficulty `d`.
2. Client must find a nonce such that `SHA256(token || nonce)` begins with characters from an allowed set — on average requiring `16^d` hash attempts (hex output). Difficulty 4 ≈ tens of milliseconds on a phone; difficulty 12 ≈ seconds even on a fast CPU.
3. Client returns `(token, nonce)`; edge verifies with **one** hash (~microseconds) and checks the timestamp (≤30 s window) — challenges are single-use, so replay is impossible.
4. Difficulty is dynamic: driven by global connections-per-second and attack mode.
```mermaid
sequenceDiagram
participant C as Client
participant E as Edge (PoW gate)
C->>E: request arrives during elevated load
E-->>C: {challenge token, difficulty d, allowed hex, timestamp}
Note over C: browser loops nonce:<br/>SHA256(token+nonce) starts with d allowed chars<br/>cost: O(16^d) hashes on the CLIENT
C-->>E: solution {token, nonce}
Note over E: verify = ONE sha256 + timestamp check<br/>FILTERING POINT: asymmetry —<br/>client pays seconds, server pays microseconds
E->>E: verified → forward to app logic
rect rgb(230, 240, 255)
Note over C,E: Dynamic difficulty: CPS > 500 → d=12, >100 → 10,<br/>>50 → 8, calm → 4. Attack raises everyone's price;<br/>verified/reputation-trusted clients get lower d or skip.
end
```
The economics are the point: verification is ~10⁶ times cheaper than solving, so the edge can demand work from millions of suspects while spending almost nothing itself. GPU farms don't rescue the attacker much — SHA256 isn't memory-hard but the bottleneck becomes orchestration of millions of solutions, and raising `d` scales their cost exponentially while ours stays flat.
### 3.3 Behavioral analysis
Per-session statistics distinguish humans from scripted floods:
- request pacing and inter-arrival variance (scripts are too regular; instant responses <200 ms are bots),
- endpoint mix (humans browse; bots hammer one URL),
- header order/fingerprint consistency,
- navigation coherence (referers, resource loading order).
Deviations feed a risk score instead of hard-blocking immediately — reducing false positives on NAT users behind one IP.
### 3.4 Reputation scoring
Every source accumulates a score (−100..+100):
| Signal | Effect |
|---|---|
| Passed PoW/challenges before | positive |
| Sent malformed/death-code packets | strong negative, auto-ban |
| ASN category (residential / datacenter / mobile / Tor) | baseline weighting of all limits |
| Anomaly vs 168-hour hourly baseline profile | negative drift |
Reputation multiplies rate limits and sets PoW difficulty: trusted residential client at calm time → difficulty 4, no friction; fresh datacenter IP mid-attack → strictest limits plus difficulty 12. Verified clients (fingerprint cache in Redis, HMAC-SHA256 based, TTL 24 h) skip re-challenges entirely — returning users don't pay twice.
### Summary
| Mechanism | What it stops |
|---|---|
| Challenge-response gate | Free unauthenticated access to expensive logic |
| Hashcash PoW, dynamic difficulty | Mass request generation — makes it economically irrational |
| Behavioral analysis | Bots that solve PoW but act non-human |
| Reputation scoring | Distributed floods via rented residential IPs |
## Русский
## 1. Что такое L7-флад
HTTP-флад шлёт запросы, **синтаксически безупречные** — валидный TCP, валидный TLS, валидный HTTP — с такой интенсивностью и стоимостным профилем, которые истощают ресурсы приложения. В отдельном запросе нет ничего аномального; аномалия статистическая (объём, распределение, стоимость), а не протокольная.
Типичные формы:
- **GET-флад по дорогим эндпоинтам** — долбление страниц, триггерящих джойны в БД, поиск, генерацию отчётов или некэшированный рендеринг. 100 req/s против `/search?q=...` больнее, чем 10 000 req/s по статике.
- **POST-флад** — отправка форм, запись в API: каждый запрос стоит бэкенду не только CPU, но и записей, локов и внешних вызовов.
- **Cache-busting** — добавление уникального query-параметра к каждому запросу (`/?t=<random>`, `?utm_<random>=1`), чтобы каждый ответ был cache-miss и уходил в origin. Тот же номинальный трафик внезапно умножает нагрузку на бэкенд в разы.
- **Маскировка под легитимный трафик** — полный TLS с корректным SNI, правдоподобные User-Agent (или реальные headless-браузеры), обработка cookies, человекоподобный темп. Распределение через резиденциальные прокси ломает наивные проверки «датацентровый ли это IP».
## 2. Почему защита L3/L4 здесь бессильна
Все слои ниже приложения видят поток абсолютно нормальных разговоров:
```mermaid
flowchart TD
R[Запросы приходят] --> X{XDP / фильтры ядра}
X -- "TCP-хендшейк валиден<br/>аномалий флагов нет<br/>rate может быть ниже per-IP лимитов" --> P[Соединения приняты]
P --> H{L7-движок:<br/>парсинг HTTP}
H -- "запросы ВАЛИДНЫ.<br/>Атака живёт в семантике:<br/>какой эндпоинт, какая цена,<br/>какой паттерн" --> Q{Для решения нужен контекст приложения}
subgraph useless ["Что видит L3/L4"]
B1[src IP]
B2[packet rate]
B3[TCP flags]
end
subgraph needed ["Что реально нужно для решения"]
N1[модель стоимости эндпоинтов]
N2[поведение сессии]
N3[кросс-запросные паттерны]
N4[история репутации]
end
style Q fill:#f5e6c8
```
Конкретно:
- Дроп по одному лишь rate наказывает легитимные всплески (загрузка одной страницы порождает десятки запросов).
- Пакеты атакующего проходят все санити-чеки, потому что они и есть санитарные.
- Per-IP лимиты помогают ровно до тех пор, пока ботнет не размажется по тысячам резиденциальных прокси-IP.
Единственный слой, отличающий `/` от `/expensive-report`, а браузерную сессию от скрипта, — слой, распарсивший HTTP-обмен и держащий состояние сессии. Поэтому L7-флад решается в userspace — ядро уже потеряло информацию, нужную для вердикта.
## 3. Защита
### 3.1 Гейтинг challenge-response
До запуска дорогой логики edge выдаёт дешёвый challenge, который надо решить, прежде чем реальный запрос будет обслужен. Легитимные браузеры решают его прозрачно; скрипты платят цену. «Бесплатные запросы» превращаются в «платные», не трогая честных пользователей.
### 3.2 SHA256 hashcash proof-of-work с динамической сложностью
Ключевой анти-флад механизм Rampart'а (слой 2 стека). Принцип:
1. Edge генерирует случайный per-request токен-challenge + timestamp + текущую сложность `d`.
2. Клиент должен найти nonce, при котором `SHA256(token || nonce)` начинается с символов из разрешённого набора — в среднем требуется `16^d` попыток хеширования (hex-вывод). Сложность 4 ≈ десятки миллисекунд даже на телефоне; сложность 12 ≈ секунды даже на быстром CPU.
3. Клиент возвращает `(token, nonce)`; edge проверяет **одним** хешем (~микросекунды) и проверяет timestamp (окно ≤30 с) — challenge одноразовый, replay невозможен.
4. Сложность динамическая: управляется глобальным CPS и режимом атаки.
```mermaid
sequenceDiagram
participant C as Клиент
participant E as Edge (PoW-гейт)
C->>E: запрос пришёл при повышенной нагрузке
E-->>C: {challenge token, difficulty d, allowed hex, timestamp}
Note over C: браузер крутит цикл nonce:<br/>SHA256(token+nonce) начинается с d разрешённых символов<br/>цена: O(16^d) хешей у КЛИЕНТА
C-->>E: решение {token, nonce}
Note over E: верификация = ОДИН sha256 + проверка timestamp<br/>ТОЧКА ФИЛЬТРАЦИИ: асимметрия —<br/>клиент платит секундами, сервер микросекундами
E->>E: верифицирован → вперёд к логике приложения
rect rgb(230, 240, 255)
Note over C,E: Динамическая сложность: CPS > 500 → d=12, >100 → 10,<br/>>50 → 8, спокойствие → 4. Атака повышает цену всем;<br/>верифицированные/доверенные клиенты получают низкую d или скип.
end
```
Экономика — сама суть механизма: верификация в ~10⁶ раз дешевле решения, поэтому edge может требовать работу от миллионов подозреваемых, почти ничего не тратя. GPU-фермы мало помогают атакующему — SHA256 не memory-hard, но узким местом становится оркестрация миллионов решений, а рост `d` масштабирует их цену экспоненциально, тогда как наша остаётся плоской.
### 3.3 Поведенческий анализ
Посессионная статистика отличает людей от скриптовых флудов:
- темп запросов и дисперсия интервалов (скрипты слишком ровные; мгновенные ответы <200 мс — боты),
- микс эндпоинтов (люди гуляют по сайту; боты долбят один URL),
- согласованность порядка заголовков/фингерпринта,
- связность навигации (referer'ы, порядок подгрузки ресурсов).
Отклонения капают в risk-score вместо немедленного жёсткого блока — снижая ложные срабатывания на NAT-пользователей за одним IP.
### 3.4 Reputation scoring
Каждый источник накапливает score (−100..+100):
| Сигнал | Эффект |
|---|---|
| Ранее проходил PoW/challenge | позитив |
| Слал мусорные/death-code пакеты | сильный негатив, авто-бан |
| Категория ASN (residential / datacenter / mobile / Tor) | базовое взвешивание всех лимитов |
| Аномалия против 168-часового почасового baseline | негативный дрейф |
Репутация умножает rate limit'ы и задаёт сложность PoW: доверенный residential-клиент в спокойное время → сложность 4, ноль трения; свежий датацентровый IP посреди атаки → самые строгие лимиты плюс сложность 12. Верифицированные клиенты (кэш fingerprint'ов в Redis на базе HMAC-SHA256, TTL 24 ч) вообще пропускают повторные challenge — вернувшиеся пользователи не платят дважды.
### Итог
| Механизм | Что останавливает |
|---|---|
| Гейт challenge-response | Бесплатный анонимный доступ к дорогой логике |
| Hashcash PoW, динамическая сложность | Массовую генерацию запросов — делает её экономически бессмысленной |
| Поведенческий анализ | Ботов, решивших PoW, но ведущих себя не по-человечески |
| Reputation scoring | Распределённые флуды через арендованные резиденциальные IP |

View file

@ -0,0 +1,197 @@
# Slowloris and Slow Attacks: Exhaustion Without Bandwidth
> Knowledge Base · Rampart attack fundamentals · Related: [defense-levels.md](../defense-levels.md), [http-flood.md](./http-flood.md)
## English
## 1. The opposite of a flood
A volumetric attack shouts; a slow attack whispers. Slowloris (named after the original 2009 tool) does not try to overwhelm bandwidth or packet rate — it tries to **occupy every available connection slot with connections that are technically alive but never finish**.
The mechanism exploits how HTTP servers parse requests:
1. Attacker opens a TCP connection and sends `GET / HTTP/1.1`.
2. Then it sends headers one at a time, dribbling out partial headers like `X-a: b\r\n` every few seconds.
3. The server cannot reject the request yet: the header block is incomplete, and by protocol it must keep waiting for `\r\n\r\n` that terminates the header section.
4. Just before the server's read timeout fires, the attacker sends another partial header — resetting the timer.
5. Repeat forever.
Each connection consumes a worker/thread/event-loop slot and a socket buffer while transferring essentially zero bytes. When all workers are busy waiting for "requests in progress", legitimate clients queue behind them and time out.
```mermaid
sequenceDiagram
participant A as Attacker
participant W as Server worker pool
participant L as Legitimate client
A->>W: TCP connect + "GET / HTTP/1.1"
loop every few seconds, forever
A->>W: "X-a: b" (partial header)
Note over W: read timer resets,<br/>worker stays occupied<br/>FILTERING POINT: timeout must be<br/>shorter than the dribble interval
end
Note over W: pool exhausted:<br/>all workers wait on dead requests
L->>W: (legitimate request)
W--)L: queued → connection timeout
```
Hundreds of such connections from a single IP can take down a default-configured web server. The attack traffic is measured in **bytes per minute** — it slips under any volumetric threshold.
## 2. How slow attacks differ from volumetric
| Property | Volumetric (SYN/UDP flood) | Slow (Slowloris family) |
|---|---|---|
| Resource attacked | Bandwidth, backlog, pps budget | Worker pool, sockets, memory per connection |
| Traffic volume | Huge | Tiny |
| Visible at L3/L4? | Yes — flag anomalies, rate spikes | No — packets look perfectly normal TCP |
| Source count needed | Often many IPs | Often one IP is enough |
| Right filter layer | Kernel (XDP) | Userspace, after parsing begins |
This last row matters most: no amount of kernel-level filtering can distinguish a Slowloris connection from a slow human client, because at the byte level they look identical. The decision requires understanding *application state* ("this request has been incomplete for 10 seconds"), which only exists above the kernel.
Variants of the same idea:
- **Slow Read** — complete request, but read the response 1 byte at a time, keeping the socket open.
- **Slow POST / R-U-Dead-Yet** — declare `Content-Length: 1000000`, then send body bytes at a trickle.
- **Slow connection holding** — open and just never send anything (idle hold).
## 3. Defense
### 3.1 Header/body read timeouts — the primary weapon
The server must enforce absolute deadlines independent of incoming bytes:
- **Header deadline**: total time to receive the full header block (e.g. 10 s). Not an idle timeout that the dribble resets — a hard cap from connection start to end-of-headers.
- **Body deadline**: total time to receive declared `Content-Length` bytes, and a sanity cap on the length itself.
- **Idle eviction**: a connection with no bytes for N seconds is closed regardless of state.
In Rampart's userspace engine this is exactly `tokio::time::timeout` around the handshake/read phase — if a full request isn't parsed within the window, the connection is dropped and the source gets a reputation penalty.
### 3.2 Per-IP connection limits
A single IP rarely needs more than a handful of concurrent connections. Enforce:
- max concurrent connections per IP,
- max new connections per second per IP (token bucket),
- global cap on half-parsed requests as a fraction of the pool.
With per-IP caps, one machine running Slowloris can occupy its own quota and nothing else; a distributed variant is then throttled by rate limits plus ASN reputation weighting (datacenter sources get smaller quotas than residential).
### 3.3 Reverse-proxy buffering — shrink the blast radius
Putting a buffering reverse proxy (or Rampart edge itself) in front of the application changes the game:
- The proxy reads the **complete** request before forwarding anything upstream.
- The application's workers only ever see fully-formed requests; slow clients stall proxy buffers, not app threads.
- Proxy buffers are cheap, sized for thousands of stalled connections, and paired with aggressive timeouts from §3.1.
### Why the userspace filter is the main defense here
```mermaid
flowchart TD
C[Connection established] --> X{XDP layer}
X -- "sees only valid TCP.<br/>Cannot tell slow attack<br/>from slow human" --> P[Bytes reach userspace]
P --> T{Userspace engine:<br/>header read timeout,<br/>conn-per-IP limit,<br/>request state tracking}
T -- "incomplete past deadline" --> D[Drop + reputation penalty]
T -- complete in time --> APP[Application logic]
style D fill:#f5d0d0
```
Kernel layers (XDP, nftables) operate on packets and flags; a slow attack produces perfectly valid packets. The discriminating information — "how long has this request been incomplete, how many such requests does this IP hold" — lives in application-level session state. Hence: cheap kernel layers still handle the volumetric shield, but **the userspace engine is where slow attacks actually get decided**, which is why Rampart places its timeout/reputation logic there rather than trying to push everything into eBPF.
## Русский
## 1. Полная противоположность фладу
Объёмная атака кричит; медленная атака шепчет. Slowloris (назван по одноимённому инструменту 2009 года) не пытается задавить полосу или pps — он пытается **занять все слоты соединений соединениями, которые формально живы, но никогда не завершаются**.
Механизм эксплуатирует то, как HTTP-серверы парсят запросы:
1. Атакующий открывает TCP-соединение и шлёт `GET / HTTP/1.1`.
2. Затем отправляет заголовки по одному, выпуская частичные заголовки вроде `X-a: b\r\n` раз в несколько секунд.
3. Сервер пока не может отклонить запрос: блок заголовков неполон, и по протоколу он обязан ждать завершающие `\r\n\r\n`.
4. За мгновение до срабатывания read-таймаута атакующий шлёт ещё один частичный заголовок — таймер сбрасывается.
5. И так бесконечно.
Каждое соединение занимает слот воркера/потока/события цикла и сокетный буфер, передавая фактически ноль байт. Когда все воркеры ждут «запросы в процессе», легитимные клиенты встают в очередь и отваливаются по таймауту.
```mermaid
sequenceDiagram
participant A as Атакующий
participant W as Пул воркеров сервера
participant L as Легитимный клиент
A->>W: TCP connect + "GET / HTTP/1.1"
loop каждые несколько секунд, вечно
A->>W: "X-a: b" (частичный заголовок)
Note over W: read-таймер сброшен,<br/>воркер занят<br/>ТОЧКА ФИЛЬТРАЦИИ: таймаут должен быть короче<br/>интервала «капельницы»
end
Note over W: пул исчерпан:<br/>все воркеры ждут мёртвые запросы
L->>W: (легитимный запрос)
W--)L: в очереди → connection timeout
```
Несколько сотен таких соединений с одного IP кладут веб-сервер с дефолтной конфигурацией. Трафик атаки измеряется в **байтах в минуту** — под любой объёмный порог не попадает.
## 2. Чем медленные атаки отличаются от объёмных
| Свойство | Объёмные (SYN/UDP flood) | Медленные (семейство Slowloris) |
|---|---|---|
| Атакуемый ресурс | Полоса, backlog, бюджет pps | Пул воркеров, сокеты, память на соединение |
| Объём трафика | Огромный | Крошечный |
| Видно на L3/L4? | Да — аномалии флагов, всплески rate | Нет — пакеты выглядят абсолютно нормальным TCP |
| Сколько нужно источников | Часто много IP | Часто хватает одного |
| Правильный слой фильтрации | Ядро (XDP) | Userspace, после начала парсинга |
Последняя строка важнее всего: никакая фильтрация уровня ядра не отличит Slowloris-соединение от медленного человеческого клиента — на уровне байтов они идентичны. Решение требует понимания *прикладного состояния* («этот запрос неполон уже 10 секунд»), которого у ядра просто нет.
Варианты той же идеи:
- **Slow Read** — запрос полный, но ответ читается по 1 байту, сокет держится открытым.
- **Slow POST / R-U-Dead-Yet** — объявляется `Content-Length: 1000000`, затем тело капает по байтам.
- **Удержание соединения** — открыть и вообще ничего не слать (idle-hold).
## 3. Защита
### 3.1 Таймауты на чтение заголовков/тела — главное оружие
Сервер обязан держать абсолютные дедлайны независимо от входящих байтов:
- **Дедлайн заголовков**: общее время на получение полного блока заголовков (например, 10 с). Не idle-таймаут, который «капельница» сбрасывает, а жёсткий лимит от начала соединения до конца заголовков.
- **Дедлайн тела**: общее время на получение заявленных `Content-Length` байт плюс санитарный потолок на саму длину.
- **Evict по простою**: соединение без единого байта N секунд закрывается независимо от состояния.
В userspace-движке Rampart это ровно `tokio::time::timeout` вокруг фазы хендшейка/чтения — если полный запрос не распарсен в окно, соединение дропается, источник получает штраф репутации.
### 3.2 Лимиты соединений per IP
Одному IP редко нужно больше горстки одновременных соединений. Вводим:
- максимум одновременных соединений на IP,
- максимум новых соединений в секунду на IP (token bucket),
- глобальный потолок на долю недопарсенных запросов в пуле.
При per-IP квотах одна машина со Slowloris занимает только свою квоту и больше ничего; распределённый вариант затем душится rate limit'ами и весами ASN-репутации (датацентровые источники получают меньшие квоты, чем residential).
### 3.3 Буферизация reverse-proxy — сузить радиус поражения
Буферизирующий reverse-proxy (или сам edge Rampart'а) перед приложением меняет правила игры:
- Прокси читает **полный** запрос до пересылки чего-либо наверх.
- Воркеры приложения видят только полностью сформированные запросы; медленные клиенты упираются в буферы прокси, а не в потоки приложения.
- Буферы прокси дёшевы, рассчитаны на тысячи зависших соединений и работают в паре с агрессивными таймаутами из §3.1.
### Почему userspace-фильтр здесь главный
```mermaid
flowchart TD
C[Соединение установлено] --> X{Слой XDP}
X -- "видит только валидный TCP.<br/>Не отличит медленную атаку<br/>от медленного человека" --> P[Байты доходят до userspace]
P --> T{Userspace-движок:<br/>header read timeout,<br/>лимит conn-per-IP,<br/>отслеживание состояния запроса}
T -- "неполон за дедлайном" --> D[Дроп + штраф репутации]
T -- "успел целиком" --> APP[Прикладная логика]
style D fill:#f5d0d0
```
Ядерные слои (XDP, nftables) оперируют пакетами и флагами; медленная атака производит идеально валидные пакеты. Различающая информация — «как долго этот запрос остаётся незавершённым и сколько их у этого IP» — живёт в прикладном состоянии сессии. Поэтому: дешёвые ядерные слои продолжают держать объёмный щит, но **медленные атаки реально решаются в userspace-движке** — именно поэтому Rampart размещает там логику таймаутов и репутации, а не пытается затолкать всё в eBPF.

View file

@ -0,0 +1,237 @@
# SYN Flood: Anatomy of the Classic TCP Attack
> Knowledge Base · Rampart attack fundamentals · Related: [kernel-tuning.md](../practice/kernel-tuning.md), [defense-levels.md](../defense-levels.md)
## English
## 1. The TCP handshake, briefly
Every TCP connection starts with a three-way handshake:
1. **SYN** — the client sends a packet with the SYN flag and its initial sequence number (ISN).
2. **SYN-ACK** — the server replies with SYN+ACK and its own ISN.
3. **ACK** — the client confirms; the connection moves to `ESTABLISHED`.
Between steps 1 and 3, the connection is **half-open**: the server has already allocated memory for it in a special queue called the **SYN backlog** (`tcp_max_syn_backlog`), waiting for the final ACK. A half-open connection lives until `tcp_synack_retries` retransmissions are exhausted (default ~1 minute).
This asymmetry is the whole problem: **the attacker spends one packet per half-open connection, the server spends memory + a timer + a potential retransmission.**
## 2. How the attack works
The attacker floods the target with SYN packets and never sends the final ACK (or spoofs an unreachable source IP, so SYN-ACKs go nowhere). The backlog fills up with dead half-open connections. When it is full, legitimate SYNs are dropped — the service becomes unreachable.
```mermaid
sequenceDiagram
participant B as Attacker / botnet
participant K as Kernel (listen socket)
participant L as Legitimate client
Note over K: SYN backlog capacity = tcp_max_syn_backlog
B->>K: SYN #1 (never completes)
K-->>B: SYN-ACK (retransmits up to tcp_synack_retries)
B->>K: SYN #2..#N (no ACK ever)
K-->>B: SYN-ACK...
Note over K: backlog full of half-open entries<br/>retransmit timers burning CPU
L->>K: SYN (legitimate)
K--)L: dropped — backlog overflow<br/>FILTERING POINT: this is what we must prevent
```
Key point: the victim never sees an error — connections simply time out. From the user's perspective the port is "down" even though the machine may be nearly idle.
## 3. Attack variants
| Variant | Source addresses | Difficulty | Why it works |
|---|---|---|---|
| **Single-IP flood** | One real IP | Trivial | Only viable against hosts without per-source limiting; one IP can still open tens of thousands of half-open sockets |
| **Distributed (botnet)** | Many real IPs | Low | Each bot opens a few hundred half-open connections; per-IP limits are diluted across thousands of sources |
| **Spoofed source IPs** | Random / unreachable IPs | Medium | SYN-ACK goes to a third party; the attacker pays 1 packet per connection, the server pays memory + retransmissions; per-IP limiting is useless because each "source" appears once |
Spoofed floods are the most dangerous variant: rate-limiting by source IP cannot help when every packet has a fresh fake address.
## 4. SYN cookies: stateless handshake
SYN cookies move the connection state out of server memory and into the wire. Instead of allocating a backlog entry, the server encodes everything it needs to know into the sequence number of its own SYN-ACK:
```
SYN-ACK seq = MD5/IP-hash(secret, src_ip, src_port, dst_ip, dst_port) ← top 24 bits
+ timestamp mod 2^6 ← middle 5 bits (rotating)
+ MSS encoding ← low 3 bits
```
When the final ACK arrives, the server recomputes the expected value from the ACK number. If it matches, the connection was legitimately completed — *and only then* does the kernel allocate a socket. No backlog entry was ever consumed by the attacker.
```mermaid
sequenceDiagram
participant B as Attacker (no ACK)
participant K as Kernel with syncookies
participant L as Legitimate client
B->>K: SYN
K-->>B: SYN-ACK with encoded seq (state NOT stored)
Note over B: attacker ignores it — nothing happened on the server
L->>K: SYN
K-->>L: SYN-ACK with encoded seq (state NOT stored)
L->>K: ACK (seq+1 matches cookie)
Note over K: cookie verified → NOW allocate socket
K-->>L: ESTABLISHED
rect rgb(230, 240, 255)
Note over K: FILTERING POINT: allocation happens only after<br/>cryptographic proof of round-trip capability
end
```
### Trade-offs
SYN cookies are not free:
- **TCP options are lost** — window scale, SACK, timestamps cannot be negotiated because there are no bits left in the sequence number (the Linux workaround stores a small MSS code only). Legitimate clients get degraded connections while cookies are active.
- **No retransmission bookkeeping** — the kernel does not remember outstanding SYN-ACKs, so behavior under packet loss differs slightly from the normal path.
- **CPU cost** — a hash computation per SYN instead of a table insert; negligible normally, measurable at millions of pps.
- They activate only under backlog pressure (`net.ipv4.tcp_syncookies = 1`, value `2` = always on), so they are a **fallback**, not the first line.
## 5. Defense layering in Rampart
The correct order of defense is cheapest-first: kill the flood before the kernel ever touches its backlog.
```mermaid
flowchart TD
A[SYN packets arrive at NIC] --> B{XDP program:<br/>per-src-IP SYN throttle}
B -- "over N SYNs/sec from one IP<br/>→ temporary ban entry in eBPF map" --> X[XDP_DROP<br/>~50-100 ns/packet]
B --> C{Invalid flags?<br/>SYN+FIN, SYN+RST}
C -- yes --> X
C -- no --> D[Kernel stack]
D --> E{Backlog full?}
E -- "yes → SYN cookies kick in<br/>(stateless, no memory spent)" --> F[Cookie-verified connections proceed]
E -- no --> G[Normal handshake]
F --> H[Userspace engine:<br/>rate limit, PoW challenge]
G --> H
style X fill:#f5d0d0
style F fill:#d0e8d0
```
Layer responsibilities:
1. **XDP SYN throttle (first line)** — a token-bucket or fixed-window counter per source IP in an eBPF LRU map. More than N SYNs/sec from one address → drop in the driver, before `sk_buff` allocation. This handles single-IP floods entirely and blunts distributed ones.
2. **Kernel sysctls (second line)** — `tcp_syncookies=1`, enlarged `tcp_max_syn_backlog` and `somaxconn`, reduced `tcp_synack_retries`. Full list and rationale: [kernel-tuning.md](../practice/kernel-tuning.md).
3. **Userspace engine (third line)** — connections that complete the handshake face per-IP connection rate limiting and a proof-of-work challenge before any application logic runs.
## Русский
## 1. TCP-хендшейк вкратце
Каждое TCP-соединение начинается с трёхстороннего рукопожатия:
1. **SYN** — клиент шлёт пакет с флагом SYN и своим начальным номером последовательности (ISN).
2. **SYN-ACK** — сервер отвечает SYN+ACK со своим ISN.
3. **ACK** — клиент подтверждает; соединение переходит в `ESTABLISHED`.
Между шагами 1 и 3 соединение является **полуоткрытым (half-open)**: сервер уже выделил под него память в специальной очереди — **SYN backlog** (`tcp_max_syn_backlog`) — и ждёт финальный ACK. Полуоткрытое соединение живёт до исчерпания ретрансмиссий `tcp_synack_retries` (по умолчанию около минуты).
В этой асимметрии и вся проблема: **атакующий тратит один пакет на каждое полуоткрытое соединение, сервер — память + таймер + потенциальную ретрансмиccию.**
## 2. Как работает атака
Атакующий заваливает цель SYN-пакетами и никогда не отправляет финальный ACK (или подделывает недостижимый исходный IP — тогда SYN-ACK уходят в никуда). Backlog заполняется мёртвыми полуоткрытыми соединениями. Когда он переполнен, легитимные SYN дропаются — сервис становится недоступен.
```mermaid
sequenceDiagram
participant B as Атакующий / ботнет
participant K as Ядро (listening socket)
participant L as Легитимный клиент
Note over K: ёмкость backlog = tcp_max_syn_backlog
B->>K: SYN #1 (никогда не завершится)
K-->>B: SYN-ACK (ретранслирует до tcp_synack_retries раз)
B->>K: SYN #2..#N (ACK не будет никогда)
K-->>B: SYN-ACK...
Note over K: backlog забит полуоткрытыми записями,<br/>таймеры ретрансляций жгут CPU
L->>K: SYN (легитимный)
K--)L: дроп — переполнение backlog<br/>ТОЧКА ФИЛЬТРАЦИИ: именно этого надо не допустить
```
Важный момент: жертва не видит никакой ошибки — соединения просто отваливаются по таймауту. Со стороны пользователя порт «лежит», хотя машина может быть почти простаивает.
## 3. Варианты атаки
| Вариант | Адреса источника | Сложность | Почему работает |
|---|---|---|---|
| **Флад с одного IP** | Один реальный IP | Тривиально | Работает только против хостов без per-source лимитов; один IP всё равно открывает десятки тысяч полусокетов |
| **Распределённый (ботнет)** | Много реальных IP | Низко | Каждый бот держит несколько сотен полуоткрытых соединений; per-IP лимиты размываются по тысячам источников |
| **Поддельные source IP** | Случайные / недостижимые IP | Средне | SYN-ACK уходит третьей стороне; атакующий платит 1 пакет за соединение, сервер — памятью и ретрансляциями; per-IP лимитирование бесполезно, ведь каждый «источник» появляется один раз |
Спуфинг-вариант самый опасный: ограничение по source IP бессмысленно, когда каждый пакет приходит с нового поддельного адреса.
## 4. SYN cookies: stateless-хендшейк
SYN cookies выносят состояние соединения из памяти сервера прямо в сеть. Вместо выделения записи в backlog сервер кодирует всю нужную информацию в номере последовательности собственного SYN-ACK:
```
SYN-ACK seq = hash(secret, src_ip, src_port, dst_ip, dst_port) ← старшие 24 бита
+ timestamp mod 2^6 ← средние 5 бит (ротация)
+ кодировка MSS ← младшие 3 бита
```
Когда приходит финальный ACK, сервер пересчитывает ожидаемое значение из номера ACK. Если совпало — соединение завершено легитимно, и *только тогда* ядро выделяет сокет. Атакующий не израсходовал ни одной записи backlog.
```mermaid
sequenceDiagram
participant B as Атакующий (без ACK)
participant K as Ядро с syncookies
participant L as Легитимный клиент
B->>K: SYN
K-->>B: SYN-ACK с закодированным seq (состояние НЕ хранится)
Note over B: атакующий игнорирует — на сервере ничего не произошло
L->>K: SYN
K-->>L: SYN-ACK с закодированным seq (состояние НЕ хранится)
L->>K: ACK (seq+1 совпадает с cookie)
Note over K: cookie верифицирован → ТОЛЬКО СЕЙЧАС выделяем сокет
K-->>L: ESTABLISHED
rect rgb(230, 240, 255)
Note over K: ТОЧКА ФИЛЬТРАЦИИ: аллокация происходит только после<br/>криптографического доказательства способности к round-trip
end
```
### Trade-offs
SYN cookies не бесплатны:
- **Потеря TCP-опций** — window scale, SACK, timestamps согласовать нельзя: в номере последовательности нет свободных бит (в Linux сохраняется только небольшой код MSS). Легитимные клиенты получают ухудшенные соединения, пока cookies активны.
- **Нет учёта ретрансляций** — ядро не помнит отправленные SYN-ACK, поэтому поведение при потерях немного отличается от нормального пути.
- **Стоимость CPU** — хеш на каждый SYN вместо вставки в таблицу; обычно незаметно, но измеримо при миллионах pps.
- Cookies включаются только при давлении на backlog (`net.ipv4.tcp_syncookies = 1`, значение `2` = всегда) — это **резервный механизм**, а не первая линия обороны.
## 5. Эшелонированная защита в Rampart
Правильный порядок защиты — от дешёвого к дорогому: убить флад до того, как ядро вообще тронет свой backlog.
```mermaid
flowchart TD
A[SYN-пакеты приходят на NIC] --> B{XDP-программа:<br/>per-src-IP SYN throttle}
B -- "больше N SYN/сек с одного IP<br/>→ временный бан в eBPF map" --> X[XDP_DROP<br/>~50-100 нс/пакет]
B --> C{Невалидные флаги?<br/>SYN+FIN, SYN+RST}
C -- да --> X
C -- нет --> D[Стек ядра]
D --> E{Backlog переполнен?}
E -- "да → включаются SYN cookies<br/>(stateless, память не тратится)" --> F[Соединения с верным cookie проходят дальше]
E -- нет --> G[Обычный хендшейк]
F --> H[Userspace-движок:<br/>rate limit, PoW-challenge]
G --> H
style X fill:#f5d0d0
style F fill:#d0e8d0
```
Ответственность слоёв:
1. **XDP SYN throttle (первая линия)** — token bucket или fixed-window счётчик на source IP в eBPF LRU map. Больше N SYN/сек с одного адреса → дроп в драйвере, до выделения `sk_buff`. Это полностью закрывает одно-IP флуды и ослабляет распределённые.
2. **Sysctls ядра (вторая линия)** — `tcp_syncookies=1`, увеличенные `tcp_max_syn_backlog` и `somaxconn`, сниженный `tcp_synack_retries`. Полный список и обоснование: [kernel-tuning.md](../practice/kernel-tuning.md).
3. **Userspace-движок (третья линия)** — соединения, прошедшие хендшейк, упираются в per-IP rate limit и proof-of-work challenge до запуска любой прикладной логики.

View file

@ -0,0 +1,177 @@
# UDP Amplification: Small Request, Giant Answer
> Knowledge Base · Rampart attack fundamentals · Related: [defense-levels.md](../defense-levels.md), [syn-flood.md](./syn-flood.md)
## English
## 1. Why UDP is the attacker's favorite protocol
UDP is connectionless: anyone can send a packet to any port without ever completing a handshake, and the receiving service will answer. This enables two abuses at once:
- **Reflection** — the attacker sets the *source* IP of their request packets to the victim's address. The open UDP service then sends its reply to the victim, not to the attacker. The victim sees traffic coming from thousands of legitimate DNS/NTP/Memcached servers around the world — attribution and blocking become hard.
- **Amplification** — if the response is much larger than the request, every attacker byte is multiplied. The attack bandwidth is no longer limited by the botnet's uplink but by the amplification factor of the abused protocol.
Combined: a botnet with 1 Gbps of egress can generate tens or hundreds of Gbps of inbound traffic.
```mermaid
sequenceDiagram
participant A as Attacker (spoofing src = Victim)
participant R as Open resolver / NTP / Memcached
participant V as Victim
A->>R: small UDP query<br/>(src IP forged = V)
Note over A: cost: ~60 bytes of upload per query
R-->>V: huge UDP response<br/>(sent to spoofed source!)
Note over V: receives 28x–10000x the bytes<br/>FILTERING POINT: this traffic must die<br/>before it consumes real bandwidth/CPU
V->>R: (victim cannot tell "real" servers from reflectors)
```
The attacker never sees the responses and doesn't care — the goal is the victim's pipe, not a conversation.
## 2. Amplification factors
Measured as `response size / request size` for a single well-formed query:
| Protocol | Request | Response | Amplification factor |
|---|---|---|---|
| **DNS** (open resolver, ANY/other records) | ~60 B query | up to ~3 KB response | **~28–54x** |
| **NTP** (`monlist` on old versions) | ~234 B command | up to ~130 KB list of peers | **~556x** |
| **Memcached** (exposed UDP port 11211) | ~15 B `get` command | megabytes of cached data, chunked into datagrams | **~10,000x+** |
Memcached deserves special mention: a single exposed instance with a few GB of cache turns a tiny botnet into a terabit-class event — the largest recorded volumetric attacks have used exactly this vector.
Other commonly abused protocols follow the same pattern: Chargen (~356x), SNMP (~6x), SSDP (~30x), CoAP (~10x), Portmapper (~28x).
Why does this work? Because these are legitimate services answering what looks like a legitimate question. The reflector is a victim too — it did nothing wrong except being open to the internet with UDP.
## 3. Defense
Defense against amplification has three distinct levels, each belonging to a different party:
### 3.1 BCP38 at the upstream — kill spoofing at the source
BCP38 ("Network Ingress Filtering") means the ISP verifies that packets leaving a customer network carry source addresses that actually belong to that customer. If every upstream performed BCP38 filtering (uRPF loose/strict mode), reflection attacks would be impossible by construction — you cannot spoof an address whose route points elsewhere.
This is outside the victim's control; it must be demanded from providers. When choosing hosting/upstream, BCP38 compliance is a real selection criterion.
### 3.2 UDP policy drop on the edge — your own first line
If your service speaks TCP only (as Rampart's protected services do), then **every incoming UDP packet to your public ports is noise by definition**. Drop it in XDP, before the kernel allocates a socket buffer:
```mermaid
flowchart TD
U[UDP packet arrives] --> P{XDP program:<br/>is UDP allowed on this port?}
P -- "no UDP listener expected<br/>→ policy drop" --> D[XDP_DROP<br/>~50 ns/packet, before skb]
P -- "UDP legitimately used<br/>(e.g. QUIC/DNS you host)" --> RL{Per-source rate limit<br/>in eBPF LRU map}
RL -- over limit --> D
RL -- ok --> K[Kernel stack]
style D fill:#f5d0d0
```
Rules of thumb:
- No UDP service on the port → drop all UDP there.
- Legitimate UDP service → strict per-source rate limiting + response-size caps; never run Memcached-style protocols on public ports.
- Fragmented UDP → drop first fragments with MF flag set unless fragmentation is genuinely needed.
Because XDP drops happen in the NIC driver (~tens of nanoseconds per packet), even multi-million-pps floods consume a fraction of one core instead of saturating the machine.
### 3.3 Rate limiting per source — for the unavoidable remainder
Traffic you cannot classify away (legitimate UDP protocols) gets token-bucket limits per source IP and per subnet in eBPF maps, plus ASN-based reputation weighting (datacenter sources get stricter budgets than residential). Persistent offenders graduate to a blacklist synced across edge nodes.
### Summary table
| Level | Who deploys | What it stops |
|---|---|---|
| BCP38 / uRPF upstream | Providers | Spoofing itself — removes the root cause |
| XDP UDP policy drop | You | The flood reaching kernel/userspace at all |
| Per-source rate limit | You | Abuse of UDP ports you actually need |
## Русский
## 1. Почему UDP — любимый протокол атакующего
UDP не требует соединения: любой может отправить пакет на любой порт, не завершая хендшейк, и сервис ответит. Это открывает сразу две возможности для злоупотребления:
- **Reflection (рефлексия)** — атакующий подменяет *source*-адрес своих запросов на адрес жертвы. Открытый UDP-сервис шлёт ответ жертве, а не атакующему. Жертва видит трафик с тысяч легитимных DNS/NTP/Memcached-серверов по всему миру — атрибуция и блокировка резко усложняются.
- **Amplification (усиление)** — если ответ сильно больше запроса, каждый байт атакующего умножается. Полоса атаки больше не ограничена аплинком ботнета, а определяется коэффициентом усиления эксплуатируемого протокола.
Вместе: ботнет с исходящими 1 Гбит/с генерирует десятки и сотни Гбит/с входящего трафика.
```mermaid
sequenceDiagram
participant A as Атакующий (spoofed src = Жертва)
participant R as Открытый резолвер / NTP / Memcached
participant V as Жертва
A->>R: маленький UDP-запрос<br/>(подделан src IP = V)
Note over A: цена: ~60 байт аплинка за запрос
R-->>V: огромный UDP-ответ<br/>(уходит на поддельный адрес!)
Note over V: получает в 28–10000 раз больше байт<br/>ТОЧКА ФИЛЬТРАЦИИ: этот трафик должен умереть,<br/>пока он не съел реальную полосу/CPU
V->>R: (жертва не может отличить «настоящие» серверы от рефлекторов)
```
Ответы атакующему не нужны и не интересны ему — цель полоса жертвы, а не диалог.
## 2. Коэффициенты усиления
Измеряется как `размер ответа / размер запроса` для одного корректного запроса:
| Протокол | Запрос | Ответ | Коэффициент усиления |
|---|---|---|---|
| **DNS** (открытый резолвер, ANY и др.) | запрос ~60 Б | ответ до ~3 КБ | **~28–54x** |
| **NTP** (`monlist` на старых версиях) | команда ~234 Б | список пиров до ~130 КБ | **~556x** |
| **Memcached** (открытый UDP-порт 11211) | команда `get` ~15 Б | мегабайты кэша, разбитые на датаграммы | **~10000x+** |
Memcached заслуживает отдельного упоминания: один открытый инстанс с парой гигабайт кэша превращает крошечный ботнет в терабитное событие — крупнейшие задокументированные объёмные атаки использовали именно этот вектор.
Другие часто эксплуатируемые протоколы работают так же: Chargen (~356x), SNMP (~6x), SSDP (~30x), CoAP (~10x), Portmapper (~28x).
Почему это работает? Потому что это легитимные сервисы, отвечающие на внешне легитимный вопрос. Рефлектор тоже жертва — он ничего плохого не сделал, просто был открыт в интернет по UDP.
## 3. Защита
Защита от амплификации существует на трёх уровнях, и каждый принадлежит разной стороне:
### 3.1 BCP38 на аплинке — убить спуфинг в зародыше
BCP38 («Network Ingress Filtering») означает, что провайдер проверяет: пакеты, покидающие клиентскую сеть, несут source-адреса, реально принадлежащие этой сети. Если бы все аплинки делали BCP38-фильтрацию (uRPF loose/strict), атаки отражением стали бы невозможны конструктивно — нельзя подделать адрес, чей маршрут ведёт в другое место.
Это вне контроля жертвы; этого нужно требовать от провайдеров. При выборе хостинга/аплинка соответствие BCP38 — реальный критерий отбора.
### 3.2 UDP policy drop на edge — ваша первая линия
Если ваш сервис говорит только по TCP (как защищаемые Rampart'ом сервисы), то **каждый входящий UDP-пакет на публичные порты — шум по определению**. Дропайте его в XDP, до того как ядро выделит сокет-буфер:
```mermaid
flowchart TD
U[Пришёл UDP-пакет] --> P{XDP-программа:<br/>разрешён ли UDP на этом порту?}
P -- "UDP-листенер не ожидается<br/>→ policy drop" --> D[XDP_DROP<br/>~50 нс/пакет, до skb]
P -- "UDP легитимен<br/>(например, свой QUIC/DNS)" --> RL{Per-source rate limit<br/>в eBPF LRU map}
RL -- превышен --> D
RL -- ок --> K[Стек ядра]
style D fill:#f5d0d0
```
Практические правила:
- На порту нет UDP-сервиса → дропать весь UDP там.
- Легитимный UDP-сервис есть → строгий per-source rate limit + ограничение размера ответа; Memcached-подобные протоколы наружу никогда не выставлять.
- Фрагментированный UDP → дропать первый фрагмент с флагом MF, если фрагментация действительно не нужна.
Поскольку XDP-дроп происходит в драйвере NIC (~десятки наносекунд на пакет), даже многомиллионный pps-флад съедает долю одного ядра вместо насыщения машины.
### 3.3 Rate limit per source — для неизбежного остатка
Трафик, который нельзя классифицировать прочь (легитимные UDP-протоколы), получает token-bucket лимиты на source IP и подсеть в eBPF-мапах плюс взвешивание по ASN-репутации (датацентровым источникам — более строгие бюджеты, чем residential). Устойчивые нарушители попадают в blacklist, синхронизируемый между edge-нодами.
### Сводная таблица
| Уровень | Кто внедряет | Что останавливает |
|---|---|---|
| BCP38 / uRPF у аплинка | Провайдеры | Сам спуфинг — устраняет первопричину |
| XDP UDP policy drop | Вы | Достижение фладом ядра/userspace вообще |
| Per-source rate limit | Вы | Злоупотребление нужными вам UDP-портами |

238
docs/kb/defense-levels.md Normal file
View file

@ -0,0 +1,238 @@
# Where to Filter Traffic: Comparing Defense Levels
> Knowledge Base · Rampart networking fundamentals
>
> The single most important architectural decision in a network protection system is **where** in the packet's journey you decide to drop it. Every level further down the stack costs more CPU per packet and gives you more information per packet. This document compares the levels, shows the full path of a packet through the Linux network stack, and explains why Rampart uses a hybrid kernel fast-path + userspace smart-path design.
## English
## 1. The core trade-off
There is an inverse relationship between **how early** you can inspect a packet and **how much** you can know about it:
- Early (kernel, pre-skb): you see raw bytes — Ethernet header, IP header, TCP header. Inspection is nearly free, but you cannot see application semantics.
- Late (userspace proxy): you see fully reassembled protocol streams — handshakes, requests, sessions. Inspection is expensive per byte, but the decision quality is much higher.
Good defense puts cheap decisions first and expensive decisions last.
## 2. Comparison of filtering levels
| Level | Hook point | When packet is dropped | Latency added per packet | CPU cost per packet | What can be checked | Development complexity |
|---|---|---|---|---|---|---|
| **XDP / eBPF** | NIC driver (or generic), before `skb` allocation | Before the kernel allocates `sk_buff`, before any socket work | ~tens of ns; drop happens right after DMA | Lowest: no skb alloc, no conntrack unless you opt in. Native mode: 15–20M pps dropped per core on modern hardware; generic mode (virtio): ~3–5M pps | Raw L2/L3/L4 headers: IP validation, TCP flags sanity (SYN+FIN, SYN+RST), port allowlists, per-IP rate limits, blacklist/whitelist maps, SYN cookies implemented manually, simple state machines (e.g., SYN → SYN-ACK seen?) | High: C with eBPF verifier constraints (no unbounded loops in older kernels, pointer arithmetic rules, map-based state only); hard to debug |
| **tc / eBPF** (`clsact` qdisc) | Traffic control layer, after skb exists, both ingress & egress | After `skb` allocation but before netfilter and before the socket lookup | Low, slightly above XDP-generic | Medium-low: skb already allocated; still cheaper than netfilter chains for pure header logic | Everything XDP sees, plus: packet marks, classification into qdiscs, egress shaping, works on all interfaces including those without native XDP support (bridges, bonds in some setups) | High-ish: same eBPF verifier, plus tc filter plumbing (`tc filter add ... bpf`) |
| **nftables / iptables** (netfilter hooks) | `PREROUTING`, `INPUT`, `FORWARD`, `OUTPUT`, `POSTROUTING` | Inside the kernel network stack, before delivery to the socket | Low-to-medium; each rule is evaluated linearly | Medium: full hook chain traversal, optional conntrack (which itself costs memory and CPU per connection) | L3/L4 matching (addr/port/proto), conntrack state (`ESTABLISHED` vs `NEW`), rate limiting (`hashlimit`, `recent`), set-based lookups (nftables sets/maps), SYN proxying (`synproxy`), basic string matching (expensive) | Low-medium: declarative config, well documented, no verifier; but limited to what matches exist |
| **Userspace reverse proxy** (nginx / HAProxy / Envoy / custom Rust engine like Rampart edge) | Socket receive queue → application event loop (epoll/io_uring) | After the kernel has done the full TCP handshake and delivered bytes to userspace | Highest: syscall overhead, context switches, copies; measured in microseconds | Highest: full TCP state machine per connection, socket buffers, epoll wakeups. But cost is **per connection**, not per packet | Full L7 semantics: protocol parsing, request validity, authentication, PoW challenges, session behavior, per-client rate limiting, reputation, dynamic difficulty, TLS termination | Low-medium: normal programming language, easy testing; but every attack connection consumes kernel socket resources until you close it |
Key numbers to internalize (order-of-magnitude, hardware-dependent):
```
XDP native drop: ~50–100 ns/packet, millions of pps per core
XDP generic drop: a few hundred ns/packet (extra copy driver→skb path)
nftables drop: hundreds of ns to µs (rule count dependent)
conntrack entry: ~300 bytes RAM per tracked connection
Userspace accept+drop: ~10–20 µs CPU per connection (epoll, Rust/C)
≈ 80k new conn/s per core for a real L7 handler
```
The practical consequence: if a 5M pps flood reaches your userspace proxy, your proxy dies doing `accept()` calls that produce nothing. If the same flood hits XDP_DROP, it burns ~one core at most.
## 3. Packet path through the Linux stack
Where each mechanism can intervene:
```mermaid
flowchart TD
A[Packet arrives at NIC] --> B{XDP program<br/>attached to driver?}
B -- "XDP_DROP<br/>(no skb allocated)" --> DROPPED1[Dropped, cheapest]
B -- XDP_PASS --> C[Driver builds sk_buff]
C --> E{tc ingress filter<br/>clsact qdisc}
E -- "tc drop / bpf verdict" --> DROPPED2[Dropped, no netfilter cost]
E -- accept --> F[Netfilter PREROUTING]
F --> G{Conntrack<br/>new connection?}
G --> H[nftables INPUT chain<br/>rules, hashlimit, synproxy]
H -- REJECT/DROP --> DROPPED3[Dropped inside stack]
H -- ACCEPT --> I[TCP/IP stack processing<br/>SYN queue → accept queue]
I -- "backlog overflow,<br/>no syncookies" --> DROPPED4[Dropped by kernel]
I --> J[Socket listen queue<br/>somaxconn limit]
J --> K[Application accepts<br/>epoll / io_uring]
K --> L{L7 logic:<br/>parse, auth, PoW, rate limit}
L -- reject --> DROPPED5[Connection closed<br/>most expensive drop]
L -- valid --> M[Proxied to backend]
style DROPPED1 fill:#2e7d32,color:#fff
style DROPPED2 fill:#43a047,color:#fff
style DROPPED3 fill:#7cb342,color:#000
style DROPPED4 fill:#c0ca33,color:#000
style DROPPED5 fill:#e53935,color:#fff
```
The color gradient is the point: green drops are almost free, red drops cost a full TCP handshake plus userspace scheduling. Every layer you let a bad packet traverse multiplies its cost to you by orders of magnitude.
## 4. Decision rule: drop as early as possible, decide as late as necessary
A useful way to split responsibilities:
### Filter at L3/L4 (kernel: XDP, tc, nftables)
Volume attacks where correctness does not require understanding the payload:
- SYN floods, ACK floods, RST/FIN floods with no matching connection state
- Invalid or nonsensical TCP flag combinations (SYN+FIN, SYN+RST, URG-only)
- IP fragmentation abuse (drop initial fragments when the protocol never needs them)
- Spoofed sources (uRPF-style checks, TTL heuristics)
- Known-bad IPs from a shared blacklist (BPF maps updated from userspace)
- Per-IP packet/connection rate limiting above a generous threshold
These checks are per-packet, stateless or near-stateless, and identical for every protocol — they do not need to know whether the traffic is HTTP, SSH, game traffic, or anything else. That makes them perfect candidates for compile-time-reusable kernel components.
### Decide at L7 (userspace)
Anything requiring session context or protocol semantics:
- Is this a syntactically and semantically valid request?
- Has this client proven computational effort (PoW challenge)?
- Does this client's behavior over time look human/legitimate (reputation)?
- Should difficulty rise because aggregate load crossed a threshold?
These questions cannot be answered from headers alone, so they must live where the stream is visible — the userspace engine.
### The boundary principle
If a check depends only on fields present in the first ~100 bytes of the frame and applies to every packet equally → push it toward XDP/tc/nftables.
If a check depends on history, reassembly, or protocol grammar → keep it in userspace.
Everything else lives somewhere between, typically nftables with conntrack or a tc BPF state machine.
## 5. Why hybrid architecture is the industry standard
No single level survives contact with real attacks:
- Kernel-only defense (firewall tuning alone): survives volumetric floods, but cannot tell a legitimate client from a bot once headers look fine.
- Userspace-only defense: excellent decision quality, but a modest pps flood exhausts the softirq/accept path before your smart logic ever runs.
Hence the pattern used by commercial scrubbing services and open-source stacks alike:
1. **Kernel fast-path**: XDP drops the obvious garbage at line rate; per-IP throttling keeps the connection rate bounded; blacklists are hot-updated from userspace via BPF maps.
2. **Userspace smart-path**: what survives is a small, semantically rich stream that justifies spending tens of microseconds per connection on parsing, PoW, and reputation.
The two halves communicate: userspace intelligence writes policy (block this IP, raise throttle for this prefix) into kernel maps; kernel telemetry (dropped counters, sampled offenders) flows back up to userspace for analysis. Neither half is useful alone; together they cover both axes of the trade-off.
This is exactly how Rampart structures its edge: eBPF/XDP programs own L3/L4 volume rejection, the Rust engine owns L7 verification (protocol plugins behind feature flags, universal SHA256 proof-of-work), and shared maps/counters are the contract between them.
---
---
## Русский
## 1. Ключевой компромисс
Между тем, **насколько рано** вы можете инспектировать пакет, и тем, **сколько** вы о нём знаете, существует обратная зависимость:
- Рано (ядро, до skb): видны сырые байты — заголовки Ethernet, IP, TCP. Инспекция почти бесплатна, но семантику приложения не видно.
- Поздно (userspace-прокси): видны полностью собранные потоки протокола — хендшейки, запросы, сессии. Инспекция дорогая в расчёте на байт, зато качество решения намного выше.
Хорошая защита сначала применяет дешёвые проверки, а дорогие оставляет напоследок.
## 2. Сравнение уровней фильтрации
| Уровень | Точка перехвата | Когда пакет дропается | Добавляемая задержка на пакет | Стоимость CPU на пакет | Что можно проверить | Сложность разработки |
|---|---|---|---|---|---|---|
| **XDP / eBPF** | Драйвер NIC (или generic), до аллокации `skb` | До того как ядро выделит `sk_buff`, до какой-либо работы с сокетами | Десятки нс; дроп сразу после DMA | Минимальная: нет аллокации skb, нет conntrack без явного включения. Native-режим: 15–20 млн pps дропа на ядро на современном железе; generic-режим (virtio): ~3–5 млн pps | Сырые заголовки L2/L3/L4: валидация IP, проверка флагов TCP (SYN+FIN, SYN+RST), порт-белые списки, per-IP rate limit, чёрные/белые списки через BPF-мапы, самописные SYN cookies, простые конечные автоматы (например: SYN → видели SYN-ACK?) | Высокая: язык C с ограничениями eBPF-верификатора (нет неограниченных циклов на старых ядрах, правила арифметики указателей, состояние только через мапы); тяжело отлаживать |
| **tc / eBPF** (`clsact` qdisc) | Уровень traffic control, после существования skb, ingress и egress | После аллокации `skb`, но до netfilter и до поиска сокета | Низкая, чуть выше XDP-generic | Средне-низкая: skb уже выделен; для чистой логики на заголовках всё ещё дешевле цепочек netfilter | Всё, что видит XDP, плюс: маркировка пакетов, классификация в qdisc, шейпинг на egress, работает на интерфейсах без нативного XDP (мосты, бонды в ряде конфигураций) | Повышенная: тот же верификатор eBPF плюс обвязка tc-фильтров (`tc filter add ... bpf`) |
| **nftables / iptables** (хуки netfilter) | `PREROUTING`, `INPUT`, `FORWARD`, `OUTPUT`, `POSTROUTING` | Внутри сетевого стека ядра, до доставки сокету | От низкой к средней; правила вычисляются линейно | Средняя: полный проход по цепочке хуков, опциональный conntrack (который сам стоит памяти и CPU на каждое соединение) | L3/L4-сопоставление (адрес/порт/протокол), состояние conntrack (`ESTABLISHED` vs `NEW`), rate limiting (`hashlimit`, `recent`), lookup по сетам (nftables sets/maps), SYN-проксирование (`synproxy`), базовый string match (дорогой) | Низко-средняя: декларативный конфиг, хорошая документация, нет верификатора; но ограничено набором существующих match'ей |
| **Userspace reverse proxy** (nginx / HAProxy / Envoy / собственный Rust-движок, как edge в Rampart) | Очередь приёма сокета → event loop приложения (epoll/io_uring) | После того как ядро полностью завершило TCP handshake и отдало байты в userspace | Наибольшая: накладные расходы syscall'ов, переключения контекста, копирования; микросекунды | Максимальная: полный TCP state machine на соединение, буферы сокетов, пробуждения epoll. Но стоимость считается **на соединение**, а не на пакет | Полная семантика L7: парсинг протокола, валидность запросов, аутентификация, PoW-челленджи, поведение сессий, per-client rate limit, репутация, динамическая сложность, терминирование TLS | Низко-средняя: обычный язык программирования, простое тестирование; но каждое атакующее соединение потребляет ресурсы сокетов ядра, пока его не закроешь |
Ключевые цифры, которые стоит запомнить (порядок величин, зависит от железа):
```
XDP native drop: ~50–100 нс/пакет, миллионы pps на ядро
XDP generic drop: несколько сотен нс/пакет (лишняя копия по пути driver→skb)
nftables drop: сотни нс — мкс (зависит от числа правил)
Запись conntrack: ~300 байт RAM на отслеживаемое соединение
Accept+drop в юзерспейсе: ~10–20 мкс CPU на соединение (epoll, Rust/C)
≈ 80 тыс. новых conn/s на ядро у реального L7-обработчика
```
Практический вывод: если флуд в 5 млн pps дойдёт до вашего userspace-прокси, прокси умрёт, выполняя пустые `accept()`. Если тот же флуд упирается в XDP_DROP, он сожжёт максимум одно ядро.
## 3. Путь пакета через стек Linux
Где каждый механизм может вмешаться:
```mermaid
flowchart TD
A[Пакет прибыл на NIC] --> B{XDP-программа<br/>прицеплена к драйверу?}
B -- "XDP_DROP<br/>(skb не выделялся)" --> DROPPED1[Дропнут, дешевле некуда]
B -- XDP_PASS --> C[Драйвер создаёт sk_buff]
C --> E{tc ingress фильтр<br/>qdisc clsact}
E -- "tc drop / вердикт bpf" --> DROPPED2[Дропнут, без затрат netfilter]
E -- accept --> F[Netfilter PREROUTING]
F --> G{Conntrack:<br/>новое соединение?}
G --> H[Цепочка nftables INPUT<br/>правила, hashlimit, synproxy]
H -- REJECT/DROP --> DROPPED3[Дропнут внутри стека]
H -- ACCEPT --> I[Обработка стека TCP/IP<br/>SYN-очередь → accept-очередь]
I -- "переполнение backlog,<br/>без syncookies" --> DROPPED4[Дропнут ядром]
I --> J[Очередь прослушивания сокета<br/>лимит somaxconn]
J --> K[Приложение принимает<br/>epoll / io_uring]
K --> L{Логика L7:<br/>парсинг, аутентификация, PoW, rate limit}
L -- reject --> DROPPED5[Соединение закрыто<br/>самый дорогой дроп]
L -- valid --> M[Проксировано на бэкенд]
style DROPPED1 fill:#2e7d32,color:#fff
style DROPPED2 fill:#43a047,color:#fff
style DROPPED3 fill:#7cb342,color:#000
style DROPPED4 fill:#c0ca33,color:#000
style DROPPED5 fill:#e53935,color:#fff
```
Цветовой градиент здесь и есть главная мысль: зелёные дропы почти бесплатны, красный стоит полного TCP handshake плюс планирования в userspace. Каждый уровень, который плохой пакет проходит насквозь, умножает его стоимость для вас на порядки.
## 4. Правило решения: дропай как можно раньше, решай как можно позже
Полезный способ разделить ответственность:
### Фильтровать на L3/L4 (ядро: XDP, tc, nftables)
Объёмные атаки, где корректность не требует понимания полезной нагрузки:
- SYN-флуды, ACK-флуды, RST/FIN-флуды без соответствующего состояния соединения
- Неверные или бессмысленные комбинации TCP-флагов (SYN+FIN, SYN+RST, только URG)
- Злоупотребление IP-фрагментацией (дроп первых фрагментов, когда протоколу они никогда не нужны)
- Поддельные источники (проверки в духе uRPF, эвристики по TTL)
- Известные плохие IP из общего блэклиста (BPF-мапы, обновляемые из userspace)
- Per-IP ограничение частоты пакетов/соединений выше щедрого порога
Эти проверки попакетные, без состояния или почти без состояния, и одинаковы для любого протокола — им не нужно знать, HTTP это, SSH, игровой трафик или что-то ещё. Это делает их идеальными кандидатами на переиспользуемые compile-time компоненты ядра.
### Решать на L7 (userspace)
Всё, что требует контекста сессии или семантики протокола:
- Является ли запрос синтаксически и семантически валидным?
- Доказал ли клиент вычислительные затраты (PoW-челлендж)?
- Похожа ли история поведения клиента на легитимную (репутация)?
- Нужно ли поднять сложность, потому что суммарная нагрузка пересекла порог?
На эти вопросы нельзя ответить по одним заголовкам, поэтому они живут там, где виден поток — в движке userspace.
### Принцип границы
Если проверка зависит только от полей в первых ~100 байтах кадра и одинаково применима к каждому пакету → двигайте её в XDP/tc/nftables.
Если проверка зависит от истории, сборки потока или грамматики протокола → держите её в userspace.
Всё остальное лежит посередине — обычно это nftables с conntrack или конечный автомат на tc BPF.
## 5. Почему гибридная архитектура — стандарт индустрии
Ни один отдельный уровень не выдерживает встречи с реальными атаками:
- Только ядро (один лишь тюнинг firewall'а): переживёт объёмный флуд, но не отличит легитимного клиента от бота, если заголовки выглядят нормально.
- Только userspace: отличное качество решений, но даже скромный флуд по pps исчерпает путь softirq/accept раньше, чем заработает ваша умная логика.
Поэтому и коммерческие сервисы чистки трафика, и open-source стеки используют одну схему:
1. **Kernel fast-path**: XDP сбрасывает очевидный мусор на линейной скорости; per-IP троттлинг ограничивает частоту соединений; блэклисты горячо обновляются из userspace через BPF-мапы.
2. **Userspace smart-path**: то, что выжило, — небольшой семантически насыщенный поток, ради которого оправданы десятки микросекунд CPU на соединение на парсинг, PoW и репутацию.
Половины общаются между собой: интеллект в userspace пишет политику (заблокировать этот IP, поднять троттлинг для этого префикса) в kernel-мапы; телеметрия ядра (счётчики дропов, сэмплы нарушителей) течёт наверх в userspace для анализа. Поодиночке ни одна половина не полезна; вместе они закрывают обе оси компромисса.
Именно так устроен edge в Rampart: программы eBPF/XDP отвечают за объёмное отсечение на L3/L4, Rust-движок — за верификацию на L7 (протокол-плагины за feature-флагами, универсальный SHA256 proof-of-work), а разделяемые мапы и счётчики — контракт между ними.

View file

@ -0,0 +1,290 @@
# Kernel Tuning Against DDoS (sysctl)
> Knowledge Base · Practice
>
> Before any smart defense runs, the Linux kernel itself decides how many half-open connections it will tolerate, how deep its queues are, and when it gives up. Misconfigured defaults turn a moderate flood into a total outage. This article walks through the sysctl parameters that matter, explains each one, and provides a ready-to-adapt config.
## English
## 1. How it works
All parameters below live under `/proc/sys/` and can be set either at runtime (`sysctl -w`, lost on reboot) or persistently via files in `/etc/sysctl.d/*.conf` (applied by `systemd-sysctl` at boot or `sysctl --system` manually). The `/proc/sys/net/ipv4/tcp_syncookies` file corresponds to the `net.ipv4.tcp_syncookies` key, and so on.
## 2. Parameters explained
### TCP SYN flood protection
**`net.ipv4.tcp_syncookies = 1`**
When the SYN accept queue for a listening socket overflows, the kernel stops storing half-open connections and instead encodes the connection state into cryptographically signed cookies inside the SYN-ACK sequence number. A real client returns the cookie in its ACK; a flooder usually does not. This is the single most important anti-SYN-flood knob in stock Linux.
- `0` — disabled
- `1` — enabled unconditionally
- `2` — always send cookies regardless of queue state (more aggressive; use only if you know what you are doing, since some TCP option negotiation degrades)
Trade-off: cookies cannot carry TCP options negotiated during handshake (window scaling is preserved on Linux via a syncookie extension, but SACK/Timestamps may be lost), so throughput per connection can drop slightly. Keep it `1` — the cost is negligible versus being SYN-flooded to death with default settings.
**`net.ipv4.tcp_max_syn_backlog = 65535`**
Maximum number of half-open (SYN received, ACK not yet) connections queued **per listening socket** before the kernel starts dropping SYNs or engaging syncookies. Default is often 128–1024 depending on kernel version and `net.core.somaxconn`. Raising it lets a burst of legitimate clients through without immediately triggering cookie mode. Cost: each queued SYN consumes a small amount of memory (request_sock structure), so this scales with RAM, not much else.
**`net.ipv4.tcp_synack_retries = 2`** and **`net.ipv4.tcp_syn_retries = 2`**
How many times the kernel retransmits SYN-ACK (server side) / SYN (client side) without an answer before giving up. Lower values clean up dead half-open connections faster, freeing backlog slots during floods. Default 5–6 means a dead connection lingers for over a minute; value `2` cuts that to seconds.
### Listen queues
**`net.core.somaxconn = 65535`**
Upper bound on the *accept* queue (fully established connections waiting for the application to call `accept()`) of every listening socket. Critically: the application must also ask for a large backlog (the second argument of `listen()`); the effective value is `min(somaxconn, app_backlog)`. If your proxy accepts slower than clients connect, a full accept queue causes silent SYN drops or resets — raising `somaxconn` alone does nothing if the app requests 128.
**`net.ipv4.tcp_abort_on_overflow = 0`**
Leave at `0` (default): when the accept queue overflows, the kernel silently drops SYNs, letting clients retry gracefully. Setting it to `1` sends RST immediately — useful for fail-fast load tests, harmful in production.
### Packet ingress
**`net.core.netdev_max_backlog = 65535`**
Queue of frames received by the NIC but not yet processed by the CPU's softirq (NET_RX) loop. Under a packet burst faster than the CPU can drain it, this queue absorbs the difference; overflow means dropped packets *before* any filtering logic sees them. Default is often 1000, which a multi-million-pps burst exhausts instantly. Trade-off: larger backlog adds latency under sustained overload and holds more memory; it buys time for the drainer but never replaces actual filtering.
**`net.core.rmem_max` / `net.core.wmem_max`**
Ceiling for socket receive/send buffer sizes that applications can request via SO_RCVBUF/SO_SNDBUF (and for autotuning limits via the `tcp_rmem`/`tcp_wmem` third values). For high-throughput proxies moving bulk data, 16–128 MB ceilings are common; for a pure drop/throttle edge they matter little.
### Ephemeral ports and connection churn
**`net.ipv4.ip_local_port_range = 1024 65535`**
Range of source ports used for outgoing connections. When your node acts as a client — e.g., an edge proxy opening upstream connections toward backends — each concurrent outgoing connection consumes one tuple (src IP, src port, dst IP, dst port). The old default `32768 60999` gives ~28k ports; widening to the full range gives ~64k per destination tuple. Combined with:
**`net.ipv4.tcp_fin_timeout = 15`** (default 60)
Seconds a socket sits in FIN-WAIT-2 after the peer closed. Long waits tie up tuples and fd entries during churn-heavy attacks (connect-flood patterns). Lowering to 10–30 accelerates cleanup. Note: this does **not** affect TIME_WAIT duration (that's fixed at ~60s, controlled indirectly by `tcp_tw_reuse`).
**`net.ipv4.tcp_tw_reuse = 1`**
Allows reuse of TIME_WAIT sockets for *outgoing* connections when timestamps make it safe. Helps when port exhaustion, not memory, is the constraint.
### Conntrack
**`net.netfilter.nf_conntrack_max`**
Maximum number of tracked connections in the conntrack table. Only relevant if you use stateful filtering (nftables/iptables with `-m conntrack`, NAT). A connection flood can fill this table; once full, new legitimate connections get dropped too. Size it as: expected peak concurrent connections × safety factor (2–4). Each entry costs roughly 300 bytes, so 1M entries ≈ 300 MB.
**`net.netfilter.nf_conntrack_buckets`**
Hash table buckets (set at module load time, read-only afterwards). Rule of thumb: `conntrack_max / 4`. If you don't need conntrack at all on a dedicated edge box, disabling the modules entirely saves both memory and per-packet CPU.
## 3. Ready config
`/etc/sysctl.d/ddos.conf`:
```ini
# /etc/sysctl.d/ddos.conf
# Kernel hardening against volumetric L3/L4 attacks.
# Apply with: sudo sysctl --system
# --- SYN flood ---
# Enable SYN cookies when half-open queue overflows.
net.ipv4.tcp_syncookies = 1
# Deeper half-open queue per listening socket.
net.ipv4.tcp_max_syn_backlog = 65535
# Give up on dead handshakes fast.
net.ipv4.tcp_synack_retries = 2
net.ipv4.tcp_syn_retries = 2
# --- Accept queues ---
# Allow applications to request large listen backlogs.
net.core.somaxconn = 65535
# Do NOT reset clients on queue overflow (graceful retry).
net.ipv4.tcp_abort_on_overflow = 0
# --- Ingress buffering ---
# Absorb bursts between NIC IRQ and softirq processing.
net.core.netdev_max_backlog = 65535
# Socket buffer ceilings for bulk transfer.
net.core.rmem_max = 16777216
net.core.wmem_max = 16777216
net.ipv4.tcp_rmem = 4096 87380 16777216
net.ipv4.tcp_wmem = 4096 65536 16777216
# --- Outgoing connection churn (edge -> backend) ---
net.ipv4.ip_local_port_range = 1024 65535
net.ipv4.tcp_fin_timeout = 15
net.ipv4.tcp_tw_reuse = 1
# --- Conntrack (only if using stateful rules) ---
# 1M tracked connections, ~300MB RAM.
net.netfilter.nf_conntrack_max = 1048576
# Reduce timeout of half-open tracked connections.
net.netfilter.nf_conntrack_tcp_timeout_syn_recv = 30
# --- Optional: silence ICMP flood ---
# net.ipv4.icmp_echo_ignore_all = 1
```
Apply and verify:
```bash
sudo sysctl --system
sysctl net.ipv4.tcp_syncookies net.core.somaxconn net.core.netdev_max_backlog
# Confirm the app-side backlog matches:
ss -lnt # Recv-Q column shows current accept queue usage vs limit on LISTEN sockets
```
## 4. Trade-offs and warnings
1. **Bigger queues ≠ protection.** Deep backlogs buy seconds of absorption. Without early dropping (XDP/nftables) they just delay the collapse and add memory pressure. Tuning raises the ceiling; it does not remove the attack.
2. **Memory accounting.** `tcp_max_syn_backlog=65535` × many listening ports × request_sock size, plus conntrack at ~300 B/entry — do the arithmetic for your VDS RAM. On a 2 GB box, don't set conntrack_max to 4M.
3. **Latency under overload.** Large `netdev_max_backlog` means packets wait longer in softirq queues exactly when the system is already overloaded — tail latency grows even though drops shrink.
4. **Application cooperation.** `somaxconn` is only a cap. The effective backlog is whatever the application passes to `listen()`. Check with `ss -lnt`.
5. **Conntrack is optional.** A dedicated edge doing stateless XDP drops + userspace verification may not need conntrack at all; loading it just to have big tables wastes CPU per packet.
6. **Test changes under load**, not just `sysctl --system`. A parameter that looks harmless can shift behavior dramatically at 100k pps (see stress-testing.md).
---
---
## Русский
# Тюнинг ядра Linux против DDoS (sysctl)
> Knowledge Base · Практика
>
> Прежде чем заработает какая-либо умная защита, само ядро Linux решает, сколько полуоткрытых соединений оно потерпит, насколько глубоки его очереди и когда сдаваться. Неоптимальные дефолты превращают умеренный флуд в полный отказ обслуживания. В этой статье разобраны значимые параметры sysctl, объяснён каждый и приведён готовый конфиг для адаптации.
## 1. Как это устроено
Все перечисленные параметры живут в `/proc/sys/` и задаются либо на лету (`sysctl -w`, сбрасывается при перезагрузке), либо постоянно через файлы в `/etc/sysctl.d/*.conf` (применяются `systemd-sysctl` при загрузке или вручную командой `sysctl --system`). Файл `/proc/sys/net/ipv4/tcp_syncookies` соответствует ключу `net.ipv4.tcp_syncookies` и так далее.
## 2. Разбор параметров
### Защита от SYN flood
**`net.ipv4.tcp_syncookies = 1`**
Когда очередь SYN для слушающего сокета переполняется, ядро перестаёт хранить полуоткрытые соединения и вместо этого кодирует состояние соединения в криптографически подписанные cookie внутри номера последовательности SYN-ACK. Реальный клиент вернёт cookie в своём ACK; флудер обычно нет. Это важнейший штатный регулятор против SYN-флуда в Linux.
- `0` — выключено
- `1` — включено по необходимости
- `2` — всегда слать cookie независимо от состояния очередей (агрессивнее; используйте, только если понимаете последствия, поскольку часть согласования TCP-опций деградирует)
Компромисс: cookie не могут переносить TCP-опции, согласовываемые в handshake (window scaling в Linux сохраняется через расширение syncookie, но SACK/Timestamps могут потеряться), поэтому пропускная способность соединения может слегка упасть. Держите `1` — цена ничтожна в сравнении с гибелью от SYN-флуда на дефолтах.
**`net.ipv4.tcp_max_syn_backlog = 65535`**
Максимум полуоткрытых (SYN получен, ACK ещё нет) соединений в очереди **на каждый слушающий сокет**, прежде чем ядро начнёт дропать SYN или включать syncookies. По умолчанию часто 128–1024 в зависимости от версии ядра и `net.core.somaxconn`. Увеличение позволяет всплеску легитимных клиентов пройти без немедленного перехода в cookie-режим. Цена: каждая SYN в очереди потребляет немного памяти (структура request_sock) — растёт расход RAM, больше почти ничего.
**`net.ipv4.tcp_synack_retries = 2`** и **`net.ipv4.tcp_syn_retries = 2`**
Сколько раз ядро ретранслирует SYN-ACK (сервер) / SYN (клиент) без ответа, прежде чем сдаться. Меньшие значения быстрее вычищают мёртвые полуоткрытые соединения, освобождая слоты backlog'а во время флуда. Дефолтные 5–6 означают, что мёртвое соединение висит больше минуты; значение `2` сокращает это до секунд.
### Очереди прослушивания
**`net.core.somaxconn = 65535`**
Верхняя граница *accept*-очереди (полностью установленные соединения, ожидающие вызова `accept()` приложением) каждого слушающего сокета. Критично: приложение тоже должно запросить большой backlog (второй аргумент `listen()`); эффективное значение — `min(somaxconn, backlog_приложения)`. Если прокси принимает медленнее, чем клиенты подключаются, переполненная accept-очередь вызывает тихие дропы SYN или reset'ы — поднятие одного лишь `somaxconn` ничего не даст, если приложение запрашивает 128.
**`net.ipv4.tcp_abort_on_overflow = 0`**
Оставьте `0` (дефолт): при переполнении accept-очереди ядро молча дропает SYN, позволяя клиентам корректно повторить попытку. Значение `1` шлёт RST немедленно — полезно для fail-fast нагрузочных тестов, вредно в продакшене.
### Приём пакетов
**`net.core.netdev_max_backlog = 65535`**
Очередь кадров, принятых NIC, но ещё не обработанных циклом softirq (NET_RX) на CPU. Когда всплеск пакетов быстрее, чем CPU успевает разгребать, эта очередь поглощает разницу; переполнение означает дроп пакетов *до* того, как их увидит хоть какая-то логика фильтрации. Дефолт часто 1000 — всплеск в миллионы pps исчерпывает его мгновенно. Компромисс: увеличенный backlog добавляет задержку при устойчивой перегрузке и держит больше памяти; он покупает время для разгребающего, но никогда не заменяет саму фильтрацию.
**`net.core.rmem_max` / `net.core.wmem_max`**
Потолок размеров буферов приёма/отправки сокетов, которые приложения могут запросить через SO_RCVBUF/SO_SNDBUF (и лимиты автотюнинга через третьи значения `tcp_rmem`/`tcp_wmem`). Для высокопроизводительных прокси, гоняющих объёмные данные, потолки 16–128 МБ обычны; для чистого drop/throttle-edge они мало что меняют.
### Эфемерные порты и churn соединений
**`net.ipv4.ip_local_port_range = 1024 65535`**
Диапазон исходящих портов для исходящих соединений. Когда нода выступает клиентом — например, edge-прокси открывает апстрим-соединения к бэкендам, — каждое одновременное исходящее соединение занимает один кортеж (src IP, src порт, dst IP, dst порт). Старый дефолт `32768 60999` даёт ~28 тыс. портов; расширение до полного диапазона — ~64 тыс. на кортеж назначения. В паре с:
**`net.ipv4.tcp_fin_timeout = 15`** (дефолт 60)
Секунды, которые сокет висит в FIN-WAIT-2 после закрытия пиром. Долгое ожидание связывает кортежи и fd во время churn-атак (паттерны connect-flood). Снижение до 10–30 ускоряет очистку. Заметьте: это **не влияет** на длительность TIME_WAIT (она фиксирована ~60 с, косвенно управляется через `tcp_tw_reuse`).
**`net.ipv4.tcp_tw_reuse = 1`**
Разрешает переиспользование сокетов в TIME_WAIT для *исходящих* соединений, когда timestamps делают это безопасным. Помогает, когда ограничением является нехватка портов, а не памяти.
### Conntrack
**`net.netfilter.nf_conntrack_max`**
Максимальное число отслеживаемых соединений в таблице conntrack. Актуально только при stateful-фильтрации (nftables/iptables с `-m conntrack`, NAT). Флуд соединений может заполнить таблицу; после этого дропаются уже и новые легитимные соединения. Размер: ожидаемый пик одновременных соединений × коэффициент запаса (2–4). Каждая запись стоит примерно 300 байт, значит 1 млн записей ≈ 300 МБ.
**`net.netfilter.nf_conntrack_buckets`**
Число корзин хеш-таблицы (задаётся при загрузке модуля, потом read-only). Правило большого пальца: `conntrack_max / 4`. Если conntrack на выделенном edge вообще не нужен — полное отключение модулей экономит и память, и CPU на каждом пакете.
## 3. Готовый конфиг
`/etc/sysctl.d/ddos.conf`:
```ini
# /etc/sysctl.d/ddos.conf
# Устойчивость ядра к объёмным атакам L3/L4.
# Применить: sudo sysctl --system
# --- SYN flood ---
# SYN cookies при переполнении очереди полуоткрытых.
net.ipv4.tcp_syncookies = 1
# Более глубокая очередь полуоткрытых на каждый слушающий сокет.
net.ipv4.tcp_max_syn_backlog = 65535
# Быстро отказываться от мёртвых handshake.
net.ipv4.tcp_synack_retries = 2
net.ipv4.tcp_syn_retries = 2
# --- Очереди accept ---
# Разрешить приложениям большие listen-backlog.
net.core.somaxconn = 65535
# НЕ сбрасывать клиентов при переполнении очереди (мягкий retry).
net.ipv4.tcp_abort_on_overflow = 0
# --- Буферизация входа ---
# Поглощать всплески между IRQ сетевой карты и обработкой softirq.
net.core.netdev_max_backlog = 65535
# Потолки буферов сокетов для объёмной передачи.
net.core.rmem_max = 16777216
net.core.wmem_max = 16777216
net.ipv4.tcp_rmem = 4096 87380 16777216
net.ipv4.tcp_wmem = 4096 65536 16777216
# --- Churn исходящих соединений (edge → бэкенд) ---
net.ipv4.ip_local_port_range = 1024 65535
net.ipv4.tcp_fin_timeout = 15
net.ipv4.tcp_tw_reuse = 1
# --- Conntrack (только при stateful-правилах) ---
# 1 млн отслеживаемых соединений, ~300 МБ RAM.
net.netfilter.nf_conntrack_max = 1048576
# Сократить таймаут полуоткрытых записей.
net.netfilter.nf_conntrack_tcp_timeout_syn_recv = 30
# --- Опционально: заглушить ICMP flood ---
# net.ipv4.icmp_echo_ignore_all = 1
```
Применить и проверить:
```bash
sudo sysctl --system
sysctl net.ipv4.tcp_syncookies net.core.somaxconn net.core.netdev_max_backlog
# Убедиться, что app-side backlog совпадает:
ss -lnt # колонка Recv-Q показывает текущее использование accept-очереди против лимита на LISTEN-сокетах
```
## 4. Компромиссы и предупреждения
1. **Большие очереди ≠ защита.** Глубокие backlog'и покупают секунды поглощения. Без раннего отсечения (XDP/nftables) они лишь откладывают коллапс и давят на память. Тюнинг поднимает потолок, но не устраняет атаку.
2. **Учёт памяти.** `tcp_max_syn_backlog=65535` × число слушающих портов × размер request_sock, плюс conntrack по ~300 Б/запись — посчитайте под свою VDS. На машине со 2 ГБ не ставьте conntrack_max в 4M.
3. **Задержка при перегрузке.** Большой `netdev_max_backlog` означает, что пакеты ждут дольше в softirq-очередях именно тогда, когда система уже перегружена — хвостовая задержка растёт, даже хотя дропов меньше.
4. **Кооперация приложения.** `somaxconn` — только потолок. Эффективный backlog — то, что приложение передаёт в `listen()`. Проверяйте через `ss -lnt`.
5. **Conntrack опционален.** Выделенный edge с stateless XDP-дропами и юзерспейс-верификацией может вообще обходиться без conntrack; грузить его ради больших таблиц — тратить CPU на каждом пакете.
6. **Тестируйте изменения под нагрузкой**, а не только `sysctl --system`. Параметр, выглядящий безобидно, может радикально поменять поведение на 100 тыс. pps (см. stress-testing.md).

View file

@ -0,0 +1,333 @@
# NIC Tuning: Queues, IRQ Affinity, Ring Buffers, Offloads
> Knowledge Base · Practice
>
> The network interface card is the first CPU consumer on the packet's path. A single-queue NIC feeding one core caps your entire defense at whatever that core can process; misconfigured offloads silently corrupt or slow traffic. This article covers multiqueue setup, receive-side scaling, interrupt distribution, ring buffers, and offload switches — with commands you can run to verify each step.
## English
## 1. Multiqueue NICs (ethtool -L)
Modern NICs expose multiple hardware RX/TX queues, each with its own interrupt. The kernel spreads packets across queues using a hash of header fields (RSS). If only 1 combined channel is active, all packets — including millions of flood packets — hit a single core.
```bash
# Show current queue count and max supported
ethtool -l eth0
# Example output:
# Channel parameters for eth0:
# Pre-set maximums:
# RX: 8
# TX: 8
# Other: 1
# Combined: 8
# Current hardware settings:
# RX: 1
# TX: 1
# Other: 1
# Combined: 1 <-- everything lands on one queue!
# Raise combined channels (RX+TX share) to maximum
sudo ethtool -L eth0 combined 8
# Or set RX/TX separately if the card distinguishes them
sudo ethtool -L eth0 rx 4 tx 4
```
On virtual machines (virtio), the number of queues is bounded by `queues` in the VM configuration and vCPUs available; match queues to vCPU count.
## 2. RSS / RPS / XPS
Three mechanisms with confusingly similar names:
| Mechanism | Layer | What it does | Configured via |
|---|---|---|---|
| **RSS** (Receive Side Scaling) | Hardware | NIC hashes packet headers → picks RX queue → raises that queue's IRQ | `ethtool -x eth0` (view), `ethtool -X eth0` (set indirection table) |
| **RPS** (Receive Packet Steering) | Software kernel | Same idea as RSS but done in software after the driver receives the packet — for NICs/queues without RSS or to spread further | sysfs per RX queue |
| **XPS** (Transmit Packet Steering) | Software kernel | Picks TX queue matching the transmitting CPU, keeping flow locality | sysfs per TX queue |
```bash
# View RSS indirection table and hash key
ethtool -x eth0
# Steer queues across CPUs evenly (example for 4 queues)
sudo ethtool -X eth0 equal 4
# Enable RPS on the first RX queue: point it at CPUs 2-3 (mask 0xc = bits 2,3)
echo c | sudo tee /sys/class/net/eth0/queues/rx-0/rps_cpus
# Check whether RPS is actually spreading (counters per CPU)
cat /proc/net/softnet_stat # column 2 = dropped at softirq, column 10+ depend on kernel version
# XPS: steer tx-0 to CPU 0 (mask 0x1)
echo 1 | sudo tee /sys/class/net/eth0/queues/tx-0/xps_cpus
```
Rule of thumb: prefer RSS when the hardware supports it; add RPS only where hardware queues < cores; XPS matters mainly for high-throughput *outgoing* paths.
## 3. IRQ affinity
Each RX queue generates interrupts. By default, the kernel may route them all to CPU0 — creating a hotspot exactly where your XDP program will also run.
```bash
# See how interrupts are currently distributed
grep eth0 /proc/interrupts
# List IRQ numbers of the NIC queues
grep -E 'eth0.*TxRx' /proc/interrupts | awk '{print $1}' | tr -d ':'
# Pin IRQ 41 to CPU 2 (mask is a hex bitmask)
echo 2 | sudo tee /proc/irq/41/smp_affinity
# Pin IRQ 42 to CPU 3, etc.
echo 4 | sudo tee /proc/irq/42/smp_affinity
```
Notes:
- `smp_affinity` is a hexadecimal bitmask: `1`=CPU0, `2`=CPU1, `4`=CPU2, `c`=CPU2+CPU3, `ff`=first 8 CPUs.
- On systems with irqbalance daemon running, it may override manual pinning — stop/mask it (`systemctl stop irqbalance`) or configure exclusions.
- For NAPI-driven high pps, interrupts coalesce into softirq processing; check `mpstat -P ALL 1` — `%soft` shows which CPUs are doing network work.
- Common strategy for an edge node: dedicate some cores to network softirq/XDP and others to userspace application threads, so a flood saturating softirq does not starve the proxy logic.
## 4. Ring buffer size
The RX ring is on-NIC memory where received descriptors wait for the driver. Too small → drops under bursts; too large → latency and memory waste.
```bash
# View current and maximum ring sizes
ethtool -g eth0
# Example output:
# Ring parameters for eth0:
# Pre-set maximums:
# RX: 4096
# TX: 4096
# Current hardware settings:
# RX: 256 <-- small, drops under burst
# TX: 256
# Set RX ring to max
sudo ethtool -G eth0 rx 4096
```
Verify drops directly:
```bash
ip -s link show eth0 # look at "dropped" counter
ethtool -S eth0 | grep -i drop # per-hardware-counter view
netstat -i # alternative overview
```
## 5. Offloads: GRO / TSO / LRO / checksumming
Offloads let the NIC or driver merge/split segments and compute checksums, cutting CPU per byte for bulk traffic:
- **GRO** (Generic Receive Offload): merges received segments into large skbs in software.
- **LRO** (Large Receive Offload): same in hardware — breaks forwarding/routing because merged skbs lose MAC/IP details; never enable on routers/proxies.
- **TSO** (TCP Segmentation Offload): NIC splits large outgoing buffers into MSS-sized segments.
- **RX/TX checksum offload**: NIC validates/computes checksums.
```bash
# View all offloads
ethtool -k eth0
# Toggle examples
sudo ethtool -K eth0 gro on
sudo ethtool -K eth0 tso on
sudo ethtool -K eth0 lro off # usually already off; keep it off on proxies
```
**When to turn GRO/TSO OFF:** when running XDP programs that must inspect individual packets, aggressive GRO merging changes what your eBPF sees (merged super-packets on the generic XDP path). Also consider disabling during packet-rate benchmarking, since merging hides the true pps cost. Keep them ON for normal bulk throughput workloads — they save substantial CPU.
## 6. Verifying load distribution
```bash
# Per-CPU softirq utilization, refresh every second
mpstat -P ALL 1
# Interrupt counters per CPU (watch eth0 lines move)
watch -n1 'grep eth0 /proc/interrupts'
# Softirq backlog / drops per CPU
cat /proc/net/softnet_stat
# columns: processed | dropped | time_squeeze ...
# Kernel-side packet drops summary
ip -s link
dropwatch -l 1 # if installed: live trace of where kernel drops packets
```
Healthy picture on a tuned edge: `%soft` spread over several cores rather than 100% on one; `dropped` in `/proc/net/softnet_stat` stays near zero except during deliberate overload tests; NIC-level `dropped` grows only when the attack exceeds what early filtering absorbs.
## 7. Persistence
All `ethtool` and sysfs settings are volatile. Persist them via a systemd unit, a network dispatcher script (`/etc/network/if-up.d/`), NetworkManager dispatcher, or netplan `set-link` options depending on your distro.
---
---
## Русский
# Тюнинг NIC: очереди, привязка IRQ, кольцевые буферы, offload'ы
> Knowledge Base · Практика
>
> Сетевая карта — первый потребитель CPU на пути пакета. Одноочередевой NIC, кормящий одно ядро, ограничивает всю вашу защиту тем, что успеет это ядро; неверно настроенные offload'ы молча портят или замедляют трафик. Статья охватывает настройку multiqueue, масштабирование приёма, распределение прерываний, кольцевые буферы и переключатели offload — с командами для проверки каждого шага.
## 1. Multiqueue NIC (ethtool -L)
Современные NIC предоставляют несколько аппаратных очередей RX/TX, у каждой своё прерывание. Ядро распределяет пакеты по очередям через хеш полей заголовков (RSS). Если активен только 1 combined-канал, все пакеты — включая миллионы пакетов флуда — попадают на одно ядро.
```bash
# Показать текущее число очередей и максимум
ethtool -l eth0
# Пример вывода:
# Channel parameters for eth0:
# Pre-set maximums:
# RX: 8
# TX: 8
# Other: 1
# Combined: 8
# Current hardware settings:
# RX: 1
# TX: 1
# Other: 1
# Combined: 1 <-- всё валится в одну очередь!
# Поднять combined-каналы (общие RX+TX) до максимума
sudo ethtool -L eth0 combined 8
# Или задать RX/TX по отдельности, если карта их различает
sudo ethtool -L eth0 rx 4 tx 4
```
На виртуальных машинах (virtio) число очередей ограничено параметром `queues` в конфигурации ВМ и числом vCPU; согласуйте количество очередей с числом vCPU.
## 2. RSS / RPS / XPS
Три механизма с путающими названиями:
| Механизм | Уровень | Что делает | Настраивается через |
|---|---|---|---|
| **RSS** (Receive Side Scaling) | Железо | NIC хеширует заголовки пакета → выбирает RX-очередь → поднимает её IRQ | `ethtool -x eth0` (просмотр), `ethtool -X eth0` (таблица indirection) |
| **RPS** (Receive Packet Steering) | Софт ядра | Та же идея, что RSS, но программно после приёма драйвером — для карт/очередей без RSS или для дальнейшего распределения | sysfs на каждую RX-очередь |
| **XPS** (Transmit Packet Steering) | Софт ядра | Выбирает TX-очередь, соответствующую передающему CPU, сохраняя локальность потока | sysfs на каждую TX-очередь |
```bash
# Посмотреть таблицу indirection и хеш-ключ RSS
ethtool -x eth0
# Равномерно раскидать очереди по CPU (пример для 4 очередей)
sudo ethtool -X eth0 equal 4
# Включить RPS на первой RX-очереди: направить на CPU 2-3 (маска 0xc = биты 2,3)
echo c | sudo tee /sys/class/net/eth0/queues/rx-0/rps_cpus
# Проверить, реально ли RPS распределяет (счётчики по CPU)
cat /proc/net/softnet_stat # колонка 2 = дропы в softirq
# XPS: направить tx-0 на CPU 0 (маска 0x1)
echo 1 | sudo tee /sys/class/net/eth0/queues/tx-0/xps_cpus
```
Правило большого пальца: предпочитайте RSS, если железо умеет; добавляйте RPS там, где аппаратных очередей меньше числа ядер; XPS важен прежде всего для высокопроизводительных *исходящих* путей.
## 3. Привязка IRQ (IRQ affinity)
Каждая RX-очередь генерирует прерывания. По умолчанию ядро может направить их все на CPU0 — создавая горячую точку ровно там, где будет работать и ваша XDP-программа.
```bash
# Как сейчас распределены прерывания
grep eth0 /proc/interrupts
# Номера IRQ очередей NIC
grep -E 'eth0.*TxRx' /proc/interrupts | awk '{print $1}' | tr -d ':'
# Привязать IRQ 41 к CPU 2 (маска — hex-битовая маска)
echo 2 | sudo tee /proc/irq/41/smp_affinity
# Привязать IRQ 42 к CPU 3 и т.д.
echo 4 | sudo tee /proc/irq/42/smp_affinity
```
Замечания:
- `smp_affinity` — шестнадцатеричная битовая маска: `1`=CPU0, `2`=CPU1, `4`=CPU2, `c`=CPU2+CPU3, `ff`=первые 8 CPU.
- Если работает демон irqbalance, он может перезаписать ручную привязку — остановите/замаскируйте его (`systemctl stop irqbalance`) или настройте исключения.
- При высоких pps обработка через NAPI сворачивает прерывания в обработку softirq; проверяйте `mpstat -P ALL 1` — колонка `%soft` показывает, какие CPU занимаются сетью.
- Частая стратегия для edge-ноды: выделить часть ядер под сетевой softirq/XDP, остальные — под потоки userspace-приложения, чтобы флуд, забивающий softirq, не душил логику прокси.
## 4. Размер кольцевого буфера
RX-ring — память на NIC, где принятые дескрипторы ждут драйвера. Слишком мал → дропы при всплесках; слишком велик → задержки и лишний расход памяти.
```bash
# Текущие и максимальные размеры ring
ethtool -g eth0
# Пример вывода:
# Ring parameters for eth0:
# Pre-set maximums:
# RX: 4096
# TX: 4096
# Current hardware settings:
# RX: 256 <-- мало, дропы при всплесках
# TX: 256
# Поставить RX ring на максимум
sudo ethtool -G eth0 rx 4096
```
Дропы проверяются напрямую:
```bash
ip -s link show eth0 # смотреть счётчик "dropped"
ethtool -S eth0 | grep -i drop # вид по аппаратным счётчикам
netstat -i # альтернативный обзор
```
## 5. Offload'ы: GRO / TSO / LRO / контрольные суммы
Offload'ы позволяют NIC или драйверу склеивать/делить сегменты и считать чексуммы, снижая расход CPU на байт для объёмного трафика:
- **GRO** (Generic Receive Offload): склеивает принятые сегменты в крупные skb программно.
- **LRO** (Large Receive Offload): то же аппаратно — ломает маршрутизацию/форвардинг, потому что склеенные skb теряют детали MAC/IP; никогда не включать на роутерах/прокси.
- **TSO** (TCP Segmentation Offload): NIC сам делит большие исходящие буферы на сегменты размера MSS.
- **RX/TX checksum offload**: NIC проверяет/вычисляет чексуммы.
```bash
# Посмотреть все offload'ы
ethtool -k eth0
# Примеры переключения
sudo ethtool -K eth0 gro on
sudo ethtool -K eth0 tso on
sudo ethtool -K eth0 lro off # обычно уже выключен; на прокси держать выключенным
```
**Когда выключать GRO/TSO:** когда запущены XDP-программы, обязанные видеть отдельные пакеты, агрессивное склеивание GRO меняет то, что видит ваш eBPF (склеенные супер-пакеты на generic-пути XDP). Также подумайте об отключении при бенчмарках частоты пакетов: склейка прячет настоящую стоимость pps. Для обычных объёмных нагрузок держите их включёнными — они заметно экономят CPU.
## 6. Проверка распределения нагрузки
```bash
# Загрузка softirq по CPU, обновление раз в секунду
mpstat -P ALL 1
# Счётчики прерываний по CPU (следим, как двигаются строки eth0)
watch -n1 'grep eth0 /proc/interrupts'
# Очередь softirq / дропы по CPU
cat /proc/net/softnet_stat
# Сводка дропов на стороне ядра
ip -s link
dropwatch -l 1 # если установлен: live-трейс мест дропа в ядре
```
Здоровая картина на настроенном edge: `%soft` распределён по нескольким ядрам, а не 100% на одном; `dropped` в `/proc/net/softnet_stat` держится около нуля вне специальных тестов перегрузки; аппаратный `dropped` растёт только когда атака превышает то, что поглощает раннее отсечение.
## 7. Сохранение настроек
Все настройки `ethtool` и sysfs летучи. Сохраняйте их через systemd unit, скрипт сетевого dispatcher'а (`/etc/network/if-up.d/`), dispatcher NetworkManager или опции `set-link` в netplan — в зависимости от дистрибутива.

View file

@ -0,0 +1,354 @@
# Stress Testing Your Own Infrastructure
> Knowledge Base · Practice
>
> A defense system that has never been attacked is a hypothesis, not a defense. This article covers the standard load-generation tools (hping3, iperf3, wrk/k6), a safe testing methodology, and the metrics that actually tell you whether your protection works. It ends with a real case study from this project.
## English
## 0. Ethics and legality — read first
- **Test only infrastructure you own or have explicit written permission to test.** Launching flood traffic against third-party systems is a crime in most jurisdictions (unauthorized impairment of computer systems), regardless of intent or duration.
- **Never test from cloud providers against targets outside their network without authorization** — most providers prohibit it in ToS and will terminate your account.
- **Use staging environments and loopback/bridge networks.** Everything in this article can be done on a single machine or a private two-node lab.
- Coordinate timing with your team: an unplanned "test" against production is indistinguishable from an outage caused by an attack.
- If you want to study attack traffic safely, generate it yourself against your own stub services — which is exactly what the tools below do.
## 1. The tools and what each one measures
| Tool | Attack/load type | Layer | What you learn |
|---|---|---|---|
| **hping3** | SYN flood, UDP flood, ICMP flood, flag-abuse | L3/L4 | How kernel + early filters behave under packet floods |
| **iperf3** | Bulk TCP/UDP throughput | L4 | Bandwidth ceiling of NIC/tunnel/kernel path |
| **wrk** | HTTP keep-alive requests | L7 | Request rate and latency of an HTTP endpoint under sustained load |
| **k6** | Scripted HTTP/API scenarios (JS) | L7 | Realistic user flows, thresholds, gradual ramp-up |
| **tcpkali** | Raw TCP connections/sec | L4/L7 | Connection-rate limits of a custom TCP service |
## 2. hping3 — L3/L4 floods
```bash
# Install
sudo apt install hping3
# SYN flood at full speed toward YOUR OWN server
# (run from a second machine or VM, not the target itself)
sudo hping3 -S --flood -p 443 TARGET_IP
# SYN flood with randomized source addresses — exercises
# anti-spoofing, syncookies, per-prefix throttling
sudo hping3 -S --flood -p 443 --rand-source TARGET_IP
# UDP flood (bandwidth-style)
sudo hping3 --udp --flood -p 53 TARGET_IP
# Invalid flag combination (SYN+FIN) — should be dropped by any sane filter
sudo hping3 -S -F -p 443 TARGET_IP
# Slower, controlled rate instead of --flood (packets per second)
sudo hping3 -S -p 443 --interval u1000 TARGET_IP # ~1000 pps
```
Observe the effect while it runs:
```bash
watch -n1 'ss -s' # socket summary: timewait/synrecv counts
watch -n1 'cat /proc/net/netstat | grep -A1 TcpExt:' # SYN cookies sent, etc.
nstat -az | grep -i syn # SynCookiesSent, ListenDrops counters
ip -s link # interface-level drops
```
`netstat -s` / `nstat` output worth knowing:
- `SYNs to LISTEN sockets dropped` — accept/SYN backlog overflowed
- `SYN cookies sent` — syncookie mode engaged (see kernel-tuning.md)
## 3. iperf3 — bandwidth
```bash
# Server side (target machine)
iperf3 -s
# Client side: TCP upload for 30 seconds
iperf3 -c TARGET_IP -t 30
# Parallel streams to saturate path
iperf3 -c TARGET_IP -t 30 -P 8
# UDP at fixed rate (e.g., 500 Mbit) with loss/jitter report
iperf3 -c TARGET_IP -u -b 500M -t 30
# Reverse direction (server sends to client)
iperf3 -c TARGET_IP -t 30 -R
```
What to look for: achieved throughput vs link capacity, UDP `lost/datagrams` ratio (packet loss under load), retransmits on TCP runs (`sender retransmits`). If a "protected" path shows far lower throughput than the bare one, your filter may be over-dropping legitimate bulk traffic.
## 4. wrk / k6 — L7 load
```bash
# wrk: 200 connections, 8 threads, 30 seconds against your own endpoint
wrk -t8 -c200 -d30s http://TARGET_IP/
# With a latency distribution summary
wrk -t8 -c200 -d30s --latency http://TARGET_IP/
# Custom request via Lua script file (POST example): wrk -s script.lua ...
```
```bash
# k6: scripted scenario with ramping virtual users
k6 run --vus 50 --duration 60s script.js
```
Minimal k6 script (`script.js`):
```javascript
import http from 'k6/http';
import { check } from 'k6';
export const options = {
stages: [
{ duration: '30s', target: 100 }, // ramp to 100 VUs
{ duration: '60s', target: 100 },
{ duration: '30s', target: 0 }, // ramp down
],
};
export default function () {
const res = http.get('http://TARGET_IP/');
check(res, { 'status is 200': (r) => r.status === 200 });
}
```
L7 generators answer a different question than hping3: not "does the pipe survive" but "does the application logic stay correct and fast when many clients hammer valid-looking requests".
## 5. Methodology
### Environment rules
1. **Staging or isolated lab first.** Two VMs/VDS on the same internal network, or Docker bridge networks — never production.
2. **Load source separate from target.** Generating flood on the same box as the defense skews every number (shared CPU, shared softirq).
3. **Baseline before defense**: measure how the unprotected service dies. Only then can you prove the defense changed anything.
4. **One variable at a time**, minimum 3 runs, take the median; warm up ~10 s before counting.
5. **Record the environment**: kernel version, CPU model/count, RAM, NIC type (virtio vs physical), queue settings from nic-tuning.md.
### Metrics that matter
| Metric | Command / source | Healthy signal |
|---|---|---|
| Packet rate (pps) | `sar -n DEV 1`, `ethtool -S` | Matches generator's intended rate |
| Bitrate | `sar -n DEV 1`, `iftop` | Within link budget |
| CPU softirq % | `mpstat -P ALL 1` (`%soft`) | Spread across cores, not pegged on one |
| Interface drops | `ip -s link` | Grows during attack *only* if that's your drop policy |
| Socket states | `ss -s` | No runaway TIME_WAIT/SYN-RECV accumulation |
| Kernel drop reasons | `nstat -az`, `dropwatch` | Drops attributable to intended filters |
| App-level allowed/blocked | Your service metrics (e.g., Prometheus `/metrics`) | Block ratio rises under attack; legit clients still pass |
| Legit-client latency | Separate probe client measuring RTT | Stays within SLO during the attack |
The last two rows are the crucial ones: a defense that blocks everything including real users has failed.
### Interpreting results
- **Blocked ratio high + legit RTT stable** → defense works.
- **Everything blocked** → filter too aggressive (check thresholds).
- **Nothing blocked, CPU melts** → attack traffic bypasses your layers; check whether it reaches userspace at all (`mpstat`, XDP drop counters).
- **Generator saturates first** → your numbers measure the generator, not the target. Scale out sources or lower rates.
## 6. Case study: edge-only loopback test on a small VDS (2026-08-04)
Full report: `docs/research/load-test-report.md`; scripts: `deploy/test/stress/`.
Setup: single 2 vCPU / 3.8 GB VDS, Ubuntu 22.04, Docker bridge `172.30.0.0/24`. One container ran the Rampart edge process plus a stub echo backend; another container acted as attacker holding 100 additional source IPs (`172.30.0.101–200`) and generating handshake-shaped L7 floods indistinguishable from real clients at the protocol level. XDP was disabled and PoW disabled — deliberately, so the test measured pure L7 rate limiting + reputation.
Phases:
- **Phase A (limits off)**: raw throughput measurement — ~121.5k handshakes in 30 s (~4.0k conn/s), all proxied; edge CPU peaked ~179% (both cores). Without limits, the same flood also throttled legitimate probe clients (2 of 5 succeeded).
- **Phase B (default per-IP limit 5 pps/IP, burst 10, reputation ban)**: identical flood — **119,376 blocked vs 528 allowed ≈ 99.6% blocked** at max ~32% CPU. All 5 legitimate probe clients passed during the attack with RTT 2.2–5.8 ms; abusive IPs got blacklisted after repeated violations.
- **Phase C (hping3 SYN flood, `--rand-source`)**: no impact on the L7 edge — without XDP, raw SYN handling belongs entirely to the kernel (syncookies/backlog). Confirms the layer separation discussed in defense-levels.md.
- **Phase D (300 kept-open connections)**: all proxied, negligible CPU — steady-state connection holding was limited by the backend stub, not the edge.
Lessons transferable to any project:
1. Always run both phases: **defense off** (find raw ceiling) and **defense on** (prove block ratio + legit availability). Phase B alone would hide the fact that unlimited mode harms real clients.
2. Masked L7 floods (valid protocol payloads from many IPs) are the honest adversary; simple SYN tests only validate the kernel layer.
3. A tiny 2-vCPU box handled the attack at 32% CPU once per-IP limiting engaged — good early-layer filtering buys enormous headroom.
4. Keep a legit probe client running throughout every phase; it is your false-positive alarm.
---
---
## Русский
# Стресс-тестирование собственной инфраструктуры
> Knowledge Base · Практика
>
> Система защиты, которую никогда не атаковали, — это гипотеза, а не защита. Статья охватывает стандартные инструменты генерации нагрузки (hping3, iperf3, wrk/k6), безопасную методику тестирования и метрики, которые реально показывают, работает ли защита. В конце — реальный кейс этого проекта.
## 0. Этика и законность — прочтите сначала
- **Тестируйте только инфраструктуру, которой владеете или на тестирование которой есть явное письменное разрешение.** Запуск флуд-трафика в чужие системы — уголовное преступление в большинстве юрисдикций (несанкционированное нарушение работы компьютерных систем), независимо от намерений и длительности.
- **Никогда не тестируйте из облачных провайдеров против целей вне их сети без разрешения** — большинство провайдеров запрещают это в ToS и блокируют аккаунт.
- **Используйте staging и loopback/bridge-сети.** Всё описанное ниже выполняется на одной машине или в приватной лаборатории из двух узлов.
- Согласуйте время с командой: незапланированный «тест» продакшена неотличим от аварии, вызванной атакой.
- Если хотите безопасно изучать атакующий трафик — генерируйте его сами против собственных заглушек; ровно это и делают инструменты ниже.
## 1. Инструменты и что каждый измеряет
| Инструмент | Тип атаки/нагрузки | Уровень | Что вы узнаёте |
|---|---|---|---|
| **hping3** | SYN flood, UDP flood, ICMP flood, злоупотребление флагами | L3/L4 | Как ведут себя ядро и ранние фильтры под пакетным флудом |
| **iperf3** | Объёмная пропускная способность TCP/UDP | L4 | Потолок пропускной способности пути NIC/туннель/ядро |
| **wrk** | HTTP-запросы поверх keep-alive | L7 | Частота запросов и задержки HTTP-эндпоинта под устойчивой нагрузкой |
| **k6** | Скриптовые HTTP/API-сценарии (JS) | L7 | Реалистичные пользовательские сценарии, пороги, плавный разгон |
| **tcpkali** | Сырые TCP-соединения/сек | L4/L7 | Предел частоты соединений кастомного TCP-сервиса |
## 2. hping3 — флуды L3/L4
```bash
# Установка
sudo apt install hping3
# SYN flood на полной скорости в СВОЙ сервер
# (запускать со второй машины или ВМ, не с самой цели)
sudo hping3 -S --flood -p 443 TARGET_IP
# SYN flood со случайными адресами источника — проверяет
# анти-спуфинг, syncookies, троттлинг по префиксам
sudo hping3 -S --flood -p 443 --rand-source TARGET_IP
# UDP flood (полосно-затратный)
sudo hping3 --udp --flood -p 53 TARGET_IP
# Неверная комбинация флагов (SYN+FIN) — любой адекватный фильтр должен дропнуть
sudo hping3 -S -F -p 443 TARGET_IP
# Медленнее, управляемая скорость вместо --flood (пакетов в секунду)
sudo hping3 -S -p 443 --interval u1000 TARGET_IP # ~1000 pps
```
Наблюдайте за эффектом во время запуска:
```bash
watch -n1 'ss -s' # сводка сокетов: счётчики timewait/synrecv
watch -n1 'cat /proc/net/netstat | grep -A1 TcpExt:' # отправленные SYN cookies и пр.
nstat -az | grep -i syn # счётчики SynCookiesSent, ListenDrops
ip -s link # дропы на уровне интерфейса
```
Полезные строки вывода `netstat -s` / `nstat`:
- `SYNs to LISTEN sockets dropped` — переполнение accept/SYN backlog'а
- `SYN cookies sent` — включился режим syncookies (см. kernel-tuning.md)
## 3. iperf3 — полоса пропускания
```bash
# Серверная сторона (целевая машина)
iperf3 -s
# Клиентская сторона: TCP-загрузка 30 секунд
iperf3 -c TARGET_IP -t 30
# Параллельные потоки для насыщения канала
iperf3 -c TARGET_IP -t 30 -P 8
# UDP на фиксированной скорости (например, 500 Мбит) с отчётом о потерях/джиттере
iperf3 -c TARGET_IP -u -b 500M -t 30
# Обратное направление (сервер шлёт клиенту)
iperf3 -c TARGET_IP -t 30 -R
```
На что смотреть: достигнутая полоса против ёмкости линка, отношение `lost/datagrams` по UDP (потери под нагрузкой), ретрансмиты в TCP-прогонах (`sender retransmits`). Если «защищённый» путь показывает заметно меньшую полосу, чем голый, ваш фильтр может перерезать и легитимный объёмный трафик.
## 4. wrk / k6 — нагрузка L7
```bash
# wrk: 200 соединений, 8 потоков, 30 секунд против своего эндпоинта
wrk -t8 -c200 -d30s http://TARGET_IP/
# С разбивкой задержек
wrk -t8 -c200 -d30s --latency http://TARGET_IP/
# Кастомный запрос через Lua-скрипт (пример POST): wrk -s script.lua ...
```
```bash
# k6: скриптовый сценарий с плавным числом виртуальных пользователей
k6 run --vus 50 --duration 60s script.js
```
Минимальный скрипт k6 (`script.js`):
```javascript
import http from 'k6/http';
import { check } from 'k6';
export const options = {
stages: [
{ duration: '30s', target: 100 }, // разгон до 100 VU
{ duration: '60s', target: 100 },
{ duration: '30s', target: 0 }, // спуск
],
};
export default function () {
const res = http.get('http://TARGET_IP/');
check(res, { 'status is 200': (r) => r.status === 200 });
}
```
L7-генераторы отвечают на другой вопрос, нежели hping3: не «выживет ли труба», а «остаётся ли логика приложения корректной и быстрой, когда множество клиентов долбят похожими на настоящие запросами».
## 5. Методика
### Правила окружения
1. **Сначала staging или изолированная лаборатория.** Две ВМ/VDS в одной внутренней сети или docker bridge-сети — никогда продакшен.
2. **Источник нагрузки отдельно от цели.** Генерация флуда на той же машине, где защита, искажает все цифры (общий CPU, общий softirq).
3. **Базлайн до защиты**: замерьте, как умирает незащищённый сервис. Только так можно доказать, что защита что-то изменила.
4. **По одной переменной за раз**, минимум 3 прогона, берём медиану; прогрев ~10 с до начала подсчёта.
5. **Фиксируйте окружение**: версия ядра, модель/число CPU, RAM, тип NIC (virtio против физического), настройки очередей из nic-tuning.md.
### Метрики, которые имеют значение
| Метрика | Команда / источник | Здоровый сигнал |
|---|---|---|
| Частота пакетов (pps) | `sar -n DEV 1`, `ethtool -S` | Совпадает с задуманной скоростью генератора |
| Битрейт | `sar -n DEV 1`, `iftop` | В пределах бюджета линка |
| CPU softirq % | `mpstat -P ALL 1` (`%soft`) | Распределено по ядрам, не упирается в одно |
| Дропы интерфейса | `ip -s link` | Растут при атаке *только* если это ваша политика дропа |
| Состояния сокетов | `ss -s` | Нет неконтролируемого накопления TIME_WAIT/SYN-RECV |
| Причины дропов в ядре | `nstat -az`, `dropwatch` | Дропы объясняются задуманными фильтрами |
| Allowed/blocked на уровне приложения | Метрики сервиса (например, Prometheus `/metrics`) | Доля блокировки растёт под атакой; легитимные клиенты проходят |
| Задержка легитимного клиента | Отдельный пробный клиент, меряющий RTT | Остается в рамках SLO во время атаки |
Последние две строки — решающие: защита, блокирующая всех подряд, включая реальных пользователей, провалилась.
### Интерпретация результатов
- **Высокая доля блокировки + стабильный RTT легитимных** → защита работает.
- **Заблокировано всё** → фильтр слишком агрессивен (проверьте пороги).
- **Ничего не заблокировано, CPU плавится** → атакующий трафик обходит ваши слои; проверьте, доходит ли он вообще до userspace (`mpstat`, счётчики XDP-дропов).
- **Генератор насыщается первым** → вы меряете генератор, а не цель. Масштабируйте источники или снизьте скорость.
## 6. Кейс: edge-only тест на loopback небольшой VDS (2026-08-04)
Полный отчёт: `docs/research/load-test-report.md`; скрипты: `deploy/test/stress/`.
Окружение: одна VDS 2 vCPU / 3.8 ГБ, Ubuntu 22.04, docker bridge `172.30.0.0/24`. Один контейнер запускал процесс Rampart edge плюс stub echo-бэкенд; второй контейнер выступал атакующим с сотней дополнительных IP-источников (`172.30.0.101–200`) и генерировал маскированные под протокол L7-флуды, неотличимые от реальных клиентов на уровне протокола. XDP был выключен и PoW выключен — сознательно, чтобы тест измерял чистый L7: rate limiting + репутацию.
Фазы:
- **Фаза A (лимиты сняты)**: измерение сырой пропускной способности — ~121,5 тыс. хендшейков за 30 с (~4.0 тыс. conn/s), всё проксировано; пик CPU edge ~179% (оба ядра). Без лимитов тот же флуд «душит» и легитимных пробных клиентов (успешны 2 из 5).
- **Фаза B (дефолтный лимит 5 pps/IP, burst 10, репутационный бан)**: идентичный флуд — **119 376 заблокировано против 528 пропущенных ≈ 99,6% blocked** при пике CPU ~32%. Все 5 легитимных пробных клиентов прошли во время атаки с RTT 2.2–5.8 мс; злоупотребляющие IP уходили в блэклист после повторных нарушений.
- **Фаза C (SYN flood через hping3, `--rand-source`)**: влияния на L7 edge нет — без XDP сырая обработка SYN целиком принадлежит ядру (syncookies/backlog). Подтверждает разделение слоёв из defense-levels.md.
- **Фаза D (300 удерживаемых соединений)**: все проксированы, CPU незначителен — удержание соединений в установившемся режиме ограничивал stub-бэкенд, а не edge.
Выводы, переносимые на любой проект:
1. Всегда гоняйте обе фазы: **защита выключена** (находим сырой потолок) и **защита включена** (доказываем долю блокировки и доступность легитимных клиентов). Одна лишь фаза B скрыла бы тот факт, что режим без лимитов вредит реальным клиентам.
2. Маскированные L7-флуды (валидные полезные нагрузки протокола с множества IP) — честный противник; простые SYN-тесты проверяют только слой ядра.
3. Маленькая VDS на 2 vCPU отбивала атаку при 32% CPU, как только включился per-IP лимит — хорошее раннее отсечение покупает огромный запас прочности.
4. Пробный легитимный клиент должен работать на протяжении каждой фазы — это ваш детектор ложных срабатываний.

View file

@ -1,287 +0,0 @@
# Migration - Rampart
> Как обновляться между версиями без даунтайма.
---
## Общие принципы
1. **Читай CHANGELOG** перед обновлением
2. **Бэкап** перед любой миграцией: Redis RDB, конфиги, сертификаты
3. **Одна нода** сначала - тестируй на одной edge, потом на всех
4. **Откат** - всегда сохраняй предыдущую версию бинарника
---
## v0.1 → v0.2 (Redis + Registry)
### Изменения
- Edge нода начинает использовать Redis для синхронизации блэклиста
- Paper плагин пишет в Redis вместо локального YAML
- Velocity получает server registry из Redis
### Шаги
```bash
# 1. Поднять Redis (если ещё нет)
docker compose up -d redis
# 2. Настроить Redis пароль
echo "requirepass НОВЫЙ_ПАРОЛЬ" >> /etc/redis/redis.conf
systemctl restart redis
# 3. Обновить конфиг edge ноды
cat >> /etc/rampart/config.toml << 'EOF'
[store]
redis_url = "redis://:НОВЫЙ_ПАРОЛЬ@10.0.0.1:6379/0"
blacklist_cache_ttl_secs = 300
EOF
# 4. Обновить Paper плагин (перейти с file → redis)
sed -i 's/registration_mode: "file"/registration_mode: "redis"/' paper-global.yml
sed -i 's|# redis_url:|redis_url: "redis://:НОВЫЙ_ПАРОЛЬ@10.0.0.1:6379/0"|' paper-global.yml
# 5. Обновить Velocity плагин
# Добавить redis_url в velocity.toml
# 6. Рестарт по очереди (no downtime)
systemctl restart rampart-edge # по одной edge ноде
systemctl restart velocity # по одной velocity
# Paper плагины - reload через /reload команду
```
### Откат
```bash
# Если что-то пошло не так:
# 1. Вернуть registration_mode: "file" в paper-global.yml
# 2. Убрать redis_url из всех конфигов
# 3. Рестартнуть всё в обратном порядке
```
---
## v0.2 → v0.3 (Observability)
### Изменения
- Добавляются Prometheus метрики на всех компонентах
- ClickHouse для хранения attack log
- Grafana дашборды
### Шаги
```bash
# 1. Поднять стек мониторинга
docker compose up -d clickhouse prometheus grafana
# 2. Создать таблицы ClickHouse
clickhouse-client --query "
CREATE DATABASE IF NOT EXISTS rampart;
CREATE TABLE IF NOT EXISTS rampart.blocked (
ts DateTime CODEC(Delta, ZSTD),
edge LowCardinality(String),
src_ip IPv4,
src_asn UInt32,
src_country LowCardinality(FixedString(2)),
reason LowCardinality(String),
proto_ver Int32,
hostname String CODEC(ZSTD)
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(ts)
ORDER BY (ts, edge, src_ip)
TTL ts + INTERVAL 90 DAY;
"
# 3. Настроить scrape targets в prometheus.yml
# 4. Импортировать Grafana dashboard
# 5. Ничего рестартить не нужно - метрики уже встроены
```
### Проверка
```bash
curl -s http://EDGE_IP:9090/metrics | grep rampart
```
---
## v0.3 → v0.4 (XDP)
### Изменения
- XDP программа на C
- libbpf-rs для загрузки в ядро
- Feature flag: `xdp`
### Шаги
```bash
# 1. Проверить совместимость
systemd-detect-virt # нужно kvm или none
uname -r # нужно 5.10+
ethtool -i eth0 # драйвер
# 2. Установить зависимости
sudo apt-get install -y libbpf-dev clang llvm linux-headers-$(uname -r)
# 3. Собрать с XDP
cargo build --release --features xdp
# 4. Обновить бинарник
cp /usr/local/bin/rampart-core /usr/local/bin/rampart-core.backup
cp target/release/rampart-core /usr/local/bin/
# 5. Включить XDP в конфиге
cat >> /etc/rampart/config.toml << 'EOF'
[xdp]
enabled = true
interface = "eth0"
EOF
# 6. Рестарт
systemctl restart rampart-edge
# 7. Проверить
journalctl -u rampart-edge | grep XDP
ip link show | grep xdp
```
### Откат
```bash
# Отключить XDP
rampart config set xdp_enabled false
systemctl restart rampart-edge
# Вернуть старый бинарник
cp /usr/local/bin/rampart-core.backup /usr/local/bin/rampart-core
```
---
## v0.4 → v0.5 (Anti-Bot)
### Изменения
- GeoIP (MaxMind GeoLite2)
- Sonar 3.0 интеграция
- Challenge API
### Шаги
```bash
# 1. Зарегистрироваться на maxmind.com, скачать GeoLite2-ASN
# 2. Разместить базу на Manager
mkdir -p /var/lib/rampart/geoip
cp GeoLite2-ASN.mmdb /var/lib/rampart/geoip/
# 3. Обновить конфиг edge
cat >> /etc/rampart/config.toml << 'EOF'
[geoip]
db_path = "/var/lib/rampart/geoip/GeoLite2-ASN.mmdb"
# ASN с повышенным скорингом
vpn_asns = [16276, 24940, 20473]
datacenter_asns = [16509, 14618, 8075]
EOF
# 4. Обновить Velocity плагин (с поддержкой Sonar)
cp plugins/velocity/target/rampart-velocity-*.jar /opt/velocity/plugins/
systemctl restart velocity
# 5. Проверить
rampart geoip lookup 1.2.3.4
```
---
## v0.5 → v0.6 (Scale + HA)
### Изменения
- Rust LB вместо HAProxy
- mTLS между всеми компонентами
- QUIC канал Edge ↔ Manager
- NATS JetStream
### Шаги
```bash
# 1. Развернуть NATS
docker compose up -d nats
# 2. Обновить конфиг Manager
cat >> /etc/rampart/manager.toml << 'EOF'
[nats]
urls = ["nats://127.0.0.1:4222"]
[quic]
bind = "0.0.0.0:7777"
EOF
# 3. Сгенерировать PKI
rampart pki init --root-ca rampart-ca
rampart pki issue --ca edge-ca --name edge-eu-1 --ip 10.0.100.1
rampart pki issue --ca infra-ca --name manager --ip 10.0.0.1
# 4. Развернуть сертификаты на все ноды
# 5. Включить mTLS в конфигах
# 6. Постепенно перевести трафик с HAProxy на Rust LB
```
### Миграция с HAProxy
```bash
# Фаза 1: Запустить Rust LB рядом с HAProxy
# (разные порты: HAProxy :25565, Rust LB :25566)
# Фаза 2: Переключить edge ноды на Rust LB
# (изменить backend.address в config.toml)
# Фаза 3: Остановить HAProxy
# (когда все edge переключены)
```
---
## v0.6 → v0.7 (Polish)
### Изменения
- io_uring runtime (feature flag)
- Zero-copy splice
- SLSA Level 3
### Шаги
```bash
# 1. Проверить io_uring доступность
cat /proc/sys/kernel/io_uring_disabled # 0 = OK
# 2. Собрать с io_uring
cargo build --release --features io-uring
# 3. Заменить бинарник
cp /usr/local/bin/rampart-core /usr/local/bin/rampart-core.epoll.backup
cp target/release/rampart-core /usr/local/bin/
systemctl restart rampart-edge
# 4. Проверить
journalctl -u rampart-edge | grep "io_uring"
# 5. Бенчмарк: сравнить производительность
tcpkali --connections 1000 --connect-rate 5000 --duration 30s EDGE_IP:25565
```
---
## Чеклист перед любой миграцией
```
☐ Прочитал CHANGELOG
☐ Сделал бэкап Redis: redis-cli SAVE
☐ Сделал бэкап конфигов: tar czf /backup/rampart-configs-$(date +%Y%m%d).tar.gz /etc/rampart/
☐ Сохранил старые бинарники
☐ Есть доступ к серверу через OOB/IPMI (на случай если сеть отвалится)
☐ Есть откат-план
☐ Предупредил команду в Discord
```
---
*Версия: 1.0 | Июль 2026*

View file

@ -1,183 +1,300 @@
# Architecture - Rampart
# Architecture — Rampart
> Актуально: v0.2+
> Статус: основной документ
> Relevant for: v0.3+ (universal redesign)
> Status: primary design document
> Language: English (research notes) — see [RU summary](#russian-summary) at the end
---
## 6-слойная архитектура защиты
## What changed
Rampart began as a 6-layer, Minecraft-specific protection stack: XDP filter with
hardcoded Minecraft handshake states → PoW → Rust L7 core → Velocity proxy (Java)
→ Paper agent (Java) → Traffic Intel. The big-bang redesign turns it into a
**universal L3/L4/L7 network protection platform** for any TCP service:
- VDS / dedicated servers,
- web services and APIs,
- game servers of any kind (Minecraft is now just a plugin, not the core).
The layer count drops from 6 to 4. Everything protocol-specific is extracted from
the core into compile-time protocol plugins; everything Java-based is removed.
## Layered architecture
```
┌──────────────────────────────────────────────────────────────────────┐
│ LAYER 1: XDP/eBPF (ядро) дроп L3/L4 до kernel TCP stack │
│ LAYER 1: XDP/eBPF (C) drop L3/L4 before kernel TCP stack │
│ ───────────────────────────── │
│ TCP state machine (minecraft_filter.c): │
│ AWAIT_ACK → AWAIT_MC_HANDSHAKE → AWAIT_LOGIN → verified │
│ + SYN throttle per-IP │
│ + IP blacklist (LPM_TRIE) │
│ + Invalid TCP flags drop (SYN+FIN, SYN+RST, URG, пустые) │
│ + UDP drop (MC = TCP only) │
│ + Per-connection seq tracking │
│ + bpf_timer idle cleanup │
│ + IP/CIDR whitelist │
│ ─────────────────────────────────────── │
│ Reference: Minecraft-XDP-eBPF (исправленный: нет pure ACK deadlock,│
│ LRU maps, IPv6, idle таймеры на conntrack) │
│ Universal (protocol-agnostic): │
│ + Generic TCP state machine (SYN → ESTABLISHED lifecycle, │
│ per-connection seq tracking, bpf_timer idle cleanup) │
│ + SYN throttle per-IP │
│ + IP/CIDR blacklist + whitelist (LPM_TRIE) │
│ + Invalid TCP flags drop (SYN+FIN, SYN+RST, URG, empty) │
│ + UDP policy (configurable: pass / drop / rate-limit) │
│ + Pluggable BPF protocol hooks (see ADR-002) │
├──────────────────────────────────────────────────────────────────────┤
│ LAYER 2: PoW Challenge (Rust) анти-handshake-flood │
│ LAYER 2: Universal PoW Challenge (Rust) anti-handshake-flood │
│ ───────────────────────────── │
│ SHA256 hashcash перед HMAC handshake: │
│ 1. Edge шлёт challenge (random + timestamp + difficulty) │
│ 2. Клиент решает PoW (nonce brute-force) │
│ 3. Edge верифицирует SHA256(data + nonce) prefix │
│ + Dynamic difficulty: повышается при CPS > threshold │
│ + Per-connection одноразовый challenge (nonce replay защита) │
│ ─────────────────────────────────────── │
│ Reference: PowGo (адаптирован: per-request challenge, timestamp, │
│ dynamic difficulty, без Redis, без IP+UA сессии) │
│ SHA256 hashcash over any TCP protocol (see ADR-004): │
│ 1. Edge sends challenge (random + timestamp + difficulty) │
│ 2. Client solves PoW (nonce brute-force) │
│ 3. Edge verifies SHA256(data + nonce) prefix │
│ + Dynamic difficulty: rises when CPS > threshold │
│ + Per-connection one-time challenge (nonce replay protection) │
├──────────────────────────────────────────────────────────────────────┤
│ LAYER 3: Rust Core (userspace) L7 фильтрация │
│ LAYER 3: Rust Userspace Core L7 filtering │
│ ───────────────────────────── │
│ + MC handshake парсинг (VarInt, bounds check) │
│ + HMAC-SHA256 hostname signature │
│ + Rate limit (token bucket per-IP) │
│ + Death code auto-ban (8 паттернов) │
│ + ASN/GeoIP reputation │
│ + Blacklist (Redis sync) │
│ + L7 handshake analysis (via active protocol plugin) │
│ + Rate limit (token bucket per-IP) │
│ + HMAC signatures (constant-time compare) │
│ + Death-code patterns (auto-ban on malicious payloads) │
├──────────────────────────────────────────────────────────────────────┤
│ LAYER 4: Velocity Proxy (Java) верификация игроков │
│ LAYER 4: Protocol Plugins (compile-time feature crates) │
│ ───────────────────────────── │
│ + Domain whitelist (блок прямых IP) │
│ + HMAC verification (constant-time compare) │
│ + Falling check (детерминированная физика: pre-computed кэш) │
│ + Protocol check (Transaction, SetHeldItem, ArmAnimation) │
│ + Vehicle check (Boat/Minecart gravity) │
│ + CAPTCHA challenge (Map item / PoW) │
│ + Redis server registry (delta-sync) │
│ + TPS-aware load balancer (circuit breaker < 12 TPS) │
│ ─────────────────────────────────────── │
│ Reference: Sonar pipeline + LimboFilter falling check │
│ (исправлено: HMAC fingerprint, idempotent finishVerification, │
│ без QuietDecoderException, без race в handler switching) │
├──────────────────────────────────────────────────────────────────────┤
│ LAYER 5: Paper Agent (Java) авто-регистрация │
│ ───────────────────────────── │
│ + Redis heartbeat (TPS, online игроки, память, CPU) │
│ + Auto-registration/unregistration │
│ + HMAC login check │
│ + Graceful shutdown │
├──────────────────────────────────────────────────────────────────────┤
│ LAYER 6: Traffic Intelligence (Rust + Redis) аналитика │
│ ───────────────────────────── │
│ + 168-hour traffic profiling (per-hour-slot baseline) │
│ + EWMA adaptive thresholds (правильная variance формула) │
│ + Z-Score anomaly detection (3 consecutive minutes для алерта) │
│ + Attack detection (CPS, PPS thresholds) │
│ + Reputation system (IP score -100..+100) │
│ + Discord webhook на события │
│ ─────────────────────────────────────── │
│ Reference: AtomGuard (исправлено: EWMA variance, Isolation Forest │
│ реально используется, без race в pipeline) │
│ + minecraft — first plugin (existing MC-handshake code moved in) │
│ + http, grpc — planned │
└──────────────────────────────────────────────────────────────────────┘
TRAFFIC INTEL (cross-cutting, Rust + Redis):
+ EWMA adaptive thresholds
+ Traffic profiling (per-slot baselines)
+ IP reputation system
```
## Схема прохождения трафика
### Traffic path
```
Атакующий (ботнет)
Attacker (botnet)
|
v
[1] XDP/eBPF ─── TCP state machine ─── blacklist ─── SYN throttle
| дроп: SYN flood, UDP, invalid flags, non-MC port
v (чистый TCP, прошёл state machine)
[2] PoW Challenge ─── SHA256 hashcash ─── dynamic difficulty
| дроп: не решил PoW за N секунд
v (валидный PoW)
[3] Rust Core ─── handshake parse ─── HMAC sign ─── rate limit ─── death code
| дроп: rate limit, invalid packet, bad HMAC
v (валидный MC handshake + HMAC)
[4] Velocity ─── domain check ─── HMAC verify ─── falling/physics check ─── CAPTCHA
| дроп: bad domain, bad HMAC, failed physics
v (верифицированный игрок)
[5] Game Server
| Чистый трафик, без DDoS нагрузки
[1] XDP/eBPF ─── universal TCP state machine ─── CIDR lists ─── SYN throttle
| drop: SYN flood, invalid flags, blacklisted CIDRs,
| UDP policy violations, failed BPF hook checks
v (clean TCP that passed state machine + hooks)
[2] PoW Challenge ─── SHA256 hashcash ─── dynamic difficulty [OFF by default]
| drop: did not solve PoW in time
v (valid PoW)
[3] Userspace Core ─── plugin handshake parse ─── rate limit ─── death codes
| drop: rate limit, malformed packets
v (validated application-layer client)
[4] Consumer service (game server, web backend, ...)
```
## Компоненты системы
## Component diagram
```
┌────────────────────────────────────────────────────────────────┐
│ EDGE NODE │
│ XDP/eBPF (C) → PoW (Rust) → Rust Core → Manager API │
│ ──────────────────────────────────────────────────────────── │
│ Требования: KVM/Bare Metal, 2-4 vCPU, 2-4 GB, kernel 5.10+ │
│ XDP native: Intel i40e, Mellanox ConnectX, virtio (generic) │
└────────────────────────┬───────────────────────────────────────┘
│ mTLS/QUIC
┌────────────────────────▼───────────────────────────────────────┐
│ VELOCITY CLUSTER │
│ Java 21, Velocity 3.4+, x20 нод │
│ Domain check → HMAC verify → Physics → CAPTCHA → Router │
└────────────────────────┬───────────────────────────────────────┘
│
┌───────────────┼───────────────┐
▼ ▼ ▼
Hub (x100) Game Servers Game Servers
лобби Survival (x100) Skyblock (x100)
разные VDS/дедики
```mermaid
flowchart TB
subgraph Kernel["Layer 1 — Kernel"]
XDP["XDP/eBPF filter (C)<br/>TCP state machine · SYN throttle<br/>CIDR black/white · UDP policy"]
HOOKS["BPF protocol hooks<br/>(pluggable modules)"]
XDP --- HOOKS
end
subgraph Core["rampart-core (Rust)"]
LOADER["XDP loader<br/>(libbpf / aya)"]
POW["PoW challenge engine<br/>(SHA256 hashcash)"]
L7["L7 filtering core<br/>rate limit · HMAC · death codes"]
INTEL["Traffic Intel<br/>EWMA · profiling · reputation"]
LOADER --> XDP
POW --> L7 --> PLUGINAPI
INTEL -.->|thresholds & scores| L7
INTEL -.->|dynamic difficulty| POW
end
subgraph Plugins["Layer 4 — Protocol plugins (feature crates)"]
PLUGINAPI["Plugin trait API<br/>(stabilization in progress)"]
MC["minecraft plugin"]
HTTP["http plugin<br/>(planned)"]
GRPC["grpc plugin<br/>(planned)"]
PLUGINAPI --> MC
PLUGINAPI -.-> HTTP
PLUGINAPI -.-> GRPC
end
subgraph Control["Control plane"]
MANAGER["rampart-manager<br/>(axum API)"]
REDIS[(Redis)]
CLI["rampart-cli"]
MANAGER <--> REDIS
CLI --> MANAGER
end
L7 --> BACKEND["Protected service<br/>(game server / web backend / VDS)"]
style HTTP stroke-dasharray: 5 5
style GRPC stroke-dasharray: 5 5
```
## Требования к хостингу
## Architecture Decision Records
| Нода | Роль | CPU | RAM | Тип | XDP |
|------|------|-----|-----|-----|-----|
| **Edge** | XDP + PoW + фильтрация | 2-4 vCPU | 2-4 GB | KVM / Bare Metal | ✅ |
| **Velocity** | MC Proxy + верификация | 4 vCPU | 4-8 GB | KVM | ❌ |
| **Manager** | API + Redis | 2-4 vCPU | 4-8 GB | KVM | ❌ |
| **Hub** | Лобби | 4-8 vCPU | 8-16 GB | KVM / Bare Metal | ❌ |
| **Game** | Игровой процесс | 4-8 vCPU | 8-32 GB | KVM / Bare Metal | ❌ |
### ADR-004: Compile-time protocol plugins instead of hardcoded Minecraft
> ⚠️ XDP требует KVM или Bare Metal. OpenVZ/LXC контейнеры — XDP не работает.
> Проверить: `systemd-detect-virt`
**Context.** The Rust userspace core originally parsed only Minecraft handshakes
(VarInt decoding, hostname bounds checks). Every non-Minecraft use case required
forking the codebase.
## Sizing guide
**Decision.** Protocol-specific parsing moves behind an internal **plugin trait
API** implemented by separate feature crates (`rampart-plugin-minecraft`, later
`rampart-plugin-http`, `rampart-plugin-grpc`). Plugins are selected at compile
time via Cargo features — no dynamic loading, no ABI stability burden. The first
plugin, `minecraft`, receives the existing MC-handshake code as-is.
| Игроков | Edge нод | Velocity нод | Edge RAM | Стоимость/мес |
|---------|----------|--------------|----------|---------------|
| до 500 | 1 | 2 | 2 GB | ~$15-30 |
| до 2 000 | 2 | 4 | 4 GB | ~$40-80 |
| до 10 000 | 4-6 | 8-10 | 8 GB | ~$150-300 |
| до 50 000 | 10-15 | 15-20 | 16 GB | ~$600-1200 |
**Consequences.**
## Граница XDP / Rust (критично)
- (+) The core becomes protocol-neutral; one platform covers VDS, web, and games.
- (+) Compile-time selection keeps dispatch static and the hot path monomorphic.
- (−) Supporting multiple protocols simultaneously requires composing feature
sets per build; runtime protocol multiplexing is out of scope until the plugin
API stabilizes.
- (−) The trait API must stabilize before third-party plugins appear (roadmap).
```
XDP делает: Rust делает:
TCP state machine (stateful) PoW challenge (SHA256)
SYN throttle per-IP MC handshake парсинг
IP blacklist (LPM_TRIE) HMAC подпись hostname
Invalid TCP flags drop Rate limit (connections/sec)
UDP drop Death code auto-ban
Per-connection seq tracking GeoIP/ASN lookup
bpf_timer idle cleanup Blacklist (сложные правила)
```
### ADR-005: Pluggable BPF hooks instead of hardcoded Minecraft states
XDP **не может**: SHA256, HMAC, floating point, heap allocation, сложные строки.
Всё L7 — только в Rust userspace.
**Context.** The XDP program contained a Minecraft-specific TCP state machine:
`AWAIT_ACK → AWAIT_MC_HANDSHAKE → AWAIT_LOGIN → verified`. This made Layer 1
unusable for any non-Minecraft traffic.
## ADR-001: Rust для Edge Core
**Decision.** The XDP filter keeps only **generic, protocol-agnostic** logic:
a universal TCP lifecycle state machine, SYN throttle, CIDR black/white lists,
TCP-flag sanity checks, and configurable UDP policy. Deep protocol parsing moves
into **separate BPF hook modules** attached to the main filter at load time.
Each hook can inspect payload after the TCP header and return allow/drop/skip.
**Решение:** Rust + tokio
**Альтернативы:** Go (GC паузы), C (небезопасен), Java (память)
**Причина:** Zero-cost abstractions, memory safety, нет GC, libbpf-rs
**Consequences.**
## ADR-002: Redis как хранилище состояния
- (+) Layer 1 works for arbitrary TCP services out of the box.
- (+) Hook modules keep the main filter small, auditable, and verifiable.
- (−) Hook attachment adds a map-dispatch indirection on the hot path; measured
cost is acceptable, but the full benchmark suite is still in progress.
- (−) Hooks are more constrained than userspace parsing (no loops without
bounded verification); anything complex stays in Layer 3.
**Решение:** Redis + локальный кэш на edge нодах
**Оговорка:** При падении Redis — edge работает с кэшем, Velocity с кэшем серверов
**Масштаб:** Redis Cluster при 1000+ серверов, Redis Sentinel для HA
### ADR-006: Remove Java layers (Velocity/Paper) and React dashboard
## ADR-003: NATS для критических событий
**Context.** Layers 4–5 were Java services: a Velocity proxy performing domain
checks, HMAC verification, physics/CAPTCHA challenges, plus a Paper agent doing
Redis heartbeat and server auto-registration. A React dashboard covered the
control plane UI.
**Решение:** NATS JetStream для blacklist updates, attack events, audit log
**Причина:** Redis Pub/Sub — fire-and-forget, NATS — at-least-once delivery
**Decision.** All Java components and the dashboard are removed from the
repository. Consumers integrate with Rampart through the **Manager API**
(axum + Redis sync) instead of embedding proxy-side Java plugins. See
[Migration notes](#migration-notes).
**Consequences.**
- (+) Universality: Rampart no longer assumes Minecraft or the JVM at all;
it protects whatever sits behind it.
- (+) Dramatically smaller deployment surface: edge nodes are pure Rust + C.
- (−) Capabilities unique to the Java layers (physics checks, in-game CAPTCHA)
are gone; where needed they become responsibilities of consumer-side plugins
built against the Manager API.
- (−) Existing Velocity/Paper deployments must migrate or stay on pre-redesign
git history.
### ADR-007: Universal PoW stays, off by default
**Context.** The SHA256 hashcash challenge previously assumed a Minecraft
text-challenge flow, which vanilla clients could not solve. In the universal
platform the challenge is redefined to work over any TCP protocol.
**Decision.** Keep the PoW layer as a core capability, redesigned as a
protocol-independent challenge, but ship it **disabled by default**
(`pow.enabled = false`). Operators enable it when their client ecosystem can
answer the challenge (custom clients, modded protocols, HTTP integrations).
**Consequences.**
- (+) Anti-handshake-flood capability remains available platform-wide.
- (+) Default configuration never breaks clients that cannot solve PoW.
- (−) Out of the box, handshake floods are mitigated only by Layer 1 throttling
and Layer 3 rate limits until PoW is explicitly enabled.
### ADR-008: Bilingual knowledge base as documentation-first strategy
**Context.** Operational experience (attack anatomy, defense levels, practice
guides) was scattered across research notes, runbooks, and chat history. New
operators repeatedly rediscovered the same lessons.
**Decision.** Maintain `docs/kb/` as a curated, bilingual (EN/RU) knowledge
base: attack anatomy, defense level explanations, and practice guides.
Documentation-first: every new attack class or defense mechanism gets a KB
entry as part of the change, not after the fact.
**Consequences.**
- (+) Onboarding cost drops; operational decisions cite KB articles.
- (+) EN/RU mirroring serves both the international audience and the original
Russian-speaking operator community.
- (−) Bilingual maintenance doubles writing effort; entries may temporarily lag
in one language.
## Migration notes
**Removed from the repository** (recoverable from git history):
- `plugins/` — Java Velocity plugin (domain whitelist, physics checks, CAPTCHA,
TPS-aware routing) and Paper agent (Redis heartbeat, auto-registration).
- `velocity/`, `paper/` — build scaffolding for the Java layers.
- `dashboard/` — React + Vite + TypeScript control plane UI.
All removed sources remain accessible in git history
(`git log --follow -- plugins/ velocity/ paper/ dashboard/`).
**Moved into the plugin crate:**
- Minecraft handshake parsing (VarInt, bounds checks) from the Rust core →
`minecraft` protocol plugin crate.
- Minecraft-specific connection states in the XDP state machine → replaced by
the universal state machine; MC-specific deep parsing will reappear as a BPF
hook module (planned).
**Kept in place:**
- XDP/eBPF generic filtering (maps, LPM trie, throttle timers).
- SHA256 hashcash PoW engine (redesigned to be protocol-agnostic, off by default).
- Rate limiting, HMAC, death-code pattern matching in the userspace core.
- Traffic Intel (EWMA thresholds, profiling, reputation).
- Manager API (axum) + Redis sync, CLI.
**Integration path for former Java-layer consumers:** talk to rampart-manager's
HTTP API for server registration, blacklists, and event streams. There is no
in-process Java integration anymore.
## Requirements (edge node)
| Item | Requirement |
|------|-------------|
| Virtualization | KVM / Bare Metal (XDP does not work under OpenVZ/LXC; check `systemd-detect-virt`) |
| Kernel | 5.10+ |
| CPU/RAM | 2–4 vCPU, 2–4 GB |
| XDP drivers | native: Intel i40e, Mellanox ConnectX; virtio falls back to generic mode |
## Historical ADRs (pre-redesign, still valid)
- **ADR-001: Rust for the edge core.** Rust + tokio over Go (GC pauses),
C (memory safety), Java (footprint). Zero-cost abstractions + libbpf-rs.
- **ADR-002: Redis as state store.** Redis + local cache on edge nodes; edges
keep operating from cache if Redis is down; Redis Cluster/Sentinel for scale/HA.
- **ADR-003: NATS for critical events.** NATS JetStream for blacklist updates,
attack events, audit log — at-least-once delivery instead of Redis Pub/Sub's
fire-and-forget.
---
## RU summary (краткая выжимка)
Rampart переработан из 6-слойной Minecraft-специфичной защиты в универсальную
платформу сетевой защиты уровня L3/L4/L7 для любых сервисов. Слоёв стало четыре:
(1) XDP/eBPF в ядре — только протоколо-независимая логика (универсальный TCP
state machine, SYN throttle, CIDR-списки, UDP policy), глубокий парсинг вынесен
в подключаемые BPF hook-модули; (2) универсальный PoW-challenge (SHA256 hashcash)
поверх любого TCP-протокола, выключен по умолчанию; (3) userspace-ядро на Rust —
L7-анализ, rate limit, HMAC, death-code паттерны; (4) компайл-тайм протокол-плагины
(feature crates), первый — minecraft, далее http и grpc. Traffic Intel
(EWMA, профилирование, репутация) работает поперёк всех слоёв. Java-слои
(velocity/paper) и React dashboard удалены — потребители интегрируются через
Manager API (axum + Redis). Документация строится вокруг двуязычной базы знаний
docs/kb. Полные детали миграции и ссылки на git-историю — в Migration notes выше.

View file

@ -1,303 +0,0 @@
# Minecraft Protocol - Парсинг, VarInt, Fingerprinting
> Актуально: v0.1+
> Основа всей фильтрации - знание протокола.
---
## Handshake пакет (0x00) - структура
```
┌──────────────────────────────────────────────────────┐
│ VarInt │ Packet Length │
├──────────────────────────────────────────────────────┤
│ VarInt │ Packet ID = 0x00 │
├──────────────────────────────────────────────────────┤
│ VarInt │ Protocol Version │
│ │ 765 = 1.20.4, 769 = 1.21.4, 766 = 26.1 │
├──────────────────────────────────────────────────────┤
│ String │ Server Address (hostname) │
│ │ VarInt (length) + UTF-8 bytes │
├──────────────────────────────────────────────────────┤
│ UShort │ Server Port (big-endian, 2 bytes) │
├──────────────────────────────────────────────────────┤
│ VarInt │ Next State: 1 = Status, 2 = Login │
└──────────────────────────────────────────────────────┘
```
---
## VarInt - строгий парсер с bounds check
```rust
// minecraft/varint.rs
#[derive(Debug)]
pub enum VarIntError {
Incomplete, // данных меньше чем нужно
TooBig, // VarInt > 5 байт (не по спецификации)
Overflow, // значение выходит за i32
}
pub fn read_varint(buf: &[u8], start: usize) -> Result<(i32, usize), VarIntError> {
let mut value: i32 = 0;
let mut shift = 0;
for (i, &byte) in buf[start..].iter().enumerate() {
if i >= 5 {
// MC VarInt максимум 5 байт - всё что больше: атака
return Err(VarIntError::TooBig);
}
let segment = (byte & 0x7F) as i32;
// Проверяем overflow до сдвига
if shift >= 32 || (shift == 28 && segment > 0x0F) {
return Err(VarIntError::Overflow);
}
value |= segment << shift;
shift += 7;
if (byte & 0x80) == 0 {
return Ok((value, start + i + 1));
}
}
Err(VarIntError::Incomplete)
}
// VarString = VarInt (length) + UTF-8 bytes
pub fn read_string(buf: &[u8], start: usize) -> Result<(String, usize), ParseError> {
let (len, after_len) = read_varint(buf, start)?;
if len < 0 || len > 32767 {
return Err(ParseError::StringTooLong);
}
let end = after_len + len as usize;
if end > buf.len() {
return Err(ParseError::Incomplete);
}
let s = std::str::from_utf8(&buf[after_len..end])
.map_err(|_| ParseError::InvalidUtf8)?
.to_string();
Ok((s, end))
}
```
---
## Полный парсер handshake
```rust
// minecraft/handshake.rs
#[derive(Debug)]
pub struct McHandshake {
pub protocol_version: i32,
pub server_address: String,
pub server_port: u16,
pub next_state: NextState,
}
#[derive(Debug, PartialEq)]
pub enum NextState {
Status, // 1 - ping
Login, // 2 - игрок заходит
Unknown(i32),
}
impl McHandshake {
pub fn parse(buf: &[u8]) -> Result<Self, ParseError> {
let mut pos = 0;
// Packet length (игнорируем значение, просто двигаемся дальше)
let (_, after_len) = read_varint(buf, pos)?;
pos = after_len;
// Packet ID - должен быть 0x00
let (packet_id, after_id) = read_varint(buf, pos)?;
pos = after_id;
if packet_id != 0x00 {
return Err(ParseError::NotHandshake(packet_id));
}
// Protocol version (не валидируем - не хардкодим версии)
let (protocol_version, after_pv) = read_varint(buf, pos)?;
pos = after_pv;
// Server address
let (server_address, after_addr) = read_string(buf, pos)?;
pos = after_addr;
// Защита от слишком длинного hostname
if server_address.len() > 255 {
return Err(ParseError::HostnameTooLong);
}
// Server port (big-endian u16)
if pos + 2 > buf.len() {
return Err(ParseError::Incomplete);
}
let server_port = u16::from_be_bytes([buf[pos], buf[pos + 1]]);
pos += 2;
// Next state
let (next_state_raw, _) = read_varint(buf, pos)?;
let next_state = match next_state_raw {
1 => NextState::Status,
2 => NextState::Login,
n => NextState::Unknown(n),
};
Ok(McHandshake {
protocol_version,
server_address,
server_port,
next_state,
})
}
pub fn is_login(&self) -> bool {
self.next_state == NextState::Login
}
}
```
---
## Hostname суффиксы - Forge, FabricProxy, HMAC
```
Обычный клиент: "play.server.com"
Forge (старый): "play.server.com\0FML\0"
NeoForge/Forge: "play.server.com\0FML2\0"
FabricProxy-Lite: "play.server.com\0" + base64(data)
Наш HMAC: "play.server.com\0shield\0<hex_hmac>"
Комбинации:
Forge + HMAC: "play.server.com\0FML2\0\0shield\0<hex_hmac>"
```
### Правильный порядок разбора
```rust
// ВАЖНО: сначала убираем FML суффикс, потом проверяем HMAC
// Если делать наоборот - HMAC подпись не совпадёт
pub struct ParsedHostname {
pub domain: String, // "play.server.com"
pub forge_marker: Option<String>, // "FML2" если Forge
pub hmac: Option<String>, // hex HMAC если прошли через edge
}
pub fn parse_hostname(raw: &str) -> ParsedHostname {
let parts: Vec<&str> = raw.split('\0').collect();
// Ищем "shield" среди частей
let shield_pos = parts.iter().position(|&p| p == "shield");
// Forge маркер - обычно вторая часть
let forge_marker = parts.get(1)
.filter(|&&p| p == "FML" || p == "FML2" || p == "FML3")
.map(|&s| s.to_string());
ParsedHostname {
domain: parts[0].to_string(),
forge_marker,
hmac: shield_pos.and_then(|i| parts.get(i + 1)).map(|s| s.to_string()),
}
}
```
---
## Client Fingerprinting
### По handshake
```rust
pub enum ClientType {
Vanilla,
NeoForge, // \0FML2\0
Forge, // \0FML\0
FabricProxy, // специфичный base64 суффикс
Bot, // подозрительные паттерны
Unknown,
}
pub fn fingerprint_from_handshake(h: &McHandshake) -> ClientType {
let addr = &h.server_address;
if addr.contains("\0FML2\0") { return ClientType::NeoForge; }
if addr.contains("\0FML\0") { return ClientType::Forge; }
// Очень старый или нестандартный protocol_version
if h.protocol_version < 47 || h.protocol_version > 10000 {
return ClientType::Bot;
}
ClientType::Unknown
}
```
### По plugin channels (после Login)
```rust
// Lunar, Badlion, Feather регистрируют свои каналы через
// LoginPluginRequest / PluginChannels пакет
pub fn fingerprint_from_channels(channels: &[String]) -> Option<ClientType> {
for ch in channels {
if ch.starts_with("lunarclient:") { return Some(ClientType::LunarClient); }
if ch.starts_with("badlion:") { return Some(ClientType::BadlionClient); }
if ch.starts_with("feather:") { return Some(ClientType::FeatherClient); }
if ch.starts_with("pvplounge:") { return Some(ClientType::PvPLounge); }
}
None
}
```
---
## Важные нюансы протокола
```
1. Один TCP коннект = один игрок. MC не мультиплексирует.
2. После Handshake(next_state=2) → LoginStart пакет
Если LoginStart не пришёл за 5 сек → это бот. DROP.
3. Protocol version не хардкодить.
Mojang с 2025 использует новую схему (26.1, 26.2...).
Принимаем любой валидный VarInt в диапазоне 0..10000.
4. Hostname может прийти TCP-фрагментированным (несколько сегментов).
Парсер должен уметь работать с неполными данными - читать пока
не получим полный пакет или timeout.
5. Status ping (next_state=1) - не требует авторизации.
Боты часто используют для разведки (онлайн, версия сервера).
Rate limit status отдельно от login.
6. MC 1.20.2+ использует Configuration phase между Login и Play.
Velocity обрабатывает автоматически - нам не важно для edge.
```
---
## Совместимость версий (июль 2026)
| Версия MC | Protocol version | Схема |
|---|---|---|
| 1.20.4 | 765 | Старая |
| 1.21.1 | 767 | Старая |
| 1.21.4 | 769 | Старая |
| 26.1 | 8xx | Новая (Mojang) |
| 26.2 | 8xx | Новая (Mojang) |
Velocity 3.4+ поддерживает обе схемы прозрачно.
Sonar 3.x поддерживает 1.8 - 26.2.

View file

@ -4,281 +4,155 @@
---
## 1. DDoS атака - пошагово (3 ночи, вы сонный)
## 1. DDoS атака — пошагово
```bash
# ── ШАГ 1: Подтвердить атаку ──
# Открыть Grafana → посмотреть алерты
# Или в CLI:
curl -s http://localhost:9090/api/v1/alerts | jq '.data.alerts[] | select(.state=="firing")'
# Метрики edge ноды
curl -s http://EDGE_IP:9090/metrics | grep -E "rampart_(connections|rate_limit|attack_status)"
# Проверить метрики edge ноды
curl -s http://EDGE_IP:9090/metrics | grep -E "rampart_(connections|rate_limit|blocked)"
# rampart_attack_status: 0 = normal, 1 = suspicious, 2 = under_attack
# (детектор по порогу pps, см. src/traffic/detector.rs)
# ── ШАГ 2: Определить тип атаки ──
# Если CPU < 50% и много DROP → XDP работает, атака L3/L4
# Если CPU > 80% → атака L7 (handshake flood)
# Много SYN без завершения handshake → L3/L4, смотрим kernel/XDP
ss -s
# Проверка XDP счётчиков
cat /sys/kernel/debug/tracing/trace_pipe | head -20
# Много установленных коннектов с малым трафиком → slow-атака (L4)
# Много коннектов/сек с одних IP → connection flood
# ── ШАГ 3: Действия ──
# ── ШАГ 3: Действия через конфиг (правка + рестарт) ──
# A) SYN flood (XDP справляется)
# → просто наблюдаем, XDP дропает на уровне ядра
# → проверить CPU: должен быть < 30%
echo "Наблюдаем, XDP работает"
# A) Connection flood → ужесточить per-IP лимиты в /etc/rampart/config.toml:
# [limits]
# rate_limit_pps = 2.0
# rate_limit_burst = 5.0
# max_connections_per_ip = 5
systemctl restart rampart
# B) Handshake flood (L7)
# → Ужесточить rate limit на лету
rampart config set rate_limit_login_pps 2
rampart config set rate_limit_burst 5
# B) Атака с известных подсетей → внести в blacklist через Manager API или CLI:
rampart-cli blacklist add 1.2.3.4 "ddos source"
rampart-cli blacklist list
# → Включить emergency mode (только whitelist)
rampart emergency --enable
# Это блокирует все IP кроме whitelist (доверенные ASN, verified players)
# C) Доверенные IP (мониторинг, админы) → whitelist в config.toml
# (только IP-адреса), затем systemctl restart rampart
# C) Атака с датацентров
# → Заблокировать ASN
rampart blacklist add asn 16276 # OVH
rampart blacklist add asn 24940 # Hetzner
# → Включить GeoIP фильтр (блокировать страну)
rampart geoip block CN RU
# D) Атака на конкретный протокол
# → Временно заблокировать статус пинги
rampart config set rate_limit_status_pps 0.1
# → Заблокировать старые версии протокола
rampart config set min_protocol_version 765
# D) Повысить стоимость флуда → включить PoW:
# [pow]
# enabled = true
# difficulty = 5
systemctl restart rampart
# ── ШАГ 4: Если не помогает ──
# Включить challenge для ВСЕХ новых подключений
rampart challenge --mode all --type timing
# Emergency mode (через CLI):
rampart-cli emergency enable
# ... и обратно после атаки:
rampart-cli emergency disable
# В крайнем случае - отключить все не-WG порты на edge
systemctl stop rampart-edge
# В крайнем случае — закрыть порт на firewall и разбираться:
iptables -A INPUT -p tcp --dport 25565 -j DROP
# Игроки не заходят, но серверы в безопасности
# Проверить через провайдера: возможно у них есть tools для фильтрации
# ── ШАГ 5: После атаки ──
# Выключить emergency mode
rampart emergency --disable
# Проверить логи в ClickHouse
clickhouse-client --query "
SELECT src_country, count() as attacks
FROM rampart.blocked
WHERE ts > now() - INTERVAL 1 HOUR
GROUP BY src_country
ORDER BY attacks DESC
LIMIT 10
"
# События атак пишутся в ClickHouse (если настроен clickhouse_url):
clickhouse-client --query "SELECT count() FROM rampart.events WHERE event_type='attack'"
# Написать post-mortem
```
---
> Честно: hot-reload конфига не реализован — изменения применяются рестартом.
> ASN/CIDR-баны, GeoIP-блокировки стран и выбор типов challenge через CLI не
> реализованы; blacklist принимает цели в том виде, в котором их хранит Manager.
## 2. Edge нода не стартует
```bash
# 1. Проверить статус
systemctl status rampart-edge
systemctl status rampart
journalctl -u rampart -n 50 --no-pager
# 2. Логи
journalctl -u rampart-edge -n 50 --no-pager
# Типичные причины:
# 3. Типичные причины:
# A) Порт занят
# A) Порт занят
ss -tlnp | grep 25565
# Решение: сменить порт в /etc/rampart/config.toml
# B) Конфиг не валидный
rampart config validate /etc/rampart/config.toml
# B) Конфиг невалиден — проверь парсингом теста:
cargo test --test config_parse
# Или запусти вручную и прочитай ошибку:
RAMPART_CONFIG=/etc/rampart/config.toml /usr/local/bin/rampart
# C) libbpf не найден (если собрано с XDP)
ldd /usr/local/bin/rampart-core | grep bpf
# Решение: apt-get install libbpf-dev
# C) Нет ни одного protocol handler
# rampart требует зарегистрированный ProtocolHandler (feature protocol-http
# или внешний крейт). Пока плагинов нет — edge в исследовательском режиме.
# D) Нет прав на BPF
# Решение: sudo setcap cap_bpf+ep /usr/local/bin/rampart-core
# 4. Запуск вручную (для диагностики)
/usr/local/bin/rampart-core --config /etc/rampart/config.toml --verbose
# D) XDP не загрузился
journalctl -u rampart | grep -i xdp
# Временное решение: [xdp] enabled = false в config.toml
```
---
## 3. XDP не загружается
```bash
# 1. Проверить виртуализацию
systemd-detect-virt
# openvz/lxc → XDP не работает. Сменить провайдера.
# 2. Проверить версию ядра
uname -r
# < 5.10 → обновить ядро
# 3. Проверить драйвер
systemd-detect-virt # openvz/lxc → XDP не работает, нужен KVM
uname -r # нужно 5.10+
ethtool -i eth0 | grep driver
# virtio → только generic mode
# i40e/mlx5 → native mode
# 4. Проверить XDP поддержку
sudo ip link set dev eth0 xdp off 2>&1
# "Operation not supported" → XDP не поддерживается
# Проверить компиляцию BPF-программы:
clang -O2 -g -target bpf -c xdp/core/universal_filter.c -o /tmp/universal_filter.o
# 5. Решение: отключить XDP в config.toml
# [xdp]
# enabled = false
# И перезапустить edge
systemctl restart rampart-edge
# Отключить XDP:
# [xdp] enabled = false → systemctl restart rampart
```
---
## 4. Игроки не могут зайти
## 4. Клиенты не могут подключиться
```bash
# 1. Проверить edge ноду
curl -s http://EDGE_IP:9090/metrics | grep rampart_connections
# Если 0 → edge не принимает соединения
# 2. Проверить что порт открыт
nc -zv EDGE_IP 25565
# 3. Проверить HMAC
# На velocity: /logs/rampart-hmac.log
# "HMAC mismatch" → не совпадает secret
# "Direct IP blocked" → игрок подключился не через edge
# 4. Проверить firewall
nc -zv EDGE_IP 25565 # порт открыт?
iptables -L INPUT -n -v | grep 25565
dig +short your.domain # DNS указывает на edge?
# 5. Проверить DNS
dig +short play.example.com
# Должен показывать IP edge ноды
# 6. Проверить rate limit
# Если игроков много с одного IP (NAT) - превышают лимит
rampart config set max_connections_per_ip 50 # увеличить
# NAT: много клиентов за одним IP упираются в max_connections_per_ip — увеличь.
```
---
## 5. Высокая нагрузка на edge
```bash
# 1. Определить bottleneck
htop -p $(pgrep -d',' rampart)
iftop -i eth0 # pps/bandwidth
# CPU
htop -p $(pgrep -d',' rampart-core)
# Память
ps aux | grep rampart-core
# I/O (если много логов)
iotop
# Сеть (pps, bandwidth)
iftop -i eth0
# 2. Типичные причины:
# A) Не хватает воркеров
# → Увеличить workers = vCPU
rampart config set workers_count $(nproc)
systemctl restart rampart-edge
# B) CPU > 80% от L7 парсинга
# → Включить XDP чтобы разгрузить userspace
# → Уменьшить rate_limit до разумных пределов
# → Проверить что нет SQL injection или других атак (парсинг hostname!)
# C) Утечка памяти
# → Проверить RSS за последние часы
# → Если растёт - включить профилирование
rampart debug pprof
# 3. Временное решение
rampart config set max_connections 50000 # ограничить
# 4. Постоянное решение
# Добавить ещё одну edge ноду
rampart add-node --role edge --name edge-eu-2 --ip 45.200.10.2
# Не хватает воркеров → [workers] count = vCPU, рестарт.
# CPU > 80% от userspace → включи XDP ([xdp] enabled = true),
# чтобы дропать мусор раньше, и ужесточи rate limit.
```
---
## 6. ClickHouse переполнен
```bash
# 1. Проверить дисковое пространство
df -h /var/lib/clickhouse
# 2. Очистить старые партиции (> 90 дней)
clickhouse-client --query "
SELECT partition, formatReadableSize(bytes_on_disk)
FROM system.parts
WHERE table = 'blocked'
ORDER BY partition
"
# Удалить старые
clickhouse-client --query "
ALTER TABLE rampart.blocked DROP PARTITION '2025-01'
"
# 3. Настроить TTL если не сделано
clickhouse-client --query "
ALTER TABLE rampart.blocked
MODIFY TTL ts + INTERVAL 90 DAY
"
# 4. Отключить логирование на время (если совсем плохо)
rampart config set clickhouse_enabled false
# Данные складываются в буфер, не теряются
clickhouse-client --query "ALTER TABLE rampart.events MODIFY TTL ts + INTERVAL 90 DAY"
# Временное отключение записи: закомментируй clickhouse_url в config.toml, рестарт.
```
---
## 7. Краткий справочник команд
```bash
rampart status # Общее состояние системы
rampart doctor # Полная диагностика
rampart config get workers.count # Получить параметр
rampart config set workers.count 4 # Установить параметр (hot reload)
rampart blacklist add 1.2.3.4 # Забанить IP
rampart blacklist add asn 24940 # Забанить ASN
rampart blacklist list # Список забаненных
rampart blacklist remove 1.2.3.4 # Разбанить
rampart whitelist add 10.0.0.0/16 # Добавить в whitelist
rampart emergency --enable # Включить emergency mode
rampart emergency --disable # Выключить
rampart drain edge-eu-1 # Плавно вывести ноду
rampart reload backend # Перезагрузить список бэкендов
rampart pki rotate --role edge # Ротация сертификатов
rampart wg sync # Синхронизация WireGuard
rampart debug pprof # CPU профиль
rampart debug heap # Heap профиль
rampart debug metrics # Prometheus метрики в CLI
rampart-cli status # Общее состояние системы
rampart-cli doctor # Полная диагностика
rampart-cli config get <key> # Получить параметр
rampart-cli config set <key> <value> # Установить параметр
rampart-cli blacklist add <target> [reason]
rampart-cli blacklist remove <target>
rampart-cli blacklist list
rampart-cli emergency enable|disable # Emergency mode
rampart-cli drain <node> # Плавно вывести ноду
```
Полный список: `rampart-cli --help` (источник — src/bin/rampart-cli.rs).
---
*Версия: 1.0 | Июль 2026*
*Версия: 2.0 | Август 2026*

View file

@ -4,265 +4,90 @@
---
## 1. Unit тесты (Rust)
## 1. Unit и integration тесты (Rust)
```bash
# Все тесты
cargo test
# Конкретный модуль
cargo test handshake
cargo test hmac
cargo test rate_limiter
# С выводом
cargo test -- --nocapture
# С профилированием
cargo test --release
cargo test # все тесты
cargo test --test config_parse
cargo test -- --nocapture # с выводом
```
### Что тестировать
Существующие наборы (tests/):
| Модуль | Happy path | Error cases |
|--------|-----------|-------------|
| VarInt parser | обычный, короткий | overflow, incomplete, >5 байт |
| MC Handshake | vanilla, forge, hmac | truncated, invalid utf8, wrong packet id |
| HMAC sign/verify | правильный secret | wrong secret, empty hostname, timing |
| Rate limiter | under limit, reset | over limit, burst, concurrent |
| Blacklist | add/check/remove | expired entry, duplicate add |
| Тест | Что покрывает |
|------|---------------|
| `config_parse` | Парсинг и валидация единого конфига (src/config) |
| `filter_logic` | Blacklist, rate limit, geo-фильтры |
| `pow_roundtrip` | Выдача/решение/проверка PoW challenge |
| `protocol_registry` | Реестр протокольных плагинов |
### Пример: VarInt
---
```rust
#[test]
fn test_varint_normal() {
let buf = vec![0x00];
assert_eq!(read_varint(&buf, 0).unwrap(), (0, 1));
}
## 2. Статический анализ
#[test]
fn test_varint_max() {
let buf = vec![0xFF, 0xFF, 0xFF, 0xFF, 0x07];
assert_eq!(read_varint(&buf, 0).unwrap(), (i32::MAX, 5));
}
```bash
cargo fmt --all --check
cargo clippy --all-targets --all-features -- -D warnings
cargo deny check
```
#[test]
fn test_varint_overflow() {
let buf = vec![0xFF, 0xFF, 0xFF, 0xFF, 0x0F]; // > 5 байт
assert!(matches!(read_varint(&buf, 0), Err(VarIntError::TooBig)));
}
XDP: smoke-check компиляции BPF-программы:
#[test]
fn test_varint_incomplete() {
let buf = vec![0x80]; // ждём ещё байты
assert!(matches!(read_varint(&buf, 0), Err(VarIntError::Incomplete)));
}
```bash
clang -O2 -g -target bpf -c xdp/core/universal_filter.c -o /tmp/universal_filter.o
```
---
## 2. Интеграционные тесты
## 3. Локальный integration-стенд (Docker)
```bash
# Требуют: docker compose up (redis, clickhouse)
cargo test --test integration
bash deploy/test/run_test.sh
```
### Что тестируем
```rust
#[tokio::test]
async fn test_full_flow() {
// 1. Запускаем edge ноду (test config)
// 2. Подключаемся Minecraft клиентом (через tokio::net::TcpStream)
// 3. Шлём валидный handshake
// 4. Проверяем что HMAC добавлен
// 5. Проверяем что трафик проксирован до backend
}
#[tokio::test]
async fn test_blacklist_sync() {
// 1. Добавляем IP в блэклист через Redis
// 2. Проверяем что edge нода его подхватила
// 3. Пытаемся подключиться с забаненного IP
// 4. Проверяем что соединение отклонено
}
```
---
## 3. Fuzzing
```rust
// tests/fuzz/handshake.rs
#![no_main]
use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &[u8]| {
// Должен крашиться на любой вход
let _ = McHandshake::parse(data);
});
```
```bash
cargo install cargo-fuzz
cargo fuzz run handshake_parser
```
Поднимает backend-stub (TCP echo), edge (`rampart` с config.test.toml) и
attacker-контейнер. Сценарии — в [deploy/test/README.md](../deploy/test/README.md).
---
## 4. Нагрузочное тестирование
### Базовый тест (tcpkali)
### Много-IP TCP flood на VDS (edge-only)
`deploy/test/stress/` — полный цикл без Redis/ClickHouse: edge-контейнер с
stub-бэкендом (socat echo) + attacker-контейнер со 100 source IP.
`flood.py` шлёт чистые TCP-соединения (connect / slowloris / keepalive),
во время флуда параллельно подключаются generic TCP-клиенты (`legit.py`),
замеряющие RTT.
```bash
# Установка
cargo install tcpkali
# 50k новых соединений
tcpkali \
--connections 1000 \
--connect-rate 5000 \
--duration 60s \
EDGE_IP:25565
# 500 активных соединений с трафиком
tcpkali \
--connections 500 \
--connect-rate 100 \
--duration 120s \
--message-rate 1 \
--message "$(xxd mc_handshake.bin)" \
EDGE_IP:25565
```
### SYN flood (hping3)
```bash
# Только на свои серверы!
hping3 -S --flood -p 25565 EDGE_IP
# С рандомным src IP
hping3 -S --flood -p 25565 --rand-source EDGE_IP
```
### Реальные Minecraft боты (SoulFire)
```bash
java -jar SoulFire.jar \
--target play.example.com:25565 \
--amount 200 \
--join-delay 50 \
--protocol-version 765
```
---
## 5. DDoS simulation
```bash
# Сценарий 1: SYN flood
# Ожидание: XDP дропает, CPU < 30%
hping3 -S --flood -p 25565 EDGE_IP
# Сценарий 2: Handshake flood
# Ожидание: rate limit блокирует, CPU < 60%
for i in $(seq 1 1000); do
(echo -n "$MC_HANDSHAKE" | nc -w1 EDGE_IP 25565) &
done
# Сценарий 3: Slowloris
# Ожидание: timeout 5 сек, соединение закрывается
while true; do
echo -n -e '\x01' | nc -w 10 EDGE_IP 25565
done
# Сценарий 4: Fragmented handshake
# Ожидание: буферизация, успешный парсинг
# (отправляем handshake по 1 байту с задержкой 100ms)
```
### Готовый много-IP стресс-тест на VDS (edge-only)
`deploy/test/stress/` — полный цикл без Redis/Velocity/Paper: edge-контейнер с stub-бэкендом
(socat echo) + attacker-контейнер со 100 source IP. Атака **маскируется под обычный трафик**
(валидные handshake со случайными hostname), во время флуда параллельно заходят легитимные
клиенты (`legit.py`), замеряющие RTT.
```bash
# На VDS
git clone https://github.com/loki5512344/rampart.git && cd rampart
cargo build --release --bin rampart-core
cp target/release/rampart-core deploy/test/stress/edge-ctx/rampart-core
cargo build --release --bin rampart
cd deploy/test/stress && bash run-stress.sh
```
Фазы: A — сырая пропускная способность (лимиты 100k), B — защита (дефолт 5 pps/IP),
C — SYN flood, D — активные соединения. Результаты прогона 2026-08-04 —
в [load-test-report.md](research/load-test-report.md).
Фазы: A — сырая пропускная способность (лимиты сняты), B — защита (per-IP rate
limit + reputation bans), C — SYN flood hping3, D — активные соединения.
---
## 6. CI Pipeline
```yaml
# .github/workflows/test.yml
name: Test
on: [push, pull_request]
jobs:
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: cargo test
- run: cargo clippy -- -D warnings
- run: cargo fmt --check
integration:
runs-on: ubuntu-latest
services:
redis:
image: redis:7-alpine
ports:
- 6379:6379
steps:
- uses: actions/checkout@v4
- run: cargo test --test integration
fuzz:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: cargo fuzz run handshake_parser -- -runs=100000
bench:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: cargo bench
```
---
## 7. Метрики качества
### tcpkali / hping3
```bash
# Покрытие кода
cargo install cargo-tarpaulin
cargo tarpaulin --out Html
open tarpaulin-report.html
# Цели:
# core/handshake.rs: > 95%
# core/hmac.rs: > 90%
# core/rate_limit: > 85%
# xdp/: тесты в изолированной среде
tcpkali --connections 1000 --connect-rate 5000 --duration 60s EDGE_IP:25565
hping3 -S --flood -p 25565 --rand-source EDGE_IP # только на свои серверы!
```
---
*Версия: 1.0 | Июль 2026*
## 5. Метрики для проверки во время теста
```bash
curl -s http://localhost:9090/metrics | grep rampart_
# rampart_connections_total{result="allowed|blocked"}
# rampart_rate_limit_hits
# rampart_pow_challenges_total{result=...}
# rampart_attack_status
```
---
*Версия: 2.0 | Август 2026*

View file

@ -7,181 +7,67 @@
### "XDP не загружается"
```bash
# Проверяем виртуализацию
systemd-detect-virt
# openvz / lxc -> XDP не работает, нужен KVM
systemd-detect-virt # openvz/lxc → XDP не работает, нужен KVM
uname -r # нужно 5.10+
dpkg -l | grep libbpf # libbpf-dev нужен при сборке
# Проверяем ядро
uname -r
# Нужно 5.10+
journalctl -u rampart | grep -i "xdp\|ebpf\|bpf"
# Проверяем зависимости
dpkg -l | grep libbpf
# libbpf-dev должен быть установлен
# Проверить компиляцию BPF-программы вручную:
clang -O2 -g -target bpf -c xdp/core/universal_filter.c -o /tmp/uf.o
# Смотрим ошибку загрузки
journalctl -u rampart-edge | grep -i "xdp\|ebpf\|bpf"
# Если драйвер не поддерживает native - fallback на generic
# В конфиге:
[xdp]
mode = "generic" # вместо "native" или "auto"
# Не помогло — отключи XDP в config.toml ([xdp] enabled = false) и рестарт.
```
### "Edge не коннектится к Manager"
### "Rate limit блокирует реальных клиентов"
```bash
# Проверяем WireGuard
ping 10.0.0.1
# Нет ответа -> WireGuard не работает
# Смотрим метрики
curl -s http://EDGE_IP:9090/metrics | grep rate_limit
wg show
# Смотрим peer Manager - есть ли last handshake?
# Нет handshake -> проблема с ключами или firewall у Manager
# Проверяем firewall на Manager
ssh root@MANAGER_IP 'iptables -L INPUT -n | grep 51820'
# Должно быть правило ACCEPT для UDP 51820
# Проверяем что Manager слушает
ssh root@MANAGER_IP 'ss -ulnp | grep 51820'
# Пересоздаём WireGuard handshake
wg set wg0 peer MANAGER_PUBKEY endpoint MANAGER_IP:51820
```
### "Rate limit блокирует реальных игроков"
```bash
# Симптом: игроки жалуются что не могут зайти
# Смотрим кого блокируем
journalctl -u rampart-edge | grep "RATE_LIMIT" | tail -50
# Если блокируем целые подсети мобильных операторов (NAT):
# Увеличиваем лимит для мобильных ASN
rampart config set rate_limit.mobile_multiplier 3.0
# Или поднимаем общий лимит
rampart config set rate_limit.max_connections_per_ip 10
rampart config reload
# Клиенты за NAT (один IP — много людей) → увеличь лимиты в config.toml:
# [limits] max_connections_per_ip = 50
# Или добавь их адрес в whitelist (только IP), рестарт.
```
### "Высокое CPU на edge ноде"
```bash
# Смотрим что жрёт CPU
top -p $(pgrep rampart-edge)
# Профилируем
perf top -p $(pgrep rampart-edge)
top -p $(pgrep rampart)
perf top -p $(pgrep rampart)
# Частые причины:
# 1. Слишком много активных соединений -> включить XDP чтобы дропать раньше
# 2. HMAC считается для каждого пакета -> норма, так и должно быть
# 3. GeoIP lookup медленный -> включить кэш
[geo]
cache_size = 100000
cache_ttl_secs = 3600
# 1. Много мусорных коннектов в userspace → включи XDP ([xdp] enabled = true)
# 2. Заниженные rate limit → соединения постоянно рвутся и пересоздаются
# 3. Не хватает воркеров → [workers] count = числу vCPU
```
### "Edge не стартует: no protocol plugins compiled"
Реестр протоколов пуст: `rampart` требует хотя бы один ProtocolHandler
(feature `protocol-http` или внешний крейт-плагин). Пока плагинов нет,
edge запускается только в исследовательских целях. См. src/bin/rampart.rs.
---
## Velocity плагин
## Manager
### "Velocity не видит серверы"
### "Manager не стартует: JWT_SECRET must be set"
```bash
# В логах Velocity ищем:
grep -i "rampart\|registry\|redis" /opt/velocity/logs/latest.log
# Частые причины:
# 1. Redis недоступен
redis-cli -h 10.0.0.1 -a $REDIS_PASSWORD ping
# Connection refused -> Redis не слушает на WireGuard IP
# 2. Неверный пароль Redis
# В config.yml проверяем redis.password
# 3. Velocity не в WireGuard сети
ping 10.0.0.1 # с ноды Velocity
# Нет ответа -> настраиваем WireGuard
# 4. Серверы не зарегистрированы (Paper агент не запущен)
redis-cli -h 10.0.0.1 -a $REDIS_PASSWORD keys "rampart:servers:*"
# Пустой ответ -> Paper агент не работает
export JWT_SECRET="$(openssl rand -hex 32)" # минимум 32 байта
export API_PASSWORD="сильный_пароль" # 'changeme' запрещён
export REDIS_URL="redis://127.0.0.1:6379/0" # опционально
rampart-manager
```
### "Игроков не пускает - 'Подключение по IP запрещено'"
### "Manager отдаёт 401"
Токен истёк (`JWT_EXPIRATION_SECS`, дефолт 86400) — перелогинься:
```bash
# Это нормально если игрок подключается по IP, а не домену
# Проверяем что DNS работает:
nslookup play.yourserver.com
# Должен вернуть IP edge ноды
# Если игрок подключается через домен и всё равно кикает:
# Проверяем что edge HMAC совпадает с Velocity
# На Velocity смотрим логи:
grep "HMAC\|shield" /opt/velocity/logs/latest.log
# Частые причины:
# 1. Разные HMAC секреты на edge и Velocity
# Сравниваем:
cat /etc/rampart/config.toml | grep hmac_secret
grep RAMPART_HMAC_SECRET /opt/velocity/velocity.conf
# 2. Edge нода не добавляет HMAC (add_hmac_header = false)
# В /etc/rampart/config.toml:
[shield]
add_hmac_header = true
```
### "Игрок попадает не на тот сервер"
```bash
# Проверяем стратегию балансировщика
grep "strategy" /opt/velocity/plugins/rampart/config.yml
# Смотрим онлайн по серверам
rampart server list
# Если сервер переполнен но всё равно получает игроков:
# Проверяем что Paper агент обновляет онлайн
redis-cli -h 10.0.0.1 -a $REDIS_PASSWORD \
GET rampart:servers:hub_1
# В JSON смотрим "online" - должно обновляться
```
---
## Paper агент
### "Агент не регистрирует сервер"
```bash
# В логах Minecraft сервера:
grep -i "rampart\|shield agent" /opt/minecraft/logs/latest.log
# Частые причины:
# 1. Redis недоступен с этой ноды
redis-cli -h 10.0.0.1 -a $REDIS_PASSWORD ping
# 2. Неверный IP в конфиге (указан публичный вместо WireGuard)
# Проверить env RAMPART_SERVER_IP
# Должен быть 10.0.x.x (WireGuard IP)
# 3. Дублирующееся имя сервера
redis-cli -h 10.0.0.1 -a $REDIS_PASSWORD \
keys "rampart:servers:*"
# Если имя уже есть - изменить RAMPART_SERVER_NAME
# 4. Агент не установлен
ls /opt/minecraft/plugins/ | grep rampart-paper
# Должен быть .jar файл
curl -s -X POST localhost:8080/api/v1/auth/login \
-H 'Content-Type: application/json' -d '{"password":"..."}'
```
---
@ -191,78 +77,20 @@ ls /opt/minecraft/plugins/ | grep rampart-paper
### "Redis падает с OOM"
```bash
# Проверяем использование памяти
redis-cli -a $REDIS_PASSWORD INFO memory | grep used_memory_human
# Настраиваем eviction policy
redis-cli -a $REDIS_PASSWORD CONFIG SET maxmemory 2gb
redis-cli -a $REDIS_PASSWORD CONFIG SET maxmemory-policy allkeys-lru
# Смотрим что занимает место
redis-cli -a $REDIS_PASSWORD --bigkeys
redis-cli INFO memory | grep used_memory_human
redis-cli CONFIG SET maxmemory 2gb
redis-cli CONFIG SET maxmemory-policy allkeys-lru
```
### "Redis медленно отвечает"
### "Edge не синхронизирует блэклист через Redis"
```bash
# Запускаем latency monitor
redis-cli -a $REDIS_PASSWORD --latency-history -i 1
# Смотрим slowlog
redis-cli -a $REDIS_PASSWORD SLOWLOG GET 10
# Частые причины:
# 1. KEYS команда (блокирует) -> заменить на SCAN
# 2. Нет persistent connection pool -> Jedis pool в Velocity плагине
# 3. Сеть: проверяем пинг от Velocity до Redis через WireGuard
ping 10.0.0.1
# redis_url задан и не пустой? Сборка с feature store-redis (дефолт)?
grep redis_url /etc/rampart/config.toml
journalctl -u rampart | grep -i redis
```
---
## WireGuard
### "Ноды не видят друг друга"
```bash
# На каждой ноде
wg show
# Смотрим:
# - есть ли peer с нужным PublicKey
# - есть ли "latest handshake" (должен быть свежий)
# - endpoint правильный
# Если нет handshake:
# 1. Проверяем что Manager слушает UDP 51820
ss -ulnp | grep 51820
# 2. Проверяем firewall на Manager
iptables -L INPUT -n | grep 51820
# 3. Проверяем что ключи правильные
wg pubkey < /etc/wireguard/private.key
# Должен совпасть с PublicKey у peer на Manager
# Форс рестарт
systemctl restart wg-quick@wg0
```
### "Высокий пинг через WireGuard"
```bash
# Измеряем
ping 10.0.0.1
# Нормально: < 5 мс внутри датацентра, < 50 мс между регионами
# Если > 200 мс -> проблема с маршрутизацией
traceroute 10.0.0.1
# MTU проблема (фрагментация):
ping -M do -s 1400 10.0.0.1
# Если drops -> MTU слишком большой
# В /etc/wireguard/wg0.conf добавить:
MTU = 1380
```
Без Redis edge работает автономно на локальном кэше блэклиста.
---
@ -271,57 +99,27 @@ MTU = 1380
### "ClickHouse не принимает данные"
```bash
# Проверяем что запущен
systemctl status clickhouse-server
# или
docker compose ps rampart-clickhouse
# Проверяем таблицы
curl http://localhost:8123/ping
clickhouse-client --query "SHOW TABLES FROM rampart"
# Проверяем ошибки вставки в логах Manager
journalctl -u rampart-manager | grep -i "clickhouse\|insert"
# Частые причины:
# 1. Таблица не создана -> запускаем миграции
rampart db migrate
# 2. Нет места на диске
df -h
# ClickHouse хранит в /var/lib/clickhouse/
# 3. Неверная схема (после обновления)
clickhouse-client --query "DESCRIBE TABLE rampart.blocked"
df -h /var/lib/clickhouse
```
Запись настраивается `store.clickhouse_url` в config.toml. Ошибки записи не
роняют edge — они уходят в debug-лог.
---
## Общая диагностика
```bash
# Полная проверка системы одной командой
rampart doctor
# Что проверяет:
# ✅ WireGuard туннели
# ✅ Redis доступность
# ✅ NATS доступность
# ✅ Manager API
# ✅ Все edge ноды online
# ✅ Все Velocity ноды online
# ✅ Хотя бы один Hub онлайн
# ✅ HMAC секрет одинаковый везде
# ✅ Сертификаты не истекают в ближайшие 30 дней
# ✅ Redis память < 80%
# ✅ Место на дисках > 20%
# Вывод:
# [OK] Redis: 10.0.0.1:6379
# [OK] Manager API: /api/health
# [WARN] Edge eu-1: last seen 45 sec ago (порог 30 сек)
# [FAIL] Hub_5: не зарегистрирован в Redis
rampart-cli doctor # полная диагностика через Manager API
rampart-cli status # краткий статус
```
> Честно: набор проверок `doctor` ограничен тем, что реализовано в CLI
> (см. src/cli/commands/doctor.rs) и доступно Manager API.
---
*Версия: 1.0 | Июль 2026*
*Версия: 2.0 | Август 2026*

View file

@ -1,7 +1,7 @@
# VDS Compatibility - Rampart
> Совместимость VDS провайдеров с XDP/eBPF, io_uring и WireGuard.
> Обновляется: Июль 2026
> Совместимость VDS провайдеров с XDP/eBPF.
> Обновляется: Август 2026
---
@ -9,60 +9,14 @@
Некоторые провайдеры используют виртуализацию, которая **не поддерживает XDP**:
| Тип виртуализации | XDP Native | XDP Generic | io_uring | Рекомендация |
|---|---|---|---|---|
| **KVM** | ✅ (зависит от драйвера) | ✅ | ✅ | Лучший выбор |
| **Bare Metal** | ✅ | ✅ | ✅ | Идеально для edge |
| **VMware** | ❌ | ✅ | ✅ | Приемлемо |
| **Hyper-V** | ❌ | ✅ | ✅ | Приемлемо |
| **OpenVZ / LXC** | ❌ | ❌ | ❌ | **НЕ ИСПОЛЬЗОВАТЬ** для edge |
| Тип виртуализации | XDP Native | XDP Generic | Рекомендация |
|---|---|---|---|
| **KVM** | ✅ (зависит от драйвера) | ✅ | Лучший выбор |
| **Bare Metal** | ✅ | ✅ | Идеально для edge |
| **VMware / Hyper-V** | ❌ | ✅ | Приемлемо |
| **OpenVZ / LXC** | ❌ | ❌ | **НЕ ИСПОЛЬЗОВАТЬ** для edge |
> ⚠️ **OpenVZ/LXC контейнеры не поддерживают XDP и io_uring.**
> Если купите VDS за $3 у OVH - XDP не заведётся.
---
## Таблица провайдеров
### Edge нода (требует XDP)
| Провайдер | План | Виртуализация | XDP Native | XDP Generic | Цена/мес | Примечание |
|---|---|---|---|---|---|---|
| **Hetzner** | CX22 (2vCPU, 4GB) | KVM | ❌ (virtio) | ✅ | €4.5 | Отличный entry-level |
| **Hetzner** | CPX21 (3vCPU, 4GB) | KVM | ✅ (i40e) | ✅ | €6.9 | Рекомендуется |
| **Hetzner** | AX102 (8vCPU, 32GB) | Bare Metal | ✅ | ✅ | €35 | Для крупных нод |
| **Contabo** | Cloud VPS S (4vCPU, 8GB) | KVM | ❌ | ✅ | €5.0 | Бюджетно, но CPU слабее |
| **Vultr** | High Frequency (2vCPU, 4GB) | KVM | ✅ | ✅ | $12 | Хорошая сеть |
| **Vultr** | Regular (2vCPU, 4GB) | KVM | ❌ (virtio) | ✅ | $6 | Базовый вариант |
| **OVHcloud** | VPS Value (2vCPU, 4GB) | KVM | ❌ | ✅ | €3.5 | Бюджетно |
| **OVHcloud** | VPS Elite (4vCPU, 8GB) | KVM | ✅ | ✅ | €15 | Рекомендуется |
| **OVHcloud** | Bare Metal Game (4vCPU, 32GB) | Bare Metal | ✅ | ✅ | €30 | Для game серверов |
| **DigitalOcean** | Premium (2vCPU, 4GB) | KVM | ❌ | ✅ | $12 | Стабильно, но дороже |
| **Linode** | Dedicated CPU (4vCPU, 8GB) | KVM | ✅ | ✅ | $36 | Дороговато для edge |
| **Scaleway** | DEV1-L (4vCPU, 8GB) | KVM | ❌ (virtio) | ✅ | €11 | - |
| **AWS** | c6i.large (2vCPU, 4GB) | Nitro KVM | ✅ (ena) | ✅ | ~$24 | Дорого, сложный network |
| **Google Cloud** | e2-standard-2 (2vCPU, 4GB) | KVM | ❌ | ✅ | ~$17 | - |
> ✅ = Подтверждено работает
> ❌ = Не поддерживается драйвером
### Manager / Load Balancer (XDP не нужен)
Для Manager, HAProxy, Rust LB подойдёт **любой KVM VDS** с 2 vCPU. XDP не требуется.
| Провайдер | План | Цена/мес |
|---|---|---|
| Hetzner CX22 | 2vCPU, 4GB | €4.5 |
| Contabo VPS S | 4vCPU, 8GB | €5.0 |
| OVH VPS Value | 2vCPU, 4GB | €3.5 |
### Game серверы (Minecraft)
| Провайдер | План | RAM | Цена/мес | Примечание |
|---|---|---|---|---|
| Hetzner AX102 | Bare Metal, 8vCPU | 32GB | €35 | Лучшее соотношение |
| OVH Game | 4vCPU | 32GB | €30 | Оптимизирован для игр |
| Localhost | Dedicated | 64GB+ | - | Лучшая производительность |
> ⚠️ OpenVZ/LXC не поддерживают XDP. Дешёвая VDS за $3 — XDP не заведётся.
---
@ -82,66 +36,37 @@ uname -r
# 4. XDP доступность
sudo ip link set dev eth0 xdp off 2>&1 || echo "XDP не поддерживается"
# 5. io_uring доступность
cat /proc/sys/kernel/io_uring_disabled
# 0 = OK, 1 = только root, 2 = заблокирован
```
---
## Рекомендуемые конфигурации
## Провайдеры
### Для старта (v0.1, до 500 игроков)
| Провайдер | План | Виртуализация | XDP Native | Цена/мес |
|---|---|---|---|---|
| Hetzner | CPX21 (3vCPU, 4GB) | KVM | ✅ (i40e на AX) | €6.9 |
| Hetzner | AX102 (bare metal) | — | ✅ | €35 |
| Vultr | High Frequency | KVM | ✅ | $12 |
| OVHcloud | VPS Elite | KVM | ✅ | €15 |
| Contabo | Cloud VPS S | KVM | ❌ (generic only) | €5.0 |
| DigitalOcean | Premium | KVM | ❌ (generic only) | $12 |
```
1 × Hetzner CX22 (€4.5) - Manager + Redis + NATS
1 × Hetzner CX22 (€4.5) - Edge нода (XDP Generic)
1 × Velocity на той же VDS что и Manager
N × Game серверы (ваши существующие)
Итого: ~€9/мес
```
Для Manager подойдёт любой KVM VDS с 2 vCPU — XDP там не нужен.
### Medium (v0.4+, до 5000 игроков)
```
1 × Hetzner CPX31 (€12) - Manager + Redis + NATS + ClickHouse
2 × Hetzner CPX21 (€6.9) - Edge ноды (XDP Native)
2 × Hetzner CX32 (€8) - Velocity
5 × Hetzner AX102 (€35) - Game серверы
Итого: ~€230/мес
```
### Large (v0.6+, до 50000 игроков)
```
1 × Hetzner AX102 (€35) - Manager + NATS + ClickHouse
4 × Hetzner CPX31 (€12) - Rust LB
6 × Hetzner CPX31 (€12) - Edge ноды (XDP Native)
15 × Hetzner CX32 (€8) - Velocity
20 × Hetzner AX102 (€35) - Game серверы
Итого: ~€1100/мес
```
> Таблица ориентировочная: перед покупкой проверь драйвер NIC и ядро командами выше.
---
## Лимиты провайдеров
### Hetzner
- **Traffic:** CX/CPX - 20TB включено, далее €1/TB
- **DDoS Protection:** Встроенная L3/L4 защита (10Gbps blackhole)
- **BGP:** Только на выделенных серверах (AX)
### Contabo
- **Traffic:** Неограничен (512Mbps)
- **DDoS Protection:** Есть, но слабая
- **CPU:** Старшие модели Intel Xeon, но shared
- Traffic: CX/CPX — 20TB включено, далее €1/TB
- DDoS Protection: встроенная L3/L4 защита (10Gbps blackhole)
### OVHcloud
- **VPS:** OpenVZ на старых тарифах - **проверяйте перед покупкой**
- **Game серверы:** Встроенная DDoS защита (up to 1Tbps)
- **BGP:** На Bare Metal
- VPS: старые тарифы бывают на OpenVZ — **проверяй перед покупкой**
- Game-линейка: встроенная DDoS защита до 1Tbps
---
*Версия: 1.0 | Июль 2026*
*Версия: 2.0 | Август 2026*

View file

@ -1,28 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<projectDescription>
<name>rampart-plugins-plugins</name>
<comment>Project plugins created by Buildship.</comment>
<projects>
</projects>
<buildSpec>
<buildCommand>
<name>org.eclipse.buildship.core.gradleprojectbuilder</name>
<arguments>
</arguments>
</buildCommand>
</buildSpec>
<natures>
<nature>org.eclipse.buildship.core.gradleprojectnature</nature>
</natures>
<filteredResources>
<filter>
<id>1784460569616</id>
<name></name>
<type>30</type>
<matcher>
<id>org.eclipse.core.resources.regexFilterMatcher</id>
<arguments>node_modules|\.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__</arguments>
</matcher>
</filter>
</filteredResources>
</projectDescription>

View file

@ -1,13 +0,0 @@
arguments=--init-script /home/loki/.cache/opencode/bin/jdtls/config_linux/org.eclipse.osgi/57/0/.cp/gradle/init/init.gradle
auto.sync=false
build.scans.enabled=false
connection.gradle.distribution=GRADLE_DISTRIBUTION(WRAPPER)
connection.project.dir=
eclipse.preferences.version=1
gradle.user.home=
java.home=/usr/lib/jvm/java-21-openjdk
jvm.arguments=
offline.mode=false
override.workspace.settings=true
show.console.view=true
show.executions.view=true

View file

@ -1,14 +0,0 @@
subprojects {
apply(plugin = "java")
configure<JavaPluginExtension> {
toolchain {
languageVersion.set(JavaLanguageVersion.of(21))
}
}
repositories {
mavenCentral()
maven("https://repo.papermc.io/repository/maven-public/")
}
}

View file

@ -1,2 +0,0 @@
version=0.1.0
group=me.rampart

Some files were not shown because too many files have changed in this diff Show more