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:
catarrh committed 2026-09-19 12:34:16 +03:00
1 parent 8441b56e99
commit 38650b4797
20 files changed
+392 -323

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
"""simtrace_analyser — server side of SIMtrace Analyser.
Stores captured GSMTAP/PCAP APDU traffic in SQLite, decodes it, and serves
the PWA (``frontend/``) plus the HTTP API on a single origin.
"""
from .version import __version__
__all__ = ['__version__']
+85
View File
@@ -0,0 +1,85 @@
"""simtrace-analyser-server — CLI entry point.
Usage:
simtrace-analyser-server --capture gsmtap [--gsmtap-port 4729] [--port 8081]
simtrace-analyser-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 .version import __version__
def _default_web_dir():
# <repo>/frontend, whether run from source or an editable install.
# __file__ is <repo>/simtrace_analyser/__main__.py → up two levels.
return os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'frontend')
def main():
p = argparse.ArgumentParser(
prog='simtrace-analyser-server',
description='HTTP API + PWA server for APDU trace 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'simtrace-analyser-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()
+229
View File
@@ -0,0 +1,229 @@
"""Capture backends: GSMTAP listener and direct SIMtrace2 sniffer."""
import struct
import sys
import threading
import time
import traceback
from simtrace2_pysniff.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 simtrace2_pysniff.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 simtrace2_pysniff.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}')
+431
View File
@@ -0,0 +1,431 @@
"""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 simtrace2_pysniff.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
+392
View File
@@ -0,0 +1,392 @@
"""HTTP API + static PWA server for SIMtrace Analyser."""
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 simtrace2_pysniff.gsmtap import build_gsmtap_packet, GSMTAP_SIM_ATR, GSMTAP_SIM_APDU
from simtrace2_pysniff.pcap import build_pcap, parse_pcap, parse_pcapng
from .version 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)
+3
View File
@@ -0,0 +1,3 @@
"""Single source of truth for the SIMtrace Analyser version."""
__version__ = '2.0.0'