the-bastion/lib/perl/OVH/Bastion.pm
Stéphane Lesimple 534ce45812
release v3.01.01
2020-12-04 16:36:08 +00:00

974 lines
37 KiB
Perl

package OVH::Bastion;
# vim: set filetype=perl ts=4 sw=4 sts=4 et:
use common::sense;
use Fcntl;
use POSIX qw(strftime);
our $VERSION = '3.01.01';
BEGIN {
# only used by the handler below
my $_SAVED_ARGV = join('^', @ARGV);
sub _warn_die_handler {
my ($type, $msg) = @_;
# ignore if parsing code (undef) or in eval (1)
return 1 if (!defined $^S || $^S);
# ignore this unimportant error (perl race condition?)
return 1 if (defined $msg and $msg =~ m{^panic: fold_constants JMPENV_PUSH returned 2});
# eval{} in a BEGIN{} in Net::DNS, ignore it
return 1 if (defined $msg and $msg =~ m{^Can't locate Net/});
my $criticity = ($type eq 'die' ? 'err' : 'warning');
# Net::Server can be noisy if the client fails to establish the SSL connection,
# transform those die into info to avoid triggering SIEM alerts
$criticity = 'info' if (defined $msg and $msg =~ m{^Could not finalize SSL connection with client handle});
require Carp;
OVH::Bastion::syslogFormatted(
criticity => $criticity,
type => $type,
fields => [['sudouser', $ENV{'SUDO_USER'}], ['version', $OVH::Bastion::VERSION], ['msg', $msg], ['program', $0], ['cmdline', $_SAVED_ARGV], ['trace', Carp::longmess()]]
);
return 1;
}
$SIG{__WARN__} = sub { _warn_die_handler("warn", @_) };
$SIG{__DIE__} = sub { _warn_die_handler("die", @_) };
}
use JSON;
use Term::ANSIColor;
use File::Basename; # dirname
use Cwd; # need to use realpath because we use that to build sudoers for groups
our $BASEPATH = Cwd::realpath(dirname(__FILE__) . '/../../../'); # usually /opt/bastion
# untaint $BASEPATH manually because realpath() tainted it back
($BASEPATH) = $BASEPATH =~ m{(\S+)};
use lib dirname(__FILE__) . '/../';
use OVH::Result;
use parent qw( Exporter );
our @EXPORT = ## no critic (AutomaticExportation)
qw( osh_header osh_footer osh_exit osh_debug osh_info osh_warn osh_crit osh_ok HEXIT warn_syslog );
our $AUTOLOAD;
use constant {
EXIT_OK => 0,
EXIT_PLUGIN_ERROR => 100,
EXIT_ACCOUNT_INACTIVE => 101,
EXIT_HOST_NOT_FOUND => 102,
EXIT_READ_ONLY => 103,
EXIT_UNKNOWN_COMMAND => 104,
EXIT_EXEC_FAILED => 105,
EXIT_RESTRICTED_COMMAND => 106,
EXIT_ACCESS_DENIED => 107,
EXIT_PASSFILE_NOT_FOUND => 108,
EXIT_OUT_OF_SPACE => 109,
EXIT_CONFIGURATION_FAILURE => 110,
EXIT_GETOPTS_FAILED => 111,
EXIT_NO_HOST => 112,
EXIT_ACCOUNT_EXPIRED => 113,
EXIT_INTERACTIVE_DISABLED => 114,
EXIT_CONFLICTING_OPTIONS => 115,
EXIT_MOSH_DISABLED => 116,
EXIT_GOT_SIGNAL => 117,
EXIT_MAINTENANCE_MODE => 118,
EXIT_REALM_INVALID => 119,
EXIT_ACCOUNT_INVALID => 120,
EXIT_TTL_EXPIRED => 121,
EXIT_MFA_PASSWORD_SETUP_REQUIRED => 122,
EXIT_MFA_TOTP_SETUP_REQUIRED => 123,
EXIT_MFA_ANY_SETUP_REQUIRED => 124,
EXIT_MFA_FAILED => 125,
EXIT_TTYREC_CMDLINE_FAILED => 126,
EXIT_INVALID_REMOTE_USER => 127,
EXIT_INVALID_REMOTE_HOST => 128,
};
use constant {
MFA_PASSWORD_REQUIRED_GROUP => 'mfa-password-reqd',
MFA_PASSWORD_CONFIGURED_GROUP => 'mfa-password-configd',
MFA_PASSWORD_BYPASS_GROUP => 'mfa-password-bypass',
MFA_TOTP_REQUIRED_GROUP => 'mfa-totp-reqd',
MFA_TOTP_CONFIGURED_GROUP => 'mfa-totp-configd',
MFA_TOTP_BYPASS_GROUP => 'mfa-totp-bypass',
PAM_AUTH_BYPASS_GROUP => 'bastion-nopam',
TOTP_FILENAME => '.otp',
TOTP_BASEDIR => '/var/otp',
# authorized_keys file, relative to the user's HOME directory.
# if you change this, also change it in lib/shell/functions.inc
AK_FILE => '.ssh/authorized_keys2',
OPT_ACCOUNT_INGRESS_PIV_POLICY => 'ingress_piv_policy',
OPT_ACCOUNT_INGRESS_PIV_GRACE => 'ingress_piv_grace',
OPT_ACCOUNT_ALWAYS_ACTIVE => 'always_active',
OPT_ACCOUNT_IDLE_IGNORE => 'idle_ignore',
};
###########
# FUNCTIONS
# for i in *.inc ; do bz=$(basename $i .inc) ; echo "$bz => "'[qw{ '$(grep ^sub $i | grep -v 'sub _' | awk '{print $2}' | tr "\n" " ")'}],' ; done
my %_autoload_files = (
allowdeny => [
qw{ get_personal_account_keys get_group_keys is_access_way_granted get_ip ip2host get_user_groups print_acls is_access_granted ssh_test_access_way get_acl_way get_acls duration2human }
],
allowkeeper => [
qw{ is_user_in_group is_group_existing get_acl_from_file get_account_acl is_valid_uid get_next_available_uid is_bastion_account_valid_and_existing is_account_valid is_account_existing access_modify is_valid_group is_valid_group_and_existing add_user_to_group get_group_servers_list get_group_list get_account_list is_admin is_super_owner is_group_aclkeeper is_group_gatekeeper is_group_owner is_group_guest is_group_member is_auditor get_remote_accounts_from_realm is_valid_ttl get_realm_list }
],
configuration => [qw{ load_configuration_file main_configuration_directory load_configuration config account_config plugin_config group_config json_load }],
execute => [qw{ execute result_from_helper helper_decapsulate helper }],
interactive => [qw{ interactive }],
log => [qw{ syslog syslog_close syslogFormatted warn_syslog log_access_insert log_access_update log_access_get }],
os => [
qw{ sysinfo is_linux is_debian is_redhat is_bsd is_freebsd is_openbsd is_netbsd sys_useradd sys_groupadd sys_userdel sys_groupdel sys_addmembertogroup sys_delmemberfromgroup sys_changepassword sys_neutralizepassword sys_setpasswordpolicy sys_getsudoersfolder sys_getpasswordinfo sys_setfacl has_acls }
],
ssh => [
qw{ get_ssh_pub_key_info is_valid_public_key get_from_for_user_key generate_ssh_key get_bastion_ips get_supported_ssh_algorithms_list is_allowed_algo_and_size is_valid_fingerprint print_public_key get_authorized_keys_from_file account_ssh_config_get account_ssh_config_set verify_piv put_authorized_keys_to_file ssh_ingress_keys_piv_apply }
],
password => [qw{ get_hashes_from_password get_hashes_list }],
jail => [qw{ jailify }],
mock => [qw{ enable_mocking is_mocking set_mock_data mock_get_account_entry mock_get_account_access_way }],
);
sub AUTOLOAD { ## no critic (AutoLoading)
my $subname = $AUTOLOAD;
$subname =~ s/.*:://;
foreach my $file (keys %_autoload_files) {
if (grep { $subname eq $_ } @{$_autoload_files{$file}}) {
require $BASEPATH . '/lib/perl/OVH/Bastion/' . $file . '.inc';
# Catch a declared but not implemented subroutine before calling it
if (not exists &$AUTOLOAD) {
die "AUTOLOAD FAILED: forgot to declare $subname in $file";
}
goto &$AUTOLOAD;
}
}
die "AUTOLOAD FAILED: $AUTOLOAD";
}
# checks whether an account is expired (inactivity) if that's configured on this bastion
sub is_account_nonexpired {
my %params = @_;
my $sysaccount = $params{'sysaccount'};
my $remoteaccount = $params{'remoteaccount'};
if (not $sysaccount) {
return R('ERR_MISSING_PARAMETER', msg => "Missing 'sysaccount' argument");
}
# accountMaxInactiveDays is the max allowed inactive days to not block login. 0 means feature disabled.
my $accountMaxInactiveDays = 0;
my $fnret = OVH::Bastion::config('accountMaxInactiveDays');
if ($fnret and $fnret->value > 0) {
$accountMaxInactiveDays = $fnret->value;
}
my $isFirstLogin;
my $lastlog;
my $filepath = "/home/$sysaccount/lastlog" . ($remoteaccount ? "_$remoteaccount" : "");
my $value = {filepath => $filepath};
if (-e $filepath) {
$isFirstLogin = 0;
$lastlog = (stat(_))[9];
osh_debug("is_account_nonexpired: got lastlog date: $lastlog");
# if lastlog file is available, fetch some info from it
if (open(my $lastloginfh, "<", $filepath)) {
my $info = <$lastloginfh>;
chomp $info;
close($lastloginfh);
$value->{'info'} = $info;
}
}
else {
my ($previousDir) = getcwd() =~ m{^(/[a-z0-9_./-]+)}i;
if (!chdir("/home/$sysaccount")) {
osh_debug("is_account_nonexpired: no exec access to the folder!");
return R('ERR_NO_ACCESS', msg => "No read access to this account folder to compute last login time");
}
chdir($previousDir);
$isFirstLogin = 1;
# get the account creation timestamp as the lastlog
$fnret = OVH::Bastion::account_config(account => $sysaccount, key => "creation_timestamp");
if ($fnret && $fnret->value) {
$lastlog = $fnret->value;
osh_debug("is_account_nonexpired: got creation date from config.creation_timestamp: $lastlog");
}
elsif (-e "/home/$sysaccount/accountCreate.comment") {
# fall back to the stat of the accountCreate.comment file
$lastlog = (stat(_))[9];
osh_debug("is_account_nonexpired: got creation date from accountCreate.comment stat: $lastlog");
}
else {
# last fall back to the stat of the ttyrec/ folder
$lastlog = (stat("/home/$sysaccount/ttyrec"))[9];
osh_debug("is_account_nonexpired: got creation date from ttyrec/ stat: $lastlog");
}
}
my $seconds = time() - $lastlog;
my $days = int($seconds / 86400);
$value->{'days'} = $days;
$value->{'seconds'} = $seconds;
$value->{'already_seen_before'} = !$isFirstLogin;
osh_debug("Last account activity: $days days ago");
if ($accountMaxInactiveDays == 0) {
# no expiration configured, allow login and return some info
return R('OK_FIRST_LOGIN', value => $value) if $isFirstLogin;
return R('OK_EXPIRATION_NOT_CONFIGURED', value => $value);
}
else {
if ($days < $accountMaxInactiveDays) {
# expiration configured, but account not expired, allow login
return R('OK_NOT_EXPIRED', value => $value);
}
else {
# account expired, deny login
my $msg = OVH::Bastion::config("accountExpiredMessage")->value;
$msg = "Sorry, but your account has expired (#DAYS# days), access denied by policy." if !$msg;
$msg =~ s/#DAYS#/$days/g;
return R(
'KO_EXPIRED',
value => $value,
msg => $msg,
);
}
}
return R('ERR_INTERNAL_ERROR');
}
# Check whether a user is still active, if this feature has been enabled in the config
sub is_account_active {
my %params = @_;
my $account = $params{'account'};
my $fnret;
my $checkProgram = OVH::Bastion::config('accountExternalValidationProgram')->value;
return R('OK_FEATURE_DISABLED') if !$checkProgram;
# Get sysaccount from account because for realm case we need to check if the support account of the realm is active
$fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account);
$fnret or return $fnret;
my $sysaccount = $fnret->value->{'sysaccount'};
# If in alwaysActive, then is active
my $alwaysActiveAccounts = OVH::Bastion::config('alwaysActiveAccounts');
if ($alwaysActiveAccounts and $alwaysActiveAccounts->value) {
if (grep { $sysaccount eq $_ } @{$alwaysActiveAccounts->value}) {
return R('OK');
}
}
# If account has the flag in public config, then is active
if (OVH::Bastion::account_config(account => $sysaccount, key => OVH::Bastion::OPT_ACCOUNT_ALWAYS_ACTIVE, public => 1)) {
return R('OK');
}
if (!-r -x $checkProgram) {
warn_syslog("Configured check program '$checkProgram' doesn't exist or is not readable+executable");
return R('ERR_INTERNAL', msg => "The account activeness check program doesn't exist. Report this to sysadmin!");
}
$fnret = OVH::Bastion::execute(cmd => [$checkProgram, $sysaccount]);
if (!$fnret) {
warn_syslog("Failed to execute program '$checkProgram': " . $fnret->msg);
return R('ERR_INTERNAL', msg => "The account activeness check program failed. Report this to sysadmin!");
}
=cut exit code meanings are as follows:
EXIT_ACTIVE => 0,
EXIT_INACTIVE => 1,
EXIT_UNKNOWN => 2,
EXIT_UNKNOWN_SILENT_ERROR => 3,
EXIT_UNKNOWN_NOISY_ERROR => 4,
=cut
if ($fnret->value->{'status'} == 0) {
return R('OK');
}
if ($fnret->value->{'status'} == 3) {
if (!$fnret->value->{'stderr'}) {
warn_syslog("External account validation program returned status 2 (empty stderr)");
}
else {
warn_syslog("External account validation program returned status 2: " . $_) for @{$fnret->value->{'stderr'} || []};
}
}
if ($fnret->value->{'status'} == 4) {
if (!$fnret->value->{'stderr'}) {
osh_warn("External account validation program returned status 2 (empty stderr)");
}
else {
osh_warn("External account validation program returned status 2: " . $_) for @{$fnret->value->{'stderr'} || []};
}
}
if ($fnret->value->{'status'} >= 2 && $fnret->value->{'status'} <= 4) {
return R('ERR_UNKNOWN');
}
return R('KO_INACTIVE_ACCOUNT');
}
sub json_output { ## no critic (ArgUnpacking)
my $R = shift;
my %params = @_;
my $force_default = $params{'force_default'};
my $command = $params{'command'} || $ENV{'PLUGIN_NAME'};
my $JsonObject = JSON->new->utf8;
$JsonObject = $JsonObject->convert_blessed(1);
if ($ENV{'PLUGIN_JSON'} eq 'PRETTY' and not $force_default) {
$JsonObject->pretty(1);
}
my $encoded_json = $JsonObject->encode({error_code => $R->err, error_message => $R->msg, command => $command, value => $R->value});
# rename forbidden strings
$encoded_json =~ s/JSON_(START|OUTPUT|END)/JSON__$1/g;
if ($ENV{'PLUGIN_JSON'} eq 'GREP' and not $force_default) {
$encoded_json =~ tr/\r\n/ /;
print "\nJSON_OUTPUT=$encoded_json\n";
}
else {
print "\nJSON_START\n$encoded_json\nJSON_END\n";
}
return;
}
sub osh_header {
my $text = shift || '';
require Sys::Hostname;
my $hostname = Sys::Hostname::hostname();
my $versionline = 'the-bastion-' . $VERSION;
my $output = '';
$output .= colored('---' . $hostname . '-' x (80 - length($hostname) - length($versionline) - 6) . "$versionline---" . "\n", 'bold blue');
$output .= colored("=> $text\n", "blue");
$output .= colored('-' x 80 . "\n", 'blue');
print $output unless ($ENV{'PLUGIN_QUIET'});
return;
}
sub osh_footer {
my $text = shift;
if (not defined $text) {
$text = $ENV{'PLUGIN_NAME'};
}
my $output = '';
$output .= colored('-' x (80 - length($text) - 6) . "</$text>---" . "\n", 'bold blue');
print $output unless ($ENV{'PLUGIN_QUIET'});
return;
}
# Used to exit plugins. Can be used in several ways:
# With an R object: osh_exit(R('OK', value => {}, msg => "okey"))
# Or with 1 value, that will be taken as the R->err: osh_exit('OK')
# Or with 2 values, that will be taken as err, msg: osh_exit('ERR_UNKNOWN', 'Unexpected error')
# With more values, they'll be used as constructor for an R object
sub osh_exit { ## no critic (ArgUnpacking)
my $R;
if (@_ == 1) {
$R = ref $_[0] eq 'OVH::Result' ? $_[0] : R($_[0]);
}
elsif (@_ == 2) {
my $err = shift || 'OK';
my $msg = shift;
$R = R($err, msg => $msg);
}
else {
$R = R(@_);
}
if (!$R) {
OVH::Bastion::osh_crit($R->msg);
}
elsif ($R->msg ne $R->err) {
OVH::Bastion::osh_info($R->msg);
}
if ($ENV{'PLUGIN_JSON'}) {
OVH::Bastion::json_output($R);
}
osh_footer();
exit($R ? OVH::Bastion::EXIT_OK : OVH::Bastion::EXIT_PLUGIN_ERROR);
}
sub osh_ok { ## no critic (ArgUnpacking)
my $R = ref $_[0] eq 'OVH::Result' ? $_[0] : R('OK', value => $_[0], msg => $_[1]);
if ($R->msg ne $R->err) {
OVH::Bastion::osh_info($R->msg);
}
if ($ENV{'PLUGIN_JSON'}) {
OVH::Bastion::json_output($R);
}
osh_footer();
exit OVH::Bastion::EXIT_OK;
}
# HEXIT aka "helper exit", used by helper scripts found in helpers/
# Can be used in several ways:
# With an R object: HEXIT(R('OK', value => {}, msg => "okey"))
# Or with 1 value, that will be taken as the R->err: HEXIT('OK')
# Or with 2 values, that will be taken as err, msg: HEXIT('ERR_UNKNOWN', 'Unexpected error')
# With more values, they'll be used as constructor for an R object
sub HEXIT { ## no critic (ArgUnpacking)
my $R;
if (@_ == 1) {
$R = ref $_[0] eq 'OVH::Result' ? $_[0] : R($_[0]);
}
elsif (@_ == 2) {
my $err = shift || 'OK';
my $msg = shift;
$R = R($err, msg => $msg);
}
else {
$R = R(@_);
}
OVH::Bastion::json_output($R, force_default => 1);
exit 0;
}
sub osh_debug {
my $text = shift;
if (($ENV{'PLUGIN_DEBUG'} or $ENV{'OSH_DEBUG'}) and not $ENV{'PLUGIN_QUIET'}) {
foreach my $line (split /^/, $text) {
chomp $line;
print STDERR colored("~ <$$:$0> $line", 'bold black') . "\n";
}
}
return;
}
sub osh_info {
return _osh_log(text => shift, color => 'blue', onlyPrefix => 1);
}
sub osh_warn {
return _osh_log(text => shift, color => 'magenta');
}
sub osh_crit {
return _osh_log(text => shift, color => 'red bold');
}
sub _osh_log {
my %params = @_;
my $output = $ENV{'FORCE_STDERR'} ? *STDERR : *STDOUT;
if ($ENV{'PLUGIN_QUIET'}) {
print $output $params{'text'} . "\n";
}
else {
foreach my $line (split /^/, $params{'text'}) {
chomp $line;
if ($params{'onlyPrefix'}) {
print $output colored('~ ', $params{'color'}) . "$line\n";
}
else {
print $output colored("~ $line", $params{'color'}) . "\n";
}
}
}
return;
}
sub is_valid_ip {
my %params = @_;
my $ip = $params{'ip'};
my $allowPrefixes = $params{'allowPrefixes'}; # if not, a /24 or /32 notation is rejected
my $fast = $params{'fast'}; # fast mode: avoid instantiating Net::IP... except if ipv6
if ($fast and $ip !~ m{:}) {
# fast asked and it's not an IPv6, regex ftw
if ($ip =~ m{^(?<shortip>(?<x1>[0-9]{1,3})\.(?<x2>[0-9]{1,3})\.(?<x3>[0-9]{1,3})\.(?<x4>[0-9]{1,3}))((?<slash>/)(?<prefix>\d+))?$}) { ## no critic (ProhibitUnusedCapture)
if (defined $+{'prefix'} and not $allowPrefixes) {
return R('KO_INVALID_IP', msg => "Invalid IP address ($ip), as prefixes are not allowed");
}
foreach my $key (qw{ x1 x2 x3 x4 }) {
return R('KO_INVALID_IP', msg => "Invalid IP address ($ip)") if (not defined $+{$key} or $+{$key} > 255);
}
if (defined $+{'prefix'} and $+{'prefix'} > 32) {
return R('KO_INVALID_IP', msg => "Invalid IP address ($ip)");
}
if (defined $+{'slash'} and not defined $+{'prefix'}) {
# got a / in $ip but it's not followed by \d+
return R('KO_INVALID_IP', msg => "Invalid IP address ($ip)");
}
return R('OK', value => {ip => $ip}) if (defined $+{'prefix'} && $+{'prefix'} != 32);
return R('OK', value => {ip => $+{'shortip'}});
}
return R('KO_INVALID_IP', msg => "Invalid IP address ($ip)");
}
require Net::IP;
my $IpObject = Net::IP->new($ip);
if (not $IpObject) {
return R('KO_INVALID_IP', msg => "Invalid IP address ($ip)");
}
my $shortip = $IpObject->prefix;
# if /32 or /128, omit the /prefixlen on $shortip
my $type = 'prefix';
if ( ($IpObject->version == 4 and $IpObject->prefixlen == 32)
or ($IpObject->version == 6 and $IpObject->prefixlen == 128))
{
$shortip =~ s'/\d+$'';
$type = 'single';
}
if (not $allowPrefixes and $type eq 'prefix') {
return R('KO_INVALID_IP', msg => "Invalid IP address ($ip), as prefixes are not allowed");
}
return R('OK', value => {ip => $shortip, prefix => $IpObject->prefix, prefixlen => $IpObject->prefixlen, version => $IpObject->version, type => $type});
}
sub is_valid_port {
my %params = @_;
my $port = $params{'port'};
if ($port =~ /^(\d+)$/ && $port > 0 && $port <= 65535) {
return R('OK', value => $1);
}
return R('ERR_INVALID_PARAMETER', msg => "Port must be numeric and 0 < port <= 65535");
}
sub is_valid_remote_user {
my %params = @_;
my $user = $params{'user'};
if ($user =~ /^([a-zA-Z0-9._!-]{1,32})$/) {
return R('OK', value => $1);
}
return R('ERR_INVALID_PARAMETER', msg => "Specified user doesn't seem to be valid");
}
sub touch_file {
my $file = shift;
my $perms = shift;
my $ret;
my $fh;
if (defined $perms) {
$ret = sysopen($fh, $file, O_RDWR | O_CREAT, $perms);
}
else {
$ret = sysopen($fh, $file, O_RDWR | O_CREAT);
}
if ($ret) {
close($fh);
utime(undef, undef, $file); # update mod/access time to now
# just in case we didn't create the file, and $perms is specified, chmod the file
chmod $perms, $file if $perms;
return R('OK');
}
# else
return R('KO', msg => "Couldn't create file $file: $!");
}
sub get_plugin_list {
my %params = @_;
my $restrictedOnly = $params{'restrictedOnly'};
my %plugins;
foreach my $dir (
$OVH::Bastion::BASEPATH . '/bin/plugin/open',
$OVH::Bastion::BASEPATH . '/bin/plugin/group-gatekeeper',
$OVH::Bastion::BASEPATH . '/bin/plugin/group-aclkeeper',
$OVH::Bastion::BASEPATH . '/bin/plugin/group-owner',
$OVH::Bastion::BASEPATH . '/bin/plugin/restricted',
$OVH::Bastion::BASEPATH . '/bin/plugin/admin',
)
{
if (opendir(my $dh, $dir)) {
while (my $file = readdir($dh)) {
# if exists, will be overwritten, that's why the order of foreach(dir) is important,
# from most open to most restricted (but plugins should never have the same name anyway)
$plugins{$file} = {name => $file, dir => $dir} if ($file !~ /\./);
}
close($dh);
}
}
if ($restrictedOnly) {
foreach my $plugin (keys %plugins) {
delete $plugins{$plugin} if $plugins{$plugin}->{'dir'} !~ m{/restricted$};
}
}
return R('OK', value => \%plugins);
}
sub can_account_execute_plugin {
my %params = @_;
my $account = $params{'account'} || OVH::Bastion::get_user_from_env()->value;
my $plugin = $params{'plugin'};
my $fnret;
if (not $plugin or not $account) {
return R('ERR_MISSING_PARAMETER', msg => "Missing mandatory param account or plugin");
}
# sanitize for -T
my ($sanePlugin) = $plugin =~ /^([a-zA-Z0-9_-]+)$/;
if ($plugin ne $sanePlugin) {
return R('ERR_INVALID_PARAMETER', msg => "Parameter 'plugin' contains invalid characters");
}
$plugin = $sanePlugin;
my $path_plugin = $OVH::Bastion::BASEPATH . '/bin/plugin';
# first, check if the plugin is readonly-proof if we are in readonly mode (slave)
$fnret = OVH::Bastion::config('readOnlySlaveMode');
$fnret or return $fnret;
if ($fnret->value and not OVH::Bastion::is_plugin_readonly_proof(plugin => $plugin)) {
return R('ERR_READ_ONLY',
msg => "You can't use this command on this bastion instance, as this is a write/modify command,\n"
. "and this bastion instance is read-only (slave). Please do this on the master instance of my cluster instead!");
}
# realm accounts are very restricted
if ($account =~ m{^realm_}) {
return R('ERR_SECURITY_VIOLATION', msg => "Realm support accounts can't execute any plugin by themselves");
}
if ($account =~ m{/} && !grep { $plugin eq $_ } qw{ alive help info mtr nc ping selfForgetHostKey selfListAccesses selfListEgressKeys }) {
return R('ERR_REALM_USER', msg => "Realm accounts can't execute this plugin, use --osh help to get the allowed plugin list");
}
# open plugins, always start to look there
if (-f ($path_plugin . '/open/' . $plugin)) {
return R('OK', value => {fullpath => $path_plugin . '/open/' . $plugin, type => 'open', plugin => $plugin});
}
# aclkeeper/gatekeepers/owners plugins
if (-f ($path_plugin . '/group-aclkeeper/' . $plugin) or -f ($path_plugin . '/group-gatekeeper/' . $plugin) or -f ($path_plugin . '/group-owner/' . $plugin)) {
# need to parse group to see if maybe member of group-gatekeeper or group-owner (or super owner)
my %canDo = (gatekeeper => 0, aclkeeper => 0, owner => 0);
$fnret = OVH::Bastion::get_user_groups(extra => 1, account => $account);
my @userGroups = $fnret ? @{$fnret->value} : ();
foreach my $type (qw{ aclkeeper gatekeeper owner }) {
if (-f "$path_plugin/group-$type/$plugin") {
# we can always execute these commands if we are a super owner
my $canDo = OVH::Bastion::is_super_owner(account => $account) ? 1 : 0;
# or if we are $type on at least one group
$canDo += grep { /^key.*-\Q$type\E$/ } @userGroups;
return R(
'OK',
value => {
fullpath => "$path_plugin/group-$type/$plugin",
type => "group-$type",
plugin => $plugin
}
) if $canDo;
return R(
'KO_PERMISSION_DENIED',
value => {type => "group-type", plugin => $plugin},
msg => "Sorry, you must be a group $type to use this command"
);
}
}
# unreachable code:
return R('KO_PERMISSION_DENIED', value => {type => 'group-unknown', plugin => $plugin}, msg => "Permission denied");
}
# restricted plugins (osh-* system groups based)
if (-f ($path_plugin . '/restricted/' . $plugin)) {
if (OVH::Bastion::is_user_in_group(user => $account, group => "osh-$plugin")) {
return R('OK', value => {fullpath => $path_plugin . '/restricted/' . $plugin, type => 'restricted', plugin => $plugin});
}
else {
return R(
'KO_PERMISSION_DENIED',
value => {type => 'restricted', plugin => $plugin},
msg => "Sorry, this command is restricted and requires you to be specifically granted"
);
}
}
# admin plugins
if (-f ($path_plugin . '/admin/' . $plugin)) {
if (OVH::Bastion::is_admin(account => $account)) {
return R('OK', value => {fullpath => $path_plugin . '/admin/' . $plugin, type => 'admin', plugin => $plugin});
}
else {
return R(
'KO_PERMISSION_DENIED',
value => {type => 'admin', plugin => $plugin},
msg => "Sorry, this command is only available to bastion admins"
);
}
}
# still here ? sorry.
return R('KO_UNKNOWN_PLUGIN', value => {type => 'open'}, msg => "Unknown command");
}
sub is_plugin_readonly_proof {
my %params = @_;
my $plugin = $params{'plugin'};
if (not defined $plugin) {
return R('ERR_MISSING_PARAMETER', msg => "Missing parameter 'plugin'");
}
my $fnret = OVH::Bastion::plugin_config(plugin => $plugin, key => "master_only");
if ($fnret && $fnret->value) {
return R('KO_NOT_READONLY', msg => "Plugin not allowed in readonly mode");
}
# if not "1" or not defined, default to allow on master or slaves
return R('OK');
}
sub set_terminal_mode_for_plugin {
my %params = @_;
my $plugin = $params{'plugin'};
my $action = $params{'action'};
if (my @missingParameters = grep { not defined $params{$_} } qw{ plugin action }) {
local $" = ', ';
return R('ERR_MISSING_PARAMETER', "Missing mandatory parameter(s): @missingParameters");
}
if (not grep { $action eq $_ } qw{ set restore }) {
return R('ERR_INVALID_PARAMETER', "Parameter 'action' is invalid, expected either 'set' or 'restore'");
}
my $mode;
my $fnret = OVH::Bastion::plugin_config(plugin => $plugin, key => "terminal_mode");
if ($fnret && defined $fnret->value) {
if (grep { $fnret->value eq $_ } qw{ noecho cbreak raw }) {
$mode = $fnret->value;
}
else {
osh_warn("Invalid terminal configuration setup for plugin $plugin, please report to your sysadmin!");
}
}
# noecho: user might type passwords there
# cbreak: only allow CTRL+C
# raw: block CTRL+C
return R('OK_NOT_NEEDED') if not defined $mode;
$mode = 'restore' if $action eq 'restore';
require Term::ReadKey;
Term::ReadKey::ReadMode($mode);
return R('OK'); # ReadMode returns nothing :(
}
sub generate_uniq_id {
require Digest::SHA;
return R('OK', value => unpack("H12", Digest::SHA::sha512(pack("SLL", $$, time, int(rand(2**32))))));
}
sub get_user_from_env {
my ($sanitized) = (getpwuid($>))[0] =~ /([0-9a-zA-Z_.-]+)/;
return R('OK', value => $sanitized);
}
sub get_home_from_env {
my ($sanitized) = (getpwuid($>))[7] =~ m{^([a-zA-Z0-9_/.-]+)$};
$sanitized =~ s/\.+/./g; # disallow 2 or more consecutive dots, i.e. "john.doe" is ok, "john/../../../etc/passwd" is not
return R('OK', value => $sanitized);
}
sub get_passfile {
my %params = @_;
my $nameHint = $params{'hint'};
my $context = $params{'context'};
my $tryLegacy = $params{'tryLegacy'};
my $self = $params{'self'} || OVH::Bastion::get_user_from_env()->value;
$nameHint =~ s/[^a-zA-Z0-9_.-]//g;
if ($context eq 'self') {
# in this case, we look into the $self home dir
my $home = OVH::Bastion::get_home_from_env()->value;
my $passFile = "$home/pass/$self";
return R('OK', value => $passFile) if (-f -r $passFile);
}
elsif ($context eq 'group') {
# new mode: nameHint is actually the name of a group (technically, shortGroup)
my $passFile = "/home/key$nameHint/pass/$nameHint";
return R('OK', value => $passFile) if (-f -r $passFile);
if ($tryLegacy) {
# auto fall back to legacy mode: nameHint is a file under the global /home/passkeeper directory
$passFile = "/home/passkeeper/$nameHint";
return R('OK', value => $passFile) if (-f -r $passFile);
}
}
elsif ($context eq 'legacy') {
# legacy mode only: nameHint is a file under the global /home/passkeeper directory
my $passFile = "/home/passkeeper/$nameHint";
return R('OK', value => $passFile) if (-f -r $passFile);
}
return R('KO_PASSFILE_NOT_FOUND', msg => "Unable to find (or read) a password file in context '$context' and name '$nameHint'");
}
sub build_ttyrec_cmdline {
my %params = @_;
if (!$params{'home'}) {
return R('ERR_MISSING_PARAMETER', msg => "Missing home parameter");
}
if (!$params{'ip'}) {
return R('ERR_MISSING_PARAMETER', msg => "Missing ip parameter");
}
# build ttyrec filename format
my $bastionName = OVH::Bastion::config('bastionName')->value;
my $ttyrecFilenameFormat = OVH::Bastion::config('ttyrecFilenameFormat')->value;
$ttyrecFilenameFormat =~ s/&bastionname/$bastionName/g;
$ttyrecFilenameFormat =~ s/&uniqid/$params{'uniqid'}/g if $params{'uniqid'};
$ttyrecFilenameFormat =~ s/&ip/$params{'ip'}/g if $params{'ip'};
$ttyrecFilenameFormat =~ s/&port/$params{'port'}/g if $params{'port'};
$ttyrecFilenameFormat =~ s/&user/$params{'user'}/g if $params{'user'};
$ttyrecFilenameFormat =~ s/&account/$params{'account'}/g if $params{'account'};
if ($ttyrecFilenameFormat =~ /&(bastionname|uniqid|ip|port|user|account)/) {
# if we still have a placeholder here, then we were missing parameters
return R('ERR_MISSING_PARAMETER', msg => "Missing bastionname, uniqid, ip, port, user or account in ttyrec cmdline building");
}
# ensure there are no '/'
$ttyrecFilenameFormat =~ tr{/}{_};
# preprend (and create) directory
my $saveDir = $params{'home'} . "/ttyrec";
mkdir($saveDir);
if ($params{'realm'} && $params{'remoteaccount'}) {
$saveDir .= "/" . $params{'remoteaccount'};
mkdir($saveDir);
}
$saveDir .= "/" . $params{'ip'};
mkdir($saveDir);
my $saveFileFormat = "$saveDir/$ttyrecFilenameFormat";
# also build the first ttyrec filename ourselves
my $saveFile = $saveFileFormat;
$saveFile = strftime($saveFile, localtime(time));
if ($saveFile =~ /#usec#/) {
require Time::HiRes;
my $usec = sprintf("%06d", (Time::HiRes::gettimeofday())[1]);
$saveFile =~ s{#usec#}{$usec}g;
}
# forge ttyrec command
my $idleKillTimeout = OVH::Bastion::config('idleKillTimeout')->value;
my $idleLockTimeout = OVH::Bastion::config('idleLockTimeout')->value;
my $warnBeforeLockSeconds = OVH::Bastion::config('warnBeforeLockSeconds')->value;
my $warnBeforeKillSeconds = OVH::Bastion::config('warnBeforeKillSeconds')->value;
my @ttyrec = ('ttyrec', '-f', $saveFile, '-F', $saveFileFormat);
push @ttyrec, '-v' if $params{'debug'};
push @ttyrec, '-T', 'always' if $params{'tty'};
push @ttyrec, '-T', 'never' if $params{'notty'};
my $fnret = OVH::Bastion::account_config(account => $params{'account'}, key => OVH::Bastion::OPT_ACCOUNT_IDLE_IGNORE, public => 1);
if ($fnret && $fnret->value =~ /yes/) {
osh_debug("Account is immune to idle, not adding ttyrec commandline parameters");
}
else {
push @ttyrec, '-k', $idleKillTimeout if $idleKillTimeout;
push @ttyrec, '-t', $idleLockTimeout if $idleLockTimeout;
push @ttyrec, '-s', "To unlock, use '--osh unlock' from another console" if $idleLockTimeout;
push @ttyrec, '--warn-before-lock', $warnBeforeLockSeconds if $warnBeforeLockSeconds;
push @ttyrec, '--warn-before-kill', $warnBeforeKillSeconds if $warnBeforeKillSeconds;
}
my $ttyrecAdditionalParameters = OVH::Bastion::config('ttyrecAdditionalParameters')->value;
push @ttyrec, @$ttyrecAdditionalParameters if @$ttyrecAdditionalParameters;
return R('OK', value => {saveFile => $saveFile, cmd => \@ttyrec});
}
sub do_pamtester {
my %params = @_;
my $sysself = $params{'sysself'};
my $self = $params{'self'};
my $fnret;
if (!$sysself || !$self) {
return R('ERR_MISSING_PARAMETER', msg => "Missing mandatory arguments 'sysself' or 'self'");
}
# use system() instead of OVH::Bastion::execute() because we need it to grab the term
my $pamtries = 3;
while (1) {
my $pamsysret;
if (OVH::Bastion::is_freebsd()) {
$pamsysret = system('sudo', '-n', '-u', 'root', '--', '/usr/bin/env', 'pamtester', 'sshd', $sysself, 'authenticate');
}
else {
$pamsysret = system('pamtester', 'sshd', $sysself, 'authenticate');
}
if ($pamsysret < 0) {
return R('KO_MFA_FAILED', msg => "MFA is required for this host, but this bastion is missing the `pamtester' tool, aborting");
}
elsif ($pamsysret != 0) {
if (--$pamtries <= 0) {
return R('KO_MFA_FAILED', msg => "Sorry, but Multi-Factor Authentication failed, I can't connect you to this host");
}
next;
}
# success, if we are configured to launch a external command on pamtester success, do it.
# see the bastion.conf.dist file for usage example.
my $MFAPostCommand = OVH::Bastion::config('MFAPostCommand')->value;
if (ref $MFAPostCommand eq 'ARRAY' && @$MFAPostCommand) {
s/%ACCOUNT%/$self/g for @$MFAPostCommand;
$fnret = OVH::Bastion::execute(cmd => $MFAPostCommand, must_succeed => 1);
if (!$fnret) {
warn_syslog("MFAPostCommand returned a non-zero value: " . $fnret->msg);
}
}
last;
}
return R('OK_MFA_SUCCESS');
}
1;