2 Commits
Author SHA1 Message Date
bzuccaro 5f6dc5bcdb 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
2026-08-17 22:28:41 -05:00
bzuccaroandfactory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> 2bf39ecb9d fix: repair recon scanning and add macOS deployment
Start scans via the Pager's native /api/pineap/recon/new so history
appends instead of rotating. Enforce finite durations (1-86400s,
default 30s), remove the unsupported Continuous mode, and refuse the
unsafe manual stop that left recon.db locked.

Rework scan list and detail reads into single-pass aggregate SQL,
shorten lock retries, and serve cached results during short exclusive
lock windows. Fall back to immutable read-only access when the
firmware leaves a stale lock after native completion.

Frontend auto-follows new scans, queues a single in-flight detail
refresh, keeps previous tables visible while a scan starts, and shows
completion toasts and daemon error details.

Add scripts/deploy.sh for macOS/Linux (zip packaging, scp/ssh install,
atomic payload replacement, service restart, portal refresh) with
README instructions, plus regression coverage for native start,
safe stop semantics, aggregate queries, and stale-lock fallback.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
2026-08-17 17:46:54 -05:00
13 changed files with 1150 additions and 99 deletions
+1
View File
@@ -3,4 +3,5 @@ __pycache__/
*.pyc
.worktrees/
.openchamber/
+17 -3
View File
@@ -12,18 +12,32 @@ terminal.
- WiFi Pineapple Pager, firmware `Pineapple Pager 24.10.1`
- `python3` on the device (present on current firmware)
- Windows dev box with Python 3.11 (`winget install Python.Python.3.11`)
- Python 3.11 on the development machine
## Install (sideload)
macOS/Linux:
```bash
# Recommended: key authentication
./scripts/deploy.sh --ssh-key "$HOME/.ssh/pager_key"
# Password authentication requires sshpass
brew install hudochenkov/sshpass/sshpass
./scripts/deploy.sh --password '<device-password>'
```
Windows:
```powershell
# deploy.ps1 needs either an SSH key or sshpass for password auth:
& .\scripts\deploy.ps1 -SshKey "$HOME\.ssh\pager_key"
# or set up a key and add it: ssh-copy-id root@172.16.52.1
```
This builds `build\pager-webui\payload-<b64>.zip`, uploads it, extracts it to
`/root/payloads/user/remote_access/pager-webui/`, and refreshes the portal index.
The deployment scripts build `build/pager-webui/payload-<b64>.zip`, upload it,
extract it to `/root/payloads/user/remote_access/pager-webui/`, and refresh the
portal index.
Then on the Pager menu, run **Mark VIII**:
- **Yes** to "Run as background service?" -> procd service (respawns on crash,
@@ -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"
@@ -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
+331 -52
View File
@@ -40,6 +40,10 @@ HOST = os.environ.get('PAGER_HOST', '0.0.0.0')
PORT = int(os.environ.get('PAGER_PORT', '8080'))
_recon_scan_state = {'active': False, 'started': 0, 'duration': 0}
DEFAULT_RECON_DURATION = 30
_recon_scans_cache = {'db': None, 'updated': 0, 'data': {'scans': []}}
_recon_status_cache = {
'db': None, 'updated': 0, 'last_scan': None, 'last_activity': None}
_payload_runs = {}
_payload_runs_lock = threading.Lock()
PAYLOAD_RUN_DIR = os.environ.get('PAGER_PAYLOAD_RUN_DIR', '/tmp/pagerwebui-payload-runs')
@@ -864,21 +868,36 @@ def h_deauth_client(ctx):
SQLITE_BUSY_MSGS = ('database is locked', 'database is busy')
def _db_rows(db, sql, _retries=5):
def _db_rows(db, sql, _retries=1):
if sqlite3 is not None:
conn = sqlite3.connect('file:%s?mode=ro' % db, uri=True)
try:
cur = conn.execute(sql)
cols = [d[0] for d in cur.description]
return [dict(zip(cols, row)) for row in cur.fetchall()]
finally:
conn.close()
rc, out, err = device_run([SQLITE_CLI, '-json', '-cmd', '.timeout 5000', db, sql])
conn = sqlite3.connect('file:%s?mode=ro' % db, uri=True)
try:
cur = conn.execute(sql)
cols = [d[0] for d in cur.description]
return [dict(zip(cols, row)) for row in cur.fetchall()]
finally:
conn.close()
except sqlite3.Error as exc:
raise RuntimeError('sqlite read failed: %s' % exc)
rc, out, err = device_run([SQLITE_CLI, '-json', '-cmd', '.timeout 500', db, sql])
attempt = 1
while rc != 0 and any(m in (err or '') for m in SQLITE_BUSY_MSGS) and attempt < _retries:
time.sleep(0.3)
rc, out, err = device_run([SQLITE_CLI, '-json', '-cmd', '.timeout 5000', db, sql])
rc, out, err = device_run([SQLITE_CLI, '-json', '-cmd', '.timeout 500', db, sql])
attempt += 1
st = _recon_scan_state
elapsed = time.time() - st['started'] if st['started'] else 0
completed_here = st['duration'] > 0 and elapsed >= st['duration'] + 2
if (rc != 0 and any(m in (err or '') for m in SQLITE_BUSY_MSGS)
and db == RECON_DB and (not st['active'] or completed_here)):
# Pager firmware leaves a stale exclusive lock after native completion.
# The file is stable by this point, so bypass that stale lock read-only.
immutable_db = 'file:%s?immutable=1' % db
rc, out, err = device_run(
[SQLITE_CLI, '-json', immutable_db, sql])
if rc != 0:
raise RuntimeError('sqlite read failed: %s' % (err or out).strip())
if out.strip():
return json.loads(out)
return []
@@ -944,25 +963,50 @@ def decode_encryption(v):
def recon_scans_data(limit=50):
rows = _db_rows(RECON_DB,
'WITH recent AS (SELECT id, time, name FROM scan ORDER BY id DESC LIMIT %d), '
'devices AS (SELECT scan, count(*) AS devices FROM wifi_device '
'WHERE scan IN (SELECT id FROM recent) GROUP BY scan), '
'aps AS (SELECT scan, count(*) AS aps FROM ssid '
'WHERE type = 8 AND scan IN (SELECT id FROM recent) GROUP BY scan), '
'captures AS (SELECT scan, count(*) AS handshakes FROM handshake '
'WHERE scan IN (SELECT id FROM recent) GROUP BY scan) '
'SELECT s.id, s.time, s.name, '
'(SELECT count(*) FROM wifi_device w WHERE w.scan = s.id) AS devices, '
'(SELECT count(*) FROM ssid a WHERE a.scan = s.id AND a.type = 8) AS aps, '
'(SELECT count(*) FROM handshake h WHERE h.scan = s.id) AS handshakes '
'FROM scan s ORDER BY s.id DESC LIMIT %d' % limit)
'COALESCE(w.devices, 0) AS devices, '
'COALESCE(a.aps, 0) AS aps, '
'COALESCE(h.handshakes, 0) AS handshakes '
'FROM recent s '
'LEFT JOIN devices w ON w.scan = s.id '
'LEFT JOIN aps a ON a.scan = s.id '
'LEFT JOIN captures h ON h.scan = s.id '
'ORDER BY s.id DESC' % limit)
return {'scans': [{'id': r['id'], 'time': r['time'], 'name': r.get('name'),
'devices': r['devices'], 'aps': r['aps'],
'handshakes': r['handshakes']} for r in rows]}
def recon_scan_data(scan_id):
scans = _db_rows(RECON_DB, 'SELECT id, time, name FROM scan WHERE id = %d' % scan_id)
rows = _db_rows(RECON_DB,
"SELECT 'scan' AS kind, id AS row_id, time, name, "
"NULL AS mac, NULL AS bssid, NULL AS ssid, NULL AS hidden, "
"NULL AS channel, NULL AS encryption, NULL AS signal, NULL AS freq, "
"NULL AS packets, NULL AS stahash, NULL AS aphash "
"FROM scan WHERE id = %d "
"UNION ALL SELECT 'ap', hash, time, NULL, NULL, bssid, ssid, hidden, "
"channel, encryption, signal, freq, NULL, NULL, NULL "
"FROM ssid WHERE scan = %d AND type = 8 AND bssid IS NOT NULL "
"UNION ALL SELECT 'device', hash, time, NULL, mac, NULL, NULL, NULL, "
"NULL, NULL, signal, freq, packets, NULL, NULL "
"FROM wifi_device WHERE scan = %d "
"UNION ALL SELECT 'handshake', hash, time, NULL, NULL, NULL, NULL, NULL, "
"NULL, NULL, NULL, NULL, NULL, stahash, aphash "
"FROM handshake WHERE scan = %d" % (scan_id, scan_id, scan_id, scan_id))
scans = [r for r in rows if r.get('kind') == 'scan']
if not scans:
return None
aps = []
for r in _db_rows(RECON_DB,
'SELECT bssid, ssid, hidden, channel, encryption, signal, freq '
'FROM ssid WHERE scan = %d AND type = 8 AND bssid IS NOT NULL '
'ORDER BY signal ASC' % scan_id):
ap_macs = set()
for r in (row for row in rows if row.get('kind') == 'ap'):
ap_macs.add((r.get('bssid') or '').strip().upper())
aps.append({'bssid': fmt_mac(r.get('bssid')),
'ssid': decode_ssid(r.get('ssid')),
'hidden': bool(r.get('hidden')),
@@ -970,51 +1014,55 @@ def recon_scan_data(scan_id):
'signal': r.get('signal'),
'freq': r.get('freq'),
'encryption': decode_encryption(r.get('encryption'))})
ap_macs = set()
for r in _db_rows(RECON_DB,
'SELECT DISTINCT bssid FROM ssid WHERE scan = %d AND type = 8 AND bssid IS NOT NULL' % scan_id):
ap_macs.add((r.get('bssid') or '').strip().upper())
aps.sort(key=lambda row: row['signal'] if row['signal'] is not None else 0)
devices = [r for r in rows if r.get('kind') == 'device']
clients = []
for r in _db_rows(RECON_DB,
'SELECT mac, signal, freq, packets FROM wifi_device WHERE scan = %d ORDER BY time ASC' % scan_id):
for r in sorted(devices, key=lambda row: row.get('time') or 0):
if (r.get('mac') or '').strip().upper() in ap_macs:
continue
clients.append({'mac': fmt_mac(r.get('mac')), 'signal': r.get('signal'),
'freq': r.get('freq'), 'packets': r.get('packets')})
mac_of = {}
for r in _db_rows(RECON_DB,
'SELECT hash, mac FROM wifi_device WHERE scan = %d' % scan_id):
mac_of[r['hash']] = fmt_mac(r.get('mac'))
mac_of = {r['row_id']: fmt_mac(r.get('mac')) for r in devices}
handshakes = []
for r in _db_rows(RECON_DB,
'SELECT stahash, aphash, time FROM handshake WHERE scan = %d' % scan_id):
for r in (row for row in rows if row.get('kind') == 'handshake'):
handshakes.append({'ap': mac_of.get(r.get('aphash'), '--'),
'client': mac_of.get(r.get('stahash'), '--'),
'time': r.get('time')})
return {'scan': {'id': scans[0]['id'], 'time': scans[0]['time'],
return {'scan': {'id': scans[0]['row_id'], 'time': scans[0]['time'],
'name': scans[0].get('name')},
'aps': aps, 'clients': clients, 'handshakes': handshakes}
def h_recon_start(ctx):
body = {}
scan_time = (getattr(ctx, 'body', None) or {}).get('scan_time')
if scan_time is not None:
body['scan_time'] = int(scan_time)
status, data = daemon_sock_call('POST', '/api/pineap/log/recon/start', body=body)
scan_time = (getattr(ctx, 'body', None) or {}).get(
'scan_time', DEFAULT_RECON_DURATION)
try:
scan_time = int(scan_time)
except (TypeError, ValueError):
return 400, {'error': 'scan_time must be an integer'}
if scan_time < 1 or scan_time > 86400:
return 400, {'error': 'scan_time is out of range'}
body = {'scan_time': scan_time}
# log/recon/start restarts the recon logger and can rotate the existing
# database. recon/new is the Pager's native "start another scan" action and
# appends a scan without discarding history.
status, data = daemon_sock_call('POST', '/api/pineap/recon/new', body=body)
if status != 200 or not (data or {}).get('success'):
return 502, {'error': 'daemon recon start failed'}
return 502, {'error': 'native recon scan failed', 'detail': data}
_recon_scan_state['active'] = True
_recon_scan_state['started'] = time.time()
_recon_scan_state['duration'] = int(scan_time) if scan_time is not None else 0
_recon_scan_state['duration'] = scan_time
return 200, {'ok': True}
def h_recon_stop(ctx):
status, data = daemon_sock_call('POST', '/api/pineap/log/recon/stop', body={})
if status != 200 or not (data or {}).get('success'):
return 502, {'error': 'daemon recon stop failed'}
_recon_scan_state['active'] = False
scanning, remaining = _recon_scan_snapshot()
if scanning:
return 409, {
'error': 'Pager firmware cannot stop a recon scan safely; '
'this scan will finish automatically',
'scan_remaining': remaining,
}
return 200, {'ok': True}
@@ -1030,23 +1078,40 @@ def _recon_scan_snapshot():
def _recon_watchdog_tick():
"""The daemon ignores scan_time and scans until stopped, so the webui enforces
the requested duration by issuing a stop when the timed scan expires."""
"""Clear the UI timer when the native timed scan reaches its duration.
The Pager has no recon-stop operation. log/recon/stop controls the storage
service and leaves recon.db locked, so timed scans must end natively.
"""
st = _recon_scan_state
if st['active'] and st['duration'] > 0 and time.time() - st['started'] >= st['duration']:
daemon_sock_call('POST', '/api/pineap/log/recon/stop', body={})
st['active'] = False
def h_recon_status(ctx):
rows = _db_rows(RECON_DB, 'SELECT MAX(time) AS t FROM scan')
last = rows[0]['t'] if rows and rows[0].get('t') is not None else None
act = _db_rows(RECON_DB, 'SELECT MAX(time) AS t FROM wifi_device')
last_activity = act[0]['t'] if act and act[0].get('t') is not None else last
scanning, remaining = _recon_scan_snapshot()
cache = _recon_status_cache
if cache['db'] != RECON_DB:
cache.update({'db': RECON_DB, 'updated': 0,
'last_scan': None, 'last_activity': None})
stale = False
try:
rows = _db_rows(RECON_DB,
'SELECT (SELECT MAX(time) FROM scan) AS last_scan, '
'(SELECT MAX(time) FROM wifi_device) AS last_activity')
if rows:
cache['last_scan'] = rows[0].get('last_scan')
cache['last_activity'] = rows[0].get('last_activity')
cache['updated'] = time.time()
except RuntimeError:
stale = True
last = cache['last_scan']
last_activity = cache['last_activity']
if last_activity is None:
last_activity = last
return 200, {'last_scan': last, 'last_activity': last_activity,
'active': last_activity is not None and int(time.time()) - last_activity < 300,
'scanning': scanning, 'scan_remaining': remaining}
'scanning': scanning, 'scan_remaining': remaining, 'stale': stale}
def _db_write(db, sql):
@@ -1129,7 +1194,17 @@ def h_recon_examine(ctx):
def h_recon_scans(ctx):
return 200, recon_scans_data()
cache = _recon_scans_cache
if cache['db'] != RECON_DB:
cache.update({'db': RECON_DB, 'updated': 0, 'data': {'scans': []}})
try:
cache['data'] = recon_scans_data()
cache['updated'] = time.time()
except RuntimeError:
if not cache['updated'] or time.time() - cache['updated'] > 120:
return 503, {'error': 'recon database is temporarily unavailable'}
return 200, dict(cache['data'], stale=True)
return 200, dict(cache['data'], stale=False)
def h_recon_scan_detail(ctx):
@@ -1888,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
@@ -2338,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')
@@ -2490,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)
@@ -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; }
@@ -261,13 +261,13 @@
<script src="js/config.js"></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/xterm.min.js"></script>
<script src="js/xterm-addon-fit.min.js"></script>
<script src="js/terminal.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>
</body>
</html>
@@ -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;
@@ -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); } };
})();
@@ -1047,7 +1047,9 @@ views.recon = (root) => {
const state = { scans: [], selected: null, detail: null,
apPage: 0, apSearch: '', clientPage: 0, clientSearch: '',
apPer: reconPer('ap', 10), clientPer: reconPer('client', 10),
apSort: null, clientSort: null, focusAp: null, autoFollow: false, scanActive: false };
apSort: null, clientSort: null, focusAp: null, autoFollow: false,
scanActive: false, detailLoading: false, detailLoadingId: null,
detailQueued: false, detailId: null };
const cols = reconLoadCols();
// ---- title cards ----
@@ -1133,7 +1135,7 @@ views.recon = (root) => {
const scanLabel = h('label', { class: 'switch recon-scan-toggle' }, scanToggle, h('span', { class: 'track' }), ' Scan');
scanBar.appendChild(scanLabel);
const durSel = h('select', { class: 'sel', id: 'recon-duration' });
[[30, '30 Seconds'], [60, '1 Minute'], [120, '2 Minutes'], [300, '5 Minutes'], [600, '10 Minutes'], [0, 'Continuous']]
[[30, '30 Seconds'], [60, '1 Minute'], [120, '2 Minutes'], [300, '5 Minutes'], [600, '10 Minutes']]
.forEach(([v, t]) => durSel.appendChild(h('option', { value: String(v), text: t })));
durSel.value = localStorage.getItem('pw_scan_duration') || '30';
durSel.addEventListener('change', () => localStorage.setItem('pw_scan_duration', durSel.value));
@@ -1150,18 +1152,22 @@ views.recon = (root) => {
.then(() => {
if (on) {
state.scanActive = true;
state.selected = null;
state.autoFollow = true;
state.apPage = 0;
state.clientPage = 0;
App.toast('Scan started');
} else {
state.scanActive = false;
state.autoFollow = false;
App.toast('Scan stopped');
}
restartPoll();
load();
})
.catch(() => { scanToggle.checked = !on; App.toast('Recon control failed', 'error'); })
.catch((err) => {
scanToggle.checked = !on;
App.toast((err && err.message) || 'Recon control failed', 'error');
})
.finally(() => { pendingScan = false; scanToggle.disabled = false; });
});
@@ -1452,19 +1458,39 @@ views.recon = (root) => {
function loadDetail() {
if (state.selected == null) return;
PagerAPI.get('/api/recon/scans/' + state.selected).then((r) => {
const scanId = state.selected;
if (state.detailLoading) {
if (state.detailLoadingId !== scanId) state.detailQueued = true;
return;
}
if (!state.scanActive && state.detailId === scanId) return;
state.detailLoading = true;
state.detailLoadingId = scanId;
PagerAPI.get('/api/recon/scans/' + scanId).then((r) => {
if (state.selected !== scanId) return;
state.detail = r.data;
state.detailId = scanId;
drawCharts(r.data);
renderTables();
hsCount.textContent = (r.data.handshakes || []).length;
}).catch(() => {});
}).catch(() => {}).finally(() => {
state.detailLoading = false;
state.detailLoadingId = null;
if (state.detailQueued) {
state.detailQueued = false;
loadDetail();
}
});
}
function load() {
PagerAPI.get('/api/recon/scans').then((r) => {
state.scans = r.data.scans || [];
const keep = state.selected && state.scans.some((s) => s.id === state.selected)
? state.selected : (state.scans[0] ? state.scans[0].id : null);
const newest = state.scans[0] ? state.scans[0].id : null;
const keep = state.autoFollow
? newest
: (state.selected && state.scans.some((s) => s.id === state.selected)
? state.selected : newest);
sel.innerHTML = '';
state.scans.forEach((s) => {
const opt = document.createElement('option');
@@ -1482,17 +1508,23 @@ views.recon = (root) => {
state.selected = keep;
if (keep != null) loadDetail();
}).catch(() => {});
PagerAPI.get('/api/pineap/get_config').then((r) => {
hsAuto.querySelector('input').checked = !!((r.data || {}).loghandshake);
}).catch(() => {});
PagerAPI.get('/api/recon/status').then((r) => {
const scanning = !!r.data.scanning;
const wasScanning = state.scanActive;
const completed = wasScanning && !scanning;
state.scanActive = scanning;
if (!pendingScan) scanToggle.checked = scanning;
restartPoll();
if (wasScanning !== scanning) restartPoll();
if (completed) {
state.autoFollow = false;
App.toast('Scan complete');
}
}).catch(() => {});
}
PagerAPI.get('/api/pineap/get_config').then((r) => {
hsAuto.querySelector('input').checked = !!((r.data || {}).loghandshake);
}).catch(() => {});
load();
let pollIv = null;
const restartPoll = () => {
@@ -1771,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) => {
@@ -2010,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');
@@ -2128,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);
+186
View File
@@ -0,0 +1,186 @@
#!/usr/bin/env bash
set -euo pipefail
PAGER_HOST="172.16.52.1"
PAGER_USER="root"
PASSWORD=""
SSH_KEY=""
BUILD_DIR=""
PORTAL_REFRESH=true
usage() {
cat <<'EOF'
Usage: scripts/deploy.sh [options]
Options:
--host HOST Pager address (default: 172.16.52.1)
--user USER SSH user (default: root)
--password PASSWORD SSH/device password (requires sshpass)
--ssh-key PATH SSH private key
--build-dir PATH Build output directory (default: <repo>/build)
--no-portal-refresh Skip the best-effort portal refresh
-h, --help Show this help
If neither --password nor --ssh-key is supplied, ssh/scp prompt normally.
EOF
}
while (($#)); do
case "$1" in
--host) PAGER_HOST="${2:?missing value for --host}"; shift 2 ;;
--user) PAGER_USER="${2:?missing value for --user}"; shift 2 ;;
--password) PASSWORD="${2:?missing value for --password}"; shift 2 ;;
--ssh-key) SSH_KEY="${2:?missing value for --ssh-key}"; shift 2 ;;
--build-dir) BUILD_DIR="${2:?missing value for --build-dir}"; shift 2 ;;
--no-portal-refresh) PORTAL_REFRESH=false; shift ;;
-h|--help) usage; exit 0 ;;
*) printf 'Unknown option: %s\n' "$1" >&2; usage >&2; exit 2 ;;
esac
done
for command in python3 zip scp ssh; do
command -v "$command" >/dev/null || {
printf 'Required command not found: %s\n' "$command" >&2
exit 1
}
done
if [[ -n "$PASSWORD" ]] && ! command -v sshpass >/dev/null; then
printf 'Password deployment requires sshpass (brew install hudochenkov/sshpass/sshpass).\n' >&2
exit 1
fi
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PAYLOAD_KEY="pager-webui"
PAYLOAD_CATEGORY="remote_access"
PAYLOAD_DIR="$ROOT/payload/user/$PAYLOAD_CATEGORY/$PAYLOAD_KEY"
BUILD_DIR="${BUILD_DIR:-$ROOT/build}"
OUT_DIR="$BUILD_DIR/$PAYLOAD_KEY"
STAGE="$OUT_DIR/stage"
[[ -d "$PAYLOAD_DIR" ]] || {
printf 'Payload directory not found: %s\n' "$PAYLOAD_DIR" >&2
exit 1
}
mkdir -p "$OUT_DIR"
rm -rf "$STAGE"
mkdir -p "$STAGE/user/$PAYLOAD_CATEGORY"
cp -R "$PAYLOAD_DIR" "$STAGE/user/$PAYLOAD_CATEGORY/$PAYLOAD_KEY"
find "$STAGE" \( -type d -name __pycache__ -o -type f -name '*.pyc' \) -prune -exec rm -rf {} +
B64_KEY="$(python3 -c 'import base64; print(base64.urlsafe_b64encode(b"pager-webui").decode().rstrip("="))')"
ZIP_NAME="payload-$B64_KEY.zip"
ZIP_PATH="$OUT_DIR/$ZIP_NAME"
MANIFEST_PATH="$OUT_DIR/_hak5_manifest.json"
rm -f "$ZIP_PATH"
(
cd "$STAGE"
zip -q -r "$ZIP_PATH" user
)
HASH="$(python3 -c 'import hashlib, sys; print(hashlib.sha256(open(sys.argv[1], "rb").read()).hexdigest())' "$ZIP_PATH")"
python3 - "$PAYLOAD_DIR/_hak5_manifest.json" "$MANIFEST_PATH" "$HASH" "$ZIP_NAME" <<'PY'
import json
import sys
import time
source, destination, digest, zip_name = sys.argv[1:]
with open(source, encoding='utf-8') as handle:
manifest = json.load(handle)
manifest['time'] = int(time.time())
manifest['last_hash'] = digest
manifest['zip'] = zip_name
with open(destination, 'w', encoding='ascii') as handle:
json.dump(manifest, handle, indent=2)
handle.write('\n')
PY
printf 'Built: %s\n' "$ZIP_PATH"
TARGET="$PAGER_USER@$PAGER_HOST"
run_scp() {
if [[ -n "$PASSWORD" && -n "$SSH_KEY" ]]; then
SSHPASS="$PASSWORD" sshpass -e scp -i "$SSH_KEY" "$@"
elif [[ -n "$PASSWORD" ]]; then
SSHPASS="$PASSWORD" sshpass -e scp "$@"
elif [[ -n "$SSH_KEY" ]]; then
scp -i "$SSH_KEY" "$@"
else
scp "$@"
fi
}
run_ssh() {
if [[ -n "$PASSWORD" && -n "$SSH_KEY" ]]; then
SSHPASS="$PASSWORD" sshpass -e ssh -i "$SSH_KEY" "$@"
elif [[ -n "$PASSWORD" ]]; then
SSHPASS="$PASSWORD" sshpass -e ssh "$@"
elif [[ -n "$SSH_KEY" ]]; then
ssh -i "$SSH_KEY" "$@"
else
ssh "$@"
fi
}
run_scp "$ZIP_PATH" "$MANIFEST_PATH" "$TARGET:/tmp/"
REMOTE_PAYLOAD_DIR="user/$PAYLOAD_CATEGORY/$PAYLOAD_KEY"
LEGACY_PAYLOAD_DIR="user/general/$PAYLOAD_KEY"
REMOTE_COMMAND="set -e
cd /root/payloads
stage='.pager-webui.deploy.\$\$'
backup='.pager-webui.backup.\$\$'
trap 'rm -rf \"\$stage\" \"\$backup\"' EXIT
mkdir -p \"\$stage\"
cd \"\$stage\"
unzip -q '/tmp/$ZIP_NAME'
new=\"\$PWD/$REMOTE_PAYLOAD_DIR\"
[ -f \"\$new/server.py\" ] && [ -f \"\$new/payload.sh\" ] && [ -d \"\$new/www\" ]
cp /tmp/_hak5_manifest.json \"\$new/_hak5_manifest.json\"
chmod +x \"\$new/payload.sh\" \"\$new/pagerwebui.init\"
chmod -R 755 \"\$new/www\"
cd /root/payloads
if [ -d '$REMOTE_PAYLOAD_DIR' ]; then
mkdir -p \"\$(dirname \"\$backup\")\"
mv '$REMOTE_PAYLOAD_DIR' \"\$backup\"
fi
if mv \"\$new\" '$REMOTE_PAYLOAD_DIR'; then
rm -rf \"\$backup\" '$LEGACY_PAYLOAD_DIR'
else
[ ! -d \"\$backup\" ] || mv \"\$backup\" '$REMOTE_PAYLOAD_DIR'
exit 1
fi
rm -f '/tmp/$ZIP_NAME' /tmp/_hak5_manifest.json
if [ -x /etc/init.d/pagerwebui ] && /etc/init.d/pagerwebui running >/dev/null 2>&1; then
/etc/init.d/pagerwebui restart
fi
echo EXTRACT_OK"
run_ssh "$TARGET" "$REMOTE_COMMAND"
printf 'Installed to /root/payloads/%s/\n' "$REMOTE_PAYLOAD_DIR"
if $PORTAL_REFRESH && [[ -n "$PASSWORD" ]]; then
PASSWORD_B64="$(printf '%s' "$PASSWORD" | base64)"
PORTAL_OK=false
for attempt in 1 2 3; do
if printf '%s\n' "$PASSWORD_B64" | run_ssh "$TARGET" 'read -r password_b64
password=$(printf "%s" "$password_b64" | base64 -d)
login_body=$(printf "%s" "$password" | python3 -c '"'"'import json, sys; print(json.dumps({"username": "root", "password": sys.stdin.read()}))'"'"')
token=$(curl -sS -X POST http://127.0.0.1:1471/api/login -H "Content-Type: application/json" -d "$login_body" |
python3 -c '"'"'import json, sys; print(json.load(sys.stdin).get("token", ""))'"'"')
[ -n "$token" ] &&
curl -fsS -X POST http://127.0.0.1:1471/api/payloads/portal/refresh -H "Authorization: Bearer $token" >/dev/null'; then
PORTAL_OK=true
break
fi
sleep 2
done
if $PORTAL_OK; then
printf 'Portal refreshed.\n'
else
printf 'Warning: portal refresh failed; payload installation is complete.\n' >&2
fi
elif $PORTAL_REFRESH; then
printf 'Skipping portal refresh without --password; payload installation is complete.\n'
fi
printf 'Deploy complete. Browse http://%s:8080/\n' "$PAGER_HOST"
+267
View File
@@ -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: <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__':
unittest.main()
+58 -20
View File
@@ -182,9 +182,8 @@ class DaemonSockTest(unittest.TestCase):
calls = []
server.daemon_sock_call = lambda m, p, body=None: calls.append((m, p, body)) or (200, {'success': True})
server.h_recon_start(type('C', (), {'args': ()})())
server.h_recon_stop(type('C', (), {'args': ()})())
self.assertEqual(calls[0], ('POST', '/api/pineap/log/recon/start', {}))
self.assertEqual(calls[1], ('POST', '/api/pineap/log/recon/stop', {}))
self.assertEqual(calls, [
('POST', '/api/pineap/recon/new', {'scan_time': 30})])
def test_start_forwards_scan_time(self):
calls = []
@@ -192,19 +191,36 @@ class DaemonSockTest(unittest.TestCase):
ctx = type('C', (), {'args': (), 'body': {'scan_time': 60}})()
status, data = server.h_recon_start(ctx)
self.assertEqual(status, 200)
self.assertEqual(calls[0], ('POST', '/api/pineap/log/recon/start', {'scan_time': 60}))
self.assertEqual(calls[0], ('POST', '/api/pineap/recon/new', {'scan_time': 60}))
def test_start_defaults_empty_body(self):
calls = []
server.daemon_sock_call = lambda m, p, body=None: calls.append((m, p, body)) or (200, {'success': True})
server.h_recon_start(type('C', (), {'args': ()})())
self.assertEqual(calls[0], ('POST', '/api/pineap/log/recon/start', {}))
self.assertEqual(calls[0], ('POST', '/api/pineap/recon/new', {'scan_time': 30}))
def test_start_rejects_invalid_scan_time(self):
calls = []
server.daemon_sock_call = lambda m, p, body=None: calls.append((m, p, body))
ctx = type('C', (), {'args': (), 'body': {'scan_time': 'forever'}})()
status, data = server.h_recon_start(ctx)
self.assertEqual(status, 400)
self.assertIn('scan_time', data['error'])
self.assertEqual(calls, [])
def test_start_reports_native_failure(self):
server._recon_scan_state = {'active': False, 'started': 0, 'duration': 0}
server.daemon_sock_call = lambda m, p, body=None: (500, {'error': 'no radio'})
status, data = server.h_recon_start(
type('C', (), {'args': (), 'body': {'scan_time': 30}})())
self.assertEqual(status, 502)
self.assertEqual(data['error'], 'native recon scan failed')
self.assertEqual(data['detail'], {'error': 'no radio'})
self.assertFalse(server._recon_scan_state['active'])
class ReconScanStateTest(unittest.TestCase):
"""The daemon ignores scan_time and scans continuously until 'stop'. The webui
must track the requested duration itself so timed scans actually end and the
toggle can reflect real scan state."""
"""The webui mirrors the duration of the Pager's native timed scan."""
def setUp(self):
self.db = make_db()
@@ -248,26 +264,33 @@ class ReconScanStateTest(unittest.TestCase):
self.assertFalse(data['scanning'])
self.assertEqual(data['scan_remaining'], 0)
def test_continuous_scan_has_no_remaining(self):
def test_zero_duration_is_rejected(self):
server.time.time = lambda: 1000.0
self._start(scan_time=0)
status, data = self._status()
self.assertTrue(data['scanning'])
self.assertIsNone(data['scan_remaining'])
status, data = self._start(scan_time=0)
self.assertEqual(status, 400)
self.assertFalse(server._recon_scan_state['active'])
def test_default_start_is_continuous(self):
def test_default_start_uses_thirty_seconds(self):
server.time.time = lambda: 1000.0
self._start()
status, data = self._status()
self.assertTrue(data['scanning'])
self.assertIsNone(data['scan_remaining'])
self.assertEqual(data['scan_remaining'], 30)
def test_stop_clears_scanning(self):
def test_stop_rejects_active_native_scan(self):
server.time.time = lambda: 1000.0
self._start(scan_time=30)
self._stop()
status, data = self._stop()
self.assertEqual(status, 409)
self.assertIn('finish automatically', data['error'])
self.assertEqual(data['scan_remaining'], 30)
status, data = self._status()
self.assertFalse(data['scanning'])
self.assertTrue(data['scanning'])
def test_stop_is_idempotent_when_inactive(self):
status, data = self._stop()
self.assertEqual(status, 200)
self.assertEqual(data, {'ok': True})
def test_start_failure_does_not_mark_scanning(self):
server.time.time = lambda: 1000.0
@@ -277,13 +300,13 @@ class ReconScanStateTest(unittest.TestCase):
self.assertFalse(data['scanning'])
def test_watchdog_stops_expired_timed_scan(self):
calls = []
server.time.time = lambda: 1000.0
self._start(scan_time=10)
server.time.time = lambda: 1012.0
calls = []
server.daemon_sock_call = lambda m, p, body=None: calls.append((m, p)) or (200, {'success': True})
server._recon_watchdog_tick()
self.assertEqual(calls, [('POST', '/api/pineap/log/recon/stop')])
self.assertEqual(calls, [])
self.assertFalse(server._recon_scan_state['active'])
def test_watchdog_leaves_active_scan_alone(self):
@@ -454,6 +477,21 @@ class CliFallbackTest(unittest.TestCase):
server.RECON_DB = '/nonexistent.db'
self.assertEqual(server.decode_ssid('casaalicia\\x00.\\xde_'), 'casaalicia\x00.\ufffd_')
def test_completed_recon_lock_uses_immutable_read(self):
calls = []
server._recon_scan_state = {'active': False, 'started': 0, 'duration': 0}
def locked_then_read(args, timeout=20):
calls.append(args)
if len(calls) == 1:
return 5, '', 'Error: database is locked'
return 0, '[{"id": 2}]', ''
server.device_run = locked_then_read
rows = server._db_rows(self.db, 'SELECT MAX(id) AS id FROM scan')
self.assertEqual(rows, [{'id': 2}])
self.assertEqual(calls[1][-2], 'file:%s?immutable=1' % self.db)
def make_hs_db():
db = make_db()