pvl/dhcp/leases.py
author Tero Marttila <terom@paivola.fi>
Sun, 07 Sep 2014 14:21:56 +0300
changeset 424 e77e967d59b0
parent 420 ee31a0b573f2
permissions -rw-r--r--
hgignore: use glob; ignore snmp mibs
"""
    DHCP dhcpd.leases handling/tracking
"""

import pvl.syslog.tail # TailFile

from datetime import datetime
from pvl.dhcp.config import DHCPConfigParser

import logging; log = logging.getLogger('pvl.dhcp.leases')

DHCPD_LEASES = '/var/lib/dhcp/dhcpd.leases'

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=DHCPD_LEASES) :
        """
            path        - path to dhcpd.leases file
        """

        # tail; handles file re-writes
        self.source = pvl.syslog.tail.Tail(path)

        # parser state
        self.parser = DHCPConfigParser()

        # 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 DHCPConfigParser.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(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')
        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)
        
        # XXX: datetime UTC or local?
        if next_state and ends and ends < datetime.now() :
            state = next_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()