Compare commits
18
Commits
52f9f6ecd5
...
86d26d8457
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86d26d8457 | ||
|
|
883a839692 | ||
|
|
cd54402892 | ||
|
|
21e308386f | ||
|
|
a67faecee9 | ||
|
|
2c9fa8d137 | ||
|
|
797db1816a | ||
|
|
d149cb13fd | ||
|
|
922c3ec4ae | ||
|
|
377cc83060 | ||
|
|
00cbc52254 | ||
|
|
7d48b7ad06 | ||
|
|
cd26553d21 | ||
|
|
6e3968c19a | ||
|
|
5a72566381 | ||
|
|
5eb81b90f1 | ||
|
|
904843307e | ||
|
|
37b5e821dd |
@@ -0,0 +1,143 @@
|
||||
# Task 1 Report: Cached OUI Identity Resolution
|
||||
|
||||
## Status
|
||||
|
||||
Implemented and verified cached local OUI identity resolution while preserving the existing `oui_vendor()` behavior and unrelated unstaged HTML report changes.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [x] Added failing resolver tests before production changes.
|
||||
- [x] Verified the tests failed because `OUI_DATA_PATHS` and `oui_identity()` were absent.
|
||||
- [x] Added Nmap and macchanger data paths.
|
||||
- [x] Added a lazy process-level identity cache.
|
||||
- [x] Added local database parsing for plain and separated OUI prefixes.
|
||||
- [x] Preserved Nmap precedence over macchanger and built-in fallback.
|
||||
- [x] Added local/randomized and unknown identities.
|
||||
- [x] Returned JSON-safe dictionaries with `manufacturer`, `model`, `oui`, and `source`.
|
||||
- [x] Kept `model` as `None` for every source.
|
||||
- [x] Preserved `oui_vendor()` behavior.
|
||||
- [x] Ran focused and regression tests.
|
||||
- [x] Self-reviewed the diff and checked whitespace.
|
||||
|
||||
## TDD Evidence
|
||||
|
||||
Initial command:
|
||||
|
||||
```text
|
||||
python3 -m unittest tests.test_recon.OuiVendorTest -v
|
||||
```
|
||||
|
||||
Initial result before implementation:
|
||||
|
||||
```text
|
||||
Ran 8 tests in 0.005s
|
||||
|
||||
FAILED (errors=3)
|
||||
```
|
||||
|
||||
All three new identity tests errored at `mock.patch.object(server, 'OUI_DATA_PATHS', ...)` with:
|
||||
|
||||
```text
|
||||
AttributeError: <module 'server' ...> does not have the attribute 'OUI_DATA_PATHS'
|
||||
```
|
||||
|
||||
After the minimal implementation, the focused resolver test result was:
|
||||
|
||||
```text
|
||||
python3 -m unittest tests.test_recon.OuiVendorTest -v
|
||||
|
||||
Ran 9 tests in 0.005s
|
||||
|
||||
OK
|
||||
```
|
||||
|
||||
## Final Verification
|
||||
|
||||
Command:
|
||||
|
||||
```text
|
||||
python3 -m unittest tests.test_recon.DecodersTest -v
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```text
|
||||
test_decode_encryption_cases (tests.test_recon.DecodersTest.test_decode_encryption_cases) ... ok
|
||||
test_decode_ssid_bytes_and_str (tests.test_recon.DecodersTest.test_decode_ssid_bytes_and_str) ... ok
|
||||
test_decode_ssid_cli_escapes (tests.test_recon.DecodersTest.test_decode_ssid_cli_escapes) ... ok
|
||||
test_fmt_mac_colon_form (tests.test_recon.DecodersTest.test_fmt_mac_colon_form) ... ok
|
||||
test_fmt_mac_noop (tests.test_recon.DecodersTest.test_fmt_mac_noop) ... ok
|
||||
test_norm_mac_12hex (tests.test_recon.DecodersTest.test_norm_mac_12hex) ... ok
|
||||
|
||||
----------------------------------------------------------------------
|
||||
Ran 6 tests in 0.005s
|
||||
|
||||
OK
|
||||
```
|
||||
|
||||
Command:
|
||||
|
||||
```text
|
||||
python3 -m unittest tests.test_recon -v
|
||||
```
|
||||
|
||||
Output summary:
|
||||
|
||||
```text
|
||||
----------------------------------------------------------------------
|
||||
Ran 119 tests in 0.681s
|
||||
|
||||
OK
|
||||
```
|
||||
|
||||
All 119 individual tests printed `... ok`; there were no failures, errors, or warnings.
|
||||
|
||||
Command:
|
||||
|
||||
```text
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Output: no output; exit status 0.
|
||||
|
||||
## Self-Review
|
||||
|
||||
- Local/randomized detection occurs before cache loading, avoiding unnecessary file access.
|
||||
- Cache precedence follows path order and never overwrites an earlier prefix.
|
||||
- Missing files are skipped without masking parsing or programming errors.
|
||||
- Unknown and invalid MACs return JSON-safe values.
|
||||
- `AA` was not used as the unknown global test prefix because its locally administered bit is set; `AC` correctly represents a globally administered unknown prefix.
|
||||
- Existing unstaged HTML signal-ordering changes in `server.py` and `tests/test_recon.py` are intentionally excluded from the Task 1 commit.
|
||||
|
||||
## Concerns
|
||||
|
||||
None. Database source labels rely on the specified two-entry `OUI_DATA_PATHS` ordering.
|
||||
|
||||
## Live Path Correction
|
||||
|
||||
The committed Task 1 diff used `/usr/share/macchanger/OUI.list`, but the design
|
||||
specification and live macchanger package use
|
||||
`/usr/share/macchanger/wireless.list`. Updated `OUI_DATA_PATHS` to the required
|
||||
live path. Resolver tests patch `OUI_DATA_PATHS`, so no test changes were needed.
|
||||
|
||||
Verification after the correction:
|
||||
|
||||
```text
|
||||
python3 -m unittest tests.test_recon.OuiVendorTest -v
|
||||
|
||||
Ran 9 tests in 0.005s
|
||||
|
||||
OK
|
||||
```
|
||||
|
||||
```text
|
||||
python3 -m unittest tests.test_recon -v
|
||||
|
||||
Ran 119 tests in 0.498s
|
||||
|
||||
OK
|
||||
```
|
||||
|
||||
The unrelated unstaged HTML signal-ordering changes in `server.py` and
|
||||
`tests/test_recon.py`, plus untracked loot and certificate files, remain
|
||||
excluded from this correction.
|
||||
@@ -0,0 +1,25 @@
|
||||
# Task 2 Report: Enrich Scan Associations and APs
|
||||
|
||||
## Status
|
||||
|
||||
Implemented and verified. The Task 2 changes are committed as `feat: associate recon clients with confirmed networks`.
|
||||
|
||||
## Changes
|
||||
|
||||
- Added scan-scoped handshake and optional `hostap_client` evidence queries.
|
||||
- Added deterministic association deduplication keyed by client, BSSID, and SSID.
|
||||
- Kept directed probe (`ssid.type = 5`) records out of associations.
|
||||
- Added AP `device_identity`, `clients`, and unique `client_count` fields.
|
||||
- Added client `vendor` and `associations` fields, including empty associations for unassociated clients.
|
||||
- Preserved SSID-only `hostap_client` evidence without assigning a BSSID.
|
||||
- Added fixture coverage for duplicate evidence, a second AP/client pair, directed probes, missing optional tables, identity fields, and timestamps.
|
||||
|
||||
## Verification
|
||||
|
||||
- Focused tests: `python3 -m unittest tests.test_recon.ReconDataTest -v` -> 7/7 passed.
|
||||
- Complete Recon tests: `python3 -m unittest tests.test_recon -v` -> 121/121 passed.
|
||||
- Diff validation: `git diff --check` -> clean.
|
||||
|
||||
## Concerns
|
||||
|
||||
- The working tree contains pre-existing unstaged HTML report signal-order changes and untracked `loot/` and certificate files; these were intentionally preserved and excluded from the Task 2 commit.
|
||||
@@ -16,7 +16,7 @@ terminal.
|
||||
## Requirements
|
||||
|
||||
- WiFi Pineapple Pager, firmware `Pineapple Pager 24.10.1`
|
||||
- `python3` on the device (present on current firmware)
|
||||
- `python3` on the device (factory 24.10.1 may not ship it; `scripts/deploy.sh` installs OpenWrt `python3-light` from offline ipks)
|
||||
- Python 3.11 on the development machine
|
||||
|
||||
## Install (sideload)
|
||||
@@ -51,6 +51,15 @@ Then on the Pager menu, run **Mark VIII**:
|
||||
- Re-run the payload while running to **Stop** the service.
|
||||
- `PAYLOAD_GET_CONFIG pager_webui auto_mode/run_mode` skip the prompt.
|
||||
|
||||
Every payload run (and every service startup) first runs an **environment
|
||||
check** that prints on the payload screen / `/tmp/pagerwebui.log`: daemon
|
||||
reachable, pineapd alive, monitor interfaces up, and recon DB readable. On a
|
||||
healthy pager it **does not rewrite** live PineAP or wireless UCI (SSID pool,
|
||||
hopping, dummy_radio0 STA, Open AP). Crash-prone settings are reported as
|
||||
warnings. Startup aborts only if a core dependency fails. The health monitor
|
||||
will restart a dead pineapd and re-raise dropped monitors; it will not clear
|
||||
the SSID list or disable pool broadcast.
|
||||
|
||||
Browse `http://172.16.52.1:8080/` and log in with the device password.
|
||||
|
||||
## Uninstall / recovery
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
# Recon, Reports, Enterprise Certificates, and PineAP Stability Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Make Recon status truthful, enrich offline HTML reports with signal maps, move Handshakes under PineAP, add self-signed Evil Enterprise certificate generation, and contain known `pineapd` crash loops.
|
||||
|
||||
**Architecture:** Preserve the existing single-file Python backend and vanilla-JavaScript SPA conventions. Add small pure helpers around existing Recon, report, enterprise, and health boundaries, with backend-generated state replacing frontend inference. Implement each subsystem test-first and deploy only after the complete local suite passes.
|
||||
|
||||
**Tech Stack:** Python 3.11 standard library and device `python3-light`, BusyBox/OpenWrt commands, OpenSSL CLI, vanilla JavaScript, inline CSS/SVG, `unittest`.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Support Pineapple Pager firmware `24.10.1` and daemon SHA-256 `e2cf0453d7e7d2e08bf31a242289e480d44649923a5199eed954d73df0cf1da5`.
|
||||
- Do not add Python packages, frontend packages, a build system, remote assets, or inline report JavaScript.
|
||||
- Do not patch or replace `/usr/sbin/pineapd`.
|
||||
- Keep pool broadcast disabled on known affected and unknown builds.
|
||||
- Preserve existing authentication, same-origin checks, and API error conventions.
|
||||
- Use argv command lists, never shell interpolation, for user-supplied certificate fields.
|
||||
- Preserve unrelated worktree changes and do not stage `loot/` or untracked existing certificate files unless a task explicitly requires them.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Truthful Recon Coverage State
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/remote_access/pager-webui/server.py:66-79, 1335-1765`
|
||||
- Modify: `payload/user/remote_access/pager-webui/www/js/views.js:1680-1910, 2672-2783`
|
||||
- Test: `tests/test_recon.py:165-512`
|
||||
- Test: `tests/test_env_check.py:202-324`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `_recon_coverage(iface, active) -> dict` with `state`, `reason`, and `channel`.
|
||||
- Produces: scan-state keys `baseline_scan_id`, `scan_id`, `settling_until`, and `result_warning`.
|
||||
- Produces: `/api/recon/status` fields `coverage_24`, `scan_id`, and `result_warning`.
|
||||
- Consumes: existing `_recon_hopper_preflight`, `_iface_associated`, `_dummy_sta_borrowable`, `recon_scans_data`, and `_recon_scan_state` lock.
|
||||
|
||||
- [ ] **Step 1: Add failing backend coverage tests**
|
||||
|
||||
Add tests that assert an enabled but unassociated dummy STA is not diagnosed as starvation, a successfully borrowed dummy reports `full`, a failed busy probe reports `current_channel_only` or `pinned` with its observed reason, and hopper interfaces are empty after loop cleanup. Mock `_iface_associated`, `_iface_admin_up`, `_run`, and channel state so each test controls runtime evidence rather than UCI alone.
|
||||
|
||||
- [ ] **Step 2: Run the focused coverage tests and verify failure**
|
||||
|
||||
Run: `python3 -m unittest tests.test_recon.ReconHopperTest tests.test_env_check.EnvCheckTest -v`
|
||||
|
||||
Expected: new assertions fail because status has no `coverage_24` object and completed hopper state retains interfaces.
|
||||
|
||||
- [ ] **Step 3: Implement backend coverage state**
|
||||
|
||||
Add a pure status helper that reports `idle` when no scan is active, `full` only when `wlan0mon` is in the active hopper, `unavailable` when the monitor is absent/down, and otherwise uses the preflight skip reason/current channel to report `pinned` or `current_channel_only`. Store preflight per-interface outcomes in `_recon_hop_state`; do not derive the result solely from `_sta_uplink_enabled()`.
|
||||
|
||||
Clear `_recon_hop_state['ifaces']` in the hopper loop's `finally` block while preserving the last coverage reason long enough for final status polling.
|
||||
|
||||
- [ ] **Step 4: Add failing scan identity tests**
|
||||
|
||||
Add tests that mock the existing maximum scan ID before start, return a newer row after start, and assert status tracks that exact ID. Add a timeout test using a mocked clock that advances 15 seconds beyond duration and asserts `result_warning` is set instead of selecting the prior scan.
|
||||
|
||||
- [ ] **Step 5: Run scan-state tests and verify failure**
|
||||
|
||||
Run: `python3 -m unittest tests.test_recon.ReconScanStateTest -v`
|
||||
|
||||
Expected: failures because `_recon_scan_state` has no baseline/result identity or settlement state.
|
||||
|
||||
- [ ] **Step 6: Implement result association and settlement**
|
||||
|
||||
Before native start, query the maximum scan ID. After acceptance, store it as `baseline_scan_id`. During watchdog/status updates, query for the first row with a greater ID and set `scan_id`. At elapsed duration, allow a 15-second settlement window; after it expires without a row, set `result_warning` and end the run. Reset all identity fields on explicit reset/start.
|
||||
|
||||
- [ ] **Step 7: Update Recon frontend state**
|
||||
|
||||
Render backend `coverage_24` copy instead of the `wlan0Sta && !hop24` fallback. During auto-follow, select `status.scan_id` even when its counts are zero. Show completion only after a tracked result exists, or show the backend result warning after settlement failure. Keep existing styling and ASCII copy.
|
||||
|
||||
- [ ] **Step 8: Run Recon and environment tests**
|
||||
|
||||
Run: `python3 -m unittest tests.test_recon tests.test_env_check -v`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 9: Commit Recon correctness**
|
||||
|
||||
```bash
|
||||
git add payload/user/remote_access/pager-webui/server.py payload/user/remote_access/pager-webui/www/js/views.js tests/test_recon.py tests/test_env_check.py
|
||||
git commit -m "fix: report observed recon coverage and scan results"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Firmware-Gated pineapd Containment
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/remote_access/pager-webui/server.py:4432-4616, 4667-4814, 6982-6992`
|
||||
- Modify: `payload/user/remote_access/pager-webui/www/js/app.js:516-546`
|
||||
- Modify: `payload/user/remote_access/pager-webui/README.md:112-130`
|
||||
- Modify: `README.md:54-61, 112-130`
|
||||
- Test: `tests/test_health.py`
|
||||
- Test: `tests/test_env_check.py`
|
||||
- Test: `tests/test_robustness.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `_pineapd_build() -> dict` with `firmware`, `sha256`, and `policy` (`affected` or `unknown`).
|
||||
- Produces: `_reconcile_pineapd_safety() -> dict` listing changed keys and errors.
|
||||
- Produces: health-state keys `events`, `quarantined`, `stable_since`, `build`, and `last_reconcile`.
|
||||
- Produces: `GET /api/health/diagnostics` as a JSON download with no secrets.
|
||||
|
||||
- [ ] **Step 1: Add failing build-policy and reconciliation tests**
|
||||
|
||||
Test the exact affected firmware/hash pair, an unknown hash, the five required safe UCI values, no unrelated UCI writes, one commit only when values changed, and no command-socket ping. Assert unknown builds are reported but not automatically rewritten.
|
||||
|
||||
- [ ] **Step 2: Run focused tests and verify failure**
|
||||
|
||||
Run: `python3 -m unittest tests.test_health tests.test_env_check -v`
|
||||
|
||||
Expected: failures because build policy and production reconciliation do not exist.
|
||||
|
||||
- [ ] **Step 3: Implement build detection and safe reconciliation**
|
||||
|
||||
Read firmware through the existing release parser and hash `/usr/sbin/pineapd` with `sha256sum`. Match the exact affected tuple. On affected builds, compare and set only:
|
||||
|
||||
```text
|
||||
pineapd.@ssidpool[0].disable=1
|
||||
pineapd.wlan2mon.disable=1
|
||||
pineapd.wlan2mon.hop=0
|
||||
pineapd.wlan1mon.bands=5
|
||||
pineapd.wlan0mon.bands=2
|
||||
```
|
||||
|
||||
Commit `pineapd` once if needed. Invoke reconciliation before Mark VIII explicitly starts/restarts the daemon, not continuously while a healthy daemon is running.
|
||||
|
||||
- [ ] **Step 4: Add failing circuit-breaker tests**
|
||||
|
||||
Use a mocked clock and process/SIGSEGV samples to assert three crashes in two minutes enter quarantine, Mark VIII issues no further restart, five stable minutes clear quarantine, and explicit recovery reruns reconciliation. Add pool tests for preserving 64 entries/4096 bytes and quarantining then clearing data above either limit only during a correlated crash loop.
|
||||
|
||||
- [ ] **Step 5: Implement bounded health recovery**
|
||||
|
||||
Track timestamped PID/SIGSEGV events in a bounded in-memory list. Enter quarantine at three crashes in 120 seconds. Stop Mark VIII restart calls while quarantined. Clear after 300 stable seconds or explicit recovery. For an oversized pool during the threshold-crossing event, write a timestamped diagnostic copy under `/root/loot/pineapd-diagnostics/`, clear through the existing safe pool command, and make one reconciled restart attempt.
|
||||
|
||||
- [ ] **Step 6: Add diagnostics endpoint tests**
|
||||
|
||||
Assert the download contains firmware/hash, policy, relevant UCI, monitor state, pool metrics, bounded event history, and health actions. Assert it excludes certificate private-key contents, enterprise secrets, cookies, and daemon binary data.
|
||||
|
||||
- [ ] **Step 7: Implement diagnostics and UI health detail**
|
||||
|
||||
Register `GET /api/health/diagnostics`. Reuse `Download` with JSON content and attachment naming. Expand `/api/health` with build/quarantine/reconcile data and display concise affected/unknown/quarantined state in the existing top-bar health treatment.
|
||||
|
||||
- [ ] **Step 8: Correct stability documentation**
|
||||
|
||||
Document automatic affected-build reconciliation, unknown-build behavior, passive checks, thresholds, and diagnostic preservation. Remove claims that normal health checks always clear the pool.
|
||||
|
||||
- [ ] **Step 9: Run health, environment, and robustness tests**
|
||||
|
||||
Run: `python3 -m unittest tests.test_health tests.test_env_check tests.test_robustness -v`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 10: Commit containment changes**
|
||||
|
||||
```bash
|
||||
git add payload/user/remote_access/pager-webui/server.py payload/user/remote_access/pager-webui/www/js/app.js payload/user/remote_access/pager-webui/README.md README.md tests/test_health.py tests/test_env_check.py tests/test_robustness.py
|
||||
git commit -m "fix: contain affected pineapd crash loops"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Self-Contained Report Signal Maps
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/remote_access/pager-webui/server.py:1921-2174`
|
||||
- Test: `tests/test_recon.py:991-1191`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `_report_channel_maps(aps) -> str` containing zero or more escaped inline SVG sections.
|
||||
- Produces: `_report_channel_map(aps, band) -> str` for one of `2.4`, `5`, or `6`.
|
||||
- Consumes: AP dictionaries from `recon_scan_data` and existing `_esc_html`, `band_of`, and report CSS.
|
||||
|
||||
- [ ] **Step 1: Add failing report-map tests**
|
||||
|
||||
Add representative 2.4, 5, and 6 GHz APs and assert one SVG per populated band, expected labels, all four signal colors, raised-cosine path data, and escaped SSID/vendor metadata inside `<title>`. Assert no `<script>`, remote URL, or malformed SVG for missing channel/frequency.
|
||||
|
||||
- [ ] **Step 2: Run report tests and verify failure**
|
||||
|
||||
Run: `python3 -m unittest tests.test_recon.ReconReportTest tests.test_recon.ReconArchivesTest -v`
|
||||
|
||||
Expected: failures because reports contain occupancy tables but no signal-map SVG.
|
||||
|
||||
- [ ] **Step 3: Implement pure SVG helpers**
|
||||
|
||||
Port the existing channel-to-frequency, band-range, -100/-30 dBm scaling, +/-10 MHz width, and raised-cosine sampling math to Python. Clamp signal values to the graph range. Generate deterministic `viewBox` SVG with axes, labels, paths, and escaped titles. Omit bands with no plottable APs.
|
||||
|
||||
- [ ] **Step 4: Integrate maps and print styling**
|
||||
|
||||
Add responsive `.signal-map`, `.map-grid`, axis, and print CSS to `REPORT_CSS`. Insert maps after stat cards and before breakdown tables in `_recon_html_download`. Label live GPS as a report-generation-time fix and preserve archive omission.
|
||||
|
||||
- [ ] **Step 5: Run report tests**
|
||||
|
||||
Run: `python3 -m unittest tests.test_recon.ReconReportTest tests.test_recon.ReconArchivesTest -v`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit report maps**
|
||||
|
||||
```bash
|
||||
git add payload/user/remote_access/pager-webui/server.py tests/test_recon.py
|
||||
git commit -m "feat: add signal maps to HTML recon reports"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Move Handshakes Under PineAP
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/remote_access/pager-webui/www/js/app.js:94-99, 411-436`
|
||||
- Modify: `payload/user/remote_access/pager-webui/www/js/views.js:330-363, 944-948, 1747-1750, 2905-2908`
|
||||
- Test: `tests/test_recon.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: canonical route `#/pineap/handshakes` rendering `views.recon_handshakes` within `pineapShell`.
|
||||
- Preserves: redirect `#/recon/handshakes` to the canonical route.
|
||||
|
||||
- [ ] **Step 1: Add failing static route tests**
|
||||
|
||||
Assert `PINEAP_TABS` contains `#/pineap/handshakes`, `RECON_TABS` does not contain Handshakes, the router registers the canonical route, the legacy redirect exists, and the Recon card links to the canonical route.
|
||||
|
||||
- [ ] **Step 2: Run route tests and verify failure**
|
||||
|
||||
Run: `python3 -m unittest tests.test_recon -v`
|
||||
|
||||
Expected: new navigation assertions fail.
|
||||
|
||||
- [ ] **Step 3: Move the route and shell**
|
||||
|
||||
Add Handshakes to `PINEAP_TABS`, remove it from `RECON_TABS`, register `#/pineap/handshakes`, redirect the old hash, update the summary-card link, and render the existing Handshakes body inside `pineapShell(root, '#/pineap/handshakes')` without changing data APIs.
|
||||
|
||||
- [ ] **Step 4: Run Recon tests**
|
||||
|
||||
Run: `python3 -m unittest tests.test_recon -v`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit navigation**
|
||||
|
||||
```bash
|
||||
git add payload/user/remote_access/pager-webui/www/js/app.js payload/user/remote_access/pager-webui/www/js/views.js tests/test_recon.py
|
||||
git commit -m "ui: move handshakes into PineAP"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Evil Enterprise Self-Signed Certificates
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/remote_access/pager-webui/server.py:3462-4151, 6673-6683`
|
||||
- Modify: `payload/user/remote_access/pager-webui/www/js/views.js:1386-1628`
|
||||
- Modify: `payload/user/remote_access/pager-webui/www/css/app.css:501-620`
|
||||
- Modify: `payload/user/remote_access/pager-webui/pagerwebui.init:15-25`
|
||||
- Test: `tests/test_attacks.py:204-319, 434-455`
|
||||
- Test: `tests/test_pineap_enterprise.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `_validate_cert_request(fields) -> dict` normalized subject/SAN/validity data or raises `ValueError`.
|
||||
- Produces: `_ent_cert_status() -> dict` with validity, subject, SANs, fingerprint, dates, key type/size, and key-match state.
|
||||
- Produces: `_generate_ent_certificate(fields) -> dict` with install/restart/rollback result.
|
||||
- Produces: `GET /api/pineap/enterprise/certificate` and `POST /api/pineap/enterprise/certificate/generate`.
|
||||
|
||||
- [ ] **Step 1: Add failing validation and status tests**
|
||||
|
||||
Test country length, control-character rejection, 128-byte subject limits, required common name, 1-3650 validity, no more than 20 SANs, ASCII DNS syntax/253-character limit, and normalization of comma/newline-separated SAN input. Mock OpenSSL status commands and assert private-key content is never returned.
|
||||
|
||||
- [ ] **Step 2: Run enterprise tests and verify failure**
|
||||
|
||||
Run: `python3 -m unittest tests.test_attacks tests.test_pineap_enterprise -v`
|
||||
|
||||
Expected: failures because certificate request/status helpers and routes do not exist.
|
||||
|
||||
- [ ] **Step 3: Implement validation and certificate inspection**
|
||||
|
||||
Use strict subject-field validation and DNS-label parsing. Inspect certificate metadata with bounded OpenSSL argv calls. Compare certificate/key public-key digests without exposing them through the API. Return clear invalid/missing state rather than treating nonempty files as valid.
|
||||
|
||||
- [ ] **Step 4: Add failing atomic generation tests**
|
||||
|
||||
Mock temporary-directory creation and OpenSSL to assert `req -x509 -newkey rsa:2048 -sha256 -nodes`, SAN/serverAuth extensions, mode `0600` for key, `0644` for certificate, validation before replacement, backup restoration on failure, and cleanup of temporary files.
|
||||
|
||||
- [ ] **Step 5: Implement atomic generation**
|
||||
|
||||
Generate under a mode-`0700` directory inside `/root/loot/enterprise`. Validate parseability, current validity, key match, subject, and SANs. Back up active files, atomically replace them, and restore backups on install failure. Replace `_ensure_ent_certs` shared-bundle copying with generation through this path when valid active files are absent.
|
||||
|
||||
- [ ] **Step 6: Add failing active-restart and rollback tests**
|
||||
|
||||
When `_ent_running()` is true, assert the existing deployment state is copied, the new certificate is installed, `_deploy_enterprise` is invoked with unchanged settings, and success requires enabled hostapd. Force deployment failure and assert old certificate restoration plus one previous-state redeploy attempt; include rollback outcome in HTTP 502 data.
|
||||
|
||||
- [ ] **Step 7: Implement serialized restart and rollback**
|
||||
|
||||
Use the attack/deploy lock for certificate generation. Keep old certificate backups until redeploy succeeds. On failure, stop partial enterprise state, restore files, and attempt previous deployment exactly once. Do not recursively call certificate generation from deployment fallback.
|
||||
|
||||
- [ ] **Step 8: Add certificate APIs and matching UI card**
|
||||
|
||||
Register authenticated GET/status and POST/generate routes. Add an Evil Enterprise card using existing `.settings-form-grid`, `.pineap-infobox`, `runAction`, and button patterns. Include the requested fields, installed metadata, generation busy state, and a warning that active Enterprise will restart automatically. Refresh attack and certificate status after completion.
|
||||
|
||||
- [ ] **Step 9: Fix secret permissions**
|
||||
|
||||
Replace `chmod -R 755 "$PAGER_WEBUI_DIR"` with directory/file-specific safe modes that preserve executable scripts while keeping bundled/runtime keys `0600`. Explicitly chmod runtime `server.key`, `eap_users`, and enterprise state to `0600` after writes.
|
||||
|
||||
- [ ] **Step 10: Run enterprise tests**
|
||||
|
||||
Run: `python3 -m unittest tests.test_attacks tests.test_pineap_enterprise -v`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 11: Commit certificate flow**
|
||||
|
||||
```bash
|
||||
git add payload/user/remote_access/pager-webui/server.py payload/user/remote_access/pager-webui/www/js/views.js payload/user/remote_access/pager-webui/www/css/app.css payload/user/remote_access/pager-webui/pagerwebui.init tests/test_attacks.py tests/test_pineap_enterprise.py
|
||||
git commit -m "feat: generate Evil Enterprise certificates"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Integration Verification and Pager Deployment
|
||||
|
||||
**Files:**
|
||||
- Modify if required by verified defects: files from Tasks 1-5 only
|
||||
- Build artifact: `build/pager-webui/payload-cGFnZXItd2VidWk.zip`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: all completed task interfaces.
|
||||
- Produces: verified local suite, payload build, live Pager deployment, and browser/device evidence.
|
||||
|
||||
- [ ] **Step 1: Run every unit-test module in isolated processes**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
for f in tests/test_*.py; do python3 -m unittest "tests.$(basename "$f" .py)" -v || exit 1; done
|
||||
```
|
||||
|
||||
Expected: every module reports `OK`.
|
||||
|
||||
- [ ] **Step 2: Build the payload**
|
||||
|
||||
Run the repository's existing payload build/deploy script in its build-only or pre-upload stage. Verify the staged payload contains updated source and no `loot/`, temporary private keys, test caches, or unrelated untracked files.
|
||||
|
||||
- [ ] **Step 3: Deploy to the authorized Pager**
|
||||
|
||||
Use `scripts/deploy.sh --password 'Bryce9205'` against `root@172.16.52.1`. Record whether safe UCI reconciliation changes the five affected keys. Do not enable pool broadcast.
|
||||
|
||||
- [ ] **Step 4: Verify daemon and APIs**
|
||||
|
||||
Confirm `pidof pineapd`, `/api/health`, affected build/hash, non-quarantined status, safe UCI values, both monitor interfaces expected by hardware, and diagnostic download. Watch logs long enough to cover multiple historical 15-second crash intervals.
|
||||
|
||||
- [ ] **Step 5: Verify Recon behavior**
|
||||
|
||||
Run a controlled timed scan with enabled but unassociated `dummy_radio0`; verify no false starvation message, tracked scan ID, and 2.4 GHz results. Then use an existing legitimate AP/STA pin scenario and confirm channel-limited copy is accurate.
|
||||
|
||||
- [ ] **Step 6: Verify reports and navigation in browser**
|
||||
|
||||
At desktop and mobile viewports, verify Recon has Scanning/Reports only, PineAP has Handshakes, the old hash redirects, and generated live/archive HTML reports contain printable maps for all available bands.
|
||||
|
||||
- [ ] **Step 7: Verify certificate generation and restart**
|
||||
|
||||
Generate a certificate with representative attributes, inspect it with `openssl x509 -text`, verify file modes, deploy Enterprise, generate a second certificate while active, and confirm automatic restart uses the new fingerprint. Exercise a controlled mocked/local failure for rollback if forcing hostapd failure on the live device would risk connectivity.
|
||||
|
||||
- [ ] **Step 8: Review final diff and status**
|
||||
|
||||
Run `git status --short`, `git diff --check`, and inspect the cumulative diff from `00cbc52`. Confirm `loot/` and pre-existing untracked `certs/` remain unstaged.
|
||||
|
||||
- [ ] **Step 9: Commit only verified integration fixes if any**
|
||||
|
||||
If verification required code changes, stage only those files and commit with a focused message. Do not create an empty commit.
|
||||
@@ -0,0 +1,281 @@
|
||||
# Recon Client Associations and Device Identity Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add confirmed client-to-SSID/AP associations and best-available manufacturer identity to Recon APIs, tables, focus details, exports, and reports.
|
||||
|
||||
**Architecture:** Keep `recon_scan_data()` as the canonical enrichment boundary. Add cached local OUI resolution and scan-scoped evidence joins in `server.py`, then consume the enriched object shape in existing JSON/CSV/HTML serializers and `views.recon`. Handshake evidence may resolve BSSID; `hostap_client` evidence remains SSID-only because its schema has no BSSID.
|
||||
|
||||
**Tech Stack:** Python 3 standard library, SQLite read-only queries, vanilla JavaScript, existing test suite, no frontend build step.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Associations must never be inferred from proximity, channel, timing, or probe requests.
|
||||
- `ssid.type = 5` directed probes are not associations.
|
||||
- `hostap_client` rows create confirmed SSID-only associations and never claim an AP BSSID.
|
||||
- OUI resolution uses Nmap, macchanger, then the built-in map, then `Unknown`.
|
||||
- Missing OUI files or optional association data must not fail Recon.
|
||||
- No external network lookup or wireless/PineAP configuration change is allowed.
|
||||
- Preserve unrelated existing changes in `server.py`, `tests/test_recon.py`, `loot/`, and certificate files.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add Cached OUI Identity Resolution
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/remote_access/pager-webui/server.py` near `OUI_VENDORS`, `_oui_prefix`, and `oui_vendor`
|
||||
- Test: `tests/test_recon.py` in `DecodersTest`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces `oui_identity(mac)` returning a JSON-safe dictionary with `manufacturer`, `model`, `oui`, and `source`.
|
||||
- Keeps `oui_vendor(mac)` behavior compatible for existing callers.
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
Add tests that patch `server.OUI_DATA_PATHS` and `server.open`, reset the identity cache, and assert Nmap wins over macchanger and built-in fallback. Add tests for missing files, unknown global MACs, locally administered MACs, and `model is None`.
|
||||
|
||||
```python
|
||||
def test_oui_identity_prefers_nmap_then_macchanger(self):
|
||||
server._oui_identity_cache = None
|
||||
files = {
|
||||
'/nmap': 'C89E43 Apple Corporation\n',
|
||||
'/mac': 'C89E43 fallback\n',
|
||||
}
|
||||
with mock.patch.object(server, 'OUI_DATA_PATHS', ['/nmap', '/mac']), \
|
||||
mock.patch('builtins.open', side_effect=lambda p, *a, **k:
|
||||
mock.mock_open(read_data=files[p]).return_value):
|
||||
value = server.oui_identity('C89E43648080')
|
||||
self.assertEqual(value['manufacturer'], 'Apple Corporation')
|
||||
self.assertEqual(value['source'], 'nmap')
|
||||
self.assertIsNone(value['model'])
|
||||
|
||||
def test_oui_identity_handles_local_and_unknown(self):
|
||||
server._oui_identity_cache = {}
|
||||
self.assertEqual(server.oui_identity('02:11:22:33:44:55')['manufacturer'],
|
||||
'Local/Randomized')
|
||||
self.assertEqual(server.oui_identity('AA:BB:CC:00:00:01')['manufacturer'],
|
||||
'Unknown')
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the focused tests and verify failure**
|
||||
|
||||
Run: `python3 -m unittest tests.test_recon.DecodersTest -v`
|
||||
|
||||
Expected: FAIL because `OUI_DATA_PATHS`, `_oui_identity_cache`, and
|
||||
`oui_identity()` do not yet exist.
|
||||
|
||||
- [ ] **Step 3: Implement the smallest resolver**
|
||||
|
||||
Add the two device paths, a process-level cache, parsers for the first six
|
||||
hexadecimal characters in each local database line, source labels `nmap` and
|
||||
`macchanger`, and fallback to `OUI_VENDORS`. Return `model: None` for every
|
||||
current source. Detect the locally administered bit before file lookup.
|
||||
|
||||
- [ ] **Step 4: Run focused and regression tests**
|
||||
|
||||
Run: `python3 -m unittest tests.test_recon.DecodersTest -v`
|
||||
|
||||
Expected: PASS, including the pre-existing `oui_vendor` assertions.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add payload/user/remote_access/pager-webui/server.py tests/test_recon.py
|
||||
git commit -m "feat: resolve recon device manufacturers locally"
|
||||
```
|
||||
|
||||
### Task 2: Enrich Scan Associations and APs
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/remote_access/pager-webui/server.py` in `recon_scan_data()` and its nearby SQL helpers
|
||||
- Test: `tests/test_recon.py` in `ReconDataTest`
|
||||
|
||||
**Interfaces:**
|
||||
- `recon_scan_data(scan_id, _timeout=20, _limit=None, db=None)` returns existing fields plus AP `device_identity`, `clients`, `client_count`, and client `vendor`, `associations`.
|
||||
|
||||
- [ ] **Step 1: Extend the fixture and write failing tests**
|
||||
|
||||
Add `hostap_client` rows, a second AP/client handshake pair, and a type-5 probe row to `make_db()`. Assert handshake association has SSID/BSSID/source, host-AP association has SSID and no BSSID, type-5 does not associate, duplicate evidence merges sources, AP counts are unique, and client/AP vendors are present.
|
||||
|
||||
```python
|
||||
def test_scan_detail_associations_are_confirmed_only(self):
|
||||
data = server.recon_scan_data(1)
|
||||
client = next(c for c in data['clients'] if c['mac'] == 'AE:77:C0:EB:31:41')
|
||||
self.assertEqual(client['associations'][0]['sources'], ['handshake'])
|
||||
self.assertEqual(client['associations'][0]['ssid'], 'Anderson-5')
|
||||
self.assertEqual(client['associations'][0]['bssid'], 'C8:9E:43:64:80:80')
|
||||
self.assertEqual(data['aps'][0]['client_count'], 1)
|
||||
self.assertNotIn('ProbeOnlySSID', [a['ssid'] for a in client['associations']])
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the focused test and verify failure**
|
||||
|
||||
Run: `python3 -m unittest tests.test_recon.ReconDataTest -v`
|
||||
|
||||
Expected: FAIL because enriched association fields do not exist.
|
||||
|
||||
- [ ] **Step 3: Add bounded evidence queries**
|
||||
|
||||
Load handshake pairs from the selected scan and resolve AP/client MACs from the
|
||||
already loaded `wifi_device` rows. If `hostap_client` exists, query only rows
|
||||
for the selected scan inside a guarded `try` block; if the table is absent,
|
||||
use an empty list. Do not join type-5 rows into the association map.
|
||||
|
||||
- [ ] **Step 4: Build deterministic deduplicated associations**
|
||||
|
||||
Normalize MACs and use `(client_mac, bssid, ssid)` as the association key,
|
||||
where a missing BSSID is represented separately from any AP row. Merge source
|
||||
names in stable order `handshake`, then `hostap_client`, and retain host-AP
|
||||
timestamps. Attach BSSID-backed associations to matching APs only; attach
|
||||
SSID-only host-AP evidence to clients only.
|
||||
|
||||
- [ ] **Step 5: Add identity and AP/client projection**
|
||||
|
||||
Call `oui_identity()` for every AP BSSID and client MAC. Add `clients` and
|
||||
`client_count` to AP objects, preserving current AP ordering and existing
|
||||
fields. Keep clients with no association and set `associations: []`.
|
||||
|
||||
- [ ] **Step 6: Run the complete Recon tests**
|
||||
|
||||
Run: `python3 -m unittest tests.test_recon -v`
|
||||
|
||||
Expected: PASS for all existing and new tests.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add payload/user/remote_access/pager-webui/server.py tests/test_recon.py
|
||||
git commit -m "feat: associate recon clients with confirmed networks"
|
||||
```
|
||||
|
||||
### Task 3: Propagate Enriched Data Through Exports
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/remote_access/pager-webui/server.py` in Recon CSV/HTML builders and download handlers
|
||||
- Test: `tests/test_recon.py` in `ReconReportTest`
|
||||
|
||||
**Interfaces:**
|
||||
- Existing JSON downloads preserve the enriched detail object.
|
||||
- Existing CSV and HTML downloads include identity, client counts, and confirmed association details.
|
||||
|
||||
- [ ] **Step 1: Write failing export assertions**
|
||||
|
||||
Assert JSON contains `device_identity` and `associations`, CSV headers/rows
|
||||
contain `Device Identity`, `Client Count`, and semicolon-separated confirmed
|
||||
SSIDs, and HTML contains a `Confirmed Clients` section while excluding the
|
||||
type-5 probe SSID.
|
||||
|
||||
- [ ] **Step 2: Run report tests and verify failure**
|
||||
|
||||
Run: `python3 -m unittest tests.test_recon.ReconReportTest -v`
|
||||
|
||||
Expected: FAIL because serializers currently omit the enrichment.
|
||||
|
||||
- [ ] **Step 3: Implement deterministic flattening and report sections**
|
||||
|
||||
Keep JSON unchanged apart from its enriched source object. Add CSV columns
|
||||
using a stable display identity and `'; '.join()` for multiple association
|
||||
SSIDs. Add AP identity/client count to the existing AP table and a confirmed
|
||||
client table to HTML, escaping all values through the existing HTML helpers.
|
||||
|
||||
- [ ] **Step 4: Run report and full Python tests**
|
||||
|
||||
Run: `python3 -m unittest tests.test_recon tests.test_health tests.test_ws -v`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add payload/user/remote_access/pager-webui/server.py tests/test_recon.py
|
||||
git commit -m "feat: include recon identity in exports"
|
||||
```
|
||||
|
||||
### Task 4: Update Recon Tables and Focus Details
|
||||
|
||||
**Files:**
|
||||
- Modify: `payload/user/remote_access/pager-webui/www/js/views.js` around `reconDefaultCols`, `colDefs`, table column definitions, and `renderFocus`
|
||||
- Modify: `payload/user/remote_access/pager-webui/www/css/app.css` for compact identity/association detail styling if needed
|
||||
- Test: `tests/test_recon.py` source assertions or existing frontend smoke harness
|
||||
|
||||
**Interfaces:**
|
||||
- Existing `views.recon` consumes enriched AP/client objects without new endpoints.
|
||||
- `pw_recon_cols` migration preserves existing values and adds new defaults.
|
||||
|
||||
- [ ] **Step 1: Add source-level failing assertions**
|
||||
|
||||
Assert the source contains default AP `identity` and `clients` columns, client
|
||||
`vendor` and `associated_ssid` columns, association search values, and a
|
||||
`Confirmed Clients` focus section.
|
||||
|
||||
- [ ] **Step 2: Implement client/AP display helpers**
|
||||
|
||||
Add a helper that formats `device_identity` as manufacturer plus model when
|
||||
model is non-null, otherwise manufacturer plus OUI for unknown values. Add a
|
||||
helper that formats one association SSID or `first SSID +N` and sets the full
|
||||
association summary in the cell `title`.
|
||||
|
||||
- [ ] **Step 3: Update column defaults and settings**
|
||||
|
||||
Replace AP `vendor` with `identity`, add AP `clients`, and add client `vendor`
|
||||
and `associated_ssid` defaults. When loading old settings, merge missing keys
|
||||
from `reconDefaultCols()` rather than discarding the saved preferences.
|
||||
|
||||
- [ ] **Step 4: Update filtering, sorting, and table rendering**
|
||||
|
||||
Include formatted identity and association strings in searchable values,
|
||||
retain numeric sorting for client counts, and render the new columns through
|
||||
the existing table/paginator code.
|
||||
|
||||
- [ ] **Step 5: Add confirmed clients to AP focus**
|
||||
|
||||
Render MAC, vendor, and comma-separated evidence sources from `ap.clients`.
|
||||
Show `No confirmed clients` for an empty list and do not list probe-only
|
||||
records.
|
||||
|
||||
- [ ] **Step 6: Run frontend/source and Python tests**
|
||||
|
||||
Run: `python3 -m unittest discover -s tests -p 'test_*.py' -v`
|
||||
|
||||
Expected: PASS. Then run the project’s existing browser smoke harness if
|
||||
available and verify desktop/mobile Recon rendering without changing device
|
||||
configuration.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add payload/user/remote_access/pager-webui/www/js/views.js payload/user/remote_access/pager-webui/www/css/app.css tests/test_recon.py
|
||||
git commit -m "feat: show recon client identities and associations"
|
||||
```
|
||||
|
||||
### Task 5: On-Device Read-Only Verification
|
||||
|
||||
**Files:**
|
||||
- No source changes expected.
|
||||
- Evidence: local command output only; do not add credentials or device dumps to git.
|
||||
|
||||
- [ ] **Step 1: Deploy through the project’s normal development/deploy path**
|
||||
|
||||
Use the existing script documented in `README.md`; do not alter wireless or
|
||||
PineAP settings.
|
||||
|
||||
- [ ] **Step 2: Compare API data with read-only SQLite evidence**
|
||||
|
||||
Query `scan`, `wifi_device`, `ssid`, `handshake`, and `hostap_client` using the
|
||||
read-only SQLite URI. Confirm handshake BSSID/client pairs match API
|
||||
associations, host-AP entries are SSID-only, and type-5 rows are absent from
|
||||
associations.
|
||||
|
||||
- [ ] **Step 3: Verify identity and UI behavior**
|
||||
|
||||
Confirm AP identity resolves from an installed local database or fallback,
|
||||
unknown/local MAC labels are honest, Access Points shows client counts and
|
||||
identity, Clients shows vendor/SSID, and the focus sidebar shows confirmed
|
||||
clients at desktop and mobile widths.
|
||||
|
||||
- [ ] **Step 4: Run final verification before claiming completion**
|
||||
|
||||
Run: `git diff --check`, `python3 -m unittest discover -s tests -p 'test_*.py' -v`, and `git status --short`.
|
||||
|
||||
Expected: no whitespace errors, all tests pass, and only intended source
|
||||
changes plus pre-existing worktree changes are present.
|
||||
@@ -0,0 +1,98 @@
|
||||
# Startup Environment Check + Self-Heal
|
||||
|
||||
Date: 2026-08-19
|
||||
|
||||
## Problem
|
||||
|
||||
Rebooting the Pineapple left Mark VIII in a broken state: the boot-persistent
|
||||
procd service came back, but nothing reconciled pineapd/UCI/monitors, so the UI
|
||||
reported state that did not match the device (pool "off" while actually
|
||||
broadcasting), native recon scans failed, and scans only returned 5GHz results.
|
||||
|
||||
Three observed failures:
|
||||
|
||||
1. **native recon scan failed** — `h_recon_start` proxies
|
||||
`/api/pineap/recon/new`; it returns 502 when the daemon fails (typically
|
||||
pineapd down/crash-looping). Nothing verifies pineapd health before the user
|
||||
starts a scan.
|
||||
2. **scans only showed 5GHz** — radio0's AP interfaces (wlan0open/wlan0wpa)
|
||||
pin `wlan0mon` to one 2.4GHz channel (a phy's channel is held by its AP
|
||||
interface), so 2.4GHz results mostly vanish while 5GHz `wlan1mon` hopping
|
||||
keeps producing results. The band UCI config itself is correct.
|
||||
3. **pool "on" while UI shows "off"** — the UI's advertise state is derived
|
||||
only from UCI `pineapd.@ssidpool[0].disable`. Nothing syncs pineapd's
|
||||
*runtime* pool broadcast to match UCI. UCI can say disabled while pineapd is
|
||||
live-broadcasting.
|
||||
|
||||
## Design decisions
|
||||
|
||||
- Keep boot auto-start; the service self-heals at every startup instead.
|
||||
- The env check auto-fixes everything fixable, re-verifies, and only fails hard
|
||||
on core deps (daemon unreachable after fix, pineapd down after restart, recon
|
||||
DB unreadable).
|
||||
- 5GHz-only scans are handled by verify + warn (no scan-time AP pausing).
|
||||
|
||||
## Implementation
|
||||
|
||||
### 1. `env_check()` in server.py (single source of truth)
|
||||
|
||||
Runs in order and returns `[{step, ok, detail, action}]` where `ok` is one of
|
||||
`pass`, `fixed`, `warn`, `fail`:
|
||||
|
||||
1. **Daemon reachable** — `daemon_sock_call('GET', '/api/pineap/get_config')`.
|
||||
Report-only.
|
||||
2. **pineapd alive** — `pidof pineapd`. If down: stabilize UCI, restart
|
||||
`/etc/init.d/pineapd`, re-verify. Core: if still down after restart -> fail.
|
||||
3. **Sane-off UCI defaults** — reuse the `_stabilize_pineapd` wanted-dict (pool
|
||||
`disable=1` + clear pool list, `wlan2mon.disable=1`/`hop=0`,
|
||||
`wlan1mon.bands=5`/`hop=0`, `wlan0mon.bands=2`). Write + commit when
|
||||
missing. Refactor so the health monitor and env check share the wanted-dict.
|
||||
4. **Runtime pool sync** — when UCI says pool disabled, call
|
||||
`_pineap('SSIDPOOL', 'DISABLE')` so pineapd's runtime broadcast matches the
|
||||
UI. Report the resulting runtime state as `pool_runtime`.
|
||||
5. **Monitors up** — `_bring_monitors_up()` for wlan0mon/wlan1mon.
|
||||
6. **Recon DB readable** — read-only scan-count query against `RECON_DB`.
|
||||
Report row count. Core: unreadable -> fail.
|
||||
7. **2.4GHz sampling** — report enabled radio0 APs (wlan0open/wlan0wpa).
|
||||
Warn: "2.4GHz under-sampled while an OpenAP/Evil WPA AP is up on radio0".
|
||||
|
||||
### 2. CLI mode `server.py --env-check`
|
||||
|
||||
`if __name__ == '__main__'` branch: run `env_check()`, print verbose
|
||||
`[PASS] step — detail` / `[FIXED] ...` / `[WARN] ...` / `[FAIL] ...` lines to
|
||||
stdout, exit 0 (all pass/warn) or 1 (any core fail).
|
||||
|
||||
### 3. Startup self-heal
|
||||
|
||||
`serve()` runs `env_check()` before binding, stores the report in module state,
|
||||
and logs to `/tmp/pagerwebui.log` (covers boot + procd respawn).
|
||||
|
||||
### 4. API + UI
|
||||
|
||||
- `GET /api/health` gains `env` (last env-check report + timestamp +
|
||||
pass/fixed/warn/fail counts) and `pool_runtime` (actual runtime broadcast
|
||||
state after sync).
|
||||
- `GET /api/recon/status` gains `wlan0_pinned` (true when a radio0 AP is
|
||||
enabled); the recon scan bar shows a 2.4GHz under-sampling warning pill.
|
||||
- `h_recon_start` failure includes `detail` in the user-facing error.
|
||||
|
||||
### 5. payload.sh
|
||||
|
||||
Before starting the service (both foreground and background paths), run
|
||||
`python3 "$SCRIPT_DIR/server.py" --env-check`, show output verbosely on the
|
||||
payload screen, and abort with a red message on exit 1. Bump header version to
|
||||
match the current release.
|
||||
|
||||
### 6. Tests
|
||||
|
||||
- `tests/test_env_check.py` — fake `device_run` UCI harness (pattern from
|
||||
`test_health.py`): defaults applied when missing / idempotent when set, pool
|
||||
list cleared, runtime SSIDPOOL disable invoked when UCI says disabled,
|
||||
monitors brought up, pineapd restarted when down, DB unreadable -> fail,
|
||||
CLI `--env-check` prints and exits correctly.
|
||||
- `test_health.py` updated for the shared stabilization refactor.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- No scan-time AP pausing / radio0 AP teardown.
|
||||
- No changes to radio1 / evil-twin / enterprise config handling.
|
||||
@@ -0,0 +1,233 @@
|
||||
# Recon, Reports, Enterprise Certificates, and PineAP Stability Design
|
||||
|
||||
## Summary
|
||||
|
||||
This change improves five related Mark VIII workflows on Pineapple Pager 24.10.1:
|
||||
|
||||
1. Recon reports radio coverage from observed runtime behavior instead of treating an enabled dummy STA as proof of starvation.
|
||||
2. Self-contained HTML reports include signal maps matching the Recon UI.
|
||||
3. Evil Enterprise can generate and activate a user-defined self-signed server certificate.
|
||||
4. Handshakes moves from Recon to PineAP while old links continue to work.
|
||||
5. Mark VIII contains and diagnoses known `pineapd` crash loops without attempting an unsupported binary patch.
|
||||
|
||||
The implementation remains compatible with the existing constraints: Python standard library only, no frontend build step, no external report assets, and no modifications to the stock `pineapd` binary.
|
||||
|
||||
## Confirmed Current Behavior
|
||||
|
||||
On the target Pager, `wireless.dummy_radio0` is enabled, but `wlan0` is unassociated and `wlan0mon` exists. The current UI nevertheless displays `2.4GHz starved` because `wlan0_sta` means only that the UCI section is enabled. It does not prove association, administrative state, channel pinning, or a failed channel change.
|
||||
|
||||
Recon completion is also currently timer-based. The service does not associate the timer with the resulting scan row, and the UI prefers the newest nonempty scan. A completed run can therefore leave an older scan selected.
|
||||
|
||||
The target also has crash-prone PineAP settings: the nonexistent `wlan2mon` is enabled for hopping, `wlan1mon` includes 2.4 GHz, and pool broadcast lacks an explicit disabled value. Mark VIII reports these states but does not currently reconcile them. The running daemon is the stock `/usr/sbin/pineapd` with SHA-256 `e2cf0453d7e7d2e08bf31a242289e480d44649923a5199eed954d73df0cf1da5`.
|
||||
|
||||
## Recon Runtime Accuracy
|
||||
|
||||
### Radio State Model
|
||||
|
||||
The backend will expose a structured 2.4 GHz coverage state rather than asking the frontend to infer it from UCI booleans. The state will distinguish:
|
||||
|
||||
- `full`: `wlan0mon` passed the channel-control probe and is participating in the active hopper.
|
||||
- `pinned`: a live associated STA or active AP holds `phy0`, and the monitor cannot change channel.
|
||||
- `current_channel_only`: the channel-control probe failed, but native Recon remains able to collect on the current channel.
|
||||
- `unavailable`: `wlan0mon` is absent or down and cannot collect.
|
||||
- `idle`: no scan is active, with no claim about current scan coverage.
|
||||
|
||||
Supporting fields will identify the observed reason, current channel, associated STA state, dummy-STA configured state, dummy-STA parked state, and active hopper interfaces. User-visible messages will come from this backend diagnosis.
|
||||
|
||||
An enabled but unassociated `dummy_radio0` will not by itself produce a starvation warning. The preflight channel probe remains authoritative. If the dummy STA causes `Resource busy`, the existing safe borrowing flow will park it and retry. If the retry succeeds, coverage is `full`; if it fails, coverage reflects the observed limitation.
|
||||
|
||||
The hopper loop will clear active interface state when it exits. Completed scans must not retain stale `hopper_ifaces` that imply hopping remains active.
|
||||
|
||||
### Scan Identity and Completion
|
||||
|
||||
Before starting native Recon, Mark VIII will record the highest existing scan ID. After the daemon accepts the start, polling will identify the first newer scan row and retain that ID as the run's result. The status response will include the tracked scan ID when available.
|
||||
|
||||
Elapsed duration still controls when Mark VIII requests or recognizes the end of the timed run, but the frontend will not announce a completed result until the new scan row is available. A 15-second settlement period will allow final SQLite writes to appear. If no row appears within that period, the scan ends with an explicit result-unavailable warning rather than silently selecting an older scan.
|
||||
|
||||
The Recon UI will follow the tracked scan ID, including while it is initially empty. It will no longer use the newest nonempty historical scan as a substitute for the active run.
|
||||
|
||||
### UI Presentation
|
||||
|
||||
The scan bar will preserve the existing styling and use concise messages:
|
||||
|
||||
- `2.4 GHz hopping` for full coverage.
|
||||
- `2.4 GHz limited to channel N -- <observed reason>` for pinned collection.
|
||||
- `2.4 GHz monitor unavailable` when collection is impossible.
|
||||
|
||||
Warnings describe coverage quality, not whether the scan as a whole succeeded. The 5 GHz radio can continue independently when 2.4 GHz is limited.
|
||||
|
||||
## HTML Report Signal Maps
|
||||
|
||||
### Rendering
|
||||
|
||||
The shared live/archive HTML generator will add dependency-free inline SVG maps for every populated band. The geometry will match `MiniChart.channelMap`:
|
||||
|
||||
- Prefer observed frequency and otherwise derive it from channel.
|
||||
- Use the same 2.4, 5, and 6 GHz ranges.
|
||||
- Use a fixed -100 to -30 dBm vertical scale.
|
||||
- Represent each AP as a raised-cosine 20 MHz lobe.
|
||||
- Use the same green, yellow, orange, and red signal thresholds.
|
||||
|
||||
Each SVG will include axes, channel/frequency labels, a signal-strength scale, and an accessible title. Each AP path will include an SVG `<title>` containing escaped SSID, BSSID, channel, signal, encryption, and vendor metadata. Reports require no JavaScript and remain printable and usable offline.
|
||||
|
||||
### Existing Statistics
|
||||
|
||||
This pass retains the current report scope: summary cards, band breakdown, encryption breakdown, channel occupancy, and AP details. It will not add new client or handshake detail tables.
|
||||
|
||||
Labels will describe their actual semantics. The report will use the existing exact client-count query instead of raw device observations. GPS metadata will state that it is the current fix at report generation time; archived reports will continue to omit current GPS.
|
||||
|
||||
Report helpers will accept empty or incomplete AP data safely. Unknown bands or channels will not produce malformed SVG. User-controlled fields are HTML-escaped in text and attribute contexts.
|
||||
|
||||
## Evil Enterprise Certificate Generation
|
||||
|
||||
### User Flow
|
||||
|
||||
The Evil Enterprise page will gain a certificate card matching the current PineAP cards, form controls, infoboxes, and action buttons. This is an inline flow rather than a visually distinct wizard.
|
||||
|
||||
The form accepts:
|
||||
|
||||
- Country
|
||||
- State or province
|
||||
- Locality
|
||||
- Organization
|
||||
- Organizational unit
|
||||
- Common name
|
||||
- Subject alternative DNS names
|
||||
- Validity in days
|
||||
|
||||
The card shows the installed certificate subject, SHA-256 fingerprint, validity range, key type/size, and certificate/key-match status. It never displays or returns private-key material.
|
||||
|
||||
### Generation and Validation
|
||||
|
||||
The backend generates a 2048-bit RSA self-signed server certificate using the device's `openssl` command. The certificate includes SHA-256 signatures, server-auth key usage, the requested subject, and validated DNS SAN entries. Validity is constrained to 1 through 3650 days.
|
||||
|
||||
Inputs are rejected when they contain line breaks or control characters, exceed 128 UTF-8 bytes per subject field, contain an invalid two-letter country code, contain invalid DNS SAN values, include more than 20 SAN entries, or result in an empty common name. Each SAN must be a valid DNS name of at most 253 ASCII characters. Arguments are passed as an argv list rather than through a shell.
|
||||
|
||||
Generation occurs in a private temporary directory. Before installation, the backend verifies that:
|
||||
|
||||
- OpenSSL can parse the certificate.
|
||||
- OpenSSL can parse the private key.
|
||||
- Certificate and key public keys match.
|
||||
- The generated certificate is currently valid.
|
||||
- Subject and SAN values reflect the request.
|
||||
|
||||
Only validated files replace the active certificate and key. Replacement uses backups and atomic renames. The private key and temporary files use mode `0600`; public certificates use `0644`.
|
||||
|
||||
The fallback certificate path will use this same validated generator rather than copying a shared private key. The payload init script will stop applying recursive `0755` permissions to secret material.
|
||||
|
||||
### Automatic Restart and Rollback
|
||||
|
||||
If Evil Enterprise is not active, a successful generation becomes the certificate for the next deployment.
|
||||
|
||||
If Evil Enterprise is active, the backend snapshots its current deployment state, installs the new certificate, and automatically redeploys the AP with the same SSID, channel, encryption, hidden state, EAP method, and secret. Success requires hostapd to reach `ENABLED` with the new certificate.
|
||||
|
||||
If redeployment fails, the backend restores the prior certificate files and attempts to redeploy the previous AP configuration. The API reports both the generation failure and whether rollback restored service. Concurrent generation/deployment operations are serialized with the existing attack-operation boundary extended to certificate changes.
|
||||
|
||||
## Navigation
|
||||
|
||||
Top-level Recon remains. Its tabs become:
|
||||
|
||||
- Scanning
|
||||
- Reports
|
||||
|
||||
Handshakes moves into `PINEAP_TABS` at `#/pineap/handshakes`. Its page body and data APIs remain unchanged. The Recon handshake summary card links to the new route.
|
||||
|
||||
`#/recon/handshakes` redirects to `#/pineap/handshakes` so bookmarks and old links continue to work. This redirect is a concrete compatibility requirement because the previous route shipped in the UI.
|
||||
|
||||
## pineapd Containment and Diagnostics
|
||||
|
||||
### Supported-Build Policy
|
||||
|
||||
Mark VIII will identify the device by firmware description and daemon SHA-256. The initial affected-build record covers Pager 24.10.1 and the confirmed daemon hash. Known affected builds receive automatic crash-prevention reconciliation.
|
||||
|
||||
Unknown builds retain the conservative UI block on pool broadcast, but Mark VIII will not silently apply hash-specific assumptions. Health output will identify the build as unknown and explain that broadcast remains unavailable pending validation. A future verified-safe build can be allowlisted without changing the UI contract.
|
||||
|
||||
### Safe Reconciliation
|
||||
|
||||
Before Mark VIII starts or explicitly restarts `pineapd` on the affected build, it will ensure:
|
||||
|
||||
- `pineapd.@ssidpool[0].disable=1`
|
||||
- `pineapd.wlan2mon.disable=1`
|
||||
- `pineapd.wlan2mon.hop=0`
|
||||
- `pineapd.wlan1mon.bands=5`
|
||||
- `pineapd.wlan0mon.bands=2`
|
||||
|
||||
Mark VIII will continue using passive `pidof` checks and will not health-poll the daemon command socket. It will not disable normal 5 GHz hopping merely to make startup appear safe.
|
||||
|
||||
The reconciler records original and resulting values and changes only known crash-prevention keys. It does not rewrite unrelated wireless or PineAP settings. Normal startup does not clear the SSID pool.
|
||||
|
||||
### Crash Circuit Breaker
|
||||
|
||||
The health monitor will track PID transitions and SIGSEGV-count changes in a rolling two-minute window. Three observed crashes within that window will stop additional Mark VIII restarts and mark the daemon `quarantined`; stock procd behavior remains visible but is not amplified by the web UI.
|
||||
|
||||
If the pool has refilled above 64 entries or 4096 encoded bytes during a correlated crash loop, Mark VIII will first save a timestamped diagnostic copy, then clear the pool as a last-resort recovery action and perform one controlled restart. This action and its reason appear in health status. A pool at or below both limits is preserved.
|
||||
|
||||
The circuit breaker resets only after the daemon remains alive for five minutes or after an explicit operator recovery action. Recovery reruns safe reconciliation before restart.
|
||||
|
||||
### Diagnostic Bundle
|
||||
|
||||
A read-only diagnostic endpoint will produce a downloadable text or JSON bundle containing:
|
||||
|
||||
- Firmware and board identity
|
||||
- `pineapd` hash and process state
|
||||
- Relevant PineAP UCI values
|
||||
- Monitor interface presence and channel state
|
||||
- Pool entry count and encoded size, without exposing unrelated secrets
|
||||
- Recent PID transitions, restart decisions, and SIGSEGV deltas
|
||||
- Relevant recent log lines
|
||||
- Mark VIII health actions and quarantine state
|
||||
|
||||
The bundle supports vendor reporting and future safe-build validation. It does not include the stock binary or private certificate/key material.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Recon probe failures remain per-radio failures; one radio does not invalidate the other.
|
||||
- Database settlement uses bounded retries and reports a missing result explicitly.
|
||||
- Report generation returns the existing 404/503 responses for missing or unavailable scans.
|
||||
- Certificate APIs return validation errors as HTTP 400, generation/runtime failures as 502, and include rollback state when an active AP was affected.
|
||||
- Health reconciliation failures do not trigger unbounded retries. They are exposed through `/api/health` and diagnostics.
|
||||
- All new state-changing APIs retain authentication and same-origin enforcement.
|
||||
|
||||
## Testing
|
||||
|
||||
Backend tests will cover:
|
||||
|
||||
- Enabled but unassociated dummy STA does not imply starvation.
|
||||
- Associated client/AP and failed channel probes produce accurate limited-coverage states.
|
||||
- Parked dummy STA and successful retry report full coverage.
|
||||
- Hopper interfaces clear when hopping ends.
|
||||
- Active runs bind to a newly created scan ID and do not select older nonempty scans.
|
||||
- Missing result settlement produces an explicit warning.
|
||||
- SVG maps render each populated band, signal colors, escaped metadata, empty data, and archive reports without external assets.
|
||||
- Certificate input validation, SAN generation, key/certificate matching, atomic replacement, file modes, active-AP restart, and rollback.
|
||||
- Navigation route declarations and legacy redirect.
|
||||
- Affected-build matching, safe UCI reconciliation, unknown-build policy, passive health checks, crash threshold, pool quarantine, and circuit-breaker recovery.
|
||||
|
||||
Verification on the Pager will include:
|
||||
|
||||
- A 2.4 GHz scan with the enabled but unassociated dummy STA.
|
||||
- A scan while an actual STA or AP pins `phy0`.
|
||||
- Opening and printing generated live and archived HTML reports.
|
||||
- Generating a certificate and inspecting it with OpenSSL.
|
||||
- Generating while Evil Enterprise runs and confirming automatic restart.
|
||||
- Exercising a forced restart failure and confirming certificate/AP rollback.
|
||||
- Observing `pineapd` stability and health state after safe reconciliation.
|
||||
- Desktop and mobile checks of Recon, Reports, PineAP Handshakes, and Evil Enterprise.
|
||||
|
||||
## Deployment Order
|
||||
|
||||
1. Recon state and scan-result identity.
|
||||
2. Firmware-gated `pineapd` reconciliation and health containment.
|
||||
3. HTML report SVG maps.
|
||||
4. Handshakes navigation move.
|
||||
5. Certificate generation, permissions, restart, and rollback.
|
||||
6. Full local test suite, payload build, device deployment, and on-device verification.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Patching or replacing the proprietary `pineapd` binary.
|
||||
- Enabling SSID-pool broadcast on the known affected daemon.
|
||||
- Adding external chart libraries or report assets.
|
||||
- Adding client or handshake detail tables to reports in this pass.
|
||||
- Creating a CA hierarchy or CA-download workflow; the selected certificate design is a self-signed server certificate.
|
||||
- Persisting browser-only signal history in reports.
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
# Recon Client Associations and Device Identity Design
|
||||
|
||||
## Summary
|
||||
|
||||
Enrich the Recon Scanning view with confirmed client-to-network associations
|
||||
and best-available device identity. Associations must come from explicit
|
||||
evidence and must never be inferred from proximity, channel, timing, or probe
|
||||
requests. Access-point identity uses local device databases and existing
|
||||
fallback data without requiring internet access.
|
||||
|
||||
The implementation remains compatible with the current constraints: Python
|
||||
standard library only, no frontend build step, bounded scan-detail queries,
|
||||
and read-only access to the stock Recon database.
|
||||
|
||||
## Confirmed Device Behavior
|
||||
|
||||
The target Pager Recon database contains:
|
||||
|
||||
- `ssid.type = 8`: access-point observations with BSSID and SSID.
|
||||
- `ssid.type = 5`: directed probe observations with a device MAC and SSID but
|
||||
no BSSID. These do not prove association.
|
||||
- `ssid.type = 4`: unassociated observations used by the current landscape
|
||||
count.
|
||||
- `handshake.stahash` and `handshake.aphash`: explicit client/AP pairs that
|
||||
resolve through `wifi_device.hash`.
|
||||
- `hostap_client`: clients associated with an AP hosted by the Pineapple,
|
||||
including client MAC, SSID, and connection timestamps.
|
||||
|
||||
The target has local manufacturer databases at
|
||||
`/usr/share/nmap/nmap-mac-prefixes` and
|
||||
`/usr/share/macchanger/wireless.list`. The Recon schema does not retain WPS or
|
||||
other vendor information elements that would support dependable model
|
||||
detection.
|
||||
|
||||
## Association Model
|
||||
|
||||
`recon_scan_data()` remains the canonical scan-detail builder. It emits only
|
||||
confirmed associations from these sources:
|
||||
|
||||
1. WPA handshake rows, using `aphash` and `stahash` to resolve the AP and
|
||||
client MAC addresses through `wifi_device`.
|
||||
2. `hostap_client` rows scoped to the selected scan, representing clients that
|
||||
associated with a Pineapple-hosted AP.
|
||||
|
||||
Directed probes and other `ssid` observations do not create associations.
|
||||
There is no channel, signal, timing, or SSID-name inference.
|
||||
|
||||
Evidence for the same client/network pair is deduplicated. The resulting
|
||||
association records preserve all evidence sources, for example
|
||||
`["handshake", "hostap_client"]`.
|
||||
|
||||
### Client Shape
|
||||
|
||||
Each existing client object retains `mac`, `signal`, `freq`, and `packets` and
|
||||
gains:
|
||||
|
||||
- `vendor`: best-available client manufacturer label.
|
||||
- `associations`: an array of confirmed association objects.
|
||||
|
||||
Each association can contain:
|
||||
|
||||
- `ssid`
|
||||
- `bssid`, when known
|
||||
- `ap_identity`
|
||||
- `sources`
|
||||
- `connected_time`, when supplied by `hostap_client`
|
||||
- `disconnected_time`, when supplied by `hostap_client`
|
||||
|
||||
An absent relationship is represented by an empty array. The UI labels this
|
||||
state `Unknown`; it does not invent an SSID.
|
||||
|
||||
### Access-Point Shape
|
||||
|
||||
Each existing AP object retains its current fields and gains:
|
||||
|
||||
- `device_identity`: structured manufacturer, optional model, OUI, and source
|
||||
metadata.
|
||||
- `clients`: confirmed associated-client summaries.
|
||||
- `client_count`: the number of unique confirmed client MAC addresses.
|
||||
|
||||
Handshake evidence resolves an AP by BSSID. The current `hostap_client` schema
|
||||
does not contain a BSSID, so those rows create SSID-only associations and are
|
||||
not assigned to an AP row. A matching SSID alone is insufficient to claim a
|
||||
specific BSSID.
|
||||
|
||||
## Device Identity Resolution
|
||||
|
||||
Identity resolution follows this precedence:
|
||||
|
||||
1. `/usr/share/nmap/nmap-mac-prefixes`.
|
||||
2. `/usr/share/macchanger/wireless.list`.
|
||||
3. The existing built-in `OUI_VENDORS` map.
|
||||
4. `Unknown`.
|
||||
|
||||
The parsed local maps are cached once per server process. Missing, malformed,
|
||||
or unreadable files are skipped without failing Recon.
|
||||
|
||||
The identity object includes the visible three-byte OUI prefix. Locally
|
||||
administered MAC addresses report `Local/Randomized` rather than a vendor.
|
||||
Globally administered addresses absent from all sources report `Unknown`.
|
||||
The current sources identify manufacturers, not models, so `model` is `null`.
|
||||
Vendor names, SSIDs, and MAC patterns are not treated as models.
|
||||
|
||||
## Recon UI
|
||||
|
||||
The existing Recon layout, tabs, pagination, and focus sidebar remain intact.
|
||||
|
||||
### Access Points
|
||||
|
||||
The table gains two default-visible columns:
|
||||
|
||||
- `Device Identity`: the best reliable display label. It shows manufacturer
|
||||
and model when both are explicitly available, manufacturer alone when only
|
||||
the OUI resolves, and `Unknown (AA:BB:CC)` when unresolved.
|
||||
- `Clients`: the confirmed unique-client count.
|
||||
|
||||
The richer `Device Identity` column replaces the current `Vendor` column to
|
||||
avoid duplicate information. Selecting an AP continues to open the existing
|
||||
focus sidebar, which gains a `Confirmed Clients` section listing client MAC,
|
||||
client vendor, and evidence sources.
|
||||
|
||||
### Clients
|
||||
|
||||
The table gains two default-visible columns:
|
||||
|
||||
- `Vendor`
|
||||
- `Associated SSID`
|
||||
|
||||
A single association shows its SSID. Multiple associations show the first
|
||||
SSID followed by `+N`; the cell title contains every SSID, BSSID when known,
|
||||
AP identity, and evidence source. Clients without confirmed evidence show
|
||||
`Unknown`.
|
||||
|
||||
Search includes the new identity and association values. Existing column
|
||||
preferences in `pw_recon_cols` are merged with current defaults, so users keep
|
||||
their settings while newly introduced columns receive their default-visible
|
||||
state.
|
||||
|
||||
## Exports and Reports
|
||||
|
||||
All exports use the same enriched scan-detail objects:
|
||||
|
||||
- JSON preserves structured identity and association arrays.
|
||||
- CSV adds flattened identity, client count, and association fields. Multiple
|
||||
associations are separated unambiguously with semicolons.
|
||||
- HTML adds AP identity and confirmed-client count to AP details and includes
|
||||
a confirmed-client table. The report labels these as confirmed associations
|
||||
and does not mix in directed probes.
|
||||
|
||||
## Error Handling and Performance
|
||||
|
||||
- Association reads are scoped to the selected scan.
|
||||
- Existing bounded scan-detail behavior remains bounded; enrichment must not
|
||||
introduce unbounded cross-scan joins.
|
||||
- Missing optional tables or schema differences yield empty association data
|
||||
while preserving existing AP and client results.
|
||||
- Missing OUI files fall back through the resolver chain and never make the
|
||||
Recon endpoint fail.
|
||||
- No external lookup or network dependency is introduced.
|
||||
- Duplicate handshake or host-AP evidence collapses deterministically by
|
||||
normalized client MAC and network identity.
|
||||
|
||||
## Testing
|
||||
|
||||
Backend tests cover:
|
||||
|
||||
- Nmap, macchanger, and built-in OUI precedence.
|
||||
- Missing and malformed local OUI files.
|
||||
- Local/randomized and unknown MAC handling.
|
||||
- Handshake-derived client/AP/SSID associations.
|
||||
- `hostap_client` associations remain SSID-only and do not attach to an AP row.
|
||||
- Deduplication and combined evidence sources.
|
||||
- Explicit exclusion of `ssid.type = 5` probe rows.
|
||||
- Missing optional association data.
|
||||
- Enriched JSON, CSV, and HTML output.
|
||||
|
||||
Frontend tests or source assertions cover:
|
||||
|
||||
- New default columns and stored-column preference migration.
|
||||
- Confirmed association display and the `Unknown` state.
|
||||
- Search over identity and association values.
|
||||
- Confirmed clients in the AP focus sidebar.
|
||||
|
||||
On-device verification uses read-only SQLite queries to compare API
|
||||
associations with source evidence, confirms local OUI resolution, and checks
|
||||
the Recon view at desktop and mobile widths. Verification does not alter
|
||||
radio, PineAP, or wireless configuration.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Inferring associations from probe requests, frequency, signal, time, or
|
||||
physical proximity.
|
||||
- Active probing, deauthentication, or traffic capture to discover clients.
|
||||
- Internet OUI or device-fingerprinting services.
|
||||
- Guessing AP models from manufacturer, SSID naming, or MAC patterns.
|
||||
- Redesigning the Recon navigation or table framework.
|
||||
@@ -8,7 +8,7 @@
|
||||
"title": "Mark VIII",
|
||||
"author": "c4ch3c4d3",
|
||||
"description": "Mark VII-style web management UI for the WiFi Pineapple Pager",
|
||||
"version": "1.2",
|
||||
"version": "1.3.2",
|
||||
"category": "remote_access",
|
||||
"tags": ["remote-access", "web-interface", "device-management", "pineap"],
|
||||
"firmware": "Pineapple Pager 24.10.1"
|
||||
|
||||
@@ -7,18 +7,24 @@ USE_PROCD=1
|
||||
PAGER_WEBUI_DIR="/root/payloads/user/remote_access/pager-webui"
|
||||
[ -f "$PAGER_WEBUI_DIR/server.py" ] || PAGER_WEBUI_DIR="/mmc/root/payloads/user/remote_access/pager-webui"
|
||||
|
||||
boot() {
|
||||
PAGER_WEBUI_BOOT=1
|
||||
start
|
||||
}
|
||||
|
||||
start_service() {
|
||||
[ -f "$PAGER_WEBUI_DIR/server.py" ] || return 1
|
||||
chmod -R 755 "$PAGER_WEBUI_DIR" 2>/dev/null
|
||||
procd_open_instance pagerwebui
|
||||
procd_set_param command /usr/bin/python3 "$PAGER_WEBUI_DIR/server.py"
|
||||
procd_set_param env PAGER_WEBUI_BOOT="${PAGER_WEBUI_BOOT:-0}"
|
||||
procd_set_param respawn
|
||||
procd_set_param stdout 1
|
||||
procd_set_param stderr 1
|
||||
procd_set_param pidfile /tmp/pagerwebui.pid
|
||||
procd_set_param term_timeout 90
|
||||
procd_close_instance
|
||||
}
|
||||
|
||||
stop_service() {
|
||||
rm -f /tmp/pagerwebui.pid
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# Title: Mark VIII
|
||||
# Description: Mark VII-style web management UI for the WiFi Pineapple Pager
|
||||
# Author: c4ch3c4d3
|
||||
# Version: 1.1
|
||||
# Version: 1.3.2
|
||||
# Category: Remote-Access
|
||||
# Tags: remote-access, web-interface, device-management, pineap
|
||||
# Firmware: Pineapple Pager 24.10.1
|
||||
@@ -28,7 +28,7 @@ get_pager_ip() {
|
||||
}
|
||||
|
||||
LOG "cyan" "+---------------------------+"
|
||||
LOG "cyan" "| Mark VIII v1.1 |"
|
||||
LOG "cyan" "| Mark VIII v1.3.2 |"
|
||||
LOG "cyan" "+---------------------------+"
|
||||
|
||||
if ! command -v python3 >/dev/null 2>&1; then
|
||||
@@ -36,6 +36,52 @@ if ! command -v python3 >/dev/null 2>&1; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
run_env_check() {
|
||||
LOG "cyan" "Running environment check..."
|
||||
if ! python3 "$SCRIPT_DIR/server.py" --env-check; then
|
||||
LOG "red" "Environment check FAILED. Fix the issues above and re-run the payload."
|
||||
sleep 3
|
||||
exit 1
|
||||
fi
|
||||
LOG "green" "Environment check passed."
|
||||
}
|
||||
|
||||
wait_for_server() {
|
||||
attempts="${1:-30}"
|
||||
while [ "$attempts" -gt 0 ]; do
|
||||
if curl -fsS "http://127.0.0.1:$PORT/" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
attempts=$((attempts - 1))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
wait_for_server_stop() {
|
||||
attempts="${1:-12}"
|
||||
while [ "$attempts" -gt 0 ]; do
|
||||
if ! curl -fsS "http://127.0.0.1:$PORT/" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
sleep 1
|
||||
attempts=$((attempts - 1))
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
release_pager_truth() {
|
||||
python3 "$SCRIPT_DIR/server.py" --release-pager >/tmp/pagerwebui-release.log 2>&1 || true
|
||||
}
|
||||
|
||||
remove_boot_service() {
|
||||
"$INIT_SCRIPT" stop 2>/dev/null || true
|
||||
wait_for_server_stop 90 || true
|
||||
release_pager_truth
|
||||
"$INIT_SCRIPT" disable 2>/dev/null || true
|
||||
rm -f "$INIT_SCRIPT"
|
||||
}
|
||||
|
||||
if [ -f "$INIT_SCRIPT" ] && "$INIT_SCRIPT" running 2>/dev/null; then
|
||||
PAGER_IP=$(get_pager_ip)
|
||||
LOG "green" "Mark VIII service is running"
|
||||
@@ -43,14 +89,26 @@ if [ -f "$INIT_SCRIPT" ] && "$INIT_SCRIPT" running 2>/dev/null; then
|
||||
resp=$(CONFIRMATION_DIALOG "Stop service?")
|
||||
if user_confirmed "$resp"; then
|
||||
LOG "yellow" "Stopping service..."
|
||||
"$INIT_SCRIPT" stop
|
||||
"$INIT_SCRIPT" disable
|
||||
rm -f "$INIT_SCRIPT"
|
||||
LOG "cyan" "Service stopped"
|
||||
remove_boot_service
|
||||
if ! wait_for_server_stop 90; then
|
||||
LOG "red" "Service is still listening on port $PORT"
|
||||
exit 1
|
||||
fi
|
||||
LOG "cyan" "Service stopped and removed from boot"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -f "$INIT_SCRIPT" ] && "$INIT_SCRIPT" enabled 2>/dev/null; then
|
||||
LOG "yellow" "Mark VIII boot service is installed but not running"
|
||||
resp=$(CONFIRMATION_DIALOG "Remove boot service?")
|
||||
if user_confirmed "$resp"; then
|
||||
remove_boot_service
|
||||
LOG "cyan" "Boot service removed"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
AUTO_MODE=$(PAYLOAD_GET_CONFIG pager_webui auto_mode 2>/dev/null)
|
||||
RUN_MODE=$(PAYLOAD_GET_CONFIG pager_webui run_mode 2>/dev/null)
|
||||
|
||||
@@ -67,13 +125,21 @@ else
|
||||
fi
|
||||
|
||||
if user_confirmed "$resp"; then
|
||||
run_env_check
|
||||
LOG "cyan" "Starting as background service..."
|
||||
[ ! -f "$SCRIPT_DIR/server.py" ] && { LOG "red" "server.py not found!"; exit 1; }
|
||||
cp "$SCRIPT_DIR/pagerwebui.init" "$INIT_SCRIPT"
|
||||
chmod +x "$INIT_SCRIPT"
|
||||
"$INIT_SCRIPT" enable
|
||||
"$INIT_SCRIPT" start
|
||||
sleep 1
|
||||
if ! "$INIT_SCRIPT" start; then
|
||||
LOG "red" "Service start command failed"
|
||||
exit 1
|
||||
fi
|
||||
if ! wait_for_server 90; then
|
||||
remove_boot_service
|
||||
LOG "red" "Service failed startup checks; inspect logread"
|
||||
exit 1
|
||||
fi
|
||||
PAGER_IP=$(get_pager_ip)
|
||||
LOG "green" "Service started!"
|
||||
LOG "green" "http://$PAGER_IP:$PORT"
|
||||
@@ -82,22 +148,25 @@ if user_confirmed "$resp"; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
run_env_check
|
||||
LOG "cyan" "Starting foreground mode..."
|
||||
cleanup() {
|
||||
LOG "yellow" "Stopping Mark VIII..."
|
||||
[ -f "$PID_FILE" ] && kill "$(cat "$PID_FILE")" 2>/dev/null
|
||||
wait_for_server_stop 90 || true
|
||||
release_pager_truth
|
||||
rm -f "$PID_FILE"
|
||||
LOG "cyan" "Stopped."
|
||||
LOG "cyan" "Stopped. Pager UI is source of truth."
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
[ ! -f "$SCRIPT_DIR/server.py" ] && { LOG "red" "server.py not found!"; exit 1; }
|
||||
python3 "$SCRIPT_DIR/server.py" >/tmp/pagerwebui.log 2>&1 &
|
||||
echo $! > "$PID_FILE"
|
||||
sleep 1
|
||||
|
||||
PAGER_IP=$(get_pager_ip)
|
||||
if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
|
||||
if wait_for_server 90 && [ -f "$PID_FILE" ] &&
|
||||
kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
|
||||
LOG "green" "http://$PAGER_IP:$PORT"
|
||||
LOG ""
|
||||
LOG "magenta" "Press B to stop"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -57,8 +57,11 @@ hostapd_cli -p /var/run/hostapd-mk8 -i wlan1ent pineape_auth_enable
|
||||
```
|
||||
|
||||
Captured credentials flow to pineapd's socket and land in
|
||||
`hostap_basic`/`hostap_chalresp` in recon.db. Tear down: kill the pidfile
|
||||
pid, `iw dev wlan1ent del`, resume hop.
|
||||
`hostap_basic` / `hostap_chalresp` in recon.db. Mark VIII exposes them as
|
||||
EAP identities + MSCHAPv2 (RADIUS inner-auth equivalent) at
|
||||
`/api/pineap/enterprise/radius`, with hashcat `-m 5500` and john `netntlm`
|
||||
export. The Pager is an EAP terminator (PineAPE), not a UDP/1812 RADIUS
|
||||
proxy. Tear down: kill the pidfile pid, `iw dev wlan1ent del`, resume hop.
|
||||
|
||||
## Access
|
||||
|
||||
|
||||
@@ -62,6 +62,10 @@ body {
|
||||
align-items: center; justify-content: center;
|
||||
}
|
||||
.toolbar-icon-btn:hover, .toolbar-icon-btn:focus-visible { background: rgba(255,255,255,.12); outline: none; }
|
||||
.toolbar-icon-btn:disabled, .toolbar-icon-btn.busy,
|
||||
.menu-link:disabled, .menu-link.busy {
|
||||
opacity: .45; cursor: wait; pointer-events: none;
|
||||
}
|
||||
.toolbar-icon-btn svg { width: 24px; height: 24px; display: block; }
|
||||
#terminal-btn.active, #pager-btn.active, .toolbar-icon-btn[aria-expanded="true"] {
|
||||
background: #1976d2; color: #fff;
|
||||
@@ -200,12 +204,26 @@ html.dark .health-chip.bad { background: #4a2020; color: #ffb4a9; }
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.3);
|
||||
}
|
||||
.btn:hover { background: var(--primary-dark); }
|
||||
.btn:disabled, .btn.busy { opacity: .5; cursor: default; pointer-events: none; }
|
||||
.btn.ghost {
|
||||
background: transparent; color: var(--primary); box-shadow: none;
|
||||
border: 1px solid var(--primary);
|
||||
}
|
||||
.btn.danger { background: var(--danger); }
|
||||
.btn:disabled, .btn.busy {
|
||||
background: #9e9e9e; color: #fafafa; opacity: 1;
|
||||
cursor: wait; pointer-events: none; box-shadow: none;
|
||||
}
|
||||
.btn.ghost:disabled, .btn.ghost.busy {
|
||||
background: var(--surface-alt); color: var(--muted);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.btn.danger:disabled, .btn.danger.busy { background: #9e9e9e; color: #fafafa; }
|
||||
html.dark .btn:disabled, html.dark .btn.busy {
|
||||
background: #616161; color: #eeeeee;
|
||||
}
|
||||
html.dark .btn.ghost:disabled, html.dark .btn.ghost.busy {
|
||||
background: var(--surface-alt); color: var(--muted);
|
||||
}
|
||||
input, select {
|
||||
background: var(--surface); color: var(--text); border: 1px solid var(--border);
|
||||
border-radius: 2px; padding: 8px 10px; width: 100%;
|
||||
@@ -293,6 +311,9 @@ pre.logs {
|
||||
.switch input:checked + .track::after { left: 22px; }
|
||||
.switch input:indeterminate + .track { background: #9e9e9e; }
|
||||
.switch input:indeterminate + .track::after { left: 12px; }
|
||||
.switch:has(input:disabled), .switch:has(input.busy) {
|
||||
opacity: .55; cursor: wait; pointer-events: none;
|
||||
}
|
||||
.sel { padding: 6px 8px; border: 1px solid var(--border, #e0e0e0); border-radius: 4px; background: var(--card, #fff); color: var(--text, #212121); }
|
||||
.pager { display: flex; gap: 8px; align-items: center; margin-top: 8px; }
|
||||
.muted { color: var(--muted, #686868); }
|
||||
@@ -342,7 +363,7 @@ html.dark .muted { color: #bdbdbd; }
|
||||
.recon-ps-actions .icon-btn svg { width: 18px; height: 18px; }
|
||||
.icon-btn { background: transparent; color: var(--muted); border: 0; border-radius: 50%; width: 36px; height: 36px; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; padding: 0; }
|
||||
.icon-btn:hover { background: var(--surface-alt); color: var(--text); }
|
||||
.icon-btn:disabled { opacity: .38; cursor: default; }
|
||||
.icon-btn:disabled, .icon-btn.busy { opacity: .38; cursor: wait; pointer-events: none; }
|
||||
.icon-btn svg { width: 22px; height: 22px; }
|
||||
.recon-scan-bar { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
|
||||
.recon-scan-bar .sel { width: auto; }
|
||||
@@ -388,6 +409,9 @@ html.dark .recon-row-compare td { background: rgba(25, 118, 210, .18); }
|
||||
.recon-focus-twin:hover { background: #689f38; }
|
||||
.recon-focus-detail { display: flex; justify-content: space-between; gap: 10px; padding: 3px 0; border-bottom: 1px solid var(--border); font-size: 13px; }
|
||||
.recon-focus-detail-label { color: var(--muted); flex: none; }
|
||||
.recon-focus-client { display: grid; grid-template-columns: 1fr; gap: 2px; padding: 6px 0; border-bottom: 1px solid var(--border); font-size: 12px; }
|
||||
.recon-focus-client-mac { font-family: Consolas, Menlo, monospace; }
|
||||
.recon-focus-client-sources { color: var(--muted); }
|
||||
.recon-sort-arrow { color: var(--muted); font-size: 11px; }
|
||||
th.recon-sorted { color: var(--primary); }
|
||||
.recon-per { width: auto; }
|
||||
@@ -406,7 +430,7 @@ html.dark .recon-dbm-bar { background: #333; }
|
||||
.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:disabled, .recon-pill.busy { opacity: .5; cursor: wait; pointer-events: none; }
|
||||
.recon-pill.on { background: #e8f5e9; border-color: #a5d6a7; color: #2e7d32; }
|
||||
html.dark .recon-pill.on { background: #1b3a23; color: #81c784; }
|
||||
|
||||
@@ -475,7 +499,7 @@ html.dark .modal { background: #303030; }
|
||||
.seg-btn { background: transparent; border: none; padding: 5px 14px; font-size: 12px; cursor: pointer; color: var(--muted, #666); }
|
||||
.seg-btn + .seg-btn { border-left: 1px solid var(--border, #e0e0e0); }
|
||||
.seg-btn.active { background: var(--primary, #1976d2); color: #fff; }
|
||||
.seg-btn.busy { opacity: .5; pointer-events: none; }
|
||||
.seg-btn:disabled, .seg-btn.busy { opacity: .5; cursor: wait; pointer-events: none; }
|
||||
|
||||
/* ---- PineAP Mark VII layout ---- */
|
||||
.pineap-title-card-container { display: flex; width: 100%; flex-wrap: wrap; justify-content: space-between; gap: 30px; margin: 8px 0 16px; }
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>WiFi Pineapple</title>
|
||||
<link rel="icon" type="image/png" href="assets/logo.png">
|
||||
<link rel="stylesheet" href="css/app.css?v=20260819-1">
|
||||
<link rel="stylesheet" href="css/app.css?v=20260820-4">
|
||||
<link rel="stylesheet" href="js/xterm.css">
|
||||
</head>
|
||||
<body>
|
||||
@@ -262,13 +262,13 @@
|
||||
|
||||
<script src="js/config.js"></script>
|
||||
<script src="js/icons.js?v=20260818-7"></script>
|
||||
<script src="js/api.js?v=20260819-2"></script>
|
||||
<script src="js/api.js?v=20260820-4"></script>
|
||||
<script src="js/chart.js?v=20260819-1"></script>
|
||||
<script src="js/xterm.min.js"></script>
|
||||
<script src="js/xterm-addon-fit.min.js"></script>
|
||||
<script src="js/terminal.js"></script>
|
||||
<script src="js/pager.js"></script>
|
||||
<script src="js/views.js?v=20260819-2"></script>
|
||||
<script src="js/app.js?v=20260819-1"></script>
|
||||
<script src="js/terminal.js?v=20260820-4"></script>
|
||||
<script src="js/pager.js?v=20260820-4"></script>
|
||||
<script src="js/views.js?v=20260820-4"></script>
|
||||
<script src="js/app.js?v=20260820-4"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -3,32 +3,32 @@
|
||||
const PagerAPI = (() => {
|
||||
let apiBase = '';
|
||||
let on401 = null;
|
||||
// Reads are polled and must never hang a page's refresh loop; writes have
|
||||
// server-side timeouts up to 45s (radio deploys) so they get no client
|
||||
// abort.
|
||||
const GET_TIMEOUT_MS = 20000;
|
||||
async function request(method, path, body) {
|
||||
const WRITE_TIMEOUT_MS = 45000;
|
||||
async function request(method, path, body, attempt) {
|
||||
attempt = attempt || 0;
|
||||
const opts = { method, headers: {}, credentials: 'include' };
|
||||
if (body !== undefined) {
|
||||
opts.headers['Content-Type'] = 'application/json';
|
||||
opts.body = JSON.stringify(body);
|
||||
}
|
||||
const timeoutMs = method === 'GET' ? GET_TIMEOUT_MS : WRITE_TIMEOUT_MS;
|
||||
const ctl = new AbortController();
|
||||
const timer = method === 'GET' ? setTimeout(() => ctl.abort(), GET_TIMEOUT_MS) : null;
|
||||
if (timer) opts.signal = ctl.signal;
|
||||
const timer = setTimeout(() => ctl.abort(), timeoutMs);
|
||||
opts.signal = ctl.signal;
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(apiBase + path, opts);
|
||||
} catch (e) {
|
||||
if (timer && e && e.name === 'AbortError') {
|
||||
const error = new Error('Request timed out');
|
||||
error.status = 0;
|
||||
throw error;
|
||||
clearTimeout(timer);
|
||||
if (method === 'GET' && attempt < 1) {
|
||||
return request(method, path, body, attempt + 1);
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
const error = new Error((e && e.name === 'AbortError') ? 'Request timed out' : (e && e.message) || 'Network error');
|
||||
error.status = 0;
|
||||
throw error;
|
||||
}
|
||||
clearTimeout(timer);
|
||||
if (res.status === 401) {
|
||||
if (on401) on401();
|
||||
throw new Error('unauthorized');
|
||||
|
||||
@@ -241,7 +241,7 @@ const App = (() => {
|
||||
});
|
||||
}
|
||||
|
||||
function handleMenuAction(action) {
|
||||
function handleMenuAction(action, button) {
|
||||
closeToolbarMenus();
|
||||
if (action === 'help') {
|
||||
location.hash = '#/settings/help';
|
||||
@@ -252,14 +252,18 @@ const App = (() => {
|
||||
if (typeof views.openClientModeModal === 'function') views.openClientModeModal();
|
||||
else checkInternet(true);
|
||||
} else if (action === 'logout') {
|
||||
if (button) { button.disabled = true; button.classList.add('busy'); }
|
||||
PagerAPI.post('/api/logout')
|
||||
.then(() => showLogin())
|
||||
.catch((error) => toast(error.message || 'Logout failed', 'error'));
|
||||
.catch((error) => toast(error.message || 'Logout failed', 'error'))
|
||||
.finally(() => { if (button) { button.disabled = false; button.classList.remove('busy'); } });
|
||||
} else if (action === 'reboot') {
|
||||
if (!window.confirm('Reboot Mark VIII now?')) return;
|
||||
if (button) { button.disabled = true; button.classList.add('busy'); }
|
||||
PagerAPI.post('/api/settings/reboot')
|
||||
.then(() => toast('Reboot requested. Mark VIII will disconnect shortly.'))
|
||||
.catch((error) => toast(error.message || 'Reboot failed', 'error'));
|
||||
.catch((error) => toast(error.message || 'Reboot failed', 'error'))
|
||||
.finally(() => { if (button) { button.disabled = false; button.classList.remove('busy'); } });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -332,7 +336,7 @@ const App = (() => {
|
||||
renderNotifications();
|
||||
});
|
||||
Array.prototype.forEach.call(els.overflowMenu.querySelectorAll('[data-menu-action]'), (item) => {
|
||||
item.addEventListener('click', () => handleMenuAction(item.getAttribute('data-menu-action')));
|
||||
item.addEventListener('click', () => handleMenuAction(item.getAttribute('data-menu-action'), item));
|
||||
});
|
||||
document.addEventListener('click', closeToolbarMenus);
|
||||
|
||||
@@ -342,13 +346,14 @@ const App = (() => {
|
||||
const pw = document.getElementById('login-password').value;
|
||||
document.getElementById('login-error').textContent = '';
|
||||
btn.disabled = true;
|
||||
btn.classList.add('busy');
|
||||
PagerAPI.login('root', pw)
|
||||
.then(() => { document.getElementById('login-password').value = ''; showApp(); toast('Logged in'); })
|
||||
.catch((err) => {
|
||||
document.getElementById('login-error').textContent = (err && err.message && err.message !== 'unauthorized')
|
||||
? 'Login failed.' : 'Invalid credentials.';
|
||||
})
|
||||
.finally(() => { btn.disabled = false; });
|
||||
.finally(() => { btn.disabled = false; btn.classList.remove('busy'); });
|
||||
});
|
||||
|
||||
document.getElementById('terminal-btn').addEventListener('click', () => {
|
||||
@@ -516,6 +521,9 @@ const Live = (() => {
|
||||
if (h.pineap_up === false) {
|
||||
el.textContent = 'PINEAPD DOWN';
|
||||
el.className = 'health-chip bad';
|
||||
} else if (h.env && h.env.overall === 'fail') {
|
||||
el.textContent = 'ENV CHECK FAIL';
|
||||
el.className = 'health-chip bad';
|
||||
} else if (h.pool_disabled) {
|
||||
el.textContent = 'POOL OFF';
|
||||
el.className = 'health-chip warn';
|
||||
|
||||
@@ -5,6 +5,7 @@ const Pager = (() => {
|
||||
const SCREEN_HEIGHT = 222;
|
||||
const FB_STRIDE = SCREEN_WIDTH * 4;
|
||||
const PAGER_WIDTH = 745;
|
||||
const MAX_QUEUED_KEYS = 24;
|
||||
|
||||
const KEY_MAP = {
|
||||
'LEFT.png': 'ArrowLeft',
|
||||
@@ -23,6 +24,10 @@ const Pager = (() => {
|
||||
let screenerr = null;
|
||||
let keyws = null;
|
||||
let screenws = null;
|
||||
let wantOpen = false;
|
||||
let retryTimer = null;
|
||||
let retryMs = 400;
|
||||
const pendingKeys = [];
|
||||
|
||||
function ensure() {
|
||||
if (table) return;
|
||||
@@ -36,10 +41,17 @@ const Pager = (() => {
|
||||
const src = img.getAttribute('src').split('/').pop();
|
||||
const key = KEY_MAP[src];
|
||||
if (!key) return;
|
||||
img.addEventListener('click', () => press(img, key));
|
||||
img.setAttribute('alt', key.replace('Arrow', ''));
|
||||
img.addEventListener('pointerdown', (event) => {
|
||||
event.preventDefault();
|
||||
press(img, key);
|
||||
});
|
||||
});
|
||||
const retry = document.getElementById('screen_retry');
|
||||
if (retry) retry.addEventListener('click', () => connect());
|
||||
if (retry) retry.addEventListener('click', () => {
|
||||
retryMs = 400;
|
||||
connect();
|
||||
});
|
||||
}
|
||||
|
||||
function press(el, key) {
|
||||
@@ -48,8 +60,26 @@ const Pager = (() => {
|
||||
sendKey(key);
|
||||
}
|
||||
|
||||
function queueKey(k) {
|
||||
pendingKeys.push(k);
|
||||
while (pendingKeys.length > MAX_QUEUED_KEYS) pendingKeys.shift();
|
||||
}
|
||||
|
||||
function flushKeys() {
|
||||
while (pendingKeys.length && keyws && keyws.readyState === WebSocket.OPEN) {
|
||||
try { keyws.send(pendingKeys.shift()); }
|
||||
catch (e) { break; }
|
||||
}
|
||||
}
|
||||
|
||||
function sendKey(k) {
|
||||
if (keyws && keyws.readyState === WebSocket.OPEN) keyws.send(k);
|
||||
if (keyws && keyws.readyState === WebSocket.OPEN) {
|
||||
try { keyws.send(k); return true; }
|
||||
catch (e) {}
|
||||
}
|
||||
queueKey(k);
|
||||
if (wantOpen) connectKeys();
|
||||
return false;
|
||||
}
|
||||
|
||||
function renderRGBAFrame(bytes) {
|
||||
@@ -76,33 +106,81 @@ const Pager = (() => {
|
||||
pager.src = canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
function connect() {
|
||||
disconnect();
|
||||
function showError(show) {
|
||||
if (screenerr) screenerr.hidden = !show;
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (!wantOpen) return;
|
||||
clearTimeout(retryTimer);
|
||||
retryTimer = setTimeout(() => {
|
||||
if (!wantOpen) return;
|
||||
connectScreen();
|
||||
connectKeys();
|
||||
}, retryMs);
|
||||
retryMs = Math.min(5000, Math.max(400, retryMs * 2));
|
||||
}
|
||||
|
||||
function connectScreen() {
|
||||
if (!wantOpen) return;
|
||||
if (screenws && (screenws.readyState === WebSocket.OPEN || screenws.readyState === WebSocket.CONNECTING)) return;
|
||||
try {
|
||||
const sock = new WebSocket(App.pagerScreenWs);
|
||||
sock.binaryType = 'arraybuffer';
|
||||
screenws = sock;
|
||||
sock.onopen = () => { screenerr.hidden = true; };
|
||||
sock.onopen = () => {
|
||||
retryMs = 400;
|
||||
if (keyws && keyws.readyState === WebSocket.OPEN) showError(false);
|
||||
};
|
||||
sock.onmessage = (ev) => {
|
||||
showError(false);
|
||||
if (ev.data instanceof ArrayBuffer) renderRGBAFrame(new Uint8Array(ev.data));
|
||||
else if (ev.data && ev.data.arrayBuffer) ev.data.arrayBuffer().then((b) => renderRGBAFrame(new Uint8Array(b)));
|
||||
};
|
||||
sock.onerror = () => { screenerr.hidden = false; };
|
||||
sock.onclose = () => { if (screenws === sock) screenws = null; screenerr.hidden = false; };
|
||||
sock.onerror = () => { showError(true); };
|
||||
sock.onclose = () => {
|
||||
if (screenws === sock) screenws = null;
|
||||
showError(true);
|
||||
scheduleReconnect();
|
||||
};
|
||||
} catch (e) {
|
||||
screenerr.hidden = false;
|
||||
}
|
||||
try {
|
||||
const sock = new WebSocket(App.pagerKeysWs);
|
||||
keyws = sock;
|
||||
sock.onclose = () => { if (keyws === sock) keyws = null; };
|
||||
sock.onerror = () => { try { sock.close(); } catch (e2) {} };
|
||||
} catch (e) {
|
||||
keyws = null;
|
||||
showError(true);
|
||||
scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
function connectKeys() {
|
||||
if (!wantOpen) return;
|
||||
if (keyws && (keyws.readyState === WebSocket.OPEN || keyws.readyState === WebSocket.CONNECTING)) return;
|
||||
try {
|
||||
const sock = new WebSocket(App.pagerKeysWs);
|
||||
keyws = sock;
|
||||
sock.onopen = () => {
|
||||
retryMs = 400;
|
||||
flushKeys();
|
||||
if (screenws && screenws.readyState === WebSocket.OPEN) showError(false);
|
||||
};
|
||||
sock.onclose = () => {
|
||||
if (keyws === sock) keyws = null;
|
||||
scheduleReconnect();
|
||||
};
|
||||
sock.onerror = () => { try { sock.close(); } catch (e2) {} };
|
||||
} catch (e) {
|
||||
keyws = null;
|
||||
scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
function connect() {
|
||||
connectScreen();
|
||||
connectKeys();
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
wantOpen = false;
|
||||
clearTimeout(retryTimer);
|
||||
retryTimer = null;
|
||||
pendingKeys.length = 0;
|
||||
if (screenws) { try { screenws.close(); } catch (e) {} screenws = null; }
|
||||
if (keyws) { try { keyws.close(); } catch (e) {} keyws = null; }
|
||||
}
|
||||
@@ -121,6 +199,8 @@ const Pager = (() => {
|
||||
panel.classList.remove('hidden');
|
||||
document.getElementById('pager-btn').classList.add('active');
|
||||
applyScale();
|
||||
wantOpen = true;
|
||||
retryMs = 400;
|
||||
connect();
|
||||
try { pager.focus(); } catch (e) {}
|
||||
} else {
|
||||
|
||||
@@ -5,6 +5,9 @@ const Term = (() => {
|
||||
let fitAddon = null;
|
||||
let ws = null;
|
||||
let panel = null;
|
||||
let wantOpen = false;
|
||||
let retryTimer = null;
|
||||
let retryMs = 400;
|
||||
|
||||
function ensure() {
|
||||
if (term) return;
|
||||
@@ -14,7 +17,20 @@ const Term = (() => {
|
||||
term.loadAddon(fitAddon);
|
||||
term.open(document.getElementById('terminal'));
|
||||
try { fitAddon.fit(); } catch (e) {}
|
||||
term.onData((d) => { if (ws && ws.readyState === WebSocket.OPEN) ws.send(d); });
|
||||
term.onData((d) => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
try { ws.send(d); } catch (e) {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (!wantOpen) return;
|
||||
clearTimeout(retryTimer);
|
||||
retryTimer = setTimeout(() => {
|
||||
if (wantOpen) connect();
|
||||
}, retryMs);
|
||||
retryMs = Math.min(5000, Math.max(400, retryMs * 2));
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
@@ -23,6 +39,8 @@ const Term = (() => {
|
||||
panel.classList.remove('hidden');
|
||||
document.getElementById('terminal-btn').classList.add('active');
|
||||
try { fitAddon.fit(); } catch (e) {}
|
||||
wantOpen = true;
|
||||
retryMs = 400;
|
||||
connect();
|
||||
} else {
|
||||
panel.classList.add('hidden');
|
||||
@@ -32,25 +50,39 @@ const Term = (() => {
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (ws) return;
|
||||
if (!wantOpen) return;
|
||||
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return;
|
||||
if (term) term.reset();
|
||||
let sock;
|
||||
try {
|
||||
sock = new WebSocket(App.terminalWs);
|
||||
} catch (e) {
|
||||
term.writeln('\r\n[cannot reach daemon terminal: ' + e.message + ']');
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
ws = sock;
|
||||
sock.onopen = () => { retryMs = 400; };
|
||||
sock.onmessage = (ev) => {
|
||||
if (typeof ev.data === 'string') term.write(ev.data);
|
||||
else ev.data.text().then((t) => term.write(t));
|
||||
};
|
||||
sock.onclose = () => { if (ws === sock) ws = null; if (term) term.writeln('\r\n[connection closed]'); };
|
||||
sock.onclose = () => {
|
||||
if (ws === sock) ws = null;
|
||||
if (term && wantOpen) {
|
||||
term.writeln('\r\n[connection closed — reconnecting]');
|
||||
scheduleReconnect();
|
||||
} else if (term) {
|
||||
term.writeln('\r\n[connection closed]');
|
||||
}
|
||||
};
|
||||
sock.onerror = () => { try { sock.close(); } catch (e) {} };
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
wantOpen = false;
|
||||
clearTimeout(retryTimer);
|
||||
retryTimer = null;
|
||||
if (ws) { try { ws.close(); } catch (e) {} ws = null; }
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+58
-9
@@ -97,31 +97,75 @@ PY
|
||||
printf 'Built: %s\n' "$ZIP_PATH"
|
||||
|
||||
TARGET="$PAGER_USER@$PAGER_HOST"
|
||||
SSH_OPTS=(-o StrictHostKeyChecking=accept-new)
|
||||
if [[ -n "$PASSWORD" && -z "$SSH_KEY" ]]; then
|
||||
SSH_OPTS+=(-o PreferredAuthentications=password -o PubkeyAuthentication=no)
|
||||
fi
|
||||
|
||||
run_scp() {
|
||||
if [[ -n "$PASSWORD" && -n "$SSH_KEY" ]]; then
|
||||
SSHPASS="$PASSWORD" sshpass -e scp -i "$SSH_KEY" "$@"
|
||||
SSHPASS="$PASSWORD" sshpass -e scp "${SSH_OPTS[@]}" -i "$SSH_KEY" "$@"
|
||||
elif [[ -n "$PASSWORD" ]]; then
|
||||
SSHPASS="$PASSWORD" sshpass -e scp "$@"
|
||||
SSHPASS="$PASSWORD" sshpass -e scp "${SSH_OPTS[@]}" "$@"
|
||||
elif [[ -n "$SSH_KEY" ]]; then
|
||||
scp -i "$SSH_KEY" "$@"
|
||||
scp "${SSH_OPTS[@]}" -i "$SSH_KEY" "$@"
|
||||
else
|
||||
scp "$@"
|
||||
scp "${SSH_OPTS[@]}" "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
run_ssh() {
|
||||
if [[ -n "$PASSWORD" && -n "$SSH_KEY" ]]; then
|
||||
SSHPASS="$PASSWORD" sshpass -e ssh -i "$SSH_KEY" "$@"
|
||||
SSHPASS="$PASSWORD" sshpass -e ssh "${SSH_OPTS[@]}" -i "$SSH_KEY" "$@"
|
||||
elif [[ -n "$PASSWORD" ]]; then
|
||||
SSHPASS="$PASSWORD" sshpass -e ssh "$@"
|
||||
SSHPASS="$PASSWORD" sshpass -e ssh "${SSH_OPTS[@]}" "$@"
|
||||
elif [[ -n "$SSH_KEY" ]]; then
|
||||
ssh -i "$SSH_KEY" "$@"
|
||||
ssh "${SSH_OPTS[@]}" -i "$SSH_KEY" "$@"
|
||||
else
|
||||
ssh "$@"
|
||||
ssh "${SSH_OPTS[@]}" "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
install_python3() {
|
||||
if run_ssh "$TARGET" 'command -v python3 >/dev/null'; then
|
||||
printf 'python3 already present on the pager.\n'
|
||||
return 0
|
||||
fi
|
||||
printf 'python3 missing on pager; installing python3-light (offline ipks).\n'
|
||||
local cache="$ROOT/build/python-ipk"
|
||||
local pkg_base='https://downloads.openwrt.org/releases/24.10.1/packages/mipsel_24kc'
|
||||
mkdir -p "$cache"
|
||||
local files=(
|
||||
"base/libbz2-1.0_1.0.8-r1_mipsel_24kc.ipk"
|
||||
"packages/libpython3-3.11_3.11.14-r1_mipsel_24kc.ipk"
|
||||
"packages/python3-base_3.11.14-r1_mipsel_24kc.ipk"
|
||||
"packages/python3-light_3.11.14-r1_mipsel_24kc.ipk"
|
||||
)
|
||||
local names=()
|
||||
local rel
|
||||
for rel in "${files[@]}"; do
|
||||
local name="${rel##*/}"
|
||||
names+=("$name")
|
||||
if [[ ! -s "$cache/$name" ]]; then
|
||||
curl -fsSL --retry 3 -o "$cache/$name" "$pkg_base/$rel"
|
||||
fi
|
||||
done
|
||||
run_ssh "$TARGET" 'mkdir -p /tmp/python-ipk && rm -rf /tmp/python-ipk/*'
|
||||
(
|
||||
cd "$cache"
|
||||
run_scp "${names[@]}" "$TARGET:/tmp/python-ipk/"
|
||||
)
|
||||
run_ssh "$TARGET" 'set -e
|
||||
cd /tmp/python-ipk
|
||||
opkg install libbz2-1.0_*.ipk libpython3-3.11_*.ipk python3-base_*.ipk python3-light_*.ipk
|
||||
command -v python3 >/dev/null
|
||||
python3 -c "import json,socket,hashlib,threading,select,subprocess,struct,base64,re"
|
||||
rm -rf /tmp/python-ipk
|
||||
echo PYTHON_OK'
|
||||
}
|
||||
|
||||
install_python3
|
||||
|
||||
run_scp "$ZIP_PATH" "$MANIFEST_PATH" "$TARGET:/tmp/"
|
||||
|
||||
REMOTE_PAYLOAD_DIR="user/$PAYLOAD_CATEGORY/$PAYLOAD_KEY"
|
||||
@@ -151,8 +195,13 @@ else
|
||||
exit 1
|
||||
fi
|
||||
rm -f '/tmp/$ZIP_NAME' /tmp/_hak5_manifest.json
|
||||
if [ -x /etc/init.d/pagerwebui ] && /etc/init.d/pagerwebui running >/dev/null 2>&1; then
|
||||
cp '$REMOTE_PAYLOAD_DIR/pagerwebui.init' /etc/init.d/pagerwebui
|
||||
chmod +x /etc/init.d/pagerwebui
|
||||
/etc/init.d/pagerwebui enable
|
||||
if /etc/init.d/pagerwebui running >/dev/null 2>&1; then
|
||||
/etc/init.d/pagerwebui restart
|
||||
else
|
||||
/etc/init.d/pagerwebui start
|
||||
fi
|
||||
echo EXTRACT_OK"
|
||||
run_ssh "$TARGET" "$REMOTE_COMMAND"
|
||||
|
||||
+136
-3
@@ -80,11 +80,20 @@ class AttacksDeployTest(unittest.TestCase):
|
||||
self.old_state = server.PINEAP_STATE_FILE
|
||||
server.PINEAP_STATE_FILE = os.path.join(self.tmp, 'state.json')
|
||||
self.old_ent = {k: getattr(server, k) for k in
|
||||
('ENT_CONF', 'ENT_PIDFILE', 'ENT_EAP_USERS', 'ENT_STATE')}
|
||||
('ENT_CONF', 'ENT_PIDFILE', 'ENT_EAP_USERS', 'ENT_STATE',
|
||||
'ENT_DIR', 'ENT_CA_CERT', 'ENT_SERVER_CERT', 'ENT_SERVER_KEY',
|
||||
'ENT_LOG', 'ENT_CAPTURES', 'ENT_DH_FILE')}
|
||||
server.ENT_CONF = os.path.join(self.tmp, 'enterprise.conf')
|
||||
server.ENT_PIDFILE = os.path.join(self.tmp, 'mk8.pid')
|
||||
server.ENT_EAP_USERS = os.path.join(self.tmp, 'eap_users')
|
||||
server.ENT_STATE = os.path.join(self.tmp, 'state.json')
|
||||
server.ENT_DIR = os.path.join(self.tmp, 'ent')
|
||||
server.ENT_CA_CERT = os.path.join(server.ENT_DIR, 'ca.pem')
|
||||
server.ENT_SERVER_CERT = os.path.join(server.ENT_DIR, 'server.pem')
|
||||
server.ENT_SERVER_KEY = os.path.join(server.ENT_DIR, 'server.key')
|
||||
server.ENT_LOG = os.path.join(server.ENT_DIR, 'hostapd.log')
|
||||
server.ENT_CAPTURES = os.path.join(server.ENT_DIR, 'captures.json')
|
||||
server.ENT_DH_FILE = os.path.join(server.ENT_DIR, 'dh.pem')
|
||||
self.old_ent_running = server._ent_running
|
||||
self.old_ent_state = server._ent_state_loaded
|
||||
server._ent_running = lambda: True
|
||||
@@ -203,10 +212,25 @@ class AttacksDeployTest(unittest.TestCase):
|
||||
self.assertIn(['iw', 'phy', 'phy1', 'interface', 'add', 'wlan1ent',
|
||||
'type', 'managed'], cmds)
|
||||
self.assertIn(['iw', 'dev', 'wlan1ent', 'set', 'type', 'ap'], cmds)
|
||||
self.assertIn(['/usr/sbin/hostapd', '-B', '-P', server.ENT_PIDFILE,
|
||||
server.ENT_CONF], cmds)
|
||||
self.assertTrue(any(c[:4] == ['/usr/sbin/hostapd', '-B', '-P', server.ENT_PIDFILE]
|
||||
for c in cmds))
|
||||
self.assertEqual(self.f.state['pineapd.@hostapd[0].mgmtiface'], 'wlan1ent')
|
||||
self.assertEqual(self.f.state['pineapd.wlan1mon.hop'], '0')
|
||||
with open(server.ENT_CONF) as f:
|
||||
conf = f.read()
|
||||
self.assertIn('ca_cert=', conf)
|
||||
self.assertIn('server_cert=', conf)
|
||||
self.assertIn('private_key=', conf)
|
||||
self.assertIn('ieee8021x=1', conf)
|
||||
self.assertIn('eap_server=1', conf)
|
||||
self.assertNotIn('eap_server_identity', conf)
|
||||
self.assertNotIn('eap_server_erp', conf)
|
||||
self.assertNotIn('dh_file=', conf)
|
||||
self.assertIn('ieee80211w=0', conf)
|
||||
with open(server.ENT_EAP_USERS) as f:
|
||||
users = f.read()
|
||||
self.assertIn('PEAP,TTLS', users)
|
||||
self.assertIn('[2]', users)
|
||||
|
||||
def test_deploy_enterprise_rejects_non_5g_channel(self):
|
||||
status, _ = server.h_attacks_deploy(ctx({
|
||||
@@ -223,6 +247,77 @@ class AttacksDeployTest(unittest.TestCase):
|
||||
cmds = [r[0] for r in self.f.runs]
|
||||
self.assertIn(['iw', 'dev', 'wlan1ent', 'del'], cmds)
|
||||
|
||||
def test_deploy_enterprise_writes_passphrase_and_hidden(self):
|
||||
status, payload = server.h_attacks_deploy(ctx({
|
||||
'kind': 'enterprise', 'ssid': 'CorpAP', 'passphrase': 'labsecret',
|
||||
'enctype': 'wpa2', 'hidden': True, 'channel': 36}))
|
||||
self.assertEqual(status, 200)
|
||||
with open(server.ENT_EAP_USERS) as f:
|
||||
users = f.read()
|
||||
self.assertIn('labsecret', users)
|
||||
self.assertIn('PEAP,TTLS', users)
|
||||
self.assertIn('[2]', users)
|
||||
with open(server.ENT_CONF) as f:
|
||||
conf = f.read()
|
||||
self.assertIn('ignore_broadcast_ssid=1', conf)
|
||||
self.assertIn('ca_cert=', conf)
|
||||
|
||||
def test_eap_secret_sanitizes_quotes(self):
|
||||
self.assertEqual(server._eap_secret(''), 'dummy')
|
||||
self.assertEqual(server._eap_secret('ab"c\ndef'), 'abcdef')
|
||||
|
||||
def test_hostapd_unknown_items_parse_pager_error(self):
|
||||
err = ("Line 14: unknown configuration item 'eap_server_identity'\n"
|
||||
"1 errors found in configuration file '/root/loot/enterprise.conf'\n"
|
||||
"Failed to set up interface with /root/loot/enterprise.conf\n"
|
||||
"Failed to initialize interface\n")
|
||||
self.assertEqual(server._hostapd_unknown_items(err), ['eap_server_identity'])
|
||||
|
||||
def test_drop_hostapd_keys_removes_only_named_lines(self):
|
||||
conf = ('interface=wlan1ent\n'
|
||||
'eap_server=1\n'
|
||||
'eap_server_identity=hostapd\n'
|
||||
'dh_file=/tmp/dh.pem\n')
|
||||
new, changed = server._drop_hostapd_keys(
|
||||
conf, ['eap_server_identity', 'dh_file'])
|
||||
self.assertTrue(changed)
|
||||
self.assertIn('eap_server=1', new)
|
||||
self.assertIn('interface=wlan1ent', new)
|
||||
self.assertNotIn('eap_server_identity', new)
|
||||
self.assertNotIn('dh_file=', new)
|
||||
|
||||
def test_start_ent_hostapd_strips_unknown_keys_and_retries(self):
|
||||
with open(server.ENT_CONF, 'w') as f:
|
||||
f.write('interface=wlan1ent\neap_server=1\neap_server_identity=hostapd\n')
|
||||
orig = server.device_run
|
||||
seen = []
|
||||
|
||||
def wrapped(args, timeout=20, input_data=None):
|
||||
a = list(args)
|
||||
if a and a[0] == '/usr/sbin/hostapd':
|
||||
with open(server.ENT_CONF) as fh:
|
||||
text = fh.read()
|
||||
seen.append(text)
|
||||
if 'eap_server_identity' in text:
|
||||
return (1, '',
|
||||
"Line 3: unknown configuration item 'eap_server_identity'\n"
|
||||
"1 errors found in configuration file '%s'\n"
|
||||
"Failed to initialize interface\n" % server.ENT_CONF)
|
||||
return (0, '', '')
|
||||
return orig(args, timeout=timeout, input_data=input_data)
|
||||
|
||||
server.device_run = wrapped
|
||||
try:
|
||||
rc, _out, _err = server._start_ent_hostapd()
|
||||
finally:
|
||||
server.device_run = orig
|
||||
self.assertEqual(rc, 0)
|
||||
self.assertGreaterEqual(len(seen), 2)
|
||||
with open(server.ENT_CONF) as f:
|
||||
conf = f.read()
|
||||
self.assertNotIn('eap_server_identity', conf)
|
||||
self.assertIn('eap_server=1', conf)
|
||||
|
||||
def test_deploy_validation(self):
|
||||
status, _ = server.h_attacks_deploy(ctx({'kind': 'wpa', 'ssid': ''}))
|
||||
self.assertEqual(status, 400)
|
||||
@@ -322,5 +417,43 @@ class AttacksExportTest(unittest.TestCase):
|
||||
self.assertIn('/root/loot/pcap/b.cap', hc)
|
||||
|
||||
|
||||
class AttacksStatusTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.saved = {
|
||||
'_count_table': server._count_table,
|
||||
'_ent_summary': server._ent_summary,
|
||||
'daemon_sock_call': server.daemon_sock_call,
|
||||
'_uci_ap_summary': server._uci_ap_summary,
|
||||
'_read_hop': server._read_hop,
|
||||
}
|
||||
|
||||
def tearDown(self):
|
||||
for name, fn in self.saved.items():
|
||||
setattr(server, name, fn)
|
||||
|
||||
def test_status_exposes_enterprise_ap_for_ui(self):
|
||||
server._count_table = lambda t: {
|
||||
'hostap_handshake': 2, 'hostap_basic': 3, 'hostap_chalresp': 1
|
||||
}.get(t, 0)
|
||||
server._ent_summary = lambda detail=True: {
|
||||
'enabled': True, 'live': True, 'ssid': 'CorpLab',
|
||||
'iface': 'wlan1ent', 'stations': ['AA:BB:CC:DD:EE:FF'],
|
||||
'auth_method': 'mschapv2', 'certs': True, 'captures': 4,
|
||||
'ctrl_linked': True,
|
||||
}
|
||||
server.daemon_sock_call = lambda *a, **k: (200, {'pineape_disabled': False})
|
||||
server._uci_ap_summary = lambda *a, **k: None
|
||||
server._read_hop = lambda: '1'
|
||||
status, payload = server.h_attacks_status(ctx())
|
||||
self.assertEqual(status, 200)
|
||||
self.assertTrue(payload['enterprise']['ap']['live'])
|
||||
self.assertEqual(payload['enterprise']['ap']['ssid'], 'CorpLab')
|
||||
self.assertEqual(payload['enterprise']['identities'], 3)
|
||||
self.assertEqual(payload['enterprise']['mschapv2'], 1)
|
||||
self.assertEqual(payload['enterprise']['creds'], 4)
|
||||
self.assertEqual(payload['handshakes'], 2)
|
||||
self.assertTrue(payload['enterprise']['pineape']['enabled'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
import importlib
|
||||
import io
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import redirect_stdout
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'remote_access', 'pager-webui'))
|
||||
import server
|
||||
|
||||
|
||||
def setUpModule():
|
||||
importlib.reload(server)
|
||||
|
||||
|
||||
class EnvCheckTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
fd, self.db = tempfile.mkstemp(suffix='.db')
|
||||
os.close(fd)
|
||||
conn = sqlite3.connect(self.db)
|
||||
conn.execute('CREATE TABLE scan(id INTEGER PRIMARY KEY, time INT, name TEXT)')
|
||||
conn.execute("INSERT INTO scan (time, name) VALUES (1786466531, 'pager')")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
server.RECON_DB = self.db
|
||||
self.runs = []
|
||||
self.ping_ok = True
|
||||
self.daemon_ok = True
|
||||
self.ip_link_ok = True
|
||||
self.iface_up = {'wlan0mon': True, 'wlan1mon': True}
|
||||
self.uci_state = {}
|
||||
server.ENV_CHECK_STATE.update({'report': None, 'overall': None, 'updated': 0,
|
||||
'pool_runtime': None})
|
||||
self.old_iface_up = server._iface_up
|
||||
server._iface_up = lambda name: self.iface_up.get(name, True)
|
||||
self.old_daemon = server.daemon_sock_call
|
||||
self.old_run = server.device_run
|
||||
server.daemon_sock_call = self.fake_daemon
|
||||
server.device_run = self.fake_run
|
||||
|
||||
def tearDown(self):
|
||||
server._iface_up = self.old_iface_up
|
||||
server.daemon_sock_call = self.old_daemon
|
||||
server.device_run = self.old_run
|
||||
try:
|
||||
os.unlink(self.db)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def fake_daemon(self, method, path, body=None, timeout=10):
|
||||
if self.daemon_ok:
|
||||
return 200, {'autossidpool': False}
|
||||
return 0, None
|
||||
|
||||
def fake_run(self, args, timeout=20, input_data=None):
|
||||
self.runs.append(list(args))
|
||||
a = list(args)
|
||||
if a[0] == 'pidof' and a[1] == 'pineapple':
|
||||
return (0, '23456\n', '') if self.daemon_ok else (1, '', '')
|
||||
if a[0] == 'pidof' and a[1] == 'pineapd':
|
||||
return (0, '12345\n', '') if self.ping_ok else (1, '', '')
|
||||
if a[0] == 'uci':
|
||||
if a[1] == 'set':
|
||||
k, _, v = a[2].partition('=')
|
||||
self.uci_state[k] = v
|
||||
return (0, '', '')
|
||||
if a[1] == 'delete':
|
||||
for k in list(self.uci_state):
|
||||
if k == a[2] or k.startswith(a[2] + '.'):
|
||||
del self.uci_state[k]
|
||||
return (0, '', '')
|
||||
if a[1] == 'get':
|
||||
return (0, self.uci_state.get(a[2], '') + '\n', '')
|
||||
if a[1] == 'commit':
|
||||
return (0, '', '')
|
||||
if a[1] == 'show':
|
||||
sec = a[2]
|
||||
return (0, ''.join("%s=%s\n" % (k, v) for k, v in self.uci_state.items()
|
||||
if k.startswith(sec + '.')), '')
|
||||
if a[0] == '_pineap':
|
||||
return (0, '', '')
|
||||
if a[:3] == ['ip', 'link', 'set']:
|
||||
if self.ip_link_ok:
|
||||
self.iface_up[a[3]] = True
|
||||
return (0, '', '')
|
||||
return (1, '', 'interface unavailable')
|
||||
if a[0] in ('ip', '/etc/init.d/pineapd'):
|
||||
return (0, '', '')
|
||||
return (0, '', '')
|
||||
|
||||
def safe_set(self):
|
||||
for key, value in server.PINEAPD_SAFE_UCI.items():
|
||||
self.uci_state[key] = value
|
||||
|
||||
def steps(self, report, needle):
|
||||
return [r for r in report if needle in r['detail']]
|
||||
|
||||
def test_pass_when_state_sane(self):
|
||||
self.safe_set()
|
||||
report = server.env_check()
|
||||
self.assertEqual(server.ENV_CHECK_STATE['overall'], 'pass')
|
||||
self.assertEqual([r['ok'] for r in report],
|
||||
['pass'] * len(report))
|
||||
self.assertEqual(server.ENV_CHECK_STATE['pool_runtime'], 'disabled')
|
||||
self.assertNotIn(['_pineap', 'SSIDPOOL', 'DISABLE'], self.runs)
|
||||
|
||||
def test_warns_sane_defaults_when_missing_without_mutating(self):
|
||||
self.uci_state['pineapd.@ssidpool[0].ssid'] = 'QmVlcg=='
|
||||
report = server.env_check()
|
||||
self.assertEqual(
|
||||
self.steps(report, 'live PineAP UCI left unchanged')[0]['ok'], 'warn')
|
||||
self.assertNotIn(['/etc/init.d/pineapd', 'restart'], self.runs)
|
||||
self.assertNotIn(['/etc/init.d/pineapd', 'stop'], self.runs)
|
||||
self.assertFalse(any(a[:2] == ['uci', 'set'] for a in self.runs))
|
||||
self.assertFalse(any(a[:2] == ['uci', 'commit'] for a in self.runs))
|
||||
self.assertFalse(any(a[:2] == ['uci', 'delete'] for a in self.runs))
|
||||
self.assertEqual(self.uci_state['pineapd.@ssidpool[0].ssid'], 'QmVlcg==')
|
||||
for key in server.PINEAPD_SAFE_UCI:
|
||||
self.assertNotIn(key, self.uci_state)
|
||||
|
||||
def test_uci_pass_when_already_set(self):
|
||||
self.safe_set()
|
||||
report = server.env_check()
|
||||
self.assertEqual(self.steps(report, 'sane-off UCI defaults already set')[0]['ok'], 'pass')
|
||||
|
||||
def test_does_not_commit_refilled_pool_while_live(self):
|
||||
self.safe_set()
|
||||
self.uci_state['pineapd.@ssidpool[0].ssid'] = 'QmVlcg=='
|
||||
report = server.env_check()
|
||||
actions = ' | '.join((r.get('action') or '') for r in report)
|
||||
self.assertNotIn('pool-list cleared', actions)
|
||||
self.assertIn('pineapd.@ssidpool[0].ssid', self.uci_state)
|
||||
|
||||
def test_restarts_pineapd_when_down(self):
|
||||
self.safe_set()
|
||||
self.ping_ok = False
|
||||
self.pidof_calls = 0
|
||||
real_ping = self.fake_run
|
||||
|
||||
def ping_then_up(args, timeout=20, input_data=None):
|
||||
if args[0] == 'pidof' and args[1] == 'pineapd':
|
||||
self.pidof_calls += 1
|
||||
if self.pidof_calls > 1:
|
||||
return (0, '12345\n', '')
|
||||
return real_ping(args, timeout=timeout, input_data=input_data)
|
||||
|
||||
server.device_run = ping_then_up
|
||||
report = server.env_check()
|
||||
self.assertEqual(self.steps(report, 'pineapd was down')[0]['ok'], 'fixed')
|
||||
self.assertIn(['/etc/init.d/pineapd', 'restart'], self.runs)
|
||||
|
||||
def test_fail_when_pineapd_stays_down(self):
|
||||
self.safe_set()
|
||||
self.ping_ok = False
|
||||
report = server.env_check()
|
||||
self.assertEqual(self.steps(report, 'did not come back')[0]['ok'], 'fail')
|
||||
self.assertEqual(server.ENV_CHECK_STATE['overall'], 'fail')
|
||||
|
||||
def test_fail_when_daemon_unreachable(self):
|
||||
self.daemon_ok = False
|
||||
report = server.env_check()
|
||||
self.assertEqual(self.steps(report, 'daemon unreachable')[0]['ok'], 'fail')
|
||||
self.assertEqual(server.ENV_CHECK_STATE['overall'], 'fail')
|
||||
|
||||
def test_raises_down_monitors(self):
|
||||
self.safe_set()
|
||||
self.iface_up = {'wlan0mon': False, 'wlan1mon': True}
|
||||
report = server.env_check()
|
||||
self.assertEqual(self.steps(report, 'monitor interfaces brought up')[0]['ok'], 'fixed')
|
||||
self.assertIn(['ip', 'link', 'set', 'wlan0mon', 'up'], self.runs)
|
||||
|
||||
def test_unavailable_monitor_fails_startup_contract(self):
|
||||
self.safe_set()
|
||||
self.iface_up = {'wlan0mon': False, 'wlan1mon': True}
|
||||
self.ip_link_ok = False
|
||||
report = server.env_check()
|
||||
step = self.steps(report, 'monitor interfaces unavailable')[0]
|
||||
self.assertEqual(step['ok'], 'fail')
|
||||
self.assertEqual(server.ENV_CHECK_STATE['overall'], 'fail')
|
||||
|
||||
def test_monitors_up_pass(self):
|
||||
self.safe_set()
|
||||
report = server.env_check()
|
||||
self.assertEqual(self.steps(report, 'monitors up')[0]['ok'], 'pass')
|
||||
|
||||
def test_recon_db_unreadable_fails(self):
|
||||
self.safe_set()
|
||||
os.unlink(self.db)
|
||||
report = server.env_check()
|
||||
self.assertEqual(self.steps(report, 'recon DB unreadable')[0]['ok'], 'fail')
|
||||
self.assertEqual(server.ENV_CHECK_STATE['overall'], 'fail')
|
||||
|
||||
def test_recon_db_readable_reports_count(self):
|
||||
self.safe_set()
|
||||
report = server.env_check()
|
||||
step = self.steps(report, 'recon DB readable')[0]
|
||||
self.assertEqual(step['ok'], 'pass')
|
||||
self.assertIn('(1 scans)', step['detail'])
|
||||
|
||||
def test_wlan0_pinned_warns(self):
|
||||
self.safe_set()
|
||||
self.uci_state['wireless.wlan0wpa.disabled'] = '0'
|
||||
report = server.env_check()
|
||||
self.assertEqual(self.steps(report, '2.4GHz under-sampled')[0]['ok'], 'warn')
|
||||
|
||||
def test_wlan0_not_pinned_when_absent(self):
|
||||
self.safe_set()
|
||||
report = server.env_check()
|
||||
self.assertEqual(self.steps(report, 'no radio0 AP pins wlan0mon')[0]['ok'], 'pass')
|
||||
|
||||
def test_wlan0_not_pinned_when_disabled(self):
|
||||
self.safe_set()
|
||||
self.uci_state['wireless.wlan0open.disabled'] = '1'
|
||||
self.uci_state['wireless.wlan0wpa.disabled'] = '1'
|
||||
report = server.env_check()
|
||||
self.assertEqual(self.steps(report, 'no radio0 AP pins wlan0mon')[0]['ok'], 'pass')
|
||||
|
||||
def test_sta_uplink_warns_when_enabled_without_mutating(self):
|
||||
self.safe_set()
|
||||
self.uci_state['wireless.dummy_radio0.mode'] = 'sta'
|
||||
self.uci_state['wireless.dummy_radio0.ifname'] = 'wlan0'
|
||||
self.uci_state['wireless.dummy_radio0.disabled'] = '0'
|
||||
report = server.env_check()
|
||||
self.assertEqual(self.steps(report, 'dummy_radio0 STA uplink is enabled')[0]['ok'], 'warn')
|
||||
self.assertEqual(self.uci_state['wireless.dummy_radio0.disabled'], '0')
|
||||
self.assertNotIn(['ip', 'link', 'set', 'wlan0', 'down'], self.runs)
|
||||
self.assertNotIn(['wifi', 'reload'], self.runs)
|
||||
self.assertFalse(any(
|
||||
a[:2] == ['uci', 'set'] and 'dummy_radio0' in a[2]
|
||||
for a in self.runs if len(a) > 2))
|
||||
|
||||
def test_sta_uplink_pass_when_absent(self):
|
||||
self.safe_set()
|
||||
report = server.env_check()
|
||||
self.assertEqual(self.steps(report, 'no STA uplink pinning phy0')[0]['ok'], 'pass')
|
||||
|
||||
def test_sta_uplink_pass_when_disabled(self):
|
||||
self.safe_set()
|
||||
self.uci_state['wireless.dummy_radio0.mode'] = 'sta'
|
||||
self.uci_state['wireless.dummy_radio0.disabled'] = '1'
|
||||
report = server.env_check()
|
||||
self.assertEqual(self.steps(report, 'no STA uplink pinning phy0')[0]['ok'], 'pass')
|
||||
|
||||
def test_recon_status_exposes_sta(self):
|
||||
self.safe_set()
|
||||
self.uci_state['wireless.dummy_radio0.mode'] = 'sta'
|
||||
status, payload = server.h_recon_status(type('C', (), {'query': {}})())
|
||||
self.assertEqual(status, 200)
|
||||
self.assertTrue(payload['wlan0_sta'])
|
||||
|
||||
def test_cli_exits_zero_on_pass(self):
|
||||
self.safe_set()
|
||||
buf = io.StringIO()
|
||||
with redirect_stdout(buf):
|
||||
code = server.env_check_cli()
|
||||
self.assertEqual(code, 0)
|
||||
self.assertIn('[PASS]', buf.getvalue())
|
||||
self.assertIn('ENVIRONMENT CHECK: PASS', buf.getvalue())
|
||||
|
||||
def test_cli_exits_one_on_fail(self):
|
||||
self.safe_set()
|
||||
self.daemon_ok = False
|
||||
buf = io.StringIO()
|
||||
with redirect_stdout(buf):
|
||||
code = server.env_check_cli()
|
||||
self.assertEqual(code, 1)
|
||||
self.assertIn('[FAIL]', buf.getvalue())
|
||||
|
||||
def test_startup_check_retries_core_failure(self):
|
||||
reports = [
|
||||
[{'ok': 'fail', 'detail': 'daemon unreachable'}],
|
||||
[{'ok': 'pass', 'detail': 'daemon reachable'}],
|
||||
]
|
||||
old_check = server.env_check
|
||||
old_sleep = server.time.sleep
|
||||
|
||||
def check():
|
||||
report = reports.pop(0)
|
||||
server.ENV_CHECK_STATE['overall'] = report[0]['ok']
|
||||
return report
|
||||
|
||||
server.env_check = check
|
||||
server.time.sleep = lambda seconds: None
|
||||
try:
|
||||
result = server.startup_env_check(attempts=2, delay=0)
|
||||
finally:
|
||||
server.env_check = old_check
|
||||
server.time.sleep = old_sleep
|
||||
self.assertEqual(result[0]['ok'], 'pass')
|
||||
|
||||
def test_startup_check_raises_after_retries(self):
|
||||
old_check = server.env_check
|
||||
old_sleep = server.time.sleep
|
||||
|
||||
def check():
|
||||
server.ENV_CHECK_STATE['overall'] = 'fail'
|
||||
return [{'ok': 'fail', 'detail': 'daemon unreachable'}]
|
||||
|
||||
server.env_check = check
|
||||
server.time.sleep = lambda seconds: None
|
||||
try:
|
||||
with self.assertRaises(RuntimeError):
|
||||
server.startup_env_check(attempts=2, delay=0)
|
||||
finally:
|
||||
server.env_check = old_check
|
||||
server.time.sleep = old_sleep
|
||||
|
||||
def test_health_exposes_env_and_pool_runtime(self):
|
||||
self.safe_set()
|
||||
server.env_check()
|
||||
status, payload = server.h_health(type('C', (), {'query': {}})())
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(payload['pool_runtime'], 'disabled')
|
||||
self.assertEqual(payload['env']['overall'], 'pass')
|
||||
self.assertEqual(payload['env']['counts']['pass'], len(payload['env']['steps']))
|
||||
|
||||
def test_recon_status_exposes_wlan0_pinned(self):
|
||||
self.safe_set()
|
||||
self.uci_state['wireless.wlan0open.disabled'] = '0'
|
||||
status, payload = server.h_recon_status(type('C', (), {'query': {}})())
|
||||
self.assertEqual(status, 200)
|
||||
self.assertTrue(payload['wlan0_pinned'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
+20
-17
@@ -14,6 +14,7 @@ class HealthCheckTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.runs = []
|
||||
self.ping_ok = True
|
||||
self.ip_link_ok = True
|
||||
self.sigsegvs = 0
|
||||
self.iface_up = {'wlan0mon': True, 'wlan1mon': True}
|
||||
self.uci_state = {}
|
||||
@@ -32,6 +33,11 @@ class HealthCheckTest(unittest.TestCase):
|
||||
return (1, '', '')
|
||||
if a[0] == 'logread':
|
||||
return (0, 'SIGSEGV\n' * self.sigsegs if hasattr(self, 'sigsegs') else '', '')
|
||||
if a[:3] == ['ip', 'link', 'set']:
|
||||
if self.ip_link_ok:
|
||||
self.iface_up[a[3]] = True
|
||||
return (0, '', '')
|
||||
return (1, '', 'interface unavailable')
|
||||
if a[:2] == ['uci', 'set']:
|
||||
k, _, v = a[2].partition('=')
|
||||
self.uci_state[k] = v
|
||||
@@ -67,12 +73,15 @@ class HealthCheckTest(unittest.TestCase):
|
||||
self.assertIn(['ip', 'link', 'set', 'wlan0mon', 'up'], [r[0] for r in self.runs])
|
||||
self.assertEqual(result['monitor_fixes'], 1)
|
||||
|
||||
def test_down_with_growing_sigsegv_disables_pool(self):
|
||||
def test_down_restarts_pineapd_without_rewriting_uci(self):
|
||||
self.ping_ok = False
|
||||
self.sigsegs = 5
|
||||
self.uci_state['pineapd.@ssidpool[0].ssid'] = 'QmVlcg=='
|
||||
result = server.health_check()
|
||||
self.assertIn('pool broadcast disabled', result['last_action'])
|
||||
self.assertEqual(self.uci_state['pineapd.@ssidpool[0].disable'], '1')
|
||||
self.assertEqual(result['last_action'], 'pineapd restart')
|
||||
self.assertNotIn('pineapd.@ssidpool[0].disable', self.uci_state)
|
||||
self.assertEqual(self.uci_state['pineapd.@ssidpool[0].ssid'], 'QmVlcg==')
|
||||
self.assertFalse(any(r[0][:2] == ['uci', 'set'] for r in self.runs))
|
||||
self.assertIn(['/etc/init.d/pineapd', 'restart'], [r[0] for r in self.runs])
|
||||
self.assertEqual(result['fixes'], 1)
|
||||
|
||||
@@ -88,21 +97,15 @@ class HealthCheckTest(unittest.TestCase):
|
||||
self.assertEqual(result['last_action'], 'pineapd restart')
|
||||
self.assertIn(['/etc/init.d/pineapd', 'restart'], [r[0] for r in self.runs])
|
||||
|
||||
def test_down_stabilizes_known_crash_sources(self):
|
||||
self.ping_ok = False
|
||||
self.uci_state['pineapd.@ssidpool[0].disable'] = '1'
|
||||
result = server.health_check()
|
||||
self.assertEqual(self.uci_state['pineapd.wlan2mon.disable'], '1')
|
||||
self.assertEqual(self.uci_state['pineapd.wlan1mon.bands'], '5')
|
||||
self.assertIn('stabilized', result['last_action'])
|
||||
|
||||
def test_down_clears_refilled_pool_list(self):
|
||||
def test_down_does_not_stabilize_or_clear_pool(self):
|
||||
self.ping_ok = False
|
||||
self.uci_state['pineapd.@ssidpool[0].disable'] = '1'
|
||||
self.uci_state['pineapd.@ssidpool[0].ssid'] = 'QmVlcg=='
|
||||
result = server.health_check()
|
||||
self.assertNotIn('pineapd.@ssidpool[0].ssid', self.uci_state)
|
||||
self.assertIn('pool-list cleared', result['last_action'])
|
||||
self.assertEqual(result['last_action'], 'pineapd restart')
|
||||
self.assertEqual(self.uci_state['pineapd.@ssidpool[0].ssid'], 'QmVlcg==')
|
||||
self.assertNotIn('pineapd.wlan2mon.disable', self.uci_state)
|
||||
self.assertNotIn('pineapd.wlan1mon.bands', self.uci_state)
|
||||
|
||||
def test_down_without_crash_brings_monitors_up(self):
|
||||
self.ping_ok = False
|
||||
@@ -112,12 +115,12 @@ class HealthCheckTest(unittest.TestCase):
|
||||
self.assertEqual(result['last_action'], 'monitor interfaces brought up')
|
||||
self.assertIn(['ip', 'link', 'set', 'wlan1mon', 'up'], [r[0] for r in self.runs])
|
||||
|
||||
def test_down_disables_pool_regardless_of_sigsegv_history(self):
|
||||
def test_down_does_not_disable_pool_regardless_of_sigsegv_history(self):
|
||||
self.ping_ok = False
|
||||
server._health['sigsegv_last'] = 4
|
||||
result = server.health_check()
|
||||
self.assertIn('SSID pool broadcast disabled', result['last_action'])
|
||||
self.assertEqual(self.uci_state['pineapd.@ssidpool[0].disable'], '1')
|
||||
self.assertEqual(result['last_action'], 'pineapd restart')
|
||||
self.assertNotIn('pineapd.@ssidpool[0].disable', self.uci_state)
|
||||
|
||||
def test_fix_cooldown_prevents_thrash(self):
|
||||
self.ping_ok = False
|
||||
|
||||
@@ -153,6 +153,26 @@ class LoggingTest(unittest.TestCase):
|
||||
self.assertEqual(server._line_count(
|
||||
type('C', (), {'query': {'lines': '-10'}})(), 200), 0)
|
||||
|
||||
def test_system_uses_bounded_logread(self):
|
||||
calls = []
|
||||
|
||||
def fake(args, timeout=20, input_data=None):
|
||||
calls.append(list(args))
|
||||
return (0, '\n'.join('line%d' % i for i in range(20)), '')
|
||||
|
||||
server.device_run = fake
|
||||
status, payload = server.h_logging_system(type('C', (), {
|
||||
'args': (), 'query': {'lines': '5'}})())
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(len(payload['lines']), 5)
|
||||
self.assertTrue(calls)
|
||||
self.assertEqual(calls[0][:2], ['logread', '-l'])
|
||||
|
||||
def test_json_or_and_pool_list_tolerate_empty(self):
|
||||
self.assertIsNone(server._json_or(None))
|
||||
self.assertEqual(server._parse_pool_list(None), [])
|
||||
self.assertEqual(server._unique_keep_order(['a', '', 'a', 'b']), ['a', 'b'])
|
||||
|
||||
|
||||
class SettingsTest(unittest.TestCase):
|
||||
def test_hostname_get(self):
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'remote_access', 'pager-webui'))
|
||||
@@ -10,34 +13,217 @@ def setUpModule():
|
||||
__import__('importlib').reload(server)
|
||||
|
||||
|
||||
class EnterpriseTest(unittest.TestCase):
|
||||
SCHEMA = '''
|
||||
CREATE TABLE hostap_basic(id INTEGER PRIMARY KEY, scan INT, time INT, type TEXT,
|
||||
identity TEXT, password TEXT, verified INT NOT NULL DEFAULT 0);
|
||||
CREATE TABLE hostap_chalresp(id INTEGER PRIMARY KEY, scan INT, time INT, type TEXT,
|
||||
username TEXT, challenge BLOB, response BLOB,
|
||||
verified INT NOT NULL DEFAULT 0);
|
||||
CREATE TABLE hostap_client(id INTEGER PRIMARY KEY, scan INT, hash INT, mac TEXT, ssid BLOB,
|
||||
connected_time INT, disconnected_time INT);
|
||||
'''
|
||||
|
||||
|
||||
class EnterpriseApiTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._orig_rows = server._db_rows
|
||||
self._orig_write = server._db_write
|
||||
|
||||
def tearDown(self):
|
||||
server._db_rows = self._orig_rows
|
||||
server._db_write = self._orig_write
|
||||
|
||||
def test_basic_rows(self):
|
||||
server._db_rows = lambda db, sql: [{'time': 1, 'username': 'a', 'password': 'b'}]
|
||||
status, payload = server.h_enterprise_data(type('C', (), {'args': ('basic',)})())
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(payload['table'], 'hostap_basic')
|
||||
self.assertEqual(payload['rows'][0]['username'], 'a')
|
||||
self.assertEqual(payload['rows'][0]['identity'], 'a')
|
||||
|
||||
def test_challenge_rows(self):
|
||||
def test_challenge_rows_empty(self):
|
||||
server._db_rows = lambda db, sql: []
|
||||
status, payload = server.h_enterprise_data(type('C', (), {'args': ('challenge',)})())
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(payload['table'], 'hostap_chalresp')
|
||||
self.assertEqual(payload['rows'], [])
|
||||
|
||||
def test_unknown_table(self):
|
||||
status, payload = server.h_enterprise_data(type('C', (), {'args': ('nope',)})())
|
||||
self.assertEqual(status, 400)
|
||||
|
||||
def test_clear(self):
|
||||
def test_clear_uses_chalresp_table(self):
|
||||
calls = []
|
||||
server._db_write = lambda db, sql: calls.append(sql)
|
||||
status, payload = server.h_enterprise_clear(type('C', (), {'body': {'table': 'challenge'}})())
|
||||
self.assertEqual(status, 200)
|
||||
self.assertTrue(any('hostap_challenge' in s for s in calls))
|
||||
self.assertTrue(any('hostap_chalresp' in s for s in calls))
|
||||
self.assertFalse(any('hostap_challenge' in s for s in calls))
|
||||
|
||||
def test_clear_all(self):
|
||||
calls = []
|
||||
server._db_write = lambda db, sql: calls.append(sql)
|
||||
status, payload = server.h_enterprise_clear(type('C', (), {'body': {'table': 'all'}})())
|
||||
self.assertEqual(status, 200)
|
||||
joined = ' '.join(calls)
|
||||
self.assertIn('hostap_basic', joined)
|
||||
self.assertIn('hostap_chalresp', joined)
|
||||
|
||||
def test_clear_unknown_table(self):
|
||||
status, payload = server.h_enterprise_clear(type('C', (), {'body': {'table': 'nope'}})())
|
||||
self.assertEqual(status, 400)
|
||||
|
||||
|
||||
class EnterpriseHashFormatTest(unittest.TestCase):
|
||||
def test_blob_to_hex_bytes_and_hex_string(self):
|
||||
self.assertEqual(server._blob_to_hex(b'\x11\x22\x33\x44'), '11223344')
|
||||
self.assertEqual(server._blob_to_hex('AABBCCDD'), 'aabbccdd')
|
||||
self.assertEqual(server._blob_to_hex("X'AABB'"), 'aabb')
|
||||
self.assertEqual(server._blob_to_hex('\\xde\\xad'), 'dead')
|
||||
|
||||
def test_hashcat_5500_and_john(self):
|
||||
chal = '1122334455667788'
|
||||
resp = '00112233445566778899aabbccddeeff0011223344556677'
|
||||
self.assertEqual(
|
||||
server._mschap_hashcat_5500('bob', chal, resp),
|
||||
'bob::::00112233445566778899aabbccddeeff0011223344556677:1122334455667788')
|
||||
self.assertEqual(
|
||||
server._mschap_john('bob', chal, resp),
|
||||
'bob:$NETNTLM$1122334455667788$00112233445566778899aabbccddeeff0011223344556677')
|
||||
|
||||
def test_format_chalresp_row_hexes_blobs_and_is_json_safe(self):
|
||||
row = server._format_chalresp_row({
|
||||
'time': 1700000000,
|
||||
'username': 'alice',
|
||||
'type': 'MSCHAPV2',
|
||||
'challenge': bytes.fromhex('1122334455667788'),
|
||||
'response': bytes.fromhex('00112233445566778899aabbccddeeff0011223344556677'),
|
||||
'verified': 0,
|
||||
})
|
||||
self.assertEqual(row['challenge'], '1122334455667788')
|
||||
self.assertEqual(row['response'], '00112233445566778899aabbccddeeff0011223344556677')
|
||||
self.assertIn('alice::::', row['hashcat'])
|
||||
self.assertIn(':$NETNTLM$', row['john'])
|
||||
json.dumps(row)
|
||||
|
||||
|
||||
class EnterpriseLogParseTest(unittest.TestCase):
|
||||
def test_parse_wpe_mschapv2_and_identity(self):
|
||||
log = (
|
||||
"mschapv2: Wed Aug 19 21:00:00 2026\n"
|
||||
" username: bob\n"
|
||||
" challenge: 11:22:33:44:55:66:77:88\n"
|
||||
" response: 00112233445566778899aabbccddeeff0011223344556677\n"
|
||||
"hashcat NETNTLM: bob::::00112233445566778899aabbccddeeff0011223344556677:1122334455667788\n"
|
||||
"EAP-Identity 'alice@corp.local'\n"
|
||||
"GTC: username: carol password: hunter2\n"
|
||||
)
|
||||
items = server._parse_ent_log(log)
|
||||
kinds = [i['kind'] for i in items]
|
||||
self.assertIn('mschapv2', kinds)
|
||||
self.assertIn('eap-identity', kinds)
|
||||
self.assertIn('gtc', kinds)
|
||||
mschap = [i for i in items if i['kind'] == 'mschapv2'][0]
|
||||
self.assertEqual(mschap['username'], 'bob')
|
||||
self.assertIn('bob::::', mschap['hashcat'])
|
||||
gtc = [i for i in items if i['kind'] == 'gtc'][0]
|
||||
self.assertEqual(gtc['password'], 'hunter2')
|
||||
|
||||
|
||||
class EnterpriseCaptureDbTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
fd, self.db = tempfile.mkstemp(suffix='.db')
|
||||
os.close(fd)
|
||||
conn = sqlite3.connect(self.db)
|
||||
conn.executescript(SCHEMA)
|
||||
chal = bytes.fromhex('1122334455667788')
|
||||
resp = bytes.fromhex('00112233445566778899aabbccddeeff0011223344556677')
|
||||
conn.execute(
|
||||
"INSERT INTO hostap_basic (id, scan, time, type, identity, password, verified) "
|
||||
"VALUES (1, 1, 1700000001, 'PEAP', 'bob', '', 0)")
|
||||
conn.execute(
|
||||
"INSERT INTO hostap_chalresp (id, scan, time, type, username, challenge, response, verified) "
|
||||
"VALUES (1, 1, 1700000002, 'MSCHAPV2', 'bob', ?, ?, 0)", (chal, resp))
|
||||
conn.execute(
|
||||
"INSERT INTO hostap_client (id, scan, hash, mac, ssid, connected_time, disconnected_time) "
|
||||
"VALUES (1, 1, 1, 'AABBCCDDEEFF', X'436F7270', 1700000003, NULL)")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
self.old_db = server.RECON_DB
|
||||
server.RECON_DB = self.db
|
||||
|
||||
def tearDown(self):
|
||||
server.RECON_DB = self.old_db
|
||||
try:
|
||||
os.unlink(self.db)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def test_challenge_endpoint_returns_hashcat(self):
|
||||
status, payload = server.h_enterprise_data(type('C', (), {'args': ('challenge',)})())
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(payload['table'], 'hostap_chalresp')
|
||||
row = payload['rows'][0]
|
||||
self.assertEqual(row['username'], 'bob')
|
||||
self.assertEqual(row['challenge'], '1122334455667788')
|
||||
self.assertEqual(
|
||||
row['hashcat'],
|
||||
'bob::::00112233445566778899aabbccddeeff0011223344556677:1122334455667788')
|
||||
json.dumps(payload, default=server._json_default)
|
||||
|
||||
def test_radius_payload_unifies_captures(self):
|
||||
status, payload = server.h_enterprise_radius(type('C', (), {'args': ()})())
|
||||
self.assertEqual(status, 200)
|
||||
self.assertTrue(payload['note'])
|
||||
kinds = [c['kind'] for c in payload['captures']]
|
||||
self.assertIn('eap-identity', kinds)
|
||||
self.assertIn('mschapv2', kinds)
|
||||
self.assertEqual(payload['hashcat']['mode'], 5500)
|
||||
self.assertEqual(len(payload['hashcat']['lines']), 1)
|
||||
self.assertEqual(payload['clients'][0]['ssid'], 'Corp')
|
||||
|
||||
def test_export_hashcat_download(self):
|
||||
status, payload = server.h_enterprise_export(type('C', (), {'args': ('hashcat',)})())
|
||||
self.assertEqual(status, 200)
|
||||
self.assertIsInstance(payload, server.Download)
|
||||
self.assertIn(b'bob::::', payload.data)
|
||||
self.assertTrue(payload.filename.endswith('.5500'))
|
||||
|
||||
def test_export_john_and_json(self):
|
||||
status, payload = server.h_enterprise_export(type('C', (), {'args': ('john',)})())
|
||||
self.assertEqual(status, 200)
|
||||
self.assertIn(b'$NETNTLM$', payload.data)
|
||||
status, payload = server.h_enterprise_export(type('C', (), {'args': ('json',)})())
|
||||
self.assertEqual(status, 200)
|
||||
body = json.loads(payload.data.decode('utf-8'))
|
||||
self.assertEqual(body['hashcat']['mode'], 5500)
|
||||
|
||||
|
||||
class EnterpriseHarvestTest(unittest.TestCase):
|
||||
def test_harvest_reads_hostapd_file_and_skips_logread(self):
|
||||
calls = []
|
||||
fd, log_path = tempfile.mkstemp()
|
||||
os.write(fd, b"EAP-Identity 'fromfile'\n")
|
||||
os.close(fd)
|
||||
cap_path = log_path + '.json'
|
||||
old_log, old_cap = server.ENT_LOG, server.ENT_CAPTURES
|
||||
old_run = server.device_run
|
||||
server.ENT_LOG = log_path
|
||||
server.ENT_CAPTURES = cap_path
|
||||
server.device_run = lambda args, timeout=20, input_data=None: (
|
||||
calls.append(list(args)) or (0, "identity: 'syslog-user'\n", ''))
|
||||
try:
|
||||
items = server._harvest_ent_log()
|
||||
self.assertTrue(any(item.get('username') == 'fromfile' for item in items))
|
||||
self.assertFalse(any(args and args[0] == 'logread' for args in calls))
|
||||
finally:
|
||||
server.ENT_LOG = old_log
|
||||
server.ENT_CAPTURES = old_cap
|
||||
server.device_run = old_run
|
||||
os.unlink(log_path)
|
||||
if os.path.exists(cap_path):
|
||||
os.unlink(cap_path)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
@@ -53,7 +53,7 @@ class SsidPoolHandlersTest(unittest.TestCase):
|
||||
server.h_ssids_post(type('C', (), {'args': (), 'body': {'action': 'add', 'ssid': 'NewNet'}})())
|
||||
self.assertTrue(any(c[0] == 'PINEAPPLE_SSID_POOL_ADD' for c in calls))
|
||||
|
||||
def test_advertise_routes(self):
|
||||
def test_advertise_enable_is_blocked(self):
|
||||
calls = []
|
||||
|
||||
def fake(method, path, body=None, timeout=10):
|
||||
@@ -61,10 +61,11 @@ class SsidPoolHandlersTest(unittest.TestCase):
|
||||
return (200, {'success': True})
|
||||
|
||||
server.daemon_sock_call = fake
|
||||
server.h_pineap_advertise(type('C', (), {'body': {'enable': True}})())
|
||||
status, payload = server.h_pineap_advertise(type('C', (), {'body': {'enable': True}})())
|
||||
self.assertEqual(status, 400)
|
||||
self.assertEqual(calls, [])
|
||||
server.h_pineap_advertise(type('C', (), {'body': {'enable': False}})())
|
||||
self.assertEqual(calls, [('/api/pineap/ssidpool/enable', {'enable': True}),
|
||||
('/api/pineap/ssidpool/disable', {'enable': False})])
|
||||
self.assertEqual(calls, [('/api/pineap/ssidpool/disable', {'enable': False})])
|
||||
|
||||
def test_collect_routes(self):
|
||||
calls = []
|
||||
|
||||
@@ -144,6 +144,7 @@ class PineapProxyTest(unittest.TestCase):
|
||||
self.assertEqual(payload['enterprise']['enabled'], False)
|
||||
self.assertEqual(payload['enterprise']['ssid'], '')
|
||||
self.assertEqual(payload['pool']['collecting'], True)
|
||||
self.assertTrue(payload['pool']['broadcast_blocked'])
|
||||
self.assertEqual(payload['radios']['radio0']['band'], '2.4')
|
||||
self.assertEqual(payload['radios']['radio1']['channel'], 'auto')
|
||||
self.assertEqual(payload['pineape']['enabled'], True)
|
||||
@@ -261,6 +262,32 @@ class PineapFilterTest(unittest.TestCase):
|
||||
[server.HAK5CMD, 'PINEAPPLE_NETWORK_FILTER_CLEAR', 'deny'],
|
||||
[server.HAK5CMD, 'PINEAPPLE_NETWORK_FILTER_MODE', 'deny']])
|
||||
|
||||
def test_filter_get_falls_back_to_hak5cmd_when_daemon_is_down(self):
|
||||
server.daemon_sock_call = lambda method, path, body=None, timeout=10: (0, None)
|
||||
|
||||
def fake(args, timeout=20, input_data=None):
|
||||
cmd = args[1] if len(args) > 1 else ''
|
||||
if cmd.endswith('_MODE'):
|
||||
return (0, 'deny\n', '')
|
||||
if cmd.endswith('_LIST'):
|
||||
return (0, 'AA:BB:CC:DD:EE:FF\n', '')
|
||||
return (0, '', '')
|
||||
|
||||
server.device_run = fake
|
||||
status, payload = server.h_filter_get(ctx(), 'client')
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(payload['mode'], 'deny')
|
||||
self.assertEqual(payload['entries'], ['AA:BB:CC:DD:EE:FF'])
|
||||
self.assertEqual(payload['source'], 'hak5cmd')
|
||||
|
||||
def test_filter_get_returns_empty_list_when_everything_is_down(self):
|
||||
server.daemon_sock_call = lambda method, path, body=None, timeout=10: (0, None)
|
||||
server.device_run = lambda args, timeout=20, input_data=None: (1, '', 'refused')
|
||||
status, payload = server.h_filter_get(ctx(), 'ssid')
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(payload['entries'], [])
|
||||
self.assertIn('error', payload)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
+354
-20
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
@@ -39,8 +40,22 @@ def make_db():
|
||||
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)")
|
||||
"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.execute("INSERT INTO wifi_device (hash, scan, mac, time, signal, freq, packets) VALUES (101, 1, 'AA11BB22CC33', 1786466533, -61, 2412, 4)")
|
||||
conn.execute("INSERT INTO wifi_device (hash, scan, mac, time, signal, freq, packets) VALUES (102, 1, 'DDEEFFEEDD00', 1786466534, -58, 2412, 7)")
|
||||
conn.execute("INSERT INTO wifi_device (hash, scan, mac, time, signal, freq, packets) VALUES (103, 1, '021122334455', 1786466535, -80, 2412, 1)")
|
||||
conn.execute("INSERT INTO ssid (hash, wifi_device, scan, type, bssid, ssid, hidden, time, signal, freq, channel, encryption) "
|
||||
"VALUES (101, 102, 1, 8, 'DDEEFFEEDD00', X'5365636F6E642D4E6574', 0, 1786466534, -58, 2412, 1, 0)")
|
||||
conn.execute("INSERT INTO handshake (hash, scan, stahash, aphash, time) VALUES (101, 1, 101, 102, 1786466602)")
|
||||
conn.execute("INSERT INTO hostap_client (id, scan, hash, mac, ssid, connected_time, disconnected_time) "
|
||||
"VALUES (101, 1, 20, 'AE77C0EB3141', X'416E646572736F6E2D35', 1786466601, 1786466605)")
|
||||
conn.execute("INSERT INTO hostap_client (id, scan, hash, mac, ssid, connected_time, disconnected_time) "
|
||||
"VALUES (102, 1, 22, 'AA11BB22CC33', X'486F73744150', 1786466603, NULL)")
|
||||
conn.execute("INSERT INTO hostap_client (id, scan, hash, mac, ssid, connected_time, disconnected_time) "
|
||||
"VALUES (103, 1, 23, 'AA11BB22CC33', X'486F73744150', 1786466604, 1786466606)")
|
||||
conn.execute("INSERT INTO ssid (hash, wifi_device, scan, type, bssid, ssid, hidden, time, signal, freq, channel, encryption) "
|
||||
"VALUES (102, 101, 1, 5, NULL, X'50726F62654F6E6C7953534944', 0, 1786466604, -30, 2412, NULL, NULL)")
|
||||
conn.execute("INSERT INTO hostap_basic (scan, time, type, identity, password, verified) VALUES (1, 1786466601, 'WPA', 'bob', '', 0)")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -101,16 +116,16 @@ class ReconDataTest(unittest.TestCase):
|
||||
self.assertEqual(newest['time'], 1786466848)
|
||||
self.assertEqual(newest['name'], 'pager')
|
||||
old = data['scans'][1]
|
||||
self.assertEqual(old['devices'], 2)
|
||||
self.assertEqual(old['aps'], 2)
|
||||
self.assertEqual(old['handshakes'], 1)
|
||||
self.assertEqual(old['devices'], 5)
|
||||
self.assertEqual(old['aps'], 3)
|
||||
self.assertEqual(old['handshakes'], 2)
|
||||
self.assertNotIn('uuid', old)
|
||||
|
||||
def test_scan_detail_decodes_aps(self):
|
||||
data = server.recon_scan_data(1)
|
||||
self.assertEqual(data['scan']['id'], 1)
|
||||
self.assertEqual(data['scan']['time'], 1786466531)
|
||||
self.assertEqual(len(data['aps']), 2)
|
||||
self.assertEqual(len(data['aps']), 3)
|
||||
aps = {a['bssid']: a for a in data['aps']}
|
||||
a = aps['C8:9E:43:64:80:80']
|
||||
self.assertEqual(a['ssid'], 'Anderson-5')
|
||||
@@ -125,16 +140,50 @@ class ReconDataTest(unittest.TestCase):
|
||||
def test_scan_detail_clients_exclude_ap_macs(self):
|
||||
data = server.recon_scan_data(1)
|
||||
macs = [c['mac'] for c in data['clients']]
|
||||
self.assertEqual(macs, ['AE:77:C0:EB:31:41'])
|
||||
self.assertEqual(macs, ['AE:77:C0:EB:31:41', 'AA:11:BB:22:CC:33',
|
||||
'02:11:22:33:44:55'])
|
||||
|
||||
def test_scan_detail_handshakes_resolve_macs(self):
|
||||
data = server.recon_scan_data(1)
|
||||
self.assertEqual(len(data['handshakes']), 1)
|
||||
hs = data['handshakes'][0]
|
||||
self.assertEqual(len(data['handshakes']), 2)
|
||||
hs = next(h for h in data['handshakes']
|
||||
if h['client'] == 'AE:77:C0:EB:31:41')
|
||||
self.assertEqual(hs['ap'], 'C8:9E:43:64:80:80')
|
||||
self.assertEqual(hs['client'], 'AE:77:C0:EB:31:41')
|
||||
self.assertEqual(hs['time'], 1786466600)
|
||||
|
||||
def test_scan_detail_associations_are_confirmed_only(self):
|
||||
data = server.recon_scan_data(1)
|
||||
client = next(c for c in data['clients'] if c['mac'] == 'AE:77:C0:EB:31:41')
|
||||
handshake = next(a for a in client['associations'] if 'bssid' in a)
|
||||
self.assertEqual(handshake['sources'], ['handshake'])
|
||||
self.assertEqual(handshake['ssid'], 'Anderson-5')
|
||||
self.assertEqual(handshake['bssid'], 'C8:9E:43:64:80:80')
|
||||
self.assertEqual(client['vendor']['manufacturer'], 'Local/Randomized')
|
||||
self.assertEqual(next(a for a in data['aps'] if a['bssid'] == 'C8:9E:43:64:80:80')['client_count'], 1)
|
||||
self.assertEqual(next(a for a in data['aps'] if a['bssid'] == 'C8:9E:43:64:80:80')['clients'][0]['mac'], client['mac'])
|
||||
self.assertNotIn('ProbeOnlySSID', [a['ssid'] for a in client['associations']])
|
||||
hostap_client = next(c for c in data['clients'] if c['mac'] == 'AA:11:BB:22:CC:33')
|
||||
ssid_only = next(a for a in hostap_client['associations'] if a['ssid'] == 'HostAP')
|
||||
self.assertEqual(ssid_only['sources'], ['hostap_client'])
|
||||
self.assertNotIn('bssid', ssid_only)
|
||||
self.assertEqual(ssid_only['connected_time'], 1786466604)
|
||||
self.assertEqual(ssid_only['disconnected_time'], 1786466606)
|
||||
self.assertEqual(sum(a['ssid'] == 'HostAP' for a in hostap_client['associations']), 1)
|
||||
second_ap = next(a for a in hostap_client['associations'] if 'bssid' in a)
|
||||
self.assertEqual(second_ap['ssid'], 'Second-Net')
|
||||
unassociated = next(c for c in data['clients'] if c['mac'] == '02:11:22:33:44:55')
|
||||
self.assertEqual(unassociated['associations'], [])
|
||||
|
||||
def test_scan_detail_allows_missing_hostap_client_table(self):
|
||||
conn = sqlite3.connect(self.db)
|
||||
conn.execute('DROP TABLE hostap_client')
|
||||
conn.commit()
|
||||
conn.close()
|
||||
data = server.recon_scan_data(1)
|
||||
client = next(c for c in data['clients'] if c['mac'] == 'AE:77:C0:EB:31:41')
|
||||
self.assertEqual([a['sources'] for a in client['associations']], [['handshake']])
|
||||
|
||||
def test_scan_detail_missing_returns_none(self):
|
||||
self.assertIsNone(server.recon_scan_data(999))
|
||||
|
||||
@@ -162,10 +211,138 @@ class FakeSock:
|
||||
pass
|
||||
|
||||
|
||||
class ReconHopperTest(unittest.TestCase):
|
||||
def test_preflight_probes_one_channel_per_radio(self):
|
||||
calls = []
|
||||
with mock.patch.object(
|
||||
server, '_set_monitor_channel',
|
||||
side_effect=lambda interface, channel:
|
||||
calls.append((interface, channel)) or (True, '')):
|
||||
with mock.patch.object(server, '_monitor_down', return_value=False):
|
||||
self.assertEqual(
|
||||
server._recon_hopper_preflight(),
|
||||
(True, 'monitor channel control ready'))
|
||||
expected = [
|
||||
(interface, channels[0])
|
||||
for interface, channels in server.RECON_CHANNELS.items()
|
||||
]
|
||||
self.assertEqual(calls, expected)
|
||||
self.assertEqual(server._recon_hop_state['ifaces'], ['wlan0mon', 'wlan1mon'])
|
||||
|
||||
def test_preflight_skips_busy_radio_and_keeps_the_other(self):
|
||||
def set_channel(interface, channel):
|
||||
if interface == 'wlan0mon':
|
||||
return False, 'wlan0mon channel 1: command failed: Resource busy (-16)'
|
||||
return True, ''
|
||||
|
||||
with mock.patch.object(server, '_set_monitor_channel', side_effect=set_channel):
|
||||
with mock.patch.object(server, '_monitor_down', return_value=False):
|
||||
with mock.patch.object(server, '_sta_uplink_enabled', return_value=False):
|
||||
with mock.patch.object(server, '_wlan0_pinned', return_value=True):
|
||||
ok, detail = server._recon_hopper_preflight()
|
||||
self.assertTrue(ok)
|
||||
self.assertIn('wlan0mon', server._recon_hop_state['skipped'])
|
||||
self.assertEqual(server._recon_hop_state['ifaces'], ['wlan1mon'])
|
||||
self.assertIn('2.4 GHz hopping skipped', detail)
|
||||
self.assertIn('Scanning 5 GHz only', detail)
|
||||
|
||||
def test_preflight_fails_when_no_monitor_is_usable(self):
|
||||
with mock.patch.object(
|
||||
server, '_set_monitor_channel',
|
||||
return_value=(False, 'wlan0mon channel 1: No such device')):
|
||||
with mock.patch.object(server, '_monitor_down', return_value=True):
|
||||
ok, detail = server._recon_hopper_preflight()
|
||||
self.assertFalse(ok)
|
||||
self.assertIn('unavailable', detail.lower())
|
||||
|
||||
def test_busy_error_is_classified(self):
|
||||
self.assertEqual(
|
||||
server._iw_error_kind('wlan0mon channel 1: command failed: Resource busy (-16)'),
|
||||
'busy')
|
||||
self.assertEqual(server._iw_error_kind('No such device'), 'missing')
|
||||
|
||||
def test_set_channel_surfaces_iw_failure(self):
|
||||
with mock.patch.object(
|
||||
server, 'device_run',
|
||||
return_value=(240, '', 'Device or resource busy')):
|
||||
ok, detail = server._set_monitor_channel('wlan0mon', 6)
|
||||
self.assertFalse(ok)
|
||||
self.assertIn('wlan0mon channel 6', detail)
|
||||
self.assertIn('Device or resource busy', detail)
|
||||
|
||||
def test_dummy_sta_not_borrowable_when_client_mode_on(self):
|
||||
with mock.patch.object(server, '_wifi_client_mode_enabled', return_value=True):
|
||||
with mock.patch.object(server, '_wlan0_pinned', return_value=False):
|
||||
with mock.patch.object(server, '_wlan0_mgmt_enabled', return_value=False):
|
||||
self.assertFalse(server._dummy_sta_borrowable())
|
||||
|
||||
def test_dummy_sta_borrowable_when_only_dummy_is_up(self):
|
||||
with mock.patch.object(server, '_wifi_client_mode_enabled', return_value=False):
|
||||
with mock.patch.object(server, '_wlan0_pinned', return_value=False):
|
||||
with mock.patch.object(server, '_wlan0_mgmt_enabled', return_value=False):
|
||||
with mock.patch.object(server, '_iface_associated', return_value=False):
|
||||
with mock.patch.object(server, '_sta_uplink_enabled', return_value=True):
|
||||
self.assertTrue(server._dummy_sta_borrowable())
|
||||
|
||||
def test_preflight_parks_dummy_sta_and_hops_24ghz(self):
|
||||
def set_channel(interface, channel):
|
||||
if interface == 'wlan0mon' and not server._recon_hop_state.get('borrowed_wlan0'):
|
||||
return False, 'wlan0mon channel 1: command failed: Resource busy (-16)'
|
||||
return True, ''
|
||||
|
||||
def borrow():
|
||||
server._recon_hop_state['borrowed_wlan0'] = True
|
||||
return True
|
||||
|
||||
with mock.patch.object(server, '_set_monitor_channel', side_effect=set_channel):
|
||||
with mock.patch.object(server, '_monitor_down', return_value=False):
|
||||
with mock.patch.object(server, '_dummy_sta_borrowable', return_value=True):
|
||||
with mock.patch.object(server, '_borrow_dummy_sta', side_effect=borrow):
|
||||
ok, detail = server._recon_hopper_preflight()
|
||||
self.assertTrue(ok)
|
||||
self.assertEqual(detail, 'monitor channel control ready')
|
||||
self.assertEqual(server._recon_hop_state['ifaces'], ['wlan0mon', 'wlan1mon'])
|
||||
self.assertTrue(server._recon_hop_state['borrowed_wlan0'])
|
||||
self.assertEqual(server._recon_hop_state['skipped'], {})
|
||||
|
||||
def test_preflight_does_not_park_when_ap_holds_phy0(self):
|
||||
def set_channel(interface, channel):
|
||||
if interface == 'wlan0mon':
|
||||
return False, 'wlan0mon channel 1: command failed: Resource busy (-16)'
|
||||
return True, ''
|
||||
|
||||
with mock.patch.object(server, '_set_monitor_channel', side_effect=set_channel):
|
||||
with mock.patch.object(server, '_monitor_down', return_value=False):
|
||||
with mock.patch.object(server, '_dummy_sta_borrowable', return_value=False):
|
||||
with mock.patch.object(server, '_borrow_dummy_sta') as borrow:
|
||||
with mock.patch.object(server, '_sta_uplink_enabled', return_value=False):
|
||||
with mock.patch.object(server, '_wlan0_pinned', return_value=True):
|
||||
ok, detail = server._recon_hopper_preflight()
|
||||
self.assertTrue(ok)
|
||||
borrow.assert_not_called()
|
||||
self.assertEqual(server._recon_hop_state['ifaces'], ['wlan1mon'])
|
||||
self.assertIn('Open AP / Evil WPA', detail)
|
||||
|
||||
def test_reset_restores_parked_dummy_sta(self):
|
||||
server._recon_hop_state['borrowed_wlan0'] = True
|
||||
with mock.patch.object(
|
||||
server, 'device_run', return_value=(0, '', '')) as run:
|
||||
server._reset_recon_hop_state()
|
||||
run.assert_any_call(['ip', 'link', 'set', 'wlan0', 'up'], timeout=10)
|
||||
self.assertFalse(server._recon_hop_state['borrowed_wlan0'])
|
||||
|
||||
|
||||
class DaemonSockTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
# h_recon_start now reads shared scan state; keep these isolated.
|
||||
server._recon_scan_state = {'active': False, 'started': 0, 'duration': 0}
|
||||
preflight = mock.patch.object(
|
||||
server, '_recon_hopper_preflight', return_value=(True, 'ready'))
|
||||
start = mock.patch.object(server, '_start_recon_hopper')
|
||||
preflight.start()
|
||||
start.start()
|
||||
self.addCleanup(preflight.stop)
|
||||
self.addCleanup(start.stop)
|
||||
|
||||
def test_socket_call_posts_json_to_sock(self):
|
||||
server.DAEMON_SOCK = '/tmp/api.sock'
|
||||
@@ -201,6 +378,7 @@ class DaemonSockTest(unittest.TestCase):
|
||||
status, data = server.h_recon_start(ctx)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(calls[0], ('POST', '/api/pineap/recon/new', {'scan_time': 60}))
|
||||
server._start_recon_hopper.assert_called_once_with(60)
|
||||
|
||||
def test_start_defaults_empty_body(self):
|
||||
calls = []
|
||||
@@ -224,9 +402,43 @@ class DaemonSockTest(unittest.TestCase):
|
||||
type('C', (), {'args': (), 'body': {'scan_time': 30}})())
|
||||
self.assertEqual(status, 502)
|
||||
self.assertEqual(data['error'], 'native recon scan failed')
|
||||
self.assertEqual(data['detail'], {'error': 'no radio'})
|
||||
self.assertEqual(data['detail'], 'recon/new: no radio')
|
||||
self.assertEqual(data['daemon'], {'error': 'no radio'})
|
||||
self.assertFalse(server._recon_scan_state['active'])
|
||||
|
||||
def test_start_reports_hopper_preflight_failure(self):
|
||||
calls = []
|
||||
server._recon_hopper_preflight.return_value = (
|
||||
False, 'Recon radios are unavailable. wlan0mon is missing.')
|
||||
server.daemon_sock_call = lambda *args, **kwargs: calls.append(args)
|
||||
status, data = server.h_recon_start(
|
||||
type('C', (), {'args': (), 'body': {'scan_time': 30}})())
|
||||
self.assertEqual(status, 503)
|
||||
self.assertEqual(data['error'], 'Could not prepare recon radios')
|
||||
self.assertIn('unavailable', data['detail'])
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
def test_start_returns_warning_when_a_radio_is_skipped(self):
|
||||
calls = []
|
||||
def fake_preflight():
|
||||
server._recon_hop_state.update({
|
||||
'warning': '2.4 GHz hopping skipped: Open AP is holding phy0. Scanning 5 GHz only.',
|
||||
'ifaces': ['wlan1mon'],
|
||||
'skipped': {'wlan0mon': '2.4 GHz hopping skipped: Open AP is holding phy0.'},
|
||||
'hint': 'Stop the 2.4 GHz AP to hop 2.4 GHz.',
|
||||
})
|
||||
return True, server._recon_hop_state['warning']
|
||||
server._recon_hopper_preflight.side_effect = fake_preflight
|
||||
server.daemon_sock_call = lambda m, p, body=None: calls.append((m, p, body)) or (200, {'success': True})
|
||||
status, data = server.h_recon_start(
|
||||
type('C', (), {'args': (), 'body': {'scan_time': 30}})())
|
||||
self.assertEqual(status, 200)
|
||||
self.assertTrue(data.get('ok'))
|
||||
self.assertIn('2.4 GHz hopping skipped', data.get('warning'))
|
||||
self.assertEqual(data.get('hopping'), ['wlan1mon'])
|
||||
self.assertEqual(calls[0][1], '/api/pineap/recon/new')
|
||||
server._start_recon_hopper.assert_called_once_with(30)
|
||||
|
||||
|
||||
class ReconScanStateTest(unittest.TestCase):
|
||||
"""The webui mirrors the duration of the Pager's native timed scan."""
|
||||
@@ -235,6 +447,13 @@ class ReconScanStateTest(unittest.TestCase):
|
||||
self.db = make_db()
|
||||
server.RECON_DB = self.db
|
||||
server._recon_scan_state = {'active': False, 'started': 0, 'duration': 0}
|
||||
preflight = mock.patch.object(
|
||||
server, '_recon_hopper_preflight', return_value=(True, 'ready'))
|
||||
start = mock.patch.object(server, '_start_recon_hopper')
|
||||
preflight.start()
|
||||
start.start()
|
||||
self.addCleanup(preflight.stop)
|
||||
self.addCleanup(start.stop)
|
||||
|
||||
def tearDown(self):
|
||||
os.unlink(self.db)
|
||||
@@ -355,7 +574,7 @@ class ReconExtrasTest(unittest.TestCase):
|
||||
status, data = server.h_recon_status(type('C', (), {'args': ()})())
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(data['last_scan'], 1786466848)
|
||||
self.assertEqual(data['last_activity'], 1786466532)
|
||||
self.assertEqual(data['last_activity'], 1786466535)
|
||||
self.assertTrue(data['active'])
|
||||
server.time.time = lambda: 1786466532 + 1000
|
||||
status, data = server.h_recon_status(type('C', (), {'args': ()})())
|
||||
@@ -367,12 +586,13 @@ class ReconExtrasTest(unittest.TestCase):
|
||||
status, data = server.h_recon_status(type('C', (), {'args': ()})())
|
||||
self.assertEqual(status, 200)
|
||||
self.assertFalse(data['hopper_online'])
|
||||
self.assertIn('hopper_error', data)
|
||||
self.assertTrue(data['history_reset'])
|
||||
|
||||
def test_hopper_online_cached(self):
|
||||
server._hopper_cache.update({'updated': 0, 'online': None})
|
||||
with mock.patch.object(server, 'wifi_ifaces',
|
||||
return_value=['wlan0mon', 'wlan1mon', 'wlan2mon']):
|
||||
return_value=['wlan0mon', 'wlan1mon']):
|
||||
self.assertTrue(server._hopper_online())
|
||||
# Second call within the cache window must not re-run iwinfo.
|
||||
with mock.patch.object(server, 'wifi_ifaces',
|
||||
@@ -409,7 +629,7 @@ class ReconExtrasTest(unittest.TestCase):
|
||||
self.assertEqual(status, 200)
|
||||
kinds = [e['type'] for e in data['events']]
|
||||
self.assertIn('auth attempt', kinds)
|
||||
self.assertEqual(data['events'][0]['time'], 1786466601)
|
||||
self.assertEqual(data['events'][0]['time'], 1786466602)
|
||||
|
||||
|
||||
class ReconExamineTest(unittest.TestCase):
|
||||
@@ -419,7 +639,7 @@ class ReconExamineTest(unittest.TestCase):
|
||||
ctx = type('C', (), {'args': (), 'body': {'bssid': 'AA:BB:CC:DD:EE:FF'}})()
|
||||
status, data = server.h_recon_examine(ctx)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(calls[0], ('PINEAPPLE_EXAMINE_BSSID', 'AA:BB:CC:DD:EE:FF'))
|
||||
self.assertEqual(calls[0], ('PINEAPPLE_EXAMINE_BSSID', 'AA:BB:CC:DD:EE:FF', '30'))
|
||||
|
||||
def test_examine_channel_calls_hak5(self):
|
||||
calls = []
|
||||
@@ -427,7 +647,24 @@ class ReconExamineTest(unittest.TestCase):
|
||||
ctx = type('C', (), {'args': (), 'body': {'channel': 6}})()
|
||||
status, data = server.h_recon_examine(ctx)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(calls[0], ('PINEAPPLE_EXAMINE_CHANNEL', '6'))
|
||||
self.assertEqual(calls[0], ('PINEAPPLE_EXAMINE_CHANNEL', '6', '30'))
|
||||
|
||||
def test_examine_channel_5ghz_sends_duration(self):
|
||||
calls = []
|
||||
server.hak5 = lambda *args, **kw: calls.append(args) or ''
|
||||
ctx = type('C', (), {'args': (), 'body': {'channel': 140, 'seconds': 15}})()
|
||||
status, data = server.h_recon_examine(ctx)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(calls[0], ('PINEAPPLE_EXAMINE_CHANNEL', '140', '15'))
|
||||
self.assertEqual(data.get('seconds'), 15)
|
||||
|
||||
def test_examine_compact_bssid_is_colonized(self):
|
||||
calls = []
|
||||
server.hak5 = lambda *args, **kw: calls.append(args) or ''
|
||||
ctx = type('C', (), {'args': (), 'body': {'bssid': 'aabbccddeeff'}})()
|
||||
status, data = server.h_recon_examine(ctx)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(calls[0], ('PINEAPPLE_EXAMINE_BSSID', 'AA:BB:CC:DD:EE:FF', '30'))
|
||||
|
||||
def test_examine_requires_target(self):
|
||||
server.hak5 = lambda *args, **kw: ''
|
||||
@@ -708,6 +945,58 @@ class OuiVendorTest(unittest.TestCase):
|
||||
self.assertEqual(server.oui_vendor(None), 'Unknown')
|
||||
self.assertEqual(server.oui_vendor('--'), 'Unknown')
|
||||
|
||||
def test_oui_identity_prefers_nmap_then_macchanger(self):
|
||||
server._oui_identity_cache = None
|
||||
files = {
|
||||
'/nmap': 'C89E43 Apple Corporation\n',
|
||||
'/mac': 'C89E43 fallback\n',
|
||||
}
|
||||
with mock.patch.object(server, 'OUI_DATA_PATHS', ['/nmap', '/mac']), \
|
||||
mock.patch('builtins.open', side_effect=lambda p, *a, **k:
|
||||
mock.mock_open(read_data=files[p]).return_value):
|
||||
value = server.oui_identity('C89E43648080')
|
||||
self.assertEqual(value['manufacturer'], 'Apple Corporation')
|
||||
self.assertEqual(value['source'], 'nmap')
|
||||
self.assertIsNone(value['model'])
|
||||
|
||||
def test_oui_identity_uses_macchanger_when_nmap_is_missing(self):
|
||||
server._oui_identity_cache = None
|
||||
files = {'/mac': 'C8-9E-43 fallback\n'}
|
||||
|
||||
def open_file(path, *args, **kwargs):
|
||||
if path not in files:
|
||||
raise OSError('missing')
|
||||
return mock.mock_open(read_data=files[path]).return_value
|
||||
|
||||
with mock.patch.object(server, 'OUI_DATA_PATHS', ['/nmap', '/mac']), \
|
||||
mock.patch('builtins.open', side_effect=open_file):
|
||||
value = server.oui_identity('C89E43648080')
|
||||
self.assertEqual(value['manufacturer'], 'fallback')
|
||||
self.assertEqual(value['source'], 'macchanger')
|
||||
|
||||
def test_oui_identity_handles_missing_files_unknown_and_local(self):
|
||||
server._oui_identity_cache = None
|
||||
with mock.patch.object(server, 'OUI_DATA_PATHS', ['/missing']), \
|
||||
mock.patch('builtins.open', side_effect=OSError('missing')):
|
||||
unknown = server.oui_identity('AC:BB:CC:00:00:01')
|
||||
self.assertEqual(unknown['manufacturer'], 'Unknown')
|
||||
self.assertEqual(unknown['source'], 'unknown')
|
||||
self.assertEqual(unknown['oui'], 'ACBBCC')
|
||||
self.assertIsNone(unknown['model'])
|
||||
|
||||
local = server.oui_identity('02:11:22:33:44:55')
|
||||
self.assertEqual(local['manufacturer'], 'Local/Randomized')
|
||||
self.assertEqual(local['source'], 'local')
|
||||
self.assertEqual(local['oui'], '021122')
|
||||
|
||||
def test_oui_identity_falls_back_to_builtin_vendors(self):
|
||||
server._oui_identity_cache = None
|
||||
with mock.patch.object(server, 'OUI_DATA_PATHS', []):
|
||||
value = server.oui_identity('B8:27:EB:00:00:00')
|
||||
self.assertEqual(value['manufacturer'], 'Raspberry Pi')
|
||||
self.assertEqual(value['source'], 'builtin')
|
||||
self.assertIsNone(value['model'])
|
||||
|
||||
def test_band_of_frequencies(self):
|
||||
self.assertEqual(server.band_of(2412), '2.4')
|
||||
self.assertEqual(server.band_of(5200), '5')
|
||||
@@ -748,7 +1037,7 @@ class ReconEnrichmentTest(unittest.TestCase):
|
||||
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.assertEqual(len(data['aps']), 3)
|
||||
self.assertLessEqual(len(data['clients']), 1)
|
||||
self.assertEqual(data['scan']['id'], 1)
|
||||
|
||||
@@ -821,6 +1110,23 @@ class ReconReportTest(unittest.TestCase):
|
||||
self.assertIn('unassociated,1', text)
|
||||
self.assertIn('C8:9E:43:64:80:80', text)
|
||||
|
||||
def test_json_download_preserves_enriched_recon_data(self):
|
||||
status, payload = server.h_recon_scan_download(self._ctx(('1',)))
|
||||
self.assertEqual(status, 200)
|
||||
data = json.loads(payload.data.decode('utf-8'))
|
||||
self.assertIn('device_identity', data['aps'][0])
|
||||
self.assertIn('associations', data['clients'][0])
|
||||
|
||||
def test_csv_download_contains_identity_counts_and_associations(self):
|
||||
status, payload = server.h_recon_scan_download_csv(self._ctx(('1',)))
|
||||
self.assertEqual(status, 200)
|
||||
text = payload.data.decode('utf-8')
|
||||
self.assertIn('Device Identity', text)
|
||||
self.assertIn('Client Count', text)
|
||||
self.assertIn('Confirmed SSIDs', text)
|
||||
self.assertIn('Anderson-5', text)
|
||||
self.assertIn('Local/Randomized', text)
|
||||
|
||||
def test_html_download_contains_stats(self):
|
||||
with mock.patch.object(server, '_gps_status_data', return_value={'lock': False}):
|
||||
status, payload = server.h_recon_scan_download_html(self._ctx(('1',)))
|
||||
@@ -849,6 +1155,15 @@ class ReconReportTest(unittest.TestCase):
|
||||
# no GPS line without a fix
|
||||
self.assertNotIn('GPS:', text)
|
||||
|
||||
def test_html_report_includes_confirmed_clients(self):
|
||||
with mock.patch.object(server, '_gps_status_data', return_value={'lock': False}):
|
||||
status, payload = server.h_recon_scan_download_html(self._ctx(('1',)))
|
||||
self.assertEqual(status, 200)
|
||||
text = payload.data.decode('utf-8')
|
||||
self.assertIn('Confirmed Clients', text)
|
||||
self.assertIn('AE:77:C0:EB:31:41', text)
|
||||
self.assertIn('handshake', text)
|
||||
self.assertNotIn('ProbeOnlySSID', text)
|
||||
def test_html_report_includes_gps_when_locked(self):
|
||||
with mock.patch.object(server, '_gps_status_data',
|
||||
return_value={'lock': True, 'lat': 37.7,
|
||||
@@ -874,6 +1189,19 @@ class ReconReportTest(unittest.TestCase):
|
||||
self.assertEqual(status, 503)
|
||||
|
||||
|
||||
class ReconFrontendTest(unittest.TestCase):
|
||||
def test_recon_view_supports_identity_and_confirmed_associations(self):
|
||||
path = os.path.join(os.path.dirname(__file__), '..', 'payload', 'user',
|
||||
'remote_access', 'pager-webui', 'www', 'js', 'views.js')
|
||||
with open(path, encoding='utf-8') as source_file:
|
||||
source = source_file.read()
|
||||
for expected in (
|
||||
"identity: true", "clients: true", "associated_ssid",
|
||||
"device_identity", "associations", "Confirmed Clients",
|
||||
"No confirmed clients", "reconLoadCols", "Object.assign"):
|
||||
self.assertIn(expected, source)
|
||||
|
||||
|
||||
class ReconArchivesTest(unittest.TestCase):
|
||||
"""Read-only history from pineapd-rotated databases (error-*-recon.db)."""
|
||||
|
||||
@@ -1118,18 +1446,24 @@ class WigleTest(unittest.TestCase):
|
||||
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)
|
||||
raw = content.encode('utf-8') if isinstance(content, str) else content
|
||||
path = os.path.join(self.dir, name)
|
||||
fd = os.open(path, os.O_CREAT | os.O_WRONLY | os.O_TRUNC, 0o644)
|
||||
try:
|
||||
os.write(fd, raw)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
def test_file_rows_count_excludes_header(self):
|
||||
self._write('a.csv', 'header\nr1\nr2\n')
|
||||
self._write('b.csv', 'onlyheader\n')
|
||||
payload = b'header\nr1\nr2\n'
|
||||
self._write('a.csv', payload)
|
||||
self._write('b.csv', b'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'))
|
||||
self.assertEqual(files['a.csv']['size'], os.path.getsize(os.path.join(self.dir, 'a.csv')))
|
||||
|
||||
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'
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from unittest import mock
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def ctx(body=None):
|
||||
return type('C', (), {'body': body, 'args': (), 'query': {}})()
|
||||
|
||||
|
||||
class DaemonRetryTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.old_sleep = server.DAEMON_SOCK_RETRY_SLEEP
|
||||
self.old_retries = server.DAEMON_SOCK_RETRIES
|
||||
server.DAEMON_SOCK_RETRY_SLEEP = 0
|
||||
server.DAEMON_SOCK_RETRIES = 2
|
||||
|
||||
def tearDown(self):
|
||||
server.DAEMON_SOCK_RETRY_SLEEP = self.old_sleep
|
||||
server.DAEMON_SOCK_RETRIES = self.old_retries
|
||||
|
||||
def test_sock_retries_then_succeeds(self):
|
||||
attempts = {'n': 0}
|
||||
|
||||
class Sock:
|
||||
def __init__(self):
|
||||
self.chunks = [b'HTTP/1.1 200 OK\r\n\r\n{"ok":true}', b'']
|
||||
|
||||
def settimeout(self, t):
|
||||
pass
|
||||
|
||||
def connect(self, addr):
|
||||
attempts['n'] += 1
|
||||
if attempts['n'] < 2:
|
||||
raise OSError('busy')
|
||||
|
||||
def sendall(self, data):
|
||||
pass
|
||||
|
||||
def recv(self, n):
|
||||
return self.chunks.pop(0) if self.chunks else b''
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
with mock.patch.object(server.socket, 'socket', lambda *a, **k: Sock()):
|
||||
status, data = server.daemon_sock_call('GET', '/api/pineap/get_config')
|
||||
self.assertEqual(attempts['n'], 2)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(data, {'ok': True})
|
||||
|
||||
def test_call_retries_on_oserror(self):
|
||||
attempts = {'n': 0}
|
||||
|
||||
def boom(*a, **k):
|
||||
attempts['n'] += 1
|
||||
raise OSError('down')
|
||||
|
||||
with mock.patch.object(server.socket, 'socket', boom):
|
||||
status, data = server.daemon_call('GET', '/api/api_ping')
|
||||
self.assertEqual(attempts['n'], 3)
|
||||
self.assertEqual(status, 0)
|
||||
self.assertIsNone(data)
|
||||
|
||||
|
||||
class ConfigWriteSafetyTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.old_sock = server.daemon_sock_call
|
||||
|
||||
def tearDown(self):
|
||||
server.daemon_sock_call = self.old_sock
|
||||
def test_set_config_refuses_when_get_fails(self):
|
||||
calls = []
|
||||
|
||||
def fake(method, path, body=None, timeout=10):
|
||||
calls.append((method, path))
|
||||
if method == 'GET':
|
||||
return 0, None
|
||||
return 200, {'success': True}
|
||||
|
||||
server.daemon_sock_call = fake
|
||||
status, payload = server.h_pineap_set_config(ctx({'loghandshake': True}))
|
||||
self.assertEqual(status, 502)
|
||||
self.assertIn('could not read', payload['error'])
|
||||
self.assertFalse(any(c[0] == 'PUT' for c in calls))
|
||||
|
||||
def test_hostapd_set_refuses_when_get_fails(self):
|
||||
calls = []
|
||||
|
||||
def fake(method, path, body=None, timeout=10):
|
||||
calls.append((method, path))
|
||||
if method == 'GET':
|
||||
return 0, None
|
||||
return 200, {'success': True}
|
||||
|
||||
server.daemon_sock_call = fake
|
||||
status, payload = server.h_pineap_hostapd_set(ctx({'pineape_auth_pass': True}))
|
||||
self.assertEqual(status, 502)
|
||||
self.assertFalse(any(c[0] == 'PUT' for c in calls))
|
||||
|
||||
|
||||
class Hak5RetryTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.old_sleep = server.HAK5_RETRY_SLEEP
|
||||
self.old_run = server.device_run
|
||||
server.HAK5_RETRY_SLEEP = 0
|
||||
|
||||
def tearDown(self):
|
||||
server.HAK5_RETRY_SLEEP = self.old_sleep
|
||||
server.device_run = self.old_run
|
||||
|
||||
def test_hak5_raises_after_retries(self):
|
||||
calls = []
|
||||
|
||||
def fake(args, timeout=20, input_data=None):
|
||||
calls.append(args)
|
||||
return 1, '', 'busy'
|
||||
|
||||
server.device_run = fake
|
||||
with self.assertRaises(RuntimeError):
|
||||
server.hak5('PINEAPPLE_SSID_POOL_LIST')
|
||||
self.assertEqual(len(calls), 3)
|
||||
|
||||
def test_hak5_succeeds_on_retry(self):
|
||||
calls = []
|
||||
|
||||
def fake(args, timeout=20, input_data=None):
|
||||
calls.append(args)
|
||||
if len(calls) < 2:
|
||||
return 1, '', 'busy'
|
||||
return 0, 'ok\n', ''
|
||||
|
||||
server.device_run = fake
|
||||
out = server.hak5('PINEAPPLE_SSID_POOL_LIST')
|
||||
self.assertEqual(out, 'ok\n')
|
||||
self.assertEqual(len(calls), 2)
|
||||
|
||||
def test_hak5_treats_error_text_as_failure(self):
|
||||
calls = []
|
||||
|
||||
def fake(args, timeout=20, input_data=None):
|
||||
calls.append(args)
|
||||
return 0, '', 'ERROR: invalid time (expected number of seconds)'
|
||||
|
||||
server.device_run = fake
|
||||
with self.assertRaises(RuntimeError):
|
||||
server.hak5('PINEAPPLE_EXAMINE_CHANNEL', '140')
|
||||
self.assertEqual(len(calls), 3)
|
||||
|
||||
|
||||
class SsidPoolFailureTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.old_hak5 = server.hak5
|
||||
|
||||
def tearDown(self):
|
||||
server.hak5 = self.old_hak5
|
||||
def test_ssids_post_add_returns_502_on_hak5_failure(self):
|
||||
server.hak5 = lambda *a, **k: (_ for _ in ()).throw(RuntimeError('busy'))
|
||||
status, payload = server.h_ssids_post(ctx({'action': 'add', 'ssid': 'NewNet'}))
|
||||
self.assertEqual(status, 502)
|
||||
self.assertIn('ssid pool update failed', payload['error'])
|
||||
|
||||
def test_examine_returns_502_on_hak5_failure(self):
|
||||
server.hak5 = lambda *a, **k: (_ for _ in ()).throw(RuntimeError('busy'))
|
||||
status, payload = server.h_recon_examine(type('C', (), {
|
||||
'args': (), 'body': {'bssid': 'AA:BB:CC:DD:EE:FF'}})())
|
||||
self.assertEqual(status, 502)
|
||||
self.assertEqual(payload['error'], 'examine failed')
|
||||
|
||||
|
||||
class AdvertiseBlockTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.old_proxy = server._daemon_proxy
|
||||
self.old_uci = server._uci_section
|
||||
|
||||
def tearDown(self):
|
||||
server._daemon_proxy = self.old_proxy
|
||||
server._uci_section = self.old_uci
|
||||
def test_advertise_enable_always_refused(self):
|
||||
server._uci_section = lambda name: {'disable': '0'}
|
||||
calls = []
|
||||
server._daemon_proxy = lambda method, path, body=None, timeout=15: (
|
||||
calls.append((method, path, body)) or (200, {'success': True}))
|
||||
status, payload = server.h_pineap_advertise(ctx({'enable': True}))
|
||||
self.assertEqual(status, 400)
|
||||
self.assertIn('cannot be re-enabled', payload['error'])
|
||||
self.assertEqual(calls, [])
|
||||
|
||||
def test_advertise_disable_still_proxies(self):
|
||||
calls = []
|
||||
server._daemon_proxy = lambda method, path, body=None, timeout=15: (
|
||||
calls.append(path) or (200, {'success': True}))
|
||||
status, payload = server.h_pineap_advertise(ctx({'enable': False}))
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(calls, ['ssidpool/disable'])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,159 @@
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'remote_access', 'pager-webui'))
|
||||
import server
|
||||
|
||||
|
||||
class PagerTruthTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.uci = {
|
||||
'pineapd.wlan1mon.hop': '1',
|
||||
'pineapd.wlan2mon.disable': '0',
|
||||
'wireless.dummy_radio0.disabled': '0',
|
||||
'wireless.radio1.channel': 'auto',
|
||||
'wireless.radio1.band': '5g',
|
||||
'wireless.wlan1open': None,
|
||||
'wireless.wlan1wpa': None,
|
||||
}
|
||||
self.ifaces = {}
|
||||
fd, self.snap = tempfile.mkstemp(suffix='.json')
|
||||
os.close(fd)
|
||||
os.unlink(self.snap)
|
||||
self.old_file = server.PAGER_SNAPSHOT_FILE
|
||||
self.old_pineap = server.PINEAP_STATE_FILE
|
||||
server.PAGER_SNAPSHOT_FILE = self.snap
|
||||
server.PINEAP_STATE_FILE = self.snap + '.pineap'
|
||||
self.old_run = server.device_run
|
||||
server.device_run = self.fake_run
|
||||
self.old_ent = server._disable_enterprise_ap
|
||||
server._disable_enterprise_ap = lambda resume_hop=True: None
|
||||
|
||||
def tearDown(self):
|
||||
server.device_run = self.old_run
|
||||
server._disable_enterprise_ap = self.old_ent
|
||||
server.PAGER_SNAPSHOT_FILE = self.old_file
|
||||
server.PINEAP_STATE_FILE = self.old_pineap
|
||||
for path in (self.snap, self.snap + '.tmp', self.snap + '.pineap'):
|
||||
try:
|
||||
os.unlink(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def fake_run(self, args, timeout=20, input_data=None):
|
||||
a = list(args)
|
||||
if a[:2] == ['uci', '-q'] and a[2] == 'get':
|
||||
key = a[3]
|
||||
if key.startswith('wireless.') and key.count('.') == 1:
|
||||
name = key.split('.', 1)[1]
|
||||
if name in self.ifaces:
|
||||
return 0, 'wifi-iface\n', ''
|
||||
return 1, '', ''
|
||||
val = self.uci.get(key)
|
||||
if val is None:
|
||||
return 1, '', ''
|
||||
return 0, val + '\n', ''
|
||||
if a[:2] == ['uci', 'show']:
|
||||
sec = a[2]
|
||||
name = sec.split('.', 1)[-1]
|
||||
cfg = self.ifaces.get(name) or {}
|
||||
body = ''.join("%s.%s='%s'\n" % (sec, k, v) for k, v in cfg.items())
|
||||
return (0, body, '') if cfg or name in self.ifaces else (1, '', '')
|
||||
if a[:2] == ['uci', 'set']:
|
||||
expr = a[2]
|
||||
if '=' not in expr:
|
||||
return 0, '', ''
|
||||
key, _, val = expr.partition('=')
|
||||
parts = key.split('.')
|
||||
if len(parts) == 2 and parts[0] == 'wireless' and val == 'wifi-iface':
|
||||
self.ifaces.setdefault(parts[1], {})
|
||||
return 0, '', ''
|
||||
if len(parts) == 3 and parts[0] == 'wireless' and (
|
||||
parts[1] in self.ifaces or parts[1] in ('wlan1open', 'wlan1wpa', 'wlan1ent')):
|
||||
self.ifaces.setdefault(parts[1], {})[parts[2]] = val
|
||||
return 0, '', ''
|
||||
self.uci[key] = val
|
||||
return 0, '', ''
|
||||
if a[:2] == ['uci', 'delete']:
|
||||
key = a[2]
|
||||
parts = key.split('.')
|
||||
if len(parts) == 2 and parts[0] == 'wireless':
|
||||
self.ifaces.pop(parts[1], None)
|
||||
self.uci.pop(key, None)
|
||||
return 0, '', ''
|
||||
if a[:2] == ['uci', 'commit']:
|
||||
return 0, '', ''
|
||||
if a[0] in ('wifi', '/etc/init.d/pineapd', 'kill', 'iw'):
|
||||
return 0, '', ''
|
||||
return 0, '', ''
|
||||
|
||||
def test_restore_reverts_radio1_ap_and_hop(self):
|
||||
server.capture_pager_snapshot()
|
||||
self.assertTrue(os.path.isfile(self.snap))
|
||||
self.uci['pineapd.wlan1mon.hop'] = '0'
|
||||
self.ifaces['wlan1open'] = {'ssid': 'EvilTwin', 'disabled': '0', 'device': 'radio1'}
|
||||
self.uci['wireless.dummy_radio0.disabled'] = '1'
|
||||
result = server.restore_pager_truth()
|
||||
self.assertTrue(result['ok'])
|
||||
self.assertTrue(result['restored'])
|
||||
self.assertEqual(self.uci['pineapd.wlan1mon.hop'], '1')
|
||||
self.assertNotIn('wlan1open', self.ifaces)
|
||||
self.assertEqual(self.uci['wireless.dummy_radio0.disabled'], '0')
|
||||
self.assertFalse(os.path.isfile(self.snap))
|
||||
|
||||
def test_restore_without_snapshot_is_safe(self):
|
||||
result = server.restore_pager_truth()
|
||||
self.assertTrue(result['ok'])
|
||||
self.assertFalse(result['restored'])
|
||||
self.assertEqual(result['reason'], 'no snapshot')
|
||||
|
||||
def test_mode_get_reports_snapshot(self):
|
||||
server.capture_pager_snapshot()
|
||||
status, payload = server.h_mode_get(None)
|
||||
self.assertEqual(status, 200)
|
||||
self.assertTrue(payload['snapshot'])
|
||||
self.assertTrue(payload['markviii'])
|
||||
self.assertEqual(payload['pager_port'], 1471)
|
||||
|
||||
def test_payload_refresh_falls_back_to_disk(self):
|
||||
tmp = tempfile.mkdtemp()
|
||||
self.addCleanup(lambda: shutil.rmtree(tmp, ignore_errors=True))
|
||||
payload_dir = tmp
|
||||
for part in ('user', 'games', 'snake'):
|
||||
payload_dir = os.path.join(payload_dir, part)
|
||||
if not os.path.isdir(payload_dir):
|
||||
os.mkdir(payload_dir)
|
||||
with open(os.path.join(payload_dir, 'payload.sh'), 'w') as handle:
|
||||
handle.write('#!/bin/sh\n')
|
||||
old_roots = server.PAYLOAD_ROOTS
|
||||
old_daemon = server._payload_daemon
|
||||
server.PAYLOAD_ROOTS = (tmp,)
|
||||
server._payload_daemon = lambda *a, **k: (500, {'error': 'portal down'})
|
||||
try:
|
||||
status, data = server.h_payloads_refresh(None)
|
||||
finally:
|
||||
server.PAYLOAD_ROOTS = old_roots
|
||||
server._payload_daemon = old_daemon
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(len(data['payloads']), 1)
|
||||
self.assertEqual(data['payloads'][0]['key'], 'user~games~snake')
|
||||
self.assertIn('warning', data)
|
||||
|
||||
def test_respawn_keeps_original_snapshot(self):
|
||||
server.capture_pager_snapshot()
|
||||
self.uci['pineapd.wlan1mon.hop'] = '0'
|
||||
server.capture_pager_snapshot()
|
||||
result = server.restore_pager_truth()
|
||||
self.assertTrue(result['restored'])
|
||||
self.assertEqual(self.uci['pineapd.wlan1mon.hop'], '1')
|
||||
|
||||
def test_uci_get_keeps_settings_default(self):
|
||||
self.assertEqual(server._uci_get('missing.key', 'UTC'), 'UTC')
|
||||
self.assertIsNone(server._uci_get('missing.key'))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -51,6 +51,8 @@ class StatusTest(unittest.TestCase):
|
||||
def fake(args, timeout=20):
|
||||
if args == ['iwinfo']:
|
||||
return 0, 'wlan0 ESSID: "Pineapple"\n', ''
|
||||
if args[0] == 'iw':
|
||||
return 1, '', 'busy'
|
||||
if args == ['iwinfo', 'wlan0', 'assoclist']:
|
||||
return 0, '00:11:22:33:44:55 -64 dBm Signal: -64 dBm Rate: 12 Mbit/s\nAA:BB:CC:DD:EE:FF -40 dBm Signal: -40 dBm Rate: 24 Mbit/s\n', ''
|
||||
return 0, '', ''
|
||||
@@ -61,6 +63,23 @@ class StatusTest(unittest.TestCase):
|
||||
self.assertEqual(clients[0]['rssi'], -64)
|
||||
self.assertEqual(clients[0]['iface'], 'wlan0')
|
||||
|
||||
def test_assoc_clients_skips_monitor_ifaces(self):
|
||||
def fake(args, timeout=20):
|
||||
if args == ['iwinfo']:
|
||||
return 0, 'wlan0mon ESSID: unknown\nwlan0wpa ESSID: "x"\n', ''
|
||||
if args[0] == 'iw' and len(args) > 2 and args[2] == 'wlan0mon':
|
||||
raise AssertionError('must not query monitor ifaces')
|
||||
if args[:2] == ['iwinfo', 'wlan0mon']:
|
||||
raise AssertionError('must not query monitor ifaces')
|
||||
if args == ['iw', 'dev', 'wlan0wpa', 'station', 'dump']:
|
||||
return 0, 'Station aa:bb:cc:dd:ee:ff (on wlan0wpa)\n\tsignal: -50 dBm\n', ''
|
||||
return 1, '', ''
|
||||
server.device_run = fake
|
||||
clients = server.assoc_clients()
|
||||
self.assertEqual(len(clients), 1)
|
||||
self.assertEqual(clients[0]['mac'], 'AA:BB:CC:DD:EE:FF')
|
||||
self.assertEqual(clients[0]['iface'], 'wlan0wpa')
|
||||
|
||||
def test_h_status_shape(self):
|
||||
server.device_run = lambda args, timeout=20: (0, '', '')
|
||||
server.current_token = lambda: 'tok'
|
||||
|
||||
Reference in New Issue
Block a user