release: Mark VIII 1.1

Wireless client mode (connect to WiFi as client):
- settings/wifi/client API: state, scan, connect, disconnect, route
- Internet Connection topbar dialog and functional Settings > Networking card
- routing toggle syncing UCI flag and daemon state
- fix trailing-slash hash routes (View not available)
- hidden-SSID filtering, encryption classification (Open/WPA2/WPA3/mixed)
- tests for client state, scan parsing, connect/disconnect, routing
This commit is contained in:
2026-08-17 22:28:41 -05:00
parent 2bf39ecb9d
commit 5f6dc5bcdb
10 changed files with 719 additions and 13 deletions
+1
View File
@@ -3,4 +3,5 @@ __pycache__/
*.pyc *.pyc
.worktrees/ .worktrees/
.openchamber/
@@ -8,7 +8,7 @@
"title": "Mark VIII", "title": "Mark VIII",
"author": "c4ch3c4d3", "author": "c4ch3c4d3",
"description": "Mark VII-style web management UI for the WiFi Pineapple Pager", "description": "Mark VII-style web management UI for the WiFi Pineapple Pager",
"version": "1.0", "version": "1.1",
"category": "remote_access", "category": "remote_access",
"tags": ["remote-access", "web-interface", "device-management", "pineap"], "tags": ["remote-access", "web-interface", "device-management", "pineap"],
"firmware": "Pineapple Pager 24.10.1" "firmware": "Pineapple Pager 24.10.1"
@@ -2,7 +2,7 @@
# Title: Mark VIII # Title: Mark VIII
# Description: Mark VII-style web management UI for the WiFi Pineapple Pager # Description: Mark VII-style web management UI for the WiFi Pineapple Pager
# Author: c4ch3c4d3 # Author: c4ch3c4d3
# Version: 1.0 # Version: 1.1
# Category: Remote-Access # Category: Remote-Access
# Tags: remote-access, web-interface, device-management, pineap # Tags: remote-access, web-interface, device-management, pineap
# Firmware: Pineapple Pager 24.10.1 # Firmware: Pineapple Pager 24.10.1
@@ -28,7 +28,7 @@ get_pager_ip() {
} }
LOG "cyan" "+---------------------------+" LOG "cyan" "+---------------------------+"
LOG "cyan" "| Mark VIII v1.0 |" LOG "cyan" "| Mark VIII v1.1 |"
LOG "cyan" "+---------------------------+" LOG "cyan" "+---------------------------+"
if ! command -v python3 >/dev/null 2>&1; then if ! command -v python3 >/dev/null 2>&1; then
@@ -1963,11 +1963,32 @@ def _proxy_json(method, path, body=None):
return 200, (data if isinstance(data, dict) else {'ok': True}) 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): def _payload_daemon(method, path, body=None):
status, data = daemon_call(method, path, body=body, token=current_token(), timeout=45) status, data = daemon_call(method, path, body=body, token=current_token(), timeout=45)
if status != 200: if status != 200:
return (502 if status == 0 else status), { 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)): if not isinstance(data, (dict, list)):
return 502, {'error': 'Pager payload service returned an invalid response'} return 502, {'error': 'Pager payload service returned an invalid response'}
return 200, data 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') 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/network', h_settings_network)
ROUTER.add('GET', r'/api/settings/wifi/management', h_settings_management_wifi) 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('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('GET', r'/api/settings/hardware', h_settings_hardware)
ROUTER.add('POST', 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) ROUTER.add('GET', r'/api/settings/advanced', h_settings_advanced)
@@ -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-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 > .switch { display: flex; margin: 10px 0; color: var(--text); font-size: 13px; }
.settings-card a { color: var(--primary); } .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) { @media (max-width: 700px) {
.tabbar { overflow-x: auto; flex-wrap: nowrap; } .tabbar { overflow-x: auto; flex-wrap: nowrap; }
.tabbar .tab { flex: 0 0 auto; } .tabbar .tab { flex: 0 0 auto; }
@@ -261,13 +261,13 @@
<script src="js/config.js"></script> <script src="js/config.js"></script>
<script src="js/icons.js?v=20260811-6"></script> <script src="js/icons.js?v=20260811-6"></script>
<script src="js/api.js?v=20260811-3"></script> <script src="js/api.js?v=20260817-4"></script>
<script src="js/chart.js"></script> <script src="js/chart.js"></script>
<script src="js/xterm.min.js"></script> <script src="js/xterm.min.js"></script>
<script src="js/xterm-addon-fit.min.js"></script> <script src="js/xterm-addon-fit.min.js"></script>
<script src="js/terminal.js"></script> <script src="js/terminal.js"></script>
<script src="js/pager.js"></script> <script src="js/pager.js"></script>
<script src="js/views.js?v=20260811-11"></script> <script src="js/views.js?v=20260817-12"></script>
<script src="js/app.js?v=20260811-9"></script> <script src="js/app.js?v=20260811-9"></script>
</body> </body>
</html> </html>
@@ -23,7 +23,9 @@ const PagerAPI = (() => {
data = await res.text(); data = await res.text();
} }
if (!res.ok) { 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); const error = new Error(message);
error.status = res.status; error.status = res.status;
error.data = data; error.data = data;
@@ -83,7 +83,7 @@ const App = (() => {
function route() { function route() {
closeToolbarMenus(); closeToolbarMenus();
const hash = location.hash || '#/dashboard'; const hash = (location.hash || '#/dashboard').replace(/\/+$/, '');
const name = routes[hash]; const name = routes[hash];
if (currentView && currentView.destroy) currentView.destroy(); if (currentView && currentView.destroy) currentView.destroy();
els.content.innerHTML = ''; els.content.innerHTML = '';
@@ -233,7 +233,8 @@ const App = (() => {
location.hash = '#/settings/advanced'; location.hash = '#/settings/advanced';
toast('Update controls are available in Advanced settings'); toast('Update controls are available in Advanced settings');
} else if (action === 'internet') { } else if (action === 'internet') {
checkInternet(true); if (typeof views.openClientModeModal === 'function') views.openClientModeModal();
else checkInternet(true);
} else if (action === 'logout') { } else if (action === 'logout') {
PagerAPI.post('/api/logout') PagerAPI.post('/api/logout')
.then(() => showLogin()) .then(() => showLogin())
@@ -410,8 +411,9 @@ const App = (() => {
'#/settings/help': 'settings_help' '#/settings/help': 'settings_help'
}; };
return { init, route, toast, showLogin, wsUrl: (p) => WS_BASE + p, terminalWs: TERMINAL_WS, return { init, route, toast, showLogin, checkInternet, wsUrl: (p) => WS_BASE + p,
pagerScreenWs: PAGER_SCREEN_WS, pagerKeysWs: PAGER_KEYS_WS, apiBase: API_BASE, terminalWs: TERMINAL_WS, pagerScreenWs: PAGER_SCREEN_WS,
pagerKeysWs: PAGER_KEYS_WS, apiBase: API_BASE,
keyOf, railItems, go: (h) => { location.hash = h; }, keyOf, railItems, go: (h) => { location.hash = h; },
get key() { return keyOf(location.hash); } }; get key() { return keyOf(location.hash); } };
})(); })();
@@ -1803,7 +1803,8 @@ function payloadCard(item, actions, tags) {
function setPayloadBusy(button, busy, label) { function setPayloadBusy(button, busy, label) {
button.disabled = busy; 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) => { 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'); 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) => { views.settings = (root) => {
const box = settingsShell(root, '#/settings'); const box = settingsShell(root, '#/settings');
const user = settingsCard(box, 'User Management & Timezone'); const user = settingsCard(box, 'User Management & Timezone');
@@ -2160,7 +2368,8 @@ views.settings = (root) => {
views.settings_networking = (root) => { views.settings_networking = (root) => {
const box = settingsShell(root, '#/settings/networking'); 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 recon = settingsCard(box, 'Recon Wireless Interfaces');
const reconBody = h('div', { class: 'settings-chip-row', text: 'Loading…' }); const reconBody = h('div', { class: 'settings-chip-row', text: 'Loading…' });
recon.appendChild(reconBody); recon.appendChild(reconBody);
+267
View File
@@ -41,6 +41,26 @@ class PayloadsProxyTest(unittest.TestCase):
server.h_payloads_remove(type('C', (), {'args': (), 'body': {'key': 'nautilus'}})()) 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)) 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): def test_installed_inventory_flattens_firmware_records(self):
old = server._payload_daemon old = server._payload_daemon
server._payload_daemon = lambda method, path, body=None: (200, [{ server._payload_daemon = lambda method, path, body=None: (200, [{
@@ -275,5 +295,252 @@ class SettingsTest(unittest.TestCase):
self.assertNotIn('password', payload) 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: <BROADCAST,MULTICAST,UP,LOWER_UP> 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__': if __name__ == '__main__':
unittest.main() unittest.main()