|
1 #!/usr/bin/env python |
|
2 |
|
3 """ |
|
4 Process zonefiles. |
|
5 """ |
|
6 |
|
7 __version__ = '0.0.1-dev' |
|
8 |
|
9 import optparse |
|
10 import codecs |
|
11 import logging |
|
12 |
|
13 log = logging.getLogger() |
|
14 |
|
15 # command-line options, global state |
|
16 options = None |
|
17 |
|
18 def parse_options (argv) : |
|
19 """ |
|
20 Parse command-line arguments. |
|
21 """ |
|
22 |
|
23 parser = optparse.OptionParser( |
|
24 prog = argv[0], |
|
25 usage = '%prog: [options]', |
|
26 version = __version__, |
|
27 |
|
28 # module docstring |
|
29 description = __doc__, |
|
30 ) |
|
31 |
|
32 # logging |
|
33 general = optparse.OptionGroup(parser, "General Options") |
|
34 |
|
35 general.add_option('-q', '--quiet', dest='loglevel', action='store_const', const=logging.WARNING, help="Less output") |
|
36 general.add_option('-v', '--verbose', dest='loglevel', action='store_const', const=logging.INFO, help="More output") |
|
37 general.add_option('-D', '--debug', dest='loglevel', action='store_const', const=logging.DEBUG, help="Even more output") |
|
38 |
|
39 parser.add_option_group(general) |
|
40 |
|
41 parser.add_option('-c', '--input-charset', metavar='CHARSET', default='utf-8', |
|
42 help="Encoding used for input files") |
|
43 |
|
44 parser.add_option('-o', '--output', metavar='FILE', default='-', |
|
45 help="Write to output file; default stdout") |
|
46 |
|
47 parser.add_option('--output-charset', metavar='CHARSET', default='utf-8', |
|
48 help="Encoding used for output files") |
|
49 |
|
50 parser.add_option('--serial', metavar='FILE', |
|
51 help="Read/update serial from given .serial file") |
|
52 |
|
53 parser.add_option('--forward-zone', action='store_true', |
|
54 help="Generate forward zone") |
|
55 |
|
56 parser.add_option('--forward-txt', action='store_true', |
|
57 help="Generate TXT records for forward zone") |
|
58 |
|
59 parser.add_option('--forward-mx', metavar='MX', |
|
60 help="Generate MX records for forward zone") |
|
61 |
|
62 parser.add_option('--reverse-domain', metavar='DOMAIN', |
|
63 help="Domain to use for hosts in reverse zone") |
|
64 |
|
65 parser.add_option('--reverse-zone', metavar='NET', |
|
66 help="Generate forward zone for given subnet (x.z.y)") |
|
67 |
|
68 |
|
69 # parser.add_option('--output-forward', metavar='FILE', default=False, help="Hosts output file") |
|
70 # parser.add_option('--output-reverse', metavar='FILE', default=False, help="Reverse-hosts output file") |
|
71 # parser.add_option('--forward-info', action='store_true', help="Include additional TXT records in forward zone output") |
|
72 |
|
73 # parser.add_option('--reverse-zone', metavar='DOMAIN', help="Zone origin used for reverse zone") |
|
74 |
|
75 # defaults |
|
76 parser.set_defaults( |
|
77 loglevel = logging.INFO, |
|
78 ) |
|
79 |
|
80 # parse |
|
81 options, args = parser.parse_args(argv[1:]) |
|
82 |
|
83 # configure |
|
84 logging.basicConfig( |
|
85 format = '%(processName)s: %(name)s: %(levelname)s %(funcName)s : %(message)s', |
|
86 level = options.loglevel, |
|
87 ) |
|
88 |
|
89 return options, args |
|
90 |
|
91 def parse_record (line) : |
|
92 """ |
|
93 Parse (name, ttl, type, data, comment) from bind zonefile. |
|
94 |
|
95 Returns None for empty/comment lines. |
|
96 """ |
|
97 |
|
98 # was line indented? |
|
99 indent = line.startswith(' ') or line.startswith('\t') |
|
100 |
|
101 # strip |
|
102 line = line.strip() |
|
103 |
|
104 if not line or line.startswith(';') : |
|
105 # skip |
|
106 return |
|
107 |
|
108 #log.debug("line=%r", line) |
|
109 |
|
110 # parse comment out |
|
111 parts = line.split(';', 1) |
|
112 |
|
113 if ';' in line : |
|
114 data, comment = line.split(';', 1) |
|
115 |
|
116 line = data.rstrip() |
|
117 comment = comment.strip() |
|
118 |
|
119 else : |
|
120 line = line.rstrip() |
|
121 comment = None |
|
122 |
|
123 #log.debug("line=%r, comment=%r", line, comment) |
|
124 |
|
125 # parse data out? |
|
126 if '"' in line : |
|
127 line, data, end = line.split('"') |
|
128 parts = line.split() |
|
129 |
|
130 else : |
|
131 parts = line.split() |
|
132 data = parts.pop(-1) |
|
133 |
|
134 #log.debug("parts=%r, data=%r", parts, data) |
|
135 |
|
136 # indented lines don't have name |
|
137 if indent : |
|
138 name = None |
|
139 |
|
140 else : |
|
141 name = parts.pop(0) |
|
142 |
|
143 #log.debug("name=%r", name) |
|
144 |
|
145 # parse ttl/cls/type |
|
146 ttl = cls = None |
|
147 |
|
148 type = parts.pop(-1) |
|
149 |
|
150 if parts and parts[0][0].isdigit() : |
|
151 ttl = parts.pop(0) |
|
152 |
|
153 if parts : |
|
154 cls = parts.pop(0) |
|
155 |
|
156 #log.debug("ttl=%r, cls=%r, parts=%r", ttl, cls, parts) |
|
157 |
|
158 if parts : |
|
159 raise Exception("Extra data: %r" % (line)) |
|
160 |
|
161 return name, ttl, type, data, comment |
|
162 |
|
163 def parse_zone (file) : |
|
164 """ |
|
165 Parse |
|
166 (name, ttl, type, data, comment) |
|
167 data from zonefile. |
|
168 """ |
|
169 |
|
170 for lineno, line in enumerate(file) : |
|
171 data = parse_record(line) |
|
172 |
|
173 if data : |
|
174 yield data |
|
175 |
|
176 def process_zone_forwards (zone, txt=False, mx=False) : |
|
177 """ |
|
178 Process zone data -> forward zone data. |
|
179 """ |
|
180 |
|
181 for name, ttl, type, data, comment in zone : |
|
182 yield name, ttl, type, data |
|
183 |
|
184 if type == 'A' : |
|
185 if txt and comment : |
|
186 # name |
|
187 yield None, ttl, 'TXT', u'"{0}"'.format(comment) |
|
188 |
|
189 # XXX: RP, do we need it? |
|
190 |
|
191 if mx : |
|
192 # XXX: is this a good idea? |
|
193 yield None, ttl, 'MX', '10 {mx}'.format(mx=mx) |
|
194 |
|
195 def reverse_addr (ip) : |
|
196 """ |
|
197 Return in-addr.arpa reverse for given IPv4 IP. |
|
198 """ |
|
199 |
|
200 # parse |
|
201 octets = tuple(int(part) for part in ip.split('.')) |
|
202 |
|
203 for octet in octets : |
|
204 assert 0 <= octet <= 255 |
|
205 |
|
206 return '.'.join([str(octet) for octet in reversed(octets)] + ['in-addr', 'arpa']) |
|
207 |
|
208 def fqdn (*parts) : |
|
209 return '.'.join(parts) + '.' |
|
210 |
|
211 |
|
212 def process_zone_reverse (zone, origin, domain) : |
|
213 """ |
|
214 Process zone data -> reverse zone data. |
|
215 """ |
|
216 |
|
217 for name, ttl, type, data, comment in zone : |
|
218 if type != 'A' : |
|
219 continue |
|
220 |
|
221 ip = data |
|
222 |
|
223 # generate reverse-addr |
|
224 reverse = reverse_addr(ip) |
|
225 |
|
226 # verify |
|
227 if zone and reverse.endswith(origin) : |
|
228 reverse = reverse[:-(len(origin) + 1)] |
|
229 |
|
230 else : |
|
231 log.warning("Reverse does not match zone origin: (%s) -> %s <-> %s", ip, reverse, origin) |
|
232 continue |
|
233 |
|
234 # domain to use |
|
235 host_domain = domain |
|
236 host_fqdn = fqdn(name, domain) |
|
237 |
|
238 yield reverse, 'PTR', host_fqdn |
|
239 |
|
240 def build_zone (zone) : |
|
241 for item in zone : |
|
242 ttl = cls = comment = None |
|
243 |
|
244 if len(item) == 3 : |
|
245 name, type, data = item |
|
246 |
|
247 elif len(item) == 4 : |
|
248 name, ttl, type, data = item |
|
249 |
|
250 elif len(item) == 5 : |
|
251 name, ttl, type, data, comment = item |
|
252 |
|
253 else : |
|
254 raise Exception("Weird zone entry: {0}".format(item)) |
|
255 |
|
256 if not name : |
|
257 name = '' |
|
258 |
|
259 if not ttl : |
|
260 ttl = '' |
|
261 |
|
262 if not cls : |
|
263 cls = '' |
|
264 |
|
265 if comment : |
|
266 comment = '\t;' + comment |
|
267 else : |
|
268 comment = '' |
|
269 |
|
270 yield u"{name:25} {ttl:4} {cls:2} {type:5} {data}{comment}".format(name=name, ttl=ttl, cls=cls, type=type, data=data, comment=comment) |
|
271 |
|
272 def write_zone (file, zone) : |
|
273 for line in build_zone(zone) : |
|
274 file.write(unicode(line + '\n')) |
|
275 |
|
276 def open_file (path, mode, charset) : |
|
277 """ |
|
278 Open unicode-enabled file from path, with - using stdio. |
|
279 """ |
|
280 |
|
281 if path == '-' : |
|
282 # use stdin/out based on mode |
|
283 stream, func = { |
|
284 'r': (sys.stdin, codecs.getreader), |
|
285 'w': (sys.stdout, codecs.getwriter), |
|
286 }[mode[0]] |
|
287 |
|
288 # wrap |
|
289 return func(charset)(stream) |
|
290 |
|
291 else : |
|
292 # open |
|
293 return codecs.open(path, mode, charset) |
|
294 |
|
295 def main (argv) : |
|
296 global options |
|
297 |
|
298 options, args = parse_options(argv) |
|
299 |
|
300 if args : |
|
301 # open files |
|
302 input_files = [open_file(path, 'r', options.input_charset) for path in args] |
|
303 |
|
304 else : |
|
305 # default to stdout |
|
306 input_files = [open_file('-', 'r', options.input_charset)] |
|
307 |
|
308 # process zone data |
|
309 zone = [] |
|
310 |
|
311 for file in input_files : |
|
312 log.info("Reading zone: %s", file) |
|
313 |
|
314 zone += list(parse_zone(file)) |
|
315 |
|
316 # output file |
|
317 output = open_file(options.output, 'w', options.output_charset) |
|
318 |
|
319 if options.forward_zone : |
|
320 log.info("Write forward zone: %s", output) |
|
321 |
|
322 zone = list(process_zone_forwards(zone, txt=options.forward_txt, mx=options.forward_mx)) |
|
323 |
|
324 elif options.reverse_zone : |
|
325 origin = reverse_addr(options.reverse_zone) |
|
326 domain = options.reverse_domain |
|
327 |
|
328 if not domain : |
|
329 log.error("--reverse-zone requires --reverse-domain") |
|
330 return 1 |
|
331 |
|
332 zone = list(process_zone_reverse(zone, origin=origin, domain=domain)) |
|
333 |
|
334 else : |
|
335 log.warn("Nothing to do") |
|
336 |
|
337 write_zone(output, zone) |
|
338 |
|
339 return 0 |
|
340 |
|
341 if __name__ == '__main__': |
|
342 import sys |
|
343 |
|
344 sys.exit(main(sys.argv)) |