fix: harden UI actions and daemon calls for reliable control (v1.3.2)

Retry and serialize pineapd/hak5 calls, queue virtual-pager keys, and grey out buttons until the pager finishes. Deploy now installs python3-light after factory firmware. Bump version to 1.3.2.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-20 15:49:10 -05:00
co-authored by Cursor
parent cd26553d21
commit 7d48b7ad06
25 changed files with 3625 additions and 782 deletions
+7 -5
View File
@@ -16,7 +16,7 @@ terminal.
## Requirements ## Requirements
- WiFi Pineapple Pager, firmware `Pineapple Pager 24.10.1` - 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 - Python 3.11 on the development machine
## Install (sideload) ## Install (sideload)
@@ -53,10 +53,12 @@ Then on the Pager menu, run **Mark VIII**:
Every payload run (and every service startup) first runs an **environment Every payload run (and every service startup) first runs an **environment
check** that prints on the payload screen / `/tmp/pagerwebui.log`: daemon check** that prints on the payload screen / `/tmp/pagerwebui.log`: daemon
reachable, pineapd alive, sane-off PineAP UCI defaults applied (SSID pool reachable, pineapd alive, monitor interfaces up, and recon DB readable. On a
broadcast off, 6GHz hopper disabled, monitor bands), runtime pool sync, monitor healthy pager it **does not rewrite** live PineAP or wireless UCI (SSID pool,
interfaces up, and recon DB readable. Anything fixable is fixed and re-verified; hopping, dummy_radio0 STA, Open AP). Crash-prone settings are reported as
startup aborts only if a core dependency fails. 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. Browse `http://172.16.52.1:8080/` and log in with the device password.
@@ -8,7 +8,7 @@
"title": "Mark VIII", "title": "Mark VIII",
"author": "c4ch3c4d3", "author": "c4ch3c4d3",
"description": "Mark VII-style web management UI for the WiFi Pineapple Pager", "description": "Mark VII-style web management UI for the WiFi Pineapple Pager",
"version": "1.3.1", "version": "1.3.2",
"category": "remote_access", "category": "remote_access",
"tags": ["remote-access", "web-interface", "device-management", "pineap"], "tags": ["remote-access", "web-interface", "device-management", "pineap"],
"firmware": "Pineapple Pager 24.10.1" "firmware": "Pineapple Pager 24.10.1"
@@ -21,7 +21,7 @@ start_service() {
procd_set_param respawn procd_set_param respawn
procd_set_param stdout 1 procd_set_param stdout 1
procd_set_param stderr 1 procd_set_param stderr 1
procd_set_param term_timeout 10 procd_set_param term_timeout 90
procd_close_instance procd_close_instance
} }
@@ -2,7 +2,7 @@
# Title: Mark VIII # Title: Mark VIII
# Description: Mark VII-style web management UI for the WiFi Pineapple Pager # Description: Mark VII-style web management UI for the WiFi Pineapple Pager
# Author: c4ch3c4d3 # Author: c4ch3c4d3
# Version: 1.3.1 # Version: 1.3.2
# Category: Remote-Access # Category: Remote-Access
# Tags: remote-access, web-interface, device-management, pineap # Tags: remote-access, web-interface, device-management, pineap
# Firmware: Pineapple Pager 24.10.1 # Firmware: Pineapple Pager 24.10.1
@@ -28,7 +28,7 @@ get_pager_ip() {
} }
LOG "cyan" "+---------------------------+" LOG "cyan" "+---------------------------+"
LOG "cyan" "| Mark VIII v1.3.1 |" LOG "cyan" "| Mark VIII v1.3.2 |"
LOG "cyan" "+---------------------------+" LOG "cyan" "+---------------------------+"
if ! command -v python3 >/dev/null 2>&1; then if ! command -v python3 >/dev/null 2>&1; then
@@ -70,8 +70,14 @@ wait_for_server_stop() {
return 1 return 1
} }
release_pager_truth() {
python3 "$SCRIPT_DIR/server.py" --release-pager >/tmp/pagerwebui-release.log 2>&1 || true
}
remove_boot_service() { remove_boot_service() {
"$INIT_SCRIPT" stop 2>/dev/null || true "$INIT_SCRIPT" stop 2>/dev/null || true
wait_for_server_stop 90 || true
release_pager_truth
"$INIT_SCRIPT" disable 2>/dev/null || true "$INIT_SCRIPT" disable 2>/dev/null || true
rm -f "$INIT_SCRIPT" rm -f "$INIT_SCRIPT"
} }
@@ -84,7 +90,7 @@ if [ -f "$INIT_SCRIPT" ] && "$INIT_SCRIPT" running 2>/dev/null; then
if user_confirmed "$resp"; then if user_confirmed "$resp"; then
LOG "yellow" "Stopping service..." LOG "yellow" "Stopping service..."
remove_boot_service remove_boot_service
if ! wait_for_server_stop 12; then if ! wait_for_server_stop 90; then
LOG "red" "Service is still listening on port $PORT" LOG "red" "Service is still listening on port $PORT"
exit 1 exit 1
fi fi
@@ -147,8 +153,10 @@ LOG "cyan" "Starting foreground mode..."
cleanup() { cleanup() {
LOG "yellow" "Stopping Mark VIII..." LOG "yellow" "Stopping Mark VIII..."
[ -f "$PID_FILE" ] && kill "$(cat "$PID_FILE")" 2>/dev/null [ -f "$PID_FILE" ] && kill "$(cat "$PID_FILE")" 2>/dev/null
wait_for_server_stop 90 || true
release_pager_truth
rm -f "$PID_FILE" rm -f "$PID_FILE"
LOG "cyan" "Stopped." LOG "cyan" "Stopped. Pager UI is source of truth."
} }
trap cleanup EXIT INT TERM trap cleanup EXIT INT TERM
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 Captured credentials flow to pineapd's socket and land in
`hostap_basic`/`hostap_chalresp` in recon.db. Tear down: kill the pidfile `hostap_basic` / `hostap_chalresp` in recon.db. Mark VIII exposes them as
pid, `iw dev wlan1ent del`, resume hop. 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 ## Access
@@ -62,6 +62,10 @@ body {
align-items: center; justify-content: center; 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: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; } .toolbar-icon-btn svg { width: 24px; height: 24px; display: block; }
#terminal-btn.active, #pager-btn.active, .toolbar-icon-btn[aria-expanded="true"] { #terminal-btn.active, #pager-btn.active, .toolbar-icon-btn[aria-expanded="true"] {
background: #1976d2; color: #fff; 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); box-shadow: 0 1px 3px rgba(0,0,0,.3);
} }
.btn:hover { background: var(--primary-dark); } .btn:hover { background: var(--primary-dark); }
.btn:disabled, .btn.busy { opacity: .5; cursor: default; pointer-events: none; }
.btn.ghost { .btn.ghost {
background: transparent; color: var(--primary); box-shadow: none; background: transparent; color: var(--primary); box-shadow: none;
border: 1px solid var(--primary); border: 1px solid var(--primary);
} }
.btn.danger { background: var(--danger); } .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 { input, select {
background: var(--surface); color: var(--text); border: 1px solid var(--border); background: var(--surface); color: var(--text); border: 1px solid var(--border);
border-radius: 2px; padding: 8px 10px; width: 100%; border-radius: 2px; padding: 8px 10px; width: 100%;
@@ -293,6 +311,9 @@ pre.logs {
.switch input:checked + .track::after { left: 22px; } .switch input:checked + .track::after { left: 22px; }
.switch input:indeterminate + .track { background: #9e9e9e; } .switch input:indeterminate + .track { background: #9e9e9e; }
.switch input:indeterminate + .track::after { left: 12px; } .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); } .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; } .pager { display: flex; gap: 8px; align-items: center; margin-top: 8px; }
.muted { color: var(--muted, #686868); } .muted { color: var(--muted, #686868); }
@@ -342,7 +363,7 @@ html.dark .muted { color: #bdbdbd; }
.recon-ps-actions .icon-btn svg { width: 18px; height: 18px; } .recon-ps-actions .icon-btn svg { width: 18px; height: 18px; }
.icon-btn { background: transparent; color: var(--muted); border: 0; border-radius: 50%; width: 36px; height: 36px; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; padding: 0; } .icon-btn { background: transparent; color: var(--muted); border: 0; border-radius: 50%; width: 36px; height: 36px; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; padding: 0; }
.icon-btn:hover { background: var(--surface-alt); color: var(--text); } .icon-btn:hover { background: var(--surface-alt); color: var(--text); }
.icon-btn:disabled { opacity: .38; cursor: default; } .icon-btn:disabled, .icon-btn.busy { opacity: .38; cursor: wait; pointer-events: none; }
.icon-btn svg { width: 22px; height: 22px; } .icon-btn svg { width: 22px; height: 22px; }
.recon-scan-bar { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; } .recon-scan-bar { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
.recon-scan-bar .sel { width: auto; } .recon-scan-bar .sel { width: auto; }
@@ -406,7 +427,7 @@ html.dark .recon-dbm-bar { background: #333; }
.recon-chip.active { background: var(--primary); border-color: var(--primary); color: #fff; } .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 { 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: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; } .recon-pill.on { background: #e8f5e9; border-color: #a5d6a7; color: #2e7d32; }
html.dark .recon-pill.on { background: #1b3a23; color: #81c784; } html.dark .recon-pill.on { background: #1b3a23; color: #81c784; }
@@ -475,7 +496,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 { 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 + .seg-btn { border-left: 1px solid var(--border, #e0e0e0); }
.seg-btn.active { background: var(--primary, #1976d2); color: #fff; } .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 Mark VII layout ---- */
.pineap-title-card-container { display: flex; width: 100%; flex-wrap: wrap; justify-content: space-between; gap: 30px; margin: 8px 0 16px; } .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"> <meta name="color-scheme" content="light dark">
<title>WiFi Pineapple</title> <title>WiFi Pineapple</title>
<link rel="icon" type="image/png" href="assets/logo.png"> <link rel="icon" type="image/png" href="assets/logo.png">
<link rel="stylesheet" href="css/app.css?v=20260819-1"> <link rel="stylesheet" href="css/app.css?v=20260820-4">
<link rel="stylesheet" href="js/xterm.css"> <link rel="stylesheet" href="js/xterm.css">
</head> </head>
<body> <body>
@@ -262,13 +262,13 @@
<script src="js/config.js"></script> <script src="js/config.js"></script>
<script src="js/icons.js?v=20260818-7"></script> <script src="js/icons.js?v=20260818-7"></script>
<script src="js/api.js?v=20260819-2"></script> <script src="js/api.js?v=20260820-4"></script>
<script src="js/chart.js?v=20260819-1"></script> <script src="js/chart.js?v=20260819-1"></script>
<script src="js/xterm.min.js"></script> <script src="js/xterm.min.js"></script>
<script src="js/xterm-addon-fit.min.js"></script> <script src="js/xterm-addon-fit.min.js"></script>
<script src="js/terminal.js"></script> <script src="js/terminal.js?v=20260820-4"></script>
<script src="js/pager.js"></script> <script src="js/pager.js?v=20260820-4"></script>
<script src="js/views.js?v=20260819-2"></script> <script src="js/views.js?v=20260820-4"></script>
<script src="js/app.js?v=20260819-1"></script> <script src="js/app.js?v=20260820-4"></script>
</body> </body>
</html> </html>
@@ -3,32 +3,32 @@
const PagerAPI = (() => { const PagerAPI = (() => {
let apiBase = ''; let apiBase = '';
let on401 = null; let on401 = null;
// Reads are polled and must never hang a page's refresh loop; writes have
// server-side timeouts up to 45s (radio deploys) so they get no client
// abort.
const GET_TIMEOUT_MS = 20000; 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' }; const opts = { method, headers: {}, credentials: 'include' };
if (body !== undefined) { if (body !== undefined) {
opts.headers['Content-Type'] = 'application/json'; opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(body); opts.body = JSON.stringify(body);
} }
const timeoutMs = method === 'GET' ? GET_TIMEOUT_MS : WRITE_TIMEOUT_MS;
const ctl = new AbortController(); const ctl = new AbortController();
const timer = method === 'GET' ? setTimeout(() => ctl.abort(), GET_TIMEOUT_MS) : null; const timer = setTimeout(() => ctl.abort(), timeoutMs);
if (timer) opts.signal = ctl.signal; opts.signal = ctl.signal;
let res; let res;
try { try {
res = await fetch(apiBase + path, opts); res = await fetch(apiBase + path, opts);
} catch (e) { } catch (e) {
if (timer && e && e.name === 'AbortError') { clearTimeout(timer);
const error = new Error('Request timed out'); if (method === 'GET' && attempt < 1) {
error.status = 0; return request(method, path, body, attempt + 1);
throw error;
} }
throw e; const error = new Error((e && e.name === 'AbortError') ? 'Request timed out' : (e && e.message) || 'Network error');
} finally { error.status = 0;
if (timer) clearTimeout(timer); throw error;
} }
clearTimeout(timer);
if (res.status === 401) { if (res.status === 401) {
if (on401) on401(); if (on401) on401();
throw new Error('unauthorized'); throw new Error('unauthorized');
@@ -241,7 +241,7 @@ const App = (() => {
}); });
} }
function handleMenuAction(action) { function handleMenuAction(action, button) {
closeToolbarMenus(); closeToolbarMenus();
if (action === 'help') { if (action === 'help') {
location.hash = '#/settings/help'; location.hash = '#/settings/help';
@@ -252,14 +252,18 @@ const App = (() => {
if (typeof views.openClientModeModal === 'function') views.openClientModeModal(); if (typeof views.openClientModeModal === 'function') views.openClientModeModal();
else checkInternet(true); else checkInternet(true);
} else if (action === 'logout') { } else if (action === 'logout') {
if (button) { button.disabled = true; button.classList.add('busy'); }
PagerAPI.post('/api/logout') PagerAPI.post('/api/logout')
.then(() => showLogin()) .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') { } else if (action === 'reboot') {
if (!window.confirm('Reboot Mark VIII now?')) return; if (!window.confirm('Reboot Mark VIII now?')) return;
if (button) { button.disabled = true; button.classList.add('busy'); }
PagerAPI.post('/api/settings/reboot') PagerAPI.post('/api/settings/reboot')
.then(() => toast('Reboot requested. Mark VIII will disconnect shortly.')) .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(); renderNotifications();
}); });
Array.prototype.forEach.call(els.overflowMenu.querySelectorAll('[data-menu-action]'), (item) => { 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); document.addEventListener('click', closeToolbarMenus);
@@ -342,13 +346,14 @@ const App = (() => {
const pw = document.getElementById('login-password').value; const pw = document.getElementById('login-password').value;
document.getElementById('login-error').textContent = ''; document.getElementById('login-error').textContent = '';
btn.disabled = true; btn.disabled = true;
btn.classList.add('busy');
PagerAPI.login('root', pw) PagerAPI.login('root', pw)
.then(() => { document.getElementById('login-password').value = ''; showApp(); toast('Logged in'); }) .then(() => { document.getElementById('login-password').value = ''; showApp(); toast('Logged in'); })
.catch((err) => { .catch((err) => {
document.getElementById('login-error').textContent = (err && err.message && err.message !== 'unauthorized') document.getElementById('login-error').textContent = (err && err.message && err.message !== 'unauthorized')
? 'Login failed.' : 'Invalid credentials.'; ? 'Login failed.' : 'Invalid credentials.';
}) })
.finally(() => { btn.disabled = false; }); .finally(() => { btn.disabled = false; btn.classList.remove('busy'); });
}); });
document.getElementById('terminal-btn').addEventListener('click', () => { document.getElementById('terminal-btn').addEventListener('click', () => {
@@ -5,6 +5,7 @@ const Pager = (() => {
const SCREEN_HEIGHT = 222; const SCREEN_HEIGHT = 222;
const FB_STRIDE = SCREEN_WIDTH * 4; const FB_STRIDE = SCREEN_WIDTH * 4;
const PAGER_WIDTH = 745; const PAGER_WIDTH = 745;
const MAX_QUEUED_KEYS = 24;
const KEY_MAP = { const KEY_MAP = {
'LEFT.png': 'ArrowLeft', 'LEFT.png': 'ArrowLeft',
@@ -23,6 +24,10 @@ const Pager = (() => {
let screenerr = null; let screenerr = null;
let keyws = null; let keyws = null;
let screenws = null; let screenws = null;
let wantOpen = false;
let retryTimer = null;
let retryMs = 400;
const pendingKeys = [];
function ensure() { function ensure() {
if (table) return; if (table) return;
@@ -36,10 +41,17 @@ const Pager = (() => {
const src = img.getAttribute('src').split('/').pop(); const src = img.getAttribute('src').split('/').pop();
const key = KEY_MAP[src]; const key = KEY_MAP[src];
if (!key) return; 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'); const retry = document.getElementById('screen_retry');
if (retry) retry.addEventListener('click', () => connect()); if (retry) retry.addEventListener('click', () => {
retryMs = 400;
connect();
});
} }
function press(el, key) { function press(el, key) {
@@ -48,8 +60,26 @@ const Pager = (() => {
sendKey(key); 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) { 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) { function renderRGBAFrame(bytes) {
@@ -76,33 +106,81 @@ const Pager = (() => {
pager.src = canvas.toDataURL('image/png'); pager.src = canvas.toDataURL('image/png');
} }
function connect() { function showError(show) {
disconnect(); 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 { try {
const sock = new WebSocket(App.pagerScreenWs); const sock = new WebSocket(App.pagerScreenWs);
sock.binaryType = 'arraybuffer'; sock.binaryType = 'arraybuffer';
screenws = sock; screenws = sock;
sock.onopen = () => { screenerr.hidden = true; }; sock.onopen = () => {
retryMs = 400;
if (keyws && keyws.readyState === WebSocket.OPEN) showError(false);
};
sock.onmessage = (ev) => { sock.onmessage = (ev) => {
showError(false);
if (ev.data instanceof ArrayBuffer) renderRGBAFrame(new Uint8Array(ev.data)); 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))); else if (ev.data && ev.data.arrayBuffer) ev.data.arrayBuffer().then((b) => renderRGBAFrame(new Uint8Array(b)));
}; };
sock.onerror = () => { screenerr.hidden = false; }; sock.onerror = () => { showError(true); };
sock.onclose = () => { if (screenws === sock) screenws = null; screenerr.hidden = false; }; sock.onclose = () => {
if (screenws === sock) screenws = null;
showError(true);
scheduleReconnect();
};
} catch (e) { } catch (e) {
screenerr.hidden = false; showError(true);
} scheduleReconnect();
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;
} }
} }
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() { function disconnect() {
wantOpen = false;
clearTimeout(retryTimer);
retryTimer = null;
pendingKeys.length = 0;
if (screenws) { try { screenws.close(); } catch (e) {} screenws = null; } if (screenws) { try { screenws.close(); } catch (e) {} screenws = null; }
if (keyws) { try { keyws.close(); } catch (e) {} keyws = null; } if (keyws) { try { keyws.close(); } catch (e) {} keyws = null; }
} }
@@ -121,6 +199,8 @@ const Pager = (() => {
panel.classList.remove('hidden'); panel.classList.remove('hidden');
document.getElementById('pager-btn').classList.add('active'); document.getElementById('pager-btn').classList.add('active');
applyScale(); applyScale();
wantOpen = true;
retryMs = 400;
connect(); connect();
try { pager.focus(); } catch (e) {} try { pager.focus(); } catch (e) {}
} else { } else {
@@ -5,6 +5,9 @@ const Term = (() => {
let fitAddon = null; let fitAddon = null;
let ws = null; let ws = null;
let panel = null; let panel = null;
let wantOpen = false;
let retryTimer = null;
let retryMs = 400;
function ensure() { function ensure() {
if (term) return; if (term) return;
@@ -14,7 +17,20 @@ const Term = (() => {
term.loadAddon(fitAddon); term.loadAddon(fitAddon);
term.open(document.getElementById('terminal')); term.open(document.getElementById('terminal'));
try { fitAddon.fit(); } catch (e) {} 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() { function toggle() {
@@ -23,6 +39,8 @@ const Term = (() => {
panel.classList.remove('hidden'); panel.classList.remove('hidden');
document.getElementById('terminal-btn').classList.add('active'); document.getElementById('terminal-btn').classList.add('active');
try { fitAddon.fit(); } catch (e) {} try { fitAddon.fit(); } catch (e) {}
wantOpen = true;
retryMs = 400;
connect(); connect();
} else { } else {
panel.classList.add('hidden'); panel.classList.add('hidden');
@@ -32,25 +50,39 @@ const Term = (() => {
} }
function connect() { function connect() {
if (ws) return; if (!wantOpen) return;
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return;
if (term) term.reset(); if (term) term.reset();
let sock; let sock;
try { try {
sock = new WebSocket(App.terminalWs); sock = new WebSocket(App.terminalWs);
} catch (e) { } catch (e) {
term.writeln('\r\n[cannot reach daemon terminal: ' + e.message + ']'); term.writeln('\r\n[cannot reach daemon terminal: ' + e.message + ']');
scheduleReconnect();
return; return;
} }
ws = sock; ws = sock;
sock.onopen = () => { retryMs = 400; };
sock.onmessage = (ev) => { sock.onmessage = (ev) => {
if (typeof ev.data === 'string') term.write(ev.data); if (typeof ev.data === 'string') term.write(ev.data);
else ev.data.text().then((t) => term.write(t)); 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) {} }; sock.onerror = () => { try { sock.close(); } catch (e) {} };
} }
function disconnect() { function disconnect() {
wantOpen = false;
clearTimeout(retryTimer);
retryTimer = null;
if (ws) { try { ws.close(); } catch (e) {} ws = null; } if (ws) { try { ws.close(); } catch (e) {} ws = null; }
} }
File diff suppressed because it is too large Load Diff
+58 -17
View File
@@ -97,31 +97,75 @@ PY
printf 'Built: %s\n' "$ZIP_PATH" printf 'Built: %s\n' "$ZIP_PATH"
TARGET="$PAGER_USER@$PAGER_HOST" 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() { run_scp() {
if [[ -n "$PASSWORD" && -n "$SSH_KEY" ]]; then 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 elif [[ -n "$PASSWORD" ]]; then
SSHPASS="$PASSWORD" sshpass -e scp "$@" SSHPASS="$PASSWORD" sshpass -e scp "${SSH_OPTS[@]}" "$@"
elif [[ -n "$SSH_KEY" ]]; then elif [[ -n "$SSH_KEY" ]]; then
scp -i "$SSH_KEY" "$@" scp "${SSH_OPTS[@]}" -i "$SSH_KEY" "$@"
else else
scp "$@" scp "${SSH_OPTS[@]}" "$@"
fi fi
} }
run_ssh() { run_ssh() {
if [[ -n "$PASSWORD" && -n "$SSH_KEY" ]]; then 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 elif [[ -n "$PASSWORD" ]]; then
SSHPASS="$PASSWORD" sshpass -e ssh "$@" SSHPASS="$PASSWORD" sshpass -e ssh "${SSH_OPTS[@]}" "$@"
elif [[ -n "$SSH_KEY" ]]; then elif [[ -n "$SSH_KEY" ]]; then
ssh -i "$SSH_KEY" "$@" ssh "${SSH_OPTS[@]}" -i "$SSH_KEY" "$@"
else else
ssh "$@" ssh "${SSH_OPTS[@]}" "$@"
fi 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/" run_scp "$ZIP_PATH" "$MANIFEST_PATH" "$TARGET:/tmp/"
REMOTE_PAYLOAD_DIR="user/$PAYLOAD_CATEGORY/$PAYLOAD_KEY" REMOTE_PAYLOAD_DIR="user/$PAYLOAD_CATEGORY/$PAYLOAD_KEY"
@@ -151,16 +195,13 @@ else
exit 1 exit 1
fi fi
rm -f '/tmp/$ZIP_NAME' /tmp/_hak5_manifest.json rm -f '/tmp/$ZIP_NAME' /tmp/_hak5_manifest.json
webui_running=false cp '$REMOTE_PAYLOAD_DIR/pagerwebui.init' /etc/init.d/pagerwebui
if [ -x /etc/init.d/pagerwebui ] && /etc/init.d/pagerwebui running >/dev/null 2>&1; then chmod +x /etc/init.d/pagerwebui
webui_running=true /etc/init.d/pagerwebui enable
fi if /etc/init.d/pagerwebui running >/dev/null 2>&1; then
if [ -f /etc/init.d/pagerwebui ]; then
cp '$REMOTE_PAYLOAD_DIR/pagerwebui.init' /etc/init.d/pagerwebui
chmod +x /etc/init.d/pagerwebui
fi
if \$webui_running; then
/etc/init.d/pagerwebui restart /etc/init.d/pagerwebui restart
else
/etc/init.d/pagerwebui start
fi fi
echo EXTRACT_OK" echo EXTRACT_OK"
run_ssh "$TARGET" "$REMOTE_COMMAND" run_ssh "$TARGET" "$REMOTE_COMMAND"
+136 -3
View File
@@ -80,11 +80,20 @@ class AttacksDeployTest(unittest.TestCase):
self.old_state = server.PINEAP_STATE_FILE self.old_state = server.PINEAP_STATE_FILE
server.PINEAP_STATE_FILE = os.path.join(self.tmp, 'state.json') server.PINEAP_STATE_FILE = os.path.join(self.tmp, 'state.json')
self.old_ent = {k: getattr(server, k) for k in 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_CONF = os.path.join(self.tmp, 'enterprise.conf')
server.ENT_PIDFILE = os.path.join(self.tmp, 'mk8.pid') server.ENT_PIDFILE = os.path.join(self.tmp, 'mk8.pid')
server.ENT_EAP_USERS = os.path.join(self.tmp, 'eap_users') server.ENT_EAP_USERS = os.path.join(self.tmp, 'eap_users')
server.ENT_STATE = os.path.join(self.tmp, 'state.json') 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_running = server._ent_running
self.old_ent_state = server._ent_state_loaded self.old_ent_state = server._ent_state_loaded
server._ent_running = lambda: True server._ent_running = lambda: True
@@ -203,10 +212,25 @@ class AttacksDeployTest(unittest.TestCase):
self.assertIn(['iw', 'phy', 'phy1', 'interface', 'add', 'wlan1ent', self.assertIn(['iw', 'phy', 'phy1', 'interface', 'add', 'wlan1ent',
'type', 'managed'], cmds) 'type', 'managed'], cmds)
self.assertIn(['iw', 'dev', 'wlan1ent', 'set', 'type', 'ap'], cmds) self.assertIn(['iw', 'dev', 'wlan1ent', 'set', 'type', 'ap'], cmds)
self.assertIn(['/usr/sbin/hostapd', '-B', '-P', server.ENT_PIDFILE, self.assertTrue(any(c[:4] == ['/usr/sbin/hostapd', '-B', '-P', server.ENT_PIDFILE]
server.ENT_CONF], cmds) for c in cmds))
self.assertEqual(self.f.state['pineapd.@hostapd[0].mgmtiface'], 'wlan1ent') self.assertEqual(self.f.state['pineapd.@hostapd[0].mgmtiface'], 'wlan1ent')
self.assertEqual(self.f.state['pineapd.wlan1mon.hop'], '0') 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): def test_deploy_enterprise_rejects_non_5g_channel(self):
status, _ = server.h_attacks_deploy(ctx({ status, _ = server.h_attacks_deploy(ctx({
@@ -223,6 +247,77 @@ class AttacksDeployTest(unittest.TestCase):
cmds = [r[0] for r in self.f.runs] cmds = [r[0] for r in self.f.runs]
self.assertIn(['iw', 'dev', 'wlan1ent', 'del'], cmds) 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): def test_deploy_validation(self):
status, _ = server.h_attacks_deploy(ctx({'kind': 'wpa', 'ssid': ''})) status, _ = server.h_attacks_deploy(ctx({'kind': 'wpa', 'ssid': ''}))
self.assertEqual(status, 400) self.assertEqual(status, 400)
@@ -322,5 +417,43 @@ class AttacksExportTest(unittest.TestCase):
self.assertIn('/root/loot/pcap/b.cap', hc) 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__': if __name__ == '__main__':
unittest.main() unittest.main()
+19 -13
View File
@@ -106,15 +106,19 @@ class EnvCheckTest(unittest.TestCase):
self.assertEqual(server.ENV_CHECK_STATE['pool_runtime'], 'disabled') self.assertEqual(server.ENV_CHECK_STATE['pool_runtime'], 'disabled')
self.assertNotIn(['_pineap', 'SSIDPOOL', 'DISABLE'], self.runs) self.assertNotIn(['_pineap', 'SSIDPOOL', 'DISABLE'], self.runs)
def test_applies_sane_defaults_when_missing(self): def test_warns_sane_defaults_when_missing_without_mutating(self):
self.uci_state['pineapd.@ssidpool[0].ssid'] = 'QmVlcg=='
report = server.env_check() report = server.env_check()
self.assertEqual(self.steps(report, 'sane-off UCI defaults applied')[0]['ok'], 'fixed')
self.assertEqual( self.assertEqual(
self.steps(report, 'runtime safety settings changed')[0]['ok'], self.steps(report, 'live PineAP UCI left unchanged')[0]['ok'], 'warn')
'fixed') self.assertNotIn(['/etc/init.d/pineapd', 'restart'], self.runs)
self.assertIn(['/etc/init.d/pineapd', 'restart'], self.runs) self.assertNotIn(['/etc/init.d/pineapd', 'stop'], self.runs)
for key, value in server.PINEAPD_SAFE_UCI.items(): self.assertFalse(any(a[:2] == ['uci', 'set'] for a in self.runs))
self.assertEqual(self.uci_state[key], value) 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): def test_uci_pass_when_already_set(self):
self.safe_set() self.safe_set()
@@ -213,17 +217,19 @@ class EnvCheckTest(unittest.TestCase):
report = server.env_check() report = server.env_check()
self.assertEqual(self.steps(report, 'no radio0 AP pins wlan0mon')[0]['ok'], 'pass') self.assertEqual(self.steps(report, 'no radio0 AP pins wlan0mon')[0]['ok'], 'pass')
def test_sta_uplink_disabled_when_enabled(self): def test_sta_uplink_warns_when_enabled_without_mutating(self):
self.safe_set() self.safe_set()
self.uci_state['wireless.dummy_radio0.mode'] = 'sta' self.uci_state['wireless.dummy_radio0.mode'] = 'sta'
self.uci_state['wireless.dummy_radio0.ifname'] = 'wlan0' self.uci_state['wireless.dummy_radio0.ifname'] = 'wlan0'
self.uci_state['wireless.dummy_radio0.disabled'] = '0' self.uci_state['wireless.dummy_radio0.disabled'] = '0'
report = server.env_check() report = server.env_check()
self.assertEqual(self.steps(report, 'dummy_radio0 STA uplink disabled')[0]['ok'], 'fixed') self.assertEqual(self.steps(report, 'dummy_radio0 STA uplink is enabled')[0]['ok'], 'warn')
self.assertEqual(self.uci_state['wireless.dummy_radio0.disabled'], '1') self.assertEqual(self.uci_state['wireless.dummy_radio0.disabled'], '0')
self.assertIn(['ip', 'link', 'set', 'wlan0', 'down'], self.runs) self.assertNotIn(['ip', 'link', 'set', 'wlan0', 'down'], self.runs)
self.assertNotIn(['wifi', 'reload'], self.runs, self.assertNotIn(['wifi', 'reload'], self.runs)
'STA fix must not bounce the radios') 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): def test_sta_uplink_pass_when_absent(self):
self.safe_set() self.safe_set()
+14 -17
View File
@@ -73,12 +73,15 @@ class HealthCheckTest(unittest.TestCase):
self.assertIn(['ip', 'link', 'set', 'wlan0mon', 'up'], [r[0] for r in self.runs]) self.assertIn(['ip', 'link', 'set', 'wlan0mon', 'up'], [r[0] for r in self.runs])
self.assertEqual(result['monitor_fixes'], 1) 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.ping_ok = False
self.sigsegs = 5 self.sigsegs = 5
self.uci_state['pineapd.@ssidpool[0].ssid'] = 'QmVlcg=='
result = server.health_check() result = server.health_check()
self.assertIn('pool broadcast disabled', result['last_action']) self.assertEqual(result['last_action'], 'pineapd restart')
self.assertEqual(self.uci_state['pineapd.@ssidpool[0].disable'], '1') 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.assertIn(['/etc/init.d/pineapd', 'restart'], [r[0] for r in self.runs])
self.assertEqual(result['fixes'], 1) self.assertEqual(result['fixes'], 1)
@@ -94,21 +97,15 @@ class HealthCheckTest(unittest.TestCase):
self.assertEqual(result['last_action'], 'pineapd restart') self.assertEqual(result['last_action'], 'pineapd restart')
self.assertIn(['/etc/init.d/pineapd', 'restart'], [r[0] for r in self.runs]) self.assertIn(['/etc/init.d/pineapd', 'restart'], [r[0] for r in self.runs])
def test_down_stabilizes_known_crash_sources(self): def test_down_does_not_stabilize_or_clear_pool(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):
self.ping_ok = False self.ping_ok = False
self.uci_state['pineapd.@ssidpool[0].disable'] = '1' self.uci_state['pineapd.@ssidpool[0].disable'] = '1'
self.uci_state['pineapd.@ssidpool[0].ssid'] = 'QmVlcg==' self.uci_state['pineapd.@ssidpool[0].ssid'] = 'QmVlcg=='
result = server.health_check() result = server.health_check()
self.assertNotIn('pineapd.@ssidpool[0].ssid', self.uci_state) self.assertEqual(result['last_action'], 'pineapd restart')
self.assertIn('pool-list cleared', result['last_action']) 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): def test_down_without_crash_brings_monitors_up(self):
self.ping_ok = False self.ping_ok = False
@@ -118,12 +115,12 @@ class HealthCheckTest(unittest.TestCase):
self.assertEqual(result['last_action'], 'monitor interfaces brought up') self.assertEqual(result['last_action'], 'monitor interfaces brought up')
self.assertIn(['ip', 'link', 'set', 'wlan1mon', 'up'], [r[0] for r in self.runs]) 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 self.ping_ok = False
server._health['sigsegv_last'] = 4 server._health['sigsegv_last'] = 4
result = server.health_check() result = server.health_check()
self.assertIn('SSID pool broadcast disabled', result['last_action']) self.assertEqual(result['last_action'], 'pineapd restart')
self.assertEqual(self.uci_state['pineapd.@ssidpool[0].disable'], '1') self.assertNotIn('pineapd.@ssidpool[0].disable', self.uci_state)
def test_fix_cooldown_prevents_thrash(self): def test_fix_cooldown_prevents_thrash(self):
self.ping_ok = False self.ping_ok = False
+20
View File
@@ -153,6 +153,26 @@ class LoggingTest(unittest.TestCase):
self.assertEqual(server._line_count( self.assertEqual(server._line_count(
type('C', (), {'query': {'lines': '-10'}})(), 200), 0) 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): class SettingsTest(unittest.TestCase):
def test_hostname_get(self): def test_hostname_get(self):
+190 -4
View File
@@ -1,5 +1,8 @@
import json
import os import os
import sqlite3
import sys import sys
import tempfile
import unittest import unittest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'payload', 'user', 'remote_access', 'pager-webui')) 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) __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): def test_basic_rows(self):
server._db_rows = lambda db, sql: [{'time': 1, 'username': 'a', 'password': 'b'}] server._db_rows = lambda db, sql: [{'time': 1, 'username': 'a', 'password': 'b'}]
status, payload = server.h_enterprise_data(type('C', (), {'args': ('basic',)})()) status, payload = server.h_enterprise_data(type('C', (), {'args': ('basic',)})())
self.assertEqual(status, 200) self.assertEqual(status, 200)
self.assertEqual(payload['table'], 'hostap_basic')
self.assertEqual(payload['rows'][0]['username'], 'a') 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: [] server._db_rows = lambda db, sql: []
status, payload = server.h_enterprise_data(type('C', (), {'args': ('challenge',)})()) status, payload = server.h_enterprise_data(type('C', (), {'args': ('challenge',)})())
self.assertEqual(status, 200) self.assertEqual(status, 200)
self.assertEqual(payload['table'], 'hostap_chalresp')
self.assertEqual(payload['rows'], []) self.assertEqual(payload['rows'], [])
def test_unknown_table(self): def test_unknown_table(self):
status, payload = server.h_enterprise_data(type('C', (), {'args': ('nope',)})()) status, payload = server.h_enterprise_data(type('C', (), {'args': ('nope',)})())
self.assertEqual(status, 400) self.assertEqual(status, 400)
def test_clear(self): def test_clear_uses_chalresp_table(self):
calls = [] calls = []
server._db_write = lambda db, sql: calls.append(sql) server._db_write = lambda db, sql: calls.append(sql)
status, payload = server.h_enterprise_clear(type('C', (), {'body': {'table': 'challenge'}})()) status, payload = server.h_enterprise_clear(type('C', (), {'body': {'table': 'challenge'}})())
self.assertEqual(status, 200) 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): def test_clear_unknown_table(self):
status, payload = server.h_enterprise_clear(type('C', (), {'body': {'table': 'nope'}})()) status, payload = server.h_enterprise_clear(type('C', (), {'body': {'table': 'nope'}})())
self.assertEqual(status, 400) 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__': if __name__ == '__main__':
unittest.main() unittest.main()
+5 -4
View File
@@ -53,7 +53,7 @@ class SsidPoolHandlersTest(unittest.TestCase):
server.h_ssids_post(type('C', (), {'args': (), 'body': {'action': 'add', 'ssid': 'NewNet'}})()) 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)) 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 = [] calls = []
def fake(method, path, body=None, timeout=10): def fake(method, path, body=None, timeout=10):
@@ -61,10 +61,11 @@ class SsidPoolHandlersTest(unittest.TestCase):
return (200, {'success': True}) return (200, {'success': True})
server.daemon_sock_call = fake 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}})()) server.h_pineap_advertise(type('C', (), {'body': {'enable': False}})())
self.assertEqual(calls, [('/api/pineap/ssidpool/enable', {'enable': True}), self.assertEqual(calls, [('/api/pineap/ssidpool/disable', {'enable': False})])
('/api/pineap/ssidpool/disable', {'enable': False})])
def test_collect_routes(self): def test_collect_routes(self):
calls = [] calls = []
+27
View File
@@ -144,6 +144,7 @@ class PineapProxyTest(unittest.TestCase):
self.assertEqual(payload['enterprise']['enabled'], False) self.assertEqual(payload['enterprise']['enabled'], False)
self.assertEqual(payload['enterprise']['ssid'], '') self.assertEqual(payload['enterprise']['ssid'], '')
self.assertEqual(payload['pool']['collecting'], True) self.assertEqual(payload['pool']['collecting'], True)
self.assertTrue(payload['pool']['broadcast_blocked'])
self.assertEqual(payload['radios']['radio0']['band'], '2.4') self.assertEqual(payload['radios']['radio0']['band'], '2.4')
self.assertEqual(payload['radios']['radio1']['channel'], 'auto') self.assertEqual(payload['radios']['radio1']['channel'], 'auto')
self.assertEqual(payload['pineape']['enabled'], True) 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_CLEAR', 'deny'],
[server.HAK5CMD, 'PINEAPPLE_NETWORK_FILTER_MODE', '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__': if __name__ == '__main__':
unittest.main() unittest.main()
+148 -22
View File
@@ -163,33 +163,54 @@ class FakeSock:
class ReconHopperTest(unittest.TestCase): class ReconHopperTest(unittest.TestCase):
def test_preflight_verifies_every_non_dfs_channel(self): def test_preflight_probes_one_channel_per_radio(self):
calls = [] calls = []
with mock.patch.object( with mock.patch.object(
server, '_set_monitor_channel', server, '_set_monitor_channel',
side_effect=lambda interface, channel: side_effect=lambda interface, channel:
calls.append((interface, channel)) or (True, '')): calls.append((interface, channel)) or (True, '')):
self.assertEqual( with mock.patch.object(server, '_monitor_down', return_value=False):
server._recon_hopper_preflight(), self.assertEqual(
(True, 'monitor channel control ready')) server._recon_hopper_preflight(),
(True, 'monitor channel control ready'))
expected = [ expected = [
(interface, channel) (interface, channels[0])
for interface, channels in server.RECON_CHANNELS.items() for interface, channels in server.RECON_CHANNELS.items()
for channel in channels
] ]
self.assertEqual(calls, expected) self.assertEqual(calls, expected)
self.assertEqual(server._recon_hop_state['ifaces'], ['wlan0mon', 'wlan1mon'])
def test_preflight_stops_at_first_unusable_channel(self): def test_preflight_skips_busy_radio_and_keeps_the_other(self):
def set_channel(interface, channel): def set_channel(interface, channel):
if interface == 'wlan1mon' and channel == 44: if interface == 'wlan0mon':
return False, 'wlan1mon channel 44: busy' return False, 'wlan0mon channel 1: command failed: Resource busy (-16)'
return 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, '_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( with mock.patch.object(
server, '_set_monitor_channel', side_effect=set_channel): server, '_set_monitor_channel',
ok, detail = server._recon_hopper_preflight() 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.assertFalse(ok)
self.assertIn('wlan1mon channel 44', detail) 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): def test_set_channel_surfaces_iw_failure(self):
with mock.patch.object( with mock.patch.object(
@@ -200,6 +221,67 @@ class ReconHopperTest(unittest.TestCase):
self.assertIn('wlan0mon channel 6', detail) self.assertIn('wlan0mon channel 6', detail)
self.assertIn('Device or resource busy', 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): class DaemonSockTest(unittest.TestCase):
def setUp(self): def setUp(self):
@@ -278,15 +360,36 @@ class DaemonSockTest(unittest.TestCase):
def test_start_reports_hopper_preflight_failure(self): def test_start_reports_hopper_preflight_failure(self):
calls = [] calls = []
server._recon_hopper_preflight.return_value = ( server._recon_hopper_preflight.return_value = (
False, 'wlan1mon channel 36: Device or resource busy') False, 'Recon radios are unavailable. wlan0mon is missing.')
server.daemon_sock_call = lambda *args, **kwargs: calls.append(args) server.daemon_sock_call = lambda *args, **kwargs: calls.append(args)
status, data = server.h_recon_start( status, data = server.h_recon_start(
type('C', (), {'args': (), 'body': {'scan_time': 30}})()) type('C', (), {'args': (), 'body': {'scan_time': 30}})())
self.assertEqual(status, 503) self.assertEqual(status, 503)
self.assertEqual(data['error'], 'recon radio preflight failed') self.assertEqual(data['error'], 'Could not prepare recon radios')
self.assertIn('wlan1mon', data['detail']) self.assertIn('unavailable', data['detail'])
self.assertEqual(calls, []) 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): class ReconScanStateTest(unittest.TestCase):
"""The webui mirrors the duration of the Pager's native timed scan.""" """The webui mirrors the duration of the Pager's native timed scan."""
@@ -487,7 +590,7 @@ class ReconExamineTest(unittest.TestCase):
ctx = type('C', (), {'args': (), 'body': {'bssid': 'AA:BB:CC:DD:EE:FF'}})() ctx = type('C', (), {'args': (), 'body': {'bssid': 'AA:BB:CC:DD:EE:FF'}})()
status, data = server.h_recon_examine(ctx) status, data = server.h_recon_examine(ctx)
self.assertEqual(status, 200) 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): def test_examine_channel_calls_hak5(self):
calls = [] calls = []
@@ -495,7 +598,24 @@ class ReconExamineTest(unittest.TestCase):
ctx = type('C', (), {'args': (), 'body': {'channel': 6}})() ctx = type('C', (), {'args': (), 'body': {'channel': 6}})()
status, data = server.h_recon_examine(ctx) status, data = server.h_recon_examine(ctx)
self.assertEqual(status, 200) 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): def test_examine_requires_target(self):
server.hak5 = lambda *args, **kw: '' server.hak5 = lambda *args, **kw: ''
@@ -1186,18 +1306,24 @@ class WigleTest(unittest.TestCase):
return type('C', (), {'args': args, 'body': body or {}})() return type('C', (), {'args': args, 'body': body or {}})()
def _write(self, name, content): def _write(self, name, content):
with open(os.path.join(self.dir, name), 'w') as f: raw = content.encode('utf-8') if isinstance(content, str) else content
f.write(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): def test_file_rows_count_excludes_header(self):
self._write('a.csv', 'header\nr1\nr2\n') payload = b'header\nr1\nr2\n'
self._write('b.csv', 'onlyheader\n') self._write('a.csv', payload)
self._write('b.csv', b'onlyheader\n')
status, data = server.h_recon_wigle_files(self._ctx()) status, data = server.h_recon_wigle_files(self._ctx())
self.assertEqual(status, 200) self.assertEqual(status, 200)
files = {f['name']: f for f in data['files']} files = {f['name']: f for f in data['files']}
self.assertEqual(files['a.csv']['rows'], 2) self.assertEqual(files['a.csv']['rows'], 2)
self.assertEqual(files['b.csv']['rows'], 0) 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): 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' meta = 'WigleWifi-1.6,appRelease=0.0.0,model=pineapplepager,release=0.0.0\n'
+206
View File
@@ -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()
+159
View File
@@ -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()
+19
View File
@@ -51,6 +51,8 @@ class StatusTest(unittest.TestCase):
def fake(args, timeout=20): def fake(args, timeout=20):
if args == ['iwinfo']: if args == ['iwinfo']:
return 0, 'wlan0 ESSID: "Pineapple"\n', '' return 0, 'wlan0 ESSID: "Pineapple"\n', ''
if args[0] == 'iw':
return 1, '', 'busy'
if args == ['iwinfo', 'wlan0', 'assoclist']: 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, '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, '', '' return 0, '', ''
@@ -61,6 +63,23 @@ class StatusTest(unittest.TestCase):
self.assertEqual(clients[0]['rssi'], -64) self.assertEqual(clients[0]['rssi'], -64)
self.assertEqual(clients[0]['iface'], 'wlan0') 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): def test_h_status_shape(self):
server.device_run = lambda args, timeout=20: (0, '', '') server.device_run = lambda args, timeout=20: (0, '', '')
server.current_token = lambda: 'tok' server.current_token = lambda: 'tok'