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.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user