the-bastion/lib/perl/OVH/Bastion.pm
Stéphane Lesimple a479810d83
feat: revamp logs
All connections and plugin executions emit two logs, an 'open' and
a 'close' log. We now add all the details of the connection to
the 'close' logs, those that were previously only available in the
corresponding 'open' log. This way, it is no longer required to
correlate both logs with their uniqid to have all the data:
the 'close' log should suffice. The 'open' log is still there if
for some reason the 'close' log can't be emitted (kill -9, system
crash, etc.), or if the 'open' and the 'close' log are several
hours, days or months appart.

An additional field "duration" has been added to the 'close' logs,
this represents the number of seconds (with millisecond precision)
the connection lasted.

Two new fields "globalsql" and "accountsql" have been added to the
'open'-type logs. These will contain either "ok" if we successfully
logged to the corresponding log database, "no" if it is disabled,
or "error $aDetailedMessage" if we got an error trying to insert
the row. The 'close'-type log also has the new "accountsql_close"
field, but misses the "globalsql_close" field as we never update
the global database on this event. On the 'close' log, we can also
have the value "missing", indicating that we couldn't update the
access log row in the database, as the corresponding 'open' log
couldn't insert it.

The "ttyrecsize" log field for the 'close'-type logs has been removed,
as it was never completely implemented, and contains bogus data if
ttyrec log rotation occurs. It has also been removed from the sqlite
log databases.

The 'open' and 'close' events are now pushed to our own log files,
in addition to syslog, if logging to those files is enabled (see
``enableGlobalAccesssLog`` and ``enableAccountAccessLog``), previously
the 'close' events were only pushed to syslog.

The /home/osh.log is no longer used for ``enableGlobalAccessLog``, the
global log is instead written to /home/logkeeper/global-log-YYYYMM.log.

The global sql file, enabled with ``enableGlobalSqlLog``, is now
split by year-month instead of by year, to
/home/logkeeper/global-log-YYYYMM.sqlite.
2020-12-29 16:14:50 +00:00

975 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.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) . "</$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,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;