source: trunk/bind-import@ 818

Last change on this file since 818 was 818, checked in by Kris Deugau, 4 years ago

/trunk

Eleventh sampled iteration of bind-import

File size: 14.7 KB
Line 
1#!/usr/bin/perl
2# Import a BIND zone file
3# Note we are not using Net:DNS::ZoneFile, because we want to convert $GENERATE
4# directives straight into PTR template or A+PTR template metarecords
5##
6# Copyright 2020 Kris Deugau <kdeugau@deepnet.cx>
7#
8# This program is free software: you can redistribute it and/or modify
9# it under the terms of the GNU General Public License as published by
10# the Free Software Foundation, either version 3 of the License, or
11# (at your option) any later version.
12#
13# This program is distributed in the hope that it will be useful,
14# but WITHOUT ANY WARRANTY; without even the implied warranty of
15# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16# GNU General Public License for more details.
17#
18# You should have received a copy of the GNU General Public License
19# along with this program. If not, see <http://www.gnu.org/licenses/>.
20##
21
22use strict;
23use warnings;
24use Data::Dumper;
25
26use lib '.';
27use DNSDB;
28
29my $dnsdb = new DNSDB;
30my $doimport = 0;
31
32#print Dumper(\%reverse_typemap);
33
34local $dnsdb->{dbh}->{AutoCommit} = 0;
35local $dnsdb->{dbh}->{RaiseError} = 1;
36
37##fixme: command arguments/flags to set these to alternate values
38my $group = 1;
39my $status = 1;
40my $location = '';
41# we'll update this with the actual serial number from the SOA record later
42my $serial = time();
43
44my $zname = shift @ARGV;
45my $origzone = $zname;
46die "usage: bind-import zonename\n" if !$zname;
47my $rev = 'n';
48my $zid;
49
50my %amap;
51my %namemap;
52my %cmap;
53
54##fixme: this is wrong, BIND zone files are generally complete and we're adding. merging records is an entire fridge full of worms.
55##fixme: for import, should arguably check for zone *non*existence
56if ($zname =~ /\.arpa\.?$/ || $zname =~ m,^[\d./]+$,) {
57 $rev = 'y';
58 $zname = _zone2cidr($zname) if $zname =~ /\.arpa\.?$/;
59 $zid = $dnsdb->revID($zname,':ANY:');
60 if ($zid) {
61# die "zone $origzone already present, not merging records\n";
62#$zname = new NetAddr::IP $zname;
63# $zname = DNSDB::_ZONE($zname, 'ZONE', 'r', '.').($zname->{isv6} ? '.ip6.arpa' : '.in-addr.arpa');
64 }
65# $zid = $dnsdb->{dbh}->do("INSERT INTO revzones (revnet,group_id,status,default_location,zserial) VALUES (?,?,?,?,?) RETURNING rnds_id",
66# undef, ($zname, $group, $status, $location, $serial));
67
68} else {
69 $zid = $dnsdb->domainID($zname,':ANY:');
70 if ($zid) {
71# die "zone $origzone already present, not merging records\n";
72 }
73# $zid = $dnsdb->{dbh}->do("INSERT INTO domains (domain,group_id,status,default_location,zserial) VALUES (?,?,?,?,?) RETURNING domain_id",
74# undef, ($zname, $group, $status, $location, $serial));
75}
76
77die "error creating zone stub for $zname: ".$dnsdb->{dbh}->errstr if !$zid;
78
79
80
81# still no sane way to expose a human-friendly view tag on the command line.
82my $view = shift @ARGV;
83$view = '' if !$view;
84
85##fixme: retrieve defttl from SOA record
86my $zonettl = 900;
87my $defttl = $zonettl;
88my $origin = "$zname."; # to append to unqualified names
89my %foundtypes;
90
91# need to spin up a full state machine-ish thing, because BIND zone files are all about context
92# see ch4, p56-72 in the grasshopper book
93my $prevlabel = '';
94my $curlabel = '';
95
96my $i = 0;
97
98while (my $rec = <>) {
99 chomp $rec;
100 next if $rec =~ /^\s*$/;
101 next if $rec =~ /^\s*;/; # comments
102 next if $rec =~ /^\s*\)/; # SOA closing (possibly other records too?)
103 # arguably should do some more targeted voodoo when parsing the SOA details
104
105##fixme: use external skiplist
106 # skip stale records that have no value
107 next if /^ip-192-168-1(12|20)-\d+/;
108 next if /ip.add.re.\d+\s*$/;
109
110$i++;
111#last if $i > 4;
112#print "line $i: ($rec)\n";
113 if (my ($macro,$mdetail) = ($rec =~ /^\s*\$(TTL|ORIGIN|INCLUDE|GENERATE)\s+(.+)/) ) {
114 # macro sort of thing; $TTL and $ORIGIN most common. $INCLUDE is a thing, expect it to be rare in live use tho
115 if ($macro eq 'TTL') {
116 $mdetail =~ s/\s*;.+$//;
117 if ($mdetail =~ /^\d+$/) {
118 $defttl = $mdetail;
119 } else {
120 warn "invalid \$TTL: $rec\n";
121 }
122 } elsif ($macro eq 'ORIGIN') {
123##fixme: going to skip the stupid case of "$ORIGIN com." and the like that lie
124# between . and the root domain we were told we're importing; anyone using such
125# a mess outside the root servers is clearly insane
126
127# $ORIGIN supports cascading/nesting, by watching for fully-qualified names vs partial names.
128
129print "origin ($mdetail)\n";
130 if ($mdetail =~ /\.$/) {
131 $origin = $mdetail;
132 } else {
133 # append current origin to unqualified origin
134 $origin = "$mdetail.$origin";
135 }
136
137# if ($mdetail eq '.' || $mdetail =~ /$zname\.$/ || $zname =~ /$mdetail\.$/) {
138# $origin = $mdetail;
139# } else {
140# # if we continue, we either use an $ORIGIN that's out of zone, or ignore it and potentially publish incorrect records.
141# die "bad \$ORIGIN: $_\n";
142# }
143
144 }
145 elsif ($macro eq 'GENERATE') {
146# needs to generate CIDR range(s) as needed to match the start/stop points
147 }
148 # not handling $INCLUDE or $GENERATE (altho the latter seems to be mostly a less-flexible version of the template types)
149 next;
150 }
151
152 my $origrec = $rec;
153
154 # leading whitespace indicates "same label as last record"
155 if ($rec =~ /^\s/) {
156 $curlabel = $prevlabel;
157print " found empty label, using previous label\n";
158 } else {
159 ($curlabel) = ($rec =~ /^([\w\@_.-]+)\s/);
160 }
161
162print " found '$curlabel'\n";
163
164 # magic name!
165 $curlabel = "$zname." if $curlabel eq '@';
166
167 # append $ORIGIN if name is not fully qualified.
168 if ($curlabel !~ /\.$/) {
169 $curlabel .= ($origin eq '.' ? '.' : ".$origin");
170 }
171print " expanded '$curlabel'\n";
172
173# hack pthbptt
174#$curlabel =~ s/\.\.$/./;
175 # check for zone scope. skip bad records.
176 if ($curlabel !~ /$zname.$/) {
177 warn "bad record $origrec, maybe bad \$ORIGIN?\n";
178last;
179 next;
180 }
181
182 # trim the label, if any
183 $rec =~ s/^([\w\@_.-]*)\s+//;
184
185# # records must begin in the first column, no leading whitespace
186# my ($name) = /^([\w\@_.-]+)\s/;
187
188# foo IN A 1.2.3.4
189# IN A 2.3.4.5
190# =
191# foo.zone. IN A 1.2.3.4
192# foo.zone. IN A 2.3.4.5
193
194# # "empty" label records inherit the previous label
195# # RRs start in the first column by definition, so leading whitespace indicates an inherited label
196# if (/^\s+/) {
197# # fatal error. if there is no previous label, we can by definition not set
198# # the current label based on it. this can only happen on the very first
199# # record, following records will *ALWAYS* have a previous label
200# die "bad first record ($_): no previous label\n" if !$prevlabel;
201# $name = $prevlabel;
202# }
203
204#print "$i ($rec)\n";#\t$curlabel";
205
206
207
208
209# # append zone name to record name if missing AND not dot-terminated;
210# # this happens automagically for forward zones, but not reverse because Reasons. (fixme?)
211# # suck up and deal with the error if the dot-termiated name is out of zone; should be
212# # impossible with valid BIND zone file but...
213# if ($name !~ /\.$/) {
214# $name .= ".$zname" if $name !~ /$zname$/;
215# } else {
216# warn "skipping out-of-zone record:\n\t($_)\n" if $name !~ /$zname\.$/;
217# next;
218# }
219
220
221 my $nc = 0;
222 my $class = 'IN';
223 my $type;
224 my $ttl;
225 my $distance;
226 my $weight;
227 my $port;
228 my $badrec;
229 my $curatom = 'class';
230
231 eval {
232 for (; $nc < 3; $nc++) {
233 my ($atom) = ($rec =~ /^([\w\d.]+)\s/);
234 # should be safe?
235 last if !$atom;
236 last if $type;
237#print "nc:$nc: $atom\n";
238 if ($atom =~ /^\d+$/) {
239 if (defined($ttl)) {
240 die "bad record ($origrec)\n";
241# warn "bad record ($origrec)\n";
242# $badrec = 1;
243# last;
244 } else {
245 if ($curatom ne 'class' && $curatom ne 'ttl') {
246 die "bad record ($origrec)\n";
247# warn "bad record ($origrec)\n";
248# $badrec = 1;
249# last;
250 }
251 $curatom = 'ttl';
252 $ttl = $atom;
253 }
254 }
255
256 elsif ($atom =~ /^IN|CS|CH|HS$/) {
257#print "a$nc: d2: atom [$atom]\n $rec\n" if $i == $debugid;
258 if ($atom =~ /CS|CH|HS/) {
259 die "unsupported class $atom in record ($origrec)\n";
260# warn "unsupported class $atom in record ($origrec)\n";
261# $badrec = 1;
262# last;
263 }
264 $curatom = 'class';
265 $class = $atom;
266 }
267
268 elsif ($atom =~ /^[A-Z]+/) {
269# print "dbg: type $atom\n";
270 if ($reverse_typemap{$atom}) {
271 $type = $atom;
272 } else {
273 die "unknown type $atom in record ($origrec)\n";
274 }
275 }
276 $rec =~ s/^$atom\s*//;
277 }
278 };
279 if ($@) {
280 warn $@;
281 next;
282 }
283
284##todo: BIND conflates a repeated label with repeating the TTL too. Matter of opinion whether that's really correct or not.
285 # set default TTL here so we can detect a TTL in the loop above
286 $ttl = $defttl if !defined($ttl);
287
288#next if $badrec;
289
290
291##fixme: drop curlabel? not sure it's needed
292#$curlabel = $name;
293$prevlabel = $curlabel;
294
295
296
297## by convention the optional TTL leads the optional class, but they're apparently swappable.
298# my ($ttl) = /^(\d+)?\s/;
299# if (defined $ttl) {
300# # TTL may be zero
301# s/(\d+)?\s+//;
302# } else {
303# # Fall back to zone default TTL
304# $ttl = $zonettl;
305# }
306# my ($class) = /^(IN|CS|CH|HS|\d+)\s/;
307# if (defined $class) {
308# if ($class =~ /\d+/) {
309#
310# }
311# if ($class ne 'IN') {
312# warn "Non-Internet class ($class) records not supported:\n\t$origrec\n";
313# next;
314# }
315# s/(IN|CS|CH|HS)\s+//;
316# } else {
317# $class = 'IN';
318# }
319# my ($type) = /([A-Z-]+)\s/;
320# if (!$reverse_typemap{$type}) {
321# warn "Unknown type $type, skipping\n\t($rec)\n";
322# next;
323# }
324# s/([A-Z-]+)\s+//;
325# chomp;
326
327
328 my $itype = $reverse_typemap{$type};
329 my $rdata = $rec;
330
331 # SOA is the only type that may span multiple lines. Probably. Note even AXFRed zones write multiline SOA records:
332 #@ IN SOA test.example.invalid. test.example.invalid. (2020082500 7200 900 604800 3600)
333 # IN NS olddns.example.com.
334 # IN MX 1 fred.foo.bar.invalid.
335 #foo IN A 192.168.16.45
336 # AXFR'ed zone file gets written as
337 #$ORIGIN .
338 #$TTL 3600 ; 1 hour
339 #example.invalid IN SOA test.example.invalid. test.example.invalid. (
340 # 2020082500 ; serial
341 # 7200 ; refresh (2 hours)
342 # 900 ; retry (15 minutes)
343 # 604800 ; expire (1 week)
344 # 3600 ; minimum (1 hour)
345 # )
346 # NS olddns.example.com.
347 # MX 1 fred.foo.bar.invalid.
348 #$ORIGIN example.invalid.
349 #foo A 192.168.16.45
350$foundtypes{$type}++;
351
352##fixme: strip trailing . here? dnsadmin's normalized internal format omits it, some validation fails or may go funky
353
354 if ($type eq 'SOA') {
355 my ($ns, $adminmail) = ($rdata =~ /([\w.]+)\s+([\w.]+)\s+\(/);
356 die "Can't parse gibberish SOAish record: $rec\n" if !$ns;
357 $rdata =~ s/([\w.]+)\s+([\w.]+)\s+\(\s*//;
358
359 # There are probably more efficient ways to do this but the SOA record
360 # format is essentially character based, not line-based.
361 # In theory the SOA serial etc may be spread over up to 5 lines, in any combination.
362
363 # Parse fields from $rdata if present
364 my @soabits;
365 my @soafirst = split /\s+/, $rdata;
366 while (my $f = shift @soafirst) {
367 last if $f !~ /^\d/;
368 push @soabits, $f;
369 }
370
371 # Read more lines if we don't have enough SOA fields filled
372 while (scalar(@soabits) < 5) {
373 my $tmp = <>;
374 $tmp =~ s/^\s*//;
375 my @tmpsoa = split /\s+/, $tmp;
376 while (my $f = shift @tmpsoa) {
377 last if $f !~ /^\d/;
378 push @soabits, $f;
379 }
380 if (scalar(@soabits) == 5) {
381 last;
382 }
383 }
384# $dnsdb->{dbh}->do("UPDATE [zonetable] SET serial = ? WHERE [idfield] = ?");
385# $dnsdb->{dbh}->do("INSERT INTO records () VALUES ()");
386# next;
387#Zfqdn:mname:rname:ser:ref:ret:exp:min:ttl:timestamp:lo
388#print "Z$zname:$ns:$adminmail:$soabits[0]:$soabits[1]:$soabits[2]:$soabits[3]:$soabits[4]:$ttl\n";
389 } # SOA
390
391
392 # we're using DNSDB::addrec(), so we'll skip detailed validation of other records. Most won't need further breakdown
393
394 elsif ($type eq 'A') {
395#print "+$curlabel:$rdata:$ttl\n";
396 }
397
398 elsif ($type eq 'NS') {
399#print "\&$curlabel::$rdata:$ttl\n";
400 }
401
402 elsif ($type eq 'CNAME') {
403#print "C$curlabel:$rdata:$ttl\n";
404 }
405
406 elsif ($type eq 'PTR') {
407 }
408
409 elsif ($type eq 'MX') {
410 ($distance) = ($rdata =~ /^(\d+)\s+/);
411 if (!defined($distance)) {
412 warn "malformed MX record: $origrec, skipping\n";
413 next;
414 }
415 $rdata =~ s/^\d+\s+//;
416 }
417
418 elsif ($type eq 'TXT') {
419 # Quotes may arguably be syntactically required, but they're not actually part of the record data
420 $rdata =~ s/^"//;
421 $rdata =~ s/"$//;
422#print "'$curlabel:$rdata:$ttl\n";
423 }
424
425 elsif ($type eq 'RP') {
426 }
427
428 elsif ($type eq 'AAAA') {
429 }
430
431 elsif ($type eq 'SRV') {
432 ($distance, $weight, $port) = ($rdata =~ /^(\d+)\s+(\d+)\s+(\d+)\s+/);
433 if ( !defined($distance) || !defined($weight) || !defined($port) ) {
434 warn "malformed SRV record: $origrec, skipping\n";
435 next;
436 }
437 $rdata =~ s/^\d+\s+\d+\s+\d+\s+//;
438 }
439
440 # basically a dedicated clone of TXT, not sure anything actually looks up type SPF.
441 # BIND autogenerates them from SPF TXT records.
442 elsif ($type eq 'SPF') {
443 # Quotes may arguably be syntactically required, but they're not actually part of the record data
444 $rdata =~ s/^"//;
445 $rdata =~ s/"$//;
446 }
447
448# elsif ($type eq 'TXT') {
449# elsif ($type eq 'TXT') {
450
451 else {
452 warn "unsupported type $type, may not import correctly\n";
453 }
454
455no warnings qw(uninitialized);
456#print "parsed: '$curlabel' '$class' '$ttl' '$type'->'$itype' '$rdata'\n";
457#print;
458#;imap IN 900 CNAME deepnet.cx.
459##fixme: not sure how to handle the case where someone leaves off the class.
460 if ($doimport) {
461 my ($code, $msg);
462 if ($rev eq 'n') {
463 ($code,$msg) = $dnsdb->addRec('n', $rev, $zid, \$curlabel, \$itype, \$rdata, $ttl,
464 $location, undef, undef, $distance, $weight, $port);
465 } else {
466 ($code,$msg) = $dnsdb->addRec('y', $rev, $zid, \$rdata, \$itype, \$curlabel, $ttl,
467 $location, undef, undef, $distance, $weight, $port);
468 }
469 print "$code: $msg\n";
470 }
471# $i++;
472}
473
474
475#print Dumper \%amap;
476#print Dumper \%namemap;
477#print Dumper \%cmap;
478
479#foreach my $n (keys %amap) {
480# foreach my $ip (@{$amap{$n}}) {
481##print "$ip $n\n";
482# push @{$namemap{$ip}}, $n unless grep $n, @{$namemap{$ip}};
483# }
484#}
485
486#foreach my $c (keys %cmap) {
487# if ($amap{$c}) {
488# print Dumper(\@{$amap{$c}});
489# }
490## print $amap{$c};
491#}
492
493# cname targ -> IP
494
495#foreach my $ip (sort keys %namemap) {
496# print "$ip ".join(' ', @{$namemap{$ip}})."\n";
497#}
498
499$dnsdb->{dbh}->rollback;
500
501foreach my $t (keys %foundtypes) {
502 print "found $t: $foundtypes{$t}\n";
503}
Note: See TracBrowser for help on using the repository browser.