Reconnaissance surveys (GPSD-tethered scan recording), OUI vendor lookup for client/AP tables, survey/report pages, and the matching test_recon.py suite. Co-authored with the recon-feature agent whose work was finished in this checkout.
3881 lines
143 KiB
Python
3881 lines
143 KiB
Python
#!/usr/bin/env python3
|
|
"""Mark VIII backend. Runs on the device's python3-light (stdlib only, no
|
|
urllib/http.server/sqlite3 modules); the sqlite reads fall back to the
|
|
device's sqlite3 CLI."""
|
|
import base64
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import select
|
|
import signal
|
|
import socket
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
import time
|
|
|
|
try:
|
|
import sqlite3
|
|
except ImportError:
|
|
sqlite3 = None
|
|
|
|
SQLITE_CLI = os.environ.get('PAGER_SQLITE_CLI', 'sqlite3')
|
|
|
|
DAEMON_BASE = os.environ.get('PAGER_DAEMON', 'http://127.0.0.1:1471')
|
|
DAEMON_SOCK = os.environ.get('PAGER_DAEMON_SOCK', '/tmp/api.sock')
|
|
AF_UNIX = getattr(socket, 'AF_UNIX', 1)
|
|
RECON_DB = os.environ.get('PAGER_RECON_DB', '/root/recon/recon.db')
|
|
LOOT_HS_DIR = os.environ.get('PAGER_LOOT_HS', '/root/loot/handshakes')
|
|
HAK5CMD = os.environ.get('PAGER_HAK5CMD', '/usr/bin/hak5cmd')
|
|
SESSION_FILE = os.environ.get('PAGER_SESSION_FILE', '/tmp/pagerwebui.session')
|
|
PINEAP_STATE_FILE = os.environ.get(
|
|
'PAGER_PINEAP_STATE_FILE',
|
|
os.path.join(os.environ.get('TMPDIR') or os.environ.get('TEMP') or '/tmp',
|
|
'pagerwebui.pineap-state'))
|
|
WWW_DIR = os.environ.get('PAGER_WWW_DIR',
|
|
os.path.join(os.path.dirname(os.path.abspath(__file__)), 'www'))
|
|
HOST = os.environ.get('PAGER_HOST', '0.0.0.0')
|
|
PORT = int(os.environ.get('PAGER_PORT', '8080'))
|
|
|
|
_recon_scan_state = {'active': False, 'started': 0, 'duration': 0}
|
|
DEFAULT_RECON_DURATION = 30
|
|
_recon_scans_cache = {'db': None, 'updated': 0, 'data': {'scans': []}}
|
|
_recon_status_cache = {
|
|
'db': None, 'updated': 0, 'last_scan': None, 'last_activity': None}
|
|
|
|
SURVEY_DIR = os.environ.get('PAGER_SURVEY_DIR', '/root/loot/recon-surveys')
|
|
WIGLE_DIR = os.environ.get('PAGER_WIGLE_DIR', '/root/loot/wigle')
|
|
GPSD_CONFIG = os.environ.get('PAGER_GPSD_CONFIG', '/etc/config/gpsd')
|
|
GPSD_INIT = os.environ.get('PAGER_GPSD_INIT', '/etc/init.d/gpsd')
|
|
SERIAL_DIR = os.environ.get('PAGER_SERIAL_DIR', '/dev/serial/by-path')
|
|
SURVEY_MAX_SAMPLES = int(os.environ.get('PAGER_SURVEY_MAX_SAMPLES', '1800'))
|
|
SURVEY_SAMPLE_INTERVAL = float(os.environ.get('PAGER_SURVEY_SAMPLE_INTERVAL', '2.0'))
|
|
GPS_CACHE_SECONDS = 5.0
|
|
|
|
_survey_state = {'active': False, 'id': None, 'name': None, 'path': None,
|
|
'started': 0, 'samples': 0, 'last_sample': 0}
|
|
_survey_lock = threading.Lock()
|
|
_gps_cache = {'updated': 0, 'data': None}
|
|
_gps_lock = threading.Lock()
|
|
_payload_runs = {}
|
|
_payload_runs_lock = threading.Lock()
|
|
PAYLOAD_RUN_DIR = os.environ.get('PAGER_PAYLOAD_RUN_DIR', '/tmp/pagerwebui-payload-runs')
|
|
PAYLOAD_ROOTS = tuple(os.path.realpath(path) for path in
|
|
os.environ.get('PAGER_PAYLOAD_ROOTS',
|
|
'/root/payloads:/mmc/root/payloads').split(':') if path)
|
|
SELF_PAYLOAD_DIR = os.path.realpath(os.path.dirname(os.path.abspath(__file__)))
|
|
SELF_PAYLOAD_KEY = os.environ.get('PAGER_SELF_PAYLOAD_KEY', 'user~remote_access~pager-webui')
|
|
|
|
|
|
def device_run(args, timeout=20, input_data=None):
|
|
try:
|
|
p = subprocess.run(args, input=input_data, capture_output=True, timeout=timeout)
|
|
return p.returncode, p.stdout.decode('utf-8', 'replace'), p.stderr.decode('utf-8', 'replace')
|
|
except FileNotFoundError:
|
|
return 127, '', 'not found'
|
|
except subprocess.TimeoutExpired:
|
|
return 124, '', 'timeout'
|
|
|
|
|
|
def _daemon_addr():
|
|
base = DAEMON_BASE.replace('http://', '').split('?', 1)[0]
|
|
if ':' in base:
|
|
host, port = base.rsplit(':', 1)
|
|
return host, int(port)
|
|
return base, 80
|
|
|
|
|
|
def daemon_call(method, path, body=None, token=None, timeout=15):
|
|
host, port = _daemon_addr()
|
|
data = json.dumps(body).encode() if body is not None else None
|
|
lines = ['%s %s HTTP/1.1' % (method, path), 'Host: %s:%d' % (host, port),
|
|
'Accept: application/json', 'Connection: close']
|
|
if data is not None:
|
|
lines.append('Content-Type: application/json')
|
|
lines.append('Content-Length: %d' % len(data))
|
|
if token:
|
|
lines.append('Authorization: Bearer ' + token)
|
|
req = ('\r\n'.join(lines) + '\r\n\r\n').encode('ascii') + (data or b'')
|
|
try:
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
sock.settimeout(timeout)
|
|
sock.connect((host, port))
|
|
sock.sendall(req)
|
|
resp = b''
|
|
while True:
|
|
chunk = sock.recv(65536)
|
|
if not chunk:
|
|
break
|
|
resp += chunk
|
|
sock.close()
|
|
except OSError:
|
|
return 0, None
|
|
head, _, payload = resp.partition(b'\r\n\r\n')
|
|
if not head:
|
|
return 0, None
|
|
try:
|
|
status = int(head.split(b' ', 2)[1])
|
|
except (IndexError, ValueError):
|
|
return 0, None
|
|
headers = {}
|
|
for hline in head.split(b'\r\n')[1:]:
|
|
name, _, value = hline.decode('latin-1').partition(':')
|
|
headers[name.strip().lower()] = value.strip()
|
|
if headers.get('transfer-encoding', '').lower() == 'chunked':
|
|
decoded = bytearray()
|
|
rest = payload
|
|
try:
|
|
while rest:
|
|
size_line, sep, rest = rest.partition(b'\r\n')
|
|
if not sep:
|
|
raise ValueError('missing chunk delimiter')
|
|
size = int(size_line.split(b';', 1)[0], 16)
|
|
if size == 0:
|
|
break
|
|
if len(rest) < size + 2:
|
|
raise ValueError('short chunk')
|
|
decoded.extend(rest[:size])
|
|
rest = rest[size + 2:]
|
|
payload = bytes(decoded)
|
|
except (ValueError, IndexError):
|
|
return status, payload
|
|
if 'json' in headers.get('content-type', ''):
|
|
try:
|
|
return status, json.loads(payload.decode('utf-8', 'replace'))
|
|
except Exception:
|
|
return status, payload
|
|
return status, payload
|
|
|
|
|
|
def daemon_sock_call(method, path, body=None, timeout=10):
|
|
"""Raw HTTP/1.1 request to the daemon's local unix-socket API. Returns (status, json|None)."""
|
|
data = json.dumps(body).encode() if body is not None else None
|
|
lines = ['%s %s HTTP/1.1' % (method, path), 'Host: localhost', 'Connection: close']
|
|
if data is not None:
|
|
lines += ['Content-Type: application/json', 'Content-Length: %d' % len(data)]
|
|
req = ('\r\n'.join(lines) + '\r\n\r\n').encode('ascii') + (data or b'')
|
|
sock = None
|
|
try:
|
|
sock = socket.socket(AF_UNIX, socket.SOCK_STREAM)
|
|
sock.settimeout(timeout)
|
|
sock.connect(DAEMON_SOCK)
|
|
sock.sendall(req)
|
|
resp = b''
|
|
while True:
|
|
chunk = sock.recv(65536)
|
|
if not chunk:
|
|
break
|
|
resp += chunk
|
|
except OSError:
|
|
return 0, None
|
|
finally:
|
|
if sock is not None:
|
|
try:
|
|
sock.close()
|
|
except OSError:
|
|
pass
|
|
head, _, payload = resp.partition(b'\r\n\r\n')
|
|
try:
|
|
status = int(head.split(b'\r\n', 1)[0].split(b' ', 2)[1])
|
|
except (IndexError, ValueError):
|
|
return 0, None
|
|
try:
|
|
data = json.loads(payload)
|
|
except Exception:
|
|
data = None
|
|
return status, data
|
|
|
|
|
|
class Router:
|
|
def __init__(self):
|
|
self.routes = []
|
|
|
|
def add(self, method, pattern, handler):
|
|
self.routes.append((method, re.compile('^' + pattern + '$'), handler))
|
|
|
|
def dispatch(self, method, path):
|
|
for m, rx, h in self.routes:
|
|
if m == method:
|
|
mm = rx.match(path)
|
|
if mm:
|
|
return h, mm.groups()
|
|
return None, None
|
|
|
|
|
|
ROUTER = Router()
|
|
|
|
|
|
def _safe_join(base, rel):
|
|
base = os.path.abspath(base)
|
|
full = os.path.abspath(os.path.join(base, rel))
|
|
if full == base or not full.startswith(base + os.sep):
|
|
return None
|
|
return full
|
|
|
|
|
|
class Download:
|
|
def __init__(self, data, ctype, filename=None):
|
|
self.data = data
|
|
self.ctype = ctype
|
|
self.filename = filename
|
|
|
|
def send(self, handler, status=200):
|
|
handler.send_response(status)
|
|
handler.send_header('Content-Type', self.ctype)
|
|
if self.filename:
|
|
safe = re.sub(r'[\r\n"]', '_', self.filename)
|
|
handler.send_header('Content-Disposition', 'attachment; filename="%s"' % safe)
|
|
handler.send_header('Content-Length', str(len(self.data)))
|
|
handler.send_header('Cache-Control', 'no-cache')
|
|
for name, value in getattr(handler, 'extra_headers', []):
|
|
handler.send_header(name, value)
|
|
handler.end_headers()
|
|
handler.connection.sendall(self.data)
|
|
|
|
|
|
def _unquote_plus(s):
|
|
s = s.replace('+', ' ')
|
|
return re.sub(r'%([0-9A-Fa-f]{2})', lambda m: chr(int(m.group(1), 16)), s)
|
|
|
|
|
|
def _parse_qsl(qs):
|
|
result = []
|
|
for pair in qs.split('&'):
|
|
if not pair:
|
|
continue
|
|
k, _, v = pair.partition('=')
|
|
result.append((_unquote_plus(k), _unquote_plus(v)))
|
|
return result
|
|
|
|
|
|
class _Ctx:
|
|
def __init__(self, handler, groups):
|
|
self.h = handler
|
|
self.args = groups
|
|
self.query = dict(_parse_qsl(handler.path.split('?', 1)[1])) if '?' in handler.path else {}
|
|
|
|
@property
|
|
def cookie(self):
|
|
return self.h.headers.get('cookie', '') or ''
|
|
|
|
@property
|
|
def body(self):
|
|
raw = getattr(self.h, 'body', b'')
|
|
if not raw:
|
|
return {}
|
|
try:
|
|
return json.loads(raw.decode('utf-8'))
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
|
|
|
|
|
|
def ws_handshake_reply(key):
|
|
accept = base64.b64encode(hashlib.sha1((key + WS_GUID).encode('ascii')).digest()).decode('ascii')
|
|
return ('HTTP/1.1 101 Switching Protocols\r\n'
|
|
'Upgrade: websocket\r\n'
|
|
'Connection: Upgrade\r\n'
|
|
'Sec-WebSocket-Accept: ' + accept + '\r\n\r\n').encode('ascii')
|
|
|
|
|
|
def ws_encode(payload, opcode=0x1, mask=False):
|
|
header = bytearray([0x80 | opcode])
|
|
n = len(payload)
|
|
if n < 126:
|
|
header.append(0x80 | n if mask else n)
|
|
elif n < 65536:
|
|
header.append(0x80 | 126 if mask else 126)
|
|
header += struct.pack('>H', n)
|
|
else:
|
|
header.append(0x80 | 127 if mask else 127)
|
|
header += struct.pack('>Q', n)
|
|
if mask:
|
|
mask_bytes = os.urandom(4)
|
|
header += mask_bytes
|
|
payload = bytes(b ^ mask_bytes[i % 4] for i, b in enumerate(payload))
|
|
return bytes(header) + payload
|
|
|
|
|
|
def ws_decode_frame(buf):
|
|
if len(buf) < 2:
|
|
return None, b'', 0
|
|
opcode = buf[0] & 0x0F
|
|
masked = bool(buf[1] & 0x80)
|
|
length = buf[1] & 0x7F
|
|
off = 2
|
|
if length == 126:
|
|
if len(buf) < 4:
|
|
return None, b'', 0
|
|
length = struct.unpack('>H', buf[2:4])[0]
|
|
off = 4
|
|
elif length == 127:
|
|
if len(buf) < 10:
|
|
return None, b'', 0
|
|
length = struct.unpack('>Q', buf[2:10])[0]
|
|
off = 10
|
|
if masked:
|
|
if len(buf) < off + 4:
|
|
return None, b'', 0
|
|
mask = buf[off:off + 4]
|
|
off += 4
|
|
if len(buf) < off + length:
|
|
return None, b'', 0
|
|
payload = buf[off:off + length]
|
|
if masked:
|
|
payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
|
|
return opcode, payload, off + length
|
|
|
|
|
|
def _relay_drain(buf, chunk):
|
|
"""Append chunk to buf, decode complete frames, return (remaining_buf, frames, closed).
|
|
frames is a list of (opcode, payload) for text/binary frames; closed True on a close frame."""
|
|
buf += chunk
|
|
frames = []
|
|
closed = False
|
|
off = 0
|
|
while off < len(buf):
|
|
op, payload, used = ws_decode_frame(buf[off:])
|
|
if used == 0:
|
|
break
|
|
off += used
|
|
if op in (0x1, 0x2):
|
|
frames.append((op, payload))
|
|
elif op == 0x8:
|
|
closed = True
|
|
break
|
|
return buf[off:], frames, closed
|
|
|
|
|
|
class WSPool:
|
|
def __init__(self):
|
|
self.clients = []
|
|
self.lock = threading.Lock()
|
|
|
|
def add(self, sock):
|
|
with self.lock:
|
|
self.clients.append(sock)
|
|
|
|
def remove(self, sock):
|
|
with self.lock:
|
|
if sock in self.clients:
|
|
self.clients.remove(sock)
|
|
|
|
def broadcast(self, payload_bytes):
|
|
dead = []
|
|
with self.lock:
|
|
for c in list(self.clients):
|
|
try:
|
|
c.sendall(payload_bytes)
|
|
except Exception:
|
|
dead.append(c)
|
|
for c in dead:
|
|
if c in self.clients:
|
|
self.clients.remove(c)
|
|
|
|
|
|
WS_POOL = WSPool()
|
|
LIVE_STOP = threading.Event()
|
|
|
|
|
|
def live_loop():
|
|
while not LIVE_STOP.is_set():
|
|
time.sleep(2)
|
|
if not WS_POOL.clients:
|
|
continue
|
|
# status_data() already performs the relatively expensive iwinfo
|
|
# association scan. Reuse that snapshot instead of immediately
|
|
# running the same commands a second time for every live tick.
|
|
status = status_data()
|
|
tick = {'type': 'tick', 'status': status, 'clients': status.get('clients', [])}
|
|
WS_POOL.broadcast(ws_encode(json.dumps(tick).encode()))
|
|
|
|
|
|
_REASONS = {200: 'OK', 204: 'No Content', 400: 'Bad Request', 401: 'Unauthorized',
|
|
403: 'Forbidden', 404: 'Not Found', 500: 'Internal Server Error',
|
|
502: 'Bad Gateway'}
|
|
|
|
|
|
def same_origin(headers):
|
|
"""Allow non-browser clients, but reject browser requests from another origin."""
|
|
host = (headers.get('host', '') or '').strip().lower()
|
|
if not host:
|
|
return False
|
|
source = (headers.get('origin', '') or '').strip()
|
|
if not source:
|
|
source = (headers.get('referer', '') or '').strip()
|
|
if not source:
|
|
return True
|
|
match = re.match(r'^https?://([^/]+)(?:/|$)', source, re.I)
|
|
return bool(match and match.group(1).lower() == host)
|
|
|
|
|
|
class _Headers(dict):
|
|
def _lk(self, key):
|
|
return key.lower() if isinstance(key, str) else key
|
|
|
|
def __getitem__(self, key):
|
|
return dict.__getitem__(self, self._lk(key))
|
|
|
|
def __contains__(self, key):
|
|
return dict.__contains__(self, self._lk(key))
|
|
|
|
def get(self, key, default=None):
|
|
return dict.get(self, self._lk(key), default)
|
|
|
|
|
|
class PagerHandler:
|
|
def __init__(self, conn, addr):
|
|
self.connection = conn
|
|
self.addr = addr
|
|
self.extra_headers = []
|
|
self.close_connection = False
|
|
self.command = None
|
|
self.path = None
|
|
self.headers = {}
|
|
self.body = b''
|
|
self._buf = b''
|
|
|
|
def _readline(self):
|
|
while b'\n' not in self._buf:
|
|
chunk = self.connection.recv(4096)
|
|
if not chunk:
|
|
return None
|
|
self._buf += chunk
|
|
line, self._buf = self._buf.split(b'\n', 1)
|
|
return line.rstrip(b'\r')
|
|
|
|
def read_request(self):
|
|
line = self._readline()
|
|
if line is None or not line:
|
|
return False
|
|
parts = line.decode('latin-1').split(' ')
|
|
if len(parts) != 3:
|
|
return False
|
|
self.command, self.path, version = parts
|
|
headers = _Headers()
|
|
while True:
|
|
h = self._readline()
|
|
if h is None:
|
|
return False
|
|
if not h:
|
|
break
|
|
name, _, value = h.decode('latin-1').partition(':')
|
|
headers[name.strip().lower()] = value.strip()
|
|
self.headers = headers
|
|
try:
|
|
length = int(headers.get('content-length', '0') or '0')
|
|
except ValueError:
|
|
length = 0
|
|
if length > 0:
|
|
while len(self._buf) < length:
|
|
chunk = self.connection.recv(65536)
|
|
if not chunk:
|
|
return False
|
|
self._buf += chunk
|
|
self.body = self._buf[:length]
|
|
self._buf = self._buf[length:]
|
|
else:
|
|
self.body = b''
|
|
conn_tok = headers.get('connection', '').lower()
|
|
if version == 'HTTP/1.0':
|
|
self.close_connection = conn_tok != 'keep-alive'
|
|
else:
|
|
self.close_connection = conn_tok == 'close'
|
|
return True
|
|
|
|
def send_response(self, status, reason=''):
|
|
reason = reason or _REASONS.get(status, '')
|
|
self.connection.sendall(('HTTP/1.1 %d %s\r\n' % (status, reason)).encode('latin-1'))
|
|
|
|
def send_header(self, name, value):
|
|
self.connection.sendall(('%s: %s\r\n' % (name, value)).encode('latin-1'))
|
|
|
|
def end_headers(self):
|
|
self.connection.sendall(b'\r\n')
|
|
|
|
def _dispatch(self):
|
|
if not same_origin(self.headers):
|
|
self._fail(403, 'cross-origin request rejected')
|
|
return
|
|
if self.command == 'OPTIONS':
|
|
self.send_response(204)
|
|
self.send_header('Allow', 'GET, POST, DELETE, OPTIONS')
|
|
self.end_headers()
|
|
return
|
|
self._route(self.command)
|
|
|
|
def add_extra_header(self, name, value):
|
|
if not hasattr(self, 'extra_headers'):
|
|
self.extra_headers = []
|
|
self.extra_headers.append((name, value))
|
|
|
|
def _security_headers(self):
|
|
self.send_header('X-Content-Type-Options', 'nosniff')
|
|
self.send_header('X-Frame-Options', 'DENY')
|
|
self.send_header('Referrer-Policy', 'same-origin')
|
|
self.send_header(
|
|
'Content-Security-Policy',
|
|
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "
|
|
"img-src 'self' data: blob:; connect-src 'self' ws: wss:; "
|
|
"object-src 'none'; base-uri 'none'; frame-ancestors 'none'")
|
|
|
|
def _json(self, obj, status=200, close=False):
|
|
body = json.dumps(obj).encode()
|
|
self.send_response(status)
|
|
self.send_header('Content-Type', 'application/json')
|
|
self.send_header('Content-Length', str(len(body)))
|
|
self.send_header('Cache-Control', 'no-cache')
|
|
self._security_headers()
|
|
if close:
|
|
self.close_connection = True
|
|
self.send_header('Connection', 'close')
|
|
for name, value in getattr(self, 'extra_headers', []):
|
|
self.send_header(name, value)
|
|
self.end_headers()
|
|
self.connection.sendall(body)
|
|
|
|
def _fail(self, status, message):
|
|
self._json({'error': message}, status, close=True)
|
|
|
|
def _serve_static(self, path):
|
|
rel = path.lstrip('/')
|
|
if not rel:
|
|
rel = 'index.html'
|
|
rel = rel.replace('\\', '/')
|
|
if '..' in rel.split('/'):
|
|
return False
|
|
full = _safe_join(WWW_DIR, rel)
|
|
if not full or not os.path.isfile(full):
|
|
return False
|
|
ctype = {
|
|
'.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css',
|
|
'.png': 'image/png', '.svg': 'image/svg+xml', '.json': 'application/json',
|
|
'.map': 'application/json', '.woff2': 'font/woff2',
|
|
}.get(os.path.splitext(full)[1], 'application/octet-stream')
|
|
with open(full, 'rb') as f:
|
|
data = f.read()
|
|
self.send_response(200)
|
|
self.send_header('Content-Type', ctype)
|
|
self.send_header('Content-Length', str(len(data)))
|
|
self.send_header('Cache-Control', 'no-cache')
|
|
self._security_headers()
|
|
self.end_headers()
|
|
self.connection.sendall(data)
|
|
return True
|
|
|
|
def _route(self, method):
|
|
path = self.path.split('?', 1)[0]
|
|
if self.headers.get('Upgrade', '').lower() == 'websocket':
|
|
self._ws_accept()
|
|
return
|
|
handler, groups = ROUTER.dispatch(method, path)
|
|
if handler is None:
|
|
if method == 'GET' and self._serve_static(path):
|
|
return
|
|
self._fail(404, 'not found')
|
|
return
|
|
if method != 'POST' or path != '/api/login':
|
|
if not check_auth(self.headers.get('Cookie', '') or ''):
|
|
self._fail(401, 'unauthorized')
|
|
return
|
|
ctx = _Ctx(self, groups)
|
|
try:
|
|
result = handler(ctx)
|
|
except Exception as e:
|
|
self._fail(500, str(e))
|
|
return
|
|
if result is None:
|
|
return
|
|
status, payload = result
|
|
if isinstance(payload, Download):
|
|
payload.send(self, status)
|
|
else:
|
|
self._json(payload, status)
|
|
|
|
def _ws_accept(self):
|
|
self.close_connection = True
|
|
if not same_origin(self.headers):
|
|
return self._fail(403, 'cross-origin websocket rejected')
|
|
path = self.path.split('?', 1)[0]
|
|
if path == '/api/terminal/openWs':
|
|
return self._ws_relay_daemon('/api/terminal/openWs')
|
|
if path in ('/api/pager/display/screen.ws', '/api/pager/input/keys.ws'):
|
|
return self._ws_relay_daemon(path)
|
|
if path != '/api/ws':
|
|
return self._fail(404, 'not found')
|
|
if not check_auth(self.headers.get('Cookie', '') or ''):
|
|
return self._fail(401, 'unauthorized')
|
|
key = self.headers.get('Sec-WebSocket-Key', '')
|
|
self.connection.sendall(ws_handshake_reply(key))
|
|
WS_POOL.add(self.connection)
|
|
try:
|
|
while True:
|
|
opcode, payload = self._ws_read_frame()
|
|
if opcode is None or opcode == 0x8:
|
|
break
|
|
if opcode == 0x9:
|
|
self.connection.sendall(ws_encode(payload, opcode=0xA))
|
|
finally:
|
|
WS_POOL.remove(self.connection)
|
|
try:
|
|
self.connection.close()
|
|
except OSError:
|
|
pass
|
|
|
|
def _ws_read_frame(self):
|
|
hdr = self._recv_exact(2)
|
|
if hdr is None:
|
|
return None, b''
|
|
length = hdr[1] & 0x7F
|
|
if length == 126:
|
|
ext = self._recv_exact(2)
|
|
if ext is None:
|
|
return None, b''
|
|
length = struct.unpack('>H', ext)[0]
|
|
elif length == 127:
|
|
ext = self._recv_exact(8)
|
|
if ext is None:
|
|
return None, b''
|
|
length = struct.unpack('>Q', ext)[0]
|
|
masked = bool(hdr[1] & 0x80)
|
|
mask = self._recv_exact(4) if masked else b''
|
|
payload = self._recv_exact(length)
|
|
if payload is None:
|
|
return None, b''
|
|
if masked:
|
|
payload = bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
|
|
return hdr[0] & 0x0F, payload
|
|
|
|
def _recv_exact(self, n):
|
|
buf = b''
|
|
while len(buf) < n:
|
|
chunk = self.connection.recv(n - len(buf))
|
|
if not chunk:
|
|
return None
|
|
buf += chunk
|
|
return buf
|
|
|
|
def _ws_relay_daemon(self, daemon_path):
|
|
if not check_auth(self.headers.get('Cookie', '') or ''):
|
|
return self._fail(401, 'unauthorized')
|
|
key = self.headers.get('Sec-WebSocket-Key', '')
|
|
self.connection.sendall(ws_handshake_reply(key))
|
|
daemon_sock, err = _daemon_ws_connect(daemon_path)
|
|
if err:
|
|
try:
|
|
self.connection.sendall(ws_encode(('relay error: ' + err).encode(), opcode=0x1))
|
|
except OSError:
|
|
pass
|
|
try:
|
|
self.connection.close()
|
|
except OSError:
|
|
pass
|
|
return
|
|
daemon_sock.setblocking(False)
|
|
import select
|
|
buf = b''
|
|
try:
|
|
while True:
|
|
rlist, _, _ = select.select([self.connection, daemon_sock], [], [], 5)
|
|
for s in rlist:
|
|
if s is daemon_sock:
|
|
try:
|
|
chunk = daemon_sock.recv(65536)
|
|
except (BlockingIOError, InterruptedError):
|
|
continue
|
|
if not chunk:
|
|
return
|
|
# daemon frames may be masked per its own framing; decode then forward unmasked
|
|
buf, frames, closed = _relay_drain(buf, chunk)
|
|
for op, payload in frames:
|
|
self.connection.sendall(ws_encode(payload, opcode=op))
|
|
if closed:
|
|
return
|
|
else:
|
|
opcode, payload = self._ws_read_frame()
|
|
if opcode is None or opcode == 0x8:
|
|
return
|
|
if opcode in (0x1, 0x2, 0x9):
|
|
daemon_sock.sendall(ws_encode(payload, opcode=opcode, mask=True))
|
|
finally:
|
|
try:
|
|
daemon_sock.close()
|
|
except OSError:
|
|
pass
|
|
try:
|
|
self.connection.close()
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def load_session():
|
|
try:
|
|
os.chmod(SESSION_FILE, 0o600)
|
|
with open(SESSION_FILE) as f:
|
|
return json.load(f)
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
def save_session(session):
|
|
tmp = SESSION_FILE + '.tmp'
|
|
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
with os.fdopen(fd, 'w') as f:
|
|
json.dump(session, f)
|
|
os.chmod(tmp, 0o600)
|
|
os.replace(tmp, SESSION_FILE)
|
|
os.chmod(SESSION_FILE, 0o600)
|
|
|
|
|
|
def current_token():
|
|
return load_session().get('token', '')
|
|
|
|
|
|
def current_serverid():
|
|
return load_session().get('serverid', '')
|
|
|
|
|
|
def check_auth(cookie_header):
|
|
if not cookie_header:
|
|
return False
|
|
session = load_session()
|
|
serverid = session.get('serverid', '')
|
|
token = session.get('token', '')
|
|
if not serverid or not token:
|
|
return False
|
|
expected = 'AUTH_%s=%s' % (serverid, token)
|
|
for entry in cookie_header.split(';'):
|
|
if entry.strip() == expected:
|
|
return True
|
|
return False
|
|
|
|
|
|
def h_login(ctx):
|
|
username = (ctx.body or {}).get('username', '')
|
|
password = (ctx.body or {}).get('password', '')
|
|
status, data = daemon_call('POST', '/api/login', body={'username': username, 'password': password})
|
|
if status != 200 or not isinstance(data, dict) or 'token' not in data:
|
|
return 401, {'error': 'login failed'}
|
|
token = data['token']
|
|
pstatus, ping = daemon_call('GET', '/api/api_ping', token=token)
|
|
serverid = ping.get('serverid', '') if isinstance(ping, dict) else ''
|
|
save_session({'serverid': serverid, 'token': token, 'created': int(time.time())})
|
|
ctx.h.add_extra_header('Set-Cookie', 'AUTH_%s=%s; Path=/; HttpOnly; SameSite=Strict' % (serverid, token))
|
|
return 200, {'ok': True, 'serverid': serverid}
|
|
|
|
|
|
def h_logout(ctx):
|
|
serverid = current_serverid()
|
|
try:
|
|
os.unlink(SESSION_FILE)
|
|
except OSError:
|
|
pass
|
|
cookie_name = 'AUTH_%s' % serverid if serverid else 'AUTH'
|
|
ctx.h.add_extra_header(
|
|
'Set-Cookie', '%s=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0' % cookie_name)
|
|
return 200, {'ok': True}
|
|
|
|
|
|
def h_api_ping(ctx):
|
|
token = current_token()
|
|
status, data = daemon_call('GET', '/api/api_ping', token=token)
|
|
if status != 200 or not isinstance(data, dict):
|
|
return 502, {'error': 'daemon unreachable'}
|
|
return 200, data
|
|
|
|
|
|
def battery_data(power_supply='/sys/class/power_supply'):
|
|
try:
|
|
for name in sorted(os.listdir(power_supply)):
|
|
try:
|
|
with open(os.path.join(power_supply, name, 'type')) as f:
|
|
typ = f.read().strip()
|
|
except OSError:
|
|
continue
|
|
if typ != 'Battery':
|
|
continue
|
|
level = None
|
|
cap = os.path.join(power_supply, name, 'capacity')
|
|
if os.path.exists(cap):
|
|
try:
|
|
with open(cap) as f:
|
|
level = int(f.read().strip())
|
|
except ValueError:
|
|
level = None
|
|
charging = False
|
|
st = os.path.join(power_supply, name, 'status')
|
|
if os.path.exists(st):
|
|
try:
|
|
with open(st) as f:
|
|
charging = 'Charg' in f.read()
|
|
except OSError:
|
|
charging = False
|
|
return {'level': level, 'charging': charging}
|
|
except OSError:
|
|
pass
|
|
return {'level': None, 'charging': False}
|
|
|
|
|
|
def wifi_ifaces():
|
|
rc, out, err = device_run(['iwinfo'])
|
|
names = []
|
|
for line in out.splitlines():
|
|
m = re.match(r'^(\S+)\s+', line)
|
|
if m and (m.group(1).startswith('wlan') or m.group(1).startswith('radio')):
|
|
names.append(m.group(1))
|
|
return names
|
|
|
|
|
|
def wifi_iface_info(name):
|
|
rc, out, err = device_run(['iwinfo', name, 'info'])
|
|
info = {'iface': name}
|
|
for line in out.splitlines():
|
|
m = re.search(r'ESSID:\s*"([^"]*)"', line)
|
|
if m:
|
|
info['ssid'] = m.group(1)
|
|
m = re.search(r'Mode:\s*(\S+)', line)
|
|
if m:
|
|
info['mode'] = m.group(1)
|
|
m = re.search(r'Channel:\s*(\d+)', line)
|
|
if m:
|
|
info['channel'] = int(m.group(1))
|
|
m = re.search(r'Link Quality:\s*(\d+)/(\d+)', line)
|
|
if m:
|
|
info['quality'] = {'signal': int(m.group(1)), 'max': int(m.group(2))}
|
|
return info
|
|
|
|
|
|
MAC_RE = re.compile(r'^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$')
|
|
|
|
|
|
def normalize_mac(mac):
|
|
mac = (mac or '').strip().upper()
|
|
return mac if MAC_RE.match(mac) else None
|
|
|
|
|
|
def h_clients(ctx):
|
|
clients = assoc_clients()
|
|
return 200, {'clients': clients, 'count': len(clients)}
|
|
|
|
|
|
def h_client_kick(ctx):
|
|
mac = normalize_mac((ctx.body or {}).get('mac'))
|
|
if not mac:
|
|
return 400, {'error': 'invalid mac'}
|
|
hak5('PINEAPPLE_DEVICE_FILTER_MODE', 'deny')
|
|
hak5('PINEAPPLE_DEVICE_FILTER_ADD', 'deny', mac)
|
|
hak5('PINEAPPLE_DEAUTH_CLIENT', mac)
|
|
return 200, {'ok': True}
|
|
|
|
|
|
def h_deauth_client(ctx):
|
|
mac = normalize_mac((ctx.body or {}).get('mac'))
|
|
if not mac:
|
|
return 400, {'error': 'invalid mac'}
|
|
hak5('PINEAPPLE_DEAUTH_CLIENT', mac)
|
|
return 200, {'ok': True}
|
|
|
|
|
|
SQLITE_BUSY_MSGS = ('database is locked', 'database is busy')
|
|
|
|
|
|
def _db_rows(db, sql, _retries=1, timeout=20):
|
|
if sqlite3 is not None:
|
|
try:
|
|
conn = sqlite3.connect('file:%s?mode=ro' % db, uri=True)
|
|
try:
|
|
cur = conn.execute(sql)
|
|
cols = [d[0] for d in cur.description]
|
|
return [dict(zip(cols, row)) for row in cur.fetchall()]
|
|
finally:
|
|
conn.close()
|
|
except sqlite3.Error as exc:
|
|
raise RuntimeError('sqlite read failed: %s' % exc)
|
|
rc, out, err = device_run([SQLITE_CLI, '-json', '-cmd', '.timeout 500', db, sql],
|
|
timeout=timeout)
|
|
attempt = 1
|
|
while rc != 0 and any(m in (err or '') for m in SQLITE_BUSY_MSGS) and attempt < _retries:
|
|
time.sleep(0.3)
|
|
rc, out, err = device_run([SQLITE_CLI, '-json', '-cmd', '.timeout 500', db, sql],
|
|
timeout=timeout)
|
|
attempt += 1
|
|
st = _recon_scan_state
|
|
elapsed = time.time() - st['started'] if st['started'] else 0
|
|
completed_here = st['duration'] > 0 and elapsed >= st['duration'] + 2
|
|
if (rc != 0 and any(m in (err or '') for m in SQLITE_BUSY_MSGS)
|
|
and db == RECON_DB and (not st['active'] or completed_here)):
|
|
# Pager firmware leaves a stale exclusive lock after native completion.
|
|
# The file is stable by this point, so bypass that stale lock read-only.
|
|
immutable_db = 'file:%s?immutable=1' % db
|
|
rc, out, err = device_run(
|
|
[SQLITE_CLI, '-json', immutable_db, sql], timeout=timeout)
|
|
if rc != 0:
|
|
raise RuntimeError('sqlite read failed: %s' % (err or out).strip())
|
|
if out.strip():
|
|
return json.loads(out)
|
|
return []
|
|
|
|
|
|
def fmt_mac(raw):
|
|
"""AE77C0EB3141 -> AE:77:C0:EB:31:41; anything else passes through (None/'' -> '--')."""
|
|
raw = (raw or '').strip()
|
|
if len(raw) == 12 and all(c in '0123456789ABCDEFabcdef' for c in raw):
|
|
return ':'.join(raw[i:i + 2] for i in range(0, 12, 2))
|
|
return raw or '--'
|
|
|
|
|
|
def decode_ssid(raw):
|
|
if raw is None:
|
|
return ''
|
|
if isinstance(raw, str):
|
|
if '\\x' in raw:
|
|
out = bytearray()
|
|
i = 0
|
|
n = len(raw)
|
|
while i < n:
|
|
if (raw[i] == '\\' and i + 3 < n and raw[i + 1] == 'x'
|
|
and all(c in '0123456789abcdefABCDEF' for c in raw[i + 2:i + 4])):
|
|
out.append(int(raw[i + 2:i + 4], 16))
|
|
i += 4
|
|
else:
|
|
out.extend(raw[i].encode('utf-8', 'replace'))
|
|
i += 1
|
|
raw = bytes(out)
|
|
else:
|
|
return raw
|
|
try:
|
|
return raw.decode('utf-8', 'replace')
|
|
except Exception:
|
|
return raw.decode('latin-1', 'replace')
|
|
|
|
|
|
ENC_WEP = 0x01 | 0x02
|
|
ENC_TKIP = 0x04
|
|
ENC_CCMP = 0x08
|
|
ENC_GCMP = 0x20
|
|
ENC_GCMP256 = 0x80
|
|
ENC_CCMP256 = 0x100
|
|
|
|
|
|
def decode_encryption(v):
|
|
"""Pager recon.db encryption bitfield -> old-UI-style display string."""
|
|
v = v or 0
|
|
if v == 0:
|
|
return 'Open'
|
|
parts = []
|
|
if v & (ENC_GCMP256 | ENC_CCMP256):
|
|
parts.append('WPA3')
|
|
if v & (ENC_CCMP | ENC_GCMP):
|
|
parts.append('WPA2')
|
|
if v & ENC_TKIP:
|
|
parts.append('WPA')
|
|
if v & ENC_WEP:
|
|
parts.append('WEP')
|
|
return ' '.join(parts) if parts else 'Open'
|
|
|
|
|
|
# Compact OUI -> vendor table (24-bit prefix, hex without colons). Covers the
|
|
# vendors most commonly seen in the field; everything else resolves to
|
|
# 'Unknown'. Locally administered MACs resolve to 'Local'.
|
|
OUI_VENDORS = {
|
|
'00000C': 'Cisco', '000393': 'Apple', '000625': 'Linksys', '000C42': 'MikroTik',
|
|
'000DB9': 'Intel', '001376': 'MikroTik', '0016CB': 'Apple', '0017F2': 'Apple',
|
|
'001BFC': 'ASUS', '001E10': 'Huawei', '0025C7': 'Apple', '00500B': 'HP',
|
|
'0050F2': 'Micro-Star', '00606E': 'Xerox', '00A0C9': 'Intel', '00C0CA': 'Xerox',
|
|
'040CCE': 'Apple', '041854': 'Ubiquiti', '04ED33': 'Apple', '04F13E': 'Apple',
|
|
'04F938': 'Apple', '080007': 'Apple', '080028': 'Texas Instruments',
|
|
'080046': 'Sony', '083E8E': 'Apple', '089E01': 'Apple', '0C74C2': 'Apple',
|
|
'0C96BF': 'Huawei', '107BEF': 'Huawei', '10A2DC': 'Apple', '10BF48': 'Apple',
|
|
'1425BE': 'Huawei', '147A19': 'Apple', '1499E2': 'Apple', '14AC3C': 'Apple',
|
|
'14CC20': 'TP-Link', '181D86': 'Apple', '1C5C55': 'Apple', '1CE1A7': 'TP-Link',
|
|
'20010F': 'Apple', '20620B': 'Apple', '240AC4': 'Espressif', '241F4A': 'Apple',
|
|
'24A0DF': 'Apple', '24A43C': 'Ubiquiti', '24ABC0': 'Apple', '24B6FD': 'Espressif',
|
|
'247189': 'Espressif', '28CDC1': 'Raspberry Pi', '28CFE9': 'Apple',
|
|
'2C6E85': 'Apple', '2C7E81': 'Apple', '2CCF67': 'Raspberry Pi', '300C23': 'Apple',
|
|
'30720B': 'Apple', '3402E5': 'Apple', '3478D7': 'Apple', '381020': 'Apple',
|
|
'3C0754': 'Apple', '3C2177': 'TP-Link', '3C71BF': 'Espressif', '3C99F7': 'ASUS',
|
|
'3CD16E': 'Apple', '4006A0': 'Apple', '4083DE': 'Apple', '40B076': 'ASUS',
|
|
'40D3AE': 'Apple', '443212': 'Apple', '44C9A2': 'Apple', '44D9E7': 'Ubiquiti',
|
|
'48BF6B': 'Apple', '48C04E': 'Apple', '4C5E0C': 'MikroTik', '502D21': 'Apple',
|
|
'50C7BF': 'TP-Link', '54843B': 'Apple', '549392': 'ASUS', '54E43A': 'Apple',
|
|
'58B0D4': 'Apple', '586A97': 'TP-Link', '5C961D': 'Apple', '5CE1A1': 'Apple',
|
|
'603C07': 'Apple', '6083B2': 'Apple', '60D9C7': 'TP-Link', '640094': 'Apple',
|
|
'640980': 'TP-Link', '64167F': 'MikroTik', '649EF3': 'Apple', '6C0486': 'TP-Link',
|
|
'6C3B6B': 'MikroTik', '6C4008': 'Apple', '6CD68A': 'Apple', '70A2B3': 'Apple',
|
|
'742AF0': 'Apple', '748898': 'MikroTik', '74C46B': 'Apple', '782B1D': 'Apple',
|
|
'7847A6': 'Apple', '78CA39': 'Apple', '78E3B5': 'Ubiquiti', '7C0191': 'Apple',
|
|
'7CD1C3': 'Apple', '802AA8': 'Ubiquiti', '80BE05': 'Apple', '847C9B': 'Apple',
|
|
'84F3EB': 'Espressif', '88D42A': 'Apple', '8C3AF4': 'Apple', '8C7B9D': 'Apple',
|
|
'8CDEF9': 'Xiaomi', '90039F': 'Apple', '90B21F': 'Apple', '90F652': 'TP-Link',
|
|
'94099B': 'Apple', '98D6BB': 'Apple', '9CD24B': 'Ubiquiti', 'A01828': 'Apple',
|
|
'A020A6': 'Xiaomi', 'A05E6B': 'Apple', 'A07591': 'TP-Link', 'A41F72': 'Apple',
|
|
'A4B197': 'Apple', 'A4CF12': 'Espressif', 'A86484': 'Apple', 'ACBC32': 'Apple',
|
|
'B0DA00': 'Apple', 'B4E1EB': 'Apple', 'B827EB': 'Raspberry Pi',
|
|
'B8E45B': 'Raspberry Pi', 'BCA834': 'Apple', 'C03F0E': 'TP-Link',
|
|
'C04A00': 'Apple', 'C05E06': 'Apple', 'C08C60': 'Apple', 'C0E422': 'Apple',
|
|
'C45F5E': 'Espressif', 'C46516': 'Apple', 'C47D4F': 'Apple', 'C85B76': 'Apple',
|
|
'C8B5B7': 'Apple', 'C89A00': 'Apple', 'CC08E0': 'Apple', 'CC25EF': 'Apple',
|
|
'CC3D82': 'Apple', 'CCC73B': 'Apple', 'D0E140': 'Apple', 'D4154F': 'TP-Link',
|
|
'D4CA6D': 'Apple', 'D8A25E': 'Apple', 'DCA632': 'Raspberry Pi',
|
|
'DC6DCD': 'Apple', 'E03005': 'Apple', 'E45F01': 'Raspberry Pi',
|
|
'E45610': 'Apple', 'E4E4AB': 'Apple', 'E8802E': 'TP-Link', 'E8988F': 'Espressif',
|
|
'E8F2E2': 'Apple', 'EC8EB5': 'TP-Link', 'F0175E': 'Apple', 'F0D5BF': 'Apple',
|
|
'F4044C': 'Apple', 'F49BA0': 'Xiaomi', 'F4E97D': 'Apple', 'F8FFC2': 'Apple',
|
|
'FC633E': 'Google', 'FCF080': 'Apple',
|
|
}
|
|
|
|
|
|
def _oui_prefix(mac):
|
|
"""'C8:9E:43:64:80:80' / 'C89E43648080' -> 'C89E43' (uppercase, no colons)."""
|
|
mac = (mac or '').strip().upper().replace(':', '').replace('-', '').replace('.', '')
|
|
if len(mac) >= 6 and all(c in '0123456789ABCDEF' for c in mac[:6]):
|
|
return mac[:6]
|
|
return None
|
|
|
|
|
|
def oui_vendor(mac):
|
|
"""Best-effort vendor name for a MAC. Locally administered -> 'Local'."""
|
|
if not mac or mac == '--':
|
|
return 'Unknown'
|
|
prefix = _oui_prefix(mac)
|
|
if prefix is None:
|
|
return 'Unknown'
|
|
if int(prefix[1], 16) & 2: # locally administered (second hex digit bit 1)
|
|
return 'Local'
|
|
return OUI_VENDORS.get(prefix, 'Unknown')
|
|
|
|
|
|
def band_of(freq):
|
|
"""Channel frequency (MHz) -> '2.4' | '5' | '6' | '--'."""
|
|
if freq is None:
|
|
return '--'
|
|
try:
|
|
freq = int(freq)
|
|
except (TypeError, ValueError):
|
|
return '--'
|
|
if freq <= 0:
|
|
return '--'
|
|
if freq < 2500:
|
|
return '2.4'
|
|
if freq < 6000:
|
|
return '5'
|
|
return '6'
|
|
|
|
|
|
|
|
def recon_scans_data(limit=50, _timeout=20):
|
|
rows = _db_rows(RECON_DB,
|
|
'WITH recent AS (SELECT id, time, name FROM scan ORDER BY id DESC LIMIT %d), '
|
|
'devices AS (SELECT scan, count(*) AS devices FROM wifi_device '
|
|
'WHERE scan IN (SELECT id FROM recent) GROUP BY scan), '
|
|
'aps AS (SELECT scan, count(*) AS aps FROM ssid '
|
|
'WHERE type = 8 AND scan IN (SELECT id FROM recent) GROUP BY scan), '
|
|
'captures AS (SELECT scan, count(*) AS handshakes FROM handshake '
|
|
'WHERE scan IN (SELECT id FROM recent) GROUP BY scan) '
|
|
'SELECT s.id, s.time, s.name, '
|
|
'COALESCE(w.devices, 0) AS devices, '
|
|
'COALESCE(a.aps, 0) AS aps, '
|
|
'COALESCE(h.handshakes, 0) AS handshakes '
|
|
'FROM recent s '
|
|
'LEFT JOIN devices w ON w.scan = s.id '
|
|
'LEFT JOIN aps a ON a.scan = s.id '
|
|
'LEFT JOIN captures h ON h.scan = s.id '
|
|
'ORDER BY s.id DESC' % limit, timeout=_timeout)
|
|
return {'scans': [{'id': r['id'], 'time': r['time'], 'name': r.get('name'),
|
|
'devices': r['devices'], 'aps': r['aps'],
|
|
'handshakes': r['handshakes']} for r in rows]}
|
|
|
|
|
|
def recon_scan_data(scan_id, _timeout=20, _limit=None):
|
|
"""Scan detail with per-AP enrichment (band/vendor/first_seen/last_seen)
|
|
plus an unassociated count.
|
|
|
|
With `_limit`, client rows are capped and the unassociated rows collapse to
|
|
a count. The live survey view never renders the full client list, and the
|
|
unassociated/device branches dominate query time on slow recon dbs, so the
|
|
bounded mode keeps the 2s survey poll cheap.
|
|
"""
|
|
if _limit:
|
|
sql = ("WITH dev AS (SELECT hash, time, mac, signal, freq, packets "
|
|
"FROM wifi_device WHERE scan = %d LIMIT %d) "
|
|
"SELECT 'scan' AS kind, id AS row_id, time, name, "
|
|
"NULL AS mac, NULL AS bssid, NULL AS ssid, NULL AS hidden, "
|
|
"NULL AS channel, NULL AS encryption, NULL AS signal, NULL AS freq, "
|
|
"NULL AS packets, NULL AS stahash, NULL AS aphash "
|
|
"FROM scan WHERE id = %d "
|
|
"UNION ALL SELECT 'ap', hash, time, NULL, NULL, bssid, ssid, hidden, "
|
|
"channel, encryption, signal, freq, NULL, NULL, NULL "
|
|
"FROM ssid WHERE scan = %d AND type = 8 AND bssid IS NOT NULL "
|
|
"UNION ALL SELECT 'device', hash, time, NULL, mac, NULL, NULL, NULL, "
|
|
"NULL, NULL, signal, freq, packets, NULL, NULL "
|
|
"FROM dev "
|
|
"UNION ALL SELECT 'handshake', hash, time, NULL, NULL, NULL, NULL, NULL, "
|
|
"NULL, NULL, NULL, NULL, NULL, stahash, aphash "
|
|
"FROM handshake WHERE scan = %d" % (scan_id, _limit, scan_id, scan_id, scan_id))
|
|
rows = _db_rows(RECON_DB, sql, timeout=_timeout)
|
|
cnt = _db_rows(RECON_DB, 'SELECT count(*) AS c FROM ssid WHERE scan = %d AND type = 4'
|
|
% scan_id, timeout=_timeout)
|
|
unassociated = cnt[0]['c'] if cnt else 0
|
|
else:
|
|
rows = _db_rows(RECON_DB,
|
|
"SELECT 'scan' AS kind, id AS row_id, time, name, "
|
|
"NULL AS mac, NULL AS bssid, NULL AS ssid, NULL AS hidden, "
|
|
"NULL AS channel, NULL AS encryption, NULL AS signal, NULL AS freq, "
|
|
"NULL AS packets, NULL AS stahash, NULL AS aphash "
|
|
"FROM scan WHERE id = %d "
|
|
"UNION ALL SELECT 'ap', hash, time, NULL, NULL, bssid, ssid, hidden, "
|
|
"channel, encryption, signal, freq, NULL, NULL, NULL "
|
|
"FROM ssid WHERE scan = %d AND type = 8 AND bssid IS NOT NULL "
|
|
"UNION ALL SELECT 'unassociated', hash, time, NULL, NULL, NULL, ssid, "
|
|
"hidden, channel, encryption, signal, freq, NULL, NULL, NULL "
|
|
"FROM ssid WHERE scan = %d AND type = 4 "
|
|
"UNION ALL SELECT 'device', hash, time, NULL, mac, NULL, NULL, NULL, "
|
|
"NULL, NULL, signal, freq, packets, NULL, NULL "
|
|
"FROM wifi_device WHERE scan = %d "
|
|
"UNION ALL SELECT 'handshake', hash, time, NULL, NULL, NULL, NULL, NULL, "
|
|
"NULL, NULL, NULL, NULL, NULL, stahash, aphash "
|
|
"FROM handshake WHERE scan = %d" % (scan_id, scan_id, scan_id, scan_id, scan_id),
|
|
timeout=_timeout)
|
|
unassociated = sum(1 for r in rows if r.get('kind') == 'unassociated')
|
|
scans = [r for r in rows if r.get('kind') == 'scan']
|
|
if not scans:
|
|
return None
|
|
# Per-bssid first/last sighting across the scan's ssid rows.
|
|
seen = {}
|
|
for r in (row for row in rows if row.get('kind') == 'ap'):
|
|
mac = (r.get('bssid') or '').strip().upper()
|
|
t = r.get('time') or 0
|
|
lo, hi = seen.get(mac, (None, None))
|
|
seen[mac] = (t if lo is None else min(lo, t), t if hi is None else max(hi, t))
|
|
aps = []
|
|
ap_macs = set()
|
|
for r in (row for row in rows if row.get('kind') == 'ap'):
|
|
mac = (r.get('bssid') or '').strip().upper()
|
|
ap_macs.add(mac)
|
|
lo, hi = seen.get(mac, (None, None))
|
|
aps.append({'bssid': fmt_mac(r.get('bssid')),
|
|
'ssid': decode_ssid(r.get('ssid')),
|
|
'hidden': bool(r.get('hidden')),
|
|
'channel': r.get('channel'),
|
|
'signal': r.get('signal'),
|
|
'freq': r.get('freq'),
|
|
'encryption': decode_encryption(r.get('encryption')),
|
|
'band': band_of(r.get('freq')),
|
|
'vendor': oui_vendor(fmt_mac(r.get('bssid'))),
|
|
'first_seen': lo,
|
|
'last_seen': hi})
|
|
aps.sort(key=lambda row: row['signal'] if row['signal'] is not None else 0)
|
|
devices = [r for r in rows if r.get('kind') == 'device']
|
|
clients = []
|
|
for r in sorted(devices, key=lambda row: row.get('time') or 0):
|
|
if (r.get('mac') or '').strip().upper() in ap_macs:
|
|
continue
|
|
clients.append({'mac': fmt_mac(r.get('mac')), 'signal': r.get('signal'),
|
|
'freq': r.get('freq'), 'packets': r.get('packets')})
|
|
mac_of = {r['row_id']: fmt_mac(r.get('mac')) for r in devices}
|
|
handshakes = []
|
|
for r in (row for row in rows if row.get('kind') == 'handshake'):
|
|
handshakes.append({'ap': mac_of.get(r.get('aphash'), '--'),
|
|
'client': mac_of.get(r.get('stahash'), '--'),
|
|
'time': r.get('time')})
|
|
return {'scan': {'id': scans[0]['row_id'], 'time': scans[0]['time'],
|
|
'name': scans[0].get('name')},
|
|
'aps': aps, 'clients': clients, 'handshakes': handshakes,
|
|
'unassociated': unassociated}
|
|
|
|
|
|
def h_recon_start(ctx):
|
|
scan_time = (getattr(ctx, 'body', None) or {}).get(
|
|
'scan_time', DEFAULT_RECON_DURATION)
|
|
try:
|
|
scan_time = int(scan_time)
|
|
except (TypeError, ValueError):
|
|
return 400, {'error': 'scan_time must be an integer'}
|
|
if scan_time < 1 or scan_time > 86400:
|
|
return 400, {'error': 'scan_time is out of range'}
|
|
body = {'scan_time': scan_time}
|
|
# log/recon/start restarts the recon logger and can rotate the existing
|
|
# database. recon/new is the Pager's native "start another scan" action and
|
|
# appends a scan without discarding history.
|
|
status, data = daemon_sock_call('POST', '/api/pineap/recon/new', body=body)
|
|
if status != 200 or not (data or {}).get('success'):
|
|
return 502, {'error': 'native recon scan failed', 'detail': data}
|
|
_recon_scan_state['active'] = True
|
|
_recon_scan_state['started'] = time.time()
|
|
_recon_scan_state['duration'] = scan_time
|
|
return 200, {'ok': True}
|
|
|
|
|
|
def h_recon_stop(ctx):
|
|
scanning, remaining = _recon_scan_snapshot()
|
|
if scanning:
|
|
return 409, {
|
|
'error': 'Pager firmware cannot stop a recon scan safely; '
|
|
'this scan will finish automatically',
|
|
'scan_remaining': remaining,
|
|
}
|
|
return 200, {'ok': True}
|
|
|
|
|
|
def _recon_scan_snapshot():
|
|
st = _recon_scan_state
|
|
if not st['active']:
|
|
return False, None
|
|
elapsed = time.time() - st['started']
|
|
if st['duration'] > 0 and elapsed >= st['duration']:
|
|
return False, 0
|
|
remaining = None if st['duration'] == 0 else int(st['duration'] - elapsed)
|
|
return True, remaining
|
|
|
|
|
|
def _recon_watchdog_tick():
|
|
"""Clear the UI timer when the native timed scan reaches its duration.
|
|
|
|
The Pager has no recon-stop operation. log/recon/stop controls the storage
|
|
service and leaves recon.db locked, so timed scans must end natively.
|
|
Also records a survey sample when a survey is active.
|
|
"""
|
|
st = _recon_scan_state
|
|
if st['active'] and st['duration'] > 0 and time.time() - st['started'] >= st['duration']:
|
|
st['active'] = False
|
|
try:
|
|
_survey_sample()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def h_recon_status(ctx):
|
|
scanning, remaining = _recon_scan_snapshot()
|
|
cache = _recon_status_cache
|
|
if cache['db'] != RECON_DB:
|
|
cache.update({'db': RECON_DB, 'updated': 0,
|
|
'last_scan': None, 'last_activity': None})
|
|
stale = False
|
|
try:
|
|
rows = _db_rows(RECON_DB,
|
|
'SELECT (SELECT MAX(time) FROM scan) AS last_scan, '
|
|
'(SELECT MAX(time) FROM wifi_device) AS last_activity')
|
|
if rows:
|
|
cache['last_scan'] = rows[0].get('last_scan')
|
|
cache['last_activity'] = rows[0].get('last_activity')
|
|
cache['updated'] = time.time()
|
|
except RuntimeError:
|
|
stale = True
|
|
last = cache['last_scan']
|
|
last_activity = cache['last_activity']
|
|
if last_activity is None:
|
|
last_activity = last
|
|
return 200, {'last_scan': last, 'last_activity': last_activity,
|
|
'active': last_activity is not None and int(time.time()) - last_activity < 300,
|
|
'scanning': scanning, 'scan_remaining': remaining, 'stale': stale}
|
|
|
|
|
|
def _db_write(db, sql):
|
|
if sqlite3 is not None:
|
|
conn = sqlite3.connect(db)
|
|
try:
|
|
conn.execute(sql)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
return
|
|
rc, out, err = device_run([SQLITE_CLI, '-cmd', '.timeout 5000', db, sql])
|
|
attempt = 1
|
|
while rc != 0 and any(m in (err or '') for m in SQLITE_BUSY_MSGS) and attempt < 5:
|
|
time.sleep(0.3)
|
|
rc, out, err = device_run([SQLITE_CLI, '-cmd', '.timeout 5000', db, sql])
|
|
attempt += 1
|
|
if rc != 0:
|
|
raise RuntimeError('sqlite write failed: %s' % (err or out).strip())
|
|
|
|
|
|
RECON_CHILD_TABLES = ['wifi_device', 'ssid', 'handshake', 'hostap_chalresp',
|
|
'hostap_basic', 'hostap_client', 'hostap_handshake']
|
|
|
|
|
|
def recon_delete_scan(scan_id):
|
|
for t in RECON_CHILD_TABLES:
|
|
try:
|
|
_db_write(RECON_DB, 'DELETE FROM %s WHERE scan = %d' % (t, scan_id))
|
|
except Exception:
|
|
continue
|
|
_db_write(RECON_DB, 'DELETE FROM scan WHERE id = %d' % scan_id)
|
|
|
|
|
|
def h_recon_delete(ctx):
|
|
scan_id = int(ctx.args[0])
|
|
if not _db_rows(RECON_DB, 'SELECT id FROM scan WHERE id = %d' % scan_id):
|
|
return 404, {'error': 'scan not found'}
|
|
recon_delete_scan(scan_id)
|
|
return 200, {'ok': True}
|
|
|
|
|
|
def h_recon_scan_download(ctx):
|
|
scan_id = int(ctx.args[0])
|
|
data = recon_scan_data(scan_id)
|
|
if data is None:
|
|
return 404, {'error': 'scan not found'}
|
|
return 200, Download(json.dumps(data, indent=2).encode('utf-8'),
|
|
'application/json', 'scan-%d.json' % scan_id)
|
|
|
|
|
|
def recon_events_data(limit=200):
|
|
sql = ("SELECT time, 'auth attempt' AS type FROM hostap_basic WHERE time IS NOT NULL "
|
|
"UNION ALL SELECT time, 'challenge response' FROM hostap_chalresp WHERE time IS NOT NULL "
|
|
"UNION ALL SELECT time, 'handshake' FROM hostap_handshake WHERE time IS NOT NULL "
|
|
"UNION ALL SELECT time, 'wpa handshake' FROM handshake WHERE time IS NOT NULL "
|
|
"ORDER BY time DESC LIMIT %d" % limit)
|
|
try:
|
|
rows = _db_rows(RECON_DB, sql)
|
|
except Exception:
|
|
rows = []
|
|
return {'events': [{'time': r.get('time'), 'type': r.get('type')} for r in rows]}
|
|
|
|
|
|
def h_recon_events(ctx):
|
|
return 200, recon_events_data()
|
|
|
|
|
|
def h_recon_examine(ctx):
|
|
body = getattr(ctx, 'body', None) or {}
|
|
bssid = (body.get('bssid') or '').strip()
|
|
channel = body.get('channel')
|
|
if bssid:
|
|
hak5('PINEAPPLE_EXAMINE_BSSID', bssid)
|
|
elif channel is not None:
|
|
hak5('PINEAPPLE_EXAMINE_CHANNEL', str(int(channel)))
|
|
else:
|
|
return 400, {'error': 'examine requires bssid or channel'}
|
|
return 200, {'ok': True}
|
|
|
|
|
|
def h_recon_scans(ctx):
|
|
cache = _recon_scans_cache
|
|
if cache['db'] != RECON_DB:
|
|
cache.update({'db': RECON_DB, 'updated': 0, 'data': {'scans': []}})
|
|
try:
|
|
cache['data'] = recon_scans_data()
|
|
cache['updated'] = time.time()
|
|
except RuntimeError:
|
|
if not cache['updated'] or time.time() - cache['updated'] > 120:
|
|
return 503, {'error': 'recon database is temporarily unavailable'}
|
|
return 200, dict(cache['data'], stale=True)
|
|
return 200, dict(cache['data'], stale=False)
|
|
|
|
|
|
def h_recon_scan_detail(ctx):
|
|
scan_id = int(ctx.args[0])
|
|
data = recon_scan_data(scan_id)
|
|
if data is None:
|
|
return 404, {'error': 'scan not found'}
|
|
return 200, data
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Recon report helpers (CSV / HTML) for scan and survey downloads.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _fmt_ts(ts):
|
|
if not ts:
|
|
return '--'
|
|
try:
|
|
return time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(int(ts)))
|
|
except (ValueError, OSError, TypeError):
|
|
return str(ts)
|
|
|
|
|
|
def _csv_escape(v):
|
|
v = '' if v is None else str(v)
|
|
if any(c in v for c in ',"\n\r'):
|
|
return '"' + v.replace('"', '""') + '"'
|
|
return v
|
|
|
|
|
|
def _aps_csv(data):
|
|
out = ['bssid,ssid,hidden,band,channel,freq,encryption,signal,vendor,first_seen,last_seen']
|
|
for a in (data or {}).get('aps') or []:
|
|
out.append(','.join(_csv_escape(x) for x in [
|
|
a.get('bssid'), a.get('ssid'), int(bool(a.get('hidden'))),
|
|
a.get('band'), a.get('channel'), a.get('freq'),
|
|
a.get('encryption'), a.get('signal'), a.get('vendor'),
|
|
_fmt_ts(a.get('first_seen')), _fmt_ts(a.get('last_seen'))]))
|
|
out.append('unassociated,%d' % ((data or {}).get('unassociated') or 0))
|
|
return '\r\n'.join(out) + '\r\n'
|
|
|
|
|
|
def _survey_aps_csv(detail):
|
|
out = ['bssid,ssid,band,channel,min_dbm,avg_dbm,max_dbm,samples,first_seen,last_seen']
|
|
for a in (detail or {}).get('aps') or []:
|
|
out.append(','.join(_csv_escape(x) for x in [
|
|
a.get('bssid'), a.get('ssid'), a.get('band'), a.get('channel'),
|
|
a.get('min'), a.get('avg'), a.get('max'), a.get('samples'),
|
|
_fmt_ts(a.get('first_seen')), _fmt_ts(a.get('last_seen'))]))
|
|
return '\r\n'.join(out) + '\r\n'
|
|
|
|
|
|
def _esc_html(v):
|
|
if v is None:
|
|
return ''
|
|
return (str(v).replace('&', '&').replace('<', '<')
|
|
.replace('>', '>').replace('"', '"'))
|
|
|
|
|
|
REPORT_CSS = """
|
|
body { font-family: -apple-system, 'Segoe UI', Roboto, sans-serif; margin: 24px; color: #222; background: #fff; }
|
|
h1 { font-size: 20px; margin: 0 0 4px; }
|
|
.sub { color: #666; margin-bottom: 16px; }
|
|
table { border-collapse: collapse; width: 100%; font-size: 13px; }
|
|
th, td { border: 1px solid #ddd; padding: 6px 10px; text-align: left; }
|
|
th { background: #f4f4f4; }
|
|
tr:nth-child(even) td { background: #fafafa; }
|
|
.stats { margin: 12px 0; font-size: 13px; color: #333; }
|
|
"""
|
|
|
|
|
|
def _html_table(headers, rows):
|
|
out = ['<table><thead><tr>']
|
|
for header in headers:
|
|
out.append('<th>%s</th>' % _esc_html(header))
|
|
out.append('</tr></thead><tbody>')
|
|
for row in rows:
|
|
out.append('<tr>')
|
|
for cell in row:
|
|
out.append('<td>%s</td>' % _esc_html(cell))
|
|
out.append('</tr>')
|
|
out.append('</tbody></table>')
|
|
return ''.join(out)
|
|
|
|
|
|
def _html_doc(title, subtitle, body_html, stats=None):
|
|
parts = ['<!DOCTYPE html><html><head><meta charset="utf-8"><title>%s</title>'
|
|
'<style>%s</style></head><body>' % (_esc_html(title), REPORT_CSS)]
|
|
parts.append('<h1>%s</h1>' % _esc_html(title))
|
|
parts.append('<div class="sub">%s</div>' % _esc_html(subtitle))
|
|
for label, value in (stats or []):
|
|
parts.append('<div class="stats"><b>%s:</b> %s</div>' % (_esc_html(label), value))
|
|
parts.append(body_html)
|
|
parts.append('</body></html>')
|
|
return ''.join(parts)
|
|
|
|
|
|
def _recon_read_retry(fn, attempts=3, pause=1.0):
|
|
"""Retry a recon.db read briefly; pineapd's write bursts hold the DB
|
|
exclusive lock and reads can time out mid-burst."""
|
|
last = None
|
|
for _ in range(attempts):
|
|
try:
|
|
return fn()
|
|
except RuntimeError as exc:
|
|
last = exc
|
|
time.sleep(pause)
|
|
raise last
|
|
|
|
|
|
def _scan_client_count(scan_id, _timeout=12):
|
|
rows = _db_rows(RECON_DB,
|
|
"SELECT count(*) AS c FROM wifi_device w WHERE w.scan = %d "
|
|
"AND w.mac NOT IN (SELECT DISTINCT bssid FROM ssid "
|
|
"WHERE scan = %d AND type = 8 AND bssid IS NOT NULL)"
|
|
% (scan_id, scan_id), timeout=_timeout)
|
|
return rows[0]['c'] if rows else 0
|
|
|
|
|
|
def h_recon_scan_download_csv(ctx):
|
|
scan_id = int(ctx.args[0])
|
|
try:
|
|
data = _recon_read_retry(lambda: recon_scan_data(scan_id, _timeout=15, _limit=300))
|
|
except RuntimeError:
|
|
return 503, {'error': 'recon database is temporarily unavailable'}
|
|
if data is None:
|
|
return 404, {'error': 'scan not found'}
|
|
return 200, Download(_aps_csv(data).encode('utf-8'), 'text/csv',
|
|
'scan-%d.csv' % scan_id)
|
|
|
|
|
|
def h_recon_scan_download_html(ctx):
|
|
scan_id = int(ctx.args[0])
|
|
try:
|
|
data = _recon_read_retry(lambda: recon_scan_data(scan_id, _timeout=15, _limit=300))
|
|
client_count = _scan_client_count(scan_id, _timeout=12)
|
|
except RuntimeError:
|
|
return 503, {'error': 'recon database is temporarily unavailable'}
|
|
if data is None:
|
|
return 404, {'error': 'scan not found'}
|
|
rows = []
|
|
for a in data.get('aps') or []:
|
|
rows.append([a.get('ssid') or '(hidden)', a.get('bssid'), a.get('band'),
|
|
a.get('channel'), a.get('signal'), a.get('encryption'),
|
|
a.get('vendor'), _fmt_ts(a.get('first_seen')),
|
|
_fmt_ts(a.get('last_seen'))])
|
|
scan = data.get('scan') or {}
|
|
stats = [('Started', _fmt_ts(scan.get('time'))),
|
|
('Access points', len(data.get('aps') or [])),
|
|
('Clients', client_count),
|
|
('Handshakes', len(data.get('handshakes') or [])),
|
|
('Unassociated', data.get('unassociated') or 0)]
|
|
body = _html_table(['SSID', 'BSSID', 'Band', 'Ch', 'Signal', 'Encryption',
|
|
'Vendor', 'First seen', 'Last seen'], rows)
|
|
return 200, Download(_html_doc('Scan #%d' % scan.get('id'),
|
|
'Pager recon capture report', body,
|
|
stats=stats).encode('utf-8'),
|
|
'text/html', 'scan-%d.html' % scan_id)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GPS: serial device discovery, gpsd control, TPV/SKY parsing, status cache.
|
|
# Tied to the Glytch GPS mod (gpsd + uci 'gpsd' config section).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _gps_serial_candidates():
|
|
candidates = []
|
|
if os.path.isdir(SERIAL_DIR):
|
|
try:
|
|
names = sorted(os.listdir(SERIAL_DIR))
|
|
except OSError:
|
|
names = []
|
|
for name in names:
|
|
path = os.path.join(SERIAL_DIR, name)
|
|
# by-path entries are named like '1.3_1-1.3:1.0' and only reveal
|
|
# their ttyACM/ttyUSB target through the resolved symlink.
|
|
target = os.path.realpath(path)
|
|
if ('ttyACM' in name or 'ttyUSB' in name
|
|
or 'ttyACM' in target or 'ttyUSB' in target):
|
|
candidates.append((name, path))
|
|
return candidates
|
|
|
|
|
|
def _uci_gps_get():
|
|
rc, out, err = device_run(['uci', 'get', 'gpsd.core.device'])
|
|
return out.strip() or None
|
|
|
|
|
|
def _uci_gps_set(device):
|
|
device_run(['uci', 'set', 'gpsd.core.device=%s' % device])
|
|
device_run(['uci', 'commit', 'gpsd'])
|
|
|
|
|
|
def _gpsd_running():
|
|
rc, out, err = device_run(['pgrep', '-f', 'gpsd'])
|
|
return rc == 0
|
|
|
|
|
|
def _gpsd_restart():
|
|
device_run([GPSD_INIT, 'restart'], timeout=15)
|
|
|
|
|
|
def _gps_from_gpspipe():
|
|
try:
|
|
p = subprocess.Popen(['gpspipe', '-w', '-n', '3'],
|
|
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
|
out, _ = p.communicate(timeout=8)
|
|
except Exception:
|
|
return None
|
|
tpv = None
|
|
sky = None
|
|
for line in out.decode('utf-8', 'replace').splitlines():
|
|
obj = _json_or(line)
|
|
if not isinstance(obj, dict):
|
|
continue
|
|
cls = obj.get('class')
|
|
if cls == 'TPV' and tpv is None:
|
|
tpv = obj
|
|
elif cls == 'SKY' and sky is None:
|
|
sky = obj
|
|
if tpv is None:
|
|
return None
|
|
return {'fix': tpv.get('mode') or 0,
|
|
'lat': tpv.get('lat'),
|
|
'lon': tpv.get('lon'),
|
|
'alt': tpv.get('alt'),
|
|
'speed': tpv.get('speed'),
|
|
'satellites': (sky or {}).get('satellites') or None}
|
|
|
|
|
|
def _gps_from_hak5cmd():
|
|
out = hak5('GPS_GET', timeout=10)
|
|
obj = _json_or(out)
|
|
if not isinstance(obj, dict):
|
|
return None
|
|
return {'fix': obj.get('fix') or obj.get('mode') or 0,
|
|
'lat': obj.get('lat') or obj.get('latitude'),
|
|
'lon': obj.get('lon') or obj.get('longitude'),
|
|
'alt': obj.get('alt'),
|
|
'speed': obj.get('speed'),
|
|
'satellites': obj.get('satellites') or obj.get('satellites_used')}
|
|
|
|
|
|
def _wigle_config():
|
|
_, cur = daemon_sock_call('GET', '/api/pineap/get_config')
|
|
base = dict(PINEAP_CONFIG_DEFAULTS)
|
|
if isinstance(cur, dict) and 'reconpath' in cur:
|
|
base.update(cur)
|
|
return base
|
|
|
|
|
|
def _wigle_set(enabled):
|
|
base = _wigle_config()
|
|
base['logwigle'] = bool(enabled)
|
|
return _daemon_proxy('PUT', 'set_config', base)
|
|
|
|
|
|
def _gps_status_data_nocache():
|
|
try:
|
|
device = _uci_gps_get()
|
|
except Exception:
|
|
device = None
|
|
try:
|
|
present = any(os.path.exists(path)
|
|
for _, path in _gps_serial_candidates())
|
|
except Exception:
|
|
present = False
|
|
try:
|
|
running = _gpsd_running()
|
|
except Exception:
|
|
running = False
|
|
data = {'device': device, 'present': present, 'fix': 0,
|
|
'lat': None, 'lon': None, 'alt': None, 'speed': None,
|
|
'satellites': None, 'gpsd_running': running,
|
|
'updated': None, 'wigle': _wigle_config().get('logwigle', False)}
|
|
fix = _gps_from_gpspipe() if running else None
|
|
if fix is None:
|
|
fix = _gps_from_hak5cmd()
|
|
if fix:
|
|
data.update(fix)
|
|
data['updated'] = int(time.time())
|
|
return data
|
|
|
|
|
|
def _gps_status_data():
|
|
with _gps_lock:
|
|
if (_gps_cache['data'] is not None
|
|
and time.time() - _gps_cache['updated'] < GPS_CACHE_SECONDS):
|
|
return _gps_cache['data']
|
|
data = _gps_status_data_nocache()
|
|
_gps_cache.update({'updated': time.time(), 'data': data})
|
|
return data
|
|
|
|
|
|
def h_recon_gps(ctx):
|
|
return 200, _gps_status_data()
|
|
|
|
|
|
def h_recon_gps_configure(ctx):
|
|
try:
|
|
candidates = _gps_serial_candidates()
|
|
except Exception:
|
|
candidates = []
|
|
if not candidates:
|
|
return 200, {'present': False, 'error': 'No GPS serial device found'}
|
|
try:
|
|
current = _uci_gps_get()
|
|
except Exception:
|
|
current = None
|
|
ordered = sorted(candidates, key=lambda c: (c[0] != current, c[0]))
|
|
tried = []
|
|
for name, path in ordered[:3]:
|
|
tried.append(name)
|
|
try:
|
|
_uci_gps_set(name)
|
|
_gpsd_restart()
|
|
time.sleep(1.5)
|
|
fix = _gps_from_gpspipe()
|
|
except Exception:
|
|
fix = None
|
|
if fix is not None and fix.get('fix'):
|
|
data = _gps_status_data_nocache()
|
|
data.update({'configured': True, 'device': name, 'tried': tried,
|
|
'lock': True})
|
|
return 200, data
|
|
data = _gps_status_data_nocache()
|
|
data.update({'configured': True, 'device': ordered[0][0], 'tried': tried,
|
|
'note': 'GPS bound, waiting for a fix'})
|
|
return 200, data
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# WiGLE logging: toggle the daemon 'logwigle' setting, list and download the
|
|
# CSV files the daemon writes under WIGLE_DIR.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def wigle_files_data():
|
|
files = []
|
|
if os.path.isdir(WIGLE_DIR):
|
|
try:
|
|
names = sorted(os.listdir(WIGLE_DIR))
|
|
except OSError:
|
|
names = []
|
|
for name in names:
|
|
path = os.path.join(WIGLE_DIR, name)
|
|
if not os.path.isfile(path):
|
|
continue
|
|
try:
|
|
size = os.path.getsize(path)
|
|
mtime = int(os.path.getmtime(path))
|
|
except OSError:
|
|
continue
|
|
rows = None
|
|
if size <= 2 * 1024 * 1024:
|
|
try:
|
|
with open(path, 'r', errors='replace') as fh:
|
|
lines = [line for line in fh if line.strip()]
|
|
# WiGLE CSVs lead with a meta line ('WigleWifi-1.6,...')
|
|
# followed by the column header; only count data rows.
|
|
header_offset = 2 if lines and lines[0].startswith('WigleWifi') else 1
|
|
rows = max(0, len(lines) - header_offset)
|
|
except OSError:
|
|
rows = None
|
|
files.append({'name': name, 'size': size, 'mtime': mtime, 'rows': rows})
|
|
return {'files': files}
|
|
|
|
|
|
def h_recon_wigle_files(ctx):
|
|
return 200, wigle_files_data()
|
|
|
|
|
|
def h_recon_wigle_file(ctx):
|
|
name = _unquote_plus(ctx.args[0])
|
|
path = _safe_join(WIGLE_DIR, name)
|
|
if path is None or not os.path.isfile(path):
|
|
return 404, {'error': 'file not found'}
|
|
try:
|
|
with open(path, 'rb') as fh:
|
|
body = fh.read()
|
|
except OSError:
|
|
return 404, {'error': 'file not found'}
|
|
return 200, Download(body, 'text/csv', os.path.basename(path))
|
|
|
|
|
|
def h_recon_wigle(ctx):
|
|
enable = bool((getattr(ctx, 'body', None) or {}).get('enable'))
|
|
status, data = _wigle_set(enable)
|
|
if status != 200:
|
|
return status, data
|
|
if enable:
|
|
hak5('WIGLE_START', timeout=10)
|
|
else:
|
|
hak5('WIGLE_STOP', timeout=10)
|
|
resp = {'ok': True, 'wigle': enable}
|
|
if enable:
|
|
files = wigle_files_data().get('files') or []
|
|
if files:
|
|
resp['filename'] = files[-1]['name']
|
|
return 200, resp
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Surveys: JSONL overlay on the device (meta line + one 'sample' line per
|
|
# tick of the always-on recon). The recon.db itself is never modified.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _survey_path(sid):
|
|
return _safe_join(SURVEY_DIR, '%s.jsonl' % sid)
|
|
|
|
|
|
def _survey_slug(name):
|
|
return re.sub(r'[^A-Za-z0-9]+', '-', (name or '').strip()).strip('-')
|
|
|
|
|
|
def _survey_recording_state():
|
|
st = _survey_state
|
|
if not st['active']:
|
|
return None
|
|
return {'active': True, 'id': st['id'], 'name': st['name'],
|
|
'started': st['started'], 'samples': st['samples']}
|
|
|
|
|
|
def _survey_sample():
|
|
with _survey_lock:
|
|
st = _survey_state
|
|
if not st['active'] or not st['path']:
|
|
return
|
|
now = time.time()
|
|
if now - st['last_sample'] < SURVEY_SAMPLE_INTERVAL:
|
|
return
|
|
if st['samples'] >= SURVEY_MAX_SAMPLES:
|
|
st['active'] = False
|
|
return
|
|
try:
|
|
scans = recon_scans_data(1, _timeout=6).get('scans') or []
|
|
detail = recon_scan_data(scans[0]['id'], _timeout=12, _limit=300) if scans else None
|
|
gps = _gps_status_data()
|
|
sample = {'t': int(now),
|
|
'scan': (detail or {}).get('scan'),
|
|
'aps': (detail or {}).get('aps') or [],
|
|
'clients': (detail or {}).get('clients') or [],
|
|
'handshakes': (detail or {}).get('handshakes') or [],
|
|
'unassociated': (detail or {}).get('unassociated') or 0,
|
|
'gps': {'fix': gps.get('fix'), 'lat': gps.get('lat'),
|
|
'lon': gps.get('lon'),
|
|
'satellites': gps.get('satellites')}}
|
|
with open(st['path'], 'a') as fh:
|
|
fh.write(json.dumps({'sample': sample}) + '\n')
|
|
st['samples'] += 1
|
|
st['last_sample'] = now
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def h_recon_survey_start(ctx):
|
|
with _survey_lock:
|
|
if _survey_state['active']:
|
|
return 409, {'error': 'a survey is already recording'}
|
|
name = ((getattr(ctx, 'body', None) or {}).get('name') or '').strip()
|
|
sid = time.strftime('%Y%m%d-%H%M%S')
|
|
slug = _survey_slug(name)
|
|
if slug:
|
|
sid += '-' + slug
|
|
path = _survey_path(sid)
|
|
if path is None:
|
|
return 500, {'error': 'invalid survey id'}
|
|
try:
|
|
os.makedirs(SURVEY_DIR, exist_ok=True)
|
|
except OSError:
|
|
return 500, {'error': 'cannot create survey directory'}
|
|
meta = {'id': sid, 'name': name, 'started': int(time.time()),
|
|
'interval': SURVEY_SAMPLE_INTERVAL, 'max_samples': SURVEY_MAX_SAMPLES}
|
|
try:
|
|
with open(path, 'w') as fh:
|
|
fh.write(json.dumps({'meta': meta}) + '\n')
|
|
except OSError:
|
|
return 500, {'error': 'cannot write survey file'}
|
|
_survey_state.update({'active': True, 'id': sid, 'name': name, 'path': path,
|
|
'started': meta['started'], 'samples': 0,
|
|
'last_sample': 0})
|
|
return 200, {'ok': True, 'id': sid, 'started': meta['started']}
|
|
|
|
|
|
def h_recon_survey_stop(ctx):
|
|
with _survey_lock:
|
|
st = dict(_survey_state)
|
|
if st['active']:
|
|
_survey_state['active'] = False
|
|
return 200, {'ok': True, 'samples': st['samples'], 'id': st['id']}
|
|
|
|
|
|
def h_recon_survey_live(ctx):
|
|
detail = None
|
|
try:
|
|
scans = recon_scans_data(1, _timeout=6).get('scans') or []
|
|
if scans:
|
|
detail = recon_scan_data(scans[0]['id'], _timeout=12, _limit=300)
|
|
except RuntimeError:
|
|
pass
|
|
try:
|
|
gps = _gps_status_data()
|
|
except Exception:
|
|
gps = {}
|
|
return 200, {'scan': (detail or {}).get('scan'),
|
|
'aps': (detail or {}).get('aps') or [],
|
|
'clients': (detail or {}).get('clients') or [],
|
|
'handshakes': (detail or {}).get('handshakes') or [],
|
|
'unassociated': (detail or {}).get('unassociated') or 0,
|
|
'gps': gps,
|
|
'recording': _survey_recording_state()}
|
|
|
|
|
|
def recon_surveys_data():
|
|
surveys = []
|
|
if os.path.isdir(SURVEY_DIR):
|
|
try:
|
|
names = sorted(os.listdir(SURVEY_DIR), reverse=True)
|
|
except OSError:
|
|
names = []
|
|
for name in names:
|
|
if not name.endswith('.jsonl'):
|
|
continue
|
|
path = os.path.join(SURVEY_DIR, name)
|
|
sid = name[:-6]
|
|
meta = None
|
|
samples = 0
|
|
try:
|
|
size = os.path.getsize(path)
|
|
with open(path, 'r', errors='replace') as fh:
|
|
for line in fh:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
obj = json.loads(line)
|
|
except ValueError:
|
|
continue
|
|
if 'meta' in obj:
|
|
meta = obj['meta']
|
|
elif 'sample' in obj:
|
|
samples += 1
|
|
except OSError:
|
|
continue
|
|
if meta is None:
|
|
continue
|
|
surveys.append({'id': sid, 'name': meta.get('name') or sid,
|
|
'started': meta.get('started'),
|
|
'samples': samples, 'size': size})
|
|
return {'surveys': surveys}
|
|
|
|
|
|
def h_recon_surveys(ctx):
|
|
try:
|
|
return 200, recon_surveys_data()
|
|
except Exception:
|
|
return 200, {'surveys': []}
|
|
|
|
|
|
def recon_survey_data(sid):
|
|
path = _survey_path(sid)
|
|
if path is None or not os.path.isfile(path):
|
|
return None
|
|
agg = {}
|
|
order = []
|
|
gps_fixes = []
|
|
try:
|
|
with open(path, 'r', errors='replace') as fh:
|
|
for line in fh:
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
obj = json.loads(line)
|
|
except ValueError:
|
|
continue
|
|
s = obj.get('sample')
|
|
if not isinstance(s, dict):
|
|
continue
|
|
g = s.get('gps') or {}
|
|
if g.get('lat') is not None and g.get('lon') is not None:
|
|
gps_fixes.append({'t': s.get('t'), 'lat': g['lat'],
|
|
'lon': g['lon']})
|
|
for a in s.get('aps') or []:
|
|
bssid = a.get('bssid')
|
|
if not bssid:
|
|
continue
|
|
entry = agg.get(bssid)
|
|
if entry is None:
|
|
entry = {'ssid': a.get('ssid'), 'bssid': bssid,
|
|
'band': a.get('band'), 'channel': a.get('channel'),
|
|
'min': None, 'max': None, 'sum': 0, 'count': 0,
|
|
'first_seen': None, 'last_seen': None}
|
|
agg[bssid] = entry
|
|
order.append(bssid)
|
|
sig = a.get('signal')
|
|
if sig is not None:
|
|
entry['min'] = sig if entry['min'] is None else min(entry['min'], sig)
|
|
entry['max'] = sig if entry['max'] is None else max(entry['max'], sig)
|
|
entry['sum'] += sig
|
|
entry['count'] += 1
|
|
t = s.get('t')
|
|
if t:
|
|
entry['first_seen'] = (t if entry['first_seen'] is None
|
|
else min(entry['first_seen'], t))
|
|
entry['last_seen'] = (t if entry['last_seen'] is None
|
|
else max(entry['last_seen'], t))
|
|
except OSError:
|
|
return None
|
|
aps = []
|
|
for bssid in order:
|
|
e = agg[bssid]
|
|
aps.append({'ssid': e['ssid'], 'bssid': e['bssid'], 'band': e['band'],
|
|
'channel': e['channel'], 'min': e['min'],
|
|
'avg': round(e['sum'] / e['count']) if e['count'] else None,
|
|
'max': e['max'], 'samples': e['count'],
|
|
'first_seen': e['first_seen'], 'last_seen': e['last_seen']})
|
|
aps.sort(key=lambda a: a['avg'] if a['avg'] is not None else 0)
|
|
return {'id': sid, 'aps': aps, 'gps_fixes': len(gps_fixes),
|
|
'first_gps': gps_fixes[0] if gps_fixes else None,
|
|
'last_gps': gps_fixes[-1] if gps_fixes else None}
|
|
|
|
|
|
def h_recon_survey_detail(ctx):
|
|
sid = ctx.args[0]
|
|
data = recon_survey_data(sid)
|
|
if data is None:
|
|
return 404, {'error': 'survey not found'}
|
|
return 200, data
|
|
|
|
|
|
def h_recon_survey_download(ctx):
|
|
sid = ctx.args[0]
|
|
fmt = ctx.args[1]
|
|
if fmt == 'json':
|
|
data = recon_survey_data(sid)
|
|
if data is None:
|
|
return 404, {'error': 'survey not found'}
|
|
return 200, Download(json.dumps(data, indent=2).encode('utf-8'),
|
|
'application/json', 'survey-%s.json' % sid)
|
|
data = recon_survey_data(sid)
|
|
if data is None:
|
|
return 404, {'error': 'survey not found'}
|
|
if fmt == 'csv':
|
|
return 200, Download(_survey_aps_csv(data).encode('utf-8'), 'text/csv',
|
|
'survey-%s.csv' % sid)
|
|
if fmt == 'html':
|
|
rows = []
|
|
for a in data.get('aps') or []:
|
|
rows.append([a.get('ssid') or '(hidden)', a.get('bssid'),
|
|
a.get('band'), a.get('channel'), a.get('min'),
|
|
a.get('avg'), a.get('max'), a.get('samples'),
|
|
_fmt_ts(a.get('first_seen')), _fmt_ts(a.get('last_seen'))])
|
|
fixes = data.get('gps_fixes') or 0
|
|
fg = data.get('first_gps') or {}
|
|
lg = data.get('last_gps') or {}
|
|
if fixes:
|
|
gps = ('%d fixes · first %.5f, %.5f · last %.5f, %.5f'
|
|
% (fixes, fg.get('lat') or 0, fg.get('lon') or 0,
|
|
lg.get('lat') or 0, lg.get('lon') or 0))
|
|
else:
|
|
gps = 'No GPS fixes during this survey'
|
|
body = _html_table(['SSID', 'BSSID', 'Band', 'Ch', 'Min', 'Avg', 'Max',
|
|
'Samples', 'First', 'Last'], rows)
|
|
return 200, Download(_html_doc('Survey %s' % sid,
|
|
'AP signal aggregates', body,
|
|
stats=[('GPS', gps)]).encode('utf-8'),
|
|
'text/html', 'survey-%s.html' % sid)
|
|
return 404, {'error': 'unknown format'}
|
|
|
|
|
|
def h_recon_survey_delete(ctx):
|
|
sid = ctx.args[0]
|
|
path = _survey_path(sid)
|
|
if path is None or not os.path.isfile(path):
|
|
return 404, {'error': 'survey not found'}
|
|
with _survey_lock:
|
|
if _survey_state.get('id') == sid and _survey_state['active']:
|
|
return 409, {'error': 'cannot delete the survey that is recording'}
|
|
try:
|
|
os.remove(path)
|
|
except OSError:
|
|
return 500, {'error': 'delete failed'}
|
|
return 200, {'ok': True}
|
|
|
|
|
|
HS_FILENAME_RE = re.compile(
|
|
r'^(?:(\d+)_)?([0-9A-Fa-f]{2}(?:[:-][0-9A-Fa-f]{2}){5})_'
|
|
r'([0-9A-Fa-f]{2}(?:[:-][0-9A-Fa-f]{2}){5})(?:_handshake)?'
|
|
r'(?:_(full|partial|incomplete))?\.([A-Za-z0-9]+)$')
|
|
|
|
|
|
def parse_hs_filename(name):
|
|
m = HS_FILENAME_RE.match(name or '')
|
|
if not m:
|
|
return None
|
|
ts, ap, client, kind, ext = m.groups()
|
|
return {'ts': int(ts) if ts else None,
|
|
'ap': ap.replace('-', ':'),
|
|
'client': client.replace('-', ':'),
|
|
'kind': kind or 'full',
|
|
'ext': ext}
|
|
|
|
|
|
def _norm_mac(m):
|
|
m = (m or '').strip().upper().replace('-', ':')
|
|
if len(m) == 12 and ':' not in m and all(c in '0123456789ABCDEF' for c in m):
|
|
m = ':'.join(m[i:i + 2] for i in range(0, 12, 2))
|
|
return m
|
|
|
|
|
|
def _hs_db_by_pair(min_ts):
|
|
rows = _db_rows(RECON_DB,
|
|
'SELECT h.time, '
|
|
'(h.hs1 IS NOT NULL AND length(h.hs1) > 0) AS m1, '
|
|
'(h.hs2 IS NOT NULL AND length(h.hs2) > 0) AS m2, '
|
|
'(h.hs3 IS NOT NULL AND length(h.hs3) > 0) AS m3, '
|
|
'(h.hs4 IS NOT NULL AND length(h.hs4) > 0) AS m4, '
|
|
'(h.beacon IS NOT NULL AND length(h.beacon) > 0) AS beacon, '
|
|
'w1.mac AS ap, w2.mac AS sta '
|
|
'FROM handshake h '
|
|
'JOIN wifi_device w1 ON w1.hash = h.aphash '
|
|
'JOIN wifi_device w2 ON w2.hash = h.stahash '
|
|
'WHERE h.time >= %d ORDER BY h.time' % min_ts)
|
|
db = {}
|
|
for r in rows:
|
|
db[(_norm_mac(r.get('ap')), _norm_mac(r.get('sta')))] = {
|
|
'time': r.get('time'),
|
|
'part_mask': (1 if r.get('m1') else 0) | (2 if r.get('m2') else 0)
|
|
| (4 if r.get('m3') else 0) | (8 if r.get('m4') else 0),
|
|
'beacon': bool(r.get('beacon')),
|
|
}
|
|
return db
|
|
|
|
|
|
def _compose_hs(name, size, mtime, part, db):
|
|
base = {'source': 'Recon', 'name': name, 'size': size,
|
|
'location': os.path.join(LOOT_HS_DIR, name), 'file_exists': True}
|
|
if part is None:
|
|
ext = name.rsplit('.', 1)[-1] if '.' in name else ''
|
|
base.update({'mac': '--', 'client': '--', 'type': 'full',
|
|
'timestamp': mtime, 'in_db': False, 'part_mask': 0,
|
|
'beacon': False, 'extension': ext})
|
|
return base
|
|
rec = db.get((_norm_mac(part['ap']), _norm_mac(part['client'])))
|
|
base.update({
|
|
'mac': part['ap'], 'client': part['client'], 'type': part['kind'],
|
|
'timestamp': (rec or {}).get('time') or part['ts'] or mtime,
|
|
'in_db': rec is not None,
|
|
'part_mask': (rec or {}).get('part_mask', 0),
|
|
'beacon': bool((rec or {}).get('beacon', False)),
|
|
'extension': part['ext']})
|
|
return base
|
|
|
|
|
|
def handshakes_data():
|
|
files = []
|
|
parsed = []
|
|
min_ts = None
|
|
try:
|
|
names = sorted(os.listdir(LOOT_HS_DIR))
|
|
except OSError:
|
|
names = []
|
|
for name in names:
|
|
p = os.path.join(LOOT_HS_DIR, name)
|
|
try:
|
|
if not os.path.isfile(p) or name.startswith('.'):
|
|
continue
|
|
st = os.stat(p)
|
|
except OSError:
|
|
continue
|
|
mtime = int(st.st_mtime)
|
|
files.append({'name': name, 'size': st.st_size, 'mtime': mtime})
|
|
part = parse_hs_filename(name)
|
|
if part is not None:
|
|
ts = part['ts'] if part['ts'] is not None else mtime
|
|
part['ts'] = ts
|
|
if min_ts is None or ts < min_ts:
|
|
min_ts = ts
|
|
parsed.append((name, st.st_size, mtime, part))
|
|
handshakes = []
|
|
db = {}
|
|
if min_ts is not None:
|
|
try:
|
|
db = _hs_db_by_pair(min_ts)
|
|
except Exception:
|
|
db = {}
|
|
for name, size, mtime, part in parsed:
|
|
handshakes.append(_compose_hs(name, size, mtime, part, db))
|
|
return {'files': files, 'handshakes': handshakes}
|
|
|
|
|
|
def h_handshakes_get(ctx):
|
|
return 200, handshakes_data()
|
|
|
|
|
|
def h_handshake_file(ctx):
|
|
name = _unquote_plus(ctx.args[0])
|
|
full = _safe_join(LOOT_HS_DIR, name)
|
|
if not full or not os.path.isfile(full):
|
|
return 404, {'error': 'not found'}
|
|
with open(full, 'rb') as f:
|
|
body = f.read()
|
|
return 200, Download(body, 'application/octet-stream', name)
|
|
|
|
|
|
def h_handshakes_delete(ctx):
|
|
name = (ctx.body or {}).get('name') or ctx.query.get('name') or ''
|
|
safe = os.path.basename(name)
|
|
if not safe or safe != name:
|
|
return 400, {'error': 'invalid name'}
|
|
p = os.path.join(LOOT_HS_DIR, safe)
|
|
if not os.path.isfile(p):
|
|
return 404, {'error': 'not found'}
|
|
os.remove(p)
|
|
return 200, handshakes_data()
|
|
|
|
|
|
def h_handshakes_location(ctx):
|
|
return 200, {'location': LOOT_HS_DIR}
|
|
|
|
|
|
def h_handshakes_delete_all(ctx):
|
|
try:
|
|
names = os.listdir(LOOT_HS_DIR)
|
|
except OSError:
|
|
names = []
|
|
for name in names:
|
|
p = os.path.join(LOOT_HS_DIR, name)
|
|
try:
|
|
if os.path.isfile(p) and not name.startswith('.'):
|
|
os.remove(p)
|
|
except OSError:
|
|
continue
|
|
return 200, handshakes_data()
|
|
|
|
|
|
def h_loot_zip(ctx):
|
|
status, raw = daemon_call('GET', '/api/loot/zip', token=current_token())
|
|
if status != 200 or not isinstance(raw, bytes):
|
|
return 502, {'error': 'daemon failed'}
|
|
return 200, Download(raw, 'application/zip', 'loot.zip')
|
|
|
|
|
|
def h_loot_archive(ctx):
|
|
status, data = daemon_call('POST', '/api/loot/archive', token=current_token())
|
|
return (200 if status == 200 else 502), (data if isinstance(data, dict) else {'ok': status == 200})
|
|
|
|
|
|
def assoc_clients(ifaces=None):
|
|
clients = []
|
|
for name in (wifi_ifaces() if ifaces is None else ifaces):
|
|
rc, out, err = device_run(['iwinfo', name, 'assoclist'])
|
|
for line in out.splitlines():
|
|
m = re.match(r'\s*([0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2}:[0-9A-Fa-f]{2})\s+', line)
|
|
if not m:
|
|
continue
|
|
mac = m.group(1).upper()
|
|
rssi = None
|
|
rm = re.search(r'Signal:\s*(-?\d+)', line)
|
|
if rm:
|
|
rssi = int(rm.group(1))
|
|
clients.append({'mac': mac, 'iface': name, 'rssi': rssi})
|
|
return clients
|
|
|
|
|
|
def disk_data():
|
|
rc, out, err = device_run(['df', '-k', '/root'])
|
|
lines = out.splitlines()
|
|
if len(lines) >= 2:
|
|
parts = lines[1].split()
|
|
if len(parts) >= 4:
|
|
try:
|
|
size = int(parts[1]); used = int(parts[2]); avail = int(parts[3])
|
|
return {'size': size * 1024, 'used': used * 1024, 'avail': avail * 1024}
|
|
except ValueError:
|
|
pass
|
|
return {}
|
|
|
|
|
|
def uptime_data():
|
|
rc, out, err = device_run(['cat', '/proc/uptime'])
|
|
try:
|
|
return int(float(out.split()[0]))
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def firmware_data():
|
|
rc, out, err = device_run(['cat', '/etc/openwrt_release'])
|
|
dist = None
|
|
for line in out.splitlines():
|
|
if line.startswith('DISTRIB_DESCRIPTION'):
|
|
dist = line.split('=', 1)[1].strip().strip('"')
|
|
return dist
|
|
|
|
|
|
def daemon_status():
|
|
status, data = daemon_call('GET', '/api/api_ping', token=current_token())
|
|
if status == 200 and isinstance(data, dict):
|
|
return data
|
|
return {}
|
|
|
|
|
|
def hostname_data():
|
|
rc, out, err = device_run(['uci', 'get', 'system.@system[0].hostname'])
|
|
return out.strip() or None
|
|
|
|
|
|
def status_data():
|
|
# iwinfo can be slow on the Pager, especially while radios are being
|
|
# reconfigured. Discover interfaces once and share the result between
|
|
# the radio and association portions of this snapshot.
|
|
ifaces = wifi_ifaces()
|
|
return {
|
|
'battery': battery_data(),
|
|
'firmware': firmware_data(),
|
|
'daemon': daemon_status(),
|
|
'wifi': [wifi_iface_info(n) for n in ifaces],
|
|
'clients': assoc_clients(ifaces),
|
|
'disk': disk_data(),
|
|
'uptime': uptime_data(),
|
|
'hostname': hostname_data(),
|
|
}
|
|
|
|
|
|
def h_status(ctx):
|
|
return 200, status_data()
|
|
|
|
|
|
def h_device(ctx):
|
|
rc, out, err = device_run(['ip', 'link'])
|
|
macs = re.findall(r'link/ether ([0-9a-f:]{17})', out.lower())
|
|
return 200, {'hostname': hostname_data(), 'macs': sorted(set(macs)), 'model': 'WiFi Pineapple Pager'}
|
|
|
|
|
|
# Map logical setting -> uci option. Adjust after on-device `uci show pineapd`.
|
|
def uci_show(section='pineapd'):
|
|
rc, out, err = device_run(['uci', 'show', section])
|
|
return out
|
|
|
|
|
|
def uci_set(option, value):
|
|
device_run(['uci', 'set', '%s=%s' % (option, value)])
|
|
device_run(['uci', 'commit'])
|
|
|
|
|
|
def uci_delete(option):
|
|
device_run(['uci', 'delete', option])
|
|
device_run(['uci', 'commit'])
|
|
|
|
|
|
def uci_add_list(option, value):
|
|
device_run(['uci', 'add_list', '%s=%s' % (option, value)])
|
|
device_run(['uci', 'commit'])
|
|
|
|
|
|
def _daemon_proxy(method, subpath, body=None, timeout=15):
|
|
status, data = daemon_sock_call(method, '/api/pineap/%s' % subpath, body=body, timeout=timeout)
|
|
if status != 200:
|
|
return (502 if status == 0 else status), {'error': 'daemon failed', 'detail': data}
|
|
return 200, (data if isinstance(data, dict) else {'ok': data is not None})
|
|
|
|
|
|
def h_pineap_get_config(ctx):
|
|
return _daemon_proxy('GET', 'get_config')
|
|
|
|
|
|
PINEAP_CONFIG_DEFAULTS = {
|
|
'reconpath': '/root/recon/',
|
|
'reconname': 'pager',
|
|
'payloadpath': '/root/payloads/alerts/',
|
|
'handshakepath': '/root/loot/handshakes/',
|
|
'loghandshake': False,
|
|
'logpartialhandshake': False,
|
|
'pcappath': '/root/loot/pcap',
|
|
'logpcap': False,
|
|
'logwigle': False,
|
|
'logrecon': True,
|
|
'autossidpool': False,
|
|
}
|
|
|
|
HOSTAPD_DEFAULTS = {
|
|
'mgmt_ifaces': ['wlan0mgmt'],
|
|
'wpa_ifaces': ['wlan0wpa'],
|
|
'pineap_disabled': False,
|
|
'pineape_disabled': False,
|
|
'pineape_auth_pass': True,
|
|
}
|
|
|
|
|
|
def h_pineap_set_config(ctx):
|
|
body = ctx.body or {}
|
|
_, cur = daemon_sock_call('GET', '/api/pineap/get_config')
|
|
base = dict(PINEAP_CONFIG_DEFAULTS)
|
|
if isinstance(cur, dict) and 'reconpath' in cur:
|
|
base.update(cur)
|
|
base.update(body)
|
|
status, data = _daemon_proxy('PUT', 'set_config', base)
|
|
if status == 200 and 'autossidpool' in body:
|
|
update_pineap_state(mode='advanced', collect=bool(body['autossidpool']))
|
|
return status, data
|
|
|
|
|
|
def h_pineap_hostapd_get(ctx):
|
|
return _daemon_proxy('GET', 'hostapd/get_config')
|
|
|
|
|
|
def h_pineap_hostapd_set(ctx):
|
|
body = ctx.body or {}
|
|
_, cur = daemon_sock_call('GET', '/api/pineap/hostapd/get_config')
|
|
base = dict(HOSTAPD_DEFAULTS)
|
|
if isinstance(cur, dict) and 'mgmt_ifaces' in cur:
|
|
base.update(cur)
|
|
base.update({k: v for k, v in body.items() if k in HOSTAPD_DEFAULTS})
|
|
return _daemon_proxy('PUT', 'hostapd/set_config', base)
|
|
|
|
|
|
def h_pineap_enable(ctx):
|
|
enable = bool((ctx.body or {}).get('enable'))
|
|
status, data = _daemon_proxy('PUT', 'hostapd/enable_pineap', {'enable': enable})
|
|
if status == 200:
|
|
update_pineap_state(mode='advanced', enabled=enable)
|
|
return status, data
|
|
|
|
|
|
def h_pineap_mimic(ctx):
|
|
enable = bool((ctx.body or {}).get('enable'))
|
|
status, data = _daemon_proxy('POST', 'mimic/enable' if enable else 'mimic/disable')
|
|
if status == 200:
|
|
update_pineap_state(mode='advanced', karma=enable)
|
|
return status, data
|
|
|
|
|
|
def h_pineap_examine(ctx):
|
|
body = ctx.body or {}
|
|
if body.get('reset'):
|
|
return _daemon_proxy('POST', 'examine/reset', {'reset': True})
|
|
if body.get('bssid'):
|
|
req = {'bssid': body['bssid']}
|
|
if body.get('seconds') is not None:
|
|
req['seconds'] = int(body['seconds'])
|
|
return _daemon_proxy('POST', 'examine/bssid', req)
|
|
if body.get('channel') is not None:
|
|
return _daemon_proxy('POST', 'examine/channel', {'channel': str(int(body['channel']))})
|
|
return 400, {'error': 'examine requires bssid, channel or reset'}
|
|
|
|
|
|
def _uci_values(section):
|
|
"""Return the simple key/value fields emitted by ``uci show``."""
|
|
rc, out, err = device_run(['uci', 'show', section])
|
|
cfg = {}
|
|
for line in out.splitlines():
|
|
line = line.strip()
|
|
if '=' not in line:
|
|
continue
|
|
k, _, v = line.partition('=')
|
|
cfg[k.rsplit('.', 1)[-1]] = v.strip("'")
|
|
return cfg
|
|
|
|
|
|
def _uci_wifi_iface(name):
|
|
return _uci_values('wireless.%s' % name)
|
|
|
|
|
|
BAND_2G = '2.4'
|
|
BAND_5G = '5'
|
|
BAND_6G = '6'
|
|
|
|
CHANNEL_BANDS = {
|
|
BAND_2G: list(range(1, 15)),
|
|
BAND_5G: list(range(36, 178)),
|
|
BAND_6G: list(range(181, 234, 4)),
|
|
}
|
|
DFS_CHANNELS = frozenset([52, 56, 60, 64, 100, 104, 108, 112, 116, 120,
|
|
124, 128, 132, 136, 140, 144])
|
|
|
|
|
|
_last_reconcile = 0.0
|
|
|
|
|
|
def channel_band(ch):
|
|
if ch is None:
|
|
return None
|
|
try:
|
|
ch = int(ch)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
if 1 <= ch <= 14:
|
|
return BAND_2G
|
|
if 36 <= ch <= 177:
|
|
return BAND_5G
|
|
if 177 < ch <= 233 and (ch - 1) % 4 == 0:
|
|
return BAND_6G
|
|
return None
|
|
|
|
|
|
def channel_freq(band, ch):
|
|
if band == BAND_2G:
|
|
return 2412 + (ch - 1) * 5
|
|
if band == BAND_5G:
|
|
return 5180 + (ch - 36) * 5
|
|
if band == BAND_6G:
|
|
return 5955 + (ch - 1) * 5
|
|
return None
|
|
|
|
|
|
def band_htmode(band):
|
|
return {BAND_2G: 'HT20', BAND_5G: 'VHT80', BAND_6G: 'HE80'}.get(band)
|
|
|
|
|
|
def band_radio(band):
|
|
return 'radio0' if band == BAND_2G else 'radio1'
|
|
|
|
|
|
def _uci_section(section):
|
|
return _uci_values(section)
|
|
|
|
|
|
def h_pineap_wifi_get_ap(ctx):
|
|
global _last_reconcile
|
|
|
|
def _iface_state(open_name, wpa_name, radio_name):
|
|
open_cfg = _uci_wifi_iface(open_name)
|
|
wpa_cfg = _uci_wifi_iface(wpa_name)
|
|
radio_cfg = _uci_wifi_iface(radio_name)
|
|
|
|
def _chan(cfg):
|
|
channel = cfg.get('channel') or ''
|
|
try:
|
|
return int(channel)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
radio_channel = _chan(radio_cfg)
|
|
open_channel = _chan(open_cfg) or radio_channel
|
|
wpa_channel = _chan(wpa_cfg) or radio_channel
|
|
encryption = wpa_cfg.get('encryption') or ''
|
|
if encryption.startswith('psk2'):
|
|
encryption = 'psk2'
|
|
elif encryption.startswith('sae'):
|
|
encryption = 'sae'
|
|
elif encryption.startswith('owe'):
|
|
encryption = 'owe'
|
|
return open_cfg, wpa_cfg, radio_cfg, open_channel, wpa_channel, encryption
|
|
|
|
if _uci_wifi_iface('wlan1open') or _uci_wifi_iface('wlan1wpa'):
|
|
open_cfg, wpa_cfg, radio_cfg, open_channel, wpa_channel, encryption = _iface_state(
|
|
'wlan1open', 'wlan1wpa', 'radio1')
|
|
else:
|
|
open_cfg, wpa_cfg, radio_cfg, open_channel, wpa_channel, encryption = _iface_state(
|
|
'wlan0open', 'wlan0wpa', 'radio0')
|
|
status, data = daemon_sock_call('GET', '/api/pineap/hostapd/get_config')
|
|
host = data if status == 200 and isinstance(data, dict) else {}
|
|
status2, data2 = daemon_sock_call('GET', '/api/pineap/get_config')
|
|
pinecfg = data2 if status2 == 200 and isinstance(data2, dict) else {}
|
|
pool = _uci_section('pineapd.@ssidpool[0]')
|
|
radio1 = _uci_wifi_iface('radio1')
|
|
for name in ('wlan1open', 'wlan1wpa'):
|
|
cfg = _uci_wifi_iface(name)
|
|
if cfg and cfg.get('disabled') != '1' and not os.path.exists('/sys/class/net/%s' % name):
|
|
if time.time() - _last_reconcile > 30:
|
|
_last_reconcile = time.time()
|
|
device_run(['wifi', 'reload'])
|
|
break
|
|
return 200, {
|
|
'open': {
|
|
'enabled': open_cfg.get('disabled') == '0',
|
|
'ssid': open_cfg.get('ssid') or '',
|
|
'bssid': open_cfg.get('macaddr') or '',
|
|
'target': pool.get('target') or None,
|
|
'hidden': open_cfg.get('hidden') == '1',
|
|
'channel': open_channel,
|
|
'country': radio_cfg.get('country') or '',
|
|
},
|
|
'wpa': {
|
|
'ssid': wpa_cfg.get('ssid') or '',
|
|
'passphrase': wpa_cfg.get('key') or '',
|
|
'enctype': encryption,
|
|
'hidden': wpa_cfg.get('hidden') == '1',
|
|
'enabled': wpa_cfg.get('disabled') == '0',
|
|
'channel': wpa_channel,
|
|
},
|
|
'enterprise': {'enabled': not host.get('pineape_disabled', True)},
|
|
'pool': {'disabled': None, 'collecting': bool(pinecfg.get('autossidpool'))},
|
|
'radio1': {
|
|
'band': {'2g': BAND_2G, '5g': BAND_5G, '6g': BAND_6G}.get(
|
|
(radio1 or {}).get('band'), BAND_5G),
|
|
'channel': (radio1 or {}).get('channel') or 'auto',
|
|
'htmode': (radio1 or {}).get('htmode') or 'VHT80',
|
|
'country': (radio1 or {}).get('country') or '',
|
|
},
|
|
}
|
|
|
|
|
|
def _apply_open_radio(openap):
|
|
"""Persist the Open AP's radio channel/country to wireless.radio0. The
|
|
daemon's iface-level channel write does not affect the actual radio, so
|
|
apply channel/country here and reload wifi when they change."""
|
|
changed = False
|
|
radio = _uci_wifi_iface('radio0') or {}
|
|
for key in ('channel', 'country'):
|
|
value = openap.get(key)
|
|
if value is None:
|
|
continue
|
|
if str(value) != (radio.get(key) or ''):
|
|
device_run(['uci', 'set', 'wireless.radio0.%s=%s' % (key, value)])
|
|
changed = True
|
|
if changed:
|
|
device_run(['uci', 'commit', 'wireless'])
|
|
device_run(['wifi', 'reload'])
|
|
|
|
|
|
def _open_channel(value):
|
|
if value is None:
|
|
return 1
|
|
try:
|
|
return int(value)
|
|
except (TypeError, ValueError):
|
|
return 1
|
|
|
|
|
|
def _read_hop():
|
|
rc, out, err = device_run(['uci', 'get', 'pineapd.wlan1mon.hop'])
|
|
if rc != 0:
|
|
return None
|
|
return out.strip()
|
|
|
|
|
|
def _pause_hop():
|
|
if _read_hop() != '0':
|
|
device_run(['uci', 'set', 'pineapd.wlan1mon.hop=0'])
|
|
device_run(['uci', 'commit', 'pineapd'])
|
|
device_run(['/etc/init.d/pineapd', 'reload'])
|
|
|
|
|
|
def _resume_hop():
|
|
if _read_hop() == '0':
|
|
device_run(['uci', 'set', 'pineapd.wlan1mon.hop=1'])
|
|
device_run(['uci', 'commit', 'pineapd'])
|
|
device_run(['/etc/init.d/pineapd', 'reload'])
|
|
|
|
|
|
def _remove_radio1_ap():
|
|
device_run(['uci', 'delete', 'wireless.wlan1open'])
|
|
device_run(['uci', 'delete', 'wireless.wlan1wpa'])
|
|
device_run(['uci', 'set', 'wireless.radio1.channel=auto'])
|
|
device_run(['uci', 'set', 'wireless.radio1.band=5g'])
|
|
device_run(['uci', 'commit', 'wireless'])
|
|
_resume_hop()
|
|
|
|
|
|
def _apply_radio1_ap(openap, wpa):
|
|
band = None
|
|
if openap is not None:
|
|
band = channel_band(openap.get('channel'))
|
|
iface = 'wlan1open'
|
|
if wpa is not None:
|
|
band = channel_band(wpa.get('channel'))
|
|
iface = 'wlan1wpa'
|
|
if band not in (BAND_5G, BAND_6G):
|
|
raise ValueError('radio1 AP requires a 5GHz or 6GHz channel')
|
|
if band == BAND_6G and wpa is None:
|
|
raise ValueError('6GHz open APs are not supported (6GHz requires WPA3/OWE)')
|
|
if band == BAND_6G and wpa is not None:
|
|
if (wpa.get('enctype') or 'psk2') not in ('sae', 'owe'):
|
|
raise ValueError('6GHz requires WPA3 (sae or owe)')
|
|
device_run(['uci', 'delete', 'wireless.wlan1open'])
|
|
device_run(['uci', 'delete', 'wireless.wlan1wpa'])
|
|
device_run(['uci', 'set', 'wireless.radio1.band=%s' % ('6g' if band == BAND_6G else '5g')])
|
|
ch = int(wpa.get('channel') if wpa is not None else openap.get('channel'))
|
|
device_run(['uci', 'set', 'wireless.radio1.channel=%d' % ch])
|
|
device_run(['uci', 'set', 'wireless.radio1.htmode=%s' % band_htmode(band)])
|
|
country = (wpa or openap or {}).get('country')
|
|
if country:
|
|
device_run(['uci', 'set', 'wireless.radio1.country=%s' % country])
|
|
cfg = wpa if wpa is not None else openap
|
|
device_run(['uci', 'set', 'wireless.%s=wifi-iface' % iface])
|
|
device_run(['uci', 'set', 'wireless.%s.device=radio1' % iface])
|
|
device_run(['uci', 'set', 'wireless.%s.mode=ap' % iface])
|
|
device_run(['uci', 'set', 'wireless.%s.ifname=%s' % (iface, iface)])
|
|
device_run(['uci', 'set', 'wireless.%s.disabled=0' % iface])
|
|
device_run(['uci', 'set', 'wireless.%s.ssid=%s' % (iface, cfg.get('ssid') or '')])
|
|
device_run(['uci', 'set', 'wireless.%s.hidden=%d' % (iface, 1 if cfg.get('hidden') else 0)])
|
|
device_run(['uci', 'set', 'wireless.%s.channel=%d' % (iface, int(cfg.get('channel')))])
|
|
if wpa is not None:
|
|
device_run(['uci', 'set', 'wireless.%s.encryption=%s' % (iface, wpa.get('enctype') or 'psk2')])
|
|
device_run(['uci', 'set', 'wireless.%s.key=%s' % (iface, wpa.get('passphrase') or '')])
|
|
else:
|
|
device_run(['uci', 'set', 'wireless.%s.encryption=none' % iface])
|
|
bssid = (openap or {}).get('bssid') or ''
|
|
if bssid:
|
|
if not re.match(r'^[0-9A-F]{2}(?::[0-9A-F]{2}){5}$', bssid.upper()):
|
|
raise ValueError('invalid BSSID format')
|
|
device_run(['uci', 'set', 'wireless.%s.macaddr=%s' % (iface, bssid.upper())])
|
|
device_run(['uci', 'commit', 'wireless'])
|
|
_pause_hop()
|
|
device_run(['wifi', 'reload'])
|
|
|
|
|
|
def h_pineap_wifi_set_ap(ctx):
|
|
body = ctx.body or {}
|
|
wpa = body.get('wpa') or {}
|
|
openap = body.get('open') or {}
|
|
wpa_band = channel_band(wpa.get('channel')) if wpa.get('channel') is not None else None
|
|
open_band = channel_band(openap.get('channel')) if openap.get('channel') is not None else None
|
|
use_radio1 = wpa_band in (BAND_5G, BAND_6G) or open_band in (BAND_5G, BAND_6G)
|
|
if use_radio1:
|
|
wpa_active = (wpa_band in (BAND_5G, BAND_6G) and bool(wpa.get('enabled', True))
|
|
and wpa.get('channel') is not None)
|
|
open_active = (open_band in (BAND_5G, BAND_6G) and bool(openap.get('enabled', True))
|
|
and openap.get('channel') is not None)
|
|
wpa_present = bool(wpa.get('ssid') or wpa.get('enabled') is not None)
|
|
open_present = bool(openap.get('ssid') or openap.get('enabled') is not None)
|
|
if (wpa_present and wpa_band == BAND_2G) or (open_present and open_band == BAND_2G):
|
|
return 400, {'error': 'cannot configure 2.4GHz and radio1 APs in one request'}
|
|
if not (wpa_active or open_active):
|
|
_remove_radio1_ap()
|
|
device_run(['wifi', 'reload'])
|
|
return 200, {'ok': True}
|
|
try:
|
|
_apply_radio1_ap(openap if open_active else None,
|
|
wpa if wpa_active else None)
|
|
except ValueError as exc:
|
|
return 400, {'error': str(exc)}
|
|
return 200, {'ok': True}
|
|
if _uci_wifi_iface('wlan1open') or _uci_wifi_iface('wlan1wpa'):
|
|
_remove_radio1_ap()
|
|
device_run(['wifi', 'reload'])
|
|
configs = []
|
|
if wpa.get('ssid') or wpa.get('enabled') is not None:
|
|
configs.append({
|
|
'interface': 'wlan0wpa',
|
|
'ssid': wpa.get('ssid', ''),
|
|
'enctype': wpa.get('enctype') or 'psk2',
|
|
'enabled': bool(wpa.get('enabled', True)),
|
|
'hidden': bool(wpa.get('hidden', False)),
|
|
'key': wpa.get('passphrase') or '',
|
|
'channel': 1,
|
|
})
|
|
if openap.get('ssid') or openap.get('enabled') is not None:
|
|
configs.append({
|
|
'interface': 'wlan0open',
|
|
'ssid': openap.get('ssid', ''),
|
|
'enctype': 'none',
|
|
'enabled': bool(openap.get('enabled', True)),
|
|
'hidden': bool(openap.get('hidden', False)),
|
|
'channel': _open_channel(openap.get('channel')),
|
|
'bssid': openap.get('bssid') or '',
|
|
})
|
|
if not configs:
|
|
return 400, {'error': 'no configuration provided'}
|
|
status, data = daemon_sock_call('PUT', '/api/settings/wifi/set_ap', body={'configs': configs}, timeout=45)
|
|
if status != 200:
|
|
return (502 if status == 0 else status), {'error': 'daemon failed', 'detail': data}
|
|
_apply_open_radio(openap)
|
|
return 200, {'ok': True}
|
|
|
|
|
|
def h_pineap_advertise(ctx):
|
|
enable = bool((ctx.body or {}).get('enable'))
|
|
status, data = _daemon_proxy('POST', 'ssidpool/enable' if enable else 'ssidpool/disable', {'enable': enable})
|
|
if status == 200:
|
|
update_pineap_state(mode='advanced', advertise=enable)
|
|
return status, data
|
|
|
|
|
|
def h_pineap_collect(ctx):
|
|
enable = bool((ctx.body or {}).get('enable'))
|
|
status, data = _daemon_proxy('POST', 'ssidpool/enable_collect' if enable else 'ssidpool/disable_collect', {'enable': enable})
|
|
if status == 200:
|
|
update_pineap_state(mode='advanced', collect=enable)
|
|
return status, data
|
|
|
|
|
|
def h_pineap_interfaces(ctx):
|
|
return _daemon_proxy('PUT', 'interfaces/set_interface', ctx.body or {})
|
|
|
|
|
|
def hak5(*args, timeout=30):
|
|
rc, out, err = device_run([HAK5CMD] + list(args), timeout=timeout)
|
|
return out
|
|
|
|
|
|
def _json_or(text):
|
|
text = text.strip()
|
|
if text.startswith('{') or text.startswith('['):
|
|
try:
|
|
return json.loads(text)
|
|
except ValueError:
|
|
return None
|
|
return None
|
|
|
|
|
|
def _parse_pool_list(text):
|
|
obj = _json_or(text)
|
|
if isinstance(obj, dict) and 'ssids' in obj:
|
|
return [str(s) for s in obj['ssids']]
|
|
if isinstance(obj, list):
|
|
return [str(s) for s in obj]
|
|
out = []
|
|
for line in text.splitlines():
|
|
line = line.strip().strip('"')
|
|
low = line.lower()
|
|
if not line:
|
|
continue
|
|
if low in ('ssid', 'ssids') or low.startswith('ssid pool') or low.startswith('no '):
|
|
continue
|
|
out.append(line)
|
|
return out
|
|
|
|
|
|
def h_ssids_get(ctx):
|
|
return 200, {'ssids': _parse_pool_list(hak5('PINEAPPLE_SSID_POOL_LIST'))}
|
|
|
|
|
|
def h_ssids_post(ctx):
|
|
body = ctx.body or {}
|
|
action = body.get('action')
|
|
if action == 'add':
|
|
ssid = (body.get('ssid') or '').strip()
|
|
if not ssid:
|
|
return 400, {'error': 'ssid required'}
|
|
hak5('PINEAPPLE_SSID_POOL_ADD', ssid)
|
|
elif action == 'remove':
|
|
hak5('PINEAPPLE_SSID_POOL_DELETE', (body.get('ssid') or '').strip())
|
|
elif action == 'clear':
|
|
hak5('PINEAPPLE_SSID_POOL_CLEAR')
|
|
else:
|
|
return 400, {'error': 'unknown action'}
|
|
return 200, {'ssids': _parse_pool_list(hak5('PINEAPPLE_SSID_POOL_LIST'))}
|
|
|
|
|
|
FILTER_DAEMON = {
|
|
'client': ('macfilter/get_config', 'macfilter/set_mode', 'PINEAPPLE_DEVICE_FILTER'),
|
|
'ssid': ('ssidfilter/get_config', 'ssidfilter/set_config', 'PINEAPPLE_NETWORK_FILTER'),
|
|
}
|
|
|
|
|
|
def load_pineap_state():
|
|
try:
|
|
with open(PINEAP_STATE_FILE) as f:
|
|
state = json.load(f)
|
|
return state if isinstance(state, dict) else {}
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
def save_pineap_state(state):
|
|
tmp = PINEAP_STATE_FILE + '.tmp'
|
|
with open(tmp, 'w') as f:
|
|
json.dump(state, f)
|
|
os.replace(tmp, PINEAP_STATE_FILE)
|
|
|
|
|
|
def update_pineap_state(mode=None, **flags):
|
|
state = load_pineap_state()
|
|
if mode is not None:
|
|
state['mode'] = mode
|
|
state.update(flags)
|
|
save_pineap_state(state)
|
|
return state
|
|
|
|
|
|
def h_pineap_mode_get(ctx):
|
|
state = load_pineap_state()
|
|
mode = state.get('mode')
|
|
_, config = daemon_sock_call('GET', '/api/pineap/get_config')
|
|
_, hostapd = daemon_sock_call('GET', '/api/pineap/hostapd/get_config')
|
|
collect = config.get('autossidpool') if isinstance(config, dict) else None
|
|
enabled = None
|
|
if isinstance(hostapd, dict) and 'pineap_disabled' in hostapd:
|
|
enabled = not bool(hostapd['pineap_disabled'])
|
|
|
|
# On the Pager, the Mimic/PineAP switch is the response engine itself:
|
|
# Passive intentionally leaves it disabled, while Active enables it.
|
|
# Treat only a real mismatch with that preset (or disabled collection) as
|
|
# a custom/Advanced setup.
|
|
expected_enabled = {'passive': False, 'active': True}.get(mode)
|
|
engine_mismatch = (enabled is not None and expected_enabled is not None
|
|
and enabled != expected_enabled)
|
|
if mode in ('passive', 'active') and (collect is False or engine_mismatch):
|
|
mode = 'advanced'
|
|
state = update_pineap_state(mode='advanced', collect=collect)
|
|
elif mode not in ('passive', 'active', 'advanced'):
|
|
mode = 'advanced' if collect is False or enabled is False else 'unknown'
|
|
|
|
result = dict(state)
|
|
result['mode'] = mode
|
|
if collect is not None:
|
|
result['collect'] = bool(collect)
|
|
if enabled is not None:
|
|
result['enabled'] = enabled
|
|
return 200, result
|
|
|
|
|
|
def h_pineap_mode_post(ctx):
|
|
mode = ((ctx.body or {}).get('mode') or '').strip().lower()
|
|
if mode not in ('passive', 'active', 'advanced'):
|
|
return 400, {'error': 'mode must be passive, active, or advanced'}
|
|
if mode == 'advanced':
|
|
return 200, update_pineap_state(mode='advanced')
|
|
|
|
enabled = mode == 'active'
|
|
steps = [
|
|
(('enable' if enabled else 'disable') + ' PineAP response engine',
|
|
'PUT', 'hostapd/enable_pineap', {'enable': enabled}),
|
|
('enable SSID collection', 'POST', 'ssidpool/enable_collect', {'enable': True}),
|
|
(('enable' if mode == 'active' else 'disable') + ' SSID pool broadcasting',
|
|
'POST', 'ssidpool/enable' if mode == 'active' else 'ssidpool/disable',
|
|
{'enable': mode == 'active'}),
|
|
]
|
|
for label, method, path, body in steps:
|
|
status, data = _daemon_proxy(method, path, body)
|
|
if status != 200:
|
|
return status, {'error': 'failed to ' + label, 'detail': data}
|
|
return 200, update_pineap_state(mode=mode, enabled=enabled, karma=enabled,
|
|
collect=True, advertise=(mode == 'active'))
|
|
|
|
|
|
def h_filter_get(ctx, kind):
|
|
get_path, set_path, hak5_prefix = FILTER_DAEMON[kind]
|
|
status, data = daemon_sock_call('GET', '/api/pineap/%s' % get_path)
|
|
if status != 200 or not isinstance(data, dict):
|
|
return (502 if status == 0 else status), {'error': 'daemon failed', 'detail': data}
|
|
mode = data.get('mode') or 'allow'
|
|
if kind == 'client':
|
|
entries = data.get('denied_macs') if mode == 'deny' else data.get('allowed_macs')
|
|
else:
|
|
entries = data.get('denied_ssids') if mode == 'deny' else data.get('allowed_ssids')
|
|
values = [str(e) for e in (entries or [])]
|
|
if kind == 'ssid':
|
|
decoded = []
|
|
for value in values:
|
|
try:
|
|
raw = base64.b64decode(value, validate=True)
|
|
decoded.append(raw.decode('utf-8'))
|
|
except (ValueError, TypeError):
|
|
# Older daemon builds may return literal SSIDs instead.
|
|
decoded.append(value)
|
|
values = decoded
|
|
return 200, {'mode': mode, 'entries': values}
|
|
|
|
|
|
def h_filter_post(ctx, kind):
|
|
body = ctx.body or {}
|
|
action = body.get('action')
|
|
_, _, prefix = FILTER_DAEMON[kind]
|
|
status, current = h_filter_get(ctx, kind)
|
|
if status != 200:
|
|
return status, current
|
|
mode = (body.get('mode') or current.get('mode') or '').strip()
|
|
if mode not in ('allow', 'deny'):
|
|
return 400, {'error': 'mode must be allow or deny'}
|
|
|
|
def run_filter(command, *args):
|
|
rc, out, err = device_run([HAK5CMD, command] + list(args), timeout=30)
|
|
if rc != 0:
|
|
return 502, {'error': 'filter command failed', 'detail': err or out}
|
|
return None
|
|
|
|
if action == 'set_mode':
|
|
failed = run_filter('%s_MODE' % prefix, mode)
|
|
elif action == 'add':
|
|
value = (body.get('value') or '').strip()
|
|
if not value:
|
|
return 400, {'error': 'value required'}
|
|
failed = run_filter('%s_ADD' % prefix, mode, value)
|
|
elif action == 'delete':
|
|
value = (body.get('value') or '').strip()
|
|
if not value:
|
|
return 400, {'error': 'value required'}
|
|
failed = run_filter('%s_DELETE' % prefix, mode, value)
|
|
elif action == 'clear':
|
|
failed = run_filter('%s_CLEAR' % prefix, mode)
|
|
elif action == 'allow_all':
|
|
# "deny" mode means allow by default. An empty deny list therefore
|
|
# allows every client/SSID without manufacturing wildcard entries.
|
|
failed = run_filter('%s_CLEAR' % prefix, 'deny')
|
|
if not failed:
|
|
failed = run_filter('%s_MODE' % prefix, 'deny')
|
|
else:
|
|
return 400, {'error': 'unknown action'}
|
|
if failed:
|
|
return failed
|
|
return h_filter_get(ctx, kind)
|
|
|
|
|
|
ENTERPRISE_TABLES = {'basic': 'hostap_basic', 'challenge': 'hostap_challenge'}
|
|
|
|
|
|
def h_enterprise_data(ctx):
|
|
table = ENTERPRISE_TABLES.get((ctx.args or [''])[0])
|
|
if not table:
|
|
return 400, {'error': 'unknown table'}
|
|
rows = _db_rows(RECON_DB, 'SELECT * FROM %s ORDER BY time' % table)
|
|
return 200, {'table': table, 'rows': rows or []}
|
|
|
|
|
|
def h_enterprise_clear(ctx):
|
|
table = ENTERPRISE_TABLES.get((ctx.body or {}).get('table', ''))
|
|
if not table:
|
|
return 400, {'error': 'unknown table'}
|
|
try:
|
|
_db_write(RECON_DB, 'DELETE FROM %s' % table)
|
|
except RuntimeError as e:
|
|
return 502, {'error': str(e)}
|
|
return 200, {'ok': True}
|
|
|
|
|
|
def _proxy_json(method, path, body=None):
|
|
status, data = daemon_call(method, path, body=body, token=current_token())
|
|
if status != 200:
|
|
return (502 if status == 0 else status), {'error': 'daemon failed', 'detail': data}
|
|
return 200, (data if isinstance(data, dict) else {'ok': True})
|
|
|
|
|
|
def _payload_detail(data):
|
|
if isinstance(data, dict):
|
|
text = data.get('error') or data.get('detail')
|
|
if isinstance(text, str):
|
|
return text
|
|
return json.dumps(data)
|
|
if isinstance(data, bytes):
|
|
data = data.decode('utf-8', 'replace')
|
|
if isinstance(data, str):
|
|
try:
|
|
parsed = json.loads(data)
|
|
except Exception:
|
|
return data
|
|
if isinstance(parsed, dict):
|
|
text = parsed.get('error') or parsed.get('detail')
|
|
if isinstance(text, str):
|
|
return text
|
|
return data
|
|
return str(data) if data is not None else ''
|
|
|
|
|
|
def _payload_daemon(method, path, body=None):
|
|
status, data = daemon_call(method, path, body=body, token=current_token(), timeout=45)
|
|
if status != 200:
|
|
return (502 if status == 0 else status), {
|
|
'error': 'Pager payload service failed', 'detail': _payload_detail(data)}
|
|
if not isinstance(data, (dict, list)):
|
|
return 502, {'error': 'Pager payload service returned an invalid response'}
|
|
return 200, data
|
|
|
|
|
|
def _payload_key(value):
|
|
value = str(value or '')
|
|
return value if re.match(r'^[A-Za-z0-9._~-]+$', value) else ''
|
|
|
|
|
|
def _payload_installed():
|
|
status, data = _payload_daemon('POST', '/api/payloads/portal/updates', {})
|
|
if status != 200:
|
|
return status, data
|
|
rows = []
|
|
for record in data if isinstance(data, list) else data.get('payloads', []):
|
|
if not isinstance(record, dict):
|
|
continue
|
|
item = record.get('installed') or {}
|
|
if not isinstance(item, dict):
|
|
continue
|
|
rows.append({
|
|
'uuid': item.get('uuid', ''),
|
|
'key': item.get('key', ''),
|
|
'path': item.get('path', ''),
|
|
'category': item.get('category', ''),
|
|
'title': item.get('title') or item.get('key', ''),
|
|
'author': item.get('author', ''),
|
|
'description': item.get('description', ''),
|
|
'version': item.get('version', ''),
|
|
'launchpoint': item.get('launchpoint') or 'payload.sh',
|
|
'interpreter': item.get('interpreter', ''),
|
|
'disabled': bool(item.get('disabled')),
|
|
'missingmanifest': bool(record.get('missingmanifest')),
|
|
'update': record.get('update') if isinstance(record.get('update'), dict) else None
|
|
})
|
|
return 200, {'payloads': rows}
|
|
|
|
|
|
def _payload_record(key):
|
|
status, data = _payload_installed()
|
|
if status != 200:
|
|
return status, data
|
|
for item in data.get('payloads', []):
|
|
if item.get('key') == key:
|
|
return 200, item
|
|
return 404, {'error': 'Installed payload not found'}
|
|
|
|
|
|
def _payload_safe_launch(item):
|
|
base = os.path.realpath(str(item.get('path') or ''))
|
|
launch = os.path.realpath(os.path.join(base, str(item.get('launchpoint') or 'payload.sh')))
|
|
allowed = any(base == root or base.startswith(root + os.sep) for root in PAYLOAD_ROOTS)
|
|
if (not allowed or launch == base or not launch.startswith(base + os.sep)
|
|
or not os.path.isfile(launch)):
|
|
return None
|
|
if base == SELF_PAYLOAD_DIR:
|
|
return None
|
|
return base, launch
|
|
|
|
|
|
def _payload_run_view(run, include_output=True):
|
|
proc = run.get('_process')
|
|
returncode = proc.poll() if proc is not None else run.get('returncode')
|
|
running = returncode is None
|
|
if not running and run.get('finished') is None:
|
|
run['finished'] = int(time.time())
|
|
run['returncode'] = returncode
|
|
view = {key: value for key, value in run.items() if not key.startswith('_') and key != 'log'}
|
|
view.update({'running': running, 'returncode': returncode})
|
|
if include_output:
|
|
output = ''
|
|
try:
|
|
with open(run.get('log', ''), 'rb') as handle:
|
|
handle.seek(0, 2)
|
|
size = handle.tell()
|
|
handle.seek(max(0, size - 65536))
|
|
output = handle.read().decode('utf-8', 'replace')
|
|
except OSError:
|
|
pass
|
|
view['output'] = output
|
|
return view
|
|
|
|
|
|
def h_payloads_index(ctx):
|
|
return _payload_daemon('GET', '/api/payloads/portal/index')
|
|
|
|
|
|
def h_payloads_installed(ctx):
|
|
return _payload_installed()
|
|
|
|
|
|
def h_payloads_refresh(ctx):
|
|
return _payload_daemon('POST', '/api/payloads/portal/refresh', {})
|
|
|
|
|
|
def h_payloads_install(ctx):
|
|
key = _payload_key((ctx.body or {}).get('key', ''))
|
|
if not key:
|
|
return 400, {'error': 'key required'}
|
|
return _payload_daemon('POST', '/api/payloads/portal/%s/install' % key, {})
|
|
|
|
|
|
def h_payloads_remove(ctx):
|
|
key = _payload_key((ctx.body or {}).get('key', ''))
|
|
if not key:
|
|
return 400, {'error': 'key required'}
|
|
if key == SELF_PAYLOAD_KEY:
|
|
return 409, {'error': 'The active Mark VIII payload cannot remove itself'}
|
|
return _payload_daemon('POST', '/api/payloads/portal/%s/remove' % key, {})
|
|
|
|
|
|
def h_payloads_runs(ctx):
|
|
with _payload_runs_lock:
|
|
rows = [_payload_run_view(run) for run in _payload_runs.values()]
|
|
rows.sort(key=lambda row: row.get('started', 0), reverse=True)
|
|
return 200, {'runs': rows}
|
|
|
|
|
|
def h_payloads_run(ctx):
|
|
key = _payload_key((ctx.body or {}).get('key', ''))
|
|
if not key:
|
|
return 400, {'error': 'valid key required'}
|
|
status, item = _payload_record(key)
|
|
if status != 200:
|
|
return status, item
|
|
if item.get('disabled'):
|
|
return 409, {'error': 'This payload is disabled'}
|
|
safe = _payload_safe_launch(item)
|
|
if not safe:
|
|
if os.path.realpath(str(item.get('path') or '')) == SELF_PAYLOAD_DIR:
|
|
return 409, {'error': 'The Mark VIII payload cannot launch itself'}
|
|
return 409, {'error': 'Payload launchpoint is unavailable or unsafe'}
|
|
base, launch = safe
|
|
interpreter = str(item.get('interpreter') or '').strip()
|
|
command = [interpreter, launch] if interpreter else [launch]
|
|
try:
|
|
os.makedirs(PAYLOAD_RUN_DIR, exist_ok=True)
|
|
seed = '%s:%s:%s' % (key, time.time(), os.getpid())
|
|
run_id = hashlib.sha256(seed.encode()).hexdigest()[:12]
|
|
log_path = os.path.join(PAYLOAD_RUN_DIR, run_id + '.log')
|
|
log_handle = open(log_path, 'wb')
|
|
try:
|
|
proc = subprocess.Popen(command, cwd=base, stdout=log_handle,
|
|
stderr=subprocess.STDOUT, start_new_session=True)
|
|
finally:
|
|
log_handle.close()
|
|
except (OSError, ValueError) as exc:
|
|
return 500, {'error': 'Unable to launch payload: %s' % exc}
|
|
run = {
|
|
'id': run_id, 'key': key, 'title': item.get('title') or key,
|
|
'category': item.get('category', ''), 'path': base, 'pid': proc.pid,
|
|
'started': int(time.time()), 'finished': None, 'returncode': None,
|
|
'log': log_path, '_process': proc
|
|
}
|
|
with _payload_runs_lock:
|
|
_payload_runs[run_id] = run
|
|
return 200, {'run': _payload_run_view(run)}
|
|
|
|
|
|
def h_payloads_stop(ctx):
|
|
run_id = str((ctx.body or {}).get('id', ''))
|
|
with _payload_runs_lock:
|
|
run = _payload_runs.get(run_id)
|
|
if not run:
|
|
return 404, {'error': 'WebUI payload run not found'}
|
|
proc = run.get('_process')
|
|
if proc is None or proc.poll() is not None:
|
|
return 409, {'error': 'Payload is not running'}
|
|
try:
|
|
if hasattr(os, 'killpg'):
|
|
os.killpg(proc.pid, signal.SIGTERM)
|
|
else:
|
|
proc.terminate()
|
|
except OSError as exc:
|
|
return 500, {'error': 'Unable to stop payload: %s' % exc}
|
|
return 200, {'ok': True}
|
|
|
|
|
|
def _tail(text, lines):
|
|
return text.splitlines()[-lines:] if lines else []
|
|
|
|
|
|
def _line_count(ctx, default, maximum=2000):
|
|
"""Parse and bound a log-tail line count from an HTTP query."""
|
|
try:
|
|
value = int(ctx.query.get('lines', default))
|
|
except (AttributeError, TypeError, ValueError):
|
|
value = default
|
|
return max(0, min(maximum, value))
|
|
|
|
|
|
def h_logging_system(ctx):
|
|
lines = _line_count(ctx, 200)
|
|
rc, out, err = device_run(['logread'])
|
|
return 200, {'lines': _tail(out, lines)}
|
|
|
|
|
|
PINEAP_LOG = '/var/log/pineapd.log'
|
|
|
|
|
|
def h_logging_pineap(ctx):
|
|
lines = _line_count(ctx, 200)
|
|
if os.path.isfile(PINEAP_LOG):
|
|
with open(PINEAP_LOG, 'r', errors='replace') as f:
|
|
return 200, {'lines': _tail(f.read(), lines)}
|
|
rc, out, err = device_run(['logread'])
|
|
relevant = [l for l in out.splitlines() if 'pineap' in l.lower()]
|
|
return 200, {'lines': relevant[-lines:]}
|
|
|
|
|
|
def h_settings_hostname(ctx):
|
|
if ctx.h.command == 'POST':
|
|
hostname = (ctx.body or {}).get('hostname', '').strip()
|
|
if not hostname:
|
|
return 400, {'error': 'hostname required'}
|
|
uci_set('system.@system[0].hostname', hostname)
|
|
return 200, {'hostname': hostname_data()}
|
|
|
|
|
|
def h_settings_password(ctx):
|
|
body = ctx.body or {}
|
|
newpw = body.get('new_password') or body.get('password', '')
|
|
if not newpw:
|
|
return 400, {'error': 'password required'}
|
|
if len(newpw) < 4:
|
|
return 400, {'error': 'password must be at least 4 characters'}
|
|
if any(ch in newpw for ch in ('\x00', '\r', '\n')):
|
|
return 400, {'error': 'password contains unsupported characters'}
|
|
repeat = body.get('repeat_password')
|
|
if repeat is not None and repeat != newpw:
|
|
return 400, {'error': 'new passwords do not match'}
|
|
current = body.get('current_password')
|
|
if current is not None:
|
|
status, data = daemon_call('POST', '/api/login',
|
|
body={'username': 'root', 'password': current})
|
|
if status != 200:
|
|
return 403, {'error': 'current password is incorrect'}
|
|
password_input = ('%s\n%s\n' % (newpw, newpw)).encode('utf-8')
|
|
rc, _, err = device_run(['/bin/passwd', 'root'], timeout=15,
|
|
input_data=password_input)
|
|
if rc != 0:
|
|
return 500, {'error': err.strip() or 'password change failed'}
|
|
try:
|
|
os.unlink(SESSION_FILE)
|
|
except OSError:
|
|
pass
|
|
return 200, {'ok': True}
|
|
|
|
|
|
def h_settings_ntp(ctx):
|
|
if ctx.h.command == 'POST':
|
|
body = ctx.body or {}
|
|
enabled = '1' if body.get('enabled', True) else '0'
|
|
uci_set('system.ntp.enabled', enabled)
|
|
servers = body.get('servers', [])
|
|
if isinstance(servers, list):
|
|
uci_delete('system.ntp.server')
|
|
for s in servers:
|
|
if s.strip():
|
|
uci_add_list('system.ntp.server', s.strip())
|
|
device_run(['/etc/init.d/sysntpd', 'restart'])
|
|
rc, out, err = device_run(['uci', 'show', 'system.ntp'])
|
|
raw = {}
|
|
for line in out.splitlines():
|
|
if '=' in line:
|
|
k, v = line.split('=', 1)
|
|
raw[k.strip()] = v.strip()
|
|
def values(value):
|
|
"""Parse the one-or-many shell-quoted values emitted by `uci show`."""
|
|
result = []
|
|
for match in re.finditer(r"'([^']*)'|\"([^\"]*)\"|([^\s]+)", value or ''):
|
|
result.append(next((g for g in match.groups() if g is not None), ''))
|
|
return result
|
|
servers = []
|
|
for k, v in raw.items():
|
|
if k.endswith('.server'):
|
|
servers.extend(values(v))
|
|
enabled_values = values(raw.get('system.ntp.enabled', '1'))
|
|
enabled = (enabled_values[0] if enabled_values else '1') != '0'
|
|
return 200, {'enabled': enabled, 'servers': servers}
|
|
|
|
|
|
def h_settings_service(ctx):
|
|
rc, out, err = device_run(['/etc/init.d/pagerwebui', 'running'])
|
|
running = rc == 0
|
|
rc2, out2, err2 = device_run(['test', '-f', '/etc/init.d/pagerwebui'])
|
|
return 200, {'running': running, 'background': rc2 == 0}
|
|
|
|
|
|
def _uci_get(option, default=''):
|
|
rc, out, err = device_run(['uci', 'get', option])
|
|
return out.strip() if rc == 0 else default
|
|
|
|
|
|
def _request_is_post(ctx):
|
|
return getattr(getattr(ctx, 'h', None), 'command', 'GET') == 'POST'
|
|
|
|
|
|
def h_settings_timezone(ctx):
|
|
if _request_is_post(ctx):
|
|
body = ctx.body or {}
|
|
timezone = (body.get('timezone') or '').strip()
|
|
zonename = (body.get('zonename') or '').strip()
|
|
if not timezone or not re.match(r'^[A-Za-z0-9_+,:./-]{1,96}$', timezone):
|
|
return 400, {'error': 'invalid timezone'}
|
|
device_run(['uci', 'set', 'system.@system[0].timezone=%s' % timezone])
|
|
if zonename and re.match(r'^[A-Za-z0-9_+./-]{1,96}$', zonename):
|
|
device_run(['uci', 'set', 'system.@system[0].zonename=%s' % zonename])
|
|
else:
|
|
device_run(['uci', 'delete', 'system.@system[0].zonename'])
|
|
device_run(['uci', 'commit', 'system'])
|
|
device_run(['/etc/init.d/system', 'reload'])
|
|
return 200, {
|
|
'timezone': _uci_get('system.@system[0].timezone', 'UTC'),
|
|
'zonename': _uci_get('system.@system[0].zonename', '')
|
|
}
|
|
|
|
|
|
def h_settings_sync_time(ctx):
|
|
timestamp = (ctx.body or {}).get('timestamp', '')
|
|
if isinstance(timestamp, (int, float)):
|
|
timestamp = time.strftime('%Y-%m-%d %H:%M:%S', time.gmtime(timestamp))
|
|
timestamp = str(timestamp).strip()
|
|
if not re.match(r'^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$', timestamp):
|
|
return 400, {'error': 'timestamp must be UTC YYYY-MM-DD HH:MM:SS'}
|
|
rc, out, err = device_run(['date', '-u', '-s', timestamp])
|
|
if rc != 0:
|
|
return 502, {'error': err or out or 'failed to set time'}
|
|
device_run(['hwclock', '-w'])
|
|
return 200, {'ok': True, 'utc': timestamp}
|
|
|
|
|
|
def _parse_df_rows(text):
|
|
rows = []
|
|
for line in text.splitlines()[1:]:
|
|
parts = line.split()
|
|
if len(parts) < 7:
|
|
continue
|
|
try:
|
|
size = int(parts[2]) * 1024
|
|
used = int(parts[3]) * 1024
|
|
available = int(parts[4]) * 1024
|
|
except ValueError:
|
|
continue
|
|
rows.append({'filesystem': parts[0], 'format': parts[1], 'size': size,
|
|
'used': used, 'available': available,
|
|
'used_percent': parts[5], 'mount': ' '.join(parts[6:])})
|
|
return rows
|
|
|
|
|
|
def h_settings_resources(ctx):
|
|
rc, out, err = device_run(['df', '-PT'])
|
|
return 200, {'filesystems': _parse_df_rows(out)}
|
|
|
|
|
|
def h_settings_usb(ctx):
|
|
rc, out, err = device_run(['lsusb'])
|
|
devices = []
|
|
pattern = re.compile(r'^Bus\s+(\d+)\s+Device\s+(\d+):\s+ID\s+([0-9A-Fa-f:]+)\s*(.*)$')
|
|
for line in out.splitlines():
|
|
match = pattern.match(line.strip())
|
|
if match:
|
|
devices.append({'bus': match.group(1), 'device': match.group(2),
|
|
'id': match.group(3), 'name': match.group(4).strip()})
|
|
return 200, {'devices': devices}
|
|
|
|
|
|
def h_settings_network(ctx):
|
|
_, link_out, _ = device_run(['ip', '-o', 'link', 'show'])
|
|
_, addr_out, _ = device_run(['ip', '-o', '-4', 'addr', 'show'])
|
|
addresses = {}
|
|
for line in addr_out.splitlines():
|
|
match = re.match(r'^\d+:\s+([^\s]+)\s+inet\s+([^\s]+)', line)
|
|
if match:
|
|
addresses.setdefault(match.group(1).split('@', 1)[0], []).append(match.group(2))
|
|
interfaces = []
|
|
for line in link_out.splitlines():
|
|
match = re.match(r'^\d+:\s+([^:]+):\s+<([^>]*)>.*?(?:link/\S+\s+([^\s]+))?', line)
|
|
if not match:
|
|
continue
|
|
name = match.group(1).split('@', 1)[0]
|
|
# Monitor interfaces on the Pager report link/[803] instead of
|
|
# link/ether, but still expose a normal MAC immediately afterward.
|
|
mac_match = re.search(r'link/\S+\s+([0-9A-Fa-f:]{17})', line)
|
|
flags = [f for f in match.group(2).split(',') if f]
|
|
# The current Pager iproute build occasionally renders LOWER_UP100.
|
|
flags = [re.sub(r'100$', '', f) for f in flags]
|
|
interfaces.append({'name': name, 'addresses': addresses.get(name, []),
|
|
'mac': mac_match.group(1).upper() if mac_match else '',
|
|
'flags': flags})
|
|
_, route_out, _ = device_run(['route', '-n'])
|
|
routes = []
|
|
for line in route_out.splitlines():
|
|
parts = line.split()
|
|
if len(parts) == 8 and re.match(r'^\d+\.\d+\.\d+\.\d+$', parts[0]):
|
|
routes.append({'destination': parts[0], 'gateway': parts[1],
|
|
'genmask': parts[2], 'flags': parts[3],
|
|
'metric': parts[4], 'ref': parts[5], 'use': parts[6],
|
|
'interface': parts[7]})
|
|
return 200, {'interfaces': interfaces, 'routes': routes,
|
|
'client': {'interface': 'wlan0cli',
|
|
'enabled': _uci_get('wireless.wlan0cli.disabled', '1') == '0'},
|
|
'recon_interfaces': [i['name'] for i in interfaces
|
|
if i['name'].endswith('mon')]}
|
|
|
|
|
|
def h_settings_management_wifi(ctx):
|
|
current = _uci_wifi_iface('wlan0mgmt')
|
|
if _request_is_post(ctx):
|
|
body = ctx.body or {}
|
|
enabled = bool(body.get('enabled'))
|
|
ssid = (body.get('ssid') or current.get('ssid') or '').strip()
|
|
bssid = (body.get('bssid') or current.get('macaddr') or '').strip().upper()
|
|
password = body.get('password') or current.get('key') or ''
|
|
if enabled and not ssid:
|
|
return 400, {'error': 'SSID is required when the management AP is enabled'}
|
|
if enabled and len(password) < 8:
|
|
return 400, {'error': 'management password must be at least 8 characters'}
|
|
if bssid and not re.match(r'^[0-9A-F]{2}(?::[0-9A-F]{2}){5}$', bssid):
|
|
return 400, {'error': 'invalid BSSID'}
|
|
values = {
|
|
'ssid': ssid, 'hidden': '1' if body.get('hidden') else '0',
|
|
'disabled': '0' if enabled else '1',
|
|
'encryption': 'psk2' if password else 'none'
|
|
}
|
|
if password:
|
|
values['key'] = password
|
|
if bssid:
|
|
values['macaddr'] = bssid
|
|
for key, value in values.items():
|
|
device_run(['uci', 'set', 'wireless.wlan0mgmt.%s=%s' % (key, value)])
|
|
device_run(['uci', 'commit', 'wireless'])
|
|
rc, out, err = device_run(['wifi', 'reload'], timeout=45)
|
|
if rc != 0:
|
|
return 502, {'error': err or out or 'wireless reload failed'}
|
|
current = _uci_wifi_iface('wlan0mgmt')
|
|
return 200, {
|
|
'ssid': current.get('ssid') or '', 'bssid': current.get('macaddr') or '',
|
|
'hidden': current.get('hidden') == '1',
|
|
'enabled': current.get('disabled') == '0',
|
|
'has_password': bool(current.get('key'))
|
|
}
|
|
|
|
|
|
WIFI_CLIENT_ENCRYPTIONS = {
|
|
'none': 'none', 'open': 'none',
|
|
'wpa2': 'psk2', 'psk2': 'psk2',
|
|
'wpa3': 'sae', 'sae': 'sae',
|
|
'wpa2wpa3': 'sae-mixed', 'sae-mixed': 'sae-mixed'
|
|
}
|
|
|
|
|
|
def _freq_to_channel(freq):
|
|
if not freq:
|
|
return None
|
|
if freq < 2484:
|
|
return int((freq - 2412) / 5 + 1)
|
|
if freq == 2484:
|
|
return 14
|
|
return int((freq - 5000) / 5)
|
|
|
|
|
|
def _wifi_client_state():
|
|
cfg = _uci_wifi_iface('wlan0cli')
|
|
state = {
|
|
'enabled': cfg.get('disabled', '1') != '1',
|
|
'connected': False,
|
|
'ssid': cfg.get('ssid') or '',
|
|
'connected_ssid': '',
|
|
'ip': '',
|
|
'signal': None,
|
|
'freq': None,
|
|
'routed': cfg.get('routed') == '1',
|
|
'has_password': bool(cfg.get('key'))
|
|
}
|
|
rc, out, err = device_run(['iw', 'dev', 'wlan0cli', 'link'], timeout=10)
|
|
for line in out.splitlines():
|
|
line = line.strip()
|
|
if line.startswith('Connected to'):
|
|
state['connected'] = True
|
|
elif line.startswith('SSID:'):
|
|
state['connected_ssid'] = line.split(':', 1)[1].strip().strip('"')
|
|
elif line.startswith('signal:'):
|
|
try:
|
|
state['signal'] = int(float(line.split(':', 1)[1].split()[0]))
|
|
except (ValueError, IndexError):
|
|
state['signal'] = None
|
|
elif line.startswith('freq:'):
|
|
try:
|
|
state['freq'] = int(float(line.split(':', 1)[1].split()[0]))
|
|
except (ValueError, IndexError):
|
|
state['freq'] = None
|
|
if not state['connected']:
|
|
state['connected_ssid'] = ''
|
|
_, addr_out, _ = device_run(['ip', '-4', 'addr', 'show', 'dev', 'wlan0cli'], timeout=10)
|
|
for line in addr_out.splitlines():
|
|
m = re.search(r'inet\s+(\d+\.\d+\.\d+\.\d+)', line)
|
|
if m:
|
|
state['ip'] = m.group(1)
|
|
break
|
|
return state
|
|
|
|
|
|
def h_settings_wifi_client(ctx):
|
|
return 200, _wifi_client_state()
|
|
|
|
|
|
def _parse_wifi_scan(out):
|
|
networks = []
|
|
for block in out.split('BSS '):
|
|
block = block.strip()
|
|
if not block:
|
|
continue
|
|
m = re.match(r'([0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5})\(on', block)
|
|
bss = m.group(1).upper() if m else ''
|
|
freq = None
|
|
m = re.search(r'freq:\s*([\d.]+)', block)
|
|
if m:
|
|
try:
|
|
freq = int(float(m.group(1)))
|
|
except ValueError:
|
|
freq = None
|
|
signal = None
|
|
m = re.search(r'signal:\s*(-?\d+(?:\.\d+)?)', block)
|
|
if m:
|
|
try:
|
|
signal = int(float(m.group(1)))
|
|
except ValueError:
|
|
signal = None
|
|
ssid = ''
|
|
m = re.search(r'SSID:\s*([^\n]*)', block)
|
|
if m:
|
|
ssid = m.group(1).strip().strip('"')
|
|
if (not ssid or all(ord(c) < 32 or c == '\ufffd' for c in ssid)
|
|
or re.match(r'^(\\x[0-9A-Fa-f]{2})+$', ssid)):
|
|
ssid = ''
|
|
if not ssid:
|
|
continue
|
|
auth = ''
|
|
m = re.search(r'Authentication suites:\s*([^\n]+)', block)
|
|
if m:
|
|
auth = m.group(1).strip()
|
|
if 'RSN:' in block and 'WPA:' in block:
|
|
encryption = 'WPA/WPA2'
|
|
elif 'RSN:' in block:
|
|
if 'SAE' in auth and 'PSK' in auth:
|
|
encryption = 'WPA2/WPA3'
|
|
elif 'SAE' in auth:
|
|
encryption = 'WPA3'
|
|
else:
|
|
encryption = 'WPA2'
|
|
elif 'WPA:' in block:
|
|
encryption = 'WPA'
|
|
else:
|
|
encryption = 'Open'
|
|
networks.append({'bssid': bss, 'ssid': ssid, 'freq': freq,
|
|
'channel': _freq_to_channel(freq), 'signal': signal,
|
|
'encryption': encryption})
|
|
by_ssid = {}
|
|
for net in networks:
|
|
key = net['ssid'] or net['bssid']
|
|
current = by_ssid.get(key)
|
|
if current is None or (net['signal'] or -200) > (current['signal'] or -200):
|
|
by_ssid[key] = net
|
|
return sorted(by_ssid.values(),
|
|
key=lambda n: n['signal'] if n['signal'] is not None else -200,
|
|
reverse=True)
|
|
|
|
|
|
def h_settings_wifi_client_scan(ctx):
|
|
rc, out, err = device_run(['iw', 'dev', 'wlan0', 'scan'], timeout=25)
|
|
if rc != 0:
|
|
return 502, {'error': err or out or 'scan failed'}
|
|
return 200, {'networks': _parse_wifi_scan(out)}
|
|
|
|
|
|
def h_settings_wifi_client_connect(ctx):
|
|
body = ctx.body or {}
|
|
ssid = (body.get('ssid') or '').strip()
|
|
if not ssid:
|
|
return 400, {'error': 'SSID is required'}
|
|
encryption = (body.get('encryption') or 'wpa2').strip().lower()
|
|
if encryption not in WIFI_CLIENT_ENCRYPTIONS:
|
|
return 400, {'error': 'unsupported encryption type'}
|
|
enc = WIFI_CLIENT_ENCRYPTIONS[encryption]
|
|
password = body.get('password') or ''
|
|
if enc != 'none' and len(password) < 8:
|
|
return 400, {'error': 'password must be at least 8 characters'}
|
|
routed = '1' if body.get('routed') else '0'
|
|
device_run(['uci', 'set', 'wireless.wlan0cli.ssid=%s' % ssid])
|
|
device_run(['uci', 'set', 'wireless.wlan0cli.encryption=%s' % enc])
|
|
device_run(['uci', 'set', 'wireless.wlan0cli.disabled=0'])
|
|
device_run(['uci', 'set', 'wireless.wlan0cli.routed=%s' % routed])
|
|
if enc == 'none':
|
|
device_run(['uci', 'delete', 'wireless.wlan0cli.key'])
|
|
else:
|
|
device_run(['uci', 'set', 'wireless.wlan0cli.key=%s' % password])
|
|
device_run(['uci', 'commit', 'wireless'])
|
|
daemon_sock_call('PUT', '/api/settings/wifi/set_client_route', {'routed': body.get('routed') or False})
|
|
rc, out, err = device_run(['wifi', 'reload'], timeout=45)
|
|
if rc != 0:
|
|
return 502, {'error': err or out or 'wireless reload failed'}
|
|
return 200, _wifi_client_state()
|
|
|
|
|
|
def h_settings_wifi_client_disconnect(ctx):
|
|
device_run(['uci', 'set', 'wireless.wlan0cli.disabled=1'])
|
|
device_run(['uci', 'commit', 'wireless'])
|
|
rc, out, err = device_run(['wifi', 'reload'], timeout=45)
|
|
if rc != 0:
|
|
return 502, {'error': err or out or 'wireless reload failed'}
|
|
return 200, _wifi_client_state()
|
|
|
|
|
|
def h_settings_wifi_client_route(ctx):
|
|
routed = bool((ctx.body or {}).get('routed'))
|
|
device_run(['uci', 'set', 'wireless.wlan0cli.routed=%d' % (1 if routed else 0)])
|
|
device_run(['uci', 'commit', 'wireless'])
|
|
daemon_sock_call('PUT', '/api/settings/wifi/set_client_route', {'routed': routed})
|
|
return 200, _wifi_client_state()
|
|
|
|
|
|
PAGER_LED_COLORS = ('red', 'green', 'blue', 'yellow', 'cyan', 'magenta', 'white')
|
|
|
|
|
|
def h_settings_hardware(ctx):
|
|
prefix = 'system.@pager[0].'
|
|
if _request_is_post(ctx):
|
|
body = ctx.body or {}
|
|
color = (body.get('led_color') or '').lower()
|
|
if color not in PAGER_LED_COLORS:
|
|
return 400, {'error': 'unsupported LED color'}
|
|
values = {
|
|
'led_color': color,
|
|
'vibrate': '1' if body.get('vibrate') else '0',
|
|
'clock24hr': '1' if body.get('clock24hr') else '0',
|
|
'lcd_brightness': str(max(1, min(11, int(body.get('lcd_brightness', 11))))),
|
|
'dim_brightness': str(max(0, min(11, int(body.get('dim_brightness', 2))))),
|
|
'dim_timeout': str(max(0, min(3600, int(body.get('dim_timeout', 15))))),
|
|
'lcd_timeout': str(max(0, min(86400, int(body.get('lcd_timeout', 300)))))
|
|
}
|
|
for key, value in values.items():
|
|
device_run(['uci', 'set', prefix + key + '=' + value])
|
|
device_run(['uci', 'commit', 'system'])
|
|
def number(key, fallback):
|
|
try:
|
|
return int(_uci_get(prefix + key, str(fallback)))
|
|
except ValueError:
|
|
return fallback
|
|
return 200, {
|
|
'led_color': _uci_get(prefix + 'led_color', 'magenta'),
|
|
'vibrate': _uci_get(prefix + 'vibrate', '1') == '1',
|
|
'clock24hr': _uci_get(prefix + 'clock24hr', '0') == '1',
|
|
'lcd_brightness': number('lcd_brightness', 11),
|
|
'dim_brightness': number('dim_brightness', 2),
|
|
'dim_timeout': number('dim_timeout', 15),
|
|
'lcd_timeout': number('lcd_timeout', 300)
|
|
}
|
|
|
|
|
|
def h_settings_advanced(ctx):
|
|
if _request_is_post(ctx):
|
|
channel = (ctx.body or {}).get('update_channel', '').strip().lower()
|
|
if channel not in ('stable', 'beta', 'nightly'):
|
|
return 400, {'error': 'invalid update channel'}
|
|
uci_set('system.updates.channel', channel)
|
|
return 200, {'hostname': hostname_data(),
|
|
'update_channel': _uci_get('system.updates.channel', 'stable')}
|
|
|
|
|
|
def h_settings_internet(ctx):
|
|
rc, _out, _err = device_run(
|
|
['ping', '-c', '1', '-W', '2', '1.1.1.1'], timeout=5)
|
|
return 200, {'online': rc == 0, 'checked_at': int(time.time())}
|
|
|
|
|
|
def _perform_reboot():
|
|
device_run(['reboot'], timeout=5)
|
|
|
|
|
|
def h_settings_reboot(ctx):
|
|
timer = threading.Timer(1.0, _perform_reboot)
|
|
timer.daemon = True
|
|
timer.start()
|
|
return 200, {'ok': True}
|
|
|
|
|
|
def h_settings_diagnostics(ctx):
|
|
commands = [
|
|
('System', ['uname', '-a']), ('Uptime', ['uptime']),
|
|
('Addresses', ['ip', 'addr']), ('Routes', ['route', '-n']),
|
|
('Filesystems', ['df', '-hT']), ('USB Devices', ['lsusb']),
|
|
('Wireless', ['iw', 'dev']), ('Recent Log', ['logread', '-l', '120'])
|
|
]
|
|
sections = []
|
|
report = ['WiFi Pineapple Pager diagnostics', time.strftime('%Y-%m-%d %H:%M:%S UTC', time.gmtime())]
|
|
for title, command in commands:
|
|
rc, out, err = device_run(command, timeout=30)
|
|
text = (out or err or '(no output)').strip()
|
|
sections.append({'title': title, 'output': text, 'ok': rc == 0})
|
|
report.extend(['', '=== %s ===' % title, text])
|
|
return 200, {'sections': sections, 'report': '\n'.join(report)}
|
|
|
|
|
|
ROUTER.add('POST', r'/api/login', h_login)
|
|
ROUTER.add('POST', r'/api/logout', h_logout)
|
|
ROUTER.add('GET', r'/api/api_ping', h_api_ping)
|
|
ROUTER.add('GET', r'/api/status', h_status)
|
|
ROUTER.add('GET', r'/api/device', h_device)
|
|
ROUTER.add('GET', r'/api/pineap/get_config', h_pineap_get_config)
|
|
ROUTER.add('POST', r'/api/pineap/set_config', h_pineap_set_config)
|
|
ROUTER.add('GET', r'/api/pineap/mode', h_pineap_mode_get)
|
|
ROUTER.add('POST', r'/api/pineap/mode', h_pineap_mode_post)
|
|
ROUTER.add('GET', r'/api/pineap/hostapd', h_pineap_hostapd_get)
|
|
ROUTER.add('POST', r'/api/pineap/hostapd', h_pineap_hostapd_set)
|
|
ROUTER.add('POST', r'/api/pineap/enable', h_pineap_enable)
|
|
ROUTER.add('POST', r'/api/pineap/mimic', h_pineap_mimic)
|
|
ROUTER.add('POST', r'/api/pineap/examine', h_pineap_examine)
|
|
ROUTER.add('POST', r'/api/pineap/wifi/get_ap', h_pineap_wifi_get_ap)
|
|
ROUTER.add('POST', r'/api/pineap/wifi/set_ap', h_pineap_wifi_set_ap)
|
|
ROUTER.add('POST', r'/api/pineap/ssidpool/advertise', h_pineap_advertise)
|
|
ROUTER.add('POST', r'/api/pineap/ssidpool/collect', h_pineap_collect)
|
|
ROUTER.add('POST', r'/api/pineap/interfaces', h_pineap_interfaces)
|
|
ROUTER.add('GET', r'/api/pineap/ssids', h_ssids_get)
|
|
ROUTER.add('POST', r'/api/pineap/ssids', h_ssids_post)
|
|
ROUTER.add('GET', r'/api/pineap/filters/client', lambda ctx: h_filter_get(ctx, 'client'))
|
|
ROUTER.add('POST', r'/api/pineap/filters/client', lambda ctx: h_filter_post(ctx, 'client'))
|
|
ROUTER.add('GET', r'/api/pineap/filters/ssid', lambda ctx: h_filter_get(ctx, 'ssid'))
|
|
ROUTER.add('POST', r'/api/pineap/filters/ssid', lambda ctx: h_filter_post(ctx, 'ssid'))
|
|
ROUTER.add('GET', r'/api/pineap/enterprise/(basic|challenge)', h_enterprise_data)
|
|
ROUTER.add('POST', r'/api/pineap/enterprise/clear', h_enterprise_clear)
|
|
ROUTER.add('GET', r'/api/pineap/clients', h_clients)
|
|
ROUTER.add('POST', r'/api/pineap/clients/kick', h_client_kick)
|
|
ROUTER.add('POST', r'/api/pineap/deauth/client', h_deauth_client)
|
|
ROUTER.add('POST', r'/api/recon/start', h_recon_start)
|
|
ROUTER.add('POST', r'/api/recon/stop', h_recon_stop)
|
|
ROUTER.add('GET', r'/api/recon/status', h_recon_status)
|
|
ROUTER.add('GET', r'/api/recon/scans', h_recon_scans)
|
|
ROUTER.add('GET', r'/api/recon/scans/(\d+)/download/json', h_recon_scan_download)
|
|
ROUTER.add('GET', r'/api/recon/scans/(\d+)', h_recon_scan_detail)
|
|
ROUTER.add('DELETE', r'/api/recon/scans/(\d+)', h_recon_delete)
|
|
ROUTER.add('GET', r'/api/recon/events', h_recon_events)
|
|
ROUTER.add('POST', r'/api/recon/examine', h_recon_examine)
|
|
ROUTER.add('GET', r'/api/recon/scans/(\d+)/download/csv', h_recon_scan_download_csv)
|
|
ROUTER.add('GET', r'/api/recon/scans/(\d+)/download/html', h_recon_scan_download_html)
|
|
ROUTER.add('GET', r'/api/recon/gps', h_recon_gps)
|
|
ROUTER.add('POST', r'/api/recon/gps/configure', h_recon_gps_configure)
|
|
ROUTER.add('POST', r'/api/recon/wigle', h_recon_wigle)
|
|
ROUTER.add('GET', r'/api/recon/wigle/files', h_recon_wigle_files)
|
|
ROUTER.add('GET', r'/api/recon/wigle/files/([^/]+)', h_recon_wigle_file)
|
|
ROUTER.add('GET', r'/api/recon/survey/live', h_recon_survey_live)
|
|
ROUTER.add('POST', r'/api/recon/survey/start', h_recon_survey_start)
|
|
ROUTER.add('POST', r'/api/recon/survey/stop', h_recon_survey_stop)
|
|
ROUTER.add('GET', r'/api/recon/surveys', h_recon_surveys)
|
|
ROUTER.add('GET', r'/api/recon/surveys/([^/]+)', h_recon_survey_detail)
|
|
ROUTER.add('GET', r'/api/recon/surveys/([^/]+)/download/(csv|json|html)', h_recon_survey_download)
|
|
ROUTER.add('DELETE', r'/api/recon/surveys/([^/]+)', h_recon_survey_delete)
|
|
ROUTER.add('GET', r'/api/pineap/handshakes/location', h_handshakes_location)
|
|
ROUTER.add('DELETE', r'/api/pineap/handshakes/all', h_handshakes_delete_all)
|
|
ROUTER.add('GET', r'/api/pineap/handshakes', h_handshakes_get)
|
|
ROUTER.add('GET', r'/api/pineap/handshakes/([^/]+)', h_handshake_file)
|
|
ROUTER.add('DELETE', r'/api/pineap/handshakes', h_handshakes_delete)
|
|
ROUTER.add('GET', r'/api/loot/zip', h_loot_zip)
|
|
ROUTER.add('POST', r'/api/loot/archive', h_loot_archive)
|
|
ROUTER.add('GET', r'/api/payloads/index', h_payloads_index)
|
|
ROUTER.add('GET', r'/api/payloads/installed', h_payloads_installed)
|
|
ROUTER.add('POST', r'/api/payloads/refresh', h_payloads_refresh)
|
|
ROUTER.add('POST', r'/api/payloads/install', h_payloads_install)
|
|
ROUTER.add('POST', r'/api/payloads/remove', h_payloads_remove)
|
|
ROUTER.add('GET', r'/api/payloads/runs', h_payloads_runs)
|
|
ROUTER.add('POST', r'/api/payloads/run', h_payloads_run)
|
|
ROUTER.add('POST', r'/api/payloads/stop', h_payloads_stop)
|
|
ROUTER.add('GET', r'/api/logging/system', h_logging_system)
|
|
ROUTER.add('GET', r'/api/logging/pineap', h_logging_pineap)
|
|
ROUTER.add('GET', r'/api/settings/hostname', h_settings_hostname)
|
|
ROUTER.add('POST', r'/api/settings/hostname', h_settings_hostname)
|
|
ROUTER.add('POST', r'/api/settings/password', h_settings_password)
|
|
ROUTER.add('GET', r'/api/settings/ntp', h_settings_ntp)
|
|
ROUTER.add('POST', r'/api/settings/ntp', h_settings_ntp)
|
|
ROUTER.add('GET', r'/api/settings/service', h_settings_service)
|
|
ROUTER.add('GET', r'/api/settings/timezone', h_settings_timezone)
|
|
ROUTER.add('POST', r'/api/settings/timezone', h_settings_timezone)
|
|
ROUTER.add('POST', r'/api/settings/synctime', h_settings_sync_time)
|
|
ROUTER.add('GET', r'/api/settings/resources', h_settings_resources)
|
|
ROUTER.add('GET', r'/api/settings/usb', h_settings_usb)
|
|
ROUTER.add('GET', r'/api/settings/network', h_settings_network)
|
|
ROUTER.add('GET', r'/api/settings/wifi/management', h_settings_management_wifi)
|
|
ROUTER.add('POST', r'/api/settings/wifi/management', h_settings_management_wifi)
|
|
ROUTER.add('GET', r'/api/settings/wifi/client', h_settings_wifi_client)
|
|
ROUTER.add('POST', r'/api/settings/wifi/client/scan', h_settings_wifi_client_scan)
|
|
ROUTER.add('POST', r'/api/settings/wifi/client/connect', h_settings_wifi_client_connect)
|
|
ROUTER.add('POST', r'/api/settings/wifi/client/disconnect', h_settings_wifi_client_disconnect)
|
|
ROUTER.add('POST', r'/api/settings/wifi/client/route', h_settings_wifi_client_route)
|
|
ROUTER.add('GET', r'/api/settings/hardware', h_settings_hardware)
|
|
ROUTER.add('POST', r'/api/settings/hardware', h_settings_hardware)
|
|
ROUTER.add('GET', r'/api/settings/advanced', h_settings_advanced)
|
|
ROUTER.add('POST', r'/api/settings/advanced', h_settings_advanced)
|
|
ROUTER.add('GET', r'/api/settings/internet', h_settings_internet)
|
|
ROUTER.add('POST', r'/api/settings/reboot', h_settings_reboot)
|
|
ROUTER.add('GET', r'/api/settings/diagnostics', h_settings_diagnostics)
|
|
|
|
|
|
def _daemon_ws_connect(path):
|
|
"""Open an RFC6455 WS to the daemon. Returns (sock, error)."""
|
|
import socket as _socket
|
|
host = DAEMON_BASE.replace('http://', '').split(':')
|
|
sock = _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM)
|
|
sock.settimeout(10)
|
|
sock.connect((host[0], int(host[1])))
|
|
key = base64.b64encode(os.urandom(16)).decode('ascii')
|
|
sess = load_session()
|
|
cookie = 'AUTH_%s=%s' % (sess.get('serverid', ''), sess.get('token', ''))
|
|
req = ('GET %s HTTP/1.1\r\n'
|
|
'Host: %s\r\n'
|
|
'Upgrade: websocket\r\n'
|
|
'Connection: Upgrade\r\n'
|
|
'Sec-WebSocket-Key: %s\r\n'
|
|
'Sec-WebSocket-Version: 13\r\n'
|
|
'Cookie: %s\r\n'
|
|
'\r\n') % (path, DAEMON_BASE.replace('http://', ''), key, cookie)
|
|
sock.sendall(req.encode('ascii'))
|
|
resp = b''
|
|
while b'\r\n\r\n' not in resp:
|
|
chunk = sock.recv(4096)
|
|
if not chunk:
|
|
sock.close()
|
|
return None, 'daemon closed during handshake'
|
|
resp += chunk
|
|
if b' 101 ' not in resp.split(b'\r\n', 1)[0]:
|
|
sock.close()
|
|
return None, resp.split(b'\r\n', 1)[0].decode('ascii', 'replace')
|
|
return sock, None
|
|
|
|
|
|
def _handle_conn(conn, addr):
|
|
try:
|
|
conn.settimeout(60)
|
|
h = PagerHandler(conn, addr)
|
|
while not h.close_connection:
|
|
if not h.read_request():
|
|
break
|
|
h._dispatch()
|
|
except Exception:
|
|
pass
|
|
finally:
|
|
try:
|
|
conn.close()
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def _recon_watchdog_loop():
|
|
while not LIVE_STOP.is_set():
|
|
time.sleep(1)
|
|
_recon_watchdog_tick()
|
|
|
|
|
|
def serve():
|
|
threading.Thread(target=live_loop, daemon=True).start()
|
|
threading.Thread(target=_recon_watchdog_loop, daemon=True).start()
|
|
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
sock.bind((HOST, PORT))
|
|
sock.listen(16)
|
|
while True:
|
|
conn, addr = sock.accept()
|
|
threading.Thread(target=_handle_conn, args=(conn, addr), daemon=True).start()
|
|
|
|
|
|
if __name__ == '__main__':
|
|
serve()
|