13 KiB
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.pyortests/. - Karma state is NOT readable from the daemon (only
mimic/enable|disable); track it client-side askarmaOn(null = unknown, defaults tonullon page load). - Mode presets only apply Pager-supported features:
POST /api/pineap/enableandPOST /api/pineap/mimic. Do NOT touchssidpool/*(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 restartover sshpass SSH. - Commit messages follow repo style (
feat:,fix:,docs:,test:). - Mode badge highlight refinement vs. the approved spec (intent-preserving): when
karmaOnis 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.segstyles) - Modify:
payload/user/general/pager-webui/www/js/views.js(replace the body ofviews.pineap, lines ~187-255)
Interfaces:
-
Consumes:
pineapShell(root, hash)(appends h1 + tab bar, returns content box),pineapSetCard(card, label, value)(sets.card-label/.card-valuetext),h(),btn(label, onclick, variant),PagerAPI.get/post,App.go(hash),App.toast. -
Produces:
views.pineapwith (1) a.segsegmented control with three buttons, (2) a 3-card stats row keyedstats.ssids/stats.clients/stats.handshakes, (3) a view-localkarmaOnvariable consumed byload()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:
.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.pineapinviews.js
Replace everything from views.pineap = (root) => { through the closing }; of that function (current lines ~187-255) with:
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:
$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
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 -NoProfile -ExecutionPolicy Bypass -File .\scripts\deploy.ps1 -Password "<PAGER_PASSWORD>"
Then restart and confirm the port is up:
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):
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
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.