--- /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()
+