Architecture - sparkycz1/honeypot-shelf GitHub Wiki

🏗️ Architecture

Every honeypot lies for a living — this is the part of the app that has to tell the truth.

This page covers the stack, project layout, the REST API, and the cross-cutting security essentials (CSRF, headers, secrets at rest, FIPS alignment). Everything feature-specific has its own page — split out so a long single document doesn't get harder to search as this app grows:

Page Covers
🔐 Authentication & RBAC Login/sessions/TOTP/WebAuthn/OIDC, the company-membership RBAC model, Impersonate
🍯 Honeypot Management Provisioning, VPN connectivity, the Config tab, the data model, how events arrive, syslog targets
📝 Audit Log Hash-chain integrity, retention, export, SIEM forwarding
🔔 Notifications Rules, scope, wording, the SSRF guard on webhooks

🧱 Stack

Layer Choice Notes
Language Python 3.14 (image python:3.14.8-slim, pinned by digest)
Web framework FastAPI async, OpenAPI schema for free
Templates / UI Jinja2 + htmx 2.0.11 (vendored locally) no SPA build, no CDN; Swagger UI 5.33.1 and xterm.js 6.0.0 vendored the same way
Database PostgreSQL 18 (postgres:18.6) via asyncpg + SQLAlchemy 2.1 (async)
Migrations Alembic async engine
Task queue / broker Redis (redis:8.10.2) broker + result backend for Celery; also backs the login rate limiter
Background tasks Celery + Celery Beat one worker, exactly one beat — never scale beat past 1 replica
Auth: passwords argon2-cffi argon2id
Auth: LDAP ldap3 pure Python
Auth: OIDC Authlib discovery, auth-code flow, ID token validation
Auth: TOTP pyotp + qrcode RFC 6238
Auth: WebAuthn/passkeys webauthn ceremony verification
Outbound HTTP httpx2 notifications, GeoIP download, remote logo, OIDC (via Authlib); the maintained successor of httpx, TLS verified against the system trust store
Reverse proxy (optional) Caddy automatic HTTPS, TLS 1.3, HTTP/3
Packaging uv uv.lock is committed
Containers Docker (multi-stage) + Compose

Carried over almost entirely — stack, layout, and most of the auth code — from debcontrol, a sister project for Debian fleets. See CLAUDE.md for the exact "what was reused vs. what's different" ledger.

flowchart LR
    Honeypot[["OpenCanary honeypot<br/>(a Raspberry Pi)"]]
    Browser["Browser<br/>htmx, no SPA build"]
    Web["web<br/>FastAPI"]
    DB[("PostgreSQL")]
    Redis[("Redis<br/>broker + result backend")]
    Worker["worker<br/>Celery"]
    Beat["beat<br/>Celery Beat scheduler"]

    Worker -->|"SSH: read OpenCanary's own log"| Honeypot
    Browser <-->|HTTP| Web
    Web <--> DB
    Beat -->|"periodic sweeps, incl. the log poll"| Redis
    Redis --> Worker
    Worker <--> DB
Loading

Unlike debcontrol's Machine, a Honeypot starts producing events with no forwarder or push setup on its side at all — web/worker reach out over SSH and read whatever's new in OpenCanary's own log. See Honeypot Management.

📂 Project structure

app/
  audit.py      the single audit-log write path (hash chaining, verification) — from debcontrol, unchanged
  auth/         login (local/LDAP/OIDC), sessions, TOTP, WebAuthn, per-user API tokens,
                company scoping (app/auth/scope.py) — see Authentication & RBAC
  core/         config (pydantic-settings), logging, encryption, CSRF, editable app settings
  db/           SQLAlchemy models + async session
  schemas/      Pydantic schemas for forms
  services/     logic shared between routes (e.g. honeypot online/offline status)
  tasks/        Celery app (beat schedule, fork-safety hook) + the daily job bodies
  web/          FastAPI routers, Jinja2 templates, static files
alembic/        DB migrations
wiki/           this documentation

🌐 The REST API: read and write, mirroring the web UI

Authenticated with a per-user API token (Authorization: Bearer), not a session cookie. A token can do whatever its owning account currently permits, company-scoped exactly like the web UI — a second door into the same house, not a looser one.

Deliberately still web-UI-only: SSH key rotation, LDAP/OIDC config, and syslog forwarding (secret/credential surfaces or lock-out risk, meant to be handled deliberately by a human); the interactive SSH terminal (inherently interactive, no REST shape); the "Fix it" readiness flow's one-time password (same secret-handling reason as key rotation); Impersonate (see Authentication & RBAC); the whole-application restore (it replaces every account and token, including the caller's — POST /api/v1/backup makes the backup itself).

GET /api/v1/events (+ /export, CSV or JSON) is the read surface over HoneypotEvent — filterable, paginated on the list endpoint. Mirrors the audit log's own CSV-export shape (spreadsheet-formula-injection guard included).

🔒 Security model

CSRF, strict CSP (no inline scripts/styles, no CDN — htmx and Swagger UI vendored locally), security headers, secrets-at-rest encryption, SSH host-key pinning (no trust-on-first-use), and the hash-chained audit log are all unchanged from debcontrol in spirit — see that project's own wiki for the exhaustive version. Specifically:

  • Headers: CSP, X-Frame-Options: DENY, Referrer-Policy: no-referrer, Permissions-Policy, Cross-Origin-Opener-Policy: same-origin (severs window.opener across an OIDC redirect chain), HSTS in production. CSS/JS are linked as static_url(...) — /static/...?v=<content hash> — and served immutable; a bare /static/ URL must revalidate, so an upgrade never pairs new HTML with a stale stylesheet or script.
  • WebSockets (terminal, Logs' live follow, Initialize, live updates) authenticate by hand (app.auth.middleware doesn't run for them) and refuse a handshake whose Origin names another host (app.auth.websocket_origin) — the session cookie is SameSite=Strict, but a sibling subdomain counts as same-site and would otherwise get it attached.
  • The web terminal under the CSP: vendored xterm.js 6 (+ addon-fit, addon-webgl). xterm's default DOM renderer styles cells by injecting <style> elements, which style-src 'self' blocks, so the WebGL addon draws glyphs and colors on a <canvas> instead (disposed on context loss). Without WebGL2 the DOM renderer takes over, kept usable by static/css/xterm-csp.css — the scrollbar, row font, cursor, selection and 256-color palette rules xterm would otherwise inject — so everything but 24-bit truecolor still shows. The palette there mirrors the theme in static/js/terminal.js; change both together. The browser console still reports xterm's blocked <style> injections; that is expected.
  • Containers: web/worker/beat/migrate run as an unprivileged user with every Linux capability dropped and no-new-privileges; only the optional VPN sidecar keeps NET_ADMIN.

Deliberately out of scope: no AI assistant, no user-definable roles.

Secrets at rest

Every *_encrypted column is AES-256-GCM — a fresh random nonce per value. decrypt_secret still transparently reads the older Fernet (AES-128-CBC) format this app used before v0.17.0, so nothing needs an immediate migration; scripts/reencrypt_secrets.py upgrades every remaining legacy value in one idempotent pass.

FIPS alignment

Not certified — that needs a NIST-validated crypto module (a build/ deployment decision, not application code), and the stock python:3.14-slim image isn't one. What the app does control: never relying on an algorithm FIPS wouldn't approve, so a deployment that needs real certification only swaps the crypto module underneath.

  • Secrets at rest: AES-256-GCM, not Fernet's AES-128 — both are FIPS-approved; this is "prefer the stronger default," not a fix.
  • Signed tickets: explicit SHA-256 rather than itsdangerous's own HMAC-SHA1 default (itself still FIPS-approved — same reasoning).
  • SSH connections restrict key exchange/encryption/MAC to an approved subset (NIST-curve ECDH or ≥2048-bit DH with SHA-2, AES-GCM/CTR, HMAC-SHA-2) — except the unauthenticated host-key-discovery probe (must stay unrestricted to learn what it's dealing with) and the accepted host-key algorithm itself (pinned by exact fingerprint, not algorithm).
  • TOTP and WebAuthn already only use approved algorithms.

The one deliberate exception: Argon2id for password hashing. FIPS/SP 800-132 only approves PBKDF2. This app keeps Argon2id anyway — memory-hard, meaningfully more GPU/ASIC-resistant, which is exactly what protects an account if the hash table ever leaks. A considered trade-off, not an oversight.

⚠️ **GitHub.com Fallback** ⚠️