2
0
mirror of https://github.com/xcat2/xcat-core.git synced 2026-09-09 05:00:44 +00:00

Merge pull request #7816 from VersatusHPC/fix/sudoer-postscript-password

fix(sudoer): take the password from the passwd table
This commit is contained in:
Daniel Hilst
2026-09-08 10:29:50 -03:00
committed by GitHub
5 changed files with 924 additions and 42 deletions
@@ -146,6 +146,14 @@ You can enable **secureroot** feature for more secure consideration. ::
Then, after the new ``packimage`` or ``nodeset`` command, the root password hash can only be acquired on-the-fly with strict security control.
The ``sudoer`` postscript creates a login user with passwordless ``sudo``, named ``xcat`` unless the postscript runs as ``sudoer -u <username>``, and adds the cluster SSH host key to its ``authorized_keys``. An existing login account is kept, together with the other keys in its ``authorized_keys``. Root, service accounts, and accounts without a login shell are refused. The password of the account is always managed: it comes from the ``passwd`` table, and the account is locked when the table has no row for it: ::
chtab key=system,username=xcat passwd.password=<password>
The node acquires the password hash on-the-fly, the same way as the **secureroot** root password hash. The management node serves the hash only for the sudoer named in the ``postscripts`` or ``postbootscripts`` of the node, its osimage, or ``xcatdefaults``. The postscript grants ``sudo`` and installs the key only after the password is set or locked. If the management node does not answer or refuses the request, the postscript fails and leaves the account unprivileged.
The ``sudo`` rule lives in ``/etc/sudoers.d/xcat-sudoer``, which each run replaces and which records the account it grants. A rerun with another name revokes the previous account: it loses the rule, its password is locked, and the cluster key is removed from its ``authorized_keys``, while its other keys and its login stay. Lines that an older version of the postscript appended to ``/etc/sudoers`` are moved out on the first run, after ``visudo`` accepts the result. A failed run removes the managed rule, so a node keeps no grant from an earlier run. On a node without ``/etc/sudoers.d`` the rule is appended once to ``/etc/sudoers`` and a rename does not revoke the previous name.
Nodes Inter-Access in The Cluster
---------------------------------
+64 -6
View File
@@ -328,12 +328,13 @@ sub process_request
next;
} elsif ($parm =~ /xcat_secure_pw:/) {
xCAT::MsgUtils->trace(0, 'I', "credentials: sending $parm to $client");
my @users=split(/:/,$parm);
if (defined($users[1]) and $users[1] eq 'root') {
my $pass = xCAT::PasswordUtils::crypt_system_password();
if ($pass) {
push @{$rsp->{'data'}}, { content => [ $pass ], desc => [ $parm ] };
}
my (undef, $user) = split(/:/, $parm);
my ($hash, $error) = system_password_hash($client, $user);
if ($hash) {
push @{ $rsp->{'data'} }, { content => [$hash], desc => [$parm] };
} else {
push @{ $rsp->{'error'} }, "Unable to get the password hash for $parm: $error";
xCAT::MsgUtils->trace(0, 'W', "credentials: Unable to get the password hash for $parm: $error");
}
next;
} else {
@@ -550,6 +551,63 @@ sub _sign_x509_certificate {
return $certificate;
}
# A sudoer without a passwd row gets the locked field so the node applies the reply as is.
sub system_password_hash {
my ($node, $user) = @_;
unless (defined($user) and $user =~ /^[A-Za-z_][A-Za-z0-9_.-]{0,31}$/) {
return (undef, 'invalid user name');
}
unless ($user eq 'root' or configured_sudoers($node)->{$user}) {
return (undef, "$user is not a configured sudoer of $node");
}
my %key = (key => 'system', username => $user);
my $passwd = xCAT::Table->new('passwd', -create => 0);
my $entry = $passwd ? $passwd->getAttribs(\%key, 'password') : undef;
$passwd->close() if $passwd;
unless ($entry and defined($entry->{password})) {
return ('!', undef) unless $user eq 'root';
return (undef, 'no password in the passwd table for root');
}
my $hash = xCAT::PasswordUtils::crypt_system_password('passwd', \%key, [ 'password', 'cryptmethod' ]);
return ($hash, $hash ? undef : "unable to hash the password of $user");
}
# The sudoer postscript entries of a node, from the same three sources
# Postage.pm uses: xcatdefaults, the osimage of provmethod, and the node.
sub configured_sudoers {
my $node = shift;
my @lists;
my $posttab = xCAT::Table->new('postscripts', -create => 0);
if ($posttab) {
my $defaults = $posttab->getAttribs({ node => 'xcatdefaults' }, 'postscripts', 'postbootscripts');
my $own = $posttab->getNodeAttribs($node, [ 'postscripts', 'postbootscripts' ]);
push @lists, map { ($_->{postscripts}, $_->{postbootscripts}) } grep { $_ } ($defaults, $own);
$posttab->close();
}
my $typetab = xCAT::Table->new('nodetype', -create => 0);
my $type = $typetab ? $typetab->getNodeAttribs($node, ['provmethod']) : undef;
$typetab->close() if $typetab;
if ($type and $type->{provmethod} and $type->{provmethod} !~ /^(?:install|netboot|statelite)$/) {
my $imagetab = xCAT::Table->new('osimage', -create => 0);
my $image = $imagetab ? $imagetab->getAttribs({ imagename => $type->{provmethod} }, 'postscripts', 'postbootscripts') : undef;
$imagetab->close() if $imagetab;
push @lists, ($image->{postscripts}, $image->{postbootscripts}) if $image;
}
my %sudoers;
foreach my $entry (map { split /,/, $_ } grep { defined } @lists) {
next unless $entry =~ /^\s*sudoer(?:\s+(.*?))?\s*$/;
my $args = defined $1 ? $1 : '';
my $name = $args =~ /(?:^|\s)-u\s*(\S+)/ ? $1 : 'xcat';
$sudoers{$name} = 1;
}
return \%sudoers;
}
sub ok_with_node {
my $node = shift;
@@ -0,0 +1,258 @@
#!/usr/bin/env perl
## no critic (TestingAndDebugging::ProhibitNoStrict)
use strict;
use warnings;
use File::Spec;
use FindBin;
use Test::More;
BEGIN {
package xCAT::Table;
our %rows;
our @opened;
sub import { }
sub new {
my ($class, $table, @options) = @_;
push @opened, [ $table, @options ];
return bless { table => $table }, $class;
}
sub getAttribs {
my ($self, $key, @columns) = @_;
foreach my $row ( @{ $rows{ $self->{table} } || [] } ) {
next if grep { !defined($row->{$_}) or $row->{$_} ne $key->{$_} } keys %$key;
return { map { $_ => $row->{$_} } @columns };
}
return;
}
sub getNodeAttribs {
my ($self, $node, $columns) = @_;
return $self->getAttribs({ node => $node }, @$columns);
}
sub close { }
$INC{'xCAT/Table.pm'} = 1;
package xCAT::NodeRange;
sub import {
no strict 'refs';
*{ caller() . '::noderange' } = \&noderange;
}
sub noderange { return ( $_[0] ); }
$INC{'xCAT/NodeRange.pm'} = 1;
package xCAT::Zone;
sub import { }
$INC{'xCAT/Zone.pm'} = 1;
package xCAT::Utils;
sub import { }
sub isAIX { return 0; }
sub isServiceNode { return 0; }
$INC{'xCAT/Utils.pm'} = 1;
package xCAT::NetworkUtils;
sub import { }
sub getipaddr { return (); }
$INC{'xCAT/NetworkUtils.pm'} = 1;
package xCAT::PasswordUtils;
our @calls;
sub import { }
sub crypt_system_password {
my ( $table, $key, $fields ) = @_;
$key ||= { key => 'system', username => 'root' };
push @calls, [ $table, {%$key}, $fields ? [@$fields] : undef ];
return '$6$salt$' . $key->{username};
}
$INC{'xCAT/PasswordUtils.pm'} = 1;
package xCAT::TableUtils;
sub import { }
sub get_site_attribute { return ('192.0.2.10'); }
$INC{'xCAT/TableUtils.pm'} = 1;
package xCAT::MsgUtils;
our @traces;
sub import { }
sub trace { push @traces, [@_]; }
sub message {
my ( $class, $type, $rsp, $callback ) = @_;
$callback->($rsp) if ref($callback) eq 'CODE';
}
$INC{'xCAT/MsgUtils.pm'} = 1;
package LWP;
sub import { }
$INC{'LWP.pm'} = 1;
package LWP::UserAgent;
sub new { return bless {}, shift; }
package HTTP::Request::Common;
sub import {
no strict 'refs';
*{ caller() . '::GET' } = sub { return $_[0]; };
}
$INC{'HTTP/Request/Common.pm'} = 1;
}
my $repo_root = File::Spec->catdir( $FindBin::Bin, '..', '..' );
my $plugin = File::Spec->catfile(
$repo_root, qw(xCAT-server lib xcat plugins credentials.pm)
);
require $plugin;
# The node callback on port 300 is the only part that needs a live node.
{
no warnings qw(redefine once);
*xCAT_plugin::credentials::ok_with_node = sub { return 1; };
}
sub request_hash {
my ( $node, $parameter ) = @_;
my @responses;
@xCAT::PasswordUtils::calls = ();
@xCAT::Table::opened = ();
@xCAT::MsgUtils::traces = ();
xCAT_plugin::credentials::process_request(
{
command => ['getcredentials'],
arg => [$parameter],
_xcat_clienthost => [$node],
callback_port => [300],
},
sub { push @responses, @_; }
);
return \@responses;
}
sub served_hash {
my ($responses) = @_;
my ($data) = grep { $_->{data} } @$responses;
return unless $data;
return ( $data->{data}->[0]->{content}->[0], $data->{data}->[0]->{desc}->[0] );
}
sub served_error {
my ($responses) = @_;
my ($error) = grep { $_->{error} } @$responses;
return unless $error;
return $error->{error}->[0];
}
%xCAT::Table::rows = (
passwd => [
{ key => 'system', username => 'root', password => 'rootpw', cryptmethod => undef },
{ key => 'system', username => 'xcat', password => 'sudoerpw', cryptmethod => 'sha512' },
{ key => 'system', username => 'ops', password => 'opspw', cryptmethod => undef },
{ key => 'system', username => 'img', password => 'imgpw', cryptmethod => undef },
{ key => 'system', username => 'nopw', password => undef, cryptmethod => undef },
],
postscripts => [
{ node => 'xcatdefaults', postscripts => 'syslog,remoteshell', postbootscripts => 'otherpkgs' },
{ node => 'compute-01', postscripts => 'sudoer,confignetwork', postbootscripts => undef },
{ node => 'compute-02', postscripts => undef, postbootscripts => 'sudoer -u ops' },
{ node => 'compute-03', postscripts => 'confignetwork', postbootscripts => undef },
{ node => 'compute-04', postscripts => undef, postbootscripts => undef },
{ node => 'compute-05', postscripts => 'sudoer -unopw', postbootscripts => undef },
],
nodetype => [
{ node => 'compute-01', provmethod => 'install' },
{ node => 'compute-04', provmethod => 'alma9-x86_64-install-compute' },
],
osimage => [
{ imagename => 'alma9-x86_64-install-compute', postscripts => 'sudoer -u img', postbootscripts => undef },
],
);
{
my ( $hash, $desc ) = served_hash( request_hash( 'compute-03', 'xcat_secure_pw:root' ) );
is( $hash, '$6$salt$root', 'root hash is served without a sudoer entry' );
is( $desc, 'xcat_secure_pw:root', 'the reply names the requested credential' );
is_deeply(
$xCAT::PasswordUtils::calls[0],
[ 'passwd', { key => 'system', username => 'root' }, [ 'password', 'cryptmethod' ] ],
'root is hashed from the passwd row key=system,username=root'
);
}
{
my ( $hash, $desc ) = served_hash( request_hash( 'compute-01', 'xcat_secure_pw:xcat' ) );
is( $hash, '$6$salt$xcat', 'the default sudoer of a node with the sudoer postscript gets its hash' );
is( $desc, 'xcat_secure_pw:xcat', 'the reply names the sudoer credential' );
is_deeply(
$xCAT::PasswordUtils::calls[0],
[ 'passwd', { key => 'system', username => 'xcat' }, [ 'password', 'cryptmethod' ] ],
'the sudoer is hashed from the passwd row key=system,username=xcat'
);
}
{
my $responses = request_hash( 'compute-01', 'xcat_secure_pw:ops' );
ok( !defined( ( served_hash($responses) )[0] ), 'a user that is not the configured sudoer gets no hash' );
like( served_error($responses), qr/ops is not a configured sudoer of compute-01/,
'the reply says the user is not configured for the node' );
is( scalar @xCAT::PasswordUtils::calls, 0, 'nothing is hashed for an unconfigured user' );
}
{
my ( $hash ) = served_hash( request_hash( 'compute-02', 'xcat_secure_pw:ops' ) );
is( $hash, '$6$salt$ops', 'a sudoer named with -u in postbootscripts gets its hash' );
my $responses = request_hash( 'compute-02', 'xcat_secure_pw:xcat' );
like( served_error($responses), qr/xcat is not a configured sudoer/,
'the default name is not served when the node names another sudoer' );
}
{
my $responses = request_hash( 'compute-03', 'xcat_secure_pw:xcat' );
ok( !defined( ( served_hash($responses) )[0] ), 'a node without the sudoer postscript gets no hash' );
like( served_error($responses), qr/not a configured sudoer of compute-03/,
'the reply names the node without the postscript' );
}
{
my ( $hash ) = served_hash( request_hash( 'compute-04', 'xcat_secure_pw:img' ) );
is( $hash, '$6$salt$img', 'a sudoer configured on the osimage of provmethod gets its hash' );
}
{
local $xCAT::Table::rows{postscripts}->[0]->{postbootscripts} = 'otherpkgs,sudoer -u ops';
my ( $hash ) = served_hash( request_hash( 'compute-03', 'xcat_secure_pw:ops' ) );
is( $hash, '$6$salt$ops', 'a sudoer configured in xcatdefaults applies to every node' );
}
{
my ( $field, $desc ) = served_hash( request_hash( 'compute-05', 'xcat_secure_pw:nopw' ) );
is( $field, '!', 'a sudoer row without a password gets the locked field' );
is( $desc, 'xcat_secure_pw:nopw', 'the locked reply names the sudoer credential' );
is( scalar @xCAT::PasswordUtils::calls, 0, 'nothing is hashed for an empty password' );
}
{
local $xCAT::Table::rows{passwd} = [ grep { $_->{username} ne 'xcat' } @{ $xCAT::Table::rows{passwd} } ];
my ( $field ) = served_hash( request_hash( 'compute-01', 'xcat_secure_pw:xcat' ) );
is( $field, '!', 'a sudoer without a passwd row gets the locked field' );
}
{
local $xCAT::Table::rows{passwd} = [ grep { $_->{username} ne 'root' } @{ $xCAT::Table::rows{passwd} } ];
my $responses = request_hash( 'compute-01', 'xcat_secure_pw:root' );
ok( !defined( ( served_hash($responses) )[0] ), 'root without a passwd row is not locked' );
like( served_error($responses), qr/no password in the passwd table for root/,
'root without a passwd row answers with an error' );
}
{
my $responses = request_hash( 'compute-01', 'xcat_secure_pw:../root' );
ok( !defined( ( served_hash($responses) )[0] ), 'an invalid user name gets no hash' );
like( served_error($responses), qr/invalid user name/, 'the reply carries an error for an invalid name' );
is( scalar @xCAT::Table::opened, 0, 'an invalid user name never reaches a table' );
}
{
my $responses = request_hash( 'compute-01', 'xcat_secure_pw:' );
ok( !defined( ( served_hash($responses) )[0] ), 'a missing user name gets no hash' );
is( scalar @xCAT::Table::opened, 0, 'a missing user name never reaches a table' );
}
done_testing();
+399
View File
@@ -0,0 +1,399 @@
#!/usr/bin/env perl
use strict;
use warnings;
use File::Path qw(make_path);
use File::Temp qw(tempdir);
use FindBin;
use Test::More;
my $script = "$FindBin::Bin/../../xCAT/postscripts/sudoer";
plan skip_all => 'sudoer postscript not found' unless -r $script;
plan skip_all => 'postscript targets Linux nodes' unless $^O eq 'linux';
my $source = read_file($script);
sub field_reply {
my ($field) = @_;
return <<"XML";
<xcatresponse>
<data>
<content>$field</content>
<desc>xcat_secure_pw</desc>
</data>
</xcatresponse>
<xcatresponse>
<serverdone></serverdone>
</xcatresponse>
XML
}
my $hash_reply = field_reply('$6$saltsalt$hashhashhash');
my $locked_reply = field_reply('!');
my $error_reply = <<'XML';
<xcatresponse>
<error>Unable to get the password hash for xcat_secure_pw: xcat is not a configured sudoer</error>
<errorcode>1</errorcode>
</xcatresponse>
<xcatresponse>
<serverdone></serverdone>
</xcatresponse>
XML
my $managed = '/etc/sudoers.d/xcat-sudoer';
my $legacy_rule = "xcat ALL=(ALL) NOPASSWD: ALL\n";
my $legacy_tty = "Defaults:xcat !requiretty\n";
sub managed_for {
my ($name) = @_;
return "# xCAT sudoer: $name\n$name ALL=(ALL) NOPASSWD: ALL\nDefaults:$name !requiretty\n";
}
# Build a scratch tree. Existing accounts are passwd lines "name:uid:shell";
# the useradd stub appends the account it creates, so getent sees it.
sub scratch_tree {
my (%opt) = @_;
my $root = tempdir(CLEANUP => 1);
make_path("$root/bin", "$root/etc", "$root/xcatpost/hostkeys");
my $passwd = '';
foreach my $account (@{ $opt{accounts} || [] }) {
my ($name, $uid, $shell) = split /:/, $account;
make_path("$root/home/$name");
$passwd .= "$name:x:$uid:100::$root/home/$name:$shell\n";
}
write_file("$root/etc/passwd", $passwd);
write_stub("$root/bin/useradd",
"echo \"useradd \$*\" >> '$root/calls'\n"
. ($opt{useradd_fails} ? "exit 1\n"
: "name=\$2; mkdir -p '$root/home/'\$name\n"
. "echo \"\$name:x:1001:100::$root/home/\$name:/bin/bash\" >> '$root/etc/passwd'\n"));
write_stub("$root/bin/usermod",
"echo \"usermod \$*\" >> '$root/calls'\n" . ($opt{usermod_fails} ? "exit 1\n" : ''));
write_stub("$root/bin/getent",
"awk -F: -v n=\"\$2\" '\$1 == n' '$root/etc/passwd'\n");
write_stub("$root/bin/visudo",
"echo \"visudo \$*\" >> '$root/calls'\n" . ($opt{visudo_rejects} ? "exit 1\n" : ''));
write_stub("$root/bin/getcredentials.awk",
"echo \"getcredentials \$*\" >> '$root/calls'\ncat '$root/reply.xml'\n");
write_stub("$root/bin/allowcred.awk", "sleep 30\n");
write_stub("$root/bin/logger", "echo \"\$*\" >> '$root/log'\n");
write_stub("$root/bin/chown", "exit 0\n");
write_file("$root/xcatlib.sh", "restartservice(){ :; }\n");
write_file("$root/etc/redhat-release", "stub\n");
write_file("$root/etc/login.defs", "UID_MIN 1000\nUID_MAX 60000\n");
# ssh-keygen leaves a trailing space after an empty comment
write_file("$root/xcatpost/hostkeys/ssh_host_rsa_key.pub", "ssh-rsa RSAKEY \n");
write_file("$root/xcatpost/hostkeys/ssh_host_dsa_key.pub", "ssh-dss DSAKEY \n");
# The includedir line names the scratch directory because the path
# rewrite below also rewrites the pattern the postscript greps for.
my $sudoers = "root ALL=(ALL) ALL\n";
$sudoers .= "#includedir $root/etc/sudoers.d\n" unless $opt{no_sudoers_d};
$sudoers .= $opt{legacy} x 1 if $opt{legacy};
make_path("$root/etc/sudoers.d") unless $opt{no_sudoers_d};
write_file("$root/etc/sudoers", $sudoers);
my $src = $source;
$src =~ s{/usr/sbin/(useradd|usermod)}{$root/bin/$1}g;
$src =~ s{/etc/sudoers\.d}{$root/etc/sudoers.d}g;
$src =~ s{ /etc/sudoers\b}{ $root/etc/sudoers}g;
$src =~ s{/etc/redhat-release}{$root/etc/redhat-release}g;
$src =~ s{/etc/login\.defs}{$root/etc/login.defs}g;
$src =~ s{/xcatpost/hostkeys}{$root/xcatpost/hostkeys}g;
write_file("$root/sudoer", $src);
chmod 0755, "$root/sudoer";
return $root;
}
# Run the postscript in a scratch tree and collect what it changed.
sub run_sudoer {
my (%opt) = @_;
my $root = $opt{root} || scratch_tree(%opt);
my $user = $opt{user} || 'xcat';
my $args = defined $opt{args} ? $opt{args} : '';
write_file("$root/reply.xml", defined $opt{reply} ? $opt{reply} : '');
unlink "$root/calls", "$root/log";
system(qq{cd '$root' && MASTER='10.0.0.1' XCATSERVER='10.0.0.1:3001' }
. qq{PATH="$root/bin:\$PATH" ./sudoer $args >/dev/null 2>&1});
my $rc = $? >> 8;
return {
root => $root,
rc => $rc,
calls => read_file("$root/calls"),
log => read_file("$root/log"),
sudoers => read_file("$root/etc/sudoers"),
managed => read_file("$root$managed"),
authorized_keys => read_file("$root/home/$user/.ssh/authorized_keys"),
};
}
sub read_file {
my ($p) = @_;
return '' unless -e $p;
open my $fh, '<', $p or die "open $p: $!";
local $/;
my $content = <$fh>;
close $fh;
return defined $content ? $content : '';
}
sub write_file {
my ($p, $c) = @_;
open my $fh, '>', $p or die "open $p: $!";
print {$fh} $c;
close $fh;
return;
}
sub write_stub {
my ($p, $body) = @_;
write_file($p, "#!/bin/sh\n$body");
chmod 0755, $p;
return;
}
sub leftovers {
my ($root) = @_;
return join ',', grep { /xcat-sudoer\.|sudoers\.xcat\./ } glob("$root/etc/sudoers.d/* $root/etc/*");
}
# --- default user, no passwd row: the reply is the locked field --------------
{
my $r = run_sudoer(reply => $locked_reply);
is($r->{rc}, 0, 'the postscript completes without a password');
like($r->{calls}, qr{^useradd -m xcat$}m, 'the xcat account is created');
unlike($r->{calls}, qr{^useradd .*-p}m, 'no password is passed to useradd');
like($r->{calls}, qr{^usermod -p ! xcat$}m, 'the locked field from the reply is applied');
is(scalar(() = $r->{calls} =~ /^getcredentials/mg), 1, 'a locked reply is not retried');
like($r->{calls}, qr{^getcredentials xcat_secure_pw:xcat$}m,
'the password field is requested for the xcat user');
is($r->{managed}, managed_for('xcat'),
'the managed sudoers.d file records the account, the rule and the requiretty default');
is((stat "$r->{root}$managed")[2] & 07777, 0440, 'the managed file is mode 0440');
like($r->{calls}, qr{^visudo -cf }m, 'the managed file is checked with visudo before it is installed');
is(leftovers($r->{root}), '', 'no temporary sudoers file is left behind');
unlike($r->{sudoers}, qr{xcat}, '/etc/sudoers itself is not touched');
like($r->{authorized_keys}, qr{^ssh-rsa RSAKEY$}m, 'the RSA host key is installed');
like($r->{authorized_keys}, qr{^ssh-dss DSAKEY$}m, 'the DSA host key is installed');
like($r->{log}, qr{xcat has no password in the passwd table}, 'the locked account is logged');
}
# --- default user, passwd row present ---------------------------------------
{
my $r = run_sudoer(reply => $hash_reply);
is($r->{rc}, 0, 'the postscript completes with a password');
like($r->{calls}, qr{^useradd -m xcat$}m, 'the account is created before the password is set');
like($r->{calls}, qr{^usermod -p \$6\$saltsalt\$hashhashhash xcat$}m,
'the hash from the passwd table is applied to the account');
like($r->{log}, qr{set the password of xcat}, 'the applied password is logged');
}
# --- another user name, then a rename revokes the previous account -----------
{
my $r = run_sudoer(user => 'ops', args => '-u ops', reply => $hash_reply);
is($r->{rc}, 0, 'the postscript completes for a named user');
like($r->{calls}, qr{^useradd -m ops$}m, 'the named account is created');
like($r->{calls}, qr{^getcredentials xcat_secure_pw:ops$}m,
'the password field is requested for the named user');
like($r->{calls}, qr{^usermod -p \$6\$saltsalt\$hashhashhash ops$}m,
'the hash is applied to the named user');
is($r->{managed}, managed_for('ops'), 'the managed file names the user');
my $opskeys = "$r->{root}/home/ops/.ssh/authorized_keys";
make_path("$r->{root}/home/ops/.ssh");
write_file($opskeys, read_file($opskeys) . "ssh-ed25519 OPSKEY ops\@laptop\n");
my $again = run_sudoer(root => $r->{root}, user => 'admin', args => '-u admin', reply => $hash_reply);
is($again->{rc}, 0, 'a rerun with another name completes');
is($again->{managed}, managed_for('admin'), 'the managed file names the new sudoer only');
like($again->{calls}, qr{^usermod -p ! ops$}m, 'the previous sudoer is locked');
unlike(read_file($opskeys), qr{RSAKEY|DSAKEY}, 'the cluster keys are removed from the previous sudoer');
like(read_file($opskeys), qr{^ssh-ed25519 OPSKEY ops\@laptop$}m, 'the other key of the previous sudoer stays');
like($again->{log}, qr{revoked the previous sudoer ops}, 'the revocation is logged');
is(leftovers($r->{root}), '', 'no temporary file is left behind by the rename');
my $same = run_sudoer(root => $r->{root}, user => 'admin', args => '-u admin', reply => $hash_reply);
unlike($same->{calls}, qr{^usermod -p ! }m, 'a rerun with the same name revokes nothing');
}
# --- the lines of the previous postscript are moved out of /etc/sudoers ------
{
my $legacy = $legacy_rule . $legacy_tty . $legacy_rule . $legacy_tty;
my $root = scratch_tree(legacy => $legacy, accounts => ['xcat:1001:/bin/bash']);
make_path("$root/home/xcat/.ssh");
write_file("$root/home/xcat/.ssh/authorized_keys", "ssh-rsa RSAKEY\nssh-dss DSAKEY\n");
my $r = run_sudoer(root => $root, reply => $hash_reply);
is($r->{rc}, 0, 'the postscript completes on a node set up by the previous version');
unlike($r->{sudoers}, qr{xcat}, 'the legacy lines are gone from /etc/sudoers');
like($r->{sudoers}, qr{^root ALL=\(ALL\) ALL$}m, 'the other lines of /etc/sudoers stay');
like($r->{sudoers}, qr{^#includedir }m, 'the includedir line stays');
is((stat "$r->{root}/etc/sudoers")[2] & 07777, 0440, '/etc/sudoers is mode 0440 after the migration');
is($r->{managed}, managed_for('xcat'), 'the rule now lives in the managed file');
is(scalar(() = $r->{authorized_keys} =~ /RSAKEY/g), 1,
'the key written by the previous version is recognized and not duplicated');
unlike($r->{calls}, qr{^usermod -p ! xcat$}m, 'the legacy account is not locked when it stays the sudoer');
is(scalar(() = $r->{calls} =~ /^visudo -cf /mg), 2, 'both the migrated /etc/sudoers and the managed file are checked');
}
{
my $root = scratch_tree(reply => $hash_reply, legacy => $legacy_rule . $legacy_tty,
accounts => ['xcat:1001:/bin/bash']);
make_path("$root/home/xcat/.ssh");
write_file("$root/home/xcat/.ssh/authorized_keys", "ssh-rsa RSAKEY\nssh-dss DSAKEY\n");
my $r = run_sudoer(root => $root, user => 'ops', args => '-u ops', reply => $hash_reply);
is($r->{rc}, 0, 'a rename on a node set up by the previous version completes');
unlike($r->{sudoers}, qr{xcat}, 'the legacy lines are gone after the rename');
like($r->{calls}, qr{^usermod -p ! xcat$}m, 'the legacy xcat account is locked when the sudoer is renamed');
is(read_file("$root/home/xcat/.ssh/authorized_keys"), '', 'the cluster keys are removed from the legacy account');
is($r->{managed}, managed_for('ops'), 'the managed file names the new sudoer');
}
{
my $r = run_sudoer(reply => $hash_reply, legacy => $legacy_rule, accounts => ['xcat:1001:/bin/bash'],
visudo_rejects => 1);
isnt($r->{rc}, 0, 'a migration that visudo rejects fails the postscript');
like($r->{sudoers}, qr{^xcat ALL=}m, '/etc/sudoers is left as it was');
is($r->{managed}, '', 'no managed file is written when the migration fails');
is(leftovers($r->{root}), '', 'no temporary file is left behind by the failed migration');
like($r->{log}, qr{unable to take the legacy rule out of \S*/etc/sudoers}, 'the failed migration is logged');
}
# --- an existing login account is kept, with its other keys -------------------
{
my $root = scratch_tree(accounts => ['xcat:1001:/bin/bash']);
make_path("$root/home/xcat/.ssh");
write_file("$root/home/xcat/.ssh/authorized_keys", "ssh-ed25519 ADMINKEY admin\@mgmt\n");
my $r = run_sudoer(root => $root, reply => $hash_reply);
is($r->{rc}, 0, 'the postscript completes for an existing account');
unlike($r->{calls}, qr{^useradd}m, 'an existing login account is not recreated');
like($r->{calls}, qr{^usermod -p \$6\$saltsalt\$hashhashhash xcat$}m,
'the password of the existing account is refreshed');
like($r->{authorized_keys}, qr{^ssh-ed25519 ADMINKEY admin\@mgmt$}m,
'the keys already in authorized_keys survive');
like($r->{authorized_keys}, qr{^ssh-rsa RSAKEY$}m, 'the cluster key is added without the trailing space');
my $again = run_sudoer(root => $root, reply => $hash_reply);
is(scalar(() = $again->{authorized_keys} =~ /RSAKEY/g), 1, 'a rerun does not duplicate the cluster key');
is(scalar(() = $again->{authorized_keys} =~ /ADMINKEY/g), 1, 'a rerun does not duplicate the other key');
}
# --- without sudoers.d the rule is appended once to /etc/sudoers -------------
{
my $r = run_sudoer(reply => $hash_reply, no_sudoers_d => 1);
is($r->{rc}, 0, 'the postscript completes without sudoers.d');
like($r->{sudoers}, qr{^xcat ALL=\(ALL\) NOPASSWD: ALL$}m, 'the rule is appended to /etc/sudoers');
like($r->{sudoers}, qr{^Defaults:xcat !requiretty$}m, 'requiretty is disabled on Red Hat');
is($r->{managed}, '', 'no managed file is written without sudoers.d');
my $again = run_sudoer(root => $r->{root}, reply => $hash_reply);
is(scalar(() = $again->{sudoers} =~ /^xcat ALL=/mg), 1, 'a rerun does not duplicate the rule');
is(scalar(() = $again->{sudoers} =~ /^Defaults:xcat/mg), 1, 'a rerun does not duplicate the default');
}
# --- a dotted name only touches its own home ----------------------------------
{
my $root = scratch_tree(accounts => ['opsXadmin:1002:/bin/bash', 'ops.admin:1003:/bin/bash']);
make_path("$root/home/opsXadmin/.ssh");
write_file("$root/home/opsXadmin/.ssh/authorized_keys", "KEEP\n");
write_file("$root/home/opsXadmin/keep.txt", "KEEP\n");
my $r = run_sudoer(root => $root, user => 'ops.admin', args => '-u ops.admin', reply => $hash_reply);
is($r->{rc}, 0, 'the postscript completes for a dotted name');
like($r->{authorized_keys}, qr{RSAKEY}, 'the keys land in the home of the dotted name');
is(read_file("$root/home/opsXadmin/.ssh/authorized_keys"), "KEEP\n",
'the authorized_keys of the similarly named account are untouched');
is(read_file("$root/home/opsXadmin/keep.txt"), "KEEP\n",
'the home of the similarly named account is untouched');
}
# --- failures leave the account unprivileged and drop the earlier grant -------
{
my $r = run_sudoer(reply => '', accounts => ['xcat:1001:/bin/bash']);
isnt($r->{rc}, 0, 'a missing reply fails the postscript');
is(scalar(() = $r->{calls} =~ /^getcredentials/mg), 3, 'the request is retried three times');
unlike($r->{calls}, qr{^usermod}m, 'the password is left unchanged without a reply');
is($r->{managed}, '', 'no sudo rule is granted without a reply');
is($r->{authorized_keys}, '', 'no key is installed without a reply');
like($r->{log}, qr{no password for xcat, leaving the account unprivileged: no reply from 10\.0\.0\.1},
'the missing reply is logged');
}
{
my $first = run_sudoer(reply => $hash_reply);
is($first->{managed}, managed_for('xcat'), 'a successful run grants the rule');
my $r = run_sudoer(root => $first->{root}, reply => $error_reply);
isnt($r->{rc}, 0, 'a server error fails the postscript');
is(scalar(() = $r->{calls} =~ /^getcredentials/mg), 1, 'a server error is not retried');
unlike($r->{calls}, qr{^usermod}m, 'the password is left unchanged on a server error');
is($r->{managed}, '', 'the rule of the earlier run is removed on a server error');
like($r->{log}, qr{leaving the account unprivileged: .*not a configured sudoer}, 'the server error is logged');
}
{
my $r = run_sudoer(reply => $hash_reply, usermod_fails => 1);
isnt($r->{rc}, 0, 'a failed usermod fails the postscript');
is($r->{managed}, '', 'no sudo rule is granted when the password cannot be set');
is($r->{authorized_keys}, '', 'no key is installed when the password cannot be set');
like($r->{log}, qr{unable to set the password of xcat}, 'the failed password update is logged');
}
{
my $r = run_sudoer(reply => $hash_reply, useradd_fails => 1);
isnt($r->{rc}, 0, 'a failed useradd fails the postscript');
unlike($r->{calls}, qr{^getcredentials}m, 'no credential is requested for a missing account');
is($r->{managed}, '', 'no sudo rule is granted for a missing account');
is($r->{authorized_keys}, '', 'no key is installed for a missing account');
}
{
my $r = run_sudoer(reply => $hash_reply, visudo_rejects => 1);
isnt($r->{rc}, 0, 'a managed file that visudo rejects fails the postscript');
is($r->{managed}, '', 'the rejected managed file is not installed');
is(leftovers($r->{root}), '', 'the rejected temporary file is removed');
is($r->{authorized_keys}, '', 'no key is installed when the rule cannot be written');
like($r->{log}, qr{unable to write .*xcat-sudoer}, 'the rejected rule is logged');
}
# --- root, service accounts, and non-login accounts are refused ---------------
foreach my $case (
[ 'root', 'root:0:/bin/bash', qr{root is a system account \(uid 0\)} ],
[ 'sshd', 'sshd:74:/sbin/nologin', qr{sshd is a system account \(uid 74\)} ],
[ 'nobody', 'nobody:65534:/bin/bash', qr{nobody is a system account \(uid 65534\)} ],
[ 'batch', 'batch:1005:/sbin/nologin', qr{batch has no login shell} ],
[ 'noshell', 'noshell:1006:', qr{noshell has no login shell} ],
) {
my ($name, $account, $message) = @$case;
my $r = run_sudoer(user => $name, args => "-u $name", reply => $hash_reply, accounts => [$account]);
isnt($r->{rc}, 0, "$name is refused");
unlike($r->{calls}, qr{^(useradd|usermod|getcredentials)}m, "nothing is changed for $name");
is($r->{managed}, '', "no sudo rule is written for $name");
is($r->{authorized_keys}, '', "the authorized_keys of $name are not touched");
like($r->{log}, $message, "the refusal of $name is logged");
}
# --- bad arguments ------------------------------------------------------------
{
my $r = run_sudoer(args => '-u xcat -p secret', reply => $hash_reply);
isnt($r->{rc}, 0, 'a command-line password is rejected');
unlike($r->{calls}, qr{^useradd}m, 'no account is created for a rejected call');
}
{
my $r = run_sudoer(args => q{-u 'bad name'}, reply => $hash_reply);
isnt($r->{rc}, 0, 'a user name with a space is rejected');
unlike($r->{calls}, qr{^useradd}m, 'no account is created for a bad name');
}
done_testing();
+195 -36
View File
@@ -5,8 +5,33 @@
# Setup a sudoer named xcat and copy the xCAT public SSH key in its
# authorized_keys file. Only applies to Linux.
#
# The sudoer gets the password stored in the passwd table under
# key=system,username=<sudoer>. Without that row the account has no
# password and accepts only the SSH key.
#
#------------------------------------------------------------------------------
function usage() {
echo ""
echo "Usage: $0 [-u username]"
echo -e "\t-u sudoer user name, xcat by default"
exit 1
}
SUDOER="xcat"
while getopts "u:" opt;
do
case $opt in
u) SUDOER="$OPTARG";;
*) usage;;
esac
done
if [[ ! "$SUDOER" =~ ^[A-Za-z_][A-Za-z0-9_.-]{0,31}$ ]]
then
usage;
fi
if [ -n "$LOGLABEL" ]; then
log_label=$LOGLABEL
else
@@ -18,48 +43,182 @@ if [ "$(uname -s|tr 'A-Z' 'a-z')" = "linux" ];then
. $str_dir_name/xcatlib.sh
fi
# Configuration for the sudoer
SUDOER="xcat"
SUDOERPW="rootpw"
PRIV="$SUDOER ALL=(ALL) NOPASSWD: ALL"
SEED=`date "+%s"`
ENCRYPT=`perl -e "print crypt($SUDOERPW, $SEED)"`
master=$MASTER
useflowcontrol=0
if [ "$USEFLOWCONTROL" = "YES" ] || [ "$USEFLOWCONTROL" = "yes" ] || [ "$USEFLOWCONTROL" = "1" ]; then
useflowcontrol=1
fi
MANAGED=/etc/sudoers.d/xcat-sudoer
HOSTKEYS="/xcatpost/hostkeys/ssh_host_rsa_key.pub /xcatpost/hostkeys/ssh_host_dsa_key.pub"
LEGACY_RULE="xcat ALL=(ALL) NOPASSWD: ALL"
LEGACY_TTY="Defaults:xcat !requiretty"
function log() {
logger -t $log_label -p "local4.$1" "sudoer: $2"
}
# A failed run keeps no sudo rule from an earlier run
function fail() {
log err "$1"
rm -f "$MANAGED"
exit 1
}
# ssh-keygen leaves a trailing space after an empty comment and the previous
# version of this postscript wrote the key without it
function cluster_key() {
sed -e 's/[[:space:]]*$//' "$1"
}
function valid_sudoers() {
command -v visudo >/dev/null 2>&1 || return 0
visudo -cf "$1" >/dev/null 2>&1
}
# Take the lines the previous version of this postscript appended out of
# /etc/sudoers. The rule now lives in the managed file.
function migrate_legacy_sudoers() {
grep -qxF "$LEGACY_RULE" /etc/sudoers || return 0
local tmp
tmp=$(mktemp /etc/sudoers.xcat.XXXXXX) || return 1
grep -vxF -e "$LEGACY_RULE" -e "$LEGACY_TTY" /etc/sudoers > "$tmp"
if ! valid_sudoers "$tmp" || ! chmod 0440 "$tmp" || ! mv -f "$tmp" /etc/sudoers; then
rm -f "$tmp"
return 1
fi
legacy_xcat=1
}
# The account of an earlier run keeps its login, without sudo, password,
# or the cluster key
function revoke_account() {
local name=$1 home keyfile pubkey tmp
[ "$name" = "$SUDOER" ] && return 0
getent passwd "$name" >/dev/null || return 0
/usr/sbin/usermod -p '!' "$name" || return 1
home=$(getent passwd "$name" | cut -f6 -d :)
keyfile="$home/.ssh/authorized_keys"
if [[ "$home" == /* ]] && [ -f "$keyfile" ]; then
for pubkey in $HOSTKEYS; do
[ -r "$pubkey" ] || continue
tmp=$(mktemp "$keyfile.XXXXXX") || return 1
grep -vxF "$(cluster_key "$pubkey")" "$keyfile" > "$tmp"
cat "$tmp" > "$keyfile" || { rm -f "$tmp"; return 1; }
rm -f "$tmp"
done
fi
log info "revoked the previous sudoer $name"
}
function grant_sudo() {
local tmp
tmp=$(mktemp /etc/sudoers.d/xcat-sudoer.XXXXXX) || return 1
{
echo "# xCAT sudoer: $SUDOER"
echo "$SUDOER ALL=(ALL) NOPASSWD: ALL"
if [ -e "/etc/redhat-release" ]; then
echo "Defaults:$SUDOER !requiretty"
fi
} > "$tmp" || { rm -f "$tmp"; return 1; }
if ! valid_sudoers "$tmp" || ! chmod 0440 "$tmp" || ! mv -f "$tmp" "$MANAGED"; then
rm -f "$tmp"
return 1
fi
}
function append_sudo() {
local rule="$SUDOER ALL=(ALL) NOPASSWD: ALL"
grep -qxF "$rule" /etc/sudoers || echo "$rule" >> /etc/sudoers || return 1
if [ -e "/etc/redhat-release" ]; then
grep -qxF "Defaults:$SUDOER !requiretty" /etc/sudoers || echo "Defaults:$SUDOER !requiretty" >> /etc/sudoers || return 1
fi
}
# Add the cluster host keys and keep the keys that are already there
function grant_keys() {
local keyfile="$sudoer_home/.ssh/authorized_keys" pubkey key
mkdir -p "$sudoer_home/.ssh" && touch "$keyfile" || return 1
for pubkey in $HOSTKEYS; do
[ -r "$pubkey" ] || continue
key=$(cluster_key "$pubkey")
grep -qxF "$key" "$keyfile" || echo "$key" >> "$keyfile" || return 1
done
chmod 0644 "$keyfile" && chown "$SUDOER" "$keyfile"
}
# Never manage root, a service account, or an account without a login shell
uid_min=$(awk '$1 == "UID_MIN" { print $2 }' /etc/login.defs 2>/dev/null)
uid_max=$(awk '$1 == "UID_MAX" { print $2 }' /etc/login.defs 2>/dev/null)
account=$(getent passwd "$SUDOER")
if [ -n "$account" ]; then
uid=$(echo "$account" | cut -f3 -d :)
shell=$(echo "$account" | cut -f7 -d :)
if [ "$uid" -lt "${uid_min:-1000}" ] || [ "$uid" -gt "${uid_max:-60000}" ]; then
fail "$SUDOER is a system account (uid $uid), leaving it alone"
fi
case "$shell" in
*/nologin|*/false|"") fail "$SUDOER has no login shell, leaving it alone";;
esac
fi
# Create sudoer
/usr/sbin/userdel $SUDOER
/usr/sbin/useradd -p $ENCRYPT -m $SUDOER
echo "$PRIV" >> /etc/sudoers
if [ -e "/etc/redhat-release" ]; then
echo "Defaults:$SUDOER !requiretty" >> /etc/sudoers
if [ -z "$account" ]; then
/usr/sbin/useradd -m "$SUDOER" || fail "unable to create $SUDOER"
fi
# The password field comes from the passwd table on the management node,
# the same way remoteshell gets the root hash when secureroot is enabled.
# The reply is the hash, or "!" when the table has no password for the
# account. sudo and the SSH key are granted only once the field is applied.
allowcred.awk &
CREDPID=$!
sleep 1
response=""
for attempt in 1 2 3; do
if [ $useflowcontrol = "1" ]; then
log info "sending xcatflowrequest $master 3001"
/xcatpost/xcatflowrequest $master 3001
fi
response=$(getcredentials.awk xcat_secure_pw:$SUDOER | grep -E -v '</{0,1}xcatresponse>|</{0,1}serverdone>' | sed -e 's/&lt;/</' -e 's/&gt;/>/' -e 's/&amp;/&/' -e 's/&quot/"/' -e "s/&apos;/'/")
[ -n "$response" ] && break
[ $attempt -lt 3 ] && sleep $((attempt * 5))
done
{ kill -9 $CREDPID && wait $CREDPID; } 2>/dev/null
SUDOERPWFIELD=$(echo "$response" | sed -n 's%.*<content>\(.*\)</content>.*%\1%p')
if [ -z "$SUDOERPWFIELD" ]; then
ERR_MSG=$(echo "$response" | sed -n 's%.*<error>\(.*\)</error>.*%\1%p')
fail "no password for $SUDOER, leaving the account unprivileged: ${ERR_MSG:-no reply from $master}"
fi
/usr/sbin/usermod -p "$SUDOERPWFIELD" "$SUDOER" || fail "unable to set the password of $SUDOER, leaving the account unprivileged"
if [ "$SUDOERPWFIELD" = "!" ]; then
log info "$SUDOER has no password in the passwd table, the account is locked"
else
log info "set the password of $SUDOER from the passwd table"
fi
# Find sudoer home
HOME=`egrep "^$SUDOER:" /etc/passwd | cut -f6 -d :`
sudoer_home=$(getent passwd "$SUDOER" | cut -f6 -d :)
if [[ "$sudoer_home" != /* ]]; then
fail "no home directory for $SUDOER, leaving the account unprivileged"
fi
# Create the SSH directory in sudoer's home
mkdir -p $HOME/.ssh/
sleep 1
rm -rf $HOME/.ssh/authorized_keys
#-----------------
# Retrieve RSA key
#-----------------
KEY=`cat /xcatpost/hostkeys/ssh_host_rsa_key.pub`
# Put key in authorized_keys file
echo -e $KEY >> $HOME/.ssh/authorized_keys
#-----------------
# Retrieve DSA key
#-----------------
KEY=`cat /xcatpost/hostkeys/ssh_host_dsa_key.pub`
# Put key in authorized_keys file
echo -e $KEY >> $HOME/.ssh/authorized_keys
chmod 0644 $HOME/.ssh/authorized_keys
chown $SUDOER:users $HOME/.ssh/authorized_keys
# Configuration for the sudoer
if [ -d /etc/sudoers.d ] && grep -qE '^[#@]includedir[[:space:]]+/etc/sudoers.d' /etc/sudoers; then
previous=$(sed -n '1s/^# xCAT sudoer: //p' "$MANAGED" 2>/dev/null)
legacy_xcat=""
migrate_legacy_sudoers || fail "unable to take the legacy rule out of /etc/sudoers"
if [ -n "$legacy_xcat" ]; then
revoke_account xcat || fail "unable to revoke the legacy sudoer xcat"
fi
if [ -n "$previous" ]; then
revoke_account "$previous" || fail "unable to revoke the previous sudoer $previous"
fi
grant_sudo || fail "unable to write $MANAGED"
else
append_sudo || fail "unable to add the rule of $SUDOER to /etc/sudoers"
fi
grant_keys || fail "unable to install the cluster key for $SUDOER"
# Restart the SSHD for syncfiles postscript to do the sync work