Files
Mark-VIII/docs/superpowers/plans/2026-08-10-pager-webui.md
T
2026-08-11 20:24:24 -07:00

151 KiB
Raw Blame History

Pager WebUI Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build a Mark VII-style web management UI that runs on the WiFi Pineapple Pager at http://172.16.52.1:8080/, packaged as a Payload-Portal-installable payload with core-parity featureset (Dashboard, PineAP, Recon, Handshakes/Loot, Payloads, Logs, Settings, bottom-docked terminal).

Architecture: A single Python 3.11 stdlib file (server.py) serves the static SPA and a JSON API on 0.0.0.0:8080, talking to the Hak5 daemon (127.0.0.1:1471) and device tools (hak5cmd, uci, iwinfo, sqlite3) via subprocess/urllib. The frontend is a vanilla-JS hash-routed SPA. A shell installer (payload.sh + procd init) mirrors the nautilus payload pattern for background/foreground modes.

Tech Stack: Python 3.11 stdlib only (runtime), vanilla JS + xterm.js (bundled, copied from the on-disk nautilus payload), PowerShell build/deploy scripts, OpenWRT init/procd.

Global Constraints

  • Runtime is Python 3.11 stdlib only — no pip installs, no third-party modules in server.py.
  • Target device: WiFi Pineapple Pager 172.16.52.1, OpenWRT Pineapple Pager 24.10.1 (ramips/mt76x8), daemon HTTP API on 127.0.0.1:1471, UI on 0.0.0.0:8080.
  • No stock files modified, no opkg changes, no firmware modification. Only our own payload directory + our init script under /etc/init.d/pagerwebui.
  • All state-changing endpoints require auth. Session cookie AUTH_<serverid> (host 172.16.52.1, Path=/, HttpOnly, SameSite=Lax). Only /api/login is unauthenticated.
  • All device commands executed via subprocess with argument lists (never shell string interpolation).
  • recon.db opened read-only (mode=ro).
  • Frontend is vanilla JS — no build step, no framework, no package.json.
  • Committed SPA API base is same-origin (/api); the local dev loop overrides it via a generated config.js (see Task 20).
  • Local Windows Python for dev/tests: C:\Users\root\AppData\Local\Programs\Python\Python311\python.exe (use full path in scripts; the bare python may still resolve to the Microsoft Store alias until PATH refresh).
  • Workspace: C:\Users\root\Documents\Pineapple\pager-webui (currently NOT a git repo — Task 1 runs git init; skip that step if the user does not want a repo).
  • Payload dir installed to /root/payloads/user/general/pager-webui/ on the device.
  • On-device unknowns from spec §10 (iwinfo/hostapd_cli client source, hak5cmd output shapes, UCI option names, recon.db schema) are resolved during implementation by running the real commands over SSH first, then finalizing parsers. Tasks flag exactly which commands to run.

File Map

pager-webui\
├── payload\user\general\pager-webui\
│   ├── _hak5_manifest.json        # Task 19 (template + generated fields)
│   ├── payload.sh                 # Task 18 (nautilus-pattern installer)
│   ├── pagerwebui.init            # Task 18 (procd init)
│   ├── server.py                  # Tasks 1-10 (backend)
│   └── www\
│       ├── index.html             # Task 11
│       ├── css\app.css            # Task 11
│       ├── js\
│       │   ├── config.js          # Task 12 (defaults: same-origin)
│       │   ├── api.js             # Task 12
│       │   ├── app.js             # Task 12 (router + shell + login)
│       │   ├── views.js           # Tasks 13-16
│       │   ├── terminal.js        # Task 17
│       │   ├── xterm.min.js       # Task 11 (copied from nautilus)
│       │   ├── xterm-addon-fit.min.js  # Task 11 (copied)
│       │   └── xterm.css          # Task 11 (copied)
│       └── assets\                # Task 11 (logo/svg)
├── scripts\
│   ├── deploy.ps1                 # Task 19
│   ├── dev.ps1                    # Task 20
│   └── dev_proxy.py               # Task 20 (stdlib HTTP+API proxy for dev)
├── tests\                         # Tasks 1-10 (Python unittest)
└── README.md                      # Task 20

API Contract (backend ↔ frontend)

Handlers return (status, jsonable) or (status, Download). Shapes used by the frontend:

Route Method Request Response
/api/login POST {username, password} {token, serverid} + Set-Cookie: AUTH_<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 of server.Router), server.PagerHandler, server._Ctx, server.Download, server.device_run(args), server.daemon_call(method, path, body, token), server.WWW_DIR. Later tasks register routes via ROUTER.add('GET', r'/api/status', h_status) and define handler functions def h_x(ctx) -> (status, payload).

  • _Ctx exposes: .body (dict from JSON body), .cookie (str), .query (dict), .args (regex groups tuple), .h (the BaseHTTPRequestHandler). Fake ctx objects in tests mirror this.

  • Step 1: Write the failing tests

tests/test_core.py:

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), handlers h_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_status returns (200, {...}) per the API contract.

  • Step 1: Inspect device command output (record for parser constants)

Over SSH: iwinfo, iwinfo wlan0 info, iwinfo wlan0 assoclist, cat /sys/class/power_supply/*/uevent, df -k /root, cat /proc/uptime, cat /etc/openwrt_release. Note the exact field names so the regexes below match (adjust if the device differs).

  • Step 2: Write the failing tests

tests/test_status.py:

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 constant server.SETTING_MAP.

  • Step 1: Inspect device UCI layout

Over SSH run uci show pineapd and uci show pineapd | head -60. Record which options control: mimic, advertise/probe collection, handshake collection, random MAC, wigle/wpa-secure?, bands. Adjust SETTING_MAP below to the real section/option names (e.g. pineapd.pineapd.mimic). The generic reader falls back to raw passthrough so the frontend still works with unknown names.

  • Step 2: Write the failing tests

tests/test_pineap_settings.py:

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) where kind is 'client' or 'ssid'.

  • Step 1: Inspect hak5cmd output on device

Over SSH run (read-only): hak5cmd PINEAPPLE_SSID_POOL_LIST, hak5cmd PINEAPPLE_DEVICE_FILTER_LIST, hak5cmd PINEAPPLE_NETWORK_FILTER_LIST, hak5cmd PINEAPPLE_DEVICE_FILTER_MODE, hak5cmd PINEAPPLE_SSID_POOL_START, hak5cmd PINEAPPLE_SSID_POOL_COLLECT_START. Record the output text shapes and the argument order for _ADD, _DELETE, _MODE, _START. Adjust the parsers below to match (they tolerate JSON, one-ssid-per-line, and CSV headers).

  • Step 2: Write the failing tests

tests/test_pineap_pool.py:

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>"}' then curl -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 to logread | grep -i pineap.

  • Change the root password with BusyBox passwd, supplying the password twice over stdin. Confirm by logging back in through both SSH and the daemon-backed WebUI.

  • Step 2: Write the failing tests

tests/test_misc.py:

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 with add/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.css from C:\Users\root\Documents\Pineapple\wifipineapplepager\payloads\library\user\remote_access\nautilus\www\

Interfaces:

  • Produces: HTML element IDs consumed by later tasks: #login-screen, #login-form, #login-password, #app, #rail, #content, #live-status, #terminal-btn, #terminal-panel, #terminal, #toast-container.

  • Step 1: Copy xterm bundles

$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), global h(...) 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 views object; h(tag, attrs, ...children) helper; table(columns, rows) helper; fmtTime(ts), fmtDur(secs), badge(on), btn(label, onclk, cls) helpers used by all later views.

  • Step 1: Create views.js with helpers + dashboard

'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.js to expose apiBase

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 (default ws://<host>:1471/api/terminal/openWs), App.toast, global Terminal and FitAddon from the xterm bundles.

  • Produces: global Term with toggle(). Wired in app.js (#terminal-btn and #terminal-close).

  • Step 1: Create terminal.js

'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 the nautilus pattern (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.ps1 which builds build/pager-webui/payload-<b64>.zip (containing user/general/pager-webui/**), writes build/pager-webui/_hak5_manifest.json with time/last_hash/zip, uploads to /tmp on the Pager, extracts to /root/payloads/, chmod +xs payload.sh, and (unless -NoPortalRefresh) refreshes the daemon portal index so it appears in the Virtual Pager portal view.

  • Step 1: Create _hak5_manifest.json (template)

{
  "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 www tree; deployed backend on the Pager at :8080.

  • Produces: the local dev loop described in spec §8, and project documentation including the recovery drill.

  • Step 1: Create scripts/dev_proxy.py

#!/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_mode skip the prompt.

Browse http://172.16.52.1:8080/ and log in with the device password.

Uninstall / recovery

Re-run the payload and confirm "Stop service?" (stops, disables, removes the init script), then delete the payload directory via the portal or: rm -rf /root/payloads/user/general/pager-webui. No stock files are modified. After a firmware upgrade (which wipes the overlay) re-run the payload to re-enable — same caveat as nautilus.

Local dev loop

.\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 on 0.0.0.0:8080; talks to the Hak5 daemon (127.0.0.1:1471), hak5cmd, uci, iwinfo, and recon.db (read-only).
  • www/ — vanilla JS SPA (no build step) + bundled xterm.js.
  • payload.sh + pagerwebui.init — nautilus-style installer / procd service.

Security notes

  • Auth via device password validated against the daemon; HttpOnly session cookie AUTH_<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 110 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 1117 SPA complete against the deployed backend (login, all 7 pages, terminal).
C — Packaging 1820 Portal zip + manifest build, install + run on the Pager, background/foreground, reboot persistence, recovery drill documented in README.

On-device verification (spec §8) is mandatory for these tasks because the device output shapes are resolved there: 3 (iwinfo), 4 (uci), 5 (hak5cmd), 7 (recon.db schema), 9 (portal/log/password), 19 (deploy), and Task 10 step 5 (WS). Do not skip the SSH inspection steps — the parsers are written against documented-on-device formats.

Final gate checklist before declaring done:

  • Full unit suite green on Windows (Task 20 step 5).
  • scripts/deploy.ps1 builds payload-<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 opkg changes (uci show diff of our sections only).