blob: 5b3f145d978f53722ac749b86d71c78ce48f2cc5 [file] [log] [blame]
David K. Bainbridgeb5415042016-05-13 17:06:10 -07001#!/usr/bin/python
2import sys, threading, thread, subprocess, re, time, datetime, bisect, BaseHTTPServer
3from optparse import OptionParser
4from Queue import Queue
5
6def parse_timestamp(raw_str):
7 tokens = raw_str.split()
8
9 if len(tokens) == 1:
10 if tokens[0].lower() == 'never':
11 return 'never';
12
13 else:
14 raise Exception('Parse error in timestamp')
15
16 elif len(tokens) == 3:
17 return datetime.datetime.strptime(' '.join(tokens[1:]),
18 '%Y/%m/%d %H:%M:%S')
19
20 else:
21 raise Exception('Parse error in timestamp')
22
23def timestamp_is_ge(t1, t2):
24 if t1 == 'never':
25 return True
26
27 elif t2 == 'never':
28 return False
29
30 else:
31 return t1 >= t2
32
33
34def timestamp_is_lt(t1, t2):
35 if t1 == 'never':
36 return False
37
38 elif t2 == 'never':
39 return t1 != 'never'
40
41 else:
42 return t1 < t2
43
44
45def timestamp_is_between(t, tstart, tend):
46 return timestamp_is_ge(t, tstart) and timestamp_is_lt(t, tend)
47
48
49def parse_hardware(raw_str):
50 tokens = raw_str.split()
51
52 if len(tokens) == 2:
53 return tokens[1]
54
55 else:
56 raise Exception('Parse error in hardware')
57
58
59def strip_endquotes(raw_str):
60 return raw_str.strip('"')
61
62
63def identity(raw_str):
64 return raw_str
65
66
67def parse_binding_state(raw_str):
68 tokens = raw_str.split()
69
70 if len(tokens) == 2:
71 return tokens[1]
72
73 else:
74 raise Exception('Parse error in binding state')
75
76
77def parse_next_binding_state(raw_str):
78 tokens = raw_str.split()
79
80 if len(tokens) == 3:
81 return tokens[2]
82
83 else:
84 raise Exception('Parse error in next binding state')
85
86
87def parse_rewind_binding_state(raw_str):
88 tokens = raw_str.split()
89
90 if len(tokens) == 3:
91 return tokens[2]
92
93 else:
94 raise Exception('Parse error in next binding state')
95
96def parse_res_fixed_address(raw_str):
97 return raw_str
98
99def parse_res_hardware(raw_str):
100 tokens = raw_str.split()
101 return tokens[1]
102
103def parse_reservation_file(res_file):
104 valid_keys = {
105 'hardware' : parse_res_hardware,
106 'fixed-address' : parse_res_fixed_address,
107 }
108
109 res_db = {}
110 res_rec = {}
111 in_res = False
112 for line in res_file:
113 if line.lstrip().startswith('#'):
114 continue
115 tokens = line.split()
116
117 if len(tokens) == 0:
118 continue
119
120 key = tokens[0].lower()
121
122 if key == 'host':
123 if not in_res:
124 res_rec = {'hostname' : tokens[1]}
125 in_res = True
126
127 else:
128 raise Exception("Parse error in reservation file")
129 elif key == '}':
130 if in_res:
131 for k in valid_keys:
132 if callable(valid_keys[k]):
133 res_rec[k] = res_rec.get(k, '')
134 else:
135 res_rec[k] = False
136
137 hostname = res_rec['hostname']
138
139 if hostname in res_db:
140 res_db[hostname].insert(0, res_rec)
141
142 else:
143 res_db[hostname] = [res_rec]
144
145 res_rec = {}
146 in_res = False
147
148 else:
149 raise Exception('Parse error in reservation file')
150
151 elif key in valid_keys:
152 if in_res:
153 value = line[(line.index(key) + len(key)):]
154 value = value.strip().rstrip(';').rstrip()
155
156 if callable(valid_keys[key]):
157 res_rec[key] = valid_keys[key](value)
158 else:
159 res_rec[key] = True
160
161 else:
162 raise Exception('Parse error in reservation file')
163
164 else:
165 if in_res:
166 raise Exception('Parse error in reservation file')
167
168 if in_res:
169 raise Exception('Parse error in reservation file')
170
171 # Turn the leases into an array
172 results = []
173 for res in res_db:
174 results.append({
175 'client-hostname' : res_db[res][0]['hostname'],
176 'hardware' : res_db[res][0]['hardware'],
177 'ip_address' : res_db[res][0]['fixed-address'],
178 })
179 return results
180
181
182def parse_leases_file(leases_file):
183 valid_keys = {
184 'starts': parse_timestamp,
185 'ends': parse_timestamp,
186 'tstp': parse_timestamp,
187 'tsfp': parse_timestamp,
188 'atsfp': parse_timestamp,
189 'cltt': parse_timestamp,
190 'hardware': parse_hardware,
191 'binding': parse_binding_state,
192 'next': parse_next_binding_state,
193 'rewind': parse_rewind_binding_state,
194 'uid': strip_endquotes,
195 'client-hostname': strip_endquotes,
196 'option': identity,
197 'set': identity,
198 'on': identity,
199 'abandoned': None,
200 'bootp': None,
201 'reserved': None,
202 }
203
204 leases_db = {}
205
206 lease_rec = {}
207 in_lease = False
208 in_failover = False
209
210 for line in leases_file:
211 if line.lstrip().startswith('#'):
212 continue
213
214 tokens = line.split()
215
216 if len(tokens) == 0:
217 continue
218
219 key = tokens[0].lower()
220
221 if key == 'lease':
222 if not in_lease:
223 ip_address = tokens[1]
224
225 lease_rec = {'ip_address' : ip_address}
226 in_lease = True
227
228 else:
229 raise Exception('Parse error in leases file')
230
231 elif key == 'failover':
232 in_failover = True
233 elif key == '}':
234 if in_lease:
235 for k in valid_keys:
236 if callable(valid_keys[k]):
237 lease_rec[k] = lease_rec.get(k, '')
238 else:
239 lease_rec[k] = False
240
241 ip_address = lease_rec['ip_address']
242
243 if ip_address in leases_db:
244 leases_db[ip_address].insert(0, lease_rec)
245
246 else:
247 leases_db[ip_address] = [lease_rec]
248
249 lease_rec = {}
250 in_lease = False
251
252 elif in_failover:
253 in_failover = False
254 continue
255 else:
256 raise Exception('Parse error in leases file')
257
258 elif key in valid_keys:
259 if in_lease:
260 value = line[(line.index(key) + len(key)):]
261 value = value.strip().rstrip(';').rstrip()
262
263 if callable(valid_keys[key]):
264 lease_rec[key] = valid_keys[key](value)
265 else:
266 lease_rec[key] = True
267
268 else:
269 raise Exception('Parse error in leases file')
270
271 else:
272 if in_lease:
273 raise Exception('Parse error in leases file')
274
275 if in_lease:
276 raise Exception('Parse error in leases file')
277
278 return leases_db
279
280
281def round_timedelta(tdelta):
282 return datetime.timedelta(tdelta.days,
283 tdelta.seconds + (0 if tdelta.microseconds < 500000 else 1))
284
285
286def timestamp_now():
287 n = datetime.datetime.utcnow()
288 return datetime.datetime(n.year, n.month, n.day, n.hour, n.minute,
289 n.second)# + (0 if n.microsecond < 500000 else 1))
290
291
292def lease_is_active(lease_rec, as_of_ts):
293 return lease_rec['binding'] != 'free' and timestamp_is_between(as_of_ts, lease_rec['starts'],
294 lease_rec['ends'])
295
296
297def ipv4_to_int(ipv4_addr):
298 parts = ipv4_addr.split('.')
299 return (int(parts[0]) << 24) + (int(parts[1]) << 16) + \
300 (int(parts[2]) << 8) + int(parts[3])
301
302def select_active_leases(leases_db, as_of_ts):
303 retarray = []
304 sortedarray = []
305
306 for ip_address in leases_db:
307 lease_rec = leases_db[ip_address][0]
308
309 if lease_is_active(lease_rec, as_of_ts):
310 ip_as_int = ipv4_to_int(ip_address)
311 insertpos = bisect.bisect(sortedarray, ip_as_int)
312 sortedarray.insert(insertpos, ip_as_int)
313 retarray.insert(insertpos, lease_rec)
314
315 return retarray
316
317def matched(list, target):
318 if list == None:
319 return False
320
321 for r in list:
322 if re.match(r, target) != None:
323 return True
324 return False
325
326def convert_to_seconds(time_val):
327 num = int(time_val[:-1])
328 if time_val.endswith('s'):
329 return num
330 elif time_val.endswith('m'):
331 return num * 60
332 elif time_val.endswith('h'):
333 return num * 60 * 60
334 elif time_val.endswith('d'):
335 return num * 60 * 60 * 24
336
337def ping(ip, timeout):
338 cmd = ['ping', '-c', '1', '-w', timeout, ip]
339 try:
340 out = subprocess.check_output(cmd)
341 return True
342 except subprocess.CalledProcessError as e:
343 return False
344
345def ping_worker(list, to, respQ):
346 for lease in list:
347 respQ.put(
348 {
349 'verified': ping(lease['ip_address'], to),
350 'lease' : lease,
351 })
352
353def interruptable_get(q):
354 r = None
355 while True:
356 try:
357 return q.get(timeout=1000)
358 except Queue.Empty:
359 pass
360
361##############################################################################
362
363def harvest(options):
364
365 ifilter = None
366 if options.include != None:
367 ifilter = options.include.translate(None, ' ').split(',')
368
369 rfilter = None
370 if options.filter != None:
371 rfilter = options.filter.split(',')
372
373 myfile = open(options.leases, 'r')
374 leases = parse_leases_file(myfile)
375 myfile.close()
376
377 reservations = []
378 try:
379 with open(options.reservations, 'r') as res_file:
380 reservations = parse_reservation_file(res_file)
381 res_file.close()
382 except (IOError) as e:
383 pass
384
385 now = timestamp_now()
386 report_dataset = select_active_leases(leases, now) + reservations
387
388 verified = []
389 if options.verify:
390
391 # To verify is lease information is valid, i.e. that the host which got the lease still responding
392 # we ping the host. Not perfect, but good for the main use case. As the lease file can get long
393 # a little concurrency is used. The lease list is divided amoung workers and each worker takes
394 # a share.
395 respQ = Queue()
396 to = str(convert_to_seconds(options.timeout))
397 share = int(len(report_dataset) / options.worker_count)
398 extra = len(report_dataset) % options.worker_count
399 start = 0
400 for idx in range(0, options.worker_count):
401 end = start + share
402 if extra > 0:
403 end = end + 1
404 extra = extra - 1
405 worker = threading.Thread(target=ping_worker, args=(report_dataset[start:end], to, respQ))
406 worker.daemon = True
407 worker.start()
408 start = end
409
410 # All the verification work has been farmed out to worker threads, so sit back and wait for reponses.
411 # Once all responses are received we are done. Probably should put a time out here as well, but for
412 # now we expect a response for every lease, either positive or negative
413 count = 0
414 while count != len(report_dataset):
415 resp = interruptable_get(respQ)
416 count = count + 1
417 if resp['verified']:
418 print("INFO: verified host '%s' with address '%s'" % (resp['lease']['client-hostname'], resp['lease']['ip_address']))
419 verified.append(resp['lease'])
420 else:
421 print("INFO: dropping host '%s' with address '%s' (not verified)" % (resp['lease']['client-hostname'], resp['lease']['ip_address']))
422 else:
423 verified = report_dataset
424
425 # Look for duplicate names and add the compressed MAC as a suffix
426 names = {}
427 for lease in verified:
428 # If no client hostname use MAC
429 name = lease['client-hostname']
430 if 'client-hostname' not in lease or len(name) == 0:
431 name = "UNK-" + lease['hardware'].translate(None, ':').upper()
432
433 if name in names:
434 names[name] = '+'
435 else:
436 names[name] = '-'
437
438 size = 0
439 count = 0
440 for lease in verified:
441 name = lease['client-hostname']
442 if 'client-hostname' not in lease or len(name) == 0:
443 name = "UNK-" + lease['hardware'].translate(None, ':').upper()
444
445 if (ifilter != None and name in ifilter) or matched(rfilter, name):
446 if names[name] == '+':
447 lease['client-hostname'] = name + '-' + lease['hardware'].translate(None, ':').upper()
448 size = max(size, len(lease['client-hostname']))
449 count += 1
450
451 if options.dest == '-':
452 out=sys.stdout
453 else:
454 out=open(options.dest, 'w+')
455
456 for lease in verified:
457 name = lease['client-hostname']
458 if 'client-hostname' not in lease or len(name) == 0:
459 name = "UNK-" + lease['hardware'].translate(None, ':').upper()
460
461 if ifilter != None and name in ifilter or matched(rfilter, name):
David K. Bainbridgee80e4f92016-06-12 17:23:30 -0700462 out.write(format(name, '<'+str(size)) + ' IN A ' + lease['ip_address'] + ' ; ' + lease['hardware'] +'\n')
David K. Bainbridgeb5415042016-05-13 17:06:10 -0700463 if options.dest != '-':
464 out.close()
465 return count
466
467def reload_zone(rndc, server, port, key, zone):
468 cmd = [rndc, '-s', server]
469 if key != None:
470 cmd.extend(['-c', key])
471 cmd.extend(['-p', port, 'reload'])
472 if zone != None:
473 cmd.append(zone)
474
475 try:
476 out = subprocess.check_output(cmd)
477 print("INFO: [%s UTC] updated DNS sever" % time.asctime(time.gmtime()))
478 except subprocess.CalledProcessError as e:
479 print("ERROR: failed to update DNS server, exit code %d" % e.returncode)
480 print(e.output)
481
482def handleRequestsUsing(requestQ):
483 return lambda *args: ApiHandler(requestQ, *args)
484
485class ApiHandler(BaseHTTPServer.BaseHTTPRequestHandler):
486 def __init__(s, requestQ, *args):
487 s.requestQ = requestQ
488 BaseHTTPServer.BaseHTTPRequestHandler.__init__(s, *args)
489
490 def do_HEAD(s):
491 s.send_response(200)
492 s.send_header("Content-type", "application/json")
493 s.end_headers()
494
495 def do_POST(s):
496 if s.path == '/harvest':
497 waitQ = Queue()
498 s.requestQ.put(waitQ)
499 resp = waitQ.get(block=True, timeout=None)
500 s.send_response(200)
501 s.send_header('Content-type', 'application/json')
502 s.end_headers()
503
504 if resp == "QUIET":
505 s.wfile.write('{ "response" : "QUIET" }')
506 else:
507 s.wfile.write('{ "response" : "OK" }')
508
509 else:
510 s.send_response(404)
511
512 def do_GET(s):
513 """Respond to a GET request."""
514 s.send_response(404)
515
516def do_api(hostname, port, requestQ):
517 server_class = BaseHTTPServer.HTTPServer
518 httpd = server_class((hostname, int(port)), handleRequestsUsing(requestQ))
519 print("INFO: [%s UTC] Start API server on %s:%s" % (time.asctime(time.gmtime()), hostname, port))
520 try:
521 httpd.serve_forever()
522 except KeyboardInterrupt:
523 pass
524 httpd.server_close()
525 print("INFO: [%s UTC] Stop API server on %s:%s" % (time.asctime(time.gmtime()), hostname, port))
526
527def harvester(options, requestQ):
528 quiet = convert_to_seconds(options.quiet)
529 last = -1
530 resp = "OK"
531 while True:
532 responseQ = requestQ.get(block=True, timeout=None)
533 if last == -1 or (time.time() - last) > quiet:
534 work_field(options)
535 last = time.time()
536 resp = "OK"
537 else:
538 resp = "QUIET"
539
540 if responseQ != None:
541 responseQ.put(resp)
542
543def work_field(options):
544 start = datetime.datetime.now()
545 print("INFO: [%s UTC] starting to harvest hosts from DHCP" % (time.asctime(time.gmtime())))
546 count = harvest(options)
547 end = datetime.datetime.now()
548 delta = end - start
549 print("INFO: [%s UTC] harvested %d hosts, taking %d seconds" % (time.asctime(time.gmtime()), count, delta.seconds))
550 if options.update:
551 reload_zone(options.rndc, options.server, options.port, options.key, options.zone)
552
553def main():
554 parser = OptionParser()
555 parser.add_option('-l', '--leases', dest='leases', default='/dhcp/dhcpd.leases',
556 help="specifies the DHCP lease file from which to harvest")
557 parser.add_option('-x', '--reservations', dest='reservations', default='/etc/dhcp/dhcpd.reservations',
558 help="specified the reservation file as ISC DHCP doesn't update the lease file for fixed addresses")
559 parser.add_option('-d', '--dest', dest='dest', default='/bind/dhcp_harvest.inc',
560 help="specifies the file to write the additional DNS information")
561 parser.add_option('-i', '--include', dest='include', default=None,
562 help="list of hostnames to include when harvesting DNS information")
563 parser.add_option('-f', '--filter', dest='filter', default=None,
564 help="list of regex expressions to use as an include filter")
565 parser.add_option('-r', '--repeat', dest='repeat', default=None,
566 help="continues to harvest DHCP information every specified interval")
567 parser.add_option('-c', '--command', dest='rndc', default='rndc',
568 help="shell command to execute to cause reload")
569 parser.add_option('-k', '--key', dest='key', default=None,
570 help="rndc key file to use to access DNS server")
571 parser.add_option('-s', '--server', dest='server', default='127.0.0.1',
572 help="server to reload after generating updated dns information")
573 parser.add_option('-p', '--port', dest='port', default='954',
574 help="port on server to contact to reload server")
575 parser.add_option('-z', '--zone', dest='zone', default=None,
576 help="zone to reload after generating updated dns information")
577 parser.add_option('-u', '--update', dest='update', default=False, action='store_true',
578 help="update the DNS server, by reloading the zone")
579 parser.add_option('-y', '--verify', dest='verify', default=False, action='store_true',
580 help="verify the hosts with a ping before pushing them to DNS")
581 parser.add_option('-t', '--timeout', dest='timeout', default='1s',
582 help="specifies the duration to wait for a verification ping from a host")
583 parser.add_option('-a', '--apiserver', dest='apiserver', default='0.0.0.0',
584 help="specifies the interfaces on which to listen for API requests")
585 parser.add_option('-e', '--apiport', dest='apiport', default='8954',
586 help="specifies the port on which to listen for API requests")
587 parser.add_option('-q', '--quiet', dest='quiet', default='1m',
588 help="specifieds a minimum quiet period between actually harvest times.")
589 parser.add_option('-w', '--workers', dest='worker_count', type='int', default=5,
590 help="specifies the number of workers to use when verifying IP addresses")
591
592 (options, args) = parser.parse_args()
593
594 # Kick off a thread to listen for HTTP requests to force a re-evaluation
595 requestQ = Queue()
596 api = threading.Thread(target=do_api, args=(options.apiserver, options.apiport, requestQ))
597 api.daemon = True
598 api.start()
599
600 if options.repeat == None:
601 work_field(options)
602 else:
603 secs = convert_to_seconds(options.repeat)
604 farmer = threading.Thread(target=harvester, args=(options, requestQ))
605 farmer.daemon = True
606 farmer.start()
607 while True:
608 cropQ = Queue()
609 requestQ.put(cropQ)
610 interruptable_get(cropQ)
611 time.sleep(secs)
612
613if __name__ == "__main__":
614 main()