stage-disc-d discovery: cockpit + CLI

This commit is contained in:
m17hr1l
2026-06-06 21:06:39 +02:00
parent ddb40ff92c
commit 9b49f768ca
3 changed files with 201 additions and 4 deletions

View File

@@ -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.<domain>"),
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}")

View File

@@ -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")

View File

@@ -0,0 +1,72 @@
{% extends "base.html" %}
{% block title %}Discovery — psyc admin{% endblock %}
{% block content %}
<section class="panel">
<div class="panel-head">
<h1>Peer discovery</h1>
<span class="count">{{ candidates|length }} candidate{{ '' if candidates|length == 1 else 's' }}</span>
</div>
<p class="page-intro">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 <code>unknown</code> — vouching is what eventually promotes them. Once seeds exist, enabling the <code>peer-pull</code> pulse pipeline runs this on a cadence.</p>
<p class="back"><a href="/admin/federation">← back to federation</a></p>
{% if flash %}
<div class="verdict verdict-clean">{{ flash }}</div>
{% endif %}
</section>
<section class="panel">
<div class="panel-head">
<h2>Seed domains</h2>
<span class="count">{{ seeds|length }} configured</span>
</div>
<p class="page-intro">One domain per line. Each seed is resolved via <code>_psyc._tcp.&lt;domain&gt;</code> SRV+TXT; its <code>/federation/peers/public</code> is fetched and recursed.</p>
<form method="post" action="/admin/federation/discovery/seeds" style="display:grid; gap:10px; max-width:680px;">
<textarea name="seeds" rows="6" class="lookup-input" style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:12px;" placeholder="peer1.example.com&#10;peer2.example.org">{{ seeds_text }}</textarea>
<div style="display:flex; gap:10px;">
<button type="submit" class="btn btn-enforce">save seeds</button>
</div>
</form>
<form method="post" action="/admin/federation/discovery/walk" style="margin-top:14px;">
<button type="submit" class="btn btn-approve" {% if not seeds %}disabled{% endif %}>walk now</button>
</form>
</section>
<section class="panel">
<div class="panel-head">
<h2>Recent candidates</h2>
<span class="count">{{ candidates|length }} in registry</span>
</div>
<p class="page-intro">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.</p>
{% if candidates %}
<table class="ledger">
<thead><tr><th>Domain</th><th>Fingerprint</th><th>Status</th><th>Discovered</th><th>Last seen</th><th>Notes</th></tr></thead>
<tbody>
{% for p in candidates %}
<tr class="ledger-row">
<td><strong>{{ p.domain }}</strong></td>
<td class="lg-sub" style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace;">{{ p.fingerprint[:8] }}…{{ p.fingerprint[-8:] }}</td>
<td>
{% if p.status == 'trusted' %}
<span class="sev-badge" style="background:rgba(74,222,128,0.10); color:var(--green); border-color:var(--green);">trusted</span>
{% elif p.status == 'blocked' %}
<span class="sev-badge" style="background:rgba(248,113,113,0.10); color:var(--red); border-color:var(--red);">blocked</span>
{% else %}
<span class="sev-badge">{{ p.status }}</span>
{% endif %}
</td>
<td class="lg-ts">{{ (p.discovered_at or '')[:16] | replace('T', ' ') }}</td>
<td class="lg-ts">{{ ((p.last_seen or '')[:16] | replace('T', ' ')) or '—' }}</td>
<td class="lg-sub">{{ (p.notes or '')[:60] }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="page-intro">(no candidates yet — add a seed above and walk)</p>
{% endif %}
</section>
{% endblock %}