295 lines
9.6 KiB
Python
295 lines
9.6 KiB
Python
"""
|
||
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 (< 2 min)</div>
|
||
<div class="row"><span class="dot" style="background:#f1c40f"></span>Stale (> 2 min)</div>
|
||
<div class="row"><span class="dot" style="background:#e74c3c"></span>Broken (> 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) |