Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions snap7/server/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}")

Expand Down Expand Up @@ -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:
Expand Down
43 changes: 43 additions & 0 deletions tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()]
Expand Down