"""
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()