scp81: always reuse the connection, collapsible options, label polish (v2.2.15)
- Keep-alive is no longer a setting: the TLS connection stays open between POSTs for the whole dialog (the card, as HTTP client, may still dial a new one at any time - GP Am. B 4.3.1) and only the 204 ends it, with a clean close_notify while the response is still buffered, then FIN. Dropped keep_alive/on_before_close/_scp81_wait_drained and the Connection-header 'close' value (the API, status and UI no longer carry a keep-alive knob). - The Listener Options block is a collapsed <details> with a 'custom' badge when anything differs from the reference defaults; the Reset button moved into the body so it cannot toggle the panel. - X-Admin-Targeted-Application is opt-in (checkbox + //aid/... field, field disabled while off); X-Admin-Next-URI has a checkbox + hint explaining the one-shot rule of GP Am. B 4.4.2; both dependent fields grey out when unchecked. - Labels/i18n: 'Chunked body (Transfer-Encoding: chunked)' stays English, 'Show link events (...)', 'Теги comprehension-required'; the compact-header wording now spells out that it omits the optional space after ':' (legal per RFC 7230 3.2 OWS; saves one byte per header). - Tests: a 200 keeps the socket for the next POST; the 204 closes with a mutual close_notify exchange; options helper/badge unit tests; removed the close-per-response and drain-wait tests. SW cache otaman-v181.
This commit is contained in:
@@ -327,7 +327,7 @@
|
||||
<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>Start</strong>, сохраняются в браузере) открывает HTTP-фрейминг — <strong>chunked</strong>-тело / <strong>размер чанка</strong> (0 = весь ответ в одной TLS-записи), keep-alive, заголовок Connection, компактные заголовки и Next-URI (снято — заголовок не отправляется) — и фрейминг скрипта (неопределённая длина <code class="font-mono text-sm">AE 80 …</code> / определённая <code class="font-mono text-sm">AA</code> в Command Scripting template, теги с обязательным пониманием, целевое приложение). <strong>События канала</strong> (ENVELOPE Channel status, TS 102 223 §7.5.11) действуют во всех режимах. У TLS нет настроек: слушатель предлагает TLS 1.0–1.2 и все PSK-наборы, а карта выбирает; строка состояния и TLS-журнал показывают фактически использованные версию и набор, а сбой рукопожатия по причине TLS/набора пишется как <code class="font-mono text-sm">tls-handshake-failed</code>.</p>
|
||||
<p class="mb-2">Блок <strong>Настройки</strong> (по умолчанию свёрнут; при отличии от эталонных значений показывается метка <em>изменены</em>; применяются при <strong>Start</strong>, сохраняются в браузере) открывает HTTP-фрейминг — <strong>Chunked body</strong> / <strong>размер чанка</strong> (0 = весь ответ в одной TLS-записи), заголовок Connection, компактные заголовки (без необязательного пробела после <code class="font-mono text-sm">:</code>, допустимо по RFC 7230 §3.2) и Next-URI (флажок + шаблон; снято — одноразовый режим: карта выполнит команду, не вернёт строку ответа и закроет сессию) — и фрейминг скрипта (неопределённая длина <code class="font-mono text-sm">AE 80 …</code> / определённая <code class="font-mono text-sm">AA</code> в Command Scripting template, теги comprehension-required и необязательный <code class="font-mono text-sm">X-Admin-Targeted-Application</code> со своим флажком). <strong>Показывать события канала</strong> (ENVELOPE Channel status, TS 102 223 §7.5.11) действует во всех режимах. Соединение удерживается между POST-запросами и аккуратно закрывается на 204. У TLS нет настроек: слушатель предлагает TLS 1.0–1.2 и все PSK-наборы, а карта выбирает; строка состояния и TLS-журнал показывают фактически использованные версию и набор, а сбой рукопожатия по причине TLS/набора пишется как <code class="font-mono text-sm">tls-handshake-failed</code>.</p>
|
||||
<p class="mb-2"><strong>Сценарий</strong> выбирает список команд, отдаваемый в сессии — <strong>Нет</strong> (оставить настроенный на сервере скрипт) или один из скриптов подвкладки <strong>«Скрипты»</strong>; выбранный список передаётся серверу при старте слушателя.</p>
|
||||
<p class="text-sm mb-3">Строка состояния показывает слушатель, согласованный идентификатор и активные каналы (байты in/out). Панель <strong>Script results (R-APDUs)</strong> перечисляет каждый отданный C-APDU с его R-APDU/SW и прогрессом <em>done/total</em>; журнал <strong>HTTP OTA log</strong> фиксирует OPEN/CLOSE CHANNEL, SEND/RECEIVE DATA и каждый шаг TLS/HTTP/скрипта (<code class="font-mono text-sm">tls-handshake</code>, <code class="font-mono text-sm">tls-request</code>, <code class="font-mono text-sm">script-send</code>, <code class="font-mono text-sm">script-rapdu</code>, <code class="font-mono text-sm">script-page</code>, <code class="font-mono text-sm">script-done</code>, <code class="font-mono text-sm">data-available</code>, <code class="font-mono text-sm">peer-close</code>), кнопка <strong>Clear</strong> очищает его.</p>
|
||||
<h3 id="scp81-scripts" class="text-lg font-medium mb-2">5.2 Скрипты</h3>
|
||||
|
||||
+1
-1
@@ -327,7 +327,7 @@
|
||||
<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">The <strong>Options</strong> block (applied at <strong>Start</strong>, remembered in the browser) exposes the HTTP framing — <strong>chunked</strong> body / <strong>chunk size</strong> (0 = the whole response in one TLS record), keep-alive, the Connection header, compact headers and the Next-URI (unchecked = omit the header) — and the script framing (indefinite <code class="font-mono text-sm">AE 80 …</code> / definite <code class="font-mono text-sm">AA</code> Command Scripting template, comprehension-required tags, targeted application). <strong>Link events</strong> (Channel status ENVELOPEs, TS 102 223 §7.5.11) apply to every mode. TLS itself has no settings: the listener offers TLS 1.0–1.2 and all PSK suites and lets the card negotiate; the state line and the TLS log show the version/cipher actually used, and a handshake that fails for a TLS/cipher reason is logged as <code class="font-mono text-sm">tls-handshake-failed</code>.</p>
|
||||
<p class="mb-2">The <strong>Options</strong> block (collapsed by default; a <em>custom</em> marker appears when anything differs from the reference defaults; applied at <strong>Start</strong>, remembered in the browser) exposes the HTTP framing — <strong>Chunked body</strong> / <strong>chunk size</strong> (0 = the whole response in one TLS record), the Connection header, compact headers (omitting the optional space after <code class="font-mono text-sm">:</code>, legal per RFC 7230 §3.2) and the Next-URI (checkbox + template; unchecked = one-shot: the card executes the command, returns no response string and closes the session) — and the script framing (indefinite <code class="font-mono text-sm">AE 80 …</code> / definite <code class="font-mono text-sm">AA</code> Command Scripting template, comprehension-required tags, and the optional <code class="font-mono text-sm">X-Admin-Targeted-Application</code> with its own checkbox). <strong>Show link events</strong> (Channel status ENVELOPEs, TS 102 223 §7.5.11) applies to every mode. The connection is kept open between POSTs and closed cleanly at the 204. TLS itself has no settings: the listener offers TLS 1.0–1.2 and all PSK suites and lets the card negotiate; the state line and the TLS log show the version/cipher actually used, and a handshake that fails for a TLS/cipher reason is logged as <code class="font-mono text-sm">tls-handshake-failed</code>.</p>
|
||||
<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>
|
||||
<p class="text-sm mb-3">The state line shows the listener, the negotiated identity and live channels (bytes in/out). <strong>Script results (R-APDUs)</strong> lists each served C-APDU with its R-APDU/SW and a <em>done/total</em> progress; the <strong>HTTP OTA log</strong> records OPEN/CLOSE CHANNEL, SEND/RECEIVE DATA and every TLS/HTTP/script step (<code class="font-mono text-sm">tls-handshake</code>, <code class="font-mono text-sm">tls-request</code>, <code class="font-mono text-sm">script-send</code>, <code class="font-mono text-sm">script-rapdu</code>, <code class="font-mono text-sm">script-page</code>, <code class="font-mono text-sm">script-done</code>, <code class="font-mono text-sm">data-available</code>, <code class="font-mono text-sm">peer-close</code>), with <strong>Clear</strong>.</p>
|
||||
<h3 id="scp81-scripts" class="text-lg font-medium mb-2">5.2 Scripts</h3>
|
||||
|
||||
+134
-75
@@ -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.14</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.15</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>
|
||||
@@ -995,44 +995,49 @@
|
||||
<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 flex-wrap items-center gap-2 mb-2">
|
||||
<span class="text-sm text-gray-500 dark:text-slate-400" data-l10n="Options (applied at Start)">Options (applied at Start)</span>
|
||||
<button onclick="scp81OptionsReset()" class="ml-auto px-2 py-0.5 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300" data-l10n="Reset to defaults">Reset to defaults</button>
|
||||
</div>
|
||||
<div id="scp81-opts-http" class="space-y-2">
|
||||
<div class="text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="HTTP framing">HTTP framing</div>
|
||||
<label class="flex items-center gap-2 text-xs text-gray-600 dark:text-slate-300 cursor-pointer"><input type="checkbox" id="opt-chunked" onchange="scp81OptionsPersist()" class="rounded"> <span data-l10n="Chunked body (Transfer-Encoding: chunked)">Chunked body (Transfer-Encoding: chunked)</span></label>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<label class="text-xs text-gray-600 dark:text-slate-400" data-l10n="Chunk size (bytes, 0 = one TLS record)">Chunk size (bytes, 0 = one TLS record)</label>
|
||||
<input id="opt-chunk-size" type="number" min="0" max="4096" onchange="scp81OptionsPersist()" class="w-24 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800">
|
||||
<details id="scp81-opts" class="border border-gray-200 dark:border-slate-700 rounded mb-3">
|
||||
<summary class="p-3 cursor-pointer select-none text-sm text-gray-500 dark:text-slate-400">
|
||||
<span data-l10n="Options (applied at Start)">Options (applied at Start)</span>
|
||||
<span id="scp81-opts-badge" class="ml-1 text-xs text-amber-500 dark:text-amber-400"></span>
|
||||
</summary>
|
||||
<div class="px-3 pb-3">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<button onclick="scp81OptionsReset()" class="ml-auto px-2 py-0.5 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300" data-l10n="Reset to defaults">Reset to defaults</button>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-xs text-gray-600 dark:text-slate-300 cursor-pointer"><input type="checkbox" id="opt-keep-alive" onchange="scp81OptionsPersist()" class="rounded"> <span data-l10n="Keep-alive (one connection until the session ends)">Keep-alive (one connection until the session ends)</span></label>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<label class="text-xs text-gray-600 dark:text-slate-400" data-l10n="Connection header">Connection header</label>
|
||||
<select id="opt-conn-header" onchange="scp81OptionsPersist()" class="border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800">
|
||||
<option value="none" data-l10n="omit (implicit keep-alive)">omit (implicit keep-alive)</option>
|
||||
<option value="close">close</option>
|
||||
<option value="keep-alive">keep-alive</option>
|
||||
</select>
|
||||
<div id="scp81-opts-http" class="space-y-2">
|
||||
<div class="text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="HTTP framing">HTTP framing</div>
|
||||
<label class="flex items-center gap-2 text-xs text-gray-600 dark:text-slate-300 cursor-pointer"><input type="checkbox" id="opt-chunked" onchange="scp81OptionsPersist()" class="rounded"> <span data-l10n="Chunked body (Transfer-Encoding: chunked)">Chunked body (Transfer-Encoding: chunked)</span></label>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<label class="text-xs text-gray-600 dark:text-slate-400" data-l10n="Chunk size (bytes, 0 = one TLS record)">Chunk size (bytes, 0 = one TLS record)</label>
|
||||
<input id="opt-chunk-size" type="number" min="0" max="4096" onchange="scp81OptionsPersist()" class="w-24 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800">
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<label class="text-xs text-gray-600 dark:text-slate-400" data-l10n="Connection header">Connection header</label>
|
||||
<select id="opt-conn-header" onchange="scp81OptionsPersist()" class="border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800">
|
||||
<option value="none" data-l10n="omit (implicit keep-alive)">omit (implicit keep-alive)</option>
|
||||
<option value="keep-alive">keep-alive</option>
|
||||
</select>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-xs text-gray-600 dark:text-slate-300 cursor-pointer"><input type="checkbox" id="opt-compact" onchange="scp81OptionsPersist()" class="rounded"> <span data-l10n="Compact headers (omit the optional space after ':')">Compact headers (omit the optional space after ':')</span></label>
|
||||
<label class="flex items-center gap-2 text-xs text-gray-600 dark:text-slate-300 cursor-pointer"><input type="checkbox" id="opt-next-uri" onchange="scp81NextUriToggle()" class="rounded"> <span data-l10n="Send X-Admin-Next-URI (required to continue the session)">Send X-Admin-Next-URI (required to continue the session)</span></label>
|
||||
<input id="opt-next-uri-value" onchange="scp81OptionsPersist()" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800" placeholder="/api/scp81?req=%d">
|
||||
<div class="text-xs text-gray-400 dark:text-slate-500" data-l10n="Unchecked = one-shot: per GP Am. B §4.4.2 the card executes the command, returns no response string and closes the session.">Unchecked = one-shot: per GP Am. B §4.4.2 the card executes the command, returns no response string and closes the session.</div>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-xs text-gray-600 dark:text-slate-300 cursor-pointer"><input type="checkbox" id="opt-compact" onchange="scp81OptionsPersist()" class="rounded"> <span data-l10n="Compact headers (no space after ':')">Compact headers (no space after ':')</span></label>
|
||||
<label class="flex items-center gap-2 text-xs text-gray-600 dark:text-slate-300 cursor-pointer"><input type="checkbox" id="opt-next-uri" onchange="scp81OptionsPersist()" class="rounded"> <span data-l10n="Next-URI (unchecked = omit the header)">Next-URI (unchecked = omit the header)</span></label>
|
||||
<input id="opt-next-uri-value" onchange="scp81OptionsPersist()" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800" placeholder="/api/scp81?req=%d">
|
||||
</div>
|
||||
<div id="scp81-opts-script" class="space-y-2 mt-3">
|
||||
<div class="text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Script framing">Script framing</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<select id="opt-script-template" onchange="scp81OptionsPersist()" class="border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800">
|
||||
<option value="indefinite" data-l10n="Indefinite (AE 80 … 00 00)">Indefinite (AE 80 … 00 00)</option>
|
||||
<option value="definite" data-l10n="Definite (AA)">Definite (AA)</option>
|
||||
</select>
|
||||
<label class="flex items-center gap-2 text-xs text-gray-600 dark:text-slate-300 cursor-pointer"><input type="checkbox" id="opt-cr-tag" onchange="scp81OptionsPersist()" class="rounded"> <span data-l10n="Comprehension-required tags">Comprehension-required tags</span></label>
|
||||
<div id="scp81-opts-script" class="space-y-2 mt-3">
|
||||
<div class="text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Script framing">Script framing</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<select id="opt-script-template" onchange="scp81OptionsPersist()" class="border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800">
|
||||
<option value="indefinite" data-l10n="Indefinite (AE 80 … 00 00)">Indefinite (AE 80 … 00 00)</option>
|
||||
<option value="definite" data-l10n="Definite (AA)">Definite (AA)</option>
|
||||
</select>
|
||||
<label class="flex items-center gap-2 text-xs text-gray-600 dark:text-slate-300 cursor-pointer"><input type="checkbox" id="opt-cr-tag" onchange="scp81OptionsPersist()" class="rounded"> <span data-l10n="Comprehension-required tags">Comprehension-required tags</span></label>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-xs text-gray-600 dark:text-slate-300 cursor-pointer"><input type="checkbox" id="opt-targeted-app-on" onchange="scp81TargetedAppToggle()" class="rounded"> <span data-l10n="Send X-Admin-Targeted-Application">Send X-Admin-Targeted-Application</span></label>
|
||||
<input id="opt-targeted-app" onchange="scp81OptionsPersist()" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800" placeholder="//aid/A000000151000000">
|
||||
</div>
|
||||
<input id="opt-targeted-app" onchange="scp81OptionsPersist()" class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1 dark:bg-slate-800" placeholder="X-Admin-Targeted-Application, e.g. //aid/A000000151000000">
|
||||
<label class="flex items-center gap-2 text-xs text-gray-600 dark:text-slate-300 cursor-pointer mt-3"><input type="checkbox" id="opt-link-events" onchange="scp81OptionsPersist()" class="rounded"> <span data-l10n="Show link events (Channel status ENVELOPEs, TS 102 223 7.5.11)">Show link events (Channel status ENVELOPEs, TS 102 223 7.5.11)</span></label>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-xs text-gray-600 dark:text-slate-300 cursor-pointer mt-3"><input type="checkbox" id="opt-link-events" onchange="scp81OptionsPersist()" class="rounded"> <span data-l10n="Link events (Channel status ENVELOPEs, TS 102 223 7.5.11)">Link events (Channel status ENVELOPEs, TS 102 223 7.5.11)</span></label>
|
||||
</div>
|
||||
</details>
|
||||
<div class="border border-gray-200 dark:border-slate-700 rounded p-3 mb-3">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<span class="text-sm text-gray-500 dark:text-slate-400" data-l10n="Script results (R-APDUs)">Script results (R-APDUs)</span>
|
||||
@@ -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',
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'otaman-v180';
|
||||
const CACHE = 'otaman-v181';
|
||||
const URLS = [
|
||||
'index.html',
|
||||
'help.html',
|
||||
|
||||
@@ -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 <details> without the open attribute
|
||||
assert.match(html, /<details id="scp81-opts" class="[^"]*">/);
|
||||
assert.ok(!/<details id="scp81-opts"[^>]*\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')"));
|
||||
|
||||
Reference in New Issue
Block a user