Example #1
0
    def start(self, host, port):
        srv_ctl = cpppo.dotdict()
        srv_ctl.control = cpppo.apidict(timeout=self.config.timeout)
        srv_ctl.control["done"] = False
        srv_ctl.control["disable"] = False
        srv_ctl.control.setdefault("latency", self.config.latency)

        options = cpppo.dotdict()
        options.setdefault("enip_process", logix.process)
        kwargs = dict(options, tags=self.tags, server=srv_ctl)

        tcp_mode = True if self.config.mode == "tcp" else False
        udp_mode = True if self.config.mode == "udp" else False

        self.control = srv_ctl.control
        self.start_event.set()

        logger.debug(
            "ENIP server started on: %s:%d, mode: %s" % (host, port, self.config.mode)
        )
        while not self.control["done"]:
            network.server_main(
                address=(host, port),
                target=self.handle,
                kwargs=kwargs,
                udp=udp_mode,
                tcp=tcp_mode,
            )
Example #2
0
 def stats_for(self, peer):
     if peer is None:
         return None, None
     connkey = "%s_%d" % (peer[0].replace('.', '_'), peer[1])
     stats = self.connections.get(connkey)
     if stats is not None:
         return stats, connkey
     stats = cpppo.apidict(timeout=self.config.timeout)
     self.connections[connkey] = stats
     stats['requests'] = 0
     stats['received'] = 0
     stats['eof'] = False
     stats['interface'] = peer[0]
     stats['port'] = peer[1]
     return stats, connkey
Example #3
0
 def stats_for(self, peer):
     if peer is None:
         return None, None
     connkey = "%s_%d" % (peer[0].replace('.', '_'), peer[1])
     stats = self.connections.get(connkey)
     if stats is not None:
         return stats, connkey
     stats = cpppo.apidict(timeout=self.config.timeout)
     self.connections[connkey] = stats
     stats['requests'] = 0
     stats['received'] = 0
     stats['eof'] = False
     stats['interface'] = peer[0]
     stats['port'] = peer[1]
     return stats, connkey
Example #4
0
 def stats_for(self, peer):
     if peer is None:
         return None, None
     connkey = "%s_%d" % (peer[0].replace(".", "_"), peer[1])
     stats = self.connections.get(connkey)
     if stats is not None:
         return stats, connkey
     stats = cpppo.apidict(timeout=self.config.timeout)
     self.connections[connkey] = stats
     stats["requests"] = 0
     stats["received"] = 0
     stats["eof"] = False
     stats["interface"] = peer[0]
     stats["port"] = peer[1]
     return stats, connkey
Example #5
0
def test_logix_remote_pylogix( count=100 ):
    """Performance of pylogix executing an operation a number of times on a socket connected
    Logix simulator, within the same Python interpreter (ie. all on a single CPU
    thread).  Only connects on the standard port.

    """
    #logging.getLogger().setLevel( logging.NORMAL )
    enip.lookup_reset() # Flush out any existing CIP Objects for a fresh start
    svraddr		        = ('localhost', 44828)
    kwargs			= {
        'argv': [
            #'-v',
            #'--log',		'/tmp/pylogix.log',
            #'--profile',	'/tmp/plogix.prof',
            '--address',	'%s:%d' % svraddr,
            'SCADA=INT[1000]'
        ],
        'server': {
            'control': cpppo.apidict( enip.timeout, { 
                'done': False
            } ),
        },
    }
    logixthread_kwargs		= {
        'count':		count,
        'svraddr':		svraddr,
        'kwargs':		kwargs
    }

    log.normal( "test_logix_remote_pylogix w/ server.control in object %s", id( kwargs['server']['control'] ))
    # This is sort of "inside-out".  This thread will run logix_remote, which will signal the
    # enip_main (via the kwargs.server...) to shut down.  However, to do line-based performance
    # measurement, we need to be running enip.main in the "Main" thread...
    logixthread			= threading.Thread( target=logix_remote_pylogix, kwargs=logixthread_kwargs )
    logixthread.daemon		= True
    logixthread.start()

    try:
        enip_main( **kwargs )
    finally:
        kwargs['server']['control'].done = True # Signal the server to terminate

    logixthread.join()
    log.normal( "Shutdown of server complete" )
Example #6
0
    def start(self, host, port):
        srv_ctl = cpppo.dotdict()
        srv_ctl.control = cpppo.apidict(timeout=self.config.timeout)
        srv_ctl.control['done'] = False
        srv_ctl.control['disable'] = False
        srv_ctl.control.setdefault('latency', self.config.latency)

        options = cpppo.dotdict()
        options.setdefault('enip_process', logix.process)
        kwargs = dict(options, tags=self.tags, server=srv_ctl)

        tcp_mode = True if self.config.mode == 'tcp' else False
        udp_mode = True if self.config.mode == 'udp' else False

        logger.debug('ENIP server started on: %s:%d, mode: %s' % (host, port, self.config.mode))
        while not self.stopped:
            network.server_main(address=(host, port), target=self.handle,
                                kwargs=kwargs, idle_service=None,
                                udp=udp_mode, tcp=tcp_mode,
                                thread_factory=network.server_thread)
Example #7
0
def test_logix_remote( count=50 ):
    """Performance of executing an operation a number of times on a socket connected
    Logix simulator, within the same Python interpreter (ie. all on a single CPU
    thread).

    """
    #logging.getLogger().setLevel( logging.NORMAL )
    enip.lookup_reset() # Flush out any existing CIP Objects for a fresh start
    svraddr		        = ('localhost', 12345)
    kwargs			= {
        'argv': [
            #'-v',
            #'--log',		'/tmp/logix.log',
            #'--profile',	'/tmp/logix.prof',
            '--address',	'%s:%d' % svraddr,
            'SCADA=INT[1000]'
        ],
        'server': {
            'control': cpppo.apidict( enip.timeout, { 
                'done': False
            } ),
        },
    }
    logixthread_kwargs		= {
        'count':		count,
        'svraddr':		svraddr,
        'kwargs':		kwargs
    }

    log.normal( "test_logix_remote w/ server.control in object %s", id( kwargs['server']['control'] ))
    # This is sort of "inside-out".  This thread will run logix_remote, which will signal the
    # enip.main (via the kwargs.server...) to shut down.  However, to do line-based performance
    # measurement, we need to be running enip.main in the "Main" thread...
    logixthread			= threading.Thread( target=logix_remote, kwargs=logixthread_kwargs )
    logixthread.daemon		= True
    logixthread.start()

    enip.main( **kwargs )

    logixthread.join()
Example #8
0
def test_logix_remote( count=100 ):
    """Performance of executing an operation a number of times on a socket connected
    Logix simulator, within the same Python interpreter (ie. all on a single CPU
    thread).

    """
    svraddr		        = ('localhost', 12345)
    kwargs			= cpppo.dotdict({
        'argv': [
            #'-v',
            #'--log',		'/tmp/logix.log',
            #'--profile',	'/tmp/logix.prof',
            '--address',	'%s:%d' % svraddr,
            'SCADA=INT[1000]'
        ],
        'server': {
            'control':	cpppo.apidict( enip.timeout, { 
                'done': False
            }),
        },
    })

    # This is sort of "inside-out".  This thread will run logix_remote, which will signal the
    # enip.main (via the kwargs.server...) to shut down.  However, to do line-based performance
    # measurement, we need to be running enip.main in the "Main" thread...
    logixthread			= threading.Thread( target=logix_remote, kwargs={
        'count': count,
        'svraddr': svraddr,
        'kwargs': kwargs
    } )
    logixthread.daemon		= True
    logixthread.start()

    enip.main( **kwargs )

    logixthread.join()
Example #9
0
def test_logix_remote(count=100):
    """Performance of executing an operation a number of times on a socket connected
    Logix simulator, within the same Python interpreter (ie. all on a single CPU
    thread).

    """
    svraddr = ('localhost', 12345)
    kwargs = cpppo.dotdict({
        'argv': [
            #'-v',
            #'--log',		'/tmp/logix.log',
            #'--profile',	'/tmp/logix.prof',
            '--address',
            '%s:%d' % svraddr,
            'SCADA=INT[1000]'
        ],
        'server': {
            'control': cpppo.apidict(enip.timeout, {'done': False}),
        },
    })

    # This is sort of "inside-out".  This thread will run logix_remote, which will signal the
    # enip.main (via the kwargs.server...) to shut down.  However, to do line-based performance
    # measurement, we need to be running enip.main in the "Main" thread...
    logixthread = threading.Thread(target=logix_remote,
                                   kwargs={
                                       'count': count,
                                       'svraddr': svraddr,
                                       'kwargs': kwargs
                                   })
    logixthread.daemon = True
    logixthread.start()

    enip.main(**kwargs)

    logixthread.join()
Example #10
0
File: main.py Project: ekw/cpppo
def main( argv=None, attribute_class=device.Attribute, identity_class=None, idle_service=None,
          **kwds ):
    """Pass the desired argv (excluding the program name in sys.arg[0]; typically pass argv=None, which
    is equivalent to argv=sys.argv[1:], the default for argparse.  Requires at least one tag to be
    defined.

    If a cpppo.apidict() is passed for kwds['server']['control'], we'll use it to transmit server
    control signals via its .done, .disable, .timeout and .latency attributes.

    Uses the provided attribute_class (default: device.Attribute) to process all EtherNet/IP
    attribute I/O (eg. Read/Write Tag [Fragmented]) requests.  By default, device.Attribute stores
    and retrieves the supplied data.  To perform other actions (ie. forward the data to your own
    application), derive from device.Attribute, and override the __getitem__ and __setitem__
    methods.

    If an idle_service function is provided, it will be called after a period of latency between
    incoming requests.

    """
    global address
    global options
    global tags
    global srv_ctl
    global latency
    global timeout

    ap				= argparse.ArgumentParser(
        description = "Provide an EtherNet/IP Server",
        epilog = "" )

    ap.add_argument( '-v', '--verbose',
                     default=0, action="count",
                     help="Display logging information." )
    ap.add_argument( '-a', '--address',
                     default=( "%s:%d" % address ),
                     help="EtherNet/IP interface[:port] to bind to (default: %s:%d)" % (
                         address[0], address[1] ))
    ap.add_argument( '-p', '--print', default=False, action='store_true',
                     help="Print a summary of operations to stdout" )
    ap.add_argument( '-l', '--log',
                     help="Log file, if desired" )
    ap.add_argument( '-w', '--web',
                     default="",
                     help="Web API [interface]:[port] to bind to (default: %s, port 80)" % (
                         address[0] ))
    ap.add_argument( '-d', '--delay',
                     help="Delay response to each request by a certain number of seconds (default: 0.0)",
                     default="0.0" )
    ap.add_argument( '-s', '--size',
                     help="Limit EtherNet/IP encapsulated request size to the specified number of bytes (default: None)",
                     default=None )
    ap.add_argument( '-P', '--profile',
                     help="Output profiling data to a file (default: None)",
                     default=None )
    ap.add_argument( 'tags', nargs="+",
                     help="Any tags, their type (default: INT), and number (default: 1), eg: tag=INT[1000]")

    args			= ap.parse_args( argv )

    # Deduce interface:port address to bind, and correct types (default is address, above)
    bind			= args.address.split(':')
    assert 1 <= len( bind ) <= 2, "Invalid --address [<interface>]:[<port>}: %s" % args.address
    bind			= ( str( bind[0] ) if bind[0] else address[0],
                                    int( bind[1] ) if len( bind ) > 1 and bind[1] else address[1] )

    # Set up logging level (-v...) and --log <file>
    levelmap 			= {
        0: logging.WARNING,
        1: logging.NORMAL,
        2: logging.DETAIL,
        3: logging.INFO,
        4: logging.DEBUG,
        }
    cpppo.log_cfg['level']	= ( levelmap[args.verbose] 
                                    if args.verbose in levelmap
                                    else logging.DEBUG )

    # Chain any provided idle_service function with log rotation; these may (also) consult global
    # signal flags such as logrotate_request, so execute supplied functions before logrotate_perform
    idle_service		= [ idle_service ] if idle_service else []
    if args.log:
        # Output logging to a file, and handle UNIX-y log file rotation via 'logrotate', which sends
        # signals to indicate that a service's log file has been moved/renamed and it should re-open
        cpppo.log_cfg['filename']= args.log
        signal.signal( signal.SIGHUP, logrotate_request )
        idle_service.append( logrotate_perform )

    logging.basicConfig( **cpppo.log_cfg )


    # Pull out a 'server.control...' supplied in the keywords, and make certain it's a
    # cpppo.apidict.  We'll use this to transmit control signals to the server thread.  Set the
    # current values to sane initial defaults/conditions.
    if 'server' in kwds:
        assert 'control' in kwds['server'], "A 'server' keyword provided without a 'control' attribute"
        srv_ctl			= cpppo.dotdict( kwds.pop( 'server' ))
        assert isinstance( srv_ctl['control'], cpppo.apidict ), "The server.control... must be a cpppo.apidict"
    else:
        srv_ctl.control		= cpppo.apidict( timeout=timeout )

    srv_ctl.control['done']	= False
    srv_ctl.control['disable']	= False
    srv_ctl.control.setdefault( 'latency', latency )

    # Global options data.  Copy any remaining keyword args supplied to main().  This could
    # include an alternative enip_process, for example, instead of defaulting to logix.process.
    options.update( kwds )

    # Specify a response delay.  The options.delay is another dotdict() layer, so it's attributes
    # (eg. .value, .range) are available to the web API for manipulation.  Therefore, they can be
    # set to arbitrary values at random times!  However, the type will be retained.
    def delay_range( *args, **kwds ):
        """If a delay.range like ".1-.9" is specified, then change the delay.value every second to something
        in that range."""
        assert 'delay' in kwds and 'range' in kwds['delay'] and '-' in kwds['delay']['range'], \
            "No delay=#-# specified"
        log.normal( "Delaying all responses by %s seconds", kwds['delay']['range'] )
        while True:
            # Once we start, changes to delay.range will be re-evaluated each loop
            time.sleep( 1 )
            try:
                lo,hi		= map( float, kwds['delay']['range'].split( '-' ))
                kwds['delay']['value'] = random.uniform( lo, hi )
                log.info( "Mutated delay == %g", kwds['delay']['value'] )
            except Exception as exc:
                log.warning( "No delay=#[.#]-#[.#] range specified: %s", exc )

    options.delay		= cpppo.dotdict()
    try:
        options.delay.value	= float( args.delay )
        log.normal( "Delaying all responses by %r seconds" , options.delay.value )
    except:
        assert '-' in args.delay, \
            "Unrecognized --delay=%r option" % args.delay
        # A range #-#; set up a thread to mutate the option.delay.value over the .range
        options.delay.range	= args.delay
        options.delay.value	= 0.0
        mutator			= threading.Thread( target=delay_range, kwargs=options )
        mutator.daemon		= True
        mutator.start()

    # Create all the specified tags/Attributes.  The enip_process function will (somehow) assign the
    # given tag name to reference the specified Attribute.  We'll define an Attribute to print
    # I/O if args.print is specified; reads will only be logged at logging.NORMAL and above.
    class Attribute_print( attribute_class ):
        def __getitem__( self, key ):
            value		= super( Attribute_print, self ).__getitem__( key )
            if log.isEnabledFor( logging.NORMAL ):
                print( "%20s[%5s-%-5s] == %s" % (
                    self.name, 
                    key.indices( len( self ))[0]   if isinstance( key, slice ) else key,
                    key.indices( len( self ))[1]-1 if isinstance( key, slice ) else key,
                    value ))
            return value

        def __setitem__( self, key, value ):
            super( Attribute_print, self ).__setitem__( key, value )
            print( "%20s[%5s-%-5s] <= %s" % (
                self.name, 
                key.indices( len( self ))[0]   if isinstance( key, slice ) else key,
                key.indices( len( self ))[1]-1 if isinstance( key, slice ) else key,
                value ))

    for t in args.tags:
        tag_name, rest		= t, ''
        if '=' in tag_name:
            tag_name, rest	= tag_name.split( '=', 1 )
        tag_type, rest		= rest or 'INT', ''
        tag_size		= 1
        if '[' in tag_type:
            tag_type, rest	= tag_type.split( '[', 1 )
            assert ']' in rest, "Invalid tag; mis-matched [...]"
            tag_size, rest	= rest.split( ']', 1 )
        assert not rest, "Invalid tag specified; expected tag=<type>[<size>]: %r" % t
        tag_type		= str( tag_type ).upper()
        typenames		= {"INT": parser.INT, "DINT": parser.DINT, "SINT": parser.SINT, "REAL": parser.REAL }
        assert tag_type in typenames, "Invalid tag type; must be one of %r" % list( typenames.keys() )
        tag_default		= 0.0 if tag_type == "REAL" else 0
        try:
            tag_size		= int( tag_size )
        except:
            raise AssertionError( "Invalid tag size: %r" % tag_size )

        # Ready to create the tag and its Attribute (and error code to return, if any).  If tag_size
        # is 1, it will be a scalar Attribute.  Since the tag_name may contain '.', we don't want
        # the normal dotdict.__setitem__ resolution to parse it; use plain dict.__setitem__.
        log.normal( "Creating tag: %s=%s[%d]", tag_name, tag_type, tag_size )
        tag_entry		= cpppo.dotdict()
        tag_entry.attribute	= ( Attribute_print if args.print else attribute_class )(
            tag_name, typenames[tag_type], default=( tag_default if tag_size == 1 else [tag_default] * tag_size ))
        tag_entry.error		= 0x00
        dict.__setitem__( tags, tag_name, tag_entry )

    # Use the Logix simulator by default (unless some other one was supplied as a keyword options to
    # main(), loaded above into 'options').  This key indexes an immutable value (not another
    # dotdict layer), so is not available for the web API to report/manipulate.
    options.setdefault( 'enip_process', logix.process )
    options.setdefault( 'identity_class', identity_class )

    # The Web API

    # Deduce web interface:port address to bind, and correct types (default is address, above).
    # Default to the same interface as we're bound to, port 80.  We'll only start if non-empty --web
    # was provided, though (even if it's just ':', to get all defaults).  Usually you'll want to
    # specify at least --web :[<port>].
    http			= args.web.split(':')
    assert 1 <= len( http ) <= 2, "Invalid --web [<interface>]:[<port>}: %s" % args.web
    http			= ( str( http[0] ) if http[0] else bind[0],
                                    int( http[1] ) if len( http ) > 1 and http[1] else 80 )


    if args.web:
        assert 'web' in sys.modules, "Failed to import web API module; --web option not available.  Run 'pip install web.py'"
        logging.normal( "EtherNet/IP Simulator Web API Server: %r" % ( http, ))
        webserver		= threading.Thread( target=web_api, kwargs={'http': http} )
        webserver.daemon	= True
        webserver.start()

        
    # The EtherNet/IP Simulator.  Pass all the top-level options keys/values as keywords, and pass
    # the entire tags dotdict as a tags=... keyword.  The server_main server.control signals (.done,
    # .disable) are also passed as the server= keyword.  We are using an cpppo.apidict with a long
    # timeout; this will block the web API for several seconds to allow all threads to respond to
    # the signals delivered via the web API.
    logging.normal( "EtherNet/IP Simulator: %r" % ( bind, ))
    kwargs			= dict( options, latency=latency, size=args.size, tags=tags, server=srv_ctl )

    tf				= network.server_thread
    tf_kwds			= dict()
    if args.profile:
        tf			= network.server_thread_profiling
        tf_kwds['filename']	= args.profile

    disabled			= False	# Recognize toggling between en/disabled
    while not srv_ctl.control.done:
        if not srv_ctl.control.disable:
            if disabled:
                logging.detail( "EtherNet/IP Server enabled" )
                disabled= False
            network.server_main( address=bind, target=enip_srv, kwargs=kwargs,
                                 idle_service=lambda: map( lambda f: f(), idle_service ),
                                 thread_factory=tf, **tf_kwds )
        else:
            if not disabled:
                logging.detail( "EtherNet/IP Server disabled" )
                disabled= True
            time.sleep( latency )            # Still disabled; wait a bit

    return 0
Example #11
0
File: main.py Project: ekw/cpppo
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()
Example #12
0
def main(argv=None, **kwds):
    """Pass the desired argv (excluding the program name in sys.arg[0]; typically
    pass argv=None, which is equivalent to argv=sys.argv[1:], the default for
    argparse.  Requires at least one tag to be defined.

    If a cpppo.apidict() is passed for kwds['server']['control'], we'll use it
    to transmit server control signals via its .done, .disable, .timeout and
    .latency attributes.

    """
    global address
    global options
    global tags
    global srv_ctl
    global latency
    global timeout

    ap = argparse.ArgumentParser(description="Provide an EtherNet/IP Server",
                                 epilog="")

    ap.add_argument('-v',
                    '--verbose',
                    default=0,
                    action="count",
                    help="Display logging information.")
    ap.add_argument(
        '-a',
        '--address',
        default=("%s:%d" % address),
        help="EtherNet/IP interface[:port] to bind to (default: %s:%d)" %
        (address[0], address[1]))
    ap.add_argument('-l', '--log', help="Log file, if desired")
    ap.add_argument(
        '-w',
        '--web',
        default="",
        help="Web API [interface]:[port] to bind to (default: %s, port 80)" %
        (address[0]))
    ap.add_argument(
        '-d',
        '--delay',
        help=
        "Delay response to each request by a certain number of seconds (default: 0.0)",
        default="0.0")
    ap.add_argument('-p',
                    '--profile',
                    help="Output profiling data to a file (default: None)",
                    default=None)
    ap.add_argument(
        'tags',
        nargs="+",
        help=
        "Any tags, their type (default: INT), and number (default: 1), eg: tag=INT[1000]"
    )

    args = ap.parse_args(argv)

    # Deduce interface:port address to bind, and correct types (default is address, above)
    bind = args.address.split(':')
    assert 1 <= len(
        bind
    ) <= 2, "Invalid --address [<interface>]:[<port>}: %s" % args.address
    bind = (str(bind[0]) if bind[0] else address[0],
            int(bind[1]) if len(bind) > 1 and bind[1] else address[1])

    # Set up logging level (-v...) and --log <file>
    levelmap = {
        0: logging.WARNING,
        1: logging.NORMAL,
        2: logging.DETAIL,
        3: logging.INFO,
        4: logging.DEBUG,
    }
    cpppo.log_cfg['level'] = (levelmap[args.verbose]
                              if args.verbose in levelmap else logging.DEBUG)

    idle_service = None
    if args.log:
        # Output logging to a file, and handle UNIX-y log file rotation via 'logrotate', which sends
        # signals to indicate that a service's log file has been moved/renamed and it should re-open
        cpppo.log_cfg['filename'] = args.log
        signal.signal(signal.SIGHUP, logrotate_request)
        idle_service = logrotate_perform

    logging.basicConfig(**cpppo.log_cfg)

    # Pull out a 'server.control...' supplied in the keywords, and make certain it's a
    # cpppo.apidict.  We'll use this to transmit control signals to the server thread.  Set the
    # current values to sane initial defaults/conditions.
    if 'server' in kwds:
        assert 'control' in kwds[
            'server'], "A 'server' keyword provided without a 'control' attribute"
        srv_ctl = cpppo.dotdict(kwds.pop('server'))
        assert isinstance(
            srv_ctl['control'],
            cpppo.apidict), "The server.control... must be a cpppo.apidict"
    else:
        srv_ctl.control = cpppo.apidict(timeout=timeout)

    srv_ctl.control['done'] = False
    srv_ctl.control['disable'] = False
    srv_ctl.control.setdefault('latency', latency)

    # Global options data.  Copy any remaining keyword args supplied to main().  This could
    # include an alternative enip_process, for example, instead of defaulting to logix.process.
    options.update(kwds)

    # Specify a response delay.  The options.delay is another dotdict() layer, so it's attributes
    # (eg. .value, .range) are available to the web API for manipulation.  Therefore, they can be
    # set to arbitrary values at random times!  However, the type will be retained.
    def delay_range(*args, **kwds):
        """If a delay.range like ".1-.9" is specified, then change the delay.value every second to something
        in that range."""
        assert 'delay' in kwds and 'range' in kwds['delay'] and '-' in kwds['delay']['range'], \
            "No delay=#-# specified"
        log.normal("Delaying all responses by %s seconds",
                   kwds['delay']['range'])
        while True:
            # Once we start, changes to delay.range will be re-evaluated each loop
            time.sleep(1)
            try:
                lo, hi = map(float, kwds['delay']['range'].split('-'))
                kwds['delay']['value'] = random.uniform(lo, hi)
                log.info("Mutated delay == %g", kwds['delay']['value'])
            except Exception as exc:
                log.warning("No delay=#[.#]-#[.#] range specified: %s", exc)

    options.delay = cpppo.dotdict()
    try:
        options.delay.value = float(args.delay)
        log.normal("Delaying all responses by %r seconds", options.delay.value)
    except:
        assert '-' in args.delay, \
            "Unrecognized --delay=%r option" % args.delay
        # A range #-#; set up a thread to mutate the option.delay.value over the .range
        options.delay.range = args.delay
        options.delay.value = 0.0
        mutator = threading.Thread(target=delay_range, kwargs=options)
        mutator.daemon = True
        mutator.start()

    # Create all the specified tags/Attributes.  The enip_process function will (somehow) assign the
    # given tag name to reference the specified Attribute.
    for t in args.tags:
        tag_name, rest = t, ''
        if '=' in tag_name:
            tag_name, rest = tag_name.split('=', 1)
        tag_type, rest = rest or 'INT', ''
        tag_size = 1
        if '[' in tag_type:
            tag_type, rest = tag_type.split('[', 1)
            assert ']' in rest, "Invalid tag; mis-matched [...]"
            tag_size, rest = rest.split(']', 1)
        assert not rest, "Invalid tag specified; expected tag=<type>[<size>]: %r" % t
        tag_type = str(tag_type).upper()
        typenames = {
            "INT": parser.INT,
            "DINT": parser.DINT,
            "SINT": parser.SINT,
            "REAL": parser.REAL
        }
        assert tag_type in typenames, "Invalid tag type; must be one of %r" % list(
            typenames.keys())
        try:
            tag_size = int(tag_size)
        except:
            raise AssertionError("Invalid tag size: %r" % tag_size)

        # Ready to create the tag and its Attribute (and error code to return, if any).  If tag_size
        # is 1, it will be a scalar Attribute.
        log.normal("Creating tag: %s=%s[%d]", tag_name, tag_type, tag_size)
        tags[tag_name] = cpppo.dotdict()
        tags[tag_name].attribute = device.Attribute(
            tag_name,
            typenames[tag_type],
            default=(0 if tag_size == 1 else [0] * tag_size))
        tags[tag_name].error = 0x00

    # Use the Logix simulator by default (unless some other one was supplied as a keyword options to
    # main(), loaded above into 'options').  This key indexes an immutable value (not another dotdict
    # layer), so is not available for the web API to report/manipulate.
    options.setdefault('enip_process', logix.process)

    # The Web API

    # Deduce web interface:port address to bind, and correct types (default is address, above).
    # Default to the same interface as we're bound to, port 80.  We'll only start if non-empty --web
    # was provided, though (even if it's just ':', to get all defaults).  Usually you'll want to
    # specify at least --web :[<port>].
    http = args.web.split(':')
    assert 1 <= len(
        http) <= 2, "Invalid --web [<interface>]:[<port>}: %s" % args.web
    http = (str(http[0]) if http[0] else bind[0],
            int(http[1]) if len(http) > 1 and http[1] else 80)

    if args.web:
        assert 'web' in sys.modules, "Failed to import web API module; --web option not available.  Run 'pip install web.py'"
        logging.normal("EtherNet/IP Simulator Web API Server: %r" % (http, ))
        webserver = threading.Thread(target=web_api, kwargs={'http': http})
        webserver.daemon = True
        webserver.start()

    # The EtherNet/IP Simulator.  Pass all the top-level options keys/values as keywords, and pass
    # the entire tags dotdict as a tags=... keyword.  The server_main server.control signals (.done,
    # .disable) are also passed as the server= keyword.  We are using an cpppo.apidict with a long
    # timeout; this will block the web API for several seconds to allow all threads to respond to
    # the signals delivered via the web API.
    logging.normal("EtherNet/IP Simulator: %r" % (bind, ))
    kwargs = dict(options, latency=latency, tags=tags, server=srv_ctl)

    tf = network.server_thread
    tf_kwds = dict()
    if args.profile:
        tf = network.server_thread_profiling
        tf_kwds['filename'] = args.profile

    disabled = False  # Recognize toggling between en/disabled
    while not srv_ctl.control.done:
        if not srv_ctl.control.disable:
            if disabled:
                logging.detail("EtherNet/IP Server enabled")
                disabled = False
            network.server_main(address=bind,
                                target=enip_srv,
                                kwargs=kwargs,
                                idle_service=idle_service,
                                thread_factory=tf,
                                **tf_kwds)
        else:
            if not disabled:
                logging.detail("EtherNet/IP Server disabled")
                disabled = True
            time.sleep(latency)  # Still disabled; wait a bit

    return 0
Example #13
0
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()
Example #14
0
def main(argv=None,
         attribute_class=device.Attribute,
         idle_service=None,
         identity_class=None,
         UCMM_class=None,
         message_router_class=None,
         connection_manager_class=None,
         **kwds):
    """Pass the desired argv (excluding the program name in sys.arg[0]; typically pass argv=None, which
    is equivalent to argv=sys.argv[1:], the default for argparse.  Requires at least one tag to be
    defined.

    If a cpppo.apidict() is passed for kwds['server']['control'], we'll use it to transmit server
    control signals via its .done, .disable, .timeout and .latency attributes.

    Uses the provided attribute_class (default: device.Attribute) to process all EtherNet/IP
    attribute I/O (eg. Read/Write Tag [Fragmented]) requests.  By default, device.Attribute stores
    and retrieves the supplied data.  To perform other actions (ie. forward the data to your own
    application), derive from device.Attribute, and override the __getitem__ and __setitem__
    methods.

    If an idle_service function is provided, it will be called after a period of latency between
    incoming requests.

    """
    global address
    global options
    global tags
    global srv_ctl
    global latency
    global timeout

    ap = argparse.ArgumentParser(description="Provide an EtherNet/IP Server",
                                 epilog="")

    ap.add_argument('-v',
                    '--verbose',
                    default=0,
                    action="count",
                    help="Display logging information.")
    ap.add_argument(
        '-a',
        '--address',
        default=("%s:%d" % address),
        help="EtherNet/IP interface[:port] to bind to (default: %s:%d)" %
        (address[0], address[1]))
    ap.add_argument('-p',
                    '--print',
                    default=False,
                    action='store_true',
                    help="Print a summary of operations to stdout")
    ap.add_argument('-l', '--log', help="Log file, if desired")
    ap.add_argument(
        '-w',
        '--web',
        default="",
        help="Web API [interface]:[port] to bind to (default: %s, port 80)" %
        (address[0]))
    ap.add_argument(
        '-d',
        '--delay',
        default="0.0",
        help=
        "Delay response to each request by a certain number of seconds (default: 0.0)"
    )
    ap.add_argument(
        '-s',
        '--size',
        help=
        "Limit EtherNet/IP encapsulated request size to the specified number of bytes (default: None)",
        default=None)
    ap.add_argument(
        '--route-path',
        default=None,
        help=
        "Route Path, in JSON, eg. %r (default: None); 0/false to accept only empty route_path"
        % (str(json.dumps(route_path_default))))
    ap.add_argument('-P',
                    '--profile',
                    default=None,
                    help="Output profiling data to a file (default: None)")
    ap.add_argument(
        'tags',
        nargs="+",
        help=
        "Any tags, their type (default: INT), and number (default: 1), eg: tag=INT[1000]"
    )

    args = ap.parse_args(argv)

    # Deduce interface:port address to bind, and correct types (default is address, above)
    bind = args.address.split(':')
    assert 1 <= len(
        bind
    ) <= 2, "Invalid --address [<interface>]:[<port>}: %s" % args.address
    bind = (str(bind[0]) if bind[0] else address[0],
            int(bind[1]) if len(bind) > 1 and bind[1] else address[1])

    # Set up logging level (-v...) and --log <file>
    levelmap = {
        0: logging.WARNING,
        1: logging.NORMAL,
        2: logging.DETAIL,
        3: logging.INFO,
        4: logging.DEBUG,
    }
    cpppo.log_cfg['level'] = (levelmap[args.verbose]
                              if args.verbose in levelmap else logging.DEBUG)

    # Chain any provided idle_service function with log rotation; these may (also) consult global
    # signal flags such as logrotate_request, so execute supplied functions before logrotate_perform
    idle_service = [idle_service] if idle_service else []
    if args.log:
        # Output logging to a file, and handle UNIX-y log file rotation via 'logrotate', which sends
        # signals to indicate that a service's log file has been moved/renamed and it should re-open
        cpppo.log_cfg['filename'] = args.log
        signal.signal(signal.SIGHUP, logrotate_request)
        idle_service.append(logrotate_perform)

    logging.basicConfig(**cpppo.log_cfg)

    # Pull out a 'server.control...' supplied in the keywords, and make certain it's a
    # cpppo.apidict.  We'll use this to transmit control signals to the server thread.  Set the
    # current values to sane initial defaults/conditions.
    if 'server' in kwds:
        assert 'control' in kwds[
            'server'], "A 'server' keyword provided without a 'control' attribute"
        srv_ctl = cpppo.dotdict(kwds.pop('server'))
        assert isinstance(
            srv_ctl['control'],
            cpppo.apidict), "The server.control... must be a cpppo.apidict"
    else:
        srv_ctl.control = cpppo.apidict(timeout=timeout)

    srv_ctl.control['done'] = False
    srv_ctl.control['disable'] = False
    srv_ctl.control.setdefault('latency', latency)

    # Global options data.  Copy any remaining keyword args supplied to main().  This could
    # include an alternative enip_process, for example, instead of defaulting to logix.process.
    options.update(kwds)

    # Specify a response delay.  The options.delay is another dotdict() layer, so it's attributes
    # (eg. .value, .range) are available to the web API for manipulation.  Therefore, they can be
    # set to arbitrary values at random times!  However, the type will be retained.
    def delay_range(*args, **kwds):
        """If a delay.range like ".1-.9" is specified, then change the delay.value every second to something
        in that range."""
        assert 'delay' in kwds and 'range' in kwds['delay'] and '-' in kwds['delay']['range'], \
            "No delay=#-# specified"
        log.normal("Delaying all responses by %s seconds",
                   kwds['delay']['range'])
        while True:
            # Once we start, changes to delay.range will be re-evaluated each loop
            time.sleep(1)
            try:
                lo, hi = map(float, kwds['delay']['range'].split('-'))
                kwds['delay']['value'] = random.uniform(lo, hi)
                log.info("Mutated delay == %g", kwds['delay']['value'])
            except Exception as exc:
                log.warning("No delay=#[.#]-#[.#] range specified: %s", exc)

    options.delay = cpppo.dotdict()
    try:
        options.delay.value = float(args.delay)
        log.normal("Delaying all responses by %r seconds", options.delay.value)
    except:
        assert '-' in args.delay, \
            "Unrecognized --delay=%r option" % args.delay
        # A range #-#; set up a thread to mutate the option.delay.value over the .range
        options.delay.range = args.delay
        options.delay.value = 0.0
        mutator = threading.Thread(target=delay_range, kwargs=options)
        mutator.daemon = True
        mutator.start()

    # Create all the specified tags/Attributes.  The enip_process function will (somehow) assign the
    # given tag name to reference the specified Attribute.  We'll define an Attribute to print
    # I/O if args.print is specified; reads will only be logged at logging.NORMAL and above.
    class Attribute_print(attribute_class):
        def __getitem__(self, key):
            value = super(Attribute_print, self).__getitem__(key)
            if log.isEnabledFor(logging.NORMAL):
                print("%20s[%5s-%-5s] == %s" %
                      (self.name, key.indices(len(self))[0] if isinstance(
                          key, slice) else key, key.indices(len(self))[1] -
                       1 if isinstance(key, slice) else key, value))
            return value

        def __setitem__(self, key, value):
            super(Attribute_print, self).__setitem__(key, value)
            print("%20s[%5s-%-5s] <= %s" %
                  (self.name, key.indices(len(self))[0] if isinstance(
                      key, slice) else key, key.indices(len(self))[1] -
                   1 if isinstance(key, slice) else key, value))

    for t in args.tags:
        tag_name, rest = t, ''
        if '=' in tag_name:
            tag_name, rest = tag_name.split('=', 1)
        tag_type, rest = rest or 'INT', ''
        tag_size = 1
        if '[' in tag_type:
            tag_type, rest = tag_type.split('[', 1)
            assert ']' in rest, "Invalid tag; mis-matched [...]"
            tag_size, rest = rest.split(']', 1)
        assert not rest, "Invalid tag specified; expected tag=<type>[<size>]: %r" % t
        tag_type = str(tag_type).upper()
        typenames = {
            "BOOL": (parser.BOOL, 0),
            "INT": (parser.INT, 0),
            "DINT": (parser.DINT, 0),
            "SINT": (parser.SINT, 0),
            "REAL": (parser.REAL, 0.0),
            "SSTRING": (parser.SSTRING, ''),
        }
        assert tag_type in typenames, "Invalid tag type; must be one of %r" % list(
            typenames)
        tag_class, tag_default = typenames[tag_type]
        try:
            tag_size = int(tag_size)
        except:
            raise AssertionError("Invalid tag size: %r" % tag_size)

        # Ready to create the tag and its Attribute (and error code to return, if any).  If tag_size
        # is 1, it will be a scalar Attribute.  Since the tag_name may contain '.', we don't want
        # the normal dotdict.__setitem__ resolution to parse it; use plain dict.__setitem__.
        log.normal("Creating tag: %s=%s[%d]", tag_name, tag_type, tag_size)
        tag_entry = cpppo.dotdict()
        tag_entry.attribute = (
            Attribute_print if args.print else attribute_class)(
                tag_name,
                tag_class,
                default=(tag_default if tag_size == 1 else [tag_default] *
                         tag_size))
        tag_entry.error = 0x00
        dict.__setitem__(tags, tag_name, tag_entry)

    # Use the Logix simulator and all the basic required default CIP message processing classes by
    # default (unless some other one was supplied as a keyword options to main(), loaded above into
    # 'options').  This key indexes an immutable value (not another dotdict layer), so is not
    # available for the web API to report/manipulate.  By default, we'll specify no route_path, so
    # any request route_path will be accepted.  Otherwise, we'll create a UCMM-derived class with
    # the specified route_path, which will filter only requests w/ the correct route_path.
    options.setdefault('enip_process', logix.process)
    if identity_class:
        options.setdefault('identity_class', identity_class)
    assert not UCMM_class or not args.route_path, \
        "Specify either a route-path, or a custom UCMM_class; not both"
    if args.route_path is not None:
        # Must be JSON, eg. '[{"link"...}]', or '0'/'false' to explicitly specify no route_path
        # accepted (must be empty in request)
        class UCMM_class_with_route(device.UCMM):
            route_path = json.loads(args.route_path)

        UCMM_class = UCMM_class_with_route
    if UCMM_class:
        options.setdefault('UCMM_class', UCMM_class)
    if message_router_class:
        options.setdefault('message_router_class', message_router_class)
    if connection_manager_class:
        options.setdefault('connection_manager_class',
                           connection_manager_class)

    # The Web API

    # Deduce web interface:port address to bind, and correct types (default is address, above).
    # Default to the same interface as we're bound to, port 80.  We'll only start if non-empty --web
    # was provided, though (even if it's just ':', to get all defaults).  Usually you'll want to
    # specify at least --web :[<port>].
    http = args.web.split(':')
    assert 1 <= len(
        http) <= 2, "Invalid --web [<interface>]:[<port>}: %s" % args.web
    http = (str(http[0]) if http[0] else bind[0],
            int(http[1]) if len(http) > 1 and http[1] else 80)

    if args.web:
        assert 'web' in sys.modules, "Failed to import web API module; --web option not available.  Run 'pip install web.py'"
        logging.normal("EtherNet/IP Simulator Web API Server: %r" % (http, ))
        webserver = threading.Thread(target=web_api, kwargs={'http': http})
        webserver.daemon = True
        webserver.start()

    # The EtherNet/IP Simulator.  Pass all the top-level options keys/values as keywords, and pass
    # the entire tags dotdict as a tags=... keyword.  The server_main server.control signals (.done,
    # .disable) are also passed as the server= keyword.  We are using an cpppo.apidict with a long
    # timeout; this will block the web API for several seconds to allow all threads to respond to
    # the signals delivered via the web API.
    logging.normal("EtherNet/IP Simulator: %r" % (bind, ))
    kwargs = dict(options,
                  latency=latency,
                  size=args.size,
                  tags=tags,
                  server=srv_ctl)

    tf = network.server_thread
    tf_kwds = dict()
    if args.profile:
        tf = network.server_thread_profiling
        tf_kwds['filename'] = args.profile

    disabled = False  # Recognize toggling between en/disabled
    while not srv_ctl.control.done:
        if not srv_ctl.control.disable:
            if disabled:
                logging.detail("EtherNet/IP Server enabled")
                disabled = False
            network.server_main(
                address=bind,
                target=enip_srv,
                kwargs=kwargs,
                idle_service=lambda: map(lambda f: f(), idle_service),
                thread_factory=tf,
                **tf_kwds)
        else:
            if not disabled:
                logging.detail("EtherNet/IP Server disabled")
                disabled = True
            time.sleep(latency)  # Still disabled; wait a bit

    return 0