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 @@
-
+
-
+