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.pymust remain stdlib-only (nourllib/http.server/sqlite3guarantee on device; sqlite reads fall back tosqlite3CLI 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, eachtests/test_*.pyrun 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 blockSETTING_MAPat ~line 1354 throughh_filter_postend ~line 1545; add proxy helper neardaemon_sock_call; add enterprise handlers; updateROUTER.addblock ~line 1649) - Test:
tests/test_pineap_settings.py,tests/test_pineap_pool.py,tests/test_pineap_clients.py,tests/test_pineap_aps.py(rewrite); createtests/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-> daemonget_configpassthrough{loghandshake, logpartialhandshake, logpcap, logwigle, logrecon, autossidpool, reconpath, reconname, handshakepath, ...}POST /api/pineap/set_config{...flags}-> daemonset_configpassthroughGET /api/pineap/hostapd-> daemonhostapd/get_config{mgmt_ifaces, wpa_ifaces, pineap_disabled, pineape_disabled, pineape_auth_pass}POST /api/pineap/hostapd{pineap_disabled?, pineape_disabled?, pineape_auth_pass?}-> daemonhostapd/set_configPOST /api/pineap/enable{enable: bool}-> daemonhostapd/enable_pineapPOST /api/pineap/mimic{enable: bool}-> daemonmimic/enable|mimic/disablePOST /api/pineap/examine{bssid, seconds?}|{channel}|{reset: true}-> daemonexamine/bssid|examine/channel|examine/resetPOST /api/pineap/wifi/get_ap/wifi/set_ap-> daemonsettings/wifi/get_ap|settings/wifi/set_ap(Evil WPA + Open AP details)POST /api/pineap/ssidpool/advertise{enable}-> daemonssidpool/enable|ssidpool/disablePOST /api/pineap/ssidpool/collect{enable}-> daemonssidpool/enable_collect|ssidpool/disable_collectPOST /api/pineap/interfaces{device, hop?, inject?, bands?, primary?}-> daemoninterfaces/set_interfaceGET /api/pineap/filters/{client|ssid}->{mode, entries}(mode+active list via daemonmacfilter/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 daemonmacfilter/set_mode|ssidfilter/set_config; list mutations via hak5cmdPINEAPPLE_DEVICE_FILTER_*|PINEAPPLE_NETWORK_FILTER_*GET /api/pineap/enterprise/basic,GET /api/pineap/enterprise/challenge->{rows: [...]}from recon.dbhostap_basic/hostap_challenge(via_db_rows)POST /api/pineap/enterprise/clear{table: basic|challenge}->_db_writedelete 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(replacePINEAP_TABS/pineapShell/views.pineap*block, lines ~163-360; update recon/api/pineap/settingsreferences 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/tabBarhelpers;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_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):
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 readcollect_handshakesasloghandshake.
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 1h_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
|| {}/.catchfallbacks so the page never dies. - Type consistency:
h_pineap_*handler names, route paths, and frontendPagerAPI.*calls are cross-checked above.