changeset 558 | 840092ee4d97 |
parent 548 | 3d35d0eef197 |
child 603 | b58236f9ea7b |
557:d2e187c1f548 | 558:840092ee4d97 |
---|---|
82 parser.add_option('--reverse-domain', metavar='DOMAIN', |
82 parser.add_option('--reverse-domain', metavar='DOMAIN', |
83 help="Domain to use for hosts in reverse zone") |
83 help="Domain to use for hosts in reverse zone") |
84 |
84 |
85 parser.add_option('--reverse-zone', metavar='NET', |
85 parser.add_option('--reverse-zone', metavar='NET', |
86 help="Generate forward zone for given subnet (x.z.y)") |
86 help="Generate forward zone for given subnet (x.z.y)") |
87 |
|
88 # |
|
89 parser.add_option('--doctest', action='store_true', |
|
90 help="Run module doctests") |
|
87 |
91 |
88 # defaults |
92 # defaults |
89 parser.set_defaults( |
93 parser.set_defaults( |
90 loglevel = logging.WARN, |
94 loglevel = logging.WARN, |
91 |
95 |
103 level = options.loglevel, |
107 level = options.loglevel, |
104 ) |
108 ) |
105 |
109 |
106 return options, args |
110 return options, args |
107 |
111 |
112 class ZoneError (Exception) : |
|
113 pass |
|
114 |
|
115 class ZoneLineError (ZoneError) : |
|
116 """ |
|
117 ZoneLine-related error |
|
118 """ |
|
119 |
|
120 def __init__ (self, line, msg, *args, **kwargs) : |
|
121 super(ZoneLineError, self).__init__("%s: %s" % (line, msg.format(*args, **kwargs))) |
|
122 |
|
108 class ZoneLine (object) : |
123 class ZoneLine (object) : |
109 """ |
124 """ |
110 A line in a zonefile. |
125 A line in a zonefile. |
111 """ |
126 """ |
112 |
127 |
134 |
149 |
135 ts = None |
150 ts = None |
136 |
151 |
137 if line_timestamp_prefix : |
152 if line_timestamp_prefix : |
138 if ': ' not in line : |
153 if ': ' not in line : |
139 raise Exception("Missing timestamp prefix on line: %s:%d: %s" % (file, lineno, line)) |
154 raise ZoneError("%s:%d: Missing timestamp prefix: %s" % (file, lineno, line)) |
140 |
155 |
141 # split prefix |
156 # split prefix |
142 prefix, line = line.split(': ', 1) |
157 prefix, line = line.split(': ', 1) |
143 |
158 |
144 # parse it out |
159 # parse it out |
200 """ |
215 """ |
201 |
216 |
202 # the underlying line |
217 # the underlying line |
203 line = None |
218 line = None |
204 |
219 |
220 # possible $ORIGIN context |
|
221 origin = None |
|
222 |
|
205 # record fields |
223 # record fields |
206 name = None |
224 name = None |
207 type = None |
225 type = None |
208 |
226 |
209 # list of data fields |
227 # list of data fields |
212 # optional |
230 # optional |
213 ttl = None |
231 ttl = None |
214 cls = None |
232 cls = None |
215 |
233 |
216 @classmethod |
234 @classmethod |
217 def parse (cls, line) : |
235 def parse (cls, line, parts=None, origin=None) : |
218 """ |
236 """ |
219 Parse from ZoneLine. Returns None if there is no record on the line.. |
237 Parse from ZoneLine. Returns None if there is no record on the line.. |
220 """ |
238 """ |
221 |
239 |
222 if not line.parts : |
240 if parts is None : |
241 parts = list(line.parts) |
|
242 |
|
243 if not parts : |
|
223 # skip |
244 # skip |
224 return |
245 return |
225 |
246 |
226 # consume parts |
|
227 parts = list(line.parts) |
|
228 |
|
229 # indented lines don't have name |
247 # indented lines don't have name |
230 if line.indent : |
248 if line.indent : |
231 name = None |
249 name = None |
232 |
250 |
233 else : |
251 else : |
234 name = parts.pop(0) |
252 name = parts.pop(0) |
235 |
253 |
236 log.debug(" name=%r", name) |
254 log.debug(" name=%r, origin=%r", name, origin) |
255 |
|
256 if len(parts) < 2 : |
|
257 raise ZoneLineError(line, "Too few parts to parse: {0!r}", line.data) |
|
237 |
258 |
238 # parse ttl/cls/type |
259 # parse ttl/cls/type |
239 ttl = _cls = None |
260 ttl = _cls = None |
240 |
261 |
241 if parts and parts[0][0].isdigit() : |
262 if parts and parts[0][0].isdigit() : |
251 data = parts |
272 data = parts |
252 |
273 |
253 log.debug(" ttl=%r, cls=%r, type=%r, data=%r", ttl, _cls, type, data) |
274 log.debug(" ttl=%r, cls=%r, type=%r, data=%r", ttl, _cls, type, data) |
254 |
275 |
255 return cls(name, type, data, |
276 return cls(name, type, data, |
277 origin = origin, |
|
256 ttl = ttl, |
278 ttl = ttl, |
257 cls = _cls, |
279 cls = _cls, |
258 line = line, |
280 line = line, |
259 ) |
281 ) |
260 |
282 |
261 def __init__ (self, name, type, data, ttl=None, cls=None, line=None, comment=None) : |
283 def __init__ (self, name, type, data, origin=None, ttl=None, cls=None, line=None, comment=None) : |
262 self.name = name |
284 self.name = name |
263 self.type = type |
285 self.type = type |
264 self.data = data |
286 self.data = data |
265 |
287 |
266 self.ttl = ttl |
288 self.ttl = ttl |
267 self.cls = cls |
289 self.cls = cls |
268 |
290 |
291 self.origin = origin |
|
269 self.line = line |
292 self.line = line |
270 |
293 |
271 # XXX: within line |
294 # XXX: within line |
272 self._comment = comment |
295 self._comment = comment |
273 |
296 |
290 data = ' '.join(unicode(data) for data in self.data), |
313 data = ' '.join(unicode(data) for data in self.data), |
291 comment = comment, |
314 comment = comment, |
292 ) |
315 ) |
293 |
316 |
294 def __str__ (self) : |
317 def __str__ (self) : |
295 return ' '.join((self.name, self.type, ' '.join(self.data))) |
318 return ' '.join((self.name or '', self.type, ' '.join(self.data))) |
296 |
319 |
297 class TXTRecord (ZoneRecord) : |
320 class TXTRecord (ZoneRecord) : |
298 """ |
321 """ |
299 TXT record. |
322 TXT record. |
300 """ |
323 """ |
303 return super(TXTRecord, self).__init__(name, 'TXT', |
326 return super(TXTRecord, self).__init__(name, 'TXT', |
304 [u'"{0}"'.format(text.replace('"', '\\"'))], |
327 [u'"{0}"'.format(text.replace('"', '\\"'))], |
305 **opts |
328 **opts |
306 ) |
329 ) |
307 |
330 |
308 def parse_record (path, lineno, line, **opts) : |
331 class OffsetValue (object) : |
309 """ |
332 def __init__ (self, value) : |
310 Parse (name, ttl, type, data, comment) from bind zonefile. |
333 self.value = value |
311 |
334 |
312 Returns None for empty/comment lines. |
335 def __getitem__ (self, offset) : |
313 """ |
336 value = self.value + offset |
314 |
337 |
315 # line |
338 #log.debug("OffsetValue: %d[%d] -> %d", self.value, offset, value) |
316 line = ZoneLine.parse(path, lineno, line, **opts) |
339 |
317 record = ZoneRecord.parse(line) |
340 return value |
318 |
341 |
319 if record : |
342 def parse_generate_field (line, field) : |
320 return record |
343 """ |
321 |
344 Parse a $GENERATE lhs/rhs field: |
322 def parse_zone_records (file, **opts) : |
345 $ |
346 ${<offset>[,<width>[,<base>]]} |
|
347 \$ |
|
348 $$ |
|
349 |
|
350 Returns a wrapper that builds the field-value when called with the index. |
|
351 |
|
352 >>> parse_generate_field(None, "foo")(1) |
|
353 'foo' |
|
354 >>> parse_generate_field(None, "foo-$")(1) |
|
355 'foo-1' |
|
356 >>> parse_generate_field(None, "foo-$$")(1) |
|
357 'foo-$' |
|
358 >>> parse_generate_field(None, "\$")(1) |
|
359 '$' |
|
360 >>> parse_generate_field(None, "10.0.0.${100}")(1) |
|
361 '10.0.0.101' |
|
362 >>> parse_generate_field(None, "foo-${0,2,d}")(1) |
|
363 'foo-01' |
|
364 |
|
365 """ |
|
366 |
|
367 input = field |
|
368 expr = [] |
|
369 |
|
370 while '$' in field : |
|
371 # defaults |
|
372 offset = 0 |
|
373 width = 0 |
|
374 base = 'd' |
|
375 escape = False |
|
376 |
|
377 # different forms |
|
378 if '${' in field : |
|
379 pre, body = field.split('${', 1) |
|
380 body, post = body.split('}', 1) |
|
381 |
|
382 # parse body |
|
383 parts = body.split(',') |
|
384 |
|
385 # offset |
|
386 offset = int(parts.pop(0)) |
|
387 |
|
388 # width |
|
389 if parts : |
|
390 width = int(parts.pop(0)) |
|
391 |
|
392 # base |
|
393 if parts : |
|
394 base = parts.pop(0) |
|
395 |
|
396 if parts: |
|
397 # fail |
|
398 raise ZoneLineError(line, "extra data in ${...} body: {0!r}", parts) |
|
399 |
|
400 elif '$$' in field : |
|
401 pre, post = field.split('$$', 1) |
|
402 escape = True |
|
403 |
|
404 elif '\\$' in field : |
|
405 pre, post = field.split('\\$', 1) |
|
406 escape = True |
|
407 |
|
408 else : |
|
409 pre, post = field.split('$', 1) |
|
410 |
|
411 expr.append(pre) |
|
412 |
|
413 if escape : |
|
414 expr.append('$') |
|
415 |
|
416 else : |
|
417 # meta-format |
|
418 fmt = '{value[%d]:0%d%s}' % (offset, width, base) |
|
419 |
|
420 log.debug("field=%r -> pre=%r, fmt=%r, post=%r", field, expr, fmt, post) |
|
421 |
|
422 expr.append(fmt) |
|
423 |
|
424 field = post |
|
425 |
|
426 # final |
|
427 if field : |
|
428 expr.append(field) |
|
429 |
|
430 # combine |
|
431 expr = ''.join(expr) |
|
432 |
|
433 log.debug("%s: %s", input, expr) |
|
434 |
|
435 # processed |
|
436 def value_func (value) : |
|
437 # magic wrapper to implement offsets |
|
438 return expr.format(value=OffsetValue(value)) |
|
439 |
|
440 return value_func |
|
441 |
|
442 def process_generate (line, origin, parts) : |
|
443 """ |
|
444 Process a |
|
445 $GENERATE <start>-<stop>[/<step>] lhs [ttl] [class] type rhs [comment] |
|
446 directive into a series of ZoneResource's. |
|
447 """ |
|
448 |
|
449 range = parts.pop(0) |
|
450 |
|
451 # parse range |
|
452 if '/' in range : |
|
453 range, step = range.split('/') |
|
454 step = int(step) |
|
455 else : |
|
456 step = 1 |
|
457 |
|
458 start, stop = range.split('-') |
|
459 start = int(start) |
|
460 stop = int(stop) |
|
461 |
|
462 log.debug(" range: start=%r, stop=%r, step=%r", start, stop, step) |
|
463 |
|
464 # inclusive |
|
465 range = xrange(start, stop + 1, step) |
|
466 |
|
467 lhs_func = parse_generate_field(line, parts.pop(0)) |
|
468 rhs_func = parse_generate_field(line, parts.pop(-1)) |
|
469 body = parts |
|
470 |
|
471 for i in range : |
|
472 # build |
|
473 parts = [lhs_func(i)] + body + [rhs_func(i)] |
|
474 |
|
475 log.debug(" %03d: %r", i, parts) |
|
476 |
|
477 # parse |
|
478 yield ZoneRecord.parse(line, parts=parts, origin=origin) |
|
479 |
|
480 def parse_zone_records (file, origin=None, **opts) : |
|
323 """ |
481 """ |
324 Parse ZoneRecord items from the given zonefile, ignoring non-record lines. |
482 Parse ZoneRecord items from the given zonefile, ignoring non-record lines. |
325 """ |
483 """ |
326 |
484 |
327 for lineno, line in enumerate(file) : |
485 ttl = None |
328 record = parse_record(file.name, lineno, line, **opts) |
486 |
487 skip_multiline = False |
|
488 |
|
489 for lineno, raw_line in enumerate(file) : |
|
490 # parse comment |
|
491 if ';' in raw_line : |
|
492 line, comment = raw_line.split(';', 1) |
|
493 else : |
|
494 line = raw_line |
|
495 comment = None |
|
496 |
|
497 # XXX: handle multi-line statements... |
|
498 # start |
|
499 if '(' in line : |
|
500 skip_multiline = True |
|
501 |
|
502 log.warn("%s:%d: Start of multi-line statement: %s", file.name, lineno, raw_line) |
|
503 |
|
504 # end? |
|
505 if ')' in line : |
|
506 skip_multiline = False |
|
507 |
|
508 log.warn("%s:%d: End of multi-line statement: %s", file.name, lineno, raw_line) |
|
509 |
|
510 continue |
|
511 |
|
512 elif skip_multiline : |
|
513 log.warn("%s:%d: Multi-line statement: %s", file.name, lineno, raw_line) |
|
514 |
|
515 continue |
|
516 |
|
517 # parse |
|
518 line = ZoneLine.parse(file.name, lineno, raw_line, **opts) |
|
519 |
|
520 if not line.data : |
|
521 log.debug("%s: skip empty line: %s", line, raw_line) |
|
522 |
|
523 continue |
|
524 |
|
525 elif line.data.startswith('$') : |
|
526 # control record |
|
527 type = line.parts[0] |
|
528 |
|
529 if type == '$ORIGIN': |
|
530 # update |
|
531 origin = line.parts[1] |
|
532 |
|
533 log.info("%s: origin: %s", line, origin) |
|
534 |
|
535 elif type == '$GENERATE': |
|
536 # process... |
|
537 log.info("%s: generate: %s", line, line.parts) |
|
538 |
|
539 for record in process_generate(line, origin, line.parts[1:]) : |
|
540 yield record |
|
541 |
|
542 else : |
|
543 log.warning("%s: skip control record: %s", line, line.data) |
|
544 |
|
545 # XXX: passthrough! |
|
546 continue |
|
547 |
|
548 # normal record? |
|
549 record = ZoneRecord.parse(line, origin=origin) |
|
329 |
550 |
330 if record : |
551 if record : |
331 yield record |
552 yield record |
332 |
553 |
333 def check_zone_hosts (zone, whitelist=None) : |
554 else : |
555 # unknown |
|
556 log.warning("%s: skip unknown line: %s", line, line.data) |
|
557 |
|
558 def check_zone_hosts (zone, whitelist=None, whitelist_types=set(['TXT'])) : |
|
334 """ |
559 """ |
335 Parse host/IP pairs from the zone, and verify that they are unique. |
560 Parse host/IP pairs from the zone, and verify that they are unique. |
336 |
561 |
337 As an exception, names listed in the given whitelist may have multiple IPs. |
562 As an exception, names listed in the given whitelist may have multiple IPs. |
338 """ |
563 """ |
340 by_name = {} |
565 by_name = {} |
341 by_ip = {} |
566 by_ip = {} |
342 |
567 |
343 fail = None |
568 fail = None |
344 |
569 |
570 last_name = None |
|
571 |
|
345 for r in zone : |
572 for r in zone : |
346 name = r.name |
573 name = r.name or last_name |
574 |
|
575 name = (r.origin, name) |
|
347 |
576 |
348 # name |
577 # name |
349 if name not in by_name : |
578 if r.type not in whitelist_types : |
350 by_name[name] = r |
579 if name not in by_name : |
351 |
580 by_name[name] = r |
352 elif r.name in whitelist : |
581 |
353 log.debug("Duplicate whitelist entry: %s", r) |
582 elif r.name in whitelist : |
354 |
583 log.debug("Duplicate whitelist entry: %s", r) |
355 else : |
584 |
356 # fail! |
585 else : |
357 log.warn("%s: Duplicate name: %s <-> %s", r.line, r, by_name[name]) |
586 # fail! |
358 fail = True |
587 log.warn("%s: Duplicate name: %s <-> %s", r.line, r, by_name[name]) |
588 fail = True |
|
359 |
589 |
360 # ip |
590 # ip |
361 if r.type == 'A' : |
591 if r.type == 'A' : |
362 ip, = r.data |
592 ip, = r.data |
363 |
593 |
427 assert 0 <= octet <= 255 |
657 assert 0 <= octet <= 255 |
428 |
658 |
429 return '.'.join([str(octet) for octet in reversed(octets)] + ['in-addr', 'arpa']) |
659 return '.'.join([str(octet) for octet in reversed(octets)] + ['in-addr', 'arpa']) |
430 |
660 |
431 def fqdn (*parts) : |
661 def fqdn (*parts) : |
432 return '.'.join(parts) + '.' |
662 fqdn = '.'.join(parts) |
433 |
663 |
664 # we may be given an fqdn in parts |
|
665 if not fqdn.endswith('.') : |
|
666 fqdn += '.' |
|
667 |
|
668 return fqdn |
|
434 |
669 |
435 def process_zone_reverse (zone, origin, domain) : |
670 def process_zone_reverse (zone, origin, domain) : |
436 """ |
671 """ |
437 Process zone data -> reverse zone data. |
672 Process zone data -> reverse zone data. |
438 """ |
673 """ |
453 else : |
688 else : |
454 log.warning("Reverse does not match zone origin, skipping: (%s) -> %s <-> %s", ip, reverse, origin) |
689 log.warning("Reverse does not match zone origin, skipping: (%s) -> %s <-> %s", ip, reverse, origin) |
455 continue |
690 continue |
456 |
691 |
457 # domain to use |
692 # domain to use |
458 host_domain = domain |
693 host_domain = r.origin or domain |
459 host_fqdn = fqdn(r.name, domain) |
694 host_fqdn = fqdn(r.name, host_domain) |
460 |
695 |
461 yield ZoneRecord(reverse, 'PTR', [host_fqdn]) |
696 yield ZoneRecord(reverse, 'PTR', [host_fqdn]) |
462 |
697 |
463 def write_zone_records (file, zone) : |
698 def write_zone_records (file, zone) : |
464 for r in zone : |
699 for r in zone : |
486 def main (argv) : |
721 def main (argv) : |
487 global options |
722 global options |
488 |
723 |
489 options, args = parse_options(argv) |
724 options, args = parse_options(argv) |
490 |
725 |
726 if options.doctest : |
|
727 import doctest |
|
728 fail, total = doctest.testmod() |
|
729 return fail |
|
730 |
|
491 if args : |
731 if args : |
492 # open files |
732 # open files |
493 input_files = [open_file(path, 'r', options.input_charset) for path in args] |
733 input_files = [open_file(path, 'r', options.input_charset) for path in args] |
494 |
734 |
495 else : |
735 else : |