Files
kattila.status/kattila_poc.py
2026-04-17 19:23:04 +03:00

296 lines
9.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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"<b>{s['name'].upper()}</b>\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 = """<!DOCTYPE html>
<html>
<head>
<title>kattila — Network Map</title>
<script src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>
<style>
*, *::before, *::after { box-sizing: border-box; }
body { font-family: 'Segoe UI', sans-serif; background: #0d1117; color: #e6edf3; margin: 0; }
#map { width: 100vw; height: 100vh; }
#hud {
position: absolute; top: 12px; left: 12px;
background: rgba(13,17,23,0.88); backdrop-filter: blur(6px);
border: 1px solid #30363d; border-radius: 8px;
padding: 12px 16px; font-size: 12px; z-index: 10; min-width: 140px;
}
#hud b { display: block; margin-bottom: 6px; font-size: 13px; color: #58a6ff; }
.dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; margin-right: 6px; }
.row { margin: 3px 0; }
#clock { margin-top: 10px; font-size: 10px; color: #8b949e; border-top: 1px solid #30363d; padding-top: 6px; }
</style>
</head>
<body>
<div id="hud">
<b>Link status</b>
<div class="row"><span class="dot" style="background:#2ecc71"></span>Active (&lt; 2 min)</div>
<div class="row"><span class="dot" style="background:#f1c40f"></span>Stale (&gt; 2 min)</div>
<div class="row"><span class="dot" style="background:#e74c3c"></span>Broken (&gt; 24 h)</div>
<div class="row"><span class="dot" style="background:#95a5a6"></span>No handshake</div>
<div id="clock"></div>
</div>
<div id="map"></div>
<script>
const nodes = new vis.DataSet();
const edges = new vis.DataSet();
const net = new vis.Network(
document.getElementById('map'),
{ nodes, edges },
{
layout: { hierarchical: { direction: 'UD', nodeSpacing: 300, levelSeparation: 200 } },
physics: false,
nodes: {
shape: 'box',
font: { multi: 'html', color: '#e6edf3', size: 13 },
margin: 10,
color: { background: '#161b22', border: '#30363d' },
},
edges: {
arrows: 'to',
font: { size: 10, color: '#8b949e', strokeWidth: 0 },
smooth: { type: 'curvedCW', roundness: 0.15 },
},
}
);
async function refresh() {
const res = await fetch('/status/data');
const data = await res.json();
nodes.update(data.nodes);
edges.update(data.edges);
document.getElementById('clock').textContent =
'Updated ' + new Date().toLocaleTimeString();
}
setInterval(refresh, 10000);
refresh();
</script>
</body>
</html>"""
if __name__ == "__main__":
app.run(host="10.37.11.2", port=5086)