Files
otaman/pysim_otaman_server/httpota.py
T
catarrh ea1730b206 scp81: BIP terminal emulation (phase A)
Groundwork for HTTP OTA emulation (GP RAM over HTTP / SCP81): the server
now emulates the terminal side of Bearer Independent Protocol and redirects
the card's TCP channel to a locally configured target.

- new pysim_otaman_server/httpota.py: BER-TLV parser for proactive commands,
  BipTerminal (per-channel TCP client with rx buffering, redirect target,
  bounded event log) and TcpDumpServer (raw capture listener used to
  calibrate the card's TLS ClientHello before the PSK platform exists)
- server.py: BIP commands 0x40-0x43 (+0x44) are handled in
  _handle_proactive_chain with TR payloads matching the captured real
  terminal traces (result first, Channel status with link-established bit,
  Bearer description, Buffer size, Channel data/length); connection
  failures return result 3A/00; names and decoders added for the proactive
  log; /api/scp81/status, /log, /log-clear and /bip (start/stop dump mode)
- tests: TR byte vectors from the traces, TLV parsing, open/send/receive/
  close flow over a local peer, dump listener; 123 Python tests pass
2026-09-13 22:56:48 +03:00

357 lines
11 KiB
Python

"""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.
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:
OPEN CHANNEL TR: result, Channel status (38), Bearer description (35), Buffer size (39)
SEND DATA TR: result, Channel data length (37)
RECEIVE DATA TR: result, Channel data (36), Channel data length (37)
CLOSE CHANNEL TR: result
"""
import socket
import threading
import time
MAX_LOG = 1000
def ber_len_read(data, off):
"""Read a BER-TLV length at data[off]; returns (length, next_offset)."""
if off >= len(data):
return 0, off
b = data[off]
if b < 0x80:
return b, off + 1
n = b & 0x7F
if n == 0 or off + 1 + n > len(data):
return 0, len(data)
return int.from_bytes(data[off + 1:off + 1 + n], 'big'), off + 1 + n
def proactive_tlvs(raw):
"""Top-level TLV map {tag: value} of a D0 proactive command."""
out = {}
if not raw or raw[0] != 0xD0:
return out
ln, off = ber_len_read(raw, 1)
end = min(len(raw), off + ln)
while off + 1 < end:
tag = raw[off]
tlen, off2 = ber_len_read(raw, off + 1)
val = raw[off2:off2 + tlen]
off = off2 + tlen
out.setdefault(tag, val)
return out
def parse_other_address(value):
"""Decode an 'Other address' TLV (21=IPv4, 57=IPv6, F0=FQDN)."""
if not value:
return None
t = value[0]
if t == 0x21 and len(value) >= 5:
return '.'.join(str(b) for b in value[1:5])
if t == 0x57 and len(value) >= 17:
return ':'.join('%x' % int.from_bytes(value[i:i + 2], 'big') for i in range(1, 17, 2))
if t == 0xF0:
return value[1:].decode('ascii', 'replace')
return None
def parse_transport_level(value):
"""Decode an UICC/terminal interface transport level TLV -> (proto, port)."""
if not value or len(value) < 3:
return None, None
return value[0], int.from_bytes(value[1:3], 'big')
TAG_BEARER = 0x35
TAG_CHANNEL_DATA = 0x36
TAG_CHANNEL_DATA_LENGTH = 0x37
TAG_CHANNEL_STATUS = 0x38
TAG_BUFFER_SIZE = 0x39
TAG_TRANSPORT_LEVEL = 0x3C
TAG_OTHER_ADDRESS = 0x3E
TAG_NAA = 0x47
class BipChannel:
def __init__(self, channel_id, sock, requested, target, buffer_size):
self.id = channel_id
self.sock = sock
self.requested = requested
self.target = target
self.buffer_size = buffer_size or 512
self.rx = bytearray()
self.bytes_in = 0
self.bytes_out = 0
self.opened_at = time.time()
self.peer_closed = False
def pump(self, timeout=0.05):
"""Move whatever the network has into the local buffer. Returns bytes moved."""
if self.peer_closed:
return 0
moved = 0
self.sock.settimeout(timeout)
try:
while True:
chunk = self.sock.recv(self.buffer_size)
if not chunk:
self.peer_closed = True
break
self.rx.extend(chunk)
self.bytes_in += len(chunk)
moved += len(chunk)
if len(chunk) < self.buffer_size:
break
except (socket.timeout, BlockingIOError):
pass
except OSError:
self.peer_closed = True
return moved
def send(self, data):
self.sock.sendall(data)
self.bytes_out += len(data)
def take(self, maxlen):
self.pump()
n = min(maxlen, len(self.rx), self.buffer_size)
out = bytes(self.rx[:n])
del self.rx[:n]
return out
def available(self):
self.pump()
return len(self.rx)
def send_capacity(self):
free = self.buffer_size - len(self.rx)
return 0xFF if free > 0xFF else max(0, free)
def close(self):
try:
self.sock.shutdown(socket.SHUT_RDWR)
except OSError:
pass
try:
self.sock.close()
except OSError:
pass
class BipTerminal:
"""Terminal (device) side of BIP: channels to the configured target."""
def __init__(self):
self.enabled = False
self.target = None
self.channels = {}
self.next_id = 1
self.entries = []
self.seq = 0
self.lock = threading.Lock()
def log(self, kind, **fields):
with self.lock:
self.seq += 1
entry = {'seq': self.seq, 't': time.time(), 'kind': kind}
entry.update(fields)
self.entries.append(entry)
if len(self.entries) > MAX_LOG:
del self.entries[:len(self.entries) - MAX_LOG]
return entry
def enable(self, host, port):
self.target = (host, int(port))
self.enabled = True
self.log('enabled', target='%s:%d' % self.target)
def disable(self):
self.enabled = False
self.log('disabled')
self.close_all()
self.target = None
def close_all(self):
for ch in list(self.channels.values()):
self._close_channel(ch)
def _close_channel(self, ch):
ch.close()
if self.channels.get(ch.id) is ch:
del self.channels[ch.id]
def _alloc_id(self):
for _ in range(7):
cid = self.next_id
self.next_id = 1 if cid >= 7 else cid + 1
if cid not in self.channels:
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:
return None, 'bip disabled'
target = self.target
requested = '%s:%s' % (requested_host, requested_port)
cid = self._alloc_id()
if cid is None:
self.log('open-fail', requested=requested, reason='no free channel')
return None, 'no free channel'
try:
sock = socket.create_connection(target, timeout=2.0)
except OSError as e:
self.log('open-fail', requested=requested, target='%s:%d' % target, reason=str(e))
return None, str(e)
ch = BipChannel(cid, sock, requested, target, buffer_size)
self.channels[cid] = ch
self.log('open', channel=cid, requested=requested, target='%s:%d' % target,
buffer_size=ch.buffer_size)
return cid, None
def send(self, channel_id, data):
ch = self.channels.get(channel_id)
if not ch:
return False
try:
ch.send(data)
except OSError as e:
self.log('send-fail', channel=channel_id, error=str(e))
self._close_channel(ch)
return False
self.log('send', channel=channel_id, bytes=len(data), hex=data.hex().upper()[:2000])
return True
def receive(self, channel_id, maxlen):
ch = self.channels.get(channel_id)
if not ch:
return None
data = ch.take(maxlen)
if data:
self.log('receive', channel=channel_id, bytes=len(data), remaining=len(ch.rx),
hex=data.hex().upper()[:2000])
return data
def available(self, channel_id):
ch = self.channels.get(channel_id)
return ch.available() if ch else 0
def send_capacity(self, channel_id):
ch = self.channels.get(channel_id)
return ch.send_capacity() if ch else 0
def clear_log(self):
with self.lock:
self.entries = []
def close(self, channel_id):
ch = self.channels.get(channel_id)
if not ch:
return False
self.log('close', channel=channel_id, bytes_in=ch.bytes_in, bytes_out=ch.bytes_out)
self._close_channel(ch)
return True
def status(self):
channels = []
for ch in self.channels.values():
channels.append({
'id': ch.id,
'requested': ch.requested,
'target': '%s:%d' % ch.target,
'buffer_size': ch.buffer_size,
'bytes_in': ch.bytes_in,
'bytes_out': ch.bytes_out,
'pending': len(ch.rx),
'peer_closed': ch.peer_closed,
})
return {
'enabled': self.enabled,
'target': '%s:%d' % self.target if self.target else None,
'channels': channels,
'seq': self.seq,
}
def entries_after(self, after=0):
with self.lock:
return [e for e in self.entries if e['seq'] > after]
class TcpDumpServer:
"""Plain TCP listener that logs whatever it receives (ClientHello capture).
Used as the BIP redirect target until the PSK TLS platform is brought up.
"""
def __init__(self, host, port, on_rx=None, on_log=None):
self.on_rx = on_rx
self.on_log = on_log
self.stopped = False
self.conns = []
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.sock.bind((host, int(port)))
self.sock.listen(4)
self.host, self.port = self.sock.getsockname()[:2]
self.thread = threading.Thread(target=self._accept_loop, daemon=True)
self.thread.start()
if self.on_log:
self.on_log('listener-start', host=self.host, port=self.port)
def _accept_loop(self):
while not self.stopped:
try:
self.sock.settimeout(0.2)
conn, addr = self.sock.accept()
except socket.timeout:
continue
except OSError:
break
self.conns.append(conn)
if self.on_log:
self.on_log('conn', peer='%s:%d' % addr[:2])
threading.Thread(target=self._conn_loop, args=(conn, addr), daemon=True).start()
def _conn_loop(self, conn, addr):
try:
while not self.stopped:
conn.settimeout(0.2)
try:
data = conn.recv(4096)
except socket.timeout:
continue
except OSError:
break
if not data:
break
if self.on_rx:
self.on_rx('%s:%d' % addr[:2], data)
finally:
try:
conn.close()
except OSError:
pass
def stop(self):
self.stopped = True
if self.on_log:
self.on_log('listener-stop', host=self.host, port=self.port)
try:
self.sock.close()
except OSError:
pass
for conn in self.conns:
try:
conn.close()
except OSError:
pass
self.conns = []