diff --git a/snap7/server/__init__.py b/snap7/server/__init__.py index 1a652cc7..b1f1714c 100644 --- a/snap7/server/__init__.py +++ b/snap7/server/__init__.py @@ -2606,6 +2606,12 @@ def accept_connection(self) -> bool: logger.debug("ISO connection established") return True + except (ConnectionResetError, ConnectionAbortedError, TimeoutError) as e: + # A peer that goes away before the ISO handshake completes is + # routine - port scans, health checks, a cancelled connect - and + # says nothing about this server. + logger.info(f"Peer left before the ISO connection was established: {e}") + return False except Exception as e: logger.error(f"Error accepting ISO connection: {e}") return False @@ -2638,6 +2644,16 @@ def receive_data(self) -> bytes: pdu_len, pdu_type, eot_num = struct.unpack(">BBB", payload[:3]) + if pdu_type == self.COTP_DR: + # The peer is closing the connection the way ISO 8073 says to; + # confirm it and let the caller treat this as a normal end. + logger.debug("Received COTP DR from client") + try: + self.socket.sendall(self._build_tpkt(self._build_cotp_dc())) + except OSError: + pass # the peer may already be gone + raise ConnectionAbortedError("Client requested disconnect") + if pdu_type != self.COTP_DT: raise S7ConnectionError(f"Expected COTP DT, got {pdu_type:#02x}") @@ -2715,6 +2731,16 @@ def _build_cotp_cc(self) -> bytes: return base_pdu + pdu_size_param + def _build_cotp_dc(self) -> bytes: + """Build COTP Disconnect Confirm.""" + return struct.pack( + ">BBHH", + 5, # PDU length + self.COTP_DC, # PDU type + self.dst_ref, # Destination reference + self.src_ref, # Source reference + ) + def _recv_exact(self, size: int, deadline: float | None = None) -> bytes: """Receive exactly the specified bytes within one absolute deadline.""" if size < 0: diff --git a/tests/test_server.py b/tests/test_server.py index d6a6e4e3..d475c2f5 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -327,6 +327,49 @@ 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_disconnect_confirm_has_valid_length(self) -> None: + client_socket = MagicMock() + connection = ServerISOConnection(client_socket) + connection.dst_ref = 0x000F + connection.src_ref = 0x0001 + + disconnect_confirm = connection._build_cotp_dc() + + assert disconnect_confirm == bytes.fromhex("05c0000f0001") + assert disconnect_confirm[0] == len(disconnect_confirm) - 1 + + def test_a_disconnect_request_ends_the_connection_normally(self) -> None: + # The client sends a COTP DR when it disconnects; treating it as an + # unexpected PDU logs an error for an ordinary goodbye. + client_socket = MagicMock() + connection = ServerISOConnection(client_socket) + connection._recv_exact = MagicMock( + side_effect=[ + b"\x03\x00\x00\x0b", + b"\x06\x80\x00\x00\x01\x00\x00", + ] + ) + + with pytest.raises(ConnectionAbortedError): + connection.receive_data() + + sent = b"".join(call.args[0] for call in client_socket.sendall.call_args_list) + assert sent[5:6] == bytes([connection.COTP_DC]), "the disconnect is confirmed" + + def test_a_disconnect_request_is_confirmed_even_if_the_peer_is_gone(self) -> None: + client_socket = MagicMock() + client_socket.sendall.side_effect = OSError("broken pipe") + connection = ServerISOConnection(client_socket) + connection._recv_exact = MagicMock( + side_effect=[ + b"\x03\x00\x00\x0b", + b"\x06\x80\x00\x00\x01\x00\x00", + ] + ) + + with pytest.raises(ConnectionAbortedError): + connection.receive_data() + def test_partial_frame_timeout_closes_connection(self) -> None: client_socket = MagicMock() client_socket.recv.side_effect = [b"\x03", TimeoutError()]