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 @@
@@ -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