release: Mark VIII 1.0
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user