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:
@@ -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)
|
||||
@@ -53,10 +53,12 @@ Then on the Pager menu, run **Mark VIII**:
|
||||
|
||||
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, sane-off PineAP UCI defaults applied (SSID pool
|
||||
broadcast off, 6GHz hopper disabled, monitor bands), runtime pool sync, monitor
|
||||
interfaces up, and recon DB readable. Anything fixable is fixed and re-verified;
|
||||
startup aborts only if a core dependency fails.
|
||||
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.
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"title": "Mark VIII",
|
||||
"author": "c4ch3c4d3",
|
||||
"description": "Mark VII-style web management UI for the WiFi Pineapple Pager",
|
||||
"version": "1.3.1",
|
||||
"version": "1.3.2",
|
||||
"category": "remote_access",
|
||||
"tags": ["remote-access", "web-interface", "device-management", "pineap"],
|
||||
"firmware": "Pineapple Pager 24.10.1"
|
||||
|
||||
@@ -21,7 +21,7 @@ start_service() {
|
||||
procd_set_param respawn
|
||||
procd_set_param stdout 1
|
||||
procd_set_param stderr 1
|
||||
procd_set_param term_timeout 10
|
||||
procd_set_param term_timeout 90
|
||||
procd_close_instance
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# Title: Mark VIII
|
||||
# Description: Mark VII-style web management UI for the WiFi Pineapple Pager
|
||||
# Author: c4ch3c4d3
|
||||
# Version: 1.3.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.3.1 |"
|
||||
LOG "cyan" "| Mark VIII v1.3.2 |"
|
||||
LOG "cyan" "+---------------------------+"
|
||||
|
||||
if ! command -v python3 >/dev/null 2>&1; then
|
||||
@@ -70,8 +70,14 @@ wait_for_server_stop() {
|
||||
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"
|
||||
}
|
||||
@@ -84,7 +90,7 @@ if [ -f "$INIT_SCRIPT" ] && "$INIT_SCRIPT" running 2>/dev/null; then
|
||||
if user_confirmed "$resp"; then
|
||||
LOG "yellow" "Stopping 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"
|
||||
exit 1
|
||||
fi
|
||||
@@ -147,8 +153,10 @@ 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
|
||||
|
||||
|
||||
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; }
|
||||
@@ -406,7 +427,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 +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 + .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');
|
||||
clearTimeout(timer);
|
||||
if (method === 'GET' && attempt < 1) {
|
||||
return request(method, path, body, attempt + 1);
|
||||
}
|
||||
const error = new Error((e && e.name === 'AbortError') ? 'Request timed out' : (e && e.message) || 'Network error');
|
||||
error.status = 0;
|
||||
throw error;
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
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', () => {
|
||||
|
||||
@@ -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
+56
-15
@@ -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,16 +195,13 @@ else
|
||||
exit 1
|
||||
fi
|
||||
rm -f '/tmp/$ZIP_NAME' /tmp/_hak5_manifest.json
|
||||
webui_running=false
|
||||
if [ -x /etc/init.d/pagerwebui ] && /etc/init.d/pagerwebui running >/dev/null 2>&1; then
|
||||
webui_running=true
|
||||
fi
|
||||
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 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()
|
||||
|
||||
+19
-13
@@ -106,15 +106,19 @@ class EnvCheckTest(unittest.TestCase):
|
||||
self.assertEqual(server.ENV_CHECK_STATE['pool_runtime'], 'disabled')
|
||||
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()
|
||||
self.assertEqual(self.steps(report, 'sane-off UCI defaults applied')[0]['ok'], 'fixed')
|
||||
self.assertEqual(
|
||||
self.steps(report, 'runtime safety settings changed')[0]['ok'],
|
||||
'fixed')
|
||||
self.assertIn(['/etc/init.d/pineapd', 'restart'], self.runs)
|
||||
for key, value in server.PINEAPD_SAFE_UCI.items():
|
||||
self.assertEqual(self.uci_state[key], value)
|
||||
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()
|
||||
@@ -213,17 +217,19 @@ class EnvCheckTest(unittest.TestCase):
|
||||
report = server.env_check()
|
||||
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.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 disabled')[0]['ok'], 'fixed')
|
||||
self.assertEqual(self.uci_state['wireless.dummy_radio0.disabled'], '1')
|
||||
self.assertIn(['ip', 'link', 'set', 'wlan0', 'down'], self.runs)
|
||||
self.assertNotIn(['wifi', 'reload'], self.runs,
|
||||
'STA fix must not bounce the radios')
|
||||
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()
|
||||
|
||||
+14
-17
@@ -73,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)
|
||||
|
||||
@@ -94,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
|
||||
@@ -118,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()
|
||||
|
||||
+144
-18
@@ -163,33 +163,54 @@ class FakeSock:
|
||||
|
||||
|
||||
class ReconHopperTest(unittest.TestCase):
|
||||
def test_preflight_verifies_every_non_dfs_channel(self):
|
||||
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, channel)
|
||||
(interface, channels[0])
|
||||
for interface, channels in server.RECON_CHANNELS.items()
|
||||
for channel in channels
|
||||
]
|
||||
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):
|
||||
if interface == 'wlan1mon' and channel == 44:
|
||||
return False, 'wlan1mon channel 44: busy'
|
||||
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', side_effect=set_channel):
|
||||
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('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):
|
||||
with mock.patch.object(
|
||||
@@ -200,6 +221,67 @@ class ReconHopperTest(unittest.TestCase):
|
||||
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):
|
||||
@@ -278,15 +360,36 @@ class DaemonSockTest(unittest.TestCase):
|
||||
def test_start_reports_hopper_preflight_failure(self):
|
||||
calls = []
|
||||
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)
|
||||
status, data = server.h_recon_start(
|
||||
type('C', (), {'args': (), 'body': {'scan_time': 30}})())
|
||||
self.assertEqual(status, 503)
|
||||
self.assertEqual(data['error'], 'recon radio preflight failed')
|
||||
self.assertIn('wlan1mon', data['detail'])
|
||||
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."""
|
||||
@@ -487,7 +590,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 = []
|
||||
@@ -495,7 +598,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: ''
|
||||
@@ -1186,18 +1306,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