Architecture¶
HoneyDjango is a four-layer pipeline — trap → capture → enrich → visualise — built on Django 5.2, Celery, Django Channels, PostgreSQL, and Redis.
System diagram¶
attacker / scanner
│
▼
┌──────────────────────────────┐
│ nginx (TLS termination) │ adds X-JA3-Hash + X-Forwarded-For
└──────────────┬───────────────┘
▼
┌──────────────────────────────┐
│ ASGI server (Daphne) │
│ ┌────────────────────────┐ │
│ │ SecurityMiddleware │ │
│ ├────────────────────────┤ │
│ │ HoneyMiddleware ◄──────┼──┼── DecoyRoute cache (60s TTL,
│ │ match? ──yes──┐ │ │ signal-invalidated)
│ ├────────────────┼───────┤ │
│ │ rest of stack │ │ │
│ │ + URLconf │ │ │
│ │ (fallback decoy│views, │ │
│ │ canary ping) │ │ │
│ └────────────────┼───────┘ │
└───────────────────┼──────────┘
▼
capture_event() render_decoy()
HoneyEvent INSERT ──────────► fake response
│ back to attacker
enrich_event.delay(id)
│
▼ Redis (broker)
┌──────────────────────────────┐
│ Celery worker (enrichment) │
│ GeoIP → feeds → TTP → JA3 │──► AttackerProfile (score, tags)
│ → evaluate alert rules ─────┼──► dispatch_alert (alerts queue)
└──────────────┬───────────────┘ │
│ group_send ▼
▼ Redis (channel layer) Slack / email / webhook
┌──────────────────────────────┐
│ EventConsumer (WebSocket) │
└──────────────┬───────────────┘
▼
dashboards: live table + Leaflet map
Middleware pipeline¶
HoneyMiddleware sits directly after SecurityMiddleware, before session/auth middleware and before URL resolution. The hot path is designed to add near-zero latency to legitimate traffic:
- No per-request queries for matching. Active
DecoyRouterows are held in an in-process cache (regexes pre-compiled), reloaded at most once per 60 seconds; save/delete signals invalidate it immediately. Matching tries exact paths first, then regexes, highest priority first. - No network I/O. On a match, the only synchronous work is one
HoneyEventinsert (headers minus cookie/authorization, body truncated at 64 KB, JA3 from the nginx-setX-JA3-Hashheader, User-Agent tool tags), then the fake response is returned. Everything slow is deferred to Celery. - Rate-limited writes. A Redis counter caps stored events at 50 per IP per 5-minute window; over the cap the event is dropped but the decoy response is identical, so the throttle is invisible to the attacker.
- No double capture. Because middleware runs before URL resolution, a path matched by a DecoyRoute never reaches the fallback decoy views — rendering and capture logic are shared between both entry points, so either path produces identical events and responses.
Celery enrichment pipeline¶
enrich_event(event_id) runs on the enrichment queue, one task per captured event, and is idempotent — an already-enriched (or deleted) event is a no-op, so retries and duplicate deliveries are harmless.
Slow I/O happens before any lock is taken: GeoIP lookup (local MaxMind file), threat-feed HTTP calls, and TTP regex classification of the path+body. Then a single transaction locks the event and its AttackerProfile row (select_for_update — two workers enriching the same IP serialise instead of double-counting) and folds the results in:
| Signal | Score effect |
|---|---|
New TTP tag (e.g. sql_injection) | +10 each, first time only |
| Threat feed flags IP malicious | +20 once per (IP, feed) |
| Known-scanner JA3 fingerprint | +30 once per IP, sets is_known_scanner |
Tool-identity tags (sqlmap, curl, …) | recorded, no score of their own |
The score is capped at 100 and never decremented. Geo fields are backfilled only when empty. Finally the event is linked to its profile and marked enriched, alert rules are evaluated against the updated profile (queueing dispatch_alert tasks on the alerts queue for matches), and — only after the transaction commits — a compact row is broadcast to the WebSocket group.
A third periodic task, refresh_threat_feeds, runs hourly under Celery beat and purges expired ThreatFeedEntry rows.
WebSocket event flow¶
- The dashboard page opens a WebSocket;
EventConsumersubscribes that connection to the sharedeventschannel group (Redis channel layer). - When enrichment commits, the task
group_sends the serialised row to the group — usingtransaction.on_commit, so a dashboard can never see a row that a rollback undid. - The consumer projects the row onto a small field allowlist (id, ip, path, decoy type, country, score, tags, timestamp, lat/lon) and forwards it to the browser — never the full event, which may carry large headers/bodies.
- Client-side, the row is prepended to the live table and, if it has coordinates, a pulsing marker is dropped on the Leaflet map.
The KPI counter strip deliberately does not use the WebSocket — it's an HTMX poll every 10 seconds, and the map's base layer polls a server-side-cached GeoJSON endpoint every 30 seconds. Three freshness mechanisms, each matched to how fresh its data needs to be.
Data model relationships¶
DecoyRoute (trap config; no FKs — consulted by middleware only)
CanaryToken created_by ──► User
(trigger stamps: triggered / triggered_at / trigger_ip)
HoneyEvent many ──► one AttackerProfile (SET_NULL on profile delete)
• immutable capture record: path, method, headers, body, ja3, UA,
decoy_type, capture-time tool tags
• enriched flag = has been folded into its profile
AttackerProfile (one per IP — the accumulating dossier)
• geo/ASN identity, first/last seen, event_count
• threat_score 0–100, tags[], is_known_scanner
ThreatFeedEntry (per IP × source reputation cache, TTL-purged;
joined to profiles by IP value, not FK)
AlertRule (condition JSON + notifier config; last_fired, fire_count.
Per-(rule, attacker) mute window lives in the Redis cache,
not the database)
The split worth internalising: HoneyEvent is the immutable record of what happened once; AttackerProfile is the mutable summary of everything one IP has ever done. Enrichment is the only writer that connects the two, and everything downstream — dashboard, alerts, exports — reads from that pair.