From a387cdc1b6ed1d775db17169e21cce0f9fb551b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD=20=D0=A2=D1=80=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Tue, 22 Sep 2026 01:41:14 +0300 Subject: [PATCH] feat: GSMTAP-SIM APDU streaming for Wireshark / SIMtrace Analyser (v3.1.0) Streams every APDU the server sends or receives as GSMTAP-SIM UDP packets, so a live capture can be followed in Wireshark or the SIMtrace Analyser without a hardware sniffer. CLI-only: --gsmtap [HOST[:PORT]], default target 127.0.0.1:4729; no UI or API. - pysim_simple_server/gsmtap.py: 16-byte big-endian GSMTAP-SIM header (type 0x04, sub_type 0x00 = APDU / 0x01 = ATR) + raw APDU bytes, a fire-and-forget non-blocking sender that never raises into card I/O, an ApduTracer (a response is sent as data + SW1SW2, the wire form) and a fan-out tracer. The packet layout is byte-identical to sigrok-iso7816-stream / simtrace2-sniff (verified against the sigrok module) and is what the analyser's GSMTAP receiver expects. - __main__.py: --gsmtap option, tracer installed on the shared transport before the first APDU, combined with --apdu-trace via the fan-out and re-attached across equips (pySim nulls the tracer on every equip); one ATR packet per equip from _apply_equipped_card/_send_gsmtap_atr. - start.sh/start.bat: forward their extra arguments to the server, so ./start.sh --gsmtap works. - tests: packet layout, loopback UDP delivery, tracer mapping, target parsing, fan-out; docs (READMEs, help EN/RU, AGENTS); version 3.1.0, sw.js simple-v235. --- README.md | 3 +- README_RUS.md | 3 +- frontend/help-ru.html | 2 +- frontend/help.html | 2 +- frontend/index.html | 2 +- frontend/sw.js | 2 +- pyproject.toml | 2 +- pysim_simple_server/__main__.py | 54 +++++++++--- pysim_simple_server/gsmtap.py | 151 ++++++++++++++++++++++++++++++++ pysim_simple_server/server.py | 26 +++++- start.bat | 6 +- start.sh | 5 +- tests/test_gsmtap.py | 120 +++++++++++++++++++++++++ 13 files changed, 352 insertions(+), 26 deletions(-) create mode 100644 pysim_simple_server/gsmtap.py create mode 100644 tests/test_gsmtap.py diff --git a/README.md b/README.md index 816404c..0416f56 100644 --- a/README.md +++ b/README.md @@ -662,7 +662,7 @@ The bundled Python server wraps [pySim](https://osmocom.org/projects/pysim/wiki) | `setup.sh` / `setup.bat` | Creates `.venv/`, installs pysim and the server. Run once after cloning. | | `start.sh` / `start.bat` | Starts the server from the venv (serves the PWA + API on `:8080`). | -`start.sh` auto-detects the reader (PC/SC if `pcscd` is running, else `/dev/ttyUSB0`); `start.bat` always uses `-p 0` (PC/SC is built into Windows). If no reader is found the server still starts ("Reader: none") — initialize the card later via the **Equip** button. +`start.sh` auto-detects the reader (PC/SC if `pcscd` is running, else `/dev/ttyUSB0`); `start.bat` always uses `-p 0` (PC/SC is built into Windows). Extra arguments are passed through to the server, e.g. `./start.sh --gsmtap` or `start.bat --gsmtap 10.0.0.5:4729`. If no reader is found the server still starts ("Reader: none") — initialize the card later via the **Equip** button. ### Manual installation @@ -685,6 +685,7 @@ pysim-simple-server --http-port 8080 | `-d` / `--device` | Serial device path | | `--no-card-init` | Skip card init to preserve the CAT session (no file manager) | | `--apdu-trace` | Log APDU-level traces to stderr | +| `--gsmtap [HOST[:PORT]]` | Stream every APDU (and the card ATR at equip) as GSMTAP-SIM UDP packets for Wireshark / SIMtrace Analyser (`--capture gsmtap`); default target `127.0.0.1:4729`. Combines with `--apdu-trace` | | `--log-requests` | Log request/response payloads to stderr | | `--sms-oa` / `--sms-sm-sc` | SMS-DELIVER originating address / SM-SC for PoR-in-submit | | `--terminal-profile` | TERMINAL PROFILE payload hex (default: 33-byte real-handset profile that advertises BIP events/commands; the live card ignores HTTP OTA without it) | diff --git a/README_RUS.md b/README_RUS.md index 13cb10e..40ba617 100644 --- a/README_RUS.md +++ b/README_RUS.md @@ -645,7 +645,7 @@ PWA проверяет версию сервера при подключении | `setup.sh` / `setup.bat` | Создаёт `.venv/`, ставит pysim и сервер. Запускать один раз после клонирования. | | `start.sh` / `start.bat` | Запускает сервер из venv (раздаёт PWA + API на `:8080`). | -`start.sh` автоопределяет ридер (PC/SC при работающем `pcscd`, иначе `/dev/ttyUSB0`); `start.bat` всегда использует `-p 0`. Без ридера сервер всё равно стартует («Reader: none») — карту можно инициализировать позже кнопкой **Equip**. +`start.sh` автоопределяет ридер (PC/SC при работающем `pcscd`, иначе `/dev/ttyUSB0`); `start.bat` всегда использует `-p 0`. Дополнительные аргументы передаются серверу, например `./start.sh --gsmtap` или `start.bat --gsmtap 10.0.0.5:4729`. Без ридера сервер всё равно стартует («Reader: none») — карту можно инициализировать позже кнопкой **Equip**. ### Ручная установка @@ -668,6 +668,7 @@ pysim-simple-server --http-port 8080 | `-d` / `--device` | Путь к serial-устройству | | `--no-card-init` | Пропустить инициализацию карты (сохранить CAT-сессию) | | `--apdu-trace` | Лог APDU-трафика в stderr | +| `--gsmtap [HOST[:PORT]]` | Поток всех APDU (и ATR карты при equip) в виде GSMTAP-SIM UDP-пакетов для Wireshark / SIMtrace Analyser (`--capture gsmtap`); цель по умолчанию `127.0.0.1:4729`. Совместимо с `--apdu-trace` | | `--log-requests` | Лог запросов/ответов в stderr | | `--sms-oa` / `--sms-sm-sc` | Адрес отправителя SMS-DELIVER / SM-SC для PoR-in-submit | | `--terminal-profile` | Hex TERMINAL PROFILE (по умолчанию — 33-байтовый профиль реального телефона с BIP-событиями/командами; без него живая карта не запускает HTTP OTA) | diff --git a/frontend/help-ru.html b/frontend/help-ru.html index b9d3243..ac3dadb 100644 --- a/frontend/help-ru.html +++ b/frontend/help-ru.html @@ -546,7 +546,7 @@ start.bat # запускает сервер (PWA + API)
  • Резерв сервера — при запуске без аргументов ридера сервер сам опрашивает PC/SC-ридер при старте (3 попытки с интервалом 2 с)
  • Serial-ридеры — запустите сервер вручную с -d /dev/ttyUSB0 (Linux)
  • -

    Если карта отсутствует, вкладка «Картридер» показывает «Карта не обнаружена. Вставьте карту и нажмите Подключить карту». Вставьте карту — сервер инициализирует её автоматически (auto-equip включён по умолчанию) — либо нажмите Equip card. Сам сервер запускается и с пустым картридером: отсутствие карты — нормальное состояние, а не ошибка.

    +

    Если карта отсутствует, вкладка «Картридер» показывает «Карта не обнаружена. Вставьте карту и нажмите Подключить карту». Вставьте карту — сервер инициализирует её автоматически (auto-equip включён по умолчанию) — либо нажмите Equip card. Сам сервер запускается и с пустым картридером: отсутствие карты — нормальное состояние, а не ошибка. Дополнительные аргументы start.sh/start.bat передаются серверу (например, ./start.sh --gsmtap — поток APDU в виде GSMTAP-SIM пакетов для Wireshark / SIMtrace Analyser).

    9.6 Ручная установка

    # Создать и активировать venv
    diff --git a/frontend/help.html b/frontend/help.html
    index bceb846..9f2b671 100644
    --- a/frontend/help.html
    +++ b/frontend/help.html
    @@ -546,7 +546,7 @@ start.bat           # starts the server (serves PWA + API)
  • Server fallback — started without reader arguments, the server itself probes for a PC/SC reader at startup (3 attempts, 2 s apart)
  • Serial readers — start the server manually with -d /dev/ttyUSB0 (Linux)
  • -

    If no card is present, the Card reader tab shows “No card detected”. Insert the card — the server auto-equips it (auto-equip is on by default) — or click Equip card. The server itself starts fine with an empty reader; the absence of a card is a normal state, not an error.

    +

    If no card is present, the Card reader tab shows “No card detected”. Insert the card — the server auto-equips it (auto-equip is on by default) — or click Equip card. The server itself starts fine with an empty reader; the absence of a card is a normal state, not an error. Extra arguments given to start.sh/start.bat are passed through to the server (e.g. ./start.sh --gsmtap to stream APDUs as GSMTAP-SIM packets for Wireshark / SIMtrace Analyser).

    9.6 Manual installation

    # Create and activate a venv
    diff --git a/frontend/index.html b/frontend/index.html
    index d49b445..1564d0f 100644
    --- a/frontend/index.html
    +++ b/frontend/index.html
    @@ -1436,7 +1436,7 @@
     // ===== Version =====
     // Single source of truth for the PWA version: shown in the header and used
     // by the server version check in pysimConnect().
    -const SIMPLE_VERSION = '3.0.1';
    +const SIMPLE_VERSION = '3.1.0';
     document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
     
     // ===== Tab switching =====
    diff --git a/frontend/sw.js b/frontend/sw.js
    index ba1eef0..fea4de0 100644
    --- a/frontend/sw.js
    +++ b/frontend/sw.js
    @@ -1,4 +1,4 @@
    -const CACHE = 'simple-v234';
    +const CACHE = 'simple-v235';
     const URLS = [
       'index.html',
       'help.html',
    diff --git a/pyproject.toml b/pyproject.toml
    index 4f77cab..c5d1df8 100644
    --- a/pyproject.toml
    +++ b/pyproject.toml
    @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
     
     [project]
     name = "pysim-simple-server"
    -version = "3.0.1"
    +version = "3.1.0"
     description = "HTTP REST server wrapping pysim for the SIMple PWA"
     requires-python = ">=3.8"
     # pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
    diff --git a/pysim_simple_server/__main__.py b/pysim_simple_server/__main__.py
    index 912b5eb..14be27b 100644
    --- a/pysim_simple_server/__main__.py
    +++ b/pysim_simple_server/__main__.py
    @@ -13,7 +13,8 @@ from pySim.cards import UiccCardBase
     
     from .shell import load_pysim_app
     from . import fastinit
    -from .server import PysimHandler, StderrApduTracer, _LoggingApduTracer, VERSION, _send_terminal_profile, _DefaultProactiveHandler, _handle_proactive_chain, _send_status, _init_proactive_session, _timing_on, _tlog, _set_menu_timeout, start_card_monitor, set_auto_equip, _read_iccid, _netstate_read, _netstate_install, _LineFilter
    +from . import gsmtap
    +from .server import PysimHandler, StderrApduTracer, _LoggingApduTracer, VERSION, _send_terminal_profile, _DefaultProactiveHandler, _handle_proactive_chain, _send_status, _init_proactive_session, _timing_on, _tlog, _set_menu_timeout, start_card_monitor, set_auto_equip, _read_iccid, _netstate_read, _netstate_install, _send_gsmtap_atr, _LineFilter
     
     
     _server_start = 0
    @@ -70,6 +71,10 @@ def main():
                             help='Auto-send a timeout TERMINAL RESPONSE if a paused STK command is not answered (default: 60, 0 disables)')
         parser.add_argument('--no-auto-equip', action='store_true', default=False,
                             help='Do not automatically initialize a card right after it is inserted (default: auto-equip on)')
    +    parser.add_argument('--gsmtap', nargs='?', const=gsmtap.DEFAULT_TARGET, default=None,
    +                        metavar='HOST[:PORT]',
    +                        help='Stream every APDU (and the card ATR) as GSMTAP-SIM UDP packets for '
    +                             'Wireshark / SIMtrace Analyser (default target: %s)' % gsmtap.DEFAULT_TARGET)
     
         opts = parser.parse_args()
         opts.skip_card_init = opts.no_card_init
    @@ -79,6 +84,25 @@ def main():
         if opts.menu_timeout is not None:
             _set_menu_timeout(opts.menu_timeout)
         set_auto_equip(not opts.no_auto_equip and not opts.skip_card_init)
    +    # APDU tracers: --apdu-trace (stderr) and --gsmtap (GSMTAP-SIM UDP) can be
    +    # combined; the fan-out keeps pySim's single-tracer interface.
    +    gsmtap_sender = None
    +    tracers = []
    +    if opts.apdu_trace:
    +        tracers.append(_LoggingApduTracer())
    +    if opts.gsmtap is not None:
    +        try:
    +            gsmtap_host, gsmtap_port = gsmtap.parse_target(opts.gsmtap)
    +            gsmtap_sender = gsmtap.GsmtapSender(gsmtap_host, gsmtap_port)
    +            tracers.append(gsmtap.GsmtapApduTracer(gsmtap_sender))
    +            sys.stderr.write('GSMTAP: streaming APDUs to %s\n' % gsmtap_sender.target)
    +        except (ValueError, OSError) as e:
    +            sys.stderr.write('GSMTAP: disabled (%s)\n' % e)
    +    tracer = None
    +    if len(tracers) == 1:
    +        tracer = tracers[0]
    +    elif tracers:
    +        tracer = gsmtap.FanoutApduTracer(tracers)
         sl = None
         scc = None
         card = None
    @@ -104,8 +128,8 @@ def main():
     
         try:
             kwargs = {}
    -        if opts.apdu_trace:
    -            kwargs['apdu_tracer'] = _LoggingApduTracer()
    +        if tracer is not None:
    +            kwargs['apdu_tracer'] = tracer
             t_phase = time.time()
             sl = mod.init_reader(opts, **kwargs)
             _tlog('init_reader: %.0fms' % ((time.time() - t_phase) * 1000))
    @@ -203,17 +227,17 @@ def main():
                 _tlog('terminal_profile_drain: %.0fms' % ((time.time() - t_phase) * 1000))
             except Exception:
                 traceback.print_exc(file=sys.stderr)
    -    if app is not None and opts.apdu_trace:
    -        # PysimApp.__init__ routes PySimLogger through app.poutput() (app.stdout)
    -        # and drops the root level to INFO. Re-route pysim's own APDU trace logging
    -        # directly to fd 1 so it survives the app.stdout/StringIO redirection in the
    -        # HTTP handlers and the INFO level suppression.
    -        PySimLogger.setup(print_callback=_log_stdout)
    -        PySimLogger.set_level(logging.DEBUG)
    +    if app is not None and tracer is not None:
    +        if opts.apdu_trace:
    +            # PysimApp.__init__ routes PySimLogger through app.poutput() (app.stdout)
    +            # and drops the root level to INFO. Re-route pysim's own APDU trace logging
    +            # directly to fd 1 so it survives the app.stdout/StringIO redirection in the
    +            # HTTP handlers and the INFO level suppression.
    +            PySimLogger.setup(print_callback=_log_stdout)
    +            PySimLogger.set_level(logging.DEBUG)
             # PysimApp.__init__ and every `equip` wipe the transport apdu_tracer
    -        # (_onchange_apdu_trace sets it to None). Re-attach our tracer and make
    -        # sure it stays attached across equip/re-equip.
    -        tracer = _LoggingApduTracer()
    +        # (_onchange_apdu_trace sets it to None). Re-attach our tracer(s) and make
    +        # sure they stay attached across equip/re-equip.
             def _reattach_tracer():
                 if app.card:
                     app.card._scc._tp.apdu_tracer = tracer
    @@ -243,6 +267,10 @@ def main():
         server.card_present = card is not None
         server.card_session = 1 if card is not None else 0
         server.iccid = iccid
    +    server.gsmtap = gsmtap_sender
    +    # Stream the ATR so a GSMTAP receiver (SIMtrace Analyser, Wireshark) has
    +    # the session context before the first APDU of this session.
    +    _send_gsmtap_atr(server)
         # Network state monitor: install the state read during the startup init
         # (right after the ICCID, before the TERMINAL PROFILE).  No readable
         # ICCID means the card is considered unusable - give up.
    diff --git a/pysim_simple_server/gsmtap.py b/pysim_simple_server/gsmtap.py
    new file mode 100644
    index 0000000..d24c254
    --- /dev/null
    +++ b/pysim_simple_server/gsmtap.py
    @@ -0,0 +1,151 @@
    +# coding=utf-8
    +"""GSMTAP-SIM UDP sender for live APDU capture.
    +
    +Streams every APDU the server sends/receives as GSMTAP-SIM packets, so a
    +GSMTAP receiver (SIMtrace Analyser ``--capture gsmtap``, Wireshark, or
    +simtrace2-sniff) can follow the card dialogue live.  Enabled with
    +``--gsmtap [HOST[:PORT]]`` only (default ``127.0.0.1:4729``) - there is no
    +UI or API for it.
    +
    +The packet format is the one shared by libosmocore's ``gsmtap.h``,
    +simtrace2-sniff, sigrok-iso7816-stream and the SIMtrace Analyser: a 16-byte
    +big-endian header (version 2, ``hdr_len`` 4, type 0x04 = SIM, sub_type,
    +``res`` flags) followed by the raw APDU/TPDU bytes.  A response is sent as
    +``data + SW1SW2`` (the wire form); the receiver infers the direction from
    +the ISO 7816 case, exactly like a sniffer capture.
    +
    +Sending is fire-and-forget on a non-blocking socket: a missing listener must
    +never affect card I/O.
    +"""
    +
    +import socket
    +import struct
    +
    +from pySim.transport import ApduTracer
    +
    +GSMTAP_VERSION = 0x02
    +GSMTAP_HDR_LEN = 4              # in 32-bit words (16 bytes)
    +GSMTAP_TYPE_SIM = 0x04
    +
    +GSMTAP_SIM_APDU = 0x00
    +GSMTAP_SIM_ATR = 0x01
    +
    +GSMTAP_UDP_PORT = 4729
    +DEFAULT_TARGET = '127.0.0.1:%d' % GSMTAP_UDP_PORT
    +
    +_HDR_FMT = '!BBBBHBBIBBBB'      # 16 bytes, big-endian
    +_HDR_SIZE = struct.calcsize(_HDR_FMT)
    +
    +
    +def build_packet(sub_type, data, flags=0, slot_nr=0):
    +    """Build a complete GSMTAP-SIM packet (header + payload) as bytes."""
    +    hdr = struct.pack(
    +        _HDR_FMT,
    +        GSMTAP_VERSION,   # version
    +        GSMTAP_HDR_LEN,   # hdr_len (in 32-bit words)
    +        GSMTAP_TYPE_SIM,  # type
    +        0,                # timeslot
    +        0,                # arfcn
    +        0,                # signal_dbm
    +        0,                # snr_db
    +        0,                # frame_number
    +        sub_type,         # sub_type
    +        0,                # antenna_nr
    +        slot_nr,          # sub_slot
    +        flags,            # res (GSMTAP_FLAG_*; 0 here)
    +    )
    +    return hdr + bytes(data)
    +
    +
    +def parse_target(target):
    +    """'HOST[:PORT]' -> (host, port); empty/None -> the default target."""
    +    text = str(target or '').strip()
    +    if not text:
    +        text = DEFAULT_TARGET
    +    if ':' in text:
    +        host, _sep, port = text.rpartition(':')
    +        return host or '127.0.0.1', int(port)
    +    return text, GSMTAP_UDP_PORT
    +
    +
    +class GsmtapSender:
    +    """Fire-and-forget GSMTAP-SIM UDP sender (never raises on send)."""
    +
    +    def __init__(self, host='127.0.0.1', port=GSMTAP_UDP_PORT):
    +        self._addr = (host, int(port))
    +        self._sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    +        # A full socket buffer must never stall a card operation.
    +        self._sock.setblocking(False)
    +
    +    @property
    +    def target(self):
    +        return '%s:%d' % self._addr
    +
    +    def send(self, sub_type, data, flags=0, slot_nr=0):
    +        try:
    +            self._sock.sendto(build_packet(sub_type, data, flags, slot_nr),
    +                              self._addr)
    +        except OSError:
    +            pass
    +
    +    def send_apdu(self, data, slot_nr=0):
    +        self.send(GSMTAP_SIM_APDU, data, slot_nr=slot_nr)
    +
    +    def send_atr(self, data, slot_nr=0):
    +        self.send(GSMTAP_SIM_ATR, data, slot_nr=slot_nr)
    +
    +    def close(self):
    +        try:
    +            self._sock.close()
    +        except OSError:
    +            pass
    +
    +
    +class GsmtapApduTracer(ApduTracer):
    +    """pySim APDU tracer that streams every APDU as a GSMTAP-SIM packet.
    +
    +    Commands are sent as-is; a response is sent as ``data + SW1SW2`` so the
    +    receiver sees the same wire TPDU a hardware sniffer would capture.
    +    Malformed hex never raises into pySim's transport.
    +    """
    +
    +    def __init__(self, sender):
    +        super().__init__()
    +        self.sender = sender
    +
    +    def trace_command(self, cmd):
    +        if not cmd:
    +            return
    +        try:
    +            self.sender.send_apdu(bytes.fromhex(cmd))
    +        except (ValueError, TypeError):
    +            pass
    +
    +    def trace_response(self, cmd, sw, resp):
    +        data = (resp or '') + (sw or '')
    +        if not data:
    +            return
    +        try:
    +            self.sender.send_apdu(bytes.fromhex(data))
    +        except (ValueError, TypeError):
    +            pass
    +
    +
    +class FanoutApduTracer(ApduTracer):
    +    """Forward tracer callbacks to several tracers (e.g. stderr + GSMTAP)."""
    +
    +    def __init__(self, tracers):
    +        super().__init__()
    +        self.tracers = list(tracers)
    +
    +    def trace_command(self, cmd):
    +        for tracer in self.tracers:
    +            tracer.trace_command(cmd)
    +
    +    def trace_response(self, cmd, sw, resp):
    +        for tracer in self.tracers:
    +            tracer.trace_response(cmd, sw, resp)
    +
    +    def trace_reset(self):
    +        for tracer in self.tracers:
    +            tracer.trace_reset()
    diff --git a/pysim_simple_server/server.py b/pysim_simple_server/server.py
    index 83a86af..ecab3b7 100644
    --- a/pysim_simple_server/server.py
    +++ b/pysim_simple_server/server.py
    @@ -28,7 +28,7 @@ from osmocom.tlv import BER_TLV_IE
     from osmocom.utils import rpad
     
     
    -VERSION = '3.0.1'
    +VERSION = '3.1.0'
     
     MAX_ENVELOPE_SEGMENTS = 5  # max SMS segments for outgoing C-APDU in ENVELOPE
     
    @@ -2532,10 +2532,34 @@ def _handle_card_disconnect():
         _reset_proactive_log()
     
     
    +def _send_gsmtap_atr(server):
    +    """Stream the card's ATR as a GSMTAP-SIM packet (--gsmtap only).
    +
    +    Sent at every equip so a GSMTAP receiver (SIMtrace Analyser, Wireshark)
    +    has the session context before the first APDU of the new session."""
    +    sender = getattr(server, 'gsmtap', None)
    +    if sender is None:
    +        return
    +    rs = getattr(server, 'rs', None)
    +    atr = None
    +    if rs is not None:
    +        try:
    +            atr = (rs.identity or {}).get('ATR')
    +        except Exception:
    +            atr = None
    +    if not atr:
    +        return
    +    try:
    +        sender.send_atr(bytes.fromhex(atr))
    +    except (ValueError, TypeError):
    +        pass
    +
    +
     def _apply_equipped_card(server):
         """Common post-equip state refresh + TERMINAL PROFILE, shared by the
         /api/command equip branch and the auto-equip worker."""
         global _CARD_CONNECTED
    +    _send_gsmtap_atr(server)
         server.stk_pending = None
         server.menu_active = False
         _cancel_menu_timeout()
    diff --git a/start.bat b/start.bat
    index 85cf8e4..5bc5224 100644
    --- a/start.bat
    +++ b/start.bat
    @@ -8,14 +8,14 @@ set VENV_DIR=%~dp0.venv
     if exist "%VENV_DIR%\Scripts\pysim-simple-server.exe" (
         echo Starting pysim-simple-server from venv on http://127.0.0.1:8080
         echo Press Ctrl+C to stop.
    -    "%VENV_DIR%\Scripts\pysim-simple-server.exe" --http-port 8080 -p 0
    +    "%VENV_DIR%\Scripts\pysim-simple-server.exe" --http-port 8080 -p 0 %*
         goto :eof
     )
     
     if exist "%~dp0pysim_simple_server\__main__.py" (
         echo Starting pysim-simple-server from source on http://127.0.0.1:8080
         echo Press Ctrl+C to stop.
    -    python -m pysim_simple_server --http-port 8080 -p 0
    +    python -m pysim_simple_server --http-port 8080 -p 0 %*
         goto :eof
     )
     
    @@ -23,7 +23,7 @@ where pysim-simple-server >nul 2>&1
     if %errorlevel% equ 0 (
         echo Starting pysim-simple-server on http://127.0.0.1:8080
         echo Press Ctrl+C to stop.
    -    pysim-simple-server --http-port 8080 -p 0
    +    pysim-simple-server --http-port 8080 -p 0 %*
         goto :eof
     )
     
    diff --git a/start.sh b/start.sh
    index 2eee453..a082b17 100755
    --- a/start.sh
    +++ b/start.sh
    @@ -27,7 +27,7 @@ elif command -v pysim-simple-server &> /dev/null; then
         SERVER="pysim-simple-server"
     elif [ -f "$SCRIPT_DIR/pysim_simple_server/__main__.py" ]; then
         echo "Starting pysim-simple-server from source on http://127.0.0.1:8080"
    -    cd "$SCRIPT_DIR" && python3 -m pysim_simple_server --http-port 8080 $READER_ARGS
    +    cd "$SCRIPT_DIR" && python3 -m pysim_simple_server --http-port 8080 $READER_ARGS "$@"
         exit $?
     else
         echo "Error: pysim-simple-server not installed."
    @@ -38,4 +38,5 @@ fi
     
     echo "Starting pysim-simple-server on http://127.0.0.1:8080"
     echo "Press Ctrl+C to stop."
    -$SERVER --http-port 8080 $READER_ARGS
    +echo "Extra arguments are passed to the server (e.g. ./start.sh --gsmtap)."
    +$SERVER --http-port 8080 $READER_ARGS "$@"
    diff --git a/tests/test_gsmtap.py b/tests/test_gsmtap.py
    new file mode 100644
    index 0000000..810d0d9
    --- /dev/null
    +++ b/tests/test_gsmtap.py
    @@ -0,0 +1,120 @@
    +# coding=utf-8
    +"""Tests for the GSMTAP-SIM APDU sender (gsmtap.py)."""
    +
    +import socket
    +import struct
    +import unittest
    +
    +from pysim_simple_server import gsmtap
    +
    +
    +class PacketTests(unittest.TestCase):
    +    def test_header_and_payload(self):
    +        apdu = bytes.fromhex('00A40004023F00')
    +        pkt = gsmtap.build_packet(gsmtap.GSMTAP_SIM_APDU, apdu)
    +        self.assertEqual(len(pkt), 16 + len(apdu))
    +        (version, hdr_len, pkt_type, timeslot, arfcn, signal, snr, frame,
    +         sub_type, antenna, slot, res) = struct.unpack('!BBBBHBBIBBBB', pkt[:16])
    +        self.assertEqual(version, gsmtap.GSMTAP_VERSION)
    +        self.assertEqual(hdr_len, 4)
    +        self.assertEqual(pkt_type, 0x04)          # GSMTAP_TYPE_SIM
    +        self.assertEqual((timeslot, arfcn, signal, snr, frame), (0, 0, 0, 0, 0))
    +        self.assertEqual(sub_type, gsmtap.GSMTAP_SIM_APDU)
    +        self.assertEqual((antenna, slot, res), (0, 0, 0))
    +        self.assertEqual(pkt[16:], apdu)
    +
    +    def test_atr_subtype_and_slot(self):
    +        pkt = gsmtap.build_packet(gsmtap.GSMTAP_SIM_ATR, b'\x3b\x00', slot_nr=2)
    +        self.assertEqual(pkt[12], gsmtap.GSMTAP_SIM_ATR)   # sub_type
    +        self.assertEqual(pkt[14], 2)                       # sub_slot
    +        self.assertEqual(pkt[16:], b'\x3b\x00')
    +
    +    def test_parse_target_defaults_and_overrides(self):
    +        self.assertEqual(gsmtap.parse_target(None), ('127.0.0.1', 4729))
    +        self.assertEqual(gsmtap.parse_target(''), ('127.0.0.1', 4729))
    +        self.assertEqual(gsmtap.parse_target('127.0.0.1:4729'), ('127.0.0.1', 4729))
    +        self.assertEqual(gsmtap.parse_target('10.0.0.5'), ('10.0.0.5', 4729))
    +        self.assertEqual(gsmtap.parse_target('10.0.0.5:5000'), ('10.0.0.5', 5000))
    +        self.assertEqual(gsmtap.parse_target(':5000'), ('127.0.0.1', 5000))
    +
    +
    +class SenderTests(unittest.TestCase):
    +    def test_send_apdu_and_atr_reach_a_udp_listener(self):
    +        sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    +        sock.bind(('127.0.0.1', 0))
    +        sock.settimeout(2.0)
    +        host, port = sock.getsockname()
    +        sender = gsmtap.GsmtapSender(host, port)
    +        try:
    +            sender.send_apdu(bytes.fromhex('00A40004023F00'))
    +            sender.send_atr(bytes.fromhex('3B00'))
    +            first, _addr = sock.recvfrom(2048)
    +            second, _addr = sock.recvfrom(2048)
    +        finally:
    +            sender.close()
    +            sock.close()
    +        self.assertEqual(first[12], gsmtap.GSMTAP_SIM_APDU)   # sub_type
    +        self.assertEqual(first[16:], bytes.fromhex('00A40004023F00'))
    +        self.assertEqual(second[12], gsmtap.GSMTAP_SIM_ATR)
    +        self.assertEqual(second[16:], bytes.fromhex('3B00'))
    +
    +    def test_send_never_raises_without_a_listener(self):
    +        sender = gsmtap.GsmtapSender('127.0.0.1', 1)   # nothing listening
    +        sender.send_apdu(b'\x00\xa4')                  # must not raise
    +        sender.close()
    +
    +
    +class Recorder:
    +    """Stand-in for GsmtapSender recording the APDU payloads."""
    +
    +    def __init__(self):
    +        self.apdus = []
    +
    +    def send_apdu(self, data, slot_nr=0):
    +        self.apdus.append(bytes(data))
    +
    +
    +class TracerTests(unittest.TestCase):
    +    def test_command_and_response_are_wire_shaped(self):
    +        rec = Recorder()
    +        tracer = gsmtap.GsmtapApduTracer(rec)
    +        tracer.trace_command('00A40004023F00')
    +        tracer.trace_response('00A40004023F00', '9000', '622982027821')
    +        self.assertEqual(rec.apdus, [
    +            bytes.fromhex('00A40004023F00'),
    +            bytes.fromhex('6229820278219000'),   # data + SW1SW2
    +        ])
    +
    +    def test_response_without_data_is_the_sw(self):
    +        rec = Recorder()
    +        gsmtap.GsmtapApduTracer(rec).trace_response('00B000000A', '6A82', '')
    +        self.assertEqual(rec.apdus, [bytes.fromhex('6A82')])
    +
    +    def test_malformed_hex_never_raises(self):
    +        rec = Recorder()
    +        tracer = gsmtap.GsmtapApduTracer(rec)
    +        tracer.trace_command('not-hex')
    +        tracer.trace_response('00A4', None, None)
    +        self.assertEqual(rec.apdus, [])
    +
    +    def test_fanout_forwards_every_callback(self):
    +        class T:
    +            def __init__(self):
    +                self.calls = []
    +
    +            def trace_command(self, cmd):
    +                self.calls.append(('cmd', cmd))
    +
    +            def trace_response(self, cmd, sw, resp):
    +                self.calls.append(('rsp', cmd, sw, resp))
    +
    +            def trace_reset(self):
    +                self.calls.append(('reset',))
    +
    +        a, b = T(), T()
    +        fan = gsmtap.FanoutApduTracer([a, b])
    +        fan.trace_command('00A4')
    +        fan.trace_response('00A4', '9000', '')
    +        fan.trace_reset()
    +        self.assertEqual(a.calls, b.calls)
    +        self.assertEqual(len(a.calls), 3)