#! /usr/bin/perl -T
# vim: set filetype=perl ts=4 sw=4 sts=4 et:
# FILEMODE 0755
# FILEOWN root root
use 5.026;
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 ();
use List::Util  qw{ first };

$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);

    if (!first { "X-Bastion-Local-Status" eq $_->[0] } @headers) {
        push @headers, ["X-Bastion-Local-Status" => "$code $msg"];
    }
    my %ret = (code => $code, msg => $msg, body => $body, headers => \@headers, allowed => $merged{'allowed'});
    OVH::Bastion::json_output(R('OK', value => encode_base64(freeze(\%ret))), 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,
    "egress-protocol=s"               => \my $egress_protocol,
);
push @headers, ["X-Bastion-Remote-Host" => $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) {
    my %ret = (code => 200, msg => 'OK', body => $OVH::Bastion::VERSION, allowed => 1);
    OVH::Bastion::json_output(R('OK', value => encode_base64(freeze(\%ret))), no_delimiters => 1);
    exit 0;
}

$egress_protocol ||= "https";

$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\n", {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("$egress_protocol/$method");
push @{$log_params{'custom'}}, ['post_length' => length($content)];

# hardcoded list of protocols we support
if (!grep { $egress_protocol eq $_ } qw{ http https }) {
    log_and_exit(400, "Bad Request (bad protocol)", "Egress protocol is invalid\n", {comment => "invalid_protocol"});
}

# check egress protocol against the list of allowed ones
$fnret = OVH::Bastion::load_configuration_file(
    file   => OVH::Bastion::main_configuration_directory() . "/osh-http-proxy.conf",
    secure => 1,
);
if (not $fnret) {
    osh_warn "Error loading configuration: $fnret";
    log_and_exit(
        500,
        "Internal Error",
        "Configuration is invalid, please contact the bastion admin\n",
        {comment => "invalid_configuration"}
    );
}

my $config = $fnret->value();

# just look at the egress protocol, the other configuration values are handled by the daemon, not the worker (us)
$config->{'allowed_egress_protocols'} ||= ['https'];    # default value
if (ref $config->{'allowed_egress_protocols'} ne 'ARRAY') {
    osh_warn "Invalid allowed_egress_protocols config type: " . (ref $config->{'allowed_egress_protocols'});
    log_and_exit(
        500,
        "Internal Error",
        "Configuration is invalid, please contact the bastion admin\n",
        {comment => "invalid_configuration"}
    );
}

if (not grep { $egress_protocol eq $_ } @{$config->{'allowed_egress_protocols'}}) {
    log_and_exit(
        400,
        "Bad Request (forbidden protocol)",
        "Requested protocol '$egress_protocol' is forbidden\n",
        {comment => "forbidden_protocol"}
    );
}

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\n", {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\n",
        {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\n", {comment => "missing_password"});
}

# convert the remotemachine into an IP if needed
my $remoteip;
$fnret = OVH::Bastion::get_ip(host => $remotemachine);
if ($fnret && $fnret->value->{'ip'} && $fnret->value->{'ip'} =~ /^([0-9a-f.:]+)$/) {
    $remoteip = $1;    # untaint
}
else {
    my $comment = lc($fnret->err);
    $comment =~ tr/-/_/;
    log_and_exit(400, "Bad Request (" . $fnret->err . ")", "$fnret\n", {comment => $comment});
}
push @headers, ["X-Bastion-Remote-IP" => $remoteip];

delete $log_params{'hostto'};
$log_params{'ipto'} = $remoteip;

# if it's an IPv6, we need to add brackets around it as an URI, and adapt the name of the log subdirectory
my $remotemachine_uri = $remoteip;
my $logsubdir         = $remoteip;
if (index($remotemachine, ':') != -1) {
    $remotemachine_uri = "[$remotemachine]";
    # as :'s might not be allowed on all filesystems, replace :'s by .'s and surround by v6[]
    $logsubdir =~ tr/:/./;
    $logsubdir = "v6[$logsubdir]";
}

# 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\n",
        {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) . ")\n",
        {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@$remoteip, time: 50ms (to re-compute)
$fnret = OVH::Bastion::is_access_granted(
    account  => $account,
    user     => $user,
    ipfrom   => $ENV{'REMOTE_ADDR'},
    ip       => $remoteip,
    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_uri:$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\n", {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($config->{'allowed_egress_protocols'});
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 => $egress_protocol . "://" . $remotemachine_uri . ':' . $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());

# the remote server might need to have the original hostname to serve the proper contents
$req->header('Host' => $remotemachine);

# 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);
    }
    # we can use freeze() instead of nfreeze() because the thaw() will be made by our parent,
    # which guarantees that we're using the same endianness, obviously
    $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/$logsubdir";
-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-Remote-Status" => $res->status_line]      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;

if ($res) {
    push @headers, ["X-Bastion-Local-Status" => "200 OK"];
    log_and_exit($res->code + 0, $res->message, $res->decoded_content, {});
}
else {
    push @headers, ["X-Bastion-Local-Status" => "504 Device Timeout"];
    log_and_exit(504, "Device Timeout", "Device Timeout\n", {comment => "device_timeout"});
}
