From 3e1805dab83faeafa0d610e5bfc3e77d2f1342c1 Mon Sep 17 00:00:00 2001 From: c4ch3c4d3 Date: Tue, 11 Aug 2026 20:24:24 -0700 Subject: [PATCH] release: Mark VIII 1.0 --- .gitignore | 6 + LICENSE | 21 + README.md | 102 + THIRD_PARTY_NOTICES.md | 29 + docs/plans/2026-08-11-pineapple-ui-clone.md | 1533 ++++++ docs/specs/2026-08-10-pager-webui-design.md | 243 + .../2026-08-11-pineapple-ui-clone-design.md | 176 + .../plans/2026-08-10-pager-webui.md | 4103 +++++++++++++++++ .../2026-08-11-handshakes-markvii-parity.md | 687 +++ .../2026-08-11-openap-markvii-settings.md | 199 + .../plans/2026-08-11-openap-mk7-card.md | 508 ++ .../plans/2026-08-11-pineap-markvii-layout.md | 670 +++ .../2026-08-11-pineap-overview-markvii.md | 252 + .../plans/2026-08-11-pineap-page-overhaul.md | 822 ++++ .../plans/2026-08-11-recon-markvii.md | 908 ++++ ...-08-11-handshakes-markvii-parity-design.md | 274 ++ ...26-08-11-openap-markvii-settings-design.md | 103 + .../2026-08-11-openap-mk7-card-design.md | 86 + ...2026-08-11-pineap-markvii-layout-design.md | 108 + ...26-08-11-pineap-overview-markvii-design.md | 103 + .../2026-08-11-pineap-page-overhaul-design.md | 162 + .../specs/2026-08-11-recon-markvii-design.md | 215 + .../2026-08-11-virtual-pager-dock-design.md | 185 + .../user/remote_access/pager-webui/LICENSE | 21 + .../user/remote_access/pager-webui/README.md | 33 + .../pager-webui/THIRD_PARTY_NOTICES.md | 29 + .../pager-webui/_hak5_manifest.json | 15 + .../remote_access/pager-webui/pagerwebui.init | 24 + .../user/remote_access/pager-webui/payload.sh | 113 + .../user/remote_access/pager-webui/server.py | 2570 +++++++++++ .../pager-webui/www/assets/.gitkeep | 0 .../pager-webui/www/assets/logo.png | Bin 0 -> 41321 bytes .../pager-webui/www/assets/pager/A_Button.png | Bin 0 -> 2326 bytes .../pager-webui/www/assets/pager/B_Button.png | Bin 0 -> 2544 bytes .../pager-webui/www/assets/pager/DOWN.png | Bin 0 -> 2051 bytes .../pager-webui/www/assets/pager/LEFT.png | Bin 0 -> 3162 bytes .../pager-webui/www/assets/pager/RIGHT.png | Bin 0 -> 3097 bytes .../pager-webui/www/assets/pager/UP.png | Bin 0 -> 2036 bytes .../pager-webui/www/assets/pager/spacer.gif | Bin 0 -> 43 bytes .../www/assets/pager/virtual_pager_01.png | Bin 0 -> 5167 bytes .../www/assets/pager/virtual_pager_02.png | Bin 0 -> 4811 bytes .../www/assets/pager/virtual_pager_04.png | Bin 0 -> 1853 bytes .../www/assets/pager/virtual_pager_05.png | Bin 0 -> 5781 bytes .../www/assets/pager/virtual_pager_06.png | Bin 0 -> 1156 bytes .../www/assets/pager/virtual_pager_08.png | Bin 0 -> 1154 bytes .../www/assets/pager/virtual_pager_10.png | Bin 0 -> 1225 bytes .../www/assets/pager/virtual_pager_12.png | Bin 0 -> 2635 bytes .../www/assets/pager/virtual_pager_13.png | Bin 0 -> 2907 bytes .../www/assets/pager/virtual_pager_15.png | Bin 0 -> 1116 bytes .../www/assets/pager/virtual_pager_17.png | Bin 0 -> 1149 bytes .../www/assets/pager/virtual_pager_18.png | Bin 0 -> 1083 bytes .../www/assets/pager/virtual_pager_20.png | Bin 0 -> 1258 bytes .../www/assets/pager/virtual_pager_21.png | Bin 0 -> 1262 bytes .../www/assets/pager/virtual_pager_22.png | Bin 0 -> 1155 bytes .../www/assets/pager/virtual_pager_23.png | Bin 0 -> 1183 bytes .../www/assets/pager/virtual_pager_24.png | Bin 0 -> 1322 bytes .../remote_access/pager-webui/www/css/app.css | 484 ++ .../remote_access/pager-webui/www/index.html | 273 ++ .../remote_access/pager-webui/www/js/api.js | 42 + .../remote_access/pager-webui/www/js/app.js | 488 ++ .../remote_access/pager-webui/www/js/chart.js | 137 + .../pager-webui/www/js/config.js | 7 + .../remote_access/pager-webui/www/js/icons.js | 34 + .../remote_access/pager-webui/www/js/pager.js | 158 + .../pager-webui/www/js/terminal.js | 72 + .../remote_access/pager-webui/www/js/views.js | 2271 +++++++++ .../pager-webui/www/js/xterm-addon-fit.min.js | 1 + .../pager-webui/www/js/xterm.css | 209 + .../pager-webui/www/js/xterm.min.js | 2 + scripts/deploy.ps1 | 115 + scripts/dev.ps1 | 19 + scripts/dev_proxy.py | 84 + tests/__init__.py | 0 tests/test_auth.py | 124 + tests/test_core.py | 71 + tests/test_loot.py | 59 + tests/test_misc.py | 279 ++ tests/test_pineap_clients.py | 63 + tests/test_pineap_enterprise.py | 43 + tests/test_pineap_modes.py | 126 + tests/test_pineap_pool.py | 82 + tests/test_pineap_proxy.py | 258 ++ tests/test_pineap_settings.py | 55 + tests/test_recon.py | 599 +++ tests/test_status.py | 104 + tests/test_ws.py | 119 + 86 files changed, 20678 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 THIRD_PARTY_NOTICES.md create mode 100644 docs/plans/2026-08-11-pineapple-ui-clone.md create mode 100644 docs/specs/2026-08-10-pager-webui-design.md create mode 100644 docs/specs/2026-08-11-pineapple-ui-clone-design.md create mode 100644 docs/superpowers/plans/2026-08-10-pager-webui.md create mode 100644 docs/superpowers/plans/2026-08-11-handshakes-markvii-parity.md create mode 100644 docs/superpowers/plans/2026-08-11-openap-markvii-settings.md create mode 100644 docs/superpowers/plans/2026-08-11-openap-mk7-card.md create mode 100644 docs/superpowers/plans/2026-08-11-pineap-markvii-layout.md create mode 100644 docs/superpowers/plans/2026-08-11-pineap-overview-markvii.md create mode 100644 docs/superpowers/plans/2026-08-11-pineap-page-overhaul.md create mode 100644 docs/superpowers/plans/2026-08-11-recon-markvii.md create mode 100644 docs/superpowers/specs/2026-08-11-handshakes-markvii-parity-design.md create mode 100644 docs/superpowers/specs/2026-08-11-openap-markvii-settings-design.md create mode 100644 docs/superpowers/specs/2026-08-11-openap-mk7-card-design.md create mode 100644 docs/superpowers/specs/2026-08-11-pineap-markvii-layout-design.md create mode 100644 docs/superpowers/specs/2026-08-11-pineap-overview-markvii-design.md create mode 100644 docs/superpowers/specs/2026-08-11-pineap-page-overhaul-design.md create mode 100644 docs/superpowers/specs/2026-08-11-recon-markvii-design.md create mode 100644 docs/superpowers/specs/2026-08-11-virtual-pager-dock-design.md create mode 100644 payload/user/remote_access/pager-webui/LICENSE create mode 100644 payload/user/remote_access/pager-webui/README.md create mode 100644 payload/user/remote_access/pager-webui/THIRD_PARTY_NOTICES.md create mode 100644 payload/user/remote_access/pager-webui/_hak5_manifest.json create mode 100755 payload/user/remote_access/pager-webui/pagerwebui.init create mode 100755 payload/user/remote_access/pager-webui/payload.sh create mode 100644 payload/user/remote_access/pager-webui/server.py create mode 100644 payload/user/remote_access/pager-webui/www/assets/.gitkeep create mode 100644 payload/user/remote_access/pager-webui/www/assets/logo.png create mode 100644 payload/user/remote_access/pager-webui/www/assets/pager/A_Button.png create mode 100644 payload/user/remote_access/pager-webui/www/assets/pager/B_Button.png create mode 100644 payload/user/remote_access/pager-webui/www/assets/pager/DOWN.png create mode 100644 payload/user/remote_access/pager-webui/www/assets/pager/LEFT.png create mode 100644 payload/user/remote_access/pager-webui/www/assets/pager/RIGHT.png create mode 100644 payload/user/remote_access/pager-webui/www/assets/pager/UP.png create mode 100644 payload/user/remote_access/pager-webui/www/assets/pager/spacer.gif create mode 100644 payload/user/remote_access/pager-webui/www/assets/pager/virtual_pager_01.png create mode 100644 payload/user/remote_access/pager-webui/www/assets/pager/virtual_pager_02.png create mode 100644 payload/user/remote_access/pager-webui/www/assets/pager/virtual_pager_04.png create mode 100644 payload/user/remote_access/pager-webui/www/assets/pager/virtual_pager_05.png create mode 100644 payload/user/remote_access/pager-webui/www/assets/pager/virtual_pager_06.png create mode 100644 payload/user/remote_access/pager-webui/www/assets/pager/virtual_pager_08.png create mode 100644 payload/user/remote_access/pager-webui/www/assets/pager/virtual_pager_10.png create mode 100644 payload/user/remote_access/pager-webui/www/assets/pager/virtual_pager_12.png create mode 100644 payload/user/remote_access/pager-webui/www/assets/pager/virtual_pager_13.png create mode 100644 payload/user/remote_access/pager-webui/www/assets/pager/virtual_pager_15.png create mode 100644 payload/user/remote_access/pager-webui/www/assets/pager/virtual_pager_17.png create mode 100644 payload/user/remote_access/pager-webui/www/assets/pager/virtual_pager_18.png create mode 100644 payload/user/remote_access/pager-webui/www/assets/pager/virtual_pager_20.png create mode 100644 payload/user/remote_access/pager-webui/www/assets/pager/virtual_pager_21.png create mode 100644 payload/user/remote_access/pager-webui/www/assets/pager/virtual_pager_22.png create mode 100644 payload/user/remote_access/pager-webui/www/assets/pager/virtual_pager_23.png create mode 100644 payload/user/remote_access/pager-webui/www/assets/pager/virtual_pager_24.png create mode 100644 payload/user/remote_access/pager-webui/www/css/app.css create mode 100644 payload/user/remote_access/pager-webui/www/index.html create mode 100644 payload/user/remote_access/pager-webui/www/js/api.js create mode 100644 payload/user/remote_access/pager-webui/www/js/app.js create mode 100644 payload/user/remote_access/pager-webui/www/js/chart.js create mode 100644 payload/user/remote_access/pager-webui/www/js/config.js create mode 100644 payload/user/remote_access/pager-webui/www/js/icons.js create mode 100644 payload/user/remote_access/pager-webui/www/js/pager.js create mode 100644 payload/user/remote_access/pager-webui/www/js/terminal.js create mode 100644 payload/user/remote_access/pager-webui/www/js/views.js create mode 100644 payload/user/remote_access/pager-webui/www/js/xterm-addon-fit.min.js create mode 100644 payload/user/remote_access/pager-webui/www/js/xterm.css create mode 100644 payload/user/remote_access/pager-webui/www/js/xterm.min.js create mode 100644 scripts/deploy.ps1 create mode 100644 scripts/dev.ps1 create mode 100644 scripts/dev_proxy.py create mode 100644 tests/__init__.py create mode 100644 tests/test_auth.py create mode 100644 tests/test_core.py create mode 100644 tests/test_loot.py create mode 100644 tests/test_misc.py create mode 100644 tests/test_pineap_clients.py create mode 100644 tests/test_pineap_enterprise.py create mode 100644 tests/test_pineap_modes.py create mode 100644 tests/test_pineap_pool.py create mode 100644 tests/test_pineap_proxy.py create mode 100644 tests/test_pineap_settings.py create mode 100644 tests/test_recon.py create mode 100644 tests/test_status.py create mode 100644 tests/test_ws.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2c720d2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +build/ +__pycache__/ +*.pyc + +.worktrees/ + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b7c7bec --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 c4ch3c4d3 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..696cb1a --- /dev/null +++ b/README.md @@ -0,0 +1,102 @@ +# Mark VIII + +A Mark VII-style web management UI that runs **on the WiFi Pineapple Pager** at +`http://172.16.52.1:8080/`. Packaged as a native Pager payload. + +Features: Dashboard (live), PineAP (settings, SSID pool, filters, clients/kick), +Recon (scans from `recon.db`), Handshakes/Loot, Payloads (embedded stock Pager +Portal), Logs, Settings (hostname/NTP/password/prefs), and a bottom-docked xterm +terminal. + +## Requirements + +- WiFi Pineapple Pager, firmware `Pineapple Pager 24.10.1` +- `python3` on the device (present on current firmware) +- Windows dev box with Python 3.11 (`winget install Python.Python.3.11`) + +## Install (sideload) + +```powershell +# deploy.ps1 needs either an SSH key or sshpass for password auth: +& .\scripts\deploy.ps1 -SshKey "$HOME\.ssh\pager_key" +# or set up a key and add it: ssh-copy-id root@172.16.52.1 +``` + +This builds `build\pager-webui\payload-.zip`, uploads it, extracts it to +`/root/payloads/user/remote_access/pager-webui/`, and refreshes the portal index. + +Then on the Pager menu, run **Mark VIII**: +- **Yes** to "Run as background service?" -> procd service (respawns on crash, + boot-persistent via rc.d symlinks). +- **No** -> foreground mode; press **B** to stop. +- Re-run the payload while running to **Stop** the service. +- `PAYLOAD_GET_CONFIG pager_webui auto_mode/run_mode` skip the prompt. + +Browse `http://172.16.52.1:8080/` and log in with the device password. + +## Uninstall / recovery + +Re-run the payload and confirm "Stop service?" (stops, disables, removes the +init script), then delete the payload directory via the portal or: +`rm -rf /root/payloads/user/remote_access/pager-webui`. No stock files are modified. +After a **firmware upgrade** (which wipes the overlay), reinstall and run the +payload to re-enable it—the same caveat as Nautilus. + +## Local dev loop + +```powershell +.\scripts\deploy.ps1 -SshKey "$HOME\.ssh\pager_key" # deploy backend once +.\scripts\dev.ps1 -Tunnel # local SPA + API proxy +# open http://127.0.0.1:8000 +``` + +`dev.ps1` serves `www/` locally, proxies `/api/*` to the Pager, and points the +terminal at the Pager's daemon WS (`-Tunnel` opens the `:1471` SSH tunnel). +The live WebSocket falls back to 5s polling through the dev proxy. + +## API tests + +Python unit tests (stdlib `unittest`, runnable on Windows with mocks). Run each +module in its own process—the tests monkeypatch module-level helpers and do not +restore them, so a single `discover` process leaks state between files: + +```powershell +$py = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe" +Get-ChildItem tests\test_*.py | ForEach-Object { + $mod = "tests." + [IO.Path]::GetFileNameWithoutExtension($_.Name) + & $py -m unittest $mod -v +} +``` + +On-device smoke tests cover every page, background vs foreground, +terminal I/O, and reboot persistence. + +## Architecture + +- `server.py` — Mark VIII's pure-socket HTTP + JSON API + minimal RFC6455 WS on + `0.0.0.0:8080`, written to run on the device's `python3-light` (no + `urllib`/`http.server`/`sqlite3` stdlib modules there); talks to the Hak5 + daemon (`127.0.0.1:1471`) over a raw-socket HTTP client, `hak5cmd`, + `uci`, `iwinfo`, and `recon.db` read-only (via the `sqlite3` CLI). +- `www/` — vanilla JS SPA (no build step) + bundled xterm.js. +- `payload.sh` + `pagerwebui.init` — Nautilus-style installer / procd service. + +## Security notes + +- Auth via device password validated against the daemon; HttpOnly session + cookie `AUTH_`; all state-changing endpoints gated. +- Commands run with argument lists (no shell interpolation). +- Binds `0.0.0.0:8080` — same exposure class as the stock `:1471`/`:7681`. +- Same-origin validation protects browser writes and WebSocket upgrades; the + daemon token is stored in a root-only temporary session file. + +## Out of scope (v1) + +`:1471` takeover, Mark VII-only features (Campaigns/Modules/Cloud C2/EAP), +physical-display mirroring, and a PR to `hak5/wifipineapplepager-payloads` +(packaging is drop-in ready for that PR). + +## License + +Mark VIII source code is available under the MIT License. See `LICENSE` and +`THIRD_PARTY_NOTICES.md` for bundled-component and trademark notices. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..2bc4539 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,29 @@ +# Third-party notices + +Mark VIII bundles xterm.js and the xterm.js fit addon. + +Copyright (c) 2014 The xterm.js authors. All rights reserved. + +These components are distributed under the MIT License. Permission is hereby +granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software +without restriction, including without limitation the rights to use, copy, +modify, merge, publish, distribute, sublicense, and/or sell copies of the +Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +WiFi Pineapple, PineAP, Pager, and related names and imagery are trademarks or +assets of Hak5 LLC and their respective owners. They are not covered by the +Mark VIII MIT license. Mark VIII is an independent community payload and is not +an official Hak5 product. diff --git a/docs/plans/2026-08-11-pineapple-ui-clone.md b/docs/plans/2026-08-11-pineapple-ui-clone.md new file mode 100644 index 0000000..b43b307 --- /dev/null +++ b/docs/plans/2026-08-11-pineapple-ui-clone.md @@ -0,0 +1,1533 @@ +# Pineapple UI Clone 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 Pager WebUI (`:8080`) into a faithful clone of the stock Hak5 WiFi Pineapple UI (`:1471`) — light Material look, old icon-rail navigation, old information architecture — while reusing the existing backend and API surface (plus one new read-only endpoint). + +**Architecture:** The SPA stays a no-build vanilla JS app running on the device's `python3-light` HTTP server. All front-end changes live under `payload/user/general/pager-webui/www/`. The design tokens, shell, navigation, and views are rewritten to mirror the old Angular/Material UI; the `Live` WS/poll channel, `PagerAPI` auth, and `server.py` session mechanics are untouched. One new backend route (`GET /api/pineap/aps`) runs `iwinfo scan` read-only. + +**Tech Stack:** Vanilla JS (no framework, no bundler), hand-rolled CSS (Material look via custom properties), hand-rolled `` line chart (no Chart.js — the old UI's Chart.js is inlined in its Angular bundle and no offline copy is available), Python `server.py` for the one new endpoint, `unittest` for backend tests. + +## Global Constraints + +- Device runtime: `python3-light` on WiFi Pineapple Pager 24.10.1 — no third-party pip packages, no build step, ES5-compatible JS only (device browser = stock Chromium-era, but keep to ES6 that modern mobile browsers support; the existing code already uses `const`/arrow functions and `async/await`). +- No new `/api/*` surface except `GET /api/pineap/aps` (read-only, `iwinfo scan`). +- Auth/session mechanics unchanged: `AUTH_` HttpOnly cookie, login as user `root` with the device password. +- Design tokens (from the old UI): content bg `#fafafa`, cards `#fff` with elevation shadow, toolbar + rail `#424242`, rail hover `#a9a9a9` (dark `#545454`), active indicator `#1976d2` (3px right border), primary `#1976d2`, danger `#d32f2f`, ok `#7cb342`, warn `#f9a825`, text `#212121` / muted `#686868`; font stack `Roboto, "Segoe UI", Arial, sans-serif`. Dark theme (opt-in): rail `#3a3a3a`, surfaces `#303030`, cards `#424242`. +- Info architecture (sidebar order): Dashboard, Campaigns, PineAP, Recon, Logging, Modules & Packages, Settings. Sub-pages: PineAP → Open/Clients/Filtering/APs/Impersonation (no Enterprise); Recon → Overview/Handshakes; Logging → Overview/System. +- Login: password only (username fixed `root`), old-style card. +- Terminal stays bottom-docked (existing behavior), restyled. +- Existing Python `unittest` suite must stay green (`tests/` run per-file). +- Commits follow repo style (`feat:`, `fix:`, `docs:`). + +--- + +### Task 1: Backend — `GET /api/pineap/aps` + +**Files:** +- Modify: `payload/user/general/pager-webui/server.py` (add parser + handler + route next to the other pineap handlers/routes) +- Test: `tests/test_pineap_aps.py` (new) + +**Interfaces:** +- Consumes: `server.device_run(args, timeout=20)` (exists), `server.wifi_ifaces()` (exists — returns `wlan*`/`radio*` iface names), `server.ROUTER.add(method, pattern, handler)` (exists), handler signature `(ctx) -> (status, payload)` where `ctx.args` is a tuple. +- Produces: + - `server.parse_iwinfo_scan(text) -> list[dict]` with keys `bssid` (upper-case MAC), `ssid` (str, may be `''`), `channel` (int or `None`), `signal` (int or `None`), `encryption` (str, may be `'none'`). + - `server.aps_data() -> list[dict]` — concatenation of `parse_iwinfo_scan(out)` per iface, each row augmented with `iface`. + - `server.h_pineap_aps(ctx) -> (200, {'aps': [...], 'count': n})`. + - Route: `ROUTER.add('GET', r'/api/pineap/aps', h_pineap_aps)`. + +- [ ] **Step 1: Write the failing test** + +Create `tests/test_pineap_aps.py`: + +```python +import os +import sys +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'general', 'pager-webui')) +import server + +SAMPLE = """Cell 01 - Address: AA:BB:CC:DD:EE:FF + ESSID: "Hak5" + Mode: Master Channel: 6 + Signal: -45 dBm Quality: 70/100 + Encryption: WPA2 PSK (CCMP) + +Cell 02 - Address: 00:11:22:33:44:55 + ESSID: "" + Mode: Master Channel: 11 + Signal: -60 dBm Quality: 55/100 + Encryption: none +""" + +_orig_aps_data = server.aps_data + + +class ParseTest(unittest.TestCase): + def test_parses_cells(self): + rows = server.parse_iwinfo_scan(SAMPLE) + self.assertEqual(len(rows), 2) + self.assertEqual(rows[0]['bssid'], 'AA:BB:CC:DD:EE:FF') + self.assertEqual(rows[0]['ssid'], 'Hak5') + self.assertEqual(rows[0]['channel'], 6) + self.assertEqual(rows[0]['signal'], -45) + self.assertEqual(rows[0]['encryption'], 'WPA2 PSK (CCMP)') + self.assertEqual(rows[1]['ssid'], '') + self.assertEqual(rows[1]['encryption'], 'none') + + def test_empty_text(self): + self.assertEqual(server.parse_iwinfo_scan(''), []) + + +class ApsHandlerTest(unittest.TestCase): + def tearDown(self): + server.aps_data = _orig_aps_data + + def test_handler(self): + server.aps_data = lambda: [{'bssid': 'AA:BB:CC:DD:EE:FF', 'iface': 'wlan0mon'}] + status, payload = server.h_pineap_aps(type('C', (), {'args': ()})()) + self.assertEqual(status, 200) + self.assertEqual(payload['count'], 1) + self.assertEqual(payload['aps'][0]['bssid'], 'AA:BB:CC:DD:EE:FF') +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```powershell +& "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe" -m unittest tests.test_pineap_aps -v +``` +Expected: FAIL with `AttributeError: module 'server' has no attribute 'parse_iwinfo_scan'`. + +- [ ] **Step 3: Implement the parser, data function, handler, and route** + +In `server.py`, after `assoc_clients()` (near the existing `wifi_ifaces`/`iwinfo` helpers), add: + +```python +def parse_iwinfo_scan(text): + aps = [] + cur = None + for line in text.splitlines(): + m = re.match(r'^Cell \d+ - Address:\s*([0-9A-Fa-f:]+)', line) + if m: + if cur is not None: + aps.append(cur) + cur = {'bssid': m.group(1).upper(), 'ssid': '', 'channel': None, + 'signal': None, 'encryption': ''} + continue + if cur is None: + continue + m = re.search(r'ESSID:\s*"([^"]*)"', line) + if m: + cur['ssid'] = m.group(1) + m = re.search(r'Channel:\s*(\d+)', line) + if m: + cur['channel'] = int(m.group(1)) + m = re.search(r'Signal:\s*(-?\d+)', line) + if m: + cur['signal'] = int(m.group(1)) + m = re.search(r'Encryption:\s*(.+)', line) + if m: + cur['encryption'] = m.group(1).strip() + if cur is not None: + aps.append(cur) + return aps + + +def aps_data(): + aps = [] + for name in wifi_ifaces(): + rc, out, err = device_run(['iwinfo', name, 'scan']) + if rc != 0: + continue + for ap in parse_iwinfo_scan(out): + ap['iface'] = name + aps.append(ap) + return aps + + +def h_pineap_aps(ctx): + aps = aps_data() + return 200, {'aps': aps, 'count': len(aps)} +``` + +In the `ROUTER.add` block (after the existing `POST /api/pineap/clients/kick` line), add: + +```python +ROUTER.add('GET', r'/api/pineap/aps', h_pineap_aps) +``` + +- [ ] **Step 4: Run the test to verify it passes** + +```powershell +& "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe" -m unittest tests.test_pineap_aps -v +``` +Expected: PASS (3 tests). + +- [ ] **Step 5: Run the existing suite to confirm no 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 existing modules PASS. + +- [ ] **Step 6: Commit** + +```bash +git add payload/user/general/pager-webui/server.py tests/test_pineap_aps.py +git commit -m "feat: add read-only GET /api/pineap/aps (iwinfo scan)" +``` + +--- + +### Task 2: Assets — fetch old-UI logo; add `icons.js` + +**Files:** +- Create: `payload/user/general/pager-webui/www/assets/logo.png` (copied from the old UI) +- Create: `payload/user/general/pager-webui/www/js/icons.js` +- Modify: `payload/user/general/pager-webui/www/index.html` (favicon link) + +**Interfaces:** +- Produces: `window.PineappleIcons` — a plain object mapping icon name → SVG markup string. Names used by later tasks: `dashboard`, `campaigns`, `pineap`, `recon`, `logging`, `modules`, `settings`, `chevron`, `terminal`, `wifi`, `extension`, `receipt`. Each string is a full inline `...` at 24×24. + +- [ ] **Step 1: Download the old-UI logo** + +```powershell +curl.exe -s -o "payload\user\general\pager-webui\www\assets\logo.png" "http://172.16.42.1:1471/assets/icons/logo.png" +``` +Verify: file is ~41 KB PNG (`(Get-Item ...).Length`). + +- [ ] **Step 2: Extract the sidebar SVG paths from the old bundle** + +The Angular bundle was previously downloaded to `%TEMP%\opencode\oldui_main.js` (re-fetch with `curl.exe -s --compressed -u "root:hak5pineapple" -m 60 "http://172.16.42.1:1471/main.ce5a318adf590e170f6d.js" -o "$env:TEMP\opencode\oldui_main.js"` if missing). Extract the three inline nav icon paths: + +```powershell +$c = [IO.File]::ReadAllText("$env:TEMP\opencode\oldui_main.js") +$i = $c.IndexOf('["class","sidenav-entry"],["matTooltip","Dashboard"]') +$t = $c.Substring([Math]::Max(0, $i - 200), 30000) -replace '\s+', ' ' +[regex]::Matches($t, '"d","([A-Za-z0-9,.\- ]+)"') | ForEach-Object { $_.Groups[1].Value } +``` +Expected: three paths — `view-dashboard` (Dashboard), a gear (Campaigns), and a building/landmark (Recon). + +- [ ] **Step 3: Write `js/icons.js`** + +```js +'use strict'; + +const PineappleIcons = { + dashboard: '', + campaigns: '', + pineap: '', + recon: '', + logging: '', + modules: '', + settings: '', + chevron: '', + terminal: '', + wifi: '', + extension: '', + receipt: '' +}; +``` + +Note: `campaigns`, `settings`, and `modules`/`extension` intentionally share Material glyph shapes (gear / puzzle) exactly as the old bundle does. If Step 2's extraction produces different text than the literal strings above, prefer the bundle-extracted `d` attributes verbatim and update `icons.js` to match. + +- [ ] **Step 4: Reference the favicon in `index.html`** + +Add `` inside the `` of `payload/user/general/pager-webui/www/index.html`. + +- [ ] **Step 5: Verify assets serve locally** + +Serve the payload dir with the project dev server (or open the file path) and confirm `assets/logo.png` and `js/icons.js` load: +```powershell +python -c "import http.server, functools, os; os.chdir(r'payload\user\general\pager-webui\www'); http.server.test(HandlerClass=http.server.SimpleHTTPRequestHandler, port=8000)" +``` +Then `curl.exe -s -o NUL -w "%{http_code} %{size_download}" http://127.0.0.1:8000/assets/logo.png` and `.../js/icons.js` — both 200. + +- [ ] **Step 6: Commit** + +```bash +git add payload/user/general/pager-webui/www/assets/logo.png payload/user/general/pager-webui/www/js/icons.js payload/user/general/pager-webui/www/index.html +git commit -m "feat: vendor pineapple logo and Material nav icons" +``` + +--- + +### Task 3: Theme CSS — Material light/dark rewrite of `app.css` + +**Files:** +- Rewrite: `payload/user/general/pager-webui/www/css/app.css` + +**Interfaces:** +- Produces: CSS custom properties on `:root` (light) and `html.dark` (dark). Class names consumed by later tasks: + - Shell: `#login-screen`, `.login-card`, `.login-logo`, `.login-title`, `.login-field`, `.login-error`, `.spinner`, `#topbar`, `.brand-logo`, `.brand-text`, `.toolbar-spacer`, `#live-status`, `#terminal-btn`, `#layout`, `#rail`, `#rail .entry`, `#rail .entry.active`, `#rail .entry .entry-icon`, `#rail .entry .entry-text`, `#rail .entry.divider`, `#rail .rail-footer`, `#rail.open`, `#content`, `#terminal-panel`, `#terminal-bar`. + - Tabs: `.tabbar`, `.tabbar .tab`, `.tabbar .tab.active`, `.tabbar .ink`. + - Content: `.page-title`, `.cards`, `.card`, `.card-label`, `.card-value`, `.section`, `.row`, `.tbl`, `.toggle`, `.badge`, `.badge.on`, `.badge.off`, `.btn`, `.btn.raised`, `.btn.ghost`, `.btn.danger`, `input`, `select`, `label`, `code`, `pre.logs`, `.empty`, `.toast`. + +- [ ] **Step 1: Replace `app.css` with the Material theme** + +Use CSS custom properties so light/dark swap via `html.dark`. Key values from Global Constraints. Structure: + +```css +:root { + --bg: #fafafa; --surface: #fff; --surface-alt: #f5f5f5; + --toolbar: #424242; --rail: #424242; --rail-hover: #545454; + --text: #212121; --muted: #686868; --border: #e0e0e0; + --primary: #1976d2; --primary-dark: #105694; --danger: #d32f2f; + --ok: #7cb342; --warn: #f9a825; --terminal-bg: #000; + --shadow: 0 2px 2px rgba(0,0,0,.24), 0 0 2px rgba(0,0,0,.12); + --ink: #1976d2; +} +html.dark { + --bg: #303030; --surface: #424242; --surface-alt: #3a3a3a; + --toolbar: #424242; --rail: #3a3a3a; --rail-hover: #545454; + --text: #fff; --muted: #bdbdbd; --border: #545454; + --shadow: 0 2px 2px rgba(0,0,0,.5), 0 0 2px rgba(0,0,0,.3); +} +* { box-sizing: border-box; } +html, body { margin: 0; height: 100%; } +body { + background: var(--bg); color: var(--text); + font: 14px/1.45 Roboto, "Segoe UI", Arial, sans-serif; + display: flex; flex-direction: column; +} +.hidden { display: none !important; } +``` + +Implement all of the following styles (light values, dark automatically via variables): +- `#login-screen` — `flex:1; display:flex; align-items:center; justify-content:center; background:#9c9c9c;` +- `.login-card` — `background:#fff; border-radius:2px; padding:32px; width:360px; box-shadow:0 8px 20px rgba(0,0,0,.4); text-align:center;` +- `.login-logo` — `height:148px; margin-bottom:8px;` +- `.login-title` — `margin:0 0 20px; font-size:26px; font-weight:400; color:#212121;` +- `.login-field` — full-width Material-style inputs: `width:100%; padding:10px 12px; border:1px solid #ccc; border-radius:2px; margin:6px 0; font-size:14px;` +- `.login-error` — `color:#d32f2f; font-size:13px; min-height:18px;` +- `.spinner` — 18px ring loader (CSS border spinner, blue). +- `#topbar` — `height:64px; background:var(--toolbar); color:#fff; display:flex; align-items:center; padding:0 16px; box-shadow:0 2px 4px rgba(0,0,0,.4);` +- `.brand-logo` — `height:32px; margin-right:10px;` +- `.brand-text` — `font-size:20px; font-weight:400; letter-spacing:.5px;` +- `.toolbar-spacer` — `flex:1;` +- `#live-status` — `color:#e0e0e0; font-size:12px; margin-right:16px; font-variant-numeric:tabular-nums;` +- `#terminal-btn` — ghost white toolbar button. +- `#layout` — `flex:1; display:flex; min-height:0;` +- `#rail` — `width:60px; background:var(--rail); color:#fff; display:flex; flex-direction:column; overflow-y:auto; transition:width .2s;` +- `#rail.open` — `width:220px;` +- `#rail .entry` — `height:48px; display:flex; align-items:center; padding:0 0 0 18px; color:#e0e0e0; cursor:pointer; border-right:3px solid transparent; font-size:14px;` +- `#rail .entry:hover` — `background:var(--rail-hover);` +- `#rail .entry.active` — `border-right:3px solid #1976d2; background:rgba(255,255,255,.08); color:#fff;` +- `.entry-icon` — `font-size:24px; width:24px; height:24px; display:flex; align-items:center; justify-content:center; margin-right:14px; flex:none;` +- `.entry-icon svg` — `width:24px; height:24px; display:block;` +- `#topbar .btn.ghost` — `color:#fff; border-color:#fff;` +- `.entry-text` — `white-space:nowrap; opacity:0; transition:opacity .2s;` +- `#rail.open .entry-text` — `opacity:1;` +- `#rail .entry.divider` — `height:0; padding:0; margin:8px 0; border-top:1px solid #616161; flex:none;` +- `.rail-footer` — `margin-top:auto;` +- `#content` — `flex:1; overflow-y:auto; padding:20px 30px 5px 5px;` +- `.tabbar` — `display:flex; border-bottom:2px solid #e0e0e0; margin-bottom:16px; position:relative;` +- `.tabbar .tab` — `padding:10px 16px; cursor:pointer; color:var(--muted); font-size:14px; border-bottom:2px solid transparent; margin-bottom:-2px;` +- `.tabbar .tab.active` — `color:var(--primary); border-bottom-color:var(--ink);` +- `.page-title` — `font-size:24px; font-weight:400; margin:0 0 12px;` +- `.cards` — `display:grid; grid-template-columns:repeat(auto-fill, minmax(170px,1fr)); gap:12px;` +- `.card` — `background:var(--surface); border-radius:2px; box-shadow:var(--shadow); padding:14px;` +- `.card-label` — `font-size:12px; color:var(--muted);` +- `.card-value` — `font-size:28px; font-weight:400; margin-top:4px;` +- `.section` — `background:var(--surface); border-radius:2px; box-shadow:var(--shadow); padding:16px; margin-bottom:16px;` +- `.section h2` — `margin:0 0 12px; font-size:16px; font-weight:500;` +- `.row` — `display:flex; gap:10px; align-items:flex-end; flex-wrap:wrap;` +- `.row > div` — `flex:1; min-width:140px;` +- `.tbl` — `width:100%; border-collapse:collapse;` +- `.tbl th, .tbl td` — `text-align:left; padding:8px 10px; border-bottom:1px solid var(--border);` +- `.tbl th` — `color:var(--muted); font-size:12px; font-weight:500;` +- `.tbl tr:hover td` — `background:var(--surface-alt);` +- `.toggle` — `display:flex; align-items:center; gap:8px; margin:6px 0;` +- `.badge` — `display:inline-block; padding:2px 10px; border-radius:10px; font-size:11px;` +- `.badge.on` — `background:#e8f5e9; color:#2e7d32;` (dark: override color via `html.dark` or use `var(--ok)` text) +- `.badge.off` — `background:#fff3e0; color:#e65100;` +- `.btn` — `background:var(--primary); color:#fff; border:0; border-radius:2px; padding:8px 14px; font-size:14px; cursor:pointer; box-shadow:0 1px 3px rgba(0,0,0,.3);` +- `.btn:hover` — `background:var(--primary-dark);` +- `.btn.ghost` — `background:transparent; color:var(--primary); box-shadow:none; border:1px solid var(--primary);` +- `.btn.danger` — `background:var(--danger);` +- `input, select` — `background:var(--surface); color:var(--text); border:1px solid var(--border); border-radius:2px; padding:8px 10px; width:100%;` +- `label` — `display:block; font-size:12px; color:var(--muted); margin:8px 0 4px;` +- `code` — `background:var(--surface-alt); padding:1px 5px; border-radius:2px;` +- `pre.logs` — `background:var(--terminal-bg); color:#c9d1d9; padding:12px; overflow:auto; max-height:420px; font-size:12px; white-space:pre-wrap; border-radius:2px;` +- `.empty` — `padding:24px; text-align:center; color:var(--muted);` +- `#terminal-panel` — `border-top:1px solid var(--border); background:#000; height:280px; display:flex; flex-direction:column;` +- `#terminal-bar` — `display:flex; justify-content:space-between; align-items:center; background:var(--toolbar); padding:4px 10px; font-size:12px; color:#e0e0e0;` +- `#toast-container` — `position:fixed; right:16px; bottom:16px; display:flex; flex-direction:column; gap:8px; z-index:100;` +- `.toast` — `padding:10px 14px; border-radius:2px; background:var(--surface); box-shadow:var(--shadow); color:var(--text);` +- `.toast.error` — `border-left:4px solid var(--danger);` +- `.toast.info` — `border-left:4px solid var(--primary);` +- Terminal xterm stays black; `.xterm` sizing preserved (`#terminal .xterm { height:100%; }`). + +Write the full stylesheet — do not omit any listed selector. + +- [ ] **Step 2: Sanity-check the file** + +Verify the file parses as CSS (balanced braces) and contains both `:root` and `html.dark` blocks, plus `#rail` and `.tabbar` selectors: +```powershell +Select-String -Path payload\user\general\pager-webui\www\css\app.css -Pattern ':root','html.dark','#rail','.tabbar' +``` +Expected: all four patterns found. + +- [ ] **Step 3: Commit** + +```bash +git add payload/user/general/pager-webui/www/css/app.css +git commit -m "feat: Material light/dark theme CSS for UI clone" +``` + +--- + +### Task 4: Shell markup — rewrite `index.html` + +**Files:** +- Rewrite: `payload/user/general/pager-webui/www/index.html` + +**Interfaces:** +- Consumes: `js/icons.js` (`PineappleIcons`), `css/app.css` classes from Task 3, existing `js/xterm.min.js`, `js/xterm-addon-fit.min.js`, `js/xterm.css`. +- Produces: element IDs wired by `js/app.js` (Task 5): `login-screen`, `login-form`, `login-password`, `login-error`, `login-button`, `app`, `topbar`, `brand-logo`, `brand-text`, `live-status`, `terminal-btn`, `layout`, `rail`, `content`, `terminal-panel`, `terminal-bar`, `terminal-close`, `terminal`, `toast-container`. + +- [ ] **Step 1: Replace `index.html`** + +```html + + + + + + +WiFi Pineapple + + + + + +
+ +
+ + + +
+ + + + + + + + + + + + +``` + +Note: `#rail` is rendered empty; Task 5 builds its entries from JS. `js/chart.js` is created in Task 5 — the script tag here is fine because it loads before `app.js` runs on `DOMContentLoaded`; until Task 5 lands, a missing file is expected and harmless in this interim commit only if you open the page before Task 5. To keep each task independently servable, create an empty placeholder `js/chart.js` in Task 4: + +```js +'use strict'; +window.MiniChart = window.MiniChart || { draw: function () {} }; +``` + +- [ ] **Step 2: Verify** + +`curl.exe -s http://127.0.0.1:8000/` (dev server from Task 2) returns 200 and contains `id="rail"`, `assets/logo.png`, `js/icons.js`, `js/chart.js`. + +- [ ] **Step 3: Commit** + +```bash +git add payload/user/general/pager-webui/www/index.html payload/user/general/pager-webui/www/js/chart.js +git commit -m "feat: old-style app shell markup" +``` + +--- + +### Task 5: Shell JS — routing, rail, theme, shortcuts, chart + +**Files:** +- Rewrite: `payload/user/general/pager-webui/www/js/app.js` +- Create: `payload/user/general/pager-webui/www/js/chart.js` +- Modify: `payload/user/general/pager-webui/www/js/config.js` (no change needed — leave as-is) + +**Interfaces:** +- Consumes: `PagerAPI` (`login`, `get`, `post`, `del`, `on401`, `setBase`), `PineappleIcons` (Task 2), DOM IDs from Task 4, `Live` (defined in `app.js` itself, as today). +- Produces (used by `js/views.js` in Tasks 6–8): + - `App.toast(msg, kind)` — creates `.toast` in `#toast-container`, auto-removes after 4s. + - `App.route()` — reads `location.hash`, looks up `views[name]`, calls `views[name](rootEl)`, returns the view handle `{destroy}`. + - `App.railItems` — the ordered nav list: `[{key:'dashboard', label:'Dashboard', hash:'#/dashboard', icon:'dashboard'}, {key:'campaigns', label:'Campaigns', hash:'#/campaigns', icon:'campaigns'}, {key:'pineap', label:'PineAP', hash:'#/pineap', icon:'pineap'}, {key:'recon', label:'Recon', hash:'#/recon', icon:'recon'}, {key:'logging', label:'Logging', hash:'#/logging', icon:'logging'}, {key:'modules', label:'Modules & Packages', hash:'#/modules', icon:'modules'}, {key:'settings', label:'Settings', hash:'#/settings', icon:'settings'}]` with dividers after `campaigns`, `logging`, and before the footer. + - `App.key` — current nav key derived from hash (parent segment, e.g. `#/pineap/clients` → `pineap`). + - `App.go(hash)` — sets `location.hash`. + - `Live.onTick(fn)` — subscribes; `Live.start()` — WS `/api/ws` + 5s poll fallback (keep existing logic). + - `MiniChart.draw(canvas, series, opts)` (in `chart.js`) — draws a 2-series line chart. + - `window.THEME` helpers: `Theme.apply()` sets `document.documentElement.classList.toggle('dark', localStorage.pw_theme === 'dark')`; `Theme.toggle()`; `Theme.current()`. +- Keyboard shortcuts: `d`→`#/dashboard`, `c`→`#/campaigns`, `p`→`#/pineap`, `r`→`#/recon`, `l`→`#/logging`, `m`→`#/modules`, `` ` ``→terminal toggle. Ignore when focus is in an input/textarea. + +- [ ] **Step 1: Write `js/chart.js` (mini canvas line chart)** + +```js +'use strict'; + +const MiniChart = (() => { + function draw(canvas, series, opts) { + const o = opts || {}; + const W = canvas.width = canvas.clientWidth * (window.devicePixelRatio || 1); + const H = canvas.height = 140 * (window.devicePixelRatio || 1); + const ctx = canvas.getContext('2d'); + ctx.scale(window.devicePixelRatio || 1, window.devicePixelRatio || 1); + 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; + // gridlines + 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(); + } + }); + } + return { draw }; +})(); +``` + +- [ ] **Step 2: Rewrite `js/app.js`** + +Keep the existing `Live` implementation verbatim (WS + poll fallback + `updateBar`), but change `updateBar` to also write the brand text, and replace the app shell logic: + +```js +'use strict'; + +const Theme = (() => { + const KEY = 'pw_theme'; + function apply() { + document.documentElement.classList.toggle('dark', localStorage.getItem(KEY) === 'dark'); + } + function toggle() { + localStorage.setItem(KEY, Theme.current() === 'dark' ? 'light' : 'dark'); + apply(); + } + function current() { return document.documentElement.classList.contains('dark') ? 'dark' : 'light'; } + return { apply, toggle, current, KEY }; +})(); + +const App = (() => { + const cfg = window.PAGER_CONFIG || {}; + const API_BASE = cfg.apiBase || ''; + const WS_BASE = (cfg.wsBase || location.origin).replace(/^http/, 'ws'); + const TERMINAL_WS = cfg.terminalWs || ('ws://' + location.hostname + ':1471/api/terminal/openWs'); + + const els = {}; + let currentView = null; + + const railItems = [ + { key: 'dashboard', label: 'Dashboard', hash: '#/dashboard', icon: 'dashboard' }, + { key: 'campaigns', label: 'Campaigns', hash: '#/campaigns', icon: 'campaigns' }, + { key: 'pineap', label: 'PineAP', hash: '#/pineap', icon: 'pineap' }, + { key: 'recon', label: 'Recon', hash: '#/recon', icon: 'recon' }, + { key: 'logging', label: 'Logging', hash: '#/logging', icon: 'logging' }, + { key: 'modules', label: 'Modules & Packages', hash: '#/modules', icon: 'modules' }, + { key: 'settings', label: 'Settings', hash: '#/settings', icon: 'settings' } + ]; + const railDividers = new Set(['campaigns', 'logging']); + + function keyOf(hash) { + const seg = (hash || '#/dashboard').split('/')[0].replace('#/', ''); + return seg || 'dashboard'; + } + + function buildRail() { + const rail = els.rail; + rail.innerHTML = ''; + railItems.forEach((it) => { + if (railDividers.has(it.key)) { + const d = document.createElement('div'); + d.className = 'entry divider'; + rail.appendChild(d); + } + const a = document.createElement('a'); + a.className = 'entry'; + a.href = it.hash; + a.title = it.label; + a.innerHTML = '' + PineappleIcons[it.icon] + '' + it.label + ''; + a.addEventListener('click', (e) => { e.preventDefault(); location.hash = it.hash; }); + rail.appendChild(a); + }); + const foot = document.createElement('div'); + foot.className = 'rail-footer'; + const open = document.createElement('a'); + open.className = 'entry'; + open.title = 'Open Menu'; + open.innerHTML = '' + PineappleIcons.chevron + 'Open Menu'; + open.addEventListener('click', (e) => { + e.preventDefault(); + const openState = localStorage.getItem('pw_rail') === 'open'; + localStorage.setItem('pw_rail', openState ? 'closed' : 'open'); + els.rail.classList.toggle('open', !openState); + }); + foot.appendChild(open); + rail.appendChild(foot); + } + + function route() { + const hash = location.hash || '#/dashboard'; + const name = routes[hash]; + if (currentView && currentView.destroy) currentView.destroy(); + els.content.innerHTML = ''; + if (!name || !views[name]) { + const ph = document.createElement('div'); + ph.className = 'section empty'; + ph.textContent = 'View not available.'; + els.content.appendChild(ph); + currentView = null; + } else { + currentView = views[name](els.content); + } + const key = keyOf(hash); + Array.prototype.forEach.call(els.rail.querySelectorAll('.entry'), (a) => { + a.classList.toggle('active', a.getAttribute('href') === hash || keyOf(a.getAttribute('href')) === key); + }); + } + + function toast(msg, kind) { + const d = document.createElement('div'); + d.className = 'toast ' + (kind || 'info'); + d.textContent = msg; + els.toasts.appendChild(d); + setTimeout(() => d.remove(), 4000); + } + + function showApp() { + els.login.classList.add('hidden'); + els.app.classList.remove('hidden'); + Theme.apply(); + Live.start(); + route(); + } + + function showLogin() { + els.app.classList.add('hidden'); + els.login.classList.remove('hidden'); + } + + function init() { + els.login = document.getElementById('login-screen'); + els.app = document.getElementById('app'); + els.rail = document.getElementById('rail'); + els.content = document.getElementById('content'); + els.toasts = document.getElementById('toast-container'); + + document.getElementById('login-form').addEventListener('submit', (e) => { + e.preventDefault(); + const btn = document.getElementById('login-button'); + const pw = document.getElementById('login-password').value; + document.getElementById('login-error').textContent = ''; + btn.disabled = true; + PagerAPI.login('root', pw) + .then(() => { document.getElementById('login-password').value = ''; showApp(); toast('Logged in'); }) + .catch((err) => { + document.getElementById('login-error').textContent = (err && err.message && err.message !== 'unauthorized') + ? 'Login failed.' : 'Invalid credentials.'; + }) + .finally(() => { btn.disabled = false; }); + }); + + document.getElementById('terminal-btn').addEventListener('click', () => { + if (typeof Term === 'undefined') toast('Terminal not available yet'); + else Term.toggle(); + }); + document.getElementById('terminal-close').addEventListener('click', () => { + if (typeof Term === 'undefined') toast('Terminal not available yet'); + else Term.toggle(); + }); + window.addEventListener('hashchange', route); + document.addEventListener('keydown', (e) => { + const t = e.target; + if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT')) return; + if (e.ctrlKey || e.metaKey || e.altKey) return; + const map = { d: '#/dashboard', c: '#/campaigns', p: '#/pineap', r: '#/recon', l: '#/logging', m: '#/modules' }; + if (map[e.key.toLowerCase()]) { location.hash = map[e.key.toLowerCase()]; } + else if (e.key === '`') { if (typeof Term !== 'undefined') Term.toggle(); } + }); + + PagerAPI.setBase(API_BASE); + PagerAPI.on401 = () => showLogin(); + buildRail(); + Theme.apply(); + checkSession(); + } + + function checkSession() { + PagerAPI.get('/api/api_ping') + .then(() => showApp()) + .catch(() => showLogin()); + } + + const routes = { + '#/dashboard': 'dashboard', + '#/campaigns': 'campaigns', + '#/pineap': 'pineap', + '#/pineap/open': 'pineap_open', + '#/pineap/clients': 'pineap_clients', + '#/pineap/filtering': 'pineap_filtering', + '#/pineap/aps': 'pineap_aps', + '#/pineap/impersonation': 'pineap_impersonation', + '#/recon': 'recon', + '#/recon/handshakes': 'recon_handshakes', + '#/logging': 'logging', + '#/logging/system': 'logging_system', + '#/modules': 'modules', + '#/settings': 'settings' + }; + + return { init, route, toast, showLogin, wsUrl: (p) => WS_BASE + p, terminalWs: TERMINAL_WS, apiBase: API_BASE, + keyOf, railItems, go: (h) => { location.hash = h; } }; +})(); + +const Live = (() => { + let ws = null; + let ever = false; + let poll = null; + const subs = []; + let timer = null; + function stopPoll() { + if (poll) { clearInterval(poll); poll = null; } + } + function start() { + if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return; + stopPoll(); + try { ws = new WebSocket(App.wsUrl('/api/ws')); } + catch (e) { fallback(); return; } + ws.onopen = () => { ever = true; }; + ws.onmessage = (ev) => { + let msg; + try { msg = JSON.parse(ev.data); } catch (e) { return; } + subs.forEach((fn) => fn(msg)); + updateBar(msg); + }; + ws.onclose = () => { + ws = null; + clearTimeout(timer); + if (ever) timer = setTimeout(start, 5000); + else fallback(); + }; + ws.onerror = () => { try { ws.close(); } catch (e) {} }; + } + function fallback() { + stopPoll(); + poll = setInterval(async () => { + try { + const r = await PagerAPI.get('/api/status'); + const msg = { type: 'tick', status: r.data, clients: r.data.clients }; + subs.forEach((fn) => fn(msg)); + updateBar(msg); + } catch (e) {} + }, 5000); + } + function updateBar(msg) { + const b = (msg.status || {}).battery || {}; + const n = (msg.clients || []).length; + const el = document.getElementById('live-status'); + if (el) el.textContent = 'BAT ' + (b.level == null ? '--' : b.level + '%' + (b.charging ? '+' : '')) + ' CLIENTS ' + n; + } + return { start, onTick: (fn) => subs.push(fn) }; +})(); + +document.addEventListener('DOMContentLoaded', () => App.init()); +``` + +- [ ] **Step 3: Verify no syntax errors** + +```powershell +$c = Get-Content -Raw payload\user\general\pager-webui\www\js\app.js +if ($c -match 'function|const' -and $c -match 'App.init') { 'app.js OK' } +$d = Get-Content -Raw payload\user\general\pager-webui\www\js\chart.js +if ($d -match 'MiniChart.draw') { 'chart.js OK' } +``` +Also open `http://127.0.0.1:8000/` in a browser (dev server from Task 2) — the login card renders with the logo and no console errors; the `#rail` renders 7 entries + footer. + +- [ ] **Step 4: Commit** + +```bash +git add payload/user/general/pager-webui/www/js/app.js payload/user/general/pager-webui/www/js/chart.js +git commit -m "feat: shell JS - routing, icon rail, keyboard shortcuts, theme" +``` + +--- + +### Task 6: Views — Dashboard, Campaigns, tab-bar helper + +**Files:** +- Modify: `payload/user/general/pager-webui/www/js/views.js` (replace everything; helper functions `h`, `table`, `fmtTime`, `fmtDur`, `badge`, `btn` are retained/restyled) + +**Interfaces:** +- Consumes: `PagerAPI`, `App.toast`, `App.go`, `App.keyOf`, `Live.onTick`, `MiniChart.draw`, DOM helpers. +- Produces (used by Tasks 7–8): shared helpers `h`, `table`, `fmtTime`, `fmtDur`, `btn`, `badge`, plus `tabBar(box, items, activeHash)` which renders `.tabbar` with `.tab` items linking to `activeHash` values and a blue `.ink` bar; and view functions `views.dashboard`, `views.campaigns` (this task) and `views.pineap_open` … `views.settings` (Tasks 7–8). + +- [ ] **Step 1: Replace `views.js` with helpers + Dashboard + Campaigns** + +Keep `h`, `table`, `fmtTime`, `fmtDur`, `badge`, `btn` from the current file (identical semantics) **except** `btn`, which must always carry the base `btn` class so Task 3's `.btn`/`.btn.ghost`/`.btn.danger` styles apply: + +```js +const btn = (label, onclk, cls) => h('button', { class: 'btn ' + (cls || ''), onclick: onclk, text: label }); +``` + +```js +const tabBar = (box, items, activeHash) => { + const bar = h('div', { class: 'tabbar' }); + items.forEach((it) => { + const t = h('a', { class: 'tab' + (it.hash === activeHash ? ' active' : ''), href: it.hash, text: it.label }); + t.addEventListener('click', (e) => { e.preventDefault(); location.hash = it.hash; }); + bar.appendChild(t); + }); + box.appendChild(bar); + return bar; +}; +``` + +Replace `views.dashboard`: + +```js +views.dashboard = (root) => { + const history = { clients: [], handshakes: [] }; + const max = 60; + + const title = h('h1', { class: 'page-title', text: 'Dashboard' }); + root.appendChild(title); + + const grid = h('div', { class: 'cards' }); + root.appendChild(grid); + const defs = [ + ['clients', 'Clients Connected'], ['handshakes', 'Handshakes Captured'], + ['disk', 'Disk Usage'], ['uptime', 'Uptime'] + ]; + const cards = {}; + defs.forEach(([k, label]) => { + const card = h('div', { class: 'card' }, + h('div', { class: 'card-label', text: label }), + h('div', { class: 'card-value', text: '--' })); + grid.appendChild(card); + cards[k] = card.querySelector('.card-value'); + }); + + const chartBox = h('div', { class: 'section' }, + h('h2', {}, 'Clients'), + h('canvas', { id: 'dash-chart', style: 'width:100%;height:140px' })); + root.appendChild(chartBox); + const canvas = chartBox.querySelector('#dash-chart'); + + const update = (msg) => { + const s = msg.status || {}; + const n = (msg.clients || []).length; + history.clients.push(n); + history.handshakes.push(0); + if (history.clients.length > max) { history.clients.shift(); history.handshakes.shift(); } + cards.clients.textContent = n; + const b = s.battery || {}; + if (s.uptime != null) cards.uptime.textContent = fmtDur(s.uptime); + if (s.disk) cards.disk.textContent = (s.disk.used / 1048576).toFixed(1) + ' / ' + (s.disk.size / 1048576).toFixed(1) + ' GB'; + if (typeof MiniChart !== 'undefined') { + MiniChart.draw(canvas, [ + { label: 'Clients', color: '#1976d2', points: history.clients }, + { label: 'Handshakes', color: '#7cb342', points: history.handshakes } + ]); + } + }; + Live.onTick(update); + PagerAPI.get('/api/status').then((r) => update({ status: r.data, clients: r.data.clients })); + PagerAPI.get('/api/pineap/handshakes').then((r) => { + cards.handshakes.textContent = (r.data.files || []).length; + history.handshakes = new Array(history.clients.length).fill((r.data.files || []).length); + }).catch(() => {}); + + const clBox = h('div', { class: 'section' }, + h('h2', {}, 'Connected Clients'), + h('div', { id: 'dash-clients' })); + root.appendChild(clBox); + const hsBox = h('div', { class: 'section' }, + h('h2', {}, 'Captured WPA Handshakes'), + h('div', { id: 'dash-handshakes' })); + root.appendChild(hsBox); + + function loadClients() { + PagerAPI.get('/api/pineap/clients').then((r) => { + document.getElementById('dash-clients').innerHTML = ''; + document.getElementById('dash-clients').appendChild(table( + [{ label: 'MAC', key: 'mac' }, { label: 'Interface', key: 'iface' }, { label: 'RSSI', key: 'rssi' }, + { label: '', render: () => '' }], + r.data.clients, + (c) => ({ style: 'cursor:pointer', + onclick: () => { if (confirm('Deauthenticate ' + c.mac + '?')) PagerAPI.post('/api/pineap/deauth/client', { mac: c.mac }).then(() => App.toast('Deauthenticated')).then(loadClients); } }))); + const cols = ['MAC', 'Interface', 'RSSI']; + document.getElementById('dash-clients').querySelectorAll('.tbl th').forEach((th, i) => { if (i >= cols.length) th.textContent = 'Deauth'; }); + }).catch(() => {}); + } + function loadHandshakes() { + PagerAPI.get('/api/pineap/handshakes').then((r) => { + document.getElementById('dash-handshakes').innerHTML = ''; + document.getElementById('dash-handshakes').appendChild(table( + [{ label: 'File', key: 'name' }, { label: 'Size', key: 'size' }, { label: 'Modified', key: 'mtime' }], + (r.data.files || []).map((f) => ({ name: f.name, size: f.size, mtime: fmtTime(f.mtime) })))); + }).catch(() => {}); + } + loadClients(); + loadHandshakes(); + const iv = setInterval(loadClients, 10000); + return { destroy: () => clearInterval(iv) }; +}; +``` + +Replace `views.payloads`... no — add `views.campaigns`: + +```js +views.campaigns = (root) => { + root.appendChild(h('h1', { class: 'page-title', text: 'Campaigns' })); + root.appendChild(h('div', { class: 'section empty' }, + 'Campaigns are not supported on the WiFi Pineapple Pager.')); + return { destroy: () => {} }; +}; +``` + +Remove the old `views.payloads`, `views.logs`, `views.settings`, `views.pineap`, `views.recon`, `views.handshakes` definitions from the file (they are re-added in Tasks 7–8). Keep the file loading without errors — every `views.*` referenced by `App.routes` must exist by the end of Task 8; for now, Tasks 7–8 add the rest. + +- [ ] **Step 2: Verify** + +With the dev server running, log in and navigate to `#/dashboard`: the page renders the title, 4 cards, a chart canvas, and the two tables; no console errors. Navigate to `#/campaigns` → placeholder card. + +- [ ] **Step 3: Commit** + +```bash +git add payload/user/general/pager-webui/www/js/views.js +git commit -m "feat: dashboard (cards+chart+tables) and campaigns placeholder" +``` + +--- + +### Task 7: Views — PineAP Suite (5 tabs) + +**Files:** +- Modify: `payload/user/general/pager-webui/www/js/views.js` + +**Interfaces:** +- Consumes: `tabBar`, helpers, `PagerAPI`, `App.toast`, `App.go`. +- Produces: `views.pineap` (wrapper that renders the tab bar and delegates to the active sub-view), `views.pineap_open`, `views.pineap_clients`, `views.pineap_filtering`, `views.pineap_aps`, `views.pineap_impersonation`. + +- [ ] **Step 1: Add the PineAP views** + +```js +const PINEAP_TABS = [ + { label: 'Open', hash: '#/pineap/open' }, + { label: 'Clients', hash: '#/pineap/clients' }, + { label: 'Filtering', hash: '#/pineap/filtering' }, + { label: 'APs', hash: '#/pineap/aps' }, + { label: 'Impersonation', hash: '#/pineap/impersonation' } +]; + +function pineapShell(root, activeHash, inner) { + root.appendChild(h('h1', { class: 'page-title', text: 'PineAP Suite' })); + tabBar(root, PINEAP_TABS, activeHash); + const box = h('div', {}); + root.appendChild(box); + return inner(box); +} + +views.pineap = (root) => { + const hash = location.hash || '#/pineap'; + if (hash === '#/pineap') { location.hash = '#/pineap/open'; return { destroy: () => {} }; } + const view = hash.split('/')[2] || 'open'; + const map = { + open: views.pineap_open, clients: views.pineap_clients, + filtering: views.pineap_filtering, aps: views.pineap_aps, + impersonation: views.pineap_impersonation + }; + if (!map[view]) { location.hash = '#/pineap/open'; return { destroy: () => {} }; } + return pineapShell(root, hash, (box) => map[view](box)); +}; +``` + +`views.pineap_open` = current `views.pineap` settings content (settings toggles + bands + reload) with the old labels: + +```js +views.pineap_open = (root) => { + const settings = {}; + const toggleDefs = [ + ['mimic', 'Mimic'], ['advertise', 'Advertise'], ['collect_probes', 'Collect probes'], + ['collect_handshakes', 'Collect handshakes'], ['random_mac', 'Random MAC'], ['wigle', 'WiGLE'] + ]; + const box = h('div', { class: 'section' }, h('h2', {}, 'PineAP Settings')); + root.appendChild(box); + toggleDefs.forEach(([k, label]) => { + const cb = h('input', { type: 'checkbox', id: 'set-' + k }); + const wrap = h('label', { class: 'toggle' }, cb, ' ' + label); + settings[k] = cb; + cb.addEventListener('change', () => saveSettings()); + box.appendChild(wrap); + }); + const bandsSel = h('select', { id: 'set-bands' }, + h('option', { value: '2.4', text: '2.4 GHz' }), + h('option', { value: '5', text: '5 GHz' }), + h('option', { value: 'dual', text: 'Dual' })); + bandsSel.addEventListener('change', () => saveSettings()); + box.appendChild(h('label', {}, 'Bands', bandsSel)); + box.appendChild(h('div', { class: 'row' }, + h('div', {}, btn('Reload config', () => { PagerAPI.post('/api/pineap/settings', {}).then(loadSettings); }, 'ghost')))); + + function saveSettings() { + const body = {}; + toggleDefs.forEach(([k]) => { body[k] = settings[k].checked; }); + body.bands = bandsSel.value; + PagerAPI.post('/api/pineap/settings', body).then(() => App.toast('Settings saved')); + } + function loadSettings() { + PagerAPI.get('/api/pineap/settings').then((r) => { + const s = r.data.settings || {}; + toggleDefs.forEach(([k]) => { settings[k].checked = !!s[k]; }); + bandsSel.value = s.bands || '2.4'; + }); + } + loadSettings(); + return { destroy: () => {} }; +}; +``` + +`views.pineap_clients` = current connected-clients list (from the old flat PineAP view): + +```js +views.pineap_clients = (root) => { + const box = h('div', { class: 'section' }, h('h2', {}, 'Connected Clients')); + root.appendChild(box); + function render() { + box.innerHTML = ''; + box.appendChild(h('h2', {}, 'Connected Clients')); + box.appendChild(btn('Refresh', load, 'ghost')); + box.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']; + box.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; render(); }); + } + const state = { clients: [] }; + load(); + const iv = setInterval(load, 10000); + return { destroy: () => clearInterval(iv) }; +}; +``` + +`views.pineap_filtering` = current filter sections (Client Filter + SSID Filter) rendered side by side in two `.section`s: + +```js +views.pineap_filtering = (root) => { + const state = { cMode: '', cEntries: [], sMode: '', sEntries: [] }; + const cfBox = h('div', { class: 'section' }, h('h2', {}, 'PineAP Client Filter')); + const sfBox = h('div', { class: 'section' }, h('h2', {}, 'PineAP SSID Filter')); + root.appendChild(cfBox); + root.appendChild(sfBox); + + function renderFilter(box, kind) { + box.innerHTML = ''; + box.appendChild(h('h2', {}, kind === 'client' ? 'PineAP Client Filter' : 'PineAP SSID Filter')); + const list = kind === 'client' ? state.cEntries : state.sEntries; + const mode = kind === 'client' ? state.cMode : state.sMode; + const path = '/api/pineap/filters/' + kind; + const modeSel = h('select', { id: 'mode-' + kind }, + h('option', { value: 'allow', text: 'Allow list' }), + h('option', { value: 'deny', text: 'Deny list' }), + h('option', { value: 'off', text: 'Off' })); + modeSel.value = mode; + modeSel.addEventListener('change', () => { + PagerAPI.post(path, { action: 'set_mode', mode: modeSel.value }).then(() => refresh()); + }); + const valueIn = h('input', { id: 'val-' + 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('val-' + kind).value.trim(); + if (!v) return; + PagerAPI.post(path, { action: 'add', value: v }).then(() => { refresh(); App.toast('Added'); }); + })), + h('div', {}, btn('Clear', () => PagerAPI.post(path, { action: 'clear' }).then(refresh), 'danger')))); + box.appendChild(table( + [{ label: kind === 'client' ? 'MAC' : 'SSID', key: 'value' }], + list.map((e) => ({ value: e })), + (r) => ({ style: 'cursor:pointer', + onclick: () => { if (confirm('Delete ' + r.value + '?')) PagerAPI.post(path, { action: 'delete', value: r.value }).then(refresh); } }))); + } + function refresh() { + PagerAPI.get('/api/pineap/filters/client').then((r) => { state.cMode = r.data.mode; state.cEntries = r.data.entries; renderFilter(cfBox, 'client'); }); + PagerAPI.get('/api/pineap/filters/ssid').then((r) => { state.sMode = r.data.mode; state.sEntries = r.data.entries; renderFilter(sfBox, 'ssid'); }); + } + refresh(); + return { destroy: () => {} }; +}; +``` + +`views.pineap_aps` (uses the Task 1 endpoint): + +```js +views.pineap_aps = (root) => { + const box = h('div', { class: 'section' }, h('h2', {}, 'Access Points')); + root.appendChild(box); + function load() { + box.innerHTML = ''; + box.appendChild(h('h2', {}, 'Access Points')); + box.appendChild(btn('Refresh', load, 'ghost')); + 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 || '--' + })); + box.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) box.appendChild(h('div', { class: 'empty', text: 'No access points found.' })); + }).catch(() => box.appendChild(h('div', { class: 'empty', text: 'Scan failed.' }))); + } + load(); + const iv = setInterval(load, 30000); + return { destroy: () => clearInterval(iv) }; +}; +``` + +`views.pineap_impersonation` = the current SSID pool section: + +```js +views.pineap_impersonation = (root) => { + const state = { ssids: [] }; + const box = h('div', { class: 'section' }, h('h2', {}, 'SSID Impersonation')); + root.appendChild(box); + function render() { + box.innerHTML = ''; + box.appendChild(h('h2', {}, 'SSID Impersonation')); + const row = h('div', { class: 'row' }, + h('div', {}, h('label', {}, 'SSID', h('input', { id: 'pool-ssid' }))), + h('div', {}, btn('Add', () => { + const v = document.getElementById('pool-ssid').value.trim(); + if (!v) return; + PagerAPI.post('/api/pineap/ssids', { action: 'add', ssid: v }).then((r) => { state.ssids = r.data.ssids; render(); App.toast('Added'); }); + })), + h('div', {}, btn('Clear', () => PagerAPI.post('/api/pineap/ssids', { action: 'clear' }).then((r) => { state.ssids = r.data.ssids; render(); }), 'danger'))); + const actions = h('div', { class: 'row', style: 'margin-top:10px' }, + h('div', {}, btn('Start', () => PagerAPI.post('/api/pineap/ssidpool/start'))), + h('div', {}, btn('Stop', () => PagerAPI.post('/api/pineap/ssidpool/stop'))), + h('div', {}, btn('Collect start', () => PagerAPI.post('/api/pineap/ssidpool/collect_start'))), + h('div', {}, btn('Collect stop', () => PagerAPI.post('/api/pineap/ssidpool/collect_stop')))); + box.appendChild(row); + box.appendChild(actions); + box.appendChild(table([{ label: 'SSID', key: 'ssid' }], state.ssids.map((s) => ({ ssid: s })))); + document.querySelectorAll('#pool-ssid').forEach((x) => { x.value = ''; }); + } + function load() { + PagerAPI.get('/api/pineap/ssids').then((r) => { state.ssids = r.data.ssids; render(); }); + } + load(); + return { destroy: () => {} }; +}; +``` + +- [ ] **Step 2: Verify** + +Navigate `#/pineap`, `#/pineap/open`, `#/pineap/clients`, `#/pineap/filtering`, `#/pineap/aps`, `#/pineap/impersonation` — tab bar shows 5 tabs, active tab matches hash, each sub-view renders, APs table shows scan results or "No access points found." + +- [ ] **Step 3: Commit** + +```bash +git add payload/user/general/pager-webui/www/js/views.js +git commit -m "feat: PineAP Suite with Open/Clients/Filtering/APs/Impersonation tabs" +``` + +--- + +### Task 8: Views — Recon, Logging, Modules & Packages, Settings + +**Files:** +- Modify: `payload/user/general/pager-webui/www/js/views.js` + +**Interfaces:** +- Consumes: `tabBar`, helpers, `PagerAPI`, `App.toast`. +- Produces: `views.recon`, `views.recon_handshakes`, `views.logging`, `views.logging_system`, `views.modules`, `views.settings`. + +- [ ] **Step 1: Add Recon views** + +```js +const RECON_TABS = [ + { label: 'Overview', hash: '#/recon' }, + { label: 'Handshakes', hash: '#/recon/handshakes' } +]; +const LOGGING_TABS = [ + { label: 'Overview', hash: '#/logging' }, + { label: 'System', hash: '#/logging/system' } +]; + +views.recon = (root) => { + root.appendChild(h('h1', { class: 'page-title', text: 'Recon' })); + tabBar(root, RECON_TABS, '#/recon'); + const listBox = h('div', { class: 'section' }, h('h2', {}, 'Scans')); + const detailBox = h('div', { class: 'section hidden' }, h('h2', {}, 'Scan Detail')); + root.appendChild(listBox); + root.appendChild(detailBox); + + function loadScans() { + PagerAPI.get('/api/recon/scans').then((r) => { + listBox.innerHTML = ''; + listBox.appendChild(h('h2', {}, 'Scans')); + const row = h('div', { class: 'row' }, + h('div', {}, btn('New scan', () => PagerAPI.post('/api/recon/start').then(() => App.toast('Scan started')))), + h('div', {}, btn('Stop', () => PagerAPI.post('/api/recon/stop').then(() => App.toast('Scan stopped')), 'danger')), + h('div', {}, btn('Refresh', loadScans, 'ghost'))); + listBox.appendChild(row); + listBox.appendChild(table( + [{ label: 'ID', key: 'id' }, { label: 'Timestamp', key: 'timestamp' }], + (r.data.scans || []).map((s) => ({ id: s.id, timestamp: fmtTime(s.timestamp) })), + (s) => ({ style: 'cursor:pointer', onclick: () => loadDetail(s.id) }))); + }); + } + function loadDetail(id) { + PagerAPI.get('/api/recon/scans/' + id).then((r) => { + detailBox.classList.remove('hidden'); + detailBox.innerHTML = ''; + detailBox.appendChild(h('h2', {}, 'Scan #' + id)); + const aps = r.data.aps || [], hs = r.data.handshakes || []; + detailBox.appendChild(h('h3', { style: 'margin:8px 0 4px' }, 'APs (' + aps.length + ')')); + detailBox.appendChild(table( + [{ label: 'BSSID', key: 'bssid' }, { label: 'SSID', key: 'ssid' }], + aps.map((a) => ({ bssid: a.bssid || a.mac || a.essid || a.wifi_device || '--', ssid: a.ssid || '--' })))); + detailBox.appendChild(h('h3', { style: 'margin:8px 0 4px' }, 'Handshakes (' + hs.length + ')')); + detailBox.appendChild(table( + [{ label: 'BSSID', key: 'bssid' }, { label: 'Client', key: 'client' }], + hs.map((x) => ({ bssid: x.bssid || x.ap_mac || '--', client: x.client || x.client_mac || '--' })))); + }); + } + loadScans(); + return { destroy: () => {} }; +}; +``` + +`views.recon_handshakes` = the loot files table (from the old flat `views.handshakes`): + +```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' }, h('h2', {}, 'Handshakes')); + root.appendChild(box); + function load() { + PagerAPI.get('/api/pineap/handshakes').then((r) => { + box.innerHTML = ''; + box.appendChild(h('h2', {}, 'Handshakes')); + const row = 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'))); + box.appendChild(row); + box.appendChild(table( + [{ label: 'File', key: 'name' }, { label: 'Size', key: 'size' }, + { label: 'Modified', key: 'mtime' }, { label: '', render: () => '' }], + (r.data.files || []).map((f) => ({ name: f.name, size: f.size, mtime: fmtTime(f.mtime) })), + (f) => ({ style: 'cursor:pointer', + onclick: () => { if (confirm('Delete ' + f.name + '?')) PagerAPI.del('/api/pineap/handshakes', { name: f.name }).then(load); } }))); + box.querySelectorAll('.tbl th')[3].textContent = 'Delete'; + }); + } + load(); + return { destroy: () => {} }; +}; +``` + +- [ ] **Step 2: Add Logging views** + +```js +views.logging = (root) => { + root.appendChild(h('h1', { class: 'page-title', text: 'Logging' })); + tabBar(root, LOGGING_TABS, '#/logging'); + const sysBox = h('div', { class: 'section' }, h('h2', {}, 'System Log')); + const pineBox = h('div', { class: 'section' }, h('h2', {}, 'PineAP Log')); + root.appendChild(sysBox); + root.appendChild(pineBox); + function render(box, kind) { + PagerAPI.get('/api/logging/' + kind + '?lines=200').then((r) => { + box.innerHTML = ''; + box.appendChild(h('h2', {}, kind === 'system' ? 'System Log' : 'PineAP Log')); + box.appendChild(btn('Refresh', () => render(box, kind), 'ghost')); + box.appendChild(h('pre', { class: 'logs' }, (r.data.lines || []).join('\n'))); + }).catch(() => {}); + } + render(sysBox, 'system'); + render(pineBox, 'pineap'); + const iv = setInterval(() => render(sysBox, 'system'), 10000); + return { destroy: () => clearInterval(iv) }; +}; + +views.logging_system = (root) => { + root.appendChild(h('h1', { class: 'page-title', text: 'Logging' })); + tabBar(root, LOGGING_TABS, '#/logging/system'); + const box = h('div', { class: 'section' }, h('h2', {}, 'System Log')); + root.appendChild(box); + function render() { + PagerAPI.get('/api/logging/system?lines=400').then((r) => { + box.innerHTML = ''; + box.appendChild(h('h2', {}, 'System Log')); + box.appendChild(btn('Refresh', render, 'ghost')); + box.appendChild(h('pre', { class: 'logs' }, (r.data.lines || []).join('\n'))); + }).catch(() => {}); + } + render(); + const iv = setInterval(render, 10000); + return { destroy: () => clearInterval(iv) }; +}; +``` + +- [ ] **Step 3: Add Modules & Packages view** (payload portal, from the old flat `views.payloads`, plus a search filter) + +```js +views.modules = (root) => { + root.appendChild(h('h1', { class: 'page-title', text: 'Modules & Packages' })); + const box = h('div', { class: 'section' }, h('h2', {}, 'Payloads')); + root.appendChild(box); + function load(filter) { + PagerAPI.get('/api/payloads/index').then((r) => { + box.innerHTML = ''; + box.appendChild(h('h2', {}, 'Payloads')); + const search = h('input', { id: 'mod-search', placeholder: 'Search…', style: 'max-width:280px' }); + box.appendChild(search); + box.appendChild(btn('Refresh', () => load(search.value), 'ghost')); + const payloads = (r.data.payloads || r.data || []) + .filter((p) => { + const q = (filter || '').toLowerCase(); + if (!q) return true; + return ((p.name || p.title || p.key || '') + ' ' + (p.desc || '')).toLowerCase().indexOf(q) !== -1; + }); + box.appendChild(table( + [{ label: 'Name', key: 'name' }, { label: 'Description', key: 'desc' }, { label: '', render: () => '' }], + payloads.map((p) => ({ name: p.name || p.title || p.key, desc: p.desc || '', key: p.key || p.id || p.name })), + (p) => ({ onclick: (e) => e.stopPropagation() }))); + const rows = box.querySelectorAll('.tbl tbody tr'); + rows.forEach((tr, i) => { + const p = payloads[i]; + const td = tr.cells[2]; + td.appendChild(btn('Install', () => PagerAPI.post('/api/payloads/install', { key: p.key }).then(() => App.toast('Installing')))); + td.appendChild(btn('Remove', () => PagerAPI.post('/api/payloads/remove', { key: p.key }).then(() => App.toast('Removed')), 'danger')); + }); + search.addEventListener('input', () => load(search.value)); + }); + } + load(''); + return { destroy: () => {} }; +}; +``` + +- [ ] **Step 4: Add Settings view** (from the old flat `views.settings`, with a theme selector added) + +```js +views.settings = (root) => { + root.appendChild(h('h1', { class: 'page-title', text: 'Settings' })); + const general = h('div', { class: 'section' }, h('h2', {}, 'General')); + const ntpBox = h('div', { class: 'section' }, h('h2', {}, 'Time (NTP)')); + const passBox = h('div', { class: 'section' }, h('h2', {}, 'Device Password')); + const webui = h('div', { class: 'section' }, h('h2', {}, 'WebUI Preferences')); + root.appendChild(general); + root.appendChild(ntpBox); + root.appendChild(passBox); + root.appendChild(webui); + + PagerAPI.get('/api/status').then((r) => { + const s = r.data; + general.appendChild(h('p', {}, 'Firmware: ', h('code', { text: s.firmware || '--' }))); + general.appendChild(h('p', {}, 'Model: ', h('code', { text: 'WiFi Pineapple Pager' }))); + general.appendChild(h('p', {}, 'Uptime: ', h('code', { text: fmtDur(s.uptime) }))); + general.appendChild(h('p', {}, 'Disk: ', h('code', { text: s.disk ? (s.disk.used / 1048576).toFixed(1) + ' / ' + (s.disk.size / 1048576).toFixed(1) + ' GB' : '--' }))); + }); + + const hnInput = h('input', { id: 'set-hostname' }); + PagerAPI.get('/api/settings/hostname').then((r) => { hnInput.value = r.data.hostname || ''; }); + general.appendChild(h('div', { class: 'row' }, + h('div', {}, h('label', {}, 'Hostname', hnInput)), + h('div', {}, btn('Save', () => PagerAPI.post('/api/settings/hostname', { hostname: hnInput.value }).then(() => App.toast('Saved')))))); + + PagerAPI.get('/api/settings/service').then((r) => { + general.appendChild(h('p', {}, 'WebUI service: ', + h('span', { class: 'badge ' + (r.data.running ? 'on' : 'off'), text: r.data.running ? 'RUNNING' : 'STOPPED' }), + ' ', h('span', { class: 'muted', text: r.data.background ? '(background mode)' : '(foreground mode)' }))); + }); + + const ntpEnabled = h('input', { type: 'checkbox', id: 'ntp-enabled' }); + const ntpServers = h('input', { id: 'ntp-servers' }); + PagerAPI.get('/api/settings/ntp').then((r) => { + ntpEnabled.checked = !!r.data.enabled; + ntpServers.value = (r.data.servers || []).join(', '); + }); + ntpBox.appendChild(h('label', { class: 'toggle' }, ntpEnabled, ' Enabled')); + ntpBox.appendChild(h('label', {}, 'NTP servers (comma separated)', ntpServers)); + ntpBox.appendChild(btn('Save', () => PagerAPI.post('/api/settings/ntp', { + enabled: ntpEnabled.checked, + servers: ntpServers.value.split(',').map((s) => s.trim()).filter(Boolean) + }).then(() => App.toast('NTP saved')), 'ghost')); + + const newPw = h('input', { type: 'password', id: 'set-password' }); + passBox.appendChild(h('label', {}, 'New device password', newPw)); + passBox.appendChild(btn('Change password', () => { + if (newPw.value.length < 4) { App.toast('Password too short', 'error'); return; } + PagerAPI.post('/api/settings/password', { password: newPw.value }).then(() => { App.toast('Password changed'); newPw.value = ''; }); + }, 'danger')); + + const pollInput = h('input', { type: 'number', id: 'pref-poll', min: '2', max: '120' }); + pollInput.value = localStorage.getItem('pw-poll') || '5'; + pollInput.addEventListener('change', () => { + localStorage.setItem('pw-poll', pollInput.value); + App.toast('Poll interval saved (applies on next load)'); + }); + const themeSel = h('select', { id: 'pref-theme' }, + h('option', { value: 'light', text: 'Light' }), + h('option', { value: 'dark', text: 'Dark' })); + themeSel.value = Theme.current(); + themeSel.addEventListener('change', () => { + localStorage.setItem(Theme.KEY, themeSel.value); + Theme.apply(); + App.toast('Theme updated'); + }); + webui.appendChild(h('label', {}, 'Poll interval (s)', pollInput)); + webui.appendChild(h('label', {}, 'Theme', themeSel)); + + return { destroy: () => {} }; +}; +``` + +- [ ] **Step 5: Remove leftover/duplicate definitions** + +Ensure the file defines exactly these views (no old flat `views.pineap`, `views.recon`, `views.logs`, `views.payloads`, `views.handshakes`, or the old `views.settings` in addition): `dashboard`, `campaigns`, `pineap`, `pineap_open`, `pineap_clients`, `pineap_filtering`, `pineap_aps`, `pineap_impersonation`, `recon`, `recon_handshakes`, `logging`, `logging_system`, `modules`, `settings`. + +Verify with: +```powershell +$v = Get-Content -Raw payload\user\general\pager-webui\www\js\views.js +@('dashboard','campaigns','pineap','pineap_open','pineap_clients','pineap_filtering','pineap_aps','pineap_impersonation','recon','recon_handshakes','logging','logging_system','modules','settings') | ForEach-Object { if ($v -match 'views\.' + $_ + ' ?=') { "$_ OK" } else { "$_ MISSING" } } +``` +Expected: all `OK`. + +- [ ] **Step 6: Verify every route** + +With the dev server running and logged in, visit each hash route and confirm the page renders without console errors: `#/dashboard`, `#/campaigns`, `#/pineap/open`, `#/pineap/clients`, `#/pineap/filtering`, `#/pineap/aps`, `#/pineap/impersonation`, `#/recon`, `#/recon/handshakes`, `#/logging`, `#/logging/system`, `#/modules`, `#/settings`. Test the theme selector toggles dark mode. Test keyboard shortcuts `d`, `p`, `r`, `l`, `m`, and backquote opens the terminal. + +- [ ] **Step 7: Commit** + +```bash +git add payload/user/general/pager-webui/www/js/views.js +git commit -m "feat: Recon, Logging, Modules, Settings views with old IA tabs" +``` + +--- + +### Task 9: Build, deploy, and 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 (incl. new `test_pineap_aps`). + +- [ ] **Step 2: Build and deploy to the Pager** + +```powershell +& .\scripts\deploy.ps1 -SshKey "$HOME\.ssh\pager_key" -Password "" +``` +(If no key, use `-Password ""` and rely on sshpass if installed; otherwise run the printed scp/ssh commands manually.) + +- [ ] **Step 3: Verify the payload serves the new UI** + +```powershell +curl.exe -s -o NUL -w "%{http_code} %{size_download}`n" http://172.16.52.1:8080/ # 200 +curl.exe -s -o NUL -w "%{http_code} %{size_download}`n" http://172.16.52.1:8080/assets/logo.png # 200, ~41k +curl.exe -s -o NUL -w "%{http_code} %{size_download}`n" http://172.16.52.1:8080/js/icons.js # 200 +curl.exe -s -o NUL -w "%{http_code} %{size_download}`n" http://172.16.52.1:8080/js/chart.js # 200 +curl.exe -s -o NUL -w "%{http_code} %{size_download}`n" http://172.16.52.1:8080/css/app.css # 200 +curl.exe -s -o NUL -w "%{http_code}`n" "http://172.16.52.1:8080/api/pineap/aps" # 401 (auth required) +``` +Then log in via the browser and confirm `/api/pineap/aps` returns `{"aps":[...],"count":n}` when authenticated. + +- [ ] **Step 4: On-device smoke pass (background + foreground modes)** + +Per spec v1 §8: run the payload in background mode, browse `http://172.16.52.1:8080/`, and walk every view in §Task 8 Step 6. Re-run in foreground mode (press `B` to stop) and repeat. Confirm: login card (gray bg, logo, password only), toolbar, rail expand/collapse + active indicator, tab bars, dashboard chart, theme toggle, keyboard shortcuts, terminal open/close, and that reboot persistence still holds (re-run payload, service auto-starts). + +- [ ] **Step 5: Final commit of any smoke fixes** + +If any fix was needed during verification, commit it: +```bash +git add -A +git commit -m "fix: UI clone smoke-test fixes" +``` +(Only if changes exist.) + +--- + +## Self-Review Notes (run before handing off) + +- **Spec coverage:** §3.2 tokens → Task 3; §3.3 shell → Tasks 4–5; §3.4 login → Tasks 4–5; §3.5 views → Tasks 6–8; §3.5 APs endpoint → Task 1; §3.6 data flow → Tasks 5–6; §3.7 error handling → retained toast/login-error logic; §4 testing → Task 9. Global Constraints (sidebar order, sub-pages, no Enterprise, password-only login, bottom-docked terminal) all enforced in Tasks 4–8. +- **Type/name consistency:** `h`, `table`, `fmtTime`, `fmtDur`, `btn`, `badge`, `tabBar` are defined once (Task 6) and reused by Tasks 7–8; `MiniChart.draw(canvas, series, opts)` defined in Task 5 and consumed in Task 6; `App.railItems`/`App.keyOf`/`App.go` defined in Task 5; `PineappleIcons` keys (`dashboard`, `campaigns`, `pineap`, `recon`, `logging`, `modules`, `settings`, `chevron`, `wifi`, `extension`, `receipt`) match Task 2. The three Task-2 icons that Task 8 does not use (`wifi`, `extension`, `receipt`) are optional conveniences and may be dropped if unused. +- **Chart.js deviation:** the old UI's Chart.js is inlined in its Angular bundle; no offline copy exists on the device or dev box, so the dashboard chart is a hand-rolled `` renderer (Task 5) delivering the same visual (2-series line chart). This was flagged to the user during design; it replaces the vendored-Chart.js idea from the design discussion. +- **Git:** each task commits independently; the spec (`docs/specs/2026-08-11-pineapple-ui-clone-design.md`) is already committed. diff --git a/docs/specs/2026-08-10-pager-webui-design.md b/docs/specs/2026-08-10-pager-webui-design.md new file mode 100644 index 0000000..a2a4164 --- /dev/null +++ b/docs/specs/2026-08-10-pager-webui-design.md @@ -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 ` or the `AUTH_` 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,/install,/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-.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_` (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/` | 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). diff --git a/docs/specs/2026-08-11-pineapple-ui-clone-design.md b/docs/specs/2026-08-11-pineapple-ui-clone-design.md new file mode 100644 index 0000000..24d3439 --- /dev/null +++ b/docs/specs/2026-08-11-pineapple-ui-clone-design.md @@ -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` `_authToken` + `AUTH_` 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_` 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 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 `` 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 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. diff --git a/docs/superpowers/plans/2026-08-10-pager-webui.md b/docs/superpowers/plans/2026-08-10-pager-webui.md new file mode 100644 index 0000000..69df3d3 --- /dev/null +++ b/docs/superpowers/plans/2026-08-10-pager-webui.md @@ -0,0 +1,4103 @@ +# Pager WebUI 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:** Build a Mark VII-style web management UI that runs on the WiFi Pineapple Pager at `http://172.16.52.1:8080/`, packaged as a Payload-Portal-installable payload with core-parity featureset (Dashboard, PineAP, Recon, Handshakes/Loot, Payloads, Logs, Settings, bottom-docked terminal). + +**Architecture:** A single Python 3.11 stdlib file (`server.py`) serves the static SPA and a JSON API on `0.0.0.0:8080`, talking to the Hak5 daemon (`127.0.0.1:1471`) and device tools (`hak5cmd`, `uci`, `iwinfo`, `sqlite3`) via subprocess/urllib. The frontend is a vanilla-JS hash-routed SPA. A shell installer (`payload.sh` + procd init) mirrors the `nautilus` payload pattern for background/foreground modes. + +**Tech Stack:** Python 3.11 stdlib only (runtime), vanilla JS + xterm.js (bundled, copied from the on-disk `nautilus` payload), PowerShell build/deploy scripts, OpenWRT init/procd. + +## Global Constraints + +- Runtime is **Python 3.11 stdlib only** — no pip installs, no third-party modules in `server.py`. +- Target device: WiFi Pineapple Pager `172.16.52.1`, OpenWRT `Pineapple Pager 24.10.1` (`ramips/mt76x8`), daemon HTTP API on `127.0.0.1:1471`, UI on `0.0.0.0:8080`. +- **No stock files modified, no `opkg` changes, no firmware modification.** Only our own payload directory + our init script under `/etc/init.d/pagerwebui`. +- All state-changing endpoints require auth. Session cookie `AUTH_` (host `172.16.52.1`, `Path=/`, `HttpOnly`, `SameSite=Lax`). Only `/api/login` is unauthenticated. +- All device commands executed via `subprocess` with **argument lists** (never shell string interpolation). +- `recon.db` opened read-only (`mode=ro`). +- Frontend is vanilla JS — no build step, no framework, no package.json. +- Committed SPA API base is same-origin (`/api`); the local dev loop overrides it via a generated `config.js` (see Task 20). +- Local Windows Python for dev/tests: `C:\Users\root\AppData\Local\Programs\Python\Python311\python.exe` (use full path in scripts; the bare `python` may still resolve to the Microsoft Store alias until PATH refresh). +- Workspace: `C:\Users\root\Documents\Pineapple\pager-webui` (currently NOT a git repo — Task 1 runs `git init`; skip that step if the user does not want a repo). +- Payload dir installed to `/root/payloads/user/general/pager-webui/` on the device. +- On-device unknowns from spec §10 (iwinfo/hostapd_cli client source, hak5cmd output shapes, UCI option names, recon.db schema) are resolved during implementation by running the real commands over SSH first, then finalizing parsers. Tasks flag exactly which commands to run. + +--- + +## File Map + +``` +pager-webui\ +├── payload\user\general\pager-webui\ +│ ├── _hak5_manifest.json # Task 19 (template + generated fields) +│ ├── payload.sh # Task 18 (nautilus-pattern installer) +│ ├── pagerwebui.init # Task 18 (procd init) +│ ├── server.py # Tasks 1-10 (backend) +│ └── www\ +│ ├── index.html # Task 11 +│ ├── css\app.css # Task 11 +│ ├── js\ +│ │ ├── config.js # Task 12 (defaults: same-origin) +│ │ ├── api.js # Task 12 +│ │ ├── app.js # Task 12 (router + shell + login) +│ │ ├── views.js # Tasks 13-16 +│ │ ├── terminal.js # Task 17 +│ │ ├── xterm.min.js # Task 11 (copied from nautilus) +│ │ ├── xterm-addon-fit.min.js # Task 11 (copied) +│ │ └── xterm.css # Task 11 (copied) +│ └── assets\ # Task 11 (logo/svg) +├── scripts\ +│ ├── deploy.ps1 # Task 19 +│ ├── dev.ps1 # Task 20 +│ └── dev_proxy.py # Task 20 (stdlib HTTP+API proxy for dev) +├── tests\ # Tasks 1-10 (Python unittest) +└── README.md # Task 20 +``` + +--- + +## API Contract (backend ↔ frontend) + +Handlers return `(status, jsonable)` or `(status, Download)`. Shapes used by the frontend: + +| Route | Method | Request | Response | +|---|---|---|---| +| `/api/login` | POST | `{username, password}` | `{token, serverid}` + `Set-Cookie: AUTH_=` | +| `/api/api_ping` | GET | – | `{serverid, version}` | +| `/api/status` | GET | – | `{battery:{level,charging}, firmware, daemon:{version}, wifi:[{iface,mode,ssid,channel,quality}], clients:[{mac,iface,rssi}], disk:{size,used,avail}, uptime, hostname}` | +| `/api/device` | GET | – | `{hostname, macs:[], model}` | +| `/api/pineap/settings` | GET/POST | settings object | `{settings:{...}, raw:{...}}` | +| `/api/pineap/ssids` | GET/POST | `{action:'add'\|'remove'\|'clear', ssid?}` | `{ssids:[]}` | +| `/api/pineap/ssidpool/{start,stop,collect_start,collect_stop}` | POST | – | `{ok:true}` | +| `/api/pineap/filters/client` | GET/POST | `{action:'set_mode'\|'add'\|'delete'\|'clear', mode?, value?}` | `{mode, entries:[]}` | +| `/api/pineap/filters/ssid` | GET/POST | same as client | `{mode, entries:[]}` | +| `/api/pineap/clients` | GET | – | `{clients:[{mac,iface,rssi}], count}` | +| `/api/pineap/clients/kick` | POST | `{mac}` | `{ok:true}` | +| `/api/pineap/deauth/client` | POST | `{mac}` | `{ok:true}` | +| `/api/recon/start`, `/api/recon/stop` | POST | – | `{ok:true}` | +| `/api/recon/scans` | GET | – | `{scans:[{id,timestamp}]}` | +| `/api/recon/scans/` | GET | – | `{scan:{...}, aps:[], clients:[], handshakes:[]}` | +| `/api/pineap/handshakes` | GET/DELETE | DELETE `{name}` or `?name=` | `{files:[{name,size,mtime}]}` | +| `/api/loot/zip` | GET | – | `application/zip` download (proxy daemon w/ cookie) | +| `/api/loot/archive` | POST | – | proxied daemon response | +| `/api/payloads/index` | GET | – | proxied daemon portal index | +| `/api/payloads/refresh` | POST | – | `{ok:true}` | +| `/api/payloads/install` | POST | `{key}` | `{ok:true}` | +| `/api/payloads/remove` | POST | `{key}` | `{ok:true}` | +| `/api/logging/system?lines=200` | GET | – | `{lines:[...]}` | +| `/api/logging/pineap?lines=200` | GET | – | `{lines:[...]}` | +| `/api/settings/hostname` | GET/POST | `{hostname}` | `{hostname}` | +| `/api/settings/password` | POST | `{password}` | `{ok:true}` | +| `/api/settings/ntp` | GET/POST | `{enabled, servers:[]}` | `{enabled, servers:[]}` | +| `/api/settings/service` | GET | – | `{background:bool, running:bool}` | +| `/api/ws` | WS | – | `{"type":"tick","status":...,"clients":...}` every ~2s | +| `/api/terminal/openWs` | WS | – | relay fallback to daemon (primary = direct client→daemon WS) | + +Static: `/` → `www/index.html`; any `www/` file served when it exists and no route matches. + +--- + +### Task 1: Scaffold repo, test runner, and server core (Router + static + ctx + Download) + +**Files:** +- Create: `tests/__init__.py` +- Create: `tests/test_core.py` +- Create: `payload/user/general/pager-webui/server.py` (core only) + +**Interfaces:** +- Produces: `server.ROUTER` (instance of `server.Router`), `server.PagerHandler`, `server._Ctx`, `server.Download`, `server.device_run(args)`, `server.daemon_call(method, path, body, token)`, `server.WWW_DIR`. Later tasks register routes via `ROUTER.add('GET', r'/api/status', h_status)` and define handler functions `def h_x(ctx) -> (status, payload)`. +- `_Ctx` exposes: `.body` (dict from JSON body), `.cookie` (str), `.query` (dict), `.args` (regex groups tuple), `.h` (the BaseHTTPRequestHandler). Fake ctx objects in tests mirror this. + +- [ ] **Step 1: Write the failing tests** + +`tests/test_core.py`: + +```python +import os +import sys +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'general', 'pager-webui')) +import server + + +class FakeHandler: + def __init__(self, path='/', headers=None, body=None): + self.path = path + self.headers = headers or {} + self._body = body + + def read_body(self): + return self._body if self._body is not None else {} + + +class RouterTest(unittest.TestCase): + def test_dispatch_matches_and_captures(self): + server.ROUTER = server.Router() + server.ROUTER.add('GET', r'/api/recon/scans/(\d+)', lambda ctx: (200, {'id': ctx.args[0]})) + handler, groups = server.ROUTER.dispatch('GET', '/api/recon/scans/42') + self.assertIsNotNone(handler) + self.assertEqual(groups, ('42',)) + + def test_dispatch_returns_none_on_mismatch(self): + server.ROUTER = server.Router() + handler, groups = server.ROUTER.dispatch('POST', '/nope') + self.assertIsNone(handler) + + +class StaticTest(unittest.TestCase): + def test_safe_join_rejects_traversal(self): + self.assertFalse(server._safe_join(server.WWW_DIR, '../server.py')) + + def test_safe_join_accepts_subpath(self): + p = server._safe_join(server.WWW_DIR, 'js/app.js') + self.assertTrue(p.startswith(server.WWW_DIR)) + + +class DeviceRunTest(unittest.TestCase): + def test_device_run_returns_triple(self): + rc, out, err = server.device_run(['cmd', 'not', 'there']) + self.assertIsInstance(rc, int) + + +if __name__ == '__main__': + unittest.main() +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `& "C:\Users\root\AppData\Local\Programs\Python\Python311\python.exe" -m unittest tests.test_core -v` +Expected: FAIL — `ModuleNotFoundError: No module named 'server'`. + +- [ ] **Step 3: Implement the core** + +`payload/user/general/pager-webui/server.py` (this is the full file for Task 1; later tasks add to it): + +```python +#!/usr/bin/env python3 +"""Pager WebUI backend. Python 3.11 stdlib only.""" +import base64 +import hashlib +import json +import os +import re +import sqlite3 +import struct +import subprocess +import sys +import threading +import time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen +import urllib.parse + +DAEMON_BASE = os.environ.get('PAGER_DAEMON', 'http://127.0.0.1:1471') +RECON_DB = os.environ.get('PAGER_RECON_DB', '/root/recon/recon.db') +LOOT_HS_DIR = os.environ.get('PAGER_LOOT_HS', '/root/loot/handshakes') +HAK5CMD = os.environ.get('PAGER_HAK5CMD', '/usr/bin/hak5cmd') +SESSION_FILE = os.environ.get('PAGER_SESSION_FILE', '/tmp/pagerwebui.session') +WWW_DIR = os.environ.get('PAGER_WWW_DIR', + os.path.join(os.path.dirname(os.path.abspath(__file__)), 'www')) +HOST = os.environ.get('PAGER_HOST', '0.0.0.0') +PORT = int(os.environ.get('PAGER_PORT', '8080')) + + +def device_run(args, timeout=20): + try: + p = subprocess.run(args, capture_output=True, timeout=timeout) + return p.returncode, p.stdout.decode('utf-8', 'replace'), p.stderr.decode('utf-8', 'replace') + except FileNotFoundError: + return 127, '', 'not found' + except subprocess.TimeoutExpired: + return 124, '', 'timeout' + + +def daemon_call(method, path, body=None, token=None, timeout=15): + url = DAEMON_BASE + path + data = json.dumps(body).encode() if body is not None else None + headers = {'Content-Type': 'application/json'} + if token: + headers['Authorization'] = 'Bearer ' + token + req = Request(url, data=data, headers=headers, method=method) + try: + with urlopen(req, timeout=timeout) as r: + raw = r.read() + ct = r.headers.get('Content-Type', '') + if 'json' in ct: + return r.status, json.loads(raw.decode('utf-8', 'replace')) + return r.status, raw + except HTTPError as e: + raw = e.read() + try: + return e.code, json.loads(raw.decode('utf-8', 'replace')) + except Exception: + return e.code, raw + except URLError: + return 0, None + + +class Router: + def __init__(self): + self.routes = [] + + def add(self, method, pattern, handler): + self.routes.append((method, re.compile('^' + pattern + '$'), handler)) + + def dispatch(self, method, path): + for m, rx, h in self.routes: + if m == method: + mm = rx.match(path) + if mm: + return h, mm.groups() + return None, None + + +ROUTER = Router() + + +def _safe_join(base, rel): + base = os.path.abspath(base) + full = os.path.abspath(os.path.join(base, rel)) + if full == base or not full.startswith(base + os.sep): + return None + return full + + +class Download: + def __init__(self, data, ctype, filename=None): + self.data = data + self.ctype = ctype + self.filename = filename + + def send(self, handler, status=200): + handler.send_response(status) + handler.send_header('Content-Type', self.ctype) + if self.filename: + handler.send_header('Content-Disposition', 'attachment; filename="%s"' % self.filename) + handler.send_header('Content-Length', str(len(self.data))) + handler.send_header('Cache-Control', 'no-cache') + for name, value in getattr(handler, 'extra_headers', []): + handler.send_header(name, value) + handler.end_headers() + handler.wfile.write(self.data) + + +class _Ctx: + def __init__(self, handler, groups): + self.h = handler + self.args = groups + self.query = dict(urllib.parse.parse_qsl(handler.path.split('?', 1)[1])) if '?' in handler.path else {} + + @property + def cookie(self): + return self.h.headers.get('Cookie', '') or '' + + @property + def body(self): + length = int(self.h.headers.get('Content-Length') or 0) + if length == 0: + return {} + try: + return json.loads(self.h.rfile.read(length).decode('utf-8')) + except Exception: + return {} + + +class PagerHandler(BaseHTTPRequestHandler): + server_version = 'PagerWebUI/0.1' + protocol_version = 'HTTP/1.1' + + def log_message(self, fmt, *args): + pass + + def add_extra_header(self, name, value): + if not hasattr(self, 'extra_headers'): + self.extra_headers = [] + self.extra_headers.append((name, value)) + + def _json(self, obj, status=200): + body = json.dumps(obj).encode() + self.send_response(status) + self.send_header('Content-Type', 'application/json') + self.send_header('Content-Length', str(len(body))) + self.send_header('Cache-Control', 'no-cache') + for name, value in getattr(self, 'extra_headers', []): + self.send_header(name, value) + self.end_headers() + self.wfile.write(body) + + def _fail(self, status, message): + self._json({'error': message}, status) + + def _serve_static(self, path): + rel = path.lstrip('/') + if not rel: + rel = 'index.html' + rel = rel.replace('\\', '/') + if '..' in rel.split('/'): + return False + full = _safe_join(WWW_DIR, rel) + if not full or not os.path.isfile(full): + return False + ctype = { + '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', + '.png': 'image/png', '.svg': 'image/svg+xml', '.json': 'application/json', + '.map': 'application/json', '.woff2': 'font/woff2', + }.get(os.path.splitext(full)[1], 'application/octet-stream') + with open(full, 'rb') as f: + data = f.read() + self.send_response(200) + self.send_header('Content-Type', ctype) + self.send_header('Content-Length', str(len(data))) + self.send_header('Cache-Control', 'no-cache') + self.end_headers() + self.wfile.write(data) + return True + + def _route(self, method): + path = self.path.split('?', 1)[0] + if self.headers.get('Upgrade', '').lower() == 'websocket': + self._ws_accept() + return + handler, groups = ROUTER.dispatch(method, path) + if handler is None: + if method == 'GET' and self._serve_static(path): + return + self._fail(404, 'not found') + return + if method != 'POST' or path != '/api/login': + if not check_auth(self.headers.get('Cookie', '') or ''): + self._fail(401, 'unauthorized') + return + ctx = _Ctx(self, groups) + try: + result = handler(ctx) + except Exception as e: + self._fail(500, str(e)) + return + if result is None: + return + status, payload = result + if isinstance(payload, Download): + payload.send(self, status) + else: + self._json(payload, status) + + def _ws_accept(self): + self._fail(501, 'websocket not yet implemented') + + def do_GET(self): + self._route('GET') + + def do_POST(self): + self._route('POST') + + def do_DELETE(self): + self._route('DELETE') + + def do_OPTIONS(self): + self.send_response(204) + self.send_header('Allow', 'GET, POST, DELETE, OPTIONS') + self.end_headers() + + +def check_auth(cookie_header): + return False # Task 2 + + +def serve(): + srv = ThreadingHTTPServer((HOST, PORT), PagerHandler) + srv.serve_forever() + + +if __name__ == '__main__': + serve() +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `& "C:\Users\root\AppData\Local\Programs\Python\Python311\python.exe" -m unittest tests.test_core -v` +Expected: PASS (3 tests). Then confirm every module runs green. **Run each module in its own process** — the tests monkeypatch module-level helpers (`device_run`, `hak5`, `daemon_call`, config paths) and do not restore them, so one `discover` process leaks state between files: + +```powershell +$py = "C:\Users\root\AppData\Local\Programs\Python\Python311\python.exe" +Get-ChildItem tests\test_*.py | ForEach-Object { + $mod = "tests." + [IO.Path]::GetFileNameWithoutExtension($_.Name) + & $py -m unittest $mod -v +} +``` + +Expected: every module ends `OK`. + +- [ ] **Step 5: Initialize git and commit** + +```bash +cd C:\Users\root\Documents\Pineapple\pager-webui +git init +git add docs tests payload/user/general/pager-webui/server.py +git commit -m "feat: scaffold pager-webui with server core (router, static, ctx)" +``` + +Also add a `.gitignore` with `build/`, `__pycache__/`, `*.pyc` before the first commit. + +--- + +### Task 2: Session store, auth gating, `/api/login`, `/api/api_ping` + +**Files:** +- Modify: `payload/user/general/pager-webui/server.py` +- Create: `tests/test_auth.py` + +**Interfaces:** +- Consumes: `server.SESSION_FILE`, `server.daemon_call`, `server.ROUTER`, `server.check_auth`. +- Produces: `server.load_session()`, `server.save_session(dict)`, `server.current_token()`, `server.check_auth(cookie)`, handlers `h_login(ctx)`, `h_api_ping(ctx)`. + +- [ ] **Step 1: Write the failing tests** + +`tests/test_auth.py`: + +```python +import json +import os +import sys +import tempfile +import unittest +from unittest import mock + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'general', 'pager-webui')) +import server + + +class SessionTest(unittest.TestCase): + def setUp(self): + fd, self.path = tempfile.mkstemp() + os.close(fd) + server.SESSION_FILE = self.path + + def tearDown(self): + try: + os.unlink(self.path) + except OSError: + pass + + def test_save_and_load_roundtrip(self): + server.save_session({'serverid': '001337AEE050', 'token': 'abc123'}) + self.assertEqual(server.load_session(), {'serverid': '001337AEE050', 'token': 'abc123'}) + + def test_load_missing_file_returns_empty(self): + os.unlink(self.path) + self.assertEqual(server.load_session(), {}) + + def test_check_auth_matches_cookie(self): + server.save_session({'serverid': '001337AEE050', 'token': 'abc123'}) + self.assertTrue(server.check_auth('foo=bar; AUTH_001337AEE050=abc123')) + self.assertFalse(server.check_auth('AUTH_001337AEE050=wrong')) + self.assertFalse(server.check_auth('')) + + def test_current_token(self): + server.save_session({'serverid': 'S', 'token': 'tok'}) + self.assertEqual(server.current_token(), 'tok') + + +class LoginTest(unittest.TestCase): + def test_login_success_sets_cookie_payload(self): + def fake_daemon(method, path, body=None, token=None, timeout=15): + if path == '/api/login': + return 200, {'token': 'tok123'} + if path == '/api/api_ping': + return 200, {'serverid': '001337AEE050', 'version': '1.0'} + return 404, None + server.daemon_call = fake_daemon + server.SESSION_FILE = os.path.join(tempfile.gettempdir(), 'pagerwebui_test_session.json') + if os.path.exists(server.SESSION_FILE): + os.unlink(server.SESSION_FILE) + class H: + def __init__(self): + self.headers = [] + def add_extra_header(self, name, value): + self.headers.append((name, value)) + class Ctx: + body = {'username': 'root', 'password': 'pw'} + cookie = '' + h = H() + ctx = Ctx() + status, payload = server.h_login(ctx) + self.assertEqual(status, 200) + self.assertEqual(payload['token'], 'tok123') + self.assertEqual(payload['serverid'], '001337AEE050') + self.assertEqual(server.load_session()['token'], 'tok123') + self.assertTrue(any(n == 'Set-Cookie' and v.startswith('AUTH_001337AEE050=tok123') for n, v in ctx.h.headers)) + + def test_login_failure_returns_401(self): + def fake_daemon(method, path, body=None, token=None, timeout=15): + return 401, {'error': 'bad'} + server.daemon_call = fake_daemon + class Ctx: + body = {'username': 'root', 'password': 'nope'} + cookie = '' + status, payload = server.h_login(Ctx()) + self.assertEqual(status, 401) + + def test_api_ping(self): + server.daemon_call = lambda *a, **k: (200, {'serverid': 'S1', 'version': '9'}) + server.SESSION_FILE = os.path.join(tempfile.gettempdir(), 'pagerwebui_test_session.json') + status, payload = server.h_api_ping(type('C', (), {'cookie': '', 'args': ()})()) + self.assertEqual(status, 200) + self.assertEqual(payload['serverid'], 'S1') + + +if __name__ == '__main__': + unittest.main() +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `& "C:\Users\root\AppData\Local\Programs\Python\Python311\python.exe" -m unittest tests.test_auth -v` +Expected: FAIL — `AttributeError: module 'server' has no attribute 'load_session'`. + +- [ ] **Step 3: Implement** + +Add to `server.py` (replace the stub `check_auth` and register routes at the bottom): + +```python +def load_session(): + try: + with open(SESSION_FILE) as f: + return json.load(f) + except Exception: + return {} + + +def save_session(session): + tmp = SESSION_FILE + '.tmp' + with open(tmp, 'w') as f: + json.dump(session, f) + os.replace(tmp, SESSION_FILE) + + +def current_token(): + return load_session().get('token', '') + + +def current_serverid(): + return load_session().get('serverid', '') + + +def check_auth(cookie_header): + if not cookie_header: + return False + session = load_session() + expected = 'AUTH_%s=%s' % (session.get('serverid', ''), session.get('token', '')) + return expected in cookie_header + + +def h_login(ctx): + username = (ctx.body or {}).get('username', '') + password = (ctx.body or {}).get('password', '') + status, data = daemon_call('POST', '/api/login', body={'username': username, 'password': password}) + if status != 200 or not isinstance(data, dict) or 'token' not in data: + return 401, {'error': 'login failed'} + token = data['token'] + pstatus, ping = daemon_call('GET', '/api/api_ping', token=token) + serverid = ping.get('serverid', '') if isinstance(ping, dict) else '' + save_session({'serverid': serverid, 'token': token, 'created': int(time.time())}) + ctx.h.add_extra_header('Set-Cookie', 'AUTH_%s=%s; Path=/; HttpOnly; SameSite=Lax' % (serverid, token)) + return 200, {'token': token, 'serverid': serverid} + + +def h_api_ping(ctx): + token = current_token() + status, data = daemon_call('GET', '/api/api_ping', token=token) + if status != 200 or not isinstance(data, dict): + return 502, {'error': 'daemon unreachable'} + return 200, data +``` + +Register routes (append before `def serve()`): + +```python +ROUTER.add('POST', r'/api/login', h_login) +ROUTER.add('GET', r'/api/api_ping', h_api_ping) +``` + +Also update `PagerHandler._route`'s auth gate so `/api/api_ping` requires auth too (it already does — only `/api/login` is exempt; this is correct per spec). + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `& "C:\Users\root\AppData\Local\Programs\Python\Python311\python.exe" -m unittest tests.test_auth -v` +Expected: PASS (all auth + core tests). Confirm login is exempt from auth by checking `_route`: POST to `/api/login` skips the gate. Run the per-module loop from Task 1 to confirm nothing else regressed. + +- [ ] **Step 5: Commit** + +```bash +git add tests payload/user/general/pager-webui/server.py +git commit -m "feat: session auth, login and api_ping endpoints" +``` + +--- + +### Task 3: `/api/status` and `/api/device` + +**Files:** +- Modify: `payload/user/general/pager-webui/server.py` +- Create: `tests/test_status.py` + +**Interfaces:** +- Consumes: `server.device_run`, `server.current_token`, `server.daemon_call`. +- Produces: `server.battery_data()`, `server.wifi_ifaces()`, `server.wifi_iface_info(name)`, `server.assoc_clients()`, `server.disk_data()`, `server.uptime_data()`, `server.firmware_data()`, `server.daemon_status()`, `server.h_status(ctx)`, `server.h_device(ctx)`. +- `h_status` returns `(200, {...})` per the API contract. + +- [ ] **Step 1: Inspect device command output (record for parser constants)** + +Over SSH: `iwinfo`, `iwinfo wlan0 info`, `iwinfo wlan0 assoclist`, `cat /sys/class/power_supply/*/uevent`, `df -k /root`, `cat /proc/uptime`, `cat /etc/openwrt_release`. Note the exact field names so the regexes below match (adjust if the device differs). + +- [ ] **Step 2: Write the failing tests** + +`tests/test_status.py`: + +```python +import os +import sys +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'general', 'pager-webui')) +import server + + +def patch_device(fn): + def wrapper(self): + server.device_run = fn + self.addCleanup(lambda: setattr(server, 'device_run', server.device_run)) + return wrapper + + +class StatusTest(unittest.TestCase): + def test_battery_from_capacity_file(self): + import tempfile + base = tempfile.mkdtemp() + os.makedirs(os.path.join(base, 'battery')) + with open(os.path.join(base, 'battery', 'type'), 'w') as f: + f.write('Battery') + with open(os.path.join(base, 'battery', 'capacity'), 'w') as f: + f.write('87') + with open(os.path.join(base, 'battery', 'status'), 'w') as f: + f.write('Charging') + self.assertEqual(server.battery_data(base), {'level': 87, 'charging': True}) + + def test_battery_missing_returns_none(self): + import tempfile + self.assertEqual(server.battery_data(tempfile.mkdtemp()), {'level': None, 'charging': False}) + + def test_uptime_parses(self): + server.device_run = lambda args, timeout=20: (0, '1234.56 4321.10\n', '') + self.assertEqual(server.uptime_data(), 1234) + + def test_disk_parses_df(self): + server.device_run = lambda args, timeout=20: (0, 'Filesystem 1K-blocks Used Available Use% Mounted on\n/dev/mmcblk0p3 8388608 1024000 7364608 13% /root\n', '') + d = server.disk_data() + self.assertEqual(d['avail'], 7364608 * 1024) + + def test_wifi_ifaces_extracts_names(self): + server.device_run = lambda args, timeout=20: (0, 'wlan0 ESSID: "Pineapple"\nwlan1 ESSID: "Pineapple"\n', '') if args == ['iwinfo'] else (0, '', '') + self.assertEqual(server.wifi_ifaces(), ['wlan0', 'wlan1']) + + def test_assoc_clients_parses(self): + def fake(args, timeout=20): + if args == ['iwinfo']: + return 0, 'wlan0 ESSID: "Pineapple"\n', '' + if args == ['iwinfo', 'wlan0', 'assoclist']: + return 0, '00:11:22:33:44:55 -64 dBm Signal: -64 dBm Rate: 12 Mbit/s\nAA:BB:CC:DD:EE:FF -40 dBm Signal: -40 dBm Rate: 24 Mbit/s\n', '' + return 0, '', '' + server.device_run = fake + clients = server.assoc_clients() + self.assertEqual(len(clients), 2) + self.assertEqual(clients[0]['mac'], '00:11:22:33:44:55') + self.assertEqual(clients[0]['rssi'], -64) + self.assertEqual(clients[0]['iface'], 'wlan0') + + def test_h_status_shape(self): + server.device_run = lambda args, timeout=20: (0, '', '') + server.current_token = lambda: 'tok' + server.daemon_call = lambda m, p, body=None, token=None, timeout=15: (200, {'version': '1'}) + class Ctx: + cookie = 'AUTH_S=tok' + args = () + status, payload = server.h_status(Ctx()) + self.assertEqual(status, 200) + for k in ('battery', 'firmware', 'daemon', 'wifi', 'clients', 'disk', 'uptime', 'hostname'): + self.assertIn(k, payload) + + +class DeviceTest(unittest.TestCase): + def test_h_device(self): + server.device_run = lambda args, timeout=20: (0, 'pager\n', '') + class Ctx: + args = () + status, payload = server.h_device(Ctx()) + self.assertEqual(status, 200) + self.assertEqual(payload['hostname'], 'pager') + self.assertIn('macs', payload) + + +if __name__ == '__main__': + unittest.main() +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `& "C:\Users\root\AppData\Local\Programs\Python\Python311\python.exe" -m unittest tests.test_status -v` +Expected: FAIL — `AttributeError: module 'server' has no attribute 'battery_data'`. + +- [ ] **Step 4: Implement** + +Add to `server.py`: + +```python +def battery_data(power_supply='/sys/class/power_supply'): + try: + for name in sorted(os.listdir(power_supply)): + try: + typ = open(os.path.join(power_supply, name, 'type')).read().strip() + except OSError: + continue + if typ != 'Battery': + continue + level = None + cap = os.path.join(power_supply, name, 'capacity') + if os.path.exists(cap): + try: + level = int(open(cap).read().strip()) + except ValueError: + level = None + charging = False + st = os.path.join(power_supply, name, 'status') + if os.path.exists(st): + try: + charging = 'Charg' in open(st).read() + except OSError: + charging = False + return {'level': level, 'charging': charging} + except OSError: + pass + return {'level': None, 'charging': False} + + +def wifi_ifaces(): + rc, out, err = device_run(['iwinfo']) + names = [] + for line in out.splitlines(): + m = re.match(r'^(\S+)\s+', line) + if m and (m.group(1).startswith('wlan') or m.group(1).startswith('radio')): + names.append(m.group(1)) + return names + + +def wifi_iface_info(name): + rc, out, err = device_run(['iwinfo', name, 'info']) + info = {'iface': name} + for line in out.splitlines(): + m = re.search(r'ESSID:\s*"([^"]*)"', line) + if m: + info['ssid'] = m.group(1) + m = re.search(r'Mode:\s*(\S+)', line) + if m: + info['mode'] = m.group(1) + m = re.search(r'Channel:\s*(\d+)', line) + if m: + info['channel'] = int(m.group(1)) + m = re.search(r'Link Quality:\s*(\d+)/(\d+)', line) + if m: + info['quality'] = {'signal': int(m.group(1)), 'max': int(m.group(2))} + return info + + +def assoc_clients(): + clients = [] + for name in wifi_ifaces(): + rc, out, err = device_run(['iwinfo', name, 'assoclist']) + for line in out.splitlines(): + m = re.match(r'\s*([0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2})\s+', line) + if not m: + continue + mac = m.group(1).upper() + rssi = None + rm = re.search(r'Signal:\s*(-?\d+)', line) + if rm: + rssi = int(rm.group(1)) + clients.append({'mac': mac, 'iface': name, 'rssi': rssi}) + return clients + + +def disk_data(): + rc, out, err = device_run(['df', '-k', '/root']) + lines = out.splitlines() + if len(lines) >= 2: + parts = lines[1].split() + if len(parts) >= 4: + try: + size = int(parts[1]); used = int(parts[2]); avail = int(parts[3]) + return {'size': size * 1024, 'used': used * 1024, 'avail': avail * 1024} + except ValueError: + pass + return {} + + +def uptime_data(): + rc, out, err = device_run(['cat', '/proc/uptime']) + try: + return int(float(out.split()[0])) + except Exception: + return None + + +def firmware_data(): + rc, out, err = device_run(['cat', '/etc/openwrt_release']) + dist = None + for line in out.splitlines(): + if line.startswith('DISTRIB_DESCRIPTION'): + dist = line.split('=', 1)[1].strip().strip('"') + return dist + + +def daemon_status(): + status, data = daemon_call('GET', '/api/api_ping', token=current_token()) + if status == 200 and isinstance(data, dict): + return data + return {} + + +def hostname_data(): + rc, out, err = device_run(['uci', 'get', 'system.@system[0].hostname']) + return out.strip() or None + + +def status_data(): + return { + 'battery': battery_data(), + 'firmware': firmware_data(), + 'daemon': daemon_status(), + 'wifi': [wifi_iface_info(n) for n in wifi_ifaces()], + 'clients': assoc_clients(), + 'disk': disk_data(), + 'uptime': uptime_data(), + 'hostname': hostname_data(), + } + + +def h_status(ctx): + return 200, status_data() + + +def h_device(ctx): + rc, out, err = device_run(['ip', 'link']) + macs = re.findall(r'link/ether ([0-9a-f:]{17})', out.lower()) + return 200, {'hostname': hostname_data(), 'macs': sorted(set(macs)), 'model': 'WiFi Pineapple Pager'} +``` + +Register routes: + +```python +ROUTER.add('GET', r'/api/status', h_status) +ROUTER.add('GET', r'/api/device', h_device) +``` + +- [ ] **Step 4b: Run tests to verify they pass** + +Run: `& "C:\Users\root\AppData\Local\Programs\Python\Python311\python.exe" -m unittest tests.test_status -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add tests payload/user/general/pager-webui/server.py +git commit -m "feat: status and device endpoints" +``` + +--- + +### Task 4: UCI helpers + `/api/pineap/settings` + +**Files:** +- Modify: `payload/user/general/pager-webui/server.py` +- Create: `tests/test_pineap_settings.py` + +**Interfaces:** +- Consumes: `server.device_run`, `server.ROUTER`. +- Produces: `server.uci_show(section)`, `server.uci_set(option, value)`, `server.pineap_settings_data()`, `server.h_pineap_settings_get(ctx)`, `server.h_pineap_settings_post(ctx)`, module constant `server.SETTING_MAP`. + +- [ ] **Step 1: Inspect device UCI layout** + +Over SSH run `uci show pineapd` and `uci show pineapd | head -60`. Record which options control: mimic, advertise/probe collection, handshake collection, random MAC, wigle/wpa-secure?, bands. Adjust `SETTING_MAP` below to the real section/option names (e.g. `pineapd.pineapd.mimic`). The generic reader falls back to `raw` passthrough so the frontend still works with unknown names. + +- [ ] **Step 2: Write the failing tests** + +`tests/test_pineap_settings.py`: + +```python +import os +import sys +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'general', 'pager-webui')) +import server + + +class UciHelpersTest(unittest.TestCase): + def test_uci_show_parses_lines(self): + server.device_run = lambda args, timeout=20: (0, "pineapd.pineapd.mimic='1'\npineapd.pineapd.advertise='0'\n", '') + out = server.uci_show('pineapd') + self.assertIn('pineapd.pineapd.mimic', out) + + def test_uci_set_uses_arglist(self): + calls = [] + def fake(args, timeout=20): + calls.append(args) + return 0, '', '' + server.device_run = fake + server.uci_set('pineapd.pineapd.mimic', '1') + self.assertIn(['uci', 'set', 'pineapd.pineapd.mimic=1'], calls) + self.assertIn(['uci', 'commit'], calls) + + +class PineapSettingsTest(unittest.TestCase): + UCI_OUT = ( + "pineapd.pineapd.mimic='1'\n" + "pineapd.pineapd.advertise='0'\n" + "pineapd.pineapd.collect_handshakes='1'\n" + "pineapd.pineapd.bands='2.4'\n" + ) + + def test_get_settings_maps_bools(self): + server.device_run = lambda args, timeout=20: (0, self.UCI_OUT, '') + status, payload = server.h_pineap_settings_get(type('C', (), {'args': ()})()) + self.assertEqual(status, 200) + s = payload['settings'] + self.assertTrue(s['mimic']) + self.assertFalse(s['advertise']) + self.assertEqual(s['bands'], '2.4') + + def test_post_settings_sets_and_reloads(self): + calls = [] + def fake(args, timeout=20): + calls.append(args) + return 0, '', '' + server.device_run = fake + server.h_pineap_settings_post(type('C', (), {'args': (), 'body': {'mimic': True, 'bands': 'dual'}})()) + sets = [a for a in calls if a[1] == 'set'] + self.assertTrue(any(a[2] == 'pineapd.pineapd.mimic=1' for a in sets)) + self.assertTrue(any(a[2] == 'pineapd.pineapd.bands=dual' for a in sets)) + self.assertIn(['/etc/init.d/pineapd', 'reload'], calls) + + +if __name__ == '__main__': + unittest.main() +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `& "C:\Users\root\AppData\Local\Programs\Python\Python311\python.exe" -m unittest tests.test_pineap_settings -v` +Expected: FAIL — missing `uci_show` / `h_pineap_settings_*`. + +- [ ] **Step 4: Implement** + +Add to `server.py`: + +```python +# Map logical setting -> uci option. Adjust after on-device `uci show pineapd`. +SETTING_MAP = { + 'mimic': 'pineapd.pineapd.mimic', + 'advertise': 'pineapd.pineapd.advertise', + 'collect_probes': 'pineapd.pineapd.collect_probes', + 'collect_handshakes': 'pineapd.pineapd.collect_handshakes', + 'random_mac': 'pineapd.pineapd.random_mac', + 'wigle': 'pineapd.pineapd.wigle', + 'bands': 'pineapd.pineapd.bands', +} + + +def uci_show(section='pineapd'): + rc, out, err = device_run(['uci', 'show', section]) + return out + + +def uci_set(option, value): + device_run(['uci', 'set', '%s=%s' % (option, value)]) + device_run(['uci', 'commit']) + + +def uci_delete(option): + device_run(['uci', 'delete', option]) + device_run(['uci', 'commit']) + + +def uci_add_list(option, value): + device_run(['uci', 'add_list', '%s=%s' % (option, value)]) + device_run(['uci', 'commit']) + + +def _uci_map(): + raw = {} + for line in uci_show('pineapd').splitlines(): + if '=' in line: + k, v = line.split('=', 1) + raw[k.strip()] = v.strip().strip("'") + return raw + + +def _bool(v): + return v in ('1', 'true', 'on') + + +def pineap_settings_data(): + raw = _uci_map() + settings = {'bands': '2.4'} + for key, option in SETTING_MAP.items(): + if option in raw: + val = raw[option] + if key == 'bands': + settings[key] = val + else: + settings[key] = _bool(val) + return {'settings': settings, 'raw': raw} + + +def h_pineap_settings_get(ctx): + return 200, pineap_settings_data() + + +def h_pineap_settings_post(ctx): + body = ctx.body or {} + for key, value in body.items(): + if key in SETTING_MAP and key != 'bands': + uci_set(SETTING_MAP[key], '1' if value else '0') + elif key == 'bands' and value in ('2.4', '5', 'dual'): + uci_set(SETTING_MAP[key], value) + device_run(['/etc/init.d/pineapd', 'reload']) + return 200, pineap_settings_data() +``` + +Register routes: + +```python +ROUTER.add('GET', r'/api/pineap/settings', h_pineap_settings_get) +ROUTER.add('POST', r'/api/pineap/settings', h_pineap_settings_post) +``` + +- [ ] **Step 4b: Run tests to verify they pass** + +Run: `& "C:\Users\root\AppData\Local\Programs\Python\Python311\python.exe" -m unittest tests.test_pineap_settings -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add tests payload/user/general/pager-webui/server.py +git commit -m "feat: uci helpers and pineap settings endpoints" +``` + +--- + +### Task 5: `hak5cmd` helper + SSID pool + filters + +**Files:** +- Modify: `payload/user/general/pager-webui/server.py` +- Create: `tests/test_pineap_pool.py` + +**Interfaces:** +- Consumes: `server.device_run`, `server.ROUTER`. +- Produces: `server.hak5(*args)`, `server._parse_pool_list(text)`, `server.h_ssids_get(ctx)`, `server.h_ssids_post(ctx)`, `server.h_ssidpool_action(ctx, action)`, `server.h_filter_get(ctx, kind)`, `server.h_filter_post(ctx, kind)` where `kind` is `'client'` or `'ssid'`. + +- [ ] **Step 1: Inspect hak5cmd output on device** + +Over SSH run (read-only): `hak5cmd PINEAPPLE_SSID_POOL_LIST`, `hak5cmd PINEAPPLE_DEVICE_FILTER_LIST`, `hak5cmd PINEAPPLE_NETWORK_FILTER_LIST`, `hak5cmd PINEAPPLE_DEVICE_FILTER_MODE`, `hak5cmd PINEAPPLE_SSID_POOL_START`, `hak5cmd PINEAPPLE_SSID_POOL_COLLECT_START`. Record the output text shapes and the argument order for `_ADD`, `_DELETE`, `_MODE`, `_START`. Adjust the parsers below to match (they tolerate JSON, one-ssid-per-line, and CSV headers). + +- [ ] **Step 2: Write the failing tests** + +`tests/test_pineap_pool.py`: + +```python +import os +import sys +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'general', 'pager-webui')) +import server + + +class Hak5Test(unittest.TestCase): + def test_hak5_uses_full_path_arglist(self): + calls = [] + def fake(args, timeout=20): + calls.append(args) + return 0, 'line1\n', '' + server.device_run = fake + out = server.hak5('PINEAPPLE_SSID_POOL_LIST') + self.assertEqual(calls[0][0], server.HAK5CMD) + self.assertIn('PINEAPPLE_SSID_POOL_LIST', calls[0]) + self.assertEqual(out, 'line1\n') + + +class PoolParsingTest(unittest.TestCase): + def test_parse_json_pool(self): + text = '{"ssids": ["A", "B"]}' + self.assertEqual(server._parse_pool_list(text), ['A', 'B']) + + def test_parse_line_pool(self): + text = '"SSID A"\nSSID B\n' + self.assertEqual(server._parse_pool_list(text), ['SSID A', 'SSID B']) + + +class FilterParsingTest(unittest.TestCase): + def test_parse_lines(self): + entries = server._parse_filter_list('00:11:22:33:44:55\nAA:BB:CC:DD:EE:FF\n') + self.assertEqual(len(entries), 2) + + def test_parse_json_entries(self): + entries = server._parse_filter_list('{"macs": ["00:11:22:33:44:55"]}') + self.assertEqual(entries, ['00:11:22:33:44:55']) + + +class SsidPoolHandlersTest(unittest.TestCase): + def test_ssids_get(self): + server.hak5 = lambda *a, **k: '{"ssids": ["FreeWiFi"]}\n' + class Ctx: + args = () + status, payload = server.h_ssids_get(Ctx()) + self.assertEqual(status, 200) + self.assertEqual(payload['ssids'], ['FreeWiFi']) + + def test_ssids_post_add(self): + calls = [] + def fake(*args): + calls.append(args) + return 'ok' + server.hak5 = fake + server.h_ssids_post(type('C', (), {'args': (), 'body': {'action': 'add', 'ssid': 'NewNet'}})()) + self.assertTrue(any(c[0] == 'PINEAPPLE_SSID_POOL_ADD' for c in calls)) + + def test_ssidpool_action(self): + calls = [] + def fake(*args): + calls.append(args) + return 'ok' + server.hak5 = fake + server.h_ssidpool_action(type('C', (), {'args': ('start',)})()) + self.assertEqual(calls[0][0], 'PINEAPPLE_SSID_POOL_START') + + +if __name__ == '__main__': + unittest.main() +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `& "C:\Users\root\AppData\Local\Programs\Python\Python311\python.exe" -m unittest tests.test_pineap_pool -v` +Expected: FAIL — missing functions. + +- [ ] **Step 4: Implement** + +Add to `server.py`: + +```python +def hak5(*args, timeout=30): + rc, out, err = device_run([HAK5CMD] + list(args), timeout=timeout) + return out + + +def _json_or(text): + text = text.strip() + if text.startswith('{') or text.startswith('['): + try: + return json.loads(text) + except ValueError: + return None + return None + + +def _parse_pool_list(text): + obj = _json_or(text) + if isinstance(obj, dict) and 'ssids' in obj: + return [str(s) for s in obj['ssids']] + if isinstance(obj, list): + return [str(s) for s in obj] + out = [] + for line in text.splitlines(): + line = line.strip().strip('"') + low = line.lower() + if not line: + continue + if low in ('ssid', 'ssids') or low.startswith('ssid pool') or low.startswith('no '): + continue + out.append(line) + return out + + +def _parse_filter_list(text): + obj = _json_or(text) + if isinstance(obj, dict): + for k in ('macs', 'ssids', 'entries'): + if k in obj: + return [str(x) for x in obj[k]] + if isinstance(obj, list): + return [str(x) for x in obj] + out = [] + for line in text.splitlines(): + line = line.strip() + if line and not line.lower().startswith(('mac', 'ssid', 'client')): + out.append(line) + return out + + +def h_ssids_get(ctx): + return 200, {'ssids': _parse_pool_list(hak5('PINEAPPLE_SSID_POOL_LIST'))} + + +def h_ssids_post(ctx): + body = ctx.body or {} + action = body.get('action') + if action == 'add': + ssid = (body.get('ssid') or '').strip() + if not ssid: + return 400, {'error': 'ssid required'} + hak5('PINEAPPLE_SSID_POOL_ADD', ssid) + elif action == 'remove': + hak5('PINEAPPLE_SSID_POOL_DELETE', (body.get('ssid') or '').strip()) + elif action == 'clear': + hak5('PINEAPPLE_SSID_POOL_CLEAR') + else: + return 400, {'error': 'unknown action'} + return 200, {'ssids': _parse_pool_list(hak5('PINEAPPLE_SSID_POOL_LIST'))} + + +SSIDPOOL_ACTIONS = { + 'start': 'PINEAPPLE_SSID_POOL_START', + 'stop': 'PINEAPPLE_SSID_POOL_STOP', + 'collect_start': 'PINEAPPLE_SSID_POOL_COLLECT_START', + 'collect_stop': 'PINEAPPLE_SSID_POOL_COLLECT_STOP', +} + + +def h_ssidpool_action(ctx): + cmd = SSIDPOOL_ACTIONS.get(ctx.args[0]) + if not cmd: + return 400, {'error': 'unknown action'} + hak5(cmd) + return 200, {'ok': True} + + +FILTER_CMDS = { + ('client', 'mode'): ('PINEAPPLE_DEVICE_FILTER_MODE', 'PINEAPPLE_DEVICE_FILTER_LIST', + 'PINEAPPLE_DEVICE_FILTER_ADD', 'PINEAPPLE_DEVICE_FILTER_DELETE', 'PINEAPPLE_DEVICE_FILTER_CLEAR'), + ('ssid', 'mode'): ('PINEAPPLE_NETWORK_FILTER_MODE', 'PINEAPPLE_NETWORK_FILTER_LIST', + 'PINEAPPLE_NETWORK_FILTER_ADD', 'PINEAPPLE_NETWORK_FILTER_DELETE', 'PINEAPPLE_NETWORK_FILTER_CLEAR'), +} + + +def h_filter_get(ctx, kind): + mode_cmd, list_cmd, _, _, _ = FILTER_CMDS[(kind, 'mode')] + mode = hak5(mode_cmd).strip() + return 200, {'mode': mode, 'entries': _parse_filter_list(hak5(list_cmd))} + + +def h_filter_post(ctx, kind): + body = ctx.body or {} + action = body.get('action') + mode_cmd, list_cmd, add_cmd, del_cmd, clear_cmd = FILTER_CMDS[(kind, 'mode')] + if action == 'set_mode': + mode = (body.get('mode') or '').strip() + if mode: + hak5(mode_cmd, mode) + elif action == 'add': + value = (body.get('value') or '').strip() + if not value: + return 400, {'error': 'value required'} + hak5(add_cmd, value) + elif action == 'delete': + hak5(del_cmd, (body.get('value') or '').strip()) + elif action == 'clear': + hak5(clear_cmd) + else: + return 400, {'error': 'unknown action'} + return 200, h_filter_get(ctx, kind)[1] +``` + +Register routes: + +```python +ROUTER.add('GET', r'/api/pineap/ssids', h_ssids_get) +ROUTER.add('POST', r'/api/pineap/ssids', h_ssids_post) +ROUTER.add('POST', r'/api/pineap/ssidpool/(start|stop|collect_start|collect_stop)', h_ssidpool_action) +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')) +``` + +- [ ] **Step 4b: Run tests to verify they pass** + +Run: `& "C:\Users\root\AppData\Local\Programs\Python\Python311\python.exe" -m unittest tests.test_pineap_pool -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add tests payload/user/general/pager-webui/server.py +git commit -m "feat: hak5cmd helper, ssid pool and filter endpoints" +``` + +--- + +### Task 6: Clients, kick, deauth + +**Files:** +- Modify: `payload/user/general/pager-webui/server.py` +- Create: `tests/test_pineap_clients.py` + +**Interfaces:** +- Consumes: `server.assoc_clients()`, `server.hak5`, `server.ROUTER`. +- Produces: `server.h_clients(ctx)`, `server.h_client_kick(ctx)`, `server.h_deauth_client(ctx)`, `server.normalize_mac(mac)`. + +- [ ] **Step 1: Inspect device commands** + +Over SSH run `iwinfo wlan0open assoclist` and `hostapd_cli -i wlan0open list_sta` (note which interfaces exist: `wlan0open`, `wlan0wpa`, `wlan0mgmt`). If `iwinfo` misses clients that `hostapd_cli` finds, extend `assoc_clients()` to also run `hostapd_cli -i all_sta` per interface (spec §10). Also verify `hak5cmd PINEAPPLE_DEVICE_FILTER_MODE deny` argument order. + +- [ ] **Step 2: Write the failing tests** + +`tests/test_pineap_clients.py`: + +```python +import os +import sys +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'general', 'pager-webui')) +import server + + +class NormalizeTest(unittest.TestCase): + def test_normalize(self): + self.assertEqual(server.normalize_mac(' 00:11:22:33:44:55 '), '00:11:22:33:44:55') + self.assertEqual(server.normalize_mac('aa:bb:cc:dd:ee:ff'), 'AA:BB:CC:DD:EE:FF') + + def test_invalid_returns_none(self): + self.assertIsNone(server.normalize_mac('nope')) + + +class ClientsTest(unittest.TestCase): + def test_clients_handler(self): + server.assoc_clients = lambda: [{'mac': 'AA:BB:CC:DD:EE:FF', 'iface': 'wlan0open', 'rssi': -55}] + class Ctx: + args = () + status, payload = server.h_clients(Ctx()) + self.assertEqual(status, 200) + self.assertEqual(payload['count'], 1) + + def test_kick_validates_and_deny_adds(self): + calls = [] + def fake(*args): + calls.append(args) + return 'ok' + server.hak5 = fake + server.h_client_kick(type('C', (), {'args': (), 'body': {'mac': '00:11:22:33:44:55'}})()) + self.assertTrue(any(c[0] == 'PINEAPPLE_DEVICE_FILTER_ADD' for c in calls)) + self.assertTrue(any(c[0] == 'PINEAPPLE_DEAUTH_CLIENT' for c in calls)) + + def test_kick_bad_mac_400(self): + status, payload = server.h_client_kick(type('C', (), {'args': (), 'body': {'mac': 'x'}})()) + self.assertEqual(status, 400) + + def test_deauth_client(self): + calls = [] + def fake(*args): + calls.append(args) + return 'ok' + server.hak5 = fake + server.h_deauth_client(type('C', (), {'args': (), 'body': {'mac': '00:11:22:33:44:55'}})()) + self.assertEqual(calls[0][0], 'PINEAPPLE_DEAUTH_CLIENT') + + +if __name__ == '__main__': + unittest.main() +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `& "C:\Users\root\AppData\Local\Programs\Python\Python311\python.exe" -m unittest tests.test_pineap_clients -v` +Expected: FAIL — missing functions. + +- [ ] **Step 4: Implement** + +Add to `server.py`: + +```python +MAC_RE = re.compile(r'^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$') + + +def normalize_mac(mac): + mac = (mac or '').strip().upper() + return mac if MAC_RE.match(mac) else None + + +def h_clients(ctx): + clients = assoc_clients() + return 200, {'clients': clients, 'count': len(clients)} + + +def h_client_kick(ctx): + mac = normalize_mac((ctx.body or {}).get('mac')) + if not mac: + return 400, {'error': 'invalid mac'} + hak5('PINEAPPLE_DEVICE_FILTER_MODE', 'deny') + hak5('PINEAPPLE_DEVICE_FILTER_ADD', mac) + hak5('PINEAPPLE_DEAUTH_CLIENT', mac) + return 200, {'ok': True} + + +def h_deauth_client(ctx): + mac = normalize_mac((ctx.body or {}).get('mac')) + if not mac: + return 400, {'error': 'invalid mac'} + hak5('PINEAPPLE_DEAUTH_CLIENT', mac) + return 200, {'ok': True} +``` + +Register routes: + +```python +ROUTER.add('GET', r'/api/pineap/clients', h_clients) +ROUTER.add('POST', r'/api/pineap/clients/kick', h_client_kick) +ROUTER.add('POST', r'/api/pineap/deauth/client', h_deauth_client) +``` + +- [ ] **Step 4b: Run tests to verify they pass** + +Run: `& "C:\Users\root\AppData\Local\Programs\Python\Python311\python.exe" -m unittest tests.test_pineap_clients -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add tests payload/user/general/pager-webui/server.py +git commit -m "feat: clients list, kick and deauth endpoints" +``` + +--- + +### Task 7: Recon start/stop + recon.db scans + +**Files:** +- Modify: `payload/user/general/pager-webui/server.py` +- Create: `tests/test_recon.py` + +**Interfaces:** +- Consumes: `server.hak5`, `server.device_run`, `server.ROUTER`, `server.RECON_DB`. +- Produces: `server.recon_conn()`, `server.recon_scans_data()`, `server.recon_scan_data(scan_id)`, `server.h_recon_start(ctx)`, `server.h_recon_stop(ctx)`, `server.h_recon_scans(ctx)`, `server.h_recon_scan_detail(ctx, scan_id)`. + +- [ ] **Step 1: Inspect recon.db schema on device** + +Over SSH run: +- `sqlite3 /root/recon/recon.db '.tables'` +- `sqlite3 /root/recon/recon.db '.schema scan'` +- `sqlite3 /root/recon/recon.db '.schema wifi_device'` +- `sqlite3 /root/recon/recon.db '.schema ssid'` +- `sqlite3 /root/recon/recon.db '.schema handshake'` + +Record real column names. The code below introspects columns dynamically so it survives naming differences, but set the table names (`scan`, `wifi_device`, `ssid`, `handshake`) to whatever `.tables` reports. The `scan` table should have an integer `id` and a timestamp column (choose `timestamp`; if absent, prefer `time` or `datetime`). + +- [ ] **Step 2: Write the failing tests** + +`tests/test_recon.py`: + +```python +import os +import sqlite3 +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'general', 'pager-webui')) +import server + + +class ReconDbTest(unittest.TestCase): + def setUp(self): + fd, self.db = tempfile.mkstemp(suffix='.db') + os.close(fd) + conn = sqlite3.connect(self.db) + conn.execute('CREATE TABLE scan (id INTEGER PRIMARY KEY, timestamp INTEGER)') + conn.execute('INSERT INTO scan (timestamp) VALUES (1700000000)') + conn.execute('INSERT INTO scan (timestamp) VALUES (1700001000)') + conn.execute('CREATE TABLE wifi_device (id INTEGER PRIMARY KEY, scan_id INTEGER, bssid TEXT, ssid TEXT)') + conn.execute("INSERT INTO wifi_device (scan_id, bssid, ssid) VALUES (1, '00:11:22:33:44:55', 'Net1')") + conn.commit() + conn.close() + server.RECON_DB = self.db + + def tearDown(self): + os.unlink(self.db) + + def test_scans_lists_rows(self): + data = server.recon_scans_data() + self.assertEqual(len(data['scans']), 2) + self.assertEqual(data['scans'][0]['id'], 2) + + def test_scan_detail_returns_tables(self): + data = server.recon_scan_data(1) + self.assertEqual(data['scan']['id'], 1) + self.assertIn('aps', data) + + +class ReconHandlersTest(unittest.TestCase): + def test_start_stop_call_hak5(self): + calls = [] + def fake(*args): + calls.append(args) + return 'ok' + server.hak5 = fake + server.h_recon_start(type('C', (), {'args': ()})()) + server.h_recon_stop(type('C', (), {'args': ()})()) + self.assertEqual(calls[0][0], 'PINEAPPLE_RECON_NEW') + self.assertTrue(any(c[0] == 'PINEAPPLE_RECON_STOP' for c in calls)) + + def test_scan_detail_bad_id_404(self): + fd, db = tempfile.mkstemp(suffix='.db') + os.close(fd) + conn = sqlite3.connect(db) + conn.execute('CREATE TABLE scan (id INTEGER PRIMARY KEY, timestamp INTEGER)') + conn.close() + server.RECON_DB = db + try: + status, payload = server.h_recon_scan_detail(type('C', (), {'args': ('999',)})()) + self.assertEqual(status, 404) + finally: + os.unlink(db) + + +if __name__ == '__main__': + unittest.main() +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `& "C:\Users\root\AppData\Local\Programs\Python\Python311\python.exe" -m unittest tests.test_recon -v` +Expected: FAIL — missing functions. + +- [ ] **Step 4: Implement** + +Add to `server.py`: + +```python +RECON_TABLES = {'scan': 'scan', 'wifi_device': 'wifi_device', 'ssid': 'ssid', 'handshake': 'handshake'} +RECON_TS_COL = 'timestamp' + + +def recon_conn(): + return sqlite3.connect('file:%s?mode=ro' % RECON_DB, uri=True) + + +def _cols(conn, table): + rows = conn.execute('PRAGMA table_info(%s)' % table).fetchall() + return [r[1] for r in rows] + + +def recon_scans_data(limit=50): + conn = recon_conn() + cols = _cols(conn, RECON_TABLES['scan']) + ts = RECON_TS_COL if RECON_TS_COL in cols else (cols[1] if len(cols) > 1 else 'id') + try: + cur = conn.execute('SELECT id, %s FROM %s ORDER BY id DESC LIMIT ?' % (ts, RECON_TABLES['scan']), (limit,)) + scans = [{'id': r[0], 'timestamp': r[1]} for r in cur.fetchall()] + finally: + conn.close() + return {'scans': scans} + + +def recon_scan_data(scan_id): + conn = recon_conn() + try: + scan = conn.execute('SELECT * FROM %s WHERE id=?' % RECON_TABLES['scan'], (scan_id,)).fetchone() + if scan is None: + return None + cols = _cols(conn, RECON_TABLES['scan']) + scan_row = dict(zip(cols, scan)) + aps = [] + wcols = _cols(conn, RECON_TABLES['wifi_device']) + for row in conn.execute('SELECT * FROM %s WHERE scan_id=?' % RECON_TABLES['wifi_device'], (scan_id,)).fetchall(): + aps.append(dict(zip(wcols, row))) + clients = [] + hcols = _cols(conn, RECON_TABLES['handshake']) + handshakes = [] + if 'handshake' in RECON_TABLES: + try: + for row in conn.execute('SELECT * FROM %s WHERE scan_id=?' % RECON_TABLES['handshake'], (scan_id,)).fetchall(): + handshakes.append(dict(zip(hcols, row))) + except sqlite3.OperationalError: + handshakes = [] + return {'scan': scan_row, 'aps': aps, 'clients': clients, 'handshakes': handshakes} + finally: + conn.close() + + +def h_recon_start(ctx): + hak5('PINEAPPLE_RECON_NEW') + return 200, {'ok': True} + + +def h_recon_stop(ctx): + hak5('PINEAPPLE_RECON_STOP') + return 200, {'ok': True} + + +def h_recon_scans(ctx): + return 200, recon_scans_data() + + +def h_recon_scan_detail(ctx): + scan_id = int(ctx.args[0]) + data = recon_scan_data(scan_id) + if data is None: + return 404, {'error': 'scan not found'} + return 200, data +``` + +Register routes: + +```python +ROUTER.add('POST', r'/api/recon/start', h_recon_start) +ROUTER.add('POST', r'/api/recon/stop', h_recon_stop) +ROUTER.add('GET', r'/api/recon/scans', h_recon_scans) +ROUTER.add('GET', r'/api/recon/scans/(\d+)', h_recon_scan_detail) +``` + +- [ ] **Step 4b: Run tests to verify they pass** + +Run: `& "C:\Users\root\AppData\Local\Programs\Python\Python311\python.exe" -m unittest tests.test_recon -v` +Expected: PASS. Then run the per-module loop from Task 1 to confirm nothing else regressed. + +- [ ] **Step 5: Commit** + +```bash +git add tests payload/user/general/pager-webui/server.py +git commit -m "feat: recon start/stop and recon.db scan endpoints" +``` + +--- + +### Task 8: Handshakes + loot zip/archive proxy + +**Files:** +- Modify: `payload/user/general/pager-webui/server.py` +- Create: `tests/test_loot.py` + +**Interfaces:** +- Consumes: `server.device_run`, `server.daemon_call`, `server.current_token`, `server.Download`, `server.ROUTER`, `server.LOOT_HS_DIR`. +- Produces: `server.handshakes_data()`, `server.h_handshakes_get(ctx)`, `server.h_handshakes_delete(ctx)`, `server.h_loot_zip(ctx)`, `server.h_loot_archive(ctx)`. + +- [ ] **Step 1: Inspect device loot layout** + +Over SSH run `ls -la /root/loot/` and `ls -la /root/loot/handshakes/`. Confirm handshake files (`.pcap`, `.cap`) live directly in `/root/loot/handshakes/`. + +- [ ] **Step 2: Write the failing tests** + +`tests/test_loot.py`: + +```python +import os +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'general', 'pager-webui')) +import server + + +class HandshakesTest(unittest.TestCase): + def setUp(self): + self.dir = tempfile.mkdtemp() + server.LOOT_HS_DIR = self.dir + with open(os.path.join(self.dir, 'hs1.cap'), 'wb') as f: + f.write(b'\x00\x01\x02') + with open(os.path.join(self.dir, 'hs2.pcap'), 'wb') as f: + f.write(b'\x00' * 5) + + def test_list(self): + status, payload = server.h_handshakes_get(type('C', (), {'args': ()})()) + self.assertEqual(status, 200) + names = [f['name'] for f in payload['files']] + self.assertEqual(names, ['hs1.cap', 'hs2.pcap']) + self.assertEqual(payload['files'][0]['size'], 3) + + def test_delete_by_body(self): + server.h_handshakes_delete(type('C', (), {'args': (), 'body': {'name': 'hs1.cap'}})()) + self.assertFalse(os.path.exists(os.path.join(self.dir, 'hs1.cap'))) + + def test_delete_rejects_traversal(self): + status, payload = server.h_handshakes_delete(type('C', (), {'args': (), 'body': {'name': '../server.py'}})()) + self.assertEqual(status, 400) + + +class LootProxyTest(unittest.TestCase): + def test_loot_zip_returns_download(self): + server.daemon_call = lambda m, p, body=None, token=None, timeout=15: (200, b'PK\x03\x04zipdata') + server.current_token = lambda: 'tok' + status, payload = server.h_loot_zip(type('C', (), {'args': ()})()) + self.assertEqual(status, 200) + self.assertEqual(payload.ctype, 'application/zip') + + def test_loot_archive_posts_to_daemon(self): + calls = [] + def fake(m, p, body=None, token=None, timeout=15): + calls.append((m, p)) + return 200, {'ok': True} + server.daemon_call = fake + server.current_token = lambda: 'tok' + server.h_loot_archive(type('C', (), {'args': ()})()) + self.assertTrue(any(m == 'POST' and p == '/api/loot/archive' for m, p in calls)) + + +if __name__ == '__main__': + unittest.main() +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `& "C:\Users\root\AppData\Local\Programs\Python\Python311\python.exe" -m unittest tests.test_loot -v` +Expected: FAIL — missing functions. + +- [ ] **Step 4: Implement** + +Add to `server.py`: + +```python +def handshakes_data(): + files = [] + 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 os.path.isfile(p) and not name.startswith('.'): + st = os.stat(p) + files.append({'name': name, 'size': st.st_size, 'mtime': int(st.st_mtime)}) + except OSError: + continue + return {'files': files} + + +def h_handshakes_get(ctx): + return 200, handshakes_data() + + +def h_handshakes_delete(ctx): + name = (ctx.body or {}).get('name') or ctx.query.get('name') or '' + safe = os.path.basename(name) + if not safe or safe != name: + return 400, {'error': 'invalid name'} + p = os.path.join(LOOT_HS_DIR, safe) + if not os.path.isfile(p): + return 404, {'error': 'not found'} + os.remove(p) + return 200, handshakes_data() + + +def h_loot_zip(ctx): + status, raw = daemon_call('GET', '/api/loot/zip', token=current_token()) + if status != 200 or not isinstance(raw, bytes): + return 502, {'error': 'daemon failed'} + return 200, Download(raw, 'application/zip', 'loot.zip') + + +def h_loot_archive(ctx): + status, data = daemon_call('POST', '/api/loot/archive', token=current_token()) + return (200 if status == 200 else 502), (data if isinstance(data, dict) else {'ok': status == 200}) +``` + +Register routes: + +```python +ROUTER.add('GET', r'/api/pineap/handshakes', h_handshakes_get) +ROUTER.add('DELETE', r'/api/pineap/handshakes', h_handshakes_delete) +ROUTER.add('GET', r'/api/loot/zip', h_loot_zip) +ROUTER.add('POST', r'/api/loot/archive', h_loot_archive) +``` + +- [ ] **Step 4b: Run tests to verify they pass** + +Run: `& "C:\Users\root\AppData\Local\Programs\Python\Python311\python.exe" -m unittest tests.test_loot -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add tests payload/user/general/pager-webui/server.py +git commit -m "feat: handshakes list/delete and loot zip/archive proxy" +``` + +--- + +### Task 9: Payloads portal proxy + logging + settings (hostname/password/ntp/service) + +**Files:** +- Modify: `payload/user/general/pager-webui/server.py` +- Create: `tests/test_misc.py` + +**Interfaces:** +- Consumes: `server.daemon_call`, `server.current_token`, `server.device_run`, `server.uci_*`, `server.hak5`, `server.ROUTER`. +- Produces: `server.h_payloads_index(ctx)`, `server.h_payloads_refresh(ctx)`, `server.h_payloads_install(ctx)`, `server.h_payloads_remove(ctx)`, `server.h_logging_system(ctx)`, `server.h_logging_pineap(ctx)`, `server.h_settings_hostname(ctx)`, `server.h_settings_password(ctx)`, `server.h_settings_ntp(ctx)`, `server.h_settings_service(ctx)`. + +- [ ] **Step 1: Inspect daemon portal + log sources on device** + +Over SSH run: +- Login and curl: `curl -s -X POST http://127.0.0.1:1471/api/login -d '{"username":"root","password":""}'` then `curl -s http://127.0.0.1:1471/api/payloads/portal/index -H "Authorization: Bearer "` to capture the index shape. +- `ls /var/log/` for a pineapd log (e.g. `/var/log/pineapd.log`); fall back to `logread | grep -i pineap`. +- Change the root password with BusyBox `passwd`, supplying the password twice over stdin. Confirm by logging back in through both SSH and the daemon-backed WebUI. + +- [ ] **Step 2: Write the failing tests** + +`tests/test_misc.py`: + +```python +import os +import sys +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'general', 'pager-webui')) +import server + + +class PayloadsProxyTest(unittest.TestCase): + def test_index_proxies(self): + server.daemon_call = lambda m, p, body=None, token=None, timeout=15: (200, {'payloads': []}) + server.current_token = lambda: 'tok' + class Ctx: + args = () + status, payload = server.h_payloads_index(Ctx()) + self.assertEqual(status, 200) + self.assertEqual(payload, {'payloads': []}) + + def test_install_uses_key(self): + calls = [] + def fake(m, p, body=None, token=None, timeout=15): + calls.append((m, p)) + return 200, {'ok': True} + server.daemon_call = fake + server.current_token = lambda: 'tok' + server.h_payloads_install(type('C', (), {'args': (), 'body': {'key': 'nautilus'}})()) + self.assertTrue(any(m == 'POST' and '/api/payloads/portal/nautilus/install' in p for m, p in calls)) + + def test_remove_uses_key(self): + calls = [] + def fake(m, p, body=None, token=None, timeout=15): + calls.append((m, p)) + return 200, {'ok': True} + server.daemon_call = fake + server.current_token = lambda: 'tok' + server.h_payloads_remove(type('C', (), {'args': (), 'body': {'key': 'nautilus'}})()) + self.assertTrue(any(m == 'POST' and '/api/payloads/portal/nautilus/remove' in p for m, p in calls)) + + +class LoggingTest(unittest.TestCase): + def test_system_tails(self): + server.device_run = lambda args, timeout=20: (0, '\n'.join('line%d' % i for i in range(20)), '') + status, payload = server.h_logging_system(type('C', (), {'args': (), 'query': {'lines': '5'}})()) + self.assertEqual(status, 200) + self.assertEqual(len(payload['lines']), 5) + + +class SettingsTest(unittest.TestCase): + def test_hostname_get(self): + class H: + command = 'GET' + server.device_run = lambda args, timeout=20: (0, 'pager\n', '') + status, payload = server.h_settings_hostname(type('C', (), {'args': (), 'h': H()})()) + self.assertEqual(status, 200) + self.assertEqual(payload['hostname'], 'pager') + + def test_password_uses_passwd_stdin(self): + calls = [] + def fake(args, timeout=20, input_data=None): + calls.append((args, input_data)) + return 0, '', '' + server.device_run = fake + server.h_settings_password(type('C', (), {'args': (), 'body': {'password': 'newpw'}})()) + self.assertEqual(calls, [(['/bin/passwd', 'root'], b'newpw\nnewpw\n')]) + + def test_ntp_get_parses(self): + class H: + command = 'GET' + server.device_run = lambda args, timeout=20: (0, "system.ntp.enabled='1'\nsystem.ntp.server='0.pool.ntp.org'\n", '') + status, payload = server.h_settings_ntp(type('C', (), {'args': (), 'h': H()})()) + self.assertEqual(status, 200) + self.assertEqual(payload['enabled'], True) + self.assertIn('0.pool.ntp.org', payload['servers']) + + def test_service_running_detection(self): + server.device_run = lambda args, timeout=20: (0, '', '') + status, payload = server.h_settings_service(type('C', (), {'args': ()})()) + self.assertEqual(status, 200) + self.assertIn('running', payload) + self.assertIn('background', payload) + + +if __name__ == '__main__': + unittest.main() +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `& "C:\Users\root\AppData\Local\Programs\Python\Python311\python.exe" -m unittest tests.test_misc -v` +Expected: FAIL — missing functions. + +- [ ] **Step 4: Implement** + +Add to `server.py`: + +```python +def _proxy_json(method, path, body=None): + status, data = daemon_call(method, path, body=body, token=current_token()) + 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_payloads_index(ctx): + return _proxy_json('GET', '/api/payloads/portal/index') + + +def h_payloads_refresh(ctx): + return _proxy_json('POST', '/api/payloads/portal/refresh') + + +def h_payloads_install(ctx): + key = (ctx.body or {}).get('key', '') + if not key: + return 400, {'error': 'key required'} + return _proxy_json('POST', '/api/payloads/portal/%s/install' % key) + + +def h_payloads_remove(ctx): + key = (ctx.body or {}).get('key', '') + if not key: + return 400, {'error': 'key required'} + return _proxy_json('POST', '/api/payloads/portal/%s/remove' % key) + + +def _tail(text, lines): + return text.splitlines()[-lines:] if lines else [] + + +def h_logging_system(ctx): + lines = int(ctx.query.get('lines', '200')) + rc, out, err = device_run(['logread']) + return 200, {'lines': _tail(out, lines)} + + +PINEAP_LOG = '/var/log/pineapd.log' + + +def h_logging_pineap(ctx): + lines = int(ctx.query.get('lines', '200')) + if os.path.isfile(PINEAP_LOG): + with open(PINEAP_LOG, 'r', errors='replace') as f: + return 200, {'lines': _tail(f.read(), lines)} + rc, out, err = device_run(['logread']) + relevant = [l for l in out.splitlines() if 'pineap' in l.lower()] + return 200, {'lines': relevant[-lines:]} + + +def h_settings_hostname(ctx): + if ctx.h.command == 'POST': + hostname = (ctx.body or {}).get('hostname', '').strip() + if not hostname: + return 400, {'error': 'hostname required'} + uci_set('system.@system[0].hostname', hostname) + return 200, {'hostname': hostname_data()} + + +def h_settings_password(ctx): + newpw = (ctx.body or {}).get('password', '') + if not newpw: + return 400, {'error': 'password required'} + device_run(['/bin/passwd', 'root'], input_data=(newpw + '\n' + newpw + '\n').encode()) + return 200, {'ok': True} + + +def h_settings_ntp(ctx): + if ctx.h.command == 'POST': + body = ctx.body or {} + enabled = '1' if body.get('enabled', True) else '0' + uci_set('system.ntp.enabled', enabled) + servers = body.get('servers', []) + if isinstance(servers, list): + uci_delete('system.ntp.server') + for s in servers: + if s.strip(): + uci_add_list('system.ntp.server', s.strip()) + device_run(['/etc/init.d/sysntpd', 'restart']) + rc, out, err = device_run(['uci', 'show', 'system.ntp']) + raw = {} + for line in out.splitlines(): + if '=' in line: + k, v = line.split('=', 1) + raw[k.strip()] = v.strip().strip("'") + servers = [] + for k, v in raw.items(): + if k.endswith('.server'): + servers.append(v) + return 200, {'enabled': raw.get('system.ntp.enabled', '1') != '0', 'servers': servers} + + +def h_settings_service(ctx): + rc, out, err = device_run(['/etc/init.d/pagerwebui', 'running']) + running = rc == 0 + rc2, out2, err2 = device_run(['test', '-f', '/etc/init.d/pagerwebui']) + return 200, {'running': running, 'background': rc2 == 0} +``` + +Register routes: + +```python +ROUTER.add('GET', r'/api/payloads/index', h_payloads_index) +ROUTER.add('POST', r'/api/payloads/refresh', h_payloads_refresh) +ROUTER.add('POST', r'/api/payloads/install', h_payloads_install) +ROUTER.add('POST', r'/api/payloads/remove', h_payloads_remove) +ROUTER.add('GET', r'/api/logging/system', h_logging_system) +ROUTER.add('GET', r'/api/logging/pineap', h_logging_pineap) +ROUTER.add('GET', r'/api/settings/hostname', h_settings_hostname) +ROUTER.add('POST', r'/api/settings/hostname', h_settings_hostname) +ROUTER.add('POST', r'/api/settings/password', h_settings_password) +ROUTER.add('GET', r'/api/settings/ntp', h_settings_ntp) +ROUTER.add('POST', r'/api/settings/ntp', h_settings_ntp) +ROUTER.add('GET', r'/api/settings/service', h_settings_service) +``` + +Note: `h_settings_hostname` and `h_settings_ntp` branch on `ctx.h.command` (set by `BaseHTTPRequestHandler.command` to `'GET'`/`'POST'`); the test fakes above provide `h.command = 'GET'` for the GET path. + +- [ ] **Step 4b: Run tests to verify they pass** + +Run: `& "C:\Users\root\AppData\Local\Programs\Python\Python311\python.exe" -m unittest tests.test_misc -v` +Expected: PASS after the test-fake amendment above. + +- [ ] **Step 5: Commit** + +```bash +git add tests payload/user/general/pager-webui/server.py +git commit -m "feat: payloads portal proxy, logging, settings endpoints" +``` + +--- + +### Task 10: WebSocket `/api/ws` live loop + terminal relay fallback + +**Files:** +- Modify: `payload/user/general/pager-webui/server.py` +- Create: `tests/test_ws.py` + +**Interfaces:** +- Consumes: `server.status_data()`, `server.assoc_clients()`, `server.PagerHandler._ws_accept`, `server.ROUTER`. +- Produces: `server.ws_handshake_reply(key)`, `server.ws_encode(payload, opcode)`, `server.ws_decode_frame(buf)` → `(opcode, payload, consumed)`, `server.WS_POOL` (class with `add/remove/broadcast`), `server.live_loop()`, `server.h_terminal_ws(ctx)`. + +- [ ] **Step 1: Write the failing tests** + +`tests/test_ws.py`: + +```python +import base64 +import hashlib +import json +import os +import sys +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'general', 'pager-webui')) +import server + + +class FrameCodecTest(unittest.TestCase): + def test_encode_small_text_frame(self): + raw = server.ws_encode(b'hi', opcode=0x1) + self.assertEqual(raw[0] & 0x80, 0x80) + self.assertEqual(raw[0] & 0x0F, 0x1) + self.assertEqual(raw[1], 2) + + def test_decode_unmasked_text(self): + frame = server.ws_encode(b'hello', opcode=0x1) + opcode, payload, consumed = server.ws_decode_frame(frame) + self.assertEqual(opcode, 0x1) + self.assertEqual(payload, b'hello') + self.assertEqual(consumed, len(frame)) + + def test_decode_masked_text(self): + import struct + mask = b'\x01\x02\x03\x04' + payload = b'abc' + masked = bytes(payload[i] ^ mask[i % 4] for i in range(len(payload))) + frame = bytes([0x81, 0x80 | len(payload)]) + mask + masked + opcode, out, consumed = server.ws_decode_frame(frame) + self.assertEqual(out, b'abc') + + def test_handshake_reply_uses_sha1(self): + key = 'dGhlIHNhbXBsZSBub25jZQ==' + reply = server.ws_handshake_reply(key).decode() + self.assertIn('101 Switching Protocols', reply) + self.assertIn('s3pPLMBiTxaQ9kYGzzhZRbK+xOo=', reply) + + +class WsPoolTest(unittest.TestCase): + def test_broadcast_skips_dead(self): + class FakeSock: + def __init__(self, fail=False): + self.fail = fail + self.sent = [] + def sendall(self, b): + if self.fail: + raise OSError('closed') + self.sent.append(b) + pool = server.WSPool() + good = FakeSock() + dead = FakeSock(fail=True) + pool.add(good) + pool.add(dead) + pool.broadcast(b'x') + self.assertEqual(len(good.sent), 1) + self.assertNotIn(dead, pool.clients) + + +if __name__ == '__main__': + unittest.main() +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `& "C:\Users\root\AppData\Local\Programs\Python\Python311\python.exe" -m unittest tests.test_ws -v` +Expected: FAIL — missing functions. + +- [ ] **Step 3: Implement** + +Add to `server.py`: + +```python +WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11' + + +def ws_handshake_reply(key): + accept = base64.b64encode(hashlib.sha1((key + WS_GUID).encode('ascii')).digest()).decode('ascii') + return ('HTTP/1.1 101 Switching Protocols\r\n' + 'Upgrade: websocket\r\n' + 'Connection: Upgrade\r\n' + 'Sec-WebSocket-Accept: ' + accept + '\r\n\r\n').encode('ascii') + + +def ws_encode(payload, opcode=0x1): + header = bytearray([0x80 | opcode]) + n = len(payload) + if n < 126: + header.append(n) + elif n < 65536: + header.append(126) + header += struct.pack('>H', n) + else: + header.append(127) + header += struct.pack('>Q', n) + return bytes(header) + payload + + +def ws_decode_frame(buf): + if len(buf) < 2: + return None, b'', 0 + opcode = buf[0] & 0x0F + masked = bool(buf[1] & 0x80) + length = buf[1] & 0x7F + off = 2 + if length == 126: + if len(buf) < 4: + return None, b'', 0 + length = struct.unpack('>H', buf[2:4])[0] + off = 4 + elif length == 127: + if len(buf) < 10: + return None, b'', 0 + length = struct.unpack('>Q', buf[2:10])[0] + off = 10 + if masked: + if len(buf) < off + 4: + return None, b'', 0 + mask = buf[off:off + 4] + off += 4 + if len(buf) < off + length: + return None, b'', 0 + payload = buf[off:off + length] + if masked: + payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload)) + return opcode, payload, off + length + + +class WSPool: + def __init__(self): + self.clients = [] + self.lock = threading.Lock() + + def add(self, sock): + with self.lock: + self.clients.append(sock) + + def remove(self, sock): + with self.lock: + if sock in self.clients: + self.clients.remove(sock) + + def broadcast(self, payload_bytes): + dead = [] + with self.lock: + for c in list(self.clients): + try: + c.sendall(payload_bytes) + except Exception: + dead.append(c) + for c in dead: + if c in self.clients: + self.clients.remove(c) + + +WS_POOL = WSPool() +LIVE_STOP = threading.Event() + + +def live_loop(): + while not LIVE_STOP.is_set(): + time.sleep(2) + if not WS_POOL.clients: + continue + tick = {'type': 'tick', 'status': status_data(), 'clients': assoc_clients()} + WS_POOL.broadcast(ws_encode(json.dumps(tick).encode())) +``` + +Wire the WS accept path. Replace the stub `PagerHandler._ws_accept` with a real implementation. Add these methods to the class: + +```python + def _ws_accept(self): + path = self.path.split('?', 1)[0] + if path == '/api/terminal/openWs': + return self._ws_relay_terminal() + if path != '/api/ws': + return self._fail(404, 'not found') + if not check_auth(self.headers.get('Cookie', '') or ''): + return self._fail(401, 'unauthorized') + key = self.headers.get('Sec-WebSocket-Key', '') + self.connection.sendall(ws_handshake_reply(key)) + WS_POOL.add(self.connection) + try: + while True: + opcode, payload = self._ws_read_frame() + if opcode is None or opcode == 0x8: + break + if opcode == 0x9: + self.connection.sendall(ws_encode(payload, opcode=0xA)) + finally: + WS_POOL.remove(self.connection) + try: + self.connection.close() + except OSError: + pass + + def _ws_read_frame(self): + hdr = self._recv_exact(2) + if hdr is None: + return None, b'' + length = hdr[1] & 0x7F + if length == 126: + ext = self._recv_exact(2) + if ext is None: + return None, b'' + length = struct.unpack('>H', ext)[0] + elif length == 127: + ext = self._recv_exact(8) + if ext is None: + return None, b'' + length = struct.unpack('>Q', ext)[0] + masked = bool(hdr[1] & 0x80) + mask = self._recv_exact(4) if masked else b'' + payload = self._recv_exact(length) + if payload is None: + return None, b'' + if masked: + payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload)) + return hdr[0] & 0x0F, payload + + def _recv_exact(self, n): + buf = b'' + while len(buf) < n: + chunk = self.connection.recv(n - len(buf)) + if not chunk: + return None + buf += chunk + return buf +``` + +Terminal relay fallback (used when the client cannot reach the daemon directly; the SPA connects direct to `ws://172.16.52.1:1471` by default): + +```python +def _daemon_ws_connect(path): + """Open an RFC6455 WS to the daemon. Returns (sock, error).""" + import socket as _socket + host = DAEMON_BASE.replace('http://', '').split(':') + sock = _socket.create_connection((host[0], int(host[1])), timeout=10) + key = base64.b64encode(os.urandom(16)).decode('ascii') + sess = load_session() + cookie = 'AUTH_%s=%s' % (sess.get('serverid', ''), sess.get('token', '')) + req = ('GET %s HTTP/1.1\r\n' + 'Host: %s\r\n' + 'Upgrade: websocket\r\n' + 'Connection: Upgrade\r\n' + 'Sec-WebSocket-Key: %s\r\n' + 'Sec-WebSocket-Version: 13\r\n' + 'Cookie: %s\r\n' + '\r\n') % (path, DAEMON_BASE.replace('http://', ''), key, cookie) + sock.sendall(req.encode('ascii')) + resp = b'' + while b'\r\n\r\n' not in resp: + chunk = sock.recv(4096) + if not chunk: + sock.close() + return None, 'daemon closed during handshake' + resp += chunk + if b' 101 ' not in resp.split(b'\r\n', 1)[0]: + sock.close() + return None, resp.split(b'\r\n', 1)[0].decode('ascii', 'replace') + return sock, None + + + def _ws_relay_terminal(self): + if not check_auth(self.headers.get('Cookie', '') or ''): + return self._fail(401, 'unauthorized') + key = self.headers.get('Sec-WebSocket-Key', '') + self.connection.sendall(ws_handshake_reply(key)) + daemon_sock, err = _daemon_ws_connect('/api/terminal/openWs') + if err: + try: + self.connection.sendall(ws_encode(('relay error: ' + err).encode(), opcode=0x1)) + except OSError: + pass + try: + self.connection.close() + except OSError: + pass + return + daemon_sock.setblocking(False) + import select + try: + while True: + rlist, _, _ = select.select([self.connection, daemon_sock], [], [], 5) + for s in rlist: + if s is daemon_sock: + try: + chunk = daemon_sock.recv(65536) + except (BlockingIOError, InterruptedError): + continue + if not chunk: + return + # daemon frames are masked per RFC6455; decode then forward unmasked + off = 0 + while off < len(chunk): + op, payload, used = ws_decode_frame(chunk[off:]) + if used == 0: + break + off += used + if op == 0x1: + self.connection.sendall(ws_encode(payload, opcode=0x1)) + elif op == 0x8: + return + else: + opcode, payload = self._ws_read_frame() + if opcode is None or opcode == 0x8: + return + if opcode == 0x1 or opcode == 0x9: + daemon_sock.sendall(ws_encode(payload, opcode=opcode)) + finally: + try: + daemon_sock.close() + except OSError: + pass + try: + self.connection.close() + except OSError: + pass +``` + +Start the live loop when the server starts. In `serve()`: + +```python +def serve(): + threading.Thread(target=live_loop, daemon=True).start() + srv = ThreadingHTTPServer((HOST, PORT), PagerHandler) + srv.serve_forever() +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `& "C:\Users\root\AppData\Local\Programs\Python\Python311\python.exe" -m unittest tests.test_ws -v` +Expected: PASS. Then run the per-module loop from Task 1 to confirm the whole suite is green. + +- [ ] **Step 5: On-device WS validation (background job note)** + +Deploy a dev copy to the Pager (Task 19 provides `deploy.ps1`; until then copy `server.py` + `www` manually with `scp` and run `python3 server.py` in a tmux/screen). Then verify with a browser console WS test: `new WebSocket('ws://172.16.52.1:8080/api/ws')` after login receives `tick` frames every ~2s. + +- [ ] **Step 6: Commit** + +```bash +git add tests payload/user/general/pager-webui/server.py +git commit -m "feat: websocket live loop and terminal relay fallback" +``` + +--- +### Task 11: SPA shell (`index.html` + `app.css`) and xterm assets + +**Files:** +- Create: `payload/user/general/pager-webui/www/index.html` +- Create: `payload/user/general/pager-webui/www/css/app.css` +- Create: `payload/user/general/pager-webui/www/assets/.gitkeep` +- Copy: `js/xterm.min.js`, `js/xterm-addon-fit.min.js`, `js/xterm.css` from `C:\Users\root\Documents\Pineapple\wifipineapplepager\payloads\library\user\remote_access\nautilus\www\` + +**Interfaces:** +- Produces: HTML element IDs consumed by later tasks: `#login-screen`, `#login-form`, `#login-password`, `#app`, `#rail`, `#content`, `#live-status`, `#terminal-btn`, `#terminal-panel`, `#terminal`, `#toast-container`. + +- [ ] **Step 1: Copy xterm bundles** + +```powershell +$src = "C:\Users\root\Documents\Pineapple\wifipineapplepager\payloads\library\user\remote_access\nautilus\www" +$dst = "C:\Users\root\Documents\Pineapple\pager-webui\payload\user\general\pager-webui\www\js" +New-Item -ItemType Directory -Force -Path $dst | Out-Null +Copy-Item "$src\xterm.min.js" "$dst\xterm.min.js" +Copy-Item "$src\xterm-addon-fit.min.js" "$dst\xterm-addon-fit.min.js" +Copy-Item "$src\xterm.css" "$dst\xterm.css" +``` + +Verify with `Get-Item "$dst\*"` — the three files exist and `xterm.min.js` is ~283 KB. + +- [ ] **Step 2: Create `index.html`** + +```html + + + + + + +Pager WebUI + + + + +
+ +
+ + + +
+ + + + + + + + + + +``` + +- [ ] **Step 3: Create `css/app.css`** + +```css +:root { + --bg: #0d1117; --bg2: #161b22; --bg3: #1c2330; + --fg: #c9d1d9; --muted: #8b949e; --accent: #00d4aa; --warn: #f0a35e; + --err: #ff6b6b; --border: #2b333f; --ok: #3fb950; +} +* { box-sizing: border-box; } +html, body { margin: 0; height: 100%; } +body { + background: var(--bg); color: var(--fg); + font: 14px/1.45 "Segoe UI", system-ui, sans-serif; + display: flex; flex-direction: column; +} +.hidden { display: none !important; } +button { + background: var(--accent); color: #04241c; border: 0; border-radius: 4px; + padding: 7px 14px; font-weight: 600; cursor: pointer; +} +button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--accent); } +button.ghost.active { background: var(--accent); color: #04241c; } +button.danger { background: var(--err); color: #fff; } +button:disabled { opacity: .45; cursor: default; } +input, select { + background: var(--bg2); color: var(--fg); border: 1px solid var(--border); + border-radius: 4px; padding: 8px 10px; width: 100%; +} +label { display: block; font-size: 12px; color: var(--muted); margin: 10px 0 4px; } + +#login-screen { flex: 1; display: flex; align-items: center; justify-content: center; } +.login-card { + background: var(--bg2); border: 1px solid var(--border); border-radius: 8px; + padding: 32px; width: 320px; +} +.login-card h1 { margin: 0 0 4px; font-size: 24px; letter-spacing: 2px; } +.login-card h1 span { color: var(--accent); } +.login-card button { width: 100%; margin-top: 18px; } +.muted { color: var(--muted); } + +#app { flex: 1; display: flex; flex-direction: column; min-height: 0; } +#topbar { + display: flex; align-items: center; gap: 16px; padding: 10px 16px; + background: var(--bg2); border-bottom: 1px solid var(--border); +} +.brand { font-weight: 700; letter-spacing: 3px; font-size: 16px; } +.brand span { color: var(--accent); } +.live { flex: 1; text-align: right; color: var(--muted); font-variant-numeric: tabular-nums; } + +#layout { flex: 1; display: flex; min-height: 0; } +#rail { + width: 170px; background: var(--bg2); border-right: 1px solid var(--border); + display: flex; flex-direction: column; padding: 12px 8px; gap: 2px; overflow-y: auto; +} +#rail a { color: var(--muted); text-decoration: none; padding: 8px 10px; border-radius: 4px; } +#rail a:hover { color: var(--fg); background: var(--bg3); } +#rail a.active { color: var(--accent); background: var(--bg3); } +#content { flex: 1; overflow-y: auto; padding: 18px 22px; min-width: 0; } + +#terminal-panel { + border-top: 1px solid var(--border); background: #000; height: 280px; + display: flex; flex-direction: column; +} +#terminal-bar { + display: flex; justify-content: space-between; align-items: center; + background: var(--bg2); padding: 4px 10px; font-size: 12px; color: var(--muted); +} +#terminal-bar button { padding: 2px 10px; } +#terminal { flex: 1; padding: 4px 0 0 8px; } +#terminal .xterm { height: 100%; } + +.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 12px; } +.card { background: var(--bg2); border: 1px solid var(--border); border-radius: 8px; padding: 14px; } +.card-label { font-size: 12px; color: var(--muted); text-transform: uppercase; letter-spacing: 1px; } +.card-value { font-size: 26px; font-weight: 700; margin-top: 6px; } + +.section { background: var(--bg2); border: 1px solid var(--border); border-radius: 8px; padding: 16px; margin-bottom: 16px; } +.section h2 { margin: 0 0 12px; font-size: 16px; } +.section h2 .hint { font-size: 12px; color: var(--muted); font-weight: 400; margin-left: 8px; } +.row { display: flex; gap: 10px; align-items: flex-end; flex-wrap: wrap; } +.row > div { flex: 1; min-width: 140px; } +.tbl { width: 100%; border-collapse: collapse; } +.tbl th, .tbl td { text-align: left; padding: 7px 9px; border-bottom: 1px solid var(--border); } +.tbl th { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 1px; } +.tbl tr:hover td { background: var(--bg3); } +.toggle { display: flex; align-items: center; gap: 8px; margin: 6px 0; } +.toggle input { width: auto; } +.badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; } +.badge.on { background: #123b1d; color: var(--ok); } +.badge.off { background: #3b1d12; color: var(--warn); } +#toast-container { position: fixed; right: 16px; bottom: 16px; display: flex; flex-direction: column; gap: 8px; z-index: 100; } +.toast { padding: 10px 14px; border-radius: 6px; background: var(--bg2); border: 1px solid var(--border); max-width: 320px; } +.toast.error { border-color: var(--err); color: var(--err); } +.toast.info { border-color: var(--accent); } +pre.logs { background: #000; color: #c9d1d9; padding: 12px; border-radius: 6px; overflow: auto; max-height: 420px; font-size: 12px; white-space: pre-wrap; } +code { background: var(--bg3); padding: 1px 5px; border-radius: 3px; } +``` + +- [ ] **Step 4: Verify shell loads** + +Run: `& "C:\Users\root\AppData\Local\Programs\Python\Python311\python.exe" -m http.server 8000 -d "C:\Users\root\Documents\Pineapple\pager-webui\payload\user\general\pager-webui\www"` in a background PowerShell, open `http://127.0.0.1:8000/`, confirm the login card renders and no 404s in DevTools console (JS files are stubs that don't exist yet — expect 404s for `config.js`/`api.js`/etc. this task; they arrive in Task 12). + +- [ ] **Step 5: Commit** + +```bash +git add payload/user/general/pager-webui/www +git commit -m "feat: SPA shell, dark theme, xterm assets" +``` + +--- + +### Task 12: `config.js`, `api.js`, `app.js` (router, login, shell logic) + +**Files:** +- Create: `payload/user/general/pager-webui/www/js/config.js` +- Create: `payload/user/general/pager-webui/www/js/api.js` +- Create: `payload/user/general/pager-webui/www/js/app.js` + +**Interfaces:** +- Produces: globals `window.PAGER_CONFIG`, `PagerAPI` (`get/post/del/login`, `setBase`, `on401`), `App` (`init`, `route`, `toast`, `showLogin`, `wsUrl`), `Live` (`start`, `onTick`), global `h(...)` DOM helper, `views` (populated in Tasks 13-16), `Term` (Task 17). `App.init()` boots on DOMContentLoaded. + +- [ ] **Step 1: Create `config.js`** + +```js +window.PAGER_CONFIG = { + apiBase: '', + wsBase: '', + terminalWs: '' +}; +``` + +- [ ] **Step 2: Create `api.js`** + +```js +'use strict'; + +const PagerAPI = (() => { + let apiBase = ''; + let on401 = null; + async function request(method, path, body) { + const opts = { method, headers: {}, credentials: 'include' }; + if (body !== undefined) { + opts.headers['Content-Type'] = 'application/json'; + opts.body = JSON.stringify(body); + } + const res = await fetch(apiBase + path, opts); + if (res.status === 401) { + if (on401) on401(); + throw new Error('unauthorized'); + } + const ct = res.headers.get('Content-Type') || ''; + if (ct.indexOf('json') !== -1) { + try { return { status: res.status, data: await res.json() }; } + catch (e) { return { status: res.status, data: null }; } + } + return { status: res.status, data: await res.text() }; + } + return { + setBase: (b) => { apiBase = b; }, + get: (p) => request('GET', p), + post: (p, b) => request('POST', p, b === undefined ? {} : b), + del: (p, b) => request('DELETE', p, b === undefined ? {} : b), + login: async (username, password) => request('POST', '/api/login', { username, password }) + }; +})(); +``` + +- [ ] **Step 3: Create `app.js`** + +```js +'use strict'; + +const App = (() => { + const cfg = window.PAGER_CONFIG || {}; + const API_BASE = cfg.apiBase || ''; + const WS_BASE = (cfg.wsBase || location.origin).replace(/^http/, 'ws'); + const TERMINAL_WS = cfg.terminalWs || ('ws://' + location.hostname + ':1471/api/terminal/openWs'); + + const els = {}; + let currentView = null; + + const routes = { + '#/dashboard': views.dashboard, + '#/pineap': views.pineap, + '#/recon': views.recon, + '#/handshakes': views.handshakes, + '#/payloads': views.payloads, + '#/logs': views.logs, + '#/settings': views.settings + }; + + function init() { + els.login = document.getElementById('login-screen'); + els.app = document.getElementById('app'); + els.content = document.getElementById('content'); + els.toasts = document.getElementById('toast-container'); + + document.getElementById('login-form').addEventListener('submit', (e) => { + e.preventDefault(); + const pw = document.getElementById('login-password').value; + PagerAPI.login('root', pw) + .then(() => { document.getElementById('login-password').value = ''; showApp(); toast('Logged in'); }) + .catch((err) => toast(err.message || 'Login failed', 'error')); + }); + + document.getElementById('terminal-btn').addEventListener('click', () => Term.toggle()); + document.getElementById('terminal-close').addEventListener('click', () => Term.toggle()); + window.addEventListener('hashchange', route); + + PagerAPI.setBase(API_BASE); + PagerAPI.on401 = () => showLogin(); + checkSession(); + } + + function checkSession() { + PagerAPI.get('/api/api_ping') + .then(() => showApp()) + .catch(() => showLogin()); + } + + function showApp() { + els.login.classList.add('hidden'); + els.app.classList.remove('hidden'); + Live.start(); + route(); + } + + function showLogin() { + els.app.classList.add('hidden'); + els.login.classList.remove('hidden'); + } + + function route() { + const hash = location.hash || '#/dashboard'; + const view = routes[hash] || routes['#/dashboard']; + if (currentView && currentView.destroy) currentView.destroy(); + els.content.innerHTML = ''; + currentView = view(els.content); + document.querySelectorAll('#rail a').forEach((a) => + a.classList.toggle('active', a.getAttribute('href') === hash)); + } + + function toast(msg, kind) { + const d = document.createElement('div'); + d.className = 'toast ' + (kind || 'info'); + d.textContent = msg; + els.toasts.appendChild(d); + setTimeout(() => d.remove(), 4000); + } + + return { init, route, toast, showLogin, wsUrl: (p) => WS_BASE + p, terminalWs: TERMINAL_WS }; +})(); + +const Live = (() => { + let ws = null; + const subs = []; + let timer = null; + function start() { + if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return; + try { ws = new WebSocket(App.wsUrl('/api/ws')); } + catch (e) { fallback(); return; } + ws.onmessage = (ev) => { + let msg; + try { msg = JSON.parse(ev.data); } catch (e) { return; } + subs.forEach((fn) => fn(msg)); + updateBar(msg); + }; + ws.onclose = () => { ws = null; clearTimeout(timer); timer = setTimeout(start, 5000); }; + ws.onerror = () => { try { ws.close(); } catch (e) {} }; + } + function fallback() { + clearInterval(timer); + timer = setInterval(async () => { + try { + const r = await PagerAPI.get('/api/status'); + const msg = { type: 'tick', status: r.data, clients: r.data.clients }; + subs.forEach((fn) => fn(msg)); + updateBar(msg); + } catch (e) {} + }, 5000); + } + function updateBar(msg) { + const b = (msg.status || {}).battery || {}; + const n = (msg.clients || []).length; + const el = document.getElementById('live-status'); + if (el) el.textContent = 'BAT ' + (b.level == null ? '--' : b.level + '%' + (b.charging ? '+' : '')) + ' CLIENTS ' + n; + } + return { start, onTick: (fn) => subs.push(fn) }; +})(); + +document.addEventListener('DOMContentLoaded', () => App.init()); +``` + +- [ ] **Step 4: Verify in browser** + +Re-start the static server from Task 11 and load `http://127.0.0.1:8000/`. DevTools console shows a failed `GET /api/api_ping` (401 or 404) — this is expected pre-deploy; the login card must appear. Confirm no JS syntax errors by checking for uncaught exceptions. + +- [ ] **Step 5: Commit** + +```bash +git add payload/user/general/pager-webui/www/js +git commit -m "feat: api client, router, login and live ws client" +``` + +--- + +### Task 13: Dashboard view + +**Files:** +- Create: `payload/user/general/pager-webui/www/js/views.js` (starts with shared helpers + `views.dashboard`; later tasks append to this file) + +**Interfaces:** +- Consumes: `h()` (defined here), `Live.onTick`, `PagerAPI`, `App.toast`. +- Produces: global `views` object; `h(tag, attrs, ...children)` helper; `table(columns, rows)` helper; `fmtTime(ts)`, `fmtDur(secs)`, `badge(on)`, `btn(label, onclk, cls)` helpers used by all later views. + +- [ ] **Step 1: Create `views.js` with helpers + dashboard** + +```js +'use strict'; + +const views = {}; + +const h = (tag, attrs, ...children) => { + const n = document.createElement(tag); + if (attrs) { + for (const k in attrs) { + if (k === 'class') n.className = attrs[k]; + else if (k === 'text') n.textContent = attrs[k]; + else if (k === 'html') n.innerHTML = attrs[k]; + else if (k.startsWith('on')) n.addEventListener(k.slice(2), attrs[k]); + else n.setAttribute(k, attrs[k]); + } + } + for (const c of children) { + if (c == null) continue; + n.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); + } + return n; +}; + +const table = (columns, rows, rowAttrs) => { + const t = h('table', { class: 'tbl' }); + const thead = h('thead'), tr = h('tr'); + columns.forEach((c) => tr.appendChild(h('th', { text: c.label }))); + thead.appendChild(tr); t.appendChild(thead); + const tb = h('tbody'); + (rows || []).forEach((r) => { + const trr = h('tr', rowAttrs ? rowAttrs(r) : {}); + columns.forEach((c) => trr.appendChild(h('td', { text: c.render ? c.render(r) : r[c.key] }))); + tb.appendChild(trr); + }); + t.appendChild(tb); + return t; +}; + +const fmtTime = (ts) => { + if (!ts) return '--'; + const d = new Date(ts * 1000); + return d.toLocaleString(); +}; + +const fmtDur = (secs) => { + if (secs == null) return '--'; + const d = Math.floor(secs / 86400), hh = Math.floor((secs % 86400) / 3600), + mm = Math.floor((secs % 3600) / 60); + return (d ? d + 'd ' : '') + hh + 'h ' + mm + 'm'; +}; + +const badge = (on) => h('span', { class: 'badge ' + (on ? 'on' : 'off'), text: on ? 'ON' : 'OFF' }); + +const btn = (label, onclk, cls) => h('button', { class: cls || '', onclick: onclk, text: label }); + +views.dashboard = (root) => { + const grid = h('div', { class: 'cards' }); + root.appendChild(grid); + const defs = [ + ['battery', 'Battery'], ['clients', 'Clients'], ['aps', 'APs'], + ['handshakes', 'Handshakes'], ['firmware', 'Firmware'], ['uptime', 'Uptime'] + ]; + const cards = {}; + defs.forEach(([k, label]) => { + const card = h('div', { class: 'card' }, + h('div', { class: 'card-label', text: label }), + h('div', { class: 'card-value', text: '--' })); + grid.appendChild(card); + cards[k] = card.querySelector('.card-value'); + }); + const update = (msg) => { + const s = msg.status || {}; + const b = s.battery || {}; + cards.battery.textContent = b.level == null ? '--' : b.level + '%' + (b.charging ? ' +' : ''); + cards.clients.textContent = (msg.clients || []).length; + cards.aps.textContent = (s.wifi || []).length; + if (s.firmware) cards.firmware.textContent = s.firmware; + if (s.uptime != null) cards.uptime.textContent = fmtDur(s.uptime); + }; + Live.onTick(update); + PagerAPI.get('/api/status').then((r) => update({ status: r.data, clients: r.data.clients })); + PagerAPI.get('/api/pineap/handshakes') + .then((r) => { cards.handshakes.textContent = (r.data.files || []).length; }) + .catch(() => {}); + const ifaceBox = h('div', { class: 'section' }, + h('h2', {}, 'Wireless Interfaces'), + h('div', { id: 'iface-table' })); + root.appendChild(ifaceBox); + PagerAPI.get('/api/status').then((r) => { + const wifi = r.data.wifi || []; + document.getElementById('iface-table').appendChild(table( + [{ label: 'Interface', key: 'iface' }, { label: 'Mode', key: 'mode' }, + { label: 'SSID', key: 'ssid' }, { label: 'Channel', key: 'channel' }], wifi)); + }); + return { destroy: () => {} }; +}; +``` + +- [ ] **Step 2: Verify in browser** + +Static-serve `www` from Task 11 (or the dev proxy once it exists), log in against a deployed backend, navigate to Dashboard. Expect 6 cards (battery/clients/APs/handshakes/firmware/uptime) and a Wireless Interfaces table. If no backend is deployed yet, skip the visual check and confirm `views.js` loads without syntax errors (no console exceptions). + +- [ ] **Step 3: Commit** + +```bash +git add payload/user/general/pager-webui/www/js/views.js +git commit -m "feat: dashboard view with live counters" +``` + +--- + +### Task 14: PineAP view + +**Files:** +- Modify: `payload/user/general/pager-webui/www/js/views.js` (append) + +**Interfaces:** +- Consumes: `h`, `table`, `badge`, `btn`, `fmtTime`, `PagerAPI`, `App.toast`. +- Produces: `views.pineap(root)`. Renders: settings toggles (mimic, advertise, collect_probes, collect_handshakes, random_mac, wigle, bands), SSID pool CRUD + start/stop/collect buttons, client filter + SSID filter (mode + list CRUD), clients table with kick. + +- [ ] **Step 1: Append the PineAP view** + +```js +views.pineap = (root) => { + const state = { ssids: [], cMode: '', cEntries: [], sMode: '', sEntries: [], clients: [] }; + + const settingsBox = h('div', { class: 'section' }, h('h2', {}, 'PineAP Settings')); + const poolBox = h('div', { class: 'section' }, h('h2', {}, 'SSID Pool')); + const cfBox = h('div', { class: 'section' }, h('h2', {}, 'Client Filter')); + const sfBox = h('div', { class: 'section' }, h('h2', {}, 'SSID Filter')); + const clBox = h('div', { class: 'section' }, h('h2', {}, 'Connected Clients')); + [settingsBox, poolBox, cfBox, sfBox, clBox].forEach((b) => root.appendChild(b)); + + const settings = {}; + const toggleDefs = [ + ['mimic', 'Mimic'], ['advertise', 'Advertise'], ['collect_probes', 'Collect probes'], + ['collect_handshakes', 'Collect handshakes'], ['random_mac', 'Random MAC'], ['wigle', 'WiGLE'] + ]; + toggleDefs.forEach(([k, label]) => { + const cb = h('input', { type: 'checkbox', id: 'set-' + k }); + const wrap = h('label', { class: 'toggle' }, cb, ' ' + label); + settings[k] = cb; + cb.addEventListener('change', () => saveSettings()); + settingsBox.appendChild(wrap); + }); + const bandsSel = h('select', { id: 'set-bands' }, + h('option', { value: '2.4', text: '2.4 GHz' }), + h('option', { value: '5', text: '5 GHz' }), + h('option', { value: 'dual', text: 'Dual' })); + bandsSel.addEventListener('change', () => saveSettings()); + settingsBox.appendChild(h('label', {}, 'Bands', bandsSel)); + settingsBox.appendChild(h('div', { class: 'row' }, + h('div', {}, btn('Reload config', () => { PagerAPI.post('/api/pineap/settings', {}).then(loadSettings); }, 'ghost')))); + + function saveSettings() { + const body = {}; + toggleDefs.forEach(([k]) => { body[k] = settings[k].checked; }); + body.bands = bandsSel.value; + PagerAPI.post('/api/pineap/settings', body).then(() => App.toast('Settings saved')); + } + function loadSettings() { + PagerAPI.get('/api/pineap/settings').then((r) => { + const s = r.data.settings || {}; + toggleDefs.forEach(([k]) => { settings[k].checked = !!s[k]; }); + bandsSel.value = s.bands || '2.4'; + }); + } + + function renderPool() { + poolBox.innerHTML = ''; + poolBox.appendChild(h('h2', {}, 'SSID Pool')); + const row = h('div', { class: 'row' }, + h('div', {}, h('label', {}, 'SSID', (() => { const i = h('input', { id: 'pool-ssid' }); return i; })())), + h('div', {}, btn('Add', () => { + const v = document.getElementById('pool-ssid').value.trim(); + if (!v) return; + PagerAPI.post('/api/pineap/ssids', { action: 'add', ssid: v }).then((r) => { + state.ssids = r.data.ssids; renderPool(); App.toast('Added'); + }); + })), + h('div', {}, btn('Clear', () => { + PagerAPI.post('/api/pineap/ssids', { action: 'clear' }).then((r) => { + state.ssids = r.data.ssids; renderPool(); + }); + }, 'danger'))); + const actions = h('div', { class: 'row', style: 'margin-top:10px' }, + h('div', {}, btn('Start', () => PagerAPI.post('/api/pineap/ssidpool/start'))), + h('div', {}, btn('Stop', () => PagerAPI.post('/api/pineap/ssidpool/stop'))), + h('div', {}, btn('Collect start', () => PagerAPI.post('/api/pineap/ssidpool/collect_start'))), + h('div', {}, btn('Collect stop', () => PagerAPI.post('/api/pineap/ssidpool/collect_stop')))); + poolBox.appendChild(row); + poolBox.appendChild(actions); + poolBox.appendChild(table( + [{ label: 'SSID', key: 'ssid' }], + state.ssids.map((s) => ({ ssid: s })), + () => ({}))); + document.querySelectorAll('#pool-ssid').forEach((x) => x.value = ''); + } + + function renderFilter(box, kind) { + box.innerHTML = ''; + box.appendChild(h('h2', {}, kind === 'client' ? 'Client Filter' : 'SSID Filter')); + const list = kind === 'client' ? state.cEntries : state.sEntries; + const mode = kind === 'client' ? state.cMode : state.sMode; + const path = '/api/pineap/filters/' + kind; + const modeSel = h('select', { id: 'mode-' + kind }, + h('option', { value: 'allow', text: 'Allow list' }), + h('option', { value: 'deny', text: 'Deny list' }), + h('option', { value: 'off', text: 'Off' })); + modeSel.value = mode; + modeSel.addEventListener('change', () => { + PagerAPI.post(path, { action: 'set_mode', mode: modeSel.value }).then(() => refreshFilters()); + }); + const valueIn = h('input', { id: 'val-' + 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('val-' + kind).value.trim(); + if (!v) return; + PagerAPI.post(path, { action: 'add', value: v }).then(() => { refreshFilters(); App.toast('Added'); }); + })), + h('div', {}, btn('Clear', () => PagerAPI.post(path, { action: 'clear' }).then(refreshFilters), 'danger')))); + box.appendChild(table( + [{ label: kind === 'client' ? 'MAC' : 'SSID', key: 'value' }], + list.map((e) => ({ value: e })), + (r) => ({ onclick: () => { if (confirm('Delete ' + r.value + '?')) PagerAPI.post(path, { action: 'delete', value: r.value }).then(refreshFilters); }, style: 'cursor:pointer' }))); + } + + function refreshFilters() { + PagerAPI.get('/api/pineap/filters/client').then((r) => { state.cMode = r.data.mode; state.cEntries = r.data.entries; renderFilter(cfBox, 'client'); }); + PagerAPI.get('/api/pineap/filters/ssid').then((r) => { state.sMode = r.data.mode; state.sEntries = r.data.entries; renderFilter(sfBox, 'ssid'); }); + } + + function renderClients() { + clBox.innerHTML = ''; + clBox.appendChild(h('h2', {}, 'Connected Clients')); + const refreshBtn = btn('Refresh', () => loadClients(), 'ghost'); + clBox.appendChild(refreshBtn); + clBox.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(loadClients); } }))); + const cols = ['MAC', 'Interface', 'RSSI']; + clBox.querySelectorAll('.tbl th').forEach((th, i) => { if (i >= cols.length) th.textContent = 'Kick'; }); + } + function loadClients() { + PagerAPI.get('/api/pineap/clients').then((r) => { state.clients = r.data.clients; renderClients(); }); + } + + loadSettings(); + renderPool(); + refreshFilters(); + loadClients(); + const iv = setInterval(loadClients, 10000); + return { destroy: () => clearInterval(iv) }; +}; +``` + +Note: the two last columns of the clients table are rendered as rows in `table()`; the header override sets the 4th header to "Kick" after render. If the Pager's client list proves slow under polling, change `10000` to a lower frequency or remove the interval. + +- [ ] **Step 2: Verify in browser** + +Log in, open `#/pineap`. Confirm: settings toggles load from `/api/pineap/settings`; adding an SSID to the pool calls POST and re-renders; filter mode select + add/delete work; clients table shows rows with working Kick confirm. Use the dev proxy against a deployed backend. + +- [ ] **Step 3: Commit** + +```bash +git add payload/user/general/pager-webui/www/js/views.js +git commit -m "feat: pineap view (settings, ssid pool, filters, clients)" +``` + +--- + +### Task 15: Recon + Handshakes views + +**Files:** +- Modify: `payload/user/general/pager-webui/www/js/views.js` (append) + +**Interfaces:** +- Consumes: shared helpers, `PagerAPI`, `App.toast`. +- Produces: `views.recon(root)`, `views.handshakes(root)`. + +- [ ] **Step 1: Append the Recon view** + +```js +views.recon = (root) => { + const listBox = h('div', { class: 'section' }, h('h2', {}, 'Scans')); + const detailBox = h('div', { class: 'section hidden' }, h('h2', {}, 'Scan Detail')); + root.appendChild(listBox); + root.appendChild(detailBox); + + function loadScans() { + PagerAPI.get('/api/recon/scans').then((r) => { + listBox.innerHTML = ''; + listBox.appendChild(h('h2', {}, 'Scans')); + const row = h('div', { class: 'row' }, + h('div', {}, btn('New scan', () => PagerAPI.post('/api/recon/start').then(() => App.toast('Scan started')))), + h('div', {}, btn('Stop', () => PagerAPI.post('/api/recon/stop').then(() => App.toast('Scan stopped')), 'danger')), + h('div', {}, btn('Refresh', loadScans, 'ghost'))); + listBox.appendChild(row); + listBox.appendChild(table( + [{ label: 'ID', key: 'id' }, { label: 'Timestamp', key: 'timestamp' }], + (r.data.scans || []).map((s) => ({ id: s.id, timestamp: fmtTime(s.timestamp) })), + (s) => ({ style: 'cursor:pointer', onclick: () => loadDetail(s.id) }))); + }); + } + + function loadDetail(id) { + PagerAPI.get('/api/recon/scans/' + id).then((r) => { + detailBox.classList.remove('hidden'); + detailBox.innerHTML = ''; + detailBox.appendChild(h('h2', {}, 'Scan #' + id)); + const aps = r.data.aps || [], hs = r.data.handshakes || []; + detailBox.appendChild(h('h3', { style: 'margin:8px 0 4px' }, 'APs (' + aps.length + ')')); + detailBox.appendChild(table( + [{ label: 'BSSID', key: 'bssid' }, { label: 'SSID', key: 'ssid' }], + aps.map((a) => ({ bssid: a.bssid || a.mac || a.essid || a.wifi_device || '--', ssid: a.ssid || '--' })))); + detailBox.appendChild(h('h3', { style: 'margin:8px 0 4px' }, 'Handshakes (' + hs.length + ')')); + detailBox.appendChild(table( + [{ label: 'BSSID', key: 'bssid' }, { label: 'Client', key: 'client' }], + hs.map((x) => ({ bssid: x.bssid || x.ap_mac || '--', client: x.client || x.client_mac || '--' })))); + }); + } + + loadScans(); + return { destroy: () => {} }; +}; +``` + +- [ ] **Step 2: Append the Handshakes view** + +```js +views.handshakes = (root) => { + const box = h('div', { class: 'section' }, h('h2', {}, 'Handshakes')); + root.appendChild(box); + + function load() { + PagerAPI.get('/api/pineap/handshakes').then((r) => { + box.innerHTML = ''; + box.appendChild(h('h2', {}, 'Handshakes')); + const row = 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'))); + box.appendChild(row); + box.appendChild(table( + [{ label: 'File', key: 'name' }, { label: 'Size', key: 'size' }, + { label: 'Modified', key: 'mtime' }, { label: '', render: () => '' }], + (r.data.files || []).map((f) => ({ name: f.name, size: f.size, mtime: fmtTime(f.mtime) })), + (f) => ({ style: 'cursor:pointer', onclick: () => { if (confirm('Delete ' + f.name + '?')) PagerAPI.del('/api/pineap/handshakes', { name: f.name }).then(load); } }))); + box.querySelectorAll('.tbl th')[3].textContent = 'Delete'; + }); + } + + load(); + return { destroy: () => {} }; +}; +``` + +Note: `App.apiBase` must be exposed for the zip download link. In Task 12, `App` returns `wsUrl` and `terminalWs` but not `apiBase`. Update `app.js` return to include `apiBase: API_BASE` (one-line change in this task) so `App.apiBase + '/api/loot/zip'` resolves to the correct origin. + +- [ ] **Step 3: Update `app.js` to expose `apiBase`** + +In `app.js`, change the `return` of `App` to include `apiBase: API_BASE`: + +```js + return { init, route, toast, showLogin, wsUrl: (p) => WS_BASE + p, terminalWs: TERMINAL_WS, apiBase: API_BASE }; +``` + +- [ ] **Step 4: Verify in browser** + +`#/recon`: scans table lists rows; clicking a row shows AP + handshake detail; New scan/Stop buttons POST. `#/handshakes`: files listed with sizes; Download triggers a zip download; Delete confirms and removes; Archive POSTs. The `.tbl th` 4th header is set to "Delete". + +- [ ] **Step 5: Commit** + +```bash +git add payload/user/general/pager-webui/www/js/views.js payload/user/general/pager-webui/www/js/app.js +git commit -m "feat: recon and handshakes views" +``` + +--- + +### Task 16: Payloads, Logs, Settings views + +**Files:** +- Modify: `payload/user/general/pager-webui/www/js/views.js` (append) + +**Interfaces:** +- Consumes: shared helpers, `PagerAPI`, `App.toast`. +- Produces: `views.payloads(root)`, `views.logs(root)`, `views.settings(root)`. + +- [ ] **Step 1: Append the Payloads view** + +```js +views.payloads = (root) => { + const box = h('div', { class: 'section' }, h('h2', {}, 'Payloads')); + root.appendChild(box); + + function load() { + PagerAPI.get('/api/payloads/index').then((r) => { + box.innerHTML = ''; + box.appendChild(h('h2', {}, 'Payloads')); + box.appendChild(btn('Refresh', load, 'ghost')); + const payloads = r.data.payloads || r.data || []; + box.appendChild(table( + [{ label: 'Name', key: 'name' }, { label: 'Description', key: 'desc' }, + { label: '', render: () => '' }], + payloads.map((p) => ({ name: p.name || p.title || p.key, desc: p.desc || '', key: p.key || p.id || p.name })), + (p) => ({ + onclick: (e) => { e.stopPropagation(); } + }))); + const rows = box.querySelectorAll('.tbl tbody tr'); + rows.forEach((tr, i) => { + const p = payloads[i]; + const td = h('td'); + td.appendChild(btn('Install', () => PagerAPI.post('/api/payloads/install', { key: p.key }).then(() => App.toast('Installing')))); + td.appendChild(btn('Remove', () => PagerAPI.post('/api/payloads/remove', { key: p.key }).then(() => App.toast('Removed')), 'danger')); + tr.appendChild(td); + }); + }); + } + + load(); + return { destroy: () => {} }; +}; +``` + +- [ ] **Step 2: Append the Logs view** + +```js +views.logs = (root) => { + const sysBox = h('div', { class: 'section' }, h('h2', {}, 'System Log')); + const pineBox = h('div', { class: 'section' }, h('h2', {}, 'PineAP Log')); + root.appendChild(sysBox); + root.appendChild(pineBox); + + function render(box, kind) { + PagerAPI.get('/api/logging/' + kind + '?lines=200').then((r) => { + const oldPre = box.querySelector('pre'); + const pre = h('pre', { class: 'logs' }, (r.data.lines || []).join('\n')); + box.innerHTML = ''; + box.appendChild(h('h2', {}, kind === 'system' ? 'System Log' : 'PineAP Log')); + box.appendChild(btn('Refresh', () => render(box, kind), 'ghost')); + box.appendChild(pre); + }).catch(() => {}); + } + + render(sysBox, 'system'); + render(pineBox, 'pineap'); + const iv = setInterval(() => { render(sysBox, 'system'); }, 10000); + return { destroy: () => clearInterval(iv) }; +}; +``` + +- [ ] **Step 3: Append the Settings view** + +```js +views.settings = (root) => { + const general = h('div', { class: 'section' }, h('h2', {}, 'General')); + const ntpBox = h('div', { class: 'section' }, h('h2', {}, 'Time (NTP)')); + const passBox = h('div', { class: 'section' }, h('h2', {}, 'Device Password')); + const webui = h('div', { class: 'section' }, h('h2', {}, 'WebUI Preferences')); + root.appendChild(general); + root.appendChild(ntpBox); + root.appendChild(passBox); + root.appendChild(webui); + + PagerAPI.get('/api/status').then((r) => { + const s = r.data; + general.appendChild(h('p', {}, 'Firmware: ', h('code', { text: s.firmware || '--' }))); + general.appendChild(h('p', {}, 'Model: ', h('code', { text: 'WiFi Pineapple Pager' }))); + general.appendChild(h('p', {}, 'Uptime: ', h('code', { text: fmtDur(s.uptime) }))); + general.appendChild(h('p', {}, 'Disk: ', h('code', { text: s.disk ? (s.disk.used / 1048576).toFixed(1) + ' / ' + (s.disk.size / 1048576).toFixed(1) + ' GB' : '--' }))); + }); + + const hnInput = h('input', { id: 'set-hostname' }); + PagerAPI.get('/api/settings/hostname').then((r) => { hnInput.value = r.data.hostname || ''; }); + general.appendChild(h('div', { class: 'row' }, + h('div', {}, h('label', {}, 'Hostname', hnInput)), + h('div', {}, btn('Save', () => PagerAPI.post('/api/settings/hostname', { hostname: hnInput.value }).then(() => App.toast('Saved')))))); + + PagerAPI.get('/api/settings/service').then((r) => { + general.appendChild(h('p', {}, 'WebUI service: ', + h('span', { class: 'badge ' + (r.data.running ? 'on' : 'off'), text: r.data.running ? 'RUNNING' : 'STOPPED' }), + ' ', h('span', { class: 'muted', text: r.data.background ? '(background mode)' : '(foreground mode)' }))); + }); + + const ntpEnabled = h('input', { type: 'checkbox', id: 'ntp-enabled' }); + const ntpServers = h('input', { id: 'ntp-servers' }); + PagerAPI.get('/api/settings/ntp').then((r) => { + ntpEnabled.checked = !!r.data.enabled; + ntpServers.value = (r.data.servers || []).join(', '); + }); + ntpBox.appendChild(h('label', { class: 'toggle' }, ntpEnabled, ' Enabled')); + ntpBox.appendChild(h('label', {}, 'NTP servers (comma separated)', ntpServers)); + ntpBox.appendChild(btn('Save', () => PagerAPI.post('/api/settings/ntp', { + enabled: ntpEnabled.checked, + servers: ntpServers.value.split(',').map((s) => s.trim()).filter(Boolean) + }).then(() => App.toast('NTP saved')), 'ghost')); + + const newPw = h('input', { type: 'password', id: 'set-password' }); + passBox.appendChild(h('label', {}, 'New device password', newPw)); + passBox.appendChild(btn('Change password', () => { + if (newPw.value.length < 4) { App.toast('Password too short', 'error'); return; } + PagerAPI.post('/api/settings/password', { password: newPw.value }).then(() => { App.toast('Password changed'); newPw.value = ''; }); + }, 'danger')); + + const pollInput = h('input', { type: 'number', id: 'pref-poll', min: '2', max: '120' }); + pollInput.value = localStorage.getItem('pw-poll') || '5'; + pollInput.addEventListener('change', () => { + localStorage.setItem('pw-poll', pollInput.value); + App.toast('Poll interval saved (applies on next load)'); + }); + const accentSel = h('select', { id: 'pref-accent' }, + h('option', { value: '#00d4aa', text: 'Teal' }), + h('option', { value: '#ff6b6b', text: 'Red' }), + h('option', { value: '#58a6ff', text: 'Blue' })); + accentSel.value = localStorage.getItem('pw-accent') || '#00d4aa'; + accentSel.addEventListener('change', () => { + localStorage.setItem('pw-accent', accentSel.value); + document.documentElement.style.setProperty('--accent', accentSel.value); + }); + document.documentElement.style.setProperty('--accent', accentSel.value); + webui.appendChild(h('label', {}, 'Poll interval (s)', pollInput)); + webui.appendChild(h('label', {}, 'Accent color', accentSel)); + + return { destroy: () => {} }; +}; +``` + +- [ ] **Step 4: Verify in browser** + +`#/payloads`: portal index renders with per-row Install/Remove buttons and a Refresh button. `#/logs`: both logs render with refresh; auto-refresh every 10s. `#/settings`: hostname loads and saves; NTP toggles + server list; password change posts; accent color applies live; poll interval persists to localStorage. + +- [ ] **Step 5: Commit** + +```bash +git add payload/user/general/pager-webui/www/js/views.js +git commit -m "feat: payloads, logs and settings views" +``` + +--- + +### Task 17: Terminal panel (xterm.js, bottom-docked) + +**Files:** +- Create: `payload/user/general/pager-webui/www/js/terminal.js` + +**Interfaces:** +- Consumes: `App.terminalWs` (default `ws://:1471/api/terminal/openWs`), `App.toast`, global `Terminal` and `FitAddon` from the xterm bundles. +- Produces: global `Term` with `toggle()`. Wired in `app.js` (`#terminal-btn` and `#terminal-close`). + +- [ ] **Step 1: Create `terminal.js`** + +```js +'use strict'; + +const Term = (() => { + let term = null; + let fitAddon = null; + let ws = null; + let panel = null; + + function ensure() { + if (term) return; + panel = document.getElementById('terminal-panel'); + term = new Terminal({ cursorBlink: true, scrollback: 2000, cols: 80, rows: 24 }); + fitAddon = new FitAddon.FitAddon(); + term.loadAddon(fitAddon); + term.open(document.getElementById('terminal')); + try { fitAddon.fit(); } catch (e) {} + term.onData((d) => { if (ws && ws.readyState === WebSocket.OPEN) ws.send(d); }); + } + + function toggle() { + ensure(); + if (panel.classList.contains('hidden')) { + panel.classList.remove('hidden'); + document.getElementById('terminal-btn').classList.add('active'); + try { fitAddon.fit(); } catch (e) {} + connect(); + } else { + panel.classList.add('hidden'); + document.getElementById('terminal-btn').classList.remove('active'); + disconnect(); + } + } + + function connect() { + if (ws) return; + if (term) term.reset(); + try { + ws = new WebSocket(App.terminalWs); + } catch (e) { + term.writeln('\r\n[cannot reach daemon terminal: ' + e.message + ']'); + return; + } + ws.onmessage = (ev) => { + if (typeof ev.data === 'string') term.write(ev.data); + else ev.data.text().then((t) => term.write(t)); + }; + ws.onclose = () => { ws = null; if (term) term.writeln('\r\n[connection closed]'); }; + ws.onerror = () => { try { ws.close(); } catch (e) {} }; + } + + function disconnect() { + if (ws) { try { ws.close(); } catch (e) {} ws = null; } + } + + window.addEventListener('resize', () => { + if (fitAddon && panel && !panel.classList.contains('hidden')) { + try { fitAddon.fit(); } catch (e) {} + } + }); + + return { toggle }; +})(); +``` + +- [ ] **Step 2: Verify terminal I/O against the daemon** + +Deploy the backend (Task 19) so `server.py` runs at `:8080`. Log in to the SPA (the login sets the `AUTH_` cookie for host `172.16.52.1`, which the daemon's WS handshake on `:1471` also receives). Click **Terminal**. Expect a live shell: `echo hi` returns `hi`; resize the window and confirm the panel adapts (if the daemon ignores resize, xterm stays 80x24 — acceptable per spec §10, do not chase it). + +If the direct connection fails (e.g. browser blocks mixed content or cookie not sent), verify the relay fallback: temporarily edit `config.js` to set `terminalWs: ''` and change `app.js` `TERMINAL_WS` computation to use `ws://:8080/api/terminal/openWs`, then confirm I/O still works through `server.py`'s relay. + +- [ ] **Step 3: Commit** + +```bash +git add payload/user/general/pager-webui/www/js/terminal.js +git commit -m "feat: docked xterm terminal panel" +``` + +--- +### Task 18: `payload.sh` installer + `pagerwebui.init` + +**Files:** +- Create: `payload/user/general/pager-webui/payload.sh` +- Create: `payload/user/general/pager-webui/pagerwebui.init` + +**Interfaces:** +- Consumes: nothing from earlier tasks except file layout. +- Produces: the portal-runner entrypoint (`payload.sh`) and procd init script, mirroring the `nautilus` pattern (spec §4). Installed init path: `/etc/init.d/pagerwebui`. + +- [ ] **Step 1: Create `payload.sh`** + +```bash +#!/bin/bash +# Title: Pager WebUI +# Description: Mark VII-style web management UI for the WiFi Pineapple Pager +# Author: Hak5 Community +# Version: 0.1.0 +# Firmware: Pineapple Pager 24.10.1 + +SCRIPT_SOURCE="${BASH_SOURCE[0]:-$0}" +SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_SOURCE")" 2>/dev/null && pwd)" +[ -f "$SCRIPT_DIR/server.py" ] || SCRIPT_DIR="/root/payloads/user/general/pager-webui" +[ -f "$SCRIPT_DIR/server.py" ] || SCRIPT_DIR="/mmc/root/payloads/user/general/pager-webui" +PORT=8080 +PID_FILE="/tmp/pagerwebui.pid" +INIT_SCRIPT="/etc/init.d/pagerwebui" + +user_confirmed() { + [ "$1" = "true" ] || [ "$1" = "$DUCKYSCRIPT_USER_CONFIRMED" ] +} + +get_pager_ip() { + for iface in br-lan eth0 wlan0 usb0; do + IP=$(ip -4 addr show "$iface" 2>/dev/null | awk '/inet / {print $2}' | cut -d'/' -f1 | head -1) + [ -n "$IP" ] && echo "$IP" && return + done + echo "172.16.52.1" +} + +LOG "cyan" "+---------------------------+" +LOG "cyan" "| Pager WebUI v0.1 |" +LOG "cyan" "+---------------------------+" + +if ! command -v python3 >/dev/null 2>&1; then + LOG "red" "python3 required (present on current firmware)" + exit 1 +fi + +if [ -f "$INIT_SCRIPT" ] && "$INIT_SCRIPT" running 2>/dev/null; then + PAGER_IP=$(get_pager_ip) + LOG "green" "Pager WebUI service is running" + LOG "green" "http://$PAGER_IP:$PORT" + resp=$(CONFIRMATION_DIALOG "Stop service?") + if user_confirmed "$resp"; then + LOG "yellow" "Stopping service..." + "$INIT_SCRIPT" stop + "$INIT_SCRIPT" disable + rm -f "$INIT_SCRIPT" + LOG "cyan" "Service stopped" + fi + exit 0 +fi + +AUTO_MODE=$(PAYLOAD_GET_CONFIG pager_webui auto_mode 2>/dev/null) +RUN_MODE=$(PAYLOAD_GET_CONFIG pager_webui run_mode 2>/dev/null) + +if [ "$AUTO_MODE" = "true" ]; then + if [ "$RUN_MODE" = "background" ]; then + resp="true" + LOG "cyan" "Auto-starting background mode..." + else + resp="" + LOG "cyan" "Auto-starting foreground mode..." + fi +else + resp=$(CONFIRMATION_DIALOG "Run as background service?") +fi + +if user_confirmed "$resp"; then + LOG "cyan" "Starting as background service..." + [ ! -f "$SCRIPT_DIR/server.py" ] && { LOG "red" "server.py not found!"; exit 1; } + cp "$SCRIPT_DIR/pagerwebui.init" "$INIT_SCRIPT" + chmod +x "$INIT_SCRIPT" + "$INIT_SCRIPT" enable + "$INIT_SCRIPT" start + sleep 1 + PAGER_IP=$(get_pager_ip) + LOG "green" "Service started!" + LOG "green" "http://$PAGER_IP:$PORT" + LOG "cyan" "Re-run payload to stop" + sleep 3 + exit 0 +fi + +LOG "cyan" "Starting foreground mode..." +cleanup() { + LOG "yellow" "Stopping Pager WebUI..." + [ -f "$PID_FILE" ] && kill "$(cat "$PID_FILE")" 2>/dev/null + rm -f "$PID_FILE" + LOG "cyan" "Stopped." +} +trap cleanup EXIT INT TERM + +[ ! -f "$SCRIPT_DIR/server.py" ] && { LOG "red" "server.py not found!"; exit 1; } +python3 "$SCRIPT_DIR/server.py" >/tmp/pagerwebui.log 2>&1 & +echo $! > "$PID_FILE" +sleep 1 + +PAGER_IP=$(get_pager_ip) +if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then + LOG "green" "http://$PAGER_IP:$PORT" + LOG "" + LOG "magenta" "Press B to stop" + while true; do + BUTTON=$(WAIT_FOR_INPUT) + if [ "$BUTTON" = "B" ] || [ "$BUTTON" = "Escape" ]; then + break + fi + done +else + LOG "red" "Failed to start server! Check /tmp/pagerwebui.log" + exit 1 +fi +``` + +- [ ] **Step 2: Create `pagerwebui.init`** + +```sh +#!/bin/sh /etc/rc.common + +START=99 +STOP=10 +USE_PROCD=1 + +PAGER_WEBUI_DIR="/root/payloads/user/general/pager-webui" + +start_service() { + [ -f "$PAGER_WEBUI_DIR/server.py" ] || return 1 + chmod -R 755 "$PAGER_WEBUI_DIR" 2>/dev/null + procd_open_instance pagerwebui + procd_set_param command /usr/bin/python3 "$PAGER_WEBUI_DIR/server.py" + procd_set_param respawn + procd_set_param stdout 1 + procd_set_param stderr 1 + procd_set_param pidfile /tmp/pagerwebui.pid + procd_close_instance +} + +stop_service() { + rm -f /tmp/pagerwebui.pid +} +``` + +- [ ] **Step 3: Static checks** + +Verify both files have no CRLF line endings (OpenWRT `/bin/sh` breaks on `\r\n`). In PowerShell: + +```powershell +$files = "payload\user\general\pager-webui\payload.sh","payload\user\general\pager-webui\pagerwebui.init" +foreach ($f in $files) { + $bytes = [IO.File]::ReadAllBytes((Join-Path "C:\Users\root\Documents\Pineapple\pager-webui" $f)) + $crlf = ($bytes | Where-Object { $_ -eq 13 }).Count + if ($crlf -gt 0) { Write-Host "$f has $crlf CR bytes - converting"; $t = [IO.File]::ReadAllText((Join-Path "C:\Users\root\Documents\Pineapple\pager-webui" $f)); $t = $t -replace "`r`n","`n"; [IO.File]::WriteAllText((Join-Path "C:\Users\root\Documents\Pineapple\pager-webui" $f), $t) } +} +``` + +Then run `bash -n payload.sh` locally (Git Bash/WSL) if available; otherwise note syntax check will happen on-device with `sh -n`. + +- [ ] **Step 4: Commit** + +```bash +git add payload/user/general/pager-webui/payload.sh payload/user/general/pager-webui/pagerwebui.init +git commit -m "feat: payload installer and procd init script" +``` + +--- + +### Task 19: `_hak5_manifest.json` + `scripts/deploy.ps1` + +**Files:** +- Create: `payload/user/general/pager-webui/_hak5_manifest.json` +- Create: `scripts/deploy.ps1` + +**Interfaces:** +- Consumes: the payload dir layout; `server.py` + `www`. +- Produces: `scripts/deploy.ps1` which builds `build/pager-webui/payload-.zip` (containing `user/general/pager-webui/**`), writes `build/pager-webui/_hak5_manifest.json` with `time`/`last_hash`/`zip`, uploads to `/tmp` on the Pager, extracts to `/root/payloads/`, `chmod +x`s `payload.sh`, and (unless `-NoPortalRefresh`) refreshes the daemon portal index so it appears in the Virtual Pager portal view. + +- [ ] **Step 1: Create `_hak5_manifest.json` (template)** + +```json +{ + "name": "pager-webui", + "title": "Pager WebUI", + "version": "0.1.0", + "author": "Hak5 Community", + "description": "Mark VII-style web management UI for the WiFi Pineapple Pager", + "category": "general", + "firmware": "Pineapple Pager 24.10.1", + "path": "user/general/pager-webui", + "time": "", + "last_hash": "", + "zip": "" +} +``` + +- [ ] **Step 2: Create `scripts/deploy.ps1`** + +```powershell +param( + [string]$PagerHost = "172.16.52.1", + [string]$User = "root", + [string]$Password = "", + [string]$SshKey = "", + [string]$BuildDir = "", + [switch]$NoPortalRefresh +) + +$ErrorActionPreference = "Stop" +$Root = Split-Path -Parent $PSScriptRoot +$PayloadKey = "pager-webui" +$PayloadDir = Join-Path $Root "payload\user\general\$PayloadKey" +if (-not (Test-Path $PayloadDir)) { throw "Payload dir not found: $PayloadDir" } + +if (-not $BuildDir) { $BuildDir = Join-Path $Root "build" } +$OutDir = Join-Path $BuildDir $PayloadKey +New-Item -ItemType Directory -Force -Path $OutDir | Out-Null + +# --- 1. Stage payload tree ------------------------------------------------- +$Stage = Join-Path $OutDir "stage" +if (Test-Path $Stage) { Remove-Item -Recurse -Force $Stage } +New-Item -ItemType Directory -Force -Path (Join-Path $Stage "user\general") | Out-Null +Copy-Item -Recurse $PayloadDir (Join-Path $Stage "user\general\$PayloadKey") + +# --- 2. Build zip (portal format: payload-.zip) ----------------------- +$b64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($PayloadKey)).TrimEnd('=').Replace('+','-').Replace('/','_') +$ZipName = "payload-$b64.zip" +$ZipPath = Join-Path $OutDir $ZipName +if (Test-Path $ZipPath) { Remove-Item -Force $ZipPath } + +Add-Type -AssemblyName System.IO.Compression +$zip = New-Object System.IO.Compression.ZipArchive([IO.File]::Open($ZipPath, 'Create'), [IO.Compression.ZipArchiveMode]::Create) +try { + Get-ChildItem -Recurse -File $Stage | ForEach-Object { + $rel = $_.FullName.Substring($Stage.Length + 1).Replace('\', '/') + $entry = $zip.CreateEntry($rel, [IO.Compression.CompressionLevel]::Optimal) + $es = $entry.Open() + $bytes = [IO.File]::ReadAllBytes($_.FullName) + $es.Write($bytes, 0, $bytes.Length) + $es.Close() + } +} finally { $zip.Dispose() } + +# --- 3. Manifest with generated fields ------------------------------------ +$hash = (Get-FileHash -Algorithm SHA256 $ZipPath).Hash.ToLower() +$manifest = @{ + name = $PayloadKey + title = "Pager WebUI" + version = "0.1.0" + author = "Hak5 Community" + description = "Mark VII-style web management UI for the WiFi Pineapple Pager" + category = "general" + firmware = "Pineapple Pager 24.10.1" + path = "user/general/$PayloadKey" + time = [int64]([DateTimeOffset]::UtcNow.ToUnixTimeSeconds()) + last_hash = $hash + zip = $ZipName +} | ConvertTo-Json +Set-Content -Path (Join-Path $OutDir "_hak5_manifest.json") -Value $manifest -Encoding ascii +Write-Host "Built: $ZipPath" + +# --- 4. Credentials / transport ------------------------------------------- +if ($SshKey) { + $sshBase = "$User@$PagerHost" + $scp = "scp -i `"$SshKey`"" + $ssh = "ssh -i `"$SshKey`"" +} elseif (Get-Command sshpass -ErrorAction SilentlyContinue) { + if (-not $Password) { $Password = Read-Host -AsSecureString "Pager root password"; $Password = [Runtime.InteropServices.Marshal]::PtrToStringAuto([Runtime.InteropServices.Marshal]::SecureStringToBSTR($Password)) } + $sshBase = "$User@$PagerHost" + $scp = "sshpass -p `"$Password`" scp" + $ssh = "sshpass -p `"$Password`" ssh" +} else { + Write-Host "`nNo sshpass or -SshKey found. Run these manually (password prompts appear):" + Write-Host " scp `"$ZipPath`" $User@$PagerHost:/tmp/" + Write-Host " ssh $User@$PagerHost `"cd /root/payloads && python3 -m zipfile -e /tmp/$ZipName . && chmod +x user/general/$PayloadKey/payload.sh && rm -f /tmp/$ZipName`"" + Write-Host "Then re-run this script with -SshKey, or install sshpass." + exit 1 +} + +# --- 5. Upload + extract on device ---------------------------------------- +& cmd /c "$scp `"$ZipPath`" $sshBase:/tmp/" | Out-Null +if ($LASTEXITCODE -ne 0) { throw "SCP failed" } + +$remoteCmd = "cd /root/payloads && rm -rf user/general/$PayloadKey && python3 -m zipfile -e /tmp/$ZipName . && chmod +x user/general/$PayloadKey/payload.sh && chmod -R 755 user/general/$PayloadKey/www && rm -f /tmp/$ZipName && echo EXTRACT_OK" +& cmd /c "$ssh $sshBase `"$remoteCmd`"" +if ($LASTEXITCODE -ne 0) { throw "Remote extraction failed" } +Write-Host "Installed to /root/payloads/user/general/$PayloadKey/" + +# --- 6. Portal refresh (best-effort) -------------------------------------- +if (-not $NoPortalRefresh) { + if (-not $Password) { + Write-Host "Skipping portal refresh (no password supplied). Run the payload from the on-device menu to verify." + } else { + $tokenJson = & cmd /c "$ssh $sshBase `"curl -s -X POST http://127.0.0.1:1471/api/login -d '{\"username\":\"root\",\"password\":\"$Password\"}'`"" + $token = ($tokenJson | ConvertFrom-Json).token + if ($token) { + & cmd /c "$ssh $sshBase `"curl -s -X POST http://127.0.0.1:1471/api/payloads/portal/refresh -H 'Authorization: Bearer $token'`"" | Out-Null + Write-Host "Portal refreshed. The payload should appear in the on-device Payloads menu / Virtual Pager portal." + } else { + Write-Host "Login to portal refresh failed; the payload is installed as a directory - run it from the menu." + } + } +} +Write-Host "Deploy complete. Run payload.sh from the Pager menu, then browse http://$PagerHost:8080/" +``` + +Note: `chmod` is applied on-device because `System.IO.Compression` does not preserve Unix mode bits. This is also why `-NoPortalRefresh` exists: it lets you skip the daemon login when you only want a file update. + +- [ ] **Step 3: Verify the build (no device needed)** + +```powershell +& ".\scripts\deploy.ps1" -BuildDir "C:\Users\root\Documents\Pineapple\pager-webui\build\test" -NoPortalRefresh +``` + +Since no `sshpass`/`-SshKey` is passed, the script should print the manual scp/ssh commands and exit before upload. Confirm `build\test\pager-webui\payload-*.zip` and `_hak5_manifest.json` exist and the zip contains `user/general/pager-webui/payload.sh` (open in Explorer or `tar -tf`). + +- [ ] **Step 4: On-device smoke test (first real deploy)** + +Run with `-SshKey` (or sshpass) against the Pager. Then on the device menu run the payload in background mode; `curl http://172.16.52.1:8080/api/api_ping` returns 401 (auth required) and `curl http://172.16.52.1:8080/` returns `index.html`. Login via the browser and walk each page (spec §8 on-device smoke tests). + +- [ ] **Step 5: Commit** + +```bash +git add payload/user/general/pager-webui/_hak5_manifest.json scripts/deploy.ps1 +git commit -m "feat: payload manifest and deploy script" +``` + +--- + +### Task 20: Dev loop (`scripts/dev.ps1` + `scripts/dev_proxy.py`) + README + +**Files:** +- Create: `scripts/dev.ps1` +- Create: `scripts/dev_proxy.py` +- Create: `README.md` + +**Interfaces:** +- Consumes: the `www` tree; deployed backend on the Pager at `:8080`. +- Produces: the local dev loop described in spec §8, and project documentation including the recovery drill. + +- [ ] **Step 1: Create `scripts/dev_proxy.py`** + +```python +#!/usr/bin/env python3 +"""Local dev proxy: serves www/ statically and proxies /api/* to the Pager.""" +import argparse +import http.server +import urllib.request +import urllib.error + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument('--pager-host', default='172.16.52.1') + ap.add_argument('--pager-port', type=int, default=8080) + ap.add_argument('--port', type=int, default=8000) + ap.add_argument('--www', default='payload/user/general/pager-webui/www') + args = ap.parse_args() + api_target = 'http://%s:%d' % (args.pager_host, args.pager_port) + + class Proxy(http.server.SimpleHTTPRequestHandler): + def __init__(self, *a, **kw): + super().__init__(*a, directory=args.www, **kw) + + def log_message(self, fmt, *a): + pass + + def _serve_dev_config(self): + body = ('window.PAGER_CONFIG = { apiBase: "", wsBase: "", ' + 'terminalWs: "ws://%s:1471/api/terminal/openWs" };\n' % args.pager_host).encode() + self.send_response(200) + self.send_header('Content-Type', 'text/javascript') + self.send_header('Content-Length', str(len(body))) + self.send_header('Cache-Control', 'no-cache') + self.end_headers() + self.wfile.write(body) + return True + + def _proxy(self, method): + body = None + if self.headers.get('Content-Length'): + body = self.rfile.read(int(self.headers['Content-Length'])) + req = urllib.request.Request(api_target + self.path, data=body, method=method) + if self.headers.get('Cookie'): + req.add_header('Cookie', self.headers['Cookie']) + req.add_header('Content-Type', self.headers.get('Content-Type', 'application/json')) + try: + with urllib.request.urlopen(req, timeout=25) as r: + data = r.read() + self.send_response(r.status) + self.send_header('Content-Type', r.headers.get('Content-Type', 'application/octet-stream')) + self.send_header('Content-Length', str(len(data))) + self.end_headers() + self.wfile.write(data) + except urllib.error.HTTPError as e: + data = e.read() + self.send_response(e.code) + self.send_header('Content-Type', e.headers.get('Content-Type', 'application/json')) + self.send_header('Content-Length', str(len(data))) + self.end_headers() + self.wfile.write(data) + + def do_GET(self): + if self.path == '/js/config.js': + return self._serve_dev_config() + if self.path.startswith('/api/'): + return self._proxy('GET') + return super().do_GET() + + def do_POST(self): + return self._proxy('POST') if self.path.startswith('/api/') else self.send_error(404) + + def do_DELETE(self): + return self._proxy('DELETE') if self.path.startswith('/api/') else self.send_error(404) + + http.server.ThreadingHTTPServer(('127.0.0.1', args.port), Proxy).serve_forever() + + +if __name__ == '__main__': + main() +``` + +Note: `/api/ws` is HTTP-only in the proxy, so the live WS from the dev origin falls back to the 5s polling path in `Live` (by design). The terminal uses the real Pager WS directly via the generated `config.js` `terminalWs`. + +- [ ] **Step 2: Create `scripts/dev.ps1`** + +```powershell +param( + [string]$PagerHost = "172.16.52.1", + [int]$Port = 8000, + [switch]$Tunnel +) + +$ErrorActionPreference = "Stop" +$Root = Split-Path -Parent $PSScriptRoot +$Python = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe" +if (-not (Test-Path $Python)) { throw "Python 3.11 not found. Install with: winget install Python.Python.3.11" } + +if ($Tunnel) { + Write-Host "Opening SSH tunnel 1471 -> 127.0.0.1:1471 (for direct terminal WS). Close the ssh window to stop it." + Start-Process ssh -ArgumentList "-N","-L","1471:127.0.0.1:1471","root@$PagerHost" -WindowStyle Minimized +} + +Write-Host "Dev server: http://127.0.0.1:$Port (API proxied to http://$PagerHost:8080)" +Write-Host "Requires the backend deployed: scripts\deploy.ps1" +& $Python "$PSScriptRoot\dev_proxy.py" --pager-host $PagerHost --port $Port --www "$Root\payload\user\general\pager-webui\www" +``` + +- [ ] **Step 3: Create `README.md`** + +```markdown +# Pager WebUI + +A Mark VII-style web management UI that runs **on the WiFi Pineapple Pager** at +`http://172.16.52.1:8080/`. Packaged as a Payload-Portal-installable payload. + +Features: Dashboard (live), PineAP (settings, SSID pool, filters, clients/kick), +Recon (scans from `recon.db`), Handshakes/Loot, Payloads (portal), Logs, +Settings (hostname/NTP/password/prefs), and a bottom-docked xterm terminal. + +## Requirements + +- WiFi Pineapple Pager, firmware `Pineapple Pager 24.10.1` +- `python3` on the device (present on current firmware) +- Windows dev box with Python 3.11 (`winget install Python.Python.3.11`) + +## Install (sideload) + +```powershell +# deploy.ps1 needs either an SSH key or sshpass for password auth: +& .\scripts\deploy.ps1 -SshKey "$HOME\.ssh\pager_key" +# or set up a key and add it: ssh-copy-id root@172.16.52.1 +``` + +This builds `build\pager-webui\payload-.zip`, uploads it, extracts it to +`/root/payloads/user/general/pager-webui/`, and refreshes the portal index. + +Then on the Pager menu, run **Pager WebUI**: +- **Yes** to "Run as background service?" -> procd service (respawns on crash, + boot-persistent via rc.d symlinks). +- **No** -> foreground mode; press **B** to stop. +- Re-run the payload while running to **Stop** the service. +- `PAYLOAD_GET_CONFIG pager_webui auto_mode/run_mode` skip the prompt. + +Browse `http://172.16.52.1:8080/` and log in with the device password. + +## Uninstall / recovery + +Re-run the payload and confirm "Stop service?" (stops, disables, removes the +init script), then delete the payload directory via the portal or: +`rm -rf /root/payloads/user/general/pager-webui`. No stock files are modified. +After a **firmware upgrade** (which wipes the overlay) re-run the payload to +re-enable — same caveat as nautilus. + +## Local dev loop + +```powershell +.\scripts\deploy.ps1 -SshKey "$HOME\.ssh\pager_key" # deploy backend once +.\scripts\dev.ps1 -Tunnel # local SPA + API proxy +# open http://127.0.0.1:8000 +``` + +`dev.ps1` serves `www/` locally, proxies `/api/*` to the Pager, and points the +terminal at the Pager's daemon WS (`-Tunnel` opens the `:1471` SSH tunnel). +The live WebSocket falls back to 5s polling through the dev proxy. + +## API tests + +Python unit tests (stdlib `unittest`, runnable on Windows with mocks). Run each +module in its own process — the tests monkeypatch module-level helpers and do not +restore them, so a single `discover` process leaks state between files: + +```powershell +$py = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe" +Get-ChildItem tests\test_*.py | ForEach-Object { + $mod = "tests." + [IO.Path]::GetFileNameWithoutExtension($_.Name) + & $py -m unittest $mod -v +} +``` + +On-device smoke tests per spec §8 cover every page, background vs foreground, +terminal I/O, and reboot persistence. + +## Architecture + +- `server.py` — Python 3.11 stdlib HTTP + JSON API + minimal RFC6455 WS on + `0.0.0.0:8080`; talks to the Hak5 daemon (`127.0.0.1:1471`), `hak5cmd`, + `uci`, `iwinfo`, and `recon.db` (read-only). +- `www/` — vanilla JS SPA (no build step) + bundled xterm.js. +- `payload.sh` + `pagerwebui.init` — nautilus-style installer / procd service. + +## Security notes + +- Auth via device password validated against the daemon; HttpOnly session + cookie `AUTH_`; all state-changing endpoints gated. +- Commands run with argument lists (no shell interpolation). +- Binds `0.0.0.0:8080` — same exposure class as the stock `:1471`/`:7681`. + +## Out of scope (v1) + +`:1471` takeover, Mark VII-only features (Campaigns/Modules/Cloud C2/EAP), +physical-display mirroring, and a PR to `hak5/wifipineapplepager-payloads` +(packaging is drop-in ready for that PR). +``` + +- [ ] **Step 4: Verify the dev loop** + +```powershell +& .\scripts\dev.ps1 +``` + +Load `http://127.0.0.1:8000/` — `js/config.js` is served with the Pager host, `/api/api_ping` proxies to the device (expect 401 until login), and the login flow works end-to-end. Confirm `README.md` renders (the nested code fence for the deploy command is intentional — keep the inner triple-backticks escaped as `\```\`` ` if the renderer complains, or drop the outer fence and show the command inline). + +- [ ] **Step 5: Final full test run + commit** + +Run the per-module loop from Task 1 (each module in its own process): + +```powershell +$py = "C:\Users\root\AppData\Local\Programs\Python\Python311\python.exe" +Get-ChildItem tests\test_*.py | ForEach-Object { + $mod = "tests." + [IO.Path]::GetFileNameWithoutExtension($_.Name) + & $py -m unittest $mod -v +} +``` + +All modules end `OK`. Then: + +```bash +git add scripts/dev.ps1 scripts/dev_proxy.py README.md +git commit -m "docs: dev loop, proxy and README" +``` + +--- + +## Execution Order & Checkpoints + +Tasks are grouped into three gates so the work stays reviewable: + +| Gate | Tasks | Deliverable / checkpoint | +|---|---|---| +| **A — Backend** | 1–10 | `server.py` complete; the per-module unittest loop from Task 1 is green on Windows; every handler exercised once on-device against the real daemon via a dev deploy. | +| **B — Frontend** | 11–17 | SPA complete against the deployed backend (login, all 7 pages, terminal). | +| **C — Packaging** | 18–20 | Portal zip + manifest build, install + run on the Pager, background/foreground, reboot persistence, recovery drill documented in README. | + +On-device verification (spec §8) is **mandatory** for these tasks because the +device output shapes are resolved there: 3 (iwinfo), 4 (uci), 5 (hak5cmd), +7 (recon.db schema), 9 (portal/log/password), 19 (deploy), and Task 10 step 5 +(WS). Do not skip the SSH inspection steps — the parsers are written against +documented-on-device formats. + +Final gate checklist before declaring done: +- [ ] Full unit suite green on Windows (Task 20 step 5). +- [ ] `scripts/deploy.ps1` builds `payload-.zip` + manifest (Task 19 step 3). +- [ ] On-device: login, dashboard counters move, SSID pool CRUD, filter toggle, recon start + scan read, handshake list, portal install/remove, terminal I/O. +- [ ] Background service survives reboot; foreground stops cleanly with **B**; uninstall/re-install drill passes. +- [ ] No stock files modified, no `opkg` changes (`uci show` diff of our sections only). diff --git a/docs/superpowers/plans/2026-08-11-handshakes-markvii-parity.md b/docs/superpowers/plans/2026-08-11-handshakes-markvii-parity.md new file mode 100644 index 0000000..c337fde --- /dev/null +++ b/docs/superpowers/plans/2026-08-11-handshakes-markvii-parity.md @@ -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: '', + close: '', + question_mark: '', +``` + +- [ ] **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 "" +``` + +- [ ] **Step 2: Restart the webui service** + +```powershell +sshpass -p "" 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\":\"\"}" | 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_` 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_` cookie note in Task 5 matches server.py `h_login`. diff --git a/docs/superpowers/plans/2026-08-11-openap-markvii-settings.md b/docs/superpowers/plans/2026-08-11-openap-markvii-settings.md new file mode 100644 index 0000000..9f8cf84 --- /dev/null +++ b/docs/superpowers/plans/2026-08-11-openap-markvii-settings.md @@ -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 ""`, 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 "" +``` +Then restart and confirm the port is up (over sshpass SSH): +```bash +sshpass -p "" 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 "" 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":""}' > /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. diff --git a/docs/superpowers/plans/2026-08-11-openap-mk7-card.md b/docs/superpowers/plans/2026-08-11-openap-mk7-card.md new file mode 100644 index 0000000..1374254 --- /dev/null +++ b/docs/superpowers/plans/2026-08-11-openap-mk7-card.md @@ -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 ""`, 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.`, 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 "" +``` +Then restart and confirm the port is up (over sshpass SSH): +```bash +sshpass -p "" 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 "" 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 "" 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:, bssid:, hidden:, enabled:true, channel:, country:}}` (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. diff --git a/docs/superpowers/plans/2026-08-11-pineap-markvii-layout.md b/docs/superpowers/plans/2026-08-11-pineap-markvii-layout.md new file mode 100644 index 0000000..2f845da --- /dev/null +++ b/docs/superpowers/plans/2026-08-11-pineap-markvii-layout.md @@ -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 ""`, 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 "" +``` +Then restart and confirm the port is up (over sshpass SSH): +```bash +sshpass -p "" 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 "" 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. diff --git a/docs/superpowers/plans/2026-08-11-pineap-overview-markvii.md b/docs/superpowers/plans/2026-08-11-pineap-overview-markvii.md new file mode 100644 index 0000000..24a9a22 --- /dev/null +++ b/docs/superpowers/plans/2026-08-11-pineap-overview-markvii.md @@ -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 ""`, 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 "" +``` +Then restart and confirm the port is up: +```bash +sshpass -p "" 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":""}' > /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. diff --git a/docs/superpowers/plans/2026-08-11-pineap-page-overhaul.md b/docs/superpowers/plans/2026-08-11-pineap-page-overhaul.md new file mode 100644 index 0000000..21012d9 --- /dev/null +++ b/docs/superpowers/plans/2026-08-11-pineap-page-overhaul.md @@ -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. diff --git a/docs/superpowers/plans/2026-08-11-recon-markvii.md b/docs/superpowers/plans/2026-08-11-recon-markvii.md new file mode 100644 index 0000000..05fe853 --- /dev/null +++ b/docs/superpowers/plans/2026-08-11-recon-markvii.md @@ -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 `` 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 `` 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 ""` (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 ``. + +- [ ] **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: '', + file_download: '', + delete: '', + settings: '', + search: '', + first_page: '', + last_page: '', + chevron_left: '', + chevron_right: '' +``` + +- [ ] **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 "" +``` +(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. diff --git a/docs/superpowers/specs/2026-08-11-handshakes-markvii-parity-design.md b/docs/superpowers/specs/2026-08-11-handshakes-markvii-parity-design.md new file mode 100644 index 0000000..6f2dcce --- /dev/null +++ b/docs/superpowers/specs/2026-08-11-handshakes-markvii-parity-design.md @@ -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 `__.`. + +## 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: `___handshake.pcap|.22000` + - partial: `___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 >= + 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 + `__.` 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 >= + ` 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`). diff --git a/docs/superpowers/specs/2026-08-11-openap-markvii-settings-design.md b/docs/superpowers/specs/2026-08-11-openap-markvii-settings-design.md new file mode 100644 index 0000000..15962c1 --- /dev/null +++ b/docs/superpowers/specs/2026-08-11-openap-markvii-settings-design.md @@ -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. diff --git a/docs/superpowers/specs/2026-08-11-openap-mk7-card-design.md b/docs/superpowers/specs/2026-08-11-openap-mk7-card-design.md new file mode 100644 index 0000000..824433c --- /dev/null +++ b/docs/superpowers/specs/2026-08-11-openap-mk7-card-design.md @@ -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** — `` 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 "" 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: }`. 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=`; if `open.channel` present, `uci set wireless.radio0.channel=`; 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`. diff --git a/docs/superpowers/specs/2026-08-11-pineap-markvii-layout-design.md b/docs/superpowers/specs/2026-08-11-pineap-markvii-layout-design.md new file mode 100644 index 0000000..76c4f78 --- /dev/null +++ b/docs/superpowers/specs/2026-08-11-pineap-markvii-layout-design.md @@ -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. diff --git a/docs/superpowers/specs/2026-08-11-pineap-overview-markvii-design.md b/docs/superpowers/specs/2026-08-11-pineap-overview-markvii-design.md new file mode 100644 index 0000000..6920d85 --- /dev/null +++ b/docs/superpowers/specs/2026-08-11-pineap-overview-markvii-design.md @@ -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). diff --git a/docs/superpowers/specs/2026-08-11-pineap-page-overhaul-design.md b/docs/superpowers/specs/2026-08-11-pineap-page-overhaul-design.md new file mode 100644 index 0000000..24a703a --- /dev/null +++ b/docs/superpowers/specs/2026-08-11-pineap-page-overhaul-design.md @@ -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/` 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. diff --git a/docs/superpowers/specs/2026-08-11-recon-markvii-design.md b/docs/superpowers/specs/2026-08-11-recon-markvii-design.md new file mode 100644 index 0000000..ce75b33 --- /dev/null +++ b/docs/superpowers/specs/2026-08-11-recon-markvii-design.md @@ -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: {totalHandshakes} + Handshakes Captured + Automatically Collect Any Handshakes + 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`. diff --git a/docs/superpowers/specs/2026-08-11-virtual-pager-dock-design.md b/docs/superpowers/specs/2026-08-11-virtual-pager-dock-design.md new file mode 100644 index 0000000..1bdb66c --- /dev/null +++ b/docs/superpowers/specs/2026-08-11-virtual-pager-dock-design.md @@ -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://:1471/api/pager/display/screen.ws` — streams binary RGBA + framebuffer frames. Requires the daemon `AUTH_` cookie; returns + `401 Unauthorized` without it. +- Keys: `ws://:1471/api/pager/input/keys.ws` — text frames; same cookie. +- Both handshakes verified returning `101 Switching Protocols` with a valid + `AUTH_=` 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 + `` 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 + +`` (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, ``, 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). diff --git a/payload/user/remote_access/pager-webui/LICENSE b/payload/user/remote_access/pager-webui/LICENSE new file mode 100644 index 0000000..b7c7bec --- /dev/null +++ b/payload/user/remote_access/pager-webui/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 c4ch3c4d3 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/payload/user/remote_access/pager-webui/README.md b/payload/user/remote_access/pager-webui/README.md new file mode 100644 index 0000000..2d6c2ee --- /dev/null +++ b/payload/user/remote_access/pager-webui/README.md @@ -0,0 +1,33 @@ +# Mark VIII + +Mark VIII is a community web-management payload for the WiFi Pineapple Pager. +It runs locally on the Pager and exposes its interface on port `8080`. + +## Install + +Copy this complete directory to either: + +```text +/root/payloads/user/remote_access/pager-webui/ +/mmc/root/payloads/user/remote_access/pager-webui/ +``` + +Ensure `payload.sh` is executable. It will then appear under Remote Access in +the Pager payload menu. + +## Run + +Run Mark VIII from the Pager. Choose background mode to install and enable its +`procd` service, or foreground mode to keep it attached to the payload screen. +Open `http://172.16.52.1:8080/` and authenticate with the Pager's device +password. Re-running the payload stops and removes the background service. + +## Requirements and security + +- WiFi Pineapple Pager firmware 24.10.1 +- `python3` (included with the supported firmware) +- Access to port 8080 should be limited to trusted management networks + +Mark VIII runs as root because it manages the device. Review the source before +installation and use it only on systems and networks you are authorized to +administer. diff --git a/payload/user/remote_access/pager-webui/THIRD_PARTY_NOTICES.md b/payload/user/remote_access/pager-webui/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..2bc4539 --- /dev/null +++ b/payload/user/remote_access/pager-webui/THIRD_PARTY_NOTICES.md @@ -0,0 +1,29 @@ +# Third-party notices + +Mark VIII bundles xterm.js and the xterm.js fit addon. + +Copyright (c) 2014 The xterm.js authors. All rights reserved. + +These components are distributed under the MIT License. Permission is hereby +granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software +without restriction, including without limitation the rights to use, copy, +modify, merge, publish, distribute, sublicense, and/or sell copies of the +Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +WiFi Pineapple, PineAP, Pager, and related names and imagery are trademarks or +assets of Hak5 LLC and their respective owners. They are not covered by the +Mark VIII MIT license. Mark VIII is an independent community payload and is not +an official Hak5 product. diff --git a/payload/user/remote_access/pager-webui/_hak5_manifest.json b/payload/user/remote_access/pager-webui/_hak5_manifest.json new file mode 100644 index 0000000..64993d1 --- /dev/null +++ b/payload/user/remote_access/pager-webui/_hak5_manifest.json @@ -0,0 +1,15 @@ +{ + "payload": "pager-webui", + "parent": "user/remote_access", + "key": "user~remote_access~pager-webui", + "time": "", + "last_hash": "", + "zip": "", + "title": "Mark VIII", + "author": "c4ch3c4d3", + "description": "Mark VII-style web management UI for the WiFi Pineapple Pager", + "version": "1.0", + "category": "remote_access", + "tags": ["remote-access", "web-interface", "device-management", "pineap"], + "firmware": "Pineapple Pager 24.10.1" +} diff --git a/payload/user/remote_access/pager-webui/pagerwebui.init b/payload/user/remote_access/pager-webui/pagerwebui.init new file mode 100755 index 0000000..cfa7d33 --- /dev/null +++ b/payload/user/remote_access/pager-webui/pagerwebui.init @@ -0,0 +1,24 @@ +#!/bin/sh /etc/rc.common + +START=99 +STOP=10 +USE_PROCD=1 + +PAGER_WEBUI_DIR="/root/payloads/user/remote_access/pager-webui" +[ -f "$PAGER_WEBUI_DIR/server.py" ] || PAGER_WEBUI_DIR="/mmc/root/payloads/user/remote_access/pager-webui" + +start_service() { + [ -f "$PAGER_WEBUI_DIR/server.py" ] || return 1 + chmod -R 755 "$PAGER_WEBUI_DIR" 2>/dev/null + procd_open_instance pagerwebui + procd_set_param command /usr/bin/python3 "$PAGER_WEBUI_DIR/server.py" + procd_set_param respawn + procd_set_param stdout 1 + procd_set_param stderr 1 + procd_set_param pidfile /tmp/pagerwebui.pid + procd_close_instance +} + +stop_service() { + rm -f /tmp/pagerwebui.pid +} diff --git a/payload/user/remote_access/pager-webui/payload.sh b/payload/user/remote_access/pager-webui/payload.sh new file mode 100755 index 0000000..27eff5a --- /dev/null +++ b/payload/user/remote_access/pager-webui/payload.sh @@ -0,0 +1,113 @@ +#!/bin/bash +# Title: Mark VIII +# Description: Mark VII-style web management UI for the WiFi Pineapple Pager +# Author: c4ch3c4d3 +# Version: 1.0 +# Category: Remote-Access +# Tags: remote-access, web-interface, device-management, pineap +# Firmware: Pineapple Pager 24.10.1 + +SCRIPT_SOURCE="${BASH_SOURCE[0]:-$0}" +SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_SOURCE")" 2>/dev/null && pwd)" +[ -f "$SCRIPT_DIR/server.py" ] || SCRIPT_DIR="/root/payloads/user/remote_access/pager-webui" +[ -f "$SCRIPT_DIR/server.py" ] || SCRIPT_DIR="/mmc/root/payloads/user/remote_access/pager-webui" +PORT=8080 +PID_FILE="/tmp/pagerwebui.pid" +INIT_SCRIPT="/etc/init.d/pagerwebui" + +user_confirmed() { + [ "$1" = "true" ] || [ "$1" = "$DUCKYSCRIPT_USER_CONFIRMED" ] +} + +get_pager_ip() { + for iface in br-lan eth0 wlan0 usb0; do + IP=$(ip -4 addr show "$iface" 2>/dev/null | awk '/inet / {print $2}' | cut -d'/' -f1 | head -1) + [ -n "$IP" ] && echo "$IP" && return + done + echo "172.16.52.1" +} + +LOG "cyan" "+---------------------------+" +LOG "cyan" "| Mark VIII v1.0 |" +LOG "cyan" "+---------------------------+" + +if ! command -v python3 >/dev/null 2>&1; then + LOG "red" "python3 required (present on current firmware)" + exit 1 +fi + +if [ -f "$INIT_SCRIPT" ] && "$INIT_SCRIPT" running 2>/dev/null; then + PAGER_IP=$(get_pager_ip) + LOG "green" "Mark VIII service is running" + LOG "green" "http://$PAGER_IP:$PORT" + resp=$(CONFIRMATION_DIALOG "Stop service?") + if user_confirmed "$resp"; then + LOG "yellow" "Stopping service..." + "$INIT_SCRIPT" stop + "$INIT_SCRIPT" disable + rm -f "$INIT_SCRIPT" + LOG "cyan" "Service stopped" + fi + exit 0 +fi + +AUTO_MODE=$(PAYLOAD_GET_CONFIG pager_webui auto_mode 2>/dev/null) +RUN_MODE=$(PAYLOAD_GET_CONFIG pager_webui run_mode 2>/dev/null) + +if [ "$AUTO_MODE" = "true" ]; then + if [ "$RUN_MODE" = "background" ]; then + resp="true" + LOG "cyan" "Auto-starting background mode..." + else + resp="" + LOG "cyan" "Auto-starting foreground mode..." + fi +else + resp=$(CONFIRMATION_DIALOG "Run as background service?") +fi + +if user_confirmed "$resp"; then + LOG "cyan" "Starting as background service..." + [ ! -f "$SCRIPT_DIR/server.py" ] && { LOG "red" "server.py not found!"; exit 1; } + cp "$SCRIPT_DIR/pagerwebui.init" "$INIT_SCRIPT" + chmod +x "$INIT_SCRIPT" + "$INIT_SCRIPT" enable + "$INIT_SCRIPT" start + sleep 1 + PAGER_IP=$(get_pager_ip) + LOG "green" "Service started!" + LOG "green" "http://$PAGER_IP:$PORT" + LOG "cyan" "Re-run payload to stop" + sleep 3 + exit 0 +fi + +LOG "cyan" "Starting foreground mode..." +cleanup() { + LOG "yellow" "Stopping Mark VIII..." + [ -f "$PID_FILE" ] && kill "$(cat "$PID_FILE")" 2>/dev/null + rm -f "$PID_FILE" + LOG "cyan" "Stopped." +} +trap cleanup EXIT INT TERM + +[ ! -f "$SCRIPT_DIR/server.py" ] && { LOG "red" "server.py not found!"; exit 1; } +python3 "$SCRIPT_DIR/server.py" >/tmp/pagerwebui.log 2>&1 & +echo $! > "$PID_FILE" +sleep 1 + +PAGER_IP=$(get_pager_ip) +if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then + LOG "green" "http://$PAGER_IP:$PORT" + LOG "" + LOG "magenta" "Press B to stop" + while true; do + BUTTON=$(WAIT_FOR_INPUT) + if [ "$BUTTON" = "B" ] || [ "$BUTTON" = "Escape" ]; then + break + fi + done +else + LOG "red" "Failed to start server! Check /tmp/pagerwebui.log" + exit 1 +fi diff --git a/payload/user/remote_access/pager-webui/server.py b/payload/user/remote_access/pager-webui/server.py new file mode 100644 index 0000000..5a59eec --- /dev/null +++ b/payload/user/remote_access/pager-webui/server.py @@ -0,0 +1,2570 @@ +#!/usr/bin/env python3 +"""Mark VIII backend. Runs on the device's python3-light (stdlib only, no +urllib/http.server/sqlite3 modules); the sqlite reads fall back to the +device's sqlite3 CLI.""" +import base64 +import hashlib +import json +import os +import re +import select +import signal +import socket +import struct +import subprocess +import sys +import threading +import time + +try: + import sqlite3 +except ImportError: + sqlite3 = None + +SQLITE_CLI = os.environ.get('PAGER_SQLITE_CLI', 'sqlite3') + +DAEMON_BASE = os.environ.get('PAGER_DAEMON', 'http://127.0.0.1:1471') +DAEMON_SOCK = os.environ.get('PAGER_DAEMON_SOCK', '/tmp/api.sock') +AF_UNIX = getattr(socket, 'AF_UNIX', 1) +RECON_DB = os.environ.get('PAGER_RECON_DB', '/root/recon/recon.db') +LOOT_HS_DIR = os.environ.get('PAGER_LOOT_HS', '/root/loot/handshakes') +HAK5CMD = os.environ.get('PAGER_HAK5CMD', '/usr/bin/hak5cmd') +SESSION_FILE = os.environ.get('PAGER_SESSION_FILE', '/tmp/pagerwebui.session') +PINEAP_STATE_FILE = os.environ.get( + 'PAGER_PINEAP_STATE_FILE', + os.path.join(os.environ.get('TMPDIR') or os.environ.get('TEMP') or '/tmp', + 'pagerwebui.pineap-state')) +WWW_DIR = os.environ.get('PAGER_WWW_DIR', + os.path.join(os.path.dirname(os.path.abspath(__file__)), 'www')) +HOST = os.environ.get('PAGER_HOST', '0.0.0.0') +PORT = int(os.environ.get('PAGER_PORT', '8080')) + +_recon_scan_state = {'active': False, 'started': 0, 'duration': 0} +_payload_runs = {} +_payload_runs_lock = threading.Lock() +PAYLOAD_RUN_DIR = os.environ.get('PAGER_PAYLOAD_RUN_DIR', '/tmp/pagerwebui-payload-runs') +PAYLOAD_ROOTS = tuple(os.path.realpath(path) for path in + os.environ.get('PAGER_PAYLOAD_ROOTS', + '/root/payloads:/mmc/root/payloads').split(':') if path) +SELF_PAYLOAD_DIR = os.path.realpath(os.path.dirname(os.path.abspath(__file__))) +SELF_PAYLOAD_KEY = os.environ.get('PAGER_SELF_PAYLOAD_KEY', 'user~remote_access~pager-webui') + + +def device_run(args, timeout=20, input_data=None): + try: + p = subprocess.run(args, input=input_data, capture_output=True, timeout=timeout) + return p.returncode, p.stdout.decode('utf-8', 'replace'), p.stderr.decode('utf-8', 'replace') + except FileNotFoundError: + return 127, '', 'not found' + except subprocess.TimeoutExpired: + return 124, '', 'timeout' + + +def _daemon_addr(): + base = DAEMON_BASE.replace('http://', '').split('?', 1)[0] + if ':' in base: + host, port = base.rsplit(':', 1) + return host, int(port) + return base, 80 + + +def daemon_call(method, path, body=None, token=None, timeout=15): + host, port = _daemon_addr() + data = json.dumps(body).encode() if body is not None else None + lines = ['%s %s HTTP/1.1' % (method, path), 'Host: %s:%d' % (host, port), + 'Accept: application/json', 'Connection: close'] + if data is not None: + lines.append('Content-Type: application/json') + lines.append('Content-Length: %d' % len(data)) + if token: + lines.append('Authorization: Bearer ' + token) + req = ('\r\n'.join(lines) + '\r\n\r\n').encode('ascii') + (data or b'') + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(timeout) + sock.connect((host, port)) + sock.sendall(req) + resp = b'' + while True: + chunk = sock.recv(65536) + if not chunk: + break + resp += chunk + sock.close() + except OSError: + return 0, None + head, _, payload = resp.partition(b'\r\n\r\n') + if not head: + return 0, None + try: + status = int(head.split(b' ', 2)[1]) + except (IndexError, ValueError): + return 0, None + headers = {} + for hline in head.split(b'\r\n')[1:]: + name, _, value = hline.decode('latin-1').partition(':') + headers[name.strip().lower()] = value.strip() + if headers.get('transfer-encoding', '').lower() == 'chunked': + decoded = bytearray() + rest = payload + try: + while rest: + size_line, sep, rest = rest.partition(b'\r\n') + if not sep: + raise ValueError('missing chunk delimiter') + size = int(size_line.split(b';', 1)[0], 16) + if size == 0: + break + if len(rest) < size + 2: + raise ValueError('short chunk') + decoded.extend(rest[:size]) + rest = rest[size + 2:] + payload = bytes(decoded) + except (ValueError, IndexError): + return status, payload + if 'json' in headers.get('content-type', ''): + try: + return status, json.loads(payload.decode('utf-8', 'replace')) + except Exception: + return status, payload + return status, payload + + +def daemon_sock_call(method, path, body=None, timeout=10): + """Raw HTTP/1.1 request to the daemon's local unix-socket API. Returns (status, json|None).""" + data = json.dumps(body).encode() if body is not None else None + lines = ['%s %s HTTP/1.1' % (method, path), 'Host: localhost', 'Connection: close'] + if data is not None: + lines += ['Content-Type: application/json', 'Content-Length: %d' % len(data)] + req = ('\r\n'.join(lines) + '\r\n\r\n').encode('ascii') + (data or b'') + sock = None + try: + sock = socket.socket(AF_UNIX, socket.SOCK_STREAM) + sock.settimeout(timeout) + sock.connect(DAEMON_SOCK) + sock.sendall(req) + resp = b'' + while True: + chunk = sock.recv(65536) + if not chunk: + break + resp += chunk + except OSError: + return 0, None + finally: + if sock is not None: + try: + sock.close() + except OSError: + pass + head, _, payload = resp.partition(b'\r\n\r\n') + try: + status = int(head.split(b'\r\n', 1)[0].split(b' ', 2)[1]) + except (IndexError, ValueError): + return 0, None + try: + data = json.loads(payload) + except Exception: + data = None + return status, data + + +class Router: + def __init__(self): + self.routes = [] + + def add(self, method, pattern, handler): + self.routes.append((method, re.compile('^' + pattern + '$'), handler)) + + def dispatch(self, method, path): + for m, rx, h in self.routes: + if m == method: + mm = rx.match(path) + if mm: + return h, mm.groups() + return None, None + + +ROUTER = Router() + + +def _safe_join(base, rel): + base = os.path.abspath(base) + full = os.path.abspath(os.path.join(base, rel)) + if full == base or not full.startswith(base + os.sep): + return None + return full + + +class Download: + def __init__(self, data, ctype, filename=None): + self.data = data + self.ctype = ctype + self.filename = filename + + def send(self, handler, status=200): + handler.send_response(status) + handler.send_header('Content-Type', self.ctype) + if self.filename: + safe = re.sub(r'[\r\n"]', '_', self.filename) + handler.send_header('Content-Disposition', 'attachment; filename="%s"' % safe) + handler.send_header('Content-Length', str(len(self.data))) + handler.send_header('Cache-Control', 'no-cache') + for name, value in getattr(handler, 'extra_headers', []): + handler.send_header(name, value) + handler.end_headers() + handler.connection.sendall(self.data) + + +def _unquote_plus(s): + s = s.replace('+', ' ') + return re.sub(r'%([0-9A-Fa-f]{2})', lambda m: chr(int(m.group(1), 16)), s) + + +def _parse_qsl(qs): + result = [] + for pair in qs.split('&'): + if not pair: + continue + k, _, v = pair.partition('=') + result.append((_unquote_plus(k), _unquote_plus(v))) + return result + + +class _Ctx: + def __init__(self, handler, groups): + self.h = handler + self.args = groups + self.query = dict(_parse_qsl(handler.path.split('?', 1)[1])) if '?' in handler.path else {} + + @property + def cookie(self): + return self.h.headers.get('cookie', '') or '' + + @property + def body(self): + raw = getattr(self.h, 'body', b'') + if not raw: + return {} + try: + return json.loads(raw.decode('utf-8')) + except Exception: + return {} + + +WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11' + + +def ws_handshake_reply(key): + accept = base64.b64encode(hashlib.sha1((key + WS_GUID).encode('ascii')).digest()).decode('ascii') + return ('HTTP/1.1 101 Switching Protocols\r\n' + 'Upgrade: websocket\r\n' + 'Connection: Upgrade\r\n' + 'Sec-WebSocket-Accept: ' + accept + '\r\n\r\n').encode('ascii') + + +def ws_encode(payload, opcode=0x1, mask=False): + header = bytearray([0x80 | opcode]) + n = len(payload) + if n < 126: + header.append(0x80 | n if mask else n) + elif n < 65536: + header.append(0x80 | 126 if mask else 126) + header += struct.pack('>H', n) + else: + header.append(0x80 | 127 if mask else 127) + header += struct.pack('>Q', n) + if mask: + mask_bytes = os.urandom(4) + header += mask_bytes + payload = bytes(b ^ mask_bytes[i % 4] for i, b in enumerate(payload)) + return bytes(header) + payload + + +def ws_decode_frame(buf): + if len(buf) < 2: + return None, b'', 0 + opcode = buf[0] & 0x0F + masked = bool(buf[1] & 0x80) + length = buf[1] & 0x7F + off = 2 + if length == 126: + if len(buf) < 4: + return None, b'', 0 + length = struct.unpack('>H', buf[2:4])[0] + off = 4 + elif length == 127: + if len(buf) < 10: + return None, b'', 0 + length = struct.unpack('>Q', buf[2:10])[0] + off = 10 + if masked: + if len(buf) < off + 4: + return None, b'', 0 + mask = buf[off:off + 4] + off += 4 + if len(buf) < off + length: + return None, b'', 0 + payload = buf[off:off + length] + if masked: + payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload)) + return opcode, payload, off + length + + +def _relay_drain(buf, chunk): + """Append chunk to buf, decode complete frames, return (remaining_buf, frames, closed). + frames is a list of (opcode, payload) for text/binary frames; closed True on a close frame.""" + buf += chunk + frames = [] + closed = False + off = 0 + while off < len(buf): + op, payload, used = ws_decode_frame(buf[off:]) + if used == 0: + break + off += used + if op in (0x1, 0x2): + frames.append((op, payload)) + elif op == 0x8: + closed = True + break + return buf[off:], frames, closed + + +class WSPool: + def __init__(self): + self.clients = [] + self.lock = threading.Lock() + + def add(self, sock): + with self.lock: + self.clients.append(sock) + + def remove(self, sock): + with self.lock: + if sock in self.clients: + self.clients.remove(sock) + + def broadcast(self, payload_bytes): + dead = [] + with self.lock: + for c in list(self.clients): + try: + c.sendall(payload_bytes) + except Exception: + dead.append(c) + for c in dead: + if c in self.clients: + self.clients.remove(c) + + +WS_POOL = WSPool() +LIVE_STOP = threading.Event() + + +def live_loop(): + while not LIVE_STOP.is_set(): + time.sleep(2) + if not WS_POOL.clients: + continue + # status_data() already performs the relatively expensive iwinfo + # association scan. Reuse that snapshot instead of immediately + # running the same commands a second time for every live tick. + status = status_data() + tick = {'type': 'tick', 'status': status, 'clients': status.get('clients', [])} + WS_POOL.broadcast(ws_encode(json.dumps(tick).encode())) + + +_REASONS = {200: 'OK', 204: 'No Content', 400: 'Bad Request', 401: 'Unauthorized', + 403: 'Forbidden', 404: 'Not Found', 500: 'Internal Server Error', + 502: 'Bad Gateway'} + + +def same_origin(headers): + """Allow non-browser clients, but reject browser requests from another origin.""" + host = (headers.get('host', '') or '').strip().lower() + if not host: + return False + source = (headers.get('origin', '') or '').strip() + if not source: + source = (headers.get('referer', '') or '').strip() + if not source: + return True + match = re.match(r'^https?://([^/]+)(?:/|$)', source, re.I) + return bool(match and match.group(1).lower() == host) + + +class _Headers(dict): + def _lk(self, key): + return key.lower() if isinstance(key, str) else key + + def __getitem__(self, key): + return dict.__getitem__(self, self._lk(key)) + + def __contains__(self, key): + return dict.__contains__(self, self._lk(key)) + + def get(self, key, default=None): + return dict.get(self, self._lk(key), default) + + +class PagerHandler: + def __init__(self, conn, addr): + self.connection = conn + self.addr = addr + self.extra_headers = [] + self.close_connection = False + self.command = None + self.path = None + self.headers = {} + self.body = b'' + self._buf = b'' + + def _readline(self): + while b'\n' not in self._buf: + chunk = self.connection.recv(4096) + if not chunk: + return None + self._buf += chunk + line, self._buf = self._buf.split(b'\n', 1) + return line.rstrip(b'\r') + + def read_request(self): + line = self._readline() + if line is None or not line: + return False + parts = line.decode('latin-1').split(' ') + if len(parts) != 3: + return False + self.command, self.path, version = parts + headers = _Headers() + while True: + h = self._readline() + if h is None: + return False + if not h: + break + name, _, value = h.decode('latin-1').partition(':') + headers[name.strip().lower()] = value.strip() + self.headers = headers + try: + length = int(headers.get('content-length', '0') or '0') + except ValueError: + length = 0 + if length > 0: + while len(self._buf) < length: + chunk = self.connection.recv(65536) + if not chunk: + return False + self._buf += chunk + self.body = self._buf[:length] + self._buf = self._buf[length:] + else: + self.body = b'' + conn_tok = headers.get('connection', '').lower() + if version == 'HTTP/1.0': + self.close_connection = conn_tok != 'keep-alive' + else: + self.close_connection = conn_tok == 'close' + return True + + def send_response(self, status, reason=''): + reason = reason or _REASONS.get(status, '') + self.connection.sendall(('HTTP/1.1 %d %s\r\n' % (status, reason)).encode('latin-1')) + + def send_header(self, name, value): + self.connection.sendall(('%s: %s\r\n' % (name, value)).encode('latin-1')) + + def end_headers(self): + self.connection.sendall(b'\r\n') + + def _dispatch(self): + if not same_origin(self.headers): + self._fail(403, 'cross-origin request rejected') + return + if self.command == 'OPTIONS': + self.send_response(204) + self.send_header('Allow', 'GET, POST, DELETE, OPTIONS') + self.end_headers() + return + self._route(self.command) + + def add_extra_header(self, name, value): + if not hasattr(self, 'extra_headers'): + self.extra_headers = [] + self.extra_headers.append((name, value)) + + def _security_headers(self): + self.send_header('X-Content-Type-Options', 'nosniff') + self.send_header('X-Frame-Options', 'DENY') + self.send_header('Referrer-Policy', 'same-origin') + self.send_header( + 'Content-Security-Policy', + "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; " + "img-src 'self' data: blob:; connect-src 'self' ws: wss:; " + "object-src 'none'; base-uri 'none'; frame-ancestors 'none'") + + def _json(self, obj, status=200, close=False): + body = json.dumps(obj).encode() + self.send_response(status) + self.send_header('Content-Type', 'application/json') + self.send_header('Content-Length', str(len(body))) + self.send_header('Cache-Control', 'no-cache') + self._security_headers() + if close: + self.close_connection = True + self.send_header('Connection', 'close') + for name, value in getattr(self, 'extra_headers', []): + self.send_header(name, value) + self.end_headers() + self.connection.sendall(body) + + def _fail(self, status, message): + self._json({'error': message}, status, close=True) + + def _serve_static(self, path): + rel = path.lstrip('/') + if not rel: + rel = 'index.html' + rel = rel.replace('\\', '/') + if '..' in rel.split('/'): + return False + full = _safe_join(WWW_DIR, rel) + if not full or not os.path.isfile(full): + return False + ctype = { + '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', + '.png': 'image/png', '.svg': 'image/svg+xml', '.json': 'application/json', + '.map': 'application/json', '.woff2': 'font/woff2', + }.get(os.path.splitext(full)[1], 'application/octet-stream') + with open(full, 'rb') as f: + data = f.read() + self.send_response(200) + self.send_header('Content-Type', ctype) + self.send_header('Content-Length', str(len(data))) + self.send_header('Cache-Control', 'no-cache') + self._security_headers() + self.end_headers() + self.connection.sendall(data) + return True + + def _route(self, method): + path = self.path.split('?', 1)[0] + if self.headers.get('Upgrade', '').lower() == 'websocket': + self._ws_accept() + return + handler, groups = ROUTER.dispatch(method, path) + if handler is None: + if method == 'GET' and self._serve_static(path): + return + self._fail(404, 'not found') + return + if method != 'POST' or path != '/api/login': + if not check_auth(self.headers.get('Cookie', '') or ''): + self._fail(401, 'unauthorized') + return + ctx = _Ctx(self, groups) + try: + result = handler(ctx) + except Exception as e: + self._fail(500, str(e)) + return + if result is None: + return + status, payload = result + if isinstance(payload, Download): + payload.send(self, status) + else: + self._json(payload, status) + + def _ws_accept(self): + self.close_connection = True + if not same_origin(self.headers): + return self._fail(403, 'cross-origin websocket rejected') + path = self.path.split('?', 1)[0] + if path == '/api/terminal/openWs': + return self._ws_relay_daemon('/api/terminal/openWs') + if path in ('/api/pager/display/screen.ws', '/api/pager/input/keys.ws'): + return self._ws_relay_daemon(path) + if path != '/api/ws': + return self._fail(404, 'not found') + if not check_auth(self.headers.get('Cookie', '') or ''): + return self._fail(401, 'unauthorized') + key = self.headers.get('Sec-WebSocket-Key', '') + self.connection.sendall(ws_handshake_reply(key)) + WS_POOL.add(self.connection) + try: + while True: + opcode, payload = self._ws_read_frame() + if opcode is None or opcode == 0x8: + break + if opcode == 0x9: + self.connection.sendall(ws_encode(payload, opcode=0xA)) + finally: + WS_POOL.remove(self.connection) + try: + self.connection.close() + except OSError: + pass + + def _ws_read_frame(self): + hdr = self._recv_exact(2) + if hdr is None: + return None, b'' + length = hdr[1] & 0x7F + if length == 126: + ext = self._recv_exact(2) + if ext is None: + return None, b'' + length = struct.unpack('>H', ext)[0] + elif length == 127: + ext = self._recv_exact(8) + if ext is None: + return None, b'' + length = struct.unpack('>Q', ext)[0] + masked = bool(hdr[1] & 0x80) + mask = self._recv_exact(4) if masked else b'' + payload = self._recv_exact(length) + if payload is None: + return None, b'' + if masked: + payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload)) + return hdr[0] & 0x0F, payload + + def _recv_exact(self, n): + buf = b'' + while len(buf) < n: + chunk = self.connection.recv(n - len(buf)) + if not chunk: + return None + buf += chunk + return buf + + def _ws_relay_daemon(self, daemon_path): + if not check_auth(self.headers.get('Cookie', '') or ''): + return self._fail(401, 'unauthorized') + key = self.headers.get('Sec-WebSocket-Key', '') + self.connection.sendall(ws_handshake_reply(key)) + daemon_sock, err = _daemon_ws_connect(daemon_path) + if err: + try: + self.connection.sendall(ws_encode(('relay error: ' + err).encode(), opcode=0x1)) + except OSError: + pass + try: + self.connection.close() + except OSError: + pass + return + daemon_sock.setblocking(False) + import select + buf = b'' + try: + while True: + rlist, _, _ = select.select([self.connection, daemon_sock], [], [], 5) + for s in rlist: + if s is daemon_sock: + try: + chunk = daemon_sock.recv(65536) + except (BlockingIOError, InterruptedError): + continue + if not chunk: + return + # daemon frames may be masked per its own framing; decode then forward unmasked + buf, frames, closed = _relay_drain(buf, chunk) + for op, payload in frames: + self.connection.sendall(ws_encode(payload, opcode=op)) + if closed: + return + else: + opcode, payload = self._ws_read_frame() + if opcode is None or opcode == 0x8: + return + if opcode in (0x1, 0x2, 0x9): + daemon_sock.sendall(ws_encode(payload, opcode=opcode, mask=True)) + finally: + try: + daemon_sock.close() + except OSError: + pass + try: + self.connection.close() + except OSError: + pass + + +def load_session(): + try: + os.chmod(SESSION_FILE, 0o600) + with open(SESSION_FILE) as f: + return json.load(f) + except Exception: + return {} + + +def save_session(session): + tmp = SESSION_FILE + '.tmp' + fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, 'w') as f: + json.dump(session, f) + os.chmod(tmp, 0o600) + os.replace(tmp, SESSION_FILE) + os.chmod(SESSION_FILE, 0o600) + + +def current_token(): + return load_session().get('token', '') + + +def current_serverid(): + return load_session().get('serverid', '') + + +def check_auth(cookie_header): + if not cookie_header: + return False + session = load_session() + serverid = session.get('serverid', '') + token = session.get('token', '') + if not serverid or not token: + return False + expected = 'AUTH_%s=%s' % (serverid, token) + for entry in cookie_header.split(';'): + if entry.strip() == expected: + return True + return False + + +def h_login(ctx): + username = (ctx.body or {}).get('username', '') + password = (ctx.body or {}).get('password', '') + status, data = daemon_call('POST', '/api/login', body={'username': username, 'password': password}) + if status != 200 or not isinstance(data, dict) or 'token' not in data: + return 401, {'error': 'login failed'} + token = data['token'] + pstatus, ping = daemon_call('GET', '/api/api_ping', token=token) + serverid = ping.get('serverid', '') if isinstance(ping, dict) else '' + save_session({'serverid': serverid, 'token': token, 'created': int(time.time())}) + ctx.h.add_extra_header('Set-Cookie', 'AUTH_%s=%s; Path=/; HttpOnly; SameSite=Strict' % (serverid, token)) + return 200, {'ok': True, 'serverid': serverid} + + +def h_logout(ctx): + serverid = current_serverid() + try: + os.unlink(SESSION_FILE) + except OSError: + pass + cookie_name = 'AUTH_%s' % serverid if serverid else 'AUTH' + ctx.h.add_extra_header( + 'Set-Cookie', '%s=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0' % cookie_name) + return 200, {'ok': True} + + +def h_api_ping(ctx): + token = current_token() + status, data = daemon_call('GET', '/api/api_ping', token=token) + if status != 200 or not isinstance(data, dict): + return 502, {'error': 'daemon unreachable'} + return 200, data + + +def battery_data(power_supply='/sys/class/power_supply'): + try: + for name in sorted(os.listdir(power_supply)): + try: + with open(os.path.join(power_supply, name, 'type')) as f: + typ = f.read().strip() + except OSError: + continue + if typ != 'Battery': + continue + level = None + cap = os.path.join(power_supply, name, 'capacity') + if os.path.exists(cap): + try: + with open(cap) as f: + level = int(f.read().strip()) + except ValueError: + level = None + charging = False + st = os.path.join(power_supply, name, 'status') + if os.path.exists(st): + try: + with open(st) as f: + charging = 'Charg' in f.read() + except OSError: + charging = False + return {'level': level, 'charging': charging} + except OSError: + pass + return {'level': None, 'charging': False} + + +def wifi_ifaces(): + rc, out, err = device_run(['iwinfo']) + names = [] + for line in out.splitlines(): + m = re.match(r'^(\S+)\s+', line) + if m and (m.group(1).startswith('wlan') or m.group(1).startswith('radio')): + names.append(m.group(1)) + return names + + +def wifi_iface_info(name): + rc, out, err = device_run(['iwinfo', name, 'info']) + info = {'iface': name} + for line in out.splitlines(): + m = re.search(r'ESSID:\s*"([^"]*)"', line) + if m: + info['ssid'] = m.group(1) + m = re.search(r'Mode:\s*(\S+)', line) + if m: + info['mode'] = m.group(1) + m = re.search(r'Channel:\s*(\d+)', line) + if m: + info['channel'] = int(m.group(1)) + m = re.search(r'Link Quality:\s*(\d+)/(\d+)', line) + if m: + info['quality'] = {'signal': int(m.group(1)), 'max': int(m.group(2))} + return info + + +MAC_RE = re.compile(r'^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$') + + +def normalize_mac(mac): + mac = (mac or '').strip().upper() + return mac if MAC_RE.match(mac) else None + + +def h_clients(ctx): + clients = assoc_clients() + return 200, {'clients': clients, 'count': len(clients)} + + +def h_client_kick(ctx): + mac = normalize_mac((ctx.body or {}).get('mac')) + if not mac: + return 400, {'error': 'invalid mac'} + hak5('PINEAPPLE_DEVICE_FILTER_MODE', 'deny') + hak5('PINEAPPLE_DEVICE_FILTER_ADD', 'deny', mac) + hak5('PINEAPPLE_DEAUTH_CLIENT', mac) + return 200, {'ok': True} + + +def h_deauth_client(ctx): + mac = normalize_mac((ctx.body or {}).get('mac')) + if not mac: + return 400, {'error': 'invalid mac'} + hak5('PINEAPPLE_DEAUTH_CLIENT', mac) + return 200, {'ok': True} + + +SQLITE_BUSY_MSGS = ('database is locked', 'database is busy') + + +def _db_rows(db, sql, _retries=5): + if sqlite3 is not None: + conn = sqlite3.connect('file:%s?mode=ro' % db, uri=True) + try: + cur = conn.execute(sql) + cols = [d[0] for d in cur.description] + return [dict(zip(cols, row)) for row in cur.fetchall()] + finally: + conn.close() + rc, out, err = device_run([SQLITE_CLI, '-json', '-cmd', '.timeout 5000', db, sql]) + attempt = 1 + while rc != 0 and any(m in (err or '') for m in SQLITE_BUSY_MSGS) and attempt < _retries: + time.sleep(0.3) + rc, out, err = device_run([SQLITE_CLI, '-json', '-cmd', '.timeout 5000', db, sql]) + attempt += 1 + if out.strip(): + return json.loads(out) + return [] + + +def fmt_mac(raw): + """AE77C0EB3141 -> AE:77:C0:EB:31:41; anything else passes through (None/'' -> '--').""" + raw = (raw or '').strip() + if len(raw) == 12 and all(c in '0123456789ABCDEFabcdef' for c in raw): + return ':'.join(raw[i:i + 2] for i in range(0, 12, 2)) + return raw or '--' + + +def decode_ssid(raw): + if raw is None: + return '' + if isinstance(raw, str): + if '\\x' in raw: + out = bytearray() + i = 0 + n = len(raw) + while i < n: + if (raw[i] == '\\' and i + 3 < n and raw[i + 1] == 'x' + and all(c in '0123456789abcdefABCDEF' for c in raw[i + 2:i + 4])): + out.append(int(raw[i + 2:i + 4], 16)) + i += 4 + else: + out.extend(raw[i].encode('utf-8', 'replace')) + i += 1 + raw = bytes(out) + else: + return raw + try: + return raw.decode('utf-8', 'replace') + except Exception: + return raw.decode('latin-1', 'replace') + + +ENC_WEP = 0x01 | 0x02 +ENC_TKIP = 0x04 +ENC_CCMP = 0x08 +ENC_GCMP = 0x20 +ENC_GCMP256 = 0x80 +ENC_CCMP256 = 0x100 + + +def decode_encryption(v): + """Pager recon.db encryption bitfield -> old-UI-style display string.""" + v = v or 0 + if v == 0: + return 'Open' + parts = [] + if v & (ENC_GCMP256 | ENC_CCMP256): + parts.append('WPA3') + if v & (ENC_CCMP | ENC_GCMP): + parts.append('WPA2') + if v & ENC_TKIP: + parts.append('WPA') + if v & ENC_WEP: + parts.append('WEP') + return ' '.join(parts) if parts else 'Open' + + +def recon_scans_data(limit=50): + rows = _db_rows(RECON_DB, + 'SELECT s.id, s.time, s.name, ' + '(SELECT count(*) FROM wifi_device w WHERE w.scan = s.id) AS devices, ' + '(SELECT count(*) FROM ssid a WHERE a.scan = s.id AND a.type = 8) AS aps, ' + '(SELECT count(*) FROM handshake h WHERE h.scan = s.id) AS handshakes ' + 'FROM scan s ORDER BY s.id DESC LIMIT %d' % limit) + return {'scans': [{'id': r['id'], 'time': r['time'], 'name': r.get('name'), + 'devices': r['devices'], 'aps': r['aps'], + 'handshakes': r['handshakes']} for r in rows]} + + +def recon_scan_data(scan_id): + scans = _db_rows(RECON_DB, 'SELECT id, time, name FROM scan WHERE id = %d' % scan_id) + if not scans: + return None + aps = [] + for r in _db_rows(RECON_DB, + 'SELECT bssid, ssid, hidden, channel, encryption, signal, freq ' + 'FROM ssid WHERE scan = %d AND type = 8 AND bssid IS NOT NULL ' + 'ORDER BY signal ASC' % scan_id): + aps.append({'bssid': fmt_mac(r.get('bssid')), + 'ssid': decode_ssid(r.get('ssid')), + 'hidden': bool(r.get('hidden')), + 'channel': r.get('channel'), + 'signal': r.get('signal'), + 'freq': r.get('freq'), + 'encryption': decode_encryption(r.get('encryption'))}) + ap_macs = set() + for r in _db_rows(RECON_DB, + 'SELECT DISTINCT bssid FROM ssid WHERE scan = %d AND type = 8 AND bssid IS NOT NULL' % scan_id): + ap_macs.add((r.get('bssid') or '').strip().upper()) + clients = [] + for r in _db_rows(RECON_DB, + 'SELECT mac, signal, freq, packets FROM wifi_device WHERE scan = %d ORDER BY time ASC' % scan_id): + if (r.get('mac') or '').strip().upper() in ap_macs: + continue + clients.append({'mac': fmt_mac(r.get('mac')), 'signal': r.get('signal'), + 'freq': r.get('freq'), 'packets': r.get('packets')}) + mac_of = {} + for r in _db_rows(RECON_DB, + 'SELECT hash, mac FROM wifi_device WHERE scan = %d' % scan_id): + mac_of[r['hash']] = fmt_mac(r.get('mac')) + handshakes = [] + for r in _db_rows(RECON_DB, + 'SELECT stahash, aphash, time FROM handshake WHERE scan = %d' % scan_id): + handshakes.append({'ap': mac_of.get(r.get('aphash'), '--'), + 'client': mac_of.get(r.get('stahash'), '--'), + 'time': r.get('time')}) + return {'scan': {'id': scans[0]['id'], 'time': scans[0]['time'], + 'name': scans[0].get('name')}, + 'aps': aps, 'clients': clients, 'handshakes': handshakes} + + +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'} + _recon_scan_state['active'] = True + _recon_scan_state['started'] = time.time() + _recon_scan_state['duration'] = int(scan_time) if scan_time is not None else 0 + return 200, {'ok': True} + + +def h_recon_stop(ctx): + status, data = daemon_sock_call('POST', '/api/pineap/log/recon/stop', body={}) + if status != 200 or not (data or {}).get('success'): + return 502, {'error': 'daemon recon stop failed'} + _recon_scan_state['active'] = False + return 200, {'ok': True} + + +def _recon_scan_snapshot(): + st = _recon_scan_state + if not st['active']: + return False, None + elapsed = time.time() - st['started'] + if st['duration'] > 0 and elapsed >= st['duration']: + return False, 0 + remaining = None if st['duration'] == 0 else int(st['duration'] - elapsed) + return True, remaining + + +def _recon_watchdog_tick(): + """The daemon ignores scan_time and scans until stopped, so the webui enforces + the requested duration by issuing a stop when the timed scan expires.""" + st = _recon_scan_state + if st['active'] and st['duration'] > 0 and time.time() - st['started'] >= st['duration']: + daemon_sock_call('POST', '/api/pineap/log/recon/stop', body={}) + st['active'] = False + + +def h_recon_status(ctx): + rows = _db_rows(RECON_DB, 'SELECT MAX(time) AS t FROM scan') + last = rows[0]['t'] if rows and rows[0].get('t') is not None else None + act = _db_rows(RECON_DB, 'SELECT MAX(time) AS t FROM wifi_device') + last_activity = act[0]['t'] if act and act[0].get('t') is not None else last + scanning, remaining = _recon_scan_snapshot() + return 200, {'last_scan': last, 'last_activity': last_activity, + 'active': last_activity is not None and int(time.time()) - last_activity < 300, + 'scanning': scanning, 'scan_remaining': remaining} + + +def _db_write(db, sql): + if sqlite3 is not None: + conn = sqlite3.connect(db) + try: + conn.execute(sql) + conn.commit() + finally: + conn.close() + return + rc, out, err = device_run([SQLITE_CLI, '-cmd', '.timeout 5000', db, sql]) + attempt = 1 + while rc != 0 and any(m in (err or '') for m in SQLITE_BUSY_MSGS) and attempt < 5: + time.sleep(0.3) + rc, out, err = device_run([SQLITE_CLI, '-cmd', '.timeout 5000', db, sql]) + attempt += 1 + if rc != 0: + raise RuntimeError('sqlite write failed: %s' % (err or out).strip()) + + +RECON_CHILD_TABLES = ['wifi_device', 'ssid', 'handshake', 'hostap_chalresp', + 'hostap_basic', 'hostap_client', 'hostap_handshake'] + + +def recon_delete_scan(scan_id): + for t in RECON_CHILD_TABLES: + try: + _db_write(RECON_DB, 'DELETE FROM %s WHERE scan = %d' % (t, scan_id)) + except Exception: + continue + _db_write(RECON_DB, 'DELETE FROM scan WHERE id = %d' % scan_id) + + +def h_recon_delete(ctx): + scan_id = int(ctx.args[0]) + if not _db_rows(RECON_DB, 'SELECT id FROM scan WHERE id = %d' % scan_id): + return 404, {'error': 'scan not found'} + recon_delete_scan(scan_id) + return 200, {'ok': True} + + +def h_recon_scan_download(ctx): + scan_id = int(ctx.args[0]) + data = recon_scan_data(scan_id) + if data is None: + return 404, {'error': 'scan not found'} + return 200, Download(json.dumps(data, indent=2).encode('utf-8'), + 'application/json', 'scan-%d.json' % scan_id) + + +def recon_events_data(limit=200): + sql = ("SELECT time, 'auth attempt' AS type FROM hostap_basic WHERE time IS NOT NULL " + "UNION ALL SELECT time, 'challenge response' FROM hostap_chalresp WHERE time IS NOT NULL " + "UNION ALL SELECT time, 'handshake' FROM hostap_handshake WHERE time IS NOT NULL " + "UNION ALL SELECT time, 'wpa handshake' FROM handshake WHERE time IS NOT NULL " + "ORDER BY time DESC LIMIT %d" % limit) + try: + rows = _db_rows(RECON_DB, sql) + except Exception: + rows = [] + return {'events': [{'time': r.get('time'), 'type': r.get('type')} for r in rows]} + + +def h_recon_events(ctx): + return 200, recon_events_data() + + +def h_recon_examine(ctx): + body = getattr(ctx, 'body', None) or {} + bssid = (body.get('bssid') or '').strip() + channel = body.get('channel') + if bssid: + hak5('PINEAPPLE_EXAMINE_BSSID', bssid) + elif channel is not None: + hak5('PINEAPPLE_EXAMINE_CHANNEL', str(int(channel))) + else: + return 400, {'error': 'examine requires bssid or channel'} + return 200, {'ok': True} + + +def h_recon_scans(ctx): + return 200, recon_scans_data() + + +def h_recon_scan_detail(ctx): + scan_id = int(ctx.args[0]) + data = recon_scan_data(scan_id) + if data is None: + return 404, {'error': 'scan not found'} + return 200, data + + +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 = {} + if min_ts is not None: + try: + db = _hs_db_by_pair(min_ts) + except Exception: + db = {} + for name, size, mtime, part in parsed: + handshakes.append(_compose_hs(name, size, mtime, part, db)) + return {'files': files, 'handshakes': handshakes} + + +def h_handshakes_get(ctx): + return 200, handshakes_data() + + +def h_handshake_file(ctx): + name = _unquote_plus(ctx.args[0]) + full = _safe_join(LOOT_HS_DIR, name) + if not full or not os.path.isfile(full): + return 404, {'error': 'not found'} + with open(full, 'rb') as f: + body = f.read() + return 200, Download(body, 'application/octet-stream', name) + + +def h_handshakes_delete(ctx): + name = (ctx.body or {}).get('name') or ctx.query.get('name') or '' + safe = os.path.basename(name) + if not safe or safe != name: + return 400, {'error': 'invalid name'} + p = os.path.join(LOOT_HS_DIR, safe) + if not os.path.isfile(p): + return 404, {'error': 'not found'} + os.remove(p) + return 200, handshakes_data() + + +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() + + +def h_loot_zip(ctx): + status, raw = daemon_call('GET', '/api/loot/zip', token=current_token()) + if status != 200 or not isinstance(raw, bytes): + return 502, {'error': 'daemon failed'} + return 200, Download(raw, 'application/zip', 'loot.zip') + + +def h_loot_archive(ctx): + status, data = daemon_call('POST', '/api/loot/archive', token=current_token()) + return (200 if status == 200 else 502), (data if isinstance(data, dict) else {'ok': status == 200}) + + +def assoc_clients(ifaces=None): + clients = [] + for name in (wifi_ifaces() if ifaces is None else ifaces): + rc, out, err = device_run(['iwinfo', name, 'assoclist']) + for line in out.splitlines(): + m = re.match(r'\s*([0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2})\s+', line) + if not m: + continue + mac = m.group(1).upper() + rssi = None + rm = re.search(r'Signal:\s*(-?\d+)', line) + if rm: + rssi = int(rm.group(1)) + clients.append({'mac': mac, 'iface': name, 'rssi': rssi}) + return clients + + +def disk_data(): + rc, out, err = device_run(['df', '-k', '/root']) + lines = out.splitlines() + if len(lines) >= 2: + parts = lines[1].split() + if len(parts) >= 4: + try: + size = int(parts[1]); used = int(parts[2]); avail = int(parts[3]) + return {'size': size * 1024, 'used': used * 1024, 'avail': avail * 1024} + except ValueError: + pass + return {} + + +def uptime_data(): + rc, out, err = device_run(['cat', '/proc/uptime']) + try: + return int(float(out.split()[0])) + except Exception: + return None + + +def firmware_data(): + rc, out, err = device_run(['cat', '/etc/openwrt_release']) + dist = None + for line in out.splitlines(): + if line.startswith('DISTRIB_DESCRIPTION'): + dist = line.split('=', 1)[1].strip().strip('"') + return dist + + +def daemon_status(): + status, data = daemon_call('GET', '/api/api_ping', token=current_token()) + if status == 200 and isinstance(data, dict): + return data + return {} + + +def hostname_data(): + rc, out, err = device_run(['uci', 'get', 'system.@system[0].hostname']) + return out.strip() or None + + +def status_data(): + # iwinfo can be slow on the Pager, especially while radios are being + # reconfigured. Discover interfaces once and share the result between + # the radio and association portions of this snapshot. + ifaces = wifi_ifaces() + return { + 'battery': battery_data(), + 'firmware': firmware_data(), + 'daemon': daemon_status(), + 'wifi': [wifi_iface_info(n) for n in ifaces], + 'clients': assoc_clients(ifaces), + 'disk': disk_data(), + 'uptime': uptime_data(), + 'hostname': hostname_data(), + } + + +def h_status(ctx): + return 200, status_data() + + +def h_device(ctx): + rc, out, err = device_run(['ip', 'link']) + macs = re.findall(r'link/ether ([0-9a-f:]{17})', out.lower()) + return 200, {'hostname': hostname_data(), 'macs': sorted(set(macs)), 'model': 'WiFi Pineapple Pager'} + + +# Map logical setting -> uci option. Adjust after on-device `uci show pineapd`. +def uci_show(section='pineapd'): + rc, out, err = device_run(['uci', 'show', section]) + return out + + +def uci_set(option, value): + device_run(['uci', 'set', '%s=%s' % (option, value)]) + device_run(['uci', 'commit']) + + +def uci_delete(option): + device_run(['uci', 'delete', option]) + device_run(['uci', 'commit']) + + +def uci_add_list(option, value): + device_run(['uci', 'add_list', '%s=%s' % (option, value)]) + device_run(['uci', 'commit']) + + +def _daemon_proxy(method, subpath, body=None, timeout=15): + status, data = daemon_sock_call(method, '/api/pineap/%s' % subpath, body=body, timeout=timeout) + 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') + + +PINEAP_CONFIG_DEFAULTS = { + 'reconpath': '/root/recon/', + 'reconname': 'pager', + 'payloadpath': '/root/payloads/alerts/', + 'handshakepath': '/root/loot/handshakes/', + 'loghandshake': False, + 'logpartialhandshake': False, + 'pcappath': '/root/loot/pcap', + 'logpcap': False, + 'logwigle': False, + 'logrecon': True, + 'autossidpool': False, +} + +HOSTAPD_DEFAULTS = { + 'mgmt_ifaces': ['wlan0mgmt'], + 'wpa_ifaces': ['wlan0wpa'], + 'pineap_disabled': False, + 'pineape_disabled': False, + 'pineape_auth_pass': True, +} + + +def h_pineap_set_config(ctx): + body = ctx.body or {} + _, cur = daemon_sock_call('GET', '/api/pineap/get_config') + base = dict(PINEAP_CONFIG_DEFAULTS) + if isinstance(cur, dict) and 'reconpath' in cur: + base.update(cur) + base.update(body) + status, data = _daemon_proxy('PUT', 'set_config', base) + if status == 200 and 'autossidpool' in body: + update_pineap_state(mode='advanced', collect=bool(body['autossidpool'])) + return status, data + + +def h_pineap_hostapd_get(ctx): + return _daemon_proxy('GET', 'hostapd/get_config') + + +def h_pineap_hostapd_set(ctx): + body = ctx.body or {} + _, cur = daemon_sock_call('GET', '/api/pineap/hostapd/get_config') + base = dict(HOSTAPD_DEFAULTS) + if isinstance(cur, dict) and 'mgmt_ifaces' in cur: + base.update(cur) + base.update({k: v for k, v in body.items() if k in HOSTAPD_DEFAULTS}) + return _daemon_proxy('PUT', 'hostapd/set_config', base) + + +def h_pineap_enable(ctx): + enable = bool((ctx.body or {}).get('enable')) + status, data = _daemon_proxy('PUT', 'hostapd/enable_pineap', {'enable': enable}) + if status == 200: + update_pineap_state(mode='advanced', enabled=enable) + return status, data + + +def h_pineap_mimic(ctx): + enable = bool((ctx.body or {}).get('enable')) + status, data = _daemon_proxy('POST', 'mimic/enable' if enable else 'mimic/disable') + if status == 200: + update_pineap_state(mode='advanced', karma=enable) + return status, data + + +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 _uci_values(section): + """Return the simple key/value fields emitted by ``uci show``.""" + rc, out, err = device_run(['uci', 'show', section]) + cfg = {} + for line in out.splitlines(): + line = line.strip() + if '=' not in line: + continue + k, _, v = line.partition('=') + cfg[k.rsplit('.', 1)[-1]] = v.strip("'") + return cfg + + +def _uci_wifi_iface(name): + return _uci_values('wireless.%s' % name) + + +def _uci_section(section): + return _uci_values(section) + + +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 + encryption = wpa_cfg.get('encryption') or '' + # OpenWrt commonly decorates the key-management value with a cipher + # (for example, "psk2+ccmp"). The UI exposes the logical modes, so + # normalize the live UCI value to an option the select can represent. + if encryption.startswith('psk2'): + encryption = 'psk2' + elif encryption.startswith('sae'): + encryption = 'sae' + elif encryption.startswith('owe'): + encryption = 'owe' + 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': encryption, + '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'))}, + } + + +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 + radio = _uci_wifi_iface('radio0') + for key in ('channel', 'country'): + value = openap.get(key) + if value is None: + continue + if str(value) != (radio.get(key) or ''): + device_run(['uci', 'set', 'wireless.radio0.%s=%s' % (key, value)]) + changed = True + if changed: + device_run(['uci', 'commit', 'wireless']) + device_run(['wifi', 'reload']) + + +def _open_channel(value): + if value is None: + return 1 + try: + return int(value) + except (TypeError, ValueError): + return 1 + + +def h_pineap_wifi_set_ap(ctx): + body = ctx.body or {} + configs = [] + wpa = body.get('wpa') or {} + openap = body.get('open') or {} + if wpa.get('ssid') or wpa.get('enabled') is not None: + configs.append({ + 'interface': 'wlan0wpa', + 'ssid': wpa.get('ssid', ''), + 'enctype': wpa.get('enctype') or 'psk2', + 'enabled': bool(wpa.get('enabled', True)), + 'hidden': bool(wpa.get('hidden', False)), + 'key': wpa.get('passphrase') or '', + 'channel': 1, + }) + 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': _open_channel(openap.get('channel')), + '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} + + +def h_pineap_advertise(ctx): + enable = bool((ctx.body or {}).get('enable')) + status, data = _daemon_proxy('POST', 'ssidpool/enable' if enable else 'ssidpool/disable', {'enable': enable}) + if status == 200: + update_pineap_state(mode='advanced', advertise=enable) + return status, data + + +def h_pineap_collect(ctx): + enable = bool((ctx.body or {}).get('enable')) + status, data = _daemon_proxy('POST', 'ssidpool/enable_collect' if enable else 'ssidpool/disable_collect', {'enable': enable}) + if status == 200: + update_pineap_state(mode='advanced', collect=enable) + return status, data + + +def h_pineap_interfaces(ctx): + return _daemon_proxy('PUT', 'interfaces/set_interface', ctx.body or {}) + + +def hak5(*args, timeout=30): + rc, out, err = device_run([HAK5CMD] + list(args), timeout=timeout) + return out + + +def _json_or(text): + text = text.strip() + if text.startswith('{') or text.startswith('['): + try: + return json.loads(text) + except ValueError: + return None + return None + + +def _parse_pool_list(text): + obj = _json_or(text) + if isinstance(obj, dict) and 'ssids' in obj: + return [str(s) for s in obj['ssids']] + if isinstance(obj, list): + return [str(s) for s in obj] + out = [] + for line in text.splitlines(): + line = line.strip().strip('"') + low = line.lower() + if not line: + continue + if low in ('ssid', 'ssids') or low.startswith('ssid pool') or low.startswith('no '): + continue + out.append(line) + return out + + +def h_ssids_get(ctx): + return 200, {'ssids': _parse_pool_list(hak5('PINEAPPLE_SSID_POOL_LIST'))} + + +def h_ssids_post(ctx): + body = ctx.body or {} + action = body.get('action') + if action == 'add': + ssid = (body.get('ssid') or '').strip() + if not ssid: + return 400, {'error': 'ssid required'} + hak5('PINEAPPLE_SSID_POOL_ADD', ssid) + elif action == 'remove': + hak5('PINEAPPLE_SSID_POOL_DELETE', (body.get('ssid') or '').strip()) + elif action == 'clear': + hak5('PINEAPPLE_SSID_POOL_CLEAR') + else: + return 400, {'error': 'unknown action'} + return 200, {'ssids': _parse_pool_list(hak5('PINEAPPLE_SSID_POOL_LIST'))} + + +FILTER_DAEMON = { + 'client': ('macfilter/get_config', 'macfilter/set_mode', 'PINEAPPLE_DEVICE_FILTER'), + 'ssid': ('ssidfilter/get_config', 'ssidfilter/set_config', 'PINEAPPLE_NETWORK_FILTER'), +} + + +def load_pineap_state(): + try: + with open(PINEAP_STATE_FILE) as f: + state = json.load(f) + return state if isinstance(state, dict) else {} + except Exception: + return {} + + +def save_pineap_state(state): + tmp = PINEAP_STATE_FILE + '.tmp' + with open(tmp, 'w') as f: + json.dump(state, f) + os.replace(tmp, PINEAP_STATE_FILE) + + +def update_pineap_state(mode=None, **flags): + state = load_pineap_state() + if mode is not None: + state['mode'] = mode + state.update(flags) + save_pineap_state(state) + return state + + +def h_pineap_mode_get(ctx): + state = load_pineap_state() + mode = state.get('mode') + _, config = daemon_sock_call('GET', '/api/pineap/get_config') + _, hostapd = daemon_sock_call('GET', '/api/pineap/hostapd/get_config') + collect = config.get('autossidpool') if isinstance(config, dict) else None + enabled = None + if isinstance(hostapd, dict) and 'pineap_disabled' in hostapd: + enabled = not bool(hostapd['pineap_disabled']) + + # On the Pager, the Mimic/PineAP switch is the response engine itself: + # Passive intentionally leaves it disabled, while Active enables it. + # Treat only a real mismatch with that preset (or disabled collection) as + # a custom/Advanced setup. + expected_enabled = {'passive': False, 'active': True}.get(mode) + engine_mismatch = (enabled is not None and expected_enabled is not None + and enabled != expected_enabled) + if mode in ('passive', 'active') and (collect is False or engine_mismatch): + mode = 'advanced' + state = update_pineap_state(mode='advanced', collect=collect) + elif mode not in ('passive', 'active', 'advanced'): + mode = 'advanced' if collect is False or enabled is False else 'unknown' + + result = dict(state) + result['mode'] = mode + if collect is not None: + result['collect'] = bool(collect) + if enabled is not None: + result['enabled'] = enabled + return 200, result + + +def h_pineap_mode_post(ctx): + mode = ((ctx.body or {}).get('mode') or '').strip().lower() + if mode not in ('passive', 'active', 'advanced'): + return 400, {'error': 'mode must be passive, active, or advanced'} + if mode == 'advanced': + return 200, update_pineap_state(mode='advanced') + + enabled = mode == 'active' + steps = [ + (('enable' if enabled else 'disable') + ' PineAP response engine', + 'PUT', 'hostapd/enable_pineap', {'enable': enabled}), + ('enable SSID collection', 'POST', 'ssidpool/enable_collect', {'enable': True}), + (('enable' if mode == 'active' else 'disable') + ' SSID pool broadcasting', + 'POST', 'ssidpool/enable' if mode == 'active' else 'ssidpool/disable', + {'enable': mode == 'active'}), + ] + for label, method, path, body in steps: + status, data = _daemon_proxy(method, path, body) + if status != 200: + return status, {'error': 'failed to ' + label, 'detail': data} + return 200, update_pineap_state(mode=mode, enabled=enabled, karma=enabled, + collect=True, advertise=(mode == 'active')) + + +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') + values = [str(e) for e in (entries or [])] + if kind == 'ssid': + decoded = [] + for value in values: + try: + raw = base64.b64decode(value, validate=True) + decoded.append(raw.decode('utf-8')) + except (ValueError, TypeError): + # Older daemon builds may return literal SSIDs instead. + decoded.append(value) + values = decoded + return 200, {'mode': mode, 'entries': values} + + +def h_filter_post(ctx, kind): + body = ctx.body or {} + action = body.get('action') + _, _, prefix = FILTER_DAEMON[kind] + status, current = h_filter_get(ctx, kind) + if status != 200: + return status, current + mode = (body.get('mode') or current.get('mode') or '').strip() + if mode not in ('allow', 'deny'): + return 400, {'error': 'mode must be allow or deny'} + + def run_filter(command, *args): + rc, out, err = device_run([HAK5CMD, command] + list(args), timeout=30) + if rc != 0: + return 502, {'error': 'filter command failed', 'detail': err or out} + return None + + if action == 'set_mode': + failed = run_filter('%s_MODE' % prefix, mode) + elif action == 'add': + value = (body.get('value') or '').strip() + if not value: + return 400, {'error': 'value required'} + failed = run_filter('%s_ADD' % prefix, mode, value) + elif action == 'delete': + value = (body.get('value') or '').strip() + if not value: + return 400, {'error': 'value required'} + failed = run_filter('%s_DELETE' % prefix, mode, value) + elif action == 'clear': + failed = run_filter('%s_CLEAR' % prefix, mode) + elif action == 'allow_all': + # "deny" mode means allow by default. An empty deny list therefore + # allows every client/SSID without manufacturing wildcard entries. + failed = run_filter('%s_CLEAR' % prefix, 'deny') + if not failed: + failed = run_filter('%s_MODE' % prefix, 'deny') + else: + return 400, {'error': 'unknown action'} + if failed: + return failed + return h_filter_get(ctx, kind) + + +ENTERPRISE_TABLES = {'basic': 'hostap_basic', 'challenge': 'hostap_challenge'} + + +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} + + +def _proxy_json(method, path, body=None): + status, data = daemon_call(method, path, body=body, token=current_token()) + 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 _payload_daemon(method, path, body=None): + status, data = daemon_call(method, path, body=body, token=current_token(), timeout=45) + if status != 200: + return (502 if status == 0 else status), { + 'error': 'Pager payload service failed', 'detail': data} + if not isinstance(data, (dict, list)): + return 502, {'error': 'Pager payload service returned an invalid response'} + return 200, data + + +def _payload_key(value): + value = str(value or '') + return value if re.match(r'^[A-Za-z0-9._~-]+$', value) else '' + + +def _payload_installed(): + status, data = _payload_daemon('POST', '/api/payloads/portal/updates', {}) + if status != 200: + return status, data + rows = [] + for record in data if isinstance(data, list) else data.get('payloads', []): + if not isinstance(record, dict): + continue + item = record.get('installed') or {} + if not isinstance(item, dict): + continue + rows.append({ + 'uuid': item.get('uuid', ''), + 'key': item.get('key', ''), + 'path': item.get('path', ''), + 'category': item.get('category', ''), + 'title': item.get('title') or item.get('key', ''), + 'author': item.get('author', ''), + 'description': item.get('description', ''), + 'version': item.get('version', ''), + 'launchpoint': item.get('launchpoint') or 'payload.sh', + 'interpreter': item.get('interpreter', ''), + 'disabled': bool(item.get('disabled')), + 'missingmanifest': bool(record.get('missingmanifest')), + 'update': record.get('update') if isinstance(record.get('update'), dict) else None + }) + return 200, {'payloads': rows} + + +def _payload_record(key): + status, data = _payload_installed() + if status != 200: + return status, data + for item in data.get('payloads', []): + if item.get('key') == key: + return 200, item + return 404, {'error': 'Installed payload not found'} + + +def _payload_safe_launch(item): + base = os.path.realpath(str(item.get('path') or '')) + launch = os.path.realpath(os.path.join(base, str(item.get('launchpoint') or 'payload.sh'))) + allowed = any(base == root or base.startswith(root + os.sep) for root in PAYLOAD_ROOTS) + if (not allowed or launch == base or not launch.startswith(base + os.sep) + or not os.path.isfile(launch)): + return None + if base == SELF_PAYLOAD_DIR: + return None + return base, launch + + +def _payload_run_view(run, include_output=True): + proc = run.get('_process') + returncode = proc.poll() if proc is not None else run.get('returncode') + running = returncode is None + if not running and run.get('finished') is None: + run['finished'] = int(time.time()) + run['returncode'] = returncode + view = {key: value for key, value in run.items() if not key.startswith('_') and key != 'log'} + view.update({'running': running, 'returncode': returncode}) + if include_output: + output = '' + try: + with open(run.get('log', ''), 'rb') as handle: + handle.seek(0, 2) + size = handle.tell() + handle.seek(max(0, size - 65536)) + output = handle.read().decode('utf-8', 'replace') + except OSError: + pass + view['output'] = output + return view + + +def h_payloads_index(ctx): + return _payload_daemon('GET', '/api/payloads/portal/index') + + +def h_payloads_installed(ctx): + return _payload_installed() + + +def h_payloads_refresh(ctx): + return _payload_daemon('POST', '/api/payloads/portal/refresh', {}) + + +def h_payloads_install(ctx): + key = _payload_key((ctx.body or {}).get('key', '')) + if not key: + return 400, {'error': 'key required'} + return _payload_daemon('POST', '/api/payloads/portal/%s/install' % key, {}) + + +def h_payloads_remove(ctx): + key = _payload_key((ctx.body or {}).get('key', '')) + if not key: + return 400, {'error': 'key required'} + if key == SELF_PAYLOAD_KEY: + return 409, {'error': 'The active Mark VIII payload cannot remove itself'} + return _payload_daemon('POST', '/api/payloads/portal/%s/remove' % key, {}) + + +def h_payloads_runs(ctx): + with _payload_runs_lock: + rows = [_payload_run_view(run) for run in _payload_runs.values()] + rows.sort(key=lambda row: row.get('started', 0), reverse=True) + return 200, {'runs': rows} + + +def h_payloads_run(ctx): + key = _payload_key((ctx.body or {}).get('key', '')) + if not key: + return 400, {'error': 'valid key required'} + status, item = _payload_record(key) + if status != 200: + return status, item + if item.get('disabled'): + return 409, {'error': 'This payload is disabled'} + safe = _payload_safe_launch(item) + if not safe: + if os.path.realpath(str(item.get('path') or '')) == SELF_PAYLOAD_DIR: + return 409, {'error': 'The Mark VIII payload cannot launch itself'} + return 409, {'error': 'Payload launchpoint is unavailable or unsafe'} + base, launch = safe + interpreter = str(item.get('interpreter') or '').strip() + command = [interpreter, launch] if interpreter else [launch] + try: + os.makedirs(PAYLOAD_RUN_DIR, exist_ok=True) + seed = '%s:%s:%s' % (key, time.time(), os.getpid()) + run_id = hashlib.sha256(seed.encode()).hexdigest()[:12] + log_path = os.path.join(PAYLOAD_RUN_DIR, run_id + '.log') + log_handle = open(log_path, 'wb') + try: + proc = subprocess.Popen(command, cwd=base, stdout=log_handle, + stderr=subprocess.STDOUT, start_new_session=True) + finally: + log_handle.close() + except (OSError, ValueError) as exc: + return 500, {'error': 'Unable to launch payload: %s' % exc} + run = { + 'id': run_id, 'key': key, 'title': item.get('title') or key, + 'category': item.get('category', ''), 'path': base, 'pid': proc.pid, + 'started': int(time.time()), 'finished': None, 'returncode': None, + 'log': log_path, '_process': proc + } + with _payload_runs_lock: + _payload_runs[run_id] = run + return 200, {'run': _payload_run_view(run)} + + +def h_payloads_stop(ctx): + run_id = str((ctx.body or {}).get('id', '')) + with _payload_runs_lock: + run = _payload_runs.get(run_id) + if not run: + return 404, {'error': 'WebUI payload run not found'} + proc = run.get('_process') + if proc is None or proc.poll() is not None: + return 409, {'error': 'Payload is not running'} + try: + if hasattr(os, 'killpg'): + os.killpg(proc.pid, signal.SIGTERM) + else: + proc.terminate() + except OSError as exc: + return 500, {'error': 'Unable to stop payload: %s' % exc} + return 200, {'ok': True} + + +def _tail(text, lines): + return text.splitlines()[-lines:] if lines else [] + + +def _line_count(ctx, default, maximum=2000): + """Parse and bound a log-tail line count from an HTTP query.""" + try: + value = int(ctx.query.get('lines', default)) + except (AttributeError, TypeError, ValueError): + value = default + return max(0, min(maximum, value)) + + +def h_logging_system(ctx): + lines = _line_count(ctx, 200) + rc, out, err = device_run(['logread']) + return 200, {'lines': _tail(out, lines)} + + +PINEAP_LOG = '/var/log/pineapd.log' + + +def h_logging_pineap(ctx): + lines = _line_count(ctx, 200) + if os.path.isfile(PINEAP_LOG): + with open(PINEAP_LOG, 'r', errors='replace') as f: + return 200, {'lines': _tail(f.read(), lines)} + rc, out, err = device_run(['logread']) + relevant = [l for l in out.splitlines() if 'pineap' in l.lower()] + return 200, {'lines': relevant[-lines:]} + + +def h_settings_hostname(ctx): + if ctx.h.command == 'POST': + hostname = (ctx.body or {}).get('hostname', '').strip() + if not hostname: + return 400, {'error': 'hostname required'} + uci_set('system.@system[0].hostname', hostname) + return 200, {'hostname': hostname_data()} + + +def h_settings_password(ctx): + body = ctx.body or {} + newpw = body.get('new_password') or body.get('password', '') + if not newpw: + return 400, {'error': 'password required'} + if len(newpw) < 4: + return 400, {'error': 'password must be at least 4 characters'} + if any(ch in newpw for ch in ('\x00', '\r', '\n')): + return 400, {'error': 'password contains unsupported characters'} + repeat = body.get('repeat_password') + if repeat is not None and repeat != newpw: + return 400, {'error': 'new passwords do not match'} + current = body.get('current_password') + if current is not None: + status, data = daemon_call('POST', '/api/login', + body={'username': 'root', 'password': current}) + if status != 200: + return 403, {'error': 'current password is incorrect'} + password_input = ('%s\n%s\n' % (newpw, newpw)).encode('utf-8') + rc, _, err = device_run(['/bin/passwd', 'root'], timeout=15, + input_data=password_input) + if rc != 0: + return 500, {'error': err.strip() or 'password change failed'} + try: + os.unlink(SESSION_FILE) + except OSError: + pass + return 200, {'ok': True} + + +def h_settings_ntp(ctx): + if ctx.h.command == 'POST': + body = ctx.body or {} + enabled = '1' if body.get('enabled', True) else '0' + uci_set('system.ntp.enabled', enabled) + servers = body.get('servers', []) + if isinstance(servers, list): + uci_delete('system.ntp.server') + for s in servers: + if s.strip(): + uci_add_list('system.ntp.server', s.strip()) + device_run(['/etc/init.d/sysntpd', 'restart']) + rc, out, err = device_run(['uci', 'show', 'system.ntp']) + raw = {} + for line in out.splitlines(): + if '=' in line: + k, v = line.split('=', 1) + raw[k.strip()] = v.strip() + def values(value): + """Parse the one-or-many shell-quoted values emitted by `uci show`.""" + result = [] + for match in re.finditer(r"'([^']*)'|\"([^\"]*)\"|([^\s]+)", value or ''): + result.append(next((g for g in match.groups() if g is not None), '')) + return result + servers = [] + for k, v in raw.items(): + if k.endswith('.server'): + servers.extend(values(v)) + enabled_values = values(raw.get('system.ntp.enabled', '1')) + enabled = (enabled_values[0] if enabled_values else '1') != '0' + return 200, {'enabled': enabled, 'servers': servers} + + +def h_settings_service(ctx): + rc, out, err = device_run(['/etc/init.d/pagerwebui', 'running']) + running = rc == 0 + rc2, out2, err2 = device_run(['test', '-f', '/etc/init.d/pagerwebui']) + return 200, {'running': running, 'background': rc2 == 0} + + +def _uci_get(option, default=''): + rc, out, err = device_run(['uci', 'get', option]) + return out.strip() if rc == 0 else default + + +def _request_is_post(ctx): + return getattr(getattr(ctx, 'h', None), 'command', 'GET') == 'POST' + + +def h_settings_timezone(ctx): + if _request_is_post(ctx): + body = ctx.body or {} + timezone = (body.get('timezone') or '').strip() + zonename = (body.get('zonename') or '').strip() + if not timezone or not re.match(r'^[A-Za-z0-9_+,:./-]{1,96}$', timezone): + return 400, {'error': 'invalid timezone'} + device_run(['uci', 'set', 'system.@system[0].timezone=%s' % timezone]) + if zonename and re.match(r'^[A-Za-z0-9_+./-]{1,96}$', zonename): + device_run(['uci', 'set', 'system.@system[0].zonename=%s' % zonename]) + else: + device_run(['uci', 'delete', 'system.@system[0].zonename']) + device_run(['uci', 'commit', 'system']) + device_run(['/etc/init.d/system', 'reload']) + return 200, { + 'timezone': _uci_get('system.@system[0].timezone', 'UTC'), + 'zonename': _uci_get('system.@system[0].zonename', '') + } + + +def h_settings_sync_time(ctx): + timestamp = (ctx.body or {}).get('timestamp', '') + if isinstance(timestamp, (int, float)): + timestamp = time.strftime('%Y-%m-%d %H:%M:%S', time.gmtime(timestamp)) + timestamp = str(timestamp).strip() + if not re.match(r'^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$', timestamp): + return 400, {'error': 'timestamp must be UTC YYYY-MM-DD HH:MM:SS'} + rc, out, err = device_run(['date', '-u', '-s', timestamp]) + if rc != 0: + return 502, {'error': err or out or 'failed to set time'} + device_run(['hwclock', '-w']) + return 200, {'ok': True, 'utc': timestamp} + + +def _parse_df_rows(text): + rows = [] + for line in text.splitlines()[1:]: + parts = line.split() + if len(parts) < 7: + continue + try: + size = int(parts[2]) * 1024 + used = int(parts[3]) * 1024 + available = int(parts[4]) * 1024 + except ValueError: + continue + rows.append({'filesystem': parts[0], 'format': parts[1], 'size': size, + 'used': used, 'available': available, + 'used_percent': parts[5], 'mount': ' '.join(parts[6:])}) + return rows + + +def h_settings_resources(ctx): + rc, out, err = device_run(['df', '-PT']) + return 200, {'filesystems': _parse_df_rows(out)} + + +def h_settings_usb(ctx): + rc, out, err = device_run(['lsusb']) + devices = [] + pattern = re.compile(r'^Bus\s+(\d+)\s+Device\s+(\d+):\s+ID\s+([0-9A-Fa-f:]+)\s*(.*)$') + for line in out.splitlines(): + match = pattern.match(line.strip()) + if match: + devices.append({'bus': match.group(1), 'device': match.group(2), + 'id': match.group(3), 'name': match.group(4).strip()}) + return 200, {'devices': devices} + + +def h_settings_network(ctx): + _, link_out, _ = device_run(['ip', '-o', 'link', 'show']) + _, addr_out, _ = device_run(['ip', '-o', '-4', 'addr', 'show']) + addresses = {} + for line in addr_out.splitlines(): + match = re.match(r'^\d+:\s+([^\s]+)\s+inet\s+([^\s]+)', line) + if match: + addresses.setdefault(match.group(1).split('@', 1)[0], []).append(match.group(2)) + interfaces = [] + for line in link_out.splitlines(): + match = re.match(r'^\d+:\s+([^:]+):\s+<([^>]*)>.*?(?:link/\S+\s+([^\s]+))?', line) + if not match: + continue + name = match.group(1).split('@', 1)[0] + # Monitor interfaces on the Pager report link/[803] instead of + # link/ether, but still expose a normal MAC immediately afterward. + mac_match = re.search(r'link/\S+\s+([0-9A-Fa-f:]{17})', line) + flags = [f for f in match.group(2).split(',') if f] + # The current Pager iproute build occasionally renders LOWER_UP100. + flags = [re.sub(r'100$', '', f) for f in flags] + interfaces.append({'name': name, 'addresses': addresses.get(name, []), + 'mac': mac_match.group(1).upper() if mac_match else '', + 'flags': flags}) + _, route_out, _ = device_run(['route', '-n']) + routes = [] + for line in route_out.splitlines(): + parts = line.split() + if len(parts) == 8 and re.match(r'^\d+\.\d+\.\d+\.\d+$', parts[0]): + routes.append({'destination': parts[0], 'gateway': parts[1], + 'genmask': parts[2], 'flags': parts[3], + 'metric': parts[4], 'ref': parts[5], 'use': parts[6], + 'interface': parts[7]}) + return 200, {'interfaces': interfaces, 'routes': routes, + 'client': {'interface': 'wlan0cli', + 'enabled': _uci_get('wireless.wlan0cli.disabled', '1') == '0'}, + 'recon_interfaces': [i['name'] for i in interfaces + if i['name'].endswith('mon')]} + + +def h_settings_management_wifi(ctx): + current = _uci_wifi_iface('wlan0mgmt') + if _request_is_post(ctx): + body = ctx.body or {} + enabled = bool(body.get('enabled')) + ssid = (body.get('ssid') or current.get('ssid') or '').strip() + bssid = (body.get('bssid') or current.get('macaddr') or '').strip().upper() + password = body.get('password') or current.get('key') or '' + if enabled and not ssid: + return 400, {'error': 'SSID is required when the management AP is enabled'} + if enabled and len(password) < 8: + return 400, {'error': 'management password must be at least 8 characters'} + if bssid and not re.match(r'^[0-9A-F]{2}(?::[0-9A-F]{2}){5}$', bssid): + return 400, {'error': 'invalid BSSID'} + values = { + 'ssid': ssid, 'hidden': '1' if body.get('hidden') else '0', + 'disabled': '0' if enabled else '1', + 'encryption': 'psk2' if password else 'none' + } + if password: + values['key'] = password + if bssid: + values['macaddr'] = bssid + for key, value in values.items(): + device_run(['uci', 'set', 'wireless.wlan0mgmt.%s=%s' % (key, value)]) + device_run(['uci', 'commit', 'wireless']) + rc, out, err = device_run(['wifi', 'reload'], timeout=45) + if rc != 0: + return 502, {'error': err or out or 'wireless reload failed'} + current = _uci_wifi_iface('wlan0mgmt') + return 200, { + 'ssid': current.get('ssid') or '', 'bssid': current.get('macaddr') or '', + 'hidden': current.get('hidden') == '1', + 'enabled': current.get('disabled') == '0', + 'has_password': bool(current.get('key')) + } + + +PAGER_LED_COLORS = ('red', 'green', 'blue', 'yellow', 'cyan', 'magenta', 'white') + + +def h_settings_hardware(ctx): + prefix = 'system.@pager[0].' + if _request_is_post(ctx): + body = ctx.body or {} + color = (body.get('led_color') or '').lower() + if color not in PAGER_LED_COLORS: + return 400, {'error': 'unsupported LED color'} + values = { + 'led_color': color, + 'vibrate': '1' if body.get('vibrate') else '0', + 'clock24hr': '1' if body.get('clock24hr') else '0', + 'lcd_brightness': str(max(1, min(11, int(body.get('lcd_brightness', 11))))), + 'dim_brightness': str(max(0, min(11, int(body.get('dim_brightness', 2))))), + 'dim_timeout': str(max(0, min(3600, int(body.get('dim_timeout', 15))))), + 'lcd_timeout': str(max(0, min(86400, int(body.get('lcd_timeout', 300))))) + } + for key, value in values.items(): + device_run(['uci', 'set', prefix + key + '=' + value]) + device_run(['uci', 'commit', 'system']) + def number(key, fallback): + try: + return int(_uci_get(prefix + key, str(fallback))) + except ValueError: + return fallback + return 200, { + 'led_color': _uci_get(prefix + 'led_color', 'magenta'), + 'vibrate': _uci_get(prefix + 'vibrate', '1') == '1', + 'clock24hr': _uci_get(prefix + 'clock24hr', '0') == '1', + 'lcd_brightness': number('lcd_brightness', 11), + 'dim_brightness': number('dim_brightness', 2), + 'dim_timeout': number('dim_timeout', 15), + 'lcd_timeout': number('lcd_timeout', 300) + } + + +def h_settings_advanced(ctx): + if _request_is_post(ctx): + channel = (ctx.body or {}).get('update_channel', '').strip().lower() + if channel not in ('stable', 'beta', 'nightly'): + return 400, {'error': 'invalid update channel'} + uci_set('system.updates.channel', channel) + return 200, {'hostname': hostname_data(), + 'update_channel': _uci_get('system.updates.channel', 'stable')} + + +def h_settings_internet(ctx): + rc, _out, _err = device_run( + ['ping', '-c', '1', '-W', '2', '1.1.1.1'], timeout=5) + return 200, {'online': rc == 0, 'checked_at': int(time.time())} + + +def _perform_reboot(): + device_run(['reboot'], timeout=5) + + +def h_settings_reboot(ctx): + timer = threading.Timer(1.0, _perform_reboot) + timer.daemon = True + timer.start() + return 200, {'ok': True} + + +def h_settings_diagnostics(ctx): + commands = [ + ('System', ['uname', '-a']), ('Uptime', ['uptime']), + ('Addresses', ['ip', 'addr']), ('Routes', ['route', '-n']), + ('Filesystems', ['df', '-hT']), ('USB Devices', ['lsusb']), + ('Wireless', ['iw', 'dev']), ('Recent Log', ['logread', '-l', '120']) + ] + sections = [] + report = ['WiFi Pineapple Pager diagnostics', time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime())] + for title, command in commands: + rc, out, err = device_run(command, timeout=30) + text = (out or err or '(no output)').strip() + sections.append({'title': title, 'output': text, 'ok': rc == 0}) + report.extend(['', '=== %s ===' % title, text]) + return 200, {'sections': sections, 'report': '\n'.join(report)} + + +ROUTER.add('POST', r'/api/login', h_login) +ROUTER.add('POST', r'/api/logout', h_logout) +ROUTER.add('GET', r'/api/api_ping', h_api_ping) +ROUTER.add('GET', r'/api/status', h_status) +ROUTER.add('GET', r'/api/device', h_device) +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/mode', h_pineap_mode_get) +ROUTER.add('POST', r'/api/pineap/mode', h_pineap_mode_post) +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/ssids', h_ssids_get) +ROUTER.add('POST', r'/api/pineap/ssids', h_ssids_post) +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) +ROUTER.add('GET', r'/api/pineap/clients', h_clients) +ROUTER.add('POST', r'/api/pineap/clients/kick', h_client_kick) +ROUTER.add('POST', r'/api/pineap/deauth/client', h_deauth_client) +ROUTER.add('POST', r'/api/recon/start', h_recon_start) +ROUTER.add('POST', r'/api/recon/stop', h_recon_stop) +ROUTER.add('GET', r'/api/recon/status', h_recon_status) +ROUTER.add('GET', r'/api/recon/scans', h_recon_scans) +ROUTER.add('GET', r'/api/recon/scans/(\d+)/download/json', h_recon_scan_download) +ROUTER.add('GET', r'/api/recon/scans/(\d+)', h_recon_scan_detail) +ROUTER.add('DELETE', r'/api/recon/scans/(\d+)', h_recon_delete) +ROUTER.add('GET', r'/api/recon/events', h_recon_events) +ROUTER.add('POST', r'/api/recon/examine', h_recon_examine) +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) +ROUTER.add('GET', r'/api/loot/zip', h_loot_zip) +ROUTER.add('POST', r'/api/loot/archive', h_loot_archive) +ROUTER.add('GET', r'/api/payloads/index', h_payloads_index) +ROUTER.add('GET', r'/api/payloads/installed', h_payloads_installed) +ROUTER.add('POST', r'/api/payloads/refresh', h_payloads_refresh) +ROUTER.add('POST', r'/api/payloads/install', h_payloads_install) +ROUTER.add('POST', r'/api/payloads/remove', h_payloads_remove) +ROUTER.add('GET', r'/api/payloads/runs', h_payloads_runs) +ROUTER.add('POST', r'/api/payloads/run', h_payloads_run) +ROUTER.add('POST', r'/api/payloads/stop', h_payloads_stop) +ROUTER.add('GET', r'/api/logging/system', h_logging_system) +ROUTER.add('GET', r'/api/logging/pineap', h_logging_pineap) +ROUTER.add('GET', r'/api/settings/hostname', h_settings_hostname) +ROUTER.add('POST', r'/api/settings/hostname', h_settings_hostname) +ROUTER.add('POST', r'/api/settings/password', h_settings_password) +ROUTER.add('GET', r'/api/settings/ntp', h_settings_ntp) +ROUTER.add('POST', r'/api/settings/ntp', h_settings_ntp) +ROUTER.add('GET', r'/api/settings/service', h_settings_service) +ROUTER.add('GET', r'/api/settings/timezone', h_settings_timezone) +ROUTER.add('POST', r'/api/settings/timezone', h_settings_timezone) +ROUTER.add('POST', r'/api/settings/synctime', h_settings_sync_time) +ROUTER.add('GET', r'/api/settings/resources', h_settings_resources) +ROUTER.add('GET', r'/api/settings/usb', h_settings_usb) +ROUTER.add('GET', r'/api/settings/network', h_settings_network) +ROUTER.add('GET', r'/api/settings/wifi/management', h_settings_management_wifi) +ROUTER.add('POST', r'/api/settings/wifi/management', h_settings_management_wifi) +ROUTER.add('GET', r'/api/settings/hardware', h_settings_hardware) +ROUTER.add('POST', r'/api/settings/hardware', h_settings_hardware) +ROUTER.add('GET', r'/api/settings/advanced', h_settings_advanced) +ROUTER.add('POST', r'/api/settings/advanced', h_settings_advanced) +ROUTER.add('GET', r'/api/settings/internet', h_settings_internet) +ROUTER.add('POST', r'/api/settings/reboot', h_settings_reboot) +ROUTER.add('GET', r'/api/settings/diagnostics', h_settings_diagnostics) + + +def _daemon_ws_connect(path): + """Open an RFC6455 WS to the daemon. Returns (sock, error).""" + import socket as _socket + host = DAEMON_BASE.replace('http://', '').split(':') + sock = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) + sock.settimeout(10) + sock.connect((host[0], int(host[1]))) + key = base64.b64encode(os.urandom(16)).decode('ascii') + sess = load_session() + cookie = 'AUTH_%s=%s' % (sess.get('serverid', ''), sess.get('token', '')) + req = ('GET %s HTTP/1.1\r\n' + 'Host: %s\r\n' + 'Upgrade: websocket\r\n' + 'Connection: Upgrade\r\n' + 'Sec-WebSocket-Key: %s\r\n' + 'Sec-WebSocket-Version: 13\r\n' + 'Cookie: %s\r\n' + '\r\n') % (path, DAEMON_BASE.replace('http://', ''), key, cookie) + sock.sendall(req.encode('ascii')) + resp = b'' + while b'\r\n\r\n' not in resp: + chunk = sock.recv(4096) + if not chunk: + sock.close() + return None, 'daemon closed during handshake' + resp += chunk + if b' 101 ' not in resp.split(b'\r\n', 1)[0]: + sock.close() + return None, resp.split(b'\r\n', 1)[0].decode('ascii', 'replace') + return sock, None + + +def _handle_conn(conn, addr): + try: + conn.settimeout(60) + h = PagerHandler(conn, addr) + while not h.close_connection: + if not h.read_request(): + break + h._dispatch() + except Exception: + pass + finally: + try: + conn.close() + except OSError: + pass + + +def _recon_watchdog_loop(): + while not LIVE_STOP.is_set(): + time.sleep(1) + _recon_watchdog_tick() + + +def serve(): + threading.Thread(target=live_loop, daemon=True).start() + threading.Thread(target=_recon_watchdog_loop, daemon=True).start() + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind((HOST, PORT)) + sock.listen(16) + while True: + conn, addr = sock.accept() + threading.Thread(target=_handle_conn, args=(conn, addr), daemon=True).start() + + +if __name__ == '__main__': + serve() diff --git a/payload/user/remote_access/pager-webui/www/assets/.gitkeep b/payload/user/remote_access/pager-webui/www/assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/payload/user/remote_access/pager-webui/www/assets/logo.png b/payload/user/remote_access/pager-webui/www/assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..b06f58ea1815c5a2df788290e024b0a51cbdca1b GIT binary patch literal 41321 zcmeFabyQqS7ULvRRz00Dvo4NkBIf&>Bt2u^Sf z5ZvwS+&eROhMCElwZ8ZMc`LmZQfJrRRrRZ?eRfry1CbBa6|pfWFaQ7mwz86(765=C z{qqYA1%729{_Q*X4|EqL19t!blkn#k0^n69832HxVJ9p5@S&}=dG_PYr)vW@wG)!-rH8MphfrZ? zSgA^UngUR)Vi_9zg=akpslsn@zssZ>AwZuaz&sL!RnVYB0BOG$G6eu>1qA5pm-n;*IYnm3SjaL_Wk2 zjnfrXPmha+Wl))vok-9eX#Pr?tHU>)oHv9Ycj53e0FaYF3~%k>wa*|<`QV`7i#i-r z&hKp~w+t2*%Qve-B`)xeTl5GTzTp5@Q$WQ~ppG}WOh1rpO)zsjPou4?aHVSjIh)gZ zhb}+a$iInioSIr)S($wBQO3}`PcQJsro$AbckOTzywJJ?3gI;lQA9dh==gl3 z`j%=a5&gNv>{^`SRXyhI74;~?M`epfeKw+HO#;^!a<5_zdEYWb$|SOOF^$|>EzNUW zB1+B+04kqIBKRk878zS2o~SV6>3hJIP62>}Cg=9AENF;O+h>bI-nY9_*NQnz0I0Qc zqALJkA_wBq>8qCNK?4BfazfZD?^7PNlX1XMsM?Wd+cB@q1fR=*K6c39$zWJPsocz1 zOP|WHgbh?uvzdXvN>lU0G;PC@TyVHLbQ^FaTyQVU(X!fEnnTc$?)Rb*n$u55AXr4{ zGrmA0O%FYzZ&yMhhz8MpiXhTtP)QI_;?auIq0?2OK9Fz&ii8^}d`J-M1@Jz-3Tsy6 zNe-#fl=zBOE8AEkMDbi97xRmi2hXcGlAMe$ZZ#AVF@kT_zm(YGPRB{g_He^C$WZw{ zI&gD!cgnmM=0H!f?0?TtidE80QTeD8u_^M{a_GG`575LsF4S&Aku!=O4)|czMG0l{gs33E~LBc>WQxAB8{!k+QtZ zjqyO`_xU;_1S1L~PFjqc9K{*@N)(JqpQf$st58LfIq2AXLswp}bguBNP_ED)7-GD( zkao>K(_UiH>U;8ld=+n%e-#DtlJ~xD-YczdMQ?QQ1>zpdl)b6bs?ZECBINgcB{J}g zL|Nr+S#HWGg)OP=sa?f}#|QjxDKzS7U-U-$Hx)P1HV}`zv9QC4pY=r8u#jhvp_18< zdy`eXR4pJMjVABW;TGcAdbysCo9?Wu&sN74K%CHrk@6trUJ4}}Sf}`1!MnM4n7W6$ z{W@{QCfb4J3c7sS%f;wlQ%iK;y?(f*O{r~DELIf$&Zfv&M=C$a;5rh;pi{L|LpEPC z=e_B2b2lBPC3&?E2faOZNtkDdz{h*<2c`nN*qUBmQ#VCRyeobqBFp!HPp?|*FxgrP zb@`?1S*C|N`Z`Nt+F?-C9MLZ85-Vi^ziJ~7#K`_#YFes(iBgGXiQ-m^VEInr>w*^f zCWFfmoclcwn2nefZQ=%zU$1LVX@4ukC?rvn5IrvEdT0NZ@=cSRcbik;kC$1kCeSQZ z=fXMPmh}s&K=8HBwc|zMu_Bf}N;MV;Wd>`IOuS3L)mfYx+%vPUDQny*E&J9y#XN2C zc|zl`c;1*otO9O~UANIPXxXvbu{-Y`%02pfDr(==e!S9BYb;FM7e6^ae={)x@M=)$07!A^>rT`S1XjgE@K*R zDr+jlFKkp#QZLCGx9P0hulrP2Tu)xPI*BqLB+THzLtTqI0hXGk-`0b{(Z|f^GDN@P;P&mg@-nSwGsn*3KAJv>~-= zbbodj5gn=Ww4Wl4H)fpW-MKcvY1b^852%pXV%%9Dg zWx(hDrL$LdL34Fj3Nt%}+kU<@NJCa5#HMzg5o`%g&?}p$tbgnM?$msjRPsd9EqhsV zSzKt2ch11{vkcpDbY3=?@z(0r?{*ka2xUUBL5vK!A7Ti+Jo5{nZ+jsn zIs-YWn%cP^lhb&g_n2=p%UR@D;IQfXq~^>ISgNVy@a{!Px=zvYwY6tX0YQZw2-8ji-1EkRa!Md zT{wB~J|0^UNet5gp*PbD4Brmw4jTsWu;6gSq^uDejrxHSo0?zZc0#j&FQ|*}Eyrr( zbLQgsW>sv}ZyBOlB(J>a9C;-~TX>!&Cd9kSyrWl3s=Q{X$zmVa`CQ#M6EBpxn|-@O&_S@6 zSobzeMURtOf32nTiAa^WtM3fPGBGlH*dvru@rI~6HpelIk{!LY@}QP`eron1ChdJs z+iMZ*w=i`vRzm}~W+!%xHT1y-^R?y|VXrAAi6f)RiDpRCh=V6tW_?yE!=3xu(r1%v zic3B=S`Ar`SZ}V)ab_9fq`pgAOT%%(o|>+~o_|!cb9^E{15O)YnQ3}fC)=3UCf?8t zDf~1zASR`guXChxp(FTX$6aMY78N=q&YspWoGr9=6YGc-8kW1yu z>Qkxm$akA(sh5t%W$KQuJ3WXah}Wo$ICT?^U&p9NBy_am#@V+Nae8Q5Z(i?ub7Bi( z>&9kwFEP5v`L1l9qR*ewV2e(B#rT&lqK?vfQQJ_1pUAvAc%_h`{p!-t!tjH}XUXC5 z?{j`viZishnf&4gz7Gy{KG{ugJ{?W6IT(t0o$@*`i0gdTY$mtPf8BC${t2%0?gu=N z#S!`2=ykDXu|cDQ29JZPnN>o)2EA7mqNa}DJ-&23fqCZ~4$mr{qJ%yhx=A>W!<9vjOONXh!w%cZ5|$JZS(0+Q-n=T5 z9kLp_mqmJ$d(&S|N&*f#8{3)~9DX_cChZ_Csm(Fq?e%xq7J0=?cSko(>n9&R9+RU- zmpkp=v)=6kaBgCnen?A-+7kvgG5O0RTjK?R4}#^wm^F zEu9^?%&nX)AY49ATXFD|1cR-Ihl?l}?CtH%<;}}CVz5fKpqbMu0E zc{$-ZINg1nJj{JKo!lA!H1c;lau9b*H#-*(J7*`lpLWeHoIO1x7#MyQ^pDTq+U4l- zkAj@s|HckZ5$t2`0_Nf32LBf&HML)>IXeDFO?MA@FL+J<*0TRtpu3K*3k0kMad-A~ zvxLZdL7Y4o|5GKbEdOE0#na8<4?b3wV2A_65w7A6uRPCxtb)tmS0MjT|Ceg~L;PP` z?_p>CSLghT?oY{oRfG80{TH>Ll0Vh{7z%&(kT`t&iORY`%srglbex?XB>x;`f32V3 z%cq~CijGCk&dJKz+nrS$d{^bK{{G7vM9$m;BKcF3lZS_sS5SwCTa-sYl#h>-TUeBv z`=5;bne&bfHTc-KGWRh5U#$E~-n(|3t?aCQ|0gSd<@{G0YHFg&PVOG&PL>d5IZ1fi zx$Nw$L?I#;0{j+Mf}9qD{1%-2!tihA+`JG@L16)IK5lMnO9-#fAC3H1^?x%h=WOZu zb0EP@|2grkoGszz|1ms7&8_)_M6BT4MEC_o;4C2moFd#1b53iB1wW6K0G}YQ70=%b z`X^)mR*;699o#ROJN#3gpKY;%7sksgAY?5pEXXM$Vh-;gK7IjCb8B7;PHS!fejXlv zAuDrki@z7~AI$t)QA&31@X_S^PooSz*8cE3+7Q=&z4~{7gWVq<#Kqjr9rANxN-+F= zX8yzF{KKRC)cIqAi<(>h^gWW6KZh^GN*w$zSO2QkKQ#ZQX7}&i`M)9f)BZoH|MxPy zZ6Qv-8vcKp`XkqWH{$MW?cr_i2DxtoA0hwC%>2{pzpC9eFAo0cs~znAFLZ>vx!3={ zZusA3)&CRS@Yg7|v^96Kfmlg`|2}~Ke&K)Xxj*~!UuV|eXY0SuL~$!iQEO*6M{^HJ zJ4bUH2-wBRMjZUt#lOlF|H}u-Iy*SKsX1FgB>BX_|LO97)PnE4J^XXyP10RUQHD-g zURIDtL{N~EmkaK({xb7V`Tohy&zqbm+}prMzWE=s9Io?k*Y4)m|LHXxJpafq#LEr; zfO&p?|C{PRi|?u${95&&#dlTz($sab^N{5ITWNPL|C83wJF(~=P_^MfBSaG3w7Zw? z=6-1B198xovxDC&+<*E!K3;gE{!qFr`Hz|g|5fv!B!AQVb3px5aQBC&`12|}bc9E( z;D3az|K>#g;cNd-{_2F+j zfc!f78`rP-+>!pqbqC0=lfQBOn$I2SZ(MhP{5ts?*RT29k^aVY2gt9Jzj6JV&mHM+ zTz7!{I{6#duld}O{>F6&$gh*Xas8Ul9qDgecYypl`5V`-`P`BI#&rkCuam!V{hH4m z>2F+jfc!f78`rP-+>!pqbqC0=lfQBOn$I2SZ(MhP{5ts?*RT29k^aVY2gt9Jzj6JV z&mHM+Tz7!{I{6#duld}O{>F6&$gh*Xas8Ul9qDgecYypl`5V`-`P`BI#&rkCuam!V z{hH4m>2F+jfc!f78`rP-+>!pqbqC0=lfT4;@z;x>5GVNCpWg7-Jd-I7)!?s)(pf5L z!GHQO0RYfv0KoYz{O2YB;K>aDd^dx?UHTFLAazbO?NR^$j2kIQk~*9V#4EzOb0wcL0Lg+isF z2=Gq`01*fTPNRwnT}R_ki#|6&FNy?3ts|14q5v>~(UVe_2!Y!+K{?D{#%_Z3atvct zZjn9%?oCMr16r$ZrO;mjVo zgwYh+t0MhCa(bxHM)83W&kRbtd5r3XdLpxQ0DMe4hgy#6q@@r{_){e9xMWBwj5mq1 zLEx9KW_Hg-$mK1spWMa^cwudz7N_wUF>xRNoeuWqkA9(x3-Bs7-D{N(kqtq#z4Z5Y z$1_KamwlEFFM5<3JiL%7e0Ol9BFI{xAFYYf}V zbE-3N?$?8|YgOB`V8~Hi*7a9veSqf6O`F@M#amKfD!}5>u8et!FC+u%o-#}uK824~ z$w@f>a6t~_RUzPU#L;9$3Av>jmG(QXD|W_>rRa6Z`Sw76?q;e{;IkU2V9&X4qrTGt z9cgkrVwcFQMAT^wBu8eJ&(xd{k^J@Zqq$RKJmm^vPWti)H0h(_Wos!wCP@T0o|!#! zYdb6*R_8g{JOm}(S@&H)gdAmh@p4gzK%kz`+U<2c&&jX;&?FfgH#*q3U^@w_y6nlB z=$LYKH$vRZ&|%+(t9gD9k9D-Z%+hgqKRX^~Su$tj@h!SO0?Ze;2-uep+3;$NVJP4#WicCa7=OY51&T4ChUw6#Y&4LyHHh^Q zSx_)rB}i*b1E#U(F+az*B^3OMD@=yp%3|+(b+59J*Uy72L>eloFoQ2lAY1~f*tJ{$ zF09cdeYJLcN9ktk#jh3Jh~>P8w2+I(ONjQR>mjLP?(PJ5(Tf&Pi0F;N=%@#t*a=BW zNN&#Mp_-JiN<;#xT4v4wCNu-8u9&@mNK2S*!ggFJ-Zza;BFypP% z^EV_w1zt_{io*ZQM=;x-AQ&MW#k?P&K}j9;Tn?LkPjDTvhds{*73jc>^is~%bivCk z>8TUX9KqHAy#Om+UYVD)jLl$S16fBQlsaXY5D}ry6<8B8fD2S1@rd4nTFyMj#0AU-8nWfhVM;6zRBQa*c5{=8!XL4JDg!wHjECA43oE^EPZClnx7 z$Ji-?G`FAE0u4`Mk*sMsvdK6FI96^-K^dbe6+s<0 zZ@C2!AH(Xx>B&`;5z^YoB1s*z?_ccnA*$y3&@`=ZKD{bO2}3%-w7u`V$7B{!+tuuf zr3C5HDr>93USyi360=-xg~?kwSdj_kokJz zv3;*agnOFJbPVL#Lg%s4Cl|o;)o0oWNpkzJFU09B?WhSY?dxu>N1`xyFZEiH8WAA8 z0_Y%sZ#w0eN~XQyV6qnBU!8jp9^V4N$4_0}AfLH0C-f=x(V z5m$34VJHXvfu(EpbEb%*2tA=$pT;Nyuvo`l*-)%m!1n5DAj9utRf|?bNZs$X%|L5Y zhVQ8s&b}x8((-A3$D*1Vc))o78PVPcP8))NPlJupP|u}v3GAbxeif7#+6ud+F*iJ_ zd9ytwj4n!iw_mX*#f~2S0G;LW`q^xmuh)Vb==M|%_mITu4nBRY+#ZO&3PP&WRLG_* zP&Ys0OgXH?eUC&QFNL3l>0s$74kmCo?q7Ul?{Fgb#OFo-oGVPUZ{xDlgOzA3@!WES zh%bkXpqUoko;+>Y&Bw=HlG-T+$<{J4>r=v&B+|H!LK|^`@|)%H2|wsqrTs>+jPf3* znVS8fZ^8m!nSgqwC3~h^3;uOgv(YYTNNtC?BI_j0#^YtN8Ib0zgoo}MkS(;fbZ0#z z1g7ofgaYJD@4f zm-CJ4R_Od~{kJeGpXW()Zr#p2BPvbKw0@;T1XuVXWS`hot*IC0%Qops1bm^SfSFZ~ zGWHPTlwrOOeim#)kpORPLwe2}AA-z~?Ix4-QKov?m31J(+J*!bkSd+=oSG^sVL|}G ztGd4$>Zas^RLTTu;q^H8(Va*}c~N4f*6SN*9wC%Q?(C= zYIf!gB$yI(8RM}Y-B%*(sBkbnM(|?*B5FhJV5czBi05sQzoi- zT#-QH$r=ucPL)!S^z?U@#SwpeoRu9LYe=i?WJ;-zy@xIG5-~>3gD@MArKO<3Ub$nr zS7PXz%zKdwoT+_o{G=lMV;5)UqMIoKcyh?SZ^PTLu|+`6cssdsH47+M5x%sgc3y5G z0jqXrXj6nr%=Cuz@PpC=4M3Us+TE+49$tzdy?iCN&j^c!E&HCN9lr~RK5@V@NAwDIa+cqjf{dQ#b=Wqqg+5O@H;C9=?9P$6C=Oi!_t`w%wZHE5Gd zRL4#|_FholqPOYt>f7*teBk^21GgVR*E6V4Z|m+Ued64B0iSpxOBU`VX@clu;r0Gt z=a!1-Ac?&uvOI~nQ zI5J{LHFQ(Nq?Oi1WH0r&!04VD?!pB5?yld^pfV9HZh#TsJkV^%zVk#bXuLb-IJeq~ zl~aQn67>}sVLGs_RPl#{@e1ARM{Wm7rfCNe=dG=DnZ- zQk?Ec_O1mC0ma`KCl5PMG~B*{N%76u3w|k^UVMr@bw=oda!%{gjI}+Sc=^cFRvzL+*AMR1?m+Le~z!^H5R&ggW$S~ zH!|0Q+_Q2}ls}0Q^x@(WPvny4_SrgT-mS!*A>@el7_Zr|a58P1NoayuDj{iH9xAA~ zR~nOcjx#B0doc!T-|XAChDwpTobN#Ol2}aMNdt|_$>HPMCw03pQEi``Kz`4sGV`6_ zG23k7svUr>=Wr-ZRv>^6v2lI!NjE>KV{J%4u1AYPqBN$ z-#DTXlEgw2vpr+Af1?1P47*NOU9MzwQMnIpw})oz2(N|+K_x=M6GqbI`kex|CK_TWw>mexLMc;5qRR^e1vmjb&+=)R9Wz$V4_OV0w`a0~Z5wJ2wbN z$f92yOl?16O}GD)FZ zN~Y%!$R-CCfm)hRpeI+%~}U@2K3-Z6{%KlnD^i-255!`fQW3=1XgaVviP; z=5@pf{hlpOTfS%p=DF)v=j-H3eN!b&wQrw&<0V`3jhup29HKo2vtzA&###|V6|Uhp zP|5XK^~-8eCUOqlo-o#AI1kwlt@S!U+|c4ZH*fhok;u~8wbq;J$cO!4>zXa#ExU`i z0XeA`7j0B2+NBlY#(O$Fj$SB6Xfj3!$K>(|7d-8FKp_%0yCFv$&vk zT&I*t@-vpmCD~F^9g-J$bq`Je3pgb!S}5qPu|-Jd7KZ(t$7)co>@HI6__H^>Xx0b| zCM)r4@8Bb+B+URGO^?I;VI(24k0m}ZE|qC8X*Ro{*Hw#Z^@Fg-nf-%W*@dp+_fL;i zps8=F9e8iyH^BMCRJ3}aW*%guh1lwwitz`KW&n!YyGbDz#f%OGSVnnm3iXbSiTwl@ z^Qj`U=2cH(Gwc>9)*cLwwG4B}^k3cl0>b*eZ7LB|8{z>x6m_kk+$rg{rrAVSOL zV?eg7#h@gE|NZY!w|&$6*{u>7yOInH2#Wwk)pKlQ@s)mSSb7DD>Yp}k#p`9T@wUi{ z{j@_g>8C-vL>pHJUl_k4h_Ega=zUOpzY{;UftPVjjG?eu3NWOX2+_#(!Ox#O`>q7o z{r*+i_0sD_WCX)Obwtib7RXVJb$11ymRXSH0=c2BxU=E+7ijpUD`%r4I^#(!bGX4% zrHwG&zKyu%AR`?z)$WL04JagKxEkZf*^hB))QPhvCB4$`=f2nVT3=p-gg@v4$tmu8 znLX$AQ>UKl0@$yY7dA7qN=69SQBf}#Umb^F_3uFz%d#L4o#LfUiplr(U?Yq;* zU;8$0=@z-UHn6V5BA1+)`1jStP6NMmxv5)Q-IJa%FgO}h2m77J%ejI<@spkv=8x?& z-u7)o_HAe-TD(X;G-6pDQDM?M8*Y>)Tk~ z3M|gBxGL1g9bbjMCv+*3C}o+(yi&w;)$jt=JPq&ny9p525^qvE$Uto%ssED63UiXr zR!S2`Ut`G|lI^gTM8A4OtOsOg&6~HAUB0EL0&hQk0~==I-?i>Xmed!tN6~wPy9dK+ zYpFr9J6MK7sDj;#cpukxr8_boKY=lZtlyxH;=lj$>{axlJ>r>IZtv1l?Ie~P-FCrq z!o4AqCAwf>%>#lN-Y_d`-{k_m(&CkQ^v(eOg<+z`GJZgq)WAF!w;F9~$?5yM8Vyw>ll655MesD|Q$NZI zBvvf}+T658)9x)!`|PQ=I@XJg7jQ?0?yz)zC@{iew4R8OCbv&~<}B${5z|@%Q}-y7 z+9PDBzqX^i*US|`HA)K|urnN+JNQVoMJ<6fyPn}<3Y%;}DjnIVIMr0RWncExXy;t; z6|c<4E@Ay{P>&eUxcC5abU&0ZhdRDuA2Wys7mMYAT~{|}U_{1Kw_@F@2U+4JGpp?{ zlEIksBzs(Y;DuCzlT!BB#YR|m#?^70(u27k@^Ud;g53?bKEopYrzy`b3ED8+2tz_k z#+sd800PI4+uEfLm%E=r3Qk->QqOdx`+05cnKZA`L>XF;;d>~ekxRo7+Y<;OSIzH% zF8JJnkxNa9EOVvLTPnLP8$~eG9*rV4P7k09fN`;&DGP(3{2~ z(B9JwFdKe~AXt<4*kCf{)625_m{!`exK0f}L`9O>i3!MA8K01+2UW5YY*&Kv)TrH` z?Q*%U{#bnKFdn>UV{lEG#KLvWO&_^ru1qvJy--OJeQ< z>QZK%lCWNQohy>|y%ZPjy>#`!ADWFwmXb;S61e9-L7G&8dPaFwyXM3akMzOUCs+|8 za;%nnCUnJnQ$)#7Y#LSLL(s)OrCm)LQns(RgtI3rB{v|)tQqi_pzCneXD2Vd^WEbO zXz$ceQizL!Ncm?!U@%*Qb0DsdA0UHR8QQwaWkVMl6xHFxI+q&jTF84bfVDD;_=(&x zDwG5Lgd_e81PnY3TAY6baO!WGdqU9FlE`v{ z>gHB&x$yeK5gJ(LJHUq3??~VWfo2c0W^(`v*oDd^1x|fxCp9;ya0<1`i%VjxG&Sv%aP-P60(CdZVxE>XMdNW*^X?B*&5Y&_ynVC$gA) zwmi^K6g(^xyDF_V#;}<}5y5=|ji_ubxL4V^q9 z$-}XAJwkDUueMNd&rek1a}~IRCw^#LE(IupD=Rzk`pM(`Zo~!jJXJ-=Q4v-A1lP)e z*xTGayZ)#WGfCDHY_7QHXdEETW(fieJX9Pz!bwCQuE?cOYBV!0f{#CPm0F6z*C)@i z%HMIwx}kNlJ{3!?$xO*W-SD3lW=D*kV2Esms_gAA_iYT5JNY6>^<2GeKZViL4An+| zyS1el66!QL^nYyrzQF70E@j{=3*rUjw7~n2Bb$coy_=~g<`+w}4PD4TOwEENqcW0% zE%OPClv6M><@TRtsVuCg42RlAGHC_~yS(qz5Y$wV+VQQBRPy9bORhpp9@Z0^vtb0? z+Y*r*UPD~aHVYVyA@=W^B}H!`hazp@0E1$!(2pBe#JDK)bzj#lUtzQn$rgo{BoCunzclm~6(*HJh$eZP3lG*@sBi>cmA2lj z^S83jd#VpZxnL++JHe&WmL3U6em!Yd94|aTZ4(Hzn>iZzU9qnarXMYOH($q~&^Ys3 zQ^%iG9+#cAvNF6SS_q3P!^W)#A39T~B@-rqu7_dIyd3-(+dqzKnOJ(k2g?kp#kBTZ z3*3RJkY6{hyJzo8L%+of&*lW6pTJqG+_ zKU@X!z@+t-3wj8$vG}{%N~F?_Eh`ek`ckQ{jnVkKE(;~pnM>^_iSqb;BJBH52YDw;4pD%9?M39;63X#dH; z0rFaVT#eDvvsbA6c4#hvLm5&g&pWD)t&MP`oKDJio*ar9TbvgpEE1K2cr$ZiBoU|5 zL`iSe*6N<}z*74*Jd=lUiH!?II~xL#Gk5lq)UDXId|hMUqqRVm0O`p73piyqGy9@)k61 zTrJfbTqUe&>awr0nPHikCLzgU-vApGF+bo7{i2u~!}Qqq?K|G^8^cyi+K0Z@qLHOa z__^_MO|-X*7<96g?vg649c7~EB(Ao@Z2YKsGa7kn*qJ)Dt-3cgbgAm}l#oF5%Xj$M zYG_1BP{fL*pz1=a(s6@sym04O0$IHujKhqGAm}LUI;Ls}Zkj+(k6yr`LOIvxnuripZh8on#J zOi@49YnIl%b0nty#+$Of9zGYdMDKM+DyfbW#!k0XGIiF*6V~gNp&IaaPeFz;VfIRy z>qmauLQN@d>|Y9ppU=jAcRRI9EnrRx3ii9jLh_;9JVqz2tK-gvmIn-<7?#tR^{^pM zAfgu>L$;c+iy57sR2Vc%r5$B8DqTDz*+!d9m)l>fVSw-8)-4J!v*b;@Cv>BRZ@5v6 z=4Uz`W&$W%7@N1Rch%6vO zj?OZ(JQ3_CWE0dbN8x*=nA9G=p7gU!COm(igJteG|y!D_W!>N3)=)3dTUd zjUaq$dr9P8TtX18>oZW47}4Ir$!aQu?OL`6<5>fl6RR2a%TR|$Yq91`oWaI!G1d3W$v`w@b#G@|# z6_PA@G1r_*AQ=jdi0!tE1^I>Wg1Bx=czGuXdvsyQYqSIHGlBgz9s33YL%^r0^ANLawv9tK(i$-~;o!fhVbko#} zBJ8%2_rk}_HUBg0<_O32fR0qVV4b5XV~nvF#&1q&-&-+YBFH=g&Kj5_4Y=#w05LAUj|^kHYsQJEy*l9<+ev0wcOx>VrN z5Ij^^h9ADb_oUM8YT&z$QmlDkMc`4rY={0qH-ptIto~q+%d#$$nC7HQ0;hig^h`Y` z+Fdi5`zg3b7(d4um3ZIUq$CHSPX;=7pj_7)(QmT+nmm7i(uMmbw2W=h0EFTe%P$J| za^CRGn>bLEqQ3LK@Cs6ZCj6*Sm7r?~enxCxwh{}$+M>G_h9R`Ga4tn8a*FRId?x$Wg=1R4#^V|&oUOnv9%NcUjuq-s#XLQmiDY^!0> zS&{_S2ho^S9wr1`(aR=-)f8F4@tW^Eo!YavddftmfS6hnTY;20Y-mQv`*GPAZPgp* zo9t9}m;5MF@7-koQ8sJy(69fV3h;pwaMR zQ|tLtu3EME!I#CAd7a*-j}+R{JsFZDYYNA2hEV!8)ZiZf>ti?gD#DJS3m1MYGTiW% z`?!`S7am`?!UtJp|JjoycOOQE0DjzpUO^<@tR;-9LnLi3K z+U@Hg*~;Qx_{^F;ne+33FE1GaUH2+@9h%b!tS!@!C`GSUzQC ztNTovvjUMz65GyM(og%UsXlnfMF~q_#=wJpQc6<&XtW9G6DFy?vu!aO5#lYqr4DI-(aq#1Ou%-qZ9J%0heq}Wz(1& zR9=>8{oLNF0Ge>n7hHHy{dn2r5I(GGj!&FmQqe^esfz0Fd*FBSmuifqf|@i{C+^(w zt6c&26^gyn60zEb=uAX=h6MvVbreKj7~t=%zUmsqTYx4~rc6*;B{ORt9tggw@-1MRS4Z;^b90+xz3}56lhm^srhdufIApvx z>aU-o1wT}{B}NZz=v+1_yim8Ibz|_ibB+TH`!D$S~2Ogut=fOU#F!juD^uSj2 zd5^A`vaUj$uEK6a=F<<`xLoMluiwOS8Il|1ugsy0yqmV#A2fPos6xuU)@n!*olh@4 zKlvIRS$rdrwQb^IPX{X3B~gWy4@uhFfl+d84Cnf}HS=EOl_Vvy2imIl31-9D^0G^0 z0;u8pl&JVNXJVV7cK!D{szc)77s{qvU-MqrHGL3%-%)jjxuSDy0Xno|6i4>rq+tro zIT&eO6cSWra{MZ7)2A5_g-K>_T5eF;sBRk-ltnrupfNgrsOnE=$&073co{=tFQQK7 ztPlQS|4ND0AYqVr+XmpGTqfEr7DZA6c-%ly}qe1R>hDr;QCCs`a%$xFXpyIdBJt*wR}?h)pcqLw0w!#!aqEE~SG zd9*?JtWBpGGm9k>U~DqdYR<;x=6);(4IJSJ*@AlkkF#=8gz1Ln8i-&u%Qp_Si)$(4 zes*>WFK64prdsaw@#s&zpRU7dmrzxpD)Bc@$bT$XkxG6!q%j4!KVBs8xnYoi?Tbj| ztMmju?vR-4-{XK#*Q~6v4oaWk+}cH25?g7w%C2zR(0nJ9qGx_HG&z)kJXUnv-k-`p z@{!0IosP!1#=9UU3m%2)k2q>}AxGq0xT$6HBTe=nF8TUHxe8yls+f@?p7D29bLkOqMUkTp>KBI?@* zvZv37u&QWArOlr5`my%}UBS1LPPL|GDnL<;P2%3v6|Blcam>cTh9tY&)yYW0xNg!Z z>-5FoiK7Sb$+WNTOPb;;Vt18`1pKT;JP-<)KYm#x!NKaevW|9J3~Pr!Jm`Lc6I^3; z2$iriMrLK&6N~zE?SQjO=NE%(0Csl57t&N1QqT2)fU-Zh`V1U_qMCdhB4a!%Ls$3X zyhmBlE4>N20>yCyTRk%S3EimDvJ-owo0^Dfk+?bl#vQH!VnSa0Y|b{hNi={Ev;e2A=89b=6H3$K#)4zG14&{w-KJB!d{>_$*;8Qq+-HPQzDG} zDk8T9vfsu8<#q6xyq4R?RI(jDh;4pFtVHWJ0!TxQ3L&T2=Nlo#za%D*+Q3K?(UY6h znchKKXR3@t)&Ho_s8XnQ)EutTt3d)(-LiXeO!U~%sOG|AVe$d-phNj4d4!Bg&%N7| zVx~^gkoAyLqh@x)dxuyHSpHGXm|C-+yo-;EnOfkBZ4>{>N5@vk@c)1VQLeD_uT#y1 zKepy!INP%6-jXf1|0F{6^Zk2&d9l<#uS5l*b5Z zTH&q!KrXvrQ-5_>Xu@|>JLYEGNAgc!ox;yAjNfvWFi?1=(VQKWy3xzuvOU2w)*`yiXwr**HA*}Je*lL@5O8D zEE@_J7lJM@1Diw%f*y&IssWly!2QF|-h;;#gzB5AVSqsHT4;AcpuK7p<*~dUbd-17 z^+VCtnP2)mB0GDYRJk-cw13YQ+~I+~m5UH%+<`1iktL-FlSV5O+%1bc~PALLt4hh&Hw<39~YZ^<%Qk+_ouN3i<#m_&j@}jUaYCh z63jfVc&CA|%oohK>+->vZ60~nK9XyI;GCsreKR(h=!}sdN&op*Ekr?-Ikj9L4aiZa zh{@?pa{efISz@GPBKiW}j2a3c2heVw!u^5I4F~ap5gK>eLxd)f_>kWdh#AdRs3U?A z+;2QnYX8wwH696{Vl;A!uB4!^1)u4*k)iOtN=Z^-`of&=H7j2wBA0web*Ai5xWW@# zSNLBGLSKj&7osuj7{Sbhvh=yaB@8+Oj8d)VZFXuvQK$gM2cWism!t_^!GJSog08el zVfl&mjTHF7K=0yc%lM&vF;=o=Li19nd&7ABi@;69G&7g^L}V`aTz||1SyrS;ruDam zg#%a<=ou@|bFJTo4`;V!lLa zox8vLv#Z=(aBAQ8aq2M$NI&+YDo460kD*D+{dUMe&*L61y-uzhv8N(8ze}ssJU0hv z(PxVvA%?FU(5SLJq3 zaJsv>pFs}a8cXa6Jv34OupN5N)wZ5&#Ujr&liIFkE8r}SQCbu;S2C^z4`V=)OZ|AO zt9)k=FBn6_*PAqWfSf7kYV-^Z&R8$sVrG={>}qTAQGK`P=GvC=iR)|?3x&K=;yFhN zGw~{dR*8BECa;xO18kM7O;7pR?t?XDOtq^782kwoP6u&QfhK+Uamh@A9|P*do|``) zoUu08xpE0(YMP%otQKCS+o65Yo21+I zNUH&DEMqJeQKmr%Csu1po+UFb}fI|Jp; zc$4iJe+pcBH@%eTzonuM*8AFMsnR)oWe1_)+rnVg(M{a~j(21DP++SpQ9|@bdl*~a24nK@ye@rfXc9{+#c*@~ zgMhJ!y%A6pX{pTfdA2-U1q=Fl_;E;UhlE%4+`&*FE{o9#?_p~HA@=bF?d6V957L=q zAJZa`d^bU1q{X@0KR-0u4XbY>FC=egbJH#2VSBtnHU~I-7auvjO8IHKTkYyH0 zmwG}5nMy-~E*nHr-RVB~`w310S2*%G#(vffyF_wDCu%BtyNr(`)!nbI%v9IH0z=2We6Ke@&fbR8)P~?qO!=Mk(nQr9>qprC|u^4uzphP`U=Bq(MSJ zKtSnMIz|ztyN4K%l4J7M@dwvOpQil3X8(7~C`bO2@ z8gpPKT{Uw3sj%2tuiVce|LdqzF}Xb1jI<&K|7!*EzEUB9MFX>F*91+)laJzTiz2ox zg$;;28aL{^K`)3K9A~3s8r2CbDX3}K^4-awY8;uXhyv0canv89e`VJ$nEf|9m_Q;g1H;#=KAP!OvY%+1zV&IZ5+!6^Uu4)v|AQ) zeNtWv2D1pw3(FEDexU5XQJ}V=iY9y$;qizet^sIn@rTiG<~scIt6bzNypJgHxliv!B%$lu-~ayp~9wzs)4Cw%u;*`{+u zurCHW1xCp&>7IIyVoE7@%Hh6kxK=k5VwMdA-p}&!>RBjr8ib`MAuj1f7TA^0O5@#~ z<0nbSR9su87$8Xx%6&+`?Gh**8oy(%pj*7#lP7C+SydP?FrM(HBc<4Xsy4xPp)FL| zi81>Ai`}>}!|cDCA3^$*d4ueE&z_KNf~(dMFYHV~9r7(@g?lkOWeB|AZhF>Rtmk_7 z226=9&xr>QD66>L;sq$gc0zd_xJUF)Zr8x7k(9-N`X=kuY`Ei|aPDN|iR^u5Z(i@9EkkEu;)lzjT{e>hbZ#RdTxA2-1>$ z79fYw$x_hG+N?kGUYsWiU!oFi^Zzn1AHhcoOAtocaT@2X6=!KCybyl#KMdA64yCG> zAb%_X##}lOeR}m2cT{7_%UR|GtAKZ9?ye4pBUc`2t5_9~>Rr#;^cocAY~xRiiq31z260Y^Qjf9qrUX=`=7 zzrK|k#FFLp$0Su&PxnJWJPL9%ZD;>TENY267!Rp5>huBK7DVd}(y9sxGwqkqXoUa3 zU?R(^qglOy)6@WUYI;z|;D(735Ez1mF6wNutw`N~sVl%9p=)3B;6h8<@W=2;tA3t> zK&?lUmsQMbR+OgdRFE>-2Ta36f>S>-9b>de7tBZ+%Bg&Ucd<+!=Bwj`tlM`Z3b}5_ zqI1Y3RynjJ7{p8)9&^&gK{Yh<&UoQc5m@wM5@=KT`CE4kMOsH5u+pLEDb90aDt$3Y zqzTxSVWMS?8Js=q+~6u1Z-Y7TMQw&)IiCzZ;AxklKSLer^ItCJtX`$}`rPc?N#oq( z=6s`e&tLavt63`c_2#dkwR}&$h~~upBh}^l63(tq4yJ|d+M%a}1wH7$KqPi?JeoKn zOc_4q$rq^$zZ58m_ScXjECkOsSCDrUB>69H9YooAX;}F-%X#zJSgO+uk@skL{*%cY z&Fz1f&-W&cf-hv+RkJlbJiz#BGBeWIdnLMNd-Q2d19tYg5-+`0{;Vhj_e*h_u5(gA z*w~cWbGlaeCt6>=)_R@b?T0ve=sYQbCH~dvX?{*uSQ{9u_?s*Bb?8A?2{ee9IUNZwPDv(0Gw4TC2f!Yf;C#!p9CYS}<7WK@qV8FF^dQ6Ft$9I) zw>;6bic<4&)kDhpQBnV8EX2_`(nMChjFSo;1p(ZjJowm}i#0N}C zPj>$@wP%h#PFuu&)%uxLIUp)C_%gs@IyG_?Ccw>F*uwsRc5q1GrPZZBg!1Uk~V+8pIo?O; zRdsVai)Zxc9T(e%YAI^!DK3=6%+LCkwjH85iHgke_n^!W|3b_)lI_`RXU)H7_tp8( z&p1)TsaH}(GHiY$LO7!|N+YZkhe=Xor=wY9VX6DG zHtCxR!Jf=p?1R5oRpcv^+Uj;x>l77C{?wv&Gl)EZ8;XsgD-hIzTZ&dC3Hg$3a_X6^ zx`0U4ZYf{9GsV#-q@F#;xrNY)%sWC~k-S9jA7zp;wsn}r3tnLwE024NYPhUyR=mkcE2<)){oS# zU~hj*!sZVpaG4Lif{B<`NeQ+z$}xi4XwSN<{bd807ECjr&R76un9|`uf$4pV!PejG zV@^1$jYV>P82+y>~Vsy9CH5LebD zlriM?Wmlfhr!wFdK;8WE2~Iiku)W%Cztq(!2KKW1g%G9qZ_*&4DZ}}W-&}DxF0E=m zOa{=3){S&~=5LZJynkG-f8Y_1BV0V*=K0EREe>?hH~(>Ka*1i5$dl_rn5-Z!fEmIqW!SH@#z z@9h|QPXC*!n^xZ0QyKp$To%h@v#T?q0wxw|Wz6{p%dqzJLUGnd--8DBo6zj9b1Tvb zk6f}ecjSXlR^!>vb1E9l=~}%6qeo&Y$td%xxN7hkxTXp74Spu00>?Slwxc!@Q5KRG z*|Cr4x}Dp;8QZ7;^9C}>>i5>i>OtPW>!dkq-|01LaTL1lHVogOVF|dh0{V)KaYGzE*4vrG zo}v*W-u$>|KDs?WJUTJzH?HF-#L~>@Rb@gdWn>-%nrfd?up$k$c>bpke=DE>&(#6j&2Yda{E_ z8PWuz)LKy$V-*_8*hi1;%#W-{DN$E~Y)uzhRqva=jkdE~HdmxWj`S1<`{aY`D9G4) z+A6%kId-A_JsbNbD1pTL@T)k*fRemjze zMF!>HxBKPP`}1Xos71cvr<}i;HdCBG9hGM=k-R+0b!Vr71upyF9?Xxw?plhDlqm*=nlO2NL=)VaGMuw~Ru;`#?YwC; zQmUU|8&&o;3VKs`3m$l6{vTKMam`_N^!aXSFYp=+=vtls=i^v8j(PbMUxqzGuKA0c zIoEZ=t$AVQEWaU}_w%I>{o8hwY5Tb*KxoNg7qM~(J*YkO+nR`ZD5a+;a#;hcKpJc% z?FmIQ!wLfLytzE|b=qLC_dibCwnuu-FF8G&&qaF~ma8?43O6SstO zSa&H+`_u_Fc~te8I{Qmmwyb%Zmc>X3kzhBipGYpB^B!vZ+sQYFNFMExjIM`gU71Hm zrnq3dcZ+-0fq7ZVLO6Pxhj?-sSW_UPODCyD7Z8ef?i@Z=-!PMuTUmDf%QB_WYLocy zzT1pI6FKHkS+|X>j_mJ8@wUFROBIxBDV3V$^KjQ0Ro7@Jw>W=qEw6_r7PWyQa{}i8 zXxTRe&{<0s(9`ri@;d8jR98G{*I_-ToN9aIs`x;y@fGgSf@IN@98XN9}YKuH-pisihI4s(#D#{F{-`S9PiVr%{!8^ zo&9DfX7IcI)cLU|*-BC1-hxJp{swa`FY@{OxwBkHL)8*gB!IEfqLM1yIRta+wqBpl|ytHwaM2WRBU3g2!iA1q@ zm~Mcp8ZmuV<{&R>KzE%hHXUJ@Eu;N$0@#204+|xtmZX5^4+KA@)F-ElY18m% zY<3Wst(47dSI`Iy_{rZ!xSIaZUqSaPB)<6yWl!KlvaK(F@Eb2wu1CwI7dDAG8b#`4 zvQw6Bcyd zn+bAXM+c+~PvVsJbyhfbHt2V3_pF!9et1hj>;?~CLyHiV*a3SlLeK`3bMiIgb>)@65r97DVe;rv!a=Fwyu zYP2p;)nk4$heE9K+xyWRm`5MUrS*T2+;i_>vjm+=9Ep3POcd}eir->~nL@Y@I9D2Y zz$+CMlW16`ekp`E+9621TB51|m;DeA#iE!VgbY#D1L$h0GpoYz3RX;OxiPFZpwa zMRy+_Cx4n9)IYoCLd`8(47_I09y~p030Wwg+GIs7%G_q5#VMu~K^Zd4@0PS7xyNBN z`NQxbUUVNKt6q)}agO;fb^X2V!yigo>&J66mtmg=NHfoFUxx){6FZrPU7b9Cv-`dX z(TuNsD+fe^me}=EF5qcv;(R-%zN8tNZ$s&Tx!&}14zprNK~P@<6~znsOV9xzk&^zm zE_QAXoj-J1{|%B)gjS!3G9D^E`H{2$r&3-qTqe(5A1lWpkCm}ybCUHh#A+pSaiqbd zf*xU!Ztv8=Z+l}aov4@+N>&!oZ^_EI`Pme?GD-S^%MV}xILs*cOz%G?N zHKc_ued8PrV!=B!za<{uYd>zQT^1>!JJ@`;yv1bZerW>XB{43|O|&D1+*adh;7 zi0}m_2dEqL%0gBr@vKoj6Ai6i_7Y(`)}{UmiTG~>UZ^~n&;&O+P}W!>{nAvxur-_MT9 z#9Qh&TKN?i=a6w3?{vTGgIhXcY^M>iWKuF31ZyzA`^$W28iM6H!o-vekwW;Of;{xb zxcg4c`47CQ-Np&xY-`EZ%~JAB3DmUmyo-5yI4o*`NTMX>hTl-@<@|!3CEXw#^os*R zYKK=72`BqHc;CayJ@@_7Ux-yOwj(pG;6Z)r?6 zm0G^;pqn^{gpGPcjubAa$7R3h+!Vh{&@iz1A*kdSx3Xf1g#TR;(rMXkZjJBt{GG8L z`1jTU)a4nGv1wt<$x;Rzfg6u?C|V@L571$r{*Y*ibucPmz&shOM$ZLOSMGfm>w4Zv zr$?9|U_*fT8^Uy`WtpQIw&2{=L-(}K>ItQ&U$T&~&yk);0j^sY_DTKtb%LyJn~6a0 z*-6k#m>u($3#uM>&(Jc#wSUG^cVESGNjg^9R1%!Ck^Os&$bO11ISU7etkd$AwuqxU zdV$S%+{A_oF-q4HZM?gn7_nfI{-yt*;VLvuRlEp{TJ-V$j%SLe?!N8rZ)(b zeG3HiwH4dV(!g=;1zP9N;pqe;D>|E?H|@L3P~p#|kSTC<7B?b0_g?u-8o$s@eH}Pon?xD{U0={BN z>z2az9ntkxN%sReK{2qLp{?@KuA^ih!er0K0BauT)|148)ykBe|HUJou zZv8MU0(GLG+Nv(YPCN^lTazYq6H|~v$(^x zp7qegyLIsW$%r?cZP7^1lwqZ&>^JOmOnT5yen)l_KQ18zA4uK)xX^WxaaeD3u-(&- z#IrLBdcE9$o_tIjKSq&FyY;7WH(qH>Z)8cqvbQY2S@P$dgpqI`_|Bkw!POwZmh?^y z;3TI1)ME(rd?$2~WN9`KFgNMK$h+@UOwOc&qeDQlSmSUUsD`Dc>y?YqrWm~eR1E4I^40LPz z3iE1`mqyG{AJsw!yNbjo<6gVq9zf5O%$E&H@a$r%xdp?W##<(Ue}M0~JbUW*qFH-_ zFEY@=!`N#Mi9QD*@+ZyTUk7G1`~=-kMeFU!J^vV*gpODPomoeF%%HL`{BP*T=3vPT zqN;jIgL>^tlnclmfBI396e39AT=+VbX3+Tv<7oYFX zLcjfP;-Z{ciWWmXOL3UP)!n|llbE+e0$vOO{$abMI83KgHR&W*^rCun<&NLDA^fnL zq2-(%tT-*j`11WrMc0xgiH>4> zmKR#p0wVJ71O4_5xFACBli`&M*9Y#tv-?+W5_%|P=fFNu zitAV#bo*GEGK>d%?6GsHkQjSK^ziCZ5hb@=xNs^-8Se2qmQ9Hf**LEJdxw+JIqkQk zhr@z3hO9rag7e2;q+=)snO1HV7HN03B)cmR`}BbH@UadGMcUZw>Pe6%S6=ce9v;bv_G5FvlSmv0Oo-g`qaAa z3wBqr>jH6_4|XxP*;JieG-d8~(GSmf%WUE_I^+k-EJn4;o$ku&*RI;|>Y0B;Qbc9? z+Rb0t{Q?GYOZV!&Xq<4?oBw`ItF7B(!U^GR<*E!o=>YP88D`AVw>;#^23!f`;KD8r{l)EEF*2)VaM8Nf~i$acCvhLt9o4OCrRP)sLI3 z%UfyX2ZqD6(5(!vVvFOvtD&FO)ym=?GLv7jO}DBk%Q=j zH$A+|B8*eDUmnQTMdD2rLE{zw30waVM;Wu~4K(ihK{b z(8@RWztt1O6V;~+c%I@|8M;OFg~aZLxLEE=HEi>(i~~!~;_$qYa@_8hmt(D4){dYs z@KDRiq`c0fnN0>=ZLrrX|AzT0GLox>N^RVcSXlmOe`KOJ9p7oeR5+~FoLe4y)mk&} z8J%dewq=U3?08V(1P)p#c3cH`P1b7Y%ia$=}u%C2C@SWVUEM z1r?_C#@{%>QNn&`z}kL;x}!{-8~^F8;wxY9K3-%lS|zAeZ?@JWPNlj9s5vbW=V zi{c&7Bi4M)a$O{3(A}X4BR>C~F1clE(Xl)G^bw?}=Uq~R{{8+d1e@o2GleF1DAde& z3%Tex5ZkeYr<+#wFEWU1vSYHIgog?R`K?FW$&(Iv7k4Xnqcx5H8U34v{_^ZI7c+Gb z+{=|LA#-DnXzW?M$=u*z^${k6R;-^5Sy+Q{5V|;f(A@+3lulrLlQ4$`FfahVmZxp^A zo$xe>r9qV*ch}z#H*mz*vs%rV`e0f36P6hZT<({LV{OauMvPHzq;#x62ykH^ z;DO1BGfcbVy@%t+tjao5acHE!bs{N@-?id}r;H5hHhBRdmbk!BK-}SXeAUOe8Svu6 za6B|(?MLpjn@a+bdC#l4?0mWT%G2Zox_1OGk>C9mrWeMtmdCVF#6H3z&q4)P^$)+xHG*Io5N1DqJ0c zx_lAX@6|WmKTy5;JKQX}e8~%{s9v$81w+gH37NqD&*Rzej5()|Jve?S1h182tYl+VeQ=;NpncQ@?|gZZ*1*WfK}LXH>s_9S>)@} zQ+Vkt)XByz4KEw_V$FLRouG!KN_M<TmCPq}5I5#-0j*wkvJbKQ%%zXxP7VOPxx9iA zv4sVMFZ*v$p!mTY*P%;PB-hLwT=2a-?eL2d8y(YM6F=tf)0}R}D|_C}41rQ>@XG%Q z$M*`(LH@;D)qRss5jmHPxAu;4jlQ1X0=I|aqxb-J7X#EvrD92}I>D$wE)I|=F%ZjJ z2!D`>w#F-8lZ{FMy%Row6|#qv&``OZUn9Lhw}SfXOBtfX1wqWGkgi=e`Jp$yCOaL% z+t|X7Qssi-ge=!$6NWeUUg1JjYgTqp8z?QD>BB|+(AxgbW(*;^kFxrM#k&fBE9=By zpMcQ>U*}1#481u`L!5s;!#%-i-+=h~@f*iUm>r3om^`jscZkbtMmMK~Ny4samrKZ+ zw&~o8CWTSM>5RK{ogc)>TCF}=b8c}}kcnq2(4$C}CSh1#9IF|74N64)CKE6U(H-z+ zGsY0z5XSZXaxE$(4w4>80}kWf*Vn<^=Oj4hRdu>|&p*ZA<#SDYnCiI!j-$poc$3G* zg4|l?{QqJUzmfijQDlu^xTIb$P{Ta|;Zk(=g)lENQ>5X!a^EN+&b$rbo)<0$ zpx%Txin`rN(lmV{Rdt7j{N*KkTQU-%+OXng>1CprXU0yRo0-T&kcHEy^OH|n(fye# zsoy*2iLXtptb9sPc$!=928sZsu()y$e`wM!P3|YTKL1AQUnCsa3+RqN@xKw!|HET| zF(|%X0o4eBM>OV~%R!ob!Cf z7U}z3)S3vEUjLj7w*q^D-=i-fL~|00?0&xOyTl?#`VB&-sQcfX)Y?k=zVAV*K;ooB zkP?X%Ewo3fEVbH*y5d|2lyHN(ftY^2;Jvp98n{XGRP8T>3$zUy_uZ31TL6lk`fBeQ zc^Etgu%(03oCnuE>$@G1-y%tlaO>*ev;V^(#SYR3$XZ1JpoOc2JVo|))nB9%*{~dI z?!b142*<wERZY@@uBKpUE&w>OIOm(LGv0p)Im6 zOn*pG?;~Fg*`Ne-J%~gc?P!)-_=>y>-%_7{wep8Zc6I*KyM&=eFUsKv* ziw^k&W*p*N9!ymX6cnO0X{}gMDx4nA-DYN7AcHmEaZLMLu}%?!uRhOjIjs z0kV$+aoUt`xVvX-9bW~17!XrTVvSF`{Kg%!_rI*(1^3T&SAQWg(w9cL!?&k+d5Q&- z`6(xV)vV)pB@jn{UU~wetfqX!eYg8=SVQ%((mM>fi%0J7kNN4{^~BMG{wKG7BPW~V zYOo3jzaD^z*~qb=PSoC!2&pJdahk}WJ1MQ)^(^=HVc2W$ooz-w zOc`dE^#Z)-BK3FIF7}v`Dg!*a#z|nb7|rlfBepAccYN{}?Pt^~3N7Yz z*T(Rp2=$y^_` zx&kkHMJyY&>6%H~DuH7}j-7jl0>32W&=ytg~xD;}#v!qHz2xKOxP3>Gw) za2`}paN*X7aFH7xgxC&YJ%wXPWk^%((W1}ttK{7&o3@e{Y|nRUkO+Kg9e(wlB=+WK zRbQ_?cxD1u6?FQZJ3MM9Y;n3}_!5)NSoR+Y`jV}aDN?oPg(ZGcYz@OubF9SDsj}Mq zB}zOp>q;4MG$wcfxZ2pbx@^BY$$5&OpVSsy^FF`sR#AsYDUm*q!jY=ZAp;syhaE0B zZCz+bQwVMF2c#*jmfePJclF{Z5f8%{p33&7`QH&~wqmPfa9byD2Z{J|vGhE)U`NQd z!=%XOO=b9DwB+REL*_$|-aMC)kzqjrtoQ%rdT&E-MX>DF>0+7X%bBpipNfKpe1)uK G$o~O0As}1; literal 0 HcmV?d00001 diff --git a/payload/user/remote_access/pager-webui/www/assets/pager/A_Button.png b/payload/user/remote_access/pager-webui/www/assets/pager/A_Button.png new file mode 100644 index 0000000000000000000000000000000000000000..a0fd64b6195a0d9e72399a671f107d922cc115f9 GIT binary patch literal 2326 zcmbVOc~ld39uJoah_cAFs2BpaD3DAD36KU62uCbI77(&tFi9p5Aen43Aq0!qSOf)O z)hGgj9LicjA$WisRXlLffE?vi@rhcMLuwQ?(n1FncmMG9?b~^8=KDLopU?MxZ}x_U z1ezGx7-29N6Gjj{43!e}bzY>4eh0OthfwiX#Gi$P!#qR=iXn^-7iL3Ph5(F(!XS_< z|3@1{!(jB}{0J7pVg^$=umBIv>EM$EA{33m(7cmHASVGruxu!nFZ9AqHeSYI`CKnt z6qyMyMSf5mKPW{Eg{Oo>a8eRDo?M*wIxH=jiV_GQ1jHr_5`_|KvKQ_HFBM(S9TRZa z4=PB47w)4|EM_Ry4;DjMPdw3u0{|qfyC>e0;t7Br?mUh&))jCi695tcpt!gKR5Cy% zQm~&M9BNR^@ zeB*N+tF&3B$1=81_8FyzZ5~Xz`{H*>qvU1c8Rgk?jj>77->aT7yf3_Zf$ZEd96K!O z*bzirj~$J#d!MmtDsN}+)``A%db^J5)ZWz^Ka)9dy6t&z_8hZ7xibJ8|- zYA%(|Qkd$P!|lwfXsh9OiKZhRc~w}`Xd)uNzj{| ztf4{f?n}A+nW*>+^(XCWCdkJ_oNn3gcp__ex;t)LZRDpexg;^0PRdo@$$JyM*KP9I z&y=R|E39*)k>1Uv!`jqJTfTa@$sZngyjWkQDl@{2G}i@|XnDBXwcPuX8_n)<>TmJ) zc28$iM)~(Nv=paWW|J4T;NkW zu`TYidnb*;+Lp}t7YQMth&I)tL%MS){NFoM)QdR)Ej+QG7eJi>QH(SJ> z@X5YoXZe0ydt7>lW)X65qTT@>On2`rE+`94_wQU2b0U1^2Nmb%*N2v0Pp`5H{H3rd zVTEdNF>%JWOxN%c;gm`yRfUs$E>;Haw{5i?+gAAWrGYZ6`w*$C`0Jul1IwqS+k@6l zNF9iqw~R#d3(XSfSw*Ld6Bxyq0)>_QrGc)rnN@+Ul3B>&GHh;@>^|9erLMbJA>A-; zoX-wN9`VGs#(VC&0`jPDN-P{--@kg~nct>JKBu>`DWNEzM6Ms$mt0QFHR`x@r4y)) zI!KQ@a5%J9akzUnt^S_#sdLVg>1!ccWB8w0#+N&}u4e zfjKjBHX~Z|HaORNEPK~;P5Fb&uDw@ykGk}#1b+Xl_ElZg9@QS(lA_M3-4n90Vqj71 zy2*eCo^`C`7>Xf}6rWSMt?MehV39%X>Sc=ZlS}D6OUkb<(<^8R8X721#VV-!D<7S) zWkliASOL-dRUEav-fCx?(bdxvOY>ILSBx1$wKwX4vGTDBiTxTzezu^)tgvk1)74)^ zaxGoBg!8u@*SzlEeR4+w_^{pbIejX%8NslR@vvn{H%)I!AGY}<6gq9@?UJysXr8Iw zjvmmw(;vJUMK4@h!0jXGH#z-gHzQNzv>;lI_J=3ma+V2e>euesZWjYZ`g~t=el)sP zR2y1{&n^wl>g%J&^!Gnr$GY}P$87R$NCri)IAjoXE^0qhZ35!u@q-6hkU`-PT$9ka z@n6@b>*PwcL)ZBpr>?fYjX$=LB@;`I8LYg(wsX4QZ!o*S#F})=rzY#-a}&4%e+2e41bImsobJ_x9)x1X7v-AHBIl$i|x0c zpG(QsZ11hgX}WaVcw^=hX?Kn_{M-9?nW`!0YsMs-QvvpU>sAhLT@@rN4xkLCJx;3p z$?L{3J_jG&rFOiYw0w4;cA#K1h$)Sz4ouZtHn7HI44Am~m;~0%{i!hgL+EFHV-$Y? D&pp_9 literal 0 HcmV?d00001 diff --git a/payload/user/remote_access/pager-webui/www/assets/pager/B_Button.png b/payload/user/remote_access/pager-webui/www/assets/pager/B_Button.png new file mode 100644 index 0000000000000000000000000000000000000000..cb53ad3836245f9e562114f2b45f373779264e64 GIT binary patch literal 2544 zcmbVOdo)!09-j;%DM|9EY(si5PX;rL8U`aqj_2s1*)w}%8uKtSHA9+;>!7EFd^^XDG7(}f_h!pO#L;pED1}jUNX&%+KOq&)d6uufn!8S5Gia{)sal*BE*?EW#>nD27^wb`;h6b6mL3_ z=tFSz^mJu7lNkgTPghq0-DL{vCz8oQ5f7g7%}0EH!&3ecOZAe%pj<2s6^rAh1|cX` zEEmgS#S(zzj3W|&r7V!g7fp(mO}6Q4)(lw6-wZ=OQn3*DQfDgv2QmC_`G3Vj|8M>9 zNKp96VfnA&nGzurIeGlP4G8mnpI{NPL{em1p6MKOLZNgG{TTF6W!KeU)ZZfFs4qkJmTR5gszN#!Jc=RZzv-)#l-7%MbP15Vzx2pPY(Y%y4 z%oBw^Zt=~kzIN68<#C!9OaDM$=W;}uduSdUNWvo3yReAKt&&OxxSq_ zY|ZgI>UgUbjl3?e&1qAqi&k@XM2E8|@btSjT~jI%-me%h{Uvg>QXTqxzfJe%nH^R3 zSMeonMu#XjUVoaHxM$OSKTCMU>q!(vZ~d79j$TLHhuVs_uQ%}bhnd3b%OT0QcMF<1 zjV6u~9r5vQenE|_k&o~N6=wkZ8@0T(tl9BfR@DmMqx{3evKCzG?_2zrQn|0RzeVV# zxVd&!>W<;73X@@l_FuYY=$gwMyuTXU)r%ek!^r zN*q+~rU|}j8<^FFmltbh!NitQ^|_t-r(b(LI6ipX<|FneOfqi?_vMQU9K0{917WoS z^!6SrS1jGqyXMWFT=3|Or>#~xw$~R}4!Y?yN&sSCR~nEr$F4DN`EH?jx@yI#JPV`k zop}!bmOCsNT772w@W~I;)k({T+)*Q@C(ZLJ#W~^INooWAYR}8YIdOq~vWcK2=1WFC zW(|kfI_h!Kjs;Z5)R$$qsYu)AR(KVa^~Wt79sF0W@tEpm(BTK}g|K|qNw-fxlHr5g z-Y&mh$i6L6k?z&Sd}~}an(yD;SblCL)U3aO_12C;%A|~E@oR6`su8?<1>4N&y%Mus`0?fk52l$9Bw zkH(*D_{}a=-yD1IdaQl~keQv{`e09z7#GzE!Z0bh#!%h~_@h-1$^NXxebpO_ zX1iJU=czeq?@oq7DH(SYGFXP!;0#ugwz)N!KIhJ^xOJ$5p;{$}V(a=BxijgJw(&T~ zs(pvm;-}st1&xKjl0bV`T3xDb%CZ~Q`Xq10-G~hBoL9_Mt%k6#&gTkCBdefC=8)xM zEw*n?fNW{=W{a$$X=)WFnAcR0NSK})Ga*aHMri^U&}=6%iy6SB9UA}%TD@%IT2+Hw zFw=;eG~rs@8?HAUJ#Eo|ZAf4~bsfpfcOThTx5aJMEb5*MoQEmr$CeCK;8+!DRQJ73 zrm02CE&86M_i`#t`on*23^j@Zi!=B|kN1W!^77LoMHh^$A&=fy)NxJ1r@aMChLP3H z);9O`&D`Fn_4-l%^It2$$46A3Ov+x?a~ev!vvGEF<~2M~)?V!2FnTJcs%??r;(cOD zLkZb#_NdP3L)B69$SzA7o>^!6fSit?Cp~CQ2YXR|v4MSzvrrOgkvgqLJ(55lS|5qN zkJ03|)g&$#a%n}^7w1~B8mv@B@tH<}8E-H*KQ&H;AKHuTn_LzeiR8YZ%bqD6-t*4}+nheD&q{|tP!**(^0@*m94JCITA$w~ej Dd3zsi literal 0 HcmV?d00001 diff --git a/payload/user/remote_access/pager-webui/www/assets/pager/DOWN.png b/payload/user/remote_access/pager-webui/www/assets/pager/DOWN.png new file mode 100644 index 0000000000000000000000000000000000000000..826257ca87ab651158b6659dcb8390200f27210b GIT binary patch literal 2051 zcmbVNdsGuw8XtMMIr3H(AtIdwM2jXf3FditNC<&cqcI>4(b7pWK#IwP$*>UQVXX>6 zj%;}psfdsO&1wZIwJZg-P~@>HN~J!iTDwRU(UszAt4D1oBJTd->DjY$&dj~{yT9M} z{?6=`ua90n3jpyFw7jSo@4_+NO+DOQDvY6s6^8=x={Lqu2XbS zqYkAfaAdGdABm=GVsddbHg|oTDmOzVP}9T1z>pjvsX&Vo2$-YYrZWh0Lg_E{3dy}q z4AH@t5F#U#KI>GXOb$k3I0_1wEQSh(gF&8vDc}lVgwIP=tpeFFn*+hY5X@z;VIc<= zvbf-@hfW&A)u}>-Sn|poc?zYc6NFv}LD|{a%3TfkO+pss3VXZ zhR)zWr$LMwRJcY@XfPdU(}*asEFzRnIy)PMRxgviR;)9;$`zS4CuCadK6Ni1}qC#ktIp>pCgm$`(r~kAnA>81Fj+Kf@~9Gsw^$4Bc$R` zI(frXYt+JEHo`(!96p1qRKkqaNGgZHPeqgrCBhHpaFpc2WzFe%6JIQj;IJb_FkcWA z!D2~ZzDUFub2(y|C*t$r2;Ll5sxuIXPKD0d){wSmxe<~-1yxIMObfouvrzK}AO5%aXZh;?H-8Z6 z6l80b|7xB&5!sQp?dxeE53lD5)saJllhg8XeEU-XZ~&#^h`5}wd$q|io*TUf($2q2 zK%dk|TCQ8$h)IFG0yHl?>U=0DuXr#fsJMP-x!=q(+Tm)yvu&%gIH%1qz4es#6$xk7 zwoGw2Tf1A1Y#%vzJq)_@)l|#B?*9F@>0x1VV(Q5!FP;tO-LA!4&vQkc3hjv>4w`PJ z953)(K0n}b$Fk*<7Zq8wCF#7v@lM>@#s{D4+D`Y5D~3MXp!~k7^qaI;f@1uq{O63T zkCJIyp2o{EKkqN9$)CrbD)&8{;lAvU>-e1{^!}#(hr&mM2W}O6$sX?T8?O6qarYxA z#p(D}Ym{%$it@j4=**O`k?IARx`O(yjjk62`hf0)l{d>LRysN+g;!@un=bA4c{;tZ z_v(1Ei<_n6K@&5&llP!;%f7spWBA_Z=Z$^V_w_Zkb!$(!H_2flvtnrf!K&}fBR3~6 zY^Vw!bqcAgh_wIkSr0ADmG(|R`LDYiB$&t2MWsh0CH}2l(QQe(Oy7x7DlL#kb>2g* zDj7-6YyGUxSf8QuU{3ofcu1Uj@vR9~c4pft>Gk6F$fH^(!RW4wt10Y3Ux{h#6uS(& z?YPh4Ycck{RWIE$wpeLxa)FAxdntuG)_jt>yeK!nebE9f_BxuxlKwgxcjZdTN=-9?`#I_9=T2TRQnW-jqToF)V0o3Y(MDdf2(al zUzjPzVXy#2zHNAX%7K52BiUuQdaWzV*;wS!37k8b-|T%CwD5YIOoNW;!?Y`U?UZ}@ z!Y`t|JzU(amRR6^>7A6aueSv@7moOwDxWymi)EaFtSl#Y4|L~Z?+-q@+b=26_mpty zP3xvTE$DijS36kUI2Dy2$d|*4T@U@$KHAysstG^Us>V zU*`G#)VRu?5+=H_l=Xr6mztcuf`v(2H=$)7XMmDY*31*GCsi~cS(3Da6xc2ETWV>( z{N=^rslj~budOo~yWed+e{Kk=2%Uk?G<>+H^FbnZ&DFRm$ihb~XH72}RTjgEuu0yZ zEcAiS?)S{kS^bOlS}OB-US~_{!Y<)~Ki1#>KG5&l)B(m1KV9;?`RuKU_PUCJ=uY1! z^6m8g6m`aB$IaIdQe%IX<^8tNJv-o1P-&U9GT1)>a_Ol_8dVJUKW&(S-ma=DT&TKn zZ_rNb+%)c1)>KQq`qgO>`zI4_uUe#qId9Bjz9R%^sQ?ovPk* e=AOcN^MTe%$4^D~{;|*YTb4$x7q^Js+xa{HA|(C* literal 0 HcmV?d00001 diff --git a/payload/user/remote_access/pager-webui/www/assets/pager/LEFT.png b/payload/user/remote_access/pager-webui/www/assets/pager/LEFT.png new file mode 100644 index 0000000000000000000000000000000000000000..3d19bfe09b98fff85eb43c68fe384dc40251a596 GIT binary patch literal 3162 zcmbVOc{r478y`X$DI}y~jFNR0V+Mmd7;CodTZfr3W5zPW%vi=C*+aIXq=iUCmQvZb zLQ<3>TedIZl+3Zq_KlYFeSe(mT;KJ**Y&>7bKlSJcW=*qU)OuV%ECwxCmQ z7;Dbzz!)0zlV;iX(tbe6gMcYXa7T>EA)n1^{+3 z$+nIRM{_eYp6Uz1?qDFnzJ44w0HA#;*bj^MCNMxaf+v}x1D>mG1cS&PI$#H+In3No zpFkoThtLQ%Ar`jy5O2Ju2l&)UkajSdW5Acdz=DE(eJFHvunzdUT{LIEqlSV(-ysZd z9q^B!9L=pj`cxVLqzQqm;9)QXNL>@6iPD5&HPnfCWsoXN6$ynQpfHq*Dh!Q;q2Vad z&j-wLNb?|~tucl_-EkxxFp0tNLqnlKK|zop1cXZSgu*p7HK8z7sH&<82cbe|QW)4^ z6$)MUmjw)gj;E3R7-T91v||yAqXsZ^z?@)zWZ~;)ZvK~H3jJrTI8}oNWBs6T2n_1$ zyOY{?G@W5h_)i#ri>BK${RmKN0-YK_!*i~LDEo`dDc?Uk+5vK+L0i(uoO{9gV5s;2 zUjl_;g3$qUP9Pp+4>Up*3&+Bd8Y(Cp4yK}pB_dTch*+Eo4y%DcB5|Ar1^;E|@Aw#u zrlBEROoBF66R&Ux%#xqnaxuJ%3G0NGjJ|O~hzR zWSeOmddw~KQ_^!v#azKn9W%Es1!=aKpp63Q(Z??BnXlS2TT!_Bks@ivbn3k|x63S+ z(;TWye| z(Rg*;O~$9;e+GRHUEYxK*Vkt;{Q`U{C2K5`tM^@>q(x4LFw~hzk);W)Qe|dCV}uIue$%4L{1PR8pjt>o%d+c0 zq=a43wK!NR9(4V-RgtWC=xi;sc<=ZzK-xFFf7wmBAS(@~vl`yMJ^RV(o>RQ5*@!;z zr{0H0=f+@LoqT1&|Ket20E&=t%lJj=`msA70=T|4P9MK_%cfpTcXFhYV=x z*d$AoPX3v2?lg0sbp-E3kH$eq^|uaWao7R|f8;rYYR@mlobG%0CKx zE!3l0C*;X<)B`_!xeiefI*XR}H@VoGdx2X#Hdbu#p+ir)USR2EZ_(r%LQmVQ55#mj z@H|PVpzKSDj=3mtFxN_v94hxkv46Smuxa>MInU61Tuhw)8f!Oi^Qd*!Vtf8EN$GGk zw~dK~Z$qN{#wDBtXNS~rU#iAZQ`@$mPpYNhj1~;c17sT4>LlalQ{p8y-ARR=virni z%1QU~08b0}vI8oIUZhZ{s;(O@Rz~BCPBObYM+*h6M2ET@NywgjGBek?6u{M@IIMgQ z56eyF&X=M;+(3P(YA(wg`!u=hBnJ_a7U5>2-%m`V2qkUgwO?$%yMf~33eR$Bdp@Gu zEGr9^nt8v!dq$zLB&mIJUfn7}Py%wL@w}d|O7nN|<5@}R_XwEZ;?;`1isyFmO5Qpy z1Qdrl0n3sEo}?l}2A!h^JQN`chI>U7a;PK5XWo@$Ar2g9uq*D}o7W`%y80jil}&%} z@UCUnOJ%WRqr7i<+bTeJtQ{s*kPnPaU5k53UP zqnb(BeYp1m(Z?wjC@nx6ZN`~7rJBZMfQCb#OKOS1+;RrPAH-%Tk4cz(08cbfh?Zfy zjyUr0T>Y%TK(Zd|g=89B=~@!yW*zb;T@QLtSi-m^!+oUve%|X75(2la2jc6$PkiX-lg(6r&5B4zm+0wT;&OX6PMD6phyN+L$&? zwgQ}}s=b+U2w^&v#4lze+TSTEWzTW|j-{`Bl~{I&gUc$pXzH(wJOjqGyuk64ud&-w z|FD;D5?Rbr1U+7_PBL98CE=!9+l!))g--&u zpKR57gFBywd7o%>X>LdW)Ju6?*=}h^RLV4$c!{%X2bUszT8sA3qDPMm@PE;EjL5Nx z&5@@*)6~*KqSw{rv8*j*S%dAx>wkJ*L*3yf z_PsO!Aad>`zEfMRdbL^dWyzuVkSX@7=j2wG*3|)2ARcd;wPzxux%X+Ty^Q4M(Qyg6 zFRnhT7vXU|xk24#wY^?_>9Wapi$z3>`{db10NIwdciH9fhNXVmH#E=I(wvGMqXQ(k z>*6OeGavJw|5rnq{_jN=merHZiz)U?t=DOLKs9}vV!hevRFlRFp6B@ep9Z!jko;P^ zx-woR+KJvvlM3xOc0Tr~%9QExp!|C?L*ap37zoKUp$|7i7)$3f0YnBjhzX^z>7h(2lgFLiBG{e*+u;Z$#Np)xY^IP7jblf01o$`y*jHY>e7$;%gh9Wm z2qPR|-<+a(ctc&dd?wT$frba8P&QCIdxSmK9!1C5g#=qeF(`~J5@mx#Vc{4Q-WG*N zW1-(4nA{+r5rU_ZT)&%>?;K!ZLLm>2M8?L(B4TY2Tz)7LZEtUnL}8E^3|y`O7l=4Q zdK{c1*z$vc#1sVcSv(<&%Ym*k(u26sLI;@K*>6#>c^)2r5OW0Ib0yCjGLFteq7f)0 zo4p#^S8aii%KVQTf2u9;74euzDpSCX<_F7560+q7Sf0MWHngfJ_Xh99XUXeAk0fz} zquET3kV0~R$!`!076WgCp`+<&TO1r46oi6rqlehSaUt{|cn}?DV{02E7g+QUocV_*%NA=58ia5 zF=8DtGM{#%=0=DqS|#|nL~ABRJnqoFv1q3jZe)DcsI|^us&=-1Xes8!#|31!?+MUP z)QlzJ@KmcH>*Qfw*-XRAN=oCy-jd4dSg#4yrEN>UhXDs<;GtKLbf>a6n+URsg_JVi z->U;2e`ueFL&$l_;GEzV`ptLfnM4%M~9ddY@sk}PWV=f{<6jgj0{Dm}8aPO5u469B_F=8MHqo#rjQ?G@*Ssx-)- zX4UJ{<{hv9ZIqGhuBduRd$erJogBjE)8OZIX1gfU+3o22@sYEff%NHOBTXL+usPj5 z>#}=`?s*YI{b(msYB|EiLpJVsfAf|5jUGu@Z~va-nOk*&yc9J-os&`-Q6~#x9Q81C#S6t&5%kR$hCon%U2Av@4MC- zWSU23=uMBR9;~-0*f42j@m4z%Dv&NL9s45L8ed?RM<{C7tTtAvnLHjd5lg;8ZMNDt z;C$M=ce}1{NBOB6%Oxd=_nfQPMcs~6#+&DthyBVqMXK(jb~WkGrW3Curui>lI8#G5 z>&6*l$7cz|0cx2kUl;sN*idCi052$aOFFN9>>~0;BpuS+7>Rajrfy2u01u>n;bqa1 zF(k7Vwl?-|olm-^#))W`p{nj2_bIWZ<-9?e!_^5;33JgXYx|Z5?`6@dJEv*sfwPDA zoj7`|_L8k3L6+beRQ1-`-~NH5C{;ykkZ=NuiyEMmj2V9nUoqAL>_GziA@hh1gQ)%C zn%qIpns|s4m2lolpwJumv9ckRYoPEt{gft1C82+zDh05=tFLpUzhQQBe?V2v9Xj~rW_6a+ZF8`wTiI0G1EdcG zV$6~1AKsC?4ewPStUs!G^7ZuP;X2WmV=0G~LPQA*#l@wZp&O@z!1KyDA8_8A@waYh zN$OJ#7bgcv>b{uWB&V&C>~)R;h_~V|700JQZ7K@ywQ-bt1xySDT1<89z$kNx8uL7J zV=ic%>}To@AnlKjUr|upzwGC#7!=X8^QvF2y9qqmC>?m$U$4QltTFE8oy*$xIXXIN z4aS^~=E^k+lxA_-7t`kO~Y z=kB>#p___K<0?DNrg)}G$#3`^&zS1sE19nju@UQA**0;Nz=3xx7pH_zm&Q7qvQ(5b z)-1E10ZTyoIt~6K7l{y{ipoj|l@Tf%$nVVZK5!rslb@-gs3^883($jSo!LnOEWEQ2 zoc7&S;%mtqQ`%_F>_nng{1|5w%`L4Nk&A`IX4wX%5y+89FYn3C>xzFRf;WaqLAQ;R zWsMPF4TDH2DBLhLu*orA-;k}@+-BaAaM8=n)q-t#c<%o8>ZgszpdrO~O2NC&LEWMs zKx@<=&dBIiNeYQ$L9K1@-YuC1TWcUnBYAILMczyaJdk;Z5b%0Y2#t!-si5TmEsA@v zC>Y7j0)O*yU%&<39YqC$Q{rdteCl2oay$z5WwPugDm7!2d2oA#;bp(Gx!x^(bIo`Z zoIDWMjLdlrwiQd;TX(LhJUd}ZY%_e*qVI|Ba!% zf5?9>cW+0>eB#GW$djZ!T903p8i<6lOJ{5<3#>X6p4IA$1c@gK`8MHKlJ}q=$mYbr zzGE@DPNlW>rH&7ipIo;Hjb3~>SCi~B5Voj!)=}B6#sBfMflbi0LgAtI!Cu;Q*K*;5 z?bvI5=aX+-xn#5(b!&%rWaD7G%Ui9NbIsMA)Nt#|kf^~l|6|gE*Vq?rJ?=YE%xk&O zFR#;2Zn6xYxNi1n61Gsi}x(-1~~lLaps z10JZ@$b0$xF!lIaiqy4Z{fCCCq`G(elaJ8Y4kF8j=+@<@FVaSKU7h2W zr-kB*(fFHBtJELHzAv8WIjH`E;4+rCqJ{#bAF0i~T_=uQ{aL4wJxSM`15^JEaXKh8 literal 0 HcmV?d00001 diff --git a/payload/user/remote_access/pager-webui/www/assets/pager/UP.png b/payload/user/remote_access/pager-webui/www/assets/pager/UP.png new file mode 100644 index 0000000000000000000000000000000000000000..a6ff36b611493ec3c5b71b5acf713ada19371c0f GIT binary patch literal 2036 zcmbVNc~ld39uG0rNYN&Ea5kNUqH-jeBm|N`Y$4$Y5{{Tc#IiC;CL{~V#AJXFWepY^ z(Wd3lXVnVJ10shaD^^*RYSCI+&_YpI1=cS5RA^aPtA$FfJ5jOy!`HWO=e?Ql@A!T` z-}_BjYI6J%cVBlL4!1;-AeLcrH}urj z2I$m6S_W4NN{u2|t4pw&;PuvIxyqWW;;U(Itp%bi0*pWpqYz-x=NZfbi;(t|SAeZu z$4naVR0Yiy(w;e`kfs76!~_F;28*r&K@JeXXYj-MAjFH%s6qiY$mTLZ4igNgvq1qD z6tKd9=MN1tXi{qgGI88p^UZZ0yQ zkcDnA2h1~wVYAAlGom`g0Js<-C6bQ{X_&LmqR<k| zw5Qr;R0jX%#!I!$@**S5l)+{s-=xAyq6wG>W9j>2LoP+k8$pUmht&nj6C$4NmoKV4wtLML^x}n=Vf@YIGV+c6~*xQ zvC%A69LS4_;fce!VlX0x#{;7y=CKlk8HEfgc;2=Sv;7x1wFY)1ji~kv3{r~2V zi8;k|HOudsXZ{H5NZ0bkG+>(-^8_2PAu?gpGQ4|N1P(_aOT^J~%Z(pC*_5z8&Hu|O z;qb_f(e%vZo@;kToUc(6$jcX!0}Z{Gmr%>=?ORj3C%*8it!`+uPB%1mhn-jxOrR32 z5#+rwX;r{i+xBQCMmpyPHWdD8^z5B;mk*gcHlaz^DgU0jaJTSE=gf%6ez&oM@ZN)i zOPxRe-H;7BDl2dIh2@{sHjPA1nL^hrhbmsXNPfgFPtVwvtraWVhxs4B-my7<@cRSy z-od~J|Ehar&2rvMS@$sUo#dMPqvwR32$E2mnsKW6c<`$yc}+=8($mX z(Lc$7_oU{YVfbH~&HixZO6%7>dlLhpa!SPa-?jK3AK!ZH(hP_SvL0XF7`!*}f;RI& zO{JKae6)C7nD=9+cTM@NE#8KYyv9n9ACgzs$9hsNHwK0e6N(=v9$C0Odv>OLS$(Xc z&d)z_!KU~X1mZ<|l*8$SaW_|<$^>NGF`jSeBQ2Xidndp)4p)DQCpT3j`{7MRAA6T{ z53Gp{A$suLlmyQ(Q&E(f#P;Fiwn;p9rj(6-4th{a@0<9Qgt0n}_u*kjB<20ZXrh2B-Trc)s#@)j_=?)J z)-)rrsqTXr#7IZ_tIGlj%xd@DlV!^j`m`+> zfwD-cf?E1r>K;W2!Qp>J_F(b#BS(nTtG-!X3&(E#WbGenJijWgd)K#Wst-PP5p;BA zu_G`2Zw&iY;m$V~m+pw0<7}UfFHQZ-(a^6Dtgh;*db9km{XdT>LkYX61g~#a+-^@G z;eqS9vo?>>MN3nvr;q;1(e$4j_{`QtaS!bo9F+Tcyb`=2rFb^}eS47nRECl7HCUYa z#@er~S1NwYp-gThix1!KQVxs;{fDurs+w{(ofEP*D1Y)$bJqGNmMq4m1Cd)UtzHol z*wn$>hrkj4;J%{SOn$eif7?WwB(Sbv#yK(Ubt`v>vCD%$ElQyzbvbPH&69WdeHWZT z{S6JYrHjY!seOGps}9#60vpxG=sPSQ#9ut-{mVzB-pU$-e&ExSx-b9I-5`@mEVh>8 z%GT2pGn8wmb8LR3v1RUQ!p`Pa0}KF@J$o7-wo_YnM5dppml=!&zh>CnaMJ0t`y;i@ QV%L9I5}Pb;jmh5rU;En|JOBUy literal 0 HcmV?d00001 diff --git a/payload/user/remote_access/pager-webui/www/assets/pager/spacer.gif b/payload/user/remote_access/pager-webui/www/assets/pager/spacer.gif new file mode 100644 index 0000000000000000000000000000000000000000..5bfd67a2d6f72ac3a55cbfcea5866e841d22f5d9 GIT binary patch literal 43 mcmZ?wbhEHbWMp7uXkdT>#h)yUAf^t80Ld^gF}W}@SOWlZ0R#L1 literal 0 HcmV?d00001 diff --git a/payload/user/remote_access/pager-webui/www/assets/pager/virtual_pager_01.png b/payload/user/remote_access/pager-webui/www/assets/pager/virtual_pager_01.png new file mode 100644 index 0000000000000000000000000000000000000000..02f72943d269653c48c8d652430a85a0223190fa GIT binary patch literal 5167 zcmbVPc|4Ts+aIK&P%4hHuVtIDGj>MyC1e?-F$RN~VP+U>oJz73vTxxK6_e~`EoI-u zObC^I%dtm{-=lLn@B7F3{66pJoj>NepXdHw-)p(A<$0nk%=B59FEWEbAQnRdm?a2A zKM$;RPaX&UdwKs%0xn!cJv*Wm&W%Vy;87rLSDXuq-w=avM_Hl}u7SQCC^Zm>ArNhC zN3=6Ffg*7jX~YpmIsoGXNP|FXngKotq!)_F?}BniW7P#$>zV}l(XQ%(whE>YQy*QF z2ihPAkFp9fvqlDaAyr%jH8uFv0-yi^21P{h2VlIh1ZaS|;7?vCus(VW7Ucg4A$q9` z{$-S%sRh3-4v*qjk(QA{LLhSdN-EMSiYgF6SRgIdC_{;dwUQy26g5`CaxFo{HxCdo4S7D+M5=2!U84 zB0vgD5c{{JQK`u~j|7%&Qalq~-%d44?tGV*BoPc;BH|I`x-3ltF^sFnpI zDjWnlV`2!?whkCw9%l-$-g@1(Mv}E}zskYSWNasX*A$$YWGcZJW6a3N*wFff|FVJh zNF@aiPOY`}!X?LCI%(&leeBrTdc7-`YdFhN>1!_77o03VZgA4oU3B4xW|Y9~ZWX4E zZsFZqkLR|817zjW)7ARGhxKy@bp^b&CD*-dBg~!XL7?!P*IZ~7q(iF6))te`aM@>` z(nxu946;s^EhYC4f9$?YjA7b&5^La%S7C05{^z1_C4|ag_ zLKbB-cPoJcXIZLanjhxt>|qY9rC5-KCkVjq)4QSYRdhpf)oaDg>bl$VX+?`)8p}Q3 z{y1uX!=LX{#^4(YPs6meNF3cgp1bnow{U^_NMO%GSK~v5JAK`~El2vjh_|0Mlgv{G z{W*Z0H_ozEHz*lTwAJ$`P7qmU8#e!pr6|-Z0b6IZ7~!X?9uMW-%AH^YwmO2olPTj0 zq0UT9Hw-DAeYslSYKqmxA-SaxE%4Y|LKfu% zoTYf9Ix(>xA2_G85N}i9X9IuYS+!|EtX!2z8{ex;l2Z|2zhouppv3dJhyeuR2eq1+ zJc^_0+w+wM?Ytx|TM*x>aB*Qr!XPHwE2k2{q;%#~us_V;mi7bK=yG}YUj6YcoiGi~ zyj!Q|q@GlsJse$p)sW;G=Qv)fwYS}w$~(^d#NLW+)f-3(sH7pMtLfJA4GS9XMyav? z^6AtjY)pKg297_o5|4k5qTC<(ou&lOmv4JWji>o$GtcjtvV3?)|N8y)jjqwG*dKIN*A3VFlG+6}KL$B3c*n9Y}0i*->= z)u`wCq7PM{kR?^Vx)looECyR8C2aFK?!(j{?_6ok_sYFdCf7yZrQL<<7Tb<^235t; zoqqPa&#l+IiZ+T_nQM`U+jfN|p@;FGWYc_Dfxu)8)DGLHs~i+~@6YGP((J1huyxGX z{?U?=8jQsWVraP2=F1z!fv868tLn0l`n`CC4O#i5pUFH_4;u&1aaxs6=UpyomQJ+Vu-W~0un(;mH} z$fp^}j*zn5zR4C+eDWJN9^B9gWzKSoE3r`Woo+S3w7t}m?79og3$yMOD)R=zCOq}m!U z5}NEb{rGoB?HuxOUq=+vD3aF8_pmeEYdxR^K=W52?!mFV&znC++{MGd_m22 zuohLd!JG}5m6e$_o!qi_>Hfmf5x3_#!pgLZlZPUM8Mo0OJU{St-XwkPguT9cH+!VS z98OAEJoVtSWT`ZOEvWuxP`lZaKUkPNW#YBIPT$k>;v(C8kXRud4Rgh*ap#I?q1{9p z$pl^{oCVZmuD_hb{Hi2W9F5^2MV$T+^pQ_uUaqqCW45azchK5S+mle$QK0jK`2E}n z`FgdWaoJ(tx~_`aH{%VhfDr+<>S~MApBCcWjG898l}`Yjbo{94;CeO14NbW*m0ZuO zEC@tc)d^z5HNrOuIJS<^WAki*l=Wp8P_LCeB;O)La8?s#5#+{r4A~piSv(JA3@xw$ zkMU2=mUIW~3zuF5)Gt={^2AKnm}mP0?O8kh`OO+b=Y;D%?UZwc)lE<1aM?F1R`Sb* zpNyn;xsJOdPDL$<;;!XI;0#KJyT28z?YPS=aRW?So%x9sB{H$B`*xg-kxqCm>Lv8o z>1GSLx&604Dgq+_h(+n$h~##HdCtp+tZI3?&`nF*{vveCXtw-@tr&SA8h~hpBCp28 zT}oUbGUV4i=k8Y+Z(Bl?D1W3?+V;`k-~tds4s5ZVDMK6&XY&usy>(oYZTk7nWDXN* zCYmcFD+2U>>d~8TZ)xM}AmTk$$$81F^Qd=2m>N+Yf8&S4VL+{chDe1K1rDh?Vnt>b z_@-`1yWO>k{up~b3$ksU9UyDEv%erGP+trH>EBHG4yUi87Ip0(X^6LctDLCipYU6y zmDXGr&7E{76)QRAi1lOy-KH5rgDE0oha3V9)q3r{PG8pJLJ@e$cT!?zkH^j{h>5{! zcQ761M+X@`zH_sjI5;p~Xq<1=rn4|Wwt&lT3}v+8Ui8Sv1$I$R)>*AmgZ|tPBRUl{ zh?ug~En|I$2j7{wYwS7smbKqA&v%_!^w|9%cI{El>eq3=Aq(Rwpt1dau;H3p9luEd zakEc9VB;%|dsKEVaoJ2YKe?oMe%FkCPcA6nJ(aw*lno9`3pTm!TNTn#{#$w-zZom< zwu*}-`?-3`i=wk%L~N`ykDO1Ns_YSwkbucne6sv1l<`D3*!5%2YkV8V{dRTcJ4p_< zS7j00YUe#ki!%!TjMpERHqWf8HqU?^F1A0+{=#|Jzv4t+e9kO< zisg>4M@JW7YMNBRzHAn3%dmH{E_}j)Ei+`?C?urJd+&pi_BkH{L3esG$0X@Ou}kB| zDh8gNtcueVx`XRZZhcT+KL!-N=M}Z@op6?8il^XynJQI^3?ki6*HP)1RdynOR@BeQ;fhn65(PihjEhz{t?787%F;U=e?2 z*9TL{t-&sCyVFJGNPDA6p~q0|OYazayLxIJS~Qln=#4o*7ZH)ArHVpW#r?ILZ<@aBg)Vj&-UwXS;ToKAW)7qR6JtoIFyG}~wd!GCA%W+D=(*nPYy=AGqw5^F+OHlI4FBO`*f`$_%pcqcbpDt=vBeG&tpop!GuG|&xvQjRz=yK zWh~`QTxe>ql;%z@H;Xw-DzXcnJZ6~>D;>^2<~K+ui8)K=vX!&UGca80-&ogi?e0tT zJ7&I+h?#u}z7ZgQ8IA=q^~`UDRl5_LAIj&ekTaao8Od7-Y{Fg7mfx_Nu7*|R?}rRC z(m7exEeyVHrP{X4D|t!Ro-SBuGD~VWJXxWEeX*ig+Na{SC2%wr)Pv94#ndpN$WnEF z)>D0AVo}sfuVT)HeXMxXfevlLF*X#Z^s#PhXiC16et$a2uU6#kz6@pG8#AIik2?uQ7#)?8gByU?o|B{uLyp6>}3_I z)hcQxN0VkyxHS!w187bz(@JCGebjoNb2g{DX>dY#?0v~<(bJdB+*36YGOn zuhxB|XU#d?I8*@<9~q?^sS-k1sh(K3id^R&Z`T^M#e)Ucuso0Y>Z}^5W-h71n1-f< zm-Q-tOJT&xC*f*{q4-uqx1(|OhJv)J=ydp_=@XZ<7wtEm__l$vhJ5a98{U2ea;E+$ zg*q}$yql-0#rdW;olh(x;z`v0_85Np1i5b=3&@8{$qmfptrc}@<{Ws|kDK71J!|}K z#7Okp-HCPFf5^JRa4x#SJhENY7&$X`O1Et?EShvQez-4b$m?em29(4#jb0MX-2CGj z)0-0i6Qee#=XsWUzYPgMR;0RY-ve*13yZr_cL-@DpxK2RF`be~*c|I~YI^0Z7dy#! zr{kqt*~nGTmhi`Jr0Nl}+?feSb_d#;Ith=s3}_L@eT>gq_xg>Bx&P=7!PH45OV5E% zND`c|k<(vpK04C6%fZ{EnGiO47P|sM5eIm`-{dX!2uk-~VM@i+l1oMmI=m$uo`Uz& zZ~rBQa?CImE|Xid7t-u-Y*y(HmYl08VIc?R zwRAM%I_Nz1C`+ z>hfut44Z1Lrfbk{;oTHwD&J1I6RCq*ZG5@@I-$exEy4HcNW@~HLuSWqJr<_9qvYJ} za=z65)VD(x?~MxoVfK zogBUp`x*_SLH)w^x9{@yH-R$w{&KA^K{g;j&VE-r_L%-;z3tbFDfgy@^v7>aUYA#- z4Di%;GuN)L`{>jkGVD=S=4P$@hfl%oye;fuaowL5wX|q|4umWG-0rS)`nnx5@Z0V& zrj!^xmQt#SnA}0|LGC**s8b#=_%seayf06t9rVm4G9S#nJ=5Qlg^BOR%wa|r~ z-r!J70+S*(tbmT~6IIyfE!?4qqcm! z?8Ca5CSumnN5*r3^KmzSjKM@R+Py=&0E+r#lXc$f;)5 T+vU9u;6a9ZX0R7JH*fz3t=&|w literal 0 HcmV?d00001 diff --git a/payload/user/remote_access/pager-webui/www/assets/pager/virtual_pager_02.png b/payload/user/remote_access/pager-webui/www/assets/pager/virtual_pager_02.png new file mode 100644 index 0000000000000000000000000000000000000000..e06a69f4f40f99f8602dad22c7926be5377af8a0 GIT binary patch literal 4811 zcmb_gcT^K;yN^9sqb@3l7(zFMl1K|3LJ2D!L7Ey;AVd-fp@ga~C5oa10Yi5|s)9%d zfmJ$42a#T+ORrLdFYd0p_uRARThBdret*2ryz}&OX5P7HsIPtOF!x~q0B{VggED5X z{s6!p6!0LM8t;urVJ}=X&1*Ch3W4U1q2d8*IEpPEjCRD>;f?Va9NoDNuLuC_qZ3W9 z(XQ!TL1HP6;+Std;vSApY-s>MQN_avgC*foMH#H+0g+@|> z{NdC!Jwvbtg^CBui%Y?kg$(|t5o1enr71z!&i)aFqm!QAUy8{tf98svH3<)llZ2GG zq=cj6x6pp)cA**L|90aaxm`@@PIw7pybHyZie;CC!2eyCoxcBW=o=&38>9i1$gT_K z7K(y(b;OftXp|C!{X`r`#32!Ij1)#nS`H>-Yby!6j3G$F-i^n6iQwVp{^+-C$Fg{C8Z@Pr>-uCl95J9%BsuBNvg?ymqn9ZXc#gU|J^o`ZTnAI z>HjK=)S%)qGz!&}Lb>(55e)4qG>VHo#R&|T5tot#i|ApnMDn*S(Qjq?BWe_$N_4~H zw5SwE@b7s>692)6AB+DFdEEase-dn`B)&Dv|7f1?TkMYfM*cMo?3=&l2~TDZ5tThH z2JmC%;RY_!I>J(V1b6Uhl4*8q#VtZE#8O~ z;5d2qme++tkJFc9V=Siav>4F~Z#{3^3w8nTm!t3Q8x&hd@K;hm7iJ%qZk2-e#?17M z!9+#xf`)LrZ+R@1CbSrXLoLHIz&%~^XQ%FgoMJn~HeLAlgMSDB^}n%AudGIb_5%2R z5TLz30s-Z>`}l^CXf$xoZP1Sa0NnGh2t9z-(*)|&jKyF0|5eO3ed^-;9d;fo1^fZ3 zz#jwqt?;j?O;3XCPx`ckKco)$vFd>L|6QH`P5tILG&UB%cC!^*Q@^h>!)~%`WwguJ zePn7%bXLI)G#FF1)LG^O4CNF#2?Eb5&>qK(C&eq$b(IHRYc|Cy*bQcw6oib{g=t4A zFEOt0$X+)#F{!Gk&`9q}<;!#EI^*Z(C+{+z`@$3ZTtGko1m8IvqwK1?=3QjP`@;N2 zvhr?>zt849#oc$87gtxqxuy5bD$|$8!{PV_-GIZKqqFM+;j)9*VTsib^0O%2xf>z% zzX6@kE_A2Fqqp)M@pIc7ODz?8ql@Hz0ZB>2UlXx?TSvJSuUY0gOzFdgA6yX581xKI zf+sQz7HV6vQp`76_%!;8)Vsar!=vs<_s^5WhnbiU54g&q8X-LLw>;Ds3Mku=*wYaA zxjrV$Du0*W7I#ijM~2)jRs$&u_c^q-JBQxhn23Ll&oFb_tr*r8plwiz9fga>w)EnV zogtTi&XLcAJ=We7!F@;k&ktk_Cfva_$Jy9ou~oxPjDkmxOudHgMDt46HN_Yg>2j8o z>$)wD96&@p$&=UG?9oiDeuzt|LpXG${Q`179b4j-Ev)>N!zCNPOkl~{Wzwcwj6ByT z`Bb(SDqt7QRSM7*TJY(#lrfd8GNHjZIcKH5s+yk{S}<#$SQ4co0|NsOOk+&OXoxIl-X3EI$MU_q&7A)I`5k55Rp*)-x`q5!aP1u^)3?GsGS?%0jEw0k%9p%Qz-kt=w_$M(i8jYT7 zjwT4`pwXej6@hhx(@Y2ia_ftSZ@uZgGFn}Tz_IhvOo-kw=Wqj;>50 z#?KuRjQ3OK;8nIhc#KQz0@CdX5s_R{ZpKc)El!h)&^3h*nHJcToJ)6JW4GKU)}Ne7 z2SI*m-Jgj|V^SA})V#*V8zK$rpNR5rt<53$Rbv_@q6+LWDXbe8cY1uA{L!ap84In& zC!Uj1nogZR4TYM$%>8-{rhH81>U$9Xd&baBukZq&6{FrEWA#X}>|sp}=2F!}e6Qn6 zWYfA$XR~hjB|a3vu&>fjaHJPl^71BiFMikjMp1kQ`_m{Q;hlY%Bx&;~@yksmx?7vT zRDZ!Enhg5pW|fp>4Ukt#-TRHtXlPKB&Mygv`sW?DJgj(By#Y zg-(3VJG-wMWUM#FmNgjfRSlR*QuokAK7q)7ts0cV5wmd=RORbRn90+-2gU+eh+95t zMdg;#M2y3!<|{EG&w?YLN}sU94V^a7c@(E47@d+M+?HWLu^Q*SeDikfb%zlP5j{od zohf>sN#U!lqL6cz+`>5AUT0_Xz5@c9%Ll8VL5TeVPupI{ijTYAHxf?GtgMu?>h~1< z+_5;{tOzxD#0!HCdYa@1%4?{1>Bsi9caR9ER^8`No{fz$>BNRr*OO)Aqa^oO@?yW9;@+gs!DV<6wtsSun#%wq)g8=KMZB z_|w{4L8s@sk*|AC6CVn9b$Wkk!9-vAkY?2-6BigAbF=PquwXPcGS-ZIqE|e&(H>;~ zL1SgI^XHI@3PKI)8jWSjaPm8kx~XWB>lWuAK8IEcl#aY4r79k3_vDL8a`=dJ-uB-r z;#>^M0fur}zwt2owJARGJ}0ziVlbLVHLIyuO>J$ubK#Zq*Mk=Mv@{R8SDlMV^2aeJ zOAKucbg3?PA}-RbF{*dicd^Z|cg@Wxn<~g9@9l4zZrtr3*pOdQMEq2poA=Z;Hs@jP z$;2r6I6d1(fngfBGnT)!KuylJI%H2`+ka~tZOuD;$z#$G5&aP#rdGJuDnLAW4F+e>}4pS>oQiZkb1REU=QL zWcNnp#gGu|bKSupR@DcX)oi}SGQn-DX}X}_d^aojn&TC|FL0j_quH0zt}fGy32M*&BW^8{T<`M~TnLoLSZzRO2jP zCFs2~uV^&lhc`vB5z{-sQ1mUh>%NzV>?4i36ec?}N)Ot+XY$5gRV3(sM9z9AXt_oM z%!+n>>4bMEE3cNY?N5(X>VHPqb(0BU$tQXj5QIKCx@U)os1ao7Y)w0>n$@0yfBS~M zw!xo_i-VerZ?rslZ}Jqs?ab3nc27RZfANJ4Y3q3LxSBy`#B;xg`WN%KR_7ykM2|JT zmYt;(-bZJN0Ym**U9QDnW}ei!RT>sY=WjAFZ}Y{Sr#sLatFL!LW_zrNrj>j`m(xt4 zky)Ug!2-28YtaIq2OBOEy4|nWdph-G6aD6u+w#0*3yx-jUNwxK(}?qK3lYB*Hqe&B zrnADet%TPf^!pNUhfO<{}jBAV#jJi2AnL;qh{}-kKKXGIq9icg}DY zpBy(l$P-$l;4Z~kA2IH~W1qRwzEo^+Em8P}LVWyupXk>Ia!Ow7%-C_|5U2LzS=Vy{ zOG%##fW2o3n>-gAf`$5kveSLMzP?=ol(?Tv$YrHJeKs4(ulAn3<#5(C$V*Rv#L~J- zHGb`N@xt1wl;519Lhw^%Qu6y)rHSO4&ckU&oc; z%kx6!_ARfT!HiBwuV1|^Nv~3CFBS7Pz*wcUOeJUfTb?@w$r87&jkV%Je@)2XTFbkv z-SRH1e#ny2oo}Piz0m4;Pw}Ft&(>;=AnAf8jRuqs*Nc4yMjdQR-{3USZz{}68CETc zM%qq_I1Mls=C(+qW8FFU2WbYAn;fNxHhQY&l`*fDkW_8fZ6>!2lbnyOoe?2! z8d?W_rP@j;BzYQgY&o|=>R$VNT4zD3Ot434i&x9Id{u%n{W^@@H=`*7Uh6g`;wHWQ z?*@ZvdF~$4j`kAuzETSQMBeG_?AQdJIB|BTy2D0`)rN+9s2=sB9nC~|>pa;1fuRk2 zoa-fIA~-rGB5E#kl25g5=Y_%j7e^mHv6TLFa&OFNT`Z*GjAl>FyJ1ye%L2yNDpA{- zDV@=zyXwFJ+@pVKAX0<^QxI-wWV9B(?JVzLs9hM5Ni~5EC_|dPqRN+t_4v1F~Sf3t&DsFSxc1HeKTgcq`MCU9mL4Qgv9X4i`E-(Rk;A=9#cCr9un)jt+2b@(kn{1| P?P#c$v{f7#5JNMI6tkVJh3R1!7(L2 zDOJHUH!(dmC^a#qvhZZ84FdynWoAf3NrbPDRdRl=ULr`1UPW#JP&WgEjeSKyVsdtB zi9%9pdS;%j()-=}l@u~lY?Z=IeGPmIoKrJ0J*tXQgRA^PlB=?lEmM^2?G$V(tbhjO zrj{fsROII56{{85o)B8yM>wnCTiBSeY7F8Ja0TffCTdqLehNAQv~NbwHl2QbtKh zft9{~d3m{Bxv^e;QM$gNrKP35fswwEkuFe$ZgFK^Nn(X=Ua>OF1ees}sS4XnDjBq$Z(46Le) zLn;eW^@CE2^Gl18fl-pC4ATq@KKvGe6armiHyCVz)TqB>Eakt zaqG<;+iYP*i31Dy_Ci?!N)48@SnHH zr?;{3-`DPb{4vdN`_?~?eir#mf4zI#yX)_Z3V+}G@!rmCP9Njtmu`D%OojeLRSB3^ zJ9ZKkoWv{pLl`J^{e+8v21kSJktm$m1XM4!Q`mG z!P2N8z|!O(fXTrjjL?8!VKoQSL{troSZzg8(}2rmFg5sG4^~656K=2)FoKdm+F0My zSKx^9yF7aGaoJ1mq~9+BGTas4b0~J?8b8lc)9*NP^|UmQ+V`^P z?t6B}FH3_2`O{V})9ctb+x*(&GcVg#aw{#e3GKgWovo(V(Xn!KZ^f?))lD~jh2_5} ztc(9GR(yWi-Q7Q&oqv}e_M4k=?C|f^(dnW*83M`dy9*!I=&p;oC2;6ITVY{Ze%#j6 zDOHR;rH?)=^qbQ4_TJW3Hy^od*IV{B|8zUl`@`O_-g)bSCB4961sGz;F_-k-_V3@l z^SZoLrRPs;OU|{Gy?akvvTolJ<@zPx9r9L+9$mclgvW>d-<%s587gBEvkRLHL>Yj< M)78&qol`;+0DCe<6951J literal 0 HcmV?d00001 diff --git a/payload/user/remote_access/pager-webui/www/assets/pager/virtual_pager_05.png b/payload/user/remote_access/pager-webui/www/assets/pager/virtual_pager_05.png new file mode 100644 index 0000000000000000000000000000000000000000..51f4489a303601519a9b3d6938ac623ba94caf1c GIT binary patch literal 5781 zcmbVQcT^K?mj?|Uq)HVGsI)*r?-2r_N|7cQiY6gcAwWV0K{_HJMO1o`DqRGmS5azc zf)ptty$c9R+4#P%-|n9Cojtodf6P2{=l*WLGtb**?>vCd%RZyvC>vnvrzLZYPX?TSP@Vemi{#?HY7%D3In!UuG)f%2J2>xt>Ps$uLM zw0zw$#=iO{XkRC^ybYi7O`wvu0#Sf729E@KJ3ny2DR@Kq{*tReT>mCR_<(=0;GLj+ ze;Z}4cN?gNb;khZ!Q!H5F)>M?tUOp=MqUglCu@rq0ZNETNJGRVA!0J35@HI{VhZ9i zz<&-tqC$5YTLmMy#y{$acThfiJl<6S0`c&dad~-ph?oRKLPC_tA&T>H z!6UszU2xa_NPuH-Xm4hfNdOX6eJ~(;z)66IZ+uDN=#G=X)7%%XNyFMqL6Zu($Xm6 zMMnIOoPURh!{yZ_)FmY4Ods;Y8u8ELqftg4)x7)k5>R0gHL-i~ zv5DM?-J&E=)kH$VRHY4vnRribSW)3kFew3_fk-;PpjX9JCQMI5Cz#rE&`z~7)j2aU zbcb?sRZ&?%Ig{u`0uy8Ry_ESUcv&cBJl06hl4-^4RyXMRD!tc)Xz#%r^(=?1Uz zjsAgwW{*xv+OrCj`rW%PH$>Pa0Ow-fr*Fp{od#nXe%uqcuEDSFe(lD&<6P>Gx#H~t zw_e&cd(L=_&$@QJl(e%xp4o1XJzO1GE)z$O_SV7Q1DLoA3^Fs6 zkPC;-Q(`>09~0OZ_T9tTFOKPenZ8m+0IWKwbEp!n$uX8$Zsr$3#{~{aN#W$^?ChlF zP+$phz8Zi(+_+oY9U~sxZ7g7b1VTFIe*CKRcsK2MC46+W`a^nDRFtw+h01&Ia~g}x z?c3}Bawak>Hz&uNsae5T%`w`U-s(uj+EaS^{lAPs3hSida$jQoQ8=;rSIhK9nG zHj4aWCR`q71aj@8A`_kYh#Kv z+Z9DU&PHQr28SRhJiD%rU3@9RqE_Q!ZFi1GU@JX18Fx1O_;OrdN|@VJjk6n)ONNDd zySDx6CFa!L`gN5cV$pQ6sS(^NDG&d|2}7_Rm8^$wp;&NPJc+aerSug-#mgr3a7g^LOcNfc#e zUw6Lu+?tkmS1x;VYaHC38XuA1D==?=>(q6vEFeOa$p?>@YUbnReH3U;%r*(E)|m^E zJBcvvUm42PL?BZ5-Cn?7CMJS(A}I;LPnhzS@W4HNLt&BPy|DCv&JXe4Q{N%Y1opOf z8wL*YDi-@YFPGohP0RUvo*85&sj<&}lfFoP;(d0K?&le}zZ=`qc$y5~Qn9q0PV@O* z6WEAbuW@cT+&b>Z4WcgGUhTg+aK?D4bheIB^L&52MbJfkl6l$xXh;*=aO#=ntT|Lx zYgXQKRcXRi_o3X+*3qI_?$f5)JEbSyUPH5Y%C5+{X6X~W+vVIALIrse3#MC+JLq>W z*EOo&dhWS7`7F>H0JBW!e3WINVLE0i>>nUH$x2_`R~vLR6)4orU;bq2iXH5ZGt_czM@-FG+}AH#NUG=UjTcFK{nvH#y2r zWq`z=tdrV}kC#2S_BI@Kh3`go zm-ZECZe3!gjpe!N&klK0^=9+_tmlg(^|F`Z-;uPRn%~%Np>cd`6$L}R)47c}naeRyAVF^6FeZNZYc5L2RQrzuP_ISCfs$U;#KTs$l21&c)xW8ty!5BodVB@1{ z#`BVn&2+DQ)&047Nqqyd2b^k-o<1eKF@w-DRDN+^FJ*5XZS)!8|GLdF6#A**I)C%g zUZ5&p5I$&?)bGnl2iFUBi8obO!W6RdL$5v4yq@^E7Ap~I`z^~=)w=~}8z&6{@I6Br zI0{ll3Bg#HGI1utTsLerALW_f9PRR}Hh#3C&Y6(w?$fMB!37vE5xY*gU_@iO(BE3| z^TFHUlvHIve09=UNsHhOHH8a5R>>V-f4I@v-echWrsBYMe)=NCrIl$>lZ>=9k;KHD z`$Z3u=!>Jx_?cfOFTN4<9mVQ_Aq>8+b}HJR0wa?JO&pjDMxUIV_#1;Js!WZLAASju zB2Ku%O)1Nw=~Xo}LRhcdgpyG>aBXh2e@RS3MYMisgw=<&2CuMlG|J9;t33%QPO^MO zrP+IjB~3V6p(<(4mn!rIifnn3TWLcV-7$xV4(`ypV^z_3_lwWj$#KN*uO`77+I1c)RrrhGGF`EI!835pFv1S=y#5C$SCX6 zyIO8blGBTq_EXUFzzt~q><&I(a1yApNtJaOmJB5XJMXqVX5DqGUJ~PUy2G|3o5H&< zH}CmXgx``wkS@|Z&1US%&vyT7H6XAlQ-Ys}Q`Y>xce~a7oio4fZ2v2IFah0^LSfB~ z&fMNo5PM;BJhe&#RVFvfuMX9Rsnb@x5m5@EDArTfeQGu0UMPComO=w0SOHk+x1b%V zWWcHu?8xutP&{cB?qjjOxcT_#zNEuQ|5}Qi4U=VnRe*Zeye%c7GFy~2B4zoVbV!6x zF}gc;^_$o?%r}Xk%iGV)*s@yz68jBK1%2`hUDJQ7#eI<$BrpVe*P-O8$1$WD5b2){zlyNdVwnXM zJ(p@4*<{s;_O^eIKRVp1`>y}w{M#45wgFeVo4bj}FR1n_*m6QkS!AE|VDcYr(r0^x zgpZ?U-hj<=#N765&YT$mZXX%(zYdQKJkK9S>KeKUgs~4hP&$KX zL4lDy@jDo}h$ahmYGk>Uk(?uua4oo2wTck$y+QfuPW2ijL^O+KKZ>l!i zg^Y#^b<#AHTY3656|0&;iEAmcLN@87?p?a~T_QWjlmGdS##d{i&3LW}1c7kLx3NK8vI%!#?e>S9{<4#EB>Dwol zlysCF4p}W~tE;0q!9;>o2uvc;^vE}AgiTR81WNZ?P%N#eaatz=sM_ty5)uS2kNFYj z!WZm$)1tV$2GI;w*QTZnbuOzdWR{MpVeVMIrKk67s0d=rn1vap#71UC&|XIA#l7OBacx{(6h%%ve&2Zx7` zv(TcSBsZwH50Il#bA}I?nATDty&Y!t3D+%hSxV}?l9OeYD-&A$33s#;|T&#*>yyu7%94eh_XBpiT9$jvTz6fOx5 z^7vAE8R|FcH{#U9(I!&L0DxDs4r8>bJOICUsaP$1t$HX1?!L6C$k^%6I zAJXPJj4>zTc5-YLi1VSy-tfcCLACrKbJCA|iX%yJe)#g`K~+n-(<~2&m0#~(B`g+v+LSGtC&)>oA_|O#0Hu_C*38^k4jhA@w81B@Y4le+<~kM#eDXrIZf6hDPsd3>o*Cr zG3?BM>m_tK{@XKN;uD1!MmoE6th`zjf1F^_1_%rRO`)ENXk17r^S|?fo5pQEZ_WJG zK;tZ8icCZa_kLf~wqI3eXSB$8eZ17qcYZK$c=0W!cdx9!;Vhhsq3`TqFfg2rY{~v^ zf~8QG@>yIrva9!FKh$TTb8q}P#HtYvnbG(a$bdh0`k5OuTXo}>naI7^^mJzCZx?Jn zvnT;{(CYdKq?j8y2mr*vBFZon)m{N9R$as9K>^&cyK*UDVrd79O^4Quhr za_4~}HQYCC3Lh6;u2}2=MO$~xO{Q7S`!4KVd10n&dqQ45GFH>HwuTV455$^#QOLBCcSoE|13N3*yzTh317AA}BW z-$sk3&c6tc%B!jh9t&cN7)T!o2{RPf)RU}bA0hHyw{NfG`-HyCcI!JeQFMD&Cyd>! z-8U`Sw)lO|md3g`acmL=LPNt-+gU(qOP6;Lgnv*cpU}0S!g@+r$xfXEl%F_QL0Xco ziwo~&aYxUHjwFfLL-`}`9GJnRORCW^_2XOUfabc~ZnUFg4q>_6>ACOfd4xt#=_237yh<)vfela; zs5}yir2qf` literal 0 HcmV?d00001 diff --git a/payload/user/remote_access/pager-webui/www/assets/pager/virtual_pager_06.png b/payload/user/remote_access/pager-webui/www/assets/pager/virtual_pager_06.png new file mode 100644 index 0000000000000000000000000000000000000000..af66e181447a94acc175f43a17fcc19e830b88ea GIT binary patch literal 1156 zcmbVMO=#3W6b?$I778j9Q3QvGV$md%Y_m;5W7%zXbgS$IB zHg<3v*IC+QRoME0y<6K?u+BMid6-zv5Lg<*uF9cO94}k#^VW5k$ z;6RuGy}GDpb%?ayy0a4~P*EkAkziI(prJxT&Vt2_QfPSS~La z4&udHv8qW43ME;DlJBRfHPJDx;NM{^i;k;PA(kpQCKHjvuB5)DMP}vuyQ37yqA~VI z9=jJbZV_j~#{n%_CeJjY>$yfoK{Aq6O~~3d6naoy6|_3C1siD@RkhiYm0Na}<6D+q zEXqBarWbOuT!dOauUT2ug1vc7gSp-ow-m$_1rBcc_Ly(REhq&g`&X_p5Me|~R3&7* z)d)kQgpzoagh0uPG6dVp$nk_Jyh-d9BjST*oegi982&B)1-|>g^^;go zQrawkG*3&!Ix^iZO#?eD%@YpT5JhZSUaQ+?*`!@5S-EQR_Twva-3(lF^1#{ds~29h zCqG|2+3U5PZtGm#KO3(4@__dzCm$XAvFYggnJ#c}%?`Wm_DpT9Uhw7d}RL3SG4H{YQOva q<)`=eFJF6j;l%mji+B1z^N+dp`3+|G^~zlOUnms@ty}q{Grs^3I(n4= literal 0 HcmV?d00001 diff --git a/payload/user/remote_access/pager-webui/www/assets/pager/virtual_pager_08.png b/payload/user/remote_access/pager-webui/www/assets/pager/virtual_pager_08.png new file mode 100644 index 0000000000000000000000000000000000000000..4425808e0f826fcaec761612ae2867ec1c712b59 GIT binary patch literal 1154 zcmbVMTWHfz7>+vJoG9WACMY3-7ce;|?b0T3akNXTMLTOP+6TuxO-{G4<+9|=c0&*p z#i38K2QP>Uf*>fqi%e7|x`80LiHIn^_~7-)H}zy)rw>EKK=Pl<|9$^GBe~9<%bM3U zGYqpVvrErY*+}0zO^fOG;bpRciWZV8lKpO(RFIFEgz1(ro3T+3&SPW_4LreZ46|s+ zDildEyGJ!#TR@?XP_;dZW|+42s)vj|OxP0cu^f&2a(9elEmPwTN?DloI&iPGYuLy8 zhdT?#aG#-=T>B2Tt*TN28xzD3W9;n1QG>o&R`Ys#)*RhwTrVM>3P7b& z5h_u^^?QJ*C<=fP5Q*?qgAaxrf~vd|Y^*crI52$6BbMv1AtNfegGA$~v$ZH}FPohs zc7oYl(X4?g@_;BnVB2A6Gui>k3Nc`S7}~ zq|>4VWhIpm#Wa+YNm-9cI*cb}87AU&Y{m%)atvIzZBg4bY-{?jShd5)h`4^ib^Gg$ zkn44c8}zy!8;J=bWVd9IVL9Q^*04;qsCDdHN3fapU7MZBvue%b!@tE}!<+w`KR}%V zVYB?vJoO{mk>PS~8t7(jp0GoQ$fwitLY{a^C+$i`PZX-JK2G0jUMQ|;c-y~nskS<` z`(4BK?g!7mA8WElSAD*rJUjDp`-!Fl7wq=psrwC6`A_E-ygi8HnLbnG>M@~bks^Qzi(GI=d` zUK@XJo%wcjWbBsI^&$DLvc_y2{WQ_FJT?3?+ISr-Sj;>c*!+F6vHN29SIDF~_1nqA G$A1BRp?Yut literal 0 HcmV?d00001 diff --git a/payload/user/remote_access/pager-webui/www/assets/pager/virtual_pager_10.png b/payload/user/remote_access/pager-webui/www/assets/pager/virtual_pager_10.png new file mode 100644 index 0000000000000000000000000000000000000000..643e80a13f05463af29bd3a92051cb5657d5199a GIT binary patch literal 1225 zcmbVMZD<@t7+!5D=}91H+oYD3WkaEs?C$Jc_p-a>8ZS2&d+No@^}s_YW_EWjS(5$e z?!>#Cq9CFajrgN};SWPfO{=tu1f(q`SQKI`)L@!IpbdUt3u>xAN?Y0=oxS9uKSB`) zcHWtH-sk;(_fW3qiN?sI5r$zJGy7nk(v9@{qG1EwFBjkmO16@8k?eO%q>OybBu!Vx zY{o|YIFFGza_~Fc!7%GatU{3#vrlV=YYQlp5h}Jv(G1hkS@DoDhzYCXe#=R4*UwCG ztYs#+XXPx&dMP|$?W_8Df3>GzR0j>!~t^%aAmy9+x3Zk+IVj_t1QJ~2{lj7{k;;2F2 zENOYzy<(2u65IeGo+gUra#<+H1lR8uB~?{L5EY|Qo=Wh+h(l0?cY@Zs28aX0w>)CG z4jXDjx;sn~9Cda%3fs$OR~0+KO0H>IQsrPex_rtkiSLP6?{_Ka`Qx}YKG8pAerNCqZ2`XZQ?sl}p5LXxcTaa{-e9#oQf zrG#`|M@meVbxPt=UC&y02$PbWPIW74Iw?urK=Wl6N+6;d*1bWwiIp&y2|{>s#!HH!jR?H&1s?&h%V)i*vVY=O%e*Wa^Ek|Em%&JQSLO6BY qKZ_=Q`TMnXsUOZ?_^|cHmOf@%AGCJ7^yh)_=aNbHz>{5luigW=&!Q#( literal 0 HcmV?d00001 diff --git a/payload/user/remote_access/pager-webui/www/assets/pager/virtual_pager_12.png b/payload/user/remote_access/pager-webui/www/assets/pager/virtual_pager_12.png new file mode 100644 index 0000000000000000000000000000000000000000..a1296f428043f9ff82f0457f96dcf2b456bdd353 GIT binary patch literal 2635 zcmc&$eK?bQAKz)-qHrjs6Ri;yvlliq?~JU_S`td!+wQU0#x^#d*P3v6yl^IBayt7yoa8t^gMrbo$I-tf1dlg?)&@weShE2_x*cacLLdc&wAx8 z$`A--J;{Ygkx{(tEmTsFeSN@!d>PT^J9+UBau|F8zy%@pbPg4SkytynyFE>{Ljl(hlR&-2+tPmL*03XxUCJ$N=T3yV1ax9CS(P(c?6*~{F7aRj9(EW;jm9C z{2*)i=b*gY$S_9^7lc`upb<0_$`od9VPb)^KmmAj2F)0TL1C~+lqnL0Ltsz@EQ)}} z!M;3jnL{p}L7)(wzqpf~tl@!teh2}H6bJ++0#g$XHvoyYu&_X)Fh~prA=5zcBG`OD zh+y;dRxJ=g9*xTk;WIgG*osAf$_e9J!)3vK&Vm)<=JwSvoA;$wvZ^74KnN0Tf1VmCm9w&@TleL7Qw+fb(@2?%LD9WNC?B_CNeF1`r99kF) zWb;WxYq;#jgwCWBOfdi&Kx6R;9F>Yfm;nqd0?z=b2r7U##bT*4f?uVP7T9v@)Sz*XN&neQ)H^j~5Lj$9DnbGRNH zPVnj=kOMh<4lj@s0>j`;&?wkWH-N@uuZVW7wCQu!M3Bo22kFjS4h#0F&IIN+V)*a! z|BR>quk}O9f8kR8766Ude=5m&Y?R*IKeAP|K>64Bm6C>iaE za%~56z(att0GQdt`dQB%Mq#jK;l zpxO`Z{vWLL@}9R|RjsM{=b^aVwY&H9^x@k(6+N#Oy4c!^+Vcr-7s9;#hjN`;i}f@Y zKCG#(d!&EkjW{Q$xb6YAUM%5VYoVH}m1JmUd8A9P?~nG?>df6-_Cx&ZTH$bq5su}CKGyWhg-|-V;N3!~y_kC{$WPxhe<9NEqf)Jst~_0{_ML7E=JgRh`7HYH2$gcV;*gR@OBQ?I z@Is4pJBy3tbM(ux_0!WLDOnW!@x7`__E@~1vL3*4FX5-!p_kB`22OBhS)@Rn>+yzm zhf_x{d!1XF_`_G+@w_!@Hh((9azM~KetPP>M659=Z%sUTAls!h_F6P*ht1+c-`HOd zcE6o3_UxtKO3gg5_`C)skrOk#R zXq!&$h;aWNUb^n76!2bG7BO#7b#~9w!0JG+vz>k#s);tqOAf&Cs|lU4V}G~wl~i^Z zPzplu>Eki4%A~o3?+qwnh|OaslC-vnf~dqiPwI#cdTUZOv2r1qJOakHx(k_mA3oAJ zUS-t&ah+m!d7bifYTO-#8gbKaPsnzXqGMMP?UAKZ(cd}mb;yj_5fyQhLnFab8cH+9 zf9wi(uKk&N4Us@QVH(lvYS!X2;t^ep@#0ddjRnT+{~S+3BAz8EoH?6y9`qqU0w}zw>2P{>ev6r)?$v z&1$V#9Z#sGa_%yOr3wP` z>q7Gw>ODLNLA6M+>4B1^iu1{mfXh4UCU@Oeb%a?}m}S>v)Mqx?Nv+a5egaie*1xnK z&0m{s92eykPJMp1@P(Z)yy4t+VIeFJD(+MZXsry}prV6n^FLsEIZ#<4((b}wu7PnF qG@*NngH5@3h6z=TdJw&49mL1TQvVm`p0btSJCc(-@s@*s?7skWdtFli literal 0 HcmV?d00001 diff --git a/payload/user/remote_access/pager-webui/www/assets/pager/virtual_pager_13.png b/payload/user/remote_access/pager-webui/www/assets/pager/virtual_pager_13.png new file mode 100644 index 0000000000000000000000000000000000000000..5d0e92a7398c54a0e2a88c9c95a11eee46bd9eee GIT binary patch literal 2907 zcmbVOc~leU7LNop>?NXBR2ZVNNV7shhyn(}CIrGNkELdk3>ZQ(X%Yw^AhM}|fQo=c zD!T}%C@O+r1&b85;=)#y;=*DPQ9+9c;tMLi{^9F+=gm1Y-*>GC3%L5Of~QfdCgF7z%koATRod z4rmhsfsE$+_{;p6ZZs||#DVh|T$E6(U?UKlY@@^=R{+TX4iw54Sz}&Y{22rAdDfV% zWF~}g#jTX1A!=ExJXKivc`P0OH=IUkMS7b zBSa>!#(WCOpUDF3VF?6S;fPo+fnW|;TH&lHRs@i08N&SzAQ4DpJi#1KpkPS^8ks;N zQh?77M&VGx3!!;296!5LNYd(y2za4z zKDCc%smu%dPZ(cCOMRln5Z()t!Vwa#;z~l+E|3-F`)5bI2;1j0t_KzsUaW-%K@q8WHLvwP>2h5zQ$)T=p?!$ znL@R4pc9FX1gf1Kl|dmh2$ptKDuHgfz;zKxWuS-)E%@due1GPW|B*|xmq4HlmiWMM z_(CJFw!<=5x*Zk+Bnpm508E%5moJ(>GM&GsPgye{2|p6zIZ9w5@UhM`{#Ro7-}3*& z=l#F+!z+Tq&o|58nrGoi(UJ4pFVmn9zRVLOQVfwqF)d-m&`ShjNt6qN?h|!y?1*}} zuCMOq7h|IOi^2DV^|E^1WIqkHiT$aYfz5~d!qoR*F#AjlJW-x@EQabjB{ek5(9uRk z$9CC%1{d(HLFo>DSW&!uOQ2~^uE)jK?dNA+T>>wv+_@26e4~IiWn>9cqRnSwVg?Fk z#+%`kn}v@x5iD)=8AJK5yw;OEIWT);T7*`m^7F>I@olwsbT8rxLS~b&GM?WKO-Hgy#IB0B0FYE zvg@Jc!=&AAj|(y_%h1Q&w^~sT?)SsL9($xVduQgSIHU1woJ+)phF?aWAIQD)wBY^O z22HthnIOL1g|@%U8)hCcUShHql?T{Qg|uIewyoOx#W479KJy zGe47^_V(=tZ(}QD@8MNfinFg!^?<@d&!T8q+GV2PX8^hU_K(B3J$|uw?;}jLeI?SX zcZ&sGh6inQPt@k?uhVPAbTEvH-JJw7-vySg#*v?zblJ^JC~0mw7E?*0wvMWkC5 z*OZpC!d2tJ=6&&((2g1>v>3Q!?)6t%t{Al$t~vB9HhJ_b#Op-x$2zX?Z%u08LE4IcXY45`ODmwl<)nI zda5Dc^`qRH%deNR$Er_@DIeyVb|G=$4Rx*D^bEI_>+XO~?5ecy3F&&0b0($Qsa)#e z=YM8?h>)sY5Z^|uqNLc9uU=}DS0laEL*$~#Ngb~p;i(C{Uv44e(7_L|TXstUKI;UE zH$XplK{zq39AwFS`P-BZ%d>cQa`L<7sv8GXkE<1Qcv(^lW1M#uqq{Jwuig+se3o6X z1_G8L+gdj`W$kX=dRV8{U{TCVsav47|9A4x;CdB4!PE5Z6(g1|F^bN6eS?gwPMkPB~;H#Bzm1WVARN+W%)ZsWtyQKzbs zqFtejrea{J9Y3z3+h2+*>Uq_0^7?tNUC5DyWeHo~$J2{e} zD*dZP4sq{;lh5D0XlSxr-2h2~Hfpw#zMWk0JNuf&j%ta{c(j89%a${_rM4)4Hgnf> zSLhQ!Q0=hM*jUG>Vkc3Xwz56V_gM3qq`90V*S!XLPs&kYNjvvd32(-QpPI4==w6a8 zd{8A=W}TE?6yFk(9rP;JThRRuUdwh4TjD>#*UhO8Zw~tbuT5WbwE{o7Yxs}rqx<~0 zD<>Z2Xc6S`XP?q$VGrqAliUZZGa4sE&bAiUrCd;&s}d%dR(U->W63R% zuA>N8!l{YXxpf}#C5uzRxUPh`b8NLYrnyIa1_zRg#`ge zr`s^`)-}U^rS#_aennT$4N6N(YHMriU2Dq6Qva0u0RxnKcWH*x^wUoq>sX;hsr{X$ z?3&AF`p+s?M>(eOH4^LY_oKSa^kYNV2Ywn&Ft(4lwfK}Y-pp@FSsL!o z5~er$q|z~Qon}UZ)_c~$*;S)9l{yNB-pt1Q^7SP)qrJGw@WuetflZq!SY|x=kCMaz kTW&#YmJjcZ?V`m9t+JpGd3AA5=6`ox9NZZv?bwO`2GLHnp#T5? literal 0 HcmV?d00001 diff --git a/payload/user/remote_access/pager-webui/www/assets/pager/virtual_pager_15.png b/payload/user/remote_access/pager-webui/www/assets/pager/virtual_pager_15.png new file mode 100644 index 0000000000000000000000000000000000000000..e3da7e83da13c2816d7c910cc9224745dfc8518b GIT binary patch literal 1116 zcmbVLO=#0l91pmSIkxFZHcuh*;Ls#5>Dne?b+$|Ait8+0#UA&XysmN0$CB4ItwUuB zB0KnT5W%mTAozjcNe|AWH;*#Zc~fB^hzAdXzVu^yC_@Y+|M&j+{r=zo%bD3@1AY7Z zD2f_LO{-ZV_mOw4w}*VujB%fcVVun4IlG9f&_z^Sw+o0)nQ#GR5!7o-uhBR~?W!5M zJkFrH?(V}*k7J(S%L5K$tRs>2IC_;p8Jq&T^>P021PPN>TTZ}1T>?k~6tyZ~e zh_l@VUXW#(2O=+uEJ3hd&BD-UEpMc4K}DYC8V)vWiw-Qpf?dHeh6LNp!gSKQYaiQ5D^jDb~^D@H9j^L zO~{g*j0?gPkP-<=jf7PYO-K@mN88+#3$8^~pQpO8hC$R*oy>*f`UY+5a)#&iD0*2dG_=a2Sxzgc@Xw7Podc>ejX zy~?`0{-tYqbnwHaXHR;&&R+Zc===4n4-Rzq_gr{24~9C5pZDswy}_0Dm4_!Uei~|m tJ}#XupK-U|-Z(khwdWkYxi$Rl&^on}?BBeb{E-RvJ(ZkQA1CHle*Ti35q8V{vjfwUi{LobB8h17n1k;-n`HI|K{rC#L2$i z!@U$m_2sf!p70^^T;H*syx%X=tAriGV?{jWmT?{Wh)S7m3DG$l&Y(PkX0!GjB`B(= zX%&jNsGm{|*XCfT!!>M=peZUb+VG%JMVKz38OupBpC3PAXv<78XC)ozUItaHY|BSe zt%-ussv3&Pj2@>G4V4(M5r%Zbo^=AXk!0F-RdNo+JVUota5c$vf-35hbjI}&t#ATs z01&0)3a7*r0OfeuI7W*=lz0&3L5vlFDgjl9(QCvIhrU@>^V;~DJ90}h6^uQV=j-)4 zSC4Y8Kf?=(qVPcEMUf>MY|wNtY_Lu+)U}|Y!0;^(TdqTg7GcSq!%2n&+sVTAbbZ~h z6Rg#WRE=*yj~6(=+jf{*TRXsc^e2o>wSz*_Lwp_u?woIsmXwFOU{b!V9fgV{8uheq zk-orL%{At1dJO#L8t@VoOktN>Yiin9#Me8D7&; zl9)-!vNDzugmECJ)3O$mG!ReAGDyX{*qjq!=oqN$+akURc4Q=;0b63#jE^98{etVx zb_Zdy;$kjpi)X7>yzI73qafvjnxmY9`0NK{U=^(;+2)@ zcl(wFK54pL=vQ literal 0 HcmV?d00001 diff --git a/payload/user/remote_access/pager-webui/www/assets/pager/virtual_pager_18.png b/payload/user/remote_access/pager-webui/www/assets/pager/virtual_pager_18.png new file mode 100644 index 0000000000000000000000000000000000000000..9517a84aead093fd424bc698c19ac4bd9cf8ef8a GIT binary patch literal 1083 zcmbVLPiWIn7!R9>&drkt?ZuceP%z0$+O$b%H`-?13U_Jex>?C-e;1m>Oc3izL5dH4%efFt1N2cp!0XIk+28hYq*hFmEh0Calpxs$}gR%@W+;M7k zQmq-Sw{32>Pv+Ds`Sf8s| z>noOKb5oOSHqxmB7ZJ!t?y48+QI6~S)#*7_iyYfEAuBnqmsGtn&lYijSWS?43xFh> z(F8560jOpg)@4=(vLb?{2-3U^bOq>An(aRv4H(!By=s*D;pi>LH3{)`QEa!{LOUto zU|EzjO%s7E$}&$a_^{&<81Y^>KJZ|m&&B&ZtwT~pf6_SAI;?ekBvw&~TY*JK(ik6rY5R^26dTfP^s9kG=K@y^ zY_(kEk+PBF=pVs$Y&|JM2}+8}r%e;^DcDeWwE<1uglbYzOe)gSz|UcL!^kUgF<(@* z>AWPBfLbW1Mp`jIrl6`IpBZ4wUPz#4p+Rhi#_nSaM`HD2fFQv^4dd0pBFr~2!C@2o zteh4kz+S9C%kg5>rFcxestpu4H<4Wmu*-J)tUHI~@Nf0^@b>@qPoznS@v{82JOdTo z$oP1`26{Q*6Y?mD0?L-%SNA6=)9Pg-UyI(GKYne(Goy{&RN~lUDK%2EnyEXb7tPVF zjdy1gTaT`RL^0MMpIExE{qg0*x8jU;KD)I4?ZH~=+4hjC{#;zV_1)ipz4!Ir!yC7m ZVP@yk>61@eAKu1vE>F)HPYO#LzX8+7f*}4;6}*T}w!2lNh!`fBnfKoJy*F>(%l?k` z^>ZVOA{0fNLJWcJCsW7Z7veZhV^Po`Sj+=;VJ0T-c%sHEZK5uLJNH_9Mr4DNb> z8YpV&pqcB$o#{4JcPtKuHe6u21Wi#5YXcYRy$I8J)NR@c=HvBIhBl1^vqegSwA+Mw z%nd^x$_};X^r2o|F_^V$=!QTg1Qx=O4y-=gSAzso;#J8y6!Q#SGQqtGW<03QbO+t! zc!*Xwfz<(s(s6}TVhVtAyr8e5MIcH%i1HxDia?csD#YmW!w`p_QBX5lOW7USC72$J zU6tpH#UfXXa*o%{3yPxfK;%V{B^IndXk!?#wqI9a(2%ctri)F-rb9-UclvRHA;FHH z!gAB;31ZtX=Za*F51`8n9N;Z0JX*=x#~Ji1j7hEi+@Op24Dy|RPbXJWsH=cU`u^@H zG$hfen>>@;3+&Szz28DMPH72-XgI?()TjsrC`d9J%jW^R8Wtp0F2FpShjLVs@??q$ z6`slPnwE@3HBFP1=AQW&0S~I;!|KiSIJD>91I|$wLr3 zUe0m)DuvL|<6y_{aa>xAaRQ*1r=f1zp{PE*rtwp2$TJ6!(c(E4UCOg+P7=evoqrr} z{BQnv5)>a6%WuU~5s``vmlNGUHWU4XY|=y?>6WgslV>Sv%FL9O%mvTiy}TB=wXS;I zlZEp$y{DJYI&fud*{biucUrez+}4_YRdsap$l-vS^K|rfTRfFHb><+y^8CK{+@Y^a zhu`1NF8gwA`_%)ZWDE^eljpkuEZBRkz(FV{iSj zJ@v;z`Jbv#{)cL52~z!Vc*WLB_eLJqJp8^SI{n#&-Ruu(ml#saFHuLzc5Nk!*(K_- z>g~3H8=v|%ri9}|^6L`|LcY*w=eajUz2@HIgCDBqP-71k56pYCy*~W=q?+5cOO0K7 FegavKpl|>H literal 0 HcmV?d00001 diff --git a/payload/user/remote_access/pager-webui/www/assets/pager/virtual_pager_21.png b/payload/user/remote_access/pager-webui/www/assets/pager/virtual_pager_21.png new file mode 100644 index 0000000000000000000000000000000000000000..50468d369e809cebad08fc7b7ee44d9a159dc31f GIT binary patch literal 1262 zcmbVMZHUxH7>-yEJ!@6Gf(q4;1M$QpldQ=l>1}22avzKCdF(Fq{N+uO+1>OuA11SI z_CP6zr6^VOj~x2*{K6CA5L-k=PAVwq4@Ho+6e(C}sihQuSX#d(d$*_PA<{6(%)IkH z&-*d&n|%ZQy-S)`H&Ya~B;BWH$sCe*$HFG^{fYf&f=sJ$_aM%B1zd#zqEeP;AUf^9 zBFZ9Y)pp)Q9TYXcX6Fa-U}mdkdJYF;9WHWwf~KgB&5;kyVT5S|6>T@k{B`LnL)%u8 z`CQ6?jNgSycHc;VawGkDb7a_5EoSp)bVsBS0taD8M^4!dwJ6Edc{Q?*#XLjTRq$|< znRaS0GeCEF0isn-U`+rLbi2x_vI?NmUNAS(A`m4WBzPdRBG4qD2{JwNFvMVB6|}70 zGh>eIl1vF>U*q{|waQf!oEH>%K~+^Ah`cDWM1u`$E`|~7hOG?-9ff9K``GqeI%b51 zSHVe!I6EDM<7YCn#BMl~E0Q%nf<7;BfOniYw7PbPv*?u@b83hAnveJ_3cX5Tk}D~+ zHozo(?=}=G5^uC^flclOmUYjpILO6mJ;{(0&ay2nAwmHPlETV{0oYBjAhAjT8ms}8 zgd`bcl7$A(TzFki$qBt%P}J^}AoKvGvs2M!NeAtniULyY4Q$#CF?3DTux%6DGg$CW ztkxAEh`k{1dF4hS43s?Vg(c6YMVS)-y*>j?+l@tS@ik3Ht)sx+g{+>ybLe`WHG2*p z-Y@=Xy!C(c#}lXcxL95*o`#52WW1d12C|v$C*+bQ3P`ss8~^noMa^5B)>HZD$45U; zel#`E^5rkNi?@vI;X{4;<-K?QTe|w#%82?)In1E`K^PKX?1I`2kpv0}EE1 z9BL+G-PWm=c#MsF|7#|43{1aV73W8}QNNi@_%sRU>!*06k?B+tNvTi9R#-zW4im|K9h_o?tSQ@9OEKoau1fZSnrG+(2%d~Fm7s<*19Xq$ zAzI-CRtF$NM-)y8D*(!of_{}2fhh4H#Dg#^0#yR45T>gSLmYZWLCtEZsyiaZmXPAZ>5}^PENoK?OJYYLvL1N_s%(HnYha@RamatH> za~xmO;^B~%lw>6t7lag$6A4)hOB#qIWEsRGHE!DWF|>75^KBB}Rc_~rT(!qT5IbJZ zai(gGFfid@$DeRqS`2dnps!@0ZrXvWJ-DVy)*AB6d&o$6jzu5TSv8M|;otJF@Qwei zA5VhfgJ$`wd1@-sk-_$G8i;T>Psk=in-t7 zdFRXJ=<4rtYWKzRs&;m9|7Bw|QC`@(y_amy)bFgfjIOwKt=#C^eB+S2TNRgg*5^A` z+)o`({8tO}_qXF~Bfsu^yYyl+(C#*HqbqZbtyNGU9+5X%f9x*Z++HN$H&oT&#slX4 dTmAE1%NX@+si}XY^Ywo4TSzDSwB^Lu+#kf%fL{Or literal 0 HcmV?d00001 diff --git a/payload/user/remote_access/pager-webui/www/assets/pager/virtual_pager_23.png b/payload/user/remote_access/pager-webui/www/assets/pager/virtual_pager_23.png new file mode 100644 index 0000000000000000000000000000000000000000..cde6d570f8a3cfac4371b548f0402acf8010a3f6 GIT binary patch literal 1183 zcmbVMOK8+U7*4GgtyWZuFHk~?qR=Fhtl3RMx9zrR?Sk7b+r{qDNiy4pHkp`AyJ-=W zR!YT#M=u_V;7!CHdZ~g4_Ta%oPd@MV zK*KYGMQdi<(rm7)lkJFfs=!4AvXMLCg?g0cs(N)Q$HyYaRv~0O&22eV%H`Q!93WN` zB;Eob!KO4pOKJeBsj}71%0N~`kPtzVmw~PTT}raGhoc4qyQ~+C{+ca(j!zuyv9U&I;z^R`sIyy9xPC6T zt=J1|xuRJUBj}5g07Tb~L#v`gQb2#)*byBTr+p+AP>3f3i>UF4Chk>==)U^}*+kf8)6Mdg#G3HVE}tng|Xn!E|sgrb;qNlJA+JMj%8 zlTtEjpQ`m`B&i>$Jw2+CR1A>nQB{yh)wx+OB+#=^-L^w**SN;6T)j6ykl>(*@kG55 z@?)6ba18sboD?L$UdTbq@#3S4ahbNFHc;S9A-g}oE?dpB?(E>hzs0}BxBoYPB6UiP zo8_Gb=a?s4Q?=JludB3sNy098NSZ#ee zAB{|%ymx=!QjN2)t7JA%Txb5vqZ8&2`OV?aOY>EGlys{UecOCXlqPP4X=Ik)9U7X$ R?y>lnk?k8ap7dOs{S69RdgcHC literal 0 HcmV?d00001 diff --git a/payload/user/remote_access/pager-webui/www/assets/pager/virtual_pager_24.png b/payload/user/remote_access/pager-webui/www/assets/pager/virtual_pager_24.png new file mode 100644 index 0000000000000000000000000000000000000000..db07777cbbad8215c7333f40e340bb67994c8d60 GIT binary patch literal 1322 zcmeAS@N?(olHy`uVBq!ia0vp^VLQj#UE5hcO-X(i=}MX3yqDfvmM z3ZA)%>8U}fi7AzZCsS=07?>+FLn2Bde0{8v^KIxIwK0@@$ndN=gc> z^!3Zj%k|2Q_413-^$jg8E%gnI^o@*kfhu&1EAvVcD|GXUm0>2hq!uR^WfqiV=I1GZ zOiWD5FDcB64Oj|Ez%N`bdwS-j7?3GfRvdb%p7d`U0fW^O&u+bEG%6e z4GrB4ES#JyT+B>e49uM@EDRjYVR}9Dic1pnl2c)JX9Dep>NUix*UGslHL)bWC?r2W z2bK^5GV)9Ei!<^I6pYOD3=I@Cd=ry1^FTtHplE{l+9kCpvn(~mttdZN0UXX&nONLF zoc|&EQ^*ZJeW0WCL5T$^@xX+DDH6nl=LR4Lo}W_lfSITWm|G;$KC?0~FuHrXIEGZ* zdUI=|*I@^l;~($O4S%u3b)gl9)=CkXuZ%LaTy7MnO&i7-WBs8ifG_p=>U_}!UGpWcs zaZn)cseA0bN4&i5e*VpEa&ohG?aqJo^y*qi2W*HTJJ+r2hk597eu?Emn z91S49l)bGJ_+!3;=d0-__PT&dFHcuLmvv4FO#o75 B#zFu9 literal 0 HcmV?d00001 diff --git a/payload/user/remote_access/pager-webui/www/css/app.css b/payload/user/remote_access/pager-webui/www/css/app.css new file mode 100644 index 0000000..a3c4699 --- /dev/null +++ b/payload/user/remote_access/pager-webui/www/css/app.css @@ -0,0 +1,484 @@ +:root { + --bg: #fafafa; --surface: #fff; --surface-alt: #f5f5f5; + --toolbar: #424242; --rail: #f3f3f3; --rail-hover: #a9a9a9; + --rail-fg: #212121; --rail-fg-active: #1976d2; + --text: #212121; --muted: #686868; --border: #e0e0e0; + --primary: #1976d2; --primary-dark: #105694; --danger: #d32f2f; + --ok: #7cb342; --warn: #f9a825; --terminal-bg: #000; + --shadow: 0 2px 2px rgba(0,0,0,.24), 0 0 2px rgba(0,0,0,.12); + --ink: #1976d2; +} +html.dark { + --bg: #303030; --surface: #424242; --surface-alt: #3a3a3a; + --toolbar: #424242; --rail: #3a3a3a; --rail-hover: #545454; + --rail-fg: #e0e0e0; --rail-fg-active: #fff; + --text: #fff; --muted: #bdbdbd; --border: #545454; + --shadow: 0 2px 2px rgba(0,0,0,.5), 0 0 2px rgba(0,0,0,.3); +} +* { box-sizing: border-box; } +html, body { margin: 0; height: 100%; } +body { + background: var(--bg); color: var(--text); + font: 14px/1.45 Roboto, "Segoe UI", Arial, sans-serif; + display: flex; flex-direction: column; +} +.hidden { display: none !important; } + +#app { flex: 1; display: flex; flex-direction: column; min-height: 0; } +#login-screen { flex: 1; display: flex; align-items: center; justify-content: center; background: #9c9c9c; } +.login-card { + background: #fff; border-radius: 2px; padding: 32px; width: 360px; + box-shadow: 0 8px 20px rgba(0,0,0,.4); text-align: center; +} +.login-logo { height: 148px; margin-bottom: 8px; } +.login-title { margin: 0 0 20px; font-size: 26px; font-weight: 400; color: #212121; } +.login-field { + width: 100%; padding: 10px 12px; border: 1px solid #ccc; + border-radius: 2px; margin: 6px 0; font-size: 14px; +} +.login-error { color: #d32f2f; font-size: 13px; min-height: 18px; } +.spinner { + display: inline-block; width: 18px; height: 18px; + border: 2px solid rgba(25,118,210,.25); border-top-color: var(--primary); + border-radius: 50%; animation: spin .8s linear infinite; +} +@keyframes spin { to { transform: rotate(360deg); } } + +#topbar { + height: 64px; background: var(--toolbar); color: #fff; + display: flex; align-items: center; padding: 0 16px; + box-shadow: 0 2px 4px rgba(0,0,0,.4); position: relative; z-index: 80; +} +.brand-logo { height: 32px; margin-right: 10px; } +.brand-text { font-size: 20px; font-weight: 400; letter-spacing: .5px; } +.toolbar-spacer { flex: 1; } +#live-status { + color: #e0e0e0; font-size: 12px; margin-right: 16px; font-variant-numeric: tabular-nums; +} +.toolbar-action { position: relative; } +.toolbar-icon-btn { + width: 44px; height: 44px; padding: 10px; border: 0; border-radius: 50%; + background: transparent; color: #fff; cursor: pointer; display: flex; + align-items: center; justify-content: center; +} +.toolbar-icon-btn:hover, .toolbar-icon-btn:focus-visible { background: rgba(255,255,255,.12); outline: none; } +.toolbar-icon-btn svg { width: 24px; height: 24px; display: block; } +#terminal-btn.active, #pager-btn.active, .toolbar-icon-btn[aria-expanded="true"] { + background: #1976d2; color: #fff; +} +.notification-badge { + position: absolute; top: 2px; right: 1px; min-width: 18px; height: 18px; + padding: 0 5px; border-radius: 9px; background: #f44336; color: #fff; + border: 2px solid var(--toolbar); font-size: 10px; font-weight: 600; + line-height: 14px; text-align: center; pointer-events: none; +} +.toolbar-menu { + position: absolute; top: calc(100% + 8px); right: 0; width: 280px; + background: var(--surface); color: var(--text); border-radius: 3px; + box-shadow: 0 6px 18px rgba(0,0,0,.35); overflow: hidden; +} +.toolbar-menu:not(.hidden) { animation: menu-in .12s ease-out; } +@keyframes menu-in { from { opacity: 0; transform: translateY(-4px); } } +.toolbar-menu-header { + min-height: 48px; padding: 0 16px; border-bottom: 1px solid var(--border); + display: flex; align-items: center; justify-content: space-between; font-weight: 500; +} +.menu-link { + border: 0; background: transparent; color: var(--primary); padding: 8px; + cursor: pointer; font: inherit; font-size: 12px; +} +.menu-link:disabled { color: var(--muted); cursor: default; opacity: .65; } +.notifications-menu { width: min(360px, calc(100vw - 24px)); } +.notifications-list { max-height: min(420px, calc(100vh - 96px)); overflow-y: auto; } +.notification-empty { padding: 28px 16px; color: var(--muted); text-align: center; } +.notification-item { + display: grid; grid-template-columns: 10px 1fr auto; gap: 10px; align-items: start; + padding: 12px 16px; border-bottom: 1px solid var(--border); +} +.notification-item:last-child { border-bottom: 0; } +.notification-kind { width: 8px; height: 8px; border-radius: 50%; margin-top: 6px; background: var(--primary); } +.notification-kind.error { background: var(--danger); } +.notification-kind.success { background: var(--ok); } +.notification-message { min-width: 0; overflow-wrap: anywhere; } +.notification-time { color: var(--muted); font-size: 11px; white-space: nowrap; margin-top: 2px; } +.toolbar-menu-item { + width: 100%; min-height: 48px; padding: 8px 16px; border: 0; background: transparent; + color: var(--text); display: flex; align-items: center; gap: 16px; text-align: left; + cursor: pointer; font: inherit; +} +.toolbar-menu-item:hover, .toolbar-menu-item:focus-visible { background: var(--surface-alt); outline: none; } +.menu-icon { width: 22px; height: 22px; color: var(--muted); flex: none; } +.menu-icon svg { width: 22px; height: 22px; display: block; } +.toolbar-menu-divider { height: 1px; background: var(--border); margin: 4px 0; } +.internet-menu-item > span:nth-child(2) { flex: 1; } +.internet-menu-item small { display: block; color: var(--muted); font-size: 11px; margin-top: 1px; } +.connection-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--muted); flex: none; } +.connection-dot.online { background: var(--ok); } +.connection-dot.offline { background: var(--danger); } +.connection-dot.checking { animation: connection-pulse 1s ease-in-out infinite alternate; } +@keyframes connection-pulse { to { opacity: .35; } } +.danger-item { color: var(--danger); } +.danger-item .menu-icon { color: var(--danger); } +@media (max-width: 620px) { + #topbar { padding: 0 8px; } + #live-status { display: none; } + .toolbar-icon-btn { width: 40px; height: 40px; padding: 8px; } +} +#layout { flex: 1; display: flex; min-height: 0; } +#rail { + width: 60px; background: var(--rail); color: var(--rail-fg); + display: flex; flex-direction: column; overflow-y: auto; overflow-x: hidden; transition: width .2s; +} +#rail.open { width: 220px; } +#rail .entry { + height: 48px; display: flex; align-items: center; padding: 0 0 0 18px; + overflow: hidden; color: var(--rail-fg); cursor: pointer; border-right: 3px solid transparent; font-size: 14px; +} +#rail .entry:hover { background: var(--rail-hover); color: var(--text); } +#rail .entry.active { + border-right: 3px solid #1976d2; background: transparent; color: var(--rail-fg-active); +} +.entry-icon { + font-size: 24px; width: 24px; height: 24px; display: flex; + align-items: center; justify-content: center; margin-right: 14px; flex: none; +} +.entry-icon svg { width: 24px; height: 24px; display: block; } +#topbar .btn.ghost { color: #fff; border-color: #fff; } +.entry-text { white-space: nowrap; opacity: 0; transition: opacity .2s; } +#rail.open .entry-text { opacity: 1; } +#rail .entry.divider { + height: 0; padding: 0; margin: 8px 0; border-top: 1px solid rgba(0,0,0,.12); flex: none; +} +.rail-footer { margin-top: auto; } +#content { flex: 1; overflow-y: auto; padding: 20px 30px 5px 5px; } + +.tabbar { + display: flex; border-bottom: 2px solid #e0e0e0; + margin-bottom: 16px; position: relative; +} +.tabbar .tab { + padding: 10px 16px; cursor: pointer; color: var(--muted); font-size: 14px; + border-bottom: 2px solid transparent; margin-bottom: -2px; +} +.tabbar .tab.active { color: var(--primary); border-bottom-color: var(--ink); } + +.page-title { font-size: 24px; font-weight: 400; margin: 0 0 12px; } +.cards { + display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 12px; margin-bottom: 16px; +} +.card { background: var(--surface); border-radius: 2px; box-shadow: var(--shadow); padding: 14px; } +.card-label { font-size: 12px; color: var(--muted); } +.card-value { font-size: 28px; font-weight: 400; margin-top: 4px; } +.section { + background: var(--surface); border-radius: 2px; box-shadow: var(--shadow); + padding: 16px; margin-bottom: 16px; +} +.section h2 { margin: 0 0 12px; font-size: 16px; font-weight: 500; } +.row { display: flex; gap: 10px; align-items: flex-end; flex-wrap: wrap; } +.row > div { flex: 1; min-width: 140px; } +.tbl { width: 100%; border-collapse: collapse; } +.tbl th, .tbl td { text-align: left; padding: 8px 10px; border-bottom: 1px solid var(--border); } +.tbl th { color: var(--muted); font-size: 12px; font-weight: 500; } +.tbl tr:hover td { background: var(--surface-alt); } +.toggle { display: flex; align-items: center; gap: 8px; margin: 6px 0; } +.toggle input { width: auto; } +.badge { display: inline-block; padding: 2px 10px; border-radius: 10px; font-size: 11px; } +.badge.on { background: #e8f5e9; color: #2e7d32; } +.badge.off { background: #fff3e0; color: #e65100; } +.badge.unknown { background: #eeeeee; color: #616161; } +.btn { + background: var(--primary); color: #fff; border: 0; border-radius: 2px; + padding: 8px 14px; font-size: 14px; cursor: pointer; + box-shadow: 0 1px 3px rgba(0,0,0,.3); +} +.btn:hover { background: var(--primary-dark); } +.btn:disabled, .btn.busy { opacity: .5; cursor: default; pointer-events: none; } +.btn.ghost { + background: transparent; color: var(--primary); box-shadow: none; + border: 1px solid var(--primary); +} +.btn.danger { background: var(--danger); } +input, select { + background: var(--surface); color: var(--text); border: 1px solid var(--border); + border-radius: 2px; padding: 8px 10px; width: 100%; +} +label { display: block; font-size: 12px; color: var(--muted); margin: 8px 0 4px; } +code { background: var(--surface-alt); padding: 1px 5px; border-radius: 2px; } +pre.logs { + background: var(--terminal-bg); color: #c9d1d9; padding: 12px; overflow: auto; + max-height: 420px; font-size: 12px; white-space: pre-wrap; border-radius: 2px; +} +.log-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; } +.log-toolbar h2 { margin: 0; } +.log-search { width: min(260px, 35vw); } +.empty { padding: 24px; text-align: center; color: var(--muted); } +#terminal-panel { + position: fixed; left: 0; right: 0; bottom: 0; + border-top: 1px solid var(--border); background: #000; height: 280px; + display: flex; flex-direction: column; z-index: 60; + box-shadow: 0 -3px 10px rgba(0,0,0,.35); +} +#terminal-panel:not(.hidden) { animation: term-up .18s ease-out; } +@keyframes term-up { from { transform: translateY(100%); } to { transform: translateY(0); } } +#terminal-bar { + display: flex; justify-content: space-between; align-items: center; + background: var(--toolbar); padding: 4px 10px; font-size: 12px; color: #e0e0e0; +} +#toast-container { + position: fixed; right: 16px; bottom: 16px; display: flex; + flex-direction: column; gap: 8px; z-index: 100; +} +.toast { + padding: 10px 14px; border-radius: 2px; background: var(--surface); + box-shadow: var(--shadow); color: var(--text); +} +.toast.error { border-left: 4px solid var(--danger); } +.toast.info { border-left: 4px solid var(--primary); } +.toast.success { border-left: 4px solid var(--ok); } +#terminal .xterm { height: 100%; } + +#pager-panel { + position: fixed; left: 0; right: 0; bottom: 0; + border-top: 1px solid var(--border); background: #303030; height: 540px; + display: flex; flex-direction: column; z-index: 60; + box-shadow: 0 -3px 10px rgba(0,0,0,.35); +} +#pager-panel:not(.hidden) { animation: term-up .18s ease-out; } +#pager-bar { + display: flex; justify-content: space-between; align-items: center; + background: var(--toolbar); padding: 4px 10px; font-size: 12px; color: #e0e0e0; +} +#pager-body { flex: 1; min-height: 0; overflow: hidden; padding: 10px; } +#pager_ui { + border-collapse: collapse; border-spacing: 0; margin: 0 auto; + border-radius: 62px; + box-shadow: inset 0 1px 0 rgba(255,255,255,.035), inset 0 0 34px rgb(24 24 24), + inset 0 -22px 36px rgba(0,0,0,.42), 0 14px 38px rgba(0,0,0,.48), + 0 0 22px rgba(0,239,53,.09); +} +#pager_ui td { padding: 0; margin: 0; } +#pager_ui img { display: block; } +.pager-scale-wrapper { transform-origin: top center; } +.pager-btn { + cursor: pointer; user-select: none; -webkit-user-drag: none; + transition: filter 0.08s ease; +} +.pager-btn.pressed { filter: brightness(0.6); } +#pager_error { + position: absolute; top: 0; left: 0; width: 480px; height: 222px; + font-size: large; text-align: center; color: #fff; + display: flex; align-items: center; justify-content: center; flex-direction: column; + background: rgba(0,0,0,.8); z-index: 5; +} +#pager_error[hidden] { display: none; } + +.title-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 16px; margin: 8px 0 16px; } +.title-card { background: var(--card, #fff); border: 1px solid var(--border, #e0e0e0); border-radius: 4px; padding: 12px 16px; box-shadow: 0 2px 4px rgba(0, 0, 0, .12); } +.title-card h2 { margin: 0 0 8px; font-size: 15px; font-weight: 500; } +.chart-box { height: 160px; } +.card-stat { font-size: 28px; font-weight: 500; margin: 4px 0; } +.switch { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; -webkit-user-select: none; user-select: none; } +.switch input { display: none; } +.switch .track { width: 40px; height: 20px; border-radius: 10px; background: #bdbdbd; position: relative; transition: background .2s; } +.switch .track::after { content: ''; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: #fff; transition: left .2s; } +.switch input:checked + .track { background: #1976d2; } +.switch input:checked + .track::after { left: 22px; } +.switch input:indeterminate + .track { background: #9e9e9e; } +.switch input:indeterminate + .track::after { left: 12px; } +.sel { padding: 6px 8px; border: 1px solid var(--border, #e0e0e0); border-radius: 4px; background: var(--card, #fff); color: var(--text, #212121); } +.pager { display: flex; gap: 8px; align-items: center; margin-top: 8px; } +.muted { color: var(--muted, #686868); } +html.dark .title-card { background: #424242; border-color: #545454; } +html.dark .sel { background: #424242; border-color: #545454; color: #fff; } +html.dark .muted { color: #bdbdbd; } + +/* ---- Recon (Mark VII parity) ---- */ +.recon-title-card-container { display: flex; width: 100%; flex-wrap: wrap; justify-content: space-between; gap: 10px; margin: 8px 0 16px; } +.recon-title-card { flex: 1 1 220px; min-width: 220px; margin-bottom: 1em; } +.recon-card { background: var(--surface); border-radius: 2px; box-shadow: var(--shadow); height: 200px; padding: 12px 16px; display: flex; flex-direction: column; } +.recon-title-card-title { font-size: 20px; margin-bottom: 15px; display: flex; align-items: center; color: var(--text); } +.recon-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-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-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; } + +/* ---- Recon AP focus sidebar + table sort ---- */ +.recon-focus-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-focus-header { display: flex; align-items: center; gap: 6px; margin-bottom: 4px; } +.recon-focus-header-text { font-size: 20px; line-height: 1.2; flex: 1; word-break: break-word; } +.recon-focus-bssid { font-family: Consolas, Menlo, monospace; font-size: 13px; -webkit-user-select: text; user-select: text; } +.recon-focus-subtext { color: grey; font-style: italic; font-size: 12px; margin-bottom: 8px; } +.recon-focus-body { margin-top: 18px; } +.recon-focus-body-title { font-size: 16px; margin-bottom: 8px; color: var(--text); } +.recon-focus-action-button { width: 100%; margin-bottom: 5px; } +.recon-focus-detail { display: flex; justify-content: space-between; gap: 10px; padding: 3px 0; border-bottom: 1px solid var(--border); font-size: 13px; } +.recon-focus-detail-label { color: var(--muted); flex: none; } +.recon-sort-arrow { color: var(--muted); font-size: 11px; } +th.recon-sorted { color: var(--primary); } +.recon-per { width: auto; } + +/* ---- 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; } + +/* ---- PineAP overview segmented control ---- */ +.seg { display: inline-flex; margin-top: 8px; border: 1px solid var(--border, #e0e0e0); border-radius: 4px; overflow: hidden; } +.seg-btn { background: transparent; border: none; padding: 5px 14px; font-size: 12px; cursor: pointer; color: var(--muted, #666); } +.seg-btn + .seg-btn { border-left: 1px solid var(--border, #e0e0e0); } +.seg-btn.active { background: var(--primary, #1976d2); color: #fff; } +.seg-btn.busy { opacity: .5; pointer-events: none; } + +/* ---- 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%; margin-top: 0; } +.pineap-card-button-group .seg-btn { flex: 1; } +.pineap-mode-save { display: flex; justify-content: flex-end; margin-top: 10px; } +.pineap-mode-features { margin: 6px 0 0; padding-left: 20px; } +.pineap-card-settings, .pineap-card-pool, .pineap-card-handshakes, .pineap-card-inject { flex: 1; } +.pineap-handshakes-none { display: flex; justify-content: center; font-style: italic; color: var(--muted); } + +/* ---- 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; } +.pineap-title-card > .switch { display: flex; margin: 8px 0; } + +/* ---- PineAP filters ---- */ +.filter-mode-seg { margin: 0 0 8px; } +.pineap-filter-description { color: var(--muted); font-size: 13px; margin: 2px 0 12px; } +.pineap-filter-entry-row { align-items: flex-end; } +.pineap-filter-value { flex: 1 1 280px; } +.pineap-filter-actions { display: flex; gap: 8px; flex-wrap: wrap; margin: 8px 0 14px; } +.pineap-filter-card .tbl tbody tr { cursor: pointer; } +.pineap-filter-card .tbl tbody tr:hover { background: rgba(25, 118, 210, .08); } + +/* ---- 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; } + +/* ---- Payloads (Pager-native Mark VII layout) ---- */ +.payload-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; } +.payload-heading h2 { margin-bottom: 4px; font-size: 20px; font-weight: 400; } +.payload-heading p { margin: 0; max-width: 760px; } +.payload-filters { display: grid; grid-template-columns: minmax(220px, 2fr) minmax(170px, 1fr); gap: 10px; max-width: 720px; } +.payload-result-count { margin: 9px 0; font-size: 12px; } +.payload-list { display: grid; gap: 10px; } +.payload-card { display: flex; align-items: center; gap: 16px; padding: 14px 2px; border-top: 1px solid var(--border); } +.payload-card-main { flex: 1; min-width: 0; } +.payload-card h3, .payload-run h3, .payload-dev-block h3 { margin: 0; font-size: 16px; font-weight: 500; } +.payload-author { margin: 2px 0 7px; font-size: 12px; } +.payload-description { margin: 0 0 10px; max-width: 850px; } +.payload-meta { display: flex; flex-wrap: wrap; gap: 6px; } +.payload-tag { padding: 2px 8px; border-radius: 10px; background: var(--surface-alt); color: var(--muted); font-size: 11px; text-transform: capitalize; } +.payload-actions { display: flex; align-items: center; justify-content: flex-end; gap: 7px; flex-wrap: wrap; } +.payload-actions .btn { white-space: nowrap; } +.payload-empty { padding: 30px 16px; border-top: 1px solid var(--border); color: var(--muted); text-align: center; font-style: italic; } +.payload-runs { display: grid; gap: 12px; } +.payload-run { border: 1px solid var(--border); border-radius: 3px; padding: 13px; } +.payload-run-head { display: flex; align-items: center; gap: 12px; } +.payload-run-head > div:first-child { flex: 1; } +.payload-run-head p { margin: 3px 0 0; font-size: 12px; } +.payload-run-state { border-radius: 10px; padding: 3px 9px; font-size: 11px; } +.payload-run-state.running { color: #2e7d32; background: #e8f5e9; } +.payload-run-state.finished { color: var(--muted); background: var(--surface-alt); } +.payload-output { max-height: 240px; overflow: auto; margin: 12px 0 0; padding: 10px; border-radius: 2px; background: #111; color: #d8d8d8; font: 12px/1.45 monospace; white-space: pre-wrap; } +.payload-dev-grid { display: grid; grid-template-columns: repeat(2, minmax(240px, 1fr)); gap: 14px; margin: 18px 0; } +.payload-dev-block { padding: 15px; border: 1px solid var(--border); background: var(--surface-alt); } +.payload-dev-block p { margin-bottom: 0; } +.payload-code { margin: 10px 0 0; padding: 12px; overflow: auto; background: #111; color: #d8d8d8; font-size: 12px; } + +/* ---- Settings (Mark VII structure, Pager capabilities) ---- */ +.settings-content { margin-top: 14px; } +.settings-card { margin-bottom: 16px; } +.settings-card h2 { font-size: 20px; font-weight: 400; margin-bottom: 10px; } +.settings-subtitle { margin: -4px 0 14px; font-size: 13px; line-height: 1.45; } +.settings-form-grid { + display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 4px 16px; align-items: end; +} +.settings-actions { display: flex; gap: 8px; flex-wrap: wrap; margin: 12px 0; } +.settings-table-wrap { overflow-x: auto; width: 100%; } +.settings-table-wrap .tbl { min-width: 680px; } +.settings-kv-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 8px 20px; } +.settings-kv { display: flex; align-items: center; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--border); padding: 7px 0; } +.settings-kv > span { color: var(--muted); } +.settings-chip-row { display: flex; gap: 8px; flex-wrap: wrap; } +.settings-chip { padding: 5px 9px; } +.settings-diagnostics { max-height: 520px; white-space: pre-wrap; word-break: break-word; } +.settings-card > .switch { display: flex; margin: 10px 0; color: var(--text); font-size: 13px; } +.settings-card a { color: var(--primary); } +@media (max-width: 700px) { + .tabbar { overflow-x: auto; flex-wrap: nowrap; } + .tabbar .tab { flex: 0 0 auto; } + .settings-form-grid { grid-template-columns: 1fr; } + .payload-heading, .payload-card, .payload-run-head { align-items: stretch; flex-direction: column; } + .payload-filters, .payload-dev-grid { grid-template-columns: 1fr; } + .payload-actions { justify-content: flex-start; } +} diff --git a/payload/user/remote_access/pager-webui/www/index.html b/payload/user/remote_access/pager-webui/www/index.html new file mode 100644 index 0000000..1459f2d --- /dev/null +++ b/payload/user/remote_access/pager-webui/www/index.html @@ -0,0 +1,273 @@ + + + + + + +WiFi Pineapple + + + + + +
+ + + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+ + + + + + + + + + + +
+ + + +
+ + + + + + + + + + + + + + + +
+ + + + + + + + + + + +
+ + + +
+ + + +
+ + + + + +
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + + + + + + + + + + + + diff --git a/payload/user/remote_access/pager-webui/www/js/api.js b/payload/user/remote_access/pager-webui/www/js/api.js new file mode 100644 index 0000000..877a4f5 --- /dev/null +++ b/payload/user/remote_access/pager-webui/www/js/api.js @@ -0,0 +1,42 @@ +'use strict'; + +const PagerAPI = (() => { + let apiBase = ''; + let on401 = null; + async function request(method, path, body) { + const opts = { method, headers: {}, credentials: 'include' }; + if (body !== undefined) { + opts.headers['Content-Type'] = 'application/json'; + opts.body = JSON.stringify(body); + } + const res = await fetch(apiBase + path, opts); + if (res.status === 401) { + if (on401) on401(); + throw new Error('unauthorized'); + } + const ct = res.headers.get('Content-Type') || ''; + let data; + if (ct.indexOf('json') !== -1) { + try { data = await res.json(); } + catch (e) { data = null; } + } else { + data = await res.text(); + } + if (!res.ok) { + const message = data && data.error ? data.error : ('HTTP ' + res.status); + const error = new Error(message); + error.status = res.status; + error.data = data; + throw error; + } + return { status: res.status, data }; + } + return { + setBase: (b) => { apiBase = b; }, + on401: (fn) => { on401 = fn; }, + get: (p) => request('GET', p), + post: (p, b) => request('POST', p, b === undefined ? {} : b), + del: (p, b) => request('DELETE', p, b === undefined ? {} : b), + login: async (username, password) => request('POST', '/api/login', { username, password }) + }; +})(); diff --git a/payload/user/remote_access/pager-webui/www/js/app.js b/payload/user/remote_access/pager-webui/www/js/app.js new file mode 100644 index 0000000..bdf5055 --- /dev/null +++ b/payload/user/remote_access/pager-webui/www/js/app.js @@ -0,0 +1,488 @@ +'use strict'; + +const Theme = (() => { + const KEY = 'pw_theme'; + function apply() { + document.documentElement.classList.toggle('dark', localStorage.getItem(KEY) === 'dark'); + } + function toggle() { + localStorage.setItem(KEY, Theme.current() === 'dark' ? 'light' : 'dark'); + apply(); + } + function current() { return document.documentElement.classList.contains('dark') ? 'dark' : 'light'; } + return { apply, toggle, current, KEY }; +})(); + +const App = (() => { + const cfg = window.PAGER_CONFIG || {}; + const API_BASE = cfg.apiBase || ''; + const WS_BASE = (cfg.wsBase || location.origin).replace(/^http/, 'ws'); + const TERMINAL_WS = cfg.terminalWs || ('ws://' + location.hostname + ':1471/api/terminal/openWs'); + const PAGER_SCREEN_WS = cfg.pagerScreenWs || (WS_BASE + '/api/pager/display/screen.ws'); + const PAGER_KEYS_WS = cfg.pagerKeysWs || (WS_BASE + '/api/pager/input/keys.ws'); + + const els = {}; + let currentView = null; + let notifications = []; + let unreadNotifications = 0; + const NOTIFICATIONS_KEY = 'markviii_notifications'; + + const railItems = [ + { key: 'dashboard', label: 'Dashboard', hash: '#/dashboard', icon: 'dashboard' }, + { key: 'pineap', label: 'PineAP', hash: '#/pineap', icon: 'wifi' }, + { key: 'recon', label: 'Recon', hash: '#/recon', icon: 'recon' }, + { key: 'logging', label: 'Logging', hash: '#/logging', icon: 'logging' }, + { key: 'modules', label: 'Payloads', hash: '#/modules', icon: 'modules' }, + { key: 'settings', label: 'Settings', hash: '#/settings', icon: 'settings' } + ]; + const railDividers = new Set(['logging']); + + function keyOf(hash) { + const seg = (hash || '#/dashboard').replace(/^#\//, '').split('/')[0]; + return seg || 'dashboard'; + } + + function buildRail() { + const rail = els.rail; + rail.innerHTML = ''; + function railEntry(it) { + const a = document.createElement('a'); + a.className = 'entry'; + a.href = it.hash; + a.title = it.label; + a.innerHTML = '' + PineappleIcons[it.icon] + '' + it.label + ''; + a.addEventListener('click', (e) => { e.preventDefault(); location.hash = it.hash; }); + return a; + } + railItems.filter((it) => it.key !== 'settings').forEach((it) => { + if (railDividers.has(it.key)) { + const d = document.createElement('div'); + d.className = 'entry divider'; + rail.appendChild(d); + } + rail.appendChild(railEntry(it)); + }); + const foot = document.createElement('div'); + foot.className = 'rail-footer'; + const settings = railItems.find((it) => it.key === 'settings'); + if (settings) foot.appendChild(railEntry(settings)); + const open = document.createElement('a'); + open.className = 'entry'; + open.title = 'Open Menu'; + open.innerHTML = '' + PineappleIcons.chevron_right + 'Open Menu'; + open.addEventListener('click', (e) => { + e.preventDefault(); + const openState = localStorage.getItem('pw_rail') === 'open'; + localStorage.setItem('pw_rail', openState ? 'closed' : 'open'); + els.rail.classList.toggle('open', !openState); + }); + foot.appendChild(open); + rail.appendChild(foot); + rail.classList.toggle('open', localStorage.getItem('pw_rail') === 'open'); + } + + function route() { + closeToolbarMenus(); + const hash = location.hash || '#/dashboard'; + const name = routes[hash]; + if (currentView && currentView.destroy) currentView.destroy(); + els.content.innerHTML = ''; + if (!name || !views[name]) { + const ph = document.createElement('div'); + ph.className = 'section empty'; + ph.textContent = 'View not available.'; + els.content.appendChild(ph); + currentView = null; + } else { + currentView = views[name](els.content); + } + const key = keyOf(hash); + Array.prototype.forEach.call(els.rail.querySelectorAll('.entry'), (a) => { + const href = a.getAttribute('href'); + a.classList.toggle('active', !!href && (href === hash || keyOf(href) === key)); + }); + } + + function loadNotifications() { + try { + const stored = JSON.parse(sessionStorage.getItem(NOTIFICATIONS_KEY) || '{}'); + notifications = Array.isArray(stored.items) ? stored.items.slice(0, 30) : []; + unreadNotifications = Math.max(0, Number(stored.unread) || 0); + } catch (e) { + notifications = []; + unreadNotifications = 0; + } + } + + function saveNotifications() { + try { + sessionStorage.setItem(NOTIFICATIONS_KEY, JSON.stringify({ + items: notifications.slice(0, 30), unread: unreadNotifications + })); + } catch (e) {} + } + + function notificationTime(timestamp) { + const date = new Date(timestamp); + if (Number.isNaN(date.getTime())) return ''; + const today = new Date(); + if (date.toDateString() === today.toDateString()) { + return date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); + } + return date.toLocaleDateString([], { month: 'short', day: 'numeric' }); + } + + function renderNotifications() { + if (!els.notificationsList) return; + els.notificationsList.innerHTML = ''; + els.notificationBadge.classList.toggle('hidden', unreadNotifications === 0); + els.notificationBadge.textContent = unreadNotifications > 99 ? '99+' : String(unreadNotifications); + els.notificationsClear.disabled = notifications.length === 0; + if (!notifications.length) { + const empty = document.createElement('div'); + empty.className = 'notification-empty'; + empty.textContent = 'No Notifications'; + els.notificationsList.appendChild(empty); + return; + } + notifications.forEach((notice) => { + const item = document.createElement('div'); + item.className = 'notification-item'; + const marker = document.createElement('span'); + marker.className = 'notification-kind ' + (notice.kind || 'info'); + const message = document.createElement('span'); + message.className = 'notification-message'; + message.textContent = notice.message; + const time = document.createElement('time'); + time.className = 'notification-time'; + time.dateTime = new Date(notice.timestamp).toISOString(); + time.textContent = notificationTime(notice.timestamp); + item.append(marker, message, time); + els.notificationsList.appendChild(item); + }); + } + + function addNotification(message, kind) { + const normalizedKind = ['error', 'success'].indexOf(kind) === -1 ? 'info' : kind; + notifications.unshift({ + message: String(message).slice(0, 300), kind: normalizedKind, timestamp: Date.now() + }); + notifications = notifications.slice(0, 30); + unreadNotifications += 1; + saveNotifications(); + renderNotifications(); + } + + function toast(msg, kind, options) { + if (!options || options.notify !== false) addNotification(msg, kind); + const d = document.createElement('div'); + d.className = 'toast ' + (kind || 'info'); + d.textContent = msg; + els.toasts.appendChild(d); + setTimeout(() => d.remove(), 4000); + } + + function closeToolbarMenus() { + if (!els.notificationsMenu) return; + els.notificationsMenu.classList.add('hidden'); + els.overflowMenu.classList.add('hidden'); + els.notificationsButton.setAttribute('aria-expanded', 'false'); + els.overflowButton.setAttribute('aria-expanded', 'false'); + } + + function toggleToolbarMenu(name) { + const menu = name === 'notifications' ? els.notificationsMenu : els.overflowMenu; + const button = name === 'notifications' ? els.notificationsButton : els.overflowButton; + const opening = menu.classList.contains('hidden'); + closeToolbarMenus(); + if (!opening) return; + menu.classList.remove('hidden'); + button.setAttribute('aria-expanded', 'true'); + if (name === 'notifications') { + unreadNotifications = 0; + saveNotifications(); + renderNotifications(); + } + } + + function setInternetStatus(state) { + const labels = { checking: 'Checking\u2026', online: 'Online', offline: 'Offline' }; + els.internetStatus.textContent = labels[state]; + els.internetStatusDot.className = 'connection-dot ' + state; + } + + function checkInternet(announce) { + setInternetStatus('checking'); + return PagerAPI.get('/api/settings/internet') + .then((response) => { + const online = !!response.data.online; + setInternetStatus(online ? 'online' : 'offline'); + if (announce) toast(online ? 'Internet connection is online' : 'Internet connection is offline', online ? 'success' : 'error'); + }) + .catch(() => { + setInternetStatus('offline'); + if (announce) toast('Unable to check the internet connection', 'error'); + }); + } + + function handleMenuAction(action) { + closeToolbarMenus(); + if (action === 'help') { + location.hash = '#/settings/help'; + } else if (action === 'updates') { + location.hash = '#/settings/advanced'; + toast('Update controls are available in Advanced settings'); + } else if (action === 'internet') { + checkInternet(true); + } else if (action === 'logout') { + PagerAPI.post('/api/logout') + .then(() => showLogin()) + .catch((error) => toast(error.message || 'Logout failed', 'error')); + } else if (action === 'reboot') { + if (!window.confirm('Reboot Mark VIII now?')) return; + PagerAPI.post('/api/settings/reboot') + .then(() => toast('Reboot requested. Mark VIII will disconnect shortly.')) + .catch((error) => toast(error.message || 'Reboot failed', 'error')); + } + } + + function showDock(name) { + if (name === 'pager') { + if (typeof Term !== 'undefined' && Term.isOpen()) Term.close(); + } else { + if (typeof Pager !== 'undefined' && Pager.isOpen()) Pager.close(); + } + } + + function showApp() { + els.login.classList.add('hidden'); + els.app.classList.remove('hidden'); + Theme.apply(); + Live.start(); + route(); + checkInternet(false); + } + + function showLogin() { + closeToolbarMenus(); + if (typeof Live !== 'undefined') Live.stop(); + if (typeof Term !== 'undefined' && Term.isOpen()) Term.close(); + if (typeof Pager !== 'undefined' && Pager.isOpen()) Pager.close(); + els.app.classList.add('hidden'); + els.login.classList.remove('hidden'); + } + + function init() { + els.login = document.getElementById('login-screen'); + els.app = document.getElementById('app'); + els.rail = document.getElementById('rail'); + els.content = document.getElementById('content'); + els.toasts = document.getElementById('toast-container'); + els.notificationsButton = document.getElementById('notifications-btn'); + els.notificationsMenu = document.getElementById('notifications-menu'); + els.notificationsList = document.getElementById('notifications-list'); + els.notificationsClear = document.getElementById('notifications-clear'); + els.notificationBadge = document.getElementById('notification-badge'); + els.overflowButton = document.getElementById('overflow-btn'); + els.overflowMenu = document.getElementById('overflow-menu'); + els.internetStatus = document.getElementById('internet-status'); + els.internetStatusDot = document.getElementById('internet-status-dot'); + + els.notificationsButton.innerHTML = PineappleIcons.notifications; + document.getElementById('terminal-btn').innerHTML = PineappleIcons.terminal; + document.getElementById('pager-btn').innerHTML = PineappleIcons.pager; + els.overflowButton.innerHTML = PineappleIcons.more_vert; + Array.prototype.forEach.call(document.querySelectorAll('[data-icon]'), (icon) => { + icon.innerHTML = PineappleIcons[icon.getAttribute('data-icon')] || ''; + }); + loadNotifications(); + renderNotifications(); + + els.notificationsButton.addEventListener('click', (event) => { + event.stopPropagation(); + toggleToolbarMenu('notifications'); + }); + els.overflowButton.addEventListener('click', (event) => { + event.stopPropagation(); + toggleToolbarMenu('overflow'); + }); + els.notificationsMenu.addEventListener('click', (event) => event.stopPropagation()); + els.overflowMenu.addEventListener('click', (event) => event.stopPropagation()); + els.notificationsClear.addEventListener('click', () => { + notifications = []; + unreadNotifications = 0; + saveNotifications(); + renderNotifications(); + }); + Array.prototype.forEach.call(els.overflowMenu.querySelectorAll('[data-menu-action]'), (item) => { + item.addEventListener('click', () => handleMenuAction(item.getAttribute('data-menu-action'))); + }); + document.addEventListener('click', closeToolbarMenus); + + document.getElementById('login-form').addEventListener('submit', (e) => { + e.preventDefault(); + const btn = document.getElementById('login-button'); + const pw = document.getElementById('login-password').value; + document.getElementById('login-error').textContent = ''; + btn.disabled = true; + PagerAPI.login('root', pw) + .then(() => { document.getElementById('login-password').value = ''; showApp(); toast('Logged in'); }) + .catch((err) => { + document.getElementById('login-error').textContent = (err && err.message && err.message !== 'unauthorized') + ? 'Login failed.' : 'Invalid credentials.'; + }) + .finally(() => { btn.disabled = false; }); + }); + + document.getElementById('terminal-btn').addEventListener('click', () => { + if (typeof Term === 'undefined') { toast('Terminal not available yet'); return; } + showDock('terminal'); + Term.toggle(); + }); + document.getElementById('terminal-close').addEventListener('click', () => { + if (typeof Term === 'undefined') { toast('Terminal not available yet'); return; } + showDock('terminal'); + Term.toggle(); + }); + document.getElementById('pager-btn').addEventListener('click', () => { + if (typeof Pager === 'undefined') { toast('Virtual Pager not available yet'); return; } + showDock('pager'); + Pager.toggle(); + }); + document.getElementById('pager-close').addEventListener('click', () => { + if (typeof Pager === 'undefined') { toast('Virtual Pager not available yet'); return; } + showDock('pager'); + Pager.toggle(); + }); + window.addEventListener('hashchange', route); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + closeToolbarMenus(); + return; + } + if (localStorage.getItem('pw_hotkeys') === 'false') return; + const t = e.target; + if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT')) return; + if (e.ctrlKey || e.metaKey || e.altKey) return; + const map = { d: '#/dashboard', p: '#/pineap', r: '#/recon', l: '#/logging', m: '#/modules' }; + if (map[e.key.toLowerCase()]) { location.hash = map[e.key.toLowerCase()]; } + else if (e.key === '`') { + if (typeof Term === 'undefined') return; + if (typeof Pager !== 'undefined' && Pager.isOpen()) Pager.close(); + Term.toggle(); + } + }); + + PagerAPI.setBase(API_BASE); + PagerAPI.on401(() => showLogin()); + buildRail(); + Theme.apply(); + checkSession(); + } + + function checkSession() { + PagerAPI.get('/api/api_ping') + .then(() => showApp()) + .catch(() => showLogin()); + } + + const routes = { + '#/dashboard': 'dashboard', + '#/pineap': 'pineap', + '#/pineap/open': 'pineap_open', + '#/pineap/evilwpa': 'pineap_evilwpa', + '#/pineap/impersonation': 'pineap_impersonation', + '#/pineap/clients': 'pineap_clients', + '#/pineap/filtering': 'pineap_filtering', + '#/recon': 'recon', + '#/recon/handshakes': 'recon_handshakes', + '#/logging': 'logging', + '#/logging/system': 'logging_system', + '#/modules': 'modules', + '#/modules/online': 'modules_online', + '#/modules/running': 'modules_running', + '#/modules/develop': 'modules_develop', + '#/settings': 'settings', + '#/settings/networking': 'settings_networking', + '#/settings/wifi': 'settings_wifi', + '#/settings/led': 'settings_led', + '#/settings/advanced': 'settings_advanced', + '#/settings/help': 'settings_help' + }; + + return { init, route, toast, showLogin, wsUrl: (p) => WS_BASE + p, terminalWs: TERMINAL_WS, + pagerScreenWs: PAGER_SCREEN_WS, pagerKeysWs: PAGER_KEYS_WS, apiBase: API_BASE, + keyOf, railItems, go: (h) => { location.hash = h; }, + get key() { return keyOf(location.hash); } }; +})(); + +const Live = (() => { + let ws = null; + let ever = false; + let poll = null; + const subs = []; + let timer = null; + function stopPoll() { + if (poll) { clearInterval(poll); poll = null; } + } + function start() { + if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return; + stopPoll(); + try { ws = new WebSocket(App.wsUrl('/api/ws')); } + catch (e) { fallback(); return; } + ws.onopen = () => { ever = true; }; + ws.onmessage = (ev) => { + let msg; + try { msg = JSON.parse(ev.data); } catch (e) { return; } + subs.forEach((fn) => fn(msg)); + updateBar(msg); + }; + ws.onclose = () => { + ws = null; + clearTimeout(timer); + if (ever) timer = setTimeout(start, 5000); + else fallback(); + }; + ws.onerror = () => { try { ws.close(); } catch (e) {} }; + } + function stop() { + ever = false; + clearTimeout(timer); + timer = null; + stopPoll(); + if (ws) { + const active = ws; + ws = null; + active.onclose = null; + try { active.close(); } catch (e) {} + } + } + function fallback() { + stopPoll(); + const seconds = Math.max(2, Math.min(120, parseInt(localStorage.getItem('pw-poll') || '5', 10) || 5)); + poll = setInterval(async () => { + try { + const r = await PagerAPI.get('/api/status'); + const msg = { type: 'tick', status: r.data, clients: r.data.clients }; + subs.forEach((fn) => fn(msg)); + updateBar(msg); + } catch (e) {} + }, seconds * 1000); + } + function updateBar(msg) { + const b = (msg.status || {}).battery || {}; + const n = (msg.clients || []).length; + const el = document.getElementById('live-status'); + if (el) el.textContent = 'BAT ' + (b.level == null ? '--' : b.level + '%' + (b.charging ? '+' : '')) + ' CLIENTS ' + n; + } + function onTick(fn) { + subs.push(fn); + return () => { + const i = subs.indexOf(fn); + if (i !== -1) subs.splice(i, 1); + }; + } + return { start, stop, onTick }; +})(); + +document.addEventListener('DOMContentLoaded', () => App.init()); diff --git a/payload/user/remote_access/pager-webui/www/js/chart.js b/payload/user/remote_access/pager-webui/www/js/chart.js new file mode 100644 index 0000000..d05d675 --- /dev/null +++ b/payload/user/remote_access/pager-webui/www/js/chart.js @@ -0,0 +1,137 @@ +'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); + 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 }; +})(); diff --git a/payload/user/remote_access/pager-webui/www/js/config.js b/payload/user/remote_access/pager-webui/www/js/config.js new file mode 100644 index 0000000..313b6e9 --- /dev/null +++ b/payload/user/remote_access/pager-webui/www/js/config.js @@ -0,0 +1,7 @@ +window.PAGER_CONFIG = { + apiBase: '', + wsBase: '', + terminalWs: '', + pagerScreenWs: '', + pagerKeysWs: '' +}; diff --git a/payload/user/remote_access/pager-webui/www/js/icons.js b/payload/user/remote_access/pager-webui/www/js/icons.js new file mode 100644 index 0000000..2847598 --- /dev/null +++ b/payload/user/remote_access/pager-webui/www/js/icons.js @@ -0,0 +1,34 @@ +'use strict'; + +window.PineappleIcons = { + dashboard: '', + pineap: '', + recon: '', + logging: '', + modules: '', + settings: '', + chevron: '', + terminal: '', + wifi: '', + extension: '', + receipt: '', + refresh: '', + file_download: '', + delete: '', + settings: '', + search: '', + first_page: '', + last_page: '', + chevron_left: '', + check: '', + close: '', + question_mark: '', + chevron_right: '', + notifications: '', + pager: '', + more_vert: '', + help: '', + update: '', + logout: '', + reboot: '' +}; diff --git a/payload/user/remote_access/pager-webui/www/js/pager.js b/payload/user/remote_access/pager-webui/www/js/pager.js new file mode 100644 index 0000000..1a775cf --- /dev/null +++ b/payload/user/remote_access/pager-webui/www/js/pager.js @@ -0,0 +1,158 @@ +'use strict'; + +const Pager = (() => { + const SCREEN_WIDTH = 480; + const SCREEN_HEIGHT = 222; + const FB_STRIDE = SCREEN_WIDTH * 4; + const PAGER_WIDTH = 745; + + const KEY_MAP = { + 'LEFT.png': 'ArrowLeft', + 'UP.png': 'ArrowUp', + 'RIGHT.png': 'ArrowRight', + 'DOWN.png': 'ArrowDown', + 'A_Button.png': 'Enter', + 'B_Button.png': 'Escape' + }; + + let panel = null; + let bodyEl = null; + let table = null; + let pager = null; + let canvas = null; + let screenerr = null; + let keyws = null; + let screenws = null; + + function ensure() { + if (table) return; + panel = document.getElementById('pager-panel'); + bodyEl = document.getElementById('pager-body'); + table = document.getElementById('pager_ui'); + pager = document.getElementById('pager'); + canvas = document.getElementById('pager_canvas'); + screenerr = document.getElementById('pager_error'); + table.querySelectorAll('img.pager-btn').forEach((img) => { + const src = img.getAttribute('src').split('/').pop(); + const key = KEY_MAP[src]; + if (!key) return; + img.addEventListener('click', () => press(img, key)); + }); + const retry = document.getElementById('screen_retry'); + if (retry) retry.addEventListener('click', () => connect()); + } + + function press(el, key) { + el.classList.add('pressed'); + setTimeout(() => el.classList.remove('pressed'), 80); + sendKey(key); + } + + function sendKey(k) { + if (keyws && keyws.readyState === WebSocket.OPEN) keyws.send(k); + } + + function renderRGBAFrame(bytes) { + if (bytes.length < FB_STRIDE * SCREEN_HEIGHT) { + console.warn('pager frame too small for 480x222', bytes.length); + return; + } + const ctx = canvas.getContext('2d'); + const imageData = ctx.createImageData(SCREEN_WIDTH, SCREEN_HEIGHT); + const dst = imageData.data; + let di = 0; + for (let y = 0; y < SCREEN_HEIGHT; y++) { + const rowStart = y * FB_STRIDE; + for (let x = 0; x < SCREEN_WIDTH; x++) { + const si = rowStart + x * 4; + dst[di] = bytes[si]; + dst[di + 1] = bytes[si + 1]; + dst[di + 2] = bytes[si + 2]; + dst[di + 3] = bytes[si + 3]; + di += 4; + } + } + ctx.putImageData(imageData, 0, 0); + pager.src = canvas.toDataURL('image/png'); + } + + function connect() { + disconnect(); + try { + const sock = new WebSocket(App.pagerScreenWs); + sock.binaryType = 'arraybuffer'; + screenws = sock; + sock.onopen = () => { screenerr.hidden = true; }; + sock.onmessage = (ev) => { + if (ev.data instanceof ArrayBuffer) renderRGBAFrame(new Uint8Array(ev.data)); + else if (ev.data && ev.data.arrayBuffer) ev.data.arrayBuffer().then((b) => renderRGBAFrame(new Uint8Array(b))); + }; + sock.onerror = () => { screenerr.hidden = false; }; + sock.onclose = () => { if (screenws === sock) screenws = null; screenerr.hidden = false; }; + } catch (e) { + screenerr.hidden = false; + } + try { + const sock = new WebSocket(App.pagerKeysWs); + keyws = sock; + sock.onclose = () => { if (keyws === sock) keyws = null; }; + sock.onerror = () => { try { sock.close(); } catch (e2) {} }; + } catch (e) { + keyws = null; + } + } + + function disconnect() { + if (screenws) { try { screenws.close(); } catch (e) {} screenws = null; } + if (keyws) { try { keyws.close(); } catch (e) {} keyws = null; } + } + + function applyScale() { + if (!bodyEl || !table) return; + const availableWidth = bodyEl.clientWidth - 20; + const scale = Math.min(1, availableWidth / PAGER_WIDTH); + table.style.zoom = String(scale); + table.style.marginBottom = ''; + } + + function toggle() { + ensure(); + if (panel.classList.contains('hidden')) { + panel.classList.remove('hidden'); + document.getElementById('pager-btn').classList.add('active'); + applyScale(); + connect(); + try { pager.focus(); } catch (e) {} + } else { + panel.classList.add('hidden'); + document.getElementById('pager-btn').classList.remove('active'); + disconnect(); + } + } + + function isOpen() { + return !!panel && !panel.classList.contains('hidden'); + } + + function close() { + if (isOpen()) toggle(); + } + + window.addEventListener('resize', () => { + if (table && panel && !panel.classList.contains('hidden')) applyScale(); + }); + + document.addEventListener('keydown', (e) => { + if (!isOpen()) return; + const t = e.target; + if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' + || t.tagName === 'BUTTON' || t.tagName === 'A' || t.isContentEditable)) return; + if (e.ctrlKey || e.metaKey || e.altKey) return; + const keys = ['ArrowLeft', 'ArrowUp', 'ArrowRight', 'ArrowDown', 'Enter', 'Escape']; + if (keys.indexOf(e.key) === -1) return; + if (e.key.indexOf('Arrow') === 0) e.preventDefault(); + if (!e.repeat) sendKey(e.key); + }); + + return { toggle, close, isOpen }; +})(); diff --git a/payload/user/remote_access/pager-webui/www/js/terminal.js b/payload/user/remote_access/pager-webui/www/js/terminal.js new file mode 100644 index 0000000..9cf9cba --- /dev/null +++ b/payload/user/remote_access/pager-webui/www/js/terminal.js @@ -0,0 +1,72 @@ +'use strict'; + +const Term = (() => { + let term = null; + let fitAddon = null; + let ws = null; + let panel = null; + + function ensure() { + if (term) return; + panel = document.getElementById('terminal-panel'); + term = new Terminal({ cursorBlink: true, scrollback: 2000, cols: 80, rows: 24 }); + fitAddon = new FitAddon.FitAddon(); + term.loadAddon(fitAddon); + term.open(document.getElementById('terminal')); + try { fitAddon.fit(); } catch (e) {} + term.onData((d) => { if (ws && ws.readyState === WebSocket.OPEN) ws.send(d); }); + } + + function toggle() { + ensure(); + if (panel.classList.contains('hidden')) { + panel.classList.remove('hidden'); + document.getElementById('terminal-btn').classList.add('active'); + try { fitAddon.fit(); } catch (e) {} + connect(); + } else { + panel.classList.add('hidden'); + document.getElementById('terminal-btn').classList.remove('active'); + disconnect(); + } + } + + function connect() { + if (ws) return; + if (term) term.reset(); + let sock; + try { + sock = new WebSocket(App.terminalWs); + } catch (e) { + term.writeln('\r\n[cannot reach daemon terminal: ' + e.message + ']'); + return; + } + ws = sock; + sock.onmessage = (ev) => { + if (typeof ev.data === 'string') term.write(ev.data); + else ev.data.text().then((t) => term.write(t)); + }; + sock.onclose = () => { if (ws === sock) ws = null; if (term) term.writeln('\r\n[connection closed]'); }; + sock.onerror = () => { try { sock.close(); } catch (e) {} }; + } + + function disconnect() { + if (ws) { try { ws.close(); } catch (e) {} ws = null; } + } + + function isOpen() { + return !!panel && !panel.classList.contains('hidden'); + } + + function close() { + if (isOpen()) toggle(); + } + + window.addEventListener('resize', () => { + if (fitAddon && panel && !panel.classList.contains('hidden')) { + try { fitAddon.fit(); } catch (e) {} + } + }); + + return { toggle, close, isOpen }; +})(); diff --git a/payload/user/remote_access/pager-webui/www/js/views.js b/payload/user/remote_access/pager-webui/www/js/views.js new file mode 100644 index 0000000..4d096da --- /dev/null +++ b/payload/user/remote_access/pager-webui/www/js/views.js @@ -0,0 +1,2271 @@ +'use strict'; + +const views = {}; + +const h = (tag, attrs, ...children) => { + const n = document.createElement(tag); + if (attrs) { + for (const k in attrs) { + if (k === 'class') n.className = attrs[k]; + else if (k === 'text') n.textContent = attrs[k]; + else if (k === 'html') n.innerHTML = attrs[k]; + else if (k.startsWith('on')) n.addEventListener(k.slice(2), attrs[k]); + else n.setAttribute(k, attrs[k]); + } + } + for (const c of children) { + if (c == null) continue; + n.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); + } + return n; +}; + +const table = (columns, rows, rowAttrs) => { + const t = h('table', { class: 'tbl' }); + const thead = h('thead'), tr = h('tr'); + columns.forEach((c) => tr.appendChild(h('th', { text: c.label }))); + thead.appendChild(tr); t.appendChild(thead); + const tb = h('tbody'); + (rows || []).forEach((r) => { + const trr = h('tr', rowAttrs ? rowAttrs(r) : {}); + columns.forEach((c) => trr.appendChild(h('td', { text: c.render ? c.render(r) : r[c.key] }))); + tb.appendChild(trr); + }); + t.appendChild(tb); + return t; +}; + +const fmtTime = (ts) => { + if (!ts) return '--'; + const d = new Date(ts * 1000); + return d.toLocaleString(); +}; + +const fmtDur = (secs) => { + if (secs == null) return '--'; + const d = Math.floor(secs / 86400), hh = Math.floor((secs % 86400) / 3600), + mm = Math.floor((secs % 3600) / 60); + return (d ? d + 'd ' : '') + hh + 'h ' + mm + 'm'; +}; + +const fmtBytes = (bytes) => { + if (bytes == null || !isFinite(Number(bytes))) return '--'; + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let value = Number(bytes), unit = 0; + while (Math.abs(value) >= 1024 && unit < units.length - 1) { + value /= 1024; + unit += 1; + } + return value.toFixed(unit < 2 ? 0 : 1) + ' ' + units[unit]; +}; + +const downloadText = (filename, text) => { + const url = URL.createObjectURL(new Blob([text], { type: 'text/plain;charset=utf-8' })); + const a = h('a', { href: url, download: filename }); + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(() => URL.revokeObjectURL(url), 0); +}; + +const badge = (on) => h('span', { class: 'badge ' + (on ? 'on' : 'off'), text: on ? 'ON' : 'OFF' }); + +const btn = (label, onclk, cls) => h('button', { class: 'btn ' + (cls || ''), onclick: onclk, text: label }); + +const iconBtn = (name, title, onclk) => { + const b = h('button', { class: 'icon-btn', title: title || '', onclick: onclk }); + b.innerHTML = PineappleIcons[name] || ''; + return b; +}; + +const tabBar = (box, items, activeHash) => { + const bar = h('div', { class: 'tabbar' }); + items.forEach((it) => { + const t = h('a', { class: 'tab' + (it.hash === activeHash ? ' active' : ''), href: it.hash, text: it.label }); + t.addEventListener('click', (e) => { e.preventDefault(); location.hash = it.hash; }); + bar.appendChild(t); + }); + box.appendChild(bar); + return bar; +}; + +views.dashboard = (root) => { + const history = { clients: [] }; + const max = 60; + + const title = h('h1', { class: 'page-title', text: 'Dashboard' }); + root.appendChild(title); + + const grid = h('div', { class: 'cards' }); + root.appendChild(grid); + const defs = [ + ['clients', 'Clients Connected'], ['handshakes', 'Handshakes Captured'], + ['disk', 'Disk Usage'], ['uptime', 'Uptime'] + ]; + const cards = {}; + defs.forEach(([k, label]) => { + const card = h('div', { class: 'card' }, + h('div', { class: 'card-label', text: label }), + h('div', { class: 'card-value', text: 'Loading…' })); + grid.appendChild(card); + cards[k] = card.querySelector('.card-value'); + }); + + const chartBox = h('div', { class: 'section' }, + h('h2', {}, 'Clients'), + h('canvas', { id: 'dash-chart', style: 'width:100%;height:140px' })); + root.appendChild(chartBox); + const canvas = chartBox.querySelector('#dash-chart'); + + const update = (msg) => { + const s = msg.status || {}; + const n = (msg.clients || []).length; + history.clients.push(n); + if (history.clients.length > max) { history.clients.shift(); } + cards.clients.textContent = n; + cards.uptime.textContent = s.uptime == null ? 'Unavailable' : fmtDur(s.uptime); + cards.disk.textContent = s.disk && s.disk.size != null + ? fmtBytes(s.disk.used) + ' / ' + fmtBytes(s.disk.size) : 'Unavailable'; + if (typeof MiniChart !== 'undefined') { + MiniChart.draw(canvas, [ + { label: 'Clients', color: '#1976d2', points: history.clients } + ]); + } + }; + const unsubscribe = Live.onTick(update); + PagerAPI.get('/api/status').then((r) => update({ status: r.data, clients: r.data.clients })) + .catch(() => { + cards.clients.textContent = 'Unavailable'; + cards.disk.textContent = 'Unavailable'; + cards.uptime.textContent = 'Unavailable'; + }); + PagerAPI.get('/api/pineap/handshakes').then((r) => { + cards.handshakes.textContent = (r.data.files || []).length; + }).catch(() => { cards.handshakes.textContent = 'Unavailable'; }); + + const clBody = h('div', {}); + const clBox = h('div', { class: 'section' }, + h('h2', {}, 'Connected Clients'), clBody); + root.appendChild(clBox); + const hsBody = h('div', {}); + const hsBox = h('div', { class: 'section' }, + h('h2', {}, 'Captured WPA Handshakes'), hsBody); + root.appendChild(hsBox); + + function loadClients() { + PagerAPI.get('/api/pineap/clients').then((r) => { + clBody.innerHTML = ''; + clBody.appendChild(table( + [{ label: 'MAC', key: 'mac' }, { label: 'Interface', key: 'iface' }, { label: 'RSSI', key: 'rssi' }, + { label: '', render: () => '' }], + r.data.clients, + (c) => ({ style: 'cursor:pointer', + onclick: () => { if (confirm('Deauthenticate ' + c.mac + '?')) PagerAPI.post('/api/pineap/deauth/client', { mac: c.mac }).then(() => App.toast('Deauthenticated')).then(loadClients); } }))); + const cols = ['MAC', 'Interface', 'RSSI']; + clBody.querySelectorAll('.tbl th').forEach((th, i) => { if (i >= cols.length) th.textContent = 'Deauth'; }); + }).catch(() => { clBody.textContent = 'Unable to load connected clients.'; }); + } + function loadHandshakes() { + PagerAPI.get('/api/pineap/handshakes').then((r) => { + hsBody.innerHTML = ''; + hsBody.appendChild(table( + [{ label: 'File', key: 'name' }, { label: 'Size', key: 'size' }, { label: 'Modified', key: 'mtime' }], + (r.data.files || []).map((f) => ({ name: f.name, size: f.size, mtime: fmtTime(f.mtime) })))); + }).catch(() => { hsBody.textContent = 'Unable to load captured handshakes.'; }); + } + loadClients(); + loadHandshakes(); + const iv = setInterval(loadClients, 10000); + return { destroy: () => { clearInterval(iv); unsubscribe(); } }; +}; + +const PINEAP_TABS = [ + { label: 'PineAP', hash: '#/pineap' }, + { label: 'Open AP', hash: '#/pineap/open' }, + { label: 'Evil WPA', hash: '#/pineap/evilwpa' }, + { label: 'Impersonation', hash: '#/pineap/impersonation' }, + { label: 'Clients', hash: '#/pineap/clients' }, + { label: 'Filtering', hash: '#/pineap/filtering' } +]; + +// The Pager daemon can change these states but cannot read them back. Keep +// them explicitly unknown until this WebUI successfully changes them. +const PINEAP_SESSION = { mode: null, karma: null, advertise: null, collect: null }; + +function setKnownCheckbox(cb, value) { + cb.indeterminate = value == null; + if (value != null) cb.checked = !!value; +} + +function pineapShell(root, activeHash) { + root.appendChild(h('h1', { class: 'page-title', text: 'PineAP' })); + tabBar(root, PINEAP_TABS, activeHash); + const box = h('div', {}); + root.appendChild(box); + return box; +} + +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)); +} + +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: '—' }); + let selectedMode = 'unknown'; + let modeDirty = false; + let modePending = false; + const segBtns = {}; + const modeBar = h('div', { class: 'seg' }); + ['passive', 'active', 'advanced'].forEach((m) => { + const b = h('button', { class: 'seg-btn', text: m[0].toUpperCase() + m.slice(1) }); + b.addEventListener('click', () => selectMode(m, true)); + modeBar.appendChild(b); + segBtns[m] = b; + }); + const modeInfo = h('div', { class: 'muted', style: 'margin-top:8px;font-size:12px' }); + const saveModeBtn = btn('Save Mode', saveMode, 'ghost'); + saveModeBtn.disabled = true; + const modeCard = h('div', { class: 'pineap-title-card' }, + h('div', { class: 'pineap-card-title-flex' }, mode), + h('div', { class: 'pineap-card-button-group' }, modeBar), + modeInfo, + h('div', { class: 'pineap-mode-save' }, saveModeBtn)); + + const quick = { + 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: 'switch' }, quick.collect, h('span', { class: 'track' }), 'Capture SSIDs to Pool')); + quickCard.appendChild(h('label', { class: 'switch' }, quick.advertise, h('span', { class: 'track' }), '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: {} }; + 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'] + ]; + statusDefs.forEach(([k, label, hash]) => { + const val = h('span', { text: '—' }); + cards[k].value = val; + cardWrap.appendChild(pineapTitleCard(label, hash, val)); + }); + box.appendChild(cardWrap); + + function bind(cb, on, remember) { + cb.addEventListener('change', () => { + const requested = cb.checked; + cb.indeterminate = false; + on(requested).then(() => { + if (remember) remember(requested); + load(); + }).catch(() => { + cb.checked = !requested; + load(); + App.toast('Failed', 'error'); + }); + }); + } + function rememberAdvanced(key, value) { + PINEAP_SESSION[key] = value; + PINEAP_SESSION.mode = 'advanced'; + modeDirty = false; + selectMode('advanced', false); + } + bind(quick.collect, (v) => PagerAPI.post('/api/pineap/ssidpool/collect', { enable: v }), + (v) => rememberAdvanced('collect', v)); + bind(quick.advertise, (v) => PagerAPI.post('/api/pineap/ssidpool/advertise', { enable: v }), + (v) => rememberAdvanced('advertise', v)); + setKnownCheckbox(quick.advertise, PINEAP_SESSION.advertise); + + function renderModeInfo(m) { + modeInfo.innerHTML = ''; + if (m === 'unknown') { + modeInfo.textContent = 'Select a mode to establish the Pager\'s PineAP preset.'; + return; + } + const descriptions = { + passive: ['Capture SSIDs to the impersonation pool', 'Do not broadcast the pool', 'Keep the PineAP response engine disabled'], + active: ['Capture SSIDs to the impersonation pool', 'Broadcast the impersonation pool', 'Enable the PineAP response engine'] + }; + if (m === 'advanced') { + modeInfo.textContent = 'All supported PineAP features are individually customizable from Quick Settings and the PineAP tabs.'; + return; + } + modeInfo.appendChild(h('div', { text: 'In ' + m[0].toUpperCase() + m.slice(1) + ' Mode:' })); + const list = h('ul', { class: 'pineap-mode-features' }); + descriptions[m].forEach((text) => list.appendChild(h('li', { text }))); + modeInfo.appendChild(list); + } + + function selectMode(m, dirty) { + selectedMode = m; + if (dirty) modeDirty = true; + Object.keys(segBtns).forEach((k) => segBtns[k].classList.toggle('active', k === m)); + mode.textContent = m === 'unknown' ? 'Unknown' : m[0].toUpperCase() + m.slice(1); + mode.className = 'badge ' + (m === 'unknown' ? 'unknown' : 'on'); + renderModeInfo(m); + saveModeBtn.disabled = !modeDirty || modePending || m === 'unknown'; + } + + function saveMode() { + if (!modeDirty || modePending || selectedMode === 'unknown') return; + modePending = true; + saveModeBtn.disabled = true; + saveModeBtn.classList.add('busy'); + PagerAPI.post('/api/pineap/mode', { mode: selectedMode }).then((r) => { + const state = r.data || {}; + PINEAP_SESSION.mode = state.mode || selectedMode; + ['karma', 'advertise', 'collect'].forEach((key) => { + if (typeof state[key] === 'boolean') PINEAP_SESSION[key] = state[key]; + }); + modeDirty = false; + selectMode(PINEAP_SESSION.mode, false); + App.toast('Mode: ' + PINEAP_SESSION.mode[0].toUpperCase() + PINEAP_SESSION.mode.slice(1)); + load(); + }).catch(() => { App.toast('Failed to save PineAP mode', 'error'); }) + .finally(() => { + modePending = false; + saveModeBtn.classList.remove('busy'); + saveModeBtn.disabled = !modeDirty; + }); + } + + let loadPending = false; + function load() { + if (loadPending) return; + loadPending = true; + const stateRequest = 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/mode').catch(() => ({ data: {} })) + ]).then(([cfg, host, ap, preset]) => { + const c = cfg.data || {}, hh = host.data || {}, a = ap.data || {}, p = preset.data || {}; + const disabled = Object.prototype.hasOwnProperty.call(hh, 'pineap_disabled') ? !!hh.pineap_disabled : null; + const wpa = a.wpa || {}; + PINEAP_SESSION.mode = ['passive', 'active', 'advanced'].indexOf(p.mode) !== -1 ? p.mode : 'unknown'; + ['karma', 'advertise', 'collect'].forEach((key) => { + if (typeof p[key] === 'boolean') PINEAP_SESSION[key] = p[key]; + }); + if (!modeDirty) selectMode(PINEAP_SESSION.mode, false); + mode.className = 'badge ' + (disabled === true ? 'off' : disabled === false ? 'on' : 'unknown'); + const collect = typeof PINEAP_SESSION.collect === 'boolean' + ? PINEAP_SESSION.collect + : Object.prototype.hasOwnProperty.call(c, 'autossidpool') ? !!c.autossidpool : null; + setKnownCheckbox(quick.collect, collect); + const pool = a.pool || {}; + if (pool.disabled != null) PINEAP_SESSION.advertise = pool.disabled === false; + setKnownCheckbox(quick.advertise, PINEAP_SESSION.advertise); + cards.karma.value.textContent = PINEAP_SESSION.karma == null ? 'Unknown' : (PINEAP_SESSION.karma ? 'On' : 'Off'); + const open = a.open || {}; + cards.open.value.textContent = open.enabled == null ? 'Unavailable' : (open.enabled ? 'On' : 'Off'); + cards.wpa.value.textContent = wpa.enabled == null ? 'Unavailable' : (wpa.enabled ? 'On' : 'Off'); + }); + const statRequests = [ + PagerAPI.get('/api/pineap/ssids').then((ss) => { + stats.ssids.textContent = Array.isArray((ss.data || {}).ssids) ? ss.data.ssids.length : 'Unavailable'; + }).catch(() => { stats.ssids.textContent = 'Unavailable'; }), + PagerAPI.get('/api/pineap/clients').then((cl) => { + stats.clients.textContent = typeof (cl.data || {}).count === 'number' ? cl.data.count : 'Unavailable'; + }).catch(() => { stats.clients.textContent = 'Unavailable'; }), + PagerAPI.get('/api/pineap/handshakes').then((hs) => { + stats.handshakes.textContent = Array.isArray((hs.data || {}).files) ? hs.data.files.length : 'Unavailable'; + }).catch(() => { stats.handshakes.textContent = 'Unavailable'; }) + ]; + Promise.allSettled([stateRequest].concat(statRequests)).finally(() => { loadPending = false; }); + } + load(); + const iv = setInterval(load, 5000); + return { destroy: () => clearInterval(iv) }; +}; + +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'], ['CY', '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', 'Portugal'], ['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', 'Uruguay'], + ['VE', 'Venezuela'], ['VN', 'Vietnam'] +]; + +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' }); + let karmaDirty = false; + karmaCb.addEventListener('change', () => { + karmaDirty = true; + karmaCb.indeterminate = false; + render(); + }); + + 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() { + const requests = [PagerAPI.post('/api/pineap/wifi/set_ap', { + open: { + ssid: ssidIn.value, + bssid: bssidIn.value.trim(), + hidden: hiddenCb.checked, + enabled: state.enabledLoaded ? !!state.enabled : true, + channel: chSel.value ? parseInt(chSel.value, 10) : null, + country: coSel.value + } + })]; + if (karmaDirty) requests.push(PagerAPI.post('/api/pineap/mimic', { enable: karmaCb.checked })); + Promise.allSettled(requests).then((results) => { + const ok = results.every((r) => r.status === 'fulfilled'); + if (ok && karmaDirty) { + PINEAP_SESSION.karma = karmaCb.checked; + karmaDirty = false; + } + 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/filters/ssid').catch(() => ({ data: {} })), + PagerAPI.get('/api/pineap/filters/client').catch(() => ({ data: {} })) + ]).then(([ap, sf, cf]) => { + const a = ap.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.enabledLoaded = !!(a.open); + state.enabled = !!open.enabled; + if (!karmaDirty) setKnownCheckbox(karmaCb, PINEAP_SESSION.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: () => {} }; +}; + +const EVIL_ENC = [ + ['psk2', 'WPA2 PSK'], ['sae', 'WPA3 SAE'], ['owe', 'WPA3 OWE'] +]; + +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', type: 'password', autocomplete: 'new-password' }); + 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: 'switch' }, hiddenCb, h('span', { class: 'track' }), 'Hidden')); + cfg.appendChild(h('label', { class: 'switch' }, enabledCb, h('span', { class: 'track' }), '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 captureCb = h('input', { type: 'checkbox', id: 'ew-capture' }); + const partialCb = h('input', { type: 'checkbox', id: 'ew-partial' }); + capBox.appendChild(h('div', { class: 'pineap-settings-section', text: 'Automatic Capture' })); + capBox.appendChild(h('label', { class: 'switch' }, captureCb, h('span', { class: 'track' }), 'Capture WPA handshakes')); + capBox.appendChild(h('label', { class: 'switch' }, partialCb, h('span', { class: 'track' }), 'Keep partial handshakes')); + capBox.appendChild(h('div', { class: 'muted', style: 'margin:6px 0 10px;font-size:12px' }, + 'Automatically save handshakes observed by PineAP. Partial captures may not contain enough material for password recovery.')); + capBox.appendChild(btn('Save capture settings', () => { + PagerAPI.post('/api/pineap/set_config', { + loghandshake: captureCb.checked, + logpartialhandshake: partialCb.checked + }).then(() => { App.toast('Handshake capture settings saved'); load(); }) + .catch(() => App.toast('Failed to save capture settings', 'error')); + }, 'ghost')); + capBox.appendChild(h('div', { class: 'pineap-settings-section', text: 'Targeted Capture' })); + 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 && Array.prototype.some.call(encSel.options, (o) => o.value === w.enctype)) { + encSel.value = w.enctype; + } + hiddenCb.checked = !!w.hidden; + enabledCb.checked = !!w.enabled; + }).catch(() => {}); + PagerAPI.get('/api/pineap/get_config').then((r) => { + const p = r.data || {}; + captureCb.checked = !!p.loghandshake; + partialCb.checked = !!p.logpartialhandshake; + }).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) }; +}; + +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: 'switch' }, enabledCb, h('span', { class: 'track' }), 'Enabled')); + cfg.appendChild(h('label', { class: 'switch' }, authCb, h('span', { class: 'track' }), '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) }; +}; + +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: 'switch' }, advCb, h('span', { class: 'track' }), 'Advertise AP Impersonation Pool')); + poolBox.appendChild(h('label', { class: 'switch' }, colCb, h('span', { class: 'track' }), 'Capture SSIDs to Pool')); + poolBox.appendChild(list); + box.appendChild(poolBox); + setKnownCheckbox(advCb, PINEAP_SESSION.advertise); + advCb.addEventListener('change', () => { + const requested = advCb.checked; + advCb.indeterminate = false; + PagerAPI.post('/api/pineap/ssidpool/advertise', { enable: requested }).then(() => { + PINEAP_SESSION.advertise = requested; + load(); + }).catch(() => { setKnownCheckbox(advCb, PINEAP_SESSION.advertise); 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: 'pineap-handshakes-none', 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 || {}; + if (p.disabled != null) PINEAP_SESSION.advertise = p.disabled === false; + setKnownCheckbox(advCb, PINEAP_SESSION.advertise); + setKnownCheckbox(colCb, Object.prototype.hasOwnProperty.call(p, 'collecting') ? !!p.collecting : null); + }).catch(() => {}); + } + load(); + return { destroy: () => {} }; +}; + +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'; }); + } + let pending = false; + function load() { + if (pending) return; + pending = true; + PagerAPI.get('/api/pineap/clients').then((r) => { + state.clients = r.data.clients || []; + count.textContent = (r.data && typeof r.data.count === 'number') ? r.data.count : '—'; + render(); + }).catch(() => { count.textContent = 'Unavailable'; body.textContent = 'Unable to load connected clients.'; }) + .finally(() => { pending = false; }); + } + load(); + const iv = setInterval(load, 5000); + return { destroy: () => clearInterval(iv) }; +}; + +views.pineap_filtering = (root) => { + const box = pineapShell(root, '#/pineap/filtering'); + function filterCard(title, kind) { + const path = '/api/pineap/filters/' + kind; + const noun = kind === 'client' ? 'client MACs' : 'SSIDs'; + const singular = kind === 'client' ? 'client MAC' : 'SSID'; + const state = { mode: 'deny', entries: [], pending: false }; + const allowDefault = h('button', { class: 'seg-btn', text: 'Allow by default' }); + const denyDefault = h('button', { class: 'seg-btn', text: 'Deny by default' }); + const modeBar = h('div', { class: 'seg filter-mode-seg' }, allowDefault, denyDefault); + const explanation = h('div', { class: 'pineap-filter-description' }); + const valueIn = h('input', { id: 'fv-' + kind, autocomplete: 'off' }); + const valueLabel = h('label', {}, h('span', { class: 'pineap-filter-input-label' }), valueIn); + const list = h('div', {}); + const addButton = btn('Add', addEntry); + const clearButton = btn('Clear current list', clearCurrent, 'danger'); + const allowAllButton = btn('Allow all', allowAll, 'ghost'); + const card = h('div', { class: 'pineap-title-card pineap-filter-card' }, + h('div', { class: 'pineap-card-title' }, title), + h('div', { class: 'pineap-settings-section', text: 'Default behavior' }), + modeBar, + explanation, + h('div', { class: 'row pineap-filter-entry-row' }, + h('div', { class: 'pineap-filter-value' }, valueLabel), + h('div', {}, addButton)), + h('div', { class: 'pineap-filter-actions' }, allowAllButton, clearButton), + list); + box.appendChild(card); + + function setPending(value) { + state.pending = value; + [allowDefault, denyDefault, addButton, clearButton, allowAllButton, valueIn] + .forEach((el) => { el.disabled = value; }); + } + + function applyResponse(r) { + const data = (r && r.data) || {}; + state.mode = data.mode === 'allow' ? 'allow' : 'deny'; + state.entries = Array.isArray(data.entries) ? data.entries : []; + render(); + } + + function fail(message) { + App.toast(message, 'error'); + } + + function mutate(payload, success) { + if (state.pending) return; + setPending(true); + PagerAPI.post(path, Object.assign({ mode: state.mode }, payload)) + .then((r) => { applyResponse(r); if (success) App.toast(success); }) + .catch(() => fail('Failed to update ' + title.toLowerCase())) + .finally(() => setPending(false)); + } + + function setMode(mode) { + if (state.mode === mode || state.pending) return; + mutate({ action: 'set_mode', mode }, mode === 'deny' + ? title + ': allowing by default' + : title + ': denying by default'); + } + + function addEntry() { + const value = valueIn.value.trim(); + if (!value) { fail(singular + ' required'); return; } + mutate({ action: 'add', value }, singular + ' added'); + valueIn.value = ''; + } + + function removeEntry(value) { + if (confirm('Remove ' + value + ' from this ' + (state.mode === 'deny' ? 'deny' : 'allow') + ' list?')) { + mutate({ action: 'delete', value }, singular + ' removed'); + } + } + + function clearCurrent() { + if (confirm('Clear every entry from the current ' + (state.mode === 'deny' ? 'deny' : 'allow') + ' list?')) { + mutate({ action: 'clear' }, 'Current list cleared'); + } + } + + function allowAll() { + if (confirm('Allow all ' + noun + '? This clears the deny list and changes the default behavior to allow.')) { + mutate({ action: 'allow_all' }, 'All ' + noun + ' allowed'); + } + } + + function render() { + const isDenyList = state.mode === 'deny'; + allowDefault.classList.toggle('active', isDenyList); + denyDefault.classList.toggle('active', !isDenyList); + explanation.textContent = isDenyList + ? 'New ' + noun + ' are allowed. Only entries in the deny list are blocked.' + : 'New ' + noun + ' are blocked. Only entries in the allow list are permitted.'; + valueLabel.querySelector('.pineap-filter-input-label').textContent = + 'Add to ' + (isDenyList ? 'deny' : 'allow') + ' list'; + valueIn.placeholder = kind === 'client' ? 'AA:BB:CC:DD:EE:FF' : 'Network name'; + list.innerHTML = ''; + list.appendChild(table( + [{ label: (isDenyList ? 'Denied ' : 'Allowed ') + singular, key: 'value' }, + { label: '', render: () => 'Remove' }], + state.entries.map((e) => ({ value: e })), + (row) => ({ onclick: () => removeEntry(row.value) }))); + if (!state.entries.length) list.appendChild(h('div', { + class: 'pineap-handshakes-none', + text: 'The ' + (isDenyList ? 'deny' : 'allow') + ' list is empty.' + })); + } + + allowDefault.addEventListener('click', () => setMode('deny')); + denyDefault.addEventListener('click', () => setMode('allow')); + valueIn.addEventListener('keydown', (e) => { if (e.key === 'Enter') addEntry(); }); + PagerAPI.get(path).then(applyResponse).catch(() => fail('Unable to load ' + title.toLowerCase())); + } + filterCard('Client Filter', 'client'); + filterCard('SSID Filter', 'ssid'); + return { destroy: () => {} }; +}; + +const RECON_TABS = [ + { label: 'Scanning', hash: '#/recon' }, + { label: 'Handshakes', hash: '#/recon/handshakes' } +]; + +const RECON_LANDSCAPE_COLORS = ['#2ecc71', '#2980b9', '#8e44ad']; +const RECON_ENC_COLORS = ['#2ecc71', '#2980b9', '#8e44ad', '#e74c3c', '#ff0000', '#34495e']; +const RECON_ENC_BUCKETS = ['Open', 'WEP', 'WPA', 'WPA2', 'WPA3', 'Enterprise']; +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 } +]; + +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)); +} + +function reconEncBucket(enc) { + const s = (enc || '').trim(); + if (s === 'Open') return 'Open'; + if (s.indexOf('Enterprise') !== -1) return 'Enterprise'; + if (s.indexOf('WEP') !== -1) return 'WEP'; + if (s.indexOf('WPA2') !== -1) return 'WPA2'; + if (s.indexOf('WPA3') !== -1) return 'WPA3'; + if (s.indexOf('WPA') !== -1) return 'WPA'; + return s || 'Unknown'; +} + +function reconPer(key, def) { + const v = parseInt(localStorage.getItem('pw_recon_per_' + key), 10); + return [10, 25, 50].indexOf(v) !== -1 ? v : def; +} + +function reconCmp(a, b, col, dir) { + const numeric = col.key === 'channel' || col.key === 'signal' || col.key === 'freq' || col.key === 'packets'; + if (numeric) { + const x = a[col.key] == null ? -Infinity : Number(a[col.key]); + const y = b[col.key] == null ? -Infinity : Number(b[col.key]); + return (x - y) * dir; + } + const xs = String(a[col.key] == null ? '' : a[col.key]).toLowerCase(); + const ys = String(b[col.key] == null ? '' : b[col.key]).toLowerCase(); + return xs.localeCompare(ys) * dir; +} + +views.recon = (root) => { + root.appendChild(h('h1', { class: 'page-title', text: 'Recon' })); + tabBar(root, RECON_TABS, '#/recon'); + + const state = { scans: [], selected: null, detail: null, + apPage: 0, apSearch: '', clientPage: 0, clientSearch: '', + apPer: reconPer('ap', 10), clientPer: reconPer('client', 10), + apSort: null, clientSort: null, focusAp: null, autoFollow: false, scanActive: false }; + const cols = reconLoadCols(); + + // ---- 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 encContent = titleCard('Encryption Landscape', false); + const encBox = h('div', { class: 'recon-chart-box' }); + encContent.appendChild(encBox); + const encCanvas = h('canvas', { id: 'recon-encryption' }); + encBox.appendChild(encCanvas); + const encEmpty = h('div', { class: 'recon-no-data', text: 'No encryption data is available yet.' }); + encBox.appendChild(encEmpty); + + 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/set_config', { loghandshake: 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.clientPage = 0; + loadDetail(); + }); + psRow.appendChild(sel); + psRow.appendChild(iconBtn('file_download', 'Download scan JSON', () => { + if (state.selected != null) window.location = App.apiBase + '/api/recon/scans/' + state.selected + '/download/json'; + })); + psRow.appendChild(iconBtn('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(iconBtn('settings', 'Recon settings', () => sidebar.classList.toggle('hidden'))); + let pendingScan = false; + scanToggle.addEventListener('change', () => { + if (pendingScan) { scanToggle.checked = !scanToggle.checked; return; } + const on = scanToggle.checked; + pendingScan = true; + scanToggle.disabled = true; + PagerAPI.post(on ? '/api/recon/start' : '/api/recon/stop', on ? { scan_time: parseInt(durSel.value, 10) } : {}) + .then(() => { + if (on) { + state.scanActive = true; + state.selected = null; + state.apPage = 0; + state.clientPage = 0; + App.toast('Scan started'); + } else { + state.scanActive = false; + App.toast('Scan stopped'); + } + restartPoll(); + load(); + }) + .catch(() => { scanToggle.checked = !on; App.toast('Recon control failed', 'error'); }) + .finally(() => { pendingScan = false; 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); + + // ---- AP focus sidebar ---- + const focusSidebar = h('div', { class: 'recon-focus-sidebar hidden' }); + root.appendChild(focusSidebar); + + function renderFocus() { + const ap = state.focusAp; + if (!ap) return; + focusSidebar.innerHTML = ''; + const head = h('div', { class: 'recon-focus-header' }, + h('div', { class: 'recon-focus-header-text', text: ap.ssid || 'Hidden SSID' }), + btn('×', () => { state.focusAp = null; focusSidebar.classList.add('hidden'); renderTables(); }, 'ghost')); + focusSidebar.appendChild(head); + focusSidebar.appendChild(h('div', { class: 'recon-focus-bssid', text: ap.bssid || '--' })); + focusSidebar.appendChild(h('div', { class: 'recon-focus-subtext', text: 'Channel ' + (ap.channel == null ? '--' : ap.channel) + ' · ' + (ap.freq || '--') + ' MHz' })); + + const actions = h('div', { class: 'recon-focus-body' }); + focusSidebar.appendChild(actions); + actions.appendChild(h('div', { class: 'recon-focus-body-title', text: 'Actions' })); + const capture = h('button', { class: 'btn recon-focus-action-button', text: 'Capture WPA Handshakes' }); + capture.addEventListener('click', () => { + PagerAPI.post('/api/pineap/set_config', { loghandshake: true }) + .then(() => App.toast('Handshake capture enabled (device-wide on Pager)')) + .catch(() => App.toast('Failed to enable handshake capture', 'error')); + }); + actions.appendChild(capture); + const stopHs = h('button', { class: 'btn danger recon-focus-action-button', text: 'Stop Handshake Capture' }); + stopHs.addEventListener('click', () => { + PagerAPI.post('/api/pineap/set_config', { loghandshake: false }) + .then(() => App.toast('Handshake capture disabled')) + .catch(() => App.toast('Failed to disable handshake capture', 'error')); + }); + actions.appendChild(stopHs); + const exB = h('button', { class: 'btn recon-focus-action-button', text: 'Examine BSSID' }); + exB.addEventListener('click', () => { + if (!ap.bssid) return; + PagerAPI.post('/api/recon/examine', { bssid: ap.bssid }) + .then(() => App.toast('Examining ' + ap.bssid)) + .catch(() => App.toast('Examine failed', 'error')); + }); + actions.appendChild(exB); + const exC = h('button', { class: 'btn recon-focus-action-button', text: 'Examine Channel' }); + exC.addEventListener('click', () => { + if (ap.channel == null) { App.toast('Channel unknown', 'error'); return; } + PagerAPI.post('/api/recon/examine', { channel: ap.channel }) + .then(() => App.toast('Examining channel ' + ap.channel)) + .catch(() => App.toast('Examine failed', 'error')); + }); + actions.appendChild(exC); + + const details = h('div', { class: 'recon-focus-body' }); + focusSidebar.appendChild(details); + details.appendChild(h('div', { class: 'recon-focus-body-title', text: 'Details' })); + [['Channel', ap.channel == null ? '--' : ap.channel], + ['Signal', ap.signal == null ? '--' : ap.signal + ' dBm'], + ['Encryption', ap.encryption || '--'], + ['Frequency', ap.freq || '--'], + ['Hidden', ap.hidden ? 'Yes' : 'No']].forEach(([k, v]) => { + details.appendChild(h('div', { class: 'recon-focus-detail' }, + h('span', { class: 'recon-focus-detail-label', text: k }), + h('span', { text: v }))); + }); + } + + function toggleFocus(ap) { + if (state.focusAp && state.focusAp.bssid === ap.bssid) { + state.focusAp = null; + focusSidebar.classList.add('hidden'); + } else { + state.focusAp = ap; + renderFocus(); + focusSidebar.classList.remove('hidden'); + } + renderTables(); + } + + // ---- 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, icon, title, fn) => { + const b = h('button', { class: 'icon-btn', id: key + '-' + id, title: title }); + b.innerHTML = PineappleIcons[icon] || ''; + b.addEventListener('click', fn); + return b; + }; + return h('div', { class: 'recon-paginator' }, + mk('first', 'first_page', 'First page', () => { state[key + 'Page'] = 0; renderTables(); }), + mk('prev', 'chevron_left', 'Previous page', () => { state[key + 'Page'] = Math.max(0, state[key + 'Page'] - 1); renderTables(); }), + h('span', { class: 'muted', id: key + '-range', text: '' }), + mk('next', 'chevron_right', 'Next page', () => { state[key + 'Page'] = Math.min(reconPageCount(key) - 1, state[key + 'Page'] + 1); renderTables(); }), + mk('last', 'last_page', '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.clientSearch; + return Math.max(1, Math.ceil(reconFiltered(rows, q, colsArr).length / state[key + 'Per'])); + } + + function perSelect(key) { + const sel = h('select', { class: 'sel recon-per', id: key + '-per' }); + [10, 25, 50].forEach((n) => sel.appendChild(h('option', { value: String(n), text: n + ' / page' }))); + sel.value = String(state[key + 'Per']); + sel.addEventListener('change', () => { + state[key + 'Per'] = parseInt(sel.value, 10) || 10; + localStorage.setItem('pw_recon_per_' + key, String(state[key + 'Per'])); + state[key + 'Page'] = 0; + renderTables(); + }); + return sel; + } + + 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' }), + perSelect(key), + buildPaginator(key)); + box.appendChild(head); + head.querySelector('#' + searchId).addEventListener('input', onInput); + } + + 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.clientSearch = document.getElementById('cli-search').value; state.clientPage = 0; renderTables(); }); + const cliBody = h('div', { class: 'recon-table-body' }); + cliCard.appendChild(cliBody); + + function renderTable(box, key, rows, colsArr, emptyMsg) { + box.innerHTML = ''; + const vis = colsArr.filter((c) => cols[key][c.key]); + const per = state[key + 'Per']; + const page = state[key + 'Page']; + const start = page * per; + const slice = rows.slice(start, start + per); + const rowAttrs = key === 'ap' + ? (r) => ({ + class: state.focusAp && state.focusAp.bssid === r.bssid ? 'recon-row-selected' : '', + style: 'cursor:pointer', + onclick: () => toggleFocus(r) + }) + : undefined; + const tbl = table(vis, slice, rowAttrs); + box.appendChild(tbl); + tbl.querySelectorAll('th').forEach((th, i) => { + const col = vis[i]; + if (!col) return; + th.style.cursor = 'pointer'; + th.title = 'Sort by ' + col.label; + const arrow = h('span', { class: 'recon-sort-arrow' }); + th.appendChild(arrow); + th.addEventListener('click', () => { + const cur = state[key + 'Sort']; + state[key + 'Sort'] = (cur && cur.key === col.key) ? { key: col.key, dir: -cur.dir } : { key: col.key, dir: 1 }; + state[key + 'Page'] = 0; + renderTables(); + }); + const cur = state[key + 'Sort']; + if (cur && cur.key === col.key) { + th.classList.add('recon-sorted'); + arrow.textContent = cur.dir === 1 ? ' ▲' : ' ▼'; + } + }); + 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 + per, rows.length) + ' of ' + rows.length : '0 of 0'; + const p = reconPageCount(key); + const first = document.getElementById(key + '-first'); + const prev = document.getElementById(key + '-prev'); + const next = document.getElementById(key + '-next'); + const last = document.getElementById(key + '-last'); + if (first) first.disabled = page === 0; + if (prev) prev.disabled = page === 0; + if (next) next.disabled = page >= p - 1; + if (last) last.disabled = page >= p - 1; + } + + function sortRows(rows, key, colsArr) { + const s = state[key + 'Sort']; + if (!s) return rows; + const col = colsArr.find((c) => c.key === s.key); + if (!col) return rows; + const copy = rows.slice(); + copy.sort((a, b) => reconCmp(a, b, col, s.dir)); + return copy; + } + + function renderTables() { + const d = state.detail || { aps: [], clients: [], handshakes: [] }; + const apF = reconFiltered(d.aps || [], state.apSearch, RECON_AP_COLS); + const cliF = reconFiltered(d.clients || [], state.clientSearch, RECON_CLIENT_COLS); + renderTable(apBody, 'ap', sortRows(apF, 'ap', RECON_AP_COLS), RECON_AP_COLS, 'No access points in this scan.'); + renderTable(cliBody, 'client', sortRows(cliF, 'client', RECON_CLIENT_COLS), 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'); + } + } + const encCounts = {}; + (d.aps || []).forEach((a) => { + const b = reconEncBucket(a.encryption); + encCounts[b] = (encCounts[b] || 0) + 1; + }); + const enc = document.getElementById('recon-encryption'); + if (enc && typeof MiniChart !== 'undefined' && MiniChart.doughnut) { + if ((d.aps || []).length) { + MiniChart.doughnut(enc, RECON_ENC_BUCKETS.map((k, i) => ({ + label: k, value: encCounts[k] || 0, color: RECON_ENC_COLORS[i] + })), { legend: true, height: 130 }); + enc.classList.remove('hidden'); + encEmpty.classList.add('hidden'); + } else { + enc.classList.add('hidden'); + encEmpty.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/pineap/get_config').then((r) => { + hsAuto.querySelector('input').checked = !!((r.data || {}).loghandshake); + }).catch(() => {}); + PagerAPI.get('/api/recon/status').then((r) => { + const scanning = !!r.data.scanning; + state.scanActive = scanning; + if (!pendingScan) scanToggle.checked = scanning; + restartPoll(); + }).catch(() => {}); + } + + load(); + let pollIv = null; + const restartPoll = () => { + clearInterval(pollIv); + pollIv = setInterval(load, state.scanActive ? 5000 : 10000); + }; + restartPoll(); + return { destroy: () => clearInterval(pollIv) }; +}; + + +const LOGGING_TABS = [ + { label: 'PineAP', hash: '#/logging' }, + { label: 'System', hash: '#/logging/system' } +]; + +function hsType(name) { + const n = (name || '').toLowerCase(); + if (/\.(cap|pcap|pcapng)$/.test(n)) return 'PCAP'; + if (/\.(hccapx|hc22000|22000|hccap)$/.test(n)) return 'Hashcat'; + return 'Unknown'; +} + +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.", + html: PineappleIcons.question_mark })); + } + + function boolGlyph(v) { + return h('td', { class: 'mat-cell hs-cell-center' }, + h('span', { class: v ? 'hs-ok' : 'hs-bad', html: 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(() => { + if (box.querySelector('.recon-table-head')) flash(false, 'Failed to load handshakes'); + else App.toast('Failed to load handshakes', 'error'); + }); + } + + 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: () => {} }; +}; + +views.logging = (root) => { + root.appendChild(h('h1', { class: 'page-title', text: 'Logging' })); + tabBar(root, LOGGING_TABS, '#/logging'); + const lines = []; + const search = h('input', { class: 'log-search', placeholder: 'Search activity log' }); + const output = h('pre', { class: 'logs' }); + const pineBox = h('div', { class: 'section' }, + h('div', { class: 'log-toolbar' }, + h('h2', {}, 'Activity Log'), + h('span', { class: 'toolbar-spacer' }), + search, + btn('Download', () => downloadText('pineap.log', lines.join('\n')), 'ghost'), + btn('Refresh', load, 'ghost')), + output); + root.appendChild(pineBox); + function render() { + const q = search.value.trim().toLowerCase(); + output.textContent = lines.filter((line) => !q || line.toLowerCase().indexOf(q) !== -1).join('\n'); + } + search.addEventListener('input', render); + let pending = false; + function load() { + if (pending) return; + pending = true; + PagerAPI.get('/api/logging/pineap?lines=200').then((r) => { + lines.splice(0, lines.length, ...((r.data || {}).lines || [])); + render(); + }).catch(() => { App.toast('Failed to load PineAP log', 'error'); }) + .finally(() => { pending = false; }); + } + load(); + const iv = setInterval(load, 10000); + return { destroy: () => clearInterval(iv) }; +}; + +views.logging_system = (root) => { + root.appendChild(h('h1', { class: 'page-title', text: 'Logging' })); + tabBar(root, LOGGING_TABS, '#/logging/system'); + const lines = []; + const search = h('input', { class: 'log-search', placeholder: 'Search system log' }); + const output = h('pre', { class: 'logs' }); + const box = h('div', { class: 'section' }, + h('div', { class: 'log-toolbar' }, + h('h2', {}, 'System Log'), + h('span', { class: 'toolbar-spacer' }), + search, + btn('Download', () => downloadText('system.log', lines.join('\n')), 'ghost'), + btn('Refresh', load, 'ghost')), + output); + root.appendChild(box); + function render() { + const q = search.value.trim().toLowerCase(); + output.textContent = lines.filter((line) => !q || line.toLowerCase().indexOf(q) !== -1).join('\n'); + } + search.addEventListener('input', render); + let pending = false; + function load() { + if (pending) return; + pending = true; + PagerAPI.get('/api/logging/system?lines=400').then((r) => { + lines.splice(0, lines.length, ...((r.data || {}).lines || [])); + render(); + }).catch(() => { App.toast('Failed to load system log', 'error'); }) + .finally(() => { pending = false; }); + } + load(); + const iv = setInterval(load, 10000); + return { destroy: () => clearInterval(iv) }; +}; + +const PAYLOAD_TABS = [ + { label: 'Installed', hash: '#/modules' }, + { label: 'Payloads', hash: '#/modules/online' }, + { label: 'Running', hash: '#/modules/running' }, + { label: 'Develop', hash: '#/modules/develop' } +]; + +function payloadShell(root, activeHash) { + root.appendChild(h('h1', { class: 'page-title', text: 'Payloads' })); + tabBar(root, PAYLOAD_TABS, activeHash); + const box = h('div', { class: 'payload-content' }); + root.appendChild(box); + return box; +} + +function payloadToolbar(title, description, action) { + const head = h('div', { class: 'payload-heading' }, + h('div', {}, h('h2', { text: title }), h('p', { class: 'muted', text: description }))); + if (action) head.appendChild(action); + return head; +} + +function payloadFilters(onchange) { + const search = h('input', { class: 'payload-search', placeholder: 'Search payloads' }); + const category = h('select', { class: 'payload-category' }, h('option', { value: '', text: 'All categories' })); + search.addEventListener('input', onchange); + category.addEventListener('change', onchange); + return { search, category, node: h('div', { class: 'payload-filters' }, search, category) }; +} + +function fillPayloadCategories(select, rows, key) { + const values = Array.from(new Set((rows || []).map((row) => row[key] || '').filter(Boolean))).sort(); + values.forEach((value) => select.appendChild(h('option', { value, text: value.replace(/_/g, ' ') }))); +} + +function payloadVersionTag(version) { + const value = String(version || '').trim(); + return value ? (/^v/i.test(value) ? value : 'v' + value) : ''; +} + +function payloadCard(item, actions, tags) { + const meta = h('div', { class: 'payload-meta' }); + (tags || []).filter(Boolean).forEach((tag) => meta.appendChild(h('span', { class: 'payload-tag', text: tag }))); + const controls = h('div', { class: 'payload-actions' }); + (actions || []).forEach((action) => controls.appendChild(action)); + return h('article', { class: 'payload-card' }, + h('div', { class: 'payload-card-main' }, + h('h3', { text: item.title || item.key || 'Untitled payload' }), + h('p', { class: 'payload-author muted', text: item.author ? 'by ' + item.author : 'Local payload' }), + h('p', { class: 'payload-description', text: item.description || 'No description provided.' }), + meta), controls); +} + +function setPayloadBusy(button, busy, label) { + button.disabled = busy; + if (label) button.textContent = busy ? label : button.dataset.label; +} + +views.modules = (root) => { + const box = payloadShell(root, '#/modules'); + let alive = true; + let rows = []; + const list = h('div', { class: 'payload-list' }); + const status = h('p', { class: 'payload-result-count muted' }); + const filters = payloadFilters(render); + const refresh = btn('Refresh', load, 'ghost'); + box.appendChild(h('div', { class: 'section' }, + payloadToolbar('Installed Payloads', 'Payloads installed on this Pager. Launch them here or manage their Pager Portal version.', refresh), + filters.node, status, list)); + + function render() { + if (!alive) return; + const q = filters.search.value.trim().toLowerCase(); + const category = filters.category.value; + const visible = rows.filter((item) => (!category || item.category === category) && + (!q || [item.title, item.author, item.description, item.key].join(' ').toLowerCase().indexOf(q) !== -1)); + status.textContent = visible.length + ' of ' + rows.length + ' installed'; + list.innerHTML = ''; + if (!visible.length) { + list.appendChild(h('div', { class: 'payload-empty', text: rows.length ? 'No payloads match these filters.' : 'No payloads are installed. Get payloads from the Payloads tab.' })); + return; + } + visible.forEach((item) => { + const launch = btn('Run', () => { + setPayloadBusy(launch, true, 'Starting...'); + PagerAPI.post('/api/payloads/run', { key: item.key }).then(() => { + App.toast(item.title + ' started'); + location.hash = '#/modules/running'; + }).catch(apiError('Unable to start payload')).finally(() => setPayloadBusy(launch, false)); + }); + launch.dataset.label = 'Run'; + if (item.disabled || item.key === 'user~remote_access~pager-webui') { + launch.disabled = true; + launch.title = item.disabled ? 'This payload is disabled' : 'The WebUI cannot launch itself'; + } + const update = item.update ? btn('Update', () => installPayload(item, update), 'ghost') : null; + if (update) update.dataset.label = 'Update'; + const remove = btn('Remove', () => { + if (!confirm('Remove "' + item.title + '" from this Pager?')) return; + setPayloadBusy(remove, true, 'Removing...'); + PagerAPI.post('/api/payloads/remove', { key: item.key }).then(() => { + App.toast(item.title + ' removed'); load(); + }).catch(apiError('Unable to remove payload')).finally(() => setPayloadBusy(remove, false)); + }, 'danger'); + remove.dataset.label = 'Remove'; + if (item.key === 'user~remote_access~pager-webui') { + remove.disabled = true; + remove.title = 'The active WebUI cannot remove itself'; + } + list.appendChild(payloadCard(item, [launch, update, remove].filter(Boolean), + [item.category && item.category.replace(/_/g, ' '), payloadVersionTag(item.version), + item.update && 'Update available', item.missingmanifest && 'Local manifest'])); + }); + } + + function installPayload(item, button) { + setPayloadBusy(button, true, 'Updating...'); + PagerAPI.post('/api/payloads/install', { key: item.key }).then(() => { + App.toast(item.title + ' updated'); load(); + }).catch(apiError('Unable to update payload')).finally(() => setPayloadBusy(button, false)); + } + + function load() { + refresh.disabled = true; + PagerAPI.get('/api/payloads/installed').then((r) => { + if (!alive) return; + rows = (r.data && r.data.payloads) || []; + filters.category.innerHTML = ''; + fillPayloadCategories(filters.category, rows, 'category'); + render(); + }).catch(apiError('Unable to load installed payloads')).finally(() => { refresh.disabled = false; }); + } + load(); + return { destroy: () => { alive = false; } }; +}; + +views.modules_online = (root) => { + const box = payloadShell(root, '#/modules/online'); + let alive = true; + let rows = [], installed = new Set(); + const list = h('div', { class: 'payload-list' }); + const status = h('p', { class: 'payload-result-count muted' }); + const filters = payloadFilters(render); + const refresh = btn('Get Available Payloads', () => { + refresh.disabled = true; + PagerAPI.post('/api/payloads/refresh').then(() => { + App.toast('Pager Portal listings refreshed'); return load(); + }).catch(apiError('Unable to refresh Pager Portal')).finally(() => { refresh.disabled = false; }); + }); + box.appendChild(h('div', { class: 'section' }, + payloadToolbar('Available Payloads', 'Browse community payloads from the Pager Portal and install them directly on this Pager.', refresh), + filters.node, status, list)); + + function render() { + if (!alive) return; + const q = filters.search.value.trim().toLowerCase(); + const category = filters.category.value; + const matches = rows.filter((item) => (!category || item.parent === category) && + (!q || [item.title, item.author, item.description, item.key].join(' ').toLowerCase().indexOf(q) !== -1)); + const visible = matches.slice(0, 120); + status.textContent = matches.length + ' result' + (matches.length === 1 ? '' : 's') + + (matches.length > visible.length ? ' - showing first ' + visible.length : ''); + list.innerHTML = ''; + if (!visible.length) { + list.appendChild(h('div', { class: 'payload-empty', text: rows.length ? 'No payloads match these filters.' : 'No Portal listing is cached. Select Get Available Payloads.' })); + return; + } + visible.forEach((item) => { + const isInstalled = installed.has(item.key); + const install = btn(isInstalled ? 'Installed' : 'Install', () => { + if (installed.has(item.key)) return; + install.dataset.label = 'Install'; + setPayloadBusy(install, true, 'Installing...'); + PagerAPI.post('/api/payloads/install', { key: item.key }).then(() => { + installed.add(item.key); App.toast(item.title + ' installed'); render(); + }).catch(apiError('Unable to install payload')).finally(() => setPayloadBusy(install, false)); + }, isInstalled ? 'ghost' : ''); + install.dataset.label = isInstalled ? 'Installed' : 'Install'; + install.disabled = isInstalled; + list.appendChild(payloadCard(item, [install], + [item.parent && item.parent.replace('/', ' / ').replace(/_/g, ' '), payloadVersionTag(item.version)])); + }); + } + + function load() { + return Promise.all([PagerAPI.get('/api/payloads/index'), PagerAPI.get('/api/payloads/installed')]).then((results) => { + if (!alive) return; + rows = (results[0].data && results[0].data.payloads) || []; + installed = new Set(((results[1].data && results[1].data.payloads) || []).map((item) => item.key)); + filters.category.innerHTML = ''; + fillPayloadCategories(filters.category, rows, 'parent'); + render(); + }).catch(apiError('Unable to load Pager Portal payloads')); + } + load(); + return { destroy: () => { alive = false; } }; +}; + +views.modules_running = (root) => { + const box = payloadShell(root, '#/modules/running'); + let alive = true, pending = false; + const list = h('div', { class: 'payload-runs' }); + const refresh = btn('Refresh', load, 'ghost'); + box.appendChild(h('div', { class: 'section' }, + payloadToolbar('Running Payloads', 'Tracks payloads launched from this WebUI session. Output is read from each payload process.', refresh), list)); + + function render(rows) { + if (!alive) return; + list.innerHTML = ''; + if (!rows.length) { + list.appendChild(h('div', { class: 'payload-empty', text: 'No payloads have been launched from this WebUI session.' })); + return; + } + rows.forEach((run) => { + const state = h('span', { class: 'payload-run-state ' + (run.running ? 'running' : 'finished'), + text: run.running ? 'Running' : (run.returncode === 0 ? 'Completed' : 'Exited ' + run.returncode) }); + const actions = h('div', { class: 'payload-actions' }); + if (run.running) { + const stop = btn('Stop', () => { + stop.disabled = true; + PagerAPI.post('/api/payloads/stop', { id: run.id }).then(() => { + App.toast(run.title + ' stop requested'); setTimeout(load, 500); + }).catch(apiError('Unable to stop payload')).finally(() => { stop.disabled = false; }); + }, 'danger'); + actions.appendChild(stop); + } + const output = h('pre', { class: 'payload-output', text: run.output || 'No output yet.' }); + list.appendChild(h('article', { class: 'payload-run' }, + h('div', { class: 'payload-run-head' }, + h('div', {}, h('h3', { text: run.title }), + h('p', { class: 'muted', text: 'PID ' + run.pid + ' - started ' + fmtTime(run.started) })), + state, actions), output)); + }); + } + + function load() { + if (pending) return; + pending = true; refresh.disabled = true; + PagerAPI.get('/api/payloads/runs').then((r) => render((r.data && r.data.runs) || [])) + .catch(apiError('Unable to load payload runs')).finally(() => { pending = false; refresh.disabled = false; }); + } + load(); + const iv = setInterval(load, 3000); + return { destroy: () => { alive = false; clearInterval(iv); } }; +}; + +views.modules_develop = (root) => { + const box = payloadShell(root, '#/modules/develop'); + box.appendChild(h('div', { class: 'section payload-develop' }, + h('h2', { text: 'Developing Pager Payloads' }), + h('p', { text: 'Pager payloads are native shell or script-based workflows, not Pineapple modules. A payload lives in a context and category folder and declares its launchpoint and metadata in _hak5_manifest.json.' }), + h('div', { class: 'payload-dev-grid' }, + h('div', { class: 'payload-dev-block' }, + h('h3', { text: 'Payload structure' }), + h('pre', { class: 'payload-code', text: '/root/payloads/user/general/my_payload/\n payload.sh\n _hak5_manifest.json\n assets/' })), + h('div', { class: 'payload-dev-block' }, + h('h3', { text: 'Native capabilities' }), + h('p', { text: 'Scripts launched here keep access to Pager commands such as ALERT, LOG, CONFIRMATION_DIALOG, TEXT_PICKER and other hak5cmd-backed helpers.' }))), + h('p', { class: 'muted', text: 'Use the terminal to create or inspect payload files. Refresh Installed when the manifest changes so the firmware inventory is re-read.' }), + btn('Open Terminal', () => { + const terminal = document.getElementById('terminal-btn'); + if (terminal) terminal.click(); + }))); + return { destroy: () => {} }; +}; + +const SETTINGS_TABS = [ + { label: 'General', hash: '#/settings' }, + { label: 'Networking', hash: '#/settings/networking' }, + { label: 'WiFi', hash: '#/settings/wifi' }, + { label: 'LED', hash: '#/settings/led' }, + { label: 'Advanced', hash: '#/settings/advanced' }, + { label: 'Help', hash: '#/settings/help' } +]; + +function settingsShell(root, activeHash) { + root.appendChild(h('h1', { class: 'page-title', text: 'Settings' })); + tabBar(root, SETTINGS_TABS, activeHash); + const box = h('div', { class: 'settings-content' }); + root.appendChild(box); + return box; +} + +function settingsCard(box, title, subtitle) { + const card = h('div', { class: 'section settings-card' }, h('h2', {}, title)); + if (subtitle) card.appendChild(h('p', { class: 'muted settings-subtitle', text: subtitle })); + box.appendChild(card); + return card; +} + +function apiError(message) { + return (err) => App.toast((err && err.message && err.message !== 'request failed') ? err.message : message, 'error'); +} + +views.settings = (root) => { + const box = settingsShell(root, '#/settings'); + const user = settingsCard(box, 'User Management & Timezone'); + const currentPw = h('input', { type: 'password', autocomplete: 'current-password' }); + const newPw = h('input', { type: 'password', autocomplete: 'new-password' }); + const repeatPw = h('input', { type: 'password', autocomplete: 'new-password' }); + user.appendChild(h('div', { class: 'settings-form-grid' }, + h('label', {}, 'Current Password', currentPw), + h('label', {}, 'New Password', newPw), + h('label', {}, 'Repeat New Password', repeatPw))); + user.appendChild(btn('Update Password', () => { + if (!currentPw.value) { App.toast('Current password is required', 'error'); return; } + if (newPw.value.length < 4) { App.toast('New password is too short', 'error'); return; } + if (newPw.value !== repeatPw.value) { App.toast('New passwords do not match', 'error'); return; } + PagerAPI.post('/api/settings/password', { + current_password: currentPw.value, new_password: newPw.value, + repeat_password: repeatPw.value + }).then(() => { + currentPw.value = ''; newPw.value = ''; repeatPw.value = ''; + App.toast('Root password updated'); + }).catch(apiError('Failed to update password')); + })); + + const timezones = [ + ['UTC', '(GMT+0) Coordinated Universal Time', 'Etc/UTC'], + ['EST5EDT,M3.2.0,M11.1.0', '(GMT-5) Eastern Time (US & Canada)', 'America/New_York'], + ['CST6CDT,M3.2.0,M11.1.0', '(GMT-6) Central Time (US & Canada)', 'America/Chicago'], + ['MST7MDT,M3.2.0,M11.1.0', '(GMT-7) Mountain Time (US & Canada)', 'America/Denver'], + ['MST7', '(GMT-7) Arizona', 'America/Phoenix'], + ['PST8PDT,M3.2.0,M11.1.0', '(GMT-8) Pacific Time (US & Canada)', 'America/Los_Angeles'], + ['AKST9AKDT,M3.2.0,M11.1.0', '(GMT-9) Alaska', 'America/Anchorage'], + ['HST10', '(GMT-10) Hawaii', 'Pacific/Honolulu'], + ['GMT0BST,M3.5.0/1,M10.5.0', '(GMT+0) London', 'Europe/London'], + ['CET-1CEST,M3.5.0,M10.5.0/3', '(GMT+1) Central Europe', 'Europe/Berlin'], + ['JST-9', '(GMT+9) Japan', 'Asia/Tokyo'], + ['AEST-10AEDT,M10.1.0,M4.1.0/3', '(GMT+10) Sydney', 'Australia/Sydney'] + ]; + const tzSel = h('select', { 'aria-label': 'Timezone' }); + timezones.forEach(([value, label, zone]) => tzSel.appendChild(h('option', { value, text: label, 'data-zone': zone }))); + const tzActions = h('div', { class: 'settings-actions' }, + btn('Update Timezone', () => { + const opt = tzSel.options[tzSel.selectedIndex]; + PagerAPI.post('/api/settings/timezone', { timezone: tzSel.value, zonename: opt.getAttribute('data-zone') || '' }) + .then(() => App.toast('Timezone updated')).catch(apiError('Failed to update timezone')); + }), + btn('Sync Browser Time', () => { + const d = new Date(); + const stamp = d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0') + '-' + + String(d.getUTCDate()).padStart(2, '0') + ' ' + String(d.getUTCHours()).padStart(2, '0') + ':' + + String(d.getUTCMinutes()).padStart(2, '0') + ':' + String(d.getUTCSeconds()).padStart(2, '0'); + PagerAPI.post('/api/settings/synctime', { timestamp: stamp }) + .then(() => App.toast('Pager time synchronized')).catch(apiError('Failed to synchronize time')); + }, 'ghost')); + user.appendChild(h('label', {}, 'Timezone', tzSel)); + user.appendChild(tzActions); + PagerAPI.get('/api/settings/timezone').then((r) => { + const value = (r.data || {}).timezone || 'UTC'; + if (!Array.from(tzSel.options).some((o) => o.value === value)) { + tzSel.insertBefore(h('option', { value, text: value + ' (current)' }), tzSel.firstChild); + } + tzSel.value = value; + }).catch(apiError('Unable to load timezone')); + + const ntp = settingsCard(box, 'Network Time', 'Configure the Pager\'s NTP client.'); + const ntpEnabled = h('input', { type: 'checkbox' }); + const ntpServers = h('input', {}); + ntp.appendChild(h('label', { class: 'switch' }, ntpEnabled, h('span', { class: 'track' }), 'Enabled')); + ntp.appendChild(h('label', {}, 'NTP servers (comma separated)', ntpServers)); + ntp.appendChild(btn('Save', () => PagerAPI.post('/api/settings/ntp', { + enabled: ntpEnabled.checked, + servers: ntpServers.value.split(',').map((s) => s.trim()).filter(Boolean) + }).then(() => App.toast('Network time settings saved')).catch(apiError('Failed to save network time')))); + PagerAPI.get('/api/settings/ntp').then((r) => { + ntpEnabled.checked = !!r.data.enabled; + ntpServers.value = (r.data.servers || []).join(', '); + }).catch(apiError('Unable to load network time settings')); + + const details = settingsCard(box, 'Device & Web Interface'); + const detailBody = h('div', { class: 'settings-kv-grid' }); + details.appendChild(detailBody); + Promise.all([PagerAPI.get('/api/status'), PagerAPI.get('/api/settings/service')]).then(([status, service]) => { + const s = status.data || {}, svc = service.data || {}; + [['Model', 'WiFi Pineapple Pager'], ['Firmware', s.firmware || 'Unavailable'], + ['Uptime', fmtDur(s.uptime)], ['Disk', s.disk && s.disk.size != null ? fmtBytes(s.disk.used) + ' / ' + fmtBytes(s.disk.size) : 'Unavailable'], + ['WebUI Service', (svc.running ? 'Running' : 'Stopped') + (svc.background ? ' (background)' : '')]] + .forEach(([k, v]) => detailBody.appendChild(h('div', { class: 'settings-kv' }, h('span', { text: k }), h('code', { text: v })))); + }).catch(apiError('Unable to load device details')); + + settingsCard(box, 'Button Script', 'The Mark VII button script has no safe Pager equivalent. Pager buttons remain managed by the native input and payload-launcher system.'); + + const resources = settingsCard(box, 'Resources'); + const resourceBody = h('div', { class: 'settings-table-wrap', text: 'Loading…' }); + resources.appendChild(resourceBody); + PagerAPI.get('/api/settings/resources').then((r) => { + resourceBody.innerHTML = ''; + resourceBody.appendChild(table([ + { label: 'Filesystem', key: 'filesystem' }, { label: 'Format', key: 'format' }, + { label: 'Size', render: (x) => fmtBytes(x.size) }, { label: 'Used', render: (x) => fmtBytes(x.used) }, + { label: 'Available', render: (x) => fmtBytes(x.available) }, { label: 'Used %', key: 'used_percent' }, + { label: 'Mount Point', key: 'mount' } + ], (r.data || {}).filesystems || [])); + }).catch(() => { resourceBody.textContent = 'Unable to load filesystems.'; }); + + const usb = settingsCard(box, 'USB Devices'); + const usbBody = h('div', { class: 'settings-table-wrap', text: 'Loading…' }); + usb.appendChild(usbBody); + PagerAPI.get('/api/settings/usb').then((r) => { + usbBody.innerHTML = ''; + const rows = (r.data || {}).devices || []; + usbBody.appendChild(table([{ label: 'Bus', key: 'bus' }, { label: 'Device', key: 'device' }, + { label: 'VID:PID', key: 'id' }, { label: 'Name', key: 'name' }], rows)); + if (!rows.length) usbBody.appendChild(h('div', { class: 'empty', text: 'No USB devices reported.' })); + }).catch(() => { usbBody.textContent = 'Unable to load USB devices.'; }); + return { destroy: () => {} }; +}; + +views.settings_networking = (root) => { + const box = settingsShell(root, '#/settings/networking'); + settingsCard(box, 'Wireless Client Mode', 'The Pager firmware owns client-mode association. Status is shown here; connection changes remain in the native Pager interface to protect PineAP radio state.'); + const recon = settingsCard(box, 'Recon Wireless Interfaces'); + const reconBody = h('div', { class: 'settings-chip-row', text: 'Loading…' }); + recon.appendChild(reconBody); + const interfaces = settingsCard(box, 'Interfaces'); + const interfaceBody = h('div', { class: 'settings-table-wrap', text: 'Loading…' }); + interfaces.appendChild(interfaceBody); + const routes = settingsCard(box, 'Routing Table'); + const routeBody = h('div', { class: 'settings-table-wrap', text: 'Loading…' }); + routes.appendChild(routeBody); + PagerAPI.get('/api/settings/network').then((r) => { + const d = r.data || {}; + reconBody.innerHTML = ''; + (d.recon_interfaces || []).forEach((name) => reconBody.appendChild(h('code', { class: 'settings-chip', text: name }))); + if (!(d.recon_interfaces || []).length) reconBody.textContent = 'No monitor interfaces detected.'; + interfaceBody.innerHTML = ''; + interfaceBody.appendChild(table([{ label: 'Name', key: 'name' }, + { label: 'IP Address', render: (x) => (x.addresses || []).join(', ') || '—' }, + { label: 'MAC Address', render: (x) => x.mac || '—' }, + { label: 'Flags', render: (x) => (x.flags || []).join(', ') }], d.interfaces || [])); + routeBody.innerHTML = ''; + routeBody.appendChild(table([{ label: 'Destination', key: 'destination' }, { label: 'Gateway', key: 'gateway' }, + { label: 'Genmask', key: 'genmask' }, { label: 'Interface', key: 'interface' }, + { label: 'Flags', key: 'flags' }, { label: 'Metric', key: 'metric' }, + { label: 'Ref', key: 'ref' }, { label: 'Use', key: 'use' }], d.routes || [])); + }).catch(apiError('Unable to load networking information')); + return { destroy: () => {} }; +}; + +views.settings_wifi = (root) => { + const box = settingsShell(root, '#/settings/wifi'); + const card = settingsCard(box, 'Management Access Point', 'Configure the Pager management WiFi interface. Saving reloads wireless services; USB management access remains available.'); + const ssid = h('input', {}), bssid = h('input', { placeholder: 'Optional' }); + const password = h('input', { type: 'password', placeholder: 'Leave blank to keep the existing password', autocomplete: 'new-password' }); + const confirm = h('input', { type: 'password', placeholder: 'Repeat only when changing it', autocomplete: 'new-password' }); + const hidden = h('input', { type: 'checkbox' }), enabled = h('input', { type: 'checkbox' }); + card.appendChild(h('div', { class: 'settings-form-grid' }, h('label', {}, 'Management SSID', ssid), h('label', {}, 'BSSID', bssid))); + card.appendChild(h('label', { class: 'switch' }, hidden, h('span', { class: 'track' }), 'Hidden')); + card.appendChild(h('label', { class: 'switch' }, enabled, h('span', { class: 'track' }), 'Enabled')); + card.appendChild(h('div', { class: 'settings-form-grid' }, h('label', {}, 'Password', password), h('label', {}, 'Confirm Password', confirm))); + const save = btn('Save', () => { + if (password.value !== confirm.value) { App.toast('Management passwords do not match', 'error'); return; } + save.disabled = true; + PagerAPI.post('/api/settings/wifi/management', { ssid: ssid.value.trim(), bssid: bssid.value.trim(), + password: password.value, hidden: hidden.checked, enabled: enabled.checked }) + .then(() => { password.value = ''; confirm.value = ''; App.toast('Management WiFi saved'); }) + .catch(apiError('Failed to save management WiFi')).finally(() => { save.disabled = false; }); + }); + card.appendChild(save); + PagerAPI.get('/api/settings/wifi/management').then((r) => { + const d = r.data || {}; ssid.value = d.ssid || ''; bssid.value = d.bssid || ''; + hidden.checked = !!d.hidden; enabled.checked = !!d.enabled; + }).catch(apiError('Unable to load management WiFi')); + return { destroy: () => {} }; +}; + +views.settings_led = (root) => { + const box = settingsShell(root, '#/settings/led'); + const card = settingsCard(box, 'LED Configuration', 'Pager-specific equivalents of the Mark VII LED controls. Settings are stored in the native Pager configuration.'); + const color = h('select', {}); + ['red', 'green', 'blue', 'yellow', 'cyan', 'magenta', 'white'].forEach((v) => color.appendChild(h('option', { value: v, text: v[0].toUpperCase() + v.slice(1) }))); + const vibrate = h('input', { type: 'checkbox' }), clock24 = h('input', { type: 'checkbox' }); + const lcd = h('input', { type: 'number', min: '1', max: '11' }); + const dim = h('input', { type: 'number', min: '0', max: '11' }); + const dimTimeout = h('input', { type: 'number', min: '0', max: '3600' }); + const lcdTimeout = h('input', { type: 'number', min: '0', max: '86400' }); + card.appendChild(h('label', {}, 'Status LED color', color)); + card.appendChild(h('label', { class: 'switch' }, vibrate, h('span', { class: 'track' }), 'Vibrate with alerts')); + card.appendChild(h('label', { class: 'switch' }, clock24, h('span', { class: 'track' }), '24-hour clock')); + card.appendChild(h('div', { class: 'settings-form-grid' }, + h('label', {}, 'LCD brightness (1–11)', lcd), h('label', {}, 'Dim brightness (0–11)', dim), + h('label', {}, 'Dim timeout (seconds)', dimTimeout), h('label', {}, 'LCD timeout (seconds)', lcdTimeout))); + function values() { return { led_color: color.value, vibrate: vibrate.checked, clock24hr: clock24.checked, + lcd_brightness: Number(lcd.value), dim_brightness: Number(dim.value), + dim_timeout: Number(dimTimeout.value), lcd_timeout: Number(lcdTimeout.value) }; } + card.appendChild(btn('Save', () => PagerAPI.post('/api/settings/hardware', values()) + .then(() => App.toast('Pager hardware preferences saved')).catch(apiError('Failed to save hardware preferences')))); + PagerAPI.get('/api/settings/hardware').then((r) => { + const d = r.data || {}; color.value = d.led_color || 'magenta'; vibrate.checked = !!d.vibrate; + clock24.checked = !!d.clock24hr; lcd.value = d.lcd_brightness; dim.value = d.dim_brightness; + dimTimeout.value = d.dim_timeout; lcdTimeout.value = d.lcd_timeout; + }).catch(apiError('Unable to load hardware preferences')); + return { destroy: () => {} }; +}; + +views.settings_advanced = (root) => { + const box = settingsShell(root, '#/settings/advanced'); + const updates = settingsCard(box, 'Alternative Updates', 'Select the Pager software update channel. Update checking and firmware installation remain in the native Pager workflow.'); + const channel = h('select', {}, h('option', { value: 'stable', text: 'Stable (Recommended)' }), + h('option', { value: 'beta', text: 'Beta' }), h('option', { value: 'nightly', text: 'Nightly' })); + updates.appendChild(h('label', {}, 'Selected Software Update Channel', channel)); + updates.appendChild(btn('Set Update Channel', () => PagerAPI.post('/api/settings/advanced', { update_channel: channel.value }) + .then(() => App.toast('Update channel saved')).catch(apiError('Failed to save update channel')))); + + const host = settingsCard(box, 'Hostname', 'The hostname appears in SSH and DHCP client requests.'); + const hostname = h('input', {}); + host.appendChild(h('label', {}, 'Hostname', hostname)); + host.appendChild(btn('Save', () => PagerAPI.post('/api/settings/hostname', { hostname: hostname.value.trim() }) + .then(() => App.toast('Hostname saved')).catch(apiError('Failed to save hostname')))); + + const webui = settingsCard(box, 'Web Interface'); + const theme = h('select', {}, h('option', { value: 'light', text: 'Light' }), h('option', { value: 'dark', text: 'Dark' })); + const poll = h('input', { type: 'number', min: '2', max: '120' }); + const hotkeys = h('input', { type: 'checkbox' }); + theme.value = Theme.current(); poll.value = localStorage.getItem('pw-poll') || '5'; + hotkeys.checked = localStorage.getItem('pw_hotkeys') !== 'false'; + webui.appendChild(h('div', { class: 'settings-form-grid' }, h('label', {}, 'UI Theme', theme), h('label', {}, 'Poll interval (seconds)', poll))); + webui.appendChild(h('label', { class: 'switch' }, hotkeys, h('span', { class: 'track' }), 'Enable Hot Keys')); + webui.appendChild(btn('Save Web Interface', () => { + localStorage.setItem(Theme.KEY, theme.value); localStorage.setItem('pw-poll', poll.value); + localStorage.setItem('pw_hotkeys', hotkeys.checked ? 'true' : 'false'); Theme.apply(); + App.toast('Web interface preferences saved'); + })); + + PagerAPI.get('/api/settings/advanced').then((r) => { channel.value = r.data.update_channel || 'stable'; hostname.value = r.data.hostname || ''; }) + .catch(apiError('Unable to load advanced settings')); + return { destroy: () => {} }; +}; + +views.settings_help = (root) => { + const box = settingsShell(root, '#/settings/help'); + const help = settingsCard(box, 'Help & Information'); + help.appendChild(h('p', {}, 'Documentation: ', h('a', { href: 'https://docs.hak5.org/wifi-pineapple/', target: '_blank', rel: 'noopener', text: 'docs.hak5.org' }))); + help.appendChild(h('p', {}, 'Community support: ', h('a', { href: 'https://community.hak5.org/', target: '_blank', rel: 'noopener', text: 'community.hak5.org' }))); + help.appendChild(h('p', {}, 'Downloads: ', h('a', { href: 'https://downloads.hak5.org/', target: '_blank', rel: 'noopener', text: 'downloads.hak5.org' }))); + const diagnostics = settingsCard(box, 'Diagnostics', 'Generate a local support report containing device, network, storage, radio, USB, and recent log information.'); + const output = h('pre', { class: 'logs settings-diagnostics', text: 'No report generated.' }); + let report = ''; + const generate = btn('Generate Diagnostics', () => { + generate.disabled = true; output.textContent = 'Generating diagnostics…'; + PagerAPI.get('/api/settings/diagnostics').then((r) => { report = r.data.report || ''; output.textContent = report; }) + .catch(() => { output.textContent = 'Diagnostics failed.'; App.toast('Failed to generate diagnostics', 'error'); }) + .finally(() => { generate.disabled = false; }); + }); + diagnostics.appendChild(h('div', { class: 'settings-actions' }, generate, + btn('Download Report', () => { if (report) downloadText('pager-diagnostics.txt', report); }, 'ghost'))); + diagnostics.appendChild(output); + const license = settingsCard(box, 'Licenses'); + license.appendChild(h('p', { class: 'muted', text: 'This community WebUI runs alongside the licensed WiFi Pineapple Pager firmware. Third-party component notices remain available in their distributed source files.' })); + return { destroy: () => {} }; +}; diff --git a/payload/user/remote_access/pager-webui/www/js/xterm-addon-fit.min.js b/payload/user/remote_access/pager-webui/www/js/xterm-addon-fit.min.js new file mode 100644 index 0000000..45e1611 --- /dev/null +++ b/payload/user/remote_access/pager-webui/www/js/xterm-addon-fit.min.js @@ -0,0 +1 @@ +!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(self,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core,t=e._renderService.dimensions;if(0===t.css.cell.width||0===t.css.cell.height)return;const r=0===this._terminal.options.scrollback?0:e.viewport.scrollBarWidth,i=window.getComputedStyle(this._terminal.element.parentElement),o=parseInt(i.getPropertyValue("height")),s=Math.max(0,parseInt(i.getPropertyValue("width"))),n=window.getComputedStyle(this._terminal.element),l=o-(parseInt(n.getPropertyValue("padding-top"))+parseInt(n.getPropertyValue("padding-bottom"))),a=s-(parseInt(n.getPropertyValue("padding-right"))+parseInt(n.getPropertyValue("padding-left")))-r;return{cols:Math.max(2,Math.floor(a/t.css.cell.width)),rows:Math.max(1,Math.floor(l/t.css.cell.height))}}}})(),e})())); \ No newline at end of file diff --git a/payload/user/remote_access/pager-webui/www/js/xterm.css b/payload/user/remote_access/pager-webui/www/js/xterm.css new file mode 100644 index 0000000..74acc26 --- /dev/null +++ b/payload/user/remote_access/pager-webui/www/js/xterm.css @@ -0,0 +1,209 @@ +/** + * Copyright (c) 2014 The xterm.js authors. All rights reserved. + * Copyright (c) 2012-2013, Christopher Jeffrey (MIT License) + * https://github.com/chjj/term.js + * @license MIT + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * Originally forked from (with the author's permission): + * Fabrice Bellard's javascript vt100 for jslinux: + * http://bellard.org/jslinux/ + * Copyright (c) 2011 Fabrice Bellard + * The original design remains. The terminal itself + * has been extended to include xterm CSI codes, among + * other features. + */ + +/** + * Default styles for xterm.js + */ + +.xterm { + cursor: text; + position: relative; + user-select: none; + -ms-user-select: none; + -webkit-user-select: none; +} + +.xterm.focus, +.xterm:focus { + outline: none; +} + +.xterm .xterm-helpers { + position: absolute; + top: 0; + /** + * The z-index of the helpers must be higher than the canvases in order for + * IMEs to appear on top. + */ + z-index: 5; +} + +.xterm .xterm-helper-textarea { + padding: 0; + border: 0; + margin: 0; + /* Move textarea out of the screen to the far left, so that the cursor is not visible */ + position: absolute; + opacity: 0; + left: -9999em; + top: 0; + width: 0; + height: 0; + z-index: -5; + /** Prevent wrapping so the IME appears against the textarea at the correct position */ + white-space: nowrap; + overflow: hidden; + resize: none; +} + +.xterm .composition-view { + /* TODO: Composition position got messed up somewhere */ + background: #000; + color: #FFF; + display: none; + position: absolute; + white-space: nowrap; + z-index: 1; +} + +.xterm .composition-view.active { + display: block; +} + +.xterm .xterm-viewport { + /* On OS X this is required in order for the scroll bar to appear fully opaque */ + background-color: #000; + overflow-y: scroll; + cursor: default; + position: absolute; + right: 0; + left: 0; + top: 0; + bottom: 0; +} + +.xterm .xterm-screen { + position: relative; +} + +.xterm .xterm-screen canvas { + position: absolute; + left: 0; + top: 0; +} + +.xterm .xterm-scroll-area { + visibility: hidden; +} + +.xterm-char-measure-element { + display: inline-block; + visibility: hidden; + position: absolute; + top: 0; + left: -9999em; + line-height: normal; +} + +.xterm.enable-mouse-events { + /* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */ + cursor: default; +} + +.xterm.xterm-cursor-pointer, +.xterm .xterm-cursor-pointer { + cursor: pointer; +} + +.xterm.column-select.focus { + /* Column selection mode */ + cursor: crosshair; +} + +.xterm .xterm-accessibility, +.xterm .xterm-message { + position: absolute; + left: 0; + top: 0; + bottom: 0; + right: 0; + z-index: 10; + color: transparent; + pointer-events: none; +} + +.xterm .live-region { + position: absolute; + left: -9999px; + width: 1px; + height: 1px; + overflow: hidden; +} + +.xterm-dim { + /* Dim should not apply to background, so the opacity of the foreground color is applied + * explicitly in the generated class and reset to 1 here */ + opacity: 1 !important; +} + +.xterm-underline-1 { text-decoration: underline; } +.xterm-underline-2 { text-decoration: double underline; } +.xterm-underline-3 { text-decoration: wavy underline; } +.xterm-underline-4 { text-decoration: dotted underline; } +.xterm-underline-5 { text-decoration: dashed underline; } + +.xterm-overline { + text-decoration: overline; +} + +.xterm-overline.xterm-underline-1 { text-decoration: overline underline; } +.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; } +.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; } +.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; } +.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; } + +.xterm-strikethrough { + text-decoration: line-through; +} + +.xterm-screen .xterm-decoration-container .xterm-decoration { + z-index: 6; + position: absolute; +} + +.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer { + z-index: 7; +} + +.xterm-decoration-overview-ruler { + z-index: 8; + position: absolute; + top: 0; + right: 0; + pointer-events: none; +} + +.xterm-decoration-top { + z-index: 2; + position: relative; +} diff --git a/payload/user/remote_access/pager-webui/www/js/xterm.min.js b/payload/user/remote_access/pager-webui/www/js/xterm.min.js new file mode 100644 index 0000000..a68eae6 --- /dev/null +++ b/payload/user/remote_access/pager-webui/www/js/xterm.min.js @@ -0,0 +1,2 @@ +!function(e,t){if("object"==typeof exports&&"object"==typeof module)module.exports=t();else if("function"==typeof define&&define.amd)define([],t);else{var i=t();for(var s in i)("object"==typeof exports?exports:e)[s]=i[s]}}(self,(()=>(()=>{"use strict";var e={4567:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.AccessibilityManager=void 0;const n=i(9042),o=i(6114),a=i(9924),h=i(844),c=i(5596),l=i(4725),d=i(3656);let _=t.AccessibilityManager=class extends h.Disposable{constructor(e,t){super(),this._terminal=e,this._renderService=t,this._liveRegionLineCount=0,this._charsToConsume=[],this._charsToAnnounce="",this._accessibilityContainer=document.createElement("div"),this._accessibilityContainer.classList.add("xterm-accessibility"),this._rowContainer=document.createElement("div"),this._rowContainer.setAttribute("role","list"),this._rowContainer.classList.add("xterm-accessibility-tree"),this._rowElements=[];for(let e=0;ethis._handleBoundaryFocus(e,0),this._bottomBoundaryFocusListener=e=>this._handleBoundaryFocus(e,1),this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._refreshRowsDimensions(),this._accessibilityContainer.appendChild(this._rowContainer),this._liveRegion=document.createElement("div"),this._liveRegion.classList.add("live-region"),this._liveRegion.setAttribute("aria-live","assertive"),this._accessibilityContainer.appendChild(this._liveRegion),this._liveRegionDebouncer=this.register(new a.TimeBasedDebouncer(this._renderRows.bind(this))),!this._terminal.element)throw new Error("Cannot enable accessibility before Terminal.open");this._terminal.element.insertAdjacentElement("afterbegin",this._accessibilityContainer),this.register(this._terminal.onResize((e=>this._handleResize(e.rows)))),this.register(this._terminal.onRender((e=>this._refreshRows(e.start,e.end)))),this.register(this._terminal.onScroll((()=>this._refreshRows()))),this.register(this._terminal.onA11yChar((e=>this._handleChar(e)))),this.register(this._terminal.onLineFeed((()=>this._handleChar("\n")))),this.register(this._terminal.onA11yTab((e=>this._handleTab(e)))),this.register(this._terminal.onKey((e=>this._handleKey(e.key)))),this.register(this._terminal.onBlur((()=>this._clearLiveRegion()))),this.register(this._renderService.onDimensionsChange((()=>this._refreshRowsDimensions()))),this._screenDprMonitor=new c.ScreenDprMonitor(window),this.register(this._screenDprMonitor),this._screenDprMonitor.setListener((()=>this._refreshRowsDimensions())),this.register((0,d.addDisposableDomListener)(window,"resize",(()=>this._refreshRowsDimensions()))),this._refreshRows(),this.register((0,h.toDisposable)((()=>{this._accessibilityContainer.remove(),this._rowElements.length=0})))}_handleTab(e){for(let t=0;t0?this._charsToConsume.shift()!==e&&(this._charsToAnnounce+=e):this._charsToAnnounce+=e,"\n"===e&&(this._liveRegionLineCount++,21===this._liveRegionLineCount&&(this._liveRegion.textContent+=n.tooMuchOutput)),o.isMac&&this._liveRegion.textContent&&this._liveRegion.textContent.length>0&&!this._liveRegion.parentNode&&setTimeout((()=>{this._accessibilityContainer.appendChild(this._liveRegion)}),0))}_clearLiveRegion(){this._liveRegion.textContent="",this._liveRegionLineCount=0,o.isMac&&this._liveRegion.remove()}_handleKey(e){this._clearLiveRegion(),/\p{Control}/u.test(e)||this._charsToConsume.push(e)}_refreshRows(e,t){this._liveRegionDebouncer.refresh(e,t,this._terminal.rows)}_renderRows(e,t){const i=this._terminal.buffer,s=i.lines.length.toString();for(let r=e;r<=t;r++){const e=i.translateBufferLineToString(i.ydisp+r,!0),t=(i.ydisp+r+1).toString(),n=this._rowElements[r];n&&(0===e.length?n.innerText=" ":n.textContent=e,n.setAttribute("aria-posinset",t),n.setAttribute("aria-setsize",s))}this._announceCharacters()}_announceCharacters(){0!==this._charsToAnnounce.length&&(this._liveRegion.textContent+=this._charsToAnnounce,this._charsToAnnounce="")}_handleBoundaryFocus(e,t){const i=e.target,s=this._rowElements[0===t?1:this._rowElements.length-2];if(i.getAttribute("aria-posinset")===(0===t?"1":`${this._terminal.buffer.lines.length}`))return;if(e.relatedTarget!==s)return;let r,n;if(0===t?(r=i,n=this._rowElements.pop(),this._rowContainer.removeChild(n)):(r=this._rowElements.shift(),n=i,this._rowContainer.removeChild(r)),r.removeEventListener("focus",this._topBoundaryFocusListener),n.removeEventListener("focus",this._bottomBoundaryFocusListener),0===t){const e=this._createAccessibilityTreeNode();this._rowElements.unshift(e),this._rowContainer.insertAdjacentElement("afterbegin",e)}else{const e=this._createAccessibilityTreeNode();this._rowElements.push(e),this._rowContainer.appendChild(e)}this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._terminal.scrollLines(0===t?-1:1),this._rowElements[0===t?1:this._rowElements.length-2].focus(),e.preventDefault(),e.stopImmediatePropagation()}_handleResize(e){this._rowElements[this._rowElements.length-1].removeEventListener("focus",this._bottomBoundaryFocusListener);for(let e=this._rowContainer.children.length;ee;)this._rowContainer.removeChild(this._rowElements.pop());this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._refreshRowsDimensions()}_createAccessibilityTreeNode(){const e=document.createElement("div");return e.setAttribute("role","listitem"),e.tabIndex=-1,this._refreshRowDimensions(e),e}_refreshRowsDimensions(){if(this._renderService.dimensions.css.cell.height){this._accessibilityContainer.style.width=`${this._renderService.dimensions.css.canvas.width}px`,this._rowElements.length!==this._terminal.rows&&this._handleResize(this._terminal.rows);for(let e=0;e{function i(e){return e.replace(/\r?\n/g,"\r")}function s(e,t){return t?"[200~"+e+"[201~":e}function r(e,t,r,n){e=s(e=i(e),r.decPrivateModes.bracketedPasteMode&&!0!==n.rawOptions.ignoreBracketedPasteMode),r.triggerDataEvent(e,!0),t.value=""}function n(e,t,i){const s=i.getBoundingClientRect(),r=e.clientX-s.left-10,n=e.clientY-s.top-10;t.style.width="20px",t.style.height="20px",t.style.left=`${r}px`,t.style.top=`${n}px`,t.style.zIndex="1000",t.focus()}Object.defineProperty(t,"__esModule",{value:!0}),t.rightClickHandler=t.moveTextAreaUnderMouseCursor=t.paste=t.handlePasteEvent=t.copyHandler=t.bracketTextForPaste=t.prepareTextForTerminal=void 0,t.prepareTextForTerminal=i,t.bracketTextForPaste=s,t.copyHandler=function(e,t){e.clipboardData&&e.clipboardData.setData("text/plain",t.selectionText),e.preventDefault()},t.handlePasteEvent=function(e,t,i,s){e.stopPropagation(),e.clipboardData&&r(e.clipboardData.getData("text/plain"),t,i,s)},t.paste=r,t.moveTextAreaUnderMouseCursor=n,t.rightClickHandler=function(e,t,i,s,r){n(e,t,i),r&&s.rightClickSelect(e),t.value=s.selectionText,t.select()}},7239:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ColorContrastCache=void 0;const s=i(1505);t.ColorContrastCache=class{constructor(){this._color=new s.TwoKeyMap,this._css=new s.TwoKeyMap}setCss(e,t,i){this._css.set(e,t,i)}getCss(e,t){return this._css.get(e,t)}setColor(e,t,i){this._color.set(e,t,i)}getColor(e,t){return this._color.get(e,t)}clear(){this._color.clear(),this._css.clear()}}},3656:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.addDisposableDomListener=void 0,t.addDisposableDomListener=function(e,t,i,s){e.addEventListener(t,i,s);let r=!1;return{dispose:()=>{r||(r=!0,e.removeEventListener(t,i,s))}}}},6465:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.Linkifier2=void 0;const n=i(3656),o=i(8460),a=i(844),h=i(2585);let c=t.Linkifier2=class extends a.Disposable{get currentLink(){return this._currentLink}constructor(e){super(),this._bufferService=e,this._linkProviders=[],this._linkCacheDisposables=[],this._isMouseOut=!0,this._wasResized=!1,this._activeLine=-1,this._onShowLinkUnderline=this.register(new o.EventEmitter),this.onShowLinkUnderline=this._onShowLinkUnderline.event,this._onHideLinkUnderline=this.register(new o.EventEmitter),this.onHideLinkUnderline=this._onHideLinkUnderline.event,this.register((0,a.getDisposeArrayDisposable)(this._linkCacheDisposables)),this.register((0,a.toDisposable)((()=>{this._lastMouseEvent=void 0}))),this.register(this._bufferService.onResize((()=>{this._clearCurrentLink(),this._wasResized=!0})))}registerLinkProvider(e){return this._linkProviders.push(e),{dispose:()=>{const t=this._linkProviders.indexOf(e);-1!==t&&this._linkProviders.splice(t,1)}}}attachToDom(e,t,i){this._element=e,this._mouseService=t,this._renderService=i,this.register((0,n.addDisposableDomListener)(this._element,"mouseleave",(()=>{this._isMouseOut=!0,this._clearCurrentLink()}))),this.register((0,n.addDisposableDomListener)(this._element,"mousemove",this._handleMouseMove.bind(this))),this.register((0,n.addDisposableDomListener)(this._element,"mousedown",this._handleMouseDown.bind(this))),this.register((0,n.addDisposableDomListener)(this._element,"mouseup",this._handleMouseUp.bind(this)))}_handleMouseMove(e){if(this._lastMouseEvent=e,!this._element||!this._mouseService)return;const t=this._positionFromMouseEvent(e,this._element,this._mouseService);if(!t)return;this._isMouseOut=!1;const i=e.composedPath();for(let e=0;e{null==e||e.forEach((e=>{e.link.dispose&&e.link.dispose()}))})),this._activeProviderReplies=new Map,this._activeLine=e.y);let r=!1;for(const[i,n]of this._linkProviders.entries())t?(null===(s=this._activeProviderReplies)||void 0===s?void 0:s.get(i))&&(r=this._checkLinkProviderResult(i,e,r)):n.provideLinks(e.y,(t=>{var s,n;if(this._isMouseOut)return;const o=null==t?void 0:t.map((e=>({link:e})));null===(s=this._activeProviderReplies)||void 0===s||s.set(i,o),r=this._checkLinkProviderResult(i,e,r),(null===(n=this._activeProviderReplies)||void 0===n?void 0:n.size)===this._linkProviders.length&&this._removeIntersectingLinks(e.y,this._activeProviderReplies)}))}_removeIntersectingLinks(e,t){const i=new Set;for(let s=0;se?this._bufferService.cols:s.link.range.end.x;for(let e=n;e<=o;e++){if(i.has(e)){r.splice(t--,1);break}i.add(e)}}}}_checkLinkProviderResult(e,t,i){var s;if(!this._activeProviderReplies)return i;const r=this._activeProviderReplies.get(e);let n=!1;for(let t=0;tthis._linkAtPosition(e.link,t)));e&&(i=!0,this._handleNewLink(e))}if(this._activeProviderReplies.size===this._linkProviders.length&&!i)for(let e=0;ethis._linkAtPosition(e.link,t)));if(r){i=!0,this._handleNewLink(r);break}}return i}_handleMouseDown(){this._mouseDownLink=this._currentLink}_handleMouseUp(e){if(!this._element||!this._mouseService||!this._currentLink)return;const t=this._positionFromMouseEvent(e,this._element,this._mouseService);t&&this._mouseDownLink===this._currentLink&&this._linkAtPosition(this._currentLink.link,t)&&this._currentLink.link.activate(e,this._currentLink.link.text)}_clearCurrentLink(e,t){this._element&&this._currentLink&&this._lastMouseEvent&&(!e||!t||this._currentLink.link.range.start.y>=e&&this._currentLink.link.range.end.y<=t)&&(this._linkLeave(this._element,this._currentLink.link,this._lastMouseEvent),this._currentLink=void 0,(0,a.disposeArray)(this._linkCacheDisposables))}_handleNewLink(e){if(!this._element||!this._lastMouseEvent||!this._mouseService)return;const t=this._positionFromMouseEvent(this._lastMouseEvent,this._element,this._mouseService);t&&this._linkAtPosition(e.link,t)&&(this._currentLink=e,this._currentLink.state={decorations:{underline:void 0===e.link.decorations||e.link.decorations.underline,pointerCursor:void 0===e.link.decorations||e.link.decorations.pointerCursor},isHovered:!0},this._linkHover(this._element,e.link,this._lastMouseEvent),e.link.decorations={},Object.defineProperties(e.link.decorations,{pointerCursor:{get:()=>{var e,t;return null===(t=null===(e=this._currentLink)||void 0===e?void 0:e.state)||void 0===t?void 0:t.decorations.pointerCursor},set:e=>{var t,i;(null===(t=this._currentLink)||void 0===t?void 0:t.state)&&this._currentLink.state.decorations.pointerCursor!==e&&(this._currentLink.state.decorations.pointerCursor=e,this._currentLink.state.isHovered&&(null===(i=this._element)||void 0===i||i.classList.toggle("xterm-cursor-pointer",e)))}},underline:{get:()=>{var e,t;return null===(t=null===(e=this._currentLink)||void 0===e?void 0:e.state)||void 0===t?void 0:t.decorations.underline},set:t=>{var i,s,r;(null===(i=this._currentLink)||void 0===i?void 0:i.state)&&(null===(r=null===(s=this._currentLink)||void 0===s?void 0:s.state)||void 0===r?void 0:r.decorations.underline)!==t&&(this._currentLink.state.decorations.underline=t,this._currentLink.state.isHovered&&this._fireUnderlineEvent(e.link,t))}}}),this._renderService&&this._linkCacheDisposables.push(this._renderService.onRenderedViewportChange((e=>{if(!this._currentLink)return;const t=0===e.start?0:e.start+1+this._bufferService.buffer.ydisp,i=this._bufferService.buffer.ydisp+1+e.end;if(this._currentLink.link.range.start.y>=t&&this._currentLink.link.range.end.y<=i&&(this._clearCurrentLink(t,i),this._lastMouseEvent&&this._element)){const e=this._positionFromMouseEvent(this._lastMouseEvent,this._element,this._mouseService);e&&this._askForLink(e,!1)}}))))}_linkHover(e,t,i){var s;(null===(s=this._currentLink)||void 0===s?void 0:s.state)&&(this._currentLink.state.isHovered=!0,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(t,!0),this._currentLink.state.decorations.pointerCursor&&e.classList.add("xterm-cursor-pointer")),t.hover&&t.hover(i,t.text)}_fireUnderlineEvent(e,t){const i=e.range,s=this._bufferService.buffer.ydisp,r=this._createLinkUnderlineEvent(i.start.x-1,i.start.y-s-1,i.end.x,i.end.y-s-1,void 0);(t?this._onShowLinkUnderline:this._onHideLinkUnderline).fire(r)}_linkLeave(e,t,i){var s;(null===(s=this._currentLink)||void 0===s?void 0:s.state)&&(this._currentLink.state.isHovered=!1,this._currentLink.state.decorations.underline&&this._fireUnderlineEvent(t,!1),this._currentLink.state.decorations.pointerCursor&&e.classList.remove("xterm-cursor-pointer")),t.leave&&t.leave(i,t.text)}_linkAtPosition(e,t){const i=e.range.start.y*this._bufferService.cols+e.range.start.x,s=e.range.end.y*this._bufferService.cols+e.range.end.x,r=t.y*this._bufferService.cols+t.x;return i<=r&&r<=s}_positionFromMouseEvent(e,t,i){const s=i.getCoords(e,t,this._bufferService.cols,this._bufferService.rows);if(s)return{x:s[0],y:s[1]+this._bufferService.buffer.ydisp}}_createLinkUnderlineEvent(e,t,i,s,r){return{x1:e,y1:t,x2:i,y2:s,cols:this._bufferService.cols,fg:r}}};t.Linkifier2=c=s([r(0,h.IBufferService)],c)},9042:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.tooMuchOutput=t.promptLabel=void 0,t.promptLabel="Terminal input",t.tooMuchOutput="Too much output to announce, navigate to rows manually to read"},3730:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.OscLinkProvider=void 0;const n=i(511),o=i(2585);let a=t.OscLinkProvider=class{constructor(e,t,i){this._bufferService=e,this._optionsService=t,this._oscLinkService=i}provideLinks(e,t){var i;const s=this._bufferService.buffer.lines.get(e-1);if(!s)return void t(void 0);const r=[],o=this._optionsService.rawOptions.linkHandler,a=new n.CellData,c=s.getTrimmedLength();let l=-1,d=-1,_=!1;for(let t=0;to?o.activate(e,t,i):h(0,t),hover:(e,t)=>{var s;return null===(s=null==o?void 0:o.hover)||void 0===s?void 0:s.call(o,e,t,i)},leave:(e,t)=>{var s;return null===(s=null==o?void 0:o.leave)||void 0===s?void 0:s.call(o,e,t,i)}})}_=!1,a.hasExtendedAttrs()&&a.extended.urlId?(d=t,l=a.extended.urlId):(d=-1,l=-1)}}t(r)}};function h(e,t){if(confirm(`Do you want to navigate to ${t}?\n\nWARNING: This link could potentially be dangerous`)){const e=window.open();if(e){try{e.opener=null}catch(e){}e.location.href=t}else console.warn("Opening link blocked as opener could not be cleared")}}t.OscLinkProvider=a=s([r(0,o.IBufferService),r(1,o.IOptionsService),r(2,o.IOscLinkService)],a)},6193:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.RenderDebouncer=void 0,t.RenderDebouncer=class{constructor(e,t){this._parentWindow=e,this._renderCallback=t,this._refreshCallbacks=[]}dispose(){this._animationFrame&&(this._parentWindow.cancelAnimationFrame(this._animationFrame),this._animationFrame=void 0)}addRefreshCallback(e){return this._refreshCallbacks.push(e),this._animationFrame||(this._animationFrame=this._parentWindow.requestAnimationFrame((()=>this._innerRefresh()))),this._animationFrame}refresh(e,t,i){this._rowCount=i,e=void 0!==e?e:0,t=void 0!==t?t:this._rowCount-1,this._rowStart=void 0!==this._rowStart?Math.min(this._rowStart,e):e,this._rowEnd=void 0!==this._rowEnd?Math.max(this._rowEnd,t):t,this._animationFrame||(this._animationFrame=this._parentWindow.requestAnimationFrame((()=>this._innerRefresh())))}_innerRefresh(){if(this._animationFrame=void 0,void 0===this._rowStart||void 0===this._rowEnd||void 0===this._rowCount)return void this._runRefreshCallbacks();const e=Math.max(this._rowStart,0),t=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(e,t),this._runRefreshCallbacks()}_runRefreshCallbacks(){for(const e of this._refreshCallbacks)e(0);this._refreshCallbacks=[]}}},5596:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ScreenDprMonitor=void 0;const s=i(844);class r extends s.Disposable{constructor(e){super(),this._parentWindow=e,this._currentDevicePixelRatio=this._parentWindow.devicePixelRatio,this.register((0,s.toDisposable)((()=>{this.clearListener()})))}setListener(e){this._listener&&this.clearListener(),this._listener=e,this._outerListener=()=>{this._listener&&(this._listener(this._parentWindow.devicePixelRatio,this._currentDevicePixelRatio),this._updateDpr())},this._updateDpr()}_updateDpr(){var e;this._outerListener&&(null===(e=this._resolutionMediaMatchList)||void 0===e||e.removeListener(this._outerListener),this._currentDevicePixelRatio=this._parentWindow.devicePixelRatio,this._resolutionMediaMatchList=this._parentWindow.matchMedia(`screen and (resolution: ${this._parentWindow.devicePixelRatio}dppx)`),this._resolutionMediaMatchList.addListener(this._outerListener))}clearListener(){this._resolutionMediaMatchList&&this._listener&&this._outerListener&&(this._resolutionMediaMatchList.removeListener(this._outerListener),this._resolutionMediaMatchList=void 0,this._listener=void 0,this._outerListener=void 0)}}t.ScreenDprMonitor=r},3236:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Terminal=void 0;const s=i(3614),r=i(3656),n=i(6465),o=i(9042),a=i(3730),h=i(1680),c=i(3107),l=i(5744),d=i(2950),_=i(1296),u=i(428),f=i(4269),v=i(5114),p=i(8934),g=i(3230),m=i(9312),S=i(4725),C=i(6731),b=i(8055),y=i(8969),w=i(8460),E=i(844),k=i(6114),L=i(8437),D=i(2584),R=i(7399),x=i(5941),A=i(9074),B=i(2585),T=i(5435),M=i(4567),O="undefined"!=typeof window?window.document:null;class P extends y.CoreTerminal{get onFocus(){return this._onFocus.event}get onBlur(){return this._onBlur.event}get onA11yChar(){return this._onA11yCharEmitter.event}get onA11yTab(){return this._onA11yTabEmitter.event}get onWillOpen(){return this._onWillOpen.event}constructor(e={}){super(e),this.browser=k,this._keyDownHandled=!1,this._keyDownSeen=!1,this._keyPressHandled=!1,this._unprocessedDeadKey=!1,this._accessibilityManager=this.register(new E.MutableDisposable),this._onCursorMove=this.register(new w.EventEmitter),this.onCursorMove=this._onCursorMove.event,this._onKey=this.register(new w.EventEmitter),this.onKey=this._onKey.event,this._onRender=this.register(new w.EventEmitter),this.onRender=this._onRender.event,this._onSelectionChange=this.register(new w.EventEmitter),this.onSelectionChange=this._onSelectionChange.event,this._onTitleChange=this.register(new w.EventEmitter),this.onTitleChange=this._onTitleChange.event,this._onBell=this.register(new w.EventEmitter),this.onBell=this._onBell.event,this._onFocus=this.register(new w.EventEmitter),this._onBlur=this.register(new w.EventEmitter),this._onA11yCharEmitter=this.register(new w.EventEmitter),this._onA11yTabEmitter=this.register(new w.EventEmitter),this._onWillOpen=this.register(new w.EventEmitter),this._setup(),this.linkifier2=this.register(this._instantiationService.createInstance(n.Linkifier2)),this.linkifier2.registerLinkProvider(this._instantiationService.createInstance(a.OscLinkProvider)),this._decorationService=this._instantiationService.createInstance(A.DecorationService),this._instantiationService.setService(B.IDecorationService,this._decorationService),this.register(this._inputHandler.onRequestBell((()=>this._onBell.fire()))),this.register(this._inputHandler.onRequestRefreshRows(((e,t)=>this.refresh(e,t)))),this.register(this._inputHandler.onRequestSendFocus((()=>this._reportFocus()))),this.register(this._inputHandler.onRequestReset((()=>this.reset()))),this.register(this._inputHandler.onRequestWindowsOptionsReport((e=>this._reportWindowsOptions(e)))),this.register(this._inputHandler.onColor((e=>this._handleColorEvent(e)))),this.register((0,w.forwardEvent)(this._inputHandler.onCursorMove,this._onCursorMove)),this.register((0,w.forwardEvent)(this._inputHandler.onTitleChange,this._onTitleChange)),this.register((0,w.forwardEvent)(this._inputHandler.onA11yChar,this._onA11yCharEmitter)),this.register((0,w.forwardEvent)(this._inputHandler.onA11yTab,this._onA11yTabEmitter)),this.register(this._bufferService.onResize((e=>this._afterResize(e.cols,e.rows)))),this.register((0,E.toDisposable)((()=>{var e,t;this._customKeyEventHandler=void 0,null===(t=null===(e=this.element)||void 0===e?void 0:e.parentNode)||void 0===t||t.removeChild(this.element)})))}_handleColorEvent(e){if(this._themeService)for(const t of e){let e,i="";switch(t.index){case 256:e="foreground",i="10";break;case 257:e="background",i="11";break;case 258:e="cursor",i="12";break;default:e="ansi",i="4;"+t.index}switch(t.type){case 0:const s=b.color.toColorRGB("ansi"===e?this._themeService.colors.ansi[t.index]:this._themeService.colors[e]);this.coreService.triggerDataEvent(`${D.C0.ESC}]${i};${(0,x.toRgbString)(s)}${D.C1_ESCAPED.ST}`);break;case 1:if("ansi"===e)this._themeService.modifyColors((e=>e.ansi[t.index]=b.rgba.toColor(...t.color)));else{const i=e;this._themeService.modifyColors((e=>e[i]=b.rgba.toColor(...t.color)))}break;case 2:this._themeService.restoreColor(t.index)}}}_setup(){super._setup(),this._customKeyEventHandler=void 0}get buffer(){return this.buffers.active}focus(){this.textarea&&this.textarea.focus({preventScroll:!0})}_handleScreenReaderModeOptionChange(e){e?!this._accessibilityManager.value&&this._renderService&&(this._accessibilityManager.value=this._instantiationService.createInstance(M.AccessibilityManager,this)):this._accessibilityManager.clear()}_handleTextAreaFocus(e){this.coreService.decPrivateModes.sendFocus&&this.coreService.triggerDataEvent(D.C0.ESC+"[I"),this.updateCursorStyle(e),this.element.classList.add("focus"),this._showCursor(),this._onFocus.fire()}blur(){var e;return null===(e=this.textarea)||void 0===e?void 0:e.blur()}_handleTextAreaBlur(){this.textarea.value="",this.refresh(this.buffer.y,this.buffer.y),this.coreService.decPrivateModes.sendFocus&&this.coreService.triggerDataEvent(D.C0.ESC+"[O"),this.element.classList.remove("focus"),this._onBlur.fire()}_syncTextArea(){if(!this.textarea||!this.buffer.isCursorInViewport||this._compositionHelper.isComposing||!this._renderService)return;const e=this.buffer.ybase+this.buffer.y,t=this.buffer.lines.get(e);if(!t)return;const i=Math.min(this.buffer.x,this.cols-1),s=this._renderService.dimensions.css.cell.height,r=t.getWidth(i),n=this._renderService.dimensions.css.cell.width*r,o=this.buffer.y*this._renderService.dimensions.css.cell.height,a=i*this._renderService.dimensions.css.cell.width;this.textarea.style.left=a+"px",this.textarea.style.top=o+"px",this.textarea.style.width=n+"px",this.textarea.style.height=s+"px",this.textarea.style.lineHeight=s+"px",this.textarea.style.zIndex="-5"}_initGlobal(){this._bindKeys(),this.register((0,r.addDisposableDomListener)(this.element,"copy",(e=>{this.hasSelection()&&(0,s.copyHandler)(e,this._selectionService)})));const e=e=>(0,s.handlePasteEvent)(e,this.textarea,this.coreService,this.optionsService);this.register((0,r.addDisposableDomListener)(this.textarea,"paste",e)),this.register((0,r.addDisposableDomListener)(this.element,"paste",e)),k.isFirefox?this.register((0,r.addDisposableDomListener)(this.element,"mousedown",(e=>{2===e.button&&(0,s.rightClickHandler)(e,this.textarea,this.screenElement,this._selectionService,this.options.rightClickSelectsWord)}))):this.register((0,r.addDisposableDomListener)(this.element,"contextmenu",(e=>{(0,s.rightClickHandler)(e,this.textarea,this.screenElement,this._selectionService,this.options.rightClickSelectsWord)}))),k.isLinux&&this.register((0,r.addDisposableDomListener)(this.element,"auxclick",(e=>{1===e.button&&(0,s.moveTextAreaUnderMouseCursor)(e,this.textarea,this.screenElement)})))}_bindKeys(){this.register((0,r.addDisposableDomListener)(this.textarea,"keyup",(e=>this._keyUp(e)),!0)),this.register((0,r.addDisposableDomListener)(this.textarea,"keydown",(e=>this._keyDown(e)),!0)),this.register((0,r.addDisposableDomListener)(this.textarea,"keypress",(e=>this._keyPress(e)),!0)),this.register((0,r.addDisposableDomListener)(this.textarea,"compositionstart",(()=>this._compositionHelper.compositionstart()))),this.register((0,r.addDisposableDomListener)(this.textarea,"compositionupdate",(e=>this._compositionHelper.compositionupdate(e)))),this.register((0,r.addDisposableDomListener)(this.textarea,"compositionend",(()=>this._compositionHelper.compositionend()))),this.register((0,r.addDisposableDomListener)(this.textarea,"input",(e=>this._inputEvent(e)),!0)),this.register(this.onRender((()=>this._compositionHelper.updateCompositionElements())))}open(e){var t;if(!e)throw new Error("Terminal requires a parent element.");e.isConnected||this._logService.debug("Terminal.open was called on an element that was not attached to the DOM"),this._document=e.ownerDocument,this.element=this._document.createElement("div"),this.element.dir="ltr",this.element.classList.add("terminal"),this.element.classList.add("xterm"),e.appendChild(this.element);const i=O.createDocumentFragment();this._viewportElement=O.createElement("div"),this._viewportElement.classList.add("xterm-viewport"),i.appendChild(this._viewportElement),this._viewportScrollArea=O.createElement("div"),this._viewportScrollArea.classList.add("xterm-scroll-area"),this._viewportElement.appendChild(this._viewportScrollArea),this.screenElement=O.createElement("div"),this.screenElement.classList.add("xterm-screen"),this._helperContainer=O.createElement("div"),this._helperContainer.classList.add("xterm-helpers"),this.screenElement.appendChild(this._helperContainer),i.appendChild(this.screenElement),this.textarea=O.createElement("textarea"),this.textarea.classList.add("xterm-helper-textarea"),this.textarea.setAttribute("aria-label",o.promptLabel),k.isChromeOS||this.textarea.setAttribute("aria-multiline","false"),this.textarea.setAttribute("autocorrect","off"),this.textarea.setAttribute("autocapitalize","off"),this.textarea.setAttribute("spellcheck","false"),this.textarea.tabIndex=0,this._coreBrowserService=this._instantiationService.createInstance(v.CoreBrowserService,this.textarea,null!==(t=this._document.defaultView)&&void 0!==t?t:window),this._instantiationService.setService(S.ICoreBrowserService,this._coreBrowserService),this.register((0,r.addDisposableDomListener)(this.textarea,"focus",(e=>this._handleTextAreaFocus(e)))),this.register((0,r.addDisposableDomListener)(this.textarea,"blur",(()=>this._handleTextAreaBlur()))),this._helperContainer.appendChild(this.textarea),this._charSizeService=this._instantiationService.createInstance(u.CharSizeService,this._document,this._helperContainer),this._instantiationService.setService(S.ICharSizeService,this._charSizeService),this._themeService=this._instantiationService.createInstance(C.ThemeService),this._instantiationService.setService(S.IThemeService,this._themeService),this._characterJoinerService=this._instantiationService.createInstance(f.CharacterJoinerService),this._instantiationService.setService(S.ICharacterJoinerService,this._characterJoinerService),this._renderService=this.register(this._instantiationService.createInstance(g.RenderService,this.rows,this.screenElement)),this._instantiationService.setService(S.IRenderService,this._renderService),this.register(this._renderService.onRenderedViewportChange((e=>this._onRender.fire(e)))),this.onResize((e=>this._renderService.resize(e.cols,e.rows))),this._compositionView=O.createElement("div"),this._compositionView.classList.add("composition-view"),this._compositionHelper=this._instantiationService.createInstance(d.CompositionHelper,this.textarea,this._compositionView),this._helperContainer.appendChild(this._compositionView),this.element.appendChild(i);try{this._onWillOpen.fire(this.element)}catch(e){}this._renderService.hasRenderer()||this._renderService.setRenderer(this._createRenderer()),this._mouseService=this._instantiationService.createInstance(p.MouseService),this._instantiationService.setService(S.IMouseService,this._mouseService),this.viewport=this._instantiationService.createInstance(h.Viewport,this._viewportElement,this._viewportScrollArea),this.viewport.onRequestScrollLines((e=>this.scrollLines(e.amount,e.suppressScrollEvent,1))),this.register(this._inputHandler.onRequestSyncScrollBar((()=>this.viewport.syncScrollArea()))),this.register(this.viewport),this.register(this.onCursorMove((()=>{this._renderService.handleCursorMove(),this._syncTextArea()}))),this.register(this.onResize((()=>this._renderService.handleResize(this.cols,this.rows)))),this.register(this.onBlur((()=>this._renderService.handleBlur()))),this.register(this.onFocus((()=>this._renderService.handleFocus()))),this.register(this._renderService.onDimensionsChange((()=>this.viewport.syncScrollArea()))),this._selectionService=this.register(this._instantiationService.createInstance(m.SelectionService,this.element,this.screenElement,this.linkifier2)),this._instantiationService.setService(S.ISelectionService,this._selectionService),this.register(this._selectionService.onRequestScrollLines((e=>this.scrollLines(e.amount,e.suppressScrollEvent)))),this.register(this._selectionService.onSelectionChange((()=>this._onSelectionChange.fire()))),this.register(this._selectionService.onRequestRedraw((e=>this._renderService.handleSelectionChanged(e.start,e.end,e.columnSelectMode)))),this.register(this._selectionService.onLinuxMouseSelection((e=>{this.textarea.value=e,this.textarea.focus(),this.textarea.select()}))),this.register(this._onScroll.event((e=>{this.viewport.syncScrollArea(),this._selectionService.refresh()}))),this.register((0,r.addDisposableDomListener)(this._viewportElement,"scroll",(()=>this._selectionService.refresh()))),this.linkifier2.attachToDom(this.screenElement,this._mouseService,this._renderService),this.register(this._instantiationService.createInstance(c.BufferDecorationRenderer,this.screenElement)),this.register((0,r.addDisposableDomListener)(this.element,"mousedown",(e=>this._selectionService.handleMouseDown(e)))),this.coreMouseService.areMouseEventsActive?(this._selectionService.disable(),this.element.classList.add("enable-mouse-events")):this._selectionService.enable(),this.options.screenReaderMode&&(this._accessibilityManager.value=this._instantiationService.createInstance(M.AccessibilityManager,this)),this.register(this.optionsService.onSpecificOptionChange("screenReaderMode",(e=>this._handleScreenReaderModeOptionChange(e)))),this.options.overviewRulerWidth&&(this._overviewRulerRenderer=this.register(this._instantiationService.createInstance(l.OverviewRulerRenderer,this._viewportElement,this.screenElement))),this.optionsService.onSpecificOptionChange("overviewRulerWidth",(e=>{!this._overviewRulerRenderer&&e&&this._viewportElement&&this.screenElement&&(this._overviewRulerRenderer=this.register(this._instantiationService.createInstance(l.OverviewRulerRenderer,this._viewportElement,this.screenElement)))})),this._charSizeService.measure(),this.refresh(0,this.rows-1),this._initGlobal(),this.bindMouse()}_createRenderer(){return this._instantiationService.createInstance(_.DomRenderer,this.element,this.screenElement,this._viewportElement,this.linkifier2)}bindMouse(){const e=this,t=this.element;function i(t){const i=e._mouseService.getMouseReportCoords(t,e.screenElement);if(!i)return!1;let s,r;switch(t.overrideType||t.type){case"mousemove":r=32,void 0===t.buttons?(s=3,void 0!==t.button&&(s=t.button<3?t.button:3)):s=1&t.buttons?0:4&t.buttons?1:2&t.buttons?2:3;break;case"mouseup":r=0,s=t.button<3?t.button:3;break;case"mousedown":r=1,s=t.button<3?t.button:3;break;case"wheel":if(0===e.viewport.getLinesScrolled(t))return!1;r=t.deltaY<0?0:1,s=4;break;default:return!1}return!(void 0===r||void 0===s||s>4)&&e.coreMouseService.triggerMouseEvent({col:i.col,row:i.row,x:i.x,y:i.y,button:s,action:r,ctrl:t.ctrlKey,alt:t.altKey,shift:t.shiftKey})}const s={mouseup:null,wheel:null,mousedrag:null,mousemove:null},n={mouseup:e=>(i(e),e.buttons||(this._document.removeEventListener("mouseup",s.mouseup),s.mousedrag&&this._document.removeEventListener("mousemove",s.mousedrag)),this.cancel(e)),wheel:e=>(i(e),this.cancel(e,!0)),mousedrag:e=>{e.buttons&&i(e)},mousemove:e=>{e.buttons||i(e)}};this.register(this.coreMouseService.onProtocolChange((e=>{e?("debug"===this.optionsService.rawOptions.logLevel&&this._logService.debug("Binding to mouse events:",this.coreMouseService.explainEvents(e)),this.element.classList.add("enable-mouse-events"),this._selectionService.disable()):(this._logService.debug("Unbinding from mouse events."),this.element.classList.remove("enable-mouse-events"),this._selectionService.enable()),8&e?s.mousemove||(t.addEventListener("mousemove",n.mousemove),s.mousemove=n.mousemove):(t.removeEventListener("mousemove",s.mousemove),s.mousemove=null),16&e?s.wheel||(t.addEventListener("wheel",n.wheel,{passive:!1}),s.wheel=n.wheel):(t.removeEventListener("wheel",s.wheel),s.wheel=null),2&e?s.mouseup||(t.addEventListener("mouseup",n.mouseup),s.mouseup=n.mouseup):(this._document.removeEventListener("mouseup",s.mouseup),t.removeEventListener("mouseup",s.mouseup),s.mouseup=null),4&e?s.mousedrag||(s.mousedrag=n.mousedrag):(this._document.removeEventListener("mousemove",s.mousedrag),s.mousedrag=null)}))),this.coreMouseService.activeProtocol=this.coreMouseService.activeProtocol,this.register((0,r.addDisposableDomListener)(t,"mousedown",(e=>{if(e.preventDefault(),this.focus(),this.coreMouseService.areMouseEventsActive&&!this._selectionService.shouldForceSelection(e))return i(e),s.mouseup&&this._document.addEventListener("mouseup",s.mouseup),s.mousedrag&&this._document.addEventListener("mousemove",s.mousedrag),this.cancel(e)}))),this.register((0,r.addDisposableDomListener)(t,"wheel",(e=>{if(!s.wheel){if(!this.buffer.hasScrollback){const t=this.viewport.getLinesScrolled(e);if(0===t)return;const i=D.C0.ESC+(this.coreService.decPrivateModes.applicationCursorKeys?"O":"[")+(e.deltaY<0?"A":"B");let s="";for(let e=0;e{if(!this.coreMouseService.areMouseEventsActive)return this.viewport.handleTouchStart(e),this.cancel(e)}),{passive:!0})),this.register((0,r.addDisposableDomListener)(t,"touchmove",(e=>{if(!this.coreMouseService.areMouseEventsActive)return this.viewport.handleTouchMove(e)?void 0:this.cancel(e)}),{passive:!1}))}refresh(e,t){var i;null===(i=this._renderService)||void 0===i||i.refreshRows(e,t)}updateCursorStyle(e){var t;(null===(t=this._selectionService)||void 0===t?void 0:t.shouldColumnSelect(e))?this.element.classList.add("column-select"):this.element.classList.remove("column-select")}_showCursor(){this.coreService.isCursorInitialized||(this.coreService.isCursorInitialized=!0,this.refresh(this.buffer.y,this.buffer.y))}scrollLines(e,t,i=0){var s;1===i?(super.scrollLines(e,t,i),this.refresh(0,this.rows-1)):null===(s=this.viewport)||void 0===s||s.scrollLines(e)}paste(e){(0,s.paste)(e,this.textarea,this.coreService,this.optionsService)}attachCustomKeyEventHandler(e){this._customKeyEventHandler=e}registerLinkProvider(e){return this.linkifier2.registerLinkProvider(e)}registerCharacterJoiner(e){if(!this._characterJoinerService)throw new Error("Terminal must be opened first");const t=this._characterJoinerService.register(e);return this.refresh(0,this.rows-1),t}deregisterCharacterJoiner(e){if(!this._characterJoinerService)throw new Error("Terminal must be opened first");this._characterJoinerService.deregister(e)&&this.refresh(0,this.rows-1)}get markers(){return this.buffer.markers}registerMarker(e){return this.buffer.addMarker(this.buffer.ybase+this.buffer.y+e)}registerDecoration(e){return this._decorationService.registerDecoration(e)}hasSelection(){return!!this._selectionService&&this._selectionService.hasSelection}select(e,t,i){this._selectionService.setSelection(e,t,i)}getSelection(){return this._selectionService?this._selectionService.selectionText:""}getSelectionPosition(){if(this._selectionService&&this._selectionService.hasSelection)return{start:{x:this._selectionService.selectionStart[0],y:this._selectionService.selectionStart[1]},end:{x:this._selectionService.selectionEnd[0],y:this._selectionService.selectionEnd[1]}}}clearSelection(){var e;null===(e=this._selectionService)||void 0===e||e.clearSelection()}selectAll(){var e;null===(e=this._selectionService)||void 0===e||e.selectAll()}selectLines(e,t){var i;null===(i=this._selectionService)||void 0===i||i.selectLines(e,t)}_keyDown(e){if(this._keyDownHandled=!1,this._keyDownSeen=!0,this._customKeyEventHandler&&!1===this._customKeyEventHandler(e))return!1;const t=this.browser.isMac&&this.options.macOptionIsMeta&&e.altKey;if(!t&&!this._compositionHelper.keydown(e))return this.options.scrollOnUserInput&&this.buffer.ybase!==this.buffer.ydisp&&this.scrollToBottom(),!1;t||"Dead"!==e.key&&"AltGraph"!==e.key||(this._unprocessedDeadKey=!0);const i=(0,R.evaluateKeyboardEvent)(e,this.coreService.decPrivateModes.applicationCursorKeys,this.browser.isMac,this.options.macOptionIsMeta);if(this.updateCursorStyle(e),3===i.type||2===i.type){const t=this.rows-1;return this.scrollLines(2===i.type?-t:t),this.cancel(e,!0)}return 1===i.type&&this.selectAll(),!!this._isThirdLevelShift(this.browser,e)||(i.cancel&&this.cancel(e,!0),!i.key||!!(e.key&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&1===e.key.length&&e.key.charCodeAt(0)>=65&&e.key.charCodeAt(0)<=90)||(this._unprocessedDeadKey?(this._unprocessedDeadKey=!1,!0):(i.key!==D.C0.ETX&&i.key!==D.C0.CR||(this.textarea.value=""),this._onKey.fire({key:i.key,domEvent:e}),this._showCursor(),this.coreService.triggerDataEvent(i.key,!0),!this.optionsService.rawOptions.screenReaderMode||e.altKey||e.ctrlKey?this.cancel(e,!0):void(this._keyDownHandled=!0))))}_isThirdLevelShift(e,t){const i=e.isMac&&!this.options.macOptionIsMeta&&t.altKey&&!t.ctrlKey&&!t.metaKey||e.isWindows&&t.altKey&&t.ctrlKey&&!t.metaKey||e.isWindows&&t.getModifierState("AltGraph");return"keypress"===t.type?i:i&&(!t.keyCode||t.keyCode>47)}_keyUp(e){this._keyDownSeen=!1,this._customKeyEventHandler&&!1===this._customKeyEventHandler(e)||(function(e){return 16===e.keyCode||17===e.keyCode||18===e.keyCode}(e)||this.focus(),this.updateCursorStyle(e),this._keyPressHandled=!1)}_keyPress(e){let t;if(this._keyPressHandled=!1,this._keyDownHandled)return!1;if(this._customKeyEventHandler&&!1===this._customKeyEventHandler(e))return!1;if(this.cancel(e),e.charCode)t=e.charCode;else if(null===e.which||void 0===e.which)t=e.keyCode;else{if(0===e.which||0===e.charCode)return!1;t=e.which}return!(!t||(e.altKey||e.ctrlKey||e.metaKey)&&!this._isThirdLevelShift(this.browser,e)||(t=String.fromCharCode(t),this._onKey.fire({key:t,domEvent:e}),this._showCursor(),this.coreService.triggerDataEvent(t,!0),this._keyPressHandled=!0,this._unprocessedDeadKey=!1,0))}_inputEvent(e){if(e.data&&"insertText"===e.inputType&&(!e.composed||!this._keyDownSeen)&&!this.optionsService.rawOptions.screenReaderMode){if(this._keyPressHandled)return!1;this._unprocessedDeadKey=!1;const t=e.data;return this.coreService.triggerDataEvent(t,!0),this.cancel(e),!0}return!1}resize(e,t){e!==this.cols||t!==this.rows?super.resize(e,t):this._charSizeService&&!this._charSizeService.hasValidSize&&this._charSizeService.measure()}_afterResize(e,t){var i,s;null===(i=this._charSizeService)||void 0===i||i.measure(),null===(s=this.viewport)||void 0===s||s.syncScrollArea(!0)}clear(){var e;if(0!==this.buffer.ybase||0!==this.buffer.y){this.buffer.clearAllMarkers(),this.buffer.lines.set(0,this.buffer.lines.get(this.buffer.ybase+this.buffer.y)),this.buffer.lines.length=1,this.buffer.ydisp=0,this.buffer.ybase=0,this.buffer.y=0;for(let e=1;e{Object.defineProperty(t,"__esModule",{value:!0}),t.TimeBasedDebouncer=void 0,t.TimeBasedDebouncer=class{constructor(e,t=1e3){this._renderCallback=e,this._debounceThresholdMS=t,this._lastRefreshMs=0,this._additionalRefreshRequested=!1}dispose(){this._refreshTimeoutID&&clearTimeout(this._refreshTimeoutID)}refresh(e,t,i){this._rowCount=i,e=void 0!==e?e:0,t=void 0!==t?t:this._rowCount-1,this._rowStart=void 0!==this._rowStart?Math.min(this._rowStart,e):e,this._rowEnd=void 0!==this._rowEnd?Math.max(this._rowEnd,t):t;const s=Date.now();if(s-this._lastRefreshMs>=this._debounceThresholdMS)this._lastRefreshMs=s,this._innerRefresh();else if(!this._additionalRefreshRequested){const e=s-this._lastRefreshMs,t=this._debounceThresholdMS-e;this._additionalRefreshRequested=!0,this._refreshTimeoutID=window.setTimeout((()=>{this._lastRefreshMs=Date.now(),this._innerRefresh(),this._additionalRefreshRequested=!1,this._refreshTimeoutID=void 0}),t)}}_innerRefresh(){if(void 0===this._rowStart||void 0===this._rowEnd||void 0===this._rowCount)return;const e=Math.max(this._rowStart,0),t=Math.min(this._rowEnd,this._rowCount-1);this._rowStart=void 0,this._rowEnd=void 0,this._renderCallback(e,t)}}},1680:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.Viewport=void 0;const n=i(3656),o=i(4725),a=i(8460),h=i(844),c=i(2585);let l=t.Viewport=class extends h.Disposable{constructor(e,t,i,s,r,o,h,c){super(),this._viewportElement=e,this._scrollArea=t,this._bufferService=i,this._optionsService=s,this._charSizeService=r,this._renderService=o,this._coreBrowserService=h,this.scrollBarWidth=0,this._currentRowHeight=0,this._currentDeviceCellHeight=0,this._lastRecordedBufferLength=0,this._lastRecordedViewportHeight=0,this._lastRecordedBufferHeight=0,this._lastTouchY=0,this._lastScrollTop=0,this._wheelPartialScroll=0,this._refreshAnimationFrame=null,this._ignoreNextScrollEvent=!1,this._smoothScrollState={startTime:0,origin:-1,target:-1},this._onRequestScrollLines=this.register(new a.EventEmitter),this.onRequestScrollLines=this._onRequestScrollLines.event,this.scrollBarWidth=this._viewportElement.offsetWidth-this._scrollArea.offsetWidth||15,this.register((0,n.addDisposableDomListener)(this._viewportElement,"scroll",this._handleScroll.bind(this))),this._activeBuffer=this._bufferService.buffer,this.register(this._bufferService.buffers.onBufferActivate((e=>this._activeBuffer=e.activeBuffer))),this._renderDimensions=this._renderService.dimensions,this.register(this._renderService.onDimensionsChange((e=>this._renderDimensions=e))),this._handleThemeChange(c.colors),this.register(c.onChangeColors((e=>this._handleThemeChange(e)))),this.register(this._optionsService.onSpecificOptionChange("scrollback",(()=>this.syncScrollArea()))),setTimeout((()=>this.syncScrollArea()))}_handleThemeChange(e){this._viewportElement.style.backgroundColor=e.background.css}reset(){this._currentRowHeight=0,this._currentDeviceCellHeight=0,this._lastRecordedBufferLength=0,this._lastRecordedViewportHeight=0,this._lastRecordedBufferHeight=0,this._lastTouchY=0,this._lastScrollTop=0,this._coreBrowserService.window.requestAnimationFrame((()=>this.syncScrollArea()))}_refresh(e){if(e)return this._innerRefresh(),void(null!==this._refreshAnimationFrame&&this._coreBrowserService.window.cancelAnimationFrame(this._refreshAnimationFrame));null===this._refreshAnimationFrame&&(this._refreshAnimationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>this._innerRefresh())))}_innerRefresh(){if(this._charSizeService.height>0){this._currentRowHeight=this._renderService.dimensions.device.cell.height/this._coreBrowserService.dpr,this._currentDeviceCellHeight=this._renderService.dimensions.device.cell.height,this._lastRecordedViewportHeight=this._viewportElement.offsetHeight;const e=Math.round(this._currentRowHeight*this._lastRecordedBufferLength)+(this._lastRecordedViewportHeight-this._renderService.dimensions.css.canvas.height);this._lastRecordedBufferHeight!==e&&(this._lastRecordedBufferHeight=e,this._scrollArea.style.height=this._lastRecordedBufferHeight+"px")}const e=this._bufferService.buffer.ydisp*this._currentRowHeight;this._viewportElement.scrollTop!==e&&(this._ignoreNextScrollEvent=!0,this._viewportElement.scrollTop=e),this._refreshAnimationFrame=null}syncScrollArea(e=!1){if(this._lastRecordedBufferLength!==this._bufferService.buffer.lines.length)return this._lastRecordedBufferLength=this._bufferService.buffer.lines.length,void this._refresh(e);this._lastRecordedViewportHeight===this._renderService.dimensions.css.canvas.height&&this._lastScrollTop===this._activeBuffer.ydisp*this._currentRowHeight&&this._renderDimensions.device.cell.height===this._currentDeviceCellHeight||this._refresh(e)}_handleScroll(e){if(this._lastScrollTop=this._viewportElement.scrollTop,!this._viewportElement.offsetParent)return;if(this._ignoreNextScrollEvent)return this._ignoreNextScrollEvent=!1,void this._onRequestScrollLines.fire({amount:0,suppressScrollEvent:!0});const t=Math.round(this._lastScrollTop/this._currentRowHeight)-this._bufferService.buffer.ydisp;this._onRequestScrollLines.fire({amount:t,suppressScrollEvent:!0})}_smoothScroll(){if(this._isDisposed||-1===this._smoothScrollState.origin||-1===this._smoothScrollState.target)return;const e=this._smoothScrollPercent();this._viewportElement.scrollTop=this._smoothScrollState.origin+Math.round(e*(this._smoothScrollState.target-this._smoothScrollState.origin)),e<1?this._coreBrowserService.window.requestAnimationFrame((()=>this._smoothScroll())):this._clearSmoothScrollState()}_smoothScrollPercent(){return this._optionsService.rawOptions.smoothScrollDuration&&this._smoothScrollState.startTime?Math.max(Math.min((Date.now()-this._smoothScrollState.startTime)/this._optionsService.rawOptions.smoothScrollDuration,1),0):1}_clearSmoothScrollState(){this._smoothScrollState.startTime=0,this._smoothScrollState.origin=-1,this._smoothScrollState.target=-1}_bubbleScroll(e,t){const i=this._viewportElement.scrollTop+this._lastRecordedViewportHeight;return!(t<0&&0!==this._viewportElement.scrollTop||t>0&&i0&&(s=e),r=""}}return{bufferElements:n,cursorElement:s}}getLinesScrolled(e){if(0===e.deltaY||e.shiftKey)return 0;let t=this._applyScrollModifier(e.deltaY,e);return e.deltaMode===WheelEvent.DOM_DELTA_PIXEL?(t/=this._currentRowHeight+0,this._wheelPartialScroll+=t,t=Math.floor(Math.abs(this._wheelPartialScroll))*(this._wheelPartialScroll>0?1:-1),this._wheelPartialScroll%=1):e.deltaMode===WheelEvent.DOM_DELTA_PAGE&&(t*=this._bufferService.rows),t}_applyScrollModifier(e,t){const i=this._optionsService.rawOptions.fastScrollModifier;return"alt"===i&&t.altKey||"ctrl"===i&&t.ctrlKey||"shift"===i&&t.shiftKey?e*this._optionsService.rawOptions.fastScrollSensitivity*this._optionsService.rawOptions.scrollSensitivity:e*this._optionsService.rawOptions.scrollSensitivity}handleTouchStart(e){this._lastTouchY=e.touches[0].pageY}handleTouchMove(e){const t=this._lastTouchY-e.touches[0].pageY;return this._lastTouchY=e.touches[0].pageY,0!==t&&(this._viewportElement.scrollTop+=t,this._bubbleScroll(e,t))}};t.Viewport=l=s([r(2,c.IBufferService),r(3,c.IOptionsService),r(4,o.ICharSizeService),r(5,o.IRenderService),r(6,o.ICoreBrowserService),r(7,o.IThemeService)],l)},3107:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.BufferDecorationRenderer=void 0;const n=i(3656),o=i(4725),a=i(844),h=i(2585);let c=t.BufferDecorationRenderer=class extends a.Disposable{constructor(e,t,i,s){super(),this._screenElement=e,this._bufferService=t,this._decorationService=i,this._renderService=s,this._decorationElements=new Map,this._altBufferIsActive=!1,this._dimensionsChanged=!1,this._container=document.createElement("div"),this._container.classList.add("xterm-decoration-container"),this._screenElement.appendChild(this._container),this.register(this._renderService.onRenderedViewportChange((()=>this._doRefreshDecorations()))),this.register(this._renderService.onDimensionsChange((()=>{this._dimensionsChanged=!0,this._queueRefresh()}))),this.register((0,n.addDisposableDomListener)(window,"resize",(()=>this._queueRefresh()))),this.register(this._bufferService.buffers.onBufferActivate((()=>{this._altBufferIsActive=this._bufferService.buffer===this._bufferService.buffers.alt}))),this.register(this._decorationService.onDecorationRegistered((()=>this._queueRefresh()))),this.register(this._decorationService.onDecorationRemoved((e=>this._removeDecoration(e)))),this.register((0,a.toDisposable)((()=>{this._container.remove(),this._decorationElements.clear()})))}_queueRefresh(){void 0===this._animationFrame&&(this._animationFrame=this._renderService.addRefreshCallback((()=>{this._doRefreshDecorations(),this._animationFrame=void 0})))}_doRefreshDecorations(){for(const e of this._decorationService.decorations)this._renderDecoration(e);this._dimensionsChanged=!1}_renderDecoration(e){this._refreshStyle(e),this._dimensionsChanged&&this._refreshXPosition(e)}_createElement(e){var t,i;const s=document.createElement("div");s.classList.add("xterm-decoration"),s.classList.toggle("xterm-decoration-top-layer","top"===(null===(t=null==e?void 0:e.options)||void 0===t?void 0:t.layer)),s.style.width=`${Math.round((e.options.width||1)*this._renderService.dimensions.css.cell.width)}px`,s.style.height=(e.options.height||1)*this._renderService.dimensions.css.cell.height+"px",s.style.top=(e.marker.line-this._bufferService.buffers.active.ydisp)*this._renderService.dimensions.css.cell.height+"px",s.style.lineHeight=`${this._renderService.dimensions.css.cell.height}px`;const r=null!==(i=e.options.x)&&void 0!==i?i:0;return r&&r>this._bufferService.cols&&(s.style.display="none"),this._refreshXPosition(e,s),s}_refreshStyle(e){const t=e.marker.line-this._bufferService.buffers.active.ydisp;if(t<0||t>=this._bufferService.rows)e.element&&(e.element.style.display="none",e.onRenderEmitter.fire(e.element));else{let i=this._decorationElements.get(e);i||(i=this._createElement(e),e.element=i,this._decorationElements.set(e,i),this._container.appendChild(i),e.onDispose((()=>{this._decorationElements.delete(e),i.remove()}))),i.style.top=t*this._renderService.dimensions.css.cell.height+"px",i.style.display=this._altBufferIsActive?"none":"block",e.onRenderEmitter.fire(i)}}_refreshXPosition(e,t=e.element){var i;if(!t)return;const s=null!==(i=e.options.x)&&void 0!==i?i:0;"right"===(e.options.anchor||"left")?t.style.right=s?s*this._renderService.dimensions.css.cell.width+"px":"":t.style.left=s?s*this._renderService.dimensions.css.cell.width+"px":""}_removeDecoration(e){var t;null===(t=this._decorationElements.get(e))||void 0===t||t.remove(),this._decorationElements.delete(e),e.dispose()}};t.BufferDecorationRenderer=c=s([r(1,h.IBufferService),r(2,h.IDecorationService),r(3,o.IRenderService)],c)},5871:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ColorZoneStore=void 0,t.ColorZoneStore=class{constructor(){this._zones=[],this._zonePool=[],this._zonePoolIndex=0,this._linePadding={full:0,left:0,center:0,right:0}}get zones(){return this._zonePool.length=Math.min(this._zonePool.length,this._zones.length),this._zones}clear(){this._zones.length=0,this._zonePoolIndex=0}addDecoration(e){if(e.options.overviewRulerOptions){for(const t of this._zones)if(t.color===e.options.overviewRulerOptions.color&&t.position===e.options.overviewRulerOptions.position){if(this._lineIntersectsZone(t,e.marker.line))return;if(this._lineAdjacentToZone(t,e.marker.line,e.options.overviewRulerOptions.position))return void this._addLineToZone(t,e.marker.line)}if(this._zonePoolIndex=e.startBufferLine&&t<=e.endBufferLine}_lineAdjacentToZone(e,t,i){return t>=e.startBufferLine-this._linePadding[i||"full"]&&t<=e.endBufferLine+this._linePadding[i||"full"]}_addLineToZone(e,t){e.startBufferLine=Math.min(e.startBufferLine,t),e.endBufferLine=Math.max(e.endBufferLine,t)}}},5744:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.OverviewRulerRenderer=void 0;const n=i(5871),o=i(3656),a=i(4725),h=i(844),c=i(2585),l={full:0,left:0,center:0,right:0},d={full:0,left:0,center:0,right:0},_={full:0,left:0,center:0,right:0};let u=t.OverviewRulerRenderer=class extends h.Disposable{get _width(){return this._optionsService.options.overviewRulerWidth||0}constructor(e,t,i,s,r,o,a){var c;super(),this._viewportElement=e,this._screenElement=t,this._bufferService=i,this._decorationService=s,this._renderService=r,this._optionsService=o,this._coreBrowseService=a,this._colorZoneStore=new n.ColorZoneStore,this._shouldUpdateDimensions=!0,this._shouldUpdateAnchor=!0,this._lastKnownBufferLength=0,this._canvas=document.createElement("canvas"),this._canvas.classList.add("xterm-decoration-overview-ruler"),this._refreshCanvasDimensions(),null===(c=this._viewportElement.parentElement)||void 0===c||c.insertBefore(this._canvas,this._viewportElement);const l=this._canvas.getContext("2d");if(!l)throw new Error("Ctx cannot be null");this._ctx=l,this._registerDecorationListeners(),this._registerBufferChangeListeners(),this._registerDimensionChangeListeners(),this.register((0,h.toDisposable)((()=>{var e;null===(e=this._canvas)||void 0===e||e.remove()})))}_registerDecorationListeners(){this.register(this._decorationService.onDecorationRegistered((()=>this._queueRefresh(void 0,!0)))),this.register(this._decorationService.onDecorationRemoved((()=>this._queueRefresh(void 0,!0))))}_registerBufferChangeListeners(){this.register(this._renderService.onRenderedViewportChange((()=>this._queueRefresh()))),this.register(this._bufferService.buffers.onBufferActivate((()=>{this._canvas.style.display=this._bufferService.buffer===this._bufferService.buffers.alt?"none":"block"}))),this.register(this._bufferService.onScroll((()=>{this._lastKnownBufferLength!==this._bufferService.buffers.normal.lines.length&&(this._refreshDrawHeightConstants(),this._refreshColorZonePadding())})))}_registerDimensionChangeListeners(){this.register(this._renderService.onRender((()=>{this._containerHeight&&this._containerHeight===this._screenElement.clientHeight||(this._queueRefresh(!0),this._containerHeight=this._screenElement.clientHeight)}))),this.register(this._optionsService.onSpecificOptionChange("overviewRulerWidth",(()=>this._queueRefresh(!0)))),this.register((0,o.addDisposableDomListener)(this._coreBrowseService.window,"resize",(()=>this._queueRefresh(!0)))),this._queueRefresh(!0)}_refreshDrawConstants(){const e=Math.floor(this._canvas.width/3),t=Math.ceil(this._canvas.width/3);d.full=this._canvas.width,d.left=e,d.center=t,d.right=e,this._refreshDrawHeightConstants(),_.full=0,_.left=0,_.center=d.left,_.right=d.left+d.center}_refreshDrawHeightConstants(){l.full=Math.round(2*this._coreBrowseService.dpr);const e=this._canvas.height/this._bufferService.buffer.lines.length,t=Math.round(Math.max(Math.min(e,12),6)*this._coreBrowseService.dpr);l.left=t,l.center=t,l.right=t}_refreshColorZonePadding(){this._colorZoneStore.setPadding({full:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*l.full),left:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*l.left),center:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*l.center),right:Math.floor(this._bufferService.buffers.active.lines.length/(this._canvas.height-1)*l.right)}),this._lastKnownBufferLength=this._bufferService.buffers.normal.lines.length}_refreshCanvasDimensions(){this._canvas.style.width=`${this._width}px`,this._canvas.width=Math.round(this._width*this._coreBrowseService.dpr),this._canvas.style.height=`${this._screenElement.clientHeight}px`,this._canvas.height=Math.round(this._screenElement.clientHeight*this._coreBrowseService.dpr),this._refreshDrawConstants(),this._refreshColorZonePadding()}_refreshDecorations(){this._shouldUpdateDimensions&&this._refreshCanvasDimensions(),this._ctx.clearRect(0,0,this._canvas.width,this._canvas.height),this._colorZoneStore.clear();for(const e of this._decorationService.decorations)this._colorZoneStore.addDecoration(e);this._ctx.lineWidth=1;const e=this._colorZoneStore.zones;for(const t of e)"full"!==t.position&&this._renderColorZone(t);for(const t of e)"full"===t.position&&this._renderColorZone(t);this._shouldUpdateDimensions=!1,this._shouldUpdateAnchor=!1}_renderColorZone(e){this._ctx.fillStyle=e.color,this._ctx.fillRect(_[e.position||"full"],Math.round((this._canvas.height-1)*(e.startBufferLine/this._bufferService.buffers.active.lines.length)-l[e.position||"full"]/2),d[e.position||"full"],Math.round((this._canvas.height-1)*((e.endBufferLine-e.startBufferLine)/this._bufferService.buffers.active.lines.length)+l[e.position||"full"]))}_queueRefresh(e,t){this._shouldUpdateDimensions=e||this._shouldUpdateDimensions,this._shouldUpdateAnchor=t||this._shouldUpdateAnchor,void 0===this._animationFrame&&(this._animationFrame=this._coreBrowseService.window.requestAnimationFrame((()=>{this._refreshDecorations(),this._animationFrame=void 0})))}};t.OverviewRulerRenderer=u=s([r(2,c.IBufferService),r(3,c.IDecorationService),r(4,a.IRenderService),r(5,c.IOptionsService),r(6,a.ICoreBrowserService)],u)},2950:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CompositionHelper=void 0;const n=i(4725),o=i(2585),a=i(2584);let h=t.CompositionHelper=class{get isComposing(){return this._isComposing}constructor(e,t,i,s,r,n){this._textarea=e,this._compositionView=t,this._bufferService=i,this._optionsService=s,this._coreService=r,this._renderService=n,this._isComposing=!1,this._isSendingComposition=!1,this._compositionPosition={start:0,end:0},this._dataAlreadySent=""}compositionstart(){this._isComposing=!0,this._compositionPosition.start=this._textarea.value.length,this._compositionView.textContent="",this._dataAlreadySent="",this._compositionView.classList.add("active")}compositionupdate(e){this._compositionView.textContent=e.data,this.updateCompositionElements(),setTimeout((()=>{this._compositionPosition.end=this._textarea.value.length}),0)}compositionend(){this._finalizeComposition(!0)}keydown(e){if(this._isComposing||this._isSendingComposition){if(229===e.keyCode)return!1;if(16===e.keyCode||17===e.keyCode||18===e.keyCode)return!1;this._finalizeComposition(!1)}return 229!==e.keyCode||(this._handleAnyTextareaChanges(),!1)}_finalizeComposition(e){if(this._compositionView.classList.remove("active"),this._isComposing=!1,e){const e={start:this._compositionPosition.start,end:this._compositionPosition.end};this._isSendingComposition=!0,setTimeout((()=>{if(this._isSendingComposition){let t;this._isSendingComposition=!1,e.start+=this._dataAlreadySent.length,t=this._isComposing?this._textarea.value.substring(e.start,e.end):this._textarea.value.substring(e.start),t.length>0&&this._coreService.triggerDataEvent(t,!0)}}),0)}else{this._isSendingComposition=!1;const e=this._textarea.value.substring(this._compositionPosition.start,this._compositionPosition.end);this._coreService.triggerDataEvent(e,!0)}}_handleAnyTextareaChanges(){const e=this._textarea.value;setTimeout((()=>{if(!this._isComposing){const t=this._textarea.value,i=t.replace(e,"");this._dataAlreadySent=i,t.length>e.length?this._coreService.triggerDataEvent(i,!0):t.lengththis.updateCompositionElements(!0)),0)}}};t.CompositionHelper=h=s([r(2,o.IBufferService),r(3,o.IOptionsService),r(4,o.ICoreService),r(5,n.IRenderService)],h)},9806:(e,t)=>{function i(e,t,i){const s=i.getBoundingClientRect(),r=e.getComputedStyle(i),n=parseInt(r.getPropertyValue("padding-left")),o=parseInt(r.getPropertyValue("padding-top"));return[t.clientX-s.left-n,t.clientY-s.top-o]}Object.defineProperty(t,"__esModule",{value:!0}),t.getCoords=t.getCoordsRelativeToElement=void 0,t.getCoordsRelativeToElement=i,t.getCoords=function(e,t,s,r,n,o,a,h,c){if(!o)return;const l=i(e,t,s);return l?(l[0]=Math.ceil((l[0]+(c?a/2:0))/a),l[1]=Math.ceil(l[1]/h),l[0]=Math.min(Math.max(l[0],1),r+(c?1:0)),l[1]=Math.min(Math.max(l[1],1),n),l):void 0}},9504:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.moveToCellSequence=void 0;const s=i(2584);function r(e,t,i,s){const r=e-n(e,i),a=t-n(t,i),l=Math.abs(r-a)-function(e,t,i){let s=0;const r=e-n(e,i),a=t-n(t,i);for(let n=0;n=0&&et?"A":"B"}function a(e,t,i,s,r,n){let o=e,a=t,h="";for(;o!==i||a!==s;)o+=r?1:-1,r&&o>n.cols-1?(h+=n.buffer.translateBufferLineToString(a,!1,e,o),o=0,e=0,a++):!r&&o<0&&(h+=n.buffer.translateBufferLineToString(a,!1,0,e+1),o=n.cols-1,e=o,a--);return h+n.buffer.translateBufferLineToString(a,!1,e,o)}function h(e,t){const i=t?"O":"[";return s.C0.ESC+i+e}function c(e,t){e=Math.floor(e);let i="";for(let s=0;s0?s-n(s,o):t;const _=s,u=function(e,t,i,s,o,a){let h;return h=r(i,s,o,a).length>0?s-n(s,o):t,e=i&&he?"D":"C",c(Math.abs(o-e),h(d,s));d=l>t?"D":"C";const _=Math.abs(l-t);return c(function(e,t){return t.cols-e}(l>t?e:o,i)+(_-1)*i.cols+1+((l>t?o:e)-1),h(d,s))}},1296:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.DomRenderer=void 0;const n=i(3787),o=i(2550),a=i(2223),h=i(6171),c=i(4725),l=i(8055),d=i(8460),_=i(844),u=i(2585),f="xterm-dom-renderer-owner-",v="xterm-rows",p="xterm-fg-",g="xterm-bg-",m="xterm-focus",S="xterm-selection";let C=1,b=t.DomRenderer=class extends _.Disposable{constructor(e,t,i,s,r,a,c,l,u,p){super(),this._element=e,this._screenElement=t,this._viewportElement=i,this._linkifier2=s,this._charSizeService=a,this._optionsService=c,this._bufferService=l,this._coreBrowserService=u,this._themeService=p,this._terminalClass=C++,this._rowElements=[],this.onRequestRedraw=this.register(new d.EventEmitter).event,this._rowContainer=document.createElement("div"),this._rowContainer.classList.add(v),this._rowContainer.style.lineHeight="normal",this._rowContainer.setAttribute("aria-hidden","true"),this._refreshRowElements(this._bufferService.cols,this._bufferService.rows),this._selectionContainer=document.createElement("div"),this._selectionContainer.classList.add(S),this._selectionContainer.setAttribute("aria-hidden","true"),this.dimensions=(0,h.createRenderDimensions)(),this._updateDimensions(),this.register(this._optionsService.onOptionChange((()=>this._handleOptionsChanged()))),this.register(this._themeService.onChangeColors((e=>this._injectCss(e)))),this._injectCss(this._themeService.colors),this._rowFactory=r.createInstance(n.DomRendererRowFactory,document),this._element.classList.add(f+this._terminalClass),this._screenElement.appendChild(this._rowContainer),this._screenElement.appendChild(this._selectionContainer),this.register(this._linkifier2.onShowLinkUnderline((e=>this._handleLinkHover(e)))),this.register(this._linkifier2.onHideLinkUnderline((e=>this._handleLinkLeave(e)))),this.register((0,_.toDisposable)((()=>{this._element.classList.remove(f+this._terminalClass),this._rowContainer.remove(),this._selectionContainer.remove(),this._widthCache.dispose(),this._themeStyleElement.remove(),this._dimensionsStyleElement.remove()}))),this._widthCache=new o.WidthCache(document),this._widthCache.setFont(this._optionsService.rawOptions.fontFamily,this._optionsService.rawOptions.fontSize,this._optionsService.rawOptions.fontWeight,this._optionsService.rawOptions.fontWeightBold),this._setDefaultSpacing()}_updateDimensions(){const e=this._coreBrowserService.dpr;this.dimensions.device.char.width=this._charSizeService.width*e,this.dimensions.device.char.height=Math.ceil(this._charSizeService.height*e),this.dimensions.device.cell.width=this.dimensions.device.char.width+Math.round(this._optionsService.rawOptions.letterSpacing),this.dimensions.device.cell.height=Math.floor(this.dimensions.device.char.height*this._optionsService.rawOptions.lineHeight),this.dimensions.device.char.left=0,this.dimensions.device.char.top=0,this.dimensions.device.canvas.width=this.dimensions.device.cell.width*this._bufferService.cols,this.dimensions.device.canvas.height=this.dimensions.device.cell.height*this._bufferService.rows,this.dimensions.css.canvas.width=Math.round(this.dimensions.device.canvas.width/e),this.dimensions.css.canvas.height=Math.round(this.dimensions.device.canvas.height/e),this.dimensions.css.cell.width=this.dimensions.css.canvas.width/this._bufferService.cols,this.dimensions.css.cell.height=this.dimensions.css.canvas.height/this._bufferService.rows;for(const e of this._rowElements)e.style.width=`${this.dimensions.css.canvas.width}px`,e.style.height=`${this.dimensions.css.cell.height}px`,e.style.lineHeight=`${this.dimensions.css.cell.height}px`,e.style.overflow="hidden";this._dimensionsStyleElement||(this._dimensionsStyleElement=document.createElement("style"),this._screenElement.appendChild(this._dimensionsStyleElement));const t=`${this._terminalSelector} .${v} span { display: inline-block; height: 100%; vertical-align: top;}`;this._dimensionsStyleElement.textContent=t,this._selectionContainer.style.height=this._viewportElement.style.height,this._screenElement.style.width=`${this.dimensions.css.canvas.width}px`,this._screenElement.style.height=`${this.dimensions.css.canvas.height}px`}_injectCss(e){this._themeStyleElement||(this._themeStyleElement=document.createElement("style"),this._screenElement.appendChild(this._themeStyleElement));let t=`${this._terminalSelector} .${v} { color: ${e.foreground.css}; font-family: ${this._optionsService.rawOptions.fontFamily}; font-size: ${this._optionsService.rawOptions.fontSize}px; font-kerning: none; white-space: pre}`;t+=`${this._terminalSelector} .${v} .xterm-dim { color: ${l.color.multiplyOpacity(e.foreground,.5).css};}`,t+=`${this._terminalSelector} span:not(.xterm-bold) { font-weight: ${this._optionsService.rawOptions.fontWeight};}${this._terminalSelector} span.xterm-bold { font-weight: ${this._optionsService.rawOptions.fontWeightBold};}${this._terminalSelector} span.xterm-italic { font-style: italic;}`,t+="@keyframes blink_box_shadow_"+this._terminalClass+" { 50% { border-bottom-style: hidden; }}",t+="@keyframes blink_block_"+this._terminalClass+" { 0% {"+` background-color: ${e.cursor.css};`+` color: ${e.cursorAccent.css}; } 50% { background-color: inherit;`+` color: ${e.cursor.css}; }}`,t+=`${this._terminalSelector} .${v}.${m} .xterm-cursor.xterm-cursor-blink:not(.xterm-cursor-block) { animation: blink_box_shadow_`+this._terminalClass+" 1s step-end infinite;}"+`${this._terminalSelector} .${v}.${m} .xterm-cursor.xterm-cursor-blink.xterm-cursor-block { animation: blink_block_`+this._terminalClass+" 1s step-end infinite;}"+`${this._terminalSelector} .${v} .xterm-cursor.xterm-cursor-block {`+` background-color: ${e.cursor.css};`+` color: ${e.cursorAccent.css};}`+`${this._terminalSelector} .${v} .xterm-cursor.xterm-cursor-outline {`+` outline: 1px solid ${e.cursor.css}; outline-offset: -1px;}`+`${this._terminalSelector} .${v} .xterm-cursor.xterm-cursor-bar {`+` box-shadow: ${this._optionsService.rawOptions.cursorWidth}px 0 0 ${e.cursor.css} inset;}`+`${this._terminalSelector} .${v} .xterm-cursor.xterm-cursor-underline {`+` border-bottom: 1px ${e.cursor.css}; border-bottom-style: solid; height: calc(100% - 1px);}`,t+=`${this._terminalSelector} .${S} { position: absolute; top: 0; left: 0; z-index: 1; pointer-events: none;}${this._terminalSelector}.focus .${S} div { position: absolute; background-color: ${e.selectionBackgroundOpaque.css};}${this._terminalSelector} .${S} div { position: absolute; background-color: ${e.selectionInactiveBackgroundOpaque.css};}`;for(const[i,s]of e.ansi.entries())t+=`${this._terminalSelector} .${p}${i} { color: ${s.css}; }${this._terminalSelector} .${p}${i}.xterm-dim { color: ${l.color.multiplyOpacity(s,.5).css}; }${this._terminalSelector} .${g}${i} { background-color: ${s.css}; }`;t+=`${this._terminalSelector} .${p}${a.INVERTED_DEFAULT_COLOR} { color: ${l.color.opaque(e.background).css}; }${this._terminalSelector} .${p}${a.INVERTED_DEFAULT_COLOR}.xterm-dim { color: ${l.color.multiplyOpacity(l.color.opaque(e.background),.5).css}; }${this._terminalSelector} .${g}${a.INVERTED_DEFAULT_COLOR} { background-color: ${e.foreground.css}; }`,this._themeStyleElement.textContent=t}_setDefaultSpacing(){const e=this.dimensions.css.cell.width-this._widthCache.get("W",!1,!1);this._rowContainer.style.letterSpacing=`${e}px`,this._rowFactory.defaultSpacing=e}handleDevicePixelRatioChange(){this._updateDimensions(),this._widthCache.clear(),this._setDefaultSpacing()}_refreshRowElements(e,t){for(let e=this._rowElements.length;e<=t;e++){const e=document.createElement("div");this._rowContainer.appendChild(e),this._rowElements.push(e)}for(;this._rowElements.length>t;)this._rowContainer.removeChild(this._rowElements.pop())}handleResize(e,t){this._refreshRowElements(e,t),this._updateDimensions()}handleCharSizeChanged(){this._updateDimensions(),this._widthCache.clear(),this._setDefaultSpacing()}handleBlur(){this._rowContainer.classList.remove(m)}handleFocus(){this._rowContainer.classList.add(m),this.renderRows(this._bufferService.buffer.y,this._bufferService.buffer.y)}handleSelectionChanged(e,t,i){if(this._selectionContainer.replaceChildren(),this._rowFactory.handleSelectionChanged(e,t,i),this.renderRows(0,this._bufferService.rows-1),!e||!t)return;const s=e[1]-this._bufferService.buffer.ydisp,r=t[1]-this._bufferService.buffer.ydisp,n=Math.max(s,0),o=Math.min(r,this._bufferService.rows-1);if(n>=this._bufferService.rows||o<0)return;const a=document.createDocumentFragment();if(i){const i=e[0]>t[0];a.appendChild(this._createSelectionElement(n,i?t[0]:e[0],i?e[0]:t[0],o-n+1))}else{const i=s===n?e[0]:0,h=n===r?t[0]:this._bufferService.cols;a.appendChild(this._createSelectionElement(n,i,h));const c=o-n-1;if(a.appendChild(this._createSelectionElement(n+1,0,this._bufferService.cols,c)),n!==o){const e=r===o?t[0]:this._bufferService.cols;a.appendChild(this._createSelectionElement(o,0,e))}}this._selectionContainer.appendChild(a)}_createSelectionElement(e,t,i,s=1){const r=document.createElement("div");return r.style.height=s*this.dimensions.css.cell.height+"px",r.style.top=e*this.dimensions.css.cell.height+"px",r.style.left=t*this.dimensions.css.cell.width+"px",r.style.width=this.dimensions.css.cell.width*(i-t)+"px",r}handleCursorMove(){}_handleOptionsChanged(){this._updateDimensions(),this._injectCss(this._themeService.colors),this._widthCache.setFont(this._optionsService.rawOptions.fontFamily,this._optionsService.rawOptions.fontSize,this._optionsService.rawOptions.fontWeight,this._optionsService.rawOptions.fontWeightBold),this._setDefaultSpacing()}clear(){for(const e of this._rowElements)e.replaceChildren()}renderRows(e,t){const i=this._bufferService.buffer,s=i.ybase+i.y,r=Math.min(i.x,this._bufferService.cols-1),n=this._optionsService.rawOptions.cursorBlink,o=this._optionsService.rawOptions.cursorStyle,a=this._optionsService.rawOptions.cursorInactiveStyle;for(let h=e;h<=t;h++){const e=h+i.ydisp,t=this._rowElements[h],c=i.lines.get(e);if(!t||!c)break;t.replaceChildren(...this._rowFactory.createRow(c,e,e===s,o,a,r,n,this.dimensions.css.cell.width,this._widthCache,-1,-1))}}get _terminalSelector(){return`.${f}${this._terminalClass}`}_handleLinkHover(e){this._setCellUnderline(e.x1,e.x2,e.y1,e.y2,e.cols,!0)}_handleLinkLeave(e){this._setCellUnderline(e.x1,e.x2,e.y1,e.y2,e.cols,!1)}_setCellUnderline(e,t,i,s,r,n){i<0&&(e=0),s<0&&(t=0);const o=this._bufferService.rows-1;i=Math.max(Math.min(i,o),0),s=Math.max(Math.min(s,o),0),r=Math.min(r,this._bufferService.cols);const a=this._bufferService.buffer,h=a.ybase+a.y,c=Math.min(a.x,r-1),l=this._optionsService.rawOptions.cursorBlink,d=this._optionsService.rawOptions.cursorStyle,_=this._optionsService.rawOptions.cursorInactiveStyle;for(let o=i;o<=s;++o){const u=o+a.ydisp,f=this._rowElements[o],v=a.lines.get(u);if(!f||!v)break;f.replaceChildren(...this._rowFactory.createRow(v,u,u===h,d,_,c,l,this.dimensions.css.cell.width,this._widthCache,n?o===i?e:0:-1,n?(o===s?t:r)-1:-1))}}};t.DomRenderer=b=s([r(4,u.IInstantiationService),r(5,c.ICharSizeService),r(6,u.IOptionsService),r(7,u.IBufferService),r(8,c.ICoreBrowserService),r(9,c.IThemeService)],b)},3787:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.DomRendererRowFactory=void 0;const n=i(2223),o=i(643),a=i(511),h=i(2585),c=i(8055),l=i(4725),d=i(4269),_=i(6171),u=i(3734);let f=t.DomRendererRowFactory=class{constructor(e,t,i,s,r,n,o){this._document=e,this._characterJoinerService=t,this._optionsService=i,this._coreBrowserService=s,this._coreService=r,this._decorationService=n,this._themeService=o,this._workCell=new a.CellData,this._columnSelectMode=!1,this.defaultSpacing=0}handleSelectionChanged(e,t,i){this._selectionStart=e,this._selectionEnd=t,this._columnSelectMode=i}createRow(e,t,i,s,r,a,h,l,_,f,p){const g=[],m=this._characterJoinerService.getJoinedCharacters(t),S=this._themeService.colors;let C,b=e.getNoBgTrimmedLength();i&&b0&&M===m[0][0]){O=!0;const t=m.shift();I=new d.JoinedCellData(this._workCell,e.translateToString(!0,t[0],t[1]),t[1]-t[0]),P=t[1]-1,b=I.getWidth()}const H=this._isCellInSelection(M,t),F=i&&M===a,W=T&&M>=f&&M<=p;let U=!1;this._decorationService.forEachDecorationAtCell(M,t,void 0,(e=>{U=!0}));let N=I.getChars()||o.WHITESPACE_CELL_CHAR;if(" "===N&&(I.isUnderline()||I.isOverline())&&(N=" "),A=b*l-_.get(N,I.isBold(),I.isItalic()),C){if(y&&(H&&x||!H&&!x&&I.bg===E)&&(H&&x&&S.selectionForeground||I.fg===k)&&I.extended.ext===L&&W===D&&A===R&&!F&&!O&&!U){w+=N,y++;continue}y&&(C.textContent=w),C=this._document.createElement("span"),y=0,w=""}else C=this._document.createElement("span");if(E=I.bg,k=I.fg,L=I.extended.ext,D=W,R=A,x=H,O&&a>=M&&a<=P&&(a=M),!this._coreService.isCursorHidden&&F)if(B.push("xterm-cursor"),this._coreBrowserService.isFocused)h&&B.push("xterm-cursor-blink"),B.push("bar"===s?"xterm-cursor-bar":"underline"===s?"xterm-cursor-underline":"xterm-cursor-block");else if(r)switch(r){case"outline":B.push("xterm-cursor-outline");break;case"block":B.push("xterm-cursor-block");break;case"bar":B.push("xterm-cursor-bar");break;case"underline":B.push("xterm-cursor-underline")}if(I.isBold()&&B.push("xterm-bold"),I.isItalic()&&B.push("xterm-italic"),I.isDim()&&B.push("xterm-dim"),w=I.isInvisible()?o.WHITESPACE_CELL_CHAR:I.getChars()||o.WHITESPACE_CELL_CHAR,I.isUnderline()&&(B.push(`xterm-underline-${I.extended.underlineStyle}`)," "===w&&(w=" "),!I.isUnderlineColorDefault()))if(I.isUnderlineColorRGB())C.style.textDecorationColor=`rgb(${u.AttributeData.toColorRGB(I.getUnderlineColor()).join(",")})`;else{let e=I.getUnderlineColor();this._optionsService.rawOptions.drawBoldTextInBrightColors&&I.isBold()&&e<8&&(e+=8),C.style.textDecorationColor=S.ansi[e].css}I.isOverline()&&(B.push("xterm-overline")," "===w&&(w=" ")),I.isStrikethrough()&&B.push("xterm-strikethrough"),W&&(C.style.textDecoration="underline");let $=I.getFgColor(),j=I.getFgColorMode(),z=I.getBgColor(),K=I.getBgColorMode();const q=!!I.isInverse();if(q){const e=$;$=z,z=e;const t=j;j=K,K=t}let V,G,X,J=!1;switch(this._decorationService.forEachDecorationAtCell(M,t,void 0,(e=>{"top"!==e.options.layer&&J||(e.backgroundColorRGB&&(K=50331648,z=e.backgroundColorRGB.rgba>>8&16777215,V=e.backgroundColorRGB),e.foregroundColorRGB&&(j=50331648,$=e.foregroundColorRGB.rgba>>8&16777215,G=e.foregroundColorRGB),J="top"===e.options.layer)})),!J&&H&&(V=this._coreBrowserService.isFocused?S.selectionBackgroundOpaque:S.selectionInactiveBackgroundOpaque,z=V.rgba>>8&16777215,K=50331648,J=!0,S.selectionForeground&&(j=50331648,$=S.selectionForeground.rgba>>8&16777215,G=S.selectionForeground)),J&&B.push("xterm-decoration-top"),K){case 16777216:case 33554432:X=S.ansi[z],B.push(`xterm-bg-${z}`);break;case 50331648:X=c.rgba.toColor(z>>16,z>>8&255,255&z),this._addStyle(C,`background-color:#${v((z>>>0).toString(16),"0",6)}`);break;default:q?(X=S.foreground,B.push(`xterm-bg-${n.INVERTED_DEFAULT_COLOR}`)):X=S.background}switch(V||I.isDim()&&(V=c.color.multiplyOpacity(X,.5)),j){case 16777216:case 33554432:I.isBold()&&$<8&&this._optionsService.rawOptions.drawBoldTextInBrightColors&&($+=8),this._applyMinimumContrast(C,X,S.ansi[$],I,V,void 0)||B.push(`xterm-fg-${$}`);break;case 50331648:const e=c.rgba.toColor($>>16&255,$>>8&255,255&$);this._applyMinimumContrast(C,X,e,I,V,G)||this._addStyle(C,`color:#${v($.toString(16),"0",6)}`);break;default:this._applyMinimumContrast(C,X,S.foreground,I,V,void 0)||q&&B.push(`xterm-fg-${n.INVERTED_DEFAULT_COLOR}`)}B.length&&(C.className=B.join(" "),B.length=0),F||O||U?C.textContent=w:y++,A!==this.defaultSpacing&&(C.style.letterSpacing=`${A}px`),g.push(C),M=P}return C&&y&&(C.textContent=w),g}_applyMinimumContrast(e,t,i,s,r,n){if(1===this._optionsService.rawOptions.minimumContrastRatio||(0,_.excludeFromContrastRatioDemands)(s.getCode()))return!1;const o=this._getContrastCache(s);let a;if(r||n||(a=o.getColor(t.rgba,i.rgba)),void 0===a){const e=this._optionsService.rawOptions.minimumContrastRatio/(s.isDim()?2:1);a=c.color.ensureContrastRatio(r||t,n||i,e),o.setColor((r||t).rgba,(n||i).rgba,null!=a?a:null)}return!!a&&(this._addStyle(e,`color:${a.css}`),!0)}_getContrastCache(e){return e.isDim()?this._themeService.colors.halfContrastCache:this._themeService.colors.contrastCache}_addStyle(e,t){e.setAttribute("style",`${e.getAttribute("style")||""}${t};`)}_isCellInSelection(e,t){const i=this._selectionStart,s=this._selectionEnd;return!(!i||!s)&&(this._columnSelectMode?i[0]<=s[0]?e>=i[0]&&t>=i[1]&&e=i[1]&&e>=s[0]&&t<=s[1]:t>i[1]&&t=i[0]&&e=i[0])}};function v(e,t,i){for(;e.length{Object.defineProperty(t,"__esModule",{value:!0}),t.WidthCache=void 0,t.WidthCache=class{constructor(e){this._flat=new Float32Array(256),this._font="",this._fontSize=0,this._weight="normal",this._weightBold="bold",this._measureElements=[],this._container=e.createElement("div"),this._container.style.position="absolute",this._container.style.top="-50000px",this._container.style.width="50000px",this._container.style.whiteSpace="pre",this._container.style.fontKerning="none";const t=e.createElement("span"),i=e.createElement("span");i.style.fontWeight="bold";const s=e.createElement("span");s.style.fontStyle="italic";const r=e.createElement("span");r.style.fontWeight="bold",r.style.fontStyle="italic",this._measureElements=[t,i,s,r],this._container.appendChild(t),this._container.appendChild(i),this._container.appendChild(s),this._container.appendChild(r),e.body.appendChild(this._container),this.clear()}dispose(){this._container.remove(),this._measureElements.length=0,this._holey=void 0}clear(){this._flat.fill(-9999),this._holey=new Map}setFont(e,t,i,s){e===this._font&&t===this._fontSize&&i===this._weight&&s===this._weightBold||(this._font=e,this._fontSize=t,this._weight=i,this._weightBold=s,this._container.style.fontFamily=this._font,this._container.style.fontSize=`${this._fontSize}px`,this._measureElements[0].style.fontWeight=`${i}`,this._measureElements[1].style.fontWeight=`${s}`,this._measureElements[2].style.fontWeight=`${i}`,this._measureElements[3].style.fontWeight=`${s}`,this.clear())}get(e,t,i){let s=0;if(!t&&!i&&1===e.length&&(s=e.charCodeAt(0))<256)return-9999!==this._flat[s]?this._flat[s]:this._flat[s]=this._measure(e,0);let r=e;t&&(r+="B"),i&&(r+="I");let n=this._holey.get(r);if(void 0===n){let s=0;t&&(s|=1),i&&(s|=2),n=this._measure(e,s),this._holey.set(r,n)}return n}_measure(e,t){const i=this._measureElements[t];return i.textContent=e.repeat(32),i.offsetWidth/32}}},2223:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.TEXT_BASELINE=t.DIM_OPACITY=t.INVERTED_DEFAULT_COLOR=void 0;const s=i(6114);t.INVERTED_DEFAULT_COLOR=257,t.DIM_OPACITY=.5,t.TEXT_BASELINE=s.isFirefox||s.isLegacyEdge?"bottom":"ideographic"},6171:(e,t)=>{function i(e){return 57508<=e&&e<=57558}Object.defineProperty(t,"__esModule",{value:!0}),t.createRenderDimensions=t.excludeFromContrastRatioDemands=t.isRestrictedPowerlineGlyph=t.isPowerlineGlyph=t.throwIfFalsy=void 0,t.throwIfFalsy=function(e){if(!e)throw new Error("value must not be falsy");return e},t.isPowerlineGlyph=i,t.isRestrictedPowerlineGlyph=function(e){return 57520<=e&&e<=57527},t.excludeFromContrastRatioDemands=function(e){return i(e)||function(e){return 9472<=e&&e<=9631}(e)},t.createRenderDimensions=function(){return{css:{canvas:{width:0,height:0},cell:{width:0,height:0}},device:{canvas:{width:0,height:0},cell:{width:0,height:0},char:{width:0,height:0,left:0,top:0}}}}},456:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.SelectionModel=void 0,t.SelectionModel=class{constructor(e){this._bufferService=e,this.isSelectAllActive=!1,this.selectionStartLength=0}clearSelection(){this.selectionStart=void 0,this.selectionEnd=void 0,this.isSelectAllActive=!1,this.selectionStartLength=0}get finalSelectionStart(){return this.isSelectAllActive?[0,0]:this.selectionEnd&&this.selectionStart&&this.areSelectionValuesReversed()?this.selectionEnd:this.selectionStart}get finalSelectionEnd(){if(this.isSelectAllActive)return[this._bufferService.cols,this._bufferService.buffer.ybase+this._bufferService.rows-1];if(this.selectionStart){if(!this.selectionEnd||this.areSelectionValuesReversed()){const e=this.selectionStart[0]+this.selectionStartLength;return e>this._bufferService.cols?e%this._bufferService.cols==0?[this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)-1]:[e%this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)]:[e,this.selectionStart[1]]}if(this.selectionStartLength&&this.selectionEnd[1]===this.selectionStart[1]){const e=this.selectionStart[0]+this.selectionStartLength;return e>this._bufferService.cols?[e%this._bufferService.cols,this.selectionStart[1]+Math.floor(e/this._bufferService.cols)]:[Math.max(e,this.selectionEnd[0]),this.selectionEnd[1]]}return this.selectionEnd}}areSelectionValuesReversed(){const e=this.selectionStart,t=this.selectionEnd;return!(!e||!t)&&(e[1]>t[1]||e[1]===t[1]&&e[0]>t[0])}handleTrim(e){return this.selectionStart&&(this.selectionStart[1]-=e),this.selectionEnd&&(this.selectionEnd[1]-=e),this.selectionEnd&&this.selectionEnd[1]<0?(this.clearSelection(),!0):(this.selectionStart&&this.selectionStart[1]<0&&(this.selectionStart[1]=0),!1)}}},428:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CharSizeService=void 0;const n=i(2585),o=i(8460),a=i(844);let h=t.CharSizeService=class extends a.Disposable{get hasValidSize(){return this.width>0&&this.height>0}constructor(e,t,i){super(),this._optionsService=i,this.width=0,this.height=0,this._onCharSizeChange=this.register(new o.EventEmitter),this.onCharSizeChange=this._onCharSizeChange.event,this._measureStrategy=new c(e,t,this._optionsService),this.register(this._optionsService.onMultipleOptionChange(["fontFamily","fontSize"],(()=>this.measure())))}measure(){const e=this._measureStrategy.measure();e.width===this.width&&e.height===this.height||(this.width=e.width,this.height=e.height,this._onCharSizeChange.fire())}};t.CharSizeService=h=s([r(2,n.IOptionsService)],h);class c{constructor(e,t,i){this._document=e,this._parentElement=t,this._optionsService=i,this._result={width:0,height:0},this._measureElement=this._document.createElement("span"),this._measureElement.classList.add("xterm-char-measure-element"),this._measureElement.textContent="W".repeat(32),this._measureElement.setAttribute("aria-hidden","true"),this._measureElement.style.whiteSpace="pre",this._measureElement.style.fontKerning="none",this._parentElement.appendChild(this._measureElement)}measure(){this._measureElement.style.fontFamily=this._optionsService.rawOptions.fontFamily,this._measureElement.style.fontSize=`${this._optionsService.rawOptions.fontSize}px`;const e={height:Number(this._measureElement.offsetHeight),width:Number(this._measureElement.offsetWidth)};return 0!==e.width&&0!==e.height&&(this._result.width=e.width/32,this._result.height=Math.ceil(e.height)),this._result}}},4269:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CharacterJoinerService=t.JoinedCellData=void 0;const n=i(3734),o=i(643),a=i(511),h=i(2585);class c extends n.AttributeData{constructor(e,t,i){super(),this.content=0,this.combinedData="",this.fg=e.fg,this.bg=e.bg,this.combinedData=t,this._width=i}isCombined(){return 2097152}getWidth(){return this._width}getChars(){return this.combinedData}getCode(){return 2097151}setFromCharData(e){throw new Error("not implemented")}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}}t.JoinedCellData=c;let l=t.CharacterJoinerService=class e{constructor(e){this._bufferService=e,this._characterJoiners=[],this._nextCharacterJoinerId=0,this._workCell=new a.CellData}register(e){const t={id:this._nextCharacterJoinerId++,handler:e};return this._characterJoiners.push(t),t.id}deregister(e){for(let t=0;t1){const e=this._getJoinedRanges(s,a,n,t,r);for(let t=0;t1){const e=this._getJoinedRanges(s,a,n,t,r);for(let t=0;t{Object.defineProperty(t,"__esModule",{value:!0}),t.CoreBrowserService=void 0,t.CoreBrowserService=class{constructor(e,t){this._textarea=e,this.window=t,this._isFocused=!1,this._cachedIsFocused=void 0,this._textarea.addEventListener("focus",(()=>this._isFocused=!0)),this._textarea.addEventListener("blur",(()=>this._isFocused=!1))}get dpr(){return this.window.devicePixelRatio}get isFocused(){return void 0===this._cachedIsFocused&&(this._cachedIsFocused=this._isFocused&&this._textarea.ownerDocument.hasFocus(),queueMicrotask((()=>this._cachedIsFocused=void 0))),this._cachedIsFocused}}},8934:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.MouseService=void 0;const n=i(4725),o=i(9806);let a=t.MouseService=class{constructor(e,t){this._renderService=e,this._charSizeService=t}getCoords(e,t,i,s,r){return(0,o.getCoords)(window,e,t,i,s,this._charSizeService.hasValidSize,this._renderService.dimensions.css.cell.width,this._renderService.dimensions.css.cell.height,r)}getMouseReportCoords(e,t){const i=(0,o.getCoordsRelativeToElement)(window,e,t);if(this._charSizeService.hasValidSize)return i[0]=Math.min(Math.max(i[0],0),this._renderService.dimensions.css.canvas.width-1),i[1]=Math.min(Math.max(i[1],0),this._renderService.dimensions.css.canvas.height-1),{col:Math.floor(i[0]/this._renderService.dimensions.css.cell.width),row:Math.floor(i[1]/this._renderService.dimensions.css.cell.height),x:Math.floor(i[0]),y:Math.floor(i[1])}}};t.MouseService=a=s([r(0,n.IRenderService),r(1,n.ICharSizeService)],a)},3230:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.RenderService=void 0;const n=i(3656),o=i(6193),a=i(5596),h=i(4725),c=i(8460),l=i(844),d=i(7226),_=i(2585);let u=t.RenderService=class extends l.Disposable{get dimensions(){return this._renderer.value.dimensions}constructor(e,t,i,s,r,h,_,u){if(super(),this._rowCount=e,this._charSizeService=s,this._renderer=this.register(new l.MutableDisposable),this._pausedResizeTask=new d.DebouncedIdleTask,this._isPaused=!1,this._needsFullRefresh=!1,this._isNextRenderRedrawOnly=!0,this._needsSelectionRefresh=!1,this._canvasWidth=0,this._canvasHeight=0,this._selectionState={start:void 0,end:void 0,columnSelectMode:!1},this._onDimensionsChange=this.register(new c.EventEmitter),this.onDimensionsChange=this._onDimensionsChange.event,this._onRenderedViewportChange=this.register(new c.EventEmitter),this.onRenderedViewportChange=this._onRenderedViewportChange.event,this._onRender=this.register(new c.EventEmitter),this.onRender=this._onRender.event,this._onRefreshRequest=this.register(new c.EventEmitter),this.onRefreshRequest=this._onRefreshRequest.event,this._renderDebouncer=new o.RenderDebouncer(_.window,((e,t)=>this._renderRows(e,t))),this.register(this._renderDebouncer),this._screenDprMonitor=new a.ScreenDprMonitor(_.window),this._screenDprMonitor.setListener((()=>this.handleDevicePixelRatioChange())),this.register(this._screenDprMonitor),this.register(h.onResize((()=>this._fullRefresh()))),this.register(h.buffers.onBufferActivate((()=>{var e;return null===(e=this._renderer.value)||void 0===e?void 0:e.clear()}))),this.register(i.onOptionChange((()=>this._handleOptionsChanged()))),this.register(this._charSizeService.onCharSizeChange((()=>this.handleCharSizeChanged()))),this.register(r.onDecorationRegistered((()=>this._fullRefresh()))),this.register(r.onDecorationRemoved((()=>this._fullRefresh()))),this.register(i.onMultipleOptionChange(["customGlyphs","drawBoldTextInBrightColors","letterSpacing","lineHeight","fontFamily","fontSize","fontWeight","fontWeightBold","minimumContrastRatio"],(()=>{this.clear(),this.handleResize(h.cols,h.rows),this._fullRefresh()}))),this.register(i.onMultipleOptionChange(["cursorBlink","cursorStyle"],(()=>this.refreshRows(h.buffer.y,h.buffer.y,!0)))),this.register((0,n.addDisposableDomListener)(_.window,"resize",(()=>this.handleDevicePixelRatioChange()))),this.register(u.onChangeColors((()=>this._fullRefresh()))),"IntersectionObserver"in _.window){const e=new _.window.IntersectionObserver((e=>this._handleIntersectionChange(e[e.length-1])),{threshold:0});e.observe(t),this.register({dispose:()=>e.disconnect()})}}_handleIntersectionChange(e){this._isPaused=void 0===e.isIntersecting?0===e.intersectionRatio:!e.isIntersecting,this._isPaused||this._charSizeService.hasValidSize||this._charSizeService.measure(),!this._isPaused&&this._needsFullRefresh&&(this._pausedResizeTask.flush(),this.refreshRows(0,this._rowCount-1),this._needsFullRefresh=!1)}refreshRows(e,t,i=!1){this._isPaused?this._needsFullRefresh=!0:(i||(this._isNextRenderRedrawOnly=!1),this._renderDebouncer.refresh(e,t,this._rowCount))}_renderRows(e,t){this._renderer.value&&(e=Math.min(e,this._rowCount-1),t=Math.min(t,this._rowCount-1),this._renderer.value.renderRows(e,t),this._needsSelectionRefresh&&(this._renderer.value.handleSelectionChanged(this._selectionState.start,this._selectionState.end,this._selectionState.columnSelectMode),this._needsSelectionRefresh=!1),this._isNextRenderRedrawOnly||this._onRenderedViewportChange.fire({start:e,end:t}),this._onRender.fire({start:e,end:t}),this._isNextRenderRedrawOnly=!0)}resize(e,t){this._rowCount=t,this._fireOnCanvasResize()}_handleOptionsChanged(){this._renderer.value&&(this.refreshRows(0,this._rowCount-1),this._fireOnCanvasResize())}_fireOnCanvasResize(){this._renderer.value&&(this._renderer.value.dimensions.css.canvas.width===this._canvasWidth&&this._renderer.value.dimensions.css.canvas.height===this._canvasHeight||this._onDimensionsChange.fire(this._renderer.value.dimensions))}hasRenderer(){return!!this._renderer.value}setRenderer(e){this._renderer.value=e,this._renderer.value.onRequestRedraw((e=>this.refreshRows(e.start,e.end,!0))),this._needsSelectionRefresh=!0,this._fullRefresh()}addRefreshCallback(e){return this._renderDebouncer.addRefreshCallback(e)}_fullRefresh(){this._isPaused?this._needsFullRefresh=!0:this.refreshRows(0,this._rowCount-1)}clearTextureAtlas(){var e,t;this._renderer.value&&(null===(t=(e=this._renderer.value).clearTextureAtlas)||void 0===t||t.call(e),this._fullRefresh())}handleDevicePixelRatioChange(){this._charSizeService.measure(),this._renderer.value&&(this._renderer.value.handleDevicePixelRatioChange(),this.refreshRows(0,this._rowCount-1))}handleResize(e,t){this._renderer.value&&(this._isPaused?this._pausedResizeTask.set((()=>this._renderer.value.handleResize(e,t))):this._renderer.value.handleResize(e,t),this._fullRefresh())}handleCharSizeChanged(){var e;null===(e=this._renderer.value)||void 0===e||e.handleCharSizeChanged()}handleBlur(){var e;null===(e=this._renderer.value)||void 0===e||e.handleBlur()}handleFocus(){var e;null===(e=this._renderer.value)||void 0===e||e.handleFocus()}handleSelectionChanged(e,t,i){var s;this._selectionState.start=e,this._selectionState.end=t,this._selectionState.columnSelectMode=i,null===(s=this._renderer.value)||void 0===s||s.handleSelectionChanged(e,t,i)}handleCursorMove(){var e;null===(e=this._renderer.value)||void 0===e||e.handleCursorMove()}clear(){var e;null===(e=this._renderer.value)||void 0===e||e.clear()}};t.RenderService=u=s([r(2,_.IOptionsService),r(3,h.ICharSizeService),r(4,_.IDecorationService),r(5,_.IBufferService),r(6,h.ICoreBrowserService),r(7,h.IThemeService)],u)},9312:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.SelectionService=void 0;const n=i(9806),o=i(9504),a=i(456),h=i(4725),c=i(8460),l=i(844),d=i(6114),_=i(4841),u=i(511),f=i(2585),v=String.fromCharCode(160),p=new RegExp(v,"g");let g=t.SelectionService=class extends l.Disposable{constructor(e,t,i,s,r,n,o,h,d){super(),this._element=e,this._screenElement=t,this._linkifier=i,this._bufferService=s,this._coreService=r,this._mouseService=n,this._optionsService=o,this._renderService=h,this._coreBrowserService=d,this._dragScrollAmount=0,this._enabled=!0,this._workCell=new u.CellData,this._mouseDownTimeStamp=0,this._oldHasSelection=!1,this._oldSelectionStart=void 0,this._oldSelectionEnd=void 0,this._onLinuxMouseSelection=this.register(new c.EventEmitter),this.onLinuxMouseSelection=this._onLinuxMouseSelection.event,this._onRedrawRequest=this.register(new c.EventEmitter),this.onRequestRedraw=this._onRedrawRequest.event,this._onSelectionChange=this.register(new c.EventEmitter),this.onSelectionChange=this._onSelectionChange.event,this._onRequestScrollLines=this.register(new c.EventEmitter),this.onRequestScrollLines=this._onRequestScrollLines.event,this._mouseMoveListener=e=>this._handleMouseMove(e),this._mouseUpListener=e=>this._handleMouseUp(e),this._coreService.onUserInput((()=>{this.hasSelection&&this.clearSelection()})),this._trimListener=this._bufferService.buffer.lines.onTrim((e=>this._handleTrim(e))),this.register(this._bufferService.buffers.onBufferActivate((e=>this._handleBufferActivate(e)))),this.enable(),this._model=new a.SelectionModel(this._bufferService),this._activeSelectionMode=0,this.register((0,l.toDisposable)((()=>{this._removeMouseDownListeners()})))}reset(){this.clearSelection()}disable(){this.clearSelection(),this._enabled=!1}enable(){this._enabled=!0}get selectionStart(){return this._model.finalSelectionStart}get selectionEnd(){return this._model.finalSelectionEnd}get hasSelection(){const e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd;return!(!e||!t||e[0]===t[0]&&e[1]===t[1])}get selectionText(){const e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd;if(!e||!t)return"";const i=this._bufferService.buffer,s=[];if(3===this._activeSelectionMode){if(e[0]===t[0])return"";const r=e[0]e.replace(p," "))).join(d.isWindows?"\r\n":"\n")}clearSelection(){this._model.clearSelection(),this._removeMouseDownListeners(),this.refresh(),this._onSelectionChange.fire()}refresh(e){this._refreshAnimationFrame||(this._refreshAnimationFrame=this._coreBrowserService.window.requestAnimationFrame((()=>this._refresh()))),d.isLinux&&e&&this.selectionText.length&&this._onLinuxMouseSelection.fire(this.selectionText)}_refresh(){this._refreshAnimationFrame=void 0,this._onRedrawRequest.fire({start:this._model.finalSelectionStart,end:this._model.finalSelectionEnd,columnSelectMode:3===this._activeSelectionMode})}_isClickInSelection(e){const t=this._getMouseBufferCoords(e),i=this._model.finalSelectionStart,s=this._model.finalSelectionEnd;return!!(i&&s&&t)&&this._areCoordsInSelection(t,i,s)}isCellInSelection(e,t){const i=this._model.finalSelectionStart,s=this._model.finalSelectionEnd;return!(!i||!s)&&this._areCoordsInSelection([e,t],i,s)}_areCoordsInSelection(e,t,i){return e[1]>t[1]&&e[1]=t[0]&&e[0]=t[0]}_selectWordAtCursor(e,t){var i,s;const r=null===(s=null===(i=this._linkifier.currentLink)||void 0===i?void 0:i.link)||void 0===s?void 0:s.range;if(r)return this._model.selectionStart=[r.start.x-1,r.start.y-1],this._model.selectionStartLength=(0,_.getRangeLength)(r,this._bufferService.cols),this._model.selectionEnd=void 0,!0;const n=this._getMouseBufferCoords(e);return!!n&&(this._selectWordAt(n,t),this._model.selectionEnd=void 0,!0)}selectAll(){this._model.isSelectAllActive=!0,this.refresh(),this._onSelectionChange.fire()}selectLines(e,t){this._model.clearSelection(),e=Math.max(e,0),t=Math.min(t,this._bufferService.buffer.lines.length-1),this._model.selectionStart=[0,e],this._model.selectionEnd=[this._bufferService.cols,t],this.refresh(),this._onSelectionChange.fire()}_handleTrim(e){this._model.handleTrim(e)&&this.refresh()}_getMouseBufferCoords(e){const t=this._mouseService.getCoords(e,this._screenElement,this._bufferService.cols,this._bufferService.rows,!0);if(t)return t[0]--,t[1]--,t[1]+=this._bufferService.buffer.ydisp,t}_getMouseEventScrollAmount(e){let t=(0,n.getCoordsRelativeToElement)(this._coreBrowserService.window,e,this._screenElement)[1];const i=this._renderService.dimensions.css.canvas.height;return t>=0&&t<=i?0:(t>i&&(t-=i),t=Math.min(Math.max(t,-50),50),t/=50,t/Math.abs(t)+Math.round(14*t))}shouldForceSelection(e){return d.isMac?e.altKey&&this._optionsService.rawOptions.macOptionClickForcesSelection:e.shiftKey}handleMouseDown(e){if(this._mouseDownTimeStamp=e.timeStamp,(2!==e.button||!this.hasSelection)&&0===e.button){if(!this._enabled){if(!this.shouldForceSelection(e))return;e.stopPropagation()}e.preventDefault(),this._dragScrollAmount=0,this._enabled&&e.shiftKey?this._handleIncrementalClick(e):1===e.detail?this._handleSingleClick(e):2===e.detail?this._handleDoubleClick(e):3===e.detail&&this._handleTripleClick(e),this._addMouseDownListeners(),this.refresh(!0)}}_addMouseDownListeners(){this._screenElement.ownerDocument&&(this._screenElement.ownerDocument.addEventListener("mousemove",this._mouseMoveListener),this._screenElement.ownerDocument.addEventListener("mouseup",this._mouseUpListener)),this._dragScrollIntervalTimer=this._coreBrowserService.window.setInterval((()=>this._dragScroll()),50)}_removeMouseDownListeners(){this._screenElement.ownerDocument&&(this._screenElement.ownerDocument.removeEventListener("mousemove",this._mouseMoveListener),this._screenElement.ownerDocument.removeEventListener("mouseup",this._mouseUpListener)),this._coreBrowserService.window.clearInterval(this._dragScrollIntervalTimer),this._dragScrollIntervalTimer=void 0}_handleIncrementalClick(e){this._model.selectionStart&&(this._model.selectionEnd=this._getMouseBufferCoords(e))}_handleSingleClick(e){if(this._model.selectionStartLength=0,this._model.isSelectAllActive=!1,this._activeSelectionMode=this.shouldColumnSelect(e)?3:0,this._model.selectionStart=this._getMouseBufferCoords(e),!this._model.selectionStart)return;this._model.selectionEnd=void 0;const t=this._bufferService.buffer.lines.get(this._model.selectionStart[1]);t&&t.length!==this._model.selectionStart[0]&&0===t.hasWidth(this._model.selectionStart[0])&&this._model.selectionStart[0]++}_handleDoubleClick(e){this._selectWordAtCursor(e,!0)&&(this._activeSelectionMode=1)}_handleTripleClick(e){const t=this._getMouseBufferCoords(e);t&&(this._activeSelectionMode=2,this._selectLineAt(t[1]))}shouldColumnSelect(e){return e.altKey&&!(d.isMac&&this._optionsService.rawOptions.macOptionClickForcesSelection)}_handleMouseMove(e){if(e.stopImmediatePropagation(),!this._model.selectionStart)return;const t=this._model.selectionEnd?[this._model.selectionEnd[0],this._model.selectionEnd[1]]:null;if(this._model.selectionEnd=this._getMouseBufferCoords(e),!this._model.selectionEnd)return void this.refresh(!0);2===this._activeSelectionMode?this._model.selectionEnd[1]0?this._model.selectionEnd[0]=this._bufferService.cols:this._dragScrollAmount<0&&(this._model.selectionEnd[0]=0));const i=this._bufferService.buffer;if(this._model.selectionEnd[1]0?(3!==this._activeSelectionMode&&(this._model.selectionEnd[0]=this._bufferService.cols),this._model.selectionEnd[1]=Math.min(e.ydisp+this._bufferService.rows,e.lines.length-1)):(3!==this._activeSelectionMode&&(this._model.selectionEnd[0]=0),this._model.selectionEnd[1]=e.ydisp),this.refresh()}}_handleMouseUp(e){const t=e.timeStamp-this._mouseDownTimeStamp;if(this._removeMouseDownListeners(),this.selectionText.length<=1&&t<500&&e.altKey&&this._optionsService.rawOptions.altClickMovesCursor){if(this._bufferService.buffer.ybase===this._bufferService.buffer.ydisp){const t=this._mouseService.getCoords(e,this._element,this._bufferService.cols,this._bufferService.rows,!1);if(t&&void 0!==t[0]&&void 0!==t[1]){const e=(0,o.moveToCellSequence)(t[0]-1,t[1]-1,this._bufferService,this._coreService.decPrivateModes.applicationCursorKeys);this._coreService.triggerDataEvent(e,!0)}}}else this._fireEventIfSelectionChanged()}_fireEventIfSelectionChanged(){const e=this._model.finalSelectionStart,t=this._model.finalSelectionEnd,i=!(!e||!t||e[0]===t[0]&&e[1]===t[1]);i?e&&t&&(this._oldSelectionStart&&this._oldSelectionEnd&&e[0]===this._oldSelectionStart[0]&&e[1]===this._oldSelectionStart[1]&&t[0]===this._oldSelectionEnd[0]&&t[1]===this._oldSelectionEnd[1]||this._fireOnSelectionChange(e,t,i)):this._oldHasSelection&&this._fireOnSelectionChange(e,t,i)}_fireOnSelectionChange(e,t,i){this._oldSelectionStart=e,this._oldSelectionEnd=t,this._oldHasSelection=i,this._onSelectionChange.fire()}_handleBufferActivate(e){this.clearSelection(),this._trimListener.dispose(),this._trimListener=e.activeBuffer.lines.onTrim((e=>this._handleTrim(e)))}_convertViewportColToCharacterIndex(e,t){let i=t;for(let s=0;t>=s;s++){const r=e.loadCell(s,this._workCell).getChars().length;0===this._workCell.getWidth()?i--:r>1&&t!==s&&(i+=r-1)}return i}setSelection(e,t,i){this._model.clearSelection(),this._removeMouseDownListeners(),this._model.selectionStart=[e,t],this._model.selectionStartLength=i,this.refresh(),this._fireEventIfSelectionChanged()}rightClickSelect(e){this._isClickInSelection(e)||(this._selectWordAtCursor(e,!1)&&this.refresh(!0),this._fireEventIfSelectionChanged())}_getWordAt(e,t,i=!0,s=!0){if(e[0]>=this._bufferService.cols)return;const r=this._bufferService.buffer,n=r.lines.get(e[1]);if(!n)return;const o=r.translateBufferLineToString(e[1],!1);let a=this._convertViewportColToCharacterIndex(n,e[0]),h=a;const c=e[0]-a;let l=0,d=0,_=0,u=0;if(" "===o.charAt(a)){for(;a>0&&" "===o.charAt(a-1);)a--;for(;h1&&(u+=s-1,h+=s-1);t>0&&a>0&&!this._isCharWordSeparator(n.loadCell(t-1,this._workCell));){n.loadCell(t-1,this._workCell);const e=this._workCell.getChars().length;0===this._workCell.getWidth()?(l++,t--):e>1&&(_+=e-1,a-=e-1),a--,t--}for(;i1&&(u+=e-1,h+=e-1),h++,i++}}h++;let f=a+c-l+_,v=Math.min(this._bufferService.cols,h-a+l+d-_-u);if(t||""!==o.slice(a,h).trim()){if(i&&0===f&&32!==n.getCodePoint(0)){const t=r.lines.get(e[1]-1);if(t&&n.isWrapped&&32!==t.getCodePoint(this._bufferService.cols-1)){const t=this._getWordAt([this._bufferService.cols-1,e[1]-1],!1,!0,!1);if(t){const e=this._bufferService.cols-t.start;f-=e,v+=e}}}if(s&&f+v===this._bufferService.cols&&32!==n.getCodePoint(this._bufferService.cols-1)){const t=r.lines.get(e[1]+1);if((null==t?void 0:t.isWrapped)&&32!==t.getCodePoint(0)){const t=this._getWordAt([0,e[1]+1],!1,!1,!0);t&&(v+=t.length)}}return{start:f,length:v}}}_selectWordAt(e,t){const i=this._getWordAt(e,t);if(i){for(;i.start<0;)i.start+=this._bufferService.cols,e[1]--;this._model.selectionStart=[i.start,e[1]],this._model.selectionStartLength=i.length}}_selectToWordAt(e){const t=this._getWordAt(e,!0);if(t){let i=e[1];for(;t.start<0;)t.start+=this._bufferService.cols,i--;if(!this._model.areSelectionValuesReversed())for(;t.start+t.length>this._bufferService.cols;)t.length-=this._bufferService.cols,i++;this._model.selectionEnd=[this._model.areSelectionValuesReversed()?t.start:t.start+t.length,i]}}_isCharWordSeparator(e){return 0!==e.getWidth()&&this._optionsService.rawOptions.wordSeparator.indexOf(e.getChars())>=0}_selectLineAt(e){const t=this._bufferService.buffer.getWrappedRangeForLine(e),i={start:{x:0,y:t.first},end:{x:this._bufferService.cols-1,y:t.last}};this._model.selectionStart=[0,t.first],this._model.selectionEnd=void 0,this._model.selectionStartLength=(0,_.getRangeLength)(i,this._bufferService.cols)}};t.SelectionService=g=s([r(3,f.IBufferService),r(4,f.ICoreService),r(5,h.IMouseService),r(6,f.IOptionsService),r(7,h.IRenderService),r(8,h.ICoreBrowserService)],g)},4725:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.IThemeService=t.ICharacterJoinerService=t.ISelectionService=t.IRenderService=t.IMouseService=t.ICoreBrowserService=t.ICharSizeService=void 0;const s=i(8343);t.ICharSizeService=(0,s.createDecorator)("CharSizeService"),t.ICoreBrowserService=(0,s.createDecorator)("CoreBrowserService"),t.IMouseService=(0,s.createDecorator)("MouseService"),t.IRenderService=(0,s.createDecorator)("RenderService"),t.ISelectionService=(0,s.createDecorator)("SelectionService"),t.ICharacterJoinerService=(0,s.createDecorator)("CharacterJoinerService"),t.IThemeService=(0,s.createDecorator)("ThemeService")},6731:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.ThemeService=t.DEFAULT_ANSI_COLORS=void 0;const n=i(7239),o=i(8055),a=i(8460),h=i(844),c=i(2585),l=o.css.toColor("#ffffff"),d=o.css.toColor("#000000"),_=o.css.toColor("#ffffff"),u=o.css.toColor("#000000"),f={css:"rgba(255, 255, 255, 0.3)",rgba:4294967117};t.DEFAULT_ANSI_COLORS=Object.freeze((()=>{const e=[o.css.toColor("#2e3436"),o.css.toColor("#cc0000"),o.css.toColor("#4e9a06"),o.css.toColor("#c4a000"),o.css.toColor("#3465a4"),o.css.toColor("#75507b"),o.css.toColor("#06989a"),o.css.toColor("#d3d7cf"),o.css.toColor("#555753"),o.css.toColor("#ef2929"),o.css.toColor("#8ae234"),o.css.toColor("#fce94f"),o.css.toColor("#729fcf"),o.css.toColor("#ad7fa8"),o.css.toColor("#34e2e2"),o.css.toColor("#eeeeec")],t=[0,95,135,175,215,255];for(let i=0;i<216;i++){const s=t[i/36%6|0],r=t[i/6%6|0],n=t[i%6];e.push({css:o.channels.toCss(s,r,n),rgba:o.channels.toRgba(s,r,n)})}for(let t=0;t<24;t++){const i=8+10*t;e.push({css:o.channels.toCss(i,i,i),rgba:o.channels.toRgba(i,i,i)})}return e})());let v=t.ThemeService=class extends h.Disposable{get colors(){return this._colors}constructor(e){super(),this._optionsService=e,this._contrastCache=new n.ColorContrastCache,this._halfContrastCache=new n.ColorContrastCache,this._onChangeColors=this.register(new a.EventEmitter),this.onChangeColors=this._onChangeColors.event,this._colors={foreground:l,background:d,cursor:_,cursorAccent:u,selectionForeground:void 0,selectionBackgroundTransparent:f,selectionBackgroundOpaque:o.color.blend(d,f),selectionInactiveBackgroundTransparent:f,selectionInactiveBackgroundOpaque:o.color.blend(d,f),ansi:t.DEFAULT_ANSI_COLORS.slice(),contrastCache:this._contrastCache,halfContrastCache:this._halfContrastCache},this._updateRestoreColors(),this._setTheme(this._optionsService.rawOptions.theme),this.register(this._optionsService.onSpecificOptionChange("minimumContrastRatio",(()=>this._contrastCache.clear()))),this.register(this._optionsService.onSpecificOptionChange("theme",(()=>this._setTheme(this._optionsService.rawOptions.theme))))}_setTheme(e={}){const i=this._colors;if(i.foreground=p(e.foreground,l),i.background=p(e.background,d),i.cursor=p(e.cursor,_),i.cursorAccent=p(e.cursorAccent,u),i.selectionBackgroundTransparent=p(e.selectionBackground,f),i.selectionBackgroundOpaque=o.color.blend(i.background,i.selectionBackgroundTransparent),i.selectionInactiveBackgroundTransparent=p(e.selectionInactiveBackground,i.selectionBackgroundTransparent),i.selectionInactiveBackgroundOpaque=o.color.blend(i.background,i.selectionInactiveBackgroundTransparent),i.selectionForeground=e.selectionForeground?p(e.selectionForeground,o.NULL_COLOR):void 0,i.selectionForeground===o.NULL_COLOR&&(i.selectionForeground=void 0),o.color.isOpaque(i.selectionBackgroundTransparent)){const e=.3;i.selectionBackgroundTransparent=o.color.opacity(i.selectionBackgroundTransparent,e)}if(o.color.isOpaque(i.selectionInactiveBackgroundTransparent)){const e=.3;i.selectionInactiveBackgroundTransparent=o.color.opacity(i.selectionInactiveBackgroundTransparent,e)}if(i.ansi=t.DEFAULT_ANSI_COLORS.slice(),i.ansi[0]=p(e.black,t.DEFAULT_ANSI_COLORS[0]),i.ansi[1]=p(e.red,t.DEFAULT_ANSI_COLORS[1]),i.ansi[2]=p(e.green,t.DEFAULT_ANSI_COLORS[2]),i.ansi[3]=p(e.yellow,t.DEFAULT_ANSI_COLORS[3]),i.ansi[4]=p(e.blue,t.DEFAULT_ANSI_COLORS[4]),i.ansi[5]=p(e.magenta,t.DEFAULT_ANSI_COLORS[5]),i.ansi[6]=p(e.cyan,t.DEFAULT_ANSI_COLORS[6]),i.ansi[7]=p(e.white,t.DEFAULT_ANSI_COLORS[7]),i.ansi[8]=p(e.brightBlack,t.DEFAULT_ANSI_COLORS[8]),i.ansi[9]=p(e.brightRed,t.DEFAULT_ANSI_COLORS[9]),i.ansi[10]=p(e.brightGreen,t.DEFAULT_ANSI_COLORS[10]),i.ansi[11]=p(e.brightYellow,t.DEFAULT_ANSI_COLORS[11]),i.ansi[12]=p(e.brightBlue,t.DEFAULT_ANSI_COLORS[12]),i.ansi[13]=p(e.brightMagenta,t.DEFAULT_ANSI_COLORS[13]),i.ansi[14]=p(e.brightCyan,t.DEFAULT_ANSI_COLORS[14]),i.ansi[15]=p(e.brightWhite,t.DEFAULT_ANSI_COLORS[15]),e.extendedAnsi){const s=Math.min(i.ansi.length-16,e.extendedAnsi.length);for(let r=0;r{Object.defineProperty(t,"__esModule",{value:!0}),t.CircularList=void 0;const s=i(8460),r=i(844);class n extends r.Disposable{constructor(e){super(),this._maxLength=e,this.onDeleteEmitter=this.register(new s.EventEmitter),this.onDelete=this.onDeleteEmitter.event,this.onInsertEmitter=this.register(new s.EventEmitter),this.onInsert=this.onInsertEmitter.event,this.onTrimEmitter=this.register(new s.EventEmitter),this.onTrim=this.onTrimEmitter.event,this._array=new Array(this._maxLength),this._startIndex=0,this._length=0}get maxLength(){return this._maxLength}set maxLength(e){if(this._maxLength===e)return;const t=new Array(e);for(let i=0;ithis._length)for(let t=this._length;t=e;t--)this._array[this._getCyclicIndex(t+i.length)]=this._array[this._getCyclicIndex(t)];for(let t=0;tthis._maxLength){const e=this._length+i.length-this._maxLength;this._startIndex+=e,this._length=this._maxLength,this.onTrimEmitter.fire(e)}else this._length+=i.length}trimStart(e){e>this._length&&(e=this._length),this._startIndex+=e,this._length-=e,this.onTrimEmitter.fire(e)}shiftElements(e,t,i){if(!(t<=0)){if(e<0||e>=this._length)throw new Error("start argument out of range");if(e+i<0)throw new Error("Cannot shift elements in list beyond index 0");if(i>0){for(let s=t-1;s>=0;s--)this.set(e+s+i,this.get(e+s));const s=e+t+i-this._length;if(s>0)for(this._length+=s;this._length>this._maxLength;)this._length--,this._startIndex++,this.onTrimEmitter.fire(1)}else for(let s=0;s{Object.defineProperty(t,"__esModule",{value:!0}),t.clone=void 0,t.clone=function e(t,i=5){if("object"!=typeof t)return t;const s=Array.isArray(t)?[]:{};for(const r in t)s[r]=i<=1?t[r]:t[r]&&e(t[r],i-1);return s}},8055:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.contrastRatio=t.toPaddedHex=t.rgba=t.rgb=t.css=t.color=t.channels=t.NULL_COLOR=void 0;const s=i(6114);let r=0,n=0,o=0,a=0;var h,c,l,d,_;function u(e){const t=e.toString(16);return t.length<2?"0"+t:t}function f(e,t){return e>>0}}(h||(t.channels=h={})),function(e){function t(e,t){return a=Math.round(255*t),[r,n,o]=_.toChannels(e.rgba),{css:h.toCss(r,n,o,a),rgba:h.toRgba(r,n,o,a)}}e.blend=function(e,t){if(a=(255&t.rgba)/255,1===a)return{css:t.css,rgba:t.rgba};const i=t.rgba>>24&255,s=t.rgba>>16&255,c=t.rgba>>8&255,l=e.rgba>>24&255,d=e.rgba>>16&255,_=e.rgba>>8&255;return r=l+Math.round((i-l)*a),n=d+Math.round((s-d)*a),o=_+Math.round((c-_)*a),{css:h.toCss(r,n,o),rgba:h.toRgba(r,n,o)}},e.isOpaque=function(e){return 255==(255&e.rgba)},e.ensureContrastRatio=function(e,t,i){const s=_.ensureContrastRatio(e.rgba,t.rgba,i);if(s)return _.toColor(s>>24&255,s>>16&255,s>>8&255)},e.opaque=function(e){const t=(255|e.rgba)>>>0;return[r,n,o]=_.toChannels(t),{css:h.toCss(r,n,o),rgba:t}},e.opacity=t,e.multiplyOpacity=function(e,i){return a=255&e.rgba,t(e,a*i/255)},e.toColorRGB=function(e){return[e.rgba>>24&255,e.rgba>>16&255,e.rgba>>8&255]}}(c||(t.color=c={})),function(e){let t,i;if(!s.isNode){const e=document.createElement("canvas");e.width=1,e.height=1;const s=e.getContext("2d",{willReadFrequently:!0});s&&(t=s,t.globalCompositeOperation="copy",i=t.createLinearGradient(0,0,1,1))}e.toColor=function(e){if(e.match(/#[\da-f]{3,8}/i))switch(e.length){case 4:return r=parseInt(e.slice(1,2).repeat(2),16),n=parseInt(e.slice(2,3).repeat(2),16),o=parseInt(e.slice(3,4).repeat(2),16),_.toColor(r,n,o);case 5:return r=parseInt(e.slice(1,2).repeat(2),16),n=parseInt(e.slice(2,3).repeat(2),16),o=parseInt(e.slice(3,4).repeat(2),16),a=parseInt(e.slice(4,5).repeat(2),16),_.toColor(r,n,o,a);case 7:return{css:e,rgba:(parseInt(e.slice(1),16)<<8|255)>>>0};case 9:return{css:e,rgba:parseInt(e.slice(1),16)>>>0}}const s=e.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(s)return r=parseInt(s[1]),n=parseInt(s[2]),o=parseInt(s[3]),a=Math.round(255*(void 0===s[5]?1:parseFloat(s[5]))),_.toColor(r,n,o,a);if(!t||!i)throw new Error("css.toColor: Unsupported css format");if(t.fillStyle=i,t.fillStyle=e,"string"!=typeof t.fillStyle)throw new Error("css.toColor: Unsupported css format");if(t.fillRect(0,0,1,1),[r,n,o,a]=t.getImageData(0,0,1,1).data,255!==a)throw new Error("css.toColor: Unsupported css format");return{rgba:h.toRgba(r,n,o,a),css:e}}}(l||(t.css=l={})),function(e){function t(e,t,i){const s=e/255,r=t/255,n=i/255;return.2126*(s<=.03928?s/12.92:Math.pow((s+.055)/1.055,2.4))+.7152*(r<=.03928?r/12.92:Math.pow((r+.055)/1.055,2.4))+.0722*(n<=.03928?n/12.92:Math.pow((n+.055)/1.055,2.4))}e.relativeLuminance=function(e){return t(e>>16&255,e>>8&255,255&e)},e.relativeLuminance2=t}(d||(t.rgb=d={})),function(e){function t(e,t,i){const s=e>>24&255,r=e>>16&255,n=e>>8&255;let o=t>>24&255,a=t>>16&255,h=t>>8&255,c=f(d.relativeLuminance2(o,a,h),d.relativeLuminance2(s,r,n));for(;c0||a>0||h>0);)o-=Math.max(0,Math.ceil(.1*o)),a-=Math.max(0,Math.ceil(.1*a)),h-=Math.max(0,Math.ceil(.1*h)),c=f(d.relativeLuminance2(o,a,h),d.relativeLuminance2(s,r,n));return(o<<24|a<<16|h<<8|255)>>>0}function i(e,t,i){const s=e>>24&255,r=e>>16&255,n=e>>8&255;let o=t>>24&255,a=t>>16&255,h=t>>8&255,c=f(d.relativeLuminance2(o,a,h),d.relativeLuminance2(s,r,n));for(;c>>0}e.ensureContrastRatio=function(e,s,r){const n=d.relativeLuminance(e>>8),o=d.relativeLuminance(s>>8);if(f(n,o)>8));if(af(n,d.relativeLuminance(t>>8))?o:t}return o}const a=i(e,s,r),h=f(n,d.relativeLuminance(a>>8));if(hf(n,d.relativeLuminance(i>>8))?a:i}return a}},e.reduceLuminance=t,e.increaseLuminance=i,e.toChannels=function(e){return[e>>24&255,e>>16&255,e>>8&255,255&e]},e.toColor=function(e,t,i,s){return{css:h.toCss(e,t,i,s),rgba:h.toRgba(e,t,i,s)}}}(_||(t.rgba=_={})),t.toPaddedHex=u,t.contrastRatio=f},8969:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.CoreTerminal=void 0;const s=i(844),r=i(2585),n=i(4348),o=i(7866),a=i(744),h=i(7302),c=i(6975),l=i(8460),d=i(1753),_=i(1480),u=i(7994),f=i(9282),v=i(5435),p=i(5981),g=i(2660);let m=!1;class S extends s.Disposable{get onScroll(){return this._onScrollApi||(this._onScrollApi=this.register(new l.EventEmitter),this._onScroll.event((e=>{var t;null===(t=this._onScrollApi)||void 0===t||t.fire(e.position)}))),this._onScrollApi.event}get cols(){return this._bufferService.cols}get rows(){return this._bufferService.rows}get buffers(){return this._bufferService.buffers}get options(){return this.optionsService.options}set options(e){for(const t in e)this.optionsService.options[t]=e[t]}constructor(e){super(),this._windowsWrappingHeuristics=this.register(new s.MutableDisposable),this._onBinary=this.register(new l.EventEmitter),this.onBinary=this._onBinary.event,this._onData=this.register(new l.EventEmitter),this.onData=this._onData.event,this._onLineFeed=this.register(new l.EventEmitter),this.onLineFeed=this._onLineFeed.event,this._onResize=this.register(new l.EventEmitter),this.onResize=this._onResize.event,this._onWriteParsed=this.register(new l.EventEmitter),this.onWriteParsed=this._onWriteParsed.event,this._onScroll=this.register(new l.EventEmitter),this._instantiationService=new n.InstantiationService,this.optionsService=this.register(new h.OptionsService(e)),this._instantiationService.setService(r.IOptionsService,this.optionsService),this._bufferService=this.register(this._instantiationService.createInstance(a.BufferService)),this._instantiationService.setService(r.IBufferService,this._bufferService),this._logService=this.register(this._instantiationService.createInstance(o.LogService)),this._instantiationService.setService(r.ILogService,this._logService),this.coreService=this.register(this._instantiationService.createInstance(c.CoreService)),this._instantiationService.setService(r.ICoreService,this.coreService),this.coreMouseService=this.register(this._instantiationService.createInstance(d.CoreMouseService)),this._instantiationService.setService(r.ICoreMouseService,this.coreMouseService),this.unicodeService=this.register(this._instantiationService.createInstance(_.UnicodeService)),this._instantiationService.setService(r.IUnicodeService,this.unicodeService),this._charsetService=this._instantiationService.createInstance(u.CharsetService),this._instantiationService.setService(r.ICharsetService,this._charsetService),this._oscLinkService=this._instantiationService.createInstance(g.OscLinkService),this._instantiationService.setService(r.IOscLinkService,this._oscLinkService),this._inputHandler=this.register(new v.InputHandler(this._bufferService,this._charsetService,this.coreService,this._logService,this.optionsService,this._oscLinkService,this.coreMouseService,this.unicodeService)),this.register((0,l.forwardEvent)(this._inputHandler.onLineFeed,this._onLineFeed)),this.register(this._inputHandler),this.register((0,l.forwardEvent)(this._bufferService.onResize,this._onResize)),this.register((0,l.forwardEvent)(this.coreService.onData,this._onData)),this.register((0,l.forwardEvent)(this.coreService.onBinary,this._onBinary)),this.register(this.coreService.onRequestScrollToBottom((()=>this.scrollToBottom()))),this.register(this.coreService.onUserInput((()=>this._writeBuffer.handleUserInput()))),this.register(this.optionsService.onMultipleOptionChange(["windowsMode","windowsPty"],(()=>this._handleWindowsPtyOptionChange()))),this.register(this._bufferService.onScroll((e=>{this._onScroll.fire({position:this._bufferService.buffer.ydisp,source:0}),this._inputHandler.markRangeDirty(this._bufferService.buffer.scrollTop,this._bufferService.buffer.scrollBottom)}))),this.register(this._inputHandler.onScroll((e=>{this._onScroll.fire({position:this._bufferService.buffer.ydisp,source:0}),this._inputHandler.markRangeDirty(this._bufferService.buffer.scrollTop,this._bufferService.buffer.scrollBottom)}))),this._writeBuffer=this.register(new p.WriteBuffer(((e,t)=>this._inputHandler.parse(e,t)))),this.register((0,l.forwardEvent)(this._writeBuffer.onWriteParsed,this._onWriteParsed))}write(e,t){this._writeBuffer.write(e,t)}writeSync(e,t){this._logService.logLevel<=r.LogLevelEnum.WARN&&!m&&(this._logService.warn("writeSync is unreliable and will be removed soon."),m=!0),this._writeBuffer.writeSync(e,t)}resize(e,t){isNaN(e)||isNaN(t)||(e=Math.max(e,a.MINIMUM_COLS),t=Math.max(t,a.MINIMUM_ROWS),this._bufferService.resize(e,t))}scroll(e,t=!1){this._bufferService.scroll(e,t)}scrollLines(e,t,i){this._bufferService.scrollLines(e,t,i)}scrollPages(e){this.scrollLines(e*(this.rows-1))}scrollToTop(){this.scrollLines(-this._bufferService.buffer.ydisp)}scrollToBottom(){this.scrollLines(this._bufferService.buffer.ybase-this._bufferService.buffer.ydisp)}scrollToLine(e){const t=e-this._bufferService.buffer.ydisp;0!==t&&this.scrollLines(t)}registerEscHandler(e,t){return this._inputHandler.registerEscHandler(e,t)}registerDcsHandler(e,t){return this._inputHandler.registerDcsHandler(e,t)}registerCsiHandler(e,t){return this._inputHandler.registerCsiHandler(e,t)}registerOscHandler(e,t){return this._inputHandler.registerOscHandler(e,t)}_setup(){this._handleWindowsPtyOptionChange()}reset(){this._inputHandler.reset(),this._bufferService.reset(),this._charsetService.reset(),this.coreService.reset(),this.coreMouseService.reset()}_handleWindowsPtyOptionChange(){let e=!1;const t=this.optionsService.rawOptions.windowsPty;t&&void 0!==t.buildNumber&&void 0!==t.buildNumber?e=!!("conpty"===t.backend&&t.buildNumber<21376):this.optionsService.rawOptions.windowsMode&&(e=!0),e?this._enableWindowsWrappingHeuristics():this._windowsWrappingHeuristics.clear()}_enableWindowsWrappingHeuristics(){if(!this._windowsWrappingHeuristics.value){const e=[];e.push(this.onLineFeed(f.updateWindowsModeWrappedState.bind(null,this._bufferService))),e.push(this.registerCsiHandler({final:"H"},(()=>((0,f.updateWindowsModeWrappedState)(this._bufferService),!1)))),this._windowsWrappingHeuristics.value=(0,s.toDisposable)((()=>{for(const t of e)t.dispose()}))}}}t.CoreTerminal=S},8460:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.forwardEvent=t.EventEmitter=void 0,t.EventEmitter=class{constructor(){this._listeners=[],this._disposed=!1}get event(){return this._event||(this._event=e=>(this._listeners.push(e),{dispose:()=>{if(!this._disposed)for(let t=0;tt.fire(e)))}},5435:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.InputHandler=t.WindowsOptionsReportType=void 0;const n=i(2584),o=i(7116),a=i(2015),h=i(844),c=i(482),l=i(8437),d=i(8460),_=i(643),u=i(511),f=i(3734),v=i(2585),p=i(6242),g=i(6351),m=i(5941),S={"(":0,")":1,"*":2,"+":3,"-":1,".":2},C=131072;function b(e,t){if(e>24)return t.setWinLines||!1;switch(e){case 1:return!!t.restoreWin;case 2:return!!t.minimizeWin;case 3:return!!t.setWinPosition;case 4:return!!t.setWinSizePixels;case 5:return!!t.raiseWin;case 6:return!!t.lowerWin;case 7:return!!t.refreshWin;case 8:return!!t.setWinSizeChars;case 9:return!!t.maximizeWin;case 10:return!!t.fullscreenWin;case 11:return!!t.getWinState;case 13:return!!t.getWinPosition;case 14:return!!t.getWinSizePixels;case 15:return!!t.getScreenSizePixels;case 16:return!!t.getCellSizePixels;case 18:return!!t.getWinSizeChars;case 19:return!!t.getScreenSizeChars;case 20:return!!t.getIconTitle;case 21:return!!t.getWinTitle;case 22:return!!t.pushTitle;case 23:return!!t.popTitle;case 24:return!!t.setWinLines}return!1}var y;!function(e){e[e.GET_WIN_SIZE_PIXELS=0]="GET_WIN_SIZE_PIXELS",e[e.GET_CELL_SIZE_PIXELS=1]="GET_CELL_SIZE_PIXELS"}(y||(t.WindowsOptionsReportType=y={}));let w=0;class E extends h.Disposable{getAttrData(){return this._curAttrData}constructor(e,t,i,s,r,h,_,f,v=new a.EscapeSequenceParser){super(),this._bufferService=e,this._charsetService=t,this._coreService=i,this._logService=s,this._optionsService=r,this._oscLinkService=h,this._coreMouseService=_,this._unicodeService=f,this._parser=v,this._parseBuffer=new Uint32Array(4096),this._stringDecoder=new c.StringToUtf32,this._utf8Decoder=new c.Utf8ToUtf32,this._workCell=new u.CellData,this._windowTitle="",this._iconName="",this._windowTitleStack=[],this._iconNameStack=[],this._curAttrData=l.DEFAULT_ATTR_DATA.clone(),this._eraseAttrDataInternal=l.DEFAULT_ATTR_DATA.clone(),this._onRequestBell=this.register(new d.EventEmitter),this.onRequestBell=this._onRequestBell.event,this._onRequestRefreshRows=this.register(new d.EventEmitter),this.onRequestRefreshRows=this._onRequestRefreshRows.event,this._onRequestReset=this.register(new d.EventEmitter),this.onRequestReset=this._onRequestReset.event,this._onRequestSendFocus=this.register(new d.EventEmitter),this.onRequestSendFocus=this._onRequestSendFocus.event,this._onRequestSyncScrollBar=this.register(new d.EventEmitter),this.onRequestSyncScrollBar=this._onRequestSyncScrollBar.event,this._onRequestWindowsOptionsReport=this.register(new d.EventEmitter),this.onRequestWindowsOptionsReport=this._onRequestWindowsOptionsReport.event,this._onA11yChar=this.register(new d.EventEmitter),this.onA11yChar=this._onA11yChar.event,this._onA11yTab=this.register(new d.EventEmitter),this.onA11yTab=this._onA11yTab.event,this._onCursorMove=this.register(new d.EventEmitter),this.onCursorMove=this._onCursorMove.event,this._onLineFeed=this.register(new d.EventEmitter),this.onLineFeed=this._onLineFeed.event,this._onScroll=this.register(new d.EventEmitter),this.onScroll=this._onScroll.event,this._onTitleChange=this.register(new d.EventEmitter),this.onTitleChange=this._onTitleChange.event,this._onColor=this.register(new d.EventEmitter),this.onColor=this._onColor.event,this._parseStack={paused:!1,cursorStartX:0,cursorStartY:0,decodedLength:0,position:0},this._specialColors=[256,257,258],this.register(this._parser),this._dirtyRowTracker=new k(this._bufferService),this._activeBuffer=this._bufferService.buffer,this.register(this._bufferService.buffers.onBufferActivate((e=>this._activeBuffer=e.activeBuffer))),this._parser.setCsiHandlerFallback(((e,t)=>{this._logService.debug("Unknown CSI code: ",{identifier:this._parser.identToString(e),params:t.toArray()})})),this._parser.setEscHandlerFallback((e=>{this._logService.debug("Unknown ESC code: ",{identifier:this._parser.identToString(e)})})),this._parser.setExecuteHandlerFallback((e=>{this._logService.debug("Unknown EXECUTE code: ",{code:e})})),this._parser.setOscHandlerFallback(((e,t,i)=>{this._logService.debug("Unknown OSC code: ",{identifier:e,action:t,data:i})})),this._parser.setDcsHandlerFallback(((e,t,i)=>{"HOOK"===t&&(i=i.toArray()),this._logService.debug("Unknown DCS code: ",{identifier:this._parser.identToString(e),action:t,payload:i})})),this._parser.setPrintHandler(((e,t,i)=>this.print(e,t,i))),this._parser.registerCsiHandler({final:"@"},(e=>this.insertChars(e))),this._parser.registerCsiHandler({intermediates:" ",final:"@"},(e=>this.scrollLeft(e))),this._parser.registerCsiHandler({final:"A"},(e=>this.cursorUp(e))),this._parser.registerCsiHandler({intermediates:" ",final:"A"},(e=>this.scrollRight(e))),this._parser.registerCsiHandler({final:"B"},(e=>this.cursorDown(e))),this._parser.registerCsiHandler({final:"C"},(e=>this.cursorForward(e))),this._parser.registerCsiHandler({final:"D"},(e=>this.cursorBackward(e))),this._parser.registerCsiHandler({final:"E"},(e=>this.cursorNextLine(e))),this._parser.registerCsiHandler({final:"F"},(e=>this.cursorPrecedingLine(e))),this._parser.registerCsiHandler({final:"G"},(e=>this.cursorCharAbsolute(e))),this._parser.registerCsiHandler({final:"H"},(e=>this.cursorPosition(e))),this._parser.registerCsiHandler({final:"I"},(e=>this.cursorForwardTab(e))),this._parser.registerCsiHandler({final:"J"},(e=>this.eraseInDisplay(e,!1))),this._parser.registerCsiHandler({prefix:"?",final:"J"},(e=>this.eraseInDisplay(e,!0))),this._parser.registerCsiHandler({final:"K"},(e=>this.eraseInLine(e,!1))),this._parser.registerCsiHandler({prefix:"?",final:"K"},(e=>this.eraseInLine(e,!0))),this._parser.registerCsiHandler({final:"L"},(e=>this.insertLines(e))),this._parser.registerCsiHandler({final:"M"},(e=>this.deleteLines(e))),this._parser.registerCsiHandler({final:"P"},(e=>this.deleteChars(e))),this._parser.registerCsiHandler({final:"S"},(e=>this.scrollUp(e))),this._parser.registerCsiHandler({final:"T"},(e=>this.scrollDown(e))),this._parser.registerCsiHandler({final:"X"},(e=>this.eraseChars(e))),this._parser.registerCsiHandler({final:"Z"},(e=>this.cursorBackwardTab(e))),this._parser.registerCsiHandler({final:"`"},(e=>this.charPosAbsolute(e))),this._parser.registerCsiHandler({final:"a"},(e=>this.hPositionRelative(e))),this._parser.registerCsiHandler({final:"b"},(e=>this.repeatPrecedingCharacter(e))),this._parser.registerCsiHandler({final:"c"},(e=>this.sendDeviceAttributesPrimary(e))),this._parser.registerCsiHandler({prefix:">",final:"c"},(e=>this.sendDeviceAttributesSecondary(e))),this._parser.registerCsiHandler({final:"d"},(e=>this.linePosAbsolute(e))),this._parser.registerCsiHandler({final:"e"},(e=>this.vPositionRelative(e))),this._parser.registerCsiHandler({final:"f"},(e=>this.hVPosition(e))),this._parser.registerCsiHandler({final:"g"},(e=>this.tabClear(e))),this._parser.registerCsiHandler({final:"h"},(e=>this.setMode(e))),this._parser.registerCsiHandler({prefix:"?",final:"h"},(e=>this.setModePrivate(e))),this._parser.registerCsiHandler({final:"l"},(e=>this.resetMode(e))),this._parser.registerCsiHandler({prefix:"?",final:"l"},(e=>this.resetModePrivate(e))),this._parser.registerCsiHandler({final:"m"},(e=>this.charAttributes(e))),this._parser.registerCsiHandler({final:"n"},(e=>this.deviceStatus(e))),this._parser.registerCsiHandler({prefix:"?",final:"n"},(e=>this.deviceStatusPrivate(e))),this._parser.registerCsiHandler({intermediates:"!",final:"p"},(e=>this.softReset(e))),this._parser.registerCsiHandler({intermediates:" ",final:"q"},(e=>this.setCursorStyle(e))),this._parser.registerCsiHandler({final:"r"},(e=>this.setScrollRegion(e))),this._parser.registerCsiHandler({final:"s"},(e=>this.saveCursor(e))),this._parser.registerCsiHandler({final:"t"},(e=>this.windowOptions(e))),this._parser.registerCsiHandler({final:"u"},(e=>this.restoreCursor(e))),this._parser.registerCsiHandler({intermediates:"'",final:"}"},(e=>this.insertColumns(e))),this._parser.registerCsiHandler({intermediates:"'",final:"~"},(e=>this.deleteColumns(e))),this._parser.registerCsiHandler({intermediates:'"',final:"q"},(e=>this.selectProtected(e))),this._parser.registerCsiHandler({intermediates:"$",final:"p"},(e=>this.requestMode(e,!0))),this._parser.registerCsiHandler({prefix:"?",intermediates:"$",final:"p"},(e=>this.requestMode(e,!1))),this._parser.setExecuteHandler(n.C0.BEL,(()=>this.bell())),this._parser.setExecuteHandler(n.C0.LF,(()=>this.lineFeed())),this._parser.setExecuteHandler(n.C0.VT,(()=>this.lineFeed())),this._parser.setExecuteHandler(n.C0.FF,(()=>this.lineFeed())),this._parser.setExecuteHandler(n.C0.CR,(()=>this.carriageReturn())),this._parser.setExecuteHandler(n.C0.BS,(()=>this.backspace())),this._parser.setExecuteHandler(n.C0.HT,(()=>this.tab())),this._parser.setExecuteHandler(n.C0.SO,(()=>this.shiftOut())),this._parser.setExecuteHandler(n.C0.SI,(()=>this.shiftIn())),this._parser.setExecuteHandler(n.C1.IND,(()=>this.index())),this._parser.setExecuteHandler(n.C1.NEL,(()=>this.nextLine())),this._parser.setExecuteHandler(n.C1.HTS,(()=>this.tabSet())),this._parser.registerOscHandler(0,new p.OscHandler((e=>(this.setTitle(e),this.setIconName(e),!0)))),this._parser.registerOscHandler(1,new p.OscHandler((e=>this.setIconName(e)))),this._parser.registerOscHandler(2,new p.OscHandler((e=>this.setTitle(e)))),this._parser.registerOscHandler(4,new p.OscHandler((e=>this.setOrReportIndexedColor(e)))),this._parser.registerOscHandler(8,new p.OscHandler((e=>this.setHyperlink(e)))),this._parser.registerOscHandler(10,new p.OscHandler((e=>this.setOrReportFgColor(e)))),this._parser.registerOscHandler(11,new p.OscHandler((e=>this.setOrReportBgColor(e)))),this._parser.registerOscHandler(12,new p.OscHandler((e=>this.setOrReportCursorColor(e)))),this._parser.registerOscHandler(104,new p.OscHandler((e=>this.restoreIndexedColor(e)))),this._parser.registerOscHandler(110,new p.OscHandler((e=>this.restoreFgColor(e)))),this._parser.registerOscHandler(111,new p.OscHandler((e=>this.restoreBgColor(e)))),this._parser.registerOscHandler(112,new p.OscHandler((e=>this.restoreCursorColor(e)))),this._parser.registerEscHandler({final:"7"},(()=>this.saveCursor())),this._parser.registerEscHandler({final:"8"},(()=>this.restoreCursor())),this._parser.registerEscHandler({final:"D"},(()=>this.index())),this._parser.registerEscHandler({final:"E"},(()=>this.nextLine())),this._parser.registerEscHandler({final:"H"},(()=>this.tabSet())),this._parser.registerEscHandler({final:"M"},(()=>this.reverseIndex())),this._parser.registerEscHandler({final:"="},(()=>this.keypadApplicationMode())),this._parser.registerEscHandler({final:">"},(()=>this.keypadNumericMode())),this._parser.registerEscHandler({final:"c"},(()=>this.fullReset())),this._parser.registerEscHandler({final:"n"},(()=>this.setgLevel(2))),this._parser.registerEscHandler({final:"o"},(()=>this.setgLevel(3))),this._parser.registerEscHandler({final:"|"},(()=>this.setgLevel(3))),this._parser.registerEscHandler({final:"}"},(()=>this.setgLevel(2))),this._parser.registerEscHandler({final:"~"},(()=>this.setgLevel(1))),this._parser.registerEscHandler({intermediates:"%",final:"@"},(()=>this.selectDefaultCharset())),this._parser.registerEscHandler({intermediates:"%",final:"G"},(()=>this.selectDefaultCharset()));for(const e in o.CHARSETS)this._parser.registerEscHandler({intermediates:"(",final:e},(()=>this.selectCharset("("+e))),this._parser.registerEscHandler({intermediates:")",final:e},(()=>this.selectCharset(")"+e))),this._parser.registerEscHandler({intermediates:"*",final:e},(()=>this.selectCharset("*"+e))),this._parser.registerEscHandler({intermediates:"+",final:e},(()=>this.selectCharset("+"+e))),this._parser.registerEscHandler({intermediates:"-",final:e},(()=>this.selectCharset("-"+e))),this._parser.registerEscHandler({intermediates:".",final:e},(()=>this.selectCharset("."+e))),this._parser.registerEscHandler({intermediates:"/",final:e},(()=>this.selectCharset("/"+e)));this._parser.registerEscHandler({intermediates:"#",final:"8"},(()=>this.screenAlignmentPattern())),this._parser.setErrorHandler((e=>(this._logService.error("Parsing error: ",e),e))),this._parser.registerDcsHandler({intermediates:"$",final:"q"},new g.DcsHandler(((e,t)=>this.requestStatusString(e,t))))}_preserveStack(e,t,i,s){this._parseStack.paused=!0,this._parseStack.cursorStartX=e,this._parseStack.cursorStartY=t,this._parseStack.decodedLength=i,this._parseStack.position=s}_logSlowResolvingAsync(e){this._logService.logLevel<=v.LogLevelEnum.WARN&&Promise.race([e,new Promise(((e,t)=>setTimeout((()=>t("#SLOW_TIMEOUT")),5e3)))]).catch((e=>{if("#SLOW_TIMEOUT"!==e)throw e;console.warn("async parser handler taking longer than 5000 ms")}))}_getCurrentLinkId(){return this._curAttrData.extended.urlId}parse(e,t){let i,s=this._activeBuffer.x,r=this._activeBuffer.y,n=0;const o=this._parseStack.paused;if(o){if(i=this._parser.parse(this._parseBuffer,this._parseStack.decodedLength,t))return this._logSlowResolvingAsync(i),i;s=this._parseStack.cursorStartX,r=this._parseStack.cursorStartY,this._parseStack.paused=!1,e.length>C&&(n=this._parseStack.position+C)}if(this._logService.logLevel<=v.LogLevelEnum.DEBUG&&this._logService.debug("parsing data"+("string"==typeof e?` "${e}"`:` "${Array.prototype.map.call(e,(e=>String.fromCharCode(e))).join("")}"`),"string"==typeof e?e.split("").map((e=>e.charCodeAt(0))):e),this._parseBuffer.lengthC)for(let t=n;t0&&2===u.getWidth(this._activeBuffer.x-1)&&u.setCellFromCodePoint(this._activeBuffer.x-1,0,1,d.fg,d.bg,d.extended);for(let f=t;f=a)if(h){for(;this._activeBuffer.x=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!0),u=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y)}else if(this._activeBuffer.x=a-1,2===r)continue;if(l&&(u.insertCells(this._activeBuffer.x,r,this._activeBuffer.getNullCell(d),d),2===u.getWidth(a-1)&&u.setCellFromCodePoint(a-1,_.NULL_CELL_CODE,_.NULL_CELL_WIDTH,d.fg,d.bg,d.extended)),u.setCellFromCodePoint(this._activeBuffer.x++,s,r,d.fg,d.bg,d.extended),r>0)for(;--r;)u.setCellFromCodePoint(this._activeBuffer.x++,0,0,d.fg,d.bg,d.extended)}else u.getWidth(this._activeBuffer.x-1)?u.addCodepointToCell(this._activeBuffer.x-1,s):u.addCodepointToCell(this._activeBuffer.x-2,s)}i-t>0&&(u.loadCell(this._activeBuffer.x-1,this._workCell),2===this._workCell.getWidth()||this._workCell.getCode()>65535?this._parser.precedingCodepoint=0:this._workCell.isCombined()?this._parser.precedingCodepoint=this._workCell.getChars().charCodeAt(0):this._parser.precedingCodepoint=this._workCell.content),this._activeBuffer.x0&&0===u.getWidth(this._activeBuffer.x)&&!u.hasContent(this._activeBuffer.x)&&u.setCellFromCodePoint(this._activeBuffer.x,0,1,d.fg,d.bg,d.extended),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}registerCsiHandler(e,t){return"t"!==e.final||e.prefix||e.intermediates?this._parser.registerCsiHandler(e,t):this._parser.registerCsiHandler(e,(e=>!b(e.params[0],this._optionsService.rawOptions.windowOptions)||t(e)))}registerDcsHandler(e,t){return this._parser.registerDcsHandler(e,new g.DcsHandler(t))}registerEscHandler(e,t){return this._parser.registerEscHandler(e,t)}registerOscHandler(e,t){return this._parser.registerOscHandler(e,new p.OscHandler(t))}bell(){return this._onRequestBell.fire(),!0}lineFeed(){return this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._optionsService.rawOptions.convertEol&&(this._activeBuffer.x=0),this._activeBuffer.y++,this._activeBuffer.y===this._activeBuffer.scrollBottom+1?(this._activeBuffer.y--,this._bufferService.scroll(this._eraseAttrData())):this._activeBuffer.y>=this._bufferService.rows?this._activeBuffer.y=this._bufferService.rows-1:this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.x>=this._bufferService.cols&&this._activeBuffer.x--,this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._onLineFeed.fire(),!0}carriageReturn(){return this._activeBuffer.x=0,!0}backspace(){var e;if(!this._coreService.decPrivateModes.reverseWraparound)return this._restrictCursor(),this._activeBuffer.x>0&&this._activeBuffer.x--,!0;if(this._restrictCursor(this._bufferService.cols),this._activeBuffer.x>0)this._activeBuffer.x--;else if(0===this._activeBuffer.x&&this._activeBuffer.y>this._activeBuffer.scrollTop&&this._activeBuffer.y<=this._activeBuffer.scrollBottom&&(null===(e=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y))||void 0===e?void 0:e.isWrapped)){this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y).isWrapped=!1,this._activeBuffer.y--,this._activeBuffer.x=this._bufferService.cols-1;const e=this._activeBuffer.lines.get(this._activeBuffer.ybase+this._activeBuffer.y);e.hasWidth(this._activeBuffer.x)&&!e.hasContent(this._activeBuffer.x)&&this._activeBuffer.x--}return this._restrictCursor(),!0}tab(){if(this._activeBuffer.x>=this._bufferService.cols)return!0;const e=this._activeBuffer.x;return this._activeBuffer.x=this._activeBuffer.nextStop(),this._optionsService.rawOptions.screenReaderMode&&this._onA11yTab.fire(this._activeBuffer.x-e),!0}shiftOut(){return this._charsetService.setgLevel(1),!0}shiftIn(){return this._charsetService.setgLevel(0),!0}_restrictCursor(e=this._bufferService.cols-1){this._activeBuffer.x=Math.min(e,Math.max(0,this._activeBuffer.x)),this._activeBuffer.y=this._coreService.decPrivateModes.origin?Math.min(this._activeBuffer.scrollBottom,Math.max(this._activeBuffer.scrollTop,this._activeBuffer.y)):Math.min(this._bufferService.rows-1,Math.max(0,this._activeBuffer.y)),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_setCursor(e,t){this._dirtyRowTracker.markDirty(this._activeBuffer.y),this._coreService.decPrivateModes.origin?(this._activeBuffer.x=e,this._activeBuffer.y=this._activeBuffer.scrollTop+t):(this._activeBuffer.x=e,this._activeBuffer.y=t),this._restrictCursor(),this._dirtyRowTracker.markDirty(this._activeBuffer.y)}_moveCursor(e,t){this._restrictCursor(),this._setCursor(this._activeBuffer.x+e,this._activeBuffer.y+t)}cursorUp(e){const t=this._activeBuffer.y-this._activeBuffer.scrollTop;return t>=0?this._moveCursor(0,-Math.min(t,e.params[0]||1)):this._moveCursor(0,-(e.params[0]||1)),!0}cursorDown(e){const t=this._activeBuffer.scrollBottom-this._activeBuffer.y;return t>=0?this._moveCursor(0,Math.min(t,e.params[0]||1)):this._moveCursor(0,e.params[0]||1),!0}cursorForward(e){return this._moveCursor(e.params[0]||1,0),!0}cursorBackward(e){return this._moveCursor(-(e.params[0]||1),0),!0}cursorNextLine(e){return this.cursorDown(e),this._activeBuffer.x=0,!0}cursorPrecedingLine(e){return this.cursorUp(e),this._activeBuffer.x=0,!0}cursorCharAbsolute(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0}cursorPosition(e){return this._setCursor(e.length>=2?(e.params[1]||1)-1:0,(e.params[0]||1)-1),!0}charPosAbsolute(e){return this._setCursor((e.params[0]||1)-1,this._activeBuffer.y),!0}hPositionRelative(e){return this._moveCursor(e.params[0]||1,0),!0}linePosAbsolute(e){return this._setCursor(this._activeBuffer.x,(e.params[0]||1)-1),!0}vPositionRelative(e){return this._moveCursor(0,e.params[0]||1),!0}hVPosition(e){return this.cursorPosition(e),!0}tabClear(e){const t=e.params[0];return 0===t?delete this._activeBuffer.tabs[this._activeBuffer.x]:3===t&&(this._activeBuffer.tabs={}),!0}cursorForwardTab(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let t=e.params[0]||1;for(;t--;)this._activeBuffer.x=this._activeBuffer.nextStop();return!0}cursorBackwardTab(e){if(this._activeBuffer.x>=this._bufferService.cols)return!0;let t=e.params[0]||1;for(;t--;)this._activeBuffer.x=this._activeBuffer.prevStop();return!0}selectProtected(e){const t=e.params[0];return 1===t&&(this._curAttrData.bg|=536870912),2!==t&&0!==t||(this._curAttrData.bg&=-536870913),!0}_eraseInBufferLine(e,t,i,s=!1,r=!1){const n=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);n.replaceCells(t,i,this._activeBuffer.getNullCell(this._eraseAttrData()),this._eraseAttrData(),r),s&&(n.isWrapped=!1)}_resetBufferLine(e,t=!1){const i=this._activeBuffer.lines.get(this._activeBuffer.ybase+e);i&&(i.fill(this._activeBuffer.getNullCell(this._eraseAttrData()),t),this._bufferService.buffer.clearMarkers(this._activeBuffer.ybase+e),i.isWrapped=!1)}eraseInDisplay(e,t=!1){let i;switch(this._restrictCursor(this._bufferService.cols),e.params[0]){case 0:for(i=this._activeBuffer.y,this._dirtyRowTracker.markDirty(i),this._eraseInBufferLine(i++,this._activeBuffer.x,this._bufferService.cols,0===this._activeBuffer.x,t);i=this._bufferService.cols&&(this._activeBuffer.lines.get(i+1).isWrapped=!1);i--;)this._resetBufferLine(i,t);this._dirtyRowTracker.markDirty(0);break;case 2:for(i=this._bufferService.rows,this._dirtyRowTracker.markDirty(i-1);i--;)this._resetBufferLine(i,t);this._dirtyRowTracker.markDirty(0);break;case 3:const e=this._activeBuffer.lines.length-this._bufferService.rows;e>0&&(this._activeBuffer.lines.trimStart(e),this._activeBuffer.ybase=Math.max(this._activeBuffer.ybase-e,0),this._activeBuffer.ydisp=Math.max(this._activeBuffer.ydisp-e,0),this._onScroll.fire(0))}return!0}eraseInLine(e,t=!1){switch(this._restrictCursor(this._bufferService.cols),e.params[0]){case 0:this._eraseInBufferLine(this._activeBuffer.y,this._activeBuffer.x,this._bufferService.cols,0===this._activeBuffer.x,t);break;case 1:this._eraseInBufferLine(this._activeBuffer.y,0,this._activeBuffer.x+1,!1,t);break;case 2:this._eraseInBufferLine(this._activeBuffer.y,0,this._bufferService.cols,!0,t)}return this._dirtyRowTracker.markDirty(this._activeBuffer.y),!0}insertLines(e){this._restrictCursor();let t=e.params[0]||1;if(this._activeBuffer.y>this._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.ythis._activeBuffer.scrollBottom||this._activeBuffer.y0||(this._is("xterm")||this._is("rxvt-unicode")||this._is("screen")?this._coreService.triggerDataEvent(n.C0.ESC+"[?1;2c"):this._is("linux")&&this._coreService.triggerDataEvent(n.C0.ESC+"[?6c")),!0}sendDeviceAttributesSecondary(e){return e.params[0]>0||(this._is("xterm")?this._coreService.triggerDataEvent(n.C0.ESC+"[>0;276;0c"):this._is("rxvt-unicode")?this._coreService.triggerDataEvent(n.C0.ESC+"[>85;95;0c"):this._is("linux")?this._coreService.triggerDataEvent(e.params[0]+"c"):this._is("screen")&&this._coreService.triggerDataEvent(n.C0.ESC+"[>83;40003;0c")),!0}_is(e){return 0===(this._optionsService.rawOptions.termName+"").indexOf(e)}setMode(e){for(let t=0;te?1:2,u=e.params[0];return f=u,v=t?2===u?4:4===u?_(o.modes.insertMode):12===u?3:20===u?_(d.convertEol):0:1===u?_(i.applicationCursorKeys):3===u?d.windowOptions.setWinLines?80===h?2:132===h?1:0:0:6===u?_(i.origin):7===u?_(i.wraparound):8===u?3:9===u?_("X10"===s):12===u?_(d.cursorBlink):25===u?_(!o.isCursorHidden):45===u?_(i.reverseWraparound):66===u?_(i.applicationKeypad):67===u?4:1e3===u?_("VT200"===s):1002===u?_("DRAG"===s):1003===u?_("ANY"===s):1004===u?_(i.sendFocus):1005===u?4:1006===u?_("SGR"===r):1015===u?4:1016===u?_("SGR_PIXELS"===r):1048===u?1:47===u||1047===u||1049===u?_(c===l):2004===u?_(i.bracketedPasteMode):0,o.triggerDataEvent(`${n.C0.ESC}[${t?"":"?"}${f};${v}$y`),!0;var f,v}_updateAttrColor(e,t,i,s,r){return 2===t?(e|=50331648,e&=-16777216,e|=f.AttributeData.fromColorRGB([i,s,r])):5===t&&(e&=-50331904,e|=33554432|255&i),e}_extractColor(e,t,i){const s=[0,0,-1,0,0,0];let r=0,n=0;do{if(s[n+r]=e.params[t+n],e.hasSubParams(t+n)){const i=e.getSubParams(t+n);let o=0;do{5===s[1]&&(r=1),s[n+o+1+r]=i[o]}while(++o=2||2===s[1]&&n+r>=5)break;s[1]&&(r=1)}while(++n+t5)&&(e=1),t.extended.underlineStyle=e,t.fg|=268435456,0===e&&(t.fg&=-268435457),t.updateExtended()}_processSGR0(e){e.fg=l.DEFAULT_ATTR_DATA.fg,e.bg=l.DEFAULT_ATTR_DATA.bg,e.extended=e.extended.clone(),e.extended.underlineStyle=0,e.extended.underlineColor&=-67108864,e.updateExtended()}charAttributes(e){if(1===e.length&&0===e.params[0])return this._processSGR0(this._curAttrData),!0;const t=e.length;let i;const s=this._curAttrData;for(let r=0;r=30&&i<=37?(s.fg&=-50331904,s.fg|=16777216|i-30):i>=40&&i<=47?(s.bg&=-50331904,s.bg|=16777216|i-40):i>=90&&i<=97?(s.fg&=-50331904,s.fg|=16777224|i-90):i>=100&&i<=107?(s.bg&=-50331904,s.bg|=16777224|i-100):0===i?this._processSGR0(s):1===i?s.fg|=134217728:3===i?s.bg|=67108864:4===i?(s.fg|=268435456,this._processUnderline(e.hasSubParams(r)?e.getSubParams(r)[0]:1,s)):5===i?s.fg|=536870912:7===i?s.fg|=67108864:8===i?s.fg|=1073741824:9===i?s.fg|=2147483648:2===i?s.bg|=134217728:21===i?this._processUnderline(2,s):22===i?(s.fg&=-134217729,s.bg&=-134217729):23===i?s.bg&=-67108865:24===i?(s.fg&=-268435457,this._processUnderline(0,s)):25===i?s.fg&=-536870913:27===i?s.fg&=-67108865:28===i?s.fg&=-1073741825:29===i?s.fg&=2147483647:39===i?(s.fg&=-67108864,s.fg|=16777215&l.DEFAULT_ATTR_DATA.fg):49===i?(s.bg&=-67108864,s.bg|=16777215&l.DEFAULT_ATTR_DATA.bg):38===i||48===i||58===i?r+=this._extractColor(e,r,s):53===i?s.bg|=1073741824:55===i?s.bg&=-1073741825:59===i?(s.extended=s.extended.clone(),s.extended.underlineColor=-1,s.updateExtended()):100===i?(s.fg&=-67108864,s.fg|=16777215&l.DEFAULT_ATTR_DATA.fg,s.bg&=-67108864,s.bg|=16777215&l.DEFAULT_ATTR_DATA.bg):this._logService.debug("Unknown SGR attribute: %d.",i);return!0}deviceStatus(e){switch(e.params[0]){case 5:this._coreService.triggerDataEvent(`${n.C0.ESC}[0n`);break;case 6:const e=this._activeBuffer.y+1,t=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`${n.C0.ESC}[${e};${t}R`)}return!0}deviceStatusPrivate(e){if(6===e.params[0]){const e=this._activeBuffer.y+1,t=this._activeBuffer.x+1;this._coreService.triggerDataEvent(`${n.C0.ESC}[?${e};${t}R`)}return!0}softReset(e){return this._coreService.isCursorHidden=!1,this._onRequestSyncScrollBar.fire(),this._activeBuffer.scrollTop=0,this._activeBuffer.scrollBottom=this._bufferService.rows-1,this._curAttrData=l.DEFAULT_ATTR_DATA.clone(),this._coreService.reset(),this._charsetService.reset(),this._activeBuffer.savedX=0,this._activeBuffer.savedY=this._activeBuffer.ybase,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,this._coreService.decPrivateModes.origin=!1,!0}setCursorStyle(e){const t=e.params[0]||1;switch(t){case 1:case 2:this._optionsService.options.cursorStyle="block";break;case 3:case 4:this._optionsService.options.cursorStyle="underline";break;case 5:case 6:this._optionsService.options.cursorStyle="bar"}const i=t%2==1;return this._optionsService.options.cursorBlink=i,!0}setScrollRegion(e){const t=e.params[0]||1;let i;return(e.length<2||(i=e.params[1])>this._bufferService.rows||0===i)&&(i=this._bufferService.rows),i>t&&(this._activeBuffer.scrollTop=t-1,this._activeBuffer.scrollBottom=i-1,this._setCursor(0,0)),!0}windowOptions(e){if(!b(e.params[0],this._optionsService.rawOptions.windowOptions))return!0;const t=e.length>1?e.params[1]:0;switch(e.params[0]){case 14:2!==t&&this._onRequestWindowsOptionsReport.fire(y.GET_WIN_SIZE_PIXELS);break;case 16:this._onRequestWindowsOptionsReport.fire(y.GET_CELL_SIZE_PIXELS);break;case 18:this._bufferService&&this._coreService.triggerDataEvent(`${n.C0.ESC}[8;${this._bufferService.rows};${this._bufferService.cols}t`);break;case 22:0!==t&&2!==t||(this._windowTitleStack.push(this._windowTitle),this._windowTitleStack.length>10&&this._windowTitleStack.shift()),0!==t&&1!==t||(this._iconNameStack.push(this._iconName),this._iconNameStack.length>10&&this._iconNameStack.shift());break;case 23:0!==t&&2!==t||this._windowTitleStack.length&&this.setTitle(this._windowTitleStack.pop()),0!==t&&1!==t||this._iconNameStack.length&&this.setIconName(this._iconNameStack.pop())}return!0}saveCursor(e){return this._activeBuffer.savedX=this._activeBuffer.x,this._activeBuffer.savedY=this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.savedCurAttrData.fg=this._curAttrData.fg,this._activeBuffer.savedCurAttrData.bg=this._curAttrData.bg,this._activeBuffer.savedCharset=this._charsetService.charset,!0}restoreCursor(e){return this._activeBuffer.x=this._activeBuffer.savedX||0,this._activeBuffer.y=Math.max(this._activeBuffer.savedY-this._activeBuffer.ybase,0),this._curAttrData.fg=this._activeBuffer.savedCurAttrData.fg,this._curAttrData.bg=this._activeBuffer.savedCurAttrData.bg,this._charsetService.charset=this._savedCharset,this._activeBuffer.savedCharset&&(this._charsetService.charset=this._activeBuffer.savedCharset),this._restrictCursor(),!0}setTitle(e){return this._windowTitle=e,this._onTitleChange.fire(e),!0}setIconName(e){return this._iconName=e,!0}setOrReportIndexedColor(e){const t=[],i=e.split(";");for(;i.length>1;){const e=i.shift(),s=i.shift();if(/^\d+$/.exec(e)){const i=parseInt(e);if(L(i))if("?"===s)t.push({type:0,index:i});else{const e=(0,m.parseColor)(s);e&&t.push({type:1,index:i,color:e})}}}return t.length&&this._onColor.fire(t),!0}setHyperlink(e){const t=e.split(";");return!(t.length<2)&&(t[1]?this._createHyperlink(t[0],t[1]):!t[0]&&this._finishHyperlink())}_createHyperlink(e,t){this._getCurrentLinkId()&&this._finishHyperlink();const i=e.split(":");let s;const r=i.findIndex((e=>e.startsWith("id=")));return-1!==r&&(s=i[r].slice(3)||void 0),this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=this._oscLinkService.registerLink({id:s,uri:t}),this._curAttrData.updateExtended(),!0}_finishHyperlink(){return this._curAttrData.extended=this._curAttrData.extended.clone(),this._curAttrData.extended.urlId=0,this._curAttrData.updateExtended(),!0}_setOrReportSpecialColor(e,t){const i=e.split(";");for(let e=0;e=this._specialColors.length);++e,++t)if("?"===i[e])this._onColor.fire([{type:0,index:this._specialColors[t]}]);else{const s=(0,m.parseColor)(i[e]);s&&this._onColor.fire([{type:1,index:this._specialColors[t],color:s}])}return!0}setOrReportFgColor(e){return this._setOrReportSpecialColor(e,0)}setOrReportBgColor(e){return this._setOrReportSpecialColor(e,1)}setOrReportCursorColor(e){return this._setOrReportSpecialColor(e,2)}restoreIndexedColor(e){if(!e)return this._onColor.fire([{type:2}]),!0;const t=[],i=e.split(";");for(let e=0;e=this._bufferService.rows&&(this._activeBuffer.y=this._bufferService.rows-1),this._restrictCursor(),!0}tabSet(){return this._activeBuffer.tabs[this._activeBuffer.x]=!0,!0}reverseIndex(){if(this._restrictCursor(),this._activeBuffer.y===this._activeBuffer.scrollTop){const e=this._activeBuffer.scrollBottom-this._activeBuffer.scrollTop;this._activeBuffer.lines.shiftElements(this._activeBuffer.ybase+this._activeBuffer.y,e,1),this._activeBuffer.lines.set(this._activeBuffer.ybase+this._activeBuffer.y,this._activeBuffer.getBlankLine(this._eraseAttrData())),this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop,this._activeBuffer.scrollBottom)}else this._activeBuffer.y--,this._restrictCursor();return!0}fullReset(){return this._parser.reset(),this._onRequestReset.fire(),!0}reset(){this._curAttrData=l.DEFAULT_ATTR_DATA.clone(),this._eraseAttrDataInternal=l.DEFAULT_ATTR_DATA.clone()}_eraseAttrData(){return this._eraseAttrDataInternal.bg&=-67108864,this._eraseAttrDataInternal.bg|=67108863&this._curAttrData.bg,this._eraseAttrDataInternal}setgLevel(e){return this._charsetService.setgLevel(e),!0}screenAlignmentPattern(){const e=new u.CellData;e.content=1<<22|"E".charCodeAt(0),e.fg=this._curAttrData.fg,e.bg=this._curAttrData.bg,this._setCursor(0,0);for(let t=0;t(this._coreService.triggerDataEvent(`${n.C0.ESC}${e}${n.C0.ESC}\\`),!0))('"q'===e?`P1$r${this._curAttrData.isProtected()?1:0}"q`:'"p'===e?'P1$r61;1"p':"r"===e?`P1$r${i.scrollTop+1};${i.scrollBottom+1}r`:"m"===e?"P1$r0m":" q"===e?`P1$r${{block:2,underline:4,bar:6}[s.cursorStyle]-(s.cursorBlink?1:0)} q`:"P0$r")}markRangeDirty(e,t){this._dirtyRowTracker.markRangeDirty(e,t)}}t.InputHandler=E;let k=class{constructor(e){this._bufferService=e,this.clearRange()}clearRange(){this.start=this._bufferService.buffer.y,this.end=this._bufferService.buffer.y}markDirty(e){ethis.end&&(this.end=e)}markRangeDirty(e,t){e>t&&(w=e,e=t,t=w),ethis.end&&(this.end=t)}markAllDirty(){this.markRangeDirty(0,this._bufferService.rows-1)}};function L(e){return 0<=e&&e<256}k=s([r(0,v.IBufferService)],k)},844:(e,t)=>{function i(e){for(const t of e)t.dispose();e.length=0}Object.defineProperty(t,"__esModule",{value:!0}),t.getDisposeArrayDisposable=t.disposeArray=t.toDisposable=t.MutableDisposable=t.Disposable=void 0,t.Disposable=class{constructor(){this._disposables=[],this._isDisposed=!1}dispose(){this._isDisposed=!0;for(const e of this._disposables)e.dispose();this._disposables.length=0}register(e){return this._disposables.push(e),e}unregister(e){const t=this._disposables.indexOf(e);-1!==t&&this._disposables.splice(t,1)}},t.MutableDisposable=class{constructor(){this._isDisposed=!1}get value(){return this._isDisposed?void 0:this._value}set value(e){var t;this._isDisposed||e===this._value||(null===(t=this._value)||void 0===t||t.dispose(),this._value=e)}clear(){this.value=void 0}dispose(){var e;this._isDisposed=!0,null===(e=this._value)||void 0===e||e.dispose(),this._value=void 0}},t.toDisposable=function(e){return{dispose:e}},t.disposeArray=i,t.getDisposeArrayDisposable=function(e){return{dispose:()=>i(e)}}},1505:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.FourKeyMap=t.TwoKeyMap=void 0;class i{constructor(){this._data={}}set(e,t,i){this._data[e]||(this._data[e]={}),this._data[e][t]=i}get(e,t){return this._data[e]?this._data[e][t]:void 0}clear(){this._data={}}}t.TwoKeyMap=i,t.FourKeyMap=class{constructor(){this._data=new i}set(e,t,s,r,n){this._data.get(e,t)||this._data.set(e,t,new i),this._data.get(e,t).set(s,r,n)}get(e,t,i,s){var r;return null===(r=this._data.get(e,t))||void 0===r?void 0:r.get(i,s)}clear(){this._data.clear()}}},6114:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.isChromeOS=t.isLinux=t.isWindows=t.isIphone=t.isIpad=t.isMac=t.getSafariVersion=t.isSafari=t.isLegacyEdge=t.isFirefox=t.isNode=void 0,t.isNode="undefined"==typeof navigator;const i=t.isNode?"node":navigator.userAgent,s=t.isNode?"node":navigator.platform;t.isFirefox=i.includes("Firefox"),t.isLegacyEdge=i.includes("Edge"),t.isSafari=/^((?!chrome|android).)*safari/i.test(i),t.getSafariVersion=function(){if(!t.isSafari)return 0;const e=i.match(/Version\/(\d+)/);return null===e||e.length<2?0:parseInt(e[1])},t.isMac=["Macintosh","MacIntel","MacPPC","Mac68K"].includes(s),t.isIpad="iPad"===s,t.isIphone="iPhone"===s,t.isWindows=["Windows","Win16","Win32","WinCE"].includes(s),t.isLinux=s.indexOf("Linux")>=0,t.isChromeOS=/\bCrOS\b/.test(i)},6106:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.SortedList=void 0;let i=0;t.SortedList=class{constructor(e){this._getKey=e,this._array=[]}clear(){this._array.length=0}insert(e){0!==this._array.length?(i=this._search(this._getKey(e)),this._array.splice(i,0,e)):this._array.push(e)}delete(e){if(0===this._array.length)return!1;const t=this._getKey(e);if(void 0===t)return!1;if(i=this._search(t),-1===i)return!1;if(this._getKey(this._array[i])!==t)return!1;do{if(this._array[i]===e)return this._array.splice(i,1),!0}while(++i=this._array.length)&&this._getKey(this._array[i])===e))do{yield this._array[i]}while(++i=this._array.length)&&this._getKey(this._array[i])===e))do{t(this._array[i])}while(++i=t;){let s=t+i>>1;const r=this._getKey(this._array[s]);if(r>e)i=s-1;else{if(!(r0&&this._getKey(this._array[s-1])===e;)s--;return s}t=s+1}}return t}}},7226:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DebouncedIdleTask=t.IdleTaskQueue=t.PriorityTaskQueue=void 0;const s=i(6114);class r{constructor(){this._tasks=[],this._i=0}enqueue(e){this._tasks.push(e),this._start()}flush(){for(;this._ir)return s-t<-20&&console.warn(`task queue exceeded allotted deadline by ${Math.abs(Math.round(s-t))}ms`),void this._start();s=r}this.clear()}}class n extends r{_requestCallback(e){return setTimeout((()=>e(this._createDeadline(16))))}_cancelCallback(e){clearTimeout(e)}_createDeadline(e){const t=Date.now()+e;return{timeRemaining:()=>Math.max(0,t-Date.now())}}}t.PriorityTaskQueue=n,t.IdleTaskQueue=!s.isNode&&"requestIdleCallback"in window?class extends r{_requestCallback(e){return requestIdleCallback(e)}_cancelCallback(e){cancelIdleCallback(e)}}:n,t.DebouncedIdleTask=class{constructor(){this._queue=new t.IdleTaskQueue}set(e){this._queue.clear(),this._queue.enqueue(e)}flush(){this._queue.flush()}}},9282:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.updateWindowsModeWrappedState=void 0;const s=i(643);t.updateWindowsModeWrappedState=function(e){const t=e.buffer.lines.get(e.buffer.ybase+e.buffer.y-1),i=null==t?void 0:t.get(e.cols-1),r=e.buffer.lines.get(e.buffer.ybase+e.buffer.y);r&&i&&(r.isWrapped=i[s.CHAR_DATA_CODE_INDEX]!==s.NULL_CELL_CODE&&i[s.CHAR_DATA_CODE_INDEX]!==s.WHITESPACE_CELL_CODE)}},3734:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ExtendedAttrs=t.AttributeData=void 0;class i{constructor(){this.fg=0,this.bg=0,this.extended=new s}static toColorRGB(e){return[e>>>16&255,e>>>8&255,255&e]}static fromColorRGB(e){return(255&e[0])<<16|(255&e[1])<<8|255&e[2]}clone(){const e=new i;return e.fg=this.fg,e.bg=this.bg,e.extended=this.extended.clone(),e}isInverse(){return 67108864&this.fg}isBold(){return 134217728&this.fg}isUnderline(){return this.hasExtendedAttrs()&&0!==this.extended.underlineStyle?1:268435456&this.fg}isBlink(){return 536870912&this.fg}isInvisible(){return 1073741824&this.fg}isItalic(){return 67108864&this.bg}isDim(){return 134217728&this.bg}isStrikethrough(){return 2147483648&this.fg}isProtected(){return 536870912&this.bg}isOverline(){return 1073741824&this.bg}getFgColorMode(){return 50331648&this.fg}getBgColorMode(){return 50331648&this.bg}isFgRGB(){return 50331648==(50331648&this.fg)}isBgRGB(){return 50331648==(50331648&this.bg)}isFgPalette(){return 16777216==(50331648&this.fg)||33554432==(50331648&this.fg)}isBgPalette(){return 16777216==(50331648&this.bg)||33554432==(50331648&this.bg)}isFgDefault(){return 0==(50331648&this.fg)}isBgDefault(){return 0==(50331648&this.bg)}isAttributeDefault(){return 0===this.fg&&0===this.bg}getFgColor(){switch(50331648&this.fg){case 16777216:case 33554432:return 255&this.fg;case 50331648:return 16777215&this.fg;default:return-1}}getBgColor(){switch(50331648&this.bg){case 16777216:case 33554432:return 255&this.bg;case 50331648:return 16777215&this.bg;default:return-1}}hasExtendedAttrs(){return 268435456&this.bg}updateExtended(){this.extended.isEmpty()?this.bg&=-268435457:this.bg|=268435456}getUnderlineColor(){if(268435456&this.bg&&~this.extended.underlineColor)switch(50331648&this.extended.underlineColor){case 16777216:case 33554432:return 255&this.extended.underlineColor;case 50331648:return 16777215&this.extended.underlineColor;default:return this.getFgColor()}return this.getFgColor()}getUnderlineColorMode(){return 268435456&this.bg&&~this.extended.underlineColor?50331648&this.extended.underlineColor:this.getFgColorMode()}isUnderlineColorRGB(){return 268435456&this.bg&&~this.extended.underlineColor?50331648==(50331648&this.extended.underlineColor):this.isFgRGB()}isUnderlineColorPalette(){return 268435456&this.bg&&~this.extended.underlineColor?16777216==(50331648&this.extended.underlineColor)||33554432==(50331648&this.extended.underlineColor):this.isFgPalette()}isUnderlineColorDefault(){return 268435456&this.bg&&~this.extended.underlineColor?0==(50331648&this.extended.underlineColor):this.isFgDefault()}getUnderlineStyle(){return 268435456&this.fg?268435456&this.bg?this.extended.underlineStyle:1:0}}t.AttributeData=i;class s{get ext(){return this._urlId?-469762049&this._ext|this.underlineStyle<<26:this._ext}set ext(e){this._ext=e}get underlineStyle(){return this._urlId?5:(469762048&this._ext)>>26}set underlineStyle(e){this._ext&=-469762049,this._ext|=e<<26&469762048}get underlineColor(){return 67108863&this._ext}set underlineColor(e){this._ext&=-67108864,this._ext|=67108863&e}get urlId(){return this._urlId}set urlId(e){this._urlId=e}constructor(e=0,t=0){this._ext=0,this._urlId=0,this._ext=e,this._urlId=t}clone(){return new s(this._ext,this._urlId)}isEmpty(){return 0===this.underlineStyle&&0===this._urlId}}t.ExtendedAttrs=s},9092:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Buffer=t.MAX_BUFFER_SIZE=void 0;const s=i(6349),r=i(7226),n=i(3734),o=i(8437),a=i(4634),h=i(511),c=i(643),l=i(4863),d=i(7116);t.MAX_BUFFER_SIZE=4294967295,t.Buffer=class{constructor(e,t,i){this._hasScrollback=e,this._optionsService=t,this._bufferService=i,this.ydisp=0,this.ybase=0,this.y=0,this.x=0,this.tabs={},this.savedY=0,this.savedX=0,this.savedCurAttrData=o.DEFAULT_ATTR_DATA.clone(),this.savedCharset=d.DEFAULT_CHARSET,this.markers=[],this._nullCell=h.CellData.fromCharData([0,c.NULL_CELL_CHAR,c.NULL_CELL_WIDTH,c.NULL_CELL_CODE]),this._whitespaceCell=h.CellData.fromCharData([0,c.WHITESPACE_CELL_CHAR,c.WHITESPACE_CELL_WIDTH,c.WHITESPACE_CELL_CODE]),this._isClearing=!1,this._memoryCleanupQueue=new r.IdleTaskQueue,this._memoryCleanupPosition=0,this._cols=this._bufferService.cols,this._rows=this._bufferService.rows,this.lines=new s.CircularList(this._getCorrectBufferLength(this._rows)),this.scrollTop=0,this.scrollBottom=this._rows-1,this.setupTabStops()}getNullCell(e){return e?(this._nullCell.fg=e.fg,this._nullCell.bg=e.bg,this._nullCell.extended=e.extended):(this._nullCell.fg=0,this._nullCell.bg=0,this._nullCell.extended=new n.ExtendedAttrs),this._nullCell}getWhitespaceCell(e){return e?(this._whitespaceCell.fg=e.fg,this._whitespaceCell.bg=e.bg,this._whitespaceCell.extended=e.extended):(this._whitespaceCell.fg=0,this._whitespaceCell.bg=0,this._whitespaceCell.extended=new n.ExtendedAttrs),this._whitespaceCell}getBlankLine(e,t){return new o.BufferLine(this._bufferService.cols,this.getNullCell(e),t)}get hasScrollback(){return this._hasScrollback&&this.lines.maxLength>this._rows}get isCursorInViewport(){const e=this.ybase+this.y-this.ydisp;return e>=0&&et.MAX_BUFFER_SIZE?t.MAX_BUFFER_SIZE:i}fillViewportRows(e){if(0===this.lines.length){void 0===e&&(e=o.DEFAULT_ATTR_DATA);let t=this._rows;for(;t--;)this.lines.push(this.getBlankLine(e))}}clear(){this.ydisp=0,this.ybase=0,this.y=0,this.x=0,this.lines=new s.CircularList(this._getCorrectBufferLength(this._rows)),this.scrollTop=0,this.scrollBottom=this._rows-1,this.setupTabStops()}resize(e,t){const i=this.getNullCell(o.DEFAULT_ATTR_DATA);let s=0;const r=this._getCorrectBufferLength(t);if(r>this.lines.maxLength&&(this.lines.maxLength=r),this.lines.length>0){if(this._cols0&&this.lines.length<=this.ybase+this.y+n+1?(this.ybase--,n++,this.ydisp>0&&this.ydisp--):this.lines.push(new o.BufferLine(e,i)));else for(let e=this._rows;e>t;e--)this.lines.length>t+this.ybase&&(this.lines.length>this.ybase+this.y+1?this.lines.pop():(this.ybase++,this.ydisp++));if(r0&&(this.lines.trimStart(e),this.ybase=Math.max(this.ybase-e,0),this.ydisp=Math.max(this.ydisp-e,0),this.savedY=Math.max(this.savedY-e,0)),this.lines.maxLength=r}this.x=Math.min(this.x,e-1),this.y=Math.min(this.y,t-1),n&&(this.y+=n),this.savedX=Math.min(this.savedX,e-1),this.scrollTop=0}if(this.scrollBottom=t-1,this._isReflowEnabled&&(this._reflow(e,t),this._cols>e))for(let t=0;t.1*this.lines.length&&(this._memoryCleanupPosition=0,this._memoryCleanupQueue.enqueue((()=>this._batchedMemoryCleanup())))}_batchedMemoryCleanup(){let e=!0;this._memoryCleanupPosition>=this.lines.length&&(this._memoryCleanupPosition=0,e=!1);let t=0;for(;this._memoryCleanupPosition100)return!0;return e}get _isReflowEnabled(){const e=this._optionsService.rawOptions.windowsPty;return e&&e.buildNumber?this._hasScrollback&&"conpty"===e.backend&&e.buildNumber>=21376:this._hasScrollback&&!this._optionsService.rawOptions.windowsMode}_reflow(e,t){this._cols!==e&&(e>this._cols?this._reflowLarger(e,t):this._reflowSmaller(e,t))}_reflowLarger(e,t){const i=(0,a.reflowLargerGetLinesToRemove)(this.lines,this._cols,e,this.ybase+this.y,this.getNullCell(o.DEFAULT_ATTR_DATA));if(i.length>0){const s=(0,a.reflowLargerCreateNewLayout)(this.lines,i);(0,a.reflowLargerApplyNewLayout)(this.lines,s.layout),this._reflowLargerAdjustViewport(e,t,s.countRemoved)}}_reflowLargerAdjustViewport(e,t,i){const s=this.getNullCell(o.DEFAULT_ATTR_DATA);let r=i;for(;r-- >0;)0===this.ybase?(this.y>0&&this.y--,this.lines.length=0;n--){let h=this.lines.get(n);if(!h||!h.isWrapped&&h.getTrimmedLength()<=e)continue;const c=[h];for(;h.isWrapped&&n>0;)h=this.lines.get(--n),c.unshift(h);const l=this.ybase+this.y;if(l>=n&&l0&&(s.push({start:n+c.length+r,newLines:v}),r+=v.length),c.push(...v);let p=_.length-1,g=_[p];0===g&&(p--,g=_[p]);let m=c.length-u-1,S=d;for(;m>=0;){const e=Math.min(S,g);if(void 0===c[p])break;if(c[p].copyCellsFrom(c[m],S-e,g-e,e,!0),g-=e,0===g&&(p--,g=_[p]),S-=e,0===S){m--;const e=Math.max(m,0);S=(0,a.getWrappedLineTrimmedLength)(c,e,this._cols)}}for(let t=0;t0;)0===this.ybase?this.y0){const e=[],t=[];for(let e=0;e=0;c--)if(a&&a.start>n+h){for(let e=a.newLines.length-1;e>=0;e--)this.lines.set(c--,a.newLines[e]);c++,e.push({index:n+1,amount:a.newLines.length}),h+=a.newLines.length,a=s[++o]}else this.lines.set(c,t[n--]);let c=0;for(let t=e.length-1;t>=0;t--)e[t].index+=c,this.lines.onInsertEmitter.fire(e[t]),c+=e[t].amount;const l=Math.max(0,i+r-this.lines.maxLength);l>0&&this.lines.onTrimEmitter.fire(l)}}translateBufferLineToString(e,t,i=0,s){const r=this.lines.get(e);return r?r.translateToString(t,i,s):""}getWrappedRangeForLine(e){let t=e,i=e;for(;t>0&&this.lines.get(t).isWrapped;)t--;for(;i+10;);return e>=this._cols?this._cols-1:e<0?0:e}nextStop(e){for(null==e&&(e=this.x);!this.tabs[++e]&&e=this._cols?this._cols-1:e<0?0:e}clearMarkers(e){this._isClearing=!0;for(let t=0;t{t.line-=e,t.line<0&&t.dispose()}))),t.register(this.lines.onInsert((e=>{t.line>=e.index&&(t.line+=e.amount)}))),t.register(this.lines.onDelete((e=>{t.line>=e.index&&t.linee.index&&(t.line-=e.amount)}))),t.register(t.onDispose((()=>this._removeMarker(t)))),t}_removeMarker(e){this._isClearing||this.markers.splice(this.markers.indexOf(e),1)}}},8437:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferLine=t.DEFAULT_ATTR_DATA=void 0;const s=i(3734),r=i(511),n=i(643),o=i(482);t.DEFAULT_ATTR_DATA=Object.freeze(new s.AttributeData);let a=0;class h{constructor(e,t,i=!1){this.isWrapped=i,this._combined={},this._extendedAttrs={},this._data=new Uint32Array(3*e);const s=t||r.CellData.fromCharData([0,n.NULL_CELL_CHAR,n.NULL_CELL_WIDTH,n.NULL_CELL_CODE]);for(let t=0;t>22,2097152&t?this._combined[e].charCodeAt(this._combined[e].length-1):i]}set(e,t){this._data[3*e+1]=t[n.CHAR_DATA_ATTR_INDEX],t[n.CHAR_DATA_CHAR_INDEX].length>1?(this._combined[e]=t[1],this._data[3*e+0]=2097152|e|t[n.CHAR_DATA_WIDTH_INDEX]<<22):this._data[3*e+0]=t[n.CHAR_DATA_CHAR_INDEX].charCodeAt(0)|t[n.CHAR_DATA_WIDTH_INDEX]<<22}getWidth(e){return this._data[3*e+0]>>22}hasWidth(e){return 12582912&this._data[3*e+0]}getFg(e){return this._data[3*e+1]}getBg(e){return this._data[3*e+2]}hasContent(e){return 4194303&this._data[3*e+0]}getCodePoint(e){const t=this._data[3*e+0];return 2097152&t?this._combined[e].charCodeAt(this._combined[e].length-1):2097151&t}isCombined(e){return 2097152&this._data[3*e+0]}getString(e){const t=this._data[3*e+0];return 2097152&t?this._combined[e]:2097151&t?(0,o.stringFromCodePoint)(2097151&t):""}isProtected(e){return 536870912&this._data[3*e+2]}loadCell(e,t){return a=3*e,t.content=this._data[a+0],t.fg=this._data[a+1],t.bg=this._data[a+2],2097152&t.content&&(t.combinedData=this._combined[e]),268435456&t.bg&&(t.extended=this._extendedAttrs[e]),t}setCell(e,t){2097152&t.content&&(this._combined[e]=t.combinedData),268435456&t.bg&&(this._extendedAttrs[e]=t.extended),this._data[3*e+0]=t.content,this._data[3*e+1]=t.fg,this._data[3*e+2]=t.bg}setCellFromCodePoint(e,t,i,s,r,n){268435456&r&&(this._extendedAttrs[e]=n),this._data[3*e+0]=t|i<<22,this._data[3*e+1]=s,this._data[3*e+2]=r}addCodepointToCell(e,t){let i=this._data[3*e+0];2097152&i?this._combined[e]+=(0,o.stringFromCodePoint)(t):(2097151&i?(this._combined[e]=(0,o.stringFromCodePoint)(2097151&i)+(0,o.stringFromCodePoint)(t),i&=-2097152,i|=2097152):i=t|1<<22,this._data[3*e+0]=i)}insertCells(e,t,i,n){if((e%=this.length)&&2===this.getWidth(e-1)&&this.setCellFromCodePoint(e-1,0,1,(null==n?void 0:n.fg)||0,(null==n?void 0:n.bg)||0,(null==n?void 0:n.extended)||new s.ExtendedAttrs),t=0;--i)this.setCell(e+t+i,this.loadCell(e+i,s));for(let s=0;sthis.length){if(this._data.buffer.byteLength>=4*i)this._data=new Uint32Array(this._data.buffer,0,i);else{const e=new Uint32Array(i);e.set(this._data),this._data=e}for(let i=this.length;i=e&&delete this._combined[s]}const s=Object.keys(this._extendedAttrs);for(let t=0;t=e&&delete this._extendedAttrs[i]}}return this.length=e,4*i*2=0;--e)if(4194303&this._data[3*e+0])return e+(this._data[3*e+0]>>22);return 0}getNoBgTrimmedLength(){for(let e=this.length-1;e>=0;--e)if(4194303&this._data[3*e+0]||50331648&this._data[3*e+2])return e+(this._data[3*e+0]>>22);return 0}copyCellsFrom(e,t,i,s,r){const n=e._data;if(r)for(let r=s-1;r>=0;r--){for(let e=0;e<3;e++)this._data[3*(i+r)+e]=n[3*(t+r)+e];268435456&n[3*(t+r)+2]&&(this._extendedAttrs[i+r]=e._extendedAttrs[t+r])}else for(let r=0;r=t&&(this._combined[r-t+i]=e._combined[r])}}translateToString(e=!1,t=0,i=this.length){e&&(i=Math.min(i,this.getTrimmedLength()));let s="";for(;t>22||1}return s}}t.BufferLine=h},4841:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.getRangeLength=void 0,t.getRangeLength=function(e,t){if(e.start.y>e.end.y)throw new Error(`Buffer range end (${e.end.x}, ${e.end.y}) cannot be before start (${e.start.x}, ${e.start.y})`);return t*(e.end.y-e.start.y)+(e.end.x-e.start.x+1)}},4634:(e,t)=>{function i(e,t,i){if(t===e.length-1)return e[t].getTrimmedLength();const s=!e[t].hasContent(i-1)&&1===e[t].getWidth(i-1),r=2===e[t+1].getWidth(0);return s&&r?i-1:i}Object.defineProperty(t,"__esModule",{value:!0}),t.getWrappedLineTrimmedLength=t.reflowSmallerGetNewLineLengths=t.reflowLargerApplyNewLayout=t.reflowLargerCreateNewLayout=t.reflowLargerGetLinesToRemove=void 0,t.reflowLargerGetLinesToRemove=function(e,t,s,r,n){const o=[];for(let a=0;a=a&&r0&&(e>d||0===l[e].getTrimmedLength());e--)v++;v>0&&(o.push(a+l.length-v),o.push(v)),a+=l.length-1}return o},t.reflowLargerCreateNewLayout=function(e,t){const i=[];let s=0,r=t[s],n=0;for(let o=0;oi(e,r,t))).reduce(((e,t)=>e+t));let o=0,a=0,h=0;for(;hc&&(o-=c,a++);const l=2===e[a].getWidth(o-1);l&&o--;const d=l?s-1:s;r.push(d),h+=d}return r},t.getWrappedLineTrimmedLength=i},5295:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferSet=void 0;const s=i(8460),r=i(844),n=i(9092);class o extends r.Disposable{constructor(e,t){super(),this._optionsService=e,this._bufferService=t,this._onBufferActivate=this.register(new s.EventEmitter),this.onBufferActivate=this._onBufferActivate.event,this.reset(),this.register(this._optionsService.onSpecificOptionChange("scrollback",(()=>this.resize(this._bufferService.cols,this._bufferService.rows)))),this.register(this._optionsService.onSpecificOptionChange("tabStopWidth",(()=>this.setupTabStops())))}reset(){this._normal=new n.Buffer(!0,this._optionsService,this._bufferService),this._normal.fillViewportRows(),this._alt=new n.Buffer(!1,this._optionsService,this._bufferService),this._activeBuffer=this._normal,this._onBufferActivate.fire({activeBuffer:this._normal,inactiveBuffer:this._alt}),this.setupTabStops()}get alt(){return this._alt}get active(){return this._activeBuffer}get normal(){return this._normal}activateNormalBuffer(){this._activeBuffer!==this._normal&&(this._normal.x=this._alt.x,this._normal.y=this._alt.y,this._alt.clearAllMarkers(),this._alt.clear(),this._activeBuffer=this._normal,this._onBufferActivate.fire({activeBuffer:this._normal,inactiveBuffer:this._alt}))}activateAltBuffer(e){this._activeBuffer!==this._alt&&(this._alt.fillViewportRows(e),this._alt.x=this._normal.x,this._alt.y=this._normal.y,this._activeBuffer=this._alt,this._onBufferActivate.fire({activeBuffer:this._alt,inactiveBuffer:this._normal}))}resize(e,t){this._normal.resize(e,t),this._alt.resize(e,t),this.setupTabStops(e)}setupTabStops(e){this._normal.setupTabStops(e),this._alt.setupTabStops(e)}}t.BufferSet=o},511:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.CellData=void 0;const s=i(482),r=i(643),n=i(3734);class o extends n.AttributeData{constructor(){super(...arguments),this.content=0,this.fg=0,this.bg=0,this.extended=new n.ExtendedAttrs,this.combinedData=""}static fromCharData(e){const t=new o;return t.setFromCharData(e),t}isCombined(){return 2097152&this.content}getWidth(){return this.content>>22}getChars(){return 2097152&this.content?this.combinedData:2097151&this.content?(0,s.stringFromCodePoint)(2097151&this.content):""}getCode(){return this.isCombined()?this.combinedData.charCodeAt(this.combinedData.length-1):2097151&this.content}setFromCharData(e){this.fg=e[r.CHAR_DATA_ATTR_INDEX],this.bg=0;let t=!1;if(e[r.CHAR_DATA_CHAR_INDEX].length>2)t=!0;else if(2===e[r.CHAR_DATA_CHAR_INDEX].length){const i=e[r.CHAR_DATA_CHAR_INDEX].charCodeAt(0);if(55296<=i&&i<=56319){const s=e[r.CHAR_DATA_CHAR_INDEX].charCodeAt(1);56320<=s&&s<=57343?this.content=1024*(i-55296)+s-56320+65536|e[r.CHAR_DATA_WIDTH_INDEX]<<22:t=!0}else t=!0}else this.content=e[r.CHAR_DATA_CHAR_INDEX].charCodeAt(0)|e[r.CHAR_DATA_WIDTH_INDEX]<<22;t&&(this.combinedData=e[r.CHAR_DATA_CHAR_INDEX],this.content=2097152|e[r.CHAR_DATA_WIDTH_INDEX]<<22)}getAsCharData(){return[this.fg,this.getChars(),this.getWidth(),this.getCode()]}}t.CellData=o},643:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.WHITESPACE_CELL_CODE=t.WHITESPACE_CELL_WIDTH=t.WHITESPACE_CELL_CHAR=t.NULL_CELL_CODE=t.NULL_CELL_WIDTH=t.NULL_CELL_CHAR=t.CHAR_DATA_CODE_INDEX=t.CHAR_DATA_WIDTH_INDEX=t.CHAR_DATA_CHAR_INDEX=t.CHAR_DATA_ATTR_INDEX=t.DEFAULT_EXT=t.DEFAULT_ATTR=t.DEFAULT_COLOR=void 0,t.DEFAULT_COLOR=0,t.DEFAULT_ATTR=256|t.DEFAULT_COLOR<<9,t.DEFAULT_EXT=0,t.CHAR_DATA_ATTR_INDEX=0,t.CHAR_DATA_CHAR_INDEX=1,t.CHAR_DATA_WIDTH_INDEX=2,t.CHAR_DATA_CODE_INDEX=3,t.NULL_CELL_CHAR="",t.NULL_CELL_WIDTH=1,t.NULL_CELL_CODE=0,t.WHITESPACE_CELL_CHAR=" ",t.WHITESPACE_CELL_WIDTH=1,t.WHITESPACE_CELL_CODE=32},4863:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Marker=void 0;const s=i(8460),r=i(844);class n{get id(){return this._id}constructor(e){this.line=e,this.isDisposed=!1,this._disposables=[],this._id=n._nextId++,this._onDispose=this.register(new s.EventEmitter),this.onDispose=this._onDispose.event}dispose(){this.isDisposed||(this.isDisposed=!0,this.line=-1,this._onDispose.fire(),(0,r.disposeArray)(this._disposables),this._disposables.length=0)}register(e){return this._disposables.push(e),e}}t.Marker=n,n._nextId=1},7116:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DEFAULT_CHARSET=t.CHARSETS=void 0,t.CHARSETS={},t.DEFAULT_CHARSET=t.CHARSETS.B,t.CHARSETS[0]={"`":"◆",a:"▒",b:"␉",c:"␌",d:"␍",e:"␊",f:"°",g:"±",h:"␤",i:"␋",j:"┘",k:"┐",l:"┌",m:"└",n:"┼",o:"⎺",p:"⎻",q:"─",r:"⎼",s:"⎽",t:"├",u:"┤",v:"┴",w:"┬",x:"│",y:"≤",z:"≥","{":"π","|":"≠","}":"£","~":"·"},t.CHARSETS.A={"#":"£"},t.CHARSETS.B=void 0,t.CHARSETS[4]={"#":"£","@":"¾","[":"ij","\\":"½","]":"|","{":"¨","|":"f","}":"¼","~":"´"},t.CHARSETS.C=t.CHARSETS[5]={"[":"Ä","\\":"Ö","]":"Å","^":"Ü","`":"é","{":"ä","|":"ö","}":"å","~":"ü"},t.CHARSETS.R={"#":"£","@":"à","[":"°","\\":"ç","]":"§","{":"é","|":"ù","}":"è","~":"¨"},t.CHARSETS.Q={"@":"à","[":"â","\\":"ç","]":"ê","^":"î","`":"ô","{":"é","|":"ù","}":"è","~":"û"},t.CHARSETS.K={"@":"§","[":"Ä","\\":"Ö","]":"Ü","{":"ä","|":"ö","}":"ü","~":"ß"},t.CHARSETS.Y={"#":"£","@":"§","[":"°","\\":"ç","]":"é","`":"ù","{":"à","|":"ò","}":"è","~":"ì"},t.CHARSETS.E=t.CHARSETS[6]={"@":"Ä","[":"Æ","\\":"Ø","]":"Å","^":"Ü","`":"ä","{":"æ","|":"ø","}":"å","~":"ü"},t.CHARSETS.Z={"#":"£","@":"§","[":"¡","\\":"Ñ","]":"¿","{":"°","|":"ñ","}":"ç"},t.CHARSETS.H=t.CHARSETS[7]={"@":"É","[":"Ä","\\":"Ö","]":"Å","^":"Ü","`":"é","{":"ä","|":"ö","}":"å","~":"ü"},t.CHARSETS["="]={"#":"ù","@":"à","[":"é","\\":"ç","]":"ê","^":"î",_:"è","`":"ô","{":"ä","|":"ö","}":"ü","~":"û"}},2584:(e,t)=>{var i,s,r;Object.defineProperty(t,"__esModule",{value:!0}),t.C1_ESCAPED=t.C1=t.C0=void 0,function(e){e.NUL="\0",e.SOH="",e.STX="",e.ETX="",e.EOT="",e.ENQ="",e.ACK="",e.BEL="",e.BS="\b",e.HT="\t",e.LF="\n",e.VT="\v",e.FF="\f",e.CR="\r",e.SO="",e.SI="",e.DLE="",e.DC1="",e.DC2="",e.DC3="",e.DC4="",e.NAK="",e.SYN="",e.ETB="",e.CAN="",e.EM="",e.SUB="",e.ESC="",e.FS="",e.GS="",e.RS="",e.US="",e.SP=" ",e.DEL=""}(i||(t.C0=i={})),function(e){e.PAD="€",e.HOP="",e.BPH="‚",e.NBH="ƒ",e.IND="„",e.NEL="…",e.SSA="†",e.ESA="‡",e.HTS="ˆ",e.HTJ="‰",e.VTS="Š",e.PLD="‹",e.PLU="Œ",e.RI="",e.SS2="Ž",e.SS3="",e.DCS="",e.PU1="‘",e.PU2="’",e.STS="“",e.CCH="”",e.MW="•",e.SPA="–",e.EPA="—",e.SOS="˜",e.SGCI="™",e.SCI="š",e.CSI="›",e.ST="œ",e.OSC="",e.PM="ž",e.APC="Ÿ"}(s||(t.C1=s={})),function(e){e.ST=`${i.ESC}\\`}(r||(t.C1_ESCAPED=r={}))},7399:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.evaluateKeyboardEvent=void 0;const s=i(2584),r={48:["0",")"],49:["1","!"],50:["2","@"],51:["3","#"],52:["4","$"],53:["5","%"],54:["6","^"],55:["7","&"],56:["8","*"],57:["9","("],186:[";",":"],187:["=","+"],188:[",","<"],189:["-","_"],190:[".",">"],191:["/","?"],192:["`","~"],219:["[","{"],220:["\\","|"],221:["]","}"],222:["'",'"']};t.evaluateKeyboardEvent=function(e,t,i,n){const o={type:0,cancel:!1,key:void 0},a=(e.shiftKey?1:0)|(e.altKey?2:0)|(e.ctrlKey?4:0)|(e.metaKey?8:0);switch(e.keyCode){case 0:"UIKeyInputUpArrow"===e.key?o.key=t?s.C0.ESC+"OA":s.C0.ESC+"[A":"UIKeyInputLeftArrow"===e.key?o.key=t?s.C0.ESC+"OD":s.C0.ESC+"[D":"UIKeyInputRightArrow"===e.key?o.key=t?s.C0.ESC+"OC":s.C0.ESC+"[C":"UIKeyInputDownArrow"===e.key&&(o.key=t?s.C0.ESC+"OB":s.C0.ESC+"[B");break;case 8:if(e.altKey){o.key=s.C0.ESC+s.C0.DEL;break}o.key=s.C0.DEL;break;case 9:if(e.shiftKey){o.key=s.C0.ESC+"[Z";break}o.key=s.C0.HT,o.cancel=!0;break;case 13:o.key=e.altKey?s.C0.ESC+s.C0.CR:s.C0.CR,o.cancel=!0;break;case 27:o.key=s.C0.ESC,e.altKey&&(o.key=s.C0.ESC+s.C0.ESC),o.cancel=!0;break;case 37:if(e.metaKey)break;a?(o.key=s.C0.ESC+"[1;"+(a+1)+"D",o.key===s.C0.ESC+"[1;3D"&&(o.key=s.C0.ESC+(i?"b":"[1;5D"))):o.key=t?s.C0.ESC+"OD":s.C0.ESC+"[D";break;case 39:if(e.metaKey)break;a?(o.key=s.C0.ESC+"[1;"+(a+1)+"C",o.key===s.C0.ESC+"[1;3C"&&(o.key=s.C0.ESC+(i?"f":"[1;5C"))):o.key=t?s.C0.ESC+"OC":s.C0.ESC+"[C";break;case 38:if(e.metaKey)break;a?(o.key=s.C0.ESC+"[1;"+(a+1)+"A",i||o.key!==s.C0.ESC+"[1;3A"||(o.key=s.C0.ESC+"[1;5A")):o.key=t?s.C0.ESC+"OA":s.C0.ESC+"[A";break;case 40:if(e.metaKey)break;a?(o.key=s.C0.ESC+"[1;"+(a+1)+"B",i||o.key!==s.C0.ESC+"[1;3B"||(o.key=s.C0.ESC+"[1;5B")):o.key=t?s.C0.ESC+"OB":s.C0.ESC+"[B";break;case 45:e.shiftKey||e.ctrlKey||(o.key=s.C0.ESC+"[2~");break;case 46:o.key=a?s.C0.ESC+"[3;"+(a+1)+"~":s.C0.ESC+"[3~";break;case 36:o.key=a?s.C0.ESC+"[1;"+(a+1)+"H":t?s.C0.ESC+"OH":s.C0.ESC+"[H";break;case 35:o.key=a?s.C0.ESC+"[1;"+(a+1)+"F":t?s.C0.ESC+"OF":s.C0.ESC+"[F";break;case 33:e.shiftKey?o.type=2:e.ctrlKey?o.key=s.C0.ESC+"[5;"+(a+1)+"~":o.key=s.C0.ESC+"[5~";break;case 34:e.shiftKey?o.type=3:e.ctrlKey?o.key=s.C0.ESC+"[6;"+(a+1)+"~":o.key=s.C0.ESC+"[6~";break;case 112:o.key=a?s.C0.ESC+"[1;"+(a+1)+"P":s.C0.ESC+"OP";break;case 113:o.key=a?s.C0.ESC+"[1;"+(a+1)+"Q":s.C0.ESC+"OQ";break;case 114:o.key=a?s.C0.ESC+"[1;"+(a+1)+"R":s.C0.ESC+"OR";break;case 115:o.key=a?s.C0.ESC+"[1;"+(a+1)+"S":s.C0.ESC+"OS";break;case 116:o.key=a?s.C0.ESC+"[15;"+(a+1)+"~":s.C0.ESC+"[15~";break;case 117:o.key=a?s.C0.ESC+"[17;"+(a+1)+"~":s.C0.ESC+"[17~";break;case 118:o.key=a?s.C0.ESC+"[18;"+(a+1)+"~":s.C0.ESC+"[18~";break;case 119:o.key=a?s.C0.ESC+"[19;"+(a+1)+"~":s.C0.ESC+"[19~";break;case 120:o.key=a?s.C0.ESC+"[20;"+(a+1)+"~":s.C0.ESC+"[20~";break;case 121:o.key=a?s.C0.ESC+"[21;"+(a+1)+"~":s.C0.ESC+"[21~";break;case 122:o.key=a?s.C0.ESC+"[23;"+(a+1)+"~":s.C0.ESC+"[23~";break;case 123:o.key=a?s.C0.ESC+"[24;"+(a+1)+"~":s.C0.ESC+"[24~";break;default:if(!e.ctrlKey||e.shiftKey||e.altKey||e.metaKey)if(i&&!n||!e.altKey||e.metaKey)!i||e.altKey||e.ctrlKey||e.shiftKey||!e.metaKey?e.key&&!e.ctrlKey&&!e.altKey&&!e.metaKey&&e.keyCode>=48&&1===e.key.length?o.key=e.key:e.key&&e.ctrlKey&&("_"===e.key&&(o.key=s.C0.US),"@"===e.key&&(o.key=s.C0.NUL)):65===e.keyCode&&(o.type=1);else{const t=r[e.keyCode],i=null==t?void 0:t[e.shiftKey?1:0];if(i)o.key=s.C0.ESC+i;else if(e.keyCode>=65&&e.keyCode<=90){const t=e.ctrlKey?e.keyCode-64:e.keyCode+32;let i=String.fromCharCode(t);e.shiftKey&&(i=i.toUpperCase()),o.key=s.C0.ESC+i}else if(32===e.keyCode)o.key=s.C0.ESC+(e.ctrlKey?s.C0.NUL:" ");else if("Dead"===e.key&&e.code.startsWith("Key")){let t=e.code.slice(3,4);e.shiftKey||(t=t.toLowerCase()),o.key=s.C0.ESC+t,o.cancel=!0}}else e.keyCode>=65&&e.keyCode<=90?o.key=String.fromCharCode(e.keyCode-64):32===e.keyCode?o.key=s.C0.NUL:e.keyCode>=51&&e.keyCode<=55?o.key=String.fromCharCode(e.keyCode-51+27):56===e.keyCode?o.key=s.C0.DEL:219===e.keyCode?o.key=s.C0.ESC:220===e.keyCode?o.key=s.C0.FS:221===e.keyCode&&(o.key=s.C0.GS)}return o}},482:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Utf8ToUtf32=t.StringToUtf32=t.utf32ToString=t.stringFromCodePoint=void 0,t.stringFromCodePoint=function(e){return e>65535?(e-=65536,String.fromCharCode(55296+(e>>10))+String.fromCharCode(e%1024+56320)):String.fromCharCode(e)},t.utf32ToString=function(e,t=0,i=e.length){let s="";for(let r=t;r65535?(t-=65536,s+=String.fromCharCode(55296+(t>>10))+String.fromCharCode(t%1024+56320)):s+=String.fromCharCode(t)}return s},t.StringToUtf32=class{constructor(){this._interim=0}clear(){this._interim=0}decode(e,t){const i=e.length;if(!i)return 0;let s=0,r=0;if(this._interim){const i=e.charCodeAt(r++);56320<=i&&i<=57343?t[s++]=1024*(this._interim-55296)+i-56320+65536:(t[s++]=this._interim,t[s++]=i),this._interim=0}for(let n=r;n=i)return this._interim=r,s;const o=e.charCodeAt(n);56320<=o&&o<=57343?t[s++]=1024*(r-55296)+o-56320+65536:(t[s++]=r,t[s++]=o)}else 65279!==r&&(t[s++]=r)}return s}},t.Utf8ToUtf32=class{constructor(){this.interim=new Uint8Array(3)}clear(){this.interim.fill(0)}decode(e,t){const i=e.length;if(!i)return 0;let s,r,n,o,a=0,h=0,c=0;if(this.interim[0]){let s=!1,r=this.interim[0];r&=192==(224&r)?31:224==(240&r)?15:7;let n,o=0;for(;(n=63&this.interim[++o])&&o<4;)r<<=6,r|=n;const h=192==(224&this.interim[0])?2:224==(240&this.interim[0])?3:4,l=h-o;for(;c=i)return 0;if(n=e[c++],128!=(192&n)){c--,s=!0;break}this.interim[o++]=n,r<<=6,r|=63&n}s||(2===h?r<128?c--:t[a++]=r:3===h?r<2048||r>=55296&&r<=57343||65279===r||(t[a++]=r):r<65536||r>1114111||(t[a++]=r)),this.interim.fill(0)}const l=i-4;let d=c;for(;d=i)return this.interim[0]=s,a;if(r=e[d++],128!=(192&r)){d--;continue}if(h=(31&s)<<6|63&r,h<128){d--;continue}t[a++]=h}else if(224==(240&s)){if(d>=i)return this.interim[0]=s,a;if(r=e[d++],128!=(192&r)){d--;continue}if(d>=i)return this.interim[0]=s,this.interim[1]=r,a;if(n=e[d++],128!=(192&n)){d--;continue}if(h=(15&s)<<12|(63&r)<<6|63&n,h<2048||h>=55296&&h<=57343||65279===h)continue;t[a++]=h}else if(240==(248&s)){if(d>=i)return this.interim[0]=s,a;if(r=e[d++],128!=(192&r)){d--;continue}if(d>=i)return this.interim[0]=s,this.interim[1]=r,a;if(n=e[d++],128!=(192&n)){d--;continue}if(d>=i)return this.interim[0]=s,this.interim[1]=r,this.interim[2]=n,a;if(o=e[d++],128!=(192&o)){d--;continue}if(h=(7&s)<<18|(63&r)<<12|(63&n)<<6|63&o,h<65536||h>1114111)continue;t[a++]=h}}return a}}},225:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeV6=void 0;const i=[[768,879],[1155,1158],[1160,1161],[1425,1469],[1471,1471],[1473,1474],[1476,1477],[1479,1479],[1536,1539],[1552,1557],[1611,1630],[1648,1648],[1750,1764],[1767,1768],[1770,1773],[1807,1807],[1809,1809],[1840,1866],[1958,1968],[2027,2035],[2305,2306],[2364,2364],[2369,2376],[2381,2381],[2385,2388],[2402,2403],[2433,2433],[2492,2492],[2497,2500],[2509,2509],[2530,2531],[2561,2562],[2620,2620],[2625,2626],[2631,2632],[2635,2637],[2672,2673],[2689,2690],[2748,2748],[2753,2757],[2759,2760],[2765,2765],[2786,2787],[2817,2817],[2876,2876],[2879,2879],[2881,2883],[2893,2893],[2902,2902],[2946,2946],[3008,3008],[3021,3021],[3134,3136],[3142,3144],[3146,3149],[3157,3158],[3260,3260],[3263,3263],[3270,3270],[3276,3277],[3298,3299],[3393,3395],[3405,3405],[3530,3530],[3538,3540],[3542,3542],[3633,3633],[3636,3642],[3655,3662],[3761,3761],[3764,3769],[3771,3772],[3784,3789],[3864,3865],[3893,3893],[3895,3895],[3897,3897],[3953,3966],[3968,3972],[3974,3975],[3984,3991],[3993,4028],[4038,4038],[4141,4144],[4146,4146],[4150,4151],[4153,4153],[4184,4185],[4448,4607],[4959,4959],[5906,5908],[5938,5940],[5970,5971],[6002,6003],[6068,6069],[6071,6077],[6086,6086],[6089,6099],[6109,6109],[6155,6157],[6313,6313],[6432,6434],[6439,6440],[6450,6450],[6457,6459],[6679,6680],[6912,6915],[6964,6964],[6966,6970],[6972,6972],[6978,6978],[7019,7027],[7616,7626],[7678,7679],[8203,8207],[8234,8238],[8288,8291],[8298,8303],[8400,8431],[12330,12335],[12441,12442],[43014,43014],[43019,43019],[43045,43046],[64286,64286],[65024,65039],[65056,65059],[65279,65279],[65529,65531]],s=[[68097,68099],[68101,68102],[68108,68111],[68152,68154],[68159,68159],[119143,119145],[119155,119170],[119173,119179],[119210,119213],[119362,119364],[917505,917505],[917536,917631],[917760,917999]];let r;t.UnicodeV6=class{constructor(){if(this.version="6",!r){r=new Uint8Array(65536),r.fill(1),r[0]=0,r.fill(0,1,32),r.fill(0,127,160),r.fill(2,4352,4448),r[9001]=2,r[9002]=2,r.fill(2,11904,42192),r[12351]=1,r.fill(2,44032,55204),r.fill(2,63744,64256),r.fill(2,65040,65050),r.fill(2,65072,65136),r.fill(2,65280,65377),r.fill(2,65504,65511);for(let e=0;et[r][1])return!1;for(;r>=s;)if(i=s+r>>1,e>t[i][1])s=i+1;else{if(!(e=131072&&e<=196605||e>=196608&&e<=262141?2:1}}},5981:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.WriteBuffer=void 0;const s=i(8460),r=i(844);class n extends r.Disposable{constructor(e){super(),this._action=e,this._writeBuffer=[],this._callbacks=[],this._pendingData=0,this._bufferOffset=0,this._isSyncWriting=!1,this._syncCalls=0,this._didUserInput=!1,this._onWriteParsed=this.register(new s.EventEmitter),this.onWriteParsed=this._onWriteParsed.event}handleUserInput(){this._didUserInput=!0}writeSync(e,t){if(void 0!==t&&this._syncCalls>t)return void(this._syncCalls=0);if(this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(void 0),this._syncCalls++,this._isSyncWriting)return;let i;for(this._isSyncWriting=!0;i=this._writeBuffer.shift();){this._action(i);const e=this._callbacks.shift();e&&e()}this._pendingData=0,this._bufferOffset=2147483647,this._isSyncWriting=!1,this._syncCalls=0}write(e,t){if(this._pendingData>5e7)throw new Error("write data discarded, use flow control to avoid losing data");if(!this._writeBuffer.length){if(this._bufferOffset=0,this._didUserInput)return this._didUserInput=!1,this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(t),void this._innerWrite();setTimeout((()=>this._innerWrite()))}this._pendingData+=e.length,this._writeBuffer.push(e),this._callbacks.push(t)}_innerWrite(e=0,t=!0){const i=e||Date.now();for(;this._writeBuffer.length>this._bufferOffset;){const e=this._writeBuffer[this._bufferOffset],s=this._action(e,t);if(s){const e=e=>Date.now()-i>=12?setTimeout((()=>this._innerWrite(0,e))):this._innerWrite(i,e);return void s.catch((e=>(queueMicrotask((()=>{throw e})),Promise.resolve(!1)))).then(e)}const r=this._callbacks[this._bufferOffset];if(r&&r(),this._bufferOffset++,this._pendingData-=e.length,Date.now()-i>=12)break}this._writeBuffer.length>this._bufferOffset?(this._bufferOffset>50&&(this._writeBuffer=this._writeBuffer.slice(this._bufferOffset),this._callbacks=this._callbacks.slice(this._bufferOffset),this._bufferOffset=0),setTimeout((()=>this._innerWrite()))):(this._writeBuffer.length=0,this._callbacks.length=0,this._pendingData=0,this._bufferOffset=0),this._onWriteParsed.fire()}}t.WriteBuffer=n},5941:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.toRgbString=t.parseColor=void 0;const i=/^([\da-f])\/([\da-f])\/([\da-f])$|^([\da-f]{2})\/([\da-f]{2})\/([\da-f]{2})$|^([\da-f]{3})\/([\da-f]{3})\/([\da-f]{3})$|^([\da-f]{4})\/([\da-f]{4})\/([\da-f]{4})$/,s=/^[\da-f]+$/;function r(e,t){const i=e.toString(16),s=i.length<2?"0"+i:i;switch(t){case 4:return i[0];case 8:return s;case 12:return(s+s).slice(0,3);default:return s+s}}t.parseColor=function(e){if(!e)return;let t=e.toLowerCase();if(0===t.indexOf("rgb:")){t=t.slice(4);const e=i.exec(t);if(e){const t=e[1]?15:e[4]?255:e[7]?4095:65535;return[Math.round(parseInt(e[1]||e[4]||e[7]||e[10],16)/t*255),Math.round(parseInt(e[2]||e[5]||e[8]||e[11],16)/t*255),Math.round(parseInt(e[3]||e[6]||e[9]||e[12],16)/t*255)]}}else if(0===t.indexOf("#")&&(t=t.slice(1),s.exec(t)&&[3,6,9,12].includes(t.length))){const e=t.length/3,i=[0,0,0];for(let s=0;s<3;++s){const r=parseInt(t.slice(e*s,e*s+e),16);i[s]=1===e?r<<4:2===e?r:3===e?r>>4:r>>8}return i}},t.toRgbString=function(e,t=16){const[i,s,n]=e;return`rgb:${r(i,t)}/${r(s,t)}/${r(n,t)}`}},5770:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.PAYLOAD_LIMIT=void 0,t.PAYLOAD_LIMIT=1e7},6351:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DcsHandler=t.DcsParser=void 0;const s=i(482),r=i(8742),n=i(5770),o=[];t.DcsParser=class{constructor(){this._handlers=Object.create(null),this._active=o,this._ident=0,this._handlerFb=()=>{},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=o}registerHandler(e,t){void 0===this._handlers[e]&&(this._handlers[e]=[]);const i=this._handlers[e];return i.push(t),{dispose:()=>{const e=i.indexOf(t);-1!==e&&i.splice(e,1)}}}clearHandler(e){this._handlers[e]&&delete this._handlers[e]}setHandlerFallback(e){this._handlerFb=e}reset(){if(this._active.length)for(let e=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;e>=0;--e)this._active[e].unhook(!1);this._stack.paused=!1,this._active=o,this._ident=0}hook(e,t){if(this.reset(),this._ident=e,this._active=this._handlers[e]||o,this._active.length)for(let e=this._active.length-1;e>=0;e--)this._active[e].hook(t);else this._handlerFb(this._ident,"HOOK",t)}put(e,t,i){if(this._active.length)for(let s=this._active.length-1;s>=0;s--)this._active[s].put(e,t,i);else this._handlerFb(this._ident,"PUT",(0,s.utf32ToString)(e,t,i))}unhook(e,t=!0){if(this._active.length){let i=!1,s=this._active.length-1,r=!1;if(this._stack.paused&&(s=this._stack.loopPosition-1,i=t,r=this._stack.fallThrough,this._stack.paused=!1),!r&&!1===i){for(;s>=0&&(i=this._active[s].unhook(e),!0!==i);s--)if(i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!1,i;s--}for(;s>=0;s--)if(i=this._active[s].unhook(!1),i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!0,i}else this._handlerFb(this._ident,"UNHOOK",e);this._active=o,this._ident=0}};const a=new r.Params;a.addParam(0),t.DcsHandler=class{constructor(e){this._handler=e,this._data="",this._params=a,this._hitLimit=!1}hook(e){this._params=e.length>1||e.params[0]?e.clone():a,this._data="",this._hitLimit=!1}put(e,t,i){this._hitLimit||(this._data+=(0,s.utf32ToString)(e,t,i),this._data.length>n.PAYLOAD_LIMIT&&(this._data="",this._hitLimit=!0))}unhook(e){let t=!1;if(this._hitLimit)t=!1;else if(e&&(t=this._handler(this._data,this._params),t instanceof Promise))return t.then((e=>(this._params=a,this._data="",this._hitLimit=!1,e)));return this._params=a,this._data="",this._hitLimit=!1,t}}},2015:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.EscapeSequenceParser=t.VT500_TRANSITION_TABLE=t.TransitionTable=void 0;const s=i(844),r=i(8742),n=i(6242),o=i(6351);class a{constructor(e){this.table=new Uint8Array(e)}setDefault(e,t){this.table.fill(e<<4|t)}add(e,t,i,s){this.table[t<<8|e]=i<<4|s}addMany(e,t,i,s){for(let r=0;rt)),i=(e,i)=>t.slice(e,i),s=i(32,127),r=i(0,24);r.push(25),r.push.apply(r,i(28,32));const n=i(0,14);let o;for(o in e.setDefault(1,0),e.addMany(s,0,2,0),n)e.addMany([24,26,153,154],o,3,0),e.addMany(i(128,144),o,3,0),e.addMany(i(144,152),o,3,0),e.add(156,o,0,0),e.add(27,o,11,1),e.add(157,o,4,8),e.addMany([152,158,159],o,0,7),e.add(155,o,11,3),e.add(144,o,11,9);return e.addMany(r,0,3,0),e.addMany(r,1,3,1),e.add(127,1,0,1),e.addMany(r,8,0,8),e.addMany(r,3,3,3),e.add(127,3,0,3),e.addMany(r,4,3,4),e.add(127,4,0,4),e.addMany(r,6,3,6),e.addMany(r,5,3,5),e.add(127,5,0,5),e.addMany(r,2,3,2),e.add(127,2,0,2),e.add(93,1,4,8),e.addMany(s,8,5,8),e.add(127,8,5,8),e.addMany([156,27,24,26,7],8,6,0),e.addMany(i(28,32),8,0,8),e.addMany([88,94,95],1,0,7),e.addMany(s,7,0,7),e.addMany(r,7,0,7),e.add(156,7,0,0),e.add(127,7,0,7),e.add(91,1,11,3),e.addMany(i(64,127),3,7,0),e.addMany(i(48,60),3,8,4),e.addMany([60,61,62,63],3,9,4),e.addMany(i(48,60),4,8,4),e.addMany(i(64,127),4,7,0),e.addMany([60,61,62,63],4,0,6),e.addMany(i(32,64),6,0,6),e.add(127,6,0,6),e.addMany(i(64,127),6,0,0),e.addMany(i(32,48),3,9,5),e.addMany(i(32,48),5,9,5),e.addMany(i(48,64),5,0,6),e.addMany(i(64,127),5,7,0),e.addMany(i(32,48),4,9,5),e.addMany(i(32,48),1,9,2),e.addMany(i(32,48),2,9,2),e.addMany(i(48,127),2,10,0),e.addMany(i(48,80),1,10,0),e.addMany(i(81,88),1,10,0),e.addMany([89,90,92],1,10,0),e.addMany(i(96,127),1,10,0),e.add(80,1,11,9),e.addMany(r,9,0,9),e.add(127,9,0,9),e.addMany(i(28,32),9,0,9),e.addMany(i(32,48),9,9,12),e.addMany(i(48,60),9,8,10),e.addMany([60,61,62,63],9,9,10),e.addMany(r,11,0,11),e.addMany(i(32,128),11,0,11),e.addMany(i(28,32),11,0,11),e.addMany(r,10,0,10),e.add(127,10,0,10),e.addMany(i(28,32),10,0,10),e.addMany(i(48,60),10,8,10),e.addMany([60,61,62,63],10,0,11),e.addMany(i(32,48),10,9,12),e.addMany(r,12,0,12),e.add(127,12,0,12),e.addMany(i(28,32),12,0,12),e.addMany(i(32,48),12,9,12),e.addMany(i(48,64),12,0,11),e.addMany(i(64,127),12,12,13),e.addMany(i(64,127),10,12,13),e.addMany(i(64,127),9,12,13),e.addMany(r,13,13,13),e.addMany(s,13,13,13),e.add(127,13,0,13),e.addMany([27,156,24,26],13,14,0),e.add(h,0,2,0),e.add(h,8,5,8),e.add(h,6,0,6),e.add(h,11,0,11),e.add(h,13,13,13),e}();class c extends s.Disposable{constructor(e=t.VT500_TRANSITION_TABLE){super(),this._transitions=e,this._parseStack={state:0,handlers:[],handlerPos:0,transition:0,chunkPos:0},this.initialState=0,this.currentState=this.initialState,this._params=new r.Params,this._params.addParam(0),this._collect=0,this.precedingCodepoint=0,this._printHandlerFb=(e,t,i)=>{},this._executeHandlerFb=e=>{},this._csiHandlerFb=(e,t)=>{},this._escHandlerFb=e=>{},this._errorHandlerFb=e=>e,this._printHandler=this._printHandlerFb,this._executeHandlers=Object.create(null),this._csiHandlers=Object.create(null),this._escHandlers=Object.create(null),this.register((0,s.toDisposable)((()=>{this._csiHandlers=Object.create(null),this._executeHandlers=Object.create(null),this._escHandlers=Object.create(null)}))),this._oscParser=this.register(new n.OscParser),this._dcsParser=this.register(new o.DcsParser),this._errorHandler=this._errorHandlerFb,this.registerEscHandler({final:"\\"},(()=>!0))}_identifier(e,t=[64,126]){let i=0;if(e.prefix){if(e.prefix.length>1)throw new Error("only one byte as prefix supported");if(i=e.prefix.charCodeAt(0),i&&60>i||i>63)throw new Error("prefix must be in range 0x3c .. 0x3f")}if(e.intermediates){if(e.intermediates.length>2)throw new Error("only two bytes as intermediates are supported");for(let t=0;ts||s>47)throw new Error("intermediate must be in range 0x20 .. 0x2f");i<<=8,i|=s}}if(1!==e.final.length)throw new Error("final must be a single byte");const s=e.final.charCodeAt(0);if(t[0]>s||s>t[1])throw new Error(`final must be in range ${t[0]} .. ${t[1]}`);return i<<=8,i|=s,i}identToString(e){const t=[];for(;e;)t.push(String.fromCharCode(255&e)),e>>=8;return t.reverse().join("")}setPrintHandler(e){this._printHandler=e}clearPrintHandler(){this._printHandler=this._printHandlerFb}registerEscHandler(e,t){const i=this._identifier(e,[48,126]);void 0===this._escHandlers[i]&&(this._escHandlers[i]=[]);const s=this._escHandlers[i];return s.push(t),{dispose:()=>{const e=s.indexOf(t);-1!==e&&s.splice(e,1)}}}clearEscHandler(e){this._escHandlers[this._identifier(e,[48,126])]&&delete this._escHandlers[this._identifier(e,[48,126])]}setEscHandlerFallback(e){this._escHandlerFb=e}setExecuteHandler(e,t){this._executeHandlers[e.charCodeAt(0)]=t}clearExecuteHandler(e){this._executeHandlers[e.charCodeAt(0)]&&delete this._executeHandlers[e.charCodeAt(0)]}setExecuteHandlerFallback(e){this._executeHandlerFb=e}registerCsiHandler(e,t){const i=this._identifier(e);void 0===this._csiHandlers[i]&&(this._csiHandlers[i]=[]);const s=this._csiHandlers[i];return s.push(t),{dispose:()=>{const e=s.indexOf(t);-1!==e&&s.splice(e,1)}}}clearCsiHandler(e){this._csiHandlers[this._identifier(e)]&&delete this._csiHandlers[this._identifier(e)]}setCsiHandlerFallback(e){this._csiHandlerFb=e}registerDcsHandler(e,t){return this._dcsParser.registerHandler(this._identifier(e),t)}clearDcsHandler(e){this._dcsParser.clearHandler(this._identifier(e))}setDcsHandlerFallback(e){this._dcsParser.setHandlerFallback(e)}registerOscHandler(e,t){return this._oscParser.registerHandler(e,t)}clearOscHandler(e){this._oscParser.clearHandler(e)}setOscHandlerFallback(e){this._oscParser.setHandlerFallback(e)}setErrorHandler(e){this._errorHandler=e}clearErrorHandler(){this._errorHandler=this._errorHandlerFb}reset(){this.currentState=this.initialState,this._oscParser.reset(),this._dcsParser.reset(),this._params.reset(),this._params.addParam(0),this._collect=0,this.precedingCodepoint=0,0!==this._parseStack.state&&(this._parseStack.state=2,this._parseStack.handlers=[])}_preserveStack(e,t,i,s,r){this._parseStack.state=e,this._parseStack.handlers=t,this._parseStack.handlerPos=i,this._parseStack.transition=s,this._parseStack.chunkPos=r}parse(e,t,i){let s,r=0,n=0,o=0;if(this._parseStack.state)if(2===this._parseStack.state)this._parseStack.state=0,o=this._parseStack.chunkPos+1;else{if(void 0===i||1===this._parseStack.state)throw this._parseStack.state=1,new Error("improper continuation due to previous async handler, giving up parsing");const t=this._parseStack.handlers;let n=this._parseStack.handlerPos-1;switch(this._parseStack.state){case 3:if(!1===i&&n>-1)for(;n>=0&&(s=t[n](this._params),!0!==s);n--)if(s instanceof Promise)return this._parseStack.handlerPos=n,s;this._parseStack.handlers=[];break;case 4:if(!1===i&&n>-1)for(;n>=0&&(s=t[n](),!0!==s);n--)if(s instanceof Promise)return this._parseStack.handlerPos=n,s;this._parseStack.handlers=[];break;case 6:if(r=e[this._parseStack.chunkPos],s=this._dcsParser.unhook(24!==r&&26!==r,i),s)return s;27===r&&(this._parseStack.transition|=1),this._params.reset(),this._params.addParam(0),this._collect=0;break;case 5:if(r=e[this._parseStack.chunkPos],s=this._oscParser.end(24!==r&&26!==r,i),s)return s;27===r&&(this._parseStack.transition|=1),this._params.reset(),this._params.addParam(0),this._collect=0}this._parseStack.state=0,o=this._parseStack.chunkPos+1,this.precedingCodepoint=0,this.currentState=15&this._parseStack.transition}for(let i=o;i>4){case 2:for(let s=i+1;;++s){if(s>=t||(r=e[s])<32||r>126&&r=t||(r=e[s])<32||r>126&&r=t||(r=e[s])<32||r>126&&r=t||(r=e[s])<32||r>126&&r=0&&(s=o[a](this._params),!0!==s);a--)if(s instanceof Promise)return this._preserveStack(3,o,a,n,i),s;a<0&&this._csiHandlerFb(this._collect<<8|r,this._params),this.precedingCodepoint=0;break;case 8:do{switch(r){case 59:this._params.addParam(0);break;case 58:this._params.addSubParam(-1);break;default:this._params.addDigit(r-48)}}while(++i47&&r<60);i--;break;case 9:this._collect<<=8,this._collect|=r;break;case 10:const c=this._escHandlers[this._collect<<8|r];let l=c?c.length-1:-1;for(;l>=0&&(s=c[l](),!0!==s);l--)if(s instanceof Promise)return this._preserveStack(4,c,l,n,i),s;l<0&&this._escHandlerFb(this._collect<<8|r),this.precedingCodepoint=0;break;case 11:this._params.reset(),this._params.addParam(0),this._collect=0;break;case 12:this._dcsParser.hook(this._collect<<8|r,this._params);break;case 13:for(let s=i+1;;++s)if(s>=t||24===(r=e[s])||26===r||27===r||r>127&&r=t||(r=e[s])<32||r>127&&r{Object.defineProperty(t,"__esModule",{value:!0}),t.OscHandler=t.OscParser=void 0;const s=i(5770),r=i(482),n=[];t.OscParser=class{constructor(){this._state=0,this._active=n,this._id=-1,this._handlers=Object.create(null),this._handlerFb=()=>{},this._stack={paused:!1,loopPosition:0,fallThrough:!1}}registerHandler(e,t){void 0===this._handlers[e]&&(this._handlers[e]=[]);const i=this._handlers[e];return i.push(t),{dispose:()=>{const e=i.indexOf(t);-1!==e&&i.splice(e,1)}}}clearHandler(e){this._handlers[e]&&delete this._handlers[e]}setHandlerFallback(e){this._handlerFb=e}dispose(){this._handlers=Object.create(null),this._handlerFb=()=>{},this._active=n}reset(){if(2===this._state)for(let e=this._stack.paused?this._stack.loopPosition-1:this._active.length-1;e>=0;--e)this._active[e].end(!1);this._stack.paused=!1,this._active=n,this._id=-1,this._state=0}_start(){if(this._active=this._handlers[this._id]||n,this._active.length)for(let e=this._active.length-1;e>=0;e--)this._active[e].start();else this._handlerFb(this._id,"START")}_put(e,t,i){if(this._active.length)for(let s=this._active.length-1;s>=0;s--)this._active[s].put(e,t,i);else this._handlerFb(this._id,"PUT",(0,r.utf32ToString)(e,t,i))}start(){this.reset(),this._state=1}put(e,t,i){if(3!==this._state){if(1===this._state)for(;t0&&this._put(e,t,i)}}end(e,t=!0){if(0!==this._state){if(3!==this._state)if(1===this._state&&this._start(),this._active.length){let i=!1,s=this._active.length-1,r=!1;if(this._stack.paused&&(s=this._stack.loopPosition-1,i=t,r=this._stack.fallThrough,this._stack.paused=!1),!r&&!1===i){for(;s>=0&&(i=this._active[s].end(e),!0!==i);s--)if(i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!1,i;s--}for(;s>=0;s--)if(i=this._active[s].end(!1),i instanceof Promise)return this._stack.paused=!0,this._stack.loopPosition=s,this._stack.fallThrough=!0,i}else this._handlerFb(this._id,"END",e);this._active=n,this._id=-1,this._state=0}}},t.OscHandler=class{constructor(e){this._handler=e,this._data="",this._hitLimit=!1}start(){this._data="",this._hitLimit=!1}put(e,t,i){this._hitLimit||(this._data+=(0,r.utf32ToString)(e,t,i),this._data.length>s.PAYLOAD_LIMIT&&(this._data="",this._hitLimit=!0))}end(e){let t=!1;if(this._hitLimit)t=!1;else if(e&&(t=this._handler(this._data),t instanceof Promise))return t.then((e=>(this._data="",this._hitLimit=!1,e)));return this._data="",this._hitLimit=!1,t}}},8742:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.Params=void 0;const i=2147483647;class s{static fromArray(e){const t=new s;if(!e.length)return t;for(let i=Array.isArray(e[0])?1:0;i256)throw new Error("maxSubParamsLength must not be greater than 256");this.params=new Int32Array(e),this.length=0,this._subParams=new Int32Array(t),this._subParamsLength=0,this._subParamsIdx=new Uint16Array(e),this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1}clone(){const e=new s(this.maxLength,this.maxSubParamsLength);return e.params.set(this.params),e.length=this.length,e._subParams.set(this._subParams),e._subParamsLength=this._subParamsLength,e._subParamsIdx.set(this._subParamsIdx),e._rejectDigits=this._rejectDigits,e._rejectSubDigits=this._rejectSubDigits,e._digitIsSub=this._digitIsSub,e}toArray(){const e=[];for(let t=0;t>8,s=255&this._subParamsIdx[t];s-i>0&&e.push(Array.prototype.slice.call(this._subParams,i,s))}return e}reset(){this.length=0,this._subParamsLength=0,this._rejectDigits=!1,this._rejectSubDigits=!1,this._digitIsSub=!1}addParam(e){if(this._digitIsSub=!1,this.length>=this.maxLength)this._rejectDigits=!0;else{if(e<-1)throw new Error("values lesser than -1 are not allowed");this._subParamsIdx[this.length]=this._subParamsLength<<8|this._subParamsLength,this.params[this.length++]=e>i?i:e}}addSubParam(e){if(this._digitIsSub=!0,this.length)if(this._rejectDigits||this._subParamsLength>=this.maxSubParamsLength)this._rejectSubDigits=!0;else{if(e<-1)throw new Error("values lesser than -1 are not allowed");this._subParams[this._subParamsLength++]=e>i?i:e,this._subParamsIdx[this.length-1]++}}hasSubParams(e){return(255&this._subParamsIdx[e])-(this._subParamsIdx[e]>>8)>0}getSubParams(e){const t=this._subParamsIdx[e]>>8,i=255&this._subParamsIdx[e];return i-t>0?this._subParams.subarray(t,i):null}getSubParamsAll(){const e={};for(let t=0;t>8,s=255&this._subParamsIdx[t];s-i>0&&(e[t]=this._subParams.slice(i,s))}return e}addDigit(e){let t;if(this._rejectDigits||!(t=this._digitIsSub?this._subParamsLength:this.length)||this._digitIsSub&&this._rejectSubDigits)return;const s=this._digitIsSub?this._subParams:this.params,r=s[t-1];s[t-1]=~r?Math.min(10*r+e,i):e}}t.Params=s},5741:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.AddonManager=void 0,t.AddonManager=class{constructor(){this._addons=[]}dispose(){for(let e=this._addons.length-1;e>=0;e--)this._addons[e].instance.dispose()}loadAddon(e,t){const i={instance:t,dispose:t.dispose,isDisposed:!1};this._addons.push(i),t.dispose=()=>this._wrappedAddonDispose(i),t.activate(e)}_wrappedAddonDispose(e){if(e.isDisposed)return;let t=-1;for(let i=0;i{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferApiView=void 0;const s=i(3785),r=i(511);t.BufferApiView=class{constructor(e,t){this._buffer=e,this.type=t}init(e){return this._buffer=e,this}get cursorY(){return this._buffer.y}get cursorX(){return this._buffer.x}get viewportY(){return this._buffer.ydisp}get baseY(){return this._buffer.ybase}get length(){return this._buffer.lines.length}getLine(e){const t=this._buffer.lines.get(e);if(t)return new s.BufferLineApiView(t)}getNullCell(){return new r.CellData}}},3785:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferLineApiView=void 0;const s=i(511);t.BufferLineApiView=class{constructor(e){this._line=e}get isWrapped(){return this._line.isWrapped}get length(){return this._line.length}getCell(e,t){if(!(e<0||e>=this._line.length))return t?(this._line.loadCell(e,t),t):this._line.loadCell(e,new s.CellData)}translateToString(e,t,i){return this._line.translateToString(e,t,i)}}},8285:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.BufferNamespaceApi=void 0;const s=i(8771),r=i(8460),n=i(844);class o extends n.Disposable{constructor(e){super(),this._core=e,this._onBufferChange=this.register(new r.EventEmitter),this.onBufferChange=this._onBufferChange.event,this._normal=new s.BufferApiView(this._core.buffers.normal,"normal"),this._alternate=new s.BufferApiView(this._core.buffers.alt,"alternate"),this._core.buffers.onBufferActivate((()=>this._onBufferChange.fire(this.active)))}get active(){if(this._core.buffers.active===this._core.buffers.normal)return this.normal;if(this._core.buffers.active===this._core.buffers.alt)return this.alternate;throw new Error("Active buffer is neither normal nor alternate")}get normal(){return this._normal.init(this._core.buffers.normal)}get alternate(){return this._alternate.init(this._core.buffers.alt)}}t.BufferNamespaceApi=o},7975:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.ParserApi=void 0,t.ParserApi=class{constructor(e){this._core=e}registerCsiHandler(e,t){return this._core.registerCsiHandler(e,(e=>t(e.toArray())))}addCsiHandler(e,t){return this.registerCsiHandler(e,t)}registerDcsHandler(e,t){return this._core.registerDcsHandler(e,((e,i)=>t(e,i.toArray())))}addDcsHandler(e,t){return this.registerDcsHandler(e,t)}registerEscHandler(e,t){return this._core.registerEscHandler(e,t)}addEscHandler(e,t){return this.registerEscHandler(e,t)}registerOscHandler(e,t){return this._core.registerOscHandler(e,t)}addOscHandler(e,t){return this.registerOscHandler(e,t)}}},7090:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeApi=void 0,t.UnicodeApi=class{constructor(e){this._core=e}register(e){this._core.unicodeService.register(e)}get versions(){return this._core.unicodeService.versions}get activeVersion(){return this._core.unicodeService.activeVersion}set activeVersion(e){this._core.unicodeService.activeVersion=e}}},744:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.BufferService=t.MINIMUM_ROWS=t.MINIMUM_COLS=void 0;const n=i(8460),o=i(844),a=i(5295),h=i(2585);t.MINIMUM_COLS=2,t.MINIMUM_ROWS=1;let c=t.BufferService=class extends o.Disposable{get buffer(){return this.buffers.active}constructor(e){super(),this.isUserScrolling=!1,this._onResize=this.register(new n.EventEmitter),this.onResize=this._onResize.event,this._onScroll=this.register(new n.EventEmitter),this.onScroll=this._onScroll.event,this.cols=Math.max(e.rawOptions.cols||0,t.MINIMUM_COLS),this.rows=Math.max(e.rawOptions.rows||0,t.MINIMUM_ROWS),this.buffers=this.register(new a.BufferSet(e,this))}resize(e,t){this.cols=e,this.rows=t,this.buffers.resize(e,t),this._onResize.fire({cols:e,rows:t})}reset(){this.buffers.reset(),this.isUserScrolling=!1}scroll(e,t=!1){const i=this.buffer;let s;s=this._cachedBlankLine,s&&s.length===this.cols&&s.getFg(0)===e.fg&&s.getBg(0)===e.bg||(s=i.getBlankLine(e,t),this._cachedBlankLine=s),s.isWrapped=t;const r=i.ybase+i.scrollTop,n=i.ybase+i.scrollBottom;if(0===i.scrollTop){const e=i.lines.isFull;n===i.lines.length-1?e?i.lines.recycle().copyFrom(s):i.lines.push(s.clone()):i.lines.splice(n+1,0,s.clone()),e?this.isUserScrolling&&(i.ydisp=Math.max(i.ydisp-1,0)):(i.ybase++,this.isUserScrolling||i.ydisp++)}else{const e=n-r+1;i.lines.shiftElements(r+1,e-1,-1),i.lines.set(n,s.clone())}this.isUserScrolling||(i.ydisp=i.ybase),this._onScroll.fire(i.ydisp)}scrollLines(e,t,i){const s=this.buffer;if(e<0){if(0===s.ydisp)return;this.isUserScrolling=!0}else e+s.ydisp>=s.ybase&&(this.isUserScrolling=!1);const r=s.ydisp;s.ydisp=Math.max(Math.min(s.ydisp+e,s.ybase),0),r!==s.ydisp&&(t||this._onScroll.fire(s.ydisp))}};t.BufferService=c=s([r(0,h.IOptionsService)],c)},7994:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.CharsetService=void 0,t.CharsetService=class{constructor(){this.glevel=0,this._charsets=[]}reset(){this.charset=void 0,this._charsets=[],this.glevel=0}setgLevel(e){this.glevel=e,this.charset=this._charsets[e]}setgCharset(e,t){this._charsets[e]=t,this.glevel===e&&(this.charset=t)}}},1753:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CoreMouseService=void 0;const n=i(2585),o=i(8460),a=i(844),h={NONE:{events:0,restrict:()=>!1},X10:{events:1,restrict:e=>4!==e.button&&1===e.action&&(e.ctrl=!1,e.alt=!1,e.shift=!1,!0)},VT200:{events:19,restrict:e=>32!==e.action},DRAG:{events:23,restrict:e=>32!==e.action||3!==e.button},ANY:{events:31,restrict:e=>!0}};function c(e,t){let i=(e.ctrl?16:0)|(e.shift?4:0)|(e.alt?8:0);return 4===e.button?(i|=64,i|=e.action):(i|=3&e.button,4&e.button&&(i|=64),8&e.button&&(i|=128),32===e.action?i|=32:0!==e.action||t||(i|=3)),i}const l=String.fromCharCode,d={DEFAULT:e=>{const t=[c(e,!1)+32,e.col+32,e.row+32];return t[0]>255||t[1]>255||t[2]>255?"":`${l(t[0])}${l(t[1])}${l(t[2])}`},SGR:e=>{const t=0===e.action&&4!==e.button?"m":"M";return`[<${c(e,!0)};${e.col};${e.row}${t}`},SGR_PIXELS:e=>{const t=0===e.action&&4!==e.button?"m":"M";return`[<${c(e,!0)};${e.x};${e.y}${t}`}};let _=t.CoreMouseService=class extends a.Disposable{constructor(e,t){super(),this._bufferService=e,this._coreService=t,this._protocols={},this._encodings={},this._activeProtocol="",this._activeEncoding="",this._lastEvent=null,this._onProtocolChange=this.register(new o.EventEmitter),this.onProtocolChange=this._onProtocolChange.event;for(const e of Object.keys(h))this.addProtocol(e,h[e]);for(const e of Object.keys(d))this.addEncoding(e,d[e]);this.reset()}addProtocol(e,t){this._protocols[e]=t}addEncoding(e,t){this._encodings[e]=t}get activeProtocol(){return this._activeProtocol}get areMouseEventsActive(){return 0!==this._protocols[this._activeProtocol].events}set activeProtocol(e){if(!this._protocols[e])throw new Error(`unknown protocol "${e}"`);this._activeProtocol=e,this._onProtocolChange.fire(this._protocols[e].events)}get activeEncoding(){return this._activeEncoding}set activeEncoding(e){if(!this._encodings[e])throw new Error(`unknown encoding "${e}"`);this._activeEncoding=e}reset(){this.activeProtocol="NONE",this.activeEncoding="DEFAULT",this._lastEvent=null}triggerMouseEvent(e){if(e.col<0||e.col>=this._bufferService.cols||e.row<0||e.row>=this._bufferService.rows)return!1;if(4===e.button&&32===e.action)return!1;if(3===e.button&&32!==e.action)return!1;if(4!==e.button&&(2===e.action||3===e.action))return!1;if(e.col++,e.row++,32===e.action&&this._lastEvent&&this._equalEvents(this._lastEvent,e,"SGR_PIXELS"===this._activeEncoding))return!1;if(!this._protocols[this._activeProtocol].restrict(e))return!1;const t=this._encodings[this._activeEncoding](e);return t&&("DEFAULT"===this._activeEncoding?this._coreService.triggerBinaryEvent(t):this._coreService.triggerDataEvent(t,!0)),this._lastEvent=e,!0}explainEvents(e){return{down:!!(1&e),up:!!(2&e),drag:!!(4&e),move:!!(8&e),wheel:!!(16&e)}}_equalEvents(e,t,i){if(i){if(e.x!==t.x)return!1;if(e.y!==t.y)return!1}else{if(e.col!==t.col)return!1;if(e.row!==t.row)return!1}return e.button===t.button&&e.action===t.action&&e.ctrl===t.ctrl&&e.alt===t.alt&&e.shift===t.shift}};t.CoreMouseService=_=s([r(0,n.IBufferService),r(1,n.ICoreService)],_)},6975:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.CoreService=void 0;const n=i(1439),o=i(8460),a=i(844),h=i(2585),c=Object.freeze({insertMode:!1}),l=Object.freeze({applicationCursorKeys:!1,applicationKeypad:!1,bracketedPasteMode:!1,origin:!1,reverseWraparound:!1,sendFocus:!1,wraparound:!0});let d=t.CoreService=class extends a.Disposable{constructor(e,t,i){super(),this._bufferService=e,this._logService=t,this._optionsService=i,this.isCursorInitialized=!1,this.isCursorHidden=!1,this._onData=this.register(new o.EventEmitter),this.onData=this._onData.event,this._onUserInput=this.register(new o.EventEmitter),this.onUserInput=this._onUserInput.event,this._onBinary=this.register(new o.EventEmitter),this.onBinary=this._onBinary.event,this._onRequestScrollToBottom=this.register(new o.EventEmitter),this.onRequestScrollToBottom=this._onRequestScrollToBottom.event,this.modes=(0,n.clone)(c),this.decPrivateModes=(0,n.clone)(l)}reset(){this.modes=(0,n.clone)(c),this.decPrivateModes=(0,n.clone)(l)}triggerDataEvent(e,t=!1){if(this._optionsService.rawOptions.disableStdin)return;const i=this._bufferService.buffer;t&&this._optionsService.rawOptions.scrollOnUserInput&&i.ybase!==i.ydisp&&this._onRequestScrollToBottom.fire(),t&&this._onUserInput.fire(),this._logService.debug(`sending data "${e}"`,(()=>e.split("").map((e=>e.charCodeAt(0))))),this._onData.fire(e)}triggerBinaryEvent(e){this._optionsService.rawOptions.disableStdin||(this._logService.debug(`sending binary "${e}"`,(()=>e.split("").map((e=>e.charCodeAt(0))))),this._onBinary.fire(e))}};t.CoreService=d=s([r(0,h.IBufferService),r(1,h.ILogService),r(2,h.IOptionsService)],d)},9074:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.DecorationService=void 0;const s=i(8055),r=i(8460),n=i(844),o=i(6106);let a=0,h=0;class c extends n.Disposable{get decorations(){return this._decorations.values()}constructor(){super(),this._decorations=new o.SortedList((e=>null==e?void 0:e.marker.line)),this._onDecorationRegistered=this.register(new r.EventEmitter),this.onDecorationRegistered=this._onDecorationRegistered.event,this._onDecorationRemoved=this.register(new r.EventEmitter),this.onDecorationRemoved=this._onDecorationRemoved.event,this.register((0,n.toDisposable)((()=>this.reset())))}registerDecoration(e){if(e.marker.isDisposed)return;const t=new l(e);if(t){const e=t.marker.onDispose((()=>t.dispose()));t.onDispose((()=>{t&&(this._decorations.delete(t)&&this._onDecorationRemoved.fire(t),e.dispose())})),this._decorations.insert(t),this._onDecorationRegistered.fire(t)}return t}reset(){for(const e of this._decorations.values())e.dispose();this._decorations.clear()}*getDecorationsAtCell(e,t,i){var s,r,n;let o=0,a=0;for(const h of this._decorations.getKeyIterator(t))o=null!==(s=h.options.x)&&void 0!==s?s:0,a=o+(null!==(r=h.options.width)&&void 0!==r?r:1),e>=o&&e{var r,n,o;a=null!==(r=t.options.x)&&void 0!==r?r:0,h=a+(null!==(n=t.options.width)&&void 0!==n?n:1),e>=a&&e{Object.defineProperty(t,"__esModule",{value:!0}),t.InstantiationService=t.ServiceCollection=void 0;const s=i(2585),r=i(8343);class n{constructor(...e){this._entries=new Map;for(const[t,i]of e)this.set(t,i)}set(e,t){const i=this._entries.get(e);return this._entries.set(e,t),i}forEach(e){for(const[t,i]of this._entries.entries())e(t,i)}has(e){return this._entries.has(e)}get(e){return this._entries.get(e)}}t.ServiceCollection=n,t.InstantiationService=class{constructor(){this._services=new n,this._services.set(s.IInstantiationService,this)}setService(e,t){this._services.set(e,t)}getService(e){return this._services.get(e)}createInstance(e,...t){const i=(0,r.getServiceDependencies)(e).sort(((e,t)=>e.index-t.index)),s=[];for(const t of i){const i=this._services.get(t.id);if(!i)throw new Error(`[createInstance] ${e.name} depends on UNKNOWN service ${t.id}.`);s.push(i)}const n=i.length>0?i[0].index:t.length;if(t.length!==n)throw new Error(`[createInstance] First service dependency of ${e.name} at position ${n+1} conflicts with ${t.length} static arguments`);return new e(...[...t,...s])}}},7866:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.traceCall=t.setTraceLogger=t.LogService=void 0;const n=i(844),o=i(2585),a={trace:o.LogLevelEnum.TRACE,debug:o.LogLevelEnum.DEBUG,info:o.LogLevelEnum.INFO,warn:o.LogLevelEnum.WARN,error:o.LogLevelEnum.ERROR,off:o.LogLevelEnum.OFF};let h,c=t.LogService=class extends n.Disposable{get logLevel(){return this._logLevel}constructor(e){super(),this._optionsService=e,this._logLevel=o.LogLevelEnum.OFF,this._updateLogLevel(),this.register(this._optionsService.onSpecificOptionChange("logLevel",(()=>this._updateLogLevel()))),h=this}_updateLogLevel(){this._logLevel=a[this._optionsService.rawOptions.logLevel]}_evalLazyOptionalParams(e){for(let t=0;tJSON.stringify(e))).join(", ")})`);const t=s.apply(this,e);return h.trace(`GlyphRenderer#${s.name} return`,t),t}}},7302:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.OptionsService=t.DEFAULT_OPTIONS=void 0;const s=i(8460),r=i(844),n=i(6114);t.DEFAULT_OPTIONS={cols:80,rows:24,cursorBlink:!1,cursorStyle:"block",cursorWidth:1,cursorInactiveStyle:"outline",customGlyphs:!0,drawBoldTextInBrightColors:!0,fastScrollModifier:"alt",fastScrollSensitivity:5,fontFamily:"courier-new, courier, monospace",fontSize:15,fontWeight:"normal",fontWeightBold:"bold",ignoreBracketedPasteMode:!1,lineHeight:1,letterSpacing:0,linkHandler:null,logLevel:"info",logger:null,scrollback:1e3,scrollOnUserInput:!0,scrollSensitivity:1,screenReaderMode:!1,smoothScrollDuration:0,macOptionIsMeta:!1,macOptionClickForcesSelection:!1,minimumContrastRatio:1,disableStdin:!1,allowProposedApi:!1,allowTransparency:!1,tabStopWidth:8,theme:{},rightClickSelectsWord:n.isMac,windowOptions:{},windowsMode:!1,windowsPty:{},wordSeparator:" ()[]{}',\"`",altClickMovesCursor:!0,convertEol:!1,termName:"xterm",cancelEvents:!1,overviewRulerWidth:0};const o=["normal","bold","100","200","300","400","500","600","700","800","900"];class a extends r.Disposable{constructor(e){super(),this._onOptionChange=this.register(new s.EventEmitter),this.onOptionChange=this._onOptionChange.event;const i=Object.assign({},t.DEFAULT_OPTIONS);for(const t in e)if(t in i)try{const s=e[t];i[t]=this._sanitizeAndValidateOption(t,s)}catch(e){console.error(e)}this.rawOptions=i,this.options=Object.assign({},i),this._setupOptions()}onSpecificOptionChange(e,t){return this.onOptionChange((i=>{i===e&&t(this.rawOptions[e])}))}onMultipleOptionChange(e,t){return this.onOptionChange((i=>{-1!==e.indexOf(i)&&t()}))}_setupOptions(){const e=e=>{if(!(e in t.DEFAULT_OPTIONS))throw new Error(`No option with key "${e}"`);return this.rawOptions[e]},i=(e,i)=>{if(!(e in t.DEFAULT_OPTIONS))throw new Error(`No option with key "${e}"`);i=this._sanitizeAndValidateOption(e,i),this.rawOptions[e]!==i&&(this.rawOptions[e]=i,this._onOptionChange.fire(e))};for(const t in this.rawOptions){const s={get:e.bind(this,t),set:i.bind(this,t)};Object.defineProperty(this.options,t,s)}}_sanitizeAndValidateOption(e,i){switch(e){case"cursorStyle":if(i||(i=t.DEFAULT_OPTIONS[e]),!function(e){return"block"===e||"underline"===e||"bar"===e}(i))throw new Error(`"${i}" is not a valid value for ${e}`);break;case"wordSeparator":i||(i=t.DEFAULT_OPTIONS[e]);break;case"fontWeight":case"fontWeightBold":if("number"==typeof i&&1<=i&&i<=1e3)break;i=o.includes(i)?i:t.DEFAULT_OPTIONS[e];break;case"cursorWidth":i=Math.floor(i);case"lineHeight":case"tabStopWidth":if(i<1)throw new Error(`${e} cannot be less than 1, value: ${i}`);break;case"minimumContrastRatio":i=Math.max(1,Math.min(21,Math.round(10*i)/10));break;case"scrollback":if((i=Math.min(i,4294967295))<0)throw new Error(`${e} cannot be less than 0, value: ${i}`);break;case"fastScrollSensitivity":case"scrollSensitivity":if(i<=0)throw new Error(`${e} cannot be less than or equal to 0, value: ${i}`);break;case"rows":case"cols":if(!i&&0!==i)throw new Error(`${e} must be numeric, value: ${i}`);break;case"windowsPty":i=null!=i?i:{}}return i}}t.OptionsService=a},2660:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.OscLinkService=void 0;const n=i(2585);let o=t.OscLinkService=class{constructor(e){this._bufferService=e,this._nextId=1,this._entriesWithId=new Map,this._dataByLinkId=new Map}registerLink(e){const t=this._bufferService.buffer;if(void 0===e.id){const i=t.addMarker(t.ybase+t.y),s={data:e,id:this._nextId++,lines:[i]};return i.onDispose((()=>this._removeMarkerFromLink(s,i))),this._dataByLinkId.set(s.id,s),s.id}const i=e,s=this._getEntryIdKey(i),r=this._entriesWithId.get(s);if(r)return this.addLineToLink(r.id,t.ybase+t.y),r.id;const n=t.addMarker(t.ybase+t.y),o={id:this._nextId++,key:this._getEntryIdKey(i),data:i,lines:[n]};return n.onDispose((()=>this._removeMarkerFromLink(o,n))),this._entriesWithId.set(o.key,o),this._dataByLinkId.set(o.id,o),o.id}addLineToLink(e,t){const i=this._dataByLinkId.get(e);if(i&&i.lines.every((e=>e.line!==t))){const e=this._bufferService.buffer.addMarker(t);i.lines.push(e),e.onDispose((()=>this._removeMarkerFromLink(i,e)))}}getLinkData(e){var t;return null===(t=this._dataByLinkId.get(e))||void 0===t?void 0:t.data}_getEntryIdKey(e){return`${e.id};;${e.uri}`}_removeMarkerFromLink(e,t){const i=e.lines.indexOf(t);-1!==i&&(e.lines.splice(i,1),0===e.lines.length&&(void 0!==e.data.id&&this._entriesWithId.delete(e.key),this._dataByLinkId.delete(e.id)))}};t.OscLinkService=o=s([r(0,n.IBufferService)],o)},8343:(e,t)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.createDecorator=t.getServiceDependencies=t.serviceRegistry=void 0;const i="di$target",s="di$dependencies";t.serviceRegistry=new Map,t.getServiceDependencies=function(e){return e[s]||[]},t.createDecorator=function(e){if(t.serviceRegistry.has(e))return t.serviceRegistry.get(e);const r=function(e,t,n){if(3!==arguments.length)throw new Error("@IServiceName-decorator can only be used to decorate a parameter");!function(e,t,r){t[i]===t?t[s].push({id:e,index:r}):(t[s]=[{id:e,index:r}],t[i]=t)}(r,e,n)};return r.toString=()=>e,t.serviceRegistry.set(e,r),r}},2585:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.IDecorationService=t.IUnicodeService=t.IOscLinkService=t.IOptionsService=t.ILogService=t.LogLevelEnum=t.IInstantiationService=t.ICharsetService=t.ICoreService=t.ICoreMouseService=t.IBufferService=void 0;const s=i(8343);var r;t.IBufferService=(0,s.createDecorator)("BufferService"),t.ICoreMouseService=(0,s.createDecorator)("CoreMouseService"),t.ICoreService=(0,s.createDecorator)("CoreService"),t.ICharsetService=(0,s.createDecorator)("CharsetService"),t.IInstantiationService=(0,s.createDecorator)("InstantiationService"),function(e){e[e.TRACE=0]="TRACE",e[e.DEBUG=1]="DEBUG",e[e.INFO=2]="INFO",e[e.WARN=3]="WARN",e[e.ERROR=4]="ERROR",e[e.OFF=5]="OFF"}(r||(t.LogLevelEnum=r={})),t.ILogService=(0,s.createDecorator)("LogService"),t.IOptionsService=(0,s.createDecorator)("OptionsService"),t.IOscLinkService=(0,s.createDecorator)("OscLinkService"),t.IUnicodeService=(0,s.createDecorator)("UnicodeService"),t.IDecorationService=(0,s.createDecorator)("DecorationService")},1480:(e,t,i)=>{Object.defineProperty(t,"__esModule",{value:!0}),t.UnicodeService=void 0;const s=i(8460),r=i(225);t.UnicodeService=class{constructor(){this._providers=Object.create(null),this._active="",this._onChange=new s.EventEmitter,this.onChange=this._onChange.event;const e=new r.UnicodeV6;this.register(e),this._active=e.version,this._activeProvider=e}dispose(){this._onChange.dispose()}get versions(){return Object.keys(this._providers)}get activeVersion(){return this._active}set activeVersion(e){if(!this._providers[e])throw new Error(`unknown Unicode version "${e}"`);this._active=e,this._activeProvider=this._providers[e],this._onChange.fire(e)}register(e){this._providers[e.version]=e}wcwidth(e){return this._activeProvider.wcwidth(e)}getStringCellWidth(e){let t=0;const i=e.length;for(let s=0;s=i)return t+this.wcwidth(r);const n=e.charCodeAt(s);56320<=n&&n<=57343?r=1024*(r-55296)+n-56320+65536:t+=this.wcwidth(n)}t+=this.wcwidth(r)}return t}}}},t={};function i(s){var r=t[s];if(void 0!==r)return r.exports;var n=t[s]={exports:{}};return e[s].call(n.exports,n,n.exports,i),n.exports}var s={};return(()=>{var e=s;Object.defineProperty(e,"__esModule",{value:!0}),e.Terminal=void 0;const t=i(9042),r=i(3236),n=i(844),o=i(5741),a=i(8285),h=i(7975),c=i(7090),l=["cols","rows"];class d extends n.Disposable{constructor(e){super(),this._core=this.register(new r.Terminal(e)),this._addonManager=this.register(new o.AddonManager),this._publicOptions=Object.assign({},this._core.options);const t=e=>this._core.options[e],i=(e,t)=>{this._checkReadonlyOptions(e),this._core.options[e]=t};for(const e in this._core.options){const s={get:t.bind(this,e),set:i.bind(this,e)};Object.defineProperty(this._publicOptions,e,s)}}_checkReadonlyOptions(e){if(l.includes(e))throw new Error(`Option "${e}" can only be set in the constructor`)}_checkProposedApi(){if(!this._core.optionsService.rawOptions.allowProposedApi)throw new Error("You must set the allowProposedApi option to true to use proposed API")}get onBell(){return this._core.onBell}get onBinary(){return this._core.onBinary}get onCursorMove(){return this._core.onCursorMove}get onData(){return this._core.onData}get onKey(){return this._core.onKey}get onLineFeed(){return this._core.onLineFeed}get onRender(){return this._core.onRender}get onResize(){return this._core.onResize}get onScroll(){return this._core.onScroll}get onSelectionChange(){return this._core.onSelectionChange}get onTitleChange(){return this._core.onTitleChange}get onWriteParsed(){return this._core.onWriteParsed}get element(){return this._core.element}get parser(){return this._parser||(this._parser=new h.ParserApi(this._core)),this._parser}get unicode(){return this._checkProposedApi(),new c.UnicodeApi(this._core)}get textarea(){return this._core.textarea}get rows(){return this._core.rows}get cols(){return this._core.cols}get buffer(){return this._buffer||(this._buffer=this.register(new a.BufferNamespaceApi(this._core))),this._buffer}get markers(){return this._checkProposedApi(),this._core.markers}get modes(){const e=this._core.coreService.decPrivateModes;let t="none";switch(this._core.coreMouseService.activeProtocol){case"X10":t="x10";break;case"VT200":t="vt200";break;case"DRAG":t="drag";break;case"ANY":t="any"}return{applicationCursorKeysMode:e.applicationCursorKeys,applicationKeypadMode:e.applicationKeypad,bracketedPasteMode:e.bracketedPasteMode,insertMode:this._core.coreService.modes.insertMode,mouseTrackingMode:t,originMode:e.origin,reverseWraparoundMode:e.reverseWraparound,sendFocusMode:e.sendFocus,wraparoundMode:e.wraparound}}get options(){return this._publicOptions}set options(e){for(const t in e)this._publicOptions[t]=e[t]}blur(){this._core.blur()}focus(){this._core.focus()}resize(e,t){this._verifyIntegers(e,t),this._core.resize(e,t)}open(e){this._core.open(e)}attachCustomKeyEventHandler(e){this._core.attachCustomKeyEventHandler(e)}registerLinkProvider(e){return this._core.registerLinkProvider(e)}registerCharacterJoiner(e){return this._checkProposedApi(),this._core.registerCharacterJoiner(e)}deregisterCharacterJoiner(e){this._checkProposedApi(),this._core.deregisterCharacterJoiner(e)}registerMarker(e=0){return this._verifyIntegers(e),this._core.registerMarker(e)}registerDecoration(e){var t,i,s;return this._checkProposedApi(),this._verifyPositiveIntegers(null!==(t=e.x)&&void 0!==t?t:0,null!==(i=e.width)&&void 0!==i?i:0,null!==(s=e.height)&&void 0!==s?s:0),this._core.registerDecoration(e)}hasSelection(){return this._core.hasSelection()}select(e,t,i){this._verifyIntegers(e,t,i),this._core.select(e,t,i)}getSelection(){return this._core.getSelection()}getSelectionPosition(){return this._core.getSelectionPosition()}clearSelection(){this._core.clearSelection()}selectAll(){this._core.selectAll()}selectLines(e,t){this._verifyIntegers(e,t),this._core.selectLines(e,t)}dispose(){super.dispose()}scrollLines(e){this._verifyIntegers(e),this._core.scrollLines(e)}scrollPages(e){this._verifyIntegers(e),this._core.scrollPages(e)}scrollToTop(){this._core.scrollToTop()}scrollToBottom(){this._core.scrollToBottom()}scrollToLine(e){this._verifyIntegers(e),this._core.scrollToLine(e)}clear(){this._core.clear()}write(e,t){this._core.write(e,t)}writeln(e,t){this._core.write(e),this._core.write("\r\n",t)}paste(e){this._core.paste(e)}refresh(e,t){this._verifyIntegers(e,t),this._core.refresh(e,t)}reset(){this._core.reset()}clearTextureAtlas(){this._core.clearTextureAtlas()}loadAddon(e){this._addonManager.loadAddon(this,e)}static get strings(){return t}_verifyIntegers(...e){for(const t of e)if(t===1/0||isNaN(t)||t%1!=0)throw new Error("This API only accepts integers")}_verifyPositiveIntegers(...e){for(const t of e)if(t&&(t===1/0||isNaN(t)||t%1!=0||t<0))throw new Error("This API only accepts positive integers")}}e.Terminal=d})(),s})())); +//# sourceMappingURL=xterm.js.map \ No newline at end of file diff --git a/scripts/deploy.ps1 b/scripts/deploy.ps1 new file mode 100644 index 0000000..13593d4 --- /dev/null +++ b/scripts/deploy.ps1 @@ -0,0 +1,115 @@ +param( + [string]$PagerHost = "172.16.52.1", + [string]$User = "root", + [string]$Password = "", + [string]$SshKey = "", + [string]$BuildDir = "", + [switch]$NoPortalRefresh +) + +$ErrorActionPreference = "Stop" +$Root = Split-Path -Parent $PSScriptRoot +$PayloadKey = "pager-webui" +$PayloadCategory = "remote_access" +$PayloadDir = Join-Path $Root "payload\user\$PayloadCategory\$PayloadKey" +if (-not (Test-Path $PayloadDir)) { throw "Payload dir not found: $PayloadDir" } + +if (-not $BuildDir) { $BuildDir = Join-Path $Root "build" } +$OutDir = Join-Path $BuildDir $PayloadKey +New-Item -ItemType Directory -Force -Path $OutDir | Out-Null + +# --- 1. Stage payload tree ------------------------------------------------- +$Stage = Join-Path $OutDir "stage" +if (Test-Path $Stage) { Remove-Item -Recurse -Force $Stage } +New-Item -ItemType Directory -Force -Path (Join-Path $Stage "user\$PayloadCategory") | Out-Null +Copy-Item -Recurse $PayloadDir (Join-Path $Stage "user\$PayloadCategory\$PayloadKey") + +# --- 2. Build zip (portal format: payload-.zip) ----------------------- +$b64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($PayloadKey)).TrimEnd('=').Replace('+','-').Replace('/','_') +$ZipName = "payload-$b64.zip" +$ZipPath = Join-Path $OutDir $ZipName +if (Test-Path $ZipPath) { Remove-Item -Force $ZipPath } + +Add-Type -AssemblyName System.IO.Compression +$zip = New-Object System.IO.Compression.ZipArchive([IO.File]::Open($ZipPath, 'Create'), [IO.Compression.ZipArchiveMode]::Create) +try { + Get-ChildItem -Recurse -File $Stage | Where-Object { + $_.FullName -notmatch '[\\/]__pycache__[\\/]' -and $_.Extension -ne '.pyc' + } | ForEach-Object { + $rel = $_.FullName.Substring($Stage.Length + 1).Replace('\', '/') + $entry = $zip.CreateEntry($rel, [IO.Compression.CompressionLevel]::Optimal) + $es = $entry.Open() + $bytes = [IO.File]::ReadAllBytes($_.FullName) + if ($rel -match '(^|/)(payload\.sh|pagerwebui\.init)$') { + $bytes = [byte[]]($bytes | Where-Object { $_ -ne 13 }) + } + $es.Write($bytes, 0, $bytes.Length) + $es.Close() + } +} finally { $zip.Dispose() } + +# --- 3. Manifest with generated fields ------------------------------------ +$hash = (Get-FileHash -Algorithm SHA256 $ZipPath).Hash.ToLower() +$manifest = Get-Content -Raw (Join-Path $PayloadDir "_hak5_manifest.json") | ConvertFrom-Json +$manifest.time = [int64]([DateTimeOffset]::UtcNow.ToUnixTimeSeconds()) +$manifest.last_hash = $hash +$manifest.zip = $ZipName +$manifest = $manifest | ConvertTo-Json +Set-Content -Path (Join-Path $OutDir "_hak5_manifest.json") -Value $manifest -Encoding ascii +Write-Host "Built: $ZipPath" + +# --- 4. Credentials / transport ------------------------------------------- +if ($SshKey) { + $sshBase = "$User@$PagerHost" + $scp = "scp -i `"$SshKey`"" + $ssh = "ssh -i `"$SshKey`"" +} elseif (Get-Command sshpass -ErrorAction SilentlyContinue) { + if (-not $Password) { $Password = Read-Host -AsSecureString "Pager root password"; $Password = [Runtime.InteropServices.Marshal]::PtrToStringAuto([Runtime.InteropServices.Marshal]::SecureStringToBSTR($Password)) } + $sshBase = "$User@$PagerHost" + $scp = "sshpass -p `"$Password`" scp" + $ssh = "sshpass -p `"$Password`" ssh" +} else { + Write-Host "`nNo sshpass or -SshKey found. Run these manually (password prompts appear):" + Write-Host " scp `"$ZipPath`" $User@${PagerHost}:/tmp/" + Write-Host " ssh $User@$PagerHost `"cd /root/payloads && unzip -q -o /tmp/$ZipName && chmod +x user/general/$PayloadKey/payload.sh && rm -f /tmp/$ZipName`"" + Write-Host "Then re-run this script with -SshKey, or install sshpass." + exit 1 +} + +# --- 5. Upload + extract on device ---------------------------------------- +& cmd /c "$scp `"$ZipPath`" `"$(Join-Path $OutDir '_hak5_manifest.json')`" ${sshBase}:/tmp/" | Out-Null +if ($LASTEXITCODE -ne 0) { throw "SCP failed" } + +$remotePayloadDir = "user/$PayloadCategory/$PayloadKey" +$legacyPayloadDir = "user/general/$PayloadKey" +$remoteCmd = "cd /root/payloads && rm -rf $remotePayloadDir $legacyPayloadDir && unzip -q -o /tmp/$ZipName && cp /tmp/_hak5_manifest.json $remotePayloadDir/_hak5_manifest.json && chmod +x $remotePayloadDir/payload.sh && chmod -R 755 $remotePayloadDir/www && rm -f /tmp/$ZipName /tmp/_hak5_manifest.json && if [ -x /etc/init.d/pagerwebui ] && /etc/init.d/pagerwebui running >/dev/null 2>&1; then /etc/init.d/pagerwebui restart; fi && echo EXTRACT_OK" +& cmd /c "$ssh $sshBase `"$remoteCmd`"" +if ($LASTEXITCODE -ne 0) { throw "Remote extraction failed" } +Write-Host "Installed to /root/payloads/$remotePayloadDir/" + +# --- 6. Portal refresh (best-effort) -------------------------------------- +if (-not $NoPortalRefresh) { + if (-not $Password) { + Write-Host "Skipping portal refresh (no password supplied). Run the payload from the on-device menu to verify." + } else { + # Base64 the remote command so cmd/ssh quoting cannot mangle the JSON body. + $loginCmd = "curl -s -X POST http://127.0.0.1:1471/api/login -d '{""username"":""root"",""password"":""$Password""}'" + $b64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($loginCmd)) + $tokenJson = & cmd /c "$ssh $sshBase `"echo $b64 | base64 -d | sh`"" + $tokenJson = [string]$tokenJson -replace '\x1b\[[0-9;?]*[A-Za-z]', '' + $token = ($tokenJson | ConvertFrom-Json).token + if ($token) { + $refreshCmd = "curl -fsS -X POST http://127.0.0.1:1471/api/payloads/portal/refresh -H 'Authorization: Bearer $token'" + $b64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($refreshCmd)) + & cmd /c "$ssh $sshBase `"echo $b64 | base64 -d | sh`"" | Out-Null + if ($LASTEXITCODE -eq 0) { + Write-Host "Portal refreshed. The payload should appear in the on-device Payloads menu / Virtual Pager portal." + } else { + Write-Warning "Portal refresh failed; the local payload installation is still complete." + } + } else { + Write-Host "Login to portal refresh failed; the payload is installed as a directory - run it from the menu." + } + } +} +Write-Host "Deploy complete. Run payload.sh from the Pager menu, then browse http://${PagerHost}:8080/" diff --git a/scripts/dev.ps1 b/scripts/dev.ps1 new file mode 100644 index 0000000..5ddea3c --- /dev/null +++ b/scripts/dev.ps1 @@ -0,0 +1,19 @@ +param( + [string]$PagerHost = "172.16.52.1", + [int]$Port = 8000, + [switch]$Tunnel +) + +$ErrorActionPreference = "Stop" +$Root = Split-Path -Parent $PSScriptRoot +$Python = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe" +if (-not (Test-Path $Python)) { throw "Python 3.11 not found. Install with: winget install Python.Python.3.11" } + +if ($Tunnel) { + Write-Host "Opening SSH tunnel 1471 -> 127.0.0.1:1471 (for direct terminal WS). Close the ssh window to stop it." + Start-Process ssh -ArgumentList "-N","-L","1471:127.0.0.1:1471","root@$PagerHost" -WindowStyle Minimized +} + +Write-Host "Dev server: http://127.0.0.1:$Port (API proxied to http://$PagerHost:8080)" +Write-Host "Requires the backend deployed: scripts\deploy.ps1" +& $Python "$PSScriptRoot\dev_proxy.py" --pager-host $PagerHost --port $Port --www "$Root\payload\user\remote_access\pager-webui\www" diff --git a/scripts/dev_proxy.py b/scripts/dev_proxy.py new file mode 100644 index 0000000..a79f36b --- /dev/null +++ b/scripts/dev_proxy.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Local dev proxy: serves www/ statically and proxies /api/* to the Pager.""" +import argparse +import http.server +import urllib.request +import urllib.error + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument('--pager-host', default='172.16.52.1') + ap.add_argument('--pager-port', type=int, default=8080) + ap.add_argument('--port', type=int, default=8000) + ap.add_argument('--www', default='payload/user/remote_access/pager-webui/www') + args = ap.parse_args() + api_target = 'http://%s:%d' % (args.pager_host, args.pager_port) + + class Proxy(http.server.SimpleHTTPRequestHandler): + def __init__(self, *a, **kw): + super().__init__(*a, directory=args.www, **kw) + + def log_message(self, fmt, *a): + pass + + def _serve_dev_config(self): + body = ('window.PAGER_CONFIG = { apiBase: "", wsBase: "", ' + 'terminalWs: "ws://%s:1471/api/terminal/openWs", ' + 'pagerScreenWs: "ws://%s:1471/api/pager/display/screen.ws", ' + 'pagerKeysWs: "ws://%s:1471/api/pager/input/keys.ws" };\n' + % (args.pager_host, args.pager_host, args.pager_host)).encode() + self.send_response(200) + self.send_header('Content-Type', 'text/javascript') + self.send_header('Content-Length', str(len(body))) + self.send_header('Cache-Control', 'no-cache') + self.end_headers() + self.wfile.write(body) + return True + + def _proxy(self, method): + body = None + if self.headers.get('Content-Length'): + body = self.rfile.read(int(self.headers['Content-Length'])) + req = urllib.request.Request(api_target + self.path, data=body, method=method) + if self.headers.get('Cookie'): + req.add_header('Cookie', self.headers['Cookie']) + req.add_header('Content-Type', self.headers.get('Content-Type', 'application/json')) + try: + with urllib.request.urlopen(req, timeout=25) as r: + data = r.read() + self.send_response(r.status) + self.send_header('Content-Type', r.headers.get('Content-Type', 'application/octet-stream')) + self.send_header('Content-Length', str(len(data))) + for sc in r.headers.get_all('Set-Cookie', []): + self.send_header('Set-Cookie', sc) + self.end_headers() + self.wfile.write(data) + except urllib.error.HTTPError as e: + data = e.read() + self.send_response(e.code) + self.send_header('Content-Type', e.headers.get('Content-Type', 'application/json')) + self.send_header('Content-Length', str(len(data))) + for sc in e.headers.get_all('Set-Cookie', []): + self.send_header('Set-Cookie', sc) + self.end_headers() + self.wfile.write(data) + + def do_GET(self): + if self.path == '/js/config.js': + return self._serve_dev_config() + if self.path.startswith('/api/'): + return self._proxy('GET') + return super().do_GET() + + def do_POST(self): + return self._proxy('POST') if self.path.startswith('/api/') else self.send_error(404) + + def do_DELETE(self): + return self._proxy('DELETE') if self.path.startswith('/api/') else self.send_error(404) + + http.server.ThreadingHTTPServer(('127.0.0.1', args.port), Proxy).serve_forever() + + +if __name__ == '__main__': + main() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..01bac61 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,124 @@ +import json +import os +import stat +import sys +import tempfile +import unittest +from unittest import mock + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'remote_access', 'pager-webui')) +import server + + +def setUpModule(): + __import__('importlib').reload(server) + + +class SessionTest(unittest.TestCase): + def setUp(self): + fd, self.path = tempfile.mkstemp() + os.close(fd) + server.SESSION_FILE = self.path + + def tearDown(self): + try: + os.unlink(self.path) + except OSError: + pass + + def test_save_and_load_roundtrip(self): + server.save_session({'serverid': '001337AEE050', 'token': 'abc123'}) + self.assertEqual(server.load_session(), {'serverid': '001337AEE050', 'token': 'abc123'}) + if os.name != 'nt': + self.assertEqual(stat.S_IMODE(os.stat(self.path).st_mode), 0o600) + + def test_load_missing_file_returns_empty(self): + os.unlink(self.path) + self.assertEqual(server.load_session(), {}) + + def test_check_auth_matches_cookie(self): + server.save_session({'serverid': '001337AEE050', 'token': 'abc123'}) + self.assertTrue(server.check_auth('foo=bar; AUTH_001337AEE050=abc123')) + self.assertFalse(server.check_auth('AUTH_001337AEE050=wrong')) + self.assertFalse(server.check_auth('')) + + def test_check_auth_rejects_prefix_value(self): + server.save_session({'serverid': '001337AEE050', 'token': 'abc123'}) + self.assertFalse(server.check_auth('AUTH_001337AEE050=abc123xxxx')) + self.assertFalse(server.check_auth('prefix AUTH_001337AEE050=abc123')) + server.SESSION_FILE = os.path.join(tempfile.gettempdir(), 'pagerwebui_test_session.json') + if os.path.exists(server.SESSION_FILE): + os.unlink(server.SESSION_FILE) + self.assertFalse(server.check_auth('AUTH_=anything')) + + def test_current_token(self): + server.save_session({'serverid': 'S', 'token': 'tok'}) + self.assertEqual(server.current_token(), 'tok') + + def test_logout_removes_session_and_expires_cookie(self): + server.save_session({'serverid': 'S', 'token': 'tok'}) + class H: + def __init__(self): + self.headers = [] + def add_extra_header(self, name, value): + self.headers.append((name, value)) + ctx = type('C', (), {'h': H()})() + status, payload = server.h_logout(ctx) + self.assertEqual(status, 200) + self.assertTrue(payload['ok']) + self.assertEqual(server.load_session(), {}) + self.assertIn(('Set-Cookie', + 'AUTH_S=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0'), + ctx.h.headers) + + +class LoginTest(unittest.TestCase): + def test_login_success_sets_cookie_payload(self): + def fake_daemon(method, path, body=None, token=None, timeout=15): + if path == '/api/login': + return 200, {'token': 'tok123'} + if path == '/api/api_ping': + return 200, {'serverid': '001337AEE050', 'version': '1.0'} + return 404, None + server.daemon_call = fake_daemon + server.SESSION_FILE = os.path.join(tempfile.gettempdir(), 'pagerwebui_test_session.json') + if os.path.exists(server.SESSION_FILE): + os.unlink(server.SESSION_FILE) + class H: + def __init__(self): + self.headers = [] + def add_extra_header(self, name, value): + self.headers.append((name, value)) + class Ctx: + body = {'username': 'root', 'password': 'pw'} + cookie = '' + h = H() + ctx = Ctx() + status, payload = server.h_login(ctx) + self.assertEqual(status, 200) + self.assertNotIn('token', payload) + self.assertTrue(payload['ok']) + self.assertEqual(payload['serverid'], '001337AEE050') + self.assertEqual(server.load_session()['token'], 'tok123') + self.assertTrue(any(n == 'Set-Cookie' and v.startswith('AUTH_001337AEE050=tok123') for n, v in ctx.h.headers)) + + def test_login_failure_returns_401(self): + def fake_daemon(method, path, body=None, token=None, timeout=15): + return 401, {'error': 'bad'} + server.daemon_call = fake_daemon + class Ctx: + body = {'username': 'root', 'password': 'nope'} + cookie = '' + status, payload = server.h_login(Ctx()) + self.assertEqual(status, 401) + + def test_api_ping(self): + server.daemon_call = lambda *a, **k: (200, {'serverid': 'S1', 'version': '9'}) + server.SESSION_FILE = os.path.join(tempfile.gettempdir(), 'pagerwebui_test_session.json') + status, payload = server.h_api_ping(type('C', (), {'cookie': '', 'args': ()})()) + self.assertEqual(status, 200) + self.assertEqual(payload['serverid'], 'S1') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..42a0566 --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,71 @@ +import os +import sys +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'remote_access', 'pager-webui')) +import server + + +def setUpModule(): + __import__('importlib').reload(server) + + +class FakeHandler: + def __init__(self, path='/', headers=None, body=None): + self.path = path + self.headers = headers or {} + self._body = body + + def read_body(self): + return self._body if self._body is not None else {} + + +class RouterTest(unittest.TestCase): + def test_dispatch_matches_and_captures(self): + server.ROUTER = server.Router() + server.ROUTER.add('GET', r'/api/recon/scans/(\d+)', lambda ctx: (200, {'id': ctx.args[0]})) + handler, groups = server.ROUTER.dispatch('GET', '/api/recon/scans/42') + self.assertIsNotNone(handler) + self.assertEqual(groups, ('42',)) + + def test_dispatch_returns_none_on_mismatch(self): + server.ROUTER = server.Router() + handler, groups = server.ROUTER.dispatch('POST', '/nope') + self.assertIsNone(handler) + + +class StaticTest(unittest.TestCase): + def test_safe_join_rejects_traversal(self): + self.assertFalse(server._safe_join(server.WWW_DIR, '../server.py')) + + def test_safe_join_accepts_subpath(self): + p = server._safe_join(server.WWW_DIR, 'js/app.js') + self.assertTrue(p.startswith(server.WWW_DIR)) + + +class OriginTest(unittest.TestCase): + def test_same_origin_accepts_matching_origin(self): + self.assertTrue(server.same_origin({ + 'host': '172.16.52.1:8080', + 'origin': 'http://172.16.52.1:8080'})) + + def test_same_origin_rejects_cross_origin(self): + self.assertFalse(server.same_origin({ + 'host': '172.16.52.1:8080', + 'origin': 'http://attacker.example'})) + + def test_same_origin_allows_non_browser_client(self): + self.assertTrue(server.same_origin({'host': '172.16.52.1:8080'})) + + def test_same_origin_requires_host(self): + self.assertFalse(server.same_origin({})) + + +class DeviceRunTest(unittest.TestCase): + def test_device_run_returns_triple(self): + rc, out, err = server.device_run(['cmd', 'not', 'there']) + self.assertIsInstance(rc, int) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_loot.py b/tests/test_loot.py new file mode 100644 index 0000000..16c13fc --- /dev/null +++ b/tests/test_loot.py @@ -0,0 +1,59 @@ +import os +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'remote_access', 'pager-webui')) +import server + + +def setUpModule(): + __import__('importlib').reload(server) + + +class HandshakesTest(unittest.TestCase): + def setUp(self): + self.dir = tempfile.mkdtemp() + server.LOOT_HS_DIR = self.dir + with open(os.path.join(self.dir, 'hs1.cap'), 'wb') as f: + f.write(b'\x00\x01\x02') + with open(os.path.join(self.dir, 'hs2.pcap'), 'wb') as f: + f.write(b'\x00' * 5) + + def test_list(self): + status, payload = server.h_handshakes_get(type('C', (), {'args': ()})()) + self.assertEqual(status, 200) + names = [f['name'] for f in payload['files']] + self.assertEqual(names, ['hs1.cap', 'hs2.pcap']) + self.assertEqual(payload['files'][0]['size'], 3) + + def test_delete_by_body(self): + server.h_handshakes_delete(type('C', (), {'args': (), 'body': {'name': 'hs1.cap'}})()) + self.assertFalse(os.path.exists(os.path.join(self.dir, 'hs1.cap'))) + + def test_delete_rejects_traversal(self): + status, payload = server.h_handshakes_delete(type('C', (), {'args': (), 'body': {'name': '../server.py'}})()) + self.assertEqual(status, 400) + + +class LootProxyTest(unittest.TestCase): + def test_loot_zip_returns_download(self): + server.daemon_call = lambda m, p, body=None, token=None, timeout=15: (200, b'PK\x03\x04zipdata') + server.current_token = lambda: 'tok' + status, payload = server.h_loot_zip(type('C', (), {'args': ()})()) + self.assertEqual(status, 200) + self.assertEqual(payload.ctype, 'application/zip') + + def test_loot_archive_posts_to_daemon(self): + calls = [] + def fake(m, p, body=None, token=None, timeout=15): + calls.append((m, p)) + return 200, {'ok': True} + server.daemon_call = fake + server.current_token = lambda: 'tok' + server.h_loot_archive(type('C', (), {'args': ()})()) + self.assertTrue(any(m == 'POST' and p == '/api/loot/archive' for m, p in calls)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_misc.py b/tests/test_misc.py new file mode 100644 index 0000000..7984589 --- /dev/null +++ b/tests/test_misc.py @@ -0,0 +1,279 @@ +import os +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'remote_access', 'pager-webui')) +import server + + +def setUpModule(): + __import__('importlib').reload(server) + + +class PayloadsProxyTest(unittest.TestCase): + def test_index_proxies(self): + server.daemon_call = lambda m, p, body=None, token=None, timeout=15: (200, {'payloads': []}) + server.current_token = lambda: 'tok' + class Ctx: + args = () + status, payload = server.h_payloads_index(Ctx()) + self.assertEqual(status, 200) + self.assertEqual(payload, {'payloads': []}) + + def test_install_uses_key(self): + calls = [] + def fake(m, p, body=None, token=None, timeout=15): + calls.append((m, p)) + return 200, {'ok': True} + server.daemon_call = fake + server.current_token = lambda: 'tok' + server.h_payloads_install(type('C', (), {'args': (), 'body': {'key': 'nautilus'}})()) + self.assertTrue(any(m == 'POST' and '/api/payloads/portal/nautilus/install' in p for m, p in calls)) + + def test_remove_uses_key(self): + calls = [] + def fake(m, p, body=None, token=None, timeout=15): + calls.append((m, p)) + return 200, {'ok': True} + server.daemon_call = fake + server.current_token = lambda: 'tok' + server.h_payloads_remove(type('C', (), {'args': (), 'body': {'key': 'nautilus'}})()) + self.assertTrue(any(m == 'POST' and '/api/payloads/portal/nautilus/remove' in p for m, p in calls)) + + def test_installed_inventory_flattens_firmware_records(self): + old = server._payload_daemon + server._payload_daemon = lambda method, path, body=None: (200, [{ + 'installed': {'key': 'user~general~demo', 'title': 'Demo', + 'path': '/root/payloads/user/general/demo', + 'category': 'general', 'launchpoint': 'payload.sh'}, + 'missingmanifest': False, 'update': {'version': '2.0'}}]) + try: + status, payload = server.h_payloads_installed(type('C', (), {'args': ()})()) + finally: + server._payload_daemon = old + self.assertEqual(status, 200) + self.assertEqual(payload['payloads'][0]['title'], 'Demo') + self.assertEqual(payload['payloads'][0]['update']['version'], '2.0') + + def test_install_rejects_unsafe_key(self): + status, payload = server.h_payloads_install(type('C', (), { + 'args': (), 'body': {'key': '../payload'}})()) + self.assertEqual(status, 400) + self.assertIn('key', payload['error']) + + def test_run_launches_only_resolved_installed_payload(self): + old_record = server._payload_record + old_safe = server._payload_safe_launch + old_popen = server.subprocess.Popen + old_run_dir = server.PAYLOAD_RUN_DIR + + class FakeProcess: + pid = 4321 + def poll(self): + return None + + with tempfile.TemporaryDirectory() as tmp: + launch = os.path.join(tmp, 'payload.sh') + with open(launch, 'w') as handle: + handle.write('#!/bin/sh\n') + server._payload_record = lambda key: (200, { + 'key': key, 'title': 'Demo', 'path': tmp, + 'launchpoint': 'payload.sh', 'interpreter': 'sh', 'disabled': False}) + server._payload_safe_launch = lambda item: (tmp, launch) + calls = [] + server.subprocess.Popen = lambda command, **kwargs: calls.append((command, kwargs)) or FakeProcess() + server.PAYLOAD_RUN_DIR = os.path.join(tmp, 'runs') + server._payload_runs.clear() + try: + status, payload = server.h_payloads_run(type('C', (), { + 'body': {'key': 'user~general~demo'}})()) + finally: + server._payload_record = old_record + server._payload_safe_launch = old_safe + server.subprocess.Popen = old_popen + server.PAYLOAD_RUN_DIR = old_run_dir + server._payload_runs.clear() + self.assertEqual(status, 200) + self.assertEqual(calls[0][0], ['sh', launch]) + self.assertTrue(calls[0][1]['start_new_session']) + self.assertTrue(payload['run']['running']) + + def test_stop_rejects_untracked_process(self): + server._payload_runs.clear() + status, payload = server.h_payloads_stop(type('C', (), { + 'body': {'id': 'not-tracked'}})()) + self.assertEqual(status, 404) + + def test_remove_rejects_active_webui(self): + status, payload = server.h_payloads_remove(type('C', (), { + 'args': (), 'body': {'key': server.SELF_PAYLOAD_KEY}})()) + self.assertEqual(status, 409) + self.assertIn('cannot remove itself', payload['error']) + + +class LoggingTest(unittest.TestCase): + def test_system_tails(self): + server.device_run = lambda args, timeout=20: (0, '\n'.join('line%d' % i for i in range(20)), '') + status, payload = server.h_logging_system(type('C', (), {'args': (), 'query': {'lines': '5'}})()) + self.assertEqual(status, 200) + self.assertEqual(len(payload['lines']), 5) + + def test_invalid_line_count_uses_default(self): + server.device_run = lambda args, timeout=20: ( + 0, '\n'.join('line%d' % i for i in range(250)), '') + status, payload = server.h_logging_system(type('C', (), { + 'args': (), 'query': {'lines': 'invalid'}})()) + self.assertEqual(status, 200) + self.assertEqual(len(payload['lines']), 200) + + def test_line_count_is_bounded(self): + self.assertEqual(server._line_count( + type('C', (), {'query': {'lines': '999999'}})(), 200), 2000) + self.assertEqual(server._line_count( + type('C', (), {'query': {'lines': '-10'}})(), 200), 0) + + +class SettingsTest(unittest.TestCase): + def test_hostname_get(self): + class H: + command = 'GET' + server.device_run = lambda args, timeout=20: (0, 'pager\n', '') + status, payload = server.h_settings_hostname(type('C', (), {'args': (), 'h': H()})()) + self.assertEqual(status, 200) + self.assertEqual(payload['hostname'], 'pager') + + def test_internet_status_uses_bounded_connectivity_check(self): + calls = [] + old = server.device_run + server.device_run = lambda args, timeout=20: ( + calls.append((args, timeout)) or (0, '', '')) + try: + status, payload = server.h_settings_internet(type('C', (), {})()) + finally: + server.device_run = old + self.assertEqual(status, 200) + self.assertTrue(payload['online']) + self.assertEqual(calls[0][0][-1], '1.1.1.1') + self.assertEqual(calls[0][1], 5) + + def test_reboot_is_scheduled_after_response(self): + scheduled = [] + class FakeTimer: + def __init__(self, delay, fn): + scheduled.append((delay, fn)) + self.daemon = False + def start(self): + scheduled.append('started') + old = server.threading.Timer + server.threading.Timer = FakeTimer + try: + status, payload = server.h_settings_reboot(type('C', (), {})()) + finally: + server.threading.Timer = old + self.assertEqual(status, 200) + self.assertTrue(payload['ok']) + self.assertEqual(scheduled[0][0], 1.0) + self.assertEqual(scheduled[1], 'started') + + def test_password_uses_passwd_stdin_and_invalidates_session(self): + calls = [] + def fake(args, timeout=20, input_data=None): + calls.append((args, timeout, input_data)) + return 0, '', '' + old_run = server.device_run + old_session = server.SESSION_FILE + fd, session = tempfile.mkstemp() + os.close(fd) + server.device_run = fake + server.SESSION_FILE = session + try: + status, payload = server.h_settings_password( + type('C', (), {'args': (), 'body': {'password': 'newpw'}})()) + finally: + server.device_run = old_run + server.SESSION_FILE = old_session + self.assertEqual((status, payload), (200, {'ok': True})) + self.assertEqual(calls, [(['/bin/passwd', 'root'], 15, b'newpw\nnewpw\n')]) + self.assertFalse(os.path.exists(session)) + + def test_password_reports_passwd_failure(self): + old_run = server.device_run + server.device_run = lambda args, timeout=20, input_data=None: (1, '', 'bad password') + try: + status, payload = server.h_settings_password( + type('C', (), {'args': (), 'body': {'password': 'newpw'}})()) + finally: + server.device_run = old_run + self.assertEqual(status, 500) + self.assertEqual(payload['error'], 'bad password') + + def test_ntp_get_parses(self): + class H: + command = 'GET' + server.device_run = lambda args, timeout=20: (0, "system.ntp.enabled='1'\nsystem.ntp.server='0.pool.ntp.org'\n", '') + status, payload = server.h_settings_ntp(type('C', (), {'args': (), 'h': H()})()) + self.assertEqual(status, 200) + self.assertEqual(payload['enabled'], True) + self.assertIn('0.pool.ntp.org', payload['servers']) + + def test_ntp_get_parses_multiple_values_on_one_uci_line(self): + class H: + command = 'GET' + server.device_run = lambda args, timeout=20: ( + 0, + "system.ntp.enabled='1'\n" + "system.ntp.server='0.openwrt.pool.ntp.org' '1.openwrt.pool.ntp.org'\n", + '') + status, payload = server.h_settings_ntp(type('C', (), {'args': (), 'h': H()})()) + self.assertEqual(status, 200) + self.assertEqual(payload['servers'], [ + '0.openwrt.pool.ntp.org', '1.openwrt.pool.ntp.org']) + + def test_service_running_detection(self): + server.device_run = lambda args, timeout=20: (0, '', '') + status, payload = server.h_settings_service(type('C', (), {'args': ()})()) + self.assertEqual(status, 200) + self.assertIn('running', payload) + self.assertIn('background', payload) + + def test_resources_parses_mark_style_filesystem_rows(self): + sample = ("Filesystem Type 1024-blocks Used Available Capacity Mounted on\n" + "/dev/root squashfs 30720 30720 0 100% /rom\n" + "/dev/mmcblk0p1 ext4 3724208 21876 3492668 1% /mmc\n") + rows = server._parse_df_rows(sample) + self.assertEqual(len(rows), 2) + self.assertEqual(rows[1]['format'], 'ext4') + self.assertEqual(rows[1]['mount'], '/mmc') + self.assertEqual(rows[1]['size'], 3724208 * 1024) + + def test_usb_parser_returns_structured_devices(self): + server.device_run = lambda args, timeout=20: ( + 0, 'Bus 001 Device 004: ID 0e8d:7961 MediaTek Inc. Wireless_Device\n', '') + status, payload = server.h_settings_usb(type('C', (), {'args': ()})()) + self.assertEqual(status, 200) + self.assertEqual(payload['devices'][0]['id'], '0e8d:7961') + self.assertIn('MediaTek', payload['devices'][0]['name']) + + def test_password_rejects_mismatched_confirmation(self): + status, payload = server.h_settings_password(type('C', (), {'body': { + 'new_password': 'newpass', 'repeat_password': 'different'}})()) + self.assertEqual(status, 400) + self.assertIn('match', payload['error']) + + def test_management_wifi_get_hides_existing_password(self): + old = server._uci_wifi_iface + server._uci_wifi_iface = lambda name: { + 'ssid': 'Pager Mgmt', 'key': 'secretpass', 'disabled': '0', 'hidden': '1'} + try: + status, payload = server.h_settings_management_wifi( + type('C', (), {'body': {}, 'args': ()})()) + finally: + server._uci_wifi_iface = old + self.assertEqual(status, 200) + self.assertTrue(payload['has_password']) + self.assertNotIn('password', payload) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_pineap_clients.py b/tests/test_pineap_clients.py new file mode 100644 index 0000000..6780d93 --- /dev/null +++ b/tests/test_pineap_clients.py @@ -0,0 +1,63 @@ +import os +import sys +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'remote_access', 'pager-webui')) +import server + + +def setUpModule(): + __import__('importlib').reload(server) + +_orig_hak5 = server.hak5 +_orig_assoc_clients = server.assoc_clients + + +class NormalizeTest(unittest.TestCase): + def test_normalize(self): + self.assertEqual(server.normalize_mac(' 00:11:22:33:44:55 '), '00:11:22:33:44:55') + self.assertEqual(server.normalize_mac('aa:bb:cc:dd:ee:ff'), 'AA:BB:CC:DD:EE:FF') + + def test_invalid_returns_none(self): + self.assertIsNone(server.normalize_mac('nope')) + + +class ClientsTest(unittest.TestCase): + def tearDown(self): + server.hak5 = _orig_hak5 + server.assoc_clients = _orig_assoc_clients + + def test_clients_handler(self): + server.assoc_clients = lambda: [{'mac': 'AA:BB:CC:DD:EE:FF', 'iface': 'wlan0open', 'rssi': -55}] + class Ctx: + args = () + status, payload = server.h_clients(Ctx()) + self.assertEqual(status, 200) + self.assertEqual(payload['count'], 1) + + def test_kick_validates_and_deny_adds(self): + calls = [] + def fake(*args): + calls.append(args) + return 'ok' + server.hak5 = fake + server.h_client_kick(type('C', (), {'args': (), 'body': {'mac': '00:11:22:33:44:55'}})()) + self.assertIn(('PINEAPPLE_DEVICE_FILTER_ADD', 'deny', '00:11:22:33:44:55'), calls) + self.assertTrue(any(c[0] == 'PINEAPPLE_DEAUTH_CLIENT' for c in calls)) + + def test_kick_bad_mac_400(self): + status, payload = server.h_client_kick(type('C', (), {'args': (), 'body': {'mac': 'x'}})()) + self.assertEqual(status, 400) + + def test_deauth_client(self): + calls = [] + def fake(*args): + calls.append(args) + return 'ok' + server.hak5 = fake + server.h_deauth_client(type('C', (), {'args': (), 'body': {'mac': '00:11:22:33:44:55'}})()) + self.assertEqual(calls[0][0], 'PINEAPPLE_DEAUTH_CLIENT') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_pineap_enterprise.py b/tests/test_pineap_enterprise.py new file mode 100644 index 0000000..ecb8ece --- /dev/null +++ b/tests/test_pineap_enterprise.py @@ -0,0 +1,43 @@ +import os +import sys +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'remote_access', 'pager-webui')) +import server + + +def setUpModule(): + __import__('importlib').reload(server) + + +class 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_challenge_rows(self): + server._db_rows = lambda db, sql: [] + status, payload = server.h_enterprise_data(type('C', (), {'args': ('challenge',)})()) + self.assertEqual(status, 200) + self.assertEqual(payload['rows'], []) + + def test_unknown_table(self): + status, payload = server.h_enterprise_data(type('C', (), {'args': ('nope',)})()) + self.assertEqual(status, 400) + + def test_clear(self): + calls = [] + server._db_write = lambda db, sql: calls.append(sql) + status, payload = server.h_enterprise_clear(type('C', (), {'body': {'table': 'challenge'}})()) + self.assertEqual(status, 200) + self.assertTrue(any('hostap_challenge' in s for s in calls)) + + def test_clear_unknown_table(self): + status, payload = server.h_enterprise_clear(type('C', (), {'body': {'table': 'nope'}})()) + self.assertEqual(status, 400) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_pineap_modes.py b/tests/test_pineap_modes.py new file mode 100644 index 0000000..16fc1a0 --- /dev/null +++ b/tests/test_pineap_modes.py @@ -0,0 +1,126 @@ +import os +import shutil +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'remote_access', 'pager-webui')) +import server + + +def setUpModule(): + __import__('importlib').reload(server) + + +def ctx(body=None): + return type('C', (), {'body': body, 'args': (), 'query': {}})() + + +class PineapModeTest(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.mkdtemp(prefix='pager-mode-') + self.old_state_file = server.PINEAP_STATE_FILE + self.old_proxy = server._daemon_proxy + self.old_sock = server.daemon_sock_call + server.PINEAP_STATE_FILE = os.path.join(self.tmp, 'state.json') + + def tearDown(self): + server.PINEAP_STATE_FILE = self.old_state_file + server._daemon_proxy = self.old_proxy + server.daemon_sock_call = self.old_sock + shutil.rmtree(self.tmp) + + def test_passive_applies_mark_vii_supported_preset(self): + calls = [] + server._daemon_proxy = lambda method, path, body=None, timeout=15: ( + calls.append((method, path, body)) or (200, {'success': True})) + status, payload = server.h_pineap_mode_post(ctx({'mode': 'passive'})) + self.assertEqual(status, 200) + self.assertEqual([c[1] for c in calls], [ + 'hostapd/enable_pineap', 'ssidpool/enable_collect', + 'ssidpool/disable']) + self.assertEqual(calls[0][2], {'enable': False}) + self.assertEqual(payload['mode'], 'passive') + self.assertTrue(payload['collect']) + self.assertFalse(payload['advertise']) + self.assertFalse(payload['karma']) + self.assertFalse(payload['enabled']) + + def test_active_enables_response_engine_and_pool_broadcasting(self): + calls = [] + server._daemon_proxy = lambda method, path, body=None, timeout=15: ( + calls.append((method, path, body)) or (200, {'success': True})) + status, payload = server.h_pineap_mode_post(ctx({'mode': 'active'})) + self.assertEqual(status, 200) + self.assertEqual(calls[0], ('PUT', 'hostapd/enable_pineap', {'enable': True})) + self.assertNotIn('mimic/disable', [c[1] for c in calls]) + self.assertEqual(calls[-1], ('POST', 'ssidpool/enable', {'enable': True})) + self.assertEqual(payload['mode'], 'active') + self.assertTrue(payload['advertise']) + self.assertTrue(payload['karma']) + self.assertTrue(payload['enabled']) + + def test_advanced_preserves_device_settings(self): + calls = [] + server._daemon_proxy = lambda *args, **kwargs: (calls.append(args) or (200, {})) + status, payload = server.h_pineap_mode_post(ctx({'mode': 'advanced'})) + self.assertEqual(status, 200) + self.assertEqual(calls, []) + self.assertEqual(payload['mode'], 'advanced') + + def test_get_invalidates_preset_when_device_state_disagrees(self): + server.update_pineap_state(mode='passive', collect=True, advertise=False) + + def fake(method, path, body=None, timeout=10): + if path.endswith('/get_config') and 'hostapd' not in path: + return 200, {'autossidpool': False} + return 200, {'pineap_disabled': False} + + server.daemon_sock_call = fake + status, payload = server.h_pineap_mode_get(ctx()) + self.assertEqual(status, 200) + self.assertEqual(payload['mode'], 'advanced') + self.assertFalse(payload['collect']) + + def test_get_keeps_passive_when_response_engine_is_disabled(self): + server.update_pineap_state(mode='passive', enabled=False, + collect=True, advertise=False) + + def fake(method, path, body=None, timeout=10): + if 'hostapd' in path: + return 200, {'pineap_disabled': True} + return 200, {'autossidpool': True} + + server.daemon_sock_call = fake + status, payload = server.h_pineap_mode_get(ctx()) + self.assertEqual(status, 200) + self.assertEqual(payload['mode'], 'passive') + self.assertFalse(payload['enabled']) + + def test_get_invalidates_active_when_response_engine_stops(self): + server.update_pineap_state(mode='active', enabled=True, + collect=True, advertise=True) + + def fake(method, path, body=None, timeout=10): + if 'hostapd' in path: + return 200, {'pineap_disabled': True} + return 200, {'autossidpool': True} + + server.daemon_sock_call = fake + status, payload = server.h_pineap_mode_get(ctx()) + self.assertEqual(status, 200) + self.assertEqual(payload['mode'], 'advanced') + self.assertFalse(payload['enabled']) + + def test_manual_mimic_change_marks_advanced(self): + server.update_pineap_state(mode='passive', karma=False) + server._daemon_proxy = lambda *args, **kwargs: (200, {'success': True}) + status, _ = server.h_pineap_mimic(ctx({'enable': True})) + self.assertEqual(status, 200) + state = server.load_pineap_state() + self.assertEqual(state['mode'], 'advanced') + self.assertTrue(state['karma']) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_pineap_pool.py b/tests/test_pineap_pool.py new file mode 100644 index 0000000..ef57f5c --- /dev/null +++ b/tests/test_pineap_pool.py @@ -0,0 +1,82 @@ +import os +import sys +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'remote_access', 'pager-webui')) +import server + + +def setUpModule(): + __import__('importlib').reload(server) + + +class Hak5Test(unittest.TestCase): + def test_hak5_uses_full_path_arglist(self): + calls = [] + def fake(args, timeout=20): + calls.append(args) + return 0, 'line1\n', '' + server.device_run = fake + out = server.hak5('PINEAPPLE_SSID_POOL_LIST') + self.assertEqual(calls[0][0], server.HAK5CMD) + self.assertIn('PINEAPPLE_SSID_POOL_LIST', calls[0]) + self.assertEqual(out, 'line1\n') + + +class PoolParsingTest(unittest.TestCase): + def test_parse_json_pool(self): + text = '{"ssids": ["A", "B"]}' + self.assertEqual(server._parse_pool_list(text), ['A', 'B']) + + def test_parse_line_pool(self): + text = '"SSID A"\nSSID B\n' + self.assertEqual(server._parse_pool_list(text), ['SSID A', 'SSID B']) + + +class SsidPoolHandlersTest(unittest.TestCase): + def test_ssids_get(self): + server.hak5 = lambda *a, **k: '{"ssids": ["FreeWiFi"]}\n' + class Ctx: + args = () + status, payload = server.h_ssids_get(Ctx()) + self.assertEqual(status, 200) + self.assertEqual(payload['ssids'], ['FreeWiFi']) + + def test_ssids_post_add(self): + calls = [] + + def fake(*args): + calls.append(args) + return 'ok' + + server.hak5 = fake + server.h_ssids_post(type('C', (), {'args': (), 'body': {'action': 'add', 'ssid': 'NewNet'}})()) + self.assertTrue(any(c[0] == 'PINEAPPLE_SSID_POOL_ADD' for c in calls)) + + def test_advertise_routes(self): + 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_advertise(type('C', (), {'body': {'enable': True}})()) + server.h_pineap_advertise(type('C', (), {'body': {'enable': False}})()) + self.assertEqual(calls, [('/api/pineap/ssidpool/enable', {'enable': True}), + ('/api/pineap/ssidpool/disable', {'enable': False})]) + + def test_collect_routes(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_collect(type('C', (), {'body': {'enable': True}})()) + self.assertEqual(calls[0], ('/api/pineap/ssidpool/enable_collect', {'enable': True})) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_pineap_proxy.py b/tests/test_pineap_proxy.py new file mode 100644 index 0000000..ee39b72 --- /dev/null +++ b/tests/test_pineap_proxy.py @@ -0,0 +1,258 @@ +import os +import sys +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'remote_access', 'pager-webui')) +import server + + +def setUpModule(): + __import__('importlib').reload(server) + + +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], ('PUT', '/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})) + + def test_set_config_forwards_body(self): + calls = [] + sock_calls = [] + + def fake_get(method, path, body=None, timeout=10): + if path == '/api/pineap/get_config': + return 200, {'reconpath': '/root/recon/', 'reconname': 'pager', 'autossidpool': False} + sock_calls.append((method, path)) + return (200, {'success': True}) + + def fake(method, path, body=None, timeout=10): + calls.append((method, path, body)) + return (200, {'success': True}) + + server.daemon_sock_call = lambda m, p, body=None, timeout=10: fake_get(m, p, body, timeout) if m == 'GET' else fake(m, p, body, timeout) + server.h_pineap_set_config(ctx({'loghandshake': True})) + self.assertEqual(calls[0][0], 'PUT') + self.assertEqual(calls[0][1], '/api/pineap/set_config') + self.assertEqual(calls[0][2]['reconpath'], '/root/recon/') + self.assertEqual(calls[0][2]['reconname'], 'pager') + self.assertEqual(calls[0][2]['loghandshake'], True) + self.assertEqual(calls[0][2]['autossidpool'], False) + + def test_hostapd_set_merges_interfaces(self): + calls = [] + + def fake(method, path, body=None, timeout=10): + if method == 'GET': + return 200, {'mgmt_ifaces': ['wlan0mgmt'], 'wpa_ifaces': ['wlan0wpa'], + 'pineap_disabled': False, 'pineape_disabled': False, 'pineape_auth_pass': True} + calls.append((method, path, body)) + return (200, {'success': True}) + + server.daemon_sock_call = fake + server.h_pineap_hostapd_set(ctx({'pineape_disabled': True})) + method, path, body = calls[0] + self.assertEqual(method, 'PUT') + self.assertEqual(path, '/api/pineap/hostapd/set_config') + self.assertEqual(body['pineape_disabled'], True) + self.assertEqual(body['mgmt_ifaces'], ['wlan0mgmt']) + self.assertEqual(body['wpa_ifaces'], ['wlan0wpa']) + + 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) + + def test_wifi_set_ap_builds_configs(self): + calls = [] + + def fake_sock(method, path, body=None, timeout=10): + calls.append((method, path, body)) + return (200, {'success': True}) + + server.daemon_sock_call = fake_sock + status, _ = server.h_pineap_wifi_set_ap(ctx({'wpa': { + 'ssid': 'Evil2', 'passphrase': 'pass123', 'enctype': 'sae', 'hidden': True, 'enabled': True}})) + self.assertEqual(status, 200) + method, path, body = calls[0] + self.assertEqual(method, 'PUT') + self.assertEqual(path, '/api/settings/wifi/set_ap') + self.assertEqual(body['configs'][0]['interface'], 'wlan0wpa') + self.assertEqual(body['configs'][0]['ssid'], 'Evil2') + self.assertEqual(body['configs'][0]['enctype'], 'sae') + self.assertEqual(body['configs'][0]['key'], 'pass123') + self.assertEqual(body['configs'][0]['enabled'], True) + self.assertEqual(body['configs'][0]['channel'], 1) + + +class PineapFilterTest(unittest.TestCase): + def test_filter_get_reads_daemon_mode_and_active_list(self): + server.daemon_sock_call = lambda method, path, body=None, timeout=10: ( + 200, {'mode': 'deny', 'allowed_macs': ['00:11:22:33:44:55'], 'denied_macs': ['AA:BB:CC:DD:EE:FF']}) + status, payload = server.h_filter_get(ctx(), 'client') + self.assertEqual(status, 200) + self.assertEqual(payload['mode'], 'deny') + self.assertEqual(payload['entries'], ['AA:BB:CC:DD:EE:FF']) + + def test_ssid_filter_get_decodes_daemon_base64_values(self): + server.daemon_sock_call = lambda method, path, body=None, timeout=10: ( + 200, {'mode': 'allow', 'allowed_ssids': ['Q29kZXhGaWx0ZXJQcm9iZQ==', 'test'], + 'denied_ssids': []}) + status, payload = server.h_filter_get(ctx(), 'ssid') + self.assertEqual(status, 200) + self.assertEqual(payload['entries'], ['CodexFilterProbe', 'test']) + + def test_filter_set_mode_uses_hak5_mode_command(self): + calls = [] + + server.daemon_sock_call = lambda method, path, body=None, timeout=10: ( + 200, {'mode': 'deny', 'allowed_macs': [], 'denied_macs': []}) + server.device_run = lambda args, timeout=20: (calls.append(args) or (0, '', '')) + server.h_filter_post(ctx({'action': 'set_mode', 'mode': 'allow'}), 'client') + self.assertEqual(calls[0], [server.HAK5CMD, 'PINEAPPLE_DEVICE_FILTER_MODE', 'allow']) + + def test_filter_add_includes_active_mode(self): + calls = [] + server.daemon_sock_call = lambda method, path, body=None, timeout=10: ( + 200, {'mode': 'allow', 'allowed_macs': [], 'denied_macs': []}) + server.device_run = lambda args, timeout=20: (calls.append(args) or (0, '', '')) + server.h_filter_post(ctx({'action': 'add', 'value': '00:11:22:33:44:55'}), 'client') + self.assertEqual(calls[0], [server.HAK5CMD, 'PINEAPPLE_DEVICE_FILTER_ADD', + 'allow', '00:11:22:33:44:55']) + + def test_filter_delete_and_clear_include_active_mode(self): + calls = [] + server.daemon_sock_call = lambda method, path, body=None, timeout=10: ( + 200, {'mode': 'deny', 'allowed_ssids': [], 'denied_ssids': ['Guest']}) + server.device_run = lambda args, timeout=20: (calls.append(args) or (0, '', '')) + server.h_filter_post(ctx({'action': 'delete', 'value': 'Guest'}), 'ssid') + server.h_filter_post(ctx({'action': 'clear'}), 'ssid') + self.assertEqual(calls[0], [server.HAK5CMD, 'PINEAPPLE_NETWORK_FILTER_DELETE', 'deny', 'Guest']) + self.assertEqual(calls[1], [server.HAK5CMD, 'PINEAPPLE_NETWORK_FILTER_CLEAR', 'deny']) + + def test_filter_allow_all_clears_deny_list_and_sets_deny_mode(self): + calls = [] + server.daemon_sock_call = lambda method, path, body=None, timeout=10: ( + 200, {'mode': 'allow', 'allowed_ssids': ['Guest'], 'denied_ssids': []}) + server.device_run = lambda args, timeout=20: (calls.append(args) or (0, '', '')) + server.h_filter_post(ctx({'action': 'allow_all'}), 'ssid') + self.assertEqual(calls[:2], [ + [server.HAK5CMD, 'PINEAPPLE_NETWORK_FILTER_CLEAR', 'deny'], + [server.HAK5CMD, 'PINEAPPLE_NETWORK_FILTER_MODE', 'deny']]) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_pineap_settings.py b/tests/test_pineap_settings.py new file mode 100644 index 0000000..e3850c6 --- /dev/null +++ b/tests/test_pineap_settings.py @@ -0,0 +1,55 @@ +import os +import sys +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'remote_access', 'pager-webui')) +import server + + +def setUpModule(): + __import__('importlib').reload(server) + + +def ctx(body=None): + return type('C', (), {'body': body, 'args': (), 'query': {}})() + + +class GetConfigProxyTest(unittest.TestCase): + def test_get_config_proxies_daemon(self): + server.daemon_sock_call = lambda method, path, body=None, timeout=10: ( + 200, {'loghandshake': True, 'logrecon': False, 'autossidpool': True}) + status, payload = server.h_pineap_get_config(ctx()) + self.assertEqual(status, 200) + self.assertTrue(payload['loghandshake']) + self.assertFalse(payload['logrecon']) + + def test_set_config_forwards_flags(self): + calls = [] + + def fake(method, path, body=None, timeout=10): + if method == 'GET': + return 200, {'reconpath': '/root/recon/', 'loghandshake': False, + 'logpcap': True, 'logrecon': True} + calls.append((path, body)) + return (200, {'success': True}) + + server.daemon_sock_call = fake + server.h_pineap_set_config(ctx({'loghandshake': True, 'logpcap': False})) + path, body = calls[0] + self.assertEqual(path, '/api/pineap/set_config') + self.assertEqual(body['loghandshake'], True) + self.assertEqual(body['logpcap'], False) + self.assertEqual(body['logrecon'], True) + self.assertEqual(body['reconpath'], '/root/recon/') + self.assertTrue('autossidpool' in body) + + def test_hostapd_get(self): + server.daemon_sock_call = lambda method, path, body=None, timeout=10: ( + 200, {'pineap_disabled': False, 'pineape_disabled': False}) + status, payload = server.h_pineap_hostapd_get(ctx()) + self.assertEqual(status, 200) + self.assertFalse(payload['pineap_disabled']) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_recon.py b/tests/test_recon.py new file mode 100644 index 0000000..8aecb6b --- /dev/null +++ b/tests/test_recon.py @@ -0,0 +1,599 @@ +import os +import shutil +import sqlite3 +import sys +import tempfile +import unittest +from unittest import mock + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'remote_access', 'pager-webui')) +import server + + +def setUpModule(): + __import__('importlib').reload(server) + +SCHEMA = ''' +CREATE TABLE scan(id INTEGER PRIMARY KEY AUTOINCREMENT, uuid TEXT, time INT, name TEXT); +CREATE TABLE wifi_device(hash INT PRIMARY KEY, scan INT, mac TEXT, time INT, signal INT, freq INT, packets INT); +CREATE TABLE ssid(hash INT PRIMARY KEY, wifi_device INT, scan INT, type INT, bssid TEXT, ssid BLOB, hidden INT, time INT, signal INT, freq INT, channel INT, encryption INT); +CREATE TABLE handshake(hash INT PRIMARY KEY, scan INT, stahash INT, aphash INT, time INT, beacon BLOB, hs1 BLOB, hs2 BLOB, hs3 BLOB, hs4 BLOB); +CREATE TABLE hostap_basic(id INT PRIMARY KEY, scan INT, time INT, type TEXT, identity TEXT, password TEXT, verified INT NOT NULL DEFAULT 0); +CREATE TABLE hostap_chalresp(id INT PRIMARY KEY, scan INT, time INT, type TEXT, username TEXT, challenge BLOB, response BLOB, verified INT NOT NULL DEFAULT 0); +CREATE TABLE hostap_client(id INT PRIMARY KEY, scan INT, hash INT, mac TEXT, ssid BLOB, connected_time INT, disconnected_time INT); +CREATE TABLE hostap_handshake(id INT PRIMARY KEY, scan INT, time INT, type INT, mic BLOB, apmac BLOB, stamac BLOB, ssid BLOB, nonce BLOB, eapol BLOB); +''' + + +def make_db(): + fd, db = tempfile.mkstemp(suffix='.db') + os.close(fd) + conn = sqlite3.connect(db) + conn.executescript(SCHEMA) + conn.execute("INSERT INTO scan (uuid, time, name) VALUES ('u1', 1786466531, 'pager')") + conn.execute("INSERT INTO scan (uuid, time, name) VALUES ('u2', 1786466848, 'pager')") + conn.execute("INSERT INTO wifi_device (hash, scan, mac, time, signal, freq, packets) VALUES (1, 1, 'AE77C0EB3141', 1786466531, -71, 2412, 5)") + conn.execute("INSERT INTO wifi_device (hash, scan, mac, time, signal, freq, packets) VALUES (2, 1, 'C89E43648080', 1786466532, -76, 5745, 9)") + conn.execute("INSERT INTO ssid (hash, wifi_device, scan, type, bssid, ssid, hidden, time, signal, freq, channel, encryption) " + "VALUES (10, 2, 1, 8, 'C89E43648080', X'416E646572736F6E2D35', 0, 1786466532, -76, 5745, 149, 0x400400108)") + conn.execute("INSERT INTO ssid (hash, wifi_device, scan, type, bssid, ssid, hidden, time, signal, freq, channel, encryption) " + "VALUES (11, 2, 1, 8, '506F9A010000', X'', 1, 1786466532, -64, 5745, 149, 0)") + conn.execute("INSERT INTO ssid (hash, wifi_device, scan, type, bssid, ssid, hidden, time, signal, freq, channel, encryption) " + "VALUES (12, 1, 1, 4, NULL, X'5A6E6574', NULL, 1786466531, -40, 2412, NULL, NULL)") + conn.execute("INSERT INTO handshake (hash, scan, stahash, aphash, time) VALUES (20, 1, 1, 2, 1786466600)") + conn.execute("INSERT INTO hostap_basic (scan, time, type, identity, password, verified) VALUES (1, 1786466601, 'WPA', 'bob', '', 0)") + conn.commit() + conn.close() + return db + + +class DecodersTest(unittest.TestCase): + def test_fmt_mac_colon_form(self): + self.assertEqual(server.fmt_mac('AE77C0EB3141'), 'AE:77:C0:EB:31:41') + + def test_fmt_mac_noop(self): + self.assertEqual(server.fmt_mac('AE:77:C0:EB:31:41'), 'AE:77:C0:EB:31:41') + self.assertEqual(server.fmt_mac(''), '--') + self.assertEqual(server.fmt_mac(None), '--') + + def test_norm_mac_12hex(self): + self.assertEqual(server._norm_mac('C89E43648080'), 'C8:9E:43:64:80:80') + + def test_decode_ssid_bytes_and_str(self): + self.assertEqual(server.decode_ssid(b'Znet'), 'Znet') + self.assertEqual(server.decode_ssid('Znet'), 'Znet') + self.assertEqual(server.decode_ssid(None), '') + self.assertEqual(server.decode_ssid(b'\xff\xfeZnet'), '\ufffd\ufffdZnet') + + def test_decode_ssid_cli_escapes(self): + # Device sqlite3 CLI -json emits \xNN escapes for non-UTF8 BLOBs. + self.assertEqual(server.decode_ssid('casaalicia\\x00.\\xde_;'), 'casaalicia\x00.\ufffd_;') + + def test_decode_encryption_cases(self): + self.assertEqual(server.decode_encryption(0), 'Open') + self.assertEqual(server.decode_encryption(None), 'Open') + self.assertEqual(server.decode_encryption(2), 'WEP') + self.assertEqual(server.decode_encryption(0x04), 'WPA') + self.assertEqual(server.decode_encryption(0x08), 'WPA2') + self.assertEqual(server.decode_encryption(0x04 | 0x08), 'WPA2 WPA') + self.assertEqual(server.decode_encryption(0x400400108), 'WPA3 WPA2') + self.assertEqual(server.decode_encryption(0x20050004C), 'WPA2 WPA') + + +class ReconDataTest(unittest.TestCase): + def setUp(self): + self.db = make_db() + server.RECON_DB = self.db + + def tearDown(self): + os.unlink(self.db) + + def test_scans_list_uses_time_and_counts(self): + data = server.recon_scans_data() + self.assertEqual(len(data['scans']), 2) + newest = data['scans'][0] + self.assertEqual(newest['id'], 2) + self.assertEqual(newest['time'], 1786466848) + self.assertEqual(newest['name'], 'pager') + old = data['scans'][1] + self.assertEqual(old['devices'], 2) + self.assertEqual(old['aps'], 2) + self.assertEqual(old['handshakes'], 1) + self.assertNotIn('uuid', old) + + def test_scan_detail_decodes_aps(self): + data = server.recon_scan_data(1) + self.assertEqual(data['scan']['id'], 1) + self.assertEqual(data['scan']['time'], 1786466531) + self.assertEqual(len(data['aps']), 2) + aps = {a['bssid']: a for a in data['aps']} + a = aps['C8:9E:43:64:80:80'] + self.assertEqual(a['ssid'], 'Anderson-5') + self.assertEqual(a['channel'], 149) + self.assertEqual(a['signal'], -76) + self.assertEqual(a['encryption'], 'WPA3 WPA2') + self.assertFalse(a['hidden']) + hidden = aps['50:6F:9A:01:00:00'] + self.assertTrue(hidden['hidden']) + self.assertEqual(hidden['encryption'], 'Open') + + def test_scan_detail_clients_exclude_ap_macs(self): + data = server.recon_scan_data(1) + macs = [c['mac'] for c in data['clients']] + self.assertEqual(macs, ['AE:77:C0:EB:31:41']) + + def test_scan_detail_handshakes_resolve_macs(self): + data = server.recon_scan_data(1) + self.assertEqual(len(data['handshakes']), 1) + hs = data['handshakes'][0] + self.assertEqual(hs['ap'], 'C8:9E:43:64:80:80') + self.assertEqual(hs['client'], 'AE:77:C0:EB:31:41') + self.assertEqual(hs['time'], 1786466600) + + def test_scan_detail_missing_returns_none(self): + self.assertIsNone(server.recon_scan_data(999)) + + +class FakeSock: + def __init__(self, resp=b''): + self.resp = resp + self.sent = b'' + self.connected = None + + def settimeout(self, t): + pass + + def connect(self, addr): + self.connected = addr + + def sendall(self, b): + self.sent += b + + def recv(self, n): + chunk, self.resp = self.resp[:n], self.resp[n:] + return chunk + + def close(self): + pass + + +class DaemonSockTest(unittest.TestCase): + def test_socket_call_posts_json_to_sock(self): + server.DAEMON_SOCK = '/tmp/api.sock' + fake = FakeSock(b'HTTP/1.1 200 OK\r\nContent-Length: 17\r\n\r\n{"success":true}') + with mock.patch.object(server.socket, 'socket', lambda *a, **k: fake): + status, data = server.daemon_sock_call('POST', '/api/pineap/recon/new', body={'x': 1}) + self.assertEqual(status, 200) + self.assertEqual(data, {'success': True}) + self.assertEqual(fake.connected, '/tmp/api.sock') + self.assertTrue(fake.sent.startswith(b'POST /api/pineap/recon/new HTTP/1.1')) + self.assertTrue(b'Content-Length: 8' in fake.sent) + self.assertTrue(fake.sent.endswith(b'{"x": 1}')) + + def test_socket_call_connect_error(self): + def boom(*a, **k): + raise OSError('nope') + with mock.patch.object(server.socket, 'socket', boom): + status, data = server.daemon_sock_call('POST', '/api/pineap/recon/new') + self.assertEqual(status, 0) + self.assertIsNone(data) + + def test_start_stop_handlers_call_socket(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': ()})()) + server.h_recon_stop(type('C', (), {'args': ()})()) + self.assertEqual(calls[0], ('POST', '/api/pineap/log/recon/start', {})) + self.assertEqual(calls[1], ('POST', '/api/pineap/log/recon/stop', {})) + + 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', {})) + + +class ReconScanStateTest(unittest.TestCase): + """The daemon ignores scan_time and scans continuously until 'stop'. The webui + must track the requested duration itself so timed scans actually end and the + toggle can reflect real scan state.""" + + def setUp(self): + self.db = make_db() + server.RECON_DB = self.db + server._recon_scan_state = {'active': False, 'started': 0, 'duration': 0} + + def tearDown(self): + os.unlink(self.db) + + def _start(self, scan_time=None, fail=False): + body = {} + if scan_time is not None: + body['scan_time'] = scan_time + server.daemon_sock_call = (lambda m, p, body=None: (502, {})) if fail \ + else (lambda m, p, body=None: (200, {'success': True})) + return server.h_recon_start(type('C', (), {'args': (), 'body': body})()) + + def _stop(self, fail=False): + server.daemon_sock_call = (lambda m, p, body=None: (502, {})) if fail \ + else (lambda m, p, body=None: (200, {'success': True})) + return server.h_recon_stop(type('C', (), {'args': ()})()) + + def _status(self): + server.daemon_sock_call = lambda m, p, body=None: (200, {'success': True}) + return server.h_recon_status(type('C', (), {'args': ()})()) + + def test_timed_start_marks_scanning_with_remaining(self): + server.time.time = lambda: 1000.0 + status, data = self._start(scan_time=30) + self.assertEqual(status, 200) + status, data = self._status() + self.assertTrue(data['scanning']) + self.assertEqual(data['scan_remaining'], 30) + + def test_timed_scan_expires_when_duration_elapses(self): + base = [1000.0] + server.time.time = lambda: base[0] + self._start(scan_time=30) + base[0] = 1031.0 + status, data = self._status() + self.assertFalse(data['scanning']) + self.assertEqual(data['scan_remaining'], 0) + + def test_continuous_scan_has_no_remaining(self): + server.time.time = lambda: 1000.0 + self._start(scan_time=0) + status, data = self._status() + self.assertTrue(data['scanning']) + self.assertIsNone(data['scan_remaining']) + + def test_default_start_is_continuous(self): + server.time.time = lambda: 1000.0 + self._start() + status, data = self._status() + self.assertTrue(data['scanning']) + self.assertIsNone(data['scan_remaining']) + + def test_stop_clears_scanning(self): + server.time.time = lambda: 1000.0 + self._start(scan_time=30) + self._stop() + status, data = self._status() + self.assertFalse(data['scanning']) + + def test_start_failure_does_not_mark_scanning(self): + server.time.time = lambda: 1000.0 + status, data = self._start(scan_time=30, fail=True) + self.assertEqual(status, 502) + status, data = self._status() + self.assertFalse(data['scanning']) + + def test_watchdog_stops_expired_timed_scan(self): + calls = [] + server.time.time = lambda: 1000.0 + self._start(scan_time=10) + server.time.time = lambda: 1012.0 + server.daemon_sock_call = lambda m, p, body=None: calls.append((m, p)) or (200, {'success': True}) + server._recon_watchdog_tick() + self.assertEqual(calls, [('POST', '/api/pineap/log/recon/stop')]) + self.assertFalse(server._recon_scan_state['active']) + + def test_watchdog_leaves_active_scan_alone(self): + server.time.time = lambda: 1000.0 + self._start(scan_time=30) + server.time.time = lambda: 1010.0 + calls = [] + server.daemon_sock_call = lambda m, p, body=None: calls.append((m, p)) or (200, {'success': True}) + server._recon_watchdog_tick() + self.assertEqual(calls, []) + self.assertTrue(server._recon_scan_state['active']) + + +class ReconExtrasTest(unittest.TestCase): + def setUp(self): + self.db = make_db() + server.RECON_DB = self.db + + def tearDown(self): + os.unlink(self.db) + + def test_status_reports_last_scan_and_active(self): + server.time.time = lambda: 1786466532 + 100 + status, data = server.h_recon_status(type('C', (), {'args': ()})()) + self.assertEqual(status, 200) + self.assertEqual(data['last_scan'], 1786466848) + self.assertEqual(data['last_activity'], 1786466532) + self.assertTrue(data['active']) + server.time.time = lambda: 1786466532 + 1000 + status, data = server.h_recon_status(type('C', (), {'args': ()})()) + self.assertFalse(data['active']) + + def test_delete_cascades(self): + server.recon_delete_scan(1) + rows = server._db_rows(self.db, 'SELECT count(*) AS c FROM wifi_device') + self.assertEqual(rows[0]['c'], 0) + rows = server._db_rows(self.db, 'SELECT count(*) AS c FROM ssid') + self.assertEqual(rows[0]['c'], 0) + rows = server._db_rows(self.db, 'SELECT count(*) AS c FROM handshake') + self.assertEqual(rows[0]['c'], 0) + rows = server._db_rows(self.db, 'SELECT count(*) AS c FROM scan') + self.assertEqual(rows[0]['c'], 1) + + def test_delete_handler_404_for_missing(self): + status, data = server.h_recon_delete(type('C', (), {'args': ('999',)})()) + self.assertEqual(status, 404) + + def test_events_lists_db_rows(self): + status, data = server.h_recon_events(type('C', (), {'args': ()})()) + self.assertEqual(status, 200) + kinds = [e['type'] for e in data['events']] + self.assertIn('auth attempt', kinds) + self.assertEqual(data['events'][0]['time'], 1786466601) + + +class ReconExamineTest(unittest.TestCase): + def test_examine_bssid_calls_hak5(self): + calls = [] + server.hak5 = lambda *args, **kw: calls.append(args) or '' + ctx = type('C', (), {'args': (), 'body': {'bssid': 'AA:BB:CC:DD:EE:FF'}})() + status, data = server.h_recon_examine(ctx) + self.assertEqual(status, 200) + self.assertEqual(calls[0], ('PINEAPPLE_EXAMINE_BSSID', 'AA:BB:CC:DD:EE:FF')) + + def test_examine_channel_calls_hak5(self): + calls = [] + server.hak5 = lambda *args, **kw: calls.append(args) or '' + ctx = type('C', (), {'args': (), 'body': {'channel': 6}})() + status, data = server.h_recon_examine(ctx) + self.assertEqual(status, 200) + self.assertEqual(calls[0], ('PINEAPPLE_EXAMINE_CHANNEL', '6')) + + def test_examine_requires_target(self): + server.hak5 = lambda *args, **kw: '' + ctx = type('C', (), {'args': (), 'body': {}})() + status, data = server.h_recon_examine(ctx) + self.assertEqual(status, 400) + + +class HandshakeFileTest(unittest.TestCase): + def test_file_download_decodes_name(self): + import shutil + d = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, d) + server.LOOT_HS_DIR = d + name = '00 11_scan.cap' + with open(os.path.join(d, name), 'wb') as f: + f.write(b'PCAPDATA') + status, payload = server.h_handshake_file(type('C', (), {'args': ('00%2011_scan.cap',)})) + self.assertEqual(status, 200) + self.assertEqual(payload.data, b'PCAPDATA') + self.assertEqual(payload.filename, name) + + @unittest.skipIf(os.name == 'nt', 'colons are not valid in filenames on Windows') + def test_file_download_decodes_mac_colon_name(self): + import shutil + d = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, d) + server.LOOT_HS_DIR = d + name = '00:11:22:33:44:55_scan.cap' + with open(os.path.join(d, name), 'wb') as f: + f.write(b'PCAPDATA') + encoded = '00%3A11%3A22%3A33%3A44%3A55_scan.cap' + status, payload = server.h_handshake_file(type('C', (), {'args': (encoded,)})) + self.assertEqual(status, 200) + self.assertEqual(payload.data, b'PCAPDATA') + self.assertEqual(payload.filename, name) + + def test_file_download_404_missing(self): + import shutil + d = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, d) + server.LOOT_HS_DIR = d + status, payload = server.h_handshake_file(type('C', (), {'args': ('nope.cap',)})) + self.assertEqual(status, 404) + + +class CliFallbackTest(unittest.TestCase): + def setUp(self): + self.db = make_db() + server.RECON_DB = self.db + self._orig_sqlite3 = server.sqlite3 + self._orig_device_run = server.device_run + server.sqlite3 = None + server.device_run = lambda args, timeout=20: (0, self._run(args), '') + + def _run(self, args): + # emulate `sqlite3 -json ` over an in-memory copy + import sqlite3 as s3 + import subprocess + conn = s3.connect(self.db) + try: + cur = conn.execute(args[-1]) + conn.commit() + if cur.description: + import json + rows = cur.fetchall() + cols = [d[0] for d in cur.description] + objs = [] + for row in rows: + obj = {} + for c, v in zip(cols, row): + if isinstance(v, bytes): + try: + v = v.decode('utf-8') + except Exception: + v = ''.join('\\x%02x' % b for b in v) + obj[c] = v + objs.append(obj) + return json.dumps(objs) + return '' + finally: + conn.close() + + def tearDown(self): + server.sqlite3 = self._orig_sqlite3 + server.device_run = self._orig_device_run + os.unlink(self.db) + + def test_delete_cascade_via_cli(self): + server.recon_delete_scan(1) + rows = server._db_rows(self.db, 'SELECT count(*) AS c FROM scan') + self.assertEqual(rows[0]['c'], 1) + + def test_cli_ssid_escapes_decoded(self): + # emulate the CLI emitting \xNN escapes for a non-UTF8 blob + server.device_run = lambda args, timeout=20: (0, '[{"ssid": "casaalicia\\\\x00.\\\\xde_"}]', '') + server.RECON_DB = '/nonexistent.db' + self.assertEqual(server.decode_ssid('casaalicia\\x00.\\xde_'), 'casaalicia\x00.\ufffd_') + + +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'424541434F4E', 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() + self.dir = tempfile.mkdtemp() + self.addCleanup(server.__dict__.update, {'RECON_DB': server.RECON_DB, 'LOOT_HS_DIR': server.LOOT_HS_DIR}) + server.RECON_DB = self.db + server.LOOT_HS_DIR = self.dir + + def tearDown(self): + shutil.rmtree(self.dir) + os.unlink(self.db) + + def _write(self, name, ts): + path = os.path.join(self.dir, name) + open(path, 'w').close() + os.utime(path, (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') + + +class HandshakeRoutesTest(unittest.TestCase): + def setUp(self): + self.dir = tempfile.mkdtemp() + self.addCleanup(server.__dict__.update, {'LOOT_HS_DIR': server.LOOT_HS_DIR}) + 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'], []) + + def test_delete_all_skips_dotfiles(self): + self._write('.hidden') + self._write('1_C8-9E-43-64-80-80_AE-77-C0-EB-31-41_handshake.pcap') + 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), ['.hidden']) diff --git a/tests/test_status.py b/tests/test_status.py new file mode 100644 index 0000000..bedc909 --- /dev/null +++ b/tests/test_status.py @@ -0,0 +1,104 @@ +import os +import sys +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'remote_access', 'pager-webui')) +import server + + +def setUpModule(): + __import__('importlib').reload(server) + + +def patch_device(fn): + def wrapper(self): + server.device_run = fn + self.addCleanup(lambda: setattr(server, 'device_run', server.device_run)) + return wrapper + + +class StatusTest(unittest.TestCase): + def test_battery_from_capacity_file(self): + import tempfile + base = tempfile.mkdtemp() + os.makedirs(os.path.join(base, 'battery')) + with open(os.path.join(base, 'battery', 'type'), 'w') as f: + f.write('Battery') + with open(os.path.join(base, 'battery', 'capacity'), 'w') as f: + f.write('87') + with open(os.path.join(base, 'battery', 'status'), 'w') as f: + f.write('Charging') + self.assertEqual(server.battery_data(base), {'level': 87, 'charging': True}) + + def test_battery_missing_returns_none(self): + import tempfile + self.assertEqual(server.battery_data(tempfile.mkdtemp()), {'level': None, 'charging': False}) + + def test_uptime_parses(self): + server.device_run = lambda args, timeout=20: (0, '1234.56 4321.10\n', '') + self.assertEqual(server.uptime_data(), 1234) + + def test_disk_parses_df(self): + server.device_run = lambda args, timeout=20: (0, 'Filesystem 1K-blocks Used Available Use% Mounted on\n/dev/mmcblk0p3 8388608 1024000 7364608 13% /root\n', '') + d = server.disk_data() + self.assertEqual(d['avail'], 7364608 * 1024) + + def test_wifi_ifaces_extracts_names(self): + server.device_run = lambda args, timeout=20: (0, 'wlan0 ESSID: "Pineapple"\nwlan1 ESSID: "Pineapple"\n', '') if args == ['iwinfo'] else (0, '', '') + self.assertEqual(server.wifi_ifaces(), ['wlan0', 'wlan1']) + + def test_assoc_clients_parses(self): + def fake(args, timeout=20): + if args == ['iwinfo']: + return 0, 'wlan0 ESSID: "Pineapple"\n', '' + if args == ['iwinfo', 'wlan0', 'assoclist']: + return 0, '00:11:22:33:44:55 -64 dBm Signal: -64 dBm Rate: 12 Mbit/s\nAA:BB:CC:DD:EE:FF -40 dBm Signal: -40 dBm Rate: 24 Mbit/s\n', '' + return 0, '', '' + server.device_run = fake + clients = server.assoc_clients() + self.assertEqual(len(clients), 2) + self.assertEqual(clients[0]['mac'], '00:11:22:33:44:55') + self.assertEqual(clients[0]['rssi'], -64) + self.assertEqual(clients[0]['iface'], 'wlan0') + + def test_h_status_shape(self): + server.device_run = lambda args, timeout=20: (0, '', '') + server.current_token = lambda: 'tok' + server.daemon_call = lambda m, p, body=None, token=None, timeout=15: (200, {'version': '1'}) + class Ctx: + cookie = 'AUTH_S=tok' + args = () + status, payload = server.h_status(Ctx()) + self.assertEqual(status, 200) + for k in ('battery', 'firmware', 'daemon', 'wifi', 'clients', 'disk', 'uptime', 'hostname'): + self.assertIn(k, payload) + + def test_status_discovers_wifi_interfaces_once(self): + calls = [] + + def fake(args, timeout=20): + calls.append(args) + if args == ['iwinfo']: + return 0, 'wlan0 ESSID: "Pineapple"\n', '' + return 0, '', '' + + server.device_run = fake + server.current_token = lambda: 'tok' + server.daemon_call = lambda m, p, body=None, token=None, timeout=15: (200, {}) + server.status_data() + self.assertEqual(calls.count(['iwinfo']), 1) + + +class DeviceTest(unittest.TestCase): + def test_h_device(self): + server.device_run = lambda args, timeout=20: (0, 'pager\n', '') + class Ctx: + args = () + status, payload = server.h_device(Ctx()) + self.assertEqual(status, 200) + self.assertEqual(payload['hostname'], 'pager') + self.assertIn('macs', payload) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_ws.py b/tests/test_ws.py new file mode 100644 index 0000000..35ad439 --- /dev/null +++ b/tests/test_ws.py @@ -0,0 +1,119 @@ +import base64 +import hashlib +import json +import os +import sys +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'remote_access', 'pager-webui')) +import server + + +def setUpModule(): + __import__('importlib').reload(server) + + +class FrameCodecTest(unittest.TestCase): + def test_encode_small_text_frame(self): + raw = server.ws_encode(b'hi', opcode=0x1) + self.assertEqual(raw[0] & 0x80, 0x80) + self.assertEqual(raw[0] & 0x0F, 0x1) + self.assertEqual(raw[1], 2) + + def test_decode_unmasked_text(self): + frame = server.ws_encode(b'hello', opcode=0x1) + opcode, payload, consumed = server.ws_decode_frame(frame) + self.assertEqual(opcode, 0x1) + self.assertEqual(payload, b'hello') + self.assertEqual(consumed, len(frame)) + + def test_decode_masked_text(self): + import struct + mask = b'\x01\x02\x03\x04' + payload = b'abc' + masked = bytes(payload[i] ^ mask[i % 4] for i in range(len(payload))) + frame = bytes([0x81, 0x80 | len(payload)]) + mask + masked + opcode, out, consumed = server.ws_decode_frame(frame) + self.assertEqual(out, b'abc') + + def test_handshake_reply_uses_sha1(self): + key = 'dGhlIHNhbXBsZSBub25jZQ==' + reply = server.ws_handshake_reply(key).decode() + self.assertIn('101 Switching Protocols', reply) + self.assertIn('s3pPLMBiTxaQ9kYGzzhZRbK+xOo=', reply) + + + def test_encode_masked_roundtrip(self): + raw = server.ws_encode(b'hello', opcode=0x1, mask=True) + self.assertTrue(raw[1] & 0x80) + self.assertEqual(raw[1] & 0x7F, 5) + opcode, payload, consumed = server.ws_decode_frame(raw) + self.assertEqual(opcode, 0x1) + self.assertEqual(payload, b'hello') + self.assertEqual(consumed, len(raw)) + + +class RelayDrainTest(unittest.TestCase): + def test_complete_frame_plus_head_of_next(self): + frame1 = server.ws_encode(b'one') + head2 = server.ws_encode(b'two')[:3] + remaining, frames, closed = server._relay_drain(b'', frame1 + head2) + self.assertEqual(frames, [(0x1, b'one')]) + self.assertFalse(closed) + self.assertEqual(remaining, head2) + + def test_partial_frame_keeps_remainder(self): + head = server.ws_encode(b'hello')[:4] + remaining, frames, closed = server._relay_drain(b'', head) + self.assertEqual(frames, []) + self.assertFalse(closed) + self.assertEqual(remaining, head) + + def test_split_frame_across_chunks(self): + frame = server.ws_encode(b'abcdef') + first, second = frame[:3], frame[3:] + remaining, frames, closed = server._relay_drain(b'', first) + self.assertEqual(frames, []) + self.assertEqual(remaining, first) + remaining, frames, closed = server._relay_drain(remaining, second) + self.assertEqual(frames, [(0x1, b'abcdef')]) + self.assertFalse(closed) + self.assertEqual(remaining, b'') + + def test_binary_frame_collected_with_opcode(self): + frame = server.ws_encode(b'\x00\x01\x02\x03', opcode=0x2) + remaining, frames, closed = server._relay_drain(b'', frame) + self.assertEqual(frames, [(0x2, b'\x00\x01\x02\x03')]) + self.assertFalse(closed) + self.assertEqual(remaining, b'') + + def test_close_frame_reported(self): + close = bytes([0x88, 0x02]) + b'\x03\xe8' + remaining, frames, closed = server._relay_drain(b'', close) + self.assertTrue(closed) + self.assertEqual(frames, []) + self.assertEqual(remaining, b'') + + +class WsPoolTest(unittest.TestCase): + def test_broadcast_skips_dead(self): + class FakeSock: + def __init__(self, fail=False): + self.fail = fail + self.sent = [] + def sendall(self, b): + if self.fail: + raise OSError('closed') + self.sent.append(b) + pool = server.WSPool() + good = FakeSock() + dead = FakeSock(fail=True) + pool.add(good) + pool.add(dead) + pool.broadcast(b'x') + self.assertEqual(len(good.sent), 1) + self.assertNotIn(dead, pool.clients) + + +if __name__ == '__main__': + unittest.main()