mirror of
https://github.com/ovh/the-bastion.git
synced 2025-01-09 17:03:24 +08:00
390 lines
15 KiB
Perl
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;
|