source: trunk/DNSDB.pm@ 708

Last change on this file since 708 was 708, checked in by Kris Deugau, 9 years ago

/trunk

Refine TXT record export because of Stupid Clients Wot Ought Not To Be
Handling DNS. Some client libraries apparently get grumpy about TXT
records being split at 127 characters, which tinydns does automatically
on its native handling for TXT rcords. Add an option to generate
"generic" records split at 255 characters instead.

  • Property svn:keywords set to Date Rev Author Id
File size: 242.3 KB
Line 
1# dns/trunk/DNSDB.pm
2# Abstraction functions for DNS administration
3##
4# $Id: DNSDB.pm 708 2016-03-09 19:09:07Z kdeugau $
5# Copyright 2008-2013 Kris Deugau <kdeugau@deepnet.cx>
6#
7# This program is free software: you can redistribute it and/or modify
8# it under the terms of the GNU General Public License as published by
9# the Free Software Foundation, either version 3 of the License, or
10# (at your option) any later version.
11#
12# This program is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15# GNU General Public License for more details.
16#
17# You should have received a copy of the GNU General Public License
18# along with this program. If not, see <http://www.gnu.org/licenses/>.
19##
20
21package DNSDB;
22
23use strict;
24use warnings;
25use Exporter;
26use DBI;
27use Net::DNS;
28use Crypt::PasswdMD5;
29use Digest::MD5 qw(md5_hex);
30use Net::SMTP;
31use NetAddr::IP 4.027 qw(:lower);
32use POSIX;
33use Fcntl qw(:flock);
34use Time::TAI64 qw(:tai64);
35
36use vars qw($VERSION @ISA @EXPORT @EXPORT_OK %EXPORT_TAGS);
37
38$VERSION = 1.3; ##VERSION##
39@ISA = qw(Exporter);
40@EXPORT_OK = qw(
41 &initGlobals &login &initActionLog
42 &getPermissions &changePermissions &comparePermissions
43 &changeGroup
44 &connectDB &finish
45 &addDomain &delZone &domainName &revName &domainID &revID &addRDNS
46 &getZoneCount &getZoneList &getZoneLocation
47 &addGroup &delGroup &getChildren &groupName
48 &getGroupCount &getGroupList
49 &addUser &updateUser &delUser &userFullName &userStatus &getUserData
50 &getUserCount &getUserList &getUserDropdown
51 &addLoc &updateLoc &delLoc &getLoc
52 &getLocCount &getLocList &getLocDropdown
53 &getSOA &updateSOA &getRecLine &getRecList &getRecCount
54 &addRec &updateRec &delRec
55 &getLogCount &getLogEntries
56 &getRevPattern
57 &getTypelist
58 &parentID
59 &isParent
60 &zoneStatus &getZonesByCIDR &importAXFR
61 &export
62 &mailNotify
63 %typemap %reverse_typemap
64 @permtypes $permlist %permchains
65 );
66
67@EXPORT = qw(%typemap %reverse_typemap @permtypes $permlist %permchains);
68%EXPORT_TAGS = ( ALL => [qw(
69 &initGlobals &login &initActionLog
70 &getPermissions &changePermissions &comparePermissions
71 &changeGroup
72 &connectDB &finish
73 &addDomain &delZone &domainName &revName &domainID &revID &addRDNS
74 &getZoneCount &getZoneList &getZoneLocation
75 &addGroup &delGroup &getChildren &groupName
76 &getGroupCount &getGroupList
77 &addUser &updateUser &delUser &userFullName &userStatus &getUserData
78 &getUserCount &getUserList &getUserDropdown
79 &addLoc &updateLoc &delLoc &getLoc
80 &getLocCount &getLocList &getLocDropdown
81 &getSOA &updateSOA &getRecLine &getRecList &getRecCount
82 &addRec &updateRec &delRec
83 &getLogCount &getLogEntries
84 &getRevPattern
85 &getTypelist
86 &parentID
87 &isParent
88 &zoneStatus &getZonesByCIDR &importAXFR
89 &export
90 &mailNotify
91 %typemap %reverse_typemap
92 @permtypes $permlist %permchains
93 )]
94 );
95
96our $errstr = '';
97our $resultstr = '';
98
99# Arguably defined wholly in the db, but little reason to change without supporting code changes
100# group_view, user_view permissions? separate rDNS permission(s)?
101our @permtypes = qw (
102 group_edit group_create group_delete
103 user_edit user_create user_delete
104 domain_edit domain_create domain_delete
105 record_edit record_create record_delete record_locchg
106 location_edit location_create location_delete location_view
107 self_edit admin
108);
109our $permlist = join(',',@permtypes);
110
111# Some permissions more or less require certain others.
112our %permchains = (
113 user_edit => 'self_edit',
114 location_edit => 'location_view',
115 location_create => 'location_view',
116 location_delete => 'location_view',
117 record_locchg => 'location_view',
118);
119
120# DNS record type map and reverse map.
121# loaded from the database, from http://www.iana.org/assignments/dns-parameters
122our %typemap;
123our %reverse_typemap;
124
125## (Semi)private variables
126
127# Hash of functions for validating record types. Filled in initGlobals() since
128# it relies on visibility flags from the rectypes table in the DB
129my %validators;
130
131# Entity-relationship reference hashes.
132my %par_tbl = (
133 group => 'groups',
134 user => 'users',
135 defrec => 'default_records',
136 defrevrec => 'default_rev_records',
137 domain => 'domains',
138 revzone => 'revzones',
139 record => 'records'
140 );
141my %id_col = (
142 group => 'group_id',
143 user => 'user_id',
144 defrec => 'record_id',
145 defrevrec => 'record_id',
146 domain => 'domain_id',
147 revzone => 'rdns_id',
148 record => 'record_id'
149 );
150my %par_col = (
151 group => 'parent_group_id',
152 user => 'group_id',
153 defrec => 'group_id',
154 defrevrec => 'group_id',
155 domain => 'group_id',
156 revzone => 'group_id',
157 record => 'domain_id'
158 );
159my %par_type = (
160 group => 'group',
161 user => 'group',
162 defrec => 'group',
163 defrevrec => 'group',
164 domain => 'group',
165 revzone => 'group',
166 record => 'domain'
167 );
168
169##
170## Constructor and destructor
171##
172
173sub new {
174 my $this = shift;
175 my $class = ref($this) || $this;
176 my %args = @_;
177
178 # Prepopulate a basic config. Note some of these *will* cause errors if left unset.
179 # note: add appropriate stanzas in __cfgload() to parse these
180 my %defconfig = (
181 # The only configuration options not loadable from a config file.
182 configfile => "/etc/dnsdb/dnsdb.conf", ##CFG_LEAF##
183
184 # Database connection info
185 dbname => 'dnsdb',
186 dbuser => 'dnsdb',
187 dbpass => 'secret',
188 dbhost => '',
189
190 # Email notice settings
191 mailhost => 'smtp.example.com',
192 mailnotify => 'dnsdb@example.com', # to
193 mailsender => 'dnsdb@example.com', # from
194 mailname => 'DNS Administration',
195 orgname => 'Example Corp',
196 domain => 'example.com',
197
198 # Template directory
199 templatedir => 'templates/',
200# fmeh. this is a real web path, not a logical internal one. hm..
201# cssdir => 'templates/',
202 sessiondir => 'session/',
203 exportcache => 'cache/',
204 usecache => 1, # do we bother using the cache above?
205
206 # Session params
207 timeout => '1h', # passed as-is to CGI::Session
208
209 # Other miscellanea
210 log_failures => 1, # log all evarthing by default
211 perpage => 15,
212 maxfcgi => 10, # reasonable default?
213 force_refresh => 1,
214 lowercase => 0, # mangle as little as possible by default
215 # show IPs and CIDR blocks as-is for reverse zones. valid values are
216 # 'none' (default, show natural IP or CIDR)
217 # 'zone' (zone name, wherever used)
218 # 'record' (IP or CIDR values in reverse record lists)
219 # 'all' (all IP values in any reverse zone view)
220 showrev_arpa => 'none',
221 # Two options for template record expansion:
222 template_skip_0 => 0, # publish .0 by default
223 template_skip_255 => 0, # publish .255 by default
224 # allow TXT records to be dealt with mostly automatically by DNS server?
225 autotxt => 1,
226 );
227
228 # Config file parse calls.
229 # If we are passed a blank argument for $args{configfile},
230 # we should NOT parse the default config file - we will
231 # rely on hardcoded defaults OR caller-specified values.
232 # If we are passed a non-blank argument, parse that file.
233 # If no config file is specified, parse the default one.
234 my %siteconfig;
235 if (defined($args{configfile})) {
236 if ($args{configfile}) {
237 return if !__cfgload($args{configfile}, \%siteconfig);
238 }
239 } else {
240 return if !__cfgload($defconfig{configfile}, \%siteconfig);
241 }
242
243 # Assemble the object. Apply configuration hashes in order of precedence.
244 my $self = {
245 # Hardcoded defaults
246 %defconfig,
247 # Default config file OR caller-specified one, loaded above
248 %siteconfig,
249 # Caller-specified arguments
250 %args
251 };
252 bless $self, $class;
253
254 # Several settings are booleans. Handle multiple possible ways of setting them.
255 for my $boolopt ('log_failures', 'force_refresh', 'lowercase', 'usecache',
256 'template_skip_0', 'template_skip_255', 'autotxt') {
257 if ($self->{$boolopt} ne '1' && $self->{$boolopt} ne '0') {
258 # true/false, on/off, yes/no all valid.
259 if ($self->{$boolopt} =~ /^(?:true|false|t|f|on|off|yes|no)$/) {
260 if ($self->{$boolopt} =~ /(?:true|t|on|yes)/) {
261 $self->{$boolopt} = 1;
262 } else {
263 $self->{$boolopt} = 0;
264 }
265 } else {
266 warn "Bad $boolopt setting $self->{$boolopt}, using default\n";
267 $self->{$boolopt} = $defconfig{$boolopt};
268 }
269 }
270 }
271
272 # Enum-ish option(s)
273 if (!grep /$self->{showrev_arpa}/, ('none','zone','record','all')) {
274 warn "Bad showrev_arpa setting $self->{showrev_arpa}, using default\n";
275 $self->{showrev_arpa} = 'none';
276 }
277
278 # Try to connect to the DB, and initialize a number of handy globals.
279 $self->{dbh} = connectDB($self->{dbname}, $self->{dbuser}, $self->{dbpass}, $self->{dbhost}) or return;
280 $self->initGlobals();
281
282 return $self;
283}
284
285sub DESTROY {
286 my $self = shift;
287 $self->{dbh}->disconnect if $self->{dbh};
288}
289
290sub errstr { $DNSDB::errstr; }
291
292##
293## utility functions
294##
295
296## DNSDB::_rectable()
297# Takes default+rdns flags, returns appropriate table name
298sub _rectable {
299 my $def = shift;
300 my $rev = shift;
301
302 return 'records' if $def ne 'y';
303 return 'default_records' if $rev ne 'y';
304 return 'default_rev_records';
305} # end _rectable()
306
307## DNSDB::_recparent()
308# Takes default+rdns flags, returns appropriate parent-id column name
309sub _recparent {
310 my $def = shift;
311 my $rev = shift;
312
313 return 'group_id' if $def eq 'y';
314 return 'rdns_id' if $rev eq 'y';
315 return 'domain_id';
316} # end _recparent()
317
318## DNSDB::_ipparent()
319# Check an IP to be added in a reverse zone to see if it's really in the requested parent.
320# Takes default and reverse flags, IP (fragment) to check, parent zone ID,
321# and a reference to a NetAddr::IP object (also used to pass back a fully-reconstructed IP for
322# database insertion)
323sub _ipparent {
324 my $self = shift;
325 my $dbh = $self->{dbh};
326 my $defrec = shift;
327 my $revrec = shift;
328 my $val = shift;
329 my $id = shift;
330 my $addr = shift;
331
332 return if $revrec ne 'y'; # this sub not useful in forward zones
333
334 $$addr = NetAddr::IP->new($$val); #necessary?
335
336 # subsub to split, reverse, and overlay an IP fragment on a netblock
337 sub __rev_overlay {
338 my $splitme = shift; # ':' or '.', m'lud?
339 my $parnet = shift;
340 my $val = shift;
341 my $addr = shift;
342
343 my $joinme = $splitme;
344 $splitme = '\.' if $splitme eq '.';
345 my @working = reverse(split($splitme, $parnet->addr));
346 my @parts = reverse(split($splitme, $$val));
347 for (my $i = 0; $i <= $#parts; $i++) {
348 $working[$i] = $parts[$i];
349 }
350 my $checkme = NetAddr::IP->new(join($joinme, reverse(@working))) or return 0;
351 return 0 unless $checkme->within($parnet);
352 $$addr = $checkme; # force "correct" IP to be recorded.
353 return 1;
354 }
355
356 my ($parstr) = $dbh->selectrow_array("SELECT revnet FROM revzones WHERE rdns_id = ?", undef, ($id));
357 my $parnet = NetAddr::IP->new($parstr);
358
359 # Fail early on v6-in-v4 or v4-in-v6. We're not accepting these ATM.
360 return 0 if $parnet->addr =~ /\./ && $$val =~ /:/;
361 return 0 if $parnet->addr =~ /:/ && $$val =~ /\./;
362
363 if ($$addr && ($$val =~ /^[\da-fA-F][\da-fA-F:]+[\da-fA-F]$/ || $$val =~ m|/\d+$|)) {
364 # the only case where NetAddr::IP's acceptance of legitimate IPs is "correct" is for a proper IPv6 address,
365 # or a netblock (only expected on templates)
366 # the rest we have to restructure before fiddling. *sigh*
367 return 1 if $$addr->within($parnet);
368 } else {
369 # We don't have a complete IP in $$val (yet)... unless we have a netblock
370 if ($parnet->addr =~ /:/) {
371 $$val =~ s/^:+//; # gotta strip'em all...
372 return __rev_overlay(':', $parnet, $val, $addr);
373 }
374 if ($parnet->addr =~ /\./) {
375 $$val =~ s/^\.+//;
376 return __rev_overlay('.', $parnet, $val, $addr);
377 }
378 # should be impossible to get here...
379 }
380 # ... and here.
381 # can't do nuttin' in forward zones
382} # end _ipparent()
383
384## DNSDB::_maybeip()
385# Wrapper for quick "does this look like an IP address?" regex, so we don't make dumb copy-paste mistakes
386sub _maybeip {
387 my $izzit = shift; # reference
388 return 1 if $$izzit =~ m,^(?:[\d\./]+|[0-9a-fA-F:/]+)$,;
389}
390
391## DNSDB::_inrev()
392# Check if a given "hostname" is within a given reverse zone
393# Takes a reference to the "hostname" and the reverse zone CIDR as a NetAddr::IP
394# Returns true/false. Sets $errstr on errors.
395sub _inrev {
396 my $self = shift;
397 my $dbh = $self->{dbh};
398 # References, since we might munge them
399 my $fq = shift;
400 my $zone = shift;
401
402 # set default error
403 $errstr = "$$fq not within $zone";
404
405 # Unlike forward zones, we will not coerce the data into the reverse zone - an A record
406 # in a reverse zone is already silly enough without appending a mess of 1.2.3.in-addr.arpa
407 # (or worse, 1.2.3.4.5.6.7.8.ip6.arpa) on the end of the nominal "hostname".
408 # We're also going to allow the "hostname" to be stored as .arpa or IP, because of
409 # non-IP FQDNs in .arpa
410 if ($$fq =~ /\.arpa$/) {
411 # "FQDN" could be any syntactically legitimate string, but it must be within the formal
412 # .arpa zone. Note we're not validating these for correct reverse-IP values.
413 # yes, we really need the v6 branch on the end here.
414 $zone = _ZONE($zone, 'ZONE', 'r', '.').($zone->{isv6} ? '.ip6.arpa' : '.in-addr.arpa');
415 return unless $$fq =~ /$zone$/;
416 } else {
417 # in most cases we should be getting a real IP as the "FQDN" to test
418 my $addr = new NetAddr::IP $$fq if _maybeip($fq);
419
420 # "FQDN" should be a valid IP address. Normalize formatting if so.
421 if (!$addr) {
422 $errstr = "$$fq is not a valid IP address";
423 return;
424 }
425 return if !$zone->contains($addr);
426 ($$fq = $addr) =~ s{/(?:32|128)$}{};
427 }
428 return 1;
429} # end _inrev()
430
431## DNSDB::_hostparent()
432# A little different than _ipparent above; this tries to *find* the parent zone of a hostname
433# Takes a hostname.
434# Returns the domain ID of the parent domain if one was found.
435sub _hostparent {
436 my $self = shift;
437 my $dbh = $self->{dbh};
438 my $hname = shift;
439
440 $hname =~ s/^\*\.//; # this should be impossible to find in the domains table.
441 my @hostbits = split /\./, $hname;
442 my $sth = $dbh->prepare("SELECT count(*),domain_id FROM domains WHERE lower(domain) = lower(?) GROUP BY domain_id");
443 foreach (@hostbits) {
444 $sth->execute($hname);
445 my ($found, $parid) = $sth->fetchrow_array;
446 if ($found) {
447 return $parid;
448 }
449 $hname =~ s/^$_\.//;
450 }
451} # end _hostparent()
452
453## DNSDB::_log()
454# Log an action
455# Takes a log entry hash containing at least:
456# group_id, log entry
457# and optionally one or more of:
458# domain_id, rdns_id
459# The %userdata hash provides the user ID, username, and fullname
460sub _log {
461 my $self = shift;
462 my $dbh = $self->{dbh};
463
464 my %args = @_;
465
466 $args{rdns_id} = 0 if !$args{rdns_id};
467 $args{domain_id} = 0 if !$args{domain_id};
468
469##fixme: farm out the actual logging to different subs for file, syslog, internal, etc based on config
470# if ($self->{log_channel} eq 'sql') {
471 $dbh->do("INSERT INTO log (domain_id,rdns_id,group_id,entry,user_id,email,name) VALUES (?,?,?,?,?,?,?)",
472 undef,
473 ($args{domain_id}, $args{rdns_id}, $args{group_id}, $args{entry},
474 $self->{loguserid}, $self->{logusername}, $self->{logfullname}) );
475# } elsif ($self->{log_channel} eq 'file') {
476# } elsif ($self->{log_channel} eq 'syslog') {
477# }
478} # end _log
479
480
481##
482## Record validation subs.
483##
484
485## All of these subs take substantially the same arguments:
486# a hash containing at least the following keys:
487# - defrec (default/live flag)
488# - revrec (forward/reverse flag)
489# - id (parent entity ID)
490# - host (hostname)
491# - rectype
492# - val (IP, hostname [CNAME/MX/SRV] or text)
493# - addr (NetAddr::IP object from val. May be undef.)
494# MX and SRV record validation also expect distance, and SRV records expect weight and port as well.
495# host, rectype, and addr should be references as these may be modified in validation
496
497# A record
498sub _validate_1 {
499 my $self = shift;
500 my $dbh = $self->{dbh};
501
502 my %args = @_;
503
504# only for strict type restrictions
505# return ('FAIL', 'Reverse zones cannot contain A records') if $args{revrec} eq 'y';
506
507 if ($args{revrec} eq 'y') {
508 # Get the revzone, so we can see if ${$args{val}} is in that zone
509 my $revzone = new NetAddr::IP $self->revName($args{id}, 'y');
510
511 return ('FAIL', $errstr) if !$self->_inrev($args{val}, $revzone);
512
513 # ${$args{val}} is either a valid IP or a string ending with the .arpa zone name;
514 # now check if it's a well-formed FQDN
515 return ('FAIL', $errstr) if ! _check_hostname_form(${$args{val}}, ${$args{rectype}}, $args{defrec}, $args{revrec}) &&
516 ${$args{val}} =~ /\.arpa$/;
517
518 # Check IP is well-formed, and that it's a v4 address
519 # Fail on "compact" IPv4 variants, because they are not consistent and predictable.
520 return ('FAIL',"A record must be a valid IPv4 address")
521 unless ${$args{host}} =~ m{^\d+\.\d+\.\d+\.\d+(/\d+)?$};
522 $args{addr} = new NetAddr::IP ${$args{host}};
523 return ('FAIL',"A record must be a valid IPv4 address")
524 unless $args{addr} && !$args{addr}->{isv6};
525 # coerce IP/value to normalized form for storage
526 ${$args{host}} = $args{addr}->addr;
527
528 # I'm just going to ignore the utterly barmy idea of an A record in the *default*
529 # records for a reverse zone; it's bad enough to find one in funky legacy data.
530
531 } else {
532 # revrec ne 'y'
533
534 # Coerce all hostnames to end in ".DOMAIN" for group/default records,
535 # or the intended parent domain for live records.
536 my $pname = ($args{defrec} eq 'y' ? 'DOMAIN' : $self->domainName($args{id}));
537 ${$args{host}} =~ s/\.*$/\.$pname/ if (${$args{host}} ne '@' && ${$args{host}} !~ /$pname$/i);
538
539 # Check if it's a proper formal .arpa name for an IP, and renormalize it to the IP
540 # value if so. Done mainly for symmetry with PTR/A+PTR, and saves a conversion on export.
541 if (${$args{val}} =~ /\.arpa$/) {
542 my ($code,$tmp) = _zone2cidr(${$args{val}});
543 if ($code ne 'FAIL') {
544 ${$args{val}} = $tmp->addr;
545 $args{addr} = $tmp;
546 }
547 }
548 # Check IP is well-formed, and that it's a v4 address
549 # Fail on "compact" IPv4 variants, because they are not consistent and predictable.
550 return ('FAIL',"A record must be a valid IPv4 address")
551 unless ${$args{val}} =~ /^\d+\.\d+\.\d+\.\d+$/;
552 $args{addr} = new NetAddr::IP ${$args{val}};
553 return ('FAIL',"A record must be a valid IPv4 address")
554 unless $args{addr} && !$args{addr}->{isv6};
555 # coerce IP/value to normalized form for storage
556 ${$args{val}} = $args{addr}->addr;
557 }
558
559 return ('OK','OK');
560} # done A record
561
562# NS record
563sub _validate_2 {
564 my $self = shift;
565 my $dbh = $self->{dbh};
566
567 my %args = @_;
568
569 # NS target check - IP addresses not allowed. Must be a more or less well-formed hostname.
570 if ($args{revrec} eq 'y') {
571 return ('FAIL', "NS records cannot point directly to an IP address")
572 if ${$args{host}} =~ /^(?:[\d.]+|[0-9a-fA-F:]+)$/;
573##enhance: Look up the passed value to see if it exists. Ooo, fancy.
574 return ('FAIL', $errstr) if ! _check_hostname_form(${$args{host}}, ${$args{rectype}}, $args{defrec}, $args{revrec});
575 } else {
576 return ('FAIL', "NS records cannot point directly to an IP address")
577 if ${$args{val}} =~ /^(?:[\d.]+|[0-9a-fA-F:]+)$/;
578##enhance: Look up the passed value to see if it exists. Ooo, fancy.
579 return ('FAIL', $errstr) if ! _check_hostname_form(${$args{val}}, ${$args{rectype}}, $args{defrec}, $args{revrec});
580 }
581
582 # Check that the target of the record is within the parent.
583 if ($args{defrec} eq 'n') {
584 # Check if IP/address/zone/"subzone" is within the parent
585 if ($args{revrec} eq 'y') {
586 # Get the revzone, so we can see if ${$args{val}} is in that zone
587 my $revzone = new NetAddr::IP $self->revName($args{id}, 'y');
588
589 # Note the NS record may or may not be for the zone itself, it may be a pointer for a subzone
590 return ('FAIL', $errstr) if !$self->_inrev($args{val}, $revzone);
591
592 # ${$args{val}} is either a valid IP or a string ending with the .arpa zone name;
593 # now check if it's a well-formed FQDN
594##enhance or ##fixme
595# convert well-formed .arpa names to IP addresses to match old "strict" validation design
596 return ('FAIL', $errstr) if ! _check_hostname_form(${$args{val}}, ${$args{rectype}}, $args{defrec}, $args{revrec}) &&
597 ${$args{val}} =~ /\.arpa$/;
598 } else {
599 # Forcibly append the domain name if the hostname being added does not end with the current domain name
600 my $pname = $self->domainName($args{id});
601 ${$args{host}} =~ s/\.*$/\.$pname/ if (${$args{host}} ne '@' && ${$args{host}} !~ /$pname$/i);
602 }
603 } else {
604 # Default reverse NS records should always refer to the implied parent.
605 if ($args{revrec} eq 'y') {
606 ${$args{val}} = 'ZONE';
607 } else {
608 ${$args{host}} = 'DOMAIN';
609 }
610 }
611
612 return ('OK','OK');
613} # done NS record
614
615# CNAME record
616sub _validate_5 {
617 my $self = shift;
618 my $dbh = $self->{dbh};
619
620 my %args = @_;
621
622 # CNAMEs in reverse zones shouldn't be handled manually, they should be generated on
623 # export by use of the "delegation" type. For the masochistic, and those importing
624 # legacy data from $deity-knows-where, we'll support them.
625
626 if ($args{revrec} eq 'y') {
627 # CNAME target check - IP addresses not allowed. Must be a more or less well-formed hostname.
628 return ('FAIL', "CNAME records cannot point directly to an IP address")
629 if ${$args{host}} =~ /^(?:[\d.]+|[0-9a-fA-F:]+)$/;
630
631 if ($args{defrec} eq 'n') {
632 # Get the revzone, so we can see if ${$args{val}} is in that zone
633 my $revzone = new NetAddr::IP $self->revName($args{id}, 'y');
634 return ('FAIL', $errstr) if !$self->_inrev($args{val}, $revzone);
635 # CNAMEs can not be used for parent nodes; just leaf nodes with no other record types
636 # note that this WILL probably miss some edge cases.
637 if (${$args{val}} =~ /^[\d.\/]+$/) {
638 # convert IP "hostname" to .arpa
639 my $tmphn = _ZONE(NetAddr::IP->new(${$args{val}}), 'ZONE', 'r', '.');
640 my $tmpz = _ZONE($revzone, 'ZONE', 'r', '.');
641 return ('FAIL', "The bare zone may not be a CNAME") if $tmphn eq $tmpz;
642##enhance: look up the target name and publish that instead on export
643 }
644 }
645
646##enhance or ##fixme
647# convert well-formed .arpa names to IP addresses to match old "strict" validation design
648 return ('FAIL', $errstr) if ! _check_hostname_form(${$args{val}}, ${$args{rectype}}, $args{defrec}, $args{revrec}) &&
649 ${$args{val}} =~ /\.arpa$/;
650
651##enhance: Look up the passed value to see if it exists. Ooo, fancy.
652 return ('FAIL', $errstr) if ! _check_hostname_form(${$args{host}}, ${$args{rectype}}, $args{defrec}, $args{revrec});
653 } else {
654 # CNAME target check - IP addresses not allowed. Must be a more or less well-formed hostname.
655 return ('FAIL', "CNAME records cannot point directly to an IP address")
656 if ${$args{val}} =~ /^(?:[\d.]+|[0-9a-fA-F:]+)$/;
657
658 # Make sure target is a well-formed hostname
659 return ('FAIL', $errstr) if ! _check_hostname_form(${$args{val}}, ${$args{rectype}}, $args{defrec}, $args{revrec});
660
661 # Forcibly append the domain name if the hostname being added does not end with the current domain name
662 my $pname = ($args{defrec} eq 'y' ? 'DOMAIN' : $self->domainName($args{id}));
663 ${$args{host}} =~ s/\.*$/\.$pname/ if ${$args{host}} !~ /$pname$/i;
664
665 # CNAMEs can not be used for parent nodes; just leaf nodes with no other record types
666 # Enforce this for the zone name
667 return ('FAIL', "The bare zone name may not be a CNAME") if ${$args{host}} eq $pname || ${$args{host}} =~ /^\@/;
668
669##enhance: Look up the passed value to see if it exists. Ooo, fancy.
670 return ('FAIL', $errstr) if ! _check_hostname_form(${$args{val}}, ${$args{rectype}}, $args{defrec}, $args{revrec});
671 }
672
673 return ('OK','OK');
674} # done CNAME record
675
676# SOA record
677sub _validate_6 {
678 # Smart monkeys won't stick their fingers in here; we have
679 # separate dedicated routines to deal with SOA records.
680 return ('OK','OK');
681} # done SOA record
682
683# PTR record
684sub _validate_12 {
685 my $self = shift;
686 my $dbh = $self->{dbh};
687
688 my %args = @_;
689 my $warnflag = '';
690
691 if ($args{defrec} eq 'y') {
692 if ($args{revrec} eq 'y') {
693 if (${$args{val}} =~ /^[\d.]+$/) {
694 # v4 or bare number
695 if (${$args{val}} =~ /^\d+\.\d+\.\d+\.\d+$/) {
696 # probable full IP. pointless but harmless. validate/normalize.
697 my $tmp = NetAddr::IP->new(${$args{val}})->addr
698 or return ('FAIL', "${$args{val}} is not a valid IP address");
699 ${$args{val}} = $tmp;
700 $warnflag = "${$args{val}} will only be added to a small number of zones\n";
701 } elsif (${$args{val}} =~ /^\d+$/) {
702 # bare number. This can be expanded to either a v4 or v6 zone
703 ${$args{val}} =~ s/^\.*/ZONE./ unless ${$args{val}} =~ /^ZONE/;
704 } else {
705 # $deity-only-knows what kind of gibberish we've been given. Only usable as a formal .arpa name.
706 # Append ARPAZONE to be replaced with the formal .arpa zone name when converted to a live record.
707 ${$args{val}} =~ s/\.*$/.ARPAZONE/ unless ${$args{val}} =~ /ARPAZONE$/;
708 }
709 } elsif (${$args{val}} =~ /^[a-fA-F0-9:]+$/) {
710 # v6 or fragment; pray it's not complete gibberish
711 ${$args{val}} =~ s/^:*/ZONE::/ unless ${$args{val}} =~ /^ZONE/;
712 } else {
713 # $deity-only-knows what kind of gibberish we've been given. Only usable as a formal .arpa name.
714 # Append ARPAZONE to be replaced with the formal .arpa zone name when converted to a live record.
715 ${$args{val}} .= ".ARPAZONE" unless ${$args{val}} =~ /ARPAZONE$/;
716 }
717 } else {
718 return ('FAIL', "PTR records are not supported in default record sets for forward zones (domains)");
719 }
720 } else {
721 if ($args{revrec} eq 'y') {
722 # Get the revzone, so we can see if ${$args{val}} is in that zone
723 my $revzone = new NetAddr::IP $self->revName($args{id}, 'y');
724
725 return ('FAIL', $errstr) if !$self->_inrev($args{val}, $revzone);
726
727 if (${$args{val}} =~ /\.arpa$/) {
728 # Check that it's well-formed
729 return ('FAIL', $errstr) if ! _check_hostname_form(${$args{val}}, ${$args{rectype}}, $args{defrec}, $args{revrec});
730
731 # Check if it's a proper formal .arpa name for an IP, and renormalize it to the IP
732 # value if so. I can't see why someone would voluntarily work with those instead of
733 # the natural IP values but what the hey.
734 my ($code,$tmp) = _zone2cidr(${$args{val}});
735 ${$args{val}} = $tmp->addr if $code ne 'FAIL';
736 } else {
737 # not a formal .arpa name, so it should be an IP value. Validate...
738 return ('FAIL', "${$args{val}} is not a valid IP value")
739 unless ${$args{val}} =~ /^(?:\d+\.\d+\.\d+\.\d+|[a-fA-F0-9:]+)$/;
740 $args{addr} = NetAddr::IP->new(${$args{val}})
741 or return ('FAIL', "IP/value looks like an IP address but isn't valid");
742 # ... and normalize.
743 ${$args{val}} = $args{addr}->addr;
744 }
745 # Validate PTR target for form.
746 # %blank% skips the IP when expanding a template record
747 return ('FAIL', $errstr)
748 unless _check_hostname_form(${$args{host}}, ${$args{rectype}}, $args{defrec}, $args{revrec}) ||
749 lc(${$args{host}}) eq '%blank%';
750 } else { # revrec ne 'y'
751 # Fetch the domain and append if the passed hostname isn't within it.
752 my $pname = ($args{defrec} eq 'y' ? 'DOMAIN' : $self->domainName($args{id}));
753 ${$args{host}} =~ s/\.*$/\.$pname/ if (${$args{host}} ne '@' && ${$args{host}} !~ /$pname$/i);
754 # Validate hostname and target for form
755 return ('FAIL', $errstr) if ! _check_hostname_form(${$args{host}}, ${$args{rectype}}, $args{defrec}, $args{revrec});
756 return ('FAIL', $errstr) if ! _check_hostname_form(${$args{val}}, ${$args{rectype}}, $args{defrec}, $args{revrec});
757 }
758 }
759
760# Multiple PTR records do NOT generally do what most people believe they do,
761# and tend to fail in the most awkward way possible. Check and warn.
762
763 my $chkbase = ${$args{val}};;
764 my $hostcol = 'val'; # Reverse zone hostnames are stored "backwards"
765 if ($args{revrec} eq 'n') { # PTRs in forward zones should be rare.
766 $chkbase = ${$args{host}};
767 $hostcol = 'host';
768 }
769 my @checkvals = ($chkbase);
770 if ($chkbase =~ /,/) {
771 # push . and :: variants into checkvals if $chkbase has ,
772 my $tmp;
773 ($tmp = $chkbase) =~ s/,/./;
774 push @checkvals, $tmp;
775 ($tmp = $chkbase) =~ s/,/::/;
776 push @checkvals, $tmp;
777 }
778
779 my $pcsth = $dbh->prepare("SELECT count(*) FROM "._rectable($args{defrec},$args{revrec})." WHERE $hostcol = ?");
780 foreach my $checkme (@checkvals) {
781 if ($args{update}) {
782 # $args{update} contains the ID of the record being updated. If the list of records that matches
783 # the new hostname specification doesn't include this, the change effectively adds a new PTR that's
784 # the same as one or more existing ones.
785 my @ptrs = @{ $dbh->selectcol_arrayref("SELECT record_id FROM "._rectable($args{defrec},$args{revrec}).
786 " WHERE val = ?", undef, ($checkme)) };
787 $warnflag .= "PTR record for $checkme already exists; adding another will probably not do what you want"
788 if @ptrs && (!grep /^$args{update}$/, @ptrs);
789 } else {
790 # New record. Always warn if a PTR exists
791 # Don't warn when a matching A record exists tho
792 my ($ptrcount) = $dbh->selectrow_array("SELECT count(*) FROM "._rectable($args{defrec},$args{revrec}).
793 " WHERE $hostcol = ? AND (type=12 OR type=65280 OR type=65281)", undef, ($checkme));
794 $warnflag .= "PTR record for $checkme already exists; adding another will probably not do what you want"
795 if $ptrcount;
796 }
797 }
798
799 return ('WARN',$warnflag) if $warnflag;
800
801 return ('OK','OK');
802} # done PTR record
803
804# MX record
805sub _validate_15 {
806 my $self = shift;
807 my $dbh = $self->{dbh};
808
809 my %args = @_;
810
811# only for strict type restrictions
812# return ('FAIL', 'Reverse zones cannot contain MX records') if $args{revrec} eq 'y';
813
814 return ('FAIL', "Distance is required for MX records") unless defined(${$args{dist}});
815 ${$args{dist}} =~ s/\s*//g;
816 return ('FAIL',"Distance is required, and must be numeric") unless ${$args{dist}} =~ /^\d+$/;
817
818 ${$args{fields}} = "distance,";
819 push @{$args{vallist}}, ${$args{dist}};
820
821 if ($args{revrec} eq 'n') {
822 # MX target check - IP addresses not allowed. Must be a more or less well-formed hostname.
823 return ('FAIL', "MX records cannot point directly to an IP address")
824 if ${$args{val}} =~ /^(?:[\d.]+|[0-9a-fA-F:]+)$/;
825
826 # Coerce all hostnames to end in ".DOMAIN" for group/default records,
827 # or the intended parent domain for live records.
828 my $pname = ($args{defrec} eq 'y' ? 'DOMAIN' : $self->domainName($args{id}));
829 ${$args{host}} =~ s/\.*$/\.$pname/ if (${$args{host}} ne '@' && ${$args{host}} !~ /$pname$/i);
830 return ('FAIL', $errstr) if ! _check_hostname_form(${$args{host}}, ${$args{rectype}}, $args{defrec}, $args{revrec});
831 } else {
832 # MX target check - IP addresses not allowed. Must be a more or less well-formed hostname.
833 return ('FAIL', "MX records cannot point directly to an IP address")
834 if ${$args{host}} =~ /^(?:[\d.]+|[0-9a-fA-F:]+)$/;
835
836 # MX records in reverse zones get stricter treatment. The UI bars adding them in
837 # reverse record sets, but we "need" to allow editing existing ones. And we'll allow
838 # editing them if some loon manually munges one into a default reverse record set.
839 if ($args{defrec} eq 'n') {
840 # Get the revzone, so we can see if ${$args{val}} is in that zone
841 my $revzone = new NetAddr::IP $self->revName($args{id}, 'y');
842 return ('FAIL', $errstr) if !$self->_inrev($args{val}, $revzone);
843 }
844
845##enhance or ##fixme
846# convert well-formed .arpa names to IP addresses to match old "strict" validation design
847 return ('FAIL', $errstr) if ! _check_hostname_form(${$args{val}}, ${$args{rectype}}, $args{defrec}, $args{revrec}) &&
848 ${$args{val}} =~ /\.arpa$/;
849
850##enhance: Look up the passed value to see if it exists. Ooo, fancy.
851 return ('FAIL', $errstr) if ! _check_hostname_form(${$args{host}}, ${$args{rectype}}, $args{defrec}, $args{revrec});
852
853 }
854
855 return ('OK','OK');
856} # done MX record
857
858# TXT record
859sub _validate_16 {
860 my $self = shift;
861
862 my %args = @_;
863
864 if ($args{revrec} eq 'n') {
865 # Coerce all hostnames to end in ".DOMAIN" for group/default records,
866 # or the intended parent domain for live records.
867 my $pname = ($args{defrec} eq 'y' ? 'DOMAIN' : $self->domainName($args{id}));
868 ${$args{host}} =~ s/\.*$/\.$pname/ if (${$args{host}} ne '@' && ${$args{host}} !~ /$pname$/i);
869 return ('FAIL', $errstr) if ! _check_hostname_form(${$args{host}}, ${$args{rectype}}, $args{defrec}, $args{revrec});
870 } else {
871 # We don't coerce reverse "hostnames" into the zone, mainly because we store most sane
872 # records as IP values, not .arpa names.
873 if ($args{defrec} eq 'n') {
874 # Get the revzone, so we can see if ${$args{val}} is in that zone
875 my $revzone = new NetAddr::IP $self->revName($args{id}, 'y');
876 return ('FAIL', $errstr) if !$self->_inrev($args{val}, $revzone);
877 }
878
879##enhance or ##fixme
880# convert well-formed .arpa names to IP addresses to match old "strict" validation design
881 return ('FAIL', $errstr) if ! _check_hostname_form(${$args{val}}, ${$args{rectype}}, $args{defrec}, $args{revrec}) &&
882 ${$args{val}} =~ /\.arpa$/;
883 }
884
885 # Could arguably put a WARN return here on very long (>512) records
886 return ('OK','OK');
887} # done TXT record
888
889# RP record
890sub _validate_17 {
891 # Probably have to validate these separately some day. Call _validate_16() above since
892 # they're otherwise very similar
893 return _validate_16(@_);
894} # done RP record
895
896# AAAA record
897# Almost but not quite an exact duplicate of A record
898sub _validate_28 {
899 my $self = shift;
900 my $dbh = $self->{dbh};
901
902 my %args = @_;
903
904# only for strict type restrictions
905# return ('FAIL', 'Reverse zones cannot contain AAAA records') if $args{revrec} eq 'y';
906
907 if ($args{revrec} eq 'y') {
908 # Get the revzone, so we can see if ${$args{val}} is in that zone
909 my $revzone = new NetAddr::IP $self->revName($args{id}, 'y');
910
911 return ('FAIL', $errstr) if !$self->_inrev($args{val}, $revzone);
912
913 # ${$args{val}} is either a valid IP or a string ending with the .arpa zone name;
914 # now check if it's a well-formed FQDN
915 return ('FAIL', $errstr) if ! _check_hostname_form(${$args{val}}, ${$args{rectype}}, $args{defrec}, $args{revrec}) &&
916 ${$args{val}} =~ /\.arpa$/;
917
918 # Check IP is well-formed, and that it's a v4 address
919 # Fail on "compact" IPv4 variants, because they are not consistent and predictable.
920 return ('FAIL',"AAAA record must be a valid IPv6 address")
921 unless ${$args{host}} =~ /^[a-fA-F0-9:]+$/;
922 $args{addr} = new NetAddr::IP ${$args{host}};
923 return ('FAIL',"AAAA record must be a valid IPv6 address")
924 unless $args{addr} && $args{addr}->{isv6};
925 # coerce IP/value to normalized form for storage
926 ${$args{host}} = $args{addr}->addr;
927
928 # I'm just going to ignore the utterly barmy idea of an AAAA record in the *default*
929 # records for a reverse zone; it's bad enough to find one in funky legacy data.
930
931 } else {
932 # revrec ne 'y'
933
934 # Coerce all hostnames to end in ".DOMAIN" for group/default records,
935 # or the intended parent domain for live records.
936 my $pname = ($args{defrec} eq 'y' ? 'DOMAIN' : $self->domainName($args{id}));
937 ${$args{host}} =~ s/\.*$/\.$pname/ if (${$args{host}} ne '@' && ${$args{host}} !~ /$pname$/i);
938
939 # Check if it's a proper formal .arpa name for an IP, and renormalize it to the IP
940 # value if so. Done mainly for symmetry with PTR/AAAA+PTR, and saves a conversion on export.
941 if (${$args{val}} =~ /\.arpa$/) {
942 my ($code,$tmp) = _zone2cidr(${$args{val}});
943 if ($code ne 'FAIL') {
944 ${$args{val}} = $tmp->addr;
945 $args{addr} = $tmp;
946 }
947 }
948 # Check IP is well-formed, and that it's a v6 address
949 return ('FAIL',"AAAA record must be a valid IPv6 address")
950 unless ${$args{val}} =~ /^[a-fA-F0-9:]+$/;
951 $args{addr} = new NetAddr::IP ${$args{val}};
952 return ('FAIL',"AAAA record must be a valid IPv6 address")
953 unless $args{addr} && $args{addr}->{isv6};
954 # coerce IP/value to normalized form for storage
955 ${$args{val}} = $args{addr}->addr;
956 }
957
958 return ('OK','OK');
959} # done AAAA record
960
961# SRV record
962sub _validate_33 {
963 my $self = shift;
964 my $dbh = $self->{dbh};
965
966 my %args = @_;
967
968# Not absolutely true but WTF use is an SRV record for a reverse zone?
969# return ('FAIL', 'Reverse zones cannot contain SRV records') if $args{revrec} eq 'y';
970
971 # Key additional record parts. Always required.
972 return ('FAIL',"Distance, port and weight are required for SRV records")
973 unless defined(${$args{weight}}) && defined(${$args{port}}) && defined(${$args{dist}});
974 ${$args{dist}} =~ s/\s*//g;
975 ${$args{weight}} =~ s/\s*//g;
976 ${$args{port}} =~ s/\s*//g;
977 return ('FAIL',"Distance, port and weight are required, and must be numeric")
978 unless ${$args{weight}} =~ /^\d+$/ && ${$args{port}} =~ /^\d+$/;
979
980 ${$args{fields}} = "distance,weight,port,";
981 push @{$args{vallist}}, (${$args{dist}}, ${$args{weight}}, ${$args{port}});
982
983 if ($args{revrec} eq 'n') {
984 # Coerce all hostnames to end in ".DOMAIN" for group/default records,
985 # or the intended parent domain for live records.
986 my $pname = ($args{defrec} eq 'y' ? 'DOMAIN' : $self->domainName($args{id}));
987 ${$args{host}} =~ s/\.*$/\.$pname/ if ${$args{host}} !~ /$pname$/i;
988
989##enhance: Rejig so that we can pass back a WARN red flag, instead of
990# hard-failing, since it seems that purely from the DNS record perspective,
991# SRV records without underscores are syntactically valid
992 # Not strictly true, but SRV records not following this convention won't be found.
993 return ('FAIL',"SRV records must begin with _service._protocol [${$args{host}}]")
994 unless ${$args{host}} =~ /^_[A-Za-z-]+\._[A-Za-z]+\.[a-zA-Z0-9-]+/;
995
996 # SRV target check - IP addresses not allowed. Must be a more or less well-formed hostname.
997 return ('FAIL', "SRV records cannot point directly to an IP address")
998 if ${$args{val}} =~ /^(?:[\d.]+|[0-9a-fA-F:]+)$/;
999 } else {
1000 # hm. we can't do anything sane with IP values here; part of the record data is in
1001 # fact encoded in the "hostname". enforce .arpa names? OTOH, SRV records in a reverse
1002 # zone are pretty silly.
1003
1004##enhance: Rejig so that we can pass back a WARN red flag, instead of
1005# hard-failing, since it seems that purely from the DNS record perspective,
1006# SRV records without underscores are syntactically valid
1007 # Not strictly true, but SRV records not following this convention won't be found.
1008 return ('FAIL',"SRV records must begin with _service._protocol [${$args{host}}]")
1009 unless ${$args{val}} =~ /^_[A-Za-z]+\._[A-Za-z]+\.[a-zA-Z0-9-]+/;
1010
1011 # SRV target check - IP addresses not allowed. Must be a more or less well-formed hostname.
1012 return ('FAIL', "SRV records cannot point directly to an IP address")
1013 if ${$args{host}} =~ /^(?:[\d.]+|[0-9a-fA-F:]+)$/;
1014
1015 # SRV records in reverse zones get stricter treatment. The UI bars adding them in
1016 # reverse record sets, but we "need" to allow editing existing ones. And we'll allow
1017 # editing them if some loon manually munges one into a default reverse record set.
1018 if ($args{defrec} eq 'n') {
1019 # Get the revzone, so we can see if ${$args{val}} is in that zone
1020 my $revzone = new NetAddr::IP $self->revName($args{id}, 'y');
1021 return ('FAIL', $errstr) if !$self->_inrev($args{val}, $revzone);
1022 }
1023
1024##enhance or ##fixme
1025# convert well-formed .arpa names to IP addresses to match old "strict" validation design
1026 return ('FAIL', $errstr) if ! _check_hostname_form(${$args{val}}, ${$args{rectype}}, $args{defrec}, $args{revrec}) &&
1027 ${$args{val}} =~ /\.arpa$/;
1028
1029##enhance: Look up the passed value to see if it exists. Ooo, fancy.
1030 return ('FAIL', $errstr) if ! _check_hostname_form(${$args{host}}, ${$args{rectype}}, $args{defrec}, $args{revrec});
1031
1032 }
1033
1034 return ('OK','OK');
1035} # done SRV record
1036
1037# Now the custom types
1038
1039# A+PTR record. With a very little bit of magic we can also use this sub to validate AAAA+PTR. Whee!
1040sub _validate_65280 {
1041 my $self = shift;
1042 my $dbh = $self->{dbh};
1043
1044 my %args = @_;
1045
1046 my $code = 'OK';
1047 my $msg = 'OK';
1048
1049 if ($args{defrec} eq 'n') {
1050 # live record; revrec determines whether we validate the PTR or A component first.
1051
1052 # Fail early on non-IP gibberish in ${$args{val}}. Arguably .arpa names might be acceptable
1053 # but that gets stupid in forward zones, since these records are shared.
1054 return ('FAIL', "$typemap{${$args{rectype}}} record must be a valid IPv4 address")
1055 if ${$args{rectype}} == 65280 && ${$args{val}} !~ m{^\d+\.\d+\.\d+\.\d+(?:/\d+)?$};
1056 return ('FAIL', "$typemap{${$args{rectype}}} record must be a valid IPv6 address")
1057 if ${$args{rectype}} == 65281 && ${$args{val}} !~ m{^[a-fA-F0-9:]+(?:/\d+)?$};
1058 # If things are not OK, this should prevent Stupid in the error log.
1059 $args{addr} = new NetAddr::IP ${$args{val}}
1060 or return ('FAIL', "$typemap{${$args{rectype}}} record must be a valid IPv".
1061 (${$args{rectype}} == 65280 ? '4' : '6')." address");
1062 ${$args{val}} = $args{addr}->addr;
1063
1064 if ($args{revrec} eq 'y') {
1065 ($code,$msg) = $self->_validate_12(%args);
1066 return ($code,$msg) if $code eq 'FAIL';
1067
1068 # check A+PTR is really v4
1069 return ('FAIL',"$typemap{${$args{rectype}}} record must be a valid IPv4 address")
1070 if ${$args{rectype}} == 65280 && $args{addr}->{isv6};
1071 # check AAAA+PTR is really v6
1072 return ('FAIL',"$typemap{${$args{rectype}}} record must be a valid IPv6 address")
1073 if ${$args{rectype}} == 65281 && !$args{addr}->{isv6};
1074
1075 # Check if the reqested domain exists. If not, coerce the type down to PTR and warn.
1076 if (!(${$args{domid}} = $self->_hostparent(${$args{host}}))) {
1077 my $addmsg = "Record ".($args{update} ? 'updated' : 'added').
1078 " as PTR instead of $typemap{${$args{rectype}}}; domain not found for ${$args{host}}";
1079 $msg .= "\n$addmsg" if $code eq 'WARN';
1080 $msg = $addmsg if $code eq 'OK';
1081 ${$args{rectype}} = $reverse_typemap{PTR};
1082 return ('WARN', $msg);
1083 }
1084
1085 # Add domain ID to field list and values
1086 ${$args{fields}} .= "domain_id,";
1087 push @{$args{vallist}}, ${$args{domid}};
1088
1089 } else {
1090 ($code,$msg) = $self->_validate_1(%args) if ${$args{rectype}} == 65280;
1091 ($code,$msg) = $self->_validate_28(%args) if ${$args{rectype}} == 65281;
1092 return ($code,$msg) if $code eq 'FAIL';
1093
1094 # Check if the requested reverse zone exists - note, an IP fragment won't
1095 # work here since we don't *know* which parent to put it in.
1096 # ${$args{val}} has been validated as a valid IP by now, in one of the above calls.
1097 my ($revid) = $dbh->selectrow_array("SELECT rdns_id FROM revzones WHERE revnet >> ?".
1098 " ORDER BY masklen(revnet) DESC", undef, (${$args{val}}));
1099 if (!$revid) {
1100 $msg = "Record ".($args{update} ? 'updated' : 'added')." as ".(${$args{rectype}} == 65280 ? 'A' : 'AAAA').
1101 " instead of $typemap{${$args{rectype}}}; reverse zone not found for ${$args{val}}";
1102 ${$args{rectype}} = (${$args{rectype}} == 65280 ? $reverse_typemap{A} : $reverse_typemap{AAAA});
1103 return ('WARN', $msg);
1104 }
1105
1106 # Check for duplicate PTRs. Note we don't have to play games with $code and $msg, because
1107 # by definition there can't be duplicate PTRs if the reverse zone isn't managed here.
1108 if ($args{update}) {
1109 # Record update. There should usually be an existing PTR (the record being updated)
1110 my @ptrs = @{ $dbh->selectcol_arrayref("SELECT record_id FROM "._rectable($args{defrec},$args{revrec}).
1111 " WHERE val = ?", undef, (${$args{val}})) };
1112 if (@ptrs && (!grep /^$args{update}$/, @ptrs)) {
1113 $msg = "PTR record for ${$args{val}} already exists; adding another will probably not do what you want";
1114 $code = 'WARN';
1115 }
1116 } else {
1117 # New record. Always warn if a PTR exists
1118 my ($ptrcount) = $dbh->selectrow_array("SELECT count(*) FROM "._rectable($args{defrec},$args{revrec}).
1119 " WHERE val = ?", undef, (${$args{val}}));
1120 $msg = "PTR record for ${$args{val}} already exists; adding another will probably not do what you want"
1121 if $ptrcount;
1122 $code = 'WARN' if $ptrcount;
1123 }
1124
1125 # Add the reverse zone ID to the fieldlist
1126 ${$args{fields}} .= "rdns_id,";
1127 push @{$args{vallist}}, $revid;
1128
1129 # Coerce the hostname back to the domain; this is so it displays and manipulates
1130 # sanely in the reverse zone.
1131 if (${$args{host}} eq '@') {
1132 ${$args{host}} = $self->domainName($args{id}); # errors? What errors?
1133 }
1134 } # revrec ne 'y'
1135
1136 } else { # defrec eq 'y'
1137
1138 if ($args{revrec} eq 'y') {
1139 ($code,$msg) = $self->_validate_12(%args);
1140 return ($code,$msg) if $code eq 'FAIL';
1141 if (${$args{rectype}} == 65280) {
1142 return ('FAIL',"A+PTR record must be a valid IPv4 address or fragment")
1143 if ${$args{val}} =~ /:/;
1144 ${$args{val}} =~ s/^ZONE,/ZONE./; # Clean up after uncertain IP-fragment-type from _validate_12
1145 } elsif (${$args{rectype}} == 65281) {
1146 return ('FAIL',"AAAA+PTR record must be a valid IPv6 address or fragment")
1147 if ${$args{val}} =~ /\./;
1148 ${$args{val}} =~ s/^ZONE,/ZONE::/; # Clean up after uncertain IP-fragment-type from _validate_12
1149 }
1150 } else {
1151 # This is easy. I also can't see a real use-case for A/AAAA+PTR in *all* forward
1152 # domains, since you wouldn't be able to substitute both domain and reverse zone
1153 # sanely, and you'd end up with guaranteed over-replicated PTR records that would
1154 # confuse the hell out of pretty much anything that uses them.
1155##fixme: make this a config flag?
1156 return ('FAIL', "$typemap{${$args{rectype}}} records not allowed in default domains");
1157 }
1158 }
1159
1160 return ($code, $msg);
1161} # done A+PTR record
1162
1163# AAAA+PTR record
1164# A+PTR above has been magicked to handle AAAA+PTR as well.
1165sub _validate_65281 {
1166 return _validate_65280(@_);
1167} # done AAAA+PTR record
1168
1169# PTR template record
1170sub _validate_65282 {
1171 my $self = shift;
1172 my $dbh = $self->{dbh};
1173
1174 my %args = @_;
1175
1176 # we're *this* >.< close to being able to just call _validate_12... unfortunately we can't, quite.
1177 if ($args{revrec} eq 'y') {
1178 if ($args{defrec} eq 'n') {
1179 return ('FAIL', "Template block ${$args{val}} is not within ".$self->revName($args{id}))
1180 unless $self->_ipparent($args{defrec}, $args{revrec}, $args{val}, $args{id}, \$args{addr});
1181##fixme: warn if $args{val} is not /31 or larger block?
1182 ${$args{val}} = "$args{addr}";
1183 } else {
1184 if (${$args{val}} =~ /\./) {
1185 # looks like a v4 or fragment
1186 if (${$args{val}} =~ m|^\d+\.\d+\.\d+\.\d+(?:/\d+)?$|) {
1187 # woo! a complete IP! validate it and normalize, or fail.
1188 $args{addr} = NetAddr::IP->new(${$args{val}})
1189 or return ('FAIL', "IP/value looks like IPv4 but isn't valid");
1190 ${$args{val}} = "$args{addr}";
1191 } else {
1192 ${$args{val}} =~ s/^\.*/ZONE./ unless ${$args{val}} =~ /^ZONE/;
1193 }
1194 } elsif (${$args{val}} =~ /[a-f:]/) {
1195 # looks like a v6 or fragment
1196 ${$args{val}} =~ s/^:*/ZONE::/ if !$args{addr} && ${$args{val}} !~ /^ZONE/;
1197 if ($args{addr}) {
1198 if ($args{addr}->addr =~ /^0/) {
1199 ${$args{val}} =~ s/^:*/ZONE::/ unless ${$args{val}} =~ /^ZONE/;
1200 } else {
1201 ${$args{val}} = "$args{addr}";
1202 }
1203 }
1204 } else {
1205 # bare number (probably). These could be v4 or v6, so we'll
1206 # expand on these on creation of a reverse zone.
1207 ${$args{val}} = "ZONE,${$args{val}}" unless ${$args{val}} =~ /^ZONE/;
1208 }
1209 }
1210##fixme: validate %-patterns?
1211
1212# Unlike single PTR records, there is absolutely no way to sanely support multiple
1213# PTR templates for the same block, since they expect to expand to all the individual
1214# IPs on export. Nested templates should be supported though.
1215
1216 my @checkvals = (${$args{val}});
1217 if (${$args{val}} =~ /,/) {
1218 # push . and :: variants into checkvals if val has ,
1219 my $tmp;
1220 ($tmp = ${$args{val}}) =~ s/,/./;
1221 push @checkvals, $tmp;
1222 ($tmp = ${$args{val}}) =~ s/,/::/;
1223 push @checkvals, $tmp;
1224 }
1225##fixme: this feels wrong still - need to restrict template pseudorecords to One Of Each
1226# Per Netblock such that they don't conflict on export
1227 my $typeck;
1228# type 65282 -> ptr template -> look for any of 65282, 65283, 65284
1229 $typeck = 'type=65283 OR type=65284' if ${$args{rectype}} == 65282;
1230# type 65283 -> a+ptr template -> v4 -> look for 65282 or 65283
1231 $typeck = 'type=65283' if ${$args{rectype}} == 65282;
1232# type 65284 -> aaaa+ptr template -> v6 -> look for 65282 or 65284
1233 $typeck = 'type=65284' if ${$args{rectype}} == 65282;
1234 my $pcsth = $dbh->prepare("SELECT count(*) FROM "._rectable($args{defrec},$args{revrec})." WHERE val = ? ".
1235 "AND (type=65282 OR $typeck)");
1236 foreach my $checkme (@checkvals) {
1237 $pcsth->execute($checkme);
1238 my ($rc) = $pcsth->fetchrow_array;
1239 return ('FAIL', "Only one template pseudorecord may exist for a given IP block") if $rc > 1;
1240 }
1241
1242 } else {
1243 return ('FAIL', "Forward zones cannot contain PTR records");
1244 }
1245
1246 return ('OK','OK');
1247} # done PTR template record
1248
1249# A+PTR template record
1250sub _validate_65283 {
1251 my $self = shift;
1252 my $dbh = $self->{dbh};
1253
1254 my %args = @_;
1255
1256 my ($code,$msg) = ('OK','OK');
1257
1258##fixme: need to fiddle things since A+PTR templates are acceptable in live
1259# forward zones but not default records
1260 if ($args{defrec} eq 'n') {
1261 if ($args{revrec} eq 'n') {
1262
1263 # Coerce all hostnames to end in ".DOMAIN" for group/default records,
1264 # or the intended parent domain for live records.
1265 my $pname = $self->domainName($args{id});
1266 ${$args{host}} =~ s/\.*$/\.$pname/ if ${$args{host}} !~ /$pname$/i;
1267
1268 # check for form; note this checks both normal and "other" hostnames.
1269 return ('FAIL', $errstr)
1270 if !_check_hostname_form(${$args{host}}, ${$args{rectype}}, $args{defrec}, $args{revrec});
1271
1272 # Check if the requested reverse zone exists - note, an IP fragment won't
1273 # work here since we don't *know* which parent to put it in.
1274 # ${$args{val}} has been validated as a valid IP by now, in one of the above calls.
1275 my ($revid) = $dbh->selectrow_array("SELECT rdns_id FROM revzones WHERE revnet >> ?".
1276 " ORDER BY masklen(revnet) DESC", undef, (${$args{val}}));
1277 # Fail if no match; we can't coerce a PTR-template type down to not include the PTR bit currently.
1278 if (!$revid) {
1279 $msg = "Can't ".($args{update} ? 'update' : 'add')." ${$args{host}}/${$args{val}} as ".
1280 "$typemap{${$args{rectype}}}: reverse zone not found for ${$args{val}}";
1281##fixme: add A template, AAAA template types?
1282# ${$args{rectype}} = (${$args{rectype}} == 65280 ? $reverse_typemap{A} : $reverse_typemap{AAAA});
1283 return ('FAIL', $msg);
1284 }
1285
1286 # Add reverse zone ID to field list and values
1287 ${$args{fields}} .= "rdns_id,";
1288 push @{$args{vallist}}, $revid;
1289
1290 } else {
1291 return ('FAIL', "IP or IP fragment ${$args{val}} is not within ".$self->revName($args{id}))
1292 unless $self->_ipparent($args{defrec}, $args{revrec}, $args{val}, $args{id}, \$args{addr});
1293 ${$args{val}} = "$args{addr}";
1294
1295 if (!(${$args{domid}} = $self->_hostparent(${$args{host}}))) {
1296 my $addmsg = "Record ".($args{update} ? 'updated' : 'added').
1297 " as PTR template instead of $typemap{${$args{rectype}}}; domain not found for ${$args{host}}";
1298 $msg .= "\n$addmsg" if $code eq 'WARN';
1299 $msg = $addmsg if $code eq 'OK';
1300 ${$args{rectype}} = 65282;
1301 return ('WARN', $msg);
1302 }
1303
1304 # Add domain ID to field list and values
1305 ${$args{fields}} .= "domain_id,";
1306 push @{$args{vallist}}, ${$args{domid}};
1307 }
1308
1309 } else {
1310 my ($code,$msg) = $self->_validate_65282(%args);
1311 return ($code, $msg) if $code eq 'FAIL';
1312 # get domain, check against ${$args{name}}
1313 }
1314
1315 return ('OK','OK');
1316} # done A+PTR template record
1317
1318# AAAA+PTR template record
1319# Not sure this can be handled sanely due to the size of IPv6 address space
1320# _validate_65283 above should handle v6 template records fine. It's on export we've got trouble.
1321sub _validate_65284 {
1322 my $self = shift;
1323 my %args = @_;
1324
1325 # do a quick check on the form of the hostname part; this is effectively a
1326 # "*.0.0.f.ip6.arpa" hostname, not an actual expandable IP template pattern
1327 # like with 65283.
1328 return ('FAIL', $errstr)
1329 if !_check_hostname_form(${$args{host}}, ${$args{rectype}}, $args{defrec}, $args{revrec});
1330 return $self->_validate_65283(%args);
1331} # done AAAA+PTR template record
1332
1333# Delegation record
1334# This is essentially a specialized clone of the NS record, primarily useful
1335# for delegating IPv4 sub-/24 reverse blocks
1336sub _validate_65285 {
1337 my $self = shift;
1338 my $dbh = $self->{dbh};
1339
1340 my %args = @_;
1341
1342# Almost, but not quite, identical to NS record validation.
1343
1344 # Check that the target of the record is within the parent.
1345 # Yes, host<->val are mixed up here; can't see a way to avoid it. :(
1346 if ($args{defrec} eq 'n') {
1347 # Check if IP/address/zone/"subzone" is within the parent
1348 if ($args{revrec} eq 'y') {
1349 my $tmpip = NetAddr::IP->new(${$args{val}});
1350 my $pname = $self->revName($args{id});
1351 return ('FAIL',"${$args{val}} not within $pname")
1352 unless $self->_ipparent($args{defrec}, $args{revrec}, $args{val}, $args{id}, \$tmpip);
1353 # Normalize
1354 ${$args{val}} = "$tmpip";
1355 } else {
1356 my $pname = $self->domainName($args{id});
1357 ${$args{host}} =~ s/\.*$/\.$pname/ if ${$args{host}} !~ /$pname$/i;
1358 }
1359 } else {
1360 return ('FAIL',"Delegation records are not permitted in default record sets");
1361 }
1362 return ('OK','OK');
1363}
1364
1365# Subs not specific to a particular record type
1366
1367# Convert $$host and/or $$val to lowercase as appropriate.
1368# Should only be called if $self->{lowercase} is true.
1369# $rectype is also a reference for caller convenience
1370sub _caseclean {
1371 my ($rectype, $host, $val, $defrec, $revrec) = @_;
1372
1373 # Can't case-squash default records, due to DOMAIN, ZONE, and ADMINDOMAIN templating
1374 return if $defrec eq 'y';
1375
1376 if ($typemap{$$rectype} eq 'TXT' || $typemap{$$rectype} eq 'SPF') {
1377 # TXT records should preserve user entry in the string.
1378 # SPF records are a duplicate of TXT with a new record type value (99)
1379 $$host = lc($$host) if $revrec eq 'n'; # only lowercase $$host on live forward TXT; preserve TXT content
1380 $$val = lc($$val) if $revrec eq 'y'; # only lowercase $$val on live reverse TXT; preserve TXT content
1381 } else {
1382 # Non-TXT, live records, are fully case-insensitive
1383 $$host = lc($$host);
1384 $$val = lc($$val);
1385 } # $typemap{$$rectype} else
1386
1387} # _caseclean()
1388
1389
1390##
1391## Record data substitution subs
1392##
1393
1394# Replace ZONE in hostname, or create (most of) the actual proper zone name
1395sub _ZONE {
1396 my $zone = shift;
1397 my $string = shift;
1398 my $fr = shift || 'f'; # flag for forward/reverse order? nb: ignored for IP
1399 my $sep = shift || '-'; # Separator character - unlikely we'll ever need more than . or -
1400
1401 my $prefix;
1402
1403 $string =~ s/,/./ if !$zone->{isv6};
1404 $string =~ s/,/::/ if $zone->{isv6};
1405
1406 # Subbing ZONE in the host. We need to properly ID the netblock range
1407 # The subbed text should have "network IP with trailing zeros stripped" for
1408 # blocks lined up on octet (for v4) or hex-quad (for v6) boundaries
1409 # For blocks that do NOT line up on these boundaries, we take the most
1410 # significant octet or 16-bit chunk of the "broadcast" IP and append it
1411 # after a double-dash
1412 # ie:
1413 # 8.0.0.0/6 -> 8.0.0.0 -> 11.255.255.255; sub should be 8--11
1414 # 10.0.0.0/12 -> 10.0.0.0 -> 10.0.0.0 -> 10.15.255.255; sub should be 10-0--15
1415 # 192.168.4.0/22 -> 192.168.4.0 -> 192.168.7.255; sub should be 192-168-4--7
1416 # 192.168.0.8/29 -> 192.168.0.8 -> 192.168.0.15; sub should be 192-168-0-8--15
1417 # Similar for v6
1418
1419 if (!$zone->{isv6}) { # IPv4
1420
1421 $prefix = $zone->network->addr; # Just In Case someone managed to slip in
1422 # a funky subnet that had host bits set.
1423 my $bc = $zone->broadcast->addr;
1424
1425 if ($zone->masklen > 24) {
1426 $bc =~ s/^\d+\.\d+\.\d+\.//;
1427 } elsif ($zone->masklen > 16) {
1428 $prefix =~ s/\.0$//;
1429 $bc =~ s/^\d+\.\d+\.//;
1430 } elsif ($zone->masklen > 8) {
1431 $bc =~ s/^\d+\.//;
1432 $prefix =~ s/\.0\.0$//;
1433 } else {
1434 $prefix =~ s/\.0\.0\.0$//;
1435 }
1436 if ($zone->masklen % 8) {
1437 $bc =~ s/(\.255)+$//;
1438 $prefix .= "--$bc"; #"--".zone->masklen; # use range or mask length?
1439 }
1440 if ($fr eq 'f') {
1441 $prefix =~ s/\.+/$sep/g;
1442 } else {
1443 $prefix = join($sep, reverse(split(/\./, $prefix)));
1444 }
1445
1446 } else { # IPv6
1447
1448 if ($fr eq 'f') {
1449
1450 $prefix = $zone->network->addr; # Just In Case someone managed to slip in
1451 # a funky subnet that had host bits set.
1452 my $bc = $zone->broadcast->addr;
1453 if (($zone->masklen % 16) != 0) {
1454 # Strip trailing :0 off $prefix, and :ffff off the broadcast IP
1455 for (my $i=0; $i<(7-int($zone->masklen / 16)); $i++) {
1456 $prefix =~ s/:0$//;
1457 $bc =~ s/:ffff$//;
1458 }
1459 # Strip the leading 16-bit chunks off the front of the broadcast IP
1460 $bc =~ s/^([a-f0-9]+:)+//;
1461 # Append the remaining 16-bit chunk to the prefix after "--"
1462 $prefix .= "--$bc";
1463 } else {
1464 # Strip off :0 from the end until we reach the netblock length.
1465 for (my $i=0; $i<(8-$zone->masklen / 16); $i++) {
1466 $prefix =~ s/:0$//;
1467 }
1468 }
1469 # Actually deal with the separator
1470 $prefix =~ s/:/$sep/g;
1471
1472 } else { # $fr eq 'f'
1473
1474 $prefix = $zone->network->full; # Just In Case someone managed to slip in
1475 # a funky subnet that had host bits set.
1476 my $bc = $zone->broadcast->full;
1477 $prefix =~ s/://g; # clean these out since they're not spaced right for this case
1478 $bc =~ s/://g;
1479 # Strip trailing 0 off $prefix, and f off the broadcast IP, to match the mask length
1480 for (my $i=0; $i<(31-int($zone->masklen / 4)); $i++) {
1481 $prefix =~ s/0$//;
1482 $bc =~ s/f$//;
1483 }
1484 # Split and reverse the order of the nibbles in the network/broadcast IPs
1485 # trim another 0 for nibble-aligned blocks first, but only if we really have a block, not an IP
1486 $prefix =~ s/0$// if $zone->masklen % 4 == 0 && $zone->masklen != 128;
1487 my @nbits = reverse split //, $prefix;
1488 my @bbits = reverse split //, $bc;
1489 # Handle the sub-nibble case. Eww. I feel dirty supporting this...
1490 $nbits[0] = "$nbits[0]-$bbits[0]" if ($zone->masklen % 4) != 0;
1491 # Glue it back together
1492 $prefix = join($sep, @nbits);
1493
1494 } # $fr ne 'f'
1495
1496 } # $zone->{isv6}
1497
1498 # Do the substitution, finally
1499 $string =~ s/ZONE/$prefix/;
1500 $string =~ s/--/-/ if $sep ne '-'; # - as separator needs extra help for sub-octet v4 netblocks
1501 return $string;
1502} # done _ZONE()
1503
1504# Not quite a substitution sub, but placed here as it's basically the inverse of above;
1505# given the .arpa zone name, return the CIDR netblock the zone is for.
1506# Supports v4 non-octet/non-classful netblocks as per the method outlined in the Grasshopper Book (2nd Ed p217-218)
1507# Does NOT support non-quad v6 netblocks via the same scheme; it shouldn't ever be necessary.
1508# Takes a nominal .arpa zone name, returns a success code and NetAddr::IP, or a fail code and message
1509sub _zone2cidr {
1510 my $zone = shift;
1511
1512 my $cidr;
1513 my $tmpcidr;
1514 my $warnmsg = '';
1515
1516 if ($zone =~ /\.in-addr\.arpa\.?$/) {
1517 # v4 revzone, formal zone name type
1518 my $tmpzone = $zone;
1519 return ('FAIL', "Non-numerics in apparent IPv4 reverse zone name [$tmpzone]")
1520 if $tmpzone !~ m{^(?:\d+[/-])?[\d\.]+\.in-addr\.arpa\.?$};
1521 $tmpzone =~ s/\.in-addr\.arpa\.?//;
1522
1523 # Snag the octet pieces
1524 my @octs = split /\./, $tmpzone;
1525
1526 # Map result of a range manipulation to a mask length change. Cheaper than finding the 2-root of $octets[0]+1.
1527 # Note we will not support /31 blocks, mostly due to issues telling "24-31" -> .24/29 apart from
1528 # "24-31" -> .24/31", with a litte bit of "/31 is icky".
1529 my %maskmap = ( 3 => 2, 7 => 3, 15 => 4, 31 => 5, 63 => 6, 127 => 7,
1530 30 => 2, 29 => 3, 28 => 4, 27 => 5, 26 => 6, 25 => 7
1531 );
1532
1533 # Handle "range" blocks, eg, 80-83.168.192.in-addr.arpa (192.168.80.0/22)
1534 # Need to take the size of the range to offset the basic octet-based mask length,
1535 # and make sure the first number in the range gets used as the network address for the block
1536 # Alternate form: The second number is actually the real netmask, not the end of the range.
1537 my $masklen = 0;
1538 if ($octs[0] =~ m{^((\d+)[/-](\d+))$}) { # take the range...
1539 if (24 < $3 && $3 < 31) {
1540 # we have a real netmask
1541 $masklen = -$maskmap{$3};
1542 } else {
1543 # we have a range. NB: only real CIDR ranges are supported
1544 $masklen -= $maskmap{-(eval $1)}; # find the mask base...
1545 }
1546 $octs[0] = $2; # set the base octet of the range...
1547 }
1548 @octs = reverse @octs; # We can reverse the octet pieces now that we've extracted and munged any ranges
1549
1550# arguably we should only allow sub-octet range/mask in-addr.arpa
1551# specifications in the least significant octet, but the code is
1552# simpler if we deal with sub-octet delegations at any level.
1553
1554 # Now we find the "true" mask with the aid of the "base" calculated above
1555 if ($#octs == 0) {
1556 $masklen += 8;
1557 $tmpcidr = "$octs[0].0.0.0/$masklen"; # really hope we don't see one of these very often.
1558 } elsif ($#octs == 1) {
1559 $masklen += 16;
1560 $tmpcidr = "$octs[0].$octs[1].0.0/$masklen";
1561 } elsif ($#octs == 2) {
1562 $masklen += 24;
1563 $tmpcidr = "$octs[0].$octs[1].$octs[2].0/$masklen";
1564 } else {
1565 $masklen += 32;
1566 $tmpcidr = "$octs[0].$octs[1].$octs[2].$octs[3]/$masklen";
1567 }
1568
1569 } elsif ($zone =~ /\.ip6\.arpa\.?$/) {
1570 # v6 revzone, formal zone name type
1571 my $tmpzone = $zone;
1572##fixme: if-n-when we decide we can support sub-nibble v6 zone names, we'll need to change this segment
1573 return ('FAIL', "Non-hexadecimals in apparent IPv6 reverse zone name [$tmpzone]")
1574 if $tmpzone !~ /^[a-fA-F\d\.]+\.ip6\.arpa\.?$/;
1575 $tmpzone =~ s/\.ip6\.arpa\.?//;
1576 my @quads = reverse(split(/\./, $tmpzone));
1577 $warnmsg .= "Apparent sub-/64 IPv6 reverse zone\n" if $#quads > 15;
1578 my $nc;
1579 foreach (@quads) {
1580 $tmpcidr .= $_;
1581 $tmpcidr .= ":" if ++$nc % 4 == 0 && $nc < $#quads;
1582 }
1583 my $nq = 1 if $nc % 4 != 0;
1584 my $mask = $nc * 4; # need to do this here because we probably increment it below
1585 while ($nc++ % 4 != 0) {
1586 $tmpcidr .= "0";
1587 }
1588 # polish it off with trailing ::/mask if this is a CIDR block instead of an IP
1589 $tmpcidr .= "::/$mask" if $mask != 128;
1590 }
1591
1592 # Just to be sure, use NetAddr::IP to validate. Saves a lot of nasty regex watching for valid octet values.
1593 return ('FAIL', "Invalid zone $zone (apparent netblock $tmpcidr)")
1594 unless $cidr = NetAddr::IP->new($tmpcidr);
1595
1596 if ($warnmsg) {
1597 $errstr = $warnmsg;
1598 return ('WARN', $cidr);
1599 }
1600 return ('OK', $cidr);
1601##fixme: use wantarray() to decide what to return?
1602} # done _zone2cidr()
1603
1604# Record template %-parameter expansion, IPv4. Note that IPv6 doesn't
1605# really have a sane way to handle this type of expansion at the moment
1606# due to the size of the address space.
1607# Takes a reference to a template string to be expanded, an IP to use in the replacement,
1608# an optional netblock for the %ngb (net, gw, bcast) expansion, and an optional index
1609# number for %c "n'th usable IP in block/range" patterns.
1610# Alters the template string referred.
1611sub _template4_expand {
1612 # ugh pthui
1613 my $self;
1614 $self = shift if ref($_[0]) eq 'DNSDB';
1615
1616 my $tmpl = shift;
1617 my $ip = shift;
1618 my $subnet = shift; # for %ngb and %c
1619 my $ipindex = shift; # for %c
1620
1621 # blank $tmpl on config template_skip_0 or template_skip_255, unless we have a %ngb
1622 if ($$tmpl !~ /\%-?n-?g-?b\%/) {
1623 if ( ($ip =~ /\.0$/ && $self->{template_skip_0}) ||
1624 ($ip =~ /\.255$/ && $self->{template_skip_255}) ) {
1625 $$tmpl = '';
1626 return;
1627 }
1628 }
1629
1630 my @ipparts = split /\./, $ip;
1631 my @iphex;
1632 my @ippad;
1633 for (@ipparts) {
1634 push @iphex, sprintf("%x", $_);
1635 push @ippad, sprintf("%0.3u", $_);
1636 }
1637
1638 # Two or three consecutive separator characters (_ or -) should be rare - users that use them
1639 # anywhere other than punycoded internationalized domains get to keep the pieces when it breaks.
1640 # We clean up the ones that we may inadvertently generate after replacing %c and %ngb%
1641 my ($thrsep) = ($$tmpl =~ /[_-]{3}/);
1642 my ($twosep) = ($$tmpl =~ /[_-]{2}/);
1643
1644 # Take the simplest path to pattern substitution; replace only exactly the %c or %ngb%
1645 # patterns as-is. Then check after to see if we've caused doubled separator characters (- or _)
1646 # and eliminate them, but only if the original template didn't have them already. Also
1647 # unconditionally drop separator characters immediately before a dot; these do not always
1648 # strictly make the label invalid but almost always, and any exceptions should never show up
1649 # in a template record that expands to "many" real records anyway.
1650
1651 # %ngb and %c require a netblock
1652 if ($subnet) {
1653 # extract the fragments
1654 my ($ngb,$n,$g,$b) = ($$tmpl =~ /(\%(-?n)(-?g)(-?b)\%)/);
1655 my ($c) = ($$tmpl =~ /(\%-?c)/); my $nld = ''; my $cld = '';
1656 $c = '' if !$c;
1657 my $skipgw = ($c =~ /\%-c/ ? 0 : 1);
1658 my $ipkill = 0;
1659
1660##fixme: still have one edge case not handled well:
1661# %c%n-gb%
1662# do we drop the record as per -g, or publish the record with an index of 1 as per %c?
1663# arguably this is a "that's a STUPID question!" case
1664
1665 if ($c) {
1666 # "n'th usable IP in the block" pattern. We need the caller to provide an index
1667 # number otherwise we see exponential time growth because we have to iterate over
1668 # the whole block to map the IP back to an index. :/
1669 # NetAddr::IP does not have a method for asking "what index is IP <foo> at?"
1670
1671 # no index, or index == 0, (AKA network address), or IP == broadcast, blank the index fragment
1672 if (!$ipindex || ($$subnet->broadcast->addr eq $ip)) {
1673 $$tmpl =~ s/$c//;
1674 } else {
1675 # if we have %c, AKA "skip the gateway", and we're on the nominal gateway IP, blank the index fragment
1676 if ($skipgw && $$subnet->first->addr eq $ip) {
1677 $$tmpl =~ s/$c//;
1678 }
1679 # else replace the index fragment with the passed index minus $skipgw, so that we can start the
1680 # resulting index at 1 on net+2
1681 else {
1682 $$tmpl =~ s/$c/($ipindex-$skipgw)/e;
1683 }
1684 }
1685 } # if ($c)
1686
1687 if ($ngb) {
1688 # individually check the network, standard gateway (net+1) IP, and broadcast IP
1689 # blank $$tmpl if n, g, or b was prefixed with - (this allows "hiding" net/gw/bcast entries)
1690
1691 if ($$subnet->network->addr eq $ip) {
1692 if ($n eq '-n') {
1693 $$tmpl = '';
1694 } else {
1695 $$tmpl =~ s/$ngb/net/;
1696 $ipkill = 1;
1697 }
1698 } elsif ($$subnet->first->addr eq $ip) {
1699 if ($g eq '-g') {
1700 $$tmpl = '';
1701 } else {
1702 $$tmpl =~ s/$ngb/gw/;
1703 $ipkill = 1;
1704 }
1705 } elsif ($$subnet->broadcast->addr eq $ip) {
1706 if ($b eq '-b') {
1707 $$tmpl = '';
1708 } else {
1709 $$tmpl =~ s/$ngb/bcast/;
1710 $ipkill = 1;
1711 }
1712 } else {
1713 $$tmpl =~ s/$ngb//;
1714 }
1715 }
1716
1717 # We don't (usually) want to expand the IP-related patterns on the -net, -gw, or -bcast IPs.
1718 # Arguably this is another place for another config knob, or possibly further extension of
1719 # the template pattern to control it on a per-subnet basis.
1720 if ($ipkill) {
1721 # kill common IP patterns
1722 $$tmpl =~ s/\%[_.-]?[irdh]//;
1723 # kill IP octet patterns
1724 $$tmpl =~ s/\%[1234][dh0](?:[_.-]\%[1234][dh0]){0,3}//;
1725 }
1726
1727 # and now clean up to make sure we leave a valid DNS label... mostly. Should arguably
1728 # split on /\./ and process each label separately.
1729 $$tmpl =~ s/([_-]){3}/$1/ if !$thrsep;
1730 $$tmpl =~ s/([_-]){2}/$1/ if !$twosep;
1731 $$tmpl =~ s/[_-]\././;
1732
1733 } # if ($subnet)
1734
1735 # IP substitutions in template records:
1736 #major patterns:
1737 #dashed IP, forward and reverse
1738 #underscoreed IP, forward and reverse
1739 #dotted IP, forward and reverse (even if forward is... dumb)
1740 # -> %r for reverse, %i for forward, leading -, _, or . to indicate separator, defaults to -
1741 # %r or %-r => %4d-%3d-%2d-%1d
1742 # %_r => %4d_%3d_%2d_%1d
1743 # %.r => %4d.%3d.%2d.%1d
1744 # %i or %-i => %1d-%2d-%3d-%4d
1745 # %_i => %1d_%2d_%3d_%4d
1746 # %.i => %1d.%2d.%3d.%4d
1747 $$tmpl =~ s/\%r/\%4d-\%3d-\%2d-\%1d/g;
1748 $$tmpl =~ s/\%([-._])r/\%4d$1\%3d$1\%2d$1\%1d/g;
1749 $$tmpl =~ s/\%i/\%1d-\%2d-\%3d-\%4d/g;
1750 $$tmpl =~ s/\%([-._])i/\%1d$1\%2d$1\%3d$1\%4d/g;
1751
1752 #hex-coded IP
1753 # %h
1754 $$tmpl =~ s/\%h/$iphex[0]$iphex[1]$iphex[2]$iphex[3]/g;
1755
1756 #IP as decimal-coded 32-bit value
1757 # %d
1758 my $iptmp = $ipparts[0]*256*256*256 + $ipparts[1]*256*256 + $ipparts[2]*256 + $ipparts[3];
1759 $$tmpl =~ s/\%d/$iptmp/g;
1760
1761 #minor patterns (per-octet)
1762 # %[1234][dh0]
1763 #octet
1764 #hex-coded octet
1765 #0-padded octet
1766 $$tmpl =~ s/\%([1234])d/$ipparts[$1-1]/g;
1767 $$tmpl =~ s/\%([1234])h/$iphex[$1-1]/g;
1768 $$tmpl =~ s/\%([1234])0/$ippad[$1-1]/g;
1769
1770} # _template4_expand()
1771
1772# Broad syntactic check on the hostname. Checks for valid characters, correctly-expandable template patterns.
1773# Takes the hostname, type, and live/default and forward/reverse flags
1774# Returns true/false, sets errstr on failures
1775sub _check_hostname_form {
1776 my ($hname,$rectype,$defrec,$revrec) = @_;
1777
1778 if ($hname =~ /\%/ && ($rectype == 65282 || $rectype == 65283) ) {
1779 my $tmphost = $hname;
1780 # we don't actually need to test with the real IP passed; that saves a bit of fiddling.
1781 DNSDB::_template4_expand(\$tmphost, '10.10.10.10');
1782 if ($tmphost =~ /\%/ || lc($tmphost) !~ /^(?:\*\.)?(?:[0-9a-z_.-]+)$/) {
1783 $errstr = "Invalid template $hname";
1784 return;
1785 }
1786 } elsif ($rectype == $reverse_typemap{CNAME} && $revrec eq 'y') {
1787 # Allow / in reverse CNAME hostnames for sub-/24 delegation
1788 if (lc($hname) !~ m|^[0-9a-z_./-]+$|) {
1789 # error message is deliberately restrictive; special cases are SPECIAL and not for general use
1790 $errstr = "Hostnames may not contain anything other than (0-9 a-z . _)";
1791 return;
1792 }
1793 } elsif ($revrec eq 'y') {
1794 # Reverse zones don't support @ in hostnames
1795 if (lc($hname) !~ /^(?:\*\.)?[0-9a-z_.-]+$/) {
1796 # error message is deliberately restrictive; special cases are SPECIAL and not for general use
1797 $errstr = "Hostnames may not contain anything other than (0-9 a-z . _)";
1798 return;
1799 }
1800 } else {
1801 if (lc($hname) !~ /^(?:\*\.)?(?:[0-9a-z_.-]+|@)$/) {
1802 # Don't mention @, because it would be far too wordy to explain the nuance of @
1803 $errstr = "Hostnames may not contain anything other than (0-9 a-z . _)";
1804 return;
1805 }
1806 }
1807 return 1;
1808} # _check_hostname_form()
1809
1810
1811##
1812## Initialization and cleanup subs
1813##
1814
1815## DNSDB::__cfgload()
1816# Private sub to parse a config file and load it into %config
1817# Takes a filename and a hashref to put the parsed entries in
1818sub __cfgload {
1819 $errstr = '';
1820 my $cfgfile = shift;
1821 my $cfg = shift;
1822
1823 if (open CFG, "<$cfgfile") {
1824 while (<CFG>) {
1825 chomp;
1826 s/^\s*//;
1827 next if /^#/;
1828 next if /^$/;
1829# hmm. more complex bits in this file might require [heading] headers, maybe?
1830# $mode = $1 if /^\[(a-z)+]/;
1831 # DB connect info
1832 $cfg->{dbname} = $1 if /^dbname\s*=\s*([a-z0-9_.-]+)/i;
1833 $cfg->{dbuser} = $1 if /^dbuser\s*=\s*([a-z0-9_.-]+)/i;
1834 $cfg->{dbpass} = $1 if /^dbpass\s*=\s*([a-z0-9_.-]+)/i;
1835 $cfg->{dbhost} = $1 if /^dbhost\s*=\s*([a-z0-9_.-]+)/i;
1836 # Mail settings
1837 $cfg->{mailhost} = $1 if /^mailhost\s*=\s*([a-z0-9_.-]+)/i;
1838 $cfg->{mailnotify} = $1 if /^mailnotify\s*=\s*([a-z0-9_.\@-]+)/i;
1839 $cfg->{mailsender} = $1 if /^mailsender\s*=\s*([a-z0-9_.\@-]+)/i;
1840 $cfg->{mailname} = $1 if /^mailname\s*=\s*([a-z0-9\s_.-]+)/i;
1841 $cfg->{orgname} = $1 if /^orgname\s*=\s*([a-z0-9\s_.,'-]+)/i;
1842 $cfg->{domain} = $1 if /^domain\s*=\s*([a-z0-9_.-]+)/i;
1843 # session - note this is fed directly to CGI::Session
1844 $cfg->{timeout} = $1 if /^[tT][iI][mM][eE][oO][uU][tT]\s*=\s*(\d+[smhdwMy]?)/;
1845 $cfg->{sessiondir} = $1 if m{^sessiondir\s*=\s*([a-z0-9/_.-]+)}i;
1846 # misc
1847 $cfg->{log_failures} = $1 if /^log_failures\s*=\s*([a-z01]+)/i;
1848 $cfg->{perpage} = $1 if /^perpage\s*=\s*(\d+)/i;
1849 $cfg->{exportcache} = $1 if m{^exportcache\s*=\s*([a-z0-9/_.-]+)}i;
1850 $cfg->{usecache} = $1 if m{^usecache\s*=\s*([a-z01]+)}i;
1851 $cfg->{force_refresh} = $1 if /^force_refresh\s*=\s*([a-z01]+)/i;
1852 $cfg->{lowercase} = $1 if /^lowercase\s*=\s*([a-z01]+)/i;
1853 $cfg->{showrev_arpa} = $1 if /^showrev_arpa\s*=\s*([a-z]+)/i;
1854 $cfg->{template_skip_0} = $1 if /^template_skip_0\s*=\s*([a-z01]+)/i;
1855 $cfg->{template_skip_255} = $1 if /^template_skip_255\s*=\s*([a-z01]+)/i;
1856 $cfg->{autotxt} = $1 if /^force_refresh\s*=\s*([a-z01]+)/i;
1857# not supported in dns.cgi yet
1858# $cfg->{templatedir} = $1 if m{^templatedir\s*=\s*([a-z0-9/_.-]+)}i;
1859# $cfg->{templateoverride} = $1 if m{^templateoverride\s*=\s*([a-z0-9/_.-]+)}i;
1860 # RPC options
1861 $cfg->{rpcmode} = $1 if /^rpc_mode\s*=\s*(socket|HTTP|XMLRPC)\s*$/i;
1862 $cfg->{maxfcgi} = $1 if /^max_fcgi_requests\s*=\s*(\d+)\s*$/i;
1863 if (my ($tmp) = /^rpc_iplist\s*=\s*(.+)/i) {
1864 my @ips = split /[,\s]+/, $tmp;
1865 my $rpcsys = shift @ips;
1866 push @{$cfg->{rpcacl}{$rpcsys}}, @ips;
1867 }
1868 }
1869 close CFG;
1870 } else {
1871 $errstr = "Couldn't load configuration file $cfgfile: $!";
1872 return;
1873 }
1874 return 1;
1875} # end __cfgload()
1876
1877
1878## DNSDB::connectDB()
1879# Creates connection to DNS database.
1880# Requires the database name, username, and password.
1881# Returns a handle to the db or undef on failure.
1882# Set up for a PostgreSQL db; could be any transactional DBMS with the
1883# right changes.
1884# Called by new(); not intended to be called publicly.
1885sub connectDB {
1886 $errstr = '';
1887 my $dbname = shift;
1888 my $user = shift;
1889 my $pass = shift;
1890 my $dbh;
1891 my $DSN = "DBI:Pg:dbname=$dbname";
1892
1893 my $host = shift;
1894 $DSN .= ";host=$host" if $host;
1895
1896# Note that we want to autocommit by default, and we will turn it off locally as necessary.
1897# We may not want to print gobbledygook errors; YMMV. Have to ponder that further.
1898 $dbh = DBI->connect($DSN, $user, $pass, {
1899 AutoCommit => 1,
1900 PrintError => 0
1901 });
1902 if (!$dbh) {
1903 $errstr = $DBI::errstr;
1904 return;
1905 }
1906#) if(!$dbh);
1907
1908 local $dbh->{RaiseError} = 1;
1909
1910 eval {
1911##fixme: initialize the DB if we can't find the table (since, by definition, there's
1912# nothing there if we can't select from it...)
1913 my $tblsth = $dbh->prepare("SELECT count(*) FROM pg_catalog.pg_class WHERE relkind='r' AND relname=?");
1914 my ($tblcount) = $dbh->selectrow_array($tblsth, undef, ('misc'));
1915# return (undef,$DBI::errstr) if $dbh->err;
1916
1917#if ($tblcount == 0) {
1918# # create tables one at a time, checking for each.
1919# return (undef, "check table misc missing");
1920#}
1921
1922# Return here if we can't select.
1923# This should retrieve the dbversion key.
1924 my $sth = $dbh->prepare("SELECT key,value FROM misc WHERE misc_id=1");
1925 $sth->execute();
1926# return (undef,$DBI::errstr) if ($sth->err);
1927
1928##fixme: do stuff to the DB on version mismatch
1929# x.y series should upgrade on $DNSDB::VERSION > misc(key=>version)
1930# DB should be downward-compatible; column defaults should give sane (if possibly
1931# useless-and-needs-help) values in columns an older software stack doesn't know about.
1932
1933# See if the select returned anything (or null data). This should
1934# succeed if the select executed, but...
1935 $sth->fetchrow();
1936# return (undef,$DBI::errstr) if ($sth->err);
1937
1938 $sth->finish;
1939
1940 }; # wrapped DB checks
1941 if ($@) {
1942 $errstr = $@;
1943 return;
1944 }
1945
1946# If we get here, we should be OK.
1947 return $dbh;
1948} # end connectDB
1949
1950
1951## DNSDB::finish()
1952# Cleans up after database handles and so on.
1953# Requires a database handle
1954sub finish {
1955 my $self = shift;
1956 $self->{dbh}->disconnect;
1957} # end finish
1958
1959
1960## DNSDB::initGlobals()
1961# Initialize global variables
1962# NB: this does NOT include web-specific session variables!
1963sub initGlobals {
1964 my $self = shift;
1965 my $dbh = $self->{dbh};
1966
1967# load record types from database
1968 my $sth = $dbh->prepare("SELECT val,name,stdflag FROM rectypes");
1969 $sth->execute;
1970 while (my ($recval,$recname,$stdflag) = $sth->fetchrow_array()) {
1971 $typemap{$recval} = $recname;
1972 $reverse_typemap{$recname} = $recval;
1973 # now we fill the record validation function hash
1974 if ($stdflag < 5) {
1975 my $fn = "_validate_$recval";
1976 $validators{$recval} = \&$fn;
1977 } else {
1978 my $fn = "sub { return ('FAIL','Type $recval ($recname) not supported'); }";
1979 $validators{$recval} = eval $fn;
1980 }
1981 }
1982} # end initGlobals
1983
1984
1985## DNSDB::initRPC()
1986# Takes a remote username and remote fullname.
1987# Sets up the RPC logging-pseudouser if needed.
1988# Sets the %userdata hash for logging.
1989# Returns undef on failure
1990sub initRPC {
1991 my $self = shift;
1992 my $dbh = $self->{dbh};
1993 my %args = @_;
1994
1995 return if !$args{username};
1996 return if !$args{fullname};
1997
1998 $args{username} = "$args{username}/$args{rpcsys}";
1999
2000 my $tmpuser = $dbh->selectrow_hashref("SELECT username,user_id AS userid,group_id,firstname,lastname,status".
2001 " FROM users WHERE username=?", undef, ($args{username}) );
2002 if (!$tmpuser) {
2003 $dbh->do("INSERT INTO users (username,password,firstname,type) VALUES (?,'RPC',?,'R')", undef,
2004 ($args{username}, $args{fullname}) );
2005 $tmpuser = $dbh->selectrow_hashref("SELECT username,user_id AS userid,group_id,firstname,lastname,status".
2006 " FROM users WHERE username=?", undef, ($args{username}) );
2007 }
2008 $tmpuser->{lastname} = '' if !$tmpuser->{lastname};
2009 $self->{loguserid} = $tmpuser->{userid};
2010 $self->{logusername} = $tmpuser->{username};
2011 $self->{logfullname} = "$tmpuser->{firstname} $tmpuser->{lastname} ($args{rpcsys})";
2012 return 1 if $tmpuser;
2013} # end initRPC()
2014
2015
2016## DNSDB::login()
2017# Takes a database handle, username and password
2018# Returns a userdata hash (UID, GID, username, fullname parts) if username exists,
2019# password matches the one on file, and account is not disabled
2020# Returns undef otherwise
2021sub login {
2022 my $self = shift;
2023 my $dbh = $self->{dbh};
2024 my $user = shift;
2025 my $pass = shift;
2026
2027 my $userinfo = $dbh->selectrow_hashref("SELECT user_id,group_id,password,firstname,lastname,status".
2028 " FROM users WHERE username=?",
2029 undef, ($user) );
2030 return if !$userinfo;
2031 return if !$userinfo->{status};
2032
2033 if ($userinfo->{password} =~ m|^\$1\$([A-Za-z0-9/.]+)\$|) {
2034 # native passwords (crypt-md5)
2035 return if $userinfo->{password} ne unix_md5_crypt($pass,$1);
2036 } elsif ($userinfo->{password} =~ /^[0-9a-f]{32}$/) {
2037 # VegaDNS import (hex-coded MD5)
2038 return if $userinfo->{password} ne md5_hex($pass);
2039 } else {
2040 # plaintext (convenient now and then)
2041 return if $userinfo->{password} ne $pass;
2042 }
2043
2044 return $userinfo;
2045} # end login()
2046
2047
2048## DNSDB::initActionLog()
2049# Set up action logging. Takes a database handle and user ID
2050# Sets some internal globals and Does The Right Thing to set up a logging channel.
2051# This sets up _log() to spew out log entries to the defined channel without worrying
2052# about having to open a file or a syslog channel
2053##fixme Need to call _initActionLog_blah() for various logging channels, configured
2054# via dnsdb.conf, in $self->{log_channel} or something
2055# See https://secure.deepnet.cx/trac/dnsadmin/ticket/21
2056sub initActionLog {
2057 my $self = shift;
2058 my $dbh = $self->{dbh};
2059 my $uid = shift;
2060
2061 return if !$uid;
2062
2063 # snag user info for logging. there's got to be a way to not have to pass this back
2064 # and forth from a caller, but web usage means no persistence we can rely on from
2065 # the server side.
2066 my ($username,$fullname) = $dbh->selectrow_array("SELECT username, firstname || ' ' || lastname".
2067 " FROM users WHERE user_id=?", undef, ($uid));
2068##fixme: errors are unpossible!
2069
2070 $self->{logusername} = $username;
2071 $self->{loguserid} = $uid;
2072 $self->{logfullname} = $fullname;
2073
2074 # convert to real check once we have other logging channels
2075 # if ($self->{log_channel} eq 'sql') {
2076 # Open Log, Sez Me!
2077 # }
2078
2079} # end initActionLog
2080
2081
2082## DNSDB::getPermissions()
2083# Get permissions from DB
2084# Requires DB handle, group or user flag, ID, and hashref.
2085sub getPermissions {
2086 my $self = shift;
2087 my $dbh = $self->{dbh};
2088
2089 my $type = shift;
2090 my $id = shift;
2091 my $hash = shift;
2092
2093 my $sql = qq(
2094 SELECT
2095 p.admin,p.self_edit,
2096 p.group_create,p.group_edit,p.group_delete,
2097 p.user_create,p.user_edit,p.user_delete,
2098 p.domain_create,p.domain_edit,p.domain_delete,
2099 p.record_create,p.record_edit,p.record_delete,p.record_locchg,
2100 p.location_create,p.location_edit,p.location_delete,p.location_view
2101 FROM permissions p
2102 );
2103 if ($type eq 'group') {
2104 $sql .= qq(
2105 JOIN groups g ON g.permission_id=p.permission_id
2106 WHERE g.group_id=?
2107 );
2108 } else {
2109 $sql .= qq(
2110 JOIN users u ON u.permission_id=p.permission_id
2111 WHERE u.user_id=?
2112 );
2113 }
2114
2115 my $sth = $dbh->prepare($sql);
2116
2117##fixme? we don't trap other plain SELECT errors
2118 $sth->execute($id);
2119
2120# my $permref = $sth->fetchrow_hashref;
2121# return $permref;
2122# $hash = $permref;
2123# Eww. Need to learn how to forcibly drop a hashref onto an existing hash.
2124 ($hash->{admin},$hash->{self_edit},
2125 $hash->{group_create},$hash->{group_edit},$hash->{group_delete},
2126 $hash->{user_create},$hash->{user_edit},$hash->{user_delete},
2127 $hash->{domain_create},$hash->{domain_edit},$hash->{domain_delete},
2128 $hash->{record_create},$hash->{record_edit},$hash->{record_delete},$hash->{record_locchg},
2129 $hash->{location_create},$hash->{location_edit},$hash->{location_delete},$hash->{location_view}
2130 ) = $sth->fetchrow_array;
2131
2132} # end getPermissions()
2133
2134
2135## DNSDB::changePermissions()
2136# Update an ACL entry
2137# Takes a db handle, type, owner-id, and hashref for the changed permissions.
2138sub changePermissions {
2139 my $self = shift;
2140 my $dbh = $self->{dbh};
2141 my $type = shift;
2142 my $id = shift;
2143 my $newperms = shift;
2144 my $inherit = shift || 0;
2145
2146 my $resultmsg = '';
2147
2148 # see if we're switching from inherited to custom. for bonus points,
2149 # snag the permid and parent permid anyway, since we'll need the permid
2150 # to set/alter custom perms, and both if we're switching from custom to
2151 # inherited.
2152 my $sth = $dbh->prepare("SELECT (u.permission_id=g.permission_id) AS was_inherited,u.permission_id,g.permission_id,".
2153 ($type eq 'user' ? 'u.group_id,u.username' : 'u.parent_group_id,u.group_name').
2154 " FROM ".($type eq 'user' ? 'users' : 'groups')." u ".
2155 " JOIN groups g ON u.".($type eq 'user' ? '' : 'parent_')."group_id=g.group_id ".
2156 " WHERE u.".($type eq 'user' ? 'user' : 'group')."_id=?");
2157 $sth->execute($id);
2158
2159 my ($wasinherited,$permid,$parpermid,$parid,$name) = $sth->fetchrow_array;
2160
2161# hack phtoui
2162# group id 1 is "special" in that it's it's own parent (err... possibly.)
2163# may make its parent id 0 which doesn't exist, and as a bonus is Perl-false.
2164 $wasinherited = 0 if ($type eq 'group' && $id == 1);
2165
2166 local $dbh->{AutoCommit} = 0;
2167 local $dbh->{RaiseError} = 1;
2168
2169 # Wrap all the SQL in a transaction
2170 eval {
2171 if ($inherit) {
2172
2173 $dbh->do("UPDATE ".($type eq 'user' ? 'users' : 'groups')." SET inherit_perm='t',permission_id=? ".
2174 "WHERE ".($type eq 'user' ? 'user' : 'group')."_id=?", undef, ($parpermid, $id) );
2175 $dbh->do("DELETE FROM permissions WHERE permission_id=?", undef, ($permid) );
2176
2177 } else {
2178
2179 if ($wasinherited) { # munge new permission entry in if we're switching from inherited perms
2180##fixme: need to add semirecursive bit to properly munge inherited permission ID on subgroups and users
2181# ... if'n'when we have groups with fully inherited permissions.
2182 # SQL is coo
2183 $dbh->do("INSERT INTO permissions ($permlist,".($type eq 'user' ? 'user' : 'group')."_id) ".
2184 "SELECT $permlist,? FROM permissions WHERE permission_id=?", undef, ($id,$permid) );
2185 ($permid) = $dbh->selectrow_array("SELECT permission_id FROM permissions ".
2186 "WHERE ".($type eq 'user' ? 'user' : 'group')."_id=?", undef, ($id) );
2187 $dbh->do("UPDATE ".($type eq 'user' ? 'users' : 'groups')." SET inherit_perm='f',permission_id=? ".
2188 "WHERE ".($type eq 'user' ? 'user' : 'group')."_id=?", undef, ($permid, $id) );
2189 }
2190
2191 # and now set the permissions we were passed
2192 foreach (@permtypes) {
2193 if (defined ($newperms->{$_})) {
2194 $dbh->do("UPDATE permissions SET $_=? WHERE permission_id=?", undef, ($newperms->{$_},$permid) );
2195 }
2196 }
2197
2198 } # (inherited->)? custom
2199
2200 if ($type eq 'user') {
2201 $resultmsg = "Updated permissions for user $name";
2202 } else {
2203 $resultmsg = "Updated default permissions for group $name";
2204 }
2205 $self->_log(group_id => ($type eq 'user' ? $parid : $id), entry => $resultmsg);
2206 $dbh->commit;
2207 }; # end eval
2208 if ($@) {
2209 my $msg = $@;
2210 eval { $dbh->rollback; };
2211 return ('FAIL',"Error changing permissions: $msg");
2212 }
2213
2214 return ('OK',$resultmsg);
2215} # end changePermissions()
2216
2217
2218## DNSDB::comparePermissions()
2219# Compare two permission hashes
2220# Returns '>', '<', '=', '!'
2221sub comparePermissions {
2222 my $self = shift;
2223 my $p1 = shift;
2224 my $p2 = shift;
2225
2226 my $retval = '='; # assume equality until proven otherwise
2227
2228 no warnings "uninitialized";
2229
2230 foreach (@permtypes) {
2231 next if $p1->{$_} == $p2->{$_}; # equal is good
2232 if ($p1->{$_} && !$p2->{$_}) {
2233 if ($retval eq '<') { # if we've already found an unequal pair where
2234 $retval = '!'; # $p2 has more access, and we now find a pair
2235 last; # where $p1 has more access, the overall access
2236 } # is neither greater or lesser, it's unequal.
2237 $retval = '>';
2238 }
2239 if (!$p1->{$_} && $p2->{$_}) {
2240 if ($retval eq '>') { # if we've already found an unequal pair where
2241 $retval = '!'; # $p1 has more access, and we now find a pair
2242 last; # where $p2 has more access, the overall access
2243 } # is neither greater or lesser, it's unequal.
2244 $retval = '<';
2245 }
2246 }
2247 return $retval;
2248} # end comparePermissions()
2249
2250
2251## DNSDB::changeGroup()
2252# Change group ID of an entity
2253# Takes a database handle, entity type, entity ID, and new group ID
2254sub changeGroup {
2255 my $self = shift;
2256 my $dbh = $self->{dbh};
2257 my $type = shift;
2258 my $id = shift;
2259 my $newgrp = shift;
2260
2261##fixme: fail on not enough args
2262 #return ('FAIL', "Missing
2263
2264 return ('FAIL', "Can't change the group of a $type")
2265 unless grep /^$type$/, ('domain','revzone','user','group'); # could be extended for defrecs?
2266
2267 # Collect some names for logging and messages
2268 my $entname;
2269 if ($type eq 'domain') {
2270 $entname = $self->domainName($id);
2271 } elsif ($type eq 'revzone') {
2272 $entname = $self->revName($id);
2273 } elsif ($type eq 'user') {
2274 $entname = $self->userFullName($id, '%u');
2275 } elsif ($type eq 'group') {
2276 $entname = $self->groupName($id);
2277 }
2278
2279 my ($oldgid) = $dbh->selectrow_array("SELECT group_id FROM $par_tbl{$type} WHERE $id_col{$type}=?",
2280 undef, ($id));
2281 my $oldgname = $self->groupName($oldgid);
2282 my $newgname = $self->groupName($newgrp);
2283
2284 return ('FAIL', "Can't move things into a group that doesn't exist") if !$newgname;
2285
2286 return ('WARN', "Nothing to do, new group is the same as the old group") if $oldgid == $newgrp;
2287
2288 # Allow transactions, and raise an exception on errors so we can catch it later.
2289 # Use local to make sure these get "reset" properly on exiting this block
2290 local $dbh->{AutoCommit} = 0;
2291 local $dbh->{RaiseError} = 1;
2292
2293 eval {
2294 $dbh->do("UPDATE $par_tbl{$type} SET group_id=? WHERE $id_col{$type}=?", undef, ($newgrp, $id));
2295 # Log the change in both the old and new groups
2296 $self->_log(group_id => $oldgid, entry => "Moved $type $entname from $oldgname to $newgname");
2297 $self->_log(group_id => $newgrp, entry => "Moved $type $entname from $oldgname to $newgname");
2298 $dbh->commit;
2299 };
2300 if ($@) {
2301 my $msg = $@;
2302 eval { $dbh->rollback; };
2303 if ($self->{log_failures}) {
2304 $self->_log(group_id => $oldgid, entry => "Error moving $type $entname to $newgname: $msg");
2305 $dbh->commit; # since we enabled transactions earlier
2306 }
2307 return ('FAIL',"Error moving $type $entname to $newgname: $msg");
2308 }
2309
2310 return ('OK',"Moved $type $entname from $oldgname to $newgname");
2311} # end changeGroup()
2312
2313
2314##
2315## Processing subs
2316##
2317
2318## DNSDB::addDomain()
2319# Add a domain
2320# Takes a database handle, domain name, numeric group, boolean(ish) state (active/inactive),
2321# and a default location indicator
2322# Returns a status code and message
2323sub addDomain {
2324 $errstr = '';
2325 my $self = shift;
2326 my $dbh = $self->{dbh};
2327 my $domain = shift;
2328 return ('FAIL',"Domain must not be blank\n") if !$domain;
2329 my $group = shift;
2330 return ('FAIL',"Group must be specified\n") if !defined($group);
2331 my $state = shift;
2332 return ('FAIL',"Domain status must be specified\n") if !defined($state);
2333 my $defloc = shift || '';
2334
2335 $state = 1 if $state =~ /^active$/;
2336 $state = 1 if $state =~ /^on$/;
2337 $state = 0 if $state =~ /^inactive$/;
2338 $state = 0 if $state =~ /^off$/;
2339
2340 return ('FAIL',"Invalid domain status") if $state !~ /^\d+$/;
2341
2342 $domain = lc($domain) if $self->{lowercase};
2343
2344 return ('FAIL', "Invalid characters in domain") if $domain !~ /^[a-zA-Z0-9_.-]+$/;
2345
2346 my $sth = $dbh->prepare("SELECT domain_id FROM domains WHERE lower(domain) = lower(?) AND default_location = ?");
2347 my $dom_id;
2348
2349# quick check to start to see if we've already got one
2350 $sth->execute($domain, $defloc);
2351 ($dom_id) = $sth->fetchrow_array;
2352
2353 return ('FAIL', "Domain already exists") if $dom_id;
2354
2355 # Allow transactions, and raise an exception on errors so we can catch it later.
2356 # Use local to make sure these get "reset" properly on exiting this block
2357 local $dbh->{AutoCommit} = 0;
2358 local $dbh->{RaiseError} = 1;
2359
2360 # Wrap all the SQL in a transaction
2361 eval {
2362 # insert the domain...
2363 $dbh->do("INSERT INTO domains (domain,group_id,status,default_location) VALUES (?,?,?,?)", undef,
2364 ($domain, $group, $state, $defloc));
2365
2366 # get the ID...
2367 ($dom_id) = $dbh->selectrow_array("SELECT domain_id FROM domains WHERE lower(domain) = lower(?) AND default_location = ?",
2368 undef, ($domain, $defloc));
2369
2370 $self->_log(domain_id => $dom_id, group_id => $group,
2371 entry => "Added ".($state ? 'active' : 'inactive')." domain $domain");
2372
2373 # ... and now we construct the standard records from the default set. NB: group should be variable.
2374 my $sth = $dbh->prepare("SELECT host,type,val,distance,weight,port,ttl FROM default_records WHERE group_id=?");
2375 my $sth_in = $dbh->prepare("INSERT INTO records (domain_id,host,type,val,distance,weight,port,ttl,location)".
2376 " VALUES ($dom_id,?,?,?,?,?,?,?,?)");
2377 $sth->execute($group);
2378 while (my ($host, $type, $val, $dist, $weight, $port, $ttl) = $sth->fetchrow_array()) {
2379 $host =~ s/DOMAIN/$domain/g;
2380 $val =~ s/DOMAIN/$domain/g;
2381 _caseclean(\$type, \$host, \$val, 'n', 'n') if $self->{lowercase};
2382 $sth_in->execute($host, $type, $val, $dist, $weight, $port, $ttl, $defloc);
2383 if ($typemap{$type} eq 'SOA') {
2384 my @tmp1 = split /:/, $host;
2385 my @tmp2 = split /:/, $val;
2386 $self->_log(domain_id => $dom_id, group_id => $group,
2387 entry => "[new $domain] Added SOA record [contact $tmp1[0]] [master $tmp1[1]] ".
2388 "[refresh $tmp2[0]] [retry $tmp2[1]] [expire $tmp2[2]] [minttl $tmp2[3]], TTL $ttl");
2389 } else {
2390 my $logentry = "[new $domain] Added record '$host $typemap{$type}";
2391 $logentry .= " [distance $dist]" if $typemap{$type} eq 'MX';
2392 $logentry .= " [priority $dist] [weight $weight] [port $port]" if $typemap{$type} eq 'SRV';
2393 $self->_log(domain_id => $dom_id, group_id => $group,
2394 entry => $logentry." $val', TTL $ttl");
2395 }
2396 }
2397
2398 # once we get here, we should have suceeded.
2399 $dbh->commit;
2400 }; # end eval
2401
2402 if ($@) {
2403 my $msg = $@;
2404 eval { $dbh->rollback; };
2405 $self->_log(group_id => $group, entry => "Failed adding domain $domain ($msg)")
2406 if $self->{log_failures};
2407 $dbh->commit; # since we enabled transactions earlier
2408 return ('FAIL',$msg);
2409 } else {
2410 return ('OK',$dom_id);
2411 }
2412} # end addDomain
2413
2414
2415## DNSDB::delZone()
2416# Delete a forward or reverse zone.
2417# Takes a database handle, zone ID, and forward/reverse flag.
2418# for now, just delete the records, then the domain.
2419# later we may want to archive it in some way instead (status code 2, for example?)
2420sub delZone {
2421 my $self = shift;
2422 my $dbh = $self->{dbh};
2423 my $zoneid = shift;
2424 my $revrec = shift;
2425
2426 # Allow transactions, and raise an exception on errors so we can catch it later.
2427 # Use local to make sure these get "reset" properly on exiting this block
2428 local $dbh->{AutoCommit} = 0;
2429 local $dbh->{RaiseError} = 1;
2430
2431 return ('FAIL', 'Need a zone identifier to look up') if !$zoneid;
2432
2433 my $msg = '';
2434 my $failmsg = '';
2435 my $zone = ($revrec eq 'n' ? $self->domainName($zoneid) : $self->revName($zoneid));
2436
2437 return ('FAIL', ($revrec eq 'n' ? 'Domain' : 'Reverse zone')." ID $zoneid doesn't exist") if !$zone;
2438
2439 # Set this up here since we may use if if $self->{log_failures} is enabled
2440 my %loghash;
2441 $loghash{domain_id} = $zoneid if $revrec eq 'n';
2442 $loghash{rdns_id} = $zoneid if $revrec eq 'y';
2443 $loghash{group_id} = $self->parentID(
2444 id => $zoneid, type => ($revrec eq 'n' ? 'domain' : 'revzone'), revrec => $revrec);
2445
2446 # Wrap all the SQL in a transaction
2447 eval {
2448 # Disentangle custom record types before removing the
2449 # ones that are only in the zone to be deleted
2450 if ($revrec eq 'n') {
2451 my $sth = $dbh->prepare("UPDATE records SET type=?,domain_id=0 WHERE domain_id=? AND type=?");
2452 $failmsg = "Failure converting multizone types to single-zone";
2453 $sth->execute($reverse_typemap{PTR}, $zoneid, 65280);
2454 $sth->execute($reverse_typemap{PTR}, $zoneid, 65281);
2455 $sth->execute(65282, $zoneid, 65283);
2456 $sth->execute(65282, $zoneid, 65284);
2457 $failmsg = "Failure removing domain records";
2458 $dbh->do("DELETE FROM records WHERE domain_id=?", undef, ($zoneid));
2459 $failmsg = "Failure removing domain";
2460 $dbh->do("DELETE FROM domains WHERE domain_id=?", undef, ($zoneid));
2461 } else {
2462 my $sth = $dbh->prepare("UPDATE records SET type=?,rdns_id=0 WHERE rdns_id=? AND type=?");
2463 $failmsg = "Failure converting multizone types to single-zone";
2464 $sth->execute($reverse_typemap{A}, $zoneid, 65280);
2465 $sth->execute($reverse_typemap{AAAA}, $zoneid, 65281);
2466# We don't have an "A template" or "AAAA template" type, although it might be useful for symmetry.
2467# $sth->execute(65286?, $zoneid, 65283);
2468# $sth->execute(65286?, $zoneid, 65284);
2469 $failmsg = "Failure removing reverse records";
2470 $dbh->do("DELETE FROM records WHERE rdns_id=?", undef, ($zoneid));
2471 $failmsg = "Failure removing reverse zone";
2472 $dbh->do("DELETE FROM revzones WHERE rdns_id=?", undef, ($zoneid));
2473 }
2474
2475 $msg = "Deleted ".($revrec eq 'n' ? 'domain' : 'reverse zone')." $zone";
2476 $loghash{entry} = $msg;
2477 $self->_log(%loghash);
2478
2479 # once we get here, we should have suceeded.
2480 $dbh->commit;
2481 }; # end eval
2482
2483 if ($@) {
2484 $msg = $@;
2485 eval { $dbh->rollback; };
2486 $loghash{entry} = "Error deleting $zone: $msg ($failmsg)";
2487 if ($self->{log_failures}) {
2488 $self->_log(%loghash);
2489 $dbh->commit; # since we enabled transactions earlier
2490 }
2491 return ('FAIL', $loghash{entry});
2492 } else {
2493 return ('OK', $msg);
2494 }
2495
2496} # end delZone()
2497
2498
2499## DNSDB::domainName()
2500# Return the domain name based on a domain ID
2501# Takes a database handle and the domain ID
2502# Returns the domain name or undef on failure
2503sub domainName {
2504 $errstr = '';
2505 my $self = shift;
2506 my $dbh = $self->{dbh};
2507 my $domid = shift;
2508 my ($domname) = $dbh->selectrow_array("SELECT domain FROM domains WHERE domain_id=?", undef, ($domid) );
2509 $errstr = $DBI::errstr if !$domname;
2510 return $domname if $domname;
2511} # end domainName()
2512
2513
2514## DNSDB::revName()
2515# Return the reverse zone name based on an rDNS ID
2516# Takes a database handle and the rDNS ID, and an optional flag to force return of the CIDR zone
2517# instead of the formal .arpa zone name
2518# Returns the reverse zone name or undef on failure
2519sub revName {
2520 $errstr = '';
2521 my $self = shift;
2522 my $dbh = $self->{dbh};
2523 my $revid = shift;
2524 my $cidrflag = shift || 'n';
2525 my ($revname) = $dbh->selectrow_array("SELECT revnet FROM revzones WHERE rdns_id=?", undef, ($revid) );
2526 $errstr = $DBI::errstr if !$revname;
2527 my $tmp = new NetAddr::IP $revname;
2528 $revname = _ZONE($tmp, 'ZONE', 'r', '.').($tmp->{isv6} ? '.ip6.arpa' : '.in-addr.arpa')
2529 if ($self->{showrev_arpa} eq 'zone' || $self->{showrev_arpa} eq 'all') && $cidrflag eq 'n';
2530 return $revname if $revname;
2531} # end revName()
2532
2533
2534## DNSDB::domainID()
2535# Takes a database handle and domain name
2536# Returns the domain ID number
2537sub domainID {
2538 $errstr = '';
2539 my $self = shift;
2540 my $dbh = $self->{dbh};
2541 my $domain = shift;
2542 my ($domid) = $dbh->selectrow_array("SELECT domain_id FROM domains WHERE lower(domain) = lower(?)",
2543 undef, ($domain) );
2544 if (!$domid) {
2545 if ($dbh->err) {
2546 $errstr = $DBI::errstr;
2547 } else {
2548 $errstr = "Domain $domain not present";
2549 }
2550 }
2551 return $domid if $domid;
2552} # end domainID()
2553
2554
2555## DNSDB::revID()
2556# Takes a database handle and reverse zone name
2557# Returns the rDNS ID number
2558sub revID {
2559 $errstr = '';
2560 my $self = shift;
2561 my $dbh = $self->{dbh};
2562 my $revzone = shift;
2563 my ($revid) = $dbh->selectrow_array("SELECT rdns_id FROM revzones WHERE revnet=?", undef, ($revzone) );
2564 if (!$revid) {
2565 if ($dbh->err) {
2566 $errstr = $DBI::errstr;
2567 } else {
2568 $errstr = "Reverse zone $revzone not present";
2569 }
2570 }
2571 return $revid if $revid;
2572} # end revID()
2573
2574
2575## DNSDB::addRDNS
2576# Adds a reverse DNS zone
2577# Takes a database handle, CIDR block, reverse DNS pattern, numeric group,
2578# and boolean(ish) state (active/inactive)
2579# Returns a status code and message
2580sub addRDNS {
2581 my $self = shift;
2582 my $dbh = $self->{dbh};
2583 my $zone = shift;
2584
2585 # Autodetect formal .arpa zones
2586 if ($zone =~ /\.arpa\.?$/) {
2587 my $code;
2588 ($code,$zone) = _zone2cidr($zone);
2589 return ('FAIL', $zone) if $code eq 'FAIL';
2590 }
2591 $zone = NetAddr::IP->new($zone);
2592
2593 return ('FAIL',"Zone name must be a valid CIDR netblock") unless ($zone && $zone->addr !~ /^0/);
2594 my $revpatt = shift; # construct a custom (A/AAAA+)? PTR template record
2595 my $group = shift;
2596 my $state = shift;
2597 my $defloc = shift || '';
2598
2599 $state = 1 if $state =~ /^active$/;
2600 $state = 1 if $state =~ /^on$/;
2601 $state = 0 if $state =~ /^inactive$/;
2602 $state = 0 if $state =~ /^off$/;
2603
2604 return ('FAIL',"Invalid zone status") if $state !~ /^\d+$/;
2605
2606# quick check to start to see if we've already got one
2607 my ($rdns_id) = $dbh->selectrow_array("SELECT rdns_id FROM revzones WHERE revnet = ? AND default_location = ?",
2608 undef, ("$zone", $defloc));
2609
2610 return ('FAIL', "Zone already exists") if $rdns_id;
2611
2612 # Allow transactions, and raise an exception on errors so we can catch it later.
2613 # Use local to make sure these get "reset" properly on exiting this block
2614 local $dbh->{AutoCommit} = 0;
2615 local $dbh->{RaiseError} = 1;
2616
2617 my $warnstr = '';
2618 my $defttl = 3600; # 1 hour should be reasonable. And unless things have gone horribly
2619 # wrong, we should have a value to override this anyway.
2620
2621 # Wrap all the SQL in a transaction
2622 eval {
2623 # insert the zone...
2624 $dbh->do("INSERT INTO revzones (revnet,group_id,status,default_location) VALUES (?,?,?,?)", undef,
2625 ($zone, $group, $state, $defloc) );
2626
2627 # get the ID...
2628 ($rdns_id) = $dbh->selectrow_array("SELECT currval('revzones_rdns_id_seq')");
2629
2630 $self->_log(rdns_id => $rdns_id, group_id => $group,
2631 entry => "Added ".($state ? 'active' : 'inactive')." reverse zone $zone");
2632
2633 # ... and now we construct the standard records from the default set. NB: group should be variable.
2634 my $sth = $dbh->prepare("SELECT host,type,val,ttl FROM default_rev_records WHERE group_id=?");
2635 my $sth_in = $dbh->prepare("INSERT INTO records (rdns_id,domain_id,host,type,val,ttl,location)".
2636 " VALUES ($rdns_id,?,?,?,?,?,?)");
2637 $sth->execute($group);
2638 while (my ($host,$type,$val,$ttl) = $sth->fetchrow_array()) {
2639 # Silently skip v4/v6 mismatches. This is not an error, this is expected.
2640 if ($zone->{isv6}) {
2641 next if ($type == 65280 || $type == 65283);
2642 } else {
2643 next if ($type == 65281 || $type == 65284);
2644 }
2645
2646 $host =~ s/ADMINDOMAIN/$self->{domain}/g;
2647
2648 # Check to make sure the IP stubs will fit in the zone. Under most usage failures here should be rare.
2649 # On failure, tack a note on to a warning string and continue without adding this record.
2650 # While we're at it, we substitute $zone for ZONE in the value.
2651 if ($val eq 'ZONE') {
2652 # If we've got a pattern, we skip the default record version on (A+)PTR-template types
2653 next if $revpatt && ($type == 65282 || $type == 65283);
2654##fixme? do we care if we have multiple whole-zone templates?
2655 $val = $zone->network;
2656 } elsif ($val =~ /ZONE/) {
2657 my $tmpval = $val;
2658 $tmpval =~ s/ZONE//;
2659 # Bend the rules and allow single-trailing-number PTR or PTR template records to be inserted
2660 # as either v4 or v6. May make this an off-by-default config flag
2661 # Note that the origin records that may trigger this **SHOULD** already have ZONE,\d
2662 if ($type == 12 || $type == 65282) {
2663 $tmpval =~ s/[,.]/::/ if ($tmpval =~ /^[,.]\d+$/ && $zone->{isv6});
2664 $tmpval =~ s/[,:]+/./ if ($tmpval =~ /^(?:,|::)\d+$/ && !$zone->{isv6});
2665 }
2666 my $addr;
2667 if ($self->_ipparent('n', 'y', \$tmpval, $rdns_id, \$addr)) {
2668 $val = $addr->addr;
2669 } else {
2670 $warnstr .= "\nDefault record '$val $typemap{$type} $host' doesn't fit in $zone, skipping";
2671 next;
2672 }
2673 }
2674
2675 # Substitute $zone for ZONE in the hostname, but only for non-NS records.
2676 # NS records get this substitution on the value instead.
2677 $host = _ZONE($zone, $host) if $type != 2;
2678
2679 # Fill in the forward domain ID if we can find it, otherwise:
2680 # Coerce type down to PTR or PTR template if we can't
2681 my $domid = 0;
2682 if ($type >= 65280) {
2683 if (!($domid = $self->_hostparent($host))) {
2684 $warnstr .= "\nRecord added as PTR instead of $typemap{$type}; domain not found for $host";
2685 $type = $reverse_typemap{PTR};
2686 $domid = 0; # just to be explicit.
2687 }
2688 }
2689
2690 _caseclean(\$type, \$host, \$val, 'n', 'y') if $self->{lowercase};
2691
2692 $sth_in->execute($domid,$host,$type,$val,$ttl,$defloc);
2693
2694 if ($typemap{$type} eq 'SOA') {
2695 my @tmp1 = split /:/, $host;
2696 my @tmp2 = split /:/, $val;
2697 $self->_log(rdns_id => $rdns_id, group_id => $group,
2698 entry => "[new $zone] Added SOA record [contact $tmp1[0]] [master $tmp1[1]] ".
2699 "[refresh $tmp2[0]] [retry $tmp2[1]] [expire $tmp2[2]] [minttl $tmp2[3]], TTL $ttl");
2700 $defttl = $tmp2[3];
2701 } else {
2702 my $logentry = "[new $zone] Added record '$host $typemap{$type} $val', TTL $ttl";
2703 $logentry .= ", default location ".$self->getLoc($defloc)->{description} if $defloc;
2704 $self->_log(rdns_id => $rdns_id, domain_id => $domid, group_id => $group, entry => $logentry);
2705 }
2706 }
2707
2708 # Generate record based on provided pattern.
2709 if ($revpatt) {
2710 my $host;
2711 my $type = ($zone->{isv6} ? 65284 : 65283);
2712 my $val = $zone->network;
2713
2714 # Substitute $zone for ZONE in the hostname.
2715 $host = _ZONE($zone, $revpatt);
2716
2717 my $domid = 0;
2718 if (!($domid = $self->_hostparent($host))) {
2719 $warnstr .= "\nDefault pattern added as PTR template instead of $typemap{$type}; domain not found for $host";
2720 $type = 65282;
2721 $domid = 0; # just to be explicit.
2722 }
2723
2724 $sth_in->execute($domid,$host,$type,$val,$defttl,$defloc);
2725 my $logentry = "[new $zone] Added record '$host $typemap{$type}";
2726 $self->_log(rdns_id => $rdns_id, domain_id => $domid, group_id => $group,
2727 entry => $logentry." $val', TTL $defttl from pattern");
2728 }
2729
2730 # If there are warnings (presumably about default records skipped for cause) log them
2731 $self->_log(rdns_id => $rdns_id, group_id => $group, entry => "Warning(s) adding $zone:$warnstr")
2732 if $warnstr;
2733
2734 # once we get here, we should have suceeded.
2735 $dbh->commit;
2736 }; # end eval
2737
2738 if ($@) {
2739 my $msg = $@;
2740 eval { $dbh->rollback; };
2741 $self->_log(group_id => $group, entry => "Failed adding reverse zone $zone ($msg)")
2742 if $self->{log_failures};
2743 $dbh->commit; # since we enabled transactions earlier
2744 return ('FAIL',$msg);
2745 } else {
2746 my $retcode = 'OK';
2747 if ($warnstr) {
2748 $resultstr = $warnstr;
2749 $retcode = 'WARN';
2750 }
2751 return ($retcode, $rdns_id);
2752 }
2753
2754} # end addRDNS()
2755
2756
2757## DNSDB::getZoneCount
2758# Get count of zones in group or groups
2759# Takes a database handle and hash containing:
2760# - the "current" group
2761# - an array of "acceptable" groups
2762# - a flag for forward/reverse zones
2763# - Optionally accept a "starts with" and/or "contains" filter argument
2764# Returns an integer count of the resulting zone list.
2765sub getZoneCount {
2766 my $self = shift;
2767 my $dbh = $self->{dbh};
2768
2769 my %args = @_;
2770
2771 # Fail on bad curgroup argument. There's no sane fallback on this one.
2772 if (!$args{curgroup} || $args{curgroup} !~ /^\d+$/) {
2773 $errstr = "Bad or missing curgroup argument";
2774 return;
2775 }
2776 # Fail on bad childlist argument. This could be sanely ignored if bad, maybe.
2777 if ($args{childlist} && $args{childlist} !~ /^[\d,]+$/) {
2778 $errstr = "Bad childlist argument";
2779 return;
2780 }
2781
2782 my @filterargs;
2783 $args{startwith} = undef if $args{startwith} && $args{startwith} !~ /^(?:[a-z]|0-9)$/;
2784 push @filterargs, "^$args{startwith}" if $args{startwith};
2785 $args{filter} =~ s/\./\[\.\]/g if $args{filter}; # only match literal dots, usually in reverse zones
2786 push @filterargs, $args{filter} if $args{filter};
2787
2788 my $sql;
2789 # Not as compact, and fix-me-twice if the common bits get wrong, but much easier to read
2790 if ($args{revrec} eq 'n') {
2791 $sql = "SELECT count(*) FROM domains".
2792 " WHERE group_id IN ($args{curgroup}".($args{childlist} ? ",$args{childlist}" : '').")".
2793 ($args{startwith} ? " AND domain ~* ?" : '').
2794 ($args{filter} ? " AND domain ~* ?" : '');
2795 } else {
2796 $sql = "SELECT count(*) FROM revzones".
2797 " WHERE group_id IN ($args{curgroup}".($args{childlist} ? ",$args{childlist}" : '').")".
2798 ($args{startwith} ? " AND CAST(revnet AS VARCHAR) ~* ?" : '');
2799# if ($self->{showrev_arpa} eq 'zone' || $self->{showrev_arpa} eq 'all') {
2800 # Just In Case the UI is using formal .arpa notation, and someone enters something reversed,
2801 # we want to match both the formal and natural zone name
2802 $sql .= ($args{filter} ? " AND (CAST(revnet AS VARCHAR) ~* ? OR CAST(revnet AS VARCHAR) ~* ?)" : '');
2803 push @filterargs, join('[.]',reverse(split(/\[\.\]/,$args{filter}))) if $args{filter};
2804# } else {
2805# $sql .= ($args{filter} ? " AND CAST(revnet AS VARCHAR) ~* ?" : '');
2806# }
2807 }
2808 my ($count) = $dbh->selectrow_array($sql, undef, @filterargs);
2809 return $count;
2810} # end getZoneCount()
2811
2812
2813## DNSDB::getZoneList()
2814# Get a list of zones in the specified group(s)
2815# Takes the same arguments as getZoneCount() above
2816# Returns a reference to an array of hashrefs suitable for feeding to HTML::Template
2817sub getZoneList {
2818 my $self = shift;
2819 my $dbh = $self->{dbh};
2820
2821 my %args = @_;
2822
2823 my @zonelist;
2824
2825 $args{sortorder} = 'ASC' if !$args{sortorder} || !grep /^$args{sortorder}$/, ('ASC','DESC');
2826 $args{offset} = 0 if !$args{offset} || $args{offset} !~ /^(?:all|\d+)$/;
2827
2828 # Fail on bad curgroup argument. There's no sane fallback on this one.
2829 if (!$args{curgroup} || $args{curgroup} !~ /^\d+$/) {
2830 $errstr = "Bad or missing curgroup argument";
2831 return;
2832 }
2833 # Fail on bad childlist argument. This could be sanely ignored if bad, maybe.
2834 if ($args{childlist} && $args{childlist} !~ /^[\d,]+$/) {
2835 $errstr = "Bad childlist argument";
2836 return;
2837 }
2838
2839 my @filterargs;
2840 $args{startwith} = undef if $args{startwith} && $args{startwith} !~ /^(?:[a-z]|0-9)$/;
2841 push @filterargs, "^$args{startwith}" if $args{startwith};
2842 $args{filter} =~ s/\./\[\.\]/g if $args{filter}; # only match literal dots, usually in reverse zones
2843 push @filterargs, $args{filter} if $args{filter};
2844
2845 my $sql;
2846 # Not as compact, and fix-me-twice if the common bits get wrong, but much easier to read
2847 if ($args{revrec} eq 'n') {
2848 $args{sortby} = 'domain' if !$args{sortby} || !grep /^$args{sortby}$/, ('domain','group','status');
2849 $sql = q(SELECT
2850 domain_id AS zoneid,
2851 domain AS zone,
2852 status,
2853 groups.group_name AS group,
2854 l.description AS location
2855 FROM domains
2856 LEFT JOIN locations l ON domains.default_location=l.location
2857 INNER JOIN groups ON domains.group_id=groups.group_id ).
2858 "WHERE domains.group_id IN ($args{curgroup}".($args{childlist} ? ",$args{childlist}" : '').")".
2859 ($args{startwith} ? " AND domain ~* ?" : '').
2860 ($args{filter} ? " AND domain ~* ?" : '');
2861 } else {
2862##fixme: arguably startwith here is irrelevant. depends on the UI though.
2863 $args{sortby} = 'revnet' if !$args{sortby} || !grep /^$args{sortby}$/, ('revnet','group','status');
2864 $sql = q(SELECT
2865 rdns_id AS zoneid,
2866 revnet AS zone,
2867 status,
2868 groups.group_name AS group,
2869 l.description AS location
2870 FROM revzones
2871 LEFT JOIN locations l ON revzones.default_location=l.location
2872 INNER JOIN groups ON revzones.group_id=groups.group_id ).
2873 " WHERE revzones.group_id IN ($args{curgroup}".($args{childlist} ? ",$args{childlist}" : '').")".
2874 ($args{startwith} ? " AND CAST(revnet AS VARCHAR) ~* ?" : '');
2875# if ($self->{showrev_arpa} eq 'zone' || $self->{showrev_arpa} eq 'all') {
2876 # Just In Case the UI is using formal .arpa notation, and someone enters something reversed,
2877 # we want to match both the formal and natural zone name
2878 $sql .= ($args{filter} ? " AND (CAST(revnet AS VARCHAR) ~* ? OR CAST(revnet AS VARCHAR) ~* ?)" : '');
2879 push @filterargs, join('[.]',reverse(split(/\[\.\]/,$args{filter}))) if $args{filter};
2880# } else {
2881# $sql .= ($args{filter} ? " AND CAST(revnet AS VARCHAR) ~* ?" : '');
2882# }
2883 }
2884 # A common tail.
2885 $sql .= " ORDER BY ".($args{sortby} eq 'group' ? 'groups.group_name' : $args{sortby})." $args{sortorder} ".
2886 ($args{offset} eq 'all' ? '' : " LIMIT $self->{perpage}".
2887 " OFFSET ".$args{offset}*$self->{perpage});
2888
2889 my @working;
2890 my $zsth = $dbh->prepare($sql);
2891 $zsth->execute(@filterargs);
2892 while (my $zone = $zsth->fetchrow_hashref) {
2893 if ($args{revrec} eq 'y' && ($self->{showrev_arpa} eq 'zone' || $self->{showrev_arpa} eq 'all')) {
2894 my $tmp = new NetAddr::IP $zone->{zone};
2895 $zone->{zone} = DNSDB::_ZONE($tmp, 'ZONE', 'r', '.').($tmp->{isv6} ? '.ip6.arpa' : '.in-addr.arpa');
2896 }
2897 push @working, $zone;
2898 }
2899 return \@working;
2900} # end getZoneList()
2901
2902
2903## DNSDB::getZoneLocation()
2904# Retrieve the default location for a zone.
2905# Takes a database handle, forward/reverse flag, and zone ID
2906sub getZoneLocation {
2907 my $self = shift;
2908 my $dbh = $self->{dbh};
2909 my $revrec = shift;
2910 my $zoneid = shift;
2911
2912 my ($loc) = $dbh->selectrow_array("SELECT default_location FROM ".
2913 ($revrec eq 'n' ? 'domains WHERE domain_id = ?' : 'revzones WHERE rdns_id = ?'),
2914 undef, ($zoneid));
2915 return $loc;
2916} # end getZoneLocation()
2917
2918
2919## DNSDB::addGroup()
2920# Add a group
2921# Takes a database handle, group name, parent group, hashref for permissions,
2922# and optional template-vs-cloneme flag for the default records
2923# Returns a status code and message
2924sub addGroup {
2925 $errstr = '';
2926 my $self = shift;
2927 my $dbh = $self->{dbh};
2928 my $groupname = shift;
2929 my $pargroup = shift;
2930 my $permissions = shift;
2931
2932 # 0 indicates "custom", hardcoded.
2933 # Any other value clones that group's default records, if it exists.
2934 my $inherit = shift || 0;
2935##fixme: need a flag to indicate clone records or <?> ?
2936
2937 # Allow transactions, and raise an exception on errors so we can catch it later.
2938 # Use local to make sure these get "reset" properly on exiting this block
2939 local $dbh->{AutoCommit} = 0;
2940 local $dbh->{RaiseError} = 1;
2941
2942 my ($group_id) = $dbh->selectrow_array("SELECT group_id FROM groups WHERE group_name=?", undef, ($groupname));
2943
2944 return ('FAIL', "Group already exists") if $group_id;
2945
2946 # Wrap all the SQL in a transaction
2947 eval {
2948 $dbh->do("INSERT INTO groups (parent_group_id,group_name) VALUES (?,?)", undef, ($pargroup, $groupname) );
2949
2950 my ($groupid) = $dbh->selectrow_array("SELECT currval('groups_group_id_seq')");
2951
2952 # We work through the whole set of permissions instead of specifying them so
2953 # that when we add a new permission, we don't have to change the code anywhere
2954 # that doesn't explicitly deal with that specific permission.
2955 my @permvals;
2956 foreach (@permtypes) {
2957 if (!defined ($permissions->{$_})) {
2958 push @permvals, 0;
2959 } else {
2960 push @permvals, $permissions->{$_};
2961 }
2962 }
2963 $dbh->do("INSERT INTO permissions (group_id,$permlist) values (?".',?'x($#permtypes+1).")",
2964 undef, ($groupid, @permvals) );
2965 my ($permid) = $dbh->selectrow_array("SELECT currval('permissions_permission_id_seq')");
2966 $dbh->do("UPDATE groups SET permission_id=$permid WHERE group_id=$groupid");
2967
2968 # Default records
2969 my $sthf = $dbh->prepare("INSERT INTO default_records (group_id,host,type,val,distance,weight,port,ttl) ".
2970 "VALUES ($groupid,?,?,?,?,?,?,?)");
2971 my $sthr = $dbh->prepare("INSERT INTO default_rev_records (group_id,host,type,val,ttl) ".
2972 "VALUES ($groupid,?,?,?,?)");
2973 if ($inherit) {
2974 # Duplicate records from parent. Actually relying on inherited records feels
2975 # very fragile, and it would be problematic to roll over at a later time.
2976 my $sth2 = $dbh->prepare("SELECT host,type,val,distance,weight,port,ttl FROM default_records WHERE group_id=?");
2977 $sth2->execute($pargroup);
2978 while (my @clonedata = $sth2->fetchrow_array) {
2979 $sthf->execute(@clonedata);
2980 }
2981 # And now the reverse records
2982 $sth2 = $dbh->prepare("SELECT host,type,val,ttl FROM default_rev_records WHERE group_id=?");
2983 $sth2->execute($pargroup);
2984 while (my @clonedata = $sth2->fetchrow_array) {
2985 $sthr->execute(@clonedata);
2986 }
2987 } else {
2988##fixme: Hardcoding is Bad, mmmmkaaaay?
2989 # reasonable basic defaults for SOA, MX, NS, and minimal hosting
2990 # could load from a config file, but somewhere along the line we need hardcoded bits.
2991 $sthf->execute('ns1.example.com:hostmaster.example.com', 6, '10800:3600:604800:10800', 0, 0, 0, 86400);
2992 $sthf->execute('DOMAIN', 1, '192.168.4.2', 0, 0, 0, 7200);
2993 $sthf->execute('DOMAIN', 15, 'mx.example.com', 10, 0, 0, 7200);
2994 $sthf->execute('DOMAIN', 2, 'ns1.example.com', 0, 0, 0, 7200);
2995 $sthf->execute('DOMAIN', 2, 'ns2.example.com', 0, 0, 0, 7200);
2996 $sthf->execute('www.DOMAIN', 5, 'DOMAIN', 0, 0, 0, 7200);
2997 # reasonable basic defaults for generic reverse zone. Same as initial SQL tabledef.
2998 $sthr->execute('hostmaster.ADMINDOMAIN:ns1.ADMINDOMAIN', 6, '10800:3600:604800:10800', 86400);
2999 $sthr->execute('unused-%r.ADMINDOMAIN', 65283, 'ZONE', 3600);
3000 }
3001
3002 $self->_log(group_id => $pargroup, entry => "Added group $groupname");
3003
3004 # once we get here, we should have suceeded.
3005 $dbh->commit;
3006 }; # end eval
3007
3008 if ($@) {
3009 my $msg = $@;
3010 eval { $dbh->rollback; };
3011 if ($self->{log_failures}) {
3012 $self->_log(group_id => $pargroup, entry => "Failed to add group $groupname: $msg");
3013 $dbh->commit;
3014 }
3015 return ('FAIL',$msg);
3016 }
3017
3018 return ('OK','OK');
3019} # end addGroup()
3020
3021
3022## DNSDB::delGroup()
3023# Delete a group.
3024# Takes a group ID
3025# Returns a status code and message
3026sub delGroup {
3027 my $self = shift;
3028 my $dbh = $self->{dbh};
3029 my $groupid = shift;
3030
3031 # Allow transactions, and raise an exception on errors so we can catch it later.
3032 # Use local to make sure these get "reset" properly on exiting this block
3033 local $dbh->{AutoCommit} = 0;
3034 local $dbh->{RaiseError} = 1;
3035
3036##fixme: locate "knowable" error conditions and deal with them before the eval
3037# ... or inside, whatever.
3038# -> domains still exist in group
3039# -> ...
3040 my $failmsg = '';
3041 my $resultmsg = '';
3042
3043 # collect some pieces for logging and error messages
3044 my $groupname = $self->groupName($groupid);
3045 my $parid = $self->parentID(id => $groupid, type => 'group');
3046
3047 # Wrap all the SQL in a transaction
3048 eval {
3049 # Check for Things in the group
3050 $failmsg = "Can't remove group $groupname";
3051 my ($grpcnt) = $dbh->selectrow_array("SELECT count(*) FROM groups WHERE parent_group_id=?", undef, ($groupid));
3052 die "$grpcnt groups still in group\n" if $grpcnt;
3053 my ($domcnt) = $dbh->selectrow_array("SELECT count(*) FROM domains WHERE group_id=?", undef, ($groupid));
3054 die "$domcnt domains still in group\n" if $domcnt;
3055 my ($revcnt) = $dbh->selectrow_array("SELECT count(*) FROM revzones WHERE group_id=?", undef, ($groupid));
3056 die "$revcnt reverse zones still in group\n" if $revcnt;
3057 my ($usercnt) = $dbh->selectrow_array("SELECT count(*) FROM users WHERE group_id=?", undef, ($groupid));
3058 die "$usercnt users still in group\n" if $usercnt;
3059
3060 $failmsg = "Failed to delete default records for $groupname";
3061 $dbh->do("DELETE from default_records WHERE group_id=?", undef, ($groupid));
3062 $failmsg = "Failed to delete default reverse records for $groupname";
3063 $dbh->do("DELETE from default_rev_records WHERE group_id=?", undef, ($groupid));
3064 $failmsg = "Failed to remove group $groupname";
3065 $dbh->do("DELETE from groups WHERE group_id=?", undef, ($groupid));
3066
3067 $self->_log(group_id => $parid, entry => "Deleted group $groupname");
3068 $resultmsg = "Deleted group $groupname";
3069
3070 # once we get here, we should have suceeded.
3071 $dbh->commit;
3072 }; # end eval
3073
3074 if ($@) {
3075 my $msg = $@;
3076 eval { $dbh->rollback; };
3077 if ($self->{log_failures}) {
3078 $self->_log(group_id => $parid, entry => "$failmsg: $msg");
3079 $dbh->commit; # since we enabled transactions earlier
3080 }
3081 return ('FAIL',"$failmsg: $msg");
3082 }
3083
3084 return ('OK',$resultmsg);
3085} # end delGroup()
3086
3087
3088## DNSDB::getChildren()
3089# Get a list of all groups whose parent^n is group <n>
3090# Takes a database handle, group ID, reference to an array to put the group IDs in,
3091# and an optional flag to return only immediate children or all children-of-children
3092# default to returning all children
3093# Calls itself
3094sub getChildren {
3095 $errstr = '';
3096 my $self = shift;
3097 my $dbh = $self->{dbh};
3098 my $rootgroup = shift;
3099 my $groupdest = shift;
3100 my $immed = shift || 'all';
3101
3102 # special break for default group; otherwise we get stuck.
3103 if ($rootgroup == 1) {
3104 # by definition, group 1 is the Root Of All Groups
3105 my $sth = $dbh->prepare("SELECT group_id FROM groups WHERE NOT (group_id=1)".
3106 ($immed ne 'all' ? " AND parent_group_id=1" : '')." ORDER BY group_name");
3107 $sth->execute;
3108 while (my @this = $sth->fetchrow_array) {
3109 push @$groupdest, @this;
3110 }
3111 } else {
3112 my $sth = $dbh->prepare("SELECT group_id FROM groups WHERE parent_group_id=? ORDER BY group_name");
3113 $sth->execute($rootgroup);
3114 return if $sth->rows == 0;
3115 my @grouplist;
3116 while (my ($group) = $sth->fetchrow_array) {
3117 push @$groupdest, $group;
3118 $self->getChildren($group, $groupdest) if $immed eq 'all';
3119 }
3120 }
3121} # end getChildren()
3122
3123
3124## DNSDB::groupName()
3125# Return the group name based on a group ID
3126# Takes a database handle and the group ID
3127# Returns the group name or undef on failure
3128sub groupName {
3129 $errstr = '';
3130 my $self = shift;
3131 my $dbh = $self->{dbh};
3132 my $groupid = shift;
3133 my $sth = $dbh->prepare("SELECT group_name FROM groups WHERE group_id=?");
3134 $sth->execute($groupid);
3135 my ($groupname) = $sth->fetchrow_array();
3136 $errstr = $DBI::errstr if !$groupname;
3137 return $groupname if $groupname;
3138} # end groupName
3139
3140
3141## DNSDB::getGroupCount()
3142# Get count of subgroups in group or groups
3143# Takes a database handle and hash containing:
3144# - the "current" group
3145# - an array of "acceptable" groups
3146# - Optionally accept a "starts with" and/or "contains" filter argument
3147# Returns an integer count of the resulting group list.
3148sub getGroupCount {
3149 my $self = shift;
3150 my $dbh = $self->{dbh};
3151
3152 my %args = @_;
3153
3154 # Fail on bad curgroup argument. There's no sane fallback on this one.
3155 if (!$args{curgroup} || $args{curgroup} !~ /^\d+$/) {
3156 $errstr = "Bad or missing curgroup argument";
3157 return;
3158 }
3159 # Fail on bad childlist argument. This could be sanely ignored if bad, maybe.
3160 if ($args{childlist} && $args{childlist} !~ /^[\d,]+$/) {
3161 $errstr = "Bad childlist argument";
3162 return;
3163 }
3164
3165 my @filterargs;
3166 $args{startwith} = undef if $args{startwith} && $args{startwith} !~ /^(?:[a-z]|0-9)$/;
3167 push @filterargs, "^$args{startwith}" if $args{startwith};
3168 push @filterargs, $args{filter} if $args{filter};
3169
3170 my $sql = "SELECT count(*) FROM groups ".
3171 "WHERE parent_group_id IN ($args{curgroup}".($args{childlist} ? ",$args{childlist}" : '').")".
3172 ($args{startwith} ? " AND group_name ~* ?" : '').
3173 ($args{filter} ? " AND group_name ~* ?" : '');
3174 my ($count) = $dbh->selectrow_array($sql, undef, (@filterargs) );
3175 $errstr = $dbh->errstr if !$count;
3176 return $count;
3177} # end getGroupCount
3178
3179
3180## DNSDB::getGroupList()
3181# Get a list of sub^n-groups in the specified group(s)
3182# Takes the same arguments as getGroupCount() above
3183# Returns an arrayref containing hashrefs suitable for feeding straight to HTML::Template
3184sub getGroupList {
3185 my $self = shift;
3186 my $dbh = $self->{dbh};
3187
3188 my %args = @_;
3189
3190 # Fail on bad curgroup argument. There's no sane fallback on this one.
3191 if (!$args{curgroup} || $args{curgroup} !~ /^\d+$/) {
3192 $errstr = "Bad or missing curgroup argument";
3193 return;
3194 }
3195 # Fail on bad childlist argument. This could be sanely ignored if bad, maybe.
3196 if ($args{childlist} && $args{childlist} !~ /^[\d,]+$/) {
3197 $errstr = "Bad childlist argument";
3198 return;
3199 }
3200
3201 my @filterargs;
3202 $args{startwith} = undef if $args{startwith} && $args{startwith} !~ /^(?:[a-z]|0-9)$/;
3203 push @filterargs, "^$args{startwith}" if $args{startwith};
3204 push @filterargs, $args{filter} if $args{filter};
3205
3206 # protection against bad or missing arguments
3207 $args{sortorder} = 'ASC' if !$args{sortorder} || !grep /^$args{sortorder}$/, ('ASC','DESC');
3208 $args{sortby} = 'group' if !$args{sortby} || $args{sortby} !~ /^[\w_.]+$/;
3209 $args{offset} = 0 if !$args{offset} || $args{offset} !~ /^(?:all|\d+)$/;
3210
3211 # munge sortby for columns in database
3212 $args{sortby} = 'g.group_name' if $args{sortby} eq 'group';
3213 $args{sortby} = 'g2.group_name' if $args{sortby} eq 'parent';
3214
3215 my $sql = q(SELECT g.group_id AS groupid, g.group_name AS groupname, g2.group_name AS pgroup
3216 FROM groups g
3217 INNER JOIN groups g2 ON g2.group_id=g.parent_group_id
3218 ).
3219 " WHERE g.parent_group_id IN ($args{curgroup}".($args{childlist} ? ",$args{childlist}" : '').")".
3220 ($args{startwith} ? " AND g.group_name ~* ?" : '').
3221 ($args{filter} ? " AND g.group_name ~* ?" : '').
3222 " GROUP BY g.group_id, g.group_name, g2.group_name ".
3223 " ORDER BY $args{sortby} $args{sortorder} ".
3224 ($args{offset} eq 'all' ? '' : " LIMIT $self->{perpage} OFFSET ".$args{offset}*$self->{perpage});
3225 my $glist = $dbh->selectall_arrayref($sql, { Slice => {} }, (@filterargs) );
3226 $errstr = $dbh->errstr if !$glist;
3227
3228 # LEFT JOINs make the result set balloon beyond sanity just to include counts;
3229 # this means there's lots of crunching needed to trim the result set back down.
3230 # So instead we track the order of the groups, and push the counts into the
3231 # arrayref result separately.
3232##fixme: put this whole sub in a transaction? might be
3233# needed for accurate results on very busy systems.
3234##fixme: large group lists need prepared statements?
3235#my $ucsth = $dbh->prepare("SELECT count(*) FROM users WHERE group_id=?");
3236#my $dcsth = $dbh->prepare("SELECT count(*) FROM domains WHERE group_id=?");
3237#my $rcsth = $dbh->prepare("SELECT count(*) FROM revzones WHERE group_id=?");
3238 foreach (@{$glist}) {
3239 my ($ucnt) = $dbh->selectrow_array("SELECT count(*) FROM users WHERE group_id=?", undef, ($$_{groupid}));
3240 $$_{nusers} = $ucnt;
3241 my ($dcnt) = $dbh->selectrow_array("SELECT count(*) FROM domains WHERE group_id=?", undef, ($$_{groupid}));
3242 $$_{ndomains} = $dcnt;
3243 my ($rcnt) = $dbh->selectrow_array("SELECT count(*) FROM revzones WHERE group_id=?", undef, ($$_{groupid}));
3244 $$_{nrevzones} = $rcnt;
3245 }
3246
3247 return $glist;
3248} # end getGroupList
3249
3250
3251## DNSDB::groupID()
3252# Return the group ID based on the group name
3253# Takes a database handle and the group name
3254# Returns the group ID or undef on failure
3255sub groupID {
3256 $errstr = '';
3257 my $self = shift;
3258 my $dbh = $self->{dbh};
3259 my $group = shift;
3260 my ($grpid) = $dbh->selectrow_array("SELECT group_id FROM groups WHERE group_name=?", undef, ($group) );
3261 $errstr = $DBI::errstr if !$grpid;
3262 return $grpid if $grpid;
3263} # end groupID()
3264
3265
3266## DNSDB::addUser()
3267# Add a user.
3268# Takes a DB handle, username, group ID, password, state (active/inactive).
3269# Optionally accepts:
3270# user type (user/admin) - defaults to user
3271# permissions string - defaults to inherit from group
3272# three valid forms:
3273# i - Inherit permissions
3274# c:<user_id> - Clone permissions from <user_id>
3275# C:<permission list> - Set these specific permissions
3276# first name - defaults to username
3277# last name - defaults to blank
3278# phone - defaults to blank (could put other data within column def)
3279# Returns (OK,<uid>) on success, (FAIL,<message>) on failure
3280sub addUser {
3281 $errstr = '';
3282 my $self = shift;
3283 my $dbh = $self->{dbh};
3284 my $username = shift;
3285 my $group = shift;
3286 my $pass = shift;
3287 my $state = shift;
3288
3289 return ('FAIL', "Missing one or more required entries") if !defined($state);
3290 return ('FAIL', "Username must not be blank") if !$username;
3291
3292 # Munge in some alternate state values
3293 $state = 1 if $state =~ /^active$/;
3294 $state = 1 if $state =~ /^on$/;
3295 $state = 0 if $state =~ /^inactive$/;
3296 $state = 0 if $state =~ /^off$/;
3297
3298 my $type = shift || 'u'; # create limited users by default - fwiw, not sure yet how this will interact with ACLs
3299
3300 my $permstring = shift || 'i'; # default is to inhert permissions from group
3301
3302 my $fname = shift || $username;
3303 my $lname = shift || '';
3304 my $phone = shift || ''; # not going format-check
3305
3306 my $sth = $dbh->prepare("SELECT user_id FROM users WHERE username=?");
3307 my $user_id;
3308
3309# quick check to start to see if we've already got one
3310 $sth->execute($username);
3311 ($user_id) = $sth->fetchrow_array;
3312
3313 return ('FAIL', "User already exists") if $user_id;
3314
3315 # Allow transactions, and raise an exception on errors so we can catch it later.
3316 # Use local to make sure these get "reset" properly on exiting this block
3317 local $dbh->{AutoCommit} = 0;
3318 local $dbh->{RaiseError} = 1;
3319
3320 # Wrap all the SQL in a transaction
3321 eval {
3322 # insert the user... note we set inherited perms by default since
3323 # it's simple and cleans up some other bits of state
3324##fixme: need better handling of case of inherited or missing (!!) permissions entries
3325 my $sth = $dbh->prepare("INSERT INTO users ".
3326 "(group_id,username,password,firstname,lastname,phone,type,status,permission_id,inherit_perm) ".
3327 "VALUES (?,?,?,?,?,?,?,?,(SELECT permission_id FROM permissions WHERE group_id=?),'t')");
3328 $sth->execute($group,$username,unix_md5_crypt($pass),$fname,$lname,$phone,$type,$state,$group);
3329
3330 # get the ID...
3331 ($user_id) = $dbh->selectrow_array("SELECT currval('users_user_id_seq')");
3332
3333# Permissions! Gotta set'em all!
3334 die "Invalid permission string $permstring\n"
3335 if $permstring !~ /^(?:
3336 i # inherit
3337 |c:\d+ # clone
3338 # custom. no, the leading , is not a typo
3339 |C:(?:,(?:group|user|domain|record|location|self)_(?:edit|create|delete|locchg|view))*
3340 )$/x;
3341# bleh. I'd call another function to do my dirty work, but we're in the middle of a transaction already.
3342 if ($permstring ne 'i') {
3343 # for cloned or custom permissions, we have to create a new permissions entry.
3344 my $clonesrc = $group;
3345 if ($permstring =~ /^c:(\d+)/) { $clonesrc = $1; }
3346 $dbh->do("INSERT INTO permissions ($permlist,user_id) ".
3347 "SELECT $permlist,? FROM permissions WHERE permission_id=".
3348 "(SELECT permission_id FROM permissions WHERE ".($permstring =~ /^c:/ ? 'user' : 'group')."_id=?)",
3349 undef, ($user_id,$clonesrc) );
3350 $dbh->do("UPDATE users SET permission_id=".
3351 "(SELECT permission_id FROM permissions WHERE user_id=?) ".
3352 "WHERE user_id=?", undef, ($user_id, $user_id) );
3353 }
3354 if ($permstring =~ /^C:/) {
3355 # finally for custom permissions, we set the passed-in permissions (and unset
3356 # any that might have been brought in by the clone operation above)
3357 my ($permid) = $dbh->selectrow_array("SELECT permission_id FROM permissions WHERE user_id=?",
3358 undef, ($user_id) );
3359 foreach (@permtypes) {
3360 if ($permstring =~ /,$_/) {
3361 $dbh->do("UPDATE permissions SET $_='t' WHERE permission_id=?", undef, ($permid) );
3362 } else {
3363 $dbh->do("UPDATE permissions SET $_='f' WHERE permission_id=?", undef, ($permid) );
3364 }
3365 }
3366 }
3367
3368 $dbh->do("UPDATE users SET inherit_perm='n' WHERE user_id=?", undef, ($user_id) );
3369
3370##fixme: add another table to hold name/email for log table?
3371
3372 $self->_log(group_id => $group, entry => "Added user $username ($fname $lname)");
3373 # once we get here, we should have suceeded.
3374 $dbh->commit;
3375 }; # end eval
3376
3377 if ($@) {
3378 my $msg = $@;
3379 eval { $dbh->rollback; };
3380 if ($self->{log_failures}) {
3381 $self->_log(group_id => $group, entry => "Error adding user $username: $msg");
3382 $dbh->commit; # since we enabled transactions earlier
3383 }
3384 return ('FAIL',"Error adding user $username: $msg");
3385 }
3386
3387 return ('OK',"User $username ($fname $lname) added");
3388} # end addUser
3389
3390
3391## DNSDB::getUserCount()
3392# Get count of users in group
3393# Takes a database handle and hash containing at least the current group, and optionally:
3394# - a reference list of secondary groups
3395# - a filter string
3396# - a "Starts with" string
3397sub getUserCount {
3398 my $self = shift;
3399 my $dbh = $self->{dbh};
3400
3401 my %args = @_;
3402
3403 # Fail on bad curgroup argument. There's no sane fallback on this one.
3404 if (!$args{curgroup} || $args{curgroup} !~ /^\d+$/) {
3405 $errstr = "Bad or missing curgroup argument";
3406 return;
3407 }
3408 # Fail on bad childlist argument. This could be sanely ignored if bad, maybe.
3409 if ($args{childlist} && $args{childlist} !~ /^[\d,]+$/) {
3410 $errstr = "Bad childlist argument";
3411 return;
3412 }
3413
3414 my @filterargs;
3415 $args{startwith} = undef if $args{startwith} && $args{startwith} !~ /^(?:[a-z]|0-9)$/;
3416 push @filterargs, "^$args{startwith}" if $args{startwith};
3417 push @filterargs, $args{filter} if $args{filter};
3418
3419 my $sql = "SELECT count(*) FROM users ".
3420 "WHERE group_id IN ($args{curgroup}".($args{childlist} ? ",$args{childlist}" : '').")".
3421 ($args{startwith} ? " AND username ~* ?" : '').
3422 ($args{filter} ? " AND username ~* ?" : '');
3423 my ($count) = $dbh->selectrow_array($sql, undef, (@filterargs) );
3424 $errstr = $dbh->errstr if !$count;
3425 return $count;
3426} # end getUserCount()
3427
3428
3429## DNSDB::getUserList()
3430# Get list of users
3431# Takes the same arguments as getUserCount() above, plus optional:
3432# - sort field
3433# - sort order
3434# - offset/return-all-everything flag (defaults to $perpage records)
3435sub getUserList {
3436 my $self = shift;
3437 my $dbh = $self->{dbh};
3438
3439 my %args = @_;
3440
3441 # Fail on bad curgroup argument. There's no sane fallback on this one.
3442 if (!$args{curgroup} || $args{curgroup} !~ /^\d+$/) {
3443 $errstr = "Bad or missing curgroup argument";
3444 return;
3445 }
3446 # Fail on bad childlist argument. This could be sanely ignored if bad, maybe.
3447 if ($args{childlist} && $args{childlist} !~ /^[\d,]+$/) {
3448 $errstr = "Bad childlist argument";
3449 return;
3450 }
3451
3452 my @filterargs;
3453 $args{startwith} = undef if $args{startwith} && $args{startwith} !~ /^(?:[a-z]|0-9)$/;
3454 push @filterargs, "^$args{startwith}" if $args{startwith};
3455 push @filterargs, $args{filter} if $args{filter};
3456
3457 # better to request sorts on "simple" names, but it means we need to map it to real columns
3458 my %sortmap = (user => 'u.username', type => 'u.type', group => 'g.group_name', status => 'u.status',
3459 fname => 'fname');
3460 $args{sortby} = $sortmap{$args{sortby}};
3461
3462 # protection against bad or missing arguments
3463 $args{sortorder} = 'ASC' if !$args{sortorder} || !grep /^$args{sortorder}$/, ('ASC','DESC');
3464 $args{sortby} = 'u.username' if !$args{sortby} || $args{sortby} !~ /^[\w_.]+$/;
3465 $args{offset} = 0 if !$args{offset} || $args{offset} !~ /^(?:all|\d+)$/;
3466
3467 my $sql = "SELECT u.user_id, u.username, u.firstname || ' ' || u.lastname AS fname, u.type, g.group_name, u.status ".
3468 "FROM users u ".
3469 "INNER JOIN groups g ON u.group_id=g.group_id ".
3470 "WHERE u.group_id IN ($args{curgroup}".($args{childlist} ? ",$args{childlist}" : '').")".
3471 ($args{startwith} ? " AND u.username ~* ?" : '').
3472 ($args{filter} ? " AND u.username ~* ?" : '').
3473 " AND NOT u.type = 'R' ".
3474 " ORDER BY $args{sortby} $args{sortorder} ".
3475 ($args{offset} eq 'all' ? '' : " LIMIT $self->{perpage} OFFSET ".$args{offset}*$self->{perpage});
3476 my $ulist = $dbh->selectall_arrayref($sql, { Slice => {} }, (@filterargs) );
3477 $errstr = $dbh->errstr if !$ulist;
3478 return $ulist;
3479} # end getUserList()
3480
3481
3482## DNSDB::getUserDropdown()
3483# Get a list of usernames for use in a dropdown menu.
3484# Takes a database handle, current group, and optional "tag this as selected" flag.
3485# Returns a reference to a list of hashrefs suitable to feeding to HTML::Template
3486sub getUserDropdown {
3487 my $self = shift;
3488 my $dbh = $self->{dbh};
3489 my $grp = shift;
3490 my $sel = shift || 0;
3491
3492 my $sth = $dbh->prepare("SELECT username,user_id FROM users WHERE group_id=?");
3493 $sth->execute($grp);
3494
3495 my @userlist;
3496 while (my ($username,$uid) = $sth->fetchrow_array) {
3497 my %row = (
3498 username => $username,
3499 uid => $uid,
3500 selected => ($sel == $uid ? 1 : 0)
3501 );
3502 push @userlist, \%row;
3503 }
3504 return \@userlist;
3505} # end getUserDropdown()
3506
3507
3508## DNSDB:: updateUser()
3509# Update general data about user
3510sub updateUser {
3511 my $self = shift;
3512 my $dbh = $self->{dbh};
3513
3514##fixme: tweak calling convention so that we can update any given bit of data
3515 my $uid = shift;
3516 my $username = shift;
3517 my $group = shift;
3518 my $pass = shift;
3519 my $state = shift;
3520 my $type = shift || 'u';
3521 my $fname = shift || $username;
3522 my $lname = shift || '';
3523 my $phone = shift || ''; # not going format-check
3524
3525 my $resultmsg = '';
3526
3527 # Munge in some alternate state values
3528 $state = 1 if $state =~ /^active$/;
3529 $state = 1 if $state =~ /^on$/;
3530 $state = 0 if $state =~ /^inactive$/;
3531 $state = 0 if $state =~ /^off$/;
3532
3533 # Allow transactions, and raise an exception on errors so we can catch it later.
3534 # Use local to make sure these get "reset" properly on exiting this block
3535 local $dbh->{AutoCommit} = 0;
3536 local $dbh->{RaiseError} = 1;
3537
3538 my $sth;
3539
3540 # Password can be left blank; if so we assume there's one on file.
3541 # Actual blank passwords are bad, mm'kay?
3542 if (!$pass) {
3543 ($pass) = $dbh->selectrow_array("SELECT password FROM users WHERE user_id=?", undef, ($uid));
3544 } else {
3545 $pass = unix_md5_crypt($pass);
3546 }
3547
3548 eval {
3549 $dbh->do("UPDATE users SET username=?, password=?, firstname=?, lastname=?, phone=?, type=?, status=?".
3550 " WHERE user_id=?", undef, ($username, $pass, $fname, $lname, $phone, $type, $state, $uid));
3551 $resultmsg = "Updated user info for $username ($fname $lname)";
3552 $self->_log(group_id => $group, entry => $resultmsg);
3553 $dbh->commit;
3554 };
3555 if ($@) {
3556 my $msg = $@;
3557 eval { $dbh->rollback; };
3558 if ($self->{log_failures}) {
3559 $self->_log(group_id => $group, entry => "Error updating user $username: $msg");
3560 $dbh->commit; # since we enabled transactions earlier
3561 }
3562 return ('FAIL',"Error updating user $username: $msg");
3563 }
3564
3565 return ('OK',$resultmsg);
3566} # end updateUser()
3567
3568
3569## DNSDB::delUser()
3570# Delete a user.
3571# Takes a database handle and user ID
3572# Returns a success/failure code and matching message
3573sub delUser {
3574 my $self = shift;
3575 my $dbh = $self->{dbh};
3576 my $userid = shift;
3577
3578 return ('FAIL',"Bad userid") if !defined($userid);
3579
3580 my $userdata = $self->getUserData($userid);
3581
3582 # Allow transactions, and raise an exception on errors so we can catch it later.
3583 # Use local to make sure these get "reset" properly on exiting this block
3584 local $dbh->{AutoCommit} = 0;
3585 local $dbh->{RaiseError} = 1;
3586
3587 eval {
3588 $dbh->do("DELETE FROM users WHERE user_id=?", undef, ($userid));
3589 $self->_log(group_id => $userdata->{group_id},
3590 entry => "Deleted user ID $userid/".$userdata->{username}.
3591 " (".$userdata->{firstname}." ".$userdata->{lastname}.")");
3592 $dbh->commit;
3593 };
3594 if ($@) {
3595 my $msg = $@;
3596 eval { $dbh->rollback; };
3597 if ($self->{log_failures}) {
3598 $self->_log(group_id => $userdata->{group_id}, entry => "Error deleting user ID ".
3599 "$userid/".$userdata->{username}.": $msg");
3600 $dbh->commit;
3601 }
3602 return ('FAIL',"Error deleting user $userid/".$userdata->{username}.": $msg");
3603 }
3604
3605 return ('OK',"Deleted user ".$userdata->{username}." (".$userdata->{firstname}." ".$userdata->{lastname}.")");
3606} # end delUser
3607
3608
3609## DNSDB::userFullName()
3610# Return a pretty string!
3611# Takes a user_id and optional printf-ish string to indicate which pieces where:
3612# %u for the username
3613# %f for the first name
3614# %l for the last name
3615# All other text in the passed string will be left as-is.
3616##fixme: need a "smart" option too, so that missing/null/blank first/last names don't give funky output
3617sub userFullName {
3618 $errstr = '';
3619 my $self = shift;
3620 my $dbh = $self->{dbh};
3621 my $userid = shift;
3622 my $fullformat = shift || '%f %l (%u)';
3623 my $sth = $dbh->prepare("select username,firstname,lastname from users where user_id=?");
3624 $sth->execute($userid);
3625 my ($uname,$fname,$lname) = $sth->fetchrow_array();
3626 $errstr = $DBI::errstr if !$uname;
3627
3628 $fullformat =~ s/\%u/$uname/g;
3629 $fullformat =~ s/\%f/$fname/g;
3630 $fullformat =~ s/\%l/$lname/g;
3631
3632 return $fullformat;
3633} # end userFullName
3634
3635
3636## DNSDB::userStatus()
3637# Sets and/or returns a user's status
3638# Takes a database handle, user ID and optionally a status argument
3639# Returns undef on errors.
3640sub userStatus {
3641 my $self = shift;
3642 my $dbh = $self->{dbh};
3643 my $id = shift;
3644 my $newstatus = shift || 'mu';
3645
3646 return undef if $id !~ /^\d+$/;
3647
3648 my $userdata = $self->getUserData($id);
3649
3650 # Allow transactions, and raise an exception on errors so we can catch it later.
3651 # Use local to make sure these get "reset" properly on exiting this block
3652 local $dbh->{AutoCommit} = 0;
3653 local $dbh->{RaiseError} = 1;
3654
3655 if ($newstatus ne 'mu') {
3656 # ooo, fun! let's see what we were passed for status
3657 eval {
3658 $newstatus = 0 if $newstatus eq 'useroff';
3659 $newstatus = 1 if $newstatus eq 'useron';
3660 $dbh->do("UPDATE users SET status=? WHERE user_id=?", undef, ($newstatus, $id));
3661
3662 $resultstr = ($newstatus ? 'Enabled' : 'Disabled')." user ".$userdata->{username}.
3663 " (".$userdata->{firstname}." ".$userdata->{lastname}.")";
3664
3665 my %loghash;
3666 $loghash{group_id} = $self->parentID(id => $id, type => 'user');
3667 $loghash{entry} = $resultstr;
3668 $self->_log(%loghash);
3669
3670 $dbh->commit;
3671 };
3672 if ($@) {
3673 my $msg = $@;
3674 eval { $dbh->rollback; };
3675 $resultstr = '';
3676 $errstr = $msg;
3677##fixme: failure logging?
3678 return;
3679 }
3680 }
3681
3682 my ($status) = $dbh->selectrow_array("SELECT status FROM users WHERE user_id=?", undef, ($id));
3683 return $status;
3684} # end userStatus()
3685
3686
3687## DNSDB::getUserData()
3688# Get misc user data for display
3689sub getUserData {
3690 my $self = shift;
3691 my $dbh = $self->{dbh};
3692 my $uid = shift;
3693
3694 my $sth = $dbh->prepare("SELECT group_id,username,firstname,lastname,phone,type,status,inherit_perm ".
3695 "FROM users WHERE user_id=?");
3696 $sth->execute($uid);
3697 return $sth->fetchrow_hashref();
3698} # end getUserData()
3699
3700
3701## DNSDB::addLoc()
3702# Add a new location.
3703# Takes a database handle, group ID, short and long description, and a comma-separated
3704# list of IP addresses.
3705# Returns ('OK',<location>) on success, ('FAIL',<failmsg>) on failure
3706sub addLoc {
3707 my $self = shift;
3708 my $dbh = $self->{dbh};
3709 my $grp = shift;
3710 my $shdesc = shift;
3711 my $comments = shift;
3712 my $iplist = shift;
3713
3714 # $shdesc gets set to the generated location ID if possible, but these can be de-undefined here.
3715 $comments = '' if !$comments;
3716 $iplist = '' if !$iplist;
3717
3718 my $loc;
3719
3720 # Generate a location ID. This is, by spec, a two-character widget. We'll use [a-z][a-z]
3721 # for now; 676 locations should satisfy all but the largest of the huge networks.
3722 # Not sure whether these are case-sensitive, or what other rules might apply - in any case
3723 # the absolute maximum is 16K (256*256) since it's parsed by tinydns as a two-character field.
3724
3725 # and just to be as clear as possible; as per http://cr.yp.to/djbdns/tinydns-data.html:
3726
3727#For versions 1.04 and above: You may include a client location on each line. The line is ignored for clients
3728#outside that location. Client locations are specified by % lines:
3729#
3730# %lo:ipprefix
3731#
3732#means that IP addresses starting with ipprefix are in location lo. lo is a sequence of one or two ASCII letters.
3733
3734 # so locations could probably include both upper and lower case.
3735
3736# add just after "my $origloc = $loc;":
3737# # These expand the possible space from 26^2 to 52^2 [* note in testing only 2052 were achieved],
3738# # and wrap it around.
3739# # Yes, they skip a couple of possibles. No, I don't care.
3740# $loc = 'aA' if $loc eq 'zz';
3741# $loc = 'Aa' if $loc eq 'zZ';
3742# $loc = 'ZA' if $loc eq 'Zz';
3743# $loc = 'aa' if $loc eq 'ZZ';
3744
3745 # Allow transactions, and raise an exception on errors so we can catch it later.
3746 # Use local to make sure these get "reset" properly on exiting this block
3747 local $dbh->{AutoCommit} = 0;
3748 local $dbh->{RaiseError} = 1;
3749
3750##fixme: There is probably a far better way to do this. Sequential increments
3751# are marginally less stupid that pure random generation though, and the existence
3752# check makes sure we don't stomp on an imported one.
3753
3754 eval {
3755 # Get the "last" location. Note this is the only use for loc_id, because selecting on location Does Funky Things
3756 ($loc) = $dbh->selectrow_array("SELECT location FROM locations ORDER BY loc_id DESC LIMIT 1");
3757 ($loc) = ($loc =~ /^(..)/) if $loc;
3758 my $origloc = $loc;
3759 $loc = 'aa' if !$loc;
3760 # Make a change...
3761 $loc++;
3762 # ... and keep changing if it exists
3763 while ($dbh->selectrow_array("SELECT count(*) FROM locations WHERE location LIKE ?", undef, ($loc.'%'))) {
3764 $loc++;
3765 ($loc) = ($loc =~ /^(..)/);
3766 die "too many locations in use, can't add another one\n" if $loc eq $origloc;
3767##fixme: really need to handle this case faster somehow
3768#if $loc eq $origloc die "<thwap> bad admin: all locations used, your network is too fragmented";
3769 }
3770 # And now we should have a unique location. tinydns fundamentally limits the
3771 # number of these but there's no doc on what characters are valid.
3772 $shdesc = $loc if !$shdesc;
3773 $dbh->do("INSERT INTO locations (location, group_id, iplist, description, comments) VALUES (?,?,?,?,?)",
3774 undef, ($loc, $grp, $iplist, $shdesc, $comments) );
3775 $self->_log(entry => "Added location ($shdesc, '$iplist')");
3776 $dbh->commit;
3777 };
3778 if ($@) {
3779 my $msg = $@;
3780 eval { $dbh->rollback; };
3781 if ($self->{log_failures}) {
3782 $shdesc = $loc if !$shdesc;
3783 $self->_log(entry => "Failed adding location ($shdesc, '$iplist'): $msg");
3784 $dbh->commit;
3785 }
3786 return ('FAIL',$msg);
3787 }
3788
3789 return ('OK',$loc);
3790} # end addLoc()
3791
3792
3793## DNSDB::updateLoc()
3794# Update details of a location.
3795# Takes a database handle, location ID, group ID, short description,
3796# long comments/notes, and comma/space-separated IP list
3797# Returns a result code and message
3798sub updateLoc {
3799 my $self = shift;
3800 my $dbh = $self->{dbh};
3801 my $loc = shift;
3802 my $grp = shift;
3803 my $shdesc = shift;
3804 my $comments = shift;
3805 my $iplist = shift;
3806
3807 $shdesc = '' if !$shdesc;
3808 $comments = '' if !$comments;
3809 $iplist = '' if !$iplist;
3810
3811 # Allow transactions, and raise an exception on errors so we can catch it later.
3812 # Use local to make sure these get "reset" properly on exiting this block
3813 local $dbh->{AutoCommit} = 0;
3814 local $dbh->{RaiseError} = 1;
3815
3816 my $oldloc = $self->getLoc($loc);
3817 my $okmsg = "Updated location (".$oldloc->{description}.", '".$oldloc->{iplist}."') to ($shdesc, '$iplist')";
3818
3819 eval {
3820 $dbh->do("UPDATE locations SET group_id=?,iplist=?,description=?,comments=? WHERE location=?",
3821 undef, ($grp, $iplist, $shdesc, $comments, $loc) );
3822 $self->_log(entry => $okmsg);
3823 $dbh->commit;
3824 };
3825 if ($@) {
3826 my $msg = $@;
3827 eval { $dbh->rollback; };
3828 if ($self->{log_failures}) {
3829 $shdesc = $loc if !$shdesc;
3830 $self->_log(entry => "Failed updating location ($shdesc, '$iplist'): $msg");
3831 $dbh->commit;
3832 }
3833 return ('FAIL',$msg);
3834 }
3835
3836 return ('OK',$okmsg);
3837} # end updateLoc()
3838
3839
3840## DNSDB::delLoc()
3841sub delLoc {
3842 my $self = shift;
3843 my $dbh = $self->{dbh};
3844 my $loc = shift;
3845
3846 # Allow transactions, and raise an exception on errors so we can catch it later.
3847 # Use local to make sure these get "reset" properly on exiting this block
3848 local $dbh->{AutoCommit} = 0;
3849 local $dbh->{RaiseError} = 1;
3850
3851 my $oldloc = $self->getLoc($loc);
3852 my $olddesc = ($oldloc->{description} ? $oldloc->{description} : $loc);
3853 my $okmsg = "Deleted location ($olddesc, '".$oldloc->{iplist}."')";
3854
3855 eval {
3856 # Check for records with this location first. Deleting a location without deleting records
3857 # tagged for that location will render them unpublished without other warning.
3858 my ($r) = $dbh->selectrow_array("SELECT record_id FROM records WHERE location=? LIMIT 1", undef, ($loc) );
3859 die "Records still exist in location $olddesc\n" if $r;
3860 $dbh->do("DELETE FROM locations WHERE location=?", undef, ($loc) );
3861 $self->_log(entry => $okmsg);
3862 $dbh->commit;
3863 };
3864 if ($@) {
3865 my $msg = $@;
3866 eval { $dbh->rollback; };
3867 if ($self->{log_failures}) {
3868 $self->_log(entry => "Failed to delete location ($olddesc, '$oldloc->{iplist}'): $msg");
3869 $dbh->commit;
3870 }
3871 return ('FAIL', "Failed to delete location ($olddesc, '$oldloc->{iplist}'): $msg");
3872 }
3873
3874 return ('OK',$okmsg);
3875} # end delLoc()
3876
3877
3878## DNSDB::getLoc()
3879# Get details about a location/view
3880# Takes a database handle and location ID.
3881# Returns a reference to a hash containing the group ID, IP list, description, and comments/notes
3882sub getLoc {
3883 my $self = shift;
3884 my $dbh = $self->{dbh};
3885 my $loc = shift;
3886
3887 my $sth = $dbh->prepare("SELECT group_id,iplist,description,comments FROM locations WHERE location=?");
3888 $sth->execute($loc);
3889 return $sth->fetchrow_hashref();
3890} # end getLoc()
3891
3892
3893## DNSDB::getLocCount()
3894# Get count of locations/views
3895# Takes a database handle and hash containing at least the current group, and optionally:
3896# - a reference list of secondary groups
3897# - a filter string
3898# - a "Starts with" string
3899sub getLocCount {
3900 my $self = shift;
3901 my $dbh = $self->{dbh};
3902
3903 my %args = @_;
3904
3905 # Fail on bad curgroup argument. There's no sane fallback on this one.
3906 if (!$args{curgroup} || $args{curgroup} !~ /^\d+$/) {
3907 $errstr = "Bad or missing curgroup argument";
3908 return;
3909 }
3910 # Fail on bad childlist argument. This could be sanely ignored if bad, maybe.
3911 if ($args{childlist} && $args{childlist} !~ /^[\d,]+$/) {
3912 $errstr = "Bad childlist argument";
3913 return;
3914 }
3915
3916 my @filterargs;
3917 $args{startwith} = undef if $args{startwith} && $args{startwith} !~ /^(?:[a-z]|0-9)$/;
3918 push @filterargs, "^$args{startwith}" if $args{startwith};
3919 push @filterargs, $args{filter} if $args{filter};
3920
3921 my $sql = "SELECT count(*) FROM locations ".
3922 "WHERE group_id IN ($args{curgroup}".($args{childlist} ? ",$args{childlist}" : '').")".
3923 ($args{startwith} ? " AND description ~* ?" : '').
3924 ($args{filter} ? " AND description ~* ?" : '');
3925 my ($count) = $dbh->selectrow_array($sql, undef, (@filterargs) );
3926 $errstr = $dbh->errstr if !$count;
3927 return $count;
3928} # end getLocCount()
3929
3930
3931## DNSDB::getLocList()
3932sub getLocList {
3933 my $self = shift;
3934 my $dbh = $self->{dbh};
3935
3936 my %args = @_;
3937
3938 # Fail on bad curgroup argument. There's no sane fallback on this one.
3939 if (!$args{curgroup} || $args{curgroup} !~ /^\d+$/) {
3940 $errstr = "Bad or missing curgroup argument";
3941 return;
3942 }
3943 # Fail on bad childlist argument. This could be sanely ignored if bad, maybe.
3944 if ($args{childlist} && $args{childlist} !~ /^[\d,]+$/) {
3945 $errstr = "Bad childlist argument";
3946 return;
3947 }
3948
3949 my @filterargs;
3950 $args{startwith} = undef if $args{startwith} && $args{startwith} !~ /^(?:[a-z]|0-9)$/;
3951 push @filterargs, "^$args{startwith}" if $args{startwith};
3952 push @filterargs, $args{filter} if $args{filter};
3953
3954 # better to request sorts on "simple" names, but it means we need to map it to real columns
3955# my %sortmap = (user => 'u.username', type => 'u.type', group => 'g.group_name', status => 'u.status',
3956# fname => 'fname');
3957# $args{sortby} = $sortmap{$args{sortby}};
3958
3959 # protection against bad or missing arguments
3960 $args{sortorder} = 'ASC' if !$args{sortorder} || !grep /^$args{sortorder}$/, ('ASC','DESC');
3961 $args{sortby} = 'l.description' if !$args{sortby} || $args{sortby} !~ /^[\w_.]+$/;
3962 $args{offset} = 0 if !$args{offset} || $args{offset} !~ /^(?:all|\d+)$/;
3963
3964 my $sql = "SELECT l.location, l.description, l.iplist, g.group_name ".
3965 "FROM locations l ".
3966 "INNER JOIN groups g ON l.group_id=g.group_id ".
3967 "WHERE l.group_id IN ($args{curgroup}".($args{childlist} ? ",$args{childlist}" : '').")".
3968 ($args{startwith} ? " AND l.description ~* ?" : '').
3969 ($args{filter} ? " AND l.description ~* ?" : '').
3970 " ORDER BY $args{sortby} $args{sortorder} ".
3971 ($args{offset} eq 'all' ? '' : " LIMIT $self->{perpage} OFFSET ".$args{offset}*$self->{perpage});
3972 my $ulist = $dbh->selectall_arrayref($sql, { Slice => {} }, (@filterargs) );
3973 $errstr = $dbh->errstr if !$ulist;
3974 return $ulist;
3975} # end getLocList()
3976
3977
3978## DNSDB::getLocDropdown()
3979# Get a list of location names for use in a dropdown menu.
3980# Takes a database handle, current group, and optional "tag this as selected" flag.
3981# Returns a reference to a list of hashrefs suitable to feeding to HTML::Template
3982sub getLocDropdown {
3983 my $self = shift;
3984 my $dbh = $self->{dbh};
3985 my $grp = shift;
3986 my $sel = shift || '';
3987
3988 my $sth = $dbh->prepare(qq(
3989 SELECT description,location FROM locations
3990 WHERE group_id=?
3991 ORDER BY description
3992 ) );
3993 $sth->execute($grp);
3994
3995 my @loclist;
3996 push @loclist, { locname => "(Default/All)", loc => '', selected => ($sel ? 0 : ($sel eq '' ? 1 : 0)) };
3997 while (my ($locname, $loc) = $sth->fetchrow_array) {
3998 my %row = (
3999 locname => $locname,
4000 loc => $loc,
4001 selected => ($sel eq $loc ? 1 : 0)
4002 );
4003 push @loclist, \%row;
4004 }
4005 return \@loclist;
4006} # end getLocDropdown()
4007
4008
4009## DNSDB::getSOA()
4010# Return all suitable fields from an SOA record in separate elements of a hash
4011# Takes a database handle, default/live flag, domain/reverse flag, and parent ID
4012sub getSOA {
4013 $errstr = '';
4014 my $self = shift;
4015 my $dbh = $self->{dbh};
4016 my $def = shift;
4017 my $rev = shift;
4018 my $id = shift;
4019
4020 # (ab)use distance and weight columns to store SOA data? can't for default_rev_records...
4021 # - should really attach serial to the zone parent somewhere
4022
4023 my $sql = "SELECT record_id,host,val,ttl from "._rectable($def,$rev).
4024 " WHERE "._recparent($def,$rev)." = ? AND type=$reverse_typemap{SOA}";
4025 my $ret = $dbh->selectrow_hashref($sql, undef, ($id) );
4026 return if !$ret;
4027##fixme: stick a flag somewhere if the record doesn't exist. by the API, this is an impossible case, but...
4028
4029 ($ret->{contact},$ret->{prins}) = split /:/, $ret->{host};
4030 delete $ret->{host};
4031 ($ret->{refresh},$ret->{retry},$ret->{expire},$ret->{minttl}) = split /:/, $ret->{val};
4032 delete $ret->{val};
4033
4034 return $ret;
4035} # end getSOA()
4036
4037
4038## DNSDB::updateSOA()
4039# Update the specified SOA record
4040# Takes a database handle, default/live flag, forward/reverse flag, and SOA data hash
4041# Returns a two-element list with a result code and message
4042sub updateSOA {
4043 my $self = shift;
4044 my $dbh = $self->{dbh};
4045 my $defrec = shift;
4046 my $revrec = shift;
4047
4048 my %soa = @_;
4049
4050 my $oldsoa = $self->getSOA($defrec, $revrec, $soa{id});
4051
4052 my $msg;
4053 my %logdata;
4054 if ($defrec eq 'n') {
4055 $logdata{domain_id} = $soa{id} if $revrec eq 'n';
4056 $logdata{rdns_id} = $soa{id} if $revrec eq 'y';
4057 $logdata{group_id} = $self->parentID(id => $soa{id}, revrec => $revrec,
4058 type => ($revrec eq 'n' ? 'domain' : 'revzone') );
4059 } else {
4060 $logdata{group_id} = $soa{id};
4061 }
4062 my $parname = ($defrec eq 'y' ? $self->groupName($soa{id}) :
4063 ($revrec eq 'n' ? $self->domainName($soa{id}) : $self->revName($soa{id})) );
4064
4065 # Allow transactions, and raise an exception on errors so we can catch it later.
4066 # Use local to make sure these get "reset" properly on exiting this block
4067 local $dbh->{AutoCommit} = 0;
4068 local $dbh->{RaiseError} = 1;
4069
4070 eval {
4071 if (!$oldsoa) {
4072 # old SOA record is missing for some reason. create a new one.
4073 my $sql = "INSERT INTO "._rectable($defrec, $revrec)." ("._recparent($defrec, $revrec).
4074 ", host, type, val, ttl) VALUES (?,?,6,?,?)";
4075 $dbh->do($sql, undef, ($soa{id}, "$soa{contact}:$soa{prins}",
4076 "$soa{refresh}:$soa{retry}:$soa{expire}:$soa{minttl}", $soa{ttl}) );
4077 $msg = ($defrec eq 'y' ? ($revrec eq 'y' ? 'Default reverse ' : 'Default ') : '').
4078 "SOA missing for $parname; added (ns $soa{prins}, contact $soa{contact}, refresh $soa{refresh},".
4079 " retry $soa{retry}, expire $soa{expire}, minTTL $soa{minttl}, TTL $soa{ttl})";
4080 } else {
4081 my $sql = "UPDATE "._rectable($defrec, $revrec)." SET host=?, val=?, ttl=? WHERE record_id=? AND type=6";
4082 $dbh->do($sql, undef, ("$soa{contact}:$soa{prins}", "$soa{refresh}:$soa{retry}:$soa{expire}:$soa{minttl}",
4083 $soa{ttl}, $oldsoa->{record_id}) );
4084 $msg = "Updated ".($defrec eq 'y' ? ($revrec eq 'y' ? 'default reverse ' : 'default ') : '').
4085 "SOA for $parname: ".
4086 "(ns $oldsoa->{prins}, contact $oldsoa->{contact}, refresh $oldsoa->{refresh},".
4087 " retry $oldsoa->{retry}, expire $oldsoa->{expire}, minTTL $oldsoa->{minttl}, TTL $oldsoa->{ttl}) to ".
4088 "(ns $soa{prins}, contact $soa{contact}, refresh $soa{refresh},".
4089 " retry $soa{retry}, expire $soa{expire}, minTTL $soa{minttl}, TTL $soa{ttl})";
4090 }
4091 $logdata{entry} = $msg;
4092 $self->_log(%logdata);
4093
4094 $dbh->commit;
4095 };
4096 if ($@) {
4097 $msg = $@;
4098 eval { $dbh->rollback; };
4099 $logdata{entry} = "Error updating ".($defrec eq 'y' ? ($revrec eq 'y' ? 'default reverse zone ' : 'default ') : '').
4100 "SOA record for $parname: $msg";
4101 if ($self->{log_failures}) {
4102 $self->_log(%logdata);
4103 $dbh->commit;
4104 }
4105 return ('FAIL', $logdata{entry});
4106 } else {
4107 return ('OK', $msg);
4108 }
4109} # end updateSOA()
4110
4111
4112## DNSDB::getRecLine()
4113# Return all data fields for a zone record in separate elements of a hash
4114# Takes a database handle, default/live flag, forward/reverse flag, and record ID
4115sub getRecLine {
4116 $errstr = '';
4117 my $self = shift;
4118 my $dbh = $self->{dbh};
4119 my $defrec = shift;
4120 my $revrec = shift;
4121 my $id = shift;
4122
4123##fixme: do we need a knob to twist to switch between unix epoch and postgres time string?
4124 my $sql = "SELECT record_id,host,type,val,ttl".
4125 ($defrec eq 'n' ? ',location' : '').
4126 ($revrec eq 'n' ? ',distance,weight,port' : '').
4127 (($defrec eq 'y') ? ',group_id FROM ' : ',domain_id,rdns_id,stamp,stamp < now() AS ispast,expires,stampactive FROM ').
4128 _rectable($defrec,$revrec)." WHERE record_id=?";
4129 my $ret = $dbh->selectrow_hashref($sql, undef, ($id) );
4130
4131 if ($dbh->err) {
4132 $errstr = $DBI::errstr;
4133 return undef;
4134 }
4135
4136 if (!$ret) {
4137 $errstr = "No such record";
4138 return undef;
4139 }
4140
4141 # explicitly set a parent id
4142 if ($defrec eq 'y') {
4143 $ret->{parid} = $ret->{group_id};
4144 } else {
4145 $ret->{parid} = (($revrec eq 'n') ? $ret->{domain_id} : $ret->{rdns_id});
4146 # and a secondary if we have a custom type that lives in both a forward and reverse zone
4147 $ret->{secid} = (($revrec eq 'y') ? $ret->{domain_id} : $ret->{rdns_id}) if $ret->{type} > 65279;
4148 }
4149 $ret->{address} = $ret->{val}; # because.
4150
4151 return $ret;
4152}
4153
4154
4155##fixme: should use above (getRecLine()) to get lines for below?
4156## DNSDB::getRecList()
4157# Return records for a group or zone
4158# Takes a default/live flag, group or zone ID, start,
4159# number of records, sort field, and sort order
4160# Returns a reference to an array of hashes
4161sub getRecList {
4162 $errstr = '';
4163 my $self = shift;
4164 my $dbh = $self->{dbh};
4165
4166 my %args = @_;
4167
4168 $args{revrec} = 'n' if !$args{revrec};
4169 $args{defrec} = 'n' if !$args{defrec};
4170
4171 # protection against bad or missing arguments
4172 $args{sortorder} = 'ASC' if !$args{sortorder} || !grep /^$args{sortorder}$/, ('ASC','DESC');
4173 my $defsort;
4174 $defsort = 'host' if $args{revrec} eq 'n'; # default sort by host on domain record list
4175 $defsort = 'val' if $args{revrec} eq 'y'; # default sort by IP on revzone record list
4176 $args{sortby} = '' if !$args{sortby};
4177 $args{sortby} = $defsort if !$args{revrec};
4178 $args{sortby} = $defsort if $args{sortby} !~ /^[\w_,.]+$/;
4179 $args{offset} = 0 if !$args{offset} || $args{offset} !~ /^(?:all|\d+)$/;
4180 my $perpage = ($args{nrecs} ? $args{nrecs} : $self->{perpage});
4181
4182
4183##fixme: do we need a knob to twist to switch from unix epoch to postgres time string?
4184 my @bindvars;
4185 my $sql = "SELECT r.record_id,";
4186 # only include the parent info if we don't already know which parent we're in
4187 $sql .= "r.domain_id,r.rdns_id," unless $args{id};
4188 $sql .= "r.host,r.type,r.val,r.ttl";
4189 $sql .= ",l.description AS locname,stamp,r.stamp < now() AS ispast,r.expires,r.stampactive"
4190 if $args{defrec} eq 'n';
4191 $sql .= ",r.distance,r.weight,r.port" if $args{revrec} eq 'n';
4192 $sql .= " FROM "._rectable($args{defrec},$args{revrec})." r ";
4193 $sql .= "INNER JOIN rectypes t ON r.type=t.val "; # for sorting by type alphabetically
4194 $sql .= "LEFT JOIN locations l ON r.location=l.location " if $args{defrec} eq 'n';
4195 $sql .= "WHERE NOT r.type=$reverse_typemap{SOA}";
4196
4197 # "normal" record list
4198 if ($args{id}) {
4199 $sql .= " AND "._recparent($args{defrec},$args{revrec})." = ?";
4200 push @bindvars, $args{id};
4201 }
4202
4203 # Filtering on host/val (mainly normal record list)
4204 if ($args{filter}) {
4205 $sql .= " AND (r.host ~* ? OR r.val ~* ? OR r.host ~* ? OR r.val ~* ?)";
4206 my $tmp = join('.',reverse(split(/\./,$args{filter})));
4207 push @bindvars, ($args{filter},$args{filter});
4208 push @bindvars, ($tmp, $tmp);
4209 }
4210
4211 # Filtering on other fields
4212 foreach (qw(type distance weight port ttl description)) {
4213 if ($args{$_}) {
4214 $sql .= " AND $_ ~* ?";
4215 push @bindvars, $args{$_};
4216 }
4217 }
4218
4219 # whee! multisort means just passing comma-separated fields in sortby!
4220 my $newsort = '';
4221 foreach my $sf (split /,/, $args{sortby}) {
4222 $sf = "r.$sf";
4223 # sort on IP, correctly
4224 $sf =~ s/r\.val/inetlazy(r.val)/;
4225# hmm. do we really need to limit this?
4226# if $args{revrec} eq 'y' && $args{defrec} eq 'n';
4227 $sf =~ s/r\.type/t.alphaorder/; # subtly different from sorting on rectypes.name
4228 $newsort .= ",$sf";
4229 }
4230 $newsort =~ s/^,//;
4231##enhance: pass in ascending/descending sort per-field
4232 $sql .= " ORDER BY $newsort $args{sortorder}";
4233 # ensure consistent ordering by sorting on record_id too
4234 $sql .= ", record_id $args{sortorder}";
4235
4236 # Offset/pagination
4237 $sql .= ($args{offset} eq 'all' ? '' : " LIMIT $perpage OFFSET ".$args{offset}*$perpage);
4238
4239 my @working;
4240 my $recsth = $dbh->prepare($sql);
4241 $recsth->execute(@bindvars);
4242 while (my $rec = $recsth->fetchrow_hashref) {
4243 if ($args{revrec} eq 'y' && $args{defrec} eq 'n' &&
4244 ($self->{showrev_arpa} eq 'record' || $self->{showrev_arpa} eq 'all') &&
4245 $rec->{val} !~ /\.arpa$/ ) {
4246 # skip all reverse zone .arpa "hostnames" since they're already .arpa names.
4247##enhance: extend {showrev_arpa} eq 'record' to specify record types
4248 my $tmpip = new NetAddr::IP $rec->{val} if $rec->{val} =~ /^(?:[\d.\/]+|[a-fA-F0-9:\/]+)$/;
4249 $rec->{val} = DNSDB::_ZONE($tmpip, 'ZONE', 'r', '.').($tmpip->{isv6} ? '.ip6.arpa' : '.in-addr.arpa') if $tmpip;
4250 }
4251 push @working, $rec;
4252 }
4253 return \@working;
4254} # end getRecList()
4255
4256
4257## DNSDB::getRecCount()
4258# Return count of non-SOA records in zone (or default records in a group)
4259# Takes a database handle, default/live flag, reverse/forward flag, group/domain ID,
4260# and optional filtering modifier
4261# Returns the count
4262sub getRecCount {
4263 my $self = shift;
4264 my $dbh = $self->{dbh};
4265
4266 my %args = @_;
4267
4268 $args{defrec} = 'n' if !$args{defrec};
4269 $args{revrec} = 'n' if !$args{revrec};
4270
4271 my @bindvars;
4272 my $sql = "SELECT count(*) FROM ".
4273 _rectable($args{defrec},$args{revrec}).
4274 " r WHERE NOT type=$reverse_typemap{SOA}";
4275 if ($args{id}) {
4276 $sql .= " AND "._recparent($args{defrec},$args{revrec})." = ?";
4277 push @bindvars, $args{id};
4278 }
4279
4280 # Filtering on host/val (mainly normal record list)
4281 if ($args{filter}) {
4282 $sql .= " AND (r.host ~* ? OR r.val ~* ? OR r.host ~* ? OR r.val ~* ?)";
4283 my $tmp = join('.',reverse(split(/\./,$args{filter})));
4284 push @bindvars, ($args{filter},$args{filter});
4285 push @bindvars, ($tmp, $tmp);
4286 }
4287
4288 # Filtering on other fields
4289 foreach (qw(type distance weight port ttl description)) {
4290 if ($args{$_}) {
4291 $sql .= " AND $_ ~* ?";
4292 push @bindvars, $args{$_};
4293 }
4294 }
4295
4296 my ($count) = $dbh->selectrow_array($sql, undef, (@bindvars) );
4297
4298 return $count;
4299
4300} # end getRecCount()
4301
4302
4303## DNSDB::addRec()
4304# Add a new record to a domain or a group's default records
4305# Takes a database handle, default/live flag, group/domain ID,
4306# host, type, value, and TTL
4307# Some types require additional detail: "distance" for MX and SRV,
4308# and weight/port for SRV
4309# Returns a status code and detail message in case of error
4310##fixme: pass a hash with the record data, not a series of separate values
4311sub addRec {
4312 $errstr = '';
4313 my $self = shift;
4314 my $dbh = $self->{dbh};
4315 my $defrec = shift;
4316 my $revrec = shift;
4317 my $id = shift; # parent (group_id for defrecs, rdns_id for reverse records,
4318 # domain_id for domain records)
4319
4320 my $host = shift;
4321 my $rectype = shift; # reference so we can coerce it if "+"-types can't find both zones
4322 my $val = shift;
4323 my $ttl = shift;
4324 my $location = shift;
4325 $location = '' if !$location;
4326
4327 my $expires = shift || '';
4328 $expires = 1 if $expires eq 'until'; # Turn some special values into the appropriate booleans.
4329 $expires = 0 if $expires eq 'after';
4330 my $stamp = shift;
4331 $stamp = '' if !$stamp; # Timestamp should be a string at this point.
4332
4333 # extra safety net - apparently RPC can squeak this by. O_o
4334 return ('FAIL', "host must contain a value") if !$$host;
4335 return ('FAIL', "val must contain a value") if !$$val;
4336
4337 # Spaces are evil.
4338 $$host =~ s/^\s+//;
4339 $$host =~ s/\s+$//;
4340 if ($typemap{$$rectype} ne 'TXT') {
4341 # Leading or trailng spaces could be legit in TXT records.
4342 $$val =~ s/^\s+//;
4343 $$val =~ s/\s+$//;
4344 }
4345
4346 _caseclean($rectype, $host, $val, $defrec, $revrec) if $self->{lowercase};
4347
4348 # prep for validation
4349 my $addr = NetAddr::IP->new($$val) if _maybeip($val);
4350 $$host =~ s/\.+$//; # FQDNs ending in . are an internal detail, and really shouldn't be exposed in the UI.
4351
4352 my $domid = 0;
4353 my $revid = 0;
4354
4355 my $retcode = 'OK'; # assume everything will go OK
4356 my $retmsg = '';
4357
4358 # do simple validation first
4359 return ('FAIL', "TTL must be numeric") unless $ttl =~ /^-?\d+$/;
4360
4361 # Collect these even if we're only doing a simple A record so we can call *any* validation sub
4362 my $dist = shift;
4363 my $weight = shift;
4364 my $port = shift;
4365
4366 my $fields;
4367 my @vallist;
4368
4369 # Call the validation sub for the type requested.
4370 ($retcode,$retmsg) = $validators{$$rectype}($self, defrec => $defrec, revrec => $revrec, id => $id,
4371 host => $host, rectype => $rectype, val => $val, addr => $addr,
4372 dist => \$dist, port => \$port, weight => \$weight,
4373 fields => \$fields, vallist => \@vallist);
4374
4375 return ($retcode,$retmsg) if $retcode eq 'FAIL';
4376
4377 # Set up database fields and bind parameters
4378 $fields .= "host,type,val,ttl,"._recparent($defrec,$revrec);
4379 push @vallist, ($$host,$$rectype,$$val,$ttl,$id);
4380
4381 if ($defrec eq 'n') {
4382 # locations are not for default records, silly coder!
4383 $fields .= ",location";
4384 push @vallist, $location;
4385 # timestamps are rare.
4386 if ($stamp) {
4387 $fields .= ",stamp,expires,stampactive";
4388 push @vallist, $stamp, $expires, 'y';
4389 } else {
4390 $fields .= ",stampactive";
4391 push @vallist, 'n';
4392 }
4393 }
4394
4395 # a little magic to get the right number of ? placeholders based on how many values we're providing
4396 my $vallen = '?'.(',?'x$#vallist);
4397
4398 # Put together the success log entry. We have to use this horrible kludge
4399 # because domain_id and rdns_id may or may not be present, and if they are,
4400 # they're not at a guaranteed consistent index in the array. wheee!
4401 my %logdata;
4402 my @ftmp = split /,/, $fields;
4403 for (my $i=0; $i <= $#vallist; $i++) {
4404 $logdata{domain_id} = $vallist[$i] if $ftmp[$i] eq 'domain_id';
4405 $logdata{rdns_id} = $vallist[$i] if $ftmp[$i] eq 'rdns_id';
4406 }
4407 $logdata{group_id} = $id if $defrec eq 'y';
4408 $logdata{group_id} = $self->parentID(id => $id, type => ($revrec eq 'n' ? 'domain' : 'revzone'), revrec => $revrec)
4409 if $defrec eq 'n';
4410 $logdata{entry} = "Added ".($defrec eq 'y' ? 'default record' : 'record');
4411 # Log reverse records to match the formal .arpa tree
4412 if ($revrec eq 'y') {
4413 $logdata{entry} .= " '$$val $typemap{$$rectype} $$host";
4414 } else {
4415 $logdata{entry} .= " '$$host $typemap{$$rectype} $$val";
4416 }
4417
4418 $logdata{entry} .= " [distance $dist]" if $typemap{$$rectype} eq 'MX';
4419 $logdata{entry} .= " [priority $dist] [weight $weight] [port $port]"
4420 if $typemap{$$rectype} eq 'SRV';
4421 $logdata{entry} .= "', TTL $ttl";
4422 $logdata{entry} .= ", location ".$self->getLoc($location)->{description} if $location;
4423 $logdata{entry} .= ($expires ? ', expires at ' : ', valid after ').$stamp if $stamp;
4424
4425 # Allow transactions, and raise an exception on errors so we can catch it later.
4426 # Use local to make sure these get "reset" properly on exiting this block
4427 local $dbh->{AutoCommit} = 0;
4428 local $dbh->{RaiseError} = 1;
4429
4430 eval {
4431 $dbh->do("INSERT INTO "._rectable($defrec, $revrec)." ($fields) VALUES ($vallen)",
4432 undef, @vallist);
4433 $self->_log(%logdata);
4434 $dbh->commit;
4435 };
4436 if ($@) {
4437 my $msg = $@;
4438 eval { $dbh->rollback; };
4439 if ($self->{log_failures}) {
4440 $logdata{entry} = "Failed adding ".($defrec eq 'y' ? 'default ' : '').
4441 "record '$$host $typemap{$$rectype} $$val', TTL $ttl ($msg)";
4442 $self->_log(%logdata);
4443 $dbh->commit;
4444 }
4445 return ('FAIL',$msg);
4446 }
4447
4448 $resultstr = $logdata{entry};
4449 return ($retcode, $retmsg);
4450
4451} # end addRec()
4452
4453
4454## DNSDB::updateRec()
4455# Update a record
4456# Takes a database handle, default and reverse flags, record ID, immediate parent ID, and new record data.
4457# Returns a status code and message
4458sub updateRec {
4459 $errstr = '';
4460
4461 my $self = shift;
4462 my $dbh = $self->{dbh};
4463
4464 my $defrec = shift;
4465 my $revrec = shift;
4466 my $id = shift;
4467 my $parid = shift; # immediate parent entity that we're descending from to update the record
4468
4469 # all records have these
4470 my $host = shift;
4471 my $hostbk = $$host; # Keep a backup copy of the original, so we can WARN if the update mangles the domain
4472 my $rectype = shift;
4473 my $val = shift;
4474 my $ttl = shift;
4475 my $location = shift; # may be empty/null/undef depending on caller
4476 $location = '' if !$location;
4477
4478 my $expires = shift || '';
4479 $expires = 1 if $expires eq 'until'; # Turn some special values into the appropriate booleans.
4480 $expires = 0 if $expires eq 'after';
4481 my $stamp = shift;
4482 $stamp = '' if !$stamp; # Timestamp should be a string at this point.
4483
4484 # just set it to an empty string; failures will be caught later.
4485 $$host = '' if !$$host;
4486
4487 # Spaces are evil.
4488 $$host =~ s/^\s+//;
4489 $$host =~ s/\s+$//;
4490 if ($typemap{$$rectype} ne 'TXT') {
4491 # Leading or trailng spaces could be legit in TXT records.
4492 $$val =~ s/^\s+//;
4493 $$val =~ s/\s+$//;
4494 }
4495
4496 _caseclean($rectype, $host, $val, $defrec, $revrec) if $self->{lowercase};
4497
4498 # prep for validation
4499 my $addr = NetAddr::IP->new($$val) if _maybeip($val);
4500 $$host =~ s/\.+$//; # FQDNs ending in . are an internal detail, and really shouldn't be exposed in the UI.
4501
4502 my $domid = 0;
4503 my $revid = 0;
4504
4505 my $retcode = 'OK'; # assume everything will go OK
4506 my $retmsg = '';
4507
4508 # do simple validation first
4509 return ('FAIL', "TTL must be numeric") unless $ttl =~ /^-?\d+$/;
4510
4511 # only MX and SRV will use these
4512 my $dist = shift || 0;
4513 my $weight = shift || 0;
4514 my $port = shift || 0;
4515
4516 my $fields;
4517 my @vallist;
4518
4519 # get old record data so we have the right parent ID
4520 # and for logging (eventually)
4521 my $oldrec = $self->getRecLine($defrec, $revrec, $id);
4522
4523 # Call the validation sub for the type requested.
4524 # Note the ID to pass here is the *parent*, not the record
4525 ($retcode,$retmsg) = $validators{$$rectype}($self, defrec => $defrec, revrec => $revrec,
4526 id => ($defrec eq 'y' ? $oldrec->{group_id} : ($revrec eq 'n' ? $oldrec->{domain_id} : $oldrec->{rdns_id})),
4527 host => $host, rectype => $rectype, val => $val, addr => $addr,
4528 dist => \$dist, port => \$port, weight => \$weight,
4529 fields => \$fields, vallist => \@vallist,
4530 update => $id);
4531
4532 return ($retcode,$retmsg) if $retcode eq 'FAIL';
4533
4534 # Set up database fields and bind parameters. Note only the optional fields
4535 # (distance, weight, port, secondary parent ID) are added in the validation call above
4536 $fields .= "host,type,val,ttl,"._recparent($defrec,$revrec);
4537 push @vallist, ($$host,$$rectype,$$val,$ttl,
4538 ($defrec eq 'y' ? $oldrec->{group_id} : ($revrec eq 'n' ? $oldrec->{domain_id} : $oldrec->{rdns_id})) );
4539
4540 if ($defrec eq 'n') {
4541 # locations are not for default records, silly coder!
4542 $fields .= ",location";
4543 push @vallist, $location;
4544 # timestamps are rare.
4545 if ($stamp) {
4546 $fields .= ",stamp,expires,stampactive";
4547 push @vallist, $stamp, $expires, 'y';
4548 } else {
4549 $fields .= ",stampactive";
4550 push @vallist, 'n';
4551 }
4552 }
4553
4554 # hack hack PTHUI
4555 # need to forcibly make sure we disassociate a record with a parent it's no longer related to.
4556 # eg, PTR records may not have a domain parent, or A/AAAA records may not have a revzone parent.
4557 # needed for crossover types that got coerced down to "standard" types due to data changes
4558 # need to *avoid* funky records being updated like A/AAAA records in revzones, or PTRs in forward zones.
4559 if ($defrec eq 'n' && $oldrec->{type} > 65000) {
4560 if ($$rectype == $reverse_typemap{PTR}) {
4561 $fields .= ",domain_id";
4562 push @vallist, 0;
4563 }
4564 if ($$rectype == $reverse_typemap{A} || $$rectype == $reverse_typemap{AAAA}) {
4565 $fields .= ",rdns_id";
4566 push @vallist, 0;
4567 }
4568 }
4569 # fix fat-finger-originated record type changes
4570 if ($$rectype == 65285) { # delegation
4571 $fields .= ",rdns_id" if $revrec eq 'n';
4572 $fields .= ",domain_id" if $revrec eq 'y';
4573 push @vallist, 0;
4574 }
4575 # ... and now make sure we *do* associate a record with the "calling" parent
4576 if ($defrec eq 'n') {
4577 $domid = $parid if $revrec eq 'n';
4578 $revid = $parid if $revrec eq 'y';
4579 }
4580
4581 # Put together the success log entry. Horrible kludge from addRec() copied as-is since
4582 # we don't know whether the passed arguments or retrieved values for domain_id and rdns_id
4583 # will be maintained (due to "not-in-zone" validation changes)
4584 my %logdata;
4585 $logdata{domain_id} = $domid;
4586 $logdata{rdns_id} = $revid;
4587 my @ftmp = split /,/, $fields;
4588 for (my $i=0; $i <= $#vallist; $i++) {
4589 $logdata{domain_id} = $vallist[$i] if $ftmp[$i] eq 'domain_id';
4590 $logdata{rdns_id} = $vallist[$i] if $ftmp[$i] eq 'rdns_id';
4591 }
4592 $logdata{group_id} = $parid if $defrec eq 'y';
4593 $logdata{group_id} = $self->parentID(id => $parid, type => ($revrec eq 'n' ? 'domain' : 'revzone'), revrec => $revrec)
4594 if $defrec eq 'n';
4595 $logdata{entry} = "Updated ".($defrec eq 'y' ? 'default record' : 'record')." from\n";
4596 # Log reverse records "naturally", since they're stored, um, unnaturally.
4597 if ($revrec eq 'y') {
4598 $logdata{entry} .= " '$oldrec->{val} $typemap{$oldrec->{type}} $oldrec->{host}";
4599 } else {
4600 $logdata{entry} .= " '$oldrec->{host} $typemap{$oldrec->{type}} $oldrec->{val}";
4601 }
4602 $logdata{entry} .= " [distance $oldrec->{distance}]" if $typemap{$oldrec->{type}} eq 'MX';
4603 $logdata{entry} .= " [priority $oldrec->{distance}] [weight $oldrec->{weight}] [port $oldrec->{port}]"
4604 if $typemap{$oldrec->{type}} eq 'SRV';
4605 $logdata{entry} .= "', TTL $oldrec->{ttl}";
4606 $logdata{entry} .= ", location ".$self->getLoc($oldrec->{location})->{description} if $oldrec->{location};
4607 $logdata{entry} .= ($oldrec->{expires} ? ', expires at ' : ', valid after ').$oldrec->{stamp}
4608 if $oldrec->{stampactive};
4609 $logdata{entry} .= "\nto\n";
4610 # Log reverse records "naturally", since they're stored, um, unnaturally.
4611 if ($revrec eq 'y') {
4612 $logdata{entry} .= "'$$val $typemap{$$rectype} $$host";
4613 } else {
4614 $logdata{entry} .= "'$$host $typemap{$$rectype} $$val";
4615 }
4616 $logdata{entry} .= " [distance $dist]" if $typemap{$$rectype} eq 'MX';
4617 $logdata{entry} .= " [priority $dist] [weight $weight] [port $port]" if $typemap{$$rectype} eq 'SRV';
4618 $logdata{entry} .= "', TTL $ttl";
4619 $logdata{entry} .= ", location ".$self->getLoc($location)->{description} if $location;
4620 $logdata{entry} .= ($expires ? ', expires at ' : ', valid after ').$stamp if $stamp;
4621
4622 local $dbh->{AutoCommit} = 0;
4623 local $dbh->{RaiseError} = 1;
4624
4625 # Fiddle the field list into something suitable for updates
4626 $fields =~ s/,/=?,/g;
4627 $fields .= "=?";
4628
4629 eval {
4630 $dbh->do("UPDATE "._rectable($defrec,$revrec)." SET $fields WHERE record_id=?", undef, (@vallist, $id) );
4631 $self->_log(%logdata);
4632 $dbh->commit;
4633 };
4634 if ($@) {
4635 my $msg = $@;
4636 eval { $dbh->rollback; };
4637 if ($self->{log_failures}) {
4638 $logdata{entry} = "Failed updating ".($defrec eq 'y' ? 'default ' : '').
4639 "record '$oldrec->{host} $typemap{$oldrec->{type}} $oldrec->{val}', TTL $oldrec->{ttl} ($msg)";
4640 $self->_log(%logdata);
4641 $dbh->commit;
4642 }
4643 return ('FAIL', $msg);
4644 }
4645
4646 $resultstr = $logdata{entry};
4647 return ($retcode, $retmsg);
4648} # end updateRec()
4649
4650
4651## DNSDB::downconvert()
4652# A mostly internal (not exported) semiutilty sub to downconvert from pseudotype <x>
4653# to a compatible component type. Only a handful of operations are valid, anything
4654# else is a null-op.
4655# Takes the record ID and the new type. Returns boolean.
4656sub downconvert {
4657 my $self = shift;
4658 my $dbh = $self->{dbh};
4659 my $recid = shift;
4660 my $newtype = shift;
4661
4662 # also, only work on live records; little to no value trying to do this on default records.
4663 my $rec = $self->getRecLine('n', 'y', $recid);
4664
4665 # hm?
4666 #return 1 if !$rec;
4667
4668 return 1 if $rec->{type} < 65000; # Only the reverse-record pseudotypes can be downconverted
4669 return 1 if $rec->{type} == 65282; # Nowhere to go
4670
4671 my $delpar;
4672 my @sqlargs;
4673 if ($rec->{type} == 65280) {
4674 return 1 if $newtype != 1 && $newtype != 12;
4675 $delpar = ($newtype == 1 ? 'rdns_id' : 'domain_id');
4676 push @sqlargs, 0, $newtype, $recid;
4677 } elsif ($rec->{type} == 65281) {
4678 return 1 if $newtype != 28 && $newtype != 12;
4679 $delpar = ($newtype == 28 ? 'rdns_id' : 'domain_id');
4680 push @sqlargs, 0, $newtype, $recid;
4681 } elsif ($rec->{type} == 65283) {
4682 return 1 if $newtype != 65282;
4683 $delpar = 'rdns_id';
4684 } elsif ($rec->{type} == 65284) {
4685 return 1 if $newtype != 65282;
4686 $delpar = 'rdns_id';
4687 } else {
4688 # Your llama is on fire.
4689 }
4690
4691 local $dbh->{AutoCommit} = 0;
4692 local $dbh->{RaiseError} = 1;
4693
4694 eval {
4695 $dbh->do("UPDATE records SET $delpar = ?, type = ? WHERE record_id = ?", undef, @sqlargs);
4696 $self->_log(domain_id => $rec->{domain_id}, rdns_id => $rec->{rdns_id},
4697 group_id => $self->parentID(id => $rec->{rdns_id}, type => 'revzone', revrec => 'y'),
4698 entry => "'$rec->{host} $typemap{$rec->{type}} $rec->{val}' downconverted to ".
4699 "'$rec->{host} $typemap{$newtype} $rec->{val}'");
4700 $dbh->commit;
4701 };
4702 if ($@) {
4703 $errstr = $@;
4704 eval { $dbh->rollback; };
4705 return 0;
4706 }
4707 return 1;
4708} # end downconvert()
4709
4710
4711## DNSDB::delRec()
4712# Delete a record.
4713# Takes a default/live flag, forward/reverse flag, and the ID of the record to delete.
4714sub delRec {
4715 $errstr = '';
4716 my $self = shift;
4717 my $dbh = $self->{dbh};
4718 my $defrec = shift;
4719 my $revrec = shift;
4720 my $id = shift;
4721
4722 my $oldrec = $self->getRecLine($defrec, $revrec, $id);
4723
4724 # Allow transactions, and raise an exception on errors so we can catch it later.
4725 # Use local to make sure these get "reset" properly on exiting this block
4726 local $dbh->{AutoCommit} = 0;
4727 local $dbh->{RaiseError} = 1;
4728
4729 # Put together the log entry
4730 my %logdata;
4731 $logdata{domain_id} = $oldrec->{domain_id};
4732 $logdata{rdns_id} = $oldrec->{rdns_id};
4733 $logdata{group_id} = $oldrec->{group_id} if $defrec eq 'y';
4734 $logdata{group_id} = $self->parentID(id => ($revrec eq 'n' ? $oldrec->{domain_id} : $oldrec->{rdns_id}),
4735 type => 'domain', revrec => $revrec)
4736 if $defrec eq 'n';
4737 $logdata{entry} = "Deleted ".($defrec eq 'y' ? 'default record ' : 'record ').
4738 "'$oldrec->{host} $typemap{$oldrec->{type}} $oldrec->{val}";
4739 $logdata{entry} .= " [distance $oldrec->{distance}]" if $typemap{$oldrec->{type}} eq 'MX';
4740 $logdata{entry} .= " [priority $oldrec->{distance}] [weight $oldrec->{weight}] [port $oldrec->{port}]"
4741 if $typemap{$oldrec->{type}} eq 'SRV';
4742 $logdata{entry} .= "', TTL $oldrec->{ttl}";
4743 $logdata{entry} .= ", location ".$self->getLoc($oldrec->{location})->{description} if $oldrec->{location};
4744
4745 eval {
4746 my $sth = $dbh->do("DELETE FROM "._rectable($defrec,$revrec)." WHERE record_id=?", undef, ($id));
4747 $self->_log(%logdata);
4748 $dbh->commit;
4749 };
4750 if ($@) {
4751 my $msg = $@;
4752 eval { $dbh->rollback; };
4753 if ($self->{log_failures}) {
4754 $logdata{entry} = "Error deleting ".($defrec eq 'y' ? 'default record' : 'record').
4755 " '$oldrec->{host} $typemap{$oldrec->{type}} $oldrec->{val}', TTL $oldrec->{ttl} ($msg)";
4756 $self->_log(%logdata);
4757 $dbh->commit;
4758 }
4759 return ('FAIL', $msg);
4760 }
4761
4762 return ('OK',$logdata{entry});
4763} # end delRec()
4764
4765
4766## DNSDB::getLogCount()
4767# Get a count of log entries
4768# Takes a database handle and a hash containing at least:
4769# - Entity ID and entity type as the primary log "slice"
4770sub getLogCount {
4771 my $self = shift;
4772 my $dbh = $self->{dbh};
4773
4774 my %args = @_;
4775
4776 my @filterargs;
4777##fixme: which fields do we want to filter on?
4778# push @filterargs,
4779
4780 $errstr = 'Missing primary parent ID and/or type';
4781 # fail early if we don't have a "prime" ID to look for log entries for
4782 return if !$args{id};
4783
4784 # or if the prime id type is missing or invalid
4785 return if !$args{logtype};
4786 $args{logtype} = 'revzone' if $args{logtype} eq 'rdns'; # hack pthui
4787 $args{logtype} = 'domain' if $args{logtype} eq 'dom'; # hack pthui
4788 return if !grep /^$args{logtype}$/, ('group', 'domain', 'revzone', 'user');
4789
4790 my $sql = "SELECT count(*) FROM log ".
4791 "WHERE $id_col{$args{logtype}}=?".
4792 ($args{filter} ? " AND entry ~* ?" : '');
4793 my ($count) = $dbh->selectrow_array($sql, undef, ($args{id}, @filterargs) );
4794 $errstr = $dbh->errstr if !$count;
4795 return $count;
4796} # end getLogCount()
4797
4798
4799## DNSDB::getLogEntries()
4800# Get a list of log entries
4801# Takes arguments as with getLogCount() above, plus optional:
4802# - sort field
4803# - sort order
4804# - offset for pagination
4805sub getLogEntries {
4806 my $self = shift;
4807 my $dbh = $self->{dbh};
4808
4809 my %args = @_;
4810
4811 my @filterargs;
4812
4813 # fail early if we don't have a "prime" ID to look for log entries for
4814 return if !$args{id};
4815
4816 # or if the prime id type is missing or invalid
4817 return if !$args{logtype};
4818 $args{logtype} = 'revzone' if $args{logtype} eq 'rdns'; # hack pthui
4819 $args{logtype} = 'domain' if $args{logtype} eq 'dom'; # hack pthui
4820 return if !grep /^$args{logtype}$/, ('group', 'domain', 'revzone', 'user');
4821
4822 # Sorting defaults
4823 $args{sortorder} = 'DESC' if !$args{sortorder} || !grep /^$args{sortorder}$/, ('ASC','DESC');
4824 $args{sortby} = 'stamp' if !$args{sortby} || $args{sortby} !~ /^[\w_.]+$/;
4825 $args{offset} = 0 if !$args{offset} || $args{offset} !~ /^(?:all|\d+)$/;
4826
4827 my %sortmap = (fname => 'name', username => 'email', entry => 'entry', stamp => 'stamp');
4828 $args{sortby} = $sortmap{$args{sortby}};
4829
4830 my $sql = "SELECT user_id AS userid, email AS useremail, name AS userfname, entry AS logentry, ".
4831 "date_trunc('second',stamp) AS logtime ".
4832 "FROM log ".
4833 "WHERE $id_col{$args{logtype}}=?".
4834 ($args{filter} ? " AND entry ~* ?" : '').
4835 " ORDER BY $args{sortby} $args{sortorder}, log_id $args{sortorder}".
4836 ($args{offset} eq 'all' ? '' : " LIMIT $self->{perpage} OFFSET ".$args{offset}*$self->{perpage});
4837 my $loglist = $dbh->selectall_arrayref($sql, { Slice => {} }, ($args{id}, @filterargs) );
4838 $errstr = $dbh->errstr if !$loglist;
4839 return $loglist;
4840} # end getLogEntries()
4841
4842
4843## DNSDB::getRevPattern()
4844# Get the narrowest template pattern applicable to a passed CIDR address (may be a netblock or an IP)
4845sub getRevPattern {
4846 my $self = shift;
4847 my $dbh = $self->{dbh};
4848 my $cidr = shift;
4849 my %args = @_;
4850 $args{group} = 1 if !$args{group}; # just in case
4851 $args{location} = '' if !$args{location};
4852
4853 # for speed! Casting and comparing even ~7K records takes ~2.5s, so narrow it down to one revzone first.
4854 my ($revid) = $dbh->selectrow_array("SELECT rdns_id FROM revzones WHERE revnet >>= ?",
4855 undef, ($cidr) );
4856
4857##fixme? may need to narrow things down more by octet-chopping and doing text comparisons before casting.
4858 my ($revpatt) = $dbh->selectrow_array("SELECT host FROM records ".
4859 "WHERE (type in (12,65280,65281,65282,65283,65284)) AND rdns_id = ? ".
4860 "AND location = ? AND inetlazy(val) >>= ? ".
4861 "ORDER BY inetlazy(val) DESC LIMIT 1",
4862 undef, ($revid, $args{location}, $cidr) );
4863
4864 return $revpatt;
4865} # end getRevPattern()
4866
4867
4868## DNSDB::getRevSet()
4869# Return the unique per-IP reverse hostnames, if any, for the passed
4870# CIDR address (may be a netblock or an IP)
4871sub getRevSet {
4872 my $self = shift;
4873 my $dbh = $self->{dbh};
4874 my $cidr = shift;
4875 my %args = @_;
4876 $args{group} = 1 if !$args{group}; # just in case
4877 $args{location} = '' if !$args{location};
4878
4879 # for speed! Casting and comparing even ~7K records takes ~2.5s, so narrow it down to one revzone first.
4880 my ($revid) = $dbh->selectrow_array("SELECT rdns_id FROM revzones WHERE revnet >>= ?",
4881 undef, ($cidr) );
4882
4883 $cidr = new NetAddr::IP $cidr;
4884 if ($cidr->num > 256) { # should also catch v6!
4885 # Even reverse entries for a v4 /24 of IPs is a bit much. I don't expect
4886 # there to be a sane reason to retrive more than a /27 at once, really.
4887 # v6 is going to be hairy no matter how you slice it.
4888 $errstr = "Reverse hostname detail range too large";
4889 return;
4890 }
4891
4892 my $sth = $dbh->prepare("SELECT val, host FROM records ".
4893 "WHERE (type in (12,65280,65281,65282,65283,65284)) AND rdns_id = ? AND location = ? AND inetlazy(val) = ?");
4894
4895 my @ret;
4896 foreach my $ip (@{$cidr->splitref()}) {
4897 $sth->execute($revid, $args{location}, $ip);
4898 my @data = $sth->fetchrow_array();
4899 my %row;
4900 if (@data) {
4901 $row{r_ip} = $data[0];
4902 $row{iphost} = $data[1];
4903 } else {
4904 $row{r_ip} = $ip->addr;
4905 $row{iphost} = '';
4906 }
4907 push @ret, \%row;
4908 }
4909
4910 return \@ret;
4911} # end getRevSet()
4912
4913
4914## DNSDB::getTypelist()
4915# Get a list of record types for various UI dropdowns
4916# Takes database handle, forward/reverse/lookup flag, and optional "tag as selected" indicator (defaults to A)
4917# Returns an arrayref to list of hashrefs perfect for HTML::Template
4918sub getTypelist {
4919 my $self = shift;
4920 my $dbh = $self->{dbh};
4921 my $recgroup = shift;
4922 my $type = shift || $reverse_typemap{A};
4923
4924 # also accepting $webvar{revrec}!
4925 $recgroup = 'f' if $recgroup eq 'n';
4926 $recgroup = 'r' if $recgroup eq 'y';
4927
4928 my $sql = "SELECT val,name FROM rectypes WHERE ";
4929 if ($recgroup eq 'r') {
4930 # reverse zone types
4931 $sql .= "stdflag=2 OR stdflag=3";
4932 } elsif ($recgroup eq 'l') {
4933 # DNS lookup types. Note we avoid our custom types >= 65280, since those are entirely internal.
4934 $sql .= "(stdflag=1 OR stdflag=2 OR stdflag=3) AND val < 65280";
4935 } else {
4936 # default; forward zone types. technically $type eq 'f' but not worth the error message.
4937 $sql .= "stdflag=1 OR stdflag=2";
4938 $sql .= " AND val < 65280" if $recgroup eq 'fo'; # An extra flag to trim off the pseudotypes as well.
4939 }
4940 $sql .= " ORDER BY listorder";
4941
4942 my $sth = $dbh->prepare($sql);
4943 $sth->execute;
4944 my @typelist;
4945 # track whether the passed type is in the list at all. allows you to edit a record
4946 # that wouldn't otherwise be generally available in that zone (typically, reverse zones)
4947 # without changing its type (accidentally or otherwise)
4948 my $selflag = 0;
4949 while (my ($rval,$rname) = $sth->fetchrow_array()) {
4950 my %row = ( recval => $rval, recname => $rname );
4951 if ($rval == $type) {
4952 $row{tselect} = 1;
4953 $selflag = 1;
4954 }
4955 push @typelist, \%row;
4956 }
4957
4958 # add the passed type if it wasn't in the list
4959 if (!$selflag) {
4960 my %row = ( recval => $type, recname => $typemap{$type}, tselect => 1 );
4961 push @typelist, \%row;
4962 }
4963
4964 # Add SOA on lookups since it's not listed in other dropdowns.
4965 if ($recgroup eq 'l') {
4966 my %row = ( recval => $reverse_typemap{SOA}, recname => 'SOA' );
4967 $row{tselect} = 1 if $reverse_typemap{SOA} == $type;
4968 push @typelist, \%row;
4969 }
4970
4971 return \@typelist;
4972} # end getTypelist()
4973
4974
4975## DNSDB::parentID()
4976# Get ID of entity that is nearest parent to requested id
4977# Takes a database handle and a hash of entity ID, entity type, optional parent type flag
4978# (domain/reverse zone or group), and optional default/live and forward/reverse flags
4979# Returns the ID or undef on failure
4980sub parentID {
4981 my $self = shift;
4982 my $dbh = $self->{dbh};
4983
4984 my %args = @_;
4985
4986 # clean up defrec and revrec. default to live record, forward zone
4987 $args{defrec} = 'n' if !$args{defrec};
4988 $args{revrec} = 'n' if !$args{revrec};
4989
4990 # clean up the parent-type. Set it to group if not set
4991 $args{partype} = 'group' if !$args{partype};
4992
4993 # allow callers to be lazy with type
4994 $args{type} = 'revzone' if $args{type} eq 'domain' && $args{revrec} eq 'y';
4995
4996 if ($par_type{$args{partype}} eq 'domain' || $par_type{$args{partype}} eq 'revzone') {
4997 # only live records can have a domain/zone parent
4998 return unless ($args{type} eq 'record' && $args{defrec} eq 'n');
4999 my $result = $dbh->selectrow_hashref("SELECT ".($args{revrec} eq 'n' ? 'domain_id' : 'rdns_id').
5000 " FROM records WHERE record_id = ?",
5001 undef, ($args{id}) ) or return;
5002 return $result;
5003 } else {
5004 # snag some arguments that will either fall through or be overwritten to save some code duplication
5005 my $tmpid = $args{id};
5006 my $type = $args{type};
5007 if ($type eq 'record' && $args{defrec} eq 'n') {
5008 # Live records go through the records table first.
5009 ($tmpid) = $dbh->selectrow_array("SELECT ".($args{revrec} eq 'n' ? 'domain_id' : 'rdns_id').
5010 " FROM records WHERE record_id = ?",
5011 undef, ($args{id}) ) or return;
5012 $type = ($args{revrec} eq 'n' ? 'domain' : 'revzone');
5013 }
5014 my ($result) = $dbh->selectrow_array("SELECT $par_col{$type} FROM $par_tbl{$type} WHERE $id_col{$type} = ?",
5015 undef, ($tmpid) );
5016 return $result;
5017 }
5018# should be impossible to get here with even remotely sane arguments
5019 return;
5020} # end parentID()
5021
5022
5023## DNSDB::isParent()
5024# Returns true if $id1 is a parent of $id2, false otherwise
5025sub isParent {
5026 my $self = shift;
5027 my $dbh = $self->{dbh};
5028 my $id1 = shift;
5029 my $type1 = shift;
5030 my $id2 = shift;
5031 my $type2 = shift;
5032##todo: immediate, secondary, full (default)
5033
5034 # Return false on invalid types
5035 return 0 if !grep /^$type1$/, ('record','defrec','defrevrec','user','domain','revzone','group');
5036 return 0 if !grep /^$type2$/, ('record','defrec','defrevrec','user','domain','revzone','group');
5037
5038 # Return false on impossible relations
5039 return 0 if $type1 eq 'record'; # nothing may be a child of a record
5040 return 0 if $type1 eq 'defrec'; # nothing may be a child of a record
5041 return 0 if $type1 eq 'defrevrec'; # nothing may be a child of a record
5042 return 0 if $type1 eq 'user'; # nothing may be child of a user
5043 return 0 if $type1 eq 'domain' && $type2 ne 'record'; # domain may not be a parent of anything other than a record
5044 return 0 if $type1 eq 'revzone' && $type2 ne 'record';# reverse zone may not be a parent of anything other than a record
5045
5046 # ennnhhhh.... if we're passed an id of 0, it will never be found. usual
5047 # case would be the UI creating a new <thing>, and so we don't have an ID for
5048 # <thing> to look up yet. in that case the UI should check the parent as well.
5049 return 0 if $id1 == 0; # nothing can have a parent id of 0
5050 return 1 if $id2 == 0; # anything could have a child id of 0 (or "unknown")
5051
5052 # group 1 is the ultimate root parent
5053 return 1 if $type1 eq 'group' && $id1 == 1;
5054
5055 # groups are always (a) parent of themselves
5056 return 1 if $type1 eq 'group' && $type2 eq 'group' && $id1 == $id2;
5057
5058 my $id = $id2;
5059 my $type = $type2;
5060 my $foundparent = 0;
5061
5062 # Records are the only entity with two possible parents. We need to split the parent checks on
5063 # domain/rdns.
5064 if ($type eq 'record') {
5065 my ($dom,$rdns) = $dbh->selectrow_array("SELECT domain_id,rdns_id FROM records WHERE record_id=?",
5066 undef, ($id));
5067 # check immediate parent against request
5068 return 1 if $type1 eq 'domain' && $id1 == $dom;
5069 return 1 if $type1 eq 'revzone' && $id1 == $rdns;
5070 # if request is group, check *both* parents. Only check if the parent is nonzero though.
5071 return 1 if $dom && $self->isParent($id1, $type1, $dom, 'domain');
5072 return 1 if $rdns && $self->isParent($id1, $type1, $rdns, 'revzone');
5073 # exit here since we've executed the loop below by proxy in the above recursive calls.
5074 return 0;
5075 }
5076
5077# almost the same loop as getParents() above
5078 my $limiter = 0;
5079 while (1) {
5080 my $sql = "SELECT $par_col{$type} FROM $par_tbl{$type} WHERE $id_col{$type} = ?";
5081 my $result = $dbh->selectrow_hashref($sql,
5082 undef, ($id) );
5083 if (!$result) {
5084 $limiter++;
5085##fixme: how often will this happen on a live site? fail at max limiter <n>?
5086# 2013/10/22 only seems to happen when you request an entity that doesn't exist.
5087 warn "no results looking for $sql with id $id (depth $limiter)\n";
5088 last;
5089 }
5090 if ($result && $result->{$par_col{$type}} == $id1) {
5091 $foundparent = 1;
5092 last;
5093 } else {
5094##fixme: do we care about trying to return a "no such record/domain/user/group" error?
5095# should be impossible to create an inconsistent DB just with API calls.
5096 warn $dbh->errstr." $sql, $id" if $dbh->errstr;
5097 }
5098 # group 1 is its own parent. need this here more to break strange loops than for detecting a parent
5099 last if $result->{$par_col{$type}} == 1;
5100 $id = $result->{$par_col{$type}};
5101 $type = $par_type{$type};
5102 }
5103
5104 return $foundparent;
5105} # end isParent()
5106
5107
5108## DNSDB::zoneStatus()
5109# Returns and optionally sets a zone's status
5110# Takes a database handle, domain/revzone ID, forward/reverse flag, and optionally a status argument
5111# Returns status, or undef on errors.
5112sub zoneStatus {
5113 my $self = shift;
5114 my $dbh = $self->{dbh};
5115 my $id = shift;
5116 my $revrec = shift;
5117 my $newstatus = shift || 'mu';
5118
5119 return undef if $id !~ /^\d+$/;
5120
5121 # Allow transactions, and raise an exception on errors so we can catch it later.
5122 # Use local to make sure these get "reset" properly on exiting this block
5123 local $dbh->{AutoCommit} = 0;
5124 local $dbh->{RaiseError} = 1;
5125
5126 if ($newstatus ne 'mu') {
5127 # ooo, fun! let's see what we were passed for status
5128 eval {
5129 $newstatus = 0 if $newstatus eq 'domoff';
5130 $newstatus = 1 if $newstatus eq 'domon';
5131 $dbh->do("UPDATE ".($revrec eq 'n' ? 'domains' : 'revzones')." SET status=? WHERE ".
5132 ($revrec eq 'n' ? 'domain_id' : 'rdns_id')."=?", undef, ($newstatus,$id) );
5133
5134##fixme switch to more consise "Enabled <domain"/"Disabled <domain>" as with users?
5135 $resultstr = "Changed ".($revrec eq 'n' ? $self->domainName($id) : $self->revName($id)).
5136 " state to ".($newstatus ? 'active' : 'inactive');
5137
5138 my %loghash;
5139 $loghash{domain_id} = $id if $revrec eq 'n';
5140 $loghash{rdns_id} = $id if $revrec eq 'y';
5141 $loghash{group_id} = $self->parentID(id => $id, type => ($revrec eq 'n' ? 'domain' : 'revzone'), revrec => $revrec);
5142 $loghash{entry} = $resultstr;
5143 $self->_log(%loghash);
5144
5145 $dbh->commit;
5146 };
5147 if ($@) {
5148 my $msg = $@;
5149 eval { $dbh->rollback; };
5150 $resultstr = '';
5151 $errstr = $msg;
5152 return;
5153 }
5154 }
5155
5156 my ($status) = $dbh->selectrow_array("SELECT status FROM ".
5157 ($revrec eq 'n' ? "domains WHERE domain_id=?" : "revzones WHERE rdns_id=?"),
5158 undef, ($id) );
5159 return $status;
5160} # end zoneStatus()
5161
5162
5163## DNSDB::getZonesByCIDR()
5164# Get a list of zone names and IDs that records for a passed CIDR block are within.
5165sub getZonesByCIDR {
5166 my $self = shift;
5167 my $dbh = $self->{dbh};
5168 my %args = @_;
5169
5170 my $result = $dbh->selectall_arrayref("SELECT rdns_id,revnet FROM revzones WHERE revnet >>= ? OR revnet <<= ?",
5171 { Slice => {} }, ($args{cidr}, $args{cidr}) );
5172 return $result;
5173} # end getZonesByCIDR()
5174
5175
5176## DNSDB::importAXFR
5177# Import a domain via AXFR
5178# Takes AXFR host, domain to transfer, group to put the domain in,
5179# and an optional hash containing:
5180# status - active/inactive state flag (defaults to active)
5181# rwsoa - overwrite-SOA flag (defaults to off)
5182# rwns - overwrite-NS flag (defaults to off, doesn't affect subdomain NS records)
5183# merge - flag to automerge A or AAAA records with matching PTR records
5184# Returns a status code (OK, WARN, or FAIL) and message - message should be blank
5185# if status is OK, but WARN includes conditions that are not fatal but should
5186# really be reported.
5187sub importAXFR {
5188 my $self = shift;
5189 my $dbh = $self->{dbh};
5190 my $ifrom_in = shift;
5191 my $zone = shift;
5192 my $group = shift;
5193
5194 my %args = @_;
5195
5196##fixme: add mode to delete&replace, merge+overwrite, merge new?
5197
5198 $args{status} = (defined($args{status}) ? $args{status} : 0);
5199 $args{status} = 1 if $args{status} eq 'on';
5200
5201 my $nrecs = 0;
5202 my $soaflag = 0;
5203 my $nsflag = 0;
5204 my $warnmsg = '';
5205 my $ifrom;
5206
5207 my $rev = 'n';
5208 my $code = 'OK';
5209 my $msg = 'foobar?';
5210
5211 # choke on possible bad setting in ifrom
5212 # IPv4 and v6, and valid hostnames!
5213 ($ifrom) = ($ifrom_in =~ /^([0-9a-f\:.]+|[0-9a-z_.-]+)$/i);
5214 return ('FAIL', "Bad AXFR source host $ifrom")
5215 unless ($ifrom) = ($ifrom_in =~ /^([0-9a-f\:.]+|[0-9a-z_.-]+)$/i);
5216
5217 my $errmsg;
5218
5219 my $zone_id;
5220 my $domain_id = 0;
5221 my $rdns_id = 0;
5222 my $cidr;
5223
5224# magic happens! detect if we're importing a domain or a reverse zone
5225# while we're at it, figure out what the CIDR netblock is (if we got a .arpa)
5226# or what the formal .arpa zone is (if we got a CIDR netblock)
5227# Handles sub-octet v4 zones in the format specified in the Cricket Book, 2nd Ed, p217-218
5228
5229 if ($zone =~ m{(?:\.arpa\.?|/\d+|^[\d.]+|^[a-fA-F0-9:]+)$}) {
5230 # we seem to have a reverse zone
5231 $rev = 'y';
5232
5233 if ($zone =~ /\.arpa\.?$/) {
5234 # we have a formal reverse zone. call _zone2cidr and get the CIDR block.
5235 ($code,$msg) = _zone2cidr($zone);
5236 return ($code, $msg) if $code eq 'FAIL';
5237 $cidr = $msg;
5238 } elsif ($zone =~ m|^[\d.]+/\d+$|) {
5239 # v4 revzone, CIDR netblock
5240 $cidr = NetAddr::IP->new($zone) or return ('FAIL',"$zone is not a valid CIDR block");
5241 $zone = _ZONE($cidr, 'ZONE.in-addr.arpa', 'r', '.');
5242 } elsif ($zone =~ /^[\d.]+$/) {
5243 # v4 revzone, leading-octet format
5244 my $mask = 32;
5245 while ($zone !~ /^\d+\.\d+\.\d+\.\d+$/) {
5246 $zone .= '.0';
5247 $mask -= 8;
5248 }
5249 $zone .= "/$mask";
5250 $cidr = NetAddr::IP->new($zone) or return ('FAIL',"$zone is not a valid CIDR block");
5251 $zone = _ZONE($cidr, 'ZONE.in-addr.arpa', 'r', '.');
5252 } elsif ($zone =~ m|^[a-fA-F\d:]+/\d+$|) {
5253 # v6 revzone, CIDR netblock
5254 $cidr = NetAddr::IP->new($zone) or return ('FAIL',"$zone is not a valid CIDR block");
5255 return ('FAIL', "$zone is not a nibble-aligned block") if $cidr->masklen % 4 != 0;
5256 $zone = _ZONE($cidr, 'ZONE.ip6.arpa', 'r', '.');
5257 } elsif ($zone =~ /^[a-fA-F\d:]+$/) {
5258 # v6 revzone, leading-group format
5259 $zone =~ s/::$//;
5260 my $mask = 128;
5261 while ($zone !~ /^(?:[a-fA-F\d]{1,4}:){7}[a-fA-F\d]$/) {
5262 $zone .= ":0";
5263 $mask -= 16;
5264 }
5265 $zone .= "/$mask";
5266 $cidr = NetAddr::IP->new($zone) or return ('FAIL',"$zone is not a valid CIDR block");
5267 $zone = _ZONE($cidr, 'ZONE.ip6.arpa', 'r', '.');
5268 } else {
5269 # there is. no. else!
5270 return ('FAIL', "Unknown zone name format '$zone'");
5271 }
5272
5273 # several places this can be triggered from; better to do it once.
5274 $warnmsg .= "Apparent sub-/64 IPv6 reverse zone\n" if $cidr->masklen > 64;
5275
5276 # quick check to start to see if we've already got one
5277
5278 ($zone_id) = $dbh->selectrow_array("SELECT rdns_id FROM revzones WHERE revnet=?",
5279 undef, ("$cidr"));
5280 $rdns_id = $zone_id;
5281 } else {
5282 # default to domain
5283 ($zone_id) = $dbh->selectrow_array("SELECT domain_id FROM domains WHERE lower(domain) = lower(?)",
5284 undef, ($zone));
5285 $domain_id = $zone_id;
5286 }
5287
5288 return ('FAIL', ($rev eq 'n' ? 'Domain' : 'Reverse zone')." already exists") if $zone_id;
5289
5290 # little local utility sub to swap $val and $host for revzone records.
5291 sub _revswap {
5292 my $rechost = shift;
5293 my $recdata = shift;
5294
5295 if ($rechost =~ /\.in-addr\.arpa\.?$/) {
5296 $rechost =~ s/\.in-addr\.arpa\.?$//;
5297 $rechost = join '.', reverse split /\./, $rechost;
5298 } else {
5299 $rechost =~ s/\.ip6\.arpa\.?$//;
5300 my @nibs = reverse split /\./, $rechost;
5301 $rechost = '';
5302 my $nc;
5303 foreach (@nibs) {
5304# # fail on multicharacter nibbles; it's syntactically valid but no standard lookup
5305# # will ever reach it, because it doesn't directly represent a real IP address.
5306# return ('FAIL', "Invalid reverse v6 entry") if $_ !~ /^.$/;
5307 $rechost.= $_;
5308 $rechost .= ":" if ++$nc % 4 == 0 && $nc < 32;
5309 }
5310 $rechost .= ":" if $nc < 32 && $rechost !~ /\*$/; # close netblock records?
5311##fixme: there's a case that ends up with a partial entry here:
5312# ip:add:re:ss::
5313# can't reproduce after letting it sit overnight after discovery. :(
5314#print "$rechost\n";
5315 # canonicalize with NetAddr::IP
5316 $rechost = NetAddr::IP->new($rechost)->addr unless $rechost =~ /\*$/;
5317 }
5318 return ($recdata,$rechost)
5319 }
5320
5321
5322 # Allow transactions, and raise an exception on errors so we can catch it later.
5323 # Use local to make sure these get "reset" properly on exiting this block
5324 local $dbh->{AutoCommit} = 0;
5325 local $dbh->{RaiseError} = 1;
5326
5327 my $sth;
5328 eval {
5329
5330 if ($rev eq 'n') {
5331##fixme: serial
5332 $dbh->do("INSERT INTO domains (domain,group_id,status) VALUES (?,?,?)", undef,
5333 ($zone, $group, $args{status}) );
5334 # get domain id so we can do the records
5335 ($zone_id) = $dbh->selectrow_array("SELECT currval('domains_domain_id_seq')");
5336 $domain_id = $zone_id;
5337 $self->_log(group_id => $group, domain_id => $domain_id,
5338 entry => "[Added ".($args{status} ? 'active' : 'inactive')." domain $zone via AXFR]");
5339 } else {
5340##fixme: serial
5341 $dbh->do("INSERT INTO revzones (revnet,group_id,status) VALUES (?,?,?)", undef,
5342 ($cidr,$group,$args{status}) );
5343 # get revzone id so we can do the records
5344 ($zone_id) = $dbh->selectrow_array("SELECT currval('revzones_rdns_id_seq')");
5345 $rdns_id = $zone_id;
5346 $self->_log(group_id => $group, rdns_id => $rdns_id,
5347 entry => "[Added ".($args{status} ? 'active' : 'inactive')." reverse zone $cidr via AXFR]");
5348 }
5349
5350## bizarre DBI<->Net::DNS interaction bug:
5351## sometimes a zone will cause an immediate commit-and-exit (sort of) of the while()
5352## fixed, apparently I was doing *something* odd, but not certain what it was that
5353## caused a commit instead of barfing
5354
5355 my $res = Net::DNS::Resolver->new;
5356 $res->nameservers($ifrom);
5357 $res->axfr_start($zone)
5358 or die "Couldn't begin AXFR\n";
5359
5360 $sth = $dbh->prepare("INSERT INTO records (domain_id,rdns_id,host,type,val,distance,weight,port,ttl)".
5361 " VALUES (?,?,?,?,?,?,?,?,?)");
5362
5363 # Stash info about sub-octet v4 revzones here so we don't have
5364 # to store the CNAMEs used to delegate a suboctet zone
5365 # $suboct{zone}{ns}[] -> array of nameservers
5366 # $suboct{zone}{cname}[] -> array of extant CNAMEs (Just In Case someone did something bizarre)
5367## commented pending actual use of this data. for now, we'll just
5368## auto-(re)create the CNAMEs in revzones on export
5369# my %suboct;
5370
5371 while (my $rr = $res->axfr_next()) {
5372
5373 # Discard out-of-zone records. After trying for a while to replicate this with
5374 # *nix-based DNS servers, it appears that only MS DNS is prone to including these
5375 # in the AXFR data in the first place, and possibly only older versions at that...
5376 # so it can't be reasonably tested. Yay Microsoft.
5377 if ($rr->name !~ /$zone$/i) {
5378 $warnmsg .= "Discarding out-of-zone record ".$rr->string."\n";
5379 }
5380
5381 my $val;
5382 my $distance = 0;
5383 my $weight = 0;
5384 my $port = 0;
5385 my $logfrag = '';
5386
5387 # Collect some record parts
5388 my $type = $rr->type;
5389 my $host = $rr->name;
5390 my $ttl = ($args{newttl} ? $args{newttl} : $rr->ttl); # allow force-override TTLs
5391
5392 # Info flags for SOA and NS records
5393 $soaflag = 1 if $type eq 'SOA';
5394 $nsflag = 1 if $type eq 'NS';
5395
5396# "Primary" types:
5397# A, NS, CNAME, SOA, PTR(warn in forward), MX, TXT, AAAA, SRV, A6(ob), SPF
5398# maybe KEY
5399
5400# BIND supports:
5401# [standard]
5402# A AAAA CNAME MX NS PTR SOA TXT
5403# [variously experimental, obsolete, or obscure]
5404# HINFO MB(ex) MD(ob) MF(ob) MG(ex) MINFO(ex) MR(ex) NULL WKS AFSDB(ex) ISDN(ex) RP(ex) RT(ex) X25(ex) PX
5405# ... if one can ever find the right magic to format them correctly
5406
5407# Net::DNS supports:
5408# RRSIG SIG NSAP NS NIMLOC NAPTR MX MR MINFO MG MB LOC ISDN IPSECKEY HINFO
5409# EID DNAME CNAME CERT APL AFSDB AAAA A DS NXT NSEC3PARAM NSEC3 NSEC KEY
5410# DNSKEY DLV X25 TXT TSIG TKEY SSHFP SRV SPF SOA RT RP PX PTR NULL APL::AplItem
5411
5412# nasty big ugly case-like thing here, since we have to do *some* different
5413# processing depending on the record. le sigh.
5414
5415# do the initial processing as if the record was in a forward zone. If we're
5416# doing a revzone, we can flip $host and $val as needed, once, after this
5417# monster if-elsif-...-elsif-else. This actually simplifies things a lot.
5418
5419##fixme: what record types other than TXT can/will have >255-byte payloads?
5420
5421 if ($type eq 'A') {
5422 $val = $rr->address;
5423 } elsif ($type eq 'NS') {
5424# hmm. should we warn here if subdomain NS'es are left alone? OTOH, those should rarely be rewritten anyway.
5425 next if ($args{rwns} && ($host eq $zone));
5426 $val = $rr->nsdname;
5427 $warnmsg .= "Suspect record '".$rr->string."' may not be imported correctly: NS records may not be bare IP addresses\n"
5428 if $val =~ /^(?:(?:\d+\.){3}\d+|[a-fA-F0-9:]+)$/;
5429 $nsflag = 1;
5430 } elsif ($type eq 'CNAME') {
5431 $val = $rr->cname;
5432 $warnmsg .= "Suspect record '".$rr->string."' may not be imported correctly: CNAME records may not be bare IP addresses\n"
5433 if $val =~ /^(?:(?:\d+\.){3}\d+|[a-fA-F0-9:]+)$/;
5434 } elsif ($type eq 'SOA') {
5435 next if $args{rwsoa};
5436 $host = $rr->rname.":".$rr->mname;
5437 $val = $rr->refresh.":".$rr->retry.":".$rr->expire.":".$rr->minimum;
5438 $soaflag = 1;
5439 } elsif ($type eq 'PTR') {
5440 $val = $rr->ptrdname;
5441 } elsif ($type eq 'MX') {
5442 $val = $rr->exchange;
5443 $distance = $rr->preference;
5444 } elsif ($type eq 'TXT') {
5445##fixme: Net::DNS docs say this should be deprecated for rdatastr() or char_str_list(),
5446## but don't really seem enthusiastic about it.
5447#print "should use rdatastr:\n\t".$rr->rdatastr."\n or char_str_list:\n\t".join(' ',$rr->char_str_list())."\n";
5448# rdatastr returns a BIND-targetted logical string, including opening and closing quotes
5449# char_str_list returns a list of the individual string fragments in the record
5450# txtdata returns the more useful all-in-one form (since we want to push such protocol
5451# details as far down the stack as we can)
5452# NB: this may turn out to be more troublesome if we ever have need of >512-byte TXT records.
5453 $val = $rr->txtdata;
5454 } elsif ($type eq 'SPF') {
5455##fixme: and the same caveat here, since it is apparently a clone of ::TXT
5456 $val = $rr->txtdata;
5457 } elsif ($type eq 'AAAA') {
5458 $val = $rr->address;
5459 } elsif ($type eq 'SRV') {
5460 $val = $rr->target;
5461 $distance = $rr->priority;
5462 $weight = $rr->weight;
5463 $port = $rr->port;
5464 $warnmsg .= "Suspect record '".$rr->string."' may not be imported correctly: SRV records may not be bare IP addresses\n"
5465 if $val =~ /^(?:(?:\d+\.){3}\d+|[a-fA-F0-9:]+)$/;
5466 } elsif ($type eq 'KEY') {
5467 # we don't actually know what to do with these...
5468 $val = $rr->flags.":".$rr->protocol.":".$rr->algorithm.":".$rr->key.":".$rr->keytag.":".$rr->privatekeyname;
5469 } else {
5470 $val = $rr->rdatastr;
5471 # Finding a different record type is not fatal.... just problematic.
5472 # We may not be able to export it correctly.
5473 $warnmsg .= "Unusual record ".$rr->name." ($type) found\n";
5474 }
5475
5476 if ($rev eq 'y' && $type ne 'SOA') {
5477 # up to this point we haven't meddled with the record's hostname part or rdata part.
5478 # for reverse records, (except SOA) we must swap the two.
5479 $host = $val;
5480 $val = $rr->name;
5481 my ($tmpcode,$tmpmsg) = _zone2cidr($val);
5482 if ($tmpcode eq 'FAIL') {
5483 # $val did not have a valid IP value. It's syntactically valid but WTF?
5484 $warnmsg .= "Suspect record '".$rr->string."' may not be imported correctly: $tmpmsg\n";
5485 } else {
5486 # $val has a valid IP value. See if we can store it as that IP value.
5487 # Note we're enumerating do-nothing cases for clarity.
5488##enhance: this is where we will implement the more subtle variations on #53
5489 if ($type ne 'PTR' && $type ne 'NS' && $type ne 'CNAME' && $type ne 'TXT') {
5490 # case: the record is "weird" - ie, not a PTR, NS, CNAME, or TXT
5491 # $warnmsg .= "Discarding suspect record '".$rr->string."'\n" if $self->{strict} eq 'full';
5492 } elsif ($type eq 'PTR' && $tmpmsg->masklen != 32 && $tmpmsg->masklen != 128) {
5493 # case: PTR with netblock value, not IP value
5494 # eg, "@ PTR foo" in zone f.e.e.b.d.a.e.d.ip6.arpa should not be
5495 # stored/displayed as dead:beef::/32 PTR foo
5496
5497## hrm. WTF is this case for, anyway? Needs testing to check the logic.
5498# } elsif ( ($type eq 'PTR' || $type eq 'NS' || $type eq 'CNAME' || $type eq 'TXT') &&
5499# ($tmpmsg->masklen != $cidr->masklen)
5500# ) {
5501# # leave $val as-is if the record is "normal" (a PTR, NS, CNAME, or TXT),
5502# # and the mask does not match the zone
5503#$warnmsg .= "WTF case: $host $type $val\n";
5504# # $warnmsg .= "Discarding suspect record '".$rr->string."'\n" if $self->{strict} eq 'full';
5505
5506 } else {
5507 $val = $tmpmsg;
5508 $val =~ s/\/(?:32|128)$//; # automagically converts $val back to a string before s///
5509 #$val =~ s/:0$//g;
5510 }
5511 }
5512 # magic? convert * records to PTR template (not sure this actually makes sense)
5513 #if ($val =~ /^\*/) {
5514 # $val =~ s/\*\.//;
5515 # ($tmpcode,$tmpmsg) = _zone2cidr($val);
5516 # if ($tmpcode eq 'FAIL') {
5517 # $val = "*.$val";
5518 # $warnmsg .= "Suspect record '".$rr->string."' may not be converted to PTR template correctly: $tmpmsg\n";
5519 # } else {
5520 # $type = 'PTR template';
5521 # $val = $tmpmsg; if $tmp
5522 # $val =~ s/\/(?:32|128)$//; # automagically converts $val back to a string before s///
5523 # }
5524 #}
5525 } # non-SOA revrec $host/$val inversion and munging
5526
5527 my $logentry = "[AXFR ".($rev eq 'n' ? $zone : $cidr)."] ";
5528
5529 if ($args{merge}) {
5530 if ($rev eq 'n') {
5531 # importing a domain; we have A and AAAA records that could be merged with matching PTR records
5532 my $etype;
5533 my ($erdns,$erid,$ettl) = $dbh->selectrow_array("SELECT rdns_id,record_id,ttl FROM records ".
5534 "WHERE host=? AND val=? AND type=12",
5535 undef, ($host, $val) );
5536 if ($erid) {
5537 if ($type eq 'A') { # PTR -> A+PTR
5538 $etype = 65280;
5539 $logentry .= "Merged A record with existing PTR record '$host A+PTR $val', TTL $ettl";
5540 }
5541 if ($type eq 'AAAA') { # PTR -> AAAA+PTR
5542 $etype = 65281;
5543 $logentry .= "Merged AAAA record with existing PTR record '$host AAAA+PTR $val', TTL $ettl";
5544 }
5545 $ettl = ($ettl < $ttl ? $ettl : $ttl); # use lower TTL
5546 $dbh->do("UPDATE records SET domain_id=?,ttl=?,type=? WHERE record_id=?", undef,
5547 ($domain_id, $ettl, $etype, $erid));
5548 $nrecs++;
5549 $self->_log(group_id => $group, domain_id => $domain_id, rdns_id => $erdns, entry => $logentry);
5550 next; # while axfr_next
5551 }
5552 } # $rev eq 'n'
5553 else {
5554 # importing a revzone, we have PTR records that could be merged with matching A/AAAA records
5555 my ($domid,$erid,$ettl,$etype) = $dbh->selectrow_array("SELECT domain_id,record_id,ttl,type FROM records ".
5556 "WHERE host=? AND val=? AND (type=1 OR type=28)",
5557 undef, ($host, $val) );
5558 if ($erid) {
5559 if ($etype == 1) { # A -> A+PTR
5560 $etype = 65280;
5561 $logentry .= "Merged PTR record with existing matching A record '$host A+PTR $val', TTL $ettl";
5562 }
5563 if ($etype == 28) { # AAAA -> AAAA+PTR
5564 $etype = 65281;
5565 $logentry .= "Merged PTR record with existing matching AAAA record '$host AAAA+PTR $val', TTL $ettl";
5566 }
5567 $ettl = ($ettl < $ttl ? $ettl : $ttl); # use lower TTL
5568 $dbh->do("UPDATE records SET rdns_id=?,ttl=?,type=? WHERE record_id=?", undef,
5569 ($rdns_id, $ettl, $etype, $erid));
5570 $nrecs++;
5571 $self->_log(group_id => $group, domain_id => $domid, rdns_id => $rdns_id, entry => $logentry);
5572 next; # while axfr_next
5573 }
5574 } # $rev eq 'y'
5575 } # if $args{merge}
5576
5577 # Insert the new record
5578 $sth->execute($domain_id, $rdns_id, $host, $reverse_typemap{$type}, $val,
5579 $distance, $weight, $port, $ttl);
5580
5581 $nrecs++;
5582
5583 if ($type eq 'SOA') {
5584 # also !$args{rwsoa}, but if that's set, it should be impossible to get here.
5585 my @tmp1 = split /:/, $host;
5586 my @tmp2 = split /:/, $val;
5587 $logentry .= "Added SOA record [contact $tmp1[0]] [master $tmp1[1]] ".
5588 "[refresh $tmp2[0]] [retry $tmp2[1]] [expire $tmp2[2]] [minttl $tmp2[3]], TTL $ttl";
5589 } elsif ($logfrag) {
5590 # special case for log entries we need to meddle with a little.
5591 $logentry .= $logfrag;
5592 } else {
5593 $logentry .= "Added record '".($rev eq 'y' ? $val : $host)." $type";
5594 $logentry .= " [distance $distance]" if $type eq 'MX';
5595 $logentry .= " [priority $distance] [weight $weight] [port $port]" if $type eq 'SRV';
5596 $logentry .= " ".($rev eq 'y' ? $host : $val)."', TTL $ttl";
5597 }
5598 $self->_log(group_id => $group, domain_id => $domain_id, rdns_id => $rdns_id, entry => $logentry);
5599
5600 } # while axfr_next
5601
5602# Detect and handle delegated subzones
5603# Placeholder for when we decide what to actually do with this, see previous comments in NS and CNAME handling.
5604#foreach (keys %suboct) {
5605# print "found ".($suboct{$_}{ns} ? @{$suboct{$_}{ns}} : '0')." NS records and ".
5606# ($suboct{$_}{cname} ? @{$suboct{$_}{cname}} : '0')." CNAMEs for $_\n";
5607#}
5608
5609 # Overwrite SOA record
5610 if ($args{rwsoa}) {
5611 $soaflag = 1;
5612 my $sthgetsoa = $dbh->prepare("SELECT host,val,ttl FROM "._rectable('y', $rev)." WHERE group_id=? AND type=?");
5613 my $sthputsoa = $dbh->prepare("INSERT INTO records (".
5614 ($rev eq 'n' ? 'domain_id' : 'rdns_id').",host,type,val,ttl) VALUES (?,?,?,?,?)");
5615 $sthgetsoa->execute($group,$reverse_typemap{SOA});
5616 while (my ($host,$val,$ttl) = $sthgetsoa->fetchrow_array()) {
5617 if ($rev eq 'n') {
5618 $host =~ s/DOMAIN/$zone/g;
5619 $val =~ s/DOMAIN/$zone/g; # arguably useless
5620 } else {
5621 $host =~ s/ADMINDOMAIN/$self->{domain}/g;
5622 }
5623 $sthputsoa->execute($zone_id,$host,$reverse_typemap{SOA},$val,$ttl);
5624 }
5625 }
5626
5627 # Add standard NS records. The old one(s) should have been skipped by this point.
5628 if ($args{rwns}) {
5629 $nsflag = 1;
5630 my $sthgetns = $dbh->prepare("SELECT host,val,ttl FROM "._rectable('y',$rev)." WHERE group_id=? AND type=?");
5631 my $sthputns = $dbh->prepare("INSERT INTO records (".
5632 ($rev eq 'n' ? 'domain_id' : 'rdns_id').",host,type,val,ttl) VALUES (?,?,?,?,?)");
5633 $sthgetns->execute($group,$reverse_typemap{NS});
5634 while (my ($host,$val,$ttl) = $sthgetns->fetchrow_array()) {
5635 if ($rev eq 'n') {
5636 $host =~ s/DOMAIN/$zone/g;
5637 $val =~ s/DOMAIN/$zone/g; #hmm.
5638 } else {
5639 $host =~ s/ADMINDOMAIN/$self->{domain}/g; #hmm.
5640 $val =~ s/ZONE/$cidr/g;
5641 }
5642 $sthputns->execute($zone_id,$host,$reverse_typemap{NS},$val,$ttl);
5643 }
5644 }
5645
5646 die "No records found; either $ifrom is not authoritative or doesn't allow transfers\n" if !$nrecs;
5647 die "Bad zone: No SOA record!\n" if !$soaflag;
5648 die "Bad zone: No NS records!\n" if !$nsflag;
5649
5650 $dbh->commit;
5651
5652 };
5653
5654 if ($@) {
5655 my $msg = $@;
5656 eval { $dbh->rollback; };
5657 return ('FAIL',$msg." $warnmsg");
5658 } else {
5659 return ('WARN', $warnmsg) if $warnmsg;
5660 return ('OK',"Imported OK");
5661 }
5662
5663 # it should be impossible to get here.
5664 return ('WARN',"OOOK!");
5665} # end importAXFR()
5666
5667
5668## DNSDB::importBIND()
5669sub importBIND {
5670} # end importBIND()
5671
5672
5673## DNSDB::import_tinydns()
5674sub import_tinydns {
5675} # end import_tinydns()
5676
5677
5678## DNSDB::export()
5679# Export the DNS database, or a part of it
5680# Takes a string indicating the export type, plus optional arguments depending on type
5681# Writes zone data to targets as appropriate for type
5682sub export {
5683 my $self = shift;
5684 my $target = shift;
5685
5686 if ($target eq 'tiny') {
5687 eval {
5688 $self->__export_tiny(@_);
5689 };
5690 if ($@) {
5691 $errstr = $@;
5692 return undef;
5693 }
5694 }
5695# elsif ($target eq 'foo') {
5696# __export_foo(@_);
5697#}
5698# etc
5699
5700 return 1;
5701} # end export()
5702
5703
5704## DNSDB::__export_tiny
5705# Internal sub to implement tinyDNS (compatible) export
5706# Takes filehandle to write export to, optional argument(s)
5707# to determine which data gets exported
5708sub __export_tiny {
5709 my $self = shift;
5710 my $dbh = $self->{dbh};
5711 my $datafile = shift;
5712 my $zonefilehandle = $datafile; # makes cache/no-cache a little simpler
5713
5714##fixme: slurp up further options to specify particular zone(s) to export
5715
5716##fixme: fail if $datafile isn't an open, writable file
5717
5718 # Error check - does the cache dir exist, if we're using one?
5719 if ($self->{usecache}) {
5720 die "Cache directory does not exist\n" if !-e $self->{exportcache};
5721 die "$self->{exportcache} is not a directory\n" if !-d $self->{exportcache};
5722 die "$self->{exportcache} must be both readable and writable\n"
5723 if !-r $self->{exportcache} || !-w $self->{exportcache};
5724 }
5725
5726 # easy case - export all evarything
5727 # not-so-easy case - export item(s) specified
5728 # todo: figure out what kind of list we use to export items
5729
5730# raw packet in unknown format: first byte indicates length
5731# of remaining data, allows up to 255 raw bytes
5732
5733 # note: the only I/O failures we seem to be able to actually catch
5734 # here are "closed filehandle" errors. we're probably not writing
5735 # enough data at this point to properly trigger an "out of space"
5736 # error. :/
5737 eval {
5738 use warnings FATAL => ('io');
5739 # Locations/views - worth including in the caching setup?
5740 my $lochash = $dbh->selectall_hashref("SELECT location,iplist FROM locations", 'location');
5741 foreach my $location (keys %$lochash) {
5742 foreach my $ipprefix (split /[,\s]+/, $lochash->{$location}{iplist}) {
5743 $ipprefix =~ s/\s+//g;
5744 $ipprefix = new NetAddr::IP $ipprefix;
5745##fixme: how to handle IPv6?
5746next if $ipprefix->{isv6};
5747 # have to account for /nn CIDR entries. tinydns only speaks octet-sliced prefix.
5748 if ($ipprefix->masklen <= 8) {
5749 foreach ($ipprefix->split(8)) {
5750 my $tmp = $_->addr;
5751 $tmp =~ s/\.\d+\.\d+\.\d+$//;
5752 print $datafile "%$location:$tmp\n";
5753 }
5754 } elsif ($ipprefix->masklen <= 16) {
5755 foreach ($ipprefix->split(16)) {
5756 my $tmp = $_->addr;
5757 $tmp =~ s/\.\d+\.\d+$//;
5758 print $datafile "%$location:$tmp\n";
5759 }
5760 } elsif ($ipprefix->masklen <= 24) {
5761 foreach ($ipprefix->split(24)) {
5762 my $tmp = $_->addr;
5763 $tmp =~ s/\.\d+$//;
5764 print $datafile "%$location:$tmp\n";
5765 }
5766 } else {
5767 foreach ($ipprefix->split(32)) {
5768 print $datafile "%$location:".$_->addr."\n";
5769 }
5770 }
5771 }
5772 print $datafile "%$location\n" if !$lochash->{$location}{iplist};
5773 }
5774 };
5775 if ($@) {
5776 die "Error writing locations to master file: $@, $!\n";
5777 }
5778
5779 # tracking hash so we don't double-export A+PTR or AAAA+PTR records.
5780 my %recflags;
5781
5782# For reasons unknown, we can't sanely UNION these statements. Feh.
5783# Supposedly it should work though (note last 3 lines):
5784## PG manual
5785#UNION Clause
5786#
5787#The UNION clause has this general form:
5788#
5789# select_statement UNION [ ALL ] select_statement
5790#
5791#select_statement is any SELECT statement without an ORDER BY, LIMIT, FOR UPDATE, or FOR SHARE clause. (ORDER BY
5792#and LIMIT can be attached to a subexpression if it is enclosed in parentheses. Without parentheses, these
5793#clauses will be taken to apply to the result of the UNION, not to its right-hand input expression.)
5794 my $soasth = $dbh->prepare("SELECT host,type,val,distance,weight,port,ttl,record_id,location ".
5795 "FROM records WHERE rdns_id=? AND type=6");
5796 my $recsth = $dbh->prepare("SELECT host,type,val,distance,weight,port,ttl,record_id,location,extract(epoch from stamp),expires,stampactive ".
5797 "FROM records WHERE rdns_id=? AND NOT type=6 ".
5798 "ORDER BY masklen(inetlazy(val)) DESC, inetlazy(val)");
5799 my $revsth = $dbh->prepare("SELECT rdns_id,revnet,status,changed FROM revzones WHERE status=1 ".
5800 "ORDER BY masklen(revnet) DESC, rdns_id");
5801 my $zonesth = $dbh->prepare("UPDATE revzones SET changed='n' WHERE rdns_id=?");
5802 $revsth->execute();
5803 while (my ($revid,$revzone,$revstat,$changed) = $revsth->fetchrow_array) {
5804##fixme: need to find a way to block opening symlinked files without introducing a race.
5805# O_NOFOLLOW
5806# If pathname is a symbolic link, then the open fails. This is a FreeBSD extension, which was
5807# added to Linux in version 2.1.126. Symbolic links in earlier components of the pathname will
5808# still be followed.
5809# but that doesn't help other platforms. :/
5810 my $tmpzone = NetAddr::IP->new($revzone);
5811##fixme: locations/views? subnet mask? need to avoid possible collisions with zone/superzone
5812## (eg /20 vs /24, starting on .0.0)
5813 my $cz = $tmpzone->network->addr."-".$tmpzone->masklen;
5814 my $cachefile = "$self->{exportcache}/$cz";
5815 my $tmpcache = "$self->{exportcache}/tmp.$cz.$$";
5816 eval {
5817
5818 # write fresh records if:
5819 # - we are not using the cache
5820 # - force_refresh is set
5821 # - the zone has changed
5822 # - the cache file does not exist
5823 # - the cache file is empty
5824 if (!$self->{usecache} || $self->{force_refresh} || $changed || !-e $cachefile || -z $cachefile) {
5825 if ($self->{usecache}) {
5826 open ZONECACHE, ">$tmpcache" or die "Error creating temporary file $tmpcache: $!\n";
5827 $zonefilehandle = *ZONECACHE;
5828 }
5829
5830 # need to fetch this separately since the rest of the records all (should) have real IPs in val
5831 $soasth->execute($revid);
5832 my (@zsoa) = $soasth->fetchrow_array();
5833 $self->_printrec_tiny($zonefilehandle, $zsoa[7], 'y',\%recflags,$revzone,
5834 $zsoa[0],$zsoa[1],$zsoa[2],$zsoa[3],$zsoa[4],$zsoa[5],$zsoa[6],$zsoa[8],'');
5835
5836 $recsth->execute($revid);
5837 my $fullzone = _ZONE($tmpzone, 'ZONE', 'r', '.').($tmpzone->{isv6} ? '.ip6.arpa' : '.in-addr.arpa');
5838
5839 while (my ($host, $type, $val, $dist, $weight, $port, $ttl, $recid, $loc, $stamp, $expires, $stampactive)
5840 = $recsth->fetchrow_array) {
5841 next if $recflags{$recid};
5842
5843 # Check for out-of-zone data
5844 if ($val =~ /\.arpa$/) {
5845 # val is non-IP
5846 if ($val !~ /$fullzone$/) {
5847 warn "Not exporting out-of-zone record $val $typemap{$type} $host, $ttl (zone $tmpzone)\n";
5848 next;
5849 }
5850 } else {
5851 my $ipval = new NetAddr::IP $val;
5852 if (!$tmpzone->contains($ipval)) {
5853 warn "Not exporting out-of-zone record $val $typemap{$type} $host, $ttl (zone $tmpzone)\n";
5854 next;
5855 }
5856 } # is $val a raw .arpa name?
5857
5858 # Spaces are evil.
5859 $val =~ s/^\s+//;
5860 $val =~ s/\s+$//;
5861 if ($typemap{$type} ne 'TXT') {
5862 # Leading or trailng spaces could be legit in TXT records.
5863 $host =~ s/^\s+//;
5864 $host =~ s/\s+$//;
5865 }
5866
5867 $self->_printrec_tiny($zonefilehandle, $recid, 'y', \%recflags, $revzone,
5868 $host, $type, $val, $dist, $weight, $port, $ttl, $loc, $stamp, $expires, $stampactive);
5869
5870 $recflags{$recid} = 1;
5871
5872 } # while ($recsth)
5873
5874 if ($self->{usecache}) {
5875 close ZONECACHE; # force the file to be written
5876 # catch obvious write errors that leave an empty temp file
5877 if (-s $tmpcache) {
5878 rename $tmpcache, $cachefile
5879 or die "Error overwriting cache file $cachefile with temporary file: $!\n";
5880 }
5881 }
5882
5883 } # if $changed or cache filesize is 0
5884
5885 };
5886 if ($@) {
5887 die "error writing ".($self->{usecache} ? 'new data for ' : '')."$revzone: $@\n";
5888 # error! something borked, and we should be able to fall back on the old cache file
5889 # report the error, somehow.
5890 } else {
5891 # mark zone as unmodified. Only do this if no errors, that way
5892 # export failures should recover a little more automatically.
5893 $zonesth->execute($revid);
5894 }
5895
5896 if ($self->{usecache}) {
5897 # We've already made as sure as we can that a cached zone file is "good",
5898 # although possibly stale/obsolete due to errors creating a new one.
5899 eval {
5900 open CACHE, "<$cachefile" or die $!;
5901 print $datafile $_ or die "error copying cached $revzone to master file: $!" while <CACHE>;
5902 close CACHE;
5903 };
5904 die $@ if $@;
5905 }
5906
5907 } # while ($revsth)
5908
5909 $soasth = $dbh->prepare("SELECT host,type,val,distance,weight,port,ttl,record_id,location ".
5910 "FROM records WHERE domain_id=? AND type=6");
5911 $recsth = $dbh->prepare("SELECT host,type,val,distance,weight,port,ttl,record_id,location,extract(epoch from stamp),expires,stampactive ".
5912 "FROM records WHERE domain_id=? AND NOT type=6"); # Just exclude all types relating to rDNS
5913# "FROM records WHERE domain_id=? AND type < 65280"); # Just exclude all types relating to rDNS
5914 my $domsth = $dbh->prepare("SELECT domain_id,domain,status,changed FROM domains WHERE status=1 ORDER BY domain_id");
5915 $zonesth = $dbh->prepare("UPDATE domains SET changed='n' WHERE domain_id=?");
5916 $domsth->execute();
5917 while (my ($domid,$dom,$domstat,$changed) = $domsth->fetchrow_array) {
5918##fixme: need to find a way to block opening symlinked files without introducing a race.
5919# O_NOFOLLOW
5920# If pathname is a symbolic link, then the open fails. This is a FreeBSD extension, which was
5921# added to Linux in version 2.1.126. Symbolic links in earlier components of the pathname will
5922# still be followed.
5923# but that doesn't help other platforms. :/
5924 my $cachefile = "$self->{exportcache}/$dom";
5925 my $tmpcache = "$self->{exportcache}/tmp.$dom.$$";
5926 eval {
5927
5928 # write fresh records if:
5929 # - we are not using the cache
5930 # - force_refresh is set
5931 # - the zone has changed
5932 # - the cache file does not exist
5933 # - the cache file is empty
5934 if (!$self->{usecache} || $self->{force_refresh} || $changed || !-e $cachefile || -z $cachefile) {
5935 if ($self->{usecache}) {
5936 open ZONECACHE, ">$tmpcache" or die "Error creating temporary file $tmpcache: $!\n";
5937 $zonefilehandle = *ZONECACHE;
5938 }
5939
5940 # need to fetch this separately so the SOA comes first in the flatfile....
5941 # Just In Case we need/want to reimport from the flatfile later on.
5942 $soasth->execute($domid);
5943 my (@zsoa) = $soasth->fetchrow_array();
5944 $self->_printrec_tiny($zonefilehandle, $zsoa[7], 'n',\%recflags,$dom,
5945 $zsoa[0],$zsoa[1],$zsoa[2],$zsoa[3],$zsoa[4],$zsoa[5],$zsoa[6],$zsoa[8],'');
5946
5947 $recsth->execute($domid);
5948 while (my ($host,$type,$val,$dist,$weight,$port,$ttl,$recid,$loc,$stamp,$expires,$stampactive) = $recsth->fetchrow_array) {
5949 next if $recflags{$recid};
5950
5951 # Check for out-of-zone data
5952 $host = $dom if $host eq '@';
5953 if ($host !~ /$dom$/i) {
5954 warn "Not exporting out-of-zone record $host $type $val, $ttl (zone $dom)\n";
5955 next;
5956 }
5957
5958 # Spaces are evil.
5959 $host =~ s/^\s+//;
5960 $host =~ s/\s+$//;
5961 if ($typemap{$type} ne 'TXT') {
5962 # Leading or trailng spaces could be legit in TXT records.
5963 $val =~ s/^\s+//;
5964 $val =~ s/\s+$//;
5965 }
5966
5967 $self->_printrec_tiny($zonefilehandle, $recid, 'n', \%recflags,
5968 $dom, $host, $type, $val, $dist, $weight, $port, $ttl, $loc, $stamp, $expires, $stampactive);
5969
5970 $recflags{$recid} = 1;
5971
5972 } # while ($recsth)
5973
5974
5975 if ($self->{usecache}) {
5976 close ZONECACHE; # force the file to be written
5977 # catch obvious write errors that leave an empty temp file
5978 if (-s $tmpcache) {
5979 rename $tmpcache, $cachefile
5980 or die "Error overwriting cache file $cachefile with temporary file: $!\n";
5981 }
5982 }
5983
5984 } # if $changed or cache filesize is 0
5985
5986 };
5987 if ($@) {
5988 die "error writing ".($self->{usecache} ? 'new data for ' : '')."$dom: $@\n";
5989 # error! something borked, and we should be able to fall back on the old cache file
5990 # report the error, somehow.
5991 } else {
5992 # mark domain as unmodified. Only do this if no errors, that way
5993 # export failures should recover a little more automatically.
5994 $zonesth->execute($domid);
5995 }
5996
5997 if ($self->{usecache}) {
5998 # We've already made as sure as we can that a cached zone file is "good",
5999 # although possibly stale/obsolete due to errors creating a new one.
6000 eval {
6001 open CACHE, "<$cachefile" or die $!;
6002 print $datafile $_ or die "error copying cached $dom to master file: $!" while <CACHE>;
6003 close CACHE;
6004 };
6005 die $@ if $@;
6006 }
6007
6008 } # while ($domsth)
6009
6010 return 1;
6011} # end __export_tiny()
6012
6013
6014# Utility sub for __export_tiny above
6015sub _printrec_tiny {
6016 my $self = shift;
6017 my ($datafile, $recid, $revrec, $recflags, $zone, $host, $type, $val, $dist, $weight, $port, $ttl,
6018 $loc, $stamp, $expires, $stampactive) = @_;
6019
6020 $loc = '' if !$loc; # de-nullify - just in case
6021##fixme: handle case of record-with-location-that-doesn't-exist better.
6022# note this currently fails safe (tested) - records with a location that
6023# doesn't exist will not be sent to any client
6024# $loc = '' if !$lochash->{$loc};
6025
6026
6027## Records that are valid only before or after a set time
6028
6029# record due to expire sometime is the complex case. we don't want to just
6030# rely on tinydns' auto-adjusting TTLs, because the default TTL in that case
6031# is one day instead of the SOA minttl as BIND might do.
6032
6033# consider the case where a record is set to expire a week ahead, but the next
6034# day later you want to change it NOW (or as NOWish as you get with your DNS
6035# management practice). but now you're stuck, because someone, somewhere,
6036# has just done a lookup before your latest change was published, and they'll
6037# be caching that old, broken record for 1 day instead of your zone default
6038# TTL.
6039
6040# $stamp-$ttl is the *latest* we can publish the record with the defined TTL
6041# to still have the expiry happen as scheduled, but we need to find some
6042# *earlier* point. We can maybe guess, and 2x TTL is probably reasonable,
6043# but we need info on the export frequency.
6044
6045# export the normal, non-expiring record up until $stamp-<guesstimate>, then
6046# switch to exporting a record with the TAI64 stamp and a 0 TTL so tinydns
6047# takes over TTL management.
6048
6049 if ($stampactive) {
6050 if ($expires) {
6051 # record expires at $stamp; decide if we need to keep the TTL and ignore
6052 # the stamp for a time or if we need to change the TTL to 0 and convert
6053 # $stamp to TAI64 so tinydns can use $stamp to autoadjust the TTL on the fly.
6054# extra hack, optimally needs more knowledge of data export frequency
6055# smack the idiot customer who insists on 0 TTLs; they can suck up and
6056# deal with a 10-minute TTL. especially on scheduled changes. note this
6057# should be (export freq * 2), but we don't know the actual export frequency.
6058$ttl = 300 if $ttl == 0; #hack phtui
6059 my $ahead = (86400 < $ttl*2 ? 86400 : $ttl*2);
6060 if ((time() + $ahead) < $stamp) {
6061 # more than 2x TTL OR more than one day (whichever is less) from expiry time; publish normal record
6062 $stamp = '';
6063 } else {
6064 # less than 2x TTL from expiry time, let tinydns take over TTL management and publish the TAI64 stamp.
6065 $ttl = 0;
6066 $stamp = unixtai64($stamp);
6067 $stamp =~ s/\@//;
6068 }
6069 } else {
6070 # record is "active after"; convert epoch from database to TAI64, publish, and collect $200.
6071 $stamp = unixtai64($stamp);
6072 $stamp =~ s/\@//;
6073 }
6074 } else {
6075 # flag for active timestamp is false; don't actually put a timestamp in the output
6076 $stamp = '';
6077 }
6078
6079 # support tinydns' auto-TTL
6080 $ttl = '' if $ttl == -1;
6081# these are WAY FREAKING HIGH - higher even than most TLD registry TTLs!
6082# NS 259200 => 3d
6083# all others 86400 => 1d
6084
6085 if ($revrec eq 'y') {
6086 $val = $zone if $val eq '@';
6087 } else {
6088 $host = $zone if $host eq '@';
6089 }
6090
6091 ## Convert a bare number into an octal-coded pair of octets.
6092 # Take optional arg to indicate a decimal or hex input. Defaults to hex.
6093 sub octalize {
6094 my $tmp = shift;
6095 my $srctype = shift || 'h'; # default assumes hex string
6096 $tmp = sprintf "%0.4x", hex($tmp) if $srctype eq 'h'; # 0-pad hex to 4 digits
6097 $tmp = sprintf "%0.4x", $tmp if $srctype eq 'd'; # 0-pad decimal to 4 hex digits
6098 my @o = ($tmp =~ /^(..)(..)$/); # split into octets
6099 return sprintf "\\%0.3o\\%0.3o", hex($o[0]), hex($o[1]);
6100 }
6101
6102 # Utility sub-sub for reverse records; with "any-record-in-any-zone"
6103 # we may need to do extra processing on $val to make it publishable.
6104 sub __revswap {
6105 my $host = shift;
6106 my $val = shift;
6107 return ($val, $host) if $val =~ /\.arpa/;
6108 $val = new NetAddr::IP $val;
6109 my $newval = _ZONE($val, 'ZONE', 'r', '.').($val->{isv6} ? '.ip6.arpa' : '.in-addr.arpa');
6110 return ($newval, $host);
6111 }
6112
6113## WARNING: This works to export even the whole Internet's worth of IP space...
6114## if you have the disk/RAM to handle the dataset, and you call this sub based on /16-sized chunks
6115## A /16 took ~3 seconds with a handful of separate records; adding a /8 pushed export time out to ~13m:40s
6116## 0/0 is estimated to take ~54 hours and ~256G of disk
6117## RAM usage depends on how many non-template entries you have in the set.
6118## This should probably be done on record addition rather than export; large blocks may need to be done in a
6119## forked process
6120 sub __publish_subnet {
6121 my $self = shift; # *sigh* need to pass in the DNSDB object so we can read a couple of options
6122 my $sub = shift;
6123 my $recflags = shift;
6124 my $hpat = shift;
6125 my $fh = shift;
6126 my $ttl = shift;
6127 my $stamp = shift;
6128 my $loc = shift;
6129 my $zone = new NetAddr::IP shift;
6130 my $ptronly = shift || 0;
6131
6132 # do this conversion once, not (number-of-ips-in-subnet) times
6133 my $arpabase = _ZONE($zone, 'ZONE.in-addr.arpa', 'r', '.');
6134
6135 my $iplist = $sub->splitref(32);
6136 my $ipindex = -1;
6137 foreach (@$iplist) {
6138 my $ip = $_->addr;
6139 $ipindex++;
6140 # make as if we split the non-octet-aligned block into octet-aligned blocks as with SOA
6141 my $lastoct = (split /\./, $ip)[3];
6142 next if $$recflags{$ip}; # && $self->{skip_bcast_255}
6143 $$recflags{$ip}++;
6144 next if $hpat eq '%blank%'; # Allows blanking a subnet so no records are published.
6145 my $rec = $hpat; # start fresh with the template for each IP
6146##fixme: there really isn't a good way to handle sub-/24 zones here. This way at least
6147# seems less bad than some alternatives.
6148 $self->_template4_expand(\$rec, $ip, \$sub, $ipindex);
6149 # _template4_expand may blank $rec; if so, don't publish a record
6150 next if !$rec;
6151 if ($ptronly || $zone->masklen > 24) {
6152 print $fh "^$lastoct.$arpabase:$rec:$ttl:$stamp:$loc\n" or die $!;
6153 if (!$ptronly) {
6154 # print a separate A record. Arguably we could use an = record here instead.
6155 print $fh "+$rec:$ip:$ttl:$stamp:$loc\n" or die $!;
6156 }
6157 } else {
6158 print $fh "=$rec:$ip:$ttl:$stamp:$loc\n" or die $!;
6159 }
6160 }
6161 } # __publish_subnet
6162
6163## And now the meat.
6164
6165##fixme? append . to all host/val hostnames
6166#print "debug: rawdata: $host $typemap{$type} $val\n";
6167
6168 if ($typemap{$type} eq 'SOA') {
6169 # host contains pri-ns:responsible
6170 # val is abused to contain refresh:retry:expire:minttl
6171##fixme: "manual" serial vs tinydns-autoserial
6172 # let's be explicit about abusing $host and $val
6173 my ($email, $primary) = (split /:/, $host)[0,1];
6174 my ($refresh, $retry, $expire, $min_ttl) = (split /:/, $val)[0,1,2,3];
6175 if ($revrec eq 'y') {
6176##fixme: have to publish SOA records for each v4 /24 in sub-/16, and each /16 in sub-/8
6177# what about v6?
6178# -> only need SOA for local chunks offset from reverse delegation boundaries, so v6 is fine
6179# anyone who says they need sub-nibble v6 delegations, at this time, needs their head examined.
6180 $zone = NetAddr::IP->new($zone);
6181 # handle split-n-multiply SOA for off-octet (8 < mask < 16) or (16 < mask < 24) v4 zones
6182 if (!$zone->{isv6} && ($zone->masklen < 24) && ($zone->masklen % 8 != 0)) {
6183 foreach my $szone ($zone->split($zone->masklen + (8 - $zone->masklen % 8))) {
6184 $szone = _ZONE($szone, 'ZONE.in-addr.arpa', 'r', '.');
6185 print $datafile "Z$szone:$primary:$email"."::$refresh:$retry:$expire:$min_ttl:$ttl:$stamp:$loc\n"
6186 or die $!;
6187 }
6188 return; # skips "default" bits just below
6189 }
6190 $zone = _ZONE($zone, 'ZONE', 'r', '.').($zone->{isv6} ? '.ip6.arpa' : '.in-addr.arpa');
6191 }
6192 print $datafile "Z$zone:$primary:$email"."::$refresh:$retry:$expire:$min_ttl:$ttl:$stamp:$loc\n"
6193 or die $!;
6194
6195 } elsif ($typemap{$type} eq 'A') {
6196
6197 ($host,$val) = __revswap($host,$val) if $revrec eq 'y';
6198 print $datafile "+$host:$val:$ttl:$stamp:$loc\n" or die $!;
6199
6200 } elsif ($typemap{$type} eq 'NS') {
6201
6202 if ($revrec eq 'y') {
6203 $val = NetAddr::IP->new($val);
6204 # handle split-n-multiply SOA for off-octet (8 < mask < 16) or (16 < mask < 24) v4 zones
6205 if (!$val->{isv6} && ($val->masklen < 24) && ($val->masklen % 8 != 0)) {
6206 foreach my $szone ($val->split($val->masklen + (8 - $val->masklen % 8))) {
6207 my $szone2 = _ZONE($szone, 'ZONE.in-addr.arpa', 'r', '.');
6208 next if $$recflags{$szone2} && $$recflags{$szone2} > $val->masklen;
6209 print $datafile "\&$szone2"."::$host:$ttl:$stamp:$loc\n" or die $!;
6210 $$recflags{$szone2} = $val->masklen;
6211 }
6212 } elsif ($val->{isv6} && ($val->masklen < 64) && ($val->masklen % 4 !=0)) {
6213 foreach my $szone ($val->split($val->masklen + (4 - $val->masklen % 4))) {
6214 my $szone2 = _ZONE($szone, 'ZONE.ip6.arpa', 'r', '.');
6215 next if $$recflags{$szone2} && $$recflags{$szone2} > $val->masklen;
6216 print $datafile "\&$szone2"."::$host:$ttl:$stamp:$loc\n" or die $!;
6217 $$recflags{$szone2} = $val->masklen;
6218 }
6219 } else {
6220 my $val2 = _ZONE($val, 'ZONE', 'r', '.').($val->{isv6} ? '.ip6.arpa' : '.in-addr.arpa');
6221 print $datafile "\&$val2"."::$host:$ttl:$stamp:$loc\n" or die $!;
6222 $$recflags{$val2} = $val->masklen;
6223 }
6224 } else {
6225 print $datafile "\&$host"."::$val:$ttl:$stamp:$loc\n" or die $!;
6226 }
6227
6228 } elsif ($typemap{$type} eq 'AAAA') {
6229
6230 ($host,$val) = __revswap($host,$val) if $revrec eq 'y';
6231 my $altgrp = 0;
6232 my @altconv;
6233 # Split in to up to 8 groups of hex digits (allows for IPv6 :: 0-collapsing)
6234 foreach (split /:/, $val) {
6235 if (/^$/) {
6236 # flag blank entry; this is a series of 0's of (currently) unknown length
6237 $altconv[$altgrp++] = 's';
6238 } else {
6239 # call sub to convert 1-4 hex digits to 2 string-rep octal bytes
6240 $altconv[$altgrp++] = octalize($_)
6241 }
6242 }
6243 my $prefix = ":$host:28:";
6244 foreach my $octet (@altconv) {
6245 # if not 's', output
6246 $prefix .= $octet unless $octet =~ /^s$/;
6247 # if 's', output (9-array length)x literal '\000\000'
6248 $prefix .= '\000\000'x(9-$altgrp) if $octet =~ /^s$/;
6249 }
6250 print $datafile "$prefix:$ttl:$stamp:$loc\n" or die $!;
6251
6252 } elsif ($typemap{$type} eq 'MX') {
6253
6254 ($host,$val) = __revswap($host,$val) if $revrec eq 'y';
6255 print $datafile "\@$host"."::$val:$dist:$ttl:$stamp:$loc\n" or die $!;
6256
6257 } elsif ($typemap{$type} eq 'TXT') {
6258
6259 ($host,$val) = __revswap($host,$val) if $revrec eq 'y';
6260# le sigh. Some idiot DNS implementations don't seem to like tinydns autosplitting
6261# long TXT records at 127 characters instead of 255. Hand-crafting a record seems
6262# to paper over the remote stupid. We will NOT try to split on whitespace; the
6263# contents of a TXT record are opaque and clients who can't deal are even more broken
6264# than the ones that don't like them split at 127 characters... because BIND tries
6265# to "intelligently" split TXT data, and abso-by-damn-lutely generates chunks <255
6266# characters, and anything that can't interpret BIND's DNS responses has no business
6267# trying to interpret DNS data at all.
6268
6269 if ($self->{autotxt}) {
6270 # let tinydns deal with splitting the record. note tinydns autosplits at 127
6271 # characters, not 255. Because Reasons.
6272 $val =~ s/:/\\072/g; # may need to replace other symbols
6273 print $datafile "'$host:$val:$ttl:$stamp:$loc\n" or die $!;
6274 } else {
6275 print $datafile ":$host:16:";
6276 my @txtsegs = $val =~ /.{1,255}/g;
6277 foreach (@txtsegs) {
6278 my $len = length($_);
6279 s/:/\\072/g;
6280 printf $datafile "\\%0.3o%s", $len, $_;
6281 }
6282 print $datafile ":$ttl:$stamp:$loc\n";
6283 }
6284
6285# by-hand TXT
6286#:deepnet.cx:16:2v\075spf1\040a\040a\072bacon.deepnet.cx\040a\072home.deepnet.cx\040-all:3600
6287#@ IN TXT "v=spf1 a a:bacon.deepnet.cx a:home.deepnet.cx -all"
6288#'deepnet.cx:v=spf1 a a\072bacon.deepnet.cx a\072home.deepnet.cx -all:3600
6289
6290#txttest IN TXT "v=foo bar:bob kn;ob' \" !@#$%^&*()-=_+[]{}<>?"
6291#:txttest.deepnet.cx:16:\054v\075foo\040bar\072bob\040kn\073ob\047\040\042\040\041\100\043\044\045\136\046\052\050\051-\075\137\053\133\135\173\175\074\076\077:3600
6292
6293# very long TXT record as brought in by axfr-get
6294# note tinydns does not support >512-byte RR data, need axfr-dns (for TCP support) for that
6295# also note, tinydns does not seem to support <512, >256-byte RRdata from axfr-get either. :/
6296#:longtxt.deepnet.cx:16:
6297#\170this is a very long txt record. it is really long. long. very long. really very long. this is a very long txt record.
6298#\263 it is really long. long. very long. really very long. this is a very long txt record. it is really long. long. very long. really very long. this is a very long txt record.
6299#\351 it is really long. long. very long. really very long.this is a very long txt record. it is really long. long. very long. really very long. this is a very long txt record. it is really long. long. very long. really very long.
6300#:3600
6301
6302 } elsif ($typemap{$type} eq 'CNAME') {
6303
6304 ($host,$val) = __revswap($host,$val) if $revrec eq 'y';
6305 print $datafile "C$host:$val:$ttl:$stamp:$loc\n" or die $!;
6306
6307 } elsif ($typemap{$type} eq 'SRV') {
6308
6309 ($host,$val) = __revswap($host,$val) if $revrec eq 'y';
6310
6311 # data is two-byte values for priority, weight, port, in that order,
6312 # followed by length/string data
6313
6314 my $prefix = ":$host:33:".octalize($dist,'d').octalize($weight,'d').octalize($port,'d');
6315
6316 $val .= '.' if $val !~ /\.$/;
6317 foreach (split /\./, $val) {
6318 $prefix .= sprintf "\\%0.3o%s", length($_), $_ or die $!;
6319 }
6320 print $datafile "$prefix\\000:$ttl:$stamp:$loc\n" or die $!;
6321
6322 } elsif ($typemap{$type} eq 'RP') {
6323
6324 ($host,$val) = __revswap($host,$val) if $revrec eq 'y';
6325 # RP consists of two mostly free-form strings.
6326 # The first is supposed to be an email address with @ replaced by . (as with the SOA contact)
6327 # The second is the "hostname" of a TXT record with more info.
6328 my $prefix = ":$host:17:";
6329 my ($who,$what) = split /\s/, $val;
6330 foreach (split /\./, $who) {
6331 $prefix .= sprintf "\\%0.3o%s", length($_), $_;
6332 }
6333 $prefix .= '\000';
6334 foreach (split /\./, $what) {
6335 $prefix .= sprintf "\\%0.3o%s", length($_), $_;
6336 }
6337 print $datafile "$prefix\\000:$ttl:$stamp:$loc\n" or die $!;
6338
6339 } elsif ($typemap{$type} eq 'PTR') {
6340
6341 $$recflags{$val}++;
6342 if ($revrec eq 'y') {
6343
6344 if ($val =~ /\.arpa$/) {
6345 # someone put in the formal .arpa name. humor them.
6346 print $datafile "^$val:$host:$ttl:$stamp:$loc\n" or die $!;
6347 } else {
6348 $zone = NetAddr::IP->new($zone);
6349 if (!$zone->{isv6} && $zone->masklen > 24) {
6350 # sub-octet v4 zone
6351 ($val) = ($val =~ /\.(\d+)$/);
6352 print $datafile "^$val."._ZONE($zone, 'ZONE', 'r', '.').'.in-addr.arpa'.
6353 ":$host:$ttl:$stamp:$loc\n" or die $!;
6354 } else {
6355 # not going to care about strange results if $val is not an IP value and is resolveable in DNS
6356 $val = NetAddr::IP->new($val);
6357 print $datafile "^".
6358 _ZONE($val, 'ZONE', 'r', '.').($val->{isv6} ? '.ip6.arpa' : '.in-addr.arpa').
6359 ":$host:$ttl:$stamp:$loc\n" or die $!;
6360 }
6361 } # non-".arpa" $val
6362
6363 } else {
6364 # PTRs in forward zones are less bizarre and insane than some other record types
6365 # in reverse zones... OTOH we can't validate them any which way, so we cross our
6366 # fingers and close our eyes and make it Someone Else's Problem.
6367 print $datafile "^$host:$val:$ttl:$stamp:$loc\n" or die $!;
6368 }
6369
6370 } elsif ($type == 65280) { # A+PTR
6371
6372 $$recflags{$val}++;
6373 print $datafile "=$host:$val:$ttl:$stamp:$loc\n" or die $!;
6374
6375 } elsif ($type == 65281) { # AAAA+PTR
6376
6377 $$recflags{$val}++;
6378 # treat these as two separate records. since tinydns doesn't have
6379 # a native combined type, we have to create them separately anyway.
6380 # print both; a dangling record is harmless, and impossible via web
6381 # UI anyway
6382 $self->_printrec_tiny($datafile,$recid,'n',$recflags, $self->domainName($self->_hostparent($host)),
6383 $host, 28, $val, $dist, $weight, $port, $ttl, $loc, $stamp);
6384 $self->_printrec_tiny($datafile, $recid, 'y', $recflags, $zone,
6385 $host, 12, $val, $dist, $weight, $port, $ttl, $loc, $stamp);
6386
6387##fixme: add a config flag to indicate use of the patch from http://www.fefe.de/dns/
6388# type 6 is for AAAA+PTR, type 3 is for AAAA
6389
6390 } elsif ($type == 65282) { # PTR template
6391
6392 # only useful for v4 with standard DNS software, since this expands all
6393 # IPs in $zone (or possibly $val?) with autogenerated records
6394 $val = NetAddr::IP->new($val);
6395 return if $val->{isv6};
6396
6397 if ($val->masklen <= 16) {
6398 foreach my $sub ($val->split(16)) {
6399 $self->__publish_subnet($sub, $recflags, $host, $datafile, $ttl, $stamp, $loc, $zone, 1);
6400 }
6401 } else {
6402 $self->__publish_subnet($val, $recflags, $host, $datafile, $ttl, $stamp, $loc, $zone, 1);
6403 }
6404
6405 } elsif ($type == 65283) { # A+PTR template
6406
6407 $val = NetAddr::IP->new($val);
6408 # Just In Case. An A+PTR should be impossible to add to a v6 revzone via API.
6409 return if $val->{isv6};
6410
6411 if ($val->masklen < 16) {
6412 foreach my $sub ($val->split(16)) {
6413 $self->__publish_subnet($sub, $recflags, $host, $datafile, $ttl, $stamp, $loc, $zone, 0);
6414 }
6415 } else {
6416 $self->__publish_subnet($val, $recflags, $host, $datafile, $ttl, $stamp, $loc, $zone, 0);
6417 }
6418
6419 } elsif ($type == 65284) { # AAAA+PTR template
6420 # Stub for completeness. Could be exported to DNS software that supports
6421 # some degree of internal automagic in generic-record-creation
6422 # (eg http://search.cpan.org/dist/AllKnowingDNS/ )
6423
6424 } elsif ($type == 65285) { # Delegation
6425 # This is intended for reverse zones, but may prove useful in forward zones.
6426
6427 # All delegations need to create one or more NS records. The NS record handler knows what to do.
6428 $self->_printrec_tiny($datafile,$recid,$revrec,$recflags,$zone,$host,$reverse_typemap{'NS'},
6429 $val,$dist,$weight,$port,$ttl,$loc,$stamp);
6430 if ($revrec eq 'y') {
6431 # In the case of a sub-/24 v4 reverse delegation, we need to generate CNAMEs
6432 # to redirect all of the individual IP lookups as well.
6433 # OR
6434 # create NS records for each IP
6435 # Not sure how this would actually resolve if a /24 or larger was delegated
6436 # one way, and a sub-/24 in that >=/24 was delegated elsewhere...
6437 my $dblock = NetAddr::IP->new($val);
6438 if (!$dblock->{isv6} && $dblock->masklen > 24) {
6439 my @subs = $dblock->split;
6440 foreach (@subs) {
6441 next if $$recflags{"$_"};
6442 my ($oct) = ($_->addr =~ /(\d+)$/);
6443 print $datafile "C"._ZONE($_, 'ZONE.in-addr.arpa', 'r', '.').":$oct.".
6444 _ZONE($dblock, 'ZONE.in-addr.arpa', 'r', '.').":$ttl:$stamp:$loc\n" or die $!;
6445 $$recflags{"$_"}++;
6446 }
6447 }
6448 }
6449
6450##
6451## Uncommon types. These will need better UI support Any Day Sometime Maybe(TM).
6452##
6453
6454 } elsif ($type == 44) { # SSHFP
6455
6456 ($host,$val) = __revswap($host,$val) if $revrec eq 'y';
6457
6458 my ($algo,$fpt,$fp) = split /\s+/, $val;
6459
6460 my $rec = sprintf ":$host:44:\\%0.3o\\%0.3o", $algo, $fpt;
6461 while (my ($byte) = ($fp =~ /^(..)/) ) {
6462 $rec .= sprintf "\\%0.3o", hex($byte);
6463 $fp =~ s/^..//;
6464 }
6465 print $datafile "$rec:$ttl:$stamp:$loc\n" or die $!;
6466
6467 } else {
6468 # raw record. we don't know what's in here, so we ASS-U-ME the user has
6469 # put it in correctly, since either the user is messing directly with the
6470 # database, or the record was imported via AXFR
6471 # <split by char>
6472 # convert anything not a-zA-Z0-9.- to octal coding
6473
6474##fixme: add flag to export "unknown" record types - note we'll probably end up
6475# mangling them since they were written to the DB from Net::DNS::RR::<type>->rdatastr.
6476 #print $datafile ":$host:$type:$val:$ttl:$stamp:$loc\n";
6477
6478 } # record type if-else
6479
6480} # end _printrec_tiny()
6481
6482
6483## DNSDB::mailNotify()
6484# Sends notification mail to recipients regarding a DNSDB operation
6485sub mailNotify {
6486 my $self = shift;
6487 my $dbh = $self->{dbh};
6488 my ($subj,$message) = @_;
6489
6490 return if $self->{mailhost} eq 'smtp.example.com'; # do nothing if still using default SMTP host.
6491
6492 my $mailer = Net::SMTP->new($self->{mailhost}, Hello => "dnsadmin.$self->{domain}");
6493
6494 my $mailsender = ($self->{mailsender} ? $self->{mailsender} : $self->{mailnotify});
6495
6496 $mailer->mail($mailsender);
6497 $mailer->to($self->{mailnotify});
6498 $mailer->data("From: \"$self->{mailname}\" <$mailsender>\n",
6499 "To: <$self->{mailnotify}>\n",
6500 "Date: ".strftime("%a, %d %b %Y %H:%M:%S %z",localtime)."\n",
6501 "Subject: $subj\n",
6502 "X-Mailer: DNSAdmin v".$DNSDB::VERSION." Notify\n",
6503 "Organization: $self->{orgname}\n",
6504 "\n$message\n");
6505 $mailer->quit;
6506}
6507
6508# shut Perl up
65091;
Note: See TracBrowser for help on using the repository browser.