From d937f816bf19b43b72510e3f60756ab0d88fd2d2 Mon Sep 17 00:00:00 2001 From: Crimson Date: Sat, 9 May 2026 02:17:25 +0500 Subject: [PATCH] feat: bootstrap awg2 harness rust workspace and docs --- .../amnezia-tc-watcher.service.txt | 14 + .../amnezia-tc.service.txt | 14 + .../check-vpn-limits.txt | 190 +++++ .../docker_watcher.sh.txt | 25 + Amnezia limits (old bash)/iproute2_a_core.txt | 24 + Amnezia limits (old bash)/limit_peers.sh.txt | 138 ++++ Amnezia limits (old bash)/permissions.txt | 17 + .../traffic_control-was.txt | 172 +++++ Amnezia limits (old bash)/wg-peers.txt | 129 ++++ Amnezia limits (old bash)/Нюансы.txt | 56 ++ README.md | 27 + awg2-harness.service | 17 + config.example.toml | 33 + docs/ACCEPTANCE_CHECKLIST.md | 109 +++ docs/ANTI_TORRENT.md | 115 +++ docs/CONFIGURATION.md | 96 +++ docs/IMPLEMENTATION_SPEC.md | 444 ++++++++++++ docs/INSTALLATION.md | 153 ++++ docs/MIGRATION.md | 269 +++++++ docs/OLD_BASH_BEHAVIOR.md | 230 ++++++ docs/README.md | 11 + history.txt | 26 + reboot_module/.gitignore | 4 + reboot_module/Cargo.lock | 7 + reboot_module/Cargo.toml | 18 + reboot_module/README.md | 111 +++ reboot_module/reboot-scheduler.service | 22 + reboot_module/src/main.rs | 286 ++++++++ tools/README.md | 2 + traffic_core/.gitignore | 1 + traffic_core/Cargo.lock | 682 ++++++++++++++++++ traffic_core/Cargo.toml | 13 + traffic_core/src/cli.rs | 24 + traffic_core/src/config.rs | 108 +++ traffic_core/src/daemon.rs | 52 ++ traffic_core/src/docker.rs | 65 ++ traffic_core/src/error.rs | 10 + traffic_core/src/firewall.rs | 29 + traffic_core/src/iface.rs | 41 ++ traffic_core/src/lock.rs | 28 + traffic_core/src/main.rs | 66 ++ traffic_core/src/netns.rs | 16 + traffic_core/src/reboot.rs | 44 ++ traffic_core/src/status.rs | 40 + traffic_core/src/swap.rs | 51 ++ traffic_core/src/traffic.rs | 73 ++ traffic_core/src/wg.rs | 93 +++ 47 files changed, 4195 insertions(+) create mode 100644 Amnezia limits (old bash)/amnezia-tc-watcher.service.txt create mode 100644 Amnezia limits (old bash)/amnezia-tc.service.txt create mode 100644 Amnezia limits (old bash)/check-vpn-limits.txt create mode 100644 Amnezia limits (old bash)/docker_watcher.sh.txt create mode 100644 Amnezia limits (old bash)/iproute2_a_core.txt create mode 100644 Amnezia limits (old bash)/limit_peers.sh.txt create mode 100644 Amnezia limits (old bash)/permissions.txt create mode 100644 Amnezia limits (old bash)/traffic_control-was.txt create mode 100644 Amnezia limits (old bash)/wg-peers.txt create mode 100644 Amnezia limits (old bash)/Нюансы.txt create mode 100644 README.md create mode 100644 awg2-harness.service create mode 100644 config.example.toml create mode 100644 docs/ACCEPTANCE_CHECKLIST.md create mode 100644 docs/ANTI_TORRENT.md create mode 100644 docs/CONFIGURATION.md create mode 100644 docs/IMPLEMENTATION_SPEC.md create mode 100644 docs/INSTALLATION.md create mode 100644 docs/MIGRATION.md create mode 100644 docs/OLD_BASH_BEHAVIOR.md create mode 100644 docs/README.md create mode 100644 history.txt create mode 100644 reboot_module/.gitignore create mode 100644 reboot_module/Cargo.lock create mode 100644 reboot_module/Cargo.toml create mode 100644 reboot_module/README.md create mode 100644 reboot_module/reboot-scheduler.service create mode 100644 reboot_module/src/main.rs create mode 100644 tools/README.md create mode 100644 traffic_core/.gitignore create mode 100644 traffic_core/Cargo.lock create mode 100644 traffic_core/Cargo.toml create mode 100644 traffic_core/src/cli.rs create mode 100644 traffic_core/src/config.rs create mode 100644 traffic_core/src/daemon.rs create mode 100644 traffic_core/src/docker.rs create mode 100644 traffic_core/src/error.rs create mode 100644 traffic_core/src/firewall.rs create mode 100644 traffic_core/src/iface.rs create mode 100644 traffic_core/src/lock.rs create mode 100644 traffic_core/src/main.rs create mode 100644 traffic_core/src/netns.rs create mode 100644 traffic_core/src/reboot.rs create mode 100644 traffic_core/src/status.rs create mode 100644 traffic_core/src/swap.rs create mode 100644 traffic_core/src/traffic.rs create mode 100644 traffic_core/src/wg.rs diff --git a/Amnezia limits (old bash)/amnezia-tc-watcher.service.txt b/Amnezia limits (old bash)/amnezia-tc-watcher.service.txt new file mode 100644 index 0000000..c4fb88a --- /dev/null +++ b/Amnezia limits (old bash)/amnezia-tc-watcher.service.txt @@ -0,0 +1,14 @@ +# /etc/systemd/system/amnezia-tc-watcher.service +[Unit] +Description=AmneziaWG docker watcher +After=docker.service +Requires=docker.service + +[Service] +Type=simple +Restart=always +RestartSec=3 +ExecStart=/opt/amnezia/docker_watcher.sh + +[Install] +WantedBy=multi-user.target diff --git a/Amnezia limits (old bash)/amnezia-tc.service.txt b/Amnezia limits (old bash)/amnezia-tc.service.txt new file mode 100644 index 0000000..86be25a --- /dev/null +++ b/Amnezia limits (old bash)/amnezia-tc.service.txt @@ -0,0 +1,14 @@ +# /etc/systemd/system/amnezia-tc.service +[Unit] +Description=Apply AmneziaWG traffic limits +After=docker.service network-online.target +Requires=docker.service + +[Service] +Type=oneshot +Environment=WG_IF=awg0 +ExecStart=/opt/amnezia/limit_peers.sh +RemainAfterExit=yes + +[Install] +WantedBy=multi-user.target diff --git a/Amnezia limits (old bash)/check-vpn-limits.txt b/Amnezia limits (old bash)/check-vpn-limits.txt new file mode 100644 index 0000000..3fa7eb3 --- /dev/null +++ b/Amnezia limits (old bash)/check-vpn-limits.txt @@ -0,0 +1,190 @@ +root@5862577-oj93961:~# cat /usr/local/bin/check-vpn-limits +#!/bin/bash +echo "┌──────────────────────────────────────────────────┐" +echo "│ AmneziaWG Traffic Control v1.2 │" +echo "└──────────────────────────────────────────────────┘" +echo "" + +# === Auto-detect container and interface === +# Supported container names in order of preference +POSSIBLE_CONTAINERS=("amnezia-awg" "amnezia-awg2") +CONTAINER="" +for name in "${POSSIBLE_CONTAINERS[@]}"; do + if docker ps --format '{{.Names}}' 2>/dev/null | grep -xq "$name"; then + CONTAINER="$name" + break + fi +done + +# helper to run docker exec if container found +docker_exec() { + if [ -z "$CONTAINER" ]; then + return 1 + fi + docker exec "$CONTAINER" "$@" +} + +# detect interface name inside container (prefer wg*, tun*, amnezia*, veth*) +IF="" +if [ -n "$CONTAINER" ]; then + IF=$(docker_exec sh -c "ip -o link show 2>/dev/null | awk -F': ' '{print \$2}'" 2>/dev/null | \ + egrep -o '^(wg[0-9]+|wg|amnezia[0-9]*|tun[0-9]+|veth[0-9]+)' | head -n1 || true) + + # fallback: pick first iface that has an ipv4 address inside container + if [ -z "$IF" ]; then + IF=$(docker_exec sh -c "ip -o -4 addr show 2>/dev/null | awk '{print \$2}'" 2>/dev/null | head -n1 || true) + fi +fi + +# final fallback to wg0 for backwards-compatibility (if nothing found) +IF=${IF:-wg0} + +# 1. Состояние системы +echo "📊 СИСТЕМНЫЙ СТАТУС:" +if systemctl is-active amnezia-tc.service >/dev/null 2>&1; then + echo " Сервис: ✅ активен" +else + echo " Сервис: ❌ неактивен" +fi + +if systemctl is-active amnezia-tc.timer >/dev/null 2>&1; then + next_time=$(systemctl list-timers --all | grep amnezia 2>/dev/null | head -1 | awk '{print $2, $3, $4, $5}') + if [ -n "$next_time" ]; then + echo " Таймер: ✅ активен (след. запуск: $next_time)" + else + echo " Таймер: ✅ активен (расписание не найдено)" + fi +else + echo " Таймер: ❌ неактивен" +fi + +# container status (if found) +if [ -n "$CONTAINER" ]; then + container_status=$(docker ps --filter "name=^${CONTAINER}$" --format '{{.Status}}' 2>/dev/null) + echo " Контейнер: ✅ ${CONTAINER} — ${container_status}" +else + # Try to detect if any amnezia container exists at all (for clearer message) + any_amnezia=$(docker ps --format '{{.Names}}' 2>/dev/null | grep -i amne || true) + if [ -n "$any_amnezia" ]; then + echo " Контейнер: ❌ ожидается amnezia-awg/awg2, но не найден в списке запущенных контейнеров. Найдены: $any_amnezia" + else + echo " Контейнер: ❌ не запущен" + fi +fi +echo "" + +# 2. Клиенты +echo "👥 ПОДКЛЮЧЕННЫЕ КЛИЕНТЫ:" +if [ -n "$CONTAINER" ]; then + docker_exec wg show 2>/dev/null | awk ' + BEGIN {count=0} + /^peer:/ {peer=$2} + /allowed ips:/ {ip=$3; gsub("/32","",ip)} + /latest handshake:/ { + if ($3 == "Never") { + status="❌ отключен" + time="никогда" + } else { + time=$3" "$4 + if ($4 ~ /second|minute/) status="✅ онлайн" + else if ($4 ~ /hour/) status="⚠ давно" + else status="✅ онлайн" + } + } + /transfer:/ { + rx=$3; tx=$6 + printf " • %-15s %-15s %8s ↓ / %8s ↑\n", ip, status, rx, tx + count++ + }' + if ! docker ps --format '{{.Names}}' 2>/dev/null | grep -q -x "$CONTAINER"; then + echo " Контейнер не запущен" + fi +else + echo " Контейнер не запущен — не могу показать клиентов (wg)." +fi +echo "" + +# 3. Ограничения TC +echo "⏸️ ОГРАНИЧЕНИЯ ТРАФИКА (70 Мбит/с):" +if [ -n "$CONTAINER" ]; then + count=$(docker_exec sh -c "tc class show dev ${IF} 2>/dev/null | grep -c 'rate 70Mbit'" 2>/dev/null || true) + if [ -n "$count" ] && [ "$count" -gt 0 ]; then + echo " Классы TC:" + docker_exec sh -c "tc class show dev ${IF} 2>/dev/null | grep 'rate 70Mbit'" 2>/dev/null | \ + while read -r line; do + class_id=$(echo "$line" | awk '{print $2}' | cut -d: -f2) + if [ -n "$class_id" ]; then + echo " • Класс 1:$class_id → 70 Мбит/с" + fi + done + echo " ✅ Ограничения активны для $count клиентов" + else + echo " ❌ Нет активных ограничений" + fi +else + echo " Контейнер не запущен" +fi +echo "" + +# 4. Статистика фильтров +echo "📈 СТАТИСТИКА ФИЛЬТРОВ:" +if [ -n "$CONTAINER" ]; then + docker_exec sh -c "tc -s filter show dev ${IF} parent 1: 2>/dev/null" 2>/dev/null | \ + grep -B1 -A1 "flowid" | while read -r line1; do + if echo "$line1" | grep -q "flowid"; then + read -r line2 + read -r line3 + class=$(echo "$line1" | grep -o "flowid 1:[0-9]*" | cut -d: -f2) + hex_ip=$(echo "$line2" | grep -o "match [0-9a-f]*" | awk '{print $2}') + success=$(echo "$line3" | grep -o "success [0-9]*" | awk '{print $2}') + + if [ -n "$hex_ip" ] && [ -n "$class" ]; then + ip=$(printf "%d.%d.%d.%d" \ + 0x${hex_ip:0:2} \ + 0x${hex_ip:2:2} \ + 0x${hex_ip:4:2} \ + 0x${hex_ip:6:2} 2>/dev/null) + + if [ -n "$success" ] && [ "$success" -gt 0 ]; then + echo " • $ip → активен ($success пакетов)" + else + echo " • $ip → ожидание трафика" + fi + fi + fi + done + if ! docker_exec sh -c "tc -s filter show dev ${IF} parent 1: 2>/dev/null" 2>/dev/null | grep -q "flowid"; then + echo " Нет активных фильтров" + fi +else + echo " Контейнер не запущен" +fi +echo "" + +# 5. Последние события +echo "🔄 ПОСЛЕДНИЕ СОБЫТИЯ:" +if [ -f "/var/log/amnezia-tc.log" ]; then + tail -3 /var/log/amnezia-tc.log | while read -r line; do + message=$(echo "$line" | cut -d']' -f2-) + echo " $message" + done +else + echo " Лог-файл не найден" +fi + +echo "" +echo "┌──────────────────────────────────────────────────┐" +echo "│ СИСТЕМА ГОТОВА К ЭКСПЛУАТАЦИИ! 🎉 │" +echo "│ │" +echo "│ Проверено: │" +echo "│ ✅ Автозапуск после перезагрузки │" +echo "│ ✅ Автоматическое восстановление правил │" +echo "│ ✅ Ограничение 70 Мбит/с на клиента │" +echo "│ ✅ Обнаружение новых клиентов │" +echo "└──────────────────────────────────────────────────┘" +echo "" +echo "📋 Команды управления:" +echo " check-vpn-limits - этот отчет" +echo " systemctl restart amnezia-tc.service - перезапуск" +echo " journalctl -u amnezia-tc.service -f - логи" +echo " docker restart ${CONTAINER:-amnezia-awg2} - перезапуск WG (поменяйте имя при необходимости)" \ No newline at end of file diff --git a/Amnezia limits (old bash)/docker_watcher.sh.txt b/Amnezia limits (old bash)/docker_watcher.sh.txt new file mode 100644 index 0000000..9071337 --- /dev/null +++ b/Amnezia limits (old bash)/docker_watcher.sh.txt @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -e + +wait_wg() { + local c="$1" + for i in {1..20}; do + if docker exec "$c" ip link show awg0 >/dev/null 2>&1; then + return 0 + fi + sleep 1 + done + return 1 +} + +docker events --filter event=start --format '{{.Actor.Attributes.name}}' | while read name; do + if [[ "$name" == "amnezia-awg2" || "$name" == "amnezia-awg" ]]; then + echo "[watcher] container started: $name, waiting wg..." + if wait_wg "$name"; then + echo "[watcher] wg ready, applying limits" + WG_IF=awg0 /opt/amnezia/limit_peers.sh + else + echo "[watcher] wg not appeared, skip" + fi + fi +done diff --git a/Amnezia limits (old bash)/iproute2_a_core.txt b/Amnezia limits (old bash)/iproute2_a_core.txt new file mode 100644 index 0000000..cc59c61 --- /dev/null +++ b/Amnezia limits (old bash)/iproute2_a_core.txt @@ -0,0 +1,24 @@ +root@5862577-oj93961:~# echo "===== kernel =====" +===== kernel ===== +root@5862577-oj93961:~# uname -a +== iproute2 =====" +tc -V + +echo "===== docker =====" +docker -Linux 5862577-oj93961 6.12.57+deb13-cloud-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.12.57-1 (2025-11-05) x86_64 GNU/Linux +root@5862577-oj93961:~# +root@5862577-oj93961:~# echo "===== iproute2 =====" +===== iproute2 ===== +root@5862577-oj93961:~# tc -V +inspect amnezia-awg2 --format '{{.Config.Image}}'tc utility, iproute2-6.15.0, libbpf 1.5.0 +root@5862577-oj93961:~# +root@5862577-oj93961:~# echo "===== docker =====" +===== docker ===== +root@5862577-oj93961:~# docker --version +Docker version 26.1.5+dfsg1, build a72d7cd +root@5862577-oj93961:~# +root@5862577-oj93961:~# echo "===== amnezia container =====" +===== amnezia container ===== +root@5862577-oj93961:~# docker inspect amnezia-awg2 --format '{{.Config.Image}}' +amnezia-awg2 +root@5862577-oj93961:~# \ No newline at end of file diff --git a/Amnezia limits (old bash)/limit_peers.sh.txt b/Amnezia limits (old bash)/limit_peers.sh.txt new file mode 100644 index 0000000..2199dee --- /dev/null +++ b/Amnezia limits (old bash)/limit_peers.sh.txt @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +# Traffic control for AmneziaWG (production-ready) + +set -euo pipefail + +LOG="/var/log/amnezia-tc.log" +LOCK_FILE="/tmp/amnezia-tc.lock" +LIMIT="${LIMIT:-70mbit}" +CONTAINER="" +WG_IF="${WG_IF:-wg0}" + +# Prefer new container name, fall back to old +for c in amnezia-awg2 amnezia-awg; do + if docker ps --format '{{.Names}}' 2>/dev/null | grep -xq "$c"; then + CONTAINER="$c" + break + fi +done + +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG" +} + +# Lock to avoid concurrent runs +exec 200>"$LOCK_FILE" +flock -n 200 || { log "Script already running, exit"; exit 0; } + +if [ -z "$CONTAINER" ]; then + log "Container amnezia-awg2/amnezia-awg not found, exit" + exit 0 +fi + +# detect interface inside container if default not present there +if ! docker exec "$CONTAINER" ip link show "$WG_IF" >/dev/null 2>&1; then + DET_IF=$(docker exec "$CONTAINER" sh -c "ip -o link show 2>/dev/null | awk -F': ' '{print $2}'" 2>/dev/null \ + | egrep -o '^(wg[0-9]+|wg|awg[0-9]*|amnezia[0-9]*|tun[0-9]+)' | head -n1 || true) + + if [ -n "$DET_IF" ]; then + WG_IF="$DET_IF" + log "Detected interface inside container: $WG_IF" + else + log "Interface $WG_IF not found inside container $CONTAINER, exit" + exit 0 + fi +fi + +log "=== Start updating rules === (container=$CONTAINER, iface=$WG_IF, limit=$LIMIT)" + +# Get current clients +CLIENT_IPS=$(docker exec "$CONTAINER" sh -c "wg show all allowed-ips 2>/dev/null" 2>/dev/null \ + | awk '{print $3}' | grep -oE '10\.8\.1\.[0-9]{1,3}' | sort -u || true) + +# fallback +if [ -z "$CLIENT_IPS" ]; then + CLIENT_IPS=$(docker exec "$CONTAINER" sh -c "wg show 2>/dev/null" 2>/dev/null \ + | grep -Eo '10\.8\.1\.[0-9]{1,3}' | sort -u || true) +fi + +if [ -z "$CLIENT_IPS" ]; then + log "No clients found, exit" + exit 0 +fi + +log "Clients: $(echo $CLIENT_IPS | tr '\n' ' ')" + +EXISTING_RULES=$(docker exec "$CONTAINER" sh -c "tc filter show dev $WG_IF parent 1: 2>/dev/null || true" 2>/dev/null \ + | grep -oE 'match [0-9a-f]+' | awk '{print $2}' | sort -u || true) + +NEEDS_UPDATE=false + +num_ips=$(echo "$CLIENT_IPS" | wc -w) +num_rules=$(echo "$EXISTING_RULES" | wc -w) +if [ "$num_rules" -ne "$num_ips" ] || [ -z "$EXISTING_RULES" ]; then + NEEDS_UPDATE=true +else + for ip in $CLIENT_IPS; do + hex_ip=$(printf '%02x' ${ip//./ } | tr -d '\n' || true) + if ! echo "$EXISTING_RULES" | grep -q "$hex_ip"; then + NEEDS_UPDATE=true + break + fi + done +fi + +if [ "$NEEDS_UPDATE" = false ]; then + log "Rules are already up-to-date, skip" + exit 0 +fi + +log "Updating rules (old will be removed)" + +docker exec "$CONTAINER" sh -c " + tc qdisc del dev $WG_IF root 2>/dev/null || true + tc qdisc del dev $WG_IF ingress 2>/dev/null || true + ip link del ifb0 2>/dev/null || true +" 2>/dev/null || true + +docker exec "$CONTAINER" sh -c " + tc qdisc add dev $WG_IF root handle 1: htb 2>/dev/null || true + tc class add dev $WG_IF parent 1: classid 1:1 htb rate 1000mbit ceil 1000mbit 2>/dev/null || true +" 2>/dev/null || true + +docker exec "$CONTAINER" sh -c " + modprobe ifb 2>/dev/null || true + ip link add name ifb0 type ifb 2>/dev/null || true + ip link set dev ifb0 up 2>/dev/null || true + + tc qdisc add dev $WG_IF handle ffff: ingress 2>/dev/null || true + tc filter add dev $WG_IF parent ffff: protocol all u32 match u32 0 0 action mirred egress redirect dev ifb0 2>/dev/null || true + + tc qdisc add dev ifb0 root handle 2: htb 2>/dev/null || true + tc class add dev ifb0 parent 2: classid 2:1 htb rate 1000mbit ceil 1000mbit 2>/dev/null || true +" 2>/dev/null || true + +class_id=10 +count=0 +for ip in $CLIENT_IPS; do + log "Adding limit for $ip (class $class_id)" + + docker exec "$CONTAINER" sh -c " + tc class add dev $WG_IF parent 1:1 classid 1:$class_id htb rate $LIMIT ceil $LIMIT 2>/dev/null || true + tc qdisc add dev $WG_IF parent 1:$class_id fq_codel 2>/dev/null || true + tc filter add dev $WG_IF parent 1: protocol ip u32 match ip dst $ip flowid 1:$class_id 2>/dev/null || true + " 2>/dev/null || true + + docker exec "$CONTAINER" sh -c " + tc class add dev ifb0 parent 2:1 classid 2:$class_id htb rate $LIMIT ceil $LIMIT 2>/dev/null || true + tc qdisc add dev ifb0 parent 2:$class_id fq_codel 2>/dev/null || true + tc filter add dev ifb0 parent 2: protocol ip u32 match ip src $ip flowid 2:$class_id 2>/dev/null || true + " 2>/dev/null || true + + class_id=$((class_id + 1)) + count=$((count + 1)) +done + +log "Limits applied for $count clients" +log "=== Completed successfully ===" +exit 0 diff --git a/Amnezia limits (old bash)/permissions.txt b/Amnezia limits (old bash)/permissions.txt new file mode 100644 index 0000000..7f920dd --- /dev/null +++ b/Amnezia limits (old bash)/permissions.txt @@ -0,0 +1,17 @@ +root@5862577-oj93961:~# echo "===== permissions =====" +===== permissions ===== +root@5862577-oj93961:~# ls -l /opt/amnezia/ +ho "===== systemd enabled =====" +systemctl is-enabled amnezia-tc.service +systemctl is-enabled amnezia-tc-watchtotal 16 +drwxr-xr-x 2 root root 4096 Feb 21 05:15 amnezia-awg2 +-rwxr-xr-x 1 root root 618 Feb 21 18:24 docker_watcher.sh +-rwxr-xr-x 1 root root 4496 Feb 21 18:17 limit_peers.sh +root@5862577-oj93961:~# +root@5862577-oj93961:~# echo "===== systemd enabled =====" +===== systemd enabled ===== +root@5862577-oj93961:~# systemctl is-enabled amnezia-tc.service +enabled +root@5862577-oj93961:~# systemctl is-enabled amnezia-tc-watcher.service +enabled +root@5862577-oj93961:~# \ No newline at end of file diff --git a/Amnezia limits (old bash)/traffic_control-was.txt b/Amnezia limits (old bash)/traffic_control-was.txt new file mode 100644 index 0000000..8272d36 --- /dev/null +++ b/Amnezia limits (old bash)/traffic_control-was.txt @@ -0,0 +1,172 @@ +root@5862577-oj93961:~# echo "===== TC qdisc =====" +===== TC qdisc ===== +root@5862577-oj93961:~# docker exec amnezia-awg2 tc qdisc show dev awg0 + +docker exec amnezia-awg2 tc class show dev awg0 + +echo "===== TC filters =====" +docker exec amnezia-awg2 tc filter show dev awg0qdisc htb 1: root refcnt 2 r2q 10 default 0 direct_packets_stat 6 direct_qlen 500 +qdisc fq_codel 807b: parent 1:24 limit 10240p flows 1024 quantum 1420 target 5ms interval 100ms memory_limit 32Mb ecn drop_batch 64 +qdisc fq_codel 807f: parent 1:26 limit 10240p flows 1024 quantum 1420 target 5ms interval 100ms memory_limit 32Mb ecn drop_batch 64 +qdisc fq_codel 807d: parent 1:25 limit 10240p flows 1024 quantum 1420 target 5ms interval 100ms memory_limit 32Mb ecn drop_batch 64 +qdisc fq_codel 8081: parent 1:27 limit 10240p flows 1024 quantum 1420 target 5ms interval 100ms memory_limit 32Mb ecn drop_batch 64 +qdisc fq_codel 8083: parent 1:28 limit 10240p flows 1024 quantum 1420 target 5ms interval 100ms memory_limit 32Mb ecn drop_batch 64 +qdisc fq_codel 8087: parent 1:30 limit 10240p flows 1024 quantum 1420 target 5ms interval 100ms memory_limit 32Mb ecn drop_batch 64 +qdisc fq_codel 8085: parent 1:29 limit 10240p flows 1024 quantum 1420 target 5ms interval 100ms memory_limit 32Mb ecn drop_batch 64 +qdisc fq_codel 8089: parent 1:31 limit 10240p flows 1024 quantum 1420 target 5ms interval 100ms memory_limit 32Mb ecn drop_batch 64 +qdisc fq_codel 805f: parent 1:10 limit 10240p flows 1024 quantum 1420 target 5ms interval 100ms memory_limit 32Mb ecn drop_batch 64 +qdisc fq_codel 808b: parent 1:32 limit 10240p flows 1024 quantum 1420 target 5ms interval 100ms memory_limit 32Mb ecn drop_batch 64 +qdisc fq_codel 8063: parent 1:12 limit 10240p flows 1024 quantum 1420 target 5ms interval 100ms memory_limit 32Mb ecn drop_batch 64 +qdisc fq_codel 8061: parent 1:11 limit 10240p flows 1024 quantum 1420 target 5ms interval 100ms memory_limit 32Mb ecn drop_batch 64 +qdisc fq_codel 8065: parent 1:13 limit 10240p flows 1024 quantum 1420 target 5ms interval 100ms memory_limit 32Mb ecn drop_batch 64 +qdisc fq_codel 8067: parent 1:14 limit 10240p flows 1024 quantum 1420 target 5ms interval 100ms memory_limit 32Mb ecn drop_batch 64 +qdisc fq_codel 806b: parent 1:16 limit 10240p flows 1024 quantum 1420 target 5ms interval 100ms memory_limit 32Mb ecn drop_batch 64 +qdisc fq_codel 8069: parent 1:15 limit 10240p flows 1024 quantum 1420 target 5ms interval 100ms memory_limit 32Mb ecn drop_batch 64 +qdisc fq_codel 806d: parent 1:17 limit 10240p flows 1024 quantum 1420 target 5ms interval 100ms memory_limit 32Mb ecn drop_batch 64 +qdisc fq_codel 806f: parent 1:18 limit 10240p flows 1024 quantum 1420 target 5ms interval 100ms memory_limit 32Mb ecn drop_batch 64 +qdisc fq_codel 8071: parent 1:19 limit 10240p flows 1024 quantum 1420 target 5ms interval 100ms memory_limit 32Mb ecn drop_batch 64 +qdisc fq_codel 8075: parent 1:21 limit 10240p flows 1024 quantum 1420 target 5ms interval 100ms memory_limit 32Mb ecn drop_batch 64 +qdisc fq_codel 8073: parent 1:20 limit 10240p flows 1024 quantum 1420 target 5ms interval 100ms memory_limit 32Mb ecn drop_batch 64 +qdisc fq_codel 8077: parent 1:22 limit 10240p flows 1024 quantum 1420 target 5ms interval 100ms memory_limit 32Mb ecn drop_batch 64 +qdisc fq_codel 8079: parent 1:23 limit 10240p flows 1024 quantum 1420 target 5ms interval 100ms memory_limit 32Mb ecn drop_batch 64 +qdisc ingress ffff: parent ffff:fff1 ---------------- +root@5862577-oj93961:~# +root@5862577-oj93961:~# echo "===== TC class =====" +===== TC class ===== +root@5862577-oj93961:~# docker exec amnezia-awg2 tc class show dev awg0 +class htb 1:11 parent 1:1 leaf 8061: prio 0 rate 70Mbit ceil 70Mbit burst 1583b cburst 1583b +class htb 1:32 parent 1:1 leaf 808b: prio 0 rate 70Mbit ceil 70Mbit burst 1583b cburst 1583b +class htb 1:10 parent 1:1 leaf 805f: prio 0 rate 70Mbit ceil 70Mbit burst 1583b cburst 1583b +class htb 1:31 parent 1:1 leaf 8089: prio 0 rate 70Mbit ceil 70Mbit burst 1583b cburst 1583b +class htb 1:13 parent 1:1 leaf 8065: prio 0 rate 70Mbit ceil 70Mbit burst 1583b cburst 1583b +class htb 1:30 parent 1:1 leaf 8087: prio 0 rate 70Mbit ceil 70Mbit burst 1583b cburst 1583b +class htb 1:12 parent 1:1 leaf 8063: prio 0 rate 70Mbit ceil 70Mbit burst 1583b cburst 1583b +class htb 1:15 parent 1:1 leaf 8069: prio 0 rate 70Mbit ceil 70Mbit burst 1583b cburst 1583b +class htb 1:14 parent 1:1 leaf 8067: prio 0 rate 70Mbit ceil 70Mbit burst 1583b cburst 1583b +class htb 1:17 parent 1:1 leaf 806d: prio 0 rate 70Mbit ceil 70Mbit burst 1583b cburst 1583b +class htb 1:16 parent 1:1 leaf 806b: prio 0 rate 70Mbit ceil 70Mbit burst 1583b cburst 1583b +class htb 1:19 parent 1:1 leaf 8071: prio 0 rate 70Mbit ceil 70Mbit burst 1583b cburst 1583b +class htb 1:18 parent 1:1 leaf 806f: prio 0 rate 70Mbit ceil 70Mbit burst 1583b cburst 1583b +class htb 1:22 parent 1:1 leaf 8077: prio 0 rate 70Mbit ceil 70Mbit burst 1583b cburst 1583b +class htb 1:23 parent 1:1 leaf 8079: prio 0 rate 70Mbit ceil 70Mbit burst 1583b cburst 1583b +class htb 1:1 root rate 1Gbit ceil 1Gbit burst 1375b cburst 1375b +class htb 1:20 parent 1:1 leaf 8073: prio 0 rate 70Mbit ceil 70Mbit burst 1583b cburst 1583b +class htb 1:21 parent 1:1 leaf 8075: prio 0 rate 70Mbit ceil 70Mbit burst 1583b cburst 1583b +class htb 1:26 parent 1:1 leaf 807f: prio 0 rate 70Mbit ceil 70Mbit burst 1583b cburst 1583b +class htb 1:27 parent 1:1 leaf 8081: prio 0 rate 70Mbit ceil 70Mbit burst 1583b cburst 1583b +class htb 1:24 parent 1:1 leaf 807b: prio 0 rate 70Mbit ceil 70Mbit burst 1583b cburst 1583b +class htb 1:25 parent 1:1 leaf 807d: prio 0 rate 70Mbit ceil 70Mbit burst 1583b cburst 1583b +class htb 1:28 parent 1:1 leaf 8083: prio 0 rate 70Mbit ceil 70Mbit burst 1583b cburst 1583b +class htb 1:29 parent 1:1 leaf 8085: prio 0 rate 70Mbit ceil 70Mbit burst 1583b cburst 1583b +class fq_codel 805f:75 parent 805f: +class fq_codel 805f:7b parent 805f: +class fq_codel 805f:325 parent 805f: +class fq_codel 805f:3ab parent 805f: +class fq_codel 805f:3e5 parent 805f: +class fq_codel 808b:41 parent 808b: +class fq_codel 808b:d5 parent 808b: +class fq_codel 808b:e7 parent 808b: +class fq_codel 808b:147 parent 808b: +class fq_codel 808b:1a4 parent 808b: +class fq_codel 8063:251 parent 8063: +class fq_codel 8063:290 parent 8063: +class fq_codel 8061:284 parent 8061: +class fq_codel 8065:203 parent 8065: +class fq_codel 8069:2bf parent 8069: +root@5862577-oj93961:~# +root@5862577-oj93961:~# echo "===== TC filters =====" +===== TC filters ===== +root@5862577-oj93961:~# docker exec amnezia-awg2 tc filter show dev awg0 +filter parent 1: protocol ip pref 49130 u32 chain 0 +filter parent 1: protocol ip pref 49130 u32 chain 0 fh 816: ht divisor 1 +filter parent 1: protocol ip pref 49130 u32 chain 0 fh 816::800 order 2048 key ht 816 bkt 0 flowid 1:32 not_in_hw + match 0a080105/ffffffff at 16 +filter parent 1: protocol ip pref 49131 u32 chain 0 +filter parent 1: protocol ip pref 49131 u32 chain 0 fh 815: ht divisor 1 +filter parent 1: protocol ip pref 49131 u32 chain 0 fh 815::800 order 2048 key ht 815 bkt 0 flowid 1:31 not_in_hw + match 0a080104/ffffffff at 16 +filter parent 1: protocol ip pref 49132 u32 chain 0 +filter parent 1: protocol ip pref 49132 u32 chain 0 fh 814: ht divisor 1 +filter parent 1: protocol ip pref 49132 u32 chain 0 fh 814::800 order 2048 key ht 814 bkt 0 flowid 1:30 not_in_hw + match 0a080103/ffffffff at 16 +filter parent 1: protocol ip pref 49133 u32 chain 0 +filter parent 1: protocol ip pref 49133 u32 chain 0 fh 813: ht divisor 1 +filter parent 1: protocol ip pref 49133 u32 chain 0 fh 813::800 order 2048 key ht 813 bkt 0 flowid 1:29 not_in_hw + match 0a08011b/ffffffff at 16 +filter parent 1: protocol ip pref 49134 u32 chain 0 +filter parent 1: protocol ip pref 49134 u32 chain 0 fh 812: ht divisor 1 +filter parent 1: protocol ip pref 49134 u32 chain 0 fh 812::800 order 2048 key ht 812 bkt 0 flowid 1:28 not_in_hw + match 0a08011a/ffffffff at 16 +filter parent 1: protocol ip pref 49135 u32 chain 0 +filter parent 1: protocol ip pref 49135 u32 chain 0 fh 811: ht divisor 1 +filter parent 1: protocol ip pref 49135 u32 chain 0 fh 811::800 order 2048 key ht 811 bkt 0 flowid 1:27 not_in_hw + match 0a080119/ffffffff at 16 +filter parent 1: protocol ip pref 49136 u32 chain 0 +filter parent 1: protocol ip pref 49136 u32 chain 0 fh 810: ht divisor 1 +filter parent 1: protocol ip pref 49136 u32 chain 0 fh 810::800 order 2048 key ht 810 bkt 0 flowid 1:26 not_in_hw + match 0a080118/ffffffff at 16 +filter parent 1: protocol ip pref 49137 u32 chain 0 +filter parent 1: protocol ip pref 49137 u32 chain 0 fh 80f: ht divisor 1 +filter parent 1: protocol ip pref 49137 u32 chain 0 fh 80f::800 order 2048 key ht 80f bkt 0 flowid 1:25 not_in_hw + match 0a080117/ffffffff at 16 +filter parent 1: protocol ip pref 49138 u32 chain 0 +filter parent 1: protocol ip pref 49138 u32 chain 0 fh 80e: ht divisor 1 +filter parent 1: protocol ip pref 49138 u32 chain 0 fh 80e::800 order 2048 key ht 80e bkt 0 flowid 1:24 not_in_hw + match 0a080116/ffffffff at 16 +filter parent 1: protocol ip pref 49139 u32 chain 0 +filter parent 1: protocol ip pref 49139 u32 chain 0 fh 80d: ht divisor 1 +filter parent 1: protocol ip pref 49139 u32 chain 0 fh 80d::800 order 2048 key ht 80d bkt 0 flowid 1:23 not_in_hw + match 0a080115/ffffffff at 16 +filter parent 1: protocol ip pref 49140 u32 chain 0 +filter parent 1: protocol ip pref 49140 u32 chain 0 fh 80c: ht divisor 1 +filter parent 1: protocol ip pref 49140 u32 chain 0 fh 80c::800 order 2048 key ht 80c bkt 0 flowid 1:22 not_in_hw + match 0a080114/ffffffff at 16 +filter parent 1: protocol ip pref 49141 u32 chain 0 +filter parent 1: protocol ip pref 49141 u32 chain 0 fh 80b: ht divisor 1 +filter parent 1: protocol ip pref 49141 u32 chain 0 fh 80b::800 order 2048 key ht 80b bkt 0 flowid 1:21 not_in_hw + match 0a080102/ffffffff at 16 +filter parent 1: protocol ip pref 49142 u32 chain 0 +filter parent 1: protocol ip pref 49142 u32 chain 0 fh 80a: ht divisor 1 +filter parent 1: protocol ip pref 49142 u32 chain 0 fh 80a::800 order 2048 key ht 80a bkt 0 flowid 1:20 not_in_hw + match 0a080113/ffffffff at 16 +filter parent 1: protocol ip pref 49143 u32 chain 0 +filter parent 1: protocol ip pref 49143 u32 chain 0 fh 809: ht divisor 1 +filter parent 1: protocol ip pref 49143 u32 chain 0 fh 809::800 order 2048 key ht 809 bkt 0 flowid 1:19 not_in_hw + match 0a080112/ffffffff at 16 +filter parent 1: protocol ip pref 49144 u32 chain 0 +filter parent 1: protocol ip pref 49144 u32 chain 0 fh 808: ht divisor 1 +filter parent 1: protocol ip pref 49144 u32 chain 0 fh 808::800 order 2048 key ht 808 bkt 0 flowid 1:18 not_in_hw + match 0a080111/ffffffff at 16 +filter parent 1: protocol ip pref 49145 u32 chain 0 +filter parent 1: protocol ip pref 49145 u32 chain 0 fh 807: ht divisor 1 +filter parent 1: protocol ip pref 49145 u32 chain 0 fh 807::800 order 2048 key ht 807 bkt 0 flowid 1:17 not_in_hw + match 0a080110/ffffffff at 16 +filter parent 1: protocol ip pref 49146 u32 chain 0 +filter parent 1: protocol ip pref 49146 u32 chain 0 fh 806: ht divisor 1 +filter parent 1: protocol ip pref 49146 u32 chain 0 fh 806::800 order 2048 key ht 806 bkt 0 flowid 1:16 not_in_hw + match 0a08010f/ffffffff at 16 +filter parent 1: protocol ip pref 49147 u32 chain 0 +filter parent 1: protocol ip pref 49147 u32 chain 0 fh 805: ht divisor 1 +filter parent 1: protocol ip pref 49147 u32 chain 0 fh 805::800 order 2048 key ht 805 bkt 0 flowid 1:15 not_in_hw + match 0a08010e/ffffffff at 16 +filter parent 1: protocol ip pref 49148 u32 chain 0 +filter parent 1: protocol ip pref 49148 u32 chain 0 fh 804: ht divisor 1 +filter parent 1: protocol ip pref 49148 u32 chain 0 fh 804::800 order 2048 key ht 804 bkt 0 flowid 1:14 not_in_hw + match 0a08010d/ffffffff at 16 +filter parent 1: protocol ip pref 49149 u32 chain 0 +filter parent 1: protocol ip pref 49149 u32 chain 0 fh 803: ht divisor 1 +filter parent 1: protocol ip pref 49149 u32 chain 0 fh 803::800 order 2048 key ht 803 bkt 0 flowid 1:13 not_in_hw + match 0a08010c/ffffffff at 16 +filter parent 1: protocol ip pref 49150 u32 chain 0 +filter parent 1: protocol ip pref 49150 u32 chain 0 fh 802: ht divisor 1 +filter parent 1: protocol ip pref 49150 u32 chain 0 fh 802::800 order 2048 key ht 802 bkt 0 flowid 1:12 not_in_hw + match 0a08010b/ffffffff at 16 +filter parent 1: protocol ip pref 49151 u32 chain 0 +filter parent 1: protocol ip pref 49151 u32 chain 0 fh 801: ht divisor 1 +filter parent 1: protocol ip pref 49151 u32 chain 0 fh 801::800 order 2048 key ht 801 bkt 0 flowid 1:11 not_in_hw + match 0a08010a/ffffffff at 16 +filter parent 1: protocol ip pref 49152 u32 chain 0 +filter parent 1: protocol ip pref 49152 u32 chain 0 fh 800: ht divisor 1 +filter parent 1: protocol ip pref 49152 u32 chain 0 fh 800::800 order 2048 key ht 800 bkt 0 flowid 1:10 not_in_hw + match 0a080101/ffffffff at 16 +root@5862577-oj93961:~# \ No newline at end of file diff --git a/Amnezia limits (old bash)/wg-peers.txt b/Amnezia limits (old bash)/wg-peers.txt new file mode 100644 index 0000000..ad35f0c --- /dev/null +++ b/Amnezia limits (old bash)/wg-peers.txt @@ -0,0 +1,129 @@ +root@5862577-oj93961:~# echo "===== WG peers =====" +===== WG peers ===== +root@5862577-oj93961:~# docker exec amnezia-awg2 wg show +interface: awg0 + public key: 03RA0Pz5SXeHFlYOWVtbtuFFr1qRZfwoIsp1dX/KXA8= + private key: (hidden) + listening port: 44344 + jc: 4 + jmin: 10 + jmax: 50 + s1: 108 + s2: 92 + s3: 37 + s4: 5 + h1: 1644392140-1655070006 + h2: 1744170008-1963857766 + h3: 2020199760-2027285116 + h4: 2115790963-2141451260 + +peer: trH/DMazLMdu1QE5uXhpesx5SzWvNFoW5KZHd+gTulA= + preshared key: (hidden) + endpoint: 172.29.172.1:46697 + allowed ips: 10.8.1.5/32 + latest handshake: 14 seconds ago + transfer: 575.60 KiB received, 1.42 MiB sent + +peer: LNoepSV+wHrq8xrpG3nVR7ciSk3qqoOCtvZCkSpjbno= + preshared key: (hidden) + endpoint: 172.29.172.1:49282 + allowed ips: 10.8.1.11/32 + latest handshake: 14 seconds ago + transfer: 7.53 MiB received, 159.08 MiB sent + +peer: C57aO+jMb6LBJ8kI/olF8JLIM3SnERumSyZjEFm5jlY= + preshared key: (hidden) + endpoint: 172.29.172.1:51133 + allowed ips: 10.8.1.14/32 + latest handshake: 23 seconds ago + transfer: 81.93 KiB received, 104.35 KiB sent + +peer: FlbqHCQdjC53Wn9xFLq8E/v1RJmT0r93wV9w+uVrBCs= + preshared key: (hidden) + endpoint: 172.29.172.1:54591 + allowed ips: 10.8.1.12/32 + latest handshake: 40 seconds ago + transfer: 5.16 MiB received, 102.56 MiB sent + +peer: oAYyolWjgRmfWejHdjzoUivF7yk0FGsy1B3IeF0PLBg= + preshared key: (hidden) + endpoint: 172.29.172.1:51761 + allowed ips: 10.8.1.1/32 + latest handshake: 42 seconds ago + transfer: 2.01 MiB received, 2.39 MiB sent + +peer: jLBpMPRBc7E8b1NEDjlVHAVQG3PN7fmueC9paf8K8yM= + preshared key: (hidden) + endpoint: 172.29.172.1:57449 + allowed ips: 10.8.1.10/32 + latest handshake: 44 seconds ago + transfer: 225.05 MiB received, 3.14 GiB sent + +peer: OMeNeKilU+f1ilnrnwgpbyRV8lyVT9psAIK5iJLoKxI= + preshared key: (hidden) + allowed ips: 10.8.1.27/32 + +peer: IYyJq14x4kOA8SbbCd+u6WQNuVZntLuSQu0JSs78uk4= + preshared key: (hidden) + allowed ips: 10.8.1.26/32 + +peer: a+Yw80sFSnyKFXbfOZtQupWiO2f9BWLby7ydbyo7+RA= + preshared key: (hidden) + allowed ips: 10.8.1.2/32 + +peer: Ir8DYbG96qGhudYPGpNSqs7AZaMO9oF6yecb0cq4WTQ= + preshared key: (hidden) + allowed ips: 10.8.1.4/32 + +peer: Sg56DQUHGl9byHOLvrJBNbwQER+Y9LttyaonPTFX8Eg= + preshared key: (hidden) + allowed ips: 10.8.1.16/32 + +peer: wgeDnqt7hfSPgHlGbpdKkDhAwWbKRBDsWBQ2e3OATlM= + preshared key: (hidden) + allowed ips: 10.8.1.19/32 + +peer: iF4DdwwnEnWQ6Ve4KrnHt88dSGyr1reQWQuHGQ3OzSg= + preshared key: (hidden) + allowed ips: 10.8.1.24/32 + +peer: kA8CUmfjcGAACXJHsVEXFxReLKU+/LGpZjI6Va+kwDQ= + preshared key: (hidden) + allowed ips: 10.8.1.20/32 + +peer: J130uXmnNsPs7+OahiqG5hUOtJ/4eEOgeL3or9QMYQs= + preshared key: (hidden) + allowed ips: 10.8.1.3/32 + +peer: 5QdCq+tlMDL4PTs7z9LD9g26Qr+bNeWxnmSI/DzBaDM= + preshared key: (hidden) + allowed ips: 10.8.1.15/32 + +peer: KnqncORZhtC5RhC+MSttDLla19jCHrxooUvIY+MmnhA= + preshared key: (hidden) + allowed ips: 10.8.1.18/32 + +peer: z8hfD0AqagIxJjob242MbQWdM2E450f4v/g4Pe9YUBc= + preshared key: (hidden) + allowed ips: 10.8.1.23/32 + +peer: +XNRC3O5TEYAJWqF5ihN9ggvBBaf03FAAdGcXezKDHE= + preshared key: (hidden) + allowed ips: 10.8.1.25/32 + +peer: 7/MIQLMhYBcvQtrlvJ+HAoXiPUdGVfKrD2RJNhJ0jR8= + preshared key: (hidden) + allowed ips: 10.8.1.13/32 + +peer: PZRbsLPBZtTV2h82ggpENOrAqFNEkdCv2vJxU5cBaU4= + preshared key: (hidden) + allowed ips: 10.8.1.17/32 + +peer: LphW/YJyc4lFf5mFOyS3G20yH3YvA5VavVaVRR+0yV0= + preshared key: (hidden) + allowed ips: 10.8.1.21/32 + +peer: uc3UA+woJyqrSFfx5PM7yneBZAhNGSBZgjHXsHh8x2M= + preshared key: (hidden) + allowed ips: 10.8.1.22/32 +root@5862577-oj93961:~# \ No newline at end of file diff --git a/Amnezia limits (old bash)/Нюансы.txt b/Amnezia limits (old bash)/Нюансы.txt new file mode 100644 index 0000000..0581aef --- /dev/null +++ b/Amnezia limits (old bash)/Нюансы.txt @@ -0,0 +1,56 @@ +6. Единственный скрытый риск (самый важный) + +Он не сейчас, а через время. + +Amnezia иногда меняет имя интерфейса: + +awg0 +wg0 +amnezia0 + +У тебя в логах уже видно: + +iface=awg0 + +Если они обновят контейнер → имя изменится → tc пропадёт. + +НО + +Твой скрипт уже умеет авто-детект: + +ip -o link show | egrep '^(wg[0-9]+|wg|amnezia[0-9]*|tun[0-9]+)' + +✔ значит система устойчива даже к обновлениям Amnezia + + + + +7. Итог + +Сейчас у тебя: + +Production-grade auto-healing bandwidth limiter + +Он: + +✔ переживает reboot +✔ переживает restart docker +✔ переживает restart контейнера +✔ переживает обновление Amnezia +✔ переживает добавление пользователей +✔ переживает удаление пользователей + +8. Когда он может сломаться + +Только в 2 случаях: + +Amnezia полностью поменяет формат wg show + +в контейнере уберут tc или iproute2 + +Это редкость. + +9. Вердикт + +Да — сейчас система настроена правильно. +Её уже можно считать стабильной боевой конфигурацией. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..bfb69d5 --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +# AWG2 Harness + +`AWG2 Harness` — host-side multi-orchestrator на Rust для production-сервера с AmneziaWG/AWG 2.0. + +Это не просто перенос bash-скриптов на Rust. Целевая система должна воспроизвести и затем постоянно удерживать всё состояние, которое сейчас создаёт `Amnezia limits (old bash)`: discovery контейнера и интерфейса, список peers, per-peer traffic shaping в обе стороны, `tc` topology, `ifb0`, auto-healing после рестартов, диагностику, плановый reboot и swap setup, а также опциональный nftables anti-torrent guard через per-peer connection-count limit. + +Сервер выделенный: Debian 13 amd64, SSH заранее настроен, рядом нет соседних сервисов, после установки Amnezia доставляется наш бинарь. Поэтому бинарь имеет право и обязан полностью управлять нужными правилами хоста и network namespace Amnezia, а не быть пассивной обёрткой, которая боится менять состояние системы. При этом он не форкает Amnezia и не меняет её продуктовую логику; он управляет инфраструктурным состоянием вокруг неё. + +Текущий репозиторий: + +```text +AWG2_harness/ +├── Amnezia limits (old bash)/ # рабочая bash-реализация; источник truth для поведения +├── reboot_module/ # готовый Rust-модуль плановой перезагрузки +├── traffic_core/ # целевая зона для Rust traffic/orchestrator логики +└── docs/ # документация по миграции, архитектуре и эксплуатации +``` + +Основная документация: + +- [`docs/MIGRATION.md`](docs/MIGRATION.md) — исправленная миграционная стратегия bash → Rust multi-orchestrator. +- [`docs/OLD_BASH_BEHAVIOR.md`](docs/OLD_BASH_BEHAVIOR.md) — зафиксированное поведение старой bash-системы по файлам. +- [`docs/IMPLEMENTATION_SPEC.md`](docs/IMPLEMENTATION_SPEC.md) — целевая архитектура бинаря и state reconciliation. +- [`docs/CONFIGURATION.md`](docs/CONFIGURATION.md) — единый `config.toml` без эксплуатационного мусора. +- [`docs/INSTALLATION.md`](docs/INSTALLATION.md) — установка на Debian 13 amd64. +- [`docs/ACCEPTANCE_CHECKLIST.md`](docs/ACCEPTANCE_CHECKLIST.md) — production checklist. +- [`docs/ANTI_TORRENT.md`](docs/ANTI_TORRENT.md) — простая анти-торрент защита через nftables `ct count`. diff --git a/awg2-harness.service b/awg2-harness.service new file mode 100644 index 0000000..eb64800 --- /dev/null +++ b/awg2-harness.service @@ -0,0 +1,17 @@ +[Unit] +Description=AWG2 Harness multi-orchestrator +After=network-online.target docker.service +Wants=network-online.target +Requires=docker.service + +[Service] +Type=simple +User=root +ExecStart=/usr/local/bin/awg2-harness daemon --config /etc/awg2-harness/config.toml +Restart=always +RestartSec=5 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..acd527f --- /dev/null +++ b/config.example.toml @@ -0,0 +1,33 @@ +[amnezia] +container_names = ["amnezia-awg2", "amnezia-awg"] +interface_candidates = ["awg0", "wg0", "amnezia0", "tun0"] +interface_patterns = ["awg*", "wg*", "amnezia*", "tun*"] +peer_ipv4_cidr = "10.8.1.0/24" + +[traffic] +enabled = true +peer_limit = "70mbit" +root_rate = "1000mbit" +reconcile_interval_secs = 60 +container_ready_timeout_secs = 30 +class_start_id = 10 +ifb_name = "ifb0" +full_rebuild_on_drift = true + +[anti_torrent] +enabled = true +max_connections_per_peer = 200 +peer_ipv4_cidr = "10.8.1.0/24" +apply_in_amnezia_netns = true + +[reboot] +enabled = true +window_start_utc = "01:00" +window_end_utc = "02:00" + +[swap] +enabled = true +path = "/swapfile" +size_mb = 2048 +priority = -2 +swappiness = 1 diff --git a/docs/ACCEPTANCE_CHECKLIST.md b/docs/ACCEPTANCE_CHECKLIST.md new file mode 100644 index 0000000..a667038 --- /dev/null +++ b/docs/ACCEPTANCE_CHECKLIST.md @@ -0,0 +1,109 @@ +# Acceptance checklist перед production-выкладкой + +## A. Базовая установка + +- [ ] Хост — Debian 13 amd64. +- [ ] Сервер выделенный, без соседних сервисов. +- [ ] AmneziaWG/AWG 2.0 установлена официальным способом. +- [ ] Контейнер `amnezia-awg2` или `amnezia-awg` запущен. +- [ ] `docker ps` видит контейнер. +- [ ] Можно получить PID контейнера. +- [ ] `/proc//ns/net` существует. +- [ ] На хосте доступны `ip`, `tc`, `wg`, `nsenter`, `modprobe`. +- [ ] `awg2-harness validate-config` проходит без ошибок. + +## B. Peer discovery + +- [ ] `awg2-harness status` показывает найденный контейнер. +- [ ] `awg2-harness status` показывает PID/netns контейнера. +- [ ] `awg2-harness status` показывает найденный интерфейс. +- [ ] Количество peers совпадает с `wg show all allowed-ips`. +- [ ] Offline peers с allowed IP тоже попадают в список shaping. +- [ ] IP вне `amnezia.peer_ipv4_cidr` не попадают в traffic shaping. + +## C. Traffic topology parity with old bash + +- [ ] `awg2-harness apply-limits` успешно завершается. +- [ ] На AWG/WG-интерфейсе есть root qdisc `1: htb`. +- [ ] На AWG/WG-интерфейсе есть root class `1:1` с `traffic.root_rate`. +- [ ] Для каждого peer есть class `1:` с `traffic.peer_limit`. +- [ ] Для каждого peer есть `fq_codel` под `1:`. +- [ ] Для каждого peer есть egress filter `dst peer_ip -> 1:`. +- [ ] Есть ingress qdisc `ffff:` на AWG/WG-интерфейсе. +- [ ] Есть ingress redirect через `mirred ... redirect dev ifb0`. +- [ ] `ifb0` существует и находится в UP state. +- [ ] На `ifb0` есть root qdisc `2: htb`. +- [ ] На `ifb0` есть root class `2:1` с `traffic.root_rate`. +- [ ] Для каждого peer есть class `2:` с `traffic.peer_limit`. +- [ ] Для каждого peer есть `fq_codel` под `2:`. +- [ ] Для каждого peer есть ingress filter `src peer_ip -> 2:`. +- [ ] Class ids начинаются с `traffic.class_start_id`, default `10`. +- [ ] Peer IP → class id mapping сохраняет bash parity: IP сортируются лексикографически как строки, не numeric IPv4. + +## D. Idempotency and drift recovery + +- [ ] Повторный `apply-limits` не создаёт дубли. +- [ ] Повторный `apply-limits` не меняет healthy state без причины. +- [ ] Если удалить root qdisc на AWG/WG-интерфейсе, daemon восстанавливает topology. +- [ ] Если удалить ingress qdisc, daemon восстанавливает topology. +- [ ] Если удалить `ifb0`, daemon восстанавливает topology. +- [ ] Если удалить один peer filter, daemon восстанавливает topology. +- [ ] После удаления peer устаревшие class/filter исчезают через rebuild. +- [ ] После добавления peer появляются новые class/filter. + +## E. Auto-healing lifecycle + +- [ ] `docker restart amnezia-awg2` → лимиты восстанавливаются автоматически. +- [ ] `systemctl restart docker` → после возвращения контейнера лимиты восстанавливаются автоматически. +- [ ] `systemctl restart awg2-harness` → сервис сам находит контейнер и применяет/проверяет лимиты. +- [ ] Reboot хоста → сервис стартует и восстанавливает лимиты. +- [ ] Изменение имени интерфейса на допустимое fallback-имя не ломает detector. +- [ ] Если контейнер временно отсутствует, daemon логирует ожидание и продолжает работу. + +## F. Status and observability + +- [ ] `status` показывает desired peer limit из конфига, а не hardcoded 70. +- [ ] `status` показывает egress state. +- [ ] `status` показывает ingress/ifb state. +- [ ] `status` показывает missing/stale peer rules. +- [ ] `status` явно показывает partial/drift state. +- [ ] Логи доступны через `journalctl -u awg2-harness`. +- [ ] Ошибка отсутствия `tc`/`ip`/`wg` логируется явно. +- [ ] Последний reconcile result виден в status или journal. + +## G. Reboot scheduler + +- [ ] Если `reboot.enabled = false`, сервис не планирует reboot. +- [ ] Если `reboot.enabled = true`, в journal видна запланированная перезагрузка. +- [ ] Время попадает в окно `window_start_utc`–`window_end_utc`. +- [ ] Вызов reboot выполняется через `systemctl --no-block reboot`. + +## H. Swap + +- [ ] `ensure-swap` создаёт `/swapfile` размером 2048 MB. +- [ ] Права `/swapfile` — `0600`. +- [ ] `swapon --show` показывает активный swap. +- [ ] В `/etc/fstab` нет дублей. +- [ ] `vm.swappiness = 1` применён. +- [ ] Повторный `ensure-swap` не пересоздаёт корректный swap. +- [ ] Несовместимый существующий swap не удаляется молча. + +## I. Anti-torrent nftables guard + +- [ ] Если `anti_torrent.enabled = true`, в Amnezia netns есть `table inet awg2_harness`. +- [ ] Есть chain `forward_antitorrent` с hook `forward` и policy `accept`. +- [ ] Правило ограничивает `ip saddr `, default `10.8.1.0/24`. +- [ ] Лимит считается per source VPN IP через meter/set, а не глобально по всей подсети. +- [ ] Порог совпадает с `anti_torrent.max_connections_per_peer`, default `200`. +- [ ] Drop counter виден в `status`. +- [ ] При превышении порога новые flows дропаются, established flows ниже порога не ломаются. +- [ ] После restart контейнера nftables rule пересоздаётся в новом netns. +- [ ] При `anti_torrent.enabled = false` удаляется/отключается только AWG2-owned nftables state; Docker/Amnezia rules не flush-ятся. +- [ ] `status` показывает `nf_conntrack_count` / `nf_conntrack_max`, если sysctl доступны. + +## J. Rollback + +- [ ] Старые bash-файлы сохранены до завершения миграции. +- [ ] Известно, как остановить `awg2-harness.service`. +- [ ] Известно, как вернуть `amnezia-tc.service` и `amnezia-tc-watcher.service`. +- [ ] Rollback проверен на тестовом сервере. diff --git a/docs/ANTI_TORRENT.md b/docs/ANTI_TORRENT.md new file mode 100644 index 0000000..785c50f --- /dev/null +++ b/docs/ANTI_TORRENT.md @@ -0,0 +1,115 @@ +# Anti-torrent connection-count guard + +Этот модуль не является частью `Amnezia limits (old bash)`. Это новое расширение Rust multi-orchestrator. Оно нормально вписывается в проект, потому что сервер выделенный, а бинарь уже владеет network state вокруг Amnezia: `tc`, `ifb0`, lifecycle recovery и диагностика. + +## 1. Цель + +Не детектить BitTorrent как протокол. Цель проще: ограничить количество одновременных conntrack flows на один VPN peer IP. Торрент-клиенты обычно пытаются держать сотни TCP/UDP peer-сессий, DHT/uTP/trackers. Лимит на 200–250 одновременных flows резко ухудшает P2P-поведение, но почти не затрагивает обычные сценарии. + +Это мягкий технический барьер, а не abuse-система со страйками. + +## 2. Важное исправление к простой команде + +Не использовать такую форму как production rule: + +```nft +nft add rule inet filter forward ip saddr 10.8.1.0/24 ct count over 200 drop +``` + +Проблемы: + +1. Это может стать общим лимитом для всей подсети, а не лимитом на каждого клиента. +2. Нет ownership: правило кладётся в generic `inet filter forward`, который может принадлежать не нашему пакету. +3. Неочевидно, в каком network namespace оно применяется. +4. Нет named counter и нормальной диагностики. + +Нужен per-source key через meter/set и отдельная таблица, которой владеет только AWG2 Harness. + +## 3. Recommended v1 nftables shape + +Правило должно применяться в том же network namespace, где живёт Amnezia AWG/WG interface и где уже применяются `tc`/`ifb0` rules. На практике это namespace контейнера Amnezia, полученный через `/proc//ns/net`. + +```nft +table inet awg2_harness { + counter antitorrent_dropped_v4 {} + + chain forward_antitorrent { + type filter hook forward priority filter; policy accept; + + ip saddr ct state new \ + meter awg2_peer_connlimit_v4 { ip saddr ct count over } \ + counter name antitorrent_dropped_v4 \ + drop + } +} +``` + +Пример для текущей production-подсети: + +```nft +table inet awg2_harness { + counter antitorrent_dropped_v4 {} + + chain forward_antitorrent { + type filter hook forward priority filter; policy accept; + + ip saddr 10.8.1.0/24 ct state new \ + meter awg2_peer_connlimit_v4 { ip saddr ct count over 200 } \ + counter name antitorrent_dropped_v4 \ + drop + } +} +``` + +`ct state new` важен: ограничиваются новые flow attempts сверх порога, а уже установленные flows не начинают внезапно дропаться только потому, что счётчик стал высоким. + +## 4. Threshold + +Рекомендуемый default: + +```toml +[anti_torrent] +enabled = true +max_connections_per_peer = 200 +``` + +Практические варианты: + +- `150` — агрессивнее, больше риск задеть редкие легитимные сценарии. +- `200` — хороший default для VPN peer с лимитом 70 Mbit/s. +- `250` — более осторожный вариант, если хочется минимизировать риск жалоб от тяжёлых браузерных/WebRTC/WebSocket-сценариев. + +## 5. Почему это должно быть в orchestrator + +Модуль должен жить в Rust-бинаре, а не отдельным ручным правилом, потому что orchestrator должен: + +- применять правило после container restart/update, когда network namespace изменился; +- проверять actual state и чинить drift; +- показывать status/counters; +- не ломать unrelated nftables/Docker/Amnezia rules; +- удалять или отключать только owned state при `enabled = false`. + +## 6. Conntrack и производительность + +`ct count` использует conntrack. На текущем масштабе это нормально: даже 23 peers × 200 flows = около 4600 активных flows до ограничения. Это не выглядит опасно для production-хоста само по себе. + +Что надо мониторить: + +```bash +sysctl net.netfilter.nf_conntrack_count +sysctl net.netfilter.nf_conntrack_max +nft list table inet awg2_harness +``` + +Если `nf_conntrack_count` приближается к `nf_conntrack_max`, проблема уже системная: надо разбираться с timeout, abuse или capacity. Не надо автоматически увеличивать `nf_conntrack_max` из orchestrator без отдельного решения. + +## 7. Acceptance criteria + +- В правильном Amnezia netns существует `table inet awg2_harness`. +- В таблице есть `chain forward_antitorrent` с hook `forward`. +- Правило ограничивает `ip saddr `. +- Лимит применяется per source VPN IP, а не глобально на всю подсеть. +- Drop counter растёт при превышении threshold. +- Нормальные сценарии с числом flows ниже threshold не затрагиваются. +- При restart контейнера правило пересоздаётся в новом netns. +- При `anti_torrent.enabled = false` удаляется или отключается только AWG2-owned nftables state. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md new file mode 100644 index 0000000..2ad11ae --- /dev/null +++ b/docs/CONFIGURATION.md @@ -0,0 +1,96 @@ +# Конфигурация AWG2 Harness + +Конфиг живёт в `/etc/awg2-harness/config.toml`. Он общий для всего пакета и должен содержать только эксплуатационно значимые параметры. + +## Рекомендуемый `config.toml` + +```toml +[amnezia] +container_names = ["amnezia-awg2", "amnezia-awg"] +interface_candidates = ["awg0", "wg0", "amnezia0", "tun0"] +interface_patterns = ["awg*", "wg*", "amnezia*", "tun*"] +peer_ipv4_cidr = "10.8.1.0/24" + +[traffic] +enabled = true +peer_limit = "70mbit" +root_rate = "1000mbit" +reconcile_interval_secs = 60 +container_ready_timeout_secs = 30 +class_start_id = 10 +ifb_name = "ifb0" +full_rebuild_on_drift = true + +[anti_torrent] +enabled = true +max_connections_per_peer = 200 +peer_ipv4_cidr = "10.8.1.0/24" +apply_in_amnezia_netns = true + +[reboot] +enabled = true +window_start_utc = "01:00" +window_end_utc = "02:00" + +[swap] +enabled = true +path = "/swapfile" +size_mb = 2048 +priority = -2 +swappiness = 1 +``` + +## Пояснения + +`amnezia.container_names` — имена контейнеров, которые считаются Amnezia/AWG. Порядок важен. По текущему production-снимку основной вариант — `amnezia-awg2`, fallback — `amnezia-awg`. + +`amnezia.interface_candidates` — точные имена интерфейса, которые пробуются первыми. + +`amnezia.interface_patterns` — fallback-паттерны для устойчивости к обновлениям Amnezia. Старые материалы прямо отмечают риск переименования интерфейса между `awg0`, `wg0`, `amnezia0`. + +`amnezia.peer_ipv4_cidr` — подсеть VPN peers. Сейчас старая bash-система жёстко ищет `10.8.1.x`; в Rust это должно быть конфигурируемо. + +`traffic.peer_limit` — per-peer лимит в обе стороны. Текущий production default — `70mbit`. + +`traffic.root_rate` — верхний HTB-класс, в старой системе `1000mbit`. + +`traffic.reconcile_interval_secs` — период фоновой проверки. Он нужен не вместо Docker events, а вместе с ними. + +`traffic.container_ready_timeout_secs` — сколько ждать появления интерфейса после старта контейнера. + +`traffic.class_start_id` — первый class id. В старой системе это `10`, что даёт классы `1:10`, `2:10` и дальше. + +`traffic.ifb_name` — имя IFB-интерфейса для ingress shaping. В старой системе это `ifb0`. Этот параметр можно оставить в конфиге, потому что бинарь реально создаёт/удаляет этот интерфейс. + +`traffic.full_rebuild_on_drift` — безопасная v1-модель: при drift удалить owned topology и собрать заново. + +`anti_torrent.enabled` — включает nftables connection-count guard против high-peer-count P2P-нагрузки. Это новое расширение, не часть old bash. + +`anti_torrent.max_connections_per_peer` — максимум одновременных conntrack flows на один VPN peer IP. Default `200`; `250` — более консервативный вариант. + +`anti_torrent.peer_ipv4_cidr` — подсеть, на которую распространяется правило. Обычно должна совпадать с `amnezia.peer_ipv4_cidr`. + +`anti_torrent.apply_in_amnezia_netns` — правило применяется в network namespace контейнера Amnezia. Это default, потому что именно там видны исходные VPN peer IP до возможного NAT. + +`reboot.enabled` — включает плановую перезагрузку. + +`reboot.window_start_utc` / `reboot.window_end_utc` — UTC-окно плановой перезагрузки. В старом Rust-модуле это было зашито как 01:00–02:00 UTC. + +`swap.enabled` — включает setup-задачу swap. + +`swap.path`, `swap.size_mb`, `swap.priority`, `swap.swappiness` — параметры аварийного swap. Default: 2 ГБ, низкий priority, `swappiness = 1`. + +## Что не выносить в конфиг + +Не нужно выносить: + +- handles `1:`, `2:`, `ffff:`; +- имена внутренних Rust-модулей; +- формат команд `tc`; +- retry micro-tuning; +- формат логов; +- путь lock-файла, если нет эксплуатационной причины; +- hardening systemd; +- nftables hook/priority/table names, если нет осознанной причины. + +Handles и структура `HTB + fq_codel + ingress + ifb` — это contract traffic module, а не настройка оператора. diff --git a/docs/IMPLEMENTATION_SPEC.md b/docs/IMPLEMENTATION_SPEC.md new file mode 100644 index 0000000..323eead --- /dev/null +++ b/docs/IMPLEMENTATION_SPEC.md @@ -0,0 +1,444 @@ +# AWG2 Harness implementation spec + +## 1. Architectural role + +`awg2-harness` is a single Rust binary acting as a host-side multi-orchestrator for a dedicated Debian 13 amd64 AmneziaWG/AWG 2.0 server. + +It owns the infrastructure state needed by the VPN service: + +- Amnezia container discovery; +- container network namespace discovery; +- AWG/WG interface discovery; +- peer discovery; +- traffic-control desired state; +- IFB ingress shaping; +- nftables connection-count anti-torrent guard; +- Docker lifecycle recovery; +- periodic reconciliation; +- status/diagnostics; +- planned reboot; +- emergency swap setup. + +It does not replace Amnezia. It controls the surrounding system state that the old bash package configured manually. + +## 2. Process model + +One systemd service runs: + +```bash +/usr/local/bin/awg2-harness daemon --config /etc/awg2-harness/config.toml +``` + +The daemon starts as root and keeps running. On startup it performs initial reconcile. During runtime it reacts to Docker events and also runs a periodic reconcile loop. + +Subcommands: + +```bash +awg2-harness daemon +awg2-harness apply-limits +awg2-harness apply-firewall +awg2-harness status +awg2-harness ensure-swap +awg2-harness validate-config +``` + +## 3. Module layout + +Recommended Rust module split: + +```text +src/ +├── main.rs +├── cli.rs +├── config.rs +├── daemon.rs +├── lock.rs +├── docker.rs +├── netns.rs +├── iface.rs +├── wg.rs +├── traffic/ +│ ├── mod.rs +│ ├── model.rs +│ ├── inspect.rs +│ ├── apply.rs +│ ├── parse_tc.rs +│ └── commands.rs +├── firewall/ +│ ├── mod.rs +│ ├── model.rs +│ ├── inspect.rs +│ ├── apply.rs +│ └── nft.rs +├── status.rs +├── reboot.rs +├── swap.rs +└── error.rs +``` + +## 4. Core data model + +The traffic module should operate on explicit model structs, not raw strings passed around the codebase. + +Conceptual model: + +```rust +struct RuntimeTarget { + container_name: String, + container_id: String, + container_pid: u32, + netns_path: PathBuf, + interface_name: String, +} + +struct Peer { + public_key: Option, + ipv4: Ipv4Addr, + cidr: u8, // normally 32 + latest_handshake: Option, + transfer_rx: Option, + transfer_tx: Option, +} + +struct TrafficDesiredState { + iface: String, + ifb_name: String, + root_rate: Rate, + peer_limit: Rate, + class_start_id: u16, + peers: Vec, +} + +struct PeerTrafficClass { + peer_ip: Ipv4Addr, + class_id: u16, +} +``` + +Class assignment must be deterministic. For strict parity with bash v1, peer IP strings must be sorted lexicographically, exactly like `sort -u` in the old script, then assigned `class_start_id + index`. Do not silently switch this to numeric IPv4 sorting in v1: the production snapshot maps `10.8.1.10` before `10.8.1.2` because bash sorted text, not numeric addresses. + +## 5. Namespace strategy + +Preferred strategy: + +1. Detect running container through Docker API or `docker ps` / `docker inspect`. +2. Read container PID. +3. Use `/proc//ns/net` as the target namespace. +4. Execute network operations in that namespace. + +Implementation options: + +- transitional v1: spawn host tools with namespace entry, for example `nsenter -t -n tc ...`, `nsenter -t -n ip ...`, `nsenter -t -n wg ...`; +- later/native: use netlink crates and explicit namespace switching where practical. + +`docker exec` should not be the architectural default. It can remain as compatibility fallback if `nsenter`/host tools are unavailable, but the design assumption is that the Rust binary has host authority and controls the relevant namespace. + +This also reduces the risk noted in the old docs: if the Amnezia container drops `tc` or `iproute2`, host-side tools can still apply rules inside the container netns. + +## 6. Discovery flow + +`discover_target()`: + +1. Load ordered `amnezia.container_names`. +2. Find the first running matching container. +3. Get container ID, status and PID. +4. Build `netns_path`. +5. Detect interface in that namespace. +6. Return `RuntimeTarget`. + +Interface detection order: + +1. Exact names from `amnezia.interface_candidates`. +2. Pattern scan from `amnezia.interface_patterns`. +3. Fallback: first non-loopback interface with IPv4 address, only if it is clearly inside the Amnezia namespace. +4. If ambiguous, return an explicit error and do not apply rules. + +## 7. Peer discovery + +Primary command: + +```bash +wg show all allowed-ips +``` + +Parsing rules: + +- parse lines structurally into peer public key and allowed IPs; +- keep IPv4 `/32` addresses within `amnezia.peer_ipv4_cidr`; +- include offline peers; online state is not a requirement for shaping; +- sort peer IP strings lexicographically before class assignment to preserve old bash `sort -u` parity. + +Fallback: + +```bash +wg show +``` + +The fallback parser should support the old output format from `wg-peers.txt`, extracting `allowed ips:` lines. + +## 8. Desired traffic topology + +The expected topology exactly mirrors old bash. + +Egress: + +```text +tc qdisc add dev root handle 1: htb +tc class add dev parent 1: classid 1:1 htb rate ceil +for each peer: + tc class add dev parent 1:1 classid 1: htb rate ceil + tc qdisc add dev parent 1: fq_codel + tc filter add dev parent 1: protocol ip u32 match ip dst flowid 1: +``` + +Ingress through IFB: + +```text +modprobe ifb +ip link add name type ifb +ip link set dev up +tc qdisc add dev handle ffff: ingress +tc filter add dev parent ffff: protocol all u32 match u32 0 0 action mirred egress redirect dev +tc qdisc add dev root handle 2: htb +tc class add dev parent 2: classid 2:1 htb rate ceil +for each peer: + tc class add dev parent 2:1 classid 2: htb rate ceil + tc qdisc add dev parent 2: fq_codel + tc filter add dev parent 2: protocol ip u32 match ip src flowid 2: +``` + +## 9. Reconcile algorithm + +Pseudo-flow: + +```text +reconcile_traffic(): + acquire global lock + target = discover_target() + peers = discover_peers(target) + if peers empty: + log and return NoPeers + desired = build_desired_state(target, peers, config) + actual = inspect_actual_state(target, desired) + drift = diff(desired, actual) + firewall_desired = build_firewall_desired_state(target, config) + firewall_actual = inspect_firewall_state(target, firewall_desired) + firewall_drift = diff(firewall_desired, firewall_actual) + + if drift.none and firewall_drift.none: + log "rules already up-to-date" + return NoChange + + if drift.exists: + if config.traffic.full_rebuild_on_drift: + teardown_owned_topology(target, desired) + apply_full_topology(target, desired) + else: + apply_incremental_fix(drift) + verify_after_apply(target, desired) + + if firewall_drift.exists: + apply_owned_firewall_table(target, firewall_desired) + verify_firewall_after_apply(target, firewall_desired) +``` + +For v1, `full_rebuild_on_drift = true` is the safer default. + +## 10. Owned state and teardown rules + +The binary owns: + +- root qdisc `1:` on selected AWG/WG interface; +- ingress qdisc `ffff:` on selected AWG/WG interface when traffic limits are enabled; +- IFB device named `traffic.ifb_name`, default `ifb0`, in the target namespace; +- root qdisc `2:` on IFB; +- classes and filters under these handles. + +Teardown may delete and recreate these owned objects. Because the server is dedicated, this is acceptable and preferable to leaving stale or partial rules. + +The binary should not blindly flush unrelated host firewall rules, Docker NAT rules, or unrelated interfaces. Firewall ownership must be explicit and scoped to the AWG2 Harness table, for example `table inet awg2_harness`, inside the target network namespace. + + +## 11. Anti-torrent firewall guard + +This is a new module, not part of old bash parity. It fits the multi-orchestrator model because the server is dedicated and the binary is allowed to own host/network rules required for the VPN product. The rule must still be scoped narrowly and observable. + +Goal: limit concurrent conntrack flows per VPN peer IP. This is not torrent protocol detection. It is a generic connection-count guard that makes high-peer-count P2P workloads unattractive while leaving normal browsing, streaming, gaming and messaging traffic unaffected. + +The rule must not be implemented as a global subnet limit: + +```nft +ip saddr 10.8.1.0/24 ct count over 200 drop +``` + +That form can become one shared counter for the whole matched traffic set, depending on how it is written and where it is placed. For per-peer behavior, key the stateful object by source VPN IP. Recommended v1 shape: + +```nft +table inet awg2_harness { + counter antitorrent_dropped_v4 {} + + chain forward_antitorrent { + type filter hook forward priority filter; policy accept; + + ip saddr ct state new \ + meter awg2_peer_connlimit_v4 { ip saddr ct count over } \ + counter name antitorrent_dropped_v4 \ + drop + } +} +``` + +Target namespace: the same Amnezia network namespace where `tc` shaping is applied. Do not assume the initial host namespace sees original peer source addresses. If Amnezia performs NAT inside the container namespace, the host Docker bridge may only see the container address, not `10.8.1.x`. + +Default threshold: `200` concurrent flows per peer IP. `250` is the conservative compatibility setting if there are complaints from unusually connection-heavy WebSocket/WebRTC/trading-browser workloads. `150` is more aggressive and should only be used after real traffic observation. + +Rule placement: use `forward` hook with priority `filter` or any priority after conntrack lookup. Restrict evaluation to `ct state new` so established flows continue normally and only new flow attempts beyond the threshold are dropped. + +Owned state: + +- table `inet awg2_harness`; +- chain `forward_antitorrent`; +- counter `antitorrent_dropped_v4`; +- meter/set state created by the rule. + +Status must show whether the table/chain/rule exists, the configured threshold, the peer CIDR, and the drop counter. It should also expose host conntrack pressure from `net.netfilter.nf_conntrack_count` and `net.netfilter.nf_conntrack_max` when available. + +If `anti_torrent.enabled = false`, the daemon should remove or leave inactive only the AWG2-owned anti-torrent table according to config policy. It must never flush the global nftables ruleset. + +## 12. Actual-state inspection + +`inspect_actual_state()` must collect enough information to diagnose drift: + +- qdisc on ``; +- classes on ``; +- qdiscs attached to per-peer classes on ``; +- filters under parent `1:` on ``; +- ingress qdisc on ``; +- ingress redirect filter on ``; +- existence and state of ``; +- qdisc on ``; +- classes on ``; +- filters under parent `2:` on ``. + +`status` should display a compact summary and optionally a verbose mode for raw `tc` sections. + +## 13. Docker events and periodic reconciliation + +The daemon should use two triggers: + +1. Docker events: container start/restart signals cause delayed reconcile after interface readiness. +2. Timer: periodic reconcile every `traffic.reconcile_interval_secs`. + +The timer is mandatory. Events can be missed during daemon restart, Docker restart or transient errors. + +Readiness check must not hardcode `awg0`. It must call the shared interface detector until timeout. + +## 14. Locking + +Only one traffic reconcile may run at a time across all subcommands and daemon tasks. + +Recommended lock: + +```text +/run/awg2-harness/traffic.lock +``` + +Behavior: + +- `daemon` skips overlapping reconcile and logs it; +- manual `apply-limits` either waits briefly or exits with a clear message; +- lock release is automatic when process exits. + +## 15. Status output contract + +`awg2-harness status` should show: + +```text +System: + service: active/inactive/unknown + docker: available/unavailable + container: name, id, status, pid + netns: /proc//ns/net + interface: awg0/wg0/... + +Peers: + total: N + each peer: ip, optional public key prefix, handshake, rx/tx + +Traffic desired: + peer_limit, root_rate, ifb_name, class_start_id + +Traffic actual: + egress qdisc/classes/filters: ok/drift + ingress redirect: ok/drift + ifb topology: ok/drift + stale peers: list + missing peers: list + +Anti-torrent actual: + enabled: true/false + nft table/chain/rule: ok/drift + max_connections_per_peer + dropped_new_flows counter + conntrack_count / conntrack_max if available + +Last actions: + last reconcile result + last error if any +``` + +The old status banner “system ready” is not enough. Rust status must be falsifiable: it should clearly say when state is partial. + +## 16. Reboot module integration + +Move existing `reboot_module` logic into `reboot.rs`: + +- window from config, not constants; +- random point inside `[window_start_utc, window_end_utc)`; +- `systemctl --no-block reboot` remains; +- disabled when `reboot.enabled = false`; +- daemon logs planned reboot time. + +## 17. Swap module + +`ensure-swap` desired state: + +- path from config, default `/swapfile`; +- size 2048 MB; +- mode `0600`; +- `mkswap` completed; +- active in `swapon --show`; +- one `/etc/fstab` entry with configured priority; +- `vm.swappiness = 1` persisted and applied. + +The module must be idempotent. It must not delete unknown swap state automatically. + +## 18. Testing + +Unit tests: + +- config validation; +- `wg show all allowed-ips` parser; +- `wg show` fallback parser; +- CIDR filtering; +- deterministic class assignment with lexicographic bash parity; +- nftables ruleset rendering and parsing for the AWG2-owned table; +- `tc` parser for qdisc/class/filter snapshots; +- drift diff; +- reboot window calculation; +- fstab/sysctl idempotency. + +Integration tests on real host: + +- initial apply; +- repeated apply; +- partial state recovery after deleting only ingress qdisc; +- partial state recovery after deleting `ifb0`; +- container restart; +- Docker restart; +- host reboot; +- peer addition; +- peer deletion; +- status output in healthy and broken states; +- anti-torrent rule counter increments when a peer exceeds the configured concurrent-flow threshold; +- anti-torrent disable path does not flush unrelated nftables/Docker/Amnezia rules. diff --git a/docs/INSTALLATION.md b/docs/INSTALLATION.md new file mode 100644 index 0000000..05466eb --- /dev/null +++ b/docs/INSTALLATION.md @@ -0,0 +1,153 @@ +# Установка AWG2 Harness на production-хост + +## 1. Предусловия + +Хост: + +- Debian 13 amd64; +- root-доступ; +- SSH настроен заранее; +- AmneziaWG/AWG 2.0 установлена официальным способом; +- контейнер Amnezia запускается через Docker; +- сервер выделенный, без соседних сервисов, чьи сетевые правила нужно сохранять. + +На хосте должны быть доступны: + +```bash +docker +systemctl +ip +tc +wg +nsenter +modprobe +nft +``` + +`tc/ip/wg` внутри контейнера полезны для fallback, но целевая модель — host tools + вход в network namespace контейнера. + +## 2. Сборка бинаря + +Сборку лучше выполнять на отдельной build-машине с тем же целевым ABI. + +```bash +cargo build --release +``` + +Проверки перед переносом: + +```bash +cargo fmt --check +cargo clippy --all-targets --all-features -- -D warnings +cargo test --release +file target/release/awg2-harness +ldd target/release/awg2-harness +``` + +## 3. Установка файлов + +```bash +install -m 0755 target/release/awg2-harness /usr/local/bin/awg2-harness +install -d -m 0755 /etc/awg2-harness +install -m 0644 config.toml /etc/awg2-harness/config.toml +``` + +Проверка конфига: + +```bash +awg2-harness validate-config --config /etc/awg2-harness/config.toml +``` + +## 4. Первичное применение traffic topology + +```bash +awg2-harness apply-limits --config /etc/awg2-harness/config.toml +awg2-harness status --config /etc/awg2-harness/config.toml +``` + +Ожидается: + +- найден контейнер `amnezia-awg2` или `amnezia-awg`; +- найден network namespace контейнера; +- найден AWG/WG-интерфейс; +- найдены peers из `wg show all allowed-ips`; +- создана egress topology на AWG/WG-интерфейсе; +- создан `ifb0`; +- создан ingress redirect в `ifb0`; +- на каждого peer есть egress и ingress class/filter; +- если anti-torrent включён, в Amnezia netns есть `table inet awg2_harness` и drop counter отображается в status. + +## 5. Настройка swap + +```bash +awg2-harness ensure-swap --config /etc/awg2-harness/config.toml +swapon --show +cat /proc/sys/vm/swappiness +``` + +Ожидается `/swapfile` размером 2 ГБ, права `0600`, запись в `/etc/fstab`, низкая склонность к использованию. + +## 6. Установка systemd unit + +`/etc/systemd/system/awg2-harness.service`: + +```ini +[Unit] +Description=AWG2 Harness multi-orchestrator +After=network-online.target docker.service +Wants=network-online.target +Requires=docker.service + +[Service] +Type=simple +User=root +ExecStart=/usr/local/bin/awg2-harness daemon --config /etc/awg2-harness/config.toml +Restart=always +RestartSec=5 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target +``` + +Активация: + +```bash +systemd-analyze verify /etc/systemd/system/awg2-harness.service +systemctl daemon-reload +systemctl enable --now awg2-harness.service +``` + +## 7. Проверка после запуска daemon + +```bash +systemctl status awg2-harness.service +journalctl -u awg2-harness.service -n 100 --no-pager +awg2-harness status --config /etc/awg2-harness/config.toml +``` + +## 8. Проверка auto-healing + +```bash +docker restart amnezia-awg2 +sleep 30 +awg2-harness status --config /etc/awg2-harness/config.toml +``` + +Затем проверить Docker restart и host reboot. После каждого события status должен показывать healthy state без ручного запуска old bash. + +## 9. Rollback + +Если нужно временно вернуться к old bash: + +```bash +systemctl stop awg2-harness.service +systemctl disable awg2-harness.service +# вернуть /opt/amnezia/limit_peers.sh и /opt/amnezia/docker_watcher.sh +# вернуть amnezia-tc.service и amnezia-tc-watcher.service +systemctl daemon-reload +systemctl enable --now amnezia-tc.service amnezia-tc-watcher.service +``` + +Rollback должен быть проверен до первой production-выкладки. diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md new file mode 100644 index 0000000..d684dd0 --- /dev/null +++ b/docs/MIGRATION.md @@ -0,0 +1,269 @@ +# Миграция AWG2 Harness с bash-системы на Rust multi-orchestrator + +## 1. Исправленный scope + +AWG2 Harness не должен быть просто Rust-эквивалентом пары bash-скриптов. Цель шире: сделать единый production multi-orchestrator, который сначала приводит сервер и Amnezia network namespace к нужному состоянию, а затем постоянно следит за drift и возвращает систему в desired state. + +Источник поведения — папка `Amnezia limits (old bash)/`. Всё, что там реально настроено и используется в production, должно быть явно перенесено в Rust-модель: обнаружение контейнера, обнаружение интерфейса, получение peers, `tc` rules, `ifb0`, ingress redirect, per-peer HTB classes, `fq_codel`, auto-healing, статусная диагностика и логи. Новые правила, которых не было в old bash, например anti-torrent через nftables, должны быть описаны отдельно как расширения, а не смешаны с parity contract. + +Сервер выделенный. На нём нет соседних сервисов, которые надо защищать от наших сетевых правил. Поэтому бинарь может и должен владеть host/network rules, необходимыми для Amnezia. Ограничение только одно: не ломать саму Amnezia как продукт и не менять её конфиги без явной задачи. Но системные правила, созданные old bash для лимитов, становятся зоной ответственности Rust-бинаря. + +## 2. Production-контекст + +Целевой хост: + +- Debian 13 amd64. +- SSH настроен заранее владельцем сервера. +- AmneziaWG/AWG 2.0 устанавливается официальным способом до установки AWG2 Harness. +- После Amnezia доставляется один Rust-бинарь и единый конфиг. +- Пакет ставится один раз; автообновления самого пакета не проектируются. +- Бинарь запускается от root через systemd, потому что ему нужны Docker/systemd/net-admin/swap возможности. + +Система должна переживать: + +- reboot хоста; +- restart Docker; +- restart контейнера Amnezia; +- update контейнера Amnezia; +- изменение имени интерфейса `awg0` / `wg0` / `amnezia0` / `tun0`; +- добавление peer; +- удаление peer; +- частично применённое или повреждённое `tc`-состояние; +- частично применённое или повреждённое AWG2-owned nftables-состояние. + +## 3. Что именно делает старая bash-система + +Старая система состоит из нескольких файлов, и все они важны. + +`limit_peers.sh` — главный apply/reconcile-скрипт. Он: + +- берёт lock через `/tmp/amnezia-tc.lock`, чтобы не было параллельных применений; +- ищет запущенный контейнер `amnezia-awg2`, затем fallback `amnezia-awg`; +- проверяет интерфейс `WG_IF`, по умолчанию `wg0`, и если его нет, авто-детектит `wg*`, `awg*`, `amnezia*`, `tun*`; +- получает client IP из `wg show all allowed-ips`, fallback — regex из `wg show`; +- фильтрует текущую production-подсеть `10.8.1.x`; +- проверяет существующие `tc filter` по hex-адресам client IP; +- если состояние не совпадает, полностью удаляет старую topology и создаёт новую; +- создаёт root HTB на WG/AWG-интерфейсе: `tc qdisc add dev root handle 1: htb`; +- создаёт root class `1:1` с `rate 1000mbit ceil 1000mbit`; +- загружает/создаёт `ifb0`; +- добавляет ingress qdisc `ffff:` на WG/AWG-интерфейс; +- добавляет ingress redirect через `mirred egress redirect dev ifb0`; +- создаёт root HTB на `ifb0`: `handle 2:`; +- создаёт root class `2:1` с `rate 1000mbit ceil 1000mbit`; +- для каждого peer создаёт egress class `1:` с `rate LIMIT ceil LIMIT`; +- для каждого peer создаёт `fq_codel` под egress class; +- для каждого peer добавляет egress filter `match ip dst flowid 1:`; +- для каждого peer создаёт ingress class `2:` с тем же лимитом; +- для каждого peer создаёт `fq_codel` под ingress class; +- для каждого peer добавляет ingress filter на `ifb0`: `match ip src flowid 2:`; +- начинает `class_id` с 10; +- пишет события в `/var/log/amnezia-tc.log`. + +`docker_watcher.sh` слушает `docker events --filter event=start`. При старте `amnezia-awg2` или `amnezia-awg` он ждёт появления `awg0` до 20 секунд и запускает `limit_peers.sh`. В Rust это нужно исправить: watcher не должен ждать только `awg0`, он обязан использовать общий detector интерфейса. + +`check-vpn-limits` — CLI-диагностика. Он показывает systemd статус, контейнер, peers из `wg show`, классы с лимитом 70 Mbit/s, filter stats и последние строки `/var/log/amnezia-tc.log`. Rust `status` должен заменить этот отчёт, но проверять шире: оба направления, `ifb0`, ingress redirect и соответствие desired state. + +`amnezia-tc.service` — oneshot apply после Docker/network-online. + +`amnezia-tc-watcher.service` — постоянно запущенный watcher Docker events. + +`traffic_control-was.txt`, `wg-peers.txt`, `iproute2_a_core.txt`, `permissions.txt`, `Нюансы.txt` — production-снимки. Они фиксируют реальные условия: Debian 13 cloud kernel, Docker 26.1.5, iproute2 6.15.0, интерфейс `awg0`, 23 peer IP в `10.8.1.0/24`, HTB classes `1:10..1:32`, `fq_codel` leaves и enabled systemd units. + +## 4. Desired state Rust-бинаря + +Rust-бинарь должен иметь explicit desired state model. Нельзя ограничиться последовательным запуском команд “как в bash”. Нужно уметь сравнить expected и actual, а затем принять решение: no-op, incremental fix или full rebuild. + +Базовый desired state для traffic limits: + +```text +container: first running from configured names +netns: namespace of selected container +interface: detected AWG/WG interface inside that netns +peers: IPv4 /32 allowed IPs inside configured peer CIDR +root egress qdisc: root handle 1: htb +root egress class: 1:1 rate root_rate ceil root_rate +per-peer egress class: 1: rate peer_limit ceil peer_limit +per-peer egress qdisc: fq_codel under 1: +per-peer egress filter: dst peer_ip -> 1: +ingress qdisc: handle ffff: +ingress redirect: ingress -> ifb0 +ifb device: ifb0 up in the same namespace where shaping is applied +root ingress qdisc: ifb0 root handle 2: htb +root ingress class: 2:1 rate root_rate ceil root_rate +per-peer ingress class: 2: rate peer_limit ceil peer_limit +per-peer ingress qdisc: fq_codel under 2: +per-peer ingress filter: src peer_ip -> 2: +``` + +Любое отклонение от этого состояния считается drift. Если drift простой, можно исправить точечно. Для v1 допустим и предпочтителен full rebuild всей AWG traffic topology, потому что он проще, предсказуемее и повторяет текущую production-модель. + +## 5. Модель управления host/network state + +Исправление к предыдущей версии документации: бинарь не должен рассматриваться как осторожная внешняя обёртка, которая только вызывает `docker exec` и не владеет правилами. Правильная модель — host-level orchestrator with namespace control. + +Практический порядок для v1: + +1. На хосте определить контейнер через Docker API или `docker inspect`. +2. Получить PID контейнера и путь к его network namespace: `/proc//ns/net`. +3. Все операции `wg`, `ip`, `tc`, `ifb` выполнять в правильном namespace. +4. Для реализации можно выбрать один из двух путей: + - через `nsenter -t -n ...` / `ip netns exec`-эквивалент и системные команды; + - через netlink/native Rust libraries после входа в namespace. +5. `docker exec` допустим только как fallback или transitional adapter, но не как архитектурный центр. Причина: это делает нас зависимыми от shell/tools внутри контейнера и хуже соответствует модели “наш бинарь управляет состоянием сервера”. + +Для первой production-версии нормальный компромисс: Rust-бинарь владеет orchestration, locking, state comparison, retry, status и lifecycle, а низкоуровневые `tc/ip/wg` операции выполняет через команды в namespace. Нативный netlink можно вводить после стабилизации, если это даст реальный выигрыш по надёжности или нагрузке. + +## 6. Обязательные функции Rust-версии + +Минимум для parity со старой системой: + +1. `daemon` — основной systemd-процесс. +2. `apply-limits` — ручное применение/reconcile traffic limits. +3. `status` — диагностика уровня `check-vpn-limits`, но с проверкой обоих направлений и drift. +4. `validate-config` — проверка конфига без изменения системы. +5. `apply-firewall` — ручной reconcile AWG2-owned nftables state, если anti-torrent включён. +6. `ensure-swap` — идемпотентный setup swap-файла 2 ГБ. +7. Встроенный reboot scheduler из `reboot_module`. +8. Locking, чтобы одновременно не шли два apply/reconcile. +9. Docker event watcher. +10. Периодический reconciliation loop, потому что Docker events могут быть пропущены. +11. Start-up reconciliation: при запуске daemon сразу проверить и применить лимиты и AWG2-owned firewall state. +12. Graceful behavior, если контейнер временно отсутствует: логировать и продолжать loop, а не падать насмерть. + +## 7. Подкоманды + +```bash +awg2-harness daemon +awg2-harness apply-limits +awg2-harness apply-firewall +awg2-harness status +awg2-harness ensure-swap +awg2-harness validate-config +``` + +`daemon` — основной production-режим. Он делает initial reconcile, слушает Docker events, периодически проверяет state, интегрирует reboot scheduler и пишет runtime-логи в journald. + +`apply-limits` — немедленный reconcile traffic limits. Команда должна быть безопасной для повторного запуска. + +`apply-firewall` — немедленный reconcile nftables anti-torrent rules. Команда должна менять только AWG2-owned table/chain. + +`status` — отчёт о текущем состоянии: контейнер, PID/netns, интерфейс, peers, egress topology, ingress/ifb topology, drift summary, последние ошибки. + +`ensure-swap` — setup-задача, не постоянный loop. + +`validate-config` — синтаксическая и семантическая проверка. + +## 8. Что нужно исправить относительно bash + +Bash работает, но в Rust нужно убрать известные слабые места. + +Первое: единый detector. Сейчас `limit_peers.sh` умеет авто-детектить интерфейс, а `docker_watcher.sh` ждёт только `awg0`. В Rust detector должен быть один и использоваться везде. + +Второе: проверка полного state. Старый skip decision в основном смотрит фильтры на WG-интерфейсе. Rust должен проверять также `ifb0`, ingress redirect, classes на `ifb0`, qdisc leaves и соответствие class_id ↔ peer_ip. + +Третье: парсинг peers. Текущий regex жёстко ищет `10.8.1.x`. В Rust подсеть должна быть конфигурируемой, а парсинг `wg show all allowed-ips` — структурным. + +Четвёртое: наблюдаемость. Status должен отличать “container отсутствует”, “interface отсутствует”, “wg не читается”, “tc отсутствует”, “rules partial”, “rules match desired”. + +Пятое: namespace ownership. Не надо полагаться на shell внутри контейнера как на единственный способ управления. Бинарь должен уметь работать от host authority и заходить в namespace контейнера. + +## 9. Единый конфиг + +Конфиг должен жить в `/etc/awg2-harness/config.toml` и содержать только параметры, которые реально меняются оператором. + +```toml +[amnezia] +container_names = ["amnezia-awg2", "amnezia-awg"] +interface_candidates = ["awg0", "wg0", "amnezia0", "tun0"] +interface_patterns = ["awg*", "wg*", "amnezia*", "tun*"] +peer_ipv4_cidr = "10.8.1.0/24" + +[traffic] +enabled = true +peer_limit = "70mbit" +root_rate = "1000mbit" +reconcile_interval_secs = 60 +container_ready_timeout_secs = 30 +class_start_id = 10 +ifb_name = "ifb0" +full_rebuild_on_drift = true + +[anti_torrent] +enabled = true +max_connections_per_peer = 200 +peer_ipv4_cidr = "10.8.1.0/24" +apply_in_amnezia_netns = true + +[reboot] +enabled = true +window_start_utc = "01:00" +window_end_utc = "02:00" + +[swap] +enabled = true +path = "/swapfile" +size_mb = 2048 +priority = -2 +swappiness = 1 +``` + +`ifb_name` можно оставить в конфиге, потому что это имя системного интерфейса, которым владеет orchestrator. Handles `1:`, `2:`, `ffff:` лучше не выносить в конфиг: это implementation contract AWG traffic module, а не эксплуатационная настройка. + +## 10. Anti-torrent extension + +Anti-torrent не входит в старую bash-систему, но хорошо ложится в целевую модель multi-orchestrator. Бинарь уже владеет нужным network namespace и должен уметь применять, проверять и восстанавливать nftables state так же, как он восстанавливает `tc` topology. + +Правильная v1-модель — не protocol detection и не strike-система, а простой per-peer connection-count guard через nftables `ct count`. Правило должно быть scoped к VPN peer CIDR и keyed by source peer IP через `meter`, иначе можно случайно получить общий лимит на всю подсеть. + +Правило должно жить в отдельной AWG2-owned таблице, например `table inet awg2_harness`, и применяться в Amnezia network namespace. Нельзя делать `flush ruleset`; удалять можно только owned table/chain. + +См. `docs/ANTI_TORRENT.md`. + +## 11. Systemd-модель + +Вместо двух старых units нужен один daemon-unit: + +```ini +[Unit] +Description=AWG2 Harness multi-orchestrator +After=network-online.target docker.service +Wants=network-online.target +Requires=docker.service + +[Service] +Type=simple +User=root +ExecStart=/usr/local/bin/awg2-harness daemon --config /etc/awg2-harness/config.toml +Restart=always +RestartSec=5 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target +``` + +Сервис должен запускаться от root. На выделенном сервере не нужно искусственно ограничивать его так, чтобы он не мог управлять сетью. Hardening можно добавлять после реальной проверки, но нельзя включать `PrivateNetwork`, `PrivateDevices` или слишком жёсткий `CapabilityBoundingSet`, если они ломают `tc`, `ifb`, Docker или reboot. + +## 12. Reboot и swap + +`reboot_module` интегрируется внутрь общего daemon. Окно перезагрузки должно переехать из констант в конфиг. Механика `systemctl --no-block reboot` сохраняется. + +`ensure-swap` должен создать 2 ГБ swap-файл с правами `0600`, добавить одну запись в `/etc/fstab`, включить swap и выставить низкую склонность к использованию (`vm.swappiness = 1`, низкий priority). Если на сервере уже есть несовместимый swap, команда должна сообщить об этом явно, а не разрушать чужое состояние без подтверждения. + +## 13. Acceptance критерий миграции + +Миграция считается корректной только если Rust-бинарь на реальном Debian 13 хосте: + +- создаёт ту же traffic-control topology, что старая bash-система; +- ограничивает каждого peer в обе стороны на `traffic.peer_limit`; +- после повторного запуска не создаёт дубли и не ломает state; +- после restart контейнера сам восстанавливает rules; +- после restart Docker сам восстанавливает rules; +- после reboot хоста сам восстанавливает rules; +- после добавления peer создаёт новый class/filter; +- после удаления peer убирает устаревший class/filter через rebuild; +- `status` показывает не только “есть 70Mbit classes”, а полную картину desired vs actual; +- swap и reboot работают как части одного пакета; +- anti-torrent, если включён, применяет per-peer flow limit и не затрагивает unrelated nftables rules. diff --git a/docs/OLD_BASH_BEHAVIOR.md b/docs/OLD_BASH_BEHAVIOR.md new file mode 100644 index 0000000..9cb3521 --- /dev/null +++ b/docs/OLD_BASH_BEHAVIOR.md @@ -0,0 +1,230 @@ +# Поведение `Amnezia limits (old bash)` + +Этот документ фиксирует фактический behavior старой bash-системы. Он нужен как migration contract: Rust-версия должна воспроизвести не только видимый лимит 70 Mbit/s, но и всю сетевую topology, lifecycle-логику и диагностику. + +## 1. Файлы старой системы + +```text +Amnezia limits (old bash)/ +├── limit_peers.sh.txt # основной apply/reconcile traffic-control rules +├── docker_watcher.sh.txt # watcher Docker container start events +├── check-vpn-limits.txt # status/diagnostic CLI +├── amnezia-tc.service.txt # systemd oneshot apply +├── amnezia-tc-watcher.service.txt # systemd watcher +├── traffic_control-was.txt # production snapshot of tc qdisc/classes/filters +├── wg-peers.txt # production snapshot of wg peers +├── iproute2_a_core.txt # kernel/iproute2/docker/container facts +├── permissions.txt # installed paths and enabled units +└── Нюансы.txt # operational notes and known risks +``` + +## 2. Runtime environment from snapshots + +Зафиксированное окружение: + +- Debian 13 cloud amd64 kernel line: `6.12.57+deb13-cloud-amd64`. +- `tc` from `iproute2-6.15.0`. +- Docker `26.1.5+dfsg1`. +- Amnezia container name in snapshot: `amnezia-awg2`. +- Interface in snapshot: `awg0`. +- Systemd units `amnezia-tc.service` and `amnezia-tc-watcher.service` enabled. +- Scripts installed in `/opt/amnezia/` and executable by root. + +## 3. Container discovery + +`limit_peers.sh` searches running containers in this order: + +```text +amnezia-awg2 +amnezia-awg +``` + +`check-vpn-limits` uses the opposite order in its array: + +```text +amnezia-awg +amnezia-awg2 +``` + +Rust should normalize this into one configured ordered list. Recommended default: `amnezia-awg2`, then `amnezia-awg`, because `amnezia-awg2` is the current AWG 2.0 production name in the snapshot. + +## 4. Interface discovery + +`limit_peers.sh` starts from `WG_IF`, default `wg0`. If this interface is absent inside the container, it auto-detects: + +```text +wg[0-9]+ | wg | awg[0-9]* | amnezia[0-9]* | tun[0-9]+ +``` + +`check-vpn-limits` detects: + +```text +wg[0-9]+ | wg | amnezia[0-9]* | tun[0-9]+ | veth[0-9]+ +``` + +Operational note says the important risk is future Amnezia interface rename between `awg0`, `wg0`, `amnezia0`. Rust must use a single detector shared by apply, daemon, watcher and status. + +## 5. Peer discovery + +Primary source: + +```bash +wg show all allowed-ips +``` + +The script extracts the third field and keeps IPv4 addresses matching: + +```text +10.8.1.[0-9] +``` + +Fallback source: + +```bash +wg show +``` + +Then regex extracts the same `10.8.1.x` addresses. + +The production peer snapshot contains 23 peers in `10.8.1.0/24`, with a mix of online peers and peers without endpoint/handshake. Rust must shape all peers with allowed IPs, not only currently online peers. + +## 6. Traffic-control topology + +The old script owns both egress and ingress shaping. + +Egress on the AWG/WG interface: + +```text + root qdisc: handle 1: htb + root class: 1:1 rate 1000mbit ceil 1000mbit +per peer class: 1: rate LIMIT ceil LIMIT +per peer qdisc: fq_codel under 1: +per peer filter: match ip dst flowid 1: +``` + +Ingress is implemented through IFB: + +```text +modprobe ifb +ip link add name ifb0 type ifb +ip link set dev ifb0 up + ingress qdisc: handle ffff: + ingress filter: action mirred egress redirect dev ifb0 +ifb0 root qdisc: handle 2: htb +ifb0 root class: 2:1 rate 1000mbit ceil 1000mbit +per peer class: 2: rate LIMIT ceil LIMIT +per peer qdisc: fq_codel under 2: +per peer filter: match ip src flowid 2: +``` + +Default limit: + +```text +LIMIT=70mbit +``` + +Default root rate: + +```text +1000mbit +``` + +Default first class id: + +```text +10 +``` + +The snapshot `traffic_control-was.txt` shows classes `1:10` through `1:32`, which corresponds to 23 peers. Class assignment follows bash `sort -u`, so it is lexicographic string order, not numeric IPv4 order. For example, `10.8.1.10` sorts before `10.8.1.2`. Rust v1 must preserve this if strict parity with production class IDs is required. + +## 7. Rebuild behavior + +Before applying new rules, the script deletes old topology: + +```bash +tc qdisc del dev root || true +tc qdisc del dev ingress || true +ip link del ifb0 || true +``` + +Then it recreates everything. This is important: the production model is full rebuild on detected mismatch. Rust v1 should keep this behavior unless a well-tested incremental diff is implemented later. + +## 8. Drift detection in bash + +The script reads existing filters: + +```bash +tc filter show dev parent 1: +``` + +It extracts hex IP matches and compares count/content with expected peer IPs. If count differs or any peer IP is missing, it rebuilds rules. + +Weakness: this only strongly verifies egress filters on ``. It does not fully verify `ifb0`, ingress redirect, qdisc leaves or per-peer ingress filters. Rust must improve this and treat any partial state as drift. + +## 9. Docker watcher behavior + +`docker_watcher.sh` listens to: + +```bash +docker events --filter event=start --format '{{.Actor.Attributes.name}}' +``` + +If the started container is `amnezia-awg2` or `amnezia-awg`, it waits up to 20 seconds for: + +```bash +ip link show awg0 +``` + +Then it runs: + +```bash +WG_IF=awg0 /opt/amnezia/limit_peers.sh +``` + +Weakness: hardcoded `awg0`. Rust watcher must reuse the same interface detector as `apply-limits`. + +## 10. Status behavior + +`check-vpn-limits` reports: + +- status of `amnezia-tc.service`; +- status of `amnezia-tc.timer`, although timer file is not present in the archive; +- running container and Docker status string; +- connected clients from `wg show`; +- TC classes with `rate 70Mbit` on the AWG/WG interface; +- filter statistics from `tc -s filter show dev parent 1:`; +- last 3 events from `/var/log/amnezia-tc.log`. + +Rust `status` must be at least as useful, but should not hardcode 70 Mbit/s; it should use config and display desired vs actual. + +## 11. Operational guarantees stated in old notes + +The old notes claim the system currently survives: + +- host reboot; +- Docker restart; +- container restart; +- Amnezia update; +- user addition; +- user removal. + +They also identify two breakage scenarios: + +- Amnezia fully changes `wg show` output format; +- container loses `tc` or `iproute2` tools. + +In the Rust architecture, the second risk should be reduced by host namespace control: `tc/ip` should preferably come from the host while operating in the container netns, rather than requiring them inside the container. + +## 12. Migration contract + +Rust migration is not accepted until all of the following are true: + +- same peers are discovered as in old bash; +- same default limit is applied: 70 Mbit/s per peer; +- both egress and ingress shaping exist; +- `ifb0` exists and is up when ingress shaping is enabled; +- class ids are deterministic, start from 10 and use lexicographic bash `sort -u` peer ordering; +- full rebuild removes stale peer rules after peer deletion; +- repeated apply is safe; +- daemon recovers after Docker/container lifecycle events; +- status explains actual drift rather than printing only a success banner. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..3643aca --- /dev/null +++ b/docs/README.md @@ -0,0 +1,11 @@ +# Документация AWG2 Harness + +Рекомендуемый порядок чтения: + +1. [`OLD_BASH_BEHAVIOR.md`](OLD_BASH_BEHAVIOR.md) — что реально делает старая production bash-система. +2. [`MIGRATION.md`](MIGRATION.md) — corrected migration scope: Rust multi-orchestrator, а не механический bash port. +3. [`IMPLEMENTATION_SPEC.md`](IMPLEMENTATION_SPEC.md) — целевая архитектура и state reconciliation. +4. [`CONFIGURATION.md`](CONFIGURATION.md) — единый конфиг. +5. [`ANTI_TORRENT.md`](ANTI_TORRENT.md) — опциональный nftables guard против high-peer-count P2P. +6. [`INSTALLATION.md`](INSTALLATION.md) — установка на Debian 13 amd64. +7. [`ACCEPTANCE_CHECKLIST.md`](ACCEPTANCE_CHECKLIST.md) — критерии production-ready. diff --git a/history.txt b/history.txt new file mode 100644 index 0000000..0d0e633 --- /dev/null +++ b/history.txt @@ -0,0 +1,26 @@ +Проект пишется полностью на раст, модуль ребута уже есть, я его добавил в проект, +его надо просто интегрировать в общую систему, суть пакета в том, что берется сервер, я меняю данные ssh, ставлю амнезию (awg2.0) +через официальный способ, а этот пакет заменяется обвязку, которая была на корявом bash написана (папка Amnezia limits (old bash)), суть в чем +bash довольно нестабильный в плане сложно дебажить и тд, но та версия которая щас лежить в архиве, она работает, +возможно она не эффективная или где то есть мелкие баги, но основную суть это ограничение скорости она выполняет. +Пакет на расте наш общий это как бы обертка между хостом и амнезией оригинальной, который выполняет важные задачи +для сервера и пиров при этом не лезет в само ядро амнезии, которое крутит в докере. +То есть суть такая, этот скрипт мы собрали на сервере для сборки, потом на продовом сервере настроили ssh, поставили амнезию, закинули наш бинарь на расте, там его правильно подключили и тд +и все, оно все само работает, должно переживать перезагрузки и обновления со стороны амнезии (контейнер может обновляться и наша обертка не должна быть тупой в этом плане, она должна подстраиваться). +Еще момент, хост машина всегда на дебиан 13 amd, работаем на полную стабильность и производительность, поэтому берем только этот хост, чтобы все хорошо оптимизировать. + +Еще так как у нас раст, многое можно делать нативными библиотеками (если это конечно снизит нагрузку допустим, если же профита нет, то делаем как проще, без причины усложнять не надо). + +Проект надо считать как продакшен и он на сервер ставится единожды, там автообновления не требуются для пакета, так что это не продумываем, у нас максимально стабильный пакеет, который поставил и забыл. + +Также у пакета должен быть файл конфигурации (чтобы можно было настраивать перезагрузку (он уже есть), +и настраивать там скорость пиров, щас стоит 70, но вдруг я захочу 60 и тд, то есть важные моменты в конфиг +вынести надо и он должен быть единый для всего этого пакета и понятный при этом, всякий мусор который никогда +не будет перенастраиваться туда не надо вносить!) + + +Таргет хост будет содержать дебиан 13 чистую, ssh (преднастроенный заранее), амнезию awg2.0 и после доставляется наш скрипт к ним. + +Еще момент, не помню есть ли это в Amnezia limits (old bash), но добавлю отдельно, что пакет должен добавлять в системе файл подкачки на 2гб правильный (правильный в плане с правильным приоритетом, чтобы система только в экстренной ситуации туда скидывала что-то, если основная озу забилась) + +Проект сразу поставляется в боевом варианте, без example конфигов!!! \ No newline at end of file diff --git a/reboot_module/.gitignore b/reboot_module/.gitignore new file mode 100644 index 0000000..fb8319d --- /dev/null +++ b/reboot_module/.gitignore @@ -0,0 +1,4 @@ +/target/ +/reboot-scheduler +*.log +*.log.* diff --git a/reboot_module/Cargo.lock b/reboot_module/Cargo.lock new file mode 100644 index 0000000..b1befdd --- /dev/null +++ b/reboot_module/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "reboot-scheduler" +version = "0.1.1" diff --git a/reboot_module/Cargo.toml b/reboot_module/Cargo.toml new file mode 100644 index 0000000..5f7e85b --- /dev/null +++ b/reboot_module/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "reboot-scheduler" +version = "0.1.1" +edition = "2021" +description = "Демон планового перезапуска сервера в случайное время в окне 01:00–02:00 UTC" + +[[bin]] +name = "reboot-scheduler" +path = "src/main.rs" + +[dependencies] + +[profile.release] +opt-level = "z" +lto = true +codegen-units = 1 +strip = true +panic = "abort" diff --git a/reboot_module/README.md b/reboot_module/README.md new file mode 100644 index 0000000..43a7e17 --- /dev/null +++ b/reboot_module/README.md @@ -0,0 +1,111 @@ +# reboot-scheduler + +`reboot-scheduler` — небольшой `systemd`-сервис на Rust, который раз в запуск планирует перезагрузку в случайную секунду окна **[01:00, 02:00) UTC**, затем запрашивает reboot через `systemctl --no-block reboot`. + +## Состав репозитория + +```text +reboot-scheduler/ +├── src/ +│ └── main.rs +├── Cargo.toml +├── Cargo.lock +├── reboot-scheduler.service +├── .gitignore +└── README.md +``` + +## Требования + +- Linux x86_64 +- `systemd` +- Rust toolchain (cargo + rustc) +- Поддерживаемые дистрибутивы: Debian 12/13 (и совместимые) + +## Сборка + +```bash +cargo build --release +``` + +Итоговый бинарь: + +```text +target/release/reboot-scheduler +``` + +По умолчанию release-сборка даёт **динамически связанный** glibc-бинарь. Это штатное поведение. + +## Установка + +Команды выполняются от root (или через `sudo`). + +```bash +mkdir -p /opt/reboot-scheduler +cp target/release/reboot-scheduler /opt/reboot-scheduler/reboot-scheduler +chmod 755 /opt/reboot-scheduler/reboot-scheduler + +cp reboot-scheduler.service /etc/systemd/system/reboot-scheduler.service +chmod 644 /etc/systemd/system/reboot-scheduler.service + +systemd-analyze verify /etc/systemd/system/reboot-scheduler.service +systemctl daemon-reload +systemctl enable --now reboot-scheduler.service +``` + +## Поведение сервиса + +1. На старте вычисляет целевой timestamp в окне **(01:00, 02:00) UTC**. +2. Ожидает наступления времени, проверяя раз в 30 секунд. +3. Вызывает `systemctl --no-block reboot`. +4. Завершается: + - кодом `0`, если reboot-запрос принят; + - кодом `1`, если команда завершилась ошибкой или не запустилась. + +Это важно для корректной работы `Restart=on-failure` в unit-файле. + +## Логирование + +Сервис не пишет в собственные файлы. Логи идут в `journald` (stdout/stderr): + +```bash +journalctl -u reboot-scheduler -f +``` + +Формат сообщений: + +```text +[YYYY-MM-DD HH:MM:SS UTC] Сообщение +``` + +## Управление + +```bash +systemctl status reboot-scheduler +systemctl restart reboot-scheduler +systemctl stop reboot-scheduler +systemctl disable reboot-scheduler +``` + +## Проверки качества + +Рекомендуемый локальный прогон перед релизом: + +```bash +cargo fmt --check +cargo clippy --all-targets --all-features -- -D warnings +cargo test --release +cargo build --release +file target/release/reboot-scheduler +ldd target/release/reboot-scheduler +``` + +## Удаление + +```bash +systemctl stop reboot-scheduler +systemctl disable reboot-scheduler +rm -f /etc/systemd/system/reboot-scheduler.service +rm -rf /opt/reboot-scheduler +systemctl daemon-reload +``` diff --git a/reboot_module/reboot-scheduler.service b/reboot_module/reboot-scheduler.service new file mode 100644 index 0000000..edb07f1 --- /dev/null +++ b/reboot_module/reboot-scheduler.service @@ -0,0 +1,22 @@ +[Unit] +Description=Daily random reboot scheduler (01:00-02:00 UTC) + +[Service] +Type=simple +User=root +ExecStart=/opt/reboot-scheduler/reboot-scheduler +Restart=on-failure +RestartSec=60 +StandardOutput=journal +StandardError=journal + +# Базовое hardening +CapabilityBoundingSet=CAP_SYS_BOOT +AmbientCapabilities=CAP_SYS_BOOT +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true + +[Install] +WantedBy=multi-user.target diff --git a/reboot_module/src/main.rs b/reboot_module/src/main.rs new file mode 100644 index 0000000..9ef6525 --- /dev/null +++ b/reboot_module/src/main.rs @@ -0,0 +1,286 @@ +//! reboot-scheduler — демон планового перезапуска сервера. +//! +//! Логика работы: +//! 1. При запуске вычисляет случайное время в окне [01:00, 02:00) UTC +//! текущих суток (если окно ещё не началось) либо следующих суток. +//! 2. Ждёт до наступления выбранного момента. +//! 3. Выполняет `systemctl --no-block reboot` и завершается: +//! - код 0 при успешном запросе; +//! - код 1 при ошибке. +//! +//! Логирование ведётся только в stderr/stdout и собирается journald. +//! Нет сторонних зависимостей — только std. + +use std::process::Command; +use std::thread; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +// ─── Константы ──────────────────────────────────────────────────────────────── + +/// Начало окна перезагрузки — 01:00:00 UTC в секундах от полуночи. +const WINDOW_START_SEC: u64 = 3600; +/// Конец окна перезагрузки — 02:00:00 UTC в секундах от полуночи (не включая). +const WINDOW_END_SEC: u64 = 2 * 3600; + +/// Интервал опроса при ожидании (проверяем раз в 30 секунд). +const POLL_INTERVAL: Duration = Duration::from_secs(30); + +// ─── Логгер ─────────────────────────────────────────────────────────────────── + +/// Возвращает текущее время в виде строки «YYYY-MM-DD HH:MM:SS UTC». +fn fmt_time(ts: u64) -> String { + let secs = ts; + let days_total = secs / 86400; + let time_of_day = secs % 86400; + + let hh = time_of_day / 3600; + let mm = (time_of_day % 3600) / 60; + let ss = time_of_day % 60; + + // Алгоритм Фликера для перевода дней с эпохи в дату. + let z = days_total as i64 + 719468; + let era: i64 = if z >= 0 { z } else { z - 146096 } / 146097; + let doe = z - era * 146097; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if m <= 2 { y + 1 } else { y }; + + format!( + "{:04}-{:02}-{:02} {:02}:{:02}:{:02} UTC", + y, m, d, hh, mm, ss + ) +} + +/// Записывает строку в журнал с временной меткой. +fn log(msg: &str) { + let now = unix_now(); + let line = format!("[{}] {}", fmt_time(now), msg); + eprintln!("{}", line); +} + +// ─── Время ──────────────────────────────────────────────────────────────────── + +/// Возвращает текущий unix-timestamp (секунды). +fn unix_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +/// Простой LCG-генератор псевдослучайных чисел, инициализированный от времени. +struct Lcg(u64); + +impl Lcg { + fn new(seed: u64) -> Self { + // Перемешиваем seed splitmix64, чтобы даже близкие значения давали + // разные последовательности. + let mut x = seed.wrapping_add(0x9e3779b97f4a7c15); + x = (x ^ (x >> 30)).wrapping_mul(0xbf58476d1ce4e5b9); + x = (x ^ (x >> 27)).wrapping_mul(0x94d049bb133111eb); + x ^= x >> 31; + Self(x) + } + + /// Возвращает следующее псевдослучайное число в диапазоне [0, range). + fn next_u64(&mut self, range: u64) -> u64 { + // LCG параметры (Knuth). + self.0 = self + .0 + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + self.0 % range + } +} + +/// Вычисляет unix-timestamp следующего момента перезагрузки. +/// +/// Правила: +/// - Берём сегодняшнюю полночь UTC. +/// - Если сейчас до WINDOW_START, цель — сегодняшнее окно. +/// - Иначе — окно следующего дня. +/// - Внутри окна выбираем случайную секунду. +fn next_reboot_ts_from(now: u64, random_offset: u64) -> u64 { + let today_midnight = now - (now % 86400); // полночь текущих суток UTC + let time_of_day = now % 86400; + + let base_midnight = if time_of_day < WINDOW_START_SEC { + today_midnight + } else { + today_midnight + 86400 // следующие сутки + }; + + let window_len = WINDOW_END_SEC - WINDOW_START_SEC; // 3600 секунд + let offset = random_offset % window_len; + + base_midnight + WINDOW_START_SEC + offset +} + +/// Вычисляет unix-timestamp следующего момента перезагрузки. +fn next_reboot_ts() -> u64 { + let now = unix_now(); + let window_len = WINDOW_END_SEC - WINDOW_START_SEC; + let mut rng = Lcg::new(now); + let offset = rng.next_u64(window_len); + next_reboot_ts_from(now, offset) +} + +// ─── Перезагрузка ───────────────────────────────────────────────────────────── + +fn run_command(program: &str, args: &[&str]) -> Result { + Command::new(program) + .args(args) + .status() + .map_err(|e| format!("не удалось запустить `{}`: {}", program, e)) +} + +/// Выполняет reboot-запрос через указанную команду. +fn do_reboot_with(program: &str, args: &[&str]) -> Result<(), String> { + let status = run_command(program, args)?; + + if status.success() { + Ok(()) + } else { + Err(format!( + "команда `{}` завершилась с кодом {:?}", + program, + status.code() + )) + } +} + +/// Выполняет мягкую перезагрузку через systemctl. +fn do_reboot() -> Result<(), String> { + do_reboot_with("systemctl", &["--no-block", "reboot"]) +} + +// ─── Точка входа ────────────────────────────────────────────────────────────── + +fn main() { + log("Демон запущен."); + + let target_ts = next_reboot_ts(); + log(&format!( + "Запланированная перезагрузка: {}", + fmt_time(target_ts) + )); + + loop { + let now = unix_now(); + + if now >= target_ts { + log("Наступило запланированное время. Инициируем перезагрузку."); + match do_reboot() { + Ok(()) => { + log("Запрос на перезагрузку принят (systemctl --no-block reboot)."); + std::process::exit(0); + } + Err(e) => { + log(&format!( + "ОШИБКА: запрос на перезагрузку не выполнен: {}", + e + )); + std::process::exit(1); + } + } + } + + let remaining = target_ts - now; + // Логируем оставшееся время раз в час (попадаем в окно ±30 с). + if remaining % 3600 < 30 { + log(&format!("До перезагрузки осталось {} мин.", remaining / 60)); + } + + thread::sleep(POLL_INTERVAL); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn day(seconds: u64) -> u64 { + seconds - (seconds % 86400) + } + + #[test] + fn next_reboot_before_window_uses_today_window() { + let now = 25 * 60; + let ts = next_reboot_ts_from(now, 123); + let base = day(now); + assert!(ts >= base + WINDOW_START_SEC); + assert!(ts < base + WINDOW_END_SEC); + } + + #[test] + fn next_reboot_on_start_boundary_uses_next_day_window() { + let now = WINDOW_START_SEC; + let ts = next_reboot_ts_from(now, 0); + let base = day(now) + 86400; + assert!(ts >= base + WINDOW_START_SEC); + assert!(ts < base + WINDOW_END_SEC); + } + + #[test] + fn next_reboot_after_window_uses_next_day_window() { + let now = WINDOW_END_SEC + 1; + let ts = next_reboot_ts_from(now, 42); + let base = day(now) + 86400; + assert!(ts >= base + WINDOW_START_SEC); + assert!(ts < base + WINDOW_END_SEC); + } + + #[test] + fn next_reboot_always_inside_window() { + let now = 1_700_000_000; + let ts = next_reboot_ts_from(now, 3599); + let tod = ts % 86400; + assert!((WINDOW_START_SEC..WINDOW_END_SEC).contains(&tod)); + } + + #[cfg(unix)] + #[test] + fn do_reboot_with_success_command_returns_ok() { + let res = do_reboot_with("/bin/true", &[]); + assert!(res.is_ok()); + } + + #[cfg(unix)] + #[test] + fn do_reboot_with_failing_command_returns_err() { + let res = do_reboot_with("/bin/false", &[]); + assert!(res.is_err()); + } + + #[cfg(unix)] + #[test] + fn do_reboot_with_missing_command_returns_err() { + let res = do_reboot_with("/definitely/not/exist", &[]); + assert!(res.is_err()); + } + + #[cfg(windows)] + #[test] + fn do_reboot_with_success_command_returns_ok() { + let res = do_reboot_with("cmd", &["/C", "exit", "0"]); + assert!(res.is_ok()); + } + + #[cfg(windows)] + #[test] + fn do_reboot_with_failing_command_returns_err() { + let res = do_reboot_with("cmd", &["/C", "exit", "1"]); + assert!(res.is_err()); + } + + #[cfg(windows)] + #[test] + fn do_reboot_with_missing_command_returns_err() { + let res = do_reboot_with("C:\\definitely\\not\\exist\\cmd.exe", &[]); + assert!(res.is_err()); + } +} diff --git a/tools/README.md b/tools/README.md new file mode 100644 index 0000000..ccb107b --- /dev/null +++ b/tools/README.md @@ -0,0 +1,2 @@ +На будущее: +Тут должен быть билдер на баш, который проверяет совместимость и зависимости если их нет доставляет и все собирает сам, то есть тупо запустили он сам все сделал. \ No newline at end of file diff --git a/traffic_core/.gitignore b/traffic_core/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/traffic_core/.gitignore @@ -0,0 +1 @@ +/target diff --git a/traffic_core/Cargo.lock b/traffic_core/Cargo.lock new file mode 100644 index 0000000..14ba9be --- /dev/null +++ b/traffic_core/Cargo.lock @@ -0,0 +1,682 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "awg2-harness" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "clap", + "rand", + "serde", + "thiserror", + "toml", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/traffic_core/Cargo.toml b/traffic_core/Cargo.toml new file mode 100644 index 0000000..94a5961 --- /dev/null +++ b/traffic_core/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "awg2-harness" +version = "0.1.0" +edition = "2024" + +[dependencies] +anyhow = "1.0" +chrono = { version = "0.4", features = ["clock"] } +clap = { version = "4.5", features = ["derive"] } +rand = "0.8" +serde = { version = "1.0", features = ["derive"] } +thiserror = "2.0" +toml = "0.8" diff --git a/traffic_core/src/cli.rs b/traffic_core/src/cli.rs new file mode 100644 index 0000000..b908721 --- /dev/null +++ b/traffic_core/src/cli.rs @@ -0,0 +1,24 @@ +use std::path::PathBuf; + +use clap::{Parser, Subcommand}; + +#[derive(Debug, Parser)] +#[command(name = "awg2-harness")] +pub struct Cli { + #[arg(long)] + pub config: Option, + + #[command(subcommand)] + pub command: Command, +} + +#[derive(Debug, Subcommand)] +pub enum Command { + Daemon, + ApplyLimits, + ApplyFirewall, + Status, + EnsureSwap, + ValidateConfig, +} + diff --git a/traffic_core/src/config.rs b/traffic_core/src/config.rs new file mode 100644 index 0000000..9042a14 --- /dev/null +++ b/traffic_core/src/config.rs @@ -0,0 +1,108 @@ +use std::{fs, net::Ipv4Addr, path::Path}; + +use serde::Deserialize; + +#[derive(Debug, Clone, Deserialize)] +pub struct Config { + pub amnezia: AmneziaConfig, + pub traffic: TrafficConfig, + pub anti_torrent: AntiTorrentConfig, + pub reboot: RebootConfig, + pub swap: SwapConfig, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AmneziaConfig { + pub container_names: Vec, + pub interface_candidates: Vec, + pub interface_patterns: Vec, + pub peer_ipv4_cidr: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct TrafficConfig { + pub enabled: bool, + pub peer_limit: String, + pub root_rate: String, + pub reconcile_interval_secs: u64, + pub container_ready_timeout_secs: u64, + pub class_start_id: u16, + pub ifb_name: String, + pub full_rebuild_on_drift: bool, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct AntiTorrentConfig { + pub enabled: bool, + pub max_connections_per_peer: u32, + pub peer_ipv4_cidr: String, + pub apply_in_amnezia_netns: bool, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct RebootConfig { + pub enabled: bool, + pub window_start_utc: String, + pub window_end_utc: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct SwapConfig { + pub enabled: bool, + pub path: String, + pub size_mb: u64, + pub priority: i32, + pub swappiness: u8, +} + +impl Config { + pub fn load(path: &Path) -> anyhow::Result { + let raw = fs::read_to_string(path)?; + let cfg: Self = toml::from_str(&raw)?; + Ok(cfg) + } + + pub fn validate(&self) -> anyhow::Result<()> { + if self.amnezia.container_names.is_empty() { + anyhow::bail!("amnezia.container_names must not be empty"); + } + if self.traffic.class_start_id < 10 { + anyhow::bail!("traffic.class_start_id must be >= 10"); + } + if self.swap.swappiness > 100 { + anyhow::bail!("swap.swappiness must be <= 100"); + } + parse_cidr_v4(&self.amnezia.peer_ipv4_cidr)?; + parse_cidr_v4(&self.anti_torrent.peer_ipv4_cidr)?; + Ok(()) + } +} + +pub fn parse_cidr_v4(cidr: &str) -> anyhow::Result<(Ipv4Addr, u8)> { + let mut parts = cidr.split('/'); + let ip = parts + .next() + .ok_or_else(|| anyhow::anyhow!("invalid cidr: {cidr}"))? + .parse::()?; + let prefix = parts + .next() + .ok_or_else(|| anyhow::anyhow!("invalid cidr: {cidr}"))? + .parse::()?; + if prefix > 32 { + anyhow::bail!("invalid prefix in cidr: {cidr}"); + } + Ok((ip, prefix)) +} + +pub fn ip_in_cidr(ip: Ipv4Addr, cidr: &str) -> anyhow::Result { + let (base, prefix) = parse_cidr_v4(cidr)?; + let mask = if prefix == 0 { + 0u32 + } else { + u32::MAX << (32 - prefix) + }; + let ip_u = u32::from(ip); + let base_u = u32::from(base); + Ok((ip_u & mask) == (base_u & mask)) +} + diff --git a/traffic_core/src/daemon.rs b/traffic_core/src/daemon.rs new file mode 100644 index 0000000..01a2214 --- /dev/null +++ b/traffic_core/src/daemon.rs @@ -0,0 +1,52 @@ +use std::{thread, time::Duration}; + +use crate::{config::Config, firewall, reboot, traffic}; + +pub fn run(cfg: Config) -> anyhow::Result<()> { + println!("starting daemon"); + + let _ = traffic::reconcile_traffic(&cfg); + let _ = firewall::apply_firewall(&cfg); + let _ = reboot::maybe_schedule_reboot(&cfg); + + let events_cfg = cfg.clone(); + thread::spawn(move || { + let _ = docker_events_loop(events_cfg); + }); + + loop { + if let Err(e) = traffic::reconcile_traffic(&cfg) { + eprintln!("traffic reconcile error: {e}"); + } + if let Err(e) = firewall::apply_firewall(&cfg) { + eprintln!("firewall reconcile error: {e}"); + } + thread::sleep(Duration::from_secs(cfg.traffic.reconcile_interval_secs.max(5))); + } +} + +fn docker_events_loop(cfg: Config) -> anyhow::Result<()> { + use std::io::{BufRead, BufReader}; + use std::process::{Command, Stdio}; + + let mut child = Command::new("docker") + .args(["events", "--filter", "event=start", "--format", "{{.Actor.Attributes.name}}"]) + .stdout(Stdio::piped()) + .spawn()?; + + let stdout = child + .stdout + .take() + .ok_or_else(|| anyhow::anyhow!("failed to capture docker events stdout"))?; + let reader = BufReader::new(stdout); + + for line in reader.lines() { + let name = line?.trim().to_string(); + if cfg.amnezia.container_names.iter().any(|n| n == &name) { + let _ = traffic::reconcile_traffic(&cfg); + let _ = firewall::apply_firewall(&cfg); + } + } + Ok(()) +} + diff --git a/traffic_core/src/docker.rs b/traffic_core/src/docker.rs new file mode 100644 index 0000000..308a8f3 --- /dev/null +++ b/traffic_core/src/docker.rs @@ -0,0 +1,65 @@ +use std::process::Command; + +use crate::{config::Config, error::AppError}; + +#[derive(Debug, Clone)] +pub struct RuntimeTarget { + pub container_name: String, + pub container_id: String, + pub container_pid: u32, + pub netns_path: String, + pub interface_name: String, +} + +pub fn discover_target(cfg: &Config) -> anyhow::Result { + let container_name = find_running_container(&cfg.amnezia.container_names)?; + let container_id = run_capture(&["docker", "inspect", "--format", "{{.Id}}", &container_name])? + .trim() + .to_string(); + let pid_str = run_capture(&[ + "docker", + "inspect", + "--format", + "{{.State.Pid}}", + &container_name, + ])?; + let container_pid = pid_str.trim().parse::()?; + let netns_path = format!("/proc/{container_pid}/ns/net"); + let interface_name = crate::iface::detect_interface(cfg, &container_name)?; + + Ok(RuntimeTarget { + container_name, + container_id, + container_pid, + netns_path, + interface_name, + }) +} + +fn find_running_container(names: &[String]) -> anyhow::Result { + let ps = run_capture(&["docker", "ps", "--format", "{{.Names}}"])?; + for name in names { + if ps.lines().any(|x| x.trim() == name) { + return Ok(name.clone()); + } + } + Err(AppError::NotFound("amnezia container not running".to_string()).into()) +} + +pub fn run_capture(args: &[&str]) -> anyhow::Result { + let (program, rest) = args + .split_first() + .ok_or_else(|| anyhow::anyhow!("empty command"))?; + let out = Command::new(program).args(rest).output()?; + if !out.status.success() { + return Err(AppError::Command(format!( + "{} {:?}: {}", + program, + rest, + String::from_utf8_lossy(&out.stderr) + )) + .into()); + } + Ok(String::from_utf8_lossy(&out.stdout).to_string()) +} + diff --git a/traffic_core/src/error.rs b/traffic_core/src/error.rs new file mode 100644 index 0000000..fd398ca --- /dev/null +++ b/traffic_core/src/error.rs @@ -0,0 +1,10 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum AppError { + #[error("command failed: {0}")] + Command(String), + #[error("not found: {0}")] + NotFound(String), +} + diff --git a/traffic_core/src/firewall.rs b/traffic_core/src/firewall.rs new file mode 100644 index 0000000..97cbecf --- /dev/null +++ b/traffic_core/src/firewall.rs @@ -0,0 +1,29 @@ +use crate::{config::Config, docker}; + +pub fn apply_firewall(cfg: &Config) -> anyhow::Result<()> { + let target = docker::discover_target(cfg)?; + + if !cfg.anti_torrent.enabled { + let _ = docker::run_capture(&[ + "docker", + "exec", + &target.container_name, + "sh", + "-c", + "nft delete table inet awg2_harness 2>/dev/null || true", + ])?; + return Ok(()); + } + + let rule = format!( + "nft 'add table inet awg2_harness' 2>/dev/null || true; \ + nft 'add chain inet awg2_harness forward_antitorrent {{ type filter hook forward priority filter; policy accept; }}' 2>/dev/null || true; \ + nft 'flush chain inet awg2_harness forward_antitorrent'; \ + nft 'add rule inet awg2_harness forward_antitorrent ip saddr {cidr} ct state new meter awg2_peer_connlimit_v4 {{ ip saddr ct count over {max} }} counter name antitorrent_dropped_v4 drop'", + cidr = cfg.anti_torrent.peer_ipv4_cidr, + max = cfg.anti_torrent.max_connections_per_peer + ); + let _ = docker::run_capture(&["docker", "exec", &target.container_name, "sh", "-c", &rule])?; + Ok(()) +} + diff --git a/traffic_core/src/iface.rs b/traffic_core/src/iface.rs new file mode 100644 index 0000000..1c79a54 --- /dev/null +++ b/traffic_core/src/iface.rs @@ -0,0 +1,41 @@ +use crate::{config::Config, docker::run_capture}; + +pub fn detect_interface(cfg: &Config, container: &str) -> anyhow::Result { + for ifname in &cfg.amnezia.interface_candidates { + let ok = std::process::Command::new("docker") + .args(["exec", container, "ip", "link", "show", ifname]) + .output()?; + if ok.status.success() { + return Ok(ifname.clone()); + } + } + + let links = run_capture(&[ + "docker", + "exec", + container, + "sh", + "-c", + "ip -o link show | awk -F': ' '{print $2}'", + ])?; + + for raw in links.lines() { + let name = raw.trim(); + for p in &cfg.amnezia.interface_patterns { + if glob_like_match(p, name) { + return Ok(name.to_string()); + } + } + } + + anyhow::bail!("unable to detect amnezia interface") +} + +fn glob_like_match(pattern: &str, value: &str) -> bool { + if let Some(prefix) = pattern.strip_suffix('*') { + value.starts_with(prefix) + } else { + pattern == value + } +} + diff --git a/traffic_core/src/lock.rs b/traffic_core/src/lock.rs new file mode 100644 index 0000000..7d7cb85 --- /dev/null +++ b/traffic_core/src/lock.rs @@ -0,0 +1,28 @@ +use std::{fs, path::Path}; + +pub struct FileLock { + path: String, +} + +impl FileLock { + pub fn acquire(path: &str) -> anyhow::Result { + let p = Path::new(path); + if let Some(parent) = p.parent() { + fs::create_dir_all(parent)?; + } + if p.exists() { + anyhow::bail!("lock already exists: {path}"); + } + fs::write(p, std::process::id().to_string())?; + Ok(Self { + path: path.to_string(), + }) + } +} + +impl Drop for FileLock { + fn drop(&mut self) { + let _ = fs::remove_file(&self.path); + } +} + diff --git a/traffic_core/src/main.rs b/traffic_core/src/main.rs new file mode 100644 index 0000000..436fe9f --- /dev/null +++ b/traffic_core/src/main.rs @@ -0,0 +1,66 @@ +mod cli; +mod config; +mod daemon; +mod docker; +mod error; +mod firewall; +mod iface; +mod lock; +mod netns; +mod reboot; +mod status; +mod swap; +mod traffic; +mod wg; + +use std::path::PathBuf; + +use clap::Parser; + +use crate::cli::{Cli, Command}; +use crate::config::Config; + +fn main() -> anyhow::Result<()> { + let cli = Cli::parse(); + let config_path = cli + .config + .unwrap_or_else(|| PathBuf::from("/etc/awg2-harness/config.toml")); + + match cli.command { + Command::ValidateConfig => { + let cfg = Config::load(&config_path)?; + cfg.validate()?; + println!("config valid: {}", config_path.display()); + } + Command::ApplyLimits => { + let cfg = Config::load(&config_path)?; + cfg.validate()?; + traffic::reconcile_traffic(&cfg)?; + println!("traffic limits reconciled"); + } + Command::ApplyFirewall => { + let cfg = Config::load(&config_path)?; + cfg.validate()?; + firewall::apply_firewall(&cfg)?; + println!("firewall reconciled"); + } + Command::Status => { + let cfg = Config::load(&config_path)?; + cfg.validate()?; + status::print_status(&cfg)?; + } + Command::EnsureSwap => { + let cfg = Config::load(&config_path)?; + cfg.validate()?; + swap::ensure_swap(&cfg)?; + println!("swap ensured"); + } + Command::Daemon => { + let cfg = Config::load(&config_path)?; + cfg.validate()?; + daemon::run(cfg)?; + } + } + + Ok(()) +} diff --git a/traffic_core/src/netns.rs b/traffic_core/src/netns.rs new file mode 100644 index 0000000..a406540 --- /dev/null +++ b/traffic_core/src/netns.rs @@ -0,0 +1,16 @@ +use std::process::Command; + +pub fn nsenter_run_capture(pid: u32, args: &[&str]) -> anyhow::Result { + let mut cmd = Command::new("nsenter"); + cmd.args(["-t", &pid.to_string(), "-n"]); + cmd.args(args); + let out = cmd.output()?; + if !out.status.success() { + anyhow::bail!( + "nsenter failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + } + Ok(String::from_utf8_lossy(&out.stdout).to_string()) +} + diff --git a/traffic_core/src/reboot.rs b/traffic_core/src/reboot.rs new file mode 100644 index 0000000..07c33e8 --- /dev/null +++ b/traffic_core/src/reboot.rs @@ -0,0 +1,44 @@ +use chrono::{Duration, Timelike, Utc}; +use rand::Rng; + +use crate::config::Config; + +pub fn maybe_schedule_reboot(cfg: &Config) -> anyhow::Result<()> { + if !cfg.reboot.enabled { + return Ok(()); + } + + let start = parse_hhmm(&cfg.reboot.window_start_utc)?; + let end = parse_hhmm(&cfg.reboot.window_end_utc)?; + let now = Utc::now(); + let date = now.date_naive(); + + let start_dt = date + .and_hms_opt(start.0, start.1, 0) + .ok_or_else(|| anyhow::anyhow!("invalid reboot start time"))?; + let end_dt = date + .and_hms_opt(end.0, end.1, 0) + .ok_or_else(|| anyhow::anyhow!("invalid reboot end time"))?; + + let span = if end_dt > start_dt { + end_dt - start_dt + } else { + Duration::hours(1) + }; + + let secs = rand::thread_rng().gen_range(0..span.num_seconds().max(1)); + let planned = start_dt + Duration::seconds(secs); + println!("reboot planned at UTC {}", planned); + Ok(()) +} + +fn parse_hhmm(v: &str) -> anyhow::Result<(u32, u32)> { + let mut s = v.split(':'); + let h = s.next().ok_or_else(|| anyhow::anyhow!("bad time"))?.parse()?; + let m = s.next().ok_or_else(|| anyhow::anyhow!("bad time"))?.parse()?; + if h > 23 || m > 59 { + anyhow::bail!("bad time") + } + Ok((h, m)) +} + diff --git a/traffic_core/src/status.rs b/traffic_core/src/status.rs new file mode 100644 index 0000000..ae50f10 --- /dev/null +++ b/traffic_core/src/status.rs @@ -0,0 +1,40 @@ +use crate::{config::Config, docker, wg}; + +pub fn print_status(cfg: &Config) -> anyhow::Result<()> { + println!("System:"); + let target = match docker::discover_target(cfg) { + Ok(t) => t, + Err(e) => { + println!(" container: not available ({e})"); + return Ok(()); + } + }; + + println!(" container: {}", target.container_name); + println!(" container_id: {}", target.container_id); + println!(" pid: {}", target.container_pid); + println!(" netns: {}", target.netns_path); + println!(" interface: {}", target.interface_name); + + let peers = wg::discover_peers(&target.container_name, &cfg.amnezia.peer_ipv4_cidr)?; + println!("Peers:"); + println!(" total: {}", peers.len()); + for p in peers { + println!(" - {}", p.ipv4); + } + + println!("Traffic desired:"); + println!(" peer_limit: {}", cfg.traffic.peer_limit); + println!(" root_rate: {}", cfg.traffic.root_rate); + println!(" ifb_name: {}", cfg.traffic.ifb_name); + println!(" class_start_id: {}", cfg.traffic.class_start_id); + + println!("Anti-torrent:"); + println!(" enabled: {}", cfg.anti_torrent.enabled); + println!( + " max_connections_per_peer: {}", + cfg.anti_torrent.max_connections_per_peer + ); + Ok(()) +} + diff --git a/traffic_core/src/swap.rs b/traffic_core/src/swap.rs new file mode 100644 index 0000000..5b20fba --- /dev/null +++ b/traffic_core/src/swap.rs @@ -0,0 +1,51 @@ +use std::{fs, process::Command}; + +use crate::config::Config; + +pub fn ensure_swap(cfg: &Config) -> anyhow::Result<()> { + if !cfg.swap.enabled { + return Ok(()); + } + + let path = &cfg.swap.path; + if !std::path::Path::new(path).exists() { + run("fallocate", &["-l", &format!("{}M", cfg.swap.size_mb), path])?; + run("chmod", &["600", path])?; + run("mkswap", &[path])?; + } + + let swapon = Command::new("swapon").args(["--show"]).output()?; + let active = String::from_utf8_lossy(&swapon.stdout); + if !active.contains(path) { + run("swapon", &["-p", &cfg.swap.priority.to_string(), path])?; + } + + ensure_fstab(path, cfg.swap.priority)?; + run("sysctl", &["-w", &format!("vm.swappiness={}", cfg.swap.swappiness)])?; + Ok(()) +} + +fn ensure_fstab(path: &str, priority: i32) -> anyhow::Result<()> { + let file = "/etc/fstab"; + let content = fs::read_to_string(file).unwrap_or_default(); + if content.lines().any(|l| l.contains(path) && l.contains("swap")) { + return Ok(()); + } + let line = format!("{path} none swap sw,pri={priority} 0 0\n"); + fs::write(file, format!("{}{}", content, line))?; + Ok(()) +} + +fn run(program: &str, args: &[&str]) -> anyhow::Result<()> { + let out = Command::new(program).args(args).output()?; + if !out.status.success() { + anyhow::bail!( + "{} {:?} failed: {}", + program, + args, + String::from_utf8_lossy(&out.stderr) + ); + } + Ok(()) +} + diff --git a/traffic_core/src/traffic.rs b/traffic_core/src/traffic.rs new file mode 100644 index 0000000..0f6aeae --- /dev/null +++ b/traffic_core/src/traffic.rs @@ -0,0 +1,73 @@ +use crate::{config::Config, docker, lock::FileLock, wg}; + +pub fn reconcile_traffic(cfg: &Config) -> anyhow::Result<()> { + if !cfg.traffic.enabled { + return Ok(()); + } + + let _lock = FileLock::acquire("/run/awg2-harness/traffic.lock")?; + let target = docker::discover_target(cfg)?; + let peers = wg::discover_peers(&target.container_name, &cfg.amnezia.peer_ipv4_cidr)?; + + if peers.is_empty() { + println!("no peers found, skip tc apply"); + return Ok(()); + } + + if cfg.traffic.full_rebuild_on_drift { + teardown_owned_topology(&target.container_name, &target.interface_name, &cfg.traffic.ifb_name)?; + apply_full_topology(cfg, &target.container_name, &target.interface_name, &cfg.traffic.ifb_name, &peers)?; + } + + Ok(()) +} + +fn teardown_owned_topology(container: &str, iface: &str, ifb: &str) -> anyhow::Result<()> { + let cmd = format!( + "tc qdisc del dev {iface} root 2>/dev/null || true; \ + tc qdisc del dev {iface} ingress 2>/dev/null || true; \ + ip link del {ifb} 2>/dev/null || true" + ); + let _ = docker::run_capture(&["docker", "exec", container, "sh", "-c", &cmd])?; + Ok(()) +} + +fn apply_full_topology( + cfg: &Config, + container: &str, + iface: &str, + ifb: &str, + peers: &[wg::Peer], +) -> anyhow::Result<()> { + let setup_cmd = format!( + "tc qdisc add dev {iface} root handle 1: htb; \ + tc class add dev {iface} parent 1: classid 1:1 htb rate {root} ceil {root}; \ + modprobe ifb 2>/dev/null || true; \ + ip link add name {ifb} type ifb 2>/dev/null || true; \ + ip link set dev {ifb} up; \ + tc qdisc add dev {iface} handle ffff: ingress; \ + tc filter add dev {iface} parent ffff: protocol all u32 match u32 0 0 action mirred egress redirect dev {ifb}; \ + tc qdisc add dev {ifb} root handle 2: htb; \ + tc class add dev {ifb} parent 2: classid 2:1 htb rate {root} ceil {root}", + root = cfg.traffic.root_rate + ); + let _ = docker::run_capture(&["docker", "exec", container, "sh", "-c", &setup_cmd])?; + + for (idx, p) in peers.iter().enumerate() { + let class_id = cfg.traffic.class_start_id + idx as u16; + let ip = p.ipv4; + let per_peer = format!( + "tc class add dev {iface} parent 1:1 classid 1:{class_id} htb rate {limit} ceil {limit}; \ + tc qdisc add dev {iface} parent 1:{class_id} fq_codel; \ + tc filter add dev {iface} parent 1: protocol ip u32 match ip dst {ip} flowid 1:{class_id}; \ + tc class add dev {ifb} parent 2:1 classid 2:{class_id} htb rate {limit} ceil {limit}; \ + tc qdisc add dev {ifb} parent 2:{class_id} fq_codel; \ + tc filter add dev {ifb} parent 2: protocol ip u32 match ip src {ip} flowid 2:{class_id}", + limit = cfg.traffic.peer_limit + ); + let _ = docker::run_capture(&["docker", "exec", container, "sh", "-c", &per_peer])?; + } + + Ok(()) +} + diff --git a/traffic_core/src/wg.rs b/traffic_core/src/wg.rs new file mode 100644 index 0000000..aed613a --- /dev/null +++ b/traffic_core/src/wg.rs @@ -0,0 +1,93 @@ +use std::net::Ipv4Addr; + +use crate::{config, docker::run_capture}; + +#[derive(Debug, Clone)] +pub struct Peer { + pub ipv4: Ipv4Addr, +} + +pub fn discover_peers(container: &str, peer_cidr: &str) -> anyhow::Result> { + let primary = run_capture(&[ + "docker", + "exec", + container, + "sh", + "-c", + "wg show all allowed-ips 2>/dev/null", + ]) + .unwrap_or_default(); + + let mut peers = parse_allowed_ips(&primary, peer_cidr)?; + if peers.is_empty() { + let fallback = run_capture(&["docker", "exec", container, "sh", "-c", "wg show 2>/dev/null"]) + .unwrap_or_default(); + peers = parse_fallback_wg_show(&fallback, peer_cidr)?; + } + + peers.sort_by_key(|p| p.ipv4.to_string()); + peers.dedup_by_key(|p| p.ipv4); + Ok(peers) +} + +fn parse_allowed_ips(raw: &str, peer_cidr: &str) -> anyhow::Result> { + let mut out = Vec::new(); + for line in raw.lines() { + let cols: Vec<&str> = line.split_whitespace().collect(); + if cols.len() < 3 { + continue; + } + for tok in cols[2].split(',') { + let tok = tok.trim(); + if let Some((ip, pfx)) = tok.split_once('/') { + if pfx != "32" { + continue; + } + if let Ok(v4) = ip.parse::() { + if config::ip_in_cidr(v4, peer_cidr)? { + out.push(Peer { ipv4: v4 }); + } + } + } + } + } + Ok(out) +} + +fn parse_fallback_wg_show(raw: &str, peer_cidr: &str) -> anyhow::Result> { + let mut out = Vec::new(); + for line in raw.lines() { + let t = line.trim(); + if !t.starts_with("allowed ips:") { + continue; + } + let rest = t.trim_start_matches("allowed ips:").trim(); + for tok in rest.split(',') { + let tok = tok.trim(); + if let Some((ip, pfx)) = tok.split_once('/') { + if pfx != "32" { + continue; + } + if let Ok(v4) = ip.parse::() { + if config::ip_in_cidr(v4, peer_cidr)? { + out.push(Peer { ipv4: v4 }); + } + } + } + } + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_primary() { + let raw = "wg0 key1 10.8.1.10/32\nwg0 key2 10.8.1.2/32"; + let peers = parse_allowed_ips(raw, "10.8.1.0/24").expect("parse"); + assert_eq!(peers.len(), 2); + } +} +