stage-17: operational hardening — .env keys, model status, backup

Three load-bearing operational pieces before any new features:

* .env.example committed, .env gitignored — per-developer API keys
  (THREATFOX_AUTH_KEY, OTX_API_KEY, NVD_API_KEY) ready for the registrations
  ahead; python-dotenv loads it in the venv CLI; compose picks it up via
  env_file: .env on the cockpit service.

* Cockpit /api/inference-status endpoint + a topbar status chip that polls it
  on page load — "model · live" green when up, "model · offline" amber when
  the inference server is unreachable. No more manual checking. Compose also
  gains a healthcheck on the inference service (applies on next recreate).

* New `psyc backup` command — tars the audit trail (db + sealed packages +
  recipient keys + ledger + datasets) to data/backups/psyc-data-<ts>.tar.gz.
  Excludes the HF model cache, mock-cert receipts, and the re-trainable
  adapters — the goal is the irrecoverable evidence, not bulk artifacts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
m17hr1l
2026-05-20 19:44:58 +02:00
parent 25c1c56645
commit 9e4c217a3d
7 changed files with 104 additions and 1 deletions

20
.env.example Normal file
View File

@@ -0,0 +1,20 @@
# psyc — per-developer secrets. Copy to `.env` (already gitignored) and fill in.
#
# cp .env.example .env
# # edit .env with your own keys
#
# Do not commit .env. Each developer keeps their own keys local.
# --- CTI source API keys (sources we may add to Scoutline) ---
# ThreatFox / abuse.ch — free auth key at https://auth.abuse.ch/
THREATFOX_AUTH_KEY=
# AlienVault OTX — free key at https://otx.alienvault.com/api
OTX_API_KEY=
# NIST NVD — free key at https://nvd.nist.gov/developers/request-an-api-key
# (raises throttling from ~5 to ~50 requests / 30s)
NVD_API_KEY=
# --- Internal service URLs — overridden in docker compose; defaults for venv CLI ---
# PSYC_MOCK_CERT_URL=http://127.0.0.1:8770
# PSYC_INFERENCE_URL=http://127.0.0.1:8771
# PSYC_DATA_DIR=./data

View File

@@ -16,6 +16,7 @@ services:
build: .
image: psyc:latest
command: ["psyc", "serve", "--host", "0.0.0.0", "--port", "8767"]
env_file: .env # per-dev API keys (gitignored). cp .env.example .env first.
environment:
VIRTUAL_HOST: psyc.neuronetz.ai
VIRTUAL_PORT: "8767"
@@ -57,6 +58,12 @@ services:
networks: [backend]
restart: unless-stopped
profiles: ["gpu"]
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8771/healthz')"]
interval: 30s
timeout: 5s
retries: 3
start_period: 90s
deploy:
resources:
reservations:

View File

@@ -18,6 +18,7 @@ dependencies = [
"pynacl>=1.5",
"structlog>=24.1",
"sqlalchemy>=2.0",
"python-dotenv>=1.0",
]
[project.optional-dependencies]

View File

@@ -6,8 +6,12 @@ from typing import List
import typer
import uvicorn
from dotenv import load_dotenv
from psyc import db, log
load_dotenv() # per-dev .env (API keys) is loaded into os.environ for venv CLI
from psyc.cockpit import inference
from psyc.lines import classify, courier, proof, route, scout, seal, train
from psyc.lines import map as map_line
@@ -31,6 +35,35 @@ def status() -> None:
typer.echo(f"cases: {db.case_count()}")
@app.command("backup")
def backup(out_dir: str = typer.Option("data/backups", "--out", help="directory to write the archive into")) -> None:
"""Tar the audit trail (db + sealed packages + keys + ledger + datasets) to a timestamped archive.
Excludes the HF model cache, mock-cert receipts, model adapters (re-trainable),
and the backups dir itself — the goal is the irrecoverable evidence, not bulk artifacts.
"""
import tarfile
from datetime import datetime, timezone
from pathlib import Path
from psyc import DATA_DIR
out_path = Path(out_dir)
out_path.mkdir(parents=True, exist_ok=True)
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
archive = out_path / f"psyc-data-{ts}.tar.gz"
excluded = {".hf-cache", "adapters", "mock_cert", out_path.name}
included: list[str] = []
with tarfile.open(archive, "w:gz") as tar:
for entry in DATA_DIR.iterdir():
if entry.name in excluded:
continue
tar.add(entry, arcname=entry.name)
included.append(entry.name)
size_mb = archive.stat().st_size / (1024 * 1024)
typer.echo(f"wrote {archive} ({size_mb:.2f} MB) — included: {', '.join(sorted(included))}")
def _ingest(source: str, limit: int) -> None:
db.init_db()
typer.echo(f"fetching {source} (limit={limit})…")

View File

@@ -11,7 +11,7 @@ from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from psyc import db, log
from psyc.cockpit import journey as journey_view
from psyc.cockpit import inference, journey as journey_view
from psyc.lines import ledger as ledger_line
from psyc.lines import route as route_line
from psyc.lines import seal as seal_line
@@ -96,3 +96,9 @@ def train_view(request: Request) -> HTMLResponse:
@app.get("/healthz")
def healthz() -> dict:
return {"status": "ok"}
@app.get("/api/inference-status")
def inference_status() -> dict:
adapter = inference.server_adapter()
return {"online": adapter is not None, "adapter": adapter}

View File

@@ -83,6 +83,24 @@ h1, h2, h3,
.nav a { margin-left: 18px; color: var(--muted); }
.nav a:hover { color: var(--text); }
/* live model status chip in the topbar */
.model-status {
display: inline-flex; align-items: center; gap: 6px;
font-family: var(--font-display); font-size: 10px; letter-spacing: 1px;
text-transform: uppercase; padding: 3px 9px; margin-left: 14px;
border: 1px solid var(--border); border-radius: 4px;
background: var(--panel-2); color: var(--muted); cursor: default;
}
.model-status-dot {
width: 7px; height: 7px; border-radius: 50%;
background: var(--muted);
}
.model-status[data-state="online"] { color: var(--green); border-color: color-mix(in oklab, var(--green) 45%, var(--border)); }
.model-status[data-state="online"] .model-status-dot { background: var(--green); box-shadow: 0 0 6px var(--green); animation: status-pulse 2.2s ease-in-out infinite; }
.model-status[data-state="offline"] { color: var(--amber); border-color: color-mix(in oklab, var(--amber) 45%, var(--border)); }
.model-status[data-state="offline"] .model-status-dot { background: var(--amber); box-shadow: 0 0 6px var(--amber); }
@keyframes status-pulse { 0%,100% { opacity: 0.55; } 50% { opacity: 1; } }
/* neuronetz family framing — right of the topbar */
.family { display: flex; align-items: center; gap: 14px; }
.family-icon {

View File

@@ -21,6 +21,9 @@
<a href="/ledger">Ledger</a>
<a href="/train">Trainline</a>
</nav>
<span class="model-status" id="model-status" data-state="checking" title="checking…">
<span class="model-status-dot"></span><span class="model-status-text">model</span>
</span>
<div class="family">
<img class="family-icon" src="/static/nn-sc-icon.png" alt="NN-sc — Security/Control" title="NN-sc · Security — psyc, the security app of the neuronetz platform">
</div>
@@ -29,5 +32,20 @@
{% block content %}{% endblock %}
</main>
<footer class="footer">psyc 0.1 — defensive CTI prototype · powered by <a href="https://neuronetz.ai" target="_blank" rel="noopener">neuronetz.ai</a></footer>
<script>
fetch('/api/inference-status').then(r => r.json()).then(s => {
const el = document.getElementById('model-status');
if (!el) return;
if (s.online) {
el.dataset.state = 'online';
el.title = 'model up · ' + s.adapter;
el.querySelector('.model-status-text').textContent = 'model · live';
} else {
el.dataset.state = 'offline';
el.title = 'inference server unreachable — Classifier bot falls back to rules';
el.querySelector('.model-status-text').textContent = 'model · offline';
}
}).catch(() => {});
</script>
</body>
</html>