From 29ce6fb8e947ea579b5d67011f5642889f5dd10e Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Mon, 3 Aug 2026 10:54:28 +0200 Subject: [PATCH] feat: velocity routing, server registry and RampartVelocity tests --- .github/workflows/ci.yml | 4 +- Cargo.lock | 265 +----------------- README.md | 9 + TODO.md | 117 +++++++- clippy.toml | 4 +- crates/rampart-cli/Cargo.toml | 2 +- crates/rampart-core/src/config.rs | 16 +- crates/rampart-core/src/crypto/hmac.rs | 158 ++++++++--- crates/rampart-core/src/filter/blacklist.rs | 32 ++- crates/rampart-core/src/filter/rate_limit.rs | 86 +++++- crates/rampart-core/src/lib.rs | 2 - crates/rampart-core/src/main.rs | 139 ++++++++- crates/rampart-core/src/metrics.rs | 8 + crates/rampart-core/src/proxy/handshake.rs | 12 +- crates/rampart-core/src/proxy/listener.rs | 50 +++- crates/rampart-core/src/proxy/pow.rs | 4 +- crates/rampart-core/src/proxy/tunnel.rs | 207 +++++++++++--- crates/rampart-core/src/store/redis.rs | 14 +- crates/rampart-core/src/traffic/reputation.rs | 38 ++- crates/rampart-core/src/xdp/filter.rs | 8 +- crates/rampart-core/src/xdp/noop.rs | 2 +- crates/rampart-manager/Cargo.toml | 1 + crates/rampart-manager/src/api/auth.rs | 101 ++++++- crates/rampart-manager/src/auth.rs | 53 +++- crates/rampart-manager/src/main.rs | 31 +- deny.toml | 12 +- deploy/config/edge.toml | 4 + deploy/docker/Dockerfile.edge | 7 +- deploy/test/config.test.toml | 2 +- docs/configuration.md | 9 + .../me/rampart/paper/HmacLoginListener.java | 22 +- .../java/me/rampart/paper/RampartPaper.java | 81 +++++- .../java/me/rampart/paper/ShieldAgent.java | 7 +- plugins/velocity/build.gradle.kts | 1 + .../me/rampart/velocity/CaptchaHandler.java | 2 - .../rampart/velocity/HmacCheckListener.java | 106 ++++--- .../velocity/PhysicsCheckListener.java | 1 + .../me/rampart/velocity/RampartVelocity.java | 37 ++- .../me/rampart/velocity/ServerRegistry.java | 12 + .../me/rampart/velocity/ServerRouter.java | 10 + .../rampart/velocity/RampartVelocityTest.java | 95 +++++++ 41 files changed, 1239 insertions(+), 532 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd22f4f..c5b7be9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,10 +21,12 @@ jobs: with: components: clippy, rustfmt - uses: Swatinem/rust-cache@v2 - - run: sudo apt-get update && sudo apt-get install -y libelf-dev libbpf-dev + - run: sudo apt-get update && sudo apt-get install -y libelf-dev libbpf-dev clang linux-libc-dev - 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 rust-test: name: Rust — test diff --git a/Cargo.lock b/Cargo.lock index f2ae216..fa471d2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -309,26 +309,6 @@ dependencies = [ "tokio-util", ] -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -467,15 +447,6 @@ version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -510,21 +481,6 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -659,25 +615,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "h2" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "hashbrown" version = "0.14.5" @@ -766,7 +703,6 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "h2", "http", "http-body", "httparse", @@ -794,22 +730,6 @@ dependencies = [ "webpki-roots", ] -[[package]] -name = "hyper-tls" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" -dependencies = [ - "bytes", - "http-body-util", - "hyper", - "hyper-util", - "native-tls", - "tokio", - "tokio-native-tls", - "tower-service", -] - [[package]] name = "hyper-util" version = "0.1.20" @@ -828,11 +748,9 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2 0.6.5", - "system-configuration", "tokio", "tower-service", "tracing", - "windows-registry", ] [[package]] @@ -1072,12 +990,6 @@ version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - [[package]] name = "litemap" version = "0.8.2" @@ -1155,23 +1067,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "native-tls" -version = "0.2.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - [[package]] name = "nix" version = "0.31.3" @@ -1239,49 +1134,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" -[[package]] -name = "openssl" -version = "0.10.81" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" -dependencies = [ - "bitflags", - "cfg-if", - "foreign-types", - "libc", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - -[[package]] -name = "openssl-sys" -version = "0.9.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "parking_lot" version = "0.12.5" @@ -1375,7 +1227,7 @@ dependencies = [ "bitflags", "hex", "procfs-core", - "rustix 0.38.44", + "rustix", ] [[package]] @@ -1553,6 +1405,7 @@ dependencies = [ "redis", "serde", "serde_json", + "subtle", "thiserror 2.0.19", "tokio", "tower-http", @@ -1676,20 +1529,15 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64", "bytes", - "encoding_rs", "futures-core", - "h2", "http", "http-body", "http-body-util", "hyper", "hyper-rustls", - "hyper-tls", "hyper-util", "js-sys", "log", - "mime", - "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -1700,7 +1548,6 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-native-tls", "tokio-rustls", "tower", "tower-http", @@ -1741,23 +1588,10 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys 0.4.15", + "linux-raw-sys", "windows-sys 0.59.0", ] -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", -] - [[package]] name = "rustls" version = "0.23.42" @@ -1805,44 +1639,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags", - "core-foundation 0.10.1", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "serde" version = "1.0.229" @@ -2064,40 +1866,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "system-configuration" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" -dependencies = [ - "bitflags", - "core-foundation 0.9.4", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom 0.4.3", - "once_cell", - "rustix 1.1.4", - "windows-sys 0.61.2", -] - [[package]] name = "thiserror" version = "1.0.69" @@ -2230,16 +1998,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.4" @@ -2489,12 +2247,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - [[package]] name = "version_check" version = "0.9.5" @@ -2651,17 +2403,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link", - "windows-result", - "windows-strings", -] - [[package]] name = "windows-result" version = "0.4.1" diff --git a/README.md b/README.md index e0a19c8..4b5cb69 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,8 @@ Rampart filters traffic at kernel level (XDP/eBPF), network level (PoW challenge ``` 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 @@ -120,6 +122,8 @@ Rampart — 6-слойная система DDoS-защиты для Minecraft. ``` Слой 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 @@ -155,6 +159,11 @@ Rampart — 6-слойная система DDoS-защиты для Minecraft. | Боты (физика) | Falling check + Vehicle check | 4 | | AI-боты | PoW (CPU cost) + reputation | 2+6 | +> **Примечание:** Layer 2 (PoW) **выключен по умолчанию** (`pow.enabled = false`) из-за +> несовместимости с ванильными клиентами: текстовый challenge отправляется до handshake, +> и ванильный клиент не умеет его решать — при включении никто не сможет зайти. +> Включать только после появления клиентского мода или PoW, совместимого с протоколом Minecraft. + ### Быстрый старт ```bash diff --git a/TODO.md b/TODO.md index 8132aa5..2313338 100644 --- a/TODO.md +++ b/TODO.md @@ -56,17 +56,99 @@ | Компонент | Статус | |-----------|--------| -| **xdp/xdp_filter.c** | 0% — пустой каталог | -| **PoW Challenge (Layer 2)** | 0% — нужно писать | +| **Аудит-фикс v0.3** | **открыт** — все P0/P1/P2 из code review 2026-08 (см. сек. 2) | | **GeoIP/ASN reputation** | 0% — enum есть, реализации нет | -| **Velocity physics** (falling + protocol + vehicle) | 0% | -| **Traffic Intelligence (Layer 6)** | 0% — EWMA, 168h profiling | -| **ClickHouse + Grafana** | 0% | | **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. 6-слойная архитектура (план) +## 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), `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-слойная архитектура (план) ``` Layer 1: XDP/eBPF (C) TCP state machine, SYN throttle, blacklist, UDP drop @@ -79,7 +161,7 @@ Layer 6: Traffic Intel (Rust) EWMA thresholds, 168h profiling, reputation --- -## 3. Этапы разработки +## 5. Этапы разработки ### Этап 4: XDP/eBPF — Layer 1 (сейчас) @@ -185,7 +267,7 @@ Layer 6: Traffic Intel (Rust) EWMA thresholds, 168h profiling, reputation --- -## 4. Backlog +## 6. Backlog - [ ] Bedrock / RakNet (UDP модуль) - [ ] Plugin API через WASM (как Infrarust) @@ -196,21 +278,25 @@ Layer 6: Traffic Intel (Rust) EWMA thresholds, 168h profiling, reputation --- -## 5. Definition of Done +## 7. Definition of Done ``` ☐ cargo check / cargo test проходят ☐ cargo clippy -- -D warnings — 0 warnings ☐ cargo fmt --check проходит ☐ Unit тесты покрывают happy path + 2+ error cases -☐ Интеграционный тест проходит +☐ Интеграционный тест проходит (PoW+handshake, XDP smoke, Redis sync) +☐ Нет мёртвого кода: каждый pub-модуль/конфиг-поле/метрика имеют потребителя +☐ Нет дефолтных секретов/паролей (grep-чек в CI) +☐ Docker-образ собирает те же features, что CI (включая XDP) +☐ README соответствует коду (нет «бумажных» цифр/слоёв) ☐ Документация обновлена ☐ CI зелёный ``` --- -## 6. Anti-Patterns +## 8. Anti-Patterns ``` ❌ Тесты после кода. Пиши до (TDD) или вместе. @@ -221,8 +307,15 @@ Layer 6: Traffic Intel (Rust) EWMA thresholds, 168h profiling, reputation ❌ async где хватит sync. ❌ Секреты в репозитории. Используй .env + SOPS. ❌ Игнор compiler warnings. +❌ Мёртвый код: pub без вызовов, конфиг-поле без потребителя, метрика без writer. +❌ «Бумажный слой»: фича в README/архитектуре, которой нет в коде или она не вызывается. +❌ Дефолтный секрет: `changeme`/`password="..."` в коде или конфиге. +❌ Парсер за «один read» — MC-пакет может прийти фрагментами. +❌ Feature flag, который не собирается в Docker/CI — фичи нет. ``` +> Статус секций 5–8: план на будущее. Актуальный приоритет — **Аудит-фикс v0.3 (сек. 2)**: закрыть P0/P1/P2 до релиза. + --- -*Версия: 2.0 | Обновлён: июль 2026* +*Версия: 3.0 | Обновлён: август 2026 (аудит-фикс v0.3)* diff --git a/clippy.toml b/clippy.toml index 4a4f006..044fd69 100644 --- a/clippy.toml +++ b/clippy.toml @@ -13,8 +13,10 @@ stack-size-threshold = 512000 literal-representation-threshold = 120 # --- Разрешённое --- +# allow-unwrap-in-tests=false: чтобы `unwrap_used = "deny"` из workspace Cargo.toml +# реально срабатывал и в тестах (дефолт clippy = true, молча глушит deny). allow-expect-in-tests = true -allow-unwrap-in-tests = true +allow-unwrap-in-tests = false allow-dbg-in-tests = false allow-print-in-tests = false allow-panic-in-tests = false diff --git a/crates/rampart-cli/Cargo.toml b/crates/rampart-cli/Cargo.toml index 2c3edfa..25b1674 100644 --- a/crates/rampart-cli/Cargo.toml +++ b/crates/rampart-cli/Cargo.toml @@ -14,4 +14,4 @@ serde_json.workspace = true tracing.workspace = true anyhow.workspace = true clap.workspace = true -reqwest = { version = "0.12", features = ["json"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } diff --git a/crates/rampart-core/src/config.rs b/crates/rampart-core/src/config.rs index 88ff624..fc5a945 100644 --- a/crates/rampart-core/src/config.rs +++ b/crates/rampart-core/src/config.rs @@ -65,17 +65,24 @@ pub struct HmacConfig { pub secret: String, #[serde(default = "default_key_rotation")] pub key_rotation_interval_secs: u64, + #[serde(default = "default_signature_ttl")] + pub signature_ttl_secs: u64, } fn default_key_rotation() -> u64 { 3600 } +fn default_signature_ttl() -> u64 { + 60 +} + impl Default for HmacConfig { fn default() -> Self { Self { secret: String::new(), key_rotation_interval_secs: 3600, + signature_ttl_secs: 60, } } } @@ -262,7 +269,12 @@ pub struct PowConfig { } fn default_pow_enabled() -> bool { - true + // PoW выключен по умолчанию: текущий текстовый challenge отправляется до + // handshake и несовместим с ванильными MC-клиентами — они не умеют его + // решать, поэтому при enabled=true никто не сможет зайти на сервер. + // Включать только после появления клиентского мода или PoW, совместимого + // с протоколом Minecraft. + false } fn default_pow_difficulty() -> u8 { @@ -272,7 +284,7 @@ fn default_pow_difficulty() -> u8 { impl Default for PowConfig { fn default() -> Self { Self { - enabled: true, + enabled: false, difficulty: 4, } } diff --git a/crates/rampart-core/src/crypto/hmac.rs b/crates/rampart-core/src/crypto/hmac.rs index 36b7545..a652fa4 100644 --- a/crates/rampart-core/src/crypto/hmac.rs +++ b/crates/rampart-core/src/crypto/hmac.rs @@ -1,73 +1,153 @@ use hmac::{Hmac, Mac}; use sha2::Sha256; +use std::time::{SystemTime, UNIX_EPOCH}; use subtle::ConstantTimeEq; type HmacSha256 = Hmac; -pub fn sign(hostname: &str, secret: &[u8]) -> String { - let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts any key length"); - mac.update(hostname.as_bytes()); +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()) } -pub fn verify(hostname: &str, provided_sig: &str, secret: &[u8]) -> bool { - let expected = sign(hostname, secret); - expected.as_bytes().ct_eq(provided_sig.as_bytes()).into() +fn derive_key(secret: &[u8], bucket: u64) -> Vec { + 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() } -pub fn sign_hostname(raw: &str, secret: &[u8]) -> String { +/// Подписывает hostname-поле: `domain\0shield\0\0`. +/// +/// `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 sig = sign(domain, secret); - format!("{raw}\0shield\0{sig}") + 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}") } -pub fn parse_hostname(raw: &str) -> (String, Option) { - let parts: Vec<&str> = raw.split('\0').collect(); - let domain = parts[0].to_string(); - let hmac = parts - .iter() - .position(|&p| p == "shield") - .and_then(|i| parts.get(i + 1)) - .map(|s| s.to_string()); - (domain, hmac) +/// Проверяет подпись hostname-поля по спецификации. +/// +/// Парсит `domain\0shield\0\0`, проверяет `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() { - let secret = b"test_secret_32_bytes_long_here!!"; - let hostname = "play.example.com"; - let sig = sign(hostname, secret); - assert!(verify(hostname, &sig, secret)); + 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 secret = b"test_secret_32_bytes_long_here!!"; + let signed = sign_hostname("play.example.com", SECRET, 3600); let wrong = b"wrong_secret_32_bytes_long_here!!!"; - let hostname = "play.example.com"; - let sig = sign(hostname, wrong); - assert!(!verify(hostname, &sig, secret)); + assert!(!verify_hostname(&signed, wrong, 3600, 60)); } #[test] - fn test_sign_hostname_suffix() { - let secret = b"test_secret"; - let result = sign_hostname("play.example.com", secret); - assert!(result.starts_with("play.example.com\0shield\0")); - let sig = result.split("\0shield\0").nth(1).unwrap(); - assert_eq!(sig.len(), 64); + 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_constant_time() { - let secret = b"test_secret_32_bytes_long_here!!"; - let hostname = "play.example.com"; - let sig = sign(hostname, secret); - assert!(!verify("play.example.co", &sig, secret)); - assert!(verify(hostname, &sig, secret)); + 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)); } } diff --git a/crates/rampart-core/src/filter/blacklist.rs b/crates/rampart-core/src/filter/blacklist.rs index 844cd99..ff17d8a 100644 --- a/crates/rampart-core/src/filter/blacklist.rs +++ b/crates/rampart-core/src/filter/blacklist.rs @@ -1,4 +1,5 @@ use dashmap::DashMap; +use std::net::IpAddr; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -8,7 +9,7 @@ struct BanEntry { } pub struct Blacklist { - entries: Arc>, + entries: Arc>, } impl Default for Blacklist { @@ -24,7 +25,7 @@ impl Blacklist { } } - pub fn is_blocked(&self, ip: u32) -> bool { + pub fn is_blocked(&self, ip: IpAddr) -> bool { if let Some(entry) = self.entries.get(&ip) { if entry.expires > Instant::now() { return true; @@ -35,7 +36,7 @@ impl Blacklist { false } - pub fn add(&self, ip: u32, duration: Duration, reason: &str) { + pub fn add(&self, ip: IpAddr, duration: Duration, reason: &str) { self.entries.insert( ip, BanEntry { @@ -45,7 +46,7 @@ impl Blacklist { ); } - pub fn remove(&self, ip: u32) { + pub fn remove(&self, ip: IpAddr) { self.entries.remove(&ip); } @@ -65,34 +66,39 @@ impl Blacklist { #[cfg(test)] mod tests { use super::*; + use std::net::{IpAddr, Ipv4Addr}; + + fn ip(octets: [u8; 4]) -> IpAddr { + IpAddr::V4(Ipv4Addr::from(octets)) + } #[test] fn test_blacklist_block() { let bl = Blacklist::new(); - bl.add(0x01020304, Duration::from_secs(60), "test"); - assert!(bl.is_blocked(0x01020304)); + bl.add(ip([1, 2, 3, 4]), Duration::from_secs(60), "test"); + assert!(bl.is_blocked(ip([1, 2, 3, 4]))); } #[test] fn test_blacklist_not_blocked() { let bl = Blacklist::new(); - bl.add(0x01020304, Duration::from_secs(60), "test"); - assert!(!bl.is_blocked(0x05060708)); + bl.add(ip([1, 2, 3, 4]), Duration::from_secs(60), "test"); + assert!(!bl.is_blocked(ip([5, 6, 7, 8]))); } #[test] fn test_blacklist_expired() { let bl = Blacklist::new(); - bl.add(0x01020304, Duration::from_millis(1), "test"); + bl.add(ip([1, 2, 3, 4]), Duration::from_millis(1), "test"); std::thread::sleep(Duration::from_millis(2)); - assert!(!bl.is_blocked(0x01020304)); + assert!(!bl.is_blocked(ip([1, 2, 3, 4]))); } #[test] fn test_blacklist_remove() { let bl = Blacklist::new(); - bl.add(0x01020304, Duration::from_secs(60), "test"); - bl.remove(0x01020304); - assert!(!bl.is_blocked(0x01020304)); + bl.add(ip([1, 2, 3, 4]), Duration::from_secs(60), "test"); + bl.remove(ip([1, 2, 3, 4])); + assert!(!bl.is_blocked(ip([1, 2, 3, 4]))); } } diff --git a/crates/rampart-core/src/filter/rate_limit.rs b/crates/rampart-core/src/filter/rate_limit.rs index f153495..6ddee1e 100644 --- a/crates/rampart-core/src/filter/rate_limit.rs +++ b/crates/rampart-core/src/filter/rate_limit.rs @@ -1,40 +1,59 @@ use dashmap::DashMap; +use std::net::IpAddr; use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{Duration, Instant}; +const MAX_BUCKETS: usize = 1_000_000; +const EVICTION_IDLE: Duration = Duration::from_secs(600); +const SWEEP_INTERVAL: Duration = Duration::from_secs(60); + struct Bucket { tokens: f64, last_refill: Instant, + last_access: Instant, } pub struct RateLimiter { - buckets: Arc>, + buckets: Arc>, max_tokens: f64, refill_rate: f64, _refill_interval: Duration, + epoch: Instant, + last_sweep_elapsed: AtomicU64, + eviction_idle: Duration, } impl RateLimiter { pub fn new(rate_per_sec: f64, burst: f64) -> Self { + Self::with_eviction(rate_per_sec, burst, EVICTION_IDLE) + } + + fn with_eviction(rate_per_sec: f64, burst: f64, eviction_idle: Duration) -> Self { Self { buckets: Arc::new(DashMap::new()), max_tokens: burst, refill_rate: rate_per_sec, _refill_interval: Duration::from_secs(1), + epoch: Instant::now(), + last_sweep_elapsed: AtomicU64::new(0), + eviction_idle, } } - pub fn check(&self, ip: u32) -> bool { + pub fn check(&self, ip: IpAddr) -> bool { + let now = Instant::now(); let mut entry = self.buckets.entry(ip).or_insert_with(|| Bucket { tokens: self.max_tokens, - last_refill: Instant::now(), + last_refill: now, + last_access: now, }); - let now = Instant::now(); let elapsed = now.duration_since(entry.last_refill); let refill = elapsed.as_secs_f64() * self.refill_rate; entry.tokens = (entry.tokens + refill).min(self.max_tokens); entry.last_refill = now; + entry.last_access = now; if entry.tokens >= 1.0 { entry.tokens -= 1.0; @@ -51,40 +70,83 @@ impl RateLimiter { pub fn is_empty(&self) -> bool { self.buckets.is_empty() } + + /// Эвиктит простаивающие бакеты. Запускается, когда бакетов больше + /// MAX_BUCKETS либо по расписанию (раз в SWEEP_INTERVAL). + pub fn sweep(&self) { + let now = Instant::now(); + let elapsed_secs = now.duration_since(self.epoch).as_secs(); + let last = self.last_sweep_elapsed.load(Ordering::Relaxed); + let due = last == 0 || elapsed_secs.saturating_sub(last) >= SWEEP_INTERVAL.as_secs(); + let over_cap = self.buckets.len() > MAX_BUCKETS; + if !over_cap && !due { + return; + } + self.buckets + .retain(|_, b| now.duration_since(b.last_access) < self.eviction_idle); + self.last_sweep_elapsed.store(elapsed_secs, Ordering::Relaxed); + } } #[cfg(test)] mod tests { use super::*; + use std::net::{IpAddr, Ipv4Addr}; + + fn test_ip(octet: u8) -> IpAddr { + IpAddr::V4(Ipv4Addr::new(10, 0, 0, octet)) + } #[test] fn test_rate_limit_under() { let limiter = RateLimiter::new(10.0, 10.0); - assert!(limiter.check(1)); + assert!(limiter.check(test_ip(1))); } #[test] fn test_rate_limit_over() { let limiter = RateLimiter::new(1.0, 1.0); - assert!(limiter.check(1)); - assert!(!limiter.check(1)); + assert!(limiter.check(test_ip(1))); + assert!(!limiter.check(test_ip(1))); } #[test] fn test_rate_limit_burst() { let limiter = RateLimiter::new(1.0, 5.0); for _ in 0..5 { - assert!(limiter.check(2)); + assert!(limiter.check(test_ip(2))); } - assert!(!limiter.check(2)); + assert!(!limiter.check(test_ip(2))); } #[test] fn test_rate_limit_refill() { let limiter = RateLimiter::new(100.0, 1.0); - assert!(limiter.check(3)); - assert!(!limiter.check(3)); + assert!(limiter.check(test_ip(3))); + assert!(!limiter.check(test_ip(3))); std::thread::sleep(Duration::from_millis(20)); - assert!(limiter.check(3)); + assert!(limiter.check(test_ip(3))); + } + + #[test] + fn test_sweep_removes_idle_keeps_active() { + let limiter = RateLimiter::with_eviction(1.0, 10.0, Duration::from_millis(20)); + limiter.check(test_ip(1)); + limiter.check(test_ip(2)); + std::thread::sleep(Duration::from_millis(50)); + limiter.check(test_ip(2)); + limiter.sweep(); + assert_eq!(limiter.len(), 1); + assert!(!limiter.buckets.contains_key(&test_ip(1))); + assert!(limiter.buckets.contains_key(&test_ip(2))); + } + + #[test] + fn test_sweep_does_not_remove_active() { + let limiter = RateLimiter::with_eviction(1.0, 10.0, Duration::from_millis(50)); + limiter.check(test_ip(1)); + limiter.sweep(); + assert_eq!(limiter.len(), 1); + assert!(limiter.buckets.contains_key(&test_ip(1))); } } diff --git a/crates/rampart-core/src/lib.rs b/crates/rampart-core/src/lib.rs index 4c7a183..56138cf 100644 --- a/crates/rampart-core/src/lib.rs +++ b/crates/rampart-core/src/lib.rs @@ -6,6 +6,4 @@ pub mod pow; pub mod proxy; pub mod store; pub mod traffic; - -#[cfg(feature = "xdp")] pub mod xdp; diff --git a/crates/rampart-core/src/main.rs b/crates/rampart-core/src/main.rs index a29bf5c..18a0e3f 100644 --- a/crates/rampart-core/src/main.rs +++ b/crates/rampart-core/src/main.rs @@ -4,11 +4,26 @@ 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() @@ -17,6 +32,7 @@ async fn main() -> anyhow::Result<()> { 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( @@ -24,6 +40,9 @@ async fn main() -> anyhow::Result<()> { 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); @@ -64,35 +83,135 @@ async fn main() -> anyhow::Result<()> { }); } - #[cfg(feature = "xdp")] - if config.xdp.enabled { - use rampart_core::xdp::{XdpFilter, XdpMetrics}; + let clickhouse: Option>> = 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, + }; - let mut filter = XdpFilter::new(&config.xdp.interface); - filter.load()?; + #[cfg(feature = "xdp")] + let xdp_filter: Option>> = 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() { - filter.drain_events(); - if let Ok(stats) = filter.get_stats() { + 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)); } - filter.unload().ok(); + if let Ok(mut guard) = shared_thread.lock() { + guard.unload().ok(); + } }); - } + Some(shared) + } else { + None + }; + + #[cfg(not(feature = "xdp"))] + let xdp_filter: Option>> = 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); + 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>> { + 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()) diff --git a/crates/rampart-core/src/metrics.rs b/crates/rampart-core/src/metrics.rs index 3245afe..245567d 100644 --- a/crates/rampart-core/src/metrics.rs +++ b/crates/rampart-core/src/metrics.rs @@ -33,6 +33,14 @@ pub static POW_CURRENT_DIFFICULTY: LazyLock = LazyLock::new(|| { register_int_gauge!("rampart_pow_current_difficulty", "Current PoW difficulty").expect("POW_CURRENT_DIFFICULTY") }); +pub static ATTACK_STATUS: LazyLock = LazyLock::new(|| { + register_int_gauge!( + "rampart_attack_status", + "Attack detector status (0=normal, 1=suspicious, 2=under attack)" + ) + .expect("ATTACK_STATUS") +}); + pub async fn run_metrics_server(addr: &str) { let listener = match TcpListener::bind(addr).await { Ok(l) => l, diff --git a/crates/rampart-core/src/proxy/handshake.rs b/crates/rampart-core/src/proxy/handshake.rs index f44e722..e153cd1 100644 --- a/crates/rampart-core/src/proxy/handshake.rs +++ b/crates/rampart-core/src/proxy/handshake.rs @@ -124,25 +124,25 @@ mod tests { #[test] fn test_varint_zero() { let buf = vec![0x00]; - assert_eq!(read_varint(&buf, 0).unwrap(), (0, 1)); + 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).unwrap(), (127, 1)); + 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).unwrap(), (128, 2)); + 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).unwrap(), (i32::MAX, 5)); + assert_eq!(read_varint(&buf, 0).expect("varint should parse"), (i32::MAX, 5)); } #[test] @@ -173,7 +173,7 @@ mod tests { let len = (buf.len() - 1) as u8; buf[0] = len; - let hs = McHandshake::parse(&buf).unwrap(); + 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); @@ -206,7 +206,7 @@ mod tests { let len = (buf.len() - 1) as u8; buf[0] = len; - let hs = McHandshake::parse(&buf).unwrap(); + 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); diff --git a/crates/rampart-core/src/proxy/listener.rs b/crates/rampart-core/src/proxy/listener.rs index 4887cbd..1bb4457 100644 --- a/crates/rampart-core/src/proxy/listener.rs +++ b/crates/rampart-core/src/proxy/listener.rs @@ -3,9 +3,16 @@ 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 { @@ -13,6 +20,11 @@ pub struct ProxyListener { rate_limiter: Arc, blacklist: Arc, adjuster: Arc>, + whitelist: Arc>, + reputation: Arc, + xdp: Option>>, + clickhouse: Option>>, + allowed_1s: Arc, } impl ProxyListener { @@ -21,12 +33,22 @@ impl ProxyListener { rate_limiter: Arc, blacklist: Arc, adjuster: Arc>, + whitelist: Arc>, + reputation: Arc, + xdp: Option>>, + clickhouse: Option>>, + allowed_1s: Arc, ) -> Self { Self { config, rate_limiter, blacklist, adjuster, + whitelist, + reputation, + xdp, + clickhouse, + allowed_1s, } } @@ -41,6 +63,11 @@ impl ProxyListener { 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, @@ -48,6 +75,11 @@ impl ProxyListener { rate_limiter, blacklist, adjuster, + whitelist, + reputation, + xdp, + clickhouse, + allowed_1s, shutdown, ))); } @@ -69,12 +101,18 @@ fn build_listener(addr: std::net::SocketAddr) -> anyhow::Result { Ok(TcpListener::from_std(socket.into())?) } +#[allow(clippy::too_many_arguments)] async fn accept_loop( listener: TcpListener, config: Arc, rate_limiter: Arc, blacklist: Arc, adjuster: Arc>, + whitelist: Arc>, + reputation: Arc, + xdp: Option>>, + clickhouse: Option>>, + allowed_1s: Arc, mut shutdown: watch::Receiver, ) -> anyhow::Result<()> { loop { @@ -94,7 +132,17 @@ async fn accept_loop( continue; } }; - let handler = ConnectionHandler::new(config.clone(), rate_limiter.clone(), blacklist.clone(), adjuster.clone()); + 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}"); diff --git a/crates/rampart-core/src/proxy/pow.rs b/crates/rampart-core/src/proxy/pow.rs index 43bf3ff..8002f7c 100644 --- a/crates/rampart-core/src/proxy/pow.rs +++ b/crates/rampart-core/src/proxy/pow.rs @@ -1,10 +1,10 @@ use crate::pow::challenge::Challenge; -use std::net::Ipv4Addr; +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: Ipv4Addr, difficulty: u8) -> anyhow::Result { +pub async fn handle_pow(stream: &mut TcpStream, peer_ip: IpAddr, difficulty: u8) -> anyhow::Result { if difficulty == 0 { tracing::debug!("pow: difficulty 0, skipping for {peer_ip}"); return Ok(true); diff --git a/crates/rampart-core/src/proxy/tunnel.rs b/crates/rampart-core/src/proxy/tunnel.rs index 8a0b0ee..ee15278 100644 --- a/crates/rampart-core/src/proxy/tunnel.rs +++ b/crates/rampart-core/src/proxy/tunnel.rs @@ -5,54 +5,71 @@ use crate::filter::death_code; use crate::filter::rate_limit::RateLimiter; use crate::metrics; use crate::pow::difficulty::DifficultyAdjuster; -use crate::proxy::handshake::{McHandshake, read_varint}; +use crate::proxy::handshake::{McHandshake, ParseError, read_varint}; use crate::proxy::pow::handle_pow; -use std::net::Ipv4Addr; +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, rate_limiter: Arc, blacklist: Arc, adjuster: Arc>, + whitelist: Arc>, + reputation: Arc, + xdp: Option>>, + clickhouse: Option>>, + allowed_1s: Arc, } impl ConnectionHandler { + #[allow(clippy::too_many_arguments)] pub fn new( config: Arc, rate_limiter: Arc, blacklist: Arc, adjuster: Arc>, + whitelist: Arc>, + reputation: Arc, + xdp: Option>>, + clickhouse: Option>>, + allowed_1s: Arc, ) -> Self { Self { config, rate_limiter, blacklist, adjuster, - } - } - - fn ip_to_u32(addr: std::net::SocketAddr) -> u32 { - match addr.ip() { - std::net::IpAddr::V4(ip) => ip.to_bits(), - _ => 0, + whitelist, + reputation, + xdp, + clickhouse, + allowed_1s, } } pub async fn handle(&self, mut client: TcpStream, peer_addr: std::net::SocketAddr) -> anyhow::Result<()> { - let ip_u32 = Self::ip_to_u32(peer_addr); + let peer_ip = peer_addr.ip(); - if self.blacklist.is_blocked(ip_u32) { + if self.blacklist.is_blocked(peer_ip) { metrics::CONNECTIONS_TOTAL.with_label_values(&["blocked"]).inc(); return Ok(()); } let pow_config = &self.config.pow; - let peer_ip = Ipv4Addr::from_bits(ip_u32); - if pow_config.enabled && pow_config.difficulty > 0 && !self.config.whitelist.contains(&peer_ip.to_string()) { + if pow_config.enabled && pow_config.difficulty > 0 && !self.whitelist.contains(&peer_ip) { self.adjuster .lock() .expect("adjuster lock poisoned") @@ -75,35 +92,45 @@ impl ConnectionHandler { metrics::POW_CURRENT_DIFFICULTY.set(pow_config.difficulty as i64); } - if !self.rate_limiter.check(ip_u32) { - metrics::RATE_LIMIT_HITS.with_label_values(&["hit"]).inc(); - metrics::CONNECTIONS_TOTAL.with_label_values(&["blocked"]).inc(); + 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![0u8; 4096]; - let n = tokio::time::timeout(timeout, client.read(&mut buf)).await??; - if n == 0 { - return Ok(()); + let mut buf: Vec = 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[..n]); + let parsed = McHandshake::parse(&buf); match parsed { Ok(handshake) => { - if !self.rate_limiter.check(ip_u32) { - metrics::RATE_LIMIT_HITS.with_label_values(&["hit"]).inc(); - metrics::CONNECTIONS_TOTAL.with_label_values(&["blocked"]).inc(); + 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()); - let modified = replace_hostname(&buf[..n], &handshake.server_address, &signed)?; + 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?; @@ -111,19 +138,114 @@ impl ConnectionHandler { Err(e) => { tracing::debug!("parse error from {peer_addr}: {e}"); metrics::CONNECTIONS_TOTAL.with_label_values(&["blocked"]).inc(); - - if self.config.death_code.enabled { - if let Some(code) = death_code::detect(&buf[..n]) { - let duration = Duration::from_secs(self.config.death_code.ban_duration_secs); - self.blacklist.add(ip_u32, duration, code.as_str()); - metrics::DEATH_CODE_BANS_TOTAL.with_label_values(&[code.as_str()]).inc(); - tracing::info!("death code ban {peer_addr}: {}", code.as_str()); - } - } + 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) -> anyhow::Result { + 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> { @@ -197,10 +319,10 @@ mod tests { 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).unwrap(); + let modified = replace_hostname(&pkt, "play.example.com", new_hostname).expect("should replace hostname"); assert!(modified.len() > pkt.len()); - let parsed = McHandshake::parse(&modified).unwrap(); + let parsed = McHandshake::parse(&modified).expect("signed hostname should parse"); assert_eq!(parsed.server_address, new_hostname); } @@ -208,10 +330,11 @@ mod tests { 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).unwrap(); + 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).unwrap(); + let parsed = McHandshake::parse(&modified).expect("short hostname should parse"); assert_eq!(parsed.server_address, new_hostname); } @@ -219,9 +342,9 @@ mod tests { 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).unwrap(); + let modified = replace_hostname(&pkt, "mc.example.com", new_hostname).expect("should replace hostname"); - let parsed = McHandshake::parse(&modified).unwrap(); + 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()); @@ -232,7 +355,7 @@ mod tests { 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).unwrap(); + let (decoded, _) = read_varint(&bytes, 0).expect("varint should parse"); assert_eq!(decoded, val, "roundtrip failed for {val}"); } } diff --git a/crates/rampart-core/src/store/redis.rs b/crates/rampart-core/src/store/redis.rs index bca0686..58eb760 100644 --- a/crates/rampart-core/src/store/redis.rs +++ b/crates/rampart-core/src/store/redis.rs @@ -86,23 +86,15 @@ fn handle_event(msg: &Msg, blacklist: &Blacklist) -> anyhow::Result<()> { let payload: String = msg.get_payload()?; let event: BlacklistEvent = serde_json::from_str(&payload)?; - let ip_parts: Vec<&str> = event.ip.split('.').collect(); - if ip_parts.len() != 4 { - anyhow::bail!("invalid IP: {}", event.ip); - } - let mut ip_u32: u32 = 0; - for part in &ip_parts { - let octet: u32 = part.parse()?; - ip_u32 = (ip_u32 << 8) | octet; - } + let ip: std::net::IpAddr = event.ip.parse()?; match event.action.as_str() { "ban" => { - blacklist.add(ip_u32, Duration::from_secs(event.duration_secs), "redis"); + blacklist.add(ip, Duration::from_secs(event.duration_secs), "redis"); tracing::info!("blacklist add via Redis: {}", event.ip); }, "unban" => { - blacklist.remove(ip_u32); + blacklist.remove(ip); tracing::info!("blacklist remove via Redis: {}", event.ip); }, a => anyhow::bail!("unknown action: {a}"), diff --git a/crates/rampart-core/src/traffic/reputation.rs b/crates/rampart-core/src/traffic/reputation.rs index 4564cb7..e7f12a7 100644 --- a/crates/rampart-core/src/traffic/reputation.rs +++ b/crates/rampart-core/src/traffic/reputation.rs @@ -1,9 +1,9 @@ use dashmap::DashMap; -use std::net::Ipv4Addr; +use std::net::IpAddr; use std::sync::Arc; pub struct IpReputation { - scores: Arc>, + scores: Arc>, } impl Default for IpReputation { @@ -19,21 +19,21 @@ impl IpReputation { } } - pub fn record_good(&self, ip: Ipv4Addr) { + pub fn record_good(&self, ip: IpAddr) { let mut entry = self.scores.entry(ip).or_insert(0); *entry = (*entry + 1).min(100); } - pub fn record_bad(&self, ip: Ipv4Addr) { + pub fn record_bad(&self, ip: IpAddr) { let mut entry = self.scores.entry(ip).or_insert(0); *entry = (*entry - 10).max(-100); } - pub fn score(&self, ip: Ipv4Addr) -> i32 { + pub fn score(&self, ip: IpAddr) -> i32 { self.scores.get(&ip).map(|v| *v).unwrap_or(0) } - pub fn is_trusted(&self, ip: Ipv4Addr) -> bool { + pub fn is_trusted(&self, ip: IpAddr) -> bool { self.score(ip) > 50 } } @@ -41,18 +41,22 @@ impl IpReputation { #[cfg(test)] mod tests { use super::*; - use std::net::Ipv4Addr; + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + + fn v4(octets: [u8; 4]) -> IpAddr { + IpAddr::V4(Ipv4Addr::from(octets)) + } #[test] fn test_reputation_initial_score() { let rep = IpReputation::new(); - assert_eq!(rep.score(Ipv4Addr::new(192, 168, 1, 1)), 0); + assert_eq!(rep.score(v4([192, 168, 1, 1])), 0); } #[test] fn test_reputation_good() { let rep = IpReputation::new(); - let ip = Ipv4Addr::new(10, 0, 0, 1); + let ip = v4([10, 0, 0, 1]); rep.record_good(ip); assert_eq!(rep.score(ip), 1); } @@ -60,7 +64,7 @@ mod tests { #[test] fn test_reputation_bad() { let rep = IpReputation::new(); - let ip = Ipv4Addr::new(10, 0, 0, 2); + let ip = v4([10, 0, 0, 2]); rep.record_bad(ip); assert_eq!(rep.score(ip), -10); } @@ -68,7 +72,7 @@ mod tests { #[test] fn test_reputation_cap_positive() { let rep = IpReputation::new(); - let ip = Ipv4Addr::new(10, 0, 0, 3); + let ip = v4([10, 0, 0, 3]); for _ in 0..200 { rep.record_good(ip); } @@ -78,7 +82,7 @@ mod tests { #[test] fn test_reputation_cap_negative() { let rep = IpReputation::new(); - let ip = Ipv4Addr::new(10, 0, 0, 4); + let ip = v4([10, 0, 0, 4]); for _ in 0..20 { rep.record_bad(ip); } @@ -88,11 +92,19 @@ mod tests { #[test] fn test_is_trusted() { let rep = IpReputation::new(); - let ip = Ipv4Addr::new(10, 0, 0, 5); + let ip = v4([10, 0, 0, 5]); assert!(!rep.is_trusted(ip)); for _ in 0..51 { rep.record_good(ip); } assert!(rep.is_trusted(ip)); } + + #[test] + fn test_reputation_v6() { + let rep = IpReputation::new(); + let ip = IpAddr::V6(Ipv6Addr::LOCALHOST); + rep.record_bad(ip); + assert_eq!(rep.score(ip), -10); + } } diff --git a/crates/rampart-core/src/xdp/filter.rs b/crates/rampart-core/src/xdp/filter.rs index 6390209..6c9c996 100644 --- a/crates/rampart-core/src/xdp/filter.rs +++ b/crates/rampart-core/src/xdp/filter.rs @@ -77,7 +77,7 @@ impl XdpFilter { .with_context(|| format!("map '{}' not found", name)) } - pub fn ban_ip(&self, ip: Ipv4Addr) -> Result<()> { + pub fn ban_ip(&self, ip: Ipv4Addr, duration_secs: u64) -> Result<()> { let map = self.find_map("blacklist_map")?; let mut key = [0u8; 8]; key[0] = 32; @@ -86,7 +86,11 @@ impl XdpFilter { .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_nanos() as u64; - map.update(&key, &(now + 300_000_000_000).to_le_bytes(), MapFlags::ANY)?; + map.update( + &key, + &(now + duration_secs * 1_000_000_000).to_le_bytes(), + MapFlags::ANY, + )?; Ok(()) } diff --git a/crates/rampart-core/src/xdp/noop.rs b/crates/rampart-core/src/xdp/noop.rs index cd162f1..bcf3636 100644 --- a/crates/rampart-core/src/xdp/noop.rs +++ b/crates/rampart-core/src/xdp/noop.rs @@ -14,7 +14,7 @@ impl XdpFilter { Ok(()) } pub fn drain_events(&self) {} - pub fn ban_ip(&self, _ip: Ipv4Addr) -> Result<()> { + pub fn ban_ip(&self, _ip: Ipv4Addr, _duration_secs: u64) -> Result<()> { Ok(()) } pub fn unban_ip(&self, _ip: Ipv4Addr) -> Result<()> { diff --git a/crates/rampart-manager/Cargo.toml b/crates/rampart-manager/Cargo.toml index d4d6c1b..72cee30 100644 --- a/crates/rampart-manager/Cargo.toml +++ b/crates/rampart-manager/Cargo.toml @@ -16,6 +16,7 @@ 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"] } diff --git a/crates/rampart-manager/src/api/auth.rs b/crates/rampart-manager/src/api/auth.rs index 1b6137d..50bcae8 100644 --- a/crates/rampart-manager/src/api/auth.rs +++ b/crates/rampart-manager/src/api/auth.rs @@ -1,21 +1,104 @@ use crate::AppState; -use axum::{Json, extract::State}; +use axum::{ + Json, + extract::{ConnectInfo, State}, + http::StatusCode, +}; +use dashmap::DashMap; use serde::Deserialize; -use std::sync::Arc; +use std::{ + net::{IpAddr, SocketAddr}, + sync::Arc, + time::{Duration, Instant}, +}; +use subtle::ConstantTimeEq; + +const LOGIN_WINDOW: Duration = Duration::from_secs(60); +const LOGIN_MAX_ATTEMPTS: u32 = 5; #[derive(Deserialize)] pub struct LoginRequest { pub password: String, } -pub async fn login(State(state): State>, Json(req): Json) -> Json { - let api_password = std::env::var("API_PASSWORD").unwrap_or_else(|_| "changeme".to_string()); - if req.password != api_password { - return Json(serde_json::json!({"error": "invalid password"})); +pub async fn login( + State(state): State>, + ConnectInfo(addr): ConnectInfo, + Json(req): Json, +) -> Result, (StatusCode, Json)> { + if !allow_login_attempt(&state.login_limiter, addr.ip()) { + return Err(( + StatusCode::TOO_MANY_REQUESTS, + Json(serde_json::json!({"error": "too many requests"})), + )); } - match crate::auth::create_token(&state.jwt_secret, state.jwt_expiration) { - Ok(token) => Json(serde_json::json!({"token": token})), - Err(_) => Json(serde_json::json!({"error": "token creation failed"})), + if !verify_password(&req.password, &state.api_password) { + return Err(( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({"error": "invalid password"})), + )); + } + + match crate::auth::create_token(&state.jwt_secret, state.jwt_expiration, &state.jwt_audience) { + Ok(token) => Ok(Json(serde_json::json!({"token": token}))), + Err(_) => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": "token creation failed"})), + )), + } +} + +fn verify_password(provided: &str, expected: &str) -> bool { + let provided = provided.as_bytes(); + let expected = expected.as_bytes(); + let len_match = (provided.len() as u64).ct_eq(&(expected.len() as u64)); + let min_len = provided.len().min(expected.len()); + let bytes_match = provided[..min_len].ct_eq(&expected[..min_len]); + bool::from(len_match & bytes_match) +} + +fn allow_login_attempt(limiter: &DashMap, ip: IpAddr) -> bool { + let now = Instant::now(); + let mut slot = limiter.entry(ip).or_insert((now, 0)); + let (last_reset, attempts) = &mut *slot; + if now.duration_since(*last_reset) >= LOGIN_WINDOW { + *last_reset = now; + *attempts = 1; + } else if *attempts >= LOGIN_MAX_ATTEMPTS { + return false; + } else { + *attempts += 1; + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn verify_password_matches() { + assert!(verify_password("s3cret-pass", "s3cret-pass")); + } + + #[test] + fn verify_password_rejects_wrong() { + assert!(!verify_password("wrong-pass", "s3cret-pass")); + } + + #[test] + fn verify_password_rejects_different_length() { + assert!(!verify_password("short", "a-longer-password")); + } + + #[test] + fn allow_login_attempt_respects_limit() { + let limiter = DashMap::new(); + let ip = IpAddr::from([127, 0, 0, 1]); + for _ in 0..LOGIN_MAX_ATTEMPTS { + assert!(allow_login_attempt(&limiter, ip)); + } + assert!(!allow_login_attempt(&limiter, ip)); } } diff --git a/crates/rampart-manager/src/auth.rs b/crates/rampart-manager/src/auth.rs index a408666..3a705c0 100644 --- a/crates/rampart-manager/src/auth.rs +++ b/crates/rampart-manager/src/auth.rs @@ -10,26 +10,28 @@ use std::sync::Arc; #[derive(Debug, Serialize, Deserialize)] pub struct Claims { pub sub: String, + pub aud: String, + pub role: String, pub exp: usize, pub iat: usize, } -pub fn create_token(secret: &str, expiration: u64) -> Result { +pub fn create_token(secret: &str, expiration: u64, audience: &str) -> Result { let now = chrono::Utc::now().timestamp() as usize; let claims = Claims { sub: "rampart-admin".to_string(), + aud: audience.to_string(), + role: "admin".to_string(), exp: now + expiration as usize, iat: now, }; encode(&Header::default(), &claims, &EncodingKey::from_secret(secret.as_ref())) } -pub fn verify_token(token: &str, secret: &str) -> Result { - let token_data = decode::( - token, - &DecodingKey::from_secret(secret.as_ref()), - &Validation::default(), - )?; +pub fn verify_token(token: &str, secret: &str, audience: &str) -> Result { + let mut validation = Validation::default(); + validation.set_audience(&[audience]); + let token_data = decode::(token, &DecodingKey::from_secret(secret.as_ref()), &validation)?; Ok(token_data.claims) } @@ -63,7 +65,7 @@ pub async fn auth_middleware( }, }; - if verify_token(token, &state.jwt_secret).is_err() { + if verify_token(token, &state.jwt_secret, &state.jwt_audience).is_err() { return Err(( StatusCode::UNAUTHORIZED, Json(serde_json::json!({"error": "unauthorized"})), @@ -72,3 +74,38 @@ pub async fn auth_middleware( Ok(next.run(request).await) } + +#[cfg(test)] +mod tests { + use super::*; + + fn valid_secret() -> String { + "this-is-a-test-secret-32-bytes-long!".to_string() + } + + #[test] + fn create_verify_roundtrip_passes() { + let secret = valid_secret(); + let token = create_token(&secret, 3600, "rampart").expect("token creation should succeed"); + let claims = verify_token(&token, &secret, "rampart").expect("verification should succeed"); + assert_eq!(claims.sub, "rampart-admin"); + assert_eq!(claims.aud, "rampart"); + assert_eq!(claims.role, "admin"); + assert!(claims.exp > claims.iat); + } + + #[test] + fn verify_rejects_wrong_audience() { + let secret = valid_secret(); + let token = create_token(&secret, 3600, "rampart").expect("token creation should succeed"); + assert!(verify_token(&token, &secret, "other").is_err()); + } + + #[test] + fn verify_rejects_wrong_secret() { + let secret = valid_secret(); + let other_secret = "another-test-secret-also-32-bytes-long!".to_string(); + let token = create_token(&secret, 3600, "rampart").expect("token creation should succeed"); + assert!(verify_token(&token, &other_secret, "rampart").is_err()); + } +} diff --git a/crates/rampart-manager/src/main.rs b/crates/rampart-manager/src/main.rs index 884dab1..734cbe7 100644 --- a/crates/rampart-manager/src/main.rs +++ b/crates/rampart-manager/src/main.rs @@ -1,8 +1,11 @@ use axum::{ - Router, middleware, + Router, + http::HeaderValue, + middleware, routing::{get, post}, }; -use std::sync::Arc; +use dashmap::DashMap; +use std::{net::IpAddr, sync::Arc, time::Instant}; use tower_http::cors::CorsLayer; use tracing_subscriber::EnvFilter; @@ -13,7 +16,10 @@ 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, } #[tokio::main] @@ -26,15 +32,27 @@ async fn main() -> anyhow::Result<()> { 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::() .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())); @@ -52,15 +70,22 @@ async fn main() -> anyhow::Result<()> { .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(CorsLayer::permissive()) + .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::(); axum::serve(listener, app).await?; Ok(()) } diff --git a/deny.toml b/deny.toml index dc75996..dbf7af2 100644 --- a/deny.toml +++ b/deny.toml @@ -36,9 +36,13 @@ ignore = true [bans] multiple-versions = "deny" -wildcards = "allow" +wildcards = "deny" highlight = "all" -deny = [] +# rustls-only крипта: не даём тянуть OpenSSL в дерево зависимостей +deny = [ + { name = "openssl-sys", version = "*" }, + { name = "openssl", version = "*" }, +] skip = [ { name = "hashbrown", version = "0.14" }, { name = "socket2", version = "0.5" }, @@ -49,8 +53,8 @@ skip = [ skip-tree = [] [sources] -unknown-registry = "warn" -unknown-git = "warn" +unknown-registry = "deny" +unknown-git = "deny" allow-registry = ["https://github.com/rust-lang/crates.io-index"] allow-git = [] diff --git a/deploy/config/edge.toml b/deploy/config/edge.toml index 4406732..ddd99cd 100644 --- a/deploy/config/edge.toml +++ b/deploy/config/edge.toml @@ -33,3 +33,7 @@ port = 9090 [xdp] enabled = false + +[pow] +enabled = false +difficulty = 4 diff --git a/deploy/docker/Dockerfile.edge b/deploy/docker/Dockerfile.edge index 1b1468c..d6a6c4a 100644 --- a/deploy/docker/Dockerfile.edge +++ b/deploy/docker/Dockerfile.edge @@ -1,17 +1,18 @@ FROM rust:slim-bookworm AS builder -RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y pkg-config libssl-dev clang libelf-dev libbpf-dev linux-libc-dev && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY Cargo.toml Cargo.lock rustfmt.toml ./ COPY crates/ ./crates/ +COPY xdp/ ./xdp/ -RUN cargo build --release --bin rampart-core && \ +RUN cargo build --release --features xdp --bin rampart-core && \ cp target/release/rampart-core /app/rampart-core && \ strip /app/rampart-core FROM debian:bookworm-slim -RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* +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 diff --git a/deploy/test/config.test.toml b/deploy/test/config.test.toml index 9073419..3ad4a9f 100644 --- a/deploy/test/config.test.toml +++ b/deploy/test/config.test.toml @@ -42,4 +42,4 @@ level = "debug" enabled = true difficulty = 4 -whitelist = ["localhost", "127.0.0.1"] +whitelist = ["127.0.0.1", "::1"] diff --git a/docs/configuration.md b/docs/configuration.md index 86189df..48b565e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -49,6 +49,15 @@ count = 4 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 diff --git a/plugins/paper/src/main/java/me/rampart/paper/HmacLoginListener.java b/plugins/paper/src/main/java/me/rampart/paper/HmacLoginListener.java index 7d3a2dc..f5fcce8 100644 --- a/plugins/paper/src/main/java/me/rampart/paper/HmacLoginListener.java +++ b/plugins/paper/src/main/java/me/rampart/paper/HmacLoginListener.java @@ -8,8 +8,6 @@ import org.bukkit.event.player.PlayerLoginEvent; public class HmacLoginListener implements Listener { - private static final int HEX_SIG_LENGTH = 64; - private final RampartPaper plugin; public HmacLoginListener(RampartPaper plugin) { @@ -21,28 +19,10 @@ public class HmacLoginListener implements Listener { String secretEnv = System.getenv("RAMPART_HMAC_SECRET"); if (secretEnv == null || secretEnv.isEmpty()) return; - byte[] secret = secretEnv.getBytes(); String raw = event.getHostname(); if (raw == null || raw.isEmpty()) return; - int sepIdx = raw.indexOf(RampartPaper.SHIELD_SEPARATOR); - if (sepIdx < 0) { - return; - } - - String domain = raw.substring(0, sepIdx); - String sig = raw.substring(sepIdx + RampartPaper.SHIELD_SEPARATOR.length()); - - if (sig.length() != HEX_SIG_LENGTH) { - plugin.getLogger().warning("Invalid sig length from " + event.getAddress() + - ": got " + sig.length() + ", expected " + HEX_SIG_LENGTH); - event.disallow(PlayerLoginEvent.Result.KICK_OTHER, - Component.text("Connection rejected: invalid signature")); - return; - } - - String expected = plugin.hmacHex(domain, secret); - if (expected == null || !plugin.constantTimeEquals(sig, expected)) { + if (!plugin.verifyHostname(raw)) { plugin.getLogger().warning("HMAC verification failed for " + event.getAddress()); event.disallow(PlayerLoginEvent.Result.KICK_OTHER, Component.text("Connection rejected: invalid signature")); diff --git a/plugins/paper/src/main/java/me/rampart/paper/RampartPaper.java b/plugins/paper/src/main/java/me/rampart/paper/RampartPaper.java index b164417..f5d775d 100644 --- a/plugins/paper/src/main/java/me/rampart/paper/RampartPaper.java +++ b/plugins/paper/src/main/java/me/rampart/paper/RampartPaper.java @@ -2,14 +2,25 @@ package me.rampart.paper; import org.bukkit.plugin.java.JavaPlugin; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; + public class RampartPaper extends JavaPlugin { static final String SHIELD_SEPARATOR = "\0shield\0"; + static final String HMAC_ALGO = "HmacSHA256"; + static final int HEX_SIG_LENGTH = 64; private ShieldAgent shieldAgent; + private long rotationSecs = 3600; + private long ttlSecs = 60; @Override public void onEnable() { + rotationSecs = envLong("RAMPART_HMAC_ROTATION_SECS", 3600); + ttlSecs = envLong("RAMPART_HMAC_TTL_SECS", 60); + String secret = System.getenv("RAMPART_HMAC_SECRET"); if (secret == null || secret.isEmpty()) { getLogger().warning("RAMPART_HMAC_SECRET not set — HMAC verification disabled"); @@ -35,22 +46,66 @@ public class RampartPaper extends JavaPlugin { } } - String hmacHex(String data, byte[] secret) { + boolean verifyHostname(String raw) { + String secretEnv = System.getenv("RAMPART_HMAC_SECRET"); + if (secretEnv == null || secretEnv.isEmpty()) return false; + return verifyHostname(raw, secretEnv.getBytes(StandardCharsets.UTF_8), rotationSecs, ttlSecs); + } + + private boolean verifyHostname(String raw, byte[] secret, long rotation, long ttl) { + int sepIdx = raw.indexOf(SHIELD_SEPARATOR); + if (sepIdx < 0) return false; + + String domain = raw.substring(0, sepIdx); + String rest = raw.substring(sepIdx + SHIELD_SEPARATOR.length()); + int tsSep = rest.indexOf('\0'); + if (tsSep < 0) return false; + String tsStr = rest.substring(0, tsSep); + String sig = rest.substring(tsSep + 1); + + if (sig.length() != HEX_SIG_LENGTH || !sig.matches("[0-9a-f]+")) return false; + + long ts; try { - var mac = javax.crypto.Mac.getInstance("HmacSHA256"); - mac.init(new javax.crypto.spec.SecretKeySpec(secret, "HmacSHA256")); - byte[] raw = mac.doFinal(data.getBytes()); - StringBuilder sb = new StringBuilder(raw.length * 2); - for (byte b : raw) { - sb.append(String.format("%02x", b & 0xFF)); + ts = Long.parseLong(tsStr); + } catch (NumberFormatException e) { + return false; + } + + long now = System.currentTimeMillis() / 1000; + if (now < ts || now - ts > ttl) return false; + + long tsBucket = ts / rotation; + for (long bucket : new long[]{tsBucket, tsBucket - 1}) { + byte[] derivedKey = hmacBytes(secret, ("rampart-key-" + bucket).getBytes(StandardCharsets.UTF_8)); + if (derivedKey == null) continue; + String expected = toHex(hmacBytes(derivedKey, (domain + "|" + ts).getBytes(StandardCharsets.UTF_8))); + if (expected != null && constantTimeEquals(sig, expected)) { + return true; } - return sb.toString(); + } + return false; + } + + private byte[] hmacBytes(byte[] key, byte[] data) { + try { + Mac mac = Mac.getInstance(HMAC_ALGO); + mac.init(new SecretKeySpec(key, HMAC_ALGO)); + return mac.doFinal(data); } catch (Exception e) { getLogger().severe("HMAC error: " + e.getMessage()); return null; } } + private static String toHex(byte[] raw) { + StringBuilder sb = new StringBuilder(raw.length * 2); + for (byte b : raw) { + sb.append(String.format("%02x", b & 0xFF)); + } + return sb.toString(); + } + boolean constantTimeEquals(String a, String b) { if (a.length() != b.length()) return false; int result = 0; @@ -59,4 +114,14 @@ public class RampartPaper extends JavaPlugin { } return result == 0; } + + private static long envLong(String name, long def) { + String value = System.getenv(name); + if (value == null || value.isEmpty()) return def; + try { + return Long.parseLong(value.trim()); + } catch (NumberFormatException e) { + return def; + } + } } diff --git a/plugins/paper/src/main/java/me/rampart/paper/ShieldAgent.java b/plugins/paper/src/main/java/me/rampart/paper/ShieldAgent.java index 7866a56..8c15d19 100644 --- a/plugins/paper/src/main/java/me/rampart/paper/ShieldAgent.java +++ b/plugins/paper/src/main/java/me/rampart/paper/ShieldAgent.java @@ -14,6 +14,7 @@ public class ShieldAgent { private final RampartPaper plugin; private final Jedis jedis; private final String serverName; + private final String serverDomain; private final String serverIp; private final int serverPort; private BukkitRunnable task; @@ -35,6 +36,9 @@ public class ShieldAgent { } this.serverName = name; + String domain = System.getenv("RAMPART_SERVER_DOMAIN"); + this.serverDomain = (domain == null) ? "" : domain; + String ip = System.getenv("RAMPART_SERVER_IP"); if (ip == null || ip.isEmpty()) { try { @@ -108,7 +112,8 @@ public class ShieldAgent { } private String buildJson(String status, int online, int maxPlayers, double tps) { - return "{\"name\":\"" + serverName + "\",\"type\":\"paper\",\"ip\":\"" + serverIp + return "{\"name\":\"" + serverName + "\",\"type\":\"paper\",\"domain\":\"" + serverDomain + + "\",\"ip\":\"" + serverIp + "\",\"port\":" + serverPort + ",\"status\":\"" + status + "\",\"online\":" + online + ",\"max_players\":" + maxPlayers + ",\"tps\":" + tps diff --git a/plugins/velocity/build.gradle.kts b/plugins/velocity/build.gradle.kts index f4bbd19..a45e399 100644 --- a/plugins/velocity/build.gradle.kts +++ b/plugins/velocity/build.gradle.kts @@ -14,6 +14,7 @@ dependencies { implementation("redis.clients:jedis:5.2.0") + testImplementation("com.velocitypowered:velocity-api:3.4.0-SNAPSHOT") testImplementation("org.junit.jupiter:junit-jupiter:5.11.4") testImplementation("org.slf4j:slf4j-api:2.0.16") testRuntimeOnly("org.slf4j:slf4j-simple:2.0.16") diff --git a/plugins/velocity/src/main/java/me/rampart/velocity/CaptchaHandler.java b/plugins/velocity/src/main/java/me/rampart/velocity/CaptchaHandler.java index a0fbe67..4448384 100644 --- a/plugins/velocity/src/main/java/me/rampart/velocity/CaptchaHandler.java +++ b/plugins/velocity/src/main/java/me/rampart/velocity/CaptchaHandler.java @@ -3,7 +3,6 @@ package me.rampart.velocity; import com.velocitypowered.api.event.Subscribe; import com.velocitypowered.api.event.command.CommandExecuteEvent; import com.velocitypowered.api.event.connection.DisconnectEvent; -import com.velocitypowered.api.event.connection.LoginEvent; import com.velocitypowered.api.event.player.PlayerChatEvent; import com.velocitypowered.api.proxy.Player; import com.velocitypowered.api.proxy.ProxyServer; @@ -14,7 +13,6 @@ import java.util.Map; import java.util.Random; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.TimeUnit; public class CaptchaHandler { diff --git a/plugins/velocity/src/main/java/me/rampart/velocity/HmacCheckListener.java b/plugins/velocity/src/main/java/me/rampart/velocity/HmacCheckListener.java index 823c694..beb1db6 100644 --- a/plugins/velocity/src/main/java/me/rampart/velocity/HmacCheckListener.java +++ b/plugins/velocity/src/main/java/me/rampart/velocity/HmacCheckListener.java @@ -7,6 +7,7 @@ import org.slf4j.Logger; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; import java.util.Map; @@ -23,10 +24,14 @@ public class HmacCheckListener { private final Logger logger; private final byte[] secret; + private final long rotationSecs; + private final long ttlSecs; - public HmacCheckListener(Logger logger, String secret) { + public HmacCheckListener(Logger logger, String secret, long rotationSecs, long ttlSecs) { this.logger = logger; - this.secret = secret.getBytes(); + this.secret = secret.getBytes(StandardCharsets.UTF_8); + this.rotationSecs = rotationSecs; + this.ttlSecs = ttlSecs; } public static void markVerified(UUID uuid) { @@ -34,6 +39,10 @@ public class HmacCheckListener { PhysicsCheckListener.clearSuspicion(uuid); } + public static boolean isVerified(UUID uuid) { + return verifiedPlayers.containsKey(uuid); + } + @Subscribe public void onLogin(LoginEvent event) { var player = event.getPlayer(); @@ -53,60 +62,83 @@ public class HmacCheckListener { return; } - int sepIdx = raw.indexOf(SHIELD_SEPARATOR); - if (sepIdx < 0) { + if (raw.indexOf(SHIELD_SEPARATOR) < 0) { event.setResult(LoginEvent.ComponentResult.denied( Component.text("Connection rejected: unsigned connection") )); return; } - String domain = raw.substring(0, sepIdx); - String sig = raw.substring(sepIdx + SHIELD_SEPARATOR.length()); - - if (sig.length() != HEX_SIG_LENGTH) { - logger.warn("Invalid HMAC signature length from {}: got {}, expected {}", - player.getRemoteAddress(), sig.length(), HEX_SIG_LENGTH); - event.setResult(LoginEvent.ComponentResult.denied( - Component.text("Connection rejected: invalid signature") - )); - return; - } - - String expected = hmacHex(domain); - if (expected == null) { - event.setResult(LoginEvent.ComponentResult.denied( - Component.text("Connection rejected: internal error") - )); - return; - } - - if (!constantTimeEquals(sig, expected)) { - logger.warn("HMAC verification failed for {} (domain: {})", - player.getRemoteAddress(), domain); + if (!verifyHostname(raw, secret, rotationSecs, ttlSecs)) { + logger.warn("HMAC verification failed for {} (hostname: {})", + player.getRemoteAddress(), raw); event.setResult(LoginEvent.ComponentResult.denied( Component.text("Connection rejected: invalid signature") )); } } - private String hmacHex(String data) { + static boolean verifyHostname(String raw, byte[] secret, long rotationSecs, long ttlSecs) { + if (raw == null) return false; + int sepIdx = raw.indexOf(SHIELD_SEPARATOR); + if (sepIdx < 0) return false; + + String domain = raw.substring(0, sepIdx); + String rest = raw.substring(sepIdx + SHIELD_SEPARATOR.length()); + int tsSep = rest.indexOf('\0'); + if (tsSep < 0) return false; + String tsStr = rest.substring(0, tsSep); + String sig = rest.substring(tsSep + 1); + + if (sig.length() != HEX_SIG_LENGTH || !sig.matches("[0-9a-f]+")) return false; + + long ts; + try { + ts = Long.parseLong(tsStr); + } catch (NumberFormatException e) { + return false; + } + + long now = System.currentTimeMillis() / 1000; + if (now < ts || now - ts > ttlSecs) return false; + + long tsBucket = ts / rotationSecs; + for (long bucket : new long[]{tsBucket, tsBucket - 1}) { + String expected = sign(domain, ts, bucket, secret); + if (expected != null && constantTimeEquals(sig, expected)) { + return true; + } + } + return false; + } + + private static String sign(String domain, long ts, long bucket, byte[] masterSecret) { + byte[] derivedKey = hmacRaw(masterSecret, ("rampart-key-" + bucket).getBytes(StandardCharsets.UTF_8)); + if (derivedKey == null) return null; + byte[] sig = hmacRaw(derivedKey, (domain + "|" + ts).getBytes(StandardCharsets.UTF_8)); + if (sig == null) return null; + return toHex(sig); + } + + private static byte[] hmacRaw(byte[] key, byte[] data) { try { Mac mac = Mac.getInstance(HMAC_ALGO); - mac.init(new SecretKeySpec(secret, HMAC_ALGO)); - byte[] raw = mac.doFinal(data.getBytes()); - StringBuilder sb = new StringBuilder(raw.length * 2); - for (byte b : raw) { - sb.append(String.format("%02x", b & 0xFF)); - } - return sb.toString(); + mac.init(new SecretKeySpec(key, HMAC_ALGO)); + return mac.doFinal(data); } catch (NoSuchAlgorithmException | InvalidKeyException e) { - logger.error("HMAC error", e); return null; } } - private boolean constantTimeEquals(String a, String b) { + private static String toHex(byte[] raw) { + StringBuilder sb = new StringBuilder(raw.length * 2); + for (byte b : raw) { + sb.append(String.format("%02x", b & 0xFF)); + } + return sb.toString(); + } + + private static boolean constantTimeEquals(String a, String b) { if (a.length() != b.length()) return false; int result = 0; for (int i = 0; i < a.length(); i++) { diff --git a/plugins/velocity/src/main/java/me/rampart/velocity/PhysicsCheckListener.java b/plugins/velocity/src/main/java/me/rampart/velocity/PhysicsCheckListener.java index ba27333..79667d7 100644 --- a/plugins/velocity/src/main/java/me/rampart/velocity/PhysicsCheckListener.java +++ b/plugins/velocity/src/main/java/me/rampart/velocity/PhysicsCheckListener.java @@ -160,6 +160,7 @@ public class PhysicsCheckListener { private void triggerReVerify(Player player, PlayerTracker tracker) { if (tracker.reVerificationTriggered) return; + if (HmacCheckListener.isVerified(player.getUniqueId())) return; tracker.reVerificationTriggered = true; player.disconnect(Component.text("Re-verification required. Please reconnect.")); logger.info("Triggered re-verification for {}", player.getUsername()); diff --git a/plugins/velocity/src/main/java/me/rampart/velocity/RampartVelocity.java b/plugins/velocity/src/main/java/me/rampart/velocity/RampartVelocity.java index 29ac238..7311ac7 100644 --- a/plugins/velocity/src/main/java/me/rampart/velocity/RampartVelocity.java +++ b/plugins/velocity/src/main/java/me/rampart/velocity/RampartVelocity.java @@ -2,7 +2,10 @@ package me.rampart.velocity; import com.google.inject.Inject; import com.velocitypowered.api.event.EventManager; +import com.velocitypowered.api.event.Subscribe; +import com.velocitypowered.api.event.player.ServerPostConnectEvent; import com.velocitypowered.api.plugin.Plugin; +import com.velocitypowered.api.proxy.Player; import com.velocitypowered.api.proxy.ProxyServer; import org.slf4j.Logger; @@ -38,8 +41,10 @@ public class RampartVelocity { } if (secret != null && !secret.isEmpty()) { - logger.info("HMAC verification enabled"); - em.register(this, new HmacCheckListener(logger, secret)); + long rotationSecs = envLong("RAMPART_HMAC_ROTATION_SECS", 3600); + long ttlSecs = envLong("RAMPART_HMAC_TTL_SECS", 60); + logger.info("HMAC verification enabled (rotation={}s, ttl={}s)", rotationSecs, ttlSecs); + em.register(this, new HmacCheckListener(logger, secret, rotationSecs, ttlSecs)); } else { logger.warn("RAMPART_HMAC_SECRET not set — HMAC verification disabled"); } @@ -61,11 +66,39 @@ public class RampartVelocity { CaptchaHandler captchaHandler = new CaptchaHandler(logger, server); em.register(this, captchaHandler); + em.register(this, new CaptchaChallengeListener(captchaHandler)); logger.info("CAPTCHA handler enabled"); logger.info("Server registry + load balancer started with Redis at {}", redisUrl); } + private static class CaptchaChallengeListener { + + private final CaptchaHandler captchaHandler; + + CaptchaChallengeListener(CaptchaHandler captchaHandler) { + this.captchaHandler = captchaHandler; + } + + @Subscribe + public void onServerPostConnect(ServerPostConnectEvent event) { + Player player = event.getPlayer(); + if (player.isActive()) { + captchaHandler.challenge(player); + } + } + } + + private static long envLong(String name, long def) { + String value = System.getenv(name); + if (value == null || value.isEmpty()) return def; + try { + return Long.parseLong(value.trim()); + } catch (NumberFormatException e) { + return def; + } + } + private List loadDomainWhitelist() { String env = System.getenv("RAMPART_ALLOWED_DOMAINS"); if (env == null || env.isEmpty()) return Collections.emptyList(); diff --git a/plugins/velocity/src/main/java/me/rampart/velocity/ServerRegistry.java b/plugins/velocity/src/main/java/me/rampart/velocity/ServerRegistry.java index 17213d0..7981411 100644 --- a/plugins/velocity/src/main/java/me/rampart/velocity/ServerRegistry.java +++ b/plugins/velocity/src/main/java/me/rampart/velocity/ServerRegistry.java @@ -24,6 +24,7 @@ public class ServerRegistry { private final String redisUrl; private final AtomicInteger counter = new AtomicInteger(0); private final ConcurrentHashMap tpsCache = new ConcurrentHashMap<>(); + private final ConcurrentHashMap serverDomains = new ConcurrentHashMap<>(); private volatile List cachedServers = new ArrayList<>(); public ServerRegistry(ProxyServer proxyServer, Logger logger, String redisUrl) { @@ -63,6 +64,7 @@ public class ServerRegistry { if (!redisNames.contains(name)) { proxyServer.getServer(name).ifPresent(s -> proxyServer.unregisterServer(s.getServerInfo())); + serverDomains.remove(name); unregistered++; } } @@ -95,6 +97,12 @@ public class ServerRegistry { if (!"online".equals(status)) continue; double tps = extractJsonDouble(json, "tps"); tpsCache.put(name, tps); + String domain = extractJsonString(json, "domain"); + if (domain != null && !domain.isEmpty()) { + serverDomains.put(name, domain.trim()); + } else { + serverDomains.remove(name); + } servers.add(new ServerInfo(name, InetSocketAddress.createUnresolved(ip, port))); } catch (Exception e) { logger.warn("Failed to parse server data for key {}: {}", key, e.getMessage()); @@ -121,6 +129,10 @@ public class ServerRegistry { return tpsCache.getOrDefault(name, 20.0); } + public String getServerDomain(String name) { + return serverDomains.get(name); + } + private static String extractJsonString(String json, String key) { String search = "\"" + key + "\":\""; int start = json.indexOf(search); diff --git a/plugins/velocity/src/main/java/me/rampart/velocity/ServerRouter.java b/plugins/velocity/src/main/java/me/rampart/velocity/ServerRouter.java index a7f0ab0..d7407ae 100644 --- a/plugins/velocity/src/main/java/me/rampart/velocity/ServerRouter.java +++ b/plugins/velocity/src/main/java/me/rampart/velocity/ServerRouter.java @@ -38,6 +38,16 @@ public class ServerRouter { } public Optional routeServer(String domain) { + String cleanDomain = domain == null ? "" : domain.trim(); + if (!cleanDomain.isEmpty()) { + for (RegisteredServer server : registry.getCachedServers()) { + String name = server.getServerInfo().getName(); + if (cleanDomain.equals(registry.getServerDomain(name)) + && registry.getServerTps(name) >= TPS_DEAD) { + return Optional.of(server); + } + } + } for (RegisteredServer server : registry.getCachedServers()) { double tps = registry.getServerTps(server.getServerInfo().getName()); if (tps >= TPS_DEAD) { diff --git a/plugins/velocity/src/test/java/me/rampart/velocity/RampartVelocityTest.java b/plugins/velocity/src/test/java/me/rampart/velocity/RampartVelocityTest.java index f120671..88e91b8 100644 --- a/plugins/velocity/src/test/java/me/rampart/velocity/RampartVelocityTest.java +++ b/plugins/velocity/src/test/java/me/rampart/velocity/RampartVelocityTest.java @@ -113,6 +113,101 @@ public class RampartVelocityTest { assertTrue(DomainCheckUtil.isDomainAllowed("192.168.1.1", List.of())); } + // --- Dual-key hostname verification (mirrors HmacCheckListener.verifyHostname) --- + + @Test + void hostnameVerifyAcceptsValidSignature() { + long ts = System.currentTimeMillis() / 1000; + String raw = signHostname("play.example.com", ts, 3600, "test_secret"); + assertTrue(HmacCheckListener.verifyHostname(raw, "test_secret".getBytes(), 3600, 60)); + } + + @Test + void hostnameVerifyRejectsWrongSecret() { + long ts = System.currentTimeMillis() / 1000; + String raw = signHostname("play.example.com", ts, 3600, "secret_a"); + assertFalse(HmacCheckListener.verifyHostname(raw, "secret_b".getBytes(), 3600, 60)); + } + + @Test + void hostnameVerifyRejectsTamperedSig() { + long ts = System.currentTimeMillis() / 1000; + String raw = signHostname("play.example.com", ts, 3600, "test_secret"); + char flip = raw.charAt(raw.length() - 1) == '0' ? '1' : '0'; + String tampered = raw.substring(0, raw.length() - 1) + flip; + assertFalse(HmacCheckListener.verifyHostname(tampered, "test_secret".getBytes(), 3600, 60)); + } + + @Test + void hostnameVerifyRejectsUnsigned() { + assertFalse(HmacCheckListener.verifyHostname("play.example.com", "test_secret".getBytes(), 3600, 60)); + } + + @Test + void hostnameVerifyRejectsMalformedTs() { + String raw = "play.example.com\0shield\0notanumber\0" + "a".repeat(64); + assertFalse(HmacCheckListener.verifyHostname(raw, "test_secret".getBytes(), 3600, 60)); + } + + @Test + void hostnameVerifyRejectsBadSigLength() { + long ts = System.currentTimeMillis() / 1000; + String raw = "play.example.com\0shield\0" + ts + "\0" + "a".repeat(63); + assertFalse(HmacCheckListener.verifyHostname(raw, "test_secret".getBytes(), 3600, 60)); + } + + @Test + void hostnameVerifyRejectsExpired() { + long ts = System.currentTimeMillis() / 1000 - 120; + String raw = signHostname("play.example.com", ts, 3600, "test_secret"); + assertFalse(HmacCheckListener.verifyHostname(raw, "test_secret".getBytes(), 3600, 60)); + } + + @Test + void hostnameVerifyRejectsFutureTimestamp() { + long ts = System.currentTimeMillis() / 1000 + 3600; + String raw = signHostname("play.example.com", ts, 3600, "test_secret"); + assertFalse(HmacCheckListener.verifyHostname(raw, "test_secret".getBytes(), 3600, 60)); + } + + @Test + void hostnameVerifyAcceptsPreviousRotationBucket() { + long ts = System.currentTimeMillis() / 1000; + long bucket = ts / 3600 - 1; + String raw = "play.example.com\0shield\0" + ts + "\0" + + sign("play.example.com", ts, bucket, "test_secret"); + assertTrue(HmacCheckListener.verifyHostname(raw, "test_secret".getBytes(), 3600, 60)); + } + + private static String signHostname(String domain, long ts, long rotationSecs, String secret) { + long bucket = ts / rotationSecs; + return domain + "\0shield\0" + ts + "\0" + sign(domain, ts, bucket, secret); + } + + private static String sign(String domain, long ts, long bucket, String secret) { + byte[] derived = hmacRaw(secret.getBytes(), ("rampart-key-" + bucket).getBytes()); + byte[] sig = hmacRaw(derived, (domain + "|" + ts).getBytes()); + return toHex(sig); + } + + private static byte[] hmacRaw(byte[] key, byte[] data) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(key, "HmacSHA256")); + return mac.doFinal(data); + } catch (NoSuchAlgorithmException | InvalidKeyException e) { + throw new AssertionError(e); + } + } + + private static String toHex(byte[] raw) { + StringBuilder sb = new StringBuilder(raw.length * 2); + for (byte b : raw) { + sb.append(String.format("%02x", b & 0xFF)); + } + return sb.toString(); + } + // --- HMAC utility (mirrors HmacCheckListener) --- private String hmacHex(String data, String secret) {