1#!/usr/bin/perl -T 2 3#------------------------------------------------------------------------------ 4# This is amavis-mc, a master (of ceremonies) processes to supervise 5# supporting service processes (such as amavis-services) used by amavisd-new. 6# 7# Author: Mark Martinec <Mark.Martinec@ijs.si> 8# 9# Copyright (c) 2012-2014, Mark Martinec 10# All rights reserved. 11# 12# Redistribution and use in source and binary forms, with or without 13# modification, are permitted provided that the following conditions 14# are met: 15# 1. Redistributions of source code must retain the above copyright notice, 16# this list of conditions and the following disclaimer. 17# 2. Redistributions in binary form must reproduce the above copyright notice, 18# this list of conditions and the following disclaimer in the documentation 19# and/or other materials provided with the distribution. 20# 21# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 22# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 23# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 24# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS 25# BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 26# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 27# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 28# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 29# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 30# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 31# POSSIBILITY OF SUCH DAMAGE. 32# 33# The views and conclusions contained in the software and documentation are 34# those of the authors and should not be interpreted as representing official 35# policies, either expressed or implied, of the Jozef Stefan Institute. 36 37# (the above license is the 2-clause BSD license, also known as 38# a "Simplified BSD License", and pertains to this program only) 39# 40# Patches and problem reports are welcome. 41# The latest version of this program is available at: 42# http://www.ijs.si/software/amavisd/ 43#------------------------------------------------------------------------------ 44 45use strict; 46use re 'taint'; 47use warnings; 48use warnings FATAL => qw(utf8 void); 49no warnings 'uninitialized'; 50 51use vars qw($VERSION); $VERSION = 2.008002; 52 53use vars qw($myproduct_name $myversion_id $myversion_date $myversion); 54BEGIN { 55 $myproduct_name = 'amavis-mc'; 56 $myversion_id = '2.9.0'; $myversion_date = '20140506'; 57 $myversion = "$myproduct_name-$myversion_id ($myversion_date)"; 58} 59 60use Errno qw(ESRCH ENOENT); 61use POSIX qw(:sys_wait_h 62 WIFEXITED WIFSIGNALED WIFSTOPPED WEXITSTATUS WTERMSIG WSTOPSIG); 63use Time::HiRes qw(time); 64use IO::File qw(O_RDONLY O_WRONLY O_RDWR O_APPEND O_CREAT O_EXCL); 65use Unix::Syslog qw(:macros :subs); 66 67use vars qw(@path @services $daemon_user $daemon_group $pid_file $log_level 68 $syslog_ident $syslog_facility); 69 70 71### USER CONFIGURABLE: 72 73$daemon_user = 'vscan'; 74$daemon_group = 'vscan'; 75 76$pid_file = '/var/amavis/amavis-mc.pid'; 77 78$log_level = 0; 79$syslog_ident = 'amavis-mc'; 80$syslog_facility = LOG_MAIL; 81 82@path = qw(/usr/local/sbin /usr/local/bin /usr/sbin /sbin /usr/bin /bin); 83 84@services = ( 85 { cmd => 'amavis-services msg-forwarder' }, 86 { cmd => 'amavis-services childproc-minder' }, 87 { cmd => 'amavis-services snmp-responder' }, 88); 89 90### END OF USER CONFIGURABLE 91 92 93my($interrupted, $syslog_open, $pid_file_created, @pids_exited, %pid2service); 94 95# Return untainted copy of a string (argument can be a string or a string ref) 96# 97sub untaint($) { 98 return undef if !defined $_[0]; # must return undef even in a list context! 99 no re 'taint'; 100 local $1; # avoid Perl taint bug: tainted global $1 propagates taintedness 101 (ref($_[0]) ? ${$_[0]} : $_[0]) =~ /^(.*)\z/s; 102 $1; 103} 104 105# is message log level below the current log level (i.e. eligible for logging)? 106# 107sub ll($) { 108 my($level) = @_; 109 $level <= $log_level; 110} 111 112sub do_log($$;@) { 113# my($level,$errmsg,@args) = @_; 114 my $level = shift; 115 if ($level <= $log_level) { 116 my $errmsg = shift; 117 # treat $errmsg as sprintf format string if additional arguments provided 118 $errmsg = sprintf($errmsg,@_) if @_; 119 if (!$syslog_open) { 120 $errmsg .= "\n"; 121 print STDERR $errmsg; # print ignoring I/O status, except SIGPIPE 122 } else { 123 my $prio = $level >= 3 ? LOG_DEBUG # most frequent first 124 : $level >= 1 ? LOG_INFO 125 : $level >= 0 ? LOG_NOTICE 126 : $level >= -1 ? LOG_WARNING 127 : LOG_ERR; 128 syslog($prio, "%s", $errmsg); 129 } 130 } 131} 132 133sub find_program_path($$) { 134 my($fv_list, $path_list_ref) = @_; 135 $fv_list = [$fv_list] if !ref $fv_list; 136 my $found; 137 for my $fv (@$fv_list) { # search through alternatives 138 my(@fv_cmd) = split(' ',$fv); 139 my $cmd = $fv_cmd[0]; 140 if (!@fv_cmd) { 141 # empty, not available 142 } elsif ($cmd =~ m{^/}s) { # absolute path 143 my $errn = stat($cmd) ? 0 : 0+$!; 144 if ($errn == ENOENT) { 145 # file does not exist 146 } elsif ($errn) { 147 do_log(-1, "find_program_path: %s inaccessible: %s", $cmd,$!); 148 } elsif (-d _) { 149 do_log(0, "find_program_path: %s is a directory", $cmd); 150 } elsif (!-x _) { 151 do_log(0, "find_program_path: %s is not executable", $cmd); 152 } else { 153 $found = join(' ', @fv_cmd); 154 } 155 } elsif ($cmd =~ m{/}s) { # relative path 156 die "find_program_path: relative paths not implemented: @fv_cmd\n"; 157 } else { # walk through the specified PATH 158 for my $p (@$path_list_ref) { 159 my $errn = stat("$p/$cmd") ? 0 : 0+$!; 160 if ($errn == ENOENT) { 161 # file does not exist 162 } elsif ($errn) { 163 do_log(-1, "find_program_path: %s/%s inaccessible: %s", $p,$cmd,$!); 164 } elsif (-d _) { 165 do_log(0, "find_program_path: %s/%s is a directory", $p,$cmd); 166 } elsif (!-x _) { 167 do_log(0, "find_program_path: %s/%s is not executable", $p,$cmd); 168 } else { 169 $found = $p . '/' . join(' ', @fv_cmd); 170 last; 171 } 172 } 173 } 174 last if defined $found; 175 } 176 $found; 177} 178 179# drop privileges 180# 181sub drop_priv($$) { 182 my($desired_user,$desired_group) = @_; 183 local($1); 184 my($username,$passwd,$uid,$gid) = 185 $desired_user=~/^(\d+)$/ ? (undef,undef,$1,undef) :getpwnam($desired_user); 186 defined $uid or die "drop_priv: No such username: $desired_user\n"; 187 if (!defined($desired_group) || $desired_group eq '') { 188 $desired_group = $gid; # for logging purposes 189 } 190 else { $gid = $desired_group=~/^(\d+)$/ ? $1 : getgrnam($desired_group) } 191 defined $gid or die "drop_priv: No such group: $desired_group\n"; 192 $( = $gid; $) = "$gid $gid"; # real and effective GID 193 POSIX::setgid($gid) or die "drop_priv: Can't setgid to $gid: $!"; 194 POSIX::setuid($uid) or die "drop_priv: Can't setuid to $uid: $!"; 195 $> = $uid; $< = $uid; # just in case 196# print STDERR "desired user=$desired_user ($uid), current: EUID: $> ($<)\n"; 197# print STDERR "desired group=$desired_group ($gid), current: EGID: $) ($()\n"; 198 $> != 0 or die "drop_priv: Still running as root, aborting\n"; 199 $< != 0 or die "Effective UID changed, but Real UID is 0, aborting\n"; 200} 201 202sub daemonize() { 203 closelog() if $syslog_open; 204 $syslog_open = 0; 205 206 STDOUT->autoflush(1); 207 STDERR->autoflush(1); 208 close(STDIN) or die "Can't close STDIN: $!"; 209 210 my $pid; 211 # the first fork allows the shell to return and allows doing a setsid 212 eval { $pid = fork(); 1 } 213 or do { 214 my($eval_stat) = $@ ne '' ? $@ : "errno=$!"; chomp $eval_stat; 215 die "Error forking #1: $eval_stat"; 216 }; 217 defined $pid or die "Can't fork #1: $!"; 218 if ($pid) { # parent process terminates here 219 POSIX::_exit(0); # avoid END and destructor processing 220 } 221 222 # disassociate from a controlling terminal 223 my $pgid = POSIX::setsid(); 224 defined $pgid && $pgid >= 0 or die "Can't start a new session: $!"; 225 226 # We are now a session leader. As a session leader, opening a file 227 # descriptor that is a terminal will make it our controlling terminal. 228 # The second fork makes us NOT a session leader. Only session leaders 229 # can acquire a controlling terminal, so we may now open up any file 230 # we wish without worrying that it will become a controlling terminal. 231 232 # second fork prevents from accidentally reacquiring a controlling terminal 233 eval { $pid = fork(); 1 } 234 or do { 235 my($eval_stat) = $@ ne '' ? $@ : "errno=$!"; chomp $eval_stat; 236 die "Error forking #2: $eval_stat"; 237 }; 238 defined $pid or die "Can't fork #2: $!"; 239 if ($pid) { # parent process terminates here 240 POSIX::_exit(0); # avoid END and destructor processing 241 } 242 243 chdir('/') or die "Can't chdir to '/': $!"; 244 245 # a daemonized child process, live long and prosper... 246 do_log(2, "Daemonized as process [%s]", $$); 247 248 openlog($syslog_ident, LOG_PID | LOG_NDELAY, $syslog_facility); 249 $syslog_open = 1; 250 251 { # suppress unnecessary warning: 252 # "Filehandle STDIN reopened as STDOUT only for output" 253 # See https://rt.perl.org/rt3/Public/Bug/Display.html?id=23838 254 no warnings 'io'; 255 close(STDOUT) or die "Can't close STDOUT: $!"; 256 open(STDOUT, '>/dev/null') or die "Can't open /dev/null: $!"; 257 close(STDERR) or die "Can't close STDERR: $!"; 258 open(STDERR, '>&STDOUT') or die "Can't dup STDOUT: $!"; 259 } 260 261} 262 263# Run specified command as a subprocess. 264# Return a process id of a child process. 265# 266sub spawn_command($@) { 267 my($cmd, @args) = @_; 268 my $cmd_text = join(' ', $cmd, @args); 269 my $pid; 270 eval { 271 # Note that fork(2) returns ENOMEM on lack of swap space, and EAGAIN when 272 # process limit is reached; we want it to fail in both cases and not obey 273 # the EAGAIN and keep retrying, as perl open() does. 274 $pid = fork(); 1; 275 } or do { 276 my $eval_stat = $@ ne '' ? $@ : "errno=$!"; chomp $eval_stat; 277 die "spawn_command (forking): $eval_stat"; 278 }; 279 defined($pid) or die "spawn_command: can't fork: $!"; 280 if (!$pid) { # child 281 alarm(0); my $interrupt = ''; 282 my $h1 = sub { $interrupt = $_[0] }; 283 my $h2 = sub { die "Received signal ".$_[0] }; 284 @SIG{qw(INT HUP TERM TSTP QUIT USR1 USR2)} = ($h1) x 7; 285 my $err; 286 eval { # die must be caught, otherwise we end up with two running daemons 287 local(@SIG{qw(INT HUP TERM TSTP QUIT USR1 USR2)}) = ($h2) x 7; 288 if ($interrupt ne '') { my $i = $interrupt; $interrupt = ''; die $i } 289 close STDIN; # ignoring errors 290 close STDOUT; # ignoring errors 291 # BEWARE of Perl older that 5.6.0: sockets and pipes were not FD_CLOEXEC 292 exec {$cmd} ($cmd,@args); 293 die "spawn_command: failed to exec $cmd_text: $!"; 294 0; # paranoia 295 } or do { 296 $err = $@ ne '' ? $@ : "errno=$!"; chomp $err; 297 }; 298 eval { 299 local(@SIG{qw(INT HUP TERM TSTP QUIT USR1 USR2)}) = ($h2) x 7; 300 if ($interrupt ne '') { my $i = $interrupt; $interrupt = ''; die $i } 301 # we're in trouble if stderr was attached to a terminal, but no longer is 302 eval { do_log(-1,"spawn_command: child process [%s]: %s", $$,$err) }; 303 } or 1; # ignore failures, make perlcritic happy 304 { # no warnings; 305 POSIX::_exit(6); # avoid END and destructor processing 306 kill('KILL',$$); exit 1; # still kicking? die! 307 } 308 } 309 # parent 310 do_log(5,"spawn_command: [%s] %s", $pid, $cmd_text); 311 $pid; # return the PID of a subprocess 312} 313 314sub usage() { 315 my $me = $0; local $1; $me =~ s{([^/]*)\z}{$1}s; 316 "Usage: $me (-h | -V | [-f] [-P pid_file] [-d log_level])"; 317} 318 319# map process termination status number to an informative string, and 320# append optional message (dual-valued errno or a string or a number), 321# returning the resulting string 322# 323sub exit_status_str($;$) { 324 my($stat,$errno) = @_; my $str; 325 if (!defined($stat)) { 326 $str = '(no status)'; 327 } elsif (WIFEXITED($stat)) { 328 $str = sprintf('exit %d', WEXITSTATUS($stat)); 329 } elsif (WIFSTOPPED($stat)) { 330 $str = sprintf('stopped, signal %d', WSTOPSIG($stat)); 331 } else { 332 my $sig = WTERMSIG($stat); 333 $str = sprintf('%s, signal %d (%04x)', 334 $sig == 1 ? 'HANGUP' : $sig == 2 ? 'INTERRUPTED' : 335 $sig == 6 ? 'ABORTED' : $sig == 9 ? 'KILLED' : 336 $sig == 15 ? 'TERMINATED' : 'DIED', 337 $sig, $stat); 338 } 339 if (defined $errno) { # deal with dual-valued and plain variables 340 $str .= ', '.$errno if (0+$errno) != 0 || ($errno ne '' && $errno ne '0'); 341 } 342 $str; 343} 344 345# check errno to be 0 and a process exit status to be in the list of success 346# status codes, returning true if both are ok, and false otherwise 347# 348sub proc_status_ok($;$@) { 349 my($exit_status,$errno,@success) = @_; 350 my $ok = 0; 351 if ((!defined $errno || $errno == 0) && WIFEXITED($exit_status)) { 352 my $j = WEXITSTATUS($exit_status); 353 if (!@success) { $ok = $j==0 } # empty list implies only status 0 is good 354 elsif (grep($_==$j, @success)) { $ok = 1 } 355 } 356 $ok; 357} 358 359sub report_terminations($) { 360 my($pids_exited_list) = @_; 361 # note: child_handler may be growing the list at its tail during the loop 362 while (@$pids_exited_list) { 363 my $pid_stat = shift(@$pids_exited_list); 364 next if !$pid_stat; # just in case 365 my($pid,$status,$timestamp) = @$pid_stat; 366 my $serv = delete $pid2service{$pid}; 367 if (!$serv) { 368 do_log(-1,'Unknown process [%d] exited: %s', 369 $pid, exit_status_str($status,0)); 370 } else { 371 $serv->{status} = $status; 372 $serv->{terminated_at} = $timestamp; 373 my $ll = proc_status_ok($status,0) ? 0 : -1; 374 do_log($ll, 'Process [%d] exited (%s) after %.1f s: %s', 375 $pid, $serv->{cmd}, 376 $serv->{terminated_at} - $serv->{started_at}, 377 exit_status_str($status,0)); 378 } 379 } 380} 381 382sub child_handler { 383 my $signal = $_[0]; 384 for (;;) { 385 my $child_pid = waitpid(-1,WNOHANG); 386 # PID may be negative on Windows 387 last if !$child_pid || $child_pid == -1; 388 push(@pids_exited, [$child_pid, $?, time]); 389 } 390 $SIG{CHLD} = \&child_handler; 391}; 392 393 394# main program starts here 395 396delete @ENV{'PATH', 'IFS', 'CDPATH', 'ENV', 'BASH_ENV'}; 397$ENV{PATH} = join(':',@path) if @path; 398 399my $foreground = 0; 400my(@argv) = @ARGV; # preserve @ARGV, may modify @argv 401while (@argv >= 2 && $argv[0] =~ /^-[dP]\z/ || 402 @argv >= 1 && $argv[0] =~ /^-/) { 403 my($opt,$val); 404 $opt = shift @argv; 405 $val = shift @argv if $opt !~ /^-[hVf-]\z/; # these take no arguments 406 if ($opt eq '--') { 407 last; 408 } elsif ($opt eq '-h') { # -h (help) 409 die "$myversion\n\n" . usage() . "\n"; 410 } elsif ($opt eq '-V') { # -V (version) 411 die "$myversion\n"; 412 } elsif ($opt eq '-f') { 413 $foreground = 1; 414 } elsif ($opt eq '-d') { # -d log_level 415 $val =~ /^\d+\z/ or die "Bad value for option -d: $val\n"; 416 $log_level = untaint($val); 417 } elsif ($opt eq '-P') { # -P pid_file 418 $pid_file = untaint($val); 419 } else { 420 die "Error in command line options: $opt\n\n" . usage() . "\n"; 421 } 422} 423!@argv or die sprintf("Error parsing a command line %s\n\n%s\n", 424 join(' ',@ARGV), usage()); 425 426$SIG{'__DIE__' } = 427 sub { if (!$^S) { my($m) = @_; chomp($m); do_log(-1,"_DIE: %s", $m) } }; 428$SIG{'__WARN__'} = 429 sub { my($m) = @_; chomp($m); do_log(0,"_WARN: %s",$m) }; 430 431if ($foreground) { 432 do_log(0,"amavis master process starting in foreground, perl %s", $] ); 433} else { # daemonize 434 openlog($syslog_ident, LOG_PID | LOG_NDELAY, $syslog_facility); 435 $syslog_open = 1; 436 do_log(2,"to be daemonized"); 437 daemonize(); 438 srand(); 439 do_log(0,'amavis master process starting. '. 440 'daemonized as PID [%s], perl %s', $$, $] ); 441} 442 443if (defined $daemon_user) { 444 drop_priv($daemon_user,$daemon_group); 445} 446 447if (defined $pid_file && $pid_file ne '') { 448 my $pid_file_fh = IO::File->new; 449 $pid_file_fh->open($pid_file, O_CREAT|O_WRONLY, 0640) 450 or die "Can't create PID file $pid_file: $!"; 451 $pid_file_fh->print($$."\n") 452 or die "Can't write to PID file $pid_file: $!"; 453 $pid_file_fh->close 454 or die "Can't close PID file $pid_file: $!"; 455 $pid_file_created = 1; 456} 457 458# initialize 459for my $serv (@services) { 460 $serv->{started_cnt} = 0; 461 $serv->{pid} = $serv->{status} = undef; 462 $serv->{started_at} = $serv->{terminated_at} = undef; 463 my $found = find_program_path($serv->{cmd}, \@path); 464 defined $found 465 or die sprintf("Can't find program %s in path %s\n", 466 $serv->{cmd}, join(':',@path)); 467 $serv->{cmd} = $found; 468} 469 470$SIG{CHLD} = \&child_handler; 471 472eval { # catch TERM and INT signals for a controlled shutdown 473 my $h = sub { $interrupted = $_[0]; die "\n" }; 474 local $SIG{INT} = $h; 475 local $SIG{TERM} = $h; 476 for (;;) { 477 last if defined $interrupted; 478 for my $serv (@services) { 479 next if $serv->{disabled}; 480 report_terminations(\@pids_exited) if @pids_exited; 481 if (defined $serv->{status}) { 482 # process has terminated, clean up 483 $serv->{pid} = undef; 484 $serv->{started_at} = undef; 485 } 486 last if defined $interrupted; 487 if (!defined $serv->{pid}) { 488 # service not running 489 if ($serv->{started_cnt} >= 5) { 490 do_log(-1,'Exceeded restart count, giving up on (%s)', $serv->{cmd}); 491 $serv->{disabled} = 1; 492 } elsif (defined $serv->{terminated_at} && 493 time - $serv->{terminated_at} < 1) { 494 # postpone a restart for at least a second 495 do_log(5, 'Postponing a restart (%s)', $serv->{cmd}); 496 } else { 497 my($cmd,@args) = split(' ',$serv->{cmd}); 498 $serv->{started_cnt}++; 499 $serv->{status} = $serv->{terminated_at} = undef; 500 $serv->{started_at} = time; 501 my $pid = $serv->{pid} = spawn_command($cmd,@args); 502 do_log(0, 'Process [%d] started: %s', $pid, $serv->{cmd}); 503 # to avoid race the signal handler must not be updating %pid2service 504 $pid2service{$pid} = $serv; 505 } 506 } 507 } 508 sleep 5; # sleep may be aborted prematurely by a signal 509 } # until interrupted 510}; 511 512do_log(0, 'Master process shutting down'); 513 514for my $sig ('TERM', 'KILL') { 515 # terminate or kill child processes 516 for my $serv (@services) { 517 my $pid = $serv->{pid}; 518 next if !$pid; 519 my $n = kill($sig,$pid); 520 if ($n == 0 && $! == ESRCH) { 521 # process already gone 522 } elsif ($n == 0) { 523 do_log(-1, "Can't send SIG%s to process [%s]: %s", $sig, $pid, $!); 524 } else { 525 do_log(0, "%s process [%s] (%s)", 526 $sig eq 'TERM' ? 'Terminating' : 'Killing', 527 $pid, $serv->{cmd}); 528 } 529 } 530 my $deadline = time + 10; # 10 second grace period 531 while (time < $deadline) { 532 report_terminations(\@pids_exited); 533 sleep 1; # sleep may be aborted prematurely by a signal 534 # stop waiting if all gone 535 last if !grep { $_->{pid} && kill(0, $_->{pid}) } @services; 536 } 537 report_terminations(\@pids_exited); 538} 539 540END { 541 do_log(0,'Master process exiting: %s', $interrupted) if defined $interrupted; 542 if ($pid_file_created) { 543 unlink($pid_file) 544 or do_log(-1, "Can't delete a PID file %s: %s", $pid_file, $!); 545 } 546 if ($syslog_open) { closelog(); $syslog_open = 0 } 547} 548