mirror of
https://github.com/ovh/the-bastion.git
synced 2025-01-08 00:12:10 +08:00
2e9fe9288b
Added the `log_request_response` and `log_request_response_max_size` options to osh-http-proxy.conf. By default, requests are logged, including their body, up to a size of 64K per request response. Before, there was no size limit to the logged body response.
472 lines
17 KiB
Perl
Executable file
472 lines
17 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];
|
|
OVH::Bastion::json_output(R('OK', value => {code => $code, msg => $msg, body => $body . "\n", headers => \@headers, allowed => $merged{'allowed'}}), no_delimiters => 1);
|
|
exit 0;
|
|
}
|
|
|
|
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,
|
|
"log-request-response" => \my $log_request_response,
|
|
"log-request-response-max-size=i" => \my $log_request_response_max_size,
|
|
);
|
|
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) {
|
|
OVH::Bastion::json_output(R('OK', value => {code => 200, msg => 'OK', body => $OVH::Bastion::VERSION, allowed => 1}), no_delimiters => 1);
|
|
exit 0;
|
|
}
|
|
|
|
$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
|
|
my %passthrough_headers;
|
|
foreach my $pattern (qw{ accept content-type content-length content-encoding x-test-[a-z-]+ }) {
|
|
foreach my $header (grep { /^$pattern:/i } @client_headers) {
|
|
my ($key, $value) = $header =~ /^([^:]+):\s*(.+)$/;
|
|
$passthrough_headers{lc $key} = $value;
|
|
}
|
|
}
|
|
foreach my $key (keys %passthrough_headers) {
|
|
$req->header($key, $passthrough_headers{$key});
|
|
}
|
|
$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;
|
|
|
|
# depending on whether we must log the body or not, and whether there is a max size specified,
|
|
# prepare the log line about the body
|
|
my $body_to_log = "(BODY OMITTED)";
|
|
if ($res && $log_request_response) {
|
|
$log_request_response_max_size //= 65536;
|
|
if ($log_request_response_max_size && length($res->decoded_content) > $log_request_response_max_size) {
|
|
$body_to_log = substr($res->decoded_content, 0, $log_request_response_max_size / 2) . '[...snip...]' . substr($res->decoded_content, -$log_request_response_max_size / 2);
|
|
}
|
|
else {
|
|
$body_to_log = $res->decoded_content;
|
|
}
|
|
}
|
|
|
|
my @now = Time::HiRes::gettimeofday();
|
|
my @t = localtime($now[0]);
|
|
my @headerlog = ($uniqid, $now[0], $now[1], POSIX::strftime("%Y/%m/%d.%H:%M:%S", @t));
|
|
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",
|
|
@headerlog, $req->as_string(), @headerlog, $res ? sprintf("%s %s\n%s\n%s", $res->protocol, $res->status_line, $headers_as_string, $body_to_log) : '(DEVICE TIMEOUT)',
|
|
@headerlog,);
|
|
$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");
|
|
}
|
|
|
|
# those are the headers we allow to come back from the remote server to the client
|
|
# we don't passthrough content-encoding, as we've already decoded the content (for logging purposes)
|
|
my @passthru_headers = qw{ content-type content-length 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);
|
|
|
|
my %ret = (
|
|
headers => \@headers,
|
|
allowed => 1,
|
|
);
|
|
if ($res) {
|
|
$ret{'code'} = $res->code + 0;
|
|
$ret{'msg'} = $res->message;
|
|
$ret{'body'} = $res->decoded_content;
|
|
}
|
|
else {
|
|
$ret{'code'} = 504;
|
|
$ret{'msg'} = "Device Timeout";
|
|
$ret{'body'} = "Device Timeout\n";
|
|
}
|
|
OVH::Bastion::json_output(R('OK', value => \%ret), no_delimiters => 1);
|
|
exit 0;
|