From 4a525a77f40786218178fdefed8aa9f85b19ac0a 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:14:26 -0300 Subject: [PATCH 1/3] refactor(postscripts): share credential reply filtering and decoding Move the common credential response filters into xcatlib.sh. Keep the requests, key retry filters and caller-specific retry, flow-control and listener handling in place. --- xCAT/postscripts/remoteshell | 30 +++++++++++++++--------------- xCAT/postscripts/sudoer | 2 +- xCAT/postscripts/xcatlib.sh | 10 ++++++++++ 3 files changed, 26 insertions(+), 16 deletions(-) diff --git a/xCAT/postscripts/remoteshell b/xCAT/postscripts/remoteshell index f52409d31..f1f730e90 100755 --- a/xCAT/postscripts/remoteshell +++ b/xCAT/postscripts/remoteshell @@ -111,7 +111,7 @@ if [ $useflowcontrol = "1" ]; then fi -getcredentials.awk ssh_dsa_hostkey | grep -E -v '|' | sed -e 's/<//' -e 's/&/&/' -e 's/"/"/' -e "s/'/'/" > /tmp/ssh_dsa_hostkey +getcredentials.awk ssh_dsa_hostkey | xcat_filter_credentials > /tmp/ssh_dsa_hostkey #check the message is an error or not grep -E '' /tmp/ssh_dsa_hostkey > /dev/null 2>&1 @@ -146,7 +146,7 @@ if [ $? -ne 0 ]; then useflowcontrol=0 fi fi - getcredentials.awk ssh_dsa_hostkey | grep -v '<'|sed -e 's/<//' -e 's/&/&/' -e 's/"/"/' -e "s/'/'/" > /etc/ssh/ssh_host_dsa_key + getcredentials.awk ssh_dsa_hostkey | grep -v '<'|xcat_decode_credentials > /etc/ssh/ssh_host_dsa_key MYCONT=`cat /etc/ssh/ssh_host_dsa_key` done set_hostkey_permissions /etc/ssh/ssh_host_dsa_key @@ -181,7 +181,7 @@ if [ $useflowcontrol = "1" ]; then fi fi -getcredentials.awk ssh_rsa_hostkey | grep -E -v '|' | sed -e 's/<//' -e 's/&/&/' -e 's/"/"/' -e "s/'/'/" > /tmp/ssh_rsa_hostkey +getcredentials.awk ssh_rsa_hostkey | xcat_filter_credentials > /tmp/ssh_rsa_hostkey #check whether the message is an error or not grep -E '' /tmp/ssh_rsa_hostkey > /dev/null 2>&1 @@ -216,7 +216,7 @@ if [ $? -ne 0 ]; then fi fi - getcredentials.awk ssh_rsa_hostkey | grep -v '<'|sed -e 's/<//' -e 's/&/&/' -e 's/"/"/' -e "s/'/'/" > /etc/ssh/ssh_host_rsa_key + getcredentials.awk ssh_rsa_hostkey | grep -v '<' | xcat_decode_credentials > /etc/ssh/ssh_host_rsa_key MYCONT=`cat /etc/ssh/ssh_host_rsa_key` done set_hostkey_permissions /etc/ssh/ssh_host_rsa_key @@ -252,7 +252,7 @@ if ssh-keygen -t ecdsa -f /tmp/ecdsa_key -P "" &>/dev/null ; then fi fi - getcredentials.awk ssh_ecdsa_hostkey | grep -E -v '|' | sed -e 's/<//' -e 's/&/&/' -e 's/"/"/' -e "s/'/'/" > /tmp/ssh_ecdsa_hostkey + getcredentials.awk ssh_ecdsa_hostkey | xcat_filter_credentials > /tmp/ssh_ecdsa_hostkey #check whether the message is an error or not grep -E '' /tmp/ssh_ecdsa_hostkey > /dev/null 2>&1 @@ -287,7 +287,7 @@ if ssh-keygen -t ecdsa -f /tmp/ecdsa_key -P "" &>/dev/null ; then fi fi - getcredentials.awk ssh_ecdsa_hostkey | grep -v '<'|sed -e 's/<//' -e 's/&/&/' -e 's/"/"/' -e "s/'/'/" > /etc/ssh/ssh_host_ecdsa_key + getcredentials.awk ssh_ecdsa_hostkey | grep -v '<' | xcat_decode_credentials > /etc/ssh/ssh_host_ecdsa_key MYCONT=`cat /etc/ssh/ssh_host_ecdsa_key` done set_hostkey_permissions /etc/ssh/ssh_host_ecdsa_key @@ -332,7 +332,7 @@ if ssh-keygen -t ed25519 -f /tmp/ed25519_key -P "" &>/dev/null ; then fi fi - getcredentials.awk ssh_ed25519_hostkey | grep -E -v '|' | sed -e 's/<//' -e 's/&/&/' -e 's/"/"/' -e "s/'/'/" > /tmp/ssh_ed25519_hostkey + getcredentials.awk ssh_ed25519_hostkey | xcat_filter_credentials > /tmp/ssh_ed25519_hostkey #check whether the message is an error or not grep -E '' /tmp/ssh_ed25519_hostkey > /dev/null 2>&1 @@ -367,7 +367,7 @@ if ssh-keygen -t ed25519 -f /tmp/ed25519_key -P "" &>/dev/null ; then fi fi - getcredentials.awk ssh_ed25519_hostkey | grep -v '<'|sed -e 's/<//' -e 's/&/&/' -e 's/"/"/' -e "s/'/'/" > /etc/ssh/ssh_host_ed25519_key + getcredentials.awk ssh_ed25519_hostkey | grep -v '<' | xcat_decode_credentials > /etc/ssh/ssh_host_ed25519_key MYCONT=`cat /etc/ssh/ssh_host_ed25519_key` done set_hostkey_permissions /etc/ssh/ssh_host_ed25519_key @@ -431,7 +431,7 @@ if [ $useflowcontrol = "1" ]; then fi fi - getcredentials.awk $rootsshpubkey | grep -E -v '|'|sed -e 's/<//' -e 's/&/&/' -e 's/"/"/' -e "s/'/'/" > /tmp/ssh_root_pub_key + getcredentials.awk $rootsshpubkey | xcat_filter_credentials > /tmp/ssh_root_pub_key logger -t $log_label -p local4.info "remoteshell: gathering $rootsshpubkey " #check whether the message is an error or not @@ -467,8 +467,8 @@ if [ $? -ne 0 ]; then useflowcontrol=0 fi fi - getcredentials.awk $rootsshpubkey | grep -v '<'|sed -e 's/<//' -e 's/&/&/' -e 's/"/"/' -e "s/'/'/" > /root/.ssh/id_rsa.pub - getcredentials.awk $rootsshpubkey | grep -v '<'|sed -e 's/<//' -e 's/&/&/' -e 's/"/"/' -e "s/'/'/" >> /root/.ssh/authorized_keys + getcredentials.awk $rootsshpubkey | grep -v '<'|xcat_decode_credentials > /root/.ssh/id_rsa.pub + getcredentials.awk $rootsshpubkey | grep -v '<'|xcat_decode_credentials >> /root/.ssh/authorized_keys MYCONT=`cat /root/.ssh/id_rsa.pub` done else @@ -493,7 +493,7 @@ then useflowcontrol=0 fi fi - getcredentials.awk $rootsshpvtkey | grep -E -v '|'|sed -e 's/<//' -e 's/&/&/' -e 's/"/"/' -e "s/'/'/" > /tmp/ssh_root_key + getcredentials.awk $rootsshpvtkey | xcat_filter_credentials > /tmp/ssh_root_key logger -t $log_label -p local4.info "remoteshell: gathering $rootsshpvtkey " #check whether the message is an error or not @@ -527,7 +527,7 @@ then useflowcontrol=0 fi fi - getcredentials.awk $rootsshpvtkey | grep -v '<'|sed -e 's/<//' -e 's/&/&/' -e 's/"/"/' -e "s/'/'/" > /root/.ssh/id_rsa + getcredentials.awk $rootsshpvtkey | grep -v '<'|xcat_decode_credentials > /root/.ssh/id_rsa MYCONT=`cat /root/.ssh/id_rsa` done else @@ -561,7 +561,7 @@ if [ "x$SECUREROOT" = "x1" ] && [ "x$UPDATENODE" != "x1" ]; then useflowcontrol=0 fi fi - getcredentials.awk xcat_secure_pw:root | grep -E -v '|'|sed -e 's/<//' -e 's/&/&/' -e 's/"/"/' -e "s/'/'/" > /tmp/secure_root_hash + getcredentials.awk xcat_secure_pw:root | xcat_filter_credentials > /tmp/secure_root_hash logger -t $log_label -p local4.info "remoteshell: gathering password hash for root" #check whether the message is an error or not @@ -593,7 +593,7 @@ if [ "x$SECUREROOT" = "x1" ] && [ "x$UPDATENODE" != "x1" ]; then useflowcontrol=0 fi fi - getcredentials.awk xcat_secure_pw:root | grep -E -v '|'|sed -e 's/<//' -e 's/&/&/' -e 's/"/"/' -e "s/'/'/" > /tmp/secure_root_hash + getcredentials.awk xcat_secure_pw:root | xcat_filter_credentials > /tmp/secure_root_hash grep -E '' /tmp/secure_root_hash > /dev/null if [ $? -ne 0 ]; then rootpwhash=`cat /tmp/secure_root_hash | grep -E '' | sed -n 's%.*\(.*\).*%\1%p'` diff --git a/xCAT/postscripts/sudoer b/xCAT/postscripts/sudoer index 3c502d66f..6487c432b 100755 --- a/xCAT/postscripts/sudoer +++ b/xCAT/postscripts/sudoer @@ -180,7 +180,7 @@ for attempt in 1 2 3; do log info "sending xcatflowrequest $master 3001" /xcatpost/xcatflowrequest $master 3001 fi - response=$(getcredentials.awk xcat_secure_pw:$SUDOER | grep -E -v '|' | sed -e 's/<//' -e 's/&/&/' -e 's/"/"/' -e "s/'/'/") + response=$(getcredentials.awk xcat_secure_pw:$SUDOER | xcat_filter_credentials) [ -n "$response" ] && break [ $attempt -lt 3 ] && sleep $((attempt * 5)) done diff --git a/xCAT/postscripts/xcatlib.sh b/xCAT/postscripts/xcatlib.sh index 31f7f9cc4..3e68c2f52 100755 --- a/xCAT/postscripts/xcatlib.sh +++ b/xCAT/postscripts/xcatlib.sh @@ -1053,6 +1053,16 @@ function xcat_restart_sshd_after_failed_service_restart { $sshd_cmd } +function xcat_decode_credentials { + sed -e 's/<//' -e 's/&/&/' \ + -e 's/"/"/' -e "s/'/'/" +} + +function xcat_filter_credentials { + grep -E -v '|' | + xcat_decode_credentials +} + function fetch_mypostscript { local postroot postroot=$1 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 2/3] 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); From 97fce5a590e088ab27812d7145edbf333e778581 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:30:27 -0300 Subject: [PATCH 3/3] docs(test): list credential fixture prerequisites --- xCAT-test/native/README.md | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/xCAT-test/native/README.md b/xCAT-test/native/README.md index 80102a77d..3fe5ac773 100644 --- a/xCAT-test/native/README.md +++ b/xCAT-test/native/README.md @@ -144,8 +144,10 @@ Compressor and ctorrent fixtures record what nodes can download during the pack, or fail on request. The image lock and the ilitefile query are test doubles. This case does not boot the image. -The disk, kdump and sudoer cases require bats-core 1.4 or newer, Bubblewrap, -GNU timeout, diffutils, Capture::Tiny, File::Slurper and sudo's `visudo`. +The disk, kdump, sudoer and remoteshell cases require bats-core 1.4 or newer, +Bubblewrap, GNU timeout, diffutils, Capture::Tiny, File::Slurper and sudo's +`visudo`. The remoteshell case also needs `/usr/bin/openssl` and `ssh-keygen` +with DSA, RSA, ECDSA and Ed25519 support. Sandbox commands have a 45-second limit. Enable Linux user namespaces. Run the group as root because the disk-selection case creates dummy block-device entries: @@ -154,7 +156,8 @@ the disk-selection case creates dummy block-device entries: sudo bats xCAT-test/native/getinstdisk_selection.bats sudo prove xCAT-test/native/getinstdisk_installer.t \ xCAT-test/native/enablekdump_per_node.t \ - xCAT-test/native/sudoer_password_source.t + xCAT-test/native/sudoer_password_source.t \ + xCAT-test/native/remoteshell_credentials.t ``` The BATS cases source the unchanged disk selector. Partition and udev fixtures @@ -175,7 +178,9 @@ copy for this reason. These cases run the unchanged scripts with private filesystems and no network. Disk discovery, NFS mounts, credentials, account commands and service commands use fixtures. The disk case never opens its dummy devices. The sudoer case -checks generated rules with the real `visudo` parser. These tests do not perform +checks generated rules with the real `visudo` parser. The remoteshell case +generates disposable SSH keys and checks their installation through the real script. +These tests do not perform an installation, capture a kernel crash or modify host accounts. Check the TAP output for skipped prerequisites. A successful `prove` exit with