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 |
|