source: trunk/DNSDB.pm@ 401

Last change on this file since 401 was 401, checked in by Kris Deugau, 12 years ago

/trunk

Begin updating dns-rpc.cgi. See #43.

Add initRPC() in DNSDB.pm. This sets up the userdata needed
for logging. Prevent the pseudousers added in initRPC() from
being displayed in the user management UI. Sooner or later
this will need to be cleaned up so stale users can be deleted.
Closes #33.

Bring a bit more consistency to error messages, and eliminate
references to odd depths of the code, by adding "\n" to the end
of a few lurking die strings in DNSDB.pm, and all method-sub ones
in dns-rpc.cgi.

Clean up a handful of gross syntax and scope errors from copy-paste
work apparently never checked.

Call new loadConfig() and initRPC() on startup. Add a utility sub
to call from the method subs to check the RPC caller+IP pair against
the new config option.

Update call for delDomain to delZone. Update call to getDomRecs()
to match normalized hash-argument form from r397.

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