pvl/verkko/dhcp/leases.py
changeset 169 a81ca751664d
parent 168 4e120851ff52
child 170 455573d46a33
equal deleted inserted replaced
168:4e120851ff52 169:a81ca751664d
     1 """
       
     2     DHCP dhcpd.leases handling/tracking
       
     3 """
       
     4 
       
     5 import pvl.syslog.tail
       
     6 
       
     7 from datetime import datetime
       
     8 
       
     9 import logging; log = logging.getLogger('pvl.verkko.dhcp.leases')
       
    10 
       
    11 class DHCPLeasesParser (object) :
       
    12     """
       
    13         Simplistic parser for a dhcpd.leases file.
       
    14 
       
    15         Doesn't implement the full spec, but a useful approximation.
       
    16     """
       
    17 
       
    18     def __init__ (self) :
       
    19         self.block = None
       
    20         self.items = []
       
    21     
       
    22     @classmethod
       
    23     def split (cls, line) :
       
    24         """
       
    25             Split given line-data.
       
    26             
       
    27             >>> split = DHCPLeasesParser.split
       
    28             >>> split('foo bar')
       
    29             ['foo', 'bar']
       
    30             >>> split('"foo"')
       
    31             ['foo']
       
    32             >>> split('foo "asdf quux" bar')
       
    33             ['foo', 'asdf quux', 'bar']
       
    34             >>> split('foo "asdf quux"')
       
    35             ['foo', 'asdf quux']
       
    36         """
       
    37 
       
    38         # parse out one str
       
    39         if '"' in line :
       
    40             log.debug("%s", line)
       
    41 
       
    42             # crude
       
    43             pre, line = line.split('"', 1)
       
    44             data, post = line.rsplit('"', 1)
       
    45 
       
    46             return pre.split() + [data] + post.split()
       
    47         else :
       
    48             return line.split()
       
    49 
       
    50     @classmethod
       
    51     def lex (self, line) :
       
    52         """
       
    53             Yield tokens from the given lines.
       
    54 
       
    55             >>> lex = DHCPLeasesParser.lex
       
    56             >>> list(lex('foo;'))
       
    57             [('item', ['foo'])]
       
    58             >>> list(item for line in ['foo {', ' bar;', '}'] for item in lex(line))
       
    59             [('open', ['foo']), ('item', ['bar']), ('close', None)]
       
    60 
       
    61         """
       
    62 
       
    63         log.debug("%s", line)
       
    64 
       
    65         # comments?
       
    66         if '#' in line :
       
    67             line, comment = line.split('#', 1)
       
    68         else :
       
    69             comment = None
       
    70 
       
    71         # clean?
       
    72         line = line.strip()
       
    73 
       
    74         # parse
       
    75         if not line :
       
    76             # ignore, empty/comment
       
    77             return
       
    78         
       
    79         elif line.startswith('uid') :
       
    80             # XXX: too hard to parse properly
       
    81             return
       
    82 
       
    83         elif '{' in line :
       
    84             decl, line = line.split('{', 1)
       
    85 
       
    86             # we are in a new decl
       
    87             yield 'open', self.split(decl)
       
    88        
       
    89         elif ';' in line :
       
    90             param, line = line.split(';', 1)
       
    91             
       
    92             # a stanza
       
    93             yield 'item', self.split(param)
       
    94         
       
    95         elif '}' in line :
       
    96             close, line = line.split('}', 1)
       
    97 
       
    98             if close.strip() :
       
    99                 log.warn("Predata on close: %s", close)
       
   100 
       
   101             # end
       
   102             yield 'close', None
       
   103     
       
   104         else :
       
   105             log.warn("Unknown line: %s", line)
       
   106             return
       
   107 
       
   108         # got the whole line?
       
   109         if line.strip() :
       
   110             log.warn("Data remains: %s", line)
       
   111 
       
   112     def push_block (self, block) :
       
   113         """
       
   114             Open new block.
       
   115         """
       
   116         
       
   117         # XXX: stack
       
   118         assert not self.block
       
   119 
       
   120         self.block = block
       
   121         self.items = []
       
   122 
       
   123     def feed_block (self, item) :
       
   124         """
       
   125             Add item to block
       
   126         """
       
   127 
       
   128         assert self.block
       
   129 
       
   130         self.items.append(item)
       
   131 
       
   132     def pop_block (self) :
       
   133         """
       
   134             Close block. Returns
       
   135                 (block, [items])
       
   136         """
       
   137 
       
   138         assert self.block
       
   139 
       
   140         block, items = self.block, self.items
       
   141 
       
   142         self.block = None
       
   143         self.items = None
       
   144 
       
   145         return block, items
       
   146 
       
   147     def parse (self, line) :
       
   148         """
       
   149             Parse given line, yielding any complete blocks that come out.
       
   150 
       
   151             >>> parser = DHCPLeasesParser()
       
   152             >>> list(parser.parse_lines(['foo {', ' bar;', ' quux asdf;', '}']))
       
   153             [(['foo'], [['bar'], ['quux', 'asdf']])]
       
   154 
       
   155             >>> parser = DHCPLeasesParser()
       
   156             >>> list(parser.parse('foo {'))
       
   157             []
       
   158             >>> list(parser.parse_lines([' bar;', ' quux asdf;']))
       
   159             []
       
   160             >>> list(parser.parse('}'))
       
   161             [(['foo'], [['bar'], ['quux', 'asdf']])]
       
   162         """
       
   163 
       
   164         for token, args in self.lex(line) :
       
   165             #log.debug("%s: %s [block=%s]", token, args, self.block)
       
   166 
       
   167             if token == 'open' :
       
   168                 # open new block
       
   169                 block = args
       
   170 
       
   171                 if self.block :
       
   172                     log.warn("nested blocks: %s > %s", self.block, block)
       
   173                     continue
       
   174             
       
   175                 log.debug("open block: %s", block)
       
   176                 self.push_block(block)
       
   177             
       
   178             elif token == 'close' :
       
   179                 log.debug("close block: %s", self.block)
       
   180 
       
   181                 # collected block items
       
   182                 yield self.pop_block()
       
   183 
       
   184             # must be within block!
       
   185             elif token == 'item' :
       
   186                 item = args
       
   187 
       
   188                 if not self.block :
       
   189                     log.warn("token outside block: %s: %s", token, args)
       
   190                     continue
       
   191 
       
   192                 log.debug("block %s item: %s", self.block, item)
       
   193                 self.feed_block(item)
       
   194 
       
   195             else :
       
   196                 # ???
       
   197                 raise KeyError("Unknown token: {0}: {1}".format(token, args))
       
   198     
       
   199     def parse_lines (self, lines) :
       
   200         """
       
   201             Trivial wrapper around parse to parse multiple lines.
       
   202         """
       
   203 
       
   204         for line in lines :
       
   205             for item in self.parse(line) :
       
   206                 yield item
       
   207 
       
   208 class DHCPLeasesDatabase (object) :
       
   209     """
       
   210         Process log-structured leases file.
       
   211     """
       
   212 
       
   213     LEASE_DATES = ('starts', 'ends', 'tstp', 'tsfp', 'atsfp', 'cltt')
       
   214 
       
   215     # default format
       
   216     LEASE_DATE_NEVER = 'never'
       
   217     LEASE_DATE_FMT_DEFAULT = '%w %Y/%m/%d %H:%M:%S'
       
   218 
       
   219     lease_date_fmt = LEASE_DATE_FMT_DEFAULT
       
   220 
       
   221     def __init__ (self, path) :
       
   222         """
       
   223             path        - path to dhcpd.leases file
       
   224         """
       
   225 
       
   226         # tail; handles file re-writes
       
   227         self.source = pvl.syslog.tail.TailFile(path)
       
   228 
       
   229         # parser state
       
   230         self.parser = DHCPLeasesParser()
       
   231 
       
   232         # initial leases state
       
   233         self.leases = None
       
   234 
       
   235     def reset (self) :
       
   236         """
       
   237             Reset state, if we started to read a new file.
       
   238         """
       
   239 
       
   240         self.leases = {}
       
   241 
       
   242     def process_lease_item_date (self, args) :
       
   243         """
       
   244             Process lease-item date spec into datetime.
       
   245 
       
   246             Returns None if 'never'.
       
   247         """
       
   248 
       
   249         data = ' '.join(args)
       
   250 
       
   251         if data == self.LEASE_DATE_NEVER :
       
   252             return None
       
   253         else :
       
   254             return datetime.strptime(data, self.lease_date_fmt)
       
   255 
       
   256     def process_lease_item (self, lease, item) :
       
   257         """
       
   258             Process a single item from the lease, updating the lease dict
       
   259         """
       
   260 
       
   261         item = list(item)
       
   262 
       
   263         name = item.pop(0)
       
   264         subname = item[0] if item else None
       
   265 
       
   266         if name in self.LEASE_DATES :
       
   267             lease[name] = self.process_lease_item_date(item)
       
   268 
       
   269         elif name == 'hardware':
       
   270             # args
       
   271             lease['hwtype'], lease['hwaddr'] = item
       
   272         
       
   273         elif name == 'uid' :
       
   274             lease['uid'], = item
       
   275 
       
   276         elif name == 'client-hostname' :
       
   277             lease['client-hostname'], = item
       
   278         
       
   279         elif name == 'abandoned' :
       
   280             lease['abandoned'] = True
       
   281 
       
   282         elif name == 'binding' :
       
   283             _state, lease['binding-state'] = item
       
   284 
       
   285         elif name == 'next' and subname == 'binding' :
       
   286             _binding, _state, lease['next-binding-state'] = item
       
   287 
       
   288         else :
       
   289             log.warn("unknown lease item: %s: %s", name, item)
       
   290 
       
   291     def process_lease (self, lease_name, items) :
       
   292         """
       
   293             Process given lease block to update our state.
       
   294 
       
   295             Returns the lease object, and a possible old lease.
       
   296         """
       
   297 
       
   298         # replace any existing
       
   299         lease = self.leases[lease_name] = {}
       
   300 
       
   301         # meta
       
   302         lease['lease'] = lease_name
       
   303 
       
   304         # parse items
       
   305         for item in items :
       
   306             try :
       
   307                 self.process_lease_item(lease, item)
       
   308 
       
   309             except Exception as ex:
       
   310                 log.warn("Failed to process lease item: %s: %s:", lease_name, item, exc_info=True)
       
   311         
       
   312         # k
       
   313         log.debug("%-15s: %s", lease_name, lease)
       
   314 
       
   315         return lease
       
   316 
       
   317     def log_lease (self, lease, old_lease=None) :
       
   318         """
       
   319             Log given lease transition on stdout.
       
   320         """
       
   321 
       
   322         # log
       
   323         if old_lease :
       
   324             log.info("%-15s: %20s @ %8s <- %-8s @ %20s", old_lease['lease'],
       
   325                 old_lease.get('ends', '???'),
       
   326                 old_lease.get('next-binding-state', ''),    # optional
       
   327                 old_lease.get('binding-state', '???'), 
       
   328                 old_lease.get('starts', '???'),
       
   329             )
       
   330 
       
   331         log.info("%-15s: %20s @ %8s -> %-8s @ %20s", lease['lease'], 
       
   332                 lease.get('starts', '???'),
       
   333                 lease.get('binding-state', '???'), 
       
   334                 lease.get('next-binding-state', ''),    # optional
       
   335                 lease.get('ends', '???'),
       
   336         )
       
   337 
       
   338     def process_block (self, blockdata, log_leases=False) :
       
   339         """
       
   340             Process given block (from DHCPLeasesParser.parse()), to update state.
       
   341         """
       
   342 
       
   343         block, items = blockdata
       
   344 
       
   345         type = block.pop(0)
       
   346         args = block
       
   347 
       
   348         if type == 'lease' :
       
   349             if len(args) != 1 :
       
   350                 return log.warn("lease block with weird args, ignore: %s", args)
       
   351             
       
   352             # the lease address
       
   353             lease, = args
       
   354 
       
   355             log.debug("lease: %s: %s", lease, items)
       
   356             
       
   357             if lease in self.leases :
       
   358                 old = self.leases[lease]
       
   359             else :
       
   360                 old = None
       
   361 
       
   362             new = self.process_lease(lease, items)
       
   363 
       
   364             if log_leases :
       
   365                 self.log_lease(new, old)
       
   366 
       
   367             return new
       
   368 
       
   369         else :
       
   370             log.warn("unknown block: %s: %s", type, args)
       
   371 
       
   372     def process (self) :
       
   373         """
       
   374             Read new lines from the leases database and update our state.
       
   375             
       
   376             XXX: Returns
       
   377                 (sync, leases)
       
   378 
       
   379             whereby sync is normally False, and leases the set of (possibly) changed leases, unless during initial
       
   380             startup and on database replacement, when the sync is True, and the entire set of valid leases is returned.
       
   381         """
       
   382         
       
   383         # handle file replace by reading until EOF
       
   384         sync = False
       
   385 #        leases = []
       
   386 
       
   387         if self.leases is None :
       
   388             # initial sync
       
   389             self.reset()
       
   390             sync = True
       
   391         
       
   392         # parse in any new lines from TailFile... yields None if the file was replaced
       
   393         for line in self.source.readlines(eof_mark=True) :
       
   394             if line is None :
       
   395                 log.info("Reading new dhcpd.leases")
       
   396 
       
   397                 # resync
       
   398                 self.reset()
       
   399                 sync = True
       
   400 
       
   401             else :
       
   402                 # parse
       
   403                 for blockdata in self.parser.parse(line) :
       
   404                     # don't log if syncing, only on normal updates (next tail-cycle)
       
   405                     lease = self.process_block(blockdata, log_leases=(not sync))
       
   406 
       
   407                     #if not sync :
       
   408                     #    leases.append(lease)
       
   409                     yield lease
       
   410 
       
   411 #        if sync :
       
   412 #            return True, self.leases.values()
       
   413 #        else :
       
   414 #            return False, leases
       
   415 
       
   416     def __iter__ (self) :
       
   417         """
       
   418             Iterate over all leases.
       
   419         """
       
   420 
       
   421         return self.leases.itervalues()
       
   422 
       
   423     # utils
       
   424     def lease_state (self, lease) :
       
   425         """
       
   426             Get state for lease.
       
   427         """
       
   428 
       
   429         # count by state
       
   430         starts = lease.get('starts')
       
   431         state = lease.get('binding-state', 'unknown')
       
   432         next_state = lease.get('next-binding-state')
       
   433         ends = lease.get('ends')
       
   434 
       
   435         #log.debug("%-15s: %s: %8s -> %-8s: %s", ip, starts, state, next_state or '', ends)
       
   436 
       
   437         if next_state and ends and ends < datetime.now() :
       
   438             # XXX: mark as, "expired", even they next-binding-state is probably "free"
       
   439             state = 'expired' # lease['next-binding-state']
       
   440 
       
   441         return state
       
   442 
       
   443 if __name__ == '__main__' :
       
   444     import logging
       
   445 
       
   446     logging.basicConfig()
       
   447 
       
   448     import doctest
       
   449     doctest.testmod()
       
   450