=head1 NAME check_delivery - a local address validation system for qpsmtpd =head1 DESCRIPTION Enable user-level validation of addresess so that qpsmtpd can readily bounce invalid messages at the smtp level. It does this by expanding the recipient address into the relevant username(s) and delivery addresses using the same algorithm that qmail itself uses. It has a basic ability to parse .qmail files and act accordingly. Beyond being able to bounce mail for invalid users at the SMTP level, it also has the potential to be used for 'user level' configuration of qpsmtpd, since it works out the users that each rcptto actually gets to. However, to this working fully may take a little more work. =head1 CONFIGURATION check_delivery has three possible modes: check_delivery [ log_only | normal ] [ ] log_only: always returns DECLINE normal (the default): returns DECLINE for valid addresses, or DENY if the address is invalid is 0 by default, but should be increased during testing. This interacts with the TRACE_LEVEL as defined in lib/Qpsmtpd.pm. =head1 LIMITATIONS There is currently no support for the use of 'fastforward' in .qmail files. Also, .qmail-default files are currently only fully supported for the alias user. If a user has a .qmail-default file, then all mail to -extension will be accepted by check_delivery. =head1 REQUIREMENTS check_delivery requires the perl module CDB_Files. In order to know about .qmail files in the user home directory, it also requires dot-qmail-exists.pl, which in turn requires suidperl to be installed. =head1 BUGS None known (however, see limitations above). However, I STRONGLY suggest running in log_only mode until you are sure it works okay on your setup. =head1 CREDITS Thanks to Ask Bjoern Hansen for qpsmtpd. Also, thanks to Matt Sergeant and Tim Goodwin for CDB_File, and Andrew Pam for an earlier take on this kind of plugin. =head1 COPYRIGHT (c) Rasjid Wilcox 2003, =head1 VERSION Last updated 3 May 2003. =head1 LICENCE As per the qpsmtpd license. --------------------------------------------------------------------------- =cut use DBI; use strict; use CDB_File; use File::Basename; sub register { my ($self, $qp, @args) = @_; $self->register_hook("rcpt", "check_delivery"); # Operation Mode my $mode; # MySQL Connection Variables my $mysql_host; my $mysql_db; my $mysql_user; my $mysql_pass; # Get the operation mode first: $mode = (lc $args[0]) || "normal"; $self->{_args}->{cdmode} = $mode; unless ($mode =~ /log_only|normal/) { $self->log(0, "Bad mode \'$mode\' argument for the check_delivery plugin. Defaulting to normal mode."); $self->{_args}->{cdmode} = "normal"; } # Get the level of verbosity $self->{_args}->{cdverb} = int($args[1] || 0); # Get the MySQL Connection params $self->{_args}->{mysql_host} = $args[2] || "ip_of_db_host"; $self->{_args}->{mysql_db} = $args[3] || "vpopmail_db"; $self->{_args}->{mysql_user} = $args[4] || "vpopmail_user"; $self->{_args}->{mysql_pass} = $args[5] || "mypass"; } sub check_delivery { my ($self, $transaction, $recipient) = @_; my $verbosity = $self->{_args}->{cdverb}; my $mode = $self->{_args}->{cdmode}; # Use this if the remote server(s) ignore(s) your 550's and attemtp(s) # delivery to multiple bogus accounts; uncomment one more place below # if you uncomment this. # $self->{_cdrcpt_count}++; $self->log(5,"CDRCPT COUNT: " . $self->{_cdrcpt_count}); # return (DENY, "Too many failed attempts. Try again later.") if ($self->{_cdrcpt_count} > 2 and $self->{_cdrcpt_failed} >= 1); # # $self->log(0, "Mode is $mode, verbosity is $verbosity."); $self->qp->version =~ m/([\.\d]+).*/; my $versionnum = $1; my $user = $recipient->user; my $host = $recipient->host; $host = lc $host; my $delivery = $user . "\@" . $host; # if $host is not a rcpthost, then DECLINE (or DENY?) my @rcpt_hosts = ($self->qp->config("me"), $self->qp->config("rcpthosts")); my $local = 0; for my $allowed (@rcpt_hosts) { $allowed =~ s/^\s*(\S+)/$1/; ($local = 1, last) if (($host eq lc $allowed) or (substr($allowed,0,1) eq "." and $host =~ m/\Q$allowed\E$/i)); } if (($versionnum >= 0.26) and ($local == 0)) { my $more_rcpt_hosts = $self->qp->config('morercpthosts', 'map'); $local = 1 if exists $more_rcpt_hosts->{$host}; } return (DECLINED) unless $local; # expand the address my @deliverylist = $self->expand_address($delivery); # allow other plugins to hook into the results of this one $self->qp->run_hooks("user_delivery", $delivery, @deliverylist); my $count = $#deliverylist + 1; my $msg; # debug if ($verbosity > 2) { $self->log(0, "Count is $count."); my ($i, $j); for $i (0 .. $#deliverylist) { $msg = "Recipient($i): "; for $j (0 .. $#{$deliverylist[$i]}) { $msg = $msg . $deliverylist[$i][$j] . ', '; } $self->log(0, $msg); } } my $text; if ($self->{_args}->{cdmode} eq "log_only") { if ($count == 0) { $text = "REJECTED"; } else { $text = "ACCEPTED"; } $self->log(6-$verbosity, "Mail to $delivery would be $text by check_delivery."); } $msg = "Sorry, there is no mailbox for $delivery here. Contact " . "postmaster\@$host if you think there is a problem."; return (DENY, $msg) if ($count == 0) and ($mode eq "normal"); $self->log(6-$verbosity, "Address $delivery maps to:"); for my $k (0 .. $#deliverylist) { $msg = sprintf("%-15s", $deliverylist[$k][0]) . ", $deliverylist[$k][1]"; $self->log(6-$verbosity, $msg); } return (DECLINED); } =head1 Procedure specs for the curious. =head2 search_qmail_users($delivery) Returns ($user, $uid, $gid, $homedir, $dash, $ext) from /var/qmail/users/cdb for a match on $delivery. For wildcard assignments, it does the conversion to the above form. ie, a match on +loc:user:uid:gid:homedir:dash:pre: by 'locext' returns ($user, $uid, $gid, $homedir, $dash, $pre . 'ext') Returns a list of 6 empty strings if there is no match. =cut sub search_users_assign { my ($self, $delivery) = @_; my $verbosity = $self->{_args}->{cdverb}; my $foundsimple = 0; my $foundwild = 0; my $bestkey = ''; my ($bestkeytext, $keytext); # should /var/qmail/users/cdb be hard-coded? All the other paths in qpsmtpd # seem to be, so I guess it is okay. my $qmail_users_file = '/var/qmail/users/cdb'; tie my %data, 'CDB_File', $qmail_users_file or $self->log(8-$verbosity, "$qmail_users_file does not exist"), return ('', '', '', '', '', ''); my ($k, $v); for $k (keys %data) { # must scan through the entire database or CDB_File dies if ($foundsimple == 0) { if (substr($k, -1, 1) eq "\0") { # simple assignment $keytext = substr($k, 1, length($k) - 2); $self->log(12-$verbosity, "\=$keytext|$delivery|"); if ($keytext eq lc $delivery) { $bestkey = $k; $foundsimple = 1; } } else { # wildcard assignement if (length($k) > 0) { $keytext = substr($k, 1); $self->log(12-$verbosity, "\+$keytext|$delivery|"); if (($delivery =~ /^$keytext/i) and (length($k) > 0)) { $foundwild = 1; if (length $k > length $bestkey) { $bestkey = $k; $bestkeytext = $keytext; } } } } } } if ($foundwild == 1) { my @wildresult = split(/\0/, $data{$bestkey}, 6); my $ext = substr($delivery, length($bestkeytext)); return (@wildresult[0,1,2,3,4], $wildresult[5] . $ext); } elsif ($foundsimple == 1) { return split(/\0/, $data{$bestkey}, 6); } else { return ('', '', '', '', '', ''); } } =head2 dot_file_name($extension) Returns a .qmail filename for the given extension. Does the lowercase and . to : conversion. =cut sub dot_filename { my $extension = shift; $extension = lc $extension; $extension =~ s/\./\:/; return ('.qmail' . $extension); } =head2 dot_default_list($full_dot_filename) Returns a list of possible .qmail-xx-default files to look for. NOTE: The order of this list is important. It must start with the most specific (longest) possible .qmail file, and then progress to the least specific. If the $full_dot_filename does not contain a dash '-', then an empty list is returned. =cut sub dot_default_list { my $full_dot_file = shift; my @splitlist = split(/-/, $full_dot_file); my @defaultlist = (); my $n = scalar @splitlist - 2; while ($n >= 0) { @defaultlist = (@defaultlist, join('-', @splitlist[0..$n]) . '-default'); $n--; } return @defaultlist; } =head2 dot_qmail_exists($dirname, $dot_qmail_file) Returns 1 if it does exist, 0 otherwise. Returns 0 on errors (execpt as given below). Calls dot-qmail-exists.pl unless $user is 'alias'. In the case that dot-qmail-exists.pl returns an unexpected error (exit code 255), returns 1 too (err on the side of allowing mail through.) =cut sub dot_qmail_exists { my ($self, $dirname, $dot_qmail_file) = @_; my $verbosity = $self->{_args}->{cdverb}; my $msg = "\nAssuming $dirname/$dot_qmail_file exists."; if (-r $dirname && -x $dirname) { # should these be real uid tests? return (-f $dirname . '/' . $dot_qmail_file); } # check for existance of 'dot-qmail-exists.pl' helper command # location is currently hard-coded to be in same # directory as the qpsmtpd executable. my $dir = dirname($0); my $dqefile = "$dir/dot-qmail-exists.pl"; # unless (-x $dqefile ) { unless (-x $dqefile && -u $dqefile) { $self->log(0, "WARNING: $dqefile either does not exist, is not executable by qpsmtpd or is not suid. $msg"); return (1); } my $dqeuid = (stat $dqefile)[4]; unless ($dqeuid == 0) { $self->log(0, "WARNING: $dqefile must be owned by root. $msg"); return (1); } my $pid = open(RESULT, "-|"); if ($pid) { # parent while () { chomp; $self->log(9-$verbosity, "$dqefile returned \'$_\'"); } close(RESULT); } else { # child ($dqefile) = ($dqefile =~ m/^(.*)$/); ($dirname) = ($dirname =~ m/^(.*)$/); ($dot_qmail_file) = ($dot_qmail_file =~ m/^(.*)$/); exec $dqefile, $dirname, $dot_qmail_file || die "Unable to start $dqefile."; } my $exit_value = $? >> 8; my $signal_num = $? & 127; my $dumped_core = $? & 128; $self->log(10-$verbosity, "$dqefile returned exit status $exit_value."); unless ($signal_num == 0) { $self->log(0, "WARNING: $dqefile terminated by signal $signal_num. $msg"); return 1; } unless ($dumped_core == 0) { $self->log(0, "WARNING: $dqefile dumped core. $msg"); return 1; } return (0) if ($exit_value == 1); unless ($exit_value == 0) { $self->log(0, "WARNING: $dqefile returned unexpected exit value \'$exit_value\'. $msg"); } return (1); } =head2 get_dot_qmail($delivery) Returns ($user, $uid, $gid, $homedir, $dot_qmail_file, $default). $dot_qmail_file is the file responsible for the given $delivery, Empty strings are returned if there is no such .qmail file and the mail would be undeliverable. NOTE: This routine does not check uid and gid details, it only checks the for the existance of the returned .qmail file (with the exception of $homedir/.qmail, which is allowed to be missing). If applicable, $default is set to the portion of the address matching the -default part of the .qmail file name. =cut sub get_dot_qmail { my ($self, $delivery) = @_; my $verbosity = $self->{_args}->{cdverb}; $self->log(8-$verbosity, "Searching users/assign(cdb) for '$delivery'"); my ($user, $uid, $gid, $homedir, $dash, $ext) = $self->search_users_assign($delivery); if ($user ne '') { # We have a match in qmail-users my $full_ext = $dash . $ext; $self->log(8-$verbosity, "We have a match in users/assign!"); # if there is no dash in $full_ext, then we don't need to check # for file existance (see man qmail-local) my $full_dot_file = dot_filename($full_ext); $self->log(8-$verbosity, "Considering $homedir/$full_dot_file, with default \'\'"); return ($user, $uid, $gid, $homedir, $full_dot_file, '') if (!($full_ext =~ /-/) || $self->dot_qmail_exists($homedir, $full_dot_file)); # create as list of possible -default files to look for my @defaultlist = dot_default_list($full_dot_file); foreach my $dot_file (@defaultlist) { my $default = substr($full_ext, (length $dot_file) - 13); $self->log(8-$verbosity, "Considering $homedir/$dot_file, with default \'$default\'"); return ($user, $uid, $gid, $homedir, $dot_file, $default) if $self->dot_qmail_exists($homedir, $dot_file); } $self->log(8-$verbosity, "No relevant .qmail files found."); return ('', '', '', '', '', ''); } else { # No entry in qmail users, so: $self->log(1, "Looking in password file..."); #$self->log(8-$verbosity, "Looking in password file..."); # first just see if it is a local user ($uid, $gid, $homedir) = (getpwnam($delivery))[2,3,7]; if (defined $homedir && -d $homedir) { return ($delivery, $uid, $gid, $homedir, '.qmail', ''); } # now check for an extension address ($user, my $extension) = split(/-/, $delivery, 2); ($uid, $gid, $homedir) = (getpwnam($user))[2,3,7]; if (defined $homedir && -d $homedir) { my $full_dot_file = dot_filename('-' . $extension); $self->log(8-$verbosity, "Considering $homedir/$full_dot_file, with default \'\'"); return ($user, $uid, $gid, $homedir, $full_dot_file, '') if $self->dot_qmail_exists($homedir, $full_dot_file); my @defaultlist = dot_default_list($full_dot_file); foreach my $dot_file (@defaultlist) { my $default = substr($extension, (length $dot_file) - 14); $self->log(8-$verbosity, "Considering $homedir/$dot_file, with default \'$default\'"); return ($user, $uid, $gid, $homedir, $dot_file, $default) if $self->dot_qmail_exists($homedir, $dot_file); } } } $self->log(8-$verbosity, "No luck here either."); return ('', '', '', '', '', ''); } =head2 fqdnadr(@addresses) Given a list of addresses, returns a list of fully-qualified addresses, adding @<\var\qmail\control\me> as necessary. =cut sub fqdnadr { my ($self, @addresses) = @_; my $verbosity = $self->{_args}->{cdverb}; my @fqdnaddress = (); foreach my $address (@addresses) { unless ($address =~ /\@/) { $address = $address . "\@" . $self->qp->config("me"); } @fqdnaddress = (@fqdnaddress, $address); } return (@fqdnaddress); } =head2 expand_local($delivery) Expands a local alias-delivery into a list of email addresses. Returns -1, "error message" if undeliverable 0, @email_address for an alias expansion 1, $username if the $delivery is to a real user =cut sub expand_local { my ($self, $delivery, $addressx) = @_; my $verbosity = $self->{_args}->{cdverb}; my ($user, $uid, $gid, $homedir, $dot_qmail, $default) = $self->get_dot_qmail($delivery); return (-1, "address unknown") if $user eq ''; $self->log(0, "expand_local(): Homedir: $homedir Dot_Qmail: $dot_qmail Default: $default User: $user"); # vpopmail # if ("$homedir/$dot_qmail" =~ m#/home/vpopmail/.*/\.qmail-default#) # { #$self->log(7-$verbosity, "checking vpopmail for $delivery user: $addressx"); $self->log(1, "checking vpopmail for $delivery user: $addressx"); # can't do direct check since we don't have access to the vpopmail dir # as the user we are running under my $user_exists = $self->vpopmail_user_check($addressx); if ($user_exists) { $self->log(1, "The address $addressx was found!"); return (1, $user); } else { $self->log(1, "The address $addressx was NOT found. Checking for Catch-All in $homedir/$dot_qmail"); # open QMAIL, '<', "$homedir/$dot_qmail" or $self->log(0, "Can't open $homedir/$dot_qmail: $!"); # my $counter; # my $dotqmail_result; # foreach my $line () # { # $counter++; # $self->log(0, "##### LINE: ".$line); # if (($line =~ /bounce-no-mailbox/ or $line =~ /delete/) and $counter == 1) # { # $self->log(0, "The address $addressx does not exist in the database"); # $dotqmail_result = 0; # } # # if ($line =~ /vdelivermail/ and $line =~ /home\/vpopmail\/domains/) # { # $self->log(0, "The domain has a local catch-all defined!"); # $dotqmail_result = 1; # } # } # close(QMAIL); # # if ($dotqmail_result) # { # return (1, "Catch-all defined for domain $user"); # } # else # { # return (-1, "Address $addressx unknown!"); # } # Until we fix the above, we return that the address was not found return (-1, "Address $addressx unknown!"); } #} return (1, $user) if ($user ne 'alias'); # could (should?) check permissions here # user is 'alias' FIXME: check that this is correct my $defaultmaildir = $self->qp->config("defaultdelivery") || "./Maildir"; if ($delivery eq 'alias') { return (1, $user) if (-f $homedir . '/' . $dot_qmail); return (1, $user) if (-f $homedir . '/' . $defaultmaildir); return (-1, "address unknown"); } # alias expansion, if applicable my $filename = $homedir . '/' . $dot_qmail; return (-1, "address unknown") unless (-f $filename); open(DOT_FILE, $filename) or die "Unable to open $filename"; my $returncode = 0; my @addresslist = (); my (@newaddress, @fqdnaddress, $address); # NOTE: the first character must be #, |, ., or /, or the entire line blank # for qmail to treat the list as anything other than an address while (my $line = ) { next if ($line =~ /^#/); next if ($line =~ /^\s*$/); last if (($line =~ m!^(\.|\/)!) and ($returncode = 1)); if ($line =~ /^\|(.*)$/) { my ($cmd, $params) = split(' ', $1, 2); if ($cmd eq 'forward') { $params =~ s/\$DEFAULT/$default/g; @newaddress = split(' ', $params); } else { # call "exec_$cmd" plugin to deal with other commands # they should return ( OK | DECLINED, @addresslist) # where okay terminates further address expansion my ($returncode, @newaddress) = $self->qp->run_hooks("exec_$cmd", $params, $default, $filename, $delivery); } @addresslist = (@addresslist, $self->fqdnadr(@newaddress)); last if ($returncode == OK); } else { # add in addresses $line =~ s/^\&//; @newaddress = split(' ', $line); @addresslist = (@addresslist, $self->fqdnadr(@newaddress)); } } close(DOT_FILE); return (1, $user) if ($returncode == 1); return (0, @addresslist); } =head2 expand_address($address, @history) Expands an address to an array of arrays, where: $result[0] = ($user0, $address0, @history0) $result[1] = ($user1, $address1, @history1) ... where each $result[n] is a 'real' user and a deliverable address. The @history is to catch mail-loops. If the address is not a virtual or local host, a empty '' $user will be returned. =cut sub expand_address { my ($self, $address, @history) = @_; my $verbosity = $self->{_args}->{cdverb}; # check for looping aliases foreach my $item (@history) { if ($item eq $address) { $self->log(0, "*** WARNING: looping address $address found!"); return (); } } my ($delivery, $host) = split(/\@/, $address, 2); # do localhost check my $local = 0; my @locals; @locals = $self->qp->config("locals") or @locals = $self->qp->config("me"); for my $domain (@locals) { $domain =~ s/^\s*(\S+)/$1/; last if $local = ($host eq lc $domain); } my @result; my ($item, $i, @part, $indent, $logmsg, $newaddress, $match); unless ($local) { # do virtual domain stuff my $virtualdomain = 0; my @virtualdomains = $self->qp->config("virtualdomains"); my ($vdomain, $pre, %virtualdomains); for $item (@virtualdomains) { ($vdomain, $pre) = split(/:/, $item, 2); next if (length $pre == 0); $virtualdomains{$vdomain} = $pre; } if (defined $virtualdomains{$address} || defined $virtualdomains{$host}) { $match = $virtualdomains{$address} || $virtualdomains{$host}; $delivery = $match . '-' . $delivery; $newaddress = "$delivery\@$locals[-1]"; $indent = 2 * (scalar @history); $logmsg = "Delivered-To:" . sprintf('%*s', $indent, ' ') . "$newaddress"; $self->log(7-$verbosity, $logmsg); @history = ($newaddress, @history); } else { # non-local delivery # FIXME: we should put in a run-hook call here, so a plugin can be used # to validate users on other machines in the LAN via PAM or some such method $indent = 2 * (scalar @history); $logmsg = "Delivered-To:" . sprintf('%*s', $indent, ' ') . "$address"; $self->log(7-$verbosity, $logmsg); $result[0] = [ ('', $address, @history) ]; return @result; } } # local addresses and virtual domains (after conversion) my ($returncode, @info) = $self->expand_local($delivery,$address); $self->log(1-$verbosity, "Return code from expand_local($delivery): $returncode"); return () if ($returncode == -1); if ($returncode == 1) { $result[0] = [ ($info[0], $address, @history) ]; return @result; } my $n = 0; ADDRESS: for $newaddress (@info) { $indent = 2 * (scalar @history) + 1; $logmsg = "Delivered-To:" . sprintf('%*s', $indent, ' ') . "$newaddress"; $self->log(7-$verbosity, $logmsg); # if $newaddress is in @history, looping so drop it. foreach $item (@history) { if ($newaddress eq $item) { $self->log(0, "*** WARNING: looping address $newaddress found!"); next ADDRESS; } } @part = $self->expand_address($newaddress, $address, @history); for $i ( 0 .. $#part ) { $result[$n] = $part[$i]; $n++; } } return @result; } =head2 vpopmail_user_check($address) Looks up the supplied email address in a mysql database. This is for people with vpopmail setup to use mysql. --- Added by Aric Fedida 12/12/2003 =cut sub vpopmail_user_check { my ($self, $address) = @_; my $verbosity = $self->{_args}->{cdverb}; my $mailbox = ""; my $domain = ""; # Grab the passed mysql connection params my $mysql_host = $self->{_args}->{mysql_host}; my $mysql_db = $self->{_args}->{mysql_db}; my $mysql_user = $self->{_args}->{mysql_user}; my $mysql_pass = $self->{_args}->{mysql_pass}; # Split the address into the mailbox and domain components if ($address =~ m/^([^@]+)@(.+)$/) { ($mailbox, $domain) = ($1, $2); } # Some debugging: $self->log(0-$verbosity, "Connecting to $mysql_host to db $mysql_db as user $mysql_user"); # Connect to the vpopmail db - hardcoded, fix this! my $dbh = DBI->connect("dbi:mysql:dbname=$mysql_db:host=$mysql_host","$mysql_user","$mysql_pass"); # Do we have a user with this email address? my $sql = "SELECT * FROM vpopmail WHERE pw_name = '$mailbox' AND pw_domain = '$domain'"; # Execute the query my $sth = $dbh->prepare($sql); $sth->execute || die "Could not execute SQL statement ... maybe invalid?"; # Just return the number of items found (0 or >=1) return($sth->rows); }