""" 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 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() # level 0 = public hub, level 1 = private spoke SERVER_CONFIG = { "pussi": 0, "node03": 0, "koti": 1, "tuoppi2": 1, "node04": 1, } # ── IP ANONYMIZATION ────────────────────────────────────────────────────────── def anonymize_ip(ip: str) -> str: """ Private RFC1918 addresses: reveal subnet, hide host octet. Public addresses: replace with a short stable hash (same IP always produces the same token, so topology is still readable). """ 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.*.*" # Public IP — stable 6-char token so the graph stays consistent across # refreshes without leaking the actual address 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()) level = SERVER_CONFIG.get(hostname, 1) 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 data.get("interfaces", []): 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"}) @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", # hover tooltip } ) edges = [] seen_pairs: set[tuple] = set() for p in peers: target = key_to_server.get(p["peer_pubkey"]) if not target: continue # De-duplicate bidirectional edges (A→B and B→A → one edge) 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" # grey — no handshake ever elif h <= 120: color = "#2ecc71" # green — active elif h <= 86400: color = "#f1c40f" # yellow — stale else: color = "#e74c3c" # red — effectively broken 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 = """ kattila — Network Map
Link status
Active (< 2 min)
Stale (> 2 min)
Broken (> 24 h)
No handshake
""" if __name__ == "__main__": app.run(host="10.37.11.2", port=5086)