Files
kattila.status/kattila01.py
2026-04-12 23:12:41 +03:00

289 lines
9.4 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
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"<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", # 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 = """<!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)