Technical Track Record
Case 02: Scapy DHCP Sentinel - Rogue DHCP Detection for NetDevOps
Briefing
Rogue DHCP servers can cause outages, bad addressing, and traffic diversion within minutes, yet most teams still rely on reactive troubleshooting. I designed Scapy DHCP Sentinel as a proactive local detector: a host-side sensor sends real DHCPDISCOVER probes, captures DHCPOFFER replies, compares responders against a corporate whitelist, and surfaces suspicious servers in an operational UI before they escalate into incidents.
Technical Deep Dive
The architecture cleanly separates the privileged sensor runtime from the web core: Scapy and BPF run on the host for real network-interface access, while FastAPI, SQLite, and the local dashboard stay isolated in a containerized core. I implemented local HTTP ingest, `trusted/suspect/anomaly` classification, auditable persistence for runs and events, a recurring watchdog, and disk spool buffering so temporary core downtime does not erase operational evidence.
Rogue DHCP Detection Loop
Documentation
Local application for proactive detection of rogue DHCP servers in corporate networks, with a host-side sensor based on Scapy, a FastAPI web core, local SQLite persistence, and a locally served operational dashboard.
Overview The project was designed to monitor DHCP replies on the local network and quickly identify unauthorized servers before they cause outages, addressing errors, traffic diversion, or audit risk. The solution adopts a hybrid architecture: the sensor runs natively on the host to gain real access to Scapy and BPF on macOS, while the application core runs in Docker to simplify bootstrap, persistence, and distribution.
The main business flow is straightforward:
- the sensor sends a
DHCPDISCOVERbroadcast; - active DHCP servers reply with
DHCPOFFER; - the sensor extracts technical metadata and sends the result to the core;
- the core compares responders against a whitelist;
- replies are classified as
trusted,suspect, oranomaly; - runs, replies, and events are persisted locally and exposed in the UI.
Architecture
[Local Browser / UI]
|
| GET /api/v1/dashboard
| POST http://127.0.0.1:8999/scan
v
[detector-core - FastAPI + SQLite + UI]
^
| POST /api/v1/ingest
|
[dhcp-sensor-host - FastAPI + Scapy + Watchdog + Spool]
|
| DHCPDISCOVER / capture DHCPOFFER
v
[Local network]Technology Stack
| Component | Technology | Version | Notes |
|---|---|---|---|
| Language | Python | 3.11 in the container | Main runtime for the core |
| Core API | FastAPI | 0.115.12 | Ingest, dashboard, and health endpoints |
| Sensor API | FastAPI | 0.115.12 | Local scan and watchdog control |
| ASGI Server | Uvicorn | 0.34.0 | Runtime for core and sensor |
| Modeling and validation | Pydantic | 2.10.6 | Configuration and payload schemas |
| Configuration | PyYAML | 6.0.2 | Reads detector.yml |
| Network sensor | Scapy | 2.5.0 | DHCPDISCOVER, sniffing, and DHCP parsing |
| HTTP transport | Requests | 2.32.3 | Local sensor-to-core delivery |
| Persistence | SQLite | standard module | File-based local database |
| UI | HTML + CSS + vanilla JavaScript | n/a | Static dashboard served by the core |
| Containerization | Docker / Docker Compose | n/a | Only the core is containerized |
| Tests | Pytest + httpx | 8.3.5 / 0.28.1 | Classification, ingest, and watchdog tests |
Main Modules
| Module | Responsibility | Main Files |
|---|---|---|
| Core API | State initialization, HTTP endpoints, dashboard, and static files | core/app/main.py |
| Configuration | Configuration schema, validation, and local snapshot | core/app/config.py |
| Classification | Compares replies against the whitelist | core/app/classifier.py |
| Persistence | Stores runs, replies, events, and metrics | core/app/repository.py, core/app/database.py |
| Models | Pydantic contracts for ingest, dashboard, and domain objects | core/app/models.py |
| Logging | Structured JSON line logging | core/app/logging_utils.py |
| Sensor API | Sensor endpoints and HTTP error translation | sensor/app/service.py |
| Sensor runtime | Locks, watchdog, lifecycle, and spool flush | sensor/app/runtime.py |
| DHCP scanner | Builds DHCPDISCOVER, Scapy sniffer, and DHCPOFFER extraction | sensor/app/scanner.py |
| Transport and spool | POST to the core, temporary storage, and replay | sensor/app/transport.py |
| Dashboard | Local interface, i18n, metrics, and operational controls | core/static/index.html, core/static/app.js, core/static/base.css, core/static/dashboard.css |
| Local operations | Bootstrap, scan trigger, and sensor stop flow | scripts/bootstrap.sh, scripts/run-scan.sh, scripts/stop-sensor.sh |
Technical Flows
Live Scan: the UI orrun-scan.sh livecallsPOST /scan, the sensor performs a real scan with Scapy, forwards the payload to the core, and the core persists the result in SQLite.Watchdog: the UI orPOST /watchdog/startstarts a daemon thread in the sensor that repeatslive scansat the interval configured insensor.watchdog_interval_seconds.- Classification: each received reply is compared against
trusted_dhcp_servers; instrict, the match must be complete; inlenient, any matching identifier is enough. - Event generation: replies classified as
suspectgenerate arogue_dhcp_detectedevent with severity and payload persisted in theeventstable. - Degraded mode with spool: if the core is unavailable, the sensor stores the raw scan in
data/state/spool; on the next successful delivery or startup, it attempts to resend pending files. - Dashboard: the UI queries
GET /api/v1/dashboardto display metrics, whitelist entries, run history, recent events, and the configuration summary. - Local operation:
bootstrap.shcreates the sensor virtualenv, starts the core in Docker, starts the sensor in the background, and validates both through health checks.
Operation, Permissions, and Controls
- V1 was designed for local use on
localhost. - There is no authentication between UI, sensor, and core.
- The core uses open CORS, acceptable for local scope but not hardened for remote exposure.
- On macOS, the sensor may require
sudoto open/dev/bpf*and perform realLive ScanorWatchdogoperations. - The process that requires elevated privileges is the sensor, not the core or the UI.
- The sensor endpoint runs on
127.0.0.1:8999and the core runs on127.0.0.1:8000.
Database
| Entity | Key fields | Notes |
|---|---|---|
runs | id, started_at, completed_at, status, interface_name, transaction_id, trusted_count, suspect_count, anomaly_count, duration_ms | Summarizes each scan execution |
responses | run_id, source_ip, source_mac, server_identifier, message_type, offered_ip, classification, reason, matched_trusted_server | Stores each observed and classified DHCPOFFER |
events | run_id, response_id, event_type, severity, interface_name, payload_json | Records rogue_dhcp_detected incidents |
Rules and automations
Repository.store_run()stores the execution, each classified reply, and derived events.fetch_metrics()aggregates total runs, suspects, trusted replies, anomalies, and average duration.- Referential integrity is preserved with
FOREIGN KEYandON DELETE CASCADE. - The schema is created automatically via
init_db()during core startup.
Local persistence
data/state/detector.db: primary SQLite database.data/state/config-snapshot.json: serialized snapshot of the loaded configuration.data/logs/dhcp-detector.log: structured core log.data/logs/sensor-service.log: sensor stdout/stderr in background mode.data/state/spool/: scans not delivered to the core due to temporary unavailability.
API Response format
- The core and the sensor return raw JSON, without a single global envelope for every route.
- The dashboard endpoint returns a consolidated envelope with metrics, runs, events, trusted servers, and config summary.
- On errors, FastAPI returns
detailwith HTTP status aligned to the failure.
Core
| Method | Route | Description |
|---|---|---|
| GET | /api/v1/health | Core health check, validation mode, and total trusted servers |
| GET | /api/v1/config | Summary of the loaded configuration |
| POST | /api/v1/ingest | Receives a scan from the sensor, classifies it, and persists it |
| GET | /api/v1/dashboard | Consolidated envelope for the UI |
| GET | /api/v1/events | Lists recent events |
| GET | /api/v1/runs | Lists recent runs |
| GET | / | Serves the HTML UI with cache-control disabled |
Sensor
| Method | Route | Description |
|---|---|---|
| GET | /health | Sensor health check with watchdog state |
| GET | /watchdog/status | Snapshot of the recurring scheduler |
| POST | /watchdog/start | Starts the watchdog |
| POST | /watchdog/stop | Stops the watchdog |
| POST | /scan | Executes live, fixture, or starts watchdog depending on the mode |
Relevant status codes and behaviors
409:scan_in_progresswhen a scan is already running.403:fixture_mode_disabledwhen fixture mode is disabled in config.503:sensor_not_readyorcore_not_readywhile components are still initializing.500: unhandled failures, including Scapy/BPF permission errors.
DHCP Scanner
- The scanner generates a random locally administered
client_mac. - The
transaction_idis also random to correlate only offers related to that discover. - The sniffer uses the filter
udp and (port 67 or port 68). - Only packets with DHCP OFFER and matching
xidenter the final payload. fixturemode readssensor/fixtures/sample-scan.jsonfor demos and tests.- The
retriesfield already exists in the payload and configuration, but it does not yet produce actual repeated scans in the current implementation.
Dashboard and Interface
- The UI is served directly by the core, with no separate frontend build pipeline.
- The dashboard displays aggregate metrics, recent events, an execution ledger, the whitelist, the operational console, and watchdog state.
- The main button supports
hover, clickmicrointeraction, and execution states. - The interface includes a
PT-BR/ENlanguage selector persisted inlocalStorage. - The
Powered by Eric Barroscredit points tohttps://eric.epico.gold. - The frontend consumes the sensor at
http://127.0.0.1:8999and the core at/api/v1.
Configuration Main file
data/config/detector.yml
Example
interface: en0
timeout_seconds: 5
retries: 1
validation_mode: strict
trusted_list_version: "2026-03-10"
trusted_dhcp_servers:
- name: dhcp-core-01
ip: 192.168.1.10
mac: "00:11:22:33:44:55"
server_identifier: 192.168.1.10
logging:
level: info
format: json
path: ./data/logs/dhcp-detector.log
transport:
mode: http
endpoint: http://127.0.0.1:8000/api/v1/ingest
sensor:
host: 127.0.0.1
port: 8999
allow_fixture_mode: true
default_mode: live
watchdog_interval_seconds: 60
watchdog_autostart: false
storage:
database_path: ./data/state/detector.db
snapshot_path: ./data/state/config-snapshot.jsonField summary
| Field | Function |
|---|---|
interface | Network interface used by the sensor |
timeout_seconds | Capture window after the discover |
retries | Reserved field for future retry evolution |
validation_mode | strict or lenient |
trusted_list_version | Logical whitelist version |
trusted_dhcp_servers | List of official DHCP servers |
transport.endpoint | Local core URL for ingest |
sensor.default_mode | Default mode for the /scan endpoint |
sensor.watchdog_interval_seconds | Interval between recurring cycles |
sensor.watchdog_autostart | Starts the watchdog automatically |
storage.database_path | SQLite database path |
storage.snapshot_path | Configuration snapshot path |
Behavior after editing the file
- Rebuilding the Docker image is not required.
- There is also no hot reload in the current runtime.
- You must restart the core, the sensor, or both depending on the field changed.
Scripts
./scripts/bootstrap.sh: prepares the sensor virtualenv, starts the core, starts the sensor, and validates health checks../scripts/run-scan.sh live: runs a manual live scan through the sensor API../scripts/run-scan.sh watchdog: starts watchdog mode through the/scanendpoint../scripts/stop-sensor.sh: stops the background sensor process through the PID file.
Structure
core/
app/ API, configuration, persistence, and classification
static/ local web interface
sensor/
app/ sensor API, runtime, scanner, and transport
fixtures/ demo payload
config/ configuration example
data/ active config, logs, database, and spool
scripts/ bootstrap and operational utilities
tests/ automated testsObservability
append_json_log()writes structured events to the core log.GET /api/v1/healthandGET /healthact as minimal health checks.- The UI dashboard acts as the local operational panel.
- The SQLite database works as an auditable trail of executions and incidents.
- Disk spool enables troubleshooting of delivery failures between sensor and core.
Security
- There is no authentication or authorization between components in V1.
- The exposure surface assumes local operation on
localhost. - The whitelist is a critical asset: incorrect configuration directly affects classification.
- Using Scapy requires operational care because the sensor may run with elevated privileges.
- The project correctly separates the privileged sensor runtime from the containerized core runtime.
Deploy
- Ensure
Docker,python3, andcurlare installed on the host. - Adjust
data/config/detector.ymlaccording to the interface and trusted servers. - Run
./scripts/bootstrap.sh. - Open
http://127.0.0.1:8000to access the UI. - If you get a permission error on
/dev/bpf0, restart only the sensor withsudo.
Common Problems
Permission denied: could not open /dev/bpf0: the sensor does not have enough permission to use Scapy on macOS.Cannot connect to the Docker daemon: Docker Desktop or the daemon is not running.- UI language does not switch: the browser may be loading stale assets; restart the core and perform a hard refresh.
scan_in_progress: a manual scan or watchdog is already running in the sensor.- No events in the dashboard: the scan may have returned
no_response, the whitelist may be correct, or the sensor may not be capturing on the right interface. - Changes in
detector.ymlare not reflected: restart is required because the configuration is not reloaded automatically.
Current V1 Limits
- initial focus on macOS;
- no automatic blocking of rogue servers;
- no authentication between components;
- no formal database schema migrations;
- no real use of
retriesin the scanner; - no native integration with SIEM, NAC, switch, or firewall;
- no dynamic configuration reload.