diff --git a/CHANGES.md b/CHANGES.md index f664baeb..5cebd68d 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -6,6 +6,8 @@ CHANGES Major release: new `s7commplus` package with S7CommPlus protocol support. +* Echo calling and called TSAP parameters in pure-Python server connection + confirmations, and restart the receive deadline after each TPKT header. * Decode corroborating CPU execution attributes so S7CommPlus `get_cpu_state()` distinguishes RUN from STOP on S7-1500 and returns UNKNOWN for absent or inconsistent state attributes, including S7-1200 responses that omit them. diff --git a/snap7/server/__init__.py b/snap7/server/__init__.py index 017966c0..cc09ad5f 100644 --- a/snap7/server/__init__.py +++ b/snap7/server/__init__.py @@ -2676,8 +2676,10 @@ class ServerISOConnection: COTP_DC = 0xC0 # Disconnect Confirm COTP_DT = 0xF0 # Data Transfer - # COTP parameter code for TPDU size (ISO 8073) + # COTP parameter codes (ISO 8073) COTP_PARAM_PDU_SIZE = 0xC0 + COTP_PARAM_CALLING_TSAP = 0xC1 + COTP_PARAM_CALLED_TSAP = 0xC2 def __init__(self, client_socket: socket.socket): """Initialize server ISO connection.""" @@ -2687,6 +2689,8 @@ def __init__(self, client_socket: socket.socket): self.src_ref = 0x0001 # Server reference self.dst_ref = 0x0000 # Client reference (assigned during handshake) self.tpdu_size = 0x0A # Default: 1024 bytes (2^10) + self.calling_tsap: bytes | None = None + self.called_tsap: bytes | None = None def accept_connection(self) -> bool: """Accept ISO connection from client.""" @@ -2735,9 +2739,9 @@ def receive_data(self) -> bytes: """ fragments: list[bytes] = [] total_size = 0 - deadline = time.monotonic() + self.RECEIVE_DEADLINE while True: - tpkt_header = self._recv_exact(4, deadline) + header_deadline = time.monotonic() + self.RECEIVE_DEADLINE + tpkt_header = self._recv_exact(4, header_deadline) version, reserved, length = struct.unpack(">BBH", tpkt_header) if version != 3: @@ -2747,7 +2751,11 @@ def receive_data(self) -> bytes: if remaining <= 0: raise S7ConnectionError("Invalid TPKT length") - payload = self._recv_exact(remaining, deadline) + frame_deadline = time.monotonic() + self.RECEIVE_DEADLINE + try: + payload = self._recv_exact(remaining, frame_deadline) + except TimeoutError as e: + raise S7ConnectionError("Receive deadline exceeded after TPKT header") from e if len(payload) < 3: raise S7ConnectionError("Invalid COTP DT: too short") @@ -2802,18 +2810,25 @@ def _parse_cotp_cr(self, data: bytes) -> bool: # Store client reference self.dst_ref = src_ref - # Parse variable parameters for TPDU size + # Parse variable parameters used in the connection confirmation. + self.calling_tsap = None + self.called_tsap = None offset = 7 while offset + 2 <= len(data): param_code = data[offset] param_len = data[offset + 1] if offset + 2 + param_len > len(data): break + param_data = data[offset + 2 : offset + 2 + param_len] if param_code == self.COTP_PARAM_PDU_SIZE and param_len == 1: exponent = data[offset + 2] if 7 <= exponent <= 13: self.tpdu_size = exponent logger.debug(f"Client requested TPDU size 2^{exponent} = {1 << exponent}") + elif param_code == self.COTP_PARAM_CALLING_TSAP: + self.calling_tsap = param_data + elif param_code == self.COTP_PARAM_CALLED_TSAP: + self.called_tsap = param_data offset += 2 + param_len logger.debug(f"Received COTP CR from client ref {src_ref}") @@ -2826,8 +2841,15 @@ def _build_cotp_cc(self) -> bytes: negotiated maximum segment size and don't fall back to the ISO 8073 class-0 default of 128 bytes. """ - pdu_size_param = struct.pack(">BBB", self.COTP_PARAM_PDU_SIZE, 1, self.tpdu_size) - pdu_length = 6 + len(pdu_size_param) + parameters = bytearray(struct.pack(">BBB", self.COTP_PARAM_PDU_SIZE, 1, self.tpdu_size)) + if self.calling_tsap is not None: + parameters.extend(struct.pack(">BB", self.COTP_PARAM_CALLING_TSAP, len(self.calling_tsap))) + parameters.extend(self.calling_tsap) + if self.called_tsap is not None: + parameters.extend(struct.pack(">BB", self.COTP_PARAM_CALLED_TSAP, len(self.called_tsap))) + parameters.extend(self.called_tsap) + + pdu_length = 6 + len(parameters) base_pdu = struct.pack( ">BBHHB", pdu_length, # PDU length @@ -2837,7 +2859,7 @@ def _build_cotp_cc(self) -> bytes: 0x00, # Class/option ) - return base_pdu + pdu_size_param + return base_pdu + parameters def _build_cotp_dc(self) -> bytes: """Build COTP Disconnect Confirm.""" diff --git a/tests/test_server.py b/tests/test_server.py index 9fec91f7..df038337 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -5,7 +5,7 @@ from ctypes import c_char from datetime import datetime from threading import Thread -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import pytest @@ -397,6 +397,17 @@ def test_connection_confirm_has_valid_length_and_tpdu_size(self) -> None: assert connection_confirm == bytes.fromhex("09d0000f000100c00109") assert connection_confirm[0] == len(connection_confirm) - 1 + def test_connection_confirm_echoes_request_tsaps(self) -> None: + client_socket = MagicMock() + connection = ServerISOConnection(client_socket) + connection_request = bytes.fromhex("11e00000000f00c1020100c2020102c0010a") + + assert connection._parse_cotp_cr(connection_request) + + connection_confirm = connection._build_cotp_cc() + assert connection_confirm == bytes.fromhex("11d0000f000100c0010ac1020100c2020102") + assert connection_confirm[0] == len(connection_confirm) - 1 + def test_disconnect_confirm_has_valid_length(self) -> None: client_socket = MagicMock() connection = ServerISOConnection(client_socket) @@ -446,6 +457,25 @@ def test_partial_frame_timeout_closes_connection(self) -> None: with pytest.raises(S7ConnectionError, match="partial frame"): connection._recv_exact(4, time.monotonic() + 1) + def test_payload_gets_fresh_deadline_after_header(self) -> None: + client_socket = MagicMock() + connection = ServerISOConnection(client_socket) + connection._recv_exact = MagicMock(side_effect=[b"\x03\x00\x00\x08", b"\x02\xf0\x80x"]) + + with patch("snap7.server.time.monotonic", side_effect=[100.0, 104.0]): + assert connection.receive_data() == b"x" + + assert connection._recv_exact.call_args_list[0].args == (4, 105.0) + assert connection._recv_exact.call_args_list[1].args == (4, 109.0) + + def test_timeout_after_header_closes_connection(self) -> None: + client_socket = MagicMock() + connection = ServerISOConnection(client_socket) + connection._recv_exact = MagicMock(side_effect=[b"\x03\x00\x00\x08", TimeoutError("timed out")]) + + with pytest.raises(S7ConnectionError, match="after TPKT header"): + connection.receive_data() + def test_reassembled_request_size_is_bounded(self) -> None: client_socket = MagicMock() connection = ServerISOConnection(client_socket)