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