def test_iterators(): i = cpppo.chaining() j = cpppo.chainable( i ) k = cpppo.chainable( 'abc' ) assert i is j try: next( i ) assert False, "stream with no iterable should raise StopIteration" except StopIteration: pass assert k is not j assert isinstance( k, cpppo.chaining ) assert cpppo.peekable( i ) is i p = cpppo.peekable( '123' ) assert cpppo.peekable( p ) is p assert cpppo.chainable( p ) is not p assert list( p ) == ['1', '2', '3'] assert p.sent == 3 p.push('x') assert p.sent == 2 assert list( p ) == ['x'] assert list( p ) == [] i.chain('abc') i.chain('') i.chain( '123' ) assert list( i ) == ['a','b','c','1','2','3'] assert i.sent == 6 i.chain( 'y' ) i.push( 'x' ) assert list( i ) == ['x','y'] i.chain( None ) try: next( i ) assert False, "Expected TypeError to be raised" except TypeError: pass except Exception as e: assert False, "Expected TypeError, not %r" % ( e ) r = cpppo.rememberable( '123' ) assert next( r ) == '1' assert r.memory == [ '1' ] try: r.push( 'x' ) assert False, "Should have rejected push of inconsistent symbol" except AssertionError: pass assert r.sent == 1 r.push( '1' ) assert r.sent == 0 assert r.memory == [] assert list( r ) == r.memory == [ '1', '2','3' ]
def test_iterators(): i = cpppo.chaining() j = cpppo.chainable(i) k = cpppo.chainable("abc") assert i is j try: next(i) assert False, "stream with no iterable should raise StopIteration" except StopIteration: pass assert k is not j assert isinstance(k, cpppo.chaining) assert cpppo.peekable(i) is i p = cpppo.peekable("123") assert cpppo.peekable(p) is p assert cpppo.chainable(p) is not p assert list(p) == ["1", "2", "3"] assert p.sent == 3 p.push("x") assert p.sent == 2 assert list(p) == ["x"] assert list(p) == [] i.chain("abc") i.chain("") i.chain("123") assert list(i) == ["a", "b", "c", "1", "2", "3"] assert i.sent == 6 i.chain("y") i.push("x") assert list(i) == ["x", "y"] i.chain(None) try: next(i) assert False, "Expected TypeError to be raised" except TypeError: pass except Exception as e: assert False, "Expected TypeError, not %r" % (e) r = cpppo.rememberable("123") assert next(r) == "1" assert r.memory == ["1"] try: r.push("x") assert False, "Should have rejected push of inconsistent symbol" except AssertionError: pass assert r.sent == 1 r.push("1") assert r.sent == 0 assert r.memory == [] assert list(r) == r.memory == ["1", "2", "3"]
def request(self, data): """ Handles an unparsed request.input, parses it and processes the request with the Message Router. """ # We don't check for Unconnected Send 0x52, because replies (and some requests) don't # include the full wrapper, just the raw command. This is quite confusing; especially since # some of the commands have the same code (eg. Read Tag Fragmented, 0x52). Of course, their # replies don't (0x52|0x80 == 0xd2). The CIP.produce recognizes the absence of the # .command, and simply copies the encapsulated request.input as the response payload. We # don't encode the response here; it is done by the UCMM. assert 'request' in data and 'input' in data.request, \ "Unconnected Send message with absent or empty request" if log.isEnabledFor(logging.INFO): log.info("%s Request: %s", self, enip_format(data)) #log.info( "%s Parsing: %s", self, enip_format( data.request )) # Get the Message Router to parse and process the request into a response, producing a # data.request.input encoded response, which we will pass back as our own encoded response. MR = lookup(class_id=0x02, instance_id=1) source = cpppo.rememberable(data.request.input) try: with MR.parser as machine: for i, (m, s) in enumerate( machine.run(path='request', source=source, data=data)): pass #log.detail( "%s #%3d -> %10.10s; next byte %3d: %-10.10r: %s", # machine.name_centered(), i, s, source.sent, source.peek(), # repr( data ) if log.getEffectiveLevel() < logging.DETAIL else reprlib.repr( data )) #log.info( "%s Executing: %s", self, enip_format( data.request )) MR.request(data.request) except: # Parsing failure. We're done. Suck out some remaining input to give us some context. processed = source.sent memory = bytes(bytearray(source.memory)) pos = len(source.memory) future = bytes(bytearray(b for b in source)) where = "at %d total bytes:\n%s\n%s (byte %d)" % ( processed, repr(memory + future), '-' * (len(repr(memory)) - 1) + '^', pos) log.error("EtherNet/IP CIP error %s\n", where) raise if log.isEnabledFor(logging.INFO): log.info("%s Response: %s", self, enip_format(data)) return True
def request( self, data ): """ Handles an unparsed request.input, parses it and processes the request with the Message Router. """ # We don't check for Unconnected Send 0x52, because replies (and some requests) don't # include the full wrapper, just the raw command. This is quite confusing; especially since # some of the commands have the same code (eg. Read Tag Fragmented, 0x52). Of course, their # replies don't (0x52|0x80 == 0xd2). The CIP.produce recognizes the absence of the # .command, and simply copies the encapsulated request.input as the response payload. We # don't encode the response here; it is done by the UCMM. assert 'request' in data and 'input' in data.request, \ "Unconnected Send message with absent or empty request" if log.isEnabledFor( logging.INFO ): log.info( "%s Request: %s", self, enip_format( data )) #log.info( "%s Parsing: %s", self, enip_format( data.request )) # Get the Message Router to parse and process the request into a response, producing a # data.request.input encoded response, which we will pass back as our own encoded response. MR = lookup( class_id=0x02, instance_id=1 ) source = cpppo.rememberable( data.request.input ) try: with MR.parser as machine: for i,(m,s) in enumerate( machine.run( path='request', source=source, data=data )): pass #log.detail( "%s #%3d -> %10.10s; next byte %3d: %-10.10r: %s", # machine.name_centered(), i, s, source.sent, source.peek(), # repr( data ) if log.getEffectiveLevel() < logging.DETAIL else reprlib.repr( data )) #log.info( "%s Executing: %s", self, enip_format( data.request )) MR.request( data.request ) except: # Parsing failure. We're done. Suck out some remaining input to give us some context. processed = source.sent memory = bytes(bytearray(source.memory)) pos = len( source.memory ) future = bytes(bytearray( b for b in source )) where = "at %d total bytes:\n%s\n%s (byte %d)" % ( processed, repr(memory+future), '-' * (len(repr(memory))-1) + '^', pos ) log.error( "EtherNet/IP CIP error %s\n", where ) raise if log.isEnabledFor( logging.INFO ): log.info( "%s Response: %s", self, enip_format( data )) return True
def test_logix_multiple(): """Test the Multiple Request Service. Ensure multiple requests can be successfully handled, and invalid tags are correctly rejected. The Logix is a Message_Router instance, and is expected to be at Class 2, Instance 1. Eject any non-Logix Message_Router that presently exist. """ Obj = enip.device.lookup( enip.device.Message_Router.class_id, instance_id=1 ) if not isinstance( Obj, logix.Logix ): if Obj is not None: del enip.device.directory['2']['1'] Obj = logix.Logix( instance_id=1 ) # Create some Attributes to test, but mask the big ones from Get Attributes All. size = 1000 Obj_a1 = Obj.attribute['1'] = enip.device.Attribute( 'parts', enip.parser.DINT, default=[n for n in range( size )], mask=enip.device.Attribute.MASK_GA_ALL ) Obj_a2 = Obj.attribute['2'] = enip.device.Attribute( 'ControlWord', enip.parser.DINT, default=[0,0]) Obj_a3 = Obj.attribute['3'] = enip.device.Attribute( 'SCADA_40001', enip.parser.INT, default=[n for n in range( size )], mask=enip.device.Attribute.MASK_GA_ALL ) Obj_a4 = Obj.attribute['4'] = enip.device.Attribute( 'number', enip.parser.REAL, default=0.0) # Set up a symbolic tag referencing the Logix Object's Attribute enip.device.symbol['parts'] = {'class': Obj.class_id, 'instance': Obj.instance_id, 'attribute':1 } enip.device.symbol['ControlWord'] \ = {'class': Obj.class_id, 'instance': Obj.instance_id, 'attribute':2 } enip.device.symbol['SCADA_40001'] \ = {'class': Obj.class_id, 'instance': Obj.instance_id, 'attribute':3 } enip.device.symbol['number'] \ = {'class': Obj.class_id, 'instance': Obj.instance_id, 'attribute':4 } assert len( Obj_a1 ) == size assert len( Obj_a3 ) == size assert len( Obj_a4 ) == 1 Obj_a1[0] = 42 Obj_a2[0] = 476 Obj_a4[0] = 1.0 # Ensure that the basic CIP Object requests work on a derived Class. for description,original,produced,parsed,result,response in GA_tests: request = cpppo.dotdict( original ) log.warning( "%s; request: %s", description, enip.enip_format( request )) encoded = Obj.produce( request ) assert encoded == produced, "%s: Didn't produce correct encoded request: %r != %r" % ( description, encoded, produced ) # Now, use the Message_Router's parser to decode the encoded bytes source = cpppo.rememberable( encoded ) decoded = cpppo.dotdict() with Obj.parser as machine: for m,s in enumerate( machine.run( source=source, data=decoded )): pass for k,v in cpppo.dotdict( parsed ).items(): assert decoded[k] == v, "%s: Didn't parse expected value: %s != %r in %s" % ( description, k, v, enip.enip_format( decoded )) # Process the request into a reply, and ensure we get the expected result (some Attributes # are filtered from Get Attributes All; only a 2-element DINT array and a single REAL should # be produced) Obj.request( request ) logging.warning("%s: reply: %s", description, enip.enip_format( request )) for k,v in cpppo.dotdict( result ).items(): assert k in request and request[k] == v, \ "%s: Didn't result in expected response: %s != %r; got %r" % ( description, k, v, request[k] if k in request else "(not found)" ) # Finally, produce the encoded response encoded = Obj.produce( request ) assert encoded == response, "%s: Didn't produce correct encoded response: %r != %r" % ( description, encoded, response ) # Test that we correctly compute beg,end,endactual for various Read Tag Fragmented scenarios, # with 2-byte and 4-byte types. For the purposes of this test, we only look at path...elements. data = cpppo.dotdict() data.service = Obj.RD_FRG_RPY data.path = { 'segment': [ cpppo.dotdict( d ) for d in [ {'element': 0 }, ]] } data.read_frag = {} data.read_frag.elements = 1000 data.read_frag.offset = 0 # Reply maximum size limited beg,end,endactual = Obj.reply_elements( Obj_a1, data, 'read_frag' ) assert beg == 0 and end == 125 and endactual == 1000 # DINT == 4 bytes beg,end,endactual = Obj.reply_elements( Obj_a3, data, 'read_frag' ) assert beg == 0 and end == 250 and endactual == 1000 # INT == 2 bytes data.read_frag.offset = 125*4 # OK, second request; begin after byte offset of first beg,end,endactual = Obj.reply_elements( Obj_a1, data, 'read_frag' ) assert beg == 125 and end == 250 and endactual == 1000 # DINT == 4 bytes # Request elements limited; 0 offset data.read_frag.elements = 30 data.read_frag.offset = 0 beg,end,endactual = Obj.reply_elements( Obj_a3, data, 'read_frag' ) assert beg == 0 and end == 30 and endactual == 30 # INT == 2 bytes # Request elements limited; +'ve offset data.read_frag.elements = 70 data.read_frag.offset = 80 beg,end,endactual = Obj.reply_elements( Obj_a3, data, 'read_frag' ) assert beg == 40 and end == 70 and endactual == 70 # INT == 2 bytes # Request limited by size of data provided (Write Tag [Fragmented]) data = cpppo.dotdict() data.service = Obj.WR_FRG_RPY data.path = { 'segment': [ cpppo.dotdict( d ) for d in [ {'element': 0 }, ]] } data.write_frag = {} data.write_frag.data = [0] * 100 # 100 elements provided in this request data.write_frag.elements = 200 # Total request is to write 200 elements data.write_frag.offset = 16 # request starts 16 bytes in (8 INTs) beg,end,endactual = Obj.reply_elements( Obj_a3, data, 'write_frag' ) assert beg == 8 and end == 108 and endactual == 200 # INT == 2 bytes # ... same, but lets say request started somewhere in the middle of the array data.path = { 'segment': [ cpppo.dotdict( d ) for d in [ {'element': 222 }, ]] } beg,end,endactual = Obj.reply_elements( Obj_a3, data, 'write_frag' ) assert beg == 8+222 and end == 108+222 and endactual == 200+222 # INT == 2 bytes # Ensure correct computation of (beg,end] that are byte-offset and data/size limited data = cpppo.dotdict() data.service = Obj.WR_FRG_RPY data.path = { 'segment': [] } data.write_frag = {} data.write_frag.data = [3,4,5,6] data.write_frag.offset = 6 beg,end,endactual = Obj.reply_elements( Obj_a3, data, 'write_frag' ) assert beg == 3 and end == 7 and endactual == 1000 # INT == 2 bytes # Trigger the error cases only accessible via write # Too many elements provided for attribute capacity data.write_frag.offset = ( 1000 - 3 ) * 2 try: beg,end,endactual = Obj.reply_elements( Obj_a3, data, 'write_frag' ) assert False, "Should have raised Exception due to capacity" except Exception as exc: assert "capacity exceeded" in str( exc ) data = cpppo.dotdict() data.service = Obj.RD_FRG_RPY data.path = { 'segment': [] } data.read_frag = {} data.read_frag.offset = 6 beg,end,endactual = Obj.reply_elements( Obj_a3, data, 'read_frag' ) assert beg == 3 and end == 253 and endactual == 1000 # INT == 2 bytes # And we should be able to read with an offset right up to the last element data.read_frag.offset = 1998 beg,end,endactual = Obj.reply_elements( Obj_a3, data, 'read_frag' ) assert beg == 999 and end == 1000 and endactual == 1000 # INT == 2 bytes # Trigger all the remaining error cases # Unknown service data.service = Obj.RD_FRG_REQ try: beg,end,endactual = Obj.reply_elements( Obj_a3, data, 'read_frag' ) assert False, "Should have raised Exception due to service" except Exception as exc: assert "unknown service" in str( exc ) # Offset indivisible by element size data.service = Obj.RD_FRG_RPY data.read_frag.offset = 7 try: beg,end,endactual = Obj.reply_elements( Obj_a3, data, 'read_frag' ) assert False, "Should have raised Exception due to odd byte offset" except Exception as exc: assert "element boundary" in str( exc ) # Initial element outside bounds data.read_frag.offset = 2000 try: beg,end,endactual = Obj.reply_elements( Obj_a3, data, 'read_frag' ) assert False, "Should have raised Exception due to initial element" except Exception as exc: assert "initial element invalid" in str( exc ) # Ending element outside bounds data.read_frag.offset = 0 data.read_frag.elements = 1001 try: beg,end,endactual = Obj.reply_elements( Obj_a3, data, 'read_frag' ) assert False, "Should have raised Exception due to ending element" except Exception as exc: assert "ending element invalid" in str( exc ) # Beginning element after ending (should be no way to trigger). This request doesn't specify an # element in the path, hence defaults to element 0, and asks for a number of elements == 2. # Thus, there is no 6-byte offset possible (a 2-byte offset is, though). data.read_frag.offset = 6 data.read_frag.elements = 2 try: beg,end,endactual = Obj.reply_elements( Obj_a3, data, 'read_frag' ) assert False, "Should have raised Exception due to ending element order" except Exception as exc: assert "ending element before beginning" in str( exc ) data.read_frag.offset = 2 data.read_frag.elements = 2 beg,end,endactual = Obj.reply_elements( Obj_a3, data, 'read_frag' ) assert beg == 1 and end == 2 and endactual == 2 # INT == 2 bytes # Test an example valid multiple request data = cpppo.dotdict() data.multiple = {} data.multiple.request = [ cpppo.dotdict(), cpppo.dotdict(), cpppo.dotdict(), cpppo.dotdict(), cpppo.dotdict() ] req = data.multiple.request req[0].path = { 'segment': [ cpppo.dotdict( d ) for d in [{'symbolic': 'parts'}]] } req[0].read_tag = {} req[0].read_tag.elements = 1 req[1].path = { 'segment': [ cpppo.dotdict( d ) for d in [{'symbolic': 'ControlWord'}]] } req[1].read_tag = {} req[1].read_tag.elements = 1 req[2].path = { 'segment': [ cpppo.dotdict( d ) for d in [{'symbolic': 'number'}]] } req[2].read_tag = {} req[2].read_tag.elements = 1 req[3].path = { 'segment': [ cpppo.dotdict( d ) for d in [{'symbolic': 'number'}]] } req[3].write_tag = {} req[3].write_tag.elements = 1 req[3].write_tag.type = 0x00ca req[3].write_tag.data = [1.25] req[4].path = { 'segment': [ cpppo.dotdict( d ) for d in [{'symbolic': 'number'}]] } req[4].read_tag = {} req[4].read_tag.elements = 1 request = Obj.produce( data ) req_1 = bytes(bytearray([ 0x0A, 0x02, 0x20, 0x02, 0x24, 0x01, 0x05, 0x00, 0x0c, 0x00, 0x18, 0x00, 0x2a, 0x00, 0x36, 0x00, 0x48, 0x00, 0x4C, 0x04, 0x91, 0x05, 0x70, 0x61, 0x72, 0x74, 0x73, 0x00, 0x01, 0x00, 0x4C, 0x07, 0x91, 0x0B, 0x43, 0x6F, 0x6E, 0x74, 0x72, 0x6F, 0x6C, 0x57, 0x6F, 0x72, 0x64, 0x00, 0x01, 0x00, b'L'[0], 0x04, 0x91, 0x06, b'n'[0], b'u'[0], b'm'[0], b'b'[0], b'e'[0], b'r'[0], 0x01, 0x00, b'M'[0], 0x04, 0x91, 0x06, b'n'[0], b'u'[0], b'm'[0], b'b'[0], b'e'[0], b'r'[0], 0xca, 0x00, 0x01, 0x00, 0x00, 0x00, 0xa0, 0x3f, b'L'[0], 0x04, 0x91, 0x06, b'n'[0], b'u'[0], b'm'[0], b'b'[0], b'e'[0], b'r'[0], 0x01, 0x00, ])) assert request == req_1, \ "Unexpected result from Multiple Request Service; got: \n%r\nvs.\n%r " % ( request, req_1 ) # Now, use the Message_Router's parser source = cpppo.rememberable( request ) data = cpppo.dotdict() with Obj.parser as machine: for i,(m,s) in enumerate( machine.run( source=source, data=data )): pass log.normal( "Multiple Request: %s", enip.enip_format( data )) assert 'multiple' in data, \ "No parsed multiple found in data: %s" % enip.enip_format( data ) assert data.service == enip.device.Message_Router.MULTIPLE_REQ, \ "Expected a Multiple Request Service request: %s" % enip.enip_format( data ) assert data.multiple.number == 5, \ "Expected 5 requests in request.multiple: %s" % enip.enip_format( data ) # And ensure if we re-encode the parsed result, we get the original encoded request back assert Obj.produce( data ) == req_1 # Process the request into a reply. Obj.request( data ) log.normal( "Multiple Response: %s", enip.enip_format( data )) assert data.service == enip.device.Message_Router.MULTIPLE_RPY, \ "Expected a Multiple Request Service reply: %s" % enip.enip_format( data ) rpy_1 = bytearray([ 0x8A, 0x00, 0x00, 0x00, 0x05, 0x00, 0x0c, 0x00, 0x16, 0x00, 0x20, 0x00, 0x2a, 0x00, 0x2e, 0x00, 0xCC, 0x00, 0x00, 0x00, 0xC4, 0x00, 0x2A, 0x00, 0x00, 0x00, 0xCC, 0x00, 0x00, 0x00, 0xC4, 0x00, 0xDC, 0x01, 0x00, 0x00, 0xCC, 0x00, 0x00, 0x00, 0xCA, 0x00, 0x00, 0x00, 0x80, 0x3F, 0xcd, 0x00, 0x00, 0x00, 0xcc, 0x00, 0x00, 0x00, 0xca, 0x00, 0x00, 0x00, 0xa0, 0x3f, ]) assert data.input == rpy_1, \ "Unexpected reply from Multiple Request Service request; got: \n%r\nvs.\n%r " % ( data.input, rpy_1 ) # Now lets try some valid and invalid requests data = cpppo.dotdict() data.multiple = {} data.multiple.request = req = [ cpppo.dotdict() ] req[0].path = { 'segment': [ cpppo.dotdict( d ) for d in [{'symbolic': 'SCADA_40001'}]] } req[0].read_tag = {} req[0].read_tag.elements = 1 data.multiple.number = len( data.multiple.request ) request = Obj.produce( data ) req_good = bytearray([ 0x0A, 0x02, 0x20, 0x02, ord('$'), 0x01, 0x01, 0x00, 0x04, 0x00, 0x4C, 0x07, 0x91, 0x0b, ord('S'), ord('C'), ord('A'), ord('D'), ord('A'), ord('_'), ord('4'), ord('0'), ord('0'), ord('0'), ord('1'), 0x00, 0x01, 0x00, ]) assert request == req_good, \ "Unexpected result from Multiple Request Service request for SCADA_40001; got: \n%r\nvs.\n%r " % ( request, req_good ) Obj.request( data ) rpy_good = bytearray([ 0x8A, 0x00, 0x00, 0x00, 0x01, 0x00, 0x04, 0x00, 0xCC, 0x00, 0x00, 0x00, 0xC3, 0x00, 0x00, 0x00, ]) assert data.input == rpy_good, \ "Unexpected reply from Multiple Request Service request for SCADA_40001; got: \n%r\nvs.\n%r " % ( data.input, rpy_good ) # Add an invalid request data = cpppo.dotdict() data.multiple = {} data.multiple.request = req = [ cpppo.dotdict(), cpppo.dotdict() ] req[0].path = { 'segment': [ cpppo.dotdict( d ) for d in [{'symbolic': 'SCADA_40001'}]] } req[0].read_tag = {} req[0].read_tag.elements = 1 req[1].path = { 'segment': [ cpppo.dotdict( d ) for d in [{'symbolic': 'SCADA_40002'}]] } req[1].read_tag = {} req[1].read_tag.elements = 1 data.multiple.number = len( data.multiple.request ) request = Obj.produce( data ) req_bad = bytearray([ 0x0A, 0x02, 0x20, 0x02, ord('$'), 0x01, 0x02, 0x00, 0x06, 0x00, 0x18, 0x00, 0x4C, 0x07, 0x91, 0x0b, ord('S'), ord('C'), ord('A'), ord('D'), ord('A'), ord('_'), ord('4'), ord('0'), ord('0'), ord('0'), ord('1'), 0x00, 0x01, 0x00, 0x4C, 0x07, 0x91, 0x0b, ord('S'), ord('C'), ord('A'), ord('D'), ord('A'), ord('_'), ord('4'), ord('0'), ord('0'), ord('0'), ord('2'), 0x00, 0x01, 0x00, ]) assert request == req_bad, \ "Unexpected result from Multiple Request Service request for SCADA_40001/2; got: \n%r\nvs.\n%r " % ( request, req_bad ) Obj.request( data ) rpy_bad = bytearray([ 0x8A, 0x00, 0x00, 0x00, 0x02, 0x00, 0x06, 0x00, 0x0e, 0x00, 0xCC, 0x00, 0x00, 0x00, 0xC3, 0x00, 0x00, 0x00, 0xCC, 0x00, 0x05, 0x01, # Status code 0x05 (invalid path) 0x00, 0x00, ]) assert data.input == rpy_bad, \ "Unexpected reply from Multiple Request Service request for SCADA_40001/2; got: \n%r\nvs.\n%r " % ( data.input, rpy_bad )
def enip_srv( conn, addr, enip_process=None, delay=None, **kwds ): """Serve one Ethernet/IP client 'til EOF; then close the socket. Parses headers and encapsulated EtherNet/IP request data 'til either the parser fails (the Client has submitted an un-parsable request), or the request handler fails. Otherwise, encodes the data.response in an EtherNet/IP packet and sends it back to the client. Use the supplied enip_process function to process each parsed EtherNet/IP frame, returning True if a data.response is formulated, False if the session has ended cleanly, or raise an Exception if there is a processing failure (eg. an unparsable request, indicating that the Client is speaking an unknown dialect and the session must close catastrophically.) If a partial EtherNet/IP header is parsed and an EOF is received, the enip_header parser will raise an AssertionError, and we'll simply drop the connection. If we receive a valid header and request, the supplied enip_process function is expected to formulate an appropriate error response, and we'll continue processing requests. An option numeric delay value (or any delay object with a .value attribute evaluating to a numeric value) may be specified; every response will be delayed by the specified number of seconds. We assume that such a value may be altered over time, so we access it afresh for each use. All remaining keywords are passed along to the supplied enip_process function. """ global latency global timeout name = "enip_%s" % addr[1] log.normal( "EtherNet/IP Server %s begins serving peer %s", name, addr ) source = cpppo.rememberable() with parser.enip_machine( name=name, context='enip' ) as enip_mesg: # We can be provided a dotdict() to contain our stats. If one has been passed in, then this # means that our stats for this connection will be available to the web API; it may set # stats.eof to True at any time, terminating the connection! The web API will try to coerce # its input into the same type as the variable, so we'll keep it an int (type bool doesn't # handle coercion from strings). We'll use an apidict, to ensure that attribute values set # via the web API thread (eg. stats.eof) are blocking 'til this thread wakes up and reads # them. Thus, the web API will block setting .eof, and won't return to the caller until the # thread is actually in the process of shutting down. Internally, we'll use __setitem__ # indexing to change stats values, so we don't block ourself! stats = cpppo.apidict( timeout=timeout ) connkey = ( "%s_%d" % addr ).replace( '.', '_' ) connections[connkey] = stats try: assert enip_process is not None, \ "Must specify an EtherNet/IP processing function via 'enip_process'" stats['requests'] = 0 stats['received'] = 0 stats['eof'] = False stats['interface'] = addr[0] stats['port'] = addr[1] while not stats.eof: data = cpppo.dotdict() source.forget() # If no/partial EtherNet/IP header received, parsing will fail with a NonTerminal # Exception (dfa exits in non-terminal state). Build data.request.enip: begun = cpppo.timer() log.detail( "Transaction begins" ) for mch,sta in enip_mesg.run( path='request', source=source, data=data ): if sta is None: # No more transitions available. Wait for input. EOF (b'') will lead to # termination. We will simulate non-blocking by looping on None (so we can # check our options, in case they've been changed). If we still have input # available to process right now in 'source', we'll just check (0 timeout); # otherwise, use the specified server.control.latency. msg = None while msg is None and not stats.eof: wait=( kwds['server']['control']['latency'] if source.peek() is None else 0 ) brx = cpppo.timer() msg = network.recv( conn, timeout=wait ) now = cpppo.timer() log.detail( "Transaction receive after %7.3fs (%5s bytes in %7.3f/%7.3fs)" % ( now - begun, len( msg ) if msg is not None else "None", now - brx, wait )) # After each block of input (or None), check if the server is being # signalled done/disabled; we need to shut down so signal eof. Assumes # that (shared) server.control.{done,disable} dotdict be in kwds. We do # *not* read using attributes here, to avoid reporting completion to # external APIs (eg. web) awaiting reception of these signals. if kwds['server']['control']['done'] or kwds['server']['control']['disable']: log.detail( "%s done, due to server done/disable", enip_mesg.name_centered() ) stats['eof'] = True if msg is not None: stats['received']+= len( msg ) stats['eof'] = stats['eof'] or not len( msg ) log.detail( "%s recv: %5d: %s", enip_mesg.name_centered(), len( msg ) if msg is not None else 0, cpppo.reprlib.repr( msg )) source.chain( msg ) else: # No input. If we have symbols available, no problem; continue. # This can occur if the state machine cannot make a transition on # the input symbol, indicating an unacceptable sentence for the # grammar. If it cannot make progress, the machine will terminate # in a non-terminal state, rejecting the sentence. if source.peek() is not None: break # We're at a None (can't proceed), and no input is available. This # is where we implement "Blocking"; just loop. log.detail( "Transaction parsed after %7.3fs" % ( cpppo.timer() - begun )) # Terminal state and EtherNet/IP header recognized, or clean EOF (no partial # message); process and return response if 'request' in data: stats['requests'] += 1 try: # enip_process must be able to handle no request (empty data), indicating the # clean termination of the session if closed from this end (not required if # enip_process returned False, indicating the connection was terminated by # request.) delayseconds= 0 # response delay (if any) if enip_process( addr, data=data, **kwds ): # Produce an EtherNet/IP response carrying the encapsulated response data. # If no encapsulated data, ensure we also return a non-zero EtherNet/IP # status. A non-zero status indicates the end of the session. assert 'response.enip' in data, "Expected EtherNet/IP response; none found" if 'input' not in data.response.enip or not data.response.enip.input: log.warning( "Expected EtherNet/IP response encapsulated message; none found" ) assert data.response.enip.status, "If no/empty response payload, expected non-zero EtherNet/IP status" rpy = parser.enip_encode( data.response.enip ) log.detail( "%s send: %5d: %s %s", enip_mesg.name_centered(), len( rpy ), cpppo.reprlib.repr( rpy ), ("delay: %r" % delay) if delay else "" ) if delay: # A delay (anything with a delay.value attribute) == #[.#] (converible # to float) is ok; may be changed via web interface. try: delayseconds = float( delay.value if hasattr( delay, 'value' ) else delay ) if delayseconds > 0: time.sleep( delayseconds ) except Exception as exc: log.detail( "Unable to delay; invalid seconds: %r", delay ) try: conn.send( rpy ) except socket.error as exc: log.detail( "Session ended (client abandoned): %s", exc ) stats['eof'] = True if data.response.enip.status: log.warning( "Session ended (server EtherNet/IP status: 0x%02x == %d)", data.response.enip.status, data.response.enip.status ) stats['eof'] = True else: # Session terminated. No response, just drop connection. log.detail( "Session ended (client initiated): %s", parser.enip_format( data )) stats['eof'] = True log.detail( "Transaction complete after %7.3fs (w/ %7.3fs delay)" % ( cpppo.timer() - begun, delayseconds )) except: log.error( "Failed request: %s", parser.enip_format( data )) enip_process( addr, data=cpppo.dotdict() ) # Terminate. raise stats['processed'] = source.sent except: # Parsing failure. We're done. Suck out some remaining input to give us some context. stats['processed'] = source.sent memory = bytes(bytearray(source.memory)) pos = len( source.memory ) future = bytes(bytearray( b for b in source )) where = "at %d total bytes:\n%s\n%s (byte %d)" % ( stats.processed, repr(memory+future), '-' * (len(repr(memory))-1) + '^', pos ) log.error( "EtherNet/IP error %s\n\nFailed with exception:\n%s\n", where, ''.join( traceback.format_exception( *sys.exc_info() ))) raise finally: # Not strictly necessary to close (network.server_main will discard the socket, # implicitly closing it), but we'll do it explicitly here in case the thread doesn't die # for some other reason. Clean up the connections entry for this connection address. connections.pop( connkey, None ) log.normal( "%s done; processed %3d request%s over %5d byte%s/%5d received (%d connections remain)", name, stats.requests, " " if stats.requests == 1 else "s", stats.processed, " " if stats.processed == 1 else "s", stats.received, len( connections )) sys.stdout.flush() conn.close()
def test_logix_multiple(): """Test the Multiple Request Service. Ensure multiple requests can be successfully handled, and invalid tags are correctly rejected. The Logix is a Message_Router instance, and is expected to be at Class 2, Instance 1. Eject any non-Logix Message_Router that presently exist. """ enip.lookup_reset() # Flush out any existing CIP Objects for a fresh start Obj = logix.Logix(instance_id=1) # Create some Attributes to test, but mask the big ones from Get Attributes All. size = 1000 Obj_a1 = Obj.attribute['1'] = enip.device.Attribute( 'parts', enip.parser.DINT, default=[n for n in range(size)], mask=enip.device.Attribute.MASK_GA_ALL) Obj_a2 = Obj.attribute['2'] = enip.device.Attribute('ControlWord', enip.parser.DINT, default=[0, 0]) Obj_a3 = Obj.attribute['3'] = enip.device.Attribute( 'SCADA_40001', enip.parser.INT, default=[n for n in range(size)], mask=enip.device.Attribute.MASK_GA_ALL) Obj_a4 = Obj.attribute['4'] = enip.device.Attribute('number', enip.parser.REAL, default=0.0) # Set up a symbolic tag referencing the Logix Object's Attribute enip.device.symbol['parts'] = { 'class': Obj.class_id, 'instance': Obj.instance_id, 'attribute': 1 } enip.device.symbol['ControlWord'] \ = {'class': Obj.class_id, 'instance': Obj.instance_id, 'attribute':2 } enip.device.symbol['SCADA_40001'] \ = {'class': Obj.class_id, 'instance': Obj.instance_id, 'attribute':3 } enip.device.symbol['number'] \ = {'class': Obj.class_id, 'instance': Obj.instance_id, 'attribute':4 } assert len(Obj_a1) == size assert len(Obj_a3) == size assert len(Obj_a4) == 1 Obj_a1[0] = 42 Obj_a2[0] = 476 Obj_a4[0] = 1.0 # Ensure that the basic CIP Object requests work on a derived Class. for description, original, produced, parsed, result, response in GA_tests: request = cpppo.dotdict(original) log.warning("%s; request: %s", description, enip.enip_format(request)) encoded = Obj.produce(request) assert encoded == produced, "%s: Didn't produce correct encoded request: %r != %r" % ( description, encoded, produced) # Now, use the Message_Router's parser to decode the encoded bytes source = cpppo.rememberable(encoded) decoded = cpppo.dotdict() with Obj.parser as machine: for m, s in enumerate(machine.run(source=source, data=decoded)): pass for k, v in cpppo.dotdict(parsed).items(): assert decoded[ k] == v, "%s: Didn't parse expected value: %s != %r in %s" % ( description, k, v, enip.enip_format(decoded)) # Process the request into a reply, and ensure we get the expected result (some Attributes # are filtered from Get Attributes All; only a 2-element DINT array and a single REAL should # be produced) Obj.request(request) logging.warning("%s: reply: %s", description, enip.enip_format(request)) for k, v in cpppo.dotdict(result).items(): assert k in request and request[k] == v, \ "%s: Didn't result in expected response: %s != %r; got %r" % ( description, k, v, request[k] if k in request else "(not found)" ) # Finally, produce the encoded response encoded = Obj.produce(request) assert encoded == response, "%s: Didn't produce correct encoded response: %r != %r" % ( description, encoded, response) # Test that we correctly compute beg,end,endactual for various Read Tag Fragmented scenarios, # with 2-byte and 4-byte types. For the purposes of this test, we only look at path...elements. data = cpppo.dotdict() data.service = Obj.RD_FRG_RPY data.path = { 'segment': [cpppo.dotdict(d) for d in [ { 'element': 0 }, ]] } data.read_frag = {} data.read_frag.elements = 1000 data.read_frag.offset = 0 # Reply maximum size limited beg, end, endactual = Obj.reply_elements(Obj_a1, data, 'read_frag') assert beg == 0 and end == 125 and endactual == 1000 # DINT == 4 bytes beg, end, endactual = Obj.reply_elements(Obj_a3, data, 'read_frag') assert beg == 0 and end == 250 and endactual == 1000 # INT == 2 bytes data.read_frag.offset = 125 * 4 # OK, second request; begin after byte offset of first beg, end, endactual = Obj.reply_elements(Obj_a1, data, 'read_frag') assert beg == 125 and end == 250 and endactual == 1000 # DINT == 4 bytes # Request elements limited; 0 offset data.read_frag.elements = 30 data.read_frag.offset = 0 beg, end, endactual = Obj.reply_elements(Obj_a3, data, 'read_frag') assert beg == 0 and end == 30 and endactual == 30 # INT == 2 bytes # Request elements limited; +'ve offset data.read_frag.elements = 70 data.read_frag.offset = 80 beg, end, endactual = Obj.reply_elements(Obj_a3, data, 'read_frag') assert beg == 40 and end == 70 and endactual == 70 # INT == 2 bytes # Request limited by size of data provided (Write Tag [Fragmented]) data = cpppo.dotdict() data.service = Obj.WR_FRG_RPY data.path = { 'segment': [cpppo.dotdict(d) for d in [ { 'element': 0 }, ]] } data.write_frag = {} data.write_frag.data = [0] * 100 # 100 elements provided in this request data.write_frag.elements = 200 # Total request is to write 200 elements data.write_frag.offset = 16 # request starts 16 bytes in (8 INTs) beg, end, endactual = Obj.reply_elements(Obj_a3, data, 'write_frag') assert beg == 8 and end == 108 and endactual == 200 # INT == 2 bytes # ... same, but lets say request started somewhere in the middle of the array data.path = { 'segment': [cpppo.dotdict(d) for d in [ { 'element': 222 }, ]] } beg, end, endactual = Obj.reply_elements(Obj_a3, data, 'write_frag') assert beg == 8 + 222 and end == 108 + 222 and endactual == 200 + 222 # INT == 2 bytes # Ensure correct computation of (beg,end] that are byte-offset and data/size limited data = cpppo.dotdict() data.service = Obj.WR_FRG_RPY data.path = {'segment': []} data.write_frag = {} data.write_frag.data = [3, 4, 5, 6] data.write_frag.offset = 6 beg, end, endactual = Obj.reply_elements(Obj_a3, data, 'write_frag') assert beg == 3 and end == 7 and endactual == 1000 # INT == 2 bytes # Trigger the error cases only accessible via write # Too many elements provided for attribute capacity data.write_frag.offset = (1000 - 3) * 2 try: beg, end, endactual = Obj.reply_elements(Obj_a3, data, 'write_frag') assert False, "Should have raised Exception due to capacity" except Exception as exc: assert "capacity exceeded" in str(exc) data = cpppo.dotdict() data.service = Obj.RD_FRG_RPY data.path = {'segment': []} data.read_frag = {} data.read_frag.offset = 6 beg, end, endactual = Obj.reply_elements(Obj_a3, data, 'read_frag') assert beg == 3 and end == 253 and endactual == 1000 # INT == 2 bytes # And we should be able to read with an offset right up to the last element data.read_frag.offset = 1998 beg, end, endactual = Obj.reply_elements(Obj_a3, data, 'read_frag') assert beg == 999 and end == 1000 and endactual == 1000 # INT == 2 bytes # Trigger all the remaining error cases # Unknown service data.service = Obj.RD_FRG_REQ try: beg, end, endactual = Obj.reply_elements(Obj_a3, data, 'read_frag') assert False, "Should have raised Exception due to service" except Exception as exc: assert "unknown service" in str(exc) # Offset indivisible by element size data.service = Obj.RD_FRG_RPY data.read_frag.offset = 7 try: beg, end, endactual = Obj.reply_elements(Obj_a3, data, 'read_frag') assert False, "Should have raised Exception due to odd byte offset" except Exception as exc: assert "element boundary" in str(exc) # Initial element outside bounds data.read_frag.offset = 2000 try: beg, end, endactual = Obj.reply_elements(Obj_a3, data, 'read_frag') assert False, "Should have raised Exception due to initial element" except Exception as exc: assert "initial element invalid" in str(exc) # Ending element outside bounds data.read_frag.offset = 0 data.read_frag.elements = 1001 try: beg, end, endactual = Obj.reply_elements(Obj_a3, data, 'read_frag') assert False, "Should have raised Exception due to ending element" except Exception as exc: assert "ending element invalid" in str(exc) # Beginning element after ending (should be no way to trigger). This request doesn't specify an # element in the path, hence defaults to element 0, and asks for a number of elements == 2. # Thus, there is no 6-byte offset possible (a 2-byte offset is, though). data.read_frag.offset = 6 data.read_frag.elements = 2 try: beg, end, endactual = Obj.reply_elements(Obj_a3, data, 'read_frag') assert False, "Should have raised Exception due to ending element order" except Exception as exc: assert "ending element before beginning" in str(exc) data.read_frag.offset = 2 data.read_frag.elements = 2 beg, end, endactual = Obj.reply_elements(Obj_a3, data, 'read_frag') assert beg == 1 and end == 2 and endactual == 2 # INT == 2 bytes # Test an example valid multiple request data = cpppo.dotdict() data.multiple = {} data.multiple.request = [ cpppo.dotdict(), cpppo.dotdict(), cpppo.dotdict(), cpppo.dotdict(), cpppo.dotdict() ] req = data.multiple.request req[0].path = { 'segment': [cpppo.dotdict(d) for d in [{ 'symbolic': 'parts' }]] } req[0].read_tag = {} req[0].read_tag.elements = 1 req[1].path = { 'segment': [cpppo.dotdict(d) for d in [{ 'symbolic': 'ControlWord' }]] } req[1].read_tag = {} req[1].read_tag.elements = 1 req[2].path = { 'segment': [cpppo.dotdict(d) for d in [{ 'symbolic': 'number' }]] } req[2].read_tag = {} req[2].read_tag.elements = 1 req[3].path = { 'segment': [cpppo.dotdict(d) for d in [{ 'symbolic': 'number' }]] } req[3].write_tag = {} req[3].write_tag.elements = 1 req[3].write_tag.type = 0x00ca req[3].write_tag.data = [1.25] req[4].path = { 'segment': [cpppo.dotdict(d) for d in [{ 'symbolic': 'number' }]] } req[4].read_tag = {} req[4].read_tag.elements = 1 request = Obj.produce(data) req_1 = bytes( bytearray([ 0x0A, 0x02, 0x20, 0x02, 0x24, 0x01, 0x05, 0x00, 0x0c, 0x00, 0x18, 0x00, 0x2a, 0x00, 0x36, 0x00, 0x48, 0x00, 0x4C, 0x04, 0x91, 0x05, 0x70, 0x61, 0x72, 0x74, 0x73, 0x00, 0x01, 0x00, 0x4C, 0x07, 0x91, 0x0B, 0x43, 0x6F, 0x6E, 0x74, 0x72, 0x6F, 0x6C, 0x57, 0x6F, 0x72, 0x64, 0x00, 0x01, 0x00, b'L'[0], 0x04, 0x91, 0x06, b'n'[0], b'u'[0], b'm'[0], b'b'[0], b'e'[0], b'r'[0], 0x01, 0x00, b'M'[0], 0x04, 0x91, 0x06, b'n'[0], b'u'[0], b'm'[0], b'b'[0], b'e'[0], b'r'[0], 0xca, 0x00, 0x01, 0x00, 0x00, 0x00, 0xa0, 0x3f, b'L'[0], 0x04, 0x91, 0x06, b'n'[0], b'u'[0], b'm'[0], b'b'[0], b'e'[0], b'r'[0], 0x01, 0x00, ])) assert request == req_1, \ "Unexpected result from Multiple Request Service; got: \n%r\nvs.\n%r " % ( request, req_1 ) # Now, use the Message_Router's parser source = cpppo.rememberable(request) data = cpppo.dotdict() with Obj.parser as machine: for i, (m, s) in enumerate(machine.run(source=source, data=data)): pass log.normal("Multiple Request: %s", enip.enip_format(data)) assert 'multiple' in data, \ "No parsed multiple found in data: %s" % enip.enip_format( data ) assert data.service == enip.device.Message_Router.MULTIPLE_REQ, \ "Expected a Multiple Request Service request: %s" % enip.enip_format( data ) assert data.multiple.number == 5, \ "Expected 5 requests in request.multiple: %s" % enip.enip_format( data ) # And ensure if we re-encode the parsed result, we get the original encoded request back assert Obj.produce(data) == req_1 # Process the request into a reply. Obj.request(data) log.normal("Multiple Response: %s", enip.enip_format(data)) assert data.service == enip.device.Message_Router.MULTIPLE_RPY, \ "Expected a Multiple Request Service reply: %s" % enip.enip_format( data ) rpy_1 = bytearray([ 0x8A, 0x00, 0x00, 0x00, 0x05, 0x00, 0x0c, 0x00, 0x16, 0x00, 0x20, 0x00, 0x2a, 0x00, 0x2e, 0x00, 0xCC, 0x00, 0x00, 0x00, 0xC4, 0x00, 0x2A, 0x00, 0x00, 0x00, 0xCC, 0x00, 0x00, 0x00, 0xC4, 0x00, 0xDC, 0x01, 0x00, 0x00, 0xCC, 0x00, 0x00, 0x00, 0xCA, 0x00, 0x00, 0x00, 0x80, 0x3F, 0xcd, 0x00, 0x00, 0x00, 0xcc, 0x00, 0x00, 0x00, 0xca, 0x00, 0x00, 0x00, 0xa0, 0x3f, ]) assert data.input == rpy_1, \ "Unexpected reply from Multiple Request Service request; got: \n%r\nvs.\n%r " % ( data.input, rpy_1 ) # Now lets try some valid and invalid requests data = cpppo.dotdict() data.multiple = {} data.multiple.request = req = [cpppo.dotdict()] req[0].path = { 'segment': [cpppo.dotdict(d) for d in [{ 'symbolic': 'SCADA_40001' }]] } req[0].read_tag = {} req[0].read_tag.elements = 1 data.multiple.number = len(data.multiple.request) request = Obj.produce(data) req_good = bytearray([ 0x0A, 0x02, 0x20, 0x02, ord('$'), 0x01, 0x01, 0x00, 0x04, 0x00, 0x4C, 0x07, 0x91, 0x0b, ord('S'), ord('C'), ord('A'), ord('D'), ord('A'), ord('_'), ord('4'), ord('0'), ord('0'), ord('0'), ord('1'), 0x00, 0x01, 0x00, ]) assert request == req_good, \ "Unexpected result from Multiple Request Service request for SCADA_40001; got: \n%r\nvs.\n%r " % ( request, req_good ) Obj.request(data) rpy_good = bytearray([ 0x8A, 0x00, 0x00, 0x00, 0x01, 0x00, 0x04, 0x00, 0xCC, 0x00, 0x00, 0x00, 0xC3, 0x00, 0x00, 0x00, ]) assert data.input == rpy_good, \ "Unexpected reply from Multiple Request Service request for SCADA_40001; got: \n%r\nvs.\n%r " % ( data.input, rpy_good ) # Add an invalid request data = cpppo.dotdict() data.multiple = {} data.multiple.request = req = [cpppo.dotdict(), cpppo.dotdict()] req[0].path = { 'segment': [cpppo.dotdict(d) for d in [{ 'symbolic': 'SCADA_40001' }]] } req[0].read_tag = {} req[0].read_tag.elements = 1 req[1].path = { 'segment': [cpppo.dotdict(d) for d in [{ 'symbolic': 'SCADA_40002' }]] } req[1].read_tag = {} req[1].read_tag.elements = 1 data.multiple.number = len(data.multiple.request) request = Obj.produce(data) req_bad = bytearray([ 0x0A, 0x02, 0x20, 0x02, ord('$'), 0x01, 0x02, 0x00, 0x06, 0x00, 0x18, 0x00, 0x4C, 0x07, 0x91, 0x0b, ord('S'), ord('C'), ord('A'), ord('D'), ord('A'), ord('_'), ord('4'), ord('0'), ord('0'), ord('0'), ord('1'), 0x00, 0x01, 0x00, 0x4C, 0x07, 0x91, 0x0b, ord('S'), ord('C'), ord('A'), ord('D'), ord('A'), ord('_'), ord('4'), ord('0'), ord('0'), ord('0'), ord('2'), 0x00, 0x01, 0x00, ]) assert request == req_bad, \ "Unexpected result from Multiple Request Service request for SCADA_40001/2; got: \n%r\nvs.\n%r " % ( request, req_bad ) Obj.request(data) rpy_bad = bytearray([ 0x8A, 0x00, 0x00, 0x00, 0x02, 0x00, 0x06, 0x00, 0x0e, 0x00, 0xCC, 0x00, 0x00, 0x00, 0xC3, 0x00, 0x00, 0x00, 0xCC, 0x00, 0x05, 0x01, # Status code 0x05 (invalid path) 0x00, 0x00, ]) assert data.input == rpy_bad, \ "Unexpected reply from Multiple Request Service request for SCADA_40001/2; got: \n%r\nvs.\n%r " % ( data.input, rpy_bad )
def handle_udp(self, conn, name, enip_process, **kwds): """ Process UDP packets from multiple clients """ with parser.enip_machine(name=name, context='enip') as machine: while not kwds['server']['control']['done'] and not kwds['server']['control']['disable']: try: source = cpppo.rememberable() data = cpppo.dotdict() # If no/partial EtherNet/IP header received, parsing will fail with a NonTerminal # Exception (dfa exits in non-terminal state). Build data.request.enip: begun = cpppo.timer() # waiting for next transaction addr, stats = None, None with contextlib.closing(machine.run( path='request', source=source, data=data)) as engine: # PyPy compatibility; avoid deferred destruction of generators for mch, sta in engine: if sta is not None: # No more transitions available. Wait for input. continue assert not addr, "Incomplete UDP request from client %r" % (addr) msg = None while msg is None: # For UDP, we'll allow no input only at the start of a new request parse # (addr is None); anything else will be considered a failed request Back # to the trough for more symbols, after having already received a packet # from a peer? No go! wait = (kwds['server']['control']['latency'] if source.peek() is None else 0) brx = cpppo.timer() msg, frm = network.recvfrom(conn, timeout=wait) now = cpppo.timer() if not msg: if kwds['server']['control']['done'] or kwds['server']['control']['disable']: return (logger.info if msg else logger.debug)( "Transaction receive after %7.3fs (%5s bytes in %7.3f/%7.3fs): %r", now - begun, len(msg) if msg is not None else "None", now - brx, wait, self.stats_for(frm)[0]) # If we're at a None (can't proceed), and we haven't yet received input, # then this is where we implement "Blocking"; we just loop for input. # We have received exactly one packet from an identified peer! begun = now addr = frm stats, _ = self.stats_for(addr) # For UDP, we don't ever receive incoming EOF, or set stats['eof']. # However, we can respond to a manual eof (eg. from web interface) by # ignoring the peer's packets. assert stats and not stats.get('eof'), \ "Ignoring UDP request from client %r: %r" % (addr, msg) stats['received'] += len(msg) logger.debug("%s recv: %5d: %s", machine.name_centered(), len(msg), cpppo.reprlib.repr(msg)) source.chain(msg) # Terminal state and EtherNet/IP header recognized; process and return response assert stats if 'request' in data: stats['requests'] += 1 # enip_process must be able to handle no request (empty data), indicating the # clean termination of the session if closed from this end (not required if # enip_process returned False, indicating the connection was terminated by # request.) if enip_process(addr, data=data, **kwds): # Produce an EtherNet/IP response carrying the encapsulated response data. # If no encapsulated data, ensure we also return a non-zero EtherNet/IP # status. A non-zero status indicates the end of the session. assert 'response.enip' in data, "Expected EtherNet/IP response; none found" if 'input' not in data.response.enip or not data.response.enip.input: logger.warning("Expected EtherNet/IP response encapsulated message; none found") assert data.response.enip.status, "If no/empty response payload, expected non-zero EtherNet/IP status" rpy = parser.enip_encode(data.response.enip) logger.debug("%s send: %5d: %s", machine.name_centered(), len(rpy), cpppo.reprlib.repr(rpy)) conn.sendto(rpy, addr) logger.debug("Transaction complete after %7.3fs", cpppo.timer() - begun) stats['processed'] = source.sent except: # Parsing failure. Suck out some remaining input to give us some context, but don't re-raise if stats: stats['processed'] = source.sent memory = bytes(bytearray(source.memory)) pos = len(source.memory) future = bytes(bytearray(b for b in source)) where = "at %d total bytes:\n%s\n%s (byte %d)" % ( stats.get('processed', 0) if stats else 0, repr(memory + future), '-' * (len(repr(memory)) - 1) + '^', pos) logger.error("Client %r EtherNet/IP error %s\n\nFailed with exception:\n%s\n", addr, where, ''.join( traceback.format_exception(*sys.exc_info())))
def handle_tcp(self, conn, address, name, enip_process, delay=None, **kwds): """ Handle a TCP client """ source = cpppo.rememberable() with parser.enip_machine(name=name, context='enip') as machine: try: assert address, "EtherNet/IP CIP server for TCP/IP must be provided a peer address" stats, connkey = self.stats_for(address) while not stats.eof: data = cpppo.dotdict() source.forget() # If no/partial EtherNet/IP header received, parsing will fail with a NonTerminal # Exception (dfa exits in non-terminal state). Build data.request.enip: begun = cpppo.timer() with contextlib.closing(machine.run(path='request', source=source, data=data)) as engine: # PyPy compatibility; avoid deferred destruction of generators for mch, sta in engine: if sta is not None: continue # No more transitions available. Wait for input. EOF (b'') will lead to # termination. We will simulate non-blocking by looping on None (so we can # check our options, in case they've been changed). If we still have input # available to process right now in 'source', we'll just check (0 timeout); # otherwise, use the specified server.control.latency. msg = None while msg is None and not stats.eof: wait = (kwds['server']['control']['latency'] if source.peek() is None else 0) brx = cpppo.timer() msg = network.recv(conn, timeout=wait) now = cpppo.timer() (logger.info if msg else logger.debug)( "Transaction receive after %7.3fs (%5s bytes in %7.3f/%7.3fs)", now - begun, len(msg) if msg is not None else "None", now - brx, wait) # After each block of input (or None), check if the server is being # signalled done/disabled; we need to shut down so signal eof. Assumes # that (shared) server.control.{done,disable} dotdict be in kwds. We do # *not* read using attributes here, to avoid reporting completion to # external APIs (eg. web) awaiting reception of these signals. if kwds['server']['control']['done'] or kwds['server']['control']['disable']: logger.info("%s done, due to server done/disable", machine.name_centered()) stats['eof'] = True if msg is not None: stats['received'] += len(msg) stats['eof'] = stats['eof'] or not len(msg) if logger.getEffectiveLevel() <= logging.INFO: logger.info("%s recv: %5d: %s", machine.name_centered(), len(msg), cpppo.reprlib.repr(msg)) source.chain(msg) else: # No input. If we have symbols available, no problem; continue. # This can occur if the state machine cannot make a transition on # the input symbol, indicating an unacceptable sentence for the # grammar. If it cannot make progress, the machine will terminate # in a non-terminal state, rejecting the sentence. if source.peek() is not None: break # We're at a None (can't proceed), and no input is available. This # is where we implement "Blocking"; just loop. logger.info("Transaction parsed after %7.3fs", cpppo.timer() - begun) # Terminal state and EtherNet/IP header recognized, or clean EOF (no partial # message); process and return response if 'request' in data: stats['requests'] += 1 try: # enip_process must be able to handle no request (empty data), indicating the # clean termination of the session if closed from this end (not required if # enip_process returned False, indicating the connection was terminated by # request.) delayseconds = 0 # response delay (if any) if enip_process(address, data=data, **kwds): # Produce an EtherNet/IP response carrying the encapsulated response data. # If no encapsulated data, ensure we also return a non-zero EtherNet/IP # status. A non-zero status indicates the end of the session. assert 'response.enip' in data, "Expected EtherNet/IP response; none found" if 'input' not in data.response.enip or not data.response.enip.input: logger.warning("Expected EtherNet/IP response encapsulated message; none found") assert data.response.enip.status, "If no/empty response payload, expected non-zero EtherNet/IP status" rpy = parser.enip_encode(data.response.enip) if logger.getEffectiveLevel() <= logging.INFO: logger.info("%s send: %5d: %s %s", machine.name_centered(), len(rpy), cpppo.reprlib.repr(rpy), ("delay: %r" % delay) if delay else "") if delay: # A delay (anything with a delay.value attribute) == #[.#] (converible # to float) is ok; may be changed via web interface. try: delayseconds = float( delay.value if hasattr(delay, 'value') else delay) if delayseconds > 0: time.sleep(delayseconds) except Exception as exc: logger.info( "Unable to delay; invalid seconds: %r", delay) try: conn.send(rpy) except socket.error as exc: logger.info("Session ended (client abandoned): %s", exc) stats['eof'] = True if data.response.enip.status: logger.warning( "Session ended (server EtherNet/IP status: 0x%02x == %d)", data.response.enip.status, data.response.enip.status) stats['eof'] = True else: # Session terminated. No response, just drop connection. if logger.getEffectiveLevel() <= logging.INFO: logger.info("Session ended (client initiated): %s", parser.enip_format(data)) stats['eof'] = True logger.info( "Transaction complete after %7.3fs (w/ %7.3fs delay)", cpppo.timer() - begun, delayseconds) except: logger.error("Failed request: %s", parser.enip_format(data)) enip_process(address, data=cpppo.dotdict()) # Terminate. raise stats['processed'] = source.sent except: # Parsing failure. stats['processed'] = source.sent memory = bytes(bytearray(source.memory)) pos = len(source.memory) future = bytes(bytearray(b for b in source)) where = "at %d total bytes:\n%s\n%s (byte %d)" % (stats.processed, repr(memory + future), '-' * (len(repr(memory)) - 1) + '^', pos) logger.error("EtherNet/IP error %s\n\nFailed with exception:\n%s\n", where, ''.join(traceback.format_exception(*sys.exc_info()))) raise finally: # Not strictly necessary to close (network.server_main will discard the socket, # implicitly closing it), but we'll do it explicitly here in case the thread doesn't die # for some other reason. Clean up the connections entry for this connection address. self.connections.pop(connkey, None) logger.info( "%s done; processed %3d request%s over %5d byte%s/%5d received (%d connections remain)", name, stats.requests, " " if stats.requests == 1 else "s", stats.processed, " " if stats.processed == 1 else "s", stats.received, len(self.connections)) sys.stdout.flush() conn.close()
def test_logix_multiple(): """Test the Multiple Request Service. Ensure multiple requests can be successfully handled, and invalid tags are correctly rejected. """ size = 1000 Obj = logix.Logix() Obj_a1 = Obj.attribute['1'] = enip.device.Attribute( 'parts', enip.parser.DINT, default=[n for n in range( size )]) Obj_a2 = Obj.attribute['2'] = enip.device.Attribute( 'ControlWord', enip.parser.DINT, default=[n for n in range( size )]) Obj_a3 = Obj.attribute['3'] = enip.device.Attribute( 'SCADA_40001', enip.parser.INT, default=[n for n in range( size )]) assert len( Obj_a1 ) == size assert len( Obj_a2 ) == size Obj_a1[0] = 42 Obj_a2[0] = 476 # Set up a symbolic tag referencing the Logix Object's Attribute enip.device.symbol['parts'] = {'class': Obj.class_id, 'instance': Obj.instance_id, 'attribute':1 } enip.device.symbol['ControlWord'] \ = {'class': Obj.class_id, 'instance': Obj.instance_id, 'attribute':2 } enip.device.symbol['SCADA_40001'] \ = {'class': Obj.class_id, 'instance': Obj.instance_id, 'attribute':3 } # Test that we correctly compute beg,end,endactual for various Read Tag Fragmented scenarios, # with 2-byte and 4-byte types. For the purposes of this test, we only look at path...elements. data = cpppo.dotdict() data.service = Obj.RD_FRG_RPY data.path = { 'segment': [ cpppo.dotdict( d ) for d in [ {'element': 0 }, ]] } data.read_frag = {} data.read_frag.elements = 1000 data.read_frag.offset = 0 # Reply maximum size limited beg,end,endactual = Obj.reply_elements( Obj_a1, data, 'read_frag' ) assert beg == 0 and end == 125 and endactual == 1000 # DINT == 4 bytes beg,end,endactual = Obj.reply_elements( Obj_a3, data, 'read_frag' ) assert beg == 0 and end == 250 and endactual == 1000 # INT == 2 bytes data.read_frag.offset = 125*4 # OK, second request; begin after byte offset of first beg,end,endactual = Obj.reply_elements( Obj_a1, data, 'read_frag' ) assert beg == 125 and end == 250 and endactual == 1000 # DINT == 4 bytes # Request elements limited; 0 offset data.read_frag.elements = 30 data.read_frag.offset = 0 beg,end,endactual = Obj.reply_elements( Obj_a3, data, 'read_frag' ) assert beg == 0 and end == 30 and endactual == 30 # INT == 2 bytes # Request elements limited; +'ve offset data.read_frag.elements = 70 data.read_frag.offset = 80 beg,end,endactual = Obj.reply_elements( Obj_a3, data, 'read_frag' ) assert beg == 40 and end == 70 and endactual == 70 # INT == 2 bytes # Request limited by size of data provided (Write Tag [Fragmented]) data = cpppo.dotdict() data.service = Obj.WR_FRG_RPY data.path = { 'segment': [ cpppo.dotdict( d ) for d in [ {'element': 0 }, ]] } data.write_frag = {} data.write_frag.data = [0] * 100 # 100 elements provided in this request data.write_frag.elements = 200 # Total request is to write 200 elements data.write_frag.offset = 16 # request starts 16 bytes in (8 INTs) beg,end,endactual = Obj.reply_elements( Obj_a3, data, 'write_frag' ) assert beg == 8 and end == 108 and endactual == 200 # INT == 2 bytes # ... same, but lets say request started somewhere in the middle of the array data.path = { 'segment': [ cpppo.dotdict( d ) for d in [ {'element': 222 }, ]] } beg,end,endactual = Obj.reply_elements( Obj_a3, data, 'write_frag' ) assert beg == 8+222 and end == 108+222 and endactual == 200+222 # INT == 2 bytes # Ensure correct computation of (beg,end] that are byte-offset and data/size limited data = cpppo.dotdict() data.service = Obj.WR_FRG_RPY data.path = { 'segment': [] } data.write_frag = {} data.write_frag.data = [3,4,5,6] data.write_frag.offset = 6 beg,end,endactual = Obj.reply_elements( Obj_a3, data, 'write_frag' ) assert beg == 3 and end == 7 and endactual == 1000 # INT == 2 bytes # Trigger the error cases only accessible via write # Too many elements provided for attribute capacity data.write_frag.offset = ( 1000 - 3 ) * 2 try: beg,end,endactual = Obj.reply_elements( Obj_a3, data, 'write_frag' ) assert False, "Should have raised Exception due to capacity" except Exception as exc: assert "capacity exceeded" in str( exc ) data = cpppo.dotdict() data.service = Obj.RD_FRG_RPY data.path = { 'segment': [] } data.read_frag = {} data.read_frag.offset = 6 beg,end,endactual = Obj.reply_elements( Obj_a3, data, 'read_frag' ) assert beg == 3 and end == 253 and endactual == 1000 # INT == 2 bytes # And we should be able to read with an offset right up to the last element data.read_frag.offset = 1998 beg,end,endactual = Obj.reply_elements( Obj_a3, data, 'read_frag' ) assert beg == 999 and end == 1000 and endactual == 1000 # INT == 2 bytes # Trigger all the remaining error cases # Unknown service data.service = Obj.RD_FRG_REQ try: beg,end,endactual = Obj.reply_elements( Obj_a3, data, 'read_frag' ) assert False, "Should have raised Exception due to service" except Exception as exc: assert "unknown service" in str( exc ) # Offset indivisible by element size data.service = Obj.RD_FRG_RPY data.read_frag.offset = 7 try: beg,end,endactual = Obj.reply_elements( Obj_a3, data, 'read_frag' ) assert False, "Should have raised Exception due to odd byte offset" except Exception as exc: assert "element boundary" in str( exc ) # Initial element outside bounds data.read_frag.offset = 2000 try: beg,end,endactual = Obj.reply_elements( Obj_a3, data, 'read_frag' ) assert False, "Should have raised Exception due to initial element" except Exception as exc: assert "initial element invalid" in str( exc ) # Ending element outside bounds data.read_frag.offset = 0 data.read_frag.elements = 1001 try: beg,end,endactual = Obj.reply_elements( Obj_a3, data, 'read_frag' ) assert False, "Should have raised Exception due to ending element" except Exception as exc: assert "ending element invalid" in str( exc ) # Beginning element after ending (should be no way to trigger). This request doesn't specify an # element in the path, hence defaults to element 0, and asks for a number of elements == 2. # Thus, there is no 6-byte offset possible (a 2-byte offset is, though). data.read_frag.offset = 6 data.read_frag.elements = 2 try: beg,end,endactual = Obj.reply_elements( Obj_a3, data, 'read_frag' ) assert False, "Should have raised Exception due to ending element order" except Exception as exc: assert "ending element before beginning" in str( exc ) data.read_frag.offset = 2 data.read_frag.elements = 2 beg,end,endactual = Obj.reply_elements( Obj_a3, data, 'read_frag' ) assert beg == 1 and end == 2 and endactual == 2 # INT == 2 bytes # Test an example valid multiple request data = cpppo.dotdict() data.multiple = {} data.multiple.request = [ cpppo.dotdict(), cpppo.dotdict() ] req = data.multiple.request req[0].path = { 'segment': [ cpppo.dotdict( d ) for d in [{'symbolic': 'parts'}]] } req[0].read_tag = {} req[0].read_tag.elements = 1 req[1].path = { 'segment': [ cpppo.dotdict( d ) for d in [{'symbolic': 'ControlWord'}]] } req[1].read_tag = {} req[1].read_tag.elements = 1 request = Obj.produce( data ) req_1 = bytes(bytearray([ 0x0A, 0x02, 0x20, 0x02, 0x24, 0x01, 0x02, 0x00, 0x06, 0x00, 0x12, 0x00, 0x4C, 0x04, 0x91, 0x05, 0x70, 0x61, 0x72, 0x74, 0x73, 0x00, 0x01, 0x00, 0x4C, 0x07, 0x91, 0x0B, 0x43, 0x6F, 0x6E, 0x74, 0x72, 0x6F, 0x6C, 0x57, 0x6F, 0x72, 0x64, 0x00, 0x01, 0x00, ])) assert request == req_1, \ "Unexpected result from Multiple Request Service; got: \n%r\nvs.\n%r " % ( request, req_1 ) # Now, use the Message_Router's parser source = cpppo.rememberable( request ) data = cpppo.dotdict() with Obj.parser as machine: for i,(m,s) in enumerate( machine.run( source=source, data=data )): pass log.normal( "Multiple Request: %s", enip.enip_format( data )) assert 'multiple' in data, \ "No parsed multiple found in data: %s" % enip.enip_format( data ) assert data.service == device.Message_Router.MULTIPLE_REQ, \ "Expected a Multiple Request Service request: %s" % enip.enip_format( data ) assert data.multiple.number == 2, \ "Expected 2 requests in request.multiple: %s" % enip.enip_format( data ) # And ensure if we re-encode the parsed result, we get the original encoded request back assert Obj.produce( data ) == req_1 # Process the request into a reply. Obj.request( data ) log.normal( "Multiple Response: %s", enip.enip_format( data )) assert data.service == device.Message_Router.MULTIPLE_RPY, \ "Expected a Multiple Request Service reply: %s" % enip.enip_format( data ) rpy_1 = bytearray([ 0x8A, 0x00, 0x00, 0x00, 0x02, 0x00, 0x06, 0x00, 0x10, 0x00, 0xCC, 0x00, 0x00, 0x00, 0xC4, 0x00, 0x2A, 0x00, 0x00, 0x00, 0xCC, 0x00, 0x00, 0x00, 0xC4, 0x00, 0xDC, 0x01, 0x00, 0x00, ]) assert data.input == rpy_1, \ "Unexpected reply from Multiple Request Service request; got: \n%r\nvs.\n%r " % ( data.input, rpy_1 ) # Now lets try some valid and invalid requests data = cpppo.dotdict() data.multiple = {} data.multiple.request = req = [ cpppo.dotdict() ] req[0].path = { 'segment': [ cpppo.dotdict( d ) for d in [{'symbolic': 'SCADA_40001'}]] } req[0].read_tag = {} req[0].read_tag.elements = 1 data.multiple.number = len( data.multiple.request ) request = Obj.produce( data ) req_good = bytearray([ 0x0A, 0x02, 0x20, 0x02, ord('$'), 0x01, 0x01, 0x00, 0x04, 0x00, 0x4C, 0x07, 0x91, 0x0b, ord('S'), ord('C'), ord('A'), ord('D'), ord('A'), ord('_'), ord('4'), ord('0'), ord('0'), ord('0'), ord('1'), 0x00, 0x01, 0x00, ]) assert request == req_good, \ "Unexpected result from Multiple Request Service request for SCADA_40001; got: \n%r\nvs.\n%r " % ( request, req_good ) Obj.request( data ) rpy_good = bytearray([ 0x8A, 0x00, 0x00, 0x00, 0x01, 0x00, 0x04, 0x00, 0xCC, 0x00, 0x00, 0x00, 0xC3, 0x00, 0x00, 0x00, ]) assert data.input == rpy_good, \ "Unexpected reply from Multiple Request Service request for SCADA_40001; got: \n%r\nvs.\n%r " % ( data.input, rpy_good ) # Add an invalid request data = cpppo.dotdict() data.multiple = {} data.multiple.request = req = [ cpppo.dotdict(), cpppo.dotdict() ] req[0].path = { 'segment': [ cpppo.dotdict( d ) for d in [{'symbolic': 'SCADA_40001'}]] } req[0].read_tag = {} req[0].read_tag.elements = 1 req[1].path = { 'segment': [ cpppo.dotdict( d ) for d in [{'symbolic': 'SCADA_40002'}]] } req[1].read_tag = {} req[1].read_tag.elements = 1 data.multiple.number = len( data.multiple.request ) request = Obj.produce( data ) req_bad = bytearray([ 0x0A, 0x02, 0x20, 0x02, ord('$'), 0x01, 0x02, 0x00, 0x06, 0x00, 0x18, 0x00, 0x4C, 0x07, 0x91, 0x0b, ord('S'), ord('C'), ord('A'), ord('D'), ord('A'), ord('_'), ord('4'), ord('0'), ord('0'), ord('0'), ord('1'), 0x00, 0x01, 0x00, 0x4C, 0x07, 0x91, 0x0b, ord('S'), ord('C'), ord('A'), ord('D'), ord('A'), ord('_'), ord('4'), ord('0'), ord('0'), ord('0'), ord('2'), 0x00, 0x01, 0x00, ]) assert request == req_bad, \ "Unexpected result from Multiple Request Service request for SCADA_40001/2; got: \n%r\nvs.\n%r " % ( request, req_bad ) Obj.request( data ) rpy_bad = bytearray([ 0x8A, 0x00, 0x00, 0x00, 0x02, 0x00, 0x06, 0x00, 0x0e, 0x00, 0xCC, 0x00, 0x00, 0x00, 0xC3, 0x00, 0x00, 0x00, 0xCC, 0x00, 0x05, 0x01, # Status code 0x05 (invalid path) 0x00, 0x00, ]) assert data.input == rpy_bad, \ "Unexpected reply from Multiple Request Service request for SCADA_40001/2; got: \n%r\nvs.\n%r " % ( data.input, rpy_bad )
def handle_udp(self, conn, name, enip_process, **kwds): """ Process UDP packets from multiple clients """ with parser.enip_machine(name=name, context='enip') as machine: while not kwds['server']['control']['done'] and not kwds['server']['control']['disable']: try: source = cpppo.rememberable() data = cpppo.dotdict() # If no/partial EtherNet/IP header received, parsing will fail with a NonTerminal # Exception (dfa exits in non-terminal state). Build data.request.enip: begun = cpppo.timer() # waiting for next transaction addr, stats = None, None with contextlib.closing(machine.run( path='request', source=source, data=data)) as engine: # PyPy compatibility; avoid deferred destruction of generators for mch, sta in engine: if sta is not None: # No more transitions available. Wait for input. continue assert not addr, "Incomplete UDP request from client %r" % (addr) msg = None while msg is None: # For UDP, we'll allow no input only at the start of a new request parse # (addr is None); anything else will be considered a failed request Back # to the trough for more symbols, after having already received a packet # from a peer? No go! wait = (kwds['server']['control']['latency'] if source.peek() is None else 0) brx = cpppo.timer() msg, frm = network.recvfrom(conn, timeout=wait) now = cpppo.timer() (logger.info if msg else logger.debug)( "Transaction receive after %7.3fs (%5s bytes in %7.3f/%7.3fs): %r", now - begun, len(msg) if msg is not None else "None", now - brx, wait, self.stats_for(frm)[0]) # If we're at a None (can't proceed), and we haven't yet received input, # then this is where we implement "Blocking"; we just loop for input. # We have received exactly one packet from an identified peer! begun = now addr = frm stats, _ = self.stats_for(addr) # For UDP, we don't ever receive incoming EOF, or set stats['eof']. # However, we can respond to a manual eof (eg. from web interface) by # ignoring the peer's packets. assert stats and not stats.get('eof'), \ "Ignoring UDP request from client %r: %r" % (addr, msg) stats['received'] += len(msg) logger.debug("%s recv: %5d: %s", machine.name_centered(), len(msg), cpppo.reprlib.repr(msg)) source.chain(msg) # Terminal state and EtherNet/IP header recognized; process and return response assert stats if 'request' in data: stats['requests'] += 1 # enip_process must be able to handle no request (empty data), indicating the # clean termination of the session if closed from this end (not required if # enip_process returned False, indicating the connection was terminated by # request.) if enip_process(addr, data=data, **kwds): # Produce an EtherNet/IP response carrying the encapsulated response data. # If no encapsulated data, ensure we also return a non-zero EtherNet/IP # status. A non-zero status indicates the end of the session. assert 'response.enip' in data, "Expected EtherNet/IP response; none found" if 'input' not in data.response.enip or not data.response.enip.input: logger.warning("Expected EtherNet/IP response encapsulated message; none found") assert data.response.enip.status, "If no/empty response payload, expected non-zero EtherNet/IP status" rpy = parser.enip_encode(data.response.enip) logger.debug("%s send: %5d: %s", machine.name_centered(), len(rpy), cpppo.reprlib.repr(rpy)) conn.sendto(rpy, addr) logger.debug("Transaction complete after %7.3fs", cpppo.timer() - begun) stats['processed'] = source.sent except: # Parsing failure. Suck out some remaining input to give us some context, but don't re-raise if stats: stats['processed'] = source.sent memory = bytes(bytearray(source.memory)) pos = len(source.memory) future = bytes(bytearray(b for b in source)) where = "at %d total bytes:\n%s\n%s (byte %d)" % ( stats.get('processed', 0) if stats else 0, repr(memory + future), '-' * (len(repr(memory)) - 1) + '^', pos) logger.error("Client %r EtherNet/IP error %s\n\nFailed with exception:\n%s\n", addr, where, ''.join( traceback.format_exception(*sys.exc_info())))
def enip_srv(conn, addr, enip_process=None, delay=None, **kwds): """Serve one Ethernet/IP client 'til EOF; then close the socket. Parses headers and encapsulated EtherNet/IP request data 'til either the parser fails (the Client has submitted an un-parsable request), or the request handler fails. Otherwise, encodes the data.response in an EtherNet/IP packet and sends it back to the client. Use the supplied enip_process function to process each parsed EtherNet/IP frame, returning True if a data.response is formulated, False if the session has ended cleanly, or raise an Exception if there is a processing failure (eg. an unparsable request, indicating that the Client is speaking an unknown dialect and the session must close catastrophically.) If a partial EtherNet/IP header is parsed and an EOF is received, the enip_header parser will raise an AssertionError, and we'll simply drop the connection. If we receive a valid header and request, the supplied enip_process function is expected to formulate an appropriate error response, and we'll continue processing requests. An option numeric delay value (or any delay object with a .value attribute evaluating to a numeric value) may be specified; every response will be delayed by the specified number of seconds. We assume that such a value may be altered over time, so we access it afresh for each use. All remaining keywords are passed along to the supplied enip_process function. """ global latency global timeout name = "enip_%s" % addr[1] log.normal("EtherNet/IP Server %s begins serving peer %s", name, addr) source = cpppo.rememberable() with parser.enip_machine(name=name, context='enip') as enip_mesg: # We can be provided a dotdict() to contain our stats. If one has been passed in, then this # means that our stats for this connection will be available to the web API; it may set # stats.eof to True at any time, terminating the connection! The web API will try to coerce # its input into the same type as the variable, so we'll keep it an int (type bool doesn't # handle coercion from strings). We'll use an apidict, to ensure that attribute values set # via the web API thread (eg. stats.eof) are blocking 'til this thread wakes up and reads # them. Thus, the web API will block setting .eof, and won't return to the caller until the # thread is actually in the process of shutting down. Internally, we'll use __setitem__ # indexing to change stats values, so we don't block ourself! stats = cpppo.apidict(timeout=timeout) connkey = ("%s_%d" % addr).replace('.', '_') connections[connkey] = stats try: assert enip_process is not None, \ "Must specify an EtherNet/IP processing function via 'enip_process'" stats['requests'] = 0 stats['received'] = 0 stats['eof'] = False stats['interface'] = addr[0] stats['port'] = addr[1] while not stats.eof: data = cpppo.dotdict() source.forget() # If no/partial EtherNet/IP header received, parsing will fail with a NonTerminal # Exception (dfa exits in non-terminal state). Build data.request.enip: begun = misc.timer() log.detail("Transaction begins") states = 0 for mch, sta in enip_mesg.run(path='request', source=source, data=data): states += 1 if sta is None: # No more transitions available. Wait for input. EOF (b'') will lead to # termination. We will simulate non-blocking by looping on None (so we can # check our options, in case they've been changed). If we still have input # available to process right now in 'source', we'll just check (0 timeout); # otherwise, use the specified server.control.latency. msg = None while msg is None and not stats.eof: wait = (kwds['server']['control']['latency'] if source.peek() is None else 0) brx = misc.timer() msg = network.recv(conn, timeout=wait) now = misc.timer() log.detail( "Transaction receive after %7.3fs (%5s bytes in %7.3f/%7.3fs)" % (now - begun, len(msg) if msg is not None else "None", now - brx, wait)) # After each block of input (or None), check if the server is being # signalled done/disabled; we need to shut down so signal eof. Assumes # that (shared) server.control.{done,disable} dotdict be in kwds. We do # *not* read using attributes here, to avoid reporting completion to # external APIs (eg. web) awaiting reception of these signals. if kwds['server']['control']['done'] or kwds[ 'server']['control']['disable']: log.detail( "%s done, due to server done/disable", enip_mesg.name_centered()) stats['eof'] = True if msg is not None: stats['received'] += len(msg) stats['eof'] = stats['eof'] or not len(msg) log.detail("%s recv: %5d: %s", enip_mesg.name_centered(), len(msg) if msg is not None else 0, reprlib.repr(msg)) source.chain(msg) else: # No input. If we have symbols available, no problem; continue. # This can occur if the state machine cannot make a transition on # the input symbol, indicating an unacceptable sentence for the # grammar. If it cannot make progress, the machine will terminate # in a non-terminal state, rejecting the sentence. if source.peek() is not None: break # We're at a None (can't proceed), and no input is available. This # is where we implement "Blocking"; just loop. log.detail("Transaction parsed after %7.3fs" % (misc.timer() - begun)) # Terminal state and EtherNet/IP header recognized, or clean EOF (no partial # message); process and return response log.info("%s req. data (%5d states): %s", enip_mesg.name_centered(), states, parser.enip_format(data)) if 'request' in data: stats['requests'] += 1 try: # enip_process must be able to handle no request (empty data), indicating the # clean termination of the session if closed from this end (not required if # enip_process returned False, indicating the connection was terminated by # request.) delayseconds = 0 # response delay (if any) if enip_process(addr, data=data, **kwds): # Produce an EtherNet/IP response carrying the encapsulated response data. assert 'response' in data, "Expected EtherNet/IP response; none found" assert 'enip.input' in data.response, \ "Expected EtherNet/IP response encapsulated message; none found" rpy = parser.enip_encode(data.response.enip) log.detail("%s send: %5d: %s %s", enip_mesg.name_centered(), len(rpy), reprlib.repr(rpy), ("delay: %r" % delay) if delay else "") if delay: # A delay (anything with a delay.value attribute) == #[.#] (converible # to float) is ok; may be changed via web interface. try: delayseconds = float(delay.value if hasattr( delay, 'value') else delay) if delayseconds > 0: time.sleep(delayseconds) except Exception as exc: log.detail( "Unable to delay; invalid seconds: %r", delay) try: conn.send(rpy) except socket.error as exc: log.detail( "%s session ended (client abandoned): %s", enip_mesg.name_centered(), exc) eof = True else: # Session terminated. No response, just drop connection. log.detail("%s session ended (client initiated): %s", enip_mesg.name_centered(), parser.enip_format(data)) eof = True log.detail( "Transaction complete after %7.3fs (w/ %7.3fs delay)" % (misc.timer() - begun, delayseconds)) except: log.error("Failed request: %s", parser.enip_format(data)) enip_process(addr, data=cpppo.dotdict()) # Terminate. raise stats['processed'] = source.sent except: # Parsing failure. We're done. Suck out some remaining input to give us some context. stats['processed'] = source.sent memory = bytes(bytearray(source.memory)) pos = len(source.memory) future = bytes(bytearray(b for b in source)) where = "at %d total bytes:\n%s\n%s (byte %d)" % ( stats.processed, repr(memory + future), '-' * (len(repr(memory)) - 1) + '^', pos) log.error("EtherNet/IP error %s\n\nFailed with exception:\n%s\n", where, ''.join(traceback.format_exception(*sys.exc_info()))) raise finally: # Not strictly necessary to close (network.server_main will discard the socket, # implicitly closing it), but we'll do it explicitly here in case the thread doesn't die # for some other reason. Clean up the connections entry for this connection address. connections.pop(connkey, None) log.normal( "%s done; processed %3d request%s over %5d byte%s/%5d received (%d connections remain)", name, stats.requests, " " if stats.requests == 1 else "s", stats.processed, " " if stats.processed == 1 else "s", stats.received, len(connections)) sys.stdout.flush() conn.close()
def process( addr, data, **kwds ): """Processes an incoming parsed EtherNet/IP encapsulated request in data.request.enip.input, and produces a response with a prepared encapsulated reply, in data.response.enip.input, ready for re-encapsulation and transmission as a response. Returns True while session lives, False when the session is cleanly terminated. Raises an exception when a fatal protocol processing error occurs, and the session should be terminated forcefully. When a connection is closed, a final invocation with This roughly corresponds to the CIP Connection "client" object functionality. We parse the raw EtherNet/IP encapsulation to get something like this Register request, in data.request: "enip.command": 101, "enip.input": "array('c', '\\x01\\x00\\x00\\x00')", "enip.length": 4, "enip.options": 0, "enip.session_handle": 0, "enip.status": 0 "enip.length": 4 This is parsed by the Connection Manager: "enip.CIP.register.options": 0, "enip.CIP.register.protocol_version": 1, Other requests such as: "enip.command": 111, "enip.input": "array('c', '\\x00\\x00\\x00\\x00\\x05\\x00\\x02\\x00\\x00\\x00\\x00\\x00\\xb2\\x00\\x06\\x00\\x01\\x02 f$\\x01')", "enip.length": 22, "enip.options": 0, "enip.sender_context.input": "array('c', '\\x01\\x00\\x00\\x00\\x00\\x00\\x00\\x00')", "enip.session_handle": 285351425, "enip.status": 0 are parsed by the Connection Manager, and contain CPF entries requiring further processing by the Unconnected Message Manager (UCMM): "enip.CIP.send_data.CPF.count": 2, "enip.CIP.send_data.CPF.item[0].length": 0, "enip.CIP.send_data.CPF.item[0].type_id": 0, "enip.CIP.send_data.CPF.item[1].length": 6, "enip.CIP.send_data.CPF.item[1].type_id": 178, "enip.CIP.send_data.CPF.item[1].unconnected_send.request_path.segment[0].class": 102, "enip.CIP.send_data.CPF.item[1].unconnected_send.request_path.segment[1].instance": 1, "enip.CIP.send_data.CPF.item[1].unconnected_send.request_path.size": 2, "enip.CIP.send_data.CPF.item[1].unconnected_send.service": 1, "enip.CIP.send_data.interface": 0, "enip.CIP.send_data.timeout": 5, """ ucmm = setup() # If tags are specified, check that we've got them all set up right. If the tag doesn't exist, # add it. If it's error code doesn't match, change it. if 'tags' in kwds: for key,val in dict( kwds['tags'] ).items(): log.detail( "EtherNet/IP CIP Request (Client %16s): setup tag: %r", addr, (key, val) ) if not resolve_tag( key ): # A new tag! Allocate a new attribute ID in the Logix (Message Router). cls, ins = 0x02, 1 # The Logix Message Router Lx = lookup( cls, ins ) att = len( Lx.attribute ) while ( str( att ) in Lx.attribute ): att += 1 log.normal( "%24s.%s Attribute %3d added", Lx, val.attribute, att ) Lx.attribute[str(att)]= val.attribute redirect_tag( key, {'class': cls, 'instance': ins, 'attribute': att }) # Attribute (now) exists; find it, and make sure its error code is right. attribute = lookup( *resolve_tag( key )) assert attribute is not None, "Failed to find existing tag: %r" % key if 'error' in val and attribute.error != val.error: log.warning( "Attribute %s error code changed: 0x%02x", attribute, val.error ) attribute.error = val.error source = cpppo.rememberable() try: # Find the Connection Manager, and use it to parse the encapsulated EtherNet/IP request. We # pass an additional request.addr, to allow the Connection Manager to identify the # connection, in the case where the connection is closed spontaneously (no request, no # request.enip.session_handle). data['request.addr'] = addr # data.request may not exist, or be empty if 'enip' in data.request: source.chain( data.request.enip.input ) with ucmm.parser as machine: for i,(m,s) in enumerate( machine.run( path='request.enip', source=source, data=data )): #log.detail( "%s #%3d -> %10.10s; next byte %3d: %-10.10r: %s", # machine.name_centered(), i, s, source.sent, source.peek(), # repr( data ) if log.getEffectiveLevel() < logging.DETAIL else reprlib.repr( data )) pass if log.isEnabledFor( logging.DETAIL ): log.detail( "EtherNet/IP CIP Request (Client %16s): %s", addr, enip_format( data.request )) # Create a data.response with a structural copy of the request.enip.header. This means that # the dictionary structure is new (we won't alter the request.enip... when we add entries in # the resonse...), but the actual mutable values (eg. bytearray ) are copied. If we need # to change any values, replace them with new values instead of altering them! data.response = cpppo.dotdict( data.request ) # Let the Connection Manager process the (copied) request in response.enip, producing the # appropriate data.response.enip.input encapsulated EtherNet/IP message to return, along # with other response.enip... values (eg. .session_handle for a new Register Session). The # enip.status should normally be 0x00; the encapsulated response will contain appropriate # error indications if the encapsulated request failed. proceed = ucmm.request( data.response ) if log.isEnabledFor( logging.DETAIL ): log.detail( "EtherNet/IP CIP Response (Client %16s): %s", addr, enip_format( data.response )) return proceed except: # Parsing failure. We're done. Suck out some remaining input to give us some context. processed = source.sent memory = bytes(bytearray(source.memory)) pos = len( source.memory ) future = bytes(bytearray( b for b in source )) where = "at %d total bytes:\n%s\n%s (byte %d)" % ( processed, repr(memory+future), '-' * (len(repr(memory))-1) + '^', pos ) log.error( "EtherNet/IP CIP error %s\n", where ) raise