AiroAlert Admin
Detection
Flight Tracking
Freq-Hopping
Anti-Spoofing
SDR
Map
Help
Active Drones
currently tracked
SDR Receivers
/ online
Channels Scanned
Detections (24h)
total flights logged

Detection ThresholdsLive

Signal strength and identification confidence required to flag a detection.

Flight Tracking

Frequency-Hopping Detection

Telemetry Spoofing Detection

Map Defaults

Branding

Customer / Site Name
Shown next to the title on the map and admin pages. Leave empty to hide.

Map Source

Map Tile Source

Alert Settings

Drone Alert Popup
Enabled
Alert Trigger

SDR Settings

SDR Receivers

Gain, band, and enabled changes require a receiver restart to take effect. Click Restart receivers after saving to apply.

+ Add Device

Scan Frequency Channels

2.4 GHz
5.8 GHz
+ Add Channel

Drone Lists

Whitelist (0)
Empty
Blacklist (0)
Empty

API Keys

Each key is tied to a user. Send it as the X-API-Key header to access endpoints marked Restricted below. The full key is shown only once at creation — copy it before closing the dialog.

+ Create API Key

API Endpoints

Public endpoints are reachable without authentication. Restricted endpoints require a valid API key (or an admin session) on every request.

Method Path Description Visibility

Interface Languages

Users

Create User

Username
Password
Role

Change My Password

Current Password
Required to verify identity
New Password
Min 8 chars, uppercase, lowercase, digit, special char

Two-Factor Authentication (2FA)

Loading…

Audit Log

Timestamp Actor Action Target Detail IP
No log entries.

IQ-File Replay

Stream a recorded IQ capture through the live decoder pipeline. Live SDR capture pauses for the duration of the replay so the consumer doesn't interleave bursts at different centre frequencies. Supported formats: .cf32 (interleaved float32 I/Q), .cs16 (interleaved int16 I/Q, scaled by 1/32768), and .sigmf-data + .sigmf-meta pairs.

Upload capture file
Server directory: · Max upload:  MB

For SigMF files, sample rate and centre frequency are read from the sidecar metadata when available and override the values entered above.

Available files
Empty

System Help

What's New since v2.5.0

Map sidebar polish (v2.5.1). The Lists tab is now a full inline drone-list editor — whitelist, blacklist, filter, and remove drones directly from the operator map without bouncing to /admin#lists. The Site Overview panel gained a Blacklist hits row alongside Whitelist hits. Sidebar tab labels were shortened to Live / History / Lists, with locale-aware short forms so the row no longer overflows in long-label languages.

Map legend rewritten to match what's actually drawn (v2.5.2). A new Threat Tier (dot fill) section explains the green/yellow/red dot inside each marker; the drone-status row shows a circular ring with the real 🛸 emoji; the Signal-source row shows the 📡 emoji rendered for RF/FH detections; the Flight-trails section shows historical replay as a dashed grey rule. The map itself now renders historical-replay polylines + mid-point markers in neutral grey (start/end remain green/red so direction of travel still reads).

i18n catch-up (v2.5.2 → v2.5.7). The admin Branding card, Two-Factor Authentication card, Trusted Devices card, and the map Site Overview panel + SDR status pills (Connected / Offline / Disabled / "none configured") are now translated in every language. Added the long-missing tab_replay key so the admin Replay tab is no longer English-only.

Visual fixes (v2.5.3 → v2.5.6). The admin tab bar wraps to a second row in long-label locales instead of cropping the Help tab off the right edge. The card heading-vs-subtitle layout no longer overlaps in browsers supporting :has(). The Channels Scanned KPI now reads correctly even when /api/scan_channels is flipped to restricted in the API tab.

Map layout + Help readability (v2.5.8 → v2.5.9). The Site Overview panel has been relocated from top-left to top-right of the map so it no longer overlaps Leaflet's zoom controls. The Help-tab body text was previously hardcoded in a dark-theme slate that gave low contrast on light theme; it now uses the theme-aware text colour and reads cleanly on both themes.

Operator-UX overhaul (v2.6.0). Two big passes — one on the map sidebar, one on the admin settings page. Map: each Live-list row now has a leading-edge selection checkbox; ticking one or more drones reveals a contextual Whitelist / Blacklist / Clear bar above the list so operators can list drones in batches. A new avatar pill + dropdown at the top of the right sidebar holds Admin panel + Logout (replaces the small text link buried at the bottom of the Lists tab). Site Overview Alerts: 0 / Blacklist hits: 0 / Whitelist hits: 0 no longer render in red — zero means no threat, so the colour kicks in only when the count is non-zero (red for alerts/blacklist, green for whitelist matches). Disabled SDR receivers switched from a grey dot to amber + ⚠, so coverage gaps stop hiding in the panel. Long drone names ("OcuSync 3 (~20 MHz) @ 2464.4 MHz") wrap to two lines in the sidebar and the popup title instead of getting ellipsis-truncated. Popup card style and section labels were aligned with the Site Overview panel so the two surfaces read as the same component family. Admin: the 13-tab flat nav is now grouped into three top-level sections — Detection / Hardware / Admin — with sub-tabs revealed on demand, so the strip no longer overflows at smaller viewports. Settings tabs gained an unsaved-changes guard: trying to switch tabs with dirty fields prompts "You have unsaved changes. Leave without saving?" (and the browser-native unload dialog catches close / reload). Save buttons are now consistently placed in a fixed bottom-right bar on every settings tab (Detection / Flight Tracking / Freq-Hopping / Anti-Spoofing / Map / SDR / Frequencies). Numeric config fields no longer follow the OS locale — the EN UI used to render "0.22" as "0,22" on a Latvian / German / French host; now they always use period as the decimal separator, and scientific notation (1e-05) expands to plain decimal (0.00001). The Users tab gained a role-change confirmation step (a dropdown change now shows Save role / Cancel chips and pops "Change <user>'s role from <old> to <new>?" before calling the API). 2FA badge colours were flipped so accounts with 2FA read green ("hardened") and accounts without read amber ("action needed"). Edit / Delete buttons across SDR / Frequencies / Users / API keys / Replay were unified to outlined-secondary / outlined-red so a Delete in one tab looks like a Delete in another.

Navigation + Live-row polish (v2.6.1 → v2.6.2). Help is no longer a sub-tab under Admin; it's now a fourth top-level button in the group nav, alongside Detection / Hardware / Admin. Help is open to every role — viewer, editor, admin — so nesting it under the admin-only management surface was the wrong mental model. The Live-list rows in the operator map regained a compact SEEN entry (now / 5s / 3m / 2h) in the meta line: it stays muted while the drone is actively transmitting and turns red the moment the row's stale threshold (> 30 s) trips, reinforcing the dimmed left-edge accent at the opposite end of the row.

Multi-receiver band affinity & live receiver reconfig (v2.7.0). Three connected changes for two-Pluto deployments. Per-SDR band assignment. Each receiver registered in the SDR Devices tab now has a Band selector — Full (2.4 + 5.8), 2.4 GHz only, or 5.8 GHz only. Pinning one Pluto to 2.4 GHz and another to 5.8 GHz stops the two receivers from tune-fighting on the same channel and roughly doubles per-channel revisit rate. The setting is persisted in a new band column on sdr_devices (idempotent ALTER auto-runs on first boot). Restart receivers button. Gain, band, and name changes used to require a full docker compose restart airoalert to take effect, because the per-URI configuration was read once at thread start. The SDR tab now has a Restart receivers button next to the device list — saving an edit then clicking the button refreshes the in-memory config from DB and signals each producer thread to reconnect on its next iteration (~1 SDR poll cycle). No container restart needed. Source-tagged decode log. Per-capture summary and ++ DECODED lines now include the originating receiver's name in square brackets — e.g. [Pluto-58] 5776.5 MHz: 1 burst(s), max corr 0.821 (decoded). Trivial when one Pluto is present; essential when two are scanning in parallel. Gain precedence fix. The legacy RX_GAIN_DB environment variable used to silently override the per-SDR mode from the admin UI — an operator who set Slow Attack AGC in the DB still saw the env-var manual gain applied at the chip. Precedence is now explicit: rx_gain_mode from the DB is authoritative, and RX_GAIN_DB is only consulted as the default for manual mode when the dB field is blank. The shipped .env has been updated to comment out the legacy override.

2.4 GHz coverage for un-decodable DJI bursts (v2.8.0). Two coordinated changes to the DroneID decoder turn previously-lost 2.4 GHz traffic into actionable map coverage. In-band noise gate, universal. The symbol-3 FFT power-concentration check that previously only ran for low-correlation bursts (corr < 0.5) now applies at every correlation level. A real DJI burst concentrates ~0.9 of symbol-3 power in the 600 DroneID data carriers; a WiFi false-positive that happens to correlate with the ZC sequence concentrates ~0.02. The 0.40 threshold cleanly separates the two populations (empirically validated against 59 captured 2.4 GHz near-misses) and silently filters the WiFi alarms before they reach the turbo decoder or the RF-only fallback. Single-burst RF-only promotion. A real-DJI CRC-fail (the matched filter found a DroneID-shaped burst, the in-band gate passed it, but the turbo decoder rejected the bits — almost certainly OcuSync 3/4 firmware that the bundled remove_turbo cannot decode) now surfaces as a DJI DroneID @ X MHz (RF only) map marker on the first hit, rather than waiting for the 3-burst temporal-persistence gate the older RF-only path required. The 3-burst path stays as a fallback for ambiguous low-correlation cases. RF-Only Min Bursts in Detection is therefore now a fallback control, not the primary phantom suppressor — leave at 3 unless you see false markers, which the in-band gate should be preventing.

Live-list bulk-select & login affordance (v2.6.3 → v2.6.7). Several follow-on UX passes on the operator-map sidebar. Bulk-select bar. Per-row selection tooltip rewritten to "Select to whitelist or blacklist" so the checkbox's purpose is obvious. The action bar moved from above the list to a position-fixed panel anchored to the bottom-right of the sidebar; it slides up + fades in when selection is non-empty, slides down on clear. Buttons restyled as outlined — ✓ Whitelist (green outline) and ✗ Blacklist (red outline) — with disabled state when no operator session is active (tooltip "Log in to manage lists"). The redundant in-bar "Not logged in" text was removed (the user-menu at the top of the sidebar is the single login affordance). The per-row trail-labels checkbox was removed because operators read it as a duplicate of the selection one. Section Select all. Each group heading carries a small right-aligned Select all chip that ticks every row in its section in one shot; renders as indeterminate when the selection is partial, fades when the operator isn't an editor/admin. Map markers. Real drones now carry a compact <N> m AGL ribbon under the disc, so the operator can read altitude without opening the popup. Login affordance. Top-right button renamed from "Sign in" to "Log in" (operators were misreading Sign in as Sign up). A long-standing CSS bug that ghost-rendered the avatar pill's "?" and "—" placeholders in the logged-out sidebar was fixed.

Overview

AiroAlert is a real-time drone detection and identification system. It passively monitors the radio spectrum for DJI Drone ID transmissions, ASTM F3411 Remote ID broadcasts, Bluetooth LE advertisements, and frequency-hopping RF emissions from common UAV control links. Detected drones are decoded, geolocated, and displayed live on the map.

Detection Pipeline

SDR (PlutoSDR): The SDR receiver cycles through 2.4 GHz and 5.8 GHz DJI DroneID channels. Each channel is sampled for ~65 ms. A Zadoff-Chu matched filter detects DroneID preambles; confirmed bursts are passed through an OFDM demodulator and turbo decoder to extract the full telemetry frame.

Frequency-Hopping (FH): An STFT-based spectrogram is computed on each IQ capture. Blobs exceeding a median-adaptive threshold are extracted as candidate hops. Sequences with sufficient hop count and centre-frequency diversity are promoted to FH tracks — this catches OcuSync and Lightbridge links that carry no decodable ID.

Suppressing false-positive OcuSync from WiFi: The FH path is the most likely source of phantom drone markers in WiFi-rich 2.4 GHz environments. If the map shows OcuSync 2/3/4 tracks while no drone is in the area, tighten the gates in Detection → FH: raise FH Threshold Gap (dB) (16-18 trims weaker WiFi blobs), FH Min Hops (8-12), FH Min Distinct Centres (5-6), and FH Min Bin Onset Spread (ms) (20-25 — single wideband WiFi bursts that fragment into adjacent blobs fire at the same instant; real FH visits centres sequentially). Lowering FH Max Time Occupancy to 0.4 also rejects continuous transmitters parked on one channel. For phantom DJI DroneID @ X MHz (RF only) markers, the v2.8.0+ in-band ratio gate filters most WiFi-triggered alarms before they can promote (real DJI carries ~0.9 of symbol-3 FFT power in the 600 DroneID data carriers; WiFi-shaped bursts carry ~0.02). If you still see phantom markers after v2.8.0, bump RF-Only Min Bursts to 3-5 — the older temporal-persistence gate stays as a fallback for ambiguous low-correlation cases.

Method-III Chirp Confirmer: When an FH track is promoted, a half-symbol autocorrelation with frequency shift (Cwalina/Rajchowski/Sadowski, Sensors 2025, 25(15):4552, eq. 7) is run against the same capture. The detector exploits the odd time-frequency symmetry of DJI OcuSync chirp symbols (Zadoff-Chu OFDM with linear sweep) and is independent of the carrier frequency. Three variants are tested: 18 MHz video up-chirp, 9 MHz narrow-channel up-chirp, and DroneID irregular down-chirp. Confirmed tracks have their source label promoted from FHSS (STFT) to FHSS+chirp, raising operator confidence that the link is genuinely OcuSync vs. FH-shaped Wi-Fi/BT noise.

Remote ID / ASTM F3411: The system accepts Remote ID frames via the POST /api/remote_id HTTP endpoint. An external sniffer (Wi-Fi NAN, BT5 Long Range, or BLE) can forward hex-encoded messages for decoding. Two reference sniffers ship with v1.1.0 — ble_remote_id_sniffer.py and wifi_remote_id_sniffer.py under /opt/airoalert-sensor/ — and run unprivileged on a Raspberry Pi with a USB Wi-Fi adapter in monitor mode and the on-board BLE radio. The Location decoder also surfaces per-axis GPS / sensor accuracy ranges (horizontal, vertical, speed, barometric, timestamp) used for threat scoring and spoof detection.

Multi-Receiver TDoA (v1.1.0+)

When three or more time-synchronised receivers observe the same DroneID burst, AiroAlert can independently localise the emitter from the differences in arrival time — no on-board GPS required, and no trust placed in the drone's self-reported position. The hyperbolic solver in tdoa.py runs Gauss-Newton iteration in a local ENU tangent plane and reports both the fix and HDOP (geometric quality — <2 is good, >5 is poor). The same fix is then compared against the drone's broadcast GPS to score spoofing: a TDoA fix that disagrees with the broadcast position by more than the broadcast accuracy budget is flagged as a likely spoof.

The math (solver, fan-in correlator, anti-spoof comparator) ships and is exercised by the unit-test suite. The hardware path — sample-clock discipline via PlutoSDR + GPSDO (10 MHz reference + PPS) and per-burst capture timestamps — is gated on TimingSource.is_disciplined() returning true on every receiver, so TDoA stays dark until the GPSDO is wired and the capture-path integration lands.

Map Page

The map auto-refreshes every 2 seconds. The floating header (top-right, just left of the sidebar) is the Site Overview panel — active drones, whitelist hits, blacklist hits, alerts (red when non-zero), and a live Last update counter. Leaflet's zoom buttons sit at top-left of the map. The right sidebar opens with a user menu at the top (avatar pill + username + role; dropdown holds Admin panel for editors/admins and Logout) — signed-out visitors see a Log in button instead, which routes to the inline login form in the Lists tab. Below the user menu sit three tabs:

  • Live — currently transmitting drones, sorted by recency, split into two group headers: Alerts (blacklisted or high-threat) and Live. Each card carries a status pill (LIVE / ALERT / BLACKLIST) on the right and a coloured left-edge accent (red for alerts, green for live, dimmed for stale >30 s). The badge on the tab title mirrors the active count even when the tab is in the background.
  • History — past flights with date-range, model, and serial filters. The "X of N match" counter shows when filters are narrowing the set; CSV and JSON buttons export the filtered set (ignoring pagination — pagination is just a screen detail). Each row also has a small KML button (under the show point labels checkbox) that downloads the single flight as airoalert_flight_<serial>_<timestamp>.kml: a LineString with absolute altitudes (path floats above the terrain at the recorded MSL, not draped on the ground), Start (green) and End (red) placemarks, and a <gx:Track> with paired <when> + <gx:coord> so Google Earth's time slider plays the flight back at recorded speed. Open with Google Earth Pro (double-click) or Google Earth Web (drag-drop). The tab badge shows the total flight count.
  • Lists — drone Whitelist / Blacklist / Remove management. Click a drone in the Active tab to select it, switch to Lists, and the action buttons enable for that serial. Editing requires editor or admin role; anonymous viewers see the read-only counts plus a login form. The tab badge shows the combined whitelist + blacklist entry count.

Live-list rows are laid out as: a leading-edge selection checkbox (tooltip "Select to whitelist or blacklist"; drives the bulk-action bar pinned to the bottom of the sidebar — tick one or more rows and an outlined ✓ Whitelist / ✗ Blacklist pair slides up with the selected count), a coloured left-edge accent strip (green for live, red for alerts/blacklist, dimmed once the row goes stale), the drone name with a status pill (LIVE / ALERT / BLACKLIST) on the right, and a monospace meta line below with SEEN (compact age — now / 5s / 3m / 2h), SN (short serial), RSSI (dBm or dBFS), and distance from the primary receiver. Each group heading (Alerts · N / Live · N) carries a small right-aligned Select all affordance that ticks every row in its section in one shot (indeterminate while the selection is partial). A row whose drone hasn't been heard for over 30 seconds dims the left-edge accent and turns the SEEN value red.

Markers on the map render as a colored disc inside a colored ring with an arrow above it: the disc fill is the threat tier, the ring is the list status (white-/black-/un-listed), the emoji on the disc identifies the signal source (🛸 = decoded drone, 📡 = FH/RF-only blob, 🧑 / 🏠 = pilot / home pseudo-marker), and the arrow rotates by the drone's GPS course (or body yaw if no course is available). Real drones that report height above ground also carry a small <N> m AGL ribbon under the disc, so the operator can read altitude at a glance without opening the popup; the ribbon is omitted when the drone has no AGL value, and on pilot / home / FH pseudo-markers.

Marker popup is split into Position & Motion, optional Pilot/Home, Signal, state chips (In Air / Motor / GPS / Home Set / UUID Set), and a collapsible Details panel for UUID, version, sequence, correlation, etc. The Details open/closed state survives the 2-second auto-refresh per drone.

Map source / offline tiles: The map base layer can be switched between OpenStreetMap, CARTO Dark/Light, Esri Satellite, a fully-offline local server (the tileserver compose service, populated by scripts/fetch-tiles.sh), or a custom tile URL. The local option avoids leaking the operator's viewport coordinates to third-party CDNs and works air-gapped — required for classified deployments. Configure in Map → Map Tile Source in the admin UI.

Customer / site label: A short free-text label rendered as a pill next to the title in both the operator map header and the admin topbar. Useful when a single team operates several deployments and needs to tell them apart at a glance (e.g. ACME Airfield, Riga HQ, Demo Rig). Set in Map → Branding → Customer / Site Name; clearing the field hides the pill on both pages. The value is served via /api/public_config, so it appears for any user who can load the page.

Threat Levels

The marker disc and live-list dot fill encode an at-a-glance threat tier independent of the list-status border:

  • Low (green): whitelisted (always wins), or known/grounded with GPS lock.
  • Medium (yellow): in-air but not yet meeting "high" criteria, or no GPS lock yet.
  • High (red): blacklisted, suspicious telemetry detected, or in-air and moving faster than 5 m/s.

Whitelist short-circuits the rest — your own asset is never marked critical.

Keyboard Shortcuts

Active on the Map page when no form input has focus:

  • / — jump to Flight History tab and focus the Serial filter
  • Esc — close any open marker popup
  • / — step through the Live list, opening each drone's popup (wraps at edges)
  • 1 / 2 / 3 — switch to Live / History / Lists tab

Admin Roles

  • admin — full access: change settings, manage users, view audit log.
  • editor — can manage drone lists, translations, scan frequencies, and SDR device registry.
  • view — read-only access, can change own password.

SDR Devices

Register each physical SDR receiver in the SDR tab. Each record stores the device URI (used by the detection service), its geographic location in ASL/AGL metres, a human-readable name, RX gain mode (Slow Attack AGC / Fast Attack AGC / Manual), and an optional band restriction. Multiple receivers listed in the PLUTO_URIS environment variable (or the sdr_devices DB table) are handled in parallel by the detection pipeline. The first enabled receiver with a valid lat/lon is used as the reference point for the live-list bearing/distance column.

Band affinity (v2.7.0+). The Band selector pins a receiver to a subset of the scan plan. Full (2.4 + 5.8) is the default — the producer cycles through every tune in SCAN_PLAN_24 + SCAN_PLAN_58. 2.4 GHz only restricts to SCAN_PLAN_24 (three tunes covering 2399.5–2474.5 MHz), 5.8 GHz only restricts to SCAN_PLAN_58 (three or six tunes depending on ENABLE_5GHZ_WIDE). With two Plutos, assign one to each band so the cycle time per channel halves. Single-Pluto setups should leave it at Full.

RX gain mode. Slow Attack AGC is the right default — the AD9361's slow-attack AGC holds gain through a 640 µs DroneID burst and adapts on ~ms timescales, handling the 1 m–5 km path-loss spread automatically. Fast Attack AGC reacts inside a burst and is wrong for sparse signals on 2.4 GHz (it clamps gain down in response to ambient WiFi and never recovers in time for the drone burst). Manual takes a fixed gain (0–71 dB) and is useful for bench testing at a known distance: roughly 10 dB at 1 m, 25–30 dB at 20 m, 40 dB at 100 m, 60 dB at 1 km, 70 dB at 5 km. Per-SDR DB values are authoritative; the legacy RX_GAIN_DB environment variable is only consulted as the default for Manual mode when the dB field is blank.

Restart receivers (v2.7.0+). Gain, band, name, and enabled-flag changes are picked up on a receiver reconnect, not on the in-flight capture cycle. After saving an edit, click the Restart receivers button at the top of the SDR list — the admin route refreshes the in-memory config from DB and signals each producer thread to reconnect on its next iteration (status pill reads Reconnecting receivers for ~4 s). Round-trip is typically one SDR poll cycle. Adding or deleting a Pluto still requires a container restart, since producer threads are spawned at startup per URI; everything else is live.

IQ Replay (v2.1.0+)

The Replay tab streams a recorded IQ capture through the same decoder pipeline that processes live SDR samples (Zadoff-Chu correlation → OFDM demod → QPSK → turbo decode → DJI frame parse, plus the parallel STFT FH detector). Useful for: regression-testing a captured drone burst against a new threshold, validating a hardware change without a real flight, and reproducing a near-miss saved by IQ_DUMP_NEARMISS_DIR.

Workflow: upload a file via the Upload button, set the centre frequency and sample rate the recording was captured at, then click Replay on the listed file. Live SDR capture pauses for the duration so the consumer thread isn't fed two streams at different centre frequencies; it resumes automatically when the replay finishes or is stopped.

Supported formats: .cf32 (interleaved float32 I/Q — the SDR++/GNU Radio default), .cs16 (interleaved int16 I/Q, scaled by 1/32768 — HackRF / Pluto raw), and SigMF — upload the .sigmf-data file together with its .sigmf-meta sidecar (datatype cf32_le or ci16_le). For SigMF, sample rate and centre frequency from the sidecar override the values entered in the form. The file list flags any .sigmf-data uploaded without its sidecar so it can't be replayed by accident.

File storage: uploads land in the directory shown under the Upload button (default iq_replay/ next to airoalert.py; override with the IQ_REPLAY_DIR environment variable). Per-file size cap is configurable via IQ_REPLAY_MAX_UPLOAD_MB (default 1024 MB ≈ 4 s of cf32 at 30.72 MSPS). Filenames are restricted to alphanumerics, dot, underscore, and hyphen with a leading alphanumeric — quotes, spaces, and HTML-significant characters are rejected as a defence-in-depth against admin-UI XSS.

Audit: upload, delete, start, and stop are recorded in the audit log under actions iq_replay_upload, iq_replay_delete, iq_replay_start, and iq_replay_stop. The Replay tab is admin-only.

Alerts

When the alert popup is enabled (Map tab → Alert Settings), the map page plays a short audible beep and shows a popup whenever a new drone matching the configured trigger condition appears. The popup must be clicked to dismiss. The trigger can be set to any drone, blacklisted only, or unknown or blacklisted. The feature can be toggled per-session on the map page itself.

Languages

Ten interface languages ship by default (en, lv, de, ee, fr, lt, es, fi, sv, ua). Editors can adjust translations live in the Languages tab. Per-config-row labels and descriptions on this admin page are also translatable via the cfg_label_<key> / cfg_desc_<key> entries — labels fall back to the auto-generated Title-Case form and descriptions fall back to the English schema text when a key is missing in a given locale.

Versioning

The version label in the map legend (and admin top bar) is resolved at startup with the following priority:

  • AIROALERT_VERSION environment variable. Highest priority. Used by Docker / Compose builds — the Dockerfile takes --build-arg AIROALERT_VERSION and bakes it as ENV, so containers (which strip .git via .dockerignore and don't ship the git binary) still report a real version. Build with: AIROALERT_VERSION=$(git describe --tags --always --dirty) docker compose build.
  • git describe --tags --always --dirty in the script's directory. Used by ordinary clones. Produces 1.2.3, 1.2.3-N-g<hash>, with -dirty appended for uncommitted changes.
  • VERSION file co-located with airoalert.py. Useful for non-Docker deployments that strip .git, run shallow clones, or run under a systemd unit that can't read .git. Operators can seed it manually at install time with git describe --tags --always --dirty > VERSION.
  • unknown as a last resort.

If git describe fails on a non-Docker deploy, the systemd journal / container logs print the failure reason (FileNotFoundError = git not on PATH, CalledProcessError = not a git repo or ownership refusal, etc.) so the cause is recoverable.

Security

All passwords must be at least 8 characters and contain uppercase, lowercase, digit, and special character. Sessions expire after 8 hours. All login attempts, configuration changes, and user management actions are recorded in the audit log with actor, timestamp, and IP address.

Two-Factor Authentication (2FA)

TOTP-based 2FA (RFC 6238) — works with any standards-compliant authenticator (Google Authenticator, Authy, 1Password, Aegis, Bitwarden, ...). Enrol from Account → Two-Factor Authentication: scan the QR, type the 6-digit code, save the printable backup codes (shown once).

  • Backup codes — 10 single-use xxxx-xxxx-xxxx codes generated at enrolment. Type any one in place of a TOTP if you've lost your authenticator. Regenerate the full set from the Security card; old codes are invalidated atomically.
  • Trusted devices ("Remember this browser for 30 days") — optional checkbox on the verify step. The cookie is stored only as a SHA-256 hash (a leaked DB cannot replay it) with HttpOnly; SameSite=Strict — and Secure when the request came in over HTTPS. List + revoke per-device or revoke-all from the Security card. Wiped on password change, 2FA deactivate, and admin reset.
  • Forced 2FA for admin role — flip Config → enforce_mfa_for_admins to true. Admin users without 2FA hit a one-shot enrolment branch on next login and exchange the enrolment token for a real session in the same response.
  • Verify rate limit — 5 wrong codes from the same (IP, username) within 15 minutes installs a 15-minute lockout (HTTP 429 with retry_after_seconds). Successful verification clears the counter.
  • Lockout recovery — if a user loses both their authenticator AND their backup codes, an admin clicks Reset 2FA next to the user in the Users tab. The reset wipes the secret, backup codes, and trusted devices, revokes existing sessions, and is audited.

API Authentication

Three authentication modes coexist:

  • Anonymous — works on endpoints whose visibility is set to public in the API tab. This is the default for the data-read endpoints listed below.
  • API key — pass X-API-Key: aas_<hex> on every request. Mint keys in the admin API tab; each key is tied to a user account and the full key is shown exactly once at creation. Revoke disables the key immediately; delete removes the row entirely.
  • Admin session — the Authorization: Bearer <token> header issued by POST /api/admin/login. Required for everything under /api/admin/ and accepted as a fallback on restricted public endpoints so logged-in operators don't need a personal key to browse them.

The API tab lets an admin flip any data-read endpoint between public and restricted. Restricted endpoints reject anonymous requests with 401 {"error":"API key required"}; an API key or an admin Bearer token gets through.

API Endpoints — read

Default visibility is public; flip in the API tab to require auth.

  • GET /api/geojson — live drone positions as GeoJSON
  • GET /api/stats — system statistics
  • GET /api/history — flight history summary (includes source per row: local or the username that ingested it)
  • GET /api/history/<flight_id> — single flight with all its points
  • GET /api/live_tracks — active track points for the live trail overlay
  • GET /api/tracks — full in-memory track dump (debug)
  • GET /api/sdr_locations — registered SDR receiver positions + connection state
  • GET /api/scan_channels — scan plan, read-only
  • GET /api/public_config — map tile source, alert settings, git version
  • GET /api/translations?lang=<code> — UI translation dictionary for the given locale
  • GET /api/lists — whitelist / blacklist entries

API Endpoints — ingest

  • POST /api/remote_id — ingest Remote ID frames from an external sniffer (BLE / Wi-Fi NAN / BT5 LR). Body: {"messages":["<hex>",...],"source":"ble"|"wifi-beacon"|...}.
  • POST /api/ingest/flightalways requires X-API-Key; an admin Bearer token is not accepted here, because the flights.source column is set from the key's owning username. Used by remote detectors to push live updates and historical bulk dumps into the same DB.

Flight Ingestion Format

Body shape (all numeric fields tolerate strings):

{
  "flight_id": "abc-123",            // optional; auto-generated if omitted
  "serial":    "DJI-XXXX-001",       // required, 1..128 chars
  "model":     "Mavic 3",            // optional
  "first_seen": 1730000000,          // optional unix-seconds; falls back to min(point.ts)
  "last_seen":  1730000600,          // optional unix-seconds; falls back to max(point.ts)
  "points": [
    {"ts":1730000000,"lat":56.95,"lon":24.10,"alt":120,
     "height":100,"speed":12.3,"heading":45,"v_up":0.5,"yaw":50,
     "freq_mhz":2437,"corr":0.85,
     "pilot_lat":56.94,"pilot_lon":24.09,
     "home_lat":56.94,"home_lon":24.09}
  ]
}

Per-point required fields: ts, lat, lon. Everything else is optional. ts must be a unix-seconds value between 2000-01-01 and tomorrow — millisecond timestamps are rejected so a misconfigured client can't pollute the index.

Live vs. Historical

Both modes use the same endpoint. The server upserts the flight row and de-duplicates points by (flight_id, ts), so:

  • Live — call once per tick with one or a few fresh points. Repeating the call is harmless: first_seen stays the minimum, last_seen stays the maximum, max_alt / max_height only grow, and points already present are skipped.
  • Historical — call once per past flight with up to 5000 points. Re-running the same dump produces zero inserts on the second call.

Successful responses report both received_points (in the body you sent) and inserted_points (after dedup), so a client can confirm what actually landed.

Ingestion Limits & Rules

  • Body size — 4 MiB max per request. Larger historical dumps must be chunked.
  • Points per call — 5000 max. Calls exceeding this are rejected wholesale (no partial insert).
  • All-or-nothing validation — every point is validated before any row is written. A single bad ts / lat / lon rejects the whole call with 400; the DB is never left in a partial state.
  • Source attributionflights.source is set on first insert from the API key's owning username and is never rewritten. Re-posting an existing flight_id from a different source returns 400 "flight_id owned by a different source".
  • flight_id namespacing — caller-supplied ids are auto-prefixed ext:<source>:<id> so they cannot collide with locally-generated <serial>_<ts> ids. Omit flight_id entirely and the server derives a deterministic one from (source, serial, first_seen), which keeps repeat calls idempotent.
  • Audit trail — every successful ingest writes a flight_ingest row to the audit log with the API-key owner as the actor and <inserted>/<received> in the detail column.
  • VisibilityPOST /api/ingest/flight is not listed in the API tab and cannot be flipped to public; the X-API-Key requirement is hard-coded.

Error Responses

  • 400 — malformed JSON, missing required field, point out of range, body too large, or flight_id owned by another source. Body: {"error":"<reason>"}.
  • 401 — missing/expired/revoked API key on a restricted or ingest endpoint.
  • 403 — admin-only endpoint hit with a non-admin session.
  • 404 — unknown path, or /api/history/<flight_id> for a non-existent id.

Other Endpoints

  • GET /health — liveness probe (always anonymous).
  • GET /api/whoami — admin token probe; always returns 200 with {authenticated: bool, ...}. Not gated by the ACL.
  • /api/admin/* — admin-only management endpoints (config, users, API keys, endpoint visibility, audit log, SDR devices, scan channels, translations). Not flippable in the API tab.