Script results (R-APDUs)
@@ -7810,58 +7815,102 @@ let _scp81LastStatus = null;
// ===== SCP81 listener options (HTTP framing / script framing / link events) =====
// The API takes these at listener start; they are kept in localStorage so a
-// test setup survives reloads. TLS itself has no settings: the listener
-// accepts everything it can and reports the negotiated version/cipher.
+// test setup survives reloads. The block is collapsed by default and shows a
+// 'custom' marker when anything differs from the reference defaults. TLS
+// itself has no settings: the listener accepts everything it can and reports
+// the negotiated version/cipher.
const SCP81_OPT_DEFAULTS = {
- chunked: true, chunkSize: 0, keepAlive: true, connHeader: 'none',
- compact: false, nextUri: '/api/scp81?req=%d', linkEvents: true,
- scriptTemplate: 'indefinite', crTag: false, targetedApp: ''
+ chunked: true, chunkSize: 0, connHeader: 'none', compact: false,
+ nextUriOn: true, nextUri: '/api/scp81?req=%d', linkEvents: true,
+ scriptTemplate: 'indefinite', crTag: false,
+ targetedAppOn: false, targetedApp: ''
};
+function scp81OptionsState() {
+ const val = id => { const el = document.getElementById(id); return el ? el.value : ''; };
+ const on = id => { const el = document.getElementById(id); return !!(el && el.checked); };
+ return {
+ chunked: on('opt-chunked'),
+ chunkSize: Math.max(0, parseInt(val('opt-chunk-size'), 10) || 0),
+ connHeader: val('opt-conn-header') || 'none',
+ compact: on('opt-compact'),
+ nextUriOn: on('opt-next-uri'),
+ nextUri: val('opt-next-uri-value').trim(),
+ linkEvents: on('opt-link-events'),
+ scriptTemplate: val('opt-script-template') === 'definite' ? 'definite' : 'indefinite',
+ crTag: on('opt-cr-tag'),
+ targetedAppOn: on('opt-targeted-app-on'),
+ targetedApp: val('opt-targeted-app').trim()
+ };
+}
+
+function scp81OptionsFromForm() {
+ const s = scp81OptionsState();
+ return {
+ chunked: s.chunked,
+ chunk_size: s.chunkSize,
+ conn_header: s.connHeader,
+ compact_headers: s.compact,
+ next_uri: s.nextUriOn ? (s.nextUri || SCP81_OPT_DEFAULTS.nextUri) : '',
+ link_events: s.linkEvents,
+ script_template: s.scriptTemplate,
+ cr_tag: s.crTag,
+ targeted_app: s.targetedAppOn ? s.targetedApp : ''
+ };
+}
+
+function scp81OptionsSyncDisabled() {
+ const on = id => { const el = document.getElementById(id); return !!(el && el.checked); };
+ const sync = (id, enabled) => {
+ const el = document.getElementById(id);
+ if (!el) return;
+ el.disabled = !enabled;
+ el.classList.toggle('opacity-40', !enabled);
+ };
+ sync('opt-next-uri-value', on('opt-next-uri'));
+ sync('opt-targeted-app', on('opt-targeted-app-on'));
+}
+
+function scp81OptionsBadge() {
+ const el = document.getElementById('scp81-opts-badge');
+ if (!el) return;
+ const s = scp81OptionsState();
+ const d = SCP81_OPT_DEFAULTS;
+ const same = s.chunked === d.chunked && s.chunkSize === d.chunkSize &&
+ s.connHeader === d.connHeader && s.compact === d.compact &&
+ s.nextUriOn === d.nextUriOn && (s.nextUri || d.nextUri) === d.nextUri &&
+ s.linkEvents === d.linkEvents && s.scriptTemplate === d.scriptTemplate &&
+ s.crTag === d.crTag && s.targetedAppOn === d.targetedAppOn &&
+ s.targetedApp === d.targetedApp;
+ el.textContent = same ? '' : t('custom');
+}
+
function scp81OptionsLoad() {
let saved = {};
try { saved = JSON.parse(localStorage.getItem('otaman_scp81_opts') || '{}') || {}; } catch (e) { saved = {}; }
const opt = Object.assign({}, SCP81_OPT_DEFAULTS, saved);
+ if (saved.nextUriOn === undefined) opt.nextUriOn = (opt.nextUri || '') !== '';
const set = (id, v) => { const el = document.getElementById(id); if (el) el.value = v; };
const chk = (id, v) => { const el = document.getElementById(id); if (el) el.checked = !!v; };
chk('opt-chunked', opt.chunked);
set('opt-chunk-size', opt.chunkSize);
- chk('opt-keep-alive', opt.keepAlive);
- set('opt-conn-header', opt.connHeader);
+ set('opt-conn-header', opt.connHeader === 'keep-alive' ? 'keep-alive' : 'none');
chk('opt-compact', opt.compact);
- chk('opt-next-uri', opt.nextUri !== '');
+ chk('opt-next-uri', opt.nextUriOn);
set('opt-next-uri-value', opt.nextUri || SCP81_OPT_DEFAULTS.nextUri);
chk('opt-link-events', opt.linkEvents);
set('opt-script-template', opt.scriptTemplate === 'definite' ? 'definite' : 'indefinite');
chk('opt-cr-tag', opt.crTag);
+ chk('opt-targeted-app-on', opt.targetedAppOn);
set('opt-targeted-app', opt.targetedApp);
-}
-
-function scp81OptionsFromForm() {
- const val = id => { const el = document.getElementById(id); return el ? el.value : ''; };
- const on = id => { const el = document.getElementById(id); return !!(el && el.checked); };
- return {
- chunked: on('opt-chunked'),
- chunk_size: Math.max(0, parseInt(val('opt-chunk-size'), 10) || 0),
- keep_alive: on('opt-keep-alive'),
- conn_header: val('opt-conn-header') || 'none',
- compact_headers: on('opt-compact'),
- next_uri: on('opt-next-uri') ? (val('opt-next-uri-value').trim() || SCP81_OPT_DEFAULTS.nextUri) : '',
- link_events: on('opt-link-events'),
- script_template: val('opt-script-template') === 'definite' ? 'definite' : 'indefinite',
- cr_tag: on('opt-cr-tag'),
- targeted_app: val('opt-targeted-app').trim()
- };
+ scp81OptionsSyncDisabled();
+ scp81OptionsBadge();
}
function scp81OptionsPersist() {
- const o = scp81OptionsFromForm();
- localStorage.setItem('otaman_scp81_opts', JSON.stringify({
- chunked: o.chunked, chunkSize: o.chunk_size, keepAlive: o.keep_alive,
- connHeader: o.conn_header, compact: o.compact_headers, nextUri: o.next_uri,
- linkEvents: o.link_events, scriptTemplate: o.script_template,
- crTag: o.cr_tag, targetedApp: o.targeted_app
- }));
+ localStorage.setItem('otaman_scp81_opts', JSON.stringify(scp81OptionsState()));
+ scp81OptionsSyncDisabled();
+ scp81OptionsBadge();
}
function scp81OptionsReset() {
@@ -7869,6 +7918,16 @@ function scp81OptionsReset() {
scp81OptionsLoad();
}
+function scp81NextUriToggle() {
+ scp81OptionsSyncDisabled();
+ scp81OptionsPersist();
+}
+
+function scp81TargetedAppToggle() {
+ scp81OptionsSyncDisabled();
+ scp81OptionsPersist();
+}
+
function scp81ModeChanged() {
const mode = document.getElementById('scp81-mode').value;
const row = document.getElementById('scp81-script-row');
@@ -8242,7 +8301,6 @@ async function scp81StatusRefresh() {
s += ' | ' + (l.chunked
? ('chunked' + (l.chunk_size ? ' ' + l.chunk_size : ''))
: 'content-length');
- if (l.keep_alive) s += ' | keep-alive';
}
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;
@@ -8289,7 +8347,6 @@ async function scp81Start() {
if (mode === 'tls') {
body.chunked = opts.chunked;
body.chunk_size = opts.chunk_size;
- body.keep_alive = opts.keep_alive;
body.conn_header = opts.conn_header;
body.compact_headers = opts.compact_headers;
body.next_uri = opts.next_uri;
@@ -11579,18 +11636,20 @@ const LANG_RU = {
'Options (applied at Start)': 'Настройки (применяются при запуске)',
'Reset to defaults': 'Сбросить к умолчаниям',
'HTTP framing': 'HTTP-фрейминг',
- 'Chunked body (Transfer-Encoding: chunked)': 'Chunked-тело (Transfer-Encoding: chunked)',
+ 'Chunked body (Transfer-Encoding: chunked)': 'Chunked body (Transfer-Encoding: chunked)',
'Chunk size (bytes, 0 = one TLS record)': 'Размер чанка (байт, 0 = одна TLS-запись)',
- 'Keep-alive (one connection until the session ends)': 'Keep-alive (одно соединение до конца сессии)',
'Connection header': 'Заголовок Connection',
'omit (implicit keep-alive)': 'не отправлять (неявный keep-alive)',
- "Compact headers (no space after ':')": 'Компактные заголовки (без пробела после «:»)',
- 'Next-URI (unchecked = omit the header)': 'Next-URI (снято — заголовок не отправляется)',
+ "Compact headers (omit the optional space after ':')": 'Компактные заголовки (без необязательного пробела после «:»)',
+ 'Send X-Admin-Next-URI (required to continue the session)': 'Отправлять X-Admin-Next-URI (нужен для продолжения сессии)',
+ 'Unchecked = one-shot: per GP Am. B §4.4.2 the card executes the command, returns no response string and closes the session.': 'Снято — одноразовый режим: по GP Am. B §4.4.2 карта выполнит команду, не вернёт строку ответа и закроет сессию.',
+ 'Send X-Admin-Targeted-Application': 'Отправлять X-Admin-Targeted-Application',
+ 'custom': 'изменены',
'Script framing': 'Фрейминг скрипта',
'Indefinite (AE 80 … 00 00)': 'Неопределённая длина (AE 80 … 00 00)',
'Definite (AA)': 'Определённая длина (AA)',
- 'Comprehension-required tags': 'Теги с обязательным пониманием (CR)',
- 'Link events (Channel status ENVELOPEs, TS 102 223 7.5.11)': 'События канала (ENVELOPE Channel status, TS 102 223 7.5.11)',
+ 'Comprehension-required tags': 'Теги comprehension-required',
+ 'Show link events (Channel status ENVELOPEs, TS 102 223 7.5.11)': 'Показывать события канала (ENVELOPE Channel status, TS 102 223 7.5.11)',
'ADM verified': 'ADM подтверждён',
'ADM not verified': 'ADM не подтверждён',
'TERMINAL PROFILE': 'TERMINAL PROFILE',
diff --git a/frontend/sw.js b/frontend/sw.js
index 4fb9e9a..61e1bc1 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -1,4 +1,4 @@
-const CACHE = 'otaman-v180';
+const CACHE = 'otaman-v181';
const URLS = [
'index.html',
'help.html',
diff --git a/frontend/tests/scp81_options.test.js b/frontend/tests/scp81_options.test.js
index a6e330b..93b732a 100644
--- a/frontend/tests/scp81_options.test.js
+++ b/frontend/tests/scp81_options.test.js
@@ -22,16 +22,18 @@ function extractFunc(src, name) {
}
let code = html.match(/const SCP81_OPT_DEFAULTS = \{[\s\S]*?\n\};/)[0].replace('const ', 'var ') + '\n';
-for (const fn of ['scp81OptionsLoad', 'scp81OptionsFromForm', 'scp81OptionsPersist', 'scp81OptionsReset']) {
+for (const fn of ['scp81OptionsState', 'scp81OptionsFromForm', 'scp81OptionsSyncDisabled',
+ 'scp81OptionsBadge', 'scp81OptionsLoad', 'scp81OptionsPersist', 'scp81OptionsReset',
+ 'scp81NextUriToggle', 'scp81TargetedAppToggle']) {
code += extractFunc(html, fn) + '\n';
}
+code += 'globalThis.t = s => s;\n';
eval(code);
function setup(opts) {
const spec = Object.assign({
'opt-chunked': true,
'opt-chunk-size': 0,
- 'opt-keep-alive': true,
'opt-conn-header': 'none',
'opt-compact': false,
'opt-next-uri': true,
@@ -39,11 +41,20 @@ function setup(opts) {
'opt-link-events': true,
'opt-script-template': 'indefinite',
'opt-cr-tag': false,
- 'opt-targeted-app': ''
+ 'opt-targeted-app-on': false,
+ 'opt-targeted-app': '',
+ 'scp81-opts-badge': ''
}, opts || {});
const els = {};
for (const [id, v] of Object.entries(spec)) {
- els[id] = typeof v === 'boolean' ? { checked: v, value: '' } : { checked: true, value: String(v) };
+ const checkbox = typeof v === 'boolean';
+ els[id] = {
+ checked: checkbox ? v : true,
+ value: checkbox ? '' : String(v),
+ disabled: false,
+ textContent: '',
+ classList: { toggle() {} },
+ };
}
globalThis.document = { getElementById: id => els[id] || null };
return els;
@@ -65,7 +76,6 @@ test('scp81OptionsFromForm maps the reference defaults', () => {
assert.deepStrictEqual(scp81OptionsFromForm(), {
chunked: true,
chunk_size: 0,
- keep_alive: true,
conn_header: 'none',
compact_headers: false,
next_uri: '/api/scp81?req=%d',
@@ -80,20 +90,19 @@ test('scp81OptionsFromForm reflects a changed setup', () => {
setup({
'opt-chunked': false,
'opt-chunk-size': '100',
- 'opt-keep-alive': false,
- 'opt-conn-header': 'close',
+ 'opt-conn-header': 'keep-alive',
'opt-compact': true,
'opt-next-uri-value': '/adminserver?apdu_id=%d',
'opt-link-events': false,
'opt-script-template': 'definite',
'opt-cr-tag': true,
+ 'opt-targeted-app-on': true,
'opt-targeted-app': ' //aid/A000000151000000 '
});
assert.deepStrictEqual(scp81OptionsFromForm(), {
chunked: false,
chunk_size: 100,
- keep_alive: false,
- conn_header: 'close',
+ conn_header: 'keep-alive',
compact_headers: true,
next_uri: '/adminserver?apdu_id=%d',
link_events: false,
@@ -110,51 +119,95 @@ test('unchecked Next-URI omits the header; empty text falls back to the template
assert.strictEqual(scp81OptionsFromForm().next_uri, '/api/scp81?req=%d');
});
+test('X-Admin-Targeted-Application is only sent when its checkbox is ticked', () => {
+ // text left in the box with the checkbox off must never leak out
+ setup({ 'opt-targeted-app-on': false, 'opt-targeted-app': '//aid/A000000151000000' });
+ assert.strictEqual(scp81OptionsFromForm().targeted_app, '');
+ setup({ 'opt-targeted-app-on': true, 'opt-targeted-app': ' //aid/A000000151000000 ' });
+ assert.strictEqual(scp81OptionsFromForm().targeted_app, '//aid/A000000151000000');
+});
+
+test('the dependent fields are disabled while their checkbox is off', () => {
+ const els = setup({ 'opt-next-uri': false, 'opt-targeted-app-on': false });
+ scp81OptionsSyncDisabled();
+ assert.strictEqual(els['opt-next-uri-value'].disabled, true);
+ assert.strictEqual(els['opt-targeted-app'].disabled, true);
+ els['opt-next-uri'].checked = true;
+ els['opt-targeted-app-on'].checked = true;
+ scp81OptionsSyncDisabled();
+ assert.strictEqual(els['opt-next-uri-value'].disabled, false);
+ assert.strictEqual(els['opt-targeted-app'].disabled, false);
+});
+
+test('the custom badge flags anything that differs from the defaults', () => {
+ const els = setup();
+ scp81OptionsBadge();
+ assert.strictEqual(els['scp81-opts-badge'].textContent, '');
+ setup({ 'opt-compact': true, 'scp81-opts-badge': '' });
+ scp81OptionsBadge();
+ assert.strictEqual(globalThis.document.getElementById('scp81-opts-badge').textContent, 'custom');
+ // an empty Next-URI text is not a change (the default template applies)
+ setup({ 'opt-next-uri-value': '', 'scp81-opts-badge': '' });
+ scp81OptionsBadge();
+ assert.strictEqual(globalThis.document.getElementById('scp81-opts-badge').textContent, '');
+});
+
test('load/persist/reset round-trip through localStorage', () => {
const store = fakeStorage();
setup({
'opt-chunked': false,
'opt-chunk-size': 100,
- 'opt-keep-alive': false,
- 'opt-conn-header': 'close',
+ 'opt-conn-header': 'keep-alive',
'opt-compact': true,
'opt-next-uri': false,
'opt-link-events': false,
'opt-script-template': 'definite',
'opt-cr-tag': true,
+ 'opt-targeted-app-on': true,
'opt-targeted-app': '//aid/A000000151000000'
});
scp81OptionsPersist();
assert.ok(store['otaman_scp81_opts'].includes('"chunkSize":100'));
+ assert.ok(store['otaman_scp81_opts'].includes('"targetedAppOn":true'));
+ assert.ok(!store['otaman_scp81_opts'].includes('keepAlive'));
// a reload restores the saved setup
setup({});
scp81OptionsLoad();
- assert.strictEqual(globalThis.document.getElementById('opt-chunked').checked, false);
- assert.strictEqual(globalThis.document.getElementById('opt-chunk-size').value, 100);
- assert.strictEqual(globalThis.document.getElementById('opt-conn-header').value, 'close');
- assert.strictEqual(globalThis.document.getElementById('opt-next-uri').checked, false);
- assert.strictEqual(globalThis.document.getElementById('opt-script-template').value, 'definite');
- assert.strictEqual(globalThis.document.getElementById('opt-cr-tag').checked, true);
- assert.strictEqual(globalThis.document.getElementById('opt-targeted-app').value, '//aid/A000000151000000');
+ const get = id => globalThis.document.getElementById(id);
+ assert.strictEqual(get('opt-chunked').checked, false);
+ assert.strictEqual(get('opt-chunk-size').value, 100);
+ assert.strictEqual(get('opt-conn-header').value, 'keep-alive');
+ assert.strictEqual(get('opt-next-uri').checked, false);
+ assert.strictEqual(get('opt-script-template').value, 'definite');
+ assert.strictEqual(get('opt-cr-tag').checked, true);
+ assert.strictEqual(get('opt-targeted-app-on').checked, true);
+ assert.strictEqual(get('opt-targeted-app').value, '//aid/A000000151000000');
// reset clears the saved entry and restores the defaults
scp81OptionsReset();
assert.ok(!('otaman_scp81_opts' in store));
scp81OptionsLoad();
- assert.strictEqual(globalThis.document.getElementById('opt-chunked').checked, true);
- assert.strictEqual(globalThis.document.getElementById('opt-next-uri').checked, true);
- assert.strictEqual(globalThis.document.getElementById('opt-conn-header').value, 'none');
+ assert.strictEqual(get('opt-chunked').checked, true);
+ assert.strictEqual(get('opt-next-uri').checked, true);
+ assert.strictEqual(get('opt-conn-header').value, 'none');
+ assert.strictEqual(get('opt-targeted-app-on').checked, false);
});
test('the Listener UI wires the framing options into Start', () => {
- for (const id of ['scp81-opts-http', 'scp81-opts-script', 'opt-chunked',
- 'opt-chunk-size', 'opt-keep-alive', 'opt-conn-header', 'opt-compact',
+ for (const id of ['scp81-opts', 'scp81-opts-badge', 'scp81-opts-http', 'scp81-opts-script',
+ 'opt-chunked', 'opt-chunk-size', 'opt-conn-header', 'opt-compact',
'opt-next-uri', 'opt-next-uri-value', 'opt-script-template', 'opt-cr-tag',
- 'opt-targeted-app', 'opt-link-events']) {
+ 'opt-targeted-app-on', 'opt-targeted-app', 'opt-link-events']) {
assert.ok(html.includes('id="' + id + '"'), id);
}
- assert.ok(html.includes('body.link_events = opts.link_events;'));
+ // collapsed by default: a without the open attribute
+ assert.match(html, //);
+ assert.ok(!/]*\sopen/.test(html));
+ // the removed switch must not come back
+ assert.ok(!html.includes('opt-keep-alive'));
+ // the options travel in the start body
assert.ok(html.includes('body.chunk_size = opts.chunk_size;'));
assert.ok(html.includes('body.next_uri = opts.next_uri;'));
+ assert.ok(html.includes('body.targeted_app = opts.targeted_app || null;'));
assert.ok(html.includes('body.script_template = opts.script_template;'));
assert.ok(html.includes("httpOpts.classList.toggle('hidden', mode !== 'tls')"));
assert.ok(html.includes("scriptOpts.classList.toggle('hidden', mode !== 'tls')"));
diff --git a/pyproject.toml b/pyproject.toml
index f8fdd37..3779a4d 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pysim-otaman-server"
-version = "2.2.14"
+version = "2.2.15"
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/scp81.py b/pysim_otaman_server/scp81.py
index 7aaa999..c031517 100644
--- a/pysim_otaman_server/scp81.py
+++ b/pysim_otaman_server/scp81.py
@@ -132,8 +132,8 @@ class PskTlsServer:
def __init__(self, host, port, psk=None, identity=None, on_log=None,
responder=None, timeout=10.0, chunked=False, chunk_size=0,
- keep_alive=False, compact_headers=False, tls_version='auto',
- cipher=None, on_before_close=None, keylog=None,
+ compact_headers=False, tls_version='auto',
+ cipher=None, keylog=None,
conn_header=None, half_close=False, answer_delay=0.0,
psk_map=None):
# PSK lookup table: identity -> key. With an explicit psk_map a
@@ -159,7 +159,6 @@ class PskTlsServer:
self.chunked = chunked
# chunk_size 0 = one record for the whole response
self.chunk_size = int(chunk_size)
- self.keep_alive = keep_alive
self.compact_headers = compact_headers
# TLS is permissive by default: 'auto' accepts TLS 1.0-1.2 and lets
# OpenSSL pick the highest the card offers. The '1.0'/'1.1'/'1.2'
@@ -170,17 +169,13 @@ class PskTlsServer:
# Pin one cipher suite (e.g. PSK-AES128-CBC-SHA) if the card's SD only
# maps a specific suite to a usable SCP81 security level.
self.cipher = cipher or None
- # Called with the peer address just before closing a non-keep-alive
- # connection: the server waits until the card has drained the BIP
- # buffer, otherwise the EOF truncates the response fetch.
- self.on_before_close = on_before_close
# Debug aid: write the TLS traffic secrets to this file
# (SSLKEYLOGFILE format), so captures of the PSK dialog can be
# decrypted (tshark etc). Contains key material - use a temp path.
self.keylog = keylog or None
- # Connection header value: None = auto ('keep-alive'/'close' per the
- # keep_alive flag), 'none' = omit the header (implicit HTTP/1.1
- # keep-alive).
+ # Connection header value: None/'none' = omit the header (implicit
+ # HTTP/1.1 keep-alive); 'keep-alive' adds it explicitly. The server
+ # never closes mid-session - only the 204 ends the dialog.
self.conn_header = conn_header or None
# TLS half-close after a script body. NOTE (live 2026-09-16):
# CPython's SSLSocket.unwrap() poisons the session when the peer does
@@ -367,11 +362,7 @@ class PskTlsServer:
status, resp_headers, resp_body = self.responder(
method, target, headers, body)
reason = {200: 'OK', 204: 'No Content'}.get(status, 'Status')
- conn_hdr = self.conn_header
- if conn_hdr == 'none':
- conn_hdr = None
- elif conn_hdr is None:
- conn_hdr = 'keep-alive' if self.keep_alive else 'close'
+ conn_hdr = None if self.conn_header in (None, 'none') else self.conn_header
response = build_http_response(
status, reason, resp_headers, resp_body,
chunked=self.chunked, compact=self.compact_headers,
@@ -393,38 +384,23 @@ class PskTlsServer:
bytes=len(resp_body), chunked=self.chunked,
response_hex=response.hex().upper()[:600],
body_hex=resp_body.hex().upper()[:2000] or None)
- # 204 always ends the dialog. Without keep-alive every response
- # ends it: the card's HTTP client appears to delimit the
- # response at connection close (live 2026-09-15) and then
- # starts a fresh session for its next POST.
- if status == 204 or not resp_body or not self.keep_alive:
- peer_name = None
- if resp_body and self.on_before_close:
- try:
- peer_name = tls.getpeername()
- except Exception:
- peer_name = None
+ # Only the end of the dialog closes the connection: 204 (or
+ # an empty body) ends the session; every other response
+ # leaves the TLS connection open for the card's next POST.
+ # Reusing it - or dialing a fresh one - is the card's call
+ # (GP Am. B 4.3.1: the SD manages connection establishment).
+ if status == 204 or not resp_body:
+ # Clean TLS shutdown with the response still in the BIP
+ # buffer: the card fetches the 204 and the close_notify
+ # together, then the FIN. A bare close here makes the
+ # card abort the session with a fatal alert.
plain = None
- if not self.keep_alive:
- # Clean TLS shutdown BEFORE the card drains the
- # buffer: a bare TCP close leaves the card's TLS stack
- # with a truncated session (it then neither processes
- # the script nor posts the response), and a
- # close_notify sent only after the drain is never
- # fetched. Send it while the response still waits, so
- # the card reads both, then wait for the buffer to
- # drain and only then send the FIN.
- try:
- tls.settimeout(2.0)
- plain = tls.unwrap()
- tls = None
- except Exception:
- plain = None
- if peer_name and self.on_before_close:
- try:
- self.on_before_close(peer_name)
- except Exception:
- pass
+ try:
+ tls.settimeout(2.0)
+ plain = tls.unwrap()
+ tls = None
+ except Exception:
+ plain = None
if plain is not None:
try:
plain.close()
diff --git a/pysim_otaman_server/server.py b/pysim_otaman_server/server.py
index 73a864a..63f8cb0 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.14'
+VERSION = '2.2.15'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
@@ -1484,41 +1484,12 @@ def _scp81_listener_status():
'cipher_seen': _SCP81_LISTENER.cipher_seen,
'chunked': _SCP81_LISTENER.chunked,
'chunk_size': _SCP81_LISTENER.chunk_size,
- 'keep_alive': _SCP81_LISTENER.keep_alive,
'compact_headers': _SCP81_LISTENER.compact_headers,
'tls_version': _SCP81_LISTENER.tls_version,
'cipher': _SCP81_LISTENER.cipher}
return {'mode': 'dump', 'host': _SCP81_LISTENER.host, 'port': _SCP81_LISTENER.port}
-def _scp81_wait_drained(peer):
- """Wait until the BIP channel for this TLS connection has delivered its
- buffered bytes to the card (matched by the terminal's ephemeral port), so
- a connection close does not truncate the response fetch."""
- if not peer or len(peer) < 2:
- return
- port = peer[1]
- deadline = time.time() + 5.0
- seen_data = False
- while time.time() < deadline:
- ch = None
- for c in list(_BIP.channels.values()):
- try:
- if c.sock.getsockname()[1] == port:
- ch = c
- break
- except OSError:
- continue
- if ch is None:
- return
- if ch.rx:
- # Channel pump has picked up the response; wait for the card.
- seen_data = True
- elif seen_data:
- return
- time.sleep(0.05)
-
-
def _bip_data_available(ch):
"""Monitor-thread callback: tell the card there is server data to fetch.
@@ -2094,11 +2065,9 @@ def _scp81_bip_control(body):
responder=_scp81_script_responder,
chunked=bool(body.get('chunked', True)),
chunk_size=chunk_size,
- keep_alive=bool(body.get('keep_alive', True)),
compact_headers=bool(body.get('compact_headers', False)),
tls_version=str(body.get('tls_version') or 'auto'),
cipher=(body.get('cipher') or None),
- on_before_close=_scp81_wait_drained,
keylog=(body.get('keylog') or None),
conn_header=(body.get('conn_header') or 'none'),
answer_delay=(body.get('answer_delay') or 0),
diff --git a/tests/test_scp81.py b/tests/test_scp81.py
index 426a537..857bece 100644
--- a/tests/test_scp81.py
+++ b/tests/test_scp81.py
@@ -189,8 +189,7 @@ class PskTlsServerTest(unittest.TestCase):
server._SCP81_SCRIPT_NEXT = 0
server._SCP81_SCRIPT_RESULTS = []
srv = scp81.PskTlsServer('127.0.0.1', 0, PSK,
- responder=server._scp81_script_responder,
- keep_alive=True)
+ responder=server._scp81_script_responder)
try:
tls = self._connect(srv)
tls.sendall(b'POST /api/scp81 HTTP/1.1\r\nHost: 127.0.0.1\r\n'
@@ -216,51 +215,46 @@ class PskTlsServerTest(unittest.TestCase):
server._SCP81_SCRIPT_RESULTS = []
srv.stop()
- def test_response_closes_connection_without_keep_alive(self):
- # Default (keep_alive=False): the card's HTTP client seems to delimit
- # the response at connection close, so the server closes after each
- # response and the card starts a fresh session for its next POST.
+ def test_200_keeps_the_connection_for_the_next_post(self):
+ # The card is the HTTP client and may reuse the connection for its
+ # next POST (GP Am. B 4.3.1: connection management is the SD's job);
+ # the server never closes between requests.
def responder(method, target, headers, body):
return 200, {'X-Admin-Protocol': scp81.GP_PROTOCOL}, b'\x80\x01\x00'
srv = scp81.PskTlsServer('127.0.0.1', 0, PSK, responder=responder)
try:
tls = self._connect(srv)
- tls.sendall(b'POST /api/scp81 HTTP/1.1\r\n\r\n')
+ tls.sendall(b'POST /api/scp81?req=1 HTTP/1.1\r\n\r\n')
+ reply = self._read_http(tls)
+ self.assertTrue(reply.startswith(b'HTTP/1.1 200 OK'))
+ # same TLS session, second request
+ tls.sendall(b'POST /api/scp81?req=2 HTTP/1.1\r\n\r\n')
reply = self._read_http(tls)
self.assertTrue(reply.startswith(b'HTTP/1.1 200 OK'))
- self.assertEqual(self._recv(tls), b'') # server closed
tls.close()
finally:
srv.stop()
- def test_close_waits_for_drain_callback(self):
- # With keep_alive=False and a body, the listener calls on_before_close
- # (the server waits for the card to drain the BIP buffer) before
- # closing the connection.
- seen = []
-
+ def test_session_end_closes_with_close_notify(self):
+ # Only the 204 ends the dialog; the server shuts the TLS session down
+ # cleanly (close_notify while the response is still buffered) and
+ # then closes the socket.
def responder(method, target, headers, body):
- return 200, {'X-Admin-Protocol': scp81.GP_PROTOCOL}, b'\x80\x01\x00'
+ return 204, {'X-Admin-Protocol': scp81.GP_PROTOCOL}, b''
- srv = scp81.PskTlsServer('127.0.0.1', 0, PSK, responder=responder,
- on_before_close=lambda peer: seen.append(peer))
+ srv = scp81.PskTlsServer('127.0.0.1', 0, PSK, responder=responder)
try:
tls = self._connect(srv)
- client_port = tls.getsockname()[1]
tls.sendall(b'POST /api/scp81 HTTP/1.1\r\n\r\n')
reply = self._read_http(tls)
- self.assertTrue(reply.startswith(b'HTTP/1.1 200 OK'))
- self.assertIn(b'Connection: close', reply)
- # The server must send close_notify (clean TLS shutdown) before
- # closing: unwrap() succeeds only when the peer's close_notify
- # has been received.
+ self.assertIn(b'HTTP/1.1 204 No Content', reply)
+ # answer the server's close_notify: a mutual clean shutdown means
+ # unwrap() completes instead of timing out
tls.settimeout(3.0)
plain = tls.unwrap()
- # The close comes after the drain callback: EOF proves it ran.
- self.assertEqual(plain.recv(1), b'')
- self.assertEqual(len(seen), 1)
- self.assertEqual(seen[0][1], client_port)
+ plain.settimeout(3.0)
+ self.assertEqual(plain.recv(64), b'')
plain.close()
finally:
srv.stop()
@@ -379,8 +373,7 @@ class PskTlsServerTest(unittest.TestCase):
'Content-Type': scp81.GP_CT_COMMAND}, b'\x80\x01\x00')
return 204, {'X-Admin-Protocol': scp81.GP_PROTOCOL}, b''
- srv = scp81.PskTlsServer('127.0.0.1', 0, PSK, responder=responder,
- keep_alive=True)
+ srv = scp81.PskTlsServer('127.0.0.1', 0, PSK, responder=responder)
try:
tls = self._connect(srv)
tls.sendall(b'POST /server/adminagent?cmd=1 HTTP/1.1\r\n\r\n')
@@ -655,7 +648,7 @@ class BipControlTest(unittest.TestCase):
resp = server._scp81_bip_control({
'action': 'start', 'mode': 'tls', 'host': '127.0.0.1', 'port': 0,
'psk_hex': '00112233', 'psk_identity': 'id-1',
- 'chunked': False, 'chunk_size': 100, 'keep_alive': False,
+ 'chunked': False, 'chunk_size': 100,
'compact_headers': True, 'conn_header': 'close', 'next_uri': '',
'script_template': 'definite', 'cr_tag': True,
'targeted_app': '//aid/A000000151000000', 'link_events': False,
@@ -667,8 +660,8 @@ class BipControlTest(unittest.TestCase):
self.assertIn('version_seen', listener)
self.assertIn('cipher_seen', listener)
self.assertEqual((listener['chunked'], listener['chunk_size'],
- listener['keep_alive'], listener['compact_headers']),
- (False, 100, False, True))
+ listener['compact_headers']),
+ (False, 100, True))
self.assertEqual(server._SCP81_SCRIPT_TEMPLATE, 'definite')
self.assertTrue(server._SCP81_SCRIPT_CR_TAG)
self.assertEqual(server._SCP81_TARGETED_APP, '//aid/A000000151000000')
@@ -946,42 +939,6 @@ class DataAvailableTest(unittest.TestCase):
if __name__ == '__main__':
unittest.main()
-class WaitDrainedTest(unittest.TestCase):
- def test_wait_drained_matches_channel_by_port(self):
- ch = types.SimpleNamespace(rx=bytearray(), sock=types.SimpleNamespace(
- getsockname=lambda: ('127.0.0.1', 40001)))
- old = server._BIP
- server._BIP = types.SimpleNamespace(channels={1: ch})
- try:
- # unknown port / gone channel -> immediate
- self.assertIsNone(server._scp81_wait_drained(('127.0.0.1', 40002)))
- finally:
- server._BIP = old
-
-class WaitDrainedSlowTest(unittest.TestCase):
- def test_wait_drained_waits_for_card_fetch(self):
- import threading, time as _time
- ch = types.SimpleNamespace(rx=bytearray(), sock=types.SimpleNamespace(
- getsockname=lambda: ('127.0.0.1', 40003)))
-
- def feed():
- _time.sleep(0.15)
- ch.rx.extend(b'response-bytes') # pump picks up the response
- _time.sleep(0.25)
- ch.rx.clear() # card fetches everything
-
- old = server._BIP
- server._BIP = types.SimpleNamespace(channels={1: ch})
- th = threading.Thread(target=feed)
- th.start()
- t0 = _time.time()
- try:
- server._scp81_wait_drained(('127.0.0.1', 40003))
- finally:
- server._BIP = old
- th.join()
- self.assertGreater(_time.time() - t0, 0.3)
-
class KeylogTest(unittest.TestCase):
def test_keylog_filename_set(self):
import tempfile, os
@@ -1001,7 +958,7 @@ class ConnHeaderTest(unittest.TestCase):
def responder(method, target, headers, body):
return 204, {}, b''
srv = scp81.PskTlsServer('127.0.0.1', 0, PSK, responder=responder,
- keep_alive=True, conn_header='none')
+ conn_header='none')
try:
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.check_hostname = False