source: trunk/bind2hosts@ 805

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

/trunk

Add options for skipping based on pattern or a file of patterns
Refine parsing to pick up nearly everything including 2-deep CNAME chains

  • Property svn:executable set to *
File size: 9.3 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 my $origrec = $rec;
136
137##fixme: convert to optional skipfile?
138# skip stale records that have no value
139#next if /^ip-\d+-\d+-\d+/;
140#next if /^ip.pre.fix.\d+.static.colo/;
141
142 # leading whitespace indicates "same label as last record"
143 if ($rec =~ /^\s/) {
144 $curlabel = $prevlabel;
145 } else {
146 ($curlabel) = ($rec =~ /^([\w\@_.-]+)\s/);
147 }
148
149 # magic name!
150 $curlabel = "$zname." if $curlabel eq '@';
151
152 # append $ORIGIN if name is not fully qualified.
153 if ($curlabel !~ /\.$/) {
154 $curlabel .= ".$origin";
155 }
156
157 # check for zone scope. skip bad records.
158 if ($curlabel !~ /$zname.$/) {
159 warn "bad record $origrec, maybe bad \$ORIGIN?\n";
160 next;
161 }
162
163 # trim the label, if any
164 $rec =~ s/^([\w\@_.-]*)\s+//;
165
166 my $nc = 0;
167 my %seenatoms;
168 # we don't actually use these but we have to recognize them
169 my $class = 'IN';
170 # not preset as we need to detect whether it's present in the record
171 my $ttl;
172 my $type;
173 my $badrec;
174 my $curatom = 'class';
175
176 # now that we've collected and trimmed off the record's label, unpack the class, TTL, and type.
177 # class and TTL may be omitted, and may appear in either class,TTL or TTL,class order.
178 eval {
179 for (; $nc < 3; $nc++) {
180 last if $type; # short-circuit if we've got a type, further data is record-specific.
181 my ($atom) = ($rec =~ /^([\w\d.]+)\s/);
182 # should be safe?
183 last if !$atom;
184 if ($atom =~ /^\d+$/) {
185 if (defined($ttl)) {
186 # we already have a TTL, so another all-numeric field is invalid.
187 die "bad record ($origrec)\n";
188 } else {
189 if ($curatom ne 'class' && $curatom ne 'ttl') {
190 die "bad record ($origrec)\n";
191 }
192 $curatom = 'ttl';
193 $ttl = $atom;
194 }
195 }
196 elsif ($atom =~ /^IN|CS|CH|HS$/) {
197 if ($atom =~ /CS|CH|HS/) {
198 die "unsupported class $atom in record ($origrec)\n";
199 }
200 $curatom = 'class';
201 $class = $atom;
202 }
203 elsif ($atom =~ /^[A-Z]+/) {
204 # check against dnsadmin's internal list of known DNS types.
205 if ($reverse_typemap{$atom}) {
206 $type = $atom;
207 } else {
208 die "unknown type $atom in record ($origrec)\n";
209 }
210 $curatom = 'type';
211 }
212 $rec =~ s/^$atom\s*//;
213 } # class/type/TTL loop
214 };
215 if ($@) {
216 warn $@;
217 next;
218 }
219
220
221 $ttl = $defttl if !defined($ttl);
222
223 my $itype = $reverse_typemap{$type};
224 my $rdata = $rec;
225
226 $prevlabel = $curlabel;
227
228##fixme: squish this down for this script since SOA records are irrelevant
229 if ($type eq 'SOA') {
230 my ($ns, $adminmail) = ($rdata =~ /([\w.]+)\s+([\w.]+)\s+\(/);
231 die "Can't parse gibberish SOAish record: '$rdata'/'$origrec'\n" if !$ns;
232 $rdata =~ s/([\w.]+)\s+([\w.]+)\s+\(\s*//;
233
234 # There are probably more efficient ways to do this but the SOA record
235 # format is essentially character based, not line-based.
236 # In theory the SOA serial etc may be spread over up to 5 lines, in any combination.
237
238 # Parse fields from $rdata if present
239 my @soabits;
240 my @soafirst = split /\s+/, $rdata;
241 while (my $f = shift @soafirst) {
242 last if $f !~ /^\d/;
243 push @soabits, $f;
244 }
245
246 # Read more lines if we don't have enough SOA fields filled
247 while (scalar(@soabits) < 5) {
248 my $tmp = <>;
249 $tmp =~ s/^\s*//;
250 my @tmpsoa = split /\s+/, $tmp;
251 while (my $f = shift @tmpsoa) {
252 last if $f !~ /^\d/;
253 push @soabits, $f;
254 }
255 if (scalar(@soabits) == 5) {
256 last;
257 }
258 }
259 } # SOA
260
261##fixme: trim dupes if possible
262
263 elsif ($type eq 'A') {
264 # need the name->IP map so we can reverse-map the CNAMEs on output
265# $amap{$curlabel}{$rdata}++;
266 push @{$amap{$curlabel}}, $rdata;
267# why doesn't this work? causes ALL cases of multi-named IPs to get skipped, not just duplicates. O_o
268# push @{$namemap{$rdata}}, $curlabel unless grep $curlabel, @{$namemap{$rdata}};
269# push @{$namemap{$rdata}}, $curlabel;# unless grep $curlabel, @{$namemap{$rdata}};
270 $namemap{$rdata}{$curlabel}++;
271
272 } # A record
273
274 elsif ($type eq 'CNAME') {
275##todo: expand $rdata with $origin if unqualified
276 $cmap{$curlabel} = $rdata.($rdata =~ /\./ ? '' : ".$origin");
277 } # CNAME record
278
279 # all other record types are irrelevant for a hosts file
280
281} # <STDIN>
282
283
284
285
286#print Dumper \%cmap;
287
288while (my ($cn, $targ) = each %cmap) {
289#print "dbg: ".Dumper($targ);
290 if (!$amap{$targ}) {
291 if ($cmap{$targ}) {
292warn "chained cname $cn => $targ\n";
293 my $tmpcn = $targ;
294 $targ = $cmap{$tmpcn};
295warn " chain target $cn => $tmpcn => $targ\n";
296# next if !$amap{$targ};
297 if (!$amap{$targ}) {
298 if ($cmap{$targ}) {
299#print " second chain?\n";
300 $tmpcn = $targ;
301 $targ = $cmap{$tmpcn};
302 } else {
303#print "not found\n";
304next;
305 }
306 }
307 } else {
308 # skip depth-3 (?) CNAMES; any such zone does not belong as a hosts file anyway
309 warn "CNAME $cn => $targ not found\n";
310 next;
311 }
312 }
313# print Dumper (\%{$amap{$cmap{$cn}}});
314# print "$cn -> $cmap{$cn}\n";
315# $amap{$cmap{$cn}}{$cn}++ if $cmap{$cn} =~ /$zname.$/ && $amap{$cmap{$cn}};
316# print "dangling CNAME $cn\n" if !$namemap{$cmap{$cn}};
317# print "$cn -> $cmap{$cn}\n";
318# warn "CNAME $cn out of zone\n" if !$namemap{$cn};
319 my $targip = $amap{$targ}[0];
320#print "$cn => $targ\n" if $targ =~ /(webftp|landing)/;
321#print $targip;
322# push @{$namemap{$targip}}, $targ unless grep $targ, @{$namemap{$targip}};
323 $namemap{$targip}{$cn}++;# unless grep $targ, @{$namemap{$targip}};
324}
325
326#print Dumper \%amap;
327#foreach my $n (keys %amap) {
328# foreach my $ip (keys %{$amap{$n}}) {
329#print "$ip\t$n\n";
330# push @{$namemap{$ip}}, $n unless grep $n, @{$namemap{$ip}};
331# $namemap{$ip}{$n}++;
332# }
333#}
334
335#print Dumper \%namemap;
336foreach my $ip (sort keys %namemap) {
337 print "$ip\t".join(' ', sort keys %{$namemap{$ip}})."\n";
338}
Note: See TracBrowser for help on using the repository browser.