151 KiB
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, OpenWRTPineapple Pager 24.10.1(ramips/mt76x8), daemon HTTP API on127.0.0.1:1471, UI on0.0.0.0:8080. - No stock files modified, no
opkgchanges, 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_<serverid>(host172.16.52.1,Path=/,HttpOnly,SameSite=Lax). Only/api/loginis unauthenticated. - All device commands executed via
subprocesswith argument lists (never shell string interpolation). recon.dbopened 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 generatedconfig.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 barepythonmay 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 runsgit 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_<serverid>=<token> |
/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/<id> |
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 ofserver.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 viaROUTER.add('GET', r'/api/status', h_status)and define handler functionsdef h_x(ctx) -> (status, payload). -
_Ctxexposes:.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:
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):
#!/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:
$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
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), handlersh_login(ctx),h_api_ping(ctx). -
Step 1: Write the failing tests
tests/test_auth.py:
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):
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()):
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
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_statusreturns(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:
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:
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:
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
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 constantserver.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:
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:
# 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:
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
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)wherekindis'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:
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:
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:
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
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 <iface> 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:
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:
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:
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
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:
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:
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:
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
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:
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:
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:
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
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":"<pw>"}'thencurl -s http://127.0.0.1:1471/api/payloads/portal/index -H "Authorization: Bearer <token>"to capture the index shape. -
ls /var/log/for a pineapd log (e.g./var/log/pineapd.log); fall back tologread | 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:
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:
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:
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
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 withadd/remove/broadcast),server.live_loop(),server.h_terminal_ws(ctx). -
Step 1: Write the failing tests
tests/test_ws.py:
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:
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:
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):
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():
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
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.cssfromC:\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
$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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>Pager WebUI</title>
<link rel="stylesheet" href="css/app.css">
<link rel="stylesheet" href="js/xterm.css">
</head>
<body>
<section id="login-screen">
<form id="login-form" class="login-card">
<h1>Pager <span>WebUI</span></h1>
<p class="muted">WiFi Pineapple Pager management interface</p>
<label>Device password
<input type="password" id="login-password" autocomplete="current-password" required>
</label>
<button type="submit">Login</button>
</form>
</section>
<div id="app" class="hidden">
<header id="topbar">
<div class="brand">PAGER<span>WEBUI</span></div>
<div id="live-status" class="live">connecting…</div>
<button id="terminal-btn" class="ghost">Terminal</button>
</header>
<div id="layout">
<nav id="rail">
<a href="#/dashboard">Dashboard</a>
<a href="#/pineap">PineAP</a>
<a href="#/recon">Recon</a>
<a href="#/handshakes">Handshakes</a>
<a href="#/payloads">Payloads</a>
<a href="#/logs">Logs</a>
<a href="#/settings">Settings</a>
</nav>
<main id="content"></main>
</div>
<div id="terminal-panel" class="hidden">
<div id="terminal-bar">
<span>Terminal</span>
<button id="terminal-close" class="ghost">×</button>
</div>
<div id="terminal"></div>
</div>
</div>
<div id="toast-container"></div>
<script src="js/config.js"></script>
<script src="js/api.js"></script>
<script src="js/xterm.min.js"></script>
<script src="js/xterm-addon-fit.min.js"></script>
<script src="js/terminal.js"></script>
<script src="js/views.js"></script>
<script src="js/app.js"></script>
</body>
</html>
- Step 3: Create
css/app.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
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), globalh(...)DOM helper,views(populated in Tasks 13-16),Term(Task 17).App.init()boots on DOMContentLoaded. -
Step 1: Create
config.js
window.PAGER_CONFIG = {
apiBase: '',
wsBase: '',
terminalWs: ''
};
- Step 2: Create
api.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
'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
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
viewsobject;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.jswith helpers + dashboard
'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
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
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
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
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
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.jsto exposeapiBase
In app.js, change the return of App to include apiBase: API_BASE:
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
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
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
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
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
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(defaultws://<host>:1471/api/terminal/openWs),App.toast, globalTerminalandFitAddonfrom the xterm bundles. -
Produces: global
Termwithtoggle(). Wired inapp.js(#terminal-btnand#terminal-close). -
Step 1: Create
terminal.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_<serverid> 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://<host>:8080/api/terminal/openWs, then confirm I/O still works through server.py's relay.
- Step 3: Commit
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 thenautiluspattern (spec §4). Installed init path:/etc/init.d/pagerwebui. -
Step 1: Create
payload.sh
#!/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
#!/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:
$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
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.ps1which buildsbuild/pager-webui/payload-<b64>.zip(containinguser/general/pager-webui/**), writesbuild/pager-webui/_hak5_manifest.jsonwithtime/last_hash/zip, uploads to/tmpon the Pager, extracts to/root/payloads/,chmod +xspayload.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)
{
"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
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-<b64>.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)
& ".\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
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
wwwtree; 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
#!/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
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
# 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-<b64>.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_modeskip 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
.\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:
$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 on0.0.0.0:8080; talks to the Hak5 daemon (127.0.0.1:1471),hak5cmd,uci,iwinfo, andrecon.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_<serverid>; 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):
$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:
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.ps1buildspayload-<b64>.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
opkgchanges (uci showdiff of our sections only).