30 KiB
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_rowscall 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/handshakesmust keep returning afilesarray with the current shape ({name,size,mtime}). - Router is first-match-wins: the
locationGET route MUST be registered beforeGET /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; addparse_hs_filename,_norm_mac,_hs_db_by_pair,_compose_hsabove it) - Test:
tests/test_recon.py(addHS_RE/parse_hs_filename/handshakes_datatests)
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 | Nonewith keysts(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 keysmac, 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):
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:
$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:
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:
$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
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(addh_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:
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:
$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:
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:
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:
$py = "$env:LOCALAPPDATA\Programs\Python\Python311\python.exe"
& $py -m unittest tests.test_recon -v
Expected: all pass.
- Step 5: Commit
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):
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):
/* ---- 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:
$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
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(replacehsType/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
hsTypeandviews.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:
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 mapsf.name→PCAP/Hashcat/Unknown), so leave it in place. -
msgCellrenders 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:
& .\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
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):
& .\scripts\deploy.ps1 -Password "<PAGER_PASSWORD>"
- Step 2: Restart the webui service
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:
$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.pcapin/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.
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_hsfallback branch). - §4.3 record composition (incl.
namefield) → 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.