Polls an Aruba AirWave (AMP) instance, turns its XML API into clean JSON, and surfaces the three things a campus wireless operator actually gets paged about:
- AP availability — which access points are down, per building and per zone.
- Client density — zones carrying an abnormal client load, scored against a rolling baseline rather than a fixed threshold.
- Rogue APs — unauthorized radios seen by campus APs, ranked by AirWave's threat score and enriched with encryption and the discovering AP.
It ships with a simulated AirWave appliance, so the whole stack runs end to end with no campus hardware.
┌────────────┐ XML over ┌──────────────┐ JSON/SSE ┌───────────┐
│ AirWave │◀─ cookie auth ─│ poller + │─────────────▶│ dashboard │
│ appliance │ every 60s │ alert engine│ │ CLI / API │
└────────────┘ └──────────────┘ └───────────┘
docker compose up -d --build
open http://localhost:8080That starts two containers: the dashboard, and mock_airwave standing in for a
real appliance. Within one poll interval the board fills with 30 simulated APs
across four zones, and a rogue AP appears a few minutes in so the alerting path
is visible without waiting for a real incident.
If port 8080 is already taken (it commonly is — Jenkins, qBittorrent, and Tomcat all default to it), both ports are configurable:
DASHBOARD_PORT=18080 AIRWAVE_PORT=18081 docker compose up -dA container stuck in Created with Bind for 0.0.0.0:8080 failed: port is already allocated is always this. Check with ss -ltnp | grep 8080.
python -m venv .venv && . .venv/bin/activate
pip install -r requirements-dev.txt
python -m uvicorn mock_airwave.server:app --port 8081 & # simulated appliance
AIRWAVE_BASE_URL=http://127.0.0.1:8081 python -m uvicorn app.main:app --port 8080Delete the airwave service from docker-compose.yml, copy .env.example to
.env, and set at minimum:
AIRWAVE_BASE_URL=https://airwave.campus.edu
AIRWAVE_USERNAME=readonly-api
AIRWAVE_PASSWORD=...Use a read-only AirWave account. Nothing here writes to the appliance — the client only issues GETs against the XML views plus the form login.
The same client, without a browser — for jump hosts and for piping into other
tools. Every subcommand takes --json.
python -m app.cli health # campus rollup + per-zone table
python -m app.cli aps --down # only APs currently down
python -m app.cli aps --zone Lecture-Halls
python -m app.cli zones --json | jq '.[] | select(.ap_down > 0)'
python -m app.cli rogues --threats-only
python -m app.cli rogues --max-score 2 # 1 is the worst
python -m app.cli watch --interval 30 # poll forever, print alerts as they fire--url overrides AIRWAVE_BASE_URL for one invocation, which is the fastest way
to point the CLI at a test appliance. Colour is emitted only to a TTY, so pipes
stay clean; NO_COLOR=1 disables it entirely.
Campus network health
access points 30 (29 up, 1 down)
availability 96.67%
clients online 185 (6.4 per live AP)
rogue detections 5 (4 threats, 4 unacknowledged)
ZONE APS UP AVAIL CLIENTS PER AP
--------------- ------ ------ ------- ------
Admin-Secure 4/4 100.0% 12 3.0
Campus-Standard 13/14 92.9% 85 6.54
Lecture-Halls 5/5 100.0% 57 11.4
Residence-Halls 7/7 100.0% 31 4.43
Interactive docs at /docs. All routes are under /api; /healthz is separate
and deliberately unauthenticated so a load balancer can probe it.
| Method | Path | Notes |
|---|---|---|
GET |
/api/health |
Campus-wide rollup + poll status |
GET |
/api/snapshot |
Everything from the latest poll in one document |
GET |
/api/aps |
?group= ?status=up|down ?search= (name or IP) |
GET |
/api/groups |
Per-zone health, baselines and z-scores |
GET |
/api/rogues |
?threats_only= ?max_score=1..5 ?classification= ?unacknowledged= |
GET |
/api/alerts |
?severity= ?limit= ?since_minutes= — newest first |
GET |
/api/history |
Rolling client counts per zone, for charting |
POST |
/api/refresh |
Force a poll now instead of waiting out the interval |
GET |
/api/stream |
Server-sent events, one message per completed poll |
GET |
/healthz |
Liveness; 503 once 5 polls have failed in a row |
Rogue lists come back most-dangerous-first: lowest threat score wins, ties broken by strongest signal, because a loud rogue is physically close to campus.
Set API_KEY to require an X-API-Key header on /api/*. Leave it blank when
running behind an already-authenticated reverse proxy. /healthz is never
gated, so health probes keep working either way.
All rules share one cooldown gate (ALERT_COOLDOWN_SECONDS, default 15 min), so
a zone that stays oversubscribed for an hour produces one alert, not sixty.
| Alert | Severity | Fires when |
|---|---|---|
ap_down |
critical | An AP reports down for AP_DOWN_CONFIRMATIONS consecutive polls (default 2) |
ap_recovered |
info | A previously-alerted AP is reachable again |
client_density_spike |
warning | Zone client count exceeds DENSITY_ZSCORE_THRESHOLD σ above its rolling baseline |
rogue_detected |
warning | A rogue/suspected-rogue BSSID is seen for the first time |
rogue_high_threat |
critical | An unacknowledged rogue scores at or below ROGUE_ALERT_MAX_SCORE (1 = worst) |
poll_failure |
critical | Two or more consecutive polls of AirWave fail |
Three deliberate suppressions, each of which exists to stop a class of false page:
- AP flap debounce. A single missed poll is usually a scrape hiccup, not a dead radio, so an AP must report down twice before it alerts.
- Density floor. Spikes below
DENSITY_MIN_CLIENTS(default 15) are ignored; 3 → 9 clients is arithmetic, not an incident. Baselines also needDENSITY_MIN_SAMPLES(default 10) polls before they're trusted at all, so a fresh start stays quiet for ~10 minutes rather than alerting on an empty mean. - Cold-start rogue silence. On the very first poll every rogue looks new. Alerting on all of them would bury the operator after every restart, so the first poll seeds the known set silently.
The density baseline uses only prior samples. Including the current one would drag the mean toward the spike being detected and suppress the very alert you want. Where a zone's baseline is perfectly flat (zero σ), the engine falls back to a relative-change measure so constant zones can still alert.
Everything is environment variables; .env is read if present. See
.env.example for the annotated full set.
| Variable | Default | Purpose |
|---|---|---|
AIRWAVE_BASE_URL |
http://localhost:8081 |
Appliance base URL |
AIRWAVE_USERNAME / AIRWAVE_PASSWORD |
admin |
Read-only API account |
AIRWAVE_VERIFY_TLS |
true |
Set false only for self-signed certs on an internal net |
AIRWAVE_TIMEOUT_SECONDS |
30 |
Per-request timeout |
AIRWAVE_MAX_RETRIES |
3 |
Attempts per XML fetch before giving up |
AIRWAVE_DETAIL_CONCURRENCY |
4 |
Parallel rogue_detail.xml calls |
AIRWAVE_DETAIL_BUDGET |
25 |
Max rogue detail fetches per poll |
POLL_INTERVAL_SECONDS |
60 |
Base poll cadence |
POLL_JITTER_SECONDS |
5 |
Random spread, so a fleet doesn't sync onto one second |
HISTORY_POINTS |
240 |
Samples retained per zone (240 @ 60s = 4h) |
API_KEY |
(unset) | If set, /api/* requires X-API-Key |
CORS_ORIGINS |
(empty) | Comma-separated allowed origins |
Alert thresholds (DENSITY_*, AP_DOWN_CONFIRMATIONS, ROGUE_ALERT_MAX_SCORE,
ALERT_COOLDOWN_SECONDS) are listed in the table above.
The XML view paths are settings, not constants, because they have moved between AMP releases and some sites front the appliance with a reverse proxy that rewrites them. The defaults match a stock AMP 8.x:
| Setting | Default |
|---|---|
AIRWAVE_LOGIN_PATH |
/LOGIN |
AIRWAVE_AP_LIST_PATH |
/ap_list.xml |
AIRWAVE_AP_DETAIL_PATH |
/ap_detail.xml |
AIRWAVE_ROGUE_LIST_PATH |
/api/list_view.xml |
AIRWAVE_ROGUE_LIST_NAME |
rogue_aps (sent as ?list=) |
AIRWAVE_ROGUE_DETAIL_PATH |
/rogue_detail.xml |
Verify them against your own appliance before trusting them — don't assume this table matches your release. Log in in a browser and fetch each path directly:
curl -sc /tmp/aw.jar -X POST \
-d 'credential_0=USER&credential_1=PASS&destination=/&next_action=' \
https://airwave.campus.edu/LOGIN
curl -sb /tmp/aw.jar 'https://airwave.campus.edu/ap_list.xml' | head -40XML is what you want. HTML back means the path is wrong or the login didn't
take — the appliance answers an expired session with the login page and a
200 OK, not a 401, which is why the client sniffs bodies rather than
trusting status codes. python -m app.cli health --url ... is the fastest
end-to-end check once paths are set.
Element names vary too, so the parsers match on local name only (ignoring
the amp: namespace prefix, whose URI also drifts) and accept aliases: client
counts are read from clients, client_count or num_clients; is_up accepts
true/1/yes/up. A row that still won't parse is logged and skipped rather
than failing the whole poll.
app/
main.py FastAPI wiring, lifespan, /healthz, static mount
config.py pydantic-settings; every appliance-specific value
models.py domain models, decoupled from AirWave's XML shape
cli.py terminal front-end over the same client
airwave/
client.py cookie-session HTTP client: re-auth, retries, backoff
parsers.py XML -> models, via defusedxml
services/
poller.py background loop: fetch -> snapshot -> alerts -> commit
alerts.py the rules; stateful across polls
state.py in-memory snapshot, ring-buffer history, alert log, SSE
api/routes.py the JSON API
static/index.html the dashboard (no build step, no dependencies)
mock_airwave/ simulated appliance for dev, demos and tests
Three design decisions worth knowing before changing anything:
State is in-process, not in a database. The dashboard's entire job is to answer "what is happening right now", and every number it shows can be rebuilt from a single poll. A restart therefore costs one poll interval, not a migration. History lives in bounded ring buffers, so memory is flat over time and zones that disappear have their history dropped rather than leaking.
The client assumes AirWave will misbehave. There is no bearer token: you
POST form credentials, get a session cookie, and that cookie expires server-side
without warning — after which the appliance returns the HTML login page with a
200 OK. So the client infers auth from the body, re-authenticates once and
retries, serialises login behind a lock (a burst of concurrent calls triggers
exactly one re-auth), backs off exponentially on 5xx, and bounds concurrent
detail fetches. AirWave is one appliance serving the whole campus; fanning out
across every rogue would take hundreds of round trips, so the detail budget is
spent on the highest-threat entries only.
The poller must never die. It catches every exception per cycle, backs off
exponentially while AirWave is unhealthy (capped at 10 minutes), and adds jitter
so a fleet of instances doesn't synchronise onto the same second and hammer the
appliance. A dead poller would leave the board silently serving stale numbers —
which is why /healthz returns 503 after 5 consecutive failures and lets the
orchestrator restart the container instead.
make venv # virtualenv + dependencies
make test # 133 tests, no network required
make run # dashboard with reload, against a local simulator
make up # the full stack in Docker
make help # every targetmake targets take DASHBOARD_PORT and AIRWAVE_PORT overrides, e.g.
make up DASHBOARD_PORT=18080. Under the hood it's plain pytest and
uvicorn if you'd rather drive them directly.
Tests run entirely against mock_airwave and in-memory fixtures — the suite
never touches a real appliance and needs no credentials.
scripts/smoke.sh checks a running dashboard end to end — that the poll
landed, the parsers produced APs and rogues, detail enrichment actually
arrived, and every route serves:
make smoke # against localhost:8080
./scripts/smoke.sh https://dash.campus.edu.github/workflows/ci.yml runs three jobs on every push and pull request:
- Tests — the full suite on Python 3.11 and 3.12.
- End-to-end — boots the simulator and the dashboard, then runs
scripts/smoke.shagainst them, with a deliberately short session TTL so the re-authentication path is exercised rather than assumed. - Container — builds the image, brings the compose stack up, waits for the Docker healthcheck to report healthy, and smokes that too.
The simulator has hooks for driving scenarios by hand:
curl -X POST 'http://localhost:8081/_sim/ap/1001/down?seconds=600' # force an AP down
curl http://localhost:8081/_sim/aps # inspect sim stateIts clock runs accelerated (MOCK_TIME_ACCEL, default 20×) so the daily client
curve is visible in minutes instead of hours, and MOCK_SESSION_TTL is short in
compose so the client's re-authentication path gets exercised on every demo.
- The container runs as an unprivileged user (uid 10001). It talks to a management-plane appliance and has no business holding root in its namespace.
HEALTHCHECKprobes/healthz; the container goes unhealthy once AirWave has been unreachable for 5 polls, so an orchestrator restarts it rather than serving a silently stale board.- Behind nginx, the SSE endpoint needs buffering off. The app already sends
X-Accel-Buffering: no, which nginx honours; other proxies may need it set explicitly. - Run
uvicornwith--proxy-headersbehind a load balancer (the image already does). - Credentials come from the environment. Don't bake
.envinto the image —.dockerignoreexcludes it.
| Symptom | Cause |
|---|---|
port is already allocated, container stuck in Created |
Something else holds 8080. Set DASHBOARD_PORT. |
login returned no session cookie |
Wrong credentials, or AIRWAVE_LOGIN_PATH isn't the login form. |
received HTML (likely the login page) instead of XML |
Session didn't establish, or the XML path is wrong for your AMP release. |
| Board loads but every number is zero | First poll hasn't completed yet; check /healthz for last_error. |
| No density alerts ever | Baselines need DENSITY_MIN_SAMPLES polls (~10 min) and DENSITY_MIN_CLIENTS clients. |
certificate verify failed |
Self-signed appliance cert. Set AIRWAVE_VERIFY_TLS=false only on a trusted internal network. |
