the-bastion/lib/perl/OVH/Bastion/execute.inc

390 lines
15 KiB
Perl

# vim: set filetype=perl ts=4 sw=4 sts=4 et:
package OVH::Bastion;
use common::sense;
use IO::Handle;
use IPC::Open3;
use Symbol 'gensym';
use IO::Select;
use POSIX ":sys_wait_h";
use JSON;
use Config;
# Get signal names, i.e. signal 9 is SIGKILL, etc.
my %signum2string;
@signum2string{split ' ', $Config{sig_num}} = map { "SIG$_" } split ' ', $Config{sig_name};
sub sysret2human {
my $sysret = shift;
if ($sysret == -1) {
return R('OK', msg => "error: failed to execute ($!)");
}
elsif ($sysret & 127) {
my $signal = $sysret & 127;
my $coredump = $sysret & 128;
return R(
'OK',
value => {
coredump => $coredump ? \1 : \0,
signal => $signum2string{$signal} || $signal,
status => undef,
},
msg => sprintf("signal %d (%s)%s", $signal, $signum2string{$signal}, $coredump ? ' and coredump' : '')
);
}
else {
return R('OK', value => {coredump => \0, signal => undef, status => $sysret >> 8}, msg => sprintf("status %d", $sysret >> 8));
}
}
## no critic(ControlStructures::ProhibitDeepNests)
sub execute {
my %params = @_;
my $cmd = $params{'cmd'}; # command to execute, must be an array ref (with possible parameters)
my $expects_stdin = $params{'expects_stdin'}; # the command called expects stdin, pipe caller stdin to it
my $noisy_stdout = $params{'noisy_stdout'}; # capture stdout but print it too
my $noisy_stderr = $params{'noisy_stderr'}; # capture stderr but print it too
my $is_helper = $params{'is_helper'}; # hide JSON returns from stdout even if noisy_stdout
my $is_binary = $params{'is_binary'}; # used for e.g. scp, don't bother mimicking readline(), we lose debug and stdout/stderr are NOT returned to caller
my $stdin_str = $params{'stdin_str'}; # string to push to the STDIN of the command
my $must_succeed = $params{'must_succeed'}; # if the executed command returns a non-zero exit value, turn OK_NON_ZERO_EXIT to ERR_NON_ZERO_EXIT
my $max_stdout_bytes = $params{'max_stdout_bytes'}; # if the amount of stored stdout bytes exceeds this, halt the command and return to caller
my $system = $params{'system'}; # if set to 1, will use system() instead of open3(), needed for some plugins
$noisy_stderr = $noisy_stdout = 1 if ($ENV{'PLUGIN_DEBUG'} or $is_binary);
my $readsize = $is_binary ? 16384 : 1; # XXX needs to be enhanced to be > 1 even for non-binary
my $fnret;
=cut only to debug slow calls
if (not $is_binary)
{
require Carp;
open(SLOW, '>>', '/dev/shm/slowexecute');
print SLOW Carp::longmess(join('^',@$cmd))."\n\n";
close(SLOW);
}
=cut
require Scalar::Util;
foreach (@$cmd) {
if (Scalar::Util::tainted($_) && /(.+)/) {
# to be able to warn under -T; untaint it. we're going to crash right after anyway.
require Carp;
warn(Carp::longmess("would exec <" . join('^', @$cmd) . "> but param '$1' is tainted!"));
}
}
if ($system) {
my $child_exit_status = system(@$cmd);
$fnret = sysret2human($child_exit_status);
return R(
$child_exit_status == 0 ? 'OK' : ($must_succeed ? 'ERR_NON_ZERO_EXIT' : 'OK_NON_ZERO_EXIT'),
value => {
sysret => $child_exit_status + 0,
status => $fnret->value->{'status'},
coredump => $fnret->value->{'coredump'},
signal => $fnret->value->{'signal'},
},
msg => "Command exited with " . sysret2human($child_exit_status)->msg,
);
}
my ($child_stdin, $child_stdout, $child_stderr);
$child_stderr = gensym;
osh_debug("about to run_cmd ['" . join("','", @$cmd) . "']");
my $pid;
eval { $pid = open3($child_stdin, $child_stdout, $child_stderr, @$cmd); };
if ($@) {
chomp $@;
return R('ERR_EXEC_FAILED', msg => "Couldn't exec requested command ($@)");
}
osh_debug("waiting for child PID $pid to complete...");
my %output = ();
my %lineBuffer;
my $currentActive = undef;
my $currently_in_json_block = 0;
my %bytesnb;
# always monitor our child stdout and stderr
my $select = IO::Select->new($child_stdout, $child_stderr);
binmode $child_stdin;
binmode $child_stdout;
binmode $child_stderr;
# if some fd are closed, binmode may fail
eval { binmode STDIN; };
eval { binmode STDOUT; };
eval { binmode STDERR; };
if ($stdin_str) {
# we have some stdin data to push, do it now
syswrite $child_stdin, $stdin_str;
close($child_stdin);
}
elsif ($expects_stdin) {
# ... and also monitor our own stdin only if we expect it (we'll pipe it to our child's stdin)
$select->add(\*STDIN);
}
# then, while we still have fh to monitor
while ($select->count() > 1 || ($select->count() == 1 && !$select->exists(\*STDIN))) {
# block only for 50ms, before checking if child is dead
my @ready = $select->can_read(0.05);
# yep, we have something to read on at least one fh
if (@ready) {
# guarantee we're still reading this fh while it has something to say
$currentActive = $ready[0];
my $subSelect = IO::Select->new($currentActive);
# can_read(0) because we don't need a timeout: we KNOW there's something to read on this fh
while ($subSelect->can_read(0)) {
my $buffer;
my $nbread = sysread $currentActive, $buffer, $readsize;
# if size 0, it means it's an EOF, if undef, it's an error
if (not $nbread) {
# error, we'll warn and close
if (not defined $nbread) {
# awwww, not cool at all
warn("execute(): error while sysreading($!), closing fh!");
}
# we got an EOF on this fh, remove it from the monitor list
$select->remove($currentActive);
# if this is an EOF on our own STDIN, we need to close our child's STDIN
if ($currentActive->fileno == STDIN->fileno) {
close(STDIN); # we got eof on it, so close it
close($child_stdin); # and close our child stdin
}
else {
; # eof on our child's stdout or stderr, nothing to do
}
last;
}
# we got data, is this our child's stderr ?
if ($currentActive->fileno == $child_stderr->fileno) {
$bytesnb{'stderr'} += $nbread;
# syswrite on our own STDERR what we received
if ($noisy_stderr) {
my $offset = 0;
while ($offset < $nbread) {
my $written = syswrite STDERR, $buffer, $readsize, $offset;
if (not defined $written) {
# oww, abort writing for this cycle
warn("execute(): error while syswriting($!) on stderr, aborting this cycle");
last;
}
$offset += $written;
}
}
# mimic line-based reading (for debug, and also data will be returned to caller)
if (not $is_binary) {
# if this is a newline, push it to our output array
if ($buffer eq $/) {
osh_debug("stderr($pid): " . $lineBuffer{'stderr'}) unless $noisy_stderr; # avoid double print
push @{$output{'stderr'}}, $lineBuffer{'stderr'};
$lineBuffer{'stderr'} = '';
}
# or push it to our temp line buffer
else {
$lineBuffer{'stderr'} .= $buffer;
}
}
}
# we got data, is this our child's stdout ?
elsif ($currentActive->fileno == $child_stdout->fileno) {
$bytesnb{'stdout'} += $nbread;
# syswrite on our own STDOUT what we received
if ($noisy_stdout and not $is_helper) {
# the "if is_helper" case is handled below per-line
my $offset = 0;
while ($offset < $nbread) {
my $written = syswrite STDOUT, $buffer, $readsize, $offset;
if (not defined $written) {
# oww, abort writing for this cycle
warn("execute(): error while syswriting($!) on stdout, aborting this cycle");
last;
}
$offset += $written;
}
}
# mimic line-based reading (for debug, and also data will be returned to caller)
if (not $is_binary) {
if ($buffer eq $/) {
osh_debug("stdout($pid): " . $lineBuffer{'stdout'}) unless $noisy_stdout; # avoid double print
push @{$output{'stdout'}}, $lineBuffer{'stdout'};
if ($noisy_stdout and $is_helper) {
# in that case, we didn't noisy print each char, we wait for $/
# then print it IF this is not the result_from_helper (json)
if ($lineBuffer{'stdout'} eq 'JSON_START') {
$currently_in_json_block = 1;
}
if (not $currently_in_json_block) {
print $lineBuffer{'stdout'} . $/;
}
if ($currently_in_json_block and $lineBuffer{'stdout'} eq 'JSON_END') {
$currently_in_json_block = 0;
}
}
$lineBuffer{'stdout'} = '';
}
else {
$lineBuffer{'stdout'} .= $buffer;
}
}
if ($max_stdout_bytes && $bytesnb{'stdout'} >= $max_stdout_bytes) {
# caller got enough data, close all our child channels
$select->remove($child_stdout);
$select->remove($child_stderr);
close($child_stdin);
close($child_stdout);
close($child_stderr);
# and also our own STDIN if we're listening for it
if ($select->exists(\*STDIN)) {
$select->remove(\*STDIN);
close(STDIN);
}
# don't forget to push any pending data to our output buffer
push @{$output{'stdout'}}, $lineBuffer{'stdout'};
}
}
# we got data, is this our stdin ?
elsif ($currentActive->fileno == STDIN->fileno) {
$bytesnb{'stdin'} += $nbread;
# we just write the data to our child's own stdin
syswrite $child_stdin, $buffer;
}
# wow, we got data from an unknown fh ... it's not possible
else {
# ... but just in case:
require Data::Dumper;
osh_warn("unknown fh: " . Data::Dumper::Dumper($currentActive) . " with char <$buffer>");
osh_warn(Data::Dumper::Dumper($child_stdout));
osh_warn(Data::Dumper::Dumper($child_stderr));
osh_warn(Data::Dumper::Dumper(\*STDIN));
}
}
# /guarantee
}
}
# here, all fd went EOF (except maybe STDIN but we don't care)
# so we need to waitpid
# (might be blocking, but we have nothing to read/write anyway)
osh_debug("all fds are EOF, waiting for pid $pid indefinitely");
waitpid($pid, 0);
my $child_exit_status = $?;
$fnret = sysret2human($child_exit_status);
osh_debug("cmd returned with " . $fnret->msg);
return R(
$fnret->value->{'status'} == 0 ? 'OK' : ($must_succeed ? 'ERR_NON_ZERO_EXIT' : 'OK_NON_ZERO_EXIT'),
value => {
sysret => $child_exit_status >> 8,
sysret_raw => $child_exit_status,
stdout => $output{stdout},
stderr => $output{stderr},
bytesnb => \%bytesnb,
status => $fnret->value->{'status'},
coredump => $fnret->value->{'coredump'},
signal => $fnret->value->{'signal'},
},
msg => "Command exited with " . sysret2human($child_exit_status)->msg,
);
}
sub result_from_helper {
my $input = shift;
if (ref $input ne 'ARRAY') {
$input = [$input];
}
my $state = 1;
my @json;
foreach my $line (@$input) {
chomp;
if ($state == 1) {
if ($line eq 'JSON_START') {
# will now capture data
@json = ();
$state = 2;
}
}
elsif ($state == 2) {
if ($line eq 'JSON_END') {
# done capturing data, might still see a new JSON_START however
$state = 1;
}
else {
# capturing data
push @json, $line;
}
}
}
if (not @json) {
return R('ERR_HELPER_RETURN_EMPTY', msg => "The helper didn't return any data, maybe it crashed, please report to your sysadmin!");
}
my $json_decoded;
eval { $json_decoded = decode_json(join("\n", @json)); };
if ($@) {
return R('ERR_HELPER_RETURN_INVALID', msg => $@);
}
return R('OK', value => $json_decoded);
}
sub helper_decapsulate {
my $value = shift;
return R($value->{'error_code'}, value => $value->{'value'}, msg => $value->{'error_message'});
}
sub helper {
my %params = @_;
my @command = @{$params{'cmd'} || []};
my $expects_stdin = $params{'expects_stdin'};
my $stdin_str = $params{'stdin_str'};
my $fnret = OVH::Bastion::execute(cmd => \@command, noisy_stdout => 1, noisy_stderr => 1, is_helper => 1, expects_stdin => $expects_stdin, stdin_str => $stdin_str);
$fnret or return R('ERR_HELPER_FAILED', "something went wrong in helper script (" . $fnret->msg . ")");
$fnret = OVH::Bastion::result_from_helper($fnret->value->{'stdout'});
$fnret or return $fnret;
return OVH::Bastion::helper_decapsulate($fnret->value);
}
1;