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.03'; 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 => [['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', OPT_ACCOUNT_OSH_ONLY => 'osh_only', }; ########### # 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{ sysret2human 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) . "---" . "\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{^(?(?[0-9]{1,3})\.(?[0-9]{1,3})\.(?[0-9]{1,3})\.(?[0-9]{1,3}))((?/)(?\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,128})$/) { 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;