85 help="Domain to use for hosts in reverse zone") |
76 help="Domain to use for hosts in reverse zone") |
86 |
77 |
87 parser.add_option('--reverse-zone', metavar='NET', |
78 parser.add_option('--reverse-zone', metavar='NET', |
88 help="Generate forward zone for given subnet (x.z.y | a:b:c:d)") |
79 help="Generate forward zone for given subnet (x.z.y | a:b:c:d)") |
89 |
80 |
90 # |
|
91 parser.add_option('--doctest', action='store_true', |
|
92 help="Run module doctests") |
|
93 |
|
94 # defaults |
81 # defaults |
95 parser.set_defaults( |
82 parser.set_defaults( |
96 loglevel = logging.WARN, |
|
97 |
|
98 # XXX: combine |
83 # XXX: combine |
99 check_exempt = [], |
84 check_exempt = [], |
100 meta_ignore = [], |
85 meta_ignore = [], |
101 ) |
86 ) |
102 |
87 |
103 # parse |
88 # parse |
104 options, args = parser.parse_args(argv[1:]) |
89 options, args = parser.parse_args(argv[1:]) |
105 |
90 |
106 # configure |
91 # apply |
107 logging.basicConfig( |
92 pvl.args.apply(options, prog) |
108 format = prog + ': %(name)s: %(levelname)s %(funcName)s : %(message)s', |
|
109 level = options.loglevel, |
|
110 ) |
|
111 |
93 |
112 return options, args |
94 return options, args |
113 |
|
114 class ZoneError (Exception) : |
|
115 pass |
|
116 |
|
117 class ZoneLineError (ZoneError) : |
|
118 """ |
|
119 ZoneLine-related error |
|
120 """ |
|
121 |
|
122 def __init__ (self, line, msg, *args, **kwargs) : |
|
123 super(ZoneLineError, self).__init__("%s: %s" % (line, msg.format(*args, **kwargs))) |
|
124 |
|
125 class ZoneLine (object) : |
|
126 """ |
|
127 A line in a zonefile. |
|
128 """ |
|
129 |
|
130 file = None |
|
131 lineno = None |
|
132 |
|
133 # data |
|
134 indent = None # was the line indented? |
|
135 data = None |
|
136 parts = None # split line fields |
|
137 |
|
138 # optional |
|
139 timestamp = None |
|
140 comment = None |
|
141 |
|
142 PARSE_DATETIME_FORMAT = '%Y-%m-%d' |
|
143 |
|
144 @classmethod |
|
145 def parse (cls, file, lineno, line, line_timestamp_prefix=False) : |
|
146 """ |
|
147 Parse out given line and build. |
|
148 """ |
|
149 |
|
150 log.debug("parse: %s:%d: %s", file, lineno, line) |
|
151 |
|
152 ts = None |
|
153 |
|
154 if line_timestamp_prefix : |
|
155 if ': ' not in line : |
|
156 raise ZoneError("%s:%d: Missing timestamp prefix: %s" % (file, lineno, line)) |
|
157 |
|
158 # split prefix |
|
159 prefix, line = line.split(': ', 1) |
|
160 |
|
161 # parse it out |
|
162 ts = datetime.strptime(prefix, cls.PARSE_DATETIME_FORMAT) |
|
163 |
|
164 log.debug(" ts=%r", ts) |
|
165 |
|
166 # was line indented? |
|
167 indent = line.startswith(' ') or line.startswith('\t') |
|
168 |
|
169 # strip |
|
170 line = line.strip() |
|
171 |
|
172 log.debug(" indent=%r, line=%r", indent, line) |
|
173 |
|
174 # parse comment out? |
|
175 if ';' in line : |
|
176 line, comment = line.split(';', 1) |
|
177 |
|
178 line = line.strip() |
|
179 comment = comment.strip() |
|
180 |
|
181 else : |
|
182 line = line.strip() |
|
183 comment = None |
|
184 |
|
185 log.debug(" line=%r, comment=%r", line, comment) |
|
186 |
|
187 # parse fields |
|
188 if '"' in line : |
|
189 pre, data, post = line.split('"', 2) |
|
190 parts = pre.split() + [data] + post.split() |
|
191 |
|
192 else : |
|
193 parts = line.split() |
|
194 |
|
195 log.debug(" parts=%r", parts) |
|
196 |
|
197 # build |
|
198 return cls(file, lineno, indent, line, parts, timestamp=ts, comment=comment) |
|
199 |
|
200 def __init__ (self, file, lineno, indent, data, parts, timestamp=None, comment=None) : |
|
201 self.file = file |
|
202 self.lineno = lineno |
|
203 |
|
204 self.indent = indent |
|
205 self.data = data |
|
206 self.parts = parts |
|
207 |
|
208 self.timestamp = timestamp |
|
209 self.comment = comment |
|
210 |
|
211 def __str__ (self) : |
|
212 return "{file}:{lineno}".format(file=self.file, lineno=self.lineno) |
|
213 |
|
214 class ZoneRecord (object) : |
|
215 """ |
|
216 A record from a zonefile. |
|
217 """ |
|
218 |
|
219 # the underlying line |
|
220 line = None |
|
221 |
|
222 # possible $ORIGIN context |
|
223 origin = None |
|
224 |
|
225 # record fields |
|
226 name = None |
|
227 type = None |
|
228 |
|
229 # list of data fields |
|
230 data = None |
|
231 |
|
232 # optional |
|
233 ttl = None |
|
234 cls = None |
|
235 |
|
236 @classmethod |
|
237 def parse (cls, line, parts=None, origin=None) : |
|
238 """ |
|
239 Parse from ZoneLine. Returns None if there is no record on the line.. |
|
240 """ |
|
241 |
|
242 if parts is None : |
|
243 parts = list(line.parts) |
|
244 |
|
245 if not parts : |
|
246 # skip |
|
247 return |
|
248 |
|
249 # XXX: indented lines keep name from previous record |
|
250 if line.indent : |
|
251 name = None |
|
252 |
|
253 else : |
|
254 name = parts.pop(0) |
|
255 |
|
256 log.debug(" name=%r, origin=%r", name, origin) |
|
257 |
|
258 if len(parts) < 2 : |
|
259 raise ZoneLineError(line, "Too few parts to parse: {0!r}", line.data) |
|
260 |
|
261 # parse ttl/cls/type |
|
262 ttl = _cls = None |
|
263 |
|
264 if parts and parts[0][0].isdigit() : |
|
265 ttl = parts.pop(0) |
|
266 |
|
267 if parts and parts[0].upper() in ('IN', 'CH') : |
|
268 _cls = parts.pop(0) |
|
269 |
|
270 # always have type |
|
271 type = parts.pop(0) |
|
272 |
|
273 # remaining parts are data |
|
274 data = parts |
|
275 |
|
276 log.debug(" ttl=%r, cls=%r, type=%r, data=%r", ttl, _cls, type, data) |
|
277 |
|
278 return cls(name, type, data, |
|
279 origin = origin, |
|
280 ttl = ttl, |
|
281 cls = _cls, |
|
282 line = line, |
|
283 ) |
|
284 |
|
285 def __init__ (self, name, type, data, origin=None, ttl=None, cls=None, line=None, comment=None) : |
|
286 self.name = name |
|
287 self.type = type |
|
288 self.data = data |
|
289 |
|
290 self.ttl = ttl |
|
291 self.cls = cls |
|
292 |
|
293 self.origin = origin |
|
294 self.line = line |
|
295 |
|
296 # XXX: within line |
|
297 self._comment = comment |
|
298 |
|
299 def build_line (self) : |
|
300 """ |
|
301 Construct a zonefile-format line..." |
|
302 """ |
|
303 |
|
304 # XXX: comment? |
|
305 if self._comment : |
|
306 comment = '\t; ' + self._comment |
|
307 else : |
|
308 comment = '' |
|
309 |
|
310 return u"{name:25} {ttl:4} {cls:2} {type:5} {data}{comment}".format( |
|
311 name = self.name or '', |
|
312 ttl = self.ttl or '', |
|
313 cls = self.cls or '', |
|
314 type = self.type, |
|
315 data = ' '.join(unicode(data) for data in self.data), |
|
316 comment = comment, |
|
317 ) |
|
318 |
|
319 def __str__ (self) : |
|
320 return ' '.join((self.name or '', self.type, ' '.join(self.data))) |
|
321 |
|
322 class TXTRecord (ZoneRecord) : |
|
323 """ |
|
324 TXT record. |
|
325 """ |
|
326 |
|
327 def __init__ (self, name, text, **opts) : |
|
328 return super(TXTRecord, self).__init__(name, 'TXT', |
|
329 [u'"{0}"'.format(text.replace('"', '\\"'))], |
|
330 **opts |
|
331 ) |
|
332 |
|
333 class OffsetValue (object) : |
|
334 def __init__ (self, value) : |
|
335 self.value = value |
|
336 |
|
337 def __getitem__ (self, offset) : |
|
338 value = self.value + offset |
|
339 |
|
340 #log.debug("OffsetValue: %d[%d] -> %d", self.value, offset, value) |
|
341 |
|
342 return value |
|
343 |
|
344 def parse_generate_field (line, field) : |
|
345 """ |
|
346 Parse a $GENERATE lhs/rhs field: |
|
347 $ |
|
348 ${<offset>[,<width>[,<base>]]} |
|
349 \$ |
|
350 $$ |
|
351 |
|
352 Returns a wrapper that builds the field-value when called with the index. |
|
353 |
|
354 >>> parse_generate_field(None, "foo")(1) |
|
355 'foo' |
|
356 >>> parse_generate_field(None, "foo-$")(1) |
|
357 'foo-1' |
|
358 >>> parse_generate_field(None, "foo-$$")(1) |
|
359 'foo-$' |
|
360 >>> parse_generate_field(None, "\$")(1) |
|
361 '$' |
|
362 >>> parse_generate_field(None, "10.0.0.${100}")(1) |
|
363 '10.0.0.101' |
|
364 >>> parse_generate_field(None, "foo-${0,2,d}")(1) |
|
365 'foo-01' |
|
366 |
|
367 """ |
|
368 |
|
369 input = field |
|
370 expr = [] |
|
371 |
|
372 while '$' in field : |
|
373 # defaults |
|
374 offset = 0 |
|
375 width = 0 |
|
376 base = 'd' |
|
377 escape = False |
|
378 |
|
379 # different forms |
|
380 if '${' in field : |
|
381 pre, body = field.split('${', 1) |
|
382 body, post = body.split('}', 1) |
|
383 |
|
384 # parse body |
|
385 parts = body.split(',') |
|
386 |
|
387 # offset |
|
388 offset = int(parts.pop(0)) |
|
389 |
|
390 # width |
|
391 if parts : |
|
392 width = int(parts.pop(0)) |
|
393 |
|
394 # base |
|
395 if parts : |
|
396 base = parts.pop(0) |
|
397 |
|
398 if parts: |
|
399 # fail |
|
400 raise ZoneLineError(line, "extra data in ${...} body: {0!r}", parts) |
|
401 |
|
402 elif '$$' in field : |
|
403 pre, post = field.split('$$', 1) |
|
404 escape = True |
|
405 |
|
406 elif '\\$' in field : |
|
407 pre, post = field.split('\\$', 1) |
|
408 escape = True |
|
409 |
|
410 else : |
|
411 pre, post = field.split('$', 1) |
|
412 |
|
413 expr.append(pre) |
|
414 |
|
415 if escape : |
|
416 expr.append('$') |
|
417 |
|
418 else : |
|
419 # meta-format |
|
420 fmt = '{value[%d]:0%d%s}' % (offset, width, base) |
|
421 |
|
422 log.debug("field=%r -> pre=%r, fmt=%r, post=%r", field, expr, fmt, post) |
|
423 |
|
424 expr.append(fmt) |
|
425 |
|
426 field = post |
|
427 |
|
428 # final |
|
429 if field : |
|
430 expr.append(field) |
|
431 |
|
432 # combine |
|
433 expr = ''.join(expr) |
|
434 |
|
435 log.debug("%s: %s", input, expr) |
|
436 |
|
437 # processed |
|
438 def value_func (value) : |
|
439 # magic wrapper to implement offsets |
|
440 return expr.format(value=OffsetValue(value)) |
|
441 |
|
442 return value_func |
|
443 |
|
444 def process_generate (line, origin, parts) : |
|
445 """ |
|
446 Process a |
|
447 $GENERATE <start>-<stop>[/<step>] lhs [ttl] [class] type rhs [comment] |
|
448 directive into a series of ZoneResource's. |
|
449 """ |
|
450 |
|
451 range = parts.pop(0) |
|
452 |
|
453 # parse range |
|
454 if '/' in range : |
|
455 range, step = range.split('/') |
|
456 step = int(step) |
|
457 else : |
|
458 step = 1 |
|
459 |
|
460 start, stop = range.split('-') |
|
461 start = int(start) |
|
462 stop = int(stop) |
|
463 |
|
464 log.debug(" range: start=%r, stop=%r, step=%r", start, stop, step) |
|
465 |
|
466 # inclusive |
|
467 range = xrange(start, stop + 1, step) |
|
468 |
|
469 lhs_func = parse_generate_field(line, parts.pop(0)) |
|
470 rhs_func = parse_generate_field(line, parts.pop(-1)) |
|
471 body = parts |
|
472 |
|
473 for i in range : |
|
474 # build |
|
475 parts = [lhs_func(i)] + body + [rhs_func(i)] |
|
476 |
|
477 log.debug(" %03d: %r", i, parts) |
|
478 |
|
479 # parse |
|
480 yield ZoneRecord.parse(line, parts=parts, origin=origin) |
|
481 |
|
482 def parse_zone_records (file, origin=None, **opts) : |
|
483 """ |
|
484 Parse ZoneRecord items from the given zonefile, ignoring non-record lines. |
|
485 """ |
|
486 |
|
487 ttl = None |
|
488 |
|
489 skip_multiline = False |
|
490 |
|
491 for lineno, raw_line in enumerate(file) : |
|
492 # parse comment |
|
493 if ';' in raw_line : |
|
494 line, comment = raw_line.split(';', 1) |
|
495 else : |
|
496 line = raw_line |
|
497 comment = None |
|
498 |
|
499 # XXX: handle multi-line statements... |
|
500 # start |
|
501 if '(' in line : |
|
502 skip_multiline = True |
|
503 |
|
504 log.warn("%s:%d: Start of multi-line statement: %s", file.name, lineno, raw_line) |
|
505 |
|
506 # end? |
|
507 if ')' in line : |
|
508 skip_multiline = False |
|
509 |
|
510 log.warn("%s:%d: End of multi-line statement: %s", file.name, lineno, raw_line) |
|
511 |
|
512 continue |
|
513 |
|
514 elif skip_multiline : |
|
515 log.warn("%s:%d: Multi-line statement: %s", file.name, lineno, raw_line) |
|
516 |
|
517 continue |
|
518 |
|
519 # parse |
|
520 line = ZoneLine.parse(file.name, lineno, raw_line, **opts) |
|
521 |
|
522 if not line.data : |
|
523 log.debug("%s: skip empty line: %s", line, raw_line) |
|
524 |
|
525 continue |
|
526 |
|
527 elif line.data.startswith('$') : |
|
528 # control record |
|
529 type = line.parts[0] |
|
530 |
|
531 if type == '$ORIGIN': |
|
532 # update |
|
533 origin = line.parts[1] |
|
534 |
|
535 log.info("%s: origin: %s", line, origin) |
|
536 |
|
537 elif type == '$GENERATE': |
|
538 # process... |
|
539 log.info("%s: generate: %s", line, line.parts) |
|
540 |
|
541 for record in process_generate(line, origin, line.parts[1:]) : |
|
542 yield record |
|
543 |
|
544 else : |
|
545 log.warning("%s: skip control record: %s", line, line.data) |
|
546 |
|
547 # XXX: passthrough! |
|
548 continue |
|
549 |
|
550 # normal record? |
|
551 record = ZoneRecord.parse(line, origin=origin) |
|
552 |
|
553 if record : |
|
554 yield record |
|
555 |
|
556 else : |
|
557 # unknown |
|
558 log.warning("%s: skip unknown line: %s", line, line.data) |
|
559 |
95 |
560 def check_zone_hosts (zone, whitelist=None, whitelist_types=set(['TXT'])) : |
96 def check_zone_hosts (zone, whitelist=None, whitelist_types=set(['TXT'])) : |
561 """ |
97 """ |
562 Parse host/IP pairs from the zone, and verify that they are unique. |
98 Parse host/IP pairs from the zone, and verify that they are unique. |
563 |
99 |