diff --git a/bin/helper/osh-groupSetServers b/bin/helper/osh-groupSetServers new file mode 100755 index 000000000..892dd6382 --- /dev/null +++ b/bin/helper/osh-groupSetServers @@ -0,0 +1,105 @@ +#! /usr/bin/perl -T +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +# KEYSUDOERS # as an aclkeeper, we can add/del a server from the group server list in /home/%GROUP%/allowed.ip +# KEYSUDOERS SUPEROWNERS, %%GROUP%-aclkeeper ALL=(%GROUP%) NOPASSWD: /usr/bin/env perl -T %BASEPATH%/bin/helper/osh-groupSetServers --group %GROUP% +# FILEMODE 0755 +# FILEOWN 0 0 + +#>HEADER +use common::sense; +use Getopt::Long qw(:config no_auto_abbrev no_ignore_case); +use JSON; + +use File::Basename; +use lib dirname(__FILE__) . '/../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Helper; + +# Fetch command options +my $fnret; +my ($result, @optwarns); +my $group; +eval { + local $SIG{__WARN__} = sub { push @optwarns, shift }; + $result = GetOptions( + "group=s" => sub { $group //= $_[1] }, # ignore subsequent --group on cmdline (anti-sudoers-override) + ); +}; +if ($@) { die $@ } + +if (!$result) { + local $" = ", "; + HEXIT('ERR_BAD_OPTIONS', msg => "Error parsing options: @optwarns"); +} + +OVH::Bastion::Helper::check_spurious_args(); + +if (not $group) { + HEXIT('ERR_MISSING_PARAMETER', msg => "Missing argument 'group'"); +} + +#
PARAMS:GROUP +osh_debug("Checking group $group"); +$fnret = OVH::Bastion::is_valid_group_and_existing(group => $group, groupType => 'key'); +$fnret or HEXIT($fnret); + +# get returned untainted value +$group = $fnret->value->{'group'}; +my $shortGroup = $fnret->value->{'shortGroup'}; +osh_debug("got group $group/$shortGroup"); + +#RIGHTSCHECK +if ($self eq 'root') { + osh_debug "Real root, skipping checks of permissions"; +} +else { + $fnret = OVH::Bastion::is_group_aclkeeper(account => $self, group => $shortGroup, sudo => 1, superowner => 1); + $fnret or HEXIT('ERR_NOT_ALLOWED', msg => "Sorry, you must be an aclkeeper of group $shortGroup"); +} + +#CODE + +# the new ACL is built by the plugin and sent to our STDIN in pre-parsed JSON format +my $jsonData = ; +my $data = eval { decode_json($jsonData); }; +if ($@) { + HEXIT('ERR_INVALID_ARGUMENT', msg => "Invalid JSON data sent by the plugin, couldn't decode"); +} + +if (!$data || ref $data ne 'ARRAY') { + HEXIT('ERR_INVALID_ARGUMENT', msg => "Invalid JSON import format sent by the plugin"); +} + +$fnret = OVH::Bastion::access_modify( + way => 'group', + action => 'clear', + group => $group, +); +$fnret or HEXIT($fnret); + +osh_info("Setting ACL entries, this may take a while..."); + +my @errors; +foreach my $entry (@$data) { + $fnret = OVH::Bastion::access_modify( + way => 'group', + action => 'add', + group => $group, + ip => $entry->{ip}, + user => $entry->{user}, + port => $entry->{port}, + ); + push @errors, $fnret if !$fnret; +} + +if (!@errors) { + HEXIT('OK', value => {ACL => $data, errors => []}); +} +HEXIT('OK_WITH_ERRORS', value => {ACL => $data, errors => \@errors}); diff --git a/bin/plugin/group-aclkeeper/groupSetServers b/bin/plugin/group-aclkeeper/groupSetServers new file mode 100755 index 000000000..09d8ac421 --- /dev/null +++ b/bin/plugin/group-aclkeeper/groupSetServers @@ -0,0 +1,188 @@ +#! /usr/bin/env perl +# vim: set filetype=perl ts=4 sw=4 sts=4 et: +use common::sense; +use JSON; + +use File::Basename; +use lib dirname(__FILE__) . '/../../../lib/perl'; +use OVH::Result; +use OVH::Bastion; +use OVH::Bastion::Plugin qw( :DEFAULT help ); +use OVH::Bastion::Plugin::ACL; + +my $remainingOptions = OVH::Bastion::Plugin::begin( + argv => \@ARGV, + header => "replace a group's current ACL by a new one", + userAllowWildcards => 1, + options => { + "group=s" => \my $group, + "dry-run" => \my $dryRun, + "skip-errors" => \my $skipErrors, + }, + helptext => <<'EOF', +Replace a group's current ACL by a new list + +Usage: --osh SCRIPT_NAME --group GROUP [OPTIONS] + + --group GROUP Specify which group to modify the ACL of + --dry-run Don't actually modify the ACL, just report whether the input contains errors + --skip-errors Don't abort on STDIN parsing errors, just skip the non-parseable lines + +The list of the assets to constitute the new ACL should then be given on ``STDIN``, +respecting the following format: ``[USER@]HOST[:PORT][ COMMENT]``, with ``USER`` and ``PORT`` being optional, +and ``HOST`` being either a hostname, an IP, or an IP block in CIDR notation. The ``COMMENT`` is also optional, +and may contain spaces. + +Example of valid lines to be fed through ``STDIN``:: + + server12.example.org + logs@server + 192.0.2.21 + host1.example.net:2222 host1 on secondary sshd with alternate port + root@192.0.2.0/24 production database cluster +EOF +); + +my $fnret; + +if (not $group) { + help(); + osh_exit 'ERR_MISSING_PARAMETER', "Missing mandatory parameter 'group'"; +} + +$fnret = OVH::Bastion::is_valid_group_and_existing(group => $group, groupType => "key"); +$fnret or osh_exit($fnret); + +# get returned untainted value +$group = $fnret->value->{'group'}; +my $shortGroup = $fnret->value->{'shortGroup'}; + +$fnret = OVH::Bastion::is_group_aclkeeper(account => $self, group => $shortGroup, superowner => 1); +$fnret + or osh_exit 'ERR_NOT_GROUP_ACLKEEPER', + "Sorry, you must be an aclkeeper of group $shortGroup to be able to add servers to it"; + +osh_info + "Specify the entries of the new ACL below, one per line, with the following format: [USER\@]HOST[:PORT][ COMMENT]"; +osh_info "The list ends at EOF (usually CTRL+D)."; +osh_info "You may abort with CTRL+C if needed."; + +my @ACL; +my @errors; +my $nbLines = 0; +my $comment; +while (my $line = ) { + # trim white spaces + $line =~ s/^\s+|\s+$//g; + + # empty line ? + $line or next; + + $nbLines++; + + my ($acl_user, $acl_host, $acl_ip, $acl_port); + if ($line =~ m{^(?:(\S+)\@)?([a-zA-Z0-9_./-]+)(?::(\d+))?(?:\s+(.+))?$}) { + $acl_user = $1; + $acl_host = $2; + $acl_port = $3; + $comment = $4; + } + else { + push @errors, "Couldn't parse the line '$line'"; + osh_warn($errors[-1]); + next; + } + + # check port + if (defined $acl_port) { + $fnret = OVH::Bastion::is_valid_port(port => $acl_port); + if (!$fnret) { + push @errors, "In line $nbLines ($line), port '$acl_port' is invalid"; + osh_warn($errors[-1]); + next; + } + $acl_port = $fnret->value; + } + + # check user + if (defined $acl_user) { + $fnret = OVH::Bastion::is_valid_remote_user(user => $acl_user, allowWildcards => 1); + if (!$fnret) { + push @errors, "In line $nbLines ($line), user '$acl_user' is invalid"; + osh_warn($errors[-1]); + next; + } + $acl_user = $fnret->value; + } + + # resolve host, unless it looks like a prefix + if ($acl_host =~ m{/}) { + $fnret = OVH::Bastion::is_valid_ip(ip => $acl_host, allowPrefixes => 1); + } + else { + $fnret = OVH::Bastion::get_ip(host => $acl_host); + } + if (!$fnret) { + push @errors, "In line $nbLines ($line), $fnret"; + osh_warn($errors[-1]); + next; + } + else { + $acl_ip = $fnret->value->{'ip'}; + } + + push @ACL, {ip => $acl_ip, port => $acl_port, user => $acl_user, comment => $comment}; +} + +osh_info("Parsed " . @ACL . "/$nbLines lines successfully"); + +if (@errors && !$skipErrors) { + osh_exit( + R( + 'ERR_INVALID_PARAMETER', + msg => "Aborting due to the " + . @errors + . " parsing or host resolving errors above, use --skip-errors to proceed anyway", + value => {parsedLines => $nbLines, dryrun => $dryRun ? \1 : \0, errors => \@errors, ACL => \@ACL}, + ) + ); +} + +if ($dryRun) { + osh_ok({parsedLines => $nbLines, errors => \@errors, dryrun => \1, ACL => \@ACL}); +} + +# +# Now do it +# + +if (!@ACL) { + osh_exit(R('OK_NO_CHANGE', msg => "No ACL was given, no change was made")); +} + +my @command = qw{ sudo -n -u }; +push @command, + ($group, '--', '/usr/bin/env', 'perl', '-T', $OVH::Bastion::BASEPATH . '/bin/helper/osh-groupSetServers'); +push @command, '--group', $group; + +$fnret = OVH::Bastion::helper(cmd => \@command, stdin_str => encode_json(\@ACL)); +$fnret or osh_exit($fnret); + +# merge both error lists +if ($fnret->value && $fnret->value->{'errors'}) { + push @errors, @{$fnret->value->{'errors'} || []}; +} + +osh_exit( + R( + 'OK', + msg => "The new ACL has been set with " . @{$fnret->value->{'ACL'}} . " entries and " . @errors . " errors", + value => { + parsedLines => $nbLines, + dryrun => $dryRun ? \1 : \0, + group => $shortGroup, + ACL => $fnret->value->{'ACL'}, + errors => \@errors + } + ) +); diff --git a/doc/sphinx/plugins/group-aclkeeper/groupSetServers.rst b/doc/sphinx/plugins/group-aclkeeper/groupSetServers.rst new file mode 100644 index 000000000..a0b67aa5c --- /dev/null +++ b/doc/sphinx/plugins/group-aclkeeper/groupSetServers.rst @@ -0,0 +1,40 @@ +================ +groupSetServers +================ + +Replace a group's current ACL by a new list +=========================================== + + +.. admonition:: usage + :class: cmdusage + + --osh groupSetServers --group GROUP [OPTIONS] + +.. program:: groupSetServers + + +.. option:: --group GROUP + + Specify which group to modify the ACL of + +.. option:: --dry-run + + Don't actually modify the ACL, just report whether the input contains errors + +.. option:: --skip-errors + + Don't abort on STDIN parsing errors, just skip the non-parseable lines + + +The list of the assets to constitute the new ACL should then be given on ``STDIN``, +respecting the following format: ``[USER@]HOST[:PORT][ COMMENT]``, with ``USER`` and ``PORT`` being optional, +and ``HOST`` being either a hostname, an IP, or an IP block in CIDR notation. The ``COMMENT`` is also optional, +and may contain spaces. + +Example of valid lines to be fed through ``STDIN``:: + + server12.example.org + logs@server + host1.example.net:2222 host1 on secondary sshd with alternate port + root@192.0.2.0/24 production database cluster diff --git a/doc/sphinx/plugins/group-aclkeeper/index.rst b/doc/sphinx/plugins/group-aclkeeper/index.rst index b3007d6a0..f95d8d1ad 100644 --- a/doc/sphinx/plugins/group-aclkeeper/index.rst +++ b/doc/sphinx/plugins/group-aclkeeper/index.rst @@ -6,3 +6,4 @@ group-aclkeeper plugins groupAddServer groupDelServer + groupSetServers diff --git a/etc/sudoers.group.template.d/500-base.sudoers b/etc/sudoers.group.template.d/500-base.sudoers index 611e170b1..cfc64acd2 100644 --- a/etc/sudoers.group.template.d/500-base.sudoers +++ b/etc/sudoers.group.template.d/500-base.sudoers @@ -34,5 +34,8 @@ SUPEROWNERS, %%GROUP%-gatekeeper ALL=(allowkeeper) NOPASSWD: /usr/bin/env perl - # as an aclkeeper, we can add/del a server from the group server list in /home/%GROUP%/allowed.ip SUPEROWNERS, %%GROUP%-aclkeeper ALL=(%GROUP%) NOPASSWD: /usr/bin/env perl -T %BASEPATH%/bin/helper/osh-groupAddServer --group %GROUP% * +# as an aclkeeper, we can replace the group servers list in /home/%GROUP%/allowed.ip in batch with one command +SUPEROWNERS, %%GROUP%-aclkeeper ALL=(%GROUP%) NOPASSWD: /usr/bin/env perl -T %BASEPATH%/bin/helper/osh-groupSetServers --group %GROUP% + # as an owner, we can delete our own group SUPEROWNERS, %%GROUP%-owner ALL=(root) NOPASSWD: /usr/bin/env perl -T %BASEPATH%/bin/helper/osh-groupDelete --group %GROUP% diff --git a/lib/perl/OVH/Bastion/allowkeeper.inc b/lib/perl/OVH/Bastion/allowkeeper.inc index e878ae99b..b4e0a2820 100644 --- a/lib/perl/OVH/Bastion/allowkeeper.inc +++ b/lib/perl/OVH/Bastion/allowkeeper.inc @@ -326,9 +326,15 @@ sub access_modify { my $fnret; - foreach my $mandatoryParam (qw/action ip way/) { - if (!$params{$mandatoryParam}) { - return R('ERR_MISSING_PARAMETER', msg => "Missing parameter '$mandatoryParam'"); + if (!grep { $action eq $_ } qw{ add del clear }) { + return R('ERR_INVALID_PARAMETER', msg => "Action should be add, del or clear"); + } + + if ($action ne 'clear') { + foreach my $mandatoryParam (qw/action ip way/) { + if (!$params{$mandatoryParam}) { + return R('ERR_MISSING_PARAMETER', msg => "Missing parameter '$mandatoryParam'"); + } } } @@ -363,26 +369,24 @@ sub access_modify { return R('ERR_INVALID_PARAMETER', msg => "Parameter 'way' must be either personal, group or groupguest"); } - if ($action ne 'add' and $action ne 'del') { - return R('ERR_INVALID_PARAMETER', msg => "Action should be either 'del' or 'add'"); - } - # check ip - $fnret = OVH::Bastion::is_valid_ip(ip => $ip, allowPrefixes => 1); - return $fnret unless $fnret; - $ip = $fnret->value->{'ip'}; - - if ($fnret->value->{'type'} eq 'prefix') { - my $ipVersion = $fnret->value->{'version'}; - if (defined $widestVxPrefix{$ipVersion} && $fnret->value->{'prefixlen'} < $widestVxPrefix{$ipVersion}) { - return R( - 'ERR_INVALID_PARAMETER', - msg => sprintf( - "Specified prefix (/%d) is too wide, maximum allowed for IPv%d is /%d by this bastion policy", - $fnret->value->{'prefixlen'}, - $ipVersion, $widestVxPrefix{$ipVersion} - ), - ); + if ($action ne 'clear') { + $fnret = OVH::Bastion::is_valid_ip(ip => $ip, allowPrefixes => 1); + return $fnret unless $fnret; + $ip = $fnret->value->{'ip'}; + + if ($fnret->value->{'type'} eq 'prefix') { + my $ipVersion = $fnret->value->{'version'}; + if (defined $widestVxPrefix{$ipVersion} && $fnret->value->{'prefixlen'} < $widestVxPrefix{$ipVersion}) { + return R( + 'ERR_INVALID_PARAMETER', + msg => sprintf( + "Specified prefix (/%d) is too wide, maximum allowed for IPv%d is /%d by this bastion policy", + $fnret->value->{'prefixlen'}, + $ipVersion, $widestVxPrefix{$ipVersion} + ), + ); + } } } @@ -404,7 +408,8 @@ sub access_modify { my ($remoteaccount, $sysaccount); if (defined $account) { # accountType==normal : account must NOT be a realm_* account (but can be a realm/jdoe account) - $fnret = OVH::Bastion::is_bastion_account_valid_and_existing(account => $account, accountType => 'normal'); + $fnret = + OVH::Bastion::is_bastion_account_valid_and_existing(account => $account, accountType => 'normal', cache => 1); $fnret or return $fnret; $sysaccount = $fnret->value->{'sysaccount'}; $account = $fnret->value->{'account'}; @@ -414,7 +419,7 @@ sub access_modify { # check group my $shortGroup; if (defined $group) { - $fnret = OVH::Bastion::is_valid_group_and_existing(group => $group, groupType => 'key'); + $fnret = OVH::Bastion::is_valid_group_and_existing(group => $group, groupType => 'key', cache => 1); $fnret or return $fnret; $group = $fnret->value->{'group'}; # untainted $shortGroup = $fnret->value->{'shortGroup'}; # untainted @@ -509,28 +514,30 @@ sub access_modify { } # end of dryrun - return R('OK', msg => "Would have added the access but we've been called with dryrun") if $dryrun; + return R('OK', msg => "Would have modified the access ($action) but we've been called with dryrun") if $dryrun; - # now, check if the access we're being asked to change is already in place or not - osh_debug( - "for action $action of $user\@$ip:$port of way $way with account=$account and group=$group, checking if already granted" - ); - $fnret = OVH::Bastion::is_access_way_granted( - user => $user, - ip => $ip, - port => $port, - way => $way, - group => $shortGroup, - account => $account, - exactMatch => 1, # we're checking if the exact right we're asked to modify exists or not - ); - osh_debug("... result is $fnret"); + if ($action ne 'clear') { + # now, check if the access we're being asked to change is already in place or not + osh_debug( + "for action $action of $user\@$ip:$port of way $way with account=$account and group=$group, checking if already granted" + ); + $fnret = OVH::Bastion::is_access_way_granted( + user => $user, + ip => $ip, + port => $port, + way => $way, + group => $shortGroup, + account => $account, + exactMatch => 1, # we're checking if the exact right we're asked to modify exists or not + ); + osh_debug("... result is $fnret"); - if ($action eq 'add' and $fnret) { - return R('OK_NO_CHANGE', msg => "The requested access to add was already granted"); - } - elsif ($action eq 'del' and not $fnret) { - return R('OK_NO_CHANGE', msg => "The requested access to delete was not found, no change made"); + if ($action eq 'add' and $fnret) { + return R('OK_NO_CHANGE', msg => "The requested access to add was already granted"); + } + elsif ($action eq 'del' and not $fnret) { + return R('OK_NO_CHANGE', msg => "The requested access to delete was not found, no change made"); + } } # ok, now do the change, first define this sub @@ -543,19 +550,41 @@ sub access_modify { # check if we can access the file if (!(-e $file)) { - # it doesn't exist yet, create it OVH::Bastion::touch_file($file, oct(644)); if (!(-e $file)) { + warn_syslog("Couldn't create $file ($!)"); return R('ERR_CANNOT_CREATE_FILE', msg => "File '$file' is missing and couldn't be created"); } } # can we write to it ? if (!(-w $file)) { + warn_syslog("Couldn't write to $file ($!)"); return R('ERR_CANNOT_OPEN_FILE', msg => "File '$file' cannot be written to"); } + # if we're being asked to clear, it's pretty straightforward + if ($action eq 'clear') { + if (truncate($file, 0)) { + OVH::Bastion::syslogFormatted( + severity => 'info', + type => 'acl', + fields => [ + ['action', 'clear'], + ['type', $params{'way'}], + ['group', $shortGroup], + ['account', $params{'account'}], + ] + ); + return R('OK', msg => "Accesses successfully cleared"); + } + else { + warn_syslog("Couldn't truncate $file ($!)"); + return R('ERR_CANNOT_OPEN_FILE', msg => "Unable to truncate $file"); + } + } + # build the line we're either adding or looking for (to delete it) my $entry = $ip; $entry = $user . "@" . $entry if defined $user; diff --git a/tests/functional/tests.d/350-groups.sh b/tests/functional/tests.d/350-groups.sh index 1081b233b..1a0f3e6d7 100644 --- a/tests/functional/tests.d/350-groups.sh +++ b/tests/functional/tests.d/350-groups.sh @@ -1039,6 +1039,56 @@ EOS contain REGEX '127\.0\.0\.1[[:space:]]+22[[:space:]]+g1[[:space:]]+'$group1'\(group\)[[:space:]]+'$account2'[[:space:]]' contain '1 accesses listed' + # test groupSetServers here, then restore the previous ACL so the tests can continue + # shellcheck disable=SC1004 + script groupSetServers_valid_dryrun $a1 --osh groupSetServers --group $group1 --dry-run '< <(printf \ + "%s\n%s\n%s\n" \ + "user@127.0.0.1:1234 comment with spaces" \ + "localhost" \ + "0.0.0.0/4:42" \ + )' + json .command groupSetServers .error_code OK .value.parsedLines 3 .value.errors '[]' .value.dryrun true + json '.value.ACL[0].user' user '.value.ACL[0].ip' 127.0.0.1 '.value.ACL[0].port' 1234 '.value.ACL[0].comment' 'comment with spaces' + json '.value.ACL[1].user' null '.value.ACL[1].ip' 127.0.0.1 '.value.ACL[1].port' null '.value.ACL[1].comment' null + json '.value.ACL[2].user' null '.value.ACL[2].ip' 0.0.0.0/4 '.value.ACL[2].port' 42 '.value.ACL[2].comment' null + + # shellcheck disable=SC1004 + script groupSetServers_invalid $a1 --osh groupSetServers --group $group1 --dry-run '< <(printf \ + "%s\n%s\n%s\n%s\n" \ + "inva{}lid@127.0.0.1" \ + "doesntexist.invalid" \ + "0.0.0.0:77777" \ + "203.0.113.4/4:42" \ + )' + json .command groupSetServers .error_code ERR_INVALID_PARAMETER .value.parsedLines 4 '.value.errors|length' 4 .value.dryrun true + + # restore the previous ACL + # shellcheck disable=SC1004 + script groupSetServers_valid_skiperrors $a1 --osh groupSetServers --group $group1 --skip-errors '< <(printf \ + "%s\n%s\n%s\n%s\n%s\n" \ + "g1@127.0.0.1:22" \ + "badport:99999" \ + "g2@127.0.0.2:22" \ + "127.0.0.10" \ + "127.0.0.11" \ + )' + json .command groupSetServers .error_code OK .value.parsedLines 5 '.value.errors|length' 1 .value.dryrun false + json '.value.ACL[0].user' 'g1' '.value.ACL[0].ip' 127.0.0.1 '.value.ACL[0].port' 22 '.value.ACL[0].comment' null + json '.value.ACL[1].user' 'g2' '.value.ACL[1].ip' 127.0.0.2 '.value.ACL[1].port' 22 '.value.ACL[1].comment' null + json '.value.ACL[2].user' null '.value.ACL[2].ip' 127.0.0.10 '.value.ACL[2].port' null '.value.ACL[2].comment' null + json '.value.ACL[3].user' null '.value.ACL[3].ip' 127.0.0.11 '.value.ACL[3].port' null '.value.ACL[3].comment' null + + success groupListServers_verify_after_groupSetServers $a1 --osh groupListServers --group $group1 + json .command groupListServers .error_code OK + contain REGEX '127\.0\.0\.1[[:space:]]+22[[:space:]]+g1[[:space:]]+'$group1'\(group\)[[:space:]]+'$account1'[[:space:]]' + contain REGEX '127\.0\.0\.2[[:space:]]+22[[:space:]]+g2[[:space:]]+'$group1'\(group\)[[:space:]]+'$account1'[[:space:]]' + contain REGEX '127\.0\.0\.10[[:space:]]+\*[[:space:]]+\*[[:space:]]+'$group1'\(group\)[[:space:]]+'$account1'[[:space:]]' + contain REGEX '127\.0\.0\.11[[:space:]]+\*[[:space:]]+\*[[:space:]]+'$group1'\(group\)[[:space:]]+'$account1'[[:space:]]' + nocontain REGEX '127\.0\.0\.12[[:space:]]+\*[[:space:]]+\*[[:space:]]+'$group1'\(group\)[[:space:]]+'$account1'[[:space:]]' + contain '4 accesses listed' + + # /groupSetServers tests + # group1: a1(owner,aclkeeper,gatekeeper,member) a2() servers(127.0.0.10,127.0.0.11) plgfail list $a2 --osh groupListServers --group $group1 json .command groupListServers .error_code KO_ACCESS_DENIED .value null