Initial commit.

This commit is contained in:
Kalzu Rekku
2026-04-12 23:12:41 +03:00
commit a235a8e646
4 changed files with 869 additions and 0 deletions

288
kattila01.py Normal file
View File

@@ -0,0 +1,288 @@
"""
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)