feat: velocity routing, server registry and RampartVelocity tests
This commit is contained in:
parent
fa6de281fb
commit
29ce6fb8e9
41 changed files with 1239 additions and 532 deletions
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
265
Cargo.lock
generated
265
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
117
TODO.md
117
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<IpAddr>), `redis.rs` парсит через `IpAddr::parse`, whitelist валидируется на старте. Попутно исправлен overflow-panic в redis.rs (octet > 255). XDP остаётся IPv4-only (задокументировано).
|
||||
|
||||
### P1 — Безопасность
|
||||
|
||||
- [ ] **HMAC**: nonce/timestamp + TTL в подпись; реализовать ротацию ключей (dual-key) и задействовать `key_rotation_interval_secs` (сейчас мёртвый конфиг). Детерминированная подпись = вечная утечка.
|
||||
- [ ] **RateLimiter**: TTL-эвикция idle bucket'ов (фоновый sweep) + cap размера карты — иначе ботнет съест память.
|
||||
- [ ] **Blacklist**: вызывать `clear_expired()` по таймеру (сейчас мёртвый код).
|
||||
- [ ] **Redis IP-parse**: валидировать октеты ≤ 255 (`redis.rs:95`) — сейчас `(ip_u32<<8)|octet` с октетом >255 даёт **panic** в debug.
|
||||
- [ ] **JWT**: валидация ролей/audience, secret ≥ 32 байт, rate-limit на login.
|
||||
|
||||
### P1 — Целостность слоёв
|
||||
|
||||
- [ ] **XDP в Docker/CI**: собирать `rampart-core --features xdp`, clang+libbpf в builder-образ, smoke-attach в CI.
|
||||
- [ ] **Синхронизация blacklist Rust ↔ XDP**: `XdpFilter::ban_ip` вызывать при death-code бане; TTL из конфига, не хардкод 300с.
|
||||
- [ ] **Подключить Layer 6** (`AttackDetector`/`IpReputation`/`TrafficProfiler`) в hot path: метрики, reputation-скоринг, auto-ban.
|
||||
- [ ] **Подключить ClickHouse**: реальные `push()` из hot path + flush task + таблица (сейчас мёртвый код).
|
||||
- [ ] **CAPTCHA**: вызвать `challenge()` на входе ИЛИ удалить (сейчас мёртвый код; `markVerified`/`verifiedPlayers` пишутся, но не читаются).
|
||||
- [ ] **`routeServer(domain)`**: реализовать доменную маршрутизацию по `ServerInfo` (сейчас параметр игнорируется, только round-robin).
|
||||
|
||||
### P2 — Баги и долг
|
||||
|
||||
- [ ] **XDP IPv6**: `src_ip = ip6->daddr` (`xdp_filter.c:141`) → исправить на `saddr`; иначе whitelist/blacklist/flow-ключи по чужому IP.
|
||||
- [ ] **XDP seq-трекинг**: пересмотреть `expected_seq`; убрать dead-код в `DIRECT_READ_LOGIN` (`login_consumed < (end-cursor)` всегда false).
|
||||
- [ ] **Порядок фильтров**: rate limit ДО PoW (сейчас PoW-работа тратится на rate-limited IP); убрать двойной `check()` на соединение (съедает 2 токена).
|
||||
- [ ] **`std::sync::Mutex` в async** (`DifficultyAdjuster` в `tunnel.rs`) → `tokio::sync::Mutex`/атомика; whitelist-сравнение по строке → пре-парс IP/CIDR.
|
||||
- [ ] **`replace_hostname`**: проверка длины подписанного hostname ≤ 255 (добавка сигнатуры выбивает длинные домены).
|
||||
- [ ] **Physics**: переделать на реальные данные позиций или удалить фейковый falling check; «re-verify» должен реально что-то проверять, а не дисконнектить.
|
||||
- [ ] **Redis**: `KEYS` → `SCAN` (manager + `ServerRegistry`), TTL на ключи серверов (иначе мусор копится), **reconnect** pubsub-подписчика (сейчас умирает навсегда).
|
||||
|
||||
### P2 — Мёртвый код / конфиг
|
||||
|
||||
- [ ] Удалить или использовать: `max_connections_per_ip`, `rate_limit_status_pps`, `logging.level/format`, `ACTIVE_CONNECTIONS`, `BLACKLIST_SIZE`, `io-uring`/`tokio-splice`, `ClickHouseWriter` без вызовов.
|
||||
- [ ] **Manager blacklist**: хранить reason/created/expires, применять `duration_secs` (сейчас фабрикуются фейковые поля).
|
||||
- [ ] **CLI**: `drain`/`emergency` из заглушек → реальная логика или явный `unimplemented`.
|
||||
- [ ] **README**: убрать неподтверждённые цифры (io_uring 110k, XDP 15–20M pps), привести в соответствие коду и TODO.
|
||||
|
||||
**DoD этапа 0:** все P0 закрыты, P1/P2 закрыты или явно задекларированы как «позже с issue», `cargo test` + `cargo clippy -D warnings` + Java build + Docker (с XDP) зелёные.
|
||||
|
||||
---
|
||||
|
||||
## 3. Anti-Regression — как не допускать
|
||||
|
||||
> Каждая фича обязана пройти чеклист ниже. Мёртвый код, «бумажные слои» и дефолт-секреты = reject на ревью.
|
||||
|
||||
### Правила
|
||||
|
||||
1. **No dead code**: каждый `pub` в prod-модуле имеет вызов вне `#[cfg(test)]`. Если компонент не вызывается — он не существует (CAPTCHA, ClickHouse, Layer 6).
|
||||
2. **Config field = потребитель**: нет конфиг-поля без использования. Добавил поле — сразу потребитель (или не добавляй).
|
||||
3. **Метрика регистрируется → обновляется**: каждый Gauge/Counter имеет единственного «writer»; ревью проверяет, что `inc`/`set` реально вызываются.
|
||||
4. **Feature flag = сборка в CI**: любое `feature` собирается в CI (`--all-features` уже есть) и в Docker-образе. «Фича не в образе» = фичи нет.
|
||||
5. **По умолчанию безопасно**: нет дефолтных секретов/паролей; отсутствие обязательного env = fail-fast, а не warn.
|
||||
6. **Интеграционный тест на слой**: PoW+handshake (симуляция ванильного клиента), XDP attach smoke, Redis sync, router по домену.
|
||||
7. **Парсеры читают полный кадр**: никогда «один read» для MC-пакета; неполный кадр = accumulate или отказ, но не молчаливый drop валидного клиента.
|
||||
8. **Listener/Handler = вызывается**: новый Java-listener или Rust-модуль подключается в `main`/plugin `onEnable`, иначе reject.
|
||||
9. **CI guardrails** (добавить в `.github/workflows/ci.yml`):
|
||||
- [ ] `cargo clippy --all-targets --all-features -- -D warnings`
|
||||
- [ ] `cargo test` (уже есть) + сборка XDP (`clang -target bpf`) + Docker build с `--features xdp`
|
||||
- [ ] grep-проверка отсутствия дефолт-секретов: `changeme`, `password = "` в коде/конфигах
|
||||
- [ ] Java build (уже есть) + `./gradlew test`
|
||||
10. **README/TODO не врут**: каждое заявленное число/слой имеет ссылку на код или тест. Нет — не пишем.
|
||||
|
||||
---
|
||||
|
||||
## 4. 6-слойная архитектура (план)
|
||||
|
||||
```
|
||||
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)*
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,73 +1,153 @@
|
|||
use hmac::{Hmac, Mac};
|
||||
use sha2::Sha256;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
type HmacSha256 = Hmac<Sha256>;
|
||||
|
||||
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<u8> {
|
||||
let msg = format!("rampart-key-{bucket}");
|
||||
let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts any key length");
|
||||
mac.update(msg.as_bytes());
|
||||
mac.finalize().into_bytes().to_vec()
|
||||
}
|
||||
|
||||
pub fn sign_hostname(raw: &str, secret: &[u8]) -> String {
|
||||
/// Подписывает hostname-поле: `domain\0shield\0<ts>\0<sig>`.
|
||||
///
|
||||
/// `sig` = HMAC-SHA256(derived_key, "domain|ts"), где
|
||||
/// `derived_key` = HMAC-SHA256(master_secret, "rampart-key-{bucket}"), bucket = ts / rotation_secs.
|
||||
pub fn sign_hostname(raw: &str, secret: &[u8], rotation_secs: u64) -> String {
|
||||
let rotation_secs = rotation_secs.max(1);
|
||||
let domain = raw.split('\0').next().unwrap_or(raw);
|
||||
let 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<String>) {
|
||||
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<ts>\0<sig>`, проверяет `0 <= now - ts <= ttl_secs` и
|
||||
/// сравнивает сигнатуру constant-time для bucket из `{ts_bucket, ts_bucket - 1}`.
|
||||
pub fn verify_hostname(raw: &str, secret: &[u8], rotation_secs: u64, ttl_secs: u64) -> bool {
|
||||
let rotation_secs = rotation_secs.max(1);
|
||||
let mut parts = raw.split('\0');
|
||||
let (Some(domain), Some(tag), Some(ts_str), Some(sig)) = (parts.next(), parts.next(), parts.next(), parts.next())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if tag != "shield" || parts.next().is_some() {
|
||||
return false;
|
||||
}
|
||||
let ts: u64 = match ts_str.parse() {
|
||||
Ok(t) => t,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let now = now_secs();
|
||||
if now < ts || now - ts > ttl_secs {
|
||||
return false;
|
||||
}
|
||||
if sig.len() != 64 {
|
||||
return false;
|
||||
}
|
||||
let bucket = ts / rotation_secs;
|
||||
for candidate in [bucket, bucket.saturating_sub(1)] {
|
||||
let key = derive_key(secret, candidate);
|
||||
let expected = hmac_hex(&key, format!("{domain}|{ts}").as_bytes());
|
||||
if expected.as_bytes().ct_eq(sig.as_bytes()).into() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const SECRET: &[u8] = b"test_secret_32_bytes_long_here!!";
|
||||
|
||||
fn build_signed(secret: &[u8], domain: &str, ts: u64, rotation_secs: u64) -> String {
|
||||
let bucket = ts / rotation_secs.max(1);
|
||||
let key = derive_key(secret, bucket);
|
||||
let sig = hmac_hex(&key, format!("{domain}|{ts}").as_bytes());
|
||||
format!("{domain}\0shield\0{ts}\0{sig}")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sign_verify() {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<DashMap<u32, BanEntry>>,
|
||||
entries: Arc<DashMap<IpAddr, BanEntry>>,
|
||||
}
|
||||
|
||||
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])));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<DashMap<u32, Bucket>>,
|
||||
buckets: Arc<DashMap<IpAddr, Bucket>>,
|
||||
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)));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,4 @@ pub mod pow;
|
|||
pub mod proxy;
|
||||
pub mod store;
|
||||
pub mod traffic;
|
||||
|
||||
#[cfg(feature = "xdp")]
|
||||
pub mod xdp;
|
||||
|
|
|
|||
|
|
@ -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<Arc<tokio::sync::Mutex<ClickHouseWriter>>> = match &config.store.clickhouse_url {
|
||||
Some(url) if !url.is_empty() => {
|
||||
let writer = Arc::new(tokio::sync::Mutex::new(ClickHouseWriter::new(url)));
|
||||
rampart_core::store::clickhouse::start_flush_task(writer.clone(), shutdown_rx.clone());
|
||||
Some(writer)
|
||||
},
|
||||
_ => None,
|
||||
};
|
||||
|
||||
let mut filter = XdpFilter::new(&config.xdp.interface);
|
||||
filter.load()?;
|
||||
#[cfg(feature = "xdp")]
|
||||
let xdp_filter: Option<Arc<Mutex<XdpFilter>>> = if config.xdp.enabled {
|
||||
use rampart_core::xdp::XdpMetrics;
|
||||
|
||||
let filter = XdpFilter::new(&config.xdp.interface);
|
||||
let shared = Arc::new(Mutex::new(filter));
|
||||
shared.lock().expect("xdp lock poisoned").load()?;
|
||||
let xdp_metrics = XdpMetrics::register()?;
|
||||
|
||||
let sd = shutdown_rx.clone();
|
||||
let shared_thread = shared.clone();
|
||||
std::thread::spawn(move || {
|
||||
while !*sd.borrow() {
|
||||
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<Arc<Mutex<XdpFilter>>> = None;
|
||||
|
||||
let rl = rate_limiter.clone();
|
||||
let bl = blacklist.clone();
|
||||
let det = detector.clone();
|
||||
let a1s = allowed_1s.clone();
|
||||
let ch = clickhouse.clone();
|
||||
let mut sd = shutdown_rx.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut sec_tick = tokio::time::interval(Duration::from_secs(1));
|
||||
let mut min_tick = tokio::time::interval(Duration::from_secs(60));
|
||||
sec_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
min_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
let mut was_under_attack = false;
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
_ = sd.changed() => {
|
||||
if *sd.borrow() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
_ = sec_tick.tick() => {
|
||||
let pps = a1s.swap(0, Ordering::Relaxed) as f64;
|
||||
let status = det.lock().expect("detector lock poisoned").analyze(pps);
|
||||
metrics::ATTACK_STATUS.set(attack_status_value(status));
|
||||
if status == AttackStatus::UnderAttack {
|
||||
if !was_under_attack {
|
||||
was_under_attack = true;
|
||||
tracing::info!(pps, "attack detected: under attack");
|
||||
if let Some(writer) = &ch {
|
||||
let event = ClickHouseEvent {
|
||||
timestamp: chrono::Utc::now(),
|
||||
event_type: "attack".to_string(),
|
||||
ip: String::new(),
|
||||
data_float: pps,
|
||||
data_int: 0,
|
||||
data_string: "under_attack".to_string(),
|
||||
};
|
||||
if let Err(e) = writer.lock().await.push(event).await {
|
||||
tracing::debug!("clickhouse push error: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if was_under_attack {
|
||||
was_under_attack = false;
|
||||
}
|
||||
}
|
||||
_ = min_tick.tick() => {
|
||||
rl.sweep();
|
||||
bl.clear_expired();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tracing::info!("Rampart edge starting on {}:{}", config.bind.address, config.bind.port);
|
||||
tracing::info!("Backend: {}:{}", config.backend.address, config.backend.port);
|
||||
|
||||
let adjuster = Arc::new(Mutex::new(DifficultyAdjuster::default()));
|
||||
let listener = ProxyListener::new(config, rate_limiter, blacklist, adjuster);
|
||||
let listener = ProxyListener::new(
|
||||
config,
|
||||
rate_limiter,
|
||||
blacklist,
|
||||
adjuster,
|
||||
whitelist,
|
||||
reputation,
|
||||
xdp_filter,
|
||||
clickhouse,
|
||||
allowed_1s,
|
||||
);
|
||||
listener.run(shutdown_rx).await
|
||||
}
|
||||
|
||||
fn build_whitelist(config: &Config) -> anyhow::Result<Arc<HashSet<IpAddr>>> {
|
||||
let mut set = HashSet::with_capacity(config.whitelist.len());
|
||||
for entry in &config.whitelist {
|
||||
let ip: IpAddr = match entry.parse() {
|
||||
Ok(ip) => ip,
|
||||
Err(_) => anyhow::bail!("invalid whitelist entry: {entry}"),
|
||||
};
|
||||
set.insert(ip);
|
||||
}
|
||||
Ok(Arc::new(set))
|
||||
}
|
||||
|
||||
async fn wait_for_signal() {
|
||||
let ctrl_c = tokio::signal::ctrl_c();
|
||||
let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||
|
|
|
|||
|
|
@ -33,6 +33,14 @@ pub static POW_CURRENT_DIFFICULTY: LazyLock<IntGauge> = LazyLock::new(|| {
|
|||
register_int_gauge!("rampart_pow_current_difficulty", "Current PoW difficulty").expect("POW_CURRENT_DIFFICULTY")
|
||||
});
|
||||
|
||||
pub static ATTACK_STATUS: LazyLock<IntGauge> = 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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<RateLimiter>,
|
||||
blacklist: Arc<Blacklist>,
|
||||
adjuster: Arc<Mutex<DifficultyAdjuster>>,
|
||||
whitelist: Arc<HashSet<IpAddr>>,
|
||||
reputation: Arc<IpReputation>,
|
||||
xdp: Option<Arc<Mutex<XdpFilter>>>,
|
||||
clickhouse: Option<Arc<TokioMutex<ClickHouseWriter>>>,
|
||||
allowed_1s: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
impl ProxyListener {
|
||||
|
|
@ -21,12 +33,22 @@ impl ProxyListener {
|
|||
rate_limiter: Arc<RateLimiter>,
|
||||
blacklist: Arc<Blacklist>,
|
||||
adjuster: Arc<Mutex<DifficultyAdjuster>>,
|
||||
whitelist: Arc<HashSet<IpAddr>>,
|
||||
reputation: Arc<IpReputation>,
|
||||
xdp: Option<Arc<Mutex<XdpFilter>>>,
|
||||
clickhouse: Option<Arc<TokioMutex<ClickHouseWriter>>>,
|
||||
allowed_1s: Arc<AtomicU64>,
|
||||
) -> Self {
|
||||
Self {
|
||||
config,
|
||||
rate_limiter,
|
||||
blacklist,
|
||||
adjuster,
|
||||
whitelist,
|
||||
reputation,
|
||||
xdp,
|
||||
clickhouse,
|
||||
allowed_1s,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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<TcpListener> {
|
|||
Ok(TcpListener::from_std(socket.into())?)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn accept_loop(
|
||||
listener: TcpListener,
|
||||
config: Arc<Config>,
|
||||
rate_limiter: Arc<RateLimiter>,
|
||||
blacklist: Arc<Blacklist>,
|
||||
adjuster: Arc<Mutex<DifficultyAdjuster>>,
|
||||
whitelist: Arc<HashSet<IpAddr>>,
|
||||
reputation: Arc<IpReputation>,
|
||||
xdp: Option<Arc<Mutex<XdpFilter>>>,
|
||||
clickhouse: Option<Arc<TokioMutex<ClickHouseWriter>>>,
|
||||
allowed_1s: Arc<AtomicU64>,
|
||||
mut shutdown: watch::Receiver<bool>,
|
||||
) -> anyhow::Result<()> {
|
||||
loop {
|
||||
|
|
@ -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}");
|
||||
|
|
|
|||
|
|
@ -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<bool> {
|
||||
pub async fn handle_pow(stream: &mut TcpStream, peer_ip: IpAddr, difficulty: u8) -> anyhow::Result<bool> {
|
||||
if difficulty == 0 {
|
||||
tracing::debug!("pow: difficulty 0, skipping for {peer_ip}");
|
||||
return Ok(true);
|
||||
|
|
|
|||
|
|
@ -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<Config>,
|
||||
rate_limiter: Arc<RateLimiter>,
|
||||
blacklist: Arc<Blacklist>,
|
||||
adjuster: Arc<Mutex<DifficultyAdjuster>>,
|
||||
whitelist: Arc<HashSet<IpAddr>>,
|
||||
reputation: Arc<IpReputation>,
|
||||
xdp: Option<Arc<Mutex<XdpFilter>>>,
|
||||
clickhouse: Option<Arc<TokioMutex<ClickHouseWriter>>>,
|
||||
allowed_1s: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
impl ConnectionHandler {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
config: Arc<Config>,
|
||||
rate_limiter: Arc<RateLimiter>,
|
||||
blacklist: Arc<Blacklist>,
|
||||
adjuster: Arc<Mutex<DifficultyAdjuster>>,
|
||||
whitelist: Arc<HashSet<IpAddr>>,
|
||||
reputation: Arc<IpReputation>,
|
||||
xdp: Option<Arc<Mutex<XdpFilter>>>,
|
||||
clickhouse: Option<Arc<TokioMutex<ClickHouseWriter>>>,
|
||||
allowed_1s: Arc<AtomicU64>,
|
||||
) -> Self {
|
||||
Self {
|
||||
config,
|
||||
rate_limiter,
|
||||
blacklist,
|
||||
adjuster,
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
let mut buf: Vec<u8> = Vec::new();
|
||||
match read_full_frame(&mut client, timeout, &mut buf).await {
|
||||
Ok(false) => return Ok(()),
|
||||
Ok(true) => {},
|
||||
Err(e) => {
|
||||
tracing::debug!("read error from {peer_addr}: {e}");
|
||||
metrics::CONNECTIONS_TOTAL.with_label_values(&["blocked"]).inc();
|
||||
self.handle_death_code(peer_addr, &buf).await;
|
||||
return Ok(());
|
||||
},
|
||||
}
|
||||
|
||||
let parsed = McHandshake::parse(&buf[..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<u8>) -> anyhow::Result<bool> {
|
||||
let mut chunk = [0u8; READ_CHUNK_SIZE];
|
||||
|
||||
let first = tokio::time::timeout(timeout, client.read(&mut chunk)).await??;
|
||||
if first == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
buf.extend_from_slice(&chunk[..first]);
|
||||
|
||||
let total_len = loop {
|
||||
match read_varint(buf, 0) {
|
||||
Ok((packet_len, after_len)) => break after_len + packet_len as usize,
|
||||
Err(ParseError::Incomplete(_)) => {
|
||||
if buf.len() >= 5 {
|
||||
anyhow::bail!("length varint incomplete after {} bytes", buf.len());
|
||||
}
|
||||
let n = tokio::time::timeout(timeout, client.read(&mut chunk)).await??;
|
||||
if n == 0 {
|
||||
anyhow::bail!("connection closed while reading packet length");
|
||||
}
|
||||
buf.extend_from_slice(&chunk[..n]);
|
||||
},
|
||||
Err(e) => anyhow::bail!("invalid packet length varint: {e}"),
|
||||
}
|
||||
};
|
||||
|
||||
if total_len > MAX_FRAME_SIZE {
|
||||
anyhow::bail!("frame too large: {total_len} bytes (max {MAX_FRAME_SIZE})");
|
||||
}
|
||||
|
||||
while buf.len() < total_len {
|
||||
let n = tokio::time::timeout(timeout, client.read(&mut chunk)).await??;
|
||||
if n == 0 {
|
||||
anyhow::bail!("connection closed while reading frame body");
|
||||
}
|
||||
buf.extend_from_slice(&chunk[..n]);
|
||||
}
|
||||
|
||||
buf.truncate(total_len);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn replace_hostname(original: &[u8], _old_hostname: &str, new_hostname: &str) -> anyhow::Result<Vec<u8>> {
|
||||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}"),
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
use dashmap::DashMap;
|
||||
use std::net::Ipv4Addr;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct IpReputation {
|
||||
scores: Arc<DashMap<Ipv4Addr, i32>>,
|
||||
scores: Arc<DashMap<IpAddr, i32>>,
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<()> {
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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<Arc<AppState>>, Json(req): Json<LoginRequest>) -> Json<serde_json::Value> {
|
||||
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<Arc<AppState>>,
|
||||
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||
Json(req): Json<LoginRequest>,
|
||||
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
|
||||
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<IpAddr, (Instant, u32)>, 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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String, jsonwebtoken::errors::Error> {
|
||||
pub fn create_token(secret: &str, expiration: u64, audience: &str) -> Result<String, jsonwebtoken::errors::Error> {
|
||||
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<Claims, jsonwebtoken::errors::Error> {
|
||||
let token_data = decode::<Claims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(secret.as_ref()),
|
||||
&Validation::default(),
|
||||
)?;
|
||||
pub fn verify_token(token: &str, secret: &str, audience: &str) -> Result<Claims, jsonwebtoken::errors::Error> {
|
||||
let mut validation = Validation::default();
|
||||
validation.set_audience(&[audience]);
|
||||
let token_data = decode::<Claims>(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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<IpAddr, (Instant, u32)>,
|
||||
}
|
||||
|
||||
#[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::<u64>()
|
||||
.map_err(|_| anyhow::anyhow!("JWT_EXPIRATION_SECS must be a valid u64"))?;
|
||||
|
||||
let api_password = std::env::var("API_PASSWORD").map_err(|_| anyhow::anyhow!("API_PASSWORD must be set"))?;
|
||||
if api_password == "changeme" {
|
||||
return Err(anyhow::anyhow!("API_PASSWORD must not be the default 'changeme'"));
|
||||
}
|
||||
|
||||
let state = Arc::new(AppState {
|
||||
redis_client,
|
||||
jwt_secret,
|
||||
jwt_audience,
|
||||
jwt_expiration,
|
||||
api_password,
|
||||
login_limiter: DashMap::new(),
|
||||
});
|
||||
|
||||
tokio::spawn(sync::heartbeat::start_heartbeat_check(state.clone()));
|
||||
|
|
@ -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::<std::net::SocketAddr>();
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
12
deny.toml
12
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 = []
|
||||
|
||||
|
|
|
|||
|
|
@ -33,3 +33,7 @@ port = 9090
|
|||
|
||||
[xdp]
|
||||
enabled = false
|
||||
|
||||
[pow]
|
||||
enabled = false
|
||||
difficulty = 4
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -42,4 +42,4 @@ level = "debug"
|
|||
enabled = true
|
||||
difficulty = 4
|
||||
|
||||
whitelist = ["localhost", "127.0.0.1"]
|
||||
whitelist = ["127.0.0.1", "::1"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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,20 +46,64 @@ 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());
|
||||
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 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();
|
||||
} catch (Exception e) {
|
||||
getLogger().severe("HMAC error: " + e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
boolean constantTimeEquals(String a, String b) {
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
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")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
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 sig = raw.substring(sepIdx + SHIELD_SEPARATOR.length());
|
||||
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) {
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
String expected = hmacHex(domain);
|
||||
if (expected == null) {
|
||||
event.setResult(LoginEvent.ComponentResult.denied(
|
||||
Component.text("Connection rejected: internal error")
|
||||
));
|
||||
return;
|
||||
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;
|
||||
}
|
||||
|
||||
if (!constantTimeEquals(sig, expected)) {
|
||||
logger.warn("HMAC verification failed for {} (domain: {})",
|
||||
player.getRemoteAddress(), domain);
|
||||
event.setResult(LoginEvent.ComponentResult.denied(
|
||||
Component.text("Connection rejected: invalid signature")
|
||||
));
|
||||
}
|
||||
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 String hmacHex(String data) {
|
||||
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());
|
||||
mac.init(new SecretKeySpec(key, HMAC_ALGO));
|
||||
return mac.doFinal(data);
|
||||
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
|
||||
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();
|
||||
} catch (NoSuchAlgorithmException | InvalidKeyException e) {
|
||||
logger.error("HMAC error", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean constantTimeEquals(String a, String b) {
|
||||
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++) {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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<String> loadDomainWhitelist() {
|
||||
String env = System.getenv("RAMPART_ALLOWED_DOMAINS");
|
||||
if (env == null || env.isEmpty()) return Collections.emptyList();
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ public class ServerRegistry {
|
|||
private final String redisUrl;
|
||||
private final AtomicInteger counter = new AtomicInteger(0);
|
||||
private final ConcurrentHashMap<String, Double> tpsCache = new ConcurrentHashMap<>();
|
||||
private final ConcurrentHashMap<String, String> serverDomains = new ConcurrentHashMap<>();
|
||||
private volatile List<RegisteredServer> 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);
|
||||
|
|
|
|||
|
|
@ -38,6 +38,16 @@ public class ServerRouter {
|
|||
}
|
||||
|
||||
public Optional<RegisteredServer> 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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue