Files
Mark-VIII/payload/user/remote_access/pager-webui/server.py
T
bzuccaro 251b1f6261 feat: recon surveys with OUI/vendor lookup and report views
Reconnaissance surveys (GPSD-tethered scan recording), OUI vendor
lookup for client/AP tables, survey/report pages, and the matching
test_recon.py suite. Co-authored with the recon-feature agent whose
work was finished in this checkout.
2026-08-18 08:35:22 -05:00

3881 lines
143 KiB
Python

#!/usr/bin/env python3
"""Mark VIII backend. Runs on the device's python3-light (stdlib only, no
urllib/http.server/sqlite3 modules); the sqlite reads fall back to the
device's sqlite3 CLI."""
import base64
import hashlib
import json
import os
import re
import select
import signal
import socket
import struct
import subprocess
import sys
import threading
import time
try:
import sqlite3
except ImportError:
sqlite3 = None
SQLITE_CLI = os.environ.get('PAGER_SQLITE_CLI', 'sqlite3')
DAEMON_BASE = os.environ.get('PAGER_DAEMON', 'http://127.0.0.1:1471')
DAEMON_SOCK = os.environ.get('PAGER_DAEMON_SOCK', '/tmp/api.sock')
AF_UNIX = getattr(socket, 'AF_UNIX', 1)
RECON_DB = os.environ.get('PAGER_RECON_DB', '/root/recon/recon.db')
LOOT_HS_DIR = os.environ.get('PAGER_LOOT_HS', '/root/loot/handshakes')
HAK5CMD = os.environ.get('PAGER_HAK5CMD', '/usr/bin/hak5cmd')
SESSION_FILE = os.environ.get('PAGER_SESSION_FILE', '/tmp/pagerwebui.session')
PINEAP_STATE_FILE = os.environ.get(
'PAGER_PINEAP_STATE_FILE',
os.path.join(os.environ.get('TMPDIR') or os.environ.get('TEMP') or '/tmp',
'pagerwebui.pineap-state'))
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_scans_cache = {'db': None, 'updated': 0, 'data': {'scans': []}}
_recon_status_cache = {
'db': None, 'updated': 0, 'last_scan': None, 'last_activity': None}
SURVEY_DIR = os.environ.get('PAGER_SURVEY_DIR', '/root/loot/recon-surveys')
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')
SURVEY_MAX_SAMPLES = int(os.environ.get('PAGER_SURVEY_MAX_SAMPLES', '1800'))
SURVEY_SAMPLE_INTERVAL = float(os.environ.get('PAGER_SURVEY_SAMPLE_INTERVAL', '2.0'))
GPS_CACHE_SECONDS = 5.0
_survey_state = {'active': False, 'id': None, 'name': None, 'path': None,
'started': 0, 'samples': 0, 'last_sample': 0}
_survey_lock = threading.Lock()
_gps_cache = {'updated': 0, 'data': None}
_gps_lock = threading.Lock()
_payload_runs = {}
_payload_runs_lock = threading.Lock()
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:
return 124, '', '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 daemon_call(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)."""
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 _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).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 != '/api/login':
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
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:
opcode, payload = self._ws_read_frame()
if opcode is None or opcode == 0x8:
return
if opcode in (0x1, 0x2, 0x9):
daemon_sock.sendall(ws_encode(payload, opcode=opcode, mask=True))
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():
rc, out, err = device_run(['iwinfo'])
names = []
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):
clients = assoc_clients()
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'}
hak5('PINEAPPLE_DEVICE_FILTER_MODE', 'deny')
hak5('PINEAPPLE_DEVICE_FILTER_ADD', 'deny', mac)
hak5('PINEAPPLE_DEAUTH_CLIENT', mac)
return 200, {'ok': True}
def h_deauth_client(ctx):
mac = normalize_mac((ctx.body or {}).get('mac'))
if not mac:
return 400, {'error': 'invalid mac'}
hak5('PINEAPPLE_DEAUTH_CLIENT', mac)
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
def decode_encryption(v):
"""Pager recon.db encryption bitfield -> old-UI-style display string."""
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')
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',
}
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 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):
rows = _db_rows(RECON_DB,
'WITH recent AS (SELECT id, time, name FROM scan ORDER BY id DESC LIMIT %d), '
'devices AS (SELECT scan, count(*) AS devices FROM wifi_device '
'WHERE scan IN (SELECT id FROM recent) GROUP BY scan), '
'aps AS (SELECT scan, count(*) AS aps FROM ssid '
'WHERE type = 8 AND scan IN (SELECT id FROM recent) GROUP BY scan), '
'captures AS (SELECT scan, count(*) AS handshakes FROM handshake '
'WHERE scan IN (SELECT id FROM recent) GROUP BY scan) '
'SELECT s.id, s.time, s.name, '
'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):
"""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 survey 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 2s survey poll cheap.
"""
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(RECON_DB, sql, timeout=_timeout)
cnt = _db_rows(RECON_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(RECON_DB,
"SELECT 'scan' AS kind, id AS row_id, time, name, "
"NULL AS mac, NULL AS bssid, NULL AS ssid, NULL AS hidden, "
"NULL AS channel, NULL AS encryption, NULL AS signal, NULL AS freq, "
"NULL AS packets, NULL AS stahash, NULL AS aphash "
"FROM scan WHERE id = %d "
"UNION ALL SELECT 'ap', hash, time, NULL, NULL, bssid, ssid, hidden, "
"channel, encryption, signal, freq, NULL, NULL, NULL "
"FROM ssid WHERE scan = %d AND type = 8 AND bssid IS NOT NULL "
"UNION ALL SELECT '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'))),
'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']
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
clients.append({'mac': fmt_mac(r.get('mac')), 'signal': r.get('signal'),
'freq': r.get('freq'), 'packets': r.get('packets')})
mac_of = {r['row_id']: fmt_mac(r.get('mac')) for r in devices}
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')})
return {'scan': {'id': scans[0]['row_id'], 'time': scans[0]['time'],
'name': scans[0].get('name')},
'aps': aps, 'clients': clients, 'handshakes': handshakes,
'unassociated': unassociated}
def h_recon_start(ctx):
scan_time = (getattr(ctx, 'body', None) or {}).get(
'scan_time', DEFAULT_RECON_DURATION)
try:
scan_time = int(scan_time)
except (TypeError, ValueError):
return 400, {'error': 'scan_time must be an integer'}
if scan_time < 1 or scan_time > 86400:
return 400, {'error': 'scan_time is out of range'}
body = {'scan_time': scan_time}
# log/recon/start restarts the recon logger and can rotate the existing
# database. recon/new is the Pager's native "start another scan" action and
# appends a scan without discarding history.
status, data = daemon_sock_call('POST', '/api/pineap/recon/new', body=body)
if status != 200 or not (data or {}).get('success'):
return 502, {'error': 'native recon scan failed', 'detail': data}
_recon_scan_state['active'] = True
_recon_scan_state['started'] = time.time()
_recon_scan_state['duration'] = scan_time
return 200, {'ok': True}
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.
Also records a survey sample when a survey is active.
"""
st = _recon_scan_state
if st['active'] and st['duration'] > 0 and time.time() - st['started'] >= st['duration']:
st['active'] = False
try:
_survey_sample()
except Exception:
pass
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}
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_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 h_recon_examine(ctx):
body = getattr(ctx, 'body', None) or {}
bssid = (body.get('bssid') or '').strip()
channel = body.get('channel')
if bssid:
hak5('PINEAPPLE_EXAMINE_BSSID', bssid)
elif channel is not None:
hak5('PINEAPPLE_EXAMINE_CHANNEL', str(int(channel)))
else:
return 400, {'error': 'examine requires bssid or channel'}
return 200, {'ok': True}
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])
data = recon_scan_data(scan_id)
if data is None:
return 404, {'error': 'scan not found'}
return 200, data
# ---------------------------------------------------------------------------
# Recon report helpers (CSV / HTML) for scan and survey 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 _aps_csv(data):
out = ['bssid,ssid,hidden,band,channel,freq,encryption,signal,vendor,first_seen,last_seen']
for a in (data or {}).get('aps') or []:
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'))]))
out.append('unassociated,%d' % ((data or {}).get('unassociated') or 0))
return '\r\n'.join(out) + '\r\n'
def _survey_aps_csv(detail):
out = ['bssid,ssid,band,channel,min_dbm,avg_dbm,max_dbm,samples,first_seen,last_seen']
for a in (detail or {}).get('aps') or []:
out.append(','.join(_csv_escape(x) for x in [
a.get('bssid'), a.get('ssid'), a.get('band'), a.get('channel'),
a.get('min'), a.get('avg'), a.get('max'), a.get('samples'),
_fmt_ts(a.get('first_seen')), _fmt_ts(a.get('last_seen'))]))
return '\r\n'.join(out) + '\r\n'
def _esc_html(v):
if v is None:
return ''
return (str(v).replace('&', '&amp;').replace('<', '&lt;')
.replace('>', '&gt;').replace('"', '&quot;'))
REPORT_CSS = """
body { font-family: -apple-system, 'Segoe UI', Roboto, sans-serif; margin: 24px; color: #222; background: #fff; }
h1 { font-size: 20px; margin: 0 0 4px; }
.sub { color: #666; margin-bottom: 16px; }
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; }
.stats { margin: 12px 0; font-size: 13px; color: #333; }
"""
def _html_table(headers, rows):
out = ['<table><thead><tr>']
for header in headers:
out.append('<th>%s</th>' % _esc_html(header))
out.append('</tr></thead><tbody>')
for row in rows:
out.append('<tr>')
for cell in row:
out.append('<td>%s</td>' % _esc_html(cell))
out.append('</tr>')
out.append('</tbody></table>')
return ''.join(out)
def _html_doc(title, subtitle, body_html, stats=None):
parts = ['<!DOCTYPE html><html><head><meta charset="utf-8"><title>%s</title>'
'<style>%s</style></head><body>' % (_esc_html(title), REPORT_CSS)]
parts.append('<h1>%s</h1>' % _esc_html(title))
parts.append('<div class="sub">%s</div>' % _esc_html(subtitle))
for label, value in (stats or []):
parts.append('<div class="stats"><b>%s:</b> %s</div>' % (_esc_html(label), value))
parts.append(body_html)
parts.append('</body></html>')
return ''.join(parts)
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):
rows = _db_rows(RECON_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 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'}
rows = []
for a in data.get('aps') or []:
rows.append([a.get('ssid') or '(hidden)', a.get('bssid'), a.get('band'),
a.get('channel'), a.get('signal'), a.get('encryption'),
a.get('vendor'), _fmt_ts(a.get('first_seen')),
_fmt_ts(a.get('last_seen'))])
scan = data.get('scan') or {}
stats = [('Started', _fmt_ts(scan.get('time'))),
('Access points', len(data.get('aps') or [])),
('Clients', client_count),
('Handshakes', len(data.get('handshakes') or [])),
('Unassociated', data.get('unassociated') or 0)]
body = _html_table(['SSID', 'BSSID', 'Band', 'Ch', 'Signal', 'Encryption',
'Vendor', 'First seen', 'Last seen'], rows)
return 200, Download(_html_doc('Scan #%d' % scan.get('id'),
'Pager recon capture report', body,
stats=stats).encode('utf-8'),
'text/html', 'scan-%d.html' % scan_id)
# ---------------------------------------------------------------------------
# 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
if fix is None:
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
# ---------------------------------------------------------------------------
# Surveys: JSONL overlay on the device (meta line + one 'sample' line per
# tick of the always-on recon). The recon.db itself is never modified.
# ---------------------------------------------------------------------------
def _survey_path(sid):
return _safe_join(SURVEY_DIR, '%s.jsonl' % sid)
def _survey_slug(name):
return re.sub(r'[^A-Za-z0-9]+', '-', (name or '').strip()).strip('-')
def _survey_recording_state():
st = _survey_state
if not st['active']:
return None
return {'active': True, 'id': st['id'], 'name': st['name'],
'started': st['started'], 'samples': st['samples']}
def _survey_sample():
with _survey_lock:
st = _survey_state
if not st['active'] or not st['path']:
return
now = time.time()
if now - st['last_sample'] < SURVEY_SAMPLE_INTERVAL:
return
if st['samples'] >= SURVEY_MAX_SAMPLES:
st['active'] = False
return
try:
scans = recon_scans_data(1, _timeout=6).get('scans') or []
detail = recon_scan_data(scans[0]['id'], _timeout=12, _limit=300) if scans else None
gps = _gps_status_data()
sample = {'t': int(now),
'scan': (detail or {}).get('scan'),
'aps': (detail or {}).get('aps') or [],
'clients': (detail or {}).get('clients') or [],
'handshakes': (detail or {}).get('handshakes') or [],
'unassociated': (detail or {}).get('unassociated') or 0,
'gps': {'fix': gps.get('fix'), 'lat': gps.get('lat'),
'lon': gps.get('lon'),
'satellites': gps.get('satellites')}}
with open(st['path'], 'a') as fh:
fh.write(json.dumps({'sample': sample}) + '\n')
st['samples'] += 1
st['last_sample'] = now
except Exception:
pass
def h_recon_survey_start(ctx):
with _survey_lock:
if _survey_state['active']:
return 409, {'error': 'a survey is already recording'}
name = ((getattr(ctx, 'body', None) or {}).get('name') or '').strip()
sid = time.strftime('%Y%m%d-%H%M%S')
slug = _survey_slug(name)
if slug:
sid += '-' + slug
path = _survey_path(sid)
if path is None:
return 500, {'error': 'invalid survey id'}
try:
os.makedirs(SURVEY_DIR, exist_ok=True)
except OSError:
return 500, {'error': 'cannot create survey directory'}
meta = {'id': sid, 'name': name, 'started': int(time.time()),
'interval': SURVEY_SAMPLE_INTERVAL, 'max_samples': SURVEY_MAX_SAMPLES}
try:
with open(path, 'w') as fh:
fh.write(json.dumps({'meta': meta}) + '\n')
except OSError:
return 500, {'error': 'cannot write survey file'}
_survey_state.update({'active': True, 'id': sid, 'name': name, 'path': path,
'started': meta['started'], 'samples': 0,
'last_sample': 0})
return 200, {'ok': True, 'id': sid, 'started': meta['started']}
def h_recon_survey_stop(ctx):
with _survey_lock:
st = dict(_survey_state)
if st['active']:
_survey_state['active'] = False
return 200, {'ok': True, 'samples': st['samples'], 'id': st['id']}
def h_recon_survey_live(ctx):
detail = None
try:
scans = recon_scans_data(1, _timeout=6).get('scans') or []
if scans:
detail = recon_scan_data(scans[0]['id'], _timeout=12, _limit=300)
except RuntimeError:
pass
try:
gps = _gps_status_data()
except Exception:
gps = {}
return 200, {'scan': (detail or {}).get('scan'),
'aps': (detail or {}).get('aps') or [],
'clients': (detail or {}).get('clients') or [],
'handshakes': (detail or {}).get('handshakes') or [],
'unassociated': (detail or {}).get('unassociated') or 0,
'gps': gps,
'recording': _survey_recording_state()}
def recon_surveys_data():
surveys = []
if os.path.isdir(SURVEY_DIR):
try:
names = sorted(os.listdir(SURVEY_DIR), reverse=True)
except OSError:
names = []
for name in names:
if not name.endswith('.jsonl'):
continue
path = os.path.join(SURVEY_DIR, name)
sid = name[:-6]
meta = None
samples = 0
try:
size = os.path.getsize(path)
with open(path, 'r', errors='replace') as fh:
for line in fh:
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
except ValueError:
continue
if 'meta' in obj:
meta = obj['meta']
elif 'sample' in obj:
samples += 1
except OSError:
continue
if meta is None:
continue
surveys.append({'id': sid, 'name': meta.get('name') or sid,
'started': meta.get('started'),
'samples': samples, 'size': size})
return {'surveys': surveys}
def h_recon_surveys(ctx):
try:
return 200, recon_surveys_data()
except Exception:
return 200, {'surveys': []}
def recon_survey_data(sid):
path = _survey_path(sid)
if path is None or not os.path.isfile(path):
return None
agg = {}
order = []
gps_fixes = []
try:
with open(path, 'r', errors='replace') as fh:
for line in fh:
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
except ValueError:
continue
s = obj.get('sample')
if not isinstance(s, dict):
continue
g = s.get('gps') or {}
if g.get('lat') is not None and g.get('lon') is not None:
gps_fixes.append({'t': s.get('t'), 'lat': g['lat'],
'lon': g['lon']})
for a in s.get('aps') or []:
bssid = a.get('bssid')
if not bssid:
continue
entry = agg.get(bssid)
if entry is None:
entry = {'ssid': a.get('ssid'), 'bssid': bssid,
'band': a.get('band'), 'channel': a.get('channel'),
'min': None, 'max': None, 'sum': 0, 'count': 0,
'first_seen': None, 'last_seen': None}
agg[bssid] = entry
order.append(bssid)
sig = a.get('signal')
if sig is not None:
entry['min'] = sig if entry['min'] is None else min(entry['min'], sig)
entry['max'] = sig if entry['max'] is None else max(entry['max'], sig)
entry['sum'] += sig
entry['count'] += 1
t = s.get('t')
if t:
entry['first_seen'] = (t if entry['first_seen'] is None
else min(entry['first_seen'], t))
entry['last_seen'] = (t if entry['last_seen'] is None
else max(entry['last_seen'], t))
except OSError:
return None
aps = []
for bssid in order:
e = agg[bssid]
aps.append({'ssid': e['ssid'], 'bssid': e['bssid'], 'band': e['band'],
'channel': e['channel'], 'min': e['min'],
'avg': round(e['sum'] / e['count']) if e['count'] else None,
'max': e['max'], 'samples': e['count'],
'first_seen': e['first_seen'], 'last_seen': e['last_seen']})
aps.sort(key=lambda a: a['avg'] if a['avg'] is not None else 0)
return {'id': sid, 'aps': aps, 'gps_fixes': len(gps_fixes),
'first_gps': gps_fixes[0] if gps_fixes else None,
'last_gps': gps_fixes[-1] if gps_fixes else None}
def h_recon_survey_detail(ctx):
sid = ctx.args[0]
data = recon_survey_data(sid)
if data is None:
return 404, {'error': 'survey not found'}
return 200, data
def h_recon_survey_download(ctx):
sid = ctx.args[0]
fmt = ctx.args[1]
if fmt == 'json':
data = recon_survey_data(sid)
if data is None:
return 404, {'error': 'survey not found'}
return 200, Download(json.dumps(data, indent=2).encode('utf-8'),
'application/json', 'survey-%s.json' % sid)
data = recon_survey_data(sid)
if data is None:
return 404, {'error': 'survey not found'}
if fmt == 'csv':
return 200, Download(_survey_aps_csv(data).encode('utf-8'), 'text/csv',
'survey-%s.csv' % sid)
if fmt == 'html':
rows = []
for a in data.get('aps') or []:
rows.append([a.get('ssid') or '(hidden)', a.get('bssid'),
a.get('band'), a.get('channel'), a.get('min'),
a.get('avg'), a.get('max'), a.get('samples'),
_fmt_ts(a.get('first_seen')), _fmt_ts(a.get('last_seen'))])
fixes = data.get('gps_fixes') or 0
fg = data.get('first_gps') or {}
lg = data.get('last_gps') or {}
if fixes:
gps = ('%d fixes &middot; first %.5f, %.5f &middot; last %.5f, %.5f'
% (fixes, fg.get('lat') or 0, fg.get('lon') or 0,
lg.get('lat') or 0, lg.get('lon') or 0))
else:
gps = 'No GPS fixes during this survey'
body = _html_table(['SSID', 'BSSID', 'Band', 'Ch', 'Min', 'Avg', 'Max',
'Samples', 'First', 'Last'], rows)
return 200, Download(_html_doc('Survey %s' % sid,
'AP signal aggregates', body,
stats=[('GPS', gps)]).encode('utf-8'),
'text/html', 'survey-%s.html' % sid)
return 404, {'error': 'unknown format'}
def h_recon_survey_delete(ctx):
sid = ctx.args[0]
path = _survey_path(sid)
if path is None or not os.path.isfile(path):
return 404, {'error': 'survey not found'}
with _survey_lock:
if _survey_state.get('id') == sid and _survey_state['active']:
return 409, {'error': 'cannot delete the survey that is recording'}
try:
os.remove(path)
except OSError:
return 500, {'error': 'delete failed'}
return 200, {'ok': True}
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 assoc_clients(ifaces=None):
clients = []
for name in (wifi_ifaces() if ifaces is None else ifaces):
rc, out, err = device_run(['iwinfo', name, 'assoclist'])
for line in out.splitlines():
m = re.match(r'\s*([0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2})\s+', line)
if not m:
continue
mac = m.group(1).upper()
rssi = None
rm = re.search(r'Signal:\s*(-?\d+)', line)
if rm:
rssi = int(rm.group(1))
clients.append({'mac': mac, 'iface': name, 'rssi': rssi})
return clients
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 {}
_, 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)
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 {}
_, cur = daemon_sock_call('GET', '/api/pineap/hostapd/get_config')
base = dict(HOSTAPD_DEFAULTS)
if isinstance(cur, dict) and 'mgmt_ifaces' in cur:
base.update(cur)
base.update({k: v for k, v in body.items() if k in HOSTAPD_DEFAULTS})
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 h_pineap_wifi_get_ap(ctx):
global _last_reconcile
def _iface_state(open_name, wpa_name, radio_name):
open_cfg = _uci_wifi_iface(open_name)
wpa_cfg = _uci_wifi_iface(wpa_name)
radio_cfg = _uci_wifi_iface(radio_name)
def _chan(cfg):
channel = cfg.get('channel') or ''
try:
return int(channel)
except (TypeError, ValueError):
return None
radio_channel = _chan(radio_cfg)
open_channel = _chan(open_cfg) or radio_channel
wpa_channel = _chan(wpa_cfg) or radio_channel
encryption = wpa_cfg.get('encryption') or ''
if encryption.startswith('psk2'):
encryption = 'psk2'
elif encryption.startswith('sae'):
encryption = 'sae'
elif encryption.startswith('owe'):
encryption = 'owe'
return open_cfg, wpa_cfg, radio_cfg, open_channel, wpa_channel, encryption
if _uci_wifi_iface('wlan1open') or _uci_wifi_iface('wlan1wpa'):
open_cfg, wpa_cfg, radio_cfg, open_channel, wpa_channel, encryption = _iface_state(
'wlan1open', 'wlan1wpa', 'radio1')
else:
open_cfg, wpa_cfg, radio_cfg, open_channel, wpa_channel, encryption = _iface_state(
'wlan0open', 'wlan0wpa', 'radio0')
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 {}
pool = _uci_section('pineapd.@ssidpool[0]')
radio1 = _uci_wifi_iface('radio1')
for name in ('wlan1open', 'wlan1wpa'):
cfg = _uci_wifi_iface(name)
if cfg and cfg.get('disabled') != '1' and not os.path.exists('/sys/class/net/%s' % name):
if time.time() - _last_reconcile > 30:
_last_reconcile = time.time()
device_run(['wifi', 'reload'])
break
return 200, {
'open': {
'enabled': open_cfg.get('disabled') == '0',
'ssid': open_cfg.get('ssid') or '',
'bssid': open_cfg.get('macaddr') or '',
'target': pool.get('target') or None,
'hidden': open_cfg.get('hidden') == '1',
'channel': open_channel,
'country': radio_cfg.get('country') or '',
},
'wpa': {
'ssid': wpa_cfg.get('ssid') or '',
'passphrase': wpa_cfg.get('key') or '',
'enctype': encryption,
'hidden': wpa_cfg.get('hidden') == '1',
'enabled': wpa_cfg.get('disabled') == '0',
'channel': wpa_channel,
},
'enterprise': {'enabled': not host.get('pineape_disabled', True)},
'pool': {'disabled': None, 'collecting': bool(pinecfg.get('autossidpool'))},
'radio1': {
'band': {'2g': BAND_2G, '5g': BAND_5G, '6g': BAND_6G}.get(
(radio1 or {}).get('band'), BAND_5G),
'channel': (radio1 or {}).get('channel') or 'auto',
'htmode': (radio1 or {}).get('htmode') or 'VHT80',
'country': (radio1 or {}).get('country') or '',
},
}
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 _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():
if _read_hop() == '0':
device_run(['uci', 'set', 'pineapd.wlan1mon.hop=1'])
device_run(['uci', 'commit', 'pineapd'])
device_run(['/etc/init.d/pineapd', 'reload'])
def _remove_radio1_ap():
device_run(['uci', 'delete', 'wireless.wlan1open'])
device_run(['uci', 'delete', 'wireless.wlan1wpa'])
device_run(['uci', 'set', 'wireless.radio1.channel=auto'])
device_run(['uci', 'set', 'wireless.radio1.band=5g'])
device_run(['uci', 'commit', 'wireless'])
_resume_hop()
def _apply_radio1_ap(openap, wpa):
band = None
if openap is not None:
band = channel_band(openap.get('channel'))
iface = 'wlan1open'
if wpa is not None:
band = channel_band(wpa.get('channel'))
iface = 'wlan1wpa'
if band not in (BAND_5G, BAND_6G):
raise ValueError('radio1 AP requires a 5GHz or 6GHz channel')
if band == BAND_6G and wpa is None:
raise ValueError('6GHz open APs are not supported (6GHz requires WPA3/OWE)')
if band == BAND_6G and wpa is not None:
if (wpa.get('enctype') or 'psk2') not in ('sae', 'owe'):
raise ValueError('6GHz requires WPA3 (sae or owe)')
device_run(['uci', 'delete', 'wireless.wlan1open'])
device_run(['uci', 'delete', 'wireless.wlan1wpa'])
device_run(['uci', 'set', 'wireless.radio1.band=%s' % ('6g' if band == BAND_6G else '5g')])
ch = int(wpa.get('channel') if wpa is not None else openap.get('channel'))
device_run(['uci', 'set', 'wireless.radio1.channel=%d' % ch])
device_run(['uci', 'set', 'wireless.radio1.htmode=%s' % band_htmode(band)])
country = (wpa or openap or {}).get('country')
if country:
device_run(['uci', 'set', 'wireless.radio1.country=%s' % country])
cfg = wpa if wpa is not None else openap
device_run(['uci', 'set', 'wireless.%s=wifi-iface' % iface])
device_run(['uci', 'set', 'wireless.%s.device=radio1' % iface])
device_run(['uci', 'set', 'wireless.%s.mode=ap' % iface])
device_run(['uci', 'set', 'wireless.%s.ifname=%s' % (iface, iface)])
device_run(['uci', 'set', 'wireless.%s.disabled=0' % iface])
device_run(['uci', 'set', 'wireless.%s.ssid=%s' % (iface, cfg.get('ssid') or '')])
device_run(['uci', 'set', 'wireless.%s.hidden=%d' % (iface, 1 if cfg.get('hidden') else 0)])
device_run(['uci', 'set', 'wireless.%s.channel=%d' % (iface, int(cfg.get('channel')))])
if wpa is not None:
device_run(['uci', 'set', 'wireless.%s.encryption=%s' % (iface, wpa.get('enctype') or 'psk2')])
device_run(['uci', 'set', 'wireless.%s.key=%s' % (iface, wpa.get('passphrase') or '')])
else:
device_run(['uci', 'set', 'wireless.%s.encryption=none' % iface])
bssid = (openap or {}).get('bssid') or ''
if bssid:
if not re.match(r'^[0-9A-F]{2}(?::[0-9A-F]{2}){5}$', bssid.upper()):
raise ValueError('invalid BSSID format')
device_run(['uci', 'set', 'wireless.%s.macaddr=%s' % (iface, bssid.upper())])
device_run(['uci', 'commit', 'wireless'])
_pause_hop()
device_run(['wifi', 'reload'])
def h_pineap_wifi_set_ap(ctx):
body = ctx.body or {}
wpa = body.get('wpa') or {}
openap = body.get('open') or {}
wpa_band = channel_band(wpa.get('channel')) if wpa.get('channel') is not None else None
open_band = channel_band(openap.get('channel')) if openap.get('channel') is not None else None
use_radio1 = wpa_band in (BAND_5G, BAND_6G) or open_band in (BAND_5G, BAND_6G)
if use_radio1:
wpa_active = (wpa_band in (BAND_5G, BAND_6G) and bool(wpa.get('enabled', True))
and wpa.get('channel') is not None)
open_active = (open_band in (BAND_5G, BAND_6G) and bool(openap.get('enabled', True))
and openap.get('channel') is not None)
wpa_present = bool(wpa.get('ssid') or wpa.get('enabled') is not None)
open_present = bool(openap.get('ssid') or openap.get('enabled') is not None)
if (wpa_present and wpa_band == BAND_2G) or (open_present and open_band == BAND_2G):
return 400, {'error': 'cannot configure 2.4GHz and radio1 APs in one request'}
if not (wpa_active or open_active):
_remove_radio1_ap()
device_run(['wifi', 'reload'])
return 200, {'ok': True}
try:
_apply_radio1_ap(openap if open_active else None,
wpa if wpa_active else None)
except ValueError as exc:
return 400, {'error': str(exc)}
return 200, {'ok': True}
if _uci_wifi_iface('wlan1open') or _uci_wifi_iface('wlan1wpa'):
_remove_radio1_ap()
device_run(['wifi', 'reload'])
configs = []
if wpa.get('ssid') or wpa.get('enabled') is not None:
configs.append({
'interface': 'wlan0wpa',
'ssid': wpa.get('ssid', ''),
'enctype': wpa.get('enctype') or 'psk2',
'enabled': bool(wpa.get('enabled', True)),
'hidden': bool(wpa.get('hidden', False)),
'key': wpa.get('passphrase') or '',
'channel': 1,
})
if openap.get('ssid') or openap.get('enabled') is not None:
configs.append({
'interface': 'wlan0open',
'ssid': openap.get('ssid', ''),
'enctype': 'none',
'enabled': bool(openap.get('enabled', True)),
'hidden': bool(openap.get('hidden', False)),
'channel': _open_channel(openap.get('channel')),
'bssid': openap.get('bssid') or '',
})
if not configs:
return 400, {'error': 'no configuration provided'}
status, data = daemon_sock_call('PUT', '/api/settings/wifi/set_ap', body={'configs': configs}, timeout=45)
if status != 200:
return (502 if status == 0 else status), {'error': 'daemon failed', 'detail': data}
_apply_open_radio(openap)
return 200, {'ok': True}
def h_pineap_advertise(ctx):
enable = bool((ctx.body or {}).get('enable'))
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 {})
def hak5(*args, timeout=30):
rc, out, err = device_run([HAK5CMD] + list(args), timeout=timeout)
return out
def _json_or(text):
text = text.strip()
if text.startswith('{') or text.startswith('['):
try:
return json.loads(text)
except ValueError:
return None
return None
def _parse_pool_list(text):
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):
return 200, {'ssids': _parse_pool_list(hak5('PINEAPPLE_SSID_POOL_LIST'))}
def h_ssids_post(ctx):
body = ctx.body or {}
action = body.get('action')
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'))}
FILTER_DAEMON = {
'client': ('macfilter/get_config', 'macfilter/set_mode', 'PINEAPPLE_DEVICE_FILTER'),
'ssid': ('ssidfilter/get_config', 'ssidfilter/set_config', 'PINEAPPLE_NETWORK_FILTER'),
}
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()
mode = 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'])
# On the Pager, the Mimic/PineAP switch is the response engine itself:
# Passive intentionally leaves it disabled, while Active enables it.
# Treat only a real mismatch with that preset (or disabled collection) as
# a custom/Advanced setup.
expected_enabled = {'passive': False, 'active': True}.get(mode)
engine_mismatch = (enabled is not None and expected_enabled is not None
and enabled != expected_enabled)
if mode in ('passive', 'active') and (collect is False or engine_mismatch):
mode = 'advanced'
state = update_pineap_state(mode='advanced', collect=collect)
elif mode not in ('passive', 'active', 'advanced'):
mode = 'advanced' if collect is False or enabled is False else 'unknown'
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}),
(('enable' if mode == 'active' else 'disable') + ' SSID pool broadcasting',
'POST', 'ssidpool/enable' if mode == 'active' else 'ssidpool/disable',
{'enable': mode == 'active'}),
]
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=(mode == 'active'))
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)
if status != 200 or not isinstance(data, dict):
return (502 if status == 0 else status), {'error': 'daemon failed', 'detail': data}
mode = data.get('mode') or 'allow'
if kind == 'client':
entries = data.get('denied_macs') if mode == 'deny' else data.get('allowed_macs')
else:
entries = data.get('denied_ssids') if mode == 'deny' else data.get('allowed_ssids')
values = [str(e) for e in (entries 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):
# Older daemon builds may return literal SSIDs instead.
decoded.append(value)
values = decoded
return 200, {'mode': mode, 'entries': values}
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):
rc, out, err = device_run([HAK5CMD, command] + list(args), timeout=30)
if rc != 0:
return 502, {'error': 'filter command failed', 'detail': err or out}
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_challenge'}
def h_enterprise_data(ctx):
table = ENTERPRISE_TABLES.get((ctx.args or [''])[0])
if not table:
return 400, {'error': 'unknown table'}
rows = _db_rows(RECON_DB, 'SELECT * FROM %s ORDER BY time' % table)
return 200, {'table': table, 'rows': rows or []}
def h_enterprise_clear(ctx):
table = ENTERPRISE_TABLES.get((ctx.body or {}).get('table', ''))
if not table:
return 400, {'error': 'unknown table'}
try:
_db_write(RECON_DB, 'DELETE FROM %s' % table)
except RuntimeError as e:
return 502, {'error': str(e)}
return 200, {'ok': True}
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():
status, data = _payload_daemon('POST', '/api/payloads/portal/updates', {})
if status != 200:
return status, data
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):
return _payload_daemon('POST', '/api/payloads/portal/refresh', {})
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.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 h_logging_system(ctx):
lines = _line_count(ctx, 200)
rc, out, err = device_run(['logread'])
return 200, {'lines': _tail(out, lines)}
PINEAP_LOG = '/var/log/pineapd.log'
def h_logging_pineap(ctx):
lines = _line_count(ctx, 200)
if os.path.isfile(PINEAP_LOG):
with open(PINEAP_LOG, 'r', errors='replace') as f:
return 200, {'lines': _tail(f.read(), lines)}
rc, out, err = device_run(['logread'])
relevant = [l for l in out.splitlines() if 'pineap' in l.lower()]
return 200, {'lines': relevant[-lines:]}
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=''):
rc, out, err = device_run(['uci', 'get', option])
return out.strip() if rc == 0 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/(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/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('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/survey/live', h_recon_survey_live)
ROUTER.add('POST', r'/api/recon/survey/start', h_recon_survey_start)
ROUTER.add('POST', r'/api/recon/survey/stop', h_recon_survey_stop)
ROUTER.add('GET', r'/api/recon/surveys', h_recon_surveys)
ROUTER.add('GET', r'/api/recon/surveys/([^/]+)', h_recon_survey_detail)
ROUTER.add('GET', r'/api/recon/surveys/([^/]+)/download/(csv|json|html)', h_recon_survey_download)
ROUTER.add('DELETE', r'/api/recon/surveys/([^/]+)', h_recon_survey_delete)
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():
time.sleep(1)
_recon_watchdog_tick()
def serve():
threading.Thread(target=live_loop, daemon=True).start()
threading.Thread(target=_recon_watchdog_loop, daemon=True).start()
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)
while True:
conn, addr = sock.accept()
threading.Thread(target=_handle_conn, args=(conn, addr), daemon=True).start()
if __name__ == '__main__':
serve()