p2p-grants #1
@ -16,7 +16,7 @@ zkac-node user create alice
|
|||||||
zkac-node user create bob
|
zkac-node user create bob
|
||||||
|
|
||||||
# Bob shares one contact string with Alice out-of-band:
|
# Bob shares one contact string with Alice out-of-band:
|
||||||
# zkac-node user show bob
|
# zkac-node user show bob --peer 127.0.0.1:9810
|
||||||
|
|
||||||
# 2. Alice runs a server; pin its public key for clients
|
# 2. Alice runs a server; pin its public key for clients
|
||||||
zkac-node serve alice --port 9800 &
|
zkac-node serve alice --port 9800 &
|
||||||
@ -26,8 +26,7 @@ zkac-node server pin bob localhost:9800 --key <SERVER_PK_HEX>
|
|||||||
# 3. Alice creates a registry and grants Bob a role directly (needs Bob's contact string)
|
# 3. Alice creates a registry and grants Bob a role directly (needs Bob's contact string)
|
||||||
zkac-node registry create alice localhost:9800 --roles analyst,operator
|
zkac-node registry create alice localhost:9800 --roles analyst,operator
|
||||||
zkac-node grant alice --server localhost:9800 \
|
zkac-node grant alice --server localhost:9800 \
|
||||||
--registry <REGISTRY_ID> --role analyst --to-contact "$BOB_CONTACT" \
|
--registry <REGISTRY_ID> --role analyst --to "$BOB_CONTACT"
|
||||||
--peer 127.0.0.1:9810
|
|
||||||
|
|
||||||
# 4. Bob listens for direct p2p delivery
|
# 4. Bob listens for direct p2p delivery
|
||||||
zkac-node p2p-listen bob --host 127.0.0.1 --port 9810
|
zkac-node p2p-listen bob --host 127.0.0.1 --port 9810
|
||||||
@ -53,7 +52,7 @@ zkac-node auth bob --registry <REGISTRY_ID> --role analyst --server localhost:98
|
|||||||
| `registry update <id> <server> --registry R --add-roles …` | Add roles |
|
| `registry update <id> <server> --registry R --add-roles …` | Add roles |
|
||||||
| `registry get <id> <server> --registry R` | Fetch registry state |
|
| `registry get <id> <server> --registry R` | Fetch registry state |
|
||||||
| `registry list <id>` | List registries this user owns locally |
|
| `registry list <id>` | List registries this user owns locally |
|
||||||
| `grant <id> --server S --registry R --role X --to-contact <blob> --peer host:port` | Admin direct p2p grant (single-share recipient contact) |
|
| `grant <id> --server S --registry R --role X --to <blob>` | Admin direct p2p grant (recipient contact bundle must include peer endpoint) |
|
||||||
| `p2p-listen <id> [--host H --port P]` | Receive one direct p2p grant and store credential |
|
| `p2p-listen <id> [--host H --port P]` | Receive one direct p2p grant and store credential |
|
||||||
| `credentials list <id>` | List local credentials |
|
| `credentials list <id>` | List local credentials |
|
||||||
| `auth <id> --registry R --role X [--server S]` | Authenticated session |
|
| `auth <id> --registry R --role X [--server S]` | Authenticated session |
|
||||||
@ -106,6 +105,9 @@ Connectivity sanity check:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
zkac-node net check exampledestination.b32.i2p:9800
|
zkac-node net check exampledestination.b32.i2p:9800
|
||||||
|
zkac-node net check exampledestination.b32.i2p:9800 --handshake --key <SERVER_PK_HEX>
|
||||||
|
# or use an existing pin:
|
||||||
|
zkac-node net check exampledestination.b32.i2p:9800 --handshake --userid alice
|
||||||
```
|
```
|
||||||
|
|
||||||
With proxy:
|
With proxy:
|
||||||
|
|||||||
@ -61,6 +61,10 @@ def _proxy_target() -> tuple[str, int] | None:
|
|||||||
return _parse_host_port(raw, "ZKAC_SOCKS5_PROXY")
|
return _parse_host_port(raw, "ZKAC_SOCKS5_PROXY")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_i2p_host(host: str) -> bool:
|
||||||
|
return host.strip().lower().endswith(".i2p")
|
||||||
|
|
||||||
|
|
||||||
def _recv_exact(sock: socket.socket, n: int) -> bytes:
|
def _recv_exact(sock: socket.socket, n: int) -> bytes:
|
||||||
buf = bytearray()
|
buf = bytearray()
|
||||||
while len(buf) < n:
|
while len(buf) < n:
|
||||||
@ -100,7 +104,9 @@ def _socks5_connect(sock: socket.socket, host: str, port: int):
|
|||||||
|
|
||||||
def _connect(host: str, port: int) -> socket.socket:
|
def _connect(host: str, port: int) -> socket.socket:
|
||||||
proxy = _proxy_target()
|
proxy = _proxy_target()
|
||||||
if proxy is None:
|
# Only route through SOCKS5 for I2P destinations. This keeps local/direct
|
||||||
|
# peer delivery (e.g. 127.0.0.1) working even when ZKAC_SOCKS5_PROXY is set.
|
||||||
|
if proxy is None or not _is_i2p_host(host):
|
||||||
return socket.create_connection((host, port))
|
return socket.create_connection((host, port))
|
||||||
sock = socket.create_connection(proxy)
|
sock = socket.create_connection(proxy)
|
||||||
try:
|
try:
|
||||||
@ -111,23 +117,52 @@ def _connect(host: str, port: int) -> socket.socket:
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
def net_check(target: str, timeout_s: float = 8.0) -> dict:
|
def net_check(
|
||||||
"""Connectivity diagnostic for direct/server endpoints, with optional SOCKS5."""
|
target: str,
|
||||||
|
timeout_s: float = 8.0,
|
||||||
|
*,
|
||||||
|
handshake: bool = False,
|
||||||
|
userid: str | None = None,
|
||||||
|
server_key_hex: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Connectivity diagnostic for direct/server endpoints, with optional SOCKS5 and handshake."""
|
||||||
host, port = _parse_server(target)
|
host, port = _parse_server(target)
|
||||||
proxy = _proxy_target()
|
proxy = _proxy_target()
|
||||||
|
via = "direct" if proxy is None else f"socks5:{proxy[0]}:{proxy[1]}"
|
||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
sock.settimeout(timeout_s)
|
sock.settimeout(timeout_s)
|
||||||
try:
|
try:
|
||||||
if proxy is None:
|
if proxy is None:
|
||||||
sock.connect((host, port))
|
sock.connect((host, port))
|
||||||
via = "direct"
|
|
||||||
else:
|
else:
|
||||||
sock.connect(proxy)
|
sock.connect(proxy)
|
||||||
_socks5_connect(sock, host, port)
|
_socks5_connect(sock, host, port)
|
||||||
via = f"socks5:{proxy[0]}:{proxy[1]}"
|
|
||||||
return {"ok": True, "target": target, "via": via}
|
result = {"ok": True, "target": target, "via": via}
|
||||||
|
if handshake:
|
||||||
|
if server_key_hex and userid:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"target": target,
|
||||||
|
"via": via,
|
||||||
|
"error": "use either server_key_hex or userid for handshake, not both",
|
||||||
|
}
|
||||||
|
if server_key_hex:
|
||||||
|
server_pk = zkac.PublicKey.from_bytes(bytes.fromhex(server_key_hex))
|
||||||
|
elif userid:
|
||||||
|
server_pk = _resolve_server_pk(userid, target)
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"ok": False,
|
||||||
|
"target": target,
|
||||||
|
"via": via,
|
||||||
|
"error": "handshake check requires --key <hex> or --userid <id>",
|
||||||
|
}
|
||||||
|
node = zkac.Node(zkac.Keypair())
|
||||||
|
_ = client_handshake_anon(sock, node, server_pk)
|
||||||
|
result["handshake"] = "ok"
|
||||||
|
return result
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
via = "direct" if proxy is None else f"socks5:{proxy[0]}:{proxy[1]}"
|
|
||||||
return {"ok": False, "target": target, "via": via, "error": str(exc)}
|
return {"ok": False, "target": target, "via": via, "error": str(exc)}
|
||||||
finally:
|
finally:
|
||||||
sock.close()
|
sock.close()
|
||||||
|
|||||||
@ -34,12 +34,14 @@ def _cmd_user_list(_args):
|
|||||||
|
|
||||||
def _cmd_user_show(args):
|
def _cmd_user_show(args):
|
||||||
ident = store.load_identity(args.userid)
|
ident = store.load_identity(args.userid)
|
||||||
contact = store.export_contact_bundle(args.userid)
|
contact = store.export_contact_bundle(args.userid, peer=args.peer)
|
||||||
print(f"user: {args.userid}")
|
print(f"user: {args.userid}")
|
||||||
print(f" issuance pk: {ident['issuance_pk'].hex()}")
|
print(f" issuance pk: {ident['issuance_pk'].hex()}")
|
||||||
print(f" p2p transport pk: {ident['transport_pk'].hex()}")
|
print(f" p2p transport pk: {ident['transport_pk'].hex()}")
|
||||||
print(" share contact:")
|
print(" share contact:")
|
||||||
print(f" {contact}")
|
print(f" {contact}")
|
||||||
|
if args.peer:
|
||||||
|
print(f" contact peer endpoint: {args.peer}")
|
||||||
|
|
||||||
owned = [
|
owned = [
|
||||||
{"registry_id": r, **store.load_admin(args.userid, r)}
|
{"registry_id": r, **store.load_admin(args.userid, r)}
|
||||||
@ -109,19 +111,18 @@ def _cmd_registry_list(args):
|
|||||||
# ── grant ─────────────────────────────────────────────────────────────
|
# ── grant ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _cmd_grant(args):
|
def _cmd_grant(args):
|
||||||
to = args.to
|
parsed = store.parse_contact_bundle(args.to)
|
||||||
peer_key = args.peer_key
|
|
||||||
if args.to_contact:
|
|
||||||
if args.to or args.peer_key:
|
|
||||||
raise RuntimeError("use either --to-contact OR (--to and --peer-key), not both")
|
|
||||||
parsed = store.parse_contact_bundle(args.to_contact)
|
|
||||||
to = parsed["issuance_pk_hex"]
|
to = parsed["issuance_pk_hex"]
|
||||||
peer_key = parsed["transport_pk_hex"]
|
peer_key = parsed["transport_pk_hex"]
|
||||||
elif not args.to or not args.peer_key:
|
peer = parsed.get("peer")
|
||||||
raise RuntimeError("grant requires --to and --peer-key (or --to-contact)")
|
if not peer:
|
||||||
|
raise RuntimeError(
|
||||||
|
"contact bundle is missing peer endpoint. "
|
||||||
|
"Ask recipient to regenerate with: zkac-node user show <userid> --peer <host:port>"
|
||||||
|
)
|
||||||
|
|
||||||
result = client.grant_p2p(
|
result = client.grant_p2p(
|
||||||
args.userid, args.server, args.registry, args.role, to, args.peer, peer_key,
|
args.userid, args.server, args.registry, args.role, to, peer, peer_key,
|
||||||
)
|
)
|
||||||
print(f"granted {args.role!r} to {to[:16]}…")
|
print(f"granted {args.role!r} to {to[:16]}…")
|
||||||
print(f" delivery: direct p2p ({result['peer']})")
|
print(f" delivery: direct p2p ({result['peer']})")
|
||||||
@ -158,11 +159,19 @@ def _cmd_auth(args):
|
|||||||
|
|
||||||
|
|
||||||
def _cmd_net_check(args):
|
def _cmd_net_check(args):
|
||||||
resp = client.net_check(args.target, timeout_s=args.timeout)
|
resp = client.net_check(
|
||||||
|
args.target,
|
||||||
|
timeout_s=args.timeout,
|
||||||
|
handshake=args.handshake,
|
||||||
|
userid=args.userid,
|
||||||
|
server_key_hex=args.key,
|
||||||
|
)
|
||||||
if resp.get("ok"):
|
if resp.get("ok"):
|
||||||
print("network check: ok")
|
print("network check: ok")
|
||||||
print(f" target: {resp['target']}")
|
print(f" target: {resp['target']}")
|
||||||
print(f" via: {resp['via']}")
|
print(f" via: {resp['via']}")
|
||||||
|
if args.handshake:
|
||||||
|
print(" handshake: ok")
|
||||||
return
|
return
|
||||||
print("network check: failed")
|
print("network check: failed")
|
||||||
print(f" target: {resp['target']}")
|
print(f" target: {resp['target']}")
|
||||||
@ -190,6 +199,7 @@ def main():
|
|||||||
|
|
||||||
c = user_sub.add_parser("show", help="show user keys + registries + credentials")
|
c = user_sub.add_parser("show", help="show user keys + registries + credentials")
|
||||||
c.add_argument("userid")
|
c.add_argument("userid")
|
||||||
|
c.add_argument("--peer", default=None, help="optional recipient p2p host:port to embed in contact bundle")
|
||||||
c.set_defaults(func=_cmd_user_show)
|
c.set_defaults(func=_cmd_user_show)
|
||||||
|
|
||||||
# serve
|
# serve
|
||||||
@ -243,10 +253,7 @@ def main():
|
|||||||
c.add_argument("--server", required=True, help="host:port")
|
c.add_argument("--server", required=True, help="host:port")
|
||||||
c.add_argument("--registry", required=True)
|
c.add_argument("--registry", required=True)
|
||||||
c.add_argument("--role", required=True)
|
c.add_argument("--role", required=True)
|
||||||
c.add_argument("--to", default=None, help="recipient issuance public key (hex)")
|
c.add_argument("--to", required=True, help="recipient contact bundle")
|
||||||
c.add_argument("--to-contact", default=None, help="recipient one-string contact bundle")
|
|
||||||
c.add_argument("--peer", required=True, help="recipient p2p host:port")
|
|
||||||
c.add_argument("--peer-key", default=None, help="recipient p2p transport public key (hex)")
|
|
||||||
c.set_defaults(func=_cmd_grant)
|
c.set_defaults(func=_cmd_grant)
|
||||||
|
|
||||||
# credentials
|
# credentials
|
||||||
@ -277,6 +284,9 @@ def main():
|
|||||||
c = net_sub.add_parser("check", help="test TCP reachability (direct or SOCKS5 proxy)")
|
c = net_sub.add_parser("check", help="test TCP reachability (direct or SOCKS5 proxy)")
|
||||||
c.add_argument("target", help="host:port (supports .b32.i2p via SOCKS5)")
|
c.add_argument("target", help="host:port (supports .b32.i2p via SOCKS5)")
|
||||||
c.add_argument("--timeout", type=float, default=8.0, help="connect timeout in seconds")
|
c.add_argument("--timeout", type=float, default=8.0, help="connect timeout in seconds")
|
||||||
|
c.add_argument("--handshake", action="store_true", help="also run anonymous ZKAC handshake")
|
||||||
|
c.add_argument("--userid", default=None, help="client userid to load pinned server key for --handshake")
|
||||||
|
c.add_argument("--key", default=None, help="server public key hex for --handshake")
|
||||||
c.set_defaults(func=_cmd_net_check)
|
c.set_defaults(func=_cmd_net_check)
|
||||||
|
|
||||||
args = p.parse_args()
|
args = p.parse_args()
|
||||||
|
|||||||
@ -61,14 +61,16 @@ def load_identity(userid: str) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def export_contact_bundle(userid: str) -> str:
|
def export_contact_bundle(userid: str, peer: str | None = None) -> str:
|
||||||
"""One-string public contact bundle for out-of-band sharing."""
|
"""One-string public contact bundle for out-of-band sharing."""
|
||||||
ident = load_identity(userid)
|
ident = load_identity(userid)
|
||||||
payload = {
|
payload = {
|
||||||
"v": 1,
|
"v": 2,
|
||||||
"issuance_pk_hex": ident["issuance_pk"].hex(),
|
"issuance_pk_hex": ident["issuance_pk"].hex(),
|
||||||
"transport_pk_hex": ident["transport_pk"].hex(),
|
"transport_pk_hex": ident["transport_pk"].hex(),
|
||||||
}
|
}
|
||||||
|
if peer:
|
||||||
|
payload["peer"] = peer
|
||||||
raw = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
raw = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
||||||
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
||||||
|
|
||||||
@ -83,7 +85,8 @@ def parse_contact_bundle(bundle: str) -> dict:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise ValueError("invalid contact bundle encoding") from exc
|
raise ValueError("invalid contact bundle encoding") from exc
|
||||||
|
|
||||||
if data.get("v") != 1:
|
version = data.get("v")
|
||||||
|
if version not in (1, 2):
|
||||||
raise ValueError("unsupported contact bundle version")
|
raise ValueError("unsupported contact bundle version")
|
||||||
issuance_hex = data.get("issuance_pk_hex", "")
|
issuance_hex = data.get("issuance_pk_hex", "")
|
||||||
transport_hex = data.get("transport_pk_hex", "")
|
transport_hex = data.get("transport_pk_hex", "")
|
||||||
@ -98,10 +101,17 @@ def parse_contact_bundle(bundle: str) -> dict:
|
|||||||
raise ValueError("issuance public key must be 32 bytes")
|
raise ValueError("issuance public key must be 32 bytes")
|
||||||
if len(transport) != 32:
|
if len(transport) != 32:
|
||||||
raise ValueError("transport public key must be 32 bytes")
|
raise ValueError("transport public key must be 32 bytes")
|
||||||
return {
|
parsed = {
|
||||||
"issuance_pk_hex": issuance_hex,
|
"issuance_pk_hex": issuance_hex,
|
||||||
"transport_pk_hex": transport_hex,
|
"transport_pk_hex": transport_hex,
|
||||||
}
|
}
|
||||||
|
if version == 2:
|
||||||
|
peer = data.get("peer")
|
||||||
|
if peer is not None and not isinstance(peer, str):
|
||||||
|
raise ValueError("invalid contact bundle peer field")
|
||||||
|
if isinstance(peer, str) and peer.strip():
|
||||||
|
parsed["peer"] = peer.strip()
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
def list_users() -> list[str]:
|
def list_users() -> list[str]:
|
||||||
|
|||||||
322
demo/zkac_admin_serve.py
Normal file
322
demo/zkac_admin_serve.py
Normal file
@ -0,0 +1,322 @@
|
|||||||
|
#!/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()
|
||||||
Loading…
x
Reference in New Issue
Block a user