Skip to content

Commit 949446d

Browse files
committed
Work toward detecting Un/Connected Send and Multiple Service errors
1 parent 1ae1847 commit 949446d

File tree

3 files changed

+124
-26
lines changed

3 files changed

+124
-26
lines changed

server/enip/client.py

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,31 @@ def parse_path_component( *args, **kwds ):
7474
log = logging.getLogger( "enip.cli" )
7575

7676
class ENIPStatusError( Exception ):
77-
def __init__(self, status=None):
77+
def __init__( self, status=None, message=None ):
7878
self.status = status
79-
super( ENIPStatusError, self ).__init__("Response EtherNet/IP status: %d" % ( status ))
79+
super( ENIPStatusError, self ).__init__(
80+
( message or "Response EtherNet/IP status: 0x%02x" ) % ( status ))
81+
82+
83+
class SENDStatusError( ENIPStatusError ):
84+
"""If a Connected/Unconnected Send fails, it probably indicates a serious problem with the
85+
assumptions underlying the session; eg., that the requested Unconnected Send parameters are
86+
invalid, eg. a route path doesn't exist, or the target device is not a "routing" CIP device.
87+
88+
"""
89+
def __init__( self, status=None, message=None ):
90+
super( SENDStatusError, self ).__init__(
91+
status=status, message=message or "Response EtherNet/IP Un/Connected Send status: 0x%02x" )
92+
93+
94+
class MSVCStatusError( ENIPStatusError ):
95+
"""If a Multiple Service Request encapsulation fails, it also means that the stream of
96+
requests/replies will be defeated, and that the session should probably be restarted.
97+
98+
"""
99+
def __init__( self, status=None, message=None ):
100+
super( MSVCStatusError, self ).__init__(
101+
status=status, message=message or "Response EtherNet/IP Multiple Service status: 0x%02x" )
80102

81103

82104
def format_path( segments, count=None ):
@@ -314,14 +336,21 @@ def enip_replies( response, multiple=False ):
314336
replies = None
315337
item_1 = response.get( 'enip.CIP.send_data.CPF.item[1]' )
316338
if item_1:
317-
# Could be either Send RR Data or Send Unit Data
339+
# Could be either Send RR Data or Send Unit Data, OR a plain Unconnected Send error response status.
318340
data = item_1.get( 'unconnected_send' ) or item_1.get( 'connection_data' )
319341
if data:
320-
# Could be a Multiple Service Packet or a single Service request. Multiple
321-
# Service Packet is eg. a list of read/write_tag/frag; Single request is eg. a
342+
# A Connected/Unconnected send; any status error will likely de-synchronize the session
343+
send_status = data.get( 'status' )
344+
if send_status:
345+
raise SENDStatusError( status=send_status )
346+
# Could be a Multiple Service Packet success or failure, or a single Service request.
347+
# Multiple Service Packet is eg. a list of read/write_tag/frag; Single request is eg. a
322348
# read/write_tag/frag, converted to a list.
323349
request = data.get( 'request' )
324-
if 'multiple.request' in request:
350+
if request.get( 'service' ) == device.Message_Router.MULTIPLE_RPY: # 'multiple.request' in request:
351+
msvc_status = request.get( 'status' )
352+
if msvc_status:
353+
raise MSVCStatusError( status=msvc_status )
325354
replies = request.multiple.request
326355
else:
327356
replies = [ request ]

server/enip/parser.py

Lines changed: 69 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1231,8 +1231,8 @@ def __init__( self, name=None, **kwds ):
12311231
# But, if no pad, go parse the route path
12321232
mesg[None] = rout
12331233

1234-
# Parser for an Unconnected Send response (only seen if Unconnected Send itself fails, eg.
1235-
# was sent to a Non-Routing (simple) CIP device, or was otherwise malformed.
1234+
# Parser for an Unconnected Send error response (only seen if Unconnected Send itself fails, eg.
1235+
# was sent to a Non-Routing (simple) CIP device, or was otherwise malformed. From
12361236
uerr = USINT( context='service' )
12371237
uerr[True] = uers = octets_drop( 'reserved', repeat=1 )
12381238
uers[True] = uest = status()
@@ -1245,14 +1245,42 @@ def __init__( self, name=None, **kwds ):
12451245
# single enecapsulated C*Logix Read Tag Fragmented response (0x52/0xD2) carrying an error
12461246
# status. In fact, it is impossible to distinguish -- they are exactly the same size and
12471247
# carry the same server == 0xD2 and status coded.
1248-
def u_s_err( path=None, data=None, **kwds ):
1249-
log.normal( "%s -- checking data[%r] for unconnected_send error: %s", self, path, enip_format( data ) )
1250-
return False
1248+
def u_s_err( path=None, data=None, source=None, **kwds ):
1249+
log.isEnabledFor( logging.INFO ) and log.info(
1250+
"%s -- checking data[%r] (kwds %r) for unconnected_send error: %s",
1251+
self, path, kwds, enip_format( data )
1252+
)
1253+
if data[path+'..length'] > 6:
1254+
return False
1255+
# Might be a Unconnected Send error, perhaps with a remaining path size; peek at the
1256+
# error code and extended status; if the code is < 0x10 and there is NO extended status,
1257+
# then it must *not* be a Read Tag Fragmented error code, as ALL of its <0x10 status
1258+
# codes are required to have an extended status (which may be 0x0000, but must be
1259+
# there). HOWEVER, there are many (many) devices that DO NOT comply with the subtleties
1260+
# of the ODVA EtherNet/IP CIP spec -- and simply return eg. a status = 0x05 WITHOUT a
1261+
# 0x0000 extended status code. This makes it *impossible* to determine whether or not a
1262+
# Read Tag Fragmented response status belongs to the encapsulating Unconnected Send, or
1263+
# not. The only possible option is for the CLIENT to avoid sending Read Tag Fragmented
1264+
# requests in an Unconnected Send, or use a Multiple Service packet to encapsulate them.
1265+
svc = next( source )
1266+
pad = next( source )
1267+
sts = next( source )
1268+
ext_siz = next( source )
1269+
source.push( ext_siz )
1270+
source.push( sts )
1271+
source.push( pad )
1272+
source.push( svc )
1273+
log.isEnabledFor( logging.DETAIL ) and log.detail(
1274+
"{} -- found an Unconnected Send response error status 0x{:02x}: {}".format(
1275+
self, sts, enip_format( data ))
1276+
)
1277+
return sts < 0x10 and ext_siz == 0
1278+
12511279

12521280
slct[b'\x52'[0]] = usnd
12531281
slct[b'\xD2'[0]] = decide(
12541282
'u_s_err',
1255-
predicate = u_s_err, #lambda path=None, data=None, **kwds: data[path].length == 4,
1283+
predicate = u_s_err,
12561284
state = uerr
12571285
)
12581286
slct[True] = othr = octets( context='request', terminal=True )
@@ -1263,16 +1291,24 @@ def u_s_err( path=None, data=None, **kwds ):
12631291
@classmethod
12641292
def produce( cls, data ):
12651293
result = b''
1266-
if data.get( 'service' ) == 0x52:
1267-
result += USINT.produce( data.service )
1268-
result += EPATH.produce( data.path )
1269-
result += USINT.produce( data.priority )
1270-
result += USINT.produce( data.timeout_ticks )
1271-
result += UINT.produce( len( data.request.input ))
1272-
result += octets_encode( data.request.input )
1294+
service = data.get( 'service' )
1295+
if service == 0x52:
1296+
# An Unconnected Send request
1297+
result += USINT.produce( data.service )
1298+
result += EPATH.produce( data.path )
1299+
result += USINT.produce( data.priority )
1300+
result += USINT.produce( data.timeout_ticks )
1301+
result += UINT.produce( len( data.request.input ))
1302+
result += octets_encode( data.request.input )
12731303
if len( data.request.input ) % 2:
12741304
result += b'\x00'
12751305
result += route_path.produce( data.get( 'route_path', {} ))
1306+
elif service == 0x52|0x80 and data.get( 'status' ):
1307+
# An Unconnected Send response w/ a non-zero status code
1308+
result += USINT.produce( data.service )
1309+
result += b'\x00' # reserved
1310+
result += status.produce( data )
1311+
#TODO: Support optional "path remaining" words
12761312
else:
12771313
# Not an Unconnected Send; just return the encapsulated request.input payload
12781314
result += octets_encode( data.request.input )
@@ -1848,14 +1884,31 @@ class CIP( dfa ):
18481884
(0x0066,): unregister,
18491885
(0x006f,0x0070): send_data, # 0x006f (SendRRData) is default if CIP.send_data seen
18501886
}
1887+
18511888
def __init__( self, name=None, **kwds ):
18521889
name = name or kwds.setdefault( 'context', self.__class__.__name__ )
18531890

18541891
slct = octets_noop( 'sel_CIP' )
18551892
for cmd,cls in self.COMMAND_PARSERS.items():
1856-
slct[None] = decide( cls.__name__,
1857-
state=cls( limit='...length', terminal=True ),
1858-
predicate=lambda path=None, data=None, cmd=cmd, **kwds: data[path+'..command'] in cmd )
1893+
slct[None] = decide(
1894+
cls.__name__,
1895+
state = cls( limit='...length', terminal=True ),
1896+
predicate = lambda path=None, data=None, cmd=cmd, **kwds: data[path+'..command'] in cmd
1897+
)
1898+
1899+
def unrec_CIP( path=None, data=None, source=None, **kwds ):
1900+
log.warning(
1901+
"{} -- unrecognized CIP command in data[{}]: 0x{:04x} in: {}".format(
1902+
self, path+'..command', data.get(path+'..command', 0), enip_format( data ))
1903+
)
1904+
return False
1905+
1906+
# Unrecognized CIP request code? Log a reasonable error
1907+
slct[True] = decide(
1908+
'unrec_CIP',
1909+
state = None,
1910+
predicate = unrec_CIP,
1911+
)
18591912
super( CIP, self ).__init__( name=name, initial=slct, **kwds )
18601913

18611914
@classmethod

server/enip_test.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1884,10 +1884,25 @@ def test_enip_CPF():
18841884
# An empty request (usually indicates termination of session)
18851885
'empty_req', enip.Message_Router, {}
18861886
), (
1887-
# An invalid unconnected_send response; parsed incorrectly as an encapsulted read_frag response, due to the 0x52 request / 0xD2 response type.
1887+
# An invalid unconnected_send response; was parsed incorrectly as an encapsulated
1888+
# read_frag response, due to the 0x52 request / 0xD2 response type.
18881889
'unc_snd_bad', enip.Message_Router,
18891890
{
1890-
"boo": 1,
1891+
'enip.command': 111,
1892+
'enip.length': 20,
1893+
'enip.session_handle': 20,
1894+
'enip.status': 0,
1895+
'enip.options': 0,
1896+
'enip.CIP.send_data.interface': 0,
1897+
'enip.CIP.send_data.timeout': 0,
1898+
'enip.CIP.send_data.CPF.count': 2,
1899+
'enip.CIP.send_data.CPF.item[0].type_id': 0,
1900+
'enip.CIP.send_data.CPF.item[0].length': 0,
1901+
'enip.CIP.send_data.CPF.item[1].type_id': 178,
1902+
'enip.CIP.send_data.CPF.item[1].length': 4,
1903+
'enip.CIP.send_data.CPF.item[1].unconnected_send.service': 210,
1904+
'enip.CIP.send_data.CPF.item[1].unconnected_send.status': 8,
1905+
'enip.CIP.send_data.CPF.item[1].unconnected_send.status_ext.size': 0,
18911906
}
18921907
# ), (
18931908
# # Not a valid unconnected_send.path (symbolic tag name, instead of @6/1); can't reconstruct...
@@ -3498,13 +3513,14 @@ def test_enip_CIP( repeat=1 ):
34983513

34993514
try:
35003515
# First reconstruct any SendRRData CPF items, containing encapsulated requests/responses
3516+
# (pass through any Un/Connected Send error status)
35013517
if 'enip.CIP.send_data' in data:
35023518
cpf = data.enip.CIP.send_data
35033519
for item in cpf.CPF.item:
3504-
if 'unconnected_send' in item:
3520+
if 'unconnected_send.request' in item:
35053521
item.unconnected_send.request.input = bytearray( cls.produce( item.unconnected_send.request ))
35063522
log.detail("Produce %s message from: %r", cls,item.unconnected_send.request )
3507-
elif 'connection_data' in item:
3523+
elif 'connection_data.request' in item:
35083524
item.connection_data.request.input = bytearray( cls.produce( item.connection_data.request ))
35093525
log.detail("Produce %s message from: %r", cls,item.connection_data.request )
35103526

0 commit comments

Comments
 (0)