# HG changeset patch # User Tero Marttila # Date 1351101823 -10800 # Node ID 12816e361b2d9114bd9ce4cf8ae157f621f86455 # Parent 3e6d0feb115caf532de356b72512b025a09a5067 pvl.verkko.dhcp: import from pvl-collectd diff -r 3e6d0feb115c -r 12816e361b2d 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() +