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:
2026-09-17 09:25:23 +03:00
parent f4462c14e7
commit a59e374ff9
12 changed files with 287 additions and 268 deletions
+22 -46
View File
@@ -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()