Compare commits
52 Commits
1675a2326e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f12e344a8 | ||
|
|
00cd8ca252 | ||
|
|
77e4cb6ab9 | ||
|
|
9ba4cd2189 | ||
|
|
155d6eaaf9 | ||
|
|
d998be276b | ||
|
|
367f17a013 | ||
|
|
a8216d00ef | ||
|
|
8587e079bb | ||
|
|
cef3bcb1ed | ||
|
|
9ab3271bc8 | ||
|
|
c2bd68e246 | ||
|
|
587fd07d38 | ||
|
|
ca6ba83950 | ||
|
|
a10203d8f1 | ||
|
|
56466c334d | ||
|
|
351e16c3ce | ||
|
|
2c7f71eff8 | ||
|
|
925bf76a0b | ||
|
|
0d9baef4c8 | ||
|
|
980cf74b76 | ||
|
|
70b6af6a35 | ||
|
|
15749e050e | ||
|
|
c6c5d3b2ea | ||
|
|
e33c5b41f5 | ||
|
|
865be2e239 | ||
|
|
ff44e9e450 | ||
|
|
5950d34deb | ||
|
|
5ff6d80333 | ||
|
|
6dcaae39c3 | ||
|
|
fbad78a611 | ||
|
|
77533eccb1 | ||
|
|
3e737d61b3 | ||
|
|
a53aacfdd8 | ||
|
|
53ba537ce8 | ||
|
|
726117b19b | ||
|
|
c5472b3134 | ||
|
|
f5ca928f92 | ||
|
|
e66c3d3359 | ||
|
|
f4148d86a6 | ||
|
|
0e56fa70af | ||
|
|
31ec1557ec | ||
|
|
eadd1aea3b | ||
|
|
234e6d98ba | ||
|
|
0dbeb056c5 | ||
|
|
7a510c7acf | ||
|
|
4a9f6ceb7f | ||
|
|
ff88aba569 | ||
|
|
9b49f768ca | ||
|
|
ddb40ff92c | ||
|
|
6241a21af5 | ||
|
|
de6204819b |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -14,6 +14,9 @@ data/
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# per-operator federation host list (SSH targets are sensitive)
|
||||
scripts/hosts
|
||||
|
||||
# editors
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
@@ -23,6 +23,7 @@ dependencies = [
|
||||
"pyotp>=2.9",
|
||||
"qrcode[pil]>=7.4",
|
||||
"itsdangerous>=2.1",
|
||||
"dnspython>=2.4",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
65
scripts/deploy-all.sh
Executable file
65
scripts/deploy-all.sh
Executable file
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env bash
|
||||
# Deploy the current main commit to every federation host listed in
|
||||
# scripts/hosts (one node per line: LABEL SSH_TARGET REMOTE_PATH PUBLIC_URL).
|
||||
# Loops scripts/deploy.sh against each. Bails on first failure unless --keep-going.
|
||||
set -euo pipefail
|
||||
|
||||
HOSTS_FILE="${PSYC_HOSTS_FILE:-$(dirname "$0")/hosts}"
|
||||
KEEP_GOING=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--keep-going) KEEP_GOING=1 ;;
|
||||
-h|--help)
|
||||
echo "usage: $0 [--keep-going]"
|
||||
echo " reads $HOSTS_FILE (override with PSYC_HOSTS_FILE=...)"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ ! -f "$HOSTS_FILE" ]]; then
|
||||
echo "no hosts file at $HOSTS_FILE — copy scripts/hosts.example to scripts/hosts and edit" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
declare -a OK=() FAIL=()
|
||||
while IFS= read -r line; do
|
||||
# skip blanks + comments
|
||||
[[ -z "${line// /}" || "${line# }" == \#* ]] && continue
|
||||
# shellcheck disable=SC2206
|
||||
parts=($line)
|
||||
if [[ ${#parts[@]} -lt 4 ]]; then
|
||||
echo "[deploy-all] skipping malformed line: $line" >&2
|
||||
continue
|
||||
fi
|
||||
LABEL="${parts[0]}"
|
||||
SSH_TARGET="${parts[1]}"
|
||||
REMOTE_PATH="${parts[2]}"
|
||||
PUBLIC_URL="${parts[3]}"
|
||||
|
||||
echo
|
||||
echo "════════════════════════════════════════════════════════════════"
|
||||
echo " deploying → $LABEL ($SSH_TARGET:$REMOTE_PATH → $PUBLIC_URL)"
|
||||
echo "════════════════════════════════════════════════════════════════"
|
||||
|
||||
if PSYC_PROD_HOST="$SSH_TARGET" \
|
||||
PSYC_PROD_PATH="$REMOTE_PATH" \
|
||||
PSYC_PROD_URL="$PUBLIC_URL" \
|
||||
bash "$(dirname "$0")/deploy.sh" < /dev/null; then
|
||||
OK+=("$LABEL")
|
||||
else
|
||||
FAIL+=("$LABEL")
|
||||
if [[ $KEEP_GOING -ne 1 ]]; then
|
||||
echo "[deploy-all] $LABEL failed — stopping. pass --keep-going to continue past failures." >&2
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done < "$HOSTS_FILE"
|
||||
|
||||
echo
|
||||
echo "════════════════════════════════════════════════════════════════"
|
||||
echo " summary"
|
||||
echo "════════════════════════════════════════════════════════════════"
|
||||
echo " ok: ${OK[*]:-(none)}"
|
||||
echo " failed: ${FAIL[*]:-(none)}"
|
||||
[[ ${#FAIL[@]} -eq 0 ]]
|
||||
6
scripts/hosts.example
Normal file
6
scripts/hosts.example
Normal file
@@ -0,0 +1,6 @@
|
||||
# Federation hosts — one node per line.
|
||||
# Format: LABEL SSH_TARGET REMOTE_PATH PUBLIC_URL
|
||||
# Lines starting with # are ignored. Copy to scripts/hosts and edit.
|
||||
prod neuronetz@cloud.neuronetz.ai /home/neuronetz/docker-public/neuro-psyc https://psyc.neuronetz.ai
|
||||
sto user@sto-host.example /path/to/neuro-psyc https://psyc.maschinen-stockert.de
|
||||
bittomine user@bittomine-host.example /path/to/neuro-psyc https://psyc.bittomine.com
|
||||
@@ -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, network_view, pulse, topology_export, translog
|
||||
from psyc.result import Err, Ok
|
||||
|
||||
|
||||
_log = log.get(__name__)
|
||||
@@ -133,3 +133,221 @@ 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}")
|
||||
|
||||
# ---------- vouching + quorum --------------------------------------
|
||||
|
||||
@typer_app.command("fed-vouch")
|
||||
def fed_vouch(
|
||||
target_fp: str = typer.Argument(..., help="target peer fingerprint (32 hex)"),
|
||||
ttl_days: int = typer.Option(90, "--ttl-days", help="vouch lifetime in days"),
|
||||
) -> None:
|
||||
"""Issue a signed vouch for `target_fp`. Persists locally + rides our feed."""
|
||||
db.init_db()
|
||||
v = federation.issue_vouch(target_fp.strip(), ttl_days=ttl_days)
|
||||
typer.echo(f"vouched: {v.target_fingerprint} (expires {v.expires_at})")
|
||||
|
||||
@typer_app.command("fed-unvouch")
|
||||
def fed_unvouch(target_fp: str = typer.Argument(...)) -> None:
|
||||
"""Revoke OUR vouch for `target_fp`."""
|
||||
db.init_db()
|
||||
federation.revoke_vouch(target_fp.strip())
|
||||
typer.echo(f"revoked vouch for {target_fp}")
|
||||
|
||||
@typer_app.command("fed-vouches")
|
||||
def fed_vouches() -> None:
|
||||
"""List vouches WE have issued."""
|
||||
db.init_db()
|
||||
rows = federation.our_vouches()
|
||||
if not rows:
|
||||
typer.echo("(no vouches issued)")
|
||||
return
|
||||
for v in rows:
|
||||
exp = v.expires_at.isoformat() if v.expires_at else "—"
|
||||
typer.echo(f" {v.target_fingerprint} issued={v.issued_at.isoformat()[:16]} expires={exp[:16]}")
|
||||
|
||||
@typer_app.command("fed-quorum-set")
|
||||
def fed_quorum_set(
|
||||
trust: Optional[int] = typer.Option(None, "--trust", help="trust_min_vouchers"),
|
||||
k: Optional[int] = typer.Option(None, "--k", help="signal_quorum_k"),
|
||||
) -> None:
|
||||
"""Update quorum thresholds. Either flag is optional — only changed values overwrite."""
|
||||
db.init_db()
|
||||
cfg = federation.quorum_config()
|
||||
if trust is not None:
|
||||
cfg.trust_min_vouchers = max(1, int(trust))
|
||||
if k is not None:
|
||||
cfg.signal_quorum_k = max(1, int(k))
|
||||
federation.set_quorum_config(cfg)
|
||||
typer.echo(f"quorum: trust_min_vouchers={cfg.trust_min_vouchers} signal_quorum_k={cfg.signal_quorum_k}")
|
||||
|
||||
# ---------- transparency log --------------------------------------
|
||||
|
||||
@typer_app.command("fed-log")
|
||||
def fed_log(
|
||||
limit: int = typer.Option(20, "--limit", help="number of entries to show"),
|
||||
) -> None:
|
||||
"""Print recent transparency-log entries (newest first)."""
|
||||
db.init_db()
|
||||
rows = translog.recent(limit=limit)
|
||||
if not rows:
|
||||
typer.echo("(transparency log empty)")
|
||||
return
|
||||
for e in rows:
|
||||
typer.echo(
|
||||
f" id={e.id:5d} {e.entry_type:6s} {e.timestamp[:19]} hash={e.entry_hash[:16]}…"
|
||||
)
|
||||
|
||||
# ---------- network view ------------------------------------------
|
||||
|
||||
@typer_app.command("fed-network")
|
||||
def fed_network() -> None:
|
||||
"""Print the local federation network view — nodes, vouches, stats."""
|
||||
db.init_db()
|
||||
view = network_view.build_local_view()
|
||||
|
||||
# Nodes table.
|
||||
typer.echo("NODES")
|
||||
typer.echo(f" {'fingerprint':<34} {'label':<32} {'status':<9} dist")
|
||||
for n in view.nodes:
|
||||
fp = f"{n.fingerprint[:8]}…{n.fingerprint[-8:]}" if len(n.fingerprint) >= 16 else n.fingerprint
|
||||
label = (n.label or "")[:30]
|
||||
typer.echo(f" {fp:<34} {label:<32} {n.status:<9} {n.distance}")
|
||||
|
||||
# Vouches breakdown.
|
||||
our_fp = view.nodes[0].fingerprint
|
||||
vouch_out = [e for e in view.edges if e.kind == "vouch" and e.source_fingerprint == our_fp]
|
||||
vouch_in = [e for e in view.edges if e.kind == "vouch" and e.target_fingerprint == our_fp]
|
||||
bidir = [e for e in vouch_out if e.bidirectional]
|
||||
|
||||
typer.echo("")
|
||||
typer.echo("VOUCHES")
|
||||
if not vouch_out and not vouch_in and not bidir:
|
||||
typer.echo(" (no vouches)")
|
||||
else:
|
||||
for e in vouch_out:
|
||||
arrow = "↔" if e.bidirectional else "→"
|
||||
fp = f"{e.target_fingerprint[:8]}…{e.target_fingerprint[-8:]}"
|
||||
typer.echo(f" us {arrow} {fp}")
|
||||
for e in vouch_in:
|
||||
fp = f"{e.source_fingerprint[:8]}…{e.source_fingerprint[-8:]}"
|
||||
typer.echo(f" {fp} → us")
|
||||
|
||||
# Signal edges.
|
||||
sig_edges = [e for e in view.edges if e.kind == "signal"]
|
||||
typer.echo("")
|
||||
typer.echo("SIGNALS (24h)")
|
||||
if not sig_edges:
|
||||
typer.echo(" (no signals)")
|
||||
else:
|
||||
for e in sig_edges:
|
||||
fp = f"{e.source_fingerprint[:8]}…{e.source_fingerprint[-8:]}"
|
||||
typer.echo(f" from {fp}: {int(e.weight)}")
|
||||
|
||||
# Stats footer.
|
||||
typer.echo("")
|
||||
typer.echo("STATS")
|
||||
for k, v in view.stats.items():
|
||||
typer.echo(f" {k:<32} {v}")
|
||||
|
||||
@typer_app.command("fed-topology")
|
||||
def fed_topology() -> None:
|
||||
"""Print the sanitized docker topology JSON published at /federation/topology.
|
||||
|
||||
Useful for auditing what gets exposed to peers — pipe through `jq` to
|
||||
confirm no env vars / volume mounts / IPs leak. On a dev box where
|
||||
the docker-socket-proxy isn't running the export will be empty.
|
||||
"""
|
||||
db.init_db()
|
||||
export = topology_export.build_export()
|
||||
typer.echo(json.dumps(export.model_dump(mode="json"), indent=2))
|
||||
|
||||
@typer_app.command("fed-log-verify")
|
||||
def fed_log_verify() -> None:
|
||||
"""Re-walk the chain locally and report verification status."""
|
||||
db.init_db()
|
||||
result = translog.verify_chain()
|
||||
head = translog.head()
|
||||
head_hash = head.entry_hash if head else "(empty)"
|
||||
if isinstance(result, Err):
|
||||
typer.echo(f" ✗ broken: {result.reason}", err=True)
|
||||
typer.echo(f" head_hash: {head_hash}")
|
||||
raise typer.Exit(1)
|
||||
typer.echo(f" ✓ verified {result.value} entries")
|
||||
typer.echo(f" head_hash: {head_hash}")
|
||||
|
||||
@@ -14,6 +14,7 @@ import typer
|
||||
|
||||
from psyc import db
|
||||
from psyc.lines import pulse
|
||||
from psyc.models import Severity
|
||||
|
||||
|
||||
def _relative(dt: Optional[datetime]) -> str:
|
||||
@@ -120,3 +121,62 @@ def register(typer_app: typer.Typer) -> None:
|
||||
db.init_db()
|
||||
pulse.set_kill_switch(False)
|
||||
typer.echo("kill switch disarmed — pulse resumes")
|
||||
|
||||
@typer_app.command("pulse-respond-config")
|
||||
def pulse_respond_config(
|
||||
threshold: Optional[str] = typer.Option(
|
||||
None, "--threshold", help="min severity: low | medium | high | critical"
|
||||
),
|
||||
quorum: Optional[bool] = typer.Option(
|
||||
None, "--quorum/--no-quorum", help="require quorum on federation-sourced cases"
|
||||
),
|
||||
local_only: Optional[bool] = typer.Option(
|
||||
None, "--local-only/--no-local-only",
|
||||
help="when armed, auto-execute defers federation cases until quorum"
|
||||
),
|
||||
) -> None:
|
||||
"""Update the respond-pipeline auto-fire gates. Args left unset are unchanged."""
|
||||
db.init_db()
|
||||
if threshold is not None:
|
||||
try:
|
||||
pulse.set_respond_auto_threshold(Severity(threshold))
|
||||
except ValueError:
|
||||
typer.echo(f"error: unknown severity {threshold!r}", err=True)
|
||||
raise typer.Exit(1)
|
||||
if quorum is not None:
|
||||
pulse.set_respond_require_quorum(quorum)
|
||||
if local_only is not None:
|
||||
pulse.set_respond_local_only(local_only)
|
||||
typer.echo(
|
||||
f"threshold={pulse.respond_auto_threshold().value} "
|
||||
f"quorum={'on' if pulse.respond_require_quorum() else 'off'} "
|
||||
f"local-only={'on' if pulse.respond_local_only() else 'off'}"
|
||||
)
|
||||
|
||||
@typer_app.command("pulse-respond-status")
|
||||
def pulse_respond_status() -> None:
|
||||
"""Print the respond-pipeline gates + the last 10 audit entries."""
|
||||
db.init_db()
|
||||
mode = "manual"
|
||||
for p in pulse.state():
|
||||
if p.name == "respond":
|
||||
mode = p.mode.value
|
||||
break
|
||||
typer.echo(f"respond mode : {mode}")
|
||||
typer.echo(f"threshold : {pulse.respond_auto_threshold().value}")
|
||||
typer.echo(f"require quorum : {'yes' if pulse.respond_require_quorum() else 'no'}")
|
||||
typer.echo(f"local-only : {'yes' if pulse.respond_local_only() else 'no'}")
|
||||
|
||||
audit = db.pulse_audit_recent("respond", limit=10)
|
||||
if not audit:
|
||||
typer.echo("(no audit entries yet)")
|
||||
return
|
||||
typer.echo("")
|
||||
typer.echo(f"{'timestamp':<28} {'action':<11} {'case_id':<22} detail")
|
||||
for row in audit:
|
||||
typer.echo(
|
||||
f"{(row['timestamp'] or '')[:27]:<28} "
|
||||
f"{(row['action'] or ''):<11} "
|
||||
f"{(row['case_id'] or '—'):<22} "
|
||||
f"{(row['detail'] or '')[:80]}"
|
||||
)
|
||||
|
||||
@@ -35,6 +35,19 @@ app = FastAPI(title="psyc Operations Cockpit", version="0.1.0")
|
||||
app.add_middleware(SessionMiddleware, secret_key=adminauth.session_secret(), max_age=3600)
|
||||
app.mount("/static", StaticFiles(directory=str(HERE / "static")), name="static")
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def _security_headers(request: Request, call_next):
|
||||
"""Defense-in-depth headers. CSP is intentionally NOT set yet — the
|
||||
cockpit currently uses inline scripts in base.html / journey.html /
|
||||
federation_explore.html which would need nonces or extraction first."""
|
||||
resp = await call_next(request)
|
||||
resp.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||
resp.headers.setdefault("Referrer-Policy", "no-referrer")
|
||||
resp.headers.setdefault("X-Frame-Options", "DENY")
|
||||
return resp
|
||||
|
||||
|
||||
pulse_routes.register(app, TEMPLATES)
|
||||
federation_routes.register(app, TEMPLATES)
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@ from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, Red
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from psyc import db, log
|
||||
from psyc.lines import federation
|
||||
from psyc.lines import discovery, federation, network_view, pulse, topology_export, translog
|
||||
from psyc.result import Err
|
||||
|
||||
|
||||
_log = log.get(__name__)
|
||||
@@ -25,6 +26,33 @@ _log = log.get(__name__)
|
||||
_FEED_CACHE: Dict[str, Any] = {"ts": 0.0, "payload": None}
|
||||
_FEED_TTL = 60.0
|
||||
|
||||
# Mirror the feed cache for the public peers list — same poll-load pattern.
|
||||
_PUBLIC_PEERS_CACHE: Dict[str, Any] = {"ts": 0.0, "payload": None}
|
||||
_PUBLIC_PEERS_TTL = 60.0
|
||||
|
||||
# And again for the public federation-network payload (signed JSON view).
|
||||
_PUBLIC_NETWORK_CACHE: Dict[str, Any] = {"ts": 0.0, "payload": None}
|
||||
_PUBLIC_NETWORK_TTL = 60.0
|
||||
|
||||
# Explore-view cache. The builder fans out to trusted peers' explore feeds
|
||||
# for the distance-2 snapshot, so a polled hit must NEVER trigger that walk.
|
||||
_EXPLORE_CACHE: Dict[str, Any] = {"ts": 0.0, "payload": None, "domain": None}
|
||||
_EXPLORE_TTL = 60.0
|
||||
|
||||
# Sanitized docker topology cache. The build call hits the docker-socket-proxy
|
||||
# sidecar; polled peer admin pages mustn't re-trigger that on every poke.
|
||||
_TOPOLOGY_CACHE: Dict[str, Any] = {"ts": 0.0, "payload": None}
|
||||
_TOPOLOGY_TTL = 60.0
|
||||
|
||||
|
||||
# Headers we slap on every public endpoint so other psyc nodes' explore
|
||||
# pages can fetch them cross-origin from the browser.
|
||||
_CORS_HEADERS: Dict[str, str] = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
}
|
||||
|
||||
|
||||
def _admin_ok(request: Request) -> bool:
|
||||
return bool(request.session.get("admin_ok"))
|
||||
@@ -38,6 +66,56 @@ def _cached_feed() -> Dict[str, Any]:
|
||||
return _FEED_CACHE["payload"]
|
||||
|
||||
|
||||
def _cached_public_peers() -> Any:
|
||||
now = time.time()
|
||||
if _PUBLIC_PEERS_CACHE["payload"] is None or (now - _PUBLIC_PEERS_CACHE["ts"]) > _PUBLIC_PEERS_TTL:
|
||||
_PUBLIC_PEERS_CACHE["payload"] = discovery.public_peer_attestation()
|
||||
_PUBLIC_PEERS_CACHE["ts"] = now
|
||||
return _PUBLIC_PEERS_CACHE["payload"]
|
||||
|
||||
|
||||
def _cached_public_network() -> Dict[str, Any]:
|
||||
now = time.time()
|
||||
if _PUBLIC_NETWORK_CACHE["payload"] is None or (now - _PUBLIC_NETWORK_CACHE["ts"]) > _PUBLIC_NETWORK_TTL:
|
||||
_PUBLIC_NETWORK_CACHE["payload"] = network_view.build_public_view()
|
||||
_PUBLIC_NETWORK_CACHE["ts"] = now
|
||||
return _PUBLIC_NETWORK_CACHE["payload"]
|
||||
|
||||
|
||||
def _cached_topology() -> Dict[str, Any]:
|
||||
"""Cached sanitized docker topology — same poll-load pattern as the feed."""
|
||||
now = time.time()
|
||||
if _TOPOLOGY_CACHE["payload"] is None or (now - _TOPOLOGY_CACHE["ts"]) > _TOPOLOGY_TTL:
|
||||
export = topology_export.build_export()
|
||||
_TOPOLOGY_CACHE["payload"] = export.model_dump(mode="json")
|
||||
_TOPOLOGY_CACHE["ts"] = now
|
||||
return _TOPOLOGY_CACHE["payload"]
|
||||
|
||||
|
||||
def _cached_explore(domain: Optional[str]) -> Dict[str, Any]:
|
||||
"""Cached explore payload. Re-uses the cache when the host domain matches.
|
||||
|
||||
Domain is recorded into the payload's `node.domain` field, so a fresh
|
||||
cache slot per host avoids serving the wrong reflected name.
|
||||
"""
|
||||
now = time.time()
|
||||
cached_domain = _EXPLORE_CACHE.get("domain")
|
||||
if (
|
||||
_EXPLORE_CACHE["payload"] is None
|
||||
or (now - _EXPLORE_CACHE["ts"]) > _EXPLORE_TTL
|
||||
or cached_domain != domain
|
||||
):
|
||||
_EXPLORE_CACHE["payload"] = network_view.build_explore_view(node_domain=domain)
|
||||
_EXPLORE_CACHE["ts"] = now
|
||||
_EXPLORE_CACHE["domain"] = domain
|
||||
return _EXPLORE_CACHE["payload"]
|
||||
|
||||
|
||||
def _public_json(payload: Any) -> JSONResponse:
|
||||
"""JSONResponse with the public-CORS header set."""
|
||||
return JSONResponse(payload, headers=_CORS_HEADERS)
|
||||
|
||||
|
||||
def register(app: FastAPI, TEMPLATES: Jinja2Templates) -> None:
|
||||
"""Mount all federation routes onto `app`."""
|
||||
|
||||
@@ -103,23 +181,350 @@ 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")
|
||||
def federation_info() -> JSONResponse:
|
||||
return JSONResponse({
|
||||
return _public_json({
|
||||
"fingerprint": federation.node_fingerprint(),
|
||||
"version": federation.FEED_VERSION,
|
||||
"feed": federation.FEED_PATH,
|
||||
"key": "/federation/key",
|
||||
"explore": "/federation/explore",
|
||||
})
|
||||
|
||||
@app.get("/federation/key", response_class=PlainTextResponse)
|
||||
def federation_key() -> PlainTextResponse:
|
||||
return PlainTextResponse(federation.public_key_pem(), media_type="text/plain")
|
||||
return PlainTextResponse(
|
||||
federation.public_key_pem(),
|
||||
media_type="text/plain",
|
||||
headers=_CORS_HEADERS,
|
||||
)
|
||||
|
||||
@app.get("/federation/feed")
|
||||
def federation_feed() -> JSONResponse:
|
||||
return JSONResponse(_cached_feed())
|
||||
return _public_json(_cached_feed())
|
||||
|
||||
@app.get("/federation/peers/public")
|
||||
def federation_peers_public() -> JSONResponse:
|
||||
"""Publicly attested peer list — what other psyc walkers discover us through.
|
||||
|
||||
Only trusted peers leak; unknown + blocked are internal state and must
|
||||
never appear here.
|
||||
"""
|
||||
return _public_json(_cached_public_peers())
|
||||
|
||||
@app.get("/federation/network")
|
||||
def federation_network_public() -> JSONResponse:
|
||||
"""Signed federation-network attestation — for transitive-view fetchers.
|
||||
|
||||
Mirrors /federation/peers/public in spirit but adds our outbound vouches
|
||||
so a fetcher can reconstruct the local web-of-trust shape. Trusted peers
|
||||
only — never unknown or blocked. Signal hashes are deliberately omitted.
|
||||
"""
|
||||
return _public_json(_cached_public_network())
|
||||
|
||||
@app.get("/federation/topology")
|
||||
def federation_topology_public() -> JSONResponse:
|
||||
"""Sanitized docker topology — public, for peer-side display.
|
||||
|
||||
Whitelist-only: container names + images + state + network names. No
|
||||
env vars, no volume mounts, no IPs/MACs/gateways, no labels. CORS open
|
||||
so a peer's `/admin/federation/network` page can fetch it from the
|
||||
browser and render every node's containers alongside its own.
|
||||
"""
|
||||
return _public_json(_cached_topology())
|
||||
|
||||
# ---------- public vouches + transparency log --------------------
|
||||
|
||||
@app.get("/federation/vouches")
|
||||
def federation_vouches() -> JSONResponse:
|
||||
"""Vouches WE have issued. Peers fetch this to learn who we trust."""
|
||||
return _public_json({
|
||||
"fingerprint": federation.node_fingerprint(),
|
||||
"vouches": [v.model_dump(mode="json") for v in federation.our_vouches()],
|
||||
})
|
||||
|
||||
@app.get("/federation/log")
|
||||
def federation_log() -> JSONResponse:
|
||||
"""Last 100 transparency-log entries, newest first."""
|
||||
entries = translog.recent(limit=100)
|
||||
return _public_json({
|
||||
"count": len(entries),
|
||||
"entries": [e.model_dump(mode="json") for e in entries],
|
||||
})
|
||||
|
||||
@app.get("/federation/log/verify")
|
||||
def federation_log_verify() -> JSONResponse:
|
||||
"""Re-walk the chain locally and report status. Auditors poll this."""
|
||||
result = translog.verify_chain()
|
||||
head = translog.head()
|
||||
head_hash = head.entry_hash if head else None
|
||||
if isinstance(result, Err):
|
||||
return JSONResponse(
|
||||
{"error": result.reason, "head_hash": head_hash},
|
||||
status_code=409,
|
||||
headers=_CORS_HEADERS,
|
||||
)
|
||||
return _public_json({"verified": result.value, "head_hash": head_hash})
|
||||
|
||||
# ---------- public explore page + data ---------------------------
|
||||
|
||||
@app.get("/federation/explore", response_class=HTMLResponse)
|
||||
def federation_explore_page(request: Request) -> HTMLResponse:
|
||||
"""Public transparency view — anyone can verify this network."""
|
||||
host = request.url.hostname or ""
|
||||
peer_param = request.query_params.get("peer", "").strip()
|
||||
return TEMPLATES.TemplateResponse(
|
||||
request,
|
||||
"federation_explore.html",
|
||||
{
|
||||
"fingerprint": federation.node_fingerprint(),
|
||||
"domain": host,
|
||||
"peer": peer_param,
|
||||
},
|
||||
)
|
||||
|
||||
@app.get("/federation/explore/data")
|
||||
def federation_explore_data(request: Request) -> JSONResponse:
|
||||
"""Signed public explorer payload — peer counts, vouches, transitives.
|
||||
|
||||
Public, no auth, CORS-enabled. Cached so a polled hit never triggers
|
||||
the distance-2 fan-out. See `network_view.build_explore_view` for the
|
||||
no-leak contract.
|
||||
"""
|
||||
host = request.url.hostname or None
|
||||
payload = _cached_explore(host)
|
||||
return _public_json(payload)
|
||||
|
||||
# ---------- admin: vouches page ---------------------------------
|
||||
|
||||
@app.get("/admin/federation/vouches", response_class=HTMLResponse)
|
||||
def admin_federation_vouches(request: Request) -> HTMLResponse:
|
||||
if not _admin_ok(request):
|
||||
return RedirectResponse("/admin", status_code=303)
|
||||
peers = federation.list_peers()
|
||||
cfg = federation.quorum_config()
|
||||
ours = federation.our_vouches()
|
||||
# Per-peer view: vouches naming each peer and whether quorum is met.
|
||||
peer_rows = []
|
||||
for p in peers:
|
||||
vouches = federation.vouches_for(p.fingerprint)
|
||||
peer_rows.append({
|
||||
"peer": p,
|
||||
"vouches": vouches,
|
||||
"vouched": federation.is_vouched(p.fingerprint),
|
||||
"eligible": federation.peer_is_listening_eligible(p.fingerprint),
|
||||
})
|
||||
return TEMPLATES.TemplateResponse(
|
||||
request,
|
||||
"admin_federation_vouches.html",
|
||||
{
|
||||
"fingerprint": federation.node_fingerprint(),
|
||||
"our_vouches": ours,
|
||||
"peer_rows": peer_rows,
|
||||
"cfg": cfg,
|
||||
},
|
||||
)
|
||||
|
||||
@app.post("/admin/federation/vouches/issue")
|
||||
def admin_federation_vouch_issue(
|
||||
request: Request,
|
||||
target_fingerprint: str = Form(...),
|
||||
ttl_days: int = Form(90),
|
||||
) -> RedirectResponse:
|
||||
if not _admin_ok(request):
|
||||
raise HTTPException(status_code=403, detail="admin session required")
|
||||
try:
|
||||
federation.issue_vouch(target_fingerprint.strip(), ttl_days=ttl_days)
|
||||
except Exception as exc:
|
||||
_log.warning("federation.vouch.issue.error", error=str(exc))
|
||||
return RedirectResponse("/admin/federation/vouches", status_code=303)
|
||||
|
||||
@app.post("/admin/federation/vouches/revoke")
|
||||
def admin_federation_vouch_revoke(
|
||||
request: Request,
|
||||
target_fingerprint: str = Form(...),
|
||||
) -> RedirectResponse:
|
||||
if not _admin_ok(request):
|
||||
raise HTTPException(status_code=403, detail="admin session required")
|
||||
federation.revoke_vouch(target_fingerprint.strip())
|
||||
return RedirectResponse("/admin/federation/vouches", status_code=303)
|
||||
|
||||
# ---------- admin: transparency log page ------------------------
|
||||
|
||||
@app.get("/admin/federation/log", response_class=HTMLResponse)
|
||||
def admin_federation_log(request: Request) -> HTMLResponse:
|
||||
if not _admin_ok(request):
|
||||
return RedirectResponse("/admin", status_code=303)
|
||||
result = translog.verify_chain()
|
||||
head = translog.head()
|
||||
entries = translog.recent(limit=200)
|
||||
verify_status: Dict[str, Any]
|
||||
if isinstance(result, Err):
|
||||
verify_status = {"ok": False, "reason": result.reason}
|
||||
else:
|
||||
verify_status = {"ok": True, "verified": result.value}
|
||||
return TEMPLATES.TemplateResponse(
|
||||
request,
|
||||
"admin_federation_log.html",
|
||||
{
|
||||
"verify_status": verify_status,
|
||||
"head_hash": head.entry_hash if head else "",
|
||||
"head_id": head.id if head else 0,
|
||||
"entries": entries,
|
||||
},
|
||||
)
|
||||
|
||||
# ---------- admin: federation network view ----------------------
|
||||
|
||||
@app.get("/admin/federation/network", response_class=HTMLResponse)
|
||||
def admin_federation_network(request: Request) -> HTMLResponse:
|
||||
"""Cockpit page — force-directed federation map. Data lives at /data."""
|
||||
if not _admin_ok(request):
|
||||
return RedirectResponse("/admin", status_code=303)
|
||||
# Build local stats up front so the header card renders even if the
|
||||
# JS data-endpoint fetch fails (defensive — never give the operator a
|
||||
# blank page).
|
||||
view = network_view.build_local_view()
|
||||
return TEMPLATES.TemplateResponse(
|
||||
request,
|
||||
"admin_federation_network.html",
|
||||
{
|
||||
"fingerprint": federation.node_fingerprint(),
|
||||
"stats": view.stats,
|
||||
},
|
||||
)
|
||||
|
||||
@app.get("/admin/federation/network/data")
|
||||
def admin_federation_network_data(request: Request) -> JSONResponse:
|
||||
"""Full admin view — includes unknown/blocked peers + transitive peers.
|
||||
|
||||
Public /federation/network filters those out; this surface does not,
|
||||
because it sits behind the admin gate and the operator needs to see
|
||||
the real shape of the federation including the parts being ignored.
|
||||
"""
|
||||
if not _admin_ok(request):
|
||||
raise HTTPException(status_code=403, detail="admin session required")
|
||||
return JSONResponse(network_view.build_admin_view(include_transitive=True))
|
||||
|
||||
# ---------- admin: quorum config + per-peer/per-hash view -------
|
||||
|
||||
@app.get("/admin/federation/quorum", response_class=HTMLResponse)
|
||||
def admin_federation_quorum(request: Request) -> HTMLResponse:
|
||||
if not _admin_ok(request):
|
||||
return RedirectResponse("/admin", status_code=303)
|
||||
cfg = federation.quorum_config()
|
||||
peers = federation.list_peers()
|
||||
peer_rows = [
|
||||
{
|
||||
"peer": p,
|
||||
"vouched": federation.is_vouched(p.fingerprint),
|
||||
"eligible": federation.peer_is_listening_eligible(p.fingerprint),
|
||||
}
|
||||
for p in peers
|
||||
]
|
||||
# Group buffered signals by signal_hash and count distinct eligible peers.
|
||||
signal_rows: Dict[str, Dict[str, Any]] = {}
|
||||
for s in db.recent_signals(limit=500):
|
||||
h = s.get("signal_hash") or ""
|
||||
entry = signal_rows.setdefault(h, {
|
||||
"signal_hash": h,
|
||||
"signal_type": s.get("signal_type") or "",
|
||||
"signal_id": s.get("signal_id") or "",
|
||||
"peers": set(),
|
||||
"latest": s.get("received_at") or "",
|
||||
})
|
||||
entry["peers"].add(s.get("peer_fingerprint") or "")
|
||||
hash_summary = []
|
||||
for h, row in signal_rows.items():
|
||||
distinct_eligible = sum(
|
||||
1 for fp in row["peers"] if federation.peer_is_listening_eligible(fp)
|
||||
)
|
||||
hash_summary.append({
|
||||
"signal_hash": h,
|
||||
"signal_type": row["signal_type"],
|
||||
"signal_id": row["signal_id"],
|
||||
"distinct_peers": len(row["peers"]),
|
||||
"distinct_eligible": distinct_eligible,
|
||||
"quorum_met": distinct_eligible >= cfg.signal_quorum_k,
|
||||
"latest": row["latest"],
|
||||
})
|
||||
hash_summary.sort(key=lambda r: r["latest"], reverse=True)
|
||||
return TEMPLATES.TemplateResponse(
|
||||
request,
|
||||
"admin_federation_quorum.html",
|
||||
{
|
||||
"cfg": cfg,
|
||||
"peer_rows": peer_rows,
|
||||
"hash_summary": hash_summary,
|
||||
},
|
||||
)
|
||||
|
||||
@app.post("/admin/federation/quorum/save")
|
||||
def admin_federation_quorum_save(
|
||||
request: Request,
|
||||
trust_min_vouchers: int = Form(...),
|
||||
signal_quorum_k: int = Form(...),
|
||||
) -> RedirectResponse:
|
||||
if not _admin_ok(request):
|
||||
raise HTTPException(status_code=403, detail="admin session required")
|
||||
try:
|
||||
cfg = federation.QuorumConfig(
|
||||
trust_min_vouchers=max(1, int(trust_min_vouchers)),
|
||||
signal_quorum_k=max(1, int(signal_quorum_k)),
|
||||
)
|
||||
federation.set_quorum_config(cfg)
|
||||
except Exception as exc:
|
||||
_log.warning("federation.quorum.save.error", error=str(exc))
|
||||
return RedirectResponse("/admin/federation/quorum", status_code=303)
|
||||
|
||||
_log.info("federation.routes.registered")
|
||||
|
||||
@@ -3,13 +3,19 @@
|
||||
The cockpit venv has no torch; the fine-tuned model only runs inside the CUDA
|
||||
container behind serve_model.py. This client reaches it over HTTP and degrades
|
||||
gracefully — if the server is down, callers get None and fall back to rules.
|
||||
|
||||
Two backends are supported via PSYC_INFERENCE_MODE:
|
||||
- "psyc" (default) — native serve_model.py, POST /infer
|
||||
- "openai" — OpenAI-compatible / Ollama, POST /v1/chat/completions
|
||||
A bearer token can be set via PSYC_INFERENCE_TOKEN; it is sent on every request
|
||||
when present (psyc-native ignores it; api.neuronetz.ai requires it).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Optional
|
||||
from typing import Dict, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -18,15 +24,31 @@ from psyc.lines.train import SEVERITY_INSTRUCTION, severity_features
|
||||
from psyc.models import Case
|
||||
|
||||
|
||||
INFERENCE_URL = os.environ.get("PSYC_INFERENCE_URL", "http://127.0.0.1:8771")
|
||||
INFERENCE_URL = os.environ.get("PSYC_INFERENCE_URL", "http://127.0.0.1:8771")
|
||||
INFERENCE_TOKEN = os.environ.get("PSYC_INFERENCE_TOKEN", "")
|
||||
INFERENCE_MODE = os.environ.get("PSYC_INFERENCE_MODE", "psyc").lower()
|
||||
INFERENCE_MODEL = os.environ.get("PSYC_INFERENCE_MODEL", "psyc-v5")
|
||||
|
||||
_log = log.get(__name__)
|
||||
|
||||
|
||||
def _auth_headers() -> Dict[str, str]:
|
||||
"""Bearer header when a token is set, empty dict otherwise."""
|
||||
return {"Authorization": f"Bearer {INFERENCE_TOKEN}"} if INFERENCE_TOKEN else {}
|
||||
|
||||
|
||||
def server_adapter(timeout: float = 2.0) -> Optional[str]:
|
||||
"""Return the adapter the server is running, or None if it is unreachable."""
|
||||
try:
|
||||
with httpx.Client(timeout=timeout) as client:
|
||||
if INFERENCE_MODE == "openai":
|
||||
# OpenAI/Ollama exposes GET /v1/models — first available id wins.
|
||||
resp = client.get(f"{INFERENCE_URL}/v1/models", headers=_auth_headers())
|
||||
resp.raise_for_status()
|
||||
data = resp.json().get("data") or []
|
||||
if data:
|
||||
return str(data[0].get("id") or INFERENCE_MODEL)
|
||||
return INFERENCE_MODEL
|
||||
resp = client.get(f"{INFERENCE_URL}/healthz")
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("adapter")
|
||||
@@ -45,18 +67,44 @@ def adapter_name(timeout: float = 2.0) -> Optional[str]:
|
||||
|
||||
def model_severity(case: Case, timeout: float = 15.0) -> Optional[str]:
|
||||
"""Ask the live model to classify case severity. None if the server is down."""
|
||||
payload = {
|
||||
"instruction": SEVERITY_INSTRUCTION,
|
||||
"input": json.dumps(severity_features(case), ensure_ascii=False),
|
||||
"max_new_tokens": 16,
|
||||
}
|
||||
features_json = json.dumps(severity_features(case), ensure_ascii=False)
|
||||
try:
|
||||
with httpx.Client(timeout=timeout) as client:
|
||||
resp = client.post(f"{INFERENCE_URL}/infer", json=payload)
|
||||
resp.raise_for_status()
|
||||
output = str(resp.json().get("output", "")).strip().lower()
|
||||
if INFERENCE_MODE == "openai":
|
||||
payload = {
|
||||
"model": INFERENCE_MODEL,
|
||||
"messages": [
|
||||
{"role": "system", "content": SEVERITY_INSTRUCTION},
|
||||
{"role": "user", "content": features_json},
|
||||
],
|
||||
"max_tokens": 16,
|
||||
"temperature": 0.0,
|
||||
}
|
||||
resp = client.post(
|
||||
f"{INFERENCE_URL}/v1/chat/completions",
|
||||
json=payload,
|
||||
headers=_auth_headers(),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
choices = resp.json().get("choices") or []
|
||||
if not choices:
|
||||
return None
|
||||
output = str(choices[0].get("message", {}).get("content", "")).strip().lower()
|
||||
else:
|
||||
payload = {
|
||||
"instruction": SEVERITY_INSTRUCTION,
|
||||
"input": features_json,
|
||||
"max_new_tokens": 16,
|
||||
}
|
||||
resp = client.post(
|
||||
f"{INFERENCE_URL}/infer",
|
||||
json=payload,
|
||||
headers=_auth_headers(),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
output = str(resp.json().get("output", "")).strip().lower()
|
||||
except httpx.HTTPError as exc:
|
||||
_log.info("inference.unavailable", error=str(exc))
|
||||
return None
|
||||
_log.info("inference.severity", case_id=case.case_id, model_answer=output)
|
||||
_log.info("inference.severity", case_id=case.case_id, model_answer=output, mode=INFERENCE_MODE)
|
||||
return output
|
||||
|
||||
@@ -8,15 +8,16 @@ scheduler loop. Caller in app.py just imports + invokes register().
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import FastAPI, Form, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from psyc import log
|
||||
from psyc import db, log
|
||||
from psyc.lines import pulse
|
||||
from psyc.models import Severity
|
||||
|
||||
|
||||
_log = log.get(__name__)
|
||||
@@ -61,6 +62,10 @@ def register(app: FastAPI, templates: Jinja2Templates) -> None:
|
||||
return RedirectResponse("/admin", status_code=303)
|
||||
flash = request.query_params.get("flash", "")
|
||||
pipelines = pulse.state()
|
||||
respond_mode = next((p.mode.value for p in pipelines if p.name == "respond"), "manual")
|
||||
since = (datetime.now(timezone.utc) - timedelta(hours=24)).isoformat()
|
||||
auto_fired_24h = db.pulse_audit_count_since("respond", "auto-fire", since)
|
||||
audit_recent = db.pulse_audit_recent("respond", limit=5)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"admin_pulse.html",
|
||||
@@ -70,6 +75,13 @@ def register(app: FastAPI, templates: Jinja2Templates) -> None:
|
||||
"tick_interval": TICK_INTERVAL_SECONDS,
|
||||
"relative": _relative,
|
||||
"flash": flash,
|
||||
"respond_mode": respond_mode,
|
||||
"respond_threshold": pulse.respond_auto_threshold().value,
|
||||
"respond_require_quorum": pulse.respond_require_quorum(),
|
||||
"respond_local_only": pulse.respond_local_only(),
|
||||
"respond_auto_fired_24h": auto_fired_24h,
|
||||
"respond_audit_recent": audit_recent,
|
||||
"severity_choices": [s.value for s in Severity],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -113,6 +125,29 @@ def register(app: FastAPI, templates: Jinja2Templates) -> None:
|
||||
flash = f"run failed: {exc}"
|
||||
return RedirectResponse(f"/admin/pulse?flash={flash}", status_code=303)
|
||||
|
||||
@app.post("/admin/pulse/respond-config")
|
||||
def pulse_respond_config(
|
||||
request: Request,
|
||||
threshold: str = Form(...),
|
||||
require_quorum: Optional[str] = Form(None),
|
||||
local_only: Optional[str] = Form(None),
|
||||
) -> RedirectResponse:
|
||||
if not _admin_ok(request):
|
||||
return RedirectResponse("/admin", status_code=303)
|
||||
try:
|
||||
sev = Severity(threshold)
|
||||
pulse.set_respond_auto_threshold(sev)
|
||||
pulse.set_respond_require_quorum(require_quorum is not None)
|
||||
pulse.set_respond_local_only(local_only is not None)
|
||||
flash = (
|
||||
f"respond gates updated: threshold={sev.value}, "
|
||||
f"quorum={'on' if require_quorum is not None else 'off'}, "
|
||||
f"local-only={'on' if local_only is not None else 'off'}"
|
||||
)
|
||||
except ValueError as exc:
|
||||
flash = f"respond-config failed: {exc}"
|
||||
return RedirectResponse(f"/admin/pulse?flash={flash}", status_code=303)
|
||||
|
||||
@app.on_event("startup")
|
||||
async def _start_pulse_loop() -> None:
|
||||
# Fire-and-forget; the loop catches its own exceptions and self-restarts.
|
||||
|
||||
@@ -108,7 +108,15 @@ h1, h2, h3,
|
||||
filter: drop-shadow(0 0 6px rgba(0, 0, 0, 0.55)) drop-shadow(0 0 10px var(--accent-glow));
|
||||
}
|
||||
|
||||
.content { padding: 24px; max-width: 1280px; margin: 0 auto; }
|
||||
.content { padding: 24px; max-width: 1600px; margin: 0 auto; }
|
||||
@media (min-width: 2400px) { .content { max-width: 1900px; } }
|
||||
/* long URLs, hashes, fingerprints would otherwise blow out their card on big monitors */
|
||||
.card { min-width: 0; }
|
||||
.card code, .card a, .card dd, .card li,
|
||||
.sp-hash, .sp-id, .lg-hash, .news-case-id, .feed-name {
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
.footer { text-align: center; color: var(--muted); padding: 24px; font-size: 12px; }
|
||||
.footer a { color: var(--accent); }
|
||||
|
||||
@@ -740,6 +748,18 @@ body.wide .net-grid { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr
|
||||
text-shadow: 0 0 22px var(--accent-glow);
|
||||
}
|
||||
.hero-sub { margin: 6px 0 0; color: var(--muted); font-size: 13px; }
|
||||
.hero-meta { margin: 10px 0 0; font-size: 12px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.hero-explore {
|
||||
color: var(--accent);
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
text-decoration: none;
|
||||
border: 1px solid var(--border);
|
||||
padding: 3px 9px;
|
||||
border-radius: 999px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.hero-explore:hover { border-color: var(--accent); box-shadow: 0 0 10px var(--accent-glow); }
|
||||
.hero-explore-sub { color: var(--muted); font-size: 11px; font-family: ui-monospace, Menlo, Consolas, monospace; }
|
||||
.hero-cta {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 9px 16px; border: 1px solid var(--accent); border-radius: 8px;
|
||||
@@ -1158,3 +1178,714 @@ body.wide .net-grid { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr
|
||||
.stat-country { border-color: rgba(251,191,36,0.4); color: var(--amber); }
|
||||
.stat-confidence { border-color: rgba(167,139,250,0.4); color: #c4b5fd; }
|
||||
.stat-family { border-color: rgba(248,113,113,0.4); color: var(--red); }
|
||||
|
||||
/* ── federation network graph ──────────────────────────────── */
|
||||
.fn-stats { display: flex; flex-wrap: wrap; gap: 10px; margin: 8px 0 18px; }
|
||||
.fn-stat {
|
||||
flex: 1; min-width: 120px;
|
||||
background: var(--panel-2); border: 1px solid var(--border); border-radius: 6px;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
.fn-stat-label { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; }
|
||||
.fn-stat-value { font-family: var(--font-display); font-size: 22px; font-weight: 700; color: var(--accent); margin-top: 4px; text-shadow: 0 0 12px var(--accent-glow); }
|
||||
|
||||
#federation-network-graph { display: block; width: 100%; height: 620px; cursor: grab; }
|
||||
#federation-network-graph:active { cursor: grabbing; }
|
||||
|
||||
body.wide #federation-network-graph { height: 720px; }
|
||||
|
||||
.fn-edge { stroke: rgba(125,133,151,0.45); stroke-width: 1.2; fill: none; }
|
||||
.fn-edge-label { fill: var(--muted); font-size: 9px; text-anchor: middle; font-family: ui-monospace, Menlo, monospace; opacity: 0.7; pointer-events: none; }
|
||||
|
||||
.fn-kind-vouch .fn-edge { stroke: rgba(74,222,128,0.7); stroke-width: 1.8; }
|
||||
.fn-kind-vouch .fn-edge-label { fill: rgba(160,240,190,0.85); font-weight: 600; }
|
||||
.fn-kind-signal .fn-edge { stroke: rgba(30,200,255,0.65); stroke-dasharray: 5 4; }
|
||||
.fn-kind-signal .fn-edge-label { fill: rgba(170, 220, 255, 0.85); }
|
||||
.fn-kind-knows .fn-edge { stroke: rgba(125,133,151,0.32); stroke-dasharray: 2 4; }
|
||||
.fn-kind-knows .fn-edge-label { display: none; }
|
||||
|
||||
.fn-edge.alive { animation: fn-flow 1.6s linear infinite; }
|
||||
.fn-edge.dim { opacity: 0.55; }
|
||||
@keyframes fn-flow { to { stroke-dashoffset: -54; } }
|
||||
#federation-network-graph.flow-off .fn-edge.alive { animation: none; }
|
||||
@media (prefers-reduced-motion: reduce) { .fn-edge.alive { animation: none; } }
|
||||
|
||||
.fn-node { cursor: grab; }
|
||||
.fn-node.dragging { cursor: grabbing; }
|
||||
.fn-node circle, .fn-node rect { transition: filter 0.15s; }
|
||||
.fn-node:hover circle, .fn-node:hover rect { filter: drop-shadow(0 0 10px var(--accent)); }
|
||||
|
||||
/* Self — accent-glowing rounded square. */
|
||||
.fn-self rect {
|
||||
fill: rgba(30,200,255,0.18); stroke: var(--accent); stroke-width: 2;
|
||||
filter: drop-shadow(0 0 14px var(--accent-glow));
|
||||
}
|
||||
.fn-self .fn-label { fill: var(--accent); font-weight: 700; letter-spacing: 0.10em; font-size: 13px; }
|
||||
|
||||
/* Direct peers (distance=1). Status drives color. */
|
||||
.fn-status-trusted circle { fill: rgba(74,222,128,0.12); stroke: var(--green); stroke-width: 2; }
|
||||
.fn-status-vouched circle { fill: rgba(167,139,250,0.12); stroke: #a78bfa; stroke-width: 1.8; stroke-dasharray: 4 3; }
|
||||
.fn-status-unknown circle { fill: rgba(125,133,151,0.10); stroke: var(--muted); stroke-width: 1.6; }
|
||||
.fn-status-blocked circle { fill: rgba(248,113,113,0.10); stroke: var(--red); stroke-width: 1.6; }
|
||||
|
||||
/* Transitive (distance=2) — fade and shrink the stroke. */
|
||||
.fn-distance-2 circle { opacity: 0.78; stroke-width: 1.2; }
|
||||
.fn-distance-2 .fn-label { fill: var(--muted); font-size: 9.5px; }
|
||||
.fn-distance-2 .fn-sublabel { display: none; }
|
||||
|
||||
.fn-label, .fn-sublabel { text-anchor: middle; pointer-events: none; font-family: var(--font-display); }
|
||||
.fn-label { fill: var(--text); font-size: 11px; }
|
||||
.fn-sublabel { fill: var(--muted); font-size: 9px; font-family: ui-monospace, Menlo, monospace; letter-spacing: 0.04em; }
|
||||
|
||||
.fn-node.selected circle, .fn-node.selected rect {
|
||||
filter: drop-shadow(0 0 14px var(--accent));
|
||||
}
|
||||
.fn-node.selected .fn-label { fill: #eaf6ff; font-weight: 700; }
|
||||
|
||||
/* Legend swatches. */
|
||||
.lg-swatch { display: inline-block; width: 10px; height: 10px; border-radius: 50%; border: 1.5px solid; vertical-align: -1px; }
|
||||
.fn-lg-self { border-color: var(--accent); background: rgba(30,200,255,0.18); }
|
||||
.fn-lg-trusted { border-color: var(--green); background: rgba(74,222,128,0.18); }
|
||||
.fn-lg-vouched { border-color: #a78bfa; background: rgba(167,139,250,0.18); }
|
||||
.fn-lg-unknown { border-color: var(--muted); background: rgba(125,133,151,0.18); }
|
||||
.fn-lg-blocked { border-color: var(--red); background: rgba(248,113,113,0.18); }
|
||||
|
||||
/* Detail status badge tinting. */
|
||||
.fn-status-badge-self { color: var(--accent); border-color: var(--accent); background: rgba(30,200,255,0.10); }
|
||||
.fn-status-badge-trusted { color: var(--green); border-color: var(--green); background: rgba(74,222,128,0.10); }
|
||||
.fn-status-badge-vouched { color: #c4b5fd; border-color: #a78bfa; background: rgba(167,139,250,0.10); }
|
||||
.fn-status-badge-unknown { color: var(--muted); border-color: var(--muted); }
|
||||
.fn-status-badge-blocked { color: var(--red); border-color: var(--red); background: rgba(248,113,113,0.10); }
|
||||
|
||||
/* ---------- federation network — enriched detail layer ---------------- */
|
||||
|
||||
/* Per-node stat badge: small monospace pill sitting just below the
|
||||
sublabel ("8 sig · 2 vch · 1 quo"). SVG <text> styled, not a real
|
||||
HTML pill — we keep it inline with the node group for layout. */
|
||||
.fn-stat-badge {
|
||||
fill: var(--accent);
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
font-size: 11px;
|
||||
text-anchor: middle;
|
||||
pointer-events: none;
|
||||
opacity: 0.85;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.fn-distance-2 .fn-stat-badge { display: none; }
|
||||
|
||||
/* Corroboration edges — dotted faint accent, lower z visually. */
|
||||
.fn-kind-corroborate .fn-edge {
|
||||
stroke: var(--accent);
|
||||
stroke-width: 1.1;
|
||||
stroke-dasharray: 1 5;
|
||||
stroke-linecap: round;
|
||||
opacity: 0.28;
|
||||
animation: fn-corr-pulse 3.2s ease-in-out infinite;
|
||||
}
|
||||
.fn-kind-corroborate .fn-edge-label {
|
||||
fill: rgba(170, 220, 255, 0.55);
|
||||
font-size: 8.5px;
|
||||
display: none; /* surfaced via tooltip; chart stays calm */
|
||||
}
|
||||
.fn-kind-corroborate .fn-edge-grp { pointer-events: none; }
|
||||
@keyframes fn-corr-pulse {
|
||||
0%, 100% { stroke-opacity: 0.22; }
|
||||
50% { stroke-opacity: 0.45; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.fn-kind-corroborate .fn-edge { animation: none; }
|
||||
}
|
||||
#federation-network-graph.flow-off .fn-kind-corroborate .fn-edge { animation: none; }
|
||||
|
||||
/* Hover tooltip — absolutely positioned, accent-bordered HUD pill. */
|
||||
.fn-tooltip {
|
||||
position: absolute;
|
||||
z-index: 50;
|
||||
background: rgba(15, 17, 21, 0.96);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 0 18px var(--accent-glow), 0 6px 22px rgba(0,0,0,0.55);
|
||||
padding: 8px 10px;
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
font-size: 11px;
|
||||
color: var(--text);
|
||||
line-height: 1.45;
|
||||
pointer-events: none;
|
||||
max-width: 320px;
|
||||
white-space: nowrap;
|
||||
display: none;
|
||||
}
|
||||
.fn-tooltip.is-visible { display: block; }
|
||||
.fn-tooltip-title {
|
||||
color: var(--accent);
|
||||
font-family: var(--font-display);
|
||||
font-size: 12px;
|
||||
margin-bottom: 4px;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.fn-tooltip-row { display: flex; gap: 10px; }
|
||||
.fn-tooltip-row .k { color: var(--muted); min-width: 70px; }
|
||||
.fn-tooltip-row .v { color: var(--text); }
|
||||
|
||||
/* Search/filter bar above the graph. */
|
||||
.fn-search-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
.fn-search-bar label {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.fn-search-input {
|
||||
flex: 1;
|
||||
max-width: 460px;
|
||||
background: var(--panel-2);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 6px 10px;
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.fn-search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-glow);
|
||||
}
|
||||
.fn-search-count { color: var(--muted); font-size: 11px; font-family: ui-monospace, Menlo, Consolas, monospace; }
|
||||
|
||||
/* Search dim/highlight states. */
|
||||
.fn-node.dimmed { opacity: 0.15; }
|
||||
.fn-node.match circle, .fn-node.match rect { stroke: var(--amber); stroke-width: 2.4; filter: drop-shadow(0 0 8px rgba(251,191,36,0.55)); }
|
||||
.fn-edge-grp.dimmed { opacity: 0.08; }
|
||||
|
||||
/* Rich detail card — sits in the existing .topo-detail container. */
|
||||
.fn-detail-card {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.fn-detail-sec {
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 10px 12px;
|
||||
min-width: 0; /* allow children to wrap */
|
||||
}
|
||||
.fn-detail-sec h4 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 11px;
|
||||
font-family: var(--font-display);
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.10em;
|
||||
font-weight: 600;
|
||||
}
|
||||
.fn-detail-sec .row { display: flex; justify-content: space-between; gap: 8px; font-size: 12px; padding: 2px 0; }
|
||||
.fn-detail-sec .row .k { color: var(--muted); }
|
||||
.fn-detail-sec .row .v { color: var(--text); font-family: ui-monospace, Menlo, Consolas, monospace; word-break: break-all; }
|
||||
.fn-detail-sec code {
|
||||
font-size: 11px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
padding: 2px 5px;
|
||||
word-break: break-all;
|
||||
display: inline-block;
|
||||
}
|
||||
.fn-detail-sec .full-fp { font-size: 11px; line-height: 1.55; }
|
||||
.fn-copy-btn {
|
||||
display: inline-block;
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
padding: 1px 6px;
|
||||
margin-left: 6px;
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
cursor: pointer;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.fn-copy-btn:hover { border-color: var(--accent); box-shadow: 0 0 8px var(--accent-glow); }
|
||||
|
||||
/* Severity chips inside the Signals section. */
|
||||
.fn-sev-row { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; }
|
||||
.fn-sev-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 10px;
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--panel);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.fn-sev-chip .n { font-weight: 700; }
|
||||
.fn-sev-critical { color: var(--red); border-color: var(--red); background: rgba(248,113,113,0.10); }
|
||||
.fn-sev-high { color: var(--amber); border-color: var(--amber); background: rgba(251,191,36,0.10); }
|
||||
.fn-sev-medium { color: #fde68a; border-color: rgba(253,224,71,0.55); background: rgba(253,224,71,0.06); }
|
||||
.fn-sev-low { color: var(--muted); border-color: var(--border); }
|
||||
|
||||
/* IOC-type chips reuse the chip shell with muted accents. */
|
||||
.fn-ioc-chip {
|
||||
display: inline-flex; gap: 4px; padding: 2px 8px;
|
||||
border-radius: 10px; font-size: 10px;
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
border: 1px solid var(--border); background: var(--panel);
|
||||
color: var(--text);
|
||||
}
|
||||
.fn-ioc-chip .k { color: var(--accent); }
|
||||
.fn-ioc-chip .n { color: var(--text); font-weight: 700; }
|
||||
|
||||
/* Quorum progress bar. */
|
||||
.fn-quorum-bar {
|
||||
position: relative;
|
||||
height: 8px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.fn-quorum-fill {
|
||||
position: absolute; inset: 0 auto 0 0;
|
||||
background: linear-gradient(90deg, var(--accent), var(--green));
|
||||
box-shadow: 0 0 8px var(--accent-glow);
|
||||
}
|
||||
|
||||
/* Translog list inside the detail card. */
|
||||
.fn-trans-list {
|
||||
list-style: none; margin: 0; padding: 0;
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
font-size: 11px;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.fn-trans-list li {
|
||||
display: flex; gap: 8px; padding: 3px 0;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
}
|
||||
.fn-trans-list .id { color: var(--muted); min-width: 38px; }
|
||||
.fn-trans-list .type { color: var(--accent); min-width: 50px; }
|
||||
.fn-trans-list .ts { color: var(--muted); }
|
||||
.fn-trans-list .hash { color: var(--text); }
|
||||
|
||||
/* Clickable fingerprint chip — jumps to that peer in the graph. */
|
||||
.fn-fp-jump {
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
font-size: 11px;
|
||||
color: var(--accent);
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
padding: 1px 5px;
|
||||
cursor: pointer;
|
||||
margin: 2px 4px 2px 0;
|
||||
display: inline-block;
|
||||
}
|
||||
.fn-fp-jump:hover { border-color: var(--accent); text-shadow: 0 0 8px var(--accent-glow); }
|
||||
|
||||
/* Action buttons inside the detail card. */
|
||||
.fn-actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.fn-action-btn {
|
||||
display: inline-block;
|
||||
padding: 5px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
color: var(--accent);
|
||||
background: var(--panel);
|
||||
font-size: 12px;
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.fn-action-btn:hover { border-color: var(--accent); box-shadow: 0 0 10px var(--accent-glow); text-decoration: none; }
|
||||
|
||||
/* 24h timeline strip. */
|
||||
.fn-timeline-wrap {
|
||||
margin-top: 18px;
|
||||
padding: 12px 14px;
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.fn-timeline-head {
|
||||
display: flex; justify-content: space-between; align-items: baseline;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.fn-timeline-head h3 {
|
||||
margin: 0; font-size: 12px; color: var(--muted);
|
||||
text-transform: uppercase; letter-spacing: 0.10em; font-weight: 600;
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
.fn-timeline-head .meta { color: var(--muted); font-size: 11px; font-family: ui-monospace, Menlo, Consolas, monospace; }
|
||||
.fn-timeline {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 2px;
|
||||
height: 90px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
.fn-timeline-bar {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column-reverse; /* segments stack from bottom up */
|
||||
align-items: stretch;
|
||||
min-width: 6px;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
background: rgba(125,133,151,0.04);
|
||||
border-bottom: 1px solid transparent;
|
||||
cursor: default;
|
||||
}
|
||||
.fn-timeline-bar:hover { background: rgba(30,200,255,0.08); }
|
||||
.fn-timeline-bar-seg {
|
||||
width: 100%;
|
||||
min-height: 1px;
|
||||
transition: filter 0.15s;
|
||||
}
|
||||
.fn-timeline-bar:hover .fn-timeline-bar-seg { filter: brightness(1.25); }
|
||||
.fn-timeline-axis {
|
||||
display: flex; gap: 2px; margin-top: 4px;
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
font-size: 9px; color: var(--muted);
|
||||
}
|
||||
.fn-timeline-axis span { flex: 1; text-align: center; min-width: 6px; }
|
||||
.fn-timeline-empty {
|
||||
color: var(--muted); font-size: 12px; font-style: italic;
|
||||
text-align: center; padding: 22px 0;
|
||||
}
|
||||
|
||||
/* ===================================================================
|
||||
* federation explorer — public transparency page
|
||||
* Public-facing variant of the admin federation network UI. Reuses the
|
||||
* fn-* graph classes; fe-* is just the chrome around it.
|
||||
* =================================================================== */
|
||||
|
||||
.fe-page { background: var(--bg); }
|
||||
.fe-topbar { gap: 18px; }
|
||||
.fe-topbar .nav-toggle, .fe-topbar .nav { display: none; }
|
||||
.fe-badge {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
padding: 4px 12px;
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
font-size: 10.5px;
|
||||
color: var(--accent);
|
||||
background: rgba(30,200,255,0.08);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 999px;
|
||||
letter-spacing: 0.10em;
|
||||
text-transform: uppercase;
|
||||
box-shadow: 0 0 12px var(--accent-glow);
|
||||
margin-left: auto;
|
||||
}
|
||||
.fe-badge-dot {
|
||||
width: 7px; height: 7px; border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 8px var(--accent);
|
||||
animation: fe-pulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
@keyframes fe-pulse {
|
||||
0%, 100% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0.4; transform: scale(1.4); }
|
||||
}
|
||||
|
||||
.fe-hero { padding: 28px 32px; }
|
||||
.fe-hero-head { margin-bottom: 14px; }
|
||||
.fe-title {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--text);
|
||||
}
|
||||
.fe-title::before {
|
||||
content: "⌖ ";
|
||||
color: var(--accent);
|
||||
text-shadow: 0 0 12px var(--accent-glow);
|
||||
}
|
||||
.fe-sub {
|
||||
margin: 6px 0 0;
|
||||
color: var(--accent);
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
word-break: break-all;
|
||||
text-shadow: 0 0 8px var(--accent-glow);
|
||||
}
|
||||
.fe-intro {
|
||||
margin: 14px 0 0;
|
||||
max-width: 920px;
|
||||
color: var(--text);
|
||||
line-height: 1.55;
|
||||
font-size: 14px;
|
||||
}
|
||||
.fe-intro strong { color: var(--accent); font-weight: 600; }
|
||||
.fe-intro-sub { color: var(--muted); font-size: 12px; margin-top: 10px; }
|
||||
.fe-fp {
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
font-size: 11px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
padding: 2px 6px;
|
||||
color: var(--accent);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.fe-kpi-panel { padding: 18px 22px; }
|
||||
.fe-kpis { gap: 14px; }
|
||||
.fe-kpis .fn-stat { min-width: 130px; }
|
||||
.fe-kpi-verify .fn-stat-value { font-size: 18px; }
|
||||
.fe-kpi-verify .fn-stat-value.fe-verify-ok { color: var(--green); text-shadow: 0 0 10px rgba(74,222,128,0.45); }
|
||||
.fe-kpi-verify .fn-stat-value.fe-verify-bad { color: var(--red); text-shadow: 0 0 10px rgba(248,113,113,0.45); }
|
||||
|
||||
.fe-verify-row {
|
||||
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
|
||||
margin-top: 14px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px dashed var(--border);
|
||||
}
|
||||
.fe-verify-btn {
|
||||
font-family: var(--font-display);
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.fe-verify-result {
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.fe-verify-result.fe-verify-ok { color: var(--green); }
|
||||
.fe-verify-result.fe-verify-bad { color: var(--red); }
|
||||
.fe-verify-link {
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
border-bottom: 1px dotted var(--border);
|
||||
}
|
||||
.fe-verify-link:hover { color: var(--accent); border-color: var(--accent); }
|
||||
|
||||
.fe-stage { margin-top: 8px; }
|
||||
|
||||
.fe-walk {
|
||||
margin-top: 16px;
|
||||
padding: 14px 18px;
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
min-height: 56px;
|
||||
}
|
||||
.fe-walk-empty {
|
||||
margin: 0; color: var(--muted);
|
||||
font-style: italic; font-size: 13px;
|
||||
}
|
||||
.fe-walk-card {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
.fe-walk-card-body { min-width: 0; }
|
||||
.fe-walk-card-title {
|
||||
margin: 0 0 4px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
word-break: break-all;
|
||||
}
|
||||
.fe-walk-card-fp {
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
word-break: break-all;
|
||||
}
|
||||
.fe-walk-card-stats {
|
||||
display: flex; flex-wrap: wrap; gap: 8px;
|
||||
margin-top: 10px;
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
.fe-walk-card-stats .k { color: var(--muted); }
|
||||
.fe-walk-card-stats .v { color: var(--accent); }
|
||||
.fe-walk-card-stats > span {
|
||||
padding: 2px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
background: var(--panel);
|
||||
}
|
||||
.fe-walk-cta {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
padding: 10px 18px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--bg);
|
||||
background: var(--accent);
|
||||
border: 1px solid var(--accent);
|
||||
border-radius: 5px;
|
||||
text-decoration: none;
|
||||
box-shadow: 0 0 14px var(--accent-glow);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.fe-walk-cta:hover {
|
||||
background: #66daff;
|
||||
text-decoration: none;
|
||||
}
|
||||
.fe-walk-cta-disabled {
|
||||
color: var(--muted);
|
||||
background: transparent;
|
||||
border-color: var(--border);
|
||||
box-shadow: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.fe-vouches-panel .fe-vouches-in-list {
|
||||
list-style: none;
|
||||
margin: 0; padding: 0;
|
||||
}
|
||||
.fe-vouches-in-list li {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px dashed var(--border);
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
.fe-vouches-in-list li:last-child { border-bottom: 0; }
|
||||
.fe-vouches-in-list .fp { color: var(--accent); word-break: break-all; }
|
||||
.fe-vouches-in-list .ts { color: var(--muted); font-size: 11px; }
|
||||
.fe-vouches-in-empty {
|
||||
color: var(--muted); font-style: italic;
|
||||
display: block !important;
|
||||
text-align: center;
|
||||
padding: 18px 0;
|
||||
}
|
||||
|
||||
.fe-footer {
|
||||
margin-top: 36px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.fe-hero { padding: 18px 16px; }
|
||||
.fe-title { font-size: 24px; }
|
||||
.fe-walk-card {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.fe-walk-cta { width: 100%; justify-content: center; }
|
||||
}
|
||||
|
||||
/* peer self-view section inside the detail panel — fetched cross-origin */
|
||||
.fn-remote-sec { grid-column: 1 / -1; }
|
||||
.fn-remote-status {
|
||||
font-size: 11px; color: var(--muted); margin-left: 8px; font-weight: 400;
|
||||
text-transform: lowercase; letter-spacing: 0.02em;
|
||||
}
|
||||
.fn-remote-meta {
|
||||
display: flex; flex-wrap: wrap; gap: 12px 18px;
|
||||
font-size: 12px; color: var(--muted); margin-bottom: 12px;
|
||||
}
|
||||
.fn-remote-meta b { color: var(--text); font-weight: 600; }
|
||||
.fn-remote-meta code { font-size: 11px; color: var(--accent); }
|
||||
.fn-remote-ok { color: rgba(74,222,128,0.95); }
|
||||
.fn-remote-warn { color: rgba(251,191,36,0.95); }
|
||||
.fn-remote-cols {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 14px; margin: 8px 0 12px;
|
||||
}
|
||||
.fn-remote-h { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 6px; }
|
||||
.fn-remote-list { list-style: none; padding: 0; margin: 0; font-size: 12px; line-height: 1.55; }
|
||||
.fn-remote-list li { padding: 2px 0; border-bottom: 1px dashed rgba(125,133,151,0.18); }
|
||||
.fn-remote-list li:last-child { border-bottom: 0; }
|
||||
.fn-remote-list code { font-size: 11px; color: var(--accent); }
|
||||
.fn-remote-list .muted { color: var(--muted); margin-left: 6px; font-size: 11px; }
|
||||
.fn-remote-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 6px; }
|
||||
|
||||
/* peer container topology — sanitized snapshot fetched from peer's
|
||||
/federation/topology. Full-width row inside the detail card; networks
|
||||
+ containers in two columns, each row tagged with a small state dot. */
|
||||
.fn-topology-sec { grid-column: 1 / -1; }
|
||||
.fn-topology-meta {
|
||||
display: flex; flex-wrap: wrap; gap: 6px 16px;
|
||||
font-size: 11px; color: var(--muted); margin-bottom: 10px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.fn-topology-meta code { font-size: 11px; color: var(--accent); }
|
||||
.fn-topology-cols {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 14px; margin: 4px 0 6px;
|
||||
}
|
||||
.fn-topology-h {
|
||||
font-size: 11px; color: var(--muted);
|
||||
text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 6px;
|
||||
}
|
||||
.fn-topology-list { list-style: none; padding: 0; margin: 0; font-size: 12px; line-height: 1.55; }
|
||||
.fn-topology-list li {
|
||||
padding: 4px 0; border-bottom: 1px dashed rgba(125,133,151,0.18);
|
||||
display: block;
|
||||
}
|
||||
.fn-topology-list li:last-child { border-bottom: 0; }
|
||||
.fn-topology-list .muted { color: var(--muted); font-size: 11px; }
|
||||
|
||||
/* small colored dot indicating container state */
|
||||
.fn-topo-state-dot {
|
||||
display: inline-block; width: 6px; height: 6px; border-radius: 50%;
|
||||
margin-right: 8px; vertical-align: middle;
|
||||
background: rgba(125,133,151,0.7);
|
||||
}
|
||||
.fn-topo-state-running { background: rgba(74,222,128,1); box-shadow: 0 0 4px rgba(74,222,128,0.55); }
|
||||
.fn-topo-state-exited { background: rgba(125,133,151,0.7); }
|
||||
.fn-topo-state-paused { background: rgba(251,191,36,1); }
|
||||
.fn-topo-state-restarting,
|
||||
.fn-topo-state-dead,
|
||||
.fn-topo-state-unhealthy { background: rgba(248,113,113,1); }
|
||||
|
||||
.fn-topo-cname { color: var(--text); font-family: ui-monospace, Menlo, Consolas, monospace; }
|
||||
.fn-topo-netname { color: var(--text); font-family: ui-monospace, Menlo, Consolas, monospace; }
|
||||
.fn-topo-image {
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
font-size: 11px; color: var(--muted);
|
||||
margin-left: 14px; word-break: break-all;
|
||||
}
|
||||
.fn-topo-int {
|
||||
display: inline-block; font-size: 10px;
|
||||
padding: 1px 5px; margin-left: 6px;
|
||||
border-radius: 3px; color: var(--amber);
|
||||
background: rgba(251,191,36,0.12);
|
||||
border: 1px solid rgba(251,191,36,0.25);
|
||||
text-transform: uppercase; letter-spacing: 0.05em;
|
||||
}
|
||||
.fn-topo-health {
|
||||
display: inline-block; font-size: 10px;
|
||||
padding: 1px 5px; margin-left: 6px;
|
||||
border-radius: 3px; text-transform: uppercase; letter-spacing: 0.05em;
|
||||
background: rgba(125,133,151,0.15); color: var(--muted);
|
||||
border: 1px solid rgba(125,133,151,0.25);
|
||||
}
|
||||
.fn-topo-h-healthy { color: var(--green); border-color: rgba(74,222,128,0.35); background: rgba(74,222,128,0.10); }
|
||||
.fn-topo-h-unhealthy { color: var(--red); border-color: rgba(248,113,113,0.35); background: rgba(248,113,113,0.10); }
|
||||
.fn-topo-h-starting { color: var(--amber); border-color: rgba(251,191,36,0.35); background: rgba(251,191,36,0.10); }
|
||||
|
||||
780
src/psyc/cockpit/static/federation_explore.js
Normal file
780
src/psyc/cockpit/static/federation_explore.js
Normal file
@@ -0,0 +1,780 @@
|
||||
/* psyc — federation explorer (public, transparency view).
|
||||
*
|
||||
* Forked from federation_network.js, adapted for the public surface:
|
||||
* • data source is /federation/explore/data (signed, CORS-enabled)
|
||||
* • clicking a peer opens a walk-to-peer card with a primary CTA
|
||||
* that full-page-navigates to that peer's own /federation/explore
|
||||
* • the transparency log can be re-verified live from the page
|
||||
* • inbound vouches (who vouches for THIS node) get their own section
|
||||
* • severity/IOC-type breakdowns are intentionally NOT surfaced —
|
||||
* those stay admin-only to avoid sector-leaking via the public page
|
||||
*
|
||||
* Pure vanilla JS, no deps. Mirrors topology.js's proven force-sim loop.
|
||||
*/
|
||||
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
const svg = document.getElementById("federation-network-graph");
|
||||
const loadingEl = document.getElementById("fn-loading");
|
||||
const errorEl = document.getElementById("fn-error");
|
||||
const tooltipEl = document.getElementById("fn-tooltip");
|
||||
const walkEl = document.getElementById("fe-walk");
|
||||
const directCountEl = document.getElementById("fe-direct-count");
|
||||
const transitiveCountEl = document.getElementById("fe-transitive-count");
|
||||
const kpiPeers = document.getElementById("fe-kpi-peers");
|
||||
const kpiVouchesOut = document.getElementById("fe-kpi-vouches-out");
|
||||
const kpiVouchesIn = document.getElementById("fe-kpi-vouches-in");
|
||||
const kpiSignals = document.getElementById("fe-kpi-signals");
|
||||
const kpiCorroboration = document.getElementById("fe-kpi-corroboration");
|
||||
const kpiTranslog = document.getElementById("fe-kpi-translog");
|
||||
const kpiVerify = document.getElementById("fe-kpi-verify");
|
||||
const verifyBtn = document.getElementById("fe-verify-btn");
|
||||
const verifyResult = document.getElementById("fe-verify-result");
|
||||
const vouchesInList = document.getElementById("fe-vouches-in-list");
|
||||
const vouchesInCountEl = document.getElementById("fe-vouches-in-count");
|
||||
const settings = window.PSYC_EXPLORE || {};
|
||||
|
||||
if (!svg) return;
|
||||
|
||||
// ---------- shared escape -----------------------------------------------
|
||||
function esc(s) {
|
||||
return String(s == null ? "" : s).replace(/[&<>"']/g, c =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||
}
|
||||
function shortFp(fp) {
|
||||
if (!fp) return "—";
|
||||
if (fp.length >= 16) return fp.slice(0, 8) + "…" + fp.slice(-8);
|
||||
return fp;
|
||||
}
|
||||
function fmtAge(iso) {
|
||||
if (!iso) return "—";
|
||||
const ts = new Date(iso);
|
||||
if (isNaN(ts.getTime())) return "—";
|
||||
const secs = Math.floor((Date.now() - ts.getTime()) / 1000);
|
||||
if (secs < 0) return "just now";
|
||||
if (secs < 60) return secs + "s ago";
|
||||
if (secs < 3600) return Math.floor(secs / 60) + "m ago";
|
||||
if (secs < 86400) return Math.floor(secs / 3600) + "h ago";
|
||||
return Math.floor(secs / 86400) + "d ago";
|
||||
}
|
||||
|
||||
fetch("/federation/explore/data", { credentials: "omit" })
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||
return r.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (loadingEl) loadingEl.style.display = "none";
|
||||
render(data);
|
||||
})
|
||||
.catch(err => {
|
||||
if (loadingEl) loadingEl.style.display = "none";
|
||||
if (errorEl) {
|
||||
errorEl.style.display = "block";
|
||||
errorEl.textContent = "✗ failed to load explore payload: " + err.message;
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- verify button — fetch /federation/log/verify ---------------
|
||||
if (verifyBtn) {
|
||||
verifyBtn.addEventListener("click", () => {
|
||||
verifyBtn.disabled = true;
|
||||
verifyResult.textContent = "verifying…";
|
||||
verifyResult.classList.remove("fe-verify-ok", "fe-verify-bad");
|
||||
fetch("/federation/log/verify", { credentials: "omit" })
|
||||
.then(r => r.json().then(b => ({ status: r.status, body: b })))
|
||||
.then(({ status, body }) => {
|
||||
if (status === 200 && body.verified != null) {
|
||||
verifyResult.textContent = "✓ verified " + body.verified + " entries · head " + (body.head_hash || "").slice(0, 12) + "…";
|
||||
verifyResult.classList.add("fe-verify-ok");
|
||||
if (kpiVerify) {
|
||||
kpiVerify.textContent = "✓ ok";
|
||||
kpiVerify.classList.add("fe-verify-ok");
|
||||
kpiVerify.classList.remove("fe-verify-bad");
|
||||
}
|
||||
} else {
|
||||
verifyResult.textContent = "✗ " + (body.error || "chain invalid");
|
||||
verifyResult.classList.add("fe-verify-bad");
|
||||
if (kpiVerify) {
|
||||
kpiVerify.textContent = "✗ broken";
|
||||
kpiVerify.classList.add("fe-verify-bad");
|
||||
kpiVerify.classList.remove("fe-verify-ok");
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
verifyResult.textContent = "✗ fetch failed: " + err.message;
|
||||
verifyResult.classList.add("fe-verify-bad");
|
||||
})
|
||||
.finally(() => { verifyBtn.disabled = false; });
|
||||
});
|
||||
}
|
||||
|
||||
function render(data) {
|
||||
const node = data.node || {};
|
||||
const selfFp = data.fingerprint || node.fingerprint || "";
|
||||
const peersData = data.peers || [];
|
||||
const transitiveData = data.transitive_peers || [];
|
||||
const vouchesIn = data.vouches_in || [];
|
||||
const vouchesOut = data.vouches_out || data.vouches || [];
|
||||
|
||||
// ---------- KPI strip ------------------------------------------------
|
||||
if (kpiPeers) kpiPeers.textContent = String(node.peer_count != null ? node.peer_count : peersData.length);
|
||||
if (kpiVouchesOut) kpiVouchesOut.textContent = String(node.vouches_out_count != null ? node.vouches_out_count : vouchesOut.length);
|
||||
if (kpiVouchesIn) kpiVouchesIn.textContent = String(node.vouches_in_count != null ? node.vouches_in_count : vouchesIn.length);
|
||||
if (kpiSignals) kpiSignals.textContent = String(node.signals_count_24h || 0);
|
||||
if (kpiCorroboration) kpiCorroboration.textContent = String(node.corroboration_count_24h || 0);
|
||||
if (kpiTranslog) kpiTranslog.textContent = String(node.translog_entry_count || 0);
|
||||
if (kpiVerify) kpiVerify.textContent = "unverified";
|
||||
|
||||
if (directCountEl) directCountEl.textContent = String(peersData.length);
|
||||
if (transitiveCountEl) transitiveCountEl.textContent = String(transitiveData.length);
|
||||
|
||||
// ---------- node + edge model ----------------------------------------
|
||||
// The explore payload doesn't ship edges directly; we derive them from
|
||||
// the vouches + per-peer signal counts so the graph reads the same way
|
||||
// the admin view does.
|
||||
const peerByFp = Object.create(null);
|
||||
const nodes = [];
|
||||
|
||||
// Self at the center.
|
||||
const selfNode = {
|
||||
id: selfFp, fp: selfFp,
|
||||
domain: settings.selfDomain || node.domain || "",
|
||||
label: settings.selfDomain || (node.domain || "self"),
|
||||
status: "self",
|
||||
is_self: true,
|
||||
distance: 0,
|
||||
stats: null,
|
||||
r: 38,
|
||||
intensity: 1,
|
||||
x: 0, y: 0, vx: 0, vy: 0, fixed: false,
|
||||
};
|
||||
nodes.push(selfNode);
|
||||
peerByFp[selfFp] = selfNode;
|
||||
|
||||
// Max signal count for log-intensity normalization.
|
||||
let maxSig = 0;
|
||||
for (const p of peersData) {
|
||||
if ((p.signal_count_24h || 0) > maxSig) maxSig = p.signal_count_24h || 0;
|
||||
}
|
||||
|
||||
for (const p of peersData) {
|
||||
const fp = p.fingerprint;
|
||||
if (!fp || fp === selfFp) continue;
|
||||
const sig = p.signal_count_24h || 0;
|
||||
let intensity = 1;
|
||||
if (maxSig > 0) {
|
||||
const num = Math.log2(sig + 1);
|
||||
const den = Math.log2(maxSig + 1) || 1;
|
||||
intensity = 0.20 + 0.80 * (num / den);
|
||||
}
|
||||
const n = {
|
||||
id: fp, fp,
|
||||
domain: p.domain || "",
|
||||
label: p.domain || shortFp(fp),
|
||||
status: "trusted",
|
||||
is_self: false,
|
||||
distance: 1,
|
||||
stats: p,
|
||||
r: 16,
|
||||
intensity,
|
||||
x: 0, y: 0, vx: 0, vy: 0, fixed: false,
|
||||
};
|
||||
nodes.push(n);
|
||||
peerByFp[fp] = n;
|
||||
}
|
||||
|
||||
for (const t of transitiveData) {
|
||||
const fp = t.fingerprint;
|
||||
if (!fp || peerByFp[fp]) continue;
|
||||
const n = {
|
||||
id: fp, fp,
|
||||
domain: t.domain || "",
|
||||
label: t.domain || shortFp(fp),
|
||||
status: "unknown",
|
||||
is_self: false,
|
||||
distance: 2,
|
||||
stats: null,
|
||||
via: t.via_peer_fingerprint || "",
|
||||
r: 9,
|
||||
intensity: 0.7,
|
||||
x: 0, y: 0, vx: 0, vy: 0, fixed: false,
|
||||
};
|
||||
nodes.push(n);
|
||||
peerByFp[fp] = n;
|
||||
}
|
||||
|
||||
// Edges. Per-peer signal counts → signal edges; outbound vouches →
|
||||
// vouch edges; vouches_in → bidirectional vouch edges; transitive
|
||||
// "via" → knows edges.
|
||||
const edges = [];
|
||||
for (const p of peersData) {
|
||||
const fp = p.fingerprint;
|
||||
if (!fp || fp === selfFp) continue;
|
||||
if ((p.signal_count_24h || 0) > 0) {
|
||||
edges.push({
|
||||
source: fp, target: selfFp, kind: "signal",
|
||||
weight: p.signal_count_24h, label: p.signal_count_24h + " signals/24h",
|
||||
bidirectional: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Outbound vouches.
|
||||
const outbound = new Set();
|
||||
for (const v of vouchesOut) {
|
||||
const tgt = v.target_fingerprint;
|
||||
if (!tgt || !peerByFp[tgt]) continue;
|
||||
outbound.add(tgt);
|
||||
edges.push({
|
||||
source: selfFp, target: tgt, kind: "vouch",
|
||||
weight: 1, label: "vouched", bidirectional: false,
|
||||
});
|
||||
}
|
||||
// Inbound vouches — collapse onto existing outbound where possible.
|
||||
for (const v of vouchesIn) {
|
||||
const src = v.voucher_fingerprint;
|
||||
if (!src || !peerByFp[src]) continue;
|
||||
if (outbound.has(src)) {
|
||||
const existing = edges.find(e => e.kind === "vouch"
|
||||
&& e.source === selfFp && e.target === src);
|
||||
if (existing) {
|
||||
existing.bidirectional = true;
|
||||
existing.label = "vouched ↔";
|
||||
continue;
|
||||
}
|
||||
}
|
||||
edges.push({
|
||||
source: src, target: selfFp, kind: "vouch",
|
||||
weight: 1, label: "vouches us", bidirectional: false,
|
||||
});
|
||||
}
|
||||
// Transitive "knows" edges.
|
||||
for (const t of transitiveData) {
|
||||
const parent = t.via_peer_fingerprint;
|
||||
if (!parent || !peerByFp[parent] || !peerByFp[t.fingerprint]) continue;
|
||||
edges.push({
|
||||
source: parent, target: t.fingerprint, kind: "knows",
|
||||
weight: 0.5, label: "knows", bidirectional: false,
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- viewport + seeding ---------------------------------------
|
||||
function viewport() {
|
||||
const W = svg.clientWidth || 900;
|
||||
const H = parseInt(getComputedStyle(svg).height, 10) || 560;
|
||||
return { W, H };
|
||||
}
|
||||
let { W, H } = viewport();
|
||||
svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
|
||||
|
||||
(function seed() {
|
||||
const cx = W / 2, cy = H / 2;
|
||||
nodes.forEach((n, i) => {
|
||||
if (n.is_self) { n.x = cx; n.y = cy; return; }
|
||||
const ring = n.distance >= 2 ? Math.min(W, H) * 0.40 : Math.min(W, H) * 0.22;
|
||||
const ang = (i / Math.max(1, nodes.length)) * Math.PI * 2;
|
||||
n.x = cx + ring * Math.cos(ang) + (Math.random() - 0.5) * 20;
|
||||
n.y = cy + ring * Math.sin(ang) + (Math.random() - 0.5) * 20;
|
||||
});
|
||||
})();
|
||||
|
||||
const REPULSION = 1500;
|
||||
const SPRING_K = 0.035;
|
||||
const SPRING_REST_BASE = 110;
|
||||
const DAMP = 0.82;
|
||||
const CENTER_PULL = 0.005;
|
||||
|
||||
function tick() {
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
const a = nodes[i];
|
||||
for (let j = i + 1; j < nodes.length; j++) {
|
||||
const b = nodes[j];
|
||||
const dx = b.x - a.x, dy = b.y - a.y;
|
||||
const d2 = dx * dx + dy * dy + 0.1;
|
||||
const d = Math.sqrt(d2);
|
||||
const f = REPULSION / d2;
|
||||
const fx = (dx / d) * f, fy = (dy / d) * f;
|
||||
if (!a.fixed) { a.vx -= fx; a.vy -= fy; }
|
||||
if (!b.fixed) { b.vx += fx; b.vy += fy; }
|
||||
}
|
||||
}
|
||||
for (const e of edges) {
|
||||
const a = peerByFp[e.source], b = peerByFp[e.target];
|
||||
if (!a || !b) continue;
|
||||
const rest = e.kind === "knows" ? SPRING_REST_BASE + 60 : SPRING_REST_BASE;
|
||||
const dx = b.x - a.x, dy = b.y - a.y;
|
||||
const d = Math.sqrt(dx * dx + dy * dy) + 0.1;
|
||||
const f = (d - rest) * SPRING_K;
|
||||
const fx = (dx / d) * f, fy = (dy / d) * f;
|
||||
if (!a.fixed) { a.vx += fx; a.vy += fy; }
|
||||
if (!b.fixed) { b.vx -= fx; b.vy -= fy; }
|
||||
}
|
||||
for (const n of nodes) {
|
||||
if (n.fixed) { n.vx = 0; n.vy = 0; continue; }
|
||||
n.vx += (W / 2 - n.x) * CENTER_PULL;
|
||||
n.vy += (H / 2 - n.y) * CENTER_PULL;
|
||||
n.vx *= DAMP; n.vy *= DAMP;
|
||||
n.x += n.vx; n.y += n.vy;
|
||||
n.x = Math.max(n.r, Math.min(W - n.r, n.x));
|
||||
n.y = Math.max(n.r, Math.min(H - n.r, n.y));
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < 280; i++) tick();
|
||||
|
||||
// ---------- render SVG groups ----------------------------------------
|
||||
const ns = "http://www.w3.org/2000/svg";
|
||||
const edgesG = document.createElementNS(ns, "g");
|
||||
const nodesG = document.createElementNS(ns, "g");
|
||||
edgesG.setAttribute("class", "fn-edges");
|
||||
nodesG.setAttribute("class", "fn-nodes");
|
||||
svg.appendChild(edgesG);
|
||||
svg.appendChild(nodesG);
|
||||
|
||||
const edgeEls = edges.map(e => {
|
||||
const grp = document.createElementNS(ns, "g");
|
||||
grp.setAttribute("class", "fn-edge-grp fn-kind-" + e.kind);
|
||||
grp.dataset.source = e.source;
|
||||
grp.dataset.target = e.target;
|
||||
const ln = document.createElementNS(ns, "line");
|
||||
ln.setAttribute("class", "fn-edge");
|
||||
if (e.kind === "signal") {
|
||||
const w = Math.min(5, 1 + Math.log2(e.weight + 1) * 0.8);
|
||||
ln.setAttribute("stroke-width", w.toFixed(2));
|
||||
}
|
||||
grp.appendChild(ln);
|
||||
if (e.label) {
|
||||
const lbl = document.createElementNS(ns, "text");
|
||||
lbl.setAttribute("class", "fn-edge-label");
|
||||
lbl.textContent = e.label;
|
||||
grp.appendChild(lbl);
|
||||
}
|
||||
edgesG.appendChild(grp);
|
||||
return { line: ln, label: grp.querySelector("text"), grp };
|
||||
});
|
||||
|
||||
function _classFor(n) {
|
||||
if (n.is_self) return "fn-node fn-self";
|
||||
const dist = n.distance >= 2 ? " fn-distance-2" : " fn-distance-1";
|
||||
return "fn-node fn-status-" + n.status + dist;
|
||||
}
|
||||
|
||||
const nodeEls = nodes.map(n => {
|
||||
const g = document.createElementNS(ns, "g");
|
||||
g.setAttribute("class", _classFor(n));
|
||||
g.dataset.fp = n.fp;
|
||||
|
||||
let shape;
|
||||
if (n.is_self) {
|
||||
const sz = n.r;
|
||||
shape = document.createElementNS(ns, "rect");
|
||||
shape.setAttribute("x", -sz); shape.setAttribute("y", -sz);
|
||||
shape.setAttribute("width", sz * 2); shape.setAttribute("height", sz * 2);
|
||||
shape.setAttribute("rx", 10); shape.setAttribute("ry", 10);
|
||||
g.appendChild(shape);
|
||||
} else {
|
||||
shape = document.createElementNS(ns, "circle");
|
||||
shape.setAttribute("r", n.r);
|
||||
shape.setAttribute("fill-opacity", n.intensity.toFixed(2));
|
||||
g.appendChild(shape);
|
||||
}
|
||||
|
||||
const text = document.createElementNS(ns, "text");
|
||||
text.setAttribute("class", "fn-label");
|
||||
text.setAttribute("dy", n.r + 13);
|
||||
text.textContent = n.label;
|
||||
g.appendChild(text);
|
||||
|
||||
if (!n.is_self) {
|
||||
const sub = document.createElementNS(ns, "text");
|
||||
sub.setAttribute("class", "fn-sublabel");
|
||||
sub.setAttribute("dy", n.r + 24);
|
||||
sub.textContent = n.fp.slice(0, 8) + "…";
|
||||
g.appendChild(sub);
|
||||
|
||||
if (n.stats) {
|
||||
const badge = document.createElementNS(ns, "text");
|
||||
badge.setAttribute("class", "fn-stat-badge");
|
||||
badge.setAttribute("dy", n.r + 36);
|
||||
badge.textContent =
|
||||
"↓ " + (n.stats.signal_count_24h || 0) +
|
||||
" · ⚡ " + (n.stats.quorum_contribution_24h || 0);
|
||||
g.appendChild(badge);
|
||||
}
|
||||
}
|
||||
|
||||
const title = document.createElementNS(ns, "title");
|
||||
title.textContent = (n.is_self ? "self · " : "") + (n.domain || n.label || n.fp);
|
||||
g.appendChild(title);
|
||||
|
||||
nodesG.appendChild(g);
|
||||
return g;
|
||||
});
|
||||
|
||||
function paint() {
|
||||
for (let i = 0; i < edges.length; i++) {
|
||||
const e = edges[i];
|
||||
const a = peerByFp[e.source], b = peerByFp[e.target];
|
||||
if (!a || !b) continue;
|
||||
const els = edgeEls[i];
|
||||
els.line.setAttribute("x1", a.x); els.line.setAttribute("y1", a.y);
|
||||
els.line.setAttribute("x2", b.x); els.line.setAttribute("y2", b.y);
|
||||
if (els.label) {
|
||||
const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
|
||||
els.label.setAttribute("x", mx); els.label.setAttribute("y", my - 3);
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
nodeEls[i].setAttribute("transform", `translate(${nodes[i].x},${nodes[i].y})`);
|
||||
}
|
||||
}
|
||||
paint();
|
||||
|
||||
// ---------- tooltip --------------------------------------------------
|
||||
function showTooltip(n, clientX, clientY) {
|
||||
if (!tooltipEl) return;
|
||||
const rows = [];
|
||||
rows.push(`<div class="fn-tooltip-title">${esc(n.domain || n.label || n.fp.slice(0,12))}</div>`);
|
||||
if (n.is_self) {
|
||||
rows.push(`<div class="fn-tooltip-row"><span class="k">role</span><span class="v">self</span></div>`);
|
||||
rows.push(`<div class="fn-tooltip-row"><span class="k">peers</span><span class="v">${node.peer_count || 0}</span></div>`);
|
||||
rows.push(`<div class="fn-tooltip-row"><span class="k">signals 24h</span><span class="v">${node.signals_count_24h || 0}</span></div>`);
|
||||
} else if (n.distance >= 2) {
|
||||
rows.push(`<div class="fn-tooltip-row"><span class="k">distance</span><span class="v">2 hops (transitive)</span></div>`);
|
||||
if (n.via) {
|
||||
const parent = peerByFp[n.via];
|
||||
const via = parent ? (parent.domain || shortFp(parent.fp)) : shortFp(n.via);
|
||||
rows.push(`<div class="fn-tooltip-row"><span class="k">via</span><span class="v">${esc(via)}</span></div>`);
|
||||
}
|
||||
rows.push(`<div class="fn-tooltip-row"><span class="k">tip</span><span class="v">click to walk →</span></div>`);
|
||||
} else {
|
||||
const s = n.stats || {};
|
||||
rows.push(`<div class="fn-tooltip-row"><span class="k">status</span><span class="v">trusted</span></div>`);
|
||||
rows.push(`<div class="fn-tooltip-row"><span class="k">signals 24h</span><span class="v">${s.signal_count_24h || 0}</span></div>`);
|
||||
rows.push(`<div class="fn-tooltip-row"><span class="k">quorum hits</span><span class="v">${s.quorum_contribution_24h || 0}</span></div>`);
|
||||
rows.push(`<div class="fn-tooltip-row"><span class="k">last seen</span><span class="v">${esc(fmtAge(s.last_seen))}</span></div>`);
|
||||
rows.push(`<div class="fn-tooltip-row"><span class="k">tip</span><span class="v">click to walk →</span></div>`);
|
||||
}
|
||||
tooltipEl.innerHTML = rows.join("");
|
||||
tooltipEl.classList.add("is-visible");
|
||||
positionTooltip(clientX, clientY);
|
||||
}
|
||||
function positionTooltip(clientX, clientY) {
|
||||
if (!tooltipEl) return;
|
||||
const parent = svg.parentElement;
|
||||
if (!parent) return;
|
||||
const rect = parent.getBoundingClientRect();
|
||||
let x = clientX - rect.left + 14;
|
||||
let y = clientY - rect.top + 14;
|
||||
const tw = tooltipEl.offsetWidth || 240;
|
||||
const th = tooltipEl.offsetHeight || 100;
|
||||
if (x + tw > parent.clientWidth - 4) x = clientX - rect.left - tw - 14;
|
||||
if (y + th > parent.clientHeight - 4) y = clientY - rect.top - th - 14;
|
||||
tooltipEl.style.left = x + "px";
|
||||
tooltipEl.style.top = y + "px";
|
||||
}
|
||||
function hideTooltip() {
|
||||
if (tooltipEl) tooltipEl.classList.remove("is-visible");
|
||||
}
|
||||
|
||||
// ---------- drag + click + hover ------------------------------------
|
||||
let dragging = null, dragOffset = { x: 0, y: 0 };
|
||||
let pressedNode = null, pressedAt = null, moved = false;
|
||||
let energyBudget = 40;
|
||||
function svgPoint(clientX, clientY) {
|
||||
const pt = svg.createSVGPoint(); pt.x = clientX; pt.y = clientY;
|
||||
return pt.matrixTransform(svg.getScreenCTM().inverse());
|
||||
}
|
||||
nodeEls.forEach((g, i) => {
|
||||
const n = nodes[i];
|
||||
g.addEventListener("mousedown", ev => {
|
||||
ev.preventDefault();
|
||||
pressedNode = n;
|
||||
pressedAt = { x: ev.clientX, y: ev.clientY };
|
||||
moved = false;
|
||||
dragging = n;
|
||||
const p = svgPoint(ev.clientX, ev.clientY);
|
||||
dragOffset.x = p.x - dragging.x; dragOffset.y = p.y - dragging.y;
|
||||
if (currentLayout === "force") dragging.fixed = true;
|
||||
g.classList.add("dragging");
|
||||
});
|
||||
g.addEventListener("mouseenter", ev => showTooltip(n, ev.clientX, ev.clientY));
|
||||
g.addEventListener("mousemove", ev => positionTooltip(ev.clientX, ev.clientY));
|
||||
g.addEventListener("mouseleave", hideTooltip);
|
||||
});
|
||||
document.addEventListener("mousemove", ev => {
|
||||
if (pressedAt) {
|
||||
const dx = ev.clientX - pressedAt.x, dy = ev.clientY - pressedAt.y;
|
||||
if (dx * dx + dy * dy > 16) moved = true;
|
||||
}
|
||||
if (!dragging) return;
|
||||
const p = svgPoint(ev.clientX, ev.clientY);
|
||||
dragging.x = p.x - dragOffset.x; dragging.y = p.y - dragOffset.y;
|
||||
dragging.vx = 0; dragging.vy = 0;
|
||||
energyBudget = 80;
|
||||
});
|
||||
document.addEventListener("mouseup", () => {
|
||||
if (dragging) {
|
||||
const g = nodesG.querySelector(`[data-fp="${CSS.escape(dragging.fp)}"]`);
|
||||
if (g) g.classList.remove("dragging");
|
||||
if (currentLayout === "force") dragging.fixed = false;
|
||||
dragging = null;
|
||||
}
|
||||
if (pressedNode && !moved) selectNode(pressedNode);
|
||||
pressedNode = null; pressedAt = null;
|
||||
});
|
||||
|
||||
// ---------- walk-to-peer card ---------------------------------------
|
||||
function selectNode(n) {
|
||||
nodeEls.forEach(el => el.classList.remove("selected"));
|
||||
const me = nodesG.querySelector(`[data-fp="${CSS.escape(n.fp)}"]`);
|
||||
if (me) me.classList.add("selected");
|
||||
renderWalk(n);
|
||||
}
|
||||
function jumpToFp(fp) {
|
||||
const t = peerByFp[fp];
|
||||
if (!t) return;
|
||||
selectNode(t);
|
||||
// Scroll the graph stage into view so the user sees the highlight.
|
||||
svg.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}
|
||||
|
||||
function vouchersFor(fp) {
|
||||
// Inbound vouches naming `fp`. Right now we only have inbound vouches
|
||||
// for SELF in the public payload; for any other peer we don't see
|
||||
// who-vouches-for-them from this page.
|
||||
if (fp === selfFp) return vouchesIn.map(v => v.voucher_fingerprint);
|
||||
return [];
|
||||
}
|
||||
|
||||
function renderWalk(n) {
|
||||
if (!walkEl) return;
|
||||
const isSelf = n.is_self;
|
||||
const isTransitive = n.distance >= 2;
|
||||
const stats = n.stats || {};
|
||||
|
||||
const targetDomain = n.domain || (isSelf ? settings.selfDomain : "");
|
||||
const peerHref = targetDomain
|
||||
? `https://${targetDomain}/federation/explore`
|
||||
: "";
|
||||
|
||||
const statsHtml = [];
|
||||
if (isSelf) {
|
||||
statsHtml.push(`<span><span class="k">peers</span> <span class="v">${node.peer_count || 0}</span></span>`);
|
||||
statsHtml.push(`<span><span class="k">signals 24h</span> <span class="v">${node.signals_count_24h || 0}</span></span>`);
|
||||
statsHtml.push(`<span><span class="k">corroborations</span> <span class="v">${node.corroboration_count_24h || 0}</span></span>`);
|
||||
statsHtml.push(`<span><span class="k">translog</span> <span class="v">${node.translog_entry_count || 0} entries</span></span>`);
|
||||
} else if (isTransitive) {
|
||||
const parent = peerByFp[n.via];
|
||||
const via = parent ? (parent.domain || shortFp(parent.fp)) : shortFp(n.via);
|
||||
statsHtml.push(`<span><span class="k">distance</span> <span class="v">2 hops</span></span>`);
|
||||
statsHtml.push(`<span><span class="k">learned via</span> <span class="v">${esc(via)}</span></span>`);
|
||||
statsHtml.push(`<span><span class="k">stats</span> <span class="v">— (peer-side only)</span></span>`);
|
||||
} else {
|
||||
statsHtml.push(`<span><span class="k">status</span> <span class="v">trusted</span></span>`);
|
||||
statsHtml.push(`<span><span class="k">signals 24h</span> <span class="v">${stats.signal_count_24h || 0}</span></span>`);
|
||||
statsHtml.push(`<span><span class="k">cases / iocs 24h</span> <span class="v">${stats.cases_24h || 0} / ${stats.iocs_24h || 0}</span></span>`);
|
||||
statsHtml.push(`<span><span class="k">quorum hits</span> <span class="v">${stats.quorum_contribution_24h || 0}</span></span>`);
|
||||
statsHtml.push(`<span><span class="k">last seen</span> <span class="v">${esc(fmtAge(stats.last_seen))}</span></span>`);
|
||||
}
|
||||
|
||||
const cta = peerHref
|
||||
? `<a class="fe-walk-cta" href="${esc(peerHref)}">View this peer's federation <span aria-hidden="true">→</span></a>`
|
||||
: `<span class="fe-walk-cta fe-walk-cta-disabled" title="no public domain on file for this peer">no public address known</span>`;
|
||||
|
||||
walkEl.innerHTML = `
|
||||
<div class="fe-walk-card">
|
||||
<div class="fe-walk-card-body">
|
||||
<h3 class="fe-walk-card-title">${esc(n.domain || n.label || shortFp(n.fp))}</h3>
|
||||
<div class="fe-walk-card-fp">${esc(n.fp)}</div>
|
||||
<div class="fe-walk-card-stats">${statsHtml.join("")}</div>
|
||||
</div>
|
||||
${cta}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ---------- inbound vouches list ------------------------------------
|
||||
function renderVouchesIn() {
|
||||
if (!vouchesInList) return;
|
||||
if (vouchesInCountEl) vouchesInCountEl.textContent = String(vouchesIn.length);
|
||||
if (!vouchesIn.length) {
|
||||
vouchesInList.innerHTML = `<li class="fe-vouches-in-empty">no inbound vouches yet</li>`;
|
||||
return;
|
||||
}
|
||||
const items = vouchesIn.map(v => {
|
||||
const fp = v.voucher_fingerprint || "";
|
||||
const peer = peerByFp[fp];
|
||||
const label = peer ? (peer.domain || shortFp(fp)) : shortFp(fp);
|
||||
const clickable = peer ? `data-jump="${esc(fp)}"` : `data-jump=""`;
|
||||
return `<li>
|
||||
<span class="fp">
|
||||
<button type="button" class="fn-fp-jump" ${clickable}>${esc(label)}</button>
|
||||
<code style="margin-left:8px;color:var(--muted);font-size:11px;">${esc(fp)}</code>
|
||||
</span>
|
||||
<span class="ts">${esc(v.issued_at || "")}</span>
|
||||
</li>`;
|
||||
}).join("");
|
||||
vouchesInList.innerHTML = items;
|
||||
vouchesInList.querySelectorAll(".fn-fp-jump").forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
const fp = btn.getAttribute("data-jump") || "";
|
||||
if (fp) jumpToFp(fp);
|
||||
});
|
||||
});
|
||||
}
|
||||
renderVouchesIn();
|
||||
|
||||
// ---------- copy buttons on the static page -------------------------
|
||||
document.querySelectorAll(".fn-copy-btn").forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
const v = btn.getAttribute("data-copy") || "";
|
||||
if (!v) return;
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(v).catch(() => {});
|
||||
}
|
||||
const t = btn.textContent;
|
||||
btn.textContent = "copied";
|
||||
setTimeout(() => { btn.textContent = t; }, 1100);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------- idle animation ------------------------------------------
|
||||
function loop() {
|
||||
let moving = false;
|
||||
for (const n of nodes) {
|
||||
if (Math.abs(n.vx) > 0.04 || Math.abs(n.vy) > 0.04) { moving = true; break; }
|
||||
}
|
||||
if (moving || energyBudget > 0 || dragging) {
|
||||
tick(); paint();
|
||||
if (energyBudget > 0) energyBudget--;
|
||||
}
|
||||
requestAnimationFrame(loop);
|
||||
}
|
||||
loop();
|
||||
|
||||
// ---------- edge liveness + flow toggle -----------------------------
|
||||
edges.forEach((e, i) => {
|
||||
const ln = edgeEls[i].line;
|
||||
if (e.kind === "signal") ln.classList.add("alive");
|
||||
if (e.kind === "knows") ln.classList.add("dim");
|
||||
});
|
||||
const flowToggle = document.getElementById("fn-flow");
|
||||
function applyFlowToggle() {
|
||||
svg.classList.toggle("flow-off", !(flowToggle && flowToggle.checked));
|
||||
}
|
||||
applyFlowToggle();
|
||||
if (flowToggle) flowToggle.addEventListener("change", applyFlowToggle);
|
||||
|
||||
// ---------- layout modes --------------------------------------------
|
||||
function unfix() { for (const n of nodes) n.fixed = false; }
|
||||
function clearVel() { for (const n of nodes) { n.vx = 0; n.vy = 0; } }
|
||||
|
||||
function applyForce() {
|
||||
unfix();
|
||||
for (const n of nodes) {
|
||||
if (n.is_self) { n.x = W / 2; n.y = H / 2; n.fixed = true; continue; }
|
||||
n.vx = (Math.random() - 0.5) * 5;
|
||||
n.vy = (Math.random() - 0.5) * 5;
|
||||
}
|
||||
energyBudget = 300;
|
||||
}
|
||||
|
||||
function applyHierarchical() {
|
||||
const self = nodes.find(n => n.is_self);
|
||||
const direct = nodes.filter(n => !n.is_self && n.distance === 1);
|
||||
const transitive = nodes.filter(n => n.distance >= 2);
|
||||
if (self) { self.x = W / 2; self.y = 70; self.fixed = true; }
|
||||
direct.forEach((n, i) => {
|
||||
n.x = W * (i + 1) / (direct.length + 1);
|
||||
n.y = H * 0.42;
|
||||
n.fixed = true;
|
||||
});
|
||||
const tCount = transitive.length || 1;
|
||||
transitive.forEach((n, i) => {
|
||||
n.x = W * (i + 1) / (tCount + 1);
|
||||
n.y = H * 0.78;
|
||||
n.fixed = true;
|
||||
});
|
||||
clearVel(); paint();
|
||||
}
|
||||
|
||||
function applyRadial() {
|
||||
const self = nodes.find(n => n.is_self);
|
||||
const direct = nodes.filter(n => !n.is_self && n.distance === 1);
|
||||
const transitive = nodes.filter(n => n.distance >= 2);
|
||||
const R1 = Math.min(W, H) * 0.22;
|
||||
const R2 = Math.min(W, H) * 0.40;
|
||||
if (self) { self.x = W / 2; self.y = H / 2; self.fixed = true; }
|
||||
const dCount = direct.length || 1;
|
||||
direct.forEach((n, i) => {
|
||||
const a = (i / dCount) * Math.PI * 2 - Math.PI / 2;
|
||||
n.x = W / 2 + R1 * Math.cos(a);
|
||||
n.y = H / 2 + R1 * Math.sin(a);
|
||||
n.fixed = true;
|
||||
});
|
||||
const tCount = transitive.length || 1;
|
||||
transitive.forEach((n, i) => {
|
||||
const a = (i / tCount) * Math.PI * 2 - Math.PI / 2;
|
||||
n.x = W / 2 + R2 * Math.cos(a);
|
||||
n.y = H / 2 + R2 * Math.sin(a);
|
||||
n.fixed = true;
|
||||
});
|
||||
clearVel(); paint();
|
||||
}
|
||||
|
||||
const LAYOUTS = { force: applyForce, hier: applyHierarchical, radial: applyRadial };
|
||||
let currentLayout = "force";
|
||||
const selfNodeRef = nodes.find(n => n.is_self);
|
||||
if (selfNodeRef) { selfNodeRef.x = W / 2; selfNodeRef.y = H / 2; selfNodeRef.fixed = true; }
|
||||
|
||||
document.querySelectorAll(".topo-layout").forEach(btn => {
|
||||
btn.addEventListener("click", () => {
|
||||
const mode = btn.dataset.layout;
|
||||
if (!LAYOUTS[mode] || mode === currentLayout) return;
|
||||
document.querySelectorAll(".topo-layout").forEach(b => b.classList.toggle("is-active", b === btn));
|
||||
currentLayout = mode;
|
||||
LAYOUTS[mode]();
|
||||
});
|
||||
});
|
||||
|
||||
const resetBtn = document.getElementById("fn-reset");
|
||||
if (resetBtn) {
|
||||
resetBtn.addEventListener("click", () => {
|
||||
if (currentLayout === "force") {
|
||||
for (const n of nodes) {
|
||||
if (n.is_self) continue;
|
||||
n.vx = (Math.random() - 0.5) * 6;
|
||||
n.vy = (Math.random() - 0.5) * 6;
|
||||
}
|
||||
energyBudget = 200;
|
||||
} else {
|
||||
LAYOUTS[currentLayout]();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- wheel zoom + resize -------------------------------------
|
||||
let zoom = 1, panX = 0, panY = 0;
|
||||
svg.addEventListener("wheel", ev => {
|
||||
ev.preventDefault();
|
||||
const delta = ev.deltaY > 0 ? 1.1 : 0.9;
|
||||
zoom = Math.max(0.3, Math.min(2.5, zoom * delta));
|
||||
const vw = W / zoom, vh = H / zoom;
|
||||
svg.setAttribute("viewBox", `${(W - vw) / 2 + panX} ${(H - vh) / 2 + panY} ${vw} ${vh}`);
|
||||
}, { passive: false });
|
||||
window.addEventListener("resize", () => {
|
||||
const v = viewport();
|
||||
W = v.W; H = v.H;
|
||||
svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
|
||||
energyBudget = 60;
|
||||
});
|
||||
|
||||
// ---------- focus-peer query param ----------------------------------
|
||||
// ?peer=<domain> auto-selects that peer in the graph so deep links work.
|
||||
if (settings.focusPeer) {
|
||||
const target = nodes.find(n => n.domain && n.domain === settings.focusPeer);
|
||||
if (target) selectNode(target);
|
||||
}
|
||||
}
|
||||
})();
|
||||
1089
src/psyc/cockpit/static/federation_network.js
Normal file
1089
src/psyc/cockpit/static/federation_network.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@
|
||||
// This makes the cockpit installable as a PWA and survives flaky connections,
|
||||
// without serving stale operational data behind the operator's back.
|
||||
|
||||
const CACHE_VERSION = "psyc-v3";
|
||||
const CACHE_VERSION = "psyc-v11";
|
||||
const STATIC_ASSETS = [
|
||||
"/static/cockpit.css",
|
||||
"/static/psyc-tokens.css",
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<td class="lg-ts">{{ ((m.last_used or '')[:16] | replace('T', ' ')) or '—' }}</td>
|
||||
<td>
|
||||
<form method="post" action="/admin/members/{{ m.id }}/revoke" class="queue-action"
|
||||
onsubmit="return confirm('Revoke {{ m.label }}? Their codes stop working immediately.');">
|
||||
data-confirm-revoke="member" data-confirm-name="{{ m.label }}">
|
||||
<button type="submit" class="btn btn-reject">revoke</button>
|
||||
</form>
|
||||
</td>
|
||||
|
||||
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 %}
|
||||
@@ -8,7 +8,7 @@
|
||||
<span class="count">{{ peers|length }} peer{{ '' if peers|length == 1 else 's' }}</span>
|
||||
</div>
|
||||
<p class="page-intro">This node's Ed25519 identity. The fingerprint goes into a DNS TXT record so other psyc nodes can discover this one. The public key lets them verify any feed we publish — the private key never leaves this box.</p>
|
||||
<p class="back"><a href="/admin">← back to admin</a></p>
|
||||
<p class="back"><a href="/admin">← back to admin</a> · <a href="/admin/federation/discovery">discovery</a> · <a href="/admin/federation/vouches">vouches</a> · <a href="/admin/federation/quorum">quorum config</a> · <a href="/admin/federation/log">transparency log</a> · <a href="/admin/federation/network">network</a></p>
|
||||
|
||||
<div class="card" style="margin-bottom:14px;">
|
||||
<div class="lg-sub">node fingerprint</div>
|
||||
@@ -71,7 +71,7 @@
|
||||
<button type="submit" class="btn btn-reject" {% if p.status == 'blocked' %}disabled{% endif %}>block</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/federation/peers/{{ p.domain }}/remove" class="queue-action"
|
||||
onsubmit="return confirm('Remove {{ p.domain }}? Their signals will no longer count toward quorum.');">
|
||||
data-confirm-revoke="peer" data-confirm-name="{{ p.domain }}">
|
||||
<button type="submit" class="btn">remove</button>
|
||||
</form>
|
||||
</td>
|
||||
|
||||
63
src/psyc/cockpit/templates/admin_federation_log.html
Normal file
63
src/psyc/cockpit/templates/admin_federation_log.html
Normal file
@@ -0,0 +1,63 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Transparency Log — psyc admin{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h1>Transparency Log</h1>
|
||||
<span class="count">id @ {{ head_id }}</span>
|
||||
</div>
|
||||
<p class="page-intro">Every signal we accept from a peer is appended to a signed merkle chain. Each entry references the previous entry's hash, so tampering with any historical row invalidates every entry after. Auditors can re-walk and detect a bad peer historically — even one we trusted at the time.</p>
|
||||
<p class="back"><a href="/admin/federation">← back to federation</a> · <a href="/admin/federation/vouches">vouches</a> · <a href="/admin/federation/quorum">quorum config</a> · <a href="/federation/log/verify">public verify endpoint</a></p>
|
||||
|
||||
<div class="card" style="margin-bottom:14px;">
|
||||
<div class="lg-sub">chain verification</div>
|
||||
{% if verify_status.ok %}
|
||||
<div style="margin:6px 0;"><span class="sev-badge" style="background:rgba(74,222,128,0.10); color:var(--green); border-color:var(--green);">verified</span> {{ verify_status.verified }} entries walked, no breaks</div>
|
||||
{% else %}
|
||||
<div style="margin:6px 0;"><span class="sev-badge" style="background:rgba(248,113,113,0.10); color:var(--red); border-color:var(--red);">BROKEN</span> {{ verify_status.reason }}</div>
|
||||
{% endif %}
|
||||
{% if head_hash %}
|
||||
<div class="lg-sub" style="margin-top:8px;">head hash</div>
|
||||
<div style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:13px; word-break:break-all; color:var(--accent);">{{ head_hash }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Recent Entries</h2>
|
||||
<span class="count">{{ entries|length }} of last 200</span>
|
||||
</div>
|
||||
<p class="page-intro">Newest first. Hashes are truncated for display — full values are at <code>/federation/log</code>.</p>
|
||||
|
||||
{% if entries %}
|
||||
<table class="ledger">
|
||||
<thead><tr><th>id</th><th>When</th><th>Type</th><th>Peer / target</th><th>Signal id</th><th>Hash</th></tr></thead>
|
||||
<tbody>
|
||||
{% for e in entries %}
|
||||
<tr class="ledger-row">
|
||||
<td>{{ e.id }}</td>
|
||||
<td class="lg-ts">{{ (e.timestamp or '')[:19] | replace('T', ' ') }}</td>
|
||||
<td><span class="sev-badge">{{ e.entry_type }}</span></td>
|
||||
<td class="lg-sub" style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:11.5px;">
|
||||
{% if e.entry_type == 'signal' %}
|
||||
{{ (e.entry_data.peer_fingerprint or '')[:8] }}…
|
||||
{% elif e.entry_type == 'vouch' %}
|
||||
{{ (e.entry_data.voucher_fingerprint or '')[:8] }}…→{{ (e.entry_data.target_fingerprint or '')[:8] }}…
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:11.5px;">{{ ((e.entry_data.signal_id or e.entry_data.target_fingerprint or '') | string)[:32] }}</td>
|
||||
<td class="lg-sub" style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:11.5px;">{{ e.entry_hash[:16] }}…</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="page-intro">(chain empty — no signals appended yet)</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% endblock %}
|
||||
67
src/psyc/cockpit/templates/admin_federation_network.html
Normal file
67
src/psyc/cockpit/templates/admin_federation_network.html
Normal file
@@ -0,0 +1,67 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Federation network — psyc admin{% endblock %}
|
||||
{% block body_class %}wide{% endblock %}
|
||||
{% block content %}
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h1>Federation Network</h1>
|
||||
<span class="count">{{ stats.total_peers }} direct · <span id="fn-transitive-count">…</span> transitive</span>
|
||||
</div>
|
||||
<p class="page-intro">Force-directed map of the federation this node sits inside. Self at the center, directly-registered peers at distance 1, peers-of-peers (fetched from each trusted peer's <code>/federation/network</code>) at distance 2. Edges: <em>vouches</em> (solid), <em>signals</em> (dashed, thickness ∝ 24h volume), <em>knows</em> (dotted grey).</p>
|
||||
<p class="back"><a href="/admin/federation">← federation hub</a> · <a href="/admin/federation/discovery">discovery</a> · <a href="/admin/federation/vouches">vouches</a> · <a href="/admin/federation/quorum">quorum</a> · <a href="/admin/federation/log">log</a></p>
|
||||
|
||||
<div class="fn-stats">
|
||||
<div class="fn-stat"><div class="fn-stat-label">direct peers</div><div class="fn-stat-value">{{ stats.total_peers }}</div></div>
|
||||
<div class="fn-stat"><div class="fn-stat-label">vouched / trusted</div><div class="fn-stat-value">{{ stats.vouched_peers }}</div></div>
|
||||
<div class="fn-stat"><div class="fn-stat-label">vouches issued</div><div class="fn-stat-value">{{ stats.vouches_issued }}</div></div>
|
||||
<div class="fn-stat"><div class="fn-stat-label">signals (24h)</div><div class="fn-stat-value">{{ stats.signals_buffered_24h }}</div></div>
|
||||
<div class="fn-stat"><div class="fn-stat-label">distinct hashes</div><div class="fn-stat-value">{{ stats.distinct_signal_hashes_24h }}</div></div>
|
||||
<div class="fn-stat"><div class="fn-stat-label">quorum-met</div><div class="fn-stat-value">{{ stats.quorum_met_count }}</div></div>
|
||||
</div>
|
||||
|
||||
<div class="fn-search-bar">
|
||||
<label for="fn-search">filter</label>
|
||||
<input type="search" id="fn-search" class="fn-search-input" placeholder="domain or fingerprint substring…" autocomplete="off" spellcheck="false">
|
||||
<span id="fn-search-count" class="fn-search-count"></span>
|
||||
</div>
|
||||
|
||||
<div class="topo-stage">
|
||||
<div class="topo-toolbar">
|
||||
<div class="topo-layouts" role="tablist">
|
||||
<button type="button" class="topo-layout is-active" data-layout="force" title="organic force-directed">Force</button>
|
||||
<button type="button" class="topo-layout" data-layout="hier" title="self on top, direct peers in a row, transitives below">Hierarchical</button>
|
||||
<button type="button" class="topo-layout" data-layout="radial" title="self at center, peers on a ring, transitives outside">Radial</button>
|
||||
</div>
|
||||
<label class="topo-toggle"><input type="checkbox" id="fn-flow" checked> signal flow</label>
|
||||
<span class="topo-legend"><span class="lg-swatch fn-lg-self"></span>self</span>
|
||||
<span class="topo-legend"><span class="lg-swatch fn-lg-trusted"></span>trusted</span>
|
||||
<span class="topo-legend"><span class="lg-swatch fn-lg-vouched"></span>vouched</span>
|
||||
<span class="topo-legend"><span class="lg-swatch fn-lg-unknown"></span>unknown</span>
|
||||
<span class="topo-legend"><span class="lg-swatch fn-lg-blocked"></span>blocked</span>
|
||||
<button type="button" id="fn-reset" class="btn">re-settle</button>
|
||||
<span class="topo-hint">drag · scroll to zoom · hover for tooltip</span>
|
||||
</div>
|
||||
<svg id="federation-network-graph" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div id="fn-tooltip" class="fn-tooltip"></div>
|
||||
<div id="fn-loading" class="page-intro" style="text-align:center; margin-top:10px;">loading federation network…</div>
|
||||
<div id="fn-error" class="gate-error" style="display:none;"></div>
|
||||
</div>
|
||||
|
||||
<div id="fn-detail" class="topo-detail">
|
||||
<p class="topo-detail-empty">Click any node in the graph above to inspect it.</p>
|
||||
</div>
|
||||
|
||||
<div class="fn-timeline-wrap">
|
||||
<div class="fn-timeline-head">
|
||||
<h3>signals · last 24h</h3>
|
||||
<span class="meta" id="fn-timeline-meta">—</span>
|
||||
</div>
|
||||
<div id="fn-timeline" class="fn-timeline" aria-label="signals received per hour for the last 24 hours"></div>
|
||||
<div id="fn-timeline-axis" class="fn-timeline-axis"></div>
|
||||
</div>
|
||||
|
||||
<p class="page-intro" style="margin-top:18px;">Self fingerprint: <code style="color:var(--accent);">{{ fingerprint }}</code></p>
|
||||
</section>
|
||||
|
||||
<script src="/static/federation_network.js" defer></script>
|
||||
{% endblock %}
|
||||
92
src/psyc/cockpit/templates/admin_federation_quorum.html
Normal file
92
src/psyc/cockpit/templates/admin_federation_quorum.html
Normal file
@@ -0,0 +1,92 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Quorum Config — psyc admin{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h1>Quorum Configuration</h1>
|
||||
<span class="count">trust={{ cfg.trust_min_vouchers }} k={{ cfg.signal_quorum_k }}</span>
|
||||
</div>
|
||||
<p class="page-intro"><strong>trust_min_vouchers</strong> — distinct trusted vouchers required to make a new peer listening-eligible. <strong>signal_quorum_k</strong> — distinct listening-eligible peers required to consider a signal_hash quorum-met. Both gates live in pulse_settings; raising them tightens trust, lowering them relaxes it.</p>
|
||||
<p class="back"><a href="/admin/federation">← back to federation</a> · <a href="/admin/federation/vouches">vouches</a> · <a href="/admin/federation/log">transparency log</a></p>
|
||||
|
||||
<form method="post" action="/admin/federation/quorum/save" style="display:grid; gap:10px; max-width:520px;">
|
||||
<label class="lg-sub">trust_min_vouchers</label>
|
||||
<input type="number" name="trust_min_vouchers" value="{{ cfg.trust_min_vouchers }}" min="1" max="50" class="lookup-input" required>
|
||||
<label class="lg-sub">signal_quorum_k</label>
|
||||
<input type="number" name="signal_quorum_k" value="{{ cfg.signal_quorum_k }}" min="1" max="50" class="lookup-input" required>
|
||||
<button type="submit" class="btn btn-enforce">save config</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Per-Peer Listening Eligibility</h2>
|
||||
<span class="count">{{ peer_rows|length }}</span>
|
||||
</div>
|
||||
<p class="page-intro">A peer's feed gets ingested only when its fingerprint is eligible (directly trusted or vouched into trust).</p>
|
||||
|
||||
{% if peer_rows %}
|
||||
<table class="ledger">
|
||||
<thead><tr><th>Domain</th><th>Fingerprint</th><th>Status</th><th>Vouched</th><th>Eligible</th></tr></thead>
|
||||
<tbody>
|
||||
{% for row in peer_rows %}
|
||||
<tr class="ledger-row">
|
||||
<td><strong>{{ row.peer.domain }}</strong></td>
|
||||
<td class="lg-sub" style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace;">{{ row.peer.fingerprint[:8] }}…{{ row.peer.fingerprint[-8:] }}</td>
|
||||
<td>
|
||||
{% if row.peer.status == 'trusted' %}
|
||||
<span class="sev-badge" style="background:rgba(74,222,128,0.10); color:var(--green); border-color:var(--green);">trusted</span>
|
||||
{% elif row.peer.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">{{ row.peer.status }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{% if row.vouched %}<span class="sev-badge" style="background:rgba(74,222,128,0.10); color:var(--green); border-color:var(--green);">yes</span>{% else %}<span class="sev-badge">no</span>{% endif %}</td>
|
||||
<td>{% if row.eligible %}<span class="sev-badge" style="background:rgba(74,222,128,0.10); color:var(--green); border-color:var(--green);">listening</span>{% else %}<span class="sev-badge" style="background:rgba(248,113,113,0.10); color:var(--red); border-color:var(--red);">muted</span>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="page-intro">(no peers registered yet)</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Signal Hashes in Buffer</h2>
|
||||
<span class="count">{{ hash_summary|length }} hashes</span>
|
||||
</div>
|
||||
<p class="page-intro">Distinct eligible-peer counts per signal hash. Quorum is met when count ≥ {{ cfg.signal_quorum_k }}.</p>
|
||||
|
||||
{% if hash_summary %}
|
||||
<table class="ledger">
|
||||
<thead><tr><th>Latest</th><th>Type</th><th>Signal id</th><th>Hash</th><th>Distinct peers</th><th>Eligible</th><th>Quorum</th></tr></thead>
|
||||
<tbody>
|
||||
{% for r in hash_summary %}
|
||||
<tr class="ledger-row">
|
||||
<td class="lg-ts">{{ (r.latest or '')[:19] | replace('T', ' ') }}</td>
|
||||
<td><span class="sev-badge">{{ r.signal_type }}</span></td>
|
||||
<td style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:11.5px;">{{ r.signal_id[:48] }}</td>
|
||||
<td class="lg-sub" style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:11.5px;">{{ r.signal_hash[:16] }}…</td>
|
||||
<td>{{ r.distinct_peers }}</td>
|
||||
<td>{{ r.distinct_eligible }}</td>
|
||||
<td>
|
||||
{% if r.quorum_met %}
|
||||
<span class="sev-badge" style="background:rgba(74,222,128,0.10); color:var(--green); border-color:var(--green);">met</span>
|
||||
{% else %}
|
||||
<span class="sev-badge">below</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="page-intro">(no signals in buffer yet)</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% endblock %}
|
||||
110
src/psyc/cockpit/templates/admin_federation_vouches.html
Normal file
110
src/psyc/cockpit/templates/admin_federation_vouches.html
Normal file
@@ -0,0 +1,110 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Federation Vouches — psyc admin{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h1>Web of Trust</h1>
|
||||
<span class="count">{{ our_vouches|length }} issued</span>
|
||||
</div>
|
||||
<p class="page-intro">A vouch is an Ed25519-signed assertion that we trust another node's fingerprint. Peers gossip our vouches with their feeds, so trust accumulates: once {{ cfg.trust_min_vouchers }} of our trusted peers vouches for a new fingerprint, it becomes <em>listening-eligible</em> — its signed feeds get ingested.</p>
|
||||
<p class="back"><a href="/admin/federation">← back to federation</a> · <a href="/admin/federation/quorum">quorum config</a> · <a href="/admin/federation/log">transparency log</a></p>
|
||||
|
||||
<div class="card" style="margin-bottom:14px;">
|
||||
<div class="lg-sub">our fingerprint</div>
|
||||
<div style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:14px; word-break:break-all; color:var(--accent);">{{ fingerprint }}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Vouches We've Issued</h2>
|
||||
<span class="count">{{ our_vouches|length }}</span>
|
||||
</div>
|
||||
<p class="page-intro">We've signed these — peers that fetch our feed will see them and may extend trust accordingly.</p>
|
||||
|
||||
{% if our_vouches %}
|
||||
<table class="ledger">
|
||||
<thead><tr><th>Target fingerprint</th><th>Issued</th><th>Expires</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{% for v in our_vouches %}
|
||||
<tr class="ledger-row">
|
||||
<td style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:12px;">{{ v.target_fingerprint }}</td>
|
||||
<td class="lg-ts">{{ v.issued_at.isoformat()[:16] | replace('T', ' ') }}</td>
|
||||
<td class="lg-ts">{{ v.expires_at.isoformat()[:16] | replace('T', ' ') if v.expires_at else '—' }}</td>
|
||||
<td>
|
||||
<form method="post" action="/admin/federation/vouches/revoke" class="queue-action"
|
||||
onsubmit="return confirm('Revoke vouch for {{ v.target_fingerprint[:8] }}…?');">
|
||||
<input type="hidden" name="target_fingerprint" value="{{ v.target_fingerprint }}">
|
||||
<button type="submit" class="btn btn-reject">revoke</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="page-intro">(no vouches issued yet)</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Issue a Vouch</h2>
|
||||
</div>
|
||||
<p class="page-intro">Vouch for a peer's fingerprint. Trusted peers see this and may treat the target as listening-eligible.</p>
|
||||
<form method="post" action="/admin/federation/vouches/issue" style="display:grid; gap:10px; max-width:680px;">
|
||||
<input type="text" name="target_fingerprint" placeholder="target fingerprint (32 hex chars)" class="lookup-input" maxlength="64" required style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace;">
|
||||
<input type="number" name="ttl_days" value="90" min="1" max="3650" class="lookup-input" placeholder="ttl days">
|
||||
<button type="submit" class="btn btn-enforce">+ issue vouch</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Per-Peer Quorum Status</h2>
|
||||
<span class="count">{{ peer_rows|length }} peers</span>
|
||||
</div>
|
||||
<p class="page-intro">Threshold: {{ cfg.trust_min_vouchers }} distinct trusted vouchers required to make a non-trusted peer listening-eligible.</p>
|
||||
|
||||
{% if peer_rows %}
|
||||
<table class="ledger">
|
||||
<thead><tr><th>Peer</th><th>Status</th><th>Vouches</th><th>Quorum met</th><th>Eligible</th></tr></thead>
|
||||
<tbody>
|
||||
{% for row in peer_rows %}
|
||||
<tr class="ledger-row">
|
||||
<td><strong>{{ row.peer.domain }}</strong><br><span class="lg-sub" style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace;">{{ row.peer.fingerprint[:8] }}…{{ row.peer.fingerprint[-8:] }}</span></td>
|
||||
<td>
|
||||
{% if row.peer.status == 'trusted' %}
|
||||
<span class="sev-badge" style="background:rgba(74,222,128,0.10); color:var(--green); border-color:var(--green);">trusted</span>
|
||||
{% elif row.peer.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">{{ row.peer.status }}</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ row.vouches|length }}</td>
|
||||
<td>
|
||||
{% if row.vouched %}
|
||||
<span class="sev-badge" style="background:rgba(74,222,128,0.10); color:var(--green); border-color:var(--green);">yes</span>
|
||||
{% else %}
|
||||
<span class="sev-badge">no</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if row.eligible %}
|
||||
<span class="sev-badge" style="background:rgba(74,222,128,0.10); color:var(--green); border-color:var(--green);">listening</span>
|
||||
{% else %}
|
||||
<span class="sev-badge" style="background:rgba(248,113,113,0.10); color:var(--red); border-color:var(--red);">muted</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="page-intro">(no peers registered yet)</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% endblock %}
|
||||
@@ -35,6 +35,86 @@
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>AUTO-RESPONSE STATE</h2>
|
||||
<span class="count">{{ respond_auto_fired_24h }} auto-fired in last 24h</span>
|
||||
</div>
|
||||
<p class="page-intro">When the <code>respond</code> pipeline runs in <code>auto-execute</code>, every PROPOSED action that passes all three gates fires automatically. Below shows the live config + audit trail.</p>
|
||||
|
||||
<div style="display:flex; gap:14px; flex-wrap:wrap; margin:14px 0;">
|
||||
{# Mode badge — traffic-light coloring. auto-execute is "armed" (red), auto-propose is amber, manual is green/safe. #}
|
||||
{% if respond_mode == 'auto-execute' %}
|
||||
<div style="padding:10px 14px; border-radius:6px; background:rgba(248,113,113,0.12); border:1px solid rgba(248,113,113,0.45); color:#fca5a5; font-weight:700; letter-spacing:0.04em;">
|
||||
MODE: auto-execute (ARMED)
|
||||
</div>
|
||||
{% elif respond_mode == 'auto-propose' %}
|
||||
<div style="padding:10px 14px; border-radius:6px; background:rgba(250,204,21,0.12); border:1px solid rgba(250,204,21,0.45); color:#fde047; font-weight:700; letter-spacing:0.04em;">
|
||||
MODE: auto-propose (staging only)
|
||||
</div>
|
||||
{% else %}
|
||||
<div style="padding:10px 14px; border-radius:6px; background:rgba(74,222,128,0.10); border:1px solid var(--green); color:var(--green); font-weight:700; letter-spacing:0.04em;">
|
||||
MODE: manual (no proposals)
|
||||
</div>
|
||||
{% endif %}
|
||||
<div style="padding:10px 14px; border-radius:6px; background:var(--panel-2); border:1px solid var(--border); color:var(--text);">
|
||||
Threshold: <strong>{{ respond_threshold|upper }}+</strong>
|
||||
</div>
|
||||
<div style="padding:10px 14px; border-radius:6px; background:var(--panel-2); border:1px solid var(--border); color:var(--text);">
|
||||
Quorum: <strong style="color: {{ 'var(--green)' if respond_require_quorum else 'var(--muted)' }};">{{ 'ON' if respond_require_quorum else 'OFF' }}</strong>
|
||||
</div>
|
||||
<div style="padding:10px 14px; border-radius:6px; background:var(--panel-2); border:1px solid var(--border); color:var(--text);">
|
||||
Local-only: <strong style="color: {{ 'var(--green)' if respond_local_only else 'var(--muted)' }};">{{ 'ON' if respond_local_only else 'OFF' }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/admin/pulse/respond-config" style="display:flex; gap:10px; align-items:center; flex-wrap:wrap; margin-top:14px;">
|
||||
<label style="font-size:12px;">Min severity:
|
||||
<select name="threshold" style="background:var(--panel-2); color:var(--text); border:1px solid var(--border); border-radius:4px; padding:4px 6px;">
|
||||
{% for s in severity_choices %}
|
||||
<option value="{{ s }}" {% if s == respond_threshold %}selected{% endif %}>{{ s }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label style="display:inline-flex; align-items:center; gap:4px; font-size:12px;">
|
||||
<input type="checkbox" name="require_quorum" value="1" {% if respond_require_quorum %}checked{% endif %}> require quorum
|
||||
</label>
|
||||
<label style="display:inline-flex; align-items:center; gap:4px; font-size:12px;">
|
||||
<input type="checkbox" name="local_only" value="1" {% if respond_local_only %}checked{% endif %}> local-only
|
||||
</label>
|
||||
<button type="submit" class="btn">save gates</button>
|
||||
</form>
|
||||
|
||||
{% if respond_audit_recent %}
|
||||
<table class="ledger" style="margin-top:18px;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:18%;">timestamp</th>
|
||||
<th style="width:12%;">decision</th>
|
||||
<th style="width:18%;">case</th>
|
||||
<th>detail</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in respond_audit_recent %}
|
||||
<tr class="ledger-row">
|
||||
<td class="lg-ts">{{ row.timestamp }}</td>
|
||||
<td>
|
||||
{% if row.action == 'auto-fire' %}<span style="color: var(--green);">✓ {{ row.action }}</span>
|
||||
{% elif row.action == 'error' %}<span style="color: var(--red);">✗ {{ row.action }}</span>
|
||||
{% else %}<span style="color: var(--muted);">⊘ {{ row.action }}</span>{% endif %}
|
||||
</td>
|
||||
<td class="lg-sub"><code>{{ row.case_id or '—' }}</code>{% if row.action_id %} · #{{ row.action_id }}{% endif %}</td>
|
||||
<td class="lg-sub">{{ row.detail or '' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="lg-sub" style="margin-top:14px;">No auto-response decisions logged yet.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Pipelines</h2>
|
||||
|
||||
@@ -39,6 +39,21 @@
|
||||
btn.setAttribute("aria-expanded", "false");
|
||||
}));
|
||||
});
|
||||
// data-driven confirms (used by /admin and /admin/federation revoke/remove
|
||||
// buttons; replaces inline onsubmit which was XSS-vulnerable when the
|
||||
// confirm prompt interpolated a member label or peer domain).
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
document.querySelectorAll("form[data-confirm-revoke]").forEach(form => {
|
||||
form.addEventListener("submit", ev => {
|
||||
const kind = form.getAttribute("data-confirm-revoke") || "item";
|
||||
const name = form.getAttribute("data-confirm-name") || "";
|
||||
const msg = kind === "peer"
|
||||
? `Remove ${name}? Their signals will no longer count toward quorum.`
|
||||
: `Revoke ${name}? Their codes stop working immediately.`;
|
||||
if (!confirm(msg)) ev.preventDefault();
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</head>
|
||||
<body class="{% block body_class %}{% endblock %}">
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
<h2>Source</h2>
|
||||
<dl>
|
||||
<dt>Type</dt><dd>{{ case.source_type }}</dd>
|
||||
<dt>Reference</dt><dd>{% if case.source_ref %}<a href="{{ case.source_ref }}" target="_blank" rel="noopener">{{ case.source_ref }}</a>{% else %}—{% endif %}</dd>
|
||||
<dt>Reference</dt><dd>{% if case.source_ref %}{% if case.source_ref.startswith(('http://', 'https://')) %}<a href="{{ case.source_ref }}" target="_blank" rel="noopener">{{ case.source_ref }}</a>{% else %}<code>{{ case.source_ref }}</code>{% endif %}{% else %}—{% endif %}</dd>
|
||||
<dt>Observed</dt><dd class="muted">{{ case.observed_at.strftime('%Y-%m-%d %H:%M UTC') }}</dd>
|
||||
<dt>Ingested</dt><dd class="muted">{{ case.ingested_at.strftime('%Y-%m-%d %H:%M UTC') }}</dd>
|
||||
</dl>
|
||||
|
||||
148
src/psyc/cockpit/templates/federation_explore.html
Normal file
148
src/psyc/cockpit/templates/federation_explore.html
Normal file
@@ -0,0 +1,148 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="theme-color" content="#0f1115">
|
||||
<meta name="apple-mobile-web-app-title" content="psyc · federation explorer">
|
||||
<title>Federation Explorer — psyc</title>
|
||||
<link rel="icon" type="image/png" href="/static/psyc-logo.png">
|
||||
<link rel="apple-touch-icon" href="/static/psyc-logo.png">
|
||||
<link rel="manifest" href="/static/manifest.json">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/cockpit.css">
|
||||
<link rel="stylesheet" href="/static/psyc-tokens.css">
|
||||
<script defer src="https://analytics.neuronetz.ai/script.js" data-website-id="34c354e5-780e-4c42-a5ce-49b13ff3f088"></script>
|
||||
<script>
|
||||
if ("serviceWorker" in navigator) {
|
||||
window.addEventListener("load", () => {
|
||||
navigator.serviceWorker.register("/sw.js", { scope: "/" }).catch(() => {});
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body class="wide fe-page">
|
||||
<header class="topbar fe-topbar">
|
||||
<a class="brand" href="/federation/explore">
|
||||
<img class="brand-icon" src="/static/psyc-logo.png" alt="psyc">
|
||||
<span class="brand-sub">operations cockpit</span>
|
||||
</a>
|
||||
<span class="fe-badge" title="Public, no auth — this view is auditable by anyone">
|
||||
<span class="fe-badge-dot"></span>PUBLIC · TRANSPARENT
|
||||
</span>
|
||||
<div class="family">
|
||||
<img class="family-icon" src="/static/nn-sc-icon.png" alt="NN-sc — Security/Control" title="NN-sc · Security">
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="content">
|
||||
<section class="panel fe-hero">
|
||||
<div class="fe-hero-head">
|
||||
<h1 class="fe-title">Federation Explorer</h1>
|
||||
<p class="fe-sub" id="fe-node-domain">{{ domain or fingerprint }}</p>
|
||||
</div>
|
||||
<p class="fe-intro">
|
||||
This is a <strong>public transparency view</strong> of the psyc federation this node sits inside.
|
||||
Anyone on the internet can verify the trust network — who has vouched for whom,
|
||||
which signals are corroborated, and that the transparency log hasn't been rewritten.
|
||||
<strong>Click any peer in the graph to inspect it, then jump to that peer's own explorer.</strong>
|
||||
</p>
|
||||
<p class="fe-intro fe-intro-sub">
|
||||
Self fingerprint:
|
||||
<code class="fe-fp">{{ fingerprint }}</code>
|
||||
<button type="button" class="fn-copy-btn" data-copy="{{ fingerprint }}">copy</button>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="panel fe-kpi-panel">
|
||||
<div class="fn-stats fe-kpis">
|
||||
<div class="fn-stat"><div class="fn-stat-label">direct peers</div><div class="fn-stat-value" id="fe-kpi-peers">…</div></div>
|
||||
<div class="fn-stat"><div class="fn-stat-label">vouches out</div><div class="fn-stat-value" id="fe-kpi-vouches-out">…</div></div>
|
||||
<div class="fn-stat"><div class="fn-stat-label">vouches in</div><div class="fn-stat-value" id="fe-kpi-vouches-in">…</div></div>
|
||||
<div class="fn-stat"><div class="fn-stat-label">signals (24h)</div><div class="fn-stat-value" id="fe-kpi-signals">…</div></div>
|
||||
<div class="fn-stat"><div class="fn-stat-label">corroborations (24h)</div><div class="fn-stat-value" id="fe-kpi-corroboration">…</div></div>
|
||||
<div class="fn-stat"><div class="fn-stat-label">translog entries</div><div class="fn-stat-value" id="fe-kpi-translog">…</div></div>
|
||||
<div class="fn-stat fe-kpi-verify"><div class="fn-stat-label">log integrity</div><div class="fn-stat-value" id="fe-kpi-verify">…</div></div>
|
||||
</div>
|
||||
<div class="fe-verify-row">
|
||||
<button type="button" id="fe-verify-btn" class="btn fe-verify-btn">Verify this log →</button>
|
||||
<span id="fe-verify-result" class="fe-verify-result"></span>
|
||||
<a class="fe-verify-link" href="/federation/log" target="_blank" rel="noopener">raw log JSON</a>
|
||||
<a class="fe-verify-link" href="/federation/explore/data" target="_blank" rel="noopener">signed payload</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<h2>Trust network</h2>
|
||||
<span class="count"><span id="fe-direct-count">…</span> direct · <span id="fe-transitive-count">…</span> transitive</span>
|
||||
</div>
|
||||
<p class="page-intro">
|
||||
Self at the center, directly-trusted peers around it,
|
||||
peers-of-peers (learned from each trusted peer's signed feed) on the outer ring.
|
||||
Edges: <em>vouches</em> (solid), <em>signals</em> (dashed, thickness ∝ 24h volume),
|
||||
<em>knows</em> (dotted grey), <em>corroborate</em> (faint pulse — two peers reporting the same signal).
|
||||
</p>
|
||||
|
||||
<div class="topo-stage fe-stage">
|
||||
<div class="topo-toolbar">
|
||||
<div class="topo-layouts" role="tablist">
|
||||
<button type="button" class="topo-layout is-active" data-layout="force" title="organic force-directed">Force</button>
|
||||
<button type="button" class="topo-layout" data-layout="hier" title="self on top, direct peers in a row, transitives below">Hierarchical</button>
|
||||
<button type="button" class="topo-layout" data-layout="radial" title="self at center, peers on a ring, transitives outside">Radial</button>
|
||||
</div>
|
||||
<label class="topo-toggle"><input type="checkbox" id="fn-flow" checked> signal flow</label>
|
||||
<span class="topo-legend"><span class="lg-swatch fn-lg-self"></span>self</span>
|
||||
<span class="topo-legend"><span class="lg-swatch fn-lg-trusted"></span>trusted</span>
|
||||
<span class="topo-legend"><span class="lg-swatch fn-lg-unknown"></span>transitive</span>
|
||||
<button type="button" id="fn-reset" class="btn">re-settle</button>
|
||||
<span class="topo-hint">drag · scroll to zoom · click a peer to walk to its explorer</span>
|
||||
</div>
|
||||
<svg id="federation-network-graph" preserveAspectRatio="xMidYMid meet"></svg>
|
||||
<div id="fn-tooltip" class="fn-tooltip"></div>
|
||||
<div id="fn-loading" class="page-intro" style="text-align:center; margin-top:10px;">loading federation network…</div>
|
||||
<div id="fn-error" class="gate-error" style="display:none;"></div>
|
||||
</div>
|
||||
|
||||
<div id="fe-walk" class="fe-walk">
|
||||
<p class="fe-walk-empty">Click any peer in the graph above to inspect it and walk to its federation view.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel fe-vouches-panel">
|
||||
<div class="panel-head">
|
||||
<h2>Who vouches for this node</h2>
|
||||
<span class="count"><span id="fe-vouches-in-count">…</span> inbound</span>
|
||||
</div>
|
||||
<p class="page-intro">
|
||||
Each entry is a signed vouch naming this node as target, issued by a peer we currently trust.
|
||||
Click a fingerprint to highlight that peer in the graph above.
|
||||
</p>
|
||||
<ul id="fe-vouches-in-list" class="fe-vouches-in-list">
|
||||
<li class="fe-vouches-in-empty">no inbound vouches yet</li>
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="footer fe-footer">
|
||||
psyc · defensive CTI · transparent by design ·
|
||||
powered by <a href="https://neuronetz.ai" target="_blank" rel="noopener">neuronetz.ai</a>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// The walk-to-peer param ("?peer=domain") tells the JS to focus that peer
|
||||
// in the graph as soon as the data lands.
|
||||
// Use tojson so values are honest JS literals — does not depend on the
|
||||
// subtle HTML-entity-vs-script-context parser rules. Empty values become
|
||||
// null/"" cleanly. Reach the values via PSYC_EXPLORE.* in the script.
|
||||
window.PSYC_EXPLORE = {
|
||||
selfFingerprint: {{ fingerprint | tojson }},
|
||||
selfDomain: {{ (domain or "") | tojson }},
|
||||
focusPeer: {{ (peer or "") | tojson }}
|
||||
};
|
||||
</script>
|
||||
<script src="/static/federation_explore.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -6,6 +6,7 @@
|
||||
<div class="hero-text">
|
||||
<h1 class="hero-title">Defensive CTI in motion</h1>
|
||||
<p class="hero-sub">What psyc has seen and done — at a glance.</p>
|
||||
<p class="hero-meta"><a class="hero-explore" href="/federation/explore">Federation Explorer →</a> <span class="hero-explore-sub">public · auditable trust network</span></p>
|
||||
</div>
|
||||
<a class="hero-cta" href="/cases">All cases →</a>
|
||||
</section>
|
||||
|
||||
252
src/psyc/db.py
252
src/psyc/db.py
@@ -17,11 +17,13 @@ from sqlalchemy import (
|
||||
Table,
|
||||
Text,
|
||||
create_engine,
|
||||
event,
|
||||
func,
|
||||
insert,
|
||||
select,
|
||||
)
|
||||
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from psyc import DATA_DIR, log
|
||||
from psyc.models import Case
|
||||
@@ -134,6 +136,19 @@ pulse_settings = Table(
|
||||
Column("value", String, nullable=False),
|
||||
)
|
||||
|
||||
pulse_audit = Table(
|
||||
"pulse_audit", _metadata,
|
||||
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||
Column("pipeline", String, nullable=False), # 'respond' | 'fetch' | ...
|
||||
Column("action", String, nullable=False), # 'auto-fire' | 'skipped' | 'error'
|
||||
Column("action_id", Integer, nullable=True), # response_actions.id when relevant
|
||||
Column("case_id", String, nullable=True),
|
||||
Column("detail", Text, nullable=True),
|
||||
Column("timestamp", String, nullable=False), # ISO
|
||||
)
|
||||
Index("pulse_audit_pipeline_idx", pulse_audit.c.pipeline, pulse_audit.c.timestamp.desc())
|
||||
Index("pulse_audit_action_id_idx", pulse_audit.c.action_id)
|
||||
|
||||
peers = Table(
|
||||
"peers", _metadata,
|
||||
Column("domain", String, primary_key=True),
|
||||
@@ -161,16 +176,66 @@ Index("federation_signals_hash_idx", federation_signals.c.signal_hash)
|
||||
Index("federation_signals_peer_idx", federation_signals.c.peer_fingerprint)
|
||||
Index("federation_signals_received_idx", federation_signals.c.received_at.desc())
|
||||
|
||||
# Web-of-trust vouches — voucher signs an attestation that target is OK to listen to.
|
||||
# Quorum is reached when enough distinct trusted vouchers sign for the same target.
|
||||
vouches = Table(
|
||||
"vouches", _metadata,
|
||||
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||
Column("voucher_fingerprint", String, nullable=False),
|
||||
Column("target_fingerprint", String, nullable=False),
|
||||
Column("issued_at", String, nullable=False),
|
||||
Column("expires_at", String, nullable=True),
|
||||
Column("signature", Text, nullable=False), # base64 ed25519 sig
|
||||
)
|
||||
Index("vouches_unique_idx", vouches.c.voucher_fingerprint, vouches.c.target_fingerprint, unique=True)
|
||||
Index("vouches_target_idx", vouches.c.target_fingerprint)
|
||||
|
||||
# Transparency log — append-only signed hash chain over every signal we receive.
|
||||
# Each entry references the previous entry's hash; tampering with any row breaks
|
||||
# verify_chain on every subsequent row.
|
||||
translog = Table(
|
||||
"translog", _metadata,
|
||||
Column("id", Integer, primary_key=True, autoincrement=True),
|
||||
Column("prev_hash", String, nullable=False),
|
||||
Column("entry_type", String, nullable=False), # signal | vouch | config
|
||||
Column("entry_data", Text, nullable=False), # canonical JSON of payload
|
||||
Column("timestamp", String, nullable=False),
|
||||
Column("entry_hash", String, nullable=False),
|
||||
)
|
||||
Index("translog_hash_idx", translog.c.entry_hash)
|
||||
Index("translog_time_idx", translog.c.timestamp.desc())
|
||||
|
||||
|
||||
_log = log.get(__name__)
|
||||
_engine: Optional[Engine] = None
|
||||
|
||||
|
||||
def engine(db_path: Path = DB_PATH) -> Engine:
|
||||
"""Lazy-init the SQLite engine.
|
||||
|
||||
Uses NullPool — SQLite doesn't benefit from connection pooling (it's a
|
||||
file, opens are cheap) and the default QueuePool starved the classify +
|
||||
federation + cockpit-request workers under real load. WAL journal mode
|
||||
+ a 30s busy timeout let readers and a writer share the file safely.
|
||||
"""
|
||||
global _engine
|
||||
if _engine is None:
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
_engine = create_engine(f"sqlite:///{db_path}", future=True)
|
||||
_engine = create_engine(
|
||||
f"sqlite:///{db_path}",
|
||||
future=True,
|
||||
poolclass=NullPool,
|
||||
connect_args={"check_same_thread": False, "timeout": 30},
|
||||
)
|
||||
|
||||
@event.listens_for(_engine, "connect")
|
||||
def _sqlite_pragmas(dbapi_conn, _connection_record): # noqa: D401
|
||||
cur = dbapi_conn.cursor()
|
||||
cur.execute("PRAGMA journal_mode=WAL")
|
||||
cur.execute("PRAGMA synchronous=NORMAL")
|
||||
cur.execute("PRAGMA busy_timeout=30000")
|
||||
cur.close()
|
||||
|
||||
return _engine
|
||||
|
||||
|
||||
@@ -313,6 +378,66 @@ def kill_switch_set(armed: bool, db_path: Path = DB_PATH) -> None:
|
||||
conn.execute(stmt)
|
||||
|
||||
|
||||
def pulse_setting_get(key: str, db_path: Path = DB_PATH) -> Optional[str]:
|
||||
"""Fetch one row from pulse_settings by key; returns None if absent."""
|
||||
stmt = select(pulse_settings.c.value).where(pulse_settings.c.key == key)
|
||||
with engine(db_path).connect() as conn:
|
||||
row = conn.execute(stmt).fetchone()
|
||||
return str(row.value) if row else None
|
||||
|
||||
|
||||
def pulse_setting_set(key: str, value: str, db_path: Path = DB_PATH) -> None:
|
||||
"""Upsert one (key, value) into pulse_settings."""
|
||||
stmt = sqlite_insert(pulse_settings).values(key=key, value=value)
|
||||
stmt = stmt.on_conflict_do_update(
|
||||
index_elements=[pulse_settings.c.key],
|
||||
set_=dict(value=stmt.excluded.value),
|
||||
)
|
||||
with engine(db_path).begin() as conn:
|
||||
conn.execute(stmt)
|
||||
|
||||
|
||||
# ---------- pulse audit trail --------------------------------------------
|
||||
|
||||
def pulse_audit_record(row: dict, db_path: Path = DB_PATH) -> int:
|
||||
"""Append one pulse_audit row. Returns its id.
|
||||
|
||||
`row` must include 'pipeline', 'action', 'timestamp'. action_id, case_id,
|
||||
detail are optional. Caller controls timestamp so tests can pin it.
|
||||
"""
|
||||
stmt = insert(pulse_audit).values(**row)
|
||||
with engine(db_path).begin() as conn:
|
||||
res = conn.execute(stmt)
|
||||
return int(res.inserted_primary_key[0])
|
||||
|
||||
|
||||
def pulse_audit_recent(pipeline: str, limit: int = 25, db_path: Path = DB_PATH) -> List[dict]:
|
||||
"""Most-recent audit rows for one pipeline (newest first)."""
|
||||
stmt = (
|
||||
select(pulse_audit)
|
||||
.where(pulse_audit.c.pipeline == pipeline)
|
||||
.order_by(pulse_audit.c.timestamp.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
with engine(db_path).connect() as conn:
|
||||
return [dict(r._mapping) for r in conn.execute(stmt).fetchall()]
|
||||
|
||||
|
||||
def pulse_audit_count_since(
|
||||
pipeline: str, action: str, since_iso: str, db_path: Path = DB_PATH
|
||||
) -> int:
|
||||
"""Count audit rows for (pipeline, action) at or after `since_iso`."""
|
||||
stmt = (
|
||||
select(func.count())
|
||||
.select_from(pulse_audit)
|
||||
.where(pulse_audit.c.pipeline == pipeline)
|
||||
.where(pulse_audit.c.action == action)
|
||||
.where(pulse_audit.c.timestamp >= since_iso)
|
||||
)
|
||||
with engine(db_path).connect() as conn:
|
||||
return int(conn.execute(stmt).scalar_one())
|
||||
|
||||
|
||||
# ---------- federation: peers + signal buffer ----------------------------
|
||||
|
||||
def upsert_peer(row: dict, db_path: Path = DB_PATH) -> None:
|
||||
@@ -369,3 +494,128 @@ def recent_signals(limit: int = 200, db_path: Path = DB_PATH) -> List[dict]:
|
||||
stmt = select(federation_signals).order_by(federation_signals.c.received_at.desc()).limit(limit)
|
||||
with engine(db_path).connect() as conn:
|
||||
return [dict(r._mapping) for r in conn.execute(stmt).fetchall()]
|
||||
|
||||
|
||||
# ---------- federation: pulse_settings get/set (shared scratch kv) -------
|
||||
|
||||
def setting_get(key: str, db_path: Path = DB_PATH) -> Optional[str]:
|
||||
"""Read one pulse_settings value by key. Returns None if absent."""
|
||||
stmt = select(pulse_settings.c.value).where(pulse_settings.c.key == key)
|
||||
with engine(db_path).connect() as conn:
|
||||
row = conn.execute(stmt).fetchone()
|
||||
return None if row is None else str(row.value)
|
||||
|
||||
|
||||
def setting_set(key: str, value: str, db_path: Path = DB_PATH) -> None:
|
||||
"""Upsert one pulse_settings entry."""
|
||||
stmt = sqlite_insert(pulse_settings).values(key=key, value=value)
|
||||
stmt = stmt.on_conflict_do_update(
|
||||
index_elements=[pulse_settings.c.key],
|
||||
set_=dict(value=stmt.excluded.value),
|
||||
)
|
||||
with engine(db_path).begin() as conn:
|
||||
conn.execute(stmt)
|
||||
|
||||
|
||||
# ---------- federation: vouches ------------------------------------------
|
||||
|
||||
def upsert_vouch(row: dict, db_path: Path = DB_PATH) -> None:
|
||||
"""Insert-or-update one vouch. Unique on (voucher_fp, target_fp)."""
|
||||
stmt = sqlite_insert(vouches).values(**row)
|
||||
update_cols = {k: stmt.excluded[k] for k in row if k not in ("voucher_fingerprint", "target_fingerprint")}
|
||||
stmt = stmt.on_conflict_do_update(
|
||||
index_elements=[vouches.c.voucher_fingerprint, vouches.c.target_fingerprint],
|
||||
set_=update_cols,
|
||||
)
|
||||
with engine(db_path).begin() as conn:
|
||||
conn.execute(stmt)
|
||||
|
||||
|
||||
def list_vouches(db_path: Path = DB_PATH) -> List[dict]:
|
||||
stmt = select(vouches).order_by(vouches.c.issued_at.desc())
|
||||
with engine(db_path).connect() as conn:
|
||||
return [dict(r._mapping) for r in conn.execute(stmt).fetchall()]
|
||||
|
||||
|
||||
def vouches_by_target(target_fingerprint: str, db_path: Path = DB_PATH) -> List[dict]:
|
||||
stmt = select(vouches).where(vouches.c.target_fingerprint == target_fingerprint)
|
||||
with engine(db_path).connect() as conn:
|
||||
return [dict(r._mapping) for r in conn.execute(stmt).fetchall()]
|
||||
|
||||
|
||||
def vouches_by_voucher(voucher_fingerprint: str, db_path: Path = DB_PATH) -> List[dict]:
|
||||
stmt = select(vouches).where(vouches.c.voucher_fingerprint == voucher_fingerprint)
|
||||
with engine(db_path).connect() as conn:
|
||||
return [dict(r._mapping) for r in conn.execute(stmt).fetchall()]
|
||||
|
||||
|
||||
def delete_vouch(voucher_fingerprint: str, target_fingerprint: str, db_path: Path = DB_PATH) -> None:
|
||||
stmt = vouches.delete().where(
|
||||
(vouches.c.voucher_fingerprint == voucher_fingerprint)
|
||||
& (vouches.c.target_fingerprint == target_fingerprint)
|
||||
)
|
||||
with engine(db_path).begin() as conn:
|
||||
conn.execute(stmt)
|
||||
|
||||
|
||||
# ---------- transparency log ---------------------------------------------
|
||||
|
||||
def translog_append(row: dict, db_path: Path = DB_PATH) -> int:
|
||||
"""Append one transparency-log entry. Returns inserted id."""
|
||||
stmt = insert(translog).values(**row)
|
||||
with engine(db_path).begin() as conn:
|
||||
res = conn.execute(stmt)
|
||||
return int(res.inserted_primary_key[0])
|
||||
|
||||
|
||||
def translog_head(db_path: Path = DB_PATH) -> Optional[dict]:
|
||||
"""Highest-id (latest) entry, or None if chain empty."""
|
||||
stmt = select(translog).order_by(translog.c.id.desc()).limit(1)
|
||||
with engine(db_path).connect() as conn:
|
||||
row = conn.execute(stmt).fetchone()
|
||||
return dict(row._mapping) if row else None
|
||||
|
||||
|
||||
def translog_get(entry_id: int, db_path: Path = DB_PATH) -> Optional[dict]:
|
||||
stmt = select(translog).where(translog.c.id == entry_id)
|
||||
with engine(db_path).connect() as conn:
|
||||
row = conn.execute(stmt).fetchone()
|
||||
return dict(row._mapping) if row else None
|
||||
|
||||
|
||||
def translog_after(entry_id: int, db_path: Path = DB_PATH) -> List[dict]:
|
||||
"""All entries with id > entry_id, oldest first — for sync."""
|
||||
stmt = select(translog).where(translog.c.id > entry_id).order_by(translog.c.id.asc())
|
||||
with engine(db_path).connect() as conn:
|
||||
return [dict(r._mapping) for r in conn.execute(stmt).fetchall()]
|
||||
|
||||
|
||||
def translog_recent(limit: int = 100, db_path: Path = DB_PATH) -> List[dict]:
|
||||
stmt = select(translog).order_by(translog.c.id.desc()).limit(limit)
|
||||
with engine(db_path).connect() as conn:
|
||||
return [dict(r._mapping) for r in conn.execute(stmt).fetchall()]
|
||||
|
||||
|
||||
def translog_range(start: int = 0, end: Optional[int] = None, db_path: Path = DB_PATH) -> List[dict]:
|
||||
"""All entries with start <= id (and id <= end if given), oldest first."""
|
||||
cond = translog.c.id >= start
|
||||
if end is not None:
|
||||
cond = cond & (translog.c.id <= end)
|
||||
stmt = select(translog).where(cond).order_by(translog.c.id.asc())
|
||||
with engine(db_path).connect() as conn:
|
||||
return [dict(r._mapping) for r in conn.execute(stmt).fetchall()]
|
||||
|
||||
|
||||
def signals_for_case(case_id: str, db_path: Path = DB_PATH) -> List[dict]:
|
||||
"""All federation signals attached to this case_id (signal_type='case').
|
||||
|
||||
Empty list means no peer has ever sent us this case → we generated it
|
||||
ourselves and it counts as locally-sourced for auto-fire purposes.
|
||||
"""
|
||||
stmt = (
|
||||
select(federation_signals)
|
||||
.where(federation_signals.c.signal_type == "case")
|
||||
.where(federation_signals.c.signal_id == case_id)
|
||||
)
|
||||
with engine(db_path).connect() as conn:
|
||||
return [dict(r._mapping) for r in conn.execute(stmt).fetchall()]
|
||||
|
||||
357
src/psyc/lines/discovery.py
Normal file
357
src/psyc/lines/discovery.py
Normal file
@@ -0,0 +1,357 @@
|
||||
"""Discovery — DNS-SD resolver + BFS peer-walker for internet-wide federation.
|
||||
|
||||
Federation identity (psyc.lines.federation) gives every node a stable Ed25519
|
||||
keypair, a 32-hex fingerprint, and a DNS record format. This module is the
|
||||
*finder*: given a seed domain you suspect runs psyc, walk its DNS-SD records
|
||||
to learn the fingerprint, fetch its public peer list, and recurse.
|
||||
|
||||
Newly-discovered peers always enter the `peers` table with status="unknown" —
|
||||
they do NOT become trusted by being seen; vouching is a separate concern
|
||||
(sibling module). Discovery only populates the candidate set.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
import dns.exception
|
||||
import dns.rdatatype
|
||||
import dns.resolver
|
||||
import httpx
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from psyc import db, log
|
||||
from psyc.lines import federation
|
||||
from psyc.result import Err, Ok, Result
|
||||
|
||||
|
||||
_log = log.get(__name__)
|
||||
|
||||
DEFAULT_TIMEOUT = 5.0
|
||||
DEFAULT_PORT = 443
|
||||
DEFAULT_MAX_DEPTH = 2
|
||||
DEFAULT_MAX_PEERS = 200
|
||||
|
||||
_VALID_STATUSES = {"unknown", "trusted", "blocked", "vouched"}
|
||||
|
||||
|
||||
# ---------- candidate model --------------------------------------------------
|
||||
|
||||
class PeerCandidate(BaseModel):
|
||||
"""A peer found by the resolver/walker — not yet vetted, just observed."""
|
||||
domain: str
|
||||
fingerprint: str
|
||||
port: int = DEFAULT_PORT
|
||||
source: str # "dns-sd" | "peer-walk:<source-domain>"
|
||||
discovered_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
# ---------- DNS-SD resolver --------------------------------------------------
|
||||
|
||||
def _parse_txt_value(txt: str) -> Result[Dict[str, str], str]:
|
||||
"""Parse `v=psyc1 fp=<hex> alg=ed25519 path=...` → dict. Tolerant of order."""
|
||||
out: Dict[str, str] = {}
|
||||
for token in txt.strip().split():
|
||||
if "=" not in token:
|
||||
return Err(f"malformed TXT token (no '='): {token!r}")
|
||||
k, v = token.split("=", 1)
|
||||
out[k.strip()] = v.strip()
|
||||
if out.get("v") != federation.FEED_VERSION:
|
||||
return Err(f"unsupported version: {out.get('v')!r}")
|
||||
fp = out.get("fp", "")
|
||||
if len(fp) != 32 or any(c not in "0123456789abcdef" for c in fp.lower()):
|
||||
return Err(f"bad fingerprint: {fp!r}")
|
||||
if out.get("alg") and out["alg"] != federation.FEED_ALG:
|
||||
return Err(f"unsupported alg: {out.get('alg')!r}")
|
||||
return Ok(out)
|
||||
|
||||
|
||||
def _flatten_txt(rdata: Any) -> str:
|
||||
"""DNS TXT records are a sequence of byte-strings — join them. Tolerant of mocks."""
|
||||
strings = getattr(rdata, "strings", None)
|
||||
if strings is None:
|
||||
return str(rdata).strip('"')
|
||||
parts: List[str] = []
|
||||
for s in strings:
|
||||
if isinstance(s, bytes):
|
||||
parts.append(s.decode("utf-8", errors="replace"))
|
||||
else:
|
||||
parts.append(str(s))
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
def resolve_psyc(domain: str, timeout: float = DEFAULT_TIMEOUT) -> Result[PeerCandidate, str]:
|
||||
"""Look up `_psyc._tcp.<domain>` SRV + TXT → return a candidate.
|
||||
|
||||
SRV gives target+port; TXT carries `v=psyc1 fp=... alg=ed25519 path=...`.
|
||||
Any DNS failure, parse failure, or missing record returns Err.
|
||||
"""
|
||||
name = f"_psyc._tcp.{domain}"
|
||||
resolver = dns.resolver.Resolver()
|
||||
resolver.lifetime = timeout
|
||||
resolver.timeout = timeout
|
||||
|
||||
# SRV: target host + port.
|
||||
port = DEFAULT_PORT
|
||||
try:
|
||||
srv_answers = resolver.resolve(name, "SRV")
|
||||
except dns.resolver.NXDOMAIN:
|
||||
return Err(f"no SRV record at {name} (NXDOMAIN)")
|
||||
except dns.resolver.NoAnswer:
|
||||
return Err(f"no SRV record at {name} (NoAnswer)")
|
||||
except dns.exception.Timeout:
|
||||
return Err(f"SRV lookup timed out for {name}")
|
||||
except dns.exception.DNSException as exc:
|
||||
return Err(f"SRV lookup failed for {name}: {exc}")
|
||||
srv_list = list(srv_answers)
|
||||
if not srv_list:
|
||||
return Err(f"no SRV record at {name} (empty)")
|
||||
try:
|
||||
port = int(srv_list[0].port)
|
||||
except Exception:
|
||||
port = DEFAULT_PORT
|
||||
|
||||
# TXT: fingerprint + protocol metadata.
|
||||
try:
|
||||
txt_answers = resolver.resolve(name, "TXT")
|
||||
except dns.resolver.NXDOMAIN:
|
||||
return Err(f"no TXT record at {name} (NXDOMAIN)")
|
||||
except dns.resolver.NoAnswer:
|
||||
return Err(f"no TXT record at {name} (NoAnswer)")
|
||||
except dns.exception.Timeout:
|
||||
return Err(f"TXT lookup timed out for {name}")
|
||||
except dns.exception.DNSException as exc:
|
||||
return Err(f"TXT lookup failed for {name}: {exc}")
|
||||
txt_list = list(txt_answers)
|
||||
if not txt_list:
|
||||
return Err(f"no TXT record at {name} (empty)")
|
||||
|
||||
last_err: Optional[str] = None
|
||||
for rdata in txt_list:
|
||||
value = _flatten_txt(rdata)
|
||||
parsed = _parse_txt_value(value)
|
||||
if isinstance(parsed, Ok):
|
||||
return Ok(PeerCandidate(
|
||||
domain=domain,
|
||||
fingerprint=parsed.value["fp"].lower(),
|
||||
port=port,
|
||||
source="dns-sd",
|
||||
))
|
||||
last_err = parsed.reason
|
||||
return Err(f"no usable psyc TXT record at {name}: {last_err}")
|
||||
|
||||
|
||||
# ---------- HTTP probes ------------------------------------------------------
|
||||
|
||||
def _base_url(domain: str, port: int) -> str:
|
||||
if port == 443:
|
||||
return f"https://{domain}"
|
||||
return f"https://{domain}:{port}"
|
||||
|
||||
|
||||
def fetch_peer_info(domain: str, port: int = DEFAULT_PORT, timeout: float = DEFAULT_TIMEOUT,
|
||||
expected_fingerprint: Optional[str] = None) -> Result[Dict[str, Any], str]:
|
||||
"""GET /federation/info on a peer. Cross-checks fingerprint if provided.
|
||||
|
||||
The cross-check defends against MITM-injected TXT records — DNS said one
|
||||
fingerprint, the live node's HTTPS-served info MUST agree.
|
||||
"""
|
||||
url = _base_url(domain, port) + "/federation/info"
|
||||
try:
|
||||
with httpx.Client(timeout=timeout) as client:
|
||||
r = client.get(url)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
return Err(f"HTTP {exc.response.status_code} from {url}")
|
||||
except httpx.RequestError as exc:
|
||||
return Err(f"network error fetching {url}: {exc}")
|
||||
except ValueError as exc:
|
||||
return Err(f"non-JSON response from {url}: {exc}")
|
||||
except Exception as exc: # noqa: BLE001 — anything weird is a failure
|
||||
return Err(f"fetch failed for {url}: {exc}")
|
||||
if not isinstance(data, dict):
|
||||
return Err(f"unexpected info shape from {url}: {type(data).__name__}")
|
||||
declared = str(data.get("fingerprint", "")).lower()
|
||||
if expected_fingerprint and declared != expected_fingerprint.lower():
|
||||
return Err(
|
||||
f"fingerprint mismatch for {domain}: DNS said {expected_fingerprint!r} "
|
||||
f"but /federation/info said {declared!r}"
|
||||
)
|
||||
return Ok(data)
|
||||
|
||||
|
||||
def fetch_public_peers(domain: str, port: int = DEFAULT_PORT,
|
||||
timeout: float = DEFAULT_TIMEOUT) -> Result[List[Dict[str, Any]], str]:
|
||||
"""GET /federation/peers/public on a peer. Returns the list as-is for the walker to dedupe."""
|
||||
url = _base_url(domain, port) + "/federation/peers/public"
|
||||
try:
|
||||
with httpx.Client(timeout=timeout) as client:
|
||||
r = client.get(url)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
return Err(f"HTTP {exc.response.status_code} from {url}")
|
||||
except httpx.RequestError as exc:
|
||||
return Err(f"network error fetching {url}: {exc}")
|
||||
except ValueError as exc:
|
||||
return Err(f"non-JSON response from {url}: {exc}")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return Err(f"fetch failed for {url}: {exc}")
|
||||
if not isinstance(data, list):
|
||||
return Err(f"unexpected peers shape from {url}: {type(data).__name__}")
|
||||
out: List[Dict[str, Any]] = []
|
||||
for item in data:
|
||||
if isinstance(item, dict) and item.get("domain") and item.get("fingerprint"):
|
||||
out.append(item)
|
||||
return Ok(out)
|
||||
|
||||
|
||||
# ---------- BFS walker -------------------------------------------------------
|
||||
|
||||
def walk(seeds: List[str], max_depth: int = DEFAULT_MAX_DEPTH,
|
||||
max_peers: int = DEFAULT_MAX_PEERS, timeout: float = DEFAULT_TIMEOUT) -> List[PeerCandidate]:
|
||||
"""Breadth-first walk from `seeds` → discovered candidates.
|
||||
|
||||
For each domain: DNS-SD resolve → fetch /info to verify fingerprint →
|
||||
fetch /peers/public → enqueue its domains for the next layer. Dedupes on
|
||||
(domain, fingerprint). Skips our own fingerprint to avoid loops. All
|
||||
errors are logged but non-fatal — one bad peer doesn't abort the walk.
|
||||
"""
|
||||
own_fp = ""
|
||||
try:
|
||||
own_fp = federation.node_fingerprint()
|
||||
except Exception as exc: # noqa: BLE001 — discovery should work even pre-keygen
|
||||
_log.info("discovery.walk.no_own_fp", error=str(exc))
|
||||
|
||||
seen_pairs: Set[Tuple[str, str]] = set()
|
||||
seen_domains: Set[str] = set()
|
||||
out: List[PeerCandidate] = []
|
||||
|
||||
# Queue of (domain, depth, source). Seeds enter at depth 0.
|
||||
frontier: List[Tuple[str, int, str]] = [(d.strip(), 0, "dns-sd") for d in seeds if d and d.strip()]
|
||||
next_layer: List[Tuple[str, int, str]] = []
|
||||
|
||||
while frontier:
|
||||
domain, depth, source = frontier.pop(0)
|
||||
if domain in seen_domains:
|
||||
if not frontier:
|
||||
frontier, next_layer = next_layer, []
|
||||
continue
|
||||
seen_domains.add(domain)
|
||||
if len(out) >= max_peers:
|
||||
_log.info("discovery.walk.cap.peers", cap=max_peers)
|
||||
break
|
||||
|
||||
resolved = resolve_psyc(domain, timeout=timeout)
|
||||
if isinstance(resolved, Err):
|
||||
_log.info("discovery.resolve.skip", domain=domain, reason=resolved.reason)
|
||||
if not frontier:
|
||||
frontier, next_layer = next_layer, []
|
||||
continue
|
||||
cand = resolved.value
|
||||
cand.source = source if depth > 0 else "dns-sd"
|
||||
|
||||
if cand.fingerprint == own_fp:
|
||||
_log.info("discovery.skip.self", domain=domain)
|
||||
if not frontier:
|
||||
frontier, next_layer = next_layer, []
|
||||
continue
|
||||
|
||||
pair = (cand.domain, cand.fingerprint)
|
||||
if pair in seen_pairs:
|
||||
if not frontier:
|
||||
frontier, next_layer = next_layer, []
|
||||
continue
|
||||
seen_pairs.add(pair)
|
||||
|
||||
# Verify the live endpoint's fingerprint matches DNS. If we can't reach
|
||||
# it, still record the DNS-discovered candidate — vouching can vet it
|
||||
# later, and we don't want one HTTP outage to abort the walk.
|
||||
info_res = fetch_peer_info(domain, port=cand.port, timeout=timeout,
|
||||
expected_fingerprint=cand.fingerprint)
|
||||
if isinstance(info_res, Err):
|
||||
_log.info("discovery.info.skip", domain=domain, reason=info_res.reason)
|
||||
out.append(cand)
|
||||
if not frontier:
|
||||
frontier, next_layer = next_layer, []
|
||||
continue
|
||||
out.append(cand)
|
||||
|
||||
# Recurse: fetch this peer's public-peers list, enqueue domains.
|
||||
if depth + 1 <= max_depth:
|
||||
peers_res = fetch_public_peers(domain, port=cand.port, timeout=timeout)
|
||||
if isinstance(peers_res, Err):
|
||||
_log.info("discovery.peers.skip", domain=domain, reason=peers_res.reason)
|
||||
else:
|
||||
for item in peers_res.value:
|
||||
child_domain = str(item.get("domain", "")).strip()
|
||||
if not child_domain or child_domain in seen_domains:
|
||||
continue
|
||||
child_source = f"peer-walk:{domain}"
|
||||
next_layer.append((child_domain, depth + 1, child_source))
|
||||
|
||||
if not frontier:
|
||||
frontier, next_layer = next_layer, []
|
||||
|
||||
_log.info("discovery.walk.done", seeds=len(seeds), discovered=len(out), max_depth=max_depth)
|
||||
return out
|
||||
|
||||
|
||||
# ---------- persistence ------------------------------------------------------
|
||||
|
||||
def record_candidate(c: PeerCandidate, default_status: str = "unknown") -> None:
|
||||
"""Upsert a discovered candidate into the peers table.
|
||||
|
||||
Preserves any existing trusted/blocked status — discovery NEVER demotes a
|
||||
peer the operator has already classified. Only updates last_seen.
|
||||
"""
|
||||
if default_status not in _VALID_STATUSES:
|
||||
default_status = "unknown"
|
||||
existing = db.get_peer(c.domain)
|
||||
now = c.discovered_at.isoformat()
|
||||
if existing:
|
||||
status = existing.get("status") or default_status
|
||||
if status not in ("trusted", "blocked"):
|
||||
status = default_status
|
||||
db.upsert_peer(dict(
|
||||
domain=c.domain,
|
||||
fingerprint=existing.get("fingerprint") or c.fingerprint,
|
||||
pubkey_pem=existing.get("pubkey_pem") or "",
|
||||
status=status,
|
||||
discovered_at=existing.get("discovered_at") or now,
|
||||
last_seen=now,
|
||||
notes=existing.get("notes"),
|
||||
))
|
||||
return
|
||||
db.upsert_peer(dict(
|
||||
domain=c.domain,
|
||||
fingerprint=c.fingerprint,
|
||||
pubkey_pem="", # populated when we successfully fetch /federation/key during vouching
|
||||
status=default_status,
|
||||
discovered_at=now,
|
||||
last_seen=now,
|
||||
notes=f"discovered via {c.source}",
|
||||
))
|
||||
_log.info("discovery.recorded", domain=c.domain, fp=c.fingerprint, source=c.source)
|
||||
|
||||
|
||||
# ---------- public attestation -----------------------------------------------
|
||||
|
||||
def public_peer_attestation() -> List[Dict[str, Any]]:
|
||||
"""List of peers we'll publicly attest to. Only `trusted` — never leaks unknown/blocked.
|
||||
|
||||
This is the surface that other psyc nodes' walkers read from us. We never
|
||||
expose unknown or blocked peers — those are internal classification state.
|
||||
"""
|
||||
out: List[Dict[str, Any]] = []
|
||||
for row in db.list_peers():
|
||||
if row.get("status") != "trusted":
|
||||
continue
|
||||
out.append({
|
||||
"domain": row["domain"],
|
||||
"fingerprint": row["fingerprint"],
|
||||
"first_seen": row.get("discovered_at"),
|
||||
})
|
||||
return out
|
||||
@@ -16,14 +16,21 @@ import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
# Hostname-with-optional-port pattern for peer domains. Reject anything else at
|
||||
# registration so a hostile domain string can't reach a render context where
|
||||
# it could break out of an HTML attr or JS string.
|
||||
_DOMAIN_RE = re.compile(r"^[A-Za-z0-9._\-]+(:\d{1,5})?$")
|
||||
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ed25519
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from psyc import DATA_DIR, db, log
|
||||
from psyc.lines import translog
|
||||
from psyc.result import Err, Ok, Result
|
||||
|
||||
|
||||
@@ -259,6 +266,9 @@ def build_signed_feed(window_hours: int = 24) -> Dict[str, Any]:
|
||||
"window_hours": window_hours,
|
||||
"cases": _build_case_records(window_hours),
|
||||
"iocs": _build_ioc_records(window_hours),
|
||||
# Vouches we've issued ride along with the feed so peers can learn
|
||||
# who we trust and accumulate quorum on shared targets.
|
||||
"vouches": [v.model_dump() for v in our_vouches()],
|
||||
}
|
||||
sig = sign_payload(canonical_json(payload))
|
||||
payload["signature"] = base64.b64encode(sig).decode("ascii")
|
||||
@@ -306,10 +316,16 @@ def import_signed_feed(feed: Dict[str, Any], expected_pubkey_pem: str) -> Result
|
||||
except Exception as exc:
|
||||
return Err(f"bad pubkey: {exc}")
|
||||
|
||||
# Listening gate: only accept signals from peers we explicitly trust or
|
||||
# that quorum of trusted peers vouches for. Unknown peers don't land here.
|
||||
if not peer_is_listening_eligible(peer_fp):
|
||||
return Err(f"peer not trusted: {peer_fp}")
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
signal_ids: List[Tuple[str, str]] = []
|
||||
cases = feed.get("cases") or []
|
||||
iocs = feed.get("iocs") or []
|
||||
feed_vouches = feed.get("vouches") or []
|
||||
|
||||
for c in cases:
|
||||
case_id = c.get("case_id") or ""
|
||||
@@ -323,6 +339,15 @@ def import_signed_feed(feed: Dict[str, Any], expected_pubkey_pem: str) -> Result
|
||||
raw_json=json.dumps(c, sort_keys=True),
|
||||
))
|
||||
signal_ids.append(("case", digest))
|
||||
try:
|
||||
translog.append("signal", {
|
||||
"peer_fingerprint": peer_fp,
|
||||
"signal_type": "case",
|
||||
"signal_id": case_id,
|
||||
"signal_hash": digest,
|
||||
})
|
||||
except Exception as exc: # transparency log is best-effort, never block ingest
|
||||
_log.warning("federation.translog.append.fail", error=str(exc))
|
||||
|
||||
for i in iocs:
|
||||
value = i.get("value") or ""
|
||||
@@ -336,6 +361,36 @@ def import_signed_feed(feed: Dict[str, Any], expected_pubkey_pem: str) -> Result
|
||||
raw_json=json.dumps(i, sort_keys=True),
|
||||
))
|
||||
signal_ids.append(("ioc", digest))
|
||||
try:
|
||||
translog.append("signal", {
|
||||
"peer_fingerprint": peer_fp,
|
||||
"signal_type": "ioc",
|
||||
"signal_id": value,
|
||||
"signal_hash": digest,
|
||||
})
|
||||
except Exception as exc:
|
||||
_log.warning("federation.translog.append.fail", error=str(exc))
|
||||
|
||||
# Vouch propagation — peer asserts who they trust. We only accept vouches
|
||||
# whose declared voucher fingerprint matches the peer we just authenticated
|
||||
# (so a peer can't forge vouches "from" someone else through us).
|
||||
for v_raw in feed_vouches:
|
||||
if not isinstance(v_raw, dict):
|
||||
continue
|
||||
try:
|
||||
vouch = Vouch.model_validate(v_raw)
|
||||
except Exception as exc:
|
||||
_log.warning("federation.vouch.malformed", error=str(exc))
|
||||
continue
|
||||
if vouch.voucher_fingerprint != peer_fp:
|
||||
_log.warning(
|
||||
"federation.vouch.voucher_mismatch",
|
||||
claimed=vouch.voucher_fingerprint, actual=peer_fp,
|
||||
)
|
||||
continue
|
||||
accepted = accept_vouch(vouch, expected_pubkey_pem)
|
||||
if isinstance(accepted, Err):
|
||||
_log.warning("federation.vouch.rejected", reason=accepted.reason)
|
||||
|
||||
_log.info("federation.import.ok", peer=peer_fp, cases=len(cases), iocs=len(iocs))
|
||||
return Ok(ImportSummary(
|
||||
@@ -371,7 +426,17 @@ def _row_to_peer(row: Dict[str, Any]) -> Peer:
|
||||
|
||||
|
||||
def register_peer(domain: str, fingerprint: str, pubkey_pem: str, status: str = "unknown") -> None:
|
||||
"""Insert or update a peer in the registry. Idempotent on `domain`."""
|
||||
"""Insert or update a peer in the registry. Idempotent on `domain`.
|
||||
|
||||
Rejects malformed domain strings — only hostname chars + optional :port.
|
||||
Closes a stored-XSS hole where a hostile `domain` would have been rendered
|
||||
into the admin federation page's confirm() prompt.
|
||||
"""
|
||||
domain = (domain or "").strip()
|
||||
if not _DOMAIN_RE.match(domain):
|
||||
raise ValueError(f"invalid domain: {domain!r}")
|
||||
if fingerprint and not re.fullmatch(r"[0-9a-fA-F]{32}", fingerprint):
|
||||
raise ValueError(f"invalid fingerprint: {fingerprint!r}")
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
existing = db.get_peer(domain)
|
||||
discovered_at = existing["discovered_at"] if existing else now
|
||||
@@ -403,3 +468,279 @@ def set_peer_status(domain: str, status: str) -> None:
|
||||
|
||||
def remove_peer(domain: str) -> None:
|
||||
db.remove_peer(domain)
|
||||
|
||||
|
||||
# ---------- vouching + quorum (stage 4) ---------------------------------
|
||||
#
|
||||
# The web of trust: a peer's fingerprint becomes "listening-eligible" when
|
||||
# either we directly trust it (peers.status == "trusted") or at least
|
||||
# `trust_min_vouchers` of our trusted peers have signed a vouch for it.
|
||||
#
|
||||
# Signal-level quorum: a federation_signals row is meaningful only when
|
||||
# `signal_quorum_k` distinct vouched peers have reported the same signal_hash.
|
||||
#
|
||||
# Vouches are short Pydantic records signed with the voucher's Ed25519 key
|
||||
# over canonical JSON of the body (everything but the signature field).
|
||||
|
||||
|
||||
class Vouch(BaseModel):
|
||||
voucher_fingerprint: str
|
||||
target_fingerprint: str
|
||||
issued_at: datetime
|
||||
expires_at: Optional[datetime] = None
|
||||
signature: str = "" # base64 ed25519 sig over vouch_payload_bytes(...)
|
||||
|
||||
|
||||
class QuorumConfig(BaseModel):
|
||||
trust_min_vouchers: int = 2
|
||||
signal_quorum_k: int = 2
|
||||
|
||||
|
||||
_QC_TRUST_KEY = "wot_trust_min"
|
||||
_QC_K_KEY = "wot_quorum_k"
|
||||
|
||||
|
||||
def quorum_config() -> QuorumConfig:
|
||||
"""Live quorum settings, with sensible defaults if pulse_settings is empty."""
|
||||
cfg = QuorumConfig()
|
||||
t = db.setting_get(_QC_TRUST_KEY)
|
||||
k = db.setting_get(_QC_K_KEY)
|
||||
if t is not None:
|
||||
try:
|
||||
cfg.trust_min_vouchers = max(1, int(t))
|
||||
except ValueError:
|
||||
pass
|
||||
if k is not None:
|
||||
try:
|
||||
cfg.signal_quorum_k = max(1, int(k))
|
||||
except ValueError:
|
||||
pass
|
||||
return cfg
|
||||
|
||||
|
||||
def set_quorum_config(cfg: QuorumConfig) -> None:
|
||||
"""Persist quorum config into pulse_settings."""
|
||||
db.setting_set(_QC_TRUST_KEY, str(cfg.trust_min_vouchers))
|
||||
db.setting_set(_QC_K_KEY, str(cfg.signal_quorum_k))
|
||||
|
||||
|
||||
def vouch_payload_bytes(
|
||||
voucher_fp: str,
|
||||
target_fp: str,
|
||||
issued_at: datetime,
|
||||
expires_at: Optional[datetime],
|
||||
) -> bytes:
|
||||
"""Canonical JSON of the unsigned vouch body — what the voucher signs."""
|
||||
body: Dict[str, Any] = {
|
||||
"voucher_fingerprint": voucher_fp,
|
||||
"target_fingerprint": target_fp,
|
||||
"issued_at": issued_at.isoformat(),
|
||||
"expires_at": expires_at.isoformat() if expires_at else None,
|
||||
}
|
||||
return canonical_json(body)
|
||||
|
||||
|
||||
def _store_vouch(v: Vouch) -> None:
|
||||
db.upsert_vouch(dict(
|
||||
voucher_fingerprint=v.voucher_fingerprint,
|
||||
target_fingerprint=v.target_fingerprint,
|
||||
issued_at=v.issued_at.isoformat(),
|
||||
expires_at=v.expires_at.isoformat() if v.expires_at else None,
|
||||
signature=v.signature,
|
||||
))
|
||||
|
||||
|
||||
def _row_to_vouch(row: Dict[str, Any]) -> Vouch:
|
||||
return Vouch(
|
||||
voucher_fingerprint=row["voucher_fingerprint"],
|
||||
target_fingerprint=row["target_fingerprint"],
|
||||
issued_at=datetime.fromisoformat(row["issued_at"]),
|
||||
expires_at=datetime.fromisoformat(row["expires_at"]) if row.get("expires_at") else None,
|
||||
signature=row.get("signature") or "",
|
||||
)
|
||||
|
||||
|
||||
def issue_vouch(target_fingerprint: str, ttl_days: int = 90) -> Vouch:
|
||||
"""Sign a vouch for `target_fingerprint` with OUR key. Persists + returns it."""
|
||||
our_fp = node_fingerprint()
|
||||
issued_at = datetime.now(timezone.utc)
|
||||
expires_at = issued_at + timedelta(days=ttl_days) if ttl_days > 0 else None
|
||||
payload = vouch_payload_bytes(our_fp, target_fingerprint, issued_at, expires_at)
|
||||
sig = sign_payload(payload)
|
||||
vouch = Vouch(
|
||||
voucher_fingerprint=our_fp,
|
||||
target_fingerprint=target_fingerprint,
|
||||
issued_at=issued_at,
|
||||
expires_at=expires_at,
|
||||
signature=base64.b64encode(sig).decode("ascii"),
|
||||
)
|
||||
_store_vouch(vouch)
|
||||
try:
|
||||
translog.append("vouch", {
|
||||
"voucher_fingerprint": vouch.voucher_fingerprint,
|
||||
"target_fingerprint": vouch.target_fingerprint,
|
||||
"issued_at": vouch.issued_at.isoformat(),
|
||||
"expires_at": vouch.expires_at.isoformat() if vouch.expires_at else None,
|
||||
})
|
||||
except Exception as exc:
|
||||
_log.warning("federation.translog.append.fail", error=str(exc))
|
||||
_log.info("federation.vouch.issued", target=target_fingerprint, ttl_days=ttl_days)
|
||||
return vouch
|
||||
|
||||
|
||||
def accept_vouch(vouch: Vouch, voucher_pubkey_pem: str) -> Result[None, str]:
|
||||
"""Verify signature + expiry + voucher trust status, then persist.
|
||||
|
||||
Failure modes return Err with a short reason so the caller can log them.
|
||||
A voucher whose status is not "trusted" in our peers table is refused —
|
||||
we don't accept transitive vouches from unknown peers.
|
||||
"""
|
||||
# Expiry first — cheapest check.
|
||||
now = datetime.now(timezone.utc)
|
||||
if vouch.expires_at is not None and vouch.expires_at < now:
|
||||
return Err("vouch expired")
|
||||
|
||||
# Voucher must be a directly-trusted peer (no transitive trust at this layer).
|
||||
voucher_status = None
|
||||
for row in db.list_peers():
|
||||
if row.get("fingerprint") == vouch.voucher_fingerprint:
|
||||
voucher_status = row.get("status")
|
||||
break
|
||||
if voucher_status != "trusted":
|
||||
return Err(f"voucher not trusted: {vouch.voucher_fingerprint}")
|
||||
|
||||
# The pubkey must match the declared voucher fingerprint.
|
||||
try:
|
||||
if _fingerprint_for_pubkey_pem(voucher_pubkey_pem) != vouch.voucher_fingerprint:
|
||||
return Err("voucher pubkey does not match fingerprint")
|
||||
except Exception as exc:
|
||||
return Err(f"bad voucher pubkey: {exc}")
|
||||
|
||||
payload = vouch_payload_bytes(
|
||||
vouch.voucher_fingerprint,
|
||||
vouch.target_fingerprint,
|
||||
vouch.issued_at,
|
||||
vouch.expires_at,
|
||||
)
|
||||
try:
|
||||
signature = base64.b64decode(vouch.signature)
|
||||
except Exception:
|
||||
return Err("vouch signature not base64")
|
||||
if not verify_payload(payload, signature, voucher_pubkey_pem):
|
||||
return Err("vouch signature invalid")
|
||||
|
||||
_store_vouch(vouch)
|
||||
try:
|
||||
translog.append("vouch", {
|
||||
"voucher_fingerprint": vouch.voucher_fingerprint,
|
||||
"target_fingerprint": vouch.target_fingerprint,
|
||||
"issued_at": vouch.issued_at.isoformat(),
|
||||
"expires_at": vouch.expires_at.isoformat() if vouch.expires_at else None,
|
||||
"accepted": True,
|
||||
})
|
||||
except Exception as exc:
|
||||
_log.warning("federation.translog.append.fail", error=str(exc))
|
||||
_log.info("federation.vouch.accepted", voucher=vouch.voucher_fingerprint, target=vouch.target_fingerprint)
|
||||
return Ok(None)
|
||||
|
||||
|
||||
def revoke_vouch(target_fingerprint: str) -> None:
|
||||
"""Delete OUR vouch naming `target_fingerprint`. No-op if absent."""
|
||||
db.delete_vouch(node_fingerprint(), target_fingerprint)
|
||||
_log.info("federation.vouch.revoked", target=target_fingerprint)
|
||||
|
||||
|
||||
def our_vouches() -> List[Vouch]:
|
||||
"""Vouches we have issued (filter for voucher_fingerprint == our fp)."""
|
||||
return [_row_to_vouch(r) for r in db.vouches_by_voucher(node_fingerprint())]
|
||||
|
||||
|
||||
def vouches_for(target_fingerprint: str) -> List[Vouch]:
|
||||
"""Every vouch stored locally that names `target_fingerprint` as target."""
|
||||
return [_row_to_vouch(r) for r in db.vouches_by_target(target_fingerprint)]
|
||||
|
||||
|
||||
def is_vouched(target_fingerprint: str, min_vouchers: Optional[int] = None) -> bool:
|
||||
"""True iff ≥`min_vouchers` distinct non-expired vouches from currently-trusted
|
||||
peers name `target_fingerprint`.
|
||||
"""
|
||||
cfg = quorum_config()
|
||||
threshold = min_vouchers if min_vouchers is not None else cfg.trust_min_vouchers
|
||||
if threshold <= 0:
|
||||
return True
|
||||
now = datetime.now(timezone.utc)
|
||||
trusted_fps = {p.fingerprint for p in list_peers() if p.status == "trusted"}
|
||||
distinct_vouchers: set = set()
|
||||
for v in vouches_for(target_fingerprint):
|
||||
if v.expires_at is not None and v.expires_at < now:
|
||||
continue
|
||||
if v.voucher_fingerprint not in trusted_fps:
|
||||
continue
|
||||
distinct_vouchers.add(v.voucher_fingerprint)
|
||||
if len(distinct_vouchers) >= threshold:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def peer_is_listening_eligible(fingerprint: str) -> bool:
|
||||
"""True iff the peer is directly trusted OR vouched into trust.
|
||||
|
||||
This is the gate used by `import_signed_feed`. Auto-response will share
|
||||
this signature — keep it stable.
|
||||
"""
|
||||
if not fingerprint:
|
||||
return False
|
||||
for p in list_peers():
|
||||
if p.fingerprint == fingerprint:
|
||||
if p.status == "trusted":
|
||||
return True
|
||||
if p.status == "blocked":
|
||||
return False
|
||||
break
|
||||
return is_vouched(fingerprint)
|
||||
|
||||
|
||||
def is_quorum_met(signal_hash: str, k: Optional[int] = None) -> bool:
|
||||
"""True iff ≥k distinct vouched peers have reported `signal_hash`.
|
||||
|
||||
"Vouched" here means `peer_is_listening_eligible` — the same web-of-trust
|
||||
set the import gate respects. Self-reports from the local node do not
|
||||
count (they never end up in federation_signals).
|
||||
"""
|
||||
cfg = quorum_config()
|
||||
threshold = k if k is not None else cfg.signal_quorum_k
|
||||
if threshold <= 0:
|
||||
return True
|
||||
rows = db.signals_for_hash(signal_hash)
|
||||
distinct: set = set()
|
||||
for r in rows:
|
||||
fp = r.get("peer_fingerprint") or ""
|
||||
if not fp or fp in distinct:
|
||||
continue
|
||||
if not peer_is_listening_eligible(fp):
|
||||
continue
|
||||
distinct.add(fp)
|
||||
if len(distinct) >= threshold:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def quorum_evidence(signal_hash: str) -> List[Tuple[str, datetime]]:
|
||||
"""(peer_fingerprint, received_at) tuples for one signal_hash — for UI display.
|
||||
|
||||
Only includes signals from currently listening-eligible peers, deduped
|
||||
per fingerprint at the earliest receipt.
|
||||
"""
|
||||
rows = db.signals_for_hash(signal_hash)
|
||||
earliest: Dict[str, datetime] = {}
|
||||
for r in rows:
|
||||
fp = r.get("peer_fingerprint") or ""
|
||||
if not fp or not peer_is_listening_eligible(fp):
|
||||
continue
|
||||
try:
|
||||
ts = datetime.fromisoformat(r.get("received_at") or "")
|
||||
except ValueError:
|
||||
continue
|
||||
if fp not in earliest or ts < earliest[fp]:
|
||||
earliest[fp] = ts
|
||||
return sorted(earliest.items(), key=lambda kv: kv[1])
|
||||
|
||||
1029
src/psyc/lines/network_view.py
Normal file
1029
src/psyc/lines/network_view.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,7 @@ from typing import Callable, Dict, List, Optional, Tuple
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from psyc import db, log
|
||||
from psyc.models import Severity
|
||||
|
||||
|
||||
_log = log.get(__name__)
|
||||
@@ -33,6 +34,73 @@ class PulseMode(str, Enum):
|
||||
AUTO_EXECUTE = "auto-execute"
|
||||
|
||||
|
||||
# ---------- respond auto-fire gates -----------------------------------------
|
||||
# Persisted as rows in pulse_settings (key/value pairs). All defaults are
|
||||
# "safe" — quorum required, HIGH threshold, federation cases permitted only
|
||||
# when quorum-met.
|
||||
|
||||
_KEY_RESPOND_THRESHOLD = "respond_auto_threshold"
|
||||
_KEY_RESPOND_REQUIRE_QUORUM = "respond_require_quorum"
|
||||
_KEY_RESPOND_LOCAL_ONLY = "respond_local_only"
|
||||
|
||||
_DEFAULT_THRESHOLD = Severity.HIGH
|
||||
_DEFAULT_REQUIRE_QUORUM = True
|
||||
_DEFAULT_LOCAL_ONLY = False
|
||||
|
||||
|
||||
def _severity_rank(sev: Optional[Severity]) -> int:
|
||||
"""Rank order for severity threshold comparison. Unknown / None → -1."""
|
||||
if sev is None:
|
||||
return -1
|
||||
return {
|
||||
Severity.LOW: 0,
|
||||
Severity.MEDIUM: 1,
|
||||
Severity.HIGH: 2,
|
||||
Severity.CRITICAL: 3,
|
||||
}.get(sev, -1)
|
||||
|
||||
|
||||
def respond_auto_threshold() -> Severity:
|
||||
raw = db.pulse_setting_get(_KEY_RESPOND_THRESHOLD)
|
||||
if raw is None:
|
||||
return _DEFAULT_THRESHOLD
|
||||
try:
|
||||
return Severity(raw)
|
||||
except ValueError:
|
||||
return _DEFAULT_THRESHOLD
|
||||
|
||||
|
||||
def set_respond_auto_threshold(sev: Severity) -> None:
|
||||
if not isinstance(sev, Severity):
|
||||
raise ValueError(f"not a Severity: {sev!r}")
|
||||
db.pulse_setting_set(_KEY_RESPOND_THRESHOLD, sev.value)
|
||||
_log.info("pulse.respond.threshold.changed", severity=sev.value)
|
||||
|
||||
|
||||
def respond_require_quorum() -> bool:
|
||||
raw = db.pulse_setting_get(_KEY_RESPOND_REQUIRE_QUORUM)
|
||||
if raw is None:
|
||||
return _DEFAULT_REQUIRE_QUORUM
|
||||
return raw == "1"
|
||||
|
||||
|
||||
def set_respond_require_quorum(state: bool) -> None:
|
||||
db.pulse_setting_set(_KEY_RESPOND_REQUIRE_QUORUM, "1" if state else "0")
|
||||
_log.info("pulse.respond.quorum.changed", required=bool(state))
|
||||
|
||||
|
||||
def respond_local_only() -> bool:
|
||||
raw = db.pulse_setting_get(_KEY_RESPOND_LOCAL_ONLY)
|
||||
if raw is None:
|
||||
return _DEFAULT_LOCAL_ONLY
|
||||
return raw == "1"
|
||||
|
||||
|
||||
def set_respond_local_only(state: bool) -> None:
|
||||
db.pulse_setting_set(_KEY_RESPOND_LOCAL_ONLY, "1" if state else "0")
|
||||
_log.info("pulse.respond.local-only.changed", local_only=bool(state))
|
||||
|
||||
|
||||
class Pipeline(BaseModel):
|
||||
name: str
|
||||
title: str
|
||||
@@ -114,28 +182,262 @@ def _run_reindex() -> str:
|
||||
return f"indexed {written} IOC(s) from {len(cases)} case(s)"
|
||||
|
||||
|
||||
def _run_respond_propose() -> str:
|
||||
def _propose_for_recent_cases() -> int:
|
||||
"""Propose response actions for high-severity cases that don't yet have any.
|
||||
|
||||
Strictly proposal-only: nothing is dispatched. The respond.propose_for_case
|
||||
helper is already idempotent per case (skips cases that already have
|
||||
actions), so re-running this on a tick is safe.
|
||||
Returns total proposed-action count. Idempotent per case (respond's
|
||||
propose_for_case skips cases that already have actions).
|
||||
"""
|
||||
from psyc.lines import respond
|
||||
|
||||
cases = db.list_cases(limit=10_000)
|
||||
proposed = 0
|
||||
touched = 0
|
||||
for c in cases:
|
||||
ids = respond.propose_for_case(c)
|
||||
if ids:
|
||||
proposed += len(ids)
|
||||
touched += 1
|
||||
return f"proposed {proposed} action(s) for {touched} case(s)"
|
||||
proposed += len(ids)
|
||||
return proposed
|
||||
|
||||
|
||||
def _current_mode(pipeline_name: str) -> PulseMode:
|
||||
p = _get_pipeline(pipeline_name)
|
||||
return p.mode if p is not None else PulseMode.MANUAL
|
||||
|
||||
|
||||
def _is_quorum_met(case_digest_hash: str) -> bool:
|
||||
"""Wrapper for federation.is_quorum_met that tolerates the sibling agent
|
||||
not having shipped the function yet.
|
||||
|
||||
If federation lacks `is_quorum_met`, we fall back to False — the safe
|
||||
default ("no quorum signal → don't fire federation cases").
|
||||
"""
|
||||
try:
|
||||
from psyc.lines import federation as _federation
|
||||
fn = getattr(_federation, "is_quorum_met", None)
|
||||
if fn is None:
|
||||
return False
|
||||
return bool(fn(case_digest_hash))
|
||||
except Exception as exc: # noqa: BLE001 — defensive: any import / runtime miss → safe-false
|
||||
_log.warning("pulse.respond.quorum.unavailable", error=str(exc))
|
||||
return False
|
||||
|
||||
|
||||
def _canonical_json_local(obj: Dict[str, object]) -> bytes:
|
||||
"""Deterministic JSON serialization — mirrors federation.canonical_json
|
||||
for the case-digest computation. Local copy so we don't hard-require
|
||||
federation to be importable.
|
||||
"""
|
||||
import json
|
||||
return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||
|
||||
|
||||
def _case_digest_hash(case_id: str) -> str:
|
||||
"""SHA-256 of the canonical JSON of {case_id: ...} — what federation hashes.
|
||||
|
||||
Returns "" if the case can't be loaded (e.g. row vanished mid-fire).
|
||||
"""
|
||||
import hashlib
|
||||
from psyc.result import Ok as _Ok
|
||||
got = db.get_case(case_id)
|
||||
if not isinstance(got, _Ok):
|
||||
return ""
|
||||
case = got.value
|
||||
# Mirror federation._build_case_records' record shape so digests match.
|
||||
record = {
|
||||
"case_id": case.case_id,
|
||||
"summary": case.summary,
|
||||
"severity": case.classification.severity.value if case.classification.severity else None,
|
||||
"incident_type": case.classification.incident_type.value if case.classification.incident_type else None,
|
||||
"observed_at": case.observed_at.isoformat(),
|
||||
"feed_source": case.source_metadata.get("feed", ""),
|
||||
"iocs": (
|
||||
[{"value": v, "type": "url"} for v in case.observables.urls]
|
||||
+ [{"value": v, "type": "domain"} for v in case.observables.domains]
|
||||
+ [{"value": v, "type": "ip"} for v in case.observables.ips]
|
||||
+ [{"value": v, "type": "hash"} for v in case.observables.hashes]
|
||||
+ [{"value": v, "type": "cve"} for v in case.observables.cves]
|
||||
),
|
||||
}
|
||||
return hashlib.sha256(_canonical_json_local(record)).hexdigest()
|
||||
|
||||
|
||||
def _case_is_local(case_id: str) -> bool:
|
||||
"""True if no federation peer has ever pushed us this case_id."""
|
||||
return len(db.signals_for_case(case_id)) == 0
|
||||
|
||||
|
||||
def _audit(action: str, *, action_id: Optional[int] = None,
|
||||
case_id: Optional[str] = None, detail: str = "") -> None:
|
||||
db.pulse_audit_record(dict(
|
||||
pipeline="respond",
|
||||
action=action,
|
||||
action_id=action_id,
|
||||
case_id=case_id,
|
||||
detail=detail[:500],
|
||||
timestamp=_now().isoformat(),
|
||||
))
|
||||
|
||||
|
||||
def _auto_fire_eligible() -> Tuple[int, int]:
|
||||
"""Iterate PROPOSED actions and execute the ones that clear every gate.
|
||||
|
||||
Returns (fired_count, skipped_count). Records a pulse_audit row for every
|
||||
decision (fired or skipped-with-reason) so the cockpit can show history.
|
||||
A single failing action never aborts the batch.
|
||||
"""
|
||||
from psyc.lines import respond
|
||||
from psyc.models import ActionStatus
|
||||
from psyc.result import Ok as _Ok
|
||||
|
||||
threshold = respond_auto_threshold()
|
||||
threshold_rank = _severity_rank(threshold)
|
||||
require_quorum = respond_require_quorum()
|
||||
local_only = respond_local_only()
|
||||
|
||||
fired = 0
|
||||
skipped = 0
|
||||
|
||||
actions = respond.list_actions(status=ActionStatus.PROPOSED, limit=100)
|
||||
for action in actions:
|
||||
# Re-hydrate severity enum (action.severity is the .value string).
|
||||
try:
|
||||
sev_enum = Severity(action.severity) if action.severity else None
|
||||
except ValueError:
|
||||
sev_enum = None
|
||||
|
||||
if _severity_rank(sev_enum) < threshold_rank:
|
||||
skipped += 1
|
||||
_audit(
|
||||
"skipped",
|
||||
action_id=action.id,
|
||||
case_id=action.case_id,
|
||||
detail=f"below threshold: severity={action.severity!r} < {threshold.value}",
|
||||
)
|
||||
continue
|
||||
|
||||
is_local = _case_is_local(action.case_id)
|
||||
if require_quorum and not is_local:
|
||||
digest = _case_digest_hash(action.case_id)
|
||||
if not digest or not _is_quorum_met(digest):
|
||||
if local_only:
|
||||
# local-only is armed but this case was imported via federation
|
||||
# → defer (don't fire) until federation grants quorum
|
||||
skipped += 1
|
||||
_audit(
|
||||
"skipped",
|
||||
action_id=action.id,
|
||||
case_id=action.case_id,
|
||||
detail="local-only armed + federation-sourced case",
|
||||
)
|
||||
continue
|
||||
skipped += 1
|
||||
_audit(
|
||||
"skipped",
|
||||
action_id=action.id,
|
||||
case_id=action.case_id,
|
||||
detail="no quorum on federation-sourced case",
|
||||
)
|
||||
continue
|
||||
# else: quorum disabled, or case is locally-generated → fire.
|
||||
|
||||
try:
|
||||
result = respond.execute_action(action.id, approver="pulse-auto")
|
||||
except Exception as exc: # noqa: BLE001 — one bad action shouldn't kill the batch
|
||||
skipped += 1
|
||||
_audit(
|
||||
"error",
|
||||
action_id=action.id,
|
||||
case_id=action.case_id,
|
||||
detail=f"execute raised: {type(exc).__name__}: {exc}",
|
||||
)
|
||||
_log.warning("pulse.respond.auto-fire.error",
|
||||
action_id=action.id, error=str(exc))
|
||||
continue
|
||||
|
||||
if isinstance(result, _Ok):
|
||||
fired += 1
|
||||
_log.info("pulse.respond.auto-fire",
|
||||
action_id=action.id, case_id=action.case_id,
|
||||
type=action.action_type.value, target=action.target)
|
||||
_audit(
|
||||
"auto-fire",
|
||||
action_id=action.id,
|
||||
case_id=action.case_id,
|
||||
detail=f"{action.action_type.value} → {action.target}",
|
||||
)
|
||||
else:
|
||||
# Err path — execute_action returned Err (e.g. SOAR sink down)
|
||||
reason = getattr(result, "reason", "unknown")
|
||||
skipped += 1
|
||||
_audit(
|
||||
"error",
|
||||
action_id=action.id,
|
||||
case_id=action.case_id,
|
||||
detail=f"execute failed: {reason}",
|
||||
)
|
||||
_log.warning("pulse.respond.auto-fire.failed",
|
||||
action_id=action.id, reason=str(reason))
|
||||
|
||||
return fired, skipped
|
||||
|
||||
|
||||
def _run_respond() -> str:
|
||||
"""Propose + (when mode is auto-execute) auto-fire eligible PROPOSED actions.
|
||||
|
||||
Two phases:
|
||||
1. Always propose new actions for high-severity cases (existing behavior).
|
||||
2. If pipeline mode is auto-execute, iterate PROPOSED actions and execute
|
||||
those that clear severity/quorum/local-only gates.
|
||||
"""
|
||||
propose_count = _propose_for_recent_cases()
|
||||
mode = _current_mode("respond")
|
||||
if mode != PulseMode.AUTO_EXECUTE:
|
||||
return f"proposed {propose_count} actions; mode={mode.value} → no auto-fire"
|
||||
fired, skipped = _auto_fire_eligible()
|
||||
return f"proposed {propose_count}; auto-fired {fired}; skipped {skipped} (gate)"
|
||||
|
||||
|
||||
_DISCOVERY_SEEDS_KEY = "discovery_seeds"
|
||||
|
||||
|
||||
def get_discovery_seeds() -> List[str]:
|
||||
"""Operator-curated seed list for the discovery walker. Newline-separated in DB."""
|
||||
raw = db.pulse_setting_get(_DISCOVERY_SEEDS_KEY)
|
||||
if not raw:
|
||||
return []
|
||||
return [line.strip() for line in raw.splitlines() if line.strip()]
|
||||
|
||||
|
||||
def set_discovery_seeds(seeds: List[str]) -> None:
|
||||
"""Replace the seed list. Strips blanks + dedupes preserving order."""
|
||||
seen: set = set()
|
||||
cleaned: List[str] = []
|
||||
for s in seeds:
|
||||
v = (s or "").strip()
|
||||
if not v or v in seen:
|
||||
continue
|
||||
seen.add(v)
|
||||
cleaned.append(v)
|
||||
db.pulse_setting_set(_DISCOVERY_SEEDS_KEY, "\n".join(cleaned))
|
||||
|
||||
|
||||
def _run_peer_pull() -> str:
|
||||
return "federation not yet active"
|
||||
"""Walk DNS-SD + recurse over peer-public lists from the operator's seeds.
|
||||
|
||||
Records every fresh candidate into the `peers` table with status=unknown.
|
||||
Vouching (sibling stage) is what eventually promotes them.
|
||||
"""
|
||||
from psyc.lines import discovery
|
||||
|
||||
seeds = get_discovery_seeds()
|
||||
if not seeds:
|
||||
return "no seeds configured"
|
||||
candidates = discovery.walk(seeds)
|
||||
for c in candidates:
|
||||
try:
|
||||
discovery.record_candidate(c)
|
||||
except Exception as exc: # noqa: BLE001 — one bad write must not abort the batch
|
||||
_log.warning("pulse.peer_pull.record.error", domain=c.domain, error=str(exc))
|
||||
return f"discovered {len(candidates)} candidate(s) from {len(seeds)} seed(s)"
|
||||
|
||||
|
||||
def _run_vouch_refresh() -> str:
|
||||
@@ -149,7 +451,7 @@ _REGISTRY: Dict[str, Callable[[], str]] = {
|
||||
"classify": _run_classify,
|
||||
"prove": _run_prove,
|
||||
"reindex": _run_reindex,
|
||||
"respond": _run_respond_propose,
|
||||
"respond": _run_respond,
|
||||
"peer-pull": _run_peer_pull,
|
||||
"vouch-refresh": _run_vouch_refresh,
|
||||
}
|
||||
|
||||
228
src/psyc/lines/topology_export.py
Normal file
228
src/psyc/lines/topology_export.py
Normal file
@@ -0,0 +1,228 @@
|
||||
"""Topology export — sanitized public docker snapshot.
|
||||
|
||||
The cockpit's `docker_view.topology()` returns a rich daemon view useful to
|
||||
the local operator: container env vars, volume mounts, internal IPs, labels,
|
||||
gateways. None of that may leave the node. This module wraps `docker_view`
|
||||
with a strict whitelist: only container names, images, states, network names
|
||||
and high-level driver/health metadata are exposed. Anything not listed in
|
||||
the Pydantic schemas below is dropped before serialization.
|
||||
|
||||
Used by `/federation/topology` so peer admin pages can render every node's
|
||||
container topology side-by-side with their own.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from psyc import log
|
||||
from psyc.cockpit import docker_view
|
||||
from psyc.lines import federation
|
||||
|
||||
|
||||
_log = log.get(__name__)
|
||||
|
||||
|
||||
# Caps keep the response bounded — a runaway node with thousands of
|
||||
# containers shouldn't blow up the peer's panel.
|
||||
MAX_CONTAINERS = 200
|
||||
MAX_NETWORKS = 50
|
||||
|
||||
|
||||
# ---------- data model --------------------------------------------------
|
||||
|
||||
class TopologyContainer(BaseModel):
|
||||
"""One container — sanitized.
|
||||
|
||||
Strict whitelist: name, short_id, image (tag-only), state, health,
|
||||
network names, compose service label, started_at. No env vars, no
|
||||
volumes, no IPs, no MACs, no port mappings, no full labels dict.
|
||||
"""
|
||||
name: str
|
||||
short_id: str
|
||||
image: str
|
||||
state: str
|
||||
health: str
|
||||
networks: List[str] = Field(default_factory=list)
|
||||
service: Optional[str] = None
|
||||
started_at: Optional[str] = None
|
||||
|
||||
|
||||
class TopologyNetwork(BaseModel):
|
||||
"""One docker network — sanitized.
|
||||
|
||||
Whitelist: name, driver, internal flag, container_count. No subnet,
|
||||
no gateway, no labels, no attached-container details (those are
|
||||
surfaced via the container.networks list).
|
||||
"""
|
||||
name: str
|
||||
driver: str
|
||||
internal: bool
|
||||
container_count: int
|
||||
|
||||
|
||||
class TopologyExport(BaseModel):
|
||||
"""Whole-node container snapshot, public-safe."""
|
||||
node_fingerprint: str
|
||||
generated_at: str
|
||||
host_name: str
|
||||
container_count: int
|
||||
network_count: int
|
||||
containers: List[TopologyContainer] = Field(default_factory=list)
|
||||
networks: List[TopologyNetwork] = Field(default_factory=list)
|
||||
|
||||
|
||||
# ---------- sanitizers --------------------------------------------------
|
||||
|
||||
_BASIC_AUTH_RE = re.compile(r"^[^/@]+@")
|
||||
|
||||
|
||||
def _filter_image_name(s: str) -> str:
|
||||
"""Strip credentials from an image reference and drop digests.
|
||||
|
||||
Docker accepts `user:pass@registry/image:tag` for registries with HTTP
|
||||
basic auth — we strip everything up to and including the `@` so leaked
|
||||
creds never reach a peer. We also cut content-addressable digests
|
||||
(`...@sha256:...`) to a clean tag-only form.
|
||||
|
||||
Returns the cleaned `repo/image:tag` string. Empty input → "".
|
||||
"""
|
||||
if not s:
|
||||
return ""
|
||||
raw = str(s).strip()
|
||||
if not raw:
|
||||
return ""
|
||||
# Drop digest suffix, e.g. "nginx:1.25@sha256:abcd…" → "nginx:1.25".
|
||||
if "@sha256:" in raw:
|
||||
raw = raw.split("@sha256:", 1)[0]
|
||||
# Strip basic-auth prefix on the registry component.
|
||||
# "user:pass@host/repo:tag" → "host/repo:tag" (we never want creds out).
|
||||
if _BASIC_AUTH_RE.match(raw):
|
||||
raw = raw.split("@", 1)[1]
|
||||
# Cap length defensively.
|
||||
return raw[:160]
|
||||
|
||||
|
||||
def _short_id(raw: Any) -> str:
|
||||
s = str(raw or "")
|
||||
return s[:12]
|
||||
|
||||
|
||||
def _parse_health(status: str) -> str:
|
||||
"""Extract a healthcheck word from the docker "Status" line if present.
|
||||
|
||||
docker's container-list "Status" string includes "(healthy)" or
|
||||
"(unhealthy)" when a healthcheck is configured. We surface just that
|
||||
one-word state and fall back to "—" otherwise — no other free-form
|
||||
text from the daemon leaks out.
|
||||
"""
|
||||
if not status:
|
||||
return "—"
|
||||
low = status.lower()
|
||||
if "(healthy)" in low:
|
||||
return "healthy"
|
||||
if "(unhealthy)" in low:
|
||||
return "unhealthy"
|
||||
if "(starting)" in low or "(health: starting)" in low:
|
||||
return "starting"
|
||||
return "—"
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _empty_export(node_fp: str) -> TopologyExport:
|
||||
return TopologyExport(
|
||||
node_fingerprint=node_fp,
|
||||
generated_at=_now_iso(),
|
||||
host_name="",
|
||||
container_count=0,
|
||||
network_count=0,
|
||||
containers=[],
|
||||
networks=[],
|
||||
)
|
||||
|
||||
|
||||
# ---------- builder ------------------------------------------------------
|
||||
|
||||
def build_export() -> TopologyExport:
|
||||
"""Sanitized snapshot of this node's docker topology.
|
||||
|
||||
Calls `docker_view.topology()` and re-projects every field through the
|
||||
Pydantic whitelist above. If the proxy is unreachable (e.g. dev box
|
||||
without docker-socket-proxy) we return an empty export rather than
|
||||
raising — the public endpoint must never 500.
|
||||
"""
|
||||
try:
|
||||
node_fp = federation.node_fingerprint()
|
||||
except Exception as exc: # noqa: BLE001 — keep endpoint defensive
|
||||
_log.warning("topology_export.fp.error", error=str(exc))
|
||||
node_fp = ""
|
||||
|
||||
try:
|
||||
raw = docker_view.topology()
|
||||
except Exception as exc: # noqa: BLE001 — docker proxy may be down
|
||||
_log.warning("topology_export.docker.error", error=str(exc))
|
||||
return _empty_export(node_fp)
|
||||
|
||||
# docker_view.topology() returns a dict with `containers`, `networks`,
|
||||
# `host`, `error` fields. We treat any non-None error as "empty export"
|
||||
# rather than partially leaking through whatever did succeed.
|
||||
if raw.get("error"):
|
||||
return _empty_export(node_fp)
|
||||
|
||||
raw_host = raw.get("host") or {}
|
||||
host_name_raw = str(raw_host.get("name") or "")
|
||||
# Truncate the docker host id — it can be the actual machine hostname.
|
||||
# Keep it short, no domain. Defensive even though docker host names are
|
||||
# generally low-sensitivity.
|
||||
host_name = host_name_raw[:24]
|
||||
|
||||
raw_containers = raw.get("containers") or []
|
||||
raw_networks = raw.get("networks") or []
|
||||
|
||||
containers: List[TopologyContainer] = []
|
||||
for c in raw_containers[:MAX_CONTAINERS]:
|
||||
nets_raw = c.get("networks") or []
|
||||
net_names: List[str] = []
|
||||
for nd in nets_raw:
|
||||
nm = nd.get("name") if isinstance(nd, dict) else None
|
||||
if nm:
|
||||
net_names.append(str(nm)[:64])
|
||||
containers.append(TopologyContainer(
|
||||
name=str(c.get("name") or "?")[:64],
|
||||
short_id=_short_id(c.get("id")),
|
||||
image=_filter_image_name(c.get("image") or ""),
|
||||
state=str(c.get("state") or "")[:24],
|
||||
health=_parse_health(str(c.get("status") or "")),
|
||||
networks=net_names[:12],
|
||||
# docker_view doesn't currently surface the compose service label
|
||||
# or started_at; leave them None until that lands.
|
||||
service=None,
|
||||
started_at=None,
|
||||
))
|
||||
|
||||
networks: List[TopologyNetwork] = []
|
||||
for n in raw_networks[:MAX_NETWORKS]:
|
||||
attached = n.get("containers") or []
|
||||
networks.append(TopologyNetwork(
|
||||
name=str(n.get("name") or "")[:64],
|
||||
driver=str(n.get("driver") or "")[:24],
|
||||
internal=bool(n.get("internal")),
|
||||
container_count=len(attached),
|
||||
))
|
||||
|
||||
return TopologyExport(
|
||||
node_fingerprint=node_fp,
|
||||
generated_at=_now_iso(),
|
||||
host_name=host_name,
|
||||
container_count=len(containers),
|
||||
network_count=len(networks),
|
||||
containers=containers,
|
||||
networks=networks,
|
||||
)
|
||||
161
src/psyc/lines/translog.py
Normal file
161
src/psyc/lines/translog.py
Normal file
@@ -0,0 +1,161 @@
|
||||
"""Transparency log — append-only signed merkle chain over federation signals.
|
||||
|
||||
Every signal we receive from a peer (case, IOC, or accepted vouch) is appended
|
||||
as one `LogEntry`. Each entry's `entry_hash = sha256(canonical(prev_hash +
|
||||
entry_type + entry_data + timestamp))` references the previous head, so any
|
||||
tampering with a historical row invalidates every subsequent hash. The chain
|
||||
is public — auditors can re-fetch it and re-run `verify_chain` to detect a
|
||||
node that quietly mutated history (e.g. to hide a bad signal it accepted).
|
||||
|
||||
Hash format: lowercase hex SHA-256 of the canonical JSON of
|
||||
``{"prev_hash": "...", "entry_type": "...", "entry_data": {...}, "timestamp": "..."}``.
|
||||
Genesis entries use ``prev_hash = "0" * 64``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from psyc import db, log
|
||||
from psyc.result import Err, Ok, Result
|
||||
|
||||
|
||||
_log = log.get(__name__)
|
||||
|
||||
GENESIS_PREV_HASH = "0" * 64
|
||||
|
||||
|
||||
class LogEntry(BaseModel):
|
||||
id: int
|
||||
prev_hash: str
|
||||
entry_type: str
|
||||
entry_data: Dict[str, Any] = Field(default_factory=dict)
|
||||
timestamp: str
|
||||
entry_hash: str
|
||||
|
||||
|
||||
def _canonical_json(obj: Dict[str, Any]) -> bytes:
|
||||
return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||
|
||||
|
||||
def compute_entry_hash(prev_hash: str, entry_type: str, entry_data: Dict[str, Any], timestamp: str) -> str:
|
||||
"""Hex SHA-256 of canonical(prev_hash + entry_type + entry_data + timestamp)."""
|
||||
payload: Dict[str, Any] = {
|
||||
"prev_hash": prev_hash,
|
||||
"entry_type": entry_type,
|
||||
"entry_data": entry_data,
|
||||
"timestamp": timestamp,
|
||||
}
|
||||
return hashlib.sha256(_canonical_json(payload)).hexdigest()
|
||||
|
||||
|
||||
def _row_to_entry(row: Dict[str, Any]) -> LogEntry:
|
||||
raw = row.get("entry_data") or "{}"
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except Exception:
|
||||
data = {}
|
||||
return LogEntry(
|
||||
id=int(row["id"]),
|
||||
prev_hash=str(row["prev_hash"]),
|
||||
entry_type=str(row["entry_type"]),
|
||||
entry_data=data if isinstance(data, dict) else {},
|
||||
timestamp=str(row["timestamp"]),
|
||||
entry_hash=str(row["entry_hash"]),
|
||||
)
|
||||
|
||||
|
||||
def head(db_path: Path = db.DB_PATH) -> Optional[LogEntry]:
|
||||
"""Latest log entry, or None if the chain is empty."""
|
||||
row = db.translog_head(db_path=db_path)
|
||||
return _row_to_entry(row) if row else None
|
||||
|
||||
|
||||
def append(entry_type: str, entry_data: Dict[str, Any], db_path: Path = db.DB_PATH) -> LogEntry:
|
||||
"""Atomically append one entry to the chain. Returns the persisted entry."""
|
||||
prev = db.translog_head(db_path=db_path)
|
||||
prev_hash = str(prev["entry_hash"]) if prev else GENESIS_PREV_HASH
|
||||
timestamp = datetime.now(timezone.utc).isoformat()
|
||||
entry_hash = compute_entry_hash(prev_hash, entry_type, entry_data, timestamp)
|
||||
new_id = db.translog_append(
|
||||
dict(
|
||||
prev_hash=prev_hash,
|
||||
entry_type=entry_type,
|
||||
entry_data=json.dumps(entry_data, sort_keys=True),
|
||||
timestamp=timestamp,
|
||||
entry_hash=entry_hash,
|
||||
),
|
||||
db_path=db_path,
|
||||
)
|
||||
_log.info("translog.append", id=new_id, entry_type=entry_type, hash=entry_hash[:12])
|
||||
return LogEntry(
|
||||
id=new_id,
|
||||
prev_hash=prev_hash,
|
||||
entry_type=entry_type,
|
||||
entry_data=entry_data,
|
||||
timestamp=timestamp,
|
||||
entry_hash=entry_hash,
|
||||
)
|
||||
|
||||
|
||||
def verify_chain(start: int = 0, end: Optional[int] = None, db_path: Path = db.DB_PATH) -> Result[int, str]:
|
||||
"""Walk entries [start, end] in id order, recompute each hash, compare.
|
||||
|
||||
Returns Ok(n_verified) when every entry's recomputed hash equals the
|
||||
stored one and each prev_hash matches the previous entry's stored hash.
|
||||
Returns Err with the offending id + expected/got hashes otherwise.
|
||||
"""
|
||||
rows = db.translog_range(start=start, end=end, db_path=db_path)
|
||||
if not rows:
|
||||
return Ok(0)
|
||||
# Establish the prior hash anchor — either genesis (if walking from id=1)
|
||||
# or the entry just before `start`.
|
||||
first_id = int(rows[0]["id"])
|
||||
if first_id <= 1:
|
||||
prior_hash = GENESIS_PREV_HASH
|
||||
else:
|
||||
anchor = db.translog_get(first_id - 1, db_path=db_path)
|
||||
if anchor is None:
|
||||
return Err(f"missing anchor entry id={first_id - 1}")
|
||||
prior_hash = str(anchor["entry_hash"])
|
||||
|
||||
verified = 0
|
||||
for row in rows:
|
||||
stored_prev = str(row["prev_hash"])
|
||||
if stored_prev != prior_hash:
|
||||
return Err(
|
||||
f"broken at id={row['id']} expected_prev={prior_hash} got_prev={stored_prev}"
|
||||
)
|
||||
try:
|
||||
data = json.loads(row.get("entry_data") or "{}")
|
||||
except Exception:
|
||||
return Err(f"broken at id={row['id']} entry_data not JSON")
|
||||
if not isinstance(data, dict):
|
||||
return Err(f"broken at id={row['id']} entry_data not an object")
|
||||
recomputed = compute_entry_hash(
|
||||
stored_prev, str(row["entry_type"]), data, str(row["timestamp"])
|
||||
)
|
||||
stored_hash = str(row["entry_hash"])
|
||||
if recomputed != stored_hash:
|
||||
return Err(
|
||||
f"broken at id={row['id']} expected={recomputed} got={stored_hash}"
|
||||
)
|
||||
prior_hash = stored_hash
|
||||
verified += 1
|
||||
return Ok(verified)
|
||||
|
||||
|
||||
def recent(limit: int = 100, db_path: Path = db.DB_PATH) -> List[LogEntry]:
|
||||
"""The latest `limit` entries, newest first."""
|
||||
return [_row_to_entry(r) for r in db.translog_recent(limit=limit, db_path=db_path)]
|
||||
|
||||
|
||||
def entries_after(entry_id: int, db_path: Path = db.DB_PATH) -> List[LogEntry]:
|
||||
"""All entries with id > entry_id, oldest first — for peer sync."""
|
||||
return [_row_to_entry(r) for r in db.translog_after(entry_id, db_path=db_path)]
|
||||
376
tests/test_discovery.py
Normal file
376
tests/test_discovery.py
Normal file
@@ -0,0 +1,376 @@
|
||||
"""Discovery — DNS-SD parse + resolver, BFS walker, persistence, public endpoint."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import dns.exception
|
||||
import dns.resolver
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from psyc import db
|
||||
from psyc.cockpit import federation_routes
|
||||
from psyc.lines import discovery, federation, pulse
|
||||
from psyc.lines.discovery import (
|
||||
PeerCandidate,
|
||||
_parse_txt_value,
|
||||
fetch_peer_info,
|
||||
fetch_public_peers,
|
||||
public_peer_attestation,
|
||||
record_candidate,
|
||||
resolve_psyc,
|
||||
walk,
|
||||
)
|
||||
from psyc.result import Err, Ok
|
||||
|
||||
|
||||
# ---------- fixtures ---------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def fresh_db(tmp_path, monkeypatch):
|
||||
test_db = tmp_path / "test.db"
|
||||
eng = create_engine(f"sqlite:///{test_db}", future=True)
|
||||
db._metadata.create_all(eng, checkfirst=True)
|
||||
monkeypatch.setattr(db, "_engine", eng)
|
||||
monkeypatch.setattr(db, "DB_PATH", test_db)
|
||||
yield test_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fed_dir(tmp_path, monkeypatch):
|
||||
d = tmp_path / "federation"
|
||||
monkeypatch.setattr(federation, "FED_DIR", d)
|
||||
monkeypatch.setattr(federation, "PRIVATE_KEY_PATH", d / "node.key")
|
||||
monkeypatch.setattr(federation, "PUBLIC_KEY_PATH", d / "node.pub")
|
||||
yield d
|
||||
|
||||
|
||||
def _mk_srv(port: int = 443) -> Any:
|
||||
rd = MagicMock()
|
||||
rd.port = port
|
||||
return rd
|
||||
|
||||
|
||||
def _mk_txt(value: str) -> Any:
|
||||
rd = MagicMock()
|
||||
rd.strings = [value.encode("utf-8")]
|
||||
return rd
|
||||
|
||||
|
||||
# ---------- TXT parser -------------------------------------------------------
|
||||
|
||||
def test_parse_txt_valid():
|
||||
txt = "v=psyc1 fp=" + "a" * 32 + " alg=ed25519 path=/federation/feed"
|
||||
res = _parse_txt_value(txt)
|
||||
assert isinstance(res, Ok)
|
||||
assert res.value["fp"] == "a" * 32
|
||||
assert res.value["alg"] == "ed25519"
|
||||
|
||||
|
||||
def test_parse_txt_tolerates_token_order():
|
||||
txt = "path=/federation/feed alg=ed25519 fp=" + "f" * 32 + " v=psyc1"
|
||||
res = _parse_txt_value(txt)
|
||||
assert isinstance(res, Ok)
|
||||
|
||||
|
||||
def test_parse_txt_rejects_wrong_version():
|
||||
txt = "v=psyc2 fp=" + "a" * 32 + " alg=ed25519"
|
||||
res = _parse_txt_value(txt)
|
||||
assert isinstance(res, Err)
|
||||
assert "version" in res.reason
|
||||
|
||||
|
||||
def test_parse_txt_rejects_bad_fingerprint_length():
|
||||
txt = "v=psyc1 fp=deadbeef alg=ed25519"
|
||||
res = _parse_txt_value(txt)
|
||||
assert isinstance(res, Err)
|
||||
assert "fingerprint" in res.reason
|
||||
|
||||
|
||||
def test_parse_txt_rejects_non_hex_fingerprint():
|
||||
txt = "v=psyc1 fp=" + "z" * 32 + " alg=ed25519"
|
||||
res = _parse_txt_value(txt)
|
||||
assert isinstance(res, Err)
|
||||
|
||||
|
||||
def test_parse_txt_rejects_malformed_token():
|
||||
txt = "v=psyc1 fp=" + "a" * 32 + " alg ed25519"
|
||||
res = _parse_txt_value(txt)
|
||||
assert isinstance(res, Err)
|
||||
assert "malformed" in res.reason
|
||||
|
||||
|
||||
def test_parse_txt_rejects_wrong_alg():
|
||||
txt = "v=psyc1 fp=" + "a" * 32 + " alg=rsa"
|
||||
res = _parse_txt_value(txt)
|
||||
assert isinstance(res, Err)
|
||||
|
||||
|
||||
# ---------- resolve_psyc -----------------------------------------------------
|
||||
|
||||
def test_resolve_psyc_happy_path():
|
||||
fp = "1" * 32
|
||||
txt = f"v=psyc1 fp={fp} alg=ed25519 path=/federation/feed"
|
||||
|
||||
def fake_resolve(self, name, rdtype):
|
||||
if rdtype == "SRV":
|
||||
return [_mk_srv(port=8443)]
|
||||
if rdtype == "TXT":
|
||||
return [_mk_txt(txt)]
|
||||
raise dns.exception.DNSException("unexpected")
|
||||
|
||||
with patch.object(dns.resolver.Resolver, "resolve", fake_resolve):
|
||||
res = resolve_psyc("peer.example.com")
|
||||
assert isinstance(res, Ok)
|
||||
cand = res.value
|
||||
assert cand.domain == "peer.example.com"
|
||||
assert cand.fingerprint == fp
|
||||
assert cand.port == 8443
|
||||
assert cand.source == "dns-sd"
|
||||
|
||||
|
||||
def test_resolve_psyc_nxdomain_returns_err():
|
||||
def fake_resolve(self, name, rdtype):
|
||||
raise dns.resolver.NXDOMAIN()
|
||||
|
||||
with patch.object(dns.resolver.Resolver, "resolve", fake_resolve):
|
||||
res = resolve_psyc("nothere.example")
|
||||
assert isinstance(res, Err)
|
||||
assert "NXDOMAIN" in res.reason
|
||||
|
||||
|
||||
def test_resolve_psyc_txt_malformed_returns_err():
|
||||
def fake_resolve(self, name, rdtype):
|
||||
if rdtype == "SRV":
|
||||
return [_mk_srv()]
|
||||
return [_mk_txt("v=psyc1 fp=garbage alg=ed25519")]
|
||||
|
||||
with patch.object(dns.resolver.Resolver, "resolve", fake_resolve):
|
||||
res = resolve_psyc("peer.example")
|
||||
assert isinstance(res, Err)
|
||||
assert "TXT" in res.reason or "fingerprint" in res.reason
|
||||
|
||||
|
||||
def test_resolve_psyc_no_answer_returns_err():
|
||||
def fake_resolve(self, name, rdtype):
|
||||
raise dns.resolver.NoAnswer()
|
||||
|
||||
with patch.object(dns.resolver.Resolver, "resolve", fake_resolve):
|
||||
res = resolve_psyc("peer.example")
|
||||
assert isinstance(res, Err)
|
||||
assert "NoAnswer" in res.reason
|
||||
|
||||
|
||||
# ---------- walk -------------------------------------------------------------
|
||||
|
||||
def _stub_resolve(catalog: Dict[str, str]):
|
||||
"""Build a resolve_psyc stub that returns each domain's catalog fingerprint."""
|
||||
def _stub(domain: str, timeout: float = 5.0):
|
||||
if domain not in catalog:
|
||||
return Err(f"no record for {domain}")
|
||||
return Ok(PeerCandidate(
|
||||
domain=domain,
|
||||
fingerprint=catalog[domain],
|
||||
port=443,
|
||||
source="dns-sd",
|
||||
))
|
||||
return _stub
|
||||
|
||||
|
||||
def _stub_fetch_info_ok(*args, **kwargs):
|
||||
return Ok({"fingerprint": kwargs.get("expected_fingerprint", "")})
|
||||
|
||||
|
||||
def _stub_fetch_peers_factory(graph: Dict[str, List[Dict[str, str]]]):
|
||||
def _stub(domain: str, port: int = 443, timeout: float = 5.0):
|
||||
return Ok(graph.get(domain, []))
|
||||
return _stub
|
||||
|
||||
|
||||
def test_walk_dedupes_by_fingerprint(fresh_db, fed_dir, monkeypatch):
|
||||
# Two seeds, same fingerprint via different domains → only one survives the (domain,fp) dedupe
|
||||
# but distinct domains both surface; the (domain, fp) pair just shouldn't repeat.
|
||||
fp = "9" * 32
|
||||
catalog = {"a.example": fp, "b.example": fp}
|
||||
monkeypatch.setattr(discovery, "resolve_psyc", _stub_resolve(catalog))
|
||||
monkeypatch.setattr(discovery, "fetch_peer_info", _stub_fetch_info_ok)
|
||||
monkeypatch.setattr(discovery, "fetch_public_peers", _stub_fetch_peers_factory({}))
|
||||
out = walk(["a.example", "b.example", "a.example"], max_depth=1)
|
||||
# both unique domains made it in; the duplicate seed didn't re-enter
|
||||
assert len(out) == 2
|
||||
domains = {c.domain for c in out}
|
||||
assert domains == {"a.example", "b.example"}
|
||||
|
||||
|
||||
def test_walk_respects_max_depth(fresh_db, fed_dir, monkeypatch):
|
||||
catalog = {"d0.example": "0" * 32, "d1.example": "1" * 32, "d2.example": "2" * 32}
|
||||
graph = {
|
||||
"d0.example": [{"domain": "d1.example", "fingerprint": "1" * 32}],
|
||||
"d1.example": [{"domain": "d2.example", "fingerprint": "2" * 32}],
|
||||
}
|
||||
monkeypatch.setattr(discovery, "resolve_psyc", _stub_resolve(catalog))
|
||||
monkeypatch.setattr(discovery, "fetch_peer_info", _stub_fetch_info_ok)
|
||||
monkeypatch.setattr(discovery, "fetch_public_peers", _stub_fetch_peers_factory(graph))
|
||||
out = walk(["d0.example"], max_depth=1)
|
||||
domains = {c.domain for c in out}
|
||||
# depth 0: d0; depth 1: d1; depth 2 (d2) is excluded by max_depth=1
|
||||
assert "d0.example" in domains and "d1.example" in domains
|
||||
assert "d2.example" not in domains
|
||||
|
||||
|
||||
def test_walk_respects_max_peers(fresh_db, fed_dir, monkeypatch):
|
||||
catalog = {f"d{i}.example": f"{i:032x}" for i in range(10)}
|
||||
graph = {"d0.example": [{"domain": f"d{i}.example", "fingerprint": f"{i:032x}"} for i in range(1, 10)]}
|
||||
monkeypatch.setattr(discovery, "resolve_psyc", _stub_resolve(catalog))
|
||||
monkeypatch.setattr(discovery, "fetch_peer_info", _stub_fetch_info_ok)
|
||||
monkeypatch.setattr(discovery, "fetch_public_peers", _stub_fetch_peers_factory(graph))
|
||||
out = walk(["d0.example"], max_depth=2, max_peers=3)
|
||||
assert len(out) <= 3
|
||||
|
||||
|
||||
def test_walk_skips_own_fingerprint(fresh_db, fed_dir, monkeypatch):
|
||||
own = federation.node_fingerprint()
|
||||
catalog = {"self.example": own, "peer.example": "f" * 32}
|
||||
monkeypatch.setattr(discovery, "resolve_psyc", _stub_resolve(catalog))
|
||||
monkeypatch.setattr(discovery, "fetch_peer_info", _stub_fetch_info_ok)
|
||||
monkeypatch.setattr(discovery, "fetch_public_peers", _stub_fetch_peers_factory({}))
|
||||
out = walk(["self.example", "peer.example"], max_depth=1)
|
||||
domains = {c.domain for c in out}
|
||||
assert "self.example" not in domains
|
||||
assert "peer.example" in domains
|
||||
|
||||
|
||||
def test_walk_one_failure_does_not_abort(fresh_db, fed_dir, monkeypatch):
|
||||
catalog = {"good.example": "a" * 32} # bad.example is absent → Err on resolve
|
||||
monkeypatch.setattr(discovery, "resolve_psyc", _stub_resolve(catalog))
|
||||
monkeypatch.setattr(discovery, "fetch_peer_info", _stub_fetch_info_ok)
|
||||
monkeypatch.setattr(discovery, "fetch_public_peers", _stub_fetch_peers_factory({}))
|
||||
out = walk(["bad.example", "good.example"], max_depth=1)
|
||||
assert len(out) == 1
|
||||
assert out[0].domain == "good.example"
|
||||
|
||||
|
||||
# ---------- record_candidate -------------------------------------------------
|
||||
|
||||
def test_record_candidate_inserts_as_unknown(fresh_db):
|
||||
c = PeerCandidate(domain="new.example", fingerprint="a" * 32, source="dns-sd")
|
||||
record_candidate(c)
|
||||
row = db.get_peer("new.example")
|
||||
assert row is not None
|
||||
assert row["status"] == "unknown"
|
||||
assert row["fingerprint"] == "a" * 32
|
||||
|
||||
|
||||
def test_record_candidate_preserves_trusted(fresh_db, fed_dir):
|
||||
federation.register_peer("vip.example", "b" * 32, "PEM", status="trusted")
|
||||
# walker re-discovers it
|
||||
c = PeerCandidate(domain="vip.example", fingerprint="b" * 32, source="peer-walk:other.example")
|
||||
record_candidate(c)
|
||||
row = db.get_peer("vip.example")
|
||||
assert row["status"] == "trusted"
|
||||
|
||||
|
||||
def test_record_candidate_preserves_blocked(fresh_db, fed_dir):
|
||||
federation.register_peer("bad.example", "c" * 32, "PEM", status="blocked")
|
||||
c = PeerCandidate(domain="bad.example", fingerprint="c" * 32, source="dns-sd")
|
||||
record_candidate(c)
|
||||
row = db.get_peer("bad.example")
|
||||
assert row["status"] == "blocked"
|
||||
|
||||
|
||||
def test_record_candidate_updates_last_seen(fresh_db):
|
||||
c = PeerCandidate(domain="repeat.example", fingerprint="d" * 32, source="dns-sd")
|
||||
record_candidate(c)
|
||||
first = db.get_peer("repeat.example")
|
||||
# second pass — last_seen advances, discovered_at stays
|
||||
c2 = PeerCandidate(domain="repeat.example", fingerprint="d" * 32, source="dns-sd")
|
||||
record_candidate(c2)
|
||||
second = db.get_peer("repeat.example")
|
||||
assert second["discovered_at"] == first["discovered_at"]
|
||||
|
||||
|
||||
# ---------- public attestation -----------------------------------------------
|
||||
|
||||
def test_public_peer_attestation_only_trusted(fresh_db, fed_dir):
|
||||
federation.register_peer("trusted.example", "1" * 32, "PEM", status="trusted")
|
||||
federation.register_peer("unknown.example", "2" * 32, "PEM", status="unknown")
|
||||
federation.register_peer("blocked.example", "3" * 32, "PEM", status="blocked")
|
||||
out = public_peer_attestation()
|
||||
domains = {p["domain"] for p in out}
|
||||
assert domains == {"trusted.example"}
|
||||
|
||||
|
||||
def test_public_peer_attestation_payload_shape(fresh_db, fed_dir):
|
||||
federation.register_peer("t.example", "f" * 32, "PEM", status="trusted")
|
||||
out = public_peer_attestation()
|
||||
assert len(out) == 1
|
||||
entry = out[0]
|
||||
assert set(entry.keys()) == {"domain", "fingerprint", "first_seen"}
|
||||
|
||||
|
||||
# ---------- public endpoint via TestClient -----------------------------------
|
||||
|
||||
def _mk_app() -> FastAPI:
|
||||
app = FastAPI()
|
||||
app.add_middleware(SessionMiddleware, secret_key="test-secret")
|
||||
# Templates aren't exercised by the public endpoints we care about here.
|
||||
from fastapi.templating import Jinja2Templates
|
||||
import tempfile, os
|
||||
tdir = tempfile.mkdtemp()
|
||||
templates = Jinja2Templates(directory=tdir)
|
||||
federation_routes.register(app, templates)
|
||||
return app
|
||||
|
||||
|
||||
def test_public_peers_endpoint_excludes_unknown_blocked(fresh_db, fed_dir):
|
||||
federation.register_peer("ok.example", "a" * 32, "PEM", status="trusted")
|
||||
federation.register_peer("rude.example", "b" * 32, "PEM", status="blocked")
|
||||
federation.register_peer("new.example", "c" * 32, "PEM", status="unknown")
|
||||
# Flush in-memory cache from any earlier test.
|
||||
federation_routes._PUBLIC_PEERS_CACHE["payload"] = None
|
||||
federation_routes._PUBLIC_PEERS_CACHE["ts"] = 0.0
|
||||
app = _mk_app()
|
||||
client = TestClient(app)
|
||||
r = client.get("/federation/peers/public")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
domains = {p["domain"] for p in body}
|
||||
assert "ok.example" in domains
|
||||
assert "rude.example" not in domains
|
||||
assert "new.example" not in domains
|
||||
|
||||
|
||||
# ---------- pulse integration ------------------------------------------------
|
||||
|
||||
def test_discovery_seeds_roundtrip(fresh_db):
|
||||
assert pulse.get_discovery_seeds() == []
|
||||
pulse.set_discovery_seeds(["a.example", "b.example", "a.example", "", " "])
|
||||
# dedupe + strip blanks
|
||||
assert pulse.get_discovery_seeds() == ["a.example", "b.example"]
|
||||
|
||||
|
||||
def test_peer_pull_pipeline_no_seeds(fresh_db, fed_dir, monkeypatch):
|
||||
# peer-pull runner returns a clean message when nothing's configured.
|
||||
outcome, result = pulse.run_now("peer-pull")
|
||||
assert outcome == "ok"
|
||||
assert "no seeds" in result
|
||||
|
||||
|
||||
def test_peer_pull_pipeline_with_seeds(fresh_db, fed_dir, monkeypatch):
|
||||
pulse.set_discovery_seeds(["good.example"])
|
||||
catalog = {"good.example": "e" * 32}
|
||||
monkeypatch.setattr(discovery, "resolve_psyc", _stub_resolve(catalog))
|
||||
monkeypatch.setattr(discovery, "fetch_peer_info", _stub_fetch_info_ok)
|
||||
monkeypatch.setattr(discovery, "fetch_public_peers", _stub_fetch_peers_factory({}))
|
||||
outcome, result = pulse.run_now("peer-pull")
|
||||
assert outcome == "ok"
|
||||
assert "discovered 1" in result
|
||||
# And it was recorded.
|
||||
row = db.get_peer("good.example")
|
||||
assert row is not None
|
||||
assert row["status"] == "unknown"
|
||||
427
tests/test_explore_view.py
Normal file
427
tests/test_explore_view.py
Normal file
@@ -0,0 +1,427 @@
|
||||
"""Federation explore view — public transparency payload tests.
|
||||
|
||||
Sibling to `test_network_view.py`; focused on the explore-only shape:
|
||||
shape contract, signature round-trip, no-leak invariants, transitive walk,
|
||||
inbound vouches filter, and the corroboration counter.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from psyc import db
|
||||
from psyc.cockpit import federation_routes
|
||||
from psyc.lines import federation, network_view, translog
|
||||
from psyc.lines.network_view import build_explore_view
|
||||
|
||||
|
||||
# ---------- fixtures ----------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def fresh_db(tmp_path, monkeypatch):
|
||||
test_db = tmp_path / "test.db"
|
||||
eng = create_engine(f"sqlite:///{test_db}", future=True)
|
||||
db._metadata.create_all(eng, checkfirst=True)
|
||||
monkeypatch.setattr(db, "_engine", eng)
|
||||
monkeypatch.setattr(db, "DB_PATH", test_db)
|
||||
yield test_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fed_dir(tmp_path, monkeypatch):
|
||||
d = tmp_path / "federation"
|
||||
monkeypatch.setattr(federation, "FED_DIR", d)
|
||||
monkeypatch.setattr(federation, "PRIVATE_KEY_PATH", d / "node.key")
|
||||
monkeypatch.setattr(federation, "PUBLIC_KEY_PATH", d / "node.pub")
|
||||
yield d
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_explore_caches(monkeypatch):
|
||||
"""Prevent route-level cache bleed between tests."""
|
||||
monkeypatch.setattr(network_view, "_TRANSITIVE_CACHE", {"ts": 0.0, "view": None})
|
||||
federation_routes._FEED_CACHE["payload"] = None
|
||||
federation_routes._PUBLIC_PEERS_CACHE["payload"] = None
|
||||
federation_routes._PUBLIC_NETWORK_CACHE["payload"] = None
|
||||
if hasattr(federation_routes, "_EXPLORE_CACHE"):
|
||||
federation_routes._EXPLORE_CACHE["payload"] = None
|
||||
yield
|
||||
|
||||
|
||||
def _make_peer_pubkey() -> tuple[str, str]:
|
||||
"""Return (fingerprint, pubkey_pem) for a synthetic peer keypair."""
|
||||
import hashlib
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ed25519
|
||||
priv = ed25519.Ed25519PrivateKey.generate()
|
||||
pub = priv.public_key()
|
||||
pem = pub.public_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
).decode("ascii")
|
||||
raw = pub.public_bytes(
|
||||
encoding=serialization.Encoding.Raw,
|
||||
format=serialization.PublicFormat.Raw,
|
||||
)
|
||||
fp = hashlib.sha256(raw).digest()[:16].hex()
|
||||
return fp, pem
|
||||
|
||||
|
||||
def _silence_explore_fetch():
|
||||
return patch.object(network_view, "_fetch_peer_explore", return_value=None)
|
||||
|
||||
|
||||
def _silence_network_fetch():
|
||||
return patch.object(network_view, "_fetch_peer_network", return_value=None)
|
||||
|
||||
|
||||
# ---------- schema ------------------------------------------------------
|
||||
|
||||
def test_explore_view_top_level_shape(fresh_db, fed_dir):
|
||||
with _silence_explore_fetch(), _silence_network_fetch():
|
||||
payload = build_explore_view(node_domain="me.example")
|
||||
for key in (
|
||||
"version", "fingerprint", "generated_at",
|
||||
"node", "peers", "vouches", "vouches_out", "vouches_in",
|
||||
"transitive_peers", "corroboration_count_24h", "signature",
|
||||
):
|
||||
assert key in payload, f"missing {key}"
|
||||
node = payload["node"]
|
||||
for key in (
|
||||
"fingerprint", "domain", "generated_at",
|
||||
"transparency_log_head_hash", "translog_entry_count",
|
||||
"peer_count", "vouches_out_count", "vouches_in_count",
|
||||
"corroboration_count_24h", "signals_count_24h",
|
||||
):
|
||||
assert key in node, f"missing node.{key}"
|
||||
assert node["domain"] == "me.example"
|
||||
assert node["fingerprint"] == federation.node_fingerprint()
|
||||
|
||||
|
||||
def test_explore_peer_carries_public_safe_stats(fresh_db, fed_dir):
|
||||
fp, pem = _make_peer_pubkey()
|
||||
federation.register_peer("peer.example", fp, pem, status="trusted")
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
for i in range(3):
|
||||
db.record_signal(dict(
|
||||
peer_fingerprint=fp,
|
||||
signal_type="ioc",
|
||||
signal_id=f"1.2.3.{i}",
|
||||
signal_hash=f"hash-{i}",
|
||||
received_at=now_iso,
|
||||
raw_json=json.dumps({"type": "ip", "value": f"1.2.3.{i}"}),
|
||||
))
|
||||
with _silence_explore_fetch(), _silence_network_fetch():
|
||||
payload = build_explore_view()
|
||||
assert len(payload["peers"]) == 1
|
||||
p = payload["peers"][0]
|
||||
# Public-safe stats present.
|
||||
for key in (
|
||||
"signal_count_24h", "signal_count_total",
|
||||
"cases_24h", "iocs_24h",
|
||||
"quorum_contribution_24h", "last_seen",
|
||||
):
|
||||
assert key in p
|
||||
assert p["signal_count_24h"] == 3
|
||||
assert p["iocs_24h"] == 3
|
||||
assert p["cases_24h"] == 0
|
||||
# Sensitive fields are not surfaced per-peer.
|
||||
assert "severity_breakdown" not in p
|
||||
assert "ioc_type_breakdown" not in p
|
||||
assert "recent_translog" not in p
|
||||
|
||||
|
||||
# ---------- signature round-trip ---------------------------------------
|
||||
|
||||
def test_explore_view_signature_round_trip(fresh_db, fed_dir):
|
||||
fp, pem = _make_peer_pubkey()
|
||||
federation.register_peer("trusted.example", fp, pem, status="trusted")
|
||||
federation.issue_vouch(fp, ttl_days=30)
|
||||
with _silence_explore_fetch(), _silence_network_fetch():
|
||||
payload = build_explore_view()
|
||||
assert "signature" in payload
|
||||
sig = base64.b64decode(payload["signature"])
|
||||
unsigned = {k: v for k, v in payload.items() if k != "signature"}
|
||||
assert federation.verify_payload(
|
||||
federation.canonical_json(unsigned),
|
||||
sig,
|
||||
federation.public_key_pem(),
|
||||
) is True
|
||||
|
||||
|
||||
# ---------- no-leak invariants -----------------------------------------
|
||||
|
||||
def test_explore_view_has_no_ioc_values_or_case_ids_or_raw_json(fresh_db, fed_dir):
|
||||
"""Public payload must not expose IOC values, case_ids in raw form, or raw_json.
|
||||
|
||||
This is the core transparency-vs-leakage contract: anyone can see who's
|
||||
talking to whom and how much, but never what they're saying.
|
||||
"""
|
||||
fp, pem = _make_peer_pubkey()
|
||||
federation.register_peer("trusted.example", fp, pem, status="trusted")
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
db.record_signal(dict(
|
||||
peer_fingerprint=fp,
|
||||
signal_type="ioc",
|
||||
signal_id="evil-domain-do-not-leak.com",
|
||||
signal_hash="ioc-hash-leak",
|
||||
received_at=now_iso,
|
||||
raw_json=json.dumps({"type": "domain", "value": "evil-domain-do-not-leak.com"}),
|
||||
))
|
||||
db.record_signal(dict(
|
||||
peer_fingerprint=fp,
|
||||
signal_type="case",
|
||||
signal_id="CASE-SECRET-42",
|
||||
signal_hash="case-hash-leak",
|
||||
received_at=now_iso,
|
||||
raw_json=json.dumps({"severity": "critical", "case_id": "CASE-SECRET-42"}),
|
||||
))
|
||||
with _silence_explore_fetch(), _silence_network_fetch():
|
||||
payload = build_explore_view()
|
||||
flat = json.dumps(payload, default=str)
|
||||
# IOC values.
|
||||
assert "evil-domain-do-not-leak.com" not in flat
|
||||
# Case ids (raw).
|
||||
assert "CASE-SECRET-42" not in flat
|
||||
# raw_json shape never serialized.
|
||||
assert "raw_json" not in flat
|
||||
# Sector-leaking breakdowns.
|
||||
assert "severity_breakdown" not in flat
|
||||
assert "ioc_type_breakdown" not in flat
|
||||
|
||||
|
||||
# ---------- transitive peers --------------------------------------------
|
||||
|
||||
def test_explore_transitive_peers_populated_from_peer_responses(fresh_db, fed_dir):
|
||||
direct_fp, direct_pem = _make_peer_pubkey()
|
||||
federation.register_peer("direct.example", direct_fp, direct_pem, status="trusted")
|
||||
far_a, _ = _make_peer_pubkey()
|
||||
far_b, _ = _make_peer_pubkey()
|
||||
fake_payload: Dict[str, Any] = {
|
||||
"fingerprint": direct_fp,
|
||||
"peers": [
|
||||
{"fingerprint": far_a, "domain": "far-a.example"},
|
||||
{"fingerprint": far_b, "domain": "far-b.example"},
|
||||
],
|
||||
"vouches": [],
|
||||
}
|
||||
with patch.object(network_view, "_fetch_peer_explore", return_value=fake_payload):
|
||||
payload = build_explore_view()
|
||||
tps = payload["transitive_peers"]
|
||||
fps = {t["fingerprint"] for t in tps}
|
||||
assert far_a in fps
|
||||
assert far_b in fps
|
||||
via_fps = {t["via_peer_fingerprint"] for t in tps}
|
||||
assert via_fps == {direct_fp}
|
||||
|
||||
|
||||
def test_explore_transitive_peers_falls_back_to_network_endpoint(fresh_db, fed_dir):
|
||||
"""If a peer doesn't have /federation/explore/data (older node), fall back
|
||||
to /federation/network — the public-view shape is the same {fingerprint, peers}."""
|
||||
direct_fp, direct_pem = _make_peer_pubkey()
|
||||
federation.register_peer("direct.example", direct_fp, direct_pem, status="trusted")
|
||||
far_fp, _ = _make_peer_pubkey()
|
||||
fallback_payload: Dict[str, Any] = {
|
||||
"fingerprint": direct_fp,
|
||||
"peers": [{"fingerprint": far_fp, "domain": "far.example"}],
|
||||
"vouches": [],
|
||||
}
|
||||
with patch.object(network_view, "_fetch_peer_explore", return_value=None), \
|
||||
patch.object(network_view, "_fetch_peer_network", return_value=fallback_payload):
|
||||
payload = build_explore_view()
|
||||
assert any(t["fingerprint"] == far_fp for t in payload["transitive_peers"])
|
||||
|
||||
|
||||
def test_explore_transitive_peers_dedupe_against_direct(fresh_db, fed_dir):
|
||||
"""If a transitive fp is already a direct peer, don't duplicate it."""
|
||||
direct_fp, direct_pem = _make_peer_pubkey()
|
||||
federation.register_peer("direct.example", direct_fp, direct_pem, status="trusted")
|
||||
fake_payload = {
|
||||
"fingerprint": direct_fp,
|
||||
# Direct peer's own fp echoed back — must be deduped.
|
||||
"peers": [{"fingerprint": direct_fp, "domain": "direct.example"}],
|
||||
"vouches": [],
|
||||
}
|
||||
with patch.object(network_view, "_fetch_peer_explore", return_value=fake_payload):
|
||||
payload = build_explore_view()
|
||||
assert payload["transitive_peers"] == []
|
||||
|
||||
|
||||
# ---------- vouches_in --------------------------------------------------
|
||||
|
||||
def test_explore_vouches_in_filters_to_target_self_and_trusted_vouchers(fresh_db, fed_dir):
|
||||
"""vouches_in includes ONLY entries naming us as target whose voucher we trust."""
|
||||
our_fp = federation.node_fingerprint()
|
||||
fp_trusted, pem_t = _make_peer_pubkey()
|
||||
fp_unknown, pem_u = _make_peer_pubkey()
|
||||
federation.register_peer("trusted.example", fp_trusted, pem_t, status="trusted")
|
||||
federation.register_peer("unknown.example", fp_unknown, pem_u, status="unknown")
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
# Trusted peer vouches for us.
|
||||
db.upsert_vouch(dict(
|
||||
voucher_fingerprint=fp_trusted,
|
||||
target_fingerprint=our_fp,
|
||||
issued_at=now,
|
||||
expires_at=None,
|
||||
signature="trusted-sig",
|
||||
))
|
||||
# Unknown peer also "vouches" for us — must NOT leak.
|
||||
db.upsert_vouch(dict(
|
||||
voucher_fingerprint=fp_unknown,
|
||||
target_fingerprint=our_fp,
|
||||
issued_at=now,
|
||||
expires_at=None,
|
||||
signature="rogue-sig",
|
||||
))
|
||||
# Vouch naming someone else — must NOT appear in vouches_in.
|
||||
other_fp, _ = _make_peer_pubkey()
|
||||
db.upsert_vouch(dict(
|
||||
voucher_fingerprint=fp_trusted,
|
||||
target_fingerprint=other_fp,
|
||||
issued_at=now,
|
||||
expires_at=None,
|
||||
signature="other-sig",
|
||||
))
|
||||
with _silence_explore_fetch(), _silence_network_fetch():
|
||||
payload = build_explore_view()
|
||||
vouchers = {v["voucher_fingerprint"] for v in payload["vouches_in"]}
|
||||
assert vouchers == {fp_trusted}
|
||||
# And the rogue signature is not anywhere in the payload.
|
||||
assert "rogue-sig" not in json.dumps(payload, default=str)
|
||||
|
||||
|
||||
# ---------- corroboration counter --------------------------------------
|
||||
|
||||
def test_explore_corroboration_count_matches_distinct_shared_hashes(fresh_db, fed_dir):
|
||||
fp_a, pem_a = _make_peer_pubkey()
|
||||
fp_b, pem_b = _make_peer_pubkey()
|
||||
federation.register_peer("a.example", fp_a, pem_a, status="trusted")
|
||||
federation.register_peer("b.example", fp_b, pem_b, status="trusted")
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
# Two shared hashes between A and B.
|
||||
for h in ("shared-1", "shared-2"):
|
||||
for fp in (fp_a, fp_b):
|
||||
db.record_signal(dict(
|
||||
peer_fingerprint=fp,
|
||||
signal_type="ioc",
|
||||
signal_id="x",
|
||||
signal_hash=h,
|
||||
received_at=now_iso,
|
||||
raw_json="{}",
|
||||
))
|
||||
# One solo hash — must NOT count.
|
||||
db.record_signal(dict(
|
||||
peer_fingerprint=fp_a,
|
||||
signal_type="ioc",
|
||||
signal_id="solo",
|
||||
signal_hash="solo-hash",
|
||||
received_at=now_iso,
|
||||
raw_json="{}",
|
||||
))
|
||||
with _silence_explore_fetch(), _silence_network_fetch():
|
||||
payload = build_explore_view()
|
||||
assert payload["corroboration_count_24h"] == 2
|
||||
assert payload["node"]["corroboration_count_24h"] == 2
|
||||
|
||||
|
||||
# ---------- transparency log headline ----------------------------------
|
||||
|
||||
def test_explore_node_translog_headline_reflects_chain(fresh_db, fed_dir):
|
||||
translog.append("vouch", {"foo": "bar"})
|
||||
translog.append("signal", {"x": 1})
|
||||
with _silence_explore_fetch(), _silence_network_fetch():
|
||||
payload = build_explore_view()
|
||||
node = payload["node"]
|
||||
assert node["translog_entry_count"] == 2
|
||||
assert isinstance(node["transparency_log_head_hash"], str)
|
||||
assert len(node["transparency_log_head_hash"]) == 64 # hex sha256
|
||||
|
||||
|
||||
# ---------- HTTP routes -------------------------------------------------
|
||||
|
||||
def _mk_app() -> FastAPI:
|
||||
app = FastAPI()
|
||||
app.add_middleware(SessionMiddleware, secret_key="test-secret")
|
||||
import tempfile
|
||||
from pathlib import Path as _Path
|
||||
# We need real templates for /federation/explore HTML response.
|
||||
here = _Path(__file__).resolve().parent.parent / "src" / "psyc" / "cockpit" / "templates"
|
||||
templates = Jinja2Templates(directory=str(here))
|
||||
federation_routes.register(app, templates)
|
||||
return app
|
||||
|
||||
|
||||
def test_federation_explore_endpoint_returns_html(fresh_db, fed_dir):
|
||||
with _silence_explore_fetch(), _silence_network_fetch():
|
||||
client = TestClient(_mk_app())
|
||||
r = client.get("/federation/explore")
|
||||
assert r.status_code == 200
|
||||
assert "text/html" in r.headers.get("content-type", "")
|
||||
# Banner + page title are present.
|
||||
body = r.text
|
||||
assert "Federation Explorer" in body
|
||||
|
||||
|
||||
def test_federation_explore_data_returns_signed_json(fresh_db, fed_dir):
|
||||
fp, pem = _make_peer_pubkey()
|
||||
federation.register_peer("trusted.example", fp, pem, status="trusted")
|
||||
with _silence_explore_fetch(), _silence_network_fetch():
|
||||
client = TestClient(_mk_app())
|
||||
r = client.get("/federation/explore/data")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert "signature" in data
|
||||
assert "node" in data
|
||||
sig = base64.b64decode(data["signature"])
|
||||
unsigned = {k: v for k, v in data.items() if k != "signature"}
|
||||
assert federation.verify_payload(
|
||||
federation.canonical_json(unsigned),
|
||||
sig,
|
||||
federation.public_key_pem(),
|
||||
) is True
|
||||
|
||||
|
||||
def test_federation_explore_data_has_cors_header(fresh_db, fed_dir):
|
||||
"""Other psyc nodes' explore pages need to fetch this from the browser."""
|
||||
with _silence_explore_fetch(), _silence_network_fetch():
|
||||
client = TestClient(_mk_app())
|
||||
r = client.get("/federation/explore/data")
|
||||
assert r.status_code == 200
|
||||
assert r.headers.get("access-control-allow-origin") == "*"
|
||||
|
||||
|
||||
def test_federation_info_has_explore_and_cors(fresh_db, fed_dir):
|
||||
client = TestClient(_mk_app())
|
||||
r = client.get("/federation/info")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data.get("explore") == "/federation/explore"
|
||||
assert r.headers.get("access-control-allow-origin") == "*"
|
||||
|
||||
|
||||
def test_existing_public_endpoints_have_cors_header(fresh_db, fed_dir):
|
||||
"""All public endpoints must be cross-origin fetchable for the explorer."""
|
||||
client = TestClient(_mk_app())
|
||||
for path in (
|
||||
"/federation/key",
|
||||
"/federation/feed",
|
||||
"/federation/vouches",
|
||||
"/federation/log",
|
||||
"/federation/log/verify",
|
||||
"/federation/peers/public",
|
||||
"/federation/network",
|
||||
):
|
||||
r = client.get(path)
|
||||
assert r.status_code in (200, 409), f"{path} status {r.status_code}"
|
||||
assert r.headers.get("access-control-allow-origin") == "*", f"{path} missing CORS"
|
||||
@@ -159,6 +159,8 @@ def test_build_then_import_signed_feed_roundtrip(fresh_db, fed_dir):
|
||||
new_sig = peer_priv.sign(canonical_json(unsigned))
|
||||
feed["signature"] = base64.b64encode(new_sig).decode("ascii")
|
||||
|
||||
# Stage 4 listening gate: peer must be trusted to land signals.
|
||||
federation.register_peer("peer.example", peer_fp, peer_pub_pem, status="trusted")
|
||||
result = import_signed_feed(feed, peer_pub_pem)
|
||||
assert isinstance(result, Ok), getattr(result, "reason", "")
|
||||
summary = result.value
|
||||
@@ -228,3 +230,33 @@ def test_peer_registry_crud(fresh_db, fed_dir):
|
||||
|
||||
federation.remove_peer("peer.example")
|
||||
assert federation.list_peers() == []
|
||||
|
||||
|
||||
def test_register_peer_rejects_malformed_domain(fresh_db, fed_dir):
|
||||
"""XSS guard: domain must look like a hostname (+ optional :port)."""
|
||||
import pytest
|
||||
bad = [
|
||||
"evil.com'); alert(1); //",
|
||||
"evil.com<script>",
|
||||
"evil.com onclick=alert(1)",
|
||||
"",
|
||||
"evil com", # space
|
||||
"/etc/passwd",
|
||||
"evil.com/?phish=1",
|
||||
]
|
||||
for d in bad:
|
||||
with pytest.raises(ValueError):
|
||||
federation.register_peer(d, "ff" * 16, "PEM")
|
||||
# And good ones still pass:
|
||||
for d in ["peer.example.com", "peer.example.com:8443", "peer-1.example", "127.0.0.1:8767"]:
|
||||
federation.register_peer(d, "ff" * 16, "PEM")
|
||||
federation.remove_peer(d)
|
||||
|
||||
|
||||
def test_register_peer_rejects_malformed_fingerprint(fresh_db, fed_dir):
|
||||
"""Defense-in-depth: fingerprint must be 32 hex chars."""
|
||||
import pytest
|
||||
with pytest.raises(ValueError):
|
||||
federation.register_peer("peer.example", "not-hex", "PEM")
|
||||
with pytest.raises(ValueError):
|
||||
federation.register_peer("peer.example", "ff" * 8, "PEM") # too short
|
||||
|
||||
151
tests/test_inference.py
Normal file
151
tests/test_inference.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""Tests for the inference client — both psyc-native and openai-compatible modes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
from typing import Any, Dict
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from psyc.cockpit import inference
|
||||
from psyc.models import Case, Classification, Confidence, Severity, TLP, Observables, Evidence, Victim
|
||||
|
||||
|
||||
def _reload_with_env(monkeypatch, **env: str) -> Any:
|
||||
for k, v in env.items():
|
||||
monkeypatch.setenv(k, v)
|
||||
return importlib.reload(inference)
|
||||
|
||||
|
||||
def _case() -> Case:
|
||||
return Case(
|
||||
case_id="C-T-1",
|
||||
summary="test",
|
||||
source_type="test",
|
||||
source_ref="",
|
||||
observed_at="2026-01-01T00:00:00+00:00",
|
||||
ingested_at="2026-01-01T00:00:00+00:00",
|
||||
classification=Classification(tlp=TLP.GREEN, severity=Severity.HIGH),
|
||||
confidence=Confidence(level="medium", source_reliability="B", information_credibility="2"),
|
||||
observables=Observables(),
|
||||
evidence=Evidence(),
|
||||
source_metadata={},
|
||||
victim=Victim(),
|
||||
)
|
||||
|
||||
|
||||
def test_no_auth_header_when_token_unset(monkeypatch):
|
||||
mod = _reload_with_env(monkeypatch, PSYC_INFERENCE_TOKEN="")
|
||||
assert mod._auth_headers() == {}
|
||||
|
||||
|
||||
def test_bearer_header_when_token_set(monkeypatch):
|
||||
mod = _reload_with_env(monkeypatch, PSYC_INFERENCE_TOKEN="abc123")
|
||||
assert mod._auth_headers() == {"Authorization": "Bearer abc123"}
|
||||
|
||||
|
||||
def test_psyc_mode_server_adapter(monkeypatch):
|
||||
mod = _reload_with_env(monkeypatch, PSYC_INFERENCE_MODE="psyc", PSYC_INFERENCE_URL="http://x")
|
||||
seen: Dict[str, Any] = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen["url"] = str(request.url)
|
||||
seen["method"] = request.method
|
||||
return httpx.Response(200, json={"adapter": "/data/adapters/psyc-v5/final"})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
real_client = httpx.Client
|
||||
monkeypatch.setattr(httpx, "Client", lambda **kw: real_client(transport=transport, **{k: v for k, v in kw.items() if k != "transport"}))
|
||||
|
||||
assert mod.server_adapter() == "/data/adapters/psyc-v5/final"
|
||||
assert seen["url"].endswith("/healthz")
|
||||
|
||||
|
||||
def test_openai_mode_server_adapter(monkeypatch):
|
||||
mod = _reload_with_env(
|
||||
monkeypatch,
|
||||
PSYC_INFERENCE_MODE="openai",
|
||||
PSYC_INFERENCE_URL="https://api.example",
|
||||
PSYC_INFERENCE_TOKEN="t0k",
|
||||
PSYC_INFERENCE_MODEL="psyc-v5",
|
||||
)
|
||||
seen: Dict[str, Any] = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen["url"] = str(request.url)
|
||||
seen["auth"] = request.headers.get("authorization")
|
||||
return httpx.Response(200, json={"data": [{"id": "llama3"}, {"id": "mistral"}]})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
real_client = httpx.Client
|
||||
monkeypatch.setattr(httpx, "Client", lambda **kw: real_client(transport=transport, **{k: v for k, v in kw.items() if k != "transport"}))
|
||||
|
||||
assert mod.server_adapter() == "llama3"
|
||||
assert seen["url"].endswith("/v1/models")
|
||||
assert seen["auth"] == "Bearer t0k"
|
||||
|
||||
|
||||
def test_openai_mode_severity_request_shape(monkeypatch):
|
||||
mod = _reload_with_env(
|
||||
monkeypatch,
|
||||
PSYC_INFERENCE_MODE="openai",
|
||||
PSYC_INFERENCE_URL="https://api.example",
|
||||
PSYC_INFERENCE_TOKEN="t0k",
|
||||
PSYC_INFERENCE_MODEL="psyc-v5",
|
||||
)
|
||||
sent: Dict[str, Any] = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
sent["url"] = str(request.url)
|
||||
sent["auth"] = request.headers.get("authorization")
|
||||
sent["body"] = json.loads(request.content.decode())
|
||||
return httpx.Response(200, json={"choices": [{"message": {"content": "HIGH"}}]})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
real_client = httpx.Client
|
||||
monkeypatch.setattr(httpx, "Client", lambda **kw: real_client(transport=transport, **{k: v for k, v in kw.items() if k != "transport"}))
|
||||
|
||||
result = mod.model_severity(_case())
|
||||
assert result == "high"
|
||||
assert sent["url"].endswith("/v1/chat/completions")
|
||||
assert sent["auth"] == "Bearer t0k"
|
||||
assert sent["body"]["model"] == "psyc-v5"
|
||||
assert sent["body"]["messages"][0]["role"] == "system"
|
||||
assert sent["body"]["messages"][1]["role"] == "user"
|
||||
assert sent["body"]["max_tokens"] == 16
|
||||
|
||||
|
||||
def test_psyc_mode_severity_unchanged(monkeypatch):
|
||||
mod = _reload_with_env(monkeypatch, PSYC_INFERENCE_MODE="psyc", PSYC_INFERENCE_URL="http://x", PSYC_INFERENCE_TOKEN="")
|
||||
sent: Dict[str, Any] = {}
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
sent["url"] = str(request.url)
|
||||
sent["auth"] = request.headers.get("authorization")
|
||||
sent["body"] = json.loads(request.content.decode())
|
||||
return httpx.Response(200, json={"output": "MEDIUM"})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
real_client = httpx.Client
|
||||
monkeypatch.setattr(httpx, "Client", lambda **kw: real_client(transport=transport, **{k: v for k, v in kw.items() if k != "transport"}))
|
||||
|
||||
assert mod.model_severity(_case()) == "medium"
|
||||
assert sent["url"].endswith("/infer")
|
||||
assert sent["auth"] is None
|
||||
assert "instruction" in sent["body"]
|
||||
assert "max_new_tokens" in sent["body"]
|
||||
|
||||
|
||||
def test_server_adapter_returns_none_on_http_error(monkeypatch):
|
||||
mod = _reload_with_env(monkeypatch, PSYC_INFERENCE_MODE="openai", PSYC_INFERENCE_URL="https://api.example")
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(401, json={"error": "unauthorized"})
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
real_client = httpx.Client
|
||||
monkeypatch.setattr(httpx, "Client", lambda **kw: real_client(transport=transport, **{k: v for k, v in kw.items() if k != "transport"}))
|
||||
|
||||
assert mod.server_adapter() is None
|
||||
702
tests/test_network_view.py
Normal file
702
tests/test_network_view.py
Normal file
@@ -0,0 +1,702 @@
|
||||
"""Network view — local + transitive + public payload tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
|
||||
from psyc import db
|
||||
from psyc.lines import federation, network_view, translog
|
||||
from psyc.lines.network_view import (
|
||||
NetworkEdge,
|
||||
NetworkNode,
|
||||
NetworkView,
|
||||
build_admin_view,
|
||||
build_explore_view,
|
||||
build_local_view,
|
||||
build_public_view,
|
||||
build_transitive_view,
|
||||
)
|
||||
|
||||
|
||||
# ---------- fixtures ----------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def fresh_db(tmp_path, monkeypatch):
|
||||
test_db = tmp_path / "test.db"
|
||||
eng = create_engine(f"sqlite:///{test_db}", future=True)
|
||||
db._metadata.create_all(eng, checkfirst=True)
|
||||
monkeypatch.setattr(db, "_engine", eng)
|
||||
monkeypatch.setattr(db, "DB_PATH", test_db)
|
||||
yield test_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fed_dir(tmp_path, monkeypatch):
|
||||
d = tmp_path / "federation"
|
||||
monkeypatch.setattr(federation, "FED_DIR", d)
|
||||
monkeypatch.setattr(federation, "PRIVATE_KEY_PATH", d / "node.key")
|
||||
monkeypatch.setattr(federation, "PUBLIC_KEY_PATH", d / "node.pub")
|
||||
yield d
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_transitive_cache(monkeypatch):
|
||||
"""Prevent cache bleed between tests."""
|
||||
monkeypatch.setattr(network_view, "_TRANSITIVE_CACHE", {"ts": 0.0, "view": None})
|
||||
yield
|
||||
|
||||
|
||||
def _make_peer_pubkey() -> tuple[str, str]:
|
||||
"""Return (fingerprint, pubkey_pem) for a synthetic peer keypair."""
|
||||
import hashlib
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ed25519
|
||||
priv = ed25519.Ed25519PrivateKey.generate()
|
||||
pub = priv.public_key()
|
||||
pem = pub.public_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
).decode("ascii")
|
||||
raw = pub.public_bytes(
|
||||
encoding=serialization.Encoding.Raw,
|
||||
format=serialization.PublicFormat.Raw,
|
||||
)
|
||||
fp = hashlib.sha256(raw).digest()[:16].hex()
|
||||
return fp, pem
|
||||
|
||||
|
||||
# ---------- local view --------------------------------------------------
|
||||
|
||||
def test_local_view_empty_registry_yields_only_self(fresh_db, fed_dir):
|
||||
view = build_local_view()
|
||||
assert isinstance(view, NetworkView)
|
||||
assert len(view.nodes) == 1
|
||||
self_node = view.nodes[0]
|
||||
assert self_node.is_self is True
|
||||
assert self_node.distance == 0
|
||||
assert self_node.status == "self"
|
||||
assert self_node.fingerprint == federation.node_fingerprint()
|
||||
assert view.edges == []
|
||||
assert view.stats["total_peers"] == 0
|
||||
assert view.stats["vouched_peers"] == 0
|
||||
assert view.stats["signals_buffered_24h"] == 0
|
||||
|
||||
|
||||
def test_local_view_one_trusted_peer_no_edges(fresh_db, fed_dir):
|
||||
peer_fp, peer_pem = _make_peer_pubkey()
|
||||
federation.register_peer("peer.example", peer_fp, peer_pem, status="trusted")
|
||||
view = build_local_view()
|
||||
assert len(view.nodes) == 2
|
||||
peer_node = next(n for n in view.nodes if not n.is_self)
|
||||
assert peer_node.fingerprint == peer_fp
|
||||
assert peer_node.status == "trusted"
|
||||
assert peer_node.distance == 1
|
||||
assert peer_node.domain == "peer.example"
|
||||
assert view.edges == []
|
||||
assert view.stats["total_peers"] == 1
|
||||
assert view.stats["vouched_peers"] == 1
|
||||
|
||||
|
||||
def test_local_view_outbound_vouch_creates_edge(fresh_db, fed_dir):
|
||||
peer_fp, peer_pem = _make_peer_pubkey()
|
||||
federation.register_peer("peer.example", peer_fp, peer_pem, status="trusted")
|
||||
federation.issue_vouch(peer_fp, ttl_days=30)
|
||||
view = build_local_view()
|
||||
vouch_edges = [e for e in view.edges if e.kind == "vouch"]
|
||||
assert len(vouch_edges) == 1
|
||||
e = vouch_edges[0]
|
||||
assert e.source_fingerprint == federation.node_fingerprint()
|
||||
assert e.target_fingerprint == peer_fp
|
||||
assert view.stats["vouches_issued"] == 1
|
||||
|
||||
|
||||
def test_local_view_inbound_vouch_creates_edge(fresh_db, fed_dir):
|
||||
"""Vouches received that name us as target → peer → self edge."""
|
||||
peer_fp, peer_pem = _make_peer_pubkey()
|
||||
federation.register_peer("peer.example", peer_fp, peer_pem, status="trusted")
|
||||
# Insert a vouch where peer vouches FOR us, bypassing accept_vouch (which
|
||||
# we don't need to exercise here — the question is render shape).
|
||||
our_fp = federation.node_fingerprint()
|
||||
now = datetime.now(timezone.utc)
|
||||
db.upsert_vouch(dict(
|
||||
voucher_fingerprint=peer_fp,
|
||||
target_fingerprint=our_fp,
|
||||
issued_at=now.isoformat(),
|
||||
expires_at=(now + timedelta(days=30)).isoformat(),
|
||||
signature="x" * 88,
|
||||
))
|
||||
view = build_local_view()
|
||||
vouch_edges = [e for e in view.edges if e.kind == "vouch"]
|
||||
assert len(vouch_edges) == 1
|
||||
e = vouch_edges[0]
|
||||
assert e.source_fingerprint == peer_fp
|
||||
assert e.target_fingerprint == our_fp
|
||||
|
||||
|
||||
def test_local_view_bidirectional_vouches_collapse(fresh_db, fed_dir):
|
||||
peer_fp, peer_pem = _make_peer_pubkey()
|
||||
federation.register_peer("peer.example", peer_fp, peer_pem, status="trusted")
|
||||
federation.issue_vouch(peer_fp, ttl_days=30)
|
||||
# And peer vouches back at us.
|
||||
our_fp = federation.node_fingerprint()
|
||||
now = datetime.now(timezone.utc)
|
||||
db.upsert_vouch(dict(
|
||||
voucher_fingerprint=peer_fp,
|
||||
target_fingerprint=our_fp,
|
||||
issued_at=now.isoformat(),
|
||||
expires_at=(now + timedelta(days=30)).isoformat(),
|
||||
signature="x" * 88,
|
||||
))
|
||||
view = build_local_view()
|
||||
vouch_edges = [e for e in view.edges if e.kind == "vouch"]
|
||||
assert len(vouch_edges) == 1
|
||||
assert vouch_edges[0].bidirectional is True
|
||||
|
||||
|
||||
def test_local_view_signal_edge_weight_is_24h_count(fresh_db, fed_dir):
|
||||
peer_fp, peer_pem = _make_peer_pubkey()
|
||||
federation.register_peer("peer.example", peer_fp, peer_pem, status="trusted")
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
# Three signals from this peer within the window.
|
||||
for i in range(3):
|
||||
db.record_signal(dict(
|
||||
peer_fingerprint=peer_fp,
|
||||
signal_type="ioc",
|
||||
signal_id=f"1.2.3.{i}",
|
||||
signal_hash=f"hash-{i}",
|
||||
received_at=now_iso,
|
||||
raw_json="{}",
|
||||
))
|
||||
# One stale signal outside the window — must be ignored.
|
||||
stale = (datetime.now(timezone.utc) - timedelta(hours=48)).isoformat()
|
||||
db.record_signal(dict(
|
||||
peer_fingerprint=peer_fp,
|
||||
signal_type="ioc",
|
||||
signal_id="9.9.9.9",
|
||||
signal_hash="stale",
|
||||
received_at=stale,
|
||||
raw_json="{}",
|
||||
))
|
||||
view = build_local_view()
|
||||
sig_edges = [e for e in view.edges if e.kind == "signal"]
|
||||
assert len(sig_edges) == 1
|
||||
assert sig_edges[0].weight == 3.0
|
||||
assert sig_edges[0].source_fingerprint == peer_fp
|
||||
assert sig_edges[0].target_fingerprint == federation.node_fingerprint()
|
||||
assert view.stats["signals_buffered_24h"] == 3
|
||||
assert view.stats["distinct_signal_hashes_24h"] == 3
|
||||
|
||||
|
||||
def test_local_view_blocked_peer_renders_with_blocked_status(fresh_db, fed_dir):
|
||||
fp, pem = _make_peer_pubkey()
|
||||
federation.register_peer("blocked.example", fp, pem, status="blocked")
|
||||
view = build_local_view()
|
||||
peer = next(n for n in view.nodes if not n.is_self)
|
||||
assert peer.status == "blocked"
|
||||
|
||||
|
||||
# ---------- public view + signature round-trip --------------------------
|
||||
|
||||
def test_public_view_excludes_unknown_and_blocked(fresh_db, fed_dir):
|
||||
fp_t, pem_t = _make_peer_pubkey()
|
||||
fp_u, pem_u = _make_peer_pubkey()
|
||||
fp_b, pem_b = _make_peer_pubkey()
|
||||
federation.register_peer("trusted.example", fp_t, pem_t, status="trusted")
|
||||
federation.register_peer("unknown.example", fp_u, pem_u, status="unknown")
|
||||
federation.register_peer("blocked.example", fp_b, pem_b, status="blocked")
|
||||
|
||||
payload = build_public_view()
|
||||
fps = {p["fingerprint"] for p in payload["peers"]}
|
||||
assert fp_t in fps
|
||||
assert fp_u not in fps
|
||||
assert fp_b not in fps
|
||||
|
||||
|
||||
def test_public_view_signature_round_trip(fresh_db, fed_dir):
|
||||
fp, pem = _make_peer_pubkey()
|
||||
federation.register_peer("trusted.example", fp, pem, status="trusted")
|
||||
federation.issue_vouch(fp, ttl_days=30)
|
||||
payload = build_public_view()
|
||||
|
||||
assert "signature" in payload
|
||||
assert payload["fingerprint"] == federation.node_fingerprint()
|
||||
|
||||
sig = base64.b64decode(payload["signature"])
|
||||
unsigned = {k: v for k, v in payload.items() if k != "signature"}
|
||||
assert federation.verify_payload(
|
||||
federation.canonical_json(unsigned),
|
||||
sig,
|
||||
federation.public_key_pem(),
|
||||
) is True
|
||||
|
||||
# Vouch we issued is in the payload.
|
||||
targets = {v["target_fingerprint"] for v in payload["vouches"]}
|
||||
assert fp in targets
|
||||
|
||||
|
||||
def test_public_view_omits_signals(fresh_db, fed_dir):
|
||||
"""Public payload must not leak who's reporting what."""
|
||||
fp, pem = _make_peer_pubkey()
|
||||
federation.register_peer("trusted.example", fp, pem, status="trusted")
|
||||
db.record_signal(dict(
|
||||
peer_fingerprint=fp,
|
||||
signal_type="ioc",
|
||||
signal_id="1.2.3.4",
|
||||
signal_hash="secret-hash",
|
||||
received_at=datetime.now(timezone.utc).isoformat(),
|
||||
raw_json="{}",
|
||||
))
|
||||
payload = build_public_view()
|
||||
# No signal-shaped fields anywhere in the payload.
|
||||
flat = str(payload)
|
||||
assert "secret-hash" not in flat
|
||||
assert "signals" not in payload
|
||||
|
||||
|
||||
# ---------- transitive view ---------------------------------------------
|
||||
|
||||
def test_transitive_view_adds_distance_2_nodes(fresh_db, fed_dir):
|
||||
direct_fp, direct_pem = _make_peer_pubkey()
|
||||
federation.register_peer("direct.example", direct_fp, direct_pem, status="trusted")
|
||||
# The peer reports two peers of its own.
|
||||
far_fp_a, _ = _make_peer_pubkey()
|
||||
far_fp_b, _ = _make_peer_pubkey()
|
||||
fake_payload: Dict[str, Any] = {
|
||||
"fingerprint": direct_fp,
|
||||
"peers": [
|
||||
{"fingerprint": far_fp_a, "domain": "far-a.example"},
|
||||
{"fingerprint": far_fp_b, "domain": "far-b.example"},
|
||||
],
|
||||
"vouches": [],
|
||||
}
|
||||
with patch.object(network_view, "_fetch_peer_network", return_value=fake_payload):
|
||||
view = build_transitive_view(force_refresh=True)
|
||||
|
||||
distances = sorted(n.distance for n in view.nodes)
|
||||
assert 0 in distances and 1 in distances and 2 in distances
|
||||
transitive_fps = {n.fingerprint for n in view.nodes if n.distance == 2}
|
||||
assert far_fp_a in transitive_fps
|
||||
assert far_fp_b in transitive_fps
|
||||
# "knows" edges from direct peer to each transitive.
|
||||
knows = [e for e in view.edges if e.kind == "knows"]
|
||||
assert len(knows) == 2
|
||||
assert all(e.source_fingerprint == direct_fp for e in knows)
|
||||
assert view.stats["transitive_nodes"] == 2
|
||||
|
||||
|
||||
def test_transitive_view_failed_fetch_does_not_abort(fresh_db, fed_dir):
|
||||
fp_a, pem_a = _make_peer_pubkey()
|
||||
fp_b, pem_b = _make_peer_pubkey()
|
||||
federation.register_peer("peer-a.example", fp_a, pem_a, status="trusted")
|
||||
federation.register_peer("peer-b.example", fp_b, pem_b, status="trusted")
|
||||
|
||||
far_fp, _ = _make_peer_pubkey()
|
||||
|
||||
def fake_fetch(domain, timeout=4.0):
|
||||
if domain == "peer-a.example":
|
||||
return None # simulate a fetch failure
|
||||
return {
|
||||
"fingerprint": fp_b,
|
||||
"peers": [{"fingerprint": far_fp, "domain": "far.example"}],
|
||||
"vouches": [],
|
||||
}
|
||||
|
||||
with patch.object(network_view, "_fetch_peer_network", side_effect=fake_fetch):
|
||||
view = build_transitive_view(force_refresh=True)
|
||||
# Direct nodes both present, transitive only from B.
|
||||
assert any(n.fingerprint == fp_a for n in view.nodes)
|
||||
assert any(n.fingerprint == fp_b for n in view.nodes)
|
||||
assert any(n.fingerprint == far_fp and n.distance == 2 for n in view.nodes)
|
||||
assert view.stats["transitive_nodes"] == 1
|
||||
|
||||
|
||||
def test_transitive_view_skips_only_unknown_peers(fresh_db, fed_dir):
|
||||
"""Unknown peers shouldn't be queried — fetcher only called for trusted."""
|
||||
fp_unknown, pem_u = _make_peer_pubkey()
|
||||
fp_trusted, pem_t = _make_peer_pubkey()
|
||||
federation.register_peer("unknown.example", fp_unknown, pem_u, status="unknown")
|
||||
federation.register_peer("trusted.example", fp_trusted, pem_t, status="trusted")
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_fetch(domain, timeout=4.0):
|
||||
calls.append(domain)
|
||||
return None
|
||||
|
||||
with patch.object(network_view, "_fetch_peer_network", side_effect=fake_fetch):
|
||||
build_transitive_view(force_refresh=True)
|
||||
|
||||
assert "trusted.example" in calls
|
||||
assert "unknown.example" not in calls
|
||||
|
||||
|
||||
# ---------- admin view: per-peer enrichment + corroboration + timeline ---
|
||||
|
||||
def _no_transitive():
|
||||
"""patch.object helper — silence network fetches in admin-view tests."""
|
||||
return patch.object(network_view, "_fetch_peer_network", return_value=None)
|
||||
|
||||
|
||||
def test_admin_view_includes_stats_on_peer_nodes(fresh_db, fed_dir):
|
||||
"""Every non-self node must carry a `stats` dict in the admin view."""
|
||||
fp, pem = _make_peer_pubkey()
|
||||
federation.register_peer("peer.example", fp, pem, status="trusted")
|
||||
with _no_transitive():
|
||||
view = build_admin_view(include_transitive=False)
|
||||
self_nodes = [n for n in view["nodes"] if n["is_self"]]
|
||||
peer_nodes = [n for n in view["nodes"] if not n["is_self"]]
|
||||
assert len(self_nodes) == 1
|
||||
assert len(peer_nodes) == 1
|
||||
# Self has no stats; peers do.
|
||||
assert self_nodes[0]["stats"] is None
|
||||
peer_stats = peer_nodes[0]["stats"]
|
||||
assert isinstance(peer_stats, dict)
|
||||
for key in (
|
||||
"signals_24h", "signals_total", "cases_24h", "iocs_24h",
|
||||
"severity_breakdown", "ioc_type_breakdown",
|
||||
"vouches_in_count", "vouches_out_count",
|
||||
"quorum_contribution", "last_seen", "last_seen_relative",
|
||||
"recent_translog",
|
||||
):
|
||||
assert key in peer_stats, f"missing {key}"
|
||||
# last_seen is None when no signals have landed yet.
|
||||
assert peer_stats["last_seen"] is None
|
||||
assert peer_stats["last_seen_relative"] == "—"
|
||||
|
||||
|
||||
def test_admin_view_signals_24h_excludes_stale(fresh_db, fed_dir):
|
||||
"""signals_24h must count only rows inside the 24h window."""
|
||||
fp, pem = _make_peer_pubkey()
|
||||
federation.register_peer("peer.example", fp, pem, status="trusted")
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
stale_iso = (datetime.now(timezone.utc) - timedelta(hours=30)).isoformat()
|
||||
for i in range(3):
|
||||
db.record_signal(dict(
|
||||
peer_fingerprint=fp,
|
||||
signal_type="ioc",
|
||||
signal_id=f"v{i}",
|
||||
signal_hash=f"h{i}",
|
||||
received_at=now_iso,
|
||||
raw_json="{}",
|
||||
))
|
||||
db.record_signal(dict(
|
||||
peer_fingerprint=fp,
|
||||
signal_type="ioc",
|
||||
signal_id="stale",
|
||||
signal_hash="stale-hash",
|
||||
received_at=stale_iso,
|
||||
raw_json="{}",
|
||||
))
|
||||
with _no_transitive():
|
||||
view = build_admin_view(include_transitive=False)
|
||||
peer = next(n for n in view["nodes"] if not n["is_self"])
|
||||
assert peer["stats"]["signals_24h"] == 3
|
||||
# All-time total still sees the stale row.
|
||||
assert peer["stats"]["signals_total"] == 4
|
||||
# last_seen is populated and the relative is a short string.
|
||||
assert peer["stats"]["last_seen"] is not None
|
||||
assert peer["stats"]["last_seen_relative"] != "—"
|
||||
|
||||
|
||||
def test_admin_view_severity_and_ioc_breakdown_from_raw_json(fresh_db, fed_dir):
|
||||
"""severity_breakdown is read from raw_json for case rows, ioc_type for ioc rows."""
|
||||
fp, pem = _make_peer_pubkey()
|
||||
federation.register_peer("peer.example", fp, pem, status="trusted")
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
cases = [
|
||||
{"severity": "critical", "case_id": "c1"},
|
||||
{"severity": "critical", "case_id": "c2"},
|
||||
{"severity": "high", "case_id": "c3"},
|
||||
{"severity": "low", "case_id": "c4"},
|
||||
]
|
||||
for c in cases:
|
||||
db.record_signal(dict(
|
||||
peer_fingerprint=fp,
|
||||
signal_type="case",
|
||||
signal_id=c["case_id"],
|
||||
signal_hash=f"hash-{c['case_id']}",
|
||||
received_at=now_iso,
|
||||
raw_json=json.dumps(c),
|
||||
))
|
||||
iocs = [
|
||||
{"type": "url", "value": "https://a"},
|
||||
{"type": "url", "value": "https://b"},
|
||||
{"type": "domain", "value": "x.com"},
|
||||
{"type": "ip", "value": "1.2.3.4"},
|
||||
]
|
||||
for ioc in iocs:
|
||||
db.record_signal(dict(
|
||||
peer_fingerprint=fp,
|
||||
signal_type="ioc",
|
||||
signal_id=ioc["value"],
|
||||
signal_hash=f"hash-{ioc['value']}",
|
||||
received_at=now_iso,
|
||||
raw_json=json.dumps(ioc),
|
||||
))
|
||||
with _no_transitive():
|
||||
view = build_admin_view(include_transitive=False)
|
||||
stats = next(n for n in view["nodes"] if not n["is_self"])["stats"]
|
||||
assert stats["cases_24h"] == 4
|
||||
assert stats["iocs_24h"] == 4
|
||||
sev = stats["severity_breakdown"]
|
||||
assert sev == {"critical": 2, "high": 1, "medium": 0, "low": 1}
|
||||
ioc_t = stats["ioc_type_breakdown"]
|
||||
assert ioc_t == {"url": 2, "domain": 1, "ip": 1, "hash": 0, "cve": 0}
|
||||
|
||||
|
||||
def test_admin_view_vouch_in_out_counts(fresh_db, fed_dir):
|
||||
"""vouches_in_count counts vouches naming this peer; out counts what they've issued."""
|
||||
fp_a, pem_a = _make_peer_pubkey()
|
||||
fp_b, pem_b = _make_peer_pubkey()
|
||||
federation.register_peer("a.example", fp_a, pem_a, status="trusted")
|
||||
federation.register_peer("b.example", fp_b, pem_b, status="trusted")
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
# A vouches for B; we vouch for B too — B sees vouches_in=2.
|
||||
db.upsert_vouch(dict(
|
||||
voucher_fingerprint=fp_a,
|
||||
target_fingerprint=fp_b,
|
||||
issued_at=now, expires_at=None, signature="x",
|
||||
))
|
||||
federation.issue_vouch(fp_b, ttl_days=30)
|
||||
# B vouches for A — A sees vouches_in=1, B sees vouches_out=1.
|
||||
db.upsert_vouch(dict(
|
||||
voucher_fingerprint=fp_b,
|
||||
target_fingerprint=fp_a,
|
||||
issued_at=now, expires_at=None, signature="y",
|
||||
))
|
||||
with _no_transitive():
|
||||
view = build_admin_view(include_transitive=False)
|
||||
by_fp = {n["fingerprint"]: n for n in view["nodes"] if not n["is_self"]}
|
||||
assert by_fp[fp_a]["stats"]["vouches_in_count"] == 1
|
||||
assert by_fp[fp_a]["stats"]["vouches_out_count"] == 1 # vouches for B
|
||||
assert by_fp[fp_b]["stats"]["vouches_in_count"] == 2 # A + us
|
||||
assert by_fp[fp_b]["stats"]["vouches_out_count"] == 1 # vouches for A
|
||||
|
||||
|
||||
def test_admin_view_corroborated_signals(fresh_db, fed_dir):
|
||||
"""Pairs of peers reporting the same signal_hash → corroborated entry + edge."""
|
||||
fp_a, pem_a = _make_peer_pubkey()
|
||||
fp_b, pem_b = _make_peer_pubkey()
|
||||
federation.register_peer("a.example", fp_a, pem_a, status="trusted")
|
||||
federation.register_peer("b.example", fp_b, pem_b, status="trusted")
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
for peer_fp in (fp_a, fp_b):
|
||||
db.record_signal(dict(
|
||||
peer_fingerprint=peer_fp,
|
||||
signal_type="ioc",
|
||||
signal_id="evil.com",
|
||||
signal_hash="shared-hash-1",
|
||||
received_at=now_iso,
|
||||
raw_json="{}",
|
||||
))
|
||||
# A also reports a hash B doesn't — should NOT corroborate.
|
||||
db.record_signal(dict(
|
||||
peer_fingerprint=fp_a,
|
||||
signal_type="ioc",
|
||||
signal_id="solo.com",
|
||||
signal_hash="solo-hash",
|
||||
received_at=now_iso,
|
||||
raw_json="{}",
|
||||
))
|
||||
with _no_transitive():
|
||||
view = build_admin_view(include_transitive=False)
|
||||
corr = view["stats"]["corroborated_signals"]
|
||||
hashes = {c["signal_hash"] for c in corr}
|
||||
assert "shared-hash-1" in hashes
|
||||
assert "solo-hash" not in hashes
|
||||
shared = next(c for c in corr if c["signal_hash"] == "shared-hash-1")
|
||||
assert set(shared["peer_fingerprints"]) == {fp_a, fp_b}
|
||||
assert shared["peer_count"] == 2
|
||||
|
||||
# One corroborate edge between the pair (orientation-independent).
|
||||
corr_edges = [e for e in view["edges"] if e["kind"] == "corroborate"]
|
||||
assert len(corr_edges) == 1
|
||||
pair = {corr_edges[0]["source_fingerprint"], corr_edges[0]["target_fingerprint"]}
|
||||
assert pair == {fp_a, fp_b}
|
||||
assert corr_edges[0]["weight"] == 1.0
|
||||
|
||||
|
||||
def test_admin_view_signal_timeline_24h_returns_24_buckets(fresh_db, fed_dir):
|
||||
"""signal_timeline_24h is a 24-bucket list with correct totals."""
|
||||
fp, pem = _make_peer_pubkey()
|
||||
federation.register_peer("peer.example", fp, pem, status="trusted")
|
||||
now = datetime.now(timezone.utc)
|
||||
# Two signals one hour ago, three signals five hours ago.
|
||||
one_h = (now - timedelta(hours=1, minutes=5)).isoformat()
|
||||
five_h = (now - timedelta(hours=5, minutes=5)).isoformat()
|
||||
for i in range(2):
|
||||
db.record_signal(dict(
|
||||
peer_fingerprint=fp,
|
||||
signal_type="ioc",
|
||||
signal_id=f"a{i}",
|
||||
signal_hash=f"h-a-{i}",
|
||||
received_at=one_h,
|
||||
raw_json="{}",
|
||||
))
|
||||
for i in range(3):
|
||||
db.record_signal(dict(
|
||||
peer_fingerprint=fp,
|
||||
signal_type="ioc",
|
||||
signal_id=f"b{i}",
|
||||
signal_hash=f"h-b-{i}",
|
||||
received_at=five_h,
|
||||
raw_json="{}",
|
||||
))
|
||||
# Stale signal — must NOT show up.
|
||||
db.record_signal(dict(
|
||||
peer_fingerprint=fp,
|
||||
signal_type="ioc",
|
||||
signal_id="stale",
|
||||
signal_hash="stale-hash",
|
||||
received_at=(now - timedelta(hours=48)).isoformat(),
|
||||
raw_json="{}",
|
||||
))
|
||||
with _no_transitive():
|
||||
view = build_admin_view(include_transitive=False)
|
||||
buckets = view["stats"]["signal_timeline_24h"]
|
||||
assert isinstance(buckets, list)
|
||||
assert len(buckets) == 24
|
||||
totals = [b["total"] for b in buckets]
|
||||
assert sum(totals) == 5 # stale excluded
|
||||
# Bucket hour_offsets are 0..23 in oldest-first order.
|
||||
assert [b["hour_offset"] for b in buckets] == list(range(24))
|
||||
|
||||
|
||||
def test_admin_view_quorum_contribution(fresh_db, fed_dir):
|
||||
"""quorum_contribution counts this peer's distinct hashes that are quorum-met."""
|
||||
fp_a, pem_a = _make_peer_pubkey()
|
||||
fp_b, pem_b = _make_peer_pubkey()
|
||||
federation.register_peer("a.example", fp_a, pem_a, status="trusted")
|
||||
federation.register_peer("b.example", fp_b, pem_b, status="trusted")
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
# Shared hash → both peers report it → quorum-met (default k=2).
|
||||
for peer_fp in (fp_a, fp_b):
|
||||
db.record_signal(dict(
|
||||
peer_fingerprint=peer_fp,
|
||||
signal_type="ioc",
|
||||
signal_id="shared",
|
||||
signal_hash="quorum-hash",
|
||||
received_at=now_iso,
|
||||
raw_json="{}",
|
||||
))
|
||||
# Solo hash from A → not quorum-met.
|
||||
db.record_signal(dict(
|
||||
peer_fingerprint=fp_a,
|
||||
signal_type="ioc",
|
||||
signal_id="solo",
|
||||
signal_hash="solo-hash",
|
||||
received_at=now_iso,
|
||||
raw_json="{}",
|
||||
))
|
||||
with _no_transitive():
|
||||
view = build_admin_view(include_transitive=False)
|
||||
by_fp = {n["fingerprint"]: n for n in view["nodes"] if not n["is_self"]}
|
||||
assert by_fp[fp_a]["stats"]["quorum_contribution"] == 1
|
||||
assert by_fp[fp_b]["stats"]["quorum_contribution"] == 1
|
||||
|
||||
|
||||
def test_admin_view_recent_translog_per_peer(fresh_db, fed_dir):
|
||||
"""recent_translog lists entries where entry_data.peer_fingerprint matches."""
|
||||
fp_a, pem_a = _make_peer_pubkey()
|
||||
fp_b, pem_b = _make_peer_pubkey()
|
||||
federation.register_peer("a.example", fp_a, pem_a, status="trusted")
|
||||
federation.register_peer("b.example", fp_b, pem_b, status="trusted")
|
||||
# Append translog rows that name each peer.
|
||||
translog.append("signal", {"peer_fingerprint": fp_a, "signal_type": "ioc", "signal_id": "x"})
|
||||
translog.append("signal", {"peer_fingerprint": fp_b, "signal_type": "case", "signal_id": "y"})
|
||||
translog.append("signal", {"peer_fingerprint": fp_a, "signal_type": "case", "signal_id": "z"})
|
||||
with _no_transitive():
|
||||
view = build_admin_view(include_transitive=False)
|
||||
by_fp = {n["fingerprint"]: n for n in view["nodes"] if not n["is_self"]}
|
||||
a_log = by_fp[fp_a]["stats"]["recent_translog"]
|
||||
b_log = by_fp[fp_b]["stats"]["recent_translog"]
|
||||
assert len(a_log) == 2
|
||||
assert len(b_log) == 1
|
||||
# Each row carries the documented shape.
|
||||
for row in a_log + b_log:
|
||||
assert set(row.keys()) == {"id", "entry_type", "timestamp", "hash"}
|
||||
|
||||
|
||||
def test_explore_view_omits_ioc_values_case_ids_and_raw_json(fresh_db, fed_dir):
|
||||
"""The public explore payload must NEVER expose IOC values, case_ids, or raw_json.
|
||||
|
||||
This is the load-bearing transparency-vs-leakage contract that lives at
|
||||
the network-view layer — anyone can audit who's talking to whom and how
|
||||
much, but never *what* they're saying.
|
||||
"""
|
||||
fp, pem = _make_peer_pubkey()
|
||||
federation.register_peer("trusted.example", fp, pem, status="trusted")
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
db.record_signal(dict(
|
||||
peer_fingerprint=fp,
|
||||
signal_type="ioc",
|
||||
signal_id="evil-domain-do-not-leak.com",
|
||||
signal_hash="ioc-hash-leak",
|
||||
received_at=now_iso,
|
||||
raw_json=json.dumps({"type": "domain", "value": "evil-domain-do-not-leak.com"}),
|
||||
))
|
||||
db.record_signal(dict(
|
||||
peer_fingerprint=fp,
|
||||
signal_type="case",
|
||||
signal_id="CASE-SECRET-42",
|
||||
signal_hash="case-hash-leak",
|
||||
received_at=now_iso,
|
||||
raw_json=json.dumps({"severity": "critical", "case_id": "CASE-SECRET-42"}),
|
||||
))
|
||||
with patch.object(network_view, "_fetch_peer_explore", return_value=None), \
|
||||
patch.object(network_view, "_fetch_peer_network", return_value=None):
|
||||
payload = build_explore_view()
|
||||
flat = json.dumps(payload, default=str)
|
||||
assert "evil-domain-do-not-leak.com" not in flat
|
||||
assert "CASE-SECRET-42" not in flat
|
||||
assert "raw_json" not in flat
|
||||
# Sector-leaking breakdowns must not appear either.
|
||||
assert "severity_breakdown" not in flat
|
||||
assert "ioc_type_breakdown" not in flat
|
||||
# And peer rows carry only public-safe counts.
|
||||
for p in payload.get("peers", []):
|
||||
assert "severity_breakdown" not in p
|
||||
assert "ioc_type_breakdown" not in p
|
||||
assert "recent_translog" not in p
|
||||
|
||||
|
||||
def test_public_view_still_has_no_stats(fresh_db, fed_dir):
|
||||
"""Public payload must not surface admin-only enrichments — sensitive.
|
||||
|
||||
Even after `build_admin_view` has been invoked (which mutates node.stats
|
||||
on the cached transitive view), the public view path must stay clean.
|
||||
"""
|
||||
fp, pem = _make_peer_pubkey()
|
||||
federation.register_peer("trusted.example", fp, pem, status="trusted")
|
||||
# Seed signals + corroborated hash so admin view has rich state.
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
db.record_signal(dict(
|
||||
peer_fingerprint=fp,
|
||||
signal_type="ioc",
|
||||
signal_id="leak",
|
||||
signal_hash="leak-hash",
|
||||
received_at=now_iso,
|
||||
raw_json=json.dumps({"type": "url", "value": "https://leak"}),
|
||||
))
|
||||
# Build admin view first so any caching kicks in.
|
||||
with _no_transitive():
|
||||
build_admin_view(include_transitive=False)
|
||||
# Now build the public view and assert no admin-only fields leak.
|
||||
payload = build_public_view()
|
||||
flat = json.dumps(payload, default=str)
|
||||
assert "signals_24h" not in flat
|
||||
assert "severity_breakdown" not in flat
|
||||
assert "corroborated_signals" not in flat
|
||||
assert "signal_timeline_24h" not in flat
|
||||
assert "recent_translog" not in flat
|
||||
assert "leak-hash" not in flat
|
||||
# Peer entries in the public view never carry a `stats` field.
|
||||
for p in payload.get("peers", []):
|
||||
assert "stats" not in p
|
||||
314
tests/test_pulse_respond.py
Normal file
314
tests/test_pulse_respond.py
Normal file
@@ -0,0 +1,314 @@
|
||||
"""Pulseline auto-response gating — severity threshold, quorum, local-only.
|
||||
|
||||
The runner here is the live `_run_respond` from pulse.py. We point it at a
|
||||
temp DB, monkeypatch federation.is_quorum_met to a controllable function, and
|
||||
swap respond.execute_action for a counter so we don't reach the SOAR sink.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Tuple
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
|
||||
from psyc import db
|
||||
from psyc.lines import pulse, respond
|
||||
from psyc.lines import federation
|
||||
from psyc.models import (
|
||||
ActionStatus,
|
||||
ActionType,
|
||||
Case,
|
||||
Classification,
|
||||
Observables,
|
||||
ResponseAction,
|
||||
Severity,
|
||||
TLP,
|
||||
)
|
||||
from psyc.result import Ok
|
||||
|
||||
from conftest import make_case
|
||||
|
||||
|
||||
# ----- fixtures --------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def fresh_db(tmp_path, monkeypatch):
|
||||
"""Temp SQLite + the real runner registry. Mode pinned to auto-execute."""
|
||||
test_db = tmp_path / "respond.db"
|
||||
eng = create_engine(f"sqlite:///{test_db}", future=True)
|
||||
db._metadata.create_all(eng, checkfirst=True)
|
||||
monkeypatch.setattr(db, "_engine", eng)
|
||||
monkeypatch.setattr(db, "DB_PATH", test_db)
|
||||
yield test_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fired(monkeypatch):
|
||||
"""Capture every execute_action(action_id, approver=...) — no SOAR sink call."""
|
||||
log: List[Tuple[int, str]] = []
|
||||
|
||||
def fake_execute(action_id: int, approver: str = "operator"):
|
||||
log.append((action_id, approver))
|
||||
# Re-read the action so we can return a realistic Ok value
|
||||
got = respond.get_action(action_id)
|
||||
return got if isinstance(got, Ok) else got
|
||||
|
||||
monkeypatch.setattr(respond, "execute_action", fake_execute)
|
||||
return log
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def quorum_yes(monkeypatch):
|
||||
monkeypatch.setattr(federation, "is_quorum_met",
|
||||
lambda h, k=None: True, raising=False)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def quorum_no(monkeypatch):
|
||||
monkeypatch.setattr(federation, "is_quorum_met",
|
||||
lambda h, k=None: False, raising=False)
|
||||
|
||||
|
||||
def _set_respond_mode(mode: pulse.PulseMode) -> None:
|
||||
pulse.set_mode("respond", mode)
|
||||
|
||||
|
||||
def _propose_one(case: Case) -> int:
|
||||
db.upsert_case(case)
|
||||
ids = respond.propose_for_case(case)
|
||||
assert ids, "test setup expected at least one action proposed"
|
||||
return ids[0]
|
||||
|
||||
|
||||
# ----- severity rank ---------------------------------------------------------
|
||||
|
||||
def test_severity_rank_ordering():
|
||||
assert pulse._severity_rank(Severity.LOW) == 0
|
||||
assert pulse._severity_rank(Severity.MEDIUM) == 1
|
||||
assert pulse._severity_rank(Severity.HIGH) == 2
|
||||
assert pulse._severity_rank(Severity.CRITICAL) == 3
|
||||
assert pulse._severity_rank(None) == -1
|
||||
|
||||
|
||||
# ----- runner mode gating ----------------------------------------------------
|
||||
|
||||
def test_runner_no_auto_fire_when_mode_is_propose(fresh_db, fired, quorum_yes):
|
||||
case = make_case(feed="feodo", ips=["9.9.9.9"], severity=Severity.HIGH)
|
||||
db.upsert_case(case)
|
||||
# default seed mode for respond is auto-propose → no auto-fire even with PROPOSED actions
|
||||
result = pulse._run_respond()
|
||||
assert "no auto-fire" in result
|
||||
assert fired == []
|
||||
|
||||
|
||||
def test_runner_no_auto_fire_when_manual(fresh_db, fired, quorum_yes):
|
||||
case = make_case(feed="feodo", ips=["9.9.9.9"], severity=Severity.HIGH)
|
||||
db.upsert_case(case)
|
||||
_set_respond_mode(pulse.PulseMode.MANUAL)
|
||||
result = pulse._run_respond()
|
||||
assert "no auto-fire" in result
|
||||
assert fired == []
|
||||
|
||||
|
||||
# ----- severity threshold ----------------------------------------------------
|
||||
|
||||
def test_below_threshold_is_skipped(fresh_db, fired, quorum_yes):
|
||||
# Propose an action carrying severity=MEDIUM by hand — propose_for_case
|
||||
# only generates HIGH/CRITICAL actions, but the gate must still work for
|
||||
# any below-threshold severity we drop in.
|
||||
case = make_case(feed="feodo", ips=["9.9.9.9"], severity=Severity.HIGH)
|
||||
db.upsert_case(case)
|
||||
respond.propose_for_case(case)
|
||||
# Demote every action's severity to MEDIUM so all should be skipped under HIGH threshold.
|
||||
from sqlalchemy import update as sa_update
|
||||
with db.engine().begin() as conn:
|
||||
conn.execute(sa_update(db.response_actions).values(severity=Severity.MEDIUM.value))
|
||||
pulse.set_respond_auto_threshold(Severity.HIGH)
|
||||
_set_respond_mode(pulse.PulseMode.AUTO_EXECUTE)
|
||||
|
||||
pulse._run_respond()
|
||||
assert fired == [], "below-threshold action must not fire"
|
||||
audit = db.pulse_audit_recent("respond", limit=5)
|
||||
assert any(r["action"] == "skipped" and "below threshold" in (r["detail"] or "") for r in audit)
|
||||
|
||||
|
||||
# ----- quorum gate -----------------------------------------------------------
|
||||
|
||||
def test_federation_case_no_quorum_skipped(fresh_db, fired, quorum_no):
|
||||
case = make_case(feed="urlhaus", ips=["9.9.9.9"], severity=Severity.HIGH)
|
||||
db.upsert_case(case)
|
||||
# Mark this case as federation-sourced by inserting a signal row for it.
|
||||
db.record_signal(dict(
|
||||
peer_fingerprint="peer-a",
|
||||
signal_type="case",
|
||||
signal_id=case.case_id,
|
||||
signal_hash="dummyhash",
|
||||
received_at=datetime.now(timezone.utc).isoformat(),
|
||||
raw_json="{}",
|
||||
))
|
||||
respond.propose_for_case(case)
|
||||
|
||||
pulse.set_respond_require_quorum(True)
|
||||
pulse.set_respond_local_only(False)
|
||||
pulse.set_respond_auto_threshold(Severity.HIGH)
|
||||
_set_respond_mode(pulse.PulseMode.AUTO_EXECUTE)
|
||||
|
||||
pulse._run_respond()
|
||||
assert fired == []
|
||||
audit = db.pulse_audit_recent("respond", limit=5)
|
||||
assert any(r["action"] == "skipped" and "no quorum" in (r["detail"] or "") for r in audit)
|
||||
|
||||
|
||||
def test_local_case_fires_when_quorum_required(fresh_db, fired, quorum_no):
|
||||
"""Locally-generated cases bypass quorum — they're our own work."""
|
||||
case = make_case(feed="urlhaus", ips=["9.9.9.9"], severity=Severity.HIGH)
|
||||
db.upsert_case(case)
|
||||
# No federation_signals row → locally-generated
|
||||
respond.propose_for_case(case)
|
||||
|
||||
pulse.set_respond_require_quorum(True)
|
||||
pulse.set_respond_local_only(True) # both armed; local cases still fire
|
||||
pulse.set_respond_auto_threshold(Severity.HIGH)
|
||||
_set_respond_mode(pulse.PulseMode.AUTO_EXECUTE)
|
||||
|
||||
pulse._run_respond()
|
||||
assert len(fired) >= 1
|
||||
audit = db.pulse_audit_recent("respond", limit=10)
|
||||
assert any(r["action"] == "auto-fire" for r in audit)
|
||||
|
||||
|
||||
def test_local_case_fires_local_only_off(fresh_db, fired, quorum_no):
|
||||
"""Even with local_only OFF, a locally-generated case still fires (no quorum needed)."""
|
||||
case = make_case(feed="urlhaus", ips=["1.1.1.1"], severity=Severity.CRITICAL)
|
||||
db.upsert_case(case)
|
||||
respond.propose_for_case(case)
|
||||
|
||||
pulse.set_respond_require_quorum(True)
|
||||
pulse.set_respond_local_only(False)
|
||||
pulse.set_respond_auto_threshold(Severity.HIGH)
|
||||
_set_respond_mode(pulse.PulseMode.AUTO_EXECUTE)
|
||||
|
||||
pulse._run_respond()
|
||||
assert len(fired) >= 1
|
||||
|
||||
|
||||
def test_federation_case_with_quorum_fires(fresh_db, fired, quorum_yes):
|
||||
case = make_case(feed="urlhaus", ips=["2.2.2.2"], severity=Severity.HIGH)
|
||||
db.upsert_case(case)
|
||||
db.record_signal(dict(
|
||||
peer_fingerprint="peer-b",
|
||||
signal_type="case",
|
||||
signal_id=case.case_id,
|
||||
signal_hash="dummyhash2",
|
||||
received_at=datetime.now(timezone.utc).isoformat(),
|
||||
raw_json="{}",
|
||||
))
|
||||
respond.propose_for_case(case)
|
||||
|
||||
pulse.set_respond_require_quorum(True)
|
||||
pulse.set_respond_local_only(False)
|
||||
pulse.set_respond_auto_threshold(Severity.HIGH)
|
||||
_set_respond_mode(pulse.PulseMode.AUTO_EXECUTE)
|
||||
|
||||
pulse._run_respond()
|
||||
assert len(fired) >= 1
|
||||
|
||||
|
||||
def test_quorum_off_fires_federation_case(fresh_db, fired, quorum_no):
|
||||
"""With quorum gating disabled entirely, federation cases fire too."""
|
||||
case = make_case(feed="urlhaus", ips=["3.3.3.3"], severity=Severity.HIGH)
|
||||
db.upsert_case(case)
|
||||
db.record_signal(dict(
|
||||
peer_fingerprint="peer-c",
|
||||
signal_type="case",
|
||||
signal_id=case.case_id,
|
||||
signal_hash="dummyhash3",
|
||||
received_at=datetime.now(timezone.utc).isoformat(),
|
||||
raw_json="{}",
|
||||
))
|
||||
respond.propose_for_case(case)
|
||||
|
||||
pulse.set_respond_require_quorum(False)
|
||||
pulse.set_respond_auto_threshold(Severity.HIGH)
|
||||
_set_respond_mode(pulse.PulseMode.AUTO_EXECUTE)
|
||||
|
||||
pulse._run_respond()
|
||||
assert len(fired) >= 1
|
||||
|
||||
|
||||
# ----- kill switch -----------------------------------------------------------
|
||||
|
||||
def test_kill_switch_blocks_tick(fresh_db, fired, quorum_yes):
|
||||
"""The parent tick() skips everything when kill switch is armed."""
|
||||
case = make_case(feed="feodo", ips=["9.9.9.9"], severity=Severity.HIGH)
|
||||
db.upsert_case(case)
|
||||
respond.propose_for_case(case)
|
||||
_set_respond_mode(pulse.PulseMode.AUTO_EXECUTE)
|
||||
pulse.set_kill_switch(True)
|
||||
results = pulse.tick()
|
||||
assert all(o == "skipped" for _, o, _ in results)
|
||||
assert fired == []
|
||||
|
||||
|
||||
# ----- audit -----------------------------------------------------------------
|
||||
|
||||
def test_pulse_audit_records_fire_and_skip(fresh_db, fired, quorum_no):
|
||||
# Local case → should fire and audit auto-fire
|
||||
local = make_case(feed="urlhaus", ips=["10.0.0.1"], severity=Severity.HIGH, age_days=1)
|
||||
db.upsert_case(local)
|
||||
respond.propose_for_case(local)
|
||||
|
||||
# Federation-sourced case w/o quorum → should skip and audit skip
|
||||
fedcase = make_case(feed="urlhaus", ips=["10.0.0.2"], severity=Severity.HIGH, age_days=2)
|
||||
db.upsert_case(fedcase)
|
||||
db.record_signal(dict(
|
||||
peer_fingerprint="peer-x",
|
||||
signal_type="case",
|
||||
signal_id=fedcase.case_id,
|
||||
signal_hash="xhash",
|
||||
received_at=datetime.now(timezone.utc).isoformat(),
|
||||
raw_json="{}",
|
||||
))
|
||||
respond.propose_for_case(fedcase)
|
||||
|
||||
pulse.set_respond_require_quorum(True)
|
||||
pulse.set_respond_local_only(False)
|
||||
pulse.set_respond_auto_threshold(Severity.HIGH)
|
||||
_set_respond_mode(pulse.PulseMode.AUTO_EXECUTE)
|
||||
pulse._run_respond()
|
||||
|
||||
audit = db.pulse_audit_recent("respond", limit=20)
|
||||
actions = {r["action"] for r in audit}
|
||||
assert "auto-fire" in actions
|
||||
assert "skipped" in actions
|
||||
|
||||
|
||||
def test_audit_count_since(fresh_db, fired, quorum_no):
|
||||
case = make_case(feed="urlhaus", ips=["8.8.8.8"], severity=Severity.HIGH)
|
||||
db.upsert_case(case)
|
||||
respond.propose_for_case(case)
|
||||
pulse.set_respond_require_quorum(True)
|
||||
pulse.set_respond_auto_threshold(Severity.HIGH)
|
||||
_set_respond_mode(pulse.PulseMode.AUTO_EXECUTE)
|
||||
pulse._run_respond()
|
||||
from datetime import timedelta
|
||||
since = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
|
||||
assert db.pulse_audit_count_since("respond", "auto-fire", since) >= 1
|
||||
|
||||
|
||||
# ----- config round-trip -----------------------------------------------------
|
||||
|
||||
def test_config_round_trips(fresh_db):
|
||||
assert pulse.respond_auto_threshold() == Severity.HIGH
|
||||
assert pulse.respond_require_quorum() is True
|
||||
assert pulse.respond_local_only() is False
|
||||
|
||||
pulse.set_respond_auto_threshold(Severity.CRITICAL)
|
||||
pulse.set_respond_require_quorum(False)
|
||||
pulse.set_respond_local_only(True)
|
||||
|
||||
assert pulse.respond_auto_threshold() == Severity.CRITICAL
|
||||
assert pulse.respond_require_quorum() is False
|
||||
assert pulse.respond_local_only() is True
|
||||
313
tests/test_topology_export.py
Normal file
313
tests/test_topology_export.py
Normal file
@@ -0,0 +1,313 @@
|
||||
"""Topology export — whitelist sanitization + endpoint contract.
|
||||
|
||||
The big invariant: nothing from docker_view.topology() escapes that isn't
|
||||
in the Pydantic schema. We assert via model_fields introspection AND via a
|
||||
JSON-dump scan over a fixture that contains every dangerous field.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, List
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from starlette.middleware.sessions import SessionMiddleware
|
||||
|
||||
from psyc import db
|
||||
from psyc.cockpit import docker_view, federation_routes
|
||||
from psyc.lines import federation, topology_export
|
||||
from psyc.lines.topology_export import (
|
||||
TopologyContainer,
|
||||
TopologyExport,
|
||||
TopologyNetwork,
|
||||
_filter_image_name,
|
||||
build_export,
|
||||
)
|
||||
|
||||
|
||||
# ---------- fixtures ----------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def fresh_db(tmp_path, monkeypatch):
|
||||
test_db = tmp_path / "test.db"
|
||||
eng = create_engine(f"sqlite:///{test_db}", future=True)
|
||||
db._metadata.create_all(eng, checkfirst=True)
|
||||
monkeypatch.setattr(db, "_engine", eng)
|
||||
monkeypatch.setattr(db, "DB_PATH", test_db)
|
||||
yield test_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fed_dir(tmp_path, monkeypatch):
|
||||
d = tmp_path / "federation"
|
||||
monkeypatch.setattr(federation, "FED_DIR", d)
|
||||
monkeypatch.setattr(federation, "PRIVATE_KEY_PATH", d / "node.key")
|
||||
monkeypatch.setattr(federation, "PUBLIC_KEY_PATH", d / "node.pub")
|
||||
yield d
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_topology_cache():
|
||||
if hasattr(federation_routes, "_TOPOLOGY_CACHE"):
|
||||
federation_routes._TOPOLOGY_CACHE["payload"] = None
|
||||
federation_routes._TOPOLOGY_CACHE["ts"] = 0.0
|
||||
yield
|
||||
|
||||
|
||||
# ---------- fixture data: hostile docker_view output --------------------
|
||||
|
||||
# This payload has every leaky field docker_view *could* surface, plus
|
||||
# nested env-style data — used to prove the export is whitelist-only.
|
||||
_LEAKY_TOPOLOGY: Dict[str, Any] = {
|
||||
"containers": [
|
||||
{
|
||||
"id": "abcdef1234567890ffff",
|
||||
"name": "psyc-cockpit-1",
|
||||
"image": "registry.example/psyc:1.2",
|
||||
"state": "running",
|
||||
"status": "Up 5 minutes (healthy)",
|
||||
"networks": [
|
||||
{"name": "backend", "ip": "172.20.0.5", "gateway": "172.20.0.1", "mac": "02:42:ac:14:00:05"},
|
||||
{"name": "frontend", "ip": "172.21.0.7", "gateway": "172.21.0.1", "mac": "02:42:ac:15:00:07"},
|
||||
],
|
||||
"ports": ["0.0.0.0:8767->8767/tcp"],
|
||||
"published_ports": ["8767/tcp"],
|
||||
# These are NOT current docker_view fields but defend in depth —
|
||||
# if a future docker_view change adds them, sanitizer drops them.
|
||||
"env": ["SECRET_TOKEN=abc123", "DB_PASSWORD=hunter2"],
|
||||
"mounts": ["/var/run/docker.sock", "/etc/secrets:/secrets"],
|
||||
"labels": {"com.docker.compose.project": "psyc", "secret_label": "shh"},
|
||||
},
|
||||
{
|
||||
"id": "fedcba0987654321",
|
||||
"name": "some-stopped",
|
||||
"image": "alpine",
|
||||
"state": "exited",
|
||||
"status": "Exited (0) 2 hours ago",
|
||||
"networks": [],
|
||||
"ports": [],
|
||||
"published_ports": [],
|
||||
},
|
||||
],
|
||||
"networks": [
|
||||
{
|
||||
"id": "n1", "name": "backend", "driver": "bridge", "scope": "local",
|
||||
"internal": False, "subnet": "172.20.0.0/16", "gateway": "172.20.0.1",
|
||||
"containers": [
|
||||
{"id": "abcdef123456", "name": "psyc-cockpit-1", "ip": "172.20.0.5", "mac": "02:42:ac:14:00:05"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "n2", "name": "internal-only", "driver": "bridge", "scope": "local",
|
||||
"internal": True, "subnet": "10.99.0.0/16", "gateway": "10.99.0.1",
|
||||
"containers": [],
|
||||
},
|
||||
],
|
||||
"host": {"name": "docker-host-secret-internal.example.com", "os": "linux", "ncpu": 8},
|
||||
"error": None,
|
||||
"proxy": "http://docker-socket-proxy:2375",
|
||||
}
|
||||
|
||||
|
||||
# Sensitive strings that MUST NOT appear anywhere in the export JSON.
|
||||
_FORBIDDEN_STRINGS = (
|
||||
"SECRET_TOKEN", "DB_PASSWORD", "hunter2", "abc123",
|
||||
"/var/run/docker.sock", "/etc/secrets",
|
||||
"secret_label", "shh",
|
||||
"172.20.0.5", "172.21.0.7", # IPs
|
||||
"02:42:ac", # MAC prefix
|
||||
"172.20.0.1", # gateway
|
||||
"172.20.0.0/16", "10.99.0.0/16", # subnets
|
||||
"0.0.0.0:8767", # port mapping
|
||||
"internal.example.com", # full host
|
||||
)
|
||||
|
||||
|
||||
# ---------- model field introspection -----------------------------------
|
||||
|
||||
def test_container_model_has_no_dangerous_fields():
|
||||
fields = set(TopologyContainer.model_fields.keys())
|
||||
# whitelist — must match the design contract exactly
|
||||
assert fields == {
|
||||
"name", "short_id", "image", "state", "health",
|
||||
"networks", "service", "started_at",
|
||||
}
|
||||
# explicit deny-list, double-belt
|
||||
for forbidden in ("env", "environment", "mounts", "volumes",
|
||||
"labels", "ip", "ip_address", "ipaddress",
|
||||
"ports", "published_ports", "mac", "gateway"):
|
||||
assert forbidden not in fields, f"{forbidden} must not be a field"
|
||||
|
||||
|
||||
def test_network_model_has_no_dangerous_fields():
|
||||
fields = set(TopologyNetwork.model_fields.keys())
|
||||
assert fields == {"name", "driver", "internal", "container_count"}
|
||||
for forbidden in ("subnet", "gateway", "labels", "ipam",
|
||||
"containers", "scope", "id"):
|
||||
assert forbidden not in fields, f"{forbidden} must not be a field"
|
||||
|
||||
|
||||
def test_export_model_top_level_fields():
|
||||
fields = set(TopologyExport.model_fields.keys())
|
||||
assert fields == {
|
||||
"node_fingerprint", "generated_at", "host_name",
|
||||
"container_count", "network_count", "containers", "networks",
|
||||
}
|
||||
|
||||
|
||||
# ---------- image-name filter -------------------------------------------
|
||||
|
||||
def test_filter_image_strips_basic_auth_prefix():
|
||||
# user:pass@host/repo:tag → host/repo:tag (creds gone)
|
||||
assert _filter_image_name("user:pass@host/repo:tag") == "host/repo:tag"
|
||||
|
||||
|
||||
def test_filter_image_drops_digest_suffix():
|
||||
assert _filter_image_name(
|
||||
"nginx:1.25@sha256:abcdef0123"
|
||||
) == "nginx:1.25"
|
||||
|
||||
|
||||
def test_filter_image_passes_clean_refs_untouched():
|
||||
assert _filter_image_name("psyc:latest") == "psyc:latest"
|
||||
assert _filter_image_name(
|
||||
"ghcr.io/example/psyc:v0.3.1"
|
||||
) == "ghcr.io/example/psyc:v0.3.1"
|
||||
|
||||
|
||||
def test_filter_image_handles_empty():
|
||||
assert _filter_image_name("") == ""
|
||||
assert _filter_image_name(None) == "" # type: ignore[arg-type]
|
||||
|
||||
|
||||
# ---------- build_export contract ---------------------------------------
|
||||
|
||||
def test_build_export_returns_empty_when_docker_view_raises(fresh_db, fed_dir, monkeypatch):
|
||||
def boom():
|
||||
raise docker_view.DockerProxyError("connection refused")
|
||||
monkeypatch.setattr(docker_view, "topology", boom)
|
||||
out = build_export()
|
||||
assert isinstance(out, TopologyExport)
|
||||
assert out.container_count == 0
|
||||
assert out.containers == []
|
||||
assert out.networks == []
|
||||
# fingerprint is still real (federation key was generated)
|
||||
assert len(out.node_fingerprint) == 32
|
||||
|
||||
|
||||
def test_build_export_returns_empty_when_docker_view_reports_error(fresh_db, fed_dir, monkeypatch):
|
||||
monkeypatch.setattr(docker_view, "topology", lambda: {
|
||||
"containers": [], "networks": [], "host": {"name": "x"},
|
||||
"error": "containers: refused", "proxy": "x",
|
||||
})
|
||||
out = build_export()
|
||||
assert out.container_count == 0
|
||||
assert out.containers == []
|
||||
|
||||
|
||||
def test_build_export_sanitizes_every_field(fresh_db, fed_dir, monkeypatch):
|
||||
monkeypatch.setattr(docker_view, "topology", lambda: _LEAKY_TOPOLOGY)
|
||||
out = build_export()
|
||||
# Containers came through, but as TopologyContainer (no leaky attrs).
|
||||
assert out.container_count == 2
|
||||
by_name = {c.name: c for c in out.containers}
|
||||
cp = by_name["psyc-cockpit-1"]
|
||||
assert cp.short_id == "abcdef123456"
|
||||
assert cp.image == "registry.example/psyc:1.2"
|
||||
assert cp.state == "running"
|
||||
assert cp.health == "healthy"
|
||||
assert cp.networks == ["backend", "frontend"]
|
||||
assert cp.service is None
|
||||
# Networks came through, sanitized.
|
||||
assert out.network_count == 2
|
||||
by_net = {n.name: n for n in out.networks}
|
||||
assert by_net["backend"].driver == "bridge"
|
||||
assert by_net["backend"].internal is False
|
||||
assert by_net["backend"].container_count == 1
|
||||
assert by_net["internal-only"].internal is True
|
||||
|
||||
|
||||
def test_export_json_contains_no_dangerous_strings(fresh_db, fed_dir, monkeypatch):
|
||||
"""Strict no-leak: serialize and grep for everything sensitive."""
|
||||
monkeypatch.setattr(docker_view, "topology", lambda: _LEAKY_TOPOLOGY)
|
||||
out = build_export()
|
||||
blob = json.dumps(out.model_dump(mode="json"))
|
||||
for forbidden in _FORBIDDEN_STRINGS:
|
||||
assert forbidden not in blob, f"leak: {forbidden!r} appeared in export JSON"
|
||||
|
||||
|
||||
def test_build_export_caps_at_max_containers(fresh_db, fed_dir, monkeypatch):
|
||||
fake = {
|
||||
"containers": [
|
||||
{"id": f"id{i:04d}", "name": f"c{i}", "image": "x", "state": "running", "status": "Up", "networks": []}
|
||||
for i in range(topology_export.MAX_CONTAINERS + 50)
|
||||
],
|
||||
"networks": [], "host": {"name": "h"}, "error": None, "proxy": "",
|
||||
}
|
||||
monkeypatch.setattr(docker_view, "topology", lambda: fake)
|
||||
out = build_export()
|
||||
assert out.container_count == topology_export.MAX_CONTAINERS
|
||||
|
||||
|
||||
# ---------- HTTP endpoint -----------------------------------------------
|
||||
|
||||
def _mk_app() -> FastAPI:
|
||||
app = FastAPI()
|
||||
app.add_middleware(SessionMiddleware, secret_key="test-secret")
|
||||
from pathlib import Path as _Path
|
||||
here = _Path(__file__).resolve().parent.parent / "src" / "psyc" / "cockpit" / "templates"
|
||||
templates = Jinja2Templates(directory=str(here))
|
||||
federation_routes.register(app, templates)
|
||||
return app
|
||||
|
||||
|
||||
def test_federation_topology_endpoint_returns_json_with_cors(fresh_db, fed_dir, monkeypatch):
|
||||
monkeypatch.setattr(docker_view, "topology", lambda: _LEAKY_TOPOLOGY)
|
||||
client = TestClient(_mk_app())
|
||||
r = client.get("/federation/topology")
|
||||
assert r.status_code == 200
|
||||
assert r.headers.get("access-control-allow-origin") == "*"
|
||||
data = r.json()
|
||||
# Schema check.
|
||||
for key in ("node_fingerprint", "generated_at", "host_name",
|
||||
"container_count", "network_count", "containers", "networks"):
|
||||
assert key in data
|
||||
assert data["container_count"] == 2
|
||||
assert len(data["containers"]) == 2
|
||||
# No leaks in the wire response either.
|
||||
blob = r.text
|
||||
for forbidden in _FORBIDDEN_STRINGS:
|
||||
assert forbidden not in blob, f"leak via endpoint: {forbidden!r}"
|
||||
|
||||
|
||||
def test_federation_topology_endpoint_resilient_when_docker_unavailable(fresh_db, fed_dir, monkeypatch):
|
||||
def boom():
|
||||
raise docker_view.DockerProxyError("proxy down")
|
||||
monkeypatch.setattr(docker_view, "topology", boom)
|
||||
client = TestClient(_mk_app())
|
||||
r = client.get("/federation/topology")
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["container_count"] == 0
|
||||
assert data["containers"] == []
|
||||
|
||||
|
||||
def test_federation_topology_cache_short_circuits_repeated_calls(fresh_db, fed_dir, monkeypatch):
|
||||
"""Within TTL, a second hit must not re-call docker_view."""
|
||||
calls = {"n": 0}
|
||||
|
||||
def counted():
|
||||
calls["n"] += 1
|
||||
return _LEAKY_TOPOLOGY
|
||||
|
||||
monkeypatch.setattr(docker_view, "topology", counted)
|
||||
client = TestClient(_mk_app())
|
||||
r1 = client.get("/federation/topology")
|
||||
r2 = client.get("/federation/topology")
|
||||
assert r1.status_code == 200 and r2.status_code == 200
|
||||
assert calls["n"] == 1, "cache should suppress the second docker_view call"
|
||||
118
tests/test_translog.py
Normal file
118
tests/test_translog.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""Transparency log — append, verify, tamper detection, sync slices."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine, update
|
||||
|
||||
from psyc import db
|
||||
from psyc.lines import translog
|
||||
from psyc.lines.translog import GENESIS_PREV_HASH
|
||||
from psyc.result import Err, Ok
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fresh_db(tmp_path, monkeypatch):
|
||||
test_db = tmp_path / "test.db"
|
||||
eng = create_engine(f"sqlite:///{test_db}", future=True)
|
||||
db._metadata.create_all(eng, checkfirst=True)
|
||||
monkeypatch.setattr(db, "_engine", eng)
|
||||
monkeypatch.setattr(db, "DB_PATH", test_db)
|
||||
yield test_db
|
||||
|
||||
|
||||
def test_first_append_uses_genesis_prev_hash(fresh_db):
|
||||
e = translog.append("signal", {"x": 1})
|
||||
assert e.prev_hash == GENESIS_PREV_HASH
|
||||
assert e.id >= 1
|
||||
assert e.entry_type == "signal"
|
||||
assert e.entry_data == {"x": 1}
|
||||
# head matches
|
||||
h = translog.head()
|
||||
assert h is not None
|
||||
assert h.id == e.id
|
||||
assert h.entry_hash == e.entry_hash
|
||||
|
||||
|
||||
def test_append_chains_prev_hash(fresh_db):
|
||||
e1 = translog.append("signal", {"a": 1})
|
||||
e2 = translog.append("signal", {"b": 2})
|
||||
e3 = translog.append("vouch", {"c": 3})
|
||||
assert e2.prev_hash == e1.entry_hash
|
||||
assert e3.prev_hash == e2.entry_hash
|
||||
head = translog.head()
|
||||
assert head is not None
|
||||
assert head.entry_hash == e3.entry_hash
|
||||
|
||||
|
||||
def test_verify_chain_ok_round_trip(fresh_db):
|
||||
translog.append("signal", {"a": 1})
|
||||
translog.append("signal", {"b": 2})
|
||||
translog.append("vouch", {"c": 3})
|
||||
result = translog.verify_chain()
|
||||
assert isinstance(result, Ok)
|
||||
assert result.value == 3
|
||||
|
||||
|
||||
def test_verify_chain_empty_returns_zero(fresh_db):
|
||||
result = translog.verify_chain()
|
||||
assert isinstance(result, Ok)
|
||||
assert result.value == 0
|
||||
|
||||
|
||||
def test_verify_chain_detects_tampered_data(fresh_db):
|
||||
e1 = translog.append("signal", {"a": 1})
|
||||
e2 = translog.append("signal", {"b": 2})
|
||||
|
||||
# Mutate entry_data of the first row directly in the DB; entry_hash stays
|
||||
# the same but no longer matches the recomputed hash.
|
||||
with db.engine().begin() as conn:
|
||||
conn.execute(
|
||||
update(db.translog)
|
||||
.where(db.translog.c.id == e1.id)
|
||||
.values(entry_data=json.dumps({"a": 999}, sort_keys=True))
|
||||
)
|
||||
|
||||
result = translog.verify_chain()
|
||||
assert isinstance(result, Err)
|
||||
assert "broken at id=" in result.reason
|
||||
|
||||
|
||||
def test_verify_chain_detects_tampered_prev_hash(fresh_db):
|
||||
translog.append("signal", {"a": 1})
|
||||
e2 = translog.append("signal", {"b": 2})
|
||||
# Flip e2.prev_hash so it no longer matches e1.entry_hash.
|
||||
with db.engine().begin() as conn:
|
||||
conn.execute(
|
||||
update(db.translog)
|
||||
.where(db.translog.c.id == e2.id)
|
||||
.values(prev_hash="f" * 64)
|
||||
)
|
||||
result = translog.verify_chain()
|
||||
assert isinstance(result, Err)
|
||||
assert "broken at id=" in result.reason
|
||||
|
||||
|
||||
def test_entries_after_returns_correct_slice(fresh_db):
|
||||
e1 = translog.append("signal", {"a": 1})
|
||||
e2 = translog.append("signal", {"b": 2})
|
||||
e3 = translog.append("signal", {"c": 3})
|
||||
|
||||
after_zero = translog.entries_after(0)
|
||||
assert [e.id for e in after_zero] == [e1.id, e2.id, e3.id]
|
||||
|
||||
after_e1 = translog.entries_after(e1.id)
|
||||
assert [e.id for e in after_e1] == [e2.id, e3.id]
|
||||
|
||||
after_e3 = translog.entries_after(e3.id)
|
||||
assert after_e3 == []
|
||||
|
||||
|
||||
def test_recent_newest_first(fresh_db):
|
||||
e1 = translog.append("signal", {"a": 1})
|
||||
e2 = translog.append("signal", {"b": 2})
|
||||
e3 = translog.append("signal", {"c": 3})
|
||||
recent = translog.recent(limit=10)
|
||||
assert [e.id for e in recent] == [e3.id, e2.id, e1.id]
|
||||
336
tests/test_vouching.py
Normal file
336
tests/test_vouching.py
Normal file
@@ -0,0 +1,336 @@
|
||||
"""Vouching + quorum — sign/verify, threshold logic, import gate."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ed25519
|
||||
from sqlalchemy import create_engine
|
||||
|
||||
from psyc import db
|
||||
from psyc.lines import federation
|
||||
from psyc.lines.federation import (
|
||||
QuorumConfig,
|
||||
Vouch,
|
||||
accept_vouch,
|
||||
build_signed_feed,
|
||||
canonical_json,
|
||||
import_signed_feed,
|
||||
is_quorum_met,
|
||||
is_vouched,
|
||||
issue_vouch,
|
||||
node_fingerprint,
|
||||
our_vouches,
|
||||
peer_is_listening_eligible,
|
||||
public_key_pem,
|
||||
quorum_config,
|
||||
register_peer,
|
||||
revoke_vouch,
|
||||
set_quorum_config,
|
||||
vouch_payload_bytes,
|
||||
)
|
||||
from psyc.result import Err, Ok
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fresh_db(tmp_path, monkeypatch):
|
||||
test_db = tmp_path / "test.db"
|
||||
eng = create_engine(f"sqlite:///{test_db}", future=True)
|
||||
db._metadata.create_all(eng, checkfirst=True)
|
||||
monkeypatch.setattr(db, "_engine", eng)
|
||||
monkeypatch.setattr(db, "DB_PATH", test_db)
|
||||
yield test_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fed_dir(tmp_path, monkeypatch):
|
||||
d = tmp_path / "federation"
|
||||
monkeypatch.setattr(federation, "FED_DIR", d)
|
||||
monkeypatch.setattr(federation, "PRIVATE_KEY_PATH", d / "node.key")
|
||||
monkeypatch.setattr(federation, "PUBLIC_KEY_PATH", d / "node.pub")
|
||||
yield d
|
||||
|
||||
|
||||
def _make_peer():
|
||||
"""Generate an Ed25519 keypair + matching fingerprint for a fake peer."""
|
||||
priv = ed25519.Ed25519PrivateKey.generate()
|
||||
pub = priv.public_key()
|
||||
pub_pem = pub.public_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||
).decode("ascii")
|
||||
raw = pub.public_bytes(
|
||||
encoding=serialization.Encoding.Raw,
|
||||
format=serialization.PublicFormat.Raw,
|
||||
)
|
||||
fp = hashlib.sha256(raw).digest()[:16].hex()
|
||||
return priv, pub_pem, fp
|
||||
|
||||
|
||||
def _sign_vouch(priv, voucher_fp, target_fp, issued_at, expires_at):
|
||||
payload = vouch_payload_bytes(voucher_fp, target_fp, issued_at, expires_at)
|
||||
sig = priv.sign(payload)
|
||||
return base64.b64encode(sig).decode("ascii")
|
||||
|
||||
|
||||
# ---------- self-issued vouch round-trip --------------------------------
|
||||
|
||||
def test_issue_vouch_roundtrip(fresh_db, fed_dir):
|
||||
target = "ab" * 16
|
||||
v = issue_vouch(target, ttl_days=30)
|
||||
assert v.voucher_fingerprint == node_fingerprint()
|
||||
assert v.target_fingerprint == target
|
||||
assert v.expires_at is not None
|
||||
# round-trip from storage
|
||||
listed = our_vouches()
|
||||
assert len(listed) == 1
|
||||
assert listed[0].target_fingerprint == target
|
||||
assert listed[0].signature == v.signature
|
||||
# signature verifies under our own pubkey
|
||||
payload = vouch_payload_bytes(
|
||||
v.voucher_fingerprint, v.target_fingerprint, v.issued_at, v.expires_at
|
||||
)
|
||||
sig = base64.b64decode(v.signature)
|
||||
assert federation.verify_payload(payload, sig, public_key_pem())
|
||||
|
||||
|
||||
def test_revoke_vouch_removes_only_our_entry(fresh_db, fed_dir):
|
||||
target = "cd" * 16
|
||||
issue_vouch(target, ttl_days=30)
|
||||
assert len(our_vouches()) == 1
|
||||
revoke_vouch(target)
|
||||
assert our_vouches() == []
|
||||
|
||||
|
||||
# ---------- accept_vouch validation -------------------------------------
|
||||
|
||||
def test_accept_vouch_rejects_expired(fresh_db, fed_dir):
|
||||
priv, pem, fp = _make_peer()
|
||||
register_peer("voucher.example", fp, pem, status="trusted")
|
||||
issued = datetime.now(timezone.utc) - timedelta(days=10)
|
||||
expired = datetime.now(timezone.utc) - timedelta(days=1)
|
||||
sig = _sign_vouch(priv, fp, "target", issued, expired)
|
||||
v = Vouch(voucher_fingerprint=fp, target_fingerprint="target",
|
||||
issued_at=issued, expires_at=expired, signature=sig)
|
||||
result = accept_vouch(v, pem)
|
||||
assert isinstance(result, Err)
|
||||
assert "expired" in result.reason
|
||||
|
||||
|
||||
def test_accept_vouch_rejects_bad_signature(fresh_db, fed_dir):
|
||||
priv, pem, fp = _make_peer()
|
||||
register_peer("voucher.example", fp, pem, status="trusted")
|
||||
issued = datetime.now(timezone.utc)
|
||||
expires = issued + timedelta(days=30)
|
||||
# Sign a different target then claim it's for "real-target".
|
||||
real_sig = _sign_vouch(priv, fp, "other-target", issued, expires)
|
||||
v = Vouch(voucher_fingerprint=fp, target_fingerprint="real-target",
|
||||
issued_at=issued, expires_at=expires, signature=real_sig)
|
||||
result = accept_vouch(v, pem)
|
||||
assert isinstance(result, Err)
|
||||
assert "signature" in result.reason
|
||||
|
||||
|
||||
def test_accept_vouch_rejects_voucher_not_trusted(fresh_db, fed_dir):
|
||||
priv, pem, fp = _make_peer()
|
||||
# Voucher exists but is "unknown" not "trusted".
|
||||
register_peer("voucher.example", fp, pem, status="unknown")
|
||||
issued = datetime.now(timezone.utc)
|
||||
expires = issued + timedelta(days=30)
|
||||
sig = _sign_vouch(priv, fp, "target", issued, expires)
|
||||
v = Vouch(voucher_fingerprint=fp, target_fingerprint="target",
|
||||
issued_at=issued, expires_at=expires, signature=sig)
|
||||
result = accept_vouch(v, pem)
|
||||
assert isinstance(result, Err)
|
||||
assert "not trusted" in result.reason
|
||||
|
||||
|
||||
def test_accept_vouch_ok_for_trusted_voucher(fresh_db, fed_dir):
|
||||
priv, pem, fp = _make_peer()
|
||||
register_peer("voucher.example", fp, pem, status="trusted")
|
||||
issued = datetime.now(timezone.utc)
|
||||
expires = issued + timedelta(days=30)
|
||||
sig = _sign_vouch(priv, fp, "target", issued, expires)
|
||||
v = Vouch(voucher_fingerprint=fp, target_fingerprint="target",
|
||||
issued_at=issued, expires_at=expires, signature=sig)
|
||||
result = accept_vouch(v, pem)
|
||||
assert isinstance(result, Ok)
|
||||
|
||||
|
||||
# ---------- is_vouched threshold ----------------------------------------
|
||||
|
||||
def test_is_vouched_needs_distinct_vouchers(fresh_db, fed_dir):
|
||||
"""Two vouches from the same peer must NOT clear a threshold of 2."""
|
||||
priv, pem, fp = _make_peer()
|
||||
register_peer("voucher.example", fp, pem, status="trusted")
|
||||
|
||||
issued = datetime.now(timezone.utc)
|
||||
expires = issued + timedelta(days=30)
|
||||
sig = _sign_vouch(priv, fp, "target", issued, expires)
|
||||
v1 = Vouch(voucher_fingerprint=fp, target_fingerprint="target",
|
||||
issued_at=issued, expires_at=expires, signature=sig)
|
||||
assert isinstance(accept_vouch(v1, pem), Ok)
|
||||
|
||||
# Newer vouch from the SAME voucher — upsert replaces, count stays 1.
|
||||
issued2 = issued + timedelta(seconds=1)
|
||||
sig2 = _sign_vouch(priv, fp, "target", issued2, expires)
|
||||
v2 = Vouch(voucher_fingerprint=fp, target_fingerprint="target",
|
||||
issued_at=issued2, expires_at=expires, signature=sig2)
|
||||
assert isinstance(accept_vouch(v2, pem), Ok)
|
||||
|
||||
assert is_vouched("target", min_vouchers=2) is False
|
||||
# Threshold of 1 should pass.
|
||||
assert is_vouched("target", min_vouchers=1) is True
|
||||
|
||||
|
||||
def test_is_vouched_two_distinct_clear_threshold(fresh_db, fed_dir):
|
||||
priv_a, pem_a, fp_a = _make_peer()
|
||||
priv_b, pem_b, fp_b = _make_peer()
|
||||
register_peer("a.example", fp_a, pem_a, status="trusted")
|
||||
register_peer("b.example", fp_b, pem_b, status="trusted")
|
||||
|
||||
issued = datetime.now(timezone.utc)
|
||||
expires = issued + timedelta(days=30)
|
||||
va = Vouch(voucher_fingerprint=fp_a, target_fingerprint="target",
|
||||
issued_at=issued, expires_at=expires,
|
||||
signature=_sign_vouch(priv_a, fp_a, "target", issued, expires))
|
||||
vb = Vouch(voucher_fingerprint=fp_b, target_fingerprint="target",
|
||||
issued_at=issued, expires_at=expires,
|
||||
signature=_sign_vouch(priv_b, fp_b, "target", issued, expires))
|
||||
assert isinstance(accept_vouch(va, pem_a), Ok)
|
||||
assert isinstance(accept_vouch(vb, pem_b), Ok)
|
||||
|
||||
assert is_vouched("target", min_vouchers=2) is True
|
||||
assert is_vouched("target", min_vouchers=3) is False
|
||||
|
||||
|
||||
# ---------- quorum on signal_hash ---------------------------------------
|
||||
|
||||
def test_is_quorum_met_counts_distinct_vouched_peers_only(fresh_db, fed_dir):
|
||||
# Two trusted peers + one untrusted peer report the same signal_hash.
|
||||
_, pem_a, fp_a = _make_peer()
|
||||
_, pem_b, fp_b = _make_peer()
|
||||
_, pem_c, fp_c = _make_peer()
|
||||
register_peer("a.example", fp_a, pem_a, status="trusted")
|
||||
register_peer("b.example", fp_b, pem_b, status="trusted")
|
||||
register_peer("c.example", fp_c, pem_c, status="unknown") # not eligible
|
||||
|
||||
for fp in (fp_a, fp_b, fp_c, fp_a): # fp_a duplicated → still 1 distinct
|
||||
db.record_signal(dict(
|
||||
peer_fingerprint=fp,
|
||||
signal_type="ioc",
|
||||
signal_id="1.2.3.4",
|
||||
signal_hash="h-aaa",
|
||||
received_at=datetime.now(timezone.utc).isoformat(),
|
||||
raw_json="{}",
|
||||
))
|
||||
|
||||
assert is_quorum_met("h-aaa", k=2) is True
|
||||
assert is_quorum_met("h-aaa", k=3) is False # only 2 eligible distincts
|
||||
|
||||
|
||||
# ---------- quorum config persistence -----------------------------------
|
||||
|
||||
def test_quorum_config_defaults_and_persistence(fresh_db, fed_dir):
|
||||
cfg = quorum_config()
|
||||
assert cfg.trust_min_vouchers == 2
|
||||
assert cfg.signal_quorum_k == 2
|
||||
set_quorum_config(QuorumConfig(trust_min_vouchers=3, signal_quorum_k=4))
|
||||
cfg2 = quorum_config()
|
||||
assert cfg2.trust_min_vouchers == 3
|
||||
assert cfg2.signal_quorum_k == 4
|
||||
|
||||
|
||||
# ---------- import gate enforces listening eligibility ------------------
|
||||
|
||||
def _signed_feed_from_peer(peer_priv, peer_fp, vouches=None):
|
||||
"""Build a feed claiming origin=peer_fp, signed with peer_priv."""
|
||||
payload = {
|
||||
"version": federation.FEED_VERSION,
|
||||
"fingerprint": peer_fp,
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"window_hours": 24,
|
||||
"cases": [],
|
||||
"iocs": [{
|
||||
"value": "9.9.9.9",
|
||||
"type": "ip",
|
||||
"severity": "high",
|
||||
"first_seen": datetime.now(timezone.utc).isoformat(),
|
||||
"digest_sha256": "abc123",
|
||||
}],
|
||||
"vouches": vouches or [],
|
||||
}
|
||||
sig = peer_priv.sign(canonical_json(payload))
|
||||
payload["signature"] = base64.b64encode(sig).decode("ascii")
|
||||
return payload
|
||||
|
||||
|
||||
def test_import_feed_rejects_unknown_peer(fresh_db, fed_dir):
|
||||
peer_priv, peer_pem, peer_fp = _make_peer()
|
||||
feed = _signed_feed_from_peer(peer_priv, peer_fp)
|
||||
result = import_signed_feed(feed, peer_pem)
|
||||
assert isinstance(result, Err)
|
||||
assert "not trusted" in result.reason
|
||||
|
||||
|
||||
def test_import_feed_accepts_directly_trusted_peer(fresh_db, fed_dir):
|
||||
peer_priv, peer_pem, peer_fp = _make_peer()
|
||||
register_peer("peer.example", peer_fp, peer_pem, status="trusted")
|
||||
feed = _signed_feed_from_peer(peer_priv, peer_fp)
|
||||
result = import_signed_feed(feed, peer_pem)
|
||||
assert isinstance(result, Ok), getattr(result, "reason", "")
|
||||
|
||||
|
||||
def test_import_feed_accepts_vouched_peer(fresh_db, fed_dir):
|
||||
# Two trusted peers vouch for a third — third becomes listening-eligible.
|
||||
priv_a, pem_a, fp_a = _make_peer()
|
||||
priv_b, pem_b, fp_b = _make_peer()
|
||||
priv_c, pem_c, fp_c = _make_peer()
|
||||
register_peer("a.example", fp_a, pem_a, status="trusted")
|
||||
register_peer("b.example", fp_b, pem_b, status="trusted")
|
||||
|
||||
issued = datetime.now(timezone.utc)
|
||||
expires = issued + timedelta(days=30)
|
||||
va = Vouch(voucher_fingerprint=fp_a, target_fingerprint=fp_c,
|
||||
issued_at=issued, expires_at=expires,
|
||||
signature=_sign_vouch(priv_a, fp_a, fp_c, issued, expires))
|
||||
vb = Vouch(voucher_fingerprint=fp_b, target_fingerprint=fp_c,
|
||||
issued_at=issued, expires_at=expires,
|
||||
signature=_sign_vouch(priv_b, fp_b, fp_c, issued, expires))
|
||||
assert isinstance(accept_vouch(va, pem_a), Ok)
|
||||
assert isinstance(accept_vouch(vb, pem_b), Ok)
|
||||
assert peer_is_listening_eligible(fp_c) is True
|
||||
|
||||
feed = _signed_feed_from_peer(priv_c, fp_c)
|
||||
result = import_signed_feed(feed, pem_c)
|
||||
assert isinstance(result, Ok), getattr(result, "reason", "")
|
||||
|
||||
|
||||
def test_import_feed_propagates_vouches_in_payload(fresh_db, fed_dir):
|
||||
"""A trusted peer's feed carries a vouch the peer issued — we should
|
||||
accept_vouch it and store it locally."""
|
||||
peer_priv, peer_pem, peer_fp = _make_peer()
|
||||
register_peer("peer.example", peer_fp, peer_pem, status="trusted")
|
||||
|
||||
target_fp = "ff" * 16
|
||||
issued = datetime.now(timezone.utc)
|
||||
expires = issued + timedelta(days=30)
|
||||
peer_vouch = Vouch(
|
||||
voucher_fingerprint=peer_fp,
|
||||
target_fingerprint=target_fp,
|
||||
issued_at=issued,
|
||||
expires_at=expires,
|
||||
signature=_sign_vouch(peer_priv, peer_fp, target_fp, issued, expires),
|
||||
)
|
||||
feed = _signed_feed_from_peer(peer_priv, peer_fp, vouches=[peer_vouch.model_dump(mode="json")])
|
||||
|
||||
result = import_signed_feed(feed, peer_pem)
|
||||
assert isinstance(result, Ok), getattr(result, "reason", "")
|
||||
|
||||
# The vouch is now in our local store under the peer's fingerprint.
|
||||
stored = federation.vouches_for(target_fp)
|
||||
assert any(v.voucher_fingerprint == peer_fp for v in stored)
|
||||
Reference in New Issue
Block a user