153 lines
5.4 KiB
Python
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()
|