Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f6dc5bcdb | ||
|
|
2bf39ecb9d |
@@ -3,4 +3,5 @@ __pycache__/
|
||||
*.pyc
|
||||
|
||||
.worktrees/
|
||||
.openchamber/
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Executable
+186
@@ -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"
|
||||
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user