From 94e42f18c8fdf0a92c5c4276864d3b8632cccc10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vin=C3=ADcius=20Ferr=C3=A3o?= <2031761+viniciusferrao@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:52:59 -0300 Subject: [PATCH 1/2] fix(remoteshell): write sshd settings to the drop-in directory Keep administrator-owned sshd policy intact when a usable Include directory exists, including the existing MaxStartups value. Fall back safely when a drop-in cannot be used and fail explicitly if the required configuration helper cannot run. --- xCAT/postscripts/remoteshell | 32 ++---- xCAT/postscripts/remoteshell-sshd-config | 139 +++++++++++++++++++++++ 2 files changed, 147 insertions(+), 24 deletions(-) create mode 100755 xCAT/postscripts/remoteshell-sshd-config diff --git a/xCAT/postscripts/remoteshell b/xCAT/postscripts/remoteshell index 28f4a3dfa..debd1b6a5 100755 --- a/xCAT/postscripts/remoteshell +++ b/xCAT/postscripts/remoteshell @@ -54,31 +54,15 @@ if [ "$USEFLOWCONTROL" = "YES" ] || [ "$USEFLOWCONTROL" = "yes" ] || [ "$USEFLOW useflowcontrol=1 fi -if [ -r /etc/ssh/sshd_config ] -then - logger -t $log_label -p local4.info "remoteshell: setup /etc/ssh/sshd_config and ssh_config" - cp /etc/ssh/sshd_config /etc/ssh/sshd_config.ORIG - #delete all occurance of the attribute and then add xCAT settings - sed -i '/X11Forwarding /'d /etc/ssh/sshd_config - echo "X11Forwarding yes" >>/etc/ssh/sshd_config - # delete all MaxStartups settings and use default value - sed -i '/MaxStartups /'d /etc/ssh/sshd_config - - if [ "$SETUPFORPCM" = "1" ]; then - if [[ $OSVER == sle* ]];then - sed -i '/PasswordAuthentication /'d /etc/ssh/sshd_config - echo "PasswordAuthentication yes" >>/etc/ssh/sshd_config - elif [[ $OSVER == ubuntu* ]];then - sed -i 's/^PermitRootLogin.*/PermitRootLogin yes/' /etc/ssh/sshd_config - fi +sshd_config_helper="$(dirname "$0")/remoteshell-sshd-config" +if [ -x "$sshd_config_helper" ]; then + if ! "$sshd_config_helper" "${SETUPFORPCM:-0}" "${OSVER:-}" "$log_label"; then + logger -t $log_label -p local4.err "remoteshell: failed to configure sshd with $sshd_config_helper" + exit 1 fi -fi - -if [ -r /etc/ssh/ssh_config ] -then - sed -i '/StrictHostKeyChecking /'d /etc/ssh/ssh_config - echo "StrictHostKeyChecking no" >> /etc/ssh/ssh_config - +else + logger -t $log_label -p local4.err "remoteshell: required sshd configuration helper not found: $sshd_config_helper" + exit 1 fi xcatpost="xcatpost" if [ -d /xcatpost/_ssh ] diff --git a/xCAT/postscripts/remoteshell-sshd-config b/xCAT/postscripts/remoteshell-sshd-config new file mode 100755 index 000000000..d27d0ef4b --- /dev/null +++ b/xCAT/postscripts/remoteshell-sshd-config @@ -0,0 +1,139 @@ +#!/bin/sh +# IBM(c) 2007 EPL license http://www.eclipse.org/legal/epl-v10.html + +setup_for_pcm=${1:-0} +osver=${2:-} +log_label=${3:-xcat} +ssh_etc=${XCAT_SSH_ETC:-/etc/ssh} +logger_command=${XCAT_LOGGER:-logger} +sshd_config="$ssh_etc/sshd_config" +ssh_config="$ssh_etc/ssh_config" + +xcat_log() { + priority=$1 + shift + "$logger_command" -t "$log_label" -p "$priority" "$@" +} + +# Write a drop-in fragment atomically: scratch file then rename, the leading dot +# keeping the scratch out of sshd's *.conf glob. Returns nonzero without +# touching anything if the write fails or the file is not ours. +XCATSSHDDROPINMARKER="# Written by the xCAT remoteshell postscript." +xcat_write_dropin() { + xcat_dir=$1 + xcat_file=$2 + shift 2 + xcat_dest="$xcat_dir/$xcat_file" + if [ -e "$xcat_dest" ] && \ + ! head -n 1 "$xcat_dest" 2>/dev/null | grep -qF "$XCATSSHDDROPINMARKER"; then + return 1 + fi + xcat_tmp="$xcat_dir/.$xcat_file.xcatnew.$$" + mkdir -p "$xcat_dir" 2>/dev/null || return 1 + ( : >"$xcat_tmp" ) 2>/dev/null || return 1 + chmod 600 "$xcat_tmp" 2>/dev/null + ( + echo "$XCATSSHDDROPINMARKER Do not edit." + for xcat_line in "$@"; do + echo "$xcat_line" + done + ) 2>/dev/null >>"$xcat_tmp" || { rm -f "$xcat_tmp"; return 1; } + mv -f "$xcat_tmp" "$xcat_dest" 2>/dev/null || { rm -f "$xcat_tmp"; return 1; } + return 0 +} + +# sshd keeps the first value it finds for a keyword, so a setting ahead of the +# Include line or in an earlier fragment still wins over ours; log it. +xcat_warn_if_overridden() { + xcat_kw=$1 + xcat_own="$XCATSSHDDROPINDIR/$2" + if sed -n '/^[[:space:]]*[Mm][Aa][Tt][Cc][Hh][[:space:]]/q; /^[[:space:]]*[Ii][Nn][Cc][Ll][Uu][Dd][Ee][[:space:]]\{1,\}[^[:space:]]\{1,\}\/\*\.conf[[:space:]]*$/q; p' "$sshd_config" 2>/dev/null | \ + grep -i "^[[:space:]]*${xcat_kw}[[:space:]]" >/dev/null 2>&1; then + xcat_log local4.warning "remoteshell: $xcat_kw is set before the Include line in $sshd_config and overrides $xcat_own" + fi + for xcat_frag in "$XCATSSHDDROPINDIR"/*.conf; do + [ "$xcat_frag" = "$xcat_own" ] && break + [ -r "$xcat_frag" ] || continue + if grep -i "^[[:space:]]*${xcat_kw}[[:space:]]" "$xcat_frag" >/dev/null 2>&1; then + xcat_log local4.warning "remoteshell: $xcat_frag sets $xcat_kw and is read before $xcat_own" + fi + done +} + +xcat_warn_if_fragment_overrides_fallback() { + xcat_kw=$1 + [ -n "$XCATSSHDDIR" ] || return 0 + for xcat_frag in "$XCATSSHDDIR"/*.conf; do + [ -r "$xcat_frag" ] || continue + if grep -i "^[[:space:]]*${xcat_kw}[[:space:]]" "$xcat_frag" >/dev/null 2>&1; then + xcat_log local4.warning "remoteshell: $xcat_frag sets $xcat_kw before the fallback value appended to $sshd_config" + return 0 + fi + done +} + +if [ -r "$sshd_config" ]; then + xcat_log local4.info "remoteshell: setup $sshd_config and ssh_config" + + # Take a single *.conf Include ahead of the first Match block. An Include + # inside Match does not apply to every connection. + XCATSSHDDIR=`sed -n '/^[[:space:]]*[Mm][Aa][Tt][Cc][Hh][[:space:]]/q; s|^[[:space:]]*[Ii][Nn][Cc][Ll][Uu][Dd][Ee][[:space:]]\{1,\}\([^[:space:]]\{1,\}\)/\*\.conf[[:space:]]*$|\1|p' "$sshd_config" | head -n 1` + case "$XCATSSHDDIR" in + ""|/*) ;; + *) XCATSSHDDIR="$ssh_etc/$XCATSSHDDIR" ;; + esac + case "$XCATSSHDDIR" in + *[][*?]*) XCATSSHDDIR="" ;; + esac + + XCATSSHDDROPINDIR="" + if [ -n "$XCATSSHDDIR" ]; then + if xcat_write_dropin "$XCATSSHDDIR" "01-xcat.conf" "X11Forwarding yes"; then + XCATSSHDDROPINDIR="$XCATSSHDDIR" + xcat_warn_if_overridden "X11Forwarding" "01-xcat.conf" + else + xcat_log local4.err "remoteshell: could not write $XCATSSHDDIR/01-xcat.conf, editing $sshd_config instead" + fi + fi + if [ -z "$XCATSSHDDROPINDIR" ]; then + cp "$sshd_config" "$sshd_config.ORIG" + sed -i '/X11Forwarding /'d "$sshd_config" + echo "X11Forwarding yes" >>"$sshd_config" + xcat_warn_if_fragment_overrides_fallback "X11Forwarding" + fi + + if [ "$setup_for_pcm" = "1" ]; then + case "$osver" in + sle*) + if [ -n "$XCATSSHDDROPINDIR" ] && \ + xcat_write_dropin "$XCATSSHDDROPINDIR" "02-xcat-pcm.conf" "PasswordAuthentication yes"; then + xcat_warn_if_overridden "PasswordAuthentication" "02-xcat-pcm.conf" + else + [ -e "$sshd_config.ORIG" ] || cp "$sshd_config" "$sshd_config.ORIG" + sed -i '/PasswordAuthentication /'d "$sshd_config" + echo "PasswordAuthentication yes" >>"$sshd_config" + xcat_warn_if_fragment_overrides_fallback "PasswordAuthentication" + fi + ;; + ubuntu*) + if grep -q '^PermitRootLogin' "$sshd_config"; then + if [ -n "$XCATSSHDDROPINDIR" ] && \ + xcat_write_dropin "$XCATSSHDDROPINDIR" "02-xcat-pcm.conf" "PermitRootLogin yes"; then + xcat_warn_if_overridden "PermitRootLogin" "02-xcat-pcm.conf" + else + [ -e "$sshd_config.ORIG" ] || cp "$sshd_config" "$sshd_config.ORIG" + sed -i 's/^PermitRootLogin.*/PermitRootLogin yes/' "$sshd_config" + xcat_warn_if_fragment_overrides_fallback "PermitRootLogin" + fi + fi + ;; + esac + fi +fi + +if [ -r "$ssh_config" ]; then + sed -i '/StrictHostKeyChecking /'d "$ssh_config" + echo "StrictHostKeyChecking no" >>"$ssh_config" +fi + +exit 0 From 01b220be17233ab64540159ae4215cdd66778f62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vin=C3=ADcius=20Ferr=C3=A3o?= <2031761+viniciusferrao@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:52:59 -0300 Subject: [PATCH 2/2] test(remoteshell): cover the sshd_config handling --- xCAT-test/unit/remoteshell_sshd_config.t | 454 +++++++++++++++++++++++ 1 file changed, 454 insertions(+) create mode 100644 xCAT-test/unit/remoteshell_sshd_config.t diff --git a/xCAT-test/unit/remoteshell_sshd_config.t b/xCAT-test/unit/remoteshell_sshd_config.t new file mode 100644 index 000000000..c3559b2cd --- /dev/null +++ b/xCAT-test/unit/remoteshell_sshd_config.t @@ -0,0 +1,454 @@ +#!/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();