def echo_server(conn, addr): """Serve one echo client 'til EOF; then close the socket""" source = cpppo.chainable() with echo_machine("echo_%s" % addr[1]) as echo_line: eof = False while not eof: data = cpppo.dotdict() # See if a line has been recognized, stopping at terminal state. If this machine # is ended early due to an EOF, it should still terminate in a terminal state for mch, sta in echo_line.run(source=source, data=data): if sta is not None: continue # Non-transition; check for input, blocking if non-terminal and none left. On # EOF, terminate early; this will raise a GeneratorExit. timeout = 0 if echo_line.terminal or source.peek( ) is not None else None msg = network.recv(conn, timeout=timeout) if msg is not None: eof = not len(msg) log.info("%s recv: %5d: %s", echo_line.name_centered(), len(msg), "EOF" if eof else cpppo.reprlib.repr(msg)) source.chain(msg) if eof: break # Terminal state (or EOF). log.detail("%s: byte %5d: data: %r", echo_line.name_centered(), source.sent, data) if echo_line.terminal: conn.send(data.echo) log.info("%s done", echo_line.name_centered())
def tnet_server( conn, addr ): """Serve one tnet client 'til EOF; then close the socket""" source = cpppo.chainable() with tnet_machine( "tnet_%s" % addr[1] ) as tnet_mesg: eof = False while not eof: data = cpppo.dotdict() # Loop blocking for input, while we've consumed input from source since the last time. # If we hit this again without having used any input, we know we've hit a symbol # unacceptable to the state machine; stop for mch, sta in tnet_mesg.run( source=source, data=data ): if sta is not None: continue # Non-transition; check for input, blocking if non-terminal and none left. On # EOF, terminate early; this will raise a GeneratorExit. timeout = 0 if tnet_mesg.terminal or source.peek() is not None else None msg = network.recv( conn, timeout=timeout ) # blocking if msg is not None: eof = not len( msg ) log.info( "%s: recv: %5d: %s", tnet_mesg.name_centered(), len( msg ), "EOF" if eof else reprlib.repr( msg )) source.chain( msg ) if eof: break # Terminal state (or EOF). log.detail( "%s: byte %5d: data: %r", tnet_mesg.name_centered(), source.sent, data ) if tnet_mesg.terminal: res = json.dumps( data.tnet.type.input, indent=4, sort_keys=True ) conn.send(( res + "\n\n" ).encode( "utf-8" )) log.info( "%s done", tnet_mesg.name_centered() )
def echo_server( conn, addr ): """Serve one echo client 'til EOF; then close the socket""" source = cpppo.chainable() with echo_machine( "echo_%s" % addr[1] ) as echo_line: eof = False while not eof: data = cpppo.dotdict() # See if a line has been recognized, stopping at terminal state. If this machine # is ended early due to an EOF, it should still terminate in a terminal state for mch, sta in echo_line.run( source=source, data=data ): if sta is not None: continue # Non-transition; check for input, blocking if non-terminal and none left. On # EOF, terminate early; this will raise a GeneratorExit. timeout = 0 if echo_line.terminal or source.peek() is not None else None msg = network.recv( conn, timeout=timeout ) if msg is not None: eof = not len( msg ) log.info( "%s recv: %5d: %s", echo_line.name_centered(), len( msg ), "EOF" if eof else cpppo.reprlib.repr( msg )) source.chain( msg ) if eof: break # Terminal state (or EOF). log.detail( "%s: byte %5d: data: %r", echo_line.name_centered(), source.sent, data ) if echo_line.terminal: conn.send( data.echo ) log.info( "%s done", echo_line.name_centered() )
def recv( maxlen ): duration = cpppo.timer() - started remains = None if timeout is None else max( 0, timeout - duration ) remains = latency if timeout is None else min( # If no timeout, wait for latency (or forever, if None) timeout if latency is None else latency, # Or, we know timeout is numeric; get min of any latency max( timeout - duration, 0 )) # ... and remaining unused timeout return network.recv( conn, maxlen=maxlen, timeout=remains )
def handle( self ): '''Callback when we receive any data, until self.running becomes not True. Blocks indefinitely awaiting data. If shutdown is required, then the global socket.settimeout(<seconds>) may be used, to allow timely checking of self.running. However, since this also affects socket connects, if there are outgoing socket connections used in the same program, then these will be prevented, if the specfied timeout is too short. Hence, this is unreliable. Specify a latency of None for no recv timeout, and a drain of 0 for no waiting for reply EOF, for same behavior as stock ModbusConnectedRequestHandler. NOTE: This loop is restructured to employ finally: for logging, but is functionally equivalent to the original. ''' log.info("Modbus/TCP client socket handling started for %s", self.client_address ) try: while self.running: data = network.recv( self.request, timeout=self.latency ) if data is None: continue # No data w'in timeout; just check self.running if not data: self.running= False # EOF (empty data); done if log.isEnabledFor(logging.DEBUG): log.debug(" ".join([hex(ord(x)) for x in data])) self.framer.processIncomingPacket( data, self.execute ) except socket.error as exc: log.error("Modbus/TCP client socket error occurred %s", exc ) self.running = False except: log.error("Modbus/TCP client socket exception occurred %s", traceback.format_exc() ) self.running = False finally: log.info("Modbus/TCP client socket handling stopped for %s", self.client_address )
def tnet_server( conn, addr ): """Serve one tnet client 'til EOF; then close the socket""" source = cpppo.chainable() with tnet_machine( "tnet_%s" % addr[1] ) as tnet_mesg: eof = False while not eof: data = cpppo.dotdict() # Loop blocking for input, while we've consumed input from source since the last time. # If we hit this again without having used any input, we know we've hit a symbol # unacceptable to the state machine; stop for mch, sta in tnet_mesg.run( source=source, data=data ): if sta is not None: continue # Non-transition; check for input, blocking if non-terminal and none left. On # EOF, terminate early; this will raise a GeneratorExit. timeout = 0 if tnet_mesg.terminal or source.peek() is not None else None msg = network.recv( conn, timeout=timeout ) # blocking if msg is not None: eof = not len( msg ) log.info( "%s: recv: %5d: %s", tnet_mesg.name_centered(), len( msg ), "EOF" if eof else cpppo.reprlib.repr( msg )) source.chain( msg ) if eof: break # Terminal state (or EOF). log.detail( "%s: byte %5d: data: %r", tnet_mesg.name_centered(), source.sent, data ) if tnet_mesg.terminal: res = json.dumps( data.tnet.type.input, indent=4, sort_keys=True ) conn.send(( res + "\n\n" ).encode( "utf-8" )) log.info( "%s done", tnet_mesg.name_centered() )
def echo_cli( number, reps ): log.normal( "Echo Client %3d connecting... PID [%5d]", number, os.getpid() ) conn = socket.socket( socket.AF_INET, socket.SOCK_STREAM ) conn.connect( echo.address ) log.detail( "Echo Client %3d connected", number ) sent = b'' rcvd = b'' try: # Send messages and collect replies 'til done (or incoming EOF). Then, shut down # outgoing half of socket to drain server and shut down server. eof = False for r in range( reps ): msg = ("Client %3d, rep %d\r\n" % ( number, r )).encode() log.detail("Echo Client %3d send: %5d: %s", number, len( msg ), cpppo.reprlib.repr( msg )) sent += msg while len( msg ) and not eof: out = min( len( msg ), random.randrange( *charrange )) conn.send( msg[:out] ) msg = msg[out:] # Await inter-block chardelay if output remains, otherwise await final response # before dropping out to shutdown/drain/close. If we drop out immediately and send # a socket.shutdown, it'll sometimes deliver a reset to the server end of the # socket, before delivering the last of the data. rpy = network.recv( conn, timeout=chardelay if len( msg ) else draindelay ) if rpy is not None: eof = not len( rpy ) log.detail( "Echo Client %3d recv: %5d: %s", number, len( rpy ), "EOF" if eof else cpppo.reprlib.repr( rpy )) rcvd += rpy if eof: break log.normal( "Echo Client %3d done; %s", number, "due to EOF" if eof else "normal termination" ) except KeyboardInterrupt as exc: log.warning( "Echo Client %3d terminated: %r", number, exc ) except Exception as exc: log.warning( "Echo Client %3d failed: %r\n%s", number, exc, traceback.format_exc() ) finally: # One or more packets may be in flight; wait 'til we timeout/EOF. This shuts down conn. rpy = network.drain( conn, timeout=draindelay ) log.info( "Echo Client %3d drain %5d: %s", number, len( rpy ) if rpy is not None else 0, cpppo.reprlib.repr( rpy )) if rpy is not None: rcvd += rpy # Count the number of success/failures reported by the Echo client threads failed = not ( rcvd == sent ) if failed: log.warning( "Echo Client %3d failed: %s != %s sent", number, cpppo.reprlib.repr( rcvd ), cpppo.reprlib.repr( sent )) log.info( "Echo Client %3d exited", number ) return failed
def handle(self): '''Callback when we receive any data, until self.running becomes not True. Blocks indefinitely awaiting data. If shutdown is required, then the global socket.settimeout(<seconds>) may be used, to allow timely checking of self.running. However, since this also affects socket connects, if there are outgoing socket connections used in the same program, then these will be prevented, if the specfied timeout is too short. Hence, this is unreliable. Specify a latency of None for no recv timeout, and a drain of 0 for no waiting for reply EOF, for same behavior as stock ModbusConnectedRequestHandler. NOTE: This loop is restructured to employ finally: for logging, but is functionally equivalent to the original. ''' log.info("Modbus/TCP client socket handling started for %s", self.client_address) try: while self.running: data = network.recv(self.request, timeout=self.latency) if data is None: continue # No data w'in timeout; just check self.running if not data: self.running = False # EOF (empty data); done if log.isEnabledFor(logging.DEBUG): log.debug(" ".join([hex(ord(x)) for x in data])) self.framer.processIncomingPacket(data, self.execute) except socket.error as exc: log.error("Modbus/TCP client socket error occurred %s", exc) self.running = False except: log.error("Modbus/TCP client socket exception occurred %s", traceback.format_exc()) self.running = False finally: log.info("Modbus/TCP client socket handling stopped for %s", self.client_address)
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 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 tnet_from( conn, addr, server = cpppo.dotdict({'done': False}), timeout = None, latency = None, ignore = None, source = None ): # Provide a cpppo.chainable, if desire, to receive into and parse from """Parse and yield TNET messages from a socket w/in timeout, blocking 'til server.done or EOF between messages. If ignore contains symbols, they are ignored between TNET messages (eg. b'\n'). An absense of a TNET string within 'timeout' will yield None, allowing the user to decide to fail or continue trying for another 'timeout' period. A 0 timeout will "poll", and a None timeout will simply wait forever (the default). If desired, a separate 'latency' can be supplied, in order to pop out regularly and check server.done (eg. to allow a server Thread to exit cleanly). """ if source is None: source = cpppo.chainable() with tnet_machine( "tnet_%s" % addr[1] ) as engine: eof = False while not ( eof or server.done ): while ignore and source.peek() and source.peek() in ignore: next( source ) data = cpppo.dotdict() started = cpppo.timer() # When did we start the current attempt at a TNET string? for mch,sta in engine.run( source=source, data=data ): if sta is not None or source.peek() is not None: continue # Non-transition state, and we need more data: check for more data, enforce timeout. # Waits up to latency, or remainder of timeout -- or forever, if both are None. duration = cpppo.timer() - started msg = None while msg is None and not server.done: # Get input, forever or 'til server.done remains = latency if timeout is None else min( # If no timeout, wait for latency (or forever, if None) timeout if latency is None else latency, # Or, we know timeout is numeric; get min of any latency max( timeout - duration, 0 )) # ... and remaining unused timeout log.info( "%s: After %7.3fs, awaiting symbols (after %d processed) w/ %s recv timeout", engine.name_centered(), duration, source.sent, remains if remains is None else ( "%7.3fs" % remains )) msg = network.recv( conn, timeout=remains ) duration = cpppo.timer() - started if msg is None and timeout is not None and duration >= timeout: # No data w/in given timeout expiry! Inform the consumer, and then try again w/ fresh timeout. log.info( "%s: After %7.3fs, no TNET message after %7.3fs recv timeout", engine.name_centered(), duration, remains ) yield None started = cpppo.timer() # Only way to get here without EOF/data, is w/ server.done if server.done: break assert msg is not None # Got EOF or data eof = len( msg ) == 0 log.info( "%s: After %7.3fs, recv: %5d: %s", engine.name_centered(), duration, len( msg ), 'EOF' if eof else cpppo.reprlib.repr( msg )) if eof: break source.chain( msg ) # Terminal state, or EOF, or server.done. Only yield another TNET message if terminal. duration = cpppo.timer() - started if engine.terminal: log.debug( "%s: After %7.3fs, found a TNET: %r", engine.name_centered(), duration, data.tnet.type.input ) yield data.tnet.type.input # Could be a 0:~ / null ==> None log.detail( "%s: done w/ %s", engine.name_centered(), ', '.join( ['EOF'] if eof else [] + ['done'] if server.done else [] ))
def tnet_cli( number, tests=None ): log.info( "%3d client connecting... PID [%5d]", number, os.getpid() ) conn = socket.socket( socket.AF_INET, socket.SOCK_STREAM ) conn.connect( tnet.address ) log.info( "%3d client connected", number ) rcvd = '' try: eof = False for t in tests: msg = tnetstrings.dump( t ) log.normal( "Tnet Client %3d send: %5d: %s (from data: %s)", number, len( msg ), cpppo.reprlib.repr( msg ), cpppo.reprlib.repr( t )) while len( msg ) and not eof: out = min( len( msg ), random.randrange( *charrange )) conn.send( msg[:out] ) msg = msg[out:] # Await inter-block chardelay if output remains, otherwise await # final response before dropping out to shutdown/drain/close. # If we drop out immediately and send a socket.shutdown, it'll # sometimes deliver a reset to the server end of the socket, # before delivering the last of the data. rpy = network.recv( conn, timeout=chardelay if len( msg ) else draindelay ) if rpy is not None: eof = not len( rpy ) log.info( "Tnet Client %3d recv: %5d: %s", number, len( rpy ), "EOF" if eof else cpppo.reprlib.repr( rpy )) rcvd += rpy.decode( "utf-8" ) if eof: break log.normal( "Tnet Client %3d done; %s", number, "due to EOF" if eof else "normal termination" ) except KeyboardInterrupt as exc: log.normal( "%3d client terminated: %r", number, exc ) except Exception as exc: log.warning( "%3d client failed: %r\n%s", number, exc, traceback.format_exc() ) finally: # One or more packets may be in flight; wait 'til we timeout/EOF rpy = network.drain( conn, timeout=draindelay ) log.info( "Tnet Client %3d drain %5d: %s", number, len( rpy ) if rpy is not None else 0, cpppo.reprlib.repr( rpy )) if rpy is not None: rcvd += rpy.decode( "utf-8" ) # Count the number of successfully matched JSON decodes successes = 0 i = 0 for i, (t, r) in enumerate( zip( tests, rcvd.split( '\n\n' ))): e = json.dumps( t ) log.info( "%3d test #%3d: %32s --> %s", number, i, cpppo.reprlib.repr( t ), cpppo.reprlib.repr( e )) if r == e: successes += 1 else: log.warning( "%3d test #%3d: %32s got %s", number, i, cpppo.reprlib.repr( t ), cpppo.reprlib.repr( e )) failed = successes != len( tests ) if failed: log.warning( "%3d client failed: %d/%d tests succeeded", number, successes, len( tests )) log.info( "%3d client exited", number ) return failed
def __next__(self): """Return the next available response, or None if no complete response is available. Raises StopIteration (cease iterating) on EOF. Any other Exception indicates a client failure, and should result in the client instance being discarded. If no input is presently available, harvest any input immediately available; terminate on EOF. """ if self.source.peek() is None: rcvd = network.recv(self.conn, timeout=0) log.detail("EtherNet/IP-->%16s:%-5d rcvd %5d: %s", self.addr[0], self.addr[1], len(rcvd) if rcvd is not None else 0, repr(rcvd)) if rcvd is not None: # Some input (or EOF); source is empty; if no input available, terminate if not len(rcvd): raise StopIteration self.source.chain(rcvd) else: # Don't create parsing engine 'til we have some I/O to process. This avoids the # degenerate situation where empty I/O (EOF) always matches the empty command (used # to indicate the end of an EtherNet/IP session). if self.engine is None: return None # Initiate or continue parsing input using the machine's engine; discard the engine at # termination or on error (Exception). Any exception (including cpppo.NonTerminal) will be # propagated. result = None with self.frame as machine: try: if self.engine is None: self.data = cpppo.dotdict() self.engine = machine.run(source=self.source, data=self.data) log.detail( "EtherNet/IP %16s:%-5d run.: %s -> %10.10s; next byte %3d: %-10.10r: %r", self.addr[0], self.addr[1], machine.name_centered(), machine.current, self.source.sent, self.source.peek(), self.data) for m, s in self.engine: log.detail( "EtherNet/IP<--%16s:%-5d rpy.: %s -> %10.10s; next byte %3d: %-10.10r: %r", self.addr[0], self.addr[1], machine.name_centered(), s, self.source.sent, self.source.peek(), self.data) except Exception as exc: log.warning("EtherNet/IP<x>%16s:%-5d err.: %s", self.addr[0], self.addr[1], str(exc)) self.engine = None raise if machine.terminal: log.detail( "EtherNet/IP %16s:%-5d done: %s -> %10.10s; next byte %3d: %-10.10r: %r", self.addr[0], self.addr[1], machine.name_centered(), machine.current, self.source.sent, self.source.peek(), self.data) # Got an EtherNet/IP frame. Return it (after parsing its payload.) self.engine = None result = self.data # Parse the EtherNet/IP encapsulated CIP frame if result is not None: with self.cip as machine: for m, s in machine.run(path='enip', source=cpppo.peekable( result.enip.input), data=result): log.detail( "EtherNet/IP<--%16s:%-5d CIP : %s -> %10.10s; next byte %3d: %-10.10r: %r", self.addr[0], self.addr[1], machine.name_centered(), s, self.source.sent, self.source.peek(), self.data) pass assert machine.terminal, "No CIP payload in the EtherNet/IP frame: %r" % ( result) # Parse the Logix request responses in the EtherNet/IP CIP payload's CPF items if result is not None and 'enip.CIP.send_data' in result: for item in result.enip.CIP.send_data.CPF.item: if 'unconnected_send.request' in item: # An Unconnected Send that contained an encapsulated request (ie. not just a # Get Attribute All) with self.lgx as machine: for m, s in machine.run( source=cpppo.peekable( item.unconnected_send.request.input), data=item.unconnected_send.request): pass assert machine.terminal, "No Logix request in the EtherNet/IP CIP CPF frame: %r" % ( result) return result
def tnet_cli(number, tests=None): log.info("%3d client connecting... PID [%5d]", number, os.getpid()) conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM) conn.connect(tnet.address) log.info("%3d client connected", number) rcvd = '' try: eof = False for t in tests: msg = tnetstrings.dump(t) while len(msg) and not eof: out = min(len(msg), random.randrange(*charrange)) log.info("Tnet Client %3d send: %5d/%5d: %s", number, out, len(msg), cpppo.reprlib.repr(msg[:out])) conn.sendall(msg[:out]) msg = msg[out:] # Await inter-block chardelay if output remains, otherwise await # final response before dropping out to shutdown/drain/close. # If we drop out immediately and send a socket.shutdown, it'll # sometimes deliver a reset to the server end of the socket, # before delivering the last of the data. rpy = network.recv( conn, timeout=chardelay if len(msg) else draindelay) if rpy is not None: eof = not len(rpy) log.info("Tnet Client %3d recv: %5d: %s", number, len(rpy), "EOF" if eof else cpppo.reprlib.repr(rpy)) rcvd += rpy.decode("utf-8") if eof: break log.normal("Tnet Client %3d done; %s", number, "due to EOF" if eof else "normal termination") except KeyboardInterrupt as exc: log.normal("%3d client terminated: %r", number, exc) except Exception as exc: log.warning("%3d client failed: %r\n%s", number, exc, traceback.format_exc()) finally: # One or more packets may be in flight; wait 'til we timeout/EOF rpy = network.drain(conn, timeout=draindelay) log.info("Tnet Client %3d drain %5d: %s", number, len(rpy) if rpy is not None else 0, cpppo.reprlib.repr(rpy)) if rpy is not None: rcvd += rpy.decode("utf-8") # Count the number of successfully matched JSON decodes successes = 0 i = 0 for i, (t, r) in enumerate(zip(tests, rcvd.split('\n\n'))): e = json.dumps(t) log.info("%3d test #%3d: %32s --> %s", number, i, cpppo.reprlib.repr(t), cpppo.reprlib.repr(e)) if r == e: successes += 1 else: log.warning("%3d test #%3d: %32s got %s", number, i, cpppo.reprlib.repr(t), cpppo.reprlib.repr(e)) failed = successes != len(tests) if failed: log.warning("%3d client failed: %d/%d tests succeeded", number, successes, len(tests)) log.info("%3d client exited", number) return failed
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 __next__(self): """Return the next available response, or None if no complete response is available. Raises StopIteration (cease iterating) on EOF. Any other Exception indicates a client failure, and should result in the client instance being discarded. If no input is presently available, harvest any input immediately available; terminate on EOF. The response may not actually contain a payload, eg. if the EtherNet/IP header contains a non-zero status. """ if self.source.peek() is None: rcvd = network.recv(self.conn, timeout=0) log.info( "EtherNet/IP-->%16s:%-5d rcvd %5d: %s", self.addr[0], self.addr[1], len(rcvd) if rcvd is not None else 0, repr(rcvd), ) if rcvd is not None: # Some input (or EOF); source is empty; if no input available, terminate if not len(rcvd): raise StopIteration self.source.chain(rcvd) else: # Don't create parsing engine 'til we have some I/O to process. This avoids the # degenerate situation where empty I/O (EOF) always matches the empty command (used # to indicate the end of an EtherNet/IP session). if self.engine is None: return None # Initiate or continue parsing input using the machine's engine; discard the engine at # termination or on error (Exception). Any exception (including cpppo.NonTerminal) will be # propagated. result = None with self.frame as machine: try: if self.engine is None: self.data = cpppo.dotdict() self.engine = machine.run(source=self.source, data=self.data) log.debug( "EtherNet/IP %16s:%-5d run.: %s -> %10.10s; next byte %3d: %-10.10r: %r", self.addr[0], self.addr[1], machine.name_centered(), machine.current, self.source.sent, self.source.peek(), self.data, ) for m, s in self.engine: log.debug( "EtherNet/IP<--%16s:%-5d rpy.: %s -> %10.10s; next byte %3d: %-10.10r: %r", self.addr[0], self.addr[1], machine.name_centered(), s, self.source.sent, self.source.peek(), self.data, ) except Exception as exc: log.warning("EtherNet/IP<x>%16s:%-5d err.: %s", self.addr[0], self.addr[1], str(exc)) self.engine = None raise if machine.terminal: log.info( "EtherNet/IP %16s:%-5d done: %s -> %10.10s; next byte %3d: %-10.10r: %r", self.addr[0], self.addr[1], machine.name_centered(), machine.current, self.source.sent, self.source.peek(), self.data, ) # Got an EtherNet/IP frame. Return it (after parsing its payload.) self.engine = None result = self.data # Parse the EtherNet/IP encapsulated CIP frame, if any. If the EtherNet/IP header .size was # zero, it's status probably indicates why. if result is not None and "enip.input" in result: with self.cip as machine: for m, s in machine.run(path="enip", source=cpppo.peekable(result.enip.input), data=result): log.debug( "EtherNet/IP<--%16s:%-5d CIP : %s -> %10.10s; next byte %3d: %-10.10r: %r", self.addr[0], self.addr[1], machine.name_centered(), s, self.source.sent, self.source.peek(), self.data, ) pass assert machine.terminal, "No CIP payload in the EtherNet/IP frame: %r" % (result) # Parse the Logix request responses in the EtherNet/IP CIP payload's CPF items if result is not None and "enip.CIP.send_data" in result: for item in result.enip.CIP.send_data.CPF.item: if "unconnected_send.request" in item: # An Unconnected Send that contained an encapsulated request (ie. not just a # Get Attribute All) with self.lgx as machine: for m, s in machine.run( source=cpppo.peekable(item.unconnected_send.request.input), data=item.unconnected_send.request, ): pass assert machine.terminal, "No Logix request in the EtherNet/IP CIP CPF frame: %r" % (result) return result