feat: generic BIP terminal control (Simulator BIP pill) (v3.6.0)

BIP was only reachable through the SCP81 listener; cards that use TCP for
other purposes (an applet's OPEN CHANNEL, a push trigger without HTTP OTA)
now get a generic control surface, independent of SCP81.

Server:
- /api/bip/control starts a plain BIP session in three modes: sink
  (default; a local TcpDumpServer that accepts the card's channels and only
  logs conn/sink-rx/conn-close, never answering; port 0 = ephemeral, the
  bound port is reported), passthru (dial the OPEN CHANNEL destination, TCP
  client only) and redirect (fixed host:port).
- /api/bip/status returns {owner, bip, listener}; /api/bip/log and
  /api/bip/log-clear share the BIP event log with /api/scp81/log.
- The session state is shared with the SCP81 listener and only one session
  runs at a time: _bip_session_stop() stops whichever control started it
  and the control responses report it as `replaced` (both directions,
  SCP81 <-> BIP).  State renamed _SCP81_MODE/_TARGET/_LISTENER/_LINK_EVENTS
  -> _BIP_* plus _BIP_OWNER.
- TcpDumpServer logs conn-close and counts accepted connections; stop()
  joins the accept thread so the port is really free on restart.
- The new control endpoints are blocked during test-script runs.

PWA:
- New Simulator pill BIP (after TR Config): mode select with notes,
  Host/Port rules, Start/Stop with a "replaced" notice, status line (mode,
  bound address, owner, channels), a Channels box and the shared BIP log
  (reuses the SCP81 log renderer, now showing `peer`).
- The SCP81 status line marks a session owned by the BIP pill.

Help EN/RU 8.10, docs/api.md, AGENTS; CAT_TP/UDP recorded as not
implemented.

Tests: tests/test_bip.py (9) and frontend/tests/bip.test.js (6).
600 frontend / 482 Python green; version 3.6.0; sw simple-v273.
This commit is contained in:
2026-09-27 15:28:23 +03:00
parent 73ef3a67ca
commit 18a36a8f27
14 changed files with 924 additions and 97 deletions
+11 -11
View File
@@ -666,7 +666,7 @@ class BipControlTest(unittest.TestCase):
self.assertTrue(server._SCP81_SCRIPT_CR_TAG)
self.assertEqual(server._SCP81_TARGETED_APP, '//aid/A000000151000000')
self.assertEqual(server._SCP81_NEXT_URI, '')
self.assertFalse(server._SCP81_LINK_EVENTS)
self.assertFalse(server._BIP_LINK_EVENTS)
self.assertEqual(resp['script_template'], 'definite')
self.assertTrue(resp['cr_tag'])
self.assertFalse(resp['link_events'])
@@ -676,7 +676,7 @@ class BipControlTest(unittest.TestCase):
server._SCP81_SCRIPT_CR_TAG = False
server._SCP81_TARGETED_APP = None
server._SCP81_NEXT_URI = None
server._SCP81_LINK_EVENTS = True
server._BIP_LINK_EVENTS = True
def test_link_events_apply_to_every_mode(self):
# TS 102 223 7.5.11 events are a BIP-layer feature, not a TLS option.
@@ -684,11 +684,11 @@ class BipControlTest(unittest.TestCase):
'host': '10.11.12.13', 'port': 10174,
'link_events': False})
self.assertTrue(resp['ok'], resp)
self.assertFalse(server._SCP81_LINK_EVENTS)
self.assertFalse(server._BIP_LINK_EVENTS)
resp = server._scp81_bip_control({'action': 'start', 'mode': 'passthru',
'link_events': True})
self.assertTrue(resp['ok'], resp)
self.assertTrue(server._SCP81_LINK_EVENTS)
self.assertTrue(server._BIP_LINK_EVENTS)
def test_tls_handshake_failure_is_logged(self):
resp = server._scp81_bip_control({'action': 'start', 'mode': 'tls',
@@ -770,7 +770,7 @@ class BipControlTest(unittest.TestCase):
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.assertIsNone(server._BIP_LISTENER) # no local listener
self.assertEqual(resp['listener']['mode'], 'redirect')
self.assertEqual(resp['listener']['host'], '10.11.12.13')
self.assertEqual(resp['listener']['port'], 10174)
@@ -778,10 +778,10 @@ class BipControlTest(unittest.TestCase):
self.assertTrue(resp['bip']['enabled'])
self.assertEqual(server._BIP.target, ('10.11.12.13', 10174))
# the status endpoint sees the redirect mode while it runs ...
self.assertEqual(server._scp81_listener_status()['mode'], 'redirect')
self.assertEqual(server._bip_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.assertIsNone(server._bip_listener_status())
self.assertFalse(server._BIP.enabled)
def test_redirect_mode_requires_an_explicit_target(self):
@@ -795,7 +795,7 @@ class BipControlTest(unittest.TestCase):
resp = server._scp81_bip_control({'action': 'start', 'mode': 'redirect',
'port': 1234})
self.assertFalse(resp['ok'])
self.assertIsNone(server._SCP81_LISTENER)
self.assertIsNone(server._BIP_LISTENER)
self.assertFalse(server._BIP.enabled)
def test_passthru_mode_needs_no_target(self):
@@ -803,15 +803,15 @@ class BipControlTest(unittest.TestCase):
# 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.assertIsNone(server._BIP_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'})
self.assertEqual(server._bip_listener_status(), {'mode': 'passthru'})
server._scp81_bip_control({'action': 'stop'})
self.assertIsNone(server._scp81_listener_status())
self.assertIsNone(server._bip_listener_status())
self.assertFalse(server._BIP.enabled)
def test_start_accepts_explicit_script_list(self):