stage-disc-d discovery: cockpit + CLI
This commit is contained in:
@@ -7,14 +7,14 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import typer
|
import typer
|
||||||
|
|
||||||
from psyc import db, log
|
from psyc import db, log
|
||||||
from psyc.lines import federation
|
from psyc.lines import discovery, federation, pulse
|
||||||
from psyc.result import Err
|
from psyc.result import Err, Ok
|
||||||
|
|
||||||
|
|
||||||
_log = log.get(__name__)
|
_log = log.get(__name__)
|
||||||
@@ -133,3 +133,80 @@ def register(typer_app: typer.Typer) -> None:
|
|||||||
db.init_db()
|
db.init_db()
|
||||||
federation.remove_peer(domain)
|
federation.remove_peer(domain)
|
||||||
typer.echo(f"removed {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 fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
from psyc import db, log
|
from psyc import db, log
|
||||||
from psyc.lines import discovery, federation
|
from psyc.lines import discovery, federation, pulse
|
||||||
|
|
||||||
|
|
||||||
_log = log.get(__name__)
|
_log = log.get(__name__)
|
||||||
@@ -115,6 +115,54 @@ def register(app: FastAPI, TEMPLATES: Jinja2Templates) -> None:
|
|||||||
federation.remove_peer(domain)
|
federation.remove_peer(domain)
|
||||||
return RedirectResponse("/admin/federation", status_code=303)
|
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 --------------------------------------
|
# ---------- public endpoints --------------------------------------
|
||||||
|
|
||||||
@app.get("/federation/info")
|
@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