v0.3: 6-layer architecture complete
Layers: Layer 1: XDP/eBPF — TCP state machine, SYN throttle, blacklist, ringbuf Layer 2: PoW Challenge — SHA-256 hashcash, dynamic difficulty, constant-time verify Layer 3: Rust Core — HMAC handshake, rate limit, death code (existing) Layer 4: Velocity — Physics check, CAPTCHA, protocol verification Layer 5: Paper — Heartbeat, auto-registration (existing) Layer 6: Traffic Intel — EWMA, 168h profiling, reputation, alerts Infra: XDP→Prometheus metrics, ClickHouse + Grafana dashboard, Docker Compose Testing: 100-IP DDoS simulation, MHDDoS ref analysis, load test report Fixes: VarInt sign extension UB, pure ACK deadlock, RST/FIN cleanup Ref: MHDDoS, Sonar, LimboFilter, AtomGuard, Infrarust, MC-XDP-eBPF, PowGo
This commit is contained in:
parent
78fc6e00c7
commit
269daa071f
66 changed files with 4529 additions and 1003 deletions
120
deploy/test/README.md
Normal file
120
deploy/test/README.md
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
# Локальное тестирование Rampart
|
||||
|
||||
Запускаем всё в Docker на одной машине, без выхода в интернет.
|
||||
|
||||
## Сеть
|
||||
|
||||
Все контейнеры в одной bridge-сети `rampart-test`:
|
||||
|
||||
```
|
||||
attacker ──┐
|
||||
├── rampart-edge ── backend
|
||||
│ (XDP отключён в тестах,
|
||||
│ используется userspace-only режим)
|
||||
│
|
||||
mclient ───┘ (Minecraft клиент для теста легитимных коннектов)
|
||||
```
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
```bash
|
||||
# 1. Сеть
|
||||
docker network create rampart-test
|
||||
|
||||
# 2. Backend (Minecraft сервер или заглушка)
|
||||
docker run -d --name backend --network rampart-test itzg/minecraft-server
|
||||
|
||||
# 3. Rampart edge
|
||||
docker run -d --name rampart --network rampart-test \
|
||||
-e RAMPART_CONFIG=/etc/rampart/config.toml \
|
||||
-v ./config.test.toml:/etc/rampart/config.toml \
|
||||
rampart-core
|
||||
|
||||
# 4. Аттакер (MHDDoS)
|
||||
docker run -d --name attacker --network rampart-test \
|
||||
--cap-add=NET_RAW --cap-add=NET_ADMIN \
|
||||
python:3.11 bash -c "while true; do sleep 10; done"
|
||||
|
||||
# 5. Легитимный клиент (mclient.py)
|
||||
docker run -d --name mclient --network rampart-test \
|
||||
python:3.11 python mclient.py --target rampart:25565
|
||||
```
|
||||
|
||||
## Сценарии тестирования
|
||||
|
||||
### 1. SYN flood
|
||||
```bash
|
||||
docker exec attacker python3 /ref/MHDDoS/start.py SYN 172.x.x.x:25565 60 100
|
||||
```
|
||||
Ожидание: Rampart XDP дропает SYN-пакеты после превышения throttle.
|
||||
Метрика: `rampart_xdp_syn_throttle` растёт, CPU < 30%.
|
||||
|
||||
### 2. TCP connection flood (CPS)
|
||||
```bash
|
||||
docker exec attacker python3 /ref/MHDDoS/start.py CPS 172.x.x.x:25565 60 100
|
||||
```
|
||||
Ожидание: Rampart rate-limiter блокирует >50 conn/s с одного IP.
|
||||
Метрика: `rampart_rate_limit_hits` растёт.
|
||||
|
||||
### 3. Minecraft handshake flood
|
||||
```bash
|
||||
docker exec attacker python3 /ref/MHDDoS/start.py MINECRAFT 172.x.x.x:25565 60 100
|
||||
```
|
||||
Ожидание: Layer 2 PoW требует решения хэш-задачи.
|
||||
Метрика: `rampart_pow_challenges_total{result="failed"}` растёт.
|
||||
|
||||
### 4. Сложный ботнет (MHDDoS MCBOT)
|
||||
```bash
|
||||
docker exec attacker python3 /ref/MHDDoS/start.py MCBOT 172.x.x.x:25565 60 50
|
||||
```
|
||||
Ожидание: Physics check детектирует неестественное движение.
|
||||
Требует: PhysicsCheckListener активен.
|
||||
|
||||
### 5. DNS amplification
|
||||
```bash
|
||||
docker exec attacker python3 /ref/MHDDoS/start.py DNS 172.x.x.x:53 60 100
|
||||
```
|
||||
Ожидание: XDP дропает UDP не на порты 25565-25575.
|
||||
Метрика: `rampart_xdp_dropped` растёт.
|
||||
|
||||
### 6. Slowloris (L7)
|
||||
```bash
|
||||
docker exec attacker python3 /ref/MHDDoS/start.py SLOW http://172.x.x.x:9090 60 100
|
||||
```
|
||||
Ожидание: Таймаут чтения закрывает соединение.
|
||||
Метрика: `rampart_connections_total{result="blocked"}` растёт.
|
||||
|
||||
### 7. HTTP flood через cloudscraper (имитация CFB)
|
||||
```bash
|
||||
docker exec attacker python3 /ref/MHDDoS/start.py CFB http://172.x.x.x:9090 60 100
|
||||
```
|
||||
Ожидание: L7 rate-limiter блокирует >100 req/s с одного IP.
|
||||
Метрика: `rampart_rate_limit_hits` растёт.
|
||||
|
||||
## Легитимный тест (mclient.py)
|
||||
|
||||
Тест должен проходить: Rampart пропускает нормальный Minecraft handshake.
|
||||
|
||||
```bash
|
||||
python3 deploy/test/mclient.py --target rampart:25565 --username test_player
|
||||
```
|
||||
Ожидание: HMAC verified, соединение проксируется на backend.
|
||||
|
||||
## Метрики
|
||||
|
||||
Все метрики на http://localhost:9090/metrics:
|
||||
|
||||
```
|
||||
rampart_xdp_total
|
||||
rampart_xdp_passed
|
||||
rampart_xdp_dropped
|
||||
rampart_xdp_syn_throttle
|
||||
rampart_xdp_verified
|
||||
rampart_connections_total{result="allowed|blocked"}
|
||||
rampart_rate_limit_hits{action="hit"}
|
||||
rampart_pow_challenges_total{result="passed|failed|skipped"}
|
||||
rampart_pow_current_difficulty
|
||||
```
|
||||
|
||||
Grafana: http://localhost:3000 (admin/admin)
|
||||
ClickHouse: http://localhost:8123 (для долгосрочных метрик)
|
||||
45
deploy/test/config.test.toml
Normal file
45
deploy/test/config.test.toml
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
[bind]
|
||||
address = "0.0.0.0"
|
||||
port = 25565
|
||||
|
||||
[backend]
|
||||
address = "backend"
|
||||
port = 25565
|
||||
|
||||
[hmac]
|
||||
secret = "test-secret-for-local-dev-only"
|
||||
|
||||
[worker]
|
||||
count = 4
|
||||
|
||||
[limits]
|
||||
rate_limit_login_pps = 50
|
||||
rate_limit_burst = 100
|
||||
|
||||
[store]
|
||||
redis_url = ""
|
||||
clickhouse_url = "http://clickhouse:8123"
|
||||
|
||||
[xdp]
|
||||
enabled = false
|
||||
interface = "eth0"
|
||||
|
||||
[death_code]
|
||||
enabled = true
|
||||
|
||||
[minecraft]
|
||||
ping_timeout_ms = 5000
|
||||
handshake_timeout_ms = 10000
|
||||
|
||||
[metrics]
|
||||
enabled = true
|
||||
port = 9090
|
||||
|
||||
[logging]
|
||||
level = "debug"
|
||||
|
||||
[pow]
|
||||
enabled = true
|
||||
difficulty = 4
|
||||
|
||||
whitelist = ["localhost", "127.0.0.1"]
|
||||
73
deploy/test/mclient.py
Normal file
73
deploy/test/mclient.py
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Minecraft handshake client for testing Rampart."""
|
||||
|
||||
import argparse
|
||||
import socket
|
||||
import struct
|
||||
import time
|
||||
|
||||
|
||||
def pack_varint(value):
|
||||
buf = []
|
||||
while True:
|
||||
byte = value & 0x7F
|
||||
value >>= 7
|
||||
if value:
|
||||
byte |= 0x80
|
||||
buf.append(byte)
|
||||
if not value:
|
||||
break
|
||||
return bytes(buf)
|
||||
|
||||
|
||||
def make_handshake(host, port, protocol=767):
|
||||
packet = bytearray()
|
||||
packet.extend(pack_varint(protocol))
|
||||
packet.extend(pack_varint(len(host)))
|
||||
packet.extend(host.encode())
|
||||
packet.extend(struct.pack(">H", port))
|
||||
packet.extend(pack_varint(2))
|
||||
length = pack_varint(len(packet))
|
||||
return length + bytes(packet)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--target", default="localhost:25565")
|
||||
parser.add_argument("--username", default="test_bot")
|
||||
args = parser.parse_args()
|
||||
|
||||
host, port_str = args.target.split(":")
|
||||
port = int(port_str)
|
||||
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(10)
|
||||
sock.connect((host, port))
|
||||
sock.sendall(make_handshake(host, port))
|
||||
|
||||
data = sock.recv(4096)
|
||||
if data:
|
||||
print(f"Got response: {data.hex()}")
|
||||
# If PoW challenge -> receive challenge, solve, send nonce
|
||||
if b"challenge" in data:
|
||||
print("PoW challenge received")
|
||||
challenge = data.decode().strip()
|
||||
for nonce in range(1000000):
|
||||
import hashlib
|
||||
|
||||
h = hashlib.sha256(f"{challenge}{nonce}".encode()).hexdigest()
|
||||
if h.startswith("0000"):
|
||||
sock.sendall(str(nonce).encode())
|
||||
resp = sock.recv(4096)
|
||||
print(f"PoW ok, handshake: {resp.hex()}")
|
||||
break
|
||||
else:
|
||||
print(f"Handshake response: {data.hex()}")
|
||||
else:
|
||||
print("No response (blocked)")
|
||||
|
||||
sock.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
64
deploy/test/run_test.sh
Executable file
64
deploy/test/run_test.sh
Executable file
|
|
@ -0,0 +1,64 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
NET="rampart-test"
|
||||
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
echo "=== Создание сети ==="
|
||||
docker network create "$NET" 2>/dev/null || true
|
||||
|
||||
echo "=== ClickHouse ==="
|
||||
docker rm -f clickhouse 2>/dev/null || true
|
||||
docker run -d --name clickhouse --network "$NET" \
|
||||
-v "$DIR/../clickhouse/schema.sql:/docker-entrypoint-initdb.d/schema.sql" \
|
||||
-p 8123:8123 \
|
||||
clickhouse/clickhouse-server:latest
|
||||
|
||||
echo "=== Grafana ==="
|
||||
docker rm -f grafana 2>/dev/null || true
|
||||
docker run -d --name grafana --network "$NET" \
|
||||
-p 3000:3000 \
|
||||
-e GF_INSTALL_PLUGINS=grafana-clickhouse-datasource \
|
||||
grafana/grafana:latest
|
||||
|
||||
echo "=== Backend (Minecraft stub) ==="
|
||||
docker rm -f backend 2>/dev/null || true
|
||||
# simple TCP echo server as placeholder
|
||||
docker run -d --name backend --network "$NET" \
|
||||
alpine sh -c "apk add socat && socat TCP-LISTEN:25565,fork EXEC:'cat'"
|
||||
|
||||
echo "=== Rampart Edge ==="
|
||||
docker rm -f rampart 2>/dev/null || true
|
||||
TMPDIR=$(mktemp -d)
|
||||
cp "$DIR/../../target/release/rampart-core" "$TMPDIR/"
|
||||
cp "$DIR/../docker/Dockerfile.test" "$TMPDIR/Dockerfile"
|
||||
docker build -t rampart-core "$TMPDIR"
|
||||
rm -rf "$TMPDIR"
|
||||
docker run -d --name rampart --network "$NET" \
|
||||
--cap-add=NET_ADMIN \
|
||||
-p 25565:25565 -p 9090:9090 \
|
||||
-e RAMPART_CONFIG=/etc/rampart/config.toml \
|
||||
-v "$DIR/config.test.toml:/etc/rampart/config.toml" \
|
||||
rampart-core
|
||||
|
||||
echo "=== Attacker (MHDDoS) ==="
|
||||
docker rm -f attacker 2>/dev/null || true
|
||||
docker run -d --name attacker --network "$NET" \
|
||||
--cap-add=NET_RAW --cap-add=NET_ADMIN \
|
||||
-v "$DIR/../../ref/MHDDoS:/ref/MHDDoS" \
|
||||
python:3.11 bash -c "
|
||||
cd /ref/MHDDoS && pip install -r requirements.txt -q && \
|
||||
while true; do sleep 10; done
|
||||
"
|
||||
|
||||
echo ""
|
||||
echo "=== Готово ==="
|
||||
echo "Rampart edge: localhost:25565"
|
||||
echo "Metrics: http://localhost:9090/metrics"
|
||||
echo "Grafana: http://localhost:3000 (admin/admin)"
|
||||
echo "ClickHouse: http://localhost:8123"
|
||||
echo ""
|
||||
echo "Пример атаки:"
|
||||
echo " docker exec attacker python3 /ref/MHDDoS/start.py TCP rampart:25565 60 100"
|
||||
echo ""
|
||||
echo "Для остановки: docker rm -f rampart backend attacker clickhouse grafana"
|
||||
237
deploy/test/simulate_100ip.py
Normal file
237
deploy/test/simulate_100ip.py
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
#!/usr/bin/env python3
|
||||
"""DDoS simulation: 100 IPs from attacker container, 3 legit clients from host."""
|
||||
|
||||
import subprocess, time, re, sys
|
||||
|
||||
TARGET_IP = "172.18.0.6"
|
||||
TARGET_PORT = 25565
|
||||
METRICS_URL = "http://localhost:9090/metrics"
|
||||
DURATION = 30
|
||||
NUM_IPS = 100
|
||||
|
||||
|
||||
def metrics():
|
||||
try:
|
||||
import urllib.request
|
||||
|
||||
data = urllib.request.urlopen(METRICS_URL, timeout=5).read().decode()
|
||||
result = {}
|
||||
for line in data.splitlines():
|
||||
if line.startswith("rampart_"):
|
||||
parts = line.split()
|
||||
if len(parts) >= 2:
|
||||
result[parts[0]] = parts[-1]
|
||||
return result
|
||||
except:
|
||||
return {}
|
||||
|
||||
|
||||
def print_metrics(label, m):
|
||||
print(f" [{label}]", end="")
|
||||
for k, v in sorted(m.items()):
|
||||
print(f" {k}={v}", end="")
|
||||
print()
|
||||
|
||||
|
||||
ALLOWED = set("0123")
|
||||
|
||||
|
||||
def solve_pow(challenge, difficulty):
|
||||
import hashlib
|
||||
|
||||
t0 = time.time()
|
||||
for n in range(20_000_000):
|
||||
h = hashlib.sha256(f"{challenge}{n}".encode()).hexdigest()
|
||||
if all(c in ALLOWED for c in h[:difficulty]):
|
||||
return n, time.time() - t0
|
||||
return None, time.time() - t0
|
||||
|
||||
|
||||
def legit_client(client_id, delay):
|
||||
import socket, hashlib, struct
|
||||
|
||||
time.sleep(delay)
|
||||
m = metrics()
|
||||
diff = int(m.get("rampart_pow_current_difficulty", "4"))
|
||||
try:
|
||||
s = socket.socket()
|
||||
s.settimeout(10)
|
||||
s.connect(("localhost", TARGET_PORT))
|
||||
data = s.recv(4096).decode().strip()
|
||||
nonce, solve_t = solve_pow(data, diff)
|
||||
if nonce is None:
|
||||
print(f" [legit#{client_id}] FAILED to solve PoW (diff={diff})")
|
||||
s.close()
|
||||
return
|
||||
s.sendall(f"{nonce}\n".encode())
|
||||
time.sleep(0.1)
|
||||
|
||||
# MC handshake
|
||||
def wv(v):
|
||||
b = bytearray()
|
||||
while True:
|
||||
byte = v & 0x7F
|
||||
v >>= 7
|
||||
if v:
|
||||
byte |= 0x80
|
||||
b.append(byte)
|
||||
if not v:
|
||||
break
|
||||
return bytes(b)
|
||||
|
||||
host = "localhost"
|
||||
hs = bytearray()
|
||||
hs.extend(wv(0))
|
||||
hs.extend(wv(767))
|
||||
hs.extend(wv(len(host)))
|
||||
hs.extend(host.encode())
|
||||
hs.extend(struct.pack(">H", 25565))
|
||||
hs.extend(wv(2))
|
||||
s.sendall(wv(len(hs)) + bytes(hs))
|
||||
time.sleep(0.1)
|
||||
name = f"test_{client_id}"
|
||||
login = bytearray()
|
||||
login.extend(wv(0))
|
||||
login.extend(wv(len(name)))
|
||||
login.extend(name.encode())
|
||||
s.sendall(wv(len(login)) + bytes(login))
|
||||
resp = s.recv(4096)
|
||||
status = "OK" if resp else "no_resp"
|
||||
print(
|
||||
f" [legit#{client_id}] ✅ diff={diff} solve={solve_t:.3f}s status={status}"
|
||||
)
|
||||
except Exception as e:
|
||||
print(f" [legit#{client_id}] ❌ diff={diff} error={e}")
|
||||
finally:
|
||||
try:
|
||||
s.close()
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
def run_flood_in_attacker():
|
||||
"""Run the 100-IP flood inside the attacker container."""
|
||||
print("[setup] Launching flood inside attacker container...")
|
||||
import os, tempfile
|
||||
|
||||
script = """
|
||||
import socket, threading, time, struct
|
||||
|
||||
TARGET = ("172.18.0.6", 25565)
|
||||
DURATION = 30
|
||||
NUM_IPS = 100
|
||||
sent = 0
|
||||
lock = threading.Lock()
|
||||
|
||||
def wv(v):
|
||||
b = bytearray()
|
||||
while True:
|
||||
byte = v & 0x7F
|
||||
v >>= 7
|
||||
if v: byte |= 0x80
|
||||
b.append(byte)
|
||||
if not v: break
|
||||
return bytes(b)
|
||||
|
||||
host = "localhost"
|
||||
handshake = wv(0) + wv(767) + wv(len(host)) + host.encode() + struct.pack(">H", 25565) + wv(2)
|
||||
end = time.time() + DURATION
|
||||
|
||||
def flood():
|
||||
global sent
|
||||
while time.time() < end:
|
||||
try:
|
||||
s = socket.socket()
|
||||
s.settimeout(5)
|
||||
s.connect(TARGET)
|
||||
s.sendall(wv(len(handshake)) + handshake)
|
||||
with lock: sent += 1
|
||||
s.close()
|
||||
except: pass
|
||||
|
||||
threads = [threading.Thread(target=flood) for _ in range(NUM_IPS)]
|
||||
for t in threads: t.start()
|
||||
for t in threads: t.join()
|
||||
print(f"FLOOD_DONE:{sent}")
|
||||
"""
|
||||
tmp = tempfile.mktemp(suffix=".py")
|
||||
with open(tmp, "w") as f:
|
||||
f.write(script)
|
||||
subprocess.run(f'docker cp "{tmp}" attacker:/tmp/flood.py', shell=True, check=True)
|
||||
os.unlink(tmp)
|
||||
result = subprocess.run(
|
||||
f"docker exec attacker python3 /tmp/flood.py",
|
||||
shell=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=DURATION + 20,
|
||||
)
|
||||
for line in result.stdout.splitlines():
|
||||
if "FLOOD_DONE" in line:
|
||||
return int(line.split(":")[1])
|
||||
print(" [flood] stdout:", result.stdout[-300:])
|
||||
print(" [flood] stderr:", result.stderr[-300:])
|
||||
return 0
|
||||
|
||||
|
||||
# ── Main ──
|
||||
print("=" * 60)
|
||||
print("Rampart DDoS Simulation — 100 IP Handshake Flood + Legit Clients")
|
||||
print("=" * 60)
|
||||
|
||||
before = metrics()
|
||||
print_metrics("BEFORE", before)
|
||||
|
||||
# Launch flood in attacker container
|
||||
flood_total = run_flood_in_attacker()
|
||||
|
||||
# Launch legit clients during flood
|
||||
import threading
|
||||
|
||||
legit3 = threading.Thread(target=legit_client, args=(3, 25))
|
||||
legit2 = threading.Thread(target=legit_client, args=(2, 15))
|
||||
legit1 = threading.Thread(target=legit_client, args=(1, 5))
|
||||
legit1.start()
|
||||
time.sleep(0.1)
|
||||
legit2.start()
|
||||
time.sleep(0.1)
|
||||
legit3.start()
|
||||
|
||||
# Poll metrics during attack
|
||||
for i in range(DURATION // 5):
|
||||
time.sleep(5)
|
||||
m = metrics()
|
||||
print_metrics(f"t={(i + 1) * 5}s", m)
|
||||
|
||||
legit1.join()
|
||||
legit2.join()
|
||||
legit3.join()
|
||||
time.sleep(2)
|
||||
|
||||
after = metrics()
|
||||
print_metrics("AFTER", after)
|
||||
|
||||
# Summary
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("SUMMARY")
|
||||
print("=" * 60)
|
||||
diff = lambda k: int(after.get(k, "0")) - int(before.get(k, "0"))
|
||||
print(f" Total handshakes sent: {flood_total}")
|
||||
print(f" CPS: {flood_total // DURATION}")
|
||||
print(
|
||||
f" PoW challenges failed: +{diff('rampart_pow_challenges_total{result="failed"}')}"
|
||||
)
|
||||
print(
|
||||
f" PoW challenges passed: +{diff('rampart_pow_challenges_total{result="passed"}')}"
|
||||
)
|
||||
print(
|
||||
f" Connections allowed: +{diff('rampart_connections_total{result="allowed"}')}"
|
||||
)
|
||||
print(
|
||||
f" Connections blocked: +{diff('rampart_connections_total{result="blocked"}')}"
|
||||
)
|
||||
print(
|
||||
f" PoW difficulty (start): {before.get('rampart_pow_current_difficulty', '?')}"
|
||||
)
|
||||
print(f" PoW difficulty (end): {after.get('rampart_pow_current_difficulty', '?')}")
|
||||
Loading…
Add table
Add a link
Reference in a new issue