release: Mark VIII 1.0
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,687 @@
|
||||
# Handshakes Mark VII Parity 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:** Make the Pager WebUI Recon → Handshakes tab (`#/recon/handshakes`) look and behave like the stock Mark VII handshakes page (BSSID/Client/Source/Type/Captured/Message 1–4/Beacon Frame table, per-row Download & Delete, settings dialog with location + Delete All).
|
||||
|
||||
**Architecture:** `server.py` synthesizes the Mark VII `{handshakes:[...]}` shape from loot filenames + one scoped recon.db correlation read (zero DB reads when the loot dir is empty). The vanilla-JS `views.recon_handshakes` view is rebuilt to render the Mark VII table with check/X/? glyphs, a success/error flash, and a settings modal. New icons and CSS are added. Dashboard keeps reading the unchanged `files` array.
|
||||
|
||||
**Tech Stack:** Python 3.11 (Windows dev) / device `python3-light`; stdlib `unittest` with mocks; vanilla JS + hand-rolled `h()` DOM helpers.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- **DB reads:** at most **1** `_db_rows` call per handshakes request, and only when the loot dir is non-empty. No polling added to the handshakes view.
|
||||
- **No new dependencies** — pure stdlib; reuse existing helpers `_db_rows`, `fmt_mac`, `hsType`, `iconBtn`, `btn`, `h`, `PagerAPI`, `App`.
|
||||
- **Dashboard unchanged:** `GET /api/pineap/handshakes` must keep returning a `files` array with the current shape (`{name,size,mtime}`).
|
||||
- **Router is first-match-wins:** the `location` GET route MUST be registered before `GET /api/pineap/handshakes/([^/]+)`.
|
||||
- Tests run per module in their own process (README): `& "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe" -m unittest tests.test_recon -v`.
|
||||
- Copy exact strings from the spec (Mark VII wording for tooltips/empty state, `pcap`→`PCAP`, `22000`→`Hashcat`).
|
||||
|
||||
---
|
||||
---
|
||||
|
||||
### Task 1: Backend — filename parser + `handshakes_data()` Mark VII shape
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/general/pager-webui/server.py` (`handshakes_data()` at ~line 1060; add `parse_hs_filename`, `_norm_mac`, `_hs_db_by_pair`, `_compose_hs` above it)
|
||||
- Test: `tests/test_recon.py` (add `HS_RE`/`parse_hs_filename`/`handshakes_data` tests)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `LOOT_HS_DIR` (module global, str), `RECON_DB` (module global, str path), `_db_rows(db, sql)` (existing), `os.listdir`/`os.stat`.
|
||||
- Produces:
|
||||
- `parse_hs_filename(name) -> dict | None` with keys `ts` (int|None), `ap` (colon MAC str), `client` (colon MAC str), `kind` (`'full'|'partial'|'incomplete'`), `ext` (str).
|
||||
- `_norm_mac(m) -> str` — uppercased, dash→colon, 12-hex→colon form.
|
||||
- `handshakes_data() -> {'files': [...], 'handshakes': [...]}` where each handshake record has keys `mac, client, source, type, timestamp, in_db, part_mask, beacon, extension, name, location, file_exists`.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Append to `tests/test_recon.py` (add `import shutil` to the module imports at the top if not already present):
|
||||
|
||||
```python
|
||||
def make_hs_db():
|
||||
db = make_db()
|
||||
conn = sqlite3.connect(db)
|
||||
conn.execute(
|
||||
"INSERT INTO handshake (hash, scan, stahash, aphash, time, beacon, hs1, hs2, hs3, hs4) "
|
||||
"VALUES (21, 1, 1, 2, 1786466650, X'BEACON', X'01', X'02', X'03', X'04')")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return db
|
||||
|
||||
|
||||
class ParseHsFilenameTest(unittest.TestCase):
|
||||
def test_parse_full_pcap(self):
|
||||
p = server.parse_hs_filename('1786466650_C8:9E:43:64:80:80_AE:77:C0:EB:31:41_handshake.pcap')
|
||||
self.assertEqual(p['ts'], 1786466650)
|
||||
self.assertEqual(p['ap'], 'C8:9E:43:64:80:80')
|
||||
self.assertEqual(p['client'], 'AE:77:C0:EB:31:41')
|
||||
self.assertEqual(p['kind'], 'full')
|
||||
self.assertEqual(p['ext'], 'pcap')
|
||||
|
||||
def test_parse_partial_and_incomplete(self):
|
||||
p = server.parse_hs_filename('1_C8-9E-43-64-80-80_AE-77-C0-EB-31-41_handshake_partial.22000')
|
||||
self.assertEqual(p['kind'], 'partial')
|
||||
self.assertEqual(p['ext'], '22000')
|
||||
p = server.parse_hs_filename('1_C8:9E:43:64:80:80_AE:77:C0:EB:31:41_handshake_incomplete.pcap')
|
||||
self.assertEqual(p['kind'], 'incomplete')
|
||||
self.assertEqual(p['ext'], 'pcap')
|
||||
|
||||
def test_parse_dash_macs_and_no_ts(self):
|
||||
p = server.parse_hs_filename('C8-9E-43-64-80-80_AE-77-C0-EB-31-41_handshake.pcap')
|
||||
self.assertIsNone(p['ts'])
|
||||
self.assertEqual(p['ap'], 'C8:9E:43:64:80:80')
|
||||
self.assertEqual(p['client'], 'AE:77:C0:EB:31:41')
|
||||
|
||||
def test_parse_unrecognized(self):
|
||||
self.assertIsNone(server.parse_hs_filename('random.cap'))
|
||||
self.assertIsNone(server.parse_hs_filename('notes.txt'))
|
||||
self.assertIsNone(server.parse_hs_filename(''))
|
||||
self.assertIsNone(server.parse_hs_filename('123_mac1_mac2_handshake'))
|
||||
|
||||
|
||||
class HandshakesDataTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.db = make_hs_db()
|
||||
server.RECON_DB = self.db
|
||||
self.dir = tempfile.mkdtemp()
|
||||
server.LOOT_HS_DIR = self.dir
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.dir)
|
||||
os.unlink(self.db)
|
||||
|
||||
def _write(self, name, ts):
|
||||
os.utime(os.path.join(self.dir, name), (ts, ts))
|
||||
|
||||
def test_empty_dir_skips_db(self):
|
||||
with mock.patch.object(server, '_db_rows', side_effect=AssertionError('db should not be touched')):
|
||||
data = server.handshakes_data()
|
||||
self.assertEqual(data, {'files': [], 'handshakes': []})
|
||||
|
||||
def test_correlation_composes_full_record(self):
|
||||
self._write('1786466650_C8-9E-43-64-80-80_AE-77-C0-EB-31-41_handshake.pcap', 1786466650)
|
||||
data = server.handshakes_data()
|
||||
self.assertEqual(len(data['files']), 1)
|
||||
hs = data['handshakes'][0]
|
||||
self.assertEqual(hs['mac'], 'C8:9E:43:64:80:80')
|
||||
self.assertEqual(hs['client'], 'AE:77:C0:EB:31:41')
|
||||
self.assertEqual(hs['source'], 'Recon')
|
||||
self.assertEqual(hs['type'], 'full')
|
||||
self.assertEqual(hs['extension'], 'pcap')
|
||||
self.assertEqual(hs['timestamp'], 1786466650)
|
||||
self.assertTrue(hs['in_db'])
|
||||
self.assertEqual(hs['part_mask'], 15)
|
||||
self.assertTrue(hs['beacon'])
|
||||
self.assertEqual(hs['name'], '1786466650_C8-9E-43-64-80-80_AE-77-C0-EB-31-41_handshake.pcap')
|
||||
self.assertTrue(hs['file_exists'])
|
||||
self.assertEqual(hs['location'], os.path.join(server.LOOT_HS_DIR, hs['name']))
|
||||
|
||||
def test_file_not_in_db_has_question_mark_fields(self):
|
||||
self._write('1786467000_AA-BB-CC-DD-EE-FF_00-11-22-33-44-55_handshake.pcap', 1786467000)
|
||||
hs = server.handshakes_data()['handshakes'][0]
|
||||
self.assertFalse(hs['in_db'])
|
||||
self.assertEqual(hs['part_mask'], 0)
|
||||
self.assertFalse(hs['beacon'])
|
||||
self.assertEqual(hs['timestamp'], 1786467000)
|
||||
|
||||
def test_unparseable_file_still_listed_with_placeholders(self):
|
||||
self._write('random.cap', 1786467005)
|
||||
hs = server.handshakes_data()['handshakes'][0]
|
||||
self.assertEqual(hs['mac'], '--')
|
||||
self.assertEqual(hs['client'], '--')
|
||||
self.assertFalse(hs['in_db'])
|
||||
self.assertEqual(hs['extension'], 'cap')
|
||||
```
|
||||
|
||||
`tests/test_recon.py` already imports `os, sqlite3, sys, tempfile, unittest, mock` and `import server`; add `import shutil` to the top-of-module imports. `re` is not needed.
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run:
|
||||
```powershell
|
||||
$py = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe"
|
||||
& $py -m unittest tests.test_recon -v
|
||||
```
|
||||
Expected: `AttributeError: module 'server' has no attribute 'parse_hs_filename'` (and failures on `handshakes_data` shape).
|
||||
|
||||
- [ ] **Step 3: Implement parser + data composition**
|
||||
|
||||
In `server.py`, replace the existing `handshakes_data()` (lines ~1060–1074) with:
|
||||
|
||||
```python
|
||||
HS_FILENAME_RE = re.compile(
|
||||
r'^(?:(\d+)_)?([0-9A-Fa-f]{2}(?:[:-][0-9A-Fa-f]{2}){5})_'
|
||||
r'([0-9A-Fa-f]{2}(?:[:-][0-9A-Fa-f]{2}){5})(?:_handshake)?'
|
||||
r'(?:_(full|partial|incomplete))?\.([A-Za-z0-9]+)$')
|
||||
|
||||
|
||||
def parse_hs_filename(name):
|
||||
m = HS_FILENAME_RE.match(name or '')
|
||||
if not m:
|
||||
return None
|
||||
ts, ap, client, kind, ext = m.groups()
|
||||
return {'ts': int(ts) if ts else None,
|
||||
'ap': ap.replace('-', ':'),
|
||||
'client': client.replace('-', ':'),
|
||||
'kind': kind or 'full',
|
||||
'ext': ext}
|
||||
|
||||
|
||||
def _norm_mac(m):
|
||||
m = (m or '').strip().upper().replace('-', ':')
|
||||
if len(m) == 12 and ':' not in m and all(c in '0123456789ABCDEF' for c in m):
|
||||
m = ':'.join(m[i:i + 2] for i in range(0, 12, 2))
|
||||
return m
|
||||
|
||||
|
||||
def _hs_db_by_pair(min_ts):
|
||||
rows = _db_rows(RECON_DB,
|
||||
'SELECT h.time, '
|
||||
'(h.hs1 IS NOT NULL AND length(h.hs1) > 0) AS m1, '
|
||||
'(h.hs2 IS NOT NULL AND length(h.hs2) > 0) AS m2, '
|
||||
'(h.hs3 IS NOT NULL AND length(h.hs3) > 0) AS m3, '
|
||||
'(h.hs4 IS NOT NULL AND length(h.hs4) > 0) AS m4, '
|
||||
'(h.beacon IS NOT NULL AND length(h.beacon) > 0) AS beacon, '
|
||||
'w1.mac AS ap, w2.mac AS sta '
|
||||
'FROM handshake h '
|
||||
'JOIN wifi_device w1 ON w1.hash = h.aphash '
|
||||
'JOIN wifi_device w2 ON w2.hash = h.stahash '
|
||||
'WHERE h.time >= %d ORDER BY h.time' % min_ts)
|
||||
db = {}
|
||||
for r in rows:
|
||||
db[(_norm_mac(r.get('ap')), _norm_mac(r.get('sta')))] = {
|
||||
'time': r.get('time'),
|
||||
'part_mask': (1 if r.get('m1') else 0) | (2 if r.get('m2') else 0)
|
||||
| (4 if r.get('m3') else 0) | (8 if r.get('m4') else 0),
|
||||
'beacon': bool(r.get('beacon')),
|
||||
}
|
||||
return db
|
||||
|
||||
|
||||
def _compose_hs(name, size, mtime, part, db):
|
||||
base = {'source': 'Recon', 'name': name, 'size': size,
|
||||
'location': os.path.join(LOOT_HS_DIR, name), 'file_exists': True}
|
||||
if part is None:
|
||||
ext = name.rsplit('.', 1)[-1] if '.' in name else ''
|
||||
base.update({'mac': '--', 'client': '--', 'type': 'full',
|
||||
'timestamp': mtime, 'in_db': False, 'part_mask': 0,
|
||||
'beacon': False, 'extension': ext})
|
||||
return base
|
||||
rec = db.get((_norm_mac(part['ap']), _norm_mac(part['client'])))
|
||||
base.update({
|
||||
'mac': part['ap'], 'client': part['client'], 'type': part['kind'],
|
||||
'timestamp': (rec or {}).get('time') or part['ts'] or mtime,
|
||||
'in_db': rec is not None,
|
||||
'part_mask': (rec or {}).get('part_mask', 0),
|
||||
'beacon': bool((rec or {}).get('beacon', False)),
|
||||
'extension': part['ext']})
|
||||
return base
|
||||
|
||||
|
||||
def handshakes_data():
|
||||
files = []
|
||||
parsed = []
|
||||
min_ts = None
|
||||
try:
|
||||
names = sorted(os.listdir(LOOT_HS_DIR))
|
||||
except OSError:
|
||||
names = []
|
||||
for name in names:
|
||||
p = os.path.join(LOOT_HS_DIR, name)
|
||||
try:
|
||||
if not os.path.isfile(p) or name.startswith('.'):
|
||||
continue
|
||||
st = os.stat(p)
|
||||
except OSError:
|
||||
continue
|
||||
mtime = int(st.st_mtime)
|
||||
files.append({'name': name, 'size': st.st_size, 'mtime': mtime})
|
||||
part = parse_hs_filename(name)
|
||||
if part is not None:
|
||||
ts = part['ts'] if part['ts'] is not None else mtime
|
||||
part['ts'] = ts
|
||||
if min_ts is None or ts < min_ts:
|
||||
min_ts = ts
|
||||
parsed.append((name, st.st_size, mtime, part))
|
||||
handshakes = []
|
||||
db = _hs_db_by_pair(min_ts) if parsed else {}
|
||||
for name, size, mtime, part in parsed:
|
||||
handshakes.append(_compose_hs(name, size, mtime, part, db))
|
||||
return {'files': files, 'handshakes': handshakes}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run:
|
||||
```powershell
|
||||
$py = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe"
|
||||
& $py -m unittest tests.test_recon -v
|
||||
```
|
||||
Expected: all tests pass, including the pre-existing `HandshakeFileTest` and `ReconDataTest` cases.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/test_recon.py payload/user/general/pager-webui/server.py
|
||||
git commit -m "feat: handshakes endpoint returns Mark VII records from loot files + recon.db"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Backend — `location` + `delete-all` routes
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/general/pager-webui/server.py` (add `h_handshakes_location`, `h_handshakes_delete_all`; reorder/add route registrations at ~lines 1567–1569)
|
||||
- Test: `tests/test_recon.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `LOOT_HS_DIR`, `handshakes_data()` (Task 1).
|
||||
- Produces:
|
||||
- `h_handshakes_location(ctx) -> (200, {'location': str})`
|
||||
- `h_handshakes_delete_all(ctx) -> (200, handshakes_data())`
|
||||
- Router registrations: `GET /api/pineap/handshakes/location` (BEFORE the `([^/]+)` file route), `DELETE /api/pineap/handshakes/all`.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Append to `tests/test_recon.py`:
|
||||
|
||||
```python
|
||||
class HandshakeRoutesTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.dir = tempfile.mkdtemp()
|
||||
server.LOOT_HS_DIR = self.dir
|
||||
|
||||
def tearDown(self):
|
||||
shutil.rmtree(self.dir)
|
||||
|
||||
def _write(self, name, data=b'data'):
|
||||
with open(os.path.join(self.dir, name), 'wb') as f:
|
||||
f.write(data)
|
||||
|
||||
def test_location_returns_loot_dir(self):
|
||||
status, data = server.h_handshakes_location(type('C', (), {'args': ()})())
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(data['location'], self.dir)
|
||||
|
||||
def test_location_route_precedes_file_download(self):
|
||||
h, args = server.ROUTER.dispatch('GET', '/api/pineap/handshakes/location')
|
||||
self.assertIs(h, server.h_handshakes_location)
|
||||
|
||||
def test_delete_all_removes_files(self):
|
||||
self._write('1_C8-9E-43-64-80-80_AE-77-C0-EB-31-41_handshake.pcap')
|
||||
self._write('2_C8-9E-43-64-80-80_AE-77-C0-EB-31-41_handshake.22000')
|
||||
status, data = server.h_handshakes_delete_all(type('C', (), {'args': ()})())
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(data['files'], [])
|
||||
self.assertEqual(data['handshakes'], [])
|
||||
self.assertEqual(os.listdir(self.dir), [])
|
||||
|
||||
def test_delete_all_empty_dir_is_ok(self):
|
||||
status, data = server.h_handshakes_delete_all(type('C', (), {'args': ()})())
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(data['files'], [])
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
Run:
|
||||
```powershell
|
||||
$py = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe"
|
||||
& $py -m unittest tests.test_recon -v
|
||||
```
|
||||
Expected: `AttributeError: module 'server' has no attribute 'h_handshakes_location'` etc.
|
||||
|
||||
- [ ] **Step 3: Implement the handlers + routes**
|
||||
|
||||
In `server.py`, after `h_handshakes_delete` (line ~1100), add:
|
||||
|
||||
```python
|
||||
def h_handshakes_location(ctx):
|
||||
return 200, {'location': LOOT_HS_DIR}
|
||||
|
||||
|
||||
def h_handshakes_delete_all(ctx):
|
||||
try:
|
||||
names = os.listdir(LOOT_HS_DIR)
|
||||
except OSError:
|
||||
names = []
|
||||
for name in names:
|
||||
p = os.path.join(LOOT_HS_DIR, name)
|
||||
try:
|
||||
if os.path.isfile(p) and not name.startswith('.'):
|
||||
os.remove(p)
|
||||
except OSError:
|
||||
continue
|
||||
return 200, handshakes_data()
|
||||
```
|
||||
|
||||
Replace the route block (lines ~1567–1569) with:
|
||||
|
||||
```python
|
||||
ROUTER.add('GET', r'/api/pineap/handshakes/location', h_handshakes_location)
|
||||
ROUTER.add('DELETE', r'/api/pineap/handshakes/all', h_handshakes_delete_all)
|
||||
ROUTER.add('GET', r'/api/pineap/handshakes', h_handshakes_get)
|
||||
ROUTER.add('GET', r'/api/pineap/handshakes/([^/]+)', h_handshake_file)
|
||||
ROUTER.add('DELETE', r'/api/pineap/handshakes', h_handshakes_delete)
|
||||
```
|
||||
|
||||
(`location` MUST stay above the `([^/]+)` GET route — the Router is first-match-wins.)
|
||||
|
||||
- [ ] **Step 4: Run tests to verify they pass**
|
||||
|
||||
Run:
|
||||
```powershell
|
||||
$py = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe"
|
||||
& $py -m unittest tests.test_recon -v
|
||||
```
|
||||
Expected: all pass.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/test_recon.py payload/user/general/pager-webui/server.py
|
||||
git commit -m "feat: handshakes location + delete-all endpoints"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Frontend — glyph icons + CSS
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/general/pager-webui/www/js/icons.js`
|
||||
- Modify: `payload/user/general/pager-webui/www/css/app.css`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `PineappleIcons.check`, `PineappleIcons.close`, `PineappleIcons.question_mark` (inline Material SVG strings); CSS classes `.hs-cell-center`, `.hs-ok`, `.hs-bad`, `.hs-na`, `.hs-actions`, `.hs-warn`, `.hs-flash`, `.hs-flash-ok`, `.hs-flash-error`, `.modal-overlay`, `.modal`, `.modal-title`, `.modal-body`, `.modal-actions`, `.hs-settings-row`, `.hs-settings-label`, `.hs-settings-value`.
|
||||
- Consumed by: Task 4 view.
|
||||
|
||||
- [ ] **Step 1: Add the three icons**
|
||||
|
||||
In `www/js/icons.js`, add inside the `window.PineappleIcons = { ... }` object (keep alphabetical-ish ordering; the file is a plain object):
|
||||
|
||||
```js
|
||||
check: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M9,16.17L4.83,12L3.41,13.41L9,19L21,7L19.59,5.59L9,16.17Z"/></svg>',
|
||||
close: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12L19,6.41Z"/></svg>',
|
||||
question_mark: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M11.07,12.85C11.07,12.85 12.23,12.5 13.03,11.64C13.83,10.79 13.85,9.61 13.24,8.63C12.5,7.58 11.3,7.63 10.67,7.85C10.17,8.03 9.9,8.36 9.63,8.84L8.4,8.12C8.77,7.42 9.23,6.82 9.98,6.39C11.09,5.78 12.58,5.66 13.85,6.53C15.12,7.41 15.83,8.85 15.42,10.13C15.04,11.31 14.05,11.96 13.03,12.45C12.44,12.73 12,13.06 12,13.86V14H11.07V12.85M11,16H12.93V18H11V16Z"/></svg>',
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add CSS**
|
||||
|
||||
Append to `www/css/app.css` (after the existing recon rules):
|
||||
|
||||
```css
|
||||
/* ---- Mark VII handshakes table + settings dialog ---- */
|
||||
.hs-cell-center { text-align: center; }
|
||||
.hs-ok, .hs-bad, .hs-na { display: inline-flex; vertical-align: middle; }
|
||||
.hs-ok svg, .hs-bad svg, .hs-na svg { width: 18px; height: 18px; }
|
||||
.hs-ok { color: #7cb342; }
|
||||
.hs-bad { color: #d32f2f; }
|
||||
.hs-na { color: #9e9e9e; }
|
||||
.hs-actions { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.hs-warn { color: #d32f2f; }
|
||||
.hs-flash { font-size: 12px; margin-left: 8px; }
|
||||
.hs-flash-ok { color: #7cb342; }
|
||||
.hs-flash-error { color: #d32f2f; }
|
||||
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.4); z-index: 100; display: flex; align-items: center; justify-content: center; }
|
||||
.modal { background: var(--surface); border: 1px solid var(--border); border-radius: 4px; min-width: 420px; max-width: 600px; box-shadow: 0 8px 24px rgba(0,0,0,.3); padding: 20px; }
|
||||
.modal-title { font-size: 20px; margin-bottom: 16px; }
|
||||
.modal-body { display: flex; flex-direction: column; gap: 12px; }
|
||||
.modal-actions { display: flex; justify-content: flex-end; margin-top: 16px; }
|
||||
.hs-settings-row { display: flex; justify-content: space-between; gap: 12px; font-size: 14px; }
|
||||
.hs-settings-label { color: var(--muted); }
|
||||
.hs-settings-value { font-family: Consolas, Menlo, monospace; word-break: break-all; }
|
||||
html.dark .modal { background: #303030; }
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify the file parses**
|
||||
|
||||
There is no node on the dev box and no JS test harness, so do a brace/paren balance sanity check with Python instead of a real parse. From the repo root, run:
|
||||
```powershell
|
||||
$py = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe"
|
||||
& $py -c "s=open(r'payload/user/general/pager-webui/www/js/icons.js',encoding='utf-8').read(); assert s.count('{')==s.count('}') and s.count('(')==s.count(')'), 'unbalanced'; print('icons.js balanced OK')"
|
||||
```
|
||||
Expected: prints `icons.js balanced OK`. (CSS has no build step — the Task 5 browser pass covers it, and the icons resolve in the browser console there.)
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add payload/user/general/pager-webui/www/js/icons.js payload/user/general/pager-webui/www/css/app.css
|
||||
git commit -m "feat: handshake glyph icons and modal/flash CSS"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Frontend — rebuild `views.recon_handshakes` + settings dialog
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/general/pager-webui/www/js/views.js` (replace `hsType`/`views.recon_handshakes`, lines ~904–952)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `RECON_TABS`, `h`, `tabBar`, `iconBtn`, `btn`, `fmtTime`, `hsType`, `PagerAPI`, `App.apiBase`, `App.toast`, `PineappleIcons.*` (all existing); backend endpoints from Tasks 1–2.
|
||||
- Produces: rewritten `views.recon_handshakes` (no signature change — `(root) => ({destroy})`).
|
||||
|
||||
- [ ] **Step 1: Replace `hsType` and `views.recon_handshakes`**
|
||||
|
||||
Replace the `hsType` helper (lines ~904–909) and the whole `views.recon_handshakes` definition (lines ~911–952) in `www/js/views.js` with:
|
||||
|
||||
```js
|
||||
views.recon_handshakes = (root) => {
|
||||
root.appendChild(h('h1', { class: 'page-title', text: 'Recon' }));
|
||||
tabBar(root, RECON_TABS, '#/recon/handshakes');
|
||||
const box = h('div', { class: 'section recon-handshakes-card' });
|
||||
root.appendChild(box);
|
||||
|
||||
let flashTimer = null;
|
||||
function flash(ok, msg) {
|
||||
const head = box.querySelector('.recon-table-head');
|
||||
if (!head) return;
|
||||
const old = head.querySelector('.hs-flash');
|
||||
if (old) old.remove();
|
||||
const el = h('span', { class: 'hs-flash ' + (ok ? 'hs-flash-ok' : 'hs-flash-error'), text: msg });
|
||||
head.appendChild(el);
|
||||
clearTimeout(flashTimer);
|
||||
flashTimer = setTimeout(() => el.remove(), ok ? 3000 : 5000);
|
||||
}
|
||||
|
||||
function hsIconBtn(name, title, cls, onclk) {
|
||||
const b = h('button', { class: 'icon-btn ' + (cls || ''), title: title, onclick: onclk });
|
||||
b.innerHTML = PineappleIcons[name] || '';
|
||||
return b;
|
||||
}
|
||||
|
||||
function textCell(v) {
|
||||
return h('td', { class: 'mat-cell', text: v == null || v === '' ? '--' : String(v) });
|
||||
}
|
||||
|
||||
function naGlyph() {
|
||||
return h('td', { class: 'mat-cell hs-cell-center' },
|
||||
h('span', { class: 'hs-na',
|
||||
title: "This information isn't available. This is common when a handshake file has been found, but the associated Recon scan has been lost or deleted." },
|
||||
PineappleIcons.question_mark));
|
||||
}
|
||||
|
||||
function boolGlyph(v) {
|
||||
return h('td', { class: 'mat-cell hs-cell-center' },
|
||||
h('span', { class: v ? 'hs-ok' : 'hs-bad' }, v ? PineappleIcons.check : PineappleIcons.close));
|
||||
}
|
||||
|
||||
function msgCell(inDb, present) {
|
||||
return inDb ? boolGlyph(present) : naGlyph();
|
||||
}
|
||||
|
||||
function load(done) {
|
||||
PagerAPI.get('/api/pineap/handshakes').then((r) => {
|
||||
box.innerHTML = '';
|
||||
const head = h('div', { class: 'recon-table-head' },
|
||||
h('h2', { text: 'Captured WPA Handshakes' }),
|
||||
h('span', { class: 'toolbar-spacer' }),
|
||||
iconBtn('settings', 'Handshakes settings', openSettings));
|
||||
box.appendChild(head);
|
||||
box.appendChild(h('div', { class: 'row' },
|
||||
h('div', {}, btn('Download all (zip)', () => { window.location = App.apiBase + '/api/loot/zip'; })),
|
||||
h('div', {}, btn('Archive', () => PagerAPI.post('/api/loot/archive').then(() => App.toast('Archived')))),
|
||||
h('div', {}, btn('Refresh', () => load(), 'ghost'))));
|
||||
|
||||
const hs = r.data.handshakes || [];
|
||||
if (!hs.length) {
|
||||
box.appendChild(h('div', { class: 'empty', text: 'No Handshakes Available' }));
|
||||
if (done) done();
|
||||
return;
|
||||
}
|
||||
const t = h('table', { class: 'tbl' });
|
||||
const thead = h('thead');
|
||||
const th = h('tr');
|
||||
['BSSID', 'Client', 'Source', 'Type', 'Captured', 'Message 1', 'Message 2',
|
||||
'Message 3', 'Message 4', 'Beacon Frame', '']
|
||||
.forEach((c) => th.appendChild(h('th', { text: c })));
|
||||
thead.appendChild(th);
|
||||
t.appendChild(thead);
|
||||
const tb = h('tbody');
|
||||
hs.forEach((f) => {
|
||||
const trr = h('tr');
|
||||
trr.appendChild(textCell(f.mac));
|
||||
trr.appendChild(textCell(f.client));
|
||||
trr.appendChild(textCell(f.source));
|
||||
trr.appendChild(textCell(String(f.type).charAt(0).toUpperCase() + String(f.type).slice(1) + ' ' + hsType(f.name)));
|
||||
trr.appendChild(textCell(fmtTime(f.timestamp)));
|
||||
[1, 2, 4, 8].forEach((bit) => trr.appendChild(msgCell(f.in_db, (f.part_mask & bit) !== 0)));
|
||||
trr.appendChild(msgCell(f.in_db, !!f.beacon));
|
||||
const act = h('td', { class: 'mat-cell' },
|
||||
h('span', { class: 'hs-actions' },
|
||||
hsIconBtn('file_download', 'Download', '', () => {
|
||||
window.location = App.apiBase + '/api/pineap/handshakes/' + encodeURIComponent(f.name);
|
||||
}),
|
||||
hsIconBtn('delete', 'Delete', 'hs-warn', () => {
|
||||
PagerAPI.del('/api/pineap/handshakes', { name: f.name })
|
||||
.then(() => load(() => flash(true, 'Deleted ' + f.name)))
|
||||
.catch(() => flash(false, 'Failed to delete ' + f.name));
|
||||
})));
|
||||
trr.appendChild(act);
|
||||
tb.appendChild(trr);
|
||||
});
|
||||
t.appendChild(tb);
|
||||
box.appendChild(t);
|
||||
if (done) done();
|
||||
}).catch(() => flash(false, 'Failed to load handshakes'));
|
||||
}
|
||||
|
||||
function openSettings() {
|
||||
PagerAPI.get('/api/pineap/handshakes/location').then((r) => {
|
||||
const loc = (r.data || {}).location || '--';
|
||||
const overlay = h('div', { class: 'modal-overlay' });
|
||||
function close() { overlay.remove(); }
|
||||
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
||||
const modal = h('div', { class: 'modal' },
|
||||
h('div', { class: 'modal-title', text: 'Handshake Settings' }),
|
||||
h('div', { class: 'modal-body' },
|
||||
h('div', { class: 'hs-settings-row' },
|
||||
h('span', { class: 'hs-settings-label', text: 'Handshake Location' }),
|
||||
h('span', { class: 'hs-settings-value', text: loc })),
|
||||
btn('Delete All Handshakes', () => {
|
||||
PagerAPI.del('/api/pineap/handshakes/all')
|
||||
.then(() => { close(); load(() => flash(true, 'All handshakes deleted')); })
|
||||
.catch(() => { close(); flash(false, 'Failed to delete all handshakes'); });
|
||||
}, 'danger')),
|
||||
h('div', { class: 'modal-actions' }, btn('Close', close, 'ghost')));
|
||||
overlay.appendChild(modal);
|
||||
document.body.appendChild(overlay);
|
||||
}).catch(() => App.toast('Failed to load handshake settings', 'error'));
|
||||
}
|
||||
|
||||
load();
|
||||
return { destroy: () => {} };
|
||||
};
|
||||
```
|
||||
|
||||
Notes:
|
||||
- The old `hsType(name)` helper is still referenced (it maps `f.name` → `PCAP`/`Hashcat`/`Unknown`), so leave it in place.
|
||||
- `msgCell` renders a check/X/`?` glyph for Message 1–4 and Beacon Frame exactly like the Mark VII.
|
||||
- Delete is deliberately **no-confirm** to match the Mark VII; the flash + reload give feedback.
|
||||
|
||||
- [ ] **Step 2: Verify the view wiring**
|
||||
|
||||
Serve locally and log in:
|
||||
```powershell
|
||||
& .\scripts\dev.ps1 -Tunnel
|
||||
```
|
||||
Browse `http://127.0.0.1:8000/#/recon/handshakes` and confirm the page renders without console errors (empty state "No Handshakes Available" is expected with no captured handshakes). Check the settings gear opens the dialog showing the location and the Delete All button.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add payload/user/general/pager-webui/www/js/views.js
|
||||
git commit -m "feat: Mark VII handshakes table with per-row download/delete and settings dialog"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Deploy + on-device verification
|
||||
|
||||
**Files:**
|
||||
- None (deployment + manual smoke pass)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: all tasks 1–4.
|
||||
|
||||
- [ ] **Step 1: Deploy to the Pager**
|
||||
|
||||
Run (password auth via sshpass, per README):
|
||||
```powershell
|
||||
& .\scripts\deploy.ps1 -Password "<PAGER_PASSWORD>"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Restart the webui service**
|
||||
|
||||
```powershell
|
||||
sshpass -p "<PAGER_PASSWORD>" ssh -o StrictHostKeyChecking=no root@172.16.52.1 "/etc/init.d/pagerwebui restart"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Smoke test the API**
|
||||
|
||||
Log in and check the new shape + routes:
|
||||
```powershell
|
||||
$tok = (curl.exe -s -X POST http://172.16.52.1:8080/api/login -H "Content-Type: application/json" -d "{\"username\":\"root\",\"password\":\"<PAGER_PASSWORD>\"}" | ConvertFrom-Json).token
|
||||
curl.exe -s -b "AUTH_001337AEE050=$tok" http://172.16.52.1:8080/api/pineap/handshakes
|
||||
curl.exe -s -b "AUTH_001337AEE050=$tok" http://172.16.52.1:8080/api/pineap/handshakes/location
|
||||
```
|
||||
Expected: handshakes response has both `files` and `handshakes` keys; location returns `{"location":"/root/loot/handshakes"}`. (Use the `AUTH_<serverid>` cookie name the login actually sets — it is `AUTH_` + the returned `serverid`, e.g. `AUTH_001337AEE050`.)
|
||||
|
||||
- [ ] **Step 4: Browser verification checklist**
|
||||
|
||||
Browse `http://172.16.52.1:8080/#/recon/handshakes` and verify:
|
||||
- [ ] Table headers exactly: BSSID, Client, Source, Type, Captured, Message 1–4, Beacon Frame, action.
|
||||
- [ ] Zip-all / Archive / Refresh row still present and working.
|
||||
- [ ] With no captures: "No Handshakes Available".
|
||||
- [ ] With a capture (or a manually placed file `1786466650_C8-9E-43-64-80-80_AE-77-C0-EB-31-41_handshake.pcap` in `/root/loot/handshakes/` for testing): BSSID/Client/Source/Type/Captured render; M1–4/Beacon show ✓/✗ or `?`; download and delete work; delete flashes green; dark theme (`html.dark`) looks right.
|
||||
- [ ] Gear opens dialog with location + Delete All; Delete All empties the dir and reloads.
|
||||
- [ ] Dashboard (handshakes count + "Captured WPA Handshakes" table) still renders from `files`.
|
||||
|
||||
- [ ] **Step 5: Clean up test file + commit any fixes**
|
||||
|
||||
Remove any manually placed test file from `/root/loot/handshakes/`. If verification found defects, fix them in a new task-style cycle (test → fix → re-deploy), then commit the fixes.
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "fix: on-device verification adjustments"
|
||||
```
|
||||
(Only if there were fixes; otherwise skip.)
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage:**
|
||||
- §4.1 `handshakes_data()` shape + empty-dir skip + single batched read → Task 1.
|
||||
- §4.2 filename parser + fallback → Task 1 (parser + `_compose_hs` fallback branch).
|
||||
- §4.3 record composition (incl. `name` field) → Task 1.
|
||||
- §4.4 location + delete-all routes + ordering → Task 2.
|
||||
- §5.1 view rewrite (table, glyphs, download/delete, flash, empty state) → Task 4.
|
||||
- §5.2 settings dialog → Task 4.
|
||||
- §5.3 icons → Task 3.
|
||||
- §5.4 CSS → Task 3.
|
||||
- §6 compute mitigation → Task 1 (empty-dir skip), Task 1/4 (no polling).
|
||||
- §8 testing → Tasks 1–2, §5 smoke → Task 5.
|
||||
|
||||
**Placeholder scan:** No TBD/TODO/“similar to” — every code step contains full code.
|
||||
|
||||
**Type consistency:** `parse_hs_filename`, `_norm_mac`, `_hs_db_by_pair`, `_compose_hs`, `handshakes_data`, `h_handshakes_location`, `h_handshakes_delete_all` — names/return shapes consistent between Task 1, Task 2, and the view in Task 4 (`f.name`, `f.mac`, `f.client`, `f.type`, `f.timestamp`, `f.in_db`, `f.part_mask`, `f.beacon`). `hsType(f.name)` reuses the existing helper. `AUTH_<serverid>` cookie note in Task 5 matches server.py `h_login`.
|
||||
@@ -0,0 +1,199 @@
|
||||
# Open AP Tab — Mark VII "PineAP Settings" Card 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:** Restyle the PineAP Open AP tab (`#/pineap/open`) into the Mark VII "PineAP" settings card — titled card with subtitle, iOS-style slide switches grouped into sections, and a batched Mk7-style Save button — replacing the current flat checkbox list.
|
||||
|
||||
**Architecture:** Frontend-only. Rewrite `views.pineap_open` in `www/js/views.js` to build a `.pineap-card-settings` card: a `.pineap-card-title-flex` title row ("PineAP" + subtitle + Save button), three grouped sections of `.switch` slide toggles, and a muted footer. Toggles stage values locally (dirty flags); Save applies only the dirty fields batched per backend route (`set_config` for logging/capture, `enable`, `mimic`, `ssidpool/advertise`). Two new CSS classes in `app.css`. No backend changes.
|
||||
|
||||
**Tech Stack:** Vanilla JS (`h()`, `btn()`, `PagerAPI`, `App.toast`, `pineapShell`), the existing `.switch` slider CSS, existing `PagerAPI.post` routes.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- No backend changes. No changes to `server.py` or `tests/`.
|
||||
- Toggles must NOT call the API on change — they stage locally and mark dirty.
|
||||
- Save applies ONLY dirty fields; untouched fields keep their daemon state (preserves the unreadable live Karma state).
|
||||
- Batching per route: `set_config` fields (`loghandshake`, `logpartialhandshake`, `logpcap`, `logwigle`, `logrecon`, `autossidpool`) go in ONE `POST /api/pineap/set_config`; `pineap_disabled` → `POST /api/pineap/enable {enable}`; `karma` → `POST /api/pineap/mimic {enable}`; `advertise` → `POST /api/pineap/ssidpool/advertise {enable}`.
|
||||
- `load()` runs once on entry and after a successful Save; it must not clobber staged-but-unsaved values.
|
||||
- JS verification uses the Python delimiter-balance checker at `C:\Users\root\AppData\Local\Temp\opencode\js_balance.py` (no node available).
|
||||
- Python for the unittest loop: `$env:LOCALAPPDATA\Programs\Python\Python311\python.exe`.
|
||||
- Deploy: from repo root, `powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\deploy.ps1 -Password "<PAGER_PASSWORD>"`, then `/etc/init.d/pagerwebui restart` over sshpass.
|
||||
- Commit messages follow repo style (`feat:`, `fix:`, `docs:`).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Open AP tab Mk7 settings card (CSS + rewrite)
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/general/pager-webui/www/css/app.css` (append three classes)
|
||||
- Modify: `payload/user/general/pager-webui/www/js/views.js` (replace the whole `views.pineap_open` function, currently lines ~327-375)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `h(tag, attrs, ...children)`, `btn(label, onclk, cls)`, `PagerAPI.get/post`, `App.toast`, `pineapShell(root, hash)`, `tabBar`; existing `.switch`/`.track` CSS and `.pineap-*` layout classes from prior work.
|
||||
- Produces: `views.pineap_open` rendering the Mk7 settings card; `staged`/`dirty` maps; `save()` batched apply; `load()` populating switches without clobbering staged values.
|
||||
|
||||
- [ ] **Step 1: Append the three CSS classes to `app.css`**
|
||||
|
||||
Append to the end of `payload/user/general/pager-webui/www/css/app.css`:
|
||||
|
||||
```css
|
||||
/* ---- Open AP: Mark VII PineAP settings card ---- */
|
||||
.pineap-card-subtitle { color: var(--muted); font-size: 13px; margin: -8px 0 10px; }
|
||||
.pineap-settings-section { font-size: 13px; font-weight: 500; color: var(--muted); margin: 14px 0 4px; }
|
||||
.pineap-card-settings .switch { margin: 6px 0; }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace the whole `views.pineap_open` function in `views.js`**
|
||||
|
||||
Replace everything from `views.pineap_open = (root) => {` through the closing `};` of that function (current lines ~327-375, i.e. just before `const EVIL_ENC`) with:
|
||||
|
||||
```js
|
||||
views.pineap_open = (root) => {
|
||||
const box = pineapShell(root, '#/pineap/open');
|
||||
const wrap = h('div', { class: 'pineap-title-card pineap-card-settings' });
|
||||
wrap.appendChild(h('div', { class: 'pineap-card-title-flex' },
|
||||
h('span', { text: 'PineAP' }),
|
||||
h('span', { class: 'toolbar-spacer' }),
|
||||
btn('Save', save, '')));
|
||||
wrap.appendChild(h('div', { class: 'pineap-card-subtitle', text: 'Quickly set the general behavior of PineAP' }));
|
||||
|
||||
const staged = {};
|
||||
const dirty = {};
|
||||
const toggles = {};
|
||||
const groups = [
|
||||
['Karma', [
|
||||
['pineap_disabled', 'Enable PineAP'],
|
||||
['karma', 'Karma']
|
||||
]],
|
||||
['SSID Pool', [
|
||||
['autossidpool', 'Capture SSIDs to Pool'],
|
||||
['advertise', 'Advertise AP Impersonation Pool']
|
||||
]],
|
||||
['Logging', [
|
||||
['loghandshake', 'Log Handshakes'],
|
||||
['logpartialhandshake', 'Log Partial Handshakes'],
|
||||
['logpcap', 'Log PCAP'],
|
||||
['logwigle', 'Log WiGLE'],
|
||||
['logrecon', 'Log Recon']
|
||||
]]
|
||||
];
|
||||
groups.forEach(([section, items]) => {
|
||||
wrap.appendChild(h('div', { class: 'pineap-settings-section', text: section }));
|
||||
items.forEach(([k, label]) => {
|
||||
const cb = h('input', { type: 'checkbox', id: 'oap-' + k });
|
||||
toggles[k] = cb;
|
||||
cb.addEventListener('change', () => { staged[k] = cb.checked; dirty[k] = true; });
|
||||
wrap.appendChild(h('label', { class: 'switch' },
|
||||
cb, h('span', { class: 'track' }), ' ' + label));
|
||||
});
|
||||
});
|
||||
const info = h('div', { class: 'muted', style: 'margin-top:10px' });
|
||||
wrap.appendChild(info);
|
||||
box.appendChild(wrap);
|
||||
|
||||
function save() {
|
||||
const keys = Object.keys(dirty);
|
||||
if (!keys.length) { App.toast('No changes'); return; }
|
||||
const reqs = [];
|
||||
const setCfg = {};
|
||||
keys.forEach((k) => {
|
||||
if (k === 'pineap_disabled') reqs.push(PagerAPI.post('/api/pineap/enable', { enable: staged[k] }));
|
||||
else if (k === 'karma') reqs.push(PagerAPI.post('/api/pineap/mimic', { enable: staged[k] }));
|
||||
else if (k === 'advertise') reqs.push(PagerAPI.post('/api/pineap/ssidpool/advertise', { enable: staged[k] }));
|
||||
else setCfg[k] = staged[k];
|
||||
});
|
||||
if (Object.keys(setCfg).length) reqs.push(PagerAPI.post('/api/pineap/set_config', setCfg));
|
||||
Promise.allSettled(reqs).then((results) => {
|
||||
const ok = results.every((r) => r.status === 'fulfilled');
|
||||
App.toast(ok ? 'Settings saved' : 'Some settings failed', ok ? '' : 'error');
|
||||
Object.keys(dirty).forEach((k) => delete dirty[k]);
|
||||
load();
|
||||
});
|
||||
}
|
||||
|
||||
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 || {};
|
||||
if (!dirty.pineap_disabled) toggles.pineap_disabled.checked = !hh.pineap_disabled;
|
||||
if (!dirty.karma) toggles.karma.checked = !!c.mimic;
|
||||
['loghandshake', 'logpartialhandshake', 'logpcap', 'logwigle', 'logrecon', 'autossidpool']
|
||||
.forEach((k) => { if (!dirty[k]) toggles[k].checked = !!c[k]; });
|
||||
const pool = a.pool || {};
|
||||
if (!dirty.advertise) toggles.advertise.checked = pool.disabled === false;
|
||||
const o = a.open || {};
|
||||
info.textContent = 'PineAP MAC: ' + (o.bssid || '—') + ' Target MAC: ' + (o.target || '—');
|
||||
});
|
||||
}
|
||||
load();
|
||||
return { destroy: () => {} };
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the JS delimiter balance check**
|
||||
|
||||
Run: `& "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe" "C:\Users\root\AppData\Local\Temp\opencode\js_balance.py" "C:\Users\root\Documents\Pineapple\pager-webui\payload\user\general\pager-webui\www\js\views.js"`
|
||||
Expected: `...views.js: delimiter balance OK`
|
||||
|
||||
- [ ] **Step 4: Run the 13-module unittest loop (backend must stay green)**
|
||||
|
||||
Run from `C:\Users\root\Documents\Pineapple\pager-webui`:
|
||||
```powershell
|
||||
$py = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe"; Get-ChildItem tests\test_*.py | ForEach-Object { $mod = "tests." + [IO.Path]::GetFileNameWithoutExtension($_.Name); $out = & $py -m unittest $mod 2>&1; ($out | Select-Object -Last 1) }
|
||||
```
|
||||
Expected: every module reports `OK` (recon may show `skipped=1`).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add payload/user/general/pager-webui/www/css/app.css payload/user/general/pager-webui/www/js/views.js
|
||||
git commit -m "feat: Mark VII PineAP settings card for Open AP tab with batched save"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Deploy and verify on device
|
||||
|
||||
**Files:** none (verification only; no commit).
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1 output (deployed via `scripts/deploy.ps1`).
|
||||
|
||||
- [ ] **Step 1: Deploy the payload and restart the webui**
|
||||
|
||||
Run from `C:\Users\root\Documents\Pineapple\pager-webui`:
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\deploy.ps1 -Password "<PAGER_PASSWORD>"
|
||||
```
|
||||
Then restart and confirm the port is up (over sshpass SSH):
|
||||
```bash
|
||||
sshpass -p "<PAGER_PASSWORD>" ssh root@172.16.52.1 "/etc/init.d/pagerwebui restart; sleep 4; curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/api/api_ping"
|
||||
```
|
||||
Expected: `401` (auth required = running).
|
||||
|
||||
- [ ] **Step 2: Confirm the deployed files contain the new code**
|
||||
|
||||
```bash
|
||||
sshpass -p "<PAGER_PASSWORD>" ssh root@172.16.52.1 "grep -c 'pineap-settings-section' /root/payloads/user/general/pager-webui/www/css/app.css; grep -c 'Quickly set the general behavior of PineAP' /root/payloads/user/general/pager-webui/www/js/views.js"
|
||||
```
|
||||
Expected: both counts greater than zero.
|
||||
|
||||
- [ ] **Step 3: On-device save-path check (read-only + one harmless write/restore)**
|
||||
|
||||
Over sshpass SSH, base64 a script and run it via `echo ... | base64 -d | sh`:
|
||||
```sh
|
||||
curl -s -c /tmp/pwj -X POST http://127.0.0.1:8080/api/login -H "Content-Type: application/json" -d '{"username":"root","password":"<PAGER_PASSWORD>"}' > /dev/null
|
||||
echo before:; curl -s -b /tmp/pwj http://127.0.0.1:8080/api/pineap/get_config | grep -o '"logrecon":[a-z]*'
|
||||
curl -s -b /tmp/pwj -X POST http://127.0.0.1:8080/api/pineap/set_config -d '{"logrecon":true}' > /dev/null
|
||||
echo after-set-true:; curl -s -b /tmp/pwj http://127.0.0.1:8080/api/pineap/get_config | grep -o '"logrecon":[a-z]*'
|
||||
curl -s -b /tmp/pwj -X POST http://127.0.0.1:8080/api/pineap/set_config -d '{"logrecon":false}' > /dev/null
|
||||
echo restored:; curl -s -b /tmp/pwj http://127.0.0.1:8080/api/pineap/get_config | grep -o '"logrecon":[a-z]*'
|
||||
```
|
||||
Expected: `before` shows the current `logrecon` value, `after-set-true` shows `true`, `restored` shows the original value (verifies the batched `set_config` merge path the Save button uses).
|
||||
|
||||
- [ ] **Step 4: Report for user UI walk**
|
||||
|
||||
Tell the user the Open AP tab is now the Mk7 "PineAP" settings card: title + subtitle "Quickly set the general behavior of PineAP", grouped iOS-style slide switches (Karma / SSID Pool / Logging), and a Save button that batches only the changed toggles (unchanged Karma is left untouched). Ask them to refresh `http://172.16.52.1:8080/#/pineap/open`, flip a logging toggle, Save, and confirm the toast + that `get_config` reflects the change.
|
||||
@@ -0,0 +1,508 @@
|
||||
# Mk7 "PineAP Open Access Point" Card 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:** Rebuild the Open AP tab (`#/pineap/open`, `views.pineap_open`) into the genuine Mark 7 Pineapple's "PineAP Open Access Point" card — Open SSID / BSSID / Channel / Current Country / Hidden / Respond-to-all-probes toggles, filter notice boxes, and a Save that writes real device config.
|
||||
|
||||
**Architecture:** Backend extends the two existing `wifi/get_ap` + `wifi/set_ap` handlers to expose and persist the Open AP's SSID, BSSID (`macaddr`), hidden, channel, and country (channel/country applied to `wireless.radio0` with a `wifi reload`). Frontend replaces `views.pineap_open` with the Mk7 card; karma ("Respond to all probe requests") saves via the existing `/api/pineap/mimic` and is session-tracked (the daemon cannot report karma). Filter notice boxes reuse the existing `action`-based filter API.
|
||||
|
||||
**Tech Stack:** Python (`server.py`, unittest), vanilla JS (`views.js`, `app.css`), existing `h()`/`btn()`/`PagerAPI`/`App.toast` helpers.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Backend is UCI-driven (`_uci_wifi_iface`, `_uci_section`, `device_run`); the daemon's `/api/settings/wifi/set_ap` persists `ssid`/`hidden`/`bssid`→`macaddr`/`channel`/`enabled` to `wireless.wlan0open` but its iface-level `channel` write is **inert** for the actual radio — the radio channel/country must be written to `wireless.radio0` + `uci commit wireless` + `wifi reload`.
|
||||
- The daemon exposes **no readable karma/mimic or broadcast/advertise state** — the karma toggle is session-tracked (module-level flag, default off, updated on Save); the info line omits the "Spoofed SSID Pool will be advertised" clause.
|
||||
- Open AP `enabled` is preserved as-is (the Mk7 card has no Enabled control); the frontend always sends `enabled` = value loaded from `get_ap`.
|
||||
- Filter mutations use the existing `action` API: `POST /api/pineap/filters/ssid` `{action:'add'|'delete', value}` and `POST /api/pineap/filters/client` `{action:'set_mode', mode:'deny'}`.
|
||||
- No new routes; no daemon changes. Commit messages follow repo style (`feat:`, `fix:`, `docs:`).
|
||||
- JS verification uses `C:\Users\root\AppData\Local\Temp\opencode\js_balance.py` (no node available). Python: `$env:LOCALAPPDATA\Programs\Python\Python311\python.exe`.
|
||||
- Deploy: from repo root, `powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\deploy.ps1 -Password "<PAGER_PASSWORD>"`, then `/etc/init.d/pagerwebui restart` over sshpass.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Backend — expose and save Open AP network settings
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/general/pager-webui/server.py` (`h_pineap_wifi_get_ap` at ~1479, `h_pineap_wifi_set_ap` at ~1505, add helper `_apply_open_radio` just before `h_pineap_wifi_set_ap`)
|
||||
- Test: `tests/test_pineap_proxy.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `_uci_wifi_iface(name)` (runs `uci show wireless.<name>`, returns dict), `_uci_section(section)`, `device_run(args)`, `daemon_sock_call(method, path, body)`.
|
||||
- Produces: `h_pineap_wifi_get_ap` `open` payload now `{enabled, ssid, bssid, target, hidden, channel, country}`; `h_pineap_wifi_set_ap` accepts `open: {ssid, bssid, hidden, enabled, channel, country}`. Task 2 consumes these exact field names.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests** (extend `test_wifi_get_ap_reads_uci_wireless`; add a new set_ap test)
|
||||
|
||||
Replace the `test_wifi_get_ap_reads_uci_wireless` body's `fake_run` with the version below and add the new assertions; append `test_wifi_set_ap_open_bssid_channel_and_country` to the `PineapProxyTest` class:
|
||||
|
||||
```python
|
||||
def test_wifi_get_ap_reads_uci_wireless(self):
|
||||
def fake_run(args):
|
||||
cmd = args[0]
|
||||
if cmd == 'uci' and len(args) == 3:
|
||||
sec = args[2]
|
||||
if sec == 'wireless.wlan0wpa':
|
||||
return 0, "wireless.wlan0wpa.ifname='wlan0wpa'\nwireless.wlan0wpa.ssid='Evil1'\nwireless.wlan0wpa.encryption='psk2'\nwireless.wlan0wpa.key='sekret'\nwireless.wlan0wpa.disabled='0'\nwireless.wlan0wpa.hidden='0'\n", ''
|
||||
if sec == 'wireless.wlan0open':
|
||||
return 0, "wireless.wlan0open.disabled='1'\nwireless.wlan0open.ssid='pager-open'\nwireless.wlan0open.macaddr='DE:AD:BE:EF:00:01'\nwireless.wlan0open.hidden='1'\n", ''
|
||||
if sec == 'wireless.radio0':
|
||||
return 0, "wireless.radio0.channel='6'\nwireless.radio0.country='US'\n", ''
|
||||
if sec.startswith('pineapd.@ssidpool'):
|
||||
return 0, "pineapd.@ssidpool[0].bssid='auto'\npineapd.@ssidpool[0].target='broadcast'\n", ''
|
||||
return 0, '', ''
|
||||
|
||||
def fake_sock(method, path, body=None, timeout=10):
|
||||
if path == '/api/pineap/hostapd/get_config':
|
||||
return 200, {'pineape_disabled': False}
|
||||
if path == '/api/pineap/get_config':
|
||||
return 200, {'autossidpool': True}
|
||||
return 200, {}
|
||||
|
||||
server.device_run = fake_run
|
||||
server.daemon_sock_call = fake_sock
|
||||
status, payload = server.h_pineap_wifi_get_ap(ctx())
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(payload['wpa'], {'ssid': 'Evil1', 'passphrase': 'sekret', 'enctype': 'psk2',
|
||||
'hidden': False, 'enabled': True})
|
||||
self.assertEqual(payload['open']['enabled'], False)
|
||||
self.assertEqual(payload['open']['ssid'], 'pager-open')
|
||||
self.assertEqual(payload['open']['bssid'], 'DE:AD:BE:EF:00:01')
|
||||
self.assertEqual(payload['open']['hidden'], True)
|
||||
self.assertEqual(payload['open']['channel'], 6)
|
||||
self.assertEqual(payload['open']['country'], 'US')
|
||||
self.assertEqual(payload['open']['target'], 'broadcast')
|
||||
self.assertEqual(payload['enterprise']['enabled'], True)
|
||||
self.assertEqual(payload['pool']['collecting'], True)
|
||||
|
||||
def test_wifi_set_ap_open_bssid_channel_and_country(self):
|
||||
sock_calls = []
|
||||
run_calls = []
|
||||
|
||||
def fake_sock(method, path, body=None, timeout=10):
|
||||
sock_calls.append((method, path, body))
|
||||
return (200, {'success': True})
|
||||
|
||||
def fake_run(args):
|
||||
run_calls.append(args)
|
||||
if args[0] == 'uci' and args[1] == 'show':
|
||||
return 0, "wireless.radio0.channel='1'\n", ''
|
||||
return 0, '', ''
|
||||
|
||||
server.daemon_sock_call = fake_sock
|
||||
server.device_run = fake_run
|
||||
status, _ = server.h_pineap_wifi_set_ap(ctx({'open': {
|
||||
'ssid': 'Open', 'bssid': 'DE:AD:BE:EF:00:02', 'hidden': True,
|
||||
'channel': 6, 'country': 'US', 'enabled': True}}))
|
||||
self.assertEqual(status, 200)
|
||||
method, path, body = sock_calls[0]
|
||||
self.assertEqual(method, 'PUT')
|
||||
self.assertEqual(path, '/api/settings/wifi/set_ap')
|
||||
conf = body['configs'][0]
|
||||
self.assertEqual(conf['interface'], 'wlan0open')
|
||||
self.assertEqual(conf['ssid'], 'Open')
|
||||
self.assertEqual(conf['bssid'], 'DE:AD:BE:EF:00:02')
|
||||
self.assertEqual(conf['hidden'], True)
|
||||
self.assertEqual(conf['channel'], 6)
|
||||
self.assertEqual(conf['enabled'], True)
|
||||
sets = [a for a in run_calls if a[:2] == ['uci', 'set']]
|
||||
self.assertEqual(sets, [['uci', 'set', 'wireless.radio0.channel=6'],
|
||||
['uci', 'set', 'wireless.radio0.country=US']])
|
||||
self.assertIn(['uci', 'commit', 'wireless'], run_calls)
|
||||
self.assertIn(['wifi', 'reload'], run_calls)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests to verify they fail**
|
||||
|
||||
Run: `& "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe" -m unittest tests.test_pineap_proxy`
|
||||
Expected: both tests FAIL (get_ap returns no `ssid`/`bssid`/`hidden`/`channel`/`country`; set_ap drops `bssid`/`channel` and never runs `uci set wireless.radio0.*`).
|
||||
|
||||
- [ ] **Step 3: Implement the backend changes**
|
||||
|
||||
In `server.py`:
|
||||
|
||||
`h_pineap_wifi_get_ap` — add the radio read and the new open fields (replace the current `open_cfg = _uci_wifi_iface('wlan0open')` line block and the `'open'` dict):
|
||||
|
||||
```python
|
||||
def h_pineap_wifi_get_ap(ctx):
|
||||
open_cfg = _uci_wifi_iface('wlan0open')
|
||||
radio_cfg = _uci_wifi_iface('radio0')
|
||||
wpa_cfg = _uci_wifi_iface('wlan0wpa')
|
||||
status, data = daemon_sock_call('GET', '/api/pineap/hostapd/get_config')
|
||||
host = data if status == 200 and isinstance(data, dict) else {}
|
||||
status2, data2 = daemon_sock_call('GET', '/api/pineap/get_config')
|
||||
pinecfg = data2 if status2 == 200 and isinstance(data2, dict) else {}
|
||||
pool = _uci_section('pineapd.@ssidpool[0]')
|
||||
channel = radio_cfg.get('channel') or ''
|
||||
try:
|
||||
channel = int(channel)
|
||||
except (TypeError, ValueError):
|
||||
channel = None
|
||||
return 200, {
|
||||
'open': {
|
||||
'enabled': open_cfg.get('disabled') == '0',
|
||||
'ssid': open_cfg.get('ssid') or '',
|
||||
'bssid': open_cfg.get('macaddr') or '',
|
||||
'target': pool.get('target') or None,
|
||||
'hidden': open_cfg.get('hidden') == '1',
|
||||
'channel': channel,
|
||||
'country': radio_cfg.get('country') or '',
|
||||
},
|
||||
'wpa': {
|
||||
'ssid': wpa_cfg.get('ssid') or '',
|
||||
'passphrase': wpa_cfg.get('key') or '',
|
||||
'enctype': wpa_cfg.get('encryption') or '',
|
||||
'hidden': wpa_cfg.get('hidden') == '1',
|
||||
'enabled': wpa_cfg.get('disabled') == '0',
|
||||
},
|
||||
'enterprise': {'enabled': not host.get('pineape_disabled', True)},
|
||||
'pool': {'disabled': None, 'collecting': bool(pinecfg.get('autossidpool'))},
|
||||
}
|
||||
```
|
||||
|
||||
Add this helper immediately before `h_pineap_wifi_set_ap`:
|
||||
|
||||
```python
|
||||
def _apply_open_radio(openap):
|
||||
"""Persist the Open AP's radio channel/country to wireless.radio0. The
|
||||
daemon's iface-level channel write does not affect the actual radio, so
|
||||
apply channel/country here and reload wifi when they change."""
|
||||
changed = False
|
||||
for key in ('channel', 'country'):
|
||||
value = openap.get(key)
|
||||
if value is None:
|
||||
continue
|
||||
current = _uci_wifi_iface('radio0').get(key) or ''
|
||||
if str(value) != current:
|
||||
device_run(['uci', 'set', 'wireless.radio0.%s=%s' % (key, value)])
|
||||
changed = True
|
||||
if changed:
|
||||
device_run(['uci', 'commit', 'wireless'])
|
||||
device_run(['wifi', 'reload'])
|
||||
```
|
||||
|
||||
`h_pineap_wifi_set_ap` — replace the open branch and add the call after the daemon call (replace the current `'channel': 1` open config and the `return 200, {'ok': True}` line):
|
||||
|
||||
```python
|
||||
if openap.get('ssid') or openap.get('enabled') is not None:
|
||||
configs.append({
|
||||
'interface': 'wlan0open',
|
||||
'ssid': openap.get('ssid', ''),
|
||||
'enctype': 'none',
|
||||
'enabled': bool(openap.get('enabled', True)),
|
||||
'hidden': bool(openap.get('hidden', False)),
|
||||
'channel': int(openap['channel']) if openap.get('channel') is not None else 1,
|
||||
'bssid': openap.get('bssid') or '',
|
||||
})
|
||||
if not configs:
|
||||
return 400, {'error': 'no configuration provided'}
|
||||
status, data = daemon_sock_call('PUT', '/api/settings/wifi/set_ap', body={'configs': configs}, timeout=45)
|
||||
if status != 200:
|
||||
return (502 if status == 0 else status), {'error': 'daemon failed', 'detail': data}
|
||||
_apply_open_radio(openap)
|
||||
return 200, {'ok': True}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the tests to verify they pass**
|
||||
|
||||
Run: `& "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe" -m unittest tests.test_pineap_proxy`
|
||||
Expected: all PASS.
|
||||
|
||||
- [ ] **Step 5: Run the full unittest loop**
|
||||
|
||||
Run from repo root:
|
||||
```powershell
|
||||
$py = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe"; Get-ChildItem tests\test_*.py | ForEach-Object { $mod = "tests." + [IO.Path]::GetFileNameWithoutExtension($_.Name); $out = & $py -m unittest $mod 2>&1; ($out | Select-Object -Last 1) }
|
||||
```
|
||||
Expected: every module reports `OK` (recon may show `skipped=1`).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add payload/user/general/pager-webui/server.py tests/test_pineap_proxy.py
|
||||
git commit -m "feat: expose and save Open AP ssid/bssid/hidden/channel/country"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Frontend — Mk7 "PineAP Open Access Point" card
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/general/pager-webui/www/js/views.js` (add `OPEN_CHANNELS`/`OPEN_COUNTRIES` constants + `let OPEN_KARMA` before `views.pineap_open`; replace the whole `views.pineap_open` function, currently lines 327-409, just before `const EVIL_ENC`)
|
||||
- Modify: `payload/user/general/pager-webui/www/css/app.css` (append infobox styles at end)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1's `open` fields (`ssid`, `bssid`, `hidden`, `channel`, `country`, `enabled`) from `POST /api/pineap/wifi/get_ap`; `set_ap` `open` body keys; existing `/api/pineap/mimic`, `/api/pineap/get_config`, `GET /api/pineap/filters/{ssid,client}` → `{mode, entries}`; existing `action` filter mutations.
|
||||
- Produces: the Mk7 Open card. No later task consumes these names.
|
||||
|
||||
- [ ] **Step 1: Add the constants and module state before `views.pineap_open`**
|
||||
|
||||
Insert immediately before `views.pineap_open = (root) => {`:
|
||||
|
||||
```js
|
||||
const OPEN_CHANNELS = Array.from({ length: 11 }, (_, i) => {
|
||||
const c = i + 1;
|
||||
return [c, 'Channel ' + c + ' (' + (2412 + (c - 1) * 5) + ' MHz)'];
|
||||
});
|
||||
const OPEN_COUNTRIES = [
|
||||
['US', 'United States'], ['DZ', 'Algeria'], ['AR', 'Argentina'], ['AU', 'Australia'],
|
||||
['AT', 'Austria'], ['BH', 'Bahrain'], ['BM', 'Bermuda'], ['BO', 'Bolivia'], ['BR', 'Brazil'],
|
||||
['BG', 'Bulgaria'], ['CA', 'Canada'], ['CL', 'Chile'], ['CN', 'China'], ['CO', 'Colombia'],
|
||||
['CR', 'Costa Rica'], ['CS', 'Cyprus'], ['CZ', 'Czech Republic'], ['DK', 'Denmark'],
|
||||
['DO', 'Dominican Republic'], ['EC', 'Ecuador'], ['EG', 'Egypt'], ['SV', 'El Salvador'],
|
||||
['EE', 'Estonia'], ['FI', 'Finland'], ['FR', 'France'], ['DE', 'Germany'], ['GR', 'Greece'],
|
||||
['GT', 'Guatemala'], ['HN', 'Honduras'], ['HK', 'Hong Kong'], ['IS', 'Iceland'], ['IN', 'India'],
|
||||
['ID', 'Indonesia'], ['IE', 'Ireland'], ['PK', 'Islamic Republic of Pakistan'], ['IL', 'Israel'],
|
||||
['IT', 'Italy'], ['JM', 'Jamaica'], ['JO', 'Jordan'], ['KE', 'Kenya'], ['KW', 'Kuwait'],
|
||||
['LB', 'Lebanon'], ['LI', 'Liechtenstein'], ['LT', 'Lithuania'], ['LU', 'Luxembourg'],
|
||||
['MU', 'Mauritius'], ['MX', 'Mexico'], ['MA', 'Morocco'], ['NL', 'Netherlands'], ['NZ', 'New Zealand'],
|
||||
['NO', 'Norway'], ['OM', 'Oman'], ['PA', 'Panama'], ['PE', 'Peru'], ['PH', 'Philippines'],
|
||||
['PL', 'Poland'], ['PT', 'Portuagal'], ['PR', 'Puerto Rico'], ['QA', 'Qatar'],
|
||||
['KR', 'Republic of Korea (South Korea)'], ['RO', 'Romania'], ['RU', 'Russia'], ['SA', 'Saudi Arabia'],
|
||||
['SG', 'Singapore'], ['SI', 'Slovenia'], ['SK', 'Slovak Republic'], ['ZA', 'South Africa'],
|
||||
['ES', 'Spain'], ['LK', 'Sri Lanka'], ['SE', 'Sweden'], ['CH', 'Switzerland'], ['TW', 'Taiwan'],
|
||||
['TH', 'Thailand'], ['TT', 'Trinidad and Tobago'], ['TN', 'Tunisia'], ['TR', 'Turkey'],
|
||||
['UA', 'Ukraine'], ['AE', 'United Arab Emirates'], ['GB', 'United Kingdom'], ['UY', 'Uraguay'],
|
||||
['VE', 'Venezuela'], ['VN', 'Vietnam']
|
||||
];
|
||||
let OPEN_KARMA = false;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace the whole `views.pineap_open` function**
|
||||
|
||||
Replace everything from `views.pineap_open = (root) => {` through its closing `};` (current lines 327-409, i.e. just before `const EVIL_ENC`) with:
|
||||
|
||||
```js
|
||||
views.pineap_open = (root) => {
|
||||
const box = pineapShell(root, '#/pineap/open');
|
||||
const card = h('div', { class: 'pineap-title-card' });
|
||||
box.appendChild(card);
|
||||
|
||||
card.appendChild(h('div', { class: 'pineap-card-title' }, 'PineAP Open Access Point'));
|
||||
const subtitle = h('div', { class: 'pineap-card-subtitle' });
|
||||
card.appendChild(subtitle);
|
||||
|
||||
const ssidIn = h('input', { id: 'oa-ssid' });
|
||||
const bssidIn = h('input', { id: 'oa-bssid' });
|
||||
const chSel = h('select', { id: 'oa-channel' });
|
||||
OPEN_CHANNELS.forEach(([v, l]) => chSel.appendChild(h('option', { value: v, text: l })));
|
||||
const coSel = h('select', { id: 'oa-country' });
|
||||
OPEN_COUNTRIES.forEach(([v, l]) => coSel.appendChild(h('option', { value: v, text: l })));
|
||||
const hiddenCb = h('input', { type: 'checkbox', id: 'oa-hidden' });
|
||||
const karmaCb = h('input', { type: 'checkbox', id: 'oa-karma' });
|
||||
|
||||
card.appendChild(h('div', { class: 'row' },
|
||||
h('div', {}, h('label', {}, 'Open SSID', ssidIn)),
|
||||
h('div', {}, h('label', {}, 'BSSID', bssidIn))));
|
||||
card.appendChild(h('div', { class: 'row' },
|
||||
h('div', {}, h('label', {}, 'Channel', chSel)),
|
||||
h('div', {}, h('label', {}, 'Current Country', coSel))));
|
||||
card.appendChild(h('div', { class: 'row' },
|
||||
h('div', {}, h('label', { class: 'switch' }, hiddenCb, h('span', { class: 'track' }), ' Hidden')),
|
||||
h('div', {}, h('label', { class: 'switch' }, karmaCb, h('span', { class: 'track' }), ' Respond to all probe requests (impersonate all networks)'))));
|
||||
|
||||
const info = h('div', { class: 'muted', style: 'margin-top:10px;font-size:13px' });
|
||||
card.appendChild(info);
|
||||
const boxes = h('div', {});
|
||||
card.appendChild(boxes);
|
||||
card.appendChild(h('div', { class: 'row', style: 'margin-top:10px' },
|
||||
h('div', {}, btn('Save', save)),
|
||||
h('div', { class: 'muted', style: 'align-self:center;font-size:12px' }, 'Applying reconfigures the radio — you may be disconnected briefly.')));
|
||||
|
||||
const state = {};
|
||||
|
||||
function cfgLink() {
|
||||
return h('a', { href: '#/pineap/filtering', style: 'color:var(--primary);cursor:pointer', onclick: (e) => { e.preventDefault(); App.go('#/pineap/filtering'); } }, 'filter configuration');
|
||||
}
|
||||
function filterBtn() {
|
||||
return h('a', { class: 'btn', href: '#/pineap/filtering', style: 'text-decoration:none;display:inline-block', onclick: (e) => { e.preventDefault(); App.go('#/pineap/filtering'); } }, 'Change Filters');
|
||||
}
|
||||
function infobox(severity, text, ...actions) {
|
||||
return h('div', { class: 'pineap-infobox ' + severity },
|
||||
h('span', { text }),
|
||||
h('div', { class: 'pineap-infobox-actions' }, actions));
|
||||
}
|
||||
function filterSentence(sm, cm) {
|
||||
if (sm === 'allow' && cm === 'allow') return 'any client in the filter configuration may connect to any SSID in the filter configuration.';
|
||||
if (sm === 'deny' && cm === 'allow') return 'any client not in the filter configuration may connect to any SSID in the filter configuration.';
|
||||
if (sm === 'allow' && cm === 'deny') return 'any client in the filter configuration may connect to any SSID not in the filter configuration.';
|
||||
return 'any client not in the filter configuration may connect to any SSID not in the filter configuration.';
|
||||
}
|
||||
|
||||
function save() {
|
||||
Promise.allSettled([
|
||||
PagerAPI.post('/api/pineap/wifi/set_ap', {
|
||||
open: {
|
||||
ssid: ssidIn.value,
|
||||
bssid: bssidIn.value.trim(),
|
||||
hidden: hiddenCb.checked,
|
||||
enabled: !!state.enabled,
|
||||
channel: chSel.value ? parseInt(chSel.value, 10) : null,
|
||||
country: coSel.value
|
||||
}
|
||||
}),
|
||||
PagerAPI.post('/api/pineap/mimic', { enable: karmaCb.checked })
|
||||
]).then((results) => {
|
||||
const ok = results.every((r) => r.status === 'fulfilled');
|
||||
OPEN_KARMA = karmaCb.checked;
|
||||
App.toast(ok ? 'Open AP saved' : 'Some settings failed', ok ? '' : 'error');
|
||||
load();
|
||||
});
|
||||
}
|
||||
|
||||
function render() {
|
||||
const sm = state.ssidMode || 'deny';
|
||||
const cm = state.clientMode || 'deny';
|
||||
subtitle.textContent = '';
|
||||
subtitle.appendChild(document.createTextNode('The Open SSID is advertised without encryption. When client association is enabled, '));
|
||||
subtitle.appendChild(cfgLink());
|
||||
subtitle.appendChild(document.createTextNode(' ' + filterSentence(sm, cm)));
|
||||
|
||||
const hidden = hiddenCb.checked;
|
||||
const karma = karmaCb.checked;
|
||||
let t = 'The Open access point will be ' + (hidden ? 'hidden' : 'advertised');
|
||||
if (!karma) {
|
||||
t += '.';
|
||||
} else {
|
||||
if (sm === 'allow' && cm === 'allow') t += ', and clients in the allowed client filter list will be able to connect to any SSID in the allowed SSID filter.';
|
||||
else if (sm === 'allow' && cm === 'deny') t += ', and clients in the allowed client filter list will be able to connect to any SSID not blocked by the SSID filter.';
|
||||
else if (sm === 'deny' && cm === 'allow') t += ', and clients not in the denied client filter list will be able to connect to any SSID in the allowed SSID filter.';
|
||||
else t += ', and clients not in the denied client filter list will be able to connect to any SSID not blocked by the SSID filter.';
|
||||
}
|
||||
info.textContent = t;
|
||||
|
||||
boxes.innerHTML = '';
|
||||
const openSsid = ssidIn.value;
|
||||
const ssidList = state.ssidList || [];
|
||||
const clientList = state.clientList || [];
|
||||
if (state.ssidFetched && sm === 'allow' && openSsid && ssidList.indexOf(openSsid) === -1) {
|
||||
boxes.appendChild(infobox('error',
|
||||
'The open SSID "' + openSsid + '" is not included in the filter allow list, clients will not be able to connect.',
|
||||
btn('Add Allowed', () => PagerAPI.post('/api/pineap/filters/ssid', { action: 'add', value: openSsid }).then(load).catch(() => App.toast('Failed', 'error')))));
|
||||
}
|
||||
if (state.ssidFetched && sm === 'deny' && openSsid && ssidList.indexOf(openSsid) !== -1) {
|
||||
boxes.appendChild(infobox('error',
|
||||
'The open SSID "' + openSsid + '" is included in the filter deny list, clients will not be able to connect.',
|
||||
btn('Remove Filter', () => PagerAPI.post('/api/pineap/filters/ssid', { action: 'delete', value: openSsid }).then(load).catch(() => App.toast('Failed', 'error')))));
|
||||
}
|
||||
if (sm === 'allow' && ssidList.length > 0 && karmaCb.checked) {
|
||||
boxes.appendChild(infobox('info',
|
||||
'Remember to add SSIDs you wish to impersonate to the PineAP SSID filter, or change to "Deny" mode to allow responding to all requested networks!',
|
||||
filterBtn()));
|
||||
}
|
||||
if (state.clientFetched && cm === 'allow' && clientList.length === 0) {
|
||||
boxes.appendChild(infobox('error',
|
||||
'The PineAP Client filter is set to "allow", but no clients are listed; no clients will be able to connect!',
|
||||
btn('Change Mode', () => PagerAPI.post('/api/pineap/filters/client', { action: 'set_mode', mode: 'deny' }).then(load).catch(() => App.toast('Failed', 'error'))),
|
||||
filterBtn()));
|
||||
}
|
||||
}
|
||||
|
||||
function load() {
|
||||
Promise.all([
|
||||
PagerAPI.post('/api/pineap/wifi/get_ap').catch(() => ({ data: {} })),
|
||||
PagerAPI.get('/api/pineap/get_config').catch(() => ({ data: {} })),
|
||||
PagerAPI.get('/api/pineap/filters/ssid').catch(() => ({ data: {} })),
|
||||
PagerAPI.get('/api/pineap/filters/client').catch(() => ({ data: {} }))
|
||||
]).then(([ap, cfg, sf, cf]) => {
|
||||
const a = ap.data || {}, c = cfg.data || {};
|
||||
const open = a.open || {};
|
||||
ssidIn.value = open.ssid || '';
|
||||
bssidIn.value = open.bssid || '';
|
||||
if (open.channel != null) chSel.value = String(open.channel);
|
||||
if (open.country) coSel.value = open.country;
|
||||
hiddenCb.checked = !!open.hidden;
|
||||
state.enabled = !!open.enabled;
|
||||
karmaCb.checked = OPEN_KARMA;
|
||||
const sd = sf.data || {}, cd = cf.data || {};
|
||||
state.ssidFetched = !!sd.mode;
|
||||
state.clientFetched = !!cd.mode;
|
||||
state.ssidMode = sd.mode;
|
||||
state.clientMode = cd.mode;
|
||||
state.ssidList = sd.entries || [];
|
||||
state.clientList = cd.entries || [];
|
||||
render();
|
||||
});
|
||||
}
|
||||
load();
|
||||
return { destroy: () => {} };
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Append the infobox CSS to `app.css`**
|
||||
|
||||
Append to the end of `payload/user/general/pager-webui/www/css/app.css`:
|
||||
|
||||
```css
|
||||
/* ---- Open AP: Mk7 filter notice boxes ---- */
|
||||
.pineap-infobox { border-radius: 2px; padding: 10px 12px; margin-top: 10px; font-size: 13px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.pineap-infobox.error { background: #fdecea; color: #b71c1c; border: 1px solid #f5c6cb; }
|
||||
.pineap-infobox.info { background: #e3f2fd; color: #0d47a1; border: 1px solid #90caf9; }
|
||||
.pineap-infobox-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
html.dark .pineap-infobox.error { background: #4a2020; color: #ffb4a9; border-color: #6b2d2d; }
|
||||
html.dark .pineap-infobox.info { background: #10263a; color: #9cc7f0; border-color: #1d3a54; }
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the JS delimiter balance check**
|
||||
|
||||
Run: `& "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe" "C:\Users\root\AppData\Local\Temp\opencode\js_balance.py" "C:\Users\root\Documents\Pineapple\pager-webui\payload\user\general\pager-webui\www\js\views.js"`
|
||||
Expected: `...views.js: delimiter balance OK`
|
||||
|
||||
- [ ] **Step 5: Run the full unittest loop (backend must stay green)**
|
||||
|
||||
Run from repo root:
|
||||
```powershell
|
||||
$py = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe"; Get-ChildItem tests\test_*.py | ForEach-Object { $mod = "tests." + [IO.Path]::GetFileNameWithoutExtension($_.Name); $out = & $py -m unittest $mod 2>&1; ($out | Select-Object -Last 1) }
|
||||
```
|
||||
Expected: every module reports `OK` (recon may show `skipped=1`).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add payload/user/general/pager-webui/www/js/views.js payload/user/general/pager-webui/www/css/app.css
|
||||
git commit -m "feat: Mk7 PineAP Open Access Point card for Open AP tab"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Deploy and verify on device
|
||||
|
||||
**Files:** none (verification only; no commit).
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Tasks 1-2 output (deployed via `scripts/deploy.ps1`).
|
||||
|
||||
- [ ] **Step 1: Deploy the payload and restart the webui**
|
||||
|
||||
Run from `C:\Users\root\Documents\Pineapple\pager-webui`:
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\deploy.ps1 -Password "<PAGER_PASSWORD>"
|
||||
```
|
||||
Then restart and confirm the port is up (over sshpass SSH):
|
||||
```bash
|
||||
sshpass -p "<PAGER_PASSWORD>" ssh root@172.16.52.1 "/etc/init.d/pagerwebui restart; sleep 4; curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/api/api_ping"
|
||||
```
|
||||
Expected: `401` (auth required = running).
|
||||
|
||||
- [ ] **Step 2: Confirm the deployed files contain the new code**
|
||||
|
||||
```bash
|
||||
sshpass -p "<PAGER_PASSWORD>" ssh root@172.16.52.1 "grep -c 'PineAP Open Access Point' /root/payloads/user/general/pager-webui/www/js/views.js; grep -c 'pineap-infobox' /root/payloads/user/general/pager-webui/www/css/app.css; grep -c 'radio0' /root/payloads/user/general/pager-webui/server.py"
|
||||
```
|
||||
Expected: all counts greater than zero.
|
||||
|
||||
- [ ] **Step 3: On-device save-path round-trip (write current values, verify UCI, restore)**
|
||||
|
||||
Record the current `wireless.wlan0open` + `wireless.radio0` state first, then PUT the same values back through `set_ap` (idempotent), then verify and confirm the config is unchanged:
|
||||
|
||||
```bash
|
||||
sshpass -p "<PAGER_PASSWORD>" ssh root@172.16.52.1 "uci show wireless.wlan0open; uci show wireless.radio0 | grep -E 'channel|country'"
|
||||
```
|
||||
Then, over sshpass SSH, base64 a script and run it via `echo ... | base64 -d | sh` (avoids shell-quoting mangling) that:
|
||||
1. Logs in to the WebUI (`POST /api/login` with the root credentials, saves cookie).
|
||||
2. `POST /api/pineap/wifi/get_ap` → confirm the response contains `"open"` with `ssid`, `bssid`, `hidden`, `channel`, `country` keys.
|
||||
3. `POST /api/pineap/wifi/set_ap` with `{open:{ssid:<current>, bssid:<current>, hidden:<current>, enabled:true, channel:<current>, country:<current>}}` (the values just read) → expect `{"ok":true}`.
|
||||
4. `uci show wireless.wlan0open` again → confirm ssid/hidden/macaddr unchanged.
|
||||
Expected: get_ap returns the new fields; set_ap returns ok; UCI unchanged (idempotent write).
|
||||
|
||||
- [ ] **Step 4: Report for user UI walk**
|
||||
|
||||
Tell the user the Open AP tab is now the Mk7 "PineAP Open Access Point" card: Open SSID / BSSID / Channel (1-11) / Current Country / Hidden / "Respond to all probe requests (impersonate all networks)" switches, filter notice boxes with Add Allowed / Change Mode / Change Filters actions, and a Save button. Note the two documented limitations: the karma toggle is session-tracked (the daemon cannot report it), and the "SSIDs from the Spoofed SSID Pool will be advertised" clause is omitted (no readable broadcast state). Ask them to refresh `http://172.16.52.1:8080/#/pineap/open`, edit the Open SSID and Save, and confirm the toast + that `uci show wireless.wlan0open` reflects the change.
|
||||
@@ -0,0 +1,670 @@
|
||||
# PineAP Pages — Mark VII Layout 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:** Restyle the PineAP section's 8 tabs to the Mark VII `pineap-*` title-card layout (20px card titles, clickable title links, centered 24px values, full-width mode button group) without changing any behavior.
|
||||
|
||||
**Architecture:** Frontend-only. Add the Mk7 `.pineap-*` CSS vocabulary to `app.css`, add two small DOM helpers (`pineapCard`, `pineapTitleCard`) to `views.js`, then rebuild the markup of each of the 8 PineAP tab renderers to use them. All data fetches, toggles, presets, polling, and error handling are preserved verbatim — only the DOM structure/classes change.
|
||||
|
||||
**Tech Stack:** Vanilla JS (`h()` helper, `PagerAPI`, `btn`, `table`, `pineapShell`, `tabBar`), plain CSS, existing design tokens (`var(--surface)`, `var(--shadow)`, `var(--muted)`).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- No backend changes. No changes to `server.py` or `tests/`.
|
||||
- No behavior changes: every fetch, toggle, preset (`enable`+`mimic` only), `karmaOn` tracking, `modePending` guard, polling interval (5s; APs 10s), and `destroy()` stays exactly as it is today.
|
||||
- JS verification uses the Python delimiter-balance checker at `C:\Users\root\AppData\Local\Temp\opencode\js_balance.py` (no node available).
|
||||
- Python for the unittest loop: `$env:LOCALAPPDATA\Programs\Python\Python311\python.exe`.
|
||||
- Deploy: from repo root, `powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\deploy.ps1 -Password "<PAGER_PASSWORD>"`, then over sshpass restart the service (`/etc/init.d/pagerwebui restart`).
|
||||
- Commit messages follow repo style (`feat:`, `fix:`, `docs:`, `test:`).
|
||||
- The 8-tab `tabBar` remains the PineAP navigation.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Mk7 layout CSS + DOM helpers + PineAP overview rebuild
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/general/pager-webui/www/css/app.css` (append `.pineap-*` classes)
|
||||
- Modify: `payload/user/general/pager-webui/www/js/views.js` (helpers + full `views.pineap` replacement)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `h(tag, attrs, ...children)` (supports `class`, `text`, `on*` handlers), `btn(label, onclk, cls)`, `PagerAPI`, `App.go`, `App.toast`, `pineapShell(root, hash)`, `tabBar(box, items, hash)`, `table(columns, rows, rowAttrs)`.
|
||||
- Produces: `pineapCard(title)` and `pineapTitleCard(titleText, linkHash, valueNode)` helpers used by Tasks 2-3; `.pineap-*` CSS classes; a rebuilt `views.pineap` overview.
|
||||
|
||||
- [ ] **Step 1: Append the Mk7 layout CSS to `app.css`**
|
||||
|
||||
Append to the end of `payload/user/general/pager-webui/www/css/app.css`:
|
||||
|
||||
```css
|
||||
/* ---- PineAP Mark VII layout ---- */
|
||||
.pineap-title-card-container { display: flex; width: 100%; flex-wrap: wrap; justify-content: space-between; gap: 30px; margin: 8px 0 16px; }
|
||||
.pineap-title-card { flex: 1; min-width: 220px; background: var(--surface); border-radius: 2px; box-shadow: var(--shadow); padding: 14px 16px; margin-bottom: 16px; }
|
||||
.pineap-card-title { font-size: 20px; display: flex; align-items: center; margin-bottom: 10px; }
|
||||
.pineap-card-title-flex { display: flex; align-items: center; font-size: 20px; margin-bottom: 15px; }
|
||||
.pineap-card-title-link { color: inherit; text-decoration: none; cursor: pointer; }
|
||||
.pineap-card-title-link:visited { color: inherit; }
|
||||
.pineap-card-title-link:hover { text-decoration: underline; }
|
||||
.pineap-card-title-content { display: flex; justify-content: center; align-items: center; font-size: 24px; }
|
||||
.pineap-card-button-group { width: 100%; height: 30px; display: flex; }
|
||||
.pineap-card-button-group .seg { flex: 1; height: 100%; }
|
||||
.pineap-card-button-group .seg-btn { flex: 1; }
|
||||
.pineap-card-settings, .pineap-card-pool, .pineap-card-handshakes, .pineap-card-inject { flex: 1; }
|
||||
.pineap-handshakes-none { display: flex; justify-content: center; font-style: italic; color: var(--muted); }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the two DOM helpers and remove the now-unused `pineapSetCard`**
|
||||
|
||||
In `payload/user/general/pager-webui/www/js/views.js`, replace the `pineapSetCard` helper (currently lines ~182-185) with:
|
||||
|
||||
```js
|
||||
function pineapCard(title) {
|
||||
return h('div', { class: 'pineap-title-card' },
|
||||
h('div', { class: 'pineap-card-title' }, title));
|
||||
}
|
||||
|
||||
function pineapTitleCard(titleText, linkHash, valueNode) {
|
||||
const title = linkHash
|
||||
? h('a', { class: 'pineap-card-title-link', onclick: (e) => { e.preventDefault(); App.go(linkHash); } }, titleText)
|
||||
: titleText;
|
||||
return h('div', { class: 'pineap-title-card' },
|
||||
h('div', { class: 'pineap-card-title' }, title),
|
||||
h('div', { class: 'pineap-card-title-content' }, valueNode));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Replace the whole `views.pineap` function**
|
||||
|
||||
Replace everything from `views.pineap = (root) => {` through the closing `};` of that function (current lines ~187-328, i.e. just before `views.pineap_open`) with:
|
||||
|
||||
```js
|
||||
views.pineap = (root) => {
|
||||
const box = pineapShell(root, '#/pineap');
|
||||
|
||||
const stats = {};
|
||||
const statWrap = h('div', { class: 'pineap-title-card-container' });
|
||||
const statDefs = [
|
||||
['ssids', 'Total SSIDs in Pool', '#/pineap/impersonation'],
|
||||
['clients', 'Clients Connected', '#/pineap/clients'],
|
||||
['handshakes', 'Handshakes Captured', '#/pineap/evilwpa']
|
||||
];
|
||||
statDefs.forEach(([k, label, hash]) => {
|
||||
const val = h('span', { text: '—' });
|
||||
stats[k] = val;
|
||||
statWrap.appendChild(pineapTitleCard(label, hash, val));
|
||||
});
|
||||
box.appendChild(statWrap);
|
||||
|
||||
const mode = h('span', { class: 'badge', text: '—' });
|
||||
const segBtns = {};
|
||||
const modeBar = h('div', { class: 'seg' });
|
||||
['passive', 'active', 'advanced'].forEach((m) => {
|
||||
const b = h('button', { class: 'seg-btn', text: m[0].toUpperCase() + m.slice(1) });
|
||||
b.addEventListener('click', () => applyMode(m));
|
||||
modeBar.appendChild(b);
|
||||
segBtns[m] = b;
|
||||
});
|
||||
const modeInfo = h('div', { class: 'muted', style: 'margin-top:8px;font-size:12px' });
|
||||
const modeCard = h('div', { class: 'pineap-title-card' },
|
||||
h('div', { class: 'pineap-card-title-flex' }, mode),
|
||||
h('div', { class: 'pineap-card-button-group' }, modeBar),
|
||||
modeInfo);
|
||||
|
||||
const quick = {
|
||||
collect: h('input', { type: 'checkbox', id: 'po-collect' }),
|
||||
advertise: h('input', { type: 'checkbox', id: 'po-advertise' })
|
||||
};
|
||||
const quickCard = h('div', { class: 'pineap-title-card pineap-card-settings' },
|
||||
h('div', { class: 'pineap-card-title' }, 'Quick Settings'));
|
||||
quickCard.appendChild(h('label', { class: 'toggle' }, quick.collect, ' Capture SSIDs to Pool'));
|
||||
quickCard.appendChild(h('label', { class: 'toggle' }, quick.advertise, ' Advertise AP Impersonation Pool'));
|
||||
quickCard.appendChild(h('div', { class: 'muted', style: 'margin-top:8px;font-size:12px' },
|
||||
'Client connect/disconnect notifications are handled by the Pager alert payload system.'));
|
||||
|
||||
const modeRow = h('div', { class: 'pineap-title-card-container' });
|
||||
modeRow.appendChild(modeCard);
|
||||
modeRow.appendChild(quickCard);
|
||||
box.appendChild(modeRow);
|
||||
|
||||
const cards = { karma: {}, open: {}, wpa: {}, ent: {} };
|
||||
const cardWrap = h('div', { class: 'pineap-title-card-container' });
|
||||
const statusDefs = [
|
||||
['karma', 'Karma', '#/pineap/open'],
|
||||
['open', 'Open Network', '#/pineap/open'],
|
||||
['wpa', 'Evil WPA', '#/pineap/evilwpa'],
|
||||
['ent', 'Evil Enterprise', '#/pineap/enterprise']
|
||||
];
|
||||
statusDefs.forEach(([k, label, hash]) => {
|
||||
const val = h('span', { text: '—' });
|
||||
cards[k].value = val;
|
||||
cardWrap.appendChild(pineapTitleCard(label, hash, val));
|
||||
});
|
||||
box.appendChild(cardWrap);
|
||||
|
||||
let karmaOn = null;
|
||||
|
||||
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 setMode(m, disabled) {
|
||||
Object.keys(segBtns).forEach((k) => segBtns[k].classList.toggle('active', k === m));
|
||||
modeInfo.textContent = disabled
|
||||
? 'PineAP is off. Enable it from the Open AP tab or a mode preset to begin impersonating networks.'
|
||||
: {
|
||||
passive: 'PineAP is on; network impersonation (Karma) is off.',
|
||||
active: 'PineAP is on; Karma is expected to be enabled, impersonating open networks.',
|
||||
advanced: 'All PineAP features are enabled and customizable.'
|
||||
}[m] || '';
|
||||
}
|
||||
|
||||
let modePending = false;
|
||||
function applyMode(m) {
|
||||
const btn = segBtns[m];
|
||||
if (!btn || btn.classList.contains('active') || modePending) return;
|
||||
const on = m !== 'passive';
|
||||
modePending = true;
|
||||
btn.classList.add('busy');
|
||||
Promise.all([
|
||||
PagerAPI.post('/api/pineap/enable', { enable: true }),
|
||||
PagerAPI.post('/api/pineap/mimic', { enable: on })
|
||||
]).then(() => {
|
||||
karmaOn = on;
|
||||
setMode(m);
|
||||
App.toast('Mode: ' + m[0].toUpperCase() + m.slice(1));
|
||||
load();
|
||||
}).catch(() => { karmaOn = null; App.toast('Failed', 'error'); })
|
||||
.finally(() => { modePending = false; btn.classList.remove('busy'); });
|
||||
}
|
||||
|
||||
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: {} })),
|
||||
PagerAPI.get('/api/pineap/ssids').catch(() => ({ data: {} })),
|
||||
PagerAPI.get('/api/pineap/clients').catch(() => ({ data: {} })),
|
||||
PagerAPI.get('/api/pineap/handshakes').catch(() => ({ data: {} }))
|
||||
]).then(([cfg, host, ap, ss, cl, hs]) => {
|
||||
const c = cfg.data || {}, hh = host.data || {}, a = ap.data || {};
|
||||
const disabled = !!hh.pineap_disabled;
|
||||
const wpa = a.wpa || {}, ent = a.enterprise || {};
|
||||
const advanced = !disabled && (wpa.enabled || ent.enabled);
|
||||
const computed = disabled ? 'passive' : (karmaOn === false ? 'passive' : (advanced ? 'advanced' : 'active'));
|
||||
mode.textContent = computed[0].toUpperCase() + computed.slice(1);
|
||||
mode.className = 'badge ' + (disabled ? 'off' : 'on');
|
||||
setMode(computed, disabled);
|
||||
quick.collect.checked = !!c.autossidpool;
|
||||
const pool = a.pool || {};
|
||||
quick.advertise.checked = pool.disabled === false;
|
||||
stats.ssids.textContent = (ss.data && Array.isArray(ss.data.ssids)) ? ss.data.ssids.length : '—';
|
||||
stats.clients.textContent = (cl.data && typeof cl.data.count === 'number') ? cl.data.count : '—';
|
||||
stats.handshakes.textContent = (hs.data && Array.isArray(hs.data.files)) ? hs.data.files.length : '—';
|
||||
cards.karma.value.textContent = karmaOn == null ? '—' : (karmaOn ? 'On' : 'Off');
|
||||
const open = a.open || {};
|
||||
cards.open.value.textContent = open.enabled == null ? '—' : (open.enabled ? 'On' : 'Off');
|
||||
cards.wpa.value.textContent = wpa.enabled ? 'On' : 'Off';
|
||||
cards.ent.value.textContent = ent.enabled ? 'On' : 'Off';
|
||||
});
|
||||
}
|
||||
load();
|
||||
const iv = setInterval(load, 5000);
|
||||
return { destroy: () => clearInterval(iv) };
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the JS delimiter balance check**
|
||||
|
||||
Run: `& "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe" "C:\Users\root\AppData\Local\Temp\opencode\js_balance.py" "C:\Users\root\Documents\Pineapple\pager-webui\payload\user\general\pager-webui\www\js\views.js"`
|
||||
Expected: `...views.js: delimiter balance OK`
|
||||
|
||||
- [ ] **Step 5: Run the 13-module unittest loop (backend must stay green)**
|
||||
|
||||
Run from `C:\Users\root\Documents\Pineapple\pager-webui`:
|
||||
```powershell
|
||||
$py = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe"; Get-ChildItem tests\test_*.py | ForEach-Object { $mod = "tests." + [IO.Path]::GetFileNameWithoutExtension($_.Name); $out = & $py -m unittest $mod 2>&1; ($out | Select-Object -Last 1) }
|
||||
```
|
||||
Expected: every module reports `OK` (recon may show `skipped=1`).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add payload/user/general/pager-webui/www/css/app.css payload/user/general/pager-webui/www/js/views.js
|
||||
git commit -m "feat: Mark VII title-card layout for PineAP overview"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Open AP, Evil WPA, Enterprise tabs
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/general/pager-webui/www/js/views.js` (`views.pineap_open`, `views.pineap_evilwpa`, `views.pineap_enterprise`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `pineapCard(title)` (Task 1), `h()`, `btn()`, `table()`, `PagerAPI`, `App.toast`, `EVIL_ENC`, `pineapShell`.
|
||||
- Produces: the three tab renderers rebuilt on `.pineap-title-card` anatomy. `views.pineap_enterprise.tableBox` now returns `{ body, endpoint }` where `body` is a child div (not the card itself) — the `load()` body-clearing code keeps using `t.body`.
|
||||
|
||||
- [ ] **Step 1: Replace `views.pineap_open`**
|
||||
|
||||
Replace the whole function (current lines ~330-377) with the same code except the `wrap` construction — change:
|
||||
|
||||
```js
|
||||
const wrap = h('div', { class: 'section' }, h('h2', {}, 'Open AP'));
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```js
|
||||
const wrap = h('div', { class: 'pineap-title-card pineap-card-settings' },
|
||||
h('div', { class: 'pineap-card-title' }, 'Open AP'));
|
||||
```
|
||||
|
||||
Everything else in `views.pineap_open` (the `defs` array, the `forEach`, the `info` line, `saveCfg`, `load`) stays byte-for-byte identical.
|
||||
|
||||
- [ ] **Step 2: Replace `views.pineap_evilwpa`**
|
||||
|
||||
Replace the whole function (current lines ~383-450) with:
|
||||
|
||||
```js
|
||||
views.pineap_evilwpa = (root) => {
|
||||
const box = pineapShell(root, '#/pineap/evilwpa');
|
||||
const cfg = h('div', { class: 'pineap-title-card' },
|
||||
h('div', { class: 'pineap-card-title' }, 'Evil WPA'));
|
||||
box.appendChild(cfg);
|
||||
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' });
|
||||
cfg.appendChild(h('label', {}, 'SSID', ssidIn));
|
||||
cfg.appendChild(h('label', {}, 'Passphrase', pskIn));
|
||||
cfg.appendChild(h('label', {}, 'Encryption', encSel));
|
||||
cfg.appendChild(h('label', { class: 'toggle' }, hiddenCb, ' Hidden'));
|
||||
cfg.appendChild(h('label', { class: 'toggle' }, enabledCb, ' Enabled'));
|
||||
cfg.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'));
|
||||
})),
|
||||
h('div', { class: 'muted', style: 'align-self:center;font-size:12px' }, 'Applying reconfigures the radio — you may be disconnected briefly.')));
|
||||
|
||||
const capBox = h('div', { class: 'pineap-title-card' },
|
||||
h('div', { class: 'pineap-card-title' }, 'Handshake Capture'));
|
||||
box.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 hsBody = h('div', {});
|
||||
const hsBox = h('div', { class: 'pineap-title-card pineap-card-handshakes' },
|
||||
h('div', { class: 'pineap-card-title' }, 'Captured Handshakes'),
|
||||
hsBody);
|
||||
box.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) => {
|
||||
hsBody.innerHTML = '';
|
||||
const rows = (r.data.handshakes || []).map((x) => ({
|
||||
name: x.name || '--', ap: x.ap || '--', client: x.client || '--', type: x.type || '--'
|
||||
}));
|
||||
hsBody.appendChild(table(
|
||||
[{ label: 'File', key: 'name' }, { label: 'AP', key: 'ap' },
|
||||
{ label: 'Client', key: 'client' }, { label: 'Type', key: 'type' }],
|
||||
rows));
|
||||
if (!rows.length) hsBody.appendChild(h('div', { class: 'pineap-handshakes-none', text: 'No handshakes captured yet.' }));
|
||||
}).catch(() => {});
|
||||
}
|
||||
load();
|
||||
const iv = setInterval(load, 5000);
|
||||
return { destroy: () => clearInterval(iv) };
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Replace `views.pineap_enterprise`**
|
||||
|
||||
Replace the whole function (current lines ~452-494) with:
|
||||
|
||||
```js
|
||||
views.pineap_enterprise = (root) => {
|
||||
const box = pineapShell(root, '#/pineap/enterprise');
|
||||
const cfg = h('div', { class: 'pineap-title-card' },
|
||||
h('div', { class: 'pineap-card-title' }, 'Evil Enterprise'));
|
||||
box.appendChild(cfg);
|
||||
const enabledCb = h('input', { type: 'checkbox', id: 'ee-enabled' });
|
||||
const authCb = h('input', { type: 'checkbox', id: 'ee-auth' });
|
||||
cfg.appendChild(h('label', { class: 'toggle' }, enabledCb, ' Enabled'));
|
||||
cfg.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; App.toast('Failed', 'error'); }));
|
||||
authCb.addEventListener('change', () => PagerAPI.post('/api/pineap/hostapd', { pineape_auth_pass: authCb.checked }).then(load).catch(() => { authCb.checked = !authCb.checked; App.toast('Failed', 'error'); }));
|
||||
|
||||
function tableBox(name, endpoint, clearTable) {
|
||||
const body = h('div', {});
|
||||
const tb = h('div', { class: 'pineap-title-card pineap-card-inject' },
|
||||
h('div', { class: 'pineap-card-title-flex' },
|
||||
h('span', { text: name }),
|
||||
h('span', { class: 'toolbar-spacer' }),
|
||||
btn('Clear', () => PagerAPI.post('/api/pineap/enterprise/clear', { table: clearTable }).then(load), 'danger')),
|
||||
body);
|
||||
box.appendChild(tb);
|
||||
return { 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 4: Run the JS delimiter balance check**
|
||||
|
||||
Run: `& "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe" "C:\Users\root\AppData\Local\Temp\opencode\js_balance.py" "C:\Users\root\Documents\Pineapple\pager-webui\payload\user\general\pager-webui\www\js\views.js"`
|
||||
Expected: `...views.js: delimiter balance OK`
|
||||
|
||||
- [ ] **Step 5: Run the 13-module unittest loop**
|
||||
|
||||
Run from `C:\Users\root\Documents\Pineapple\pager-webui`:
|
||||
```powershell
|
||||
$py = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe"; Get-ChildItem tests\test_*.py | ForEach-Object { $mod = "tests." + [IO.Path]::GetFileNameWithoutExtension($_.Name); $out = & $py -m unittest $mod 2>&1; ($out | Select-Object -Last 1) }
|
||||
```
|
||||
Expected: every module reports `OK` (recon may show `skipped=1`).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add payload/user/general/pager-webui/www/js/views.js
|
||||
git commit -m "feat: Mark VII layout for Open AP, Evil WPA, Enterprise tabs"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Impersonation, Clients, Filtering, APs tabs
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/general/pager-webui/www/js/views.js` (`views.pineap_impersonation`, `views.pineap_clients`, `views.pineap_filtering`, `views.pineap_aps`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `pineapCard`/`pineapTitleCard` (Task 1), `h()`, `btn()`, `table()`, `PagerAPI`, `App.toast`, `pineapShell`.
|
||||
- Produces: the four tab renderers rebuilt. `views.pineap_impersonation` gains a `poolCount` span (updated in `render()`); `views.pineap_clients` gains a `count` span (updated in `load()`).
|
||||
|
||||
- [ ] **Step 1: Replace `views.pineap_impersonation`**
|
||||
|
||||
Replace the whole function (current lines ~496-536) with:
|
||||
|
||||
```js
|
||||
views.pineap_impersonation = (root) => {
|
||||
const box = pineapShell(root, '#/pineap/impersonation');
|
||||
const poolCount = h('span', { text: '—' });
|
||||
const countRow = h('div', { class: 'pineap-title-card-container' },
|
||||
pineapTitleCard('Total SSIDs in Pool', '#/pineap/impersonation', poolCount));
|
||||
box.appendChild(countRow);
|
||||
|
||||
const input = h('input', { id: 'imp-ssid' });
|
||||
const list = h('div', {});
|
||||
const advCb = h('input', { type: 'checkbox', id: 'imp-advertise' });
|
||||
const colCb = h('input', { type: 'checkbox', id: 'imp-collect' });
|
||||
const poolBox = h('div', { class: 'pineap-title-card pineap-card-pool' },
|
||||
h('div', { class: 'pineap-card-title' }, 'SSID Pool'));
|
||||
poolBox.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); App.toast('Added'); });
|
||||
})),
|
||||
h('div', {}, btn('Clear', () => PagerAPI.post('/api/pineap/ssids', { action: 'clear' }).then((r) => render(r.data.ssids)), 'danger'))));
|
||||
poolBox.appendChild(h('label', { class: 'toggle' }, advCb, ' Advertise AP Impersonation Pool'));
|
||||
poolBox.appendChild(h('label', { class: 'toggle' }, colCb, ' Capture SSIDs to Pool'));
|
||||
poolBox.appendChild(list);
|
||||
box.appendChild(poolBox);
|
||||
advCb.addEventListener('change', () => PagerAPI.post('/api/pineap/ssidpool/advertise', { enable: advCb.checked }).then(load).catch(() => { advCb.checked = !advCb.checked; App.toast('Failed', 'error'); }));
|
||||
colCb.addEventListener('change', () => PagerAPI.post('/api/pineap/ssidpool/collect', { enable: colCb.checked }).then(load).catch(() => { colCb.checked = !colCb.checked; App.toast('Failed', 'error'); }));
|
||||
|
||||
function render(ssids) {
|
||||
poolCount.textContent = Array.isArray(ssids) ? ssids.length : 0;
|
||||
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 === false;
|
||||
colCb.checked = !!p.collecting;
|
||||
}).catch(() => {});
|
||||
}
|
||||
load();
|
||||
return { destroy: () => {} };
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace `views.pineap_clients`**
|
||||
|
||||
Replace the whole function (current lines ~538-561) with:
|
||||
|
||||
```js
|
||||
views.pineap_clients = (root) => {
|
||||
const box = pineapShell(root, '#/pineap/clients');
|
||||
const state = { clients: [] };
|
||||
const count = h('span', { text: '—' });
|
||||
const countRow = h('div', { class: 'pineap-title-card-container' },
|
||||
pineapTitleCard('Clients Connected', '#/pineap/clients', count));
|
||||
box.appendChild(countRow);
|
||||
|
||||
const body = h('div', {});
|
||||
const tableCard = h('div', { class: 'pineap-title-card' },
|
||||
h('div', { class: 'pineap-card-title-flex' },
|
||||
h('span', { text: 'Connected Clients' }),
|
||||
h('span', { class: 'toolbar-spacer' }),
|
||||
btn('Refresh', load, 'ghost')),
|
||||
body);
|
||||
box.appendChild(tableCard);
|
||||
|
||||
function render() {
|
||||
body.innerHTML = '';
|
||||
body.appendChild(table(
|
||||
[{ label: 'MAC', key: 'mac' }, { label: 'Interface', key: 'iface' },
|
||||
{ label: 'RSSI', key: 'rssi' }, { label: '', render: () => '' }],
|
||||
state.clients,
|
||||
(r) => ({ style: 'cursor:pointer',
|
||||
onclick: () => { if (confirm('Kick ' + r.mac + '?')) PagerAPI.post('/api/pineap/clients/kick', { mac: r.mac }).then(() => App.toast('Kicked')).then(load); } })));
|
||||
const cols = ['MAC', 'Interface', 'RSSI'];
|
||||
body.querySelectorAll('.tbl th').forEach((th, i) => { if (i >= cols.length) th.textContent = 'Kick'; });
|
||||
}
|
||||
function load() {
|
||||
PagerAPI.get('/api/pineap/clients').then((r) => {
|
||||
state.clients = r.data.clients;
|
||||
count.textContent = (r.data && typeof r.data.count === 'number') ? r.data.count : state.clients.length;
|
||||
render();
|
||||
});
|
||||
}
|
||||
load();
|
||||
const iv = setInterval(load, 5000);
|
||||
return { destroy: () => clearInterval(iv) };
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Replace `views.pineap_filtering`**
|
||||
|
||||
Replace the whole function (current lines ~563-603) with:
|
||||
|
||||
```js
|
||||
views.pineap_filtering = (root) => {
|
||||
const box = pineapShell(root, '#/pineap/filtering');
|
||||
function filterCard(title) {
|
||||
const body = h('div', {});
|
||||
const card = h('div', { class: 'pineap-title-card' },
|
||||
h('div', { class: 'pineap-card-title' }, title),
|
||||
body);
|
||||
box.appendChild(card);
|
||||
return body;
|
||||
}
|
||||
const cfBox = filterCard('Client Filter');
|
||||
const sfBox = filterCard('SSID Filter');
|
||||
function renderFilter(dom, kind) {
|
||||
dom.innerHTML = '';
|
||||
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 });
|
||||
dom.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', {});
|
||||
dom.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: 'pineap-handshakes-none', text: 'No entries.' }));
|
||||
}).catch(() => {});
|
||||
}
|
||||
function refresh() { renderFilter(cfBox, 'client'); renderFilter(sfBox, 'ssid'); }
|
||||
refresh();
|
||||
return { destroy: () => {} };
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Replace `views.pineap_aps`**
|
||||
|
||||
Replace the whole function (current lines ~605-631) with:
|
||||
|
||||
```js
|
||||
views.pineap_aps = (root) => {
|
||||
const box = pineapShell(root, '#/pineap/aps');
|
||||
const body = h('div', {});
|
||||
const b = h('div', { class: 'pineap-title-card pineap-card-inject' },
|
||||
h('div', { class: 'pineap-card-title-flex' },
|
||||
h('span', { text: 'Access Points' }),
|
||||
h('span', { class: 'toolbar-spacer' }),
|
||||
btn('Refresh', load, 'ghost')),
|
||||
body);
|
||||
box.appendChild(b);
|
||||
function load() {
|
||||
body.innerHTML = '';
|
||||
PagerAPI.get('/api/pineap/aps').then((r) => {
|
||||
const rows = (r.data.aps || []).map((a) => ({
|
||||
bssid: a.bssid || '--', ssid: a.ssid || '--',
|
||||
channel: a.channel == null ? '--' : a.channel,
|
||||
signal: a.signal == null ? '--' : a.signal + ' dBm',
|
||||
encryption: a.encryption || '--', iface: a.iface || '--'
|
||||
}));
|
||||
body.appendChild(table(
|
||||
[{ label: 'BSSID', key: 'bssid' }, { label: 'SSID', key: 'ssid' },
|
||||
{ label: 'Channel', key: 'channel' }, { label: 'Signal', key: 'signal' },
|
||||
{ label: 'Encryption', key: 'encryption' }, { label: 'Interface', key: 'iface' }],
|
||||
rows));
|
||||
if (!rows.length) body.appendChild(h('div', { class: 'pineap-handshakes-none', text: 'No access points found.' }));
|
||||
}).catch(() => body.appendChild(h('div', { class: 'pineap-handshakes-none', text: 'Scan failed.' })));
|
||||
}
|
||||
load();
|
||||
const iv = setInterval(load, 10000);
|
||||
return { destroy: () => clearInterval(iv) };
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run the JS delimiter balance check**
|
||||
|
||||
Run: `& "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe" "C:\Users\root\AppData\Local\Temp\opencode\js_balance.py" "C:\Users\root\Documents\Pineapple\pager-webui\payload\user\general\pager-webui\www\js\views.js"`
|
||||
Expected: `...views.js: delimiter balance OK`
|
||||
|
||||
- [ ] **Step 6: Run the 13-module unittest loop**
|
||||
|
||||
Run from `C:\Users\root\Documents\Pineapple\pager-webui`:
|
||||
```powershell
|
||||
$py = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe"; Get-ChildItem tests\test_*.py | ForEach-Object { $mod = "tests." + [IO.Path]::GetFileNameWithoutExtension($_.Name); $out = & $py -m unittest $mod 2>&1; ($out | Select-Object -Last 1) }
|
||||
```
|
||||
Expected: every module reports `OK` (recon may show `skipped=1`).
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add payload/user/general/pager-webui/www/js/views.js
|
||||
git commit -m "feat: Mark VII layout for Impersonation, Clients, Filtering, APs tabs"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Deploy and verify on device
|
||||
|
||||
**Files:** none (verification only; no commit).
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Tasks 1-3 output (deployed via `scripts/deploy.ps1`).
|
||||
|
||||
- [ ] **Step 1: Deploy the payload and restart the webui**
|
||||
|
||||
Run from `C:\Users\root\Documents\Pineapple\pager-webui`:
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\deploy.ps1 -Password "<PAGER_PASSWORD>"
|
||||
```
|
||||
Then restart and confirm the port is up (over sshpass SSH):
|
||||
```bash
|
||||
sshpass -p "<PAGER_PASSWORD>" ssh root@172.16.52.1 "/etc/init.d/pagerwebui restart; sleep 4; curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/api/api_ping"
|
||||
```
|
||||
Expected: `401` (auth required = running).
|
||||
|
||||
- [ ] **Step 2: Confirm the deployed files contain the new classes**
|
||||
|
||||
```bash
|
||||
sshpass -p "<PAGER_PASSWORD>" ssh root@172.16.52.1 "grep -c 'pineap-title-card' /root/payloads/user/general/pager-webui/www/css/app.css; grep -c 'pineapTitleCard' /root/payloads/user/general/pager-webui/www/js/views.js"
|
||||
```
|
||||
Expected: both counts greater than zero.
|
||||
|
||||
- [ ] **Step 3: Report for user UI walk**
|
||||
|
||||
Tell the user all 8 PineAP tabs now use the Mark VII `pineap-*` title-card layout: the overview has three rows of title cards (stats with clickable title links → mode/quick-settings → status cards), the other tabs use 20px title cards with the `.pineap-card-title-flex` action rows and `.pineap-handshakes-none` empty states. Ask them to refresh `http://172.16.52.1:8080/#/pineap` and walk the tabs to confirm.
|
||||
@@ -0,0 +1,252 @@
|
||||
# PineAP Overview — Mark VII Stats + Mode Toggle 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:** Bring the Pager WebUI PineAP overview (`#/pineap`) to Mark VII parity — a clickable 3-card stats row (Total SSIDs in Pool / Clients Connected / Handshakes Captured) and a Passive/Active/Advanced quick mode toggle — using only functionality the Pager supports.
|
||||
|
||||
**Architecture:** Frontend-only change to `views.pineap` in `www/js/views.js` plus a small `.seg` segmented-control style in `www/css/app.css`. All data comes from existing, on-device-verified webui endpoints; the backend and test modules are untouched. Karma (mimic) state is unreadable from the daemon, so it is tracked in a view-local variable.
|
||||
|
||||
**Tech Stack:** Vanilla JS (hyperscript `h()` helper), existing `PagerAPI` client, existing `.cards`/`.card`/`.badge`/`.toggle` CSS, plain CSS additions.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- No backend changes. No changes to `server.py` or `tests/`.
|
||||
- Karma state is NOT readable from the daemon (only `mimic/enable|disable`); track it client-side as `karmaOn` (null = unknown, defaults to `null` on page load).
|
||||
- Mode presets only apply Pager-supported features: `POST /api/pineap/enable` and `POST /api/pineap/mimic`. Do NOT touch `ssidpool/*` (broadcast cannot start natively — it stays a manual Quick Settings toggle).
|
||||
- JS verification uses the Python delimiter-balance checker at `C:\Users\root\AppData\Local\Temp\opencode\js_balance.py` (no node available).
|
||||
- Python for the unittest loop: `$env:LOCALAPPDATA\Programs\Python\Python311\python.exe`.
|
||||
- Deploy: `powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\deploy.ps1 -Password "<PAGER_PASSWORD>"`, then `/etc/init.d/pagerwebui restart` over sshpass SSH.
|
||||
- Commit messages follow repo style (`feat:`, `fix:`, `docs:`, `test:`).
|
||||
- Mode badge highlight refinement vs. the approved spec (intent-preserving): when `karmaOn` is tracked, prefer it over the unreadable daemon state so the toggle does not visually jump after a user applies a preset.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Rebuild the PineAP overview (stats row + mode toggle + karma tracking)
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/general/pager-webui/www/css/app.css` (append `.seg` styles)
|
||||
- Modify: `payload/user/general/pager-webui/www/js/views.js` (replace the body of `views.pineap`, lines ~187-255)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `pineapShell(root, hash)` (appends h1 + tab bar, returns content box), `pineapSetCard(card, label, value)` (sets `.card-label`/`.card-value` text), `h()`, `btn(label, onclick, variant)`, `PagerAPI.get/post`, `App.go(hash)`, `App.toast`.
|
||||
- Produces: `views.pineap` with (1) a `.seg` segmented control with three buttons, (2) a 3-card stats row keyed `stats.ssids`/`stats.clients`/`stats.handshakes`, (3) a view-local `karmaOn` variable consumed by `load()` for the Karma card and the computed mode.
|
||||
|
||||
- [ ] **Step 1: Append the segmented-control CSS to `app.css`**
|
||||
|
||||
Append to the end of `payload/user/general/pager-webui/www/css/app.css`:
|
||||
|
||||
```css
|
||||
.seg { display: inline-flex; margin-top: 8px; border: 1px solid var(--ink, #999); border-radius: 4px; overflow: hidden; }
|
||||
.seg-btn { background: transparent; border: none; padding: 5px 14px; font-size: 12px; cursor: pointer; color: var(--muted, #666); }
|
||||
.seg-btn + .seg-btn { border-left: 1px solid var(--ink, #999); }
|
||||
.seg-btn.active { background: var(--primary, #1976d2); color: #fff; }
|
||||
.seg-btn.busy { opacity: .5; pointer-events: none; }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace the body of `views.pineap` in `views.js`**
|
||||
|
||||
Replace everything from `views.pineap = (root) => {` through the closing `};` of that function (current lines ~187-255) with:
|
||||
|
||||
```js
|
||||
views.pineap = (root) => {
|
||||
const box = pineapShell(root, '#/pineap');
|
||||
const mode = h('span', { class: 'badge', text: '-' });
|
||||
const intro = h('p', { class: 'muted' });
|
||||
const head = h('div', { class: 'section' },
|
||||
h('h2', {}, 'PineAP'),
|
||||
h('div', { class: 'row' }, h('div', {}, mode)),
|
||||
intro);
|
||||
box.appendChild(head);
|
||||
|
||||
const segBtns = {};
|
||||
const modeBar = h('div', { class: 'seg' });
|
||||
['passive', 'active', 'advanced'].forEach((m) => {
|
||||
const b = h('button', { class: 'seg-btn', text: m[0].toUpperCase() + m.slice(1) });
|
||||
b.addEventListener('click', () => applyMode(m));
|
||||
modeBar.appendChild(b);
|
||||
segBtns[m] = b;
|
||||
});
|
||||
const modeInfo = h('div', { class: 'muted', style: 'margin-top:8px;font-size:12px' });
|
||||
head.appendChild(modeBar);
|
||||
head.appendChild(modeInfo);
|
||||
|
||||
const quick = {
|
||||
collect: h('input', { type: 'checkbox', id: 'po-collect' }),
|
||||
advertise: h('input', { type: 'checkbox', id: 'po-advertise' })
|
||||
};
|
||||
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);
|
||||
|
||||
const stats = {};
|
||||
const statWrap = h('div', { class: 'cards' });
|
||||
const statDefs = [
|
||||
['ssids', 'Total SSIDs in Pool', '#/pineap/impersonation'],
|
||||
['clients', 'Clients Connected', '#/pineap/clients'],
|
||||
['handshakes', 'Handshakes Captured', '#/pineap/evilwpa']
|
||||
];
|
||||
statDefs.forEach(([k, label, hash]) => {
|
||||
const card = h('div', { class: 'card' },
|
||||
h('div', { class: 'card-label' }),
|
||||
h('div', { class: 'card-value' }),
|
||||
h('div', { class: 'row' }, btn('View', () => App.go(hash), 'ghost')));
|
||||
statWrap.appendChild(card);
|
||||
stats[k] = { label: card.querySelector('.card-label'), value: card.querySelector('.card-value') };
|
||||
stats[k].label.textContent = label;
|
||||
});
|
||||
box.appendChild(statWrap);
|
||||
|
||||
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' }),
|
||||
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].label = card.querySelector('.card-label');
|
||||
cards[k].value = card.querySelector('.card-value');
|
||||
});
|
||||
box.appendChild(cardWrap);
|
||||
|
||||
let karmaOn = null;
|
||||
|
||||
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 setMode(m) {
|
||||
Object.keys(segBtns).forEach((k) => segBtns[k].classList.toggle('active', k === m));
|
||||
modeInfo.textContent = {
|
||||
passive: 'PineAP is on; network impersonation (Karma) is off.',
|
||||
active: 'PineAP and Karma are on; the open network is impersonated.',
|
||||
advanced: 'All PineAP features are enabled and customizable.'
|
||||
}[m] || '';
|
||||
}
|
||||
|
||||
function applyMode(m) {
|
||||
const btn = segBtns[m];
|
||||
if (!btn || btn.classList.contains('active')) return;
|
||||
const on = m !== 'passive';
|
||||
btn.classList.add('busy');
|
||||
Promise.all([
|
||||
PagerAPI.post('/api/pineap/enable', { enable: true }),
|
||||
PagerAPI.post('/api/pineap/mimic', { enable: on })
|
||||
]).then(() => {
|
||||
karmaOn = on;
|
||||
setMode(m);
|
||||
App.toast('Mode: ' + m[0].toUpperCase() + m.slice(1));
|
||||
load();
|
||||
}).catch(() => App.toast('Failed', 'error')).finally(() => btn.classList.remove('busy'));
|
||||
}
|
||||
|
||||
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: {} })),
|
||||
PagerAPI.get('/api/pineap/ssids').catch(() => ({ data: {} })),
|
||||
PagerAPI.get('/api/pineap/clients').catch(() => ({ data: {} })),
|
||||
PagerAPI.get('/api/pineap/handshakes').catch(() => ({ data: {} }))
|
||||
]).then(([cfg, host, ap, ss, cl, hs]) => {
|
||||
const c = cfg.data || {}, hh = host.data || {}, a = ap.data || {};
|
||||
const disabled = !!hh.pineap_disabled;
|
||||
const wpa = a.wpa || {}, ent = a.enterprise || {};
|
||||
const advanced = !disabled && (wpa.enabled || ent.enabled);
|
||||
const computed = disabled ? 'passive' : (karmaOn === false ? 'passive' : (advanced ? 'advanced' : 'active'));
|
||||
mode.textContent = computed[0].toUpperCase() + computed.slice(1);
|
||||
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.';
|
||||
setMode(computed);
|
||||
quick.collect.checked = !!c.autossidpool;
|
||||
const pool = a.pool || {};
|
||||
quick.advertise.checked = pool.disabled === false;
|
||||
stats.ssids.value.textContent = (ss.data && Array.isArray(ss.data.ssids)) ? ss.data.ssids.length : '—';
|
||||
stats.clients.value.textContent = (cl.data && typeof cl.data.count === 'number') ? cl.data.count : '—';
|
||||
stats.handshakes.value.textContent = (hs.data && Array.isArray(hs.data.files)) ? hs.data.files.length : '—';
|
||||
pineapSetCard(cards.karma, 'Karma', karmaOn == null ? null : (karmaOn ? 'On' : 'Off'));
|
||||
const open = a.open || {};
|
||||
pineapSetCard(cards.open, 'Open Network', open.enabled == null ? '—' : (open.enabled ? 'On' : 'Off'));
|
||||
pineapSetCard(cards.wpa, 'Evil WPA', wpa.enabled ? 'On' : 'Off');
|
||||
pineapSetCard(cards.ent, 'Evil Enterprise', ent.enabled ? 'On' : 'Off');
|
||||
});
|
||||
}
|
||||
load();
|
||||
const iv = setInterval(load, 5000);
|
||||
return { destroy: () => clearInterval(iv) };
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run the JS delimiter balance check**
|
||||
|
||||
Run: `python "C:\Users\root\AppData\Local\Temp\opencode\js_balance.py" "C:\Users\root\Documents\Pineapple\pager-webui\payload\user\general\pager-webui\www\js\views.js"`
|
||||
Expected: `...views.js: delimiter balance OK`
|
||||
|
||||
- [ ] **Step 4: Run the 13-module unittest loop (backend must stay green)**
|
||||
|
||||
Run from `C:\Users\root\Documents\Pineapple\pager-webui`:
|
||||
```powershell
|
||||
$py = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe"; Get-ChildItem tests\test_*.py | ForEach-Object { $mod = "tests." + [IO.Path]::GetFileNameWithoutExtension($_.Name); $out = & $py -m unittest $mod 2>&1; ($out | Select-Object -Last 1) }
|
||||
```
|
||||
Expected: every module reports `OK` (recon may show `skipped=1`).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add payload/user/general/pager-webui/www/js/views.js payload/user/general/pager-webui/www/css/app.css
|
||||
git commit -m "feat: PineAP overview stats cards + passive/active/advanced mode toggle"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Deploy and verify on device
|
||||
|
||||
**Files:** none (verification only; no commit).
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1 output (deployed via `scripts/deploy.ps1`).
|
||||
|
||||
- [ ] **Step 1: Deploy the payload and restart the webui**
|
||||
|
||||
Run from `C:\Users\root\Documents\Pineapple\pager-webui`:
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\deploy.ps1 -Password "<PAGER_PASSWORD>"
|
||||
```
|
||||
Then restart and confirm the port is up:
|
||||
```bash
|
||||
sshpass -p "<PAGER_PASSWORD>" ssh root@172.16.52.1 "/etc/init.d/pagerwebui restart; sleep 4; curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/api/api_ping"
|
||||
```
|
||||
Expected: `401` (auth required = running).
|
||||
|
||||
- [ ] **Step 2: Verify the three stat endpoints return live data on device**
|
||||
|
||||
Run (base64 the script then `echo ... | base64 -d | sh` over sshpass):
|
||||
```sh
|
||||
curl -s -c /tmp/pwj -X POST http://127.0.0.1:8080/api/login -H "Content-Type: application/json" -d '{"username":"root","password":"<PAGER_PASSWORD>"}' > /dev/null
|
||||
echo ssids:; curl -s -b /tmp/pwj http://127.0.0.1:8080/api/pineap/ssids
|
||||
echo clients:; curl -s -b /tmp/pwj http://127.0.0.1:8080/api/pineap/clients
|
||||
echo handshakes:; curl -s -b /tmp/pwj http://127.0.0.1:8080/api/pineap/handshakes
|
||||
```
|
||||
Expected: `ssids` returns `{"ssids":[...]}`, `clients` returns `{"clients":[],"count":0}` (or a number), `handshakes` returns `{"files":[...],"handshakes":[...]}`.
|
||||
|
||||
- [ ] **Step 3: Confirm the deployed files contain the new code**
|
||||
|
||||
```sh
|
||||
grep -c "seg-btn\|Total SSIDs in Pool\|Handshakes Captured" /root/payloads/user/general/pager-webui/www/js/views.js
|
||||
grep -c "\.seg" /root/payloads/user/general/pager-webui/www/css/app.css
|
||||
```
|
||||
Expected: counts greater than zero.
|
||||
|
||||
- [ ] **Step 4: Report for user UI walk**
|
||||
|
||||
Tell the user the overview now has: the three stat cards (numbers populate in the 5s poll; each `View` button navigates to its tab), the Passive/Active/Advanced segmented toggle (applies PineAP master + Karma; karma tracked client-side and shown on the Karma card; badge/description update; failures toast + revert), and unchanged Quick Settings. Ask them to refresh `http://172.16.52.1:8080/#/pineap` and confirm.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,908 @@
|
||||
# Recon Mark VII Parity 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:** Rework the Pager WebUI Recon section into a faithful clone of the stock Hak5 WiFi Pineapple (Mark VII) Recon UI — Mark VII title cards, scan bar, APs/Clients tables with search + pagination, settings sidebar, and a two-tab Recon (Scanning + Handshakes, no Events) — using only data the Pager backend already exposes, plus one optional-body change to `POST /api/recon/start`.
|
||||
|
||||
**Architecture:** All front-end changes live under `payload/user/general/pager-webui/www/` (vanilla JS SPA, no build step). The Mark VII layout/markup/colors were extracted from the old Angular bundle `main.ce5a318adf590e170f6d.js`. The scanning view is rewritten to mirror Mark VII's `.recon-title-card-container` structure; charts are extended hand-rolled `<canvas>` renderers (bar + doughnut with legend); the Events tab/route/view is removed. One backend function (`h_recon_start`) forwards an optional `scan_time` to the daemon call.
|
||||
|
||||
**Tech Stack:** Vanilla JS (ES6, `const`/arrow functions as used today), hand-rolled CSS via custom properties (light/dark), hand-rolled `<canvas>` charts, Python `server.py` for the one backend change, `unittest` for the backend test.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Device runtime: `python3-light` on WiFi Pineapple Pager 24.10.1 — no third-party pip packages, no build step.
|
||||
- Front-end must stay ES6-compatible (matches existing code).
|
||||
- No new `/api/*` surface. Only `h_recon_start` body semantics change: optional `scan_time` (int seconds; `0` = continuous). When absent, behavior is byte-for-byte the current `body={}`.
|
||||
- Auth/session mechanics unchanged.
|
||||
- Design tokens (light): content `#fafafa`, cards `#fff`, toolbar `#424242`, primary `#1976d2`, text `#212121` / muted `#686868`, border `#e0e0e0`. Dark: surfaces `#303030`, cards `#424242`, border `#545454`.
|
||||
- Mark VII chart palettes (verbatim from the old bundle):
|
||||
- Landscape doughnut: `#2ecc71` (Access Points), `#2980b9` (Clients), `#8e44ad` (Unassociated).
|
||||
- Channel bar palette (cycle through for bars): `#FC68AC,#4545FF,#19DE8F,#FF294A,#23E8DB,#0FD349,#4D4AFF,#E2FF68,#FF8368,#B1FF6A,#FFFF3B,#FF677E,#D0FF6E,#F57D67,#F828E4,#EAFF6D,#3676F9,#F169E8,#3B2AE4,#3197F5,#4040FF,#FFF26A,#FCAD67,#0ACE28,#FF9E68,#55FF4A,#F9FF68,#EE687E,#FFFC67,#FFE167,#7FFF6C,#FFF236,#F26868,#6DFF74,#F568D5,#FF402A,#CAFF69,#28C20A,#6B29E9,#C7FF40,#FFB631,#D429F3,#F868C1,#14D96B,#9E29EF,#8EFF45,#FF2980,#FD29B3,#FF7A2C,#FF6967,#FFD569,#27D6EC,#98FF6B,#1EE3B5,#FFFF6B,#FFB969,#FFFF6C,#FF6795,#0BC80A,#3B54FD,#F99467,#FFC667,#2CB7F1,#6EFF91`
|
||||
- `localStorage` keys: `pw_scan_duration` (int string, default `'30'`), `pw_recon_cols` (JSON `{ap:{...},client:{...}}`).
|
||||
- Recon has exactly two tabs: `Scanning` (`#/recon`) and `Handshakes` (`#/recon/handshakes`).
|
||||
- No Band select (Pager cannot single-band scan). No graph/2D/3D view. No AP focus sidebars.
|
||||
- Existing Python `unittest` suite must stay green (`tests/` run per-file).
|
||||
- Commits follow repo style (`feat:`, `fix:`, `docs:`).
|
||||
- Deploy: `.\scripts\deploy.ps1 -SshKey "$HOME\.ssh\pager_key" -Password "<PAGER_PASSWORD>"` (fall back to printed scp/ssh commands if no key/sshpass).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Backend — `h_recon_start` forwards optional `scan_time`
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/general/pager-webui/server.py:912-916` (`h_recon_start`)
|
||||
- Modify: `tests/test_recon.py` (`DaemonSockTest`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `daemon_sock_call('POST', '/api/pineap/log/recon/start', body=...)` (exists, returns `(status, data)`); handler `ctx` may or may not have a `body` attribute (existing test builds `type('C', (), {'args': ()})()` with no `body`).
|
||||
- Produces: `h_recon_start(ctx)` → reads `getattr(ctx, 'body', None)`, forwards `{'scan_time': int}` when `scan_time` present, else `{}`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Add to `tests/test_recon.py`, inside `class DaemonSockTest` (after `test_start_stop_handlers_call_socket`):
|
||||
|
||||
```python
|
||||
def test_start_forwards_scan_time(self):
|
||||
calls = []
|
||||
server.daemon_sock_call = lambda m, p, body=None: calls.append((m, p, body)) or (200, {'success': True})
|
||||
ctx = type('C', (), {'args': (), 'body': {'scan_time': 60}})()
|
||||
status, data = server.h_recon_start(ctx)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(calls[0], ('POST', '/api/pineap/log/recon/start', {'scan_time': 60}))
|
||||
|
||||
def test_start_defaults_empty_body(self):
|
||||
calls = []
|
||||
server.daemon_sock_call = lambda m, p, body=None: calls.append((m, p, body)) or (200, {'success': True})
|
||||
server.h_recon_start(type('C', (), {'args': ()})())
|
||||
self.assertEqual(calls[0], ('POST', '/api/pineap/log/recon/start', {}))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails**
|
||||
|
||||
```powershell
|
||||
& "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe" -m unittest tests.test_recon.DaemonSockTest -v
|
||||
```
|
||||
Expected: `test_start_forwards_scan_time` FAIL (body is `{}`), `test_start_defaults_empty_body` FAIL (AttributeError on `ctx.body`).
|
||||
|
||||
- [ ] **Step 3: Implement the change**
|
||||
|
||||
Replace `h_recon_start` (currently lines 912-916):
|
||||
|
||||
```python
|
||||
def h_recon_start(ctx):
|
||||
body = {}
|
||||
scan_time = (getattr(ctx, 'body', None) or {}).get('scan_time')
|
||||
if scan_time is not None:
|
||||
body['scan_time'] = int(scan_time)
|
||||
status, data = daemon_sock_call('POST', '/api/pineap/log/recon/start', body=body)
|
||||
if status != 200 or not (data or {}).get('success'):
|
||||
return 502, {'error': 'daemon recon start failed'}
|
||||
return 200, {'ok': True}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes**
|
||||
|
||||
```powershell
|
||||
& "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe" -m unittest tests.test_recon.DaemonSockTest -v
|
||||
```
|
||||
Expected: all DaemonSockTest tests PASS.
|
||||
|
||||
- [ ] **Step 5: Run the full suite for regressions**
|
||||
|
||||
```powershell
|
||||
Get-ChildItem tests\test_*.py | ForEach-Object {
|
||||
$mod = "tests." + [IO.Path]::GetFileNameWithoutExtension($_.Name)
|
||||
& "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe" -m unittest $mod -v
|
||||
}
|
||||
```
|
||||
Expected: all modules PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add payload/user/general/pager-webui/server.py tests/test_recon.py
|
||||
git commit -m "feat: forward optional scan_time to daemon on recon start"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Charts — doughnut with legend + bar chart in `chart.js`
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/general/pager-webui/www/js/chart.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces (consumed by Task 5):
|
||||
- `MiniChart.doughnut(canvas, segments, opts)` — segments `[{label, value, color}]`; opts `{legend: bool, height: number, hole: number}`. Draws a ring doughnut (hole radius = `hole` × outer radius, default `0.65`) and, when `legend` is truthy, a legend row beneath (color dot + label, centered). Segments sum to 0 → draw empty ring and no legend.
|
||||
- `MiniChart.bar(canvas, items, opts)` — items `[{label, value, color}]`; opts `{height: number, grid: color}`. X axis labels = item labels (below chart), bars from baseline with item colors, Y gridlines, Y max = max value (min 1), no legend.
|
||||
- Unchanged: `MiniChart.draw` (dashboard line chart).
|
||||
|
||||
- [ ] **Step 1: Rewrite `chart.js`**
|
||||
|
||||
Replace the whole file with:
|
||||
|
||||
```js
|
||||
'use strict';
|
||||
|
||||
const MiniChart = (() => {
|
||||
function draw(canvas, series, opts) {
|
||||
const o = opts || {};
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = canvas.clientWidth * dpr;
|
||||
canvas.height = 140 * dpr;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
const w = canvas.clientWidth, h = 140;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
const max = Math.max(o.max || 10, ...series.map((s) => Math.max(...s.points, 0)), 1);
|
||||
const pad = 8;
|
||||
ctx.strokeStyle = o.grid || '#e0e0e0';
|
||||
ctx.lineWidth = 1;
|
||||
for (let g = 0; g <= 4; g++) {
|
||||
const y = pad + (h - pad * 2) * g / 4;
|
||||
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke();
|
||||
}
|
||||
series.forEach((s) => {
|
||||
const pts = s.points;
|
||||
if (!pts || pts.length < 2) return;
|
||||
ctx.strokeStyle = s.color || '#1976d2';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
let started = false;
|
||||
pts.forEach((v, i) => {
|
||||
if (v == null) { started = false; return; }
|
||||
const x = pad + (w - pad * 2) * i / Math.max(pts.length - 1, 1);
|
||||
const y = h - pad - (h - pad * 2) * (v / max);
|
||||
if (!started) { ctx.moveTo(x, y); started = true; } else ctx.lineTo(x, y);
|
||||
});
|
||||
ctx.stroke();
|
||||
const last = pts[pts.length - 1];
|
||||
if (last != null) {
|
||||
const x = pad + (w - pad * 2) * (pts.length - 1) / Math.max(pts.length - 1, 1);
|
||||
const y = h - pad - (h - pad * 2) * (last / max);
|
||||
ctx.fillStyle = s.color || '#1976d2';
|
||||
ctx.beginPath(); ctx.arc(x, y, 3, 0, Math.PI * 2); ctx.fill();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function doughnut(canvas, segments, opts) {
|
||||
const o = opts || {};
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const legendH = o.legend ? 22 : 0;
|
||||
const H = (o.height || 160) + legendH;
|
||||
canvas.width = canvas.clientWidth * dpr;
|
||||
canvas.height = H * dpr;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
const w = canvas.clientWidth, h = o.height || 160;
|
||||
ctx.clearRect(0, 0, w, H);
|
||||
const cx = w / 2, cy = h / 2;
|
||||
const r = Math.min(w, h) / 2 - 8;
|
||||
const hole = (o.hole == null ? 0.65 : o.hole) * r;
|
||||
const total = segments.reduce((s, x) => s + x.value, 0);
|
||||
if (!total) {
|
||||
ctx.strokeStyle = '#e0e0e0';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.stroke();
|
||||
ctx.beginPath(); ctx.arc(cx, cy, hole, 0, Math.PI * 2); ctx.stroke();
|
||||
return;
|
||||
}
|
||||
let a0 = -Math.PI / 2;
|
||||
segments.forEach((seg) => {
|
||||
const a1 = a0 + (seg.value / total) * Math.PI * 2;
|
||||
ctx.fillStyle = seg.color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(cx, cy, r, a0, a1);
|
||||
ctx.arc(cx, cy, hole, a1, a0, true);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
a0 = a1;
|
||||
});
|
||||
ctx.strokeStyle = o.stroke || '#ffffff';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.stroke();
|
||||
ctx.beginPath(); ctx.arc(cx, cy, hole, 0, Math.PI * 2); ctx.stroke();
|
||||
if (o.legend) {
|
||||
ctx.font = '11px Roboto, "Segoe UI", Arial, sans-serif';
|
||||
const dots = segments.filter((s) => s.value > 0);
|
||||
const text = dots.map((s) => s.label).join(' ');
|
||||
let tw = 0;
|
||||
dots.forEach((s) => { tw += 16 + ctx.measureText(s.label).width + 8; });
|
||||
tw = Math.max(tw - 8, 0);
|
||||
let x = (w - tw) / 2;
|
||||
const ly = h + 13;
|
||||
dots.forEach((s) => {
|
||||
ctx.fillStyle = s.color;
|
||||
ctx.beginPath(); ctx.arc(x + 4, ly - 3, 4, 0, Math.PI * 2); ctx.fill();
|
||||
ctx.fillStyle = '#686868';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.fillText(s.label, x + 12, ly);
|
||||
x += 16 + ctx.measureText(s.label).width + 8;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function bar(canvas, items, opts) {
|
||||
const o = opts || {};
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const H = o.height || 160;
|
||||
canvas.width = canvas.clientWidth * dpr;
|
||||
canvas.height = H * dpr;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
const w = canvas.clientWidth, h = H;
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
if (!items || !items.length) return;
|
||||
const max = Math.max(1, ...items.map((i) => i.value));
|
||||
const padB = 16, padT = 8, padL = 6, padR = 6;
|
||||
const plotW = w - padL - padR, plotH = h - padT - padB;
|
||||
const bw = plotW / items.length;
|
||||
ctx.strokeStyle = o.grid || '#e0e0e0';
|
||||
ctx.lineWidth = 1;
|
||||
for (let g = 0; g <= 4; g++) {
|
||||
const y = padT + plotH * g / 4;
|
||||
ctx.beginPath(); ctx.moveTo(padL, y); ctx.lineTo(w - padR, y); ctx.stroke();
|
||||
}
|
||||
items.forEach((it, i) => {
|
||||
const bh = it.value / max * plotH;
|
||||
const x = padL + bw * i + bw * 0.15;
|
||||
const wd = bw * 0.7;
|
||||
const y = padT + plotH - bh;
|
||||
ctx.fillStyle = it.color;
|
||||
ctx.fillRect(x, y, wd, bh);
|
||||
ctx.fillStyle = '#686868';
|
||||
ctx.font = '10px Roboto, "Segoe UI", Arial, sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText(String(it.label), padL + bw * i + bw / 2, h - 4);
|
||||
});
|
||||
}
|
||||
|
||||
return { draw, doughnut, bar };
|
||||
})();
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Sanity-check the file**
|
||||
|
||||
```powershell
|
||||
$c = Get-Content -Raw payload\user\general\pager-webui\www\js\chart.js
|
||||
if ($c -match 'function doughnut' -and $c -match 'function bar' -and $c -match 'return \{ draw, doughnut, bar \}') { 'chart.js OK' }
|
||||
```
|
||||
Expected: `chart.js OK`.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add payload/user/general/pager-webui/www/js/chart.js
|
||||
git commit -m "feat: add doughnut legend and bar chart to MiniChart"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Icons — Material path data for new buttons
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/general/pager-webui/www/js/icons.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces (consumed by Task 5): new keys on `PineappleIcons` — `refresh`, `file_download`, `delete`, `settings`, `search`, `first_page`, `last_page`, `chevron_left`, `chevron_right`. Each is a full inline `<svg viewBox="0 0 24 24" fill="currentColor"><path d="..."/></svg>`.
|
||||
|
||||
- [ ] **Step 1: Append the new icons**
|
||||
|
||||
Inside the `PineappleIcons` object (after the `receipt` line), add (note trailing commas between entries, last entry has none):
|
||||
|
||||
```js
|
||||
refresh: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M17.65,6.35C16.2,4.9 14.21,4 12,4A8,8 0 0,0 4,12A8,8 0 0,0 12,20C15.73,20 18.84,17.45 19.73,14H17.65C16.83,16.33 14.61,18 12,18A6,6 0 0,1 6,12A6,6 0 0,1 12,6C13.66,6 15.14,6.69 16.22,7.78L13,11H20V4L17.65,6.35Z"/></svg>',
|
||||
file_download: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19,9H15V3H9V9H5L12,16L19,9M11,18H13V22H11V18Z"/></svg>',
|
||||
delete: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6,19C6,20.1 6.9,21 8,21H16C17.1,21 18,20.1 18,19V7H6V19M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19V4Z"/></svg>',
|
||||
settings: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19.14,12.94C19.18,12.64 19.2,12.33 19.2,12C19.2,11.68 19.18,11.36 19.13,11.06L21.16,9.48C21.34,9.34 21.39,9.07 21.28,8.87L19.36,5.55C19.24,5.33 18.99,5.26 18.77,5.33L16.38,6.29C15.88,5.91 15.35,5.59 14.76,5.35L14.4,2.81C14.36,2.57 14.16,2.4 13.92,2.4H10.08C9.84,2.4 9.65,2.57 9.61,2.81L9.25,5.35C8.66,5.59 8.12,5.91 7.63,6.29L5.24,5.33C5.02,5.26 4.77,5.33 4.65,5.55L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48L4.89,11.06C4.84,11.36 4.8,11.67 4.8,12C4.8,12.33 4.82,12.64 4.87,12.94L2.84,14.52C2.66,14.66 2.61,14.93 2.72,15.13L4.64,18.45C4.76,18.67 5.01,18.74 5.23,18.67L7.62,17.71C8.12,18.09 8.65,18.41 9.24,18.65L9.6,21.19C9.65,21.43 9.84,21.6 10.08,21.6H13.92C14.16,21.6 14.36,21.43 14.4,21.19L14.76,18.65C15.35,18.41 15.88,18.09 16.38,17.71L18.77,18.67C18.99,18.74 19.24,18.67 19.36,18.45L21.28,15.13C21.39,14.93 21.34,14.66 21.16,14.52L19.14,12.94M12,15.6C10.02,15.6 8.4,13.98 8.4,12C8.4,10.02 10.02,8.4 12,8.4C13.98,8.4 15.6,10.02 15.6,12C15.6,13.98 13.98,15.6 12,15.6Z"/></svg>',
|
||||
search: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M15.5,14H14.71L14.43,13.73C15.41,12.59 16,11.11 16,9.5C16,5.91 13.09,3 9.5,3C5.91,3 3,5.91 3,9.5C3,13.09 5.91,16 9.5,16C11.11,16 12.59,15.41 13.73,14.43L14,14.71V15.5L19,20.49L20.49,19L15.5,14M9.5,14C7.01,14 5,11.99 5,9.5C5,7.01 7.01,5 9.5,5C11.99,5 14,7.01 14,9.5C14,11.99 11.99,14 9.5,14Z"/></svg>',
|
||||
first_page: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M18.41,16.59L13.82,12L18.41,7.41L17,6L11,12L17,18L18.41,16.59M6,6H8V18H6V6Z"/></svg>',
|
||||
last_page: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M5.59,7.41L10.18,12L5.59,16.59L7,18L13,12L7,6L5.59,7.41M16,6H18V18H16V6Z"/></svg>',
|
||||
chevron_left: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M15.41,16.58L10.83,12L15.41,7.41L14,6L8,12L14,18L15.41,16.58Z"/></svg>',
|
||||
chevron_right: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z"/></svg>'
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify**
|
||||
|
||||
```powershell
|
||||
$c = Get-Content -Raw payload\user\general\pager-webui\www\js\icons.js
|
||||
@('refresh','file_download','delete','settings','search','first_page','last_page','chevron_left','chevron_right') | ForEach-Object { if ($c -match $_ + ':') { "$_ OK" } else { "$_ MISSING" } }
|
||||
```
|
||||
Expected: all `OK`.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add payload/user/general/pager-webui/www/js/icons.js
|
||||
git commit -m "feat: add Material action icons for recon rework"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: CSS — Mark VII recon styles
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/general/pager-webui/www/css/app.css` (append; do not remove existing classes)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: existing custom properties (`--surface`, `--border`, `--muted`, `--primary`, `--shadow`, `--text`), `html.dark` overrides.
|
||||
- Produces (consumed by Task 5): classes `.recon-title-card-container`, `.recon-title-card`, `.recon-card`, `.recon-title-card-title`, `.recon-card-title-link`, `.recon-title-card-content`, `.recon-chart-box`, `.recon-no-data`, `.recon-hs-col`, `.recon-hs-count`, `.recon-hs-label`, `.recon-toggle`, `.recon-ps-row`, `.recon-scan-bar`, `.recon-table-head`, `.recon-search`, `.recon-paginator`, `.icon-btn`, `.recon-scan-results-card`, `.recon-table-body`, `.recon-settings-sidebar`, `.recon-settings-head`, `.recon-settings-title`, `.recon-settings-section`, `.recon-row-selected`.
|
||||
|
||||
- [ ] **Step 1: Append the recon stylesheet block**
|
||||
|
||||
Append to `app.css`:
|
||||
|
||||
```css
|
||||
/* ---- Recon (Mark VII parity) ---- */
|
||||
.recon-title-card-container { display: flex; width: 100%; flex-wrap: wrap; justify-content: space-between; gap: 10px; margin: 8px 0 16px; }
|
||||
.recon-title-card { flex: 1 1 220px; min-width: 220px; margin-bottom: 1em; }
|
||||
.recon-card { background: var(--surface); border-radius: 2px; box-shadow: var(--shadow); height: 200px; padding: 12px 16px; display: flex; flex-direction: column; }
|
||||
.recon-title-card-title { font-size: 20px; margin-bottom: 15px; display: flex; align-items: center; color: var(--text); }
|
||||
.recon-card-title-link { color: inherit; text-decoration: none; }
|
||||
.recon-card-title-link:visited { color: inherit; }
|
||||
.recon-card-title-link:hover { text-decoration: underline; }
|
||||
.recon-title-card-content { display: flex; justify-content: center; align-items: center; height: 70%; }
|
||||
.recon-chart-box { width: 100%; height: 150px; position: relative; }
|
||||
.recon-chart-box canvas { width: 100%; height: 100%; }
|
||||
.recon-no-data { font-style: italic; color: #787878; display: flex; justify-content: center; padding: 12px; }
|
||||
.recon-hs-col { display: flex; flex-direction: column; justify-content: center; align-items: center; }
|
||||
.recon-hs-count { font-size: 32px; font-weight: 700; line-height: 1.1; }
|
||||
.recon-hs-label { color: grey; margin: 2px 0 10px; }
|
||||
.recon-toggle { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text); margin: 0; cursor: pointer; }
|
||||
.recon-ps-row { display: flex; align-items: center; width: 100%; gap: 4px; }
|
||||
.recon-ps-row .sel { width: 100%; }
|
||||
.icon-btn { background: transparent; color: var(--muted); border: 0; border-radius: 50%; width: 36px; height: 36px; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; padding: 0; }
|
||||
.icon-btn:hover { background: var(--surface-alt); color: var(--text); }
|
||||
.icon-btn:disabled { opacity: .38; cursor: default; }
|
||||
.icon-btn svg { width: 22px; height: 22px; }
|
||||
.recon-scan-bar { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
|
||||
.recon-scan-bar .sel { width: auto; }
|
||||
.recon-scan-results-card { }
|
||||
.recon-table-head { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; flex-wrap: wrap; }
|
||||
.recon-table-head h2 { margin: 0; }
|
||||
.recon-search { max-width: 180px; }
|
||||
.recon-paginator { display: flex; align-items: center; gap: 2px; font-size: 12px; }
|
||||
.recon-paginator .icon-btn { width: 30px; height: 30px; }
|
||||
.recon-paginator .icon-btn svg { width: 18px; height: 18px; }
|
||||
.recon-table-body { }
|
||||
.recon-row-selected td { background: #eaeaea; }
|
||||
html.dark .recon-row-selected td { background: #565656; }
|
||||
.recon-settings-sidebar {
|
||||
position: fixed; top: 64px; right: 0; bottom: 0; width: 270px; z-index: 50;
|
||||
background: var(--surface); box-shadow: -2px 0 6px rgba(0,0,0,.24); padding: 16px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.recon-settings-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
||||
.recon-settings-title { font-size: 20px; }
|
||||
.recon-settings-section { font-size: 14px; font-weight: 500; margin: 14px 0 4px; color: var(--muted); }
|
||||
.recon-settings-sidebar .toggle { font-size: 13px; }
|
||||
.recon-handshakes-card .recon-table-head h2 { font-size: 20px; margin-bottom: 15px; }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Sanity-check**
|
||||
|
||||
```powershell
|
||||
Select-String -Path payload\user\general\pager-webui\www\css\app.css -Pattern 'recon-title-card-container','recon-scan-bar','recon-settings-sidebar','recon-row-selected'
|
||||
```
|
||||
Expected: all four found.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add payload/user/general/pager-webui/www/css/app.css
|
||||
git commit -m "feat: Mark VII recon styles (title cards, scan bar, tables, settings sidebar)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Views — rewrite `views.recon`, restyle handshakes, remove Events
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/general/pager-webui/www/js/views.js`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `h`, `table`, `fmtTime`, `btn`, `tabBar` (all existing module-level helpers), `PagerAPI`, `App.toast`, `App.apiBase`, `PineappleIcons` (Task 3), `MiniChart.doughnut` / `MiniChart.bar` (Task 2).
|
||||
- Produces: module-level `iconBtn(name, title, onclk)` helper; `RECON_TABS` (2 entries); `views.recon`; restyled `views.recon_handshakes`. **Deletes** `views.recon_events`.
|
||||
|
||||
- [ ] **Step 1: Add the `iconBtn` helper**
|
||||
|
||||
After the `btn` helper definition (near line 53):
|
||||
|
||||
```js
|
||||
const iconBtn = (name, title, onclk) => {
|
||||
const b = h('button', { class: 'icon-btn', title: title || '', onclick: onclk });
|
||||
b.innerHTML = PineappleIcons[name] || '';
|
||||
return b;
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace `RECON_TABS` and add constants**
|
||||
|
||||
Replace the current `RECON_TABS` (3 entries) with:
|
||||
|
||||
```js
|
||||
const RECON_TABS = [
|
||||
{ label: 'Scanning', hash: '#/recon' },
|
||||
{ label: 'Handshakes', hash: '#/recon/handshakes' }
|
||||
];
|
||||
|
||||
const RECON_LANDSCAPE_COLORS = ['#2ecc71', '#2980b9', '#8e44ad'];
|
||||
const RECON_CHANNEL_COLORS = ['#FC68AC','#4545FF','#19DE8F','#FF294A','#23E8DB','#0FD349','#4D4AFF','#E2FF68','#FF8368','#B1FF6A','#FFFF3B','#FF677E','#D0FF6E','#F57D67','#F828E4','#EAFF6D','#3676F9','#F169E8','#3B2AE4','#3197F5','#4040FF','#FFF26A','#FCAD67','#0ACE28','#FF9E68','#55FF4A','#F9FF68','#EE687E','#FFFC67','#FFE167','#7FFF6C','#FFF236','#F26868','#6DFF74','#F568D5','#FF402A','#CAFF69','#28C20A','#6B29E9','#C7FF40','#FFB631','#D429F3','#F868C1','#14D96B','#9E29EF','#8EFF45','#FF2980','#FD29B3','#FF7A2C','#FF6967','#FFD569','#27D6EC','#98FF6B','#1EE3B5','#FFFF6B','#FFB969','#FFFF6C','#FF6795','#0BC80A','#3B54FD','#F99467','#FFC667','#2CB7F1','#6EFF91'];
|
||||
const RECON_AP_COLS = [
|
||||
{ key: 'ssid', label: 'SSID', render: (a) => a.ssid || '(hidden)' },
|
||||
{ key: 'bssid', label: 'MAC', render: (a) => a.bssid || '--' },
|
||||
{ key: 'channel', label: 'Channel', render: (a) => a.channel == null ? '--' : a.channel },
|
||||
{ key: 'signal', label: 'Signal', render: (a) => a.signal == null ? '--' : a.signal + ' dBm' },
|
||||
{ key: 'encryption', label: 'Encryption', render: (a) => a.encryption || '--' },
|
||||
{ key: 'hidden', label: 'Hidden', render: (a) => a.hidden ? 'Yes' : 'No' }
|
||||
];
|
||||
const RECON_CLIENT_COLS = [
|
||||
{ key: 'mac', label: 'Client MAC', render: (c) => c.mac },
|
||||
{ key: 'signal', label: 'Signal', render: (c) => c.signal == null ? '--' : c.signal + ' dBm' },
|
||||
{ key: 'freq', label: 'Frequency', render: (c) => c.freq || '--' },
|
||||
{ key: 'packets', label: 'Packets', render: (c) => c.packets || 0 }
|
||||
];
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Replace `views.recon`**
|
||||
|
||||
Replace the entire `views.recon = (root) => {...};` block (lines 364-525 in the current file) with:
|
||||
|
||||
```js
|
||||
function reconDefaultCols() {
|
||||
return {
|
||||
ap: { ssid: true, bssid: true, channel: true, signal: true, encryption: true, hidden: true },
|
||||
client: { mac: true, signal: true, freq: true, packets: true }
|
||||
};
|
||||
}
|
||||
|
||||
function reconLoadCols() {
|
||||
try {
|
||||
const v = JSON.parse(localStorage.getItem('pw_recon_cols'));
|
||||
if (v && v.ap && v.client) return v;
|
||||
} catch (e) {}
|
||||
return reconDefaultCols();
|
||||
}
|
||||
|
||||
function reconFiltered(rows, q, colsArr) {
|
||||
const ql = (q || '').toLowerCase();
|
||||
if (!ql) return rows;
|
||||
return rows.filter((r) => colsArr.some((c) => String(r[c.key] == null ? '' : r[c.key]).toLowerCase().indexOf(ql) !== -1));
|
||||
}
|
||||
|
||||
views.recon = (root) => {
|
||||
root.appendChild(h('h1', { class: 'page-title', text: 'Recon' }));
|
||||
tabBar(root, RECON_TABS, '#/recon');
|
||||
|
||||
const state = { scans: [], selected: null, detail: null, active: false,
|
||||
apPage: 0, apSearch: '', cliPage: 0, cliSearch: '' };
|
||||
const cols = reconLoadCols();
|
||||
|
||||
function iconBtnView(name, title, onclk) { return iconBtn(name, title, onclk); }
|
||||
|
||||
// ---- title cards ----
|
||||
const cardWrap = h('div', { class: 'recon-title-card-container' });
|
||||
root.appendChild(cardWrap);
|
||||
|
||||
function titleCard(titleText, link) {
|
||||
const wrap = h('div', { class: 'recon-title-card' });
|
||||
const card = h('div', { class: 'recon-card' });
|
||||
wrap.appendChild(card);
|
||||
card.appendChild(link
|
||||
? h('a', { class: 'recon-card-title-link', href: '#/recon/handshakes', text: titleText })
|
||||
: h('div', { class: 'recon-title-card-title', text: titleText }));
|
||||
const content = h('div', { class: 'recon-title-card-content' });
|
||||
card.appendChild(content);
|
||||
cardWrap.appendChild(wrap);
|
||||
return content;
|
||||
}
|
||||
|
||||
const landContent = titleCard('Wireless Landscape', false);
|
||||
const landBox = h('div', { class: 'recon-chart-box' });
|
||||
landContent.appendChild(landBox);
|
||||
const landCanvas = h('canvas', { id: 'recon-landscape' });
|
||||
landBox.appendChild(landCanvas);
|
||||
const landEmpty = h('div', { class: 'recon-no-data', text: 'No wireless landscape data is available yet.' });
|
||||
landBox.appendChild(landEmpty);
|
||||
|
||||
const chanContent = titleCard('Channel Distribution', false);
|
||||
const chanBox = h('div', { class: 'recon-chart-box' });
|
||||
chanContent.appendChild(chanBox);
|
||||
const chanCanvas = h('canvas', { id: 'recon-channel' });
|
||||
chanBox.appendChild(chanCanvas);
|
||||
const chanEmpty = h('div', { class: 'recon-no-data', text: 'No channel distribution data is available yet.' });
|
||||
chanBox.appendChild(chanEmpty);
|
||||
|
||||
const hsContent = titleCard('Handshakes', true);
|
||||
const hsCol = h('div', { class: 'recon-hs-col' });
|
||||
hsContent.appendChild(hsCol);
|
||||
const hsCount = h('span', { class: 'recon-hs-count', text: '0' });
|
||||
hsCol.appendChild(hsCount);
|
||||
hsCol.appendChild(h('span', { class: 'recon-hs-label', text: 'Handshakes Captured' }));
|
||||
const hsAuto = h('label', { class: 'recon-toggle' },
|
||||
h('input', { type: 'checkbox', id: 'recon-auto-hs' }), ' Automatically Collect Any Handshakes');
|
||||
hsAuto.querySelector('input').addEventListener('change', () => {
|
||||
PagerAPI.post('/api/pineap/settings', { collect_handshakes: hsAuto.querySelector('input').checked })
|
||||
.then(() => App.toast('Settings saved')).catch(() => App.toast('Failed to save', 'error'));
|
||||
});
|
||||
hsCol.appendChild(hsAuto);
|
||||
|
||||
const psContent = titleCard('Previous Scans', false);
|
||||
const psRow = h('div', { class: 'recon-ps-row' });
|
||||
psContent.appendChild(psRow);
|
||||
const sel = h('select', { class: 'sel', id: 'recon-scan-select' });
|
||||
sel.addEventListener('change', () => {
|
||||
state.selected = parseInt(sel.value, 10) || null;
|
||||
state.apPage = 0; state.cliPage = 0;
|
||||
loadDetail();
|
||||
});
|
||||
psRow.appendChild(sel);
|
||||
psRow.appendChild(iconBtnView('file_download', 'Download scan JSON', () => {
|
||||
if (state.selected != null) window.location = App.apiBase + '/api/recon/scans/' + state.selected + '/download/json';
|
||||
}));
|
||||
psRow.appendChild(iconBtnView('delete', 'Delete scan', () => {
|
||||
if (state.selected == null) return;
|
||||
if (!confirm('Delete scan #' + state.selected + '? This cannot be undone.')) return;
|
||||
PagerAPI.del('/api/recon/scans/' + state.selected)
|
||||
.then(() => { App.toast('Scan deleted'); load(); })
|
||||
.catch(() => App.toast('Delete failed', 'error'));
|
||||
}));
|
||||
|
||||
// ---- scan bar ----
|
||||
const scanBar = h('div', { class: 'section recon-scan-bar' });
|
||||
root.appendChild(scanBar);
|
||||
const scanToggle = h('input', { type: 'checkbox', id: 'recon-scan-toggle' });
|
||||
const scanLabel = h('label', { class: 'switch recon-scan-toggle' }, scanToggle, h('span', { class: 'track' }), ' Scan');
|
||||
scanBar.appendChild(scanLabel);
|
||||
const durSel = h('select', { class: 'sel', id: 'recon-duration' });
|
||||
[[30, '30 Seconds'], [60, '1 Minute'], [120, '2 Minutes'], [300, '5 Minutes'], [600, '10 Minutes'], [0, 'Continuous']]
|
||||
.forEach(([v, t]) => durSel.appendChild(h('option', { value: String(v), text: t })));
|
||||
durSel.value = localStorage.getItem('pw_scan_duration') || '30';
|
||||
durSel.addEventListener('change', () => localStorage.setItem('pw_scan_duration', durSel.value));
|
||||
scanBar.appendChild(durSel);
|
||||
scanBar.appendChild(h('span', { class: 'toolbar-spacer' }));
|
||||
scanBar.appendChild(iconBtnView('settings', 'Recon settings', () => sidebar.classList.toggle('hidden')));
|
||||
scanToggle.addEventListener('change', () => {
|
||||
const on = scanToggle.checked;
|
||||
scanToggle.disabled = true;
|
||||
PagerAPI.post(on ? '/api/recon/start' : '/api/recon/stop', on ? { scan_time: parseInt(durSel.value, 10) } : {})
|
||||
.then(() => { App.toast(on ? 'Scan started' : 'Scan stopped'); load(); })
|
||||
.catch(() => { scanToggle.checked = !on; App.toast('Recon control failed', 'error'); })
|
||||
.finally(() => { scanToggle.disabled = false; });
|
||||
});
|
||||
|
||||
// ---- settings sidebar ----
|
||||
const sidebar = h('div', { class: 'recon-settings-sidebar hidden' });
|
||||
sidebar.appendChild(h('div', { class: 'recon-settings-head' },
|
||||
h('span', { class: 'recon-settings-title', text: 'Recon Settings' }),
|
||||
btn('×', () => sidebar.classList.add('hidden'), 'ghost')));
|
||||
const colDefs = {
|
||||
ap: [['ssid', 'Show SSID'], ['bssid', 'Show MAC'], ['channel', 'Show Channel'],
|
||||
['signal', 'Show Signal'], ['encryption', 'Show Encryption'], ['hidden', 'Show Hidden']],
|
||||
client: [['mac', 'Show MAC'], ['signal', 'Show Signal'], ['freq', 'Show Frequency'], ['packets', 'Show Packets']]
|
||||
};
|
||||
Object.keys(colDefs).forEach((grp) => {
|
||||
sidebar.appendChild(h('div', { class: 'recon-settings-section', text: grp === 'ap' ? 'Access Points' : 'Clients' }));
|
||||
colDefs[grp].forEach(([key, label]) => {
|
||||
const cb = h('input', { type: 'checkbox', id: 'col-' + grp + '-' + key });
|
||||
cb.checked = cols[grp][key];
|
||||
cb.addEventListener('change', () => { cols[grp][key] = cb.checked; localStorage.setItem('pw_recon_cols', JSON.stringify(cols)); renderTables(); });
|
||||
sidebar.appendChild(h('label', { class: 'toggle' }, cb, ' ' + label));
|
||||
});
|
||||
});
|
||||
root.appendChild(sidebar);
|
||||
|
||||
// ---- results tables ----
|
||||
const apCard = h('div', { class: 'section recon-scan-results-card' });
|
||||
root.appendChild(apCard);
|
||||
const cliCard = h('div', { class: 'section recon-scan-results-card' });
|
||||
root.appendChild(cliCard);
|
||||
|
||||
function buildPaginator(key) {
|
||||
const mk = (id, label, fn) => {
|
||||
const b = h('button', { class: 'icon-btn', id: key + '-' + id, title: label });
|
||||
b.innerHTML = PineappleIcons[['first', 'last'].indexOf(id) !== -1
|
||||
? (id === 'first' ? 'first_page' : 'last_page')
|
||||
: (id === 'prev' ? 'chevron_left' : 'chevron_right')] || '';
|
||||
b.addEventListener('click', fn);
|
||||
return b;
|
||||
};
|
||||
return h('div', { class: 'recon-paginator' },
|
||||
mk('first', 'First page', () => { state[key + 'Page'] = 0; renderTables(); }),
|
||||
mk('prev', 'Previous page', () => { state[key + 'Page'] = Math.max(0, state[key + 'Page'] - 1); renderTables(); }),
|
||||
h('span', { class: 'muted', id: key + '-range', text: '' }),
|
||||
mk('next', 'Next page', () => { state[key + 'Page'] = Math.min(reconPageCount(key) - 1, state[key + 'Page'] + 1); renderTables(); }),
|
||||
mk('last', 'Last page', () => { state[key + 'Page'] = Math.max(0, reconPageCount(key) - 1); renderTables(); }));
|
||||
}
|
||||
|
||||
function reconPageCount(key) {
|
||||
const d = state.detail || {};
|
||||
const rows = key === 'ap' ? (d.aps || []) : (d.clients || []);
|
||||
const colsArr = key === 'ap' ? RECON_AP_COLS : RECON_CLIENT_COLS;
|
||||
const q = key === 'ap' ? state.apSearch : state.cliSearch;
|
||||
return Math.max(1, Math.ceil(reconFiltered(rows, q, colsArr).length / 10));
|
||||
}
|
||||
|
||||
function tableHead(box, title, key, searchId, onInput) {
|
||||
const head = h('div', { class: 'recon-table-head' },
|
||||
h('h2', { text: title }),
|
||||
h('span', { class: 'toolbar-spacer' }),
|
||||
h('input', { class: 'recon-search', id: searchId, placeholder: 'Search' }),
|
||||
buildPaginator(key));
|
||||
box.appendChild(head);
|
||||
const input = head.querySelector('#' + searchId);
|
||||
input.addEventListener('input', onInput);
|
||||
return head;
|
||||
}
|
||||
|
||||
tableHead(apCard, 'Access Points', 'ap', 'ap-search', () => { state.apSearch = document.getElementById('ap-search').value; state.apPage = 0; renderTables(); });
|
||||
const apBody = h('div', { class: 'recon-table-body' });
|
||||
apCard.appendChild(apBody);
|
||||
|
||||
tableHead(cliCard, 'Clients', 'client', 'cli-search', () => { state.cliSearch = document.getElementById('cli-search').value; state.cliPage = 0; renderTables(); });
|
||||
const cliBody = h('div', { class: 'recon-table-body' });
|
||||
cliCard.appendChild(cliBody);
|
||||
|
||||
function renderTable(box, key, rows, colsArr, selectedBssid, emptyMsg) {
|
||||
box.innerHTML = '';
|
||||
const vis = colsArr.filter((c) => cols[key][c.key]);
|
||||
const page = state[key + 'Page'];
|
||||
const start = page * 10;
|
||||
const slice = rows.slice(start, start + 10);
|
||||
box.appendChild(table(vis, slice,
|
||||
key === 'ap' ? (r) => ({ style: 'cursor:pointer' + (r.bssid === selectedBssid ? ';background:var(--surface-alt)' : '') }) : undefined));
|
||||
if (!rows.length) box.appendChild(h('div', { class: 'empty', text: emptyMsg }));
|
||||
const range = document.getElementById(key + '-range');
|
||||
if (range) range.textContent = rows.length ? (start + 1) + '–' + Math.min(start + 10, rows.length) + ' of ' + rows.length : '0 of 0';
|
||||
const p = reconPageCount(key);
|
||||
[['first', 0], ['prev', 0], ['next', p - 1], ['last', p - 1]].forEach(([id, limit]) => {
|
||||
const el = document.getElementById(key + '-' + id);
|
||||
if (el) el.disabled = page >= p - 1 && limit !== 0 ? true : (page <= 0 && (id === 'first' || id === 'prev'));
|
||||
});
|
||||
}
|
||||
|
||||
function renderTables() {
|
||||
const d = state.detail || { aps: [], clients: [], handshakes: [] };
|
||||
const apRows = reconFiltered(d.aps || [], state.apSearch, RECON_AP_COLS);
|
||||
const cliRows = reconFiltered(d.clients || [], state.cliSearch, RECON_CLIENT_COLS);
|
||||
renderTable(apBody, 'ap', apRows, RECON_AP_COLS, '', 'No access points in this scan.');
|
||||
renderTable(cliBody, 'client', cliRows, RECON_CLIENT_COLS, '', 'No clients in this scan.');
|
||||
}
|
||||
|
||||
function drawCharts(d) {
|
||||
const n = (d.aps || []).length;
|
||||
const c = (d.clients || []).length;
|
||||
const land = document.getElementById('recon-landscape');
|
||||
if (land && typeof MiniChart !== 'undefined' && MiniChart.doughnut) {
|
||||
if (n + c > 0) {
|
||||
MiniChart.doughnut(land, [
|
||||
{ label: 'Access Points', value: n, color: RECON_LANDSCAPE_COLORS[0] },
|
||||
{ label: 'Clients', value: c, color: RECON_LANDSCAPE_COLORS[1] },
|
||||
{ label: 'Unassociated', value: 0, color: RECON_LANDSCAPE_COLORS[2] }
|
||||
], { legend: true, height: 130 });
|
||||
land.classList.remove('hidden');
|
||||
landEmpty.classList.add('hidden');
|
||||
} else {
|
||||
land.classList.add('hidden');
|
||||
landEmpty.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
const counts = {};
|
||||
(d.aps || []).forEach((a) => {
|
||||
const ch = a.channel == null ? '?' : a.channel;
|
||||
counts[ch] = (counts[ch] || 0) + 1;
|
||||
});
|
||||
const keys = Object.keys(counts).sort((a, b) => {
|
||||
if (a === '?') return 1;
|
||||
if (b === '?') return -1;
|
||||
return Number(a) - Number(b);
|
||||
});
|
||||
const ch = document.getElementById('recon-channel');
|
||||
if (ch && typeof MiniChart !== 'undefined' && MiniChart.bar) {
|
||||
if (keys.length) {
|
||||
MiniChart.bar(ch, keys.map((k, i) => ({
|
||||
label: k, value: counts[k], color: RECON_CHANNEL_COLORS[i % RECON_CHANNEL_COLORS.length]
|
||||
})), { height: 130 });
|
||||
ch.classList.remove('hidden');
|
||||
chanEmpty.classList.add('hidden');
|
||||
} else {
|
||||
ch.classList.add('hidden');
|
||||
chanEmpty.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function loadDetail() {
|
||||
if (state.selected == null) return;
|
||||
PagerAPI.get('/api/recon/scans/' + state.selected).then((r) => {
|
||||
state.detail = r.data;
|
||||
drawCharts(r.data);
|
||||
renderTables();
|
||||
hsCount.textContent = (r.data.handshakes || []).length;
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function load() {
|
||||
PagerAPI.get('/api/recon/scans').then((r) => {
|
||||
state.scans = r.data.scans || [];
|
||||
const keep = state.selected && state.scans.some((s) => s.id === state.selected)
|
||||
? state.selected : (state.scans[0] ? state.scans[0].id : null);
|
||||
sel.innerHTML = '';
|
||||
state.scans.forEach((s) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = s.id;
|
||||
opt.textContent = 'Scan #' + s.id + ' — ' + fmtTime(s.time);
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
if (keep == null) {
|
||||
state.detail = null;
|
||||
drawCharts({ aps: [], clients: [], handshakes: [] });
|
||||
renderTables();
|
||||
hsCount.textContent = '0';
|
||||
}
|
||||
if (keep != null) sel.value = keep;
|
||||
state.selected = keep;
|
||||
if (keep != null) loadDetail();
|
||||
}).catch(() => {});
|
||||
PagerAPI.get('/api/recon/status').then((r) => {
|
||||
state.active = !!r.data.active;
|
||||
scanToggle.checked = state.active;
|
||||
}).catch(() => {});
|
||||
PagerAPI.get('/api/pineap/settings').then((r) => {
|
||||
hsAuto.querySelector('input').checked = !!((r.data.settings || {}).collect_handshakes);
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
load();
|
||||
const iv = setInterval(load, 10000);
|
||||
return { destroy: () => clearInterval(iv) };
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Replace `views.recon_handshakes` title row**
|
||||
|
||||
In `views.recon_handshakes`, replace the line:
|
||||
|
||||
```js
|
||||
const box = h('div', { class: 'section' }, h('h2', {}, 'Handshakes'));
|
||||
root.appendChild(box);
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```js
|
||||
const box = h('div', { class: 'section recon-handshakes-card' });
|
||||
root.appendChild(box);
|
||||
```
|
||||
|
||||
and replace the first two lines inside `load()`:
|
||||
|
||||
```js
|
||||
box.innerHTML = '';
|
||||
box.appendChild(h('h2', {}, 'Handshakes'));
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```js
|
||||
box.innerHTML = '';
|
||||
const head = h('div', { class: 'recon-table-head' },
|
||||
h('h2', { text: 'Captured WPA Handshakes' }),
|
||||
h('span', { class: 'toolbar-spacer' }),
|
||||
iconBtn('settings', 'Handshakes settings', () => App.toast('Handshakes settings are not available on the Pager')));
|
||||
box.appendChild(head);
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Remove `views.recon_events`**
|
||||
|
||||
Delete the entire `views.recon_events = (root) => {...};` block (lines 578-612 in the current file), including its `let all = []; let page = 0;` state and pager markup.
|
||||
|
||||
- [ ] **Step 6: Verify definitions**
|
||||
|
||||
```powershell
|
||||
$v = Get-Content -Raw payload\user\general\pager-webui\www\js\views.js
|
||||
@('RECON_TABS','reconDefaultCols','reconLoadCols','reconFiltered','iconBtn','views.recon =','views.recon_handshakes =','RECON_CHANNEL_COLORS','RECON_AP_COLS','RECON_CLIENT_COLS') | ForEach-Object { if ($v -match [regex]::Escape($_) ) { "$_ OK" } else { "$_ MISSING" } }
|
||||
if ($v -match 'views\.recon_events') { 'recon_events STILL PRESENT (bad)' } else { 'recon_events REMOVED (good)' }
|
||||
```
|
||||
Expected: all definitions `OK`, `recon_events REMOVED (good)`.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add payload/user/general/pager-webui/www/js/views.js
|
||||
git commit -m "feat: Mark VII recon scanning view, restyled handshakes, remove Events tab"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Routing — drop the Events route
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/general/pager-webui/www/js/app.js:180`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `views.recon`, `views.recon_handshakes` (Task 5). `views.recon_events` no longer exists.
|
||||
- Produces: routes map without `#/recon/events`.
|
||||
|
||||
- [ ] **Step 1: Remove the line**
|
||||
|
||||
Delete line 180 (`'#/recon/events': 'recon_events',`) from the `routes` object.
|
||||
|
||||
- [ ] **Step 2: Verify**
|
||||
|
||||
```powershell
|
||||
$c = Get-Content -Raw payload\user\general\pager-webui\www\js\app.js
|
||||
if ($c -match "'#/recon/events'") { 'events route STILL PRESENT (bad)' } else { 'events route REMOVED (good)' }
|
||||
```
|
||||
Expected: `events route REMOVED (good)`.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add payload/user/general/pager-webui/www/js/app.js
|
||||
git commit -m "fix: remove recon events route"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Build, deploy, on-device verification
|
||||
|
||||
**Files:**
|
||||
- No source changes.
|
||||
|
||||
- [ ] **Step 1: Run the full backend test suite**
|
||||
|
||||
```powershell
|
||||
Get-ChildItem tests\test_*.py | ForEach-Object {
|
||||
$mod = "tests." + [IO.Path]::GetFileNameWithoutExtension($_.Name)
|
||||
& "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe" -m unittest $mod -v
|
||||
}
|
||||
```
|
||||
Expected: every module PASS.
|
||||
|
||||
- [ ] **Step 2: Deploy to the Pager**
|
||||
|
||||
```powershell
|
||||
& .\scripts\deploy.ps1 -SshKey "$HOME\.ssh\pager_key" -Password "<PAGER_PASSWORD>"
|
||||
```
|
||||
(If no key, rely on sshpass; otherwise run the printed scp/ssh commands manually.)
|
||||
|
||||
- [ ] **Step 3: Verify assets serve**
|
||||
|
||||
```powershell
|
||||
curl.exe -s -o NUL -w "%{http_code} %{size_download}`n" http://172.16.52.1:8080/
|
||||
curl.exe -s -o NUL -w "%{http_code} %{size_download}`n" http://172.16.52.1:8080/js/chart.js
|
||||
curl.exe -s -o NUL -w "%{http_code} %{size_download}`n" http://172.16.52.1:8080/js/views.js
|
||||
curl.exe -s -o NUL -w "%{http_code} %{size_download}`n" http://172.16.52.1:8080/css/app.css
|
||||
```
|
||||
Expected: all `200` with non-zero sizes.
|
||||
|
||||
- [ ] **Step 4: On-device smoke pass**
|
||||
|
||||
Log in at `http://172.16.52.1:8080/` and walk through:
|
||||
- `#/recon`: two tabs (Scanning, Handshakes); 4 title cards render (landscape doughnut + legend, channel bar chart, handshakes count, previous-scans select + download/delete icons).
|
||||
- Scan bar: Scan toggle, duration select (persist via reload), settings icon opens sidebar (column toggles persist; hide SSID column → table updates).
|
||||
- Table search filters APs/Clients; paginator first/last/prev/next + range label work; 10-per-page.
|
||||
- Previous-scan select switches detail + charts + tables; download icon fetches JSON; delete icon confirms + deletes.
|
||||
- Handshakes tab: "Captured WPA Handshakes" card, file table, Download/Delete row actions, Download all / Archive.
|
||||
- `#/recon/events` → "View not available." (route gone). Keyboard `r` → `#/recon`.
|
||||
- Dark theme (Settings → Theme) renders cards correctly.
|
||||
- Backend: start a scan with Duration = 1 Minute; confirm `/api/recon/start` returns 200 (daemon acceptance of `scan_time` is daemon-dependent; UI unaffected either way).
|
||||
|
||||
- [ ] **Step 5: Commit any smoke fixes**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "fix: recon rework smoke-test fixes"
|
||||
```
|
||||
(Only if changes exist.)
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Notes (run before handing off)
|
||||
|
||||
- **Spec coverage:** §3.1 tabs → Tasks 5–6; §3.2 layout → Task 5; §3.3 handshakes → Task 5; §3.4 charts → Task 2; §3.5 icons → Task 3; §3.6 CSS → Task 4; §3.7 backend → Task 1; §3.8 routing → Task 6; §4 data flow → Task 5 (`pw_scan_duration`, `pw_recon_cols`); §5 testing → Tasks 1 & 7; §6 out of scope → enforced (no band select, no graph view, no focus sidebars, no `/api/recon/events` removal).
|
||||
- **Name consistency:** `MiniChart.doughnut`/`MiniChart.bar` defined in Task 2 and consumed in Task 5 with the documented signatures (`doughnut(canvas, [{label,value,color}], {legend,height})`, `bar(canvas, [{label,value,color}], {height})`). `iconBtn` defined in Task 5 Step 1, used in Steps 3–4. `RECON_*` constants defined in Step 2, used in Step 3. `reconFiltered`/`reconPageCount`/`renderTables`/`renderTable`/`drawCharts`/`loadDetail`/`load` all defined before first use inside `views.recon`.
|
||||
- **Guardrail:** Task 1 must not break `test_start_stop_handlers_call_socket` — that test asserts `body={}` and `h_recon_start` now reads `getattr(ctx, 'body', None)` (absent → `{}`).
|
||||
- **Placeholder scan:** no TBD/TODO; every code step ships the full content.
|
||||
Reference in New Issue
Block a user