package Qpsmtpd;
use strict;
#use warnings;
our $VERSION = "0.95";
use vars qw($TraceLevel $Spool_dir $Size_threshold);
use lib 'lib';
use base 'Qpsmtpd::Base';
use Qpsmtpd::Address;
use Qpsmtpd::Config;
use Qpsmtpd::Constants;
my $git;
if (-e ".git") {
local $ENV{PATH} = "/usr/bin:/usr/local/bin:/opt/local/bin/";
$git = `git describe`;
$git && chomp $git;
}
our $hooks = {};
our $LOGGING_LOADED = 0;
sub _restart {
my $self = shift;
my %args = @_;
if ($args{restart}) {
# reset all global vars to defaults
$self->conf->clear_cache();
$hooks = {};
$LOGGING_LOADED = 0;
$TraceLevel = LOGWARN;
$Spool_dir = undef;
$Size_threshold = undef;
}
}
sub version { $VERSION . ($git ? "/$git" : "") }
sub TRACE_LEVEL { $TraceLevel }; # leave for plugin compatibility
sub hooks { $hooks; }
sub load_logging {
my $self = shift;
# avoid triggering log activity
return if ($LOGGING_LOADED || $hooks->{'logging'});
my $configdir = $self->config_dir("logging");
my $configfile = "$configdir/logging";
my @loggers = $self->conf->from_file($configfile, 'logging');
$configdir = $self->config_dir('plugin_dirs');
$configfile = "$configdir/plugin_dirs";
my @plugin_dirs = $self->conf->from_file($configfile, 'plugin_dirs');
unless (@plugin_dirs) {
my ($name) = ($0 =~ m!(.*?)/([^/]+)$!);
@plugin_dirs = ("$name/plugins");
}
my @loaded;
for my $logger (@loggers) {
push @loaded, $self->_load_plugin($logger, @plugin_dirs);
}
foreach my $logger (@loaded) {
$self->log(LOGINFO, "Loaded $logger");
}
$configdir = $self->config_dir("loglevel");
$configfile = "$configdir/loglevel";
$TraceLevel = $self->conf->from_file($configfile, 'loglevel');
unless (defined($TraceLevel) and $TraceLevel =~ /^\d+$/) {
$TraceLevel = LOGWARN; # Default if no loglevel file found.
}
$LOGGING_LOADED = 1;
return @loggers;
}
sub trace_level { return $TraceLevel; }
sub init_logger { # needed for compatibility
shift->trace_level();
}
sub log {
my ($self, $trace, @log) = @_;
$self->varlog($trace, join(" ", @log));
}
sub varlog {
my ($self, $trace) = (shift, shift);
my ($hook, $plugin, @log);
if ($#_ == 0) { (@log) = @_; } # log itself
elsif ($#_ == 1) { ($hook, @log) = @_; } # plus the hook
else { ($hook, $plugin, @log) = @_; } # from a plugin
$self->load_logging;
my ($rc) =
$self->run_hooks_no_respond("logging", $trace, $hook, $plugin, @log)
or return;
return if $rc == DECLINED || $rc == OK; # plugin success
return if $trace > $TraceLevel;
# no logging plugins registered, fall back to STDERR
my $prefix =
defined $plugin && defined $hook ? " ($hook) $plugin:"
: defined $plugin ? " $plugin:"
: defined $hook ? " ($hook) running plugin:"
: '';
warn join(' ', $$ . $prefix, @log), "\n";
}
sub conf {
my $self = shift;
if (!$self->{_config}) {
$self->{_config} = Qpsmtpd::Config->new();
};
return $self->{_config};
}
sub config {
my ($self, $c, $type) = @_;
$self->log(LOGDEBUG, "in config($c)");
# first run the user_config hooks
my ($rc, @config);
if (ref $type && $type->can('address')) {
($rc, @config) = $self->run_hooks_no_respond('user_config', $type, $c);
if (defined $rc && $rc == OK) {
return wantarray ? @config : $config[0];
};
};
# then run the config hooks
($rc, @config) = $self->run_hooks_no_respond('config', $c);
$self->log(LOGDEBUG,
"config($c): hook returned ("
. join(',', map { defined $_ ? $_ : 'undef' } ($rc, @config))
. ")"
);
if (defined $rc && $rc == OK) {
return wantarray ? @config : $config[0];
};
# then qmail
@config = $self->conf->get_qmail($c, $type);
return wantarray ? @config : $config[0] if @config;
# then the default, which may be undefined
return $self->conf->default($c);
}
sub config_dir {
my $self = shift;
return $self->conf->config_dir(@_);
}
sub plugin_dirs {
my $self = shift;
my @plugin_dirs = $self->config('plugin_dirs');
unless (@plugin_dirs) {
my ($path) = ($ENV{PROCESS} ? $ENV{PROCESS} : $0) =~ m!(.*?)/([^/]+)$!;
@plugin_dirs = ("$path/plugins");
}
return @plugin_dirs;
}
sub load_plugins {
my $self = shift;
my @plugins = $self->config('plugins');
my @loaded;
if ($hooks->{queue}) {
#$self->log(LOGWARN, "Plugins already loaded");
return @plugins;
}
for my $plugin_line (@plugins) {
my $this_plugin = $self->_load_plugin($plugin_line, $self->plugin_dirs);
push @loaded, $this_plugin if $this_plugin;
}
return @loaded;
}
sub _load_plugin {
my $self = shift;
my ($plugin_line, @plugin_dirs) = @_;
# untaint the config data before passing it to plugins
my ($safe_line) = $plugin_line =~ /^([ -~]+)$/ # all ascii printable
or die "unsafe characters in config line: $plugin_line\n";
my ($plugin, @args) = split /\s+/, $safe_line;
if ($plugin =~ m/::/) {
return $self->_load_package_plugin($plugin, $safe_line, \@args);
}
# regular plugins/$plugin plugin
my $plugin_name = $plugin;
$plugin =~ s/:\d+$//; # after this point, only used for filename
# Escape everything into valid perl identifiers
$plugin_name =~ s/([^A-Za-z0-9_\/])/sprintf("_%2x",unpack("C",$1))/eg;
# second pass cares for slashes and words starting with a digit
$plugin_name =~ s{
(/+) # directory
(\d?) # package's first character
}[
"::" . (length $2 ? sprintf("_%2x",unpack("C",$2)) : "")
]egx;
my $package = "Qpsmtpd::Plugin::$plugin_name";
# don't reload plugins if they are already loaded
if (!defined &{"${package}::plugin_name"}) {
for my $dir (@plugin_dirs) {
next if !-e "$dir/$plugin";
Qpsmtpd::Plugin->compile($plugin_name, $package,
"$dir/$plugin", $self->{_test_mode}, $plugin);
if ($safe_line !~ /logging/) {
$self->log(LOGDEBUG, "Loading $safe_line from $dir/$plugin");
};
last;
}
if (! defined &{"${package}::plugin_name"}) {
die "Plugin $plugin_name not found in our plugin dirs (", join(', ', @plugin_dirs), ")";
};
}
my $plug = $package->new();
$plug->_register($self, @args);
return $plug;
}
sub _load_package_plugin {
my ($self, $plugin, $plugin_line, $args) = @_;
# "full" package plugin (My::Plugin)
my $package = $plugin;
$package =~ s/[^_a-z0-9:]+//gi;
my $eval =
qq[require $package;\n] . qq[sub ${plugin}::plugin_name { '$plugin' }];
$eval =~ m/(.*)/s;
$eval = $1;
eval $eval; ## no critic (Eval)
die "Failed loading $package - eval $@" if $@;
if ($plugin_line !~ /logging/) {
$self->log(LOGDEBUG, "Loading $package ($plugin_line)");
};
my $plug = $package->new();
$plug->_register($self, @$args);
return $plug;
}
sub transaction { return {}; } # base class implements empty transaction
sub run_hooks {
my ($self, $hook) = (shift, shift);
if ($hooks->{$hook}) {
my @r;
my @local_hooks = @{$hooks->{$hook}};
$self->{_continuation} = [$hook, [@_], @local_hooks];
return $self->run_continuation();
}
return $self->hook_responder($hook, [0, ''], [@_]);
}
sub run_hooks_no_respond {
my ($self, $hook) = (shift, shift);
if ($hooks->{$hook}) {
my @r;
for my $code (@{$hooks->{$hook}}) {
eval { (@r) = $code->{code}->($self, $self->transaction, @_); };
if ($@) {
warn("FATAL PLUGIN ERROR [" . $code->{name} . "]: ", $@);
next;
}
if ($r[0] == YIELD) {
die "YIELD not valid from $hook hook";
}
last unless $r[0] == DECLINED;
}
$r[0] = DECLINED if not defined $r[0];
return @r;
}
return (0, '');
}
sub continue_read { } # subclassed in -async
sub pause_read { die "Continuations only work in qpsmtpd-async" }
sub run_continuation {
my $self = shift;
die "No continuation in progress" unless $self->{_continuation};
$self->continue_read();
my $todo = $self->{_continuation};
$self->{_continuation} = undef;
my $hook = shift @$todo || die "No hook in the continuation";
my $args = shift @$todo || die "No hook args in the continuation";
my @r;
while (@$todo) {
my $code = shift @$todo;
$self->varlog(LOGDEBUG, $hook, $code->{name});
my $tran = $self->transaction;
eval { (@r) = $code->{code}->($self, $tran, @$args); };
if ($@) {
$self->log(LOGCRIT, "FATAL PLUGIN ERROR [" . $code->{name} . "]: ", $@);
next;
}
!defined $r[0]
and $self->log(LOGERROR,
"plugin "
. $code->{name}
. " running the $hook hook returned undef!"
)
and next;
# note this is wrong as $tran is always true in the
# current code...
if ($tran) {
my $tnotes = $tran->notes($code->{name});
$tnotes->{"hook_$hook"}->{'return'} = $r[0]
if (!defined $tnotes || ref $tnotes eq "HASH");
}
else {
my $cnotes = $self->connection->notes($code->{name});
$cnotes->{"hook_$hook"}->{'return'} = $r[0]
if (!defined $cnotes || ref $cnotes eq "HASH");
}
if ($r[0] == YIELD) {
$self->pause_read();
$self->{_continuation} = [$hook, $args, @$todo];
return @r;
}
elsif ( $r[0] == DENY
or $r[0] == DENYSOFT
or $r[0] == DENY_DISCONNECT
or $r[0] == DENYSOFT_DISCONNECT)
{
$r[1] = "" if not defined $r[1];
$self->log(LOGDEBUG,
"Plugin "
. $code->{name}
. ", hook $hook returned "
. return_code($r[0])
. ", $r[1]"
);
$self->run_hooks_no_respond("deny", $code->{name}, $r[0], $r[1])
unless ($hook eq "deny");
}
else {
$r[1] = "" if not defined $r[1];
$self->log(LOGDEBUG,
"Plugin "
. $code->{name}
. ", hook $hook returned "
. return_code($r[0])
. ", $r[1]"
);
$self->run_hooks_no_respond("ok", $code->{name}, $r[0], $r[1])
unless ($hook eq "ok");
}
last unless $r[0] == DECLINED;
}
$r[0] = DECLINED if not defined $r[0];
# hook_*_parse() may return a CODE ref..
# ... which breaks when splitting as string:
@r = map { split /\n/ } @r unless (ref($r[1]) eq "CODE");
return $self->hook_responder($hook, \@r, $args);
}
sub hook_responder {
my ($self, $hook, $msg, $args) = @_;
my $code = shift @$msg;
my $responder = $hook . '_respond';
if (my $meth = $self->can($responder)) {
return $meth->($self, $code, $msg, $args);
}
return $code, @$msg;
}
sub _register_hook {
my ($self, $hook, $code, $unshift) = @_;
if ($unshift) {
unshift @{$hooks->{$hook}}, $code;
return;
}
push @{$hooks->{$hook}}, $code;
}
sub spool_dir {
my $self = shift;
return $Spool_dir if $Spool_dir; # already set
$self->log(LOGDEBUG, "Initializing spool_dir");
$Spool_dir = $self->config('spool_dir') || $self->tildeexp('~/tmp/');
$Spool_dir .= "/" if $Spool_dir !~ m!/$!;
$Spool_dir =~ /^(.+)$/ or die "spool_dir not configured properly";
$Spool_dir = $1; # cleanse the taint
my $Spool_perms = $self->config('spool_perms') || '0700';
if (!-d $Spool_dir) { # create if it doesn't exist
mkdir($Spool_dir, oct($Spool_perms))
or die "Could not create spool_dir $Spool_dir: $!";
}
# Make sure the spool dir has appropriate rights
if (((stat $Spool_dir)[2] & oct('07777')) != oct($Spool_perms)) {
$self->log(LOGWARN,
"Permissions on spool_dir $Spool_dir are not $Spool_perms")
};
return $Spool_dir;
}
# For unique filenames. We write to a local tmp dir so we don't need
# to make them unpredictable.
my $transaction_counter = 0;
sub temp_file {
my $self = shift;
my $filename =
$self->spool_dir() . join(":", time, $$, $transaction_counter++);
return $filename;
}
sub temp_dir {
my ($self, $mask) = @_;
my $dirname = $self->temp_file();
-d $dirname
or mkdir($dirname, $mask || '0700')
or die "Could not create temporary directory $dirname: $!";
return $dirname;
}
sub size_threshold {
my $self = shift;
return $Size_threshold if defined $Size_threshold;
$Size_threshold = $self->config('size_threshold') || 0;
$self->log(LOGDEBUG, "size_threshold set to $Size_threshold");
return $Size_threshold;
}
sub authenticated {
my $self = shift;
return (defined $self->{_auth} ? $self->{_auth} : "");
}
sub auth_user {
my $self = shift;
return (defined $self->{_auth_user} ? $self->{_auth_user} : "");
}
sub auth_mechanism {
my $self = shift;
return (defined $self->{_auth_mechanism} ? $self->{_auth_mechanism} : "");
}
sub address {
my $self = shift;
my $addr = Qpsmtpd::Address->new(@_);
$addr->qp($self);
return $addr;
}
1;
__END__
=head1 NAME
Qpsmtpd - base class for the qpsmtpd mail server
=head1 DESCRIPTION
This is the base class for the qpsmtpd mail server. See
L and the I file for more information.
=head1 COPYRIGHT
Copyright 2001-2012 Ask Bjørn Hansen, Develooper LLC. See the
LICENSE file for more information.
=cut