ZKAC/demo/zkac_admin_serve.py
2026-05-06 15:01:40 +02:00

323 lines
10 KiB
Python

#!/usr/bin/env python3
"""
HTTP admin debug dashboard for ``zkac-node serve``.
Runs the ZKAC TCP node in a background thread and serves a read-only web UI on
another port. Intended for loopback + I2P server-tunnel forwarding.
**Security:** This page is fully transparent (registry metadata, live sessions,
public keys). Do not expose it to untrusted networks without tunnel ACLs.
Usage::
uv sync --extra demo
uv run python demo/zkac_admin_serve.py alice \\
--node-host 127.0.0.1 --node-port 9800 \\
--web-host 127.0.0.1 --web-port 8766
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import threading
import time
from pathlib import Path
from flask import Flask, Response, jsonify, render_template_string, request
from zkac_cli.paths import user_dir
from zkac_cli.server import serve
from zkac_cli.server_debug import (
ServerDebugState,
collect_registry_debug,
data_dir_tree,
server_key_meta,
)
_PAGE = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
{% if refresh_s %}
<meta http-equiv="refresh" content="{{ refresh_s }}"/>
{% endif %}
<title>ZKAC node — admin debug</title>
<style>
:root {
--bg: #0f1419;
--panel: #1a2332;
--border: #2d3d52;
--text: #e7ecf3;
--muted: #8b9cb3;
--accent: #5b9bd5;
--ok: #6cbf6c;
--warn: #e6b35a;
}
* { box-sizing: border-box; }
body {
font-family: ui-sans-serif, system-ui, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
margin: 0;
padding: 1.25rem 1.5rem 2rem;
line-height: 1.45;
}
h1 { font-size: 1.35rem; font-weight: 600; margin: 0 0 0.25rem; }
.sub { color: var(--muted); font-size: 0.9rem; margin-bottom: 1.25rem; }
.grid { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); }
section {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1rem 1.1rem;
}
section h2 {
margin: 0 0 0.65rem;
font-size: 1rem;
font-weight: 600;
color: var(--accent);
}
pre, .mono {
font-family: ui-monospace, "Cascadia Code", "SF Mono", Menlo, monospace;
font-size: 0.78rem;
white-space: pre-wrap;
word-break: break-all;
background: #0c1017;
border-radius: 6px;
padding: 0.65rem 0.75rem;
margin: 0;
border: 1px solid var(--border);
}
table { width: 100%; border-collapse: collapse; font-size: 0.82rem; }
th, td { text-align: left; padding: 0.35rem 0.5rem; border-bottom: 1px solid var(--border); }
th { color: var(--muted); font-weight: 500; }
.pill { display: inline-block; padding: 0.12rem 0.45rem; border-radius: 999px; font-size: 0.72rem; }
.pill-live { background: #1e3d2a; color: var(--ok); }
.pill-warn { background: #3d3518; color: var(--warn); }
a { color: var(--accent); }
.links { margin-top: 1rem; font-size: 0.88rem; color: var(--muted); }
</style>
</head>
<body>
<h1>ZKAC node — admin debug</h1>
<p class="sub">
User <strong>{{ snap.userid }}</strong> · TCP
<span class="mono">{{ snap.listen.host }}:{{ snap.listen.port }}</span>
· uptime {{ snap.uptime_s }}s
{% if refresh_s %}
· auto-refresh {{ refresh_s }}s
{% endif %}
</p>
<div class="grid">
<section>
<h2>Status</h2>
<table>
<tr><th>Data directory</th><td class="mono">{{ snap.data_dir }}</td></tr>
<tr><th>Started (wall)</th><td class="mono">{{ snap.started_wall }}</td></tr>
<tr><th>Server public key</th><td class="mono">{{ snap.server_public_key_hex or "" }}</td></tr>
<tr><th>Registries (boot)</th><td>{{ snap.registries_loaded_boot }}</td></tr>
<tr><th>Active TCP sessions</th><td>
<span class="pill pill-live">{{ snap.active_connection_count }} live</span>
</td></tr>
<tr><th>Process</th><td class="mono">pid={{ proc.pid }} threads={{ proc.threads }}</td></tr>
</table>
</section>
<section>
<h2>Server key file</h2>
<pre>{{ sk_meta | tojson(indent=2) }}</pre>
</section>
<section style="grid-column: 1 / -1;">
<h2>Registries (on disk + parsed)</h2>
{% if reg %}
<table>
<tr>
<th>ID (file)</th>
<th>version</th>
<th>state hash</th>
<th>bytes (state / cert)</th>
<th>parse</th>
</tr>
{% for r in reg %}
<tr>
<td class="mono">{{ r.file_registry_id_hex[:16] }}…</td>
<td>{{ r.get("version", "") }}</td>
<td class="mono">{% if r.get("state_hash_hex") %}{{ r.state_hash_hex[:24] }}…{% else %}—{% endif %}</td>
<td>{{ r.state_bytes }} / {{ r.cert_bytes }}</td>
<td>{% if r.parsed_ok %}<span class="pill pill-live">ok</span>{% else %}<span class="pill pill-warn">fail</span>{% endif %}</td>
</tr>
{% endfor %}
</table>
{% else %}
<p class="muted">No registry state files yet.</p>
{% endif %}
</section>
<section style="grid-column: 1 / -1;">
<h2>Session connections (live)</h2>
{% if snap.active_connections %}
<table>
<tr>
<th>id</th><th>peer</th><th>phase</th><th>op</th><th>transcript</th>
<th>auth registry</th><th>role</th><th>mgmt #</th><th>echo bytes</th>
</tr>
{% for c in snap.active_connections %}
<tr>
<td class="mono">{{ c.id }}</td>
<td class="mono">{{ c.peer }}</td>
<td>{{ c.phase }}</td>
<td>{{ c.hello_op or "" }}</td>
<td class="mono">{% if c.transcript_hash_hex %}{{ c.transcript_hash_hex[:20] }}…{% else %}—{% endif %}</td>
<td class="mono">{% if c.auth_registry_hex %}{{ c.auth_registry_hex[:16] }}…{% else %}—{% endif %}</td>
<td class="mono">{% if c.auth_role_hex %}{{ c.auth_role_hex[:16] }}…{% else %}—{% endif %}</td>
<td>{{ c.mgmt_commands or 0 }}</td>
<td>{{ c.bytes_echoed or 0 }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<p class="muted">No active connections.</p>
{% endif %}
</section>
<section style="grid-column: 1 / -1;">
<h2>Recent connections</h2>
{% if snap.recent_connections %}
<table>
<tr>
<th>id</th><th>peer</th><th>phase</th><th>mgmt #</th><th>echo bytes</th><th>error</th>
</tr>
{% for c in snap.recent_connections %}
<tr>
<td class="mono">{{ c.id }}</td>
<td class="mono">{{ c.peer }}</td>
<td>{{ c.phase }}</td>
<td>{{ c.mgmt_commands or 0 }}</td>
<td>{{ c.bytes_echoed or 0 }}</td>
<td class="mono">{{ c.error or "" }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<p class="muted">No recent disconnects recorded.</p>
{% endif %}
</section>
<section style="grid-column: 1 / -1;">
<h2>Data directory tree (debug)</h2>
<pre>{{ files | tojson(indent=2) }}</pre>
</section>
<section style="grid-column: 1 / -1;">
<h2>Full debug JSON</h2>
<p class="sub" style="margin-top:0">Machine-readable snapshot (same as <a href="/api/debug.json">/api/debug.json</a>).</p>
<pre>{{ full_json }}</pre>
</section>
</div>
<p class="links">
<a href="?refresh=3">Auto-refresh 3s</a> ·
<a href="?refresh=5">5s</a> ·
<a href="?">static</a> ·
<a href="/api/debug.json">debug.json</a>
</p>
</body>
</html>
"""
def _full_payload(debug: ServerDebugState, data_dir: Path) -> dict:
snap = debug.snapshot()
reg = collect_registry_debug(data_dir)
sk = server_key_meta(data_dir)
files = data_dir_tree(data_dir)
return {
"snapshot": snap,
"server_key_file": sk,
"registries": reg,
"data_dir_files": files,
"process": {"pid": os.getpid(), "threads": threading.active_count()},
"generated_wall": time.time(),
}
def create_app(debug: ServerDebugState, data_dir: Path) -> Flask:
app = Flask(__name__)
@app.get("/")
def index():
refresh = request.args.get("refresh", "").strip()
refresh_s = int(refresh) if refresh.isdigit() and 1 <= int(refresh) <= 60 else None
data = _full_payload(debug, data_dir)
snap = data["snapshot"]
proc = data["process"]
full_json = json.dumps(data, indent=2, sort_keys=True)
return render_template_string(
_PAGE,
snap=snap,
reg=data["registries"],
sk_meta=data["server_key_file"],
files=data["data_dir_files"],
full_json=full_json,
proc=proc,
refresh_s=refresh_s,
)
@app.get("/api/debug.json")
def api_debug():
return jsonify(_full_payload(debug, data_dir))
@app.get("/healthz")
def healthz():
return Response("ok", mimetype="text/plain")
return app
def main() -> None:
p = argparse.ArgumentParser(description="ZKAC node TCP + HTTP admin debug dashboard")
p.add_argument("userid", help="user whose ~/.zkac/<userid>/server/ holds node state")
p.add_argument("--data-dir", default=None, help="override server data directory")
p.add_argument("--node-host", default="127.0.0.1")
p.add_argument("--node-port", type=int, default=9800)
p.add_argument("--web-host", default="127.0.0.1")
p.add_argument("--web-port", type=int, default=8766)
args = p.parse_args()
data_dir = Path(args.data_dir) if args.data_dir else user_dir(args.userid) / "server"
data_dir = data_dir.resolve()
debug = ServerDebugState(userid=args.userid, data_dir=str(data_dir))
t = threading.Thread(
target=serve,
kwargs={
"data_dir": str(data_dir),
"host": args.node_host,
"port": args.node_port,
"debug": debug,
},
name="zkac-serve",
daemon=True,
)
t.start()
time.sleep(0.15)
if not t.is_alive():
print("ZKAC node thread died on startup; check stderr above.", file=sys.stderr)
sys.exit(1)
app = create_app(debug, data_dir)
print(f"ZKAC TCP node: {args.node_host}:{args.node_port} (data {data_dir})")
print(f"Admin debug UI: http://{args.web_host}:{args.web_port}/")
print("Warning: admin UI exposes live sessions and registry metadata.", file=sys.stderr)
app.run(host=args.web_host, port=args.web_port, debug=False, threaded=True)
if __name__ == "__main__":
main()