init: wan-guard — fleet WAN IP → fail2ban whitelist sync
This commit is contained in:
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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)"
|
||||
@@ -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"
|
||||
@@ -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
@@ -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()
|
||||
Reference in New Issue
Block a user