# 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) {
                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_IWGRP) {
                return R('ERR_SECURITY_VIOLATION',
                    msg => "Configuration file ($file) is group-writable, 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";
}

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
    state $cached_response;

    # 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 $cached_response;
    }

    if (ref $cached_response eq 'HASH') {
        return R('OK', value => $cached_response);
    }

    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},
        {name => 'ttyrecStealthStdoutPattern',       default => '', validre => qr'^(.{0,4096})$',         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},
        {name => 'dnsSupportLevel',                       min => 0,    max => 2,           default => 2},
      )
    {
        if (not defined $C->{$o->{'name'}}) {
            $C->{$o->{'name'}} = $o->{'default'};
            push @errors,
              sprintf("Configuration error: missing option '%s', defaulting to %s", $o->{'name'}, $o->{'default'})
              if $test;
        }
        if ($C->{$o->{'name'}} =~ /^(-?\d+)$/) {
            # untaint
            $C->{$o->{'name'}} = $1;
        }
        else {
            push @errors,
              sprintf(
                "Configuration error: value of option '%s' ('%s') is not a number, defaulting to %s",
                $o->{'name'}, $C->{$o->{'name'}},
                $o->{'default'}
              );
            $C->{$o->{'name'}} = $o->{'default'};
        }
        if ($C->{$o->{'name'}} > $o->{'max'}) {
            push @errors,
              sprintf(
                "Configuration error: value of option '%s' (%s) is higher than allowed value (%s), defaulting to %s",
                $o->{'name'}, $C->{$o->{'name'}},
                $o->{'max'},  $o->{'default'}
              );
            $C->{$o->{'name'}} = $o->{'default'};
        }
        elsif ($C->{$o->{'name'}} < $o->{'min'}) {
            push @errors,
              sprintf(
                "Configuration error: value of option '%s' (%s) is lower than allowed value (%s), defaulting to %s",
                $o->{'name'}, $C->{$o->{'name'}},
                $o->{'min'},  $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 IPv4Allowed
                }
            ],
        },
        {
            default => 0,
            options => [
                qw{
                  interactiveModeAllowed readOnlySlaveMode sshClientHasOptionE ingressKeysFromAllowOverride
                  moshAllowed debug keyboardInteractiveAllowed passwordAllowed telnetAllowed remoteCommandEscapeByDefault
                  accountExternalValidationDenyOnFailure ingressRequirePIV IPv6Allowed sshAddKeysToAgentAllowed
                }
            ],
        }
      )
    {
        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 => 'ecdsa', 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 edcsa-sk ed25519-sk }],
            validre => qr/^(rsa|ecdsa|ed25519|ecdsa-sk|ed25519-sk)$/
        },
        ## 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->{'idleKillTimeout'} != 0) {
        push @errors,
            "Configuration error: option 'idleKillTimeout' ("
          . $C->{'idleKillTimeout'}
          . ") is <= 'idleLockTimeout' ("
          . $C->{'idleLockTimeout'}
          . "), setting 'idleLockTimeout' 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
    $cached_response = $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;

    # special case: override IPv6Allowed to false if system doesn't support it, and log it
    if ($key && $key eq 'IPv6Allowed' && $fnret->value->{$key} && !OVH::Bastion::system_supports_ipv6()) {
        warn_syslog("System does not support IPv6, disabling it");
        return R('OK', value => 0);
    }

    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   => OVH::Bastion::main_configuration_directory() . "/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 '"
                      . OVH::Bastion::main_configuration_directory()
                      . "/plugin.$plugin.conf'");
                return R('ERR_CONFIGURATION_ERROR',
                    msg => "Configuration file has improper rights, ask your sysadmin!");
            }
            elsif ($fnret->err eq 'KO_NO_SUCH_FILE') {
                # no configuration, just continue
            }
            elsif (!($fnret && ref $fnret->value eq 'HASH')) {
                # other error, report it and fail
                warn_syslog("Error loading plugin $plugin configuration ($fnret)");
                return R('ERR_CONFIGURATION_ERROR', msg => "Plugin configuration is invalid, aborting");
            }
            else {
                # avoid overriding keys
                foreach my $key (keys %{$fnret->value}) {
                    $config{$key} = $fnret->value->{$key} if not exists $config{$key};
                }
            }

            # do we have a config validator for this plugin?
            ## no critic(Modules::RequireBarewordIncludes)
            eval { require "OVH::Bastion::Plugin::$plugin"; };
            if (!$@) {
                my $validator = "OVH::Bastion::Plugin::${plugin}::validate_config";
                $fnret = $validator->(config => \%config);
                if (!$fnret || !$fnret->value) {
                    warn_syslog("Invalid configuration for plugin $plugin: $fnret");
                    return R('ERR_INVALID_CONFIGURATION', msg => "Plugin configuration is invalid");
                }
                %config = %{$fnret->value};
            }
        }
        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;
