#!/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()