From 2ede94fa2f71c671915d97b4524293b2b12cdb6b Mon Sep 17 00:00:00 2001 From: everbarry Date: Wed, 6 May 2026 15:01:40 +0200 Subject: [PATCH] i2p n smol fixes --- cli/README.md | 10 +- cli/zkac_cli/client.py | 49 +++++- cli/zkac_cli/main.py | 44 +++--- cli/zkac_cli/store.py | 18 ++- demo/zkac_admin_serve.py | 322 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 411 insertions(+), 32 deletions(-) create mode 100644 demo/zkac_admin_serve.py diff --git a/cli/README.md b/cli/README.md index a7ddd34..c4e28a3 100644 --- a/cli/README.md +++ b/cli/README.md @@ -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 # 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 --role analyst --to-contact "$BOB_CONTACT" \ - --peer 127.0.0.1:9810 + --registry --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 --role analyst --server localhost:98 | `registry update --registry R --add-roles …` | Add roles | | `registry get --registry R` | Fetch registry state | | `registry list ` | List registries this user owns locally | -| `grant --server S --registry R --role X --to-contact --peer host:port` | Admin direct p2p grant (single-share recipient contact) | +| `grant --server S --registry R --role X --to ` | Admin direct p2p grant (recipient contact bundle must include peer endpoint) | | `p2p-listen [--host H --port P]` | Receive one direct p2p grant and store credential | | `credentials list ` | List local credentials | | `auth --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 +# or use an existing pin: +zkac-node net check exampledestination.b32.i2p:9800 --handshake --userid alice ``` With proxy: diff --git a/cli/zkac_cli/client.py b/cli/zkac_cli/client.py index efd1c3f..3ed3c80 100644 --- a/cli/zkac_cli/client.py +++ b/cli/zkac_cli/client.py @@ -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 or --userid ", + } + 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() diff --git a/cli/zkac_cli/main.py b/cli/zkac_cli/main.py index 0888f63..cae37e8 100644 --- a/cli/zkac_cli/main.py +++ b/cli/zkac_cli/main.py @@ -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 --peer " + ) 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() diff --git a/cli/zkac_cli/store.py b/cli/zkac_cli/store.py index 1b78f81..2fae7ca 100644 --- a/cli/zkac_cli/store.py +++ b/cli/zkac_cli/store.py @@ -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]: diff --git a/demo/zkac_admin_serve.py b/demo/zkac_admin_serve.py new file mode 100644 index 0000000..6a232a5 --- /dev/null +++ b/demo/zkac_admin_serve.py @@ -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 = """ + + + + + {% if refresh_s %} + + {% endif %} + ZKAC node — admin debug + + + +

ZKAC node — admin debug

+

+ User {{ snap.userid }} · TCP + {{ snap.listen.host }}:{{ snap.listen.port }} + · uptime {{ snap.uptime_s }}s + {% if refresh_s %} + · auto-refresh {{ refresh_s }}s + {% endif %} +

+ +
+
+

Status

+ + + + + + + +
Data directory{{ snap.data_dir }}
Started (wall){{ snap.started_wall }}
Server public key{{ snap.server_public_key_hex or "—" }}
Registries (boot){{ snap.registries_loaded_boot }}
Active TCP sessions + {{ snap.active_connection_count }} live +
Processpid={{ proc.pid }} threads={{ proc.threads }}
+
+ +
+

Server key file

+
{{ sk_meta | tojson(indent=2) }}
+
+ +
+

Registries (on disk + parsed)

+ {% if reg %} + + + + + + + + + {% for r in reg %} + + + + + + + + {% endfor %} +
ID (file)versionstate hashbytes (state / cert)parse
{{ r.file_registry_id_hex[:16] }}…{{ r.get("version", "—") }}{% if r.get("state_hash_hex") %}{{ r.state_hash_hex[:24] }}…{% else %}—{% endif %}{{ r.state_bytes }} / {{ r.cert_bytes }}{% if r.parsed_ok %}ok{% else %}fail{% endif %}
+ {% else %} +

No registry state files yet.

+ {% endif %} +
+ +
+

Session connections (live)

+ {% if snap.active_connections %} + + + + + + {% for c in snap.active_connections %} + + + + + + + + + + + + {% endfor %} +
idpeerphaseoptranscriptauth registryrolemgmt #echo bytes
{{ c.id }}{{ c.peer }}{{ c.phase }}{{ c.hello_op or "—" }}{% if c.transcript_hash_hex %}{{ c.transcript_hash_hex[:20] }}…{% else %}—{% endif %}{% if c.auth_registry_hex %}{{ c.auth_registry_hex[:16] }}…{% else %}—{% endif %}{% if c.auth_role_hex %}{{ c.auth_role_hex[:16] }}…{% else %}—{% endif %}{{ c.mgmt_commands or 0 }}{{ c.bytes_echoed or 0 }}
+ {% else %} +

No active connections.

+ {% endif %} +
+ +
+

Recent connections

+ {% if snap.recent_connections %} + + + + + {% for c in snap.recent_connections %} + + + + + + + + + {% endfor %} +
idpeerphasemgmt #echo byteserror
{{ c.id }}{{ c.peer }}{{ c.phase }}{{ c.mgmt_commands or 0 }}{{ c.bytes_echoed or 0 }}{{ c.error or "—" }}
+ {% else %} +

No recent disconnects recorded.

+ {% endif %} +
+ +
+

Data directory tree (debug)

+
{{ files | tojson(indent=2) }}
+
+ +
+

Full debug JSON

+

Machine-readable snapshot (same as /api/debug.json).

+
{{ full_json }}
+
+
+ + + + +""" + + +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//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()