commit 43437002caac1d8a4a6c83782ffa90b43d348ec1 Author: ops-agent Date: Sun Aug 9 10:55:18 2026 +0300 init: wan-guard — fleet WAN IP → fail2ban whitelist sync diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..a4dcfe8 --- /dev/null +++ b/config.example.json @@ -0,0 +1,26 @@ +{ + "token": "REPLACE_WITH_STRONG_SECRET", + + "static_ips": [ + "127.0.0.1", + "::1", + "10.0.0.0/8", + "192.168.1.0/24" + ], + + "targets": [ + { + "name": "pbx-tempelhoff", + "type": "ssh", + "host": "pbx1.tempelhoff.ru", + "user": "root", + "ssh_key": "/root/.ssh/id_ed25519", + "whitelist_path": "/etc/fail2ban/jail.d/wan-guard.conf" + }, + { + "name": "local-hub", + "type": "local", + "whitelist_path": "/etc/fail2ban/jail.d/wan-guard.conf" + } + ] +} diff --git a/env.example b/env.example new file mode 100644 index 0000000..1963fc4 --- /dev/null +++ b/env.example @@ -0,0 +1,4 @@ +WAN_GUARD_TOKEN=REPLACE_WITH_STRONG_SECRET +WAN_GUARD_CONFIG=/etc/wan-guard/config.json +WAN_GUARD_PORT=8099 +WAN_GUARD_LISTEN=0.0.0.0 diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..ec8a2e8 --- /dev/null +++ b/install.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Устанавливает wan-guard на хост (ATS-хаб или любой другой). +# Запускать от root. +set -euo pipefail + +INSTALL_DIR=/opt/wan-guard +CONFIG_DIR=/etc/wan-guard +SERVICE=/etc/systemd/system/wan-guard.service + +mkdir -p "$INSTALL_DIR" "$CONFIG_DIR" +cp wan_guard.py "$INSTALL_DIR/" +chmod +x "$INSTALL_DIR/wan_guard.py" + +if [ ! -f "$CONFIG_DIR/config.json" ]; then + cp config.example.json "$CONFIG_DIR/config.json" + echo "[!] Отредактируй $CONFIG_DIR/config.json (token, targets)" +fi + +if [ ! -f "$CONFIG_DIR/env" ]; then + cp env.example "$CONFIG_DIR/env" + echo "[!] Установи WAN_GUARD_TOKEN в $CONFIG_DIR/env" +fi + +cp wan-guard.service "$SERVICE" +systemctl daemon-reload +systemctl enable --now wan-guard +echo "[ok] wan-guard запущен: $(systemctl is-active wan-guard)" diff --git a/router_notify.sh b/router_notify.sh new file mode 100644 index 0000000..41257f7 --- /dev/null +++ b/router_notify.sh @@ -0,0 +1,41 @@ +#!/bin/sh +# Скрипт для Keenetic (Entware cron, каждые 2 мин). +# Определяет внешний WAN IP и сообщает wan-guard если он изменился. +# +# Установка: +# 1. Скопировать в /opt/etc/wan-notify.sh, chmod +x +# 2. В /opt/etc/cron.d/wan-notify: +# */2 * * * * root /opt/etc/wan-notify.sh +# 3. Задать переменные ниже или вынести в /opt/etc/wan-notify.env + +WAN_GUARD_URL="http://85.198.84.96:8099/notify" +WAN_GUARD_TOKEN="REPLACE_WITH_STRONG_SECRET" +ROUTER_ID="$(uname -n)" # или задать вручную: ROUTER_ID="vesovaya" +CACHE_FILE="/tmp/wan_guard_last_ip" +IFACE="${WAN_IFACE:-eth0}" # WAN-интерфейс: eth0, ppp0 и т.д. + +# Определяем внешний IP (пробуем несколько методов) +get_wan_ip() { + # Метод 1: через ifconfig.me (нужен интернет) + IP=$(curl -s --max-time 5 --interface "$IFACE" https://ifconfig.me 2>/dev/null) + [ -n "$IP" ] && echo "$IP" && return + # Метод 2: через ip route source + IP=$(ip route get 1.1.1.1 2>/dev/null | grep -oP 'src \K[\d.]+') + [ -n "$IP" ] && echo "$IP" && return +} + +CUR=$(get_wan_ip) +[ -z "$CUR" ] && exit 0 + +PREV=$(cat "$CACHE_FILE" 2>/dev/null) +[ "$CUR" = "$PREV" ] && exit 0 + +echo "$CUR" > "$CACHE_FILE" + +curl -s --max-time 10 -X POST "$WAN_GUARD_URL" \ + --data-urlencode "token=$WAN_GUARD_TOKEN" \ + --data-urlencode "router=$ROUTER_ID" \ + --data-urlencode "ip=$CUR" \ + -o /dev/null -w "%{http_code}" | grep -q "200" \ + && logger -t wan-guard "IP обновлён: $PREV → $CUR" \ + || logger -t wan-guard "Ошибка отправки IP: $CUR" diff --git a/wan-guard.service b/wan-guard.service new file mode 100644 index 0000000..2e74ad7 --- /dev/null +++ b/wan-guard.service @@ -0,0 +1,15 @@ +[Unit] +Description=wan-guard — fleet WAN IP → fail2ban whitelist sync +After=network.target + +[Service] +Type=simple +EnvironmentFile=/etc/wan-guard/env +ExecStart=/usr/bin/python3 /opt/wan-guard/wan_guard.py +Restart=on-failure +RestartSec=10 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/wan_guard.py b/wan_guard.py new file mode 100644 index 0000000..9d3f2a5 --- /dev/null +++ b/wan_guard.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +""" +wan-guard — webhook-приёмник WAN IP от роутеров. +Собирает актуальные внешние IP флотилии и обновляет fail2ban whitelist +на указанных целевых хостах (локально или по SSH). +""" + +import os, sys, json, hmac, hashlib, time, subprocess, threading, ipaddress +from http.server import HTTPServer, BaseHTTPRequestHandler +from urllib.parse import parse_qs +from pathlib import Path + +CFG_FILE = os.environ.get("WAN_GUARD_CONFIG", "/etc/wan-guard/config.json") +TOKEN = os.environ.get("WAN_GUARD_TOKEN", "") +LISTEN = os.environ.get("WAN_GUARD_LISTEN", "0.0.0.0") +PORT = int(os.environ.get("WAN_GUARD_PORT", "8099")) + +_lock = threading.Lock() +_ip_store = {} # {router_id: {"ip": str, "ts": float}} +_config = {} + + +def load_config(): + global _config + path = Path(CFG_FILE) + if not path.exists(): + print(f"[wan-guard] config not found: {CFG_FILE}", flush=True) + sys.exit(1) + with open(path) as f: + _config = json.load(f) + + +def validate_ip(ip: str) -> bool: + try: + addr = ipaddress.ip_address(ip) + return not addr.is_loopback and not addr.is_private + except ValueError: + return False + + +def build_whitelist() -> list[str]: + static = _config.get("static_ips", ["127.0.0.1", "::1", "10.0.0.0/8"]) + dynamic = [v["ip"] for v in _ip_store.values() if v.get("ip")] + return static + list(dict.fromkeys(dynamic)) # dedupe, preserve order + + +def write_local_whitelist(path: str, ips: list[str]): + content = "[DEFAULT]\nignoreip = " + " ".join(ips) + "\n" + Path(path).parent.mkdir(parents=True, exist_ok=True) + Path(path).write_text(content) + subprocess.run(["fail2ban-client", "reload"], check=False, + capture_output=True) + print(f"[wan-guard] local whitelist updated → {path}", flush=True) + + +def write_remote_whitelist(target: dict, ips: list[str]): + host = target["host"] + path = target.get("whitelist_path", "/etc/fail2ban/jail.d/wan-guard.conf") + key = target.get("ssh_key", "/root/.ssh/id_ed25519") + user = target.get("user", "root") + content = "[DEFAULT]\nignoreip = " + " ".join(ips) + "\n" + cmd = ( + f"cat > {path} << 'WGEOF'\n{content}WGEOF\n" + f"fail2ban-client reload" + ) + result = subprocess.run( + ["ssh", "-i", key, "-o", "StrictHostKeyChecking=no", + "-o", "ConnectTimeout=8", f"{user}@{host}", cmd], + capture_output=True, text=True + ) + status = "OK" if result.returncode == 0 else f"ERR({result.returncode})" + print(f"[wan-guard] remote {host} → {status}", flush=True) + if result.stderr: + print(f"[wan-guard] stderr: {result.stderr.strip()}", flush=True) + + +def push_to_targets(): + ips = build_whitelist() + for target in _config.get("targets", []): + if target.get("type") == "local": + path = target.get("whitelist_path", + "/etc/fail2ban/jail.d/wan-guard.conf") + write_local_whitelist(path, ips) + elif target.get("type") == "ssh": + threading.Thread(target=write_remote_whitelist, + args=(target, ips), daemon=True).start() + + +class Handler(BaseHTTPRequestHandler): + def log_message(self, fmt, *args): + pass # тихий лог, важное пишем сами + + def send(self, code: int, body: bytes = b""): + self.send_response(code) + self.send_header("Content-Type", "text/plain") + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + if self.path == "/status": + with _lock: + out = json.dumps({ + "routers": _ip_store, + "whitelist": build_whitelist() + }, indent=2).encode() + self.send(200, out) + else: + self.send(404, b"not found") + + def do_POST(self): + if self.path != "/notify": + self.send(404, b"not found"); return + + length = int(self.headers.get("Content-Length", 0)) + body = parse_qs(self.rfile.read(length).decode(errors="replace")) + + token = body.get("token", [""])[0] + expected = TOKEN or _config.get("token", "") + if not expected: + self.send(500, b"token not configured"); return + if not hmac.compare_digest(token, expected): + print(f"[wan-guard] 403 bad token from {self.client_address[0]}", + flush=True) + self.send(403, b"forbidden"); return + + router = body.get("router", ["unknown"])[0][:64] + ip = body.get("ip", [""])[0].strip() + + if not ip: + self.send(400, b"missing ip"); return + if not validate_ip(ip): + self.send(400, b"invalid or private ip"); return + + with _lock: + prev = _ip_store.get(router, {}).get("ip") + if ip == prev: + self.send(200, b"unchanged"); return + _ip_store[router] = {"ip": ip, "ts": time.time()} + print(f"[wan-guard] {router}: {prev or 'new'} → {ip}", flush=True) + push_to_targets() + + self.send(200, b"ok") + + +if __name__ == "__main__": + load_config() + if not (TOKEN or _config.get("token")): + print("[wan-guard] FATAL: set WAN_GUARD_TOKEN or token in config", + flush=True) + sys.exit(1) + print(f"[wan-guard] listening on {LISTEN}:{PORT}", flush=True) + HTTPServer((LISTEN, PORT), Handler).serve_forever()