the-bastion/lib/perl/OVH/Bastion/configuration.inc
2021-11-03 15:50:10 +01:00

850 lines
37 KiB
Perl

# vim: set filetype=perl ts=4 sw=4 sts=4 et:
package OVH::Bastion;
use common::sense;
use JSON;
use Fcntl qw{ :mode :DEFAULT };
sub load_configuration_file {
my %params = @_;
my $file = $params{'file'};
# if $secure is set, won't load the file if it's not writable by root only
# it won't allow symlinks either
my $secure = $params{'secure'};
# if $rootonly is set, the $secure restriction apply, and
# in addition we won't load the file if it's o+r
my $rootonly = $params{'rootonly'};
if ($secure || $rootonly) {
my @stat = lstat($file);
if (@stat) {
if ($stat[4] != 0 or $stat[5] != 0) {
return R('ERR_SECURITY_VIOLATION', msg => "Configuration file ($file) is not owned by root, report to your sysadmin.");
}
if (!S_ISREG($stat[2])) {
return R('ERR_SECURITY_VIOLATION', msg => "Configuration file ($file) is not a regular file, report to your sysadmin.");
}
if (S_IMODE($stat[2]) & S_IWOTH) {
return R('ERR_SECURITY_VIOLATION', msg => "Configuration file ($file) is world-writable, report to your sysadmin.");
}
if ($rootonly && S_IMODE($stat[2]) & S_IROTH) {
return R('ERR_SECURITY_VIOLATION', msg => "Configuration file ($file) is world-readable, report to your sysadmin.");
}
}
# no @stat ? file doesn't exist, we'll error just below
}
return OVH::Bastion::json_load(file => $file);
}
sub main_configuration_directory {
if (!-d "/etc/bastion" && -d "/usr/local/etc/bastion") {
# if this dir exists and /etc/bastion doesn't, use /usr/local
return "/usr/local/etc/bastion";
}
elsif (!-d "/etc/bastion" && -d "/usr/pkg/etc/bastion") {
# if this dir exists and /etc/bastion doesn't, use /usr/local
return "/usr/pkg/etc/bastion";
}
# use /etc in all other cases
return "/etc/bastion";
}
my $_cache_config = undef;
sub load_configuration {
my %params = @_;
my $mock_data = $params{'mock_data'};
my $noisy = $params{'noisy'}; # print warnings/errors on stdout in addition to syslog
my $test = $params{'test'}; # noisy + also print missing configuration options
# do NOT use warn_syslog in this func, or any other function that needs to read configuration,
# or we might end up in an infinite loop: store errors we wanna log at the end
my @errors;
$noisy = 1 if $test;
if (defined $mock_data) {
if (!OVH::Bastion::is_mocking()) {
# if we're overriding configuration with mock_data without being in mocking mode, we have a problem
die("Attempted to load_configuration() with mock_data without being in mocking mode");
}
# mock data always overrides cache
undef $_cache_config;
}
if (ref $_cache_config eq 'HASH') {
return R('OK', value => $_cache_config);
}
my $C;
if (!$mock_data) {
my $file = OVH::Bastion::main_configuration_directory() . "/bastion.conf";
# check that file exists and is readable
if (not -r $file) {
return R('ERR_CANNOT_LOAD_CONFIGURATION', msg => "Configuration file $file does not exist or is not readable");
}
$C = OVH::Bastion::load_configuration_file(file => $file, secure => 1);
$C or return $C;
$C = $C->value;
}
else {
$C = $mock_data;
}
# define deprecated vs new key names association
# new old
my %new2old = qw(
accountCreateDefaultPersonalAccesses accountCreateDefaultPrivateAccesses
adminAccounts adminLogins
allowedIngressSshAlgorithms allowedSshAlgorithms
allowedEgressSshAlgorithms allowedSshAlgorithms
bastionCommand cacheCommand
bastionName cacheName
ingressKeysFrom ipWhiteList
ingressKeysFromAllowOverride ipWhiteListAllowOverride
minimumIngressRsaKeySize minimumRsaKeySize
minimumEgressRsaKeySize minimumRsaKeySize
egressKeysFrom personalKeyFrom
);
# if we're missing some new key names, look for old keys and take their value
while (my ($new, $old) = each %new2old) {
$C->{$new} //= $C->{$old};
}
####################################################
# now validate, lint, normalize and untaint the conf
my %unknownkeys = map { $_ => 1 } keys %$C;
# 1/6) Options that are strings, and must match given regex. Always include capturing parens in regex for untainting.
foreach my $o (
{name => 'bastionName', default => 'fix-my-config-please-missing-bastion-name', validre => qr/^([a-zA-Z0-9_.-]+)$/},
{name => 'bastionCommand', default => "ssh ACCOUNT\@HOSTNAME -t -- ", validre => qr/^(.+)$/},
{name => 'defaultLogin', default => "", validre => qr/^([a-zA-Z0-9_.-]*)$/, emptyok => 1},
{name => 'moshCommandLine', default => "", validre => qr/^(.*)$/, emptyok => 1},
{name => 'documentationURL', default => "https://ovh.github.io/the-bastion/", validre => qr'^([a-zA-Z0-9:/@&=",;_.# -]+)$'},
{name => 'syslogFacility', default => 'local7', validre => qr/^([a-zA-Z0-9_]+)$/},
{name => 'syslogDescription', default => 'bastion', validre => qr/^([a-zA-Z0-9_.-]+)$/},
{name => 'ttyrecFilenameFormat', default => '%Y-%m-%d.%H-%M-%S.#usec#.&uniqid.&account.&user.&ip.&port.ttyrec', validre => qr/^([a-zA-Z0-9%&#_.-]+)$/},
{name => 'accountExpiredMessage', default => '', validre => qr/^(.*)$/, emptyok => 1},
{name => 'fanciness', default => 'full', validre => qr/^((none|boomer)|(basic|millenial)|(full|genz))$/},
{name => 'accountExternalValidationProgram', default => '', validre => qr'^([a-zA-Z0-9/$_.-]*)$', emptyok => 1},
)
{
if (!$C->{$o->{'name'}} && !$o->{'emptyok'}) {
$C->{$o->{'name'}} = $o->{'default'};
push @errors, "Configuration error: missing option '" . $o->{'name'} . "', defaulting to '" . $o->{'default'} . "'" if $test;
}
if ($C->{$o->{'name'}} =~ $o->{'validre'}) {
# untaint
$C->{$o->{'name'}} = $1;
}
else {
push @errors,
"Configuration error: value of option '" . $o->{'name'} . "' ('" . $C->{$o->{'name'}} . "') didn't match allowed regex, defaulting to '" . $o->{'default'} . "'";
$C->{$o->{'name'}} = $o->{'default'};
}
delete $unknownkeys{$o->{'name'}};
}
# 2/6) Options that must be numbers, between min and max.
foreach my $o (
{name => 'accountUidMin', min => 100, max => 999_999_999, default => 2000},
{name => 'accountUidMax', min => 100, max => 999_999_999, default => 99999},
{name => 'ttyrecGroupIdOffset', min => 1, max => 999_999_999, default => 100_000},
{name => 'minimumIngressRsaKeySize', min => 1024, max => 16384, default => 2048},
{name => 'minimumEgressRsaKeySize', min => 1024, max => 16384, default => 2048},
{name => 'maximumIngressRsaKeySize', min => 1024, max => 32768, default => 8192},
{name => 'maximumEgressRsaKeySize', min => 1024, max => 32768, default => 8192},
{name => 'moshTimeoutNetwork', min => 0, max => 86400 * 365, default => 86400},
{name => 'moshTimeoutSignal', min => 0, max => 86400 * 365, default => 30},
{name => 'idleLockTimeout', min => 0, max => 86400 * 365, default => 0},
{name => 'idleKillTimeout', min => 0, max => 86400 * 365, default => 0},
{name => 'warnBeforeLockSeconds', min => 0, max => 86400 * 365, default => 0},
{name => 'warnBeforeKillSeconds', min => 0, max => 86400 * 365, default => 0},
{name => 'MFAPasswordInactiveDays', min => -1, max => 365 * 5, default => -1},
{name => 'MFAPasswordMinDays', min => 0, max => 365 * 5, default => 0},
{name => 'MFAPasswordMaxDays', min => 0, max => 365 * 5, default => 90},
{name => 'MFAPasswordWarnDays', min => 0, max => 365 * 5, default => 15},
{name => 'sshClientDebugLevel', min => 0, max => 3, default => 0},
{name => 'accountMaxInactiveDays', min => 0, max => 365 * 5, default => 0},
{name => 'interactiveModeTimeout', min => 0, max => 86400 * 365, default => 15},
{name => 'interactiveModeProactiveMFAexpiration', min => 0, max => 86400, default => 900},
)
{
if (not defined $C->{$o->{'name'}}) {
$C->{$o->{'name'}} = $o->{'default'};
push @errors, "Configuration error: missing option '" . $o->{'name'} . "', defaulting to " . $o->{'default'} if $test;
}
if ($C->{$o->{'name'}} =~ /^(-?\d+)$/) {
# untaint
$C->{$o->{'name'}} = $1;
}
else {
push @errors, "Configuration error: value of option '" . $o->{'name'} . "' ('" . $C->{$o->{'name'}} . "') is not a number, defaulting to " . $o->{'default'};
$C->{$o->{'name'}} = $o->{'default'};
}
if ($C->{$o->{'name'}} > $o->{'max'}) {
push @errors,
"Configuration error: value of option '"
. $o->{'name'} . "' ("
. $C->{$o->{'name'}}
. ") is higher than allowed value ("
. $o->{'max'}
. "), defaulting to "
. $o->{'default'};
$C->{$o->{'name'}} = $o->{'default'};
}
elsif ($C->{$o->{'name'}} < $o->{'min'}) {
push @errors,
"Configuration error: value of option '"
. $o->{'name'} . "' ("
. $C->{$o->{'name'}}
. ") is lower than allowed value ("
. $o->{'min'}
. "), defaulting to "
. $o->{'default'};
$C->{$o->{'name'}} = $o->{'default'};
}
delete $unknownkeys{$o->{'name'}};
}
# 3/6) Booleans. Standard true/false values should be used in the JSON config file, but we normalize non-bool values here.
# We cast the strings "no", "false", "disabled" to false. The JSON null value is the same as omitting the option entirely,
# hence forcing the bastion to use the default value for this option.
# For all other values, standard Perl applies: 0, "0", "" are false, everything else is true.
# We warn where we have to cast, except for 0/1/"0"/"1" for backwards compatibility.
foreach my $tuple (
{
default => 1,
options => [
qw{
enableSyslog enableGlobalAccessLog enableAccountAccessLog enableGlobalSqlLog enableAccountSqlLog displayLastLogin
interactiveModeByDefault interactiveModeProactiveMFAenabled
}
],
},
{
default => 0,
options => [
qw{
interactiveModeAllowed readOnlySlaveMode sshClientHasOptionE ingressKeysFromAllowOverride
moshAllowed debug keyboardInteractiveAllowed passwordAllowed telnetAllowed remoteCommandEscapeByDefault
accountExternalValidationDenyOnFailure ingressRequirePIV
}
],
}
)
{
foreach my $o (@{$tuple->{'options'}}) {
# if not defined (option missing or set to null), set to default value
if (not defined $C->{$o}) {
$C->{$o} = $tuple->{'default'};
push @errors, "Configuration error: missing option '$o', defaulting to " . ($tuple->{'default'} ? 'true' : 'false') if $test;
}
# if a bool, it's ok, normalize to 0/1
elsif (JSON::is_bool($C->{$o})) {
$C->{$o} = $C->{$o} ? 1 : 0;
}
# if set to "no", "false" or "disabled", be nice and cast to false (because a string is true, otherwise)
elsif (grep { lc($C->{$o}) eq lc } qw{ no false disabled }) {
push @errors, "Configuration error: found value '" . $C->{$o} . "' for option '$o', but it's supposed to be a boolean, assuming false";
$C->{$o} = 0;
}
# if 0 or 1, normalize silently (backwards compatible)
elsif ($C->{$o} == 0 || $C->{$o} == 1) {
$C->{$o} = $C->{$o} ? 1 : 0;
}
# otherwise, normalize any true/false value to 1/0, and log a warning
else {
push @errors, "Configuration error: found value '" . $C->{$o} . "' for option '$o', but it's supposed to be a boolean, assuming " . ($C->{$o} ? 'true' : 'false');
$C->{$o} = $C->{$o} ? 1 : 0;
}
delete $unknownkeys{$o};
}
}
# 4/6) Strings that must be one item of a specific enum.
foreach my $o (
{name => 'defaultAccountEgressKeyAlgorithm', default => 'rsa', valid => [qw{ rsa ecdsa ed25519 }]},
{name => 'accountMFAPolicy', default => 'enabled', valid => [qw{ disabled enabled password-required totp-required any-required }]},
{name => 'TOTPProvider', default => 'google-authenticator', valid => [qw{ none google-authenticator duo }]},
)
{
# if not defined, set to default value
if (not defined $C->{$o->{'name'}}) {
$C->{$o->{'name'}} = $o->{'default'};
push @errors, "Configuration error: missing option '" . $o->{'name'} . "', defaulting to '" . (join(" ", @{$o->{'default'}})) . "'" if $test;
}
# must be one of the allowed values
elsif (my @untainted = grep { $C->{$o->{'name'}} eq $_ } @{$o->{'valid'}}) {
$C->{$o->{'name'}} = $untainted[0];
}
else {
push @errors, "Configuration error: option '" . $o->{'name'} . "' should be one of [" . (join(",", @{$o->{'valid'}})) . "] instead of '" . $C->{$o->{'name'}} . "'";
$C->{$o->{'name'}} = $o->{'default'};
}
delete $unknownkeys{$o->{'name'}};
}
# 5/6) Arrays whose values should match a specific regex.
foreach my $o (
## no critic(RegularExpressions::ProhibitFixedStringMatches)
{name => 'allowedIngressSshAlgorithms', default => [qw{ rsa ecdsa ed25519 }], validre => qr/^(rsa|ecdsa|ed25519)$/},
## no critic(RegularExpressions::ProhibitFixedStringMatches)
{name => 'allowedEgressSshAlgorithms', default => [qw{ rsa ecdsa ed25519 }], validre => qr/^(rsa|ecdsa|ed25519)$/},
{name => 'accountCreateSupplementaryGroups', default => [], validre => qr/^(.*)$/},
{name => 'accountCreateDefaultPersonalAccesses', default => [], validre => qr/^(.*)$/},
{name => 'alwaysActiveAccounts', default => [], validre => qr/^(.*)$/},
{name => 'superOwnerAccounts', default => [], validre => qr/^(.*)$/},
{name => 'ingressKeysFrom', default => [], validre => qr'^([0-9.:%/]+)$'},
{name => 'egressKeysFrom', default => [], validre => qr'^([0-9.:%/]+)$'},
{name => 'adminAccounts', default => [], validre => qr/^(.*)$/},
{name => 'allowedNetworks', default => [], validre => qr'^([0-9.:%/]+)$'},
{name => 'forbiddenNetworks', default => [], validre => qr'^([0-9.:%/]+)$'},
{name => 'ttyrecAdditionalParameters', default => [], validre => qr/^(.*)$/},
{name => 'MFAPostCommand', default => [], validre => qr/^(.*)$/},
)
{
# if not defined, set to default value
if (not defined $C->{$o->{'name'}}) {
$C->{$o->{'name'}} = $o->{'default'};
push @errors, "Configuration error: missing option '" . $o->{'name'} . "', defaulting to [" . (join(",", @{$o->{'default'}})) . "]" if $test;
}
# must be an array
elsif (ref $C->{$o->{'name'}} ne 'ARRAY') {
$C->{$o->{'name'}} = $o->{'default'};
push @errors, "Configuration error: options option '" . $o->{'name'} . "' should be an array, defaulting to [" . (join(" ", @{$o->{'default'}})) . "]";
}
# whose values validate the regex
else {
my @untainted;
foreach my $v (@{$C->{$o->{'name'}}}) {
if ($v =~ $o->{'validre'}) {
push @untainted, $1;
}
else {
push @errors,
"Configuration error: at least one of the values of the array defined by option '"
. $o->{'name'}
. "' is invalid, defaulting to ["
. (join(" ", @{$o->{'default'}})) . "]";
$C->{$o->{'name'}} = $o->{'default'};
last;
}
}
$C->{$o->{'name'}} = \@untainted;
}
delete $unknownkeys{$o->{'name'}};
}
# 6/6) Special cases and/or additional checks for already vetted options
# ... we must have enough room between min and max
if ($C->{'accountUidMin'} + 1000 > $C->{'accountUidMax'}) {
push @errors,
"Configuration error: 'accountUidMax' ("
. $C->{'accountUidMax'}
. ") is too close from 'accountUidMin' ("
. $C->{'accountUidMin'}
. "), setting accountUidMax="
. ($C->{'accountUidMin'} + 1000);
$C->{'accountUidMax'} = $C->{'accountUidMin'} + 1000;
}
# ... ttyrec group offset must be high enough to avoid overlap with the accounts uids
if ($C->{'ttyrecGroupIdOffset'} < $C->{'accountUidMax'} - $C->{'accountUidMin'}) {
my $fixed = ($C->{'accountUidMax'} - $C->{'accountUidMin'}) + 1;
push @errors, "Configuration error: the configured 'ttyrecGroupIdOffset' (" . $C->{'ttyrecGroupIdOffset'} . ") would overlap with account UIDs, setting it to $fixed";
$C->{'ttyrecGroupIdOffset'} = $fixed;
}
# ... ensure min <= max
foreach my $key (qw{ Ingress Egress }) {
my $minkey = "minimum${key}RsaKeySize";
my $maxkey = "maximum${key}RsaKeySize";
if ($C->{$minkey} > $C->{$maxkey}) {
push @errors, "Configuration error: '$minkey' (" . $C->{$minkey} . ") must be <= '$maxkey' (" . $C->{$maxkey} . "), setting $minkey=" . $C->{$maxkey};
$C->{$minkey} = $C->{$maxkey};
}
}
# ... defaultAccountEgressKeySize can only be checked after defaultAccountEgressKeyAlgorithm has been handled
{
if (not defined $C->{'defaultAccountEgressKeySize'}) {
$C->{'defaultAccountEgressKeySize'} = 0;
}
if ($C->{'defaultAccountEgressKeySize'} =~ /^(\d+)$/) {
$C->{'defaultAccountEgressKeySize'} = $1;
}
else {
push @errors, "Configuration error: value of option 'defaultAccountEgressKeySize' ('" . $C->{'defaultAccountEgressKeySize'} . "') is not a number, defaulting to 0";
$C->{'defaultAccountEgressKeySize'} = 0;
}
if ($C->{'defaultAccountEgressKeyAlgorithm'} eq 'rsa') {
$C->{'defaultAccountEgressKeySize'} ||= 4096;
if ($C->{'defaultAccountEgressKeySize'} < 1024) {
push @errors,
"Configuration error: value of option 'defaultAccountEgressKeySize' ('" . $C->{'defaultAccountEgressKeySize'} . "') should be >= 1024, defaulting to 1024";
$C->{'defaultAccountEgressKeySize'} = 1024;
}
elsif ($C->{'defaultAccountEgressKeySize'} > 32768) {
push @errors,
"Configuration error: value of option 'defaultAccountEgressKeySize' ('" . $C->{'defaultAccountEgressKeySize'} . "') should be >= 1024, defaulting to 1024";
$C->{'defaultAccountEgressKeySize'} = 32768;
}
}
elsif ($C->{'defaultAccountEgressKeyAlgorithm'} eq 'ecdsa') {
$C->{'defaultAccountEgressKeySize'} ||= 521;
if (!grep { $C->{'defaultAccountEgressKeySize'} eq $_ } qw{ 256 384 521 }) {
push @errors,
"Configuration error: value of option 'defaultAccountEgressKeySize' ('"
. $C->{'defaultAccountEgressKeySize'}
. "') should be one of [256,384,521], defaulting to 521";
$C->{'defaultAccountEgressKeySize'} = 521;
}
}
elsif ($C->{'defaultAccountEgressKeyAlgorithm'} eq 'ed25519') {
# for ed25519, it's always 256 anyway
if ($C->{'defaultAccountEgressKeySize'} != 256) {
push @errors, "Configuration error: option 'defaultAccountEgressKeySize' has to be 256 when 'defaultAccountEgressKeyAlgorithm' is set to 'ed25519', forcing 256";
$C->{'defaultAccountEgressKeySize'} = 256;
}
}
delete $unknownkeys{'defaultAccountEgressKeySize'};
}
# ... if kill timeout is lower than lock timeout, just unset lock timeout
if ($C->{'idleKillTimeout'} <= $C->{'idleLockTimeout'} && $C->{'idleLockTimeout'} != 0) {
push @errors,
"Configuration error: option 'idleKillTimeout' ("
. $C->{'idleKillTimeout'}
. ") is <= 'idleLockTimeout' ("
. $C->{'idleLockTimeout'}
. "), setting 'idleKillTimeout' to 0";
$C->{'idleLockTimeout'} = 0;
}
# ... if warnBefore*Seconds are set whereas idle*Timeout are not, unset the warnBefore*Seconds params and log the misconfiguration
foreach my $what (qw{ Kill Lock }) {
if ($C->{"warnBefore${what}Seconds"} && !$C->{"idle${what}Timeout"}) {
push @errors,
"Configuration error: option 'warnBefore${what}Seconds' ("
. $C->{"warnBefore${what}Seconds"}
. ") is set whereas the corresponding 'idle${what}Timeout' option is not, setting 'warnBefore${what}Seconds' to 0";
$C->{"warnBefore${what}Seconds"} = 0;
}
}
# ... check that adminAccounts are actually valid accounts
{
foreach my $conf (qw{ adminAccounts superOwnerAccounts }) {
my @validAccounts;
foreach my $account (@{$C->{$conf}}) {
my $fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account);
if (!$fnret) {
push @errors, "Configuration error: specified $conf '$account' is not a valid account, ignoring";
}
else {
push @validAccounts, $fnret->value->{'account'};
}
}
$C->{$conf} = \@validAccounts;
}
}
# ... this one is complicated; it's an array of (arrays of 3 items: (two arrays and a string))
if (not defined $C->{'ingressToEgressRules'}) {
$C->{'ingressToEgressRules'} = [];
push @errors, "Configuration error: missing option 'ingressToEgressRules', defaulting to []" if $test;
}
elsif (ref $C->{'ingressToEgressRules'} ne 'ARRAY') {
$C->{'ingressToEgressRules'} = [];
push @errors, "Configuration error: option 'ingressToEgressRules' is invalid, expected an array, defaulting to []";
}
else {
SKIP: foreach my $rule (@{$C->{'ingressToEgressRules'}}) {
if (ref $rule ne 'ARRAY') {
$C->{'ingressToEgressRules'} = [];
push @errors, "Configuration error: option 'ingressToEgressRules' has an invalid format (rules should be arrays), defaulting to []";
last;
}
elsif (@$rule != 3 || ref $rule->[0] ne 'ARRAY' || ref $rule->[1] ne 'ARRAY' || ref $rule->[2]) {
$C->{'ingressToEgressRules'} = [];
push @errors, "Configuration error: option 'ingressToEgressRules' has an invalid format (rules should have 3 items: array, array, scalar), defaulting to []";
last;
}
else {
foreach my $i (0 .. 1) {
foreach my $j (0 .. $#{$rule->[$i]}) {
if ($rule->[$i][$j] =~ m{^([0-9.:%/]+)$}) {
$rule->[$i][$j] = $1;
}
else {
$C->{'ingressToEgressRules'} = [];
push @errors,
"Configuration error: option 'ingressToEgressRules' has an invalid format ('" . $rule->[$i][$j] . "' doesn't look like an IP), defaulting to []";
last SKIP;
}
}
}
if (!grep { $rule->[2] eq $_ } qw{ ALLOW-EXCLUSIVE ALLOW DENY }) {
$C->{'ingressToEgressRules'} = [];
push @errors,
"Configuration error: option 'ingressToEgressRules' has an invalid format ('" . $rule->[2] . "' should be ALLOW, DENY or ALLOW-EXCLUSIVE), defaulting to []";
}
}
}
}
delete $unknownkeys{'ingressToEgressRules'};
# ... normalize fanciness
$C->{'fanciness'} = 'none' if $C->{'fanciness'} eq 'boomer';
$C->{'fanciness'} = 'basic' if $C->{'fanciness'} eq 'millenial';
$C->{'fanciness'} = 'full' if $C->{'fanciness'} eq 'genz';
# OK we're done
$_cache_config = $C;
# now that we cached our result, we can call warn_syslog() without risking an infinite loop
warn_syslog($_, $noisy) for @errors;
warn_syslog("Configuration error: got an unknown option '$_' in configuration, ignored", $noisy) for sort keys %unknownkeys;
osh_info("Configuration loaded with " . scalar(@errors) . " warnings.") if $test;
return R('OK', value => $C);
}
sub config {
my $key = shift;
my $fnret = OVH::Bastion::load_configuration();
$fnret or return $fnret;
if (exists $fnret->value->{$key}) {
return R('OK', value => $fnret->value->{$key});
}
return R('ERR_UNKNOWN_CONFIG_PARAMETER');
}
sub account_config {
my %params = @_;
my $account = $params{'account'} || OVH::Bastion::get_user_from_env()->value;
my $key = $params{'key'};
my $value = $params{'value'}; # only for setter
my $delete = $params{'delete'}; # if true, delete the config param entirely
my $public = $params{'public'}; # if true, check in /home/allowkeeper/$account instead of /home/$account
my $fnret;
if (my @missingParameters = grep { not defined $params{$_} } qw{ account key }) {
local $" = ', ';
return R('ERR_MISSING_PARAMETER', msg => "Missing @missingParameters on account_config() call");
}
if ($key !~ /^[a-zA-Z0-9_-]+$/) {
return R('ERR_INVALID_PARAMETER', msg => "Invalid configuration key asked ($key)");
}
$fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account, accountType => ($account =~ /^realm_/ ? 'realm' : 'normal'));
$fnret or return $fnret;
$account = $fnret->value->{'account'};
my $sysaccount = $fnret->value->{'sysaccount'};
my $remoteaccount = $fnret->value->{'remoteaccount'};
my $rootdir;
if ($public) {
$rootdir = "/home/allowkeeper/$sysaccount";
}
else {
$rootdir = (getpwnam($sysaccount))[7];
}
if (!-d $rootdir) {
return R('ERR_DIRECTORY_NOT_FOUND', msg => "Home directory of $account ($rootdir) doesn't exist");
}
my $prefix = $remoteaccount ? "config_$remoteaccount" : "config";
my $filename = "$rootdir/$prefix.$key";
if ($delete) {
return R('OK') if (unlink($filename));
return R('ERR_UNLINK_FAILED', msg => "Couldn't delete account $account config $key with public=$public ($!)");
}
elsif (defined $value) {
# setter mode
unlink($filename); # remove any previous value
my $fh;
if (!sysopen($fh, $filename, O_RDWR | O_CREAT | O_EXCL)) # sysopen: avoid symlink attacks
{
return R('ERR_CANNOT_OPEN_FILE', msg => "Error while trying to open file $filename for write ($!)");
}
print $fh $value;
close($fh);
chmod 0644, $filename;
if ($public) {
# need to chown to allowkeeper:allowkeeper
my (undef, undef, $allowkeeperuid, $allowkeepergid) = getpwnam("allowkeeper");
chown $allowkeeperuid, $allowkeepergid, $filename;
}
return R('OK');
}
else {
# getter mode
my $fh;
if (!open($fh, '<', $filename)) {
return R('ERR_CANNOT_OPEN_FILE', msg => "Error while trying to open file $filename for read ($!)");
}
my $getvalue = do { local $/ = undef; <$fh> };
close($fh);
return R('OK', value => $getvalue);
}
return R('ERR_INTERNAL'); # we shouldn't be here
}
my %_plugin_config_cache;
sub plugin_config {
my %params = @_;
my $plugin = $params{'plugin'};
my $key = $params{'key'};
my $mock_data = $params{'mock_data'};
my $fnret;
if (my @missingParameters = grep { not defined $params{$_} } qw{ plugin }) {
local $" = ', ';
return R('ERR_MISSING_PARAMETER', msg => "Missing @missingParameters on plugin_config() call");
}
if (defined $mock_data) {
if (!OVH::Bastion::is_mocking()) {
# if we're overriding configuration with mock_data without being in mocking mode, we have a problem
die("Attempted to load_configuration() with mock_data without being in mocking mode");
}
# mock data always overrides our cache
delete $_plugin_config_cache{$plugin};
}
if (not exists $_plugin_config_cache{$plugin}) {
# sanitize $plugin
if ($plugin !~ /^[a-zA-Z0-9_-]{1,128}$/) {
return R('ERR_INVALID_PARAMETER', msg => "Invalid parameter for plugin");
}
# if not in cache, load it
my %config;
if (!defined $mock_data) {
# 1of2) load from builtin config (plugin.json)
my $pluginPath = $OVH::Bastion::BASEPATH . '/bin/plugin';
undef $fnret;
foreach my $pluginDir (qw{ open restricted group-gatekeeper group-aclkeeper group-owner admin }) {
if (-e "$pluginPath/$pluginDir/$plugin") {
$fnret = OVH::Bastion::load_configuration_file(file => "$pluginPath/$pluginDir/$plugin.json");
if ($fnret->err eq 'KO_CANNOT_OPEN_FILE') {
# chmod error, don't fail silently
warn_syslog("Can't read configuration file '$pluginPath/$pluginDir/$plugin.json'");
return R('ERR_CONFIGURATION_ERROR', msg => "Configuration file has improper rights, ask your sysadmin!");
}
last;
}
}
if ($fnret && ref $fnret->value eq 'HASH') {
%config = %{$fnret->value};
}
# 2of2) load from /etc config (will NOT override plugin.json keys)
$fnret = OVH::Bastion::load_configuration_file(file => "/etc/bastion/plugin.$plugin.conf", secure => 1);
if ($fnret->err eq 'KO_CANNOT_OPEN_FILE') {
# chmod error, don't fail silently
warn_syslog("Can't read configuration file '/etc/bastion/plugin.$plugin.conf'");
return R('ERR_CONFIGURATION_ERROR', msg => "Configuration file has improper rights, ask your sysadmin!");
}
if ($fnret && ref $fnret->value eq 'HASH') {
# avoid overriding keys
foreach my $key (keys %{$fnret->value}) {
$config{$key} = $fnret->value->{$key} if not exists $config{$key};
}
}
}
else {
%config = %$mock_data;
}
# compat: we previously expected "yes" as a value for the 'disabled' option, instead of a boolean.
# To keep compatibility we still consider "yes" as a true value (as any non-empty string is),
# however we check that the user was not confused and didn't try to enable the plugin by using
# a string such as "no" or "false" instead of a real false boolean:
if (defined $config{'disabled'} && $config{'disabled'} =~ /no|false/) {
warn_syslog("Configuration error for plugin $plugin on the 'disabled' key: expected a boolean, casted '" . $config{'disabled'} . "' into false");
$config{'disabled'} = 0;
}
$_plugin_config_cache{$plugin} = \%config;
}
# if no $key is specified, return all config
return R('OK', value => $_plugin_config_cache{$plugin}) if not defined $key;
# or just the requested key's value otherwise (might be undef!)
return R('OK', value => $_plugin_config_cache{$plugin}{$key});
}
sub group_config {
my %params = @_;
my $group = $params{'group'};
my $key = $params{'key'};
my $value = $params{'value'}; # only for setter
my $secret = $params{'secret'}; # only for setter, if true, only group members can read this config key
my $delete = $params{'delete'}; # only for setter, if true, delete the config param entirely
my $fnret;
if (my @missingParameters = grep { not defined $params{$_} } qw{ group key }) {
local $" = ', ';
return R('ERR_MISSING_PARAMETER', msg => "Missing @missingParameters on group_config() call");
}
if ($key !~ /^[a-zA-Z0-9_-]+$/) {
return R('ERR_INVALID_PARAMETER', msg => "Invalid configuration key asked ($key)");
}
$fnret = OVH::Bastion::is_valid_group_and_existing(group => $group, groupType => 'key');
$fnret or return $fnret;
$group = $fnret->value->{'group'};
my $shortGroup = $fnret->value->{'shortGroup'};
my $filename = "/home/$group/config.$key";
if ($delete) {
return R('OK') if (unlink($filename));
return R('ERR_UNLINK_FAILED', msg => "Couldn't delete group $shortGroup config $key ($!)");
}
elsif (defined $value) {
# setter mode
unlink($filename); # remove any previous value
my $fh;
if (!sysopen($fh, $filename, O_RDWR | O_CREAT | O_EXCL)) # sysopen: avoid symlink attacks
{
return R('ERR_CANNOT_OPEN_FILE', msg => "Error while trying to open file $filename for write ($!)");
}
print $fh $value;
close($fh);
if ($secret) {
chmod 0640, $filename;
}
else {
chmod 0644, $filename;
}
# need to chown to group:group
my (undef, undef, $groupuid, $groupgid) = getpwnam($group);
chown $groupuid, $groupgid, $filename;
return R('OK');
}
else {
# getter mode
my $fh;
if (!open($fh, '<', $filename)) {
return R('ERR_CANNOT_OPEN_FILE', msg => "Error while trying to open file $filename for read ($!)");
}
{
local $/ = undef;
$value = <$fh>;
}
close($fh);
return R('OK', value => $value);
}
return R('ERR_INTERNAL'); # we shouldn't be here
}
sub json_load {
my %params = @_;
# Check params
my $file = $params{'file'};
if (!$file) {
return R('KO_MISSING_PARAMETER', msg => "Missing 'file' parameter");
}
# Load file content
my $rawConf;
if (open(my $fh, '<', $file)) {
local $_ = undef;
while (<$fh>) {
chomp;
s/^((?:(?:[^"]*"){2}|[^"]*)*[^"]*)\/\/.*$/$1/; # Remove comment that start with //
/^\s*#/ and next; # Comment start with ^#
$rawConf .= $_ . "\n";
}
close $fh;
}
else {
# either the file doesn't exist, or we don't have the right to read it.
if (-e $file) {
return R('KO_CANNOT_OPEN_FILE', msg => "Couldn't open specified file ($!)");
}
else {
return R('KO_NO_SUCH_FILE', msg => "File '$file' doesn't exist");
}
}
# Clean file content
# Remove block comment
$rawConf =~ s/\/\*\*.+?\*\///sgm;
# Add {} if needed
if ($rawConf !~ /^\{.*\}[\n]?$/sm) {
$rawConf = "{\n" . $rawConf . "}\n";
}
#
# Parse file content
#
my $configuration;
eval { $configuration = decode_json($rawConf); };
if ($@) {
return R('KO_INVALID_JSON', msg => "Error while trying to decode JSON configuration from file: $@");
}
return R('OK', value => $configuration);
}
1;