Compare commits

...

67 Commits

Author SHA1 Message Date
m17hr1l
4f12e344a8 xss fixes from audit:
- F1 case_detail.html: scheme-check source_ref href (block javascript: URLs)
- F2 admin.html / F3 admin_federation.html: replace inline onsubmit confirm()
  with data-attr + global handler in base.html (no more label/domain
  interpolation into onsubmit attribute string)
- federation.register_peer: validate hostname + fingerprint regex at ingest
- federation_explore.html: window.PSYC_EXPLORE via | tojson
- federation_network.js: DOMAIN_RE guard on peer-supplied domain before
  building cross-origin fetch URL (also closes open-redirect via 'open
  their explorer' button)
- app.py: nosniff + Referrer-Policy: no-referrer + X-Frame-Options: DENY
- sw.js: psyc-v11 cache bump

CSP deferred — needs inline scripts moved to external files first.
Tests: +2 cases, 245/245 green.
2026-06-07 14:23:55 +02:00
m17hr1l
00cd8ca252 db: NullPool + WAL + busy_timeout — fixes QueuePool exhaustion under federation+classify load 2026-06-07 11:57:07 +02:00
m17hr1l
77e4cb6ab9 deploy-all: redirect deploy.sh stdin from /dev/null so loop doesn't drop hosts 2026-06-07 02:06:45 +02:00
m17hr1l
9ba4cd2189 merge topology: per-peer container view in federation network detail panel 2026-06-07 01:59:23 +02:00
m17hr1l
155d6eaaf9 stage-topo-d topology-export: CLI fed-topology + SW v10 2026-06-07 01:58:27 +02:00
m17hr1l
d998be276b stage-topo-c topology-export: federation network panel renders peer containers 2026-06-07 01:57:29 +02:00
m17hr1l
367f17a013 stage-topo-b topology-export: /federation/topology endpoint + CORS cache 2026-06-07 01:56:09 +02:00
m17hr1l
a8216d00ef stage-topo-a topology-export: sanitized public docker snapshot module + tests 2026-06-07 01:55:49 +02:00
m17hr1l
8587e079bb federation/network: fetch peer's own /federation/explore/data on click + render their self-view inline (their peers, vouches in/out, transitive, translog head) 2026-06-07 01:21:58 +02:00
m17hr1l
cef3bcb1ed merge explore: public transparent federation explorer with cross-jump 2026-06-07 01:19:56 +02:00
m17hr1l
9ab3271bc8 stage-exp-f explore: tests 2026-06-07 01:17:11 +02:00
m17hr1l
c2bd68e246 stage-exp-e explore: link from home + info endpoint 2026-06-07 01:16:32 +02:00
m17hr1l
587fd07d38 stage-exp-d explore: JS — cross-jump navigation + verify button 2026-06-07 01:16:02 +02:00
m17hr1l
ca6ba83950 stage-exp-c explore: HTML template + landing layout 2026-06-07 01:13:51 +02:00
m17hr1l
a10203d8f1 stage-exp-b explore: public routes + CORS on existing public endpoints 2026-06-07 01:12:25 +02:00
m17hr1l
56466c334d stage-exp-a explore: public payload builder + tests 2026-06-07 01:11:17 +02:00
m17hr1l
351e16c3ce inference: openai-compatible mode + bearer auth (for api.neuronetz.ai etc.) 2026-06-07 01:09:19 +02:00
m17hr1l
2c7f71eff8 deploy: scripts/deploy-all.sh + hosts.example for multi-node federation rollouts 2026-06-07 01:03:31 +02:00
m17hr1l
925bf76a0b merge network-detail: rich detail panel, corroboration edges, 24h timeline, search 2026-06-07 01:01:42 +02:00
m17hr1l
0d9baef4c8 stage-netd-f network detail: tests for admin enrichment (stats/corroboration/timeline) 2026-06-07 01:00:39 +02:00
m17hr1l
980cf74b76 stage-netd-d cockpit SW: bump CACHE_VERSION to psyc-v7 for network detail CSS+JS 2026-06-07 00:57:53 +02:00
m17hr1l
70b6af6a35 stage-netd-c network detail: rich detail panel + hover tooltips + search/intensity + timeline JS 2026-06-07 00:57:49 +02:00
m17hr1l
15749e050e stage-netd-b network detail: corroboration edges + timeline strip (CSS + template) 2026-06-07 00:57:44 +02:00
m17hr1l
c6c5d3b2ea stage-netd-a network detail: enrich peer stats (signals/severity/vouches/quorum) 2026-06-07 00:52:41 +02:00
m17hr1l
e33c5b41f5 merge network-view: federation graph per node with vouches + signal flow 2026-06-07 00:43:13 +02:00
m17hr1l
865be2e239 stage-net-f network view: tests 2026-06-07 00:42:11 +02:00
m17hr1l
ff44e9e450 stage-net-e network view: CLI fed-network command 2026-06-07 00:40:43 +02:00
m17hr1l
5950d34deb stage-net-d network view: cockpit page + JS force-directed graph 2026-06-07 00:40:13 +02:00
m17hr1l
5ff6d80333 stage-net-c network view: transitive fetcher + admin data endpoint 2026-06-07 00:37:32 +02:00
m17hr1l
6dcaae39c3 stage-net-b network view: public endpoint + signed payload 2026-06-07 00:37:12 +02:00
m17hr1l
fbad78a611 stage-net-a network view: data model + local view builder 2026-06-07 00:36:29 +02:00
m17hr1l
77533eccb1 ui: widen content to 1600px + force long URLs/hashes to wrap (fixes 4K/1920 overflow) 2026-06-06 21:29:16 +02:00
m17hr1l
3e737d61b3 stage-34 wire admin nav: federation hub links to discovery + SW v4 2026-06-06 21:17:57 +02:00
m17hr1l
a53aacfdd8 merge auto-response: severity/quorum/local-only gated execution
# Conflicts:
#	src/psyc/db.py
2026-06-06 21:17:20 +02:00
m17hr1l
53ba537ce8 merge vouching+translog: web-of-trust + signed merkle audit log
# Conflicts:
#	src/psyc/_federation_cli.py
#	src/psyc/cockpit/federation_routes.py
2026-06-06 21:15:11 +02:00
m17hr1l
726117b19b merge discovery: DNS-SD walker + public peers endpoint 2026-06-06 21:13:29 +02:00
m17hr1l
c5472b3134 stage-auto-e pulse: tests for auto-response gating
Cover the auto-fire decision matrix:
- _severity_rank ordering
- mode != auto-execute → never fires (auto-propose, manual)
- below-threshold action is skipped + audited
- federation case + no quorum → skipped + audited "no quorum"
- federation case + quorum met → fires
- local case + quorum required + local-only on → still fires
- local case + quorum required + local-only off → still fires
- quorum gating disabled → federation cases fire too
- kill switch armed → tick() skips everything
- pulse_audit records both auto-fire and skip rows
- audit_count_since returns the per-action counts the cockpit needs
- config round-trips through pulse_settings

Tests patch federation.is_quorum_met (raising=False so the sibling
agent can ship the real function later without breaking these), and
swap respond.execute_action for a counter so no SOAR sink call escapes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 21:12:02 +02:00
m17hr1l
f5ca928f92 stage-auto-d pulse: cockpit auto-response state panel + CLI
Cockpit:
- /admin/pulse now renders an "AUTO-RESPONSE STATE" panel above the
  pipeline table — mode badge (traffic-light colored), threshold,
  quorum on/off, local-only on/off, auto-fired-in-24h count, last 5
  audit entries, and a one-form save for threshold + gates.
- POST /admin/pulse/respond-config writes the new gates.

CLI:
- pulse-respond-config [--threshold …] [--quorum/--no-quorum]
                       [--local-only/--no-local-only]
  Args left unset are unchanged; echoes the post-state.
- pulse-respond-status prints mode, gates, and the last 10 audit
  entries.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 21:11:52 +02:00
m17hr1l
e66c3d3359 stage-auto-c pulse: respond runner with gates + auto-fire path
Replace the propose-only respond runner with a two-phase runner: phase 1
always proposes actions for fresh high-severity cases (unchanged); phase 2
fires when pipeline mode is auto-execute and the action clears all gates.

Gates:
- severity ≥ configured threshold
- if require_quorum is on, federation-sourced cases must hit
  federation.is_quorum_met (wrapped in try/except so we tolerate the
  sibling agent not having shipped that function yet — fallback is
  "no quorum metric → don't auto-fire", the safe default)
- locally-generated cases (no row in federation_signals for their case_id)
  bypass the quorum check
- when local-only is armed, federation-sourced cases never auto-fire
  even with quorum

Every decision (auto-fire, skipped, error) records a pulse_audit row so
the cockpit and CLI can show history. Per-action try/except keeps one
bad action from aborting the whole batch.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 21:11:39 +02:00
m17hr1l
f4148d86a6 stage-vouch-e federation: tests for vouching + quorum gate
test_vouching covers the contract auto-response and other agents will
gate on:

- issue_vouch round-trip (sign + verify under our own pubkey)
- accept_vouch rejects expired vouches
- accept_vouch rejects mismatched signatures
- accept_vouch rejects vouchers whose peers.status != "trusted"
- accept_vouch happy path
- is_vouched needs DISTINCT vouchers (two upserts from one peer == 1)
- is_vouched clears threshold with two distinct trusted vouchers
- is_quorum_met counts only listening-eligible peers (untrusted +
  duplicate rows don't count)
- quorum_config defaults + pulse_settings persistence
- import_signed_feed rejects unknown peer ("not trusted")
- import_signed_feed accepts directly-trusted peer
- import_signed_feed accepts a peer made eligible via two vouches
- import_signed_feed stores vouches embedded in a trusted peer's feed

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 21:11:18 +02:00
m17hr1l
0e56fa70af stage-vouch-d federation: cockpit pages + CLI + public endpoints
Public JSON endpoints (no auth):
- GET /federation/vouches — our_vouches() so peers can pull our trust
- GET /federation/log — last 100 transparency-log entries
- GET /federation/log/verify — re-walks the chain, returns
  {verified, head_hash} or 409 with {error, head_hash}

Admin pages (TOTP-gated):
- /admin/federation/vouches — issued list, issue form, revoke
  buttons, per-peer quorum-met table
- /admin/federation/log — chain verification status + last 200 entries
- /admin/federation/quorum — config form + per-peer eligibility +
  per-signal-hash distinct-eligible-peer counts

CLI: fed-vouch / fed-unvouch / fed-vouches / fed-quorum-set / fed-log /
fed-log-verify, plus existing fed-* commands untouched.

The base /admin/federation page now links to the three new sub-pages.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 21:11:03 +02:00
m17hr1l
31ec1557ec stage-auto-b pulse: pulse_audit table + history
Add a per-decision audit log so the cockpit + CLI can show what the
auto-response runner did each tick:
- pulse_audit table: id, pipeline, action ('auto-fire'|'skipped'|'error'),
  action_id, case_id, detail, timestamp
- helpers: pulse_audit_record, pulse_audit_recent, pulse_audit_count_since
- indexes on (pipeline, timestamp desc) and on action_id

Also add db.signals_for_case(case_id) — checks the federation_signals
buffer to tell whether a case was peer-sourced. Used by the runner to
decide if a quorum check is required.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 21:10:38 +02:00
m17hr1l
eadd1aea3b stage-vouch-c federation: import gate + translog hook (stage-trans-b)
import_signed_feed now refuses any feed whose declared fingerprint isn't
peer_is_listening_eligible (directly trusted OR vouched in), returning
Err("peer not trusted: …") before any signal lands.

For every case/IOC it does record, it also appends a "signal" entry to
the transparency log (best-effort — logger warns but doesn't abort
ingest if the append fails). This is the stage-trans-b hook: the
import path is the chokepoint, so attaching the chain there gives
us coverage of every peer-originated signal we've ever accepted.

build_signed_feed now includes our_vouches() in the feed body so vouches
propagate. On import we accept_vouch each one — but only if the embedded
voucher_fingerprint matches the peer we just authenticated, so a peer
can't forge vouches "from" someone else through us.

test_federation: the long-standing round-trip test now first registers
the synthetic peer as trusted so the gate lets it through.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 21:10:36 +02:00
m17hr1l
234e6d98ba stage-vouch-b federation: vouch sign/verify + quorum API
Add the web-of-trust primitives, all keyed off the existing node keypair:

- Vouch + QuorumConfig Pydantic models
- vouch_payload_bytes — canonical-JSON body that the voucher signs
- issue_vouch / accept_vouch / revoke_vouch / our_vouches / vouches_for
- is_vouched(target, min_vouchers) — counts DISTINCT trusted vouchers,
  ignoring expired vouches and re-using QuorumConfig defaults
- peer_is_listening_eligible(fp) — direct-trust OR vouched-in
- is_quorum_met(signal_hash, k) — distinct listening-eligible peers
  reporting the same hash
- quorum_evidence(signal_hash) — (peer_fp, received_at) tuples for UI
- quorum_config / set_quorum_config — persisted in pulse_settings

accept_vouch is paranoid: rejects expired vouches, vouchers that aren't
currently "trusted" in our peers table, mismatched pubkey-fingerprint
pairs, malformed base64, and Ed25519 verification failures — each with
a short Err reason.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 21:10:03 +02:00
m17hr1l
0dbeb056c5 stage-auto-a pulse: respond config (threshold/quorum/local-only)
Persisted-key/value helpers for the respond-pipeline auto-fire gates:
- respond_auto_threshold / set_… (Severity, default HIGH)
- respond_require_quorum / set_… (bool, default True)
- respond_local_only / set_…     (bool, default False)
Plus a _severity_rank helper for threshold comparison.

Backing store is the existing pulse_settings table; this commit also adds
generic pulse_setting_get / pulse_setting_set helpers in db.py so future
pulse settings don't each need their own column-pair helper.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 21:09:46 +02:00
m17hr1l
7a510c7acf stage-trans-a translog: append-only signed merkle chain + tests
translog.append computes
sha256(canonical({prev_hash, entry_type, entry_data, timestamp})) and
writes one row per call; the first entry uses prev_hash = "0"*64.
verify_chain walks rows in id order, re-hashes each, and returns
Err("broken at id=X expected=... got=...") on the first mismatch — so
tampering with either entry_data or prev_hash invalidates every
downstream row. recent / entries_after / head support peer sync and UI.

Tests cover: genesis prev_hash, chained prev_hash, full-chain verify,
tampered-data detection, tampered-prev_hash detection, slicing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 21:09:32 +02:00
m17hr1l
4a9f6ceb7f stage-vouch-a federation: vouches table + DB helpers
Add `vouches` (voucher_fp, target_fp, issued_at, expires_at, signature)
with unique (voucher, target) and target index, plus `translog` for the
append-only signed merkle chain (id, prev_hash, entry_type, entry_data,
timestamp, entry_hash). Also surface `setting_get` / `setting_set`
helpers on pulse_settings so the quorum config has a place to live.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 21:09:25 +02:00
m17hr1l
ff88aba569 stage-disc-e discovery: tests 2026-06-06 21:08:15 +02:00
m17hr1l
9b49f768ca stage-disc-d discovery: cockpit + CLI 2026-06-06 21:06:39 +02:00
m17hr1l
ddb40ff92c stage-disc-c discovery: pulse pipeline wiring + seeds settings 2026-06-06 21:04:54 +02:00
m17hr1l
6241a21af5 stage-disc-b discovery: peer walker (BFS) 2026-06-06 21:04:17 +02:00
m17hr1l
de6204819b stage-disc-a discovery: dnssd resolver + public peers endpoint 2026-06-06 21:03:33 +02:00
m17hr1l
1675a2326e stage-33 wire pulse + federation: cockpit routes, CLI, nav links, SW bump 2026-06-06 16:15:48 +02:00
m17hr1l
de5ff09815 merge federation: ed25519 identity + signed feeds
# Conflicts:
#	src/psyc/db.py
2026-06-06 16:13:36 +02:00
m17hr1l
02ce6d791c merge pulse: scheduler line + autonomy dial 2026-06-06 16:11:17 +02:00
m17hr1l
d4229dd264 stage-fed-g federation: tests 2026-06-06 16:10:31 +02:00
m17hr1l
2ef0448165 stage-fed-f federation: CLI commands 2026-06-06 16:10:26 +02:00
m17hr1l
17b94acf6b stage-fed-e federation: cockpit admin page + public feed routes 2026-06-06 16:10:19 +02:00
m17hr1l
55ffd9da3d stage-fed-d federation: signed feed export + verified import 2026-06-06 16:09:53 +02:00
m17hr1l
63e3ff2777 stage-fed-c federation: db tables for peers + signal buffer 2026-06-06 16:08:36 +02:00
m17hr1l
50158f7fa8 stage-fed-b federation: dns record format 2026-06-06 16:08:31 +02:00
m17hr1l
4c35aad2bb stage-fed-a federation: ed25519 keypair + fingerprint 2026-06-06 16:08:03 +02:00
m17hr1l
a7c59c9faa stage-33e pulse: tests 2026-06-06 16:06:54 +02:00
m17hr1l
e071f289f2 stage-33d pulse: CLI commands 2026-06-06 16:05:14 +02:00
m17hr1l
26fbe08b65 stage-33c pulse: admin cockpit page 2026-06-06 16:04:39 +02:00
m17hr1l
4d67605371 stage-33b pulse: db tables + persistence 2026-06-06 16:03:30 +02:00
m17hr1l
e710be6ebd stage-33a pulse: scheduler module with pipeline registry 2026-06-06 16:03:22 +02:00
44 changed files with 11689 additions and 17 deletions

3
.gitignore vendored
View File

@@ -14,6 +14,9 @@ data/
.env
.env.local
# per-operator federation host list (SSH targets are sensitive)
scripts/hosts
# editors
.vscode/
.idea/

View File

@@ -16,12 +16,14 @@ dependencies = [
"httpx>=0.27",
"typer>=0.12",
"pynacl>=1.5",
"cryptography>=42.0",
"structlog>=24.1",
"sqlalchemy>=2.0",
"python-dotenv>=1.0",
"pyotp>=2.9",
"qrcode[pil]>=7.4",
"itsdangerous>=2.1",
"dnspython>=2.4",
]
[project.optional-dependencies]

65
scripts/deploy-all.sh Executable file
View 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
View 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

353
src/psyc/_federation_cli.py Normal file
View File

@@ -0,0 +1,353 @@
"""Federation CLI — keygen, DNS records, feed export, peer registry, verify.
Registered onto the top-level Typer app from cli.py so the surface stays flat.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import List, Optional
import httpx
import typer
from psyc import db, log
from psyc.lines import discovery, federation, network_view, pulse, topology_export, translog
from psyc.result import Err, Ok
_log = log.get(__name__)
def register(typer_app: typer.Typer) -> None:
"""Mount all `fed-*` commands onto `typer_app`."""
@typer_app.command("fed-keygen")
def fed_keygen() -> None:
"""Generate the node's Ed25519 keypair (or load existing). Prints fingerprint."""
federation.node_keypair() # creates the files if missing
typer.echo(federation.node_fingerprint())
@typer_app.command("fed-dns")
def fed_dns(
domain: str = typer.Argument(..., help="public domain to advertise this node on"),
port: int = typer.Option(443, "--port", help="port psyc is reachable on"),
) -> None:
"""Print the DNS SRV + TXT records to publish under `domain`."""
rec = federation.dns_record(domain, port=port)
typer.echo(rec.human_instructions)
@typer_app.command("fed-feed")
def fed_feed(
window_hours: int = typer.Option(24, "--hours", help="lookback window (hours)"),
) -> None:
"""Build + print the signed feed JSON."""
db.init_db()
payload = federation.build_signed_feed(window_hours=window_hours)
typer.echo(json.dumps(payload, indent=2))
@typer_app.command("fed-verify")
def fed_verify(
peer_url: str = typer.Argument(..., help="peer base URL, e.g. https://peer.example"),
) -> None:
"""Fetch a peer's /federation/{info,key,feed} and verify the signature."""
peer_url = peer_url.rstrip("/")
try:
with httpx.Client(timeout=10.0) as client:
info = client.get(f"{peer_url}/federation/info").json()
key_text = client.get(f"{peer_url}/federation/key").text
feed = client.get(f"{peer_url}/federation/feed").json()
except Exception as exc:
typer.echo(f"error: fetch failed: {exc}", err=True)
raise typer.Exit(1)
# If the peer is already in the registry, prefer the stored pubkey
# (TOFU pin); otherwise warn and use the freshly fetched one.
declared_fp = info.get("fingerprint", "")
pubkey_pem = key_text
pinned = None
for p in federation.list_peers():
if p.fingerprint == declared_fp:
pinned = p
break
if pinned:
pubkey_pem = pinned.pubkey_pem
typer.echo(f" · using pinned pubkey for {pinned.domain}")
else:
typer.echo(" · WARNING: no pinned pubkey for this peer — trusting fetched key (TOFU)")
db.init_db()
result = federation.import_signed_feed(feed, pubkey_pem)
if isinstance(result, Err):
typer.echo(f" ✗ verification failed: {result.reason}", err=True)
raise typer.Exit(1)
s = result.value
typer.echo(f" ✓ verified peer {s.peer_fingerprint}")
typer.echo(f" cases: {s.cases_seen} iocs: {s.iocs_seen} signals buffered: {len(s.signal_ids)}")
@typer_app.command("fed-peer-add")
def fed_peer_add(
domain: str = typer.Argument(..., help="peer's public domain"),
fingerprint: str = typer.Argument(..., help="peer's 32-hex fingerprint"),
pubkey_file: Path = typer.Option(..., "--pubkey-file", help="path to peer's PEM public key"),
status: str = typer.Option("unknown", "--status", help="unknown | trusted | blocked"),
) -> None:
"""Register a peer's identity in the local registry."""
db.init_db()
pem = pubkey_file.read_text(encoding="utf-8")
federation.register_peer(domain, fingerprint, pem, status=status)
typer.echo(f"registered peer {domain} ({fingerprint[:8]}…) status={status}")
@typer_app.command("fed-peer-list")
def fed_peer_list() -> None:
"""List all registered peers."""
db.init_db()
rows = federation.list_peers()
if not rows:
typer.echo("(no peers registered)")
return
for p in rows:
typer.echo(
f" {p.status:8s} {p.domain:30s} {p.fingerprint[:8]}{p.fingerprint[-8:]}"
f" last_seen={(p.last_seen or '')[:16]}"
)
@typer_app.command("fed-peer-trust")
def fed_peer_trust(domain: str = typer.Argument(...)) -> None:
"""Mark a peer as trusted — their signals count toward quorum."""
db.init_db()
federation.set_peer_status(domain, "trusted")
typer.echo(f"{domain} → trusted")
@typer_app.command("fed-peer-block")
def fed_peer_block(domain: str = typer.Argument(...)) -> None:
"""Block a peer — ignore their feeds."""
db.init_db()
federation.set_peer_status(domain, "blocked")
typer.echo(f"{domain} → blocked")
@typer_app.command("fed-peer-remove")
def fed_peer_remove(domain: str = typer.Argument(...)) -> None:
"""Drop a peer from the registry."""
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}")

182
src/psyc/_pulse_cli.py Normal file
View File

@@ -0,0 +1,182 @@
"""Typer commands for the Pulse scheduler.
Imported and wired by `cli.py` via `register(app)`. Kept as a separate module
so the main CLI surface stays grep-friendly and the scheduler can grow its own
verbs without bloating cli.py.
"""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Optional
import typer
from psyc import db
from psyc.lines import pulse
from psyc.models import Severity
def _relative(dt: Optional[datetime]) -> str:
if dt is None:
return ""
now = datetime.now(timezone.utc)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
delta = int((dt - now).total_seconds())
past = delta < 0
secs = abs(delta)
if secs < 5:
return "now"
if secs < 60:
unit = f"{secs}s"
elif secs < 3600:
unit = f"{secs // 60}m"
elif secs < 86400:
unit = f"{secs // 3600}h"
else:
unit = f"{secs // 86400}d"
return f"{unit} ago" if past else f"in {unit}"
def register(typer_app: typer.Typer) -> None:
"""Add pulse-* commands to the given Typer app."""
@typer_app.command("pulse-status")
def pulse_status() -> None:
"""Print the pipeline table (mode · cadence · last-fired · next-fire · last-result)."""
db.init_db()
ks = pulse.kill_switch_state()
typer.echo(f"kill switch: {'ARMED' if ks else 'OFF'}")
rows = pulse.state()
if not rows:
typer.echo("(no pipelines registered)")
return
typer.echo(f"{'name':<16} {'mode':<14} {'cadence':>8} {'enabled':<7} {'last':<10} {'next':<10} result")
for p in rows:
typer.echo(
f"{p.name:<16} {p.mode.value:<14} {p.cadence_seconds:>6}s "
f"{'yes' if p.enabled else 'no':<7} "
f"{_relative(p.last_fired):<10} {_relative(p.next_fire):<10} "
f"{(p.last_result or '')[:60]}"
)
@typer_app.command("pulse-tick")
def pulse_tick() -> None:
"""Run one scheduler heartbeat and print the per-pipeline outcomes."""
db.init_db()
results = pulse.tick()
for name, outcome, result in results:
marker = {"ok": "", "err": "", "skipped": ""}.get(outcome, "·")
typer.echo(f" {marker} {name:<16} {outcome:<8} {result[:120]}")
@typer_app.command("pulse-set-mode")
def pulse_set_mode(
name: str = typer.Argument(..., help="pipeline name"),
mode: str = typer.Argument(..., help="manual | auto-propose | auto-execute"),
) -> None:
db.init_db()
try:
pulse.set_mode(name, pulse.PulseMode(mode))
except ValueError as exc:
typer.echo(f"error: {exc}", err=True)
raise typer.Exit(1)
typer.echo(f"{name} mode → {mode}")
@typer_app.command("pulse-set-cadence")
def pulse_set_cadence(
name: str = typer.Argument(..., help="pipeline name"),
seconds: int = typer.Argument(..., help="cadence in seconds (>0)"),
) -> None:
db.init_db()
try:
pulse.set_cadence(name, seconds)
except ValueError as exc:
typer.echo(f"error: {exc}", err=True)
raise typer.Exit(1)
typer.echo(f"{name} cadence → {seconds}s")
@typer_app.command("pulse-run")
def pulse_run(name: str = typer.Argument(..., help="pipeline name")) -> None:
"""Manually fire one pipeline (bypasses cadence; honors kill switch)."""
db.init_db()
try:
outcome, result = pulse.run_now(name)
except ValueError as exc:
typer.echo(f"error: {exc}", err=True)
raise typer.Exit(1)
marker = {"ok": "", "err": "", "skipped": ""}.get(outcome, "·")
typer.echo(f" {marker} {name}: {outcome}{result}")
@typer_app.command("pulse-kill")
def pulse_kill() -> None:
"""Arm the kill switch — every pipeline halts on the next tick."""
db.init_db()
pulse.set_kill_switch(True)
typer.echo("kill switch ARMED — all pipelines halted")
@typer_app.command("pulse-unkill")
def pulse_unkill() -> None:
"""Disarm the kill switch — pulse resumes on the next tick."""
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]}"
)

View File

@@ -17,11 +17,15 @@ from psyc.lines import classify, courier, lookup, proof, respond, route, scout,
from psyc.lines import map as map_line
from psyc.models import Outcome
from psyc.result import Err, Ok
from psyc._federation_cli import register as _register_federation_cli
from psyc._pulse_cli import register as _register_pulse_cli
app = typer.Typer(add_completion=False, help="psyc — defensive CTI routing & sealing")
log.configure()
_log = log.get(__name__)
_register_pulse_cli(app)
_register_federation_cli(app)
@app.command("init")

View File

@@ -12,7 +12,7 @@ from fastapi.templating import Jinja2Templates
from starlette.middleware.sessions import SessionMiddleware
from psyc import db, log
from psyc.cockpit import adminauth, case_visuals, docker_view, inference, journey as journey_view
from psyc.cockpit import adminauth, case_visuals, docker_view, federation_routes, inference, journey as journey_view, pulse_routes
from psyc.lines import courier as courier_line
from psyc.lines import ledger as ledger_line
from psyc.lines import lookup as lookup_line
@@ -36,6 +36,22 @@ app.add_middleware(SessionMiddleware, secret_key=adminauth.session_secret(), max
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)
def _admin_ok(request: Request) -> bool:
return bool(request.session.get("admin_ok"))

View File

@@ -0,0 +1,530 @@
"""Federation cockpit routes — admin page, public feed/key/info endpoints.
Wired into the FastAPI app by app.py via a single `register(app, TEMPLATES)`
call so the federation surface stays self-contained.
"""
from __future__ import annotations
import json
import time
from typing import Any, Dict, Optional, Tuple
from fastapi import FastAPI, Form, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from psyc import db, log
from psyc.lines import discovery, federation, network_view, pulse, topology_export, translog
from psyc.result import Err
_log = log.get(__name__)
# Tiny in-memory cache for the signed feed — peers may poll, recomputing
# canonical JSON + signature on every hit would be wasteful.
_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"))
def _cached_feed() -> Dict[str, Any]:
now = time.time()
if _FEED_CACHE["payload"] is None or (now - _FEED_CACHE["ts"]) > _FEED_TTL:
_FEED_CACHE["payload"] = federation.build_signed_feed()
_FEED_CACHE["ts"] = now
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`."""
@app.get("/admin/federation", response_class=HTMLResponse)
def admin_federation(request: Request) -> HTMLResponse:
if not _admin_ok(request):
return RedirectResponse("/admin", status_code=303)
host = request.url.hostname or "your-node.example"
suggested = request.query_params.get("domain", host)
rec = federation.dns_record(suggested)
peers = federation.list_peers()
signals = db.recent_signals(limit=20)
return TEMPLATES.TemplateResponse(
request,
"admin_federation.html",
{
"fingerprint": federation.node_fingerprint(),
"pubkey_pem": federation.public_key_pem(),
"suggested_domain": suggested,
"dns": rec,
"peers": peers,
"signals": signals,
},
)
@app.post("/admin/federation/peers/add")
def admin_federation_add_peer(
request: Request,
domain: str = Form(...),
fingerprint: str = Form(...),
pubkey_pem: str = Form(...),
status: str = Form("unknown"),
) -> RedirectResponse:
if not _admin_ok(request):
raise HTTPException(status_code=403, detail="admin session required")
try:
federation.register_peer(domain.strip(), fingerprint.strip(), pubkey_pem.strip(), status=status)
except Exception as exc:
_log.warning("federation.peer.add.error", domain=domain, error=str(exc))
return RedirectResponse("/admin/federation", status_code=303)
@app.post("/admin/federation/peers/{domain}/status")
def admin_federation_set_status(
request: Request,
domain: str,
status: str = Form(...),
) -> RedirectResponse:
if not _admin_ok(request):
raise HTTPException(status_code=403, detail="admin session required")
try:
federation.set_peer_status(domain, status)
except ValueError as exc:
_log.warning("federation.peer.status.bad", domain=domain, status=status, error=str(exc))
return RedirectResponse("/admin/federation", status_code=303)
@app.post("/admin/federation/peers/{domain}/remove")
def admin_federation_remove(
request: Request,
domain: str,
) -> RedirectResponse:
if not _admin_ok(request):
raise HTTPException(status_code=403, detail="admin session required")
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 _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",
headers=_CORS_HEADERS,
)
@app.get("/federation/feed")
def federation_feed() -> JSONResponse:
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")

View File

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

View File

@@ -0,0 +1,155 @@
"""Cockpit routes for the Pulse scheduler — admin-gated.
The integration is intentionally single-call: `register(app, TEMPLATES)` adds
the routes AND wires the FastAPI startup hook that launches the background
scheduler loop. Caller in app.py just imports + invokes register().
"""
from __future__ import annotations
import asyncio
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 db, log
from psyc.lines import pulse
from psyc.models import Severity
_log = log.get(__name__)
TICK_INTERVAL_SECONDS = 30
def _admin_ok(request: Request) -> bool:
"""Mirror of the local helper in app.py — admin session is just session['admin_ok']."""
return bool(request.session.get("admin_ok"))
def _relative(dt: Optional[datetime]) -> str:
"""Human-friendly "3m ago" / "in 12m" / "now". None → ''."""
if dt is None:
return ""
now = datetime.now(timezone.utc)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
delta = (dt - now).total_seconds()
past = delta < 0
secs = abs(int(delta))
if secs < 5:
return "now"
if secs < 60:
unit = f"{secs}s"
elif secs < 3600:
unit = f"{secs // 60}m"
elif secs < 86400:
unit = f"{secs // 3600}h"
else:
unit = f"{secs // 86400}d"
return f"{unit} ago" if past else f"in {unit}"
def register(app: FastAPI, templates: Jinja2Templates) -> None:
"""Attach the /admin/pulse routes and the background scheduler loop to `app`."""
@app.get("/admin/pulse", response_class=HTMLResponse)
def pulse_view(request: Request) -> HTMLResponse:
if not _admin_ok(request):
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",
{
"pipelines": pipelines,
"kill_switch": pulse.kill_switch_state(),
"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],
},
)
@app.post("/admin/pulse/kill")
def pulse_toggle_kill(request: Request) -> RedirectResponse:
if not _admin_ok(request):
return RedirectResponse("/admin", status_code=303)
new = not pulse.kill_switch_state()
pulse.set_kill_switch(new)
flash = "kill switch ARMED — all pipelines halted" if new else "kill switch disarmed — pulse resumes"
return RedirectResponse(f"/admin/pulse?flash={flash}", status_code=303)
@app.post("/admin/pulse/{name}/update")
def pulse_update(
request: Request,
name: str,
mode: str = Form(...),
cadence_seconds: int = Form(...),
enabled: Optional[str] = Form(None),
) -> RedirectResponse:
if not _admin_ok(request):
return RedirectResponse("/admin", status_code=303)
try:
pulse.set_mode(name, pulse.PulseMode(mode))
pulse.set_cadence(name, int(cadence_seconds))
pulse.set_enabled(name, enabled is not None)
flash = f"updated {name}: mode={mode}, cadence={cadence_seconds}s, enabled={enabled is not None}"
except (ValueError, KeyError) as exc:
_log.warning("pulse.update.error", name=name, error=str(exc))
flash = f"update failed: {exc}"
return RedirectResponse(f"/admin/pulse?flash={flash}", status_code=303)
@app.post("/admin/pulse/{name}/run")
def pulse_run_now(request: Request, name: str) -> RedirectResponse:
if not _admin_ok(request):
return RedirectResponse("/admin", status_code=303)
try:
outcome, result = pulse.run_now(name)
flash = f"{name}{outcome}: {result[:120]}"
except ValueError as exc:
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.
asyncio.create_task(pulse.start_background_loop(interval_seconds=TICK_INTERVAL_SECONDS))
_log.info("pulse.routes.registered", tick=TICK_INTERVAL_SECONDS)

View File

@@ -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); }

View 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 =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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);
}
}
})();

File diff suppressed because it is too large Load Diff

View File

@@ -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-v2";
const CACHE_VERSION = "psyc-v11";
const STATIC_ASSETS = [
"/static/cockpit.css",
"/static/psyc-tokens.css",

View File

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

View File

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

View File

@@ -0,0 +1,132 @@
{% extends "base.html" %}
{% block title %}Federation — psyc admin{% endblock %}
{% block content %}
<section class="panel">
<div class="panel-head">
<h1>Federation Identity</h1>
<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> &nbsp;·&nbsp; <a href="/admin/federation/discovery">discovery</a> &nbsp;·&nbsp; <a href="/admin/federation/vouches">vouches</a> &nbsp;·&nbsp; <a href="/admin/federation/quorum">quorum config</a> &nbsp;·&nbsp; <a href="/admin/federation/log">transparency log</a> &nbsp;·&nbsp; <a href="/admin/federation/network">network</a></p>
<div class="card" style="margin-bottom:14px;">
<div class="lg-sub">node fingerprint</div>
<div style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:18px; word-break:break-all; margin:6px 0 12px; color:var(--accent); text-shadow:0 0 12px var(--accent-glow);">{{ fingerprint }}</div>
<details>
<summary class="lg-sub" style="cursor:pointer;">public key (PEM)</summary>
<pre style="background:var(--panel-2); border:1px solid var(--border); border-radius:6px; padding:10px; margin-top:8px; overflow-x:auto; font-size:11.5px;">{{ pubkey_pem }}</pre>
</details>
</div>
</section>
<section class="panel">
<div class="panel-head">
<h2>Publish via DNS</h2>
<span class="count">SRV + TXT records</span>
</div>
<p class="page-intro">Paste these into your zone file. Once they're live, any peer that knows your domain can discover the node and pin the right key without out-of-band coordination.</p>
<form method="get" action="/admin/federation" class="lookup-form" style="margin-bottom:12px;">
<input type="text" name="domain" value="{{ suggested_domain }}" class="lookup-input" placeholder="domain to publish on (e.g. psyc.example.com)">
<button type="submit" class="btn btn-enforce">regenerate</button>
</form>
<pre style="background:var(--panel-2); border:1px solid var(--border); border-radius:6px; padding:12px; overflow-x:auto; font-size:12px; line-height:1.5;">{{ dns.human_instructions }}</pre>
</section>
<section class="panel">
<div class="panel-head">
<h2>Known Peers</h2>
<span class="count">{{ peers|length }} registered</span>
</div>
<p class="page-intro">Trusted peers' feeds are signature-verified on every poll. Blocked peers are recorded but ignored. Unknown peers are kept for review — nothing flows from them until you set them trusted.</p>
{% if peers %}
<table class="ledger">
<thead><tr><th>Domain</th><th>Fingerprint</th><th>Status</th><th>Discovered</th><th>Last seen</th><th></th></tr></thead>
<tbody>
{% for p in peers %}
<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">unknown</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>
<form method="post" action="/admin/federation/peers/{{ p.domain }}/status" class="queue-action">
<input type="hidden" name="status" value="trusted">
<button type="submit" class="btn btn-enforce" {% if p.status == 'trusted' %}disabled{% endif %}>trust</button>
</form>
<form method="post" action="/admin/federation/peers/{{ p.domain }}/status" class="queue-action">
<input type="hidden" name="status" value="blocked">
<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"
data-confirm-revoke="peer" data-confirm-name="{{ p.domain }}">
<button type="submit" class="btn">remove</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="page-intro">(no peers yet — add one below)</p>
{% endif %}
</section>
<section class="panel">
<div class="panel-head">
<h2>Add Peer</h2>
</div>
<p class="page-intro">Pin a peer's identity manually: their domain, their fingerprint (from their DNS TXT record), and the public key they publish at <code>/federation/key</code>.</p>
<form method="post" action="/admin/federation/peers/add" style="display:grid; gap:10px; max-width:680px;">
<input type="text" name="domain" placeholder="peer domain (e.g. peer.example.com)" class="lookup-input" required>
<input type="text" name="fingerprint" placeholder="fingerprint (32 hex chars)" class="lookup-input" maxlength="64" required style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace;">
<textarea name="pubkey_pem" placeholder="-----BEGIN PUBLIC KEY-----&#10;…&#10;-----END PUBLIC KEY-----" rows="6" class="lookup-input" required style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:11.5px;"></textarea>
<select name="status" class="lookup-input">
<option value="unknown">unknown — record only, don't trust yet</option>
<option value="trusted">trusted — count toward quorum</option>
<option value="blocked">blocked — ignore</option>
</select>
<button type="submit" class="btn btn-enforce">+ register peer</button>
</form>
</section>
<section class="panel">
<div class="panel-head">
<h2>Recent Signals</h2>
<span class="count">last {{ signals|length }} of buffer</span>
</div>
<p class="page-intro">Verified federation signals from peers — case + IOC reports awaiting quorum. The signal buffer is what later quorum logic will count over.</p>
{% if signals %}
<table class="ledger">
<thead><tr><th>Received</th><th>Peer</th><th>Type</th><th>Id</th><th>Hash</th></tr></thead>
<tbody>
{% for s in signals %}
<tr class="ledger-row">
<td class="lg-ts">{{ (s.received_at or '')[:19] | replace('T', ' ') }}</td>
<td class="lg-sub" style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace;">{{ s.peer_fingerprint[:8] }}…</td>
<td><span class="sev-badge">{{ s.signal_type }}</span></td>
<td style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:11.5px;">{{ s.signal_id[:48] }}</td>
<td class="lg-sub" style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:11.5px;">{{ s.signal_hash[:16] }}…</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="page-intro">(no signals received yet — quorum stage will populate this)</p>
{% endif %}
</section>
{% endblock %}

View 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> &nbsp;·&nbsp; <a href="/admin/federation/vouches">vouches</a> &nbsp;·&nbsp; <a href="/admin/federation/quorum">quorum config</a> &nbsp;·&nbsp; <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> &nbsp; {{ 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> &nbsp; {{ 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 %}

View 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> &nbsp;·&nbsp; <a href="/admin/federation/discovery">discovery</a> &nbsp;·&nbsp; <a href="/admin/federation/vouches">vouches</a> &nbsp;·&nbsp; <a href="/admin/federation/quorum">quorum</a> &nbsp;·&nbsp; <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 %}

View 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> &nbsp;·&nbsp; <a href="/admin/federation/vouches">vouches</a> &nbsp;·&nbsp; <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 %}

View 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> &nbsp;·&nbsp; <a href="/admin/federation/quorum">quorum config</a> &nbsp;·&nbsp; <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 %}

View File

@@ -0,0 +1,181 @@
{% extends "base.html" %}
{% block title %}Pulse — psyc admin{% endblock %}
{% block content %}
<section class="panel">
<div class="panel-head">
<h1>Pulse — autonomous heartbeat</h1>
<span class="count">{{ pipelines|length }} pipeline{{ '' if pipelines|length == 1 else 's' }}</span>
</div>
<p class="page-intro">Cron-style scheduler that drives every psyc line on a cadence without human input. Each pipeline has an autonomy mode and a cadence in seconds. The kill switch halts everything instantly — it overrides cadence, mode, and the enabled flag.</p>
<p class="back"><a href="/admin">← back to admin</a></p>
{% if flash %}
<div class="verdict verdict-clean">{{ flash }}</div>
{% endif %}
</section>
<section class="panel">
<div class="panel-head">
<h2>Global kill switch</h2>
<span class="count">{{ 'ARMED' if kill_switch else 'OFF' }}</span>
</div>
{% if kill_switch %}
<div class="verdict" style="background:rgba(248,113,113,0.12); border:1px solid rgba(248,113,113,0.45); color:#fca5a5; padding:14px 18px; border-radius:6px; font-weight:700; letter-spacing:0.04em;">
✗ KILL SWITCH ARMED — every pipeline is paused. tick() returns "skipped" for everything. Run-now is also blocked. Toggle off to resume.
</div>
<form method="post" action="/admin/pulse/kill" style="margin-top:14px;">
<button type="submit" class="btn btn-approve">Disarm kill switch</button>
</form>
{% else %}
<div class="verdict verdict-clean">✓ Pulse is live — the background loop ticks every {{ tick_interval }}s.</div>
<form method="post" action="/admin/pulse/kill" style="margin-top:14px;"
onsubmit="return confirm('Arm the kill switch? Every pipeline halts immediately.');">
<button type="submit" class="btn btn-reject">Arm kill switch</button>
</form>
{% 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>
<span class="count">{{ pipelines|selectattr('enabled')|list|length }} enabled</span>
</div>
<p class="page-intro">Mode: <code>auto-execute</code> fires the line, <code>auto-propose</code> stages proposals for human approval, <code>manual</code> runs only when you press “Run now”. Cadence is the gap between ticks; the loop wakes up every {{ tick_interval }}s.</p>
<table class="ledger">
<thead>
<tr>
<th style="width:24%;">Pipeline</th>
<th>Mode · cadence · enabled</th>
<th style="width:11%;">Last fired</th>
<th style="width:11%;">Next fire</th>
<th>Last result</th>
<th style="width:1%;"></th>
</tr>
</thead>
<tbody>
{% for p in pipelines %}
<tr class="ledger-row">
<td>
<strong>{{ p.title }}</strong>
<div class="lg-sub"><code>{{ p.name }}</code> · {{ p.description }}</div>
</td>
<td>
<form method="post" action="/admin/pulse/{{ p.name }}/update" class="queue-action" style="display:flex; gap:8px; align-items:center; flex-wrap:wrap;">
<select name="mode" style="background:var(--panel-2); color:var(--text); border:1px solid var(--border); border-radius:4px; padding:4px 6px;">
<option value="auto-execute" {% if p.mode.value == 'auto-execute' %}selected{% endif %}>auto-execute</option>
<option value="auto-propose" {% if p.mode.value == 'auto-propose' %}selected{% endif %}>auto-propose</option>
<option value="manual" {% if p.mode.value == 'manual' %}selected{% endif %}>manual</option>
</select>
<input type="number" name="cadence_seconds" value="{{ p.cadence_seconds }}" min="1" step="1"
style="background:var(--panel-2); color:var(--text); border:1px solid var(--border); border-radius:4px; padding:4px 6px; width:84px;" title="cadence in seconds">
<label style="display:inline-flex; align-items:center; gap:4px; font-size:12px;">
<input type="checkbox" name="enabled" value="1" {% if p.enabled %}checked{% endif %}> enabled
</label>
<button type="submit" class="btn">save</button>
</form>
</td>
<td class="lg-ts">
{% if p.last_fired %}<span title="{{ p.last_fired.isoformat() }}">{{ relative(p.last_fired) }}</span>{% else %}—{% endif %}
</td>
<td class="lg-ts">
{% if p.next_fire %}<span title="{{ p.next_fire.isoformat() }}">{{ relative(p.next_fire) }}</span>{% else %}—{% endif %}
</td>
<td class="lg-sub" style="max-width:0;">
{% if p.last_outcome == 'ok' %}<span style="color: var(--green);"></span>
{% elif p.last_outcome == 'err' %}<span style="color: var(--red);"></span>
{% elif p.last_outcome == 'skipped' %}<span style="color: var(--muted);"></span>
{% endif %}
{{ (p.last_result or '—')[:60] }}{% if (p.last_result or '')|length > 60 %}…{% endif %}
</td>
<td>
<form method="post" action="/admin/pulse/{{ p.name }}/run" class="queue-action">
<button type="submit" class="btn btn-enforce" title="Fire now, regardless of cadence">Run now</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% endblock %}

View File

@@ -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 %}">
@@ -69,6 +84,8 @@
</svg>
Admin
</a>
<a href="/admin/pulse" class="nav-admin" title="Autonomy scheduler">Pulse</a>
<a href="/admin/federation" class="nav-admin" title="Federation peers">Federation</a>
{% endif %}
</nav>
{% if request.session.get('admin_who') %}

View File

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

View 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>

View File

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

View File

@@ -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
@@ -114,16 +116,126 @@ Index("iocs_value_idx", iocs.c.value)
Index("iocs_type_idx", iocs.c.ioc_type)
Index("iocs_case_idx", iocs.c.case_id)
pulse_pipelines = Table(
"pulse_pipelines", _metadata,
Column("name", String, primary_key=True),
Column("title", String, nullable=False),
Column("description", Text, nullable=False, default=""),
Column("mode", String, nullable=False), # manual | auto-propose | auto-execute
Column("cadence_seconds", Integer, nullable=False),
Column("enabled", Boolean, nullable=False, default=True),
Column("last_fired", String, nullable=True), # ISO timestamp or NULL
Column("next_fire", String, nullable=True), # ISO timestamp or NULL
Column("last_result", Text, nullable=False, default=""),
Column("last_outcome", String, nullable=False, default=""), # ok | err | skipped | ""
)
pulse_settings = Table(
"pulse_settings", _metadata,
Column("key", String, primary_key=True),
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),
Column("fingerprint", String, nullable=False),
Column("pubkey_pem", Text, nullable=False),
Column("status", String, nullable=False), # unknown | trusted | blocked
Column("discovered_at", String, nullable=False),
Column("last_seen", String, nullable=True),
Column("notes", Text, nullable=True),
)
Index("peers_fp_idx", peers.c.fingerprint)
Index("peers_status_idx", peers.c.status)
federation_signals = Table(
"federation_signals", _metadata,
Column("id", Integer, primary_key=True, autoincrement=True),
Column("peer_fingerprint", String, nullable=False),
Column("signal_type", String, nullable=False), # case | ioc
Column("signal_id", String, nullable=False), # case_id or ioc value
Column("signal_hash", String, nullable=False), # sha256 of canonical record
Column("received_at", String, nullable=False),
Column("raw_json", Text, nullable=False),
)
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
@@ -214,3 +326,296 @@ def ioc_count(db_path: Path = DB_PATH) -> int:
stmt = select(func.count()).select_from(iocs)
with engine(db_path).connect() as conn:
return conn.execute(stmt).scalar_one()
# ---------- pulse scheduler ----------------------------------------------
def get_pulse_state(db_path: Path = DB_PATH) -> List[dict]:
"""Every registered pipeline, ordered by name."""
stmt = select(pulse_pipelines).order_by(pulse_pipelines.c.name)
with engine(db_path).connect() as conn:
return [dict(r._mapping) for r in conn.execute(stmt).fetchall()]
def upsert_pulse_pipeline(row: dict, db_path: Path = DB_PATH) -> None:
"""Insert or update one pipeline by name."""
stmt = sqlite_insert(pulse_pipelines).values(**row)
stmt = stmt.on_conflict_do_update(
index_elements=[pulse_pipelines.c.name],
set_=dict(
title=stmt.excluded.title,
description=stmt.excluded.description,
mode=stmt.excluded.mode,
cadence_seconds=stmt.excluded.cadence_seconds,
enabled=stmt.excluded.enabled,
last_fired=stmt.excluded.last_fired,
next_fire=stmt.excluded.next_fire,
last_result=stmt.excluded.last_result,
last_outcome=stmt.excluded.last_outcome,
),
)
with engine(db_path).begin() as conn:
conn.execute(stmt)
def kill_switch_get(db_path: Path = DB_PATH) -> bool:
stmt = select(pulse_settings.c.value).where(pulse_settings.c.key == "kill_switch")
with engine(db_path).connect() as conn:
row = conn.execute(stmt).fetchone()
if row is None:
return False
return str(row.value) == "1"
def kill_switch_set(armed: bool, db_path: Path = DB_PATH) -> None:
value = "1" if armed else "0"
stmt = sqlite_insert(pulse_settings).values(key="kill_switch", 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)
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:
"""Insert-or-update a peer by domain. `row` must include `domain`."""
stmt = sqlite_insert(peers).values(**row)
update_cols = {k: stmt.excluded[k] for k in row if k != "domain"}
stmt = stmt.on_conflict_do_update(index_elements=[peers.c.domain], set_=update_cols)
with engine(db_path).begin() as conn:
conn.execute(stmt)
def list_peers(db_path: Path = DB_PATH) -> List[dict]:
stmt = select(peers).order_by(peers.c.discovered_at.desc())
with engine(db_path).connect() as conn:
return [dict(r._mapping) for r in conn.execute(stmt).fetchall()]
def get_peer(domain: str, db_path: Path = DB_PATH) -> Optional[dict]:
stmt = select(peers).where(peers.c.domain == domain)
with engine(db_path).connect() as conn:
row = conn.execute(stmt).fetchone()
return dict(row._mapping) if row else None
def set_peer_status(domain: str, status: str, db_path: Path = DB_PATH) -> None:
from sqlalchemy import update as sa_update
stmt = sa_update(peers).where(peers.c.domain == domain).values(status=status)
with engine(db_path).begin() as conn:
conn.execute(stmt)
def remove_peer(domain: str, db_path: Path = DB_PATH) -> None:
stmt = peers.delete().where(peers.c.domain == domain)
with engine(db_path).begin() as conn:
conn.execute(stmt)
def record_signal(row: dict, db_path: Path = DB_PATH) -> int:
"""Append one federation signal. Returns the inserted row id."""
stmt = insert(federation_signals).values(**row)
with engine(db_path).begin() as conn:
res = conn.execute(stmt)
return int(res.inserted_primary_key[0])
def signals_for_hash(signal_hash: str, db_path: Path = DB_PATH) -> List[dict]:
"""All recorded signals matching `signal_hash` — quorum-lookup primitive."""
stmt = select(federation_signals).where(federation_signals.c.signal_hash == signal_hash)
with engine(db_path).connect() as conn:
return [dict(r._mapping) for r in conn.execute(stmt).fetchall()]
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
View 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

View File

@@ -0,0 +1,746 @@
"""Federation — node identity, signed feeds, peer registry.
Identity layer for internet-wide federation of psyc nodes. Each node owns
an Ed25519 keypair persisted under DATA_DIR/federation/. The public key
fingerprint (first 16 bytes of SHA256(raw_pubkey) hex-encoded) goes into a
DNS TXT record so peers can discover and authenticate the node, and the
private key signs the outbound feed at /federation/feed.
This module is the *identity* primitives only — discovery walkers,
vouching/quorum, transparency log and auto-pull live in later stages.
"""
from __future__ import annotations
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
_log = log.get(__name__)
FED_DIR = DATA_DIR / "federation"
PRIVATE_KEY_PATH = FED_DIR / "node.key"
PUBLIC_KEY_PATH = FED_DIR / "node.pub"
FEED_VERSION = "psyc1"
FEED_ALG = "ed25519"
FEED_PATH = "/federation/feed"
# ---------- keypair persistence -----------------------------------------
def _ensure_dir() -> None:
FED_DIR.mkdir(parents=True, exist_ok=True)
def node_keypair() -> Tuple[ed25519.Ed25519PrivateKey, ed25519.Ed25519PublicKey]:
"""Return the node's Ed25519 keypair, generating + persisting it on first call.
Private key lands at data/federation/node.key (PEM, chmod 0600); public
at data/federation/node.pub (PEM). Idempotent — subsequent calls load
the existing files instead of generating new ones.
"""
_ensure_dir()
if PRIVATE_KEY_PATH.exists() and PUBLIC_KEY_PATH.exists():
priv_pem = PRIVATE_KEY_PATH.read_bytes()
priv = serialization.load_pem_private_key(priv_pem, password=None)
if not isinstance(priv, ed25519.Ed25519PrivateKey):
raise RuntimeError(f"federation key at {PRIVATE_KEY_PATH} is not Ed25519")
return priv, priv.public_key()
priv = ed25519.Ed25519PrivateKey.generate()
priv_pem = priv.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
pub = priv.public_key()
pub_pem = pub.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
PRIVATE_KEY_PATH.write_bytes(priv_pem)
os.chmod(PRIVATE_KEY_PATH, 0o600)
PUBLIC_KEY_PATH.write_bytes(pub_pem)
_log.info("federation.keypair.generated", path=str(PRIVATE_KEY_PATH))
return priv, pub
def public_key_pem() -> str:
"""PEM-encoded public key as text — what peers store + verify against."""
_, pub = node_keypair()
return pub.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
).decode("ascii")
def _raw_pubkey_bytes(pub: ed25519.Ed25519PublicKey) -> bytes:
return pub.public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw,
)
def node_fingerprint() -> str:
"""Short stable id for the node — first 16 bytes of SHA256(raw_pubkey), hex.
Lives in DNS TXT records; 32 hex chars is short enough to fit but long
enough to be collision-safe for any plausible peer population.
"""
_, pub = node_keypair()
digest = hashlib.sha256(_raw_pubkey_bytes(pub)).digest()
return digest[:16].hex()
def _fingerprint_for_pubkey_pem(pubkey_pem: str) -> str:
pub = serialization.load_pem_public_key(pubkey_pem.encode("ascii"))
if not isinstance(pub, ed25519.Ed25519PublicKey):
raise ValueError("not an Ed25519 public key")
return hashlib.sha256(_raw_pubkey_bytes(pub)).digest()[:16].hex()
# ---------- DNS record format -------------------------------------------
class DNSRecord(BaseModel):
"""The SRV + TXT pair an admin pastes into their zone file."""
srv_name: str
srv_target: str
srv_port: int
srv_priority: int = 10
srv_weight: int = 10
txt_name: str
txt_value: str
human_instructions: str
def dns_record(domain: str, port: int = 443) -> DNSRecord:
"""Build the DNS-SD-style records that advertise this node at `domain`."""
fp = node_fingerprint()
srv_name = f"_psyc._tcp.{domain}"
srv_target = f"{domain}."
txt_name = srv_name
txt_value = f"v={FEED_VERSION} fp={fp} alg={FEED_ALG} path={FEED_PATH}"
instructions = (
f"; psyc federation records for {domain}\n"
f"; ----------------------------------------------------------\n"
f"; 1) SRV record — locates this psyc node (host + port).\n"
f'{srv_name}. 3600 IN SRV 10 10 {port} {srv_target}\n'
f";\n"
f"; 2) TXT record — declares protocol version, key fingerprint,\n"
f"; signature algorithm, and the feed endpoint path.\n"
f'{txt_name}. 3600 IN TXT "{txt_value}"\n'
f"; ----------------------------------------------------------\n"
f"; Once these are live, federation peers can fetch:\n"
f"; https://{domain}{FEED_PATH} (signed feed JSON)\n"
f"; https://{domain}/federation/key (public key PEM)\n"
f"; https://{domain}/federation/info (capabilities)\n"
)
return DNSRecord(
srv_name=srv_name,
srv_target=srv_target,
srv_port=port,
txt_name=txt_name,
txt_value=txt_value,
human_instructions=instructions,
)
# ---------- signing -----------------------------------------------------
def canonical_json(obj: Dict[str, Any]) -> bytes:
"""Deterministic JSON serialization — what we sign + hash over."""
return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
def sign_payload(payload: bytes) -> bytes:
"""Ed25519 signature over `payload`. Raw 64-byte sig."""
priv, _ = node_keypair()
return priv.sign(payload)
def verify_payload(payload: bytes, signature: bytes, pubkey_pem: str) -> bool:
"""True iff `signature` verifies under `pubkey_pem`. Never raises."""
try:
pub = serialization.load_pem_public_key(pubkey_pem.encode("ascii"))
if not isinstance(pub, ed25519.Ed25519PublicKey):
return False
pub.verify(signature, payload)
return True
except Exception:
return False
# ---------- feed export -------------------------------------------------
def _case_digest(case_record: Dict[str, Any]) -> str:
return hashlib.sha256(canonical_json(case_record)).hexdigest()
def _build_case_records(window_hours: int) -> List[Dict[str, Any]]:
cutoff = datetime.now(timezone.utc) - timedelta(hours=window_hours)
out: List[Dict[str, Any]] = []
for case in db.list_cases(limit=10_000):
if case.ingested_at < cutoff:
continue
record: Dict[str, Any] = {
"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]
),
}
record["digest_sha256"] = _case_digest(
{k: v for k, v in record.items() if k != "digest_sha256"}
)
out.append(record)
return out
def _build_ioc_records(window_hours: int) -> List[Dict[str, Any]]:
cutoff = datetime.now(timezone.utc) - timedelta(hours=window_hours)
out: List[Dict[str, Any]] = []
seen: set = set()
for ioc_type in ("url", "domain", "ip", "hash", "cve"):
for row in db.iocs_by_type(ioc_type):
first_seen = row.get("first_seen")
if first_seen:
try:
if datetime.fromisoformat(first_seen) < cutoff:
continue
except ValueError:
pass
key = (row["value"], row["ioc_type"])
if key in seen:
continue
seen.add(key)
record = {
"value": row["value"],
"type": row["ioc_type"],
"severity": row.get("severity"),
"first_seen": first_seen,
}
record["digest_sha256"] = hashlib.sha256(canonical_json(record)).hexdigest()
out.append(record)
return out
def build_signed_feed(window_hours: int = 24) -> Dict[str, Any]:
"""Build the JSON feed peers will pull from /federation/feed.
Pulls cases ingested in the last `window_hours` plus the corresponding
IOC slice, attaches per-record `digest_sha256` (so peers can later
quorum-match across nodes), and signs the canonical JSON of the whole
payload-minus-signature with our Ed25519 key.
"""
payload: Dict[str, Any] = {
"version": FEED_VERSION,
"fingerprint": node_fingerprint(),
"generated_at": datetime.now(timezone.utc).isoformat(),
"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")
return payload
# ---------- import + quorum-signal buffer -------------------------------
class ImportSummary(BaseModel):
peer_fingerprint: str
cases_seen: int
iocs_seen: int
signal_ids: List[Tuple[str, str]] = Field(default_factory=list)
def import_signed_feed(feed: Dict[str, Any], expected_pubkey_pem: str) -> Result[ImportSummary, str]:
"""Verify + record a peer's feed into the federation_signals buffer.
Does NOT merge into the local case store — that's the quorum stage's
job. The buffer is the per-hash signal log that quorum logic later
aggregates ("3 trusted peers reported this same IOC → promote").
"""
sig_b64 = feed.get("signature")
if not sig_b64:
return Err("missing signature")
try:
signature = base64.b64decode(sig_b64)
except Exception:
return Err("malformed signature (not base64)")
unsigned = {k: v for k, v in feed.items() if k != "signature"}
if not verify_payload(canonical_json(unsigned), signature, expected_pubkey_pem):
return Err("signature verification failed")
peer_fp = feed.get("fingerprint", "")
if not peer_fp:
return Err("missing fingerprint")
if peer_fp == node_fingerprint():
return Err("loop: own feed")
# Cross-check the declared fingerprint matches the pubkey we verified with.
try:
if _fingerprint_for_pubkey_pem(expected_pubkey_pem) != peer_fp:
return Err("fingerprint does not match provided pubkey")
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 ""
digest = c.get("digest_sha256") or hashlib.sha256(canonical_json(c)).hexdigest()
db.record_signal(dict(
peer_fingerprint=peer_fp,
signal_type="case",
signal_id=case_id,
signal_hash=digest,
received_at=now,
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 ""
digest = i.get("digest_sha256") or hashlib.sha256(canonical_json(i)).hexdigest()
db.record_signal(dict(
peer_fingerprint=peer_fp,
signal_type="ioc",
signal_id=value,
signal_hash=digest,
received_at=now,
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(
peer_fingerprint=peer_fp,
cases_seen=len(cases),
iocs_seen=len(iocs),
signal_ids=signal_ids,
))
# ---------- peer registry ------------------------------------------------
class Peer(BaseModel):
domain: str
fingerprint: str
pubkey_pem: str
status: str = "unknown" # unknown | trusted | blocked
discovered_at: str
last_seen: Optional[str] = None
notes: Optional[str] = None
def _row_to_peer(row: Dict[str, Any]) -> Peer:
return Peer(
domain=row["domain"],
fingerprint=row["fingerprint"],
pubkey_pem=row["pubkey_pem"],
status=row.get("status") or "unknown",
discovered_at=row.get("discovered_at") or "",
last_seen=row.get("last_seen"),
notes=row.get("notes"),
)
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`.
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
db.upsert_peer(dict(
domain=domain,
fingerprint=fingerprint,
pubkey_pem=pubkey_pem,
status=status,
discovered_at=discovered_at,
last_seen=now,
notes=existing.get("notes") if existing else None,
))
def list_peers() -> List[Peer]:
return [_row_to_peer(r) for r in db.list_peers()]
def get_peer(domain: str) -> Optional[Peer]:
row = db.get_peer(domain)
return _row_to_peer(row) if row else None
def set_peer_status(domain: str, status: str) -> None:
if status not in ("unknown", "trusted", "blocked"):
raise ValueError(f"unknown peer status: {status}")
db.set_peer_status(domain, status)
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])

File diff suppressed because it is too large Load Diff

682
src/psyc/lines/pulse.py Normal file
View File

@@ -0,0 +1,682 @@
"""Pulseline — cron-style scheduler that drives every psyc pipeline on a cadence.
Each registered pipeline has an autonomy mode (manual / auto-propose /
auto-execute) and a cadence in seconds. tick() iterates every pipeline and
fires whichever ones are due (and enabled, and not manual, and the global kill
switch is off). State persists to SQLite via psyc.db so cadences survive
restarts. A background asyncio loop calls tick() at a fixed interval — the
cockpit lifespan attaches it.
NOTE: federation pipelines (peer-pull, vouch-refresh) are wired as placeholders
that return a no-op string. Real federation lands in a later stage.
"""
from __future__ import annotations
import asyncio
import traceback
from datetime import datetime, timedelta, timezone
from enum import Enum
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__)
class PulseMode(str, Enum):
MANUAL = "manual"
AUTO_PROPOSE = "auto-propose"
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
description: str
mode: PulseMode
cadence_seconds: int
enabled: bool = True
last_fired: Optional[datetime] = None
next_fire: Optional[datetime] = None
last_result: str = ""
last_outcome: str = "" # "ok" | "err" | "skipped" | ""
# ---------- pipeline runners --------------------------------------------------
def _run_fetch() -> str:
"""Fetch every enabled scout source; partial fetch is fine.
Skip-on-fail is critical: keyed feeds (threatfox, malware-bazaar, otx)
raise when their key isn't configured — we don't want one missing key to
block the public ones.
"""
from psyc.lines import scout
plan: Tuple[Tuple[str, Optional[int]], ...] = (
("urlhaus", 50),
("cisa-kev", 100),
("feodo", 50),
("threatfox", 200),
("malware-bazaar", 100),
("otx", 100),
)
total = 0
feeds_ok = 0
feeds_err: List[str] = []
for source, limit in plan:
try:
cases = scout.fetch_and_signal(source, limit=limit)
for c in cases:
db.upsert_case(c)
total += len(cases)
feeds_ok += 1
except Exception as exc: # noqa: BLE001 — partial fetch is the point
feeds_err.append(source)
_log.info("pulse.fetch.skip", source=source, error=str(exc)[:200])
tail = f" (skipped: {', '.join(feeds_err)})" if feeds_err else ""
return f"fetched {total} cases across {feeds_ok} feed(s){tail}"
def _run_classify() -> str:
from psyc.lines import classify
cases = db.list_cases(limit=10_000)
n = 0
for c in cases:
classify.classify(c)
db.upsert_case(c)
n += 1
return f"classified {n} case(s)"
def _run_prove() -> str:
from psyc.lines import proof
cases = db.list_cases(limit=10_000)
n = 0
for c in cases:
proof.prove(c)
db.upsert_case(c)
n += 1
return f"proved {n} case(s)"
def _run_reindex() -> str:
from psyc.lines import lookup
cases = db.list_cases(limit=1_000_000)
written = lookup.reindex(cases)
return f"indexed {written} IOC(s) from {len(cases)} case(s)"
def _propose_for_recent_cases() -> int:
"""Propose response actions for high-severity cases that don't yet have any.
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
for c in cases:
ids = respond.propose_for_case(c)
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:
"""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:
return "federation not yet active"
# ---------- registry ----------------------------------------------------------
_REGISTRY: Dict[str, Callable[[], str]] = {
"fetch": _run_fetch,
"classify": _run_classify,
"prove": _run_prove,
"reindex": _run_reindex,
"respond": _run_respond,
"peer-pull": _run_peer_pull,
"vouch-refresh": _run_vouch_refresh,
}
# Initial defaults — seeded once on first DB init. Tuples of
# (name, title, description, mode, cadence_seconds, enabled).
_DEFAULTS: Tuple[Tuple[str, str, str, PulseMode, int, bool], ...] = (
("fetch", "Scout · fetch feeds", "Pull every configured threat feed and ingest new cases.",
PulseMode.AUTO_EXECUTE, 900, True),
("classify", "Classify · label cases", "Assign incident type, severity, TLP, and internal class to every case.",
PulseMode.AUTO_EXECUTE, 300, True),
("prove", "Proof · score confidence", "Compute confidence (reliability · credibility · freshness) for every case.",
PulseMode.AUTO_EXECUTE, 300, True),
("reindex", "Lookup · rebuild IOC index","Rebuild the IOC reverse-index over the case corpus.",
PulseMode.AUTO_EXECUTE, 3600, True),
("respond", "Respond · propose actions", "Propose human-gated response actions for newly high-severity cases.",
PulseMode.AUTO_PROPOSE, 600, True),
("peer-pull", "Federation · peer pull", "(placeholder) Pull sealed cases from federated peers.",
PulseMode.MANUAL, 600, False),
("vouch-refresh","Federation · vouch refresh","(placeholder) Refresh per-peer vouching ledgers.",
PulseMode.MANUAL, 3600, False),
)
# ---------- helpers -----------------------------------------------------------
def _now() -> datetime:
return datetime.now(timezone.utc)
def _parse_dt(value: Optional[str]) -> Optional[datetime]:
if not value:
return None
try:
return datetime.fromisoformat(value)
except ValueError:
return None
def _row_to_pipeline(row: dict) -> Pipeline:
return Pipeline(
name=row["name"],
title=row["title"],
description=row["description"],
mode=PulseMode(row["mode"]),
cadence_seconds=int(row["cadence_seconds"]),
enabled=bool(row["enabled"]),
last_fired=_parse_dt(row.get("last_fired")),
next_fire=_parse_dt(row.get("next_fire")),
last_result=row.get("last_result") or "",
last_outcome=row.get("last_outcome") or "",
)
def _pipeline_to_row(p: Pipeline) -> dict:
return dict(
name=p.name,
title=p.title,
description=p.description,
mode=p.mode.value,
cadence_seconds=int(p.cadence_seconds),
enabled=bool(p.enabled),
last_fired=p.last_fired.isoformat() if p.last_fired else None,
next_fire=p.next_fire.isoformat() if p.next_fire else None,
last_result=p.last_result or "",
last_outcome=p.last_outcome or "",
)
def seed_defaults() -> None:
"""Insert any default pipelines that aren't already in the DB. Idempotent."""
existing = {row["name"] for row in db.get_pulse_state()}
for name, title, desc, mode, cadence, enabled in _DEFAULTS:
if name in existing:
continue
p = Pipeline(
name=name, title=title, description=desc,
mode=mode, cadence_seconds=cadence, enabled=enabled,
next_fire=_now(), # first tick after install fires immediately if due
)
db.upsert_pulse_pipeline(_pipeline_to_row(p))
_log.info("pulse.defaults.seeded", count=len(_DEFAULTS))
def state() -> List[Pipeline]:
"""Every pipeline, ordered by name. Seeds defaults on the first call."""
rows = db.get_pulse_state()
if not rows:
seed_defaults()
rows = db.get_pulse_state()
return [_row_to_pipeline(r) for r in rows]
def _get_pipeline(name: str) -> Optional[Pipeline]:
for p in state():
if p.name == name:
return p
return None
def set_mode(name: str, mode: PulseMode) -> None:
p = _get_pipeline(name)
if p is None:
raise ValueError(f"unknown pipeline: {name}")
p.mode = mode
db.upsert_pulse_pipeline(_pipeline_to_row(p))
def set_cadence(name: str, seconds: int) -> None:
if seconds <= 0:
raise ValueError("cadence must be > 0 seconds")
p = _get_pipeline(name)
if p is None:
raise ValueError(f"unknown pipeline: {name}")
p.cadence_seconds = int(seconds)
db.upsert_pulse_pipeline(_pipeline_to_row(p))
def set_enabled(name: str, enabled: bool) -> None:
p = _get_pipeline(name)
if p is None:
raise ValueError(f"unknown pipeline: {name}")
p.enabled = bool(enabled)
db.upsert_pulse_pipeline(_pipeline_to_row(p))
def set_kill_switch(armed: bool) -> None:
db.kill_switch_set(armed)
_log.warning("pulse.killswitch.changed", armed=bool(armed))
def kill_switch_state() -> bool:
return db.kill_switch_get()
# ---------- the heartbeat -----------------------------------------------------
def _fire(p: Pipeline) -> Tuple[str, str]:
"""Run one pipeline. Returns (outcome, result_str). Persists the timestamps.
Outcome is "ok" if the runner returned, "err" if it raised.
"""
runner = _REGISTRY.get(p.name)
if runner is None:
outcome = "err"
result = f"no runner registered for '{p.name}'"
else:
try:
result = runner() or ""
outcome = "ok"
except Exception as exc: # noqa: BLE001 — log + record, don't crash the loop
outcome = "err"
result = f"{type(exc).__name__}: {exc}"
_log.warning("pulse.fire.error", name=p.name, error=result, trace=traceback.format_exc())
now = _now()
p.last_fired = now
p.next_fire = now + timedelta(seconds=max(1, p.cadence_seconds))
p.last_result = result[:500]
p.last_outcome = outcome
db.upsert_pulse_pipeline(_pipeline_to_row(p))
_log.info("pulse.fired", name=p.name, outcome=outcome, result=p.last_result)
return outcome, p.last_result
def _should_fire(p: Pipeline, now: datetime) -> bool:
if not p.enabled:
return False
if p.mode == PulseMode.MANUAL:
return False
if p.next_fire is None:
return True
return now >= p.next_fire
def tick() -> List[Tuple[str, str, str]]:
"""Single scheduler heartbeat. Returns (name, outcome, result_str) per pipeline.
Outcome is "ok" / "err" / "skipped" — every registered pipeline appears in
the return value so callers can see what was skipped and why.
"""
if kill_switch_state():
_log.info("pulse.tick.killed")
return [(p.name, "skipped", "kill switch armed") for p in state()]
now = _now()
out: List[Tuple[str, str, str]] = []
for p in state():
if not _should_fire(p, now):
out.append((p.name, "skipped", "not due"))
continue
outcome, result = _fire(p)
out.append((p.name, outcome, result))
return out
def run_now(name: str) -> Tuple[str, str]:
"""Manually fire one pipeline, bypassing cadence and mode. Honors kill switch.
Returns (outcome, result_str). Raises ValueError on unknown name.
"""
if kill_switch_state():
return ("skipped", "kill switch armed")
p = _get_pipeline(name)
if p is None:
raise ValueError(f"unknown pipeline: {name}")
return _fire(p)
# ---------- background loop ---------------------------------------------------
async def start_background_loop(interval_seconds: int = 30) -> None:
"""Long-running scheduler — calls tick() every interval. Launched from FastAPI lifespan.
Designed to run for the life of the process; cancellation is the normal stop signal.
"""
_log.info("pulse.loop.starting", interval=interval_seconds)
while True:
try:
tick()
except Exception as exc: # noqa: BLE001 — one bad tick must not kill the loop
_log.warning("pulse.loop.tick.error", error=str(exc), trace=traceback.format_exc())
try:
await asyncio.sleep(interval_seconds)
except asyncio.CancelledError:
_log.info("pulse.loop.cancelled")
raise

View 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
View 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
View 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
View 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"

262
tests/test_federation.py Normal file
View File

@@ -0,0 +1,262 @@
"""Federation — identity, signed feed, peer registry, signal buffer."""
from __future__ import annotations
import base64
import os
import stat
import pytest
from sqlalchemy import create_engine
from psyc import db
from psyc.lines import federation
from psyc.lines.federation import (
DNSRecord,
build_signed_feed,
canonical_json,
dns_record,
import_signed_feed,
node_fingerprint,
node_keypair,
public_key_pem,
sign_payload,
verify_payload,
)
from psyc.result import Err, Ok
from conftest import make_case
@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):
"""Redirect federation key paths to a tmp dir so each test gets a fresh key."""
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 test_keypair_persisted_with_correct_perms(fed_dir):
priv, pub = node_keypair()
assert federation.PRIVATE_KEY_PATH.exists()
assert federation.PUBLIC_KEY_PATH.exists()
mode = stat.S_IMODE(os.stat(federation.PRIVATE_KEY_PATH).st_mode)
assert mode == 0o600
def test_keypair_idempotent_across_calls(fed_dir):
priv1, pub1 = node_keypair()
priv2, pub2 = node_keypair()
# raw bytes match — same key loaded twice, not regenerated
raw1 = pub1.public_bytes(
encoding=federation.serialization.Encoding.Raw,
format=federation.serialization.PublicFormat.Raw,
)
raw2 = pub2.public_bytes(
encoding=federation.serialization.Encoding.Raw,
format=federation.serialization.PublicFormat.Raw,
)
assert raw1 == raw2
def test_fingerprint_is_stable_and_32_hex(fed_dir):
fp1 = node_fingerprint()
fp2 = node_fingerprint()
assert fp1 == fp2
assert len(fp1) == 32
assert all(c in "0123456789abcdef" for c in fp1)
def test_sign_verify_roundtrip(fed_dir):
payload = b"hello federation"
sig = sign_payload(payload)
assert verify_payload(payload, sig, public_key_pem()) is True
def test_verify_with_wrong_key_returns_false(fed_dir, tmp_path):
payload = b"the truth"
sig = sign_payload(payload)
# Build a *different* keypair in a separate directory and use its pubkey.
other = tmp_path / "other-federation"
other.mkdir()
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ed25519
other_priv = ed25519.Ed25519PrivateKey.generate()
other_pub_pem = other_priv.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
).decode("ascii")
assert verify_payload(payload, sig, other_pub_pem) is False
def test_verify_with_garbage_pubkey_returns_false_no_raise(fed_dir):
sig = sign_payload(b"x")
assert verify_payload(b"x", sig, "not a pem") is False
def test_canonical_json_is_deterministic():
a = canonical_json({"b": 1, "a": 2, "nested": {"y": 1, "x": 2}})
b = canonical_json({"a": 2, "b": 1, "nested": {"x": 2, "y": 1}})
assert a == b
def test_dns_record_txt_value_matches_spec(fed_dir):
rec = dns_record("example.com")
assert isinstance(rec, DNSRecord)
fp = node_fingerprint()
assert rec.srv_name == "_psyc._tcp.example.com"
assert rec.srv_target == "example.com."
assert rec.srv_port == 443
assert rec.txt_value == f"v=psyc1 fp={fp} alg=ed25519 path=/federation/feed"
# human instructions include both record lines
assert "_psyc._tcp.example.com" in rec.human_instructions
assert "SRV" in rec.human_instructions
assert "TXT" in rec.human_instructions
def test_build_then_import_signed_feed_roundtrip(fresh_db, fed_dir):
case = make_case(feed="urlhaus", ips=["1.1.1.1"], urls=["http://1.1.1.1/x"])
db.upsert_case(case)
feed = build_signed_feed(window_hours=24)
assert feed["version"] == "psyc1"
assert feed["fingerprint"] == node_fingerprint()
assert feed["signature"]
# cases entry made it in
assert any(c["case_id"] == case.case_id for c in feed["cases"])
# Import using our own pubkey against a *different* declared fingerprint:
# swap the fingerprint so import_signed_feed doesn't reject as a loop.
pub = public_key_pem()
# Use a fresh keypair to act as "peer" — sign a feed with that key.
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ed25519
import hashlib
peer_priv = ed25519.Ed25519PrivateKey.generate()
peer_pub_pem = peer_priv.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
).decode("ascii")
peer_raw = peer_priv.public_key().public_bytes(
encoding=serialization.Encoding.Raw,
format=serialization.PublicFormat.Raw,
)
peer_fp = hashlib.sha256(peer_raw).digest()[:16].hex()
feed["fingerprint"] = peer_fp
unsigned = {k: v for k, v in feed.items() if k != "signature"}
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
assert summary.peer_fingerprint == peer_fp
assert summary.cases_seen >= 1
def test_import_with_wrong_pubkey_returns_err(fresh_db, fed_dir):
db.upsert_case(make_case(feed="urlhaus", ips=["2.2.2.2"]))
feed = build_signed_feed(window_hours=24)
# build a *different* pubkey to claim verification against
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ed25519
other_pub_pem = ed25519.Ed25519PrivateKey.generate().public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
).decode("ascii")
# also need to change fingerprint so the loop-check doesn't trigger first
feed["fingerprint"] = "deadbeef" * 4
result = import_signed_feed(feed, other_pub_pem)
assert isinstance(result, Err)
def test_import_own_feed_returns_loop_err(fresh_db, fed_dir):
db.upsert_case(make_case(feed="urlhaus", ips=["3.3.3.3"]))
feed = build_signed_feed(window_hours=24)
result = import_signed_feed(feed, public_key_pem())
assert isinstance(result, Err)
assert "loop" in result.reason
def test_record_signal_then_lookup_by_hash(fresh_db):
rid = db.record_signal(dict(
peer_fingerprint="abc123",
signal_type="ioc",
signal_id="1.2.3.4",
signal_hash="hash-aaa",
received_at="2026-01-01T00:00:00+00:00",
raw_json="{}",
))
assert rid > 0
rows = db.signals_for_hash("hash-aaa")
assert len(rows) == 1
assert rows[0]["peer_fingerprint"] == "abc123"
# second peer reports the same hash → both surface for quorum check
db.record_signal(dict(
peer_fingerprint="def456",
signal_type="ioc",
signal_id="1.2.3.4",
signal_hash="hash-aaa",
received_at="2026-01-01T00:01:00+00:00",
raw_json="{}",
))
rows = db.signals_for_hash("hash-aaa")
assert {r["peer_fingerprint"] for r in rows} == {"abc123", "def456"}
def test_peer_registry_crud(fresh_db, fed_dir):
federation.register_peer("peer.example", "ff" * 16, "PEM", status="trusted")
peers = federation.list_peers()
assert len(peers) == 1
assert peers[0].domain == "peer.example"
assert peers[0].status == "trusted"
federation.set_peer_status("peer.example", "blocked")
assert federation.get_peer("peer.example").status == "blocked"
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
View 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
View 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

218
tests/test_pulse.py Normal file
View File

@@ -0,0 +1,218 @@
"""Pulse scheduler tests — cadence, kill switch, mode gating, persistence."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import pytest
from sqlalchemy import create_engine
from psyc import db
from psyc.lines import pulse
@pytest.fixture
def fresh_db(tmp_path, monkeypatch):
"""A temp SQLite + a swapped-in registry of fast, deterministic runners.
Real runners would talk to scout / classify / proof / etc — none of which
we want to exercise from a pulse-only test. We replace _REGISTRY in-place
so tick() drives the scheduler logic against trivial counters.
"""
test_db = tmp_path / "pulse.db"
test_engine = create_engine(f"sqlite:///{test_db}", future=True)
db._metadata.create_all(test_engine, checkfirst=True)
monkeypatch.setattr(db, "_engine", test_engine)
monkeypatch.setattr(db, "DB_PATH", test_db)
counters = {name: 0 for name in pulse._REGISTRY}
def _make(name: str):
def runner() -> str:
counters[name] += 1
return f"{name}: tick {counters[name]}"
return runner
fake_registry = {name: _make(name) for name in pulse._REGISTRY}
monkeypatch.setattr(pulse, "_REGISTRY", fake_registry)
return counters
def _set_last_fired(name: str, when: datetime, cadence: int) -> None:
"""Force a pipeline's last_fired + next_fire to a known instant."""
for p in pulse.state():
if p.name == name:
p.last_fired = when
p.next_fire = when + timedelta(seconds=cadence)
db.upsert_pulse_pipeline(pulse._pipeline_to_row(p))
return
raise AssertionError(f"unknown pipeline {name}")
def test_defaults_seeded_on_first_state(fresh_db):
pipelines = pulse.state()
names = {p.name for p in pipelines}
assert names == {"fetch", "classify", "prove", "reindex", "respond", "peer-pull", "vouch-refresh"}
# Spot-check a couple of defaults from the spec.
by_name = {p.name: p for p in pipelines}
assert by_name["fetch"].mode == pulse.PulseMode.AUTO_EXECUTE
assert by_name["fetch"].cadence_seconds == 900
assert by_name["peer-pull"].enabled is False
assert by_name["respond"].mode == pulse.PulseMode.AUTO_PROPOSE
def test_state_persists_across_reads(fresh_db):
pulse.set_cadence("fetch", 42)
pulse.set_mode("classify", pulse.PulseMode.MANUAL)
pulse.set_enabled("reindex", False)
by_name = {p.name: p for p in pulse.state()}
assert by_name["fetch"].cadence_seconds == 42
assert by_name["classify"].mode == pulse.PulseMode.MANUAL
assert by_name["reindex"].enabled is False
def test_tick_skips_not_due_pipelines(fresh_db):
# Push every pipeline well into the future — nothing should fire.
now = datetime.now(timezone.utc)
for p in pulse.state():
_set_last_fired(p.name, now, p.cadence_seconds)
results = pulse.tick()
outcomes = {name: outcome for name, outcome, _ in results}
assert all(o == "skipped" for o in outcomes.values()), outcomes
assert all(c == 0 for c in fresh_db.values()), fresh_db
def test_tick_fires_due_auto_pipelines(fresh_db):
# Force "fetch" to be due (last fired far in the past) and re-tick.
past = datetime.now(timezone.utc) - timedelta(hours=24)
for p in pulse.state():
_set_last_fired(p.name, past, p.cadence_seconds)
results = pulse.tick()
by_name = {name: (outcome, result) for name, outcome, result in results}
# auto-execute + auto-propose modes should fire; manual should skip.
assert by_name["fetch"][0] == "ok"
assert by_name["classify"][0] == "ok"
assert by_name["prove"][0] == "ok"
assert by_name["reindex"][0] == "ok"
assert by_name["respond"][0] == "ok"
# Manual + disabled pipelines stay skipped.
assert by_name["peer-pull"][0] == "skipped"
assert by_name["vouch-refresh"][0] == "skipped"
def test_tick_respects_manual_mode(fresh_db):
past = datetime.now(timezone.utc) - timedelta(hours=24)
_set_last_fired("fetch", past, 900)
pulse.set_mode("fetch", pulse.PulseMode.MANUAL)
results = pulse.tick()
by_name = {n: o for n, o, _ in results}
assert by_name["fetch"] == "skipped"
assert fresh_db["fetch"] == 0
def test_tick_respects_disabled(fresh_db):
past = datetime.now(timezone.utc) - timedelta(hours=24)
_set_last_fired("fetch", past, 900)
pulse.set_enabled("fetch", False)
results = pulse.tick()
by_name = {n: o for n, o, _ in results}
assert by_name["fetch"] == "skipped"
assert fresh_db["fetch"] == 0
def test_kill_switch_halts_everything(fresh_db):
past = datetime.now(timezone.utc) - timedelta(hours=24)
for p in pulse.state():
_set_last_fired(p.name, past, p.cadence_seconds)
pulse.set_kill_switch(True)
results = pulse.tick()
assert all(o == "skipped" for _, o, _ in results)
assert all(c == 0 for c in fresh_db.values())
# And killswitch state itself round-trips.
assert pulse.kill_switch_state() is True
pulse.set_kill_switch(False)
assert pulse.kill_switch_state() is False
def test_tick_updates_last_fired_and_next_fire(fresh_db):
past = datetime.now(timezone.utc) - timedelta(hours=24)
_set_last_fired("fetch", past, 900)
before = datetime.now(timezone.utc)
pulse.tick()
p = {x.name: x for x in pulse.state()}["fetch"]
assert p.last_fired is not None
assert p.last_fired >= before - timedelta(seconds=2)
assert p.next_fire is not None
assert p.next_fire > p.last_fired
delta = (p.next_fire - p.last_fired).total_seconds()
# 900s cadence, allow a 2s rounding window.
assert 898 <= delta <= 902
assert p.last_outcome == "ok"
assert "fetch: tick" in p.last_result
def test_run_now_bypasses_cadence(fresh_db):
# Pipeline isn't due — run_now must still fire.
future = datetime.now(timezone.utc) + timedelta(hours=1)
for p in pulse.state():
p.last_fired = datetime.now(timezone.utc)
p.next_fire = future
db.upsert_pulse_pipeline(pulse._pipeline_to_row(p))
outcome, result = pulse.run_now("fetch")
assert outcome == "ok"
assert "fetch: tick" in result
assert fresh_db["fetch"] == 1
def test_run_now_respects_kill_switch(fresh_db):
pulse.set_kill_switch(True)
outcome, result = pulse.run_now("fetch")
assert outcome == "skipped"
assert "kill switch" in result
assert fresh_db["fetch"] == 0
def test_run_now_even_when_manual(fresh_db):
"""Manual mode blocks tick() but NOT run_now — that's the whole point of manual."""
pulse.set_mode("peer-pull", pulse.PulseMode.MANUAL)
pulse.set_enabled("peer-pull", True)
outcome, _ = pulse.run_now("peer-pull")
assert outcome == "ok"
assert fresh_db["peer-pull"] == 1
def test_runner_exception_recorded_as_err(fresh_db, monkeypatch):
def boom() -> str:
raise RuntimeError("nope")
# Inject a failing runner just for "fetch".
bad_registry = dict(pulse._REGISTRY)
bad_registry["fetch"] = boom
monkeypatch.setattr(pulse, "_REGISTRY", bad_registry)
past = datetime.now(timezone.utc) - timedelta(hours=24)
_set_last_fired("fetch", past, 900)
results = pulse.tick()
by_name = {n: (o, r) for n, o, r in results}
assert by_name["fetch"][0] == "err"
assert "nope" in by_name["fetch"][1]
# And the failure is persisted, with next_fire still pushed forward so we
# don't busy-retry a broken runner every tick.
p = {x.name: x for x in pulse.state()}["fetch"]
assert p.last_outcome == "err"
assert p.next_fire is not None and p.next_fire > p.last_fired
def test_unknown_pipeline_raises(fresh_db):
with pytest.raises(ValueError):
pulse.set_mode("does-not-exist", pulse.PulseMode.MANUAL)
with pytest.raises(ValueError):
pulse.set_cadence("does-not-exist", 60)
with pytest.raises(ValueError):
pulse.run_now("does-not-exist")
def test_invalid_cadence_raises(fresh_db):
with pytest.raises(ValueError):
pulse.set_cadence("fetch", 0)
with pytest.raises(ValueError):
pulse.set_cadence("fetch", -5)

314
tests/test_pulse_respond.py Normal file
View 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

View 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
View 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
View 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)