release: Mark VIII 1.0
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,243 @@
|
||||
# Pager WebUI — Design Spec (v1)
|
||||
|
||||
- **Date:** 2026-08-10
|
||||
- **Status:** Approved (pending written-spec review)
|
||||
- **Owner:** Hak5 WiFi Pineapple Pager expansion project
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Build a Mark VII-style web management UI that runs **on the WiFi Pineapple Pager**
|
||||
itself, with core-parity featureset (Dashboard, PineAP, Recon, Handshakes/Loot,
|
||||
Payloads, Logs, Settings, and a bottom-docked terminal), packaged as a
|
||||
Payload-Portal-installable payload and deployed over SSH/SCP.
|
||||
|
||||
The UI is reached at `http://172.16.52.1:8080/` (verified free on the device).
|
||||
|
||||
## 2. Verified Research Basis (live devices)
|
||||
|
||||
### Pager (172.16.52.1)
|
||||
- MediaTek MT76x8 SoC, OpenWRT-based `Pineapple Pager 24.10.1`
|
||||
(`ramips/mt76x8`, `mipsel_24kc`, Linux 6.6.86).
|
||||
- Full root SSH (`root` + device password). `opkg` available.
|
||||
- Persistent 4GB MMC: `/root` -> `/mmc/root`; payloads live under
|
||||
`/root/payloads/`, loot under `/root/loot/`.
|
||||
- Tools present: `python3` (3.11.14), `sqlite3`, `iwinfo`, `hostapd_cli`,
|
||||
`ubus`, `uci`, `curl`, `nc`, `wget`.
|
||||
- Firmware is U-Boot **signed** — firmware modification is out of scope; no
|
||||
stock files are touched.
|
||||
|
||||
### Hak5 daemon (`/pineapple/pineapple`, Go)
|
||||
- HTTP API on `:1471` and an unauthenticated Unix socket `/tmp/api.sock`
|
||||
serving the same router.
|
||||
- Auth: `POST /api/login` (`{"username":"root","password":...}`) returns
|
||||
`{"token": ...}`. Requests authorize with
|
||||
`Authorization: Bearer <token>` or the `AUTH_<serverid>` cookie
|
||||
(serverid from `/api/api_ping`, e.g. `001337AEE050`).
|
||||
- Existing API used by the stock Virtual Pager:
|
||||
`api_ping`, `login`, `payloads/portal/{index,refresh,updates,<key>/install,<key>/remove}`,
|
||||
`loot/archive`, `loot/zip`, `files/zip/root/loot/handshakes`,
|
||||
`terminal/openWs` (WS), `pager/input/keys.ws` (WS), `pager/display/screen.ws` (WS).
|
||||
- Terminal WS `/api/terminal/openWs`: plain-text WebSocket (keystrokes in,
|
||||
output out). Requires the `AUTH_*` cookie (verified 401 without, 101 with).
|
||||
Accepts cross-origin handshakes. Cookies are host-scoped and **port-agnostic**,
|
||||
and the `:8080` page and `:1471` host are the same site, so a cookie set for
|
||||
host `172.16.52.1` is sent on the WS handshake from the `:8080` page. The
|
||||
docked terminal therefore connects **directly** to the daemon WS; a
|
||||
server-side relay is the documented fallback.
|
||||
|
||||
### Control channel (payload interface)
|
||||
- `/usr/bin/hak5cmd` (C++, protobuf client to the daemon). 49 symlinks provide
|
||||
every PineAP operation, e.g.:
|
||||
`PINEAPPLE_SSID_POOL_{ADD,ADD_FILE,START,STOP,LIST,DELETE,CLEAR,COLLECT_START,COLLECT_STOP}`,
|
||||
`PINEAPPLE_NETWORK_FILTER_{MODE,ADD,ADD_FILE,DELETE,LIST,CLEAR}`,
|
||||
`PINEAPPLE_DEVICE_FILTER_{MODE,ADD,ADD_FILE,DELETE,LIST,CLEAR}`,
|
||||
`PINEAPPLE_MIMIC_{ENABLE,DISABLE}`,
|
||||
`PINEAPPLE_DEAUTH_CLIENT`, `PINEAPPLE_EXAMINE_{BSSID,CHANNEL,RESET}`,
|
||||
`PINEAPPLE_HOPPING_{START,STOP}`, `PINEAPPLE_SET_BANDS`,
|
||||
`PINEAPPLE_RECON_NEW`, `PINEAPPLE_LOOT_ARCHIVE`.
|
||||
- Verified: commands run from CLI over SSH (read-only ones confirmed).
|
||||
|
||||
### Recon data
|
||||
- SQLite `/root/recon/recon.db`: tables `scan`, `wifi_device`, `ssid`,
|
||||
`handshake`, `hostap_chalresp`, `hostap_basic`, `hostap_client`,
|
||||
`hostap_handshake`.
|
||||
|
||||
### PineAP config
|
||||
- UCI file `/etc/config/pineapd` (sections: `pineapd`, `hostapd`,
|
||||
`ssidpool`, `ssid_filter`, `mac_filter`, `interface wlan0mon/wlan1mon/wlan2mon`).
|
||||
- Reload trigger exists (`/etc/init.d/pineapd reload`, `pineap_reload`).
|
||||
|
||||
### Precedents (payloads already on device)
|
||||
- `nautilus` (`user/remote_access/nautilus`): web UI payload with init script
|
||||
(`nautilus.init`, START=99, procd), python3 proxy, foreground/background run
|
||||
modes, `PAYLOAD_GET_CONFIG` persistence.
|
||||
- `virtual_pager_enhancer`: uhttpd+CGI on port 4040 (init script pattern).
|
||||
|
||||
### Mark VII blueprint
|
||||
- 113 `/api/*` endpoints extracted from its Angular bundle:
|
||||
dashboard, `pineap/*` (clients, kick, deauth, filters, handshakes, settings,
|
||||
ssids, summary), `recon/*` (scans, start/stop/status, tags), logging, settings/
|
||||
networking, modules, device, terminal (xterm.js bottom-docked panel).
|
||||
|
||||
## 3. Repo Layout
|
||||
|
||||
Dev workspace: `C:\Users\root\Documents\Pineapple\pager-webui\`
|
||||
|
||||
```
|
||||
pager-webui\
|
||||
├── payload\user\general\pager-webui\
|
||||
│ ├── _hak5_manifest.json
|
||||
│ ├── payload.sh # run-mode installer (nautilus pattern)
|
||||
│ ├── pagerwebui.init # OpenWRT init template (START=99, procd, respawn)
|
||||
│ ├── server.py # Python3 stdlib HTTP+WS+JSON API backend (:8080)
|
||||
│ └── www\ # vanilla JS SPA + xterm.js bundle
|
||||
├── scripts\
|
||||
│ ├── deploy.ps1 # build portal zip + SCP/install to Pager
|
||||
│ └── dev.ps1 # local dev server w/ proxy to Pager
|
||||
├── docs\specs\ # design specs
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 4. Packaging & Deployment
|
||||
|
||||
- **Payload format** mirrors `nautilus` exactly: self-contained directory
|
||||
`user/general/pager-webui/` with `payload.sh`, `pagerwebui.init`, `server.py`,
|
||||
`www/`.
|
||||
- **Sideload (v1 delivery):** `scripts/deploy.ps1` builds a portal-format zip
|
||||
(`payload-<b64>.zip`) and installs it to
|
||||
`/root/payloads/user/general/pager-webui/` on the Pager over SCP. The payload
|
||||
then appears in the on-device Payloads menu and the Virtual Pager's portal
|
||||
view — installable like any Payload Portal payload.
|
||||
- **Future portal distribution:** packaging is drop-in ready for a PR to
|
||||
`hak5/wifipineapplepager-payloads`; the build script generates the manifest
|
||||
`time` / `last_hash` / `zip` fields.
|
||||
|
||||
### `payload.sh` run flow (nautilus pattern)
|
||||
|
||||
1. If `/etc/init.d/pagerwebui` exists and `running`:
|
||||
show URL `http://172.16.52.1:8080/`, offer
|
||||
`CONFIRMATION_DIALOG "Stop service?"`; on confirm -> `stop`, `disable`,
|
||||
remove init script; exit.
|
||||
2. Read `PAYLOAD_GET_CONFIG pager_webui auto_mode` / `run_mode`. If
|
||||
`auto_mode=true`, skip prompt (background if `run_mode=background`, else
|
||||
foreground).
|
||||
3. Else `CONFIRMATION_DIALOG "Run as background service?"`:
|
||||
- **Yes -> background:** `cp pagerwebui.init /etc/init.d/pagerwebui`,
|
||||
`chmod +x`, `enable`, `start` (procd runs `python3 server.py`, respawns on
|
||||
crash). Boot-persistent via rc.d symlinks.
|
||||
- **No -> foreground:** spawn `python3 server.py` with
|
||||
`/tmp/pagerwebui.pid`, show URL, loop `WAIT_FOR_INPUT` until B/Escape,
|
||||
`trap cleanup` kills the server and removes the pid file.
|
||||
4. Guard: python3 is present on current firmware; init script fails gracefully
|
||||
if missing.
|
||||
|
||||
- **Uninstall:** the stop path above, plus deleting the payload directory
|
||||
(portal semantics).
|
||||
- **Boot persistence:** rc.d symlinks (overlay). Survives reboots and firmware
|
||||
upgrades; after a firmware upgrade (which wipes overlay) re-run the payload to
|
||||
re-enable — same caveat as nautilus. Foreground mode needs no persistence.
|
||||
|
||||
## 5. Backend (`server.py`)
|
||||
|
||||
Python 3.11 stdlib only (`http.server`, `sqlite3`, `subprocess`, `json`,
|
||||
`socketserver`, hand-rolled minimal RFC6455 for the live-update WS). Binds
|
||||
`0.0.0.0:8080`. Single origin serves the SPA and the API.
|
||||
|
||||
| Endpoint | Method | Implementation |
|
||||
|---|---|---|
|
||||
| `/api/login` | POST | validate via daemon `:1471/api/login`; on success return token and `Set-Cookie: AUTH_<serverid>` (host `172.16.52.1`, no `Domain`, `Path=/`, `HttpOnly`, `SameSite=Lax`) so docked terminal + loot downloads on `:1471` are authorized cross-port |
|
||||
| `/api/api_ping` | GET | serverid + version (mirror daemon) |
|
||||
| `/api/status` | GET | battery/power via sysfs, WiFi/interfaces via `iwinfo` + `ip`, firmware/daemon versions, disk via `df`, uptime |
|
||||
| `/api/pineap/settings` | GET/POST | read/write UCI `/etc/config/pineapd` (mimic, collect probes, advertise, collect handshakes, random MAC, wigle, bands) + `pineap_reload` |
|
||||
| `/api/pineap/ssids` | GET/POST | SSID pool read/write via `hak5cmd` |
|
||||
| `/api/pineap/ssidpool/{start,stop,collect_start,collect_stop}` | POST | via `hak5cmd` |
|
||||
| `/api/pineap/filters/client` | GET/POST | device filter mode + list via `hak5cmd` |
|
||||
| `/api/pineap/filters/ssid` | GET/POST | network filter mode + list via `hak5cmd` |
|
||||
| `/api/pineap/clients` | GET | associated clients via `iwinfo assoclist` / `hostapd_cli` on `wlan0open`/`wlan0wpa`/`wlan0mgmt` |
|
||||
| `/api/pineap/clients/kick` | POST | deauth via `hak5cmd` + auto-add to deny filter (Mark VII behavior) |
|
||||
| `/api/pineap/deauth/client` | POST | `PINEAPPLE_DEAUTH_CLIENT` |
|
||||
| `/api/recon/{start,stop}` | POST | `hak5cmd` `RECON_NEW` / stop |
|
||||
| `/api/recon/scans` | GET | scans list from `recon.db` |
|
||||
| `/api/recon/scans/<id>` | GET | APs/clients/handshakes for a scan from `recon.db` |
|
||||
| `/api/pineap/handshakes` | GET/DELETE | list/delete `/root/loot/handshakes` |
|
||||
| `/api/loot/zip` | GET | zip download (wraps daemon `:1471` with cookie) |
|
||||
| `/api/loot/archive` | POST | wraps daemon `:1471` |
|
||||
| `/api/payloads/index`, `/api/payloads/install`, `/api/payloads/remove`, `/api/payloads/refresh` | GET/POST | wrap daemon portal endpoints |
|
||||
| `/api/logging/system` | GET | `logread` (filtered, tail) |
|
||||
| `/api/logging/pineap` | GET | pineapd/daemon log sources |
|
||||
| `/api/device` | GET | hostname, MACs, model |
|
||||
| `/api/settings/hostname` | GET/POST | `uci` network hostname |
|
||||
| `/api/settings/password` | POST | change the device root password via BusyBox `passwd` stdin |
|
||||
| `/api/settings/ntp` | GET/POST | UCI `system` `timeserver` (enabled + servers) + restart `sysntpd` |
|
||||
| `/api/ws` | WS | live push: status/clients/recon deltas every ~2s |
|
||||
| `/api/terminal/openWs` | WS | same path on our origin; client connects direct to daemon `ws://172.16.52.1:1471/api/terminal/openWs` (cookie-authed). Fallback: server relays |
|
||||
|
||||
### Auth model
|
||||
- All endpoints (except `/api/login`) require a valid session cookie set by
|
||||
`/api/login`.
|
||||
- Token is never logged or stored server-side beyond the session check; the
|
||||
backend validates each request by checking the session cookie against the
|
||||
daemon's token (a lightweight session store in `/tmp/pagerwebui.session`).
|
||||
- Commands are executed with argument lists (no shell string interpolation) to
|
||||
prevent injection.
|
||||
|
||||
## 6. Frontend (vanilla JS SPA)
|
||||
|
||||
- Static files in `www/`: `index.html`, `css/app.css`, `js/app.js` (or split
|
||||
modules), `js/xterm.js` + `js/xterm-fit.js` (bundled copies), `assets/`.
|
||||
- Mark VII-style chrome: dark theme, top bar (logo, live status, **Terminal
|
||||
button**), nav rail, hash-based routing (`#/dashboard`, `#/pineap`,
|
||||
`#/recon`, `#/handshakes`, `#/payloads`, `#/logs`, `#/settings`).
|
||||
- **Dashboard:** status cards + live counters (battery, clients, APs,
|
||||
handshakes) fed by `/api/ws`.
|
||||
- **PineAP:** settings toggles (mimic/advertise/collect/handshakes/random MAC/
|
||||
wigle/bands), SSID pool CRUD + collect/start/stop, client & SSID filters
|
||||
(mode + list CRUD), client list with kick.
|
||||
- **Recon:** scan list, AP/client tables from `recon.db`, start/stop buttons.
|
||||
- **Handshakes/Loot:** list, download zip, delete, archive.
|
||||
- **Payloads:** portal list + install/remove/refresh.
|
||||
- **Logs:** system + pineap logs with tail/poll.
|
||||
- **Settings:** hostname, NTP, password, webUI prefs (poll interval, accent),
|
||||
service status (background/foreground).
|
||||
- **Terminal:** bottom-docked xterm panel toggled by the top-bar button (Mark
|
||||
VII parity). Connects `ws://172.16.52.1:1471/api/terminal/openWs`. Default
|
||||
80x24; resize behavior validated during implementation (daemon may ignore
|
||||
resize; fallback fixed size with xterm `fit` disabled).
|
||||
|
||||
## 7. Security & Resilience
|
||||
|
||||
- Auth: device password validated through the daemon; HttpOnly session cookie;
|
||||
no plaintext secret storage; state-changing endpoints all behind login.
|
||||
- No stock files modified; no `opkg` changes; reversible via `payload.sh`
|
||||
stop/uninstall; factory reset / firmware recovery remain available.
|
||||
- `server.py` failure modes: procd respawn in background mode; foreground mode
|
||||
cleans up on exit; `recon.db` opened read-only; subprocesses use arg lists.
|
||||
- Binds `0.0.0.0:8080` (same exposure class as stock `:1471` / `:7681`).
|
||||
|
||||
## 8. Testing & Verification
|
||||
|
||||
- Windows dev loop: `scripts/dev.ps1` runs the SPA + API locally and proxies to
|
||||
the Pager.
|
||||
- PowerShell-driven API tests against `:8080`: login, status, each read/write
|
||||
endpoint before frontend wiring.
|
||||
- On-device smoke tests per page: status, pool CRUD, filter toggles, recon
|
||||
start/scan read, handshake listing, portal install/remove, terminal I/O,
|
||||
background vs foreground modes, reboot persistence.
|
||||
- Recovery drill: uninstall, re-install, foreground-stop, firmware-upgrade
|
||||
caveat documented in README.
|
||||
|
||||
## 9. Out of Scope (v1)
|
||||
|
||||
- `:1471` takeover/redirect.
|
||||
- Mark VII features without a Pager equivalent: Campaigns, Modules, Cloud C²,
|
||||
Enterprise/EAP pages.
|
||||
- Physical-display screen mirror.
|
||||
- Publishing a PR to the official payloads repo (packaging ready; submission
|
||||
later).
|
||||
|
||||
## 10. Open Risks (mitigated during implementation)
|
||||
|
||||
- Client-list source validation: `iwinfo assoclist` / `hostapd_cli` against the
|
||||
Pager's hostapd interfaces (`wlan0open`, `wlan0wpa`, `wlan0mgmt`).
|
||||
- Minimal RFC6455 WebSocket server correctness.
|
||||
- Daemon terminal resize support (fallback to fixed 80x24).
|
||||
@@ -0,0 +1,176 @@
|
||||
# Pineapple UI Clone — Design Spec
|
||||
|
||||
- **Date:** 2026-08-11
|
||||
- **Status:** Approved (pending written-spec review)
|
||||
- **Owner:** Hak5 WiFi Pineapple Pager expansion project
|
||||
- **Supercedes look of:** `www/` assets shipped in `2026-08-10-pager-webui-design.md`
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Restyle the Pager WebUI (`http://172.16.52.1:8080/`) to be a faithful clone of
|
||||
the stock Hak5 WiFi Pineapple UI (`http://172.16.42.1:1471/`), so an operator
|
||||
used to one interface can use the other without re-learning navigation,
|
||||
terminology, or interaction patterns.
|
||||
|
||||
Scope is **cosmetic + navigation only**, plus one small read-only backend
|
||||
endpoint (`GET /api/pineap/aps`, §3.5). Auth/session mechanics and the payload
|
||||
packaging pipeline are unchanged.
|
||||
|
||||
Verified on-device (2026-08-11): the Pager's Go daemon on `:1471` exposes only
|
||||
`api_ping`, `login`, payload-portal, loot, and WS endpoints — it has **no**
|
||||
`/api/pineap/*` surface. All PineAP/Recon/Logging/Settings endpoints in the
|
||||
current `server.py` are implemented by pager-webui itself via `hak5cmd`,
|
||||
`uci`, `iwinfo`, and `recon.db`. (Note: the old UI at `172.16.42.1:1471` and
|
||||
the new Pager at `172.16.52.1` are separate devices.)
|
||||
|
||||
## 2. Research Basis (live UIs, captured 2026-08-11)
|
||||
|
||||
### Old UI (:1471) — stock Angular 8 / Angular Material SPA
|
||||
- Hash routes: `#/Login`, `#/Setup`, `#/Dashboard`, `#/Campaigns`
|
||||
(`/reports`), `#/Recon` (`/handshakes`), `#/PineAP` (`/open`, `/clients`,
|
||||
`/filtering`, `/enterprise`, `/aps`, `/impersonation`), `#/Settings`
|
||||
(`/networking`, `/wifi`, `/developer`, `/advanced`, `/led`, `/help`),
|
||||
`#/Logging` (`/system`), `#/Modules`.
|
||||
- **Login:** full-screen flat gray `#9c9c9c`; centered white elevated card
|
||||
(`mat-elevation-z20`): "WiFi Pineapple" `h2`, pineapple logo PNG (148px),
|
||||
Username + Password fields, raised "Login" button (spinner while busy),
|
||||
inline red error text; Cloud C2 error variant.
|
||||
- **Shell:** 64px toolbar (`#424242`, Material `mat-toolbar`) with logo + "WiFi
|
||||
Pineapple"; content offset `margin-left: 90px`; icon rail sidenav
|
||||
(`min-width:60px`, `#f3f3f3`; dark `#3a3a3a`, hover `#a9a9a9` / dark
|
||||
`#545454`): **Dashboard, Campaigns, PineAP ("PineAP Suite"), Recon, Logging,
|
||||
Modules ("Modules & Packages"), Settings**; active entry `border-right:3px
|
||||
solid #1976d2`; entry dividers; bottom "Open Menu" chevron toggles expanded
|
||||
rail; module entries are drag-reorderable (out of scope). Content background
|
||||
`#fafafa`; Roboto/Helvetica/sans-serif font stack.
|
||||
- **Views use horizontal `mat-tab` navigation with a blue ink bar** for each
|
||||
multi-page section (PineAP, Recon, Logging, Settings, Campaigns).
|
||||
- **Dashboard:** status cards (Clients, Handshakes Captured, Disk Usage, …) +
|
||||
Chart.js line chart (clients over time) + "Connected Clients" table
|
||||
(Deauthenticate) + "Captured WPA Handshakes" table.
|
||||
- **Extras:** keyboard shortcuts (`D`/`C`/`R`/`P`/`M`, Backquote = terminal),
|
||||
light/dark theme (stored in `localStorage`), notification center, corner
|
||||
"flash indicator" toasts, ASCII pineapple `(='.'=)`.
|
||||
- **Auth:** `POST /api/login` `{username, password}` → `{token}`; stored in
|
||||
`localStorage` `<base>_authToken` + `AUTH_<ServerId>` cookie; device password
|
||||
for user `root`.
|
||||
|
||||
### New UI (:8080) — vanilla JS SPA (current source of truth)
|
||||
- GitHub-dark theme (`#0d1117` bg, `#161b22` panels, teal `#00d4aa` accent,
|
||||
Segoe UI); password-only login; flat text rail (Dashboard, PineAP, Recon,
|
||||
Handshakes, Payloads, Logs, Settings); single-page sections; bottom-docked
|
||||
xterm panel; WS live updates + 5s polling fallback; bottom-right toasts.
|
||||
- Backend: pure-socket HTTP/JSON/WS on `0.0.0.0:8080` (device
|
||||
`python3-light`); auth via `AUTH_<serverid>` HttpOnly cookie validated
|
||||
against the daemon; terminal connects directly to daemon WS on `:1471`.
|
||||
|
||||
### Feature mapping (old IA → current Pager capabilities)
|
||||
| Old tab | Clone behaviour |
|
||||
|---|---|
|
||||
| Dashboard | status cards + Chart.js chart + Connected Clients + Handshakes tables |
|
||||
| Campaigns | visible tab, single "not supported on the Pager" empty-state card |
|
||||
| PineAP → Open | PineAP settings toggles (mimic/advertise/probes/handshakes/random MAC/WiGLE) + bands |
|
||||
| PineAP → Clients | connected clients + kick |
|
||||
| PineAP → Filtering | client (MAC) + SSID allow/deny/off filters |
|
||||
| PineAP → APs | **new read-only endpoint** `GET /api/pineap/aps`: `iwinfo <mon-iface> scan` table (BSSID/SSID/Channel/Signal/Encryption) |
|
||||
| PineAP → Impersonation | SSID pool add/clear + Start/Stop/Collect |
|
||||
| PineAP → Enterprise | **omitted** (not supported on Pager) |
|
||||
| Recon → Overview | scans list + new/stop/refresh + scan detail |
|
||||
| Recon → Handshakes | loot files: download zip / archive / delete |
|
||||
| Logging → Overview | system + PineAP logs |
|
||||
| Logging → System | system log with level filter (Error/Warning/Informational) |
|
||||
| Modules & Packages | payload portal (search + list + Install/Remove) |
|
||||
| Settings | general info, hostname, NTP, password, WebUI prefs (poll interval, theme) |
|
||||
|
||||
## 3. Design
|
||||
|
||||
### 3.1 Architecture
|
||||
No build step; same vanilla-JS file layout. New/vendored files under `www/`:
|
||||
|
||||
- `assets/logo.png` + favicon — copied from old UI `assets/icons/logo.png`.
|
||||
- `js/chart.min.js` — vendored Chart.js from old UI (no CDN on device).
|
||||
- `js/icons.js` — inline SVG icon set lifted from the old Angular bundle
|
||||
(dashboard grid, campaigns, pineap, recon, logging, modules, settings,
|
||||
chevron).
|
||||
- `js/themes.css` or CSS custom properties — light/dark token sets.
|
||||
- `css/app.css` — rewritten around Material light tokens.
|
||||
- `js/views.js` — restructured to old IA with sub-views + `mat-tab`-style bars.
|
||||
- `js/app.js` — routing (incl. sub-routes), keyboard shortcuts, theme toggle,
|
||||
terminal wiring, flash-style toasts.
|
||||
- `index.html` — old-style shell markup (login card, toolbar, icon rail,
|
||||
content, terminal panel).
|
||||
|
||||
Roboto: vendor `.woff2` from the device if present (`:1471/assets/` fonts);
|
||||
otherwise the existing `Roboto, Helvetica Neue, sans-serif` stack falls back to
|
||||
system fonts.
|
||||
|
||||
### 3.2 Design tokens (light default, dark optional)
|
||||
- Light: content `#fafafa`; cards `#fff` with subtle elevation/shadow; toolbar
|
||||
+ rail `#424242`; rail hover `#a9a9a9`; active border `#1976d2`; primary
|
||||
`#1976d2` / `#1e88e5`; danger `#d32f2f`; ok `#7cb342`; warn `#f9a825`; text
|
||||
`#212121` / muted `#686868`; ink bar + focus `#1976d2`.
|
||||
- Dark (mirrors old theme): rail `#3a3a3a`, hover `#545454`, surfaces `#303030`,
|
||||
cards `#424242`.
|
||||
- Theme selected via `<html class="dark">` driven by `localStorage`; toggle in
|
||||
Settings (and honored on all pages).
|
||||
|
||||
### 3.3 Shell
|
||||
- **Toolbar (64px, `#424242`):** logo + "WiFi Pineapple" brand (left); right:
|
||||
muted live status "BAT % · CLIENTS n", Terminal button.
|
||||
- **Icon rail (60px):** 7 entries, 24px inline SVG + label (label hidden while
|
||||
collapsed, `title` tooltip shown), dividers between groups, active = 3px blue
|
||||
right border. Bottom "Open Menu" chevron expands to ~200px with labels;
|
||||
state persisted in `localStorage` (mirrors old `sideNavState`).
|
||||
- **Content (`#fafafa`):** page header + horizontal tab bar (where applicable)
|
||||
+ white cards; existing `.section`/`.tbl`/`.badge`/`.row`/`.toggle` classes
|
||||
restyled to the light theme.
|
||||
- **Terminal:** stays bottom-docked (new behaviour) but restyled to the old
|
||||
look; kept docked intentionally (matches current Pager UX and spec v1).
|
||||
- **Toasts:** corner "flash indicator" style (old look), same API.
|
||||
|
||||
### 3.4 Login
|
||||
Password-only (per user decision; username fixed `root`). Full-screen gray
|
||||
`#9c9c9c`; centered white elevated card: logo, "WiFi Pineapple" `h2`, single
|
||||
Password field (placeholder "Password"), raised primary Login button with busy
|
||||
spinner, inline red error text on failure. Posts to the existing `/api/login`
|
||||
endpoint via `PagerAPI.login('root', pw)` — **no backend change**.
|
||||
|
||||
### 3.5 Views (per mapping table)
|
||||
Each multi-page section renders a `mat-tab`-style horizontal bar (blue ink
|
||||
bar) with tab items; deep-linkable via hash routes `#/pineap/clients` etc.
|
||||
Placeholder Campaigns uses the old empty-state card.
|
||||
|
||||
**PineAP → APs** requires one new backend route. Add to `server.py`:
|
||||
`GET /api/pineap/aps` → runs `iwinfo <iface> scan` over the monitor interfaces
|
||||
listed by `/api/status` and returns rows `{bssid, ssid, channel, signal,
|
||||
encryption}` (read-only, no root action, same exposure class as the existing
|
||||
`/api/status`). A unit test covers the parser with a canned `iwinfo` capture.
|
||||
|
||||
### 3.6 Data flow
|
||||
Unchanged: `PagerAPI` (fetch wrapper), `Live` WS + 5s poll fallback, `Live.onTick`
|
||||
drives the dashboard chart's rolling series (e.g. last 60 samples of clients +
|
||||
handshakes). Theme and rail state live in `localStorage`. The single new route
|
||||
is `GET /api/pineap/aps` (§3.5).
|
||||
|
||||
### 3.7 Error handling
|
||||
Unchanged: failed API calls surface via toasts; login failure shows inline red
|
||||
text; WS down → poll fallback (existing logic untouched).
|
||||
|
||||
## 4. Testing
|
||||
|
||||
- Manual on-device smoke pass per spec v1 §8: every page in background and
|
||||
foreground modes, login/logout, terminal I/O, reboot persistence.
|
||||
- Visual parity checklist: login card, toolbar, rail expand/collapse + active
|
||||
indicator, each tab bar, table/card styling, dark theme.
|
||||
- Asset checks: `logo.png`, favicon, `chart.min.js`, Roboto (if vendored) all
|
||||
serve from `:8080`; verify with `curl`.
|
||||
- Existing Python `unittest` suite (API-level) must remain green; add a test for
|
||||
the new `iwinfo` scan parser.
|
||||
- Deploy via `scripts/deploy.ps1` (existing pipeline); payload zip must include
|
||||
the new/vendored assets.
|
||||
|
||||
## 5. Out of scope
|
||||
|
||||
- Campaigns functionality, Cloud C2, Enterprise SSIDs, LED/Network/Developer
|
||||
Settings sub-pages, module drag-reorder, notification center, `:1471`
|
||||
takeover.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,687 @@
|
||||
# Handshakes Mark VII Parity 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 WebUI Recon → Handshakes tab (`#/recon/handshakes`) look and behave like the stock Mark VII handshakes page (BSSID/Client/Source/Type/Captured/Message 1–4/Beacon Frame table, per-row Download & Delete, settings dialog with location + Delete All).
|
||||
|
||||
**Architecture:** `server.py` synthesizes the Mark VII `{handshakes:[...]}` shape from loot filenames + one scoped recon.db correlation read (zero DB reads when the loot dir is empty). The vanilla-JS `views.recon_handshakes` view is rebuilt to render the Mark VII table with check/X/? glyphs, a success/error flash, and a settings modal. New icons and CSS are added. Dashboard keeps reading the unchanged `files` array.
|
||||
|
||||
**Tech Stack:** Python 3.11 (Windows dev) / device `python3-light`; stdlib `unittest` with mocks; vanilla JS + hand-rolled `h()` DOM helpers.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **DB reads:** at most **1** `_db_rows` call per handshakes request, and only when the loot dir is non-empty. No polling added to the handshakes view.
|
||||
- **No new dependencies** — pure stdlib; reuse existing helpers `_db_rows`, `fmt_mac`, `hsType`, `iconBtn`, `btn`, `h`, `PagerAPI`, `App`.
|
||||
- **Dashboard unchanged:** `GET /api/pineap/handshakes` must keep returning a `files` array with the current shape (`{name,size,mtime}`).
|
||||
- **Router is first-match-wins:** the `location` GET route MUST be registered before `GET /api/pineap/handshakes/([^/]+)`.
|
||||
- Tests run per module in their own process (README): `& "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe" -m unittest tests.test_recon -v`.
|
||||
- Copy exact strings from the spec (Mark VII wording for tooltips/empty state, `pcap`→`PCAP`, `22000`→`Hashcat`).
|
||||
|
||||
---
|
||||
---
|
||||
|
||||
### Task 1: Backend — filename parser + `handshakes_data()` Mark VII shape
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/general/pager-webui/server.py` (`handshakes_data()` at ~line 1060; add `parse_hs_filename`, `_norm_mac`, `_hs_db_by_pair`, `_compose_hs` above it)
|
||||
- Test: `tests/test_recon.py` (add `HS_RE`/`parse_hs_filename`/`handshakes_data` tests)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `LOOT_HS_DIR` (module global, str), `RECON_DB` (module global, str path), `_db_rows(db, sql)` (existing), `os.listdir`/`os.stat`.
|
||||
- Produces:
|
||||
- `parse_hs_filename(name) -> dict | None` with keys `ts` (int|None), `ap` (colon MAC str), `client` (colon MAC str), `kind` (`'full'|'partial'|'incomplete'`), `ext` (str).
|
||||
- `_norm_mac(m) -> str` — uppercased, dash→colon, 12-hex→colon form.
|
||||
- `handshakes_data() -> {'files': [...], 'handshakes': [...]}` where each handshake record has keys `mac, client, source, type, timestamp, in_db, part_mask, beacon, extension, name, location, file_exists`.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Append to `tests/test_recon.py` (add `import shutil` to the module imports at the top if not already present):
|
||||
|
||||
```python
|
||||
def make_hs_db():
|
||||
db = make_db()
|
||||
conn = sqlite3.connect(db)
|
||||
conn.execute(
|
||||
"INSERT INTO handshake (hash, scan, stahash, aphash, time, beacon, hs1, hs2, hs3, hs4) "
|
||||
"VALUES (21, 1, 1, 2, 1786466650, X'BEACON', X'01', X'02', X'03', X'04')")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return db
|
||||
|
||||
|
||||
class ParseHsFilenameTest(unittest.TestCase):
|
||||
def test_parse_full_pcap(self):
|
||||
p = server.parse_hs_filename('1786466650_C8:9E:43:64:80:80_AE:77:C0:EB:31:41_handshake.pcap')
|
||||
self.assertEqual(p['ts'], 1786466650)
|
||||
self.assertEqual(p['ap'], 'C8:9E:43:64:80:80')
|
||||
self.assertEqual(p['client'], 'AE:77:C0:EB:31:41')
|
||||
self.assertEqual(p['kind'], 'full')
|
||||
self.assertEqual(p['ext'], 'pcap')
|
||||
|
||||
def test_parse_partial_and_incomplete(self):
|
||||
p = server.parse_hs_filename('1_C8-9E-43-64-80-80_AE-77-C0-EB-31-41_handshake_partial.22000')
|
||||
self.assertEqual(p['kind'], 'partial')
|
||||
self.assertEqual(p['ext'], '22000')
|
||||
p = server.parse_hs_filename('1_C8:9E:43:64:80:80_AE:77:C0:EB:31:41_handshake_incomplete.pcap')
|
||||
self.assertEqual(p['kind'], 'incomplete')
|
||||
self.assertEqual(p['ext'], 'pcap')
|
||||
|
||||
def test_parse_dash_macs_and_no_ts(self):
|
||||
p = server.parse_hs_filename('C8-9E-43-64-80-80_AE-77-C0-EB-31-41_handshake.pcap')
|
||||
self.assertIsNone(p['ts'])
|
||||
self.assertEqual(p['ap'], 'C8:9E:43:64:80:80')
|
||||
self.assertEqual(p['client'], 'AE:77:C0:EB:31:41')
|
||||
|
||||
def test_parse_unrecognized(self):
|
||||
self.assertIsNone(server.parse_hs_filename('random.cap'))
|
||||
self.assertIsNone(server.parse_hs_filename('notes.txt'))
|
||||
self.assertIsNone(server.parse_hs_filename(''))
|
||||
self.assertIsNone(server.parse_hs_filename('123_mac1_mac2_handshake'))
|
||||
|
||||
|
||||
class HandshakesDataTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.db = make_hs_db()
|
||||
server.RECON_DB = self.db
|
||||
self.dir = tempfile.mkdtemp()
|
||||
server.LOOT_HS_DIR = self.dir
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.dir)
|
||||
os.unlink(self.db)
|
||||
|
||||
def _write(self, name, ts):
|
||||
os.utime(os.path.join(self.dir, name), (ts, ts))
|
||||
|
||||
def test_empty_dir_skips_db(self):
|
||||
with mock.patch.object(server, '_db_rows', side_effect=AssertionError('db should not be touched')):
|
||||
data = server.handshakes_data()
|
||||
self.assertEqual(data, {'files': [], 'handshakes': []})
|
||||
|
||||
def test_correlation_composes_full_record(self):
|
||||
self._write('1786466650_C8-9E-43-64-80-80_AE-77-C0-EB-31-41_handshake.pcap', 1786466650)
|
||||
data = server.handshakes_data()
|
||||
self.assertEqual(len(data['files']), 1)
|
||||
hs = data['handshakes'][0]
|
||||
self.assertEqual(hs['mac'], 'C8:9E:43:64:80:80')
|
||||
self.assertEqual(hs['client'], 'AE:77:C0:EB:31:41')
|
||||
self.assertEqual(hs['source'], 'Recon')
|
||||
self.assertEqual(hs['type'], 'full')
|
||||
self.assertEqual(hs['extension'], 'pcap')
|
||||
self.assertEqual(hs['timestamp'], 1786466650)
|
||||
self.assertTrue(hs['in_db'])
|
||||
self.assertEqual(hs['part_mask'], 15)
|
||||
self.assertTrue(hs['beacon'])
|
||||
self.assertEqual(hs['name'], '1786466650_C8-9E-43-64-80-80_AE-77-C0-EB-31-41_handshake.pcap')
|
||||
self.assertTrue(hs['file_exists'])
|
||||
self.assertEqual(hs['location'], os.path.join(server.LOOT_HS_DIR, hs['name']))
|
||||
|
||||
def test_file_not_in_db_has_question_mark_fields(self):
|
||||
self._write('1786467000_AA-BB-CC-DD-EE-FF_00-11-22-33-44-55_handshake.pcap', 1786467000)
|
||||
hs = server.handshakes_data()['handshakes'][0]
|
||||
self.assertFalse(hs['in_db'])
|
||||
self.assertEqual(hs['part_mask'], 0)
|
||||
self.assertFalse(hs['beacon'])
|
||||
self.assertEqual(hs['timestamp'], 1786467000)
|
||||
|
||||
def test_unparseable_file_still_listed_with_placeholders(self):
|
||||
self._write('random.cap', 1786467005)
|
||||
hs = server.handshakes_data()['handshakes'][0]
|
||||
self.assertEqual(hs['mac'], '--')
|
||||
self.assertEqual(hs['client'], '--')
|
||||
self.assertFalse(hs['in_db'])
|
||||
self.assertEqual(hs['extension'], 'cap')
|
||||
```
|
||||
|
||||
`tests/test_recon.py` already imports `os, sqlite3, sys, tempfile, unittest, mock` and `import server`; add `import shutil` to the top-of-module imports. `re` is not needed.
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run:
|
||||
```powershell
|
||||
$py = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe"
|
||||
& $py -m unittest tests.test_recon -v
|
||||
```
|
||||
Expected: `AttributeError: module 'server' has no attribute 'parse_hs_filename'` (and failures on `handshakes_data` shape).
|
||||
|
||||
- [ ] **Step 3: Implement parser + data composition**
|
||||
|
||||
In `server.py`, replace the existing `handshakes_data()` (lines ~1060–1074) with:
|
||||
|
||||
```python
|
||||
HS_FILENAME_RE = re.compile(
|
||||
r'^(?:(\d+)_)?([0-9A-Fa-f]{2}(?:[:-][0-9A-Fa-f]{2}){5})_'
|
||||
r'([0-9A-Fa-f]{2}(?:[:-][0-9A-Fa-f]{2}){5})(?:_handshake)?'
|
||||
r'(?:_(full|partial|incomplete))?\.([A-Za-z0-9]+)$')
|
||||
|
||||
|
||||
def parse_hs_filename(name):
|
||||
m = HS_FILENAME_RE.match(name or '')
|
||||
if not m:
|
||||
return None
|
||||
ts, ap, client, kind, ext = m.groups()
|
||||
return {'ts': int(ts) if ts else None,
|
||||
'ap': ap.replace('-', ':'),
|
||||
'client': client.replace('-', ':'),
|
||||
'kind': kind or 'full',
|
||||
'ext': ext}
|
||||
|
||||
|
||||
def _norm_mac(m):
|
||||
m = (m or '').strip().upper().replace('-', ':')
|
||||
if len(m) == 12 and ':' not in m and all(c in '0123456789ABCDEF' for c in m):
|
||||
m = ':'.join(m[i:i + 2] for i in range(0, 12, 2))
|
||||
return m
|
||||
|
||||
|
||||
def _hs_db_by_pair(min_ts):
|
||||
rows = _db_rows(RECON_DB,
|
||||
'SELECT h.time, '
|
||||
'(h.hs1 IS NOT NULL AND length(h.hs1) > 0) AS m1, '
|
||||
'(h.hs2 IS NOT NULL AND length(h.hs2) > 0) AS m2, '
|
||||
'(h.hs3 IS NOT NULL AND length(h.hs3) > 0) AS m3, '
|
||||
'(h.hs4 IS NOT NULL AND length(h.hs4) > 0) AS m4, '
|
||||
'(h.beacon IS NOT NULL AND length(h.beacon) > 0) AS beacon, '
|
||||
'w1.mac AS ap, w2.mac AS sta '
|
||||
'FROM handshake h '
|
||||
'JOIN wifi_device w1 ON w1.hash = h.aphash '
|
||||
'JOIN wifi_device w2 ON w2.hash = h.stahash '
|
||||
'WHERE h.time >= %d ORDER BY h.time' % min_ts)
|
||||
db = {}
|
||||
for r in rows:
|
||||
db[(_norm_mac(r.get('ap')), _norm_mac(r.get('sta')))] = {
|
||||
'time': r.get('time'),
|
||||
'part_mask': (1 if r.get('m1') else 0) | (2 if r.get('m2') else 0)
|
||||
| (4 if r.get('m3') else 0) | (8 if r.get('m4') else 0),
|
||||
'beacon': bool(r.get('beacon')),
|
||||
}
|
||||
return db
|
||||
|
||||
|
||||
def _compose_hs(name, size, mtime, part, db):
|
||||
base = {'source': 'Recon', 'name': name, 'size': size,
|
||||
'location': os.path.join(LOOT_HS_DIR, name), 'file_exists': True}
|
||||
if part is None:
|
||||
ext = name.rsplit('.', 1)[-1] if '.' in name else ''
|
||||
base.update({'mac': '--', 'client': '--', 'type': 'full',
|
||||
'timestamp': mtime, 'in_db': False, 'part_mask': 0,
|
||||
'beacon': False, 'extension': ext})
|
||||
return base
|
||||
rec = db.get((_norm_mac(part['ap']), _norm_mac(part['client'])))
|
||||
base.update({
|
||||
'mac': part['ap'], 'client': part['client'], 'type': part['kind'],
|
||||
'timestamp': (rec or {}).get('time') or part['ts'] or mtime,
|
||||
'in_db': rec is not None,
|
||||
'part_mask': (rec or {}).get('part_mask', 0),
|
||||
'beacon': bool((rec or {}).get('beacon', False)),
|
||||
'extension': part['ext']})
|
||||
return base
|
||||
|
||||
|
||||
def handshakes_data():
|
||||
files = []
|
||||
parsed = []
|
||||
min_ts = None
|
||||
try:
|
||||
names = sorted(os.listdir(LOOT_HS_DIR))
|
||||
except OSError:
|
||||
names = []
|
||||
for name in names:
|
||||
p = os.path.join(LOOT_HS_DIR, name)
|
||||
try:
|
||||
if not os.path.isfile(p) or name.startswith('.'):
|
||||
continue
|
||||
st = os.stat(p)
|
||||
except OSError:
|
||||
continue
|
||||
mtime = int(st.st_mtime)
|
||||
files.append({'name': name, 'size': st.st_size, 'mtime': mtime})
|
||||
part = parse_hs_filename(name)
|
||||
if part is not None:
|
||||
ts = part['ts'] if part['ts'] is not None else mtime
|
||||
part['ts'] = ts
|
||||
if min_ts is None or ts < min_ts:
|
||||
min_ts = ts
|
||||
parsed.append((name, st.st_size, mtime, part))
|
||||
handshakes = []
|
||||
db = _hs_db_by_pair(min_ts) if parsed else {}
|
||||
for name, size, mtime, part in parsed:
|
||||
handshakes.append(_compose_hs(name, size, mtime, part, db))
|
||||
return {'files': files, 'handshakes': handshakes}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run:
|
||||
```powershell
|
||||
$py = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe"
|
||||
& $py -m unittest tests.test_recon -v
|
||||
```
|
||||
Expected: all tests pass, including the pre-existing `HandshakeFileTest` and `ReconDataTest` cases.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/test_recon.py payload/user/general/pager-webui/server.py
|
||||
git commit -m "feat: handshakes endpoint returns Mark VII records from loot files + recon.db"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Backend — `location` + `delete-all` routes
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/general/pager-webui/server.py` (add `h_handshakes_location`, `h_handshakes_delete_all`; reorder/add route registrations at ~lines 1567–1569)
|
||||
- Test: `tests/test_recon.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `LOOT_HS_DIR`, `handshakes_data()` (Task 1).
|
||||
- Produces:
|
||||
- `h_handshakes_location(ctx) -> (200, {'location': str})`
|
||||
- `h_handshakes_delete_all(ctx) -> (200, handshakes_data())`
|
||||
- Router registrations: `GET /api/pineap/handshakes/location` (BEFORE the `([^/]+)` file route), `DELETE /api/pineap/handshakes/all`.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Append to `tests/test_recon.py`:
|
||||
|
||||
```python
|
||||
class HandshakeRoutesTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.dir = tempfile.mkdtemp()
|
||||
server.LOOT_HS_DIR = self.dir
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.dir)
|
||||
|
||||
def _write(self, name, data=b'data'):
|
||||
with open(os.path.join(self.dir, name), 'wb') as f:
|
||||
f.write(data)
|
||||
|
||||
def test_location_returns_loot_dir(self):
|
||||
status, data = server.h_handshakes_location(type('C', (), {'args': ()})())
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(data['location'], self.dir)
|
||||
|
||||
def test_location_route_precedes_file_download(self):
|
||||
h, args = server.ROUTER.dispatch('GET', '/api/pineap/handshakes/location')
|
||||
self.assertIs(h, server.h_handshakes_location)
|
||||
|
||||
def test_delete_all_removes_files(self):
|
||||
self._write('1_C8-9E-43-64-80-80_AE-77-C0-EB-31-41_handshake.pcap')
|
||||
self._write('2_C8-9E-43-64-80-80_AE-77-C0-EB-31-41_handshake.22000')
|
||||
status, data = server.h_handshakes_delete_all(type('C', (), {'args': ()})())
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(data['files'], [])
|
||||
self.assertEqual(data['handshakes'], [])
|
||||
self.assertEqual(os.listdir(self.dir), [])
|
||||
|
||||
def test_delete_all_empty_dir_is_ok(self):
|
||||
status, data = server.h_handshakes_delete_all(type('C', (), {'args': ()})())
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(data['files'], [])
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run:
|
||||
```powershell
|
||||
$py = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe"
|
||||
& $py -m unittest tests.test_recon -v
|
||||
```
|
||||
Expected: `AttributeError: module 'server' has no attribute 'h_handshakes_location'` etc.
|
||||
|
||||
- [ ] **Step 3: Implement the handlers + routes**
|
||||
|
||||
In `server.py`, after `h_handshakes_delete` (line ~1100), add:
|
||||
|
||||
```python
|
||||
def h_handshakes_location(ctx):
|
||||
return 200, {'location': LOOT_HS_DIR}
|
||||
|
||||
|
||||
def h_handshakes_delete_all(ctx):
|
||||
try:
|
||||
names = os.listdir(LOOT_HS_DIR)
|
||||
except OSError:
|
||||
names = []
|
||||
for name in names:
|
||||
p = os.path.join(LOOT_HS_DIR, name)
|
||||
try:
|
||||
if os.path.isfile(p) and not name.startswith('.'):
|
||||
os.remove(p)
|
||||
except OSError:
|
||||
continue
|
||||
return 200, handshakes_data()
|
||||
```
|
||||
|
||||
Replace the route block (lines ~1567–1569) with:
|
||||
|
||||
```python
|
||||
ROUTER.add('GET', r'/api/pineap/handshakes/location', h_handshakes_location)
|
||||
ROUTER.add('DELETE', r'/api/pineap/handshakes/all', h_handshakes_delete_all)
|
||||
ROUTER.add('GET', r'/api/pineap/handshakes', h_handshakes_get)
|
||||
ROUTER.add('GET', r'/api/pineap/handshakes/([^/]+)', h_handshake_file)
|
||||
ROUTER.add('DELETE', r'/api/pineap/handshakes', h_handshakes_delete)
|
||||
```
|
||||
|
||||
(`location` MUST stay above the `([^/]+)` GET route — the Router is first-match-wins.)
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run:
|
||||
```powershell
|
||||
$py = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe"
|
||||
& $py -m unittest tests.test_recon -v
|
||||
```
|
||||
Expected: all pass.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/test_recon.py payload/user/general/pager-webui/server.py
|
||||
git commit -m "feat: handshakes location + delete-all endpoints"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Frontend — glyph icons + CSS
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/general/pager-webui/www/js/icons.js`
|
||||
- Modify: `payload/user/general/pager-webui/www/css/app.css`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `PineappleIcons.check`, `PineappleIcons.close`, `PineappleIcons.question_mark` (inline Material SVG strings); CSS classes `.hs-cell-center`, `.hs-ok`, `.hs-bad`, `.hs-na`, `.hs-actions`, `.hs-warn`, `.hs-flash`, `.hs-flash-ok`, `.hs-flash-error`, `.modal-overlay`, `.modal`, `.modal-title`, `.modal-body`, `.modal-actions`, `.hs-settings-row`, `.hs-settings-label`, `.hs-settings-value`.
|
||||
- Consumed by: Task 4 view.
|
||||
|
||||
- [ ] **Step 1: Add the three icons**
|
||||
|
||||
In `www/js/icons.js`, add inside the `window.PineappleIcons = { ... }` object (keep alphabetical-ish ordering; the file is a plain object):
|
||||
|
||||
```js
|
||||
check: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M9,16.17L4.83,12L3.41,13.41L9,19L21,7L19.59,5.59L9,16.17Z"/></svg>',
|
||||
close: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z"/></svg>',
|
||||
question_mark: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M11.07,12.85C11.07,12.85 12.23,12.5 13.03,11.64C13.83,10.79 13.85,9.61 13.24,8.63C12.5,7.58 11.3,7.63 10.67,7.85C10.17,8.03 9.9,8.36 9.63,8.84L8.4,8.12C8.77,7.42 9.23,6.82 9.98,6.39C11.09,5.78 12.58,5.66 13.85,6.53C15.12,7.41 15.83,8.85 15.42,10.13C15.04,11.31 14.05,11.96 13.03,12.45C12.44,12.73 12,13.06 12,13.86V14H11.07V12.85M11,16H12.93V18H11V16Z"/></svg>',
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add CSS**
|
||||
|
||||
Append to `www/css/app.css` (after the existing recon rules):
|
||||
|
||||
```css
|
||||
/* ---- Mark VII handshakes table + settings dialog ---- */
|
||||
.hs-cell-center { text-align: center; }
|
||||
.hs-ok, .hs-bad, .hs-na { display: inline-flex; vertical-align: middle; }
|
||||
.hs-ok svg, .hs-bad svg, .hs-na svg { width: 18px; height: 18px; }
|
||||
.hs-ok { color: #7cb342; }
|
||||
.hs-bad { color: #d32f2f; }
|
||||
.hs-na { color: #9e9e9e; }
|
||||
.hs-actions { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.hs-warn { color: #d32f2f; }
|
||||
.hs-flash { font-size: 12px; margin-left: 8px; }
|
||||
.hs-flash-ok { color: #7cb342; }
|
||||
.hs-flash-error { color: #d32f2f; }
|
||||
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.4); z-index: 100; display: flex; align-items: center; justify-content: center; }
|
||||
.modal { background: var(--surface); border: 1px solid var(--border); border-radius: 4px; min-width: 420px; max-width: 600px; box-shadow: 0 8px 24px rgba(0,0,0,.3); padding: 20px; }
|
||||
.modal-title { font-size: 20px; margin-bottom: 16px; }
|
||||
.modal-body { display: flex; flex-direction: column; gap: 12px; }
|
||||
.modal-actions { display: flex; justify-content: flex-end; margin-top: 16px; }
|
||||
.hs-settings-row { display: flex; justify-content: space-between; gap: 12px; font-size: 14px; }
|
||||
.hs-settings-label { color: var(--muted); }
|
||||
.hs-settings-value { font-family: Consolas, Menlo, monospace; word-break: break-all; }
|
||||
html.dark .modal { background: #303030; }
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify the file parses**
|
||||
|
||||
There is no node on the dev box and no JS test harness, so do a brace/paren balance sanity check with Python instead of a real parse. From the repo root, run:
|
||||
```powershell
|
||||
$py = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe"
|
||||
& $py -c "s=open(r'payload/user/general/pager-webui/www/js/icons.js',encoding='utf-8').read(); assert s.count('{')==s.count('}') and s.count('(')==s.count(')'), 'unbalanced'; print('icons.js balanced OK')"
|
||||
```
|
||||
Expected: prints `icons.js balanced OK`. (CSS has no build step — the Task 5 browser pass covers it, and the icons resolve in the browser console there.)
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add payload/user/general/pager-webui/www/js/icons.js payload/user/general/pager-webui/www/css/app.css
|
||||
git commit -m "feat: handshake glyph icons and modal/flash CSS"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Frontend — rebuild `views.recon_handshakes` + settings dialog
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/general/pager-webui/www/js/views.js` (replace `hsType`/`views.recon_handshakes`, lines ~904–952)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `RECON_TABS`, `h`, `tabBar`, `iconBtn`, `btn`, `fmtTime`, `hsType`, `PagerAPI`, `App.apiBase`, `App.toast`, `PineappleIcons.*` (all existing); backend endpoints from Tasks 1–2.
|
||||
- Produces: rewritten `views.recon_handshakes` (no signature change — `(root) => ({destroy})`).
|
||||
|
||||
- [ ] **Step 1: Replace `hsType` and `views.recon_handshakes`**
|
||||
|
||||
Replace the `hsType` helper (lines ~904–909) and the whole `views.recon_handshakes` definition (lines ~911–952) in `www/js/views.js` with:
|
||||
|
||||
```js
|
||||
views.recon_handshakes = (root) => {
|
||||
root.appendChild(h('h1', { class: 'page-title', text: 'Recon' }));
|
||||
tabBar(root, RECON_TABS, '#/recon/handshakes');
|
||||
const box = h('div', { class: 'section recon-handshakes-card' });
|
||||
root.appendChild(box);
|
||||
|
||||
let flashTimer = null;
|
||||
function flash(ok, msg) {
|
||||
const head = box.querySelector('.recon-table-head');
|
||||
if (!head) return;
|
||||
const old = head.querySelector('.hs-flash');
|
||||
if (old) old.remove();
|
||||
const el = h('span', { class: 'hs-flash ' + (ok ? 'hs-flash-ok' : 'hs-flash-error'), text: msg });
|
||||
head.appendChild(el);
|
||||
clearTimeout(flashTimer);
|
||||
flashTimer = setTimeout(() => el.remove(), ok ? 3000 : 5000);
|
||||
}
|
||||
|
||||
function hsIconBtn(name, title, cls, onclk) {
|
||||
const b = h('button', { class: 'icon-btn ' + (cls || ''), title: title, onclick: onclk });
|
||||
b.innerHTML = PineappleIcons[name] || '';
|
||||
return b;
|
||||
}
|
||||
|
||||
function textCell(v) {
|
||||
return h('td', { class: 'mat-cell', text: v == null || v === '' ? '--' : String(v) });
|
||||
}
|
||||
|
||||
function naGlyph() {
|
||||
return h('td', { class: 'mat-cell hs-cell-center' },
|
||||
h('span', { class: 'hs-na',
|
||||
title: "This information isn't available. This is common when a handshake file has been found, but the associated Recon scan has been lost or deleted." },
|
||||
PineappleIcons.question_mark));
|
||||
}
|
||||
|
||||
function boolGlyph(v) {
|
||||
return h('td', { class: 'mat-cell hs-cell-center' },
|
||||
h('span', { class: v ? 'hs-ok' : 'hs-bad' }, v ? PineappleIcons.check : PineappleIcons.close));
|
||||
}
|
||||
|
||||
function msgCell(inDb, present) {
|
||||
return inDb ? boolGlyph(present) : naGlyph();
|
||||
}
|
||||
|
||||
function load(done) {
|
||||
PagerAPI.get('/api/pineap/handshakes').then((r) => {
|
||||
box.innerHTML = '';
|
||||
const head = h('div', { class: 'recon-table-head' },
|
||||
h('h2', { text: 'Captured WPA Handshakes' }),
|
||||
h('span', { class: 'toolbar-spacer' }),
|
||||
iconBtn('settings', 'Handshakes settings', openSettings));
|
||||
box.appendChild(head);
|
||||
box.appendChild(h('div', { class: 'row' },
|
||||
h('div', {}, btn('Download all (zip)', () => { window.location = App.apiBase + '/api/loot/zip'; })),
|
||||
h('div', {}, btn('Archive', () => PagerAPI.post('/api/loot/archive').then(() => App.toast('Archived')))),
|
||||
h('div', {}, btn('Refresh', () => load(), 'ghost'))));
|
||||
|
||||
const hs = r.data.handshakes || [];
|
||||
if (!hs.length) {
|
||||
box.appendChild(h('div', { class: 'empty', text: 'No Handshakes Available' }));
|
||||
if (done) done();
|
||||
return;
|
||||
}
|
||||
const t = h('table', { class: 'tbl' });
|
||||
const thead = h('thead');
|
||||
const th = h('tr');
|
||||
['BSSID', 'Client', 'Source', 'Type', 'Captured', 'Message 1', 'Message 2',
|
||||
'Message 3', 'Message 4', 'Beacon Frame', '']
|
||||
.forEach((c) => th.appendChild(h('th', { text: c })));
|
||||
thead.appendChild(th);
|
||||
t.appendChild(thead);
|
||||
const tb = h('tbody');
|
||||
hs.forEach((f) => {
|
||||
const trr = h('tr');
|
||||
trr.appendChild(textCell(f.mac));
|
||||
trr.appendChild(textCell(f.client));
|
||||
trr.appendChild(textCell(f.source));
|
||||
trr.appendChild(textCell(String(f.type).charAt(0).toUpperCase() + String(f.type).slice(1) + ' ' + hsType(f.name)));
|
||||
trr.appendChild(textCell(fmtTime(f.timestamp)));
|
||||
[1, 2, 4, 8].forEach((bit) => trr.appendChild(msgCell(f.in_db, (f.part_mask & bit) !== 0)));
|
||||
trr.appendChild(msgCell(f.in_db, !!f.beacon));
|
||||
const act = h('td', { class: 'mat-cell' },
|
||||
h('span', { class: 'hs-actions' },
|
||||
hsIconBtn('file_download', 'Download', '', () => {
|
||||
window.location = App.apiBase + '/api/pineap/handshakes/' + encodeURIComponent(f.name);
|
||||
}),
|
||||
hsIconBtn('delete', 'Delete', 'hs-warn', () => {
|
||||
PagerAPI.del('/api/pineap/handshakes', { name: f.name })
|
||||
.then(() => load(() => flash(true, 'Deleted ' + f.name)))
|
||||
.catch(() => flash(false, 'Failed to delete ' + f.name));
|
||||
})));
|
||||
trr.appendChild(act);
|
||||
tb.appendChild(trr);
|
||||
});
|
||||
t.appendChild(tb);
|
||||
box.appendChild(t);
|
||||
if (done) done();
|
||||
}).catch(() => flash(false, 'Failed to load handshakes'));
|
||||
}
|
||||
|
||||
function openSettings() {
|
||||
PagerAPI.get('/api/pineap/handshakes/location').then((r) => {
|
||||
const loc = (r.data || {}).location || '--';
|
||||
const overlay = h('div', { class: 'modal-overlay' });
|
||||
function close() { overlay.remove(); }
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
||||
const modal = h('div', { class: 'modal' },
|
||||
h('div', { class: 'modal-title', text: 'Handshake Settings' }),
|
||||
h('div', { class: 'modal-body' },
|
||||
h('div', { class: 'hs-settings-row' },
|
||||
h('span', { class: 'hs-settings-label', text: 'Handshake Location' }),
|
||||
h('span', { class: 'hs-settings-value', text: loc })),
|
||||
btn('Delete All Handshakes', () => {
|
||||
PagerAPI.del('/api/pineap/handshakes/all')
|
||||
.then(() => { close(); load(() => flash(true, 'All handshakes deleted')); })
|
||||
.catch(() => { close(); flash(false, 'Failed to delete all handshakes'); });
|
||||
}, 'danger')),
|
||||
h('div', { class: 'modal-actions' }, btn('Close', close, 'ghost')));
|
||||
overlay.appendChild(modal);
|
||||
document.body.appendChild(overlay);
|
||||
}).catch(() => App.toast('Failed to load handshake settings', 'error'));
|
||||
}
|
||||
|
||||
load();
|
||||
return { destroy: () => {} };
|
||||
};
|
||||
```
|
||||
|
||||
Notes:
|
||||
- The old `hsType(name)` helper is still referenced (it maps `f.name` → `PCAP`/`Hashcat`/`Unknown`), so leave it in place.
|
||||
- `msgCell` renders a check/X/`?` glyph for Message 1–4 and Beacon Frame exactly like the Mark VII.
|
||||
- Delete is deliberately **no-confirm** to match the Mark VII; the flash + reload give feedback.
|
||||
|
||||
- [ ] **Step 2: Verify the view wiring**
|
||||
|
||||
Serve locally and log in:
|
||||
```powershell
|
||||
& .\scripts\dev.ps1 -Tunnel
|
||||
```
|
||||
Browse `http://127.0.0.1:8000/#/recon/handshakes` and confirm the page renders without console errors (empty state "No Handshakes Available" is expected with no captured handshakes). Check the settings gear opens the dialog showing the location and the Delete All button.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add payload/user/general/pager-webui/www/js/views.js
|
||||
git commit -m "feat: Mark VII handshakes table with per-row download/delete and settings dialog"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Deploy + on-device verification
|
||||
|
||||
**Files:**
|
||||
- None (deployment + manual smoke pass)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: all tasks 1–4.
|
||||
|
||||
- [ ] **Step 1: Deploy to the Pager**
|
||||
|
||||
Run (password auth via sshpass, per README):
|
||||
```powershell
|
||||
& .\scripts\deploy.ps1 -Password "<PAGER_PASSWORD>"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Restart the webui service**
|
||||
|
||||
```powershell
|
||||
sshpass -p "<PAGER_PASSWORD>" ssh -o StrictHostKeyChecking=no root@172.16.52.1 "/etc/init.d/pagerwebui restart"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Smoke test the API**
|
||||
|
||||
Log in and check the new shape + routes:
|
||||
```powershell
|
||||
$tok = (curl.exe -s -X POST http://172.16.52.1:8080/api/login -H "Content-Type: application/json" -d "{\"username\":\"root\",\"password\":\"<PAGER_PASSWORD>\"}" | ConvertFrom-Json).token
|
||||
curl.exe -s -b "AUTH_001337AEE050=$tok" http://172.16.52.1:8080/api/pineap/handshakes
|
||||
curl.exe -s -b "AUTH_001337AEE050=$tok" http://172.16.52.1:8080/api/pineap/handshakes/location
|
||||
```
|
||||
Expected: handshakes response has both `files` and `handshakes` keys; location returns `{"location":"/root/loot/handshakes"}`. (Use the `AUTH_<serverid>` cookie name the login actually sets — it is `AUTH_` + the returned `serverid`, e.g. `AUTH_001337AEE050`.)
|
||||
|
||||
- [ ] **Step 4: Browser verification checklist**
|
||||
|
||||
Browse `http://172.16.52.1:8080/#/recon/handshakes` and verify:
|
||||
- [ ] Table headers exactly: BSSID, Client, Source, Type, Captured, Message 1–4, Beacon Frame, action.
|
||||
- [ ] Zip-all / Archive / Refresh row still present and working.
|
||||
- [ ] With no captures: "No Handshakes Available".
|
||||
- [ ] With a capture (or a manually placed file `1786466650_C8-9E-43-64-80-80_AE-77-C0-EB-31-41_handshake.pcap` in `/root/loot/handshakes/` for testing): BSSID/Client/Source/Type/Captured render; M1–4/Beacon show ✓/✗ or `?`; download and delete work; delete flashes green; dark theme (`html.dark`) looks right.
|
||||
- [ ] Gear opens dialog with location + Delete All; Delete All empties the dir and reloads.
|
||||
- [ ] Dashboard (handshakes count + "Captured WPA Handshakes" table) still renders from `files`.
|
||||
|
||||
- [ ] **Step 5: Clean up test file + commit any fixes**
|
||||
|
||||
Remove any manually placed test file from `/root/loot/handshakes/`. If verification found defects, fix them in a new task-style cycle (test → fix → re-deploy), then commit the fixes.
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "fix: on-device verification adjustments"
|
||||
```
|
||||
(Only if there were fixes; otherwise skip.)
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
- §4.1 `handshakes_data()` shape + empty-dir skip + single batched read → Task 1.
|
||||
- §4.2 filename parser + fallback → Task 1 (parser + `_compose_hs` fallback branch).
|
||||
- §4.3 record composition (incl. `name` field) → Task 1.
|
||||
- §4.4 location + delete-all routes + ordering → Task 2.
|
||||
- §5.1 view rewrite (table, glyphs, download/delete, flash, empty state) → Task 4.
|
||||
- §5.2 settings dialog → Task 4.
|
||||
- §5.3 icons → Task 3.
|
||||
- §5.4 CSS → Task 3.
|
||||
- §6 compute mitigation → Task 1 (empty-dir skip), Task 1/4 (no polling).
|
||||
- §8 testing → Tasks 1–2, §5 smoke → Task 5.
|
||||
|
||||
**Placeholder scan:** No TBD/TODO/“similar to” — every code step contains full code.
|
||||
|
||||
**Type consistency:** `parse_hs_filename`, `_norm_mac`, `_hs_db_by_pair`, `_compose_hs`, `handshakes_data`, `h_handshakes_location`, `h_handshakes_delete_all` — names/return shapes consistent between Task 1, Task 2, and the view in Task 4 (`f.name`, `f.mac`, `f.client`, `f.type`, `f.timestamp`, `f.in_db`, `f.part_mask`, `f.beacon`). `hsType(f.name)` reuses the existing helper. `AUTH_<serverid>` cookie note in Task 5 matches server.py `h_login`.
|
||||
@@ -0,0 +1,199 @@
|
||||
# Open AP Tab — Mark VII "PineAP Settings" Card 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:** Restyle the PineAP Open AP tab (`#/pineap/open`) into the Mark VII "PineAP" settings card — titled card with subtitle, iOS-style slide switches grouped into sections, and a batched Mk7-style Save button — replacing the current flat checkbox list.
|
||||
|
||||
**Architecture:** Frontend-only. Rewrite `views.pineap_open` in `www/js/views.js` to build a `.pineap-card-settings` card: a `.pineap-card-title-flex` title row ("PineAP" + subtitle + Save button), three grouped sections of `.switch` slide toggles, and a muted footer. Toggles stage values locally (dirty flags); Save applies only the dirty fields batched per backend route (`set_config` for logging/capture, `enable`, `mimic`, `ssidpool/advertise`). Two new CSS classes in `app.css`. No backend changes.
|
||||
|
||||
**Tech Stack:** Vanilla JS (`h()`, `btn()`, `PagerAPI`, `App.toast`, `pineapShell`), the existing `.switch` slider CSS, existing `PagerAPI.post` routes.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- No backend changes. No changes to `server.py` or `tests/`.
|
||||
- Toggles must NOT call the API on change — they stage locally and mark dirty.
|
||||
- Save applies ONLY dirty fields; untouched fields keep their daemon state (preserves the unreadable live Karma state).
|
||||
- Batching per route: `set_config` fields (`loghandshake`, `logpartialhandshake`, `logpcap`, `logwigle`, `logrecon`, `autossidpool`) go in ONE `POST /api/pineap/set_config`; `pineap_disabled` → `POST /api/pineap/enable {enable}`; `karma` → `POST /api/pineap/mimic {enable}`; `advertise` → `POST /api/pineap/ssidpool/advertise {enable}`.
|
||||
- `load()` runs once on entry and after a successful Save; it must not clobber staged-but-unsaved values.
|
||||
- JS verification uses the Python delimiter-balance checker at `C:\Users\root\AppData\Local\Temp\opencode\js_balance.py` (no node available).
|
||||
- Python for the unittest loop: `$env:LOCALAPPDATA\Programs\Python\Python311\python.exe`.
|
||||
- Deploy: from repo root, `powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\deploy.ps1 -Password "<PAGER_PASSWORD>"`, then `/etc/init.d/pagerwebui restart` over sshpass.
|
||||
- Commit messages follow repo style (`feat:`, `fix:`, `docs:`).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Open AP tab Mk7 settings card (CSS + rewrite)
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/general/pager-webui/www/css/app.css` (append three classes)
|
||||
- Modify: `payload/user/general/pager-webui/www/js/views.js` (replace the whole `views.pineap_open` function, currently lines ~327-375)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `h(tag, attrs, ...children)`, `btn(label, onclk, cls)`, `PagerAPI.get/post`, `App.toast`, `pineapShell(root, hash)`, `tabBar`; existing `.switch`/`.track` CSS and `.pineap-*` layout classes from prior work.
|
||||
- Produces: `views.pineap_open` rendering the Mk7 settings card; `staged`/`dirty` maps; `save()` batched apply; `load()` populating switches without clobbering staged values.
|
||||
|
||||
- [ ] **Step 1: Append the three CSS classes to `app.css`**
|
||||
|
||||
Append to the end of `payload/user/general/pager-webui/www/css/app.css`:
|
||||
|
||||
```css
|
||||
/* ---- Open AP: Mark VII PineAP settings card ---- */
|
||||
.pineap-card-subtitle { color: var(--muted); font-size: 13px; margin: -8px 0 10px; }
|
||||
.pineap-settings-section { font-size: 13px; font-weight: 500; color: var(--muted); margin: 14px 0 4px; }
|
||||
.pineap-card-settings .switch { margin: 6px 0; }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace the whole `views.pineap_open` function in `views.js`**
|
||||
|
||||
Replace everything from `views.pineap_open = (root) => {` through the closing `};` of that function (current lines ~327-375, i.e. just before `const EVIL_ENC`) with:
|
||||
|
||||
```js
|
||||
views.pineap_open = (root) => {
|
||||
const box = pineapShell(root, '#/pineap/open');
|
||||
const wrap = h('div', { class: 'pineap-title-card pineap-card-settings' });
|
||||
wrap.appendChild(h('div', { class: 'pineap-card-title-flex' },
|
||||
h('span', { text: 'PineAP' }),
|
||||
h('span', { class: 'toolbar-spacer' }),
|
||||
btn('Save', save, '')));
|
||||
wrap.appendChild(h('div', { class: 'pineap-card-subtitle', text: 'Quickly set the general behavior of PineAP' }));
|
||||
|
||||
const staged = {};
|
||||
const dirty = {};
|
||||
const toggles = {};
|
||||
const groups = [
|
||||
['Karma', [
|
||||
['pineap_disabled', 'Enable PineAP'],
|
||||
['karma', 'Karma']
|
||||
]],
|
||||
['SSID Pool', [
|
||||
['autossidpool', 'Capture SSIDs to Pool'],
|
||||
['advertise', 'Advertise AP Impersonation Pool']
|
||||
]],
|
||||
['Logging', [
|
||||
['loghandshake', 'Log Handshakes'],
|
||||
['logpartialhandshake', 'Log Partial Handshakes'],
|
||||
['logpcap', 'Log PCAP'],
|
||||
['logwigle', 'Log WiGLE'],
|
||||
['logrecon', 'Log Recon']
|
||||
]]
|
||||
];
|
||||
groups.forEach(([section, items]) => {
|
||||
wrap.appendChild(h('div', { class: 'pineap-settings-section', text: section }));
|
||||
items.forEach(([k, label]) => {
|
||||
const cb = h('input', { type: 'checkbox', id: 'oap-' + k });
|
||||
toggles[k] = cb;
|
||||
cb.addEventListener('change', () => { staged[k] = cb.checked; dirty[k] = true; });
|
||||
wrap.appendChild(h('label', { class: 'switch' },
|
||||
cb, h('span', { class: 'track' }), ' ' + label));
|
||||
});
|
||||
});
|
||||
const info = h('div', { class: 'muted', style: 'margin-top:10px' });
|
||||
wrap.appendChild(info);
|
||||
box.appendChild(wrap);
|
||||
|
||||
function save() {
|
||||
const keys = Object.keys(dirty);
|
||||
if (!keys.length) { App.toast('No changes'); return; }
|
||||
const reqs = [];
|
||||
const setCfg = {};
|
||||
keys.forEach((k) => {
|
||||
if (k === 'pineap_disabled') reqs.push(PagerAPI.post('/api/pineap/enable', { enable: staged[k] }));
|
||||
else if (k === 'karma') reqs.push(PagerAPI.post('/api/pineap/mimic', { enable: staged[k] }));
|
||||
else if (k === 'advertise') reqs.push(PagerAPI.post('/api/pineap/ssidpool/advertise', { enable: staged[k] }));
|
||||
else setCfg[k] = staged[k];
|
||||
});
|
||||
if (Object.keys(setCfg).length) reqs.push(PagerAPI.post('/api/pineap/set_config', setCfg));
|
||||
Promise.allSettled(reqs).then((results) => {
|
||||
const ok = results.every((r) => r.status === 'fulfilled');
|
||||
App.toast(ok ? 'Settings saved' : 'Some settings failed', ok ? '' : 'error');
|
||||
Object.keys(dirty).forEach((k) => delete dirty[k]);
|
||||
load();
|
||||
});
|
||||
}
|
||||
|
||||
function load() {
|
||||
Promise.all([
|
||||
PagerAPI.get('/api/pineap/get_config').catch(() => ({ data: {} })),
|
||||
PagerAPI.get('/api/pineap/hostapd').catch(() => ({ data: {} })),
|
||||
PagerAPI.post('/api/pineap/wifi/get_ap').catch(() => ({ data: {} }))
|
||||
]).then(([cfg, host, ap]) => {
|
||||
const c = cfg.data || {}, hh = host.data || {}, a = ap.data || {};
|
||||
if (!dirty.pineap_disabled) toggles.pineap_disabled.checked = !hh.pineap_disabled;
|
||||
if (!dirty.karma) toggles.karma.checked = !!c.mimic;
|
||||
['loghandshake', 'logpartialhandshake', 'logpcap', 'logwigle', 'logrecon', 'autossidpool']
|
||||
.forEach((k) => { if (!dirty[k]) toggles[k].checked = !!c[k]; });
|
||||
const pool = a.pool || {};
|
||||
if (!dirty.advertise) toggles.advertise.checked = pool.disabled === false;
|
||||
const o = a.open || {};
|
||||
info.textContent = 'PineAP MAC: ' + (o.bssid || '—') + ' Target MAC: ' + (o.target || '—');
|
||||
});
|
||||
}
|
||||
load();
|
||||
return { destroy: () => {} };
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the JS delimiter balance check**
|
||||
|
||||
Run: `& "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe" "C:\Users\root\AppData\Local\Temp\opencode\js_balance.py" "C:\Users\root\Documents\Pineapple\pager-webui\payload\user\general\pager-webui\www\js\views.js"`
|
||||
Expected: `...views.js: delimiter balance OK`
|
||||
|
||||
- [ ] **Step 4: Run the 13-module unittest loop (backend must stay green)**
|
||||
|
||||
Run from `C:\Users\root\Documents\Pineapple\pager-webui`:
|
||||
```powershell
|
||||
$py = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe"; Get-ChildItem tests\test_*.py | ForEach-Object { $mod = "tests." + [IO.Path]::GetFileNameWithoutExtension($_.Name); $out = & $py -m unittest $mod 2>&1; ($out | Select-Object -Last 1) }
|
||||
```
|
||||
Expected: every module reports `OK` (recon may show `skipped=1`).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add payload/user/general/pager-webui/www/css/app.css payload/user/general/pager-webui/www/js/views.js
|
||||
git commit -m "feat: Mark VII PineAP settings card for Open AP tab with batched save"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Deploy and verify on device
|
||||
|
||||
**Files:** none (verification only; no commit).
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1 output (deployed via `scripts/deploy.ps1`).
|
||||
|
||||
- [ ] **Step 1: Deploy the payload and restart the webui**
|
||||
|
||||
Run from `C:\Users\root\Documents\Pineapple\pager-webui`:
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\deploy.ps1 -Password "<PAGER_PASSWORD>"
|
||||
```
|
||||
Then restart and confirm the port is up (over sshpass SSH):
|
||||
```bash
|
||||
sshpass -p "<PAGER_PASSWORD>" ssh root@172.16.52.1 "/etc/init.d/pagerwebui restart; sleep 4; curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/api/api_ping"
|
||||
```
|
||||
Expected: `401` (auth required = running).
|
||||
|
||||
- [ ] **Step 2: Confirm the deployed files contain the new code**
|
||||
|
||||
```bash
|
||||
sshpass -p "<PAGER_PASSWORD>" ssh root@172.16.52.1 "grep -c 'pineap-settings-section' /root/payloads/user/general/pager-webui/www/css/app.css; grep -c 'Quickly set the general behavior of PineAP' /root/payloads/user/general/pager-webui/www/js/views.js"
|
||||
```
|
||||
Expected: both counts greater than zero.
|
||||
|
||||
- [ ] **Step 3: On-device save-path check (read-only + one harmless write/restore)**
|
||||
|
||||
Over sshpass SSH, base64 a script and run it via `echo ... | base64 -d | sh`:
|
||||
```sh
|
||||
curl -s -c /tmp/pwj -X POST http://127.0.0.1:8080/api/login -H "Content-Type: application/json" -d '{"username":"root","password":"<PAGER_PASSWORD>"}' > /dev/null
|
||||
echo before:; curl -s -b /tmp/pwj http://127.0.0.1:8080/api/pineap/get_config | grep -o '"logrecon":[a-z]*'
|
||||
curl -s -b /tmp/pwj -X POST http://127.0.0.1:8080/api/pineap/set_config -d '{"logrecon":true}' > /dev/null
|
||||
echo after-set-true:; curl -s -b /tmp/pwj http://127.0.0.1:8080/api/pineap/get_config | grep -o '"logrecon":[a-z]*'
|
||||
curl -s -b /tmp/pwj -X POST http://127.0.0.1:8080/api/pineap/set_config -d '{"logrecon":false}' > /dev/null
|
||||
echo restored:; curl -s -b /tmp/pwj http://127.0.0.1:8080/api/pineap/get_config | grep -o '"logrecon":[a-z]*'
|
||||
```
|
||||
Expected: `before` shows the current `logrecon` value, `after-set-true` shows `true`, `restored` shows the original value (verifies the batched `set_config` merge path the Save button uses).
|
||||
|
||||
- [ ] **Step 4: Report for user UI walk**
|
||||
|
||||
Tell the user the Open AP tab is now the Mk7 "PineAP" settings card: title + subtitle "Quickly set the general behavior of PineAP", grouped iOS-style slide switches (Karma / SSID Pool / Logging), and a Save button that batches only the changed toggles (unchanged Karma is left untouched). Ask them to refresh `http://172.16.52.1:8080/#/pineap/open`, flip a logging toggle, Save, and confirm the toast + that `get_config` reflects the change.
|
||||
@@ -0,0 +1,508 @@
|
||||
# Mk7 "PineAP Open Access Point" Card 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:** Rebuild the Open AP tab (`#/pineap/open`, `views.pineap_open`) into the genuine Mark 7 Pineapple's "PineAP Open Access Point" card — Open SSID / BSSID / Channel / Current Country / Hidden / Respond-to-all-probes toggles, filter notice boxes, and a Save that writes real device config.
|
||||
|
||||
**Architecture:** Backend extends the two existing `wifi/get_ap` + `wifi/set_ap` handlers to expose and persist the Open AP's SSID, BSSID (`macaddr`), hidden, channel, and country (channel/country applied to `wireless.radio0` with a `wifi reload`). Frontend replaces `views.pineap_open` with the Mk7 card; karma ("Respond to all probe requests") saves via the existing `/api/pineap/mimic` and is session-tracked (the daemon cannot report karma). Filter notice boxes reuse the existing `action`-based filter API.
|
||||
|
||||
**Tech Stack:** Python (`server.py`, unittest), vanilla JS (`views.js`, `app.css`), existing `h()`/`btn()`/`PagerAPI`/`App.toast` helpers.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Backend is UCI-driven (`_uci_wifi_iface`, `_uci_section`, `device_run`); the daemon's `/api/settings/wifi/set_ap` persists `ssid`/`hidden`/`bssid`→`macaddr`/`channel`/`enabled` to `wireless.wlan0open` but its iface-level `channel` write is **inert** for the actual radio — the radio channel/country must be written to `wireless.radio0` + `uci commit wireless` + `wifi reload`.
|
||||
- The daemon exposes **no readable karma/mimic or broadcast/advertise state** — the karma toggle is session-tracked (module-level flag, default off, updated on Save); the info line omits the "Spoofed SSID Pool will be advertised" clause.
|
||||
- Open AP `enabled` is preserved as-is (the Mk7 card has no Enabled control); the frontend always sends `enabled` = value loaded from `get_ap`.
|
||||
- Filter mutations use the existing `action` API: `POST /api/pineap/filters/ssid` `{action:'add'|'delete', value}` and `POST /api/pineap/filters/client` `{action:'set_mode', mode:'deny'}`.
|
||||
- No new routes; no daemon changes. Commit messages follow repo style (`feat:`, `fix:`, `docs:`).
|
||||
- JS verification uses `C:\Users\root\AppData\Local\Temp\opencode\js_balance.py` (no node available). Python: `$env:LOCALAPPDATA\Programs\Python\Python311\python.exe`.
|
||||
- Deploy: from repo root, `powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\deploy.ps1 -Password "<PAGER_PASSWORD>"`, then `/etc/init.d/pagerwebui restart` over sshpass.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Backend — expose and save Open AP network settings
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/general/pager-webui/server.py` (`h_pineap_wifi_get_ap` at ~1479, `h_pineap_wifi_set_ap` at ~1505, add helper `_apply_open_radio` just before `h_pineap_wifi_set_ap`)
|
||||
- Test: `tests/test_pineap_proxy.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `_uci_wifi_iface(name)` (runs `uci show wireless.<name>`, returns dict), `_uci_section(section)`, `device_run(args)`, `daemon_sock_call(method, path, body)`.
|
||||
- Produces: `h_pineap_wifi_get_ap` `open` payload now `{enabled, ssid, bssid, target, hidden, channel, country}`; `h_pineap_wifi_set_ap` accepts `open: {ssid, bssid, hidden, enabled, channel, country}`. Task 2 consumes these exact field names.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests** (extend `test_wifi_get_ap_reads_uci_wireless`; add a new set_ap test)
|
||||
|
||||
Replace the `test_wifi_get_ap_reads_uci_wireless` body's `fake_run` with the version below and add the new assertions; append `test_wifi_set_ap_open_bssid_channel_and_country` to the `PineapProxyTest` class:
|
||||
|
||||
```python
|
||||
def test_wifi_get_ap_reads_uci_wireless(self):
|
||||
def fake_run(args):
|
||||
cmd = args[0]
|
||||
if cmd == 'uci' and len(args) == 3:
|
||||
sec = args[2]
|
||||
if sec == 'wireless.wlan0wpa':
|
||||
return 0, "wireless.wlan0wpa.ifname='wlan0wpa'\nwireless.wlan0wpa.ssid='Evil1'\nwireless.wlan0wpa.encryption='psk2'\nwireless.wlan0wpa.key='sekret'\nwireless.wlan0wpa.disabled='0'\nwireless.wlan0wpa.hidden='0'\n", ''
|
||||
if sec == 'wireless.wlan0open':
|
||||
return 0, "wireless.wlan0open.disabled='1'\nwireless.wlan0open.ssid='pager-open'\nwireless.wlan0open.macaddr='DE:AD:BE:EF:00:01'\nwireless.wlan0open.hidden='1'\n", ''
|
||||
if sec == 'wireless.radio0':
|
||||
return 0, "wireless.radio0.channel='6'\nwireless.radio0.country='US'\n", ''
|
||||
if sec.startswith('pineapd.@ssidpool'):
|
||||
return 0, "pineapd.@ssidpool[0].bssid='auto'\npineapd.@ssidpool[0].target='broadcast'\n", ''
|
||||
return 0, '', ''
|
||||
|
||||
def fake_sock(method, path, body=None, timeout=10):
|
||||
if path == '/api/pineap/hostapd/get_config':
|
||||
return 200, {'pineape_disabled': False}
|
||||
if path == '/api/pineap/get_config':
|
||||
return 200, {'autossidpool': True}
|
||||
return 200, {}
|
||||
|
||||
server.device_run = fake_run
|
||||
server.daemon_sock_call = fake_sock
|
||||
status, payload = server.h_pineap_wifi_get_ap(ctx())
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(payload['wpa'], {'ssid': 'Evil1', 'passphrase': 'sekret', 'enctype': 'psk2',
|
||||
'hidden': False, 'enabled': True})
|
||||
self.assertEqual(payload['open']['enabled'], False)
|
||||
self.assertEqual(payload['open']['ssid'], 'pager-open')
|
||||
self.assertEqual(payload['open']['bssid'], 'DE:AD:BE:EF:00:01')
|
||||
self.assertEqual(payload['open']['hidden'], True)
|
||||
self.assertEqual(payload['open']['channel'], 6)
|
||||
self.assertEqual(payload['open']['country'], 'US')
|
||||
self.assertEqual(payload['open']['target'], 'broadcast')
|
||||
self.assertEqual(payload['enterprise']['enabled'], True)
|
||||
self.assertEqual(payload['pool']['collecting'], True)
|
||||
|
||||
def test_wifi_set_ap_open_bssid_channel_and_country(self):
|
||||
sock_calls = []
|
||||
run_calls = []
|
||||
|
||||
def fake_sock(method, path, body=None, timeout=10):
|
||||
sock_calls.append((method, path, body))
|
||||
return (200, {'success': True})
|
||||
|
||||
def fake_run(args):
|
||||
run_calls.append(args)
|
||||
if args[0] == 'uci' and args[1] == 'show':
|
||||
return 0, "wireless.radio0.channel='1'\n", ''
|
||||
return 0, '', ''
|
||||
|
||||
server.daemon_sock_call = fake_sock
|
||||
server.device_run = fake_run
|
||||
status, _ = server.h_pineap_wifi_set_ap(ctx({'open': {
|
||||
'ssid': 'Open', 'bssid': 'DE:AD:BE:EF:00:02', 'hidden': True,
|
||||
'channel': 6, 'country': 'US', 'enabled': True}}))
|
||||
self.assertEqual(status, 200)
|
||||
method, path, body = sock_calls[0]
|
||||
self.assertEqual(method, 'PUT')
|
||||
self.assertEqual(path, '/api/settings/wifi/set_ap')
|
||||
conf = body['configs'][0]
|
||||
self.assertEqual(conf['interface'], 'wlan0open')
|
||||
self.assertEqual(conf['ssid'], 'Open')
|
||||
self.assertEqual(conf['bssid'], 'DE:AD:BE:EF:00:02')
|
||||
self.assertEqual(conf['hidden'], True)
|
||||
self.assertEqual(conf['channel'], 6)
|
||||
self.assertEqual(conf['enabled'], True)
|
||||
sets = [a for a in run_calls if a[:2] == ['uci', 'set']]
|
||||
self.assertEqual(sets, [['uci', 'set', 'wireless.radio0.channel=6'],
|
||||
['uci', 'set', 'wireless.radio0.country=US']])
|
||||
self.assertIn(['uci', 'commit', 'wireless'], run_calls)
|
||||
self.assertIn(['wifi', 'reload'], run_calls)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests to verify they fail**
|
||||
|
||||
Run: `& "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe" -m unittest tests.test_pineap_proxy`
|
||||
Expected: both tests FAIL (get_ap returns no `ssid`/`bssid`/`hidden`/`channel`/`country`; set_ap drops `bssid`/`channel` and never runs `uci set wireless.radio0.*`).
|
||||
|
||||
- [ ] **Step 3: Implement the backend changes**
|
||||
|
||||
In `server.py`:
|
||||
|
||||
`h_pineap_wifi_get_ap` — add the radio read and the new open fields (replace the current `open_cfg = _uci_wifi_iface('wlan0open')` line block and the `'open'` dict):
|
||||
|
||||
```python
|
||||
def h_pineap_wifi_get_ap(ctx):
|
||||
open_cfg = _uci_wifi_iface('wlan0open')
|
||||
radio_cfg = _uci_wifi_iface('radio0')
|
||||
wpa_cfg = _uci_wifi_iface('wlan0wpa')
|
||||
status, data = daemon_sock_call('GET', '/api/pineap/hostapd/get_config')
|
||||
host = data if status == 200 and isinstance(data, dict) else {}
|
||||
status2, data2 = daemon_sock_call('GET', '/api/pineap/get_config')
|
||||
pinecfg = data2 if status2 == 200 and isinstance(data2, dict) else {}
|
||||
pool = _uci_section('pineapd.@ssidpool[0]')
|
||||
channel = radio_cfg.get('channel') or ''
|
||||
try:
|
||||
channel = int(channel)
|
||||
except (TypeError, ValueError):
|
||||
channel = None
|
||||
return 200, {
|
||||
'open': {
|
||||
'enabled': open_cfg.get('disabled') == '0',
|
||||
'ssid': open_cfg.get('ssid') or '',
|
||||
'bssid': open_cfg.get('macaddr') or '',
|
||||
'target': pool.get('target') or None,
|
||||
'hidden': open_cfg.get('hidden') == '1',
|
||||
'channel': channel,
|
||||
'country': radio_cfg.get('country') or '',
|
||||
},
|
||||
'wpa': {
|
||||
'ssid': wpa_cfg.get('ssid') or '',
|
||||
'passphrase': wpa_cfg.get('key') or '',
|
||||
'enctype': wpa_cfg.get('encryption') or '',
|
||||
'hidden': wpa_cfg.get('hidden') == '1',
|
||||
'enabled': wpa_cfg.get('disabled') == '0',
|
||||
},
|
||||
'enterprise': {'enabled': not host.get('pineape_disabled', True)},
|
||||
'pool': {'disabled': None, 'collecting': bool(pinecfg.get('autossidpool'))},
|
||||
}
|
||||
```
|
||||
|
||||
Add this helper immediately before `h_pineap_wifi_set_ap`:
|
||||
|
||||
```python
|
||||
def _apply_open_radio(openap):
|
||||
"""Persist the Open AP's radio channel/country to wireless.radio0. The
|
||||
daemon's iface-level channel write does not affect the actual radio, so
|
||||
apply channel/country here and reload wifi when they change."""
|
||||
changed = False
|
||||
for key in ('channel', 'country'):
|
||||
value = openap.get(key)
|
||||
if value is None:
|
||||
continue
|
||||
current = _uci_wifi_iface('radio0').get(key) or ''
|
||||
if str(value) != current:
|
||||
device_run(['uci', 'set', 'wireless.radio0.%s=%s' % (key, value)])
|
||||
changed = True
|
||||
if changed:
|
||||
device_run(['uci', 'commit', 'wireless'])
|
||||
device_run(['wifi', 'reload'])
|
||||
```
|
||||
|
||||
`h_pineap_wifi_set_ap` — replace the open branch and add the call after the daemon call (replace the current `'channel': 1` open config and the `return 200, {'ok': True}` line):
|
||||
|
||||
```python
|
||||
if openap.get('ssid') or openap.get('enabled') is not None:
|
||||
configs.append({
|
||||
'interface': 'wlan0open',
|
||||
'ssid': openap.get('ssid', ''),
|
||||
'enctype': 'none',
|
||||
'enabled': bool(openap.get('enabled', True)),
|
||||
'hidden': bool(openap.get('hidden', False)),
|
||||
'channel': int(openap['channel']) if openap.get('channel') is not None else 1,
|
||||
'bssid': openap.get('bssid') or '',
|
||||
})
|
||||
if not configs:
|
||||
return 400, {'error': 'no configuration provided'}
|
||||
status, data = daemon_sock_call('PUT', '/api/settings/wifi/set_ap', body={'configs': configs}, timeout=45)
|
||||
if status != 200:
|
||||
return (502 if status == 0 else status), {'error': 'daemon failed', 'detail': data}
|
||||
_apply_open_radio(openap)
|
||||
return 200, {'ok': True}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the tests to verify they pass**
|
||||
|
||||
Run: `& "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe" -m unittest tests.test_pineap_proxy`
|
||||
Expected: all PASS.
|
||||
|
||||
- [ ] **Step 5: Run the full unittest loop**
|
||||
|
||||
Run from repo root:
|
||||
```powershell
|
||||
$py = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe"; Get-ChildItem tests\test_*.py | ForEach-Object { $mod = "tests." + [IO.Path]::GetFileNameWithoutExtension($_.Name); $out = & $py -m unittest $mod 2>&1; ($out | Select-Object -Last 1) }
|
||||
```
|
||||
Expected: every module reports `OK` (recon may show `skipped=1`).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add payload/user/general/pager-webui/server.py tests/test_pineap_proxy.py
|
||||
git commit -m "feat: expose and save Open AP ssid/bssid/hidden/channel/country"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Frontend — Mk7 "PineAP Open Access Point" card
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/general/pager-webui/www/js/views.js` (add `OPEN_CHANNELS`/`OPEN_COUNTRIES` constants + `let OPEN_KARMA` before `views.pineap_open`; replace the whole `views.pineap_open` function, currently lines 327-409, just before `const EVIL_ENC`)
|
||||
- Modify: `payload/user/general/pager-webui/www/css/app.css` (append infobox styles at end)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1's `open` fields (`ssid`, `bssid`, `hidden`, `channel`, `country`, `enabled`) from `POST /api/pineap/wifi/get_ap`; `set_ap` `open` body keys; existing `/api/pineap/mimic`, `/api/pineap/get_config`, `GET /api/pineap/filters/{ssid,client}` → `{mode, entries}`; existing `action` filter mutations.
|
||||
- Produces: the Mk7 Open card. No later task consumes these names.
|
||||
|
||||
- [ ] **Step 1: Add the constants and module state before `views.pineap_open`**
|
||||
|
||||
Insert immediately before `views.pineap_open = (root) => {`:
|
||||
|
||||
```js
|
||||
const OPEN_CHANNELS = Array.from({ length: 11 }, (_, i) => {
|
||||
const c = i + 1;
|
||||
return [c, 'Channel ' + c + ' (' + (2412 + (c - 1) * 5) + ' MHz)'];
|
||||
});
|
||||
const OPEN_COUNTRIES = [
|
||||
['US', 'United States'], ['DZ', 'Algeria'], ['AR', 'Argentina'], ['AU', 'Australia'],
|
||||
['AT', 'Austria'], ['BH', 'Bahrain'], ['BM', 'Bermuda'], ['BO', 'Bolivia'], ['BR', 'Brazil'],
|
||||
['BG', 'Bulgaria'], ['CA', 'Canada'], ['CL', 'Chile'], ['CN', 'China'], ['CO', 'Colombia'],
|
||||
['CR', 'Costa Rica'], ['CS', 'Cyprus'], ['CZ', 'Czech Republic'], ['DK', 'Denmark'],
|
||||
['DO', 'Dominican Republic'], ['EC', 'Ecuador'], ['EG', 'Egypt'], ['SV', 'El Salvador'],
|
||||
['EE', 'Estonia'], ['FI', 'Finland'], ['FR', 'France'], ['DE', 'Germany'], ['GR', 'Greece'],
|
||||
['GT', 'Guatemala'], ['HN', 'Honduras'], ['HK', 'Hong Kong'], ['IS', 'Iceland'], ['IN', 'India'],
|
||||
['ID', 'Indonesia'], ['IE', 'Ireland'], ['PK', 'Islamic Republic of Pakistan'], ['IL', 'Israel'],
|
||||
['IT', 'Italy'], ['JM', 'Jamaica'], ['JO', 'Jordan'], ['KE', 'Kenya'], ['KW', 'Kuwait'],
|
||||
['LB', 'Lebanon'], ['LI', 'Liechtenstein'], ['LT', 'Lithuania'], ['LU', 'Luxembourg'],
|
||||
['MU', 'Mauritius'], ['MX', 'Mexico'], ['MA', 'Morocco'], ['NL', 'Netherlands'], ['NZ', 'New Zealand'],
|
||||
['NO', 'Norway'], ['OM', 'Oman'], ['PA', 'Panama'], ['PE', 'Peru'], ['PH', 'Philippines'],
|
||||
['PL', 'Poland'], ['PT', 'Portuagal'], ['PR', 'Puerto Rico'], ['QA', 'Qatar'],
|
||||
['KR', 'Republic of Korea (South Korea)'], ['RO', 'Romania'], ['RU', 'Russia'], ['SA', 'Saudi Arabia'],
|
||||
['SG', 'Singapore'], ['SI', 'Slovenia'], ['SK', 'Slovak Republic'], ['ZA', 'South Africa'],
|
||||
['ES', 'Spain'], ['LK', 'Sri Lanka'], ['SE', 'Sweden'], ['CH', 'Switzerland'], ['TW', 'Taiwan'],
|
||||
['TH', 'Thailand'], ['TT', 'Trinidad and Tobago'], ['TN', 'Tunisia'], ['TR', 'Turkey'],
|
||||
['UA', 'Ukraine'], ['AE', 'United Arab Emirates'], ['GB', 'United Kingdom'], ['UY', 'Uraguay'],
|
||||
['VE', 'Venezuela'], ['VN', 'Vietnam']
|
||||
];
|
||||
let OPEN_KARMA = false;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace the whole `views.pineap_open` function**
|
||||
|
||||
Replace everything from `views.pineap_open = (root) => {` through its closing `};` (current lines 327-409, i.e. just before `const EVIL_ENC`) with:
|
||||
|
||||
```js
|
||||
views.pineap_open = (root) => {
|
||||
const box = pineapShell(root, '#/pineap/open');
|
||||
const card = h('div', { class: 'pineap-title-card' });
|
||||
box.appendChild(card);
|
||||
|
||||
card.appendChild(h('div', { class: 'pineap-card-title' }, 'PineAP Open Access Point'));
|
||||
const subtitle = h('div', { class: 'pineap-card-subtitle' });
|
||||
card.appendChild(subtitle);
|
||||
|
||||
const ssidIn = h('input', { id: 'oa-ssid' });
|
||||
const bssidIn = h('input', { id: 'oa-bssid' });
|
||||
const chSel = h('select', { id: 'oa-channel' });
|
||||
OPEN_CHANNELS.forEach(([v, l]) => chSel.appendChild(h('option', { value: v, text: l })));
|
||||
const coSel = h('select', { id: 'oa-country' });
|
||||
OPEN_COUNTRIES.forEach(([v, l]) => coSel.appendChild(h('option', { value: v, text: l })));
|
||||
const hiddenCb = h('input', { type: 'checkbox', id: 'oa-hidden' });
|
||||
const karmaCb = h('input', { type: 'checkbox', id: 'oa-karma' });
|
||||
|
||||
card.appendChild(h('div', { class: 'row' },
|
||||
h('div', {}, h('label', {}, 'Open SSID', ssidIn)),
|
||||
h('div', {}, h('label', {}, 'BSSID', bssidIn))));
|
||||
card.appendChild(h('div', { class: 'row' },
|
||||
h('div', {}, h('label', {}, 'Channel', chSel)),
|
||||
h('div', {}, h('label', {}, 'Current Country', coSel))));
|
||||
card.appendChild(h('div', { class: 'row' },
|
||||
h('div', {}, h('label', { class: 'switch' }, hiddenCb, h('span', { class: 'track' }), ' Hidden')),
|
||||
h('div', {}, h('label', { class: 'switch' }, karmaCb, h('span', { class: 'track' }), ' Respond to all probe requests (impersonate all networks)'))));
|
||||
|
||||
const info = h('div', { class: 'muted', style: 'margin-top:10px;font-size:13px' });
|
||||
card.appendChild(info);
|
||||
const boxes = h('div', {});
|
||||
card.appendChild(boxes);
|
||||
card.appendChild(h('div', { class: 'row', style: 'margin-top:10px' },
|
||||
h('div', {}, btn('Save', save)),
|
||||
h('div', { class: 'muted', style: 'align-self:center;font-size:12px' }, 'Applying reconfigures the radio — you may be disconnected briefly.')));
|
||||
|
||||
const state = {};
|
||||
|
||||
function cfgLink() {
|
||||
return h('a', { href: '#/pineap/filtering', style: 'color:var(--primary);cursor:pointer', onclick: (e) => { e.preventDefault(); App.go('#/pineap/filtering'); } }, 'filter configuration');
|
||||
}
|
||||
function filterBtn() {
|
||||
return h('a', { class: 'btn', href: '#/pineap/filtering', style: 'text-decoration:none;display:inline-block', onclick: (e) => { e.preventDefault(); App.go('#/pineap/filtering'); } }, 'Change Filters');
|
||||
}
|
||||
function infobox(severity, text, ...actions) {
|
||||
return h('div', { class: 'pineap-infobox ' + severity },
|
||||
h('span', { text }),
|
||||
h('div', { class: 'pineap-infobox-actions' }, actions));
|
||||
}
|
||||
function filterSentence(sm, cm) {
|
||||
if (sm === 'allow' && cm === 'allow') return 'any client in the filter configuration may connect to any SSID in the filter configuration.';
|
||||
if (sm === 'deny' && cm === 'allow') return 'any client not in the filter configuration may connect to any SSID in the filter configuration.';
|
||||
if (sm === 'allow' && cm === 'deny') return 'any client in the filter configuration may connect to any SSID not in the filter configuration.';
|
||||
return 'any client not in the filter configuration may connect to any SSID not in the filter configuration.';
|
||||
}
|
||||
|
||||
function save() {
|
||||
Promise.allSettled([
|
||||
PagerAPI.post('/api/pineap/wifi/set_ap', {
|
||||
open: {
|
||||
ssid: ssidIn.value,
|
||||
bssid: bssidIn.value.trim(),
|
||||
hidden: hiddenCb.checked,
|
||||
enabled: !!state.enabled,
|
||||
channel: chSel.value ? parseInt(chSel.value, 10) : null,
|
||||
country: coSel.value
|
||||
}
|
||||
}),
|
||||
PagerAPI.post('/api/pineap/mimic', { enable: karmaCb.checked })
|
||||
]).then((results) => {
|
||||
const ok = results.every((r) => r.status === 'fulfilled');
|
||||
OPEN_KARMA = karmaCb.checked;
|
||||
App.toast(ok ? 'Open AP saved' : 'Some settings failed', ok ? '' : 'error');
|
||||
load();
|
||||
});
|
||||
}
|
||||
|
||||
function render() {
|
||||
const sm = state.ssidMode || 'deny';
|
||||
const cm = state.clientMode || 'deny';
|
||||
subtitle.textContent = '';
|
||||
subtitle.appendChild(document.createTextNode('The Open SSID is advertised without encryption. When client association is enabled, '));
|
||||
subtitle.appendChild(cfgLink());
|
||||
subtitle.appendChild(document.createTextNode(' ' + filterSentence(sm, cm)));
|
||||
|
||||
const hidden = hiddenCb.checked;
|
||||
const karma = karmaCb.checked;
|
||||
let t = 'The Open access point will be ' + (hidden ? 'hidden' : 'advertised');
|
||||
if (!karma) {
|
||||
t += '.';
|
||||
} else {
|
||||
if (sm === 'allow' && cm === 'allow') t += ', and clients in the allowed client filter list will be able to connect to any SSID in the allowed SSID filter.';
|
||||
else if (sm === 'allow' && cm === 'deny') t += ', and clients in the allowed client filter list will be able to connect to any SSID not blocked by the SSID filter.';
|
||||
else if (sm === 'deny' && cm === 'allow') t += ', and clients not in the denied client filter list will be able to connect to any SSID in the allowed SSID filter.';
|
||||
else t += ', and clients not in the denied client filter list will be able to connect to any SSID not blocked by the SSID filter.';
|
||||
}
|
||||
info.textContent = t;
|
||||
|
||||
boxes.innerHTML = '';
|
||||
const openSsid = ssidIn.value;
|
||||
const ssidList = state.ssidList || [];
|
||||
const clientList = state.clientList || [];
|
||||
if (state.ssidFetched && sm === 'allow' && openSsid && ssidList.indexOf(openSsid) === -1) {
|
||||
boxes.appendChild(infobox('error',
|
||||
'The open SSID "' + openSsid + '" is not included in the filter allow list, clients will not be able to connect.',
|
||||
btn('Add Allowed', () => PagerAPI.post('/api/pineap/filters/ssid', { action: 'add', value: openSsid }).then(load).catch(() => App.toast('Failed', 'error')))));
|
||||
}
|
||||
if (state.ssidFetched && sm === 'deny' && openSsid && ssidList.indexOf(openSsid) !== -1) {
|
||||
boxes.appendChild(infobox('error',
|
||||
'The open SSID "' + openSsid + '" is included in the filter deny list, clients will not be able to connect.',
|
||||
btn('Remove Filter', () => PagerAPI.post('/api/pineap/filters/ssid', { action: 'delete', value: openSsid }).then(load).catch(() => App.toast('Failed', 'error')))));
|
||||
}
|
||||
if (sm === 'allow' && ssidList.length > 0 && karmaCb.checked) {
|
||||
boxes.appendChild(infobox('info',
|
||||
'Remember to add SSIDs you wish to impersonate to the PineAP SSID filter, or change to "Deny" mode to allow responding to all requested networks!',
|
||||
filterBtn()));
|
||||
}
|
||||
if (state.clientFetched && cm === 'allow' && clientList.length === 0) {
|
||||
boxes.appendChild(infobox('error',
|
||||
'The PineAP Client filter is set to "allow", but no clients are listed; no clients will be able to connect!',
|
||||
btn('Change Mode', () => PagerAPI.post('/api/pineap/filters/client', { action: 'set_mode', mode: 'deny' }).then(load).catch(() => App.toast('Failed', 'error'))),
|
||||
filterBtn()));
|
||||
}
|
||||
}
|
||||
|
||||
function load() {
|
||||
Promise.all([
|
||||
PagerAPI.post('/api/pineap/wifi/get_ap').catch(() => ({ data: {} })),
|
||||
PagerAPI.get('/api/pineap/get_config').catch(() => ({ data: {} })),
|
||||
PagerAPI.get('/api/pineap/filters/ssid').catch(() => ({ data: {} })),
|
||||
PagerAPI.get('/api/pineap/filters/client').catch(() => ({ data: {} }))
|
||||
]).then(([ap, cfg, sf, cf]) => {
|
||||
const a = ap.data || {}, c = cfg.data || {};
|
||||
const open = a.open || {};
|
||||
ssidIn.value = open.ssid || '';
|
||||
bssidIn.value = open.bssid || '';
|
||||
if (open.channel != null) chSel.value = String(open.channel);
|
||||
if (open.country) coSel.value = open.country;
|
||||
hiddenCb.checked = !!open.hidden;
|
||||
state.enabled = !!open.enabled;
|
||||
karmaCb.checked = OPEN_KARMA;
|
||||
const sd = sf.data || {}, cd = cf.data || {};
|
||||
state.ssidFetched = !!sd.mode;
|
||||
state.clientFetched = !!cd.mode;
|
||||
state.ssidMode = sd.mode;
|
||||
state.clientMode = cd.mode;
|
||||
state.ssidList = sd.entries || [];
|
||||
state.clientList = cd.entries || [];
|
||||
render();
|
||||
});
|
||||
}
|
||||
load();
|
||||
return { destroy: () => {} };
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Append the infobox CSS to `app.css`**
|
||||
|
||||
Append to the end of `payload/user/general/pager-webui/www/css/app.css`:
|
||||
|
||||
```css
|
||||
/* ---- Open AP: Mk7 filter notice boxes ---- */
|
||||
.pineap-infobox { border-radius: 2px; padding: 10px 12px; margin-top: 10px; font-size: 13px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.pineap-infobox.error { background: #fdecea; color: #b71c1c; border: 1px solid #f5c6cb; }
|
||||
.pineap-infobox.info { background: #e3f2fd; color: #0d47a1; border: 1px solid #90caf9; }
|
||||
.pineap-infobox-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
html.dark .pineap-infobox.error { background: #4a2020; color: #ffb4a9; border-color: #6b2d2d; }
|
||||
html.dark .pineap-infobox.info { background: #10263a; color: #9cc7f0; border-color: #1d3a54; }
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the JS delimiter balance check**
|
||||
|
||||
Run: `& "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe" "C:\Users\root\AppData\Local\Temp\opencode\js_balance.py" "C:\Users\root\Documents\Pineapple\pager-webui\payload\user\general\pager-webui\www\js\views.js"`
|
||||
Expected: `...views.js: delimiter balance OK`
|
||||
|
||||
- [ ] **Step 5: Run the full unittest loop (backend must stay green)**
|
||||
|
||||
Run from repo root:
|
||||
```powershell
|
||||
$py = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe"; Get-ChildItem tests\test_*.py | ForEach-Object { $mod = "tests." + [IO.Path]::GetFileNameWithoutExtension($_.Name); $out = & $py -m unittest $mod 2>&1; ($out | Select-Object -Last 1) }
|
||||
```
|
||||
Expected: every module reports `OK` (recon may show `skipped=1`).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add payload/user/general/pager-webui/www/js/views.js payload/user/general/pager-webui/www/css/app.css
|
||||
git commit -m "feat: Mk7 PineAP Open Access Point card for Open AP tab"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Deploy and verify on device
|
||||
|
||||
**Files:** none (verification only; no commit).
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Tasks 1-2 output (deployed via `scripts/deploy.ps1`).
|
||||
|
||||
- [ ] **Step 1: Deploy the payload and restart the webui**
|
||||
|
||||
Run from `C:\Users\root\Documents\Pineapple\pager-webui`:
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\deploy.ps1 -Password "<PAGER_PASSWORD>"
|
||||
```
|
||||
Then restart and confirm the port is up (over sshpass SSH):
|
||||
```bash
|
||||
sshpass -p "<PAGER_PASSWORD>" ssh root@172.16.52.1 "/etc/init.d/pagerwebui restart; sleep 4; curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/api/api_ping"
|
||||
```
|
||||
Expected: `401` (auth required = running).
|
||||
|
||||
- [ ] **Step 2: Confirm the deployed files contain the new code**
|
||||
|
||||
```bash
|
||||
sshpass -p "<PAGER_PASSWORD>" ssh root@172.16.52.1 "grep -c 'PineAP Open Access Point' /root/payloads/user/general/pager-webui/www/js/views.js; grep -c 'pineap-infobox' /root/payloads/user/general/pager-webui/www/css/app.css; grep -c 'radio0' /root/payloads/user/general/pager-webui/server.py"
|
||||
```
|
||||
Expected: all counts greater than zero.
|
||||
|
||||
- [ ] **Step 3: On-device save-path round-trip (write current values, verify UCI, restore)**
|
||||
|
||||
Record the current `wireless.wlan0open` + `wireless.radio0` state first, then PUT the same values back through `set_ap` (idempotent), then verify and confirm the config is unchanged:
|
||||
|
||||
```bash
|
||||
sshpass -p "<PAGER_PASSWORD>" ssh root@172.16.52.1 "uci show wireless.wlan0open; uci show wireless.radio0 | grep -E 'channel|country'"
|
||||
```
|
||||
Then, over sshpass SSH, base64 a script and run it via `echo ... | base64 -d | sh` (avoids shell-quoting mangling) that:
|
||||
1. Logs in to the WebUI (`POST /api/login` with the root credentials, saves cookie).
|
||||
2. `POST /api/pineap/wifi/get_ap` → confirm the response contains `"open"` with `ssid`, `bssid`, `hidden`, `channel`, `country` keys.
|
||||
3. `POST /api/pineap/wifi/set_ap` with `{open:{ssid:<current>, bssid:<current>, hidden:<current>, enabled:true, channel:<current>, country:<current>}}` (the values just read) → expect `{"ok":true}`.
|
||||
4. `uci show wireless.wlan0open` again → confirm ssid/hidden/macaddr unchanged.
|
||||
Expected: get_ap returns the new fields; set_ap returns ok; UCI unchanged (idempotent write).
|
||||
|
||||
- [ ] **Step 4: Report for user UI walk**
|
||||
|
||||
Tell the user the Open AP tab is now the Mk7 "PineAP Open Access Point" card: Open SSID / BSSID / Channel (1-11) / Current Country / Hidden / "Respond to all probe requests (impersonate all networks)" switches, filter notice boxes with Add Allowed / Change Mode / Change Filters actions, and a Save button. Note the two documented limitations: the karma toggle is session-tracked (the daemon cannot report it), and the "SSIDs from the Spoofed SSID Pool will be advertised" clause is omitted (no readable broadcast state). Ask them to refresh `http://172.16.52.1:8080/#/pineap/open`, edit the Open SSID and Save, and confirm the toast + that `uci show wireless.wlan0open` reflects the change.
|
||||
@@ -0,0 +1,670 @@
|
||||
# PineAP Pages — Mark VII Layout 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:** Restyle the PineAP section's 8 tabs to the Mark VII `pineap-*` title-card layout (20px card titles, clickable title links, centered 24px values, full-width mode button group) without changing any behavior.
|
||||
|
||||
**Architecture:** Frontend-only. Add the Mk7 `.pineap-*` CSS vocabulary to `app.css`, add two small DOM helpers (`pineapCard`, `pineapTitleCard`) to `views.js`, then rebuild the markup of each of the 8 PineAP tab renderers to use them. All data fetches, toggles, presets, polling, and error handling are preserved verbatim — only the DOM structure/classes change.
|
||||
|
||||
**Tech Stack:** Vanilla JS (`h()` helper, `PagerAPI`, `btn`, `table`, `pineapShell`, `tabBar`), plain CSS, existing design tokens (`var(--surface)`, `var(--shadow)`, `var(--muted)`).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- No backend changes. No changes to `server.py` or `tests/`.
|
||||
- No behavior changes: every fetch, toggle, preset (`enable`+`mimic` only), `karmaOn` tracking, `modePending` guard, polling interval (5s; APs 10s), and `destroy()` stays exactly as it is today.
|
||||
- JS verification uses the Python delimiter-balance checker at `C:\Users\root\AppData\Local\Temp\opencode\js_balance.py` (no node available).
|
||||
- Python for the unittest loop: `$env:LOCALAPPDATA\Programs\Python\Python311\python.exe`.
|
||||
- Deploy: from repo root, `powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\deploy.ps1 -Password "<PAGER_PASSWORD>"`, then over sshpass restart the service (`/etc/init.d/pagerwebui restart`).
|
||||
- Commit messages follow repo style (`feat:`, `fix:`, `docs:`, `test:`).
|
||||
- The 8-tab `tabBar` remains the PineAP navigation.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Mk7 layout CSS + DOM helpers + PineAP overview rebuild
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/general/pager-webui/www/css/app.css` (append `.pineap-*` classes)
|
||||
- Modify: `payload/user/general/pager-webui/www/js/views.js` (helpers + full `views.pineap` replacement)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `h(tag, attrs, ...children)` (supports `class`, `text`, `on*` handlers), `btn(label, onclk, cls)`, `PagerAPI`, `App.go`, `App.toast`, `pineapShell(root, hash)`, `tabBar(box, items, hash)`, `table(columns, rows, rowAttrs)`.
|
||||
- Produces: `pineapCard(title)` and `pineapTitleCard(titleText, linkHash, valueNode)` helpers used by Tasks 2-3; `.pineap-*` CSS classes; a rebuilt `views.pineap` overview.
|
||||
|
||||
- [ ] **Step 1: Append the Mk7 layout CSS to `app.css`**
|
||||
|
||||
Append to the end of `payload/user/general/pager-webui/www/css/app.css`:
|
||||
|
||||
```css
|
||||
/* ---- PineAP Mark VII layout ---- */
|
||||
.pineap-title-card-container { display: flex; width: 100%; flex-wrap: wrap; justify-content: space-between; gap: 30px; margin: 8px 0 16px; }
|
||||
.pineap-title-card { flex: 1; min-width: 220px; background: var(--surface); border-radius: 2px; box-shadow: var(--shadow); padding: 14px 16px; margin-bottom: 16px; }
|
||||
.pineap-card-title { font-size: 20px; display: flex; align-items: center; margin-bottom: 10px; }
|
||||
.pineap-card-title-flex { display: flex; align-items: center; font-size: 20px; margin-bottom: 15px; }
|
||||
.pineap-card-title-link { color: inherit; text-decoration: none; cursor: pointer; }
|
||||
.pineap-card-title-link:visited { color: inherit; }
|
||||
.pineap-card-title-link:hover { text-decoration: underline; }
|
||||
.pineap-card-title-content { display: flex; justify-content: center; align-items: center; font-size: 24px; }
|
||||
.pineap-card-button-group { width: 100%; height: 30px; display: flex; }
|
||||
.pineap-card-button-group .seg { flex: 1; height: 100%; }
|
||||
.pineap-card-button-group .seg-btn { 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); }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the two DOM helpers and remove the now-unused `pineapSetCard`**
|
||||
|
||||
In `payload/user/general/pager-webui/www/js/views.js`, replace the `pineapSetCard` helper (currently lines ~182-185) with:
|
||||
|
||||
```js
|
||||
function pineapCard(title) {
|
||||
return h('div', { class: 'pineap-title-card' },
|
||||
h('div', { class: 'pineap-card-title' }, title));
|
||||
}
|
||||
|
||||
function pineapTitleCard(titleText, linkHash, valueNode) {
|
||||
const title = linkHash
|
||||
? h('a', { class: 'pineap-card-title-link', onclick: (e) => { e.preventDefault(); App.go(linkHash); } }, titleText)
|
||||
: titleText;
|
||||
return h('div', { class: 'pineap-title-card' },
|
||||
h('div', { class: 'pineap-card-title' }, title),
|
||||
h('div', { class: 'pineap-card-title-content' }, valueNode));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Replace the whole `views.pineap` function**
|
||||
|
||||
Replace everything from `views.pineap = (root) => {` through the closing `};` of that function (current lines ~187-328, i.e. just before `views.pineap_open`) with:
|
||||
|
||||
```js
|
||||
views.pineap = (root) => {
|
||||
const box = pineapShell(root, '#/pineap');
|
||||
|
||||
const stats = {};
|
||||
const statWrap = h('div', { class: 'pineap-title-card-container' });
|
||||
const statDefs = [
|
||||
['ssids', 'Total SSIDs in Pool', '#/pineap/impersonation'],
|
||||
['clients', 'Clients Connected', '#/pineap/clients'],
|
||||
['handshakes', 'Handshakes Captured', '#/pineap/evilwpa']
|
||||
];
|
||||
statDefs.forEach(([k, label, hash]) => {
|
||||
const val = h('span', { text: '—' });
|
||||
stats[k] = val;
|
||||
statWrap.appendChild(pineapTitleCard(label, hash, val));
|
||||
});
|
||||
box.appendChild(statWrap);
|
||||
|
||||
const mode = h('span', { class: 'badge', text: '—' });
|
||||
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', () => applyMode(m));
|
||||
modeBar.appendChild(b);
|
||||
segBtns[m] = b;
|
||||
});
|
||||
const modeInfo = h('div', { class: 'muted', style: 'margin-top:8px;font-size:12px' });
|
||||
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);
|
||||
|
||||
const quick = {
|
||||
collect: h('input', { type: 'checkbox', id: 'po-collect' }),
|
||||
advertise: h('input', { type: 'checkbox', id: 'po-advertise' })
|
||||
};
|
||||
const quickCard = h('div', { class: 'pineap-title-card pineap-card-settings' },
|
||||
h('div', { class: 'pineap-card-title' }, 'Quick Settings'));
|
||||
quickCard.appendChild(h('label', { class: 'toggle' }, quick.collect, ' Capture SSIDs to Pool'));
|
||||
quickCard.appendChild(h('label', { class: 'toggle' }, quick.advertise, ' Advertise AP Impersonation Pool'));
|
||||
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.'));
|
||||
|
||||
const modeRow = h('div', { class: 'pineap-title-card-container' });
|
||||
modeRow.appendChild(modeCard);
|
||||
modeRow.appendChild(quickCard);
|
||||
box.appendChild(modeRow);
|
||||
|
||||
const cards = { karma: {}, open: {}, wpa: {}, ent: {} };
|
||||
const cardWrap = h('div', { class: 'pineap-title-card-container' });
|
||||
const statusDefs = [
|
||||
['karma', 'Karma', '#/pineap/open'],
|
||||
['open', 'Open Network', '#/pineap/open'],
|
||||
['wpa', 'Evil WPA', '#/pineap/evilwpa'],
|
||||
['ent', 'Evil Enterprise', '#/pineap/enterprise']
|
||||
];
|
||||
statusDefs.forEach(([k, label, hash]) => {
|
||||
const val = h('span', { text: '—' });
|
||||
cards[k].value = val;
|
||||
cardWrap.appendChild(pineapTitleCard(label, hash, val));
|
||||
});
|
||||
box.appendChild(cardWrap);
|
||||
|
||||
let karmaOn = null;
|
||||
|
||||
function bind(cb, on) {
|
||||
cb.addEventListener('change', () => on(cb.checked).then(load).catch(() => { cb.checked = !cb.checked; App.toast('Failed', 'error'); }));
|
||||
}
|
||||
bind(quick.collect, (v) => PagerAPI.post('/api/pineap/ssidpool/collect', { enable: v }));
|
||||
bind(quick.advertise, (v) => PagerAPI.post('/api/pineap/ssidpool/advertise', { enable: v }));
|
||||
|
||||
function setMode(m, disabled) {
|
||||
Object.keys(segBtns).forEach((k) => segBtns[k].classList.toggle('active', k === m));
|
||||
modeInfo.textContent = disabled
|
||||
? 'PineAP is off. Enable it from the Open AP tab or a mode preset to begin impersonating networks.'
|
||||
: {
|
||||
passive: 'PineAP is on; network impersonation (Karma) is off.',
|
||||
active: 'PineAP is on; Karma is expected to be enabled, impersonating open networks.',
|
||||
advanced: 'All PineAP features are enabled and customizable.'
|
||||
}[m] || '';
|
||||
}
|
||||
|
||||
let modePending = false;
|
||||
function applyMode(m) {
|
||||
const btn = segBtns[m];
|
||||
if (!btn || btn.classList.contains('active') || modePending) return;
|
||||
const on = m !== 'passive';
|
||||
modePending = true;
|
||||
btn.classList.add('busy');
|
||||
Promise.all([
|
||||
PagerAPI.post('/api/pineap/enable', { enable: true }),
|
||||
PagerAPI.post('/api/pineap/mimic', { enable: on })
|
||||
]).then(() => {
|
||||
karmaOn = on;
|
||||
setMode(m);
|
||||
App.toast('Mode: ' + m[0].toUpperCase() + m.slice(1));
|
||||
load();
|
||||
}).catch(() => { karmaOn = null; App.toast('Failed', 'error'); })
|
||||
.finally(() => { modePending = false; btn.classList.remove('busy'); });
|
||||
}
|
||||
|
||||
function load() {
|
||||
Promise.all([
|
||||
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.get('/api/pineap/ssids').catch(() => ({ data: {} })),
|
||||
PagerAPI.get('/api/pineap/clients').catch(() => ({ data: {} })),
|
||||
PagerAPI.get('/api/pineap/handshakes').catch(() => ({ data: {} }))
|
||||
]).then(([cfg, host, ap, ss, cl, hs]) => {
|
||||
const c = cfg.data || {}, hh = host.data || {}, a = ap.data || {};
|
||||
const disabled = !!hh.pineap_disabled;
|
||||
const wpa = a.wpa || {}, ent = a.enterprise || {};
|
||||
const advanced = !disabled && (wpa.enabled || ent.enabled);
|
||||
const computed = disabled ? 'passive' : (karmaOn === false ? 'passive' : (advanced ? 'advanced' : 'active'));
|
||||
mode.textContent = computed[0].toUpperCase() + computed.slice(1);
|
||||
mode.className = 'badge ' + (disabled ? 'off' : 'on');
|
||||
setMode(computed, disabled);
|
||||
quick.collect.checked = !!c.autossidpool;
|
||||
const pool = a.pool || {};
|
||||
quick.advertise.checked = pool.disabled === false;
|
||||
stats.ssids.textContent = (ss.data && Array.isArray(ss.data.ssids)) ? ss.data.ssids.length : '—';
|
||||
stats.clients.textContent = (cl.data && typeof cl.data.count === 'number') ? cl.data.count : '—';
|
||||
stats.handshakes.textContent = (hs.data && Array.isArray(hs.data.files)) ? hs.data.files.length : '—';
|
||||
cards.karma.value.textContent = karmaOn == null ? '—' : (karmaOn ? 'On' : 'Off');
|
||||
const open = a.open || {};
|
||||
cards.open.value.textContent = open.enabled == null ? '—' : (open.enabled ? 'On' : 'Off');
|
||||
cards.wpa.value.textContent = wpa.enabled ? 'On' : 'Off';
|
||||
cards.ent.value.textContent = ent.enabled ? 'On' : 'Off';
|
||||
});
|
||||
}
|
||||
load();
|
||||
const iv = setInterval(load, 5000);
|
||||
return { destroy: () => clearInterval(iv) };
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the JS delimiter balance check**
|
||||
|
||||
Run: `& "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe" "C:\Users\root\AppData\Local\Temp\opencode\js_balance.py" "C:\Users\root\Documents\Pineapple\pager-webui\payload\user\general\pager-webui\www\js\views.js"`
|
||||
Expected: `...views.js: delimiter balance OK`
|
||||
|
||||
- [ ] **Step 5: Run the 13-module unittest loop (backend must stay green)**
|
||||
|
||||
Run from `C:\Users\root\Documents\Pineapple\pager-webui`:
|
||||
```powershell
|
||||
$py = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe"; Get-ChildItem tests\test_*.py | ForEach-Object { $mod = "tests." + [IO.Path]::GetFileNameWithoutExtension($_.Name); $out = & $py -m unittest $mod 2>&1; ($out | Select-Object -Last 1) }
|
||||
```
|
||||
Expected: every module reports `OK` (recon may show `skipped=1`).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add payload/user/general/pager-webui/www/css/app.css payload/user/general/pager-webui/www/js/views.js
|
||||
git commit -m "feat: Mark VII title-card layout for PineAP overview"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Open AP, Evil WPA, Enterprise tabs
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/general/pager-webui/www/js/views.js` (`views.pineap_open`, `views.pineap_evilwpa`, `views.pineap_enterprise`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `pineapCard(title)` (Task 1), `h()`, `btn()`, `table()`, `PagerAPI`, `App.toast`, `EVIL_ENC`, `pineapShell`.
|
||||
- Produces: the three tab renderers rebuilt on `.pineap-title-card` anatomy. `views.pineap_enterprise.tableBox` now returns `{ body, endpoint }` where `body` is a child div (not the card itself) — the `load()` body-clearing code keeps using `t.body`.
|
||||
|
||||
- [ ] **Step 1: Replace `views.pineap_open`**
|
||||
|
||||
Replace the whole function (current lines ~330-377) with the same code except the `wrap` construction — change:
|
||||
|
||||
```js
|
||||
const wrap = h('div', { class: 'section' }, h('h2', {}, 'Open AP'));
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```js
|
||||
const wrap = h('div', { class: 'pineap-title-card pineap-card-settings' },
|
||||
h('div', { class: 'pineap-card-title' }, 'Open AP'));
|
||||
```
|
||||
|
||||
Everything else in `views.pineap_open` (the `defs` array, the `forEach`, the `info` line, `saveCfg`, `load`) stays byte-for-byte identical.
|
||||
|
||||
- [ ] **Step 2: Replace `views.pineap_evilwpa`**
|
||||
|
||||
Replace the whole function (current lines ~383-450) with:
|
||||
|
||||
```js
|
||||
views.pineap_evilwpa = (root) => {
|
||||
const box = pineapShell(root, '#/pineap/evilwpa');
|
||||
const cfg = h('div', { class: 'pineap-title-card' },
|
||||
h('div', { class: 'pineap-card-title' }, 'Evil WPA'));
|
||||
box.appendChild(cfg);
|
||||
const ssidIn = h('input', { id: 'ew-ssid' });
|
||||
const pskIn = h('input', { id: 'ew-psk' });
|
||||
const encSel = h('select', { id: 'ew-enc' });
|
||||
EVIL_ENC.forEach(([v, l]) => encSel.appendChild(h('option', { value: v, text: l })));
|
||||
const hiddenCb = h('input', { type: 'checkbox', id: 'ew-hidden' });
|
||||
const enabledCb = h('input', { type: 'checkbox', id: 'ew-enabled' });
|
||||
cfg.appendChild(h('label', {}, 'SSID', ssidIn));
|
||||
cfg.appendChild(h('label', {}, 'Passphrase', pskIn));
|
||||
cfg.appendChild(h('label', {}, 'Encryption', encSel));
|
||||
cfg.appendChild(h('label', { class: 'toggle' }, hiddenCb, ' Hidden'));
|
||||
cfg.appendChild(h('label', { class: 'toggle' }, enabledCb, ' Enabled'));
|
||||
cfg.appendChild(h('div', { class: 'row' },
|
||||
h('div', {}, btn('Save', () => {
|
||||
PagerAPI.post('/api/pineap/wifi/set_ap', {
|
||||
wpa: { ssid: ssidIn.value, passphrase: pskIn.value, enctype: encSel.value,
|
||||
hidden: hiddenCb.checked, enabled: enabledCb.checked }
|
||||
}).then(() => { App.toast('Evil WPA saved'); load(); }).catch(() => App.toast('Failed', 'error'));
|
||||
})),
|
||||
h('div', { class: 'muted', style: 'align-self:center;font-size:12px' }, 'Applying reconfigures the radio — you may be disconnected briefly.')));
|
||||
|
||||
const capBox = h('div', { class: 'pineap-title-card' },
|
||||
h('div', { class: 'pineap-card-title' }, 'Handshake Capture'));
|
||||
box.appendChild(capBox);
|
||||
const bssidIn = h('input', { id: 'ew-bssid', placeholder: 'BSSID' });
|
||||
const secsIn = h('input', { id: 'ew-secs', type: 'number', value: '30', style: 'max-width:80px' });
|
||||
capBox.appendChild(h('div', { class: 'row' },
|
||||
h('div', {}, h('label', {}, 'BSSID', bssidIn)),
|
||||
h('div', {}, h('label', {}, 'Seconds', secsIn)),
|
||||
h('div', {}, btn('Examine', () => {
|
||||
const b = bssidIn.value.trim();
|
||||
if (!b) { App.toast('BSSID required', 'error'); return; }
|
||||
PagerAPI.post('/api/pineap/examine', { bssid: b, seconds: parseInt(secsIn.value, 10) || 30 })
|
||||
.then(() => App.toast('Examining ' + b)).catch(() => App.toast('Failed', 'error'));
|
||||
})),
|
||||
h('div', {}, btn('Stop', () => PagerAPI.post('/api/pineap/examine', { reset: true }).then(() => App.toast('Stopped')), 'danger'))));
|
||||
|
||||
const hsBody = h('div', {});
|
||||
const hsBox = h('div', { class: 'pineap-title-card pineap-card-handshakes' },
|
||||
h('div', { class: 'pineap-card-title' }, 'Captured Handshakes'),
|
||||
hsBody);
|
||||
box.appendChild(hsBox);
|
||||
|
||||
function load() {
|
||||
PagerAPI.post('/api/pineap/wifi/get_ap').then((r) => {
|
||||
const w = (r.data || {}).wpa || {};
|
||||
ssidIn.value = w.ssid || '';
|
||||
pskIn.value = w.passphrase || '';
|
||||
if (w.enctype) encSel.value = w.enctype;
|
||||
hiddenCb.checked = !!w.hidden;
|
||||
enabledCb.checked = !!w.enabled;
|
||||
}).catch(() => {});
|
||||
PagerAPI.get('/api/pineap/handshakes').then((r) => {
|
||||
hsBody.innerHTML = '';
|
||||
const rows = (r.data.handshakes || []).map((x) => ({
|
||||
name: x.name || '--', ap: x.ap || '--', client: x.client || '--', type: x.type || '--'
|
||||
}));
|
||||
hsBody.appendChild(table(
|
||||
[{ label: 'File', key: 'name' }, { label: 'AP', key: 'ap' },
|
||||
{ label: 'Client', key: 'client' }, { label: 'Type', key: 'type' }],
|
||||
rows));
|
||||
if (!rows.length) hsBody.appendChild(h('div', { class: 'pineap-handshakes-none', text: 'No handshakes captured yet.' }));
|
||||
}).catch(() => {});
|
||||
}
|
||||
load();
|
||||
const iv = setInterval(load, 5000);
|
||||
return { destroy: () => clearInterval(iv) };
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Replace `views.pineap_enterprise`**
|
||||
|
||||
Replace the whole function (current lines ~452-494) with:
|
||||
|
||||
```js
|
||||
views.pineap_enterprise = (root) => {
|
||||
const box = pineapShell(root, '#/pineap/enterprise');
|
||||
const cfg = h('div', { class: 'pineap-title-card' },
|
||||
h('div', { class: 'pineap-card-title' }, 'Evil Enterprise'));
|
||||
box.appendChild(cfg);
|
||||
const enabledCb = h('input', { type: 'checkbox', id: 'ee-enabled' });
|
||||
const authCb = h('input', { type: 'checkbox', id: 'ee-auth' });
|
||||
cfg.appendChild(h('label', { class: 'toggle' }, enabledCb, ' Enabled'));
|
||||
cfg.appendChild(h('label', { class: 'toggle' }, authCb, ' Auth Pass Capture'));
|
||||
enabledCb.addEventListener('change', () => PagerAPI.post('/api/pineap/hostapd', { pineape_disabled: !enabledCb.checked }).then(load).catch(() => { enabledCb.checked = !enabledCb.checked; App.toast('Failed', 'error'); }));
|
||||
authCb.addEventListener('change', () => PagerAPI.post('/api/pineap/hostapd', { pineape_auth_pass: authCb.checked }).then(load).catch(() => { authCb.checked = !authCb.checked; App.toast('Failed', 'error'); }));
|
||||
|
||||
function tableBox(name, endpoint, clearTable) {
|
||||
const body = h('div', {});
|
||||
const tb = h('div', { class: 'pineap-title-card pineap-card-inject' },
|
||||
h('div', { class: 'pineap-card-title-flex' },
|
||||
h('span', { text: name }),
|
||||
h('span', { class: 'toolbar-spacer' }),
|
||||
btn('Clear', () => PagerAPI.post('/api/pineap/enterprise/clear', { table: clearTable }).then(load), 'danger')),
|
||||
body);
|
||||
box.appendChild(tb);
|
||||
return { body, endpoint };
|
||||
}
|
||||
const basic = tableBox('Basic Data', '/api/pineap/enterprise/basic', 'basic');
|
||||
const chall = tableBox('Challenge Data', '/api/pineap/enterprise/challenge', 'challenge');
|
||||
|
||||
function load() {
|
||||
PagerAPI.get('/api/pineap/hostapd').then((r) => {
|
||||
const hh = r.data || {};
|
||||
enabledCb.checked = !hh.pineape_disabled;
|
||||
authCb.checked = !!hh.pineape_auth_pass;
|
||||
}).catch(() => {});
|
||||
[basic, chall].forEach((t) => {
|
||||
PagerAPI.get(t.endpoint).then((r) => {
|
||||
const rows = (r.data.rows || []).slice();
|
||||
t.body.innerHTML = '';
|
||||
const cols = rows.length ? Object.keys(rows[0]).map((k) => ({ label: k, key: k }))
|
||||
: [{ label: '—', key: '_none' }];
|
||||
t.body.appendChild(table(cols, rows));
|
||||
if (!rows.length) t.body.appendChild(h('div', { class: 'empty', text: 'No data captured.' }));
|
||||
}).catch(() => {});
|
||||
});
|
||||
}
|
||||
load();
|
||||
const iv = setInterval(load, 5000);
|
||||
return { destroy: () => clearInterval(iv) };
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the JS delimiter balance check**
|
||||
|
||||
Run: `& "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe" "C:\Users\root\AppData\Local\Temp\opencode\js_balance.py" "C:\Users\root\Documents\Pineapple\pager-webui\payload\user\general\pager-webui\www\js\views.js"`
|
||||
Expected: `...views.js: delimiter balance OK`
|
||||
|
||||
- [ ] **Step 5: Run the 13-module unittest loop**
|
||||
|
||||
Run from `C:\Users\root\Documents\Pineapple\pager-webui`:
|
||||
```powershell
|
||||
$py = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe"; Get-ChildItem tests\test_*.py | ForEach-Object { $mod = "tests." + [IO.Path]::GetFileNameWithoutExtension($_.Name); $out = & $py -m unittest $mod 2>&1; ($out | Select-Object -Last 1) }
|
||||
```
|
||||
Expected: every module reports `OK` (recon may show `skipped=1`).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add payload/user/general/pager-webui/www/js/views.js
|
||||
git commit -m "feat: Mark VII layout for Open AP, Evil WPA, Enterprise tabs"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Impersonation, Clients, Filtering, APs tabs
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/general/pager-webui/www/js/views.js` (`views.pineap_impersonation`, `views.pineap_clients`, `views.pineap_filtering`, `views.pineap_aps`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `pineapCard`/`pineapTitleCard` (Task 1), `h()`, `btn()`, `table()`, `PagerAPI`, `App.toast`, `pineapShell`.
|
||||
- Produces: the four tab renderers rebuilt. `views.pineap_impersonation` gains a `poolCount` span (updated in `render()`); `views.pineap_clients` gains a `count` span (updated in `load()`).
|
||||
|
||||
- [ ] **Step 1: Replace `views.pineap_impersonation`**
|
||||
|
||||
Replace the whole function (current lines ~496-536) with:
|
||||
|
||||
```js
|
||||
views.pineap_impersonation = (root) => {
|
||||
const box = pineapShell(root, '#/pineap/impersonation');
|
||||
const poolCount = h('span', { text: '—' });
|
||||
const countRow = h('div', { class: 'pineap-title-card-container' },
|
||||
pineapTitleCard('Total SSIDs in Pool', '#/pineap/impersonation', poolCount));
|
||||
box.appendChild(countRow);
|
||||
|
||||
const input = h('input', { id: 'imp-ssid' });
|
||||
const list = h('div', {});
|
||||
const advCb = h('input', { type: 'checkbox', id: 'imp-advertise' });
|
||||
const colCb = h('input', { type: 'checkbox', id: 'imp-collect' });
|
||||
const poolBox = h('div', { class: 'pineap-title-card pineap-card-pool' },
|
||||
h('div', { class: 'pineap-card-title' }, 'SSID Pool'));
|
||||
poolBox.appendChild(h('div', { class: 'row' },
|
||||
h('div', {}, h('label', {}, 'SSID', input)),
|
||||
h('div', {}, btn('Add', () => {
|
||||
const v = input.value.trim(); if (!v) return;
|
||||
PagerAPI.post('/api/pineap/ssids', { action: 'add', ssid: v }).then((r) => { input.value = ''; render(r.data.ssids); App.toast('Added'); });
|
||||
})),
|
||||
h('div', {}, btn('Clear', () => PagerAPI.post('/api/pineap/ssids', { action: 'clear' }).then((r) => render(r.data.ssids)), 'danger'))));
|
||||
poolBox.appendChild(h('label', { class: 'toggle' }, advCb, ' Advertise AP Impersonation Pool'));
|
||||
poolBox.appendChild(h('label', { class: 'toggle' }, colCb, ' Capture SSIDs to Pool'));
|
||||
poolBox.appendChild(list);
|
||||
box.appendChild(poolBox);
|
||||
advCb.addEventListener('change', () => PagerAPI.post('/api/pineap/ssidpool/advertise', { enable: advCb.checked }).then(load).catch(() => { advCb.checked = !advCb.checked; App.toast('Failed', 'error'); }));
|
||||
colCb.addEventListener('change', () => PagerAPI.post('/api/pineap/ssidpool/collect', { enable: colCb.checked }).then(load).catch(() => { colCb.checked = !colCb.checked; App.toast('Failed', 'error'); }));
|
||||
|
||||
function render(ssids) {
|
||||
poolCount.textContent = Array.isArray(ssids) ? ssids.length : 0;
|
||||
list.innerHTML = '';
|
||||
list.appendChild(table(
|
||||
[{ label: 'SSID', key: 'ssid' }, { label: '', render: () => '' }],
|
||||
(ssids || []).map((s) => ({ ssid: s })),
|
||||
(r) => ({ onclick: () => { if (confirm('Remove ' + r.ssid + '?')) PagerAPI.post('/api/pineap/ssids', { action: 'remove', ssid: r.ssid }).then((x) => render(x.data.ssids)); } })));
|
||||
list.querySelectorAll('.tbl th').forEach((th, i) => { if (i === 1) th.textContent = 'Remove'; });
|
||||
if (!ssids || !ssids.length) list.appendChild(h('div', { class: 'empty', text: 'No SSIDs in pool.' }));
|
||||
}
|
||||
function load() {
|
||||
PagerAPI.get('/api/pineap/ssids').then((r) => render(r.data.ssids)).catch(() => {});
|
||||
PagerAPI.post('/api/pineap/wifi/get_ap').then((r) => {
|
||||
const p = (r.data || {}).pool || {};
|
||||
advCb.checked = p.disabled === false;
|
||||
colCb.checked = !!p.collecting;
|
||||
}).catch(() => {});
|
||||
}
|
||||
load();
|
||||
return { destroy: () => {} };
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace `views.pineap_clients`**
|
||||
|
||||
Replace the whole function (current lines ~538-561) with:
|
||||
|
||||
```js
|
||||
views.pineap_clients = (root) => {
|
||||
const box = pineapShell(root, '#/pineap/clients');
|
||||
const state = { clients: [] };
|
||||
const count = h('span', { text: '—' });
|
||||
const countRow = h('div', { class: 'pineap-title-card-container' },
|
||||
pineapTitleCard('Clients Connected', '#/pineap/clients', count));
|
||||
box.appendChild(countRow);
|
||||
|
||||
const body = h('div', {});
|
||||
const tableCard = h('div', { class: 'pineap-title-card' },
|
||||
h('div', { class: 'pineap-card-title-flex' },
|
||||
h('span', { text: 'Connected Clients' }),
|
||||
h('span', { class: 'toolbar-spacer' }),
|
||||
btn('Refresh', load, 'ghost')),
|
||||
body);
|
||||
box.appendChild(tableCard);
|
||||
|
||||
function render() {
|
||||
body.innerHTML = '';
|
||||
body.appendChild(table(
|
||||
[{ label: 'MAC', key: 'mac' }, { label: 'Interface', key: 'iface' },
|
||||
{ label: 'RSSI', key: 'rssi' }, { label: '', render: () => '' }],
|
||||
state.clients,
|
||||
(r) => ({ style: 'cursor:pointer',
|
||||
onclick: () => { if (confirm('Kick ' + r.mac + '?')) PagerAPI.post('/api/pineap/clients/kick', { mac: r.mac }).then(() => App.toast('Kicked')).then(load); } })));
|
||||
const cols = ['MAC', 'Interface', 'RSSI'];
|
||||
body.querySelectorAll('.tbl th').forEach((th, i) => { if (i >= cols.length) th.textContent = 'Kick'; });
|
||||
}
|
||||
function load() {
|
||||
PagerAPI.get('/api/pineap/clients').then((r) => {
|
||||
state.clients = r.data.clients;
|
||||
count.textContent = (r.data && typeof r.data.count === 'number') ? r.data.count : state.clients.length;
|
||||
render();
|
||||
});
|
||||
}
|
||||
load();
|
||||
const iv = setInterval(load, 5000);
|
||||
return { destroy: () => clearInterval(iv) };
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Replace `views.pineap_filtering`**
|
||||
|
||||
Replace the whole function (current lines ~563-603) with:
|
||||
|
||||
```js
|
||||
views.pineap_filtering = (root) => {
|
||||
const box = pineapShell(root, '#/pineap/filtering');
|
||||
function filterCard(title) {
|
||||
const body = h('div', {});
|
||||
const card = h('div', { class: 'pineap-title-card' },
|
||||
h('div', { class: 'pineap-card-title' }, title),
|
||||
body);
|
||||
box.appendChild(card);
|
||||
return body;
|
||||
}
|
||||
const cfBox = filterCard('Client Filter');
|
||||
const sfBox = filterCard('SSID Filter');
|
||||
function renderFilter(dom, kind) {
|
||||
dom.innerHTML = '';
|
||||
const path = '/api/pineap/filters/' + kind;
|
||||
const modeSel = h('select', { id: 'fm-' + kind },
|
||||
h('option', { value: 'allow', text: 'Allow list' }),
|
||||
h('option', { value: 'deny', text: 'Deny list' }));
|
||||
const valueIn = h('input', { id: 'fv-' + kind });
|
||||
dom.appendChild(h('div', { class: 'row' },
|
||||
h('div', {}, h('label', {}, 'Mode', modeSel)),
|
||||
h('div', {}, h('label', {}, 'Value', valueIn)),
|
||||
h('div', {}, btn('Add', () => {
|
||||
const v = document.getElementById('fv-' + kind).value.trim();
|
||||
if (!v) return;
|
||||
PagerAPI.post(path, { action: 'add', value: v }).then(() => refresh()).catch(() => App.toast('Failed', 'error'));
|
||||
})),
|
||||
h('div', {}, btn('Clear', () => PagerAPI.post(path, { action: 'clear' }).then(refresh), 'danger'))));
|
||||
modeSel.addEventListener('change', () => PagerAPI.post(path, { action: 'set_mode', mode: modeSel.value }).then(refresh));
|
||||
const list = h('div', {});
|
||||
dom.appendChild(list);
|
||||
PagerAPI.get(path).then((r) => {
|
||||
modeSel.value = r.data.mode;
|
||||
list.innerHTML = '';
|
||||
list.appendChild(table(
|
||||
[{ label: kind === 'client' ? 'MAC' : 'SSID', key: 'value' }, { label: '', render: () => '' }],
|
||||
(r.data.entries || []).map((e) => ({ value: e })),
|
||||
(row) => ({ onclick: () => { if (confirm('Delete ' + row.value + '?')) PagerAPI.post(path, { action: 'delete', value: row.value }).then(refresh); } })));
|
||||
list.querySelectorAll('.tbl th').forEach((th, i) => { if (i === 1) th.textContent = 'Delete'; });
|
||||
if (!r.data.entries || !r.data.entries.length) list.appendChild(h('div', { class: 'pineap-handshakes-none', text: 'No entries.' }));
|
||||
}).catch(() => {});
|
||||
}
|
||||
function refresh() { renderFilter(cfBox, 'client'); renderFilter(sfBox, 'ssid'); }
|
||||
refresh();
|
||||
return { destroy: () => {} };
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Replace `views.pineap_aps`**
|
||||
|
||||
Replace the whole function (current lines ~605-631) with:
|
||||
|
||||
```js
|
||||
views.pineap_aps = (root) => {
|
||||
const box = pineapShell(root, '#/pineap/aps');
|
||||
const body = h('div', {});
|
||||
const b = h('div', { class: 'pineap-title-card pineap-card-inject' },
|
||||
h('div', { class: 'pineap-card-title-flex' },
|
||||
h('span', { text: 'Access Points' }),
|
||||
h('span', { class: 'toolbar-spacer' }),
|
||||
btn('Refresh', load, 'ghost')),
|
||||
body);
|
||||
box.appendChild(b);
|
||||
function load() {
|
||||
body.innerHTML = '';
|
||||
PagerAPI.get('/api/pineap/aps').then((r) => {
|
||||
const rows = (r.data.aps || []).map((a) => ({
|
||||
bssid: a.bssid || '--', ssid: a.ssid || '--',
|
||||
channel: a.channel == null ? '--' : a.channel,
|
||||
signal: a.signal == null ? '--' : a.signal + ' dBm',
|
||||
encryption: a.encryption || '--', iface: a.iface || '--'
|
||||
}));
|
||||
body.appendChild(table(
|
||||
[{ label: 'BSSID', key: 'bssid' }, { label: 'SSID', key: 'ssid' },
|
||||
{ label: 'Channel', key: 'channel' }, { label: 'Signal', key: 'signal' },
|
||||
{ label: 'Encryption', key: 'encryption' }, { label: 'Interface', key: 'iface' }],
|
||||
rows));
|
||||
if (!rows.length) body.appendChild(h('div', { class: 'pineap-handshakes-none', text: 'No access points found.' }));
|
||||
}).catch(() => body.appendChild(h('div', { class: 'pineap-handshakes-none', text: 'Scan failed.' })));
|
||||
}
|
||||
load();
|
||||
const iv = setInterval(load, 10000);
|
||||
return { destroy: () => clearInterval(iv) };
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the JS delimiter balance check**
|
||||
|
||||
Run: `& "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe" "C:\Users\root\AppData\Local\Temp\opencode\js_balance.py" "C:\Users\root\Documents\Pineapple\pager-webui\payload\user\general\pager-webui\www\js\views.js"`
|
||||
Expected: `...views.js: delimiter balance OK`
|
||||
|
||||
- [ ] **Step 6: Run the 13-module unittest loop**
|
||||
|
||||
Run from `C:\Users\root\Documents\Pineapple\pager-webui`:
|
||||
```powershell
|
||||
$py = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe"; Get-ChildItem tests\test_*.py | ForEach-Object { $mod = "tests." + [IO.Path]::GetFileNameWithoutExtension($_.Name); $out = & $py -m unittest $mod 2>&1; ($out | Select-Object -Last 1) }
|
||||
```
|
||||
Expected: every module reports `OK` (recon may show `skipped=1`).
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add payload/user/general/pager-webui/www/js/views.js
|
||||
git commit -m "feat: Mark VII layout for Impersonation, Clients, Filtering, APs tabs"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Deploy and verify on device
|
||||
|
||||
**Files:** none (verification only; no commit).
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Tasks 1-3 output (deployed via `scripts/deploy.ps1`).
|
||||
|
||||
- [ ] **Step 1: Deploy the payload and restart the webui**
|
||||
|
||||
Run from `C:\Users\root\Documents\Pineapple\pager-webui`:
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\deploy.ps1 -Password "<PAGER_PASSWORD>"
|
||||
```
|
||||
Then restart and confirm the port is up (over sshpass SSH):
|
||||
```bash
|
||||
sshpass -p "<PAGER_PASSWORD>" ssh root@172.16.52.1 "/etc/init.d/pagerwebui restart; sleep 4; curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/api/api_ping"
|
||||
```
|
||||
Expected: `401` (auth required = running).
|
||||
|
||||
- [ ] **Step 2: Confirm the deployed files contain the new classes**
|
||||
|
||||
```bash
|
||||
sshpass -p "<PAGER_PASSWORD>" ssh root@172.16.52.1 "grep -c 'pineap-title-card' /root/payloads/user/general/pager-webui/www/css/app.css; grep -c 'pineapTitleCard' /root/payloads/user/general/pager-webui/www/js/views.js"
|
||||
```
|
||||
Expected: both counts greater than zero.
|
||||
|
||||
- [ ] **Step 3: Report for user UI walk**
|
||||
|
||||
Tell the user all 8 PineAP tabs now use the Mark VII `pineap-*` title-card layout: the overview has three rows of title cards (stats with clickable title links → mode/quick-settings → status cards), the other tabs use 20px title cards with the `.pineap-card-title-flex` action rows and `.pineap-handshakes-none` empty states. Ask them to refresh `http://172.16.52.1:8080/#/pineap` and walk the tabs to confirm.
|
||||
@@ -0,0 +1,252 @@
|
||||
# PineAP Overview — Mark VII Stats + Mode Toggle 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:** Bring the Pager WebUI PineAP overview (`#/pineap`) to Mark VII parity — a clickable 3-card stats row (Total SSIDs in Pool / Clients Connected / Handshakes Captured) and a Passive/Active/Advanced quick mode toggle — using only functionality the Pager supports.
|
||||
|
||||
**Architecture:** Frontend-only change to `views.pineap` in `www/js/views.js` plus a small `.seg` segmented-control style in `www/css/app.css`. All data comes from existing, on-device-verified webui endpoints; the backend and test modules are untouched. Karma (mimic) state is unreadable from the daemon, so it is tracked in a view-local variable.
|
||||
|
||||
**Tech Stack:** Vanilla JS (hyperscript `h()` helper), existing `PagerAPI` client, existing `.cards`/`.card`/`.badge`/`.toggle` CSS, plain CSS additions.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- No backend changes. No changes to `server.py` or `tests/`.
|
||||
- Karma state is NOT readable from the daemon (only `mimic/enable|disable`); track it client-side as `karmaOn` (null = unknown, defaults to `null` on page load).
|
||||
- Mode presets only apply Pager-supported features: `POST /api/pineap/enable` and `POST /api/pineap/mimic`. Do NOT touch `ssidpool/*` (broadcast cannot start natively — it stays a manual Quick Settings toggle).
|
||||
- JS verification uses the Python delimiter-balance checker at `C:\Users\root\AppData\Local\Temp\opencode\js_balance.py` (no node available).
|
||||
- Python for the unittest loop: `$env:LOCALAPPDATA\Programs\Python\Python311\python.exe`.
|
||||
- Deploy: `powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\deploy.ps1 -Password "<PAGER_PASSWORD>"`, then `/etc/init.d/pagerwebui restart` over sshpass SSH.
|
||||
- Commit messages follow repo style (`feat:`, `fix:`, `docs:`, `test:`).
|
||||
- Mode badge highlight refinement vs. the approved spec (intent-preserving): when `karmaOn` is tracked, prefer it over the unreadable daemon state so the toggle does not visually jump after a user applies a preset.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Rebuild the PineAP overview (stats row + mode toggle + karma tracking)
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/general/pager-webui/www/css/app.css` (append `.seg` styles)
|
||||
- Modify: `payload/user/general/pager-webui/www/js/views.js` (replace the body of `views.pineap`, lines ~187-255)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `pineapShell(root, hash)` (appends h1 + tab bar, returns content box), `pineapSetCard(card, label, value)` (sets `.card-label`/`.card-value` text), `h()`, `btn(label, onclick, variant)`, `PagerAPI.get/post`, `App.go(hash)`, `App.toast`.
|
||||
- Produces: `views.pineap` with (1) a `.seg` segmented control with three buttons, (2) a 3-card stats row keyed `stats.ssids`/`stats.clients`/`stats.handshakes`, (3) a view-local `karmaOn` variable consumed by `load()` for the Karma card and the computed mode.
|
||||
|
||||
- [ ] **Step 1: Append the segmented-control CSS to `app.css`**
|
||||
|
||||
Append to the end of `payload/user/general/pager-webui/www/css/app.css`:
|
||||
|
||||
```css
|
||||
.seg { display: inline-flex; margin-top: 8px; border: 1px solid var(--ink, #999); border-radius: 4px; overflow: hidden; }
|
||||
.seg-btn { background: transparent; border: none; padding: 5px 14px; font-size: 12px; cursor: pointer; color: var(--muted, #666); }
|
||||
.seg-btn + .seg-btn { border-left: 1px solid var(--ink, #999); }
|
||||
.seg-btn.active { background: var(--primary, #1976d2); color: #fff; }
|
||||
.seg-btn.busy { opacity: .5; pointer-events: none; }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace the body of `views.pineap` in `views.js`**
|
||||
|
||||
Replace everything from `views.pineap = (root) => {` through the closing `};` of that function (current lines ~187-255) with:
|
||||
|
||||
```js
|
||||
views.pineap = (root) => {
|
||||
const box = pineapShell(root, '#/pineap');
|
||||
const mode = h('span', { class: 'badge', text: '-' });
|
||||
const intro = h('p', { class: 'muted' });
|
||||
const head = h('div', { class: 'section' },
|
||||
h('h2', {}, 'PineAP'),
|
||||
h('div', { class: 'row' }, h('div', {}, mode)),
|
||||
intro);
|
||||
box.appendChild(head);
|
||||
|
||||
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', () => applyMode(m));
|
||||
modeBar.appendChild(b);
|
||||
segBtns[m] = b;
|
||||
});
|
||||
const modeInfo = h('div', { class: 'muted', style: 'margin-top:8px;font-size:12px' });
|
||||
head.appendChild(modeBar);
|
||||
head.appendChild(modeInfo);
|
||||
|
||||
const quick = {
|
||||
collect: h('input', { type: 'checkbox', id: 'po-collect' }),
|
||||
advertise: h('input', { type: 'checkbox', id: 'po-advertise' })
|
||||
};
|
||||
const quickBox = h('div', { class: 'section' }, h('h2', {}, 'Quick Settings'));
|
||||
quickBox.appendChild(h('label', { class: 'toggle' }, quick.collect, ' Capture SSIDs to Pool'));
|
||||
quickBox.appendChild(h('label', { class: 'toggle' }, quick.advertise, ' Advertise AP Impersonation Pool'));
|
||||
quickBox.appendChild(h('div', { class: 'muted', style: 'margin-top:8px' },
|
||||
'Client connect/disconnect notifications are handled by the Pager alert payload system.'));
|
||||
box.appendChild(quickBox);
|
||||
|
||||
const stats = {};
|
||||
const statWrap = h('div', { class: 'cards' });
|
||||
const statDefs = [
|
||||
['ssids', 'Total SSIDs in Pool', '#/pineap/impersonation'],
|
||||
['clients', 'Clients Connected', '#/pineap/clients'],
|
||||
['handshakes', 'Handshakes Captured', '#/pineap/evilwpa']
|
||||
];
|
||||
statDefs.forEach(([k, label, hash]) => {
|
||||
const card = h('div', { class: 'card' },
|
||||
h('div', { class: 'card-label' }),
|
||||
h('div', { class: 'card-value' }),
|
||||
h('div', { class: 'row' }, btn('View', () => App.go(hash), 'ghost')));
|
||||
statWrap.appendChild(card);
|
||||
stats[k] = { label: card.querySelector('.card-label'), value: card.querySelector('.card-value') };
|
||||
stats[k].label.textContent = label;
|
||||
});
|
||||
box.appendChild(statWrap);
|
||||
|
||||
const cards = { karma: {}, open: {}, wpa: {}, ent: {} };
|
||||
const cardWrap = h('div', { class: 'cards' });
|
||||
Object.keys(cards).forEach((k) => {
|
||||
const card = h('div', { class: 'card' },
|
||||
h('div', { class: 'card-label' }),
|
||||
h('div', { class: 'card-value' }),
|
||||
h('div', { class: 'row' }, btn('Configure', () => App.go({
|
||||
karma: '#/pineap/open', open: '#/pineap/open',
|
||||
wpa: '#/pineap/evilwpa', ent: '#/pineap/enterprise'
|
||||
}[k]), 'ghost')));
|
||||
cardWrap.appendChild(card);
|
||||
cards[k].label = card.querySelector('.card-label');
|
||||
cards[k].value = card.querySelector('.card-value');
|
||||
});
|
||||
box.appendChild(cardWrap);
|
||||
|
||||
let karmaOn = null;
|
||||
|
||||
function bind(cb, on) {
|
||||
cb.addEventListener('change', () => on(cb.checked).then(load).catch(() => { cb.checked = !cb.checked; App.toast('Failed', 'error'); }));
|
||||
}
|
||||
bind(quick.collect, (v) => PagerAPI.post('/api/pineap/ssidpool/collect', { enable: v }));
|
||||
bind(quick.advertise, (v) => PagerAPI.post('/api/pineap/ssidpool/advertise', { enable: v }));
|
||||
|
||||
function setMode(m) {
|
||||
Object.keys(segBtns).forEach((k) => segBtns[k].classList.toggle('active', k === m));
|
||||
modeInfo.textContent = {
|
||||
passive: 'PineAP is on; network impersonation (Karma) is off.',
|
||||
active: 'PineAP and Karma are on; the open network is impersonated.',
|
||||
advanced: 'All PineAP features are enabled and customizable.'
|
||||
}[m] || '';
|
||||
}
|
||||
|
||||
function applyMode(m) {
|
||||
const btn = segBtns[m];
|
||||
if (!btn || btn.classList.contains('active')) return;
|
||||
const on = m !== 'passive';
|
||||
btn.classList.add('busy');
|
||||
Promise.all([
|
||||
PagerAPI.post('/api/pineap/enable', { enable: true }),
|
||||
PagerAPI.post('/api/pineap/mimic', { enable: on })
|
||||
]).then(() => {
|
||||
karmaOn = on;
|
||||
setMode(m);
|
||||
App.toast('Mode: ' + m[0].toUpperCase() + m.slice(1));
|
||||
load();
|
||||
}).catch(() => App.toast('Failed', 'error')).finally(() => btn.classList.remove('busy'));
|
||||
}
|
||||
|
||||
function load() {
|
||||
Promise.all([
|
||||
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.get('/api/pineap/ssids').catch(() => ({ data: {} })),
|
||||
PagerAPI.get('/api/pineap/clients').catch(() => ({ data: {} })),
|
||||
PagerAPI.get('/api/pineap/handshakes').catch(() => ({ data: {} }))
|
||||
]).then(([cfg, host, ap, ss, cl, hs]) => {
|
||||
const c = cfg.data || {}, hh = host.data || {}, a = ap.data || {};
|
||||
const disabled = !!hh.pineap_disabled;
|
||||
const wpa = a.wpa || {}, ent = a.enterprise || {};
|
||||
const advanced = !disabled && (wpa.enabled || ent.enabled);
|
||||
const computed = disabled ? 'passive' : (karmaOn === false ? 'passive' : (advanced ? 'advanced' : 'active'));
|
||||
mode.textContent = computed[0].toUpperCase() + computed.slice(1);
|
||||
mode.className = 'badge ' + (disabled ? 'off' : 'on');
|
||||
intro.textContent = disabled
|
||||
? 'PineAP is disabled. Enable it from the Open AP tab to begin impersonating networks.'
|
||||
: 'The WiFi Pineapple will respond to probe requests and impersonate the Open, Evil WPA, and Evil Enterprise access points.';
|
||||
setMode(computed);
|
||||
quick.collect.checked = !!c.autossidpool;
|
||||
const pool = a.pool || {};
|
||||
quick.advertise.checked = pool.disabled === false;
|
||||
stats.ssids.value.textContent = (ss.data && Array.isArray(ss.data.ssids)) ? ss.data.ssids.length : '—';
|
||||
stats.clients.value.textContent = (cl.data && typeof cl.data.count === 'number') ? cl.data.count : '—';
|
||||
stats.handshakes.value.textContent = (hs.data && Array.isArray(hs.data.files)) ? hs.data.files.length : '—';
|
||||
pineapSetCard(cards.karma, 'Karma', karmaOn == null ? null : (karmaOn ? 'On' : 'Off'));
|
||||
const open = a.open || {};
|
||||
pineapSetCard(cards.open, 'Open Network', open.enabled == null ? '—' : (open.enabled ? 'On' : 'Off'));
|
||||
pineapSetCard(cards.wpa, 'Evil WPA', wpa.enabled ? 'On' : 'Off');
|
||||
pineapSetCard(cards.ent, 'Evil Enterprise', ent.enabled ? 'On' : 'Off');
|
||||
});
|
||||
}
|
||||
load();
|
||||
const iv = setInterval(load, 5000);
|
||||
return { destroy: () => clearInterval(iv) };
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the JS delimiter balance check**
|
||||
|
||||
Run: `python "C:\Users\root\AppData\Local\Temp\opencode\js_balance.py" "C:\Users\root\Documents\Pineapple\pager-webui\payload\user\general\pager-webui\www\js\views.js"`
|
||||
Expected: `...views.js: delimiter balance OK`
|
||||
|
||||
- [ ] **Step 4: Run the 13-module unittest loop (backend must stay green)**
|
||||
|
||||
Run from `C:\Users\root\Documents\Pineapple\pager-webui`:
|
||||
```powershell
|
||||
$py = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe"; Get-ChildItem tests\test_*.py | ForEach-Object { $mod = "tests." + [IO.Path]::GetFileNameWithoutExtension($_.Name); $out = & $py -m unittest $mod 2>&1; ($out | Select-Object -Last 1) }
|
||||
```
|
||||
Expected: every module reports `OK` (recon may show `skipped=1`).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add payload/user/general/pager-webui/www/js/views.js payload/user/general/pager-webui/www/css/app.css
|
||||
git commit -m "feat: PineAP overview stats cards + passive/active/advanced mode toggle"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Deploy and verify on device
|
||||
|
||||
**Files:** none (verification only; no commit).
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1 output (deployed via `scripts/deploy.ps1`).
|
||||
|
||||
- [ ] **Step 1: Deploy the payload and restart the webui**
|
||||
|
||||
Run from `C:\Users\root\Documents\Pineapple\pager-webui`:
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\deploy.ps1 -Password "<PAGER_PASSWORD>"
|
||||
```
|
||||
Then restart and confirm the port is up:
|
||||
```bash
|
||||
sshpass -p "<PAGER_PASSWORD>" ssh root@172.16.52.1 "/etc/init.d/pagerwebui restart; sleep 4; curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/api/api_ping"
|
||||
```
|
||||
Expected: `401` (auth required = running).
|
||||
|
||||
- [ ] **Step 2: Verify the three stat endpoints return live data on device**
|
||||
|
||||
Run (base64 the script then `echo ... | base64 -d | sh` over sshpass):
|
||||
```sh
|
||||
curl -s -c /tmp/pwj -X POST http://127.0.0.1:8080/api/login -H "Content-Type: application/json" -d '{"username":"root","password":"<PAGER_PASSWORD>"}' > /dev/null
|
||||
echo ssids:; curl -s -b /tmp/pwj http://127.0.0.1:8080/api/pineap/ssids
|
||||
echo clients:; curl -s -b /tmp/pwj http://127.0.0.1:8080/api/pineap/clients
|
||||
echo handshakes:; curl -s -b /tmp/pwj http://127.0.0.1:8080/api/pineap/handshakes
|
||||
```
|
||||
Expected: `ssids` returns `{"ssids":[...]}`, `clients` returns `{"clients":[],"count":0}` (or a number), `handshakes` returns `{"files":[...],"handshakes":[...]}`.
|
||||
|
||||
- [ ] **Step 3: Confirm the deployed files contain the new code**
|
||||
|
||||
```sh
|
||||
grep -c "seg-btn\|Total SSIDs in Pool\|Handshakes Captured" /root/payloads/user/general/pager-webui/www/js/views.js
|
||||
grep -c "\.seg" /root/payloads/user/general/pager-webui/www/css/app.css
|
||||
```
|
||||
Expected: counts greater than zero.
|
||||
|
||||
- [ ] **Step 4: Report for user UI walk**
|
||||
|
||||
Tell the user the overview now has: the three stat cards (numbers populate in the 5s poll; each `View` button navigates to its tab), the Passive/Active/Advanced segmented toggle (applies PineAP master + Karma; karma tracked client-side and shown on the Karma card; badge/description update; failures toast + revert), and unchanged Quick Settings. Ask them to refresh `http://172.16.52.1:8080/#/pineap` and confirm.
|
||||
@@ -0,0 +1,822 @@
|
||||
# Mark VII PineAP Page Port Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Replace the broken pager-webui PineAP page with a faithful, fully-functional replica of the Mark VII PineAP view (8 tabs), wired to the Pager daemon's native `/api/pineap/*` unix-socket API.
|
||||
|
||||
**Architecture:** pager-webui's `server.py` proxies the Pager daemon's native PineAP REST API (root-only unix socket `/tmp/api.sock`, raw HTTP/1.1) 1:1 under its authenticated `/api/pineap/*` namespace, keeping existing custom endpoints (clients/aps/handshakes/kick) and fixing the broken uci-based settings + hak5cmd filter handlers. The vanilla-JS SPA gets a Mark VII-style 8-tab PineAP page and a corrected wifi rail icon.
|
||||
|
||||
**Tech Stack:** Python 3.11 (stdlib only, runs on device `python3-light`), vanilla JS SPA (no build step), existing `daemon_sock_call` socket client.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- `server.py` must remain stdlib-only (no `urllib`/`http.server`/`sqlite3` guarantee on device; sqlite reads fall back to `sqlite3` CLI via `_db_rows`/`_db_write`).
|
||||
- The daemon socket API (`/tmp/api.sock`) is unauthenticated by design (root-only socket). All pager-webui `/api/pineap/*` endpoints stay behind pager-webui session auth (already enforced by the server).
|
||||
- Daemon socket failure -> HTTP 502 `{error: ...}`; never raise/500.
|
||||
- Commands run with argument lists (no shell interpolation).
|
||||
- No Mark VII-only controls with no Pager equivalent (Autostart, Beacon Responses/Intervals, enterprise cert generation) in the UI.
|
||||
- Tests: stdlib `unittest`, each `tests/test_*.py` run in its own process (module-level monkeypatches do not get restored).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Backend — daemon PineAP proxy + fixed filters + enterprise endpoints
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/general/pager-webui/server.py` (replace the block `SETTING_MAP` at ~line 1354 through `h_filter_post` end ~line 1545; add proxy helper near `daemon_sock_call`; add enterprise handlers; update `ROUTER.add` block ~line 1649)
|
||||
- Test: `tests/test_pineap_settings.py`, `tests/test_pineap_pool.py`, `tests/test_pineap_clients.py`, `tests/test_pineap_aps.py` (rewrite); create `tests/test_pineap_proxy.py`, `tests/test_pineap_enterprise.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: existing `daemon_sock_call(method, path, body=None, timeout=10)` -> `(status:int, json|None)`; `_db_rows(db, sql)`; `_db_write(db, sql)`; `current_token()`; `hak5(*args)`; `normalize_mac`.
|
||||
- Produces (used by Task 2 frontend):
|
||||
- `GET /api/pineap/get_config` -> daemon `get_config` passthrough `{loghandshake, logpartialhandshake, logpcap, logwigle, logrecon, autossidpool, reconpath, reconname, handshakepath, ...}`
|
||||
- `POST /api/pineap/set_config` `{...flags}` -> daemon `set_config` passthrough
|
||||
- `GET /api/pineap/hostapd` -> daemon `hostapd/get_config` `{mgmt_ifaces, wpa_ifaces, pineap_disabled, pineape_disabled, pineape_auth_pass}`
|
||||
- `POST /api/pineap/hostapd` `{pineap_disabled?, pineape_disabled?, pineape_auth_pass?}` -> daemon `hostapd/set_config`
|
||||
- `POST /api/pineap/enable` `{enable: bool}` -> daemon `hostapd/enable_pineap`
|
||||
- `POST /api/pineap/mimic` `{enable: bool}` -> daemon `mimic/enable` | `mimic/disable`
|
||||
- `POST /api/pineap/examine` `{bssid, seconds?}` | `{channel}` | `{reset: true}` -> daemon `examine/bssid` | `examine/channel` | `examine/reset`
|
||||
- `POST /api/pineap/wifi/get_ap` / `wifi/set_ap` -> daemon `settings/wifi/get_ap` | `settings/wifi/set_ap` (Evil WPA + Open AP details)
|
||||
- `POST /api/pineap/ssidpool/advertise` `{enable}` -> daemon `ssidpool/enable`|`ssidpool/disable`
|
||||
- `POST /api/pineap/ssidpool/collect` `{enable}` -> daemon `ssidpool/enable_collect`|`ssidpool/disable_collect`
|
||||
- `POST /api/pineap/interfaces` `{device, hop?, inject?, bands?, primary?}` -> daemon `interfaces/set_interface`
|
||||
- `GET /api/pineap/filters/{client|ssid}` -> `{mode, entries}` (mode+active list via daemon `macfilter/get_config`|`ssidfilter/get_config`; entries = denied if mode==deny else allowed)
|
||||
- `POST /api/pineap/filters/{client|ssid}` `{action: set_mode|add|delete|clear, mode?, value?}` -> mode via daemon `macfilter/set_mode`|`ssidfilter/set_config`; list mutations via hak5cmd `PINEAPPLE_DEVICE_FILTER_*`|`PINEAPPLE_NETWORK_FILTER_*`
|
||||
- `GET /api/pineap/enterprise/basic`, `GET /api/pineap/enterprise/challenge` -> `{rows: [...]}` from recon.db `hostap_basic` / `hostap_challenge` (via `_db_rows`)
|
||||
- `POST /api/pineap/enterprise/clear` `{table: basic|challenge}` -> `_db_write` delete rows
|
||||
- Kept as-is: `GET /api/pineap/ssids`, `POST /api/pineap/ssids`, `GET /api/pineap/clients`, `POST /api/pineap/clients/kick`, `GET /api/pineap/aps`, `POST /api/pineap/deauth/client`, `GET/DELETE /api/pineap/handshakes*`
|
||||
|
||||
- [ ] **Step 1: Write the proxy helper + tests (failing)**
|
||||
|
||||
`tests/test_pineap_proxy.py`:
|
||||
|
||||
```python
|
||||
import os, sys, unittest
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'general', 'pager-webui'))
|
||||
import server
|
||||
|
||||
|
||||
def ctx(body=None, args=()):
|
||||
return type('C', (), {'body': body, 'args': args, 'query': {}})()
|
||||
|
||||
|
||||
class PineapProxyTest(unittest.TestCase):
|
||||
def test_proxy_get_passthrough(self):
|
||||
server.daemon_sock_call = lambda method, path, body=None, timeout=10: (200, {'loghandshake': False})
|
||||
status, payload = server.h_pineap_get_config(ctx())
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(payload['loghandshake'], False)
|
||||
|
||||
def test_proxy_post_passthrough(self):
|
||||
calls = []
|
||||
def fake(method, path, body=None, timeout=10):
|
||||
calls.append((method, path, body))
|
||||
return (200, {'success': True})
|
||||
server.daemon_sock_call = fake
|
||||
server.h_pineap_enable(ctx({'enable': True}))
|
||||
self.assertEqual(calls[0], ('POST', '/api/pineap/hostapd/enable_pineap', {'enable': True}))
|
||||
|
||||
def test_proxy_502_on_socket_failure(self):
|
||||
server.daemon_sock_call = lambda method, path, body=None, timeout=10: (0, None)
|
||||
status, payload = server.h_pineap_get_config(ctx())
|
||||
self.assertEqual(status, 502)
|
||||
|
||||
def test_mimic_routes_enable_and_disable(self):
|
||||
calls = []
|
||||
def fake(method, path, body=None, timeout=10):
|
||||
calls.append(path)
|
||||
return (200, {'success': True})
|
||||
server.daemon_sock_call = fake
|
||||
server.h_pineap_mimic(ctx({'enable': True}))
|
||||
server.h_pineap_mimic(ctx({'enable': False}))
|
||||
self.assertEqual(calls, ['/api/pineap/mimic/enable', '/api/pineap/mimic/disable'])
|
||||
|
||||
def test_examine_reset(self):
|
||||
calls = []
|
||||
def fake(method, path, body=None, timeout=10):
|
||||
calls.append((path, body))
|
||||
return (200, {'success': True})
|
||||
server.daemon_sock_call = fake
|
||||
server.h_pineap_examine(ctx({'reset': True}))
|
||||
self.assertEqual(calls[0], ('/api/pineap/examine/reset', {'reset': True}))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test, verify fail**
|
||||
|
||||
```
|
||||
$py -m unittest tests.test_pineap_proxy -v
|
||||
```
|
||||
Expected: FAIL (`AttributeError: module 'server' has no attribute 'h_pineap_get_config'`)
|
||||
|
||||
- [ ] **Step 3: Implement the proxy in server.py**
|
||||
|
||||
Replace the entire broken block starting at `SETTING_MAP = {` through `h_filter_post` (ends right before `def _proxy_json`), keeping `hak5`, `_json_or`, `_parse_pool_list`:
|
||||
|
||||
```python
|
||||
def _daemon_proxy(method, subpath, body=None):
|
||||
status, data = daemon_sock_call(method, '/api/pineap/%s' % subpath, body=body)
|
||||
if status != 200:
|
||||
return (502 if status == 0 else status), {'error': 'daemon failed', 'detail': data}
|
||||
return 200, (data if isinstance(data, dict) else {'ok': data is not None})
|
||||
|
||||
|
||||
def h_pineap_get_config(ctx):
|
||||
return _daemon_proxy('GET', 'get_config')
|
||||
|
||||
|
||||
def h_pineap_set_config(ctx):
|
||||
return _daemon_proxy('POST', 'set_config', ctx.body or {})
|
||||
|
||||
|
||||
def h_pineap_hostapd_get(ctx):
|
||||
return _daemon_proxy('GET', 'hostapd/get_config')
|
||||
|
||||
|
||||
def h_pineap_hostapd_set(ctx):
|
||||
body = ctx.body or {}
|
||||
keep = {}
|
||||
for key in ('pineap_disabled', 'pineape_disabled', 'pineape_auth_pass', 'mgmt_ifaces', 'wpa_ifaces'):
|
||||
if key in body:
|
||||
keep[key] = body[key]
|
||||
return _daemon_proxy('POST', 'hostapd/set_config', keep)
|
||||
|
||||
|
||||
def h_pineap_enable(ctx):
|
||||
return _daemon_proxy('POST', 'hostapd/enable_pineap', {'enable': bool((ctx.body or {}).get('enable'))})
|
||||
|
||||
|
||||
def h_pineap_mimic(ctx):
|
||||
enable = bool((ctx.body or {}).get('enable'))
|
||||
return _daemon_proxy('POST', 'mimic/enable' if enable else 'mimic/disable')
|
||||
|
||||
|
||||
def h_pineap_examine(ctx):
|
||||
body = ctx.body or {}
|
||||
if body.get('reset'):
|
||||
return _daemon_proxy('POST', 'examine/reset', {'reset': True})
|
||||
if body.get('bssid'):
|
||||
req = {'bssid': body['bssid']}
|
||||
if body.get('seconds') is not None:
|
||||
req['seconds'] = int(body['seconds'])
|
||||
return _daemon_proxy('POST', 'examine/bssid', req)
|
||||
if body.get('channel') is not None:
|
||||
return _daemon_proxy('POST', 'examine/channel', {'channel': str(int(body['channel']))})
|
||||
return 400, {'error': 'examine requires bssid, channel or reset'}
|
||||
|
||||
|
||||
def h_pineap_wifi_get_ap(ctx):
|
||||
status, data = daemon_sock_call('POST', '/api/settings/wifi/get_ap', body={})
|
||||
if status != 200:
|
||||
return (502 if status == 0 else status), {'error': 'daemon failed', 'detail': data}
|
||||
return 200, (data if isinstance(data, dict) else {'ok': True})
|
||||
|
||||
|
||||
def h_pineap_wifi_set_ap(ctx):
|
||||
status, data = daemon_sock_call('POST', '/api/settings/wifi/set_ap', body=ctx.body or {})
|
||||
if status != 200:
|
||||
return (502 if status == 0 else status), {'error': 'daemon failed', 'detail': data}
|
||||
return 200, (data if isinstance(data, dict) else {'ok': True})
|
||||
|
||||
|
||||
def h_pineap_advertise(ctx):
|
||||
enable = bool((ctx.body or {}).get('enable'))
|
||||
return _daemon_proxy('POST', 'ssidpool/enable' if enable else 'ssidpool/disable')
|
||||
|
||||
|
||||
def h_pineap_collect(ctx):
|
||||
enable = bool((ctx.body or {}).get('enable'))
|
||||
return _daemon_proxy('POST', 'ssidpool/enable_collect' if enable else 'ssidpool/disable_collect')
|
||||
|
||||
|
||||
def h_pineap_interfaces(ctx):
|
||||
return _daemon_proxy('POST', 'interfaces/set_interface', ctx.body or {})
|
||||
|
||||
|
||||
# --- Filters ---
|
||||
|
||||
FILTER_DAEMON = {
|
||||
'client': ('macfilter/get_config', 'macfilter/set_mode', 'PINEAPPLE_DEVICE_FILTER'),
|
||||
'ssid': ('ssidfilter/get_config', 'ssidfilter/set_config', 'PINEAPPLE_NETWORK_FILTER'),
|
||||
}
|
||||
|
||||
|
||||
def h_filter_get(ctx, kind):
|
||||
get_path, set_path, hak5_prefix = FILTER_DAEMON[kind]
|
||||
status, data = daemon_sock_call('GET', '/api/pineap/%s' % get_path)
|
||||
if status != 200 or not isinstance(data, dict):
|
||||
return (502 if status == 0 else status), {'error': 'daemon failed', 'detail': data}
|
||||
mode = data.get('mode') or 'allow'
|
||||
if kind == 'client':
|
||||
entries = data.get('denied_macs') if mode == 'deny' else data.get('allowed_macs')
|
||||
else:
|
||||
entries = data.get('denied_ssids') if mode == 'deny' else data.get('allowed_ssids')
|
||||
return 200, {'mode': mode, 'entries': [str(e) for e in (entries or [])]}
|
||||
|
||||
|
||||
def h_filter_post(ctx, kind):
|
||||
body = ctx.body or {}
|
||||
action = body.get('action')
|
||||
_, set_path, prefix = FILTER_DAEMON[kind]
|
||||
if action == 'set_mode':
|
||||
mode = (body.get('mode') or '').strip()
|
||||
if mode not in ('allow', 'deny'):
|
||||
return 400, {'error': 'mode must be allow or deny'}
|
||||
status, data = daemon_sock_call('POST', '/api/pineap/%s' % set_path, body={'mode': mode})
|
||||
if status != 200:
|
||||
return (502 if status == 0 else status), {'error': 'daemon failed', 'detail': data}
|
||||
elif action == 'add':
|
||||
value = (body.get('value') or '').strip()
|
||||
if not value:
|
||||
return 400, {'error': 'value required'}
|
||||
hak5('%s_ADD' % prefix, value)
|
||||
elif action == 'delete':
|
||||
value = (body.get('value') or '').strip()
|
||||
if not value:
|
||||
return 400, {'error': 'value required'}
|
||||
hak5('%s_DELETE' % prefix, value)
|
||||
elif action == 'clear':
|
||||
hak5('%s_CLEAR' % prefix)
|
||||
else:
|
||||
return 400, {'error': 'unknown action'}
|
||||
return h_filter_get(ctx, kind)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test, verify pass**
|
||||
|
||||
```
|
||||
$py -m unittest tests.test_pineap_proxy -v
|
||||
```
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 5: Enterprise endpoints + tests**
|
||||
|
||||
`tests/test_pineap_enterprise.py`:
|
||||
|
||||
```python
|
||||
import os, sys, unittest
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'general', 'pager-webui'))
|
||||
import server
|
||||
|
||||
|
||||
class EnterpriseTest(unittest.TestCase):
|
||||
def test_basic_rows(self):
|
||||
server._db_rows = lambda db, sql: [{'time': 1, 'username': 'a', 'password': 'b'}]
|
||||
status, payload = server.h_enterprise_data(type('C', (), {'args': ('basic',)})())
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(payload['rows'][0]['username'], 'a')
|
||||
|
||||
def test_clear(self):
|
||||
calls = []
|
||||
server._db_write = lambda db, sql: calls.append(sql)
|
||||
server.h_enterprise_clear(type('C', (), {'body': {'table': 'challenge'}})())
|
||||
self.assertTrue(any('hostap_challenge' in s for s in calls))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
```
|
||||
|
||||
Implement in server.py near `_db_write`/handshakes helpers:
|
||||
|
||||
```python
|
||||
ENTERPRISE_TABLES = {'basic': 'hostap_basic', 'challenge': 'hostap_challenge'}
|
||||
|
||||
|
||||
def _enterprise_cols(table):
|
||||
rows = _db_rows(RECON_DB, 'PRAGMA table_info(%s)' % table)
|
||||
return [r.get('name') for r in rows]
|
||||
|
||||
|
||||
def h_enterprise_data(ctx):
|
||||
table = ENTERPRISE_TABLES.get((ctx.args or [''])[0])
|
||||
if not table:
|
||||
return 400, {'error': 'unknown table'}
|
||||
rows = _db_rows(RECON_DB, 'SELECT * FROM %s ORDER BY time' % table)
|
||||
return 200, {'table': table, 'rows': rows or []}
|
||||
|
||||
|
||||
def h_enterprise_clear(ctx):
|
||||
table = ENTERPRISE_TABLES.get((ctx.body or {}).get('table', ''))
|
||||
if not table:
|
||||
return 400, {'error': 'unknown table'}
|
||||
try:
|
||||
_db_write(RECON_DB, 'DELETE FROM %s' % table)
|
||||
except RuntimeError as e:
|
||||
return 502, {'error': str(e)}
|
||||
return 200, {'ok': True}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run enterprise test, verify pass**
|
||||
|
||||
```
|
||||
$py -m unittest tests.test_pineap_enterprise -v
|
||||
```
|
||||
Expected: PASS
|
||||
|
||||
- [ ] **Step 7: Rewrite obsolete tests**
|
||||
|
||||
`tests/test_pineap_settings.py` -> drop the `UciHelpersTest`/`PineapSettingsTest` uci tests (uci helpers stay for NTP/hostname but settings no longer uses them). Replace with a `GetConfigProxyTest` asserting `h_pineap_get_config` proxies and that `set_config` forwards a whitelisted body.
|
||||
|
||||
`tests/test_pineap_pool.py` -> keep `Hak5Test`/`PoolParsingTest`/`SsidPoolHandlersTest` (those endpoints remain). Add a `FilterProxyReadTest` for `h_filter_get` reading daemon config.
|
||||
|
||||
- [ ] **Step 8: Wire the ROUTER table**
|
||||
|
||||
Replace these lines in the `ROUTER.add` block:
|
||||
|
||||
```python
|
||||
ROUTER.add('GET', r'/api/pineap/get_config', h_pineap_get_config)
|
||||
ROUTER.add('POST', r'/api/pineap/set_config', h_pineap_set_config)
|
||||
ROUTER.add('GET', r'/api/pineap/hostapd', h_pineap_hostapd_get)
|
||||
ROUTER.add('POST', r'/api/pineap/hostapd', h_pineap_hostapd_set)
|
||||
ROUTER.add('POST', r'/api/pineap/enable', h_pineap_enable)
|
||||
ROUTER.add('POST', r'/api/pineap/mimic', h_pineap_mimic)
|
||||
ROUTER.add('POST', r'/api/pineap/examine', h_pineap_examine)
|
||||
ROUTER.add('POST', r'/api/pineap/wifi/get_ap', h_pineap_wifi_get_ap)
|
||||
ROUTER.add('POST', r'/api/pineap/wifi/set_ap', h_pineap_wifi_set_ap)
|
||||
ROUTER.add('POST', r'/api/pineap/ssidpool/advertise', h_pineap_advertise)
|
||||
ROUTER.add('POST', r'/api/pineap/ssidpool/collect', h_pineap_collect)
|
||||
ROUTER.add('POST', r'/api/pineap/interfaces', h_pineap_interfaces)
|
||||
ROUTER.add('GET', r'/api/pineap/filters/client', lambda ctx: h_filter_get(ctx, 'client'))
|
||||
ROUTER.add('POST', r'/api/pineap/filters/client', lambda ctx: h_filter_post(ctx, 'client'))
|
||||
ROUTER.add('GET', r'/api/pineap/filters/ssid', lambda ctx: h_filter_get(ctx, 'ssid'))
|
||||
ROUTER.add('POST', r'/api/pineap/filters/ssid', lambda ctx: h_filter_post(ctx, 'ssid'))
|
||||
ROUTER.add('GET', r'/api/pineap/enterprise/(basic|challenge)', h_enterprise_data)
|
||||
ROUTER.add('POST', r'/api/pineap/enterprise/clear', h_enterprise_clear)
|
||||
```
|
||||
|
||||
Remove the old routes for `settings`, `ssidpool/(start|stop|collect_start|collect_stop)` and `filters` if duplicated. Keep `ssids`, `clients`, `aps`, `deauth/client`, `handshakes*` routes as-is. **Remove the `SSIDPOOL_ACTIONS`/`h_ssidpool_action` and `SETTING_MAP`/`_uci_map`/`uci_show`-for-pineapd usage that the removed block owned** (keep `uci_show`/`uci_set`/`uci_delete`/`uci_add_list` — NTP/hostname still use them).
|
||||
|
||||
- [ ] **Step 9: Run full test loop, commit**
|
||||
|
||||
```
|
||||
Get-ChildItem tests\test_*.py | ForEach-Object { & $py -m unittest "tests.$([IO.Path]::GetFileNameWithoutExtension($_.Name))" -v }
|
||||
```
|
||||
Expected: all pass. Commit:
|
||||
```
|
||||
git add payload/user/general/pager-webui/server.py tests/
|
||||
git commit -m "feat: proxy native daemon PineAP API; fix filters; add enterprise endpoints"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Frontend — 8-tab Mark VII PineAP page
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/general/pager-webui/www/js/app.js` (rail icon; routes map)
|
||||
- Modify: `payload/user/general/pager-webui/www/js/views.js` (replace `PINEAP_TABS`/`pineapShell`/`views.pineap*` block, lines ~163-360; update recon `/api/pineap/settings` references at ~495, 601, 609, 877)
|
||||
- Modify: `payload/user/general/pager-webui/www/css/app.css` (pineap cards/toggles layout)
|
||||
- Test: manual browser walk (no JS test harness)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: backend routes from Task 1; existing `h`/`table`/`btn`/`iconBtn`/`tabBar` helpers; `PagerAPI.get/post`; `App.toast`; `fmtTime`.
|
||||
- Produces: `views.pineap` (overview), `pineap_open`, `pineap_evilwpa`, `pineap_enterprise`, `pineap_impersonation`, `pineap_clients`, `pineap_filtering`, `pineap_aps`; routes `#/pineap`, `#/pineap/open`, `#/pineap/evilwpa`, `#/pineap/enterprise`, `#/pineap/impersonation`, `#/pineap/clients`, `#/pineap/filtering`, `#/pineap/aps`.
|
||||
|
||||
- [ ] **Step 1: Rail icon + routes**
|
||||
|
||||
In `app.js`: change rail item `{ key: 'pineap', label: 'PineAP', hash: '#/pineap', icon: 'pineap' }` to `icon: 'wifi'`. Extend `routes`:
|
||||
|
||||
```js
|
||||
'#/pineap': 'pineap',
|
||||
'#/pineap/open': 'pineap_open',
|
||||
'#/pineap/evilwpa': 'pineap_evilwpa',
|
||||
'#/pineap/enterprise': 'pineap_enterprise',
|
||||
'#/pineap/impersonation': 'pineap_impersonation',
|
||||
'#/pineap/clients': 'pineap_clients',
|
||||
'#/pineap/filtering': 'pineap_filtering',
|
||||
'#/pineap/aps': 'pineap_aps',
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Tab shell + overview**
|
||||
|
||||
In `views.js` replace `PINEAP_TABS`/`pineapShell`/`views.pineap`/`views.pineap_open`/`views.pineap_clients`/`views.pineap_filtering`/`views.pineap_aps`/`views.pineap_impersonation` with the new implementation (full code in the patch below). The overview derives mode from `get_config`+`hostapd`+`wifi/get_ap`:
|
||||
|
||||
```js
|
||||
const PINEAP_TABS = [
|
||||
{ label: 'PineAP', hash: '#/pineap' },
|
||||
{ label: 'Open AP', hash: '#/pineap/open' },
|
||||
{ label: 'Evil WPA', hash: '#/pineap/evilwpa' },
|
||||
{ label: 'Enterprise', hash: '#/pineap/enterprise' },
|
||||
{ label: 'Impersonation', hash: '#/pineap/impersonation' },
|
||||
{ label: 'Clients', hash: '#/pineap/clients' },
|
||||
{ label: 'Filtering', hash: '#/pineap/filtering' },
|
||||
{ label: 'APs', hash: '#/pineap/aps' }
|
||||
];
|
||||
|
||||
function pineapShell(root, activeHash, inner) {
|
||||
root.appendChild(h('h1', { class: 'page-title', text: 'PineAP' }));
|
||||
tabBar(root, PINEAP_TABS, activeHash);
|
||||
const box = h('div', {});
|
||||
root.appendChild(box);
|
||||
return inner(box);
|
||||
}
|
||||
|
||||
views.pineap = (root) => {
|
||||
tabBar(root, PINEAP_TABS, '#/pineap');
|
||||
const box = h('div', {});
|
||||
root.appendChild(box);
|
||||
|
||||
const mode = h('span', { class: 'badge', text: '—' });
|
||||
const intro = h('p', { class: 'muted' });
|
||||
const quick = {
|
||||
collect: h('input', { type: 'checkbox', id: 'po-collect' }),
|
||||
advertise: h('input', { type: 'checkbox', id: 'po-advertise' })
|
||||
};
|
||||
const cards = { karma: {}, open: {}, wpa: {}, ent: {} };
|
||||
const cardWrap = h('div', { class: 'cards' });
|
||||
Object.keys(cards).forEach((k) => {
|
||||
const card = h('div', { class: 'card' },
|
||||
h('div', { class: 'card-label', text: '' }),
|
||||
h('div', { class: 'card-value' }),
|
||||
h('div', { class: 'row' }, btn('Configure', () => App.go({
|
||||
karma: '#/pineap/open', open: '#/pineap/open',
|
||||
wpa: '#/pineap/evilwpa', ent: '#/pineap/enterprise'
|
||||
}[k]), 'ghost')));
|
||||
cardWrap.appendChild(card);
|
||||
cards[k].wrap = card;
|
||||
cards[k].label = card.querySelector('.card-label');
|
||||
cards[k].value = card.querySelector('.card-value');
|
||||
});
|
||||
|
||||
const head = h('div', { class: 'section' }, h('h2', {}, 'PineAP'), mode, intro);
|
||||
box.appendChild(head);
|
||||
const quickBox = h('div', { class: 'section' }, h('h2', {}, 'Quick Settings'));
|
||||
quickBox.appendChild(h('label', { class: 'toggle' }, quick.collect, ' Capture SSIDs to Pool'));
|
||||
quickBox.appendChild(h('label', { class: 'toggle' }, quick.advertise, ' Advertise AP Impersonation Pool'));
|
||||
quickBox.appendChild(h('div', { class: 'muted', style: 'margin-top:8px' },
|
||||
'Client connect/disconnect notifications are handled by the Pager alert payload system.'));
|
||||
box.appendChild(quickBox);
|
||||
box.appendChild(cardWrap);
|
||||
|
||||
function bind(cb, on) {
|
||||
cb.addEventListener('change', () => on(cb.checked).then(load).catch(() => { cb.checked = !cb.checked; App.toast('Failed', 'error'); }));
|
||||
}
|
||||
bind(quick.collect, (v) => PagerAPI.post('/api/pineap/ssidpool/collect', { enable: v }));
|
||||
bind(quick.advertise, (v) => PagerAPI.post('/api/pineap/ssidpool/advertise', { enable: v }));
|
||||
|
||||
function load() {
|
||||
Promise.all([
|
||||
PagerAPI.get('/api/pineap/get_config').catch(() => ({ data: {} })),
|
||||
PagerAPI.get('/api/pineap/hostapd').catch(() => ({ data: {} })),
|
||||
PagerAPI.post('/api/pineap/wifi/get_ap').catch(() => ({ data: {} }))
|
||||
]).then(([cfg, host, ap]) => {
|
||||
const c = cfg.data || {}, hh = host.data || {}, a = ap.data || {};
|
||||
const disabled = !!hh.pineap_disabled;
|
||||
const active = !disabled;
|
||||
const advanced = active && (!!hh.pineape_disabled === false || (a.wpa && a.wpa.enabled) || (a.enterprise && a.enterprise.enabled));
|
||||
mode.textContent = disabled ? 'Passive' : (advanced ? 'Advanced' : 'Active');
|
||||
mode.className = 'badge ' + (disabled ? 'off' : 'on');
|
||||
intro.textContent = disabled
|
||||
? 'PineAP is disabled. Enable it from the Open AP tab to begin impersonating networks.'
|
||||
: 'The WiFi Pineapple will respond to probe requests and impersonate the Open, Evil WPA, and Evil Enterprise access points.';
|
||||
quick.collect.checked = !!c.autossidpool;
|
||||
quick.advertise.checked = !!a.pool ? !a.pool.disabled : false;
|
||||
setCard(cards.karma, 'Karma', null);
|
||||
setCard(cards.open, 'Open Network', a.open ? (a.open.enabled ? 'On' : 'Off') : '—');
|
||||
setCard(cards.wpa, 'Evil WPA', a.wpa ? (a.wpa.enabled ? 'On' : 'Off') : '—');
|
||||
setCard(cards.ent, 'Evil Enterprise', a.enterprise ? (a.enterprise.enabled ? 'On' : 'Off') : '—');
|
||||
});
|
||||
}
|
||||
function setCard(card, label, value) {
|
||||
card.label.textContent = label;
|
||||
card.value.textContent = value == null ? '—' : value;
|
||||
}
|
||||
load();
|
||||
const iv = setInterval(load, 5000);
|
||||
return { destroy: () => clearInterval(iv) };
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Open AP view**
|
||||
|
||||
`views.pineap_open` — master `Enable PineAP` toggle (POST `/api/pineap/enable`), `Karma` (POST `/api/pineap/mimic`), Logging group (`loghandshake`, `logpartialhandshake`, `logpcap`, `logwigle`, `logrecon` via `set_config`), `Capture SSIDs to Pool`, `Advertise AP Impersonation Pool`, and read-only `PineAP MAC`/`Target MAC` pulled from `/api/pineap/wifi/get_ap` (`a.open.bssid`, `a.open.target`) when present. Full code:
|
||||
|
||||
```js
|
||||
views.pineap_open = (root) => {
|
||||
const state = { cfg: {}, host: {}, ap: {} };
|
||||
const box = h('div', { class: 'section' }, h('h2', {}, 'Open AP'));
|
||||
root.appendChild(box);
|
||||
const toggles = {};
|
||||
const defs = [
|
||||
['pineap_disabled', 'Enable PineAP', (v) => PagerAPI.post('/api/pineap/enable', { enable: v })],
|
||||
['karma', 'Karma', (v) => PagerAPI.post('/api/pineap/mimic', { enable: v })],
|
||||
['loghandshake', 'Log Handshakes', (v) => saveCfg({ loghandshake: v })],
|
||||
['logpartialhandshake', 'Log Partial Handshakes', (v) => saveCfg({ logpartialhandshake: v })],
|
||||
['logpcap', 'Log PCAP', (v) => saveCfg({ logpcap: v })],
|
||||
['logwigle', 'Log WiGLE', (v) => saveCfg({ logwigle: v })],
|
||||
['logrecon', 'Log Recon', (v) => saveCfg({ logrecon: v })],
|
||||
['autossidpool', 'Capture SSIDs to Pool', (v) => saveCfg({ autossidpool: v })],
|
||||
['advertise', 'Advertise AP Impersonation Pool', (v) => PagerAPI.post('/api/pineap/ssidpool/advertise', { enable: v })]
|
||||
];
|
||||
defs.forEach(([k, label, fn]) => {
|
||||
const cb = h('input', { type: 'checkbox', id: 'oap-' + k });
|
||||
toggles[k] = { cb, fn };
|
||||
cb.addEventListener('change', () => fn(cb.checked).then(load).catch(() => { cb.checked = !cb.checked; App.toast('Failed', 'error'); }));
|
||||
box.appendChild(h('label', { class: 'toggle' }, cb, ' ' + label));
|
||||
});
|
||||
const info = h('div', { class: 'muted', style: 'margin-top:10px' });
|
||||
box.appendChild(info);
|
||||
function saveCfg(body) {
|
||||
return PagerAPI.post('/api/pineap/set_config', body);
|
||||
}
|
||||
function load() {
|
||||
Promise.all([
|
||||
PagerAPI.get('/api/pineap/get_config').catch(() => ({ data: {} })),
|
||||
PagerAPI.get('/api/pineap/hostapd').catch(() => ({ data: {} })),
|
||||
PagerAPI.post('/api/pineap/wifi/get_ap').catch(() => ({ data: {} }))
|
||||
]).then(([cfg, host, ap]) => {
|
||||
state.cfg = cfg.data || {}; state.host = host.data || {}; state.ap = ap.data || {};
|
||||
toggles.pineap_disabled.cb.checked = !state.host.pineap_disabled;
|
||||
toggles.karma.cb.checked = !!state.cfg.mimic;
|
||||
['loghandshake', 'logpartialhandshake', 'logpcap', 'logwigle', 'logrecon', 'autossidpool']
|
||||
.forEach((k) => { toggles[k].cb.checked = !!state.cfg[k]; });
|
||||
toggles.advertise.cb.checked = !!(state.ap.pool && !state.ap.pool.disabled);
|
||||
const o = state.ap.open || {};
|
||||
info.textContent = 'PineAP MAC: ' + (o.bssid || '—') + ' Target MAC: ' + (o.target || '—');
|
||||
});
|
||||
}
|
||||
load();
|
||||
return { destroy: () => {} };
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Evil WPA view**
|
||||
|
||||
`views.pineap_evilwpa` — SSID, passphrase, encryption select (WPA2 PSK / WPA3 SAE / WPA3 OAE), Hidden toggle, Enabled toggle; save via `POST /api/pineap/wifi/set_ap` with `{ wpa: { ssid, passphrase, enctype, hidden, enabled } }`. Handshake capture card (Examine BSSID + seconds, Start/Stop via `POST /api/pineap/examine`) and the captured handshakes table (`GET /api/pineap/handshakes`). Full code:
|
||||
|
||||
```js
|
||||
const EVIL_ENC = [
|
||||
['psk2+ccmp', 'WPA2 PSK'], ['psk2+tkip', 'WPA2 PSK (TKIP)'],
|
||||
['sae', 'WPA3 SAE'], ['sae+transition', 'WPA3 SAE (Transition)'],
|
||||
['owe', 'WPA3 OWE'], ['owe+transition', 'WPA3 OWE (Transition)']
|
||||
];
|
||||
|
||||
views.pineap_evilwpa = (root) => {
|
||||
const box = h('div', { class: 'section' }, h('h2', {}, 'Evil WPA'));
|
||||
root.appendChild(box);
|
||||
const ssidIn = h('input', { id: 'ew-ssid' });
|
||||
const pskIn = h('input', { id: 'ew-psk' });
|
||||
const encSel = h('select', { id: 'ew-enc' });
|
||||
EVIL_ENC.forEach(([v, l]) => encSel.appendChild(h('option', { value: v, text: l })));
|
||||
const hiddenCb = h('input', { type: 'checkbox', id: 'ew-hidden' });
|
||||
const enabledCb = h('input', { type: 'checkbox', id: 'ew-enabled' });
|
||||
box.appendChild(h('label', {}, 'SSID', ssidIn));
|
||||
box.appendChild(h('label', {}, 'Passphrase', pskIn));
|
||||
box.appendChild(h('label', {}, 'Encryption', encSel));
|
||||
box.appendChild(h('label', { class: 'toggle' }, hiddenCb, ' Hidden'));
|
||||
box.appendChild(h('label', { class: 'toggle' }, enabledCb, ' Enabled'));
|
||||
box.appendChild(h('div', { class: 'row' },
|
||||
h('div', {}, btn('Save', () => {
|
||||
PagerAPI.post('/api/pineap/wifi/set_ap', {
|
||||
wpa: { ssid: ssidIn.value, passphrase: pskIn.value, enctype: encSel.value,
|
||||
hidden: hiddenCb.checked, enabled: enabledCb.checked }
|
||||
}).then(() => { App.toast('Evil WPA saved'); load(); }).catch(() => App.toast('Failed', 'error'));
|
||||
}))));
|
||||
|
||||
const capBox = h('div', { class: 'section' }, h('h2', {}, 'Handshake Capture'));
|
||||
root.appendChild(capBox);
|
||||
const bssidIn = h('input', { id: 'ew-bssid', placeholder: 'BSSID' });
|
||||
const secsIn = h('input', { id: 'ew-secs', type: 'number', value: '30', style: 'max-width:80px' });
|
||||
capBox.appendChild(h('div', { class: 'row' },
|
||||
h('div', {}, h('label', {}, 'BSSID', bssidIn)),
|
||||
h('div', {}, h('label', {}, 'Seconds', secsIn)),
|
||||
h('div', {}, btn('Examine', () => {
|
||||
const b = bssidIn.value.trim(); if (!b) { App.toast('BSSID required', 'error'); return; }
|
||||
PagerAPI.post('/api/pineap/examine', { bssid: b, seconds: parseInt(secsIn.value, 10) || 30 })
|
||||
.then(() => App.toast('Examining ' + b)).catch(() => App.toast('Failed', 'error'));
|
||||
})),
|
||||
h('div', {}, btn('Stop', () => PagerAPI.post('/api/pineap/examine', { reset: true }).then(() => App.toast('Stopped')), 'danger'))));
|
||||
const hsBox = h('div', { class: 'section' }, h('h2', {}, 'Captured Handshakes'));
|
||||
root.appendChild(hsBox);
|
||||
|
||||
function load() {
|
||||
PagerAPI.post('/api/pineap/wifi/get_ap').then((r) => {
|
||||
const w = (r.data || {}).wpa || {};
|
||||
ssidIn.value = w.ssid || '';
|
||||
pskIn.value = w.passphrase || '';
|
||||
if (w.enctype) encSel.value = w.enctype;
|
||||
hiddenCb.checked = !!w.hidden;
|
||||
enabledCb.checked = !!w.enabled;
|
||||
}).catch(() => {});
|
||||
PagerAPI.get('/api/pineap/handshakes').then((r) => {
|
||||
hsBox.innerHTML = '';
|
||||
hsBox.appendChild(h('h2', {}, 'Captured Handshakes'));
|
||||
const rows = (r.data.handshakes || []).map((x) => ({
|
||||
name: x.name || '--', ap: x.ap || '--', client: x.client || '--', type: x.type || '--'
|
||||
}));
|
||||
hsBox.appendChild(table(
|
||||
[{ label: 'File', key: 'name' }, { label: 'AP', key: 'ap' },
|
||||
{ label: 'Client', key: 'client' }, { label: 'Type', key: 'type' }],
|
||||
rows));
|
||||
if (!rows.length) hsBox.appendChild(h('div', { class: 'empty', text: 'No handshakes captured yet.' }));
|
||||
}).catch(() => {});
|
||||
}
|
||||
load();
|
||||
const iv = setInterval(load, 5000);
|
||||
return { destroy: () => clearInterval(iv) };
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Enterprise view**
|
||||
|
||||
`views.pineap_enterprise` — `Enabled` (`POST /api/pineap/hostapd` with `pineape_disabled: !v`) and `Auth Pass Capture` (`pineape_auth_pass`) toggles, then two tables from `/api/pineap/enterprise/basic` and `/challenge` with Clear buttons (`POST /api/pineap/enterprise/clear`):
|
||||
|
||||
```js
|
||||
views.pineap_enterprise = (root) => {
|
||||
const box = h('div', { class: 'section' }, h('h2', {}, 'Evil Enterprise'));
|
||||
root.appendChild(box);
|
||||
const enabledCb = h('input', { type: 'checkbox', id: 'ee-enabled' });
|
||||
const authCb = h('input', { type: 'checkbox', id: 'ee-auth' });
|
||||
box.appendChild(h('label', { class: 'toggle' }, enabledCb, ' Enabled'));
|
||||
box.appendChild(h('label', { class: 'toggle' }, authCb, ' Auth Pass Capture'));
|
||||
enabledCb.addEventListener('change', () => PagerAPI.post('/api/pineap/hostapd', { pineape_disabled: !enabledCb.checked }).then(load).catch(() => { enabledCb.checked = !enabledCb.checked; }));
|
||||
authCb.addEventListener('change', () => PagerAPI.post('/api/pineap/hostapd', { pineape_auth_pass: authCb.checked }).then(load).catch(() => { authCb.checked = !authCb.checked; }));
|
||||
|
||||
function tableBox(name, endpoint, clearTable) {
|
||||
const tb = h('div', { class: 'section' }, h('h2', {}, name),
|
||||
btn('Clear', () => PagerAPI.post('/api/pineap/enterprise/clear', { table: clearTable }).then(load), 'danger'));
|
||||
root.appendChild(tb);
|
||||
const body = h('div', {});
|
||||
tb.appendChild(body);
|
||||
return { tb, body, endpoint };
|
||||
}
|
||||
const basic = tableBox('Basic Data', '/api/pineap/enterprise/basic', 'basic');
|
||||
const chall = tableBox('Challenge Data', '/api/pineap/enterprise/challenge', 'challenge');
|
||||
|
||||
function load() {
|
||||
PagerAPI.get('/api/pineap/hostapd').then((r) => {
|
||||
const hh = r.data || {};
|
||||
enabledCb.checked = !hh.pineape_disabled;
|
||||
authCb.checked = !!hh.pineape_auth_pass;
|
||||
}).catch(() => {});
|
||||
[[basic], [chall]].forEach(([t]) => {
|
||||
PagerAPI.get(t.endpoint).then((r) => {
|
||||
const rows = (r.data.rows || []).slice();
|
||||
t.body.innerHTML = '';
|
||||
const cols = rows.length ? Object.keys(rows[0]).map((k) => ({ label: k, key: k }))
|
||||
: [{ label: '—', key: '_none' }];
|
||||
t.body.appendChild(table(cols, rows));
|
||||
if (!rows.length) t.body.appendChild(h('div', { class: 'empty', text: 'No data captured.' }));
|
||||
}).catch(() => {});
|
||||
});
|
||||
}
|
||||
load();
|
||||
const iv = setInterval(load, 5000);
|
||||
return { destroy: () => clearInterval(iv) };
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Impersonation view**
|
||||
|
||||
`views.pineap_impersonation` — SSID pool: list via `GET /api/pineap/ssids`, add/remove/clear via `POST /api/pineap/ssids`, advertise + collect via the pool routes:
|
||||
|
||||
```js
|
||||
views.pineap_impersonation = (root) => {
|
||||
const box = h('div', { class: 'section' }, h('h2', {}, 'SSID Pool'));
|
||||
root.appendChild(box);
|
||||
const input = h('input', { id: 'imp-ssid' });
|
||||
const list = h('div', {});
|
||||
box.appendChild(h('div', { class: 'row' },
|
||||
h('div', {}, h('label', {}, 'SSID', input)),
|
||||
h('div', {}, btn('Add', () => {
|
||||
const v = input.value.trim(); if (!v) return;
|
||||
PagerAPI.post('/api/pineap/ssids', { action: 'add', ssid: v }).then((r) => { input.value = ''; render(r.data.ssids); });
|
||||
})),
|
||||
h('div', {}, btn('Clear', () => PagerAPI.post('/api/pineap/ssids', { action: 'clear' }).then((r) => render(r.data.ssids)), 'danger'))));
|
||||
const advCb = h('input', { type: 'checkbox', id: 'imp-advertise' });
|
||||
const colCb = h('input', { type: 'checkbox', id: 'imp-collect' });
|
||||
box.appendChild(h('label', { class: 'toggle' }, advCb, ' Advertise AP Impersonation Pool'));
|
||||
box.appendChild(h('label', { class: 'toggle' }, colCb, ' Capture SSIDs to Pool'));
|
||||
advCb.addEventListener('change', () => PagerAPI.post('/api/pineap/ssidpool/advertise', { enable: advCb.checked }).then(load).catch(() => { advCb.checked = !advCb.checked; }));
|
||||
colCb.addEventListener('change', () => PagerAPI.post('/api/pineap/ssidpool/collect', { enable: colCb.checked }).then(load).catch(() => { colCb.checked = !colCb.checked; }));
|
||||
box.appendChild(list);
|
||||
|
||||
function render(ssids) {
|
||||
list.innerHTML = '';
|
||||
list.appendChild(table(
|
||||
[{ label: 'SSID', key: 'ssid' }, { label: '', render: () => '' }],
|
||||
(ssids || []).map((s) => ({ ssid: s })),
|
||||
(r) => ({ onclick: () => { if (confirm('Remove ' + r.ssid + '?')) PagerAPI.post('/api/pineap/ssids', { action: 'remove', ssid: r.ssid }).then((x) => render(x.data.ssids)); } })));
|
||||
list.querySelectorAll('.tbl th').forEach((th, i) => { if (i === 1) th.textContent = 'Remove'; });
|
||||
if (!ssids || !ssids.length) list.appendChild(h('div', { class: 'empty', text: 'No SSIDs in pool.' }));
|
||||
}
|
||||
function load() {
|
||||
PagerAPI.get('/api/pineap/ssids').then((r) => render(r.data.ssids)).catch(() => {});
|
||||
PagerAPI.post('/api/pineap/wifi/get_ap').then((r) => {
|
||||
const p = (r.data || {}).pool || {};
|
||||
advCb.checked = !p.disabled;
|
||||
colCb.checked = !!p.collecting;
|
||||
}).catch(() => {});
|
||||
}
|
||||
load();
|
||||
return { destroy: () => {} };
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Clients, Filtering, APs views**
|
||||
|
||||
`views.pineap_clients` — keep the existing connected-clients + Kick table (it already works), just re-parented into the shell.
|
||||
|
||||
`views.pineap_filtering` — rewrite to call the fixed backend (`GET/POST /api/pineap/filters/{client|ssid}`), two cards each with mode select (allow/deny) + add/delete/clear list:
|
||||
|
||||
```js
|
||||
views.pineap_filtering = (root) => {
|
||||
const cfBox = h('div', { class: 'section' }, h('h2', {}, 'Client Filter'));
|
||||
const sfBox = h('div', { class: 'section' }, h('h2', {}, 'SSID Filter'));
|
||||
root.appendChild(cfBox); root.appendChild(sfBox);
|
||||
function renderFilter(box, kind) {
|
||||
box.innerHTML = '';
|
||||
box.appendChild(h('h2', {}, kind === 'client' ? 'Client Filter' : 'SSID Filter'));
|
||||
const path = '/api/pineap/filters/' + kind;
|
||||
const modeSel = h('select', { id: 'fm-' + kind },
|
||||
h('option', { value: 'allow', text: 'Allow list' }),
|
||||
h('option', { value: 'deny', text: 'Deny list' }));
|
||||
const valueIn = h('input', { id: 'fv-' + kind });
|
||||
box.appendChild(h('div', { class: 'row' },
|
||||
h('div', {}, h('label', {}, 'Mode', modeSel)),
|
||||
h('div', {}, h('label', {}, 'Value', valueIn)),
|
||||
h('div', {}, btn('Add', () => {
|
||||
const v = document.getElementById('fv-' + kind).value.trim(); if (!v) return;
|
||||
PagerAPI.post(path, { action: 'add', value: v }).then(() => refresh()).catch(() => App.toast('Failed', 'error'));
|
||||
})),
|
||||
h('div', {}, btn('Clear', () => PagerAPI.post(path, { action: 'clear' }).then(refresh), 'danger'))));
|
||||
modeSel.addEventListener('change', () => PagerAPI.post(path, { action: 'set_mode', mode: modeSel.value }).then(refresh));
|
||||
const list = h('div', {});
|
||||
box.appendChild(list);
|
||||
PagerAPI.get(path).then((r) => {
|
||||
modeSel.value = r.data.mode;
|
||||
list.innerHTML = '';
|
||||
list.appendChild(table(
|
||||
[{ label: kind === 'client' ? 'MAC' : 'SSID', key: 'value' }, { label: '', render: () => '' }],
|
||||
(r.data.entries || []).map((e) => ({ value: e })),
|
||||
(row) => ({ onclick: () => { if (confirm('Delete ' + row.value + '?')) PagerAPI.post(path, { action: 'delete', value: row.value }).then(refresh); } })));
|
||||
list.querySelectorAll('.tbl th').forEach((th, i) => { if (i === 1) th.textContent = 'Delete'; });
|
||||
if (!r.data.entries || !r.data.entries.length) list.appendChild(h('div', { class: 'empty', text: 'No entries.' }));
|
||||
}).catch(() => {});
|
||||
}
|
||||
function refresh() { renderFilter(cfBox, 'client'); renderFilter(sfBox, 'ssid'); }
|
||||
refresh();
|
||||
return { destroy: () => {} };
|
||||
};
|
||||
```
|
||||
|
||||
`views.pineap_aps` — keep the existing AP scan table view.
|
||||
|
||||
- [ ] **Step 8: Update recon settings references**
|
||||
|
||||
In `views.js` recon view (and recon focus sidebar):
|
||||
- Line ~495: `PagerAPI.post('/api/pineap/settings', { collect_handshakes: hsAuto...checked })` -> `PagerAPI.post('/api/pineap/set_config', { loghandshake: hsAuto.querySelector('input').checked })`
|
||||
- Line ~601/609: `PagerAPI.post('/api/pineap/settings', { collect_handshakes: true/false })` -> `PagerAPI.post('/api/pineap/set_config', { loghandshake: true/false })`
|
||||
- Line ~877: `PagerAPI.get('/api/pineap/settings')` -> `PagerAPI.get('/api/pineap/get_config')`, and read `collect_handshakes` as `loghandshake`.
|
||||
|
||||
Also update the `hsAuto` checkbox initializer accordingly.
|
||||
|
||||
- [ ] **Step 9: CSS for the new layout**
|
||||
|
||||
Append to `app.css` minimal styles: `.cards { display:flex; gap:12px; flex-wrap:wrap; }`, `.card { flex:1; min-width:180px; }` (if `.card`/`.cards` don't already exist from the dashboard — reuse them), ensure `.toggle`/`.badge`/`.tabbar`/`.empty` exist (they do). No new component CSS expected beyond `.pineap-*` if needed.
|
||||
|
||||
- [ ] **Step 10: Syntax check + commit**
|
||||
|
||||
```
|
||||
$py -c "import ast; ast.parse(open(r'payload\user\general\pager-webui\server.py', encoding='utf-8').read())"
|
||||
node --check payload/user/general/pager-webui/www/js/views.js
|
||||
node --check payload/user/general/pager-webui/www/js/app.js
|
||||
```
|
||||
Expected: no errors. Commit:
|
||||
```
|
||||
git add payload/user/general/pager-webui/www/js/ payload/user/general/pager-webui/www/css/
|
||||
git commit -m "feat: Mark VII-style 8-tab PineAP page; wifi rail icon"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Deploy + on-device smoke test
|
||||
|
||||
**Files:**
|
||||
- Run: `scripts/deploy.ps1 -SshKey ...` (or sshpass flow from README)
|
||||
|
||||
- [ ] **Step 1: Build + deploy**
|
||||
|
||||
Run the deploy script per README (builds `build/pager-webui/payload-*.zip`, uploads, installs). Confirm the service restarts.
|
||||
|
||||
- [ ] **Step 2: Walk the PineAP page on the pager**
|
||||
|
||||
Browse `http://172.16.52.1:8080/#/pineap`. Verify: rail shows wifi icon; overview mode badge + quick toggles; Open AP toggles save and survive reload; Evil WPA save applies (SSID/passphrase visible on a second load); Enterprise enable + auth-pass toggle; Impersonation add/remove/clear; Clients list + kick; Filtering mode + add/delete/clear; APs table.
|
||||
|
||||
- [ ] **Step 3: Reboot persistence + fix-ups**
|
||||
|
||||
`ssh root@172.16.52.1 reboot`, then confirm settings persisted (daemon-managed). Any daemon route/field that returned 502 or an unexpected shape (e.g. `wifi/get_ap` field names, `ssidpool` list shape, evil-wpa enctype values) -> adjust backend field names in Task 1/2 accordingly and redeploy. Record exact daemon shapes discovered here back into the design doc's open-items section.
|
||||
|
||||
- [ ] **Step 4: Final commit**
|
||||
|
||||
Commit any schema adjustments from Step 3 with a `fix:` message.
|
||||
|
||||
---
|
||||
|
||||
## Self-review notes
|
||||
|
||||
- Spec coverage: backend proxy (spec Architecture 1-5) -> Task 1; frontend 8 tabs + icon (spec Architecture 1-3) -> Task 2; error handling (spec Error handling) -> 502 in `_daemon_proxy` + toasts in JS; testing (spec Testing) -> Task 1 tests + Task 3 smoke; enterprise tables (spec Open items) -> Task 1 `h_enterprise_data`/`h_enterprise_clear`.
|
||||
- Placeholders: no TBD/TODO; unknown daemon field names are called out as on-device discovery in Task 3 Step 3 and use defensive `|| {}` / `.catch` fallbacks so the page never dies.
|
||||
- Type consistency: `h_pineap_*` handler names, route paths, and frontend `PagerAPI.*` calls are cross-checked above.
|
||||
@@ -0,0 +1,908 @@
|
||||
# Recon Mark VII Parity 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:** Rework the Pager WebUI Recon section into a faithful clone of the stock Hak5 WiFi Pineapple (Mark VII) Recon UI — Mark VII title cards, scan bar, APs/Clients tables with search + pagination, settings sidebar, and a two-tab Recon (Scanning + Handshakes, no Events) — using only data the Pager backend already exposes, plus one optional-body change to `POST /api/recon/start`.
|
||||
|
||||
**Architecture:** All front-end changes live under `payload/user/general/pager-webui/www/` (vanilla JS SPA, no build step). The Mark VII layout/markup/colors were extracted from the old Angular bundle `main.ce5a318adf590e170f6d.js`. The scanning view is rewritten to mirror Mark VII's `.recon-title-card-container` structure; charts are extended hand-rolled `<canvas>` renderers (bar + doughnut with legend); the Events tab/route/view is removed. One backend function (`h_recon_start`) forwards an optional `scan_time` to the daemon call.
|
||||
|
||||
**Tech Stack:** Vanilla JS (ES6, `const`/arrow functions as used today), hand-rolled CSS via custom properties (light/dark), hand-rolled `<canvas>` charts, Python `server.py` for the one backend change, `unittest` for the backend test.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Device runtime: `python3-light` on WiFi Pineapple Pager 24.10.1 — no third-party pip packages, no build step.
|
||||
- Front-end must stay ES6-compatible (matches existing code).
|
||||
- No new `/api/*` surface. Only `h_recon_start` body semantics change: optional `scan_time` (int seconds; `0` = continuous). When absent, behavior is byte-for-byte the current `body={}`.
|
||||
- Auth/session mechanics unchanged.
|
||||
- Design tokens (light): content `#fafafa`, cards `#fff`, toolbar `#424242`, primary `#1976d2`, text `#212121` / muted `#686868`, border `#e0e0e0`. Dark: surfaces `#303030`, cards `#424242`, border `#545454`.
|
||||
- Mark VII chart palettes (verbatim from the old bundle):
|
||||
- Landscape doughnut: `#2ecc71` (Access Points), `#2980b9` (Clients), `#8e44ad` (Unassociated).
|
||||
- Channel bar palette (cycle through for bars): `#FC68AC,#4545FF,#19DE8F,#FF294A,#23E8DB,#0FD349,#4D4AFF,#E2FF68,#FF8368,#B1FF6A,#FFFF3B,#FF677E,#D0FF6E,#F57D67,#F828E4,#EAFF6D,#3676F9,#F169E8,#3B2AE4,#3197F5,#4040FF,#FFF26A,#FCAD67,#0ACE28,#FF9E68,#55FF4A,#F9FF68,#EE687E,#FFFC67,#FFE167,#7FFF6C,#FFF236,#F26868,#6DFF74,#F568D5,#FF402A,#CAFF69,#28C20A,#6B29E9,#C7FF40,#FFB631,#D429F3,#F868C1,#14D96B,#9E29EF,#8EFF45,#FF2980,#FD29B3,#FF7A2C,#FF6967,#FFD569,#27D6EC,#98FF6B,#1EE3B5,#FFFF6B,#FFB969,#FFFF6C,#FF6795,#0BC80A,#3B54FD,#F99467,#FFC667,#2CB7F1,#6EFF91`
|
||||
- `localStorage` keys: `pw_scan_duration` (int string, default `'30'`), `pw_recon_cols` (JSON `{ap:{...},client:{...}}`).
|
||||
- Recon has exactly two tabs: `Scanning` (`#/recon`) and `Handshakes` (`#/recon/handshakes`).
|
||||
- No Band select (Pager cannot single-band scan). No graph/2D/3D view. No AP focus sidebars.
|
||||
- Existing Python `unittest` suite must stay green (`tests/` run per-file).
|
||||
- Commits follow repo style (`feat:`, `fix:`, `docs:`).
|
||||
- Deploy: `.\scripts\deploy.ps1 -SshKey "$HOME\.ssh\pager_key" -Password "<PAGER_PASSWORD>"` (fall back to printed scp/ssh commands if no key/sshpass).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Backend — `h_recon_start` forwards optional `scan_time`
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/general/pager-webui/server.py:912-916` (`h_recon_start`)
|
||||
- Modify: `tests/test_recon.py` (`DaemonSockTest`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `daemon_sock_call('POST', '/api/pineap/log/recon/start', body=...)` (exists, returns `(status, data)`); handler `ctx` may or may not have a `body` attribute (existing test builds `type('C', (), {'args': ()})()` with no `body`).
|
||||
- Produces: `h_recon_start(ctx)` → reads `getattr(ctx, 'body', None)`, forwards `{'scan_time': int}` when `scan_time` present, else `{}`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Add to `tests/test_recon.py`, inside `class DaemonSockTest` (after `test_start_stop_handlers_call_socket`):
|
||||
|
||||
```python
|
||||
def test_start_forwards_scan_time(self):
|
||||
calls = []
|
||||
server.daemon_sock_call = lambda m, p, body=None: calls.append((m, p, body)) or (200, {'success': True})
|
||||
ctx = type('C', (), {'args': (), 'body': {'scan_time': 60}})()
|
||||
status, data = server.h_recon_start(ctx)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(calls[0], ('POST', '/api/pineap/log/recon/start', {'scan_time': 60}))
|
||||
|
||||
def test_start_defaults_empty_body(self):
|
||||
calls = []
|
||||
server.daemon_sock_call = lambda m, p, body=None: calls.append((m, p, body)) or (200, {'success': True})
|
||||
server.h_recon_start(type('C', (), {'args': ()})())
|
||||
self.assertEqual(calls[0], ('POST', '/api/pineap/log/recon/start', {}))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
```powershell
|
||||
& "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe" -m unittest tests.test_recon.DaemonSockTest -v
|
||||
```
|
||||
Expected: `test_start_forwards_scan_time` FAIL (body is `{}`), `test_start_defaults_empty_body` FAIL (AttributeError on `ctx.body`).
|
||||
|
||||
- [ ] **Step 3: Implement the change**
|
||||
|
||||
Replace `h_recon_start` (currently lines 912-916):
|
||||
|
||||
```python
|
||||
def h_recon_start(ctx):
|
||||
body = {}
|
||||
scan_time = (getattr(ctx, 'body', None) or {}).get('scan_time')
|
||||
if scan_time is not None:
|
||||
body['scan_time'] = int(scan_time)
|
||||
status, data = daemon_sock_call('POST', '/api/pineap/log/recon/start', body=body)
|
||||
if status != 200 or not (data or {}).get('success'):
|
||||
return 502, {'error': 'daemon recon start failed'}
|
||||
return 200, {'ok': True}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes**
|
||||
|
||||
```powershell
|
||||
& "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe" -m unittest tests.test_recon.DaemonSockTest -v
|
||||
```
|
||||
Expected: all DaemonSockTest tests PASS.
|
||||
|
||||
- [ ] **Step 5: Run the full suite for regressions**
|
||||
|
||||
```powershell
|
||||
Get-ChildItem tests\test_*.py | ForEach-Object {
|
||||
$mod = "tests." + [IO.Path]::GetFileNameWithoutExtension($_.Name)
|
||||
& "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe" -m unittest $mod -v
|
||||
}
|
||||
```
|
||||
Expected: all modules PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add payload/user/general/pager-webui/server.py tests/test_recon.py
|
||||
git commit -m "feat: forward optional scan_time to daemon on recon start"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Charts — doughnut with legend + bar chart in `chart.js`
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/general/pager-webui/www/js/chart.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces (consumed by Task 5):
|
||||
- `MiniChart.doughnut(canvas, segments, opts)` — segments `[{label, value, color}]`; opts `{legend: bool, height: number, hole: number}`. Draws a ring doughnut (hole radius = `hole` × outer radius, default `0.65`) and, when `legend` is truthy, a legend row beneath (color dot + label, centered). Segments sum to 0 → draw empty ring and no legend.
|
||||
- `MiniChart.bar(canvas, items, opts)` — items `[{label, value, color}]`; opts `{height: number, grid: color}`. X axis labels = item labels (below chart), bars from baseline with item colors, Y gridlines, Y max = max value (min 1), no legend.
|
||||
- Unchanged: `MiniChart.draw` (dashboard line chart).
|
||||
|
||||
- [ ] **Step 1: Rewrite `chart.js`**
|
||||
|
||||
Replace the whole file with:
|
||||
|
||||
```js
|
||||
'use strict';
|
||||
|
||||
const MiniChart = (() => {
|
||||
function draw(canvas, series, opts) {
|
||||
const o = opts || {};
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = canvas.clientWidth * dpr;
|
||||
canvas.height = 140 * dpr;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
const w = canvas.clientWidth, h = 140;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
const max = Math.max(o.max || 10, ...series.map((s) => Math.max(...s.points, 0)), 1);
|
||||
const pad = 8;
|
||||
ctx.strokeStyle = o.grid || '#e0e0e0';
|
||||
ctx.lineWidth = 1;
|
||||
for (let g = 0; g <= 4; g++) {
|
||||
const y = pad + (h - pad * 2) * g / 4;
|
||||
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke();
|
||||
}
|
||||
series.forEach((s) => {
|
||||
const pts = s.points;
|
||||
if (!pts || pts.length < 2) return;
|
||||
ctx.strokeStyle = s.color || '#1976d2';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
let started = false;
|
||||
pts.forEach((v, i) => {
|
||||
if (v == null) { started = false; return; }
|
||||
const x = pad + (w - pad * 2) * i / Math.max(pts.length - 1, 1);
|
||||
const y = h - pad - (h - pad * 2) * (v / max);
|
||||
if (!started) { ctx.moveTo(x, y); started = true; } else ctx.lineTo(x, y);
|
||||
});
|
||||
ctx.stroke();
|
||||
const last = pts[pts.length - 1];
|
||||
if (last != null) {
|
||||
const x = pad + (w - pad * 2) * (pts.length - 1) / Math.max(pts.length - 1, 1);
|
||||
const y = h - pad - (h - pad * 2) * (last / max);
|
||||
ctx.fillStyle = s.color || '#1976d2';
|
||||
ctx.beginPath(); ctx.arc(x, y, 3, 0, Math.PI * 2); ctx.fill();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function doughnut(canvas, segments, opts) {
|
||||
const o = opts || {};
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const legendH = o.legend ? 22 : 0;
|
||||
const H = (o.height || 160) + legendH;
|
||||
canvas.width = canvas.clientWidth * dpr;
|
||||
canvas.height = H * dpr;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
const w = canvas.clientWidth, h = o.height || 160;
|
||||
ctx.clearRect(0, 0, w, H);
|
||||
const cx = w / 2, cy = h / 2;
|
||||
const r = Math.min(w, h) / 2 - 8;
|
||||
const hole = (o.hole == null ? 0.65 : o.hole) * r;
|
||||
const total = segments.reduce((s, x) => s + x.value, 0);
|
||||
if (!total) {
|
||||
ctx.strokeStyle = '#e0e0e0';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.stroke();
|
||||
ctx.beginPath(); ctx.arc(cx, cy, hole, 0, Math.PI * 2); ctx.stroke();
|
||||
return;
|
||||
}
|
||||
let a0 = -Math.PI / 2;
|
||||
segments.forEach((seg) => {
|
||||
const a1 = a0 + (seg.value / total) * Math.PI * 2;
|
||||
ctx.fillStyle = seg.color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, r, a0, a1);
|
||||
ctx.arc(cx, cy, hole, a1, a0, true);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
a0 = a1;
|
||||
});
|
||||
ctx.strokeStyle = o.stroke || '#ffffff';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.stroke();
|
||||
ctx.beginPath(); ctx.arc(cx, cy, hole, 0, Math.PI * 2); ctx.stroke();
|
||||
if (o.legend) {
|
||||
ctx.font = '11px Roboto, "Segoe UI", Arial, sans-serif';
|
||||
const dots = segments.filter((s) => s.value > 0);
|
||||
const text = dots.map((s) => s.label).join(' ');
|
||||
let tw = 0;
|
||||
dots.forEach((s) => { tw += 16 + ctx.measureText(s.label).width + 8; });
|
||||
tw = Math.max(tw - 8, 0);
|
||||
let x = (w - tw) / 2;
|
||||
const ly = h + 13;
|
||||
dots.forEach((s) => {
|
||||
ctx.fillStyle = s.color;
|
||||
ctx.beginPath(); ctx.arc(x + 4, ly - 3, 4, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.fillStyle = '#686868';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(s.label, x + 12, ly);
|
||||
x += 16 + ctx.measureText(s.label).width + 8;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function bar(canvas, items, opts) {
|
||||
const o = opts || {};
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const H = o.height || 160;
|
||||
canvas.width = canvas.clientWidth * dpr;
|
||||
canvas.height = H * dpr;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
const w = canvas.clientWidth, h = H;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
if (!items || !items.length) return;
|
||||
const max = Math.max(1, ...items.map((i) => i.value));
|
||||
const padB = 16, padT = 8, padL = 6, padR = 6;
|
||||
const plotW = w - padL - padR, plotH = h - padT - padB;
|
||||
const bw = plotW / items.length;
|
||||
ctx.strokeStyle = o.grid || '#e0e0e0';
|
||||
ctx.lineWidth = 1;
|
||||
for (let g = 0; g <= 4; g++) {
|
||||
const y = padT + plotH * g / 4;
|
||||
ctx.beginPath(); ctx.moveTo(padL, y); ctx.lineTo(w - padR, y); ctx.stroke();
|
||||
}
|
||||
items.forEach((it, i) => {
|
||||
const bh = it.value / max * plotH;
|
||||
const x = padL + bw * i + bw * 0.15;
|
||||
const wd = bw * 0.7;
|
||||
const y = padT + plotH - bh;
|
||||
ctx.fillStyle = it.color;
|
||||
ctx.fillRect(x, y, wd, bh);
|
||||
ctx.fillStyle = '#686868';
|
||||
ctx.font = '10px Roboto, "Segoe UI", Arial, sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(String(it.label), padL + bw * i + bw / 2, h - 4);
|
||||
});
|
||||
}
|
||||
|
||||
return { draw, doughnut, bar };
|
||||
})();
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Sanity-check the file**
|
||||
|
||||
```powershell
|
||||
$c = Get-Content -Raw payload\user\general\pager-webui\www\js\chart.js
|
||||
if ($c -match 'function doughnut' -and $c -match 'function bar' -and $c -match 'return \{ draw, doughnut, bar \}') { 'chart.js OK' }
|
||||
```
|
||||
Expected: `chart.js OK`.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add payload/user/general/pager-webui/www/js/chart.js
|
||||
git commit -m "feat: add doughnut legend and bar chart to MiniChart"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Icons — Material path data for new buttons
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/general/pager-webui/www/js/icons.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces (consumed by Task 5): new keys on `PineappleIcons` — `refresh`, `file_download`, `delete`, `settings`, `search`, `first_page`, `last_page`, `chevron_left`, `chevron_right`. Each is a full inline `<svg viewBox="0 0 24 24" fill="currentColor"><path d="..."/></svg>`.
|
||||
|
||||
- [ ] **Step 1: Append the new icons**
|
||||
|
||||
Inside the `PineappleIcons` object (after the `receipt` line), add (note trailing commas between entries, last entry has none):
|
||||
|
||||
```js
|
||||
refresh: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M17.65,6.35C16.2,4.9 14.21,4 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20C15.73,20 18.84,17.45 19.73,14H17.65C16.83,16.33 14.61,18 12,18A6,6 0 0,1 6,12A6,6 0 0,1 12,6C13.66,6 15.14,6.69 16.22,7.78L13,11H20V4L17.65,6.35Z"/></svg>',
|
||||
file_download: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19,9H15V3H9V9H5L12,16L19,9M11,18H13V22H11V18Z"/></svg>',
|
||||
delete: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6,19C6,20.1 6.9,21 8,21H16C17.1,21 18,20.1 18,19V7H6V19M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19V4Z"/></svg>',
|
||||
settings: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19.14,12.94C19.18,12.64 19.2,12.33 19.2,12C19.2,11.68 19.18,11.36 19.13,11.06L21.16,9.48C21.34,9.34 21.39,9.07 21.28,8.87L19.36,5.55C19.24,5.33 18.99,5.26 18.77,5.33L16.38,6.29C15.88,5.91 15.35,5.59 14.76,5.35L14.4,2.81C14.36,2.57 14.16,2.4 13.92,2.4H10.08C9.84,2.4 9.65,2.57 9.61,2.81L9.25,5.35C8.66,5.59 8.12,5.91 7.63,6.29L5.24,5.33C5.02,5.26 4.77,5.33 4.65,5.55L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48L4.89,11.06C4.84,11.36 4.8,11.67 4.8,12C4.8,12.33 4.82,12.64 4.87,12.94L2.84,14.52C2.66,14.66 2.61,14.93 2.72,15.13L4.64,18.45C4.76,18.67 5.01,18.74 5.23,18.67L7.62,17.71C8.12,18.09 8.65,18.41 9.24,18.65L9.6,21.19C9.65,21.43 9.84,21.6 10.08,21.6H13.92C14.16,21.6 14.36,21.43 14.4,21.19L14.76,18.65C15.35,18.41 15.88,18.09 16.38,17.71L18.77,18.67C18.99,18.74 19.24,18.67 19.36,18.45L21.28,15.13C21.39,14.93 21.34,14.66 21.16,14.52L19.14,12.94M12,15.6C10.02,15.6 8.4,13.98 8.4,12C8.4,10.02 10.02,8.4 12,8.4C13.98,8.4 15.6,10.02 15.6,12C15.6,13.98 13.98,15.6 12,15.6Z"/></svg>',
|
||||
search: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M15.5,14H14.71L14.43,13.73C15.41,12.59 16,11.11 16,9.5C16,5.91 13.09,3 9.5,3C5.91,3 3,5.91 3,9.5C3,13.09 5.91,16 9.5,16C11.11,16 12.59,15.41 13.73,14.43L14,14.71V15.5L19,20.49L20.49,19L15.5,14M9.5,14C7.01,14 5,11.99 5,9.5C5,7.01 7.01,5 9.5,5C11.99,5 14,7.01 14,9.5C14,11.99 11.99,14 9.5,14Z"/></svg>',
|
||||
first_page: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M18.41,16.59L13.82,12L18.41,7.41L17,6L11,12L17,18L18.41,16.59M6,6H8V18H6V6Z"/></svg>',
|
||||
last_page: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M5.59,7.41L10.18,12L5.59,16.59L7,18L13,12L7,6L5.59,7.41M16,6H18V18H16V6Z"/></svg>',
|
||||
chevron_left: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M15.41,16.58L10.83,12L15.41,7.41L14,6L8,12L14,18L15.41,16.58Z"/></svg>',
|
||||
chevron_right: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z"/></svg>'
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify**
|
||||
|
||||
```powershell
|
||||
$c = Get-Content -Raw payload\user\general\pager-webui\www\js\icons.js
|
||||
@('refresh','file_download','delete','settings','search','first_page','last_page','chevron_left','chevron_right') | ForEach-Object { if ($c -match $_ + ':') { "$_ OK" } else { "$_ MISSING" } }
|
||||
```
|
||||
Expected: all `OK`.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add payload/user/general/pager-webui/www/js/icons.js
|
||||
git commit -m "feat: add Material action icons for recon rework"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: CSS — Mark VII recon styles
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/general/pager-webui/www/css/app.css` (append; do not remove existing classes)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: existing custom properties (`--surface`, `--border`, `--muted`, `--primary`, `--shadow`, `--text`), `html.dark` overrides.
|
||||
- Produces (consumed by Task 5): classes `.recon-title-card-container`, `.recon-title-card`, `.recon-card`, `.recon-title-card-title`, `.recon-card-title-link`, `.recon-title-card-content`, `.recon-chart-box`, `.recon-no-data`, `.recon-hs-col`, `.recon-hs-count`, `.recon-hs-label`, `.recon-toggle`, `.recon-ps-row`, `.recon-scan-bar`, `.recon-table-head`, `.recon-search`, `.recon-paginator`, `.icon-btn`, `.recon-scan-results-card`, `.recon-table-body`, `.recon-settings-sidebar`, `.recon-settings-head`, `.recon-settings-title`, `.recon-settings-section`, `.recon-row-selected`.
|
||||
|
||||
- [ ] **Step 1: Append the recon stylesheet block**
|
||||
|
||||
Append to `app.css`:
|
||||
|
||||
```css
|
||||
/* ---- Recon (Mark VII parity) ---- */
|
||||
.recon-title-card-container { display: flex; width: 100%; flex-wrap: wrap; justify-content: space-between; gap: 10px; margin: 8px 0 16px; }
|
||||
.recon-title-card { flex: 1 1 220px; min-width: 220px; margin-bottom: 1em; }
|
||||
.recon-card { background: var(--surface); border-radius: 2px; box-shadow: var(--shadow); height: 200px; padding: 12px 16px; display: flex; flex-direction: column; }
|
||||
.recon-title-card-title { font-size: 20px; margin-bottom: 15px; display: flex; align-items: center; color: var(--text); }
|
||||
.recon-card-title-link { color: inherit; text-decoration: none; }
|
||||
.recon-card-title-link:visited { color: inherit; }
|
||||
.recon-card-title-link:hover { text-decoration: underline; }
|
||||
.recon-title-card-content { display: flex; justify-content: center; align-items: center; height: 70%; }
|
||||
.recon-chart-box { width: 100%; height: 150px; position: relative; }
|
||||
.recon-chart-box canvas { width: 100%; height: 100%; }
|
||||
.recon-no-data { font-style: italic; color: #787878; display: flex; justify-content: center; padding: 12px; }
|
||||
.recon-hs-col { display: flex; flex-direction: column; justify-content: center; align-items: center; }
|
||||
.recon-hs-count { font-size: 32px; font-weight: 700; line-height: 1.1; }
|
||||
.recon-hs-label { color: grey; margin: 2px 0 10px; }
|
||||
.recon-toggle { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text); margin: 0; cursor: pointer; }
|
||||
.recon-ps-row { display: flex; align-items: center; width: 100%; gap: 4px; }
|
||||
.recon-ps-row .sel { width: 100%; }
|
||||
.icon-btn { background: transparent; color: var(--muted); border: 0; border-radius: 50%; width: 36px; height: 36px; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; padding: 0; }
|
||||
.icon-btn:hover { background: var(--surface-alt); color: var(--text); }
|
||||
.icon-btn:disabled { opacity: .38; cursor: default; }
|
||||
.icon-btn svg { width: 22px; height: 22px; }
|
||||
.recon-scan-bar { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
|
||||
.recon-scan-bar .sel { width: auto; }
|
||||
.recon-scan-results-card { }
|
||||
.recon-table-head { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; flex-wrap: wrap; }
|
||||
.recon-table-head h2 { margin: 0; }
|
||||
.recon-search { max-width: 180px; }
|
||||
.recon-paginator { display: flex; align-items: center; gap: 2px; font-size: 12px; }
|
||||
.recon-paginator .icon-btn { width: 30px; height: 30px; }
|
||||
.recon-paginator .icon-btn svg { width: 18px; height: 18px; }
|
||||
.recon-table-body { }
|
||||
.recon-row-selected td { background: #eaeaea; }
|
||||
html.dark .recon-row-selected td { background: #565656; }
|
||||
.recon-settings-sidebar {
|
||||
position: fixed; top: 64px; right: 0; bottom: 0; width: 270px; z-index: 50;
|
||||
background: var(--surface); box-shadow: -2px 0 6px rgba(0,0,0,.24); padding: 16px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.recon-settings-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
||||
.recon-settings-title { font-size: 20px; }
|
||||
.recon-settings-section { font-size: 14px; font-weight: 500; margin: 14px 0 4px; color: var(--muted); }
|
||||
.recon-settings-sidebar .toggle { font-size: 13px; }
|
||||
.recon-handshakes-card .recon-table-head h2 { font-size: 20px; margin-bottom: 15px; }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Sanity-check**
|
||||
|
||||
```powershell
|
||||
Select-String -Path payload\user\general\pager-webui\www\css\app.css -Pattern 'recon-title-card-container','recon-scan-bar','recon-settings-sidebar','recon-row-selected'
|
||||
```
|
||||
Expected: all four found.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add payload/user/general/pager-webui/www/css/app.css
|
||||
git commit -m "feat: Mark VII recon styles (title cards, scan bar, tables, settings sidebar)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Views — rewrite `views.recon`, restyle handshakes, remove Events
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/general/pager-webui/www/js/views.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `h`, `table`, `fmtTime`, `btn`, `tabBar` (all existing module-level helpers), `PagerAPI`, `App.toast`, `App.apiBase`, `PineappleIcons` (Task 3), `MiniChart.doughnut` / `MiniChart.bar` (Task 2).
|
||||
- Produces: module-level `iconBtn(name, title, onclk)` helper; `RECON_TABS` (2 entries); `views.recon`; restyled `views.recon_handshakes`. **Deletes** `views.recon_events`.
|
||||
|
||||
- [ ] **Step 1: Add the `iconBtn` helper**
|
||||
|
||||
After the `btn` helper definition (near line 53):
|
||||
|
||||
```js
|
||||
const iconBtn = (name, title, onclk) => {
|
||||
const b = h('button', { class: 'icon-btn', title: title || '', onclick: onclk });
|
||||
b.innerHTML = PineappleIcons[name] || '';
|
||||
return b;
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace `RECON_TABS` and add constants**
|
||||
|
||||
Replace the current `RECON_TABS` (3 entries) with:
|
||||
|
||||
```js
|
||||
const RECON_TABS = [
|
||||
{ label: 'Scanning', hash: '#/recon' },
|
||||
{ label: 'Handshakes', hash: '#/recon/handshakes' }
|
||||
];
|
||||
|
||||
const RECON_LANDSCAPE_COLORS = ['#2ecc71', '#2980b9', '#8e44ad'];
|
||||
const RECON_CHANNEL_COLORS = ['#FC68AC','#4545FF','#19DE8F','#FF294A','#23E8DB','#0FD349','#4D4AFF','#E2FF68','#FF8368','#B1FF6A','#FFFF3B','#FF677E','#D0FF6E','#F57D67','#F828E4','#EAFF6D','#3676F9','#F169E8','#3B2AE4','#3197F5','#4040FF','#FFF26A','#FCAD67','#0ACE28','#FF9E68','#55FF4A','#F9FF68','#EE687E','#FFFC67','#FFE167','#7FFF6C','#FFF236','#F26868','#6DFF74','#F568D5','#FF402A','#CAFF69','#28C20A','#6B29E9','#C7FF40','#FFB631','#D429F3','#F868C1','#14D96B','#9E29EF','#8EFF45','#FF2980','#FD29B3','#FF7A2C','#FF6967','#FFD569','#27D6EC','#98FF6B','#1EE3B5','#FFFF6B','#FFB969','#FFFF6C','#FF6795','#0BC80A','#3B54FD','#F99467','#FFC667','#2CB7F1','#6EFF91'];
|
||||
const RECON_AP_COLS = [
|
||||
{ key: 'ssid', label: 'SSID', render: (a) => a.ssid || '(hidden)' },
|
||||
{ key: 'bssid', label: 'MAC', render: (a) => a.bssid || '--' },
|
||||
{ key: 'channel', label: 'Channel', render: (a) => a.channel == null ? '--' : a.channel },
|
||||
{ key: 'signal', label: 'Signal', render: (a) => a.signal == null ? '--' : a.signal + ' dBm' },
|
||||
{ key: 'encryption', label: 'Encryption', render: (a) => a.encryption || '--' },
|
||||
{ key: 'hidden', label: 'Hidden', render: (a) => a.hidden ? 'Yes' : 'No' }
|
||||
];
|
||||
const RECON_CLIENT_COLS = [
|
||||
{ key: 'mac', label: 'Client MAC', render: (c) => c.mac },
|
||||
{ key: 'signal', label: 'Signal', render: (c) => c.signal == null ? '--' : c.signal + ' dBm' },
|
||||
{ key: 'freq', label: 'Frequency', render: (c) => c.freq || '--' },
|
||||
{ key: 'packets', label: 'Packets', render: (c) => c.packets || 0 }
|
||||
];
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Replace `views.recon`**
|
||||
|
||||
Replace the entire `views.recon = (root) => {...};` block (lines 364-525 in the current file) with:
|
||||
|
||||
```js
|
||||
function reconDefaultCols() {
|
||||
return {
|
||||
ap: { ssid: true, bssid: true, channel: true, signal: true, encryption: true, hidden: true },
|
||||
client: { mac: true, signal: true, freq: true, packets: true }
|
||||
};
|
||||
}
|
||||
|
||||
function reconLoadCols() {
|
||||
try {
|
||||
const v = JSON.parse(localStorage.getItem('pw_recon_cols'));
|
||||
if (v && v.ap && v.client) return v;
|
||||
} catch (e) {}
|
||||
return reconDefaultCols();
|
||||
}
|
||||
|
||||
function reconFiltered(rows, q, colsArr) {
|
||||
const ql = (q || '').toLowerCase();
|
||||
if (!ql) return rows;
|
||||
return rows.filter((r) => colsArr.some((c) => String(r[c.key] == null ? '' : r[c.key]).toLowerCase().indexOf(ql) !== -1));
|
||||
}
|
||||
|
||||
views.recon = (root) => {
|
||||
root.appendChild(h('h1', { class: 'page-title', text: 'Recon' }));
|
||||
tabBar(root, RECON_TABS, '#/recon');
|
||||
|
||||
const state = { scans: [], selected: null, detail: null, active: false,
|
||||
apPage: 0, apSearch: '', cliPage: 0, cliSearch: '' };
|
||||
const cols = reconLoadCols();
|
||||
|
||||
function iconBtnView(name, title, onclk) { return iconBtn(name, title, onclk); }
|
||||
|
||||
// ---- title cards ----
|
||||
const cardWrap = h('div', { class: 'recon-title-card-container' });
|
||||
root.appendChild(cardWrap);
|
||||
|
||||
function titleCard(titleText, link) {
|
||||
const wrap = h('div', { class: 'recon-title-card' });
|
||||
const card = h('div', { class: 'recon-card' });
|
||||
wrap.appendChild(card);
|
||||
card.appendChild(link
|
||||
? h('a', { class: 'recon-card-title-link', href: '#/recon/handshakes', text: titleText })
|
||||
: h('div', { class: 'recon-title-card-title', text: titleText }));
|
||||
const content = h('div', { class: 'recon-title-card-content' });
|
||||
card.appendChild(content);
|
||||
cardWrap.appendChild(wrap);
|
||||
return content;
|
||||
}
|
||||
|
||||
const landContent = titleCard('Wireless Landscape', false);
|
||||
const landBox = h('div', { class: 'recon-chart-box' });
|
||||
landContent.appendChild(landBox);
|
||||
const landCanvas = h('canvas', { id: 'recon-landscape' });
|
||||
landBox.appendChild(landCanvas);
|
||||
const landEmpty = h('div', { class: 'recon-no-data', text: 'No wireless landscape data is available yet.' });
|
||||
landBox.appendChild(landEmpty);
|
||||
|
||||
const chanContent = titleCard('Channel Distribution', false);
|
||||
const chanBox = h('div', { class: 'recon-chart-box' });
|
||||
chanContent.appendChild(chanBox);
|
||||
const chanCanvas = h('canvas', { id: 'recon-channel' });
|
||||
chanBox.appendChild(chanCanvas);
|
||||
const chanEmpty = h('div', { class: 'recon-no-data', text: 'No channel distribution data is available yet.' });
|
||||
chanBox.appendChild(chanEmpty);
|
||||
|
||||
const hsContent = titleCard('Handshakes', true);
|
||||
const hsCol = h('div', { class: 'recon-hs-col' });
|
||||
hsContent.appendChild(hsCol);
|
||||
const hsCount = h('span', { class: 'recon-hs-count', text: '0' });
|
||||
hsCol.appendChild(hsCount);
|
||||
hsCol.appendChild(h('span', { class: 'recon-hs-label', text: 'Handshakes Captured' }));
|
||||
const hsAuto = h('label', { class: 'recon-toggle' },
|
||||
h('input', { type: 'checkbox', id: 'recon-auto-hs' }), ' Automatically Collect Any Handshakes');
|
||||
hsAuto.querySelector('input').addEventListener('change', () => {
|
||||
PagerAPI.post('/api/pineap/settings', { collect_handshakes: hsAuto.querySelector('input').checked })
|
||||
.then(() => App.toast('Settings saved')).catch(() => App.toast('Failed to save', 'error'));
|
||||
});
|
||||
hsCol.appendChild(hsAuto);
|
||||
|
||||
const psContent = titleCard('Previous Scans', false);
|
||||
const psRow = h('div', { class: 'recon-ps-row' });
|
||||
psContent.appendChild(psRow);
|
||||
const sel = h('select', { class: 'sel', id: 'recon-scan-select' });
|
||||
sel.addEventListener('change', () => {
|
||||
state.selected = parseInt(sel.value, 10) || null;
|
||||
state.apPage = 0; state.cliPage = 0;
|
||||
loadDetail();
|
||||
});
|
||||
psRow.appendChild(sel);
|
||||
psRow.appendChild(iconBtnView('file_download', 'Download scan JSON', () => {
|
||||
if (state.selected != null) window.location = App.apiBase + '/api/recon/scans/' + state.selected + '/download/json';
|
||||
}));
|
||||
psRow.appendChild(iconBtnView('delete', 'Delete scan', () => {
|
||||
if (state.selected == null) return;
|
||||
if (!confirm('Delete scan #' + state.selected + '? This cannot be undone.')) return;
|
||||
PagerAPI.del('/api/recon/scans/' + state.selected)
|
||||
.then(() => { App.toast('Scan deleted'); load(); })
|
||||
.catch(() => App.toast('Delete failed', 'error'));
|
||||
}));
|
||||
|
||||
// ---- scan bar ----
|
||||
const scanBar = h('div', { class: 'section recon-scan-bar' });
|
||||
root.appendChild(scanBar);
|
||||
const scanToggle = h('input', { type: 'checkbox', id: 'recon-scan-toggle' });
|
||||
const scanLabel = h('label', { class: 'switch recon-scan-toggle' }, scanToggle, h('span', { class: 'track' }), ' Scan');
|
||||
scanBar.appendChild(scanLabel);
|
||||
const durSel = h('select', { class: 'sel', id: 'recon-duration' });
|
||||
[[30, '30 Seconds'], [60, '1 Minute'], [120, '2 Minutes'], [300, '5 Minutes'], [600, '10 Minutes'], [0, 'Continuous']]
|
||||
.forEach(([v, t]) => durSel.appendChild(h('option', { value: String(v), text: t })));
|
||||
durSel.value = localStorage.getItem('pw_scan_duration') || '30';
|
||||
durSel.addEventListener('change', () => localStorage.setItem('pw_scan_duration', durSel.value));
|
||||
scanBar.appendChild(durSel);
|
||||
scanBar.appendChild(h('span', { class: 'toolbar-spacer' }));
|
||||
scanBar.appendChild(iconBtnView('settings', 'Recon settings', () => sidebar.classList.toggle('hidden')));
|
||||
scanToggle.addEventListener('change', () => {
|
||||
const on = scanToggle.checked;
|
||||
scanToggle.disabled = true;
|
||||
PagerAPI.post(on ? '/api/recon/start' : '/api/recon/stop', on ? { scan_time: parseInt(durSel.value, 10) } : {})
|
||||
.then(() => { App.toast(on ? 'Scan started' : 'Scan stopped'); load(); })
|
||||
.catch(() => { scanToggle.checked = !on; App.toast('Recon control failed', 'error'); })
|
||||
.finally(() => { scanToggle.disabled = false; });
|
||||
});
|
||||
|
||||
// ---- settings sidebar ----
|
||||
const sidebar = h('div', { class: 'recon-settings-sidebar hidden' });
|
||||
sidebar.appendChild(h('div', { class: 'recon-settings-head' },
|
||||
h('span', { class: 'recon-settings-title', text: 'Recon Settings' }),
|
||||
btn('×', () => sidebar.classList.add('hidden'), 'ghost')));
|
||||
const colDefs = {
|
||||
ap: [['ssid', 'Show SSID'], ['bssid', 'Show MAC'], ['channel', 'Show Channel'],
|
||||
['signal', 'Show Signal'], ['encryption', 'Show Encryption'], ['hidden', 'Show Hidden']],
|
||||
client: [['mac', 'Show MAC'], ['signal', 'Show Signal'], ['freq', 'Show Frequency'], ['packets', 'Show Packets']]
|
||||
};
|
||||
Object.keys(colDefs).forEach((grp) => {
|
||||
sidebar.appendChild(h('div', { class: 'recon-settings-section', text: grp === 'ap' ? 'Access Points' : 'Clients' }));
|
||||
colDefs[grp].forEach(([key, label]) => {
|
||||
const cb = h('input', { type: 'checkbox', id: 'col-' + grp + '-' + key });
|
||||
cb.checked = cols[grp][key];
|
||||
cb.addEventListener('change', () => { cols[grp][key] = cb.checked; localStorage.setItem('pw_recon_cols', JSON.stringify(cols)); renderTables(); });
|
||||
sidebar.appendChild(h('label', { class: 'toggle' }, cb, ' ' + label));
|
||||
});
|
||||
});
|
||||
root.appendChild(sidebar);
|
||||
|
||||
// ---- results tables ----
|
||||
const apCard = h('div', { class: 'section recon-scan-results-card' });
|
||||
root.appendChild(apCard);
|
||||
const cliCard = h('div', { class: 'section recon-scan-results-card' });
|
||||
root.appendChild(cliCard);
|
||||
|
||||
function buildPaginator(key) {
|
||||
const mk = (id, label, fn) => {
|
||||
const b = h('button', { class: 'icon-btn', id: key + '-' + id, title: label });
|
||||
b.innerHTML = PineappleIcons[['first', 'last'].indexOf(id) !== -1
|
||||
? (id === 'first' ? 'first_page' : 'last_page')
|
||||
: (id === 'prev' ? 'chevron_left' : 'chevron_right')] || '';
|
||||
b.addEventListener('click', fn);
|
||||
return b;
|
||||
};
|
||||
return h('div', { class: 'recon-paginator' },
|
||||
mk('first', 'First page', () => { state[key + 'Page'] = 0; renderTables(); }),
|
||||
mk('prev', 'Previous page', () => { state[key + 'Page'] = Math.max(0, state[key + 'Page'] - 1); renderTables(); }),
|
||||
h('span', { class: 'muted', id: key + '-range', text: '' }),
|
||||
mk('next', 'Next page', () => { state[key + 'Page'] = Math.min(reconPageCount(key) - 1, state[key + 'Page'] + 1); renderTables(); }),
|
||||
mk('last', 'Last page', () => { state[key + 'Page'] = Math.max(0, reconPageCount(key) - 1); renderTables(); }));
|
||||
}
|
||||
|
||||
function reconPageCount(key) {
|
||||
const d = state.detail || {};
|
||||
const rows = key === 'ap' ? (d.aps || []) : (d.clients || []);
|
||||
const colsArr = key === 'ap' ? RECON_AP_COLS : RECON_CLIENT_COLS;
|
||||
const q = key === 'ap' ? state.apSearch : state.cliSearch;
|
||||
return Math.max(1, Math.ceil(reconFiltered(rows, q, colsArr).length / 10));
|
||||
}
|
||||
|
||||
function tableHead(box, title, key, searchId, onInput) {
|
||||
const head = h('div', { class: 'recon-table-head' },
|
||||
h('h2', { text: title }),
|
||||
h('span', { class: 'toolbar-spacer' }),
|
||||
h('input', { class: 'recon-search', id: searchId, placeholder: 'Search' }),
|
||||
buildPaginator(key));
|
||||
box.appendChild(head);
|
||||
const input = head.querySelector('#' + searchId);
|
||||
input.addEventListener('input', onInput);
|
||||
return head;
|
||||
}
|
||||
|
||||
tableHead(apCard, 'Access Points', 'ap', 'ap-search', () => { state.apSearch = document.getElementById('ap-search').value; state.apPage = 0; renderTables(); });
|
||||
const apBody = h('div', { class: 'recon-table-body' });
|
||||
apCard.appendChild(apBody);
|
||||
|
||||
tableHead(cliCard, 'Clients', 'client', 'cli-search', () => { state.cliSearch = document.getElementById('cli-search').value; state.cliPage = 0; renderTables(); });
|
||||
const cliBody = h('div', { class: 'recon-table-body' });
|
||||
cliCard.appendChild(cliBody);
|
||||
|
||||
function renderTable(box, key, rows, colsArr, selectedBssid, emptyMsg) {
|
||||
box.innerHTML = '';
|
||||
const vis = colsArr.filter((c) => cols[key][c.key]);
|
||||
const page = state[key + 'Page'];
|
||||
const start = page * 10;
|
||||
const slice = rows.slice(start, start + 10);
|
||||
box.appendChild(table(vis, slice,
|
||||
key === 'ap' ? (r) => ({ style: 'cursor:pointer' + (r.bssid === selectedBssid ? ';background:var(--surface-alt)' : '') }) : undefined));
|
||||
if (!rows.length) box.appendChild(h('div', { class: 'empty', text: emptyMsg }));
|
||||
const range = document.getElementById(key + '-range');
|
||||
if (range) range.textContent = rows.length ? (start + 1) + '–' + Math.min(start + 10, rows.length) + ' of ' + rows.length : '0 of 0';
|
||||
const p = reconPageCount(key);
|
||||
[['first', 0], ['prev', 0], ['next', p - 1], ['last', p - 1]].forEach(([id, limit]) => {
|
||||
const el = document.getElementById(key + '-' + id);
|
||||
if (el) el.disabled = page >= p - 1 && limit !== 0 ? true : (page <= 0 && (id === 'first' || id === 'prev'));
|
||||
});
|
||||
}
|
||||
|
||||
function renderTables() {
|
||||
const d = state.detail || { aps: [], clients: [], handshakes: [] };
|
||||
const apRows = reconFiltered(d.aps || [], state.apSearch, RECON_AP_COLS);
|
||||
const cliRows = reconFiltered(d.clients || [], state.cliSearch, RECON_CLIENT_COLS);
|
||||
renderTable(apBody, 'ap', apRows, RECON_AP_COLS, '', 'No access points in this scan.');
|
||||
renderTable(cliBody, 'client', cliRows, RECON_CLIENT_COLS, '', 'No clients in this scan.');
|
||||
}
|
||||
|
||||
function drawCharts(d) {
|
||||
const n = (d.aps || []).length;
|
||||
const c = (d.clients || []).length;
|
||||
const land = document.getElementById('recon-landscape');
|
||||
if (land && typeof MiniChart !== 'undefined' && MiniChart.doughnut) {
|
||||
if (n + c > 0) {
|
||||
MiniChart.doughnut(land, [
|
||||
{ label: 'Access Points', value: n, color: RECON_LANDSCAPE_COLORS[0] },
|
||||
{ label: 'Clients', value: c, color: RECON_LANDSCAPE_COLORS[1] },
|
||||
{ label: 'Unassociated', value: 0, color: RECON_LANDSCAPE_COLORS[2] }
|
||||
], { legend: true, height: 130 });
|
||||
land.classList.remove('hidden');
|
||||
landEmpty.classList.add('hidden');
|
||||
} else {
|
||||
land.classList.add('hidden');
|
||||
landEmpty.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
const counts = {};
|
||||
(d.aps || []).forEach((a) => {
|
||||
const ch = a.channel == null ? '?' : a.channel;
|
||||
counts[ch] = (counts[ch] || 0) + 1;
|
||||
});
|
||||
const keys = Object.keys(counts).sort((a, b) => {
|
||||
if (a === '?') return 1;
|
||||
if (b === '?') return -1;
|
||||
return Number(a) - Number(b);
|
||||
});
|
||||
const ch = document.getElementById('recon-channel');
|
||||
if (ch && typeof MiniChart !== 'undefined' && MiniChart.bar) {
|
||||
if (keys.length) {
|
||||
MiniChart.bar(ch, keys.map((k, i) => ({
|
||||
label: k, value: counts[k], color: RECON_CHANNEL_COLORS[i % RECON_CHANNEL_COLORS.length]
|
||||
})), { height: 130 });
|
||||
ch.classList.remove('hidden');
|
||||
chanEmpty.classList.add('hidden');
|
||||
} else {
|
||||
ch.classList.add('hidden');
|
||||
chanEmpty.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loadDetail() {
|
||||
if (state.selected == null) return;
|
||||
PagerAPI.get('/api/recon/scans/' + state.selected).then((r) => {
|
||||
state.detail = r.data;
|
||||
drawCharts(r.data);
|
||||
renderTables();
|
||||
hsCount.textContent = (r.data.handshakes || []).length;
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function load() {
|
||||
PagerAPI.get('/api/recon/scans').then((r) => {
|
||||
state.scans = r.data.scans || [];
|
||||
const keep = state.selected && state.scans.some((s) => s.id === state.selected)
|
||||
? state.selected : (state.scans[0] ? state.scans[0].id : null);
|
||||
sel.innerHTML = '';
|
||||
state.scans.forEach((s) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = s.id;
|
||||
opt.textContent = 'Scan #' + s.id + ' — ' + fmtTime(s.time);
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
if (keep == null) {
|
||||
state.detail = null;
|
||||
drawCharts({ aps: [], clients: [], handshakes: [] });
|
||||
renderTables();
|
||||
hsCount.textContent = '0';
|
||||
}
|
||||
if (keep != null) sel.value = keep;
|
||||
state.selected = keep;
|
||||
if (keep != null) loadDetail();
|
||||
}).catch(() => {});
|
||||
PagerAPI.get('/api/recon/status').then((r) => {
|
||||
state.active = !!r.data.active;
|
||||
scanToggle.checked = state.active;
|
||||
}).catch(() => {});
|
||||
PagerAPI.get('/api/pineap/settings').then((r) => {
|
||||
hsAuto.querySelector('input').checked = !!((r.data.settings || {}).collect_handshakes);
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
load();
|
||||
const iv = setInterval(load, 10000);
|
||||
return { destroy: () => clearInterval(iv) };
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Replace `views.recon_handshakes` title row**
|
||||
|
||||
In `views.recon_handshakes`, replace the line:
|
||||
|
||||
```js
|
||||
const box = h('div', { class: 'section' }, h('h2', {}, 'Handshakes'));
|
||||
root.appendChild(box);
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```js
|
||||
const box = h('div', { class: 'section recon-handshakes-card' });
|
||||
root.appendChild(box);
|
||||
```
|
||||
|
||||
and replace the first two lines inside `load()`:
|
||||
|
||||
```js
|
||||
box.innerHTML = '';
|
||||
box.appendChild(h('h2', {}, 'Handshakes'));
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```js
|
||||
box.innerHTML = '';
|
||||
const head = h('div', { class: 'recon-table-head' },
|
||||
h('h2', { text: 'Captured WPA Handshakes' }),
|
||||
h('span', { class: 'toolbar-spacer' }),
|
||||
iconBtn('settings', 'Handshakes settings', () => App.toast('Handshakes settings are not available on the Pager')));
|
||||
box.appendChild(head);
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Remove `views.recon_events`**
|
||||
|
||||
Delete the entire `views.recon_events = (root) => {...};` block (lines 578-612 in the current file), including its `let all = []; let page = 0;` state and pager markup.
|
||||
|
||||
- [ ] **Step 6: Verify definitions**
|
||||
|
||||
```powershell
|
||||
$v = Get-Content -Raw payload\user\general\pager-webui\www\js\views.js
|
||||
@('RECON_TABS','reconDefaultCols','reconLoadCols','reconFiltered','iconBtn','views.recon =','views.recon_handshakes =','RECON_CHANNEL_COLORS','RECON_AP_COLS','RECON_CLIENT_COLS') | ForEach-Object { if ($v -match [regex]::Escape($_) ) { "$_ OK" } else { "$_ MISSING" } }
|
||||
if ($v -match 'views\.recon_events') { 'recon_events STILL PRESENT (bad)' } else { 'recon_events REMOVED (good)' }
|
||||
```
|
||||
Expected: all definitions `OK`, `recon_events REMOVED (good)`.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add payload/user/general/pager-webui/www/js/views.js
|
||||
git commit -m "feat: Mark VII recon scanning view, restyled handshakes, remove Events tab"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Routing — drop the Events route
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/general/pager-webui/www/js/app.js:180`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `views.recon`, `views.recon_handshakes` (Task 5). `views.recon_events` no longer exists.
|
||||
- Produces: routes map without `#/recon/events`.
|
||||
|
||||
- [ ] **Step 1: Remove the line**
|
||||
|
||||
Delete line 180 (`'#/recon/events': 'recon_events',`) from the `routes` object.
|
||||
|
||||
- [ ] **Step 2: Verify**
|
||||
|
||||
```powershell
|
||||
$c = Get-Content -Raw payload\user\general\pager-webui\www\js\app.js
|
||||
if ($c -match "'#/recon/events'") { 'events route STILL PRESENT (bad)' } else { 'events route REMOVED (good)' }
|
||||
```
|
||||
Expected: `events route REMOVED (good)`.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add payload/user/general/pager-webui/www/js/app.js
|
||||
git commit -m "fix: remove recon events route"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Build, deploy, on-device verification
|
||||
|
||||
**Files:**
|
||||
- No source changes.
|
||||
|
||||
- [ ] **Step 1: Run the full backend test suite**
|
||||
|
||||
```powershell
|
||||
Get-ChildItem tests\test_*.py | ForEach-Object {
|
||||
$mod = "tests." + [IO.Path]::GetFileNameWithoutExtension($_.Name)
|
||||
& "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe" -m unittest $mod -v
|
||||
}
|
||||
```
|
||||
Expected: every module PASS.
|
||||
|
||||
- [ ] **Step 2: Deploy to the Pager**
|
||||
|
||||
```powershell
|
||||
& .\scripts\deploy.ps1 -SshKey "$HOME\.ssh\pager_key" -Password "<PAGER_PASSWORD>"
|
||||
```
|
||||
(If no key, rely on sshpass; otherwise run the printed scp/ssh commands manually.)
|
||||
|
||||
- [ ] **Step 3: Verify assets serve**
|
||||
|
||||
```powershell
|
||||
curl.exe -s -o NUL -w "%{http_code} %{size_download}`n" http://172.16.52.1:8080/
|
||||
curl.exe -s -o NUL -w "%{http_code} %{size_download}`n" http://172.16.52.1:8080/js/chart.js
|
||||
curl.exe -s -o NUL -w "%{http_code} %{size_download}`n" http://172.16.52.1:8080/js/views.js
|
||||
curl.exe -s -o NUL -w "%{http_code} %{size_download}`n" http://172.16.52.1:8080/css/app.css
|
||||
```
|
||||
Expected: all `200` with non-zero sizes.
|
||||
|
||||
- [ ] **Step 4: On-device smoke pass**
|
||||
|
||||
Log in at `http://172.16.52.1:8080/` and walk through:
|
||||
- `#/recon`: two tabs (Scanning, Handshakes); 4 title cards render (landscape doughnut + legend, channel bar chart, handshakes count, previous-scans select + download/delete icons).
|
||||
- Scan bar: Scan toggle, duration select (persist via reload), settings icon opens sidebar (column toggles persist; hide SSID column → table updates).
|
||||
- Table search filters APs/Clients; paginator first/last/prev/next + range label work; 10-per-page.
|
||||
- Previous-scan select switches detail + charts + tables; download icon fetches JSON; delete icon confirms + deletes.
|
||||
- Handshakes tab: "Captured WPA Handshakes" card, file table, Download/Delete row actions, Download all / Archive.
|
||||
- `#/recon/events` → "View not available." (route gone). Keyboard `r` → `#/recon`.
|
||||
- Dark theme (Settings → Theme) renders cards correctly.
|
||||
- Backend: start a scan with Duration = 1 Minute; confirm `/api/recon/start` returns 200 (daemon acceptance of `scan_time` is daemon-dependent; UI unaffected either way).
|
||||
|
||||
- [ ] **Step 5: Commit any smoke fixes**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "fix: recon rework smoke-test fixes"
|
||||
```
|
||||
(Only if changes exist.)
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Notes (run before handing off)
|
||||
|
||||
- **Spec coverage:** §3.1 tabs → Tasks 5–6; §3.2 layout → Task 5; §3.3 handshakes → Task 5; §3.4 charts → Task 2; §3.5 icons → Task 3; §3.6 CSS → Task 4; §3.7 backend → Task 1; §3.8 routing → Task 6; §4 data flow → Task 5 (`pw_scan_duration`, `pw_recon_cols`); §5 testing → Tasks 1 & 7; §6 out of scope → enforced (no band select, no graph view, no focus sidebars, no `/api/recon/events` removal).
|
||||
- **Name consistency:** `MiniChart.doughnut`/`MiniChart.bar` defined in Task 2 and consumed in Task 5 with the documented signatures (`doughnut(canvas, [{label,value,color}], {legend,height})`, `bar(canvas, [{label,value,color}], {height})`). `iconBtn` defined in Task 5 Step 1, used in Steps 3–4. `RECON_*` constants defined in Step 2, used in Step 3. `reconFiltered`/`reconPageCount`/`renderTables`/`renderTable`/`drawCharts`/`loadDetail`/`load` all defined before first use inside `views.recon`.
|
||||
- **Guardrail:** Task 1 must not break `test_start_stop_handlers_call_socket` — that test asserts `body={}` and `h_recon_start` now reads `getattr(ctx, 'body', None)` (absent → `{}`).
|
||||
- **Placeholder scan:** no TBD/TODO; every code step ships the full content.
|
||||
@@ -0,0 +1,274 @@
|
||||
# Handshakes Mark VII Parity — Design Spec
|
||||
|
||||
- **Date:** 2026-08-11
|
||||
- **Status:** Draft (pending user review)
|
||||
- **Owner:** WiFi Pineapple Pager expansion project
|
||||
- **Applies to:** `payload/user/general/pager-webui/server.py`,
|
||||
`payload/user/general/pager-webui/www/`, `tests/`
|
||||
- **Supercedes §3.3 of:** `docs/superpowers/specs/2026-08-11-recon-markvii-design.md`
|
||||
(that section restyled the file-listing table; this spec replaces it with the
|
||||
real Mark VII handshake table)
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Rework the Pager WebUI **Recon → Handshakes** tab (`#/recon/handshakes`) so it
|
||||
looks and behaves like the stock Mark VII handshakes page: the same table
|
||||
(**BSSID, Client, Source, Type, Captured, Message 1–4, Beacon Frame**, action
|
||||
icons) with the same **Download** and **Delete** behavior, plus the **settings
|
||||
dialog** (handshake location + "Delete All Handshakes").
|
||||
|
||||
Built **entirely from Pager-local data** (loot files in `/root/loot/handshakes`
|
||||
+ the recon.db `handshake`/`wifi_device` tables). The Pager's Go daemon does
|
||||
**not** expose `/api/pineap/handshakes` (verified 404 on-device +
|
||||
`docs/specs/2026-08-11-pineapple-ui-clone-design.md`), so the Mark VII shape is
|
||||
synthesized by `server.py`, matching how every other PineAP/Recon endpoint is
|
||||
already implemented.
|
||||
|
||||
## 2. Mark VII reference (captured 2026-08-11)
|
||||
|
||||
Source of truth: live `GET /api/pineap/handshakes` on `172.16.42.1:1471`
|
||||
(hak5pineapple) and the handshakes component in the cached Angular bundle
|
||||
`%TEMP%\opencode\oldui-main.js`.
|
||||
|
||||
### 2.1 API shape
|
||||
|
||||
`GET /api/pineap/handshakes` → `{ "handshakes": [ { ... } ] }`. Each record:
|
||||
|
||||
```json
|
||||
{ "file_exists": true,
|
||||
"mac": "86:18:98:BE:CF:A5", // AP/BSSID (colon MAC, case as stored)
|
||||
"client": "D6:9B:56:ED:28:82", // client MAC, or an Evil-Twin label
|
||||
"source": "Recon", // "Recon" | "Evil WPA/2 Twin"
|
||||
"type": "full", // "full" | "eviltwin"
|
||||
"timestamp": "2023-12-05T16:03:18Z",
|
||||
"in_db": true, // handshake also present in recon DB
|
||||
"part_mask": 15, // bit 1=m1, 2=m2, 4=m3, 8=m4
|
||||
"beacon": true,
|
||||
"extension": "pcap", // pcap | 22000
|
||||
"location": "/root/handshakes/86-18-98-BE-CF-A5_D6-9B-56-ED-28-82_full.pcap" }
|
||||
```
|
||||
|
||||
### 2.2 Page
|
||||
|
||||
- Card title row: **"Captured WPA Handshakes"** (left) + `settings` gear icon
|
||||
button (right). Gear opens a dialog: handshake **location** (from
|
||||
`GET /api/pineap/handshakes/location`) and a **Delete All Handshakes** button
|
||||
(`DELETE /api/pineap/handshakes/`). On success/error a small green check /
|
||||
red X flashes beside the title for ~3s / ~5s.
|
||||
- Table columns: `BSSID, Client, Source, Type, Captured, Message 1, Message 2,
|
||||
Message 3, Message 4, Beacon Frame, [action]`.
|
||||
- **BSSID / Client**: colon MACs as returned.
|
||||
- **Source**: `"Recon"` or `"Evil WPA/2 Twin"`.
|
||||
- **Type**: `{{type|titlecase}} {{format}}` where `format` derives from
|
||||
`extension` (`pcap`→`PCAP`, `22000`→`Hashcat`, else `Unknown`) — e.g.
|
||||
`Full PCAP`, `Hashcat`.
|
||||
- **Captured**: localized date-time of `timestamp`.
|
||||
- **Message 1–4 / Beacon Frame**: when `in_db` is true → green **check** if
|
||||
the bit (`m1..m4`, `beacon`) is set, red **X** if not. When `in_db` is
|
||||
false → grey **?** icon with tooltip "This information isn't available.
|
||||
This is common when a handshake file has been found, but the associated
|
||||
Recon scan has been lost or deleted."
|
||||
- **[action]**: `file_download` icon button + `delete` icon button (warn
|
||||
color).
|
||||
- Empty state: `"No Handshakes Available"`.
|
||||
- Component logic (from bundle): `getHandshakes()` maps each record's
|
||||
`extension`→`format` and computes `m1=part_mask&1 … m4=part_mask&8`;
|
||||
`deleteHandshake(hs)` calls `DELETE /api/pineap/handshakes/delete` with the
|
||||
record as body, then reloads and flashes success/error; `downloadHandshake`
|
||||
saves the file as `<mac>_<client>_<type>.<extension>`.
|
||||
|
||||
## 3. Pager data model
|
||||
|
||||
- **Loot files:** `/root/loot/handshakes/` (Pager native path; matches
|
||||
pager-webui `LOOT_HS_DIR`). Native naming inferred from on-device payloads
|
||||
(`handshake_sanitiser`, `deduplicate`, `handshake_2_usb`) and `pineapd`
|
||||
strings:
|
||||
- full: `<unix_ts>_<AP_MAC>_<CLIENT_MAC>_handshake.pcap|.22000`
|
||||
- partial: `<unix_ts>_<AP_MAC>_<CLIENT_MAC>_handshake_partial.pcap|.22000`
|
||||
- incomplete: `…_handshake_incomplete.pcap|.22000`
|
||||
- MACs colon-separated (may be uppercase/lowercase); Windows-normalised
|
||||
copies use dashes. The parser must accept both.
|
||||
- **recon.db `handshake` table:** `hash, scan, stahash, aphash, time,
|
||||
beacon BLOB, hs1 BLOB, hs2 BLOB, hs3 BLOB, hs4 BLOB`. The pager-webui
|
||||
resolves `aphash/stahash` → MACs via `wifi_device` (pattern already used by
|
||||
`recon_scan_data`). This supplies `part_mask`, `beacon`, `in_db`, and a
|
||||
fallback timestamp.
|
||||
- **Sources:** the Pager has no Evil-WPA/2-Twin mode, so `source` is always
|
||||
`"Recon"` and `type` is `full` / `partial` / `incomplete`.
|
||||
|
||||
## 4. Backend (`server.py`)
|
||||
|
||||
### 4.1 `handshakes_data()` → `{ files, handshakes }`
|
||||
|
||||
Keep the existing `files` array unchanged (the dashboard reads it). Add a
|
||||
`handshakes` array in the Mark VII shape. Build order:
|
||||
|
||||
1. `os.listdir(LOOT_HS_DIR)` + `os.stat` per file (existing).
|
||||
2. **If no files → return `{files, handshakes: []}` immediately — zero DB
|
||||
reads** (the compute mitigation).
|
||||
3. Parse each filename (see §4.2) → `{ap, client, kind, ext, ts}`.
|
||||
4. **One batched correlation read** (only when files exist):
|
||||
`SELECT h.time, (h.hs1 IS NOT NULL AND length(h.hs1)>0) AS m1,
|
||||
(h.hs2 IS NOT NULL AND length(h.hs2)>0) AS m2,
|
||||
(h.hs3 IS NOT NULL AND length(h.hs3)>0) AS m3,
|
||||
(h.hs4 IS NOT NULL AND length(h.hs4)>0) AS m4,
|
||||
(h.beacon IS NOT NULL AND length(h.beacon)>0) AS beacon,
|
||||
w1.mac AS ap, w2.mac AS sta
|
||||
FROM handshake h
|
||||
JOIN wifi_device w1 ON w1.hash = h.aphash
|
||||
JOIN wifi_device w2 ON w2.hash = h.stahash
|
||||
WHERE h.time >= <oldest parsed file ts>
|
||||
ORDER BY h.time`
|
||||
(a single `_db_rows` call; `handshake.time` is epoch seconds). Build a dict
|
||||
keyed by normalized `(ap, sta)` keeping the latest row.
|
||||
5. Compose one record per file (§4.3). Pure Python dict lookups — O(1) per
|
||||
file. The join resolves hashes to MACs directly, so exactly **one** sqlite
|
||||
read is needed.
|
||||
|
||||
Total added cost per request: at most 1 `_db_rows` call, only when the loot
|
||||
dir is non-empty; filename parsing is microseconds.
|
||||
|
||||
### 4.2 Filename parser
|
||||
|
||||
Tolerant regex, accepts colon or dash MACs, optional `_handshake` token,
|
||||
optional quality suffix, any extension:
|
||||
|
||||
```
|
||||
^(\d+)_([0-9a-fA-F-:]+)_([0-9a-fA-F-:]+)_handshake(?:_(full|partial|incomplete))?\.([A-Za-z0-9]+)$
|
||||
```
|
||||
|
||||
Fallback: any unparseable file still appears as a row with
|
||||
`mac:'--', client:'--', type:'full', source:'Recon'`, `in_db:false` — it
|
||||
keeps Download/Delete and shows "?" glyphs (matches Mark VII's
|
||||
file-without-DB-record presentation).
|
||||
|
||||
### 4.3 Record composition (per file)
|
||||
|
||||
| field | source |
|
||||
|---|---|
|
||||
| `mac` | parsed AP MAC (colon, case as stored) |
|
||||
| `client` | parsed client MAC (or `--`) |
|
||||
| `source` | `"Recon"` |
|
||||
| `type` | `full` / `partial` / `incomplete` (from filename; default `full`) |
|
||||
| `timestamp` | epoch seconds (parsed `ts` prefix; fallback `st_mtime`; DB `time` wins if present) |
|
||||
| `in_db` | bool — matching `(ap,sta)` row in the correlation dict |
|
||||
| `part_mask` | `m1|m2|m3|m4` from DB row (0 when not in DB) |
|
||||
| `beacon` | bool from DB row (false when not in DB) |
|
||||
| `extension` | file extension |
|
||||
| `name` | bare filename (pager-webui convenience field; used for download/delete) |
|
||||
| `location` | `LOOT_HS_DIR + '/' + name` |
|
||||
| `file_exists` | true |
|
||||
|
||||
### 4.4 New routes
|
||||
|
||||
- `GET /api/pineap/handshakes/location` → `{location: LOOT_HS_DIR}`
|
||||
(mirrors Mark VII; read-only).
|
||||
- `DELETE /api/pineap/handshakes/all` → remove every file in `LOOT_HS_DIR`,
|
||||
return updated `handshakes_data()`. Mirrors Mark VII "delete all".
|
||||
- Existing `GET /api/pineap/handshakes/{name}` (download) and
|
||||
`DELETE /api/pineap/handshakes` `{name}` (per-row delete) unchanged.
|
||||
Optionally set the download's `Content-Disposition` filename to the Mark VII
|
||||
`<mac>_<client>_<type>.<ext>` form when the name parses.
|
||||
|
||||
## 5. Frontend (`www/`)
|
||||
|
||||
### 5.1 `views.recon_handshakes` (rewrite of `views.js:911`)
|
||||
|
||||
Keep: page title "Recon", `RECON_TABS` tab bar, card title row
|
||||
"Captured WPA Handshakes" + gear, and the Pager-specific
|
||||
`Download all (zip)` / `Archive` / `Refresh` row (user decision: keep, don't
|
||||
match Mark VII pixel-for-pixel there). Replace the file table with the Mark
|
||||
VII table:
|
||||
|
||||
- Custom table build (the generic `table()` helper only renders text; this
|
||||
needs glyphs + icon buttons), header row exactly:
|
||||
`BSSID, Client, Source, Type, Captured, Message 1, Message 2, Message 3,
|
||||
Message 4, Beacon Frame, ""`.
|
||||
- Cells:
|
||||
- **BSSID / Client**: text.
|
||||
- **Source**: `"Recon"`.
|
||||
- **Type**: `${title(type)} ${format}` via existing `hsType()` (PCAP /
|
||||
Hashcat / Unknown).
|
||||
- **Captured**: `fmtTime(timestamp)`.
|
||||
- **Message 1–4**: `!in_db` → `?` icon with `title` tooltip (Mark VII
|
||||
wording); else check icon when `part_mask & bit`, X icon when not.
|
||||
- **Beacon Frame**: `!in_db` → `?` icon; else check when `beacon`, X when
|
||||
not.
|
||||
- **Action**: `file_download` icon btn → `window.location =
|
||||
apiBase + '/api/pineap/handshakes/' + encodeURIComponent(name)`; `delete`
|
||||
icon btn (warn) → `PagerAPI.del('/api/pineap/handshakes', {name})` then
|
||||
reload — no confirm (matches Mark VII).
|
||||
- Delete / delete-all success → green check flash; failure → red X + error
|
||||
text flash (replaces the current silent `.catch`).
|
||||
- Empty state text: `"No Handshakes Available"`.
|
||||
- Settings gear opens the dialog (§5.2). The previous "toast" behavior is
|
||||
removed.
|
||||
|
||||
### 5.2 Settings dialog
|
||||
|
||||
Simple modal overlay (Mark VII uses a 600px mat-dialog; replicate visually):
|
||||
- Title "Handshake Settings" (approximation of the Mark VII dialog).
|
||||
- **Handshake Location**: read-only value from
|
||||
`GET /api/pineap/handshakes/location`.
|
||||
- **Delete All Handshakes** button → `PagerAPI.del('/api/pineap/handshakes/all')`
|
||||
→ success flash + reload table + close.
|
||||
- Close button / click-outside to dismiss.
|
||||
|
||||
### 5.3 `js/icons.js`
|
||||
|
||||
Add inline Material SVG paths: `check`, `close`, `question_mark` (the Mark VII
|
||||
uses `assets/icons/question-mark.svg`; an inline `help` glyph is equivalent).
|
||||
`settings`, `file_download`, `delete`, `refresh` already exist.
|
||||
|
||||
### 5.4 `css/app.css`
|
||||
|
||||
Add (light + `html.dark`): `.hs-cell-center` (M1–4/Beacon centering),
|
||||
`.hs-ok` (green check), `.hs-bad` (red X), `.hs-na` (grey `?`),
|
||||
`.hs-flash` / `.hs-flash-ok` / `.hs-flash-error` (title-row indicator),
|
||||
`.modal-overlay`, `.modal`, `.modal-title`, `.modal-actions`, `.modal-body`.
|
||||
Reuse existing `--surface`/`--border`/`--primary` tokens.
|
||||
|
||||
## 6. Compute-cost mitigation (user requirement)
|
||||
|
||||
- No handshake files → **no** DB reads, no subprocess spawns.
|
||||
- Files exist → **exactly 1** `_db_rows` call, scoped by `WHERE h.time >=
|
||||
<oldest file ts>` so a sparse capture never scans the full recon.db history.
|
||||
- Correlation is dict-based (O(1) per file); filename parsing is pure string
|
||||
work.
|
||||
- No polling added to the view — loads on mount + manual Refresh (unchanged).
|
||||
- Context: the recon scanning view already issues ~6 `_db_rows` calls every
|
||||
10s; two extra on page load is negligible.
|
||||
|
||||
## 7. Verification notes (implementation-time, not blockers)
|
||||
|
||||
- **File naming / quality suffix**: inferred from on-device payloads, not yet
|
||||
observed live (no handshakes captured). The tolerant parser + `--`/`?`
|
||||
fallbacks keep the UI correct for any naming variant; a real capture should
|
||||
be sanity-checked if one can be produced.
|
||||
- **hs1–hs4/beacon population**: recon.db schema includes them; if the daemon
|
||||
leaves them NULL the table still renders (all X for in-DB rows).
|
||||
- `handshake.time` epoch-seconds assumption matches the existing recon reads.
|
||||
|
||||
## 8. Testing
|
||||
|
||||
- Python (`tests/test_recon.py`, same mock style as existing):
|
||||
- `handshakes_data()` returns `{files, handshakes}`; empty dir → no
|
||||
DB-helper calls (assert mocked `_db_rows` not invoked).
|
||||
- Filename parser: full/partial/incomplete × pcap/22000, colon and dash
|
||||
MACs, unparseable fallback.
|
||||
- Correlation: canned handshake/wifi_device rows → correct `part_mask`,
|
||||
`beacon`, `in_db`, `timestamp`.
|
||||
- `GET …/handshakes/location` returns the loot dir; `DELETE
|
||||
…/handshakes/all` removes all files and returns updated data.
|
||||
- Front-end: manual on-device pass — table renders, glyphs in both
|
||||
`in_db` states, download filename, delete flash, settings dialog,
|
||||
delete-all, empty state, dark theme, dashboard unchanged (still `files`).
|
||||
- Deploy via `scripts/deploy.ps1`; `curl` the changed JS/CSS for 200.
|
||||
|
||||
## 9. Out of scope
|
||||
|
||||
- Evil WPA/2 Twin handshakes (`source:"Evil WPA/2 Twin"`) — no Pager mode.
|
||||
- Editable handshake location (`PUT /api/pineap/handshakes/location`).
|
||||
- PMKID / `hostap_handshake` rows in this table (Pager's `loghandshake` is
|
||||
EAPOL; PMKID handling is separate).
|
||||
- Changing the dashboard's handshake card/table (it keeps using `files`).
|
||||
@@ -0,0 +1,103 @@
|
||||
# Design: Open AP tab — Mark VII "PineAP Settings" card
|
||||
|
||||
Date: 2026-08-11
|
||||
Status: Approved
|
||||
|
||||
## Problem
|
||||
|
||||
The PineAP **Open AP** tab (`#/pineap/open`) renders as a flat list of nine
|
||||
plain checkboxes inside a single `.pineap-title-card`. The Mark VII PineAP page's
|
||||
settings card ("PineAP") uses a titled card with a subtitle, iOS-style slide
|
||||
toggles (`mat-slide-toggle`) grouped into sections, and a Save button. The Open
|
||||
AP tab should be restyled to that anatomy, with behavior switched from
|
||||
immediate-apply to a batched Mk7-style Save.
|
||||
|
||||
## Reference (Mark VII)
|
||||
|
||||
The Mk7 "PineAP" settings card (from the firmware bundle):
|
||||
- 20px card title **"PineAP"** + muted subtitle **"Quickly set the general
|
||||
behavior of PineAP"**
|
||||
- The Passive/Active/Advanced button group (already on our Overview tab — not
|
||||
duplicated here)
|
||||
- Settings as `mat-slide-toggle` iOS switches
|
||||
- A small accent **save** button (`savePineAPSettings`)
|
||||
|
||||
## Design
|
||||
|
||||
### Layout
|
||||
|
||||
A full-width `.pineap-card-settings` card containing:
|
||||
|
||||
- **Title row** (`.pineap-card-title-flex`): title **"PineAP"**, muted subtitle
|
||||
**"Quickly set the general behavior of PineAP"**, and a **Save** button on the
|
||||
right (accent/primary `.btn`).
|
||||
- **Grouped slide-toggle settings** using the repo's existing `.switch` CSS
|
||||
(iOS-style slider; defined in `app.css` but currently unused):
|
||||
- **Karma**: Enable PineAP, Karma
|
||||
- **SSID Pool**: Capture SSIDs to Pool, Advertise AP Impersonation Pool
|
||||
- **Logging**: Log Handshakes, Log Partial Handshakes, Log PCAP, Log WiGLE,
|
||||
Log Recon
|
||||
- **Footer** (muted): `PineAP MAC: X Target MAC: Y`
|
||||
|
||||
### Behavior (Mk7-style Save, batched)
|
||||
|
||||
- Flipping a switch **stages** the value locally and marks the field dirty — no
|
||||
API call is made until Save.
|
||||
- **Save** applies only the dirty fields, batched per backend route:
|
||||
- Logging + Capture SSIDs (set_config fields `loghandshake`,
|
||||
`logpartialhandshake`, `logpcap`, `logwigle`, `logrecon`, `autossidpool`) →
|
||||
one `POST /api/pineap/set_config` with the dirty fields. The backend merges
|
||||
over the current daemon config, so untouched settings keep their values.
|
||||
- `pineap_disabled` (Enable PineAP) → `POST /api/pineap/enable {enable}` if
|
||||
dirty.
|
||||
- `karma` → `POST /api/pineap/mimic {enable}` if dirty.
|
||||
- `advertise` → `POST /api/pineap/ssidpool/advertise {enable}` if dirty.
|
||||
- All dirty routes fire together (`Promise.allSettled`); toast "Settings
|
||||
saved" if all fulfilled, else "Some settings failed" (error).
|
||||
- Dirty-only apply preserves the unreadable live Karma state: the Pager daemon
|
||||
cannot report Karma, so leaving the switch untouched means Save does not
|
||||
change it.
|
||||
- `load()` runs once on entry and after a successful Save to populate the
|
||||
switches from the daemon (`get_config`, `hostapd`, `wifi/get_ap`). No polling
|
||||
on this tab (unchanged), so staged values are never clobbered.
|
||||
- If nothing is dirty, Save toasts "No changes".
|
||||
|
||||
### Mapping table (field → route, dirty-only)
|
||||
|
||||
| Switch | Backend call | Dirty flag |
|
||||
|---|---|---|
|
||||
| Enable PineAP | `POST /api/pineap/enable {enable}` | `pineap_disabled` |
|
||||
| Karma | `POST /api/pineap/mimic {enable}` | `karma` |
|
||||
| Capture SSIDs to Pool | `POST /api/pineap/set_config {autossidpool}` | `autossidpool` |
|
||||
| Advertise AP Impersonation Pool | `POST /api/pineap/ssidpool/advertise {enable}` | `advertise` |
|
||||
| Log Handshakes | `POST /api/pineap/set_config {loghandshake}` | `loghandshake` |
|
||||
| Log Partial Handshakes | `POST /api/pineap/set_config {logpartialhandshake}` | `logpartialhandshake` |
|
||||
| Log PCAP | `POST /api/pineap/set_config {logpcap}` | `logpcap` |
|
||||
| Log WiGLE | `POST /api/pineap/set_config {logwigle}` | `logwigle` |
|
||||
| Log Recon | `POST /api/pineap/set_config {logrecon}` | `logrecon` |
|
||||
|
||||
## CSS additions (`app.css`)
|
||||
|
||||
- `.pineap-card-subtitle` — muted, ~13px subtitle under the card title.
|
||||
- `.pineap-settings-section` — small muted group label (e.g. "Karma", "SSID
|
||||
Pool", "Logging"), 13px, with margin, used between the toggle groups.
|
||||
- Reuse the existing `.switch` slider CSS (no change to it).
|
||||
|
||||
## Files touched
|
||||
|
||||
- `payload/user/general/pager-webui/www/css/app.css` — two new classes.
|
||||
- `payload/user/general/pager-webui/www/js/views.js` — rewrite
|
||||
`views.pineap_open` (layout + staged-save behavior).
|
||||
|
||||
No backend changes. No test-module changes.
|
||||
|
||||
## Verification
|
||||
|
||||
1. JS delimiter balance check (Python checker; no node available).
|
||||
2. All 13 backend unittest modules pass (unchanged).
|
||||
3. Deploy via `scripts/deploy.ps1` (password supplied securely), restart
|
||||
`/etc/init.d/pagerwebui`.
|
||||
4. On-device: Open AP tab shows the titled settings card with grouped slide
|
||||
switches and Save; flipping a switch then Save applies the values (verify
|
||||
via `get_config`/`hostapd` after Save); untouched toggles are not sent;
|
||||
user walks the tab visually.
|
||||
@@ -0,0 +1,86 @@
|
||||
# Mk7 "PineAP Open Access Point" Card — Design
|
||||
|
||||
**Goal:** Rebuild the Open AP tab (`#/pineap/open`, `views.pineap_open`) to visually and functionally match the genuine Mark 7 Pineapple's `/PineAP/open` page: a single **"PineAP Open Access Point"** card with Open AP network settings and filter notices. The current grouped-toggles "PineAP settings" card is removed.
|
||||
|
||||
**Reference:** Verified against the real Mk7 firmware bundle served at `http://172.16.42.1:1471/` (`main.ce5a318adf590e170f6d.js`), component `app-open-wifi-view` (selector), template root `QAt`.
|
||||
|
||||
## 1. Frontend — `views.pineap_open`
|
||||
|
||||
Rendered inside `pineapShell(root, '#/pineap/open')`. A single `.pineap-title-card` (matching the clone's existing Mk7 card styling) containing, top to bottom:
|
||||
|
||||
- **Title:** "PineAP Open Access Point" (`.pineap-card-title`).
|
||||
- **Subtitle:** "The Open SSID is advertised without encryption. When client association is enabled, " followed by a filter-dependent sentence selected by the SSID/client filter modes:
|
||||
- ssid=allow ∧ client=allow → "any client in the filter configuration may connect to any SSID in the filter configuration."
|
||||
- ssid=deny ∧ client=allow → "any client not in the filter configuration may connect to any SSID in the filter configuration."
|
||||
- ssid=allow ∧ client=deny → "any client in the filter configuration may connect to any SSID not in the filter configuration."
|
||||
- ssid=deny ∧ client=deny → "any client not in the filter configuration may connect to any SSID not in the filter configuration."
|
||||
- ("filter configuration" links to `#/pineap/filtering`.)
|
||||
- **Fields** (two `.row`s; labels above inputs per the clone's `label { display:block }` style):
|
||||
1. **Open SSID** — text input.
|
||||
2. **BSSID** — text input.
|
||||
3. **Channel** — `<select>` of channels 1–11, values `1`..`11`.
|
||||
4. **Current Country** — `<select>` of the Mk7 82-entry country list (see §3).
|
||||
- **Switches** (existing `.switch` slider CSS):
|
||||
- **Hidden** — bound to the Open AP `hidden` state.
|
||||
- **Respond to all probe requests (impersonate all networks)** — bound to the PineAP `karma` state (the Mk7 binds this exact toggle to `PineAPSettings.karma`). The daemon exposes no readable karma/mimic state (verified: `get_config` has no `mimic`), so the toggle is **session-tracked** (module-level flag, default off, updated on Save) — consistent with the Overview's `karmaOn` handling. Save still POSTs `mimic`.
|
||||
- **Info line** (below the switches, small muted text): "The Open access point will be " + ("hidden" if hidden else "advertised") + "." plus, when karma is on, the filter sentence from the subtitle's logic. The Mk7's ", and SSIDs from the Spoofed SSID Pool will be advertised" clause is **omitted**: the daemon exposes no readable broadcast/advertise state (pre-existing limitation; the Overview's advertise toggle has the same issue).
|
||||
- **Filter notice boxes** (stacked, below the info line), mirroring the Mk7's infobox conditions and actions:
|
||||
1. If SSID filter fetched ∧ Open SSID not in SSID-filter allow list ∧ mode=allow → error box: `The open SSID "<ssid>" is not included in the filter allow list, clients will not be able to connect.` + **Add Allowed** button → `POST /api/pineap/filters/ssid {action:'add', value: ssid}` then reload.
|
||||
2. If SSID filter fetched ∧ Open SSID in SSID-filter list ∧ mode=deny → box: open SSID is blocked by the filter deny list + **Remove Filter** button → `POST /api/pineap/filters/ssid {action:'delete', value: ssid}` then reload.
|
||||
3. If mode=allow ∧ SSID filter list non-empty ∧ karma → info box: "Remember to add SSIDs you wish to impersonate to the PineAP SSID filter (Deny)." + **Change Filters** link → `#/pineap/filtering`.
|
||||
4. If client filter fetched ∧ client mode=allow ∧ client list empty → error box: "The PineAP Client filter is set to allow mode. When using this mode, " + the mode-dependent sentence + **Change Mode** button (→ `POST /api/pineap/filters/client {action:'set_mode', mode:'deny'}`, then reload) + **Change Filters** link.
|
||||
- **Save button** (`.btn`) + muted note "Applying reconfigures the radio — you may be disconnected briefly."
|
||||
- Save payload: `POST /api/pineap/wifi/set_ap` with `{open: {ssid, bssid, hidden, enabled, channel, country}}` where `enabled` is the value loaded from `get_ap` (preserved; the Mk7 card has no Enabled control), plus `POST /api/pineap/mimic {enable: <karma switch>}`. All via `Promise.allSettled`.
|
||||
- Feedback via `App.toast('Open AP saved')` / `App.toast(..., 'error')`.
|
||||
|
||||
**Old card removed:** the "PineAP" grouped settings card (Enable PineAP / Karma / Capture SSIDs / Advertise / Logging toggles + footer MAC line + batched `saveCfg`) is deleted. Enable PineAP and Karma are covered by the Overview mode bar; SSID-pool capture/advertise by Overview Quick Settings; logging by Evil WPA / Logging. Nothing else in the app references the removed code.
|
||||
|
||||
## 2. Backend — `server.py`
|
||||
|
||||
Extend the two existing handlers; no new routes, no daemon changes.
|
||||
|
||||
### `h_pineap_wifi_get_ap` (currently ~line 1479)
|
||||
The `open` object gains fields; reads remain UCI-based (`_uci_wifi_iface('wlan0open')`, radio config via `_uci_wifi_iface('radio0')` or equivalent):
|
||||
- `ssid` — `open_cfg.get('ssid') or ''`.
|
||||
- `bssid` — **`open_cfg.get('macaddr') or ''`** (the Open AP interface MAC; changed from the ssidpool bssid). Verified: the only consumer of `open.bssid` is the old Open tab footer, which is being removed; the Overview uses only `open.enabled`.
|
||||
- `hidden` — `open_cfg.get('hidden') == '1'`.
|
||||
- `channel` — radio0 channel (2.4GHz radio hosting the open AP), as int if numeric, else `None`.
|
||||
- `country` — radio0 country code or `''`.
|
||||
- `enabled`, `target` unchanged (`enabled` = `open_cfg.get('disabled') == '0'`; `target` = pool target).
|
||||
|
||||
### `h_pineap_wifi_set_ap` (currently ~line 1505)
|
||||
The `open` branch gains support:
|
||||
- Pass `bssid` (→ `macaddr`), `hidden`, `channel` through to the daemon `PUT /api/settings/wifi/set_ap` open config (the daemon persists these to `wireless.wlan0open`, verified on-device: it writes `macaddr`, `hidden`, `channel`, `encryption`).
|
||||
- **Country** and **radio channel** are applied directly: if `open.country` present, `uci set wireless.radio0.country=<code>`; if `open.channel` present, `uci set wireless.radio0.channel=<n>`; then `uci commit wireless` + `wifi reload`. (The daemon's iface-level channel write is inert for the actual radio — verified.) Only write/reload when the value changed.
|
||||
- `enabled` semantics unchanged (preserve existing state when not supplied).
|
||||
|
||||
### Karma
|
||||
"Respond to all probe requests" is saved by the frontend via the existing `POST /api/pineap/mimic {enable}`; no backend change.
|
||||
|
||||
## 3. Data lists (frontend constants)
|
||||
|
||||
- **Channels (2.4GHz):** 1..11 (labels "Channel N (24NN MHz)" matching Mk7; values 1..11).
|
||||
- **Countries:** the Mk7's 82-entry list (US United States, DZ Algeria, AR Argentina, AU Australia, AT Austria, BH Bahrain, BM Bermuda, BO Bolivia, BR Brazil, BG Bulgaria, CA Canada, CL Chile, CN China, CO Colombia, CR Costa Rica, CS Cyprus, CZ Czech Republic, DK Denmark, DO Dominican Republic, EC Ecuador, EG Egypt, SV El Salvador, EE Estonia, FI Finland, FR France, DE Germany, GR Greece, GT Guatemala, HN Honduras, HK Hong Kong, IS Iceland, IN India, ID Indonesia, IE Ireland, PK Islamic Republic of Pakistan, IL Israel, IT Italy, JM Jamaica, JO Jordan, KE Kenya, KW Kuwait, LB Lebanon, LI Liechtenstein, LT Lithuania, LU Luxembourg, MU Mauritius, MX Mexico, MA Morocco, NL Netherlands, NZ New Zealand, NO Norway, OM Oman, PA Panama, PE Peru, PH Philippines, PL Poland, PT Portuagal, PR Puerto Rico, QA Qatar, KR Republic of Korea (South Korea), RO Romania, RU Russia, SA Saudi Arabia, SG Singapore, SI Slovenia, SK Slovak Republic, ZA South Africa, ES Spain, LK Sri Lanka, SE Sweden, CH Switzerland, TW Taiwan, TH Thailand, TT Trinidad and Tobago, TN Tunisia, TR Turkey, UA Ukraine, AE United Arab Emirates, GB United Kingdom, UY Uraguay, VE Venezuela, VN Vietnam).
|
||||
|
||||
## 4. Filter state data (frontend)
|
||||
|
||||
- SSID filter: `GET /api/pineap/filters/ssid` → `{mode, entries}` (existing handler). Mode `allow`/`deny`.
|
||||
- Client filter: `GET /api/pineap/filters/client` → `{mode, entries}`.
|
||||
- Mutations use the existing `action`-based API (matching `views.pineap_filtering`):
|
||||
- Add Allowed / Remove Filter: `POST /api/pineap/filters/ssid` with `{action:'add'|'delete', value: ssid}`.
|
||||
- Change Mode: `POST /api/pineap/filters/client` with `{action:'set_mode', mode:'deny'}`.
|
||||
|
||||
## 5. Error handling & UX
|
||||
|
||||
- All load fetches use `.catch(() => ({ data: {} }))` (existing pattern); the card still renders with empty fields if a fetch fails.
|
||||
- Save = `Promise.allSettled([set_ap, mimic])`; toast "Open AP saved" only if all fulfilled, else "Some settings failed" (error); always re-run `load()`.
|
||||
- Add Allowed / Remove Filter / Change Mode: fire, toast, re-run `load()`; failures toast 'Failed' (existing pattern).
|
||||
|
||||
## 6. Testing
|
||||
|
||||
- **Backend unit tests** (`tests/test_pineap_proxy.py`): extend the existing mocked-UCI tests —
|
||||
- `test_wifi_get_ap_reads_uci_wireless`: mock `uci show wireless.wlan0open` with `ssid`/`macaddr`/`hidden`, and `uci show wireless.radio0` with `channel`/`country`; assert the new `open` fields.
|
||||
- `test_wifi_set_ap_builds_configs`: open config now carries `bssid`/`channel`; country write triggers `uci set wireless.radio0.country` (mock `device_run`, assert the `uci set` + `uci commit` + `wifi reload` calls) only when country changed.
|
||||
- Full unittest loop stays green.
|
||||
- **JS:** delimiter-balance checker (`C:\Users\root\AppData\Local\Temp\opencode\js_balance.py`); no node available.
|
||||
- **Deploy + on-device:** deploy via `scripts/deploy.ps1`; restart webui; curl save-path round-trip: set Open SSID/hidden via `wifi/set_ap`, verify `uci show wireless.wlan0open`, restore original values. Visual check against `http://172.16.42.1:1471/#/PineAP/open`.
|
||||
@@ -0,0 +1,108 @@
|
||||
# Design: PineAP pages — Mark VII layout
|
||||
|
||||
Date: 2026-08-11
|
||||
Status: Approved
|
||||
|
||||
## Problem
|
||||
|
||||
The PineAP section of the Pager WebUI (8 tabs) uses the generic Pager WebUI card
|
||||
layout (`.section`, `.card`, `.tabbar`). The user wants the PineAP pages to look
|
||||
like the WiFi Pineapple Mark VII PineAP page: the `pineap-title-card-container`
|
||||
title-card anatomy (20px card titles with clickable title links, centered 24px
|
||||
values), a full-width Passive/Active/Advanced button group, and Mk7-style section
|
||||
cards. Scope is limited to the PineAP pages (all 8 tabs); the rest of the app and
|
||||
all behavior are unchanged.
|
||||
|
||||
## Reference (extracted from Mark VII firmware bundle)
|
||||
|
||||
The Mk7 PineAP page styles (verbatim class semantics from `mk7-main.js`):
|
||||
|
||||
```css
|
||||
.pineap-title-card-container { display:flex; width:100%; flex-wrap:wrap; justify-content:space-between; }
|
||||
.pineap-title-card { flex:1; }
|
||||
.pineap-card-title { font-size:20px; display:flex; align-items:center; margin-bottom:10px; }
|
||||
.pineap-card-title-link { color:inherit; text-decoration:none; } :visited inherit; :hover underline
|
||||
.pineap-card-title-content { display:flex; justify-content:center; align-items:center; font-size:24px; }
|
||||
.pineap-card-button-group { width:100%; height:30px; }
|
||||
.pineap-card-button { width:100%; }
|
||||
.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:grey; }
|
||||
```
|
||||
|
||||
The Recon page in this codebase already replicates this pattern as `recon-*`
|
||||
(`.recon-title-card-container`, `.recon-title-card`, `.recon-card-title-link`,
|
||||
`.recon-card-title-content`) — a proven precedent.
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Layout vocabulary in `app.css`
|
||||
|
||||
Add the Mk7 `.pineap-*` classes above, using the codebase's design tokens where
|
||||
the Mk7 used hardcoded colors (e.g., `.pineap-handshakes-none` uses `var(--muted)`
|
||||
for the italic grey text). Card background/shadow come from the existing
|
||||
`.card`-like surface treatment (`var(--surface)` + `var(--shadow)`), applied to a
|
||||
new `.pineap-title-card` surface so title cards match the app's cards.
|
||||
|
||||
### 2. Overview tab (full Mk7 rebuild)
|
||||
|
||||
Three rows, each a `.pineap-title-card-container`:
|
||||
|
||||
- **Row 1 — stat title cards:** three `.pineap-title-card` cards.
|
||||
Total SSIDs in Pool (link → `#/pineap/impersonation`), Clients Connected (link →
|
||||
`#/pineap/clients`), Handshakes Captured (link → `#/pineap/evilwpa`). Each has a
|
||||
20px `.pineap-card-title-link` and a centered 24px `.pineap-card-title-content`
|
||||
value. Data and 5s polling unchanged from the current implementation.
|
||||
- **Row 2 — mode + quick settings:** a wide mode card containing the mode badge,
|
||||
the Passive/Active/Advanced `.pineap-card-button-group` (styled as the Mk7
|
||||
30px group; reuses the existing `.seg-btn` logic) and the mode description
|
||||
line; beside it a `.pineap-card-settings` Quick Settings card (Capture SSIDs
|
||||
to Pool, Advertise AP Impersonation Pool).
|
||||
Preset behavior unchanged (PineAP master + Karma only; `karmaOn` tracking;
|
||||
`modePending` guard; karma-off wins precedence).
|
||||
- **Row 3 — status title cards:** Karma, Open Network, Evil WPA, Evil Enterprise
|
||||
as four `.pineap-title-card` cards with `.pineap-card-title` headings and
|
||||
Configure links (to `#/pineap/open`, `#/pineap/evilwpa`, `#/pineap/enterprise`).
|
||||
|
||||
### 3. Other seven tabs
|
||||
|
||||
Same Mk7 card anatomy, existing tab bar retained as navigation:
|
||||
|
||||
- **Clients (`#/pineap/clients`):** a `.pineap-title-card-container` with a
|
||||
"Clients Connected" count `.pineap-title-card` above the clients table card.
|
||||
- **Impersonation (`#/pineap/impersonation`):** a "Total SSIDs in Pool" count
|
||||
`.pineap-title-card` (link → pool section) plus the `.pineap-card-pool` pool
|
||||
list card (add/clear, advertise/collect toggles, SSID table).
|
||||
- **Open AP (`#/pineap/open`):** toggle list and info in a `.pineap-card-settings`
|
||||
card with a `.pineap-card-title` heading.
|
||||
- **Evil WPA (`#/pineap/evilwpa`):** config form in a card with `.pineap-card-title`;
|
||||
the handshake capture list becomes `.pineap-card-handshakes` with the
|
||||
`.pineap-handshakes-none` empty state.
|
||||
- **Enterprise (`#/pineap/enterprise`):** enabled/auth toggles in a settings card;
|
||||
Basic/Challenge data tables in `.pineap-card-inject`-style cards.
|
||||
- **Filtering (`#/pineap/filtering`):** client/SSID filter cards with
|
||||
`.pineap-card-title` headings (mode selector + entry tables).
|
||||
- **APs (`#/pineap/aps`):** the AP scan table card with a `.pineap-card-title`
|
||||
heading.
|
||||
|
||||
### 4. Behavior unchanged
|
||||
|
||||
No API or logic changes. All toggles, tables, preset/mode logic, and actions keep
|
||||
their current behavior. The PineAP tab bar (8 tabs) remains the navigation.
|
||||
|
||||
## Files touched
|
||||
|
||||
- `payload/user/general/pager-webui/www/css/app.css` — add `.pineap-*` classes.
|
||||
- `payload/user/general/pager-webui/www/js/views.js` — rebuild the markup of the
|
||||
8 PineAP tab renderers to the Mk7 card anatomy.
|
||||
|
||||
No backend changes. No test-module changes.
|
||||
|
||||
## Verification
|
||||
|
||||
1. JS delimiter balance check (Python checker; no node available).
|
||||
2. All 13 backend unittest modules pass (unchanged).
|
||||
3. Deploy via `scripts/deploy.ps1` (password supplied securely), restart
|
||||
`/etc/init.d/pagerwebui`.
|
||||
4. On-device walk of all 8 PineAP tabs: title cards render with clickable links,
|
||||
counts populate on the 5s poll, mode toggle still applies presets, tables and
|
||||
forms still work.
|
||||
@@ -0,0 +1,103 @@
|
||||
# Design: PineAP Overview — Mark VII stats + mode quick toggle (Pager-supported)
|
||||
|
||||
Date: 2026-08-11
|
||||
Status: Approved
|
||||
|
||||
## Problem
|
||||
|
||||
The Pager WebUI PineAP overview (`#/pineap`) currently shows only a mode badge, an
|
||||
intro line, two Quick Settings checkboxes, and four status cards (Karma / Open
|
||||
Network / Evil WPA / Evil Enterprise). Compared with the WiFi Pineapple Mark VII
|
||||
PineAP overview it is missing:
|
||||
|
||||
1. The three overall dashboard counters: **Total SSIDs in Pool**, **Clients
|
||||
Connected**, **Handshakes Captured**.
|
||||
2. The **Passive / Active / Advanced** quick mode toggle.
|
||||
|
||||
All required data is already served by existing, on-device-verified webui
|
||||
endpoints; the backend is not involved in this change.
|
||||
|
||||
## Data sources (all verified live)
|
||||
|
||||
| Card | Endpoint | Value | Link |
|
||||
|---|---|---|---|
|
||||
| Total SSIDs in Pool | `GET /api/pineap/ssids` | `data.ssids.length` (hak5cmd `PINEAPPLE_SSID_POOL_LIST`) | `#/pineap/impersonation` |
|
||||
| Clients Connected | `GET /api/pineap/clients` | `data.count` (iwinfo assoclist) | `#/pineap/clients` |
|
||||
| Handshakes Captured | `GET /api/pineap/handshakes` | `data.files.length` (loot dir scan) | `#/pineap/evilwpa` |
|
||||
|
||||
## Pager constraints (drives the design)
|
||||
|
||||
- **Karma (mimic) state is not readable.** The daemon exposes only
|
||||
`mimic/enable` and `mimic/disable` (POST); `get_config` has no karma field and
|
||||
there is no `mimic` GET route. The webui therefore tracks karma in a
|
||||
view-local variable; on a fresh page load the true state is unknown.
|
||||
- **SSID Pool Broadcasting cannot start** (`ssidpool/enable` returns 500 because
|
||||
no `wlan0open` interface exists on the Pager). The mode presets do NOT touch
|
||||
broadcast; it remains a manual Quick Settings toggle that surfaces the daemon
|
||||
error.
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Stats row (3 clickable cards)
|
||||
|
||||
A new `.cards` row placed above the existing status cards. Each card shows a
|
||||
large numeric value via the existing `.card-label` / `.card-value` styles and a
|
||||
`View`/`Configure` ghost button that navigates to the tab listed above. The row
|
||||
is populated inside the existing 5-second `load()` loop by adding the three GETs
|
||||
above to the current `Promise.all`.
|
||||
|
||||
### 2. Mode quick toggle (Passive / Active / Advanced)
|
||||
|
||||
A segmented control (new `.seg` / `.seg button` styles in `app.css`, modeled on
|
||||
`.tabbar`) rendered directly under the mode badge in the header section. It has
|
||||
three mutually exclusive buttons: Passive, Active, Advanced.
|
||||
|
||||
Preset actions (only Pager-supported features):
|
||||
|
||||
- **Passive**: `POST /api/pineap/enable {enable:true}` (PineAP on),
|
||||
`POST /api/pineap/mimic {enable:false}` (Karma off).
|
||||
- **Active**: `enable:true` + `mimic:true` (Karma on).
|
||||
- **Advanced**: `enable:true` + `mimic:true`; Evil WPA / Evil Enterprise are left
|
||||
as configured (customizable from their cards/tabs).
|
||||
|
||||
Apply flow: on click, disable the buttons, fire the preset requests (Promise.all),
|
||||
then reload state. On any failure, revert the selection and toast "Failed". The
|
||||
mode badge remains computed from readable state (`pineap_disabled`, Evil WPA /
|
||||
Enterprise enabled) and the toggle highlight is synced to that computed mode.
|
||||
|
||||
Under the toggle, a short muted line describes the currently selected mode
|
||||
(Mk7-style, adapted):
|
||||
- Passive: "PineAP is on; network impersonation (Karma) is off."
|
||||
- Active: "PineAP and Karma are on; the open network is impersonated."
|
||||
- Advanced: "All PineAP features are enabled and customizable."
|
||||
|
||||
### 3. Karma status card
|
||||
|
||||
Replaces the current always-`null` value with the locally-tracked karma state
|
||||
('On' / 'Off'; '—' if never set this session).
|
||||
|
||||
### 4. Quick Settings
|
||||
|
||||
Unchanged: "Capture SSIDs to Pool" and "Advertise AP Impersonation Pool".
|
||||
**Randomize Source MAC is explicitly out of scope** — the Pager has no backing
|
||||
route.
|
||||
|
||||
## Files touched
|
||||
|
||||
- `payload/user/general/pager-webui/www/js/views.js` — `views.pineap` overview
|
||||
section (stats row, mode toggle, karma card, feature-list line).
|
||||
- `payload/user/general/pager-webui/www/css/app.css` — add `.seg` segmented
|
||||
control styles.
|
||||
|
||||
No backend changes. No test-module changes (frontend-only; verified by JS
|
||||
delimiter balance check, the 13-module unittest loop, deploy, and an on-device
|
||||
walk).
|
||||
|
||||
## Verification
|
||||
|
||||
1. JS delimiter balance check (no node available; Python checker).
|
||||
2. All 13 test modules still pass (unchanged).
|
||||
3. Deploy via `scripts/deploy.ps1` (password supplied securely).
|
||||
4. On-device: confirm the three stat endpoints return counts; user walks the
|
||||
overview UI to confirm stats populate, cards navigate, and the mode toggle
|
||||
applies presets (karma tracking, error toast on failure).
|
||||
@@ -0,0 +1,162 @@
|
||||
# Design: Mark VII PineAP Page Port for the Pager WebUI
|
||||
|
||||
Date: 2026-08-11
|
||||
|
||||
## Goal
|
||||
|
||||
Replace the current pager-webui PineAP page with a faithful replica of the WiFi
|
||||
Pineapple Mark VII PineAP view, fully functional on the WiFi Pineapple Pager,
|
||||
independent of campaign needs. Also fix the side-rail icon (location pin ->
|
||||
wifi).
|
||||
|
||||
## Background / findings
|
||||
|
||||
- The current PineAP page (tabs: Open, Clients, Filtering, APs, Impersonation)
|
||||
looks nothing like the Mark VII page and its backend is broken on the Pager:
|
||||
- `server.py` reads Mark VII uci keys (`pineapd.pineapd.mimic`,
|
||||
`collect_handshakes`, ...) that do not exist in the Pager's `pineapd`
|
||||
config, so settings never persist (live `GET /api/pineap/settings` returns
|
||||
only `{"bands":"2.4"}` plus raw).
|
||||
- The filter-mode endpoints call `hak5cmd` commands without the required
|
||||
argument, returning usage text instead of the mode.
|
||||
- The Pager's daemon (`/pineapple/pineapple`, listens on `:1471`) natively
|
||||
exposes a Mark VII-style PineAP REST API over a root-only unix socket at
|
||||
`/tmp/api.sock` (raw HTTP/1.1, no auth - socket permission is the boundary).
|
||||
pager-webui already uses this socket (`daemon_sock_call`, e.g. recon
|
||||
start/stop). Confirmed live routes:
|
||||
|
||||
| Route | Method | Purpose |
|
||||
|---|---|---|
|
||||
| `/api/pineap/get_config`, `/set_config` | GET/POST | log flags, handshake path, ssidpool autocollect |
|
||||
| `/api/pineap/hostapd/get_config`, `/set_config` | GET/POST | master PineAP on/off, Evil WPA (`wpa_ifaces`), Evil Enterprise (`pineape_*`) |
|
||||
| `/api/pineap/hostapd/enable_pineap` | POST | master enable |
|
||||
| `/api/pineap/hostapd/enable_pineape` | POST | evil enterprise enable |
|
||||
| `/api/pineap/hostapd/enable_pineape_auth` | POST | enterprise auth-pass capture |
|
||||
| `/api/pineap/mimic/enable`, `/disable` | POST | karma |
|
||||
| `/api/pineap/examine/bssid`, `/reset` | POST | targeted handshake capture |
|
||||
| `/api/pineap/ssidpool/list`, `/add`, `/clear`, `/disable`, `/disable_collect` | GET/POST | SSID pool + advertise/collect |
|
||||
| `/api/pineap/ssidfilter/get_config`, `/set_config`, `/allow/*`, `/deny/*` | GET/POST | SSID filter mode + lists |
|
||||
| `/api/pineap/macfilter/get_config`, `/set_config`, `/set_mode`, `/allow/*`, `/deny/*` | GET/POST | MAC/client filter mode + lists |
|
||||
| `/api/pineap/interfaces/get`, `/set_interface`, `/set_interface_bands` | GET/POST | monitor interface hop/inject/bands |
|
||||
| `/api/pineap/log/recon|pcap|wigle/start|stop` | POST | logging control |
|
||||
| `/api/pineap/recon/new` | POST | new recon scan |
|
||||
|
||||
- The Pager natively supports Evil WPA (rogue AP on `wlan0wpa`: SSID, PSK,
|
||||
encryption WPA2-PSK / WPA3-SAE / WPA3-OAE, hidden, enabled) and Evil
|
||||
Enterprise (EAP, auth-pass capture), confirmed via the daemon binary strings
|
||||
and the native (virtual) pager menu.
|
||||
- The daemon lacks routes for: connected clients (use `iwinfo`), kick
|
||||
(`hak5cmd PINEAPPLE_DEAUTH_CLIENT`), nearby-AP scan (`iwinfo scan`), and
|
||||
handshake file listing (`/root/loot/handshakes/`). pager-webui already
|
||||
implements these.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Backend (`payload/user/general/pager-webui/server.py`)
|
||||
|
||||
1. Add a generic proxy handler `h_pineap_proxy` that forwards the browser
|
||||
request (method + JSON body) verbatim to the daemon socket path
|
||||
`/api/pineap/<subpath>` using the existing `daemon_sock_call`, and returns
|
||||
the daemon's JSON response. Register it for the whole native tree
|
||||
(`get_config`, `set_config`, `hostapd/*`, `mimic/*`, `examine/*`,
|
||||
`ssidpool/*`, `ssidfilter/*`, `macfilter/*`, `interfaces/*`, `log/*`,
|
||||
`recon/new`) so every native capability is reachable 1:1 through
|
||||
pager-webui's authenticated `/api/pineap/*` namespace.
|
||||
2. Keep the custom endpoints that have no daemon route: `clients` (iwinfo),
|
||||
`clients/kick` (deauth), `aps` (iwinfo scan), `handshakes` (loot listing +
|
||||
download/delete).
|
||||
3. Add Enterprise data endpoints: list + clear of `hostap_basic` /
|
||||
`hostap_challenge` rows from `recon.db` (via the sqlite3 CLI, the same
|
||||
mechanism the recon page uses).
|
||||
4. Delete the broken Mark VII-uci settings handlers (`SETTING_MAP`,
|
||||
`_uci_map`, `h_pineap_settings_get/post`) and the filter handlers that
|
||||
returned hak5 usage text (`h_filter_get/post`); replace with the proxy.
|
||||
5. On daemon socket failure return 502 with a JSON error; never crash.
|
||||
|
||||
### Frontend (`www/js/app.js`, `www/js/views.js`, `www/css/app.css`)
|
||||
|
||||
1. Rail icon: change the PineAP rail item from `pineap` (map-marker) to the
|
||||
existing `wifi` icon in `icons.js`/`app.js` rail definition.
|
||||
2. Rebuild the PineAP page as 8 tabs matching the Mark VII structure (using
|
||||
the existing vanilla-JS SPA design language - material-style cards,
|
||||
toggles, tables, tab bar):
|
||||
|
||||
| Tab | Route | Content |
|
||||
|---|---|---|
|
||||
| PineAP | `#/pineap` | Mode badge (Passive/Active/Advanced from `pineap_disabled`+mimic+evilwpa/enterprise), description, quick toggles (Capture SSIDs to Pool, Advertise AP Impersonation Pool, Randomize Source MAC), alert-payload info note, 4 status cards (Karma / Open Network / Evil WPA / Evil Enterprise) with Configure links |
|
||||
| Open AP | `#/pineap/open` | Enable PineAP (master), Karma, Logging group (handshakes/partial/pcap/wigle/recon), Capture SSIDs, Advertise Pool, Randomize MAC, AP Channel select, PineAP MAC + Target MAC (read-only) |
|
||||
| Evil WPA | `#/pineap/evilwpa` | SSID, passphrase, encryption select (WPA2-PSK / WPA3-SAE / WPA3-OAE), Hidden, Enabled; handshake capture card (Examine BSSID + start/stop/reset); captured handshakes table |
|
||||
| Enterprise | `#/pineap/enterprise` | Enabled, Auth Pass Capture; Basic Data + Challenge Data tables with Clear |
|
||||
| Impersonation | `#/pineap/impersonation` | SSID pool textarea editor + Add + Clear; Advertise + Randomize toggles; pool start/stop/collect |
|
||||
| Clients | `#/pineap/clients` | Connected clients table (MAC/interface/RSSI) + Kick, 5s auto-refresh |
|
||||
| Filtering | `#/pineap/filtering` | Client Filter + SSID Filter cards: Allow/Deny mode + line-based list textareas with add/delete/clear |
|
||||
| APs | `#/pineap/aps` | Kept as-is (iwinfo scan table, 10s refresh) |
|
||||
|
||||
3. Mark VII-only controls with no Pager equivalent (Autostart, Beacon
|
||||
Responses, Beacon Intervals, enterprise cert generation) are omitted
|
||||
rather than greyed out; Client Connect/Disconnect Notifications are shown
|
||||
as an info note (the Pager handles these via alert payloads natively).
|
||||
4. Register the new routes in the `routes` map and add the tab bar entries +
|
||||
CSS.
|
||||
|
||||
## Data flow
|
||||
|
||||
Browser -> pager-webui `/api/pineap/*` (session-authenticated) ->
|
||||
`daemon_sock_call` -> `/tmp/api.sock` -> Pager daemon -> `pineapd`/`hostapd`.
|
||||
Mutating calls show a toast; failures surface a friendly error.
|
||||
|
||||
## Error handling
|
||||
|
||||
- Daemon socket unavailable -> HTTP 502 `{error}`; frontend shows a toast and
|
||||
never renders a dead page.
|
||||
- Unknown/unsupported subpath -> 404 through pager-webui.
|
||||
- Enterprise tables empty -> "no data" empty states (same pattern as Recon).
|
||||
|
||||
## Testing
|
||||
|
||||
- Rewrite `tests/test_pineap_{settings,pool,clients,aps}.py` and add
|
||||
`test_pineap_evilwpa.py` / `test_pineap_enterprise.py` /
|
||||
`test_pineap_filtering.py` against the proxied shapes, mocking the daemon
|
||||
socket call (module-level monkeypatch per the existing test conventions).
|
||||
- Run the README's per-file unittest loop on Windows.
|
||||
- On-device smoke test after `deploy.ps1`: walk every tab, verify toggles
|
||||
persist across a reboot.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Firmware changes; the daemon API internals are used as-is.
|
||||
- A PR to `hak5/wifipineapplepager-payloads` (packaging is drop-in ready).
|
||||
|
||||
## Open items (resolved during implementation)
|
||||
|
||||
- Exact `hostapd/set_config` Evil WPA field names (SSID/PSK/encryption/
|
||||
hidden/enabled) - probe on the live device.
|
||||
**Resolved:** Evil WPA lives in UCI `wireless.wlan0wpa`. The daemon route
|
||||
`PUT /api/settings/wifi/set_ap` takes `{"configs":[{interface, ssid, enctype,
|
||||
enabled, hidden, key, channel(int)}]}` (full replace; `channel` must be int).
|
||||
`PUT /api/settings/wifi/get_ap` is a stub (always `{"interfaces":[]}`), so the
|
||||
webui reads UCI `wireless.wlan0wpa`/`wlan0open` directly. `enctype` values:
|
||||
`psk2`, `psk`, `sae`, `none` (not `wpa2`). Applying reconfigures the radio and
|
||||
briefly drops the management connection.
|
||||
- `ssidpool` list response shape (fallback: `hak5cmd PINEAPPLE_SSID_POOL_LIST`).
|
||||
**Resolved:** `GET /api/pineap/ssidpool/list` is a stub (`{"success":true}`);
|
||||
the pool is stored base64 in UCI `pineapd.@ssidpool[0].ssid`. `hak5cmd`
|
||||
`PINEAPPLE_SSID_POOL_LIST/ADD/DELETE/CLEAR` all work and are used.
|
||||
- Randomize-Source-MAC route (`GetPineAPRandomizeMAC` internal var) -
|
||||
discover the backing route/field.
|
||||
**Resolved (partial):** no readable daemon route for the pool advertise state
|
||||
(`ssidpool/enable|disable` are POST; `enable` returns 500 natively because no
|
||||
`wlan0open` interface exists on the Pager). The webui surfaces the daemon error.
|
||||
- sqlite3 CLI availability for the Enterprise tables.
|
||||
**Resolved:** sqlite3 CLI present; `hostap_basic`/`hostap_challenge` do not
|
||||
exist yet in `recon.db`, so the Enterprise tab renders empty tables (webui
|
||||
returns `rows: []`).
|
||||
- Pineapd UCI required-field cycle: the daemon's `set_config`/`hostapd/set_config`
|
||||
are full-replaces. If a write omits required fields (`pineapd.@pineapd[0].
|
||||
reconpath` etc.), `get_config` then fails and later writes stay broken. The
|
||||
webui merges each write over the current daemon config (or defaults when
|
||||
`get_config` fails) so required fields are always preserved.
|
||||
- Verified daemon methods: reads GET; `set_config`/`hostapd/set_config`/
|
||||
`hostapd/enable_pineap`/`interfaces/set_interface`/`ssidpool/add`/`wifi/*` PUT;
|
||||
toggles (`mimic/*`, `examine/*`, `ssidpool/enable|disable|enable_collect|
|
||||
disable_collect`) POST. `ssidfilter`/`macfilter` `set_config` are PUT.
|
||||
@@ -0,0 +1,215 @@
|
||||
# Recon Mark VII Parity — Design Spec
|
||||
|
||||
- **Date:** 2026-08-11
|
||||
- **Status:** Approved
|
||||
- **Owner:** WiFi Pineapple Pager expansion project
|
||||
- **Applies to:** `payload/user/general/pager-webui/www/` (front-end) and
|
||||
`payload/user/general/pager-webui/server.py` (one backend change)
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Rework the Pager WebUI Recon section so it looks and behaves as close as
|
||||
possible to the stock Hak5 WiFi Pineapple (Mark VII) UI's Recon at
|
||||
`http://172.16.42.1:1471/#/Recon`, using only data the Pager backend already
|
||||
exposes plus one small optional-body change to `POST /api/recon/start`.
|
||||
|
||||
Source of truth for the Mark VII layout: the Angular bundle
|
||||
`main.ce5a318adf590e170f6d.js` (captured 2026-08-11, cached at
|
||||
`%TEMP%\opencode\oldui_main.js`). The scanning component (`app-scanning-view`)
|
||||
template, component CSS, and Chart.js configs were extracted from it.
|
||||
|
||||
## 2. Mark VII Reference (extracted from bundle)
|
||||
|
||||
### Scanning page (`/Recon`, tab "Scanning")
|
||||
|
||||
```
|
||||
div.recon-title-card-container (flex, wrap, justify: space-between, gap 10px)
|
||||
div.recon-title-card-latest-overview
|
||||
mat-card (height: 200px)
|
||||
mat-card-title "Wireless Landscape" (font-size 20px)
|
||||
mat-card-content > div > div.recon-wifidata-chart-container
|
||||
canvas#landscapeChart (doughnut)
|
||||
div.recon-center-text > span.recon-no-data " No wireless landscape data is available yet. "
|
||||
div.recon-title-card-latest-overview "Channel Distribution" → canvas#channelChart (bar)
|
||||
div.recon-title-card-latest-overview
|
||||
mat-card (height: 200px)
|
||||
mat-card-title > a.recon-card-title-link [routerLink /Recon/handshakes] " Handshakes "
|
||||
content: <span font-size:32px><b>{totalHandshakes}</b></span>
|
||||
<span color:grey>Handshakes Captured</span>
|
||||
<mat-slide-toggle>Automatically Collect Any Handshakes</mat-slide-toggle>
|
||||
div.recon-title-card-latest-overview
|
||||
mat-card (height: 200px)
|
||||
mat-card-title "Previous Scans"
|
||||
content: mat-form-field (width 100%) > mat-label "Previous Scan" + mat-select
|
||||
button[mat-icon-button] mat-icon "file_download"
|
||||
button[mat-icon-button] mat-icon "delete"
|
||||
|
||||
mat-card (scan bar)
|
||||
mat-card-content > div (height 48px, flex align-center)
|
||||
mat-slide-toggle "Scan" (reconToggleState → startRecon/stopRecon)
|
||||
div (margin-left 20px) mat-select (width 100px) scan_time
|
||||
options: 30 Seconds/1 Minute/2 Minutes/5 Minutes/10 Minutes/Continuous
|
||||
div (margin-left 20px) mat-select band (0 = 2.4GHz, 1 = 5GHz, 2 = 5GHz-DFS)
|
||||
span[fxFlex] spacer
|
||||
button[mat-icon-button] mat-icon "settings" (opens settings sidebar)
|
||||
|
||||
div (position: relative; min-height: 500px)
|
||||
div.recon-scan-results-card
|
||||
mat-card
|
||||
mat-card-content
|
||||
div (48px row) [AP table header: search mat-form-field "Search" + mat-paginator]
|
||||
mat-table APs columns: SSID, MAC, Clients, OUI, Sec, WPS, MFP, Chan, Sign, FirstSeen, LastSeen
|
||||
mat-card-content [Clients table: search + mat-paginator]
|
||||
mat-table Clients columns: Client MAC, Signal, Channel, First seen, Last seen
|
||||
```
|
||||
|
||||
Charts (from `generateLandscapeChart` / `generateChannelChart`):
|
||||
- Landscape: `type:"doughnut"`, labels `["Access Points","Clients","Unassociated"]`,
|
||||
backgroundColor `["#2ecc71","#2980b9","#8e44ad","#e74c3c"]`, `legend:{position:"bottom"}`.
|
||||
- Channel: `type:"bar"`, one bar per channel sorted ascending, no legend, each bar
|
||||
a color from a 64-color array, `yAxes:[{ticks:{reverse:!1,stepSize:1,min:0}}]`.
|
||||
|
||||
Component CSS (key rules, light theme):
|
||||
- `.recon-title-card-container{display:flex;width:100%;flex-wrap:wrap;justify-content:space-between}`
|
||||
- `.recon-title-card-latest-overview{flex:1;margin-bottom:1em}`
|
||||
- `.recon-title-card-title{font-size:20px;margin-bottom:15px;display:flex;align-items:center}`
|
||||
- `.recon-card-title-link{color:inherit;text-decoration:none}` `:hover{text-decoration:underline}`
|
||||
- `.recon-title-card-content{display:flex;justify-content:center;align-items:center;height:70%}`
|
||||
- `.recon-no-data{font-style:italic;color:#787878;display:flex;justify-content:center}`
|
||||
- `.recon-scan-results-card{width:100%}`
|
||||
- table rows cursor pointer; `.recon-table-row-active{background-color:#eaeaea}`
|
||||
- dark: `.recon-table-row-active-dark{background-color:#565656}`
|
||||
|
||||
Tabs: Recon has exactly two tabs — `[Scanning → /Recon]`, `[Handshakes → /Recon/handshakes]`. No Events tab.
|
||||
|
||||
### Handshakes page (`/Recon/handshakes`)
|
||||
Card "Captured WPA Handshakes" (title flex, settings icon button at right),
|
||||
`table-container` with `mat-table`: columns `BSSID, Client, Source, Type, Captured,
|
||||
Message 1, Message 2, Message 3, Message 4, Beacon Frame, [action]`. Rows show
|
||||
M1–M4 check/close/missing glyphs. Delete + download per row.
|
||||
|
||||
## 3. Design (Pager)
|
||||
|
||||
### 3.1 Tabs
|
||||
`RECON_TABS` becomes two entries: **Scanning** (`#/recon`) and **Handshakes**
|
||||
(`#/recon/handshakes`). Remove `views.recon_events`, the `#/recon/events`
|
||||
route in `app.js`, and the old `views.recon_events` definition. Backend
|
||||
`GET /api/recon/events` is left untouched.
|
||||
|
||||
### 3.2 Scanning view layout (`views.recon`)
|
||||
Mark VII structure implemented with vanilla JS `h()` helpers and existing
|
||||
`PagerAPI` endpoints. State per scan: `GET /api/recon/scans` → list;
|
||||
`GET /api/recon/scans/{id}` → detail `{scan, aps[], clients[], handshakes[]}`.
|
||||
|
||||
- **Title cards row** (`.recon-title-card-container`, 200px cards):
|
||||
1. **Wireless Landscape** — `MiniChart.doughnut` with legend; segments
|
||||
`[Access Points, Clients, Unassociated]` = `[aps.length, clients.length, 0]`,
|
||||
colors `#2ecc71 / #2980b9 / #8e44ad`; placeholder text when no data.
|
||||
2. **Channel Distribution** — `MiniChart.bar`; per-channel counts from
|
||||
detail APs (unknown channel grouped as `?`), Mark VII 64-color palette,
|
||||
no legend, Y step 1.
|
||||
3. **Handshakes Captured** — 32px bold count = selected scan handshakes
|
||||
length; grey "Handshakes Captured" subtitle; "Automatically Collect Any
|
||||
Handshakes" slide toggle wired to `GET/POST /api/pineap/settings`
|
||||
(`collect_handshakes`); card title links to `#/recon/handshakes`.
|
||||
4. **Previous Scans** — "Previous Scan" select (newest first, `Scan #id — time`
|
||||
labels), `file_download` icon button → existing
|
||||
`/api/recon/scans/{id}/download/json`, `delete` icon button → existing
|
||||
`DELETE /api/recon/scans/{id}` (confirm dialog), refresh via poll.
|
||||
- **Scan bar card** (48px row): "Scan" slide toggle (`POST /api/recon/start|stop`),
|
||||
Duration select (30 Seconds / 1 Min / 2 Min / 5 Min / 10 Min / Continuous),
|
||||
spacer, `settings` icon button → settings sidebar. No Band select (Pager
|
||||
cannot single-band scan). Duration persisted in `localStorage`
|
||||
(`pw_scan_duration`) and sent on start.
|
||||
- **Results area** (`min-height:500px`), one card per table, each with a
|
||||
Material-look "Search" field (client-side filter) and a paginator
|
||||
(10/25/50/100, first/last/prev/next buttons, "1–10 of N" range label):
|
||||
- **Access Points** — columns SSID, BSSID, Channel, Signal, Encryption,
|
||||
Hidden; row click selects the AP (highlight) — no focus sidebar (backend
|
||||
has no scan-AP deauth/capture APIs).
|
||||
- **Clients** — columns MAC, Signal, Frequency, Packets.
|
||||
- **Settings sidebar**: fixed right panel (top 64px, width 270px, shadow,
|
||||
close button). Section "Access Points": Show SSID/MAC/Channel/Signal/
|
||||
Encryption/Hidden toggles. Section "Clients": Show MAC/Signal/Frequency/
|
||||
Packets toggles. Persisted as JSON in `localStorage` key `pw_recon_cols`.
|
||||
- **Polling**: refresh scans/detail every 10s while the view is mounted
|
||||
(destroy clears interval), same as today's behaviour (interval bumped from
|
||||
15s → 10s to keep charts/table fresh).
|
||||
|
||||
### 3.3 Handshakes view (`views.recon_handshakes`)
|
||||
Restyle to Mark VII look: card title "Captured WPA Handshakes" with a
|
||||
`settings` icon button (non-functional on Pager; omitted or rendered as a
|
||||
disabled hint — render it, clicking shows a toast "Settings not available on
|
||||
Pager"). Table `File / Type / Size / Modified` plus per-row `Download` and
|
||||
`Delete` actions (existing `/api/pineap/handshakes` endpoints + `/api/loot/zip`
|
||||
for "Download all"). Keep existing zip/archive actions row.
|
||||
|
||||
### 3.4 Charts (`js/chart.js`)
|
||||
- `MiniChart.doughnut(canvas, segments, { legend: true, height })` — redraw as
|
||||
a true doughnut (inner hole via `arc` ring) and draw a legend row beneath
|
||||
(color dot + label) when `legend` is set.
|
||||
- `MiniChart.bar(canvas, items, opts)` — items `{label, value, color}`; draws
|
||||
Y gridlines, bars from baseline, X labels, `maxY = ceil(max/1)`; Mark VII
|
||||
palette cycles through `items[].color`.
|
||||
- Keep existing `MiniChart.draw` (line) for the dashboard unchanged.
|
||||
|
||||
### 3.5 Icons (`js/icons.js`)
|
||||
Add to `PineappleIcons`: `refresh`, `file_download`, `delete`, `settings`,
|
||||
`search`, `first_page`, `last_page`, `chevron_left`, `chevron_right`
|
||||
(Material Design path data; `wifi`/`extension`/`receipt` already present).
|
||||
|
||||
### 3.6 CSS (`css/app.css`)
|
||||
Add Mark VII recon styles (light + `html.dark`): `.recon-title-card-container`,
|
||||
`.recon-title-card`, `.recon-title-card-title`, `.recon-card-title-link`,
|
||||
`.recon-title-card-content`, `.recon-no-data`, `.recon-chart-box`,
|
||||
`.recon-scan-bar`, `.recon-scan-bar .sel`, `.recon-search`,
|
||||
`.recon-paginator`, `.recon-settings-sidebar`, `.recon-settings-section`,
|
||||
`.recon-settings-toggle`, `.recon-settings-close`, `.recon-row-selected`.
|
||||
Card background follows `--surface`; title cards get the existing elevation
|
||||
shadow; dark overrides via `html.dark`.
|
||||
|
||||
### 3.7 Backend (`server.py`)
|
||||
`h_recon_start(ctx)` forwards an optional `scan_time` from the request body to
|
||||
the daemon:
|
||||
|
||||
```python
|
||||
def h_recon_start(ctx):
|
||||
body = {}
|
||||
scan_time = (getattr(ctx, 'body', None) or {}).get('scan_time')
|
||||
if scan_time is not None:
|
||||
body['scan_time'] = int(scan_time)
|
||||
status, data = daemon_sock_call('POST', '/api/pineap/log/recon/start', body=body)
|
||||
...
|
||||
```
|
||||
|
||||
Default remains `{}` when no `scan_time` is sent, so the current behaviour is
|
||||
unchanged. (Whether the Pager daemon honours `scan_time` is up to the daemon;
|
||||
the UI persists and sends it either way.)
|
||||
|
||||
### 3.8 Routing (`js/app.js`)
|
||||
Remove `'#/recon/events': 'recon_events'` from the `routes` map. No other
|
||||
route changes.
|
||||
|
||||
## 4. Data flow
|
||||
Unchanged API surface (except optional `scan_time` on recon start). The view
|
||||
loads scans, auto-selects the newest, fetches the detail, renders charts +
|
||||
tables, and re-fetches on a 10s interval. Search/pagination/column settings
|
||||
are client-side state (`localStorage` keys `pw_scan_duration`, `pw_recon_cols`).
|
||||
|
||||
## 5. Testing
|
||||
- Python: extend `tests/test_recon.py` `DaemonSockTest` with a case asserting
|
||||
`h_recon_start` forwards `{'scan_time': 60}` when present and `{}` otherwise
|
||||
(existing socket tests must stay green).
|
||||
- Front-end: manual on-device smoke pass — every element of the scanning page,
|
||||
tabs (2), scan start/stop, duration persistence, auto-handshake toggle,
|
||||
previous-scan select + download + delete, table search/pagination, column
|
||||
settings sidebar + persistence, handshakes page, dark theme, keyboard
|
||||
shortcut `r`.
|
||||
- Deploy via `scripts/deploy.ps1`; `curl` the served JS/CSS assets for 200.
|
||||
|
||||
## 6. Out of scope
|
||||
- AP/client "focus" sidebars (deauth/capture/clone) — no backend support.
|
||||
- 2D/3D cartography graph view — no AP↔client association data.
|
||||
- Chart.js dependency — hand-rolled canvas rendering with identical palettes.
|
||||
- Events tab, band select, "highlight active devices" aux settings.
|
||||
- Backend removal of `/api/recon/events`.
|
||||
@@ -0,0 +1,185 @@
|
||||
# Virtual Pager Dock — Design Spec
|
||||
|
||||
- **Date:** 2026-08-11
|
||||
- **Status:** Approved
|
||||
- **Owner:** WiFi Pineapple Pager expansion project
|
||||
- **Applies to:** `payload/user/general/pager-webui/www/` (front-end only) and
|
||||
`scripts/dev_proxy.py` (dev config)
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Add a **Virtual Pager** button in the topbar next to the existing **Terminal**
|
||||
button in the Pager WebUI (`http://172.16.52.1:8080/`). Clicking it opens a
|
||||
bottom-docked panel that shows a live, interactive replica of the Pager device:
|
||||
the physical-device graphic with the real-time screen in the middle and
|
||||
clickable directional/A/B buttons — matching the virtual pager view of the
|
||||
Pager's stock UI at `http://172.16.52.1:1471/`.
|
||||
|
||||
Behavior mirrors the existing Terminal dock: open/close toggling, docked panel,
|
||||
connect/disconnect of WebSockets on open/close.
|
||||
|
||||
## 2. Stock UI Reference (verified 2026-08-11)
|
||||
|
||||
Source: `http://172.16.52.1:1471/` (captured to
|
||||
`%TEMP%\opencode\pager-virtual-pager.html`, 141,707 bytes).
|
||||
|
||||
### 2.1 Connections
|
||||
|
||||
- Screen: `ws://<host>:1471/api/pager/display/screen.ws` — streams binary RGBA
|
||||
framebuffer frames. Requires the daemon `AUTH_<serverid>` cookie; returns
|
||||
`401 Unauthorized` without it.
|
||||
- Keys: `ws://<host>:1471/api/pager/input/keys.ws` — text frames; same cookie.
|
||||
- Both handshakes verified returning `101 Switching Protocols` with a valid
|
||||
`AUTH_<serverid>=<token>` cookie.
|
||||
- Cookies are host-scoped (not port-scoped), so a browser that has logged into
|
||||
`172.16.52.1:8080` already sends the cookie to `172.16.52.1:1471`. This is the
|
||||
same mechanism the existing Terminal WS uses (direct `:1471` connection, no
|
||||
backend relay), and requires **no backend changes**.
|
||||
|
||||
### 2.2 Screen framebuffer
|
||||
|
||||
- Resolution `480 × 222`, stride `480 * 4 = 1920`, expected
|
||||
`1920 * 222 = 426,240` bytes per frame (4 bytes/pixel RGBA).
|
||||
- Client renders each frame: copy RGBA into a hidden 480×222 canvas
|
||||
(`createImageData`/`putImageData`), `canvas.toDataURL('image/png')` into the
|
||||
`<img id="pager">` screen element.
|
||||
|
||||
### 2.3 Buttons / keys
|
||||
|
||||
Button image click sends these strings over the keys WS:
|
||||
|
||||
| Control | Sent key |
|
||||
|-----------|------------------|
|
||||
| LEFT | `ArrowLeft` |
|
||||
| UP | `ArrowUp` |
|
||||
| RIGHT | `ArrowRight` |
|
||||
| DOWN | `ArrowDown` |
|
||||
| A | `Enter` |
|
||||
| B | `Escape` |
|
||||
|
||||
Physical keyboard: when the pager screen is focused (or body, with no other
|
||||
control focused) the stock UI sends `e.key` on keydown (no repeats). Buttons
|
||||
show a brief `.pressed` feedback (brightness filter).
|
||||
|
||||
### 2.4 Device graphic
|
||||
|
||||
`<table id="pager_ui">` (745 × 531) built from 24 image slices +
|
||||
`spacer.gif`, button images `LEFT.png`, `UP.png`, `RIGHT.png`, `DOWN.png`,
|
||||
`A_Button.png`, `B_Button.png`, and a 480×222 screen surface with hidden canvas
|
||||
and a `#pager_error` overlay. Images are static and fetchable without auth from
|
||||
`http://172.16.52.1:1471/images/`. Identical files already exist in
|
||||
`wifipineapplepager/payloads/library/user/remote_access/nautilus/www/images/`
|
||||
(sizes match byte-for-byte); they will be copied from there and hash-verified
|
||||
against the stock UI.
|
||||
|
||||
Scaling: the stock UI scales the graphic with CSS `zoom` (`scale =
|
||||
min(1, availableWidth / 745)`), `transform-origin: top center`.
|
||||
|
||||
## 3. Design
|
||||
|
||||
### 3.1 index.html
|
||||
|
||||
- Add `#pager-btn` ("Virtual Pager", `btn ghost`) next to `#terminal-btn`.
|
||||
- Add `#pager-panel` dock after `#terminal-panel`, mirroring its structure:
|
||||
- `#pager-bar` — title "Virtual Pager" + `#pager-close` button.
|
||||
- `#pager` content area containing the pager graphic table with the live
|
||||
screen, hidden canvas, and error/retry overlay.
|
||||
- Load `js/pager.js` after `js/terminal.js`.
|
||||
|
||||
### 3.2 app.css
|
||||
|
||||
- `#pager-btn`: same style as `#terminal-btn`; `.active` state for both.
|
||||
- `#pager-panel`: fixed bottom dock like `#terminal-panel` but `height: 540px`;
|
||||
reuses the `term-up` slide-in animation and a shared `.dock` utility where
|
||||
practical. Content centered; overflow hidden.
|
||||
- Pager graphic scale-to-fit: a wrapper whose `zoom`/transform scales the
|
||||
745×531 graphic to the available panel width, never upscaling
|
||||
(`scale = min(1, availableWidth / 745)`), per §2.4.
|
||||
- `.pager-btn` cursor/`user-select`/pressed feedback (stock rules).
|
||||
- `#pager_error` overlay styles (centered, dark overlay, Reconnect button).
|
||||
|
||||
### 3.3 js/pager.js (new, mirrors js/terminal.js)
|
||||
|
||||
Module `Pager` with:
|
||||
|
||||
- `SCREEN_WIDTH = 480`, `SCREEN_HEIGHT = 222`, `FB_STRIDE = 1920`.
|
||||
- `ensure()` — lazily grab `#pager-panel`, canvas, `<img id="pager">`, build the
|
||||
graphic table (static innerHTML from bundled images), wire button clicks.
|
||||
- `toggle()` — show/hide `#pager-panel`, toggle `#pager-btn.active`,
|
||||
connect/disconnect.
|
||||
- Screen WS: binary `event.data` → `new Uint8Array(buffer)` →
|
||||
`renderRGBAFrame(bytes)` (reject frames `< FB_STRIDE * SCREEN_HEIGHT`).
|
||||
On error/close: hide screen, show error overlay with Reconnect.
|
||||
- Keys WS: `sendKey(name)` helper; button clicks send the §2.3 strings.
|
||||
- Keyboard: a `keydown` listener active only while the dock is open; skips when
|
||||
focus is in an INPUT/TEXTAREA/SELECT or the terminal; captures
|
||||
ArrowLeft/ArrowUp/ArrowRight/ArrowDown/Enter/Escape (preventDefault on the
|
||||
arrows to stop page scroll) and sends them via keys WS. Letter shortcuts
|
||||
(D/C/P/R/L/M) and backtick (terminal toggle) keep working — backtick closes
|
||||
the pager dock via the replace-on-open behavior.
|
||||
- `disconnect()` closes both WS.
|
||||
- `window.addEventListener('resize')` re-applies scale-to-fit while open.
|
||||
|
||||
### 3.4 app.js
|
||||
|
||||
- Wire `#pager-btn` and `#pager-close` to `Pager.toggle()` (guarded with
|
||||
`typeof Pager === 'undefined'` like the terminal).
|
||||
- **Replace-on-open:** a small helper `showDock(name)` that, when opening
|
||||
`pager`, calls `Term.close()` if `Term` is defined and its panel is open, and
|
||||
vice versa. Refactor the terminal wiring to go through `showDock('terminal')`
|
||||
so both directions use the same rule. (`Term` gains a `close()`/`isOpen()`
|
||||
helper in `terminal.js`.)
|
||||
- Keyboard shortcut `g` (or none) is not required; the button is the entry
|
||||
point. Backtick toggles the terminal and closes the pager dock (per
|
||||
replace-on-open).
|
||||
|
||||
### 3.5 config.js / dev_proxy.py
|
||||
|
||||
- `config.js`: add `pagerScreenWs: ''`, `pagerKeysWs: ''`.
|
||||
- `app.js`: read `cfg.pagerScreenWs || ('ws://' + location.hostname +
|
||||
':1471/api/pager/display/screen.ws')` and the keys equivalent; expose as
|
||||
`App.pagerScreenWs` / `App.pagerKeysWs`.
|
||||
- `dev_proxy.py` `_serve_dev_config`: emit both fields pointing at the pager
|
||||
host (same shape as `terminalWs`).
|
||||
|
||||
### 3.6 Assets
|
||||
|
||||
- Copy from the nautilus payload into `www/assets/pager/`:
|
||||
`virtual_pager_01..24.png`, `LEFT.png`, `UP.png`, `RIGHT.png`, `DOWN.png`,
|
||||
`A_Button.png`, `B_Button.png`, `spacer.gif`.
|
||||
- Hash-verify each against the stock UI (`http://172.16.52.1:1471/images/…`)
|
||||
during implementation; if any differ, use the stock bytes instead.
|
||||
|
||||
### 3.7 Error handling
|
||||
|
||||
- Screen WS error/close: show `#pager_error` ("Lost connection to virtual
|
||||
pager") with a Reconnect button that calls `connect()`.
|
||||
- Keys WS is best-effort: if not OPEN, button presses are ignored (stock
|
||||
behavior).
|
||||
- If WS construction throws (unreachable daemon), write a message into the
|
||||
error overlay rather than throwing in app.js.
|
||||
|
||||
## 4. Testing
|
||||
|
||||
- **Unit (Python):** none required — no backend changes.
|
||||
- **Static review:** `pager.js` frame renderer bounds check; button→key mapping
|
||||
table; keyboard guard conditions; replace-on-open logic.
|
||||
- **On-device smoke (per §8 of the main webui spec):**
|
||||
1. Log in at `http://172.16.52.1:8080/`; Virtual Pager button present next to
|
||||
Terminal.
|
||||
2. Open Virtual Pager: dock slides up, screen streams live, graphic scales to
|
||||
fit width.
|
||||
3. Click LEFT/UP/RIGHT/DOWN/A/B — screen responds; physical arrow/Enter/Escape
|
||||
also drive it; typing in an input/terminal does not.
|
||||
4. Open Terminal while Pager open → Pager dock closes (replace-on-open) and
|
||||
vice versa.
|
||||
5. Kill/reconnect network to the daemon → error overlay + Reconnect recovers.
|
||||
6. Backtick still toggles the terminal and closes the Pager dock.
|
||||
|
||||
## 5. Out of scope
|
||||
|
||||
- The stock page's embedded shell terminal and terminal-shortcut grid (the
|
||||
WebUI already has a Terminal dock).
|
||||
- The Pager Skinner / virtual-pager theme features.
|
||||
- Any backend relay for the pager WS (direct `:1471` connection, same as the
|
||||
Terminal).
|
||||
Reference in New Issue
Block a user