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
@@ -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');
error.status = 0;
throw error;
clearTimeout(timer);
if (method === 'GET' && attempt < 1) {
return request(method, path, body, attempt + 1);
}
throw e;
} finally {
if (timer) clearTimeout(timer);
const error = new Error((e && e.name === 'AbortError') ? 'Request timed out' : (e && e.message) || 'Network error');
error.status = 0;
throw error;
}
clearTimeout(timer);
if (res.status === 401) {
if (on401) on401();
throw new Error('unauthorized');
@@ -241,7 +241,7 @@ const App = (() => {
});
}
function handleMenuAction(action) {
function handleMenuAction(action, button) {
closeToolbarMenus();
if (action === 'help') {
location.hash = '#/settings/help';
@@ -252,14 +252,18 @@ const App = (() => {
if (typeof views.openClientModeModal === 'function') views.openClientModeModal();
else checkInternet(true);
} else if (action === 'logout') {
if (button) { button.disabled = true; button.classList.add('busy'); }
PagerAPI.post('/api/logout')
.then(() => showLogin())
.catch((error) => toast(error.message || 'Logout failed', 'error'));
.catch((error) => toast(error.message || 'Logout failed', 'error'))
.finally(() => { if (button) { button.disabled = false; button.classList.remove('busy'); } });
} else if (action === 'reboot') {
if (!window.confirm('Reboot Mark VIII now?')) return;
if (button) { button.disabled = true; button.classList.add('busy'); }
PagerAPI.post('/api/settings/reboot')
.then(() => toast('Reboot requested. Mark VIII will disconnect shortly.'))
.catch((error) => toast(error.message || 'Reboot failed', 'error'));
.catch((error) => toast(error.message || 'Reboot failed', 'error'))
.finally(() => { if (button) { button.disabled = false; button.classList.remove('busy'); } });
}
}
@@ -332,7 +336,7 @@ const App = (() => {
renderNotifications();
});
Array.prototype.forEach.call(els.overflowMenu.querySelectorAll('[data-menu-action]'), (item) => {
item.addEventListener('click', () => handleMenuAction(item.getAttribute('data-menu-action')));
item.addEventListener('click', () => handleMenuAction(item.getAttribute('data-menu-action'), item));
});
document.addEventListener('click', closeToolbarMenus);
@@ -342,13 +346,14 @@ const App = (() => {
const pw = document.getElementById('login-password').value;
document.getElementById('login-error').textContent = '';
btn.disabled = true;
btn.classList.add('busy');
PagerAPI.login('root', pw)
.then(() => { document.getElementById('login-password').value = ''; showApp(); toast('Logged in'); })
.catch((err) => {
document.getElementById('login-error').textContent = (err && err.message && err.message !== 'unauthorized')
? 'Login failed.' : 'Invalid credentials.';
})
.finally(() => { btn.disabled = false; });
.finally(() => { btn.disabled = false; btn.classList.remove('busy'); });
});
document.getElementById('terminal-btn').addEventListener('click', () => {
@@ -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