From d10fba9d1b743dd12e7384ecf73095f667a4f565 Mon Sep 17 00:00:00 2001 From: c4ch3c4d3 Date: Tue, 18 Aug 2026 20:16:41 -0500 Subject: [PATCH] fix: never re-enable SSID pool broadcast (crash guard) + top-bar health chip Mode 'active' and the advertise toggle could re-enable the SSID-pool broadcast that segfaults pineapd. active preset now skips ssidpool/enable (advertise stays false), the advertise endpoint refuses with an explanation when the pool is disabled, get_ap reports the real pool state, and the PineAP overview disables the toggle with a notice. Added a top-bar health chip (PINEAP OK / POOL OFF / PINEAPD DOWN) polled every 15s. --- .../user/remote_access/pager-webui/server.py | 18 ++++++++++----- .../remote_access/pager-webui/www/css/app.css | 7 ++++++ .../remote_access/pager-webui/www/index.html | 1 + .../remote_access/pager-webui/www/js/app.js | 22 +++++++++++++++++++ .../remote_access/pager-webui/www/js/views.js | 13 ++++++++--- tests/test_pineap_modes.py | 21 +++++++++++++----- 6 files changed, 69 insertions(+), 13 deletions(-) diff --git a/payload/user/remote_access/pager-webui/server.py b/payload/user/remote_access/pager-webui/server.py index 7094a75..58a3538 100644 --- a/payload/user/remote_access/pager-webui/server.py +++ b/payload/user/remote_access/pager-webui/server.py @@ -2581,7 +2581,8 @@ def h_pineap_wifi_get_ap(ctx): 'channel': _ent_summary()['channel'], 'live': _ent_summary()['live'], }, - 'pool': {'disabled': None, 'collecting': bool(pinecfg.get('autossidpool'))}, + 'pool': {'disabled': pool.get('disable') == '1', + 'collecting': bool(pinecfg.get('autossidpool'))}, 'radios': { 'radio0': _radio_dict('radio0'), 'radio1': _radio_dict('radio1'), @@ -2759,6 +2760,13 @@ def h_pineap_wifi_set_ap(ctx): def h_pineap_advertise(ctx): enable = bool((ctx.body or {}).get('enable')) + if enable: + # The SSID-pool broadcast segfaults pineapd on this firmware; the + # pool stays disabled or the crash-loop returns. + pool = _uci_section('pineapd.@ssidpool[0]') + if pool.get('disable') == '1': + return 400, {'error': 'SSID pool broadcast is disabled on this firmware ' + '(pineapd SIGSEGV crash-loop) and cannot be re-enabled'} status, data = _daemon_proxy('POST', 'ssidpool/enable' if enable else 'ssidpool/disable', {'enable': enable}) if status == 200: update_pineap_state(mode='advanced', advertise=enable) @@ -3911,16 +3919,16 @@ def h_pineap_mode_post(ctx): (('enable' if enabled else 'disable') + ' PineAP response engine', 'PUT', 'hostapd/enable_pineap', {'enable': enabled}), ('enable SSID collection', 'POST', 'ssidpool/enable_collect', {'enable': True}), - (('enable' if mode == 'active' else 'disable') + ' SSID pool broadcasting', - 'POST', 'ssidpool/enable' if mode == 'active' else 'ssidpool/disable', - {'enable': mode == 'active'}), ] + # NOTE: the SSID-pool broadcast step is intentionally omitted. The pool + # broadcast segfaults pineapd on this firmware (SIGSEGV crash-loop); the + # health monitor disables it and 'active' must not re-enable it. for label, method, path, body in steps: status, data = _daemon_proxy(method, path, body) if status != 200: return status, {'error': 'failed to ' + label, 'detail': data} return 200, update_pineap_state(mode=mode, enabled=enabled, karma=enabled, - collect=True, advertise=(mode == 'active')) + collect=True, advertise=False) def h_filter_get(ctx, kind): diff --git a/payload/user/remote_access/pager-webui/www/css/app.css b/payload/user/remote_access/pager-webui/www/css/app.css index 8dc940d..9853988 100644 --- a/payload/user/remote_access/pager-webui/www/css/app.css +++ b/payload/user/remote_access/pager-webui/www/css/app.css @@ -187,6 +187,13 @@ body { .badge.off { background: #fff3e0; color: #e65100; } .badge.warn { background: #fff8e1; color: #f57f17; } .badge.unknown { background: #eeeeee; color: #616161; } +.health-chip { font-size: 11px; font-weight: 600; letter-spacing: .04em; padding: 2px 8px; border-radius: 10px; margin-left: 10px; align-self: center; } +.health-chip.good { background: #e8f5e9; color: #2e7d32; } +.health-chip.warn { background: #fff8e1; color: #f57f17; } +.health-chip.bad { background: #fdecea; color: #b71c1c; } +html.dark .health-chip.good { background: #1b3a24; color: #81c784; } +html.dark .health-chip.warn { background: #3d3313; color: #ffd54f; } +html.dark .health-chip.bad { background: #4a2020; color: #ffb4a9; } .btn { background: var(--primary); color: #fff; border: 0; border-radius: 2px; padding: 8px 14px; font-size: 14px; cursor: pointer; diff --git a/payload/user/remote_access/pager-webui/www/index.html b/payload/user/remote_access/pager-webui/www/index.html index 5b465e6..172ffbf 100644 --- a/payload/user/remote_access/pager-webui/www/index.html +++ b/payload/user/remote_access/pager-webui/www/index.html @@ -26,6 +26,7 @@ Mark VIII +
diff --git a/payload/user/remote_access/pager-webui/www/js/app.js b/payload/user/remote_access/pager-webui/www/js/app.js index ff116b5..80c5028 100644 --- a/payload/user/remote_access/pager-webui/www/js/app.js +++ b/payload/user/remote_access/pager-webui/www/js/app.js @@ -434,6 +434,7 @@ const Live = (() => { let ws = null; let ever = false; let poll = null; + let pollHealthTimer = null; const subs = []; let timer = null; function stopPoll() { @@ -442,6 +443,10 @@ const Live = (() => { function start() { if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return; stopPoll(); + if (!pollHealthTimer) { + pollHealthTimer = setInterval(pollHealth, 15000); + pollHealth(); + } try { ws = new WebSocket(App.wsUrl('/api/ws')); } catch (e) { fallback(); return; } ws.onopen = () => { ever = true; }; @@ -489,6 +494,23 @@ const Live = (() => { const el = document.getElementById('live-status'); if (el) el.textContent = 'BAT ' + (b.level == null ? '--' : b.level + '%' + (b.charging ? '+' : '')) + ' CLIENTS ' + n; } + function pollHealth() { + fetch(API_BASE + '/api/health', { credentials: 'include' }).then((r) => r.json()) + .then((h) => { + const el = document.getElementById('health-status'); + if (!el) return; + if (h.pineap_up === false) { + el.textContent = 'PINEAPD DOWN'; + el.className = 'health-chip bad'; + } else if (h.pool_disabled) { + el.textContent = 'POOL OFF'; + el.className = 'health-chip warn'; + } else if (h.pineap_up) { + el.textContent = 'PINEAP OK'; + el.className = 'health-chip good'; + } + }).catch(() => {}); + } function onTick(fn) { subs.push(fn); return () => { diff --git a/payload/user/remote_access/pager-webui/www/js/views.js b/payload/user/remote_access/pager-webui/www/js/views.js index ec6ecf0..5a958ee 100644 --- a/payload/user/remote_access/pager-webui/www/js/views.js +++ b/payload/user/remote_access/pager-webui/www/js/views.js @@ -276,6 +276,9 @@ views.pineap = (root) => { h('div', { class: 'pineap-card-title' }, 'Quick Settings')); quickCard.appendChild(h('label', { class: 'switch' }, quick.collect, h('span', { class: 'track' }), 'Capture SSIDs to Pool')); quickCard.appendChild(h('label', { class: 'switch' }, quick.advertise, h('span', { class: 'track' }), 'Advertise AP Impersonation Pool')); + const poolNotice = h('div', { class: 'pineap-infobox warn', style: 'margin-top:8px', + text: 'Pool broadcast disabled: it segfaults pineapd on this firmware (crash-loop fix).' }); + quickCard.appendChild(poolNotice); quickCard.appendChild(h('div', { class: 'muted', style: 'margin-top:8px;font-size:12px' }, 'Client connect/disconnect notifications are handled by the Pager alert payload system.')); @@ -305,10 +308,10 @@ views.pineap = (root) => { on(requested).then(() => { if (remember) remember(requested); load(); - }).catch(() => { + }).catch((e) => { cb.checked = !requested; load(); - App.toast('Failed', 'error'); + App.toast((e && e.message) || 'Failed', 'error'); }); }); } @@ -332,7 +335,7 @@ views.pineap = (root) => { } const descriptions = { passive: ['Capture SSIDs to the impersonation pool', 'Do not broadcast the pool', 'Keep the PineAP response engine disabled'], - active: ['Capture SSIDs to the impersonation pool', 'Broadcast the impersonation pool', 'Enable the PineAP response engine'] + active: ['Capture SSIDs to the impersonation pool', 'Enable the PineAP response engine', 'Pool broadcast stays disabled (firmware crash fix)'] }; if (m === 'advanced') { modeInfo.textContent = 'All supported PineAP features are individually customizable from Quick Settings and the PineAP tabs.'; @@ -401,6 +404,10 @@ views.pineap = (root) => { : Object.prototype.hasOwnProperty.call(c, 'autossidpool') ? !!c.autossidpool : null; setKnownCheckbox(quick.collect, collect); const pool = a.pool || {}; + const poolDisabled = pool.disabled === true; + poolNotice.classList.toggle('hidden', !poolDisabled); + quick.advertise.disabled = poolDisabled; + quick.advertise.title = poolDisabled ? 'Disabled by firmware crash fix' : ''; if (pool.disabled != null) PINEAP_SESSION.advertise = pool.disabled === false; setKnownCheckbox(quick.advertise, PINEAP_SESSION.advertise); cards.karma.value.textContent = PINEAP_SESSION.karma == null ? 'Unknown' : (PINEAP_SESSION.karma ? 'On' : 'Off'); diff --git a/tests/test_pineap_modes.py b/tests/test_pineap_modes.py index 9eb1b84..cfb6ddc 100644 --- a/tests/test_pineap_modes.py +++ b/tests/test_pineap_modes.py @@ -37,8 +37,7 @@ class PineapModeTest(unittest.TestCase): status, payload = server.h_pineap_mode_post(ctx({'mode': 'passive'})) self.assertEqual(status, 200) self.assertEqual([c[1] for c in calls], [ - 'hostapd/enable_pineap', 'ssidpool/enable_collect', - 'ssidpool/disable']) + 'hostapd/enable_pineap', 'ssidpool/enable_collect']) self.assertEqual(calls[0][2], {'enable': False}) self.assertEqual(payload['mode'], 'passive') self.assertTrue(payload['collect']) @@ -46,7 +45,7 @@ class PineapModeTest(unittest.TestCase): self.assertFalse(payload['karma']) self.assertFalse(payload['enabled']) - def test_active_enables_response_engine_and_pool_broadcasting(self): + def test_active_enables_response_engine_but_never_pool_broadcast(self): calls = [] server._daemon_proxy = lambda method, path, body=None, timeout=15: ( calls.append((method, path, body)) or (200, {'success': True})) @@ -54,12 +53,24 @@ class PineapModeTest(unittest.TestCase): self.assertEqual(status, 200) self.assertEqual(calls[0], ('PUT', 'hostapd/enable_pineap', {'enable': True})) self.assertNotIn('mimic/disable', [c[1] for c in calls]) - self.assertEqual(calls[-1], ('POST', 'ssidpool/enable', {'enable': True})) + # The SSID-pool broadcast segfaults pineapd on this firmware: the + # active preset must never call ssidpool/enable. + self.assertNotIn('ssidpool/enable', [c[1] for c in calls]) self.assertEqual(payload['mode'], 'active') - self.assertTrue(payload['advertise']) + self.assertFalse(payload['advertise']) self.assertTrue(payload['karma']) self.assertTrue(payload['enabled']) + def test_advertise_refuses_when_pool_disabled(self): + server._uci_section = lambda name: {'disable': '1'} + 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_advanced_preserves_device_settings(self): calls = [] server._daemon_proxy = lambda *args, **kwargs: (calls.append(args) or (200, {}))