#!/usr/bin/perl
#! $Id: sudoscriptd-in,v 1.9 2004/11/12 05:27:54 hbo Exp $
use strict;
use warnings;
use POSIX qw(mkfifo setsid);
use POSIX qw(:sys_wait_h);
use Fcntl qw(O_RDWR O_RDONLY O_WRONLY);
use File::Path;
use Sys::Syslog qw(:DEFAULT setlogsock);
use File::Basename qw(dirname basename);
use Getopt::Long;
use lib "/usr/lib/sudoscript";
use Sudoscript;

our $ss=Sudoscript->new();
exit if (! defined $ss);

# We take one option: --datefmt|-d which controls how dates are written in the log.
my ($DATEFMT,$killme);
GetOptions (
	    "datefmt:s" => \$DATEFMT,
	    "kill" => \$killme,
	   );

$DATEFMT='short' if (! defined $DATEFMT);

my $dpid=$ss->checkpid();
if ($killme){
  if ($dpid){
    `kill -HUP $dpid`;
    print "Sudoscriptd at $dpid killed\n";
  } else {
    print "Can't find a sudoscriptd to kill!\n";
  }
  exit;
}

if ($dpid){
  print STDERR "sudoscriptd already running at $dpid\n";
  print STDERR "Can't start new sudoscriptd\n";
  exit 1;
}

# Become a daemon
my $pid=fork;
exit if $pid;
die "Couldn't fork $!" unless defined($pid);

our $rundir="/var/run";
our $fifodir="$rundir/sudoscript";
our $logdir="/var/log";

foreach my $tag ('','log','merge','comp'){
  unlink "$fifodir/stderr$tag" if (-e "$fifodir/stderr$tag");
}
$ss->daemon_io();

die "Couldn't start new session $!" unless POSIX::setsid();
  # Change our ps id, if supported by OS
$0="sudoscriptd: main";

# 2 MiByte limit on log size before compression/rotation.
my $MAXLOGSIZE=1024*1024*2;


# Open syslog
my $facility='AUTHPRIV';
setlogsock 'unix';
openlog("sudoscriptd",'pid',$facility) || die "Can't open syslog $!";
syslog('info',"Master sudoscriptd starting");

# Declare these for the interrupt handler below.
# Hash of outstanding logger PIDs by session tag
my %LOGGERS;
# PID of the log merger
my $mergerpid;
# interrupt handler to reap outstanding children

sub main_exit_handler{
  # We could be called directly, or through a signal
  # If the former, we'll have parameters.
  my $exitseverity=shift;
  my $exitmsg=shift;
  $exitseverity="info" if (!$exitseverity || $exitseverity eq 'HUP');
  $exitmsg="Master sudoscriptd caught signal. Exiting" if (!$exitmsg);
  syslog($exitseverity,$exitmsg);
  # This is main daemon shutdown. Kill all other daemons we know about.
  foreach (keys %LOGGERS){
    kill 1,$LOGGERS{$_};
    waitpid $LOGGERS{$_},&WNOHANG;
  }
  if ($mergerpid){
    kill 1, $mergerpid;
    waitpid $mergerpid,&WNOHANG;
  }
  closelog();
  exit;
};
$SIG{'HUP'} = \&main_exit_handler;

# Check to see if we have a ssers group
our ($grname,$grpasswd,$gid) = getgrnam 'ssers';
$grpasswd=""; # make strict happy
# Set up state directories
create_state_dirs();

# Record our PID for those who would wish us ill 8)
if (! (open PID,">$rundir/sudoscriptd.pid")){
  syslog('crit',"Can't open $rundir/sudoscriptd.pid %m");
  closelog();
  die "Couldn't record pid, $!";
}

print PID "$$\n";
close PID;


# The front-end FIFO
my $fifo="$fifodir/rendezvous";
# Create the FIFO that sudoshell will rendezvous on
# FIFO must me writable by group if group exists
if ($grname){
  mktypescript($fifo,0,0620,$gid);
 } else {
  mktypescript($fifo,0,0600);
}

# Create the back-end merge daemon
$mergerpid=log_merger($DATEFMT);

my $handshake; # sudoshell's id string
while (1){
  # Open the rendezvous FIFO or exit
  main_exit_handler('crit',"Couldn't open FIFO ($fifo), %m") if (!sysopen(FIFO, $fifo, O_RDONLY));

  # Main input loop. ss is on the writing end.
  while (<FIFO>){

   if (/^HELO (.*)/){ # HELO handshake. Fork a new logger
      $handshake=$1;
       syslog('info',"Forking new logger for sudoshell $handshake");
      $pid=new_logger(split /\s+/,$handshake);
      # Recored the new logger's PID
      $LOGGERS{$handshake}=$pid;
    }
    if (/^GDBY (.*)/){ # GDBY handshake. Signal and reap the old logger
      $handshake=$1;
      $pid=$LOGGERS{$handshake};
      if ($pid){
	my $count= kill 1,$pid;
	waitpid $pid,0;
	delete $LOGGERS{$handshake};
	syslog('info',"session $handshake closed");
      } else {
	syslog('err',"No PID recorded for handshake $handshake. Can't clean up session!");
      }
    }
  }
  # End of master daemon
}

sub create_state_dirs{
  # Set up logging and fifo directories
  # Create directories as needed
  if (!-d $rundir){
    if (!mkdir $rundir,0755){
      syslog('crit',"Can't mkdir $rundir %m");
      closelog();
      die "Can't mkdir $rundir $!";
    }
  }

  # FIFO dir has to be group writeable if ss is to become a user other than root
  my  $fifodirmode;
  if ($grname){
    $fifodirmode=0770;
  } else {
    $fifodirmode=0700;
  }

  `rm -fr $fifodir`;
  if (!mkdir $fifodir,$fifodirmode){
    syslog('crit',"Can't mkdir $fifodir %m");
    closelog();
    die "Can't mkdir $fifodir $!";
  }

  if ($grname){
    chown 0,$gid,$fifodir;
  }

  if (!-d $logdir){
    if(!mkdir $logdir,0700){
      syslog('crit',"Can't mkdir $logdir %m");
      closelog();
      die "Can't mkdir $logdir $!";
    }
  }
  # Let caller know where the fifo dir is
  return $fifodir;
}
#
#
#  merger daemon - 
# Collect all session's data (pre tagged by the loggers) into
# a single log file. Rotate the log if it exceeds $MAXLOGSIZE in size.
sub log_merger{
  my $ss=Sudoscript->new();
  my $pid=fork;
  return $pid if ($pid); # caller needs pid to signal us
  if (!defined $pid){
    syslog ('crit','Log merger encountered fork error %m');
    closelog();
    die "Couldn't fork $!";
  }
  $ss->daemon_io('merge');
  my $session=POSIX::setsid();
  if (!$session){
    syslog ('crit','Log merger Couldn\'t setsid() %m');
    closelog();
    die "Couldn't start new session $!";
  }
  # Change our ps id, if supported by OS
  $0="sudoscriptd: merger";

  # close parent's syslog
  closelog();

  # close parent's rendezvous FIFO
  close FIFO;

  # Open our own syslog
  my $facility='AUTHPRIV';
  openlog("sudoscriptd-merger",'pid',$facility) || die "Can't open syslog $!";

  # Predeclares for the signal handler
  # Our FIFO
  my $fifo=$fifodir."/merge";
  # List of compressor PIDs
  my @comppids;

  # Merger signal handler
  # Close and unlink FIFO
  # Reap any outstanding compressors
  $SIG{'HUP'} =
    sub {
      syslog('info',"Merger caught signal. Exiting");
      print LOG "Merger caught signal. Exiting\n";
      close LOG;
      close MYFIFO;
      unlink $fifo;
      closelog();
      foreach(@comppids){
	kill 1,$_;
	waitpid $_,&WNOHANG;
      }
      exit;
    };

  # Announce ourselves to syslog
  syslog('info',"New merger");

  # Open the log file, obtaining the current log size and possibly a compressor PID
  my ($size,$comppid)=openmylog("$logdir/sudoscript");
  # Save  PID, if any
  push @comppids,$comppid if($comppid);

  # Announce ourselves in the log file
  print LOG $ss->datestamp($DATEFMT)." New Merger\n";

  # Create out merge FIFO
  mktypescript($fifo);
  # Announce success
  print LOG "opened FIFO $fifo\n";

  # Merger input loop
  while(1){
    # open the FIFO
    sysopen(MYFIFO, $fifo,O_RDONLY) or die "Couldn't open FIFO ($fifo), $!";

    while (<MYFIFO>){

      # Datestamp the input
      $_=$ss->datestamp($DATEFMT)." $_";
      # Keep track of the size
      $size += length($_) +1;
      # Log the input
      print LOG;
      # Rotate the log if $MAXLOGSIZE is exceeded
      if ($size > $MAXLOGSIZE){
	# as above
	($size,$comppid)=openmylog("$logdir/sudoscript");
	push @comppids,$comppid if($comppid);
      }

      # if we have outstanding compressor kids, wait for them here
      if ($#comppids >=0){
	if (waitpid $comppids[0],&WNOHANG >0){
	  shift @comppids;
	}
      }
    }
  }
  close MYFIFO;
  # End of merger daemon
}
#
#
# Logger daemon
# Creates a session FIFO for sudoshell
# Accepts script(1) output from sudoshell
# Tags it with session ID, and passes it on to the merger daemon
# 
sub new_logger{
  # real user, sudoshell pid, effective user (or root if undef)
  my ($user,$sspid,$runas)=@_;
  my $ss=Sudoscript->new();
  my $pid=fork;
  return $pid if ($pid);
  if (!defined $pid){
    syslog ('crit','Child logger encountered fork error %m');
    closelog();
    die "Couldn't fork $!";
  }
  $ss->daemon_io('log');
  my $session=POSIX::setsid();
  if (!$session){
    syslog ('crit','Child logger Couldn\'t setsid() %m');
    closelog();
    die "Couldn't start new session $!";
  }
  # Close parent's syslog.
  closelog();
  # Close parent's rendezvous FIFO
  close FIFO;

  # Change our ps id, if supported by OS
  $0="sudoscriptd: logger";

  # Open our own syslog
  my $facility='AUTHPRIV';
  openlog("sudoscriptd-logger",'pid',$facility) || die "Can't open syslog $!";

  # Set up the session FIFO directory
  # We create a subdirectory in case we are granting access to the run
  # directory to the ssers group. If we are, that group must have
  # read/write access. The directory we create here has no group or other access,
  # thereby limiting access to the user only. (This is still a security hole if we are enabling
  # a role account shared by two or more real users.)
  my $sessdir;
  $sessdir=$fifodir."/ssd.${user}_";
  if ($runas){
    $sessdir .= "${runas}_$sspid";
  } else {
    $sessdir .= "root_$sspid";
  }

  if (!-d $sessdir){
    `rm -fr $sessdir` if (-e $sessdir);
    mkdir $sessdir,0700;
  }
  # If we have an "effective" username, get the UID and chown the session dir to that user.
  my ($tuname,$tpasswd,$tuid); # "target" user
  if (defined $runas){
    ($tuname,$tpasswd,$tuid)=getpwnam $runas;
  } else {
    $tuid=0;
  }
  chown $tuid,0,$sessdir;

  # Predeclare the merge FIFO name for the signal handler.
  my $mergefifo="$fifodir/merge";

  $SIG{'HUP'} =
    # Remove the session directory and FIFO and exit
    sub {
      syslog('info',"logger ($user,$sspid) caught signal. Exiting");
      print MERGEFIFO "logger ($user,$sspid) caught signal. Exiting\n";
      close MERGEFIFO;
      close SESSFIFO;
      `/bin/rm -fr $sessdir`;
      closelog();
      exit;
    };

  # Announce ourselves to syslog
  syslog('info',"new_logger for user $user with pid $sspid");

  # Open the merge FIFO for output
  if (!sysopen(MERGEFIFO, $mergefifo,O_WRONLY)){
    syslog('crit',"Couldn't open FIFO ($mergefifo) %m");
    closelog();
    die "Couldn't open FIFO ($mergefifo), $!";
  }
  # Unbuffer our OUTPUT
  my $foo=select MERGEFIFO;
  $|=1;
  select $foo;

  # Announce ourselves on the merge FIFO
  print MERGEFIFO "New logger for $user with pid $sspid\n";

  # Create the session FIFO
  my $sessfifo="$sessdir/$user$sspid.fifo";
  mktypescript($sessfifo,$tuid,0200);

  # Give ss time to Posix::pause
  sleep 1;
  # Signal sudoshell that we are ready
  kill "WINCH",$sspid;

  # Logger input/output loop
  while(1){
    # Open the session FIFO for read
    if (!sysopen(SESSFIFO, $sessfifo,O_RDONLY)){
      syslog('crit',"Couldn't open FIFO ($sessfifo) %m");
      close MERGEFIFO;
      closelog();
      die "Couldn't open FIFO ($sessfifo), $!";
    }
    while (<SESSFIFO>){
      # Tag the input with the session ID
      $_="$user:$sspid $_";
      # And send it to the merge FIFO
      print MERGEFIFO;
    }
  }
  # End of logger daemon
}
#
#
# Create a FIFO with optional owner group and mode overriding defaults
sub mktypescript {
  my ($fifo,$owner,$mode,$group)=@_;

  # Delete the FIFO if it exists.
  if (-e $fifo){
    unlink $fifo;
  }
  # Default mode is r/w to owner
  $mode=0600 if (! defined $mode);

  if (!mkfifo($fifo,$mode)){
    syslog('crit',"mktypescript() couldn't make new fifo $fifo %m");
    closelog();
    die "Can't make fifo $fifo $!";
  }

  # Default owner is root
  $owner=0 if (!defined $owner);

  # Default group is wheel/root
  $group=0 if (!defined $group);
  chown $owner,$group,$fifo;
  chmod $mode,$fifo;
}


#
# (re)open the log file
# Rotate the logs if larger than $MAXLOGSIZE
sub openmylog{
  # Passed the log file name
  my $log=shift;

  # compressor PID
  my $pid;

  # File stat values
  my($dev,$ino,$mode,$nlink,$uid,$gid,$rdev,$size);

  # Get the logfile size if it exists
  if (-e $log){
    ($dev,$ino,$mode,$nlink,$uid,$gid,$rdev,$size)=stat $log;

    # If te size exceeds $MAXLOGSIZE, fork a rotator/compressor
    if ($size>$MAXLOGSIZE){
      $pid=rotate_log($log);

      # rotator/compressor moves old log out of our way
      $size=0;
    }
  }

  # Open the log file
  # Default is create new ..
  my $op = ">";
  #.. but append if it already existts
  $op.=">" if (-e $log);
  chmod 0600,$log;
  if (!open LOG, "$op$log"){
    syslog('crit',"openmylog() couldn't open new log $op$log %m");
    die "Couldn't open log ($op$log), $!";
  }
  my $foo=select LOG;
  $|=1; # Unbuffered
  select $foo;
  # Return the current log size and any compressor PID that we may have spawned.
  return ($size,$pid);
}
#
#
# Rotate and compress the log files.
# Called by openmylog() when log file size exceeds $MAXLOGSIZE bytes
sub rotate_log {
  # We are passed the log file name
  my $log =shift;
  # We compute the dirname and basename
  my $logdir=dirname($log);
  my $logbase=basename($log);

  # Temp log file name
  my $tlog="$log.$$"; 

  # Move the old log out of the way
  link $log, $tlog;
  unlink $log;

  my $ss=Sudoscript->new();
  # Fork a child to do the rotation/compression
  my ($pid)=fork;
  # Parent gets the child PID so we can interrupt if necessary
  # We've moved the old log out of the way so the parent can create a new one.
  return $pid if $pid;


  # CHILD

  $ss->daemon_io('comp');

  die "Couldn't start new session $!" unless POSIX::setsid();

  # Fix up our ps name if OS supports it
  $0="sudoscriptd: compressor";

  my $MAXLOGS=10;
  # Rename the old log files incrementing numeric extensions
  # The $MAXLOGth one goes in the bit bucket
  my ($currlog,$lastlog);
  for (my $i=$MAXLOGS-1;$i;$i--){
    $currlog="$logdir/$logbase.$i.gz";
    $lastlog="$logdir/$logbase.".($i+1).".gz";
    if (-e $currlog){
      unlink $lastlog;
      link $currlog,$lastlog;
    }
  }
  # dot one is now linked with dot two
 # clean it up
  unlink "$logdir/$logbase.1.gz";

  # compress the temp log onto the dot one
  my $cmd="gzip -c $tlog >$logdir/$logbase.1.gz";
  `$cmd`;
  chmod 0600,"$logdir/$logbase.1.gz";
  unlink $tlog;
  exit 0;
}

=pod

=head1 NAME

  sudoscriptd - logging daemons for sudoshell(1)

=head1 SYNOPSIS

  sudoscriptd [-d|--datefmt long|short|sortable]

=head1 VERSION

This manpage documents version 2.1.2 of sudoscriptd

=head1 DESCRIPTION

I<sudoscriptd> is a daemon for logging output from L<sudoshell(8)>.
Used with that script, it provides an audit trail for shells run under
sudo.

=head1 README

When I<sudoscriptd> starts, it creates a named pipe (FIFO) in a spool 
area. Then it forks a log management daemon that opens another FIFO
and hangs around waiting for someone to write to it. When a new sudoshell
starts, it writes the name of the user who ran it (from SUDO_UID) and its
own PID to the first FIFO, then pauses waiting for a signal.
Sudoscriptd forks a logger with the information given by sudoshell,
which opens yet another FIFO, whose name is derived from the username and
PID. The logger then sends the signal that sudoshell is waiting for. 
Sudoshell then runs script(1) on the session FIFO. The logger takes the
output thus produced, tags it with a session ID, and writes it to the
log management daemon's (remember him?) FIFO. The log daemon tags the data
with a datestamp and writes it to a log file. It also manages the logs so
they don't overflow the logging partition. When the user ends her script(1)
session, sudoshell tells the front end daemon that it is done. The daemon
signals the session logger to wrap up its work, which it does by deleting
the session FIFO and exiting.

=head1 CONFIGURATION

I<sudoshell> uses L<sudo(8)> to perform all its authentication and
privilege escalation.  The I<sudoshell> user must therefore be in the
I<sudoers> file (See L<sudoers(5)>.)  with an entry that allows
running I<sudoshell> as the desired user. See the SUDOCONFIG file in
the distribution for details. (On Linux, this will be in
/usr/share/doc/sudoscript-VERSION. Everywhere else, it's in
/usr/local/doc/sudoscript-VERSION.)

=head1 IS THIS SECURE?

In a word, no. Giving a user a root shell is a bad idea if you don't trust him
or her. There are countless ways to evade the audit trail provided by sudoscript,
even without root privilege. Let me highlight the last part of that sentence: I<even
without root privilege!> (Think about the implications of the fact that a user must have
write access to the logging FIFO to see what I mean.) That means you can't rely on
this tool to maintain security for you. So, what good is sudoscript? It's useful in an
at least two environments. First, you trust your users, but need a record of what they
do for auditing purposes. Second, you may or may not trust your users, but they have
successfully agitated for a root (or other) shell. Sudoscript then provides an audit trail as
long as your users don't try to evade it.

See the file SECURITY (in the same place as SUDOCONFIG, above) for more on sudoscript's
security assumptions.

=head1 SWITCHES

One optional switch, C<--datefmt>, is accepted by C<sudoscriptd>. This
controls the format of the datestamps in the log file. Three options 
are available.

=over 4

=item long

This selects a long date format of 'wdy mon dd hh:mm:dd ZZZ YYYY' where 
'wdy' is the weekday name, 'mon' is the three letter month name, 'dd' 
is the day of the month, hh:mm:ss' is the local time, 'ZZZ' is the local time 
zone name and 'YYYY' is the four digit year.

=item short

This selects a shorter date format of 'wdy mon dd hh:mm:dd'. This is 
just the long with the time zone and year removed. C<short> is the default
format if no C<--datefmt> is given.

=item sortable

This selects a compressed and numerically sortable format of 'yyyymmddhhmmss'.

=back

=head1 FILES

The front end fifo is /var/run/sudocript/rendezvous. The backend FIFO
is /var/run/sudocript/merge. These two are semi-permanent. The session
FIFOs are named /var/run/sudocript/ssd{username}{pid}. They go away once
the session closes.

The log file is named /var/log/sudoscript. When the backend daemon
rotates the log, it forks a compressor that creates files called
/var/log/sudoscript.{n}.gz, where {n} is one through ten.
Sudoscriptd stores its PID in /var/run/sudoscriptd.pid.

=head1 BUGS

The script(1) output is pretty ugly. All control characters are preserved
exactly as typed, or worse, as displayed by curses based console apps like
vi. The content of such logs can look completely unintelligible unless
they are cleaned up first. A shell script from the "Unix Power Tools" book
that uses sed(1) to do a first pass over such logs is available at
L<ftp://ftp.oreilly.com/pub/examples/power_tools/unix/split/script.tidy>.
I considered building something like that into sudoscriptd, but rejected it
for two reasons. First, the daemon needs to get back to reading the FIFO
as quickly as possible to avoid losing data to an over-full buffer. Second,
any cleanup of the logs would I<remove information>. This could be bad if
I were over-zealous in my clean up. As it stands, you can run your own
clean up on the log data without destroying the original log.

The datestamp() routine is not locale aware and returns American
English values.

=head1 SEE ALSO

sudoscript(8)

sudoshell(1)

Sudoscript(3pm)

sudo(8)

sudoers(5)


=head1 PREREQUISITES

sudo - L<http://www.courtesan.com/sudo/index.html>

=head1 OSNAMES

C<Solaris>

C<Linux>

C<FreeBSD>

C<OpenBSD>

C<HP-UX>

=head1 SCRIPT CATEGORIES

UNIX/System_administration

=head1 CONTRIBUTORS

The following people offered helpful advice and/or code:

   Dan Rich       (drich@emplNOoyeeSPAMs.org)
   Alex Griffiths (dag@unifiedNOcomputingSPAM.com)
   Bruce Gray     (bruce.gray@aNOcSPAMm.org)
   Chan Wilson    (cwilson@coNrOp.sSgPi.cAoMm>
   Tommy Smith    (tsNmOith@eSaPtAeMl.net)
   Donny Jekels   (donny@jNOeSkPeAlMs.com

=head1 AUTHOR

Howard Owen, E<lt>hbo@egbok.comE<gt>

=head1 COPYRIGHT AND LICENSE

Copyright 2002,2003 by Howard Owen

sudoscript is free software; you can redistribute it and/or modify
it under the same terms as Perl itself. 

=cut