""" kattila Manager - Accepts reports from agents (direct or relayed) - Requires pre-shared key authentication - Anonymizes IPs in the public-facing graph """ import sqlite3 import hashlib import time import os import ipaddress # Added for IP validation from flask import Flask, render_template_string, jsonify, request, abort app = Flask(__name__) DB_PATH = "network.db" # ── PSK AUTH ────────────────────────────────────────────────────────────────── PSK_FILE = os.environ.get("PSK_FILE", "/etc/kattila/psk") def load_psk() -> str: if os.path.isfile(PSK_FILE): with open(PSK_FILE) as f: return f.read().strip() key = os.environ.get("kattila_PSK", "") if not key: raise RuntimeError(f"No PSK found. Write to {PSK_FILE} or set kattila_PSK.") return key PSK = load_psk() def require_psk(): if request.headers.get("X-kattila-PSK") != PSK: abort(401) # ── DATABASE ────────────────────────────────────────────────────────────────── def init_db(): with sqlite3.connect(DB_PATH) as conn: conn.execute("""CREATE TABLE IF NOT EXISTS servers (name TEXT PRIMARY KEY, last_seen INTEGER, level INTEGER)""") conn.execute("""CREATE TABLE IF NOT EXISTS interfaces (server_name TEXT, ifname TEXT, local_ip TEXT, public_key TEXT)""") conn.execute( """CREATE TABLE IF NOT EXISTS wg_peers (server_name TEXT, ifname TEXT, peer_pubkey TEXT, handshake INTEGER, status TEXT)""" ) init_db() # ── IP LOGIC ────────────────────────────────────────────────────────────────── def is_public_ip(ip_str: str) -> bool: """ Returns True if the IP is a global, public address. Returns False for RFC1918, loopback, link-local, or invalid IPs. """ try: ip = ipaddress.ip_address(ip_str) # is_private covers RFC1918, shared address space, etc. return ip.is_global and not ip.is_private and not ip.is_loopback and not ip.is_link_local except ValueError: return False def anonymize_ip(ip: str) -> str: """ Private RFC1918 addresses: reveal subnet, hide host octet. Public addresses: replace with a short stable hash. """ parts = ip.split(".") if len(parts) != 4: return "???" first, second = parts[0], parts[1] try: f = int(first) except ValueError: return "???" if f == 10: return f"10.{second}.*.*" if f == 172 and 16 <= int(second) <= 31: return f"172.{second}.*.*" if f == 192 and second == "168": return f"192.168.*.*" token = hashlib.sha256(ip.encode()).hexdigest()[:6] return f"[pub:{token}]" # ── API ─────────────────────────────────────────────────────────────────────── @app.route("/api/report", methods=["POST"]) def api_report(): require_psk() data = request.json hostname = data["hostname"].lower() now = int(time.time()) # DYNAMIC LEVEL DETERMINATION: # Default to level 1 (Spoke). If we find a public IP, upgrade to level 0 (Hub). level = 1 interfaces_data = data.get("interfaces", []) for iface in interfaces_data: if is_public_ip(iface.get("ip", "")): level = 0 break with sqlite3.connect(DB_PATH) as conn: conn.execute( "INSERT OR REPLACE INTO servers VALUES (?, ?, ?)", (hostname, now, level), ) conn.execute("DELETE FROM interfaces WHERE server_name = ?", (hostname,)) for iface in interfaces_data: conn.execute( "INSERT INTO interfaces VALUES (?, ?, ?, ?)", (hostname, iface["name"], iface["ip"], iface.get("public_key")), ) conn.execute("DELETE FROM wg_peers WHERE server_name = ?", (hostname,)) for peer in data.get("wg_peers", []): conn.execute( "INSERT INTO wg_peers VALUES (?, ?, ?, ?, ?)", ( hostname, peer["ifname"], peer["pubkey"], peer["handshake"], peer["status"], ), ) return jsonify({"status": "ok", "detected_level": level}) @app.route("/status/data") def get_graph_data(): with sqlite3.connect(DB_PATH) as conn: conn.row_factory = sqlite3.Row servers = conn.execute("SELECT * FROM servers").fetchall() interfaces = conn.execute("SELECT * FROM interfaces").fetchall() peers = conn.execute("SELECT * FROM wg_peers").fetchall() key_to_server = { i["public_key"]: i["server_name"] for i in interfaces if i["public_key"] } now = time.time() nodes = [] for s in servers: anon_ips = [ anonymize_ip(i["local_ip"]) for i in interfaces if i["server_name"] == s["name"] ] age = int(now - s["last_seen"]) is_alive = age < 300 nodes.append( { "id": s["name"], "label": f"{s['name'].upper()}\n{', '.join(anon_ips[:2])}", "level": s["level"], "color": "#2ecc71" if is_alive else "#e74c3c", "title": f"Last seen {age}s ago", } ) edges = [] seen_pairs: set[tuple] = set() for p in peers: target = key_to_server.get(p["peer_pubkey"]) if not target: continue pair = tuple(sorted([p["server_name"], target])) if pair in seen_pairs: continue seen_pairs.add(pair) h = p["handshake"] if h == 0: color = "#95a5a6" elif h <= 120: color = "#2ecc71" elif h <= 86400: color = "#f1c40f" else: color = "#e74c3c" edges.append( { "from": p["server_name"], "to": target, "label": p["ifname"], "color": color, "width": 3 if color == "#e74c3c" else 2, "title": f"{h}s since handshake" if h else "No handshake yet", } ) return jsonify({"nodes": nodes, "edges": edges}) # ── FRONTEND ────────────────────────────────────────────────────────────────── @app.route("/status") def index(): return render_template_string(HTML_TEMPLATE) HTML_TEMPLATE = """