From 0b53ed720b9c3e4e67bf241152b156addfce35ed Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Tue, 4 Aug 2026 19:16:27 +0200 Subject: [PATCH] feat: VDS stress test harness (edge-only) and load-test results - deploy/test/stress/: multi-IP masked handshake flood (flood.py), legit client during attack (legit.py), full orchestration (run-stress.sh), edge Dockerfile with socat stub backend and high/defense configs - docs/research/load-test-report.md: v3 VDS loopback results (2026-08-04) - README.md: performance table update (4k conn/s raw, 99.6% blocked in defense, legit clients RTT 2-6ms during attack) - docs/testing.md: multi-IP masked stress-test procedure --- README.md | 14 ++ deploy/test/stress/README.md | 60 +++++++ deploy/test/stress/configs/edge-defense.toml | 46 +++++ deploy/test/stress/configs/edge-high.toml | 46 +++++ deploy/test/stress/edge.Dockerfile | 7 + deploy/test/stress/flood.py | 151 ++++++++++++++++ deploy/test/stress/legit.py | 68 ++++++++ deploy/test/stress/run-stress.sh | 172 +++++++++++++++++++ deploy/test/stress/start.sh | 4 + docs/research/load-test-report.md | 73 ++++++++ docs/testing.md | 19 ++ 11 files changed, 660 insertions(+) create mode 100644 deploy/test/stress/README.md create mode 100644 deploy/test/stress/configs/edge-defense.toml create mode 100644 deploy/test/stress/configs/edge-high.toml create mode 100644 deploy/test/stress/edge.Dockerfile create mode 100644 deploy/test/stress/flood.py create mode 100644 deploy/test/stress/legit.py create mode 100644 deploy/test/stress/run-stress.sh create mode 100644 deploy/test/stress/start.sh diff --git a/README.md b/README.md index 4b5cb69..bf5433a 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,20 @@ Tested on Hetzner CX31 (4 vCPU, 8GB, KVM), Ubuntu 22.04, kernel 5.15 Note: Real L7 throughput (handshake + HMAC + rate limit): ~60-70k conn/s (epoll), ~85-95k (io_uring). +#### VDS stress test (2026-08-04) — edge-only, loopback + +VDS 2 vCPU / 3.8GB / Ubuntu 22.04, Docker bridge. Edge-only (слои 1–3), без Redis/Velocity/Paper. +Атака маскировалась под обычный трафик: 100 source IP, валидные Minecraft handshake. +Подробности: [load-test-report.md](docs/research/load-test-report.md), скрипты: [deploy/test/stress](deploy/test/stress). + +| Scenario | Result | +|----------|--------| +| Raw L7 throughput (valid handshake → HMAC → backend) | ~4k conn/s proxied, 100% (121.5k/30s; edge CPU ~179%, 2 cores) | +| Defense vs masked 100-IP flood (default 5 pps/IP) | **99.6% blocked** (528 allowed vs 119,376 blocked), CPU ~32% | +| Legit clients during attack | 5/5 OK, RTT 2.2–5.8ms | +| SYN flood (no XDP) | 0 impact — handled by kernel | +| Active connections | 300 held trivially (CPU ~0%, 7MB); limit is backend/fd, not edge | + ### Quick Start ```bash diff --git a/deploy/test/stress/README.md b/deploy/test/stress/README.md new file mode 100644 index 0000000..8353524 --- /dev/null +++ b/deploy/test/stress/README.md @@ -0,0 +1,60 @@ +# Стресс-тест Rampart на VDS (без Redis/Velocity/Paper) + +Проверяет edge ноду (слои 1–3) на реальном VDS. Не требует Redis, Velocity, Paper или ClickHouse — только `rampart-core` и stub-бэкенд (socat echo) в одном контейнере. + +## Топология + +``` +┌─────────────── host VDS ───────────────┐ +│ docker bridge 172.30.0.0/24 │ +│ │ +│ rampart-edge (172.30.0.2) │ +│ ├─ rampart-core :25565 (bind 0.0.0.0)│ +│ ├─ socat echo :25566 (127.0.0.1) │ ← stub бэкенд +│ └─ metrics :9090 │ +│ │ +│ rampart-attacker (172.30.0.3) │ +│ └─ 100 source IP (172.30.0.101-200) │ ← flood.py / hping3 +└────────────────────────────────────────┘ +``` + +- Метрики на хосте: `curl http://127.0.0.1:9090/metrics` +- Edge слушает на хосте: `127.0.0.1:25565` + +## Быстрый старт + +```bash +# 1. На VDS: клонировать репозиторий, собрать бинарь +git clone https://github.com/loki5512344/rampart.git && cd rampart +cargo build --release --bin rampart-core + +# 2. Запустить весь цикл (setup + 4 фазы) +# run-stress.sh сам найдёт бинарь (target/release), а папку — по себе (переменная DIR опциональна) +cd deploy/test/stress +bash run-stress.sh +``` + +> Для запуска из другого места задай `DIR` (по умолчанию — папка скрипта). + +## Фазы + +| Фаза | Конфиг | Что делает | Проверяет | +|------|--------|-----------|-----------| +| A | `edge-high.toml` (лимиты 100k) | флуд валидными handshake с 100 IP | сырую пропускную способность L7 | +| B | `edge-defense.toml` (дефолт 5 pps/IP) | та же атака | rate limit + reputation ban, доступность легитимных клиентов | +| C | — | SYN flood hping3 (rand-source) | поведение без XDP (обрабатывает kernel) | +| D | `edge-high.toml` | 300 keepalive-коннектов | удержание активных соединений | + +Во время фаз A и B параллельно подключается `legit.py` (легитимные MC клиенты), меряющий RTT — доказывает, что реальные игроки проходят во время атаки. + +## Атака маскируется под обычный трафик + +`flood.py` шлёт **валидные** Minecraft handshake (протокол 767, packet id 0x00) со случайными hostname из пула (`play.example.com`, `mc.example.com`, ...) и ждёт ответ бэкенда — на уровне L7 флуд неотличим от легитимного клиента. Различие даёт только per-IP rate limit и репутация. + +## Метрики для сбора + +```bash +curl -s http://127.0.0.1:9090/metrics | grep -E 'rampart_(connections_total|pow_challenges|attack_status)' +``` + +См. [load-test-report.md](../../research/load-test-report.md) — результаты прогона на VDS (2026-08-04). diff --git a/deploy/test/stress/configs/edge-defense.toml b/deploy/test/stress/configs/edge-defense.toml new file mode 100644 index 0000000..12e85ac --- /dev/null +++ b/deploy/test/stress/configs/edge-defense.toml @@ -0,0 +1,46 @@ +[bind] +address = "0.0.0.0" +port = 25565 + +[backend] +address = "127.0.0.1" +port = 25566 + +[hmac] +secret = "test_secret_32_bytes_long_for_integration_test" + +[workers] +count = 2 + +[limits] +handshake_timeout_secs = 5 +max_connections_per_ip = 10 +rate_limit_status_pps = 2 +rate_limit_login_pps = 5 +rate_limit_burst = 10 + +[store] +redis_url = "" +blacklist_cache_ttl_secs = 300 + +[xdp] +enabled = false +interface = "eth0" + +[death_code] +enabled = true +ban_duration_secs = 3600 + +[metrics] +enabled = true +port = 9090 + +[logging] +level = "debug" +format = "text" + +[pow] +enabled = false +difficulty = 4 + +whitelist = ["127.0.0.1", "::1"] diff --git a/deploy/test/stress/configs/edge-high.toml b/deploy/test/stress/configs/edge-high.toml new file mode 100644 index 0000000..b1fedf7 --- /dev/null +++ b/deploy/test/stress/configs/edge-high.toml @@ -0,0 +1,46 @@ +[bind] +address = "0.0.0.0" +port = 25565 + +[backend] +address = "127.0.0.1" +port = 25566 + +[hmac] +secret = "test_secret_32_bytes_long_for_integration_test" + +[workers] +count = 2 + +[limits] +handshake_timeout_secs = 5 +max_connections_per_ip = 100000 +rate_limit_status_pps = 100000 +rate_limit_login_pps = 100000 +rate_limit_burst = 100000 + +[store] +redis_url = "" +blacklist_cache_ttl_secs = 300 + +[xdp] +enabled = false +interface = "eth0" + +[death_code] +enabled = true +ban_duration_secs = 3600 + +[metrics] +enabled = true +port = 9090 + +[logging] +level = "debug" +format = "text" + +[pow] +enabled = false +difficulty = 4 + +whitelist = ["127.0.0.1", "::1"] diff --git a/deploy/test/stress/edge.Dockerfile b/deploy/test/stress/edge.Dockerfile new file mode 100644 index 0000000..7bf81e5 --- /dev/null +++ b/deploy/test/stress/edge.Dockerfile @@ -0,0 +1,7 @@ +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends socat ca-certificates && rm -rf /var/lib/apt/lists/* +COPY rampart-core /usr/local/bin/rampart-core +COPY start.sh /start.sh +RUN chmod +x /start.sh +EXPOSE 25565 9090 +CMD ["/start.sh"] diff --git a/deploy/test/stress/flood.py b/deploy/test/stress/flood.py new file mode 100644 index 0000000..3f14c2a --- /dev/null +++ b/deploy/test/stress/flood.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""Много-IP Minecraft stress generator, маскирующийся под обычный трафик. + +Отправляет валидные MC handshake с рандомными hostname из разных source IP +(--ips-start..--ips-end должны быть назначены на eth0 контейнера). + +Modes: + connect - TCP connect + close (conn/s flood) + handshake - валидный MC handshake + ждём ответ backend (маскировка под клиента) + status - status-ping handshake (state 1) + slowloris - connect + 1 байт + hold + keepalive - connect + handshake + держим соединение +""" + +import argparse +import random +import socket +import struct +import threading +import time +from datetime import datetime + +HOSTS = [ + "play.example.com", + "mc.example.com", + "lobby.example.com", + "hub.example.com", + "survival.example.com", + "skyblock.example.com", + "bedwars.example.com", + "minigames.example.com", + "vip.example.com", +] +PROTOCOL = 767 + + +def pack_varint(value): + buf = [] + while True: + byte = value & 0x7F + value >>= 7 + if value: + byte |= 0x80 + buf.append(byte) + if not value: + break + return bytes(buf) + + +def make_handshake(state=2, host=None): + host = host or random.choice(HOSTS) + pkt = bytearray() + pkt.extend(pack_varint(0)) # packet ID 0x00 + pkt.extend(pack_varint(PROTOCOL)) + pkt.extend(pack_varint(len(host))) + pkt.extend(host.encode()) + pkt.extend(struct.pack(">H", 25565)) + pkt.extend(pack_varint(state)) + return pack_varint(len(pkt)) + bytes(pkt) + + +def parse_args(): + p = argparse.ArgumentParser() + p.add_argument("--target", default="172.30.0.2") + p.add_argument("--port", type=int, default=25565) + p.add_argument( + "--mode", + choices=["connect", "handshake", "status", "slowloris", "keepalive"], + default="handshake", + ) + p.add_argument("--duration", type=int, default=30) + p.add_argument("--threads", type=int, default=100) + p.add_argument("--ips-start", type=int, default=101) + p.add_argument("--ips-end", type=int, default=200) + p.add_argument("--ips-base", default="172.30.0.") + p.add_argument("--timeout", type=float, default=5.0) + return p.parse_args() + + +def main(): + args = parse_args() + src_ips = [f"{args.ips_base}{i}" for i in range(args.ips_start, args.ips_end + 1)] + stop = threading.Event() + stats = {"sent": 0, "lock": threading.Lock()} + + def worker(): + while not stop.is_set(): + src = random.choice(src_ips) + try: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + s.bind((src, 0)) + s.settimeout(args.timeout) + s.connect((args.target, args.port)) + if args.mode == "handshake": + s.sendall(make_handshake()) + try: + s.recv(1) + except socket.timeout: + pass + elif args.mode == "status": + s.sendall(make_handshake(state=1)) + try: + s.recv(1) + except socket.timeout: + pass + elif args.mode == "slowloris": + s.sendall(b"\x01") + time.sleep(30) + elif args.mode == "keepalive": + s.sendall(make_handshake()) + time.sleep(30) + with stats["lock"]: + stats["sent"] += 1 + s.close() + except OSError: + pass + except Exception: + pass + + threads = [ + threading.Thread(target=worker, daemon=True) for _ in range(args.threads) + ] + for t in threads: + t.start() + + t0 = time.time() + try: + while time.time() - t0 < args.duration: + time.sleep(1) + with stats["lock"]: + cur = stats["sent"] + print( + f" [{datetime.now():%H:%M:%S}] src_ips={len(src_ips)} sent={cur:8d} rate={cur / max(time.time() - t0, 1):9.1f}/s" + ) + except KeyboardInterrupt: + pass + finally: + stop.set() + time.sleep(0.5) + + elapsed = max(time.time() - t0, 0.001) + with stats["lock"]: + total = stats["sent"] + print( + f"DONE total={total} avg={total / elapsed:.1f}/s elapsed={elapsed:.1f}s src_ips={len(src_ips)}" + ) + + +if __name__ == "__main__": + main() diff --git a/deploy/test/stress/legit.py b/deploy/test/stress/legit.py new file mode 100644 index 0000000..3a5ba52 --- /dev/null +++ b/deploy/test/stress/legit.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Легитимный Minecraft клиент: подключается ВО ВРЕМЯ DDoS и меряет RTT.""" + +import argparse +import socket +import struct +import time + + +def pack_varint(value): + buf = [] + while True: + byte = value & 0x7F + value >>= 7 + if value: + byte |= 0x80 + buf.append(byte) + if not value: + break + return bytes(buf) + + +def make_handshake(host="play.example.com", port=25565, state=2): + pkt = bytearray() + pkt.extend(pack_varint(0)) + pkt.extend(pack_varint(767)) + pkt.extend(pack_varint(len(host))) + pkt.extend(host.encode()) + pkt.extend(struct.pack(">H", port)) + pkt.extend(pack_varint(state)) + return pack_varint(len(pkt)) + bytes(pkt) + + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--target", default="127.0.0.1") + p.add_argument("--port", type=int, default=25565) + p.add_argument("--count", type=int, default=6) + p.add_argument("--interval", type=float, default=5.0) + p.add_argument("--timeout", type=float, default=10.0) + p.add_argument("--name", default="legit") + args = p.parse_args() + + for i in range(args.count): + t0 = time.time() + s = None + try: + s = socket.create_connection((args.target, args.port), timeout=args.timeout) + s.settimeout(args.timeout) + s.sendall(make_handshake()) + resp = s.recv(1) + rtt = (time.time() - t0) * 1000 + ok = len(resp) > 0 + print(f"[{args.name}#{i}] {'OK' if ok else 'NO_RESP'} rtt={rtt:.1f}ms") + except Exception as e: + rtt = (time.time() - t0) * 1000 + print(f"[{args.name}#{i}] FAIL {type(e).__name__}: {e} rtt={rtt:.1f}ms") + finally: + if s: + try: + s.close() + except Exception: + pass + time.sleep(args.interval) + + +if __name__ == "__main__": + main() diff --git a/deploy/test/stress/run-stress.sh b/deploy/test/stress/run-stress.sh new file mode 100644 index 0000000..f5db4a1 --- /dev/null +++ b/deploy/test/stress/run-stress.sh @@ -0,0 +1,172 @@ +#!/usr/bin/env bash +set -euo pipefail + +NET="rampart-stress-net" +SUB="172.30.0.0/24" +EDGE_IP="172.30.0.2" +ATT_IP="172.30.0.3" +DIR="${DIR:-$(cd "$(dirname "$0")" && pwd)}" +IPS_START=101 +IPS_END=200 + +edge_metrics() { + curl -s --max-time 5 http://127.0.0.1:9090/metrics 2>/dev/null | grep -E '^rampart_' || true +} +mget() { + local label="$1" + local val + val=$(edge_metrics | grep -F "$label" | awk '{print $NF}' | head -1) + echo "${val:-0}" +} +edge_cpu() { + docker stats --no-stream --format '{{.CPUPerc}}' rampart-edge 2>/dev/null || echo "?" +} + +start_edge() { + local cfg="$1" + docker rm -f rampart-edge 2>/dev/null || true + docker run -d --name rampart-edge --network "$NET" --ip "$EDGE_IP" \ + -p 127.0.0.1:25565:25565 -p 127.0.0.1:9090:9090 \ + -v "$DIR/configs/$cfg:/etc/rampart/config.toml:ro" \ + rampart-edge >/dev/null + for _ in $(seq 1 30); do + if edge_metrics | grep -q rampart_; then return 0; fi + sleep 2 + done + echo "ERROR: edge не поднялся" >&2 + return 1 +} + +run_flood_phase() { + local phase="$1" cfg="$2" duration="$3" mode="$4" threads="$5" ips="$6" name="$7" + echo "" + echo "==============================================================" + echo "ФАЗА $phase: $name (cfg=$cfg, duration=${duration}s, mode=$mode, ips=$ips, threads=$threads)" + echo "==============================================================" + start_edge "$cfg" + + local ba bb bp + ba=$(mget 'rampart_connections_total{result="allowed"}') + bb=$(mget 'rampart_connections_total{result="blocked"}') + bp=$(mget 'rampart_pow_challenges_total{result="failed"}') + echo "baseline: allowed=$ba blocked=$bb pow_fail=$bp" + + docker exec rampart-attacker python3 /flood.py \ + --target "$EDGE_IP" --port 25565 --mode "$mode" \ + --duration "$duration" --threads "$threads" \ + --ips-start "$IPS_START" --ips-end "$ips" \ + > /tmp/flood_$phase.log 2>&1 & + local flood_pid=$! + + python3 "$DIR/legit.py" --target 127.0.0.1 --port 25565 \ + --count 5 --interval $((duration / 5)) --name "phase$phase" \ + > /tmp/legit_$phase.log 2>&1 & + local legit_pid=$! + + for i in $(seq 1 $((duration / 5))); do + sleep 5 + local a bl p s cpu + a=$(mget 'rampart_connections_total{result="allowed"}') + bl=$(mget 'rampart_connections_total{result="blocked"}') + p=$(mget 'rampart_pow_challenges_total{result="failed"}') + s=$(mget 'rampart_attack_status ') + cpu=$(edge_cpu) + echo " [t=${i}x5s] status=$s cpu=$cpu allowed=+$((a - ba)) blocked=+$((bl - bb)) pow_fail=+$((p - bp))" + done + + wait "$flood_pid" || true + wait "$legit_pid" || true + + local ea eb ep es ec + ea=$(mget 'rampart_connections_total{result="allowed"}') + eb=$(mget 'rampart_connections_total{result="blocked"}') + ep=$(mget 'rampart_pow_challenges_total{result="failed"}') + es=$(mget 'rampart_attack_status ') + ec=$(edge_cpu) + echo "--- итог фазы $phase ---" + echo " attack_status=$es cpu=$ec" + echo " allowed: $((ea - ba)) (+$(( (ea - ba) / duration ))/s)" + echo " blocked: $((eb - bb)) (+$(( (eb - bb) / duration ))/s)" + echo " pow_fail: $((ep - bp))" + echo "--- легитимные клиенты во время фазы ---" + grep -E '^\[phase' /tmp/legit_$phase.log || true + echo "" +} + +echo "=== [setup] сеть + образы ===" +docker rm -f rampart-edge rampart-attacker 2>/dev/null || true +docker network rm -f "$NET" 2>/dev/null || true +docker network create --subnet "$SUB" "$NET" >/dev/null + +# Подготовка контекстов: бинарь ищем в target/release репозитория +mkdir -p "$DIR/edge-ctx" "$DIR/attacker-ctx" +cp "$DIR/flood.py" "$DIR/attacker-ctx/flood.py" 2>/dev/null || true +if [ ! -f "$DIR/edge-ctx/rampart-core" ]; then + for p in "$DIR/rampart-core" "$DIR/repo/target/release/rampart-core" "$DIR/../target/release/rampart-core"; do + if [ -f "$p" ]; then cp "$p" "$DIR/edge-ctx/rampart-core"; break; fi + done +fi +[ -f "$DIR/edge-ctx/rampart-core" ] || { echo "ERROR: rampart-core не найден. Собери: cargo build --release --bin rampart-core" >&2; exit 1; } + +docker build -q -f "$DIR/edge.Dockerfile" -t rampart-edge "$DIR/edge-ctx" +docker build -q -f "$DIR/attacker.Dockerfile" -t rampart-attacker "$DIR/attacker-ctx" + +echo "=== [setup] attacker + 100 source IP ===" +docker rm -f rampart-attacker 2>/dev/null || true +docker run -d --name rampart-attacker --network "$NET" --ip "$ATT_IP" \ + --cap-add=NET_RAW --cap-add=NET_ADMIN rampart-attacker >/dev/null +docker exec rampart-attacker sh -c ' + for i in $(seq 101 200); do + ip addr add 172.30.0.$i/24 dev eth0 2>/dev/null || true + done + echo "source IPs on eth0: $(ip addr show eth0 | grep -c "inet ")" +' + +echo "" +echo "############### PHASE A: СЫРАЯ ПРОПУСКНАЯ СПОСОБНОСТЬ ###############" +echo "############### (лимиты сняты: 100k pps, 100 src IP, валидные handshake) ###############" +run_flood_phase A edge-high.toml 30 handshake 100 "$IPS_END" "raw throughput, 100 IP flood, valid handshake" + +echo "" +echo "############### PHASE B: ЗАЩИТА (дефолтные лимиты 5 pps/IP) ###############" +echo "############### (та же атака, но теперь edge режет по IP; легитимные клиенты заходят) ###############" +run_flood_phase B edge-defense.toml 30 handshake 100 "$IPS_END" "defense, rate limit 5pps/IP + reputation bans" + +echo "" +echo "############### PHASE C: SYN flood ###############" +echo "==============================================================" +echo "ФАЗА C: SYN flood hping3 (rand-source, 20s)" +echo "==============================================================" +ba=$(mget 'rampart_connections_total{result="allowed"}') +bb=$(mget 'rampart_connections_total{result="blocked"}') +timeout 20 docker exec rampart-attacker hping3 -S --flood --rand-source -p 25565 "$EDGE_IP" || true +sleep 2 +ea=$(mget 'rampart_connections_total{result="allowed"}') +eb=$(mget 'rampart_connections_total{result="blocked"}') +es=$(mget 'rampart_attack_status ') +echo " attack_status=$es cpu=$(edge_cpu) allowed=+$((ea - ba)) blocked=+$((eb - bb))" +echo " (SYN flood обрабатывается kernel'ом/XDP, L7 edge почти не задет)" +echo "" + +echo "" +echo "############### PHASE D: активные соединения (keepalive) ###############" +echo "==============================================================" +echo "ФАЗА D: 300 keepalive коннектов (валидный handshake, держим открытым)" +echo "==============================================================" +start_edge edge-high.toml +ba=$(mget 'rampart_connections_total{result="allowed"}') +docker exec rampart-attacker python3 /flood.py \ + --target "$EDGE_IP" --port 25565 --mode keepalive \ + --duration 20 --threads 300 --ips-start "$IPS_START" --ips-end "$IPS_END" || true +sleep 2 +ea=$(mget 'rampart_connections_total{result="allowed"}') +echo " allowed=+$((ea - ba)) cpu=$(edge_cpu)" + +echo "" +echo "==============================================================" +echo "ИТОГОВЫЙ СВОД" +echo "==============================================================" +edge_metrics | grep -E 'connections_total|pow_challenges|attack_status' +echo "" +echo "CPU/память контейнеров:" +docker stats --no-stream --format 'table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}' rampart-edge rampart-attacker diff --git a/deploy/test/stress/start.sh b/deploy/test/stress/start.sh new file mode 100644 index 0000000..80da93b --- /dev/null +++ b/deploy/test/stress/start.sh @@ -0,0 +1,4 @@ +#!/bin/sh +# Stub backend (TCP echo) на 127.0.0.1:25566 для стресс-теста edge ноды. +socat TCP-LISTEN:25566,fork,reuseaddr,bind=127.0.0.1 EXEC:'cat' & +exec rampart-core --config /etc/rampart/config.toml diff --git a/docs/research/load-test-report.md b/docs/research/load-test-report.md index 04c558a..aefb6fb 100644 --- a/docs/research/load-test-report.md +++ b/docs/research/load-test-report.md @@ -262,3 +262,76 @@ Before this test, the difficulty adjuster was untested under load. The code anal a. Attack connections were being dropped before reaching the tunnel handler (kernel SYN backlog or XDP) b. Or the Metrics endpoint polling interval (5s) wasn't capturing the escalation before difficulty reset - In v2, with explicit metrics reads at each poll interval, the escalation should be visible + +--- + +## v3 Test — VDS Loopback, Edge-only (2026-08-04) + +> Date: 2026-08-04 +> Scripts: `deploy/test/stress/` (flood.py, legit.py, run-stress.sh) +> Environment: VDS 2 vCPU / 3.8 GB RAM / Ubuntu 22.04, Docker bridge 172.30.0.0/24 +> Target: Rampart v0.2.0, **edge-only** (слои 1–3) — без Redis, Velocity, Paper, ClickHouse +> Backend: socat TCP echo (stub) в том же контейнере на 127.0.0.1:25566 +> XDP: disabled, PoW: disabled, workers: 2 + +### Setup + +| Container | Role | IP | +|---|---|---| +| `rampart-edge` | rampart-core + socat stub backend | 172.30.0.2 | +| `rampart-attacker` | load generator, 100 source IP (172.30.0.101–200) | 172.30.0.3 | + +Атака **маскируется под обычный трафик**: `flood.py` отправляет валидные Minecraft handshake +(protocol 767, packet id 0x00) со случайными hostname и ждёт ответа бэкенда — на уровне L7 +ботнет неотличим от легитимных клиентов. Различение даёт только per-IP rate limit + репутация. + +### Phase A — Raw throughput (лимиты сняты: 100k pps/IP) + +| Measure | Value | +|---|---| +| Flood sent (100 IP, 30s) | ~121.5k handshake | +| Peak flood rate | ~4.0k conn/s | +| Edge allowed (proxied to backend) | 121,484 → **100%** | +| Edge CPU (peak) | ~179% (оба ядра) | +| Attack detector | `attack_status=1` (Suspicious) на старте, затем 0 (baseline-EWMA адаптируется) | + +Легитимные клиенты во время флуда: 2/5 OK (RTT 3–43ms), 3 NO_RESP — без лимитов +флуд «душит» и легитимных клиентов. + +### Phase B — Defense (дефолтные лимиты: 5 pps/IP, burst 10, reputation ban) + +| Measure | Value | +|---|---| +| Edge blocked | 119,376 | +| Edge allowed | 528 | +| Block ratio | **~99.6%** | +| Edge CPU (max) | ~32% | + +Легитимные клиенты во время атаки (тот же флуд, 100 IP): **5/5 OK, RTT 2.2–5.8ms**. +Rate limit срезает каждый IP до ~5 conn/s, после ~10–20 злоупотреблений IP уходит в +blacklist (reputation < -40) на 3600s. Реальный клиент (1 conn каждые 5s) не затронут. + +### Phase C — SYN flood (hping3 --rand-source) + +Edge не пострадал (allowed/blocked не изменились): без XDP SYN-флуд обрабатывает kernel. +L7 edge задет только при установленных TCP-соединениях. + +### Phase D — Active connections + +300 keepalive-соединений (валидный handshake, держим открытым) — все проксированы. +CPU ~0%, память ~7 MB. Удержание соединений упирается в backend (socat fork) и лимит fd, +не в edge. + +### Выводы v3 + +1. **Полный L7-путь (parse → HMAC sign → backend → relay)**: ~4.0k conn/s на 2-ядерном VDS + при 100% прохождении (121.5k за 30s). Узкое место на этой конфигурации — сам генератор + (RTT round-trip до echo-бэкенда), не edge. +2. **Rate limit + reputation работают**: та же маскированная атака режется до ~0.4% прохода + (528 vs 119,376 blocked) при CPU ~32%. +3. **Легитимные клиенты доступны во время атаки**: RTT 2.2–5.8ms, 100% успех в defense-режиме. +4. **Детектор** отмечает Suspicious на старте флуда, но baseline-EWMA быстро адаптируется — + UnderAttack требует устойчивого превышения >3× базового уровня. +5. **SYN flood без XDP** — вне зоны L7 edge; на этой конфигурации защиту от него даёт + только XDP/eBPF или ядро (syncookies). + diff --git a/docs/testing.md b/docs/testing.md index a0a5db5..f3c95ee 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -184,6 +184,25 @@ done # (отправляем handshake по 1 байту с задержкой 100ms) ``` +### Готовый много-IP стресс-тест на VDS (edge-only) + +`deploy/test/stress/` — полный цикл без Redis/Velocity/Paper: edge-контейнер с stub-бэкендом +(socat echo) + attacker-контейнер со 100 source IP. Атака **маскируется под обычный трафик** +(валидные handshake со случайными hostname), во время флуда параллельно заходят легитимные +клиенты (`legit.py`), замеряющие RTT. + +```bash +# На VDS +git clone https://github.com/loki5512344/rampart.git && cd rampart +cargo build --release --bin rampart-core +cp target/release/rampart-core deploy/test/stress/edge-ctx/rampart-core +cd deploy/test/stress && bash run-stress.sh +``` + +Фазы: A — сырая пропускная способность (лимиты 100k), B — защита (дефолт 5 pps/IP), +C — SYN flood, D — активные соединения. Результаты прогона 2026-08-04 — +в [load-test-report.md](research/load-test-report.md). + --- ## 6. CI Pipeline