#!/usr/bin/perl

=pod

=head1 NAME

ng-journalctl - A non-privileged wrapper to access journal entries for restricted uids

=head1 SYNOPSIS

B<ng-journalctl> [Flags...] [Filters...]

=head1 DESCRIPTION

B<ng-journalctl> is a wrapper around journalctl to provide unprivileged users with access to
a subset of entries in the system journal, specifically those marked as originating from the
uid of the caller or those uids which are considered to be subordinates of the caller.

The journal uids which a given caller can access consists of the caller's own uid, as well as any
uids whose username matches the form ${CALLER}_<SUBORDINATE> and is also a member of the caller's
primary group.

If no uids are specified amongst the filters then the set of all uids which the caller is permitted
to access the journal records for is assumed.

=head1 OPTIONS

Most, but not all, of the standard journalctl flags are accepted by B<ng-journalctl>, a full list
of the supported options can be listed using the --help flag.

=head1 EXAMPLES

=over 4

=item B<ng-journalctl> php oca_demo1

Show all journal entries from php sources running as the user oca_demo1

=item B<ng-journalctl> --since=today _SYSTEMD_UNIT=crond.service

Show all journal  entries from the cron service for any uid the caller has access to since the beginning of today

=back

=head1 SEE ALSO

ng-journalctl --help, B<journalctl>(1), B<systemd.journal-fields>(7)

=cut

use strict;
use English;
use Getopt::Long;


#
# Who are we acting for?
#
my $USER = getpwuid($UID);
if ($UID == 0 && exists $ENV{'SUDO_USER'} && $#ARGV >= 0 && $ARGV[0] eq '--sudo') {
	# Runnng (directly) under sudo....
	shift @ARGV;
	$USER = $ENV{'SUDO_USER'};
}
#
# Find the members of the calling user's primary group
#
my $PGID = (getpwnam($USER))[3];
my %MEMBERS = map { $_ => 1 } split(/ /, (getgrgid($PGID))[3]);
my @ORIG_ARGV=@ARGV;


my $FLAGS = {};
Getopt::Long::Configure(
	'no_auto_abbrev',
	'no_getopt_compat',
	'bundling'
);
my %GETOPT = (
	'since=s'        => \$FLAGS->{'since'},
	'until=s'        => \$FLAGS->{'until'},
	'c|cursor=s'     => \$FLAGS->{'cursor'},
	'after-cursor=s' => \$FLAGS->{'after-cursor'},
	'show-cursor'    => \$FLAGS->{'show-cursor'},
	'b|boot:s'       => \$FLAGS->{'boot'},
	'k|dmesg'        => \$FLAGS->{'dmesg'},
	'p|priority'     => \$FLAGS->{'priority'},
	'e|pager-end'    => \$FLAGS->{'pager-end'},
	'f|follow'       => \$FLAGS->{'follow'},
	'n|lines:i'      => \$FLAGS->{'lines'},
	'no-tail'        => \$FLAGS->{'no-tail'},
	'r|reverse'      => \$FLAGS->{'reverse'},
	'o|output=s'     => \$FLAGS->{'output'},
	'x|catalog'      => \$FLAGS->{'catalog'},
	'l|full'         => \$FLAGS->{'full'},
	'a|all'          => \$FLAGS->{'all'},
	#'u|unit'         => \$FLAGS->{'unit'},
	#'t|identifier=s' => \$FLAGS->{'identifier'},
	'no-pager'       => \$FLAGS->{'no-pager'},
	'h|help'         => \&usage
);
# Well known-filters
my $WKF = {
	'php' => [['_EXE=/bin/php'], ['_EXE=/sbin/php-fpm'], ['SYSLOG_IDENTIFIER=php'], ['_COMM=php'], ['_COMM=php-fpm']]
};


# Parse command line options
GetOptions(%GETOPT) or usage(1);


sub usage(;$) {
	my $rc = shift || 0;
	my $FH = ($rc > 0) ? \*STDERR : \*STDOUT;
	my %longopts = map { do { (my $a = $_) =~ s/(^[a-z]\||[:=][si]$)//g; $a; } => 1 } keys %GETOPT;
	my @usage = split(/\n/, `journalctl --help`);
	print $FH "\n";
	foreach my $l (@usage) {
		if ($l =~ m/^journalctl/) {
			$l =~ s/^journalctl/$0/;
			print $FH "$l\n";
		} elsif ($l =~ m/Query the journal/) {
			print $FH "A wrapper around journalctl which permits non-privileged users to query a limited\n";
			print $FH "sub-set of the system journal, and also provides simple predefined filter rule\n";
			print $FH "aliases for common queries\n";
		} elsif ($l =~ m/--([-a-z]+)/) {
			if (exists $longopts{$1}) {
				print $FH "$l\n";
			}
		} else {
			print $FH "$l\n";
		}
	}
	print $FH "\n";
	print $FH "Matches:\n";
	print $FH "   FIELD=value             Show log entries where FIELD is equal to value (eg _SYSTEMD_UNIT=php-fpm-pool\@oca_im1)\n";
	print $FH "                           see journalctl(1) and systemd.journal-fields(7) or further details, note that some fields\n";
	print $FH "                           are restricted (eg _UID=)\n";
	print $FH "   +                       Matches are processed as a logical AND, a + can be used to create a logical OR between\n";
	print $FH "                           multiple sets of matches\n";
	print $FH "   <username>              Show log entries generated by sources running as the specified username, note that only\n";
	print $FH "                           the current user OR users matching the pattern \${USER}_* who are also a member of the\n";
	print $FH "                           current user's primary group are permitted.  If no <username> matches are specifed an\n";
	print $FH "                           implied match of \${USER} is applied\n";
	while (my ($k, $v) = each(@$WKF)) {
		print $FH sprintf("   %-24sis equivalent to\n%-29s%s\n", $k, '', join(' + ', map { join(' ', @{$_}) } @{$v}));
	}
	print $FH "\n";
	exit($rc);
}

# Filter our flags
while (my ($k, $v) = each %$FLAGS) {
	# Delete any empty arguments
	delete $FLAGS->{$k} if !defined($v);
	# Barf on suspicious argument values
	if ($v =~ m/^\s*-/) {
		print STDERR "\nIllegal value '$v' for argument '--$k'\n";
		usage(2);
	}
}

sub is_int($) {
    return 0 if $_[0] eq '';
    $_[0] ^ $_[0] ? 0 : 1
}

# Now re-assemble $FLAGS into an argument vector
my @FLAGV = ();
while (my ($k, $v) = each %$FLAGS) {
	if (($k ne 'boot' && $v eq '') || (is_int($v) && $v == 1 && $k ne 'lines')) {
			push(@FLAGV, "--$k");
	} else {
		push(@FLAGV, "--$k=$v");
	}
}


# Ok now eveything that is left should be either a filter or a user
# Filters will always take the form _FOO=bar OR be a well-known name
# Users will always exist in passwd (getpwnam())
my @USERS = ();
my @FILTERS = ([]);
while (@ARGV) {
	my $v = shift;
	if ($v eq '+') {
		if (scalar @{$FILTERS[$#FILTERS]}) {
			push(@FILTERS, []);
		}
	} elsif (exists $WKF->{$v}) {
		# It's a "well-known" filter shortcut
		push(@FILTERS, @{$WKF->{$v}}, []);
	} elsif ($v =~ m/([A-Z_]+)=([^\s]+)/) {
		# FIELD=value Filter....
		if ($v =~ m/_UID/) {
			print STDERR "\nMatches containing _UID are not permitted\n";
			usage(3);
		}
		push(@{$FILTERS[$#FILTERS]}, $v);
	} elsif ($v =~ m|^/.+$| && -x $v) {
		# Path to exe, implies _EXE=$v
		push(@{$FILTERS[$#FILTERS]}, "_EXE=$v");
	} elsif (defined (my $uid = getpwnam($v))) {
		# The user must match <ME>_* and be a member of <ME>'s
		# primary group to be permitted to view the log or just
		# be <ME>
		if (
			!($UID eq 0 && $USER eq 'root')
			&& $v ne $USER
			&& (
				substr($v, 0, length($USER)+1) ne "${USER}_"
				|| !exists $MEMBERS{$v}
			)
		) {
			print STDERR "\nYou ($USER) are not permitted to view the journal for the user '$v'\n";
			usage(4);
		}
		push(@USERS, $uid);
	} else {
		print STDERR "\nParameter '$v' does not appear to be a valid filter or user\n";
		usage(5);
	}
}
if (!scalar @{$FILTERS[$#FILTERS]}) {
	pop(@FILTERS);
}
# If no users were specified then force everyone the caller has access to
# if we are root (on our own behalf) that's everyone so just leave the list empty
if (scalar @USERS == 0 && ($UID != 0 || $USER ne 'root')) {
	push(@USERS, scalar getpwnam($USER), (map { scalar getpwnam($_) } grep { substr($_, 0, length($USER)+1) eq "${USER}_" } keys %MEMBERS));
}

# Filter out any invalid UIDs unless we're root acting on our own behalf
if ($UID != 0 || $USER ne 'root') {
	@USERS = grep { $_ > 0 } @USERS;
}

# Check that after filtering we actually have *valid* users in the @USERS list
# OR we are root acting on our own behalf
if (scalar @USERS == 0 && ($UID != 0 || $USER ne 'root')) {
	print STDERR "\nERROR: No valid UIDs in filter list\n";
	usage(6);
}

# Now rebuild the FILTER argument vector
my @USERV = map { "_UID=$_" } @USERS;
my @FILTERV=();
foreach my $f (@FILTERS) {
	next if !scalar @{$f}; # Skip empty parts
	push(@FILTERV, '+') if (scalar @FILTERV);
	push(@FILTERV, @USERV, @{$f});
}
if (! scalar @FILTERV) {
	# Just filter by user
	@FILTERV = @USERV;
}

# And finally run the wrapped command...
if ($UID != 0) {
	# If we aren't running as root then try to sudo ourself, even though we've already filtered
	# the arguments it'll have to be done again since future sudo us, shouldn't trust current us.
	exec('sudo', ($0, '--sudo', @ORIG_ARGV));
} else {
	# Force the pager to be less and force it into secure mode to limits the tricks
	# users can play in the sudo environment
	$ENV{'PAGER'} = '/bin/less';
	$ENV{'LESSSECURE'} = 1;
	exec('journalctl', @FLAGV, @FILTERV);
}

