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, {}))