DEV Community

Cover image for CERBERUS v2: Six Ways to Watch a Network (and the Bugs That Shaped How)
Harshwardhan S. Ranvir
Harshwardhan S. Ranvir

Posted on

CERBERUS v2: Six Ways to Watch a Network (and the Bugs That Shaped How)

Multi-source LAN discovery, a trust engine that survives MAC randomization, and click-to-confirm alert emails. CLI, API, and dashboard, all built on the same service layer. Here's the full breakdown of how it's built and what broke along the way.


CERBERUS v1 answered one question: is anything on my network that I don't recognize? It did that with ARP alone, in a terminal, until you closed the terminal. No history. No distinction between "new" and "returning." Nothing that survived a reboot.

That's not a sentinel. That's a script you remember to run.

v2 is the real answer. Something that runs permanently, watches the network from more than one angle, keeps history per device, and only interrupts you when something's genuinely wrong, with a next step attached to the alert instead of just a ping.

This post covers what it does, how each piece actually works, the real bugs that shaped the design, and what I'd tell myself before starting.

What CERBERUS v2 Does

  • Multi-source detection. Scapy ARP every 60s for presence, two-tier Nmap for vendor/OS/ports/services, plus mDNS, DHCP, SSDP, and LLMNR filling in whatever ARP alone misses. Every source writes through its own "fill this if empty, never overwrite a better source" path, so a weaker signal never clobbers a stronger one.
  • A trust engine, not a device list. New devices alert, trusted ones don't, and a 24-hour learning window on first run auto-trusts your existing baseline so you're not immediately buried in "intruder" alerts for your own router.
  • MAC-randomization aware. A phone rotating its MAC per network join gets matched back to itself via hostname/vendor/label instead of looking like a brand-new device on every reconnect.
  • Actionable alert emails. A signed, single-use, click-to-confirm Trust link, and a Block link straight to your router's admin page with credentials shown alongside it. Nothing gets auto-blocked, ever.

CERBERUS v2 alert email showing returning unknown device with <br>
Trust This Device and Open Router Admin Panel buttons

What actually lands in your inbox when something unrecognized joins.
  • Dashboard, REST API, and CLI. Same service layer underneath all three. Nothing is dashboard-exclusive.

What it does NOT do:

  • No multi-user accounts or permissions — single operator, an API key (or bare localhost access) is full control
  • Nothing gets auto-blocked — Block opens your router's login page, you take the action
  • Docker only runs on Linux — raw ARP scanning needs real interface access, and Docker Desktop on Windows/macOS runs every container inside a VM that can't see your LAN, no config fixes that

CERBERUS v2 dashboard showing 5 devices, 1 untrusted flagged, <br>
learning mode active with 22h 57m remaining.

The Dashboard — MAC addresses redacted for this post it normally shows the full address per device.

Architecture: The Seam Holds Everything Together

cerberus/
├── core/           scanner_scapy.py, scanner_nmap.py, scheduler.py
├── detection/      router_detector, vendor_lookup, mdns/dhcp/ssdp/llmnr
├── intelligence/   trust_engine.py, learning_mode.py
├── storage/        device_store.py — the only module that touches SQLite
├── alerts/         alert_manager.py, email_alert.py
├── service/        cerberus_service.py — the seam
├── cli/ · api/     terminal.py, server.py
└── utils/          config_loader, link_tokens, npcap_installer, logger

frontend/src/       App.jsx, Dashboard.jsx, api.js — React + Vite, talks only to the API
Enter fullscreen mode Exit fullscreen mode

The single biggest design decision underneath all of this is the seam. cerberus_service.py is the only class the CLI and the API are allowed to call. Neither of them touches storage, the trust engine, or alerting directly.

# service/cerberus_service.py
"""
The seam: both cli/terminal.py and api/server.py call only this class,
never storage, intelligence, or alerts directly. Owns zero logic of
its own — every method is a thin dispatch, no business decisions here.
"""
Enter fullscreen mode Exit fullscreen mode

Every method on this class is a thin dispatch into storage, intelligence, or alerts. No business logic lives here. Trust and untrust also clear the device's alert cooldown through this one path, so CLI and API don't each have to independently remember to make both calls. The dashboard is just another client of the same seam. Nothing it can do is exclusive to it.

Deep Dive: How Each Component Works

1. Scapy ARP Scanning — Presence, Twice Per Cycle

ARP is stateless, has no authentication, and every device on the network answers it. That's what makes it the fastest way to know what's alive. scanner_scapy.py sends the broadcast twice, half a second apart, and merges by MAC:

answered, _  = srp(packet, timeout=self.timeout, verbose=0)
time.sleep(0.5)
answered2, _ = srp(packet, timeout=self.timeout, verbose=0)
# merge both passes, dedupe by MAC
Enter fullscreen mode Exit fullscreen mode

One pass misses devices that were mid-handshake or just slow to answer. Two passes catches most of them without doubling the scan interval. It's a half-second tax, not a full retry cycle.

2. Nmap, Two Tiers — Cheap and Expensive, on Purpose

A full aggressive scan against every host on every cycle would be slow and loud. So the scanner splits into two:

_QUICK_ARGS      = "-sn"
_AGGRESSIVE_ARGS = ("-A -T4 -sV -O --top-ports 1000 "
                    "--script=banner,http-title,ssh-hostkey,smb-os-discovery "
                    "--osscan-guess --version-intensity 7")
Enter fullscreen mode Exit fullscreen mode

scan_quick runs a ping sweep every 180s to keep vendor and hostname fresh. scan_aggressive_hosts runs the expensive fingerprint every 360s, threaded across a worker pool, and only against IPs Scapy already confirmed alive. It never scans a subnet blindly, and it never assumes a device exists just because its IP falls inside the range. A third path, scan_single_host, fires the moment Scapy spots a brand-new MAC mid-cycle, so a new device gets fingerprinted immediately instead of waiting up to six minutes for the next aggressive pass.

3. Router Detection — the WSL2 Bug

Before anything scans, Cerberus has to know which networks this machine is on. router_detector.py reads interfaces, IPs, and gateways, no Scapy or Nmap involved. This is where the nastiest bug in the whole project turned up.

The old filter for virtual adapters (VMware, VirtualBox, Docker's own bridges) checked interface names for markers like "wsl" or "vethernet", and checked IP ranges against a handful of fixed /24s. Two problems. Some Windows setups report a raw GUID as the interface name instead of anything human-readable, so the name check never matched. And WSL2's default NAT network isn't a fixed /24 at all. It lands anywhere inside 172.16.0.0/12, different per machine.

# Old: string-prefix matching against a few hardcoded /24s
# New: real CIDR containment via the stdlib
network = ipaddress.ip_network(f"{ip}/{cidr}", strict=False)
if any(network.subnet_of(v) for v in KNOWN_VIRTUAL_RANGES):
    continue
Enter fullscreen mode Exit fullscreen mode

Every undetected phantom network was getting its own full set of scan workers, Scapy plus two Nmap tiers, one of them a threaded aggressive pool, competing for CPU against the network that actually mattered, for zero real devices found, every single cycle. Swapping string matching for real ipaddress containment checks fixed it in general, not just for this one case.

4. Vendor Lookup — 40,000+ OUI Entries, Never Overwriting Nmap

Nmap ships its own small internal MAC-vendor database. Cerberus bundles a much larger one, 40,000+ real IEEE-assigned OUI entries in data/oui.txt, and backfills whatever Nmap missed:

def update_vendor_if_missing(self, mac, vendor):
    # only writes if the current vendor is NULL — never overwrites
    # something Nmap already found, even if Cerberus would word it
    # differently for the same OUI
Enter fullscreen mode Exit fullscreen mode

Same module flags known hypervisor vendor strings (VMware, VirtualBox, Hyper-V) as a purely cosmetic "possible VM" tag on alerts. It never changes a trust verdict. A bridged VM is still a real device and shows up like anything else.

5. Trust Engine — MAC Randomization Is the Default Now, Not the Edge Case

Trusted stays trusted regardless of whether a scan misses a device for one cycle. That's the correctness fix over v1. The verdict reads off the trusted column in the database, never off "did I see this MAC last cycle." A phone asleep through one ARP sweep isn't an intruder.

The harder problem: iOS's Private Wi-Fi Address and Android's equivalent rotate the device's MAC on every network join. Left unhandled, a trusted phone looks brand-new every time it reconnects.

if hostname and self._hostnames_match(hostname_lower, known_host):
    mac_rand_suspected       = True
    matched_trusted_hostname = known_host
    base_verdict = TrustVerdict.UNTRUSTED_RETURNING
Enter fullscreen mode Exit fullscreen mode

A hostname, vendor, or label match against an already-trusted device downgrades the verdict from UNTRUSTED_NEW to UNTRUSTED_RETURNING, with a randomization flag attached. Still shown, never silently re-trusted. That decision stays with the operator.

6. Learning Mode — Two Processes, One Truth

The scanner and the CLI run as separate OS processes, each holding its own LearningMode object in memory. Originally, state loaded once at startup, so running learning stop from the CLI while the scanner kept running had zero effect on it. The scanner simply never knew anything changed.

current_mtime = os.path.getmtime(self._state_file)
if self._last_seen_mtime is None or current_mtime != self._last_seen_mtime:
    self._load_state()   # changed on disk since last read it — reload
Enter fullscreen mode Exit fullscreen mode

Checking the state file's mtime before every read made the JSON file the shared source of truth instead of a restart-recovery snapshot. It also doesn't re-arm itself: has_ever_started() gets checked before any auto-start, so restarting the scanner after a deliberate learning stop doesn't reopen a fresh 24-hour trust-everything window behind your back.

7. Npcap Installer — the Import That Broke Every Platform

Windows has no native raw-socket access; Scapy needs Npcap for ARP scanning there. The installer checks, and silently installs if missing, fully non-interactive. No input() prompts, since Cerberus might run as a background service where a blocking prompt just hangs forever.

The bug: the module used to import winreg unconditionally at the top of the file. winreg is Windows-only in the standard library, so importing it on Linux or macOS raised ModuleNotFoundError immediately, which broke cerberus_main.py's own import chain, since it imports this module too. Not "Npcap-checking doesn't work on Linux." All of Cerberus failed to start on Linux, because of one unconditional import at the top of a Windows-only file.

try:
    import winreg
except ImportError:
    winreg = None   # every winreg-using function was already gated by is_windows()
Enter fullscreen mode Exit fullscreen mode

The usage itself was already correctly gated behind platform checks everywhere it mattered. The import statement wasn't.

8. Alert Emails and Signed Tokens

Trust is GET-to-confirm, POST-to-act, specifically because some email clients and corporate gateways pre-fetch links before a human opens the message. A bare GET that trusted a device on contact would fire silently from that prefetch alone.

payload = {"mac": mac, "purpose": purpose, "jti": token_id, "exp": exp_unix}
signature = hmac.new(secret.encode(), payload_b64.encode(), hashlib.sha256).hexdigest()
Enter fullscreen mode Exit fullscreen mode

MAC, purpose, and expiry are all covered by the signature, so nothing in the token survives tampering without invalidating the whole thing. Redemption itself is enforced at the database level, an atomic INSERT behind a UNIQUE constraint on the token ID, so two near-simultaneous clicks on the same link can only ever let one through. Block is deliberately not automated. It's a link to the device's router gateway with your admin credentials shown as plain text beside it. Cerberus never submits anything on your behalf.

9. Storage — WAL Mode, One Module, Four Tables

SQLite, single file, WAL journal mode, the setting that lets the CLI and the running scanner, two separate OS processes, read and write concurrently without corrupting the database. device_store.py is the only module in the entire codebase permitted to open a connection to it. Four tables: devices (keyed on MAC, everything from vendor and open ports down to trust state and label), scan_history (one row per sighting, powers the history command's timeline), alerts_log (every alert sent, post-cooldown), and used_tokens (redeemed Trust/identify tokens, so a link can never fire twice).

Sample Output

Startup:

╔══════════════════════════════════════════════════════╗
║          CERBERUS v2 — The Network Sentinel          ║
║          Three-Tier Aggressive Scanner               ║
╚══════════════════════════════════════════════════════╝
Enter fullscreen mode Exit fullscreen mode

Shutdown summary:

────────────────────────────────────────────────────────
SHUTDOWN SUMMARY
  Total devices : 14
  Trusted       : 13
  Untrusted     : 1
────────────────────────────────────────────────────────
192.168.1.108   xx:xx:xx:xx:xx:xx  Apple, Inc.            iOS 17 (91%)  [? unknown]
    443/tcp  https AirPlay
Enter fullscreen mode Exit fullscreen mode

CLI and API Reference

python -m cerberus.cli.terminal list [--untrusted]
python -m cerberus.cli.terminal trust <mac>
python -m cerberus.cli.terminal label <mac> "Owner's Laptop"
python -m cerberus.cli.terminal history <mac>
python -m cerberus.cli.terminal learning start --hours 2
Enter fullscreen mode Exit fullscreen mode

The API mirrors it route for route: /api/devices, /api/devices/<mac>/trust, /api/alerts, /api/learning/start, /api/status (the single endpoint the dashboard polls most), all behind an optional X-API-Key if CERBERUS_API_SECRET is set. /api/health is always open, for basic liveness checks. /confirm/trust/<token> and /confirm/identify/<token> sit outside /api/* on purpose, unauthenticated by design, because the signed token itself is the credential, the same model any password-reset email link uses.

One route worth naming specifically: POST /api/devices/<mac>/request-id issues a private, single-use "identify yourself" link for one device from the dashboard. You copy it and send it however you want, text, WhatsApp, in person. Cerberus never sends it anywhere on its own.

CERBERUS v2 identify confirmation page asking

What the other person sees when you send them a request-id link — no account, no app, just a name field."

Key Engineering Decisions

Why not auto-block? A false positive that auto-blocks your own laptop is worse than one more email. Cerberus tells you, hands you the login page already pointed at the right device, and gets out of the way.

Why SQLite over Postgres? Single operator, single machine, no concurrent-write contention that WAL mode doesn't already solve on its own. Nothing about this workload needs a database server.

Why one service layer under CLI, API, and dashboard? So trust/untrust always clears the alert cooldown too, in exactly one place, not three call sites each having to remember to do it separately.

Why real CIDR checks over string matching for virtual adapters? Because a fixed list of known ranges breaks the moment a platform (WSL2, in this case) doesn't use a fixed range. ipaddress.subnet_of() handles any prefix length correctly instead of the neat boundaries someone wrote out by hand.

Why keep the Npcap installer non-interactive? Cerberus is meant to run headless — a background service, eventually a scheduled task. A blocking input() prompt in that context doesn't get skipped, it just hangs forever.

What I Actually Learned

1. "It ran once" isn't the same as "it's correct." An earlier version of the ARP retry logic had its final return [] sitting inside the retry loop instead of after it. It bailed after the very first failed attempt and never retried anything. Passed every casual test, because a first-attempt failure and an all-attempts-exhausted failure look identical from the outside.

2. Cross-process state is a different category of bug entirely. Two Python processes each holding their own in-memory object is not shared state, no matter how confidently the code reads. learning stop doing nothing to a live scanner is what taught me to ask "which process owns this truth" before writing any state-checking logic since.

3. An unconditional import can take down an entire platform, not just the feature it belongs to. The Npcap installer's winreg import broke Cerberus on Linux and macOS entirely, not "Npcap detection doesn't work there." Everything failed to start. The fix that mattered was three lines. Finding it took tracing the import chain instead of assuming the crash was where the traceback pointed.

4. A "known virtual ranges" list is a snapshot of what you knew when you wrote it. The WSL2 bug wasn't really about WSL2. It was about hardcoding specific /24s instead of doing the CIDR math properly. Any future virtualization platform with a non-standard range would have hit the same wall.

5. MAC randomization isn't an edge case anymore, it's the default. Most modern phones rotate their MAC on every network join. A trust system built like v1's, MAC as the sole identity, breaks quietly for most devices on a real home network.

6. AI helped scaffold some of the discovery-protocol boilerplate. Debugging the cross-process race, the retry-loop bug, and the WSL2 phantom-network issue was mine, line by line, with logs open, because none of those show up until you run the thing against a real, messy network and watch what happens.

Limitations

  • Single-operator design — no accounts or per-user permissions, the API key (or bare localhost access) is full control
  • DHCP sniffing has a narrow, deliberate blind spot: a sighting for a MAC the store doesn't know yet gets dropped rather than retried, since the buffer clears on every drain. Rare in practice, ARP runs far more often than DHCP renewals
  • Docker deployment is Linux-only, a Docker Desktop architecture constraint, not something Cerberus can work around
  • If CERBERUS_LINK_SECRET is never set explicitly, a fresh one generates on every restart, invalidating every previously emailed Trust link

Quick Start

Linux (server / always-on):

git clone https://github.com/Ranvir2028/Cerberus-The-Network-Sentinel-v2.git
cd Cerberus-The-Network-Sentinel-v2
cp .env.example .env
docker compose up -d
Enter fullscreen mode Exit fullscreen mode

Windows / macOS (desktop):

git clone https://github.com/Ranvir2028/Cerberus-The-Network-Sentinel-v2.git
cd Cerberus-The-Network-Sentinel-v2
pip install -r requirements.txt
cp .env.example .env
cp frontend/.env.example frontend/.env
python run_dev.py
Enter fullscreen mode Exit fullscreen mode

run_dev.py runs backend and frontend together, installs Npcap on Windows automatically if it's missing, pulls npm deps on first run, and opens the dashboard once the frontend is up. Needs sudo on Linux/macOS or Administrator on Windows either way. Raw packet access requires it on any OS.

Security Note

Only run Cerberus against networks you own or have explicit permission to monitor. ARP and Nmap scanning send packets to every IP in range, on a network that isn't yours, that might get you in trouble if found out so be thoughtful about it.


GitHub: github.com/Ranvir2028/Cerberus-The-Network-Sentinel-v2

Portfolio: harshwardhan-ranvir.vercel.app

LinkedIn: linkedin.com/in/harshwardhan-s-ranvir-20a328378


Top comments (1)

Collapse
 
kartik_shenoy_aab49728d28 profile image
kartik shenoy

Great work Harshwardhan!