13 Commits
Author SHA1 Message Date
bzuccaro 3c306e2478 chore: bump version to 1.2; scrub personal details from docs
- Version 1.1 -> 1.2 in the hak5 manifest and MCP serverInfo.
- Removed leaked personal details from process docs: device root password,
  the user's personal iPhone SSID (authorized-test target), the local
  /Users/... checkout path, and the device IP where it appeared alongside
  the password. Replaced with <device-password>, <authorized-test-ssid>,
  <repo>, and <device-ip> placeholders.
2026-08-19 08:21:28 -05:00
bzuccaro f419e2216d ui: wireless landscape card — ring + legend, fix handshake toggle wrap
- Landscape card mirrors Encryption: headline text removed; square 112px
  ring (drawn height 112, CSS fixed square, no more ellipse) + HTML legend
  (dot + label + count) for Access Points / Clients / Unassociated. The
  canvas previously had no legend and the '90' headline never matched the
  dominant arc; the legend makes every count visible and labeled.
- Legend markup generalized: .recon-enc-* -> .recon-chart-* shared by both
  cards; shared chartLegendEntry() builder; landscape skips zero slices.
- .recon-toggle now flex-wraps (was overflowing 'Auto-collect handshakes'
  past the card edge at narrow widths; card min-width inner is ~142px vs a
  233px label).

Verified on device via CDP at 1200px and 860px viewports: landscape ring
112x112 square with Access Points 92 / Clients 291 / Unassociated 760;
encryption card intact; toggle label stays inside the 184px card (wraps to
2 lines, no card overflow); zero JS exceptions.
2026-08-19 08:07:46 -05:00
bzuccaro 79d0541c35 fix: encryption ring renders as a true circle, not a squashed ellipse
The enc canvas was drawn at 233x120 internally but .recon-chart-box canvas
forced width:100%/height:100% onto a ~233x115 box, scaling the bitmap and
flattening the ring. #recon-encryption is now a static 112px square with the
doughnut drawn at height 112, so internal and CSS pixels match (1:1) and the
circle stays circular. Verified on device: box and bitmap both 112x112,
card fits without overflow, legend intact, no JS exceptions.
2026-08-19 07:34:09 -05:00
bzuccaro 999117ecc0 docs: spec — correct encryption card bucket model (real families + legend id) 2026-08-19 07:24:09 -05:00
bzuccaro 1dcedae83a fix: encryption landscape card — real family buckets + legend id
- Ring and legend now derive from reconEncBucket's actual family keys
  (WPA2-PSK, WPA3-Personal, WPA2-Enterprise, ...) instead of the six
  nominal buckets that never matched encCounts, so the ring drew an
  empty ring and the legend was always blank on WPA2-dominated data.
  Ordering via RECON_ENC_ORDER; colors cycle RECON_ENC_COLORS.
- Legend div had class but no id while drawCharts looked it up with
  getElementById — the population block silently never ran. Added the id.

Verified on device via CDP: legend lists Open 9, WEP 1, WPA2-PSK 44,
WPA3-Personal 20, WPA3-Enterprise 5 with per-family dot colors; no JS
exceptions.
2026-08-19 07:24:00 -05:00
bzuccaro aa411242fc ui: encryption landscape card — ring + HTML legend with counts 2026-08-19 06:53:35 -05:00
bzuccaro a084790ab7 docs: implementation plan — encryption landscape card ring + key redesign 2026-08-19 06:52:49 -05:00
bzuccaro 4cc57745b6 docs: spec — Encryption Landscape card ring + key redesign 2026-08-19 06:51:06 -05:00
bzuccaro 2287d2803d ui: recon card order — Channel Map above Access Points, Compare APs below 2026-08-19 06:33:35 -05:00
bzuccaro 2e84a12fbd hardening: 20s client timeout on API GETs
A hung read can no longer stall the recon/pineap poll loops (AbortController).
Writes keep no client abort: radio deploys legitimately take up to 45s
server-side. Cache-bumped api.js.
2026-08-19 00:13:03 -05:00
bzuccaro 38165ac5e4 fix: MCP pineap.set_filter supports delete/clear/allow_all
The tool only accepted set_mode/add, so entries added via MCP could never be
removed. Now passes the full action set through to the same endpoint the UI
uses and returns the resulting mode/entries. Verified on device: add ->
delete round trip leaves the filter empty.
2026-08-19 00:07:08 -05:00
bzuccaro 4cf0ae62e4 fix: client kick/deauth use full bssid/target/channel deauth form
hak5cmd on this firmware has no CLIENT_KICK command and PINEAPPLE_DEAUTH_CLIENT
requires (bssid, target, channel) — the UI kick/deauth buttons and the MCP
pineap.kick_client tool previously passed only the client MAC, which printed
usage and silently did nothing.

- _deauth_target/_deauth_client_via_iface resolve the client's association
  interface via iwinfo (Access Point + Channel), pick the band-aware inject
  interface, and issue DEAUTH_CLIENT <bssid> <mac> <channel>
- h_client_kick: deny-filter (persistent) + immediate deauth with rc checks
- h_deauth_client: deauth with the full form; 502 with detail when the
  client is not associated
- MCP pineap.kick_client: resolves the client first (no side effects on
  failure), then deny-filter + deauth; verified on device (clean error for
  unassociated clients, filter list restored)
- tests updated for the new command chain
2026-08-19 00:05:42 -05:00
bzuccaro 99e6d1e948 ui: recon card redesign, channel-map hover, Send to PineAP prefill, recon hardening
- Top 5 recon cards restyled as stat cards: value + sub-line + mini chart,
  consistent 190px anatomy; Handshakes links to the handshakes page;
  Previous Scans shows count/latest plus a compact picker + action row
- Channel map records lobe geometry (canvas.__reconLobes / __reconLobesHit);
  hovering lists the networks under the cursor, click pins the tooltip,
  mouseleave hides it; empty states hide the tooltip too
- Recon focus sidebar gains 'Send to PineAP — Twin this network': routes to
  Evil WPA (psk2/sae/owe from recon encryption) or OpenAP (with BSSID) and
  prefills the form; one-shot PineAPPrefill is consumed by the attack
  launcher and never auto-deploys or leaks into manual forms
- Existing Actions verified end-to-end and hardened: capture/stop handshake
  sync the auto-collect toggle, examine buttons disable while pending
- Recon resilience: loadDetail retries after failed render or 503 (no more
  permanently blank page), scan-list errors surface in the scan bar, poll
  guard prevents overlapping loads, chart draws are individually isolated
- Auto-collect toggle re-syncs from get_config on the slow poll so the UI
  tracks the pager's own settings
2026-08-18 23:49:49 -05:00
13 changed files with 956 additions and 189 deletions
@@ -12,7 +12,7 @@
- No new Python deps; no pip; device python3-light-compatible (no urllib/http.server/sqlite3 stdlib). - No new Python deps; no pip; device python3-light-compatible (no urllib/http.server/sqlite3 stdlib).
- Truth = device state (UCI `/etc/config/wireless`, `/etc/config/pineapd`, `iw dev`, live pineapd socket), never UI cache. - Truth = device state (UCI `/etc/config/wireless`, `/etc/config/pineapd`, `iw dev`, live pineapd socket), never UI cache.
- Attacks only against `Zuccaro_iPhone_15` (authorized). No deauth blasts; band-aware inject only. - Attacks only against `<authorized-test-ssid>` (authorized). No deauth blasts; band-aware inject only.
- SSID pool broadcast stays disabled (stock SIGSEGV bug). - SSID pool broadcast stays disabled (stock SIGSEGV bug).
- Writes must be verified by re-read before success is reported. - Writes must be verified by re-read before success is reported.
- Follow existing code style: `device_run()`, `daemon_sock_call()`, `_daemon_proxy()` helpers; `h()` DOM helper in views; routes registered with `ROUTER.add`. - Follow existing code style: `device_run()`, `daemon_sock_call()`, `_daemon_proxy()` helpers; `h()` DOM helper in views; routes registered with `ROUTER.add`.
@@ -107,6 +107,6 @@
### Task 6: Deploy + on-device verification ### Task 6: Deploy + on-device verification
- [ ] **Step 1:** Run full test suite locally (each module separately). - [ ] **Step 1:** Run full test suite locally (each module separately).
- [ ] **Step 2:** Deploy via `./scripts/deploy.sh --password 'Bryce9205'`. - [ ] **Step 2:** Deploy via `./scripts/deploy.sh --password '<device-password>'`.
- [ ] **Step 3:** On-device smoke: login, status, health endpoint, attacks deploy/stop round-trip (Evil WPA on 2.4GHz with `Zuccaro_iPhone_15` SSID — no deauth), enterprise deploy/stop, MCP `initialize`+`tools/list` via curl. - [ ] **Step 3:** On-device smoke: login, status, health endpoint, attacks deploy/stop round-trip (Evil WPA on 2.4GHz with `<authorized-test-ssid>` SSID — no deauth), enterprise deploy/stop, MCP `initialize`+`tools/list` via curl.
- [ ] **Step 4:** Leave device in clean state (no active attacks, hop resumed, pool disabled, wlan1mon up). - [ ] **Step 4:** Leave device in clean state (no active attacks, hop resumed, pool disabled, wlan1mon up).
@@ -0,0 +1,179 @@
# Encryption Landscape Card — Ring + Key Redesign 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 Encryption Landscape recon card's text headline + clipped canvas legend with a full-size plain-hole ring graph and a wrapping HTML legend (dot + label + count per encryption bucket).
**Architecture:** Single-file client-side change: the recon view in `views.js` drops the `encValue`/`encSub` text nodes, draws the doughnut with `legend:false` and a taller height, and populates a new HTML legend container from the existing `encCounts` bucket map. `chart.js`'s `MiniChart.doughnut` is unchanged (its canvas `legend` option is simply no longer used by the enc card). CSS adds flex-wrap legend styles.
**Tech Stack:** Vanilla JS (no framework), canvas via `MiniChart.doughnut` in `chart.js`, plain CSS in `app.css`. Device deploy via `scripts/deploy.sh --password '<device-password>'`. Tests: none exist for the frontend; verification is via the deployed device + backend test suite (must stay green).
## Global Constraints
- Do not alter data source, `reconEncBucket`, per-scan bucketing, re-sync, or the other four recon cards.
- Legend entries: colored dot + label + count, format `● WPA2 54`; buckets with zero APs hidden.
- Ring center hole stays plain (no text).
- Keep `MiniChart.doughnut`'s `legend` option in `chart.js` (used by no caller after this change, but harmless).
- Empty state text stays "No encryption data yet — run a scan."
- No comments added to code unless already present in the surrounding style.
- Deploy and verify on the device; backend `tests/` suite must stay green (291 tests).
---
### Task 1: Ring + HTML legend for Encryption Landscape card
**Files:**
- Modify: `payload/user/remote_access/pager-webui/www/js/views.js:1497-1507` (card markup)
- Modify: `payload/user/remote_access/pager-webui/www/js/views.js:2256-2322` (drawCharts enc block)
- Modify: `payload/user/remote_access/pager-webui/www/css/app.css` (after line 328)
- Test: none (frontend); verify via device sweep
**Interfaces:**
- Consumes: `RECON_ENC_BUCKETS` (6 names, `views.js:852`), `RECON_ENC_COLORS` (6 colors, `views.js:851`), `encCounts` object `{bucketName: count}`, `reconEncBucket(a.encryption)`.
- Produces: DOM `div#recon-enc-legend` under the enc card body, populated by `drawCharts`; `canvas#recon-encryption` redrawn with `{ legend: false, height: 120 }`. `encValue`/`encSub` variables are removed.
- [ ] **Step 1: Edit the enc card markup in views.js**
Replace lines 1497-1507:
```js
const encBody = titleCard('Encryption Landscape', null);
const encValue = h('div', { class: 'recon-card-value', text: '—' });
const encSub = h('div', { class: 'recon-card-sub', text: '' });
encBody.appendChild(encValue);
encBody.appendChild(encSub);
const encBox = h('div', { class: 'recon-chart-box' });
encBody.appendChild(encBox);
const encCanvas = h('canvas', { id: 'recon-encryption' });
encBox.appendChild(encCanvas);
const encEmpty = h('div', { class: 'recon-no-data', text: 'No encryption data yet — run a scan.' });
encBox.appendChild(encEmpty);
```
with:
```js
const encBody = titleCard('Encryption Landscape', null);
const encBox = h('div', { class: 'recon-chart-box' });
encBody.appendChild(encBox);
const encCanvas = h('canvas', { id: 'recon-encryption' });
encBox.appendChild(encCanvas);
const encEmpty = h('div', { class: 'recon-no-data', text: 'No encryption data yet — run a scan.' });
encBox.appendChild(encEmpty);
const encLegend = h('div', { class: 'recon-enc-legend' });
encBody.appendChild(encLegend);
```
- [ ] **Step 2: Remove the enc text-headline computation in drawCharts**
Replace lines 2256-2266 (the `encCounts` loop stays — only the `topEnc` block goes):
```js
let topEnc = null, topEncN = 0;
Object.keys(encCounts).forEach((k) => {
if (encCounts[k] > topEncN) { topEnc = k; topEncN = encCounts[k]; }
});
encValue.textContent = topEnc || '—';
encSub.textContent = topEnc ? topEncN + ' of ' + n + ' APs' : '';
```
with nothing (delete those lines). The `encCounts` computation immediately above must remain.
- [ ] **Step 3: Rewrite the enc chart block in drawCharts**
Replace lines 2308-2322:
```js
const enc = document.getElementById('recon-encryption');
if (enc && typeof MiniChart !== 'undefined' && MiniChart.doughnut) {
try {
if (aps.length) {
MiniChart.doughnut(enc, RECON_ENC_BUCKETS.map((k, i) => ({
label: k, value: encCounts[k] || 0, color: RECON_ENC_COLORS[i]
})), { legend: true, height: 66 });
enc.classList.remove('hidden');
encEmpty.classList.add('hidden');
} else {
enc.classList.add('hidden');
encEmpty.classList.remove('hidden');
}
} catch (e) {}
}
```
with:
```js
const enc = document.getElementById('recon-encryption');
if (enc && typeof MiniChart !== 'undefined' && MiniChart.doughnut) {
try {
if (aps.length) {
MiniChart.doughnut(enc, RECON_ENC_BUCKETS.map((k, i) => ({
label: k, value: encCounts[k] || 0, color: RECON_ENC_COLORS[i]
})), { legend: false, height: 120 });
enc.classList.remove('hidden');
encEmpty.classList.add('hidden');
} else {
enc.classList.add('hidden');
encEmpty.classList.remove('hidden');
}
} catch (e) {}
}
const encLegend = document.getElementById('recon-enc-legend');
if (encLegend) {
encLegend.innerHTML = '';
RECON_ENC_BUCKETS.forEach((k, i) => {
const c = encCounts[k] || 0;
if (!c) return;
const entry = h('div', { class: 'recon-enc-entry' });
const dot = h('span', { class: 'recon-enc-dot' });
dot.style.background = RECON_ENC_COLORS[i];
entry.appendChild(dot);
entry.appendChild(h('span', { class: 'recon-enc-label', text: k }));
entry.appendChild(h('span', { class: 'recon-enc-count', text: String(c) }));
encLegend.appendChild(entry);
});
}
```
- [ ] **Step 4: Add the legend CSS to app.css**
Insert after line 328 (the `.recon-no-data` rule):
```css
.recon-enc-legend { display: flex; flex-wrap: wrap; gap: 2px 10px; margin-top: 4px; align-items: baseline; }
.recon-enc-entry { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; color: var(--text); }
.recon-enc-dot { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; }
.recon-enc-label { color: var(--muted); }
.recon-enc-count { font-weight: 600; color: var(--text); font-variant-numeric: tabular-nums; }
```
- [ ] **Step 5: Syntax check both JS files**
Run: `node --check payload/user/remote_access/pager-webui/www/js/views.js`
Run: `node --check payload/user/remote_access/pager-webui/www/js/chart.js`
Expected: exit 0, no output.
- [ ] **Step 6: Run the backend test suite**
Run: `cd <repo> && python3 -m pytest tests/ -q 2>&1 | tail -3`
Expected: `291 passed` (or the current passing count) — no regressions from unrelated files.
- [ ] **Step 7: Deploy to the device**
Run: `cd <repo> && ./scripts/deploy.sh --password '<device-password>'`
Expected: deploy completes with `EXTRACT_OK` / success output.
- [ ] **Step 8: Verify the enc card on the device**
Recreate the CDP venv if absent (`python3 -m venv /tmp/cdpenv2 && /tmp/cdpenv2/bin/pip install -q websocket-client`), then drive headless Chrome against http://<device-ip>:8080 (login `<device-password>`, go to `#/recon`, wait ~20s) and assert:
1. `document.getElementById('recon-encryption')` canvas has non-zero `width` attribute and the card is visible (not `.hidden`).
2. `document.getElementById('recon-enc-legend')` contains entries whose text matches `/WPA2/` and `/\d+/`, and no `recon-card-value` element exists inside the enc card.
3. Zero `Runtime.exceptionThrown` events.
Expected: all three pass; screenshots unavailable, text-state assertions only.
- [ ] **Step 9: Commit**
```bash
cd <repo> && git add payload/user/remote_access/pager-webui/www/js/views.js payload/user/remote_access/pager-webui/www/css/app.css && git commit -m "ui: encryption landscape card — ring + HTML legend with counts"
```
@@ -93,7 +93,7 @@ Stop button, and a post-write poll (UCI + `iw dev`) before success toast.
## Constraints ## Constraints
- Authorized target: `Zuccaro_iPhone_15` only (intermittent). Non-client - Authorized target: `<authorized-test-ssid>` only (intermittent). Non-client
environment; no deauth blasts; verify on-wire via monitor capture when needed. environment; no deauth blasts; verify on-wire via monitor capture when needed.
- SSID pool stays disabled (stock bug; re-enabling re-crashes pineapd). - SSID pool stays disabled (stock bug; re-enabling re-crashes pineapd).
@@ -0,0 +1,123 @@
# Mark VIII UI Hardening + Recon Polish — Design
Date: 2026-08-18
Status: Approved in advance by user (auto-approve; user unavailable for review)
## Context
Mark VIII (web UI for the WiFi Pineapple Pager) has been through major dev
changes. The user runs a live test tomorrow and needs the UI rock solid.
Verified on device:
- MCP server works: `tools/list` returns 16 tools; `device.state`,
`recon.aps` respond correctly.
- API endpoints used by the Recon "Actions" buttons work
(`/api/recon/examine`, `/api/pineap/set_config`).
- Recon detail endpoint is slow (~1.5 s) and can 503 when the sqlite DB is
busy; the UI swallows errors silently and can stay blank.
## Findings
1. **Recon top cards (5)**: equal 200 px cards with 20 px titles, centered
content, charts sized for wide cards. "Previous Scans" crams 5 icon
buttons + a 50+-option select into a ~220 px card → buttons stack into
three rows. "Channel Distribution" bar chart is unreadable at card width.
Handshakes toggle text wraps awkwardly.
2. **Channel map**: canvas lobes render but have zero interactivity — no way
to see which networks are under the cursor.
3. **Focus sidebar Actions**: "Capture WPA Handshakes" / "Stop Handshake
Capture" / "Examine BSSID" / "Examine Channel" work at the API level but
give weak feedback; there is no path from a recon target to the PineAP
evil-twin form.
4. **Robustness**: `loadDetail()` in the recon view swallows render/network
errors with `.catch(() => {})`; if rendering throws after `detailId` is
set, the page stays blank forever (guard short-circuits). `load()` has no
pending guard → overlapping polls. `hsAuto` (loghandshake) checkbox is
only synced at view creation — it drifts from the pager's own settings
when changed elsewhere (pager UI / another browser).
## Changes
### 1. Recon title cards — redesign (`views.js` + `app.css`)
New card anatomy (all five consistent):
- Compact header: 12 px uppercase muted title (clickable where it links).
- Primary value: 28 px bold.
- Sub-line: 12 px muted context text.
- Optional mini chart: fixed 96 px tall.
Cards (`flex: 1 1 0; min-width: 170px; height: 190px` — five fit one row
even in a ~1000 px pane):
1. **Wireless Landscape** — value: total networks (APs); sub: "N clients ·
M unassociated"; mini doughnut (APs/Clients/Unassociated).
2. **Channel Distribution** — value: busiest channel "CH 6"; sub: "N of K
APs on CH 6 · channels seen"; mini bar chart of channel counts.
3. **Encryption Landscape** — value: top encryption bucket; sub: "N of K
APs"; mini doughnut of buckets.
4. **Handshakes** — value: handshake count (links to
`#/recon/handshakes`); sub: "captured"; auto-collect toggle (restyled).
5. **Previous Scans** — value: scan count; sub: latest scan start time;
compact select row + single row of icon buttons.
Empty states get copy that matches the card ("No landscape data yet —
run a scan").
### 2. Channel map hover (`chart.js` + `views.js` + `app.css`)
- `MiniChart.channelMap` records per-lobe geometry on the canvas:
`canvas.__reconLobes = [{ ap, cx, half, topPx, basePx }]` (CSS px).
- `views.js` attaches `mousemove`/`mouseleave`/`click` to the map canvas:
- hit test: `t=(mx-cx)/half`, lift = `0.5+0.5·cos(πt)`, hovered if
`my >= basePx - lift·peakPx - 4` and `my <= basePx + 8`;
- tooltip div (absolute, inside `.recon-map-box`) lists every network
under the cursor: SSID/MAC/channel/freq/signal/encryption/vendor;
- click pins the tooltip until the next move or click;
- `mouseleave` hides it.
- Lobe geometry regenerates on every `renderChannelMap()` (redraw), so
stale geometry is impossible.
### 3. Focus sidebar: Actions validation + Send to PineAP (`views.js`)
- Existing buttons stay; toast feedback improved (already verified working
at API level; re-verified end-to-end in browser).
- New primary button **"Send to PineAP — Twin this network"**:
- encryption bucket `Open` → navigate `#/pineap/open` with prefill
`{ssid, hidden, channel, bssid}`;
- anything else → navigate `#/pineap/evilwpa` with prefill
`{ssid, hidden, channel, enctype}` where enctype maps from recon
encryption: SAE→`sae`, OWE→`owe`, WPA3-only→`sae`, else `psk2`;
- Enterprise networks still go to Evil WPA (psk2) — noted in the prefill
banner.
- Prefill mechanism: `window.PineAPPrefill = { set, consume }` (module
singleton in `views.js`); `consume()` clears after use so a stale prefill
never leaks into a manually opened form.
- `attackLauncher()` consumes the prefill when building the form (SSID,
hidden, channel via `chanSelect`, enctype, BSSID) and renders a muted
banner: "Prefilled from Recon — verify, set the passphrase, then Deploy."
Deploy is never triggered automatically (no attacks without an explicit
user action).
### 4. Robustness / sync hardening (`views.js`)
- `loadDetail()`: on fetch failure keep `detailId` unset so the poll
retries; surface "Scan data unavailable — retrying…" in the scan status
line; wrap the render body so one chart's exception cannot blank the
table (each chart draw also wrapped individually).
- `drawCharts()`: wrap each chart section in try/catch.
- `load()`: `loadPending` guard against overlapping polls; surface scan-list
errors in the status line.
- `hsAuto` re-syncs from `/api/pineap/get_config` on the 30 s slow poll
(stays in sync with the pager's own UI/settings changes).
- Version bumps in `index.html` for `app.css`, `chart.js`, `views.js`.
### 5. Verification
- Backend unchanged → existing `tests/` still pass (run the suite).
- Deploy via `scripts/deploy.sh --password` (device: 172.16.52.1).
- End-to-end in browser (device UI):
- login; recon page: cards populated with scan data; no blank-page state;
- click an AP row → focus sidebar → each Action button verified by
reading back state (`get_config`) and API responses;
- Send to PineAP → form pre-filled on the right tab (open vs WPA target);
- channel map click → tooltip shows networks under cursor;
- reboot-resilience spot check via service restart (pagerwebui restart).
@@ -0,0 +1,46 @@
# Encryption Landscape Card — Ring + Key Redesign
Date: 2026-08-19
## Problem
The "Encryption Landscape" recon card currently leads with a text headline
(`WPA2-PSK` / `54 of 96 APs`) that duplicates the ring graph below it and is of
little value. The ring itself draws an inline legend on the canvas in a single
row that overflows the 190px card width (6 labels) and gets clipped. The card
should be a proper ring graph with a readable key.
## Design
- Remove the `encValue` / `encSub` text line (`WPA2-PSK` / `54 of 96 APs`)
entirely. Title + ring + legend tell the whole story.
- Ring: existing doughnut grows to fill the card body (canvas ~120px tall),
plain hole. Segments come from the **actual** `reconEncBucket` family keys
(Open / WEP / WPA / WPA2-PSK / WPA2-Enterprise / WPA3-Personal / WPA3-PSK /
WPA3-Enterprise / Unknown) — the nominal six-bucket list was a wrong
assumption: `reconEncBucket` never returns `WPA2`/`WPA3`/`Enterprise`
verbatim, so the ring drew an empty ring on WPA2-dominated data.
Families are ordered by `RECON_ENC_ORDER`; colors cycle `RECON_ENC_COLORS`.
- Legend: move out of the canvas into real HTML below the ring. Flex-wrap so
entries never clip at 190px. Each entry: colored dot + label + count
(`● WPA2-PSK 44`). Zero-count families are never produced (built from
non-zero `encCounts` keys). The legend div carries both the class and the
id `recon-enc-legend` — drawCharts looks it up with `getElementById`, and a
class-only element made the population block silently no-op.
- Empty state unchanged: "No encryption data yet — run a scan."
- Data source, per-scan bucketing (`reconEncBucket`), re-sync, and the 5-card
layout are untouched. The other cards are untouched.
## Implementation notes
- `chart.js`: `MiniChart.doughnut` keeps the `legend` option for any other
callers (the recon landscape doughnut calls with `legend:false`; the enc
card switches to `legend:false` since HTML legend replaces it).
- `views.js`: drop `encValue`/`encSub`; build an HTML legend container
(`#recon-enc-legend`) populated in `drawCharts` from the same `encCounts`;
ring drawn with `{ legend: false, height: ~120 }` from a shared `encSegs`
array (real family keys, `RECON_ENC_ORDER`-sorted, cycled colors).
- `app.css`: `.recon-enc-legend` flex-wrap styles + dot/entry styles.
- Ring hole stays plain (no center text).
- On-device verification MUST assert the legend has non-empty entries
(class/id mismatch and the bucket-name mismatch both fail silently).
@@ -8,7 +8,7 @@
"title": "Mark VIII", "title": "Mark VIII",
"author": "c4ch3c4d3", "author": "c4ch3c4d3",
"description": "Mark VII-style web management UI for the WiFi Pineapple Pager", "description": "Mark VII-style web management UI for the WiFi Pineapple Pager",
"version": "1.1", "version": "1.2",
"category": "remote_access", "category": "remote_access",
"tags": ["remote-access", "web-interface", "device-management", "pineap"], "tags": ["remote-access", "web-interface", "device-management", "pineap"],
"firmware": "Pineapple Pager 24.10.1" "firmware": "Pineapple Pager 24.10.1"
@@ -860,17 +860,25 @@ def h_client_kick(ctx):
mac = normalize_mac((ctx.body or {}).get('mac')) mac = normalize_mac((ctx.body or {}).get('mac'))
if not mac: if not mac:
return 400, {'error': 'invalid mac'} return 400, {'error': 'invalid mac'}
hak5('PINEAPPLE_DEVICE_FILTER_MODE', 'deny') # Persistent kick: deny-filter the client so pineapd deauths every probe
hak5('PINEAPPLE_DEVICE_FILTER_ADD', 'deny', mac) # and connect, then deauth it once immediately with the full
hak5('PINEAPPLE_DEAUTH_CLIENT', mac) # (bssid, target, channel) form hak5cmd requires.
return 200, {'ok': True} for argv in ([HAK5CMD, 'PINEAPPLE_DEVICE_FILTER_MODE', 'deny'],
[HAK5CMD, 'PINEAPPLE_DEVICE_FILTER_ADD', 'deny', mac]):
rc, out, err = device_run(argv, timeout=20)
if rc != 0:
return 502, {'error': 'kick filter failed', 'detail': (err or out)[-300:]}
ok, detail = _deauth_client_via_iface(mac)
return 200, {'ok': True, 'deauth': ok, 'detail': detail or None}
def h_deauth_client(ctx): def h_deauth_client(ctx):
mac = normalize_mac((ctx.body or {}).get('mac')) mac = normalize_mac((ctx.body or {}).get('mac'))
if not mac: if not mac:
return 400, {'error': 'invalid mac'} return 400, {'error': 'invalid mac'}
hak5('PINEAPPLE_DEAUTH_CLIENT', mac) ok, detail = _deauth_client_via_iface(mac)
if not ok:
return 502, {'error': 'deauth failed', 'detail': detail}
return 200, {'ok': True} return 200, {'ok': True}
@@ -2298,6 +2306,53 @@ def assoc_clients(ifaces=None):
return clients return clients
def _iface_ap_info(iface):
"""(bssid, channel) of the AP running on `iface`, via iwinfo."""
rc, out, err = device_run(['iwinfo', iface, 'info'])
bssid = None
channel = None
for line in out.splitlines():
m = re.search(r'Access Point:\s*([0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5})', line)
if m:
bssid = m.group(1).upper()
m = re.search(r'Channel:\s*(\d+)', line)
if m:
channel = int(m.group(1))
return bssid, channel
def _deauth_target(mac):
"""(iface, bssid, channel) for an associated client, else None."""
for c in assoc_clients():
if c['mac'] != mac:
continue
bssid, channel = _iface_ap_info(c['iface'])
if not bssid or not channel:
return None
return c['iface'], bssid, channel
return None
def _deauth_client_via_iface(mac):
"""Deauth a client associated to one of our own APs.
hak5cmd's deauth needs the full (bssid, target, channel) triple; the
client's AP and channel are resolved from its association interface.
Returns (ok, detail).
"""
target = _deauth_target(mac)
if not target:
return False, 'client not associated'
iface, bssid, channel = target
band = _band_of_channel(channel)
inject = 'wlan1mon' if band == BAND_5G or band == BAND_6G else 'wlan0mon'
if inject != 'wlan1mon':
_pineap('INTERFACE', 'INJECT', inject)
rc, out, err = device_run([HAK5CMD, 'DEAUTH_CLIENT', bssid, mac,
str(channel)], timeout=30)
return rc == 0, (err or out)
def disk_data(): def disk_data():
rc, out, err = device_run(['df', '-k', '/root']) rc, out, err = device_run(['df', '-k', '/root'])
lines = out.splitlines() lines = out.splitlines()
@@ -3698,11 +3753,27 @@ def _mcp_tools():
return {'devices': _sql_table('wifi_device', args.get('limit', 50))} return {'devices': _sql_table('wifi_device', args.get('limit', 50))}
def kick(args): def kick(args):
mac = (args.get('mac') or '').strip() mac = normalize_mac(args.get('mac'))
if not mac: if not mac:
return {'error': 'mac required'} return {'error': 'mac required'}
rc, out, err = device_run([HAK5CMD, 'CLIENT_KICK', mac], timeout=20) # This firmware's hak5cmd has no CLIENT_KICK command; mirror the web
return {'ok': rc == 0, 'detail': (err or out)[-300:]} # UI's kick: deny-filter the client (deauths every probe/connect) then
# deauth it once with the full (bssid, target, channel) form. The
# client must be associated first so a failed kick has no side effects.
if not _deauth_target(mac):
return {'ok': False, 'detail': 'client not associated', 'mac': mac}
ok = True
detail = ''
for argv in ([HAK5CMD, 'PINEAPPLE_DEVICE_FILTER_MODE', 'deny'],
[HAK5CMD, 'PINEAPPLE_DEVICE_FILTER_ADD', 'deny', mac]):
rc, out, err = device_run(argv, timeout=20)
if rc != 0:
ok = False
detail = (err or out)[-300:]
break
if ok:
ok, detail = _deauth_client_via_iface(mac)
return {'ok': ok, 'detail': detail, 'mac': mac}
def set_filter(args): def set_filter(args):
kind = (args.get('kind') or 'ssid').strip() kind = (args.get('kind') or 'ssid').strip()
@@ -3710,15 +3781,19 @@ def _mcp_tools():
return {'error': 'kind must be ssid or client'} return {'error': 'kind must be ssid or client'}
action = (args.get('action') or '').strip() action = (args.get('action') or '').strip()
payload = {'action': action} payload = {'action': action}
if action == 'set_mode': if action in ('set_mode', 'add'):
payload['mode'] = (args.get('mode') or 'deny').strip() payload['mode'] = (args.get('mode') or 'deny').strip()
elif action == 'add': if action == 'add':
payload['value'] = (args.get('value') or '').strip()
if not payload['value']:
return {'error': 'value required'}
elif action == 'delete':
payload['mode'] = (args.get('mode') or 'deny').strip() payload['mode'] = (args.get('mode') or 'deny').strip()
payload['value'] = (args.get('value') or '').strip() payload['value'] = (args.get('value') or '').strip()
if not payload['value']: if not payload['value']:
return {'error': 'value required'} return {'error': 'value required'}
else: elif action not in ('clear', 'allow_all'):
return {'error': 'action must be set_mode or add'} return {'error': 'action must be set_mode, add, delete, clear or allow_all'}
status, resp = h_filter_post(_Ctx_args(payload), kind) status, resp = h_filter_post(_Ctx_args(payload), kind)
return resp if status == 200 else {'error': resp.get('error', 'filter failed')} return resp if status == 200 else {'error': resp.get('error', 'filter failed')}
@@ -3762,9 +3837,9 @@ def _mcp_tools():
_mcp_tool('recon.isearch', 'Find APs matching an SSID in the recon database.', {'ssid': {'type': 'string'}}, recon_isearch), _mcp_tool('recon.isearch', 'Find APs matching an SSID in the recon database.', {'ssid': {'type': 'string'}}, recon_isearch),
_mcp_tool('recon.devices', 'Recent observed client devices from recon.', {'limit': {'type': 'number'}}, recon_devices), _mcp_tool('recon.devices', 'Recent observed client devices from recon.', {'limit': {'type': 'number'}}, recon_devices),
_mcp_tool('pineap.kick_client', 'Disconnect a client from a PineAP/evil-twin AP.', {'mac': {'type': 'string'}}, kick), _mcp_tool('pineap.kick_client', 'Disconnect a client from a PineAP/evil-twin AP.', {'mac': {'type': 'string'}}, kick),
_mcp_tool('pineap.set_filter', 'Set the SSID/client filter: action=set_mode (mode=deny|allow) or add (value).', _mcp_tool('pineap.set_filter', 'Set the SSID/client filter: action=set_mode (mode=deny|allow), add (mode, value), delete (mode, value), clear, or allow_all. Returns the current mode and entries.',
{'kind': {'type': 'string', 'enum': ['ssid', 'client']}, {'kind': {'type': 'string', 'enum': ['ssid', 'client']},
'action': {'type': 'string', 'enum': ['set_mode', 'add']}, 'action': {'type': 'string', 'enum': ['set_mode', 'add', 'delete', 'clear', 'allow_all']},
'mode': {'type': 'string', 'enum': ['allow', 'deny']}, 'mode': {'type': 'string', 'enum': ['allow', 'deny']},
'value': {'type': 'string'}}, set_filter), 'value': {'type': 'string'}}, set_filter),
] ]
@@ -3878,7 +3953,7 @@ def _mcp_dispatch(msg):
'resources': {'listChanged': False, 'subscribe': False}, 'resources': {'listChanged': False, 'subscribe': False},
'prompts': {'listChanged': False}, 'prompts': {'listChanged': False},
}, },
'serverInfo': {'name': 'mark-viii', 'version': '1.1'}}} 'serverInfo': {'name': 'mark-viii', 'version': '1.2'}}}
if method == 'notifications/initialized': if method == 'notifications/initialized':
return 202, None return 202, None
if method == 'ping': if method == 'ping':
@@ -301,23 +301,45 @@ html.dark .sel { background: #424242; border-color: #545454; color: #fff; }
html.dark .muted { color: #bdbdbd; } html.dark .muted { color: #bdbdbd; }
/* ---- Recon (Mark VII parity) ---- */ /* ---- 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-container { display: flex; width: 100%; flex-wrap: wrap; gap: 10px; margin: 8px 0 16px; }
.recon-title-card { flex: 1 1 220px; min-width: 220px; margin-bottom: 1em; } .recon-card {
.recon-card { background: var(--surface); border-radius: 2px; box-shadow: var(--shadow); height: 200px; padding: 12px 16px; display: flex; flex-direction: column; } flex: 1 1 0; min-width: 170px; height: 190px;
.recon-title-card-title { font-size: 20px; margin-bottom: 15px; display: flex; align-items: center; color: var(--text); } background: var(--surface); border-radius: 2px; box-shadow: var(--shadow);
padding: 12px 14px; display: flex; flex-direction: column;
}
.recon-card-title {
font-size: 12px; text-transform: uppercase; letter-spacing: .05em;
color: var(--muted); margin-bottom: 6px; display: flex; align-items: center;
}
.recon-card-title-link { color: inherit; text-decoration: none; } .recon-card-title-link { color: inherit; text-decoration: none; }
.recon-card-title-link:visited { color: inherit; } .recon-card-title-link:visited { color: inherit; }
.recon-card-title-link:hover { text-decoration: underline; } .recon-card-title-link:hover { color: var(--primary); text-decoration: underline; }
.recon-title-card-content { display: flex; justify-content: center; align-items: center; height: 70%; } .recon-card-body { display: flex; flex-direction: column; flex: 1; min-height: 0; }
.recon-chart-box { width: 100%; height: 150px; position: relative; } .recon-card-value {
.recon-chart-box canvas { width: 100%; height: 100%; } font-size: 26px; font-weight: 700; line-height: 1.15; color: var(--text);
.recon-no-data { font-style: italic; color: #787878; display: flex; justify-content: center; padding: 12px; } white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
.recon-hs-col { display: flex; flex-direction: column; justify-content: center; align-items: center; } }
.recon-card-sub {
font-size: 12px; color: var(--muted); margin: 1px 0 6px; min-height: 16px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.recon-chart-box { position: relative; flex: 1; min-height: 0; margin-top: auto; }
.recon-chart-box canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
#recon-encryption, #recon-landscape { position: static; display: block; width: 112px; height: 112px; margin: 0 auto; }
.recon-no-data { font-style: italic; color: #787878; display: flex; justify-content: center; align-items: center; padding: 8px; text-align: center; }
.recon-chart-legend { display: flex; flex-wrap: wrap; gap: 2px 10px; margin-top: 4px; align-items: baseline; }
.recon-chart-entry { display: inline-flex; align-items: center; gap: 5px; font-size: 11px; color: var(--text); }
.recon-chart-dot { width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto; }
.recon-chart-label { color: var(--muted); }
.recon-chart-count { font-weight: 600; color: var(--text); font-variant-numeric: tabular-nums; }
.recon-hs-count { font-size: 32px; font-weight: 700; line-height: 1.1; } .recon-hs-count { font-size: 32px; font-weight: 700; line-height: 1.1; }
.recon-hs-label { color: grey; margin: 2px 0 10px; } .recon-hs-label { color: var(--muted); font-size: 12px; margin: 1px 0 8px; }
.recon-toggle { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text); margin: 0; cursor: pointer; } .recon-toggle { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text); margin: 0; cursor: pointer; flex-wrap: wrap; min-width: 0; }
.recon-ps-row { display: flex; align-items: center; width: 100%; gap: 4px; } .recon-ps-row { display: flex; align-items: center; width: 100%; gap: 4px; margin-top: 2px; }
.recon-ps-row .sel { width: 100%; } .recon-ps-row .sel { width: 100%; font-size: 12px; padding: 4px 6px; }
.recon-ps-actions { display: flex; align-items: center; gap: 2px; margin-top: 4px; }
.recon-ps-actions .icon-btn { width: 28px; height: 28px; }
.recon-ps-actions .icon-btn svg { width: 18px; height: 18px; }
.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 { 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:hover { background: var(--surface-alt); color: var(--text); }
.icon-btn:disabled { opacity: .38; cursor: default; } .icon-btn:disabled { opacity: .38; cursor: default; }
@@ -362,6 +384,8 @@ html.dark .recon-row-compare td { background: rgba(25, 118, 210, .18); }
.recon-focus-body { margin-top: 18px; } .recon-focus-body { margin-top: 18px; }
.recon-focus-body-title { font-size: 16px; margin-bottom: 8px; color: var(--text); } .recon-focus-body-title { font-size: 16px; margin-bottom: 8px; color: var(--text); }
.recon-focus-action-button { width: 100%; margin-bottom: 5px; } .recon-focus-action-button { width: 100%; margin-bottom: 5px; }
.recon-focus-twin { background: var(--ok); }
.recon-focus-twin:hover { background: #689f38; }
.recon-focus-detail { display: flex; justify-content: space-between; gap: 10px; padding: 3px 0; border-bottom: 1px solid var(--border); font-size: 13px; } .recon-focus-detail { display: flex; justify-content: space-between; gap: 10px; padding: 3px 0; border-bottom: 1px solid var(--border); font-size: 13px; }
.recon-focus-detail-label { color: var(--muted); flex: none; } .recon-focus-detail-label { color: var(--muted); flex: none; }
.recon-sort-arrow { color: var(--muted); font-size: 11px; } .recon-sort-arrow { color: var(--muted); font-size: 11px; }
@@ -410,6 +434,16 @@ html.dark .recon-pill.on { background: #1b3a23; color: #81c784; }
.recon-map-box { position: relative; } .recon-map-box { position: relative; }
.recon-map-box canvas { display: block; } .recon-map-box canvas { display: block; }
.recon-map-box .recon-no-data { min-height: 60px; } .recon-map-box .recon-no-data { min-height: 60px; }
.recon-map-tip {
position: absolute; z-index: 20; min-width: 220px; max-width: 260px;
background: var(--surface); border: 1px solid var(--border); border-radius: 3px;
box-shadow: var(--shadow); padding: 8px 10px; pointer-events: none; font-size: 12px;
color: var(--text);
}
.recon-map-tip-row { padding: 3px 0; border-bottom: 1px solid var(--border); }
.recon-map-tip-row:last-child { border-bottom: 0; }
.recon-map-tip-ssid { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.recon-map-tip-meta { color: var(--muted); font-size: 11px; font-variant-numeric: tabular-nums; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* ---- Reports view ---- */ /* ---- Reports view ---- */
.wigle-warn { color: #ef6c00; font-size: 12px; } .wigle-warn { color: #ef6c00; font-size: 12px; }
@@ -6,7 +6,7 @@
<meta name="color-scheme" content="light dark"> <meta name="color-scheme" content="light dark">
<title>WiFi Pineapple</title> <title>WiFi Pineapple</title>
<link rel="icon" type="image/png" href="assets/logo.png"> <link rel="icon" type="image/png" href="assets/logo.png">
<link rel="stylesheet" href="css/app.css?v=20260818-8"> <link rel="stylesheet" href="css/app.css?v=20260819-1">
<link rel="stylesheet" href="js/xterm.css"> <link rel="stylesheet" href="js/xterm.css">
</head> </head>
<body> <body>
@@ -262,13 +262,13 @@
<script src="js/config.js"></script> <script src="js/config.js"></script>
<script src="js/icons.js?v=20260818-7"></script> <script src="js/icons.js?v=20260818-7"></script>
<script src="js/api.js?v=20260817-4"></script> <script src="js/api.js?v=20260819-2"></script>
<script src="js/chart.js"></script> <script src="js/chart.js?v=20260819-1"></script>
<script src="js/xterm.min.js"></script> <script src="js/xterm.min.js"></script>
<script src="js/xterm-addon-fit.min.js"></script> <script src="js/xterm-addon-fit.min.js"></script>
<script src="js/terminal.js"></script> <script src="js/terminal.js"></script>
<script src="js/pager.js"></script> <script src="js/pager.js"></script>
<script src="js/views.js?v=20260818-9"></script> <script src="js/views.js?v=20260819-2"></script>
<script src="js/app.js?v=20260818-9"></script> <script src="js/app.js?v=20260819-1"></script>
</body> </body>
</html> </html>
@@ -3,13 +3,32 @@
const PagerAPI = (() => { const PagerAPI = (() => {
let apiBase = ''; let apiBase = '';
let on401 = null; let on401 = null;
// Reads are polled and must never hang a page's refresh loop; writes have
// server-side timeouts up to 45s (radio deploys) so they get no client
// abort.
const GET_TIMEOUT_MS = 20000;
async function request(method, path, body) { async function request(method, path, body) {
const opts = { method, headers: {}, credentials: 'include' }; const opts = { method, headers: {}, credentials: 'include' };
if (body !== undefined) { if (body !== undefined) {
opts.headers['Content-Type'] = 'application/json'; opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(body); opts.body = JSON.stringify(body);
} }
const res = await fetch(apiBase + path, opts); const ctl = new AbortController();
const timer = method === 'GET' ? setTimeout(() => ctl.abort(), GET_TIMEOUT_MS) : null;
if (timer) opts.signal = ctl.signal;
let res;
try {
res = await fetch(apiBase + path, opts);
} catch (e) {
if (timer && e && e.name === 'AbortError') {
const error = new Error('Request timed out');
error.status = 0;
throw error;
}
throw e;
} finally {
if (timer) clearTimeout(timer);
}
if (res.status === 401) { if (res.status === 401) {
if (on401) on401(); if (on401) on401();
throw new Error('unauthorized'); throw new Error('unauthorized');
@@ -161,6 +161,8 @@ const MiniChart = (() => {
// Channel map: each AP is a raised-cosine lobe at its reported center // Channel map: each AP is a raised-cosine lobe at its reported center
// frequency with the peak at its signal strength. The radio does not report // frequency with the peak at its signal strength. The radio does not report
// channel width, so every lobe assumes 20 MHz (half-width +-10 MHz). // channel width, so every lobe assumes 20 MHz (half-width +-10 MHz).
// Lobe geometry (CSS px) is recorded on the canvas so the caller can
// hit-test pointer position against the networks under the cursor.
function channelMap(canvas, aps, opts) { function channelMap(canvas, aps, opts) {
const o = opts || {}; const o = opts || {};
const dpr = window.devicePixelRatio || 1; const dpr = window.devicePixelRatio || 1;
@@ -171,6 +173,7 @@ const MiniChart = (() => {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const w = canvas.clientWidth, h = H; const w = canvas.clientWidth, h = H;
ctx.clearRect(0, 0, w, h); ctx.clearRect(0, 0, w, h);
canvas.__reconLobes = [];
if (!aps || !aps.length) return; if (!aps || !aps.length) return;
const pts = aps const pts = aps
.map((a) => ({ .map((a) => ({
@@ -211,6 +214,7 @@ const MiniChart = (() => {
const topY = y(a.signal); const topY = y(a.signal);
const peakH = Math.max(2, baseY - topY); const peakH = Math.max(2, baseY - topY);
const half = Math.max(4, (10 / (fMax - fMin)) * plotW); const half = Math.max(4, (10 / (fMax - fMin)) * plotW);
canvas.__reconLobes.push({ ap: a, cx: cx, half: half, topY: topY, baseY: baseY });
ctx.beginPath(); ctx.beginPath();
for (let i = 0; i <= 28; i++) { for (let i = 0; i <= 28; i++) {
const t = -1 + i / 14; const t = -1 + i / 14;
@@ -227,6 +231,20 @@ const MiniChart = (() => {
ctx.lineWidth = 1.5; ctx.lineWidth = 1.5;
ctx.stroke(); ctx.stroke();
}); });
// Hit test: list networks whose lobe is under (mx, my) in CSS px.
canvas.__reconLobesHit = function (mx, my, padPx) {
const pad = padPx == null ? 4 : padPx;
const hits = [];
(this.__reconLobes || []).forEach((l) => {
const dx = mx - l.cx;
if (Math.abs(dx) > l.half + 2) return;
const t = Math.min(1, Math.max(-1, dx / l.half));
const lift = Math.max(0, 0.5 + 0.5 * Math.cos(Math.PI * t));
const lobeTop = l.baseY - lift * (l.baseY - l.topY);
if (my >= lobeTop - pad && my <= l.baseY + pad) hits.push(l.ap);
});
return hits;
};
let lastLabelX = -Infinity; let lastLabelX = -Infinity;
ctx.textAlign = 'center'; ctx.textAlign = 'center';
aps.forEach((a) => { aps.forEach((a) => {
@@ -1,6 +1,20 @@
'use strict'; 'use strict';
const views = {}; const views = {};
// One-shot prefill passed between views (Recon target -> PineAP twin form).
// consume() clears the value so a stale prefill can never leak into a
// manually opened form.
const PineAPPrefill = {
data: null,
set(d) { PineAPPrefill.data = d || null; },
consume() {
const d = PineAPPrefill.data;
PineAPPrefill.data = null;
return d;
}
};
window.PineAPPrefill = PineAPPrefill;
const h = (tag, attrs, ...children) => { const h = (tag, attrs, ...children) => {
const n = document.createElement(tag); const n = document.createElement(tag);
if (attrs) { if (attrs) {
@@ -835,7 +849,7 @@ const RECON_TABS = [
const RECON_LANDSCAPE_COLORS = ['#2ecc71', '#2980b9', '#8e44ad']; const RECON_LANDSCAPE_COLORS = ['#2ecc71', '#2980b9', '#8e44ad'];
const RECON_ENC_COLORS = ['#2ecc71', '#2980b9', '#8e44ad', '#e74c3c', '#ff0000', '#34495e']; const RECON_ENC_COLORS = ['#2ecc71', '#2980b9', '#8e44ad', '#e74c3c', '#ff0000', '#34495e'];
const RECON_ENC_BUCKETS = ['Open', 'WEP', 'WPA', 'WPA2', 'WPA3', 'Enterprise']; const RECON_ENC_ORDER = ['Open', 'WEP', 'WPA', 'WPA2-PSK', 'WPA2-Enterprise', 'WPA3-Personal', 'WPA3-PSK', 'WPA3-Enterprise', 'Unknown'];
const RECON_COMPARE_COLORS = ['#2ecc71', '#2980b9', '#8e44ad', '#e67e22', '#c0392b', '#16a085']; const RECON_COMPARE_COLORS = ['#2ecc71', '#2980b9', '#8e44ad', '#e67e22', '#c0392b', '#16a085'];
const RECON_MAX_HISTORY = 90; const RECON_MAX_HISTORY = 90;
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_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'];
@@ -985,6 +999,31 @@ function attackLauncher(kind, opts) {
f.appendChild(h('label', { class: 'switch' }, hiddenCb, h('span', { class: 'track' }), 'Hidden')); f.appendChild(h('label', { class: 'switch' }, hiddenCb, h('span', { class: 'track' }), 'Hidden'));
f.appendChild(h('label', {}, 'Channel', chanSel)); f.appendChild(h('label', {}, 'Channel', chanSel));
f.appendChild(bandHint); f.appendChild(bandHint);
// Consume a Recon -> PineAP prefill (set by the Recon focus sidebar's
// "Send to PineAP" button). The form is filled but never auto-deploys:
// an attack always requires an explicit user action.
const prefill = PineAPPrefill.consume();
if (prefill && prefill.ssid) {
ssidIn.value = prefill.ssid;
hiddenCb.checked = !!prefill.hidden;
if (prefill.channel != null && prefill.channel !== '') {
const chanOpts = Array.prototype.slice.call(chanSel.options);
const hit = chanOpts.find((o) => Number(o.value) === Number(prefill.channel));
if (hit) chanSel.value = hit.value;
chanSel.dispatchEvent(new Event('change'));
}
if (encSel && prefill.enctype) {
const encOpts = Array.prototype.slice.call(encSel.options);
const hit = encOpts.find((o) => o.value === prefill.enctype);
if (hit) encSel.value = hit.value;
}
if (pskIn) pskIn.placeholder = 'Passphrase for ' + prefill.ssid;
if (bssidIn && prefill.bssid) bssidIn.value = prefill.bssid;
f.appendChild(h('div', { class: 'pineap-infobox info', style: 'margin:10px 0 0;font-size:12px',
text: 'Prefilled from Recon (' + (prefill.source || 'target') + '). Set the passphrase, verify the settings, then Deploy.' }));
}
f.appendChild(h('div', { class: 'row', style: 'margin-top:10px' }, f.appendChild(h('div', { class: 'row', style: 'margin-top:10px' },
h('div', {}, btn('Deploy Attack', () => { h('div', {}, btn('Deploy Attack', () => {
const body = { const body = {
@@ -1357,6 +1396,16 @@ views.pineap_enterprise = (root) => {
const iv = setInterval(load, 5000); const iv = setInterval(load, 5000);
return { destroy: () => clearInterval(iv) }; return { destroy: () => clearInterval(iv) };
}; };
function chartLegendEntry(s) {
const entry = h('div', { class: 'recon-chart-entry' });
const dot = h('span', { class: 'recon-chart-dot' });
dot.style.background = s.color;
entry.appendChild(dot);
entry.appendChild(h('span', { class: 'recon-chart-label', text: s.label }));
entry.appendChild(h('span', { class: 'recon-chart-count', text: String(s.value) }));
return entry;
}
function reconEncBucket(enc) { function reconEncBucket(enc) {
const s = (enc || '').trim(); const s = (enc || '').trim();
if (!s || s === 'Open') return 'Open'; if (!s || s === 'Open') return 'Open';
@@ -1372,6 +1421,14 @@ function reconEncBucket(enc) {
return s || 'Unknown'; return s || 'Unknown';
} }
// Map a recon AP's encryption string to the Evil WPA form's enctype.
function reconPrefillEnc(enc) {
const s = (enc || '').toLowerCase();
if (s.indexOf('owe') !== -1) return 'owe';
if (s.indexOf('sae') !== -1) return 'sae';
return 'psk2';
}
function reconPer(key, def) { function reconPer(key, def) {
const v = parseInt(localStorage.getItem('pw_recon_per_' + key), 10); const v = parseInt(localStorage.getItem('pw_recon_per_' + key), 10);
return [10, 25, 50].indexOf(v) !== -1 ? v : def; return [10, 25, 50].indexOf(v) !== -1 ? v : def;
@@ -1403,65 +1460,82 @@ views.recon = (root) => {
apBand: 'all', apEnc: 'all', gps: null, wigle: null, apBand: 'all', apEnc: 'all', gps: null, wigle: null,
compare: [], history: {}, mapBand: null, compare: [], history: {}, mapBand: null,
archive: null, archives: [], scanRemaining: null, archive: null, archives: [], scanRemaining: null,
hopperOnline: null, historyReset: false }; hopperOnline: null, historyReset: false, scanErr: null };
const cols = reconLoadCols(); const cols = reconLoadCols();
// ---- title cards ---- // ---- title cards (stat cards with optional mini charts) ----
const cardWrap = h('div', { class: 'recon-title-card-container' }); const cardWrap = h('div', { class: 'recon-title-card-container' });
root.appendChild(cardWrap); root.appendChild(cardWrap);
function titleCard(titleText, link) { function titleCard(titleText, link) {
const wrap = h('div', { class: 'recon-title-card' });
const card = h('div', { class: 'recon-card' }); const card = h('div', { class: 'recon-card' });
wrap.appendChild(card); const head = h('div', { class: 'recon-card-title' });
card.appendChild(link head.appendChild(link
? h('a', { class: 'recon-card-title-link', href: '#/recon/handshakes', text: titleText }) ? h('a', { class: 'recon-card-title-link', href: link, text: titleText })
: h('div', { class: 'recon-title-card-title', text: titleText })); : h('span', { text: titleText }));
const content = h('div', { class: 'recon-title-card-content' }); card.appendChild(head);
card.appendChild(content); const body = h('div', { class: 'recon-card-body' });
cardWrap.appendChild(wrap); card.appendChild(body);
return content; cardWrap.appendChild(card);
return body;
} }
const landContent = titleCard('Wireless Landscape', false); const landBody = titleCard('Wireless Landscape', null);
const landBox = h('div', { class: 'recon-chart-box' }); const landBox = h('div', { class: 'recon-chart-box' });
landContent.appendChild(landBox); landBody.appendChild(landBox);
const landCanvas = h('canvas', { id: 'recon-landscape' }); const landCanvas = h('canvas', { id: 'recon-landscape' });
landBox.appendChild(landCanvas); landBox.appendChild(landCanvas);
const landEmpty = h('div', { class: 'recon-no-data', text: 'No wireless landscape data is available yet.' }); const landEmpty = h('div', { class: 'recon-no-data', text: 'No landscape data yet — run a scan.' });
landBox.appendChild(landEmpty); landBox.appendChild(landEmpty);
const landLegend = h('div', { class: 'recon-chart-legend', id: 'recon-landscape-legend' });
landBody.appendChild(landLegend);
const chanContent = titleCard('Channel Distribution', false); const chanBody = titleCard('Channel Distribution', null);
const chanValue = h('div', { class: 'recon-card-value', text: '—' });
const chanSub = h('div', { class: 'recon-card-sub', text: '' });
chanBody.appendChild(chanValue);
chanBody.appendChild(chanSub);
const chanBox = h('div', { class: 'recon-chart-box' }); const chanBox = h('div', { class: 'recon-chart-box' });
chanContent.appendChild(chanBox); chanBody.appendChild(chanBox);
const chanCanvas = h('canvas', { id: 'recon-channel' }); const chanCanvas = h('canvas', { id: 'recon-channel' });
chanBox.appendChild(chanCanvas); chanBox.appendChild(chanCanvas);
const chanEmpty = h('div', { class: 'recon-no-data', text: 'No channel distribution data is available yet.' }); const chanEmpty = h('div', { class: 'recon-no-data', text: 'No channel data yet — run a scan.' });
chanBox.appendChild(chanEmpty); chanBox.appendChild(chanEmpty);
const encContent = titleCard('Encryption Landscape', false); const encBody = titleCard('Encryption Landscape', null);
const encBox = h('div', { class: 'recon-chart-box' }); const encBox = h('div', { class: 'recon-chart-box' });
encContent.appendChild(encBox); encBody.appendChild(encBox);
const encCanvas = h('canvas', { id: 'recon-encryption' }); const encCanvas = h('canvas', { id: 'recon-encryption' });
encBox.appendChild(encCanvas); encBox.appendChild(encCanvas);
const encEmpty = h('div', { class: 'recon-no-data', text: 'No encryption data is available yet.' }); const encEmpty = h('div', { class: 'recon-no-data', text: 'No encryption data yet — run a scan.' });
encBox.appendChild(encEmpty); encBox.appendChild(encEmpty);
const encLegend = h('div', { class: 'recon-chart-legend', id: 'recon-enc-legend' });
encBody.appendChild(encLegend);
const hsContent = titleCard('Handshakes', true); const hsBody = titleCard('Handshakes', '#/recon/handshakes');
const hsCol = h('div', { class: 'recon-hs-col' });
hsContent.appendChild(hsCol);
const hsCount = h('span', { class: 'recon-hs-count', text: '0' }); const hsCount = h('span', { class: 'recon-hs-count', text: '0' });
hsCol.appendChild(hsCount); hsBody.appendChild(hsCount);
hsCol.appendChild(h('span', { class: 'recon-hs-label', text: 'Handshakes Captured' })); hsBody.appendChild(h('span', { class: 'recon-hs-label', text: 'handshakes captured' }));
const hsAuto = h('label', { class: 'recon-toggle' }, const hsAuto = h('label', { class: 'recon-toggle' },
h('input', { type: 'checkbox', id: 'recon-auto-hs' }), ' Automatically Collect Any Handshakes'); h('input', { type: 'checkbox', id: 'recon-auto-hs' }), ' Auto-collect handshakes');
hsAuto.querySelector('input').addEventListener('change', () => { hsAuto.querySelector('input').addEventListener('change', () => {
PagerAPI.post('/api/pineap/set_config', { loghandshake: hsAuto.querySelector('input').checked }) const requested = hsAuto.querySelector('input').checked;
.then(() => App.toast('Settings saved')).catch(() => App.toast('Failed to save', 'error')); PagerAPI.post('/api/pineap/set_config', { loghandshake: requested })
.then(() => App.toast('Settings saved'))
.catch(() => {
hsAuto.querySelector('input').checked = !requested;
App.toast('Failed to save', 'error');
});
}); });
hsCol.appendChild(hsAuto); hsBody.appendChild(hsAuto);
const psContent = titleCard('Previous Scans', false); const psBody = titleCard('Previous Scans', null);
const psValue = h('div', { class: 'recon-card-value', text: '—' });
const psSub = h('div', { class: 'recon-card-sub', text: '' });
psBody.appendChild(psValue);
psBody.appendChild(psSub);
const psRow = h('div', { class: 'recon-ps-row' });
psBody.appendChild(psRow);
let pickerOptions = []; let pickerOptions = [];
const sel = h('select', { class: 'sel', id: 'recon-scan-select' }); const sel = h('select', { class: 'sel', id: 'recon-scan-select' });
sel.addEventListener('change', () => { sel.addEventListener('change', () => {
@@ -1473,6 +1547,9 @@ views.recon = (root) => {
state.detailId = null; state.detailArchive = null; state.detailId = null; state.detailArchive = null;
loadDetail(); loadDetail();
}); });
psRow.appendChild(sel);
const psActions = h('div', { class: 'recon-ps-actions' });
psBody.appendChild(psActions);
function dlBase() { function dlBase() {
if (state.selected == null) return null; if (state.selected == null) return null;
return state.archive return state.archive
@@ -1508,16 +1585,11 @@ views.recon = (root) => {
}) })
.catch(() => App.toast('Delete failed', 'error')); .catch(() => App.toast('Delete failed', 'error'));
}); });
const psActions = h('div', { class: 'row', style: 'margin:6px 0 8px' });
psActions.appendChild(dlJson); psActions.appendChild(dlJson);
psActions.appendChild(dlCsv); psActions.appendChild(dlCsv);
psActions.appendChild(dlHtml); psActions.appendChild(dlHtml);
psActions.appendChild(delBtn); psActions.appendChild(delBtn);
psActions.appendChild(delAllBtn); psActions.appendChild(delAllBtn);
psContent.appendChild(psActions);
const psRow = h('div', { class: 'recon-ps-row' });
psContent.appendChild(psRow);
psRow.appendChild(sel);
// ---- scan bar ---- // ---- scan bar ----
const scanBar = h('div', { class: 'section recon-scan-bar' }); const scanBar = h('div', { class: 'section recon-scan-bar' });
@@ -1586,8 +1658,9 @@ views.recon = (root) => {
} }
if (state.hopperOnline === false) bits.push('Hopper radio offline — fewer networks seen'); if (state.hopperOnline === false) bits.push('Hopper radio offline — fewer networks seen');
if (state.historyReset) bits.push('History reset — previous scans archived (see Previous Scans)'); if (state.historyReset) bits.push('History reset — previous scans archived (see Previous Scans)');
if (state.scanErr) bits.push(state.scanErr);
scanStatus.textContent = bits.join(' · '); scanStatus.textContent = bits.join(' · ');
scanStatus.classList.toggle('warn', state.hopperOnline === false || state.historyReset); scanStatus.classList.toggle('warn', state.hopperOnline === false || state.historyReset || !!state.scanErr);
} }
scanToggle.addEventListener('change', () => { scanToggle.addEventListener('change', () => {
if (pendingScan) { scanToggle.checked = !scanToggle.checked; return; } if (pendingScan) { scanToggle.checked = !scanToggle.checked; return; }
@@ -1670,34 +1743,63 @@ views.recon = (root) => {
const actions = h('div', { class: 'recon-focus-body' }); const actions = h('div', { class: 'recon-focus-body' });
focusSidebar.appendChild(actions); focusSidebar.appendChild(actions);
actions.appendChild(h('div', { class: 'recon-focus-body-title', text: 'Actions' })); actions.appendChild(h('div', { class: 'recon-focus-body-title', text: 'Actions' }));
const twin = h('button', { class: 'btn recon-focus-action-button recon-focus-twin', text: 'Send to PineAP — Twin this network' });
twin.addEventListener('click', () => {
const open = reconEncBucket(ap.encryption) === 'Open';
PineAPPrefill.set({
ssid: ap.ssid || '',
hidden: !!ap.hidden,
channel: ap.channel,
bssid: open ? (ap.bssid || '') : '',
enctype: open ? null : reconPrefillEnc(ap.encryption),
source: ap.ssid || 'hidden network'
});
App.go(open ? '#/pineap/open' : '#/pineap/evilwpa');
App.toast('PineAP form prefilled for ' + (ap.ssid || 'the hidden network') + ' — verify, then Deploy');
});
actions.appendChild(twin);
const capture = h('button', { class: 'btn recon-focus-action-button', text: 'Capture WPA Handshakes' }); const capture = h('button', { class: 'btn recon-focus-action-button', text: 'Capture WPA Handshakes' });
capture.addEventListener('click', () => { capture.addEventListener('click', () => {
capture.disabled = true;
PagerAPI.post('/api/pineap/set_config', { loghandshake: true }) PagerAPI.post('/api/pineap/set_config', { loghandshake: true })
.then(() => App.toast('Handshake capture enabled (device-wide on Pager)')) .then(() => {
.catch(() => App.toast('Failed to enable handshake capture', 'error')); App.toast('Handshake capture enabled (device-wide on Pager)');
if (hsAuto) hsAuto.querySelector('input').checked = true;
})
.catch(() => App.toast('Failed to enable handshake capture', 'error'))
.finally(() => { capture.disabled = false; });
}); });
actions.appendChild(capture); actions.appendChild(capture);
const stopHs = h('button', { class: 'btn danger recon-focus-action-button', text: 'Stop Handshake Capture' }); const stopHs = h('button', { class: 'btn danger recon-focus-action-button', text: 'Stop Handshake Capture' });
stopHs.addEventListener('click', () => { stopHs.addEventListener('click', () => {
stopHs.disabled = true;
PagerAPI.post('/api/pineap/set_config', { loghandshake: false }) PagerAPI.post('/api/pineap/set_config', { loghandshake: false })
.then(() => App.toast('Handshake capture disabled')) .then(() => {
.catch(() => App.toast('Failed to disable handshake capture', 'error')); App.toast('Handshake capture disabled');
if (hsAuto) hsAuto.querySelector('input').checked = false;
})
.catch(() => App.toast('Failed to disable handshake capture', 'error'))
.finally(() => { stopHs.disabled = false; });
}); });
actions.appendChild(stopHs); actions.appendChild(stopHs);
const exB = h('button', { class: 'btn recon-focus-action-button', text: 'Examine BSSID' }); const exB = h('button', { class: 'btn recon-focus-action-button', text: 'Examine BSSID' });
exB.addEventListener('click', () => { exB.addEventListener('click', () => {
if (!ap.bssid) return; if (!ap.bssid) return;
exB.disabled = true;
PagerAPI.post('/api/recon/examine', { bssid: ap.bssid }) PagerAPI.post('/api/recon/examine', { bssid: ap.bssid })
.then(() => App.toast('Examining ' + ap.bssid)) .then(() => App.toast('Examining ' + ap.bssid + ' — check the Pager screen'))
.catch(() => App.toast('Examine failed', 'error')); .catch(() => App.toast('Examine failed', 'error'))
.finally(() => { exB.disabled = false; });
}); });
actions.appendChild(exB); actions.appendChild(exB);
const exC = h('button', { class: 'btn recon-focus-action-button', text: 'Examine Channel' }); const exC = h('button', { class: 'btn recon-focus-action-button', text: 'Examine Channel' });
exC.addEventListener('click', () => { exC.addEventListener('click', () => {
if (ap.channel == null) { App.toast('Channel unknown', 'error'); return; } if (ap.channel == null) { App.toast('Channel unknown', 'error'); return; }
exC.disabled = true;
PagerAPI.post('/api/recon/examine', { channel: ap.channel }) PagerAPI.post('/api/recon/examine', { channel: ap.channel })
.then(() => App.toast('Examining channel ' + ap.channel)) .then(() => App.toast('Examining channel ' + ap.channel + ' — check the Pager screen'))
.catch(() => App.toast('Examine failed', 'error')); .catch(() => App.toast('Examine failed', 'error'))
.finally(() => { exC.disabled = false; });
}); });
actions.appendChild(exC); actions.appendChild(exC);
@@ -1729,7 +1831,6 @@ views.recon = (root) => {
// ---- compare: select up to 6 APs; the rest of the page focuses on them ---- // ---- compare: select up to 6 APs; the rest of the page focuses on them ----
const cmpCard = h('div', { class: 'section recon-compare-card' }); const cmpCard = h('div', { class: 'section recon-compare-card' });
root.appendChild(cmpCard);
cmpCard.appendChild(h('h2', { text: 'Compare APs' })); cmpCard.appendChild(h('h2', { text: 'Compare APs' }));
const cmpCanvas = h('canvas', { id: 'recon-compare', style: 'width:100%;height:150px' }); const cmpCanvas = h('canvas', { id: 'recon-compare', style: 'width:100%;height:150px' });
cmpCard.appendChild(cmpCanvas); cmpCard.appendChild(cmpCanvas);
@@ -1792,13 +1893,14 @@ views.recon = (root) => {
// ---- results tables ---- // ---- results tables ----
const apCard = h('div', { class: 'section recon-scan-results-card' }); const apCard = h('div', { class: 'section recon-scan-results-card' });
root.appendChild(apCard);
// ---- channel map (under Access Points) ---- // ---- channel map (above Access Points) ----
const mapCard = h('div', { class: 'section recon-map-card' }); const mapCard = h('div', { class: 'section recon-map-card' });
root.appendChild(mapCard); root.appendChild(mapCard);
root.appendChild(apCard);
root.appendChild(cmpCard);
mapCard.appendChild(h('h2', { text: 'Channel Map' })); mapCard.appendChild(h('h2', { text: 'Channel Map' }));
mapCard.appendChild(h('div', { class: 'recon-map-sub', text: 'Access points placed at their reported center frequency. The radio does not report channel width, so every lobe assumes 20 MHz.' })); mapCard.appendChild(h('div', { class: 'recon-map-sub', text: 'Access points placed at their reported center frequency. The radio does not report channel width, so every lobe assumes 20 MHz. Hover a lobe (or click to pin) to see the networks under it.' }));
const mapChips = h('div', { class: 'recon-chips-row recon-map-chips' }); const mapChips = h('div', { class: 'recon-chips-row recon-map-chips' });
mapCard.appendChild(mapChips); mapCard.appendChild(mapChips);
const mapBox = h('div', { class: 'recon-map-box' }); const mapBox = h('div', { class: 'recon-map-box' });
@@ -1807,6 +1909,61 @@ views.recon = (root) => {
mapBox.appendChild(mapCanvas); mapBox.appendChild(mapCanvas);
const mapEmpty = h('div', { class: 'recon-no-data', text: 'No access points with a known channel yet.' }); const mapEmpty = h('div', { class: 'recon-no-data', text: 'No access points with a known channel yet.' });
mapBox.appendChild(mapEmpty); mapBox.appendChild(mapEmpty);
const mapTip = h('div', { class: 'recon-map-tip hidden' });
mapBox.appendChild(mapTip);
let mapTipPinned = false;
function renderMapTip(hits, x, y) {
mapTip.innerHTML = '';
hits.forEach((a) => {
mapTip.appendChild(h('div', { class: 'recon-map-tip-row' },
h('div', { class: 'recon-map-tip-ssid', text: a.ssid || '(hidden SSID)' }),
h('div', { class: 'recon-map-tip-meta', text:
(a.bssid || '--') + ' · CH ' + (a.channel == null ? '--' : a.channel) +
(a.freq ? ' · ' + a.freq + ' MHz' : '') + ' · ' +
(a.signal == null ? '--' : a.signal + ' dBm') }),
h('div', { class: 'recon-map-tip-meta', text:
(a.encryption || '--') + (a.vendor && a.vendor !== 'Unknown' ? ' · ' + a.vendor : '') })));
});
const bx = mapBox.getBoundingClientRect();
const tx = Math.max(4, Math.min(x - bx.left + 14, bx.width - 250));
const ty = Math.max(4, Math.min(y - bx.top + 14, bx.height - 80));
mapTip.style.left = tx + 'px';
mapTip.style.top = ty + 'px';
mapTip.classList.remove('hidden');
}
function mapHitsAt(e) {
if (!mapCanvas.__reconLobes || !mapCanvas.__reconLobesHit) return [];
const rect = mapCanvas.getBoundingClientRect();
return mapCanvas.__reconLobesHit(e.clientX - rect.left, e.clientY - rect.top, 8);
}
mapCanvas.addEventListener('mousemove', (e) => {
const hits = mapHitsAt(e);
if (!hits.length) {
if (!mapTipPinned) mapTip.classList.add('hidden');
mapCanvas.style.cursor = 'default';
return;
}
mapCanvas.style.cursor = 'pointer';
if (!mapTipPinned) renderMapTip(hits, e.clientX, e.clientY);
});
mapCanvas.addEventListener('mouseleave', () => {
if (!mapTipPinned) mapTip.classList.add('hidden');
mapCanvas.style.cursor = 'default';
});
mapCanvas.addEventListener('click', (e) => {
const hits = mapHitsAt(e);
if (!hits.length) {
mapTip.classList.add('hidden');
mapTipPinned = false;
return;
}
mapTipPinned = !mapTipPinned;
if (mapTipPinned) renderMapTip(hits, e.clientX, e.clientY);
else mapTip.classList.add('hidden');
});
function mapAps() { function mapAps() {
const d = state.detail || {}; const d = state.detail || {};
@@ -1853,6 +2010,8 @@ views.recon = (root) => {
if (!vis.length || !hasChan) { if (!vis.length || !hasChan) {
mapCanvas.classList.add('hidden'); mapCanvas.classList.add('hidden');
mapEmpty.classList.remove('hidden'); mapEmpty.classList.remove('hidden');
mapTip.classList.add('hidden');
mapTipPinned = false;
return; return;
} }
mapEmpty.classList.add('hidden'); mapEmpty.classList.add('hidden');
@@ -2078,64 +2237,105 @@ views.recon = (root) => {
: all; : all;
const n = aps.length; const n = aps.length;
const c = (d.clients || []).length; const c = (d.clients || []).length;
const land = document.getElementById('recon-landscape'); const un = d.unassociated || 0;
if (land && typeof MiniChart !== 'undefined' && MiniChart.doughnut) {
if (n > 0) { const landSegs = state.compare.length
const segs = state.compare.length ? [{ label: 'Selected APs', value: n, color: RECON_LANDSCAPE_COLORS[0] }]
? [{ label: 'Selected APs', value: n, color: RECON_LANDSCAPE_COLORS[0] }] : [
: [ { label: 'Access Points', value: n, color: RECON_LANDSCAPE_COLORS[0] },
{ label: 'Access Points', value: n, color: RECON_LANDSCAPE_COLORS[0] }, { label: 'Clients', value: c, color: RECON_LANDSCAPE_COLORS[1] },
{ label: 'Clients', value: c, color: RECON_LANDSCAPE_COLORS[1] }, { label: 'Unassociated', value: un, color: RECON_LANDSCAPE_COLORS[2] }
{ label: 'Unassociated', value: d.unassociated || 0, color: RECON_LANDSCAPE_COLORS[2] } ];
]; const chCounts = {};
MiniChart.doughnut(land, segs, { legend: true, height: 130 });
land.classList.remove('hidden');
landEmpty.classList.add('hidden');
} else {
land.classList.add('hidden');
landEmpty.classList.remove('hidden');
}
}
const counts = {};
aps.forEach((a) => { aps.forEach((a) => {
const ch = a.channel == null ? '?' : a.channel; const ch = a.channel == null ? '?' : a.channel;
counts[ch] = (counts[ch] || 0) + 1; chCounts[ch] = (chCounts[ch] || 0) + 1;
}); });
const keys = Object.keys(counts).sort((a, b) => { const chKeys = Object.keys(chCounts);
if (a === '?') return 1; let busiest = null, busiestN = 0;
if (b === '?') return -1; chKeys.forEach((k) => {
return Number(a) - Number(b); if (k === '?') return;
if (chCounts[k] > busiestN) { busiest = k; busiestN = chCounts[k]; }
}); });
const ch = document.getElementById('recon-channel'); chanValue.textContent = busiest == null ? '—' : 'CH ' + busiest;
if (ch && typeof MiniChart !== 'undefined' && MiniChart.bar) { chanSub.textContent = busiest == null
if (keys.length) { ? ''
MiniChart.bar(ch, keys.map((k, i) => ({ : busiestN + ' of ' + n + ' APs · ' + chKeys.filter((k) => k !== '?').length + ' channels';
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');
}
}
const encCounts = {}; const encCounts = {};
aps.forEach((a) => { aps.forEach((a) => {
const b = reconEncBucket(a.encryption); const b = reconEncBucket(a.encryption);
encCounts[b] = (encCounts[b] || 0) + 1; encCounts[b] = (encCounts[b] || 0) + 1;
}); });
const encSegs = Object.keys(encCounts)
.sort((a, b) => {
const ia = RECON_ENC_ORDER.indexOf(a);
const ib = RECON_ENC_ORDER.indexOf(b);
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib) || encCounts[b] - encCounts[a];
})
.map((k, i) => ({
label: k, value: encCounts[k], color: RECON_ENC_COLORS[i % RECON_ENC_COLORS.length]
}));
const land = document.getElementById('recon-landscape');
if (land && typeof MiniChart !== 'undefined' && MiniChart.doughnut) {
try {
if (n > 0) {
MiniChart.doughnut(land, landSegs, { legend: false, height: 112 });
land.classList.remove('hidden');
landEmpty.classList.add('hidden');
} else {
land.classList.add('hidden');
landEmpty.classList.remove('hidden');
}
} catch (e) {}
}
const landLegend = document.getElementById('recon-landscape-legend');
if (landLegend) {
landLegend.innerHTML = '';
landSegs.forEach((s) => {
if (!s.value) return;
landLegend.appendChild(chartLegendEntry(s));
});
}
const ch = document.getElementById('recon-channel');
if (ch && typeof MiniChart !== 'undefined' && MiniChart.bar) {
try {
const keys = chKeys.sort((a, b) => {
if (a === '?') return 1;
if (b === '?') return -1;
return Number(a) - Number(b);
});
if (keys.length) {
MiniChart.bar(ch, keys.map((k, i) => ({
label: k, value: chCounts[k], color: RECON_CHANNEL_COLORS[i % RECON_CHANNEL_COLORS.length]
})), { height: 90 });
ch.classList.remove('hidden');
chanEmpty.classList.add('hidden');
} else {
ch.classList.add('hidden');
chanEmpty.classList.remove('hidden');
}
} catch (e) {}
}
const enc = document.getElementById('recon-encryption'); const enc = document.getElementById('recon-encryption');
if (enc && typeof MiniChart !== 'undefined' && MiniChart.doughnut) { if (enc && typeof MiniChart !== 'undefined' && MiniChart.doughnut) {
if (aps.length) { try {
MiniChart.doughnut(enc, RECON_ENC_BUCKETS.map((k, i) => ({ if (aps.length) {
label: k, value: encCounts[k] || 0, color: RECON_ENC_COLORS[i] MiniChart.doughnut(enc, encSegs, { legend: false, height: 112 });
})), { legend: true, height: 130 }); enc.classList.remove('hidden');
enc.classList.remove('hidden'); encEmpty.classList.add('hidden');
encEmpty.classList.add('hidden'); } else {
} else { enc.classList.add('hidden');
enc.classList.add('hidden'); encEmpty.classList.remove('hidden');
encEmpty.classList.remove('hidden'); }
} } catch (e) {}
}
const encLegend = document.getElementById('recon-enc-legend');
if (encLegend) {
encLegend.innerHTML = '';
encSegs.forEach((s) => {
encLegend.appendChild(chartLegendEntry(s));
});
} }
} }
@@ -2158,42 +2358,63 @@ views.recon = (root) => {
state.detailLoadingId = scanId; state.detailLoadingId = scanId;
PagerAPI.get(detailUrl()).then((r) => { PagerAPI.get(detailUrl()).then((r) => {
if (state.selected !== scanId || state.archive !== arch) return; if (state.selected !== scanId || state.archive !== arch) return;
const isNewScan = state.detailId !== scanId || state.detailArchive !== arch; try {
state.detail = r.data; const isNewScan = state.detailId !== scanId || state.detailArchive !== arch;
state.detailId = scanId; state.detail = r.data;
state.detailArchive = arch; state.detailId = scanId;
if (isNewScan) state.history = {}; state.detailArchive = arch;
const aps = r.data.aps || []; if (isNewScan) state.history = {};
const seen = {}; const aps = r.data.aps || [];
aps.forEach((a) => { if (a.bssid) seen[a.bssid] = true; }); const seen = {};
if (state.compare.some((b) => !seen[b])) { aps.forEach((a) => { if (a.bssid) seen[a.bssid] = true; });
state.compare = state.compare.filter((b) => seen[b]); if (state.compare.some((b) => !seen[b])) {
state.compare = state.compare.filter((b) => seen[b]);
}
if (!arch) {
// Signal-over-time history only makes sense for the live database.
const nowT = Date.now() / 1000;
aps.forEach((a) => {
if (a.bssid == null || a.signal == null) return;
const hist = state.history[a.bssid] || (state.history[a.bssid] = []);
hist.push({ t: nowT, sig: a.signal });
while (hist.length > RECON_MAX_HISTORY) hist.shift();
});
Object.keys(state.history).forEach((b) => {
if (!seen[b]) {
const hist = state.history[b];
const recent = hist.filter((p) => nowT - p.t < 30);
if (!recent.length) delete state.history[b];
else state.history[b] = recent;
}
});
}
drawCharts(r.data);
renderTables();
renderSelection();
renderCompare();
renderChannelMap();
hsCount.textContent = (r.data.handshakes || []).length;
if (state.scanErr) { state.scanErr = null; renderScanBar(); }
} catch (err) {
// A render failure must never brick the page: reset the detail state
// so the next poll re-fetches and re-renders.
state.detail = null;
state.detailId = null;
state.detailArchive = null;
state.scanErr = 'Scan data failed to render — retrying…';
renderScanBar();
} }
if (!arch) { }).catch(() => {
// Signal-over-time history only makes sense for the live database. // Transient failure (recon DB busy / 503 / timeout): keep detailId
const nowT = Date.now() / 1000; // unset so the next poll retries, and tell the user.
aps.forEach((a) => { if (state.selected === scanId && state.archive === arch) {
if (a.bssid == null || a.signal == null) return; state.detail = null;
const hist = state.history[a.bssid] || (state.history[a.bssid] = []); state.detailId = null;
hist.push({ t: nowT, sig: a.signal }); state.detailArchive = null;
while (hist.length > RECON_MAX_HISTORY) hist.shift(); state.scanErr = 'Scan data unavailable — retrying…';
}); renderScanBar();
Object.keys(state.history).forEach((b) => {
if (!seen[b]) {
const hist = state.history[b];
const recent = hist.filter((p) => nowT - p.t < 30);
if (!recent.length) delete state.history[b];
else state.history[b] = recent;
}
});
} }
drawCharts(r.data); }).finally(() => {
renderTables();
renderSelection();
renderCompare();
renderChannelMap();
hsCount.textContent = (r.data.handshakes || []).length;
}).catch(() => {}).finally(() => {
state.detailLoading = false; state.detailLoading = false;
state.detailLoadingId = null; state.detailLoadingId = null;
if (state.detailQueued) { if (state.detailQueued) {
@@ -2241,10 +2462,21 @@ views.recon = (root) => {
if (idx !== -1) sel.value = String(idx); if (idx !== -1) sel.value = String(idx);
delBtn.disabled = state.archive !== null; delBtn.disabled = state.archive !== null;
delBtn.title = state.archive ? 'Archived scans are read-only' : 'Delete scan'; delBtn.title = state.archive ? 'Archived scans are read-only' : 'Delete scan';
const archCount = state.archives.reduce((m, a) => m + ((a.scans || []).length), 0);
const total = state.scans.length + archCount;
psValue.textContent = total ? String(total) : '—';
const latest = state.scans[0];
psSub.textContent = total
? 'Latest: ' + fmtTime(latest ? latest.time : null) + (archCount ? ' · ' + archCount + ' archived' : '')
: 'No scans recorded yet';
} }
let loadPending = false;
function load() { function load() {
if (loadPending) return;
loadPending = true;
PagerAPI.get('/api/recon/scans').then((r) => { PagerAPI.get('/api/recon/scans').then((r) => {
state.scanErr = null;
state.scans = r.data.scans || []; state.scans = r.data.scans || [];
const newest = state.scans[0] ? state.scans[0].id : null; const newest = state.scans[0] ? state.scans[0].id : null;
let keep = null; let keep = null;
@@ -2278,7 +2510,10 @@ views.recon = (root) => {
} }
if (state.selected != null) loadDetail(); if (state.selected != null) loadDetail();
} }
}).catch(() => {}); }).catch(() => {
state.scanErr = 'Scan list unavailable — retrying…';
renderScanBar();
}).finally(() => { loadPending = false; });
PagerAPI.get('/api/recon/status').then((r) => { PagerAPI.get('/api/recon/status').then((r) => {
const scanning = !!r.data.scanning; const scanning = !!r.data.scanning;
const wasScanning = state.scanActive; const wasScanning = state.scanActive;
@@ -2309,11 +2544,13 @@ views.recon = (root) => {
state.archives = (r.data && r.data.archives) || []; state.archives = (r.data && r.data.archives) || [];
renderPicker(); renderPicker();
}).catch(() => {}); }).catch(() => {});
// Keep the auto-collect toggle in sync with the device's actual
// loghandshake setting (the pager's own UI can change it).
PagerAPI.get('/api/pineap/get_config').then((r) => {
hsAuto.querySelector('input').checked = !!((r.data || {}).loghandshake);
}).catch(() => {});
} }
PagerAPI.get('/api/pineap/get_config').then((r) => {
hsAuto.querySelector('input').checked = !!((r.data || {}).loghandshake);
}).catch(() => {});
load(); load();
loadSlow(); loadSlow();
let pollIv = null; let pollIv = null;
+51 -15
View File
@@ -9,8 +9,10 @@ import server
def setUpModule(): def setUpModule():
__import__('importlib').reload(server) __import__('importlib').reload(server)
_orig_hak5 = server.hak5 _orig_device_run = server.device_run
_orig_assoc_clients = server.assoc_clients _orig_assoc_clients = server.assoc_clients
_orig_iface_ap_info = server._iface_ap_info
_orig_pineap = server._pineap
class NormalizeTest(unittest.TestCase): class NormalizeTest(unittest.TestCase):
@@ -24,8 +26,10 @@ class NormalizeTest(unittest.TestCase):
class ClientsTest(unittest.TestCase): class ClientsTest(unittest.TestCase):
def tearDown(self): def tearDown(self):
server.hak5 = _orig_hak5 server.device_run = _orig_device_run
server.assoc_clients = _orig_assoc_clients server.assoc_clients = _orig_assoc_clients
server._iface_ap_info = _orig_iface_ap_info
server._pineap = _orig_pineap
def test_clients_handler(self): def test_clients_handler(self):
server.assoc_clients = lambda: [{'mac': 'AA:BB:CC:DD:EE:FF', 'iface': 'wlan0open', 'rssi': -55}] server.assoc_clients = lambda: [{'mac': 'AA:BB:CC:DD:EE:FF', 'iface': 'wlan0open', 'rssi': -55}]
@@ -35,15 +39,37 @@ class ClientsTest(unittest.TestCase):
self.assertEqual(status, 200) self.assertEqual(status, 200)
self.assertEqual(payload['count'], 1) self.assertEqual(payload['count'], 1)
def test_kick_validates_and_deny_adds(self): def _kick_env(self):
calls = [] calls = []
def fake(*args): def fake_run(argv, timeout=30):
calls.append(args) calls.append(argv)
return 'ok' return 0, '', ''
server.hak5 = fake server.device_run = fake_run
server.assoc_clients = lambda: [{'mac': '00:11:22:33:44:55', 'iface': 'wlan0open', 'rssi': -55}]
server._iface_ap_info = lambda iface: ('AA:BB:CC:DD:EE:FF', 6)
server._pineap = lambda *a, **k: (0, '', '')
return calls
def test_kick_validates_and_deny_adds(self):
calls = self._kick_env()
server.h_client_kick(type('C', (), {'args': (), 'body': {'mac': '00:11:22:33:44:55'}})()) server.h_client_kick(type('C', (), {'args': (), 'body': {'mac': '00:11:22:33:44:55'}})())
self.assertIn(('PINEAPPLE_DEVICE_FILTER_ADD', 'deny', '00:11:22:33:44:55'), calls) self.assertIn([server.HAK5CMD, 'PINEAPPLE_DEVICE_FILTER_ADD', 'deny', '00:11:22:33:44:55'], calls)
self.assertTrue(any(c[0] == 'PINEAPPLE_DEAUTH_CLIENT' for c in calls)) # The immediate deauth must use the full bssid/target/channel form.
self.assertTrue(any(c[:4] == [server.HAK5CMD, 'DEAUTH_CLIENT', 'AA:BB:CC:DD:EE:FF',
'00:11:22:33:44:55'] and c[4] == '6' for c in calls))
def test_kick_not_associated_still_filters(self):
calls = []
def fake_run(argv, timeout=30):
calls.append(argv)
return 0, '', ''
server.device_run = fake_run
server.assoc_clients = lambda: []
status, payload = server.h_client_kick(type('C', (), {'args': (), 'body': {'mac': '00:11:22:33:44:55'}})())
self.assertEqual(status, 200)
self.assertIs(payload['deauth'], False)
self.assertTrue(any(c == [server.HAK5CMD, 'PINEAPPLE_DEVICE_FILTER_ADD', 'deny',
'00:11:22:33:44:55'] for c in calls))
def test_kick_bad_mac_400(self): def test_kick_bad_mac_400(self):
status, payload = server.h_client_kick(type('C', (), {'args': (), 'body': {'mac': 'x'}})()) status, payload = server.h_client_kick(type('C', (), {'args': (), 'body': {'mac': 'x'}})())
@@ -51,12 +77,22 @@ class ClientsTest(unittest.TestCase):
def test_deauth_client(self): def test_deauth_client(self):
calls = [] calls = []
def fake(*args): def fake_run(argv, timeout=30):
calls.append(args) calls.append(argv)
return 'ok' return 0, '', ''
server.hak5 = fake server.device_run = fake_run
server.h_deauth_client(type('C', (), {'args': (), 'body': {'mac': '00:11:22:33:44:55'}})()) server.assoc_clients = lambda: [{'mac': '00:11:22:33:44:55', 'iface': 'wlan1wpa', 'rssi': -60}]
self.assertEqual(calls[0][0], 'PINEAPPLE_DEAUTH_CLIENT') server._iface_ap_info = lambda iface: ('AA:BB:CC:DD:EE:FF', 149)
status, payload = server.h_deauth_client(type('C', (), {'args': (), 'body': {'mac': '00:11:22:33:44:55'}})())
self.assertEqual(status, 200)
# 5 GHz client -> wlan1mon inject, no _pineap pin needed.
self.assertTrue(any(c[:4] == [server.HAK5CMD, 'DEAUTH_CLIENT', 'AA:BB:CC:DD:EE:FF',
'00:11:22:33:44:55'] and c[4] == '149' for c in calls))
def test_deauth_client_not_associated_502(self):
server.assoc_clients = lambda: []
status, payload = server.h_deauth_client(type('C', (), {'args': (), 'body': {'mac': '00:11:22:33:44:55'}})())
self.assertEqual(status, 502)
if __name__ == '__main__': if __name__ == '__main__':