source: trunk/DNSDB.pm@ 703

Last change on this file since 703 was 703, checked in by Kris Deugau, 8 years ago

/trunk

Switch to hand-crafting TXT records for export, instead of using tinydns'
native handling. Some idiot DNS clients (and some... less than experienced
IT support people) don't seem to like it autosplitting TXT records at
127 characters. So we'll split them by hand at 255 characters, and pray
nobody complains.

For mid-length DKIM key records, this generally means the record won't be
split at all, and for not-completely-loony SPF records, it will be split less.

Hooray for auditing checkboxes.

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