diff --git a/.gitignore b/.gitignore index 2c720d2..cf67a55 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ __pycache__/ *.pyc .worktrees/ +.openchamber/ diff --git a/payload/user/remote_access/pager-webui/_hak5_manifest.json b/payload/user/remote_access/pager-webui/_hak5_manifest.json index 64993d1..f760ade 100644 --- a/payload/user/remote_access/pager-webui/_hak5_manifest.json +++ b/payload/user/remote_access/pager-webui/_hak5_manifest.json @@ -8,7 +8,7 @@ "title": "Mark VIII", "author": "c4ch3c4d3", "description": "Mark VII-style web management UI for the WiFi Pineapple Pager", - "version": "1.0", + "version": "1.1", "category": "remote_access", "tags": ["remote-access", "web-interface", "device-management", "pineap"], "firmware": "Pineapple Pager 24.10.1" diff --git a/payload/user/remote_access/pager-webui/payload.sh b/payload/user/remote_access/pager-webui/payload.sh index 27eff5a..a904def 100755 --- a/payload/user/remote_access/pager-webui/payload.sh +++ b/payload/user/remote_access/pager-webui/payload.sh @@ -2,7 +2,7 @@ # Title: Mark VIII # Description: Mark VII-style web management UI for the WiFi Pineapple Pager # Author: c4ch3c4d3 -# Version: 1.0 +# Version: 1.1 # 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.0 |" +LOG "cyan" "| Mark VIII v1.1 |" LOG "cyan" "+---------------------------+" if ! command -v python3 >/dev/null 2>&1; then diff --git a/payload/user/remote_access/pager-webui/server.py b/payload/user/remote_access/pager-webui/server.py index d711600..4fcd064 100644 --- a/payload/user/remote_access/pager-webui/server.py +++ b/payload/user/remote_access/pager-webui/server.py @@ -1963,11 +1963,32 @@ def _proxy_json(method, path, body=None): return 200, (data if isinstance(data, dict) else {'ok': True}) +def _payload_detail(data): + if isinstance(data, dict): + text = data.get('error') or data.get('detail') + if isinstance(text, str): + return text + return json.dumps(data) + if isinstance(data, bytes): + data = data.decode('utf-8', 'replace') + if isinstance(data, str): + try: + parsed = json.loads(data) + except Exception: + return data + if isinstance(parsed, dict): + text = parsed.get('error') or parsed.get('detail') + if isinstance(text, str): + return text + return data + return str(data) if data is not None else '' + + def _payload_daemon(method, path, body=None): status, data = daemon_call(method, path, body=body, token=current_token(), timeout=45) if status != 200: return (502 if status == 0 else status), { - 'error': 'Pager payload service failed', 'detail': data} + 'error': 'Pager payload service failed', 'detail': _payload_detail(data)} if not isinstance(data, (dict, list)): return 502, {'error': 'Pager payload service returned an invalid response'} return 200, data @@ -2413,6 +2434,184 @@ def h_settings_management_wifi(ctx): } +WIFI_CLIENT_ENCRYPTIONS = { + 'none': 'none', 'open': 'none', + 'wpa2': 'psk2', 'psk2': 'psk2', + 'wpa3': 'sae', 'sae': 'sae', + 'wpa2wpa3': 'sae-mixed', 'sae-mixed': 'sae-mixed' +} + + +def _freq_to_channel(freq): + if not freq: + return None + if freq < 2484: + return int((freq - 2412) / 5 + 1) + if freq == 2484: + return 14 + return int((freq - 5000) / 5) + + +def _wifi_client_state(): + cfg = _uci_wifi_iface('wlan0cli') + state = { + 'enabled': cfg.get('disabled', '1') != '1', + 'connected': False, + 'ssid': cfg.get('ssid') or '', + 'connected_ssid': '', + 'ip': '', + 'signal': None, + 'freq': None, + 'routed': cfg.get('routed') == '1', + 'has_password': bool(cfg.get('key')) + } + rc, out, err = device_run(['iw', 'dev', 'wlan0cli', 'link'], timeout=10) + for line in out.splitlines(): + line = line.strip() + if line.startswith('Connected to'): + state['connected'] = True + elif line.startswith('SSID:'): + state['connected_ssid'] = line.split(':', 1)[1].strip().strip('"') + elif line.startswith('signal:'): + try: + state['signal'] = int(float(line.split(':', 1)[1].split()[0])) + except (ValueError, IndexError): + state['signal'] = None + elif line.startswith('freq:'): + try: + state['freq'] = int(float(line.split(':', 1)[1].split()[0])) + except (ValueError, IndexError): + state['freq'] = None + if not state['connected']: + state['connected_ssid'] = '' + _, addr_out, _ = device_run(['ip', '-4', 'addr', 'show', 'dev', 'wlan0cli'], timeout=10) + for line in addr_out.splitlines(): + m = re.search(r'inet\s+(\d+\.\d+\.\d+\.\d+)', line) + if m: + state['ip'] = m.group(1) + break + return state + + +def h_settings_wifi_client(ctx): + return 200, _wifi_client_state() + + +def _parse_wifi_scan(out): + networks = [] + for block in out.split('BSS '): + block = block.strip() + if not block: + continue + m = re.match(r'([0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5})\(on', block) + bss = m.group(1).upper() if m else '' + freq = None + m = re.search(r'freq:\s*([\d.]+)', block) + if m: + try: + freq = int(float(m.group(1))) + except ValueError: + freq = None + signal = None + m = re.search(r'signal:\s*(-?\d+(?:\.\d+)?)', block) + if m: + try: + signal = int(float(m.group(1))) + except ValueError: + signal = None + ssid = '' + m = re.search(r'SSID:\s*([^\n]*)', block) + if m: + ssid = m.group(1).strip().strip('"') + if (not ssid or all(ord(c) < 32 or c == '\ufffd' for c in ssid) + or re.match(r'^(\\x[0-9A-Fa-f]{2})+$', ssid)): + ssid = '' + if not ssid: + continue + auth = '' + m = re.search(r'Authentication suites:\s*([^\n]+)', block) + if m: + auth = m.group(1).strip() + if 'RSN:' in block and 'WPA:' in block: + encryption = 'WPA/WPA2' + elif 'RSN:' in block: + if 'SAE' in auth and 'PSK' in auth: + encryption = 'WPA2/WPA3' + elif 'SAE' in auth: + encryption = 'WPA3' + else: + encryption = 'WPA2' + elif 'WPA:' in block: + encryption = 'WPA' + else: + encryption = 'Open' + networks.append({'bssid': bss, 'ssid': ssid, 'freq': freq, + 'channel': _freq_to_channel(freq), 'signal': signal, + 'encryption': encryption}) + by_ssid = {} + for net in networks: + key = net['ssid'] or net['bssid'] + current = by_ssid.get(key) + if current is None or (net['signal'] or -200) > (current['signal'] or -200): + by_ssid[key] = net + return sorted(by_ssid.values(), + key=lambda n: n['signal'] if n['signal'] is not None else -200, + reverse=True) + + +def h_settings_wifi_client_scan(ctx): + rc, out, err = device_run(['iw', 'dev', 'wlan0', 'scan'], timeout=25) + if rc != 0: + return 502, {'error': err or out or 'scan failed'} + return 200, {'networks': _parse_wifi_scan(out)} + + +def h_settings_wifi_client_connect(ctx): + body = ctx.body or {} + ssid = (body.get('ssid') or '').strip() + if not ssid: + return 400, {'error': 'SSID is required'} + encryption = (body.get('encryption') or 'wpa2').strip().lower() + if encryption not in WIFI_CLIENT_ENCRYPTIONS: + return 400, {'error': 'unsupported encryption type'} + enc = WIFI_CLIENT_ENCRYPTIONS[encryption] + password = body.get('password') or '' + if enc != 'none' and len(password) < 8: + return 400, {'error': 'password must be at least 8 characters'} + routed = '1' if body.get('routed') else '0' + device_run(['uci', 'set', 'wireless.wlan0cli.ssid=%s' % ssid]) + device_run(['uci', 'set', 'wireless.wlan0cli.encryption=%s' % enc]) + device_run(['uci', 'set', 'wireless.wlan0cli.disabled=0']) + device_run(['uci', 'set', 'wireless.wlan0cli.routed=%s' % routed]) + if enc == 'none': + device_run(['uci', 'delete', 'wireless.wlan0cli.key']) + else: + device_run(['uci', 'set', 'wireless.wlan0cli.key=%s' % password]) + device_run(['uci', 'commit', 'wireless']) + daemon_sock_call('PUT', '/api/settings/wifi/set_client_route', {'routed': body.get('routed') or False}) + rc, out, err = device_run(['wifi', 'reload'], timeout=45) + if rc != 0: + return 502, {'error': err or out or 'wireless reload failed'} + return 200, _wifi_client_state() + + +def h_settings_wifi_client_disconnect(ctx): + device_run(['uci', 'set', 'wireless.wlan0cli.disabled=1']) + device_run(['uci', 'commit', 'wireless']) + rc, out, err = device_run(['wifi', 'reload'], timeout=45) + if rc != 0: + return 502, {'error': err or out or 'wireless reload failed'} + return 200, _wifi_client_state() + + +def h_settings_wifi_client_route(ctx): + routed = bool((ctx.body or {}).get('routed')) + device_run(['uci', 'set', 'wireless.wlan0cli.routed=%d' % (1 if routed else 0)]) + device_run(['uci', 'commit', 'wireless']) + daemon_sock_call('PUT', '/api/settings/wifi/set_client_route', {'routed': routed}) + return 200, _wifi_client_state() + + PAGER_LED_COLORS = ('red', 'green', 'blue', 'yellow', 'cyan', 'magenta', 'white') @@ -2565,6 +2764,11 @@ ROUTER.add('GET', r'/api/settings/usb', h_settings_usb) ROUTER.add('GET', r'/api/settings/network', h_settings_network) ROUTER.add('GET', r'/api/settings/wifi/management', h_settings_management_wifi) ROUTER.add('POST', r'/api/settings/wifi/management', h_settings_management_wifi) +ROUTER.add('GET', r'/api/settings/wifi/client', h_settings_wifi_client) +ROUTER.add('POST', r'/api/settings/wifi/client/scan', h_settings_wifi_client_scan) +ROUTER.add('POST', r'/api/settings/wifi/client/connect', h_settings_wifi_client_connect) +ROUTER.add('POST', r'/api/settings/wifi/client/disconnect', h_settings_wifi_client_disconnect) +ROUTER.add('POST', r'/api/settings/wifi/client/route', h_settings_wifi_client_route) ROUTER.add('GET', r'/api/settings/hardware', h_settings_hardware) ROUTER.add('POST', r'/api/settings/hardware', h_settings_hardware) ROUTER.add('GET', r'/api/settings/advanced', h_settings_advanced) 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 a3c4699..2f7f639 100644 --- a/payload/user/remote_access/pager-webui/www/css/app.css +++ b/payload/user/remote_access/pager-webui/www/css/app.css @@ -474,6 +474,27 @@ html.dark .pineap-infobox.info { background: #10263a; color: #9cc7f0; border-col .settings-diagnostics { max-height: 520px; white-space: pre-wrap; word-break: break-word; } .settings-card > .switch { display: flex; margin: 10px 0; color: var(--text); font-size: 13px; } .settings-card a { color: var(--primary); } + +/* ---- WiFi client mode ---- */ +.client-status { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 4px 20px; } +.client-kv { display: flex; align-items: center; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--border); padding: 6px 0; font-size: 13px; } +.client-kv > span { color: var(--muted); } +.client-actions { display: flex; gap: 8px; flex-wrap: wrap; margin: 12px 0 4px; } +.client-networks { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; max-height: 320px; overflow-y: auto; } +.client-net-row { display: flex; align-items: center; gap: 12px; padding: 9px 10px; border: 1px solid var(--border); border-radius: 3px; } +.client-net-main { flex: 1; min-width: 0; display: flex; flex-direction: column; } +.client-net-ssid { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.client-net-enc { font-size: 11px; color: var(--muted); } +.client-net-signal { color: var(--muted); font-size: 12px; white-space: nowrap; } +.client-connect-form { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; width: 100%; } +.client-connect-form input[type=password] { flex: 1 1 160px; } +.client-routing { display: flex; align-items: center; gap: 8px; margin: 8px 0 0; color: var(--text); font-size: 13px; } +.client-routing.hidden { display: none; } +.client-connect-form .client-routing { margin: 0; } +.client-modal { width: 560px; } +@media (max-width: 700px) { + .client-modal { min-width: 0; width: auto; } +} @media (max-width: 700px) { .tabbar { overflow-x: auto; flex-wrap: nowrap; } .tabbar .tab { flex: 0 0 auto; } diff --git a/payload/user/remote_access/pager-webui/www/index.html b/payload/user/remote_access/pager-webui/www/index.html index 1459f2d..c0eb881 100644 --- a/payload/user/remote_access/pager-webui/www/index.html +++ b/payload/user/remote_access/pager-webui/www/index.html @@ -261,13 +261,13 @@ - + - + diff --git a/payload/user/remote_access/pager-webui/www/js/api.js b/payload/user/remote_access/pager-webui/www/js/api.js index 877a4f5..a7f8df4 100644 --- a/payload/user/remote_access/pager-webui/www/js/api.js +++ b/payload/user/remote_access/pager-webui/www/js/api.js @@ -23,7 +23,9 @@ const PagerAPI = (() => { data = await res.text(); } if (!res.ok) { - const message = data && data.error ? data.error : ('HTTP ' + res.status); + const detail = data && typeof data.detail === 'string' ? data.detail : ''; + const message = (data && data.error ? data.error : ('HTTP ' + res.status)) + + (detail ? ': ' + detail : ''); const error = new Error(message); error.status = res.status; error.data = data; 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 bdf5055..2089b2b 100644 --- a/payload/user/remote_access/pager-webui/www/js/app.js +++ b/payload/user/remote_access/pager-webui/www/js/app.js @@ -83,7 +83,7 @@ const App = (() => { function route() { closeToolbarMenus(); - const hash = location.hash || '#/dashboard'; + const hash = (location.hash || '#/dashboard').replace(/\/+$/, ''); const name = routes[hash]; if (currentView && currentView.destroy) currentView.destroy(); els.content.innerHTML = ''; @@ -233,7 +233,8 @@ const App = (() => { location.hash = '#/settings/advanced'; toast('Update controls are available in Advanced settings'); } else if (action === 'internet') { - checkInternet(true); + if (typeof views.openClientModeModal === 'function') views.openClientModeModal(); + else checkInternet(true); } else if (action === 'logout') { PagerAPI.post('/api/logout') .then(() => showLogin()) @@ -410,8 +411,9 @@ const App = (() => { '#/settings/help': 'settings_help' }; - return { init, route, toast, showLogin, wsUrl: (p) => WS_BASE + p, terminalWs: TERMINAL_WS, - pagerScreenWs: PAGER_SCREEN_WS, pagerKeysWs: PAGER_KEYS_WS, apiBase: API_BASE, + return { init, route, toast, showLogin, checkInternet, wsUrl: (p) => WS_BASE + p, + terminalWs: TERMINAL_WS, pagerScreenWs: PAGER_SCREEN_WS, + pagerKeysWs: PAGER_KEYS_WS, apiBase: API_BASE, keyOf, railItems, go: (h) => { location.hash = h; }, get key() { return keyOf(location.hash); } }; })(); 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 7751e2e..2c4c5be 100644 --- a/payload/user/remote_access/pager-webui/www/js/views.js +++ b/payload/user/remote_access/pager-webui/www/js/views.js @@ -1803,7 +1803,8 @@ function payloadCard(item, actions, tags) { function setPayloadBusy(button, busy, label) { button.disabled = busy; - if (label) button.textContent = busy ? label : button.dataset.label; + button.textContent = busy ? (label || button.dataset.label || button.textContent) + : (button.dataset.label || button.textContent); } views.modules = (root) => { @@ -2042,6 +2043,213 @@ function apiError(message) { return (err) => App.toast((err && err.message && err.message !== 'request failed') ? err.message : message, 'error'); } +const WIFI_CLIENT_ENC_VALUES = { + 'Open': 'open', 'WPA2': 'wpa2', 'WPA3': 'wpa3', 'WPA2/WPA3': 'wpa2wpa3' +}; + +function clientModeEncValue(label) { + return WIFI_CLIENT_ENC_VALUES[label] || (label && label !== 'Open' ? 'wpa2' : 'open'); +} + +function clientModePanel(box, options) { + const state = { status: null, networks: [], connecting: false, generation: 0 }; + const statusEl = h('div', { class: 'client-status' }); + const routingSwitch = h('input', { type: 'checkbox' }); + const routingRow = h('label', { class: 'switch client-routing' }, + routingSwitch, h('span', { class: 'track' }), 'Route LAN clients through this connection'); + const actionsEl = h('div', { class: 'client-actions' }); + const netsEl = h('div', { class: 'client-networks' }); + + routingSwitch.addEventListener('change', () => { + if (!(state.status || {}).enabled) { + routingSwitch.checked = false; + App.toast('Client mode must be enabled before routing can be changed', 'error'); + return; + } + PagerAPI.post('/api/settings/wifi/client/route', { routed: routingSwitch.checked }) + .then(() => { + if (state.status) state.status.routed = routingSwitch.checked; + App.toast('Client routing ' + (routingSwitch.checked ? 'enabled' : 'disabled')); + App.checkInternet(false); + }) + .catch((e) => { + routingSwitch.checked = !routingSwitch.checked; + App.toast(e.message || 'Failed to update routing', 'error'); + }); + }); + + function renderStatus() { + const s = state.status || {}; + statusEl.innerHTML = ''; + const lines = []; + if (s.connected) { + lines.push(['Status', 'Connected'], ['Network', s.connected_ssid || s.ssid || '\u2014'], + ['IP Address', s.ip || '\u2014'], + ['Signal', s.signal != null ? s.signal + ' dBm' : '\u2014']); + } else if (s.enabled) { + lines.push(['Status', 'Enabled \u2014 not associated'], ['Network', s.ssid || '\u2014']); + } else { + lines.push(['Status', 'Disabled']); + } + lines.forEach(([k, v]) => statusEl.appendChild(h('div', { class: 'client-kv' }, + h('span', { text: k }), h('code', { text: v })))); + routingSwitch.checked = !!s.routed; + routingRow.classList.toggle('hidden', !s.enabled); + } + + function refresh() { + PagerAPI.get('/api/settings/wifi/client').then((r) => { + state.status = r.data || {}; + renderStatus(); + }).catch(apiError('Failed to load client mode status')); + } + + function renderNetworks() { + netsEl.innerHTML = ''; + if (!state.networks.length) { + netsEl.appendChild(h('div', { class: 'empty', text: 'No networks shown. Scan to discover nearby WiFi.' })); + return; + } + state.networks.forEach((net) => { + const needsPw = net.encryption !== 'Open'; + const row = h('div', { class: 'client-net-row' }, + h('div', { class: 'client-net-main' }, + h('span', { class: 'client-net-ssid', text: net.ssid || '(hidden)' }), + h('span', { class: 'client-net-enc', text: net.encryption })), + h('span', { class: 'client-net-signal', text: net.signal != null ? net.signal + ' dBm' : '\u2014' }), + btn(needsPw ? 'Connect' : 'Join', () => connectRow(row, net), needsPw ? '' : 'primary')); + netsEl.appendChild(row); + }); + } + + function connectRow(row, net) { + row.innerHTML = ''; + const encSel = h('select', {}, + h('option', { value: 'wpa2', text: 'WPA2' }), + h('option', { value: 'wpa3', text: 'WPA3' }), + h('option', { value: 'wpa2wpa3', text: 'WPA2/WPA3' })); + const value = clientModeEncValue(net.encryption); + encSel.value = value === 'open' ? 'wpa2' : value; + const pw = h('input', { type: 'password', placeholder: 'Password', autocomplete: 'new-password' }); + const routedCb = h('input', { type: 'checkbox' }); + const routedLabel = h('label', { class: 'switch client-routing' }, routedCb, + h('span', { class: 'track' }), 'Route LAN clients'); + const form = h('div', { class: 'client-connect-form' }, encSel, pw, routedLabel, + btn('Connect', () => doConnect({ ssid: net.ssid, encryption: encSel.value, + password: pw.value, routed: routedCb.checked }), 'primary'), + btn('Cancel', () => renderNetworks(), 'ghost')); + row.appendChild(form); + pw.focus(); + } + + function doConnect(opts) { + if (state.connecting) return; + state.connecting = true; + setBusy(true); + PagerAPI.post('/api/settings/wifi/client/connect', opts) + .then(() => { + App.toast('Connecting to ' + opts.ssid + '\u2026'); + state.status = null; + pollConnected(opts.ssid); + }) + .catch((e) => { + state.connecting = false; + setBusy(false); + App.toast(e.message || 'Failed to connect', 'error'); + refresh(); + }); + } + + function pollConnected(targetSsid) { + const gen = ++state.generation; + let attempts = 0; + let ipWait = 0; + const tick = () => { + if (gen !== state.generation) return; + PagerAPI.get('/api/settings/wifi/client').then((r) => { + const s = r.data || {}; + state.status = s; + renderStatus(); + if (s.connected && s.ip) { + finish('Connected to ' + (s.connected_ssid || targetSsid)); + } else if (s.connected && ++ipWait < 6) { + setTimeout(tick, 2000); + } else if (!s.connected && ++attempts >= 22) { + finish('Timed out waiting for ' + targetSsid + ' to connect', 'error'); + } else if (!s.connected) { + setTimeout(tick, 2000); + } else { + finish('Connected to ' + (s.connected_ssid || targetSsid)); + } + }).catch(() => { + state.connecting = false; + setBusy(false); + App.toast('Failed to check connection status', 'error'); + }); + }; + function finish(message, kind) { + state.connecting = false; + setBusy(false); + App.toast(message, kind); + App.checkInternet(false); + } + setTimeout(tick, 2000); + } + + function doDisconnect() { + if (state.connecting) return; + PagerAPI.post('/api/settings/wifi/client/disconnect') + .then(() => { + App.toast('WiFi client disabled'); + state.networks = []; + refresh(); + App.checkInternet(false); + }) + .catch((e) => App.toast(e.message || 'Failed to disconnect', 'error')); + } + + function scan() { + if (state.connecting) return; + netsEl.textContent = 'Scanning\u2026'; + PagerAPI.post('/api/settings/wifi/client/scan').then((r) => { + state.networks = (r.data && r.data.networks) || []; + renderNetworks(); + }).catch((e) => { + netsEl.innerHTML = ''; + netsEl.appendChild(h('div', { class: 'empty', text: 'Scan failed: ' + (e.message || 'unknown error') })); + }); + } + + function setBusy(busy) { + const buttons = actionsEl.querySelectorAll('button'); + buttons.forEach((b) => { b.disabled = busy; }); + } + + actionsEl.appendChild(btn('Scan for Networks', scan)); + actionsEl.appendChild(btn('Disconnect', doDisconnect)); + actionsEl.appendChild(btn('Refresh', refresh, 'ghost')); + if (options && options.close) actionsEl.appendChild(btn('Close', options.close, 'ghost')); + + box.appendChild(statusEl); + box.appendChild(routingRow); + box.appendChild(actionsEl); + box.appendChild(netsEl); + refresh(); + return { refresh }; +} + +views.openClientModeModal = () => { + const overlay = h('div', { class: 'modal-overlay' }); + function close() { overlay.remove(); } + overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); + const modal = h('div', { class: 'modal client-modal' }, + h('div', { class: 'modal-title', text: 'Internet Connection' }), + h('div', { class: 'modal-body' })); + overlay.appendChild(modal); + document.body.appendChild(overlay); + clientModePanel(modal.querySelector('.modal-body'), { close }); +}; + views.settings = (root) => { const box = settingsShell(root, '#/settings'); const user = settingsCard(box, 'User Management & Timezone'); @@ -2160,7 +2368,8 @@ views.settings = (root) => { views.settings_networking = (root) => { const box = settingsShell(root, '#/settings/networking'); - settingsCard(box, 'Wireless Client Mode', 'The Pager firmware owns client-mode association. Status is shown here; connection changes remain in the native Pager interface to protect PineAP radio state.'); + const client = settingsCard(box, 'Wireless Client Mode', 'Connect the Pager to an in-range WiFi network for internet access. The management AP keeps running; the radio channel follows the selected network.'); + clientModePanel(client, {}); const recon = settingsCard(box, 'Recon Wireless Interfaces'); const reconBody = h('div', { class: 'settings-chip-row', text: 'Loading…' }); recon.appendChild(reconBody); diff --git a/tests/test_misc.py b/tests/test_misc.py index 7984589..f571cd8 100644 --- a/tests/test_misc.py +++ b/tests/test_misc.py @@ -41,6 +41,26 @@ class PayloadsProxyTest(unittest.TestCase): server.h_payloads_remove(type('C', (), {'args': (), 'body': {'key': 'nautilus'}})()) self.assertTrue(any(m == 'POST' and '/api/payloads/portal/nautilus/remove' in p for m, p in calls)) + def test_install_surfaces_daemon_error_detail(self): + server.daemon_call = lambda m, p, body=None, token=None, timeout=15: ( + 500, {'error': 'network error: Get "https://downloads.hak5.org/.../download": dial tcp: lookup downloads.hak5.org on [::1]:53: server misbehaving'}) + server.current_token = lambda: 'tok' + status, payload = server.h_payloads_install(type('C', (), { + 'args': (), 'body': {'key': 'recon~client~recon_reporter'}})()) + self.assertEqual(status, 500) + self.assertIn('downloads.hak5.org', payload['detail']) + + def test_install_unwraps_raw_json_daemon_error(self): + server.daemon_call = lambda m, p, body=None, token=None, timeout=15: ( + 500, '{"error":"network error: Get \\"https://downloads.hak5.org/...\\": dial tcp: lookup downloads.hak5.org on [::1]:53: server misbehaving"}\n') + server.current_token = lambda: 'tok' + status, payload = server.h_payloads_install(type('C', (), { + 'args': (), 'body': {'key': 'recon~client~recon_reporter'}})()) + self.assertEqual(status, 500) + self.assertIn('network error', payload['detail']) + self.assertIn('downloads.hak5.org', payload['detail']) + self.assertNotIn('{', payload['detail']) + def test_installed_inventory_flattens_firmware_records(self): old = server._payload_daemon server._payload_daemon = lambda method, path, body=None: (200, [{ @@ -275,5 +295,252 @@ class SettingsTest(unittest.TestCase): self.assertNotIn('password', payload) +class WifiClientModeTest(unittest.TestCase): + @staticmethod + def fake_device_run(calls, responses): + def run(args, timeout=20, input_data=None): + calls.append((list(args), timeout)) + key = (args[0], args[1]) + return responses.get(key, (0, '', '')) + return run + + def test_client_state_parses_disabled(self): + calls = [] + run = self.fake_device_run(calls, { + ('uci', 'show'): (0, + "wireless.wlan0cli=wifi-iface\n" + "wireless.wlan0cli.ssid='OldNet'\n" + "wireless.wlan0cli.disabled='1'\n" + "wireless.wlan0cli.routed='0'\n", ''), + ('iw', 'dev'): (0, '', ''), + ('ip', '-4'): (0, '', ''), + }) + old = server.device_run + server.device_run = run + try: + status, payload = server.h_settings_wifi_client(type('C', (), {})()) + finally: + server.device_run = old + self.assertEqual(status, 200) + self.assertFalse(payload['enabled']) + self.assertFalse(payload['connected']) + self.assertEqual(payload['ssid'], 'OldNet') + self.assertFalse(payload['routed']) + self.assertEqual(payload['ip'], '') + + def test_client_state_parses_connected(self): + calls = [] + run = self.fake_device_run(calls, { + ('uci', 'show'): (0, + "wireless.wlan0cli=wifi-iface\n" + "wireless.wlan0cli.ssid='All RPH Guest WIFI'\n" + "wireless.wlan0cli.disabled='0'\n" + "wireless.wlan0cli.routed='0'\n", ''), + ('iw', 'dev'): (0, + "Connected to 02:18:4a:a7:6a:d9 (on wlan0cli)\n" + "\tSSID: All RPH Guest WIFI\n" + "\tfreq: 2462\n" + "\tsignal: -57 dBm\n", ''), + ('ip', '-4'): (0, + "6: wlan0cli: mtu 1500\n" + " inet 10.10.10.5/24 brd 10.10.10.255 scope global wlan0cli\n", ''), + }) + old = server.device_run + server.device_run = run + try: + status, payload = server.h_settings_wifi_client(type('C', (), {})()) + finally: + server.device_run = old + self.assertEqual(status, 200) + self.assertTrue(payload['enabled']) + self.assertTrue(payload['connected']) + self.assertEqual(payload['connected_ssid'], 'All RPH Guest WIFI') + self.assertEqual(payload['ip'], '10.10.10.5') + self.assertEqual(payload['signal'], -57) + self.assertEqual(payload['freq'], 2462) + + def test_scan_parses_networks_and_deduplicates(self): + sample = ( + "BSS 02:18:4a:a7:6a:d2(on wlan0)\n" + "\tfreq: 2462.0\n" + "\tsignal: -63.00 dBm\n" + "\tSSID: Riverwalk Plaza Staff\n" + "\tRSN:\t * Version: 1\n" + "\t\t * Group cipher: TKIP\n" + "\t\t * Pairwise ciphers: CCMP TKIP\n" + "\t\t * Authentication suites: PSK\n" + "BSS 02:18:4a:a7:6a:d9(on wlan0)\n" + "\tfreq: 2462.0\n" + "\tsignal: -61.00 dBm\n" + "\tSSID: All RPH Guest WIFI\n" + "\tRSN:\t * Version: 1\n" + "\t\t * Group cipher: CCMP\n" + "\t\t * Pairwise ciphers: CCMP\n" + "\t\t * Authentication suites: SAE\n" + "BSS ea:cb:bc:8e:c5:0e(on wlan0)\n" + "\tfreq: 2462.0\n" + "\tsignal: -50.00 dBm\n" + "\tSSID: OpenGuest\n" + "BSS c6:cb:bc:8e:c5:0e(on wlan0)\n" + "\tfreq: 2462.0\n" + "\tsignal: -55.00 dBm\n" + "\tSSID: \\x00\\x00\\x00\\x00\n" + "BSS 42:18:4a:a7:6a:d2(on wlan0)\n" + "\tfreq: 2462.0\n" + "\tsignal: -65.00 dBm\n" + "\tSSID: Riverwalk Plaza Staff\n" + "\tWPA:\t * Version: 1\n" + "\t\t * Group cipher: TKIP\n" + "\t\t * Authentication suites: PSK\n") + networks = server._parse_wifi_scan(sample) + by_ssid = {n['ssid']: n for n in networks} + self.assertIn('Riverwalk Plaza Staff', by_ssid) + self.assertIn('All RPH Guest WIFI', by_ssid) + self.assertIn('OpenGuest', by_ssid) + self.assertEqual(by_ssid['Riverwalk Plaza Staff']['encryption'], 'WPA2') + self.assertEqual(by_ssid['All RPH Guest WIFI']['encryption'], 'WPA3') + self.assertEqual(by_ssid['OpenGuest']['encryption'], 'Open') + self.assertEqual(by_ssid['OpenGuest']['channel'], 11) + # hidden SSID entries are omitted + self.assertNotIn('', by_ssid) + # strongest BSS per SSID wins and results are signal-sorted (strongest first) + self.assertEqual(networks[0]['ssid'], 'OpenGuest') + self.assertEqual(networks[0]['signal'], -50) + self.assertGreater(networks[0]['signal'], networks[1]['signal']) + + def test_scan_reports_device_failure(self): + old = server.device_run + server.device_run = lambda args, timeout=20: (1, '', 'scan not supported') + try: + status, payload = server.h_settings_wifi_client_scan(type('C', (), {})()) + finally: + server.device_run = old + self.assertEqual(status, 502) + self.assertIn('scan', payload['error']) + + def test_connect_requires_ssid(self): + class H: + command = 'POST' + old = server.device_run + server.device_run = lambda args, timeout=20: (0, '', '') + try: + status, payload = server.h_settings_wifi_client_connect( + type('C', (), {'h': H(), 'body': {'encryption': 'open'}})()) + finally: + server.device_run = old + self.assertEqual(status, 400) + self.assertIn('SSID', payload['error']) + + def test_connect_rejects_short_password(self): + class H: + command = 'POST' + calls = [] + run = self.fake_device_run(calls, {}) + old = server.device_run + server.device_run = run + try: + status, payload = server.h_settings_wifi_client_connect( + type('C', (), {'h': H(), 'body': {'ssid': 'X', 'encryption': 'wpa2', 'password': 'short'}})()) + finally: + server.device_run = old + self.assertEqual(status, 400) + self.assertIn('password', payload['error']) + self.assertEqual(calls, []) + + def test_connect_writes_uci_and_reloads(self): + class H: + command = 'POST' + calls = [] + run = self.fake_device_run(calls, {}) + daemon_calls = [] + old_run = server.device_run + old_sock = server.daemon_sock_call + server.device_run = run + server.daemon_sock_call = lambda m, p, body=None, timeout=10: ( + daemon_calls.append((m, p, body)) or (200, {'success': True})) + try: + status, payload = server.h_settings_wifi_client_connect( + type('C', (), {'h': H(), 'body': { + 'ssid': 'All RPH Guest WIFI', 'encryption': 'wpa2wpa3', + 'password': 'Missions1', 'routed': True}})()) + finally: + server.device_run = old_run + server.daemon_sock_call = old_sock + self.assertEqual(status, 200) + sets = [args for args, _t in calls if args[:2] == ['uci', 'set']] + expected = { + 'wireless.wlan0cli.ssid=All RPH Guest WIFI', + 'wireless.wlan0cli.encryption=sae-mixed', + 'wireless.wlan0cli.disabled=0', + 'wireless.wlan0cli.routed=1', + 'wireless.wlan0cli.key=Missions1', + } + got = {args[2] for args in sets} + self.assertTrue(expected <= got) + self.assertIn((['uci', 'commit', 'wireless'], 20), calls) + self.assertIn((['wifi', 'reload'], 45), calls) + self.assertEqual(daemon_calls, + [('PUT', '/api/settings/wifi/set_client_route', {'routed': True})]) + + def test_connect_open_clears_key(self): + class H: + command = 'POST' + calls = [] + run = self.fake_device_run(calls, {}) + old_run = server.device_run + old_sock = server.daemon_sock_call + server.device_run = run + server.daemon_sock_call = lambda m, p, body=None, timeout=10: (200, {'success': True}) + try: + status, payload = server.h_settings_wifi_client_connect( + type('C', (), {'h': H(), 'body': {'ssid': 'OpenGuest', 'encryption': 'open'}})()) + finally: + server.device_run = old_run + server.daemon_sock_call = old_sock + self.assertEqual(status, 200) + self.assertIn((['uci', 'delete', 'wireless.wlan0cli.key'], 20), calls) + self.assertNotIn((['uci', 'set', 'wireless.wlan0cli.key='], 20), calls) + + def test_route_toggle_writes_uci_and_syncs_daemon(self): + class H: + command = 'POST' + calls = [] + run = self.fake_device_run(calls, {}) + daemon_calls = [] + old_run = server.device_run + old_sock = server.daemon_sock_call + server.device_run = run + server.daemon_sock_call = lambda m, p, body=None, timeout=10: ( + daemon_calls.append((m, p, body)) or (200, {'success': True})) + try: + status, payload = server.h_settings_wifi_client_route( + type('C', (), {'h': H(), 'body': {'routed': True}})()) + finally: + server.device_run = old_run + server.daemon_sock_call = old_sock + self.assertEqual(status, 200) + self.assertIn((['uci', 'set', 'wireless.wlan0cli.routed=1'], 20), calls) + self.assertIn((['uci', 'commit', 'wireless'], 20), calls) + self.assertEqual(daemon_calls, + [('PUT', '/api/settings/wifi/set_client_route', {'routed': True})]) + + def test_disconnect_disables_and_reloads(self): + class H: + command = 'POST' + calls = [] + run = self.fake_device_run(calls, {}) + old = server.device_run + server.device_run = run + try: + status, payload = server.h_settings_wifi_client_disconnect( + type('C', (), {'h': H(), 'body': {}})()) + finally: + server.device_run = old + self.assertEqual(status, 200) + self.assertIn((['uci', 'set', 'wireless.wlan0cli.disabled=1'], 20), calls) + self.assertIn((['uci', 'commit', 'wireless'], 20), calls) + self.assertIn((['wifi', 'reload'], 45), calls) + + if __name__ == '__main__': unittest.main()