mirror of
https://github.com/ovh/the-bastion.git
synced 2024-11-14 20:24:56 +08:00
2a51a78b54
* Move to ubuntu-20.04 runner * Remove check in dockers tests
437 lines
15 KiB
Perl
Executable file
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});
|