mirror of
https://codeberg.org/gigirassy/nixos-server-config
synced 2026-08-30 23:37:41 +00:00
Add nixos/etc/anubis-watchdog/watchdog.py
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import sqlite3
|
||||
import hashlib
|
||||
import logging
|
||||
import ipaddress
|
||||
import re
|
||||
|
||||
# --- CONFIG ---
|
||||
STATE_PATH = "/var/lib/anubis-watchdog/state.json"
|
||||
DB_PATH = "/var/lib/anubis-watchdog/reports.db"
|
||||
SLEEP_REFRESH = 8
|
||||
DUPLICATE_TTL_SECONDS = 24 * 3600
|
||||
REPORT_FLUSH_INTERVAL = 5 # seconds
|
||||
INMEM_COOLDOWN = 600 # 10 minutes per IP
|
||||
|
||||
DOCKER_BIN = os.environ.get("DOCKER_BIN", "/run/current-system/sw/bin/docker")
|
||||
ABUSEIPDB_KEY_FILE = os.environ.get("ABUSEIPDB_KEY_FILE", "/etc/anubis-watchdog/abuseipdb.key")
|
||||
CURL_BIN = os.environ.get("CURL_BIN", "/run/current-system/sw/bin/curl")
|
||||
|
||||
IP_RE = re.compile(r'\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b')
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||
|
||||
# --- DB / Directories ---
|
||||
def ensure_dirs():
|
||||
os.makedirs(os.path.dirname(STATE_PATH), exist_ok=True)
|
||||
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
||||
|
||||
def init_db():
|
||||
conn = sqlite3.connect(DB_PATH, timeout=10, check_same_thread=False)
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS reports (
|
||||
ip TEXT,
|
||||
logsig TEXT,
|
||||
ts INTEGER,
|
||||
PRIMARY KEY (ip, logsig)
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
return conn
|
||||
|
||||
# --- Deduplication ---
|
||||
_recent_reports = {}
|
||||
_recent_lock = threading.Lock()
|
||||
|
||||
def signature_for_log(container, ip, raw_log):
|
||||
"""Hash IP + container + first 400 chars of log to ignore timestamps."""
|
||||
m = hashlib.sha256()
|
||||
core = f"{container}:{ip}"
|
||||
core_part = raw_log[:400]
|
||||
m.update(core.encode())
|
||||
m.update(core_part.encode())
|
||||
return m.hexdigest()
|
||||
|
||||
def already_reported(conn, ip, logsig):
|
||||
now = time.time()
|
||||
with _recent_lock:
|
||||
if ip in _recent_reports and now - _recent_reports[ip] < INMEM_COOLDOWN:
|
||||
return True
|
||||
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT ts FROM reports WHERE ip=? AND logsig=?", (ip, logsig))
|
||||
row = cur.fetchone()
|
||||
if row and (now - row[0]) < DUPLICATE_TTL_SECONDS:
|
||||
return True
|
||||
return False
|
||||
|
||||
def mark_reported(conn, ip, logsig):
|
||||
now = int(time.time())
|
||||
with _recent_lock:
|
||||
_recent_reports[ip] = now
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"INSERT OR REPLACE INTO reports(ip, logsig, ts) VALUES(?,?,?)",
|
||||
(ip, logsig, now),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# --- IP extraction ---
|
||||
def ip_is_public(ip_str: str) -> bool:
|
||||
try:
|
||||
a = ipaddress.ip_address(ip_str)
|
||||
if a.is_private or a.is_loopback or a.is_unspecified or a.is_multicast:
|
||||
return False
|
||||
if hasattr(a, "is_global") and not a.is_global:
|
||||
return False
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def extract_ips_from_text(line: str):
|
||||
cand = IP_RE.findall(line)
|
||||
return [ip for ip in cand if ip_is_public(ip)]
|
||||
|
||||
def pick_ip_from_json(obj: dict) -> str | None:
|
||||
for k in ("x-real-ip", "x_real_ip", "x-forwarded-for", "x_forwarded_for"):
|
||||
v = obj.get(k)
|
||||
if isinstance(v, str) and v.strip():
|
||||
if "," in v:
|
||||
parts = [p.strip() for p in v.split(",") if p.strip()]
|
||||
for p in parts:
|
||||
if ip_is_public(p):
|
||||
return p
|
||||
return parts[0]
|
||||
return v.strip()
|
||||
return None
|
||||
|
||||
# --- Reporting queue ---
|
||||
report_queue = []
|
||||
queue_lock = threading.Lock()
|
||||
|
||||
def queue_report(ip, comment):
|
||||
with queue_lock:
|
||||
report_queue.append((ip, comment))
|
||||
|
||||
def flush_reports(api_key, dry_run=False):
|
||||
with queue_lock:
|
||||
batch = report_queue[:]
|
||||
report_queue.clear()
|
||||
for ip, comment in batch:
|
||||
if not os.path.exists(CURL_BIN):
|
||||
logging.error("curl binary not found at %s; cannot report", CURL_BIN)
|
||||
continue
|
||||
if dry_run:
|
||||
logging.info("[dry-run] would report %s (len comment=%d)", ip, len(comment))
|
||||
continue
|
||||
cmd = [
|
||||
CURL_BIN, "-sS", "-X", "POST", "https://api.abuseipdb.com/api/v2/report",
|
||||
"-H", f"Key: {api_key}",
|
||||
"-H", "Accept: application/json",
|
||||
"--data-urlencode", f"ip={ip}",
|
||||
"--data-urlencode", "categories=19",
|
||||
"--data-urlencode", f"comment={comment}"
|
||||
]
|
||||
try:
|
||||
p = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
||||
if p.returncode == 0:
|
||||
logging.info("Reported %s to AbuseIPDB (stdout len=%d)", ip, len(p.stdout or ""))
|
||||
else:
|
||||
logging.warning("curl returned %s when reporting %s: %s", p.returncode, ip, (p.stderr or p.stdout or "")[:300])
|
||||
except Exception as e:
|
||||
logging.exception("curl failed for %s: %s", ip, e)
|
||||
|
||||
# --- Log line processing ---
|
||||
def parse_log_line(line: str):
|
||||
if "|" in line:
|
||||
right = line.split("|", 1)[1].strip()
|
||||
else:
|
||||
right = line.strip()
|
||||
try:
|
||||
obj = json.loads(right)
|
||||
return obj, right
|
||||
except Exception:
|
||||
return None, right
|
||||
|
||||
def process_line(container, line, conn, api_key, dry_run):
|
||||
obj, raw = parse_log_line(line)
|
||||
found_deny = False
|
||||
if obj and isinstance(obj, dict):
|
||||
cr = obj.get("check_result") or {}
|
||||
if isinstance(cr, dict) and cr.get("rule") == "DENY":
|
||||
found_deny = True
|
||||
else:
|
||||
if '"rule":"DENY"' in raw or '"rule": "DENY"' in raw:
|
||||
found_deny = True
|
||||
if not found_deny:
|
||||
return
|
||||
|
||||
ips = []
|
||||
if obj:
|
||||
cand = pick_ip_from_json(obj)
|
||||
if cand:
|
||||
for p in re.split(r'[;,\s]+', cand):
|
||||
try:
|
||||
ips.append(str(ipaddress.ip_address(p.strip())))
|
||||
except Exception:
|
||||
continue
|
||||
if not ips:
|
||||
ips = extract_ips_from_text(raw)
|
||||
ips = [ip for ip in ips if ip_is_public(ip)]
|
||||
if not ips:
|
||||
logging.info("DENY found but no public IP: %s", raw[:200])
|
||||
return
|
||||
|
||||
for ip in ips:
|
||||
logsig = signature_for_log(container, ip, raw)
|
||||
if already_reported(conn, ip, logsig):
|
||||
continue
|
||||
comment = f"Anubis DENY log\nContainer: {container}\nLog: {raw[:950]}"
|
||||
queue_report(ip, comment)
|
||||
mark_reported(conn, ip, logsig)
|
||||
|
||||
# --- Container follower ---
|
||||
class ContainerFollower(threading.Thread):
|
||||
def __init__(self, name, docker_bin, conn, api_key, dry_run):
|
||||
super().__init__(daemon=True)
|
||||
self.name = name
|
||||
self.docker_bin = docker_bin
|
||||
self.conn = conn
|
||||
self.api_key = api_key
|
||||
self.dry_run = dry_run
|
||||
self.proc = None
|
||||
self.stopped = threading.Event()
|
||||
|
||||
def run(self):
|
||||
while not self.stopped.is_set():
|
||||
try:
|
||||
# Filter DENY logs at Docker level
|
||||
cmd = f'{self.docker_bin} logs -f {self.name} | grep \'"rule":"DENY"\''
|
||||
self.proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
shell=True, text=True)
|
||||
logging.info("Started DENY-only log follower: %s", self.name)
|
||||
for rawline in self.proc.stdout:
|
||||
if self.stopped.is_set():
|
||||
break
|
||||
if not rawline.strip():
|
||||
continue
|
||||
process_line(self.name, rawline.rstrip("\n"), self.conn, self.api_key, self.dry_run)
|
||||
except Exception:
|
||||
logging.exception("Follower for %s crashed; restarting in 2s", self.name)
|
||||
time.sleep(2)
|
||||
finally:
|
||||
if self.proc and self.proc.poll() is None:
|
||||
try:
|
||||
self.proc.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
self.proc = None
|
||||
time.sleep(1)
|
||||
|
||||
def stop(self):
|
||||
self.stopped.set()
|
||||
if self.proc and self.proc.poll() is None:
|
||||
try:
|
||||
self.proc.terminate()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# --- Supervisor ---
|
||||
def supervisor(dry_run=False):
|
||||
ensure_dirs()
|
||||
conn = init_db()
|
||||
if not os.path.exists(ABUSEIPDB_KEY_FILE):
|
||||
logging.warning("AbuseIPDB key file missing: %s", ABUSEIPDB_KEY_FILE)
|
||||
api_key = None
|
||||
else:
|
||||
api_key = open(ABUSEIPDB_KEY_FILE).read().strip()
|
||||
followers = {}
|
||||
|
||||
# reporter thread
|
||||
def reporter_loop():
|
||||
while True:
|
||||
time.sleep(REPORT_FLUSH_INTERVAL)
|
||||
flush_reports(api_key, dry_run=dry_run)
|
||||
t = threading.Thread(target=reporter_loop, daemon=True)
|
||||
t.start()
|
||||
|
||||
while True:
|
||||
try:
|
||||
p = subprocess.run([DOCKER_BIN, "ps", "--filter", "name=anubis", "--format", "{{.Names}}"],
|
||||
capture_output=True, text=True)
|
||||
if p.returncode != 0:
|
||||
names = []
|
||||
else:
|
||||
names = [ln.strip() for ln in p.stdout.splitlines() if ln.strip()]
|
||||
except Exception:
|
||||
logging.exception("docker ps failed")
|
||||
names = []
|
||||
current = set(names)
|
||||
existing = set(followers.keys())
|
||||
|
||||
for name in current - existing:
|
||||
f = ContainerFollower(name, DOCKER_BIN, conn, api_key, dry_run)
|
||||
followers[name] = f
|
||||
f.start()
|
||||
logging.info("Started follower for %s", name)
|
||||
|
||||
for name in existing - current:
|
||||
logging.info("Stopping follower for %s", name)
|
||||
followers[name].stop()
|
||||
followers[name].join(timeout=5)
|
||||
followers.pop(name, None)
|
||||
|
||||
time.sleep(SLEEP_REFRESH)
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--dry-run", action="store_true", help="Do not actually POST")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
supervisor(dry_run=args.dry_run)
|
||||
except KeyboardInterrupt:
|
||||
logging.info("Shutting down")
|
||||
sys.exit(0)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user