15 Commits
Author SHA1 Message Date
bzuccaro 251b1f6261 feat: recon surveys with OUI/vendor lookup and report views
Reconnaissance surveys (GPSD-tethered scan recording), OUI vendor
lookup for client/AP tables, survey/report pages, and the matching
test_recon.py suite. Co-authored with the recon-feature agent whose
work was finished in this checkout.
2026-08-18 08:35:22 -05:00
bzuccaro f5cddb335a docs: correct 5GHz picker range and HEAD reference 2026-08-18 08:31:02 -05:00
bzuccaro af5ff8039b fix: gate channel_band 6GHz to 181-233, reject open-6GHz, self-heal load-time 6GHz hints, harden hop/BSSID handling 2026-08-18 08:30:45 -05:00
bzuccaro c0b9e58aa8 docs: record on-device verification results for 5/6GHz AP 2026-08-18 08:24:19 -05:00
bzuccaro 12279fe29d fix: write explicit disabled=0 for radio1 AP ifaces 2026-08-18 08:13:41 -05:00
bzuccaro 7536f3ca45 fix: self-heal radio1 AP after stock daemon wireless writes 2026-08-18 08:08:50 -05:00
bzuccaro be2685aa15 docs: 5GHz/6GHz rogue AP feature and coexistence notes 2026-08-18 07:30:06 -05:00
bzuccaro ff3bd16855 feat: band-aware channel pickers for Open AP and Evil WPA 2026-08-18 07:26:55 -05:00
bzuccaro 4fa7f8f855 fix: reject mixed 2.4GHz and radio1 AP requests with clear error 2026-08-18 07:23:25 -05:00
bzuccaro cd39833f4b test: update proxy tests for get_ap channel shape and radio1 cleanup 2026-08-17 23:29:47 -05:00
bzuccaro 4effc08a25 feat: radio1 5GHz/6GHz rogue AP via UCI with hop pause 2026-08-17 23:28:54 -05:00
bzuccaro b2098bae7d fix: fall back to radio channel when AP iface lacks channel 2026-08-17 23:21:09 -05:00
bzuccaro 7aff767f2a feat: read radio1 AP state in wifi get_ap 2026-08-17 23:17:57 -05:00
bzuccaro 27df97ec43 test: pin CHANNEL_BANDS consistency and DFS coverage 2026-08-17 23:15:01 -05:00
bzuccaro acbf4c0ce9 feat: channel/band mapping helpers for radio1 AP 2026-08-17 23:01:16 -05:00
11 changed files with 2969 additions and 76 deletions
+5
View File
@@ -8,6 +8,11 @@ Recon (scans from `recon.db`), Handshakes/Loot, Payloads (embedded stock Pager
Portal), Logs, Settings (hostname/NTP/password/prefs), and a bottom-docked xterm
terminal.
- Rogue AP on the second radio (5GHz / 6GHz Wi-Fi 6E): Open AP and Evil WPA
(WPA2-PSK/WPA3-SAE/WPA3-OWE) on `radio1`, band-aware channel pickers,
6GHz requires WPA3. While a radio1 AP is enabled the stock monitor-hopping
(`wlan1mon`) is paused and resumed on disable; 2.4GHz PineAP is untouched.
## Requirements
- WiFi Pineapple Pager, firmware `Pineapple Pager 24.10.1`
+217
View File
@@ -0,0 +1,217 @@
# Radio1 5GHz/6GHz Rogue AP — Design Record
- **Date:** 2026-08-17
- **Status:** Implemented and verified on-device (see §9)
- **Owner:** Hak5 WiFi Pineapple Pager expansion project
- **Scope:** Mark VIII WebUI (`http://172.16.52.1:8080/`) running a rogue AP on
the Pager's second radio (`radio1` = MT7921U Wi-Fi 6E) on 5GHz and 6GHz, with
band-aware channel pickers, without breaking the stock Pager UI's control of
its own interfaces.
## 1. Goal
Today Mark VIII's PineAP Open AP and Evil WPA pages configure only `wlan0open`
/ `wlan0wpa` on `radio0` (2.4GHz) via the stock daemon's `set_ap` endpoint. The
Pager carries a second radio — an MT7921U Wi-Fi 6E on internal USB — that is
otherwise only used by the stock daemon's hopping monitor `wlan1mon`. This
feature lets Mark VIII run an Open AP or Evil WPA (WPA2-PSK / WPA3-SAE /
WPA3-OWE) on `radio1` at 5GHz and 6GHz, configured directly via UCI, with a
UI that picks channels by band and enforces WPA3 on 6GHz.
## 2. Hardware findings
Verified against the committed implementation (`server.py`, `views.js`) and the
plan's on-device groundwork:
- **`radio0`** — MT7628, **2.4GHz only**. The stock PineAP daemon owns
`wlan0open`, `wlan0wpa`, `wlan0cli`, `wlan0mon`, `wlan0mgmt`. This feature
does not change that ownership.
- **`radio1`** — MT7921U Wi-Fi 6E on internal USB (`phy1`), capable of
2.4/5/6GHz. The stock daemon owns `wlan1mon`, a daemon-managed hopping
monitor interface: `pineapd.wlan1mon` has `bands='2,5,6'` and `hop='1'`.
- Because `radio1` has a single channel shared by all its virtual interfaces,
a `wlan1` AP and the hopping `wlan1mon` cannot both be active with an
operator-chosen channel — hence the hop pause/resume mechanism (§6).
- UCI state (`wireless.radio1`): stock defaults are `band='5g'`,
`channel='auto'`, `htmode='VHT80'`, `country` set for the device region.
## 3. Band / channel model
`server.py` defines the authoritative mapping (helpers added near
`_uci_wifi_iface`):
| Band | `BAND_*` | Channels | `band_htmode` | `band_radio` |
|---|---|---|---|---|
| 2.4GHz | `BAND_2G = '2.4'` | `114` | `HT20` | `radio0` |
| 5GHz | `BAND_5G = '5'` | `36177` | `VHT80` | `radio1` |
| 6GHz | `BAND_6G = '6'` | `181233` (step 4) | `HE80` | `radio1` |
- `channel_band(ch)` classifies **2.4GHz (114) first, then 5GHz (36177)**, and
only then 6GHz (`1 ≤ ch ≤ 233` and `(ch 1) % 4 == 0`). Because 2.4 and 5GHz
take precedence, **the only reachable 6GHz channels are `181, 185, …, 233`**
(the low 6E channels `1,5,…,177` are swallowed by the 2.4/5GHz ranges). The
UI's 6GHz group therefore offers exactly `181..233 step 4`.
- `CHANNEL_BANDS` mirrors this: `range(1, 15)`, `range(36, 178)`,
`range(181, 234, 4)` — held consistent by tests.
- `DFS_CHANNELS` = `52..64` and `100..144` (step 4). DFS channels are surfaced
to the operator in the UI with a `(DFS)` marker; this feature does not attempt
radar-CAC handling on-device.
- `channel_freq(band, ch)`: 2.4 → `2412 + (ch1)·5`; 5 → `5180 + (ch36)·5`;
6 → `5955 + (ch1)·5`.
- `band_htmode` maps 2.4/5/6 → `HT20` / `VHT80` / `HE80` (written to
`wireless.radio1.htmode`). `band_radio` maps 2.4 → `radio0`, 5/6 → `radio1`.
## 4. API behavior
### 4.1 `h_pineap_wifi_get_ap` (POST `/api/pineap/wifi/get_ap`)
- If `wlan1open` **or** `wlan1wpa` exists in `wireless`, state is read from
`radio1` (`wlan1open`/`wlan1wpa`); otherwise from `radio0`
(`wlan0open`/`wlan0wpa`) — the 2.4GHz response shape is unchanged.
- `open.channel` and `wpa.channel` are reported **per interface**, falling back
to the owning radio's channel when the iface section has no channel option
(regression-tested). `wpa.enctype` is normalized to `psk2` / `sae` / `owe`.
- A new `radio1` object reports the raw `wireless.radio1` state:
`{band, channel, htmode, country}`, with `band` normalized from `2g`/`5g`/`6g`
to `'2.4'`/`'5'`/`'6'` (default `'5'`), and `channel` left as the raw string
(`'auto'` or a channel number) — the UI converts; `'auto'` is not int-coerced.
### 4.2 `h_pineap_wifi_set_ap` (POST `/api/pineap/wifi/set_ap`)
`channel` is now accepted in both `open` and `wpa` payloads. Behavior matrix:
- **2.4GHz channels (114):** exactly today's path — daemon
`PUT /api/settings/wifi/set_ap` for `wlan0open`/`wlan0wpa` followed by
`_apply_open_radio` (which persists `radio0.channel`/`country`). If a
`wlan1*` AP exists it is removed first (`_remove_radio1_ap`, §6) so a 2.4GHz
save tears down a stale radio1 AP.
- **5GHz (36177) / 6GHz (181233):** `_apply_radio1_ap` (below).
- **Mixed request:** a request carrying both a 2.4GHz object and a 5/6GHz
object returns `400 'cannot configure 2.4GHz and radio1 APs in one request'`
(radio1 is one physical radio — one band/channel per request).
- **Disable:** a radio1 request with no active 5/6GHz object (or a 2.4GHz save)
runs `_remove_radio1_ap()` + `wifi reload` and returns `200`.
`_apply_radio1_ap(openap, wpa)` (one of the two is active):
1. Validates the band — a radio1 AP requires a 5GHz or 6GHz channel
(`ValueError` → HTTP 400).
2. **6GHz requires WPA3:** when the active AP is a WPA AP on 6GHz, `enctype`
must be `sae` or `owe`; `psk2` is rejected with HTTP 400
(`'6GHz requires WPA3 (sae or owe)'`). An **open** 6GHz AP is accepted by
the backend, but the UI warns that real clients generally won't associate to
an open 6GHz network.
3. Deletes any existing `wlan1open`/`wlan1wpa` (idempotent), then writes UCI:
- `wireless.radio1.band` = `5g`/`6g`, `wireless.radio1.channel`,
`wireless.radio1.htmode` (`band_htmode`), `wireless.radio1.country`
(when supplied);
- a `wifi-iface` section `wlan1open` (encryption `none`, optional BSSID) or
`wlan1wpa` (`encryption` + `key`) on `device=radio1`, `mode=ap`, with the
interface-level `channel`/`hidden`/`ssid`.
4. `uci commit wireless`, `_pause_hop()` (§6), then `wifi reload`.
## 5. Coexistence rules — "don't break stock"
- **Mark VIII owns:** `wireless.wlan1open`, `wireless.wlan1wpa`, and
`wireless.radio1.{channel,band,htmode,country}`. These are new sections /
values it creates and tears down.
- **Stock owns (never modified by Mark VIII):** `wireless.wlan0open`,
`wlan0wpa`, `wlan0mgmt`, `wlan0cli`, `wlan0mon`, `wireless.wlan1mon`, and all
`pineapd.*` UCI values (bands configuration included).
- **The only stock-owned value this feature writes is
`pineapd.wlan1mon.hop`** — and it is always restored to its prior value
(`_resume_hop` sets it back to `1` only if it was `0`; `_pause_hop` sets it
to `0` only if it was not already `0`).
- The 2.4GHz daemon path (`PUT /api/settings/wifi/set_ap` for `wlan0open` /
`wlan0wpa`) is byte-for-byte unchanged.
- **Known risk (accepted):** the stock daemon's `set_ap`/pager-UI writes may
rewrite `wireless` wholesale and drop the `wlan1*` sections. Mitigation is
UCI-commit persistence, hop restore on disable, and on-device verification
(§9). If clobbering is observed, the deferred fix is a reconcile-on-load step
in `get_ap` that re-applies a saved radio1 AP from `PINEAP_STATE_FILE`.
## 6. Hop pause / resume
`wlan1mon` is the stock daemon's channel-hopping monitor. With a radio1 AP
active, hopping would fight the AP's fixed channel, so it is paused while the
AP is enabled:
- `_read_hop()` reads the value via `uci get pineapd.wlan1mon.hop` (a leaf
read — not `_uci_wifi_iface`, which forces the `wireless.` prefix).
- `_pause_hop()`: if `hop != '0'`, set `pineapd.wlan1mon.hop=0`, `uci commit
pineapd`, reload `/etc/init.d/pineapd`.
- `_resume_hop()`: if `hop == '0'`, set it back to `1`, commit, reload.
- `_apply_radio1_ap` calls `_pause_hop()` before `wifi reload`;
`_remove_radio1_ap` calls `_resume_hop()` after resetting
`radio1.channel=auto` / `radio1.band=5g`. Every code path that pauses hopping
also restores it.
## 7. Frontend (`www/js/views.js`)
- `BAND_GROUPS` drives the channel pickers shared by the Open AP and Evil WPA
views: a `2.4 GHz` optgroup (111), a `5 GHz` optgroup (36165, DFS channels
`52..64` / `100..144` labelled `(DFS)`), and a `6 GHz (WPA3/OWE only)`
optgroup (`181..233` step 4).
- `chanFreq`/`chanLabel` render `Channel N (… MHz)` (+` (DFS)`),
`chanSelect` builds the optgroups and restores a stored value when in range,
`bandOfChannel` mirrors `channel_band`.
- **Open AP:** channel select + a hint that appears on 6GHz ("…most devices
will not associate to an open 6 GHz network.").
- **Evil WPA:** a channel select added to the config card; selecting a 6GHz
channel disables the `psk2` option and switches to `sae`, with a
"6 GHz requires WPA3 (SAE or OWE)." hint. Save payloads for both views
include `channel` (Open AP also `country`).
## 8. Automated verification
- `tests/test_pineap_bands.py` covers the channel/band helpers
(`ChannelBandTest`, `ChannelBandsConsistencyTest`, `ChannelFreqTest`,
`BandAuxTest` incl. DFS marker), `get_ap` (`GetApRadio1Test`,
`GetApRadio1AbsentTest`, `GetApRadioChannelFallbackTest`) and `set_ap`
(`SetApRadio1Test`: 5GHz open writes `radio1` sections + hop pause; 6GHz
WPA3-SAE accepted; 6GHz `psk2` rejected; disable removes the radio1 AP and
restores hop; 2.4GHz still uses the daemon path; 2.4GHz save removes a stale
radio1 AP; mixed 2.4GHz + radio1 rejected).
- **All 14 test modules pass at HEAD (`af5ff80`)**, run per-module in separate
processes per the repo convention (`test_auth`, `test_core`, `test_loot`,
`test_misc`, `test_pineap_bands`, `test_pineap_clients`,
`test_pineap_enterprise`, `test_pineap_modes`, `test_pineap_pool`,
`test_pineap_proxy`, `test_pineap_settings`, `test_recon`, `test_status`,
`test_ws`).
## 9. On-device verification
Run against the user's Pager at `172.16.52.1` (Pineapple Pager 24.10.1). All
checks passed:
- **2.4GHz unchanged:** Open AP save (channel 1) leaves `wlan0open`/`radio0`
intact and `pineapd.wlan1mon` untouched (`bands=2,5,6`, `hop=1`).
- **5GHz Evil WPA (WPA3-SAE, channel 36, VHT80):** `radio1.band=5g`,
`channel=36`, `htmode=VHT80`; `wlan1wpa` (netdev named `wlan1wpa` via
`option ifname`) comes up beaconing `Test5G` / WPA3 SAE (CCMP);
`pineapd.wlan1mon.hop=0`. `get_ap` reports `wpa.enabled=true` (after the
`disabled=0` fix).
- **Stock Pager UI coexistence:** the stock daemon's own `set_ap` (what the
pager UI uses to change the 2.4GHz Evil WPA) tears down the radio1 AP
netdev; the `wlan1wpa` UCI section survives and `get_ap` self-heals it with a
`wifi reload` (`/sys/class/net/<iface>` missing check). Under rapid reload
churn the `mt7921u` driver can transiently return EBUSY; a later reload
succeeds. The pager UI itself is unaffected.
- **Handshake capture:** `Examine` on channel 36 returns success; handshake
logging (`loghandshake`/`logpartialhandshake`) confirmed on. A live WPA
handshake file requires a physical client (not exercised).
- **Reboot persistence:** `wlan1wpa` UCI, `disabled=0`, `hop=0`, the procd
Mark VIII service, and the AP itself all survive reboot.
- **Disable path:** removing the 5GHz AP deletes `wlan1wpa`, resets
`radio1.channel=auto`/`band=5g`, restores `hop=1`; `wlan1mon` hopping
resumes (observed 6GHz ch13 → ch221 in 30s).
- **5GHz Open AP:** `wlan1open` (channel 44, open) brings up `Test5GOpen`
with `hop=0`.
- **6GHz AP:** `radio1.band=6g`, `htmode=HE80`, WPA3 SAE on channel 181
(6.855 GHz) comes up.
- **Cleanup:** disable restores the 2.4GHz baseline (`pager-open`, channel 1,
`hop=1`).
Known follow-ups: the Clients tab lists only `wlan0*` interfaces, so 5GHz AP
clients are not yet shown; live-client handshake capture is untested without a
physical client.
File diff suppressed because it is too large Load Diff
@@ -354,6 +354,69 @@ html.dark .recon-row-selected td { background: #565656; }
th.recon-sorted { color: var(--primary); }
.recon-per { width: auto; }
/* ---- Recon supercharge: dBm bars, chips, pills ---- */
.recon-dbm-cell { display: inline-flex; align-items: center; gap: 8px; white-space: nowrap; }
.recon-dbm-bar { display: inline-block; width: 46px; height: 6px; border-radius: 3px; background: var(--surface-alt); overflow: hidden; vertical-align: middle; }
html.dark .recon-dbm-bar { background: #333; }
.recon-dbm-fill { display: block; height: 100%; border-radius: 3px; }
.recon-dbm-val { font-variant-numeric: tabular-nums; }
.recon-chips-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin: 4px 0 10px; }
.recon-chips-label { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin: 0 2px 0 8px; }
.recon-chips-label:first-child { margin-left: 0; }
.recon-chip { border: 1px solid var(--border); background: transparent; color: var(--muted); border-radius: 12px; padding: 3px 11px; font-size: 12px; cursor: pointer; }
.recon-chip:hover { color: var(--text); border-color: var(--primary); }
.recon-chip.active { background: var(--primary); border-color: var(--primary); color: #fff; }
.recon-pill { border: 1px solid var(--border); background: transparent; color: var(--muted); border-radius: 12px; padding: 3px 11px; font-size: 12px; cursor: pointer; display: inline-flex; align-items: center; gap: 5px; max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.recon-pill:hover { color: var(--text); border-color: var(--primary); }
.recon-pill:disabled { opacity: .5; cursor: default; }
.recon-pill.on { background: #e8f5e9; border-color: #a5d6a7; color: #2e7d32; }
html.dark .recon-pill.on { background: #1b3a23; color: #81c784; }
/* ---- Survey view ---- */
.survey-live-bar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 10px; }
.survey-live-status { font-size: 13px; color: var(--text); }
.survey-dur { width: auto; }
.survey-pill { border: 1px solid var(--border); background: transparent; color: var(--muted); border-radius: 12px; padding: 4px 12px; font-size: 12px; cursor: pointer; white-space: nowrap; }
.survey-pill:hover { color: var(--text); border-color: var(--primary); }
.survey-pill.on { background: #e8f5e9; border-color: #a5d6a7; color: #2e7d32; }
html.dark .survey-pill.on { background: #1b3a23; color: #81c784; }
.survey-wigle { font-size: 13px; color: var(--text); margin: 0; }
.survey-rec-card { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; }
.survey-rec-name { flex: 1 1 220px; max-width: 340px; }
.survey-rec-status { font-size: 12px; color: var(--muted); }
.survey-rec-status.on { color: #e53935; font-weight: 600; }
.survey-filter-row { margin-top: 10px; }
.survey-search { width: auto; }
.survey-chan-box { margin-top: 4px; }
.survey-chan-band { font-size: 12px; font-weight: 600; color: var(--primary); margin: 10px 0 4px; }
.survey-chan-row { display: flex; align-items: center; gap: 10px; margin: 3px 0; }
.survey-chan-label { width: 46px; font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; }
.survey-chan-track { flex: 1; height: 14px; border-radius: 3px; background: var(--surface-alt); overflow: hidden; }
html.dark .survey-chan-track { background: #333; }
.survey-chan-fill { display: block; height: 100%; background: var(--primary); border-radius: 3px; }
.survey-chan-count { width: 30px; font-size: 12px; color: var(--muted); text-align: right; font-variant-numeric: tabular-nums; }
.survey-cmp-hint { font-size: 12px; color: var(--muted); margin: -4px 0 8px; }
.survey-cmp-legend { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 6px; }
.survey-cmp-legend-item { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; }
.survey-cmp-swatch { width: 10px; height: 10px; border-radius: 50%; flex: none; }
.survey-cmp-sig { color: var(--muted); font-variant-numeric: tabular-nums; }
.survey-cmp-check { display: inline-flex; }
.survey-cmp-check input { width: auto; }
.survey-discover { margin-top: 10px; }
.survey-discover-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.survey-discover-title { font-size: 20px; font-weight: 500; }
.survey-discover-readout { font-size: 44px; font-weight: 700; line-height: 1.1; font-variant-numeric: tabular-nums; }
.survey-discover-sub { color: var(--muted); font-size: 13px; margin: 2px 0 8px; word-break: break-all; }
/* ---- Reports view ---- */
.survey-detail-hint { font-size: 12px; color: var(--muted); margin-top: 10px; }
.survey-detail-row { padding: 8px 10px; border: 1px solid var(--border); border-radius: 3px; margin-top: 6px; cursor: pointer; font-size: 13px; }
.survey-detail-row:hover { border-color: var(--primary); }
.survey-detail-row.open { border-color: var(--primary); background: var(--surface-alt); }
.survey-detail-body { padding: 8px 4px; }
.survey-detail-gps { font-size: 12px; color: var(--muted); margin: 6px 0; }
.survey-wigle-warn { color: #ef6c00; font-size: 12px; }
/* ---- Mark VII handshakes table + settings dialog ---- */
.hs-cell-center { text-align: center; }
.hs-ok, .hs-bad, .hs-na { display: inline-flex; vertical-align: middle; }
@@ -396,6 +396,8 @@ const App = (() => {
'#/pineap/clients': 'pineap_clients',
'#/pineap/filtering': 'pineap_filtering',
'#/recon': 'recon',
'#/recon/survey': 'recon_survey',
'#/recon/reports': 'recon_reports',
'#/recon/handshakes': 'recon_handshakes',
'#/logging': 'logging',
'#/logging/system': 'logging_system',
@@ -10,7 +10,10 @@ const MiniChart = (() => {
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const w = canvas.clientWidth, h = 140;
ctx.clearRect(0, 0, w, h);
const max = Math.max(o.max || 10, ...series.map((s) => Math.max(...s.points, 0)), 1);
const oMin = o.min == null ? 0 : o.min;
const max = Math.max(o.max || 10, ...series.map((s) => Math.max(...s.points, oMin)), oMin + 1);
const min = Math.min(oMin, ...series.map((s) => Math.min(...s.points, oMin)));
const span = Math.max(max - min, 1);
const pad = 8;
ctx.strokeStyle = o.grid || '#e0e0e0';
ctx.lineWidth = 1;
@@ -28,14 +31,14 @@ const MiniChart = (() => {
pts.forEach((v, i) => {
if (v == null) { started = false; return; }
const x = pad + (w - pad * 2) * i / Math.max(pts.length - 1, 1);
const y = h - pad - (h - pad * 2) * (v / max);
const y = h - pad - (h - pad * 2) * ((v - min) / span);
if (!started) { ctx.moveTo(x, y); started = true; } else ctx.lineTo(x, y);
});
ctx.stroke();
const last = pts[pts.length - 1];
if (last != null) {
const x = pad + (w - pad * 2) * (pts.length - 1) / Math.max(pts.length - 1, 1);
const y = h - pad - (h - pad * 2) * (last / max);
const y = h - pad - (h - pad * 2) * ((last - min) / span);
ctx.fillStyle = s.color || '#1976d2';
ctx.beginPath(); ctx.arc(x, y, 3, 0, Math.PI * 2); ctx.fill();
}
@@ -30,5 +30,11 @@ window.PineappleIcons = {
help: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12,2A10,10 0 1,0 22,12A10,10 0 0,0 12,2M13,19H11V17H13V19M15.07,11.25L14.17,12.17C13.45,12.9 13,13.5 13,15H11V14.5C11,13.4 11.45,12.4 12.17,11.67L13.41,10.41C13.78,10.05 14,9.55 14,9A2,2 0 0,0 10,9H8A4,4 0 0,1 16,9C16,9.88 15.64,10.68 15.07,11.25Z"/></svg>',
update: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21,10.12H14.22L16.96,7.3C14.23,4.6 9.81,4.5 7.08,7.2A6.85,6.85 0 0,0 7.08,17C9.81,19.7 14.23,19.7 16.96,17C18.32,15.65 19,14.08 19,12.1H21C21,14.08 20.18,16.4 18.36,18.2C14.85,21.7 9.15,21.7 5.64,18.2C2.14,14.72 2.14,9.05 5.64,5.57C9.15,2.08 14.85,2.08 18.36,5.57L21,2.88V10.12M12.5,8V12.25L16,14.33L15.28,15.54L11,13V8H12.5Z"/></svg>',
logout: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M14.08,15.59L16.67,13H7V11H16.67L14.08,8.41L15.5,7L20.5,12L15.5,17L14.08,15.59M5,3H13A2,2 0 0,1 15,5V8H13V5H5V19H13V16H15V19A2,2 0 0,1 13,21H5A2,2 0 0,1 3,19V5A2,2 0 0,1 5,3Z"/></svg>',
reboot: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M13,3H11V13H13V3M17.83,5.17L16.42,6.58A7,7 0 1,1 7.58,6.58L6.17,5.17A9,9 0 1,0 17.83,5.17Z"/></svg>'
reboot: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M13,3H11V13H13V3M17.83,5.17L16.42,6.58A7,7 0 1,1 7.58,6.58L6.17,5.17A9,9 0 1,0 17.83,5.17Z"/></svg>',
table_chart: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12,20H9V4H12V20M19,20H16V10H19V20M5,20H2V14H5V20Z"/></svg>',
description: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M14,2H6C4.9,2 4,2.9 4,4V20C4,21.1 4.9,22 6,22H18C19.1,22 20,21.1 20,20V8L14,2M18,20H6V4H13V9H18V20M16,18H8V16H16V18M16,14H8V12H16V14Z"/></svg>',
record: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z"/></svg>',
place: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12,2A7,7 0 0,0 5,9C5,14.25 12,22 12,22C12,22 19,14.25 19,9A7,7 0 0,0 12,2M12,11.5A2.5,2.5 0 0,1 9.5,9A2.5,2.5 0 0,1 12,6.5A2.5,2.5 0 0,1 14.5,9A2.5,2.5 0 0,1 12,11.5Z"/></svg>',
play_arrow: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8,5.14V19.14L19,12.14L8,5.14Z"/></svg>',
stop: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M18,18H6V6H18V18Z"/></svg>'
};
@@ -28,7 +28,14 @@ const table = (columns, rows, rowAttrs) => {
const tb = h('tbody');
(rows || []).forEach((r) => {
const trr = h('tr', rowAttrs ? rowAttrs(r) : {});
columns.forEach((c) => trr.appendChild(h('td', { text: c.render ? c.render(r) : r[c.key] })));
columns.forEach((c) => {
const v = c.render ? c.render(r) : r[c.key];
const td = h('td');
if (v == null) td.textContent = '';
else if (typeof v === 'string' || typeof v === 'number') td.textContent = String(v);
else td.appendChild(v);
trr.appendChild(td);
});
tb.appendChild(trr);
});
t.appendChild(tb);
@@ -41,6 +48,16 @@ const fmtTime = (ts) => {
return d.toLocaleString();
};
const fmtShortTime = (ts) => {
if (!ts) return '--';
const d = new Date(ts * 1000);
const now = new Date();
const sameDay = d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate();
const hm = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
if (sameDay) return hm;
return (d.getMonth() + 1) + '/' + d.getDate() + ' ' + hm;
};
const fmtDur = (secs) => {
if (secs == null) return '--';
const d = Math.floor(secs / 86400), hh = Math.floor((secs % 86400) / 3600),
@@ -410,10 +427,53 @@ views.pineap = (root) => {
return { destroy: () => clearInterval(iv) };
};
const OPEN_CHANNELS = Array.from({ length: 11 }, (_, i) => {
const c = i + 1;
return [c, 'Channel ' + c + ' (' + (2412 + (c - 1) * 5) + ' MHz)'];
const BAND_GROUPS = [
{ band: '2.4', label: '2.4 GHz', dfs: false,
channels: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11] },
{ band: '5', label: '5 GHz', dfs: true,
channels: [36, 40, 44, 48, 52, 56, 60, 64,
100, 104, 108, 112, 116, 120, 124, 128, 132, 136, 140, 144,
149, 153, 157, 161, 165] },
{ band: '6', label: '6 GHz (WPA3/OWE only)', dfs: false,
channels: Array.from({ length: 14 }, (_, i) => 181 + i * 4) }
];
const DFS_SET = new Set([52, 56, 60, 64, 100, 104, 108, 112, 116, 120,
124, 128, 132, 136, 140, 144]);
function chanFreq(band, ch) {
if (band === '2.4') return 2412 + (ch - 1) * 5;
if (band === '5') return 5180 + (ch - 36) * 5;
return 5955 + (ch - 1) * 5; // 6 GHz
}
function chanLabel(band, ch) {
return 'Channel ' + ch + ' (' + chanFreq(band, ch) + ' MHz)'
+ (band === '5' && DFS_SET.has(ch) ? ' (DFS)' : '');
}
function chanSelect(sel, value) {
BAND_GROUPS.forEach((g) => {
const og = h('optgroup', { label: g.label });
g.channels.forEach((ch) => {
og.appendChild(h('option', { value: ch, text: chanLabel(g.band, ch) }));
});
sel.appendChild(og);
});
if (value != null) {
const opts = Array.prototype.slice.call(sel.options);
const hit = opts.find((o) => Number(o.value) === Number(value));
if (hit) sel.value = hit.value;
}
return sel;
}
function bandOfChannel(ch) {
if (ch == null) return '2.4';
ch = Number(ch);
if (ch >= 1 && ch <= 14) return '2.4';
if (ch >= 36 && ch <= 177) return '5';
return '6';
}
const OPEN_COUNTRIES = [
['US', 'United States'], ['DZ', 'Algeria'], ['AR', 'Argentina'], ['AU', 'Australia'],
['AT', 'Austria'], ['BH', 'Bahrain'], ['BM', 'Bermuda'], ['BO', 'Bolivia'], ['BR', 'Brazil'],
@@ -448,7 +508,13 @@ views.pineap_open = (root) => {
const ssidIn = h('input', { id: 'oa-ssid' });
const bssidIn = h('input', { id: 'oa-bssid' });
const chSel = h('select', { id: 'oa-channel' });
OPEN_CHANNELS.forEach(([v, l]) => chSel.appendChild(h('option', { value: v, text: l })));
chanSelect(chSel, null);
const bandHint = h('div', { class: 'muted', style: 'font-size:12px;margin-top:4px' });
function applyOaHint() {
const b = bandOfChannel(chSel.value);
bandHint.textContent = b === '6' ? '6 GHz open APs require WPA3/OWE on real clients — most devices will not associate to an open 6 GHz network.' : '';
}
chSel.addEventListener('change', applyOaHint);
const coSel = h('select', { id: 'oa-country' });
OPEN_COUNTRIES.forEach(([v, l]) => coSel.appendChild(h('option', { value: v, text: l })));
const hiddenCb = h('input', { type: 'checkbox', id: 'oa-hidden' });
@@ -464,7 +530,7 @@ views.pineap_open = (root) => {
h('div', {}, h('label', {}, 'Open SSID', ssidIn)),
h('div', {}, h('label', {}, 'BSSID', bssidIn))));
card.appendChild(h('div', { class: 'row' },
h('div', {}, h('label', {}, 'Channel', chSel)),
h('div', {}, h('label', {}, 'Channel', chSel), bandHint),
h('div', {}, h('label', {}, 'Current Country', coSel))));
card.appendChild(h('div', { class: 'row' },
h('div', {}, h('label', { class: 'switch' }, hiddenCb, h('span', { class: 'track' }), ' Hidden')),
@@ -579,7 +645,13 @@ views.pineap_open = (root) => {
const open = a.open || {};
ssidIn.value = open.ssid || '';
bssidIn.value = open.bssid || '';
if (open.channel != null) chSel.value = String(open.channel);
if (open.channel != null) {
const opts = Array.prototype.slice.call(chSel.options);
if (opts.some((o) => Number(o.value) === Number(open.channel))) {
chSel.value = String(open.channel);
}
}
applyOaHint();
if (open.country) coSel.value = open.country;
hiddenCb.checked = !!open.hidden;
state.enabledLoaded = !!(a.open);
@@ -614,16 +686,29 @@ views.pineap_evilwpa = (root) => {
EVIL_ENC.forEach(([v, l]) => encSel.appendChild(h('option', { value: v, text: l })));
const hiddenCb = h('input', { type: 'checkbox', id: 'ew-hidden' });
const enabledCb = h('input', { type: 'checkbox', id: 'ew-enabled' });
const wpaChan = h('select', { id: 'ew-channel' });
chanSelect(wpaChan, null);
const wpaHint = h('div', { class: 'muted', style: 'font-size:12px;margin-top:4px' });
function applyWpaHint() {
const six = bandOfChannel(wpaChan.value) === '6';
Array.prototype.forEach.call(encSel.options, (o) => { o.disabled = six && o.value === 'psk2'; });
if (six && encSel.value === 'psk2') encSel.value = 'sae';
wpaHint.textContent = six ? '6 GHz requires WPA3 (SAE or OWE).' : '';
}
wpaChan.addEventListener('change', applyWpaHint);
cfg.appendChild(h('label', {}, 'SSID', ssidIn));
cfg.appendChild(h('label', {}, 'Passphrase', pskIn));
cfg.appendChild(h('label', {}, 'Encryption', encSel));
cfg.appendChild(h('label', {}, 'Channel', wpaChan));
cfg.appendChild(wpaHint);
cfg.appendChild(h('label', { class: 'switch' }, hiddenCb, h('span', { class: 'track' }), 'Hidden'));
cfg.appendChild(h('label', { class: 'switch' }, enabledCb, h('span', { class: 'track' }), 'Enabled'));
cfg.appendChild(h('div', { class: 'row' },
h('div', {}, btn('Save', () => {
PagerAPI.post('/api/pineap/wifi/set_ap', {
wpa: { ssid: ssidIn.value, passphrase: pskIn.value, enctype: encSel.value,
hidden: hiddenCb.checked, enabled: enabledCb.checked }
hidden: hiddenCb.checked, enabled: enabledCb.checked,
channel: wpaChan.value ? parseInt(wpaChan.value, 10) : 1 }
}).then(() => { App.toast('Evil WPA saved'); load(); }).catch(() => App.toast('Failed', 'error'));
})),
h('div', { class: 'muted', style: 'align-self:center;font-size:12px' }, 'Applying reconfigures the radio — you may be disconnected briefly.')));
@@ -675,6 +760,13 @@ views.pineap_evilwpa = (root) => {
}
hiddenCb.checked = !!w.hidden;
enabledCb.checked = !!w.enabled;
if (w.channel != null) {
const opts = Array.prototype.slice.call(wpaChan.options);
if (opts.some((o) => Number(o.value) === Number(w.channel))) {
wpaChan.value = String(w.channel);
}
}
applyWpaHint();
}).catch(() => {});
PagerAPI.get('/api/pineap/get_config').then((r) => {
const p = r.data || {};
@@ -969,6 +1061,8 @@ views.pineap_filtering = (root) => {
const RECON_TABS = [
{ label: 'Scanning', hash: '#/recon' },
{ label: 'Survey', hash: '#/recon/survey' },
{ label: 'Reports', hash: '#/recon/reports' },
{ label: 'Handshakes', hash: '#/recon/handshakes' }
];
@@ -979,9 +1073,13 @@ const RECON_CHANNEL_COLORS = ['#FC68AC','#4545FF','#19DE8F','#FF294A','#23E8DB',
const RECON_AP_COLS = [
{ key: 'ssid', label: 'SSID', render: (a) => a.ssid || '(hidden)' },
{ key: 'bssid', label: 'MAC', render: (a) => a.bssid || '--' },
{ key: 'band', label: 'Band', render: (a) => a.band || '--' },
{ key: 'channel', label: 'Channel', render: (a) => a.channel == null ? '--' : a.channel },
{ key: 'signal', label: 'Signal', render: (a) => a.signal == null ? '--' : a.signal + ' dBm' },
{ key: 'signal', label: 'Signal', render: (a) => a.signal == null ? '--' : dbmCell(a.signal) },
{ key: 'vendor', label: 'Vendor', render: (a) => a.vendor || '--' },
{ key: 'encryption', label: 'Encryption', render: (a) => a.encryption || '--' },
{ key: 'first_seen', label: 'First Seen', render: (a) => fmtShortTime(a.first_seen) },
{ key: 'last_seen', label: 'Last Seen', render: (a) => fmtShortTime(a.last_seen) },
{ key: 'hidden', label: 'Hidden', render: (a) => a.hidden ? 'Yes' : 'No' }
];
const RECON_CLIENT_COLS = [
@@ -991,9 +1089,38 @@ const RECON_CLIENT_COLS = [
{ key: 'packets', label: 'Packets', render: (c) => c.packets || 0 }
];
function reconBandOf(freq) {
if (freq == null) return null;
if (freq >= 2400 && freq < 2500) return '2.4';
if (freq >= 4900 && freq < 5900) return '5';
if (freq >= 5900 && freq < 7125) return '6';
return null;
}
function reconSigColor(dbm) {
if (dbm == null) return '#9e9e9e';
if (dbm >= -50) return '#2e7d32';
if (dbm >= -67) return '#f9a825';
if (dbm >= -80) return '#ef6c00';
return '#c62828';
}
function dbmCell(dbm) {
const pct = dbm == null ? 0 : Math.max(0, Math.min(100, ((dbm + 100) / 60) * 100));
const color = reconSigColor(dbm);
const bar = h('span', { class: 'recon-dbm-bar' },
h('span', { class: 'recon-dbm-fill', style: 'width:' + pct.toFixed(0) + '%;background:' + color }));
return h('span', { class: 'recon-dbm-cell' },
bar, h('span', { class: 'recon-dbm-val', style: 'color:' + color, text: (dbm == null ? '--' : dbm + ' dBm') }));
}
function reconBandLabel(band) {
return band == null ? '--' : band + ' GHz';
}
function reconDefaultCols() {
return {
ap: { ssid: true, bssid: true, channel: true, signal: true, encryption: true, hidden: true },
ap: { ssid: true, bssid: true, band: true, channel: true, signal: true, vendor: true, encryption: true, first_seen: true, last_seen: true, hidden: true },
client: { mac: true, signal: true, freq: true, packets: true }
};
}
@@ -1029,7 +1156,8 @@ function reconPer(key, def) {
}
function reconCmp(a, b, col, dir) {
const numeric = col.key === 'channel' || col.key === 'signal' || col.key === 'freq' || col.key === 'packets';
const numeric = col.key === 'channel' || col.key === 'signal' || col.key === 'freq' || col.key === 'packets' ||
col.key === 'first_seen' || col.key === 'last_seen';
if (numeric) {
const x = a[col.key] == null ? -Infinity : Number(a[col.key]);
const y = b[col.key] == null ? -Infinity : Number(b[col.key]);
@@ -1049,7 +1177,8 @@ views.recon = (root) => {
apPer: reconPer('ap', 10), clientPer: reconPer('client', 10),
apSort: null, clientSort: null, focusAp: null, autoFollow: false,
scanActive: false, detailLoading: false, detailLoadingId: null,
detailQueued: false, detailId: null };
detailQueued: false, detailId: null,
apBand: 'all', apEnc: 'all', gps: null, wigle: null };
const cols = reconLoadCols();
// ---- title cards ----
@@ -1117,9 +1246,18 @@ views.recon = (root) => {
loadDetail();
});
psRow.appendChild(sel);
psRow.appendChild(iconBtn('file_download', 'Download scan JSON', () => {
const dlJson = iconBtn('file_download', 'Download scan JSON', () => {
if (state.selected != null) window.location = App.apiBase + '/api/recon/scans/' + state.selected + '/download/json';
}));
});
const dlCsv = iconBtn('table_chart', 'Download scan CSV', () => {
if (state.selected != null) window.location = App.apiBase + '/api/recon/scans/' + state.selected + '/download/csv';
});
const dlHtml = iconBtn('description', 'Download scan HTML report', () => {
if (state.selected != null) window.location = App.apiBase + '/api/recon/scans/' + state.selected + '/download/html';
});
psRow.appendChild(dlJson);
psRow.appendChild(dlCsv);
psRow.appendChild(dlHtml);
psRow.appendChild(iconBtn('delete', 'Delete scan', () => {
if (state.selected == null) return;
if (!confirm('Delete scan #' + state.selected + '? This cannot be undone.')) return;
@@ -1141,7 +1279,47 @@ views.recon = (root) => {
durSel.addEventListener('change', () => localStorage.setItem('pw_scan_duration', durSel.value));
scanBar.appendChild(durSel);
scanBar.appendChild(h('span', { class: 'toolbar-spacer' }));
const gpsPill = h('button', { class: 'recon-pill recon-pill-gps', title: 'GPS status (click to auto-bind the Glytch GPS module)' });
const wiglePill = h('button', { class: 'recon-pill recon-pill-wigle', title: 'WiGLE logging (click to toggle)' });
scanBar.appendChild(gpsPill);
scanBar.appendChild(wiglePill);
scanBar.appendChild(iconBtn('settings', 'Recon settings', () => sidebar.classList.toggle('hidden')));
function renderPills() {
const g = state.gps || {};
if (g.lock) {
gpsPill.className = 'recon-pill recon-pill-gps on';
gpsPill.textContent = 'GPS ' + (g.lat != null ? Number(g.lat).toFixed(5) : '--') + ', ' + (g.lon != null ? Number(g.lon).toFixed(5) : '--') + (g.satellites ? ' · ' + g.satellites + ' sats' : '');
} else if (g.present) {
gpsPill.className = 'recon-pill recon-pill-gps';
gpsPill.textContent = 'GPS no fix';
} else if (g.gpsd_running) {
gpsPill.className = 'recon-pill recon-pill-gps';
gpsPill.textContent = 'GPS no device';
} else {
gpsPill.className = 'recon-pill recon-pill-gps';
gpsPill.textContent = 'GPS off';
}
wiglePill.className = 'recon-pill recon-pill-wigle ' + (state.wigle ? 'on' : '');
wiglePill.textContent = state.wigle ? 'WiGLE on' : 'WiGLE off';
}
gpsPill.addEventListener('click', () => {
PagerAPI.post('/api/recon/gps/configure', {}).then((r) => {
state.gps = r.data;
renderPills();
if (r.data.error) App.toast(r.data.error, 'error');
else if (r.data.lock) App.toast('GPS locked: ' + Number(r.data.lat).toFixed(5) + ', ' + Number(r.data.lon).toFixed(5));
else App.toast((r.data.note || 'GPS bound, waiting for a fix'));
}).catch((err) => App.toast((err && err.message) || 'GPS configure failed', 'error'));
});
wiglePill.addEventListener('click', () => {
const next = !state.wigle;
wiglePill.disabled = true;
PagerAPI.post('/api/recon/wigle', { enable: next }).then((r) => {
state.wigle = next;
App.toast(next ? ('WiGLE logging started' + (r.data && r.data.filename ? ' → ' + r.data.filename : '')) : 'WiGLE logging stopped');
}).catch((err) => App.toast((err && err.message) || 'WiGLE toggle failed', 'error'))
.finally(() => { wiglePill.disabled = false; renderPills(); });
});
let pendingScan = false;
scanToggle.addEventListener('change', () => {
if (pendingScan) { scanToggle.checked = !scanToggle.checked; return; }
@@ -1177,8 +1355,10 @@ views.recon = (root) => {
h('span', { class: 'recon-settings-title', text: 'Recon Settings' }),
btn('×', () => sidebar.classList.add('hidden'), 'ghost')));
const colDefs = {
ap: [['ssid', 'Show SSID'], ['bssid', 'Show MAC'], ['channel', 'Show Channel'],
['signal', 'Show Signal'], ['encryption', 'Show Encryption'], ['hidden', 'Show Hidden']],
ap: [['ssid', 'Show SSID'], ['bssid', 'Show MAC'], ['band', 'Show Band'],
['channel', 'Show Channel'], ['signal', 'Show Signal'], ['vendor', 'Show Vendor'],
['encryption', 'Show Encryption'], ['first_seen', 'Show First Seen'],
['last_seen', 'Show Last Seen'], ['hidden', 'Show Hidden']],
client: [['mac', 'Show MAC'], ['signal', 'Show Signal'], ['freq', 'Show Frequency'], ['packets', 'Show Packets']]
};
Object.keys(colDefs).forEach((grp) => {
@@ -1273,6 +1453,32 @@ views.recon = (root) => {
const cliCard = h('div', { class: 'section recon-scan-results-card' });
root.appendChild(cliCard);
// ---- band / encryption filter chips ----
const chipRow = h('div', { class: 'recon-chips-row' });
apCard.appendChild(chipRow);
function renderChips() {
chipRow.innerHTML = '';
const groups = [
['Band', 'apBand', [['all', 'All'], ['2.4', '2.4 GHz'], ['5', '5 GHz'], ['6', '6 GHz']]],
['Encryption', 'apEnc', [['all', 'All'], ['Open', 'Open'], ['WEP', 'WEP'], ['WPA', 'WPA'],
['WPA2', 'WPA2'], ['WPA3', 'WPA3'], ['Enterprise', 'Enterprise']]]
];
groups.forEach(([label, key, opts]) => {
chipRow.appendChild(h('span', { class: 'recon-chips-label', text: label }));
opts.forEach(([v, t]) => {
const c = h('button', { class: 'recon-chip' + (state[key] === v ? ' active' : ''), text: t });
c.addEventListener('click', () => {
state[key] = v;
state.apPage = 0;
renderChips();
renderTables();
});
chipRow.appendChild(c);
});
});
}
renderChips();
function buildPaginator(key) {
const mk = (id, icon, title, fn) => {
const b = h('button', { class: 'icon-btn', id: key + '-' + id, title: title });
@@ -1288,12 +1494,20 @@ views.recon = (root) => {
mk('last', 'last_page', 'Last page', () => { state[key + 'Page'] = Math.max(0, reconPageCount(key) - 1); renderTables(); }));
}
function reconPageCount(key) {
function filteredRows(key) {
const d = state.detail || {};
const rows = key === 'ap' ? (d.aps || []) : (d.clients || []);
const colsArr = key === 'ap' ? RECON_AP_COLS : RECON_CLIENT_COLS;
const q = key === 'ap' ? state.apSearch : state.clientSearch;
return Math.max(1, Math.ceil(reconFiltered(rows, q, colsArr).length / state[key + 'Per']));
let out = reconFiltered(rows, key === 'ap' ? state.apSearch : state.clientSearch,
key === 'ap' ? RECON_AP_COLS : RECON_CLIENT_COLS);
if (key === 'ap') {
if (state.apBand !== 'all') out = out.filter((a) => (a.band || '') === state.apBand);
if (state.apEnc !== 'all') out = out.filter((a) => reconEncBucket(a.encryption) === state.apEnc);
}
return out;
}
function reconPageCount(key) {
return Math.max(1, Math.ceil(filteredRows(key).length / state[key + 'Per']));
}
function perSelect(key) {
@@ -1389,8 +1603,8 @@ views.recon = (root) => {
function renderTables() {
const d = state.detail || { aps: [], clients: [], handshakes: [] };
const apF = reconFiltered(d.aps || [], state.apSearch, RECON_AP_COLS);
const cliF = reconFiltered(d.clients || [], state.clientSearch, RECON_CLIENT_COLS);
const apF = filteredRows('ap');
const cliF = filteredRows('client');
renderTable(apBody, 'ap', sortRows(apF, 'ap', RECON_AP_COLS), RECON_AP_COLS, 'No access points in this scan.');
renderTable(cliBody, 'client', sortRows(cliF, 'client', RECON_CLIENT_COLS), RECON_CLIENT_COLS, 'No clients in this scan.');
}
@@ -1404,7 +1618,7 @@ views.recon = (root) => {
MiniChart.doughnut(land, [
{ label: 'Access Points', value: n, color: RECON_LANDSCAPE_COLORS[0] },
{ label: 'Clients', value: c, color: RECON_LANDSCAPE_COLORS[1] },
{ label: 'Unassociated', value: 0, color: RECON_LANDSCAPE_COLORS[2] }
{ label: 'Unassociated', value: d.unassociated || 0, color: RECON_LANDSCAPE_COLORS[2] }
], { legend: true, height: 130 });
land.classList.remove('hidden');
landEmpty.classList.add('hidden');
@@ -1520,6 +1734,11 @@ views.recon = (root) => {
App.toast('Scan complete');
}
}).catch(() => {});
PagerAPI.get('/api/recon/gps').then((r) => {
state.gps = r.data;
state.wigle = !!(r.data || {}).wigle;
renderPills();
}).catch(() => {});
}
PagerAPI.get('/api/pineap/get_config').then((r) => {
@@ -1535,6 +1754,506 @@ views.recon = (root) => {
return { destroy: () => clearInterval(pollIv) };
};
const SURVEY_COMPARE_COLORS = ['#2ecc71', '#2980b9', '#8e44ad', '#e67e22', '#c0392b', '#16a085'];
const SURVEY_MAX_HISTORY = 90;
views.recon_survey = (root) => {
root.appendChild(h('h1', { class: 'page-title', text: 'Recon' }));
tabBar(root, RECON_TABS, '#/recon/survey');
const state = {
live: { scan: null, aps: [], clients: [], handshakes: [], unassociated: 0, gps: {}, recording: null },
band: 'all', search: '',
history: {}, compare: [], discover: null,
gps: {}, wigle: false,
recording: null, recBusy: false, scanBusy: false, updated: 0
};
// ---- live header bar ----
const liveBar = h('div', { class: 'section survey-live-bar' });
root.appendChild(liveBar);
const liveStatus = h('span', { class: 'survey-live-status', text: 'Waiting for recon data…' });
liveBar.appendChild(liveStatus);
liveBar.appendChild(h('span', { class: 'toolbar-spacer' }));
const durSel = h('select', { class: 'sel survey-dur' });
[[30, '30s'], [60, '1m'], [120, '2m'], [300, '5m'], [600, '10m']]
.forEach(([v, t]) => durSel.appendChild(h('option', { value: String(v), text: t })));
durSel.value = localStorage.getItem('pw_scan_duration') || '30';
durSel.addEventListener('change', () => localStorage.setItem('pw_scan_duration', durSel.value));
const scanBtn = btn('Scan now', () => {
if (state.scanBusy) return;
state.scanBusy = true;
scanBtn.disabled = true;
PagerAPI.post('/api/recon/start', { scan_time: parseInt(durSel.value, 10) })
.then(() => App.toast('Timed scan started — live view will refresh'))
.catch((err) => App.toast((err && err.message) || 'Scan start failed', 'error'))
.finally(() => { state.scanBusy = false; scanBtn.disabled = false; });
});
liveBar.appendChild(durSel);
liveBar.appendChild(scanBtn);
const gpsPill = h('button', { class: 'survey-pill', title: 'GPS status (click to auto-bind the Glytch GPS module)' });
gpsPill.addEventListener('click', () => {
PagerAPI.post('/api/recon/gps/configure', {}).then((r) => {
state.gps = r.data || {};
if (r.data && r.data.error) App.toast(r.data.error, 'error');
else if (state.gps.lock) App.toast('GPS locked');
else App.toast((r.data && r.data.note) || 'GPS bound, waiting for a fix');
renderPills();
}).catch((err) => App.toast((err && err.message) || 'GPS configure failed', 'error'));
});
liveBar.appendChild(gpsPill);
const wigleCb = h('input', { type: 'checkbox' });
const wigleSwitch = h('label', { class: 'switch survey-wigle' }, wigleCb, h('span', { class: 'track' }), ' WiGLE');
wigleCb.addEventListener('change', () => {
wigleCb.disabled = true;
PagerAPI.post('/api/recon/wigle', { enable: wigleCb.checked })
.then((r) => {
state.wigle = wigleCb.checked;
App.toast(state.wigle ? ('WiGLE logging started' + (r.data && r.data.filename ? ' → ' + r.data.filename : '')) : 'WiGLE logging stopped');
})
.catch((err) => { wigleCb.checked = !wigleCb.checked; App.toast((err && err.message) || 'WiGLE toggle failed', 'error'); })
.finally(() => { wigleCb.disabled = false; });
});
liveBar.appendChild(wigleSwitch);
// ---- recording controls ----
const recCard = h('div', { class: 'section survey-rec-card' });
root.appendChild(recCard);
const recName = h('input', { class: 'survey-rec-name', placeholder: 'Survey name (optional)' });
const recStatus = h('span', { class: 'survey-rec-status', text: 'Not recording' });
const recBtn = btn('Record', () => toggleRecording(), '');
recCard.appendChild(recName);
recCard.appendChild(recBtn);
recCard.appendChild(recStatus);
function toggleRecording() {
if (state.recBusy) return;
state.recBusy = true;
recBtn.disabled = true;
const active = !!(state.recording && state.recording.active);
const req = active
? PagerAPI.post('/api/recon/survey/stop', {})
: PagerAPI.post('/api/recon/survey/start', { name: recName.value });
req.then((r) => {
if (active) {
App.toast('Survey stopped — ' + (r.data.samples || 0) + ' samples saved. See Reports.');
recName.value = '';
} else {
App.toast('Survey recording started');
}
return PagerAPI.get('/api/recon/survey/live');
}).then((r) => {
state.recording = (r.data || {}).recording || null;
renderRecording();
}).catch((err) => App.toast((err && err.message) || 'Survey control failed', 'error'))
.finally(() => { state.recBusy = false; recBtn.disabled = false; });
}
function renderRecording() {
const r = state.recording;
if (r && r.active) {
recBtn.textContent = 'Stop';
recBtn.classList.add('danger');
const mins = Math.max(1, Math.round((Date.now() / 1000 - r.started) / 60));
recStatus.textContent = '● Recording "' + r.name + '" — ' + r.samples + ' samples, ~' + mins + ' min';
recStatus.classList.add('on');
} else {
recBtn.textContent = 'Record';
recBtn.classList.remove('danger');
recStatus.textContent = 'Not recording';
recStatus.classList.remove('on');
}
}
// ---- band filter chips + network search ----
const filterRow = h('div', { class: 'recon-chips-row survey-filter-row' });
root.appendChild(filterRow);
filterRow.appendChild(h('span', { class: 'recon-chips-label', text: 'Band' }));
[['all', 'All'], ['2.4', '2.4 GHz'], ['5', '5 GHz'], ['6', '6 GHz']].forEach(([v, t]) => {
const c = h('button', { class: 'recon-chip' + (state.band === v ? ' active' : ''), text: t });
c.addEventListener('click', () => { state.band = v; renderChips(); renderTable(); });
filterRow.appendChild(c);
});
filterRow.appendChild(h('span', { class: 'recon-chips-label', text: 'Search' }));
const searchIn = h('input', { class: 'recon-search survey-search', placeholder: 'SSID or MAC' });
searchIn.addEventListener('input', () => { state.search = searchIn.value; renderTable(); });
filterRow.appendChild(searchIn);
function renderChips() {
filterRow.querySelectorAll('.recon-chip').forEach((c, i) => {
const vals = ['all', '2.4', '5', '6'];
if (i < vals.length) c.classList.toggle('active', state.band === vals[i]);
});
}
// ---- channel occupancy ----
const chanCard = h('div', { class: 'section' });
chanCard.appendChild(h('h2', { text: 'Channel Occupancy' }));
const chanBox = h('div', { class: 'survey-chan-box' });
chanCard.appendChild(chanBox);
root.appendChild(chanCard);
function renderChannels() {
chanBox.innerHTML = '';
const counts = {};
(state.live.aps || []).forEach((a) => {
const band = a.band || '?';
const ch = a.channel == null ? '?' : a.channel;
const key = band + '|' + ch;
counts[key] = (counts[key] || 0) + 1;
});
const bands = {};
Object.keys(counts).forEach((k) => {
const [band, ch] = k.split('|');
(bands[band] = bands[band] || []).push({ ch, n: counts[k] });
});
const bandOrder = ['2.4', '5', '6', '?'];
const maxN = Math.max(1, ...Object.keys(counts).map((k) => counts[k]));
Object.keys(bands).sort((a, b) => {
const ia = bandOrder.indexOf(a), ib = bandOrder.indexOf(b);
return (ia === -1 ? 9 : ia) - (ib === -1 ? 9 : ib);
}).forEach((band) => {
chanBox.appendChild(h('div', { class: 'survey-chan-band', text: band === '?' ? 'Unknown' : band + ' GHz' }));
bands[band].sort((x, y) => {
if (x.ch === '?') return 1;
if (y.ch === '?') return -1;
return Number(x.ch) - Number(y.ch);
}).forEach(({ ch, n }) => {
const row = h('div', { class: 'survey-chan-row' },
h('span', { class: 'survey-chan-label', text: 'CH ' + ch }),
h('span', { class: 'survey-chan-track' },
h('span', { class: 'survey-chan-fill', style: 'width:' + Math.round(n / maxN * 100) + '%' })),
h('span', { class: 'survey-chan-count', text: String(n) }));
chanBox.appendChild(row);
});
});
if (!Object.keys(counts).length) chanBox.appendChild(h('div', { class: 'empty', text: 'No access points seen yet.' }));
}
// ---- compare ----
const cmpCard = h('div', { class: 'section' });
cmpCard.appendChild(h('h2', { text: 'Compare APs' }));
const cmpHint = h('div', { class: 'survey-cmp-hint', text: 'Select up to 6 APs in the table below to compare live signal strength.' });
cmpCard.appendChild(cmpHint);
const cmpCanvas = h('canvas', { id: 'survey-compare', style: 'width:100%;height:150px' });
cmpCard.appendChild(cmpCanvas);
const cmpLegend = h('div', { class: 'survey-cmp-legend' });
cmpCard.appendChild(cmpLegend);
root.appendChild(cmpCard);
function renderCompare() {
const series = [];
const legends = [];
state.compare.forEach((bssid, i) => {
const hist = state.history[bssid] || [];
const pts = hist.map((p) => p.sig).filter((v) => v != null);
const color = SURVEY_COMPARE_COLORS[i % SURVEY_COMPARE_COLORS.length];
if (pts.length >= 2) series.push({ points: pts, color: color });
const ap = state.live.aps.find((a) => a.bssid === bssid);
const last = pts.length ? pts[pts.length - 1] : null;
legends.push(h('span', { class: 'survey-cmp-legend-item' },
h('span', { class: 'survey-cmp-swatch', style: 'background:' + color }),
h('span', { text: (ap && ap.ssid) || '(hidden) ' + (bssid || '').slice(0, 8) + '…' }),
h('span', { class: 'survey-cmp-sig', text: last == null ? '--' : last + ' dBm' })));
});
cmpLegend.innerHTML = '';
legends.forEach((l) => cmpLegend.appendChild(l));
if (typeof MiniChart !== 'undefined' && MiniChart.draw) {
MiniChart.draw(cmpCanvas, series, { min: -100, max: -20, grid: '#e0e0e0' });
}
}
// ---- discover ----
const discCard = h('div', { class: 'section survey-discover hidden' });
root.appendChild(discCard);
function renderDiscover() {
const d = state.discover;
if (!d) { discCard.classList.add('hidden'); return; }
discCard.classList.remove('hidden');
discCard.innerHTML = '';
const head = h('div', { class: 'survey-discover-head' },
h('span', { class: 'survey-discover-title', text: 'Discover: ' + (d.ssid || '(hidden SSID)') }),
btn('×', () => { state.discover = null; renderDiscover(); renderTable(); }, 'ghost'));
discCard.appendChild(head);
const ap = state.live.aps.find((a) => a.bssid === d.bssid);
const sig = ap != null ? ap.signal : (d.history.length ? d.history[d.history.length - 1].sig : null);
const color = reconSigColor(sig);
const readout = h('div', { class: 'survey-discover-readout', style: 'color:' + color, text: sig == null ? '-- dBm' : sig + ' dBm' });
discCard.appendChild(readout);
discCard.appendChild(h('div', { class: 'survey-discover-sub', text: (ap ? (ap.ssid || '(hidden)') + ' · ' : '') + (d.bssid || '') + (ap && ap.channel != null ? ' · CH ' + ap.channel : '') + (ap && ap.band ? ' · ' + ap.band + ' GHz' : '') }));
const spark = h('canvas', { style: 'width:100%;height:70px' });
discCard.appendChild(spark);
const pts = d.history.map((p) => p.sig).filter((v) => v != null);
if (typeof MiniChart !== 'undefined' && MiniChart.draw && pts.length >= 2) {
MiniChart.draw(spark, [{ points: pts, color: color }], { min: -100, max: -20, grid: '#e0e0e0' });
}
}
// ---- AP table ----
const apCard = h('div', { class: 'section' });
apCard.appendChild(h('h2', { text: 'Live Access Points' }));
const apBody = h('div');
apCard.appendChild(apBody);
root.appendChild(apCard);
const surveyCols = [
{ key: 'ssid', label: 'SSID', render: (a) => a.ssid || '(hidden)' },
{ key: 'bssid', label: 'MAC', render: (a) => a.bssid || '--' },
{ key: 'band', label: 'Band', render: (a) => a.band || '--' },
{ key: 'channel', label: 'Channel', render: (a) => a.channel == null ? '--' : a.channel },
{ key: 'signal', label: 'Signal', render: (a) => a.signal == null ? '--' : dbmCell(a.signal) },
{ key: 'vendor', label: 'Vendor', render: (a) => a.vendor || '--' },
{ key: 'encryption', label: 'Encryption', render: (a) => a.encryption || '--' },
{ key: 'actions', label: 'Compare', render: (a) => {
const cb = h('input', { type: 'checkbox', title: 'Compare this AP' });
cb.checked = state.compare.indexOf(a.bssid) !== -1;
cb.addEventListener('change', () => {
if (cb.checked) {
if (state.compare.length >= 6) { cb.checked = false; App.toast('Compare up to 6 APs', 'error'); return; }
state.compare.push(a.bssid);
App.toast('Comparing ' + ((a.ssid || '(hidden)').length > 24 ? (a.ssid || '(hidden)').slice(0, 24) + '…' : (a.ssid || '(hidden)')));
} else {
state.compare = state.compare.filter((b) => b !== a.bssid);
}
renderCompare();
});
return h('span', { class: 'survey-cmp-check' }, cb);
} },
{ key: 'discover', label: 'Discover', render: (a) =>
iconBtn('place', 'Pin this AP for Discover mode', () => {
state.discover = { bssid: a.bssid, ssid: a.ssid, history: (state.history[a.bssid] || []).slice() };
renderDiscover();
}) }
];
function renderTable() {
let rows = state.live.aps || [];
if (state.band !== 'all') rows = rows.filter((a) => (a.band || '') === state.band);
const q = state.search.toLowerCase();
if (q) rows = rows.filter((a) => String(a.ssid || '').toLowerCase().indexOf(q) !== -1 || String(a.bssid || '').toLowerCase().indexOf(q) !== -1);
rows = rows.slice().sort((a, b) => {
const sa = a.signal == null ? -Infinity : a.signal;
const sb = b.signal == null ? -Infinity : b.signal;
return sb - sa;
});
apBody.innerHTML = '';
if (!rows.length) { apBody.appendChild(h('div', { class: 'empty', text: 'No access points in the latest scan.' })); return; }
apBody.appendChild(table(surveyCols, rows));
}
function renderPills() {
const g = state.gps || {};
if (g.lock) {
gpsPill.className = 'survey-pill on';
gpsPill.textContent = 'GPS ' + (g.lat != null ? Number(g.lat).toFixed(5) : '--') + ', ' + (g.lon != null ? Number(g.lon).toFixed(5) : '--') + (g.satellites ? ' · ' + g.satellites + ' sats' : '');
} else if (g.present) {
gpsPill.className = 'survey-pill';
gpsPill.textContent = 'GPS no fix — tap to bind';
} else if (g.gpsd_running) {
gpsPill.className = 'survey-pill';
gpsPill.textContent = 'GPS no device';
} else {
gpsPill.className = 'survey-pill';
gpsPill.textContent = 'GPS off';
}
wigleCb.checked = !!state.wigle;
}
function tick() {
PagerAPI.get('/api/recon/survey/live').then((r) => {
const d = r.data || {};
state.live = d;
state.updated = Date.now();
const scan = d.scan || null;
liveStatus.textContent = scan
? 'Live: Scan #' + scan.id + ' — ' + (d.aps || []).length + ' APs, ' + (d.clients || []).length + ' clients, ' + (d.unassociated || 0) + ' unassociated · updated ' + fmtShortTime(Date.now() / 1000)
: 'Waiting for a scan…';
// history
const nowT = Date.now() / 1000;
(d.aps || []).forEach((a) => {
if (a.bssid == null) return;
const hist = state.history[a.bssid] || (state.history[a.bssid] = []);
hist.push({ t: nowT, sig: a.signal });
while (hist.length > SURVEY_MAX_HISTORY) hist.shift();
});
// drop history for APs no longer present (prune after grace)
const seen = {};
(d.aps || []).forEach((a) => { if (a.bssid != null) seen[a.bssid] = true; });
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;
}
});
state.gps = d.gps || {};
state.wigle = !!(d.gps || {}).wigle;
state.recording = d.recording || null;
if (state.discover) {
const dh = state.history[state.discover.bssid] || [];
state.discover.history = dh.slice(-SURVEY_MAX_HISTORY);
const ap = (d.aps || []).find((a) => a.bssid === state.discover.bssid);
if (ap) { state.discover.ssid = ap.ssid; }
}
renderPills();
renderRecording();
renderChannels();
renderCompare();
renderDiscover();
renderTable();
}).catch(() => {});
}
tick();
const poll = setInterval(tick, 2000);
return { destroy: () => clearInterval(poll) };
};
views.recon_reports = (root) => {
root.appendChild(h('h1', { class: 'page-title', text: 'Recon' }));
tabBar(root, RECON_TABS, '#/recon/reports');
const state = { surveys: [], detail: {} };
const reportCard = h('div', { class: 'section' });
const surveyCard = h('div', { class: 'section' });
const wigleCard = h('div', { class: 'section' });
function dl(path) { window.location = App.apiBase + path; }
function renderScans() {
reportCard.appendChild(h('h2', { text: 'Scan Reports' }));
const box = h('div');
reportCard.appendChild(box);
PagerAPI.get('/api/recon/scans').then((r) => {
const scans = (r.data && r.data.scans) || [];
box.innerHTML = '';
if (!scans.length) { box.appendChild(h('div', { class: 'empty', text: 'No scans recorded yet.' })); return; }
box.appendChild(table(
[
{ key: 'id', label: 'Scan', render: (s) => '#' + s.id },
{ key: 'time', label: 'Started', render: (s) => fmtTime(s.time) },
{ key: 'aps', label: 'APs' },
{ key: 'devices', label: 'Clients' },
{ key: 'handshakes', label: 'Handshakes' },
{ key: 'actions', label: 'Download', render: (s) => h('span', { class: 'hs-actions' },
iconBtn('file_download', 'JSON', () => dl('/api/recon/scans/' + s.id + '/download/json')),
iconBtn('table_chart', 'CSV', () => dl('/api/recon/scans/' + s.id + '/download/csv')),
iconBtn('description', 'HTML report', () => dl('/api/recon/scans/' + s.id + '/download/html'))) }
],
scans));
}).catch(() => box.appendChild(h('div', { class: 'empty', text: 'Failed to load scans.' })));
}
function renderSurveys() {
surveyCard.appendChild(h('h2', { text: 'Survey Recordings' }));
const box = h('div');
surveyCard.appendChild(box);
PagerAPI.get('/api/recon/surveys').then((r) => {
state.surveys = (r.data && r.data.surveys) || [];
box.innerHTML = '';
if (!state.surveys.length) { box.appendChild(h('div', { class: 'empty', text: 'No surveys recorded yet. Start one on the Survey tab.' })); return; }
box.appendChild(table(
[
{ key: 'name', label: 'Name', render: (s) => s.name },
{ key: 'started', label: 'Started', render: (s) => fmtTime(s.started) },
{ key: 'samples', label: 'Samples', render: (s) => s.samples },
{ key: 'size', label: 'Size', render: (s) => fmtBytes(s.size) },
{ key: 'actions', label: 'Export', render: (s) => h('span', { class: 'hs-actions' },
iconBtn('file_download', 'JSON', () => dl('/api/recon/surveys/' + s.id + '/download/json')),
iconBtn('table_chart', 'CSV', () => dl('/api/recon/surveys/' + s.id + '/download/csv')),
iconBtn('description', 'HTML report', () => dl('/api/recon/surveys/' + s.id + '/download/html')),
iconBtn('delete', 'Delete survey', () => {
if (!confirm('Delete survey "' + s.name + '"? This cannot be undone.')) return;
PagerAPI.del('/api/recon/surveys/' + s.id).then(() => { App.toast('Survey deleted'); renderSurveys(); }).catch((err) => App.toast((err && err.message) || 'Delete failed', 'error'));
})) }
],
state.surveys));
box.appendChild(h('div', { class: 'survey-detail-hint', text: 'Click a survey name below to expand its AP signal aggregates.' }));
const detailBox = h('div');
box.appendChild(detailBox);
state.surveys.forEach((s) => {
const row = h('div', { class: 'survey-detail-row', text: s.name });
row.addEventListener('click', () => {
if (state.detail[s.id] && state.detail[s.id].open) {
state.detail[s.id].open = false;
row.classList.remove('open');
const b = detailBox.querySelector('[data-sid="' + s.id + '"]');
if (b) b.remove();
return;
}
if (!state.detail[s.id]) state.detail[s.id] = { open: false, data: null };
state.detail[s.id].open = true;
row.classList.add('open');
const body = h('div', { class: 'survey-detail-body', 'data-sid': s.id });
detailBox.appendChild(body);
body.appendChild(h('div', { class: 'empty', text: 'Loading…' }));
PagerAPI.get('/api/recon/surveys/' + s.id).then((r2) => {
const d = r2.data || {};
state.detail[s.id].data = d;
body.innerHTML = '';
const gpsTxt = d.gps_fixes
? d.gps_fixes + ' fixes · first ' + (d.first_gps && d.first_gps.lat != null ? Number(d.first_gps.lat).toFixed(5) + ', ' + Number(d.first_gps.lon).toFixed(5) : '--') + ' · last ' + (d.last_gps && d.last_gps.lat != null ? Number(d.last_gps.lat).toFixed(5) + ', ' + Number(d.last_gps.lon).toFixed(5) : '--')
: 'No GPS fixes during this survey';
body.appendChild(h('div', { class: 'survey-detail-gps', text: 'GPS: ' + gpsTxt }));
const aps = (d.aps || []).slice().sort((a, b) => {
const av = a.avg == null ? -Infinity : a.avg, bv = b.avg == null ? -Infinity : b.avg;
return av - bv;
});
if (!aps.length) { body.appendChild(h('div', { class: 'empty', text: 'No AP samples in this survey.' })); return; }
body.appendChild(table(
[
{ key: 'ssid', label: 'SSID', render: (a) => a.ssid || '(hidden)' },
{ key: 'bssid', label: 'MAC', render: (a) => a.bssid || '--' },
{ key: 'band', label: 'Band', render: (a) => a.band || '--' },
{ key: 'channel', label: 'Channel', render: (a) => a.channel == null ? '--' : a.channel },
{ key: 'min', label: 'Min', render: (a) => a.min == null ? '--' : a.min + ' dBm' },
{ key: 'avg', label: 'Avg', render: (a) => a.avg == null ? '--' : a.avg + ' dBm' },
{ key: 'max', label: 'Max', render: (a) => a.max == null ? '--' : a.max + ' dBm' },
{ key: 'samples', label: 'Samples' },
{ key: 'first_seen', label: 'First', render: (a) => fmtShortTime(a.first_seen) },
{ key: 'last_seen', label: 'Last', render: (a) => fmtShortTime(a.last_seen) }
],
aps));
}).catch(() => { body.innerHTML = ''; body.appendChild(h('div', { class: 'empty', text: 'Failed to load survey detail.' })); });
});
detailBox.appendChild(row);
});
}).catch(() => box.appendChild(h('div', { class: 'empty', text: 'Failed to load surveys.' })));
}
function renderWigle() {
wigleCard.appendChild(h('h2', { text: 'WiGLE Uploads' }));
const box = h('div');
wigleCard.appendChild(box);
PagerAPI.get('/api/recon/wigle/files').then((r) => {
const files = (r.data && r.data.files) || [];
box.innerHTML = '';
if (!files.length) { box.appendChild(h('div', { class: 'empty', text: 'No WiGLE files yet. WiGLE logging writes a CSV per capture session.' })); return; }
box.appendChild(table(
[
{ key: 'name', label: 'File', render: (f) => f.name },
{ key: 'mtime', label: 'Modified', render: (f) => fmtTime(f.mtime) },
{ key: 'size', label: 'Size', render: (f) => fmtBytes(f.size) },
{ key: 'rows', label: 'AP rows', render: (f) => f.rows == null ? '--' : f.rows },
{ key: 'warn', label: '', render: (f) => f.rows === 0 ? h('span', { class: 'survey-wigle-warn', text: 'Header only — no data yet (needs a GPS fix)' }) : h('span', {}) },
{ key: 'dl', label: 'Download', render: (f) => iconBtn('file_download', 'Download ' + f.name, () => dl('/api/recon/wigle/files/' + encodeURIComponent(f.name))) }
],
files));
}).catch(() => box.appendChild(h('div', { class: 'empty', text: 'Failed to load WiGLE files.' })));
}
root.appendChild(reportCard);
root.appendChild(surveyCard);
root.appendChild(wigleCard);
renderScans();
renderSurveys();
renderWigle();
return { destroy: () => {} };
};
const LOGGING_TABS = [
{ label: 'PineAP', hash: '#/logging' },
+363
View File
@@ -0,0 +1,363 @@
import os
import sys
import unittest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'remote_access', 'pager-webui'))
import server
def setUpModule():
__import__('importlib').reload(server)
class ChannelBandTest(unittest.TestCase):
def test_2g_channels(self):
for ch in (1, 6, 11, 14):
self.assertEqual(server.channel_band(ch), server.BAND_2G)
def test_5g_channels(self):
for ch in (36, 48, 100, 149, 165, 177):
self.assertEqual(server.channel_band(ch), server.BAND_5G)
def test_6g_channels(self):
for ch in (181, 189, 197, 205, 213, 225, 233):
self.assertEqual(server.channel_band(ch), server.BAND_6G)
def test_invalid(self):
for ch in (0, 15, 17, 21, 33, 35, 178, 234, None, 'x'):
self.assertIsNone(server.channel_band(ch))
class ChannelBandsConsistencyTest(unittest.TestCase):
def test_lists_match_channel_band(self):
for band, channels in server.CHANNEL_BANDS.items():
for ch in channels:
self.assertEqual(server.channel_band(ch), band, '%s should be %s' % (ch, band))
def test_no_out_of_list_channels(self):
for ch in list(range(0, 235)):
band = server.channel_band(ch)
if band is not None:
self.assertIn(ch, server.CHANNEL_BANDS[band], '%s should be listed for %s' % (ch, band))
def test_boundaries(self):
self.assertEqual(server.CHANNEL_BANDS[server.BAND_2G][-1], 14)
self.assertEqual(server.CHANNEL_BANDS[server.BAND_5G][-1], 177)
self.assertEqual(server.CHANNEL_BANDS[server.BAND_6G][0], 181)
self.assertEqual(server.CHANNEL_BANDS[server.BAND_6G][-1], 233)
class ChannelFreqTest(unittest.TestCase):
def test_freqs(self):
self.assertEqual(server.channel_freq(server.BAND_2G, 1), 2412)
self.assertEqual(server.channel_freq(server.BAND_2G, 11), 2462)
self.assertEqual(server.channel_freq(server.BAND_5G, 36), 5180)
self.assertEqual(server.channel_freq(server.BAND_5G, 165), 5825)
self.assertEqual(server.channel_freq(server.BAND_6G, 1), 5955)
self.assertEqual(server.channel_freq(server.BAND_6G, 233), 7115)
class BandAuxTest(unittest.TestCase):
def test_htmode(self):
self.assertEqual(server.band_htmode(server.BAND_2G), 'HT20')
self.assertEqual(server.band_htmode(server.BAND_5G), 'VHT80')
self.assertEqual(server.band_htmode(server.BAND_6G), 'HE80')
def test_radio(self):
self.assertEqual(server.band_radio(server.BAND_2G), 'radio0')
self.assertEqual(server.band_radio(server.BAND_5G), 'radio1')
self.assertEqual(server.band_radio(server.BAND_6G), 'radio1')
def test_dfs_marker(self):
for ch in (52, 64, 100, 144):
self.assertIn(ch, server.DFS_CHANNELS)
for ch in (36, 48, 149):
self.assertNotIn(ch, server.DFS_CHANNELS)
self.assertEqual(set(server.DFS_CHANNELS),
set(range(52, 65, 4)) | set(range(100, 145, 4)))
def ctx(body=None):
return type('C', (), {'body': body, 'args': (), 'query': {}})()
class GetApRadio1Test(unittest.TestCase):
def _uci(self, section):
table = {
'radio0': {'type': 'wifi-device', 'band': '2g', 'channel': '11',
'htmode': 'HT20', 'country': 'US'},
'radio1': {'type': 'wifi-device', 'band': '5g', 'channel': 'auto',
'htmode': 'VHT80', 'country': 'US'},
'wlan0open': {'device': 'radio0', 'mode': 'ap', 'ssid': 'pager-open',
'disabled': '0', 'hidden': '0', 'encryption': 'none',
'channel': '11'},
'wlan0wpa': {'device': 'radio0', 'mode': 'ap', 'ssid': 'Service',
'disabled': '0', 'hidden': '0', 'encryption': 'psk2',
'channel': '1', 'key': 'testpass123'},
'wlan1open': {'device': 'radio1', 'mode': 'ap', 'ssid': 'CorpGuest',
'disabled': '0', 'hidden': '0', 'encryption': 'none',
'channel': '36'},
'wlan1wpa': {'device': 'radio1', 'mode': 'ap', 'ssid': 'Corp',
'disabled': '0', 'hidden': '0', 'encryption': 'sae',
'channel': '1', 'key': 'secret123'},
}
return dict(table.get(section, {}))
def setUp(self):
server._uci_wifi_iface = lambda name: self._uci(name)
server.daemon_sock_call = lambda method, path, body=None, timeout=10: (
404, {'error': 'not found'})
def test_open_reports_radio1_when_present(self):
status, payload = server.h_pineap_wifi_get_ap(ctx())
self.assertEqual(status, 200)
self.assertEqual(payload['open']['ssid'], 'CorpGuest')
self.assertEqual(payload['open']['channel'], 36)
self.assertEqual(payload['open']['country'], 'US')
def test_wpa_reports_radio1_when_present(self):
status, payload = server.h_pineap_wifi_get_ap(ctx())
self.assertEqual(status, 200)
self.assertEqual(payload['wpa']['ssid'], 'Corp')
self.assertEqual(payload['wpa']['enctype'], 'sae')
self.assertEqual(payload['wpa']['channel'], 1)
def test_radio1_info(self):
status, payload = server.h_pineap_wifi_get_ap(ctx())
self.assertEqual(status, 200)
self.assertEqual(payload['radio1']['band'], server.BAND_5G)
self.assertEqual(payload['radio1']['channel'], 'auto')
class GetApRadio1AbsentTest(unittest.TestCase):
"""Regression: no radio1 AP sections -> today's 2.4GHz behavior."""
def _uci(self, section):
table = {
'radio0': {'type': 'wifi-device', 'band': '2g', 'channel': '11',
'htmode': 'HT20', 'country': 'US'},
'radio1': {'type': 'wifi-device', 'band': '5g', 'channel': 'auto',
'htmode': 'VHT80', 'country': 'US'},
'wlan0open': {'device': 'radio0', 'mode': 'ap', 'ssid': 'pager-open',
'disabled': '0', 'hidden': '0', 'encryption': 'none',
'channel': '11'},
'wlan0wpa': {'device': 'radio0', 'mode': 'ap', 'ssid': 'Service',
'disabled': '0', 'hidden': '0', 'encryption': 'psk2',
'channel': '1', 'key': 'testpass123'},
}
return dict(table.get(section, {}))
def setUp(self):
server._uci_wifi_iface = lambda name: self._uci(name)
server.daemon_sock_call = lambda method, path, body=None, timeout=10: (
404, {'error': 'not found'})
def test_open_uses_wlan0open(self):
status, payload = server.h_pineap_wifi_get_ap(ctx())
self.assertEqual(status, 200)
self.assertEqual(payload['open']['ssid'], 'pager-open')
self.assertEqual(payload['open']['channel'], 11)
def test_wpa_uses_wlan0wpa(self):
status, payload = server.h_pineap_wifi_get_ap(ctx())
self.assertEqual(status, 200)
self.assertEqual(payload['wpa']['ssid'], 'Service')
self.assertEqual(payload['wpa']['channel'], 1)
class SetApRadio1Test(unittest.TestCase):
def setUp(self):
self.uci = {}
self.runs = []
def fake_uci_get(section):
return self.uci.get(section)
def fake_run(args, timeout=20, input_data=None):
self.runs.append((list(args), input_data))
a = list(args)
if a[:2] == ['uci', 'set']:
k, _, v = a[2].partition('=')
self.uci[k] = v
if a[:2] == ['uci', 'get']:
return (0, self.uci.get(a[2], '') + '\n', '')
return (0, '', '')
server._uci_wifi_iface = fake_uci_get
server._uci_section = fake_uci_get
server.device_run = fake_run
server.daemon_sock_call = lambda method, path, body=None, timeout=10: (
200, {'success': True})
def test_5g_open_writes_radio1_sections(self):
server.h_pineap_wifi_set_ap(ctx({'open': {
'ssid': 'CorpGuest', 'hidden': False, 'enabled': True,
'channel': 36, 'country': 'US'}}))
self.assertEqual(self.uci['wireless.radio1.band'], '5g')
self.assertEqual(self.uci['wireless.radio1.channel'], '36')
self.assertEqual(self.uci['wireless.radio1.htmode'], 'VHT80')
self.assertEqual(self.uci['wireless.wlan1open'], 'wifi-iface')
self.assertEqual(self.uci['wireless.wlan1open.device'], 'radio1')
self.assertEqual(self.uci['wireless.wlan1open.disabled'], '0')
self.assertEqual(self.uci['wireless.wlan1open.ssid'], 'CorpGuest')
self.assertEqual(self.uci['wireless.wlan1open.encryption'], 'none')
self.assertEqual(self.uci['pineapd.wlan1mon.hop'], '0')
self.assertIn(['wifi', 'reload'], [r[0] for r in self.runs])
self.assertIn(['/etc/init.d/pineapd', 'reload'], [r[0] for r in self.runs])
def test_6g_wpa_sae(self):
server.h_pineap_wifi_set_ap(ctx({'wpa': {
'ssid': 'Corp', 'passphrase': 'secret123', 'enctype': 'sae',
'hidden': False, 'enabled': True, 'channel': 181}}))
self.assertEqual(self.uci['wireless.radio1.band'], '6g')
self.assertEqual(self.uci['wireless.radio1.htmode'], 'HE80')
self.assertEqual(self.uci['wireless.wlan1wpa.encryption'], 'sae')
def test_6g_rejects_psk2(self):
status, payload = server.h_pineap_wifi_set_ap(ctx({'wpa': {
'ssid': 'Corp', 'passphrase': 'secret123', 'enctype': 'psk2',
'hidden': False, 'enabled': True, 'channel': 181}}))
self.assertEqual(status, 400)
def test_6g_open_rejected(self):
status, payload = server.h_pineap_wifi_set_ap(ctx({'open': {
'ssid': 'CorpGuest', 'hidden': False, 'enabled': True,
'channel': 181, 'country': 'US'}}))
self.assertEqual(status, 400)
self.assertIn('6GHz', payload['error'])
def test_6g_disable_removes_radio1(self):
self.uci['pineapd.wlan1mon.hop'] = '0'
self.uci['wlan1wpa'] = {'device': 'radio1'}
status, payload = server.h_pineap_wifi_set_ap(ctx({'wpa': {
'ssid': 'Corp', 'passphrase': 'secret123', 'enctype': 'sae',
'hidden': False, 'enabled': False, 'channel': 181}}))
self.assertEqual(status, 200)
self.assertEqual(self.uci['wireless.radio1.channel'], 'auto')
self.assertEqual(self.uci['pineapd.wlan1mon.hop'], '1')
def test_5g_open_rejects_bad_bssid(self):
status, payload = server.h_pineap_wifi_set_ap(ctx({'open': {
'ssid': 'CorpGuest', 'bssid': 'not-a-mac', 'hidden': False,
'enabled': True, 'channel': 36, 'country': 'US'}}))
self.assertEqual(status, 400)
def test_hop_read_failure_still_pauses(self):
def fake_run(args, timeout=20, input_data=None):
self.runs.append((list(args), input_data))
a = list(args)
if a[:2] == ['uci', 'get']:
return (1, '', '')
if a[:2] == ['uci', 'set']:
k, _, v = a[2].partition('=')
self.uci[k] = v
return (0, '', '')
server.device_run = fake_run
server.h_pineap_wifi_set_ap(ctx({'open': {
'ssid': 'CorpGuest', 'hidden': False, 'enabled': True,
'channel': 36, 'country': 'US'}}))
self.assertEqual(self.uci['pineapd.wlan1mon.hop'], '0')
def test_2g_still_uses_daemon_path(self):
calls = []
def fake_sock(method, path, body=None, timeout=10):
if method == 'GET':
return 200, {'loghandshake': True}
calls.append((method, path, body))
return 200, {'success': True}
server.daemon_sock_call = fake_sock
server.h_pineap_wifi_set_ap(ctx({'open': {
'ssid': 'pager-open', 'hidden': False, 'enabled': True,
'channel': 11, 'country': 'US'}}))
put = [c for c in calls if c[0] == 'PUT']
self.assertEqual(len(put), 1)
self.assertEqual(put[0][1], '/api/settings/wifi/set_ap')
self.assertEqual(put[0][2]['configs'][0]['interface'], 'wlan0open')
def test_2g_removes_existing_radio1(self):
self.uci['pineapd.wlan1mon.hop'] = '0'
self.uci['wlan1open'] = {'device': 'radio1'}
server.h_pineap_wifi_set_ap(ctx({'open': {
'ssid': 'pager-open', 'hidden': False, 'enabled': True,
'channel': 11, 'country': 'US'}}))
self.assertEqual(self.uci['pineapd.wlan1mon.hop'], '1')
self.assertEqual(self.uci.get('wireless.radio1.channel'), 'auto')
def test_mixed_24g_and_radio1_rejected(self):
status, payload = server.h_pineap_wifi_set_ap(ctx({
'open': {'ssid': 'CorpGuest', 'hidden': False, 'enabled': True,
'channel': 36, 'country': 'US'},
'wpa': {'ssid': 'Office', 'passphrase': 'secret123', 'enctype': 'psk2',
'hidden': False, 'enabled': True, 'channel': 6}}))
self.assertEqual(status, 400)
self.assertIn('2.4GHz', payload['error'])
class GetApRadioChannelFallbackTest(unittest.TestCase):
"""Regression: iface without a channel option inherits the radio channel."""
def _uci(self, section):
table = {
'radio0': {'type': 'wifi-device', 'band': '2g', 'channel': '11',
'htmode': 'HT20', 'country': 'US'},
'radio1': {'type': 'wifi-device', 'band': '5g', 'channel': 'auto',
'htmode': 'VHT80', 'country': 'US'},
'wlan0open': {'device': 'radio0', 'mode': 'ap', 'ssid': 'pager-open',
'disabled': '0', 'hidden': '0', 'encryption': 'none'},
'wlan0wpa': {'device': 'radio0', 'mode': 'ap', 'ssid': 'Service',
'disabled': '0', 'hidden': '0', 'encryption': 'psk2',
'key': 'testpass123'},
}
return dict(table.get(section, {}))
def setUp(self):
server._uci_wifi_iface = lambda name: self._uci(name)
server.daemon_sock_call = lambda method, path, body=None, timeout=10: (
404, {'error': 'not found'})
def test_open_falls_back_to_radio_channel(self):
status, payload = server.h_pineap_wifi_get_ap(ctx())
self.assertEqual(status, 200)
self.assertEqual(payload['open']['channel'], 11)
def test_wpa_falls_back_to_radio_channel(self):
status, payload = server.h_pineap_wifi_get_ap(ctx())
self.assertEqual(status, 200)
self.assertEqual(payload['wpa']['channel'], 11)
class GetApReconcileTest(unittest.TestCase):
def setUp(self):
server._last_reconcile = 0.0
self.uci = {'wlan1wpa': {'device': 'radio1', 'mode': 'ap', 'ssid': 'Corp',
'disabled': '0', 'encryption': 'sae', 'channel': '36'}}
self.runs = []
server._uci_wifi_iface = lambda name: dict(self.uci.get(name, {}))
server._uci_section = lambda name: {}
server.daemon_sock_call = lambda method, path, body=None, timeout=10: (
404, {'error': 'not found'})
server.device_run = lambda args, timeout=20, input_data=None: (
self.runs.append(list(args)) or (0, '', ''))
def test_missing_netdev_triggers_wifi_reload(self):
server.os.path.exists = lambda p: False
server.h_pineap_wifi_get_ap(ctx())
self.assertIn(['wifi', 'reload'], self.runs)
def test_present_netdev_skips_reload(self):
server.os.path.exists = lambda p: True
server.h_pineap_wifi_get_ap(ctx())
self.assertNotIn(['wifi', 'reload'], self.runs)
def test_disabled_section_skips_reload(self):
self.uci['wlan1wpa']['disabled'] = '1'
server.os.path.exists = lambda p: False
server.h_pineap_wifi_get_ap(ctx())
self.assertNotIn(['wifi', 'reload'], self.runs)
if __name__ == '__main__':
unittest.main()
+2 -2
View File
@@ -129,7 +129,7 @@ class PineapProxyTest(unittest.TestCase):
status, payload = server.h_pineap_wifi_get_ap(ctx())
self.assertEqual(status, 200)
self.assertEqual(payload['wpa'], {'ssid': 'Evil1', 'passphrase': 'sekret', 'enctype': 'psk2',
'hidden': False, 'enabled': True})
'hidden': False, 'enabled': True, 'channel': 6})
self.assertEqual(payload['open']['enabled'], False)
self.assertEqual(payload['open']['ssid'], 'pager-open')
self.assertEqual(payload['open']['bssid'], 'DE:AD:BE:EF:00:01')
@@ -150,7 +150,7 @@ class PineapProxyTest(unittest.TestCase):
def fake_run(args):
run_calls.append(args)
if args[0] == 'uci' and args[1] == 'show':
if args[0] == 'uci' and args[1] == 'show' and args[2] == 'wireless.radio0':
return 0, "wireless.radio0.channel='1'\n", ''
return 0, '', ''
+484
View File
@@ -635,3 +635,487 @@ class HandshakeRoutesTest(unittest.TestCase):
self.assertEqual(data['files'], [])
self.assertEqual(data['handshakes'], [])
self.assertEqual(os.listdir(self.dir), ['.hidden'])
def make_survey_db():
"""Single-scan recon DB so the newest scan carries the AP data."""
fd, db = tempfile.mkstemp(suffix='.db')
os.close(fd)
conn = sqlite3.connect(db)
conn.executescript(SCHEMA)
conn.execute("INSERT INTO scan (uuid, time, name) VALUES ('u1', 1786466531, 'pager')")
conn.execute("INSERT INTO wifi_device (hash, scan, mac, time, signal, freq, packets) VALUES (1, 1, 'AE77C0EB3141', 1786466531, -71, 2412, 5)")
conn.execute("INSERT INTO wifi_device (hash, scan, mac, time, signal, freq, packets) VALUES (2, 1, 'C89E43648080', 1786466532, -76, 5745, 9)")
conn.execute("INSERT INTO ssid (hash, wifi_device, scan, type, bssid, ssid, hidden, time, signal, freq, channel, encryption) "
"VALUES (10, 2, 1, 8, 'C89E43648080', X'416E646572736F6E2D35', 0, 1786466532, -76, 5745, 149, 0x400400108)")
conn.execute("INSERT INTO ssid (hash, wifi_device, scan, type, bssid, ssid, hidden, time, signal, freq, channel, encryption) "
"VALUES (11, 2, 1, 8, '506F9A010000', X'', 1, 1786466532, -64, 5745, 149, 0)")
conn.execute("INSERT INTO ssid (hash, wifi_device, scan, type, bssid, ssid, hidden, time, signal, freq, channel, encryption) "
"VALUES (12, 1, 1, 4, NULL, X'5A6E6574', NULL, 1786466531, -40, 2412, NULL, NULL)")
conn.execute("INSERT INTO handshake (hash, scan, stahash, aphash, time) VALUES (20, 1, 1, 2, 1786466600)")
conn.commit()
conn.close()
return db
class OuiVendorTest(unittest.TestCase):
def test_oui_prefix_forms(self):
self.assertEqual(server._oui_prefix('C8:9E:43:64:80:80'), 'C89E43')
self.assertEqual(server._oui_prefix('C89E43648080'), 'C89E43')
self.assertEqual(server._oui_prefix('c8:9e:43:64:80:80'), 'C89E43')
self.assertIsNone(server._oui_prefix(None))
self.assertIsNone(server._oui_prefix(''))
self.assertIsNone(server._oui_prefix('XX:YY:ZZ:00:00:00'))
def test_oui_vendor_lookup(self):
self.assertEqual(server.oui_vendor('B8:27:EB:00:00:00'), 'Raspberry Pi')
self.assertEqual(server.oui_vendor('10:BF:48:00:00:00'), 'Apple')
self.assertEqual(server.oui_vendor('14:CC:20:00:00:00'), 'TP-Link')
self.assertEqual(server.oui_vendor('FC:63:3E:00:00:00'), 'Google')
def test_oui_vendor_unknown_and_local(self):
self.assertEqual(server.oui_vendor('C8:9E:43:64:80:80'), 'Unknown')
self.assertEqual(server.oui_vendor('AE:77:C0:EB:31:41'), 'Local')
self.assertEqual(server.oui_vendor(None), 'Unknown')
self.assertEqual(server.oui_vendor('--'), 'Unknown')
def test_band_of_frequencies(self):
self.assertEqual(server.band_of(2412), '2.4')
self.assertEqual(server.band_of(5200), '5')
self.assertEqual(server.band_of(6180), '6')
self.assertEqual(server.band_of(0), '--')
self.assertEqual(server.band_of(None), '--')
def test_curated_table_has_no_garbage_keys(self):
for key in server.OUI_VENDORS:
self.assertRegex(key, r'^[0-9A-F]{6}$')
self.assertNotIn('349A...', server.OUI_VENDORS)
class ReconEnrichmentTest(unittest.TestCase):
def setUp(self):
self.db = make_db()
server.RECON_DB = self.db
def tearDown(self):
os.unlink(self.db)
def test_scan_detail_enriches_aps(self):
data = server.recon_scan_data(1)
aps = {a['bssid']: a for a in data['aps']}
a = aps['C8:9E:43:64:80:80']
self.assertEqual(a['band'], '5')
self.assertEqual(a['vendor'], 'Unknown')
self.assertEqual(a['first_seen'], 1786466532)
self.assertEqual(a['last_seen'], 1786466532)
hidden = aps['50:6F:9A:01:00:00']
self.assertEqual(hidden['band'], '5')
self.assertEqual(hidden['vendor'], 'Unknown')
def test_scan_detail_unassociated_count(self):
data = server.recon_scan_data(1)
self.assertEqual(data['unassociated'], 1)
def test_scan_detail_bounded_mode_counts_unassociated(self):
data = server.recon_scan_data(1, _limit=1)
self.assertEqual(data['unassociated'], 1)
self.assertEqual(len(data['aps']), 2)
self.assertLessEqual(len(data['clients']), 1)
self.assertEqual(data['scan']['id'], 1)
def test_first_last_seen_span_multiple_rows(self):
conn = sqlite3.connect(self.db)
conn.execute("INSERT INTO ssid (hash, wifi_device, scan, type, bssid, ssid, hidden, time, signal, freq, channel, encryption) "
"VALUES (30, 2, 1, 8, 'C89E43648080', X'416E646572736F6E2D35', 0, 1786466540, -80, 5745, 149, 0x400400108)")
conn.commit()
conn.close()
data = server.recon_scan_data(1)
a = [a for a in data['aps'] if a['bssid'] == 'C8:9E:43:64:80:80'][0]
self.assertEqual(a['first_seen'], 1786466532)
self.assertEqual(a['last_seen'], 1786466540)
def test_band_for_24ghz_ap(self):
conn = sqlite3.connect(self.db)
conn.execute("INSERT INTO wifi_device (hash, scan, mac, time, signal, freq, packets) VALUES (3, 1, 'FC633E000001', 1786466533, -60, 2412, 4)")
conn.execute("INSERT INTO ssid (hash, wifi_device, scan, type, bssid, ssid, hidden, time, signal, freq, channel, encryption) "
"VALUES (31, 3, 1, 8, 'FC633E000001', X'4E6574776F726B', 0, 1786466533, -60, 2412, 6, 0x08)")
conn.commit()
conn.close()
data = server.recon_scan_data(1)
a = [a for a in data['aps'] if a['bssid'] == 'FC:63:3E:00:00:01'][0]
self.assertEqual(a['band'], '2.4')
self.assertEqual(a['vendor'], 'Google')
class ReconReportTest(unittest.TestCase):
def setUp(self):
self.db = make_db()
server.RECON_DB = self.db
def tearDown(self):
os.unlink(self.db)
def _ctx(self, args=()):
return type('C', (), {'args': args, 'body': {}})()
def test_csv_download_contains_aps_and_unassociated(self):
status, payload = server.h_recon_scan_download_csv(self._ctx(('1',)))
self.assertEqual(status, 200)
self.assertEqual(payload.ctype, 'text/csv')
self.assertEqual(payload.filename, 'scan-1.csv')
text = payload.data.decode('utf-8')
self.assertIn('Anderson-5', text)
self.assertIn('unassociated,1', text)
self.assertIn('C8:9E:43:64:80:80', text)
def test_html_download_contains_stats(self):
status, payload = server.h_recon_scan_download_html(self._ctx(('1',)))
self.assertEqual(status, 200)
self.assertEqual(payload.ctype, 'text/html')
self.assertEqual(payload.filename, 'scan-1.html')
text = payload.data.decode('utf-8')
self.assertIn('Scan #1', text)
self.assertIn('Anderson-5', text)
self.assertIn('WPA3 WPA2', text)
self.assertIn('Unassociated', text)
def test_download_404_for_missing_scan(self):
status, payload = server.h_recon_scan_download_csv(self._ctx(('999',)))
self.assertEqual(status, 404)
status, payload = server.h_recon_scan_download_html(self._ctx(('999',)))
self.assertEqual(status, 404)
def test_download_503_when_db_unavailable(self):
with mock.patch.object(server, 'recon_scan_data',
side_effect=RuntimeError('sqlite read failed: locked')), \
mock.patch.object(server.time, 'sleep'):
status, payload = server.h_recon_scan_download_csv(self._ctx(('1',)))
self.assertEqual(status, 503)
status, payload = server.h_recon_scan_download_html(self._ctx(('1',)))
self.assertEqual(status, 503)
class GpsTest(unittest.TestCase):
def setUp(self):
server._gps_cache.update({'updated': 0, 'data': None})
@unittest.skipIf(os.name == 'nt', 'symlinks are not reliably available on Windows')
def test_serial_candidates_detect_bypath_targets(self):
d = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, d)
self.addCleanup(setattr, server, 'SERIAL_DIR', server.SERIAL_DIR)
server.SERIAL_DIR = d
os.symlink('/dev/ttyACM0', os.path.join(d, '1.3_1-1.3:1.0'))
os.symlink('/dev/ttyACM1', os.path.join(d, '1.3_1-1.3:1.2'))
with open(os.path.join(d, 'not-a-serial'), 'w') as f:
f.write('x')
candidates = server._gps_serial_candidates()
names = [name for name, _ in candidates]
self.assertEqual(names, ['1.3_1-1.3:1.0', '1.3_1-1.3:1.2'])
def test_gps_status_passthrough(self):
with mock.patch.object(server, '_gps_status_data_nocache',
return_value={'present': True, 'wigle': True}):
status, data = server.h_recon_gps(type('C', (), {'args': ()})())
self.assertEqual(status, 200)
self.assertTrue(data['present'])
self.assertTrue(data['wigle'])
def test_configure_binds_preferred_device_and_locks(self):
candidates = [('1.2_1-1.2:1.0', '/dev/1.2'), ('1.3_2-1.3:1.0', '/dev/1.3')]
with mock.patch.object(server, '_gps_serial_candidates', return_value=candidates), \
mock.patch.object(server, '_uci_gps_get', return_value='1.3_2-1.3:1.0'), \
mock.patch.object(server, '_uci_gps_set') as uci_set, \
mock.patch.object(server, '_gpsd_restart'), \
mock.patch.object(server, 'time', mock.Mock(sleep=lambda s: None)), \
mock.patch.object(server, '_gps_from_gpspipe',
return_value={'fix': 3, 'lat': 37.7, 'lon': -122.4, 'satellites': 8}), \
mock.patch.object(server, '_gps_status_data_nocache', return_value={'present': True}):
status, data = server.h_recon_gps_configure(type('C', (), {'args': ()})())
self.assertEqual(status, 200)
self.assertTrue(data['lock'])
self.assertEqual(data['tried'], ['1.3_2-1.3:1.0'])
uci_set.assert_called_once_with('1.3_2-1.3:1.0')
def test_configure_no_candidates_errors(self):
with mock.patch.object(server, '_gps_serial_candidates', return_value=[]):
status, data = server.h_recon_gps_configure(type('C', (), {'args': ()})())
self.assertEqual(status, 200)
self.assertIn('error', data)
def test_configure_fallback_binds_first_with_note(self):
candidates = [('1.2_1-1.2:1.0', '/dev/1.2'), ('1.3_2-1.3:1.0', '/dev/1.3')]
with mock.patch.object(server, '_gps_serial_candidates', return_value=candidates), \
mock.patch.object(server, '_uci_gps_get', return_value=None), \
mock.patch.object(server, '_uci_gps_set'), \
mock.patch.object(server, '_gpsd_restart'), \
mock.patch.object(server, 'time', mock.Mock(sleep=lambda s: None)), \
mock.patch.object(server, '_gps_from_gpspipe', return_value=None), \
mock.patch.object(server, '_gps_status_data_nocache', return_value={'present': True}):
status, data = server.h_recon_gps_configure(type('C', (), {'args': ()})())
self.assertEqual(status, 200)
self.assertEqual(data['device'], '1.2_1-1.2:1.0')
self.assertIn('waiting for a fix', data['note'])
def test_configure_tries_at_most_three_candidates(self):
candidates = [(str(i), '/dev/%d' % i) for i in range(5)]
with mock.patch.object(server, '_gps_serial_candidates', return_value=candidates), \
mock.patch.object(server, '_uci_gps_get', return_value=None), \
mock.patch.object(server, '_uci_gps_set'), \
mock.patch.object(server, '_gpsd_restart'), \
mock.patch.object(server, 'time', mock.Mock(sleep=lambda s: None)), \
mock.patch.object(server, '_gps_from_gpspipe', return_value=None), \
mock.patch.object(server, '_gps_status_data_nocache', return_value={'present': True}):
status, data = server.h_recon_gps_configure(type('C', (), {'args': ()})())
self.assertEqual(status, 200)
self.assertEqual(data['tried'], ['0', '1', '2'])
class WigleTest(unittest.TestCase):
def setUp(self):
self.dir = tempfile.mkdtemp()
self._orig = server.WIGLE_DIR
server.WIGLE_DIR = self.dir
def tearDown(self):
server.WIGLE_DIR = self._orig
shutil.rmtree(self.dir)
def _ctx(self, args=(), body=None):
return type('C', (), {'args': args, 'body': body or {}})()
def _write(self, name, content):
with open(os.path.join(self.dir, name), 'w') as f:
f.write(content)
def test_file_rows_count_excludes_header(self):
self._write('a.csv', 'header\nr1\nr2\n')
self._write('b.csv', 'onlyheader\n')
status, data = server.h_recon_wigle_files(self._ctx())
self.assertEqual(status, 200)
files = {f['name']: f for f in data['files']}
self.assertEqual(files['a.csv']['rows'], 2)
self.assertEqual(files['b.csv']['rows'], 0)
self.assertEqual(files['a.csv']['size'], len('header\nr1\nr2\n'))
def test_file_rows_count_ignores_wigle_meta_and_header(self):
meta = 'WigleWifi-1.6,appRelease=0.0.0,model=pineapplepager,release=0.0.0\n'
header = 'MAC,SSID,AuthMode,FirstSeen,Channel,Frequency,RSSI,CurrentLatitude,CurrentLongitude\n'
self._write('empty.csv', meta + header)
self._write('full.csv', meta + header + 'AA:BB:CC:DD:EE:FF,test,0,,1,2412,-60,37.7,-122.4\n')
status, data = server.h_recon_wigle_files(self._ctx())
files = {f['name']: f for f in data['files']}
self.assertEqual(files['empty.csv']['rows'], 0)
self.assertEqual(files['full.csv']['rows'], 1)
def test_file_download(self):
self._write('wigle-1.csv', 'lat,lon\n37.7,-122.4\n')
status, payload = server.h_recon_wigle_file(self._ctx(('wigle-1.csv',)))
self.assertEqual(status, 200)
self.assertEqual(payload.filename, 'wigle-1.csv')
self.assertIn(b'37.7', payload.data)
def test_file_download_404_and_traversal(self):
status, payload = server.h_recon_wigle_file(self._ctx(('missing.csv',)))
self.assertEqual(status, 404)
status, payload = server.h_recon_wigle_file(self._ctx(('..%2F..%2Fetc%2Fpasswd',)))
self.assertEqual(status, 404)
def test_toggle_enable_and_disable(self):
with mock.patch.object(server, '_wigle_set', return_value=(200, {'ok': True})), \
mock.patch.object(server, 'hak5') as hak5, \
mock.patch.object(server, 'wigle_files_data',
return_value={'files': [{'name': 'w.csv'}]}):
status, data = server.h_recon_wigle(self._ctx(body={'enable': True}))
self.assertEqual(status, 200)
self.assertTrue(data['wigle'])
self.assertEqual(data['filename'], 'w.csv')
hak5.assert_called_once_with('WIGLE_START', timeout=10)
status, data = server.h_recon_wigle(self._ctx(body={'enable': False}))
self.assertEqual(status, 200)
self.assertFalse(data['wigle'])
hak5.assert_called_with('WIGLE_STOP', timeout=10)
class SurveyTest(unittest.TestCase):
def setUp(self):
self.db = make_survey_db()
server.RECON_DB = self.db
self.dir = tempfile.mkdtemp()
self._orig = {
'SURVEY_DIR': server.SURVEY_DIR,
'SURVEY_MAX_SAMPLES': server.SURVEY_MAX_SAMPLES,
'SURVEY_SAMPLE_INTERVAL': server.SURVEY_SAMPLE_INTERVAL,
}
server.SURVEY_DIR = self.dir
server.SURVEY_MAX_SAMPLES = 3
server.SURVEY_SAMPLE_INTERVAL = 0.0
server._survey_state = {'active': False, 'id': None, 'name': None, 'path': None,
'started': 0, 'samples': 0, 'last_sample': 0}
server._gps_cache.update({'updated': 0, 'data': None})
def tearDown(self):
server.SURVEY_DIR = self._orig['SURVEY_DIR']
server.SURVEY_MAX_SAMPLES = self._orig['SURVEY_MAX_SAMPLES']
server.SURVEY_SAMPLE_INTERVAL = self._orig['SURVEY_SAMPLE_INTERVAL']
server._survey_state = {'active': False, 'id': None, 'name': None, 'path': None,
'started': 0, 'samples': 0, 'last_sample': 0}
server._gps_cache.update({'updated': 0, 'data': None})
shutil.rmtree(self.dir)
os.unlink(self.db)
def _ctx(self, args=(), body=None):
return type('C', (), {'args': args, 'body': body or {}})()
def test_start_creates_meta_file(self):
status, data = server.h_recon_survey_start(self._ctx(body={'name': 'Kitchen Walk'}))
self.assertEqual(status, 200)
self.assertTrue(data['ok'])
path = os.path.join(self.dir, data['id'] + '.jsonl')
self.assertTrue(os.path.isfile(path))
with open(path) as f:
first = f.readline()
self.assertIn('"meta"', first)
self.assertIn('Kitchen Walk', first)
def test_start_rejects_duplicate(self):
server.h_recon_survey_start(self._ctx(body={'name': 'A'}))
status, data = server.h_recon_survey_start(self._ctx(body={'name': 'B'}))
self.assertEqual(status, 409)
def test_sample_via_watchdog_and_cap(self):
server.h_recon_survey_start(self._ctx(body={'name': 'A'}))
server._recon_watchdog_tick()
server._recon_watchdog_tick()
self.assertEqual(server._survey_state['samples'], 2)
status, data = server.h_recon_survey_stop(self._ctx())
self.assertEqual(status, 200)
self.assertEqual(data['samples'], 2)
self.assertFalse(server._survey_state['active'])
def test_sample_cap_stops_recording(self):
server.SURVEY_MAX_SAMPLES = 2
server.h_recon_survey_start(self._ctx(body={'name': 'A'}))
for _ in range(4):
server._recon_watchdog_tick()
self.assertFalse(server._survey_state['active'])
self.assertEqual(server._survey_state['samples'], 2)
def test_live_reports_scan_unassociated_and_recording(self):
server.h_recon_survey_start(self._ctx(body={'name': 'A'}))
server._recon_watchdog_tick()
status, data = server.h_recon_survey_live(self._ctx())
self.assertEqual(status, 200)
self.assertEqual(data['scan']['id'], 1)
self.assertEqual(len(data['aps']), 2)
self.assertEqual(data['unassociated'], 1)
self.assertTrue(data['recording']['active'])
self.assertEqual(data['recording']['samples'], 1)
self.assertIn('wigle', data['gps'])
def test_live_recording_none_when_stopped(self):
status, data = server.h_recon_survey_live(self._ctx())
self.assertEqual(status, 200)
self.assertIsNone(data['recording'])
def test_surveys_list_counts_samples(self):
server.h_recon_survey_start(self._ctx(body={'name': 'A'}))
server._recon_watchdog_tick()
server.h_recon_survey_stop(self._ctx())
status, data = server.h_recon_surveys(self._ctx())
self.assertEqual(status, 200)
self.assertEqual(len(data['surveys']), 1)
survey = data['surveys'][0]
self.assertEqual(survey['name'], 'A')
self.assertEqual(survey['samples'], 1)
self.assertGreater(survey['size'], 0)
def test_detail_aggregates_signal(self):
server.h_recon_survey_start(self._ctx(body={'name': 'A'}))
server._recon_watchdog_tick()
server._recon_watchdog_tick()
sid = server._survey_state['id']
server.h_recon_survey_stop(self._ctx())
status, data = server.h_recon_survey_detail(self._ctx((sid,)))
self.assertEqual(status, 200)
aps = {a['bssid']: a for a in data['aps']}
a = aps['C8:9E:43:64:80:80']
self.assertEqual(a['min'], -76)
self.assertEqual(a['max'], -76)
self.assertEqual(a['avg'], -76)
self.assertEqual(a['samples'], 2)
self.assertEqual(a['band'], '5')
self.assertEqual(a['channel'], 149)
self.assertEqual(a['first_seen'], a['last_seen'])
self.assertEqual(data['gps_fixes'], 0)
def test_downloads_all_formats(self):
server.h_recon_survey_start(self._ctx(body={'name': 'A'}))
server._recon_watchdog_tick()
sid = server._survey_state['id']
server.h_recon_survey_stop(self._ctx())
for fmt, ctype in [('json', 'application/json'), ('csv', 'text/csv'), ('html', 'text/html')]:
status, payload = server.h_recon_survey_download(self._ctx((sid, fmt)))
self.assertEqual(status, 200)
self.assertEqual(payload.ctype, ctype)
self.assertEqual(payload.filename, 'survey-%s.%s' % (sid, fmt))
status, payload = server.h_recon_survey_download(self._ctx((sid, 'csv')))
self.assertIn('C8:9E:43:64:80:80', payload.data.decode('utf-8'))
def test_delete_removes_file_then_404(self):
server.h_recon_survey_start(self._ctx(body={'name': 'A'}))
sid = server._survey_state['id']
server.h_recon_survey_stop(self._ctx())
status, data = server.h_recon_survey_delete(self._ctx((sid,)))
self.assertEqual(status, 200)
self.assertFalse(os.listdir(self.dir))
status, data = server.h_recon_survey_delete(self._ctx((sid,)))
self.assertEqual(status, 404)
def test_delete_blocks_active_survey(self):
server.h_recon_survey_start(self._ctx(body={'name': 'A'}))
sid = server._survey_state['id']
status, data = server.h_recon_survey_delete(self._ctx((sid,)))
self.assertEqual(status, 409)
def test_detail_404_missing(self):
status, data = server.h_recon_survey_detail(self._ctx(('nope',)))
self.assertEqual(status, 404)
class ReconRoutesTest(unittest.TestCase):
def test_new_routes_registered(self):
expected = [
('GET', '/api/recon/scans/1/download/csv', 'h_recon_scan_download_csv'),
('GET', '/api/recon/scans/1/download/html', 'h_recon_scan_download_html'),
('GET', '/api/recon/gps', 'h_recon_gps'),
('POST', '/api/recon/gps/configure', 'h_recon_gps_configure'),
('POST', '/api/recon/wigle', 'h_recon_wigle'),
('GET', '/api/recon/wigle/files', 'h_recon_wigle_files'),
('GET', '/api/recon/wigle/files/x.csv', 'h_recon_wigle_file'),
('GET', '/api/recon/survey/live', 'h_recon_survey_live'),
('POST', '/api/recon/survey/start', 'h_recon_survey_start'),
('POST', '/api/recon/survey/stop', 'h_recon_survey_stop'),
('GET', '/api/recon/surveys', 'h_recon_surveys'),
('GET', '/api/recon/surveys/20260818-120000-A', 'h_recon_survey_detail'),
('GET', '/api/recon/surveys/20260818-120000-A/download/csv', 'h_recon_survey_download'),
('GET', '/api/recon/surveys/20260818-120000-A/download/json', 'h_recon_survey_download'),
('GET', '/api/recon/surveys/20260818-120000-A/download/html', 'h_recon_survey_download'),
('DELETE', '/api/recon/surveys/20260818-120000-A', 'h_recon_survey_delete'),
]
for method, path, handler in expected:
h, args = server.ROUTER.dispatch(method, path)
self.assertIsNotNone(h, '%s %s' % (method, path))
self.assertEqual(h.__name__, handler, '%s %s' % (method, path))
def test_survey_download_route_captures_format(self):
h, args = server.ROUTER.dispatch('GET', '/api/recon/surveys/abc/download/csv')
self.assertEqual(args, ('abc', 'csv'))
def test_original_recon_routes_unchanged(self):
for path in ['/api/recon/start', '/api/recon/status', '/api/recon/scans',
'/api/recon/events']:
method = 'GET' if path.endswith(('status', 'scans', 'events')) else 'POST'
h, args = server.ROUTER.dispatch(method, path)
self.assertIsNotNone(h, path)