From c8baebe267bd96fe82291637ea37334c832365cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vin=C3=ADcius=20Ferr=C3=A3o?= <2031761+viniciusferrao@users.noreply.github.com> Date: Sat, 18 Jul 2026 13:27:40 -0300 Subject: [PATCH 1/5] fix(dhcp): reuse Kea control socket paths (cherry picked from commit 543eab3bdc61cef487598f91f987616e6b7e0289) --- perl-xCAT/xCAT/DHCP/Backend/Kea.pm | 145 +++++++++++++++++++++++---- xCAT-server/lib/xcat/plugins/dhcp.pm | 4 +- 2 files changed, 128 insertions(+), 21 deletions(-) diff --git a/perl-xCAT/xCAT/DHCP/Backend/Kea.pm b/perl-xCAT/xCAT/DHCP/Backend/Kea.pm index c4c1a27e0..ec8ea26e6 100644 --- a/perl-xCAT/xCAT/DHCP/Backend/Kea.pm +++ b/perl-xCAT/xCAT/DHCP/Backend/Kea.pm @@ -7,6 +7,7 @@ use JSON; use File::Basename; use File::Path qw/make_path/; use Math::BigInt; +use Text::ParseWords qw/shellwords/; use xCAT::DHCP::Range; use xCAT::NetworkUtils; @@ -50,6 +51,12 @@ sub ddns_config_file { return $self->{ddns_config_file} || '/etc/kea/kea-dhcp-ddns.conf'; } +sub control_socket_path { + my ( $self, $socket_name ) = @_; + + return $self->_kea_socket_dir() . "/$socket_name"; +} + sub render_dhcp4_config { my ( $self, $intent ) = @_; @@ -154,19 +161,19 @@ sub render_ctrl_agent_config { my %sockets = ( dhcp4 => { 'socket-type' => 'unix', - 'socket-name' => $intent->{'dhcp4-socket'} || $self->_kea_control_socket('kea4-ctrl-socket'), + 'socket-name' => $intent->{'dhcp4-socket'} || $self->control_socket_path('kea4-ctrl-socket'), }, ); if ( $intent->{dhcp6} || $intent->{'dhcp6-socket'} ) { $sockets{dhcp6} = { 'socket-type' => 'unix', - 'socket-name' => $intent->{'dhcp6-socket'} || $self->_kea_control_socket('kea6-ctrl-socket'), + 'socket-name' => $intent->{'dhcp6-socket'} || $self->control_socket_path('kea6-ctrl-socket'), }; } if ( $intent->{ddns} || $intent->{'ddns-socket'} ) { $sockets{d2} = { 'socket-type' => 'unix', - 'socket-name' => $intent->{'ddns-socket'} || $self->_kea_control_socket('kea-ddns-ctrl-socket'), + 'socket-name' => $intent->{'ddns-socket'} || $self->control_socket_path('kea-ddns-ctrl-socket'), }; } @@ -911,15 +918,7 @@ sub kea_version { return $self->{kea_version} if defined $self->{kea_version}; return $self->{_detected_kea_version} if defined $self->{_detected_kea_version}; - my $command = $self->{kea_dhcp4_command} || _command_path('kea-dhcp4'); - return unless $command; - - my $output = ''; - if ( open( my $version_fh, '-|', $command, '-V' ) ) { - local $/; - $output = <$version_fh> || ''; - close($version_fh); - } + my $output = $self->_kea_command_output('-V') || ''; if ( $output =~ /(\d+(?:\.\d+){1,2})/ ) { $self->{_detected_kea_version} = $1; @@ -928,6 +927,34 @@ sub kea_version { return $self->{_detected_kea_version}; } +sub _kea_build_report { + my ($self) = @_; + + return $self->{kea_build_report} if defined $self->{kea_build_report}; + return $self->{_detected_kea_build_report} if defined $self->{_detected_kea_build_report}; + + my $output = $self->_kea_command_output('-W') || ''; + + $self->{_detected_kea_build_report} = $output; + return $output; +} + +sub _kea_command_output { + my ( $self, @args ) = @_; + + my $command = $self->{kea_dhcp4_command} || _command_path('kea-dhcp4'); + return unless $command; + + my $output = ''; + if ( open( my $command_fh, '-|', $command, @args ) ) { + local $/; + $output = <$command_fh> || ''; + close($command_fh); + } + + return $output; +} + sub _first_defined { my @values = @_; foreach my $value (@values) { @@ -1028,21 +1055,101 @@ sub _kea_socket_dir { return $self->{kea_socket_dir} if defined $self->{kea_socket_dir}; + if ( defined $self->{kea_socket_dirs} ) { + foreach my $dir ( @{ $self->{kea_socket_dirs} || [] } ) { + return $dir if -d $dir; + } + return '/var/run/kea'; + } + + my $build_report = $self->_kea_build_report(); + if ($build_report) { + my $prefix = _kea_build_option( $build_report, 'prefix' ); + if ( !defined($prefix) && $build_report =~ /^\s*Prefix:\s*(\/\S+)/m ) { + $prefix = $1; + } + $prefix = _expand_kea_build_path( $prefix, {} ); + + # Kea's Meson build unconditionally overrides the installed runtime + # directory for the /usr/local prefix, even when state directories + # are supplied as build options. + if ( defined($prefix) && $prefix eq '/usr/local' && $build_report =~ /^\s*Meson Version:/m ) { + return '/usr/local/var/run/kea'; + } + + my $local_state = _kea_build_option( $build_report, 'localstatedir' ); + $local_state = _expand_kea_build_path( $local_state, { prefix => $prefix }, $prefix ); + if ( !defined($local_state) && defined($prefix) ) { + if ( $build_report =~ /^\s*Meson Version:/m ) { + $local_state = '/var' if $prefix eq '/usr'; + } + # Kea reverts Meson's /usr/local => /var/local default. + $local_state = _expand_kea_build_path( 'var', {}, $prefix ) unless defined($local_state); + } + + my $run_state = _kea_build_option( $build_report, 'runstatedir' ); + $run_state = _expand_kea_build_path( + $run_state, + { + prefix => $prefix, + localstatedir => $local_state, + }, + $prefix, + ); + $run_state = "$local_state/run" if !defined($run_state) && defined($local_state); + return $run_state eq '/' ? '/kea' : "$run_state/kea" if defined($run_state); + } + # Kea validates Control Agent sockets against its packaged runtime - # directory, and newer packages reject /var/run/kea even when it resolves - # to /run/kea. Keep the legacy path as the unknown-state fallback for - # older Kea builds that validate before the runtime directory exists. - foreach my $dir ( @{ $self->{kea_socket_dirs} || [ '/run/kea', '/var/run/kea' ] } ) { + # directory, and newer packages reject a symlink-equivalent spelling. + # Filesystem probing is only a fallback for builds without a usable report. + foreach my $dir ( '/run/kea', '/var/run/kea' ) { return $dir if -d $dir; } return '/var/run/kea'; } -sub _kea_control_socket { - my ( $self, $socket_name ) = @_; +sub _kea_build_option { + my ( $build_report, $option ) = @_; - return $self->_kea_socket_dir() . "/$socket_name"; + return unless defined($build_report); + my $options = $build_report; + if ( $build_report =~ /^\s*(?:Configure arguments|Build Options):[ \t]*(.*?)(?=\n[ \t]*\n|^\s*C\+\+ Compiler:|\z)/ms ) { + $options = $1; + } + + my @tokens = eval { shellwords($options) }; + return if $@; + + my $value; + for ( my $index = 0; $index < @tokens; $index++ ) { + my $token = $tokens[$index]; + if ( $token =~ /^(?:--|-D)\Q$option\E=(.+)$/ ) { + $value = $1; + } elsif ( $token eq "--$option" && $index + 1 < @tokens ) { + $value = $tokens[ ++$index ]; + } + } + + return $value; +} + +sub _expand_kea_build_path { + my ( $path, $variables, $relative_base ) = @_; + + return unless defined($path); + foreach my $variable (qw/prefix localstatedir/) { + next unless defined( $variables->{$variable} ); + $path =~ s/\$\{$variable\}/$variables->{$variable}/g; + } + if ( $path !~ m{^/} && defined($relative_base) ) { + $path = $relative_base eq '/' ? "/$path" : "$relative_base/$path"; + } + $path =~ s{/$}{} if length($path) > 1; + return $path if $path =~ m{^/}; + + return; } sub _command_path { diff --git a/xCAT-server/lib/xcat/plugins/dhcp.pm b/xCAT-server/lib/xcat/plugins/dhcp.pm index f9cfcadd4..095ca03a9 100644 --- a/xCAT-server/lib/xcat/plugins/dhcp.pm +++ b/xCAT-server/lib/xcat/plugins/dhcp.pm @@ -2615,7 +2615,7 @@ sub kea_build_dhcp4_intent if (kea_control_agent_enabled()) { $intent->{'control-socket'} = { 'socket-type' => 'unix', - 'socket-name' => '/var/run/kea/kea4-ctrl-socket', + 'socket-name' => $backend->control_socket_path('kea4-ctrl-socket'), }; my $hook = $backend->host_cmds_hook_path(); if ($hook) { @@ -2670,7 +2670,7 @@ sub kea_build_dhcp6_intent if (kea_control_agent_enabled()) { $intent->{'control-socket'} = { 'socket-type' => 'unix', - 'socket-name' => '/var/run/kea/kea6-ctrl-socket', + 'socket-name' => $backend->control_socket_path('kea6-ctrl-socket'), }; my $hook = $backend->host_cmds_hook_path(); if ($hook) { From 2e56a4ca9175528853798957a769e8d07569fa12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vin=C3=ADcius=20Ferr=C3=A3o?= <2031761+viniciusferrao@users.noreply.github.com> Date: Sat, 18 Jul 2026 13:32:13 -0300 Subject: [PATCH 2/5] test(dhcp): cover Kea control socket paths (cherry picked from commit 5c3bb1d9fa0ba2181af3b91853baad3efbe8e605) --- xCAT-test/unit/dhcp_kea_plugin_intent.t | 43 +++++++++++++ xCAT-test/unit/dhcp_kea_renderer.t | 85 ++++++++++++++++++++++++- 2 files changed, 127 insertions(+), 1 deletion(-) diff --git a/xCAT-test/unit/dhcp_kea_plugin_intent.t b/xCAT-test/unit/dhcp_kea_plugin_intent.t index 5fdf97ee7..ce3a38ba5 100644 --- a/xCAT-test/unit/dhcp_kea_plugin_intent.t +++ b/xCAT-test/unit/dhcp_kea_plugin_intent.t @@ -55,6 +55,13 @@ if ( -f $source_dhcp_plugin ) { } else { require xCAT_plugin::dhcp; } +require xCAT::DHCP::Backend::Kea; + +{ + package DHCPKeaIntentBackend; + our @ISA = ('xCAT::DHCP::Backend::Kea'); + sub host_cmds_hook_path { return '/test/libdhcp_host_cmds.so'; } +} { package DHCPKeaIntentNetTable; @@ -172,6 +179,42 @@ ok(!xCAT_plugin::dhcp::dhcpd_sysconfig_uses_interface_key('opensuse-tumbleweed') is( $intent->{subnets}[0]{subnet}, '10.0.0.0/24', 'rendered subnet comes from local route' ); } +{ + no warnings 'redefine'; + local *xCAT_plugin::dhcp::kea_ipv4_routes = sub { + return ([ '10.0.0.0', 'eth0', '255.255.255.0', '' ]); + }; + local *xCAT_plugin::dhcp::kea_boot_client_classes = sub { return []; }; + local *xCAT_plugin::dhcp::kea_option_defs = sub { return []; }; + local *xCAT_plugin::dhcp::kea_global_option_data = sub { return []; }; + local *xCAT_plugin::dhcp::kea_dhcp_lease_time = sub { return 43200; }; + local *xCAT_plugin::dhcp::kea_control_agent_enabled = sub { return 1; }; + + my $backend = DHCPKeaIntentBackend->new(kea_socket_dir => '/run/kea-xcat-test'); + + local $xCAT::Table::networks = DHCPKeaIntentNetTable->new( \%network_entry ); + my $dhcp4_intent = xCAT_plugin::dhcp::kea_build_dhcp4_intent( $backend, { eth0 => 1 } ); + is( + $dhcp4_intent->{'control-socket'}{'socket-name'}, + '/run/kea-xcat-test/kea4-ctrl-socket', + 'DHCPv4 intent uses the backend-selected Control Agent socket path' + ); + + local $xCAT::Table::networks = DHCPKeaIntentNetTable->new( + { + %network_entry, + net => 'fd00::/64', + dynamicrange => undef, + } + ); + my $dhcp6_intent = xCAT_plugin::dhcp::kea_build_dhcp6_intent( $backend, { eth0 => 1 } ); + is( + $dhcp6_intent->{'control-socket'}{'socket-name'}, + '/run/kea-xcat-test/kea6-ctrl-socket', + 'DHCPv6 intent uses the backend-selected Control Agent socket path' + ); +} + { no warnings 'redefine'; local *xCAT::NetworkUtils::thishostisnot = sub { return 1; }; diff --git a/xCAT-test/unit/dhcp_kea_renderer.t b/xCAT-test/unit/dhcp_kea_renderer.t index a2e00c782..89ae04cfb 100644 --- a/xCAT-test/unit/dhcp_kea_renderer.t +++ b/xCAT-test/unit/dhcp_kea_renderer.t @@ -414,7 +414,8 @@ is( $ddns_config->{DhcpDdns}{port}, 53001, 'DDNS port is numeric' ); is( $ddns_config->{DhcpDdns}{'dns-server-timeout'}, 500, 'DDNS timeout is numeric' ); is( $ddns_config->{DhcpDdns}{'forward-ddns'}{'ddns-domains'}[0]{name}, 'cluster.example.com.', 'DDNS forward domain is rendered' ); -my $ctrl_agent_config = decode_json($backend->render_ctrl_agent_config({ 'http-port' => '8000' })); +my $ctrl_agent_backend = xCAT::DHCP::Backend::Kea->new( kea_socket_dirs => [] ); +my $ctrl_agent_config = decode_json($ctrl_agent_backend->render_ctrl_agent_config({ 'http-port' => '8000' })); is( $ctrl_agent_config->{'Control-agent'}{'http-port'}, 8000, 'Control Agent HTTP port is numeric' ); my $runtime_socket_dir = "$unit_dir/run/kea"; @@ -429,6 +430,88 @@ my $legacy_socket_backend = xCAT::DHCP::Backend::Kea->new( kea_socket_dirs => [] my $legacy_socket_config = decode_json($legacy_socket_backend->render_ctrl_agent_config({})); is( $legacy_socket_config->{'Control-agent'}{'control-sockets'}{dhcp4}{'socket-name'}, '/var/run/kea/kea4-ctrl-socket', 'Control Agent socket falls back to the legacy runtime path when no runtime directory exists' ); +my $meson_socket_backend = xCAT::DHCP::Backend::Kea->new( + kea_build_report => 'Build Options: -Dprefix=/usr -Dlocalstatedir=/var -Drunstatedir=/xcat-test-meson-run', +); +is( $meson_socket_backend->control_socket_path('kea4-ctrl-socket'), '/xcat-test-meson-run/kea/kea4-ctrl-socket', 'Meson runstatedir selects the socket path before the runtime directory exists' ); + +my $autoconf_socket_backend = xCAT::DHCP::Backend::Kea->new( + kea_build_report => q{Configure arguments: '--prefix=/usr' '--localstatedir=/var' '--runstatedir=/xcat-test-autoconf-run'}, +); +is( $autoconf_socket_backend->control_socket_path('kea4-ctrl-socket'), '/xcat-test-autoconf-run/kea/kea4-ctrl-socket', 'Autoconf runstatedir selects the socket path before the runtime directory exists' ); + +my $local_state_socket_backend = xCAT::DHCP::Backend::Kea->new( + kea_build_report => q{Configure arguments: '--prefix=/usr' '--localstatedir=/xcat-test-local-state'}, +); +is( $local_state_socket_backend->control_socket_path('kea4-ctrl-socket'), '/xcat-test-local-state/run/kea/kea4-ctrl-socket', 'localstatedir supplies the runtime path when runstatedir is not configured' ); + +my $variable_socket_backend = xCAT::DHCP::Backend::Kea->new( + kea_build_report => q{Configure arguments: + '--prefix=/usr/local' '--localstatedir=${prefix}/var' '--runstatedir=${localstatedir}/run'}, +); +is( $variable_socket_backend->control_socket_path('kea4-ctrl-socket'), '/usr/local/var/run/kea/kea4-ctrl-socket', 'Autoconf build variables resolve through the configured prefix' ); + +my $undefined_candidates_socket_backend = xCAT::DHCP::Backend::Kea->new( + kea_socket_dirs => undef, + kea_build_report => 'Build Options: -Dprefix=/usr -Drunstatedir=/xcat-test-undefined-candidates', +); +is( $undefined_candidates_socket_backend->control_socket_path('kea4-ctrl-socket'), '/xcat-test-undefined-candidates/kea/kea4-ctrl-socket', 'undefined socket candidates preserve build-report detection' ); + +my $source_default_socket_backend = xCAT::DHCP::Backend::Kea->new( + kea_build_report => 'Prefix: /usr/local', +); +is( $source_default_socket_backend->control_socket_path('kea4-ctrl-socket'), '/usr/local/var/run/kea/kea4-ctrl-socket', 'source build defaults resolve through the configured prefix' ); + +my $meson_default_socket_backend = xCAT::DHCP::Backend::Kea->new( + kea_build_report => "Meson Version: 1.10.1\nBuild Options: -Dprefix=/usr", +); +is( $meson_default_socket_backend->control_socket_path('kea4-ctrl-socket'), '/var/run/kea/kea4-ctrl-socket', 'Meson system prefix defaults local state to /var' ); + +my $meson_local_default_socket_backend = xCAT::DHCP::Backend::Kea->new( + kea_build_report => "Prefix: /usr/local\nMeson Version: 1.10.1\nBuild Options:\n\nC++ Compiler:\n", +); +is( $meson_local_default_socket_backend->control_socket_path('kea4-ctrl-socket'), '/usr/local/var/run/kea/kea4-ctrl-socket', 'Kea keeps local state prefix-relative for a Meson local prefix' ); + +my $meson_local_override_socket_backend = xCAT::DHCP::Backend::Kea->new( + kea_build_report => "Prefix: /usr/local\nMeson Version: 1.10.1\nBuild Options: -Dprefix=/usr/local -Dlocalstatedir=/srv/kea -Drunstatedir=/run/kea-custom\n", +); +is( $meson_local_override_socket_backend->control_socket_path('kea4-ctrl-socket'), '/usr/local/var/run/kea/kea4-ctrl-socket', 'Kea overrides Meson state-directory options for a /usr/local prefix' ); + +my $split_option_socket_backend = xCAT::DHCP::Backend::Kea->new( + kea_build_report => q{Configure arguments: '--prefix=/usr' '--localstatedir=/xcat-test-first' '--localstatedir' '/xcat-test-split-last'}, +); +is( $split_option_socket_backend->control_socket_path('kea4-ctrl-socket'), '/xcat-test-split-last/run/kea/kea4-ctrl-socket', 'a space-separated Autoconf option can override an earlier value' ); + +my $last_option_socket_backend = xCAT::DHCP::Backend::Kea->new( + kea_build_report => <<'BUILD_REPORT', +Configure arguments: '--prefix=/usr' '--runstatedir=/xcat-test-first' '--runstatedir=/xcat-test-last' 'CXXFLAGS=-O2 -Drunstatedir=/xcat-test-embedded-compiler' + +C++ Compiler: + CXX_ARGS: -Drunstatedir=/xcat-test-compiler-only +BUILD_REPORT +); +is( $last_option_socket_backend->control_socket_path('kea4-ctrl-socket'), '/xcat-test-last/kea/kea4-ctrl-socket', 'last build option wins without reading embedded or compiler-only flags' ); + +my $root_socket_backend = xCAT::DHCP::Backend::Kea->new( + kea_build_report => 'Build Options: -Dprefix=/ -Dlocalstatedir=/ -Drunstatedir=/', +); +is( $root_socket_backend->control_socket_path('kea4-ctrl-socket'), '/kea/kea4-ctrl-socket', 'root runstatedir joins the socket path without a duplicate separator' ); + +my $fake_kea_dhcp4 = "$unit_dir/kea-dhcp4-build-report"; +open( my $fake_kea_fh, '>', $fake_kea_dhcp4 ) or die "Unable to write fake Kea command: $!"; +print {$fake_kea_fh} "#!$^X\n"; +print {$fake_kea_fh} <<'FAKE_KEA'; +use strict; +use warnings; +exit 2 unless @ARGV == 1 && $ARGV[0] eq '-W'; +print "Build Options: -Dprefix=/usr -Drunstatedir=/xcat-test-command-run\n"; +FAKE_KEA +close($fake_kea_fh) or die "Unable to close fake Kea command: $!"; +chmod 0755, $fake_kea_dhcp4 or die "Unable to make fake Kea command executable: $!"; + +my $command_socket_backend = xCAT::DHCP::Backend::Kea->new(kea_dhcp4_command => $fake_kea_dhcp4); +is( $command_socket_backend->control_socket_path('kea4-ctrl-socket'), '/xcat-test-command-run/kea/kea4-ctrl-socket', 'socket path comes from the Kea build-report command' ); + my $socket_backend = xCAT::DHCP::Backend::Kea->new( kea_socket_dir => '/run/kea' ); my $ctrl_agent_socket_config = decode_json($socket_backend->render_ctrl_agent_config({ dhcp6 => 1, ddns => 1 })); is( $ctrl_agent_socket_config->{'Control-agent'}{'control-sockets'}{dhcp4}{'socket-name'}, '/run/kea/kea4-ctrl-socket', 'Control Agent DHCPv4 socket uses the detected Kea socket directory' ); From d96b59d2769ec019703eee6d7c592e0184584212 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vin=C3=ADcius=20Ferr=C3=A3o?= <2031761+viniciusferrao@users.noreply.github.com> Date: Sat, 18 Jul 2026 13:59:15 -0300 Subject: [PATCH 3/5] refactor(kea): centralize service account lookup (cherry picked from commit a44e9fd81963935ceb8fe7fde20c0da0c7c37de1) --- perl-xCAT/xCAT/DHCP/Backend/Kea.pm | 31 +++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/perl-xCAT/xCAT/DHCP/Backend/Kea.pm b/perl-xCAT/xCAT/DHCP/Backend/Kea.pm index ec8ea26e6..2525ae7d3 100644 --- a/perl-xCAT/xCAT/DHCP/Backend/Kea.pm +++ b/perl-xCAT/xCAT/DHCP/Backend/Kea.pm @@ -17,6 +17,7 @@ my %KEA_SERVICE_CANDIDATES = ( 'kea-dhcp-ddns' => [ 'kea-dhcp-ddns', 'kea-dhcp-ddns-server' ], 'kea-ctrl-agent' => [ 'kea-ctrl-agent' ], ); +my @KEA_ACCOUNT_CANDIDATES = ( 'kea', '_kea' ); sub new { my ( $class, %args ) = @_; @@ -57,6 +58,19 @@ sub control_socket_path { return $self->_kea_socket_dir() . "/$socket_name"; } +sub service_account { + foreach my $user (@KEA_ACCOUNT_CANDIDATES) { + my @entry = getpwnam($user); + return { + name => $entry[0], + uid => $entry[2], + gid => $entry[3], + } if @entry; + } + + return; +} + sub render_dhcp4_config { my ( $self, $intent ) = @_; @@ -485,12 +499,12 @@ sub _validate_config_with { my $prefix = ''; if ( $> == 0 ) { - my $kea_user = _kea_user(); + my $service_account = $self->service_account(); my $runuser = _command_path('runuser'); # Validate as the daemon user when possible so root does not hide # packaged Kea runtime-directory or config-readability failures. - $prefix = _shell_quote($runuser) . ' -u ' . _shell_quote($kea_user) . ' -- ' - if $kea_user && $runuser; + $prefix = _shell_quote($runuser) . ' -u ' . _shell_quote( $service_account->{name} ) . ' -- ' + if $service_account && $runuser; } my $cmd = $prefix . _shell_quote($kea) . " -t " . _shell_quote($path) . " 2>&1"; @@ -998,7 +1012,7 @@ sub _set_config_permissions { } sub _kea_group { - foreach my $group ( 'kea', '_kea' ) { + foreach my $group (@KEA_ACCOUNT_CANDIDATES) { my @entry = getgrnam($group); return ( $entry[0], $entry[2] ) if @entry; } @@ -1006,15 +1020,6 @@ sub _kea_group { return; } -sub _kea_user { - foreach my $user ( 'kea', '_kea' ) { - my @entry = getpwnam($user); - return $entry[0] if @entry; - } - - return; -} - sub _kea_service { my ( $self, $service ) = @_; From 135f14d1a311c9483cd49496cf4b4d0055fd92e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vin=C3=ADcius=20Ferr=C3=A3o?= <2031761+viniciusferrao@users.noreply.github.com> Date: Sat, 18 Jul 2026 20:28:00 -0300 Subject: [PATCH 4/5] test(kea): preserve named configuration group selection (cherry picked from commit d1555093456ab8e9ea19867a2432e07220e30358) --- xCAT-test/unit/dhcp_kea_config_group.t | 193 +++++++++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 xCAT-test/unit/dhcp_kea_config_group.t diff --git a/xCAT-test/unit/dhcp_kea_config_group.t b/xCAT-test/unit/dhcp_kea_config_group.t new file mode 100644 index 000000000..aa1f906f7 --- /dev/null +++ b/xCAT-test/unit/dhcp_kea_config_group.t @@ -0,0 +1,193 @@ +#!/usr/bin/env perl + +use strict; +use warnings; + +use FindBin; +use lib "$FindBin::Bin/../../perl-xCAT"; + +use File::Temp qw/tempfile/; +use Test::More; + +our ( %TEST_USERS, %TEST_GROUPS, %TEST_GROUP_NAMES ); +our ( @USER_LOOKUPS, @GROUP_LOOKUPS, @GROUP_ID_LOOKUPS ); + +BEGIN { + no warnings 'redefine'; + + *CORE::GLOBAL::getpwnam = sub { + my ($name) = @_; + push @USER_LOOKUPS, $name; + my $entry = $TEST_USERS{$name}; + return unless $entry; + return @$entry if wantarray; + return $entry->[0]; + }; + + *CORE::GLOBAL::getgrnam = sub { + my ($name) = @_; + push @GROUP_LOOKUPS, $name; + my $entry = $TEST_GROUPS{$name}; + return unless $entry; + return @$entry if wantarray; + return $entry->[0]; + }; + + *CORE::GLOBAL::getgrgid = sub { + my ($gid) = @_; + push @GROUP_ID_LOOKUPS, $gid; + my $name = $TEST_GROUP_NAMES{$gid}; + return unless defined $name; + return ( $name, 'x', $gid, '' ) if wantarray; + return $name; + }; +} + +use xCAT::DHCP::Backend::Kea; + +my $backend = xCAT::DHCP::Backend::Kea->new(); + +set_nss( + users => { + _kea => [ '_kea', 'x', 100, 300, '', '', '', '/var/empty', '/sbin/nologin' ], + }, + groups => { + kea => [ 'kea', 'x', 200, '' ], + _kea => [ '_kea', 'x', 400, '' ], + }, + group_names => { 300 => 'daemon-primary' }, +); + +my $service_account = selected_service_account($backend); +is_deeply( + $service_account, + { name => '_kea', uid => 100, gid => 300 }, + 'service account selection preserves the fallback daemon identity' +); + +reset_lookups(); +my ( $group, $gid ) = selected_config_group($backend); +is( $group, 'kea', 'the preferred named Kea group owns configuration files' ); +is( $gid, 200, 'configuration ownership is independent of the daemon primary GID' ); +isnt( $gid, $service_account->{gid}, 'configuration ownership does not inherit the daemon primary GID' ); +is_deeply( \@GROUP_LOOKUPS, ['kea'], 'named configuration groups are checked in preference order' ); +is_deeply( \@USER_LOOKUPS, [], 'configuration-group selection does not inspect service users' ); +is_deeply( \@GROUP_ID_LOOKUPS, [], 'configuration-group selection does not resolve a primary GID' ); + +set_nss( + groups => { + _kea => [ '_kea', 'x', 400, '' ], + }, +); +( $group, $gid ) = selected_config_group($backend); +is( $group, '_kea', 'the fallback named Kea group is selected when needed' ); +is( $gid, 400, 'the fallback named Kea group supplies configuration ownership' ); +is_deeply( \@GROUP_LOOKUPS, [ 'kea', '_kea' ], 'both named groups are checked before falling back' ); +is_deeply( \@USER_LOOKUPS, [], 'named-group fallback remains independent of service users' ); +is_deeply( \@GROUP_ID_LOOKUPS, [], 'named-group fallback remains independent of primary GIDs' ); + +set_nss( + users => { + kea => [ 'kea', 'x', 101, 300, '', '', '', '/var/empty', '/sbin/nologin' ], + }, + group_names => { 300 => 'daemon-primary' }, +); +( $group, $gid ) = selected_config_group($backend); +ok( !defined($group), 'no configuration group is selected when named Kea groups are absent' ); +ok( !defined($gid), 'no configuration GID is inherited from the service account' ); +is_deeply( \@GROUP_LOOKUPS, [ 'kea', '_kea' ], 'all named groups are checked before the public-mode fallback' ); +is_deeply( \@USER_LOOKUPS, [], 'the public-mode fallback does not inspect service users' ); +is_deeply( \@GROUP_ID_LOOKUPS, [], 'the public-mode fallback does not resolve a primary GID' ); + +SKIP: { + skip 'root privileges are required to verify file ownership and modes', 5 if $> != 0; + + set_nss( + users => { + _kea => [ '_kea', 'x', 100, 300, '', '', '', '/var/empty', '/sbin/nologin' ], + }, + groups => { + kea => [ 'kea', 'x', 200, '' ], + }, + group_names => { 300 => 'daemon-primary' }, + ); + my ( $named_fh, $named_path ) = tempfile(UNLINK => 1); + close($named_fh) or die "Unable to close $named_path: $!"; + my $named_result = apply_config_permissions( $backend, $named_path ); + ok( !$named_result->{error}, 'configuration permissions are applied with a named Kea group' ) + or diag $named_result->{error}; + is( ( stat $named_path )[5], 200, 'configuration file uses the named Kea group GID' ); + is( ( stat $named_path )[2] & 07777, 0640, 'configuration file is group-readable with a named Kea group' ); + + set_nss( + users => { + kea => [ 'kea', 'x', 101, 300, '', '', '', '/var/empty', '/sbin/nologin' ], + }, + group_names => { 300 => 'daemon-primary' }, + ); + my ( $public_fh, $public_path ) = tempfile(UNLINK => 1); + close($public_fh) or die "Unable to close $public_path: $!"; + my $public_result = apply_config_permissions( $backend, $public_path ); + ok( !$public_result->{error}, 'configuration permissions fall back without a named Kea group' ) + or diag $public_result->{error}; + is( ( stat $public_path )[2] & 07777, 0644, 'missing named Kea groups preserve the public-read fallback' ); +} + +done_testing(); + +sub set_nss { + my (%args) = @_; + + %TEST_USERS = %{ $args{users} || {} }; + %TEST_GROUPS = %{ $args{groups} || {} }; + %TEST_GROUP_NAMES = %{ $args{group_names} || {} }; + reset_lookups(); + + return; +} + +sub reset_lookups { + @USER_LOOKUPS = (); + @GROUP_LOOKUPS = (); + @GROUP_ID_LOOKUPS = (); + + return; +} + +sub selected_service_account { + my ($kea_backend) = @_; + + if ( my $resolver = $kea_backend->can('service_account') ) { + return $resolver->($kea_backend); + } + + my $legacy_resolver = xCAT::DHCP::Backend::Kea->can('_kea_user'); + my $name = $legacy_resolver ? $legacy_resolver->() : undef; + return unless defined $name; + my @entry = getpwnam($name); + return { + name => $entry[0], + uid => $entry[2], + gid => $entry[3], + }; +} + +sub selected_config_group { + my ($kea_backend) = @_; + + if ( my $resolver = xCAT::DHCP::Backend::Kea->can('_kea_group') ) { + return $resolver->(); + } + + my $resolver = $kea_backend->can('_service_group'); + return $resolver ? $resolver->($kea_backend) : undef; +} + +sub apply_config_permissions { + my ( $kea_backend, $path ) = @_; + + my $permissions = xCAT::DHCP::Backend::Kea->can('_set_config_permissions'); + return { error => 'Kea configuration-permission helper is unavailable' } unless $permissions; + return $permissions->($path) if xCAT::DHCP::Backend::Kea->can('_kea_group'); + return $permissions->( $kea_backend, $path ); +} From a8ddda0efaec970fd4595d8029e4caf327a92502 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vin=C3=ADcius=20Ferr=C3=A3o?= <2031761+viniciusferrao@users.noreply.github.com> Date: Sat, 18 Jul 2026 14:24:30 -0300 Subject: [PATCH 5/5] test(kea): harden live Control Agent smoke (cherry picked from commit 1034752080e3d5cff75cf2cf6749774b99bafe0b) --- .../dhcp_kea_control_agent_smoke.t | 377 +++++++++++++----- 1 file changed, 275 insertions(+), 102 deletions(-) diff --git a/xCAT-test/integration/dhcp_kea_control_agent_smoke.t b/xCAT-test/integration/dhcp_kea_control_agent_smoke.t index f7061a6e2..5f9473349 100644 --- a/xCAT-test/integration/dhcp_kea_control_agent_smoke.t +++ b/xCAT-test/integration/dhcp_kea_control_agent_smoke.t @@ -6,7 +6,10 @@ use lib "$FindBin::Bin/../../perl-xCAT"; use File::Path qw/make_path/; use File::Temp qw/tempdir/; +use IO::Socket::INET; +use POSIX qw/WNOHANG _exit setgid setuid/; use Test::More; +use Time::HiRes qw/sleep time/; use xCAT::DHCP::Backend::Kea; @@ -21,99 +24,172 @@ plan skip_all => 'kea-dhcp4 and kea-ctrl-agent are required' my $backend = xCAT::DHCP::Backend::Kea->new(); my $hook = $backend->host_cmds_hook_path(); -plan skip_all => 'Kea host-commands hook is required' unless $hook; +my $service_account = $backend->service_account(); +plan skip_all => 'Kea service account is required' + unless ref($service_account) eq 'HASH' + && defined( $service_account->{name} ) + && defined( $service_account->{uid} ) + && defined( $service_account->{gid} ); my $tmp = tempdir(CLEANUP => 1); -make_path('/var/run/kea'); -make_path('/var/lib/kea'); +chmod 0755, $tmp or die "Unable to make $tmp daemon-traversable: $!"; -my $socket = "/var/run/kea/kea4-xcat-smoke-$$.sock"; -my $lease_file = "/var/lib/kea/kea-leases4-xcat-smoke-$$.csv"; -unlink $socket, $lease_file; +my $runtime_dir = "$tmp/run"; +my $data_dir = "$tmp/data"; +make_path( $runtime_dir, $data_dir ); +chown( $service_account->{uid}, $service_account->{gid}, $runtime_dir, $data_dir ) == 2 + or die "Unable to set Kea fixture directory ownership: $!"; +chmod( 0750, $runtime_dir, $data_dir ) == 2 + or die "Unable to set Kea fixture directory permissions: $!"; + +local $ENV{KEA_CONTROL_SOCKET_DIR} = $runtime_dir; +local $ENV{KEA_DHCP_DATA_DIR} = $data_dir; +local $ENV{KEA_PIDFILE_DIR} = $runtime_dir; +local $ENV{KEA_LOCKFILE_DIR} = $runtime_dir; + +$backend = xCAT::DHCP::Backend::Kea->new(kea_socket_dir => $runtime_dir); + +my $port_guard = IO::Socket::INET->new( + LocalAddr => '127.0.0.1', + LocalPort => 0, + Listen => 1, + Proto => 'tcp', +) or die "Unable to reserve a Control Agent port: $!"; +my $control_agent_port = $port_guard->sockport(); + +my $socket = $backend->control_socket_path('kea4-ctrl-socket'); +my $lease_file = "$data_dir/kea-leases4.csv"; my $dhcp_config = "$tmp/kea-dhcp4.conf"; my $ctrl_config = "$tmp/kea-ctrl-agent.conf"; -write_file( - $dhcp_config, - $backend->render_dhcp4_config( +my $dhcp_settings = { + interfaces => [], + 'lease-database' => { + type => 'memfile', + name => $lease_file, + }, + 'control-socket' => { + 'socket-type' => 'unix', + 'socket-name' => $socket, + }, + subnets => [ { - interfaces => ['lo'], - 'lease-database' => { - type => 'memfile', - name => $lease_file, - }, - 'control-socket' => { - 'socket-type' => 'unix', - 'socket-name' => $socket, - }, - 'hooks-libraries' => [ { library => $hook } ], - subnets => [ - { - id => 1, - subnet => '127.0.0.0/8', - pools => [], - }, - ], - } - ) -); -write_file( - $ctrl_config, - $backend->render_ctrl_agent_config( - { - 'http-port' => 18000, - 'dhcp4-socket' => $socket, - } - ) -); + id => 1, + subnet => '127.0.0.0/8', + pools => [], + }, + ], +}; +$dhcp_settings->{'hooks-libraries'} = [ { library => $hook } ] if $hook; -my $dhcp_validation = $backend->validate_dhcp4_config($dhcp_config); -ok( !$dhcp_validation->{error}, 'live smoke DHCPv4 config validates' ) - or diag $dhcp_validation->{error}; -my $ctrl_validation = $backend->validate_ctrl_agent_config($ctrl_config); -ok( !$ctrl_validation->{error}, 'live smoke Control Agent config validates' ) - or diag $ctrl_validation->{error}; +my $dhcp_write = $backend->write_dhcp4_config( + $dhcp_settings, + path => $dhcp_config, +); +ok( !$dhcp_write->{error}, 'live smoke DHCPv4 config validates and writes' ) + or diag $dhcp_write->{error}; -my @pids; -END { - kill 'TERM', @pids if @pids; - unlink grep { defined($_) && $_ ne '' } ( $socket, $lease_file ); +my $ctrl_write = $backend->write_ctrl_agent_config( + { + 'http-port' => $control_agent_port, + }, + path => $ctrl_config, +); +ok( !$ctrl_write->{error}, 'live smoke Control Agent config validates and writes' ) + or diag $ctrl_write->{error}; + +unless ( !$dhcp_write->{error} && !$ctrl_write->{error} ) { + done_testing(); + exit 1; } -push @pids, start_daemon($kea_dhcp4, '-c', $dhcp_config, '-d', "$tmp/kea-dhcp4.log"); -ok( wait_for_process($pids[-1]), 'kea-dhcp4 stays running for smoke test' ); -push @pids, start_daemon($kea_ctrl, '-c', $ctrl_config, '-d', "$tmp/kea-ctrl-agent.log"); -ok( wait_for_process($pids[-1]), 'kea-ctrl-agent stays running for smoke test' ); +my %children; +END { stop_daemons(\%children); } -my $live_backend = xCAT::DHCP::Backend::Kea->new(control_agent_port => 18000); -my $add = $live_backend->live_upsert_reservations( - [ - { - 'subnet-id' => 1, - 'hw-address' => '52:54:00:12:34:56', - 'ip-address' => '127.0.0.50', - hostname => 'node-smoke', - }, - ], - service => ['dhcp4'] -); -ok( !$add->{error}, 'reservation-add succeeds through Kea Control Agent' ) - or diag $add->{error}; +my $dhcp_log = "$tmp/kea-dhcp4.log"; +my $dhcp_pid = start_daemon( $service_account, $kea_dhcp4, $dhcp_log, '-c', $dhcp_config, '-d' ); +$children{$dhcp_pid} = 1; +my $dhcp_ready = wait_for_socket( $dhcp_pid, $socket, \%children ); +ok( $dhcp_ready, 'kea-dhcp4 creates its Control Agent socket' ); +diag_file($dhcp_log) unless $dhcp_ready; +unless ($dhcp_ready) { + done_testing(); + exit 1; +} +is( ( stat $socket )[4], $service_account->{uid}, 'kea-dhcp4 socket belongs to the service account' ); -my $delete = $live_backend->live_delete_reservations( - [ - { - 'subnet-id' => 1, - 'hw-address' => '52:54:00:12:34:56', - 'ip-address' => '127.0.0.50', - hostname => 'node-smoke', - }, - ], - service => ['dhcp4'] -); -ok( !$delete->{error}, 'reservation-del succeeds through Kea Control Agent' ) - or diag $delete->{error}; +my $lease_ready = wait_for_file( $dhcp_pid, $lease_file, \%children ); +ok( $lease_ready, 'kea-dhcp4 creates its lease file' ); +is( ( stat $lease_file )[4], $service_account->{uid}, 'kea-dhcp4 lease file belongs to the service account' ) + if $lease_ready; +close($port_guard) or die "Unable to release the reserved Control Agent port: $!"; + +my $ctrl_log = "$tmp/kea-ctrl-agent.log"; +my $ctrl_pid = start_daemon( $service_account, $kea_ctrl, $ctrl_log, '-c', $ctrl_config, '-d' ); +$children{$ctrl_pid} = 1; + +my $live_backend = xCAT::DHCP::Backend::Kea->new(control_agent_port => $control_agent_port); +my ( $ctrl_ready, $readiness ) = wait_for_control_agent( $live_backend, $ctrl_pid, \%children ); +ok( $ctrl_ready, 'Kea Control Agent forwards commands to kea-dhcp4' ); +diag( $readiness->{error} || $readiness->{text} || 'Control Agent returned no response' ) unless $ctrl_ready; +diag_file($ctrl_log) unless $ctrl_ready; +unless ($ctrl_ready) { + done_testing(); + exit 1; +} + +SKIP: { + skip 'Kea host-commands hook is unavailable', 4 unless $hook; + + my $reservation = { + 'subnet-id' => 1, + 'hw-address' => '52:54:00:12:34:56', + 'ip-address' => '127.0.0.50', + hostname => 'node-smoke', + }; + my $add = $live_backend->live_upsert_reservations( + [$reservation], + service => ['dhcp4'] + ); + ok( !$add->{error}, 'reservation-add succeeds through Kea Control Agent' ) + or diag $add->{error}; + + my $lookup = { + 'subnet-id' => $reservation->{'subnet-id'}, + 'identifier-type' => 'hw-address', + identifier => $reservation->{'hw-address'}, + }; + my $found = $live_backend->control_agent_command( 'reservation-get', $lookup, service => ['dhcp4'] ); + my $stored = response_arguments($found); + ok( + ref($stored) eq 'HASH' + && defined( $stored->{'ip-address'} ) + && $stored->{'ip-address'} eq $reservation->{'ip-address'}, + 'reservation-get confirms the added reservation is stored' + ) or diag( $found->{error} || $found->{text} || 'reservation-get returned no reservation' ); + + my $delete = $live_backend->live_delete_reservations( + [$reservation], + service => ['dhcp4'] + ); + ok( !$delete->{error}, 'reservation-del succeeds through Kea Control Agent' ) + or diag $delete->{error}; + + my $missing = $live_backend->control_agent_command( 'reservation-get', $lookup, service => ['dhcp4'] ); + my $missing_arguments = response_arguments($missing); + my $expected_not_found = !$missing->{error} + || ( defined( $missing->{result} ) && $missing->{result} == 3 ) + || ( defined( $missing->{text} ) && $missing->{text} =~ /not\s+found/i ); + ok( + defined( $missing->{response} ) + && $expected_not_found + && ( ref($missing_arguments) ne 'HASH' || !keys %$missing_arguments ), + 'reservation-get confirms the deleted reservation is absent' + ) or diag( $missing->{error} || $missing->{text} || 'reservation-get returned an unexpected response' ); +} + +stop_daemons(\%children); done_testing(); sub command_path { @@ -131,38 +207,135 @@ sub command_path { return; } -sub write_file { - my ( $path, $content ) = @_; - - open(my $fh, '>', $path) or die "Unable to write $path: $!"; - print $fh $content; - close($fh) or die "Unable to close $path: $!"; - - return 1; -} - sub start_daemon { - my ( $command, @args ) = @_; - my $log = pop @args; + my ( $account, $command, $log, @args ) = @_; my $pid = fork(); die "Unable to fork $command: $!" unless defined $pid; if ($pid == 0) { - open(STDOUT, '>', $log) or die "Unable to write $log: $!"; - open(STDERR, '>&', \*STDOUT) or die "Unable to redirect stderr: $!"; - exec $command, @args; - die "Unable to exec $command: $!"; + open(STDOUT, '>', $log) or child_exit("Unable to write $log: $!"); + open(STDERR, '>&', \*STDOUT) or child_exit("Unable to redirect stderr: $!"); + $) = "$account->{gid} $account->{gid}"; + defined( setgid( $account->{gid} ) ) + or child_exit("Unable to set group identity to $account->{gid}: $!"); + my @group_ids = split /\s+/, $); + $( == $account->{gid} && @group_ids && !grep { $_ != $account->{gid} } @group_ids + or child_exit("Kea child did not assume group identity $account->{gid}"); + defined( setuid( $account->{uid} ) ) + or child_exit("Unable to set user identity to $account->{uid}: $!"); + $> == $account->{uid} && $< == $account->{uid} + or child_exit("Kea child did not assume user identity $account->{uid}"); + { + no warnings 'exec'; + exec { $command } $command, @args; + child_exit("Unable to exec $command: $!"); + } } return $pid; } -sub wait_for_process { - my ($pid) = @_; +sub child_exit { + my ($message) = @_; - for (1 .. 10) { - sleep 1; - return 0 unless kill 0, $pid; - return 1 if $_ >= 2; + warn "$message\n"; + _exit(127); +} + +sub wait_for_socket { + my ( $pid, $socket_path, $children ) = @_; + + for (1 .. 100) { + return 0 unless process_running( $pid, $children ); + return 1 if -S $socket_path; + sleep 0.1; } - return kill 0, $pid; + return 0; +} + +sub wait_for_file { + my ( $pid, $path, $children ) = @_; + + for (1 .. 100) { + return 0 unless process_running( $pid, $children ); + return 1 if -f $path; + sleep 0.1; + } + + return 0; +} + +sub wait_for_control_agent { + my ( $backend, $pid, $children ) = @_; + + my $last_result = {}; + my $deadline = time + 10; + while ( time < $deadline ) { + return ( 0, $last_result ) unless process_running( $pid, $children ); + $last_result = $backend->control_agent_command( + 'list-commands', + {}, + service => ['dhcp4'], + timeout => 1, + ); + return ( 1, $last_result ) unless $last_result->{error}; + my $remaining = $deadline - time; + sleep( $remaining < 0.1 ? $remaining : 0.1 ) if $remaining > 0; + } + + return ( 0, $last_result ); +} + +sub process_running { + my ( $pid, $children ) = @_; + + my $waited = waitpid( $pid, WNOHANG ); + return 1 if $waited == 0; + + delete $children->{$pid}; + return 0; +} + +sub stop_daemons { + my ($children) = @_; + + my @pids = keys %$children; + kill 'TERM', @pids if @pids; + foreach my $pid (@pids) { + for (1 .. 50) { + last unless process_running( $pid, $children ); + sleep 0.1; + } + next unless exists $children->{$pid}; + + kill 'KILL', $pid; + waitpid( $pid, 0 ); + delete $children->{$pid}; + } + + return; +} + +sub response_arguments { + my ($result) = @_; + + return unless ref($result) eq 'HASH'; + my $response = $result->{response}; + my $item = ref($response) eq 'ARRAY' ? $response->[0] : $response; + return unless ref($item) eq 'HASH'; + return $item->{arguments} if ref( $item->{arguments} ) eq 'HASH'; + + return; +} + +sub diag_file { + my ($path) = @_; + + return unless -e $path; + open( my $fh, '<', $path ) or return; + local $/; + my $content = <$fh>; + close($fh) or diag("Unable to close $path: $!"); + diag($content) if defined($content) && $content ne ''; + + return; }