323 lines
10 KiB
Python
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()
|