From b315ceaee0fec3bbb42081f5b9f8e727e8dcaecc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vin=C3=ADcius=20Ferr=C3=A3o?= <2031761+viniciusferrao@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:15:16 -0300 Subject: [PATCH] test(postscripts): exercise credential replies and retries Run the complete postscripts with controlled credential replies and native SSH key generation. Check successful and empty replies, server errors, retry limits, flow-control differences and password logging. --- .github/workflows/xcat_test.yml | 2 +- xCAT-test/native/remoteshell_credentials.t | 171 +++++++++++++++++++++ xCAT-test/native/sudoer_password_source.t | 57 ++++++- 3 files changed, 225 insertions(+), 5 deletions(-) create mode 100644 xCAT-test/native/remoteshell_credentials.t diff --git a/.github/workflows/xcat_test.yml b/.github/workflows/xcat_test.yml index 0566b0354..da6e641b7 100644 --- a/.github/workflows/xcat_test.yml +++ b/.github/workflows/xcat_test.yml @@ -40,7 +40,7 @@ jobs: sudo cp -R xCAT-test xCAT-server perl-xCAT xCAT "$workdir/" status=0 sudo sh -c 'cd "$1" && bats xCAT-test/native/getinstdisk_selection.bats' sh "$workdir" || status=1 - sudo sh -c 'cd "$1" && prove -v xCAT-test/native/getinstdisk_installer.t xCAT-test/native/enablekdump_per_node.t xCAT-test/native/sudoer_password_source.t' sh "$workdir" || status=1 + sudo sh -c 'cd "$1" && prove -v xCAT-test/native/getinstdisk_installer.t xCAT-test/native/enablekdump_per_node.t xCAT-test/native/sudoer_password_source.t xCAT-test/native/remoteshell_credentials.t' sh "$workdir" || status=1 exit "$status" - name: Prepare xCAT syslog directory if: ${{ !cancelled() }} diff --git a/xCAT-test/native/remoteshell_credentials.t b/xCAT-test/native/remoteshell_credentials.t new file mode 100644 index 000000000..7181c5666 --- /dev/null +++ b/xCAT-test/native/remoteshell_credentials.t @@ -0,0 +1,171 @@ +#!/usr/bin/env perl +use strict; +use warnings; + +use File::Path qw(make_path); +use File::Slurper qw(read_binary write_binary); +use FindBin; +use lib "$FindBin::Bin/../lib"; +use Test::More; +use XCAT::Test::File qw(repo_path); +use XCAT::Test::Process qw(run_command); +use XCAT::Test::Sandbox qw(sandbox_root sandbox_run); + +plan skip_all => 'postscript targets Linux nodes' unless $^O eq 'linux'; +die 'install openssl for the remoteshell postscript' unless -x '/usr/bin/openssl'; +my $postscripts = repo_path('xCAT/postscripts'); +my $keys = sandbox_root(); +my @types = qw(dsa rsa ecdsa ed25519); +my %public_keys; +for my $type (@types) { + my ($rc, $output) = run_command('ssh-keygen', '-q', '-t', $type, + '-f', "$keys/$type", '-N', '', '-C', ''); + die "cannot generate $type fixture: $output" if $rc; + $public_keys{$type} = read_binary("$keys/$type.pub"); + $public_keys{$type} =~ s/\s+\z//; +} +my $hash = '$6$saltsalt$hashhashhash'; + +sub write_stub { + my ($path, $body) = @_; + write_binary($path, "#!/bin/sh\n$body"); + chmod 0755, $path or die "chmod $path: $!"; +} + +sub read_optional { + my ($path) = @_; + return -f $path ? scalar read_binary($path) : ''; +} + +sub reply { + my ($content) = @_; + return "\n\n\n$content\n\n\n" + . "\n\n\n"; +} + +sub run_remoteshell { + my (%opt) = @_; + my $root = sandbox_root(); + make_path(map { "$root/$_" } qw(etc/ssh etc/systemd/system root/.ssh xcatpost replies)); + make_path("$root/tmp/ssh_dsa_hostkey") if $opt{blocked_staging}; + my $passwd = "root:x:0:0::/root:/bin/bash\n"; + $passwd .= "runner:x:$>:0::/root:/bin/bash\n" if $>; + write_binary("$root/etc/passwd", $passwd); + write_binary("$root/etc/group", "root:x:0:\n"); + write_binary("$root/etc/nsswitch.conf", "passwd: files\ngroup: files\n"); + write_binary("$root/etc/os-release", "ID=almalinux\n"); + write_binary("$root/etc/ssh/sshd_config", "Port 22\n"); + write_binary("$root/etc/ssh/ssh_config", "Host *\n"); + write_binary("$root/etc/systemd/system/sshd.service", "[Service]\n"); + for my $type (@types) { + write_binary("$root/replies/ssh_${type}_hostkey", reply(read_binary("$keys/$type"))); + } + my $zone = $opt{zone} ? ":$opt{zone}" : ''; + my $public = "$public_keys{rsa} fixture <node> '\n"; + write_binary("$root/replies/ssh_root_pub_key$zone", reply($public)); + write_binary("$root/replies/ssh_root_key$zone", reply(read_binary("$keys/rsa"))); + write_binary("$root/replies/xcat_secure_pw:root", + "\n$hash\n\n"); + if ($opt{error}) { + for my $path (glob "$root/replies/*") { + next if $opt{error} eq 'root' && $path !~ /xcat_secure_pw:root$/; + write_binary($path, "\ndenied <node> '\n\n"); + } + } + write_stub("$root/bin/getcredentials.awk", <<'SH'); +echo "getcredentials $*" >> /fixture/calls +count=$(grep -c -x -F "getcredentials $*" /fixture/calls) +[ "$count" -le "$EMPTY_REPLIES" ] && exit 0 +cat "/fixture/replies/$1" +SH + write_stub("$root/bin/allowcred.awk", "exec /bin/sleep 30\n"); + write_stub("$root/bin/sleep", "echo \"sleep \$*\" >> /fixture/calls\n"); + write_stub("$root/bin/logger", "echo \"\$*\" >> /fixture/messages\n"); + write_stub("$root/bin/systemctl", "echo \"systemctl \$*\" >> /fixture/calls\n"); + write_stub("$root/bin/usermod", "echo \"usermod \$*\" >> /fixture/calls\n"); + write_stub("$root/xcatpost/xcatflowrequest", + "echo \"flow \$*\" >> /fixture/calls\nexit \${FLOW_STATUS:-0}\n"); + my ($rc, $output) = sandbox_run($root, { + read_only => {$postscripts => '/postscripts', "$root/xcatpost" => '/xcatpost'}, + writable => {"$root/root" => '/root'}, + env => {MASTER => '192.0.2.1', OSVER => 'rhels8', NTYPE => 'compute', + ENABLESSHBETWEENNODES => 'YES', NODESETSTATE => 'netboot', SECUREROOT => '1', + UPDATENODE => '0', ZONENAME => $opt{zone} || '', + USEFLOWCONTROL => $opt{flow} ? 'YES' : 'NO', FLOW_STATUS => $opt{flow_status} || 0, + EMPTY_REPLIES => $opt{empty} || 0}}, + '/bin/bash', '/postscripts/remoteshell'); + diag($output) if $rc || $output =~ /bwrap:/; + return {root => $root, rc => $rc, output => $output, + calls => read_optional("$root/calls"), log => read_optional("$root/messages")}; +} + +for my $case ( + ['initial replies', {}], + ['retried replies', {empty => 1, flow => 1, zone => 'zone-a'}], + ['flow failure', {empty => 1, flow => 1, flow_status => 1}], + ['unwritable staging path', {blocked_staging => 1, flow => 1}], +) { + my ($label, $options) = @$case; + subtest $label => sub { + my $r = run_remoteshell(%$options); + is($r->{rc}, 0, 'full postscript completes'); + for my $type (@types) { + is(read_optional("$r->{root}/etc/ssh/ssh_host_${type}_key"), + read_binary("$keys/$type"), "$type private key is unchanged"); + is(read_optional("$r->{root}/etc/ssh/ssh_host_${type}_key.pub"), + "$public_keys{$type}\n", "$type public key is derived by ssh-keygen"); + is(scalar(() = $r->{calls} =~ /^getcredentials ssh_${type}_hostkey$/mg), + $options->{empty} || ($type eq 'dsa' && $options->{blocked_staging}) ? 2 : 1, + "$type request count"); + } + is(read_optional("$r->{root}/root/.ssh/id_rsa"), read_binary("$keys/rsa"), + 'root private key is unchanged'); + my $public = "$public_keys{rsa} fixture '\n"; + is(read_optional("$r->{root}/root/.ssh/id_rsa.pub"), $public, 'public key comment is decoded'); + is(read_optional("$r->{root}/root/.ssh/authorized_keys"), $public, 'authorized key is decoded'); + my $zone = $options->{zone} ? ":$options->{zone}" : ''; + is(scalar(() = $r->{calls} =~ /^getcredentials ssh_root_pub_key\Q$zone\E$/mg), + $options->{empty} ? 3 : 1, 'root public key retains its separate retry requests'); + is(scalar(() = $r->{calls} =~ /^getcredentials ssh_root_key\Q$zone\E$/mg), + $options->{empty} ? 2 : 1, 'root private key request count'); + is(scalar(() = $r->{calls} =~ /^getcredentials xcat_secure_pw:root$/mg), + $options->{empty} ? 2 : 1, 'secure root request count'); + like($r->{calls}, qr/^usermod -p \Q$hash\E root$/m, 'root receives the password field'); + unlike($r->{log} . $r->{output}, qr/\Q$hash\E/, 'password field is not logged or printed'); + like($r->{calls}, qr/^systemctl restart sshd/m, 'script reaches the SSH service boundary'); + my @sleeps = $r->{calls} =~ /^sleep (\d+)$/mg; + is(shift @sleeps, 1, 'credential listener startup delay is retained'); + is(scalar @sleeps, $options->{flow_status} ? 7 : 0, 'only failed flow control adds retry delays'); + ok(!grep($_ < 10 || $_ > 19, @sleeps), 'retry delays are in the existing range'); + is(scalar(() = $r->{calls} =~ /^flow /mg), + $options->{flow_status} ? 1 : $options->{blocked_staging} ? 8 : $options->{flow} ? 14 : 0, + 'remoteshell disables flow control after a failed request'); + }; +} + +for my $case (['initial errors', 0, 'all'], ['retry error', 1, 'root'], ['exhausted retries', 20, '']) { + my ($label, $empty, $error) = @$case; + subtest $label => sub { + my $r = run_remoteshell(empty => $empty, error => $error, flow => 1); + is($r->{rc}, 0, 'caller completes after a credential failure'); + unlike($r->{calls}, qr/^usermod /m, 'failed password retrieval does not change root'); + is(scalar(() = $r->{calls} =~ /^getcredentials xcat_secure_pw:root$/mg), + $empty == 20 ? 10 : $empty + 1, 'root keeps its retry limit and error stop'); + if ($error) { + like($r->{log}, qr/Failed to acquire secure root password: denied '/, + 'server error survives filtering and decoding'); + } else { + like($r->{log}, qr/Too many retries/, 'retry exhaustion is logged'); + } + if ($error eq 'all' || $empty == 20) { + for my $type (@types) { + ok(!-s "$r->{root}/etc/ssh/ssh_host_${type}_key", "no unusable $type key installed"); + is(scalar(() = $r->{calls} =~ /^getcredentials ssh_${type}_hostkey$/mg), + $empty == 20 ? 10 : 1, "$type retry limit"); + } + is(read_optional("$r->{root}/root/.ssh/authorized_keys"), '', 'no authorized key is installed'); + } + }; +} + +done_testing(); diff --git a/xCAT-test/native/sudoer_password_source.t b/xCAT-test/native/sudoer_password_source.t index 7f761cc0c..bb6289734 100644 --- a/xCAT-test/native/sudoer_password_source.t +++ b/xCAT-test/native/sudoer_password_source.t @@ -32,7 +32,8 @@ sub field_reply { XML } -my $hash_reply = field_reply('$6$saltsalt$hashhashhash'); +my $hash = '$6$saltsalt$hashhashhash'; +my $hash_reply = field_reply($hash); my $locked_reply = field_reply('!'); my $error_reply = <<'XML'; @@ -82,9 +83,13 @@ sub scratch_tree { "echo \"visudo \$*\" >> /fixture/calls\n" . ($opt{visudo_rejects} ? "exit 1\n" : 'exec /native-visudo "$@" >> /fixture/parser.log 2>&1')); write_stub("$root/bin/getcredentials.awk", - "echo \"getcredentials \$*\" >> /fixture/calls\ncat /fixture/reply.xml\n"); + "echo \"getcredentials \$*\" >> /fixture/calls\n" + . "n=\$(grep -c '^getcredentials ' /fixture/calls)\n" + . "if [ -f /fixture/reply.\$n ]; then cat /fixture/reply.\$n; else cat /fixture/reply.xml; fi\n"); write_stub("$root/bin/allowcred.awk", "exec /bin/sleep 30\n"); - write_stub("$root/bin/sleep", "exit 0\n"); + write_stub("$root/bin/sleep", "echo \"sleep \$*\" >> /fixture/calls\n"); + write_stub("$root/xcatpost/xcatflowrequest", + "echo \"flow \$*\" >> /fixture/calls\nexit \${FLOW_STATUS:-0}\n"); write_stub("$root/bin/logger", "echo \"\$*\" >> /fixture/messages\n"); write_stub("$root/bin/chown", "exit 0\n"); write_stub("$root/bin/systemctl", "echo \"systemctl \$*\" >> /fixture/calls\n"); @@ -110,20 +115,28 @@ sub run_sudoer { my $user = $opt{user} || 'xcat'; my $args = defined $opt{args} ? $opt{args} : ''; + for my $reply (glob "$root/reply.[0-9]*") { + unlink $reply or die "unlink $reply: $!"; + } write_binary("$root/reply.xml", defined $opt{reply} ? $opt{reply} : ''); + for my $index (0 .. $#{$opt{replies} || []}) { + write_binary("$root/reply." . ($index + 1), $opt{replies}[$index]); + } write_binary("$root/calls", ''); write_binary("$root/messages", ''); my ($rc, $output) = sandbox_run($root, { read_only => {$postscripts => '/postscripts', $visudo => '/native-visudo', "$root/bin" => '/usr/sbin', "$root/xcatpost" => '/xcatpost'}, writable => {"$root/home" => '/home'}, - env => {MASTER => '192.0.2.1', XCATSERVER => '192.0.2.1:3001'}}, + env => {MASTER => '192.0.2.1', XCATSERVER => '192.0.2.1:3001', + USEFLOWCONTROL => $opt{flow} ? 'YES' : 'NO', FLOW_STATUS => $opt{flow_status} || 0}}, '/bin/bash', '/postscripts/sudoer', shellwords($args)); diag($output) if $output =~ /bwrap:/; return { root => $root, rc => $rc, + output => $output, calls => read_file("$root/calls"), log => read_file("$root/messages"), sudoers => read_file("$root/etc/sudoers"), @@ -183,6 +196,42 @@ sub leftovers { like($r->{log}, qr{set the password of xcat}, 'the applied password is logged'); } +for my $flow (0, 1) { + my $r = run_sudoer(replies => ['', '', $hash_reply], flow => $flow, flow_status => 1); + is($r->{rc}, 0, "delayed password completes with flow control $flow"); + is(scalar(() = $r->{calls} =~ /^getcredentials /mg), 3, 'third reply is used'); + is_deeply([$r->{calls} =~ /^sleep (.*)$/mg], [1, 5, 10], 'retry delays stay with the caller'); + is(scalar(() = $r->{calls} =~ /^flow 192\.0\.2\.1 3001$/mg), $flow ? 3 : 0, + 'a failed flow request does not disable sudoer flow control'); + like($r->{calls}, qr/^usermod -p \$6\$saltsalt\$hashhashhash xcat$/m, 'delayed hash is applied'); + unlike($r->{log} . $r->{output}, qr/\Q$hash\E/, 'hash is not logged or printed'); +} + +{ + my $r = run_sudoer(reply => field_reply('!'), replies => [field_reply('')]); + is($r->{rc}, 1, 'a nonempty reply without content is not retried'); + is(scalar(() = $r->{calls} =~ /^getcredentials /mg), 1, 'missing content stops after one request'); + is($r->{managed}, '', 'missing content grants no sudo rule'); + is($r->{authorized_keys}, '', 'missing content grants no SSH key'); +} + +{ + my $r = run_sudoer(reply => $hash_reply, replies => ['']); + is($r->{rc}, 0, 'a delayed reply can precede a second postscript run'); + my $again = run_sudoer(root => $r->{root}, reply => $locked_reply); + is($again->{rc}, 0, 'the second postscript run succeeds'); + is(scalar(() = $again->{calls} =~ /^getcredentials /mg), 1, + 'the second run does not reuse the previous empty reply'); +} + +{ + my $r = run_sudoer(reply => "\ndenied <name> " '\n\n"); + is($r->{rc}, 1, 'encoded server error fails'); + like($r->{log}, qr/denied "; '/, 'the server error uses the existing entity substitutions'); + unlike($r->{calls}, qr/^usermod -p/m, 'an error is not used as a password'); + is($r->{managed}, '', 'an error grants no sudo rule'); +} + # --- another user name, then a rename revokes the previous account ----------- { my $r = run_sudoer(user => 'ops', args => '-u ops', reply => $hash_reply);