scp81: rename passthru to redirect, add a true passthru mode (v2.2.13)

The former 'passthru' mode is now 'redirect': it pins the configured target
and every BIP channel is connected there (the card's requested address is
only logged; host/port required). The name 'passthru' now means the new mode:
no listener and no target - each channel dials the destination the card
requests in OPEN CHANNEL (Other address + Transport level port, TCP client,
remote, 0x02 only). The specs define no default port (TS 102 223 8.59), so an
incomplete or non-TCP request fails the channel with result 3A and an
open-fail log reason.

BipTerminal gains a mode (enable(host, port, mode=...), open(..., proto=...)),
reports it in status(), and the control API/status expose redirect (target)
and passthru (per-channel targets). The PWA mode selector shows four modes
with per-mode notes and disables Host/Port in passthru; the status line shows
each channel's actual target. Docs, help (EN/RU), READMEs and the AGENTS notes
updated; SW cache otaman-v179.

Breaking API change: mode:'passthru' has the new semantics - use
mode:'redirect' for the previous behavior (no alias).
This commit is contained in:
2026-09-17 08:17:28 +03:00
parent b02f28f6ce
commit 404fdf5a1c
13 changed files with 241 additions and 76 deletions
+49 -10
View File
@@ -1,9 +1,11 @@
"""HTTP OTA (SCP81 / GP RAM over HTTP) emulation.
Phase A: terminal-side BIP emulation (OPEN/SEND/RECEIVE/CLOSE CHANNEL) plus a
raw TCP capture listener. The card's BIP channel is always redirected to the
locally configured target (the future PSK TLS platform); the address the card
requested is only logged.
raw TCP capture listener. In the default redirect mode the card's BIP channel
is always redirected to the locally configured target (the future PSK TLS
platform) and the address the card requested is only logged; in passthru mode
the channel dials the destination the card requests in OPEN CHANNEL (TCP,
UICC in client mode, remote connection).
Reference behavior (TS 102 223 8.52-8.56, GP v2.2 Amendment B) is taken from
the captured real-terminal traces in samples/HTTP_OTA/traces:
@@ -154,6 +156,7 @@ class BipTerminal:
def __init__(self):
self.enabled = False
self.mode = 'redirect'
self.target = None
self.channels = {}
self.next_id = 1
@@ -213,10 +216,17 @@ class BipTerminal:
name='bip-monitor', daemon=True)
self._monitor.start()
def enable(self, host, port):
self.target = (host, int(port))
def enable(self, host=None, port=None, mode='redirect'):
"""Enable the BIP terminal.
'redirect' (default) pins one target: every channel goes there whatever
address the card requests. 'passthru' has no target at all: every
channel dials the destination the card requested in OPEN CHANNEL."""
self.mode = mode if mode in ('redirect', 'passthru') else 'redirect'
self.target = (host, int(port)) if host and port not in (None, '') else None
self.enabled = True
self.log('enabled', target='%s:%d' % self.target)
self.log('enabled', mode=self.mode,
target='%s:%d' % self.target if self.target else None)
self._start_monitor()
def disable(self):
@@ -271,12 +281,40 @@ class BipTerminal:
return cid
return None
def open(self, requested_host, requested_port, buffer_size):
"""Open a channel to the redirect target. Returns (channel_id, error)."""
if not self.enabled or not self.target:
def open(self, requested_host, requested_port, buffer_size, proto=None):
"""Open a channel.
Redirect modes connect to the pinned target; passthru dials the
destination the card sent in OPEN CHANNEL (Other address + Transport
level port). Returns (channel_id, error)."""
if not self.enabled:
return None, 'bip disabled'
target = self.target
requested = '%s:%s' % (requested_host, requested_port)
if self.mode == 'passthru':
# Use the card's request as-is: TCP, UICC in client mode, remote
# connection (TS 102 223 6.4.27.2 / 8.59). The specs define no
# default port, so an incomplete or non-TCP request fails.
host = (requested_host or '').strip()
try:
port = int(requested_port)
except (TypeError, ValueError):
port = 0
if proto != 0x02:
reason = 'card did not request TCP client transport (passthru)'
elif not host or host == '-':
reason = 'card did not request a destination address (passthru)'
elif not 0 < port <= 0xFFFF:
reason = 'card did not request a valid port (passthru)'
else:
reason = None
if reason:
self.log('open-fail', requested=requested, reason=reason)
return None, reason
target = (host, port)
else:
if not self.target:
return None, 'bip disabled'
target = self.target
cid = self._alloc_id()
if cid is None:
self.log('open-fail', requested=requested, reason='no free channel')
@@ -363,6 +401,7 @@ class BipTerminal:
})
return {
'enabled': self.enabled,
'mode': self.mode,
'target': '%s:%d' % self.target if self.target else None,
'channels': channels,
'seq': self.seq,
+35 -17
View File
@@ -21,7 +21,7 @@ from osmocom.construct import GsmOrUcs2Adapter
from osmocom.tlv import BER_TLV_IE
VERSION = '2.2.12'
VERSION = '2.2.13'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
@@ -851,9 +851,12 @@ _PLI_DATA = {q: '' for q in PLI_QUALIFIER_NAMES}
_BIP = httpota.BipTerminal()
_SCP81_LISTENER = None
# Active listener mode and target: ('dump'|'tls'|'passthru', host, port).
# passthru has no listener object - the BIP channels connect straight to the
# external platform - so the mode/target are tracked here for the status API.
# Active listener mode: 'dump' | 'tls' | 'redirect' | 'passthru'.
# 'redirect' pins one target and has no listener object - the BIP channels
# connect straight to the configured external platform (TLS terminated
# there). 'passthru' has neither listener nor target: each channel dials the
# destination the card requests in OPEN CHANNEL. Mode/target are tracked here
# for the status API.
_SCP81_MODE = None
_SCP81_TARGET = None
# PSK table of the TLS listener: identity -> key (memory only, never logged or
@@ -1409,7 +1412,7 @@ def _handle_bip_command(scc, cmd_num, cmd_type, cmd_qual, raw, dev_src, dev_dst)
# destination TLVs - see the AGENTS.md HTTP OTA notes).
_BIP.log('open-relaxed', address=addr, port=port,
note='destination/transport not fully specified')
cid, err = _BIP.open(addr or '-', port or 0, buffer_size)
cid, err = _BIP.open(addr or '-', port or 0, buffer_size, proto=proto)
if cid is None:
return _bip_tr(cmd_num, cmd_type, cmd_qual, dev_src, dev_dst, 0x3A, 0x00, extra)
if cmd_qual and (cmd_qual & 0x04):
@@ -1461,10 +1464,15 @@ def _handle_bip_command(scc, cmd_num, cmd_type, cmd_qual, raw, dev_src, dev_dst)
def _scp81_listener_status():
if not _SCP81_LISTENER:
if _SCP81_MODE == 'passthru' and _SCP81_TARGET:
return {'mode': 'passthru', 'host': _SCP81_TARGET[0],
if _SCP81_MODE == 'redirect' and _SCP81_TARGET:
return {'mode': 'redirect', 'host': _SCP81_TARGET[0],
'port': _SCP81_TARGET[1],
'target': '%s:%d' % _SCP81_TARGET}
if _SCP81_MODE == 'passthru':
# No listener and no pinned target: every channel dials the
# destination the card requests (per-channel targets in the
# BIP status).
return {'mode': 'passthru'}
return None
if isinstance(_SCP81_LISTENER, scp81.PskTlsServer):
return {'mode': 'tls', 'host': _SCP81_LISTENER.host, 'port': _SCP81_LISTENER.port,
@@ -2010,18 +2018,28 @@ def _scp81_bip_control(body):
_SCP81_LISTENER.stop()
_SCP81_LISTENER = None
_BIP.disable()
if mode == 'passthru':
# No local listener: the card's BIP channels connect straight to the
# external platform (e.g. a production HTTP OTA server), which
# terminates TLS and runs the administration dialog. The target is a
# configured address, never the address the card requests.
if mode == 'redirect':
# No local listener: the card's BIP channels are redirected straight
# to the configured target (e.g. a production HTTP OTA server), which
# terminates TLS and runs the administration dialog. The address the
# card requests is only logged.
if not body.get('host') or body.get('port') in (None, ''):
return {'ok': False,
'error': 'passthru mode requires the target host and port'}
_SCP81_MODE = 'passthru'
'error': 'redirect mode requires the target host and port'}
_SCP81_MODE = 'redirect'
_SCP81_TARGET = (host, port)
_BIP.on_data = _bip_data_available
_BIP.enable(host, port)
_BIP.enable(host, port, mode='redirect')
return {'ok': True, 'bip': _BIP.status(),
'listener': _scp81_listener_status()}
if mode == 'passthru':
# No local listener and no pinned target: every BIP channel dials the
# destination the card requests in OPEN CHANNEL (Other address +
# Transport level port, TCP client only). Host and port are unused.
_SCP81_MODE = 'passthru'
_SCP81_TARGET = None
_BIP.on_data = _bip_data_available
_BIP.enable(mode='passthru')
return {'ok': True, 'bip': _BIP.status(),
'listener': _scp81_listener_status()}
if mode == 'tls':
@@ -2103,7 +2121,7 @@ def _scp81_bip_control(body):
_SCP81_MODE = 'tls'
_SCP81_TARGET = (_SCP81_LISTENER.host, _SCP81_LISTENER.port)
_BIP.on_data = _bip_data_available
_BIP.enable(host, _SCP81_LISTENER.port)
_BIP.enable(host, _SCP81_LISTENER.port, mode='redirect')
return {'ok': True, 'bip': _BIP.status(), 'listener': _scp81_listener_status(),
'script': list(_SCP81_SCRIPT_BASE),
'script_kind': _SCP81_SCRIPT_KIND,
@@ -2121,7 +2139,7 @@ def _scp81_bip_control(body):
on_log=lambda kind, **fields: _BIP.log(kind, **fields))
_SCP81_MODE = 'dump'
_SCP81_TARGET = (_SCP81_LISTENER.host, _SCP81_LISTENER.port)
_BIP.enable(host, _SCP81_LISTENER.port)
_BIP.enable(host, _SCP81_LISTENER.port, mode='redirect')
return {'ok': True, 'bip': _BIP.status(), 'listener': _scp81_listener_status()}