2571 lines
91 KiB
Python
2571 lines
91 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}
|
|
_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()
|