From 404fdf5a1c074b24641bc67706ca07f6493f21ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD=20=D0=A2=D1=80=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Thu, 17 Sep 2026 08:17:28 +0300 Subject: [PATCH] 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). --- README.md | 5 +-- README_RUS.md | 5 +-- docs/api.md | 38 ++++++++++++++++------ frontend/help-ru.html | 3 +- frontend/help.html | 3 +- frontend/index.html | 50 +++++++++++++++++----------- frontend/sw.js | 2 +- frontend/tests/html.test.js | 11 +++++++ pyproject.toml | 2 +- pysim_otaman_server/httpota.py | 59 ++++++++++++++++++++++++++++------ pysim_otaman_server/server.py | 52 ++++++++++++++++++++---------- tests/test_httpota.py | 53 +++++++++++++++++++++++++++--- tests/test_scp81.py | 34 ++++++++++++++------ 13 files changed, 241 insertions(+), 76 deletions(-) diff --git a/README.md b/README.md index f8c634a..3726bd0 100644 --- a/README.md +++ b/README.md @@ -590,10 +590,11 @@ Values persist on the server until restart. Apply → hex updates; Save → POST The **SCP81** tab drives HTTP OTA (GP RAM over HTTP, GPC v2.2 Amendment B) and has two pills: **Listener** and **Scripts**. -The **Listener** starts/stops the target the card's BIP channel is redirected to, in one of three modes: +The **Listener** starts/stops the target the card's BIP channel is redirected to, in one of four modes: - **PSK TLS server** (default) — a PSK TLS listener on **Host:Port** that answers with the TLS 1.2 PSK cipher suites of the spec and speaks the GP HTTP administration dialog (`X-Admin-*` headers, `200` with a command string or `204 No Content`). PSK keys come from the card presets (**Cards** tab): the key is picked by the identity the card sends in the handshake, and Start is refused when no preset has both parts. Keys are never stored or logged; an unrecognised identity is logged as `tls-psk-unknown`. -- **Pass-through (external server)** — no local listener: every BIP channel is connected to the configured external platform (Host and Port required), which terminates TLS and runs the administration dialog; the address the card requests is only logged. +- **Redirect to external server** — no local listener: every BIP channel is connected to the configured target (Host and Port required), which terminates TLS and runs the administration dialog; the address the card requests is only logged. +- **Pass-through (card destination)** — no listener and no target: the terminal connects each BIP channel to the destination the card requests in OPEN CHANNEL (`Other address` + transport port, TCP client remote only; no spec default port, so an incomplete request fails the channel). TLS is terminated by that platform, so the server's network is used (lab only). - **Capture (dump)** — accepts the card's TCP channel and logs whatever it sends (e.g. the TLS ClientHello) without answering. **Script** selects the command list served over the session: **None** (leave the server's configured script) or one of the scripts created in the **Scripts** pill; **Restart script** re-queues the selected script with `force`, starting over from the first APDU. diff --git a/README_RUS.md b/README_RUS.md index 152a5e9..0d1232b 100644 --- a/README_RUS.md +++ b/README_RUS.md @@ -560,10 +560,11 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на Вкладка **SCP81** управляет HTTP OTA (GP RAM over HTTP, GPC v2.2 Amendment B) и содержит две подвкладки: **Listener** и **Scripts**. -**Listener** запускает и останавливает цель, на которую перенаправляется BIP-канал карты, в одном из трёх режимов: +**Listener** запускает и останавливает цель, на которую перенаправляется BIP-канал карты, в одном из четырёх режимов: - **PSK TLS server** (по умолчанию) — PSK TLS-слушатель на **Host:Port**, отвечающий PSK-наборами TLS 1.2 из спецификации и ведущий HTTP-диалог административной сессии GP (заголовки `X-Admin-*`, `200` со строкой команд или `204 No Content`). PSK-ключи берутся из пресетов карт (вкладка **Cards**): ключ выбирается по идентификатору, который карта присылает в рукопожатии; если ни у одного пресета нет пары, Start не запускается. Ключи не сохраняются и не записываются в журнал; незнакомый идентификатор помечается записью `tls-psk-unknown`. -- **Pass-through (external server)** — без локального слушателя: каждый BIP-канал подключается к указанной внешней платформе (нужны Host и Port), которая завершает TLS и ведёт административный диалог; запрошенный картой адрес только журналируется. +- **Перенаправление на внешний сервер** — без локального слушателя: каждый BIP-канал подключается к указанной цели (нужны Host и Port), которая завершает TLS и ведёт административный диалог; запрошенный картой адрес только журналируется. +- **Проброс (адрес карты)** — без слушателя и без цели: терминал подключает каждый BIP-канал к адресу, который карта запрашивает в OPEN CHANNEL (`Other address` + порт transport level, только TCP-клиент, удалённое соединение; порт по умолчанию спецификациями не задан, поэтому неполный запрос отклоняет канал). TLS завершается на той платформе, и используется сеть сервера (только для лаборатории). - **Capture (dump)** — принимает TCP-канал карты и записывает всё, что она отправляет (например, TLS ClientHello), не отвечая. **Script** выбирает список команд, отдаваемый в сессии: **None** (оставить настроенный на сервере скрипт) или один из скриптов, созданных на подвкладке **Scripts**; **Restart script** заново ставит выбранный скрипт в очередь с `force`, начиная с первого APDU. diff --git a/docs/api.md b/docs/api.md index 951724f..c95fadd 100644 --- a/docs/api.md +++ b/docs/api.md @@ -507,14 +507,29 @@ without answering: {"action": "start", "mode": "dump", "host": "127.0.0.1", "port": 8443} ``` -Pass-through mode (`mode: "passthru"`) starts **no local listener**: every BIP -channel the card opens is connected to the configured external platform -(`host`/`port` are required — no defaults), which terminates TLS and runs the -administration dialog; the address the card requests is only logged. The -status API reports `mode: "passthru"` with the target while it runs. +Redirect mode (`mode: "redirect"`) starts **no local listener**: every BIP +channel the card opens is connected to the configured target (`host`/`port` +are required — no defaults), which terminates TLS and runs the administration +dialog; the address the card requests is only logged. The status API reports +`mode: "redirect"` with the target while it runs. (This is the behavior that +was called `passthru` before 2.2.13 — the name is now taken by the mode +below.) ```json -{"action": "start", "mode": "passthru", "host": "203.0.113.10", "port": 10174} +{"action": "start", "mode": "redirect", "host": "203.0.113.10", "port": 10174} +``` + +Pass-through mode (`mode: "passthru"`) starts **no listener and has no +target**: every BIP channel dials the destination the card requests in OPEN +CHANNEL — the `Other address` (`3E`/`BE`) plus the `Transport level` +(`3C`/`BC`) port, TCP client remote (`02`) only. The specs define no default +port, so an incomplete or non-TCP request fails the channel with result `3A` +and an `open-fail` log reason; `host`/`port` in the request are ignored. The +status API reports `{"mode": "passthru"}` and the per-channel targets appear +in `bip.channels`. + +```json +{"action": "start", "mode": "passthru"} ``` TLS mode runs the Phase B PSK TLS server (GPC v2.2 Amendment B): the PSK @@ -545,16 +560,19 @@ Stop either mode with `{"action": "stop"}` (also disables the BIP terminal). ### `GET /api/scp81/status` ```json -{"bip": {"enabled": true, "target": "127.0.0.1:8443", "channels": [], "seq": 12}, +{"bip": {"enabled": true, "mode": "redirect", "target": "127.0.0.1:8443", "channels": [], "seq": 12}, "listener": {"mode": "tls", "host": "127.0.0.1", "port": 8443, "psk_identities": ["89012345678901234567"], "psk_wildcard": false, "identity_seen": "89012345678901234567", "identity_matched": true}} ``` Listener modes: `tls` (local PSK TLS server), `dump` (capture-only TCP -listener) and `passthru` (no local listener; the BIP channels go straight to -`host:port`, e.g. an external HTTP OTA platform — reported as -`{"mode": "passthru", "host": ..., "port": ..., "target": "host:port"}`). +listener), `redirect` (no local listener; the BIP channels go straight to the +configured `host:port`, e.g. an external HTTP OTA platform — reported as +`{"mode": "redirect", "host": ..., "port": ..., "target": "host:port"}`) and +`passthru` (no listener and no target; each channel dials the destination the +card requests in OPEN CHANNEL — reported as `{"mode": "passthru"}`, with the +actual peer in `bip.channels[].target`). `psk_identities` lists the identities the listener accepts (keys are never exposed); `psk_wildcard` marks the legacy single-key mode. `identity_seen` / diff --git a/frontend/help-ru.html b/frontend/help-ru.html index cc60650..2743e09 100644 --- a/frontend/help-ru.html +++ b/frontend/help-ru.html @@ -323,7 +323,8 @@

Кнопки Start / Остановить управляют целью; Перезапустить скрипт ставит выбранный скрипт в очередь заново с force, начиная прогон с первого APDU. Режимы:

Сценарий выбирает список команд, отдаваемый в сессии — Нет (оставить настроенный на сервере скрипт) или один из скриптов подвкладки «Скрипты»; выбранный список передаётся серверу при старте слушателя.

diff --git a/frontend/help.html b/frontend/help.html index 73f8a96..9646025 100644 --- a/frontend/help.html +++ b/frontend/help.html @@ -323,7 +323,8 @@

Start / Stop control the target; Restart script queues the selected script again with force, restarting its run from the first APDU. Modes:

Script selects the command list served over the session — None (leave the server's configured script) or one of the scripts from the Scripts pill; the chosen list is sent to the server when the listener starts.

diff --git a/frontend/index.html b/frontend/index.html index abece5e..ec6c7b1 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -18,7 +18,7 @@
-

OTAMan SIM OTA with a Human Face v2.2.12

+

OTAMan SIM OTA with a Human Face v2.2.13

@@ -969,7 +969,8 @@
@@ -991,7 +992,8 @@
PSK keys come from the card presets (Cards tab): the listener picks the key by the identity the card sends in the TLS handshake.
- + +
@@ -7764,7 +7766,7 @@ async function pysimProactiveLogRender() { } } -// ===== SCP81: HTTP OTA listener (dump capture / PSK TLS server) ===== +// ===== SCP81: HTTP OTA listener (PSK TLS / redirect / passthru / capture) ===== let _scp81Timer = null; let _scp81LastStatus = null; @@ -7774,8 +7776,18 @@ function scp81ModeChanged() { if (row) row.style.display = (mode === 'tls') ? '' : 'none'; const pskNote = document.getElementById('scp81-psk-note'); if (pskNote) pskNote.classList.toggle('hidden', mode !== 'tls'); - const directNote = document.getElementById('scp81-passthru-note'); - if (directNote) directNote.classList.toggle('hidden', mode !== 'passthru'); + const redirectNote = document.getElementById('scp81-redirect-note'); + if (redirectNote) redirectNote.classList.toggle('hidden', mode !== 'redirect'); + const passthruNote = document.getElementById('scp81-passthru-note'); + if (passthruNote) passthruNote.classList.toggle('hidden', mode !== 'passthru'); + // passthru dials the destination from the card's OPEN CHANNEL: the + // configured target fields do not apply. + for (const id of ['scp81-host', 'scp81-port']) { + const el = document.getElementById(id); + if (!el) continue; + el.disabled = (mode === 'passthru'); + el.classList.toggle('opacity-40', mode === 'passthru'); + } } function scp81SwitchSubtab(name) { @@ -8109,7 +8121,7 @@ async function scp81StatusRefresh() { el.textContent = t('not running'); return st; } - let s = l.mode + ' ' + l.host + ':' + l.port; + let s = l.mode + (l.host && l.port ? ' ' + l.host + ':' + l.port : ''); if (l.mode === 'tls') { const ids = l.psk_identities || []; s += ' | PSK: ' + ids.length; @@ -8121,7 +8133,7 @@ async function scp81StatusRefresh() { : t('unknown identity')) + ']'; } } - const ch = (bip.channels || []).map(c => 'ch' + c.id + ' in:' + c.bytes_in + ' out:' + c.bytes_out).join(', '); + const ch = (bip.channels || []).map(c => 'ch' + c.id + (c.target ? ' → ' + c.target : '') + ' in:' + c.bytes_in + ' out:' + c.bytes_out).join(', '); if (ch) s += ' | ' + ch; el.textContent = s; return st; @@ -8152,15 +8164,15 @@ async function scp81Start() { const mode = document.getElementById('scp81-mode').value; const hostVal = document.getElementById('scp81-host').value.trim(); const portVal = document.getElementById('scp81-port').value.trim(); - if (mode === 'passthru' && (!hostVal || !portVal)) { - scp81Msg(t('Pass-through requires the target host and port'), 'text-red-500'); + if (mode === 'redirect' && (!hostVal || !portVal)) { + scp81Msg(t('Redirect requires the target host and port'), 'text-red-500'); return; } - const body = { - action: 'start', mode: mode, - host: hostVal || '127.0.0.1', - port: parseInt(portVal || '8443', 10), - }; + const body = { action: 'start', mode: mode }; + if (mode !== 'passthru') { + body.host = hostVal || '127.0.0.1'; + body.port = parseInt(portVal || '8443', 10); + } if (mode === 'tls') { const map = cardsPskMap(); if (!map.length) { @@ -11433,9 +11445,11 @@ const LANG_RU = { 'LOAD blocks': 'Блоки LOAD', 'clamped from': 'ограничено с', 'auto-fit': 'авто', - 'Pass-through (external server)': 'Проброс (внешний сервер)', - 'Pass-through: every BIP channel the card opens is connected to this Host:Port (the external HTTP OTA platform); TLS is terminated there, the card\'s requested address is only logged.': 'Проброс: каждый открываемый картой BIP-канал подключается к этому Host:Port (внешняя платформа HTTP OTA); TLS завершается там, запрошенный картой адрес только журналируется.', - 'Pass-through requires the target host and port': 'Для режима проброса нужны host и port внешнего сервера', + 'Redirect to external server': 'Перенаправление на внешний сервер', + 'Pass-through (card destination)': 'Проброс (адрес карты)', + 'Redirect: every BIP channel the card opens is connected to this Host:Port (the external HTTP OTA platform); TLS is terminated there, and the address the card requests is only logged.': 'Перенаправление: каждый открываемый картой BIP-канал подключается к этому Host:Port (внешняя платформа HTTP OTA); TLS завершается там, а запрошенный картой адрес только журналируется.', + 'Pass-through: the terminal connects each BIP channel to the destination the card requests in OPEN CHANNEL (Other address + transport port, TCP client only); Host and Port above are not used, TLS is terminated by that platform, and the server\'s network is used (lab only).': 'Проброс: терминал подключает каждый BIP-канал к адресу, который карта запрашивает в OPEN CHANNEL (Other address + порт Transport level, только TCP-клиент); поля Host и Port выше не используются, TLS завершается на той платформе, и используется сеть сервера (только для лаборатории).', + 'Redirect requires the target host and port': 'Для перенаправления нужны host и port внешнего сервера', 'ADM verified': 'ADM подтверждён', 'ADM not verified': 'ADM не подтверждён', 'TERMINAL PROFILE': 'TERMINAL PROFILE', diff --git a/frontend/sw.js b/frontend/sw.js index 9256061..1c93ae5 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'otaman-v178'; +const CACHE = 'otaman-v179'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/html.test.js b/frontend/tests/html.test.js index 80fe4c1..62ba639 100644 --- a/frontend/tests/html.test.js +++ b/frontend/tests/html.test.js @@ -168,3 +168,14 @@ test('every help anchor used by the UI exists in help.html', () => { assert.ok(html.includes("'scp81-listener' : 'scp81-scripts'")); assert.ok(html.includes("? 'scp81-scripts' : 'scp81-listener'")); }); + +test('SCP81 listener exposes its four modes with the matching notes', () => { + for (const v of ['tls', 'redirect', 'passthru', 'dump']) { + assert.ok(html.includes('value="' + v + '"'), v); + } + assert.ok(html.includes('id="scp81-redirect-note"')); + assert.ok(html.includes('id="scp81-passthru-note"')); + // redirect needs the configured target; passthru uses the card's one + assert.ok(html.includes("mode === 'redirect' && (!hostVal || !portVal)")); + assert.ok(html.includes("el.disabled = (mode === 'passthru')")); +}); diff --git a/pyproject.toml b/pyproject.toml index f985e1e..6794c51 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pysim-otaman-server" -version = "2.2.12" +version = "2.2.13" description = "HTTP REST server wrapping pysim for the OTAMan PWA" requires-python = ">=3.8" # pysim is a git-only dependency installed explicitly by setup.bat/setup.sh. diff --git a/pysim_otaman_server/httpota.py b/pysim_otaman_server/httpota.py index b62d24b..5b800d6 100644 --- a/pysim_otaman_server/httpota.py +++ b/pysim_otaman_server/httpota.py @@ -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, diff --git a/pysim_otaman_server/server.py b/pysim_otaman_server/server.py index 004198e..44e1d95 100644 --- a/pysim_otaman_server/server.py +++ b/pysim_otaman_server/server.py @@ -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()} diff --git a/tests/test_httpota.py b/tests/test_httpota.py index 07d723f..ad726e5 100644 --- a/tests/test_httpota.py +++ b/tests/test_httpota.py @@ -152,17 +152,17 @@ class BipTerminalTest(unittest.TestCase): self.assertIn('close', kinds) peer.stop() - def test_passthru_mode_roundtrip_via_bip_control(self): - # SCP81 passthru: the control API enables BIP with the external + def test_redirect_mode_roundtrip_via_bip_control(self): + # SCP81 redirect: the control API enables BIP with the external # platform as the target and starts no local listener; the card's # channel talks straight to that platform. peer = PeerServer(greeting=b'PLATFORM') peer.start() try: - resp = server._scp81_bip_control({'action': 'start', 'mode': 'passthru', + resp = server._scp81_bip_control({'action': 'start', 'mode': 'redirect', 'host': '127.0.0.1', 'port': peer.port}) self.assertTrue(resp['ok'], resp) - self.assertEqual(resp['listener']['mode'], 'passthru') + self.assertEqual(resp['listener']['mode'], 'redirect') self.assertEqual(server._BIP.target, ('127.0.0.1', peer.port)) cid, err = server._BIP.open('10.9.9.9', 10174, 512) self.assertIsNone(err) @@ -184,6 +184,51 @@ class BipTerminalTest(unittest.TestCase): self.assertIsNone(cid) self.assertIn('disabled', err) + def test_passthru_dials_the_requested_destination(self): + # passthru has no pinned target: the socket goes to the destination + # the card requested in OPEN CHANNEL (TCP client, remote). + peer = PeerServer(greeting=b'PLATFORM') + peer.start() + try: + bip = httpota.BipTerminal() + bip.enable(mode='passthru') + self.assertIsNone(bip.target) + self.assertEqual(bip.status()['mode'], 'passthru') + cid, err = bip.open('127.0.0.1', peer.port, 512, proto=0x02) + self.assertIsNone(err) + self.assertEqual(bip.channels[cid].target, ('127.0.0.1', peer.port)) + self.assertTrue(bip.send(cid, b'CARDHELLO')) + data = b'' + for _ in range(20): + data = bip.receive(cid, 100) + if data: + break + time.sleep(0.05) + self.assertEqual(data, b'PLATFORM') + self.assertTrue(bip.close(cid)) + finally: + peer.stop() + + def test_passthru_rejects_incomplete_or_non_tcp_requests(self): + # The specs define no default port (TS 102 223 8.59): anything but a + # complete TCP-client remote request fails the channel (result 3A + # upstream) with a visible log reason. + srv = socket.socket() + srv.bind(('127.0.0.1', 0)) + dead_port = srv.getsockname()[1] + srv.close() + bip = httpota.BipTerminal() + bip.enable(mode='passthru') + self.assertIsNone(bip.open('-', 0, 512, proto=0x02)[0]) # no address + self.assertIsNone(bip.open('127.0.0.1', 0, 512, proto=0x02)[0]) # no port + self.assertIsNone(bip.open('127.0.0.1', 1234, 512, proto=0x03)[0]) # TCP server mode + self.assertIsNone(bip.open('127.0.0.1', dead_port, 512, proto=0x02)[0]) # refused + reasons = [e.get('reason', '') for e in bip.entries_after(0) + if e['kind'] == 'open-fail'] + self.assertTrue(any('TCP client' in r for r in reasons), reasons) + self.assertTrue(any('valid port' in r for r in reasons), reasons) + self.assertTrue(any('address' in r for r in reasons), reasons) + def test_peer_close_queues_channel_status_event(self): # TS 102 223 7.5.11: a link lost outside a proactive command must be # reported to the UICC (channel id, link not established, info 05). diff --git a/tests/test_scp81.py b/tests/test_scp81.py index 046b838..cee311a 100644 --- a/tests/test_scp81.py +++ b/tests/test_scp81.py @@ -682,38 +682,54 @@ class BipControlTest(unittest.TestCase): self.assertFalse(resp['ok']) self.assertIn('unsupported mode', resp['error']) - def test_passthru_mode_targets_the_external_server(self): - resp = server._scp81_bip_control({'action': 'start', 'mode': 'passthru', + def test_redirect_mode_targets_the_external_server(self): + resp = server._scp81_bip_control({'action': 'start', 'mode': 'redirect', 'host': '10.11.12.13', 'port': 10174}) self.assertTrue(resp['ok'], resp) self.assertIsNone(server._SCP81_LISTENER) # no local listener - self.assertEqual(resp['listener']['mode'], 'passthru') + self.assertEqual(resp['listener']['mode'], 'redirect') self.assertEqual(resp['listener']['host'], '10.11.12.13') self.assertEqual(resp['listener']['port'], 10174) self.assertEqual(resp['listener']['target'], '10.11.12.13:10174') self.assertTrue(resp['bip']['enabled']) self.assertEqual(server._BIP.target, ('10.11.12.13', 10174)) - # the status endpoint sees the passthru mode while it runs ... - self.assertEqual(server._scp81_listener_status()['mode'], 'passthru') + # the status endpoint sees the redirect mode while it runs ... + self.assertEqual(server._scp81_listener_status()['mode'], 'redirect') # ... and stopping clears it (no stale listener in the status) server._scp81_bip_control({'action': 'stop'}) self.assertIsNone(server._scp81_listener_status()) self.assertFalse(server._BIP.enabled) - def test_passthru_mode_requires_an_explicit_target(self): + def test_redirect_mode_requires_an_explicit_target(self): # No defaults for a remote platform: the target must be configured. - resp = server._scp81_bip_control({'action': 'start', 'mode': 'passthru'}) + resp = server._scp81_bip_control({'action': 'start', 'mode': 'redirect'}) self.assertFalse(resp['ok']) self.assertIn('host and port', resp['error']) - resp = server._scp81_bip_control({'action': 'start', 'mode': 'passthru', + resp = server._scp81_bip_control({'action': 'start', 'mode': 'redirect', 'host': '10.0.0.1'}) self.assertFalse(resp['ok']) - resp = server._scp81_bip_control({'action': 'start', 'mode': 'passthru', + resp = server._scp81_bip_control({'action': 'start', 'mode': 'redirect', 'port': 1234}) self.assertFalse(resp['ok']) self.assertIsNone(server._SCP81_LISTENER) self.assertFalse(server._BIP.enabled) + def test_passthru_mode_needs_no_target(self): + # Passthru: no listener and no pinned target - every BIP channel dials + # the destination the card requests in OPEN CHANNEL. + resp = server._scp81_bip_control({'action': 'start', 'mode': 'passthru'}) + self.assertTrue(resp['ok'], resp) + self.assertIsNone(server._SCP81_LISTENER) + self.assertEqual(resp['listener'], {'mode': 'passthru'}) + self.assertEqual(server._BIP.mode, 'passthru') + self.assertIsNone(server._BIP.target) + self.assertTrue(resp['bip']['enabled']) + self.assertEqual(resp['bip']['mode'], 'passthru') + self.assertEqual(server._scp81_listener_status(), {'mode': 'passthru'}) + server._scp81_bip_control({'action': 'stop'}) + self.assertIsNone(server._scp81_listener_status()) + self.assertFalse(server._BIP.enabled) + def test_start_accepts_explicit_script_list(self): server._SCP81_PSKS = {} server._SCP81_PSK_LEGACY = None