ea1730b206
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
357 lines
11 KiB
Python
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 = []
|