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
+3 -2
View File
@@ -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.
+3 -2
View File
@@ -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.
+28 -10
View File
@@ -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` /
+2 -1
View File
@@ -323,7 +323,8 @@
<p class="mb-2">Кнопки <strong>Start</strong> / <strong>Остановить</strong> управляют целью; <strong>Перезапустить скрипт</strong> ставит выбранный скрипт в очередь заново с <code class="font-mono text-sm">force</code>, начиная прогон с первого APDU. Режимы:</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>PSK TLS сервер</strong> (по умолчанию) — PSK TLS-слушатель на <strong>Хост</strong>:<strong>Порт</strong>, отвечающий PSK-наборами TLS 1.2 из спецификации и ведущий HTTP-диалог административной сессии GP (заголовки <code class="font-mono text-sm">X-Admin-*</code>, <code class="font-mono text-sm">200</code> со строкой команд или <code class="font-mono text-sm">204 No Content</code>). PSK-ключи берутся из предустановок карт (вкладка <strong>&laquo;Карты&raquo;</strong>): ключ выбирается по идентификатору, который карта присылает в рукопожатии; если ни у одной предустановки нет пары, <strong>Start</strong> не запускается. Ключ не сохраняется и не записывается в журнал; незнакомый идентификатор помечается в журнале как <code class="font-mono text-sm">tls-psk-unknown</code>.</li>
<li><strong>Проброс (внешний сервер)</strong> — без локального слушателя: каждый BIP-канал подключается к указанной внешней платформе (нужны <strong>Хост</strong> и <strong>Порт</strong>), которая завершает TLS и ведёт административный диалог; запрошенный картой адрес только журналируется.</li>
<li><strong>Перенаправление на внешний сервер</strong> — без локального слушателя: каждый BIP-канал подключается к указанной цели (нужны <strong>Хост</strong> и <strong>Порт</strong>), которая завершает TLS и ведёт административный диалог; запрошенный картой адрес только журналируется.</li>
<li><strong>Проброс (адрес карты)</strong> — без слушателя и без цели: терминал подключает каждый BIP-канал к адресу, который карта запрашивает в OPEN CHANNEL (Other address + порт Transport level, только TCP-клиент, удалённое соединение; спецификации не задают порт по умолчанию, поэтому неполный запрос отклоняет канал). TLS завершается на той платформе, и используется сеть сервера (только для лаборатории).</li>
<li><strong>Захват (dump)</strong> — принимает TCP-канал карты и записывает всё, что она отправляет (например, TLS ClientHello), не отвечая.</li>
</ul>
<p class="mb-2"><strong>Сценарий</strong> выбирает список команд, отдаваемый в сессии — <strong>Нет</strong> (оставить настроенный на сервере скрипт) или один из скриптов подвкладки <strong>&laquo;Скрипты&raquo;</strong>; выбранный список передаётся серверу при старте слушателя.</p>
+2 -1
View File
@@ -323,7 +323,8 @@
<p class="mb-2"><strong>Start</strong> / <strong>Stop</strong> control the target; <strong>Restart script</strong> queues the selected script again with <code class="font-mono text-sm">force</code>, restarting its run from the first APDU. Modes:</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>PSK TLS server</strong> (default) — a PSK TLS listener on <strong>Host</strong>:<strong>Port</strong> that answers with the TLS 1.2 PSK cipher suites of the spec and speaks the GP HTTP administration dialog (<code class="font-mono text-sm">X-Admin-*</code> headers, <code class="font-mono text-sm">200</code> with a command string or <code class="font-mono text-sm">204 No Content</code>). PSK keys come from the card presets (<strong>Cards</strong> tab): the listener picks the key by the identity the card sends in the handshake, and <strong>Start</strong> is refused when no preset has both parts. The key is never stored or logged; <code class="font-mono text-sm">tls-psk-unknown</code> marks an unrecognised identity.</li>
<li><strong>Pass-through (external server)</strong> — no local listener: every BIP channel is connected to the configured external platform (<strong>Host</strong> and <strong>Port</strong> required), which terminates TLS and runs the administration dialog; the address the card requests is only logged.</li>
<li><strong>Redirect to external server</strong> — no local listener: every BIP channel is connected to the configured target (<strong>Host</strong> and <strong>Port</strong> required), which terminates TLS and runs the administration dialog; the address the card requests is only logged.</li>
<li><strong>Pass-through (card destination)</strong> — no listener and no target: the terminal connects each BIP channel to the destination the card requests in OPEN CHANNEL (Other address + Transport level port, TCP client remote only; the specs define no default port, so an incomplete request fails the channel). TLS is terminated by that platform, and the server's network is used (lab only).</li>
<li><strong>Capture (dump)</strong> — accepts the card's TCP channel and logs whatever it sends (e.g. the TLS ClientHello) without answering.</li>
</ul>
<p class="mb-2"><strong>Script</strong> selects the command list served over the session — <strong>None</strong> (leave the server's configured script) or one of the scripts from the <strong>Scripts</strong> pill; the chosen list is sent to the server when the listener starts.</p>
+32 -18
View File
@@ -18,7 +18,7 @@
<div class="max-w-7xl mx-auto px-6 py-2">
<div class="flex items-center justify-between mb-3">
<h1 class="text-2xl font-bold text-heading">OTAMan <span id="slogan" class="text-sm font-normal text-gray-500 dark:text-slate-400 ml-2" data-l10n="SIM OTA with a Human Face">SIM OTA with a Human Face</span> <span class="text-xs text-gray-400 dark:text-slate-500 ml-1">v2.2.12</span></h1>
<h1 class="text-2xl font-bold text-heading">OTAMan <span id="slogan" class="text-sm font-normal text-gray-500 dark:text-slate-400 ml-2" data-l10n="SIM OTA with a Human Face">SIM OTA with a Human Face</span> <span class="text-xs text-gray-400 dark:text-slate-500 ml-1">v2.2.13</span></h1>
<div class="flex items-center gap-4">
<span id="state-indicator" class="flex items-center select-none" style="cursor:default" title="Connecting...">
<span id="state-indicator-dot" class="text-xs text-gray-400" title="Connecting..."></span>
@@ -969,7 +969,8 @@
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="Mode">Mode</label>
<select id="scp81-mode" onchange="scp81ModeChanged()" class="border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800">
<option value="tls" data-l10n="PSK TLS server" selected>PSK TLS server</option>
<option value="passthru" data-l10n="Pass-through (external server)">Pass-through (external server)</option>
<option value="redirect" data-l10n="Redirect to external server">Redirect to external server</option>
<option value="passthru" data-l10n="Pass-through (card destination)">Pass-through (card destination)</option>
<option value="dump" data-l10n="Capture (dump)">Capture (dump)</option>
</select>
</div>
@@ -991,7 +992,8 @@
</div>
<div id="scp81-msg" class="text-xs mt-2 hidden"></div>
<div id="scp81-psk-note" class="mt-2 text-xs text-gray-500 dark:text-slate-400" data-l10n="PSK keys come from the card presets (Cards tab): the listener picks the key by the identity the card sends in the TLS handshake.">PSK keys come from the card presets (Cards tab): the listener picks the key by the identity the card sends in the TLS handshake.</div>
<div id="scp81-passthru-note" class="hidden mt-2 text-xs text-gray-500 dark:text-slate-400" data-l10n="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.">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.</div>
<div id="scp81-redirect-note" class="hidden mt-2 text-xs text-gray-500 dark:text-slate-400" data-l10n="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.">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.</div>
<div id="scp81-passthru-note" class="hidden mt-2 text-xs text-gray-500 dark:text-slate-400" data-l10n="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).">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).</div>
</div>
<div class="border border-gray-200 dark:border-slate-700 rounded p-3 mb-3">
<div class="flex justify-between items-center mb-2">
@@ -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',
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'otaman-v178';
const CACHE = 'otaman-v179';
const URLS = [
'index.html',
'help.html',
+11
View File
@@ -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')"));
});
+1 -1
View File
@@ -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.
+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'
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
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')
@@ -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()}
+49 -4
View File
@@ -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).
+25 -9
View File
@@ -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