release: Mark VIII 1.0

This commit is contained in:
c4ch3c4d3
2026-08-11 20:24:24 -07:00
commit 3e1805dab8
86 changed files with 20678 additions and 0 deletions
@@ -0,0 +1,822 @@
# 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.