the-bastion/lib/perl/OVH/Bastion/allowdeny.inc
Stéphane Lesimple fde20136ef
Initial commit
2020-10-20 14:30:27 +00:00

1181 lines
48 KiB
Perl

package OVH::Bastion;
# vim: set filetype=perl ts=4 sw=4 sts=4 et:
use common::sense;
use Socket qw{ :all };
sub get_personal_account_keys {
my %params = @_;
my $account = $params{'account'};
my $listOnly = $params{'listOnly'} ? 1 : 0;
my $forceKey = $params{'forceKey'};
my $fnret;
$fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account, accountType => ($account =~ /^realm_/ ? "realm" : "normal"));
$fnret or return $fnret;
$account = $fnret->value->{'account'}; # untainted version
return _get_pub_keys_from_directory(
dir => "/home/$account/.ssh",
pattern => qr/^private\.pub$|^id_[a-z0-9]+[_.]private\.\d+\.pub$/,
listOnly => $listOnly, # don't be slow and don't parse the keys (by calling ssh-keygen -lf)
forceKey => $forceKey,
wantPrivate => 1,
);
}
my %_cache_get_group_keys;
sub get_group_keys {
my %params = @_;
my $group = $params{'group'};
my $cache = $params{'cache'}; # allow cache use (useful for multicall)
my $listOnly = $params{'listOnly'} ? 1 : 0;
my $forceKey = $params{'forceKey'};
my $fnret;
my $cacheKey = "$group:$listOnly";
if ($cache and exists $_cache_get_group_keys{$cacheKey}) {
return $_cache_get_group_keys{$cacheKey};
}
$fnret = OVH::Bastion::is_valid_group_and_existing(group => $group, groupType => 'key');
$fnret or return $fnret;
$group = $fnret->value->{'group'}; # untainted version
my $shortGroup = $fnret->value->{'shortGroup'};
$fnret = _get_pub_keys_from_directory(
dir => '/home/keykeeper/' . $group,
pattern => qr/^id_([a-z0-9]+)_\Q$shortGroup\E/,
listOnly => $listOnly,
forceKey => $forceKey,
wantPrivate => 1,
);
$_cache_get_group_keys{$cacheKey} = $fnret;
return $fnret;
}
# this function simply checks if the user@ip:port is allowed in the way given,
# i.e. personal access, group access, groupguest access, or legacy access.
# it calls is_access_granted_in_file with the proper file location depending
# on the access way that is tested. note that for e.g. group accesses, we don't
# check if a given account has access to the group or not, we just check if the
# group itself has access. this check must be done by our caller.
# returns: { match, size, forceKey } for best match, if any
sub is_access_way_granted {
my %params = @_;
my $exactIpMatch = $params{'exactIpMatch'}; # $ip must be explicitely allowed (not given through a wider slash or a 0.0.0.0/0 in grantfile)
my $exactPortMatch = $params{'exactPortMatch'}; # $port must be explicitely allowed (port wildcards in grantfile will be ignored)
my $exactUserMatch = $params{'exactUserMatch'}; # $user must be explicitely allowed (user wildcards in grantfile will be ignored)
my $exactMatch = $params{'exactMatch'}; # sets exactIpMatch exactPortMatch and exactUserMatch
my $ignoreUser = $params{'ignoreUser'}; # ignore remote user COMPLETELY (plop@, or root@, or <nil>@ will all match)
my $ignorePort = $params{'ignorePort'}; # ignore port COMPLETELY (port 22, 2345, or port-wildcard will all match)
my $wantedUser = $params{'user'}; # if undef, means we look for a user wildcard allow
my $wantedIp = $params{'ip'}; # can be a single IP or a prefix
my $wantedPort = $params{'port'}; # if undef, means we look for a port wildcard allow
my $way = $params{'way'}; # personal|group|groupguest|legacy
my $group = $params{'group'}; # only meaningful and needed if type=group or type=groupguest
my $account = $params{'account'}; # only meaningful and needed if type=personal or type=groupguest
my $fnret;
$exactIpMatch = $exactPortMatch = $exactUserMatch = 1 if $exactMatch;
# 'group', 'account', and 'way' parameters are only useful to, and checked by, get_acl_way()
$fnret = OVH::Bastion::get_acl_way(way => $way, account => $account, group => $group);
$fnret or return $fnret;
my @acl = @{$fnret->value || []};
osh_debug(
"checking way $way/$account/$group with ignorePort=$ignorePort ignoreUser=$ignoreUser exactIpMatch=$exactIpMatch exactPortMatch=$exactPortMatch exactUserMatch=$exactUserMatch"
);
my ($bestMatch, $bestMatchSize, $forceKey);
foreach my $entry (@acl) {
my $allowedIp = $entry->{'ip'}; # can be a prefix
my $allowedUser = $entry->{'user'}; # can be undef (if any-user)
my $allowedPort = $entry->{'port'}; # can be undef (if any-port)
my $localForceKey = $entry->{'forceKey'};
osh_debug("checking wanted "
. (defined $wantedUser ? $wantedUser : '<u>') . '@'
. (defined $wantedIp ? $wantedIp : '<u>') . ':'
. (defined $wantedPort ? $wantedPort : '<u>')
. ' against '
. (defined $allowedUser ? $allowedUser : '<u>') . '@'
. (defined $allowedIp ? $allowedIp : '<u>') . ':'
. (defined $allowedPort ? $allowedPort : '<u>'));
$allowedIp or next; # can't be empty
# first, check port stuff
# if we get ignorePort, we skip the checks entirely
if (not $ignorePort) {
if ($exactPortMatch) {
# we want an exact match
if (not defined $allowedPort) {
if (not defined $wantedPort) {
; # both undefined ? ok
}
else {
next; # if only one of two is undef, it's not an exact match
}
}
else {
if (not defined $wantedPort) {
next; # if only one of two is undef, it's not an exact match
}
else {
next if ($wantedPort ne $allowedPort); # both defined but unequal, not a match
}
}
}
else {
# we don't want an exact match (aka wildcards allowed)
if (not defined $allowedPort) {
; # it's a wildcard, will always match
}
else {
if (not defined $wantedPort) {
next; # we want a wildcard, but we don't have it
}
else {
next if ($wantedPort ne $allowedPort); # both defined but unequal, not a match
}
}
}
}
# second, check user stuff
# if we get ignoreUser, we skip the checks entirely
if (not $ignoreUser) {
if ($exactUserMatch) {
# we want an exact match
if (not defined $allowedUser) {
if (not defined $wantedUser) {
; # both undefined ? ok
}
else {
next; # if only one of two is undef, it's not an exact match
}
}
else {
if (not defined $wantedUser) {
next; # if only one of two is undef, it's not an exact match
}
else {
next if ($wantedUser ne $allowedUser); # both defined but unequal, not a match
}
}
}
else {
# we don't want an exact match (aka wildcards allowed)
if (not defined $allowedUser) {
; # it's a wildcard, will always match
}
else {
if (not defined $wantedUser) {
next; # we want a wildcard, but we don't have it
}
else {
next if ($wantedUser ne $allowedUser); # both defined but unequal, not a match
}
}
}
}
# then, check IP
# if we want an exact match, it's a stupid strcmp()
if ($exactIpMatch) {
next if ($allowedIp ne $wantedIp);
# here, we got a perfect match
$forceKey = $localForceKey;
$bestMatch = $allowedIp;
$bestMatchSize = undef; # not needed
last; # perfect match, don't search further
}
# check IP in not-exactIpMatch case. if it contains / then it's a prefix
if ($allowedIp =~ m{/}) {
# build slash and test
require Net::Netmask;
my $ipCheck = Net::Netmask->new2($allowedIp);
if ($ipCheck && $ipCheck->match($wantedIp)) {
osh_debug("... we got a slash match !");
if (not defined $bestMatchSize or $ipCheck->size() < $bestMatchSize) {
$forceKey = $localForceKey;
$bestMatch = $allowedIp;
$bestMatchSize = $ipCheck->size();
$bestMatchSize == 1 and last; # we won't get better than this
}
}
}
else {
# it's a single ip, so a stupid strcmp() does the trick
if ($allowedIp eq $wantedIp) {
osh_debug("... we got a singleip match !");
$forceKey = $localForceKey;
$bestMatch = $allowedIp;
$bestMatchSize = 1;
last;
}
}
}
if (defined $bestMatch) {
return R('OK', value => {match => $bestMatch, size => $bestMatchSize, forceKey => $forceKey});
}
return R('KO_ACCESS_DENIED');
}
# from a given hostname, check if we have an ip or a range of ip or try to resolve
sub get_ip {
my %params = @_;
my $host = $params{'host'};
my $v4 = $params{'v4'}; # allow ipv4 ?
my $v6 = $params{'v6'}; # allow ipv6 ?
if (!$host) {
return R('ERR_MISSING_PARAMETER', msg => "Missing parameter 'host'");
}
# by default, only v4 unless specified otherwise
$v4 = 1 if not defined $v4;
$v6 = 0 if not defined $v6;
# try to see if it's already an IP
osh_debug("checking if '$host' is already an IP");
my $fnret = OVH::Bastion::is_valid_ip(ip => $host, allowPrefixes => 0);
if ($fnret) {
osh_debug("Host $host is already an IP");
if ( ($fnret->value->{'version'} == 4 && $v4)
|| ($fnret->value->{'version'} == 6 && $v6))
{
return R('OK', value => {ip => $fnret->value->{'ip'}, iplist => [$fnret->value->{'ip'}]});
}
return R('ERR_INVALID_IP', msg => "IP $host version is not allowed");
}
osh_debug("Trying to resolve '$host' because is_valid_ip() says it's not an IP");
my ($err, @res);
eval {
# dns resolving, v4/v6 compatible
# can croak
($err, @res) = getaddrinfo($host, undef, {socktype => SOCK_STREAM});
};
return R('ERR_HOST_NOT_FOUND', msg => $@) if $@;
return R('ERR_HOST_NOT_FOUND', msg => $err) if $err;
my %iplist;
my $lastip;
foreach my $item (@res) {
if ($item->{'family'} == AF_INET) {
next if not $v4;
}
elsif ($item->{'family'} == AF_INET6) {
next if not $v6;
}
else {
# unknown weird family ?
next;
}
my $as_text;
undef $err;
eval {
($err, $as_text) = getnameinfo($item->{'addr'}, NI_NUMERICHOST); # NI flag: don't use dns, just unpack the binary 'addr'
};
if (not $@ and not $err) {
$iplist{$as_text} = 1;
$lastip = $as_text;
}
}
if (%iplist) {
return R('OK', value => {ip => $lastip, iplist => [keys %iplist]});
}
# %iplist empty, not resolved (?)
return R('ERR_HOST_NOT_FOUND', msg => "Unable to resolve '$host'");
}
# reverse-dns of an IPv4 or IPv6
sub ip2host {
my $ip = shift;
my ($err, @sockaddr, $host);
eval {
# ip => packedip. AI_PASSIVE: don't use dns, just build sockaddr
# can croak
($err, @sockaddr) = getaddrinfo($ip, 0, {flags => AI_PASSIVE, socktype => SOCK_STREAM});
};
return R('ERR_INVALID_IP', msg => $@) if $@;
return R('ERR_INVALID_IP', msg => $err) if $err;
eval {
# can croak
($err, $host, undef) = getnameinfo($sockaddr[0]->{'addr'}, NI_NAMEREQD);
};
return R('ERR_HOST_NOT_FOUND', msg => $@) if $@;
return R('ERR_HOST_NOT_FOUND', msg => $err) if $err;
return R('OK', value => $host);
}
# Return an array containings the groups for which user is a member of
my %_cache_get_user_groups;
sub get_user_groups {
my %params = @_;
my $user = $params{'user'} || $params{'account'};
my $extra = $params{'extra'}; # Do we want to include gatekeeper/aclkeeper/owner groups ?
my $cache = $params{'cache'}; # allow cache use (multicall)
if (not $user) {
return R('ERR_MISSING_PARAMETER', msg => "Missing parameter 'account'");
}
if (not %_cache_get_user_groups) {
# build cache, it'll be faster than even one exec `id -nG` anyway
setgrent();
my %users;
while (my ($name, $passwd, $gid, $members) = getgrent()) {
foreach my $member (split / /, $members) {
push @{$_cache_get_user_groups{$member}}, $name;
}
}
setgrent();
}
my @groups = @{$_cache_get_user_groups{$user} || []};
my @availableGroups;
foreach my $group (@groups) {
if ($group =~ /^key.+-(gatekeeper|aclkeeper|owner)$/) {
push @availableGroups, $group if $extra;
}
else {
push @availableGroups, $group if $group =~ /^key/;
}
}
if (scalar(@availableGroups)) {
return R('OK', value => \@availableGroups);
}
else {
return R('ERR_NO_GROUP', msg => 'Unable to find any group');
}
}
sub _get_pub_keys_from_directory {
my %params = @_;
my $dir = $params{'dir'};
my $pattern = $params{'pattern'};
my $listOnly = $params{'listOnly'}; # don't open the files, just return file names
my $noexec = $params{'noexec'}; # passed to is_valid_public_key
my $forceKey = $params{'forceKey'};
my $wantPrivate = $params{'wantPrivate'}; # if set, will return the fullpath of the private key, not the public one
my $fnret;
osh_debug("looking for pub keys in dir $dir as user $ENV{'USER'}");
if (!-d $dir) {
return R('ERR_DIRECTORY_NOT_FOUND', msg => "directory $dir doesn't exist");
}
my $dh;
if (!opendir($dh, $dir)) {
return R('ERR_CANNOT_OPEN_DIRECTORY', msg => "can't open directory $dir: $!");
}
if (defined $pattern and ref $pattern ne 'Regexp') {
return R('ERR_INVALID_PARAMETER', msg => 'pattern is not a Regexp reference');
}
my %return;
while (my $file = readdir($dh)) {
$file =~ /^([a-zA-Z0-9._-]+\.pub)$/ or next;
$file = $1; # untaint
if (defined $pattern) {
$file =~ /$pattern/ or next;
}
my $filename = $file;
$file = "$dir/$file";
-f -r $file or next;
# ok file exists, is readable and matches the pattern
osh_debug("file $file matches the pattern in $dir");
my $mtime = (stat(_))[9];
if ($listOnly) {
$return{$file} = {fullpath => $file, filename => $filename, mtime => $mtime};
if ($wantPrivate) {
$return{$file}{'fullpath'} =~ s/\.pub$//;
$return{$file}{'filename'} =~ s/\.pub$//;
}
}
else {
# open the file and read the key
my $fh_key;
if (!open($fh_key, '<', $file)) {
osh_debug("can't open file $file ($!), skipping");
next;
}
while (my $line = <$fh_key>) {
# stop when we find a key or at EOF
chomp $line;
$fnret = OVH::Bastion::is_valid_public_key(way => 'egress', pubKey => $line, noexec => ($noexec && !$forceKey));
if (!$fnret) {
osh_debug("key in $file is not valid: " . $fnret->err);
osh_debug($fnret->msg);
}
else {
if ((not defined $forceKey) || ($forceKey eq $fnret->value->{'fingerprint'})) {
$return{$file} = $fnret->value;
$return{$file}{'fullpath'} = $file;
$return{$file}{'mtime'} = $mtime;
$return{$file}{'filename'} = $filename;
if ($wantPrivate) {
$return{$file}{'fullpath'} =~ s/\.pub$//;
$return{$file}{'filename'} =~ s/\.pub$//;
}
}
last;
}
}
close($fh_key);
}
}
close($dh);
# return a sorted keys list too f(mtime) desc
my @sortedKeys = sort { $return{$b}{'mtime'} <=> $return{$a}{'mtime'} } keys %return;
return R('OK', value => {keys => \%return, sortedKeys => \@sortedKeys});
}
sub duration2human {
my %params = @_;
my $s = $params{'seconds'};
my $tense = $params{'tense'};
require POSIX;
my $date = POSIX::strftime("%a %Y-%m-%d %H:%M:%S %Z", localtime(time() + ($tense eq 'past' ? -$s : $s)));
my $d = int($s / 86400);
$s -= $d * 86400;
my $h = int($s / 3600);
$s -= $h * 3600;
my $m = int($s / 60);
$s -= $m * 60;
my $duration = $d ? sprintf('%dd+%02d:%02d:%02d', $d, $h, $m, $s) : sprintf('%02d:%02d:%02d', $h, $m, $s);
return R('OK', value => {duration => $duration, date => $date, human => "$duration ($date)"});
}
sub print_acls {
my %params = @_;
my $acls = $params{'acls'} || [];
my $reverse = $params{'reverse'};
my $hideGroups = $params{'hideGroups'};
my $printIpLen = $reverse ? 30 : 15;
osh_info(
sprintf("%-" . $printIpLen . "s %5s %20s %30s %10s %13s %45s %40s %s", "IP", "PORT", "USER", "ACCESS-BY", "ADDED-BY", "ADDED-AT", "EXPIRY?", "COMMENT", "FORCED-KEY?"));
my @flatArray;
foreach my $contextAcl (@$acls) {
my $type = $contextAcl->{'type'};
my $group = $contextAcl->{'group'};
my $acl = $contextAcl->{'acl'};
next if ($hideGroups and $type =~ /^group/);
my $accessType = ($group ? "$group($type)" : $type);
foreach my $entry (@$acl) {
my $addedBy = $entry->{'addedBy'} || '(unknown)';
my $addedDate = $entry->{'addedDate'} || '(unknown)';
$addedDate = substr($addedDate, 0, 10);
my $forceKey = $entry->{'forceKey'} || '-';
my $expiry = $entry->{'expiry'} ? (duration2human(seconds => ($entry->{'expiry'} - time()))->value->{'human'}) : '-';
# type => member ('full'), guest ('partial'), personal or legacy
my $ipReverse = OVH::Bastion::ip2host($entry->{'ip'})->value if $reverse;
$entry->{'reverseDns'} = $ipReverse;
push @flatArray, $entry;
osh_info(
sprintf(
"%-" . $printIpLen . "s %5s %20s %30s %10s %13s %45s %40s %s",
$ipReverse ? $ipReverse : $entry->{'ip'},
$entry->{'port'} ? $entry->{'port'} : '(any)',
$entry->{'user'} ? $entry->{'user'} : '(any)',
$accessType, $addedBy, $addedDate, $expiry, $entry->{'userComment'} || '-', $forceKey
)
);
}
}
osh_info(scalar(@flatArray) . " accesses listed");
return R('OK', value => \@flatArray);
}
# checks if ip matches any given array of prefixes/networks
sub _is_in_any_net {
my %params = @_;
my $ip = $params{'ip'};
my $networks = $params{'networks'};
if (!$ip) {
return R('ERR_MISSING_PARAMETER', msg => "Missing parameter 'ip'");
}
if (ref $networks ne 'ARRAY') {
return R('ERR_INVALID_PARAMETER', msg => "Parameter 'networks' must be an array");
}
foreach my $net (@$networks) {
if ($net =~ m{/}) {
# build slash and test
require Net::Netmask;
my $ipCheck = Net::Netmask->new2($net);
return R('OK', value => {matched => $net}) if ($ipCheck && $ipCheck->match($ip));
}
else {
# it's a single ip, so it's a stupid strcmp() does the trick
return R('OK', value => {matched => $net}) if ($net eq $ip);
}
}
return R('KO', msg => "No match found");
}
# this function checks if the given account has access to user@ip:port
# through any of the supported ways (personal/group/guest/legacy accesses),
# by calling is_access_way_granted() multiple times with the proper params.
# it can also add the fullpath of the keys to try for allowed accesses if asked to
# returns: arrayref of contextualized grants, contextualized-grant: { type, group, $granthashref }
# granthashref: returned by is_access_way_granted, i.e. { match, size, forceKey }
sub is_access_granted {
my %params = @_;
# we'll use delete for params that we won't pass through is_access_way_granted()
my $account = delete $params{'account'}; # account to check the access grants of.
# can also be of the format "realm/remoteself"
my $ipfrom = $params{'ipfrom'}; # must be an IP (client IP)
my $ip = $params{'ip'}; # can be a single IP or a slash
my $port = $params{'port'}; # if undef, means we look for a port wildcard allow
my $user = $params{'user'}; # if undef, means we look for a user wildcard allow
my $listOnly = $params{'listOnly'}; # don't open the files, just return file names
my $noexec = $params{'noexec'}; # passed to is_valid_public_key
my $wantKeys = delete $params{'wantKeys'}; # if set, look for and return ssh keys along with allowed accesses
delete $params{'way'}; # WE specify this parameter, not our caller
delete $params{'group'}; # WE specify this parameter, not our caller
my @grants;
my $fnret;
require Data::Dumper;
# 0a/3 check if we're in a forbidden network. if we are, just bail out
my $forbiddenNetworks = OVH::Bastion::config('forbiddenNetworks')->value;
$fnret = _is_in_any_net(ip => $ip, networks => $forbiddenNetworks);
return R('KO_ACCESS_DENIED', msg => "Can't connect you to $ip as it's part of the forbidden networks of this bastion (see --osh info)") if $fnret->is_ok;
# 0b/3 check if we're not outside of the bastion allowed networks, if we are, just bail out
my $allowedNetworks = OVH::Bastion::config('allowedNetworks')->value;
if (@$allowedNetworks) {
$fnret = _is_in_any_net(ip => $ip, networks => $allowedNetworks);
return R('KO_ACCESS_DENIED', msg => "Can't connect you to $ip as it's not part of the allowed networks of this bastion (see --osh info)") if $fnret->is_ko;
}
# 0c/3 check if there are more complex "ingressToEgressRules" defined, and potentially bail out whether needed
$fnret = OVH::Bastion::config('ingressToEgressRules');
my @rules = @{$fnret->value || []};
for (my $ruleNb = 0 ; $ruleNb < @rules ; $ruleNb++) {
my ($inNets, $outNets, $policy) = @{$rules[$ruleNb]};
$fnret = _is_in_any_net(ip => $ipfrom, networks => $inNets);
if ($fnret->is_err) {
warn("Denied access due to potential configuration error in ingressToEgressRules (rule #$ruleNb, ingress");
return R('KO_ACCESS_DENIED', msg => "Error checking ingressToEgressRules, warn your bastion admin!");
}
# ingress IP doesn't match for this rule, go to next:
next if $fnret->is_ko;
# ingress IP matches, check whether egress IP matches
$fnret = _is_in_any_net(ip => $ip, networks => $outNets);
if ($fnret->is_err) {
warn("Denied access due to potential configuration error in ingressToEgressRules (rule #$ruleNb, egress");
return R('KO_ACCESS_DENIED', msg => "Error checking ingressToEgressRules, warn your bastion admin!");
}
if ($policy eq 'ALLOW-EXCLUSIVE') {
if ($fnret->is_ok) {
# egress matches: allowed, stop checking more rules
last;
}
# is_ko: we're in exclusive mode, stop checking and deny
return R('KO_ACCESS_DENIED', msg => "Can't connect you to $ip, as it's not part of the allowed networks given where you're connecting from ($ipfrom)");
}
elsif ($policy eq 'DENY') {
if ($fnret->is_ok) {
# egress matches: we have been asked to deny
return R('KO_ACCESS_DENIED', msg => "Can't connect you to $ip, as it's not part of the allowed networks given where you're connecting from ($ipfrom)");
}
# is_ko: egress doesn't match, check next rule
}
elsif ($policy eq 'ALLOW') {
if ($fnret->is_ok) {
# egress matches: we have been asked to allow, stop checking more rules
last;
}
# is_ko: egress doesn't match, check next rule
}
else {
# invalid policy
warn("Denied access due to potential configuration error in ingressToEgressRules (rule #$ruleNb, policy");
return R('KO_ACCESS_DENIED', msg => "Error checking ingressToEgressRules, warn your bastion admin!");
}
}
$fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account);
$fnret or return $fnret;
$account = $fnret->value->{'account'};
my $sysaccount = $fnret->value->{'sysaccount'};
# 1/3 check for personal accesses
# ... normal way
my $grantedPersonal = is_access_way_granted(%params, way => 'personal', account => $account);
osh_debug("is_access_granted: grantedPersonal=" . Data::Dumper::Dumper($grantedPersonal));
push @grants, {type => 'personal', %{$grantedPersonal->value}} if $grantedPersonal;
# ... legacy way
my $grantedLegacy = is_access_way_granted(%params, way => 'legacy', account => $account);
osh_debug("is_access_granted: grantedLegacy=" . Data::Dumper::Dumper($grantedLegacy));
push @grants, {type => 'personal-legacy', %{$grantedLegacy->value}} if $grantedLegacy;
# 2/3 check groups
$fnret = OVH::Bastion::get_user_groups(account => $sysaccount);
osh_debug("is_access_granted: get_user_groups of $sysaccount says " . $fnret->msg . " with grouplist " . Data::Dumper::Dumper($fnret->value));
foreach my $group (@{$fnret->value || []}) {
# sanitize the group name
$fnret = OVH::Bastion::is_valid_group(group => $group, groupType => "key");
$fnret or next;
$group = $fnret->value->{'group'}; # untaint
my $shortGroup = $fnret->value->{'shortGroup'};
# then check for group access
my $grantedGroup = is_access_way_granted(%params, way => "group", group => $shortGroup);
osh_debug("is_access_granted: grantedGroup=" . Data::Dumper::Dumper($grantedGroup));
next if not $grantedGroup; # if group doesn't have access, don't even check legacy either
# now we have to cases, if the group has access: either the account is member or guest
if (OVH::Bastion::is_group_member(group => $shortGroup, account => $account, sudo => $params{'sudo'})) {
# normal member case, just reuse $grantedGroup
osh_debug("is_access_granted: adding grantedGroup to grants because is member");
push @grants, {type => 'group-member', group => $shortGroup, %{$grantedGroup->value}};
}
elsif (OVH::Bastion::is_group_guest(group => $shortGroup, account => $account, sudo => $params{'sudo'})) {
# normal guest case
my $grantedGuest = is_access_way_granted(%params, way => "groupguest", group => $shortGroup, account => $account);
osh_debug("is_access_granted: grantedGuest=" . Data::Dumper::Dumper($grantedGuest));
# the guy must have a guest access but the group itself must also still have access
if ($grantedGuest && $grantedGroup) {
push @grants, {type => 'group-guest', group => $shortGroup, %{$grantedGuest->value}};
osh_debug("is_access_granted: adding grantedGuest to grants because is guest and group has access");
}
# special legacy case; we also check if account has a legacy access for ip AND that the group ALSO has access to this ip
if ($grantedLegacy && $grantedGroup) {
osh_debug("is_access_granted: adding grantedLegacy to grants because legacy not null and group has access");
push @grants, {type => 'group-guest-legacy', group => $shortGroup, %{$grantedLegacy->value}};
}
}
else {
# should not happen
osh_debug("is_access_granted: $account is in group $shortGroup but is neither member or guest !!?");
}
}
# 3/3 fill up keys if asked to
if ($wantKeys) {
foreach my $access (@grants) {
undef $fnret;
my $mfaFnret;
if ($access->{'type'} =~ /^group/ and $access->{'group'}) {
$fnret = OVH::Bastion::get_group_keys(group => $access->{'group'}, listOnly => $listOnly, noexec => $noexec, forceKey => $access->{'forceKey'});
$mfaFnret = OVH::Bastion::group_config(key => "mfa_required", group => $access->{'group'});
}
elsif ($access->{'type'} =~ /^personal/) {
$fnret = OVH::Bastion::get_personal_account_keys(account => $sysaccount, listOnly => $listOnly, noexec => $noexec, forceKey => $access->{'forceKey'});
$mfaFnret = OVH::Bastion::account_config(key => "personal_egress_mfa_required", account => $sysaccount);
}
else {
; # unknown access type? no key!
}
if ($fnret) {
# TODO implement $access->{forceKey} check to include only the proper key
$access->{'keys'} = $fnret->value->{'keys'};
$access->{'sortedKeys'} = $fnret->value->{'sortedKeys'};
$access->{'mfaRequired'} = $mfaFnret->value if $mfaFnret;
}
}
}
return R('OK', value => \@grants) if @grants;
my $machine = $ip;
$machine .= ":$port" if $port;
$machine = $user . '@' . $machine if $user;
return R('KO_ACCESS_DENIED', msg => "Access denied for $account to $machine");
}
sub ssh_test_access_way {
my %params = @_;
my $account = $params{'account'};
my $group = $params{'group'};
my $port = $params{'port'};
my $ip = $params{'ip'};
my $user = $params{'user'};
my $fnret;
if (defined $account and defined $group) {
return R('ERR_INCOMPATIBLE_PARAMETERS');
}
$fnret = OVH::Bastion::is_valid_ip(ip => $ip, allowPrefixes => 1);
$fnret or return $fnret;
if ($fnret->value->{'type'} eq 'prefix') {
return R('OK_PREFIX', msg => "Can't test a connection to a prefix, assuming it's OK");
}
$ip = $fnret->value->{'ip'};
if ($port) {
$fnret = OVH::Bastion::is_valid_port(port => $port);
$fnret or return $fnret;
$port = $fnret->value;
}
$user = OVH::Bastion::config("defaultLogin")->value if not $user;
$user = $account if not $user; # defaultLogin empty means the user himself
$user = OVH::Bastion::get_user_from_env()->value if not $user; # no user or account ? get from env then
$fnret = OVH::Bastion::is_valid_remote_user(user => $user);
$fnret or return $fnret;
$user = $fnret->value;
if ($group) {
$fnret = OVH::Bastion::is_valid_group_and_existing(group => $group, groupType => "key");
$fnret or return $fnret;
my $shortGroup = $fnret->value->{'shortGroup'};
$group = $fnret->value->{'group'};
$fnret = OVH::Bastion::get_group_keys(group => $shortGroup);
}
elsif ($account) {
$fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account);
$fnret or return $fnret;
$account = $fnret->value->{'account'};
$fnret = OVH::Bastion::get_personal_account_keys(account => $account);
}
else {
return R('ERR_MISSING_PARAMETER', msg => "Missing 'group' or 'account' for ssh_test_access_way");
}
$fnret or return $fnret;
my @keyList;
foreach my $keyfile (@{$fnret->value->{'sortedKeys'}}) {
my $key = $fnret->value->{'keys'}{$keyfile};
my $privkey = $key->{'fullpath'};
$privkey =~ s/\.pub$//;
push @keyList, $privkey if -r $privkey;
}
if (not @keyList) {
return R('OK_NO_KEYS_TO_TEST',
msg =>
"Couldn't find any accessible SSH key to test connection with, you're probably adding access to an account or a group you don't have access to yourself, nevermind, will continue"
);
}
if ($user eq '!scpupload' || $user eq '!scpdownload') {
return R('OK_MAGIC_USER', msg => "Didn't really test the connection, as the specified user is special");
}
my $preferredAuthentications = 'publickey';
$preferredAuthentications .= ',keyboard-interactive' if $ENV{'OSH_KBD_INTERACTIVE'};
# ssh -i with the correct keys
# UserKnownHostsFile/StrictHostKeyChecking: avoid problem when opening /dev/tty under sudo
my @command = qw{ ssh -o ConnectTimeout=5 -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no };
push @command, '-o', 'PreferredAuthentications=' . $preferredAuthentications;
foreach (@keyList) {
push @command, "-i", $_;
}
if (!OVH::Bastion::is_openbsd()) {
unshift @command, qw{ timeout -k 1 6 };
}
# add port when specified
push @command, ("-p", $port) if $port;
push @command, "-l", $user, $ip, '-T', '--', 'true';
osh_info("Testing connection to $user\@$ip, please wait...");
$fnret = OVH::Bastion::execute(cmd => \@command, noisy_stderr => 1);
$fnret or return $fnret;
no strict 'subs';
if (grep { $fnret->value->{'sysret'} eq $_ } (0, OVH::Bastion::EXIT_ACCOUNT_INVALID, OVH::Bastion::EXIT_HOST_NOT_FOUND)) {
return R('OK');
}
use strict 'subs';
my $hint;
# 124 is the return code from the timeout system command when it times out
# tested on Linux, NetBSD
if ($fnret->value->{'sysret'} == 124 || grep { $_ =~ /timed out/i } @{$fnret->value->{'stderr'} || []}) {
$hint = "Hint: did you remotely allow this bastion to access the SSH port?";
}
elsif (grep { $_ =~ /Permission denied/i } @{$fnret->value->{'stderr'} || []}) {
$hint = "Hint: did you add the proper public key to the remote's authorized_keys?";
}
my $msg = "Couldn't connect to $user\@$ip (ssh returned error " . $fnret->value->{'sysret'} . ")";
$msg .= ". $hint" if defined $hint;
return R('ERR_CONNECTION_FAILED', msg => $msg);
}
# get all accesses from an account, by any way possible
# returns: arrayref of contextualized acls, contextualized-acl: { type, group, \@aclentries }
sub get_acls {
my %params = @_;
my $account = $params{'account'};
my @acls;
my $fnret;
require Data::Dumper;
$fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account);
$fnret or return $fnret;
$account = $fnret->value->{'account'};
my $sysaccount = $fnret->value->{'sysaccount'};
# 1/3 check for personal accesses
# ... normal way
my $grantedPersonal = OVH::Bastion::get_acl_way(way => 'personal', account => $account);
osh_debug("get_acls: grantedPersonal=" . Data::Dumper::Dumper($grantedPersonal));
push @acls, {type => 'personal', acl => $grantedPersonal->value} if ($grantedPersonal && @{$grantedPersonal->value});
# ... legacy way
my $grantedLegacy = OVH::Bastion::get_acl_way(way => 'legacy', account => $account);
osh_debug("get_acls: grantedLegacy=" . Data::Dumper::Dumper($grantedLegacy));
push @acls, {type => 'personal-legacy', acl => $grantedLegacy->value} if ($grantedLegacy && @{$grantedLegacy->value});
# 2/3 check groups
$fnret = OVH::Bastion::get_user_groups(account => $sysaccount);
osh_debug("get_acls: get_user_groups of $sysaccount says " . $fnret->msg . " with grouplist " . Data::Dumper::Dumper($fnret->value));
foreach my $group (@{$fnret->value || []}) {
# sanitize the group name
$fnret = OVH::Bastion::is_valid_group(group => $group, groupType => "key");
$fnret or next;
$group = $fnret->value->{'group'}; # untaint
my $shortGroup = $fnret->value->{'shortGroup'};
# then check for group access
my $grantedGroup = OVH::Bastion::get_acl_way(way => "group", group => $shortGroup);
osh_debug("get_acls: grantedGroup=" . Data::Dumper::Dumper($grantedGroup));
next if not $grantedGroup; # if group doesn't have access, don't even check legacy either
# now we have to cases, if the group has access: either the account is member or guest
if (OVH::Bastion::is_group_member(group => $shortGroup, account => $account)) {
# normal member case, just reuse $grantedGroup
osh_debug("get_acls: adding grantedGroup to grants because is member");
push @acls, {type => 'group-member', group => $shortGroup, acl => $grantedGroup->value} if ($grantedGroup && @{$grantedGroup->value});
}
elsif (OVH::Bastion::is_group_guest(group => $shortGroup, account => $account)) {
# normal guest case
my $grantedGuest = OVH::Bastion::get_acl_way(way => "groupguest", group => $shortGroup, account => $account);
osh_debug("get_acls: grantedGuest=" . Data::Dumper::Dumper($grantedGuest));
# the guy must have a guest access but the group itself must also still have access
if ($grantedGuest && $grantedGroup) {
osh_debug("get_acls: adding grantedGuest to grants because is guest and group has access");
push @acls, {type => 'group-guest', group => $shortGroup, acl => $grantedGuest->value} if @{$grantedGuest->value};
}
# special legacy case; we also check if account has a legacy access for ip AND that the group ALSO has access to this ip
if ($grantedLegacy && $grantedGroup) {
osh_debug("get_acls: adding grantedLegacy to grants because legacy not null and group has access");
push @acls, {type => 'group-guest-legacy', group => $shortGroup, acl => $grantedLegacy->value} if @{$grantedLegacy->value};
}
}
else {
# should not happen
osh_debug("get_acls: $account is in group $shortGroup but is neither member or guest !!?");
}
}
return R('OK', value => \@acls);
}
# this function simply returns the requested acl
# i.e. personal or legacy access of an account, group access, or groupguest access.
# it just calls get_acl_from_file() with the proper file location
# returns: arrayref of entries, entry: { ip,user,port,forceKey,addedBy,addedDate,comment }
my %_cache_get_acl_way;
sub get_acl_way {
my %params = @_;
my $way = delete $params{'way'}; # personal|group|groupguest|legacy
my $group = delete $params{'group'}; # only meaningful and needed if type=group or type=groupguest
my $account = delete $params{'account'}; # only meaningful and needed if type=personal or type=groupguest
my $fnret;
my ($sysaccount, $remoteaccount);
my $key = $way;
my $prefix = 'allowed';
return R('ERR_MISSING_PARAMETER', msg => "Missing argument 'way'") if not defined $way;
if ($account) {
$fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account);
$fnret or return $fnret;
$account = $fnret->value->{'account'};
$sysaccount = $fnret->value->{'sysaccount'};
$remoteaccount = $fnret->value->{'remoteaccount'};
$prefix = "allowed_$remoteaccount" if $remoteaccount;
$key .= ":$account";
}
my $shortGroup;
if ($group) {
$fnret = OVH::Bastion::is_valid_group_and_existing(group => $group, groupType => 'key');
$fnret or return $fnret;
$group = $fnret->value->{'group'}; # untainted version
$shortGroup = $fnret->value->{'shortGroup'};
$key .= ":$group";
}
return $_cache_get_acl_way{$key} if exists $_cache_get_acl_way{$key};
if ($way eq 'personal') {
return R('ERR_MISSING_PARAMETER', msg => "Missing parameter 'account' for $way way") if not $account;
if (OVH::Bastion::is_mocking()) {
return _get_acl_from_file(mock_data => OVH::Bastion::mock_get_account_personal_accesses(account => $account));
}
if (!(-f -r "/home/allowkeeper/$sysaccount/$prefix.private")) {
return R('ERR_PERMISSION_DENIED', msg => "Couldn't open permission file with your current rights or it doesn't exist");
}
$_cache_get_acl_way{$key} = _get_acl_from_file(file => "/home/allowkeeper/$sysaccount/$prefix.private");
}
elsif ($way eq 'legacy') {
return R('ERR_MISSING_PARAMETER', msg => "Missing parameter 'account' for $way way") if not $account;
if (OVH::Bastion::is_mocking()) {
return _get_acl_from_file(mock_data => OVH::Bastion::mock_get_account_legacy_accesses(account => $account));
}
if (-f "/home/allowkeeper/$sysaccount/$prefix.private" && !-e "/home/allowkeeper/$sysaccount/$prefix.ip") {
# legacy file doesn't exist: no legacy rights
$_cache_get_acl_way{$key} = R('OK_EMPTY', value => []);
}
elsif (!(-f -r "/home/allowkeeper/$sysaccount/$prefix.ip")) {
return R('ERR_PERMISSION_DENIED', msg => "Couldn't open permission file with your current rights or it doesn't exist");
}
else {
$_cache_get_acl_way{$key} = _get_acl_from_file(file => "/home/allowkeeper/$sysaccount/$prefix.ip");
}
}
elsif ($way eq 'group') {
return R('ERR_MISSING_PARAMETER', msg => "Missing parameter 'group' for $way way") if not $group;
if (OVH::Bastion::is_mocking()) {
return _get_acl_from_file(mock_data => OVH::Bastion::mock_get_group_accesses(group => $group));
}
if (!(-f -r "/home/$group/$prefix.ip")) {
return R('ERR_PERMISSION_DENIED', msg => "Couldn't open permission file with your current rights or it doesn't exist");
}
$_cache_get_acl_way{$key} = _get_acl_from_file(file => "/home/$group/$prefix.ip");
}
elsif ($way eq 'groupguest') {
return R('ERR_MISSING_PARAMETER', msg => "Missing parameter 'account' or 'group' for $way way") if (not $group or not $account);
if (OVH::Bastion::is_mocking()) {
return _get_acl_from_file(mock_data => OVH::Bastion::mock_get_account_guest_accesses(group => $group, account => $account));
}
if (-f "/home/allowkeeper/$sysaccount/$prefix.private" && !-e "/home/allowkeeper/$sysaccount/$prefix.partial.$shortGroup") {
# guest file doesn't exist: no guest rights
$_cache_get_acl_way{$key} = R('OK_EMPTY', value => []);
}
elsif (!(-f -r "/home/allowkeeper/$sysaccount/$prefix.partial.$shortGroup")) {
return R('ERR_PERMISSION_DENIED', msg => "Couldn't open permission file with your current rights or it doesn't exist");
}
else {
$_cache_get_acl_way{$key} = _get_acl_from_file(file => "/home/allowkeeper/$sysaccount/$prefix.partial.$shortGroup");
}
}
return $_cache_get_acl_way{$key} if exists $_cache_get_acl_way{$key};
return R('ERR_INVALID_PARAMETER', msg => "Expected a parameter way with allowed values [personal,legacy,group,groupguest]");
}
# returns the parsed contents of an allowkeeper-style file
sub _get_acl_from_file {
my %params = @_;
my $file = $params{'file'};
my $mock_data = $params{'mock_data'};
my $fnret;
my @lines;
if ($mock_data) {
die "attempted to mock_data outside of mocking" if !OVH::Bastion::is_mocking();
@lines = @$mock_data;
}
else {
osh_debug("Reading ACL from '$file'");
if (not $file) {
return R('ERR_MISSING_PARAMETER', msg => "Missing parameter 'file'");
}
if (!(-e $file)) {
return R('ERR_CANNOT_OPEN_FILE', msg => "File '$file' doesn't exist");
}
if (!(-r _)) {
return R('ERR_CANNOT_OPEN_FILE', msg => "File '$file' is not readable");
}
if (open(my $fh_file, '<', $file)) {
@lines = <$fh_file>;
close($fh_file);
chomp @lines;
}
else {
return R('ERR_CANNOT_OPEN_FILE', msg => "Can't open '$file' for read ($!)");
}
}
my @entries;
foreach my $line (@lines) {
my ($ip, $user, $port, $comment, $forceKey, $expiry, $addedBy, $addedDate, $extra, $comment, $userComment);
# extract comment if any
$line =~ s/(#.*)// and $comment = $1;
# remove white spaces
$line =~ s/\s//g;
# empty line ?
$line or next;
# extract custom port if present
if ($line =~ s/:(\d+)$//) {
$fnret = OVH::Bastion::is_valid_port(port => $1);
if (!$fnret) {
osh_debug("skipping line <$line> because port ($1) is invalid");
next;
}
$port = $fnret->value;
}
# extract custom user if present
if ($line =~ s/^(\S+)\@//) {
$fnret = OVH::Bastion::is_valid_remote_user(user => $1);
if (!$fnret) {
osh_debug("skipping line <$line> because user ($1) is invalid");
next;
}
$user = $fnret->value;
}
# extract ip (v4 or v6)
if ($line =~ m{([0-9a-f./:]+)}i) {
$fnret = OVH::Bastion::is_valid_ip(ip => $1, allowPrefixes => 1, fast => 1);
if (!$fnret) {
osh_debug("skipping line <$line> because IP ($1) is invalid");
next;
}
$ip = $fnret->value->{'ip'};
}
else {
osh_debug("skipping line <$line> because no valid IP found");
next;
}
# if we have a comment, there might be stuff to extract from it
if (defined $comment) {
osh_debug("Parsing comment ($comment)");
if ($comment =~ s/# EXPIRY=(\d+)//) {
if ($1 < time()) {
osh_debug("found an expired line <$line>, skipping it");
next;
}
$expiry = $1 + 0;
}
if ($comment =~ s/# FORCEKEY=(\S+)//) {
$fnret = OVH::Bastion::is_valid_fingerprint(fingerprint => $1);
if (!$fnret) {
osh_debug("skipping line <$line> because invalid forcekey fingerprint ($1) found");
next;
}
$forceKey = $fnret->value->{'fingerprint'};
osh_debug("found a valid forced key <$forceKey>");
}
if ($comment =~ s/# COMMENT=<([^>]+)>//) {
$userComment = $1;
}
if ($comment =~ s/# add(ed)? by (\S+) on (\S+ \S+)//) {
$addedBy = $2;
$addedDate = $3;
}
$comment !~ /^\s*$/ and $extra = $comment;
}
push @entries,
{
ip => $ip,
user => $user,
port => $port,
forceKey => $forceKey,
expiry => $expiry,
addedBy => $addedBy,
addedDate => $addedDate,
userComment => $userComment,
comment => $extra,
};
}
osh_debug("found " . (scalar @entries) . " valid entries");
return R(@entries ? 'OK' : 'OK_EMPTY', value => \@entries);
}
1;