source: trunk/DNSDB.pm@ 608

Last change on this file since 608 was 608, checked in by Kris Deugau, 10 years ago

/trunk

To override the note in r607; NetAddr::IP will resolve hostnames or domain
names by way of gethostbyname() and use the resulting IP to create the
object. In most cases this shouldn't be an issue but some code segments
will now need an additional regex check before feeding things to NetAddr::IP
to ensure strange local DNS data doesn't cause user input to get mangled.

Fix code comment from r607; the bug is arguably in my use of NetAddr::IP
to answer "Is this an IP address?".

Remove NetAddr:IP call from a segment in dns.cgi; a simple regex check on
the zone CIDR retrieved from the database should be enough to answer "Is
this an IPv6 zone?".

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