2
0
mirror of https://github.com/xcat2/xcat-core.git synced 2026-09-09 14:36:43 +00:00
Files
xcat-core/xCAT-test/unit/remoteshell_sshd_config.t
T
2026-08-26 21:06:11 -03:00

455 lines
19 KiB
Perl

#!/usr/bin/env perl
use strict;
use warnings;
use File::Copy qw(copy);
use File::Path qw(make_path);
use File::Slurper qw(read_text write_text);
use File::Temp qw(tempdir);
use FindBin;
use lib "$FindBin::Bin/../lib";
use Test::More;
use XCAT::Test::File qw(repo_path);
my $postscripts = repo_path('xCAT/postscripts');
my $remoteshell = "$postscripts/remoteshell";
my $sshd_helper = "$postscripts/remoteshell-sshd-config";
plan skip_all => 'remoteshell postscript not found' unless -r $remoteshell;
plan skip_all => 'remoteshell sshd helper not found' unless -x $sshd_helper;
# The postscript is written against GNU sed; sed -i means something else on BSD.
plan skip_all => 'postscript targets Linux nodes' unless $^O eq 'linux';
is( system( 'sh', '-n', $sshd_helper ), 0,
'the sshd configuration helper has POSIX shell syntax' );
sub run_sshd_helper {
my (%opt) = @_;
my ($root, $sshdir, $config);
if ($opt{reuse}) {
# A second deployment against the tree the first one left behind.
($root, $sshdir, $config) = @{$opt{reuse}}{qw(root sshdir untouched)};
}
else {
$root = tempdir(CLEANUP => 1);
$sshdir = "$root/etc/ssh";
make_path($sshdir);
make_path("$root/bin");
# The fixtures name /etc/ssh so they read like a real config; point them
# at the scratch tree along with the script itself.
$config = $opt{sshd_config};
$config =~ s{/etc/ssh}{$sshdir}g;
write_text("$sshdir/sshd_config", $config);
write_text("$sshdir/ssh_config", "Host *\n");
make_path("$root$opt{dropin_dir}") if $opt{dropin_dir} && !$opt{skip_dropin_dir};
# Record logging in the scratch tree rather than the real syslog.
write_text("$root/bin/logger",
"#!/bin/sh\nprintf '%s\\n' \"\$*\" >> '$root/logger.log'\nexit 0\n");
chmod 0755, "$root/bin/logger";
}
my @args = (
$opt{pcm} ? 1 : 0,
defined $opt{osver} ? $opt{osver} : '',
'xcat',
);
my $rc;
my $output;
{
local $ENV{XCAT_SSH_ETC} = $sshdir;
local $ENV{XCAT_LOGGER} = "$root/bin/logger";
open(
my $pipe,
'-|', 'sh', '-c', 'exec "$@" 2>&1', 'sh',
$sshd_helper, @args,
) or die "Unable to run $sshd_helper: $!";
$output = do { local $/; <$pipe> };
close($pipe);
$rc = $?;
}
return {
root => $root,
sshdir => $sshdir,
sshd_config => read_file("$sshdir/sshd_config"),
ssh_config => read_file("$sshdir/ssh_config"),
untouched => $config,
orig => (-e "$sshdir/sshd_config.ORIG" ? 1 : 0),
output => $output,
rc => $rc,
logged => read_file("$root/logger.log"),
};
}
sub read_file {
my ($path) = @_;
return '' unless -f $path;
return read_text($path);
}
sub run_remoteshell_wrapper {
my (%opt) = @_;
my $helper_contents = $opt{helper};
my $root = tempdir(CLEANUP => 1);
my $bin = "$root/bin";
make_path($bin);
for my $name (qw(remoteshell xcatlib.sh)) {
my $source = "$postscripts/$name";
my $destination = "$bin/$name";
copy($source, $destination)
or die "Unable to stage $name: $!";
chmod 0755, $destination or die "Unable to make $destination executable: $!";
}
if (defined $helper_contents) {
write_text("$bin/remoteshell-sshd-config", $helper_contents);
chmod 0755, "$bin/remoteshell-sshd-config"
or die "Unable to make staged helper executable: $!";
}
write_text("$bin/logger", "#!/bin/sh\nprintf '%s\\n' \"\$*\" >>'$root/logger.log'\n");
chmod 0755, "$bin/logger" or die "Unable to make logger executable: $!";
my $status;
{
local %ENV = (
%ENV,
PATH => "$bin:/usr/bin:/bin",
OSVER => defined($opt{osver}) ? $opt{osver} : '',
LOGLABEL => defined($opt{log_label}) ? $opt{log_label} : 'xcat',
);
$status = system( "$bin/remoteshell", @{ $opt{args} || [] } );
}
return ($status >> 8, read_file("$root/logger.log"));
}
my $ADMIN_POLICY = <<'EOF';
Port 22
MaxStartups 3:30:3
X11Forwarding no
EOF
my $WITH_INCLUDE = "Include /etc/ssh/sshd_config.d/*.conf\n" . $ADMIN_POLICY;
SKIP: {
skip 'the management-node marker bypasses remoteshell setup', 6
if -e '/etc/xCATMN';
my ($missing_status, $missing_log) = run_remoteshell_wrapper();
isnt($missing_status, 0, 'the wrapper fails when its sshd helper is missing');
like($missing_log, qr/required sshd configuration helper not found/,
'the wrapper reports the missing helper');
my ($failed_status, $failed_log) = run_remoteshell_wrapper(
helper => "#!/bin/sh\nexit 42\n",
);
isnt($failed_status, 0, 'the wrapper fails when its sshd helper fails');
like($failed_log, qr/failed to configure sshd/,
'the wrapper reports the failed helper');
my $recording_helper = <<'SH';
#!/bin/sh
logger -t helper-args -p local4.info "$*"
exit 42
SH
my ( undef, $plain_log ) = run_remoteshell_wrapper(
helper => $recording_helper,
osver => 'ubuntu24.04',
log_label => 'wrapper-test',
);
like( $plain_log, qr/-t helper-args -p local4\.info 0 ubuntu24\.04 wrapper-test/m,
'the wrapper passes the normal helper contract' );
my ( undef, $pcm_log ) = run_remoteshell_wrapper(
helper => $recording_helper,
osver => 'ubuntu24.04',
log_label => 'wrapper-test',
args => ['-p'],
);
like( $pcm_log, qr/-t helper-args -p local4\.info 1 ubuntu24\.04 wrapper-test/m,
'the wrapper maps -p to the PCM helper contract' );
}
# --- sshd that reads a drop-in directory -----------------------------------
{
my $r = run_sshd_helper(sshd_config => $WITH_INCLUDE, dropin_dir => '/etc/ssh/sshd_config.d');
my $dropin = read_file("$r->{sshdir}/sshd_config.d/01-xcat.conf");
is($r->{rc}, 0, 'the postscript exits cleanly');
is($r->{sshd_config}, $r->{untouched}, 'sshd_config is left exactly as the administrator wrote it');
is($r->{orig}, 0, 'no sshd_config.ORIG copy is made when a drop-in is used');
like($dropin, qr/^X11Forwarding yes$/m, 'X11Forwarding is set in the drop-in');
unlike($dropin, qr/MaxStartups/, 'the drop-in does not set MaxStartups');
ok(!glob("$r->{sshdir}/sshd_config.d/*xcatnew*"), 'no scratch file is left behind');
like($r->{ssh_config}, qr/^StrictHostKeyChecking no$/m,
'the client SSH setting is configured by the same helper');
}
# The directory has to come from the file, since an administrator is free to
# point Include somewhere other than /etc/ssh/sshd_config.d.
{
my $config = "Include /etc/ssh/local.d/*.conf\n" . $ADMIN_POLICY;
my $r = run_sshd_helper(sshd_config => $config, dropin_dir => '/etc/ssh/local.d');
ok(-e "$r->{sshdir}/local.d/01-xcat.conf", 'the drop-in follows the Include path in the file');
ok(!-e "$r->{sshdir}/sshd_config.d/01-xcat.conf", 'no file is written to the assumed default path');
is($r->{sshd_config}, $r->{untouched}, 'sshd_config is untouched for a custom Include path');
}
# The directory may not exist yet on a freshly installed node.
{
my $r = run_sshd_helper(sshd_config => $WITH_INCLUDE, dropin_dir => '/etc/ssh/sshd_config.d', skip_dropin_dir => 1);
ok(-e "$r->{sshdir}/sshd_config.d/01-xcat.conf", 'the drop-in directory is created when missing');
}
# sshd resolves a relative Include under /etc/ssh, so writing it relative to
# wherever the postscript happens to be running would land nowhere useful.
{
my $config = "Include sshd_config.d/*.conf\n" . $ADMIN_POLICY;
my $r = run_sshd_helper(sshd_config => $config, dropin_dir => '/etc/ssh/sshd_config.d');
ok(-e "$r->{sshdir}/sshd_config.d/01-xcat.conf", 'a relative Include is resolved under /etc/ssh');
is($r->{sshd_config}, $r->{untouched}, 'sshd_config is untouched for a relative Include');
}
# Configuration keywords are not case sensitive.
for my $case (
['lower case include', "include /etc/ssh/sshd_config.d/*.conf\n"],
['upper case INCLUDE', "INCLUDE /etc/ssh/sshd_config.d/*.conf\n"],
) {
my ($name, $prefix) = @{$case};
my $r = run_sshd_helper(sshd_config => $prefix . $ADMIN_POLICY, dropin_dir => '/etc/ssh/sshd_config.d');
ok(-e "$r->{sshdir}/sshd_config.d/01-xcat.conf", "$name: the keyword is recognised");
}
# Forms that cannot be reduced to one directory have to fall back rather than
# guess, and an Include inside a Match block does not apply to every connection.
for my $case (
['several patterns on one line', "Include /etc/ssh/sshd_config.d/*.conf /etc/ssh/other.d/*.conf\n"],
['a quoted path', qq{Include "/etc/ssh/sshd_config.d/*.conf"\n}],
['a single file, not a glob', "Include /etc/ssh/local.conf\n"],
['an Include inside Match', "Match User admin\nInclude /etc/ssh/sshd_config.d/*.conf\n"],
['an Include inside MATCH', "MATCH User admin\nInclude /etc/ssh/sshd_config.d/*.conf\n"],
) {
my ($name, $prefix) = @{$case};
my $r = run_sshd_helper(sshd_config => $prefix . $ADMIN_POLICY, dropin_dir => '/etc/ssh/sshd_config.d');
ok(!-e "$r->{sshdir}/sshd_config.d/01-xcat.conf", "$name: no drop-in is guessed at");
is($r->{orig}, 1, "$name: falls back to editing sshd_config in place");
}
# A kernel-owned directory cannot accept the scratch file, even as root. The
# failure must stay quiet and fall back to the main configuration.
SKIP: {
skip 'procfs is not mounted at /proc', 3 unless -d '/proc/self';
my $r = run_sshd_helper(
sshd_config => "Include /proc/*.conf\n" . $ADMIN_POLICY,
);
like($r->{sshd_config}, qr/^X11Forwarding yes$/m, 'an unwritable drop-in directory falls back to the in-place edit');
is($r->{orig}, 1, 'the fallback still keeps a backup copy');
is($r->{output}, '', 'the expected drop-in fallback does not leak a shell error');
}
# SSH configuration is best effort. An unwritable client configuration must not
# prevent the wrapper from continuing to install the root keys.
{
my $r = run_sshd_helper(sshd_config => $WITH_INCLUDE, dropin_dir => '/etc/ssh/sshd_config.d');
unlink "$r->{sshdir}/ssh_config"
or die "Unable to remove the client configuration: $!";
mkdir "$r->{sshdir}/ssh_config"
or die "Unable to create the unwritable client configuration: $!";
my $again = run_sshd_helper(reuse => $r);
is($again->{rc}, 0, 'an unwritable client configuration remains best effort');
}
# An administrator's own file at 01-xcat.conf must not be clobbered; xCAT
# leaves it and edits sshd_config in place instead.
{
my $r = run_sshd_helper(sshd_config => $WITH_INCLUDE, dropin_dir => '/etc/ssh/sshd_config.d', skip_dropin_dir => 1);
make_path("$r->{sshdir}/sshd_config.d");
my $foreign = "$r->{sshdir}/sshd_config.d/01-xcat.conf";
write_text($foreign, "# admin's own file\nX11Forwarding no\n");
my $again = run_sshd_helper(reuse => $r);
is(read_file($foreign), "# admin's own file\nX11Forwarding no\n", 'a foreign 01-xcat.conf is left untouched');
like($again->{sshd_config}, qr/^X11Forwarding yes$/m, 'the setting falls back to sshd_config when the name is taken');
is($again->{orig}, 1, 'the fallback keeps a backup copy');
like($again->{logged}, qr/01-xcat\.conf sets X11Forwarding before the fallback value/,
'the fallback warns that the earlier fragment still controls X11Forwarding');
}
# The same protection covers the PCM fragment: a foreign 02-xcat-pcm.conf is
# not overwritten, and the PCM setting still lands via the in-place edit rather
# than being silently dropped.
{
my $config = "Include /etc/ssh/sshd_config.d/*.conf\nPermitRootLogin prohibit-password\n";
my $r = run_sshd_helper(sshd_config => $config, dropin_dir => '/etc/ssh/sshd_config.d', skip_dropin_dir => 1);
make_path("$r->{sshdir}/sshd_config.d");
my $foreign = "$r->{sshdir}/sshd_config.d/02-xcat-pcm.conf";
write_text($foreign, "# admin's own file\nPermitRootLogin no\n");
my $again = run_sshd_helper(reuse => $r, pcm => 1, osver => 'ubuntu24.04');
is(read_file($foreign), "# admin's own file\nPermitRootLogin no\n", 'a foreign 02-xcat-pcm.conf is left untouched');
like($again->{sshd_config}, qr/^PermitRootLogin yes$/m, 'PCM falls back to the in-place edit when its fragment cannot be written');
like($again->{logged}, qr/02-xcat-pcm\.conf sets PermitRootLogin before the fallback value/,
'the PCM fallback warns that the earlier fragment still controls root login');
}
{
my $config = "Include /etc/ssh/sshd_config.d/*.conf\n#PermitRootLogin prohibit-password\n";
my $r = run_sshd_helper(
sshd_config => $config,
dropin_dir => '/etc/ssh/sshd_config.d',
pcm => 1,
osver => 'ubuntu24.04',
);
ok(!-e "$r->{sshdir}/sshd_config.d/02-xcat-pcm.conf",
'Ubuntu PCM does not create a root-login setting when none was active');
is($r->{sshd_config}, $r->{untouched},
'Ubuntu PCM preserves a commented root-login default');
}
# sshd allows glob metacharacters anywhere in an Include path. A parent that is
# itself a pattern (sshd_config.[12].d) must not be written to literally.
{
my $config = "Include /etc/ssh/sshd_config.[12].d/*.conf\n" . $ADMIN_POLICY;
my $r = run_sshd_helper(sshd_config => $config);
ok(!-e "$r->{sshdir}/sshd_config.[12].d", 'no literal directory is created for a glob parent');
like($r->{sshd_config}, qr/^X11Forwarding yes$/m, 'a glob parent falls back to the in-place edit');
is($r->{orig}, 1, 'the glob-parent fallback keeps a backup copy');
}
# --- sshd without Include support ------------------------------------------
for my $case (
['no Include line at all', $ADMIN_POLICY],
['Include commented out', "#Include /etc/ssh/sshd_config.d/*.conf\n" . $ADMIN_POLICY],
) {
my ($name, $config) = @{$case};
my $r = run_sshd_helper(sshd_config => $config);
like($r->{sshd_config}, qr/^X11Forwarding yes$/m, "$name: X11Forwarding is set in sshd_config");
unlike($r->{sshd_config}, qr/^X11Forwarding no$/m, "$name: the old X11Forwarding line is removed");
is($r->{orig}, 1, "$name: sshd_config.ORIG is kept as a backup");
ok(!-e "$r->{sshdir}/sshd_config.d/01-xcat.conf", "$name: no drop-in is written");
}
# --- MaxStartups belongs to the administrator ------------------------------
for my $case (
['with a drop-in directory', $WITH_INCLUDE, '/etc/ssh/sshd_config.d'],
['editing in place', $ADMIN_POLICY, undef],
) {
my ($name, $config, $dir) = @{$case};
my $r = run_sshd_helper(sshd_config => $config, dropin_dir => $dir);
like($r->{sshd_config}, qr/^MaxStartups 3:30:3$/m, "$name: the administrator's MaxStartups survives");
}
# --- the PCM settings keep their own fragment ------------------------------
# updatenode reruns remoteshell without -p, so anything the PCM setup wrote has
# to survive a plain run.
{
my $config = "Include /etc/ssh/sshd_config.d/*.conf\nPermitRootLogin prohibit-password\n";
my $r = run_sshd_helper(
sshd_config => $config,
dropin_dir => '/etc/ssh/sshd_config.d',
pcm => 1,
osver => 'ubuntu24.04',
);
like(read_file("$r->{sshdir}/sshd_config.d/02-xcat-pcm.conf"), qr/^PermitRootLogin yes$/m,
'PermitRootLogin goes to its own fragment on Ubuntu');
unlike(read_file("$r->{sshdir}/sshd_config.d/01-xcat.conf"), qr/PermitRootLogin/,
'the PCM setting is kept out of 01-xcat.conf');
is($r->{sshd_config}, $r->{untouched}, 'sshd_config is untouched by the PCM setup');
my $again = run_sshd_helper(reuse => $r, osver => 'ubuntu24.04');
like(read_file("$again->{sshdir}/sshd_config.d/02-xcat-pcm.conf"), qr/^PermitRootLogin yes$/m,
'a later run without -p leaves the PCM setting alone');
like(read_file("$again->{sshdir}/sshd_config.d/01-xcat.conf"), qr/^X11Forwarding yes$/m,
'the later run still refreshes its own fragment');
}
{
my $config = "Include /etc/ssh/sshd_config.d/*.conf\nPasswordAuthentication no\n";
my $r = run_sshd_helper(
sshd_config => $config,
dropin_dir => '/etc/ssh/sshd_config.d',
pcm => 1,
osver => 'sles15',
);
like(read_file("$r->{sshdir}/sshd_config.d/02-xcat-pcm.conf"), qr/^PasswordAuthentication yes$/m,
'PasswordAuthentication goes to its own fragment on SLES');
my $again = run_sshd_helper(reuse => $r, osver => 'sles15');
like(read_file("$again->{sshdir}/sshd_config.d/02-xcat-pcm.conf"), qr/^PasswordAuthentication yes$/m,
'a later run without -p leaves the SLES setting alone');
}
# Without a drop-in directory the PCM settings are still edited in place.
{
my $r = run_sshd_helper(sshd_config => "PasswordAuthentication no\n", pcm => 1, osver => 'sles15');
like($r->{sshd_config}, qr/^PasswordAuthentication yes$/m, 'PasswordAuthentication is edited in place on SLES');
unlike($r->{sshd_config}, qr/^PasswordAuthentication no$/m, 'the old PasswordAuthentication line is removed');
}
# sshd keeps the first value it finds, so a keyword set ahead of the Include
# line still wins over the drop-in. The postscript detects that and warns.
{
my $r = run_sshd_helper(
sshd_config => "X11Forwarding no\nInclude /etc/ssh/sshd_config.d/*.conf\nPort 22\n" );
like( $r->{logged}, qr/X11Forwarding is set before the Include line/,
'a keyword set before the Include line is reported' );
like( $r->{logged}, qr{01-xcat\.conf},
'the report names the fragment that is overridden' );
}
# The same keyword after the Include line does not override the drop-in.
{
my $r = run_sshd_helper(
sshd_config => "Include /etc/ssh/sshd_config.d/*.conf\nX11Forwarding no\nPort 22\n" );
unlike( $r->{logged}, qr/X11Forwarding is set before the Include line/,
'a keyword set after the Include line is not reported' );
}
# The Include keyword is matched whatever its case and leading spacing.
{
my $r = run_sshd_helper(
sshd_config => "X11Forwarding no\n inClUdE /etc/ssh/sshd_config.d/*.conf\nPort 22\n" );
like( $r->{logged}, qr/X11Forwarding is set before the Include line/,
'the Include line is recognised whatever its case and spacing' );
}
# A keyword that merely starts with the Include name is not an Include line.
{
my $r = run_sshd_helper(
sshd_config => "IncludeFoo bar\nX11Forwarding no\nInclude /etc/ssh/sshd_config.d/*.conf\n" );
like( $r->{logged}, qr/X11Forwarding is set before the Include line/,
'a keyword that only begins with Include does not end the search' );
}
# An earlier Include that is not the selected drop-in glob does not end the
# precedence scan either.
{
my $r = run_sshd_helper(
sshd_config => "Include /etc/ssh/local.conf\nX11Forwarding no\nInclude /etc/ssh/sshd_config.d/*.conf\n" );
like( $r->{logged}, qr/X11Forwarding is set before the Include line/,
'an unrelated earlier Include does not hide an overriding keyword' );
}
done_testing();