Rename project to SIMtrace Analyser (v2.0.0)
- move the analysis server out of simtrace2_pysniff into simtrace_analyser; simtrace2_pysniff stays as the simtrace2-sniff replacement capture utility - new server CLI: simtrace-analyser-server; python -m simtrace_analyser (simtrace2-pysniff-server and python -m simtrace2_pysniff.server dropped) - simtrace2_pysniff lazy-imports hardware symbols, so the server no longer pulls PyUSB at startup; the GSMTAP/PCAP codecs work without libusb - version 2.0.0 everywhere (was 1.22.21); PWA SW cache simtrace-analyser-v54 - frontend: server name, clone URL and i18n strings updated; vendored-parser reference otaman -> SIMple - README/README_RUS reworked product-first (analyser first, utility second) - DB dir ~/.simtrace-analyser, /api status payload and hosted PWA unchanged
This commit is contained in:
1 parent
8441b56e99
commit
38650b4797
20 files changed
+392
-323
No files matched your search
@@ -1,12 +1,11 @@
|
||||
"""simtrace2-pysniff — Python replacement for simtrace2-sniff."""
|
||||
"""simtrace2-pysniff — Python replacement for simtrace2-sniff.
|
||||
|
||||
from .device import SniffSession, DeviceDisconnected, find_sniffer_device
|
||||
from .protocol import parse_message, SniffMessage
|
||||
from .gsmtap import GsmtapSender, GsmtapReceiver
|
||||
from .pcap import PcapWriter
|
||||
from .dump import FileDumper, format_message
|
||||
Capture utility bundled with SIMtrace Analyser. Hardware access (PyUSB) is
|
||||
imported lazily, so the GSMTAP/PCAP codec modules can be used without libusb.
|
||||
"""
|
||||
|
||||
from simtrace_analyser.version import __version__
|
||||
|
||||
__version__ = '1.22.21'
|
||||
__all__ = [
|
||||
'SniffSession',
|
||||
'DeviceDisconnected',
|
||||
@@ -20,3 +19,28 @@ __all__ = [
|
||||
'format_message',
|
||||
'__version__',
|
||||
]
|
||||
|
||||
_LAZY_IMPORTS = {
|
||||
'SniffSession': ('.device', 'SniffSession'),
|
||||
'DeviceDisconnected': ('.device', 'DeviceDisconnected'),
|
||||
'find_sniffer_device': ('.device', 'find_sniffer_device'),
|
||||
'parse_message': ('.protocol', 'parse_message'),
|
||||
'SniffMessage': ('.protocol', 'SniffMessage'),
|
||||
'GsmtapSender': ('.gsmtap', 'GsmtapSender'),
|
||||
'GsmtapReceiver': ('.gsmtap', 'GsmtapReceiver'),
|
||||
'PcapWriter': ('.pcap', 'PcapWriter'),
|
||||
'FileDumper': ('.dump', 'FileDumper'),
|
||||
'format_message': ('.dump', 'format_message'),
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(name):
|
||||
"""Import utility symbols on first use (PEP 562)."""
|
||||
try:
|
||||
module_name, attr = _LAZY_IMPORTS[name]
|
||||
except KeyError:
|
||||
raise AttributeError(f'module {__name__!r} has no attribute {name!r}') from None
|
||||
from importlib import import_module
|
||||
value = getattr(import_module(module_name, __name__), attr)
|
||||
globals()[name] = value
|
||||
return value
|
||||
@@ -1 +0,0 @@
|
||||
"""simtrace2-pysniff server sub-package."""
|
||||
@@ -1,85 +0,0 @@
|
||||
"""simtrace2-pysniff-server — CLI entry point.
|
||||
|
||||
Usage:
|
||||
simtrace2-pysniff-server --capture gsmtap [--gsmtap-port 4729] [--port 8081]
|
||||
simtrace2-pysniff-server --capture direct [--port 8081]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from http.server import HTTPServer
|
||||
|
||||
from .server import RequestHandler
|
||||
from .database import Database, DEFAULT_DB_PATH
|
||||
from .capture import CaptureManager, GsmtapListener, DirectSniffer
|
||||
from .. import __version__
|
||||
|
||||
|
||||
def _default_web_dir():
|
||||
# <repo>/frontend, whether run from source or an editable install.
|
||||
# __file__ is <repo>/simtrace2_pysniff/server/__main__.py → up three levels.
|
||||
return os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), 'frontend')
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser(
|
||||
prog='simtrace2-pysniff-server',
|
||||
description='HTTP API + PWA server for SIMtrace2 APDU capture and analysis')
|
||||
p.add_argument('--host', default='127.0.0.1',
|
||||
help='HTTP bind address (default: 127.0.0.1)')
|
||||
p.add_argument('--port', type=int, default=8081,
|
||||
help='HTTP server port (default: 8081)')
|
||||
p.add_argument('--db', default=DEFAULT_DB_PATH,
|
||||
help=f'SQLite database path (default: {DEFAULT_DB_PATH})')
|
||||
p.add_argument('--capture', choices=['gsmtap', 'direct', 'disabled'], default='gsmtap',
|
||||
help='Capture mode: gsmtap (listen UDP 4729), direct (SIMtrace2 USB), or disabled (no capture)')
|
||||
p.add_argument('--gsmtap-port', type=int, default=4729,
|
||||
help='UDP port for GSMTAP listener (default: 4729)')
|
||||
p.add_argument('--log-interval', type=float, default=60.0,
|
||||
help='Seconds between capture liveness heartbeat logs '
|
||||
'(default: 60.0)')
|
||||
p.add_argument('--log-requests', action='store_true', default=False,
|
||||
help='Log every HTTP request (off by default)')
|
||||
p.add_argument('--web-dir', default=_default_web_dir(), metavar='PATH',
|
||||
help='Directory with the simtrace-analyser PWA static files to serve (default: <repo>/frontend)')
|
||||
args = p.parse_args()
|
||||
|
||||
db = Database(args.db)
|
||||
|
||||
capture = None
|
||||
if args.capture == 'gsmtap':
|
||||
backend = GsmtapListener(bind_port=args.gsmtap_port)
|
||||
capture = CaptureManager(backend, db, log_interval=args.log_interval)
|
||||
elif args.capture == 'direct':
|
||||
backend = DirectSniffer()
|
||||
capture = CaptureManager(backend, db, log_interval=args.log_interval)
|
||||
|
||||
RequestHandler.db = db
|
||||
RequestHandler.capture = capture
|
||||
RequestHandler.capture_mode = args.capture
|
||||
RequestHandler.log_requests = args.log_requests
|
||||
|
||||
server = HTTPServer((args.host, args.port), RequestHandler)
|
||||
server.web_dir = args.web_dir
|
||||
|
||||
print(f'simtrace2-pysniff-server v{__version__} — http://{args.host}:{args.port}', file=sys.stderr)
|
||||
print(f' capture mode: {args.capture}', file=sys.stderr)
|
||||
if args.capture == 'gsmtap':
|
||||
print(f' GSMTAP port: {args.gsmtap_port}', file=sys.stderr)
|
||||
print(f' database: {args.db}', file=sys.stderr)
|
||||
print(f' PWA served at http://{args.host}:{args.port}/', file=sys.stderr)
|
||||
print(file=sys.stderr)
|
||||
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
finally:
|
||||
if capture and capture.active:
|
||||
capture.stop_session()
|
||||
server.server_close()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,229 +0,0 @@
|
||||
"""Capture backends: GSMTAP listener and direct SIMtrace2 sniffer."""
|
||||
|
||||
import struct
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
|
||||
from ..gsmtap import (GsmtapReceiver, GSMTAP_SIM_ATR,
|
||||
GSMTAP_SIM_PPS_REQ, GSMTAP_SIM_PPS_RSP,
|
||||
GSMTAP_SIM_RST_EVENT, GSMTAP_SIM_VCC_EVENT)
|
||||
|
||||
|
||||
def _ts():
|
||||
"""Local-time stamp for server-side capture log lines."""
|
||||
return time.strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
|
||||
def _log(msg):
|
||||
print(f'[{_ts()}] {msg}', file=sys.stderr)
|
||||
|
||||
|
||||
def gsmtap_msg_type(sub_type):
|
||||
"""Map a GSMTAP-SIM sub_type to a stored message type.
|
||||
|
||||
PPS request/response (0x02/0x03, standard GSMTAP) become 'pps'; custom
|
||||
sigrok-iso7816-stream line events (0x10/0x11) become 'rst'/'vcc';
|
||||
everything else defaults to 'tpdu'.
|
||||
"""
|
||||
return {
|
||||
GSMTAP_SIM_ATR: 'atr',
|
||||
GSMTAP_SIM_PPS_REQ: 'pps',
|
||||
GSMTAP_SIM_PPS_RSP: 'pps',
|
||||
GSMTAP_SIM_RST_EVENT: 'rst',
|
||||
GSMTAP_SIM_VCC_EVENT: 'vcc',
|
||||
}.get(sub_type, 'tpdu')
|
||||
|
||||
|
||||
class GsmtapListener:
|
||||
def __init__(self, bind_port=4729):
|
||||
self._bind_port = bind_port
|
||||
self._receiver = GsmtapReceiver(bind_port=bind_port)
|
||||
self._running = False
|
||||
self._dropped = 0
|
||||
|
||||
def start(self):
|
||||
if getattr(self._receiver, '_closed', False):
|
||||
self._receiver = GsmtapReceiver(bind_port=self._bind_port)
|
||||
self._running = True
|
||||
self._dropped = 0
|
||||
|
||||
def stop(self):
|
||||
self._running = False
|
||||
self._receiver.close()
|
||||
|
||||
@property
|
||||
def dropped(self):
|
||||
return self._dropped
|
||||
|
||||
def iter_messages(self):
|
||||
while self._running:
|
||||
try:
|
||||
sub_type, data, flags = self._receiver.read_packet()
|
||||
except (ValueError, struct.error) as e:
|
||||
# A malformed or non-SIM GSMTAP datagram (a different
|
||||
# GSMTAP packet type, a truncated frame, …) must not kill
|
||||
# the whole capture — drop it and keep listening.
|
||||
self._dropped += 1
|
||||
if self._dropped <= 1 or self._dropped % 100 == 0:
|
||||
_log(f'GSMTAP: dropped packet ({self._dropped}): {e}')
|
||||
continue
|
||||
if sub_type is None:
|
||||
continue
|
||||
yield gsmtap_msg_type(sub_type), data, flags
|
||||
|
||||
|
||||
class DirectSniffer:
|
||||
def __init__(self):
|
||||
from ..device import SniffSession
|
||||
self._session = SniffSession(
|
||||
reconnect=True,
|
||||
reconnect_delay_min=1.0,
|
||||
reconnect_delay_max=10.0,
|
||||
inactivity_timeout=0.0,
|
||||
)
|
||||
self._running = False
|
||||
|
||||
@property
|
||||
def connected(self):
|
||||
"""True while the USB sniffer device is open."""
|
||||
return self._session.connected
|
||||
|
||||
def start(self):
|
||||
self._running = True
|
||||
|
||||
def stop(self):
|
||||
self._running = False
|
||||
self._session.close()
|
||||
|
||||
def iter_messages(self):
|
||||
from ..device import DeviceDisconnected
|
||||
try:
|
||||
for msg in self._session.iter_messages():
|
||||
if not self._running:
|
||||
break
|
||||
yield msg.type, msg.data, msg.flags
|
||||
except DeviceDisconnected:
|
||||
return
|
||||
|
||||
|
||||
class CaptureManager:
|
||||
def __init__(self, backend, db, log_interval=60.0):
|
||||
self._backend = backend
|
||||
self._db = db
|
||||
self._session_id = None
|
||||
self._thread = None
|
||||
self._log_thread = None
|
||||
self._stop_event = threading.Event()
|
||||
self._start_time = 0.0
|
||||
self._latest_msg_id = 0
|
||||
self._msg_count = 0
|
||||
self._byte_count = 0
|
||||
self._log_interval = log_interval
|
||||
|
||||
@property
|
||||
def active(self):
|
||||
return self._session_id is not None
|
||||
|
||||
@property
|
||||
def session_id(self):
|
||||
return self._session_id
|
||||
|
||||
@property
|
||||
def start_time(self):
|
||||
return self._start_time
|
||||
|
||||
@property
|
||||
def latest_msg_id(self):
|
||||
return self._latest_msg_id
|
||||
|
||||
@property
|
||||
def device_connected(self):
|
||||
"""True while the capture device is connected, or None when the
|
||||
backend has no device concept (gsmtap)."""
|
||||
if isinstance(self._backend, DirectSniffer):
|
||||
return self._backend.connected
|
||||
return None
|
||||
|
||||
def start_session(self):
|
||||
if self._session_id is not None:
|
||||
self.stop_session()
|
||||
|
||||
mode = 'gsmtap' if isinstance(self._backend, GsmtapListener) else 'direct'
|
||||
self._session_id = self._db.create_session(mode)
|
||||
self._start_time = time.monotonic()
|
||||
self._latest_msg_id = 0
|
||||
self._msg_count = 0
|
||||
self._byte_count = 0
|
||||
self._backend.start()
|
||||
|
||||
self._thread = threading.Thread(target=self._capture_loop, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
self._stop_event.clear()
|
||||
_log(f'Capture started (mode={mode}, session={self._session_id})')
|
||||
self._log_thread = threading.Thread(target=self._log_loop, daemon=True)
|
||||
self._log_thread.start()
|
||||
|
||||
return self._session_id
|
||||
|
||||
def stop_session(self):
|
||||
if self._session_id is None:
|
||||
return None
|
||||
self._stop_event.set()
|
||||
self._backend.stop()
|
||||
if self._thread is not None:
|
||||
self._thread.join(timeout=2.0)
|
||||
if self._log_thread is not None:
|
||||
self._log_thread.join(timeout=2.0)
|
||||
sid = self._session_id
|
||||
self._session_id = None
|
||||
self._start_time = 0.0
|
||||
empty = self._db.count_messages(sid) == 0
|
||||
if empty:
|
||||
self._db.delete_session(sid)
|
||||
_log(f'Capture stopped: empty session discarded (session={sid})')
|
||||
return None
|
||||
self._db.close_session(sid)
|
||||
_log(f'Capture stopped: session={sid} messages={self._msg_count} '
|
||||
f'bytes={self._byte_count}')
|
||||
return sid
|
||||
|
||||
def _capture_loop(self):
|
||||
try:
|
||||
for msg_type, data, flags in self._backend.iter_messages():
|
||||
if self._session_id is None:
|
||||
break
|
||||
try:
|
||||
elapsed = round(time.monotonic() - self._start_time, 3)
|
||||
mid = self._db.insert_message(
|
||||
self._session_id, elapsed, msg_type, data, flags)
|
||||
self._msg_count += 1
|
||||
self._byte_count += len(data)
|
||||
self._latest_msg_id = mid
|
||||
except Exception as e:
|
||||
_log(f'Capture: insert error, dropping msg: {e}')
|
||||
continue
|
||||
except Exception:
|
||||
# The capture thread died unexpectedly (not a user stop).
|
||||
sid = self._session_id
|
||||
_log(f'ERROR: capture thread stopped on error (session={sid}):')
|
||||
traceback.print_exc()
|
||||
self._stop_event.set()
|
||||
if sid is not None:
|
||||
try:
|
||||
self._db.close_session(sid)
|
||||
except Exception:
|
||||
pass
|
||||
self._session_id = None
|
||||
return
|
||||
|
||||
def _log_loop(self):
|
||||
while not self._stop_event.wait(self._log_interval):
|
||||
if self._session_id is None:
|
||||
break
|
||||
dropped = getattr(self._backend, 'dropped', 0)
|
||||
_log(f'Capture alive: session={self._session_id} '
|
||||
f'messages={self._msg_count} bytes={self._byte_count} '
|
||||
f'dropped={dropped}')
|
||||
@@ -1,431 +0,0 @@
|
||||
"""SQLite database for session and APDU storage."""
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
DEFAULT_DB_DIR = os.path.expanduser('~/.simtrace-analyser')
|
||||
DEFAULT_DB_PATH = os.path.join(DEFAULT_DB_DIR, 'sessions.db')
|
||||
|
||||
# SW1 values that mean a SELECT succeeded (normal, warnings, response pending).
|
||||
# Everything else (0x64-0x6F: execution/checking/security errors) leaves the
|
||||
# current file unchanged.
|
||||
_SUCCESS_SW1 = {'61', '62', '63', '90', '91', '92', '9e', '9f'}
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT,
|
||||
started TEXT NOT NULL,
|
||||
ended TEXT,
|
||||
mode TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id INTEGER NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
elapsed REAL NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
data BLOB NOT NULL,
|
||||
flags INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_type ON messages(type);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_elapsed ON messages(session_id, elapsed);
|
||||
"""
|
||||
|
||||
|
||||
def _iso_now():
|
||||
return time.strftime('%Y-%m-%dT%H:%M:%S.', time.localtime()) + \
|
||||
f'{time.time() % 1 * 1000:03.0f}'
|
||||
|
||||
|
||||
def _iso_from_ts(ts):
|
||||
return time.strftime('%Y-%m-%dT%H:%M:%S.', time.localtime(ts)) + \
|
||||
f'{ts % 1 * 1000:03.0f}'
|
||||
|
||||
|
||||
class Database:
|
||||
def __init__(self, db_path=None):
|
||||
if db_path is None:
|
||||
db_path = DEFAULT_DB_PATH
|
||||
self._db_path = db_path
|
||||
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
||||
self._conn = sqlite3.connect(db_path, check_same_thread=False)
|
||||
self._conn.execute('PRAGMA journal_mode=WAL')
|
||||
self._conn.execute('PRAGMA foreign_keys=ON')
|
||||
self._conn.executescript(_SCHEMA)
|
||||
self._conn.commit()
|
||||
|
||||
# --- Sessions ---
|
||||
|
||||
def create_session(self, mode):
|
||||
cur = self._conn.execute(
|
||||
'INSERT INTO sessions (started, mode) VALUES (?, ?)',
|
||||
(_iso_now(), mode))
|
||||
self._conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
def close_session(self, session_id, duration_secs=None):
|
||||
if duration_secs is not None and duration_secs > 0:
|
||||
started = self._conn.execute(
|
||||
'SELECT started FROM sessions WHERE id=?', (session_id,)).fetchone()[0]
|
||||
ended = (datetime.fromisoformat(started) + timedelta(seconds=duration_secs)).isoformat()
|
||||
else:
|
||||
ended = _iso_now()
|
||||
self._conn.execute(
|
||||
'UPDATE sessions SET ended=? WHERE id=? AND ended IS NULL',
|
||||
(ended, session_id))
|
||||
self._conn.commit()
|
||||
|
||||
def set_session_times_from_ts(self, session_id, first_ts, last_ts):
|
||||
self._conn.execute(
|
||||
'UPDATE sessions SET started=?, ended=? WHERE id=?',
|
||||
(_iso_from_ts(first_ts), _iso_from_ts(last_ts), session_id))
|
||||
self._conn.commit()
|
||||
|
||||
def rename_session(self, session_id, name):
|
||||
self._conn.execute(
|
||||
'UPDATE sessions SET name=? WHERE id=?',
|
||||
(name, session_id))
|
||||
self._conn.commit()
|
||||
|
||||
def delete_session(self, session_id):
|
||||
self._conn.execute('DELETE FROM messages WHERE session_id=?', (session_id,))
|
||||
self._conn.execute('DELETE FROM sessions WHERE id=?', (session_id,))
|
||||
self._conn.commit()
|
||||
|
||||
def count_sessions(self):
|
||||
row = self._conn.execute('SELECT COUNT(*) FROM sessions').fetchone()
|
||||
return row[0]
|
||||
|
||||
def db_size(self):
|
||||
"""Total on-disk size of the SQLite database (db + WAL + SHM)."""
|
||||
total = 0
|
||||
for suffix in ('', '-wal', '-shm'):
|
||||
p = self._db_path + suffix
|
||||
if os.path.exists(p):
|
||||
total += os.path.getsize(p)
|
||||
return total
|
||||
|
||||
def vacuum(self):
|
||||
self._conn.execute('VACUUM')
|
||||
self._conn.execute('PRAGMA wal_checkpoint(TRUNCATE)')
|
||||
self._conn.commit()
|
||||
|
||||
def get_session(self, session_id):
|
||||
row = self._conn.execute(
|
||||
'SELECT id, name, started, ended, mode FROM sessions WHERE id=?',
|
||||
(session_id,)).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return self._row_to_session(row)
|
||||
|
||||
def list_sessions(self):
|
||||
rows = self._conn.execute("""
|
||||
SELECT s.id, s.name, s.started, s.ended, s.mode,
|
||||
COUNT(m.id) AS msg_count
|
||||
FROM sessions s LEFT JOIN messages m ON m.session_id = s.id
|
||||
GROUP BY s.id
|
||||
ORDER BY s.started DESC
|
||||
""").fetchall()
|
||||
return [self._row_to_session(r) for r in rows]
|
||||
|
||||
def get_active_session(self):
|
||||
row = self._conn.execute(
|
||||
'SELECT id, name, started, ended, mode FROM sessions WHERE ended IS NULL ORDER BY id DESC LIMIT 1'
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return self._row_to_session(row)
|
||||
|
||||
def _row_to_session(self, row):
|
||||
d = {'id': row[0], 'name': row[1], 'started': row[2], 'ended': row[3], 'mode': row[4]}
|
||||
if len(row) > 5:
|
||||
d['message_count'] = row[5]
|
||||
return d
|
||||
|
||||
# --- Messages ---
|
||||
|
||||
def insert_message(self, session_id, elapsed, msg_type, data, flags=0):
|
||||
cur = self._conn.execute(
|
||||
'INSERT INTO messages (session_id, elapsed, type, data, flags) VALUES (?,?,?,?,?)',
|
||||
(session_id, elapsed, msg_type, data, flags))
|
||||
self._conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
def insert_messages(self, session_id, rows):
|
||||
"""Bulk-insert messages; *rows* is an iterable of
|
||||
(elapsed, msg_type, data, flags) tuples. Returns the last rowid."""
|
||||
self._conn.executemany(
|
||||
'INSERT INTO messages (session_id, elapsed, type, data, flags) VALUES (?,?,?,?,?)',
|
||||
[(session_id, elapsed, msg_type, data, flags) for elapsed, msg_type, data, flags in rows])
|
||||
self._conn.commit()
|
||||
|
||||
def get_message(self, msg_id):
|
||||
row = self._conn.execute(
|
||||
'SELECT id, session_id, elapsed, type, data, flags FROM messages WHERE id=?',
|
||||
(msg_id,)).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return self._row_to_message(row)
|
||||
|
||||
def get_messages(self, session_id, offset=0, limit=200):
|
||||
rows = self._conn.execute(
|
||||
'SELECT id, session_id, elapsed, type, data, flags FROM messages '
|
||||
'WHERE session_id=? ORDER BY elapsed, id LIMIT ? OFFSET ?',
|
||||
(session_id, limit, offset)).fetchall()
|
||||
initial_states = None
|
||||
clk_hz = None
|
||||
if offset > 0 and rows:
|
||||
initial_states, clk_hz = self._replay_context(session_id, rows[0][0])
|
||||
return self._decode_rows(rows, initial_states=initial_states, clk_hz=clk_hz)
|
||||
|
||||
def get_messages_after(self, session_id, after_id):
|
||||
rows = self._conn.execute(
|
||||
'SELECT id, session_id, elapsed, type, data, flags FROM messages '
|
||||
'WHERE session_id=? AND id > ? ORDER BY elapsed, id',
|
||||
(session_id, after_id)).fetchall()
|
||||
initial_states, clk_hz = self._replay_context(session_id, after_id + 1)
|
||||
return self._decode_rows(rows, initial_states=initial_states, clk_hz=clk_hz)
|
||||
|
||||
def get_messages_raw(self, session_id):
|
||||
rows = self._conn.execute(
|
||||
'SELECT elapsed, type, data FROM messages WHERE session_id=? ORDER BY elapsed, id',
|
||||
(session_id,)).fetchall()
|
||||
return [{'elapsed': r[0], 'type': r[1], 'data': r[2]} for r in rows]
|
||||
|
||||
def count_messages(self, session_id):
|
||||
row = self._conn.execute(
|
||||
'SELECT COUNT(*) FROM messages WHERE session_id=?',
|
||||
(session_id,)).fetchone()
|
||||
return row[0]
|
||||
|
||||
def search_messages(self, session_id, query):
|
||||
hex_q = ''.join(c for c in query if c in '0123456789abcdefABCDEF')
|
||||
if not hex_q:
|
||||
return []
|
||||
rows = self._conn.execute(
|
||||
"SELECT id, session_id, elapsed, type, data, flags FROM messages "
|
||||
"WHERE session_id=? AND hex(data) LIKE ? ORDER BY elapsed, id",
|
||||
(session_id, f'%{hex_q.lower()}%')).fetchall()
|
||||
return [self._row_to_message(r) for r in rows]
|
||||
|
||||
def filter_messages(self, session_id, msg_type):
|
||||
rows = self._conn.execute(
|
||||
'SELECT id, session_id, elapsed, type, data, flags FROM messages '
|
||||
'WHERE session_id=? AND type=? ORDER BY elapsed, id',
|
||||
(session_id, msg_type)).fetchall()
|
||||
return [self._row_to_message(r) for r in rows]
|
||||
|
||||
def get_type_counts(self, session_id):
|
||||
rows = self._conn.execute(
|
||||
'SELECT type, COUNT(*) FROM messages WHERE session_id=? GROUP BY type ORDER BY COUNT(*) DESC',
|
||||
(session_id,)).fetchall()
|
||||
return [{'type': r[0], 'count': r[1]} for r in rows]
|
||||
|
||||
def _row_to_message(self, row, prev=None, clk_hz=None):
|
||||
data_blob = row[4]
|
||||
flags = row[5]
|
||||
msg = {
|
||||
'id': row[0],
|
||||
'session_id': row[1],
|
||||
'elapsed': row[2],
|
||||
'type': row[3],
|
||||
'data': data_blob.hex(),
|
||||
'flags': flags,
|
||||
}
|
||||
if row[3] in ('tpdu', 'change', 'fidi', 'atr', 'pps', 'rst', 'vcc'):
|
||||
try:
|
||||
from .decode import decode_sniff_msg
|
||||
msg['decoded'] = decode_sniff_msg(data_blob, row[3], flags,
|
||||
prev=prev, clk_hz=clk_hz)
|
||||
except Exception as e:
|
||||
import sys
|
||||
print(f'Decode error for msg {row[0]} ({row[3]}, {len(data_blob)} bytes): {e}',
|
||||
file=sys.stderr)
|
||||
return msg
|
||||
|
||||
def _new_channel_state(self):
|
||||
from .decode import sfi_table
|
||||
return {'prev': None, 'sel': None, 'df': '3f00', 'sfi_map': dict(sfi_table('3f00'))}
|
||||
|
||||
def _channel_state(self, states, channel):
|
||||
st = states.get(channel)
|
||||
if st is None:
|
||||
st = self._new_channel_state()
|
||||
states[channel] = st
|
||||
return st
|
||||
|
||||
def _replay_context(self, session_id, before_id):
|
||||
"""Replay (bounded) messages before *before_id* to recover per-channel
|
||||
selection, DF, SFI map, and the last RST-reported CLK frequency.
|
||||
|
||||
Returns ``(states, clk_hz)``."""
|
||||
from .decode import decode_sniff_msg
|
||||
rows = self._conn.execute(
|
||||
"SELECT data, type FROM messages WHERE session_id=? AND id < ? "
|
||||
"AND type IN ('tpdu','atr','change','gap','rst','vcc') "
|
||||
"ORDER BY id DESC LIMIT 500",
|
||||
(session_id, before_id)).fetchall()
|
||||
states = {}
|
||||
clk_hz = None
|
||||
for data, typ in reversed(rows):
|
||||
if typ in ('atr', 'change', 'gap'):
|
||||
states = {}
|
||||
# An ATR consumes the CLK rate; a replay starts fresh after it.
|
||||
clk_hz = None
|
||||
continue
|
||||
if typ in ('rst', 'vcc'):
|
||||
d = decode_sniff_msg(data, typ, 0)
|
||||
if d and d.get('clk_hz'):
|
||||
clk_hz = d['clk_hz']
|
||||
continue
|
||||
channel = data[0] & 0x03 if data and len(data) >= 1 else 0
|
||||
st = self._channel_state(states, channel)
|
||||
ctx = dict(st['prev']) if st['prev'] else {}
|
||||
ctx['sel'] = st['sel']
|
||||
ctx['df'] = st['df']
|
||||
ctx['sfi_map'] = st['sfi_map']
|
||||
d = decode_sniff_msg(data, 'tpdu', 0, prev=ctx)
|
||||
if not d or not d.get('ins_hex'):
|
||||
continue
|
||||
st['sel'], st['df'], st['sfi_map'] = self._advance_state(d, st['sel'], st['df'], st['sfi_map'])
|
||||
st['prev'] = self._context_from_decoded(d)
|
||||
return states, clk_hz
|
||||
|
||||
def _advance_state(self, d, sel, df, sfi_map):
|
||||
"""Update (sel, df, sfi_map) for one decoded TPDU."""
|
||||
from .decode import selected_df_fid, sfi_table, KNOWN_FIDS
|
||||
new_df = selected_df_fid(d)
|
||||
if new_df is not None and new_df != df:
|
||||
# Only a successful SELECT changes the current DF.
|
||||
if (d.get('sw') or {}).get('sw1') in _SUCCESS_SW1:
|
||||
df = new_df
|
||||
sfi_map = dict(sfi_table(new_df))
|
||||
# Learn SFI from the FCP of the last SELECT (GET RESPONSE).
|
||||
if d.get('ins_hex') == 'c0' and d.get('response_for') == 'SELECT' and sel:
|
||||
sfi = (d.get('response') or {}).get('sfi')
|
||||
if sfi is not None:
|
||||
sfi_map = dict(sfi_map)
|
||||
sfi_map[sfi] = sel['fid']
|
||||
sel = self._selection_after(d, sel)
|
||||
# A record/SEARCH command with a valid SFI sets the file as current EF
|
||||
# (TS 102 221 §11.1.2).
|
||||
sfi = (d.get('p2') or {}).get('sfi')
|
||||
if d.get('ins_hex') in ('b2', 'dc', 'a2') and sfi:
|
||||
fid = sfi_map.get(sfi)
|
||||
if fid:
|
||||
sel = {'fid': fid, 'name': KNOWN_FIDS.get(fid), 'structure': None}
|
||||
return sel, df, sfi_map
|
||||
|
||||
def _context_from_decoded(self, d):
|
||||
ins_hex = d.get('ins_hex')
|
||||
ins = int(ins_hex, 16) if ins_hex else None
|
||||
file_ok = False
|
||||
if ins in (0xB2, 0xDC, 0xA2):
|
||||
file_ok = True
|
||||
elif ins in (0xB0, 0xD6):
|
||||
file_ok = (d.get('p1p2') or {}).get('value', 0) == 0
|
||||
sec = ((d.get('cmd') or {}).get('tpdu') or {}).get('secured') or {}
|
||||
return {
|
||||
'ins': ins,
|
||||
'ins_name': d.get('ins_name'),
|
||||
'sw1': (d.get('sw') or {}).get('sw1'),
|
||||
'file_ok': file_ok,
|
||||
'por_ciphered': bool((sec.get('spi') or {}).get('por_ciphered')),
|
||||
}
|
||||
|
||||
def _selection_after(self, d, sel):
|
||||
ins_hex = d.get('ins_hex')
|
||||
if ins_hex == 'a4':
|
||||
from .decode import select_target_fid, KNOWN_FIDS
|
||||
sw1 = (d.get('sw') or {}).get('sw1')
|
||||
# Only a successful SELECT changes the current file. On an error
|
||||
# status word (e.g. 6A82 file-not-found) the file is unchanged.
|
||||
if sw1 not in _SUCCESS_SW1:
|
||||
return sel
|
||||
fid = select_target_fid(d)
|
||||
if fid:
|
||||
return {'fid': fid, 'name': KNOWN_FIDS.get(fid), 'structure': None}
|
||||
if (d.get('p1') or {}).get('raw') in ('03', '04'):
|
||||
return None
|
||||
elif ins_hex == 'c0' and d.get('response_for') == 'SELECT' and sel:
|
||||
# GET RESPONSE carrying the FCP of the last SELECT → capture the
|
||||
# file structure so we can sanity-check record operations.
|
||||
fd = (d.get('response') or {}).get('file_descriptor')
|
||||
if fd:
|
||||
new = dict(sel)
|
||||
new['structure'] = fd.get('structure')
|
||||
return new
|
||||
return sel
|
||||
|
||||
def _decode_rows(self, rows, initial_states=None, clk_hz=None):
|
||||
from .decode import atr_rate_fields
|
||||
msgs = []
|
||||
states = dict(initial_states or {})
|
||||
last_atr_i = None # index of the most recent ATR in *msgs*
|
||||
last_atr_rated = False # whether that ATR already carries a data rate
|
||||
for row in rows:
|
||||
channel = 0
|
||||
if row[3] == 'tpdu' and row[4] and len(row[4]) >= 5:
|
||||
channel = row[4][0] & 0x03
|
||||
st = self._channel_state(states, channel)
|
||||
ctx = dict(st['prev']) if st['prev'] else {}
|
||||
ctx['sel'] = st['sel']
|
||||
ctx['df'] = st['df']
|
||||
ctx['sfi_map'] = st['sfi_map']
|
||||
msg = self._row_to_message(row, prev=ctx, clk_hz=clk_hz)
|
||||
d = msg.get('decoded')
|
||||
if msg['type'] == 'tpdu' and d and d.get('ins_hex'):
|
||||
st['sel'], st['df'], st['sfi_map'] = self._advance_state(d, st['sel'], st['df'], st['sfi_map'])
|
||||
st['prev'] = self._context_from_decoded(d)
|
||||
elif msg['type'] == 'atr':
|
||||
states = {}
|
||||
# The CLK rate is consumed by the ATR it precedes: an ATR
|
||||
# without a preceding RST event must not inherit a stale rate.
|
||||
last_atr_i = len(msgs)
|
||||
last_atr_rated = bool(d and d.get('data_rate'))
|
||||
clk_hz = None
|
||||
elif msg['type'] == 'gap':
|
||||
# Device disconnected and reconnected — messages may have been
|
||||
# missed, so the selection is uncertain.
|
||||
states = {}
|
||||
elif msg['type'] == 'change':
|
||||
# Only card-level changes invalidate the selection. A
|
||||
# waiting-time timeout interrupts a single TPDU but leaves the
|
||||
# card selected, so the phone usually just retries.
|
||||
from ..protocol import (
|
||||
CHANGE_FLAG_CARD_INSERT, CHANGE_FLAG_CARD_EJECT,
|
||||
CHANGE_FLAG_RESET_ASSERT, CHANGE_FLAG_RESET_DEASSERT,
|
||||
)
|
||||
if msg['flags'] & (CHANGE_FLAG_CARD_INSERT | CHANGE_FLAG_CARD_EJECT |
|
||||
CHANGE_FLAG_RESET_ASSERT | CHANGE_FLAG_RESET_DEASSERT):
|
||||
states = {}
|
||||
elif msg['type'] in ('rst', 'vcc'):
|
||||
# sigrok-iso7816-stream line events: a reset assert or power
|
||||
# removal destroys the card's selection state. De-assert /
|
||||
# power-on are markers only (the following ATR resets state).
|
||||
data = row[4] if len(row) > 4 and row[4] else b''
|
||||
if d and d.get('clk_hz'):
|
||||
if (last_atr_i is not None and not last_atr_rated
|
||||
and msgs[last_atr_i].get('decoded')):
|
||||
# Ordering caveat: a short ATR can be emitted before
|
||||
# the RST event that measured its CLK rate — backfill
|
||||
# the rate onto that ATR.
|
||||
msgs[last_atr_i]['decoded'].update(
|
||||
atr_rate_fields(d['clk_hz'], msgs[last_atr_i]['decoded']))
|
||||
last_atr_rated = True
|
||||
else:
|
||||
clk_hz = d['clk_hz']
|
||||
if msg['type'] == 'rst':
|
||||
if data and data[0] == 1: # asserted
|
||||
states = {}
|
||||
else: # vcc
|
||||
if data and data[0] == 0: # power removed
|
||||
states = {}
|
||||
msgs.append(msg)
|
||||
return msgs
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,392 +0,0 @@
|
||||
"""HTTP API + static PWA server for simtrace2-pysniff."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
from urllib.parse import urlparse, parse_qs, quote
|
||||
|
||||
from .database import Database
|
||||
from .capture import CaptureManager, GsmtapListener, DirectSniffer, _log
|
||||
from ..gsmtap import build_gsmtap_packet, GSMTAP_SIM_ATR, GSMTAP_SIM_APDU
|
||||
from ..pcap import build_pcap, parse_pcap, parse_pcapng
|
||||
from .. import __version__
|
||||
|
||||
|
||||
# Static file serving (the PWA lives in <repo>/frontend, served by this server
|
||||
# so the UI and the API share an origin and no CORS/PNA is involved).
|
||||
_STATIC_MIME = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.css': 'text/css',
|
||||
'.js': 'application/javascript',
|
||||
'.json': 'application/json',
|
||||
'.png': 'image/png',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.ico': 'image/x-icon',
|
||||
'.webmanifest': 'application/manifest+json',
|
||||
'.map': 'application/json',
|
||||
'.wasm': 'application/wasm',
|
||||
}
|
||||
|
||||
|
||||
def _content_disposition(filename):
|
||||
"""RFC 6266/5987 Content-Disposition filename (ASCII fallback + UTF-8)."""
|
||||
fallback = filename.encode('ascii', 'replace').decode('ascii').replace('"', '_')
|
||||
return f"attachment; filename=\"{fallback}\"; filename*=UTF-8''{quote(filename, safe='')}"
|
||||
|
||||
|
||||
class RequestHandler(BaseHTTPRequestHandler):
|
||||
db: Database = None
|
||||
capture: CaptureManager = None
|
||||
capture_mode: str = 'gsmtap'
|
||||
log_requests: bool = False
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
if self.log_requests:
|
||||
print(f'[{self.log_date_time_string()}] {args[0]}', file=sys.stderr)
|
||||
|
||||
def _send_json(self, data, status=200):
|
||||
body = json.dumps(data, ensure_ascii=False).encode('utf-8')
|
||||
self.send_response(status)
|
||||
self.send_header('Content-Type', 'application/json')
|
||||
self.send_header('Content-Length', len(body))
|
||||
self.send_header('Access-Control-Allow-Origin', '*')
|
||||
self.send_header('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS')
|
||||
self.send_header('Access-Control-Allow-Headers', 'Content-Type')
|
||||
self.send_header('Access-Control-Allow-Private-Network', 'true')
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _send_binary(self, data, content_type, filename):
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', content_type)
|
||||
self.send_header('Content-Length', len(data))
|
||||
self.send_header('Content-Disposition', _content_disposition(filename))
|
||||
self.send_header('Access-Control-Allow-Origin', '*')
|
||||
self.send_header('Access-Control-Allow-Private-Network', 'true')
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def _send_error(self, status, message):
|
||||
self._send_json({'error': message}, status)
|
||||
|
||||
def _read_json(self):
|
||||
length = int(self.headers.get('Content-Length', 0))
|
||||
if length == 0:
|
||||
return {}
|
||||
return json.loads(self.rfile.read(length))
|
||||
|
||||
def _read_body(self):
|
||||
length = int(self.headers.get('Content-Length', 0))
|
||||
if length == 0:
|
||||
return b''
|
||||
return self.rfile.read(length)
|
||||
|
||||
def _parse_path(self):
|
||||
parsed = urlparse(self.path)
|
||||
path = parsed.path.rstrip('/')
|
||||
return path, parse_qs(parsed.query)
|
||||
|
||||
def _serve_static(self):
|
||||
web_dir = getattr(self.server, 'web_dir', None)
|
||||
if not web_dir:
|
||||
self._send_error(404, 'Not found')
|
||||
return
|
||||
rel = self.path.split('?', 1)[0].lstrip('/')
|
||||
if rel in ('', '/'):
|
||||
rel = 'index.html'
|
||||
if '..' in rel.split('/') or rel.startswith('/'):
|
||||
self._send_error(404, 'Not found')
|
||||
return
|
||||
fs_path = os.path.join(web_dir, rel)
|
||||
if not os.path.isfile(fs_path):
|
||||
self._send_error(404, 'Not found')
|
||||
return
|
||||
with open(fs_path, 'rb') as f:
|
||||
data = f.read()
|
||||
content_type = _STATIC_MIME.get(os.path.splitext(rel)[1].lower(), 'application/octet-stream')
|
||||
self.send_response(200)
|
||||
self.send_header('Content-Type', content_type)
|
||||
self.send_header('Content-Length', str(len(data)))
|
||||
if rel in ('index.html', 'sw.js'):
|
||||
self.send_header('Cache-Control', 'no-cache')
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
# --- CORS preflight ---
|
||||
def do_OPTIONS(self):
|
||||
self.send_response(204)
|
||||
self.send_header('Access-Control-Allow-Origin', '*')
|
||||
self.send_header('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS')
|
||||
self.send_header('Access-Control-Allow-Headers', 'Content-Type')
|
||||
self.send_header('Access-Control-Allow-Private-Network', 'true')
|
||||
self.end_headers()
|
||||
|
||||
# --- GET ---
|
||||
def do_GET(self):
|
||||
path, params = self._parse_path()
|
||||
|
||||
if path == '/api/status':
|
||||
self._handle_status()
|
||||
elif path == '/api/sessions':
|
||||
self._handle_list_sessions()
|
||||
elif path.startswith('/api/sessions/') and path.endswith('/pcap'):
|
||||
session_id = int(path.split('/')[-2])
|
||||
self._handle_get_session_pcap(session_id)
|
||||
elif path.startswith('/api/sessions/'):
|
||||
session_id = int(path.split('/')[-1])
|
||||
self._handle_get_session(session_id, params)
|
||||
elif path == '/api/capture/latest':
|
||||
self._handle_capture_latest(params)
|
||||
elif path == '/api/capture/status':
|
||||
self._handle_capture_status()
|
||||
elif path.startswith('/api/apdu/search'):
|
||||
session_id = int(params.get('session_id', [0])[0])
|
||||
query = params.get('q', [''])[0]
|
||||
self._handle_search(session_id, query)
|
||||
elif path.startswith('/api/apdu/filter'):
|
||||
session_id = int(params.get('session_id', [0])[0])
|
||||
msg_type = params.get('type', [''])[0]
|
||||
self._handle_filter(session_id, msg_type)
|
||||
elif path.startswith('/api/apdu/'):
|
||||
msg_id = int(path.split('/')[-1])
|
||||
self._handle_get_apdu(msg_id)
|
||||
else:
|
||||
self._serve_static()
|
||||
|
||||
# --- POST ---
|
||||
def do_POST(self):
|
||||
path, params = self._parse_path()
|
||||
|
||||
if path == '/api/capture/start':
|
||||
self._handle_capture_start()
|
||||
elif path == '/api/capture/stop':
|
||||
self._handle_capture_stop()
|
||||
elif path == '/api/sessions/import':
|
||||
self._handle_import_pcap(params)
|
||||
else:
|
||||
self._send_error(404, 'Not found')
|
||||
|
||||
# --- PATCH ---
|
||||
def do_PATCH(self):
|
||||
path, _ = self._parse_path()
|
||||
|
||||
if path.startswith('/api/sessions/') and path.endswith('/name'):
|
||||
session_id = int(path.split('/')[-2])
|
||||
body = self._read_json()
|
||||
self._handle_rename_session(session_id, body.get('name', ''))
|
||||
else:
|
||||
self._send_error(404, 'Not found')
|
||||
|
||||
# --- DELETE ---
|
||||
def do_DELETE(self):
|
||||
path, _ = self._parse_path()
|
||||
|
||||
if path.startswith('/api/sessions/'):
|
||||
session_id = int(path.split('/')[-1])
|
||||
self._handle_delete_session(session_id)
|
||||
else:
|
||||
self._send_error(404, 'Not found')
|
||||
|
||||
# --- Handlers ---
|
||||
|
||||
def _handle_status(self):
|
||||
active_session = self.db.get_active_session()
|
||||
capture = self.capture
|
||||
self._send_json({
|
||||
'server': 'simtrace-analyser-server',
|
||||
'version': __version__,
|
||||
'capture_mode': self.capture_mode,
|
||||
'capture_active': capture.active if capture else False,
|
||||
'session_id': capture.session_id if capture else None,
|
||||
'mode': active_session['mode'] if active_session else None,
|
||||
'messages_count': self.db.count_messages(capture.session_id) if capture and capture.session_id else 0,
|
||||
'device_connected': capture.device_connected if capture else None,
|
||||
})
|
||||
|
||||
def _handle_list_sessions(self):
|
||||
sessions = self.db.list_sessions()
|
||||
self._send_json({
|
||||
'sessions': sessions,
|
||||
'total_sessions': self.db.count_sessions(),
|
||||
'db_size': self.db.db_size(),
|
||||
})
|
||||
|
||||
def _handle_get_session(self, session_id, params):
|
||||
session = self.db.get_session(session_id)
|
||||
if session is None:
|
||||
self._send_error(404, 'Session not found')
|
||||
return
|
||||
offset = int(params.get('offset', [0])[0])
|
||||
limit = int(params.get('limit', [200])[0])
|
||||
messages = self.db.get_messages(session_id, offset=offset, limit=limit)
|
||||
total = self.db.count_messages(session_id)
|
||||
type_counts = self.db.get_type_counts(session_id)
|
||||
_log(f'Session opened: session={session_id} '
|
||||
f'name={session.get("name") or "(untitled)"} messages={total}')
|
||||
self._send_json({
|
||||
'session': session,
|
||||
'messages': messages,
|
||||
'total': total,
|
||||
'type_counts': type_counts,
|
||||
})
|
||||
|
||||
def _handle_get_session_pcap(self, session_id):
|
||||
session = self.db.get_session(session_id)
|
||||
if session is None:
|
||||
self._send_error(404, 'Session not found')
|
||||
return
|
||||
|
||||
try:
|
||||
start_ts = datetime.fromisoformat(session['started']).timestamp()
|
||||
except (ValueError, TypeError):
|
||||
start_ts = time.time()
|
||||
|
||||
packets = []
|
||||
for m in self.db.get_messages_raw(session_id):
|
||||
if m['type'] == 'atr':
|
||||
sub_type = GSMTAP_SIM_ATR
|
||||
elif m['type'] == 'tpdu':
|
||||
sub_type = GSMTAP_SIM_APDU
|
||||
else:
|
||||
continue
|
||||
gsmtap_hdr = build_gsmtap_packet(sub_type, b'')[:16]
|
||||
packets.append((gsmtap_hdr, m['data'], start_ts + m['elapsed']))
|
||||
|
||||
pcap = build_pcap(packets)
|
||||
name = (session.get('name') or f'session-{session_id}') + '.pcap'
|
||||
self._send_binary(pcap, 'application/vnd.tcpdump.pcap', name)
|
||||
|
||||
def _handle_capture_latest(self, params):
|
||||
after_id = int(params.get('after', [0])[0])
|
||||
if not self.capture or not self.capture.active:
|
||||
self._send_json({'messages': [], 'next_after': after_id, 'active': False,
|
||||
'device_connected': self.capture.device_connected if self.capture else None})
|
||||
return
|
||||
msg_id = self.capture.latest_msg_id
|
||||
messages = self.db.get_messages_after(self.capture.session_id, after_id)
|
||||
self._send_json({
|
||||
'messages': messages,
|
||||
'next_after': msg_id,
|
||||
'active': True,
|
||||
'session_id': self.capture.session_id,
|
||||
'device_connected': self.capture.device_connected,
|
||||
})
|
||||
|
||||
def _handle_capture_status(self):
|
||||
self._send_json({
|
||||
'active': self.capture.active if self.capture else False,
|
||||
'session_id': self.capture.session_id if self.capture else None,
|
||||
'device_connected': self.capture.device_connected if self.capture else None,
|
||||
})
|
||||
|
||||
def _handle_capture_start(self):
|
||||
if self.capture_mode == 'disabled' or self.capture is None:
|
||||
self._send_error(403, 'Capture disabled')
|
||||
return
|
||||
if self.capture.active:
|
||||
self.capture.stop_session()
|
||||
session_id = self.capture.start_session()
|
||||
started = self.db.get_session(session_id)['started']
|
||||
self._send_json({'session_id': session_id, 'started': started})
|
||||
|
||||
def _handle_capture_stop(self):
|
||||
if self.capture_mode == 'disabled' or self.capture is None:
|
||||
self._send_error(403, 'Capture disabled')
|
||||
return
|
||||
if not self.capture.active:
|
||||
self._send_error(400, 'No active capture')
|
||||
return
|
||||
session_id = self.capture.stop_session()
|
||||
if session_id is None:
|
||||
self._send_json({'session_id': None, 'messages_count': 0, 'discarded': True})
|
||||
return
|
||||
session = self.db.get_session(session_id)
|
||||
self._send_json({
|
||||
'session_id': session_id,
|
||||
'ended': session['ended'],
|
||||
'messages_count': self.db.count_messages(session_id),
|
||||
})
|
||||
|
||||
def _handle_import_pcap(self, params):
|
||||
raw = self._read_body()
|
||||
name = (params.get('name', [''])[0]).strip()
|
||||
|
||||
if not raw:
|
||||
self._send_error(400, 'Empty file')
|
||||
return
|
||||
|
||||
if raw[:4] == b'\x0a\x0d\x0d\x0a':
|
||||
packets = list(parse_pcapng(raw))
|
||||
else:
|
||||
packets = list(parse_pcap(raw))
|
||||
|
||||
if not packets:
|
||||
self._send_error(400, 'No GSMTAP data found in trace')
|
||||
return
|
||||
|
||||
session_id = self.db.create_session('pcap')
|
||||
if name:
|
||||
self.db.rename_session(session_id, name)
|
||||
|
||||
first_ts = packets[0][0]
|
||||
last_ts = packets[-1][0]
|
||||
rows = []
|
||||
for ts, msg_type, payload, flags in packets:
|
||||
elapsed = round(ts - first_ts, 6) if ts is not None else 0.0
|
||||
rows.append((max(0.0, elapsed), msg_type, payload, flags))
|
||||
self.db.insert_messages(session_id, rows)
|
||||
# Use the packet timestamps as the session window when they are real
|
||||
# (classic pcap / pcapng EPB). pcapng SPB-only files have synthetic
|
||||
# 0,1,2,… timestamps, so fall back to the import wall-clock time.
|
||||
_REAL_TS_MIN = 946684800 # 2000-01-01
|
||||
if first_ts is not None and first_ts > _REAL_TS_MIN:
|
||||
self.db.set_session_times_from_ts(session_id, first_ts, last_ts)
|
||||
else:
|
||||
duration = (last_ts - first_ts) if last_ts is not None and first_ts is not None else 0
|
||||
self.db.close_session(session_id, duration)
|
||||
|
||||
_log(f'Session imported: session={session_id} '
|
||||
f'name={name or "(untitled)"} messages={len(packets)} '
|
||||
f'bytes={sum(len(p) for _, _, p, _ in packets)}')
|
||||
self._send_json({
|
||||
'session_id': session_id,
|
||||
'name': name,
|
||||
'message_count': len(packets),
|
||||
})
|
||||
|
||||
def _handle_rename_session(self, session_id, name):
|
||||
self.db.rename_session(session_id, name)
|
||||
self._send_json({'ok': True})
|
||||
|
||||
def _handle_delete_session(self, session_id):
|
||||
if self.capture and self.capture.session_id == session_id:
|
||||
self.capture.stop_session()
|
||||
_log(f'Session deleted: session={session_id}')
|
||||
self.db.delete_session(session_id)
|
||||
self.db.vacuum()
|
||||
self._send_json({'ok': True})
|
||||
|
||||
def _handle_search(self, session_id, query):
|
||||
if not query:
|
||||
self._send_json({'messages': []})
|
||||
return
|
||||
messages = self.db.search_messages(session_id, query)
|
||||
self._send_json({'messages': messages})
|
||||
|
||||
def _handle_filter(self, session_id, msg_type):
|
||||
if not msg_type:
|
||||
self._send_json({'messages': []})
|
||||
return
|
||||
messages = self.db.filter_messages(session_id, msg_type)
|
||||
self._send_json({'messages': messages})
|
||||
|
||||
def _handle_get_apdu(self, msg_id):
|
||||
msg = self.db.get_message(msg_id)
|
||||
if msg is None:
|
||||
self._send_error(404, 'Message not found')
|
||||
return
|
||||
self._send_json(msg)
|
||||
Reference in new issue
Block a user