source: trunk/bind2hosts@ 806

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

/trunk

Fix an edge case with $ORIGIN == '.'

  • Property svn:executable set to *
File size: 9.4 KB
Line 
1#!/usr/bin/perl
2# Convert a BIND zone file to a hosts file
3##
4# Copyright 2020 Kris Deugau <kdeugau@deepnet.cx>
5#
6# This program is free software: you can redistribute it and/or modify
7# it under the terms of the GNU General Public License as published by
8# the Free Software Foundation, either version 3 of the License, or
9# (at your option) any later version.
10#
11# This program is distributed in the hope that it will be useful,
12# but WITHOUT ANY WARRANTY; without even the implied warranty of
13# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14# GNU General Public License for more details.
15#
16# You should have received a copy of the GNU General Public License
17# along with this program. If not, see <http://www.gnu.org/licenses/>.
18##
19
20use strict;
21use warnings;
22use Getopt::Long;
23
24use Data::Dumper;
25
26# push "the directory the script is in" into @INC
27use FindBin;
28use lib "$FindBin::RealBin/";
29
30use DNSDB;
31
32my @skipdefs;
33my $skipfile;
34my $dryrun = 0;
35
36GetOptions(
37 "skip=s" => \@skipdefs,
38 "skipfile=s" => \$skipfile,
39 "test|dry-run" => \$dryrun,
40);
41
42my $zname = shift @ARGV;
43
44my $usage = "usage: bind2hosts zone [--skip pattern [--skip pattern2 ...]] [--skipfile file]
45 zonename < zonefile
46
47 --skip
48 Specify a string to skip in the records. If an IP-like string is
49 used, and the zone is a reverse zone, it will also search for the
50 octet-reversed form. Specify multiple times to skip multiple
51 different record patterns.
52 --skip-file
53 A file containing patterns to skip. Patterns from the file and
54 any --skip arguments are merged.
55 zonename
56 The name of the zone to import. Required.
57
58 Zone data will be read from STDIN.
59";
60if (!$zname) {
61 die $usage;
62}
63
64if ($skipfile) {
65 if (-f $skipfile) {
66 open SKIP, "<$skipfile";
67 while (<SKIP>) {
68 chomp;
69 push @skipdefs, $_;
70 }
71 close SKIP;
72 } else {
73 warn "skipfile $skipfile requested but it doesn't seem to exist. Continuing.\n";
74 }
75}
76
77my $rev = 'n';
78my $zid;
79
80my %amap;
81my %namemap;
82my %cmap;
83
84my $dnsdb = new DNSDB;
85
86##fixme: retrieve defttl from SOA record
87#my $zonettl = 900;
88#my $defttl = $zonettl;
89# need an ultimate fallback for this one
90my $defttl = 900;
91my $origin = "$zname."; # to append to unqualified names
92my $curlabel;
93my $prevlabel;
94
95my $i = 0;
96
97# need to spin up a full state machine-ish thing, because BIND zone files are all about context
98while (my $rec = <>) {
99 chomp $rec;
100 next if $rec =~ /^\s*$/;
101 next if $rec =~ /^\s*;/;
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#print "$i: ($rec)\n";
105#last if ++$i > 5;
106
107 my $skipflag = 0;
108 foreach (@skipdefs) {
109#print "skipdbg: $_ =~ $rec\n" if $rec =~ /207/;
110 if ($rec =~ /\Q$_\E/) {
111 $skipflag = 1;
112# print "skip: $rec\n";
113 }
114 }
115 next if $skipflag;
116
117 if (my ($macro,$mdetail) = ($rec =~ /^\s*\$(TTL|ORIGIN|INCLUDE)\s+(.+)/) ) {
118 # macro sort of thing; $TTL and $ORIGIN most common. $INCLUDE is a thing, expect it to be rare in live use tho
119 if ($macro eq 'TTL') {
120 # irrelevant for a hosts file
121 } elsif ($macro eq 'ORIGIN') {
122 # $ORIGIN supports cascading/nesting, by watching for fully-qualified names vs partial names.
123 if ($mdetail =~ /\.$/) {
124 $origin = $mdetail;
125 } else {
126 # append current origin to unqualified origin
127 $origin = "$mdetail.$origin";
128 }
129 }
130##fixme: should arguably handle $INCLUDE
131# probably NOT going to handle $GENERATE, since that type of record set is best handled in DNS
132 next;
133 }
134
135 # yay for special cases
136 $origin = '' if $origin eq '.';
137
138 my $origrec = $rec;
139
140##fixme: convert to optional skipfile?
141# skip stale records that have no value
142#next if /^ip-\d+-\d+-\d+/;
143#next if /^ip.pre.fix.\d+.static.colo/;
144
145 # leading whitespace indicates "same label as last record"
146 if ($rec =~ /^\s/) {
147 $curlabel = $prevlabel;
148 } else {
149 ($curlabel) = ($rec =~ /^([\w\@_.-]+)\s/);
150 }
151
152 # magic name!
153 $curlabel = "$zname." if $curlabel eq '@';
154
155 # append $ORIGIN if name is not fully qualified.
156 if ($curlabel !~ /\.$/) {
157 $curlabel .= ".$origin";
158 }
159
160 # check for zone scope. skip bad records.
161 if ($curlabel !~ /$zname.$/) {
162 warn "bad record $origrec, maybe bad \$ORIGIN?\n";
163 next;
164 }
165
166 # trim the label, if any
167 $rec =~ s/^([\w\@_.-]*)\s+//;
168
169 my $nc = 0;
170 my %seenatoms;
171 # we don't actually use these but we have to recognize them
172 my $class = 'IN';
173 # not preset as we need to detect whether it's present in the record
174 my $ttl;
175 my $type;
176 my $badrec;
177 my $curatom = 'class';
178
179 # now that we've collected and trimmed off the record's label, unpack the class, TTL, and type.
180 # class and TTL may be omitted, and may appear in either class,TTL or TTL,class order.
181 eval {
182 for (; $nc < 3; $nc++) {
183 last if $type; # short-circuit if we've got a type, further data is record-specific.
184 my ($atom) = ($rec =~ /^([\w\d.]+)\s/);
185 # should be safe?
186 last if !$atom;
187 if ($atom =~ /^\d+$/) {
188 if (defined($ttl)) {
189 # we already have a TTL, so another all-numeric field is invalid.
190 die "bad record ($origrec)\n";
191 } else {
192 if ($curatom ne 'class' && $curatom ne 'ttl') {
193 die "bad record ($origrec)\n";
194 }
195 $curatom = 'ttl';
196 $ttl = $atom;
197 }
198 }
199 elsif ($atom =~ /^IN|CS|CH|HS$/) {
200 if ($atom =~ /CS|CH|HS/) {
201 die "unsupported class $atom in record ($origrec)\n";
202 }
203 $curatom = 'class';
204 $class = $atom;
205 }
206 elsif ($atom =~ /^[A-Z]+/) {
207 # check against dnsadmin's internal list of known DNS types.
208 if ($reverse_typemap{$atom}) {
209 $type = $atom;
210 } else {
211 die "unknown type $atom in record ($origrec)\n";
212 }
213 $curatom = 'type';
214 }
215 $rec =~ s/^$atom\s*//;
216 } # class/type/TTL loop
217 };
218 if ($@) {
219 warn $@;
220 next;
221 }
222
223
224 $ttl = $defttl if !defined($ttl);
225
226 my $itype = $reverse_typemap{$type};
227 my $rdata = $rec;
228
229 $prevlabel = $curlabel;
230
231##fixme: squish this down for this script since SOA records are irrelevant
232 if ($type eq 'SOA') {
233 my ($ns, $adminmail) = ($rdata =~ /([\w.]+)\s+([\w.]+)\s+\(/);
234 die "Can't parse gibberish SOAish record: '$rdata'/'$origrec'\n" if !$ns;
235 $rdata =~ s/([\w.]+)\s+([\w.]+)\s+\(\s*//;
236
237 # There are probably more efficient ways to do this but the SOA record
238 # format is essentially character based, not line-based.
239 # In theory the SOA serial etc may be spread over up to 5 lines, in any combination.
240
241 # Parse fields from $rdata if present
242 my @soabits;
243 my @soafirst = split /\s+/, $rdata;
244 while (my $f = shift @soafirst) {
245 last if $f !~ /^\d/;
246 push @soabits, $f;
247 }
248
249 # Read more lines if we don't have enough SOA fields filled
250 while (scalar(@soabits) < 5) {
251 my $tmp = <>;
252 $tmp =~ s/^\s*//;
253 my @tmpsoa = split /\s+/, $tmp;
254 while (my $f = shift @tmpsoa) {
255 last if $f !~ /^\d/;
256 push @soabits, $f;
257 }
258 if (scalar(@soabits) == 5) {
259 last;
260 }
261 }
262 } # SOA
263
264##fixme: trim dupes if possible
265
266 elsif ($type eq 'A') {
267 # need the name->IP map so we can reverse-map the CNAMEs on output
268# $amap{$curlabel}{$rdata}++;
269 push @{$amap{$curlabel}}, $rdata;
270# why doesn't this work? causes ALL cases of multi-named IPs to get skipped, not just duplicates. O_o
271# push @{$namemap{$rdata}}, $curlabel unless grep $curlabel, @{$namemap{$rdata}};
272# push @{$namemap{$rdata}}, $curlabel;# unless grep $curlabel, @{$namemap{$rdata}};
273 $namemap{$rdata}{$curlabel}++;
274
275 } # A record
276
277 elsif ($type eq 'CNAME') {
278##todo: expand $rdata with $origin if unqualified
279 $cmap{$curlabel} = $rdata.($rdata =~ /\./ ? '' : ".$origin");
280 } # CNAME record
281
282 # all other record types are irrelevant for a hosts file
283
284} # <STDIN>
285
286
287
288
289#print Dumper \%cmap;
290
291while (my ($cn, $targ) = each %cmap) {
292#print "dbg: ".Dumper($targ);
293 if (!$amap{$targ}) {
294 if ($cmap{$targ}) {
295warn "chained cname $cn => $targ\n";
296 my $tmpcn = $targ;
297 $targ = $cmap{$tmpcn};
298warn " chain target $cn => $tmpcn => $targ\n";
299# next if !$amap{$targ};
300 if (!$amap{$targ}) {
301 if ($cmap{$targ}) {
302#print " second chain?\n";
303 $tmpcn = $targ;
304 $targ = $cmap{$tmpcn};
305 } else {
306#print "not found\n";
307next;
308 }
309 }
310 } else {
311 # skip depth-3 (?) CNAMES; any such zone does not belong as a hosts file anyway
312 warn "CNAME $cn => $targ not found\n";
313 next;
314 }
315 }
316# print Dumper (\%{$amap{$cmap{$cn}}});
317# print "$cn -> $cmap{$cn}\n";
318# $amap{$cmap{$cn}}{$cn}++ if $cmap{$cn} =~ /$zname.$/ && $amap{$cmap{$cn}};
319# print "dangling CNAME $cn\n" if !$namemap{$cmap{$cn}};
320# print "$cn -> $cmap{$cn}\n";
321# warn "CNAME $cn out of zone\n" if !$namemap{$cn};
322 my $targip = $amap{$targ}[0];
323#print "$cn => $targ\n" if $targ =~ /(webftp|landing)/;
324#print $targip;
325# push @{$namemap{$targip}}, $targ unless grep $targ, @{$namemap{$targip}};
326 $namemap{$targip}{$cn}++;# unless grep $targ, @{$namemap{$targip}};
327}
328
329#print Dumper \%amap;
330#foreach my $n (keys %amap) {
331# foreach my $ip (keys %{$amap{$n}}) {
332#print "$ip\t$n\n";
333# push @{$namemap{$ip}}, $n unless grep $n, @{$namemap{$ip}};
334# $namemap{$ip}{$n}++;
335# }
336#}
337
338#print Dumper \%namemap;
339foreach my $ip (sort keys %namemap) {
340 print "$ip\t".join(' ', sort keys %{$namemap{$ip}})."\n";
341}
Note: See TracBrowser for help on using the repository browser.