Skip to content

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 DecoyRoute rows 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 HoneyEvent insert (headers minus cookie/authorization, body truncated at 64 KB, JA3 from the nginx-set X-JA3-Hash header, 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

  1. The dashboard page opens a WebSocket; EventConsumer subscribes that connection to the shared events channel group (Redis channel layer).
  2. When enrichment commits, the task group_sends the serialised row to the group — using transaction.on_commit, so a dashboard can never see a row that a rollback undid.
  3. 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.
  4. 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.