# Mark VII PineAP Page Port Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Replace the broken pager-webui PineAP page with a faithful, fully-functional replica of the Mark VII PineAP view (8 tabs), wired to the Pager daemon's native `/api/pineap/*` unix-socket API. **Architecture:** pager-webui's `server.py` proxies the Pager daemon's native PineAP REST API (root-only unix socket `/tmp/api.sock`, raw HTTP/1.1) 1:1 under its authenticated `/api/pineap/*` namespace, keeping existing custom endpoints (clients/aps/handshakes/kick) and fixing the broken uci-based settings + hak5cmd filter handlers. The vanilla-JS SPA gets a Mark VII-style 8-tab PineAP page and a corrected wifi rail icon. **Tech Stack:** Python 3.11 (stdlib only, runs on device `python3-light`), vanilla JS SPA (no build step), existing `daemon_sock_call` socket client. ## Global Constraints - `server.py` must remain stdlib-only (no `urllib`/`http.server`/`sqlite3` guarantee on device; sqlite reads fall back to `sqlite3` CLI via `_db_rows`/`_db_write`). - The daemon socket API (`/tmp/api.sock`) is unauthenticated by design (root-only socket). All pager-webui `/api/pineap/*` endpoints stay behind pager-webui session auth (already enforced by the server). - Daemon socket failure -> HTTP 502 `{error: ...}`; never raise/500. - Commands run with argument lists (no shell interpolation). - No Mark VII-only controls with no Pager equivalent (Autostart, Beacon Responses/Intervals, enterprise cert generation) in the UI. - Tests: stdlib `unittest`, each `tests/test_*.py` run in its own process (module-level monkeypatches do not get restored). --- ### Task 1: Backend — daemon PineAP proxy + fixed filters + enterprise endpoints **Files:** - Modify: `payload/user/general/pager-webui/server.py` (replace the block `SETTING_MAP` at ~line 1354 through `h_filter_post` end ~line 1545; add proxy helper near `daemon_sock_call`; add enterprise handlers; update `ROUTER.add` block ~line 1649) - Test: `tests/test_pineap_settings.py`, `tests/test_pineap_pool.py`, `tests/test_pineap_clients.py`, `tests/test_pineap_aps.py` (rewrite); create `tests/test_pineap_proxy.py`, `tests/test_pineap_enterprise.py` **Interfaces:** - Consumes: existing `daemon_sock_call(method, path, body=None, timeout=10)` -> `(status:int, json|None)`; `_db_rows(db, sql)`; `_db_write(db, sql)`; `current_token()`; `hak5(*args)`; `normalize_mac`. - Produces (used by Task 2 frontend): - `GET /api/pineap/get_config` -> daemon `get_config` passthrough `{loghandshake, logpartialhandshake, logpcap, logwigle, logrecon, autossidpool, reconpath, reconname, handshakepath, ...}` - `POST /api/pineap/set_config` `{...flags}` -> daemon `set_config` passthrough - `GET /api/pineap/hostapd` -> daemon `hostapd/get_config` `{mgmt_ifaces, wpa_ifaces, pineap_disabled, pineape_disabled, pineape_auth_pass}` - `POST /api/pineap/hostapd` `{pineap_disabled?, pineape_disabled?, pineape_auth_pass?}` -> daemon `hostapd/set_config` - `POST /api/pineap/enable` `{enable: bool}` -> daemon `hostapd/enable_pineap` - `POST /api/pineap/mimic` `{enable: bool}` -> daemon `mimic/enable` | `mimic/disable` - `POST /api/pineap/examine` `{bssid, seconds?}` | `{channel}` | `{reset: true}` -> daemon `examine/bssid` | `examine/channel` | `examine/reset` - `POST /api/pineap/wifi/get_ap` / `wifi/set_ap` -> daemon `settings/wifi/get_ap` | `settings/wifi/set_ap` (Evil WPA + Open AP details) - `POST /api/pineap/ssidpool/advertise` `{enable}` -> daemon `ssidpool/enable`|`ssidpool/disable` - `POST /api/pineap/ssidpool/collect` `{enable}` -> daemon `ssidpool/enable_collect`|`ssidpool/disable_collect` - `POST /api/pineap/interfaces` `{device, hop?, inject?, bands?, primary?}` -> daemon `interfaces/set_interface` - `GET /api/pineap/filters/{client|ssid}` -> `{mode, entries}` (mode+active list via daemon `macfilter/get_config`|`ssidfilter/get_config`; entries = denied if mode==deny else allowed) - `POST /api/pineap/filters/{client|ssid}` `{action: set_mode|add|delete|clear, mode?, value?}` -> mode via daemon `macfilter/set_mode`|`ssidfilter/set_config`; list mutations via hak5cmd `PINEAPPLE_DEVICE_FILTER_*`|`PINEAPPLE_NETWORK_FILTER_*` - `GET /api/pineap/enterprise/basic`, `GET /api/pineap/enterprise/challenge` -> `{rows: [...]}` from recon.db `hostap_basic` / `hostap_challenge` (via `_db_rows`) - `POST /api/pineap/enterprise/clear` `{table: basic|challenge}` -> `_db_write` delete rows - Kept as-is: `GET /api/pineap/ssids`, `POST /api/pineap/ssids`, `GET /api/pineap/clients`, `POST /api/pineap/clients/kick`, `GET /api/pineap/aps`, `POST /api/pineap/deauth/client`, `GET/DELETE /api/pineap/handshakes*` - [ ] **Step 1: Write the proxy helper + tests (failing)** `tests/test_pineap_proxy.py`: ```python import os, sys, unittest sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'general', 'pager-webui')) import server def ctx(body=None, args=()): return type('C', (), {'body': body, 'args': args, 'query': {}})() class PineapProxyTest(unittest.TestCase): def test_proxy_get_passthrough(self): server.daemon_sock_call = lambda method, path, body=None, timeout=10: (200, {'loghandshake': False}) status, payload = server.h_pineap_get_config(ctx()) self.assertEqual(status, 200) self.assertEqual(payload['loghandshake'], False) def test_proxy_post_passthrough(self): calls = [] def fake(method, path, body=None, timeout=10): calls.append((method, path, body)) return (200, {'success': True}) server.daemon_sock_call = fake server.h_pineap_enable(ctx({'enable': True})) self.assertEqual(calls[0], ('POST', '/api/pineap/hostapd/enable_pineap', {'enable': True})) def test_proxy_502_on_socket_failure(self): server.daemon_sock_call = lambda method, path, body=None, timeout=10: (0, None) status, payload = server.h_pineap_get_config(ctx()) self.assertEqual(status, 502) def test_mimic_routes_enable_and_disable(self): calls = [] def fake(method, path, body=None, timeout=10): calls.append(path) return (200, {'success': True}) server.daemon_sock_call = fake server.h_pineap_mimic(ctx({'enable': True})) server.h_pineap_mimic(ctx({'enable': False})) self.assertEqual(calls, ['/api/pineap/mimic/enable', '/api/pineap/mimic/disable']) def test_examine_reset(self): calls = [] def fake(method, path, body=None, timeout=10): calls.append((path, body)) return (200, {'success': True}) server.daemon_sock_call = fake server.h_pineap_examine(ctx({'reset': True})) self.assertEqual(calls[0], ('/api/pineap/examine/reset', {'reset': True})) if __name__ == '__main__': unittest.main() ``` - [ ] **Step 2: Run test, verify fail** ``` $py -m unittest tests.test_pineap_proxy -v ``` Expected: FAIL (`AttributeError: module 'server' has no attribute 'h_pineap_get_config'`) - [ ] **Step 3: Implement the proxy in server.py** Replace the entire broken block starting at `SETTING_MAP = {` through `h_filter_post` (ends right before `def _proxy_json`), keeping `hak5`, `_json_or`, `_parse_pool_list`: ```python def _daemon_proxy(method, subpath, body=None): status, data = daemon_sock_call(method, '/api/pineap/%s' % subpath, body=body) if status != 200: return (502 if status == 0 else status), {'error': 'daemon failed', 'detail': data} return 200, (data if isinstance(data, dict) else {'ok': data is not None}) def h_pineap_get_config(ctx): return _daemon_proxy('GET', 'get_config') def h_pineap_set_config(ctx): return _daemon_proxy('POST', 'set_config', ctx.body or {}) def h_pineap_hostapd_get(ctx): return _daemon_proxy('GET', 'hostapd/get_config') def h_pineap_hostapd_set(ctx): body = ctx.body or {} keep = {} for key in ('pineap_disabled', 'pineape_disabled', 'pineape_auth_pass', 'mgmt_ifaces', 'wpa_ifaces'): if key in body: keep[key] = body[key] return _daemon_proxy('POST', 'hostapd/set_config', keep) def h_pineap_enable(ctx): return _daemon_proxy('POST', 'hostapd/enable_pineap', {'enable': bool((ctx.body or {}).get('enable'))}) def h_pineap_mimic(ctx): enable = bool((ctx.body or {}).get('enable')) return _daemon_proxy('POST', 'mimic/enable' if enable else 'mimic/disable') def h_pineap_examine(ctx): body = ctx.body or {} if body.get('reset'): return _daemon_proxy('POST', 'examine/reset', {'reset': True}) if body.get('bssid'): req = {'bssid': body['bssid']} if body.get('seconds') is not None: req['seconds'] = int(body['seconds']) return _daemon_proxy('POST', 'examine/bssid', req) if body.get('channel') is not None: return _daemon_proxy('POST', 'examine/channel', {'channel': str(int(body['channel']))}) return 400, {'error': 'examine requires bssid, channel or reset'} def h_pineap_wifi_get_ap(ctx): status, data = daemon_sock_call('POST', '/api/settings/wifi/get_ap', body={}) if status != 200: return (502 if status == 0 else status), {'error': 'daemon failed', 'detail': data} return 200, (data if isinstance(data, dict) else {'ok': True}) def h_pineap_wifi_set_ap(ctx): status, data = daemon_sock_call('POST', '/api/settings/wifi/set_ap', body=ctx.body or {}) if status != 200: return (502 if status == 0 else status), {'error': 'daemon failed', 'detail': data} return 200, (data if isinstance(data, dict) else {'ok': True}) def h_pineap_advertise(ctx): enable = bool((ctx.body or {}).get('enable')) return _daemon_proxy('POST', 'ssidpool/enable' if enable else 'ssidpool/disable') def h_pineap_collect(ctx): enable = bool((ctx.body or {}).get('enable')) return _daemon_proxy('POST', 'ssidpool/enable_collect' if enable else 'ssidpool/disable_collect') def h_pineap_interfaces(ctx): return _daemon_proxy('POST', 'interfaces/set_interface', ctx.body or {}) # --- Filters --- FILTER_DAEMON = { 'client': ('macfilter/get_config', 'macfilter/set_mode', 'PINEAPPLE_DEVICE_FILTER'), 'ssid': ('ssidfilter/get_config', 'ssidfilter/set_config', 'PINEAPPLE_NETWORK_FILTER'), } def h_filter_get(ctx, kind): get_path, set_path, hak5_prefix = FILTER_DAEMON[kind] status, data = daemon_sock_call('GET', '/api/pineap/%s' % get_path) if status != 200 or not isinstance(data, dict): return (502 if status == 0 else status), {'error': 'daemon failed', 'detail': data} mode = data.get('mode') or 'allow' if kind == 'client': entries = data.get('denied_macs') if mode == 'deny' else data.get('allowed_macs') else: entries = data.get('denied_ssids') if mode == 'deny' else data.get('allowed_ssids') return 200, {'mode': mode, 'entries': [str(e) for e in (entries or [])]} def h_filter_post(ctx, kind): body = ctx.body or {} action = body.get('action') _, set_path, prefix = FILTER_DAEMON[kind] if action == 'set_mode': mode = (body.get('mode') or '').strip() if mode not in ('allow', 'deny'): return 400, {'error': 'mode must be allow or deny'} status, data = daemon_sock_call('POST', '/api/pineap/%s' % set_path, body={'mode': mode}) if status != 200: return (502 if status == 0 else status), {'error': 'daemon failed', 'detail': data} elif action == 'add': value = (body.get('value') or '').strip() if not value: return 400, {'error': 'value required'} hak5('%s_ADD' % prefix, value) elif action == 'delete': value = (body.get('value') or '').strip() if not value: return 400, {'error': 'value required'} hak5('%s_DELETE' % prefix, value) elif action == 'clear': hak5('%s_CLEAR' % prefix) else: return 400, {'error': 'unknown action'} return h_filter_get(ctx, kind) ``` - [ ] **Step 4: Run test, verify pass** ``` $py -m unittest tests.test_pineap_proxy -v ``` Expected: PASS - [ ] **Step 5: Enterprise endpoints + tests** `tests/test_pineap_enterprise.py`: ```python import os, sys, unittest sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'general', 'pager-webui')) import server class EnterpriseTest(unittest.TestCase): def test_basic_rows(self): server._db_rows = lambda db, sql: [{'time': 1, 'username': 'a', 'password': 'b'}] status, payload = server.h_enterprise_data(type('C', (), {'args': ('basic',)})()) self.assertEqual(status, 200) self.assertEqual(payload['rows'][0]['username'], 'a') def test_clear(self): calls = [] server._db_write = lambda db, sql: calls.append(sql) server.h_enterprise_clear(type('C', (), {'body': {'table': 'challenge'}})()) self.assertTrue(any('hostap_challenge' in s for s in calls)) if __name__ == '__main__': unittest.main() ``` Implement in server.py near `_db_write`/handshakes helpers: ```python ENTERPRISE_TABLES = {'basic': 'hostap_basic', 'challenge': 'hostap_challenge'} def _enterprise_cols(table): rows = _db_rows(RECON_DB, 'PRAGMA table_info(%s)' % table) return [r.get('name') for r in rows] def h_enterprise_data(ctx): table = ENTERPRISE_TABLES.get((ctx.args or [''])[0]) if not table: return 400, {'error': 'unknown table'} rows = _db_rows(RECON_DB, 'SELECT * FROM %s ORDER BY time' % table) return 200, {'table': table, 'rows': rows or []} def h_enterprise_clear(ctx): table = ENTERPRISE_TABLES.get((ctx.body or {}).get('table', '')) if not table: return 400, {'error': 'unknown table'} try: _db_write(RECON_DB, 'DELETE FROM %s' % table) except RuntimeError as e: return 502, {'error': str(e)} return 200, {'ok': True} ``` - [ ] **Step 6: Run enterprise test, verify pass** ``` $py -m unittest tests.test_pineap_enterprise -v ``` Expected: PASS - [ ] **Step 7: Rewrite obsolete tests** `tests/test_pineap_settings.py` -> drop the `UciHelpersTest`/`PineapSettingsTest` uci tests (uci helpers stay for NTP/hostname but settings no longer uses them). Replace with a `GetConfigProxyTest` asserting `h_pineap_get_config` proxies and that `set_config` forwards a whitelisted body. `tests/test_pineap_pool.py` -> keep `Hak5Test`/`PoolParsingTest`/`SsidPoolHandlersTest` (those endpoints remain). Add a `FilterProxyReadTest` for `h_filter_get` reading daemon config. - [ ] **Step 8: Wire the ROUTER table** Replace these lines in the `ROUTER.add` block: ```python ROUTER.add('GET', r'/api/pineap/get_config', h_pineap_get_config) ROUTER.add('POST', r'/api/pineap/set_config', h_pineap_set_config) ROUTER.add('GET', r'/api/pineap/hostapd', h_pineap_hostapd_get) ROUTER.add('POST', r'/api/pineap/hostapd', h_pineap_hostapd_set) ROUTER.add('POST', r'/api/pineap/enable', h_pineap_enable) ROUTER.add('POST', r'/api/pineap/mimic', h_pineap_mimic) ROUTER.add('POST', r'/api/pineap/examine', h_pineap_examine) ROUTER.add('POST', r'/api/pineap/wifi/get_ap', h_pineap_wifi_get_ap) ROUTER.add('POST', r'/api/pineap/wifi/set_ap', h_pineap_wifi_set_ap) ROUTER.add('POST', r'/api/pineap/ssidpool/advertise', h_pineap_advertise) ROUTER.add('POST', r'/api/pineap/ssidpool/collect', h_pineap_collect) ROUTER.add('POST', r'/api/pineap/interfaces', h_pineap_interfaces) ROUTER.add('GET', r'/api/pineap/filters/client', lambda ctx: h_filter_get(ctx, 'client')) ROUTER.add('POST', r'/api/pineap/filters/client', lambda ctx: h_filter_post(ctx, 'client')) ROUTER.add('GET', r'/api/pineap/filters/ssid', lambda ctx: h_filter_get(ctx, 'ssid')) ROUTER.add('POST', r'/api/pineap/filters/ssid', lambda ctx: h_filter_post(ctx, 'ssid')) ROUTER.add('GET', r'/api/pineap/enterprise/(basic|challenge)', h_enterprise_data) ROUTER.add('POST', r'/api/pineap/enterprise/clear', h_enterprise_clear) ``` Remove the old routes for `settings`, `ssidpool/(start|stop|collect_start|collect_stop)` and `filters` if duplicated. Keep `ssids`, `clients`, `aps`, `deauth/client`, `handshakes*` routes as-is. **Remove the `SSIDPOOL_ACTIONS`/`h_ssidpool_action` and `SETTING_MAP`/`_uci_map`/`uci_show`-for-pineapd usage that the removed block owned** (keep `uci_show`/`uci_set`/`uci_delete`/`uci_add_list` — NTP/hostname still use them). - [ ] **Step 9: Run full test loop, commit** ``` Get-ChildItem tests\test_*.py | ForEach-Object { & $py -m unittest "tests.$([IO.Path]::GetFileNameWithoutExtension($_.Name))" -v } ``` Expected: all pass. Commit: ``` git add payload/user/general/pager-webui/server.py tests/ git commit -m "feat: proxy native daemon PineAP API; fix filters; add enterprise endpoints" ``` --- ### Task 2: Frontend — 8-tab Mark VII PineAP page **Files:** - Modify: `payload/user/general/pager-webui/www/js/app.js` (rail icon; routes map) - Modify: `payload/user/general/pager-webui/www/js/views.js` (replace `PINEAP_TABS`/`pineapShell`/`views.pineap*` block, lines ~163-360; update recon `/api/pineap/settings` references at ~495, 601, 609, 877) - Modify: `payload/user/general/pager-webui/www/css/app.css` (pineap cards/toggles layout) - Test: manual browser walk (no JS test harness) **Interfaces:** - Consumes: backend routes from Task 1; existing `h`/`table`/`btn`/`iconBtn`/`tabBar` helpers; `PagerAPI.get/post`; `App.toast`; `fmtTime`. - Produces: `views.pineap` (overview), `pineap_open`, `pineap_evilwpa`, `pineap_enterprise`, `pineap_impersonation`, `pineap_clients`, `pineap_filtering`, `pineap_aps`; routes `#/pineap`, `#/pineap/open`, `#/pineap/evilwpa`, `#/pineap/enterprise`, `#/pineap/impersonation`, `#/pineap/clients`, `#/pineap/filtering`, `#/pineap/aps`. - [ ] **Step 1: Rail icon + routes** In `app.js`: change rail item `{ key: 'pineap', label: 'PineAP', hash: '#/pineap', icon: 'pineap' }` to `icon: 'wifi'`. Extend `routes`: ```js '#/pineap': 'pineap', '#/pineap/open': 'pineap_open', '#/pineap/evilwpa': 'pineap_evilwpa', '#/pineap/enterprise': 'pineap_enterprise', '#/pineap/impersonation': 'pineap_impersonation', '#/pineap/clients': 'pineap_clients', '#/pineap/filtering': 'pineap_filtering', '#/pineap/aps': 'pineap_aps', ``` - [ ] **Step 2: Tab shell + overview** In `views.js` replace `PINEAP_TABS`/`pineapShell`/`views.pineap`/`views.pineap_open`/`views.pineap_clients`/`views.pineap_filtering`/`views.pineap_aps`/`views.pineap_impersonation` with the new implementation (full code in the patch below). The overview derives mode from `get_config`+`hostapd`+`wifi/get_ap`: ```js const PINEAP_TABS = [ { label: 'PineAP', hash: '#/pineap' }, { label: 'Open AP', hash: '#/pineap/open' }, { label: 'Evil WPA', hash: '#/pineap/evilwpa' }, { label: 'Enterprise', hash: '#/pineap/enterprise' }, { label: 'Impersonation', hash: '#/pineap/impersonation' }, { label: 'Clients', hash: '#/pineap/clients' }, { label: 'Filtering', hash: '#/pineap/filtering' }, { label: 'APs', hash: '#/pineap/aps' } ]; function pineapShell(root, activeHash, inner) { root.appendChild(h('h1', { class: 'page-title', text: 'PineAP' })); tabBar(root, PINEAP_TABS, activeHash); const box = h('div', {}); root.appendChild(box); return inner(box); } views.pineap = (root) => { tabBar(root, PINEAP_TABS, '#/pineap'); const box = h('div', {}); root.appendChild(box); const mode = h('span', { class: 'badge', text: '—' }); const intro = h('p', { class: 'muted' }); const quick = { collect: h('input', { type: 'checkbox', id: 'po-collect' }), advertise: h('input', { type: 'checkbox', id: 'po-advertise' }) }; const cards = { karma: {}, open: {}, wpa: {}, ent: {} }; const cardWrap = h('div', { class: 'cards' }); Object.keys(cards).forEach((k) => { const card = h('div', { class: 'card' }, h('div', { class: 'card-label', text: '' }), h('div', { class: 'card-value' }), h('div', { class: 'row' }, btn('Configure', () => App.go({ karma: '#/pineap/open', open: '#/pineap/open', wpa: '#/pineap/evilwpa', ent: '#/pineap/enterprise' }[k]), 'ghost'))); cardWrap.appendChild(card); cards[k].wrap = card; cards[k].label = card.querySelector('.card-label'); cards[k].value = card.querySelector('.card-value'); }); const head = h('div', { class: 'section' }, h('h2', {}, 'PineAP'), mode, intro); box.appendChild(head); const quickBox = h('div', { class: 'section' }, h('h2', {}, 'Quick Settings')); quickBox.appendChild(h('label', { class: 'toggle' }, quick.collect, ' Capture SSIDs to Pool')); quickBox.appendChild(h('label', { class: 'toggle' }, quick.advertise, ' Advertise AP Impersonation Pool')); quickBox.appendChild(h('div', { class: 'muted', style: 'margin-top:8px' }, 'Client connect/disconnect notifications are handled by the Pager alert payload system.')); box.appendChild(quickBox); box.appendChild(cardWrap); function bind(cb, on) { cb.addEventListener('change', () => on(cb.checked).then(load).catch(() => { cb.checked = !cb.checked; App.toast('Failed', 'error'); })); } bind(quick.collect, (v) => PagerAPI.post('/api/pineap/ssidpool/collect', { enable: v })); bind(quick.advertise, (v) => PagerAPI.post('/api/pineap/ssidpool/advertise', { enable: v })); function load() { Promise.all([ PagerAPI.get('/api/pineap/get_config').catch(() => ({ data: {} })), PagerAPI.get('/api/pineap/hostapd').catch(() => ({ data: {} })), PagerAPI.post('/api/pineap/wifi/get_ap').catch(() => ({ data: {} })) ]).then(([cfg, host, ap]) => { const c = cfg.data || {}, hh = host.data || {}, a = ap.data || {}; const disabled = !!hh.pineap_disabled; const active = !disabled; const advanced = active && (!!hh.pineape_disabled === false || (a.wpa && a.wpa.enabled) || (a.enterprise && a.enterprise.enabled)); mode.textContent = disabled ? 'Passive' : (advanced ? 'Advanced' : 'Active'); mode.className = 'badge ' + (disabled ? 'off' : 'on'); intro.textContent = disabled ? 'PineAP is disabled. Enable it from the Open AP tab to begin impersonating networks.' : 'The WiFi Pineapple will respond to probe requests and impersonate the Open, Evil WPA, and Evil Enterprise access points.'; quick.collect.checked = !!c.autossidpool; quick.advertise.checked = !!a.pool ? !a.pool.disabled : false; setCard(cards.karma, 'Karma', null); setCard(cards.open, 'Open Network', a.open ? (a.open.enabled ? 'On' : 'Off') : '—'); setCard(cards.wpa, 'Evil WPA', a.wpa ? (a.wpa.enabled ? 'On' : 'Off') : '—'); setCard(cards.ent, 'Evil Enterprise', a.enterprise ? (a.enterprise.enabled ? 'On' : 'Off') : '—'); }); } function setCard(card, label, value) { card.label.textContent = label; card.value.textContent = value == null ? '—' : value; } load(); const iv = setInterval(load, 5000); return { destroy: () => clearInterval(iv) }; }; ``` - [ ] **Step 3: Open AP view** `views.pineap_open` — master `Enable PineAP` toggle (POST `/api/pineap/enable`), `Karma` (POST `/api/pineap/mimic`), Logging group (`loghandshake`, `logpartialhandshake`, `logpcap`, `logwigle`, `logrecon` via `set_config`), `Capture SSIDs to Pool`, `Advertise AP Impersonation Pool`, and read-only `PineAP MAC`/`Target MAC` pulled from `/api/pineap/wifi/get_ap` (`a.open.bssid`, `a.open.target`) when present. Full code: ```js views.pineap_open = (root) => { const state = { cfg: {}, host: {}, ap: {} }; const box = h('div', { class: 'section' }, h('h2', {}, 'Open AP')); root.appendChild(box); const toggles = {}; const defs = [ ['pineap_disabled', 'Enable PineAP', (v) => PagerAPI.post('/api/pineap/enable', { enable: v })], ['karma', 'Karma', (v) => PagerAPI.post('/api/pineap/mimic', { enable: v })], ['loghandshake', 'Log Handshakes', (v) => saveCfg({ loghandshake: v })], ['logpartialhandshake', 'Log Partial Handshakes', (v) => saveCfg({ logpartialhandshake: v })], ['logpcap', 'Log PCAP', (v) => saveCfg({ logpcap: v })], ['logwigle', 'Log WiGLE', (v) => saveCfg({ logwigle: v })], ['logrecon', 'Log Recon', (v) => saveCfg({ logrecon: v })], ['autossidpool', 'Capture SSIDs to Pool', (v) => saveCfg({ autossidpool: v })], ['advertise', 'Advertise AP Impersonation Pool', (v) => PagerAPI.post('/api/pineap/ssidpool/advertise', { enable: v })] ]; defs.forEach(([k, label, fn]) => { const cb = h('input', { type: 'checkbox', id: 'oap-' + k }); toggles[k] = { cb, fn }; cb.addEventListener('change', () => fn(cb.checked).then(load).catch(() => { cb.checked = !cb.checked; App.toast('Failed', 'error'); })); box.appendChild(h('label', { class: 'toggle' }, cb, ' ' + label)); }); const info = h('div', { class: 'muted', style: 'margin-top:10px' }); box.appendChild(info); function saveCfg(body) { return PagerAPI.post('/api/pineap/set_config', body); } function load() { Promise.all([ PagerAPI.get('/api/pineap/get_config').catch(() => ({ data: {} })), PagerAPI.get('/api/pineap/hostapd').catch(() => ({ data: {} })), PagerAPI.post('/api/pineap/wifi/get_ap').catch(() => ({ data: {} })) ]).then(([cfg, host, ap]) => { state.cfg = cfg.data || {}; state.host = host.data || {}; state.ap = ap.data || {}; toggles.pineap_disabled.cb.checked = !state.host.pineap_disabled; toggles.karma.cb.checked = !!state.cfg.mimic; ['loghandshake', 'logpartialhandshake', 'logpcap', 'logwigle', 'logrecon', 'autossidpool'] .forEach((k) => { toggles[k].cb.checked = !!state.cfg[k]; }); toggles.advertise.cb.checked = !!(state.ap.pool && !state.ap.pool.disabled); const o = state.ap.open || {}; info.textContent = 'PineAP MAC: ' + (o.bssid || '—') + ' Target MAC: ' + (o.target || '—'); }); } load(); return { destroy: () => {} }; }; ``` - [ ] **Step 4: Evil WPA view** `views.pineap_evilwpa` — SSID, passphrase, encryption select (WPA2 PSK / WPA3 SAE / WPA3 OAE), Hidden toggle, Enabled toggle; save via `POST /api/pineap/wifi/set_ap` with `{ wpa: { ssid, passphrase, enctype, hidden, enabled } }`. Handshake capture card (Examine BSSID + seconds, Start/Stop via `POST /api/pineap/examine`) and the captured handshakes table (`GET /api/pineap/handshakes`). Full code: ```js const EVIL_ENC = [ ['psk2+ccmp', 'WPA2 PSK'], ['psk2+tkip', 'WPA2 PSK (TKIP)'], ['sae', 'WPA3 SAE'], ['sae+transition', 'WPA3 SAE (Transition)'], ['owe', 'WPA3 OWE'], ['owe+transition', 'WPA3 OWE (Transition)'] ]; views.pineap_evilwpa = (root) => { const box = h('div', { class: 'section' }, h('h2', {}, 'Evil WPA')); root.appendChild(box); const ssidIn = h('input', { id: 'ew-ssid' }); const pskIn = h('input', { id: 'ew-psk' }); const encSel = h('select', { id: 'ew-enc' }); EVIL_ENC.forEach(([v, l]) => encSel.appendChild(h('option', { value: v, text: l }))); const hiddenCb = h('input', { type: 'checkbox', id: 'ew-hidden' }); const enabledCb = h('input', { type: 'checkbox', id: 'ew-enabled' }); box.appendChild(h('label', {}, 'SSID', ssidIn)); box.appendChild(h('label', {}, 'Passphrase', pskIn)); box.appendChild(h('label', {}, 'Encryption', encSel)); box.appendChild(h('label', { class: 'toggle' }, hiddenCb, ' Hidden')); box.appendChild(h('label', { class: 'toggle' }, enabledCb, ' Enabled')); box.appendChild(h('div', { class: 'row' }, h('div', {}, btn('Save', () => { PagerAPI.post('/api/pineap/wifi/set_ap', { wpa: { ssid: ssidIn.value, passphrase: pskIn.value, enctype: encSel.value, hidden: hiddenCb.checked, enabled: enabledCb.checked } }).then(() => { App.toast('Evil WPA saved'); load(); }).catch(() => App.toast('Failed', 'error')); })))); const capBox = h('div', { class: 'section' }, h('h2', {}, 'Handshake Capture')); root.appendChild(capBox); const bssidIn = h('input', { id: 'ew-bssid', placeholder: 'BSSID' }); const secsIn = h('input', { id: 'ew-secs', type: 'number', value: '30', style: 'max-width:80px' }); capBox.appendChild(h('div', { class: 'row' }, h('div', {}, h('label', {}, 'BSSID', bssidIn)), h('div', {}, h('label', {}, 'Seconds', secsIn)), h('div', {}, btn('Examine', () => { const b = bssidIn.value.trim(); if (!b) { App.toast('BSSID required', 'error'); return; } PagerAPI.post('/api/pineap/examine', { bssid: b, seconds: parseInt(secsIn.value, 10) || 30 }) .then(() => App.toast('Examining ' + b)).catch(() => App.toast('Failed', 'error')); })), h('div', {}, btn('Stop', () => PagerAPI.post('/api/pineap/examine', { reset: true }).then(() => App.toast('Stopped')), 'danger')))); const hsBox = h('div', { class: 'section' }, h('h2', {}, 'Captured Handshakes')); root.appendChild(hsBox); function load() { PagerAPI.post('/api/pineap/wifi/get_ap').then((r) => { const w = (r.data || {}).wpa || {}; ssidIn.value = w.ssid || ''; pskIn.value = w.passphrase || ''; if (w.enctype) encSel.value = w.enctype; hiddenCb.checked = !!w.hidden; enabledCb.checked = !!w.enabled; }).catch(() => {}); PagerAPI.get('/api/pineap/handshakes').then((r) => { hsBox.innerHTML = ''; hsBox.appendChild(h('h2', {}, 'Captured Handshakes')); const rows = (r.data.handshakes || []).map((x) => ({ name: x.name || '--', ap: x.ap || '--', client: x.client || '--', type: x.type || '--' })); hsBox.appendChild(table( [{ label: 'File', key: 'name' }, { label: 'AP', key: 'ap' }, { label: 'Client', key: 'client' }, { label: 'Type', key: 'type' }], rows)); if (!rows.length) hsBox.appendChild(h('div', { class: 'empty', text: 'No handshakes captured yet.' })); }).catch(() => {}); } load(); const iv = setInterval(load, 5000); return { destroy: () => clearInterval(iv) }; }; ``` - [ ] **Step 5: Enterprise view** `views.pineap_enterprise` — `Enabled` (`POST /api/pineap/hostapd` with `pineape_disabled: !v`) and `Auth Pass Capture` (`pineape_auth_pass`) toggles, then two tables from `/api/pineap/enterprise/basic` and `/challenge` with Clear buttons (`POST /api/pineap/enterprise/clear`): ```js views.pineap_enterprise = (root) => { const box = h('div', { class: 'section' }, h('h2', {}, 'Evil Enterprise')); root.appendChild(box); const enabledCb = h('input', { type: 'checkbox', id: 'ee-enabled' }); const authCb = h('input', { type: 'checkbox', id: 'ee-auth' }); box.appendChild(h('label', { class: 'toggle' }, enabledCb, ' Enabled')); box.appendChild(h('label', { class: 'toggle' }, authCb, ' Auth Pass Capture')); enabledCb.addEventListener('change', () => PagerAPI.post('/api/pineap/hostapd', { pineape_disabled: !enabledCb.checked }).then(load).catch(() => { enabledCb.checked = !enabledCb.checked; })); authCb.addEventListener('change', () => PagerAPI.post('/api/pineap/hostapd', { pineape_auth_pass: authCb.checked }).then(load).catch(() => { authCb.checked = !authCb.checked; })); function tableBox(name, endpoint, clearTable) { const tb = h('div', { class: 'section' }, h('h2', {}, name), btn('Clear', () => PagerAPI.post('/api/pineap/enterprise/clear', { table: clearTable }).then(load), 'danger')); root.appendChild(tb); const body = h('div', {}); tb.appendChild(body); return { tb, body, endpoint }; } const basic = tableBox('Basic Data', '/api/pineap/enterprise/basic', 'basic'); const chall = tableBox('Challenge Data', '/api/pineap/enterprise/challenge', 'challenge'); function load() { PagerAPI.get('/api/pineap/hostapd').then((r) => { const hh = r.data || {}; enabledCb.checked = !hh.pineape_disabled; authCb.checked = !!hh.pineape_auth_pass; }).catch(() => {}); [[basic], [chall]].forEach(([t]) => { PagerAPI.get(t.endpoint).then((r) => { const rows = (r.data.rows || []).slice(); t.body.innerHTML = ''; const cols = rows.length ? Object.keys(rows[0]).map((k) => ({ label: k, key: k })) : [{ label: '—', key: '_none' }]; t.body.appendChild(table(cols, rows)); if (!rows.length) t.body.appendChild(h('div', { class: 'empty', text: 'No data captured.' })); }).catch(() => {}); }); } load(); const iv = setInterval(load, 5000); return { destroy: () => clearInterval(iv) }; }; ``` - [ ] **Step 6: Impersonation view** `views.pineap_impersonation` — SSID pool: list via `GET /api/pineap/ssids`, add/remove/clear via `POST /api/pineap/ssids`, advertise + collect via the pool routes: ```js views.pineap_impersonation = (root) => { const box = h('div', { class: 'section' }, h('h2', {}, 'SSID Pool')); root.appendChild(box); const input = h('input', { id: 'imp-ssid' }); const list = h('div', {}); box.appendChild(h('div', { class: 'row' }, h('div', {}, h('label', {}, 'SSID', input)), h('div', {}, btn('Add', () => { const v = input.value.trim(); if (!v) return; PagerAPI.post('/api/pineap/ssids', { action: 'add', ssid: v }).then((r) => { input.value = ''; render(r.data.ssids); }); })), h('div', {}, btn('Clear', () => PagerAPI.post('/api/pineap/ssids', { action: 'clear' }).then((r) => render(r.data.ssids)), 'danger')))); const advCb = h('input', { type: 'checkbox', id: 'imp-advertise' }); const colCb = h('input', { type: 'checkbox', id: 'imp-collect' }); box.appendChild(h('label', { class: 'toggle' }, advCb, ' Advertise AP Impersonation Pool')); box.appendChild(h('label', { class: 'toggle' }, colCb, ' Capture SSIDs to Pool')); advCb.addEventListener('change', () => PagerAPI.post('/api/pineap/ssidpool/advertise', { enable: advCb.checked }).then(load).catch(() => { advCb.checked = !advCb.checked; })); colCb.addEventListener('change', () => PagerAPI.post('/api/pineap/ssidpool/collect', { enable: colCb.checked }).then(load).catch(() => { colCb.checked = !colCb.checked; })); box.appendChild(list); function render(ssids) { list.innerHTML = ''; list.appendChild(table( [{ label: 'SSID', key: 'ssid' }, { label: '', render: () => '' }], (ssids || []).map((s) => ({ ssid: s })), (r) => ({ onclick: () => { if (confirm('Remove ' + r.ssid + '?')) PagerAPI.post('/api/pineap/ssids', { action: 'remove', ssid: r.ssid }).then((x) => render(x.data.ssids)); } }))); list.querySelectorAll('.tbl th').forEach((th, i) => { if (i === 1) th.textContent = 'Remove'; }); if (!ssids || !ssids.length) list.appendChild(h('div', { class: 'empty', text: 'No SSIDs in pool.' })); } function load() { PagerAPI.get('/api/pineap/ssids').then((r) => render(r.data.ssids)).catch(() => {}); PagerAPI.post('/api/pineap/wifi/get_ap').then((r) => { const p = (r.data || {}).pool || {}; advCb.checked = !p.disabled; colCb.checked = !!p.collecting; }).catch(() => {}); } load(); return { destroy: () => {} }; }; ``` - [ ] **Step 7: Clients, Filtering, APs views** `views.pineap_clients` — keep the existing connected-clients + Kick table (it already works), just re-parented into the shell. `views.pineap_filtering` — rewrite to call the fixed backend (`GET/POST /api/pineap/filters/{client|ssid}`), two cards each with mode select (allow/deny) + add/delete/clear list: ```js views.pineap_filtering = (root) => { const cfBox = h('div', { class: 'section' }, h('h2', {}, 'Client Filter')); const sfBox = h('div', { class: 'section' }, h('h2', {}, 'SSID Filter')); root.appendChild(cfBox); root.appendChild(sfBox); function renderFilter(box, kind) { box.innerHTML = ''; box.appendChild(h('h2', {}, kind === 'client' ? 'Client Filter' : 'SSID Filter')); const path = '/api/pineap/filters/' + kind; const modeSel = h('select', { id: 'fm-' + kind }, h('option', { value: 'allow', text: 'Allow list' }), h('option', { value: 'deny', text: 'Deny list' })); const valueIn = h('input', { id: 'fv-' + kind }); box.appendChild(h('div', { class: 'row' }, h('div', {}, h('label', {}, 'Mode', modeSel)), h('div', {}, h('label', {}, 'Value', valueIn)), h('div', {}, btn('Add', () => { const v = document.getElementById('fv-' + kind).value.trim(); if (!v) return; PagerAPI.post(path, { action: 'add', value: v }).then(() => refresh()).catch(() => App.toast('Failed', 'error')); })), h('div', {}, btn('Clear', () => PagerAPI.post(path, { action: 'clear' }).then(refresh), 'danger')))); modeSel.addEventListener('change', () => PagerAPI.post(path, { action: 'set_mode', mode: modeSel.value }).then(refresh)); const list = h('div', {}); box.appendChild(list); PagerAPI.get(path).then((r) => { modeSel.value = r.data.mode; list.innerHTML = ''; list.appendChild(table( [{ label: kind === 'client' ? 'MAC' : 'SSID', key: 'value' }, { label: '', render: () => '' }], (r.data.entries || []).map((e) => ({ value: e })), (row) => ({ onclick: () => { if (confirm('Delete ' + row.value + '?')) PagerAPI.post(path, { action: 'delete', value: row.value }).then(refresh); } }))); list.querySelectorAll('.tbl th').forEach((th, i) => { if (i === 1) th.textContent = 'Delete'; }); if (!r.data.entries || !r.data.entries.length) list.appendChild(h('div', { class: 'empty', text: 'No entries.' })); }).catch(() => {}); } function refresh() { renderFilter(cfBox, 'client'); renderFilter(sfBox, 'ssid'); } refresh(); return { destroy: () => {} }; }; ``` `views.pineap_aps` — keep the existing AP scan table view. - [ ] **Step 8: Update recon settings references** In `views.js` recon view (and recon focus sidebar): - Line ~495: `PagerAPI.post('/api/pineap/settings', { collect_handshakes: hsAuto...checked })` -> `PagerAPI.post('/api/pineap/set_config', { loghandshake: hsAuto.querySelector('input').checked })` - Line ~601/609: `PagerAPI.post('/api/pineap/settings', { collect_handshakes: true/false })` -> `PagerAPI.post('/api/pineap/set_config', { loghandshake: true/false })` - Line ~877: `PagerAPI.get('/api/pineap/settings')` -> `PagerAPI.get('/api/pineap/get_config')`, and read `collect_handshakes` as `loghandshake`. Also update the `hsAuto` checkbox initializer accordingly. - [ ] **Step 9: CSS for the new layout** Append to `app.css` minimal styles: `.cards { display:flex; gap:12px; flex-wrap:wrap; }`, `.card { flex:1; min-width:180px; }` (if `.card`/`.cards` don't already exist from the dashboard — reuse them), ensure `.toggle`/`.badge`/`.tabbar`/`.empty` exist (they do). No new component CSS expected beyond `.pineap-*` if needed. - [ ] **Step 10: Syntax check + commit** ``` $py -c "import ast; ast.parse(open(r'payload\user\general\pager-webui\server.py', encoding='utf-8').read())" node --check payload/user/general/pager-webui/www/js/views.js node --check payload/user/general/pager-webui/www/js/app.js ``` Expected: no errors. Commit: ``` git add payload/user/general/pager-webui/www/js/ payload/user/general/pager-webui/www/css/ git commit -m "feat: Mark VII-style 8-tab PineAP page; wifi rail icon" ``` --- ### Task 3: Deploy + on-device smoke test **Files:** - Run: `scripts/deploy.ps1 -SshKey ...` (or sshpass flow from README) - [ ] **Step 1: Build + deploy** Run the deploy script per README (builds `build/pager-webui/payload-*.zip`, uploads, installs). Confirm the service restarts. - [ ] **Step 2: Walk the PineAP page on the pager** Browse `http://172.16.52.1:8080/#/pineap`. Verify: rail shows wifi icon; overview mode badge + quick toggles; Open AP toggles save and survive reload; Evil WPA save applies (SSID/passphrase visible on a second load); Enterprise enable + auth-pass toggle; Impersonation add/remove/clear; Clients list + kick; Filtering mode + add/delete/clear; APs table. - [ ] **Step 3: Reboot persistence + fix-ups** `ssh root@172.16.52.1 reboot`, then confirm settings persisted (daemon-managed). Any daemon route/field that returned 502 or an unexpected shape (e.g. `wifi/get_ap` field names, `ssidpool` list shape, evil-wpa enctype values) -> adjust backend field names in Task 1/2 accordingly and redeploy. Record exact daemon shapes discovered here back into the design doc's open-items section. - [ ] **Step 4: Final commit** Commit any schema adjustments from Step 3 with a `fix:` message. --- ## Self-review notes - Spec coverage: backend proxy (spec Architecture 1-5) -> Task 1; frontend 8 tabs + icon (spec Architecture 1-3) -> Task 2; error handling (spec Error handling) -> 502 in `_daemon_proxy` + toasts in JS; testing (spec Testing) -> Task 1 tests + Task 3 smoke; enterprise tables (spec Open items) -> Task 1 `h_enterprise_data`/`h_enterprise_clear`. - Placeholders: no TBD/TODO; unknown daemon field names are called out as on-device discovery in Task 3 Step 3 and use defensive `|| {}` / `.catch` fallbacks so the page never dies. - Type consistency: `h_pineap_*` handler names, route paths, and frontend `PagerAPI.*` calls are cross-checked above.