Skip to content

Repository files navigation

Campus Network Health & Rogue AP Dashboard

CI

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.

Dashboard showing AP availability, per-zone client density, rogue AP detections and live alerts

┌────────────┐   XML over    ┌──────────────┐   JSON/SSE   ┌───────────┐
│  AirWave   │◀─ cookie auth ─│  poller +    │─────────────▶│ dashboard │
│  appliance │   every 60s    │  alert engine│              │ CLI / API │
└────────────┘                └──────────────┘              └───────────┘

Quick start

docker compose up -d --build
open http://localhost:8080

That 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 -d

A 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.

Without Docker

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 8080

Pointing at a real appliance

Delete 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.


CLI

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

HTTP API

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.


Alert rules

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 need DENSITY_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.


Configuration

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.

AirWave endpoint paths

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 -40

XML 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.


How it works

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.


Development

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 target

make 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

CI

.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.sh against 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 state

Its 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.


Deployment notes

  • 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.
  • HEALTHCHECK probes /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 uvicorn with --proxy-headers behind a load balancer (the image already does).
  • Credentials come from the environment. Don't bake .env into the image — .dockerignore excludes it.

Troubleshooting

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.

About

Polls Aruba AirWave for AP availability, client-density anomalies and rogue APs, with alerting, a live dashboard, CLI and a simulated appliance so it runs end to end without campus hardware.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages