init: wan-guard — fleet WAN IP → fail2ban whitelist sync

This commit is contained in:
ops-agent
2026-08-09 10:55:18 +03:00
commit 43437002ca
6 changed files with 265 additions and 0 deletions
+26
View File
@@ -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"
}
]
}
+4
View File
@@ -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
+27
View File
@@ -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)"
+41
View File
@@ -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"
+15
View File
@@ -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
+152
View File
@@ -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()