2020-10-16 00:32:37 +08:00
package OVH::Bastion ;
# vim: set filetype=perl ts=4 sw=4 sts=4 et:
use common::sense ;
use Fcntl ;
use POSIX qw( strftime ) ;
2020-10-30 18:26:01 +08:00
our $ VERSION = '3.00.00' ;
2020-10-16 00:32:37 +08:00
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 thoses 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 ,
} ;
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' ,
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 wether 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 instanciating 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 } ) ;
}
1 ;