stage-fed-f federation: CLI commands
This commit is contained in:
135
src/psyc/_federation_cli.py
Normal file
135
src/psyc/_federation_cli.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""Federation CLI — keygen, DNS records, feed export, peer registry, verify.
|
||||
|
||||
Registered onto the top-level Typer app from cli.py so the surface stays flat.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
import typer
|
||||
|
||||
from psyc import db, log
|
||||
from psyc.lines import federation
|
||||
from psyc.result import Err
|
||||
|
||||
|
||||
_log = log.get(__name__)
|
||||
|
||||
|
||||
def register(typer_app: typer.Typer) -> None:
|
||||
"""Mount all `fed-*` commands onto `typer_app`."""
|
||||
|
||||
@typer_app.command("fed-keygen")
|
||||
def fed_keygen() -> None:
|
||||
"""Generate the node's Ed25519 keypair (or load existing). Prints fingerprint."""
|
||||
federation.node_keypair() # creates the files if missing
|
||||
typer.echo(federation.node_fingerprint())
|
||||
|
||||
@typer_app.command("fed-dns")
|
||||
def fed_dns(
|
||||
domain: str = typer.Argument(..., help="public domain to advertise this node on"),
|
||||
port: int = typer.Option(443, "--port", help="port psyc is reachable on"),
|
||||
) -> None:
|
||||
"""Print the DNS SRV + TXT records to publish under `domain`."""
|
||||
rec = federation.dns_record(domain, port=port)
|
||||
typer.echo(rec.human_instructions)
|
||||
|
||||
@typer_app.command("fed-feed")
|
||||
def fed_feed(
|
||||
window_hours: int = typer.Option(24, "--hours", help="lookback window (hours)"),
|
||||
) -> None:
|
||||
"""Build + print the signed feed JSON."""
|
||||
db.init_db()
|
||||
payload = federation.build_signed_feed(window_hours=window_hours)
|
||||
typer.echo(json.dumps(payload, indent=2))
|
||||
|
||||
@typer_app.command("fed-verify")
|
||||
def fed_verify(
|
||||
peer_url: str = typer.Argument(..., help="peer base URL, e.g. https://peer.example"),
|
||||
) -> None:
|
||||
"""Fetch a peer's /federation/{info,key,feed} and verify the signature."""
|
||||
peer_url = peer_url.rstrip("/")
|
||||
try:
|
||||
with httpx.Client(timeout=10.0) as client:
|
||||
info = client.get(f"{peer_url}/federation/info").json()
|
||||
key_text = client.get(f"{peer_url}/federation/key").text
|
||||
feed = client.get(f"{peer_url}/federation/feed").json()
|
||||
except Exception as exc:
|
||||
typer.echo(f"error: fetch failed: {exc}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# If the peer is already in the registry, prefer the stored pubkey
|
||||
# (TOFU pin); otherwise warn and use the freshly fetched one.
|
||||
declared_fp = info.get("fingerprint", "")
|
||||
pubkey_pem = key_text
|
||||
pinned = None
|
||||
for p in federation.list_peers():
|
||||
if p.fingerprint == declared_fp:
|
||||
pinned = p
|
||||
break
|
||||
if pinned:
|
||||
pubkey_pem = pinned.pubkey_pem
|
||||
typer.echo(f" · using pinned pubkey for {pinned.domain}")
|
||||
else:
|
||||
typer.echo(" · WARNING: no pinned pubkey for this peer — trusting fetched key (TOFU)")
|
||||
|
||||
db.init_db()
|
||||
result = federation.import_signed_feed(feed, pubkey_pem)
|
||||
if isinstance(result, Err):
|
||||
typer.echo(f" ✗ verification failed: {result.reason}", err=True)
|
||||
raise typer.Exit(1)
|
||||
s = result.value
|
||||
typer.echo(f" ✓ verified peer {s.peer_fingerprint}")
|
||||
typer.echo(f" cases: {s.cases_seen} iocs: {s.iocs_seen} signals buffered: {len(s.signal_ids)}")
|
||||
|
||||
@typer_app.command("fed-peer-add")
|
||||
def fed_peer_add(
|
||||
domain: str = typer.Argument(..., help="peer's public domain"),
|
||||
fingerprint: str = typer.Argument(..., help="peer's 32-hex fingerprint"),
|
||||
pubkey_file: Path = typer.Option(..., "--pubkey-file", help="path to peer's PEM public key"),
|
||||
status: str = typer.Option("unknown", "--status", help="unknown | trusted | blocked"),
|
||||
) -> None:
|
||||
"""Register a peer's identity in the local registry."""
|
||||
db.init_db()
|
||||
pem = pubkey_file.read_text(encoding="utf-8")
|
||||
federation.register_peer(domain, fingerprint, pem, status=status)
|
||||
typer.echo(f"registered peer {domain} ({fingerprint[:8]}…) status={status}")
|
||||
|
||||
@typer_app.command("fed-peer-list")
|
||||
def fed_peer_list() -> None:
|
||||
"""List all registered peers."""
|
||||
db.init_db()
|
||||
rows = federation.list_peers()
|
||||
if not rows:
|
||||
typer.echo("(no peers registered)")
|
||||
return
|
||||
for p in rows:
|
||||
typer.echo(
|
||||
f" {p.status:8s} {p.domain:30s} {p.fingerprint[:8]}…{p.fingerprint[-8:]}"
|
||||
f" last_seen={(p.last_seen or '—')[:16]}"
|
||||
)
|
||||
|
||||
@typer_app.command("fed-peer-trust")
|
||||
def fed_peer_trust(domain: str = typer.Argument(...)) -> None:
|
||||
"""Mark a peer as trusted — their signals count toward quorum."""
|
||||
db.init_db()
|
||||
federation.set_peer_status(domain, "trusted")
|
||||
typer.echo(f"{domain} → trusted")
|
||||
|
||||
@typer_app.command("fed-peer-block")
|
||||
def fed_peer_block(domain: str = typer.Argument(...)) -> None:
|
||||
"""Block a peer — ignore their feeds."""
|
||||
db.init_db()
|
||||
federation.set_peer_status(domain, "blocked")
|
||||
typer.echo(f"{domain} → blocked")
|
||||
|
||||
@typer_app.command("fed-peer-remove")
|
||||
def fed_peer_remove(domain: str = typer.Argument(...)) -> None:
|
||||
"""Drop a peer from the registry."""
|
||||
db.init_db()
|
||||
federation.remove_peer(domain)
|
||||
typer.echo(f"removed {domain}")
|
||||
Reference in New Issue
Block a user