the-bastion/bin/proxy/osh-http-proxy-worker
Thomas SOËTE 2a51a78b54 fix: Enable perl-tidy.sh test
* Move to ubuntu-20.04 runner
* Remove check in dockers tests
2020-11-22 21:37:34 +00:00

437 lines
15 KiB
Perl
Executable file

#! /usr/bin/perl -T
# vim: set filetype=perl ts=4 sw=4 sts=4 et:
# FILEMODE 0755
# FILEOWN root root
use common::sense;
use File::Basename;
use lib dirname(__FILE__) . '/../../lib/perl';
use OVH::Result;
use OVH::Bastion;
use Fcntl qw(:flock SEEK_END);
use Getopt::Long;
use HTTP::Message;
use IO::Pipe;
use IO::Select;
use IO::Socket::SSL;
use IO::Socket::SSL;
use LWP::UserAgent;
use MIME::Base64;
use POSIX ();
use Storable qw{ freeze thaw };
use Sys::Hostname;
use Time::HiRes ();
$ENV{'FORCE_STDERR'} = 1;
$ENV{'PATH'} = '/sbin:/bin:/usr/sbin:/usr/bin:/usr/local/sbin:/usr/local/bin:/usr/pkg/bin';
my $uniqid;
my $fnret;
my %log_params = (
'cmdtype' => 'proxyhttp_worker',
'uniq_id' => $uniqid,
'bastionip' => $ENV{'SERVER_ADDR'},
'bastionport' => $ENV{'SERVER_PORT'},
'ipfrom' => $ENV{'REMOTE_ADDR'},
'portfrom' => $ENV{'REMOTE_PORT'},
'custom' => [[user_agent => $ENV{'HTTP_USER_AGENT'}]],
);
# to handle child timeout
my $child_finished = 0;
$SIG{'CHLD'} = sub { wait; $child_finished = 1 };
my @headers;
sub log_and_exit {
my ($code, $msg, $body, $params) = @_;
my %merged = (%log_params, %$params);
$merged{'allowed'} //= 0;
# custom data will only be logged to logfile and syslog, not sql (it's not in the generic schema)
push @{$merged{'custom'}}, ['code' => $code], ['msg' => $msg];
OVH::Bastion::log_access_insert(%merged);
push @headers, ["X-Bastion-Local-Status" => $code];
HEXIT('OK', value => {code => $code, msg => $msg, body => $body . "\n", headers => \@headers, allowed => $merged{'allowed'}});
}
my $pass = delete $ENV{'PROXY_ACCOUNT_PASSWORD'};
my $content;
GetOptions(
"account=s" => \my $account,
"user=s" => \my $user,
"group=s" => \my $group,
"context=s" => \my $context,
"host=s" => \my $remotemachine,
"port=i" => \my $remoteport,
"method=s" => \my $method,
"path=s" => \my $path,
"header=s" => \my @client_headers,
"timeout=i" => \my $timeout,
"insecure" => \my $insecure,
"uniqid=s" => \$uniqid,
"post-data-stdin" => \my $postDataStdin,
"allow-downgrade" => \my $allow_downgrade,
"monitoring" => \my $monitoring,
);
push @headers, ["X-Bastion-Remote-IP" => $remotemachine];
if (!$postDataStdin) {
$content = delete $ENV{'PROXY_POST_DATA'};
$content = decode_base64($content) if $content;
}
else {
local $/ = undef;
$content = <STDIN>;
}
push @headers, ["X-Bastion-Request-Length" => "" . length($content)];
# if we're being called by the monitoring, just exit happily
if ($monitoring) {
HEXIT('OK', value => {code => 200, msg => 'OK', body => $OVH::Bastion::VERSION, allowed => 1});
}
$fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account); # time: 20ms
$fnret or log_and_exit(400, "Bad Request (bad account)", "Account name is invalid", {comment => "invalid_account"});
$account = $fnret->value->{'account'}; # untaint
$log_params{'account'} = $account;
$log_params{'user'} = $user;
$log_params{'hostto'} = $remotemachine;
$log_params{'params'} = $path;
$log_params{'plugin'} = uc($method);
push @{$log_params{'custom'}}, ['post_length' => length($content)];
my $shortGroup;
if ($group) {
$fnret = OVH::Bastion::is_valid_group_and_existing(group => $group, groupType => 'key');
$fnret or log_and_exit(400, "Bad Request (bad group)", "Group name is invalid", {comment => "invalid_group"});
$shortGroup = $fnret->value->{'shortGroup'};
$group = $fnret->value->{'group'};
}
if (!OVH::Bastion::is_valid_port(port => $remoteport)) {
log_and_exit(400, "Bad Request (bad port number)", "Port number is out of range", {comment => "invalid_port_number"});
}
$log_params{'portto'} = $remoteport;
if (!$timeout || $timeout !~ /^\d+$/ || $timeout < 1) {
$timeout = 10;
}
elsif ($timeout > 3600) {
$timeout = 3600;
}
if (not $pass) {
log_and_exit(400, "Bad Request (no password)", "No password", {comment => "missing_password"});
}
# convert the remotemachine into an IP if needed
# if: avoid loading Net::IP and BigInt if there's no host specified
if ($remotemachine !~ /^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/) {
$fnret = OVH::Bastion::get_ip(host => $remotemachine);
if ($fnret && $fnret->value->{'ip'} =~ /^([0-9.:]+)$/) {
$remotemachine = $1; # untaint
}
else {
log_and_exit(400, "Bad Request (host not resolved)", "Specified remote host couldn't be resolved through the DNS", {comment => "host_not_found"});
}
}
else {
# it's already an IP, get $1 for untaint
$remotemachine = $1;
}
delete $log_params{'hostto'};
$log_params{'ipto'} = $remotemachine;
# now check that the password we were given for account matches the hash we have stored for it
# first get our stored hash
$fnret = OVH::Bastion::account_config(account => $account, key => "proxyhttphash");
if (not $fnret or not $fnret->value) {
# bad login because we couldn't open the proper file
log_and_exit(403, "Access Denied", "No password configured for you, $account", {comment => "no_password_for_login"});
}
my $storedhash = $fnret->value;
chomp $storedhash;
# extract the salt from the stored hash we have
if ($storedhash !~ /^\$(?<cipher>[a-zA-Z0-9]{1,2})\$(?<salt>[^\$]+)\$[^\$]+$/) {
# the hash we have stored in the bastion is fucked :(
log_and_exit(500, "Internal Error (malformed hash)", "Please contact a bastion admin\n", {comment => "malformed_hash"});
}
if ($storedhash ne crypt($account . ':' . $pass, '$' . $+{'cipher'} . '$' . $+{'salt'})) {
log_and_exit(403, "Access Denied", "Incorrect username ($account) or password (#REDACTED#, length=" . length($pass) . ")", {comment => "bad_login_password"});
}
undef $pass;
undef $storedhash;
# read the password we must use
# pseudoalgo:
# if mode was explicitly specified to group, we look for a group password
# elif mode was explicitly specified to self, we look for a self account password
# elif the legacy file with the same name as $user exists in /home/passkeeper, use it
# elif the specified $user happens to be a group name, look for this group password
# else look for a self account password
my $authmode;
my $hint;
if ($context eq 'group') {
$hint = $shortGroup;
$authmode = 'group/explicit';
}
elsif ($context eq 'self') {
$hint = $account;
$authmode = 'self/explicit';
}
elsif (-f "/home/passkeeper/$user") {
$hint = $user;
$context = 'legacy';
$authmode = 'legacy';
}
elsif (OVH::Bastion::is_valid_group_and_existing(group => $user, groupType => "key")) {
$hint = $user;
$context = 'group';
$authmode = 'group/guessed';
}
else {
$hint = $account;
$context = 'self';
$authmode = 'self/default';
}
push @headers, ["X-Bastion-Auth-Mode" => $authmode];
push @{$log_params{'custom'}}, ['auth_mode' => $authmode];
# check if account or group has the right to access $user@$remotemachine, time: 50ms (to re-compute)
$fnret = OVH::Bastion::is_access_granted(account => $account, user => $user, ipfrom => $ENV{'REMOTE_ADDR'}, ip => $remotemachine, port => $remoteport, listOnly => 1, sudo => 1);
if (not $fnret) {
log_and_exit(403, "Access Denied (access denied to remote)", "This account doesn't have access to this user\@host tuple ($fnret)\n", {comment => "access_denied"});
}
else {
# check that the way we were asked to use (personal access, group access) is actually allowed for this account
my $isOk = 0;
if ($context ne 'legacy') {
foreach my $access (@{$fnret->value}) {
if ($access->{'type'} =~ /^group/ && $context eq 'group') {
$isOk = 1 if $access->{'group'} eq $hint;
}
elsif ($access->{'type'} =~ /^personal/ && $context eq 'self') {
$isOk = 1;
}
}
}
else {
# for legacy, we don't check: we didn't know how to differentiate back then
$isOk = 1;
}
if (!$isOk) {
log_and_exit(
403,
"Access Denied (access denied to remote this way)",
"This account doesn't have access to $user\@$remotemachine:$remoteport using this auth mode ($authmode)\n",
{comment => "access_denied_this_way"}
);
}
}
$log_params{'allowed'} = 1;
$fnret = OVH::Bastion::get_passfile(hint => $hint, context => $context);
if (!$fnret) {
log_and_exit(412, "Precondition Failed (egress password missing)", $fnret->msg, {comment => "no_password_found"});
}
my $device_password;
if (open(my $pwdfile, '<', $fnret->value)) {
$device_password = <$pwdfile>;
close($pwdfile);
chomp $device_password;
}
else {
log_and_exit(500, "Internal Error (egress password unreadable)", "Couldn't read the $context '$user' egress password\n", {comment => "cannot_read_password"});
}
# Now build the UA for the request between bastion and remote device
my $ua = LWP::UserAgent->new();
$ua->agent("The Bastion " . $OVH::Bastion::VERSION);
$ua->timeout($timeout);
$ua->protocols_allowed(['https']);
if ($insecure) {
$ua->ssl_opts('verify_hostname' => 0);
$ua->ssl_opts('SSL_verify_mode' => IO::Socket::SSL::SSL_VERIFY_NONE);
}
# set the remote URL
my $req = HTTP::Request->new($method => "https://" . $remotemachine . ':' . $remoteport . $path);
# add the content we get (passthru)
$req->content($content);
# passthru some tolerated headers from client->bastion to bastion->device req
foreach my $key (qw{ accept content-type content-length content-encoding }) {
my @values = grep { /^\Q$key\E:/i } @client_headers;
next if not @values;
$values[0] =~ s/^\Q$key\E:\s*//i;
$req->header($key, $values[0]);
}
$req->header('Accept-Encoding' => scalar HTTP::Message::decodable());
# set the header to auth ourselves to the remote device
$req->header('Authorization', 'Basic ' . encode_base64($user . ':' . $device_password, ''));
undef $device_password; # no longer needed
$req->header('X-Bastion-Auth-Mode', $authmode);
$req->header('X-Bastion-Ingress-Client-IP', $ENV{'REMOTE_ADDR'});
$req->header('X-Bastion-Ingress-Client-Port', $ENV{'REMOTE_PORT'});
$req->header('X-Bastion-Ingress-Client-User-Agent', $ENV{'HTTP_USER_AGENT'});
$req->header('X-Bastion-Ingress-Account', $account);
$req->header('X-Bastion-UniqID', $uniqid);
$req->header('X-Bastion-Instance', Sys::Hostname::hostname());
my $start_time = [Time::HiRes::gettimeofday()];
# to handle timeout properly, we fork a child, he'll do the req, and we'll wait for it,
# potentially killing it if the timeout fires
my $pipe = IO::Pipe->new;
my $childpid = fork();
if ($childpid == 0) {
# we are the child: make the req and return the result to our parent
$pipe->writer;
my $res;
my $downgraded = 0;
# do the req a first time with defaults
$res = $ua->request($req);
# if we get a HTTP/1.0 500 Can't connect to A.B.C.D:443 (SSL connect attempt failed error:1425F102:SSL routines:ssl_choose_client_version:unsupported protocol)
# ... then the device might be old and support TLS v1.0 maximum only. let's retry that silently if our caller allows
if ($allow_downgrade && $res && $res->code == 500 && $res->message =~ /ssl_choose_client_version:unsupported protocol/) {
$downgraded = 1;
$ua->ssl_opts('SSL_version' => 'TLSv1');
$res = $ua->request($req);
}
$pipe->print(freeze({res => $res, downgraded => $downgraded}));
exit;
}
# we are the parent: wait for our child but also consume the pipe to avoid blocking our child
$pipe->reader;
my $waiting_since = time();
my $remaining_wait = $timeout;
my $child_data;
my $timed_out = 0;
while (1) {
$remaining_wait = $timeout - (time() - $waiting_since);
# we've waited long enough, bail out
if ($remaining_wait <= 0) {
$timed_out = 1;
last;
}
my $select = IO::Select->new($pipe->fileno);
my @ready = $select->can_read($remaining_wait);
# we either have something to read or timed out
if (@ready) {
# we have something to read
my $newdata;
my $nbread = $pipe->read($newdata, 4096);
if (defined $nbread && $nbread > 0) {
$child_data .= $newdata;
}
else {
# 0 means EOF, undef means error
last;
}
}
else {
# we timed out, bail out
$timed_out = 1;
last;
}
}
my $res;
my $downgraded;
if (!$timed_out) {
# get the result of the request of our child
my $childresult = thaw($child_data);
if (ref $childresult eq 'HASH') {
$res = $childresult->{'res'};
$downgraded = $childresult->{'downgraded'};
}
}
else {
# got a timeout, kill our child
kill(9, $childpid);
}
my $delay = Time::HiRes::tv_interval($start_time, [Time::HiRes::gettimeofday()]);
# log what we got
my $basedir = "/home/$account/ttyrec";
-d $basedir || mkdir $basedir;
my $finaldir = "$basedir/$remotemachine";
-d $finaldir || mkdir $finaldir;
my @now = Time::HiRes::gettimeofday();
my @t = localtime($now[0]);
my $headers_as_string = $res ? join("", $res->{'_headers'}->as_string("\n")) : '';
my $logfile = sprintf("%s/%s.txt", $finaldir, POSIX::strftime("%F", @t));
my $logline = sprintf(
"--- BASTION_REQUEST UNIQID=%s TIMESTAMP=%d.%06d DATE=%s ---\n%s\n"
. "--- DEVICE_ANSWER UNIQID=%s TIMESTAMP=%d.%06d DATE=%s ---\n%s\n"
. "--- END UNIQID=%s TIMESTAMP=%d.%06d DATE=%s ---\n\n",
$uniqid, $now[0], $now[1], POSIX::strftime("%Y/%m/%d.%H:%M:%S", @t), $req->as_string(),
$uniqid, $now[0], $now[1], POSIX::strftime("%Y/%m/%d.%H:%M:%S", @t),
($res ? sprintf("%s %s\n%s\n%s", $res->protocol, $res->status_line, $headers_as_string, $res->decoded_content) : '(DEVICE TIMEOUT)'),
$uniqid, $now[0], $now[1], POSIX::strftime("%Y/%m/%d.%H:%M:%S", @t),
);
$logline =~ s/^(Authorization:).+/$1 (removed)/mgi;
if (open(my $log, '>>', $logfile)) {
flock($log, LOCK_EX);
print $log $logline;
flock($log, LOCK_UN);
close($log);
}
else {
warn("Couldn't open $logfile for log write");
}
my @passthru_headers = qw{ content-type client-ssl-cert-subject client-ssl-cipher client-ssl-warning };
if ($res) {
foreach my $key ($res->headers->header_field_names) {
next unless (grep { lc($key) eq $_ } @passthru_headers);
push @headers, [$key => $res->header($key)];
}
}
push @headers, ["X-Bastion-Local-Status" => ($res ? "200 OK" : "504 Device Timeout")];
push @headers, ["X-Bastion-Remote-Status" => $res->code] if $res;
push @headers, ["X-Bastion-Remote-Server" => $res->header('server')] if ($res && $res->header('server'));
push @headers, ["X-Bastion-Egress-Timing" => sprintf("%d", $delay * 1_000_000)];
push @headers, ["X-Bastion-Downgraded" => 1] if $downgraded;
# custom data will only be logged to logfile and syslog, not sql (it's not in the generic schema)
if ($res) {
push @{$log_params{'custom'}}, ['code' => $res->code], ['msg' => $res->message],;
}
OVH::Bastion::log_access_insert(%log_params);
HEXIT('OK', value => {code => $res->code + 0, msg => $res->message, body => $res->decoded_content, headers => \@headers, allowed => 1}) if $res;
HEXIT('OK', value => {code => 504, msg => "Device Timeout", body => "Device Timeout\n", headers => \@headers, allowed => 1});