i2p n smol fixes

This commit is contained in:
everbarry 2026-05-06 15:01:40 +02:00
parent 3b75d3c6f0
commit 2ede94fa2f
5 changed files with 411 additions and 32 deletions

View File

@ -16,7 +16,7 @@ zkac-node user create alice
zkac-node user create bob
# 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
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)
zkac-node registry create alice localhost:9800 --roles analyst,operator
zkac-node grant alice --server localhost:9800 \
--registry <REGISTRY_ID> --role analyst --to-contact "$BOB_CONTACT" \
--peer 127.0.0.1:9810
--registry <REGISTRY_ID> --role analyst --to "$BOB_CONTACT"
# 4. Bob listens for direct p2p delivery
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 get <id> <server> --registry R` | Fetch registry state |
| `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 |
| `credentials list <id>` | List local credentials |
| `auth <id> --registry R --role X [--server S]` | Authenticated session |
@ -106,6 +105,9 @@ Connectivity sanity check:
```bash
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:

View File

@ -61,6 +61,10 @@ def _proxy_target() -> tuple[str, int] | None:
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:
buf = bytearray()
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:
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))
sock = socket.create_connection(proxy)
try:
@ -111,23 +117,52 @@ def _connect(host: str, port: int) -> socket.socket:
raise
def net_check(target: str, timeout_s: float = 8.0) -> dict:
"""Connectivity diagnostic for direct/server endpoints, with optional SOCKS5."""
def net_check(
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)
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.settimeout(timeout_s)
try:
if proxy is None:
sock.connect((host, port))
via = "direct"
else:
sock.connect(proxy)
_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:
via = "direct" if proxy is None else f"socks5:{proxy[0]}:{proxy[1]}"
return {"ok": False, "target": target, "via": via, "error": str(exc)}
finally:
sock.close()

View File

@ -34,12 +34,14 @@ def _cmd_user_list(_args):
def _cmd_user_show(args):
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" issuance pk: {ident['issuance_pk'].hex()}")
print(f" p2p transport pk: {ident['transport_pk'].hex()}")
print(" share contact:")
print(f" {contact}")
if args.peer:
print(f" contact peer endpoint: {args.peer}")
owned = [
{"registry_id": r, **store.load_admin(args.userid, r)}
@ -109,19 +111,18 @@ def _cmd_registry_list(args):
# ── grant ─────────────────────────────────────────────────────────────
def _cmd_grant(args):
to = 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"]
peer_key = parsed["transport_pk_hex"]
elif not args.to or not args.peer_key:
raise RuntimeError("grant requires --to and --peer-key (or --to-contact)")
parsed = store.parse_contact_bundle(args.to)
to = parsed["issuance_pk_hex"]
peer_key = parsed["transport_pk_hex"]
peer = parsed.get("peer")
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(
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" delivery: direct p2p ({result['peer']})")
@ -158,11 +159,19 @@ def _cmd_auth(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"):
print("network check: ok")
print(f" target: {resp['target']}")
print(f" via: {resp['via']}")
if args.handshake:
print(" handshake: ok")
return
print("network check: failed")
print(f" target: {resp['target']}")
@ -190,6 +199,7 @@ def main():
c = user_sub.add_parser("show", help="show user keys + registries + credentials")
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)
# serve
@ -243,10 +253,7 @@ def main():
c.add_argument("--server", required=True, help="host:port")
c.add_argument("--registry", required=True)
c.add_argument("--role", required=True)
c.add_argument("--to", default=None, help="recipient issuance public key (hex)")
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.add_argument("--to", required=True, help="recipient contact bundle")
c.set_defaults(func=_cmd_grant)
# credentials
@ -277,6 +284,9 @@ def main():
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("--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)
args = p.parse_args()

View File

@ -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."""
ident = load_identity(userid)
payload = {
"v": 1,
"v": 2,
"issuance_pk_hex": ident["issuance_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")
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
@ -83,7 +85,8 @@ def parse_contact_bundle(bundle: str) -> dict:
except Exception as 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")
issuance_hex = data.get("issuance_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")
if len(transport) != 32:
raise ValueError("transport public key must be 32 bytes")
return {
parsed = {
"issuance_pk_hex": issuance_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]:

322
demo/zkac_admin_serve.py Normal file
View 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()