diff --git a/src/psyc/_federation_cli.py b/src/psyc/_federation_cli.py index e01bbe6..2d40b8c 100644 --- a/src/psyc/_federation_cli.py +++ b/src/psyc/_federation_cli.py @@ -7,14 +7,14 @@ from __future__ import annotations import json from pathlib import Path -from typing import Optional +from typing import List, Optional import httpx import typer from psyc import db, log -from psyc.lines import federation -from psyc.result import Err +from psyc.lines import discovery, federation, pulse +from psyc.result import Err, Ok _log = log.get(__name__) @@ -133,3 +133,80 @@ def register(typer_app: typer.Typer) -> None: db.init_db() federation.remove_peer(domain) typer.echo(f"removed {domain}") + + # ---------- discovery (DNS-SD walker) ---------------------------------- + + @typer_app.command("fed-resolve") + def fed_resolve( + domain: str = typer.Argument(..., help="domain to look up via _psyc._tcp."), + timeout: float = typer.Option(5.0, "--timeout", help="DNS lookup timeout, seconds"), + ) -> None: + """Resolve a domain's psyc DNS-SD record. Prints fingerprint + port.""" + result = discovery.resolve_psyc(domain, timeout=timeout) + if isinstance(result, Err): + typer.echo(f"error: {result.reason}", err=True) + raise typer.Exit(1) + c = result.value + typer.echo(f" domain {c.domain}") + typer.echo(f" fingerprint {c.fingerprint}") + typer.echo(f" port {c.port}") + typer.echo(f" source {c.source}") + + @typer_app.command("fed-walk") + def fed_walk( + seeds: List[str] = typer.Argument(..., help="one or more seed domains"), + depth: int = typer.Option(2, "--depth", help="max BFS depth"), + max_peers: int = typer.Option(200, "--max-peers", help="cap discovered candidates"), + record: bool = typer.Option(False, "--record", help="persist candidates as status=unknown"), + ) -> None: + """Walk DNS-SD + peer-public from `seeds`. Prints discovered table.""" + db.init_db() + cands = discovery.walk(seeds, max_depth=depth, max_peers=max_peers) + if not cands: + typer.echo("(no candidates discovered)") + return + typer.echo(f"{'domain':<32} {'fingerprint':<24} {'port':>5} source") + for c in cands: + fp = f"{c.fingerprint[:8]}…{c.fingerprint[-8:]}" + typer.echo(f"{c.domain:<32} {fp:<24} {c.port:>5} {c.source}") + if record: + for c in cands: + discovery.record_candidate(c) + typer.echo(f"recorded {len(cands)} candidate(s) into peers table") + + @typer_app.command("fed-seeds-list") + def fed_seeds_list() -> None: + """Print the operator-curated discovery seed list.""" + db.init_db() + seeds = pulse.get_discovery_seeds() + if not seeds: + typer.echo("(no seeds configured)") + return + for s in seeds: + typer.echo(s) + + @typer_app.command("fed-seeds-add") + def fed_seeds_add(domain: str = typer.Argument(...)) -> None: + """Append a seed domain (no-op if already present).""" + db.init_db() + seeds = pulse.get_discovery_seeds() + d = domain.strip() + if d in seeds: + typer.echo(f"{d} already a seed") + return + seeds.append(d) + pulse.set_discovery_seeds(seeds) + typer.echo(f"added seed {d}") + + @typer_app.command("fed-seeds-remove") + def fed_seeds_remove(domain: str = typer.Argument(...)) -> None: + """Remove a seed domain (no-op if absent).""" + db.init_db() + seeds = pulse.get_discovery_seeds() + d = domain.strip() + if d not in seeds: + typer.echo(f"{d} not in seeds") + return + seeds = [s for s in seeds if s != d] + pulse.set_discovery_seeds(seeds) + typer.echo(f"removed seed {d}") diff --git a/src/psyc/cockpit/federation_routes.py b/src/psyc/cockpit/federation_routes.py index b7d5f8c..ba7e4c2 100644 --- a/src/psyc/cockpit/federation_routes.py +++ b/src/psyc/cockpit/federation_routes.py @@ -15,7 +15,7 @@ from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, Red from fastapi.templating import Jinja2Templates from psyc import db, log -from psyc.lines import discovery, federation +from psyc.lines import discovery, federation, pulse _log = log.get(__name__) @@ -115,6 +115,54 @@ def register(app: FastAPI, TEMPLATES: Jinja2Templates) -> None: federation.remove_peer(domain) return RedirectResponse("/admin/federation", status_code=303) + # ---------- discovery (DNS-SD walker) ---------------------------- + + @app.get("/admin/federation/discovery", response_class=HTMLResponse) + def admin_discovery(request: Request) -> HTMLResponse: + if not _admin_ok(request): + return RedirectResponse("/admin", status_code=303) + seeds = pulse.get_discovery_seeds() + candidates = federation.list_peers() + flash = request.query_params.get("flash") or "" + return TEMPLATES.TemplateResponse( + request, + "admin_discovery.html", + { + "seeds": seeds, + "seeds_text": "\n".join(seeds), + "candidates": candidates, + "flash": flash, + }, + ) + + @app.post("/admin/federation/discovery/seeds") + def admin_discovery_seeds( + request: Request, + seeds: str = Form(""), + ) -> RedirectResponse: + if not _admin_ok(request): + raise HTTPException(status_code=403, detail="admin session required") + lines = [line for line in seeds.splitlines()] + pulse.set_discovery_seeds(lines) + return RedirectResponse("/admin/federation/discovery?flash=seeds+saved", status_code=303) + + @app.post("/admin/federation/discovery/walk") + def admin_discovery_walk(request: Request) -> RedirectResponse: + if not _admin_ok(request): + raise HTTPException(status_code=403, detail="admin session required") + seeds = pulse.get_discovery_seeds() + if not seeds: + return RedirectResponse("/admin/federation/discovery?flash=no+seeds+configured", status_code=303) + try: + cands = discovery.walk(seeds) + for c in cands: + discovery.record_candidate(c) + msg = f"discovered+{len(cands)}+candidates+from+{len(seeds)}+seed(s)" + except Exception as exc: # noqa: BLE001 — surface the error to the operator + _log.warning("federation.discovery.walk.error", error=str(exc)) + msg = f"walk+failed:+{str(exc)[:80]}" + return RedirectResponse(f"/admin/federation/discovery?flash={msg}", status_code=303) + # ---------- public endpoints -------------------------------------- @app.get("/federation/info") diff --git a/src/psyc/cockpit/templates/admin_discovery.html b/src/psyc/cockpit/templates/admin_discovery.html new file mode 100644 index 0000000..40e4683 --- /dev/null +++ b/src/psyc/cockpit/templates/admin_discovery.html @@ -0,0 +1,72 @@ +{% extends "base.html" %} +{% block title %}Discovery — psyc admin{% endblock %} +{% block content %} + +
+
+

Peer discovery

+ {{ candidates|length }} candidate{{ '' if candidates|length == 1 else 's' }} +
+

Walk DNS-SD records from a seed domain you know runs psyc, then recurse through its public peer list. Newly-found peers land here with status unknown — vouching is what eventually promotes them. Once seeds exist, enabling the peer-pull pulse pipeline runs this on a cadence.

+

← back to federation

+ + {% if flash %} +
{{ flash }}
+ {% endif %} +
+ +
+
+

Seed domains

+ {{ seeds|length }} configured +
+

One domain per line. Each seed is resolved via _psyc._tcp.<domain> SRV+TXT; its /federation/peers/public is fetched and recursed.

+
+ +
+ +
+
+ +
+ +
+
+ +
+
+

Recent candidates

+ {{ candidates|length }} in registry +
+

Every peer the walker has ever found, newest first. Trusted/blocked statuses are preserved across re-walks — discovery never demotes a peer the operator has classified.

+ + {% if candidates %} + + + + {% for p in candidates %} + + + + + + + + + {% endfor %} + +
DomainFingerprintStatusDiscoveredLast seenNotes
{{ p.domain }}{{ p.fingerprint[:8] }}…{{ p.fingerprint[-8:] }} + {% if p.status == 'trusted' %} + trusted + {% elif p.status == 'blocked' %} + blocked + {% else %} + {{ p.status }} + {% endif %} + {{ (p.discovered_at or '')[:16] | replace('T', ' ') }}{{ ((p.last_seen or '')[:16] | replace('T', ' ')) or '—' }}{{ (p.notes or '')[:60] }}
+ {% else %} +

(no candidates yet — add a seed above and walk)

+ {% endif %} +
+ +{% endblock %}