Files
Mark-VIII/docs/superpowers/plans/2026-08-11-pineap-page-overhaul.md
T
2026-08-11 20:24:24 -07:00

40 KiB

Mark VII PineAP Page Port Implementation Plan

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

Goal: Replace the broken pager-webui PineAP page with a faithful, fully-functional replica of the Mark VII PineAP view (8 tabs), wired to the Pager daemon's native /api/pineap/* unix-socket API.

Architecture: pager-webui's server.py proxies the Pager daemon's native PineAP REST API (root-only unix socket /tmp/api.sock, raw HTTP/1.1) 1:1 under its authenticated /api/pineap/* namespace, keeping existing custom endpoints (clients/aps/handshakes/kick) and fixing the broken uci-based settings + hak5cmd filter handlers. The vanilla-JS SPA gets a Mark VII-style 8-tab PineAP page and a corrected wifi rail icon.

Tech Stack: Python 3.11 (stdlib only, runs on device python3-light), vanilla JS SPA (no build step), existing daemon_sock_call socket client.

Global Constraints

  • server.py must remain stdlib-only (no urllib/http.server/sqlite3 guarantee on device; sqlite reads fall back to sqlite3 CLI via _db_rows/_db_write).
  • The daemon socket API (/tmp/api.sock) is unauthenticated by design (root-only socket). All pager-webui /api/pineap/* endpoints stay behind pager-webui session auth (already enforced by the server).
  • Daemon socket failure -> HTTP 502 {error: ...}; never raise/500.
  • Commands run with argument lists (no shell interpolation).
  • No Mark VII-only controls with no Pager equivalent (Autostart, Beacon Responses/Intervals, enterprise cert generation) in the UI.
  • Tests: stdlib unittest, each tests/test_*.py run in its own process (module-level monkeypatches do not get restored).

Task 1: Backend — daemon PineAP proxy + fixed filters + enterprise endpoints

Files:

  • Modify: payload/user/general/pager-webui/server.py (replace the block SETTING_MAP at ~line 1354 through h_filter_post end ~line 1545; add proxy helper near daemon_sock_call; add enterprise handlers; update ROUTER.add block ~line 1649)
  • Test: tests/test_pineap_settings.py, tests/test_pineap_pool.py, tests/test_pineap_clients.py, tests/test_pineap_aps.py (rewrite); create tests/test_pineap_proxy.py, tests/test_pineap_enterprise.py

Interfaces:

  • Consumes: existing daemon_sock_call(method, path, body=None, timeout=10) -> (status:int, json|None); _db_rows(db, sql); _db_write(db, sql); current_token(); hak5(*args); normalize_mac.

  • Produces (used by Task 2 frontend):

    • GET /api/pineap/get_config -> daemon get_config passthrough {loghandshake, logpartialhandshake, logpcap, logwigle, logrecon, autossidpool, reconpath, reconname, handshakepath, ...}
    • POST /api/pineap/set_config {...flags} -> daemon set_config passthrough
    • GET /api/pineap/hostapd -> daemon hostapd/get_config {mgmt_ifaces, wpa_ifaces, pineap_disabled, pineape_disabled, pineape_auth_pass}
    • POST /api/pineap/hostapd {pineap_disabled?, pineape_disabled?, pineape_auth_pass?} -> daemon hostapd/set_config
    • POST /api/pineap/enable {enable: bool} -> daemon hostapd/enable_pineap
    • POST /api/pineap/mimic {enable: bool} -> daemon mimic/enable | mimic/disable
    • POST /api/pineap/examine {bssid, seconds?} | {channel} | {reset: true} -> daemon examine/bssid | examine/channel | examine/reset
    • POST /api/pineap/wifi/get_ap / wifi/set_ap -> daemon settings/wifi/get_ap | settings/wifi/set_ap (Evil WPA + Open AP details)
    • POST /api/pineap/ssidpool/advertise {enable} -> daemon ssidpool/enable|ssidpool/disable
    • POST /api/pineap/ssidpool/collect {enable} -> daemon ssidpool/enable_collect|ssidpool/disable_collect
    • POST /api/pineap/interfaces {device, hop?, inject?, bands?, primary?} -> daemon interfaces/set_interface
    • GET /api/pineap/filters/{client|ssid} -> {mode, entries} (mode+active list via daemon macfilter/get_config|ssidfilter/get_config; entries = denied if mode==deny else allowed)
    • POST /api/pineap/filters/{client|ssid} {action: set_mode|add|delete|clear, mode?, value?} -> mode via daemon macfilter/set_mode|ssidfilter/set_config; list mutations via hak5cmd PINEAPPLE_DEVICE_FILTER_*|PINEAPPLE_NETWORK_FILTER_*
    • GET /api/pineap/enterprise/basic, GET /api/pineap/enterprise/challenge -> {rows: [...]} from recon.db hostap_basic / hostap_challenge (via _db_rows)
    • POST /api/pineap/enterprise/clear {table: basic|challenge} -> _db_write delete rows
    • Kept as-is: GET /api/pineap/ssids, POST /api/pineap/ssids, GET /api/pineap/clients, POST /api/pineap/clients/kick, GET /api/pineap/aps, POST /api/pineap/deauth/client, GET/DELETE /api/pineap/handshakes*
  • Step 1: Write the proxy helper + tests (failing)

tests/test_pineap_proxy.py:

import os, sys, unittest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'general', 'pager-webui'))
import server


def ctx(body=None, args=()):
    return type('C', (), {'body': body, 'args': args, 'query': {}})()


class PineapProxyTest(unittest.TestCase):
    def test_proxy_get_passthrough(self):
        server.daemon_sock_call = lambda method, path, body=None, timeout=10: (200, {'loghandshake': False})
        status, payload = server.h_pineap_get_config(ctx())
        self.assertEqual(status, 200)
        self.assertEqual(payload['loghandshake'], False)

    def test_proxy_post_passthrough(self):
        calls = []
        def fake(method, path, body=None, timeout=10):
            calls.append((method, path, body))
            return (200, {'success': True})
        server.daemon_sock_call = fake
        server.h_pineap_enable(ctx({'enable': True}))
        self.assertEqual(calls[0], ('POST', '/api/pineap/hostapd/enable_pineap', {'enable': True}))

    def test_proxy_502_on_socket_failure(self):
        server.daemon_sock_call = lambda method, path, body=None, timeout=10: (0, None)
        status, payload = server.h_pineap_get_config(ctx())
        self.assertEqual(status, 502)

    def test_mimic_routes_enable_and_disable(self):
        calls = []
        def fake(method, path, body=None, timeout=10):
            calls.append(path)
            return (200, {'success': True})
        server.daemon_sock_call = fake
        server.h_pineap_mimic(ctx({'enable': True}))
        server.h_pineap_mimic(ctx({'enable': False}))
        self.assertEqual(calls, ['/api/pineap/mimic/enable', '/api/pineap/mimic/disable'])

    def test_examine_reset(self):
        calls = []
        def fake(method, path, body=None, timeout=10):
            calls.append((path, body))
            return (200, {'success': True})
        server.daemon_sock_call = fake
        server.h_pineap_examine(ctx({'reset': True}))
        self.assertEqual(calls[0], ('/api/pineap/examine/reset', {'reset': True}))


if __name__ == '__main__':
    unittest.main()
  • Step 2: Run test, verify fail
$py -m unittest tests.test_pineap_proxy -v

Expected: FAIL (AttributeError: module 'server' has no attribute 'h_pineap_get_config')

  • Step 3: Implement the proxy in server.py

Replace the entire broken block starting at SETTING_MAP = { through h_filter_post (ends right before def _proxy_json), keeping hak5, _json_or, _parse_pool_list:

def _daemon_proxy(method, subpath, body=None):
    status, data = daemon_sock_call(method, '/api/pineap/%s' % subpath, body=body)
    if status != 200:
        return (502 if status == 0 else status), {'error': 'daemon failed', 'detail': data}
    return 200, (data if isinstance(data, dict) else {'ok': data is not None})


def h_pineap_get_config(ctx):
    return _daemon_proxy('GET', 'get_config')


def h_pineap_set_config(ctx):
    return _daemon_proxy('POST', 'set_config', ctx.body or {})


def h_pineap_hostapd_get(ctx):
    return _daemon_proxy('GET', 'hostapd/get_config')


def h_pineap_hostapd_set(ctx):
    body = ctx.body or {}
    keep = {}
    for key in ('pineap_disabled', 'pineape_disabled', 'pineape_auth_pass', 'mgmt_ifaces', 'wpa_ifaces'):
        if key in body:
            keep[key] = body[key]
    return _daemon_proxy('POST', 'hostapd/set_config', keep)


def h_pineap_enable(ctx):
    return _daemon_proxy('POST', 'hostapd/enable_pineap', {'enable': bool((ctx.body or {}).get('enable'))})


def h_pineap_mimic(ctx):
    enable = bool((ctx.body or {}).get('enable'))
    return _daemon_proxy('POST', 'mimic/enable' if enable else 'mimic/disable')


def h_pineap_examine(ctx):
    body = ctx.body or {}
    if body.get('reset'):
        return _daemon_proxy('POST', 'examine/reset', {'reset': True})
    if body.get('bssid'):
        req = {'bssid': body['bssid']}
        if body.get('seconds') is not None:
            req['seconds'] = int(body['seconds'])
        return _daemon_proxy('POST', 'examine/bssid', req)
    if body.get('channel') is not None:
        return _daemon_proxy('POST', 'examine/channel', {'channel': str(int(body['channel']))})
    return 400, {'error': 'examine requires bssid, channel or reset'}


def h_pineap_wifi_get_ap(ctx):
    status, data = daemon_sock_call('POST', '/api/settings/wifi/get_ap', body={})
    if status != 200:
        return (502 if status == 0 else status), {'error': 'daemon failed', 'detail': data}
    return 200, (data if isinstance(data, dict) else {'ok': True})


def h_pineap_wifi_set_ap(ctx):
    status, data = daemon_sock_call('POST', '/api/settings/wifi/set_ap', body=ctx.body or {})
    if status != 200:
        return (502 if status == 0 else status), {'error': 'daemon failed', 'detail': data}
    return 200, (data if isinstance(data, dict) else {'ok': True})


def h_pineap_advertise(ctx):
    enable = bool((ctx.body or {}).get('enable'))
    return _daemon_proxy('POST', 'ssidpool/enable' if enable else 'ssidpool/disable')


def h_pineap_collect(ctx):
    enable = bool((ctx.body or {}).get('enable'))
    return _daemon_proxy('POST', 'ssidpool/enable_collect' if enable else 'ssidpool/disable_collect')


def h_pineap_interfaces(ctx):
    return _daemon_proxy('POST', 'interfaces/set_interface', ctx.body or {})


# --- Filters ---

FILTER_DAEMON = {
    'client': ('macfilter/get_config', 'macfilter/set_mode', 'PINEAPPLE_DEVICE_FILTER'),
    'ssid': ('ssidfilter/get_config', 'ssidfilter/set_config', 'PINEAPPLE_NETWORK_FILTER'),
}


def h_filter_get(ctx, kind):
    get_path, set_path, hak5_prefix = FILTER_DAEMON[kind]
    status, data = daemon_sock_call('GET', '/api/pineap/%s' % get_path)
    if status != 200 or not isinstance(data, dict):
        return (502 if status == 0 else status), {'error': 'daemon failed', 'detail': data}
    mode = data.get('mode') or 'allow'
    if kind == 'client':
        entries = data.get('denied_macs') if mode == 'deny' else data.get('allowed_macs')
    else:
        entries = data.get('denied_ssids') if mode == 'deny' else data.get('allowed_ssids')
    return 200, {'mode': mode, 'entries': [str(e) for e in (entries or [])]}


def h_filter_post(ctx, kind):
    body = ctx.body or {}
    action = body.get('action')
    _, set_path, prefix = FILTER_DAEMON[kind]
    if action == 'set_mode':
        mode = (body.get('mode') or '').strip()
        if mode not in ('allow', 'deny'):
            return 400, {'error': 'mode must be allow or deny'}
        status, data = daemon_sock_call('POST', '/api/pineap/%s' % set_path, body={'mode': mode})
        if status != 200:
            return (502 if status == 0 else status), {'error': 'daemon failed', 'detail': data}
    elif action == 'add':
        value = (body.get('value') or '').strip()
        if not value:
            return 400, {'error': 'value required'}
        hak5('%s_ADD' % prefix, value)
    elif action == 'delete':
        value = (body.get('value') or '').strip()
        if not value:
            return 400, {'error': 'value required'}
        hak5('%s_DELETE' % prefix, value)
    elif action == 'clear':
        hak5('%s_CLEAR' % prefix)
    else:
        return 400, {'error': 'unknown action'}
    return h_filter_get(ctx, kind)
  • Step 4: Run test, verify pass
$py -m unittest tests.test_pineap_proxy -v

Expected: PASS

  • Step 5: Enterprise endpoints + tests

tests/test_pineap_enterprise.py:

import os, sys, unittest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'general', 'pager-webui'))
import server


class EnterpriseTest(unittest.TestCase):
    def test_basic_rows(self):
        server._db_rows = lambda db, sql: [{'time': 1, 'username': 'a', 'password': 'b'}]
        status, payload = server.h_enterprise_data(type('C', (), {'args': ('basic',)})())
        self.assertEqual(status, 200)
        self.assertEqual(payload['rows'][0]['username'], 'a')

    def test_clear(self):
        calls = []
        server._db_write = lambda db, sql: calls.append(sql)
        server.h_enterprise_clear(type('C', (), {'body': {'table': 'challenge'}})())
        self.assertTrue(any('hostap_challenge' in s for s in calls))


if __name__ == '__main__':
    unittest.main()

Implement in server.py near _db_write/handshakes helpers:

ENTERPRISE_TABLES = {'basic': 'hostap_basic', 'challenge': 'hostap_challenge'}


def _enterprise_cols(table):
    rows = _db_rows(RECON_DB, 'PRAGMA table_info(%s)' % table)
    return [r.get('name') for r in rows]


def h_enterprise_data(ctx):
    table = ENTERPRISE_TABLES.get((ctx.args or [''])[0])
    if not table:
        return 400, {'error': 'unknown table'}
    rows = _db_rows(RECON_DB, 'SELECT * FROM %s ORDER BY time' % table)
    return 200, {'table': table, 'rows': rows or []}


def h_enterprise_clear(ctx):
    table = ENTERPRISE_TABLES.get((ctx.body or {}).get('table', ''))
    if not table:
        return 400, {'error': 'unknown table'}
    try:
        _db_write(RECON_DB, 'DELETE FROM %s' % table)
    except RuntimeError as e:
        return 502, {'error': str(e)}
    return 200, {'ok': True}
  • Step 6: Run enterprise test, verify pass
$py -m unittest tests.test_pineap_enterprise -v

Expected: PASS

  • Step 7: Rewrite obsolete tests

tests/test_pineap_settings.py -> drop the UciHelpersTest/PineapSettingsTest uci tests (uci helpers stay for NTP/hostname but settings no longer uses them). Replace with a GetConfigProxyTest asserting h_pineap_get_config proxies and that set_config forwards a whitelisted body.

tests/test_pineap_pool.py -> keep Hak5Test/PoolParsingTest/SsidPoolHandlersTest (those endpoints remain). Add a FilterProxyReadTest for h_filter_get reading daemon config.

  • Step 8: Wire the ROUTER table

Replace these lines in the ROUTER.add block:

ROUTER.add('GET', r'/api/pineap/get_config', h_pineap_get_config)
ROUTER.add('POST', r'/api/pineap/set_config', h_pineap_set_config)
ROUTER.add('GET', r'/api/pineap/hostapd', h_pineap_hostapd_get)
ROUTER.add('POST', r'/api/pineap/hostapd', h_pineap_hostapd_set)
ROUTER.add('POST', r'/api/pineap/enable', h_pineap_enable)
ROUTER.add('POST', r'/api/pineap/mimic', h_pineap_mimic)
ROUTER.add('POST', r'/api/pineap/examine', h_pineap_examine)
ROUTER.add('POST', r'/api/pineap/wifi/get_ap', h_pineap_wifi_get_ap)
ROUTER.add('POST', r'/api/pineap/wifi/set_ap', h_pineap_wifi_set_ap)
ROUTER.add('POST', r'/api/pineap/ssidpool/advertise', h_pineap_advertise)
ROUTER.add('POST', r'/api/pineap/ssidpool/collect', h_pineap_collect)
ROUTER.add('POST', r'/api/pineap/interfaces', h_pineap_interfaces)
ROUTER.add('GET', r'/api/pineap/filters/client', lambda ctx: h_filter_get(ctx, 'client'))
ROUTER.add('POST', r'/api/pineap/filters/client', lambda ctx: h_filter_post(ctx, 'client'))
ROUTER.add('GET', r'/api/pineap/filters/ssid', lambda ctx: h_filter_get(ctx, 'ssid'))
ROUTER.add('POST', r'/api/pineap/filters/ssid', lambda ctx: h_filter_post(ctx, 'ssid'))
ROUTER.add('GET', r'/api/pineap/enterprise/(basic|challenge)', h_enterprise_data)
ROUTER.add('POST', r'/api/pineap/enterprise/clear', h_enterprise_clear)

Remove the old routes for settings, ssidpool/(start|stop|collect_start|collect_stop) and filters if duplicated. Keep ssids, clients, aps, deauth/client, handshakes* routes as-is. Remove the SSIDPOOL_ACTIONS/h_ssidpool_action and SETTING_MAP/_uci_map/uci_show-for-pineapd usage that the removed block owned (keep uci_show/uci_set/uci_delete/uci_add_list — NTP/hostname still use them).

  • Step 9: Run full test loop, commit
Get-ChildItem tests\test_*.py | ForEach-Object { & $py -m unittest "tests.$([IO.Path]::GetFileNameWithoutExtension($_.Name))" -v }

Expected: all pass. Commit:

git add payload/user/general/pager-webui/server.py tests/
git commit -m "feat: proxy native daemon PineAP API; fix filters; add enterprise endpoints"

Task 2: Frontend — 8-tab Mark VII PineAP page

Files:

  • Modify: payload/user/general/pager-webui/www/js/app.js (rail icon; routes map)
  • Modify: payload/user/general/pager-webui/www/js/views.js (replace PINEAP_TABS/pineapShell/views.pineap* block, lines ~163-360; update recon /api/pineap/settings references at ~495, 601, 609, 877)
  • Modify: payload/user/general/pager-webui/www/css/app.css (pineap cards/toggles layout)
  • Test: manual browser walk (no JS test harness)

Interfaces:

  • Consumes: backend routes from Task 1; existing h/table/btn/iconBtn/tabBar helpers; PagerAPI.get/post; App.toast; fmtTime.

  • Produces: views.pineap (overview), pineap_open, pineap_evilwpa, pineap_enterprise, pineap_impersonation, pineap_clients, pineap_filtering, pineap_aps; routes #/pineap, #/pineap/open, #/pineap/evilwpa, #/pineap/enterprise, #/pineap/impersonation, #/pineap/clients, #/pineap/filtering, #/pineap/aps.

  • Step 1: Rail icon + routes

In app.js: change rail item { key: 'pineap', label: 'PineAP', hash: '#/pineap', icon: 'pineap' } to icon: 'wifi'. Extend routes:

'#/pineap': 'pineap',
'#/pineap/open': 'pineap_open',
'#/pineap/evilwpa': 'pineap_evilwpa',
'#/pineap/enterprise': 'pineap_enterprise',
'#/pineap/impersonation': 'pineap_impersonation',
'#/pineap/clients': 'pineap_clients',
'#/pineap/filtering': 'pineap_filtering',
'#/pineap/aps': 'pineap_aps',
  • Step 2: Tab shell + overview

In views.js replace PINEAP_TABS/pineapShell/views.pineap/views.pineap_open/views.pineap_clients/views.pineap_filtering/views.pineap_aps/views.pineap_impersonation with the new implementation (full code in the patch below). The overview derives mode from get_config+hostapd+wifi/get_ap:

const PINEAP_TABS = [
  { label: 'PineAP', hash: '#/pineap' },
  { label: 'Open AP', hash: '#/pineap/open' },
  { label: 'Evil WPA', hash: '#/pineap/evilwpa' },
  { label: 'Enterprise', hash: '#/pineap/enterprise' },
  { label: 'Impersonation', hash: '#/pineap/impersonation' },
  { label: 'Clients', hash: '#/pineap/clients' },
  { label: 'Filtering', hash: '#/pineap/filtering' },
  { label: 'APs', hash: '#/pineap/aps' }
];

function pineapShell(root, activeHash, inner) {
  root.appendChild(h('h1', { class: 'page-title', text: 'PineAP' }));
  tabBar(root, PINEAP_TABS, activeHash);
  const box = h('div', {});
  root.appendChild(box);
  return inner(box);
}

views.pineap = (root) => {
  tabBar(root, PINEAP_TABS, '#/pineap');
  const box = h('div', {});
  root.appendChild(box);

  const mode = h('span', { class: 'badge', text: '—' });
  const intro = h('p', { class: 'muted' });
  const quick = {
    collect: h('input', { type: 'checkbox', id: 'po-collect' }),
    advertise: h('input', { type: 'checkbox', id: 'po-advertise' })
  };
  const cards = { karma: {}, open: {}, wpa: {}, ent: {} };
  const cardWrap = h('div', { class: 'cards' });
  Object.keys(cards).forEach((k) => {
    const card = h('div', { class: 'card' },
      h('div', { class: 'card-label', text: '' }),
      h('div', { class: 'card-value' }),
      h('div', { class: 'row' }, btn('Configure', () => App.go({
        karma: '#/pineap/open', open: '#/pineap/open',
        wpa: '#/pineap/evilwpa', ent: '#/pineap/enterprise'
      }[k]), 'ghost')));
    cardWrap.appendChild(card);
    cards[k].wrap = card;
    cards[k].label = card.querySelector('.card-label');
    cards[k].value = card.querySelector('.card-value');
  });

  const head = h('div', { class: 'section' }, h('h2', {}, 'PineAP'), mode, intro);
  box.appendChild(head);
  const quickBox = h('div', { class: 'section' }, h('h2', {}, 'Quick Settings'));
  quickBox.appendChild(h('label', { class: 'toggle' }, quick.collect, ' Capture SSIDs to Pool'));
  quickBox.appendChild(h('label', { class: 'toggle' }, quick.advertise, ' Advertise AP Impersonation Pool'));
  quickBox.appendChild(h('div', { class: 'muted', style: 'margin-top:8px' },
    'Client connect/disconnect notifications are handled by the Pager alert payload system.'));
  box.appendChild(quickBox);
  box.appendChild(cardWrap);

  function bind(cb, on) {
    cb.addEventListener('change', () => on(cb.checked).then(load).catch(() => { cb.checked = !cb.checked; App.toast('Failed', 'error'); }));
  }
  bind(quick.collect, (v) => PagerAPI.post('/api/pineap/ssidpool/collect', { enable: v }));
  bind(quick.advertise, (v) => PagerAPI.post('/api/pineap/ssidpool/advertise', { enable: v }));

  function load() {
    Promise.all([
      PagerAPI.get('/api/pineap/get_config').catch(() => ({ data: {} })),
      PagerAPI.get('/api/pineap/hostapd').catch(() => ({ data: {} })),
      PagerAPI.post('/api/pineap/wifi/get_ap').catch(() => ({ data: {} }))
    ]).then(([cfg, host, ap]) => {
      const c = cfg.data || {}, hh = host.data || {}, a = ap.data || {};
      const disabled = !!hh.pineap_disabled;
      const active = !disabled;
      const advanced = active && (!!hh.pineape_disabled === false || (a.wpa && a.wpa.enabled) || (a.enterprise && a.enterprise.enabled));
      mode.textContent = disabled ? 'Passive' : (advanced ? 'Advanced' : 'Active');
      mode.className = 'badge ' + (disabled ? 'off' : 'on');
      intro.textContent = disabled
        ? 'PineAP is disabled. Enable it from the Open AP tab to begin impersonating networks.'
        : 'The WiFi Pineapple will respond to probe requests and impersonate the Open, Evil WPA, and Evil Enterprise access points.';
      quick.collect.checked = !!c.autossidpool;
      quick.advertise.checked = !!a.pool ? !a.pool.disabled : false;
      setCard(cards.karma, 'Karma', null);
      setCard(cards.open, 'Open Network', a.open ? (a.open.enabled ? 'On' : 'Off') : '—');
      setCard(cards.wpa, 'Evil WPA', a.wpa ? (a.wpa.enabled ? 'On' : 'Off') : '—');
      setCard(cards.ent, 'Evil Enterprise', a.enterprise ? (a.enterprise.enabled ? 'On' : 'Off') : '—');
    });
  }
  function setCard(card, label, value) {
    card.label.textContent = label;
    card.value.textContent = value == null ? '—' : value;
  }
  load();
  const iv = setInterval(load, 5000);
  return { destroy: () => clearInterval(iv) };
};
  • Step 3: Open AP view

views.pineap_open — master Enable PineAP toggle (POST /api/pineap/enable), Karma (POST /api/pineap/mimic), Logging group (loghandshake, logpartialhandshake, logpcap, logwigle, logrecon via set_config), Capture SSIDs to Pool, Advertise AP Impersonation Pool, and read-only PineAP MAC/Target MAC pulled from /api/pineap/wifi/get_ap (a.open.bssid, a.open.target) when present. Full code:

views.pineap_open = (root) => {
  const state = { cfg: {}, host: {}, ap: {} };
  const box = h('div', { class: 'section' }, h('h2', {}, 'Open AP'));
  root.appendChild(box);
  const toggles = {};
  const defs = [
    ['pineap_disabled', 'Enable PineAP', (v) => PagerAPI.post('/api/pineap/enable', { enable: v })],
    ['karma', 'Karma', (v) => PagerAPI.post('/api/pineap/mimic', { enable: v })],
    ['loghandshake', 'Log Handshakes', (v) => saveCfg({ loghandshake: v })],
    ['logpartialhandshake', 'Log Partial Handshakes', (v) => saveCfg({ logpartialhandshake: v })],
    ['logpcap', 'Log PCAP', (v) => saveCfg({ logpcap: v })],
    ['logwigle', 'Log WiGLE', (v) => saveCfg({ logwigle: v })],
    ['logrecon', 'Log Recon', (v) => saveCfg({ logrecon: v })],
    ['autossidpool', 'Capture SSIDs to Pool', (v) => saveCfg({ autossidpool: v })],
    ['advertise', 'Advertise AP Impersonation Pool', (v) => PagerAPI.post('/api/pineap/ssidpool/advertise', { enable: v })]
  ];
  defs.forEach(([k, label, fn]) => {
    const cb = h('input', { type: 'checkbox', id: 'oap-' + k });
    toggles[k] = { cb, fn };
    cb.addEventListener('change', () => fn(cb.checked).then(load).catch(() => { cb.checked = !cb.checked; App.toast('Failed', 'error'); }));
    box.appendChild(h('label', { class: 'toggle' }, cb, ' ' + label));
  });
  const info = h('div', { class: 'muted', style: 'margin-top:10px' });
  box.appendChild(info);
  function saveCfg(body) {
    return PagerAPI.post('/api/pineap/set_config', body);
  }
  function load() {
    Promise.all([
      PagerAPI.get('/api/pineap/get_config').catch(() => ({ data: {} })),
      PagerAPI.get('/api/pineap/hostapd').catch(() => ({ data: {} })),
      PagerAPI.post('/api/pineap/wifi/get_ap').catch(() => ({ data: {} }))
    ]).then(([cfg, host, ap]) => {
      state.cfg = cfg.data || {}; state.host = host.data || {}; state.ap = ap.data || {};
      toggles.pineap_disabled.cb.checked = !state.host.pineap_disabled;
      toggles.karma.cb.checked = !!state.cfg.mimic;
      ['loghandshake', 'logpartialhandshake', 'logpcap', 'logwigle', 'logrecon', 'autossidpool']
        .forEach((k) => { toggles[k].cb.checked = !!state.cfg[k]; });
      toggles.advertise.cb.checked = !!(state.ap.pool && !state.ap.pool.disabled);
      const o = state.ap.open || {};
      info.textContent = 'PineAP MAC: ' + (o.bssid || '—') + '   Target MAC: ' + (o.target || '—');
    });
  }
  load();
  return { destroy: () => {} };
};
  • Step 4: Evil WPA view

views.pineap_evilwpa — SSID, passphrase, encryption select (WPA2 PSK / WPA3 SAE / WPA3 OAE), Hidden toggle, Enabled toggle; save via POST /api/pineap/wifi/set_ap with { wpa: { ssid, passphrase, enctype, hidden, enabled } }. Handshake capture card (Examine BSSID + seconds, Start/Stop via POST /api/pineap/examine) and the captured handshakes table (GET /api/pineap/handshakes). Full code:

const EVIL_ENC = [
  ['psk2+ccmp', 'WPA2 PSK'], ['psk2+tkip', 'WPA2 PSK (TKIP)'],
  ['sae', 'WPA3 SAE'], ['sae+transition', 'WPA3 SAE (Transition)'],
  ['owe', 'WPA3 OWE'], ['owe+transition', 'WPA3 OWE (Transition)']
];

views.pineap_evilwpa = (root) => {
  const box = h('div', { class: 'section' }, h('h2', {}, 'Evil WPA'));
  root.appendChild(box);
  const ssidIn = h('input', { id: 'ew-ssid' });
  const pskIn = h('input', { id: 'ew-psk' });
  const encSel = h('select', { id: 'ew-enc' });
  EVIL_ENC.forEach(([v, l]) => encSel.appendChild(h('option', { value: v, text: l })));
  const hiddenCb = h('input', { type: 'checkbox', id: 'ew-hidden' });
  const enabledCb = h('input', { type: 'checkbox', id: 'ew-enabled' });
  box.appendChild(h('label', {}, 'SSID', ssidIn));
  box.appendChild(h('label', {}, 'Passphrase', pskIn));
  box.appendChild(h('label', {}, 'Encryption', encSel));
  box.appendChild(h('label', { class: 'toggle' }, hiddenCb, ' Hidden'));
  box.appendChild(h('label', { class: 'toggle' }, enabledCb, ' Enabled'));
  box.appendChild(h('div', { class: 'row' },
    h('div', {}, btn('Save', () => {
      PagerAPI.post('/api/pineap/wifi/set_ap', {
        wpa: { ssid: ssidIn.value, passphrase: pskIn.value, enctype: encSel.value,
               hidden: hiddenCb.checked, enabled: enabledCb.checked }
      }).then(() => { App.toast('Evil WPA saved'); load(); }).catch(() => App.toast('Failed', 'error'));
    }))));

  const capBox = h('div', { class: 'section' }, h('h2', {}, 'Handshake Capture'));
  root.appendChild(capBox);
  const bssidIn = h('input', { id: 'ew-bssid', placeholder: 'BSSID' });
  const secsIn = h('input', { id: 'ew-secs', type: 'number', value: '30', style: 'max-width:80px' });
  capBox.appendChild(h('div', { class: 'row' },
    h('div', {}, h('label', {}, 'BSSID', bssidIn)),
    h('div', {}, h('label', {}, 'Seconds', secsIn)),
    h('div', {}, btn('Examine', () => {
      const b = bssidIn.value.trim(); if (!b) { App.toast('BSSID required', 'error'); return; }
      PagerAPI.post('/api/pineap/examine', { bssid: b, seconds: parseInt(secsIn.value, 10) || 30 })
        .then(() => App.toast('Examining ' + b)).catch(() => App.toast('Failed', 'error'));
    })),
    h('div', {}, btn('Stop', () => PagerAPI.post('/api/pineap/examine', { reset: true }).then(() => App.toast('Stopped')), 'danger'))));
  const hsBox = h('div', { class: 'section' }, h('h2', {}, 'Captured Handshakes'));
  root.appendChild(hsBox);

  function load() {
    PagerAPI.post('/api/pineap/wifi/get_ap').then((r) => {
      const w = (r.data || {}).wpa || {};
      ssidIn.value = w.ssid || '';
      pskIn.value = w.passphrase || '';
      if (w.enctype) encSel.value = w.enctype;
      hiddenCb.checked = !!w.hidden;
      enabledCb.checked = !!w.enabled;
    }).catch(() => {});
    PagerAPI.get('/api/pineap/handshakes').then((r) => {
      hsBox.innerHTML = '';
      hsBox.appendChild(h('h2', {}, 'Captured Handshakes'));
      const rows = (r.data.handshakes || []).map((x) => ({
        name: x.name || '--', ap: x.ap || '--', client: x.client || '--', type: x.type || '--'
      }));
      hsBox.appendChild(table(
        [{ label: 'File', key: 'name' }, { label: 'AP', key: 'ap' },
         { label: 'Client', key: 'client' }, { label: 'Type', key: 'type' }],
        rows));
      if (!rows.length) hsBox.appendChild(h('div', { class: 'empty', text: 'No handshakes captured yet.' }));
    }).catch(() => {});
  }
  load();
  const iv = setInterval(load, 5000);
  return { destroy: () => clearInterval(iv) };
};
  • Step 5: Enterprise view

views.pineap_enterpriseEnabled (POST /api/pineap/hostapd with pineape_disabled: !v) and Auth Pass Capture (pineape_auth_pass) toggles, then two tables from /api/pineap/enterprise/basic and /challenge with Clear buttons (POST /api/pineap/enterprise/clear):

views.pineap_enterprise = (root) => {
  const box = h('div', { class: 'section' }, h('h2', {}, 'Evil Enterprise'));
  root.appendChild(box);
  const enabledCb = h('input', { type: 'checkbox', id: 'ee-enabled' });
  const authCb = h('input', { type: 'checkbox', id: 'ee-auth' });
  box.appendChild(h('label', { class: 'toggle' }, enabledCb, ' Enabled'));
  box.appendChild(h('label', { class: 'toggle' }, authCb, ' Auth Pass Capture'));
  enabledCb.addEventListener('change', () => PagerAPI.post('/api/pineap/hostapd', { pineape_disabled: !enabledCb.checked }).then(load).catch(() => { enabledCb.checked = !enabledCb.checked; }));
  authCb.addEventListener('change', () => PagerAPI.post('/api/pineap/hostapd', { pineape_auth_pass: authCb.checked }).then(load).catch(() => { authCb.checked = !authCb.checked; }));

  function tableBox(name, endpoint, clearTable) {
    const tb = h('div', { class: 'section' }, h('h2', {}, name),
      btn('Clear', () => PagerAPI.post('/api/pineap/enterprise/clear', { table: clearTable }).then(load), 'danger'));
    root.appendChild(tb);
    const body = h('div', {});
    tb.appendChild(body);
    return { tb, body, endpoint };
  }
  const basic = tableBox('Basic Data', '/api/pineap/enterprise/basic', 'basic');
  const chall = tableBox('Challenge Data', '/api/pineap/enterprise/challenge', 'challenge');

  function load() {
    PagerAPI.get('/api/pineap/hostapd').then((r) => {
      const hh = r.data || {};
      enabledCb.checked = !hh.pineape_disabled;
      authCb.checked = !!hh.pineape_auth_pass;
    }).catch(() => {});
    [[basic], [chall]].forEach(([t]) => {
      PagerAPI.get(t.endpoint).then((r) => {
        const rows = (r.data.rows || []).slice();
        t.body.innerHTML = '';
        const cols = rows.length ? Object.keys(rows[0]).map((k) => ({ label: k, key: k }))
          : [{ label: '—', key: '_none' }];
        t.body.appendChild(table(cols, rows));
        if (!rows.length) t.body.appendChild(h('div', { class: 'empty', text: 'No data captured.' }));
      }).catch(() => {});
    });
  }
  load();
  const iv = setInterval(load, 5000);
  return { destroy: () => clearInterval(iv) };
};
  • Step 6: Impersonation view

views.pineap_impersonation — SSID pool: list via GET /api/pineap/ssids, add/remove/clear via POST /api/pineap/ssids, advertise + collect via the pool routes:

views.pineap_impersonation = (root) => {
  const box = h('div', { class: 'section' }, h('h2', {}, 'SSID Pool'));
  root.appendChild(box);
  const input = h('input', { id: 'imp-ssid' });
  const list = h('div', {});
  box.appendChild(h('div', { class: 'row' },
    h('div', {}, h('label', {}, 'SSID', input)),
    h('div', {}, btn('Add', () => {
      const v = input.value.trim(); if (!v) return;
      PagerAPI.post('/api/pineap/ssids', { action: 'add', ssid: v }).then((r) => { input.value = ''; render(r.data.ssids); });
    })),
    h('div', {}, btn('Clear', () => PagerAPI.post('/api/pineap/ssids', { action: 'clear' }).then((r) => render(r.data.ssids)), 'danger'))));
  const advCb = h('input', { type: 'checkbox', id: 'imp-advertise' });
  const colCb = h('input', { type: 'checkbox', id: 'imp-collect' });
  box.appendChild(h('label', { class: 'toggle' }, advCb, ' Advertise AP Impersonation Pool'));
  box.appendChild(h('label', { class: 'toggle' }, colCb, ' Capture SSIDs to Pool'));
  advCb.addEventListener('change', () => PagerAPI.post('/api/pineap/ssidpool/advertise', { enable: advCb.checked }).then(load).catch(() => { advCb.checked = !advCb.checked; }));
  colCb.addEventListener('change', () => PagerAPI.post('/api/pineap/ssidpool/collect', { enable: colCb.checked }).then(load).catch(() => { colCb.checked = !colCb.checked; }));
  box.appendChild(list);

  function render(ssids) {
    list.innerHTML = '';
    list.appendChild(table(
      [{ label: 'SSID', key: 'ssid' }, { label: '', render: () => '' }],
      (ssids || []).map((s) => ({ ssid: s })),
      (r) => ({ onclick: () => { if (confirm('Remove ' + r.ssid + '?')) PagerAPI.post('/api/pineap/ssids', { action: 'remove', ssid: r.ssid }).then((x) => render(x.data.ssids)); } })));
    list.querySelectorAll('.tbl th').forEach((th, i) => { if (i === 1) th.textContent = 'Remove'; });
    if (!ssids || !ssids.length) list.appendChild(h('div', { class: 'empty', text: 'No SSIDs in pool.' }));
  }
  function load() {
    PagerAPI.get('/api/pineap/ssids').then((r) => render(r.data.ssids)).catch(() => {});
    PagerAPI.post('/api/pineap/wifi/get_ap').then((r) => {
      const p = (r.data || {}).pool || {};
      advCb.checked = !p.disabled;
      colCb.checked = !!p.collecting;
    }).catch(() => {});
  }
  load();
  return { destroy: () => {} };
};
  • Step 7: Clients, Filtering, APs views

views.pineap_clients — keep the existing connected-clients + Kick table (it already works), just re-parented into the shell.

views.pineap_filtering — rewrite to call the fixed backend (GET/POST /api/pineap/filters/{client|ssid}), two cards each with mode select (allow/deny) + add/delete/clear list:

views.pineap_filtering = (root) => {
  const cfBox = h('div', { class: 'section' }, h('h2', {}, 'Client Filter'));
  const sfBox = h('div', { class: 'section' }, h('h2', {}, 'SSID Filter'));
  root.appendChild(cfBox); root.appendChild(sfBox);
  function renderFilter(box, kind) {
    box.innerHTML = '';
    box.appendChild(h('h2', {}, kind === 'client' ? 'Client Filter' : 'SSID Filter'));
    const path = '/api/pineap/filters/' + kind;
    const modeSel = h('select', { id: 'fm-' + kind },
      h('option', { value: 'allow', text: 'Allow list' }),
      h('option', { value: 'deny', text: 'Deny list' }));
    const valueIn = h('input', { id: 'fv-' + kind });
    box.appendChild(h('div', { class: 'row' },
      h('div', {}, h('label', {}, 'Mode', modeSel)),
      h('div', {}, h('label', {}, 'Value', valueIn)),
      h('div', {}, btn('Add', () => {
        const v = document.getElementById('fv-' + kind).value.trim(); if (!v) return;
        PagerAPI.post(path, { action: 'add', value: v }).then(() => refresh()).catch(() => App.toast('Failed', 'error'));
      })),
      h('div', {}, btn('Clear', () => PagerAPI.post(path, { action: 'clear' }).then(refresh), 'danger'))));
    modeSel.addEventListener('change', () => PagerAPI.post(path, { action: 'set_mode', mode: modeSel.value }).then(refresh));
    const list = h('div', {});
    box.appendChild(list);
    PagerAPI.get(path).then((r) => {
      modeSel.value = r.data.mode;
      list.innerHTML = '';
      list.appendChild(table(
        [{ label: kind === 'client' ? 'MAC' : 'SSID', key: 'value' }, { label: '', render: () => '' }],
        (r.data.entries || []).map((e) => ({ value: e })),
        (row) => ({ onclick: () => { if (confirm('Delete ' + row.value + '?')) PagerAPI.post(path, { action: 'delete', value: row.value }).then(refresh); } })));
      list.querySelectorAll('.tbl th').forEach((th, i) => { if (i === 1) th.textContent = 'Delete'; });
      if (!r.data.entries || !r.data.entries.length) list.appendChild(h('div', { class: 'empty', text: 'No entries.' }));
    }).catch(() => {});
  }
  function refresh() { renderFilter(cfBox, 'client'); renderFilter(sfBox, 'ssid'); }
  refresh();
  return { destroy: () => {} };
};

views.pineap_aps — keep the existing AP scan table view.

  • Step 8: Update recon settings references

In views.js recon view (and recon focus sidebar):

  • Line ~495: PagerAPI.post('/api/pineap/settings', { collect_handshakes: hsAuto...checked }) -> PagerAPI.post('/api/pineap/set_config', { loghandshake: hsAuto.querySelector('input').checked })
  • Line ~601/609: PagerAPI.post('/api/pineap/settings', { collect_handshakes: true/false }) -> PagerAPI.post('/api/pineap/set_config', { loghandshake: true/false })
  • Line ~877: PagerAPI.get('/api/pineap/settings') -> PagerAPI.get('/api/pineap/get_config'), and read collect_handshakes as loghandshake.

Also update the hsAuto checkbox initializer accordingly.

  • Step 9: CSS for the new layout

Append to app.css minimal styles: .cards { display:flex; gap:12px; flex-wrap:wrap; }, .card { flex:1; min-width:180px; } (if .card/.cards don't already exist from the dashboard — reuse them), ensure .toggle/.badge/.tabbar/.empty exist (they do). No new component CSS expected beyond .pineap-* if needed.

  • Step 10: Syntax check + commit
$py -c "import ast; ast.parse(open(r'payload\user\general\pager-webui\server.py', encoding='utf-8').read())"
node --check payload/user/general/pager-webui/www/js/views.js
node --check payload/user/general/pager-webui/www/js/app.js

Expected: no errors. Commit:

git add payload/user/general/pager-webui/www/js/ payload/user/general/pager-webui/www/css/
git commit -m "feat: Mark VII-style 8-tab PineAP page; wifi rail icon"

Task 3: Deploy + on-device smoke test

Files:

  • Run: scripts/deploy.ps1 -SshKey ... (or sshpass flow from README)

  • Step 1: Build + deploy

Run the deploy script per README (builds build/pager-webui/payload-*.zip, uploads, installs). Confirm the service restarts.

  • Step 2: Walk the PineAP page on the pager

Browse http://172.16.52.1:8080/#/pineap. Verify: rail shows wifi icon; overview mode badge + quick toggles; Open AP toggles save and survive reload; Evil WPA save applies (SSID/passphrase visible on a second load); Enterprise enable + auth-pass toggle; Impersonation add/remove/clear; Clients list + kick; Filtering mode + add/delete/clear; APs table.

  • Step 3: Reboot persistence + fix-ups

ssh root@172.16.52.1 reboot, then confirm settings persisted (daemon-managed). Any daemon route/field that returned 502 or an unexpected shape (e.g. wifi/get_ap field names, ssidpool list shape, evil-wpa enctype values) -> adjust backend field names in Task 1/2 accordingly and redeploy. Record exact daemon shapes discovered here back into the design doc's open-items section.

  • Step 4: Final commit

Commit any schema adjustments from Step 3 with a fix: message.


Self-review notes

  • Spec coverage: backend proxy (spec Architecture 1-5) -> Task 1; frontend 8 tabs + icon (spec Architecture 1-3) -> Task 2; error handling (spec Error handling) -> 502 in _daemon_proxy + toasts in JS; testing (spec Testing) -> Task 1 tests + Task 3 smoke; enterprise tables (spec Open items) -> Task 1 h_enterprise_data/h_enterprise_clear.
  • Placeholders: no TBD/TODO; unknown daemon field names are called out as on-device discovery in Task 3 Step 3 and use defensive || {} / .catch fallbacks so the page never dies.
  • Type consistency: h_pineap_* handler names, route paths, and frontend PagerAPI.* calls are cross-checked above.