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>
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>
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>
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>
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>
Inserted the analytics.neuronetz.ai tracking script tag in the <head>
of base.html with the project's website-id. defer attribute means it
never blocks page render. Cookieless / privacy-respecting (Umami).
Applies to every page that extends base — the admin gate's standalone
template intentionally skips it (no point tracking the login screen).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1. Particles now blink in time with the scan column.
Each <circle>/<rect> in the case hero SVG carries class="hero-particle"
and a CSS animation-delay equal to -(arrival-time-of-sweep-at-its-x),
so as the sweep marches left-to-right the particles light up in
sequence — a real "the scanner just touched this point" effect. The
sweep is now linear / non-alternating on a 12s cycle so the phase
stays predictable. Drop-shadow + opacity flash at the peak.
2. Stat chips added under the featured title.
Up to five compact chips showing incident type, total IOC count with
per-type breakdown (U/D/I/H/C), victim country if mapped, confidence
level, malware family. Each chip is color-coded.
3. Radar emblem in the top-right of the hero.
Pure SVG: three concentric range rings, cross-hairs, a translucent
sweep arm that spins every 4.5s, a center dot. The whole emblem
inherits the cycling-hero hue cycle so it changes color with the
grid. Smaller + dimmer on mobile; honors prefers-reduced-motion.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The hero is now layered:
base — radial accent + dark gradient (truly full-width, min 240px /
up to 320px tall, no aspect-ratio cap)
grid — full-bleed CSS grid (38px cells), line color cycles through
cyan → red → gold → green → violet over 18s, plus a slow
grid-drift so the lattice subtly slides; a glowing column
sweeps left-to-right across the hero every 12s for depth
particles — the existing case-specific SVG sits on top at lower
opacity (mix-blend-mode: screen) so the per-case identity
is preserved as a constellation glittering over the grid
overlay — title + Open-case CTA pinned to the bottom
Severity just nudges the grid opacity (CRITICAL/HIGH get a bit denser);
the color cycle stays on for all severities. Honors prefers-reduced-motion.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The banner gradient was a horizontal accent → dark fade, so visually
only the left ~30% read as a banner; the right looked unstyled. Swapped
for a uniform severity-tinted background (subtle top-to-bottom gradient
for depth, but no horizontal falloff). Border + inset shadow on the
bottom edge give it a real divider between the banner and the hero
beneath. Per-severity tints (CRITICAL red, HIGH amber) get the same
treatment — full-width, uniform.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
When I refactored the featured card from <section>→<a> for full-card
clicking, I forgot the <a> defaults to display:inline. That collapsed
the hero+overlay layout — the title rendered word-per-line down the
left edge instead of spanning the hero. display:block (+ inherit
color, no text-decoration) restores the layout.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two enhancements from the screenshot annotations:
1. The featured card now has a proper banner header above the hero —
gradient strip with "⌖ Featured Threat" label, a pulsing severity
dot, severity/TLP badges, source + ingest time. The header is
sev-themed (red border + glow for CRITICAL, amber for HIGH), and an
"Open case →" CTA at the bottom of the hero with an arrow that
nudges right on hover.
2. News items are now full-card clickable (transparent overlay link)
with rich severity-tinted hover transitions:
- Lifts (translateY -1px), tinted background + border that matches
severity (red CRITICAL, amber HIGH, pale MEDIUM/LOW), per-kind
accents for enforced/submitted/rejected/failed items.
- A right-side arrow (→) slides in on hover signaling "click to open".
- Case glyph SVG gets a subtle drop-shadow + 1.04x scale on hover.
- 180ms ease transitions across background/border/transform/shadow.
- Respects prefers-reduced-motion (turns animations off).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Template had `{{ buckets|sum(attribute='items')|length }}` which tried
to sum lists starting at 0 → TypeError. Switched to a Python-side
`total_items = sum(len(b.items) for b in buckets)` passed through the
context and rendered directly.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The home page goes from a flat event stream to something that reads like
a news blog:
- Featured-case hero card at the top, picked as the highest-severity
case (CRITICAL > HIGH, tie-break recency) from the last 7 days. Wide,
with a procedurally generated SVG hero behind a gradient overlay that
carries title + severity + TLP + feed + ingest time.
- Recent activity is now grouped under Today / Yesterday / Earlier this
week / Older bucket headers.
- Each item gets a left-border severity accent (red CRITICAL, amber
HIGH, muted MEDIUM/LOW) so the page is scannable at a glance.
Images: new cockpit/case_visuals.py generates SVGs from case data —
zero external image gen, zero curated assets. Every visual is
deterministic from case_id (so a case keeps its identity across
sessions) and themed to its severity:
- case_hero_svg() — 880x220 hero with severity radial glow, a faint
scan grid, a particle constellation with auto-connecting lines, HUD
corner brackets, and the case id whispered in the bottom-right.
- case_glyph_svg() — small mirror-symmetric identicon (5-grid),
severity-colored, shown beside each case news item in place of an
emoji icon. Two case_ids → two distinct glyphs; same id → same glyph.
7 news tests pass; visual sanity print confirms hero is deterministic
and uses the right severity accent.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
User reported mobile nav still looked unresponsive. Verified the new
nav-toggle CSS and markup are deployed; the browser's service worker
was serving the prior cockpit.css cache-first from psyc-v1.
Two fixes so this can't recur:
- CACHE_VERSION bumped to psyc-v2 → the activate handler now wipes
the v1 cache on the user's next visit.
- Static-asset strategy changed from cache-first to
stale-while-revalidate: serve from cache for instant render, then
refresh in the background so the *next* load picks up the new CSS/JS
with no manual cache-busting. Falls back to network if cold.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replaced the horizontal-scroll hack with a real mobile pattern:
- Hamburger button (☰) appears below 760px width, animates into an X when
open. Tap to slide the full nav into a drawer below the topbar, each
link a tap-target-sized row.
- Nav links auto-close the drawer on click so navigating dismisses it.
- aria-expanded / aria-controls wired up so screen readers track state.
- Below 420px the model status chip + family icon hide to keep the
topbar compact; brand-sub already hidden by the earlier mobile pass.
Discovered while debugging: prod had 377 unclassified cases (only
fetch-all was run, not classify-all/prove-all/reindex), so the journey
page's Classifier beat skipped the live model verdict entirely. Ran
the full pipeline on prod — the psyc-v5 model chip now renders on
case journeys. Worth noting for the deploy runbook: after psyc fetch-all
on a new env, always also run classify-all + prove-all + reindex to
prime the pipeline state.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The inference service declared 'image: psyc-trainer' but no build: stanza,
so 'docker compose build inference' was a silent no-op and 'compose up
inference' tried to pull from a registry that doesn't exist (denied).
Added build context pointing at Dockerfile.train so future production
deploys can rebuild it via the compose lifecycle instead of needing a
manual 'docker build' on the side.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two production-discovered fixes after first deploy:
- CISA's CDN was 403'ing the "psyc/0.1 (defensive CTI; hackathon
prototype)" User-Agent from the cloud.neuronetz.ai exit IP. Switched
to a Mozilla-compatible UA that identifies us honestly while passing
the CDN's UA filters. Overridable via PSYC_HTTP_USER_AGENT.
- fetch-all aborted on the first HTTPStatusError, so a CISA hiccup
killed the threatfox/malware-bazaar/otx legs that come after. The
outer loop now catches any exception per-source, logs a skip, and
moves on. Single-source failures no longer poison the rest of the
pull.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
On AnD0R the reverse-proxy lives on the 'backend' docker network; on
cloud.neuronetz.ai it's 'neuronetz_default'. With a hardcoded name the
cockpit ended up on a network the prod proxy couldn't see and routing
silently dropped. Network is now overridable via PSYC_PROXY_NETWORK in
.env (default 'backend' keeps dev working).
On prod, set PSYC_PROXY_NETWORK=neuronetz_default in .env before the
next compose up.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds the two env vars nginxproxy/acme-companion looks for to issue +
auto-renew the TLS cert for psyc.neuronetz.ai. LETSENCRYPT_EMAIL is
interpolated from the prod .env (LETSENCRYPT_EMAIL=...) with a sensible
fallback so dev / local deploys don't fail on the variable being unset.
.env.example documents the var.
Requires the proxy stack to (a) have acme-companion alongside
nginx-proxy with shared certs/vhost.d/html volumes and (b) publish :443.
psyc-side change only — no app code touched.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Reinstating the auto known_hosts entry on first deploy. Clear scope:
host trust (TOFU known_hosts entry) is automated — same as
'ssh -o StrictHostKeyChecking=accept-new' would do; identity keypairs
(~/.ssh/id_*) are never generated/copied/modified by deploy.sh.
PSYC_SKIP_HOST_TRUST=1 disables the auto-trust step if you'd rather
verify fingerprints manually.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
User asked the script not to touch their SSH config. Reverted the
auto-ssh-keyscan; the script now only READS ~/.ssh/known_hosts (via
ssh-keygen -F) and, when the entry is missing, exits with explicit
manual instructions for verifying the host key and registering an
identity key in Gitea. Identical behavior on the happy path; clearer
diagnostics on the unhappy path; zero modification of ~/.ssh anywhere.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
A fresh prod box has never SSH'd to gitea.neuronetz.ai before, so the
first 'git clone' failed with 'Host key verification failed'. The
script now parses the git remote URL to extract host+port, and on the
prod box does an ssh-keyscan into ~/.ssh/known_hosts before the clone
when the entry is missing. TOFU — if you want to verify the fingerprint
out-of-band, pre-populate known_hosts manually and the script will see
the entry and skip the scan.
Also: if the clone still fails after the host key is trusted (likely a
missing SSH key on Gitea side), the script now prints a clear hint
pointing at where to register it. Supports both ssh://user@host:port/
and user@host: URL forms.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
scripts/deploy.sh pushes the current branch to origin, ssh's into the
prod box (neuronetz@cloud.neuronetz.ai:/home/neuronetz/docker-public/
neuro-psyc by default — overridable via env vars), clones-or-pulls,
ensures the external 'backend' docker network exists, runs docker
compose up -d --build (+ --profile gpu if PSYC_PROD_GPU=1), and then
verifies the cockpit is healthy both on prod-internal :8767 and at the
public URL — so the script ends knowing whether the page is up.
Refuses to touch prod's .env (warns + copies .env.example if missing,
so you can edit it manually). Never transfers data/ or adapters
(gitignored; prod fetches its own corpus). Color output, idempotent,
safe to re-run.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Click a container, switch, or the host in the graph: the node gets a
pulsing accent highlight and a detail panel below renders its full
logical overview — for a container, image/status/per-network IPv4+MAC+
gateway + published ports + all ports; for a switch, driver/scope/
subnet/gateway/internal + attached-container table; for the host, OS/
CPUs/container counts. Cross-jumps in the spec tables click through to
the related node (the graph re-selects too). Click the × button or any
empty graph area to deselect.
Drag is distinguished from click by a 4px move threshold so dragging
nodes doesn't accidentally open the panel. HTML escaped end-to-end.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three layout modes (Force/Hierarchical/Radial) switch the topology graph
between organic force-directed, host→switches→containers tiers, and a
radial host-in-center wheel. Animated traffic flow on edges via
stroke-dashoffset marching: wires to running containers flow cyan,
host↔switch uplinks pulse slower in violet, container→host publish
edges flash fastest in amber-dashed; dead edges (exited containers) fade.
"traffic flow" checkbox kills the animation globally; layout choice
auto-fixes nodes (drag still overrides). Respects prefers-reduced-motion.
/admin/docker now opts into a wide layout via a new body_class block on
base.html — content area uncaps, SVG sized min(74vh, 880px), network
cards get wider min columns. Other pages keep the 1280px reading cap.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
New tecnativa/docker-socket-proxy sidecar exposes only GET on
containers/networks/info/ping; POST and DELETE are blocked. The cockpit
queries it over the backend network — /var/run/docker.sock is never
mounted into a web-facing container.
cockpit/docker_view.py normalizes the daemon view: containers carry
per-network IP/MAC + published_ports; networks carry subnet/gateway from
IPAM; host_info pulls /info (degrades gracefully). topology() returns
the combined snapshot.
/admin/docker (admin-gated): a force-directed graph (pure SVG +
vanilla JS, ~280 lines) renders the complete setup — a host node,
switch nodes with subnet labels colored by driver, container nodes
colored by state, member wires labeled with the container's IP on that
network, uplinks from non-internal switches to the host labeled with
the gateway, and dashed publish-edges from containers to the host for
their published ports. Drag to rearrange, scroll to zoom, re-settle
kicks the physics. Below the graph: containers table + grouped network
cards as a textual mirror. 12 docker_view tests; verified live (32
containers, 11 switches, real subnets + gateways).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>