#!/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} _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=5): if sqlite3 is not None: conn = sqlite3.connect('file:%s?mode=ro' % db, uri=True) try: cur = conn.execute(sql) cols = [d[0] for d in cur.description] return [dict(zip(cols, row)) for row in cur.fetchall()] finally: conn.close() rc, out, err = device_run([SQLITE_CLI, '-json', '-cmd', '.timeout 5000', db, sql]) attempt = 1 while rc != 0 and any(m in (err or '') for m in SQLITE_BUSY_MSGS) and attempt < _retries: time.sleep(0.3) rc, out, err = device_run([SQLITE_CLI, '-json', '-cmd', '.timeout 5000', db, sql]) attempt += 1 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' def recon_scans_data(limit=50): rows = _db_rows(RECON_DB, 'SELECT s.id, s.time, s.name, ' '(SELECT count(*) FROM wifi_device w WHERE w.scan = s.id) AS devices, ' '(SELECT count(*) FROM ssid a WHERE a.scan = s.id AND a.type = 8) AS aps, ' '(SELECT count(*) FROM handshake h WHERE h.scan = s.id) AS handshakes ' 'FROM scan s ORDER BY s.id DESC LIMIT %d' % limit) return {'scans': [{'id': r['id'], 'time': r['time'], 'name': r.get('name'), 'devices': r['devices'], 'aps': r['aps'], 'handshakes': r['handshakes']} for r in rows]} def recon_scan_data(scan_id): scans = _db_rows(RECON_DB, 'SELECT id, time, name FROM scan WHERE id = %d' % scan_id) if not scans: return None aps = [] for r in _db_rows(RECON_DB, 'SELECT bssid, ssid, hidden, channel, encryption, signal, freq ' 'FROM ssid WHERE scan = %d AND type = 8 AND bssid IS NOT NULL ' 'ORDER BY signal ASC' % scan_id): 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'))}) ap_macs = set() for r in _db_rows(RECON_DB, 'SELECT DISTINCT bssid FROM ssid WHERE scan = %d AND type = 8 AND bssid IS NOT NULL' % scan_id): ap_macs.add((r.get('bssid') or '').strip().upper()) clients = [] for r in _db_rows(RECON_DB, 'SELECT mac, signal, freq, packets FROM wifi_device WHERE scan = %d ORDER BY time ASC' % scan_id): if (r.get('mac') or '').strip().upper() in ap_macs: continue clients.append({'mac': fmt_mac(r.get('mac')), 'signal': r.get('signal'), 'freq': r.get('freq'), 'packets': r.get('packets')}) mac_of = {} for r in _db_rows(RECON_DB, 'SELECT hash, mac FROM wifi_device WHERE scan = %d' % scan_id): mac_of[r['hash']] = fmt_mac(r.get('mac')) handshakes = [] for r in _db_rows(RECON_DB, 'SELECT stahash, aphash, time FROM handshake WHERE scan = %d' % scan_id): handshakes.append({'ap': mac_of.get(r.get('aphash'), '--'), 'client': mac_of.get(r.get('stahash'), '--'), 'time': r.get('time')}) return {'scan': {'id': scans[0]['id'], 'time': scans[0]['time'], 'name': scans[0].get('name')}, 'aps': aps, 'clients': clients, 'handshakes': handshakes} def h_recon_start(ctx): body = {} scan_time = (getattr(ctx, 'body', None) or {}).get('scan_time') if scan_time is not None: body['scan_time'] = int(scan_time) status, data = daemon_sock_call('POST', '/api/pineap/log/recon/start', body=body) if status != 200 or not (data or {}).get('success'): return 502, {'error': 'daemon recon start failed'} _recon_scan_state['active'] = True _recon_scan_state['started'] = time.time() _recon_scan_state['duration'] = int(scan_time) if scan_time is not None else 0 return 200, {'ok': True} def h_recon_stop(ctx): status, data = daemon_sock_call('POST', '/api/pineap/log/recon/stop', body={}) if status != 200 or not (data or {}).get('success'): return 502, {'error': 'daemon recon stop failed'} _recon_scan_state['active'] = False 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(): """The daemon ignores scan_time and scans until stopped, so the webui enforces the requested duration by issuing a stop when the timed scan expires.""" st = _recon_scan_state if st['active'] and st['duration'] > 0 and time.time() - st['started'] >= st['duration']: daemon_sock_call('POST', '/api/pineap/log/recon/stop', body={}) st['active'] = False def h_recon_status(ctx): rows = _db_rows(RECON_DB, 'SELECT MAX(time) AS t FROM scan') last = rows[0]['t'] if rows and rows[0].get('t') is not None else None act = _db_rows(RECON_DB, 'SELECT MAX(time) AS t FROM wifi_device') last_activity = act[0]['t'] if act and act[0].get('t') is not None else last scanning, remaining = _recon_scan_snapshot() 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} 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): return 200, recon_scans_data() 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 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) def _uci_section(section): return _uci_values(section) def h_pineap_wifi_get_ap(ctx): open_cfg = _uci_wifi_iface('wlan0open') radio_cfg = _uci_wifi_iface('radio0') wpa_cfg = _uci_wifi_iface('wlan0wpa') 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]') channel = radio_cfg.get('channel') or '' try: channel = int(channel) except (TypeError, ValueError): channel = None encryption = wpa_cfg.get('encryption') or '' # OpenWrt commonly decorates the key-management value with a cipher # (for example, "psk2+ccmp"). The UI exposes the logical modes, so # normalize the live UCI value to an option the select can represent. if encryption.startswith('psk2'): encryption = 'psk2' elif encryption.startswith('sae'): encryption = 'sae' elif encryption.startswith('owe'): encryption = 'owe' 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': 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', }, 'enterprise': {'enabled': not host.get('pineape_disabled', True)}, 'pool': {'disabled': None, 'collecting': bool(pinecfg.get('autossidpool'))}, } 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') 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 h_pineap_wifi_set_ap(ctx): body = ctx.body or {} configs = [] wpa = body.get('wpa') or {} openap = body.get('open') or {} 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_daemon(method, path, body=None): status, data = daemon_call(method, path, body=body, token=current_token(), timeout=45) if status != 200: return (502 if status == 0 else status), { 'error': 'Pager payload service failed', 'detail': data} 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')) } 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/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/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()