654 lines
24 KiB
Python
654 lines
24 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
ZKAC opaque file-share server (demo).
|
|
|
|
Headless TCP service that combines:
|
|
|
|
* The same management-channel wire protocol as ``zkac-node serve`` so existing
|
|
``zkac-node registry create/get/update`` commands work unchanged.
|
|
* A new file-share channel that, after BBS+ role authentication, exposes
|
|
bucket primitives. The server only ever sees opaque ciphertext blobs and
|
|
per-recipient encrypted role grants; file names, contents and per-role
|
|
visibility masks are never visible server-side.
|
|
|
|
Run with::
|
|
|
|
uv run python demo/file_share_server.py --host 127.0.0.1 --port 9879
|
|
|
|
The server transport public key is printed at start-up; pin it on each client
|
|
with ``zkac-node server pin <userid> 127.0.0.1:9879 --key <hex>``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import base64
|
|
import json
|
|
import os
|
|
import socket
|
|
import sys
|
|
import threading
|
|
import traceback
|
|
import uuid
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import zkac
|
|
from zkac.tcp import FramedSession, server_handshake_anon
|
|
|
|
|
|
# ── small helpers ────────────────────────────────────────────────────
|
|
|
|
def _b64(data: bytes) -> str:
|
|
return base64.b64encode(data).decode("ascii")
|
|
|
|
|
|
def _unb64(s: str) -> bytes:
|
|
return base64.b64decode(s)
|
|
|
|
|
|
def _chmod(path: Path, mode: int) -> None:
|
|
try:
|
|
os.chmod(path, mode)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def _write_private_json(path: Path, payload: dict) -> None:
|
|
path.write_text(json.dumps(payload, indent=2))
|
|
_chmod(path, 0o600)
|
|
|
|
|
|
def _is_loopback(host: str) -> bool:
|
|
return host.strip().lower() in {"127.0.0.1", "::1", "localhost"}
|
|
|
|
|
|
def _safe_id(value: str) -> str:
|
|
"""Strict allowlist on identifiers used as filesystem names."""
|
|
if not isinstance(value, str) or not value:
|
|
raise ValueError("missing identifier")
|
|
if len(value) > 128 or any(c not in "0123456789abcdefABCDEF-" for c in value):
|
|
raise ValueError(f"invalid identifier {value!r}")
|
|
return value
|
|
|
|
|
|
# ── opaque on-disk store ─────────────────────────────────────────────
|
|
|
|
class _FileShareStore:
|
|
"""Persists registry snapshots and opaque bucket state under one data dir."""
|
|
|
|
def __init__(self, data_dir: Path) -> None:
|
|
self._dir = data_dir
|
|
self._reg_dir = data_dir / "registries"
|
|
self._buckets_dir = data_dir / "buckets"
|
|
for d in (self._dir, self._reg_dir, self._buckets_dir):
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
_chmod(d, 0o700)
|
|
self._lock = threading.Lock()
|
|
|
|
# transport key
|
|
|
|
def load_or_create_keypair(self) -> zkac.Keypair:
|
|
kf = self._dir / "server_key.json"
|
|
if kf.exists():
|
|
data = json.loads(kf.read_text())
|
|
return zkac.Keypair.from_secret_key(_unb64(data["secret_b64"]))
|
|
kp = zkac.Keypair()
|
|
_write_private_json(kf, {
|
|
"secret_b64": _b64(kp.secret_key_bytes()),
|
|
"public_b64": _b64(kp.public_key().to_bytes()),
|
|
})
|
|
return kp
|
|
|
|
# registries (mirrors zkac-node serve so the CLI works unchanged)
|
|
|
|
def save_registry(self, rid_hex: str, state_bytes: bytes, cert_bytes: bytes) -> None:
|
|
with self._lock:
|
|
(self._reg_dir / f"{rid_hex}.state").write_bytes(state_bytes)
|
|
(self._reg_dir / f"{rid_hex}.cert").write_bytes(cert_bytes)
|
|
|
|
def load_all_registries(self, mgr: zkac.RegistryManager) -> int:
|
|
n = 0
|
|
for p in sorted(self._reg_dir.glob("*.state")):
|
|
cert = self._reg_dir / f"{p.stem}.cert"
|
|
if not cert.exists():
|
|
continue
|
|
try:
|
|
mgr.restore(p.read_bytes(), cert.read_bytes())
|
|
n += 1
|
|
except Exception as exc:
|
|
print(f"[fs-server] skip registry {p.stem}: {exc}")
|
|
return n
|
|
|
|
# buckets
|
|
|
|
def _bucket_dir(self, bucket_id: str) -> Path:
|
|
return self._buckets_dir / _safe_id(bucket_id)
|
|
|
|
def _bucket_meta_path(self, bucket_id: str) -> Path:
|
|
return self._bucket_dir(bucket_id) / "meta.json"
|
|
|
|
def bucket_create(self, bucket_id: str, owner_registry_id_hex: str) -> None:
|
|
bd = self._bucket_dir(bucket_id)
|
|
with self._lock:
|
|
if bd.exists():
|
|
raise RuntimeError("bucket already exists")
|
|
(bd / "blobs").mkdir(parents=True)
|
|
(bd / "role_grants").mkdir()
|
|
_chmod(bd, 0o700)
|
|
_write_private_json(self._bucket_meta_path(bucket_id), {
|
|
"bucket_id": bucket_id,
|
|
"owner_registry_id": owner_registry_id_hex,
|
|
"finalized": False,
|
|
"role_acl": {}, # role_id_hex -> {"version": int, "allowed_blob_ids": [blob_id...]}
|
|
})
|
|
|
|
def bucket_meta(self, bucket_id: str) -> dict:
|
|
return json.loads(self._bucket_meta_path(bucket_id).read_text())
|
|
|
|
def _bucket_require_owner(self, bucket_id: str, registry_id_hex: str) -> dict:
|
|
meta = self.bucket_meta(bucket_id)
|
|
if meta.get("owner_registry_id") != registry_id_hex:
|
|
raise RuntimeError("not bucket owner")
|
|
return meta
|
|
|
|
def bucket_set_finalized(self, bucket_id: str, registry_id_hex: str, finalized: bool) -> None:
|
|
with self._lock:
|
|
meta = self._bucket_require_owner(bucket_id, registry_id_hex)
|
|
meta["finalized"] = bool(finalized)
|
|
_write_private_json(self._bucket_meta_path(bucket_id), meta)
|
|
|
|
def bucket_delete(self, bucket_id: str, registry_id_hex: str) -> None:
|
|
with self._lock:
|
|
self._bucket_require_owner(bucket_id, registry_id_hex)
|
|
bd = self._bucket_dir(bucket_id)
|
|
for sub in ("blobs", "role_grants"):
|
|
d = bd / sub
|
|
if d.is_dir():
|
|
for p in d.iterdir():
|
|
try:
|
|
p.unlink()
|
|
except OSError:
|
|
pass
|
|
try:
|
|
d.rmdir()
|
|
except OSError:
|
|
pass
|
|
try:
|
|
self._bucket_meta_path(bucket_id).unlink()
|
|
except OSError:
|
|
pass
|
|
try:
|
|
bd.rmdir()
|
|
except OSError:
|
|
pass
|
|
|
|
def bucket_put_blob(
|
|
self,
|
|
bucket_id: str,
|
|
registry_id_hex: str,
|
|
blob_id: str,
|
|
ciphertext: bytes,
|
|
) -> None:
|
|
with self._lock:
|
|
self._bucket_require_owner(bucket_id, registry_id_hex)
|
|
(self._bucket_dir(bucket_id) / "blobs" / _safe_id(blob_id)).write_bytes(ciphertext)
|
|
|
|
def bucket_put_role_grant(
|
|
self,
|
|
bucket_id: str,
|
|
registry_id_hex: str,
|
|
recipient_pk_hex: str,
|
|
role_id_hex: str,
|
|
acl_version: int,
|
|
eph_pk_b64: str,
|
|
ciphertext_b64: str,
|
|
) -> None:
|
|
with self._lock:
|
|
self._bucket_require_owner(bucket_id, registry_id_hex)
|
|
_safe_id(recipient_pk_hex)
|
|
payload = {
|
|
"bucket_id": bucket_id,
|
|
"role_id_hex": _safe_id(role_id_hex),
|
|
"acl_version": int(acl_version),
|
|
"eph_pk_b64": eph_pk_b64,
|
|
"ciphertext_b64": ciphertext_b64,
|
|
}
|
|
target = self._bucket_dir(bucket_id) / "role_grants" / f"{recipient_pk_hex}.json"
|
|
_write_private_json(target, payload)
|
|
|
|
def bucket_set_role_acl(
|
|
self,
|
|
bucket_id: str,
|
|
registry_id_hex: str,
|
|
role_id_hex: str,
|
|
allowed_blob_ids: list[str],
|
|
) -> None:
|
|
with self._lock:
|
|
meta = self._bucket_require_owner(bucket_id, registry_id_hex)
|
|
role_acl = dict(meta.get("role_acl", {}))
|
|
key = _safe_id(role_id_hex)
|
|
prev = role_acl.get(key, {})
|
|
prev_version = int(prev.get("version", 0)) if isinstance(prev, dict) else 0
|
|
role_acl[key] = {
|
|
"version": prev_version + 1,
|
|
"allowed_blob_ids": [_safe_id(b) for b in allowed_blob_ids],
|
|
}
|
|
meta["role_acl"] = role_acl
|
|
_write_private_json(self._bucket_meta_path(bucket_id), meta)
|
|
|
|
def bucket_get_blob(self, bucket_id: str, blob_id: str) -> bytes:
|
|
return (self._bucket_dir(bucket_id) / "blobs" / _safe_id(blob_id)).read_bytes()
|
|
|
|
def bucket_blob_allowed_for_role(self, bucket_id: str, role_id_hex: str, blob_id: str) -> bool:
|
|
meta = self.bucket_meta(bucket_id)
|
|
role_acl = meta.get("role_acl", {})
|
|
role_meta = role_acl.get(_safe_id(role_id_hex))
|
|
if not isinstance(role_meta, dict):
|
|
return False
|
|
allowed = role_meta.get("allowed_blob_ids")
|
|
if not isinstance(allowed, list):
|
|
return False
|
|
return _safe_id(blob_id) in {str(v) for v in allowed}
|
|
|
|
def bucket_role_acl(self, bucket_id: str, role_id_hex: str) -> dict:
|
|
meta = self.bucket_meta(bucket_id)
|
|
role_acl = meta.get("role_acl", {})
|
|
role_meta = role_acl.get(_safe_id(role_id_hex), {})
|
|
if not isinstance(role_meta, dict):
|
|
return {"version": 0, "allowed_blob_ids": []}
|
|
version = int(role_meta.get("version", 0))
|
|
allowed = role_meta.get("allowed_blob_ids", [])
|
|
if not isinstance(allowed, list):
|
|
allowed = []
|
|
return {
|
|
"version": version,
|
|
"allowed_blob_ids": [_safe_id(str(v)) for v in allowed],
|
|
}
|
|
|
|
def bucket_get_role_grant(self, bucket_id: str, recipient_pk_hex: str) -> dict:
|
|
path = self._bucket_dir(bucket_id) / "role_grants" / f"{_safe_id(recipient_pk_hex)}.json"
|
|
return json.loads(path.read_text())
|
|
|
|
def buckets_for_recipient(self, recipient_pk_hex: str) -> list[str]:
|
|
"""Bucket ids that have an encrypted role grant addressed to ``recipient_pk_hex``."""
|
|
_safe_id(recipient_pk_hex)
|
|
out: list[str] = []
|
|
for bd in sorted(self._buckets_dir.iterdir()):
|
|
grant = bd / "role_grants" / f"{recipient_pk_hex}.json"
|
|
if grant.is_file():
|
|
out.append(bd.name)
|
|
return out
|
|
|
|
def buckets_owned_by(self, registry_id_hex: str) -> list[str]:
|
|
out: list[str] = []
|
|
for bd in sorted(self._buckets_dir.iterdir()):
|
|
meta = bd / "meta.json"
|
|
if not meta.is_file():
|
|
continue
|
|
try:
|
|
if json.loads(meta.read_text()).get("owner_registry_id") == registry_id_hex:
|
|
out.append(bd.name)
|
|
except (OSError, json.JSONDecodeError):
|
|
continue
|
|
return out
|
|
|
|
|
|
# ── command dispatch (inside encrypted session) ──────────────────────
|
|
|
|
def _dispatch_mgmt(
|
|
cmd: dict,
|
|
mgr: zkac.RegistryManager,
|
|
store: _FileShareStore,
|
|
server_pk_b64: str,
|
|
transcript_hash: bytes,
|
|
) -> dict:
|
|
"""Registry management commands, wire-compatible with ``zkac-node`` CLI."""
|
|
try:
|
|
action = cmd.get("cmd")
|
|
rid_hex = cmd.get("auth_registry_id")
|
|
admin_proof_b64 = cmd.get("admin_proof_b64")
|
|
|
|
def _require_admin_for(target_rid_hex: str) -> None:
|
|
if rid_hex != target_rid_hex:
|
|
raise RuntimeError("auth_registry_id must match command registry_id")
|
|
if not isinstance(admin_proof_b64, str) or not admin_proof_b64:
|
|
raise RuntimeError("missing admin_proof_b64")
|
|
if not mgr.verify_admin(
|
|
bytes.fromhex(target_rid_hex),
|
|
_unb64(admin_proof_b64),
|
|
transcript_hash,
|
|
):
|
|
raise RuntimeError("admin authorization failed")
|
|
|
|
if action == "server_info":
|
|
return {"ok": True, "server_public_key_b64": server_pk_b64}
|
|
|
|
if action == "create_registry":
|
|
state_bytes = _unb64(cmd["state_bytes_b64"])
|
|
state_cert = _unb64(cmd["state_cert_b64"])
|
|
auth_rid = cmd.get("auth_registry_id")
|
|
if not isinstance(auth_rid, str):
|
|
raise RuntimeError("missing auth_registry_id")
|
|
if not isinstance(admin_proof_b64, str) or not admin_proof_b64:
|
|
raise RuntimeError("missing admin_proof_b64")
|
|
tmp_mgr = zkac.RegistryManager()
|
|
expected_rid = tmp_mgr.create(state_bytes, state_cert).hex()
|
|
if expected_rid != auth_rid:
|
|
raise RuntimeError("auth_registry_id does not match certified state")
|
|
if not tmp_mgr.verify_admin(
|
|
bytes.fromhex(expected_rid),
|
|
_unb64(admin_proof_b64),
|
|
transcript_hash,
|
|
):
|
|
raise RuntimeError("admin authorization failed for create_registry")
|
|
rid = mgr.create(state_bytes, state_cert)
|
|
store.save_registry(rid.hex(), state_bytes, state_cert)
|
|
return {"ok": True, "registry_id": rid.hex()}
|
|
|
|
if action == "get_registry":
|
|
rid_hex_cmd = cmd["registry_id"]
|
|
_require_admin_for(rid_hex_cmd)
|
|
rid = bytes.fromhex(rid_hex_cmd)
|
|
state_bytes, state_cert = mgr.get(rid)
|
|
return {
|
|
"ok": True,
|
|
"state_bytes_b64": _b64(state_bytes),
|
|
"state_cert_b64": _b64(state_cert),
|
|
}
|
|
|
|
if action == "update_registry":
|
|
rid_hex_cmd = cmd["registry_id"]
|
|
_require_admin_for(rid_hex_cmd)
|
|
rid = bytes.fromhex(rid_hex_cmd)
|
|
state_bytes = _unb64(cmd["state_bytes_b64"])
|
|
state_cert = _unb64(cmd["state_cert_b64"])
|
|
mgr.update(rid, state_bytes, state_cert)
|
|
store.save_registry(rid_hex_cmd, state_bytes, state_cert)
|
|
return {"ok": True}
|
|
|
|
return {"error": f"unknown command: {action}"}
|
|
|
|
except Exception as exc:
|
|
return {"error": str(exc)}
|
|
|
|
|
|
def _dispatch_fs(
|
|
cmd: dict,
|
|
store: _FileShareStore,
|
|
ctx: dict,
|
|
) -> dict:
|
|
"""File-share commands authenticated via ``ctx`` (registry_id, role_id, issuance_pk_hex)."""
|
|
try:
|
|
action = cmd.get("cmd")
|
|
registry_id_hex: str = ctx["registry_id_hex"]
|
|
role_id: bytes = ctx["role_id"]
|
|
issuance_pk_hex: str = ctx["issuance_pk_hex"]
|
|
is_admin = role_id == zkac.admin_role_id()
|
|
|
|
def _require_admin() -> None:
|
|
if not is_admin:
|
|
raise RuntimeError("admin role required for this command")
|
|
|
|
if action == "whoami":
|
|
return {
|
|
"ok": True,
|
|
"registry_id": registry_id_hex,
|
|
"role_id": role_id.hex(),
|
|
"is_admin": is_admin,
|
|
"issuance_pk_hex": issuance_pk_hex,
|
|
}
|
|
|
|
if action == "bucket_create":
|
|
_require_admin()
|
|
bid = cmd.get("bucket_id") or uuid.uuid4().hex
|
|
store.bucket_create(bid, registry_id_hex)
|
|
return {"ok": True, "bucket_id": bid}
|
|
|
|
if action == "bucket_put_blob":
|
|
_require_admin()
|
|
bid = cmd["bucket_id"]
|
|
blob_id = cmd["blob_id"]
|
|
ciphertext = _unb64(cmd["ciphertext_b64"])
|
|
store.bucket_put_blob(bid, registry_id_hex, blob_id, ciphertext)
|
|
return {"ok": True}
|
|
|
|
if action == "bucket_put_role_grant":
|
|
_require_admin()
|
|
store.bucket_put_role_grant(
|
|
cmd["bucket_id"],
|
|
registry_id_hex,
|
|
cmd["recipient_pk_hex"],
|
|
cmd["role_id_hex"],
|
|
int(cmd["acl_version"]),
|
|
cmd["eph_pk_b64"],
|
|
cmd["ciphertext_b64"],
|
|
)
|
|
return {"ok": True}
|
|
|
|
if action == "bucket_set_role_acl":
|
|
_require_admin()
|
|
store.bucket_set_role_acl(
|
|
cmd["bucket_id"],
|
|
registry_id_hex,
|
|
cmd["role_id_hex"],
|
|
list(cmd.get("allowed_blob_ids", [])),
|
|
)
|
|
return {"ok": True}
|
|
|
|
if action == "bucket_get_role_acl":
|
|
_require_admin()
|
|
role_acl = store.bucket_role_acl(cmd["bucket_id"], cmd["role_id_hex"])
|
|
return {"ok": True, **role_acl}
|
|
|
|
if action == "bucket_finalize":
|
|
_require_admin()
|
|
store.bucket_set_finalized(cmd["bucket_id"], registry_id_hex, True)
|
|
return {"ok": True}
|
|
|
|
if action == "bucket_delete":
|
|
_require_admin()
|
|
store.bucket_delete(cmd["bucket_id"], registry_id_hex)
|
|
return {"ok": True}
|
|
|
|
if action == "bucket_list_owned":
|
|
_require_admin()
|
|
return {"ok": True, "bucket_ids": store.buckets_owned_by(registry_id_hex)}
|
|
|
|
if action == "fs_buckets":
|
|
return {"ok": True, "bucket_ids": store.buckets_for_recipient(issuance_pk_hex)}
|
|
|
|
if action == "fs_get_role_grant":
|
|
bid = cmd["bucket_id"]
|
|
grant = store.bucket_get_role_grant(bid, issuance_pk_hex)
|
|
if not is_admin:
|
|
expected_role = _safe_id(role_id.hex())
|
|
granted_role_raw = grant.get("role_id_hex")
|
|
if not isinstance(granted_role_raw, str) or not granted_role_raw:
|
|
raise RuntimeError("permissions updated; role grant is outdated, request a fresh grant")
|
|
granted_role = _safe_id(granted_role_raw)
|
|
if granted_role != expected_role:
|
|
raise RuntimeError("role grant not valid for authenticated role")
|
|
current_acl = store.bucket_role_acl(bid, expected_role)
|
|
acl_version = grant.get("acl_version")
|
|
if not isinstance(acl_version, int):
|
|
raise RuntimeError("permissions updated; role grant is outdated, request a fresh grant")
|
|
if acl_version != int(current_acl.get("version", -2)):
|
|
raise RuntimeError("permissions updated; request a fresh role grant")
|
|
return {"ok": True, **grant}
|
|
|
|
if action == "fs_get_blob":
|
|
bid = cmd["bucket_id"]
|
|
blob_id = cmd["blob_id"]
|
|
if not is_admin and not store.bucket_blob_allowed_for_role(bid, role_id.hex(), blob_id):
|
|
raise RuntimeError("blob access denied by role mask")
|
|
data = store.bucket_get_blob(bid, blob_id)
|
|
return {"ok": True, "ciphertext_b64": _b64(data)}
|
|
|
|
return {"error": f"unknown command: {action}"}
|
|
|
|
except Exception as exc:
|
|
return {"error": str(exc)}
|
|
|
|
|
|
# ── per-connection handler ────────────────────────────────────────────
|
|
|
|
def _handle_conn(
|
|
conn: socket.socket,
|
|
addr: tuple,
|
|
node: zkac.Node,
|
|
mgr: zkac.RegistryManager,
|
|
store: _FileShareStore,
|
|
server_pk_b64: str,
|
|
idle_timeout_s: float,
|
|
slots: threading.BoundedSemaphore,
|
|
) -> None:
|
|
peer = f"{addr[0]}:{addr[1]}"
|
|
try:
|
|
conn.settimeout(idle_timeout_s)
|
|
session = server_handshake_anon(conn, node)
|
|
framed = FramedSession(conn, session)
|
|
transcript_hash = bytes(session.transcript_hash())
|
|
|
|
hello = json.loads(framed.recv())
|
|
op = hello.get("op")
|
|
|
|
if op == "mgmt":
|
|
while True:
|
|
try:
|
|
cmd = json.loads(framed.recv())
|
|
except (ConnectionError, OSError):
|
|
break
|
|
resp = _dispatch_mgmt(cmd, mgr, store, server_pk_b64, transcript_hash)
|
|
framed.send(json.dumps(resp).encode())
|
|
return
|
|
|
|
if op == "fs":
|
|
try:
|
|
registry_id = bytes.fromhex(hello["registry_id"])
|
|
role_id = bytes.fromhex(hello["role_id"])
|
|
proof = _unb64(hello["bbs_auth_b64"])
|
|
issuance_pk_hex = hello["issuance_pk_hex"]
|
|
_safe_id(issuance_pk_hex)
|
|
except (KeyError, ValueError) as exc:
|
|
framed.send(json.dumps({"error": f"invalid fs hello: {exc}"}).encode())
|
|
return
|
|
if role_id == zkac.admin_role_id():
|
|
ok = mgr.verify_admin(registry_id, proof, transcript_hash)
|
|
else:
|
|
ok = mgr.verify_presentation(registry_id, role_id, proof, transcript_hash)
|
|
if not ok:
|
|
framed.send(json.dumps({"error": "auth failed"}).encode())
|
|
return
|
|
framed.send(json.dumps({"ok": True, "status": "authenticated"}).encode())
|
|
ctx = {
|
|
"registry_id_hex": registry_id.hex(),
|
|
"role_id": role_id,
|
|
"issuance_pk_hex": issuance_pk_hex,
|
|
}
|
|
while True:
|
|
try:
|
|
cmd = json.loads(framed.recv())
|
|
except (ConnectionError, OSError):
|
|
break
|
|
resp = _dispatch_fs(cmd, store, ctx)
|
|
framed.send(json.dumps(resp).encode())
|
|
return
|
|
|
|
framed.send(json.dumps({"error": f"unknown op: {op}"}).encode())
|
|
|
|
except (ConnectionError, BrokenPipeError, OSError):
|
|
pass
|
|
except Exception as exc:
|
|
print(f"[fs-server] {peer} error: {exc}")
|
|
traceback.print_exc()
|
|
finally:
|
|
conn.close()
|
|
slots.release()
|
|
|
|
|
|
# ── entry point ──────────────────────────────────────────────────────
|
|
|
|
def serve(
|
|
data_dir: Path,
|
|
host: str = "127.0.0.1",
|
|
port: int = 9879,
|
|
*,
|
|
max_connections: int = 64,
|
|
idle_timeout_s: float = 60.0,
|
|
listen_backlog: int = 64,
|
|
allow_non_loopback: bool = False,
|
|
) -> None:
|
|
data_dir.mkdir(parents=True, exist_ok=True)
|
|
store = _FileShareStore(data_dir)
|
|
kp = store.load_or_create_keypair()
|
|
server_pk_b64 = _b64(kp.public_key().to_bytes())
|
|
pk_hex = kp.public_key().to_bytes().hex()
|
|
node = zkac.Node(kp)
|
|
|
|
mgr = zkac.RegistryManager()
|
|
n = store.load_all_registries(mgr)
|
|
|
|
print(f"[fs-server] data dir: {data_dir}")
|
|
print(f"[fs-server] server transport public key (pin OOB): {pk_hex}")
|
|
print(f"[fs-server] loaded {n} registries")
|
|
print(f"[fs-server] listening on {host}:{port}")
|
|
|
|
if not _is_loopback(host) and not allow_non_loopback:
|
|
raise RuntimeError(
|
|
"refusing to bind outside loopback. "
|
|
"Use --allow-non-loopback only when exposure is intentional."
|
|
)
|
|
if not _is_loopback(host):
|
|
print(f"[fs-server] warning: binding outside loopback: {host}:{port}", file=sys.stderr)
|
|
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
sock.bind((host, port))
|
|
slots = threading.BoundedSemaphore(max_connections)
|
|
sock.listen(listen_backlog)
|
|
try:
|
|
while True:
|
|
conn, addr = sock.accept()
|
|
if not slots.acquire(blocking=False):
|
|
conn.close()
|
|
continue
|
|
threading.Thread(
|
|
target=_handle_conn,
|
|
args=(conn, addr, node, mgr, store, server_pk_b64, idle_timeout_s, slots),
|
|
daemon=True,
|
|
).start()
|
|
except KeyboardInterrupt:
|
|
print("\n[fs-server] shutdown")
|
|
finally:
|
|
sock.close()
|
|
|
|
|
|
def main() -> None:
|
|
p = argparse.ArgumentParser(description="ZKAC opaque file-share server (demo)")
|
|
p.add_argument("--host", default="127.0.0.1")
|
|
p.add_argument("--port", type=int, default=9879)
|
|
p.add_argument(
|
|
"--data-dir",
|
|
type=Path,
|
|
default=Path(__file__).resolve().parent / "fs_data",
|
|
help="server state (transport key, registries, opaque buckets)",
|
|
)
|
|
p.add_argument("--idle-timeout", type=float, default=60.0)
|
|
p.add_argument("--max-connections", type=int, default=64)
|
|
p.add_argument("--allow-non-loopback", action="store_true")
|
|
args = p.parse_args()
|
|
|
|
serve(
|
|
args.data_dir,
|
|
host=args.host,
|
|
port=args.port,
|
|
max_connections=args.max_connections,
|
|
idle_timeout_s=args.idle_timeout,
|
|
allow_non_loopback=args.allow_non_loopback,
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|