CYBER // SONAR: Technical Postmortem | Security Writeup by Husain Hakim
Reflections on building a tactical intranet telemetry appliance, managing MAC addresses without root privileges, and SQLite concurrent loads.
CYBER // SONAR: Technical Postmortem
Reflections on building a tactical intranet telemetry appliance, managing MAC addresses without root privileges, and SQLite concurrent loads.
## Why MAC Addresses, Not IPs
The first real decision on this project happened before I wrote any scanning code: deciding what a "device" actually is. My first instinct was to key everything off IP address, since that's what you see when you check your router's device list. That's wrong, and I only realized it once I started testing: DHCP leases shift, so a phone that's 192.168.1.15 today can be 192.168.1.30 tomorrow. If IP is your primary key, the same physical device silently becomes a "new" device in your database every time its lease renews, and your history just fragments.
Switching the primary key to MAC address fixed that, but it meant rebuilding the discovery logic around Layer 2, not Layer 3, which is a meaningfully different problem than I'd originally scoped.
## Getting MAC Addresses Without Root
The harder constraint I set for myself was doing this without requiring root or raw sockets. Most ARP scanning tools assume elevated privileges because they're crafting raw ARP packets directly. I didn't want this to require sudo to run a home network scan.
The workaround was indirect: fire parallel ICMP pings and TCP socket probes across all 254 addresses in the subnet, and let the operating system's own networking stack do the ARP resolution for me as a side effect of routing those probes. Then I read the kernel's ARP table (`arp -a` / `/proc/net/arp`) afterward instead of building my own ARP requests. It works, but it's a workaround, not a clean solution: it depends on the OS populating that table reliably, which isn't something I control directly.
## The Randomized MAC Problem
I didn't anticipate this going in: modern phones (iOS 14+, Android 10+, recent macOS) intentionally randomize their MAC address per network specifically to prevent tracking, which is exactly the kind of tracking this tool does. So a meaningful fraction of devices on any real network show up with a MAC that's deliberately not tied to their actual hardware identity.
Detecting this required understanding a very specific bit: the U/L (Universal/Local) bit in the first byte of a MAC address. If it's set, the address is locally administered, i.e., randomized, not manufacturer-assigned. Once I could detect that, I could at least label these correctly instead of misidentifying them against my OUI vendor table. But detection isn't resolution: a randomized MAC still doesn't tell you which iPhone it is. That's where I leaned on mDNS/Bonjour service discovery to recover human-readable names like "Husain's MacBook Air" as a second, independent signal, since the MAC alone wasn't going to get me there.
## Concurrency Was Not Optional
Scanning 254 addresses sequentially, even at a 1-second timeout each, is over 4 minutes per sweep, completely unusable for anything that's supposed to feel real-time. Moving to asyncio with a ThreadPoolExecutor cut that to 1.5–3.5 seconds, which was the difference between a toy script and something that actually functions like a dashboard.
The tradeoff was concurrency bugs I hadn't dealt with before: specifically, overlapping scans competing for the same sockets and stepping on each other's database writes. I added an `asyncio.Lock()` around scan execution to serialize sweeps, which is a blunt fix (you lose true overlapping scans) but a correct one, and correctness mattered more than squeezing out more speed at that point.
## SQLite Under Concurrent Load
Default SQLite locks the entire file on write, which becomes a real problem the moment you have a background auto-scan job writing while the API is also trying to read. I hit `database is locked` errors early and fixed it by switching to WAL mode (`PRAGMA journal_mode = WAL`), which allows concurrent reads during writes. This was one of the smaller changes in terms of lines of code and one of the more important ones in terms of the app actually being usable continuously rather than just for single manual scans.
## What I'd Do Differently
The Layer 2 constraint limits deployment more than I initially accounted for. Because this depends on reading the local ARP table and doing subnet-local socket probing, it fundamentally can't run on serverless infrastructure like Vercel: it needs to run on the actual physical network, or at minimum in a container with host networking. I designed around this correctly, but I underestimated early on how much that one constraint would shape every deployment decision downstream.
Test coverage came late relative to how stateful this system is. The state-diff engine (detecting joins, leaves, and IP changes between scans) is exactly the kind of logic that's easy to get subtly wrong: off-by-one errors in "missing from this sweep" logic, or race conditions between a scan finishing and a manual purge triggering. I wrote tests, but retroactively, after the core logic existed rather than test-driving the diff engine itself. I'd build the tests alongside the diffing logic next time, not after.
The polar-coordinate radar visualization was fun but not rigorously validated. Mapping IP octet and latency to radius/angle looks compelling on screen, but I didn't spend much time validating that the visual mapping is actually meaningful vs. just aesthetically pleasing: it's closer to a stylistic choice than a data-accurate representation, and I should be honest about that distinction rather than implying the radar position encodes something more precise than it does.
## The Actual Takeaway
This project taught me that "scan a network" sounds like one problem but is actually four or five loosely related ones stacked together: physical layer discovery, hardware identity resolution, privacy-aware MAC handling, concurrent state management, and real-time visualization. Each of those had its own wrong-turn-then-correction moment, and the parts I'm most confident in now (WAL mode, the MAC-as-primary-key decision, the ARP-cache-warming approach) are the ones I got wrong first and had to actually understand before fixing.