source: trunk/DNSDB.pm@ 250

Last change on this file since 250 was 249, checked in by Kris Deugau, 13 years ago

/trunk

Fix lurking bug in SQL tabledef from early idea for default rdns
records
Fix logic bugs in A+PTR creation in default records:

  • we should NOT blindly prepend 'ZONE.' if it's present in the value/IP
  • we should not blindly append $config{domain} if ADMINDOMAIN is in the hostname
  • we need to check for "ZONE.1", "ZONE,1", and "ZONE::1" in the "does this PTR exist?" check because otherwise we'll silently end up with duplicates

Minor tweak to call to addRec() so that changes from validation
get propagated all the way back up the call chain.
See #26

  • Property svn:keywords set to Date Rev Author Id
File size: 91.2 KB
Line 
1# dns/trunk/DNSDB.pm
2# Abstraction functions for DNS administration
3###
4# SVN revision info
5# $Date: 2012-02-29 17:42:40 +0000 (Wed, 29 Feb 2012) $
6# SVN revision $Rev: 249 $
7# Last update by $Author: kdeugau $
8###
9# Copyright (C) 2008-2011 - Kris Deugau <kdeugau@deepnet.cx>
10
11package DNSDB;
12
13use strict;
14use warnings;
15use Exporter;
16use DBI;
17use Net::DNS;
18use Crypt::PasswdMD5;
19use Net::SMTP;
20use NetAddr::IP qw(:lower);
21use POSIX;
22use vars qw($VERSION @ISA @EXPORT @EXPORT_OK %EXPORT_TAGS);
23
24$VERSION = 0.1; ##VERSION##
25@ISA = qw(Exporter);
26@EXPORT_OK = qw(
27 &initGlobals
28 &initPermissions &getPermissions &changePermissions &comparePermissions
29 &changeGroup
30 &loadConfig &connectDB &finish
31 &addDomain &delDomain &domainName &revName &domainID
32 &getZoneCount &getZoneList
33 &addGroup &delGroup &getChildren &groupName
34 &addUser &updateUser &delUser &userFullName &userStatus &getUserData
35 &getSOA &getRecLine &getDomRecs &getRecCount
36 &addRec &updateRec &delRec
37 &getTypelist
38 &getParents
39 &isParent
40 &domStatus &importAXFR
41 &export
42 &mailNotify
43 %typemap %reverse_typemap %config
44 %permissions @permtypes $permlist
45 );
46
47@EXPORT = (); # Export nothing by default.
48%EXPORT_TAGS = ( ALL => [qw(
49 &initGlobals
50 &initPermissions &getPermissions &changePermissions &comparePermissions
51 &changeGroup
52 &loadConfig &connectDB &finish
53 &addDomain &delDomain &domainName &revName &domainID
54 &getZoneCount &getZoneList
55 &addGroup &delGroup &getChildren &groupName
56 &addUser &updateUser &delUser &userFullName &userStatus &getUserData
57 &getSOA &getRecLine &getDomRecs &getRecCount
58 &addRec &updateRec &delRec
59 &getTypelist
60 &getParents
61 &isParent
62 &domStatus &importAXFR
63 &export
64 &mailNotify
65 %typemap %reverse_typemap %config
66 %permissions @permtypes $permlist
67 )]
68 );
69
70our $group = 1;
71our $errstr = '';
72
73# Halfway sane defaults for SOA, TTL, etc.
74# serial defaults to 0 for convenience.
75# value will be either YYYYMMDDNN for BIND/etc, or auto-internal for tinydns
76our %def = qw (
77 contact hostmaster.DOMAIN
78 prins ns1.myserver.com
79 serial 0
80 soattl 86400
81 refresh 10800
82 retry 3600
83 expire 604800
84 minttl 10800
85 ttl 10800
86);
87
88# Arguably defined wholly in the db, but little reason to change without supporting code changes
89our @permtypes = qw (
90 group_edit group_create group_delete
91 user_edit user_create user_delete
92 domain_edit domain_create domain_delete
93 record_edit record_create record_delete
94 self_edit admin
95);
96our $permlist = join(',',@permtypes);
97
98# DNS record type map and reverse map.
99# loaded from the database, from http://www.iana.org/assignments/dns-parameters
100our %typemap;
101our %reverse_typemap;
102
103our %permissions;
104
105# Prepopulate a basic config. Note some of these *will* cause errors if left unset.
106# note: add appropriate stanzas in loadConfig to parse these
107our %config = (
108 # Database connection info
109 dbname => 'dnsdb',
110 dbuser => 'dnsdb',
111 dbpass => 'secret',
112 dbhost => '',
113
114 # Email notice settings
115 mailhost => 'smtp.example.com',
116 mailnotify => 'dnsdb@example.com', # to
117 mailsender => 'dnsdb@example.com', # from
118 mailname => 'DNS Administration',
119 orgname => 'Example Corp',
120 domain => 'example.com',
121
122 # Template directory
123 templatedir => 'templates/',
124# fmeh. this is a real web path, not a logical internal one. hm..
125# cssdir => 'templates/',
126 sessiondir => 'session/',
127
128 # Session params
129 timeout => '3600', # 1 hour default
130
131 # Other miscellanea
132 log_failures => 1, # log all evarthing by default
133 perpage => 15,
134 );
135
136## (Semi)private variables
137# Hash of functions for validating record types. Filled in initGlobals() since
138# it relies on visibility flags from the rectypes table in the DB
139my %validators;
140
141
142##
143## utility functions
144# _rectable()
145# Takes default+rdns flags, returns appropriate table name
146sub _rectable {
147 my $def = shift;
148 my $rev = shift;
149
150 return 'records' if $def ne 'y';
151 return 'default_records' if $rev ne 'y';
152 return 'default_rev_records';
153} # end _rectable()
154
155# _recparent()
156# Takes default+rdns flags, returns appropriate parent-id column name
157sub _recparent {
158 my $def = shift;
159 my $rev = shift;
160
161 return 'group_id' if $def eq 'y';
162 return 'rdns_id' if $rev eq 'y';
163 return 'domain_id';
164} # end _recparent()
165
166# Check an IP to be added in a reverse zone to see if it's really in the requested parent.
167# Takes a database handle, default and reverse flags, IP (fragment) to check, parent zone ID,
168# and a reference to a NetAddr::IP object (also used to pass back a fully-reconstructed IP for
169# database insertion)
170sub _ipparent {
171 my $dbh = shift;
172 my $defrec = shift;
173 my $revrec = shift;
174 my $val = shift;
175 my $id = shift;
176 my $addr = shift;
177
178 return if $revrec ne 'y'; # this sub not useful in forward zones
179
180 $$addr = NetAddr::IP->new($$val); #necessary?
181
182 # subsub to split, reverse, and overlay an IP fragment on a netblock
183 sub __rev_overlay {
184 my $splitme = shift; # ':' or '.', m'lud?
185 my $parnet = shift;
186 my $val = shift;
187 my $addr = shift;
188
189 my $joinme = $splitme;
190 $splitme = '\.' if $splitme eq '.';
191 my @working = reverse(split($splitme, $parnet->addr));
192 my @parts = reverse(split($splitme, $$val));
193 for (my $i = 0; $i <= $#parts; $i++) {
194 $working[$i] = $parts[$i];
195 }
196 my $checkme = NetAddr::IP->new(join($joinme, reverse(@working))) or return 0;
197 return 0 unless $checkme->within($parnet);
198 $$addr = $checkme; # force "correct" IP to be recorded.
199 return 1;
200 }
201
202 my ($parstr) = $dbh->selectrow_array("SELECT revnet FROM revzones WHERE rdns_id = ?", undef, ($id));
203 my $parnet = NetAddr::IP->new($parstr);
204
205 # Fail early on v6-in-v4 or v4-in-v6. We're not accepting these ATM.
206 return 0 if $parnet->addr =~ /\./ && $$val =~ /:/;
207 return 0 if $parnet->addr =~ /:/ && $$val =~ /\./;
208
209 if ($$addr && $$val =~ /^[\da-fA-F][\da-fA-F:]+[\da-fA-F]$/) {
210 # the only case where NetAddr::IP's acceptance of legitimate IPs is "correct" is for a proper IPv6 address.
211 # the rest we have to restructure before fiddling. *sigh*
212 return 1 if $$addr->within($parnet);
213 } else {
214 # We don't have a complete IP in $$val (yet)
215 if ($parnet->addr =~ /:/) {
216 $$val =~ s/^:+//; # gotta strip'em all...
217 return __rev_overlay(':', $parnet, $val, $addr);
218 }
219 if ($parnet->addr =~ /\./) {
220 $$val =~ s/^\.+//;
221 return __rev_overlay('.', $parnet, $val, $addr);
222 }
223 # should be impossible to get here...
224 }
225 # ... and here.
226 # can't do nuttin' in forward zones
227} # end _ipparent()
228
229# A little different than _ipparent above; this tries to *find* the parent zone of a hostname
230sub _hostparent {
231 my $dbh = shift;
232 my $hname = shift;
233
234 my @hostbits = split /\./, $hname;
235 my $sth = $dbh->prepare("SELECT count(*),domain_id FROM domains WHERE domain = ? GROUP BY domain_id");
236 foreach (@hostbits) {
237 $sth->execute($hname);
238 my ($found, $parid) = $sth->fetchrow_array;
239 if ($found) {
240 return $parid;
241 }
242 $hname =~ s/^$_\.//;
243 }
244} # end _hostparent()
245
246##
247## Record validation subs.
248##
249
250# A record
251sub _validate_1 {
252 my $dbh = shift;
253
254 my %args = @_;
255
256 return ('FAIL', 'Reverse zones cannot contain A records') if $args{revrec} eq 'y';
257
258 # Coerce all hostnames to end in ".DOMAIN" for group/default records,
259 # or the intended parent domain for live records.
260 my $pname = ($args{defrec} eq 'y' ? 'DOMAIN' : domainName($dbh,$args{id}));
261 ${$args{host}} =~ s/\.*$/\.$pname/ if ${$args{host}} !~ /$pname$/;
262
263 # Check IP is well-formed, and that it's a v4 address
264 # Fail on "compact" IPv4 variants, because they are not consistent and predictable.
265 return ('FAIL',"$typemap{${$args{rectype}}} record must be a valid IPv4 address")
266 unless ${$args{val}} =~ /^\d+\.\d+\.\d+\.\d+$/;
267 return ('FAIL',"$typemap{${$args{rectype}}} record must be a valid IPv4 address")
268 unless $args{addr} && !$args{addr}->{isv6};
269 # coerce IP/value to normalized form for storage
270 ${$args{val}} = $args{addr}->addr;
271
272 return ('OK','OK');
273} # done A record
274
275# NS record
276sub _validate_2 {
277 my $dbh = shift;
278
279 my %args = @_;
280
281 # Coerce the hostname to "DOMAIN" for forward default records, "ZONE" for reverse default records,
282 # or the intended parent zone for live records.
283##fixme: allow for delegating <subdomain>.DOMAIN?
284 if ($args{revrec} eq 'y') {
285 my $pname = ($args{defrec} eq 'y' ? 'ZONE' : revName($dbh,$args{id}));
286 ${$args{host}} = $pname if ${$args{host}} ne $pname;
287 } else {
288 my $pname = ($args{defrec} eq 'y' ? 'DOMAIN' : domainName($dbh,$args{id}));
289 ${$args{host}} = $pname if ${$args{host}} ne $pname;
290 }
291
292# Let this lie for now. Needs more magic.
293# # Check IP is well-formed, and that it's a v4 address
294# return ('FAIL',"A record must be a valid IPv4 address")
295# unless $addr && !$addr->{isv6};
296# # coerce IP/value to normalized form for storage
297# $$val = $addr->addr;
298
299 return ('OK','OK');
300} # done NS record
301
302# CNAME record
303sub _validate_5 {
304 my $dbh = shift;
305
306 my %args = @_;
307
308# Not really true, but these are only useful for delegating smaller-than-/24 IP blocks.
309# This is fundamentally a messy operation and should really just be taken care of by the
310# export process, not manual maintenance of the necessary records.
311 return ('FAIL', 'Reverse zones cannot contain CNAME records') if $args{revrec} eq 'y';
312
313 # Coerce all hostnames to end in ".DOMAIN" for group/default records,
314 # or the intended parent domain for live records.
315 my $pname = ($args{defrec} eq 'y' ? 'DOMAIN' : domainName($dbh,$args{id}));
316 ${$args{host}} =~ s/\.*$/\.$pname/ if ${$args{host}} !~ /$pname$/;
317
318 return ('OK','OK');
319} # done CNAME record
320
321# SOA record
322sub _validate_6 {
323 # Smart monkeys won't stick their fingers in here; we have
324 # separate dedicated routines to deal with SOA records.
325 return ('OK','OK');
326} # done SOA record
327
328# PTR record
329sub _validate_12 {
330 my $dbh = shift;
331
332 my %args = @_;
333
334 if ($args{revrec} eq 'y') {
335 if ($args{defrec} eq 'n') {
336 return ('FAIL', "IP or IP fragment ${$args{val}} is not within ".revName($dbh, $args{id}))
337 unless _ipparent($dbh, $args{defrec}, $args{revrec}, $args{val}, $args{id}, \$args{addr});
338 ${$args{val}} = $args{addr}->addr;
339 } else {
340 if (${$args{val}} =~ /\./) {
341 # looks like a v4 or fragment
342 if (${$args{val}} =~ /^\d+\.\d+\.\d+\.\d+$/) {
343 # woo! a complete IP! validate it and normalize, or fail.
344 $args{addr} = NetAddr::IP->new(${$args{val}})
345 or return ('FAIL', "IP/value looks like IPv4 but isn't valid");
346 ${$args{val}} = $args{addr}->addr;
347 } else {
348 ${$args{val}} =~ s/^\.*/ZONE./ unless ${$args{val}} =~ /^ZONE/;
349 }
350 } elsif (${$args{val}} =~ /[a-f:]/) {
351 # looks like a v6 or fragment
352 ${$args{val}} =~ s/^:*/ZONE::/ if !$args{addr};
353 if ($args{addr}) {
354 if ($args{addr}->addr =~ /^0/) {
355 ${$args{val}} =~ s/^:*/ZONE::/;
356 } else {
357 ${$args{val}} = $args{addr}->addr;
358 }
359 }
360 } else {
361 # bare number (probably). These could be v4 or v6, so we'll
362 # expand on these on creation of a reverse zone.
363 ${$args{val}} = "ZONE,${$args{val}}";
364 }
365 ${$args{host}} =~ s/\.*$/\.$config{domain}/ if ${$args{host}} !~ /(?:$config{domain}|ADMINDOMAIN)$/;
366 }
367
368# Multiple PTR records do NOT generally do what most people believe they do,
369# and tend to fail in the most awkward way possible. Check and warn.
370# We use $val instead of $addr->addr since we may be in a defrec, and may have eg "ZONE::42" or "ZONE.12"
371
372 my @checkvals = (${$args{val}});
373 if (${$args{val}} =~ /,/) {
374 # push . and :: variants into checkvals if val has ,
375 my $tmp;
376 ($tmp = ${$args{val}}) =~ s/,/./;
377 push @checkvals, $tmp;
378 ($tmp = ${$args{val}}) =~ s/,/::/;
379 push @checkvals, $tmp;
380 }
381 my $pcsth = $dbh->prepare("SELECT count(*) FROM "._rectable($args{defrec},$args{revrec})." WHERE val = ?");
382 foreach my $checkme (@checkvals) {
383 my $ptrcount;
384 ($ptrcount) = $dbh->selectrow_array("SELECT count(*) FROM "._rectable($args{defrec},$args{revrec}).
385 " WHERE val = ?", undef, ($checkme));
386 return ('WARN', "PTR record for $checkme already exists; adding another will probably not do what you want")
387 if $ptrcount;
388 }
389 } else {
390 # Not absolutely true but only useful if you hack things up for sub-/24 v4 reverse delegations
391 # Simpler to just create the reverse zone and grant access for the customer to edit it, and create direct
392 # PTR records on export
393 return ('FAIL',"Forward zones cannot contain PTR records");
394 }
395
396 return ('OK','OK');
397} # done PTR record
398
399# MX record
400sub _validate_15 {
401 my $dbh = shift;
402
403 my %args = @_;
404
405# Not absolutely true but WTF use is an MX record for a reverse zone?
406 return ('FAIL', 'Reverse zones cannot contain MX records') if $args{revrec} eq 'y';
407
408 return ('FAIL', "Distance is required for MX records") unless defined(${$args{dist}});
409 ${$args{dist}} =~ s/\s*//g;
410 return ('FAIL',"Distance is required, and must be numeric") unless ${$args{dist}} =~ /^\d+$/;
411
412 ${$args{fields}} = "distance,";
413 push @{$args{vallist}}, ${$args{dist}};
414
415 # Coerce all hostnames to end in ".DOMAIN" for group/default records,
416 # or the intended parent domain for live records.
417 my $pname = ($args{defrec} eq 'y' ? 'DOMAIN' : domainName($dbh,$args{id}));
418 ${$args{host}} =~ s/\.*$/\.$pname/ if ${$args{host}} !~ /$pname$/;
419
420 return ('OK','OK');
421} # done MX record
422
423# TXT record
424sub _validate_16 {
425 # Could arguably put a WARN return here on very long (>512) records
426 return ('OK','OK');
427} # done TXT record
428
429# RP record
430sub _validate_17 {
431 # Probably have to validate these some day
432 return ('OK','OK');
433} # done RP record
434
435# AAAA record
436sub _validate_28 {
437 my $dbh = shift;
438
439 my %args = @_;
440
441 return ('FAIL', 'Reverse zones cannot contain AAAA records') if $args{revrec} eq 'y';
442
443 # Coerce all hostnames to end in ".DOMAIN" for group/default records,
444 # or the intended parent domain for live records.
445 my $pname = ($args{defrec} eq 'y' ? 'DOMAIN' : domainName($dbh,$args{id}));
446 ${$args{host}} =~ s/\.*$/\.$pname/ if ${$args{host}} !~ /$pname$/;
447
448 # Check IP is well-formed, and that it's a v6 address
449 return ('FAIL',"$typemap{${$args{rectype}}} record must be a valid IPv6 address")
450 unless $args{addr} && $args{addr}->{isv6};
451 # coerce IP/value to normalized form for storage
452 ${$args{val}} = $args{addr}->addr;
453
454 return ('OK','OK');
455} # done AAAA record
456
457# SRV record
458sub _validate_33 {
459 my $dbh = shift;
460
461 my %args = @_;
462
463# Not absolutely true but WTF use is an SRV record for a reverse zone?
464 return ('FAIL', 'Reverse zones cannot contain SRV records') if $args{revrec} eq 'y';
465
466 return ('FAIL', "Distance is required for SRV records") unless defined(${$args{dist}});
467 ${$args{dist}} =~ s/\s*//g;
468 return ('FAIL',"Distance is required, and must be numeric") unless ${$args{dist}} =~ /^\d+$/;
469
470 return ('FAIL',"SRV records must begin with _service._protocol [${$args{host}}]")
471 unless ${$args{host}} =~ /^_[A-Za-z]+\._[A-Za-z]+\.[a-zA-Z0-9-]+/;
472 return ('FAIL',"Port and weight are required for SRV records")
473 unless defined(${$args{weight}}) && defined(${$args{port}});
474 ${$args{weight}} =~ s/\s*//g;
475 ${$args{port}} =~ s/\s*//g;
476
477 return ('FAIL',"Port and weight are required, and must be numeric")
478 unless ${$args{weight}} =~ /^\d+$/ && ${$args{port}} =~ /^\d+$/;
479
480 ${$args{fields}} = "distance,weight,port,";
481 push @{$args{vallist}}, (${$args{dist}}, ${$args{weight}}, ${$args{port}});
482
483 # Coerce all hostnames to end in ".DOMAIN" for group/default records,
484 # or the intended parent domain for live records.
485 my $pname = ($args{defrec} eq 'y' ? 'DOMAIN' : domainName($dbh,$args{id}));
486 ${$args{host}} =~ s/\.*$/\.$pname/ if ${$args{host}} !~ /$pname$/;
487
488 return ('OK','OK');
489} # done SRV record
490
491# Now the custom types
492
493# A+PTR record. With a very little bit of magic we can also use this sub to validate AAAA+PTR. Whee!
494sub _validate_65280 {
495 my $dbh = shift;
496
497 my %args = @_;
498
499 my $code = 'OK';
500 my $msg = 'OK';
501
502 if ($args{defrec} eq 'n') {
503 # live record; revrec determines whether we validate the PTR or A component first.
504
505 if ($args{revrec} eq 'y') {
506 ($code,$msg) = _validate_12($dbh, %args);
507 return ($code,$msg) if $code eq 'FAIL';
508
509 # Check if the reqested domain exists. If not, coerce the type down to PTR and warn.
510 if (!(${$args{domid}} = _hostparent($dbh, ${$args{host}}))) {
511 my $addmsg = "Record added as PTR instead of $typemap{${$args{rectype}}}; domain not found for ${$args{host}}";
512 $msg .= "\n$addmsg" if $code eq 'WARN';
513 $msg = $addmsg if $code eq 'OK';
514 ${$args{rectype}} = $reverse_typemap{PTR};
515 return ('WARN', $msg);
516 }
517
518 # Add domain ID to field list and values
519 ${$args{fields}} .= "domain_id,";
520 push @{$args{vallist}}, ${$args{domid}};
521
522 } else {
523 ($code,$msg) = _validate_1($dbh, %args) if ${$args{rectype}} == 65280;
524 ($code,$msg) = _validate_28($dbh, %args) if ${$args{rectype}} == 65281;
525 return ($code,$msg) if $code eq 'FAIL';
526
527 # Check if the requested reverse zone exists - note, an IP fragment won't
528 # work here since we don't *know* which parent to put it in.
529 # ${$args{val}} has been validated as a valid IP by now, in one of the above calls.
530 my ($revid) = $dbh->selectrow_array("SELECT rdns_id FROM revzones WHERE revnet >> ?".
531 " ORDER BY masklen(revnet) DESC", undef, (${$args{val}}));
532 if (!$revid) {
533 $msg = "Record added as ".(${$args{rectype}} == 65280 ? 'A' : 'AAAA').
534 " instead of $typemap{${$args{rectype}}}; reverse zone not found for ${$args{val}}";
535 ${$args{rectype}} = (${$args{rectype}} == 65280 ? $reverse_typemap{A} : $reverse_typemap{AAAA});
536 return ('WARN', $msg);
537 }
538
539 # Check for duplicate PTRs. Note we don't have to play games with $code and $msg, because
540 # by definition there can't be duplicate PTRs if the reverse zone isn't managed here.
541 my ($ptrcount) = $dbh->selectrow_array("SELECT count(*) FROM "._rectable($args{defrec},$args{revrec}).
542 " WHERE val = ?", undef, ${$args{val}});
543 if ($ptrcount) {
544 $msg = "PTR record for ${$args{val}} already exists; adding another will probably not do what you want";
545 $code = 'WARN';
546 }
547
548 ${$args{fields}} .= "rdns_id,";
549 push @{$args{vallist}}, $revid;
550 }
551
552 } else { # defrec eq 'y'
553 if ($args{revrec} eq 'y') {
554 ($code,$msg) = _validate_12($dbh, %args);
555 return ($code,$msg) if $code eq 'FAIL';
556 if (${$args{rectype}} == 65280) {
557 return ('FAIL',"A+PTR record must be a valid IPv4 address or fragment")
558 if ${$args{val}} =~ /:/;
559 ${$args{val}} =~ s/^ZONE,/ZONE./; # Clean up after uncertain IP-fragment-type from _validate_12
560 } elsif (${$args{rectype}} == 65281) {
561 return ('FAIL',"AAAA+PTR record must be a valid IPv6 address or fragment")
562 if ${$args{val}} =~ /\./;
563 ${$args{val}} =~ s/^ZONE,/ZONE::/; # Clean up after uncertain IP-fragment-type from _validate_12
564 }
565 } else {
566 # This is easy. I also can't see a real use-case for A/AAAA+PTR in *all* forward
567 # domains, since you wouldn't be able to substitute both domain and reverse zone
568 # sanely, and you'd end up with guaranteed over-replicated PTR records that would
569 # confuse the hell out of pretty much anything that uses them.
570##fixme: make this a config flag?
571 return ('FAIL', "$typemap{${$args{rectype}}} records not allowed in default domains");
572 }
573 }
574
575 return ($code, $msg);
576} # done A+PTR record
577
578# AAAA+PTR record
579# A+PTR above has been magicked to handle AAAA+PTR as well.
580sub _validate_65281 {
581 return _validate_65280(@_);
582} # done AAAA+PTR record
583
584# PTR template record
585sub _validate_65282 {
586 return ('OK','OK');
587} # done PTR template record
588
589# A+PTR template record
590sub _validate_65283 {
591 return ('OK','OK');
592} # done AAAA+PTR template record
593
594# AAAA+PTR template record
595sub _validate_65284 {
596 return ('OK','OK');
597} # done AAAA+PTR template record
598
599
600
601##
602## Initialization and cleanup subs
603##
604
605
606## DNSDB::loadConfig()
607# Load the minimum required initial state (DB connect info) from a config file
608# Load misc other bits while we're at it.
609# Takes an optional basename and config path to look for
610# Populates the %config and %def hashes
611sub loadConfig {
612 my $basename = shift || ''; # this will work OK
613##fixme $basename isn't doing what I think I thought I was trying to do.
614
615 my $deferr = ''; # place to put error from default config file in case we can't find either one
616
617 my $configroot = "/etc/dnsdb"; ##CFG_LEAF##
618 $configroot = '' if $basename =~ m|^/|;
619 $basename .= ".conf" if $basename !~ /\.conf$/;
620 my $defconfig = "$configroot/dnsdb.conf";
621 my $siteconfig = "$configroot/$basename";
622
623 # System defaults
624 __cfgload("$defconfig") or $deferr = $errstr;
625
626 # Per-site-ish settings.
627 if ($basename ne '.conf') {
628 unless (__cfgload("$siteconfig")) {
629 $errstr = ($deferr ? "Error opening default config file $defconfig: $deferr\n" : '').
630 "Error opening site config file $siteconfig";
631 return;
632 }
633 }
634
635 # Munge log_failures.
636 if ($config{log_failures} ne '1' && $config{log_failures} ne '0') {
637 # true/false, on/off, yes/no all valid.
638 if ($config{log_failures} =~ /^(?:true|false|on|off|yes|no)$/) {
639 if ($config{log_failures} =~ /(?:true|on|yes)/) {
640 $config{log_failures} = 1;
641 } else {
642 $config{log_failures} = 0;
643 }
644 } else {
645 $errstr = "Bad log_failures setting $config{log_failures}";
646 $config{log_failures} = 1;
647 # Bad setting shouldn't be fatal.
648 # return 2;
649 }
650 }
651
652 # All good, clear the error and go home.
653 $errstr = '';
654 return 1;
655} # end loadConfig()
656
657
658## DNSDB::__cfgload()
659# Private sub to parse a config file and load it into %config
660# Takes a file handle on an open config file
661sub __cfgload {
662 $errstr = '';
663 my $cfgfile = shift;
664
665 if (open CFG, "<$cfgfile") {
666 while (<CFG>) {
667 chomp;
668 s/^\s*//;
669 next if /^#/;
670 next if /^$/;
671# hmm. more complex bits in this file might require [heading] headers, maybe?
672# $mode = $1 if /^\[(a-z)+]/;
673 # DB connect info
674 $config{dbname} = $1 if /^dbname\s*=\s*([a-z0-9_.-]+)/i;
675 $config{dbuser} = $1 if /^dbuser\s*=\s*([a-z0-9_.-]+)/i;
676 $config{dbpass} = $1 if /^dbpass\s*=\s*([a-z0-9_.-]+)/i;
677 $config{dbhost} = $1 if /^dbhost\s*=\s*([a-z0-9_.-]+)/i;
678 # SOA defaults
679 $def{contact} = $1 if /^contact\s*=\s*([a-z0-9_.-]+)/i;
680 $def{prins} = $1 if /^prins\s*=\s*([a-z0-9_.-]+)/i;
681 $def{soattl} = $1 if /^soattl\s*=\s*(\d+)/i;
682 $def{refresh} = $1 if /^refresh\s*=\s*(\d+)/i;
683 $def{retry} = $1 if /^retry\s*=\s*(\d+)/i;
684 $def{expire} = $1 if /^expire\s*=\s*(\d+)/i;
685 $def{minttl} = $1 if /^minttl\s*=\s*(\d+)/i;
686 $def{ttl} = $1 if /^ttl\s*=\s*(\d+)/i;
687 # Mail settings
688 $config{mailhost} = $1 if /^mailhost\s*=\s*([a-z0-9_.-]+)/i;
689 $config{mailnotify} = $1 if /^mailnotify\s*=\s*([a-z0-9_.\@-]+)/i;
690 $config{mailsender} = $1 if /^mailsender\s*=\s*([a-z0-9_.\@-]+)/i;
691 $config{mailname} = $1 if /^mailname\s*=\s*([a-z0-9\s_.-]+)/i;
692 $config{orgname} = $1 if /^orgname\s*=\s*([a-z0-9\s_.,'-]+)/i;
693 $config{domain} = $1 if /^domain\s*=\s*([a-z0-9_.-]+)/i;
694 # session - note this is fed directly to CGI::Session
695 $config{timeout} = $1 if /^[tT][iI][mM][eE][oO][uU][tT]\s*=\s*(\d+[smhdwMy]?)/;
696 $config{sessiondir} = $1 if m{^sessiondir\s*=\s*([a-z0-9/_.-]+)}i;
697 # misc
698 $config{log_failures} = $1 if /^log_failures\s*=\s*([a-z01]+)/i;
699 $config{perpage} = $1 if /^perpage\s*=\s*(\d+)/i;
700 }
701 close CFG;
702 } else {
703 $errstr = $!;
704 return;
705 }
706 return 1;
707} # end __cfgload()
708
709
710## DNSDB::connectDB()
711# Creates connection to DNS database.
712# Requires the database name, username, and password.
713# Returns a handle to the db.
714# Set up for a PostgreSQL db; could be any transactional DBMS with the
715# right changes.
716sub connectDB {
717 $errstr = '';
718 my $dbname = shift;
719 my $user = shift;
720 my $pass = shift;
721 my $dbh;
722 my $DSN = "DBI:Pg:dbname=$dbname";
723
724 my $host = shift;
725 $DSN .= ";host=$host" if $host;
726
727# Note that we want to autocommit by default, and we will turn it off locally as necessary.
728# We may not want to print gobbledygook errors; YMMV. Have to ponder that further.
729 $dbh = DBI->connect($DSN, $user, $pass, {
730 AutoCommit => 1,
731 PrintError => 0
732 })
733 or return (undef, $DBI::errstr) if(!$dbh);
734
735##fixme: initialize the DB if we can't find the table (since, by definition, there's
736# nothing there if we can't select from it...)
737 my $tblsth = $dbh->prepare("SELECT count(*) FROM pg_catalog.pg_class WHERE relkind='r' AND relname=?");
738 my ($tblcount) = $dbh->selectrow_array($tblsth, undef, ('misc'));
739 return (undef,$DBI::errstr) if $dbh->err;
740
741#if ($tblcount == 0) {
742# # create tables one at a time, checking for each.
743# return (undef, "check table misc missing");
744#}
745
746
747# Return here if we can't select.
748# This should retrieve the dbversion key.
749 my $sth = $dbh->prepare("SELECT key,value FROM misc WHERE misc_id=1");
750 $sth->execute();
751 return (undef,$DBI::errstr) if ($sth->err);
752
753##fixme: do stuff to the DB on version mismatch
754# x.y series should upgrade on $DNSDB::VERSION > misc(key=>version)
755# DB should be downward-compatible; column defaults should give sane (if possibly
756# useless-and-needs-help) values in columns an older software stack doesn't know about.
757
758# See if the select returned anything (or null data). This should
759# succeed if the select executed, but...
760 $sth->fetchrow();
761 return (undef,$DBI::errstr) if ($sth->err);
762
763 $sth->finish;
764
765# If we get here, we should be OK.
766 return ($dbh,"DB connection OK");
767} # end connectDB
768
769
770## DNSDB::finish()
771# Cleans up after database handles and so on.
772# Requires a database handle
773sub finish {
774 my $dbh = $_[0];
775 $dbh->disconnect;
776} # end finish
777
778
779## DNSDB::initGlobals()
780# Initialize global variables
781# NB: this does NOT include web-specific session variables!
782# Requires a database handle
783sub initGlobals {
784 my $dbh = shift;
785
786# load record types from database
787 my $sth = $dbh->prepare("SELECT val,name,stdflag FROM rectypes");
788 $sth->execute;
789 while (my ($recval,$recname,$stdflag) = $sth->fetchrow_array()) {
790 $typemap{$recval} = $recname;
791 $reverse_typemap{$recname} = $recval;
792 # now we fill the record validation function hash
793 if ($stdflag < 5) {
794 my $fn = "_validate_$recval";
795 $validators{$recval} = \&$fn;
796 } else {
797 my $fn = "sub { return ('FAIL','Type $recval ($recname) not supported'); }";
798 $validators{$recval} = eval $fn;
799 }
800 }
801} # end initGlobals
802
803
804## DNSDB::initPermissions()
805# Set up permissions global
806# Takes database handle and UID
807sub initPermissions {
808 my $dbh = shift;
809 my $uid = shift;
810
811# %permissions = $(getPermissions($dbh,'user',$uid));
812 getPermissions($dbh, 'user', $uid, \%permissions);
813
814} # end initPermissions()
815
816
817## DNSDB::getPermissions()
818# Get permissions from DB
819# Requires DB handle, group or user flag, ID, and hashref.
820sub getPermissions {
821 my $dbh = shift;
822 my $type = shift;
823 my $id = shift;
824 my $hash = shift;
825
826 my $sql = qq(
827 SELECT
828 p.admin,p.self_edit,
829 p.group_create,p.group_edit,p.group_delete,
830 p.user_create,p.user_edit,p.user_delete,
831 p.domain_create,p.domain_edit,p.domain_delete,
832 p.record_create,p.record_edit,p.record_delete
833 FROM permissions p
834 );
835 if ($type eq 'group') {
836 $sql .= qq(
837 JOIN groups g ON g.permission_id=p.permission_id
838 WHERE g.group_id=?
839 );
840 } else {
841 $sql .= qq(
842 JOIN users u ON u.permission_id=p.permission_id
843 WHERE u.user_id=?
844 );
845 }
846
847 my $sth = $dbh->prepare($sql);
848
849 $sth->execute($id) or die "argh: ".$sth->errstr;
850
851# my $permref = $sth->fetchrow_hashref;
852# return $permref;
853# $hash = $permref;
854# Eww. Need to learn how to forcibly drop a hashref onto an existing hash.
855 ($hash->{admin},$hash->{self_edit},
856 $hash->{group_create},$hash->{group_edit},$hash->{group_delete},
857 $hash->{user_create},$hash->{user_edit},$hash->{user_delete},
858 $hash->{domain_create},$hash->{domain_edit},$hash->{domain_delete},
859 $hash->{record_create},$hash->{record_edit},$hash->{record_delete})
860 = $sth->fetchrow_array;
861
862} # end getPermissions()
863
864
865## DNSDB::changePermissions()
866# Update an ACL entry
867# Takes a db handle, type, owner-id, and hashref for the changed permissions.
868sub changePermissions {
869 my $dbh = shift;
870 my $type = shift;
871 my $id = shift;
872 my $newperms = shift;
873 my $inherit = shift || 0;
874
875 my $failmsg = '';
876
877 # see if we're switching from inherited to custom. for bonus points,
878 # snag the permid and parent permid anyway, since we'll need the permid
879 # to set/alter custom perms, and both if we're switching from custom to
880 # inherited.
881 my $sth = $dbh->prepare("SELECT (u.permission_id=g.permission_id) AS was_inherited,u.permission_id,g.permission_id".
882 " FROM ".($type eq 'user' ? 'users' : 'groups')." u ".
883 " JOIN groups g ON u.".($type eq 'user' ? '' : 'parent_')."group_id=g.group_id ".
884 " WHERE u.".($type eq 'user' ? 'user' : 'group')."_id=?");
885 $sth->execute($id);
886
887 my ($wasinherited,$permid,$parpermid) = $sth->fetchrow_array;
888
889# hack phtoui
890# group id 1 is "special" in that it's it's own parent (err... possibly.)
891# may make its parent id 0 which doesn't exist, and as a bonus is Perl-false.
892 $wasinherited = 0 if ($type eq 'group' && $id == 1);
893
894 local $dbh->{AutoCommit} = 0;
895 local $dbh->{RaiseError} = 1;
896
897 # Wrap all the SQL in a transaction
898 eval {
899 if ($inherit) {
900
901 $dbh->do("UPDATE ".($type eq 'user' ? 'users' : 'groups')." SET inherit_perm='t',permission_id=? ".
902 "WHERE ".($type eq 'user' ? 'user' : 'group')."_id=?", undef, ($parpermid, $id) );
903 $dbh->do("DELETE FROM permissions WHERE permission_id=?", undef, ($permid) );
904
905 } else {
906
907 if ($wasinherited) { # munge new permission entry in if we're switching from inherited perms
908##fixme: need to add semirecursive bit to properly munge inherited permission ID on subgroups and users
909# ... if'n'when we have groups with fully inherited permissions.
910 # SQL is coo
911 $dbh->do("INSERT INTO permissions ($permlist,".($type eq 'user' ? 'user' : 'group')."_id) ".
912 "SELECT $permlist,? FROM permissions WHERE permission_id=?", undef, ($id,$permid) );
913 ($permid) = $dbh->selectrow_array("SELECT permission_id FROM permissions ".
914 "WHERE ".($type eq 'user' ? 'user' : 'group')."_id=?", undef, ($id) );
915 $dbh->do("UPDATE ".($type eq 'user' ? 'users' : 'groups')." SET inherit_perm='f',permission_id=? ".
916 "WHERE ".($type eq 'user' ? 'user' : 'group')."_id=?", undef, ($permid, $id) );
917 }
918
919 # and now set the permissions we were passed
920 foreach (@permtypes) {
921 if (defined ($newperms->{$_})) {
922 $dbh->do("UPDATE permissions SET $_=? WHERE permission_id=?", undef, ($newperms->{$_},$permid) );
923 }
924 }
925
926 } # (inherited->)? custom
927
928 $dbh->commit;
929 }; # end eval
930 if ($@) {
931 my $msg = $@;
932 eval { $dbh->rollback; };
933 return ('FAIL',"$failmsg: $msg ($permid)");
934 } else {
935 return ('OK',$permid);
936 }
937
938} # end changePermissions()
939
940
941## DNSDB::comparePermissions()
942# Compare two permission hashes
943# Returns '>', '<', '=', '!'
944sub comparePermissions {
945 my $p1 = shift;
946 my $p2 = shift;
947
948 my $retval = '='; # assume equality until proven otherwise
949
950 no warnings "uninitialized";
951
952 foreach (@permtypes) {
953 next if $p1->{$_} == $p2->{$_}; # equal is good
954 if ($p1->{$_} && !$p2->{$_}) {
955 if ($retval eq '<') { # if we've already found an unequal pair where
956 $retval = '!'; # $p2 has more access, and we now find a pair
957 last; # where $p1 has more access, the overall access
958 } # is neither greater or lesser, it's unequal.
959 $retval = '>';
960 }
961 if (!$p1->{$_} && $p2->{$_}) {
962 if ($retval eq '>') { # if we've already found an unequal pair where
963 $retval = '!'; # $p1 has more access, and we now find a pair
964 last; # where $p2 has more access, the overall access
965 } # is neither greater or lesser, it's unequal.
966 $retval = '<';
967 }
968 }
969 return $retval;
970} # end comparePermissions()
971
972
973## DNSDB::changeGroup()
974# Change group ID of an entity
975# Takes a database handle, entity type, entity ID, and new group ID
976sub changeGroup {
977 my $dbh = shift;
978 my $type = shift;
979 my $id = shift;
980 my $newgrp = shift;
981
982##fixme: fail on not enough args
983 #return ('FAIL', "Missing
984
985 if ($type eq 'domain') {
986 $dbh->do("UPDATE domains SET group_id=? WHERE domain_id=?", undef, ($newgrp, $id))
987 or return ('FAIL','Group change failed: '.$dbh->errstr);
988 } elsif ($type eq 'user') {
989 $dbh->do("UPDATE users SET group_id=? WHERE user_id=?", undef, ($newgrp, $id))
990 or return ('FAIL','Group change failed: '.$dbh->errstr);
991 } elsif ($type eq 'group') {
992 $dbh->do("UPDATE groups SET parent_group_id=? WHERE group_id=?", undef, ($newgrp, $id))
993 or return ('FAIL','Group change failed: '.$dbh->errstr);
994 }
995 return ('OK','OK');
996} # end changeGroup()
997
998
999## DNSDB::_log()
1000# Log an action
1001# Internal sub
1002# Takes a database handle, domain_id, user_id, group_id, email, name and log entry
1003##fixme: convert to trailing hash for user info
1004# User info must contain a (user ID OR username)+fullname
1005sub _log {
1006 my $dbh = shift;
1007 my ($domain_id,$user_id,$group_id,$username,$name,$entry) = @_;
1008
1009##fixme: need better way(s?) to snag userinfo for log entries. don't want to have
1010# to pass around yet *another* constant (already passing $dbh, shouldn't need to)
1011 my $fullname;
1012 if (!$user_id) {
1013 ($user_id, $fullname) = $dbh->selectrow_array("SELECT user_id, firstname || ' ' || lastname FROM users".
1014 " WHERE username=?", undef, ($username));
1015 } elsif (!$username) {
1016 ($username, $fullname) = $dbh->selectrow_array("SELECT username, firstname || ' ' || lastname FROM users".
1017 " WHERE user_id=?", undef, ($user_id));
1018 } else {
1019 ($fullname) = $dbh->selectrow_array("SELECT firstname || ' ' || lastname FROM users".
1020 " WHERE user_id=?", undef, ($user_id));
1021 }
1022
1023 $name = $fullname if !$name;
1024
1025##fixme: farm out the actual logging to different subs for file, syslog, internal, etc based on config
1026 $dbh->do("INSERT INTO log (domain_id,user_id,group_id,email,name,entry) VALUES (?,?,?,?,?,?)", undef,
1027 ($domain_id,$user_id,$group_id,$username,$name,$entry));
1028# 123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890
1029# 1 2 3 4 5 6 7
1030} # end _log
1031
1032
1033##
1034## Processing subs
1035##
1036
1037## DNSDB::addDomain()
1038# Add a domain
1039# Takes a database handle, domain name, numeric group, boolean(ish) state (active/inactive),
1040# and user info hash (for logging).
1041# Returns a status code and message
1042sub addDomain {
1043 $errstr = '';
1044 my $dbh = shift;
1045 return ('FAIL',"Need database handle") if !$dbh;
1046 my $domain = shift;
1047 return ('FAIL',"Domain must not be blank") if !$domain;
1048 my $group = shift;
1049 return ('FAIL',"Need group") if !defined($group);
1050 my $state = shift;
1051 return ('FAIL',"Need domain status") if !defined($state);
1052
1053 my %userinfo = @_; # remaining bits.
1054# user ID, username, user full name
1055
1056 $state = 1 if $state =~ /^active$/;
1057 $state = 1 if $state =~ /^on$/;
1058 $state = 0 if $state =~ /^inactive$/;
1059 $state = 0 if $state =~ /^off$/;
1060
1061 return ('FAIL',"Invalid domain status") if $state !~ /^\d+$/;
1062
1063 return ('FAIL', "Invalid characters in domain") if $domain !~ /^[a-zA-Z0-9_.-]+$/;
1064
1065 my $sth = $dbh->prepare("SELECT domain_id FROM domains WHERE domain=?");
1066 my $dom_id;
1067
1068# quick check to start to see if we've already got one
1069 $sth->execute($domain);
1070 ($dom_id) = $sth->fetchrow_array;
1071
1072 return ('FAIL', "Domain already exists") if $dom_id;
1073
1074 # Allow transactions, and raise an exception on errors so we can catch it later.
1075 # Use local to make sure these get "reset" properly on exiting this block
1076 local $dbh->{AutoCommit} = 0;
1077 local $dbh->{RaiseError} = 1;
1078
1079 # Wrap all the SQL in a transaction
1080 eval {
1081 # insert the domain...
1082 $dbh->do("INSERT INTO domains (domain,group_id,status) VALUES (?,?,?)", undef, ($domain, $group, $state));
1083
1084 # get the ID...
1085 ($dom_id) = $dbh->selectrow_array("SELECT domain_id FROM domains WHERE domain=?", undef, ($domain));
1086
1087 _log($dbh, $dom_id, $userinfo{id}, $group, $userinfo{name}, $userinfo{fullname},
1088 "Added ".($state ? 'active' : 'inactive')." domain $domain");
1089
1090 # ... and now we construct the standard records from the default set. NB: group should be variable.
1091 my $sth = $dbh->prepare("SELECT host,type,val,distance,weight,port,ttl FROM default_records WHERE group_id=?");
1092 my $sth_in = $dbh->prepare("INSERT INTO records (domain_id,host,type,val,distance,weight,port,ttl)".
1093 " VALUES ($dom_id,?,?,?,?,?,?,?)");
1094 $sth->execute($group);
1095 while (my ($host,$type,$val,$dist,$weight,$port,$ttl) = $sth->fetchrow_array()) {
1096 $host =~ s/DOMAIN/$domain/g;
1097 $val =~ s/DOMAIN/$domain/g;
1098 $sth_in->execute($host,$type,$val,$dist,$weight,$port,$ttl);
1099 if ($typemap{$type} eq 'SOA') {
1100 my @tmp1 = split /:/, $host;
1101 my @tmp2 = split /:/, $val;
1102 _log($dbh, $dom_id, $userinfo{id}, $group, $userinfo{name}, $userinfo{fullname},
1103 "[new $domain] Added SOA record [contact $tmp1[0]] [master $tmp1[1]] ".
1104 "[refresh $tmp2[0]] [retry $tmp2[1]] [expire $tmp2[2]] [minttl $tmp2[3]], TTL $ttl");
1105 } else {
1106 my $logentry = "[new $domain] Added record '$host $typemap{$type}";
1107 $logentry .= " [distance $dist]" if $typemap{$type} eq 'MX';
1108 $logentry .= " [priority $dist] [weight $weight] [port $port]" if $typemap{$type} eq 'SRV';
1109 _log($dbh, $dom_id, $userinfo{id}, $group, $userinfo{name}, $userinfo{fullname},
1110 $logentry." $val', TTL $ttl");
1111 }
1112 }
1113
1114 # once we get here, we should have suceeded.
1115 $dbh->commit;
1116 }; # end eval
1117
1118 if ($@) {
1119 my $msg = $@;
1120 eval { $dbh->rollback; };
1121 return ('FAIL',$msg);
1122 } else {
1123 return ('OK',$dom_id);
1124 }
1125} # end addDomain
1126
1127
1128## DNSDB::delDomain()
1129# Delete a domain.
1130# for now, just delete the records, then the domain.
1131# later we may want to archive it in some way instead (status code 2, for example?)
1132sub delDomain {
1133 my $dbh = shift;
1134 my $domid = shift;
1135
1136 # Allow transactions, and raise an exception on errors so we can catch it later.
1137 # Use local to make sure these get "reset" properly on exiting this block
1138 local $dbh->{AutoCommit} = 0;
1139 local $dbh->{RaiseError} = 1;
1140
1141 my $failmsg = '';
1142
1143 # Wrap all the SQL in a transaction
1144 eval {
1145 my $sth = $dbh->prepare("delete from records where domain_id=?");
1146 $failmsg = "Failure removing domain records";
1147 $sth->execute($domid);
1148 $sth = $dbh->prepare("delete from domains where domain_id=?");
1149 $failmsg = "Failure removing domain";
1150 $sth->execute($domid);
1151
1152 # once we get here, we should have suceeded.
1153 $dbh->commit;
1154 }; # end eval
1155
1156 if ($@) {
1157 my $msg = $@;
1158 eval { $dbh->rollback; };
1159 return ('FAIL',"$failmsg: $msg");
1160 } else {
1161 return ('OK','OK');
1162 }
1163
1164} # end delDomain()
1165
1166
1167## DNSDB::domainName()
1168# Return the domain name based on a domain ID
1169# Takes a database handle and the domain ID
1170# Returns the domain name or undef on failure
1171sub domainName {
1172 $errstr = '';
1173 my $dbh = shift;
1174 my $domid = shift;
1175 my ($domname) = $dbh->selectrow_array("SELECT domain FROM domains WHERE domain_id=?", undef, ($domid) );
1176 $errstr = $DBI::errstr if !$domname;
1177 return $domname if $domname;
1178} # end domainName()
1179
1180
1181## DNSDB::revName()
1182# Return the reverse zone name based on an rDNS ID
1183# Takes a database handle and the rDNS ID
1184# Returns the reverse zone name or undef on failure
1185sub revName {
1186 $errstr = '';
1187 my $dbh = shift;
1188 my $revid = shift;
1189 my ($revname) = $dbh->selectrow_array("SELECT revnet FROM revzones WHERE rdns_id=?", undef, ($revid) );
1190 $errstr = $DBI::errstr if !$revname;
1191 return $revname if $revname;
1192} # end revName()
1193
1194
1195## DNSDB::domainID()
1196# Takes a database handle and domain name
1197# Returns the domain ID number
1198sub domainID {
1199 $errstr = '';
1200 my $dbh = shift;
1201 my $domain = shift;
1202 my ($domid) = $dbh->selectrow_array("SELECT domain_id FROM domains WHERE domain=?", undef, ($domain) );
1203 $errstr = $DBI::errstr if !$domid;
1204 return $domid if $domid;
1205} # end domainID()
1206
1207
1208## DNSDB::getZoneCount
1209# Get count of zones in group or groups
1210# Takes a database handle and hash containing:
1211# - the "current" group
1212# - an array of "acceptable" groups
1213# - a flag for forward/reverse zones
1214# - Optionally accept a "starts with" and/or "contains" filter argument
1215# Returns an integer count of the resulting zone list.
1216sub getZoneCount {
1217 my $dbh = shift;
1218
1219 my %args = @_;
1220
1221 my @filterargs;
1222 $args{startwith} = undef if $args{startwith} && $args{startwith} !~ /^(?:[a-z]|0-9)$/;
1223 push @filterargs, "^$args{startwith}" if $args{startwith};
1224 $args{filter} =~ s/\./\[\.\]/g if $args{filter}; # only match literal dots, usually in reverse zones
1225 push @filterargs, $args{filter} if $args{filter};
1226
1227 my $sql;
1228 # Not as compact, and fix-me-twice if the common bits get wrong, but much easier to read
1229 if ($args{revrec} eq 'n') {
1230 $sql = "SELECT count(*) FROM domains".
1231 " WHERE group_id IN ($args{curgroup}".($args{childlist} ? ",$args{childlist}" : '').")".
1232 ($args{startwith} ? " AND domain ~* ?" : '').
1233 ($args{filter} ? " AND domain ~* ?" : '');
1234 } else {
1235 $sql = "SELECT count(*) FROM revzones".
1236 " WHERE group_id IN ($args{curgroup}".($args{childlist} ? ",$args{childlist}" : '').")".
1237 ($args{startwith} ? " AND CAST(revnet AS VARCHAR) ~* ?" : '').
1238 ($args{filter} ? " AND CAST(revnet AS VARCHAR) ~* ?" : '');
1239 }
1240 my ($count) = $dbh->selectrow_array($sql, undef, @filterargs);
1241 return $count;
1242} # end getZoneCount()
1243
1244
1245## DNSDB::getZoneList()
1246# Get a list of zones in the specified group(s)
1247# Takes the same arguments as getZoneCount() above
1248# Returns a reference to an array of hashrefs suitable for feeding to HTML::Template
1249sub getZoneList {
1250 my $dbh = shift;
1251
1252 my %args = @_;
1253
1254 my @zonelist;
1255
1256 $args{sortorder} = 'ASC' if !grep $args{sortorder}, ('ASC','DESC');
1257 $args{offset} = 0 if !$args{offset} || $args{offset} !~ /^(?:all|\d+)$/;
1258
1259 my @filterargs;
1260 $args{startwith} = undef if $args{startwith} && $args{startwith} !~ /^(?:[a-z]|0-9)$/;
1261 push @filterargs, "^$args{startwith}" if $args{startwith};
1262 $args{filter} =~ s/\./\[\.\]/g if $args{filter}; # only match literal dots, usually in reverse zones
1263 push @filterargs, $args{filter} if $args{filter};
1264
1265 my $sql;
1266 # Not as compact, and fix-me-twice if the common bits get wrong, but much easier to read
1267 if ($args{revrec} eq 'n') {
1268 $args{sortby} = 'domain' if !grep $args{sortby}, ('revnet','group','status');
1269 $sql = "SELECT domain_id,domain,status,groups.group_name AS group FROM domains".
1270 " INNER JOIN groups ON domains.group_id=groups.group_id".
1271 " WHERE domains.group_id IN ($args{curgroup}".($args{childlist} ? ",$args{childlist}" : '').")".
1272 ($args{startwith} ? " AND domain ~* ?" : '').
1273 ($args{filter} ? " AND domain ~* ?" : '');
1274 } else {
1275##fixme: arguably startwith here is irrelevant. depends on the UI though.
1276 $args{sortby} = 'revnet' if !grep $args{sortby}, ('domain','group','status');
1277 $sql = "SELECT rdns_id,revnet,status,groups.group_name AS group FROM revzones".
1278 " INNER JOIN groups ON revzones.group_id=groups.group_id".
1279 " WHERE revzones.group_id IN ($args{curgroup}".($args{childlist} ? ",$args{childlist}" : '').")".
1280 ($args{startwith} ? " AND CAST(revnet AS VARCHAR) ~* ?" : '').
1281 ($args{filter} ? " AND CAST(revnet AS VARCHAR) ~* ?" : '');
1282 }
1283 # A common tail.
1284 $sql .= " ORDER BY ".($args{sortby} eq 'group' ? 'groups.group_name' : $args{sortby})." $args{sortorder} ".
1285 ($args{offset} eq 'all' ? '' : " LIMIT $config{perpage}".
1286 " OFFSET ".$args{offset}*$config{perpage});
1287 my $sth = $dbh->prepare($sql);
1288 $sth->execute(@filterargs);
1289 my $rownum = 0;
1290
1291 while (my @data = $sth->fetchrow_array) {
1292 my %row;
1293 $row{domainid} = $data[0];
1294 $row{domain} = $data[1];
1295 $row{status} = $data[2];
1296 $row{group} = $data[3];
1297 push @zonelist, \%row;
1298 }
1299
1300 return \@zonelist;
1301} # end getZoneList()
1302
1303
1304## DNSDB::addGroup()
1305# Add a group
1306# Takes a database handle, group name, parent group, hashref for permissions,
1307# and optional template-vs-cloneme flag
1308# Returns a status code and message
1309sub addGroup {
1310 $errstr = '';
1311 my $dbh = shift;
1312 my $groupname = shift;
1313 my $pargroup = shift;
1314 my $permissions = shift;
1315
1316 # 0 indicates "custom", hardcoded.
1317 # Any other value clones that group's default records, if it exists.
1318 my $inherit = shift || 0;
1319##fixme: need a flag to indicate clone records or <?> ?
1320
1321 # Allow transactions, and raise an exception on errors so we can catch it later.
1322 # Use local to make sure these get "reset" properly on exiting this block
1323 local $dbh->{AutoCommit} = 0;
1324 local $dbh->{RaiseError} = 1;
1325
1326 my $sth = $dbh->prepare("SELECT group_id FROM groups WHERE group_name=?");
1327 my $group_id;
1328
1329# quick check to start to see if we've already got one
1330 $sth->execute($groupname);
1331 ($group_id) = $sth->fetchrow_array;
1332
1333 return ('FAIL', "Group already exists") if $group_id;
1334
1335 # Wrap all the SQL in a transaction
1336 eval {
1337 $sth = $dbh->prepare("INSERT INTO groups (parent_group_id,group_name) VALUES (?,?)");
1338 $sth->execute($pargroup,$groupname);
1339
1340 $sth = $dbh->prepare("SELECT group_id FROM groups WHERE group_name=?");
1341 $sth->execute($groupname);
1342 my ($groupid) = $sth->fetchrow_array();
1343
1344# Permissions
1345 if ($inherit) {
1346 } else {
1347 my @permvals;
1348 foreach (@permtypes) {
1349 if (!defined ($permissions->{$_})) {
1350 push @permvals, 0;
1351 } else {
1352 push @permvals, $permissions->{$_};
1353 }
1354 }
1355
1356 $sth = $dbh->prepare("INSERT INTO permissions (group_id,$permlist) values (?".',?'x($#permtypes+1).")");
1357 $sth->execute($groupid,@permvals);
1358
1359 $sth = $dbh->prepare("SELECT permission_id FROM permissions WHERE group_id=?");
1360 $sth->execute($groupid);
1361 my ($permid) = $sth->fetchrow_array();
1362
1363 $dbh->do("UPDATE groups SET permission_id=$permid WHERE group_id=$groupid");
1364 } # done permission fiddling
1365
1366# Default records
1367 $sth = $dbh->prepare("INSERT INTO default_records (group_id,host,type,val,distance,weight,port,ttl) ".
1368 "VALUES ($groupid,?,?,?,?,?,?,?)");
1369 if ($inherit) {
1370 # Duplicate records from parent. Actually relying on inherited records feels
1371 # very fragile, and it would be problematic to roll over at a later time.
1372 my $sth2 = $dbh->prepare("SELECT host,type,val,distance,weight,port,ttl FROM default_records WHERE group_id=?");
1373 $sth2->execute($pargroup);
1374 while (my @clonedata = $sth2->fetchrow_array) {
1375 $sth->execute(@clonedata);
1376 }
1377 } else {
1378##fixme: Hardcoding is Bad, mmmmkaaaay?
1379 # reasonable basic defaults for SOA, MX, NS, and minimal hosting
1380 # could load from a config file, but somewhere along the line we need hardcoded bits.
1381 $sth->execute('ns1.example.com:hostmaster.example.com', 6, '10800:3600:604800:10800', 0, 0, 0, 86400);
1382 $sth->execute('DOMAIN', 1, '192.168.4.2', 0, 0, 0, 7200);
1383 $sth->execute('DOMAIN', 15, 'mx.example.com', 10, 0, 0, 7200);
1384 $sth->execute('DOMAIN', 2, 'ns1.example.com', 0, 0, 0, 7200);
1385 $sth->execute('DOMAIN', 2, 'ns2.example.com', 0, 0, 0, 7200);
1386 $sth->execute('www.DOMAIN', 5, 'DOMAIN', 0, 0, 0, 7200);
1387 }
1388
1389 # once we get here, we should have suceeded.
1390 $dbh->commit;
1391 }; # end eval
1392
1393 if ($@) {
1394 my $msg = $@;
1395 eval { $dbh->rollback; };
1396 return ('FAIL',$msg);
1397 } else {
1398 return ('OK','OK');
1399 }
1400
1401} # end addGroup()
1402
1403
1404## DNSDB::delGroup()
1405# Delete a group.
1406# Takes a group ID
1407# Returns a status code and message
1408sub delGroup {
1409 my $dbh = shift;
1410 my $groupid = shift;
1411
1412 # Allow transactions, and raise an exception on errors so we can catch it later.
1413 # Use local to make sure these get "reset" properly on exiting this block
1414 local $dbh->{AutoCommit} = 0;
1415 local $dbh->{RaiseError} = 1;
1416
1417##fixme: locate "knowable" error conditions and deal with them before the eval
1418# ... or inside, whatever.
1419# -> domains still exist in group
1420# -> ...
1421 my $failmsg = '';
1422
1423 # Wrap all the SQL in a transaction
1424 eval {
1425 my $sth = $dbh->prepare("SELECT count(*) FROM domains WHERE group_id=?");
1426 $sth->execute($groupid);
1427 my ($domcnt) = $sth->fetchrow_array;
1428 $failmsg = "Can't remove group ".groupName($dbh,$groupid);
1429 die "$domcnt domains still in group\n" if $domcnt;
1430
1431 $sth = $dbh->prepare("delete from default_records where group_id=?");
1432 $failmsg = "Failed to delete default records for ".groupName($dbh,$groupid);
1433 $sth->execute($groupid);
1434 $sth = $dbh->prepare("delete from groups where group_id=?");
1435 $failmsg = "Failed to remove group ".groupName($dbh,$groupid);
1436 $sth->execute($groupid);
1437
1438 # once we get here, we should have suceeded.
1439 $dbh->commit;
1440 }; # end eval
1441
1442 if ($@) {
1443 my $msg = $@;
1444 eval { $dbh->rollback; };
1445 return ('FAIL',"$failmsg: $msg");
1446 } else {
1447 return ('OK','OK');
1448 }
1449} # end delGroup()
1450
1451
1452## DNSDB::getChildren()
1453# Get a list of all groups whose parent^n is group <n>
1454# Takes a database handle, group ID, reference to an array to put the group IDs in,
1455# and an optional flag to return only immediate children or all children-of-children
1456# default to returning all children
1457# Calls itself
1458sub getChildren {
1459 $errstr = '';
1460 my $dbh = shift;
1461 my $rootgroup = shift;
1462 my $groupdest = shift;
1463 my $immed = shift || 'all';
1464
1465 # special break for default group; otherwise we get stuck.
1466 if ($rootgroup == 1) {
1467 # by definition, group 1 is the Root Of All Groups
1468 my $sth = $dbh->prepare("SELECT group_id FROM groups WHERE NOT (group_id=1)".
1469 ($immed ne 'all' ? " AND parent_group_id=1" : ''));
1470 $sth->execute;
1471 while (my @this = $sth->fetchrow_array) {
1472 push @$groupdest, @this;
1473 }
1474 } else {
1475 my $sth = $dbh->prepare("SELECT group_id FROM groups WHERE parent_group_id=?");
1476 $sth->execute($rootgroup);
1477 return if $sth->rows == 0;
1478 my @grouplist;
1479 while (my ($group) = $sth->fetchrow_array) {
1480 push @$groupdest, $group;
1481 getChildren($dbh,$group,$groupdest) if $immed eq 'all';
1482 }
1483 }
1484} # end getChildren()
1485
1486
1487## DNSDB::groupName()
1488# Return the group name based on a group ID
1489# Takes a database handle and the group ID
1490# Returns the group name or undef on failure
1491sub groupName {
1492 $errstr = '';
1493 my $dbh = shift;
1494 my $groupid = shift;
1495 my $sth = $dbh->prepare("SELECT group_name FROM groups WHERE group_id=?");
1496 $sth->execute($groupid);
1497 my ($groupname) = $sth->fetchrow_array();
1498 $errstr = $DBI::errstr if !$groupname;
1499 return $groupname if $groupname;
1500} # end groupName
1501
1502
1503## DNSDB::groupID()
1504# Return the group ID based on the group name
1505# Takes a database handle and the group name
1506# Returns the group ID or undef on failure
1507sub groupID {
1508 $errstr = '';
1509 my $dbh = shift;
1510 my $group = shift;
1511 my ($grpid) = $dbh->selectrow_array("SELECT group_id FROM groups WHERE group=?", undef, ($group) );
1512 $errstr = $DBI::errstr if !$grpid;
1513 return $grpid if $grpid;
1514} # end groupID()
1515
1516
1517## DNSDB::addUser()
1518# Add a user.
1519# Takes a DB handle, username, group ID, password, state (active/inactive).
1520# Optionally accepts:
1521# user type (user/admin) - defaults to user
1522# permissions string - defaults to inherit from group
1523# three valid forms:
1524# i - Inherit permissions
1525# c:<user_id> - Clone permissions from <user_id>
1526# C:<permission list> - Set these specific permissions
1527# first name - defaults to username
1528# last name - defaults to blank
1529# phone - defaults to blank (could put other data within column def)
1530# Returns (OK,<uid>) on success, (FAIL,<message>) on failure
1531sub addUser {
1532 $errstr = '';
1533 my $dbh = shift;
1534 my $username = shift;
1535 my $group = shift;
1536 my $pass = shift;
1537 my $state = shift;
1538
1539 return ('FAIL', "Missing one or more required entries") if !defined($state);
1540 return ('FAIL', "Username must not be blank") if !$username;
1541
1542 my $type = shift || 'u'; # create limited users by default - fwiw, not sure yet how this will interact with ACLs
1543
1544 my $permstring = shift || 'i'; # default is to inhert permissions from group
1545
1546 my $fname = shift || $username;
1547 my $lname = shift || '';
1548 my $phone = shift || ''; # not going format-check
1549
1550 my $sth = $dbh->prepare("SELECT user_id FROM users WHERE username=?");
1551 my $user_id;
1552
1553# quick check to start to see if we've already got one
1554 $sth->execute($username);
1555 ($user_id) = $sth->fetchrow_array;
1556
1557 return ('FAIL', "User already exists") if $user_id;
1558
1559 # Allow transactions, and raise an exception on errors so we can catch it later.
1560 # Use local to make sure these get "reset" properly on exiting this block
1561 local $dbh->{AutoCommit} = 0;
1562 local $dbh->{RaiseError} = 1;
1563
1564 my $failmsg = '';
1565
1566 # Wrap all the SQL in a transaction
1567 eval {
1568 # insert the user... note we set inherited perms by default since
1569 # it's simple and cleans up some other bits of state
1570 my $sth = $dbh->prepare("INSERT INTO users ".
1571 "(group_id,username,password,firstname,lastname,phone,type,status,permission_id,inherit_perm) ".
1572 "VALUES (?,?,?,?,?,?,?,?,(SELECT permission_id FROM permissions WHERE group_id=?),'t')");
1573 $sth->execute($group,$username,unix_md5_crypt($pass),$fname,$lname,$phone,$type,$state,$group);
1574
1575 # get the ID...
1576 ($user_id) = $dbh->selectrow_array("SELECT currval('users_user_id_seq')");
1577
1578# Permissions! Gotta set'em all!
1579 die "Invalid permission string $permstring"
1580 if $permstring !~ /^(?:
1581 i # inherit
1582 |c:\d+ # clone
1583 # custom. no, the leading , is not a typo
1584 |C:(?:,(?:group|user|domain|record|self)_(?:edit|create|delete))*
1585 )$/x;
1586# bleh. I'd call another function to do my dirty work, but we're in the middle of a transaction already.
1587 if ($permstring ne 'i') {
1588 # for cloned or custom permissions, we have to create a new permissions entry.
1589 my $clonesrc = $group;
1590 if ($permstring =~ /^c:(\d+)/) { $clonesrc = $1; }
1591 $dbh->do("INSERT INTO permissions ($permlist,user_id) ".
1592 "SELECT $permlist,? FROM permissions WHERE permission_id=".
1593 "(SELECT permission_id FROM permissions WHERE ".($permstring =~ /^c:/ ? 'user' : 'group')."_id=?)",
1594 undef, ($user_id,$clonesrc) );
1595 $dbh->do("UPDATE users SET permission_id=".
1596 "(SELECT permission_id FROM permissions WHERE user_id=?) ".
1597 "WHERE user_id=?", undef, ($user_id, $user_id) );
1598 }
1599 if ($permstring =~ /^C:/) {
1600 # finally for custom permissions, we set the passed-in permissions (and unset
1601 # any that might have been brought in by the clone operation above)
1602 my ($permid) = $dbh->selectrow_array("SELECT permission_id FROM permissions WHERE user_id=?",
1603 undef, ($user_id) );
1604 foreach (@permtypes) {
1605 if ($permstring =~ /,$_/) {
1606 $dbh->do("UPDATE permissions SET $_='t' WHERE permission_id=?", undef, ($permid) );
1607 } else {
1608 $dbh->do("UPDATE permissions SET $_='f' WHERE permission_id=?", undef, ($permid) );
1609 }
1610 }
1611 }
1612
1613 $dbh->do("UPDATE users SET inherit_perm='n' WHERE user_id=?", undef, ($user_id) );
1614
1615##fixme: add another table to hold name/email for log table?
1616
1617 # once we get here, we should have suceeded.
1618 $dbh->commit;
1619 }; # end eval
1620
1621 if ($@) {
1622 my $msg = $@;
1623 eval { $dbh->rollback; };
1624 return ('FAIL',$msg." $failmsg");
1625 } else {
1626 return ('OK',$user_id);
1627 }
1628} # end addUser
1629
1630
1631## DNSDB::checkUser()
1632# Check user/pass combo on login
1633sub checkUser {
1634 my $dbh = shift;
1635 my $user = shift;
1636 my $inpass = shift;
1637
1638 my $sth = $dbh->prepare("SELECT user_id,group_id,password,firstname,lastname FROM users WHERE username=?");
1639 $sth->execute($user);
1640 my ($uid,$gid,$pass,$fname,$lname) = $sth->fetchrow_array;
1641 my $loginfailed = 1 if !defined($uid);
1642
1643 if ($pass =~ m|^\$1\$([A-Za-z0-9/.]+)\$|) {
1644 $loginfailed = 1 if $pass ne unix_md5_crypt($inpass,$1);
1645 } else {
1646 $loginfailed = 1 if $pass ne $inpass;
1647 }
1648
1649 # nnnngggg
1650 return ($uid, $gid);
1651} # end checkUser
1652
1653
1654## DNSDB:: updateUser()
1655# Update general data about user
1656sub updateUser {
1657 my $dbh = shift;
1658
1659##fixme: tweak calling convention so that we can update any given bit of data
1660 my $uid = shift;
1661 my $username = shift;
1662 my $group = shift;
1663 my $pass = shift;
1664 my $state = shift;
1665 my $type = shift || 'u';
1666 my $fname = shift || $username;
1667 my $lname = shift || '';
1668 my $phone = shift || ''; # not going format-check
1669
1670 my $failmsg = '';
1671
1672 # Allow transactions, and raise an exception on errors so we can catch it later.
1673 # Use local to make sure these get "reset" properly on exiting this block
1674 local $dbh->{AutoCommit} = 0;
1675 local $dbh->{RaiseError} = 1;
1676
1677 my $sth;
1678
1679 # Password can be left blank; if so we assume there's one on file.
1680 # Actual blank passwords are bad, mm'kay?
1681 if (!$pass) {
1682 $sth = $dbh->prepare("SELECT password FROM users WHERE user_id=?");
1683 $sth->execute($uid);
1684 ($pass) = $sth->fetchrow_array;
1685 } else {
1686 $pass = unix_md5_crypt($pass);
1687 }
1688
1689 eval {
1690 my $sth = $dbh->prepare(q(
1691 UPDATE users
1692 SET username=?, password=?, firstname=?, lastname=?, phone=?, type=?, status=?
1693 WHERE user_id=?
1694 )
1695 );
1696 $sth->execute($username, $pass, $fname, $lname, $phone, $type, $state, $uid);
1697 $dbh->commit;
1698 };
1699 if ($@) {
1700 my $msg = $@;
1701 eval { $dbh->rollback; };
1702 return ('FAIL',"$failmsg: $msg");
1703 } else {
1704 return ('OK','OK');
1705 }
1706} # end updateUser()
1707
1708
1709## DNSDB::delUser()
1710#
1711sub delUser {
1712 my $dbh = shift;
1713 return ('FAIL',"Need database handle") if !$dbh;
1714 my $userid = shift;
1715 return ('FAIL',"Missing userid") if !defined($userid);
1716
1717 my $sth = $dbh->prepare("delete from users where user_id=?");
1718 $sth->execute($userid);
1719
1720 return ('FAIL',"Couldn't remove user: ".$sth->errstr) if $sth->err;
1721
1722 return ('OK','OK');
1723
1724} # end delUser
1725
1726
1727## DNSDB::userFullName()
1728# Return a pretty string!
1729# Takes a user_id and optional printf-ish string to indicate which pieces where:
1730# %u for the username
1731# %f for the first name
1732# %l for the last name
1733# All other text in the passed string will be left as-is.
1734##fixme: need a "smart" option too, so that missing/null/blank first/last names don't give funky output
1735sub userFullName {
1736 $errstr = '';
1737 my $dbh = shift;
1738 my $userid = shift;
1739 my $fullformat = shift || '%f %l (%u)';
1740 my $sth = $dbh->prepare("select username,firstname,lastname from users where user_id=?");
1741 $sth->execute($userid);
1742 my ($uname,$fname,$lname) = $sth->fetchrow_array();
1743 $errstr = $DBI::errstr if !$uname;
1744
1745 $fullformat =~ s/\%u/$uname/g;
1746 $fullformat =~ s/\%f/$fname/g;
1747 $fullformat =~ s/\%l/$lname/g;
1748
1749 return $fullformat;
1750} # end userFullName
1751
1752
1753## DNSDB::userStatus()
1754# Sets and/or returns a user's status
1755# Takes a database handle, user ID and optionally a status argument
1756# Returns undef on errors.
1757sub userStatus {
1758 my $dbh = shift;
1759 my $id = shift;
1760 my $newstatus = shift;
1761
1762 return undef if $id !~ /^\d+$/;
1763
1764 my $sth;
1765
1766# ooo, fun! let's see what we were passed for status
1767 if ($newstatus) {
1768 $sth = $dbh->prepare("update users set status=? where user_id=?");
1769 # ass-u-me caller knows what's going on in full
1770 if ($newstatus =~ /^[01]$/) { # only two valid for now.
1771 $sth->execute($newstatus,$id);
1772 } elsif ($newstatus =~ /^usero(?:n|ff)$/) {
1773 $sth->execute(($newstatus eq 'useron' ? 1 : 0),$id);
1774 }
1775 }
1776
1777 $sth = $dbh->prepare("select status from users where user_id=?");
1778 $sth->execute($id);
1779 my ($status) = $sth->fetchrow_array;
1780 return $status;
1781} # end userStatus()
1782
1783
1784## DNSDB::getUserData()
1785# Get misc user data for display
1786sub getUserData {
1787 my $dbh = shift;
1788 my $uid = shift;
1789
1790 my $sth = $dbh->prepare("SELECT group_id,username,firstname,lastname,phone,type,status,inherit_perm ".
1791 "FROM users WHERE user_id=?");
1792 $sth->execute($uid);
1793 return $sth->fetchrow_hashref();
1794
1795} # end getUserData()
1796
1797
1798## DNSDB::getSOA()
1799# Return all suitable fields from an SOA record in separate elements of a hash
1800# Takes a database handle, default/live flag, domain/reverse flag, and parent ID
1801sub getSOA {
1802 $errstr = '';
1803 my $dbh = shift;
1804 my $def = shift;
1805 my $rev = shift;
1806 my $id = shift;
1807 my %ret;
1808
1809 # (ab)use distance and weight columns to store SOA data? can't for default_rev_records...
1810 # - should really attach serial to the zone parent somewhere
1811
1812 my $sql = "SELECT record_id,host,val,ttl from "._rectable($def,$rev).
1813 " WHERE "._recparent($def,$rev)." = ? AND type=$reverse_typemap{SOA}";
1814
1815 my $sth = $dbh->prepare($sql);
1816 $sth->execute($id);
1817##fixme: stick a flag somewhere if the record doesn't exist. by the API, this is an impossible case, but...
1818
1819 my ($recid,$host,$val,$ttl) = $sth->fetchrow_array() or return;
1820 my ($contact,$prins) = split /:/, $host;
1821 my ($refresh,$retry,$expire,$minttl) = split /:/, $val;
1822
1823 $ret{recid} = $recid;
1824 $ret{ttl} = $ttl;
1825# $ret{serial} = $serial; # ca't use distance for serial with default_rev_records
1826 $ret{prins} = $prins;
1827 $ret{contact} = $contact;
1828 $ret{refresh} = $refresh;
1829 $ret{retry} = $retry;
1830 $ret{expire} = $expire;
1831 $ret{minttl} = $minttl;
1832
1833 return %ret;
1834} # end getSOA()
1835
1836
1837## DNSDB::updateSOA()
1838# Update the specified SOA record
1839# Takes a database handle, default/live flag, forward/reverse flag, and SOA data hash
1840sub updateSOA {
1841 my $dbh = shift;
1842 my $defrec = shift;
1843 my $revrec = shift;
1844
1845 my %soa = @_;
1846
1847##fixme: data validation: make sure {recid} is really the SOA for {parent}
1848 my $sql = "UPDATE "._rectable($defrec, $revrec)." SET host=?, val=?, ttl=? WHERE record_id=? AND type=6";
1849 $dbh->do($sql, undef, ("$soa{contact}:$soa{prins}", "$soa{refresh}:$soa{retry}:$soa{expire}:$soa{minttl}",
1850 $soa{ttl}, $soa{recid}));
1851
1852} # end updateSOA()
1853
1854
1855## DNSDB::getRecLine()
1856# Return all data fields for a zone record in separate elements of a hash
1857# Takes a database handle, default/live flag, forward/reverse flag, and record ID
1858sub getRecLine {
1859 $errstr = '';
1860 my $dbh = shift;
1861 my $defrec = shift;
1862 my $revrec = shift;
1863 my $id = shift;
1864
1865 my $sql = "SELECT record_id,host,type,val,ttl".($revrec eq 'n' ? ',distance,weight,port' : '').
1866 (($defrec eq 'y') ? ',group_id FROM ' : ',domain_id,rdns_id FROM ').
1867 _rectable($defrec,$revrec)." WHERE record_id=?";
1868 my $ret = $dbh->selectrow_hashref($sql, undef, ($id) );
1869
1870 if ($dbh->err) {
1871 $errstr = $DBI::errstr;
1872 return undef;
1873 }
1874
1875 if (!$ret) {
1876 $errstr = "No such record";
1877 return undef;
1878 }
1879
1880 # explicitly set a parent id
1881 if ($defrec eq 'y') {
1882 $ret->{parid} = $ret->{group_id};
1883 } else {
1884 $ret->{parid} = (($revrec eq 'n') ? $ret->{domain_id} : $ret->{rdns_id});
1885 # and a secondary if we have a custom type that lives in both a forward and reverse zone
1886 $ret->{secid} = (($revrec eq 'y') ? $ret->{domain_id} : $ret->{rdns_id}) if $ret->{type} > 65279;
1887 }
1888
1889 return $ret;
1890}
1891
1892
1893##fixme: should use above (getRecLine()) to get lines for below?
1894## DNSDB::getDomRecs()
1895# Return records for a domain
1896# Takes a database handle, default/live flag, group/domain ID, start,
1897# number of records, sort field, and sort order
1898# Returns a reference to an array of hashes
1899sub getDomRecs {
1900 $errstr = '';
1901 my $dbh = shift;
1902 my $def = shift;
1903 my $rev = shift;
1904 my $id = shift;
1905 my $nrecs = shift || 'all';
1906 my $nstart = shift || 0;
1907
1908## for order, need to map input to column names
1909 my $order = shift || 'host';
1910 my $direction = shift || 'ASC';
1911
1912 my $filter = shift || '';
1913
1914 my $sql = "SELECT r.record_id,r.host,r.type,r.val,r.ttl";
1915 $sql .= ",r.distance,r.weight,r.port" if $rev eq 'n';
1916 $sql .= " FROM "._rectable($def,$rev)." r ";
1917 $sql .= "INNER JOIN rectypes t ON r.type=t.val "; # for sorting by type alphabetically
1918 $sql .= "WHERE "._recparent($def,$rev)." = ?";
1919 $sql .= " AND NOT r.type=$reverse_typemap{SOA}";
1920 $sql .= " AND host ~* ?" if $filter;
1921 # use alphaorder column for "correct" ordering of sort-by-type instead of DNS RR type number
1922 $sql .= " ORDER BY ".($order eq 'type' ? 't.alphaorder' : "r.$order")." $direction";
1923
1924 my @bindvars = ($id);
1925 push @bindvars, $filter if $filter;
1926
1927 # just to be ultraparanoid about SQL injection vectors
1928 if ($nstart ne 'all') {
1929 $sql .= " LIMIT ? OFFSET ?";
1930 push @bindvars, $nrecs;
1931 push @bindvars, ($nstart*$nrecs);
1932 }
1933 my $sth = $dbh->prepare($sql) or warn $dbh->errstr;
1934 $sth->execute(@bindvars) or warn "$sql: ".$sth->errstr;
1935
1936 my @retbase;
1937 while (my $ref = $sth->fetchrow_hashref()) {
1938 push @retbase, $ref;
1939 }
1940
1941 my $ret = \@retbase;
1942 return $ret;
1943} # end getDomRecs()
1944
1945
1946## DNSDB::getRecCount()
1947# Return count of non-SOA records in zone (or default records in a group)
1948# Takes a database handle, default/live flag, reverse/forward flag, group/domain ID,
1949# and optional filtering modifier
1950# Returns the count
1951sub getRecCount {
1952 my $dbh = shift;
1953 my $defrec = shift;
1954 my $revrec = shift;
1955 my $id = shift;
1956 my $filter = shift || '';
1957
1958 # keep the nasties down, since we can't ?-sub this bit. :/
1959 # note this is chars allowed in DNS hostnames
1960 $filter =~ s/[^a-zA-Z0-9_.:-]//g;
1961
1962 my @bindvars = ($id);
1963 push @bindvars, $filter if $filter;
1964 my $sql = "SELECT count(*) FROM ".
1965 _rectable($defrec,$revrec).
1966 " WHERE "._recparent($defrec,$revrec)."=? ".
1967 "AND NOT type=$reverse_typemap{SOA}".
1968 ($filter ? " AND host ~* ?" : '');
1969 my ($count) = $dbh->selectrow_array($sql, undef, (@bindvars) );
1970
1971 return $count;
1972
1973} # end getRecCount()
1974
1975
1976## DNSDB::addRec()
1977# Add a new record to a domain or a group's default records
1978# Takes a database handle, default/live flag, group/domain ID,
1979# host, type, value, and TTL
1980# Some types require additional detail: "distance" for MX and SRV,
1981# and weight/port for SRV
1982# Returns a status code and detail message in case of error
1983##fixme: pass a hash with the record data, not a series of separate values
1984sub addRec {
1985 $errstr = '';
1986 my $dbh = shift;
1987 my $defrec = shift;
1988 my $revrec = shift;
1989 my $id = shift; # parent (group_id for defrecs, rdns_id for reverse records,
1990 # domain_id for domain records)
1991
1992 my $host = shift;
1993 my $rectype = shift; # reference so we can coerce it if "+"-types can't find both zones
1994 my $val = shift;
1995 my $ttl = shift;
1996
1997 # prep for validation
1998 my $addr = NetAddr::IP->new($val);
1999 $host =~ s/\.+$//; # FQDNs ending in . are an internal detail, and really shouldn't be exposed in the UI.
2000
2001 my $domid = 0;
2002 my $revid = 0;
2003
2004 my $retcode = 'OK'; # assume everything will go OK
2005 my $retmsg = '';
2006
2007 # do simple validation first
2008 return ('FAIL', "TTL must be numeric") unless $ttl =~ /^\d+$/;
2009
2010 # Quick check on hostname parts. Note the regex is more forgiving than the error message;
2011 # domain names technically are case-insensitive, and we use printf-like % codes for a couple
2012 # of types. Other things may also be added to validate default records of several flavours.
2013 return ('FAIL', "Hostnames may not contain anything other than (0-9 a-z . _)")
2014 if $defrec eq 'n' && $host !~ /^[0-9a-z_%.]+$/i;
2015
2016 # Collect these even if we're only doing a simple A record so we can call *any* validation sub
2017 my $dist = shift;
2018 my $port = shift;
2019 my $weight = shift;
2020
2021 my $fields;
2022 my @vallist;
2023
2024 # Call the validation sub for the type requested.
2025 ($retcode,$retmsg) = $validators{$$rectype}($dbh, (defrec => $defrec, revrec => $revrec, id => $id,
2026 host => $host, rectype => $rectype, val => $val, addr => $addr,
2027 dist => \$dist, port => \$port, weight => \$weight,
2028 fields => \$fields, vallist => \@vallist) );
2029
2030 return ($retcode,$retmsg) if $retcode eq 'FAIL';
2031
2032 # Set up database fields and bind parameters
2033 $fields .= "host,type,val,ttl,"._recparent($defrec,$revrec);
2034 push @vallist, ($$host,$$rectype,$$val,$ttl,$id);
2035 my $vallen = '?'.(',?'x$#vallist);
2036
2037 # Allow transactions, and raise an exception on errors so we can catch it later.
2038 # Use local to make sure these get "reset" properly on exiting this block
2039 local $dbh->{AutoCommit} = 0;
2040 local $dbh->{RaiseError} = 1;
2041
2042 eval {
2043 $dbh->do("INSERT INTO "._rectable($defrec, $revrec)." ($fields) VALUES ($vallen)",
2044 undef, @vallist);
2045 $dbh->commit;
2046 };
2047 if ($@) {
2048 my $msg = $@;
2049 eval { $dbh->rollback; };
2050 return ('FAIL',$msg);
2051 }
2052
2053 return ($retcode, $retmsg);
2054
2055} # end addRec()
2056
2057
2058## DNSDB::updateRec()
2059# Update a record
2060sub updateRec {
2061 $errstr = '';
2062
2063 my $dbh = shift;
2064 my $defrec = shift;
2065 my $id = shift;
2066
2067# all records have these
2068 my $host = shift;
2069 my $type = shift;
2070 my $val = shift;
2071 my $ttl = shift;
2072
2073 return('FAIL',"Missing standard argument(s)") if !defined($ttl);
2074
2075# only MX and SRV will use these
2076 my $dist = 0;
2077 my $weight = 0;
2078 my $port = 0;
2079
2080 if ($type == $reverse_typemap{MX} || $type == $reverse_typemap{SRV}) {
2081 $dist = shift;
2082 $dist =~ s/\s+//g;
2083 return ('FAIL',"MX or SRV requires distance") if !defined($dist);
2084 return ('FAIL', "Distance must be numeric") unless $dist =~ /^\d+$/;
2085 if ($type == $reverse_typemap{SRV}) {
2086 $weight = shift;
2087 $weight =~ s/\s+//g;
2088 return ('FAIL',"SRV requires weight") if !defined($weight);
2089 return ('FAIL',"Weight must be numeric") unless $weight =~ /^\d+$/;
2090 $port = shift;
2091 $port =~ s/\s+//g;
2092 return ('FAIL',"SRV requires port") if !defined($port);
2093 return ('FAIL',"Port must be numeric") unless $port =~ /^\d+$/;
2094 }
2095 }
2096
2097# Enforce IP addresses on A and AAAA types
2098 my $addr = NetAddr::IP->new($val);
2099 if ($type == $reverse_typemap{A}) {
2100 return ('FAIL',$typemap{$type}." record must be a valid IPv4 address")
2101 unless $addr && !$addr->{isv6};
2102 }
2103 if ($type == $reverse_typemap{AAAA}) {
2104 return ('FAIL',$typemap{$type}." record must be a valid IPv6 address")
2105 unless $addr && $addr->{isv6};
2106 }
2107
2108# hmm.. this might work. except possibly for something pointing to "deadbeef.ca". <g>
2109# if ($type == $reverse_typemap{NS} || $type == $reverse_typemap{MX} || $type == $reverse_typemap{SRV}) {
2110# if ($val =~ /^\s*[\da-f:.]+\s*$/) {
2111# return ('FAIL',"$val is not a valid IP address") if !$addr;
2112# }
2113# }
2114
2115 local $dbh->{AutoCommit} = 0;
2116 local $dbh->{RaiseError} = 1;
2117
2118 eval {
2119 $dbh->do("UPDATE ".($defrec eq 'y' ? 'default_' : '')."records ".
2120 "SET host=?,val=?,type=?,ttl=?,distance=?,weight=?,port=? ".
2121 "WHERE record_id=?", undef, ($host, $val, $type, $ttl, $dist, $weight, $port, $id) );
2122 $dbh->commit;
2123 };
2124 if ($@) {
2125 my $msg = $@;
2126 $dbh->rollback;
2127 return ('FAIL', $msg);
2128 }
2129
2130 return ('OK','OK');
2131} # end updateRec()
2132
2133
2134## DNSDB::delRec()
2135# Delete a record.
2136sub delRec {
2137 $errstr = '';
2138 my $dbh = shift;
2139 my $defrec = shift;
2140 my $revrec = shift;
2141 my $id = shift;
2142
2143 my $sth = $dbh->prepare("DELETE FROM "._rectable($defrec,$revrec)." WHERE record_id=?");
2144 $sth->execute($id);
2145
2146 return ('FAIL',"Couldn't remove record: ".$sth->errstr) if $sth->err;
2147
2148 return ('OK','OK');
2149} # end delRec()
2150
2151
2152 # Reference hashes.
2153my %par_tbl = (
2154 group => 'groups',
2155 user => 'users',
2156 defrec => 'default_records',
2157 defrevrec => 'default_rev_records',
2158 domain => 'domains',
2159 revzone => 'revzones',
2160 record => 'records'
2161 );
2162my %id_col = (
2163 group => 'group_id',
2164 user => 'user_id',
2165 defrec => 'record_id',
2166 defrevrec => 'record_id',
2167 domain => 'domain_id',
2168 revzone => 'rdns_id',
2169 record => 'record_id'
2170 );
2171my %par_col = (
2172 group => 'parent_group_id',
2173 user => 'group_id',
2174 defrec => 'group_id',
2175 defrevrec => 'group_id',
2176 domain => 'group_id',
2177 revzone => 'group_id',
2178 record => 'domain_id'
2179 );
2180my %par_type = (
2181 group => 'group',
2182 user => 'group',
2183 defrec => 'group',
2184 defrevrec => 'group',
2185 domain => 'group',
2186 revzone => 'group',
2187 record => 'domain'
2188 );
2189
2190
2191## DNSDB::getTypelist()
2192# Get a list of record types for various UI dropdowns
2193# Takes database handle, forward/reverse/lookup flag, and optional "tag as selected" indicator (defaults to A)
2194# Returns an arrayref to list of hashrefs perfect for HTML::Template
2195sub getTypelist {
2196 my $dbh = shift;
2197 my $recgroup = shift;
2198 my $type = shift || $reverse_typemap{A};
2199
2200 # also accepting $webvar{revrec}!
2201 $recgroup = 'f' if $recgroup eq 'n';
2202 $recgroup = 'r' if $recgroup eq 'y';
2203
2204 my $sql = "SELECT val,name FROM rectypes WHERE ";
2205 if ($recgroup eq 'r') {
2206 # reverse zone types
2207 $sql .= "stdflag=2 OR stdflag=3";
2208 } elsif ($recgroup eq 'l') {
2209 # DNS lookup types. Note we avoid our custom types >= 65280, since those are entirely internal.
2210 $sql .= "(stdflag=1 OR stdflag=2 OR stdflag=3) AND val < 65280";
2211 } else {
2212 # default; forward zone types. technically $type eq 'f' but not worth the error message.
2213 $sql .= "stdflag=1 OR stdflag=2";
2214 }
2215 $sql .= " ORDER BY listorder";
2216
2217 my $sth = $dbh->prepare($sql);
2218 $sth->execute;
2219 my @typelist;
2220 while (my ($rval,$rname) = $sth->fetchrow_array()) {
2221 my %row = ( recval => $rval, recname => $rname );
2222 $row{tselect} = 1 if $rval == $type;
2223 push @typelist, \%row;
2224 }
2225
2226 # Add SOA on lookups since it's not listed in other dropdowns.
2227 if ($recgroup eq 'l') {
2228 my %row = ( recval => $reverse_typemap{SOA}, recname => 'SOA' );
2229 $row{tselect} = 1 if $reverse_typemap{SOA} == $type;
2230 push @typelist, \%row;
2231 }
2232
2233 return \@typelist;
2234} # end getTypelist()
2235
2236
2237## DNSDB::getParents()
2238# Find out which entities are parent to the requested id
2239# Returns arrayref containing hash pairs of id/type
2240sub getParents {
2241 my $dbh = shift;
2242 my $id = shift;
2243 my $type = shift;
2244 my $depth = shift || 'all'; # valid values: 'all', 'immed', <int> (stop at this group ID)
2245
2246 my @parlist;
2247
2248 while (1) {
2249 my $result = $dbh->selectrow_hashref("SELECT $par_col{$type} FROM $par_tbl{$type} WHERE $id_col{$type} = ?",
2250 undef, ($id) );
2251 my %tmp = ($result->{$par_col{$type}} => $par_type{$type});
2252 unshift @parlist, \%tmp;
2253 last if $result->{$par_col{$type}} == 1; # group 1 is its own parent
2254 $id = $result->{$par_col{$type}};
2255 $type = $par_type{$type};
2256 }
2257
2258 return \@parlist;
2259
2260} # end getParents()
2261
2262
2263## DNSDB::isParent()
2264# Returns true if $id1 is a parent of $id2, false otherwise
2265sub isParent {
2266 my $dbh = shift;
2267 my $id1 = shift;
2268 my $type1 = shift;
2269 my $id2 = shift;
2270 my $type2 = shift;
2271##todo: immediate, secondary, full (default)
2272
2273 # Return false on invalid types
2274 return 0 if !grep /^$type1$/, ('record','defrec','defrevrec','user','domain','revzone','group');
2275 return 0 if !grep /^$type2$/, ('record','defrec','defrevrec','user','domain','revzone','group');
2276
2277 # Return false on impossible relations
2278 return 0 if $type1 eq 'record'; # nothing may be a child of a record
2279 return 0 if $type1 eq 'defrec'; # nothing may be a child of a record
2280 return 0 if $type1 eq 'defrevrec'; # nothing may be a child of a record
2281 return 0 if $type1 eq 'user'; # nothing may be child of a user
2282 return 0 if $type1 eq 'domain' && $type2 ne 'record'; # domain may not be a parent of anything other than a record
2283 return 0 if $type1 eq 'revzone' && $type2 ne 'record';# reverse zone may not be a parent of anything other than a record
2284
2285 # ennnhhhh.... if we're passed an id of 0, it will never be found. usual
2286 # case would be the UI creating a new <thing>, and so we don't have an ID for
2287 # <thing> to look up yet. in that case the UI should check the parent as well.
2288 return 0 if $id1 == 0; # nothing can have a parent id of 0
2289 return 1 if $id2 == 0; # anything could have a child id of 0 (or "unknown")
2290
2291 # group 1 is the ultimate root parent
2292 return 1 if $type1 eq 'group' && $id1 == 1;
2293
2294 # groups are always (a) parent of themselves
2295 return 1 if $type1 eq 'group' && $type2 eq 'group' && $id1 == $id2;
2296
2297 my $id = $id2;
2298 my $type = $type2;
2299 my $foundparent = 0;
2300
2301 # Records are the only entity with two possible parents. We need to split the parent checks on
2302 # domain/rdns.
2303 if ($type eq 'record') {
2304 my ($dom,$rdns) = $dbh->selectrow_array("SELECT domain_id,rdns_id FROM records WHERE record_id=?",
2305 undef, ($id));
2306 # check immediate parent against request
2307 return 1 if $type1 eq 'domain' && $id1 == $dom;
2308 return 1 if $type1 eq 'revzone' && $id1 == $rdns;
2309 # if request is group, check *both* parents. Only check if the parent is nonzero though.
2310 return 1 if $dom && isParent($dbh, $id1, $type1, $dom, 'domain');
2311 return 1 if $rdns && isParent($dbh, $id1, $type1, $rdns, 'revzone');
2312 # exit here since we've executed the loop below by proxy in the above recursive calls.
2313 return 0;
2314 }
2315
2316# almost the same loop as getParents() above
2317 my $limiter = 0;
2318 while (1) {
2319 my $sql = "SELECT $par_col{$type} FROM $par_tbl{$type} WHERE $id_col{$type} = ?";
2320 my $result = $dbh->selectrow_hashref($sql,
2321 undef, ($id) );
2322 if (!$result) {
2323 $limiter++;
2324##fixme: how often will this happen on a live site? fail at max limiter <n>?
2325 warn "no results looking for $sql with id $id (depth $limiter)\n";
2326 last;
2327 }
2328 if ($result && $result->{$par_col{$type}} == $id1) {
2329 $foundparent = 1;
2330 last;
2331 } else {
2332##fixme: do we care about trying to return a "no such record/domain/user/group" error?
2333# should be impossible to create an inconsistent DB just with API calls.
2334 warn $dbh->errstr." $sql, $id" if $dbh->errstr;
2335 }
2336 # group 1 is its own parent. need this here more to break strange loops than for detecting a parent
2337 last if $result->{$par_col{$type}} == 1;
2338 $id = $result->{$par_col{$type}};
2339 $type = $par_type{$type};
2340 }
2341
2342 return $foundparent;
2343} # end isParent()
2344
2345
2346## DNSDB::domStatus()
2347# Sets and/or returns a domain's status
2348# Takes a database handle, domain ID and optionally a status argument
2349# Returns undef on errors.
2350sub domStatus {
2351 my $dbh = shift;
2352 my $id = shift;
2353 my $newstatus = shift;
2354
2355 return undef if $id !~ /^\d+$/;
2356
2357 my $sth;
2358
2359# ooo, fun! let's see what we were passed for status
2360 if ($newstatus) {
2361 $sth = $dbh->prepare("update domains set status=? where domain_id=?");
2362 # ass-u-me caller knows what's going on in full
2363 if ($newstatus =~ /^[01]$/) { # only two valid for now.
2364 $sth->execute($newstatus,$id);
2365 } elsif ($newstatus =~ /^domo(?:n|ff)$/) {
2366 $sth->execute(($newstatus eq 'domon' ? 1 : 0),$id);
2367 }
2368 }
2369
2370 $sth = $dbh->prepare("select status from domains where domain_id=?");
2371 $sth->execute($id);
2372 my ($status) = $sth->fetchrow_array;
2373 return $status;
2374} # end domStatus()
2375
2376
2377## DNSDB::importAXFR
2378# Import a domain via AXFR
2379# Takes AXFR host, domain to transfer, group to put the domain in,
2380# and optionally:
2381# - active/inactive state flag (defaults to active)
2382# - overwrite-SOA flag (defaults to off)
2383# - overwrite-NS flag (defaults to off, doesn't affect subdomain NS records)
2384# Returns a status code (OK, WARN, or FAIL) and message - message should be blank
2385# if status is OK, but WARN includes conditions that are not fatal but should
2386# really be reported.
2387sub importAXFR {
2388 my $dbh = shift;
2389 my $ifrom_in = shift;
2390 my $domain = shift;
2391 my $group = shift;
2392 my $status = shift || 1;
2393 my $rwsoa = shift || 0;
2394 my $rwns = shift || 0;
2395
2396##fixme: add mode to delete&replace, merge+overwrite, merge new?
2397
2398 my $nrecs = 0;
2399 my $soaflag = 0;
2400 my $nsflag = 0;
2401 my $warnmsg = '';
2402 my $ifrom;
2403
2404 # choke on possible bad setting in ifrom
2405 # IPv4 and v6, and valid hostnames!
2406 ($ifrom) = ($ifrom_in =~ /^([0-9a-f\:.]+|[0-9a-z_.-]+)$/i);
2407 return ('FAIL', "Bad AXFR source host $ifrom")
2408 unless ($ifrom) = ($ifrom_in =~ /^([0-9a-f\:.]+|[0-9a-z_.-]+)$/i);
2409
2410 # Allow transactions, and raise an exception on errors so we can catch it later.
2411 # Use local to make sure these get "reset" properly on exiting this block
2412 local $dbh->{AutoCommit} = 0;
2413 local $dbh->{RaiseError} = 1;
2414
2415 my $sth = $dbh->prepare("SELECT domain_id FROM domains WHERE domain=?");
2416 my $dom_id;
2417
2418# quick check to start to see if we've already got one
2419 $sth->execute($domain);
2420 ($dom_id) = $sth->fetchrow_array;
2421
2422 return ('FAIL', "Domain already exists") if $dom_id;
2423
2424 eval {
2425 # can't do this, can't nest transactions. sigh.
2426 #my ($dcode, $dmsg) = addDomain(dbh, domain, group, status);
2427
2428##fixme: serial
2429 my $sth = $dbh->prepare("INSERT INTO domains (domain,group_id,status) VALUES (?,?,?)");
2430 $sth->execute($domain,$group,$status);
2431
2432## bizarre DBI<->Net::DNS interaction bug:
2433## sometimes a zone will cause an immediate commit-and-exit (sort of) of the while()
2434## fixed, apparently I was doing *something* odd, but not certain what it was that
2435## caused a commit instead of barfing
2436
2437 # get domain id so we can do the records
2438 $sth = $dbh->prepare("SELECT domain_id FROM domains WHERE domain=?");
2439 $sth->execute($domain);
2440 ($dom_id) = $sth->fetchrow_array();
2441
2442 my $res = Net::DNS::Resolver->new;
2443 $res->nameservers($ifrom);
2444 $res->axfr_start($domain)
2445 or die "Couldn't begin AXFR\n";
2446
2447 while (my $rr = $res->axfr_next()) {
2448 my $type = $rr->type;
2449
2450 my $sql = "INSERT INTO records (domain_id,host,type,ttl,val";
2451 my $vallen = "?,?,?,?,?";
2452
2453 $soaflag = 1 if $type eq 'SOA';
2454 $nsflag = 1 if $type eq 'NS';
2455
2456 my @vallist = ($dom_id, $rr->name, $reverse_typemap{$type}, $rr->ttl);
2457
2458# "Primary" types:
2459# A, NS, CNAME, SOA, PTR(warn in forward), MX, TXT, AAAA, SRV, A6(ob), SPF
2460# maybe KEY
2461
2462# nasty big ugly case-like thing here, since we have to do *some* different
2463# processing depending on the record. le sigh.
2464
2465##fixme: what record types other than TXT can/will have >255-byte payloads?
2466
2467 if ($type eq 'A') {
2468 push @vallist, $rr->address;
2469 } elsif ($type eq 'NS') {
2470# hmm. should we warn here if subdomain NS'es are left alone?
2471 next if ($rwns && ($rr->name eq $domain));
2472 push @vallist, $rr->nsdname;
2473 $nsflag = 1;
2474 } elsif ($type eq 'CNAME') {
2475 push @vallist, $rr->cname;
2476 } elsif ($type eq 'SOA') {
2477 next if $rwsoa;
2478 $vallist[1] = $rr->mname.":".$rr->rname;
2479 push @vallist, ($rr->refresh.":".$rr->retry.":".$rr->expire.":".$rr->minimum);
2480 $soaflag = 1;
2481 } elsif ($type eq 'PTR') {
2482 push @vallist, $rr->ptrdname;
2483 # hmm. PTR records should not be in forward zones.
2484 } elsif ($type eq 'MX') {
2485 $sql .= ",distance";
2486 $vallen .= ",?";
2487 push @vallist, $rr->exchange;
2488 push @vallist, $rr->preference;
2489 } elsif ($type eq 'TXT') {
2490##fixme: Net::DNS docs say this should be deprecated for rdatastr() or char_str_list(),
2491## but don't really seem enthusiastic about it.
2492 my $rrdata = $rr->txtdata;
2493 push @vallist, $rrdata;
2494 } elsif ($type eq 'SPF') {
2495##fixme: and the same caveat here, since it is apparently a clone of ::TXT
2496 my $rrdata = $rr->txtdata;
2497 push @vallist, $rrdata;
2498 } elsif ($type eq 'AAAA') {
2499 push @vallist, $rr->address;
2500 } elsif ($type eq 'SRV') {
2501 $sql .= ",distance,weight,port" if $type eq 'SRV';
2502 $vallen .= ",?,?,?" if $type eq 'SRV';
2503 push @vallist, $rr->target;
2504 push @vallist, $rr->priority;
2505 push @vallist, $rr->weight;
2506 push @vallist, $rr->port;
2507 } elsif ($type eq 'KEY') {
2508 # we don't actually know what to do with these...
2509 push @vallist, ($rr->flags.":".$rr->protocol.":".$rr->algorithm.":".$rr->key.":".$rr->keytag.":".$rr->privatekeyname);
2510 } else {
2511 my $rrdata = $rr->rdatastr;
2512 push @vallist, $rrdata;
2513 # Finding a different record type is not fatal.... just problematic.
2514 # We may not be able to export it correctly.
2515 $warnmsg .= "Unusual record ".$rr->name." ($type) found\n";
2516 }
2517
2518# BIND supports:
2519# A CNAME HINFO MB(ex) MD(ob) MF(ob) MG(ex) MINFO(ex) MR(ex) MX NS NULL
2520# PTR SOA TXT WKS AFSDB(ex) ISDN(ex) RP(ex) RT(ex) X25(ex) PX
2521# ... if one can ever find the right magic to format them correctly
2522
2523# Net::DNS supports:
2524# RRSIG SIG NSAP NS NIMLOC NAPTR MX MR MINFO MG MB LOC ISDN IPSECKEY HINFO
2525# EID DNAME CNAME CERT APL AFSDB AAAA A DS NXT NSEC3PARAM NSEC3 NSEC KEY
2526# DNSKEY DLV X25 TXT TSIG TKEY SSHFP SRV SPF SOA RT RP PX PTR NULL APL::AplItem
2527
2528 $sth = $dbh->prepare($sql.") VALUES (".$vallen.")") or die "problem preparing record insert SQL\n";
2529 $sth->execute(@vallist) or die "failed to insert ".$rr->string.": ".$sth->errstr."\n";
2530
2531 $nrecs++;
2532
2533 } # while axfr_next
2534
2535 # Overwrite SOA record
2536 if ($rwsoa) {
2537 $soaflag = 1;
2538 my $sthgetsoa = $dbh->prepare("SELECT host,val,ttl FROM default_records WHERE group_id=? AND type=?");
2539 my $sthputsoa = $dbh->prepare("INSERT INTO records (domain_id,host,type,val,ttl) VALUES (?,?,?,?,?)");
2540 $sthgetsoa->execute($group,$reverse_typemap{SOA});
2541 while (my ($host,$val,$ttl) = $sthgetsoa->fetchrow_array()) {
2542 $host =~ s/DOMAIN/$domain/g;
2543 $val =~ s/DOMAIN/$domain/g;
2544 $sthputsoa->execute($dom_id,$host,$reverse_typemap{SOA},$val,$ttl);
2545 }
2546 }
2547
2548 # Overwrite NS records
2549 if ($rwns) {
2550 $nsflag = 1;
2551 my $sthgetns = $dbh->prepare("SELECT host,val,ttl FROM default_records WHERE group_id=? AND type=?");
2552 my $sthputns = $dbh->prepare("INSERT INTO records (domain_id,host,type,val,ttl) VALUES (?,?,?,?,?)");
2553 $sthgetns->execute($group,$reverse_typemap{NS});
2554 while (my ($host,$val,$ttl) = $sthgetns->fetchrow_array()) {
2555 $host =~ s/DOMAIN/$domain/g;
2556 $val =~ s/DOMAIN/$domain/g;
2557 $sthputns->execute($dom_id,$host,$reverse_typemap{NS},$val,$ttl);
2558 }
2559 }
2560
2561 die "No records found; either $ifrom is not authoritative or doesn't allow transfers\n" if !$nrecs;
2562 die "Bad zone: No SOA record!\n" if !$soaflag;
2563 die "Bad zone: No NS records!\n" if !$nsflag;
2564
2565 $dbh->commit;
2566
2567 };
2568
2569 if ($@) {
2570 my $msg = $@;
2571 eval { $dbh->rollback; };
2572 return ('FAIL',$msg." $warnmsg");
2573 } else {
2574 return ('WARN', $warnmsg) if $warnmsg;
2575 return ('OK',"Imported OK");
2576 }
2577
2578 # it should be impossible to get here.
2579 return ('WARN',"OOOK!");
2580} # end importAXFR()
2581
2582
2583## DNSDB::export()
2584# Export the DNS database, or a part of it
2585# Takes database handle, export type, optional arguments depending on type
2586# Writes zone data to targets as appropriate for type
2587sub export {
2588 my $dbh = shift;
2589 my $target = shift;
2590
2591 if ($target eq 'tiny') {
2592 __export_tiny($dbh,@_);
2593 }
2594# elsif ($target eq 'foo') {
2595# __export_foo($dbh,@_);
2596#}
2597# etc
2598
2599} # end export()
2600
2601
2602## DNSDB::__export_tiny
2603# Internal sub to implement tinyDNS (compatible) export
2604# Takes database handle, filehandle to write export to, optional argument(s)
2605# to determine which data gets exported
2606sub __export_tiny {
2607 my $dbh = shift;
2608 my $datafile = shift;
2609
2610##fixme: slurp up further options to specify particular zone(s) to export
2611
2612 ## Convert a bare number into an octal-coded pair of octets.
2613 # Take optional arg to indicate a decimal or hex input. Defaults to hex.
2614 sub octalize {
2615 my $tmp = shift;
2616 my $srctype = shift || 'h'; # default assumes hex string
2617 $tmp = sprintf "%0.4x", hex($tmp) if $srctype eq 'h'; # 0-pad hex to 4 digits
2618 $tmp = sprintf "%0.4x", $tmp if $srctype eq 'd'; # 0-pad decimal to 4 hex digits
2619 my @o = ($tmp =~ /^(..)(..)$/); # split into octets
2620 return sprintf "\\%0.3o\\%0.3o", hex($o[0]), hex($o[1]);;
2621 }
2622
2623##fixme: fail if $datafile isn't an open, writable file
2624
2625 # easy case - export all evarything
2626 # not-so-easy case - export item(s) specified
2627 # todo: figure out what kind of list we use to export items
2628
2629 my $domsth = $dbh->prepare("SELECT domain_id,domain,status FROM domains WHERE status=1");
2630 my $recsth = $dbh->prepare("SELECT host,type,val,distance,weight,port,ttl ".
2631 "FROM records WHERE domain_id=?");
2632 $domsth->execute();
2633 while (my ($domid,$dom,$domstat) = $domsth->fetchrow_array) {
2634 $recsth->execute($domid);
2635 while (my ($host,$type,$val,$dist,$weight,$port,$ttl) = $recsth->fetchrow_array) {
2636##fixme: need to store location in the db, and retrieve it here.
2637# temporarily hardcoded to empty so we can include it further down.
2638my $loc = '';
2639
2640##fixme: record validity timestamp. tinydns supports fiddling with timestamps.
2641# note $ttl must be set to 0 if we want to use tinydns's auto-expiring timestamps.
2642# timestamps are TAI64
2643# ~~ 2^62 + time()
2644my $stamp = '';
2645
2646# raw packet in unknown format: first byte indicates length
2647# of remaining data, allows up to 255 raw bytes
2648
2649##fixme? append . to all host/val hostnames
2650 if ($typemap{$type} eq 'SOA') {
2651
2652 # host contains pri-ns:responsible
2653 # val is abused to contain refresh:retry:expire:minttl
2654##fixme: "manual" serial vs tinydns-autoserial
2655 # let's be explicit about abusing $host and $val
2656 my ($email, $primary) = (split /:/, $host)[0,1];
2657 my ($refresh, $retry, $expire, $min_ttl) = (split /:/, $val)[0,1,2,3];
2658 print $datafile "Z$dom:$primary:$email"."::$refresh:$retry:$expire:$min_ttl:$ttl:$stamp:$loc\n";
2659
2660 } elsif ($typemap{$type} eq 'A') {
2661
2662 print $datafile "+$host:$val:$ttl:$stamp:$loc\n";
2663
2664 } elsif ($typemap{$type} eq 'NS') {
2665
2666 print $datafile "\&$host"."::$val:$ttl:$stamp:$loc\n";
2667
2668 } elsif ($typemap{$type} eq 'AAAA') {
2669
2670 print $datafile ":$host:28:";
2671 my $altgrp = 0;
2672 my @altconv;
2673 # Split in to up to 8 groups of hex digits (allows for IPv6 :: 0-collapsing)
2674 foreach (split /:/, $val) {
2675 if (/^$/) {
2676 # flag blank entry; this is a series of 0's of (currently) unknown length
2677 $altconv[$altgrp++] = 's';
2678 } else {
2679 # call sub to convert 1-4 hex digits to 2 string-rep octal bytes
2680 $altconv[$altgrp++] = octalize($_)
2681 }
2682 }
2683 foreach my $octet (@altconv) {
2684 # if not 's', output
2685 print $datafile $octet unless $octet =~ /^s$/;
2686 # if 's', output (9-array length)x literal '\000\000'
2687 print $datafile '\000\000'x(9-$altgrp) if $octet =~ /^s$/;
2688 }
2689 print $datafile ":$ttl:$stamp:$loc\n";
2690
2691 } elsif ($typemap{$type} eq 'MX') {
2692
2693 print $datafile "\@$host"."::$val:$dist:$ttl:$stamp:$loc\n";
2694
2695 } elsif ($typemap{$type} eq 'TXT') {
2696
2697##fixme: split v-e-r-y long TXT strings? will need to do so for BIND export, at least
2698 $val =~ s/:/\\072/g; # may need to replace other symbols
2699 print $datafile "'$host:$val:$ttl:$stamp:$loc\n";
2700
2701# by-hand TXT
2702#:deepnet.cx:16:2v\075spf1\040a\040a\072bacon.deepnet.cx\040a\072home.deepnet.cx\040-all:3600
2703#@ IN TXT "v=spf1 a a:bacon.deepnet.cx a:home.deepnet.cx -all"
2704#'deepnet.cx:v=spf1 a a\072bacon.deepnet.cx a\072home.deepnet.cx -all:3600
2705
2706#txttest IN TXT "v=foo bar:bob kn;ob' \" !@#$%^&*()-=_+[]{}<>?"
2707#: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
2708
2709# very long TXT record as brought in by axfr-get
2710# note tinydns does not support >512-byte RR data, need axfr-dns (for TCP support) for that
2711# also note, tinydns does not seem to support <512, >256-byte RRdata from axfr-get either. :/
2712#:longtxt.deepnet.cx:16:
2713#\170this is a very long txt record. it is really long. long. very long. really very long. this is a very long txt record.
2714#\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.
2715#\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.
2716#:3600
2717
2718 } elsif ($typemap{$type} eq 'CNAME') {
2719
2720 print $datafile "C$host:$val:$ttl:$stamp:$loc\n";
2721
2722 } elsif ($typemap{$type} eq 'SRV') {
2723
2724 # data is two-byte values for priority, weight, port, in that order,
2725 # followed by length/string data
2726
2727 print $datafile ":$host:33:".octalize($dist,'d').octalize($weight,'d').octalize($port,'d');
2728
2729 $val .= '.' if $val !~ /\.$/;
2730 foreach (split /\./, $val) {
2731 printf $datafile "\\%0.3o%s", length($_), $_;
2732 }
2733 print $datafile "\\000:$ttl:$stamp:$loc\n";
2734
2735 } elsif ($typemap{$type} eq 'RP') {
2736
2737 # RP consists of two mostly free-form strings.
2738 # The first is supposed to be an email address with @ replaced by . (as with the SOA contact)
2739 # The second is the "hostname" of a TXT record with more info.
2740 print $datafile ":$host:17:";
2741 my ($who,$what) = split /\s/, $val;
2742 foreach (split /\./, $who) {
2743 printf $datafile "\\%0.3o%s", length($_), $_;
2744 }
2745 print $datafile '\000';
2746 foreach (split /\./, $what) {
2747 printf $datafile "\\%0.3o%s", length($_), $_;
2748 }
2749 print $datafile "\\000:$ttl:$stamp:$loc\n";
2750
2751 } elsif ($typemap{$type} eq 'PTR') {
2752
2753 # must handle both IPv4 and IPv6
2754##work
2755 # data should already be in suitable reverse order.
2756 print $datafile "^$host:$val:$ttl:$stamp:$loc\n";
2757
2758 } else {
2759 # raw record. we don't know what's in here, so we ASS-U-ME the user has
2760 # put it in correctly, since either the user is messing directly with the
2761 # database, or the record was imported via AXFR
2762 # <split by char>
2763 # convert anything not a-zA-Z0-9.- to octal coding
2764
2765##fixme: add flag to export "unknown" record types - note we'll probably end up
2766# mangling them since they were written to the DB from Net::DNS::RR::<type>->rdatastr.
2767 #print $datafile ":$host:$type:$val:$ttl:$stamp:$loc\n";
2768
2769 } # record type if-else
2770
2771 } # while ($recsth)
2772 } # while ($domsth)
2773} # end __export_tiny()
2774
2775
2776## DNSDB::mailNotify()
2777# Sends notification mail to recipients regarding an IPDB operation
2778sub mailNotify {
2779 my $dbh = shift;
2780 my ($subj,$message) = @_;
2781
2782 return if $config{mailhost} eq 'smtp.example.com'; # do nothing if still using default SMTP host.
2783
2784 my $mailer = Net::SMTP->new($config{mailhost}, Hello => "dnsadmin.$config{domain}");
2785
2786 my $mailsender = ($config{mailsender} ? $config{mailsender} : $config{mailnotify});
2787
2788 $mailer->mail($mailsender);
2789 $mailer->to($config{mailnotify});
2790 $mailer->data("From: \"$config{mailname}\" <$mailsender>\n",
2791 "To: <$config{mailnotify}>\n",
2792 "Date: ".strftime("%a, %d %b %Y %H:%M:%S %z",localtime)."\n",
2793 "Subject: $subj\n",
2794 "X-Mailer: DNSAdmin Notify v".sprintf("%.1d",$DNSDB::VERSION)."\n",
2795 "Organization: $config{orgname}\n",
2796 "\n$message\n");
2797 $mailer->quit;
2798}
2799
2800# shut Perl up
28011;
Note: See TracBrowser for help on using the repository browser.