pvl/dhcp/leases.py
author Tero Marttila <terom@paivola.fi>
Sat, 26 Jan 2013 11:49:16 +0200
changeset 174 6f339a8a87dc
parent 169 a81ca751664d
child 211 cf74bbb95d2b
permissions -rw-r--r--
split pvl.dhcp-leases from pvl.syslog-dhcp using pvl.dhcp.hosts/syslog/leases
"""
    DHCP dhcpd.leases handling/tracking
"""

import pvl.syslog.tail # TailFile

from datetime import datetime

import logging; log = logging.getLogger('pvl.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 DHCPLeases (object) :
    """
        Process log-structured leases file, updated by dhcpd.
    """

    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.Tail(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 readleases (self) :
        """
            Read new lines from the leases database and update our state.

            Yields changed leases. On startup and on periodic database reset, all leases are yielded.
        """
        
        # 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

    __iter__ = readleases

    def leases (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

# XXX: from db.dhcp_leases instead?
import pvl.verkko.db as db

class DHCPLeasesDatabase (object) :
    """
        pvl.verkko.Database dhcp_leases model for updates.
    """

    def __init__ (self, db) :
        """
            db      - pvl.verkko.Database
        """

        self.db = db

    def create (self) :
        """
            CREATE TABLEs
        """

        log.info("Creating database tables: dhcp_leases")
        db.dhcp_leases.create(self.db.engine, checkfirst=True)

    def update (self, lease) :
        """
            Try an extend an existing lease?
        """

        c = db.dhcp_leases.c

        ip = lease['lease']
        mac = lease.get('hwaddr')
        starts = lease['starts']
        ends = lease.get('ends')

        update = db.dhcp_leases.update()
        
        # XXX: if ends is None?
        if mac :
            # renew lease..?
            update = update.where((c.ip == ip) & (c.mac == mac) & ((starts < c.ends) | (c.ends == None)))
        else :
            # new state for lease..?
            update = update.where((c.ip == ip) & ((starts < c.ends) | (c.ends == ends)))

        update = update.values(
                state       = lease['binding-state'],
                next        = lease.get('next-binding-state'),
                ends        = ends,
        )

        if lease.get('client-hostname') :
            update = update.values(hostname = lease['client-hostname'])

        return self.db.update(update) > 0

    def insert (self, lease) :
        """
            Record a new lease.
        """

        c = db.dhcp_leases.c

        query = db.dhcp_leases.insert().values(
            ip          = lease['lease'],
            mac         = lease['hwaddr'],
            hostname    = lease.get('client-hostname'),

            starts      = lease['starts'],
            ends        = lease.get('ends'),

            state       = lease['binding-state'],
            next        = lease.get('next-binding-state'),
        )

        return self.db.insert(query)

    def __call__ (self, lease) :
        """
            Process given DHCP lease to update currently active lease, or insert a new one.

            XXX: transaction? *leases?
        """

        # update existing?
        if self.update(lease) :
            log.info("Update: %s", lease)

        elif lease.get('hwaddr') :
            # new
            id = self.insert(lease)

            log.info("Insert: %s -> %d", lease, id)

        else :
            # may be a free lease
            log.warn("Ignored lease: %s", lease)


if __name__ == '__main__' :
    import logging

    logging.basicConfig()

    import doctest
    doctest.testmod()