Files

153 lines
5.4 KiB
Python

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