# 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).