pvl.verkko.dhcp: import from pvl-collectd
authorTero Marttila <terom@paivola.fi>
Wed, 24 Oct 2012 21:03:43 +0300
changeset 32 12816e361b2d
parent 31 3e6d0feb115c
child 33 768983d2e71d
child 34 491f7da9d29b
pvl.verkko.dhcp: import from pvl-collectd
pvl/verkko/dhcp/__init__.py
pvl/verkko/dhcp/leases.py
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/pvl/verkko/dhcp/leases.py	Wed Oct 24 21:03:43 2012 +0300
@@ -0,0 +1,450 @@
+"""
+    DHCP dhcpd.leases handling/tracking
+"""
+
+import pvl.syslog.tail
+
+from datetime import datetime
+
+import logging; log = logging.getLogger('pvl.verkko.dhcp.leases')
+
+class DHCPLeasesParser (object) :
+    """
+        Simplistic parser for a dhcpd.leases file.
+
+        Doesn't implement the full spec, but a useful approximation.
+    """
+
+    def __init__ (self) :
+        self.block = None
+        self.items = []
+    
+    @classmethod
+    def split (cls, line) :
+        """
+            Split given line-data.
+            
+            >>> split = DHCPLeasesParser.split
+            >>> split('foo bar')
+            ['foo', 'bar']
+            >>> split('"foo"')
+            ['foo']
+            >>> split('foo "asdf quux" bar')
+            ['foo', 'asdf quux', 'bar']
+            >>> split('foo "asdf quux"')
+            ['foo', 'asdf quux']
+        """
+
+        # parse out one str
+        if '"' in line :
+            log.debug("%s", line)
+
+            # crude
+            pre, line = line.split('"', 1)
+            data, post = line.rsplit('"', 1)
+
+            return pre.split() + [data] + post.split()
+        else :
+            return line.split()
+
+    @classmethod
+    def lex (self, line) :
+        """
+            Yield tokens from the given lines.
+
+            >>> lex = DHCPLeasesParser.lex
+            >>> list(lex('foo;'))
+            [('item', ['foo'])]
+            >>> list(item for line in ['foo {', ' bar;', '}'] for item in lex(line))
+            [('open', ['foo']), ('item', ['bar']), ('close', None)]
+
+        """
+
+        log.debug("%s", line)
+
+        # comments?
+        if '#' in line :
+            line, comment = line.split('#', 1)
+        else :
+            comment = None
+
+        # clean?
+        line = line.strip()
+
+        # parse
+        if not line :
+            # ignore, empty/comment
+            return
+        
+        elif line.startswith('uid') :
+            # XXX: too hard to parse properly
+            return
+
+        elif '{' in line :
+            decl, line = line.split('{', 1)
+
+            # we are in a new decl
+            yield 'open', self.split(decl)
+       
+        elif ';' in line :
+            param, line = line.split(';', 1)
+            
+            # a stanza
+            yield 'item', self.split(param)
+        
+        elif '}' in line :
+            close, line = line.split('}', 1)
+
+            if close.strip() :
+                log.warn("Predata on close: %s", close)
+
+            # end
+            yield 'close', None
+    
+        else :
+            log.warn("Unknown line: %s", line)
+            return
+
+        # got the whole line?
+        if line.strip() :
+            log.warn("Data remains: %s", line)
+
+    def push_block (self, block) :
+        """
+            Open new block.
+        """
+        
+        # XXX: stack
+        assert not self.block
+
+        self.block = block
+        self.items = []
+
+    def feed_block (self, item) :
+        """
+            Add item to block
+        """
+
+        assert self.block
+
+        self.items.append(item)
+
+    def pop_block (self) :
+        """
+            Close block. Returns
+                (block, [items])
+        """
+
+        assert self.block
+
+        block, items = self.block, self.items
+
+        self.block = None
+        self.items = None
+
+        return block, items
+
+    def parse (self, line) :
+        """
+            Parse given line, yielding any complete blocks that come out.
+
+            >>> parser = DHCPLeasesParser()
+            >>> list(parser.parse_lines(['foo {', ' bar;', ' quux asdf;', '}']))
+            [(['foo'], [['bar'], ['quux', 'asdf']])]
+
+            >>> parser = DHCPLeasesParser()
+            >>> list(parser.parse('foo {'))
+            []
+            >>> list(parser.parse_lines([' bar;', ' quux asdf;']))
+            []
+            >>> list(parser.parse('}'))
+            [(['foo'], [['bar'], ['quux', 'asdf']])]
+        """
+
+        for token, args in self.lex(line) :
+            #log.debug("%s: %s [block=%s]", token, args, self.block)
+
+            if token == 'open' :
+                # open new block
+                block = args
+
+                if self.block :
+                    log.warn("nested blocks: %s > %s", self.block, block)
+                    continue
+            
+                log.debug("open block: %s", block)
+                self.push_block(block)
+            
+            elif token == 'close' :
+                log.debug("close block: %s", self.block)
+
+                # collected block items
+                yield self.pop_block()
+
+            # must be within block!
+            elif token == 'item' :
+                item = args
+
+                if not self.block :
+                    log.warn("token outside block: %s: %s", token, args)
+                    continue
+
+                log.debug("block %s item: %s", self.block, item)
+                self.feed_block(item)
+
+            else :
+                # ???
+                raise KeyError("Unknown token: {0}: {1}".format(token, args))
+    
+    def parse_lines (self, lines) :
+        """
+            Trivial wrapper around parse to parse multiple lines.
+        """
+
+        for line in lines :
+            for item in self.parse(line) :
+                yield item
+
+class DHCPLeasesDatabase (object) :
+    """
+        Process log-structured leases file.
+    """
+
+    LEASE_DATES = ('starts', 'ends', 'tstp', 'tsfp', 'atsfp', 'cltt')
+
+    # default format
+    LEASE_DATE_NEVER = 'never'
+    LEASE_DATE_FMT_DEFAULT = '%w %Y/%m/%d %H:%M:%S'
+
+    lease_date_fmt = LEASE_DATE_FMT_DEFAULT
+
+    def __init__ (self, path) :
+        """
+            path        - path to dhcpd.leases file
+        """
+
+        # tail; handles file re-writes
+        self.source = pvl.syslog.tail.TailFile(path)
+
+        # parser state
+        self.parser = DHCPLeasesParser()
+
+        # initial leases state
+        self.leases = None
+
+    def reset (self) :
+        """
+            Reset state, if we started to read a new file.
+        """
+
+        self.leases = {}
+
+    def process_lease_item_date (self, args) :
+        """
+            Process lease-item date spec into datetime.
+
+            Returns None if 'never'.
+        """
+
+        data = ' '.join(args)
+
+        if data == self.LEASE_DATE_NEVER :
+            return None
+        else :
+            return datetime.strptime(data, self.lease_date_fmt)
+
+    def process_lease_item (self, lease, item) :
+        """
+            Process a single item from the lease, updating the lease dict
+        """
+
+        item = list(item)
+
+        name = item.pop(0)
+        subname = item[0] if item else None
+
+        if name in self.LEASE_DATES :
+            lease[name] = self.process_lease_item_date(item)
+
+        elif name == 'hardware':
+            # args
+            lease['hwtype'], lease['hwaddr'] = item
+        
+        elif name == 'uid' :
+            lease['uid'], = item
+
+        elif name == 'client-hostname' :
+            lease['client-hostname'], = item
+        
+        elif name == 'abandoned' :
+            lease['abandoned'] = True
+
+        elif name == 'binding' :
+            _state, lease['binding-state'] = item
+
+        elif name == 'next' and subname == 'binding' :
+            _binding, _state, lease['next-binding-state'] = item
+
+        else :
+            log.warn("unknown lease item: %s: %s", name, item)
+
+    def process_lease (self, lease_name, items) :
+        """
+            Process given lease block to update our state.
+
+            Returns the lease object, and a possible old lease.
+        """
+
+        # replace any existing
+        lease = self.leases[lease_name] = {}
+
+        # meta
+        lease['lease'] = lease_name
+
+        # parse items
+        for item in items :
+            try :
+                self.process_lease_item(lease, item)
+
+            except Exception as ex:
+                log.warn("Failed to process lease item: %s: %s:", lease_name, item, exc_info=True)
+        
+        # k
+        log.debug("%-15s: %s", lease_name, lease)
+
+        return lease
+
+    def log_lease (self, lease, old_lease=None) :
+        """
+            Log given lease transition on stdout.
+        """
+
+        # log
+        if old_lease :
+            log.info("%-15s: %20s @ %8s <- %-8s @ %20s", old_lease['lease'],
+                old_lease.get('ends', '???'),
+                old_lease.get('next-binding-state', ''),    # optional
+                old_lease.get('binding-state', '???'), 
+                old_lease.get('starts', '???'),
+            )
+
+        log.info("%-15s: %20s @ %8s -> %-8s @ %20s", lease['lease'], 
+                lease.get('starts', '???'),
+                lease.get('binding-state', '???'), 
+                lease.get('next-binding-state', ''),    # optional
+                lease.get('ends', '???'),
+        )
+
+    def process_block (self, blockdata, log_leases=False) :
+        """
+            Process given block (from DHCPLeasesParser.parse()), to update state.
+        """
+
+        block, items = blockdata
+
+        type = block.pop(0)
+        args = block
+
+        if type == 'lease' :
+            if len(args) != 1 :
+                return log.warn("lease block with weird args, ignore: %s", args)
+            
+            # the lease address
+            lease, = args
+
+            log.debug("lease: %s: %s", lease, items)
+            
+            if lease in self.leases :
+                old = self.leases[lease]
+            else :
+                old = None
+
+            new = self.process_lease(lease, items)
+
+            if log_leases :
+                self.log_lease(new, old)
+
+            return new
+
+        else :
+            log.warn("unknown block: %s: %s", type, args)
+
+    def process (self) :
+        """
+            Read new lines from the leases database and update our state.
+            
+            XXX: Returns
+                (sync, leases)
+
+            whereby sync is normally False, and leases the set of (possibly) changed leases, unless during initial
+            startup and on database replacement, when the sync is True, and the entire set of valid leases is returned.
+        """
+        
+        # handle file replace by reading until EOF
+        sync = False
+#        leases = []
+
+        if self.leases is None :
+            # initial sync
+            self.reset()
+            sync = True
+        
+        # parse in any new lines from TailFile... yields None if the file was replaced
+        for line in self.source.readlines(eof_mark=True) :
+            if line is None :
+                log.info("Reading new dhcpd.leases")
+
+                # resync
+                self.reset()
+                sync = True
+
+            else :
+                # parse
+                for blockdata in self.parser.parse(line) :
+                    # don't log if syncing, only on normal updates (next tail-cycle)
+                    lease = self.process_block(blockdata, log_leases=(not sync))
+
+                    #if not sync :
+                    #    leases.append(lease)
+                    yield lease
+
+#        if sync :
+#            return True, self.leases.values()
+#        else :
+#            return False, leases
+
+    def __iter__ (self) :
+        """
+            Iterate over all leases.
+        """
+
+        return self._leases.itervalues()
+
+    # utils
+    def lease_state (self, lease) :
+        """
+            Get state for lease.
+        """
+
+        # count by state
+        starts = lease.get('starts')
+        state = lease.get('binding-state', 'unknown')
+        next_state = lease.get('next-binding-state')
+        ends = lease.get('ends')
+
+        #log.debug("%-15s: %s: %8s -> %-8s: %s", ip, starts, state, next_state or '', ends)
+
+        if next_state and ends and ends < datetime.now() :
+            # XXX: mark as, "expired", even they next-binding-state is probably "free"
+            state = 'expired' # lease['next-binding-state']
+
+        return state
+
+if __name__ == '__main__' :
+    import logging
+
+    logging.basicConfig()
+
+    import doctest
+    doctest.testmod()
+