Local-first WiFi mesh-network detector and analyzer.
Drop in any .pcap / .pcapng. MeshHawk reconstructs the 802.11 topology,
scores every connected component for "mesh-ness", and shows you a live map of
who's really talking to whom — without a single byte leaving your machine.
- Topology reconstruction — extracts QoS-data frames with scapy, resolves source / destination per the 802.11 DS flags, and builds a directed MAC graph in networkx.
- Mesh detection — partitions the graph into weakly-connected components and scores each one. A component is classified as a mesh when every node has degree ≥ 70 % of the component size.
- Per-component details — density, undirected diameter, BSSIDs, and the full device list with OUI vendor lookup (cached on disk after the first call).
- Two ingest paths — upload a capture from disk, or — when a WiFi card is in 802.11 monitor mode — sniff straight from the browser. The live-capture capability is auto-detected at runtime.
- Printable report — a per-capture report with all components, devices,
and metrics, ready for
Cmd/Ctrl + P.
┌─────────────────────────┐
│ React + Vite │
│ (apps/web, :5173) │
└────────────┬────────────┘
│ JWT (Bearer)
▼
┌─────────────────────────┐
│ FastAPI │
│ (apps/api, :8000) │
│ │
│ /auth /captures │
│ /analyses /live │
│ /system │
└─────┬──────────────┬────┘
│ │
SQLAlchemy async │ │ ARQ enqueue
▼ ▼
┌──────────┐ ┌──────────┐
│ Postgres │ │ Redis │
│ :5432 │ │ :6379 │
└──────────┘ └────┬─────┘
│ pop job
▼
┌─────────────────────────┐
│ ARQ worker │
│ (analyze_capture) │
│ │
│ scapy → networkx │
│ → vendor cache │
│ → Postgres │
└─────────────────────────┘
Per-capture pipeline:
- Upload — multipart
pcap(+ optional airodumpcsv) lands inMESHHAWK_UPLOAD_DIR. The API returns201with statuspendingand enqueuesanalyze_capture(capture_id)to Redis. - Parse — scapy walks the capture (auto-detects pcap vs pcapng), keeps
only QoS-data frames (
type == 2 && Dot11QoS present), resolves source/dest/BSSID per theto_ds/from_dsflags, filters broadcast/null/multicast MACs. - Graph — every unique
(src, dst)becomes a directed edge in anetworkx.DiGraph. Components are extracted viaweakly_connected_components. - Score — per component: density, undirected diameter, and the mesh
heuristic (
every node has degree ≥ 0.7 × (n − 1)). - Enrich — first 3 MAC octets (OUI) → vendor via
api.macvendors.com, cached on disk in themac_vendorstable. - Persist — full result lands in
analyses.result(JSONB, GIN-indexed), capture status flips toready, and the frontend's React Query auto-refetches.
A printable PDF report is generated on demand from a Jinja2 HTML template, with each component's topology rendered as an inline SVG (matplotlib
- networkx). The route is
GET /analyses/by-capture/{id}/report.pdf.
| Tool | Version | Install |
|---|---|---|
| Docker | 27+ | https://docs.docker.com/engine/install/ |
| Python | 3.13 | system / pyenv |
| uv | 0.5+ | curl -LsSf https://astral.sh/uv/install.sh | sh |
| Node | 22+ LTS (24 tested) | nvm recommended |
| Bun or pnpm or npm | latest | Makefile auto-detects |
| Pango (libpango-1.0) | system | Linux: sudo apt install libpango-1.0-0 libpangoft2-1.0-0 — required by WeasyPrint for PDF reports |
Verify with:
make doctormake setup # .env, deps, postgres+redis, migrations, demo user
make dev # API (:8000) + worker + web (:5173)Then open http://localhost:5173 and sign in with the seeded demo account:
demo@meshhawk.dev / meshhawk
Drop in a .pcap to see the topology.
make help # show all targets
# Setup
make setup # one-shot first-run (env + deps + db + seed)
make env # copy .env.example → .env if missing
make doctor # verify toolchain (uv, docker, node/bun)
# Dev
make dev # api + worker + web concurrently
make api.dev # uvicorn only
make worker.dev # ARQ worker only (auto-reload)
make web.dev # Vite only
# Install / Upgrade
make install # api + web deps
make upgrade # bump everything to latest allowed
make api.upgrade # uv lock --upgrade + sync
make web.upgrade # bun/pnpm/npm update
make web.upgrade.latest # rewrites package.json to absolute latest
make web.outdated # what's behind
# Services / DB
make services.up # postgres + redis (docker compose)
make services.down
make services.reset # wipe volumes, migrate, seed
make db.migrate # alembic upgrade head
make db.revision m="..." # generate a new migration
make db.seed # demo user
make db.shell # psql
# Quality
make lint # ruff + biome
make fmt # ruff format + biome write
make test # pytest
# Build
make build # api wheel + web dist
meshhawk/
├── apps/
│ ├── api/ # FastAPI service
│ │ ├── alembic/
│ │ ├── src/meshhawk/
│ │ │ ├── main.py # app factory + lifespan + exc handlers
│ │ │ ├── config.py # pydantic-settings (MESHHAWK_ env vars)
│ │ │ ├── db.py # async engine, sessionmaker, deps
│ │ │ ├── core/ # security, exceptions, logging
│ │ │ ├── models/ # SQLAlchemy ORM (User, Capture, Analysis, MacVendor)
│ │ │ ├── schemas/ # Pydantic request/response DTOs
│ │ │ ├── repos/ # repository pattern (query layer)
│ │ │ ├── services/
│ │ │ │ ├── pcap_parser.py # scapy → PcapStats (pcap + pcapng)
│ │ │ │ ├── csv_parser.py # airodump CSV → two sections
│ │ │ │ ├── graph_analyzer.py # networkx → GraphMetrics
│ │ │ │ ├── mac_vendor.py # OUI lookup with on-disk cache
│ │ │ │ ├── live_capture.py # scapy live + capability detection
│ │ │ │ ├── storage.py # safe file uploads
│ │ │ │ └── analysis_pipeline.py # orchestrates parser → graph → vendor
│ │ │ ├── queue/ # ARQ worker settings + Redis pool
│ │ │ ├── tasks/ # ARQ task functions
│ │ │ ├── api/ # FastAPI routers + deps
│ │ │ └── scripts/seed.py
│ │ ├── tests/
│ │ ├── alembic.ini
│ │ └── pyproject.toml
│ └── web/ # Vite + React
│ ├── src/
│ │ ├── main.tsx, router.tsx
│ │ ├── index.css # Tailwind 4 design tokens
│ │ ├── lib/ # api client, queryClient, types, utils
│ │ ├── stores/ # zustand (auth, theme)
│ │ ├── hooks/ # TanStack Query hooks
│ │ ├── components/
│ │ │ ├── ui/ # shadcn primitives
│ │ │ ├── layout/ # AuroraBackground, Navbar, CustomCursor, …
│ │ │ ├── network/ # NetworkGraph (React Flow), ComponentCard
│ │ │ ├── viz/ # PowerChart (Recharts)
│ │ │ └── common/ # StatCard, StatusPill, EmptyState
│ │ └── features/ # one folder per page-flow
│ └── package.json, vite.config.ts, biome.json
├── docker/postgres/init.sql # citext + pgcrypto extensions
├── docker-compose.yml # postgres + redis
├── Makefile
├── .env.example
└── README.md
Migrations live in apps/api/alembic/. Four tables:
| Table | Notes |
|---|---|
users |
CITEXT email (case-insensitive unique), Argon2id hash, last_login_at, composite index on (is_active, created_at) |
captures |
Per-user pcap metadata; composite indexes on (user_id, created_at DESC) and (user_id, status); FK cascade |
analyses |
One-to-one with captures (unique capture_id), aggregate stats + JSONB result with GIN index, check-constraint mesh_component_count ≤ component_count |
mac_vendors |
OUI → vendor cache, primary key oui CHAR(6) with regex check ^[0-9a-f]{6}$ |
Every table uses UUID PKs (gen_random_uuid() via pgcrypto), timezone-aware
timestamps with server_default now(), and predictable constraint names via
MetaData(naming_convention=…) so Alembic autogen stays clean.
Base path: /api/v1. OpenAPI docs at http://localhost:8000/docs.
| Method | Path | Purpose |
|---|---|---|
POST |
/auth/signup |
Create user |
POST |
/auth/login |
JSON body → { access_token, refresh_token } |
POST |
/auth/token |
OAuth2 password-flow (for /docs "Authorize") |
POST |
/auth/refresh |
Refresh access token |
GET |
/auth/me |
Current user |
| Method | Path | Purpose |
|---|---|---|
GET |
/captures |
Paginated list (most recent first) |
POST |
/captures |
Multipart upload (pcap required, csv optional) — enqueues analysis |
GET |
/captures/{id} |
Single capture (incl. status) |
DELETE |
/captures/{id} |
Removes capture + files + analysis |
GET |
/captures/{id}/file |
Download original pcap |
GET |
/captures/{id}/csv |
Download attached CSV (if any) |
| Method | Path | Purpose |
|---|---|---|
GET |
/analyses/by-capture/{id} |
Full graph result (404 until ready) |
POST |
/analyses/by-capture/{id}/rerun |
Re-queue analysis (202) |
GET |
/analyses/by-capture/{id}/report.pdf |
Render a printable PDF report |
| Method | Path | Purpose |
|---|---|---|
GET |
/live/capabilities |
Tells the UI whether to show the live-scan toggle, and why not if not |
POST |
/live/captures |
Sniff { interface, duration_seconds } and queue analysis |
| Method | Path | Purpose |
|---|---|---|
GET |
/system/health |
Returns version + DB connectivity |
All errors return a consistent envelope:
{ "error": { "code": "invalid_credentials", "message": "Invalid email or password." } }Defaults in .env.example work as-is for local dev. Override anything via
process env or .env:
MESHHAWK_ENV=development # development | production | test
MESHHAWK_DATABASE_URL=postgresql+asyncpg://…/meshhawk
MESHHAWK_REDIS_URL=redis://localhost:6379/0
MESHHAWK_JWT_SECRET=...long-random...
MESHHAWK_ACCESS_TOKEN_TTL_MIN=60
MESHHAWK_REFRESH_TOKEN_TTL_DAYS=14
MESHHAWK_CORS_ORIGINS=http://localhost:5173 # comma-separated
MESHHAWK_UPLOAD_DIR=./.data/uploads
MESHHAWK_MAX_UPLOAD_MB=200
MESHHAWK_DEMO_USER_EMAIL=demo@meshhawk.dev
MESHHAWK_DEMO_USER_PASSWORD=meshhawk
VITE_API_BASE_URL=http://localhost:8000- Vite + React + TypeScript with strict mode
- Tailwind v4 (CSS-first config, no
tailwind.config.js) — see src/index.css for the design tokens, with full light + dark variants - shadcn/ui primitives, Radix under the hood
- TanStack Query v5 for server state (smart polling: rows refresh every
1.2 s while
analyzing, then back off to 30 s) - Zustand for the bits of client state (auth + theme), both
persist-ed - React Router v7
- React Flow (
@xyflow/react) for the network graph; Recharts for power bars - Motion (formerly Framer Motion) for transitions and status ripples
- Biome for lint + format
The whole UI is responsive and supports Light / Dark / System via the navbar toggle.
The /live/capabilities route does runtime detection:
- POSIX check
iporiwinPATH- At least one interface with
/sys/class/net/<iface>/type == 803(monitor) geteuid() == 0
When all pass, the UI lights up LiveScanPage with the detected interfaces
and a duration slider. Otherwise the reason is rendered inline ("No interface
in monitor mode…", "Live capture requires root…").
To enable, put your card in monitor mode and run the API with sudo:
sudo airmon-ng start wlan0
sudo -E uv run uvicorn meshhawk.main:app --reloadmake test # apps/api: pytest -qUnit tests cover:
graph_analyzer— empty graph, multi-component, triangle is mesh, single-edge is not, threshold validationcsv_parser— both airodump sections, empty input, single-sectioncore.security— password roundtrip, access/refresh roundtrip, type validation, tampered token rejection
make db.seed (idempotent) creates:
demo@meshhawk.dev/meshhawk(superuser)
Wipe and re-seed:
make services.reset # nukes Postgres + Redis volumes, re-runs migrations + seedMIT.
Akshat Pandey, Sanskar Dwivedi, Yash Sakre, Ranjit Ranjan, Jayash Tripathi, Poorva Diwan.