stage-disc-d discovery: cockpit + CLI
This commit is contained in:
@@ -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}")
|
||||
|
||||
@@ -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")
|
||||
|
||||
72
src/psyc/cockpit/templates/admin_discovery.html
Normal file
72
src/psyc/cockpit/templates/admin_discovery.html
Normal 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.<domain></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 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 %}
|
||||
Reference in New Issue
Block a user