795a1fea49
The presets lived in the browser's localStorage and the PWA wrote the SCP80 counter back after every operation, so an increment could be lost when the response never reached the tab, when two browser contexts (or the demo origin plus a local one) held separate copies, or when the tab was closed mid-run. The counter is card state and the server performs every card operation, so the store now lives next to the server: - pysim_simple_server/presets.py: one JSON file (~/.pysim-simple-server/card_presets.json, --card-presets overrides; Path.home() resolves the same way on Windows), atomic writes (temp + fsync + os.replace), an RLock, stable uuid ids, the ICCID normalisation the PWA uses (digits / spaced / raw EF hex), a duplicate-ICCID refusal and a monotonic set_counter; every counter change is appended to card_presets.json.audit.jsonl. - API: GET /api/presets (card-free), POST /api/presets and /api/presets/update|delete|import; the import also takes the old localStorage export shape - that is the migration path, there is no automatic migration. - Operations name a preset_id and the server persists the counter they consumed: /api/send-ota (the plain SCP80 path reports final_cntr now too), the RAM install/install-app/compat steps (_ram_send_gp_apdu) and the test runner's SCP80 steps (_preset_counter_persist). - PWA: the Cards tab is a thin client of the store (load on connect, CRUD via the API, export/import), the counter write-backs became local display syncs (the SCP80 form's counter input stays a deliberate human edit) and the SCP80/RAM bodies carry preset_id; a new pysimFetchOk() surfaces the server's error bodies instead of silently using them. Policy: card keys in logs and local files are not a risk for this workbench tool (AGENTS.md) - the store is not encrypted and no redaction is required. Tests: tests/test_presets.py (store: ICCID forms, counter monotonicity and wrap, atomic writes, corrupt-file recovery, import), the preset endpoints in tests/test_fetch_routes.py (incl. the counter persistence via /api/send-ota) and the updated PWA tests. sw cache simple-v334.
339 lines
17 KiB
Python
339 lines
17 KiB
Python
import argparse
|
|
import logging
|
|
import os
|
|
import sys
|
|
import time
|
|
import traceback
|
|
from http.server import ThreadingHTTPServer
|
|
from pySim.card_handler import CardHandler
|
|
from pySim.commands import SimCardCommands
|
|
from pySim.exceptions import NoCardError
|
|
from pySim.log import PySimLogger
|
|
from pySim.cards import UiccCardBase
|
|
|
|
from .shell import load_pysim_app
|
|
from . import fastinit
|
|
from . import gsmtap
|
|
from . import presets
|
|
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
|
|
|
|
|
|
_server_start = 0
|
|
|
|
def _log_stdout(msg):
|
|
elapsed = time.time() - _server_start
|
|
os.write(1, ('[%8.3f] %s\n' % (elapsed, msg)).encode())
|
|
|
|
|
|
def _default_web_dir():
|
|
# <repo>/frontend, whether run from source or an editable install.
|
|
return os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'frontend')
|
|
|
|
|
|
def _default_mcc_mnc_list():
|
|
# Bundled default: the MIT-licensed operator list ships with the package
|
|
# (data/mcc-mnc-list.json, from pbakondy/mcc-mnc-list); --mcc-mnc-list
|
|
# overrides it and the endpoint reports available=false when missing.
|
|
pkg = os.path.dirname(os.path.abspath(__file__))
|
|
return os.path.join(pkg, 'data', 'mcc-mnc-list.json')
|
|
|
|
|
|
def _build_http_server(host, port, handler):
|
|
"""HTTP server for the PWA + API.
|
|
|
|
Threaded on purpose: card access is serialized by ``_CARD_LOCK`` (all
|
|
POSTs, the card-touching GETs and the background threads take it), so
|
|
while a long operation holds the lock the cached endpoints and static
|
|
files must still be answerable - a single-threaded server starved the
|
|
RAM modal's live progress poll (`/api/status.ram_progress`) and showed
|
|
the whole install result only at the end. Daemon threads keep a stuck
|
|
request from blocking shutdown."""
|
|
return ThreadingHTTPServer((host, port), handler)
|
|
|
|
|
|
def main():
|
|
global _server_start
|
|
_server_start = time.time()
|
|
mod = load_pysim_app()
|
|
parser = mod.option_parser
|
|
parser.description = 'pysim-simple-server — HTTP API for pysim'
|
|
parser.add_argument('--http-host', default='127.0.0.1', help='Bind address (default: 127.0.0.1)')
|
|
parser.add_argument('--http-port', type=int, default=8080, help='Bind port (default: 8080)')
|
|
parser.add_argument('--web-dir', default=_default_web_dir(), metavar='PATH',
|
|
help='Directory with the SIMple PWA static files to serve (default: <repo>/frontend)')
|
|
parser.add_argument('--mcc-mnc-list', default=_default_mcc_mnc_list(), metavar='PATH',
|
|
help='Worldwide MCC/MNC operator list (JSON) for the network-simulation operator picker (default: the bundled data/mcc-mnc-list.json)')
|
|
parser.add_argument('--log-requests', action='store_true', default=False, help='Log request/response payloads to stderr')
|
|
parser.add_argument('--card-presets', default=None, metavar='PATH',
|
|
help='Card preset store (default: ~/.pysim-simple-server/card_presets.json)')
|
|
parser.add_argument('--sms-oa', default='12345', metavar='DIGITS',
|
|
help='TP-Originating-Address (SMSC number) for the SMS-DELIVER TPDU (default: 12345)')
|
|
parser.add_argument('--sms-sm-sc', default='12345678912', metavar='DIGITS',
|
|
help='SM-SC address for SMS-SUBMIT routing in PoR-in-submit mode (default: 12345678912)')
|
|
parser.add_argument('--terminal-profile',
|
|
default='FFFFFFFF7F9F00DFFF03021FE2000000C3FB000704117800710100000038428003',
|
|
metavar='HEX',
|
|
help='TERMINAL PROFILE payload (default: the 33-byte profile of a real BIP-capable handset - the live card only starts HTTP OTA when BIP events/commands are advertised)')
|
|
parser.add_argument('--poll-interval', type=int, default=30, metavar='SECS',
|
|
help='Idle interval before automatic STATUS polling (1-255 seconds, default: 30). Disable with --poll-interval 0')
|
|
parser.add_argument('--no-card-init', action='store_true', default=False,
|
|
help='Skip pysim card initialization (preserve CAT session — no file manager)')
|
|
parser.add_argument('--timing', action='store_true', default=False,
|
|
help='Log phase durations, card resets and APDU counters with elapsed timestamps')
|
|
parser.add_argument('--fast-init', action='store_true', help=argparse.SUPPRESS)
|
|
parser.add_argument('--full-pysim-init', action='store_true', default=False,
|
|
help="Use pysim's stock init_card/equip (multiple physical card resets) instead of the default reset-free fast init")
|
|
parser.add_argument('--menu-timeout', type=int, default=60, metavar='SECS',
|
|
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
|
|
opts.fast_init = not opts.full_pysim_init
|
|
if opts.timing:
|
|
_timing_on()
|
|
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
|
|
rs = None
|
|
sim_menu = None
|
|
event_list = None
|
|
# Auto-detect PC/SC reader if none was explicitly specified.
|
|
# Handles late pcscd startup and USB enumeration delays.
|
|
if opts.pcsc_dev is None and opts.pcsc_regex is None:
|
|
try:
|
|
from smartcard.System import readers
|
|
for attempt in range(3):
|
|
r = readers()
|
|
if r:
|
|
sys.stderr.write('INIT: PC/SC reader detected: %s\n' % r[0])
|
|
opts.pcsc_dev = 0
|
|
break
|
|
if attempt < 2:
|
|
sys.stderr.write('INIT: no PC/SC readers found, retrying in 2s...\n')
|
|
time.sleep(2)
|
|
except Exception:
|
|
pass # smartcard module not available or pcscd unreachable
|
|
|
|
try:
|
|
kwargs = {}
|
|
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))
|
|
scc = SimCardCommands(sl)
|
|
scc.cat_cla = '80' # UICC CLA default; overridden for SIM after init_card
|
|
scc._tp.proactive_handler = _DefaultProactiveHandler()
|
|
t_phase = time.time()
|
|
if opts.fast_init:
|
|
try:
|
|
rs, card = fastinit.init_card_fast(sl, opts.skip_card_init, wait=True)
|
|
except NoCardError:
|
|
# Normal cardless start: there was no card in the reader (the
|
|
# 3s wait timed out). The presence monitor auto-equips once a
|
|
# card appears; nothing to recover, no traceback.
|
|
sys.stderr.write('INIT: no card in the reader — server ready; insert a card or press Equip\n')
|
|
except Exception:
|
|
print("Warning: fast card initialization failed, falling back to pysim init:", file=sys.stderr)
|
|
traceback.print_exc()
|
|
try:
|
|
rs, card = mod.init_card(sl, opts.skip_card_init)
|
|
except NoCardError:
|
|
# The fallback retried the cardless wait; still a normal
|
|
# cardless start, not an initialization failure.
|
|
sys.stderr.write('INIT: no card in the reader — server ready; insert a card or press Equip\n')
|
|
else:
|
|
try:
|
|
sl.wait_for_card(3)
|
|
rs, card = mod.init_card(sl, opts.skip_card_init)
|
|
except NoCardError:
|
|
sys.stderr.write('INIT: no card in the reader — server ready; insert a card or press Equip\n')
|
|
_tlog('card_init: %.0fms' % ((time.time() - t_phase) * 1000))
|
|
if card is not None:
|
|
# Fast init builds the card on its own SimCardCommands instance;
|
|
# adopt it so server.scc carries the card's cla_byte/sel_ctrl (the
|
|
# placeholder created above is a different object left at the SIM
|
|
# defaults, which made VERIFY go out with CLA A0 on a UICC).
|
|
scc = card._scc
|
|
scc.cat_cla = '80' if isinstance(card, UiccCardBase) else 'a0'
|
|
except Exception:
|
|
print("Warning: reader/card initialization failed:", file=sys.stderr)
|
|
traceback.print_exc()
|
|
ch = CardHandler(sl) if sl else None
|
|
t_phase = time.time()
|
|
try:
|
|
if card is not None:
|
|
app = mod.PysimApp(verbose=opts.verbose, card=card, rs=rs, sl=sl, ch=ch)
|
|
else:
|
|
# Cardless start: pySim logs 'Waiting for card...' (its own retry
|
|
# path) and pySim-shell prints 'pySim-shell not equipped!'; we
|
|
# report both cases with our own single line above. A PysimApp
|
|
# without a card would also retry the cardless wait, so install
|
|
# the filter before constructing it and drop the two internals.
|
|
saved_stdout = sys.stdout
|
|
sys.stdout = _LineFilter(saved_stdout, ('Waiting for card...', 'pySim-shell not equipped!'))
|
|
try:
|
|
app = mod.PysimApp(verbose=opts.verbose, card=None, rs=None, sl=sl, ch=ch)
|
|
finally:
|
|
sys.stdout = saved_stdout
|
|
except Exception:
|
|
print("Warning: PysimApp creation failed:", file=sys.stderr)
|
|
traceback.print_exc()
|
|
app = None
|
|
_tlog('pysim_app: %.0fms' % ((time.time() - t_phase) * 1000))
|
|
if app is not None and opts.fast_init:
|
|
fastinit.install(app)
|
|
iccid = None
|
|
netstate_files = None
|
|
if scc and card is not None and hasattr(scc, '_tp'):
|
|
scc._tp.apdu_tracer = _LoggingApduTracer()
|
|
try:
|
|
_init_proactive_session()
|
|
# Read EF.ICCID before the TERMINAL PROFILE starts the CAT session
|
|
# (the PWA auto-selects the matching card preset from it).
|
|
iccid = _read_iccid(app)
|
|
if iccid:
|
|
sys.stderr.write('INIT: ICCID %s\n' % iccid)
|
|
# Network state monitor: read the network-related EFs in the
|
|
# same CAT-free window (skipped without a readable ICCID).
|
|
try:
|
|
netstate_files = _netstate_read(app)
|
|
except Exception:
|
|
netstate_files = None
|
|
t_phase = time.time()
|
|
sys.stderr.write('INIT: sending TERMINAL PROFILE %s (CLA=%s)\n' % (opts.terminal_profile, scc.cat_cla))
|
|
sm, el = _send_terminal_profile(scc, opts.terminal_profile)
|
|
sys.stderr.write('INIT: TP done, menu=%s events=%s\n' % ('yes' if sm else 'no', 'yes' if el else 'no'))
|
|
sim_menu = sm or sim_menu
|
|
event_list = el or event_list
|
|
for _ in range(3):
|
|
st_data, st_sw = _send_status(scc)
|
|
sys.stderr.write('INIT: drain STATUS -> %s\n' % st_sw)
|
|
if not st_sw.startswith('91'):
|
|
break
|
|
_handle_proactive_chain(scc, st_sw)
|
|
_tlog('terminal_profile_drain: %.0fms' % ((time.time() - t_phase) * 1000))
|
|
except Exception:
|
|
traceback.print_exc(file=sys.stderr)
|
|
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(s) and make
|
|
# sure they stay attached across equip/re-equip.
|
|
def _reattach_tracer():
|
|
if app.card:
|
|
app.card._scc._tp.apdu_tracer = tracer
|
|
_reattach_tracer()
|
|
orig_onchange = app._onchange_apdu_trace
|
|
def _onchange_apdu_trace(param_name, old, new):
|
|
orig_onchange(param_name, old, new)
|
|
_reattach_tracer()
|
|
app._onchange_apdu_trace = _onchange_apdu_trace
|
|
server = _build_http_server(opts.http_host, opts.http_port, PysimHandler)
|
|
server.sl = sl
|
|
server.scc = scc
|
|
server.card = card
|
|
server.rs = rs
|
|
server.app = app
|
|
# PC/SC recovery: recreate the transport after a service failure (pcscd
|
|
# restart / reader re-enumeration); _ensure_transport() calls this.
|
|
def _new_transport():
|
|
return mod.init_reader(opts, **kwargs)
|
|
server.transport_factory = _new_transport
|
|
server.sms_oa = opts.sms_oa
|
|
server.sms_sc = opts.sms_sm_sc
|
|
server.log_requests = opts.log_requests
|
|
server.terminal_profile = opts.terminal_profile
|
|
server.cli_terminal_profile = opts.terminal_profile
|
|
server.web_dir = opts.web_dir
|
|
server.mcc_mnc_path = opts.mcc_mnc_list
|
|
# Card presets live server-side (the SCP80 counter is card state the server
|
|
# persists with the operation that consumed it); --card-presets overrides
|
|
# the default store in the user's home.
|
|
server.card_presets = presets.PresetStore(opts.card_presets)
|
|
sys.stderr.write('PRESETS: %s (%d presets)\n'
|
|
% (server.card_presets.path, len(server.card_presets.list())))
|
|
server.sim_menu = sim_menu
|
|
server.event_list = event_list
|
|
server.menu_active = False
|
|
server.stk_pending = None
|
|
server.card_present = card is not None
|
|
server.card_session = 1 if card is not None else 0
|
|
server.iccid = iccid
|
|
# 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.
|
|
try:
|
|
_netstate_install(server, netstate_files if iccid else None)
|
|
except Exception as e:
|
|
server.net_state = None
|
|
sys.stderr.write('INIT: network state failed: %s\n' % e)
|
|
server.equipping = False
|
|
# Set server reference for polling timer and mark the card session state
|
|
import pysim_simple_server.server
|
|
pysim_simple_server.server._server_ref = server
|
|
pysim_simple_server.server._CARD_CONNECTED = card is not None
|
|
if opts.poll_interval is not None:
|
|
pysim_simple_server.server._set_poll_interval(opts.poll_interval)
|
|
# Background STATUS polling is on by default: a CAT terminal polls during
|
|
# idle (TS 102 221 14.6.2). --poll-interval 0 disables it, and a card
|
|
# that wants no polling suspends it with POLLING OFF (TS 102 223 6.4.14)
|
|
# until a new POLL INTERVAL.
|
|
if opts.poll_interval == 0:
|
|
pysim_simple_server.server._poll_disable()
|
|
else:
|
|
pysim_simple_server.server._poll_enable()
|
|
# Start presence monitoring only after the startup init: pyscard reports an
|
|
# already-present card as "added" on the first pass, and we must not
|
|
# auto-equip over a session we just initialized. If startup init failed,
|
|
# that event triggers auto-equip instead — the desired retry.
|
|
if sl is not None and getattr(sl, '_reader', None) is not None:
|
|
start_card_monitor(str(sl._reader))
|
|
print("─" * 70)
|
|
print(" pysim-simple-server v%s listening on http://%s:%s" % (VERSION, opts.http_host, opts.http_port))
|
|
print(" Open http://%s:%s in your browser for the SIMple UI (served by this server)."
|
|
% (opts.http_host, opts.http_port))
|
|
print("─" * 70)
|
|
try:
|
|
server.serve_forever()
|
|
except KeyboardInterrupt:
|
|
print("\nShutting down...")
|
|
server.shutdown()
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main() |