diff --git a/docs/source/advanced/restapi/restapi_resource/restapi_reference.rst b/docs/source/advanced/restapi/restapi_resource/restapi_reference.rst index e5badb988..e8ba3ce0f 100644 --- a/docs/source/advanced/restapi/restapi_resource/restapi_reference.rst +++ b/docs/source/advanced/restapi/restapi_resource/restapi_reference.rst @@ -1871,7 +1871,7 @@ Initialize the dns service. :: [URI:/services/dhcp] - The dhcp service resource. ------------------------------------------------- -POST - Create the dhcpd.conf for all the networks which are defined in the xCAT Management Node. +POST - Create the DHCP server configuration for all the networks which are defined in the xCAT Management Node. ```````````````````````````````````````````````````````````````````````````````````````````````` Refer to the man page: :doc:`makedhcp ` @@ -1882,7 +1882,7 @@ Refer to the man page: :doc:`makedhcp `` + +Kea validation: + +``kea-dhcp4 -t `` + +``kea-dhcp6 -t `` + +Invalid configuration must leave the running service untouched and return a +clear error. + +Service Management +------------------ + +Service control must be backend-aware. Do not add Kea service names blindly to +the generic ``dhcp`` service map. + +ISC services: + +* ``dhcpd`` +* optional ``dhcpd6`` + +Kea services: + +* ``kea-dhcp4`` +* optional ``kea-dhcp6`` +* optional ``kea-ctrl-agent`` +* optional ``kea-dhcp-ddns`` + +Control Agent must be running before REST operations are attempted. D2 should +only be managed when Kea DDNS support is configured. + +Boot Policy +----------- + +Boot policy is the riskiest migration area. Existing ISC code uses nested +conditionals and provider-specific statements. Kea uses client classes, test +expressions, and JSON option data. + +Do not translate ISC strings directly to Kea strings. Instead, represent boot +behavior once as normalized rules, then render them per backend. + +Backends render the same intent as: + +* ISC: ``if option ...`` blocks, ``filename``, ``next-server``, and custom + option statements. +* Kea: ``client-classes``, test expressions, ``boot-file-name``, + ``next-server``, and ``option-data``. + +Boot coverage must include: + +* x86 BIOS +* x86_64 UEFI +* ARM64 +* OpenPOWER/OPAL +* ONIE +* Cumulus ZTP +* petitboot +* xNBA +* iSCSI boot options + +Host Reservations +----------------- + +Baseline Kea behavior should be deterministic and not depend on optional hooks: + +* render xCAT-owned host reservations into Kea JSON +* validate generated configuration +* reload Kea + +Optimized behavior can use Kea Control Agent plus host-commands when available. +This requires verifying that the target distribution packages include the host +commands hook library, such as ``libdhcp_host_cmds.so``. Do not assume this +library is present in EL10 or Ubuntu 24.04 without testing the actual packages. + +If host-commands are unavailable, the JSON render and reload path must still +work. + +DDNS and D2 +----------- + +ISC inline DDNS configuration does not map directly to Kea. Kea uses the +separate DHCP-DDNS daemon. + +Kea DDNS support uses D2 and should stay separate from the DHCP server config: + +* generate ``kea-dhcp-ddns.conf`` +* use the existing ``xcat_key`` material from ``/etc/xcat/ddns.key`` or the + ``passwd`` table +* render the DHCP server's D2 connection block separately from the global DDNS + behavior flags +* start D2 before the DHCP service when DDNS is enabled + +Basic Kea DHCP and PXE support should not depend on DDNS unless a target +deployment explicitly enables ``site.dnshandler=ddns``. + +Packaging +--------- + +Packaging must keep ISC dependencies for platforms using ISC and add Kea +dependencies only for platforms using Kea. + +Known areas: + +* ``xCAT.spec`` and ``xCATsn.spec`` currently depend on ``/usr/sbin/dhcpd``. +* EL10 and Ubuntu 24.04 packaging should depend on the correct Kea server + packages. +* ``dhclient`` and ``dhcp-client`` are separate client-side genesis/netboot + issues and should not be conflated with the server backend. + +Tools, Probes, UI, and Docs +--------------------------- + +Areas that currently assume ISC DHCP must become backend-aware: + +* ``xCAT-server/share/xcat/tools/dhcpop`` +* DHCP monitoring in xCAT RMC resources +* ``xCAT-probe`` checks for ``dhcpd``, ``dhcpd.conf``, and ``dhcpd.leases`` +* UI paths that run ``service dhcpd restart`` +* administrator and developer documentation + +Testing Strategy +---------------- + +Unit tests: + +* normalized DHCP intent creation +* ISC renderer regression coverage +* Kea JSON renderer coverage +* host reservation formatting +* subnet and pool mapping +* backend selection and override behavior + +Configuration validation tests: + +* ``dhcpd -t`` for ISC output +* ``kea-dhcp4 -t`` and ``kea-dhcp6 -t`` for Kea DHCP output +* ``kea-dhcp-ddns -t`` for D2 output +* ``kea-ctrl-agent -t`` for Control Agent output + +Backend selection tests: + +* ``auto`` uses ISC on EL9 +* ``auto`` uses Kea on EL10 +* ``auto`` uses Kea on Ubuntu 24.04 +* forced ``kea`` works on EL9 when Kea packages are installed +* forced unavailable backend fails clearly + +Integration matrix: + +* EL9 plus ISC +* EL9 plus forced Kea +* EL10 plus Kea +* Ubuntu 24.04 plus Kea + +Semantic parity tests: + +* compare normalized DHCP intent, not raw ISC and Kea configuration text +* verify subnets, pools, reservations, routers, DNS, NTP, log servers, lease + times, client classes, and boot rules + +Functional smoke tests: + +* ``makedhcp -n`` generates valid configuration +* backend services start successfully +* ``makedhcp `` adds a reservation +* ``makedhcp -d `` removes a reservation +* ``makedhcp -q `` returns expected data +* ``XCAT_KEA_LIVE_SMOKE=1`` validates live Control Agent host-commands when + Kea and the host-commands hook are installed +* DHCP offers contain expected boot options +* real PXE boot behavior is validated for each supported architecture + +Test Infrastructure +------------------- + +Existing container-based EL8, EL9, and EL10 tests should be extended for backend +coverage. The libvirt/KVM infrastructure on ``rome01.local.versatushpc.com.br`` +can be used for network and PXE smoke tests that are difficult to validate in +ordinary containers. + +Open test infrastructure details to confirm: + +* SSH access method and user for ``rome01.local.versatushpc.com.br`` +* available base images for EL9, EL10, and Ubuntu 24.04 +* libvirt network names and whether isolated DHCP test networks are already + available +* whether nested or privileged test guests can run DHCP client and PXE tests +* cleanup expectations for temporary VMs, networks, and storage volumes + +Implementation Order +-------------------- + +1. Add backend selection model and interface. +2. Extract ISC backend with minimal behavior change. +3. Add ISC regression tests. +4. Add normalized DHCP intent and boot policy structures. +5. Add Kea static JSON renderer. +6. Add config validation. +7. Add backend-aware service handling. +8. Add Kea boot class rendering. +9. Add baseline Kea reservation render and reload path. +10. Verify host-commands packaging and add Control Agent optimization if + available. +11. Add DDNS/D2 support as a separate phase. +12. Update packaging. +13. Update tools, probes, UI, and documentation. +14. Expand CI and KVM smoke tests. + +Guiding Rule +------------ + +``makedhcp`` remains the stable xCAT interface. ISC remains the default backend +where it works. Kea is added as a backend for platforms that need it, with shared +DHCP intent and backend-specific rendering and control. diff --git a/docs/source/guides/admin-guides/references/man8/makedhcp.8.rst b/docs/source/guides/admin-guides/references/man8/makedhcp.8.rst index da215317c..877ff828f 100644 --- a/docs/source/guides/admin-guides/references/man8/makedhcp.8.rst +++ b/docs/source/guides/admin-guides/references/man8/makedhcp.8.rst @@ -41,6 +41,8 @@ DESCRIPTION The \ **makedhcp**\ command creates and updates the DHCP configuration on the management node and service nodes. The \ **makedhcp**\ command is supported for both Linux and AIX clusters. +On Linux, the DHCP implementation is selected by the ``site.dhcpbackend`` attribute. +The ``auto`` setting keeps ISC DHCP on platforms where it is still available and uses Kea DHCP on platforms such as EL10 and Ubuntu 24.04. 1. @@ -65,8 +67,8 @@ The \ **makedhcp**\ command is supported for both Linux and AIX clusters. 4. - Then run \ **makedhcp**\ with a noderange or the \ **-a**\ option. This will inject into dhcpd configuration data pertinent to the specified nodes. - On linux, the configuration information immediately takes effect without a restart of DHCP. + Then run \ **makedhcp**\ with a noderange or the \ **-a**\ option. This will inject DHCP configuration data pertinent to the specified nodes. + With ISC DHCP, Linux node entries are updated through OMAPI. With Kea DHCP, node reservations are rendered into the Kea JSON configuration, validated, and the Kea service is restarted. @@ -85,7 +87,8 @@ OPTIONS (Which networks dhcpd should listen on can be controlled by the dhcpinterfaces attribute in the site(5)|site.5 table.) The \ **makedhcp**\ command will automatically restart the dhcp daemon after this operation. This option will replace any existing configuration file (making a backup of it first). - For Linux systems the file will include network entries as well as certain general parameters such as a dynamic range and omapi configuration. + For Linux systems using ISC DHCP, the file will include network entries as well as certain general parameters such as a dynamic range and omapi configuration. + For Linux systems using Kea DHCP, the generated files are ``/etc/kea/kea-dhcp4.conf``, ``/etc/kea/kea-dhcp6.conf`` when IPv6 networks are configured, and ``/etc/kea/kea-dhcp-ddns.conf`` when DDNS is enabled. For AIX systems the file will include network entries. On AIX systems, if there are any non-xCAT entries in the existing configuration file they will be preserved and added to the end of the new configuration file. @@ -108,6 +111,7 @@ OPTIONS \ **-s**\ \ *statements*\ For the input noderange, the argument will be interpreted like dhcp configuration file text. + This option is only supported by the ISC DHCP backend. @@ -221,6 +225,12 @@ DHCP configuration files: [RH] /etc/dhcp/dhcpd.conf +[Kea] /etc/kea/kea-dhcp4.conf + +[Kea IPv6] /etc/kea/kea-dhcp6.conf + +[Kea DDNS] /etc/kea/kea-dhcp-ddns.conf + ******** SEE ALSO @@ -228,4 +238,3 @@ SEE ALSO noderange(3)|noderange.3 - diff --git a/perl-xCAT/xCAT/DHCP/Backend.pm b/perl-xCAT/xCAT/DHCP/Backend.pm new file mode 100644 index 000000000..1a3fcd42a --- /dev/null +++ b/perl-xCAT/xCAT/DHCP/Backend.pm @@ -0,0 +1,158 @@ +package xCAT::DHCP::Backend; + +use strict; +use warnings; + +my %valid_backend = map { $_ => 1 } qw(auto isc kea); + +sub normalize { + my ( $class, $backend ) = @_; + + $backend = 'auto' unless defined($backend) && $backend ne ''; + $backend =~ s/^\s+|\s+$//g; + $backend = lc($backend); + + return $backend if $valid_backend{$backend}; + return; +} + +sub select { + my ( $class, %args ) = @_; + + my $requested = exists $args{requested} ? $args{requested} : $class->_site_backend(); + my $normalized = $class->normalize($requested); + + unless ($normalized) { + return { + error => "Invalid site.dhcpbackend value '$requested'. Valid values are auto, isc, and kea.", + }; + } + + my $selected = $normalized eq 'auto' ? $class->default_backend(%args) : $normalized; + + if ( $args{check_available} && !$class->available( $selected, %args ) ) { + return { + requested => $normalized, + name => $selected, + error => "The selected DHCP backend '$selected' is not available on this system.", + }; + } + + return { + requested => $normalized, + name => $selected, + }; +} + +sub default_backend { + my ( $class, %args ) = @_; + + my $platform = exists $args{platform} ? $args{platform} : $class->_osver('platform'); + return 'kea' if defined($platform) && $platform =~ /^el10\b/i; + + my $os = exists $args{os} ? $args{os} : $class->_osver(); + return 'kea' if defined($os) && $os =~ /^(?:rhel|rhels|rocky|alma|centos|ol)10(?:\D|$)/i; + + my $os_name = exists $args{os_name} ? $args{os_name} : $class->_osver('os'); + my $version = exists $args{version} ? $args{version} : $class->_osver('version'); + if ( defined($os_name) && $os_name =~ /^ubuntu$/i && _version_at_least( $version, '24.04' ) ) { + return 'kea'; + } + + return 'isc'; +} + +sub available { + my ( $class, $backend, %args ) = @_; + + if ( exists $args{available} && ref( $args{available} ) eq 'HASH' && exists $args{available}{$backend} ) { + return $args{available}{$backend} ? 1 : 0; + } + + if ( $backend eq 'isc' ) { + return _command_exists('dhcpd'); + } elsif ( $backend eq 'kea' ) { + return _command_exists('kea-dhcp4'); + } + + return 0; +} + +sub backend_class { + my ( $class, $backend ) = @_; + + return 'xCAT::DHCP::Backend::ISC' if $backend eq 'isc'; + return 'xCAT::DHCP::Backend::Kea' if $backend eq 'kea'; + return; +} + +sub new_backend { + my ( $class, %args ) = @_; + + my $selection = $class->select(%args); + return $selection if $selection->{error}; + + my $backend_class = $class->backend_class( $selection->{name} ); + eval "require $backend_class"; + if ($@) { + return { + %$selection, + error => "Unable to load DHCP backend '$selection->{name}': $@", + }; + } + + return $backend_class->new( selection => $selection ); +} + +sub _site_backend { + eval { + require xCAT::TableUtils; + return xCAT::TableUtils->get_site_attribute('dhcpbackend', 'auto'); + } || 'auto'; +} + +sub _osver { + my ( $class, $type ) = @_; + + eval { + require xCAT::Utils; + return defined($type) ? xCAT::Utils->osver($type) : xCAT::Utils->osver(); + } || 'unknown'; +} + +sub _command_exists { + my ($command) = @_; + + foreach my $dir ( split /:/, $ENV{PATH} || '' ) { + next unless $dir; + my $path = "$dir/$command"; + return 1 if -x $path; + } + + foreach my $path ( "/usr/sbin/$command", "/usr/bin/$command", "/sbin/$command", "/bin/$command" ) { + return 1 if -x $path; + } + + return 0; +} + +sub _version_at_least { + my ( $version, $minimum ) = @_; + + return 0 unless defined($version) && $version =~ /^\d+(?:\.\d+)*/; + + my @version_parts = split /\./, $version; + my @minimum_parts = split /\./, $minimum; + my $max = @version_parts > @minimum_parts ? @version_parts : @minimum_parts; + + for my $idx ( 0 .. $max - 1 ) { + my $left = $version_parts[$idx] || 0; + my $right = $minimum_parts[$idx] || 0; + return 1 if $left > $right; + return 0 if $left < $right; + } + + return 1; +} + +1; diff --git a/perl-xCAT/xCAT/DHCP/Backend/ISC.pm b/perl-xCAT/xCAT/DHCP/Backend/ISC.pm new file mode 100644 index 000000000..13f008c49 --- /dev/null +++ b/perl-xCAT/xCAT/DHCP/Backend/ISC.pm @@ -0,0 +1,19 @@ +package xCAT::DHCP::Backend::ISC; + +use strict; +use warnings; + +sub new { + my ( $class, %args ) = @_; + return bless \%args, $class; +} + +sub name { + return 'isc'; +} + +sub implemented { + return 1; +} + +1; diff --git a/perl-xCAT/xCAT/DHCP/Backend/Kea.pm b/perl-xCAT/xCAT/DHCP/Backend/Kea.pm new file mode 100644 index 000000000..ea3010af7 --- /dev/null +++ b/perl-xCAT/xCAT/DHCP/Backend/Kea.pm @@ -0,0 +1,775 @@ +package xCAT::DHCP::Backend::Kea; + +use strict; +use warnings; + +use JSON; +use File::Basename; +use File::Path qw/make_path/; +use Math::BigInt; +use xCAT::DHCP::Range; +use xCAT::NetworkUtils; + +sub new { + my ( $class, %args ) = @_; + return bless \%args, $class; +} + +sub name { + return 'kea'; +} + +sub implemented { + return 1; +} + +sub dhcp4_config_file { + my ($self) = @_; + return $self->{dhcp4_config_file} || '/etc/kea/kea-dhcp4.conf'; +} + +sub dhcp6_config_file { + my ($self) = @_; + return $self->{dhcp6_config_file} || '/etc/kea/kea-dhcp6.conf'; +} + +sub ctrl_agent_config_file { + my ($self) = @_; + return $self->{ctrl_agent_config_file} || '/etc/kea/kea-ctrl-agent.conf'; +} + +sub ddns_config_file { + my ($self) = @_; + return $self->{ddns_config_file} || '/etc/kea/kea-dhcp-ddns.conf'; +} + +sub render_dhcp4_config { + my ( $self, $intent ) = @_; + + $intent ||= {}; + + my %dhcp4 = ( + 'interfaces-config' => { + interfaces => $intent->{interfaces} || [], + }, + 'lease-database' => _first_defined( $intent->{'lease-database'}, $intent->{lease_database} ) || { + type => 'memfile', + name => '/var/lib/kea/kea-leases4.csv', + }, + 'valid-lifetime' => _integer( _first_defined( $intent->{'valid-lifetime'}, $intent->{valid_lifetime}, 43200 ) ), + subnet4 => [ map { $self->_render_subnet4($_) } @{ _first_defined( $intent->{subnet4}, $intent->{subnets}, [] ) } ], + ); + + $dhcp4{'control-socket'} = $intent->{'control-socket'} if $intent->{'control-socket'}; + $dhcp4{'hooks-libraries'} = $intent->{'hooks-libraries'} if $intent->{'hooks-libraries'}; + $dhcp4{'option-def'} = $intent->{'option-def'} if $intent->{'option-def'}; + $dhcp4{'client-classes'} = $intent->{'client-classes'} if $intent->{'client-classes'}; + $dhcp4{'option-data'} = $intent->{'option-data'} if $intent->{'option-data'}; + $dhcp4{'dhcp-ddns'} = $intent->{'dhcp-ddns'} if $intent->{'dhcp-ddns'}; + foreach my $field (qw/ddns-send-updates ddns-override-no-update ddns-override-client-update ddns-qualifying-suffix ddns-update-on-renew/) { + $dhcp4{$field} = $intent->{$field} if exists $intent->{$field}; + } + + return JSON->new->canonical->pretty->encode( { Dhcp4 => \%dhcp4 } ); +} + +sub render_dhcp6_config { + my ( $self, $intent ) = @_; + + $intent ||= {}; + + my %dhcp6 = ( + 'interfaces-config' => { + interfaces => $intent->{interfaces} || [], + }, + 'lease-database' => _first_defined( $intent->{'lease-database'}, $intent->{lease_database} ) || { + type => 'memfile', + name => '/var/lib/kea/kea-leases6.csv', + }, + 'preferred-lifetime' => _integer( _first_defined( $intent->{'preferred-lifetime'}, $intent->{preferred_lifetime}, 43200 ) ), + 'valid-lifetime' => _integer( _first_defined( $intent->{'valid-lifetime'}, $intent->{valid_lifetime}, 43200 ) ), + 'host-reservation-identifiers' => _first_defined( $intent->{'host-reservation-identifiers'}, [ 'duid', 'hw-address' ] ), + subnet6 => [ map { $self->_render_subnet6($_) } @{ _first_defined( $intent->{subnet6}, $intent->{subnets}, [] ) } ], + ); + + $dhcp6{'control-socket'} = $intent->{'control-socket'} if $intent->{'control-socket'}; + $dhcp6{'hooks-libraries'} = $intent->{'hooks-libraries'} if $intent->{'hooks-libraries'}; + $dhcp6{'option-data'} = $intent->{'option-data'} if $intent->{'option-data'}; + $dhcp6{'dhcp-ddns'} = $intent->{'dhcp-ddns'} if $intent->{'dhcp-ddns'}; + foreach my $field (qw/ddns-send-updates ddns-override-no-update ddns-override-client-update ddns-qualifying-suffix ddns-update-on-renew/) { + $dhcp6{$field} = $intent->{$field} if exists $intent->{$field}; + } + + return JSON->new->canonical->pretty->encode( { Dhcp6 => \%dhcp6 } ); +} + +sub render_ddns_config { + my ( $self, $intent ) = @_; + + $intent ||= {}; + my $forward_domains = $intent->{forward_domains}; + $forward_domains = $intent->{'forward-ddns'}{'ddns-domains'} + if !defined($forward_domains) && ref( $intent->{'forward-ddns'} ) eq 'HASH'; + $forward_domains ||= []; + my $reverse_domains = $intent->{reverse_domains}; + $reverse_domains = $intent->{'reverse-ddns'}{'ddns-domains'} + if !defined($reverse_domains) && ref( $intent->{'reverse-ddns'} ) eq 'HASH'; + $reverse_domains ||= []; + + my %ddns = ( + 'ip-address' => $intent->{'ip-address'} || '127.0.0.1', + port => _integer( $intent->{port} || 53001 ), + 'dns-server-timeout' => _integer( _first_defined( $intent->{'dns-server-timeout'}, 500 ) ), + 'ncr-protocol' => $intent->{'ncr-protocol'} || 'UDP', + 'ncr-format' => $intent->{'ncr-format'} || 'JSON', + 'tsig-keys' => $intent->{'tsig-keys'} || [], + 'forward-ddns' => { + 'ddns-domains' => $forward_domains, + }, + 'reverse-ddns' => { + 'ddns-domains' => $reverse_domains, + }, + ); + + $ddns{'control-sockets'} = $intent->{'control-sockets'} if $intent->{'control-sockets'}; + + return JSON->new->canonical->pretty->encode( { DhcpDdns => \%ddns } ); +} + +sub render_ctrl_agent_config { + my ( $self, $intent ) = @_; + + $intent ||= {}; + + my %sockets = ( + dhcp4 => { + 'socket-type' => 'unix', + 'socket-name' => $intent->{'dhcp4-socket'} || '/var/run/kea/kea4-ctrl-socket', + }, + ); + if ( $intent->{dhcp6} || $intent->{'dhcp6-socket'} ) { + $sockets{dhcp6} = { + 'socket-type' => 'unix', + 'socket-name' => $intent->{'dhcp6-socket'} || '/var/run/kea/kea6-ctrl-socket', + }; + } + if ( $intent->{ddns} || $intent->{'ddns-socket'} ) { + $sockets{d2} = { + 'socket-type' => 'unix', + 'socket-name' => $intent->{'ddns-socket'} || '/var/run/kea/kea-ddns-ctrl-socket', + }; + } + + my $agent = { + 'http-host' => $intent->{'http-host'} || '127.0.0.1', + 'http-port' => _integer( $intent->{'http-port'} || 8000 ), + 'control-sockets' => \%sockets, + }; + $agent->{authentication} = $intent->{authentication} if $intent->{authentication}; + + my $config = { + 'Control-agent' => $agent, + }; + + return JSON->new->canonical->pretty->encode($config); +} + +sub host_cmds_hook_path { + my ( $self, @extra_paths ) = @_; + + my @default_paths = exists $self->{host_cmds_hook_paths} + ? @{ $self->{host_cmds_hook_paths} || [] } + : ( + '/usr/lib64/kea/hooks/libdhcp_host_cmds.so', + '/usr/lib/kea/hooks/libdhcp_host_cmds.so', + '/usr/local/lib/kea/hooks/libdhcp_host_cmds.so', + glob('/usr/lib/*/kea/hooks/libdhcp_host_cmds.so'), + ); + + foreach my $path ( + @extra_paths, + @default_paths, + ) + { + return $path if defined($path) && -e $path; + } + + return; +} + +sub load_dhcp4_config { + my ( $self, $path ) = @_; + + $path ||= $self->dhcp4_config_file(); + return { Dhcp4 => { subnet4 => [] } } unless -e $path; + + open( my $fh, '<', $path ) or return { error => "Unable to read $path: $!" }; + local $/; + my $content = <$fh>; + close($fh); + + my $json = eval { decode_json($content) }; + return { error => "Unable to parse $path as JSON: $@" } if $@; + + $json->{Dhcp4}{subnet4} ||= []; + return $json; +} + +sub load_dhcp6_config { + my ( $self, $path ) = @_; + + $path ||= $self->dhcp6_config_file(); + return { Dhcp6 => { subnet6 => [] } } unless -e $path; + + open( my $fh, '<', $path ) or return { error => "Unable to read $path: $!" }; + local $/; + my $content = <$fh>; + close($fh); + + my $json = eval { decode_json($content) }; + return { error => "Unable to parse $path as JSON: $@" } if $@; + + $json->{Dhcp6}{subnet6} ||= []; + return $json; +} + +sub write_dhcp4_config { + my ( $self, $intent, %opts ) = @_; + + return $self->write_dhcp4_json( $self->render_dhcp4_config($intent), %opts ); +} + +sub write_dhcp6_config { + my ( $self, $intent, %opts ) = @_; + + return $self->write_dhcp6_json( $self->render_dhcp6_config($intent), %opts ); +} + +sub write_ddns_config { + my ( $self, $intent, %opts ) = @_; + + return $self->write_ddns_json( $self->render_ddns_config($intent), %opts ); +} + +sub write_dhcp4_json { + my ( $self, $json, %opts ) = @_; + + $opts{path} ||= $self->dhcp4_config_file(); + $opts{validator} ||= sub { $self->validate_dhcp4_config(@_) }; + return $self->_write_json_file( $json, %opts ); +} + +sub write_dhcp6_json { + my ( $self, $json, %opts ) = @_; + + $opts{path} ||= $self->dhcp6_config_file(); + $opts{validator} ||= sub { $self->validate_dhcp6_config(@_) }; + return $self->_write_json_file( $json, %opts ); +} + +sub write_ddns_json { + my ( $self, $json, %opts ) = @_; + + $opts{path} ||= $self->ddns_config_file(); + $opts{validator} ||= sub { $self->validate_ddns_config(@_) }; + return $self->_write_json_file( $json, %opts ); +} + +sub _write_json_file { + my ( $self, $json, %opts ) = @_; + + my $path = $opts{path}; + my $dir = dirname($path); + make_path($dir) unless -d $dir; + + my $tmp = "$path.xcat.$$"; + open( my $fh, '>', $tmp ) or return { error => "Unable to write $tmp: $!" }; + print $fh $json; + close($fh) or return { error => "Unable to close $tmp: $!" }; + my $permissions = _set_config_permissions($tmp); + if ( $permissions->{error} ) { + unlink $tmp; + return $permissions; + } + + if ( !$opts{skip_validate} ) { + my $validation = $opts{validator}->($tmp); + if ( $validation->{error} ) { + unlink $tmp; + return $validation; + } + } + + my $backup; + if ( $opts{backup_existing} && -e $path ) { + $backup = "$path.xcatbak"; + rename( $path, $backup ) or do { + unlink $tmp; + return { error => "Unable to back up $path to $backup: $!" }; + }; + } + + unless ( rename( $tmp, $path ) ) { + my $rename_error = $!; + rename( $backup, $path ) if $backup && -e $backup; + unlink $tmp; + return { error => "Unable to replace $path: $rename_error" }; + } + + return { path => $path, backup => $backup }; +} + +sub encode_config { + my ( $self, $config ) = @_; + return JSON->new->canonical->pretty->encode($config); +} + +sub write_ctrl_agent_config { + my ( $self, $intent, %opts ) = @_; + + $opts{path} ||= $self->ctrl_agent_config_file(); + $opts{validator} ||= sub { $self->validate_ctrl_agent_config(@_) }; + return $self->_write_json_file( $self->render_ctrl_agent_config($intent), %opts ); +} + +sub validate_dhcp4_config { + my ( $self, $path ) = @_; + + return $self->_validate_config_with( 'kea-dhcp4', 'Kea DHCPv4', $path ); +} + +sub validate_dhcp6_config { + my ( $self, $path ) = @_; + + return $self->_validate_config_with( 'kea-dhcp6', 'Kea DHCPv6', $path ); +} + +sub validate_ddns_config { + my ( $self, $path ) = @_; + + return $self->_validate_config_with( 'kea-dhcp-ddns', 'Kea DHCP-DDNS', $path ); +} + +sub validate_ctrl_agent_config { + my ( $self, $path ) = @_; + + return $self->_validate_config_with( 'kea-ctrl-agent', 'Kea Control Agent', $path ); +} + +sub _validate_config_with { + my ( $self, $command, $label, $path ) = @_; + + my $kea = _command_path($command); + return { error => "Unable to validate $label configuration: $command was not found." } unless $kea; + + my $cmd = "$kea -t " . _shell_quote($path) . " 2>&1"; + my $output = `$cmd`; + my $rc = $? >> 8; + return { error => "$label configuration validation failed: $output" } if $rc != 0; + + return { output => $output }; +} + +sub restart_services { + my ( $self, %opts ) = @_; + + require xCAT::Utils; + + my @services; + push @services, 'kea-dhcp-ddns' if $opts{ddns}; + push @services, 'kea-dhcp4'; + push @services, 'kea-dhcp6' if $opts{ipv6}; + push @services, 'kea-ctrl-agent' if $opts{ctrl_agent}; + + foreach my $service (@services) { + if ( $opts{enable} ) { + my $enable_ret = xCAT::Utils->enableservice($service); + return { error => "Failed to enable $service." } if $enable_ret != 0; + } + my $ret = xCAT::Utils->restartservice($service); + return { error => "Failed to restart $service." } if $ret != 0; + } + + return { services => \@services }; +} + +sub check_services { + my ( $self, %opts ) = @_; + + require xCAT::Utils; + + my @services = ('kea-dhcp4'); + push @services, 'kea-dhcp6' if $opts{ipv6}; + + foreach my $service (@services) { + my $ret = xCAT::Utils->checkservicestatus($service); + return { error => "$service is not running. Please start the Kea DHCP service." } if $ret != 0; + } + + return { services => \@services }; +} + +sub upsert_reservations { + my ( $self, $config, $reservations ) = @_; + + foreach my $reservation (@$reservations) { + $self->delete_reservations($config, $reservation); + my $subnet = _find_subnet_by_id( $config, $reservation->{'subnet-id'} ); + next unless $subnet; + $subnet->{reservations} ||= []; + $subnet->{reservations} = [ + grep { !_reservation_matches( $_, $reservation ) } @{ $subnet->{reservations} } + ]; + + my %stored = %$reservation; + delete $stored{'subnet-id'}; + push @{ $subnet->{reservations} }, \%stored; + } + + return $config; +} + +sub delete_reservations { + my ( $self, $config, $match ) = @_; + + my @deleted; + foreach my $subnet ( _subnets_for_config($config) ) { + my @kept; + foreach my $reservation ( @{ $subnet->{reservations} || [] } ) { + if ( _reservation_matches( $reservation, $match ) ) { + push @deleted, { %$reservation, 'subnet-id' => $subnet->{id} }; + } else { + push @kept, $reservation; + } + } + $subnet->{reservations} = \@kept; + } + + return \@deleted; +} + +sub query_reservations { + my ( $self, $config, $match ) = @_; + + my @found; + foreach my $subnet ( _subnets_for_config($config) ) { + foreach my $reservation ( @{ $subnet->{reservations} || [] } ) { + if ( _reservation_matches( $reservation, $match ) ) { + push @found, { %$reservation, 'subnet-id' => $subnet->{id}, subnet => $subnet->{subnet} }; + } + } + } + + return \@found; +} + +sub subnet_id_for_ip { + my ( $self, $config, $ip ) = @_; + + return unless defined($ip) && $ip ne ''; + my $ip_number = xCAT::NetworkUtils::getipaddr( $ip, GetNumber => 1 ); + return unless defined($ip_number); + my $bits = $ip =~ /:/ ? 128 : 32; + + foreach my $subnet ( _subnets_for_config($config) ) { + next unless $subnet->{subnet} && $subnet->{subnet} =~ m{^([^/]+)/(\d+)$}; + my ( $network, $prefix ) = ( $1, $2 ); + next if ( $network =~ /:/ ? 128 : 32 ) != $bits; + my $network_number = xCAT::NetworkUtils::getipaddr( $network, GetNumber => 1 ); + next unless defined($network_number); + + my $mask = Math::BigInt->new( "0b" . ( "1" x $prefix ) . ( "0" x ( $bits - $prefix ) ) ); + return $subnet->{id} if ( $ip_number & $mask ) == ( $network_number & $mask ); + } + + return; +} + +sub control_agent_url { + my ( $self, %opts ) = @_; + + my $host = $opts{host} || $self->{control_agent_host} || '127.0.0.1'; + my $port = $opts{port} || $self->{control_agent_port} || 8000; + return "http://$host:$port/"; +} + +sub control_agent_command { + my ( $self, $command, $arguments, %opts ) = @_; + + my $payload = { + command => $command, + arguments => $arguments || {}, + }; + $payload->{service} = $opts{service} if $opts{service}; + + if ( $self->{control_agent_handler} ) { + return $self->{control_agent_handler}->($payload, \%opts); + } + + eval { require HTTP::Tiny; }; + return { error => "Unable to use Kea Control Agent: HTTP::Tiny is not installed." } if $@; + + my $response = HTTP::Tiny->new( timeout => $opts{timeout} || 10 )->post( + $opts{url} || $self->control_agent_url(%opts), + { + headers => { + 'Content-Type' => 'application/json', + }, + content => $self->encode_config($payload), + } + ); + + return { error => "Kea Control Agent request failed: $response->{reason}" } unless $response->{success}; + + my $decoded = eval { decode_json( $response->{content} ) }; + return { error => "Unable to parse Kea Control Agent response: $@" } if $@; + + return $self->_normalize_control_agent_response($decoded); +} + +sub live_upsert_reservations { + my ( $self, $reservations, %opts ) = @_; + + my $service = $opts{service} || ['dhcp4']; + foreach my $reservation (@$reservations) { + my $delete = $self->_live_delete_reservation($reservation, service => $service, ignore_not_found => 1); + return $delete if $delete->{error}; + + my %stored = %$reservation; + my $result = $self->control_agent_command( + 'reservation-add', + { + reservation => \%stored, + 'operation-target' => 'memory', + }, + service => $service, + ); + return $result if $result->{error}; + } + + return { ok => 1 }; +} + +sub live_delete_reservations { + my ( $self, $reservations, %opts ) = @_; + + my $service = $opts{service} || ['dhcp4']; + foreach my $reservation (@$reservations) { + my $result = $self->_live_delete_reservation($reservation, service => $service, ignore_not_found => 1); + return $result if $result->{error}; + } + + return { ok => 1 }; +} + +sub _live_delete_reservation { + my ( $self, $reservation, %opts ) = @_; + + return { ok => 1 } unless defined $reservation->{'subnet-id'}; + + my %arguments = ( + 'subnet-id' => $reservation->{'subnet-id'}, + 'operation-target' => 'memory', + ); + if ( $reservation->{'hw-address'} ) { + $arguments{'identifier-type'} = 'hw-address'; + $arguments{identifier} = $reservation->{'hw-address'}; + } elsif ( $reservation->{duid} ) { + $arguments{'identifier-type'} = 'duid'; + $arguments{identifier} = $reservation->{duid}; + } elsif ( $reservation->{'ip-address'} ) { + $arguments{'ip-address'} = $reservation->{'ip-address'}; + } elsif ( ref( $reservation->{'ip-addresses'} ) eq 'ARRAY' && @{ $reservation->{'ip-addresses'} } ) { + $arguments{'ip-address'} = $reservation->{'ip-addresses'}[0]; + } else { + return { ok => 1 }; + } + + my $result = $self->control_agent_command( + 'reservation-del', + \%arguments, + service => $opts{service} || ['dhcp4'], + ); + return { ok => 1 } if $opts{ignore_not_found} && _control_agent_not_found($result); + return $result if $result->{error}; + return { ok => 1 }; +} + +sub _render_subnet4 { + my ( $self, $subnet ) = @_; + + my %rendered = ( + id => _integer( $subnet->{id} ), + subnet => $subnet->{subnet}, + ); + + $rendered{interface} = $subnet->{interface} if defined $subnet->{interface}; + $rendered{'next-server'} = _first_defined( $subnet->{'next-server'}, $subnet->{next_server} ); + $rendered{'boot-file-name'} = _first_defined( $subnet->{'boot-file-name'}, $subnet->{boot_file_name} ); + $rendered{'option-data'} = _first_defined( $subnet->{'option-data'}, $subnet->{option_data} ); + $rendered{reservations} = $subnet->{reservations} if $subnet->{reservations}; + delete $rendered{'next-server'} unless defined $rendered{'next-server'}; + delete $rendered{'boot-file-name'} unless defined $rendered{'boot-file-name'}; + delete $rendered{'option-data'} unless defined $rendered{'option-data'}; + + if ( $subnet->{pools} ) { + $rendered{pools} = $subnet->{pools}; + } elsif ( $subnet->{dynamicrange} ) { + $rendered{pools} = [ xCAT::DHCP::Range->kea_pools( $subnet->{dynamicrange} ) ]; + } else { + $rendered{pools} = []; + } + + return \%rendered; +} + +sub _render_subnet6 { + my ( $self, $subnet ) = @_; + + my %rendered = ( + id => _integer( $subnet->{id} ), + subnet => $subnet->{subnet}, + ); + + $rendered{interface} = $subnet->{interface} if defined $subnet->{interface}; + $rendered{'option-data'} = _first_defined( $subnet->{'option-data'}, $subnet->{option_data} ); + $rendered{reservations} = $subnet->{reservations} if $subnet->{reservations}; + delete $rendered{'option-data'} unless defined $rendered{'option-data'}; + + if ( $subnet->{pools} ) { + $rendered{pools} = $subnet->{pools}; + } elsif ( $subnet->{dynamicrange} ) { + $rendered{pools} = [ xCAT::DHCP::Range->kea_pools( $subnet->{dynamicrange} ) ]; + } else { + $rendered{pools} = []; + } + + return \%rendered; +} + +sub _find_subnet_by_id { + my ( $config, $subnet_id ) = @_; + + return unless defined($subnet_id); + foreach my $subnet ( _subnets_for_config($config) ) { + return $subnet if defined( $subnet->{id} ) && $subnet->{id} == $subnet_id; + } + + return; +} + +sub _subnets_for_config { + my ($config) = @_; + + return @{ $config->{Dhcp4}{subnet4} || [] } if $config->{Dhcp4}; + return @{ $config->{Dhcp6}{subnet6} || [] } if $config->{Dhcp6}; + return; +} + +sub _reservation_matches { + my ( $reservation, $match ) = @_; + + foreach my $field ( 'hostname', 'hw-address', 'duid', 'ip-address' ) { + next unless defined $match->{$field} && $match->{$field} ne ''; + return 1 if defined $reservation->{$field} && lc( $reservation->{$field} ) eq lc( $match->{$field} ); + } + if ( defined $match->{'ip-address'} && ref( $reservation->{'ip-addresses'} ) eq 'ARRAY' ) { + foreach my $ip ( @{ $reservation->{'ip-addresses'} } ) { + return 1 if lc($ip) eq lc( $match->{'ip-address'} ); + } + } + + return 0; +} + +sub _normalize_control_agent_response { + my ( $self, $decoded ) = @_; + + my $item = ref($decoded) eq 'ARRAY' ? $decoded->[0] : $decoded; + return { error => 'Kea Control Agent response was empty.' } unless ref($item) eq 'HASH'; + + return { + ok => defined( $item->{result} ) && $item->{result} == 0 ? 1 : 0, + result => $item->{result}, + text => $item->{text}, + response => $decoded, + error => defined( $item->{result} ) && $item->{result} == 0 ? undef : ( $item->{text} || 'Kea Control Agent command failed.' ), + }; +} + +sub _control_agent_not_found { + my ($result) = @_; + + return 0 unless $result && $result->{error}; + return 1 if defined( $result->{result} ) && $result->{result} == 3; + return 1 if defined( $result->{text} ) && $result->{text} =~ /not\s+(?:deleted|found)/i; + return 0; +} + +sub _first_defined { + foreach my $value (@_) { + return $value if defined $value; + } + + return; +} + +sub _integer { + my ($value) = @_; + + return $value unless defined($value) && $value =~ /^\d+$/; + return 0 + $value; +} + +sub _set_config_permissions { + my ($path) = @_; + + if ($> != 0) { + chmod 0644, $path or return { error => "Unable to set $path permissions to 0644: $!" }; + return { ok => 1 }; + } + + my ( $group, $gid ) = _kea_group(); + if ( defined($gid) ) { + chown 0, $gid, $path or return { error => "Unable to set $path ownership to root:$group: $!" }; + chmod 0640, $path or return { error => "Unable to set $path permissions to 0640: $!" }; + } else { + chmod 0644, $path or return { error => "Unable to set $path permissions to 0644: $!" }; + } + + return { ok => 1 }; +} + +sub _kea_group { + foreach my $group ( 'kea', '_kea' ) { + my @entry = getgrnam($group); + return ( $entry[0], $entry[2] ) if @entry; + } + + return; +} + +sub _command_path { + my ($command) = @_; + + foreach my $dir ( split /:/, $ENV{PATH} || '' ) { + next unless $dir; + my $path = "$dir/$command"; + return $path if -x $path; + } + + foreach my $path ( "/usr/sbin/$command", "/usr/bin/$command", "/sbin/$command", "/bin/$command" ) { + return $path if -x $path; + } + + return; +} + +sub _shell_quote { + my ($value) = @_; + + $value =~ s/'/'\\''/g; + return "'$value'"; +} + +1; diff --git a/perl-xCAT/xCAT/DHCP/BootPolicy.pm b/perl-xCAT/xCAT/DHCP/BootPolicy.pm new file mode 100644 index 000000000..7e3602c6c --- /dev/null +++ b/perl-xCAT/xCAT/DHCP/BootPolicy.pm @@ -0,0 +1,58 @@ +package xCAT::DHCP::BootPolicy; + +use strict; +use warnings; + +sub kea_client_classes { + my ( $class, %opts ) = @_; + + my $bios_boot = $opts{xnba_kpxe} ? 'xcat/xnba.kpxe' : 'pxelinux.0'; + my $uefi_boot = $opts{xnba_efi} ? 'xcat/xnba.efi' : ''; + my @classes; + + if ( $opts{xnba_kpxe} ) { + push @classes, { + name => 'xcat-xnba-bios', + test => "option[77].text == 'xNBA' and option[93].hex == 0x0000", + 'boot-file-name' => 'xcat/xnba.kpxe', + }; + } + + push @classes, ( + { + name => 'xcat-bios', + test => 'option[93].hex == 0x0000', + 'boot-file-name' => $bios_boot, + }, + ); + + if ($uefi_boot ne '') { + push @classes, { + name => 'xcat-uefi-x64', + test => 'option[93].hex == 0x0007 or option[93].hex == 0x0009', + 'boot-file-name' => $uefi_boot, + }; + } + + push @classes, ( + { + name => 'xcat-aarch64', + test => 'option[93].hex == 0x000b', + 'boot-file-name' => 'boot/grub2/grub2.aarch64', + }, + { + name => 'xcat-ppc64', + test => 'option[93].hex == 0x000c', + 'boot-file-name' => '/boot/grub2/grub2.ppc', + }, + { + name => 'xcat-ia64', + test => 'option[93].hex == 0x0002', + 'boot-file-name' => 'elilo.efi', + }, + ); + + return \@classes; +} + +1; diff --git a/perl-xCAT/xCAT/DHCP/Range.pm b/perl-xCAT/xCAT/DHCP/Range.pm new file mode 100644 index 000000000..21dc5da3b --- /dev/null +++ b/perl-xCAT/xCAT/DHCP/Range.pm @@ -0,0 +1,135 @@ +package xCAT::DHCP::Range; + +use strict; +use warnings; + +use Math::BigInt; +use Socket; +use xCAT::NetworkUtils qw/getipaddr/; + +sub parse_dynamic_ranges { + my ( $class, $ranges ) = @_; + + return [] unless defined($ranges) && $ranges ne ''; + + my @parsed; + foreach my $range ( split /;/, $ranges ) { + my $entry = $class->parse($range); + push @parsed, $entry if $entry; + } + + return \@parsed; +} + +sub parse { + my ( $class, $range ) = @_; + + return unless defined($range); + $range =~ s/^\s+|\s+$//g; + return unless $range ne ''; + + if ( $range =~ m{/} ) { + return $class->_parse_cidr($range); + } + + return $class->_parse_pair($range); +} + +sub isc_ranges { + my ( $class, $ranges ) = @_; + + return map { $class->isc_range($_) } @{ $class->parse_dynamic_ranges($ranges) }; +} + +sub kea_pools { + my ( $class, $ranges ) = @_; + + return map { { pool => $class->kea_pool($_) } } @{ $class->parse_dynamic_ranges($ranges) }; +} + +sub isc_range { + my ( $class, $entry ) = @_; + + return $entry->{cidr} if $entry->{family} == 6 && $entry->{cidr}; + return "$entry->{start} $entry->{end}"; +} + +sub kea_pool { + my ( $class, $entry ) = @_; + + return $entry->{cidr} if $entry->{family} == 6 && $entry->{cidr}; + return "$entry->{start} - $entry->{end}"; +} + +sub bounds { + my ( $class, $entry ) = @_; + + return ( $entry->{start_number}, $entry->{end_number} ); +} + +sub _parse_pair { + my ( $class, $range ) = @_; + + my @parts = grep { $_ ne '' } split /[\s,-]+/, $range; + return unless @parts >= 2; + + my ( $start, $end ) = @parts[ 0, 1 ]; + my $family = ( $start =~ /:/ || $end =~ /:/ ) ? 6 : 4; + my $start_number = getipaddr( $start, GetNumber => 1 ); + my $end_number = getipaddr( $end, GetNumber => 1 ); + + return unless defined($start_number) && defined($end_number); + + return { + source => $range, + family => $family, + start => $start, + end => $end, + start_number => $start_number, + end_number => $end_number, + }; +} + +sub _parse_cidr { + my ( $class, $range ) = @_; + + my ( $prefix, $suffix ) = split /\//, $range, 2; + return unless defined($prefix) && defined($suffix) && $suffix =~ /^\d+$/; + + my $family = $prefix =~ /:/ ? 6 : 4; + my $numbits = $family == 6 ? 128 : 32; + return if $suffix > $numbits; + + my $number = getipaddr( $prefix, GetNumber => 1 ); + return unless defined($number); + + my $highmask = Math::BigInt->new( "0b" . ( "1" x $suffix ) . ( "0" x ( $numbits - $suffix ) ) ); + my $lowmask = Math::BigInt->new( "0b" . ( "1" x ( $numbits - $suffix ) ) ); + + $number &= $highmask; + my $start_number = $number->copy(); + $number |= $lowmask; + my $end_number = $number->copy(); + + if ( $family == 6 ) { + return { + source => $range, + family => 6, + cidr => $range, + start_number => $start_number, + end_number => $end_number, + }; + } + + return { + source => $range, + family => 4, + cidr => $range, + start => inet_ntoa( pack( "N*", $start_number->numify() ) ), + end => inet_ntoa( pack( "N*", $end_number->numify() ) ), + start_number => $start_number, + end_number => $end_number, + }; +} + +1; diff --git a/perl-xCAT/xCAT/Schema.pm b/perl-xCAT/xCAT/Schema.pm index 53a2cbba3..4c73f2aee 100644 --- a/perl-xCAT/xCAT/Schema.pm +++ b/perl-xCAT/xCAT/Schema.pm @@ -1031,6 +1031,17 @@ passed as argument rather than by table value', " -------------\n" . "DHCP ATTRIBUTES\n" . " -------------\n" . +" dhcpbackend: The DHCP implementation used by makedhcp. Valid values are auto,\n" . +" isc, and kea. The default is auto. In auto mode, xCAT uses ISC\n" . +" DHCP on existing supported platforms and Kea DHCP on platforms\n" . +" where ISC DHCP is no longer available, such as EL10 and Ubuntu\n" . +" 24.04.\n\n" . +" keacontrolagent: Whether makedhcp should generate Kea Control Agent socket\n" . +" configuration. Valid values are 1/yes/true/enabled or\n" . +" 0/no/false/disabled. The default is disabled; host\n" . +" reservations use JSON render and reload unless Control\n" . +" Agent operations are explicitly enabled and the Kea\n" . +" host-commands hook is installed.\n\n" . " dhcpinterfaces: The network interfaces DHCP should listen on. If it is the same for all\n" . " nodes, use a comma-separated list of the NICs. To specify different NICs\n" . " for different nodes, use the format: \"xcatmn|eth1,eth2;service|bond0\", \n" . @@ -4338,5 +4349,3 @@ foreach (keys %xCAT::Schema::defspec) { } } 1; - - diff --git a/perl-xCAT/xCAT/Utils.pm b/perl-xCAT/xCAT/Utils.pm index 27854bfef..6c969e2c5 100644 --- a/perl-xCAT/xCAT/Utils.pm +++ b/perl-xCAT/xCAT/Utils.pm @@ -4074,14 +4074,18 @@ sub servicemap { # (general service name) => {list of possible service names} # my %svchash = ( - "dhcp" => [ "dhcp3-server", "dhcpd", "isc-dhcp-server" ], - "nfs" => [ "nfsserver", "nfs-server", "nfs", "nfs-kernel-server" ], - "named" => [ "named", "bind9" ], - "syslog" => [ "syslog", "syslogd", "rsyslog" ], - "firewall" => [ "iptables", "firewalld", "ufw" ], - "http" => [ "apache2", "httpd" ], - "ntpserver" => [ "ntpd", "ntp" ], - "mysql" => [ "mysqld", "mysql", "mariadb" ], + "dhcp" => [ "dhcp3-server", "dhcpd", "isc-dhcp-server" ], + "kea-dhcp4" => [ "kea-dhcp4", "kea-dhcp4-server", "isc-kea-dhcp4-server" ], + "kea-dhcp6" => [ "kea-dhcp6", "kea-dhcp6-server", "isc-kea-dhcp6-server" ], + "kea-ctrl-agent" => [ "kea-ctrl-agent", "kea-ctrl-agent-server", "isc-kea-ctrl-agent" ], + "kea-dhcp-ddns" => [ "kea-dhcp-ddns", "kea-dhcp-ddns-server", "isc-kea-dhcp-ddns-server" ], + "nfs" => [ "nfsserver", "nfs-server", "nfs", "nfs-kernel-server" ], + "named" => [ "named", "bind9" ], + "syslog" => [ "syslog", "syslogd", "rsyslog" ], + "firewall" => [ "iptables", "firewalld", "ufw" ], + "http" => [ "apache2", "httpd" ], + "ntpserver" => [ "ntpd", "ntp" ], + "mysql" => [ "mysqld", "mysql", "mariadb" ], ); my $path = undef; diff --git a/xCAT-client/pods/man8/makedhcp.8.pod b/xCAT-client/pods/man8/makedhcp.8.pod index 79894b6ba..1fe9b85a8 100644 --- a/xCAT-client/pods/man8/makedhcp.8.pod +++ b/xCAT-client/pods/man8/makedhcp.8.pod @@ -22,6 +22,8 @@ B B<[-h|--help]> The B command creates and updates the DHCP configuration on the management node and service nodes. The B command is supported for both Linux and AIX clusters. +On Linux, the DHCP implementation is selected by the C attribute. +The C setting keeps ISC DHCP on platforms where it is still available and uses Kea DHCP on platforms such as EL10 and Ubuntu 24.04. =over 3 @@ -41,8 +43,8 @@ Also, get the hostnames and IP addresses pushed to /etc/hosts (using L with a noderange or the B<-a> option. This will inject into dhcpd configuration data pertinent to the specified nodes. -On linux, the configuration information immediately takes effect without a restart of DHCP. +Then run B with a noderange or the B<-a> option. This will inject DHCP configuration data pertinent to the specified nodes. +With ISC DHCP, Linux node entries are updated through OMAPI. With Kea DHCP, node reservations are rendered into the Kea JSON configuration, validated, and the Kea service is restarted. =back @@ -58,7 +60,8 @@ Create a new dhcp configuration file with a network statement for each network t (Which networks dhcpd should listen on can be controlled by the dhcpinterfaces attribute in the L table.) The B command will automatically restart the dhcp daemon after this operation. This option will replace any existing configuration file (making a backup of it first). -For Linux systems the file will include network entries as well as certain general parameters such as a dynamic range and omapi configuration. +For Linux systems using ISC DHCP, the file will include network entries as well as certain general parameters such as a dynamic range and omapi configuration. +For Linux systems using Kea DHCP, the generated files are /etc/kea/kea-dhcp4.conf, /etc/kea/kea-dhcp6.conf when IPv6 networks are configured, and /etc/kea/kea-dhcp-ddns.conf when DDNS is enabled. For AIX systems the file will include network entries. On AIX systems, if there are any non-xCAT entries in the existing configuration file they will be preserved and added to the end of the new configuration file. @@ -75,6 +78,7 @@ Add the specified nodes to the DHCP server configuration. =item B<-s> I For the input noderange, the argument will be interpreted like dhcp configuration file text. +This option is only supported by the ISC DHCP backend. =item B<-d> I @@ -150,7 +154,12 @@ DHCP configuration files: [RH] /etc/dhcp/dhcpd.conf +[Kea] /etc/kea/kea-dhcp4.conf + +[Kea IPv6] /etc/kea/kea-dhcp6.conf + +[Kea DDNS] /etc/kea/kea-dhcp-ddns.conf + =head1 SEE ALSO L - diff --git a/xCAT-probe/lib/perl/LogParse.pm b/xCAT-probe/lib/perl/LogParse.pm index eed2a80fd..c2a9f6273 100644 --- a/xCAT-probe/lib/perl/LogParse.pm +++ b/xCAT-probe/lib/perl/LogParse.pm @@ -493,7 +493,7 @@ sub obtain_log_content { } else { $log_content{sender} = $split_line[1]; } - if ($split_line[2] =~ /dhcpd/i) { + if ($split_line[2] =~ /dhcpd|kea-dhcp4/i) { $log_content{label} = $::LOGLABEL_DHCPD; } elsif ($split_line[2] =~ /in.tftpd/i) { $log_content{label} = $::LOGLABEL_TFTP; @@ -521,7 +521,7 @@ sub obtain_log_content { } else { $log_content{sender} = $split_line[3]; } - if ($split_line[4] =~ /dhcpd/i) { + if ($split_line[4] =~ /dhcpd|kea-dhcp4/i) { $log_content{label} = $::LOGLABEL_DHCPD; } elsif ($split_line[4] =~ /in.tftpd/i) { $log_content{label} = $::LOGLABEL_TFTP; @@ -728,4 +728,3 @@ sub debuglogger { } } 1; - diff --git a/xCAT-probe/subcmds/discovery b/xCAT-probe/subcmds/discovery index a926064f2..6c015198e 100755 --- a/xCAT-probe/subcmds/discovery +++ b/xCAT-probe/subcmds/discovery @@ -828,6 +828,10 @@ sub dhcp_dynamic_range_check { my $msg = "DHCP dynamic range is configured"; my $dhcpconfig; + my $kea_config; + if (-e "/etc/kea/kea-dhcp4.conf") { + $kea_config = "/etc/kea/kea-dhcp4.conf"; + } if (-e "/etc/dhcp/dhcpd.conf") { $dhcpconfig = "/etc/dhcp/dhcpd.conf"; } elsif (-e "/etc/dhcp3/dhcpd.conf") { @@ -836,9 +840,9 @@ sub dhcp_dynamic_range_check { $dhcpconfig = "/etc/dhcpd.conf"; } - unless ($dhcpconfig) { + unless ($dhcpconfig || $kea_config) { probe_utils->send_msg("stdout", "f", $msg); - probe_utils->send_msg("stdout", "d", "Cannot find the dhcpd.conf file."); + probe_utils->send_msg("stdout", "d", "Cannot find the DHCP configuration file."); return 1; } @@ -847,13 +851,40 @@ sub dhcp_dynamic_range_check { my @dynamic_range; my %subnet_hash; - unless (open(FILE, $dhcpconfig)) { + if ($kea_config) { + $dhcpconfig = undef; + my $config_json = eval { + require JSON; + open(my $fh, '<', $kea_config) or die $!; + local $/; + my $content = <$fh>; + close($fh); + JSON::decode_json($content); + }; + if ($@) { + probe_utils->send_msg("stdout", "f", $msg); + probe_utils->send_msg("stdout", "d", "Cannot parse file $kea_config: $@"); + return 1; + } + foreach my $subnet4 (@{ $config_json->{Dhcp4}{subnet4} || [] }) { + next unless $subnet4->{subnet} && $subnet4->{subnet} =~ m{^([^/]+)/(\d+)$}; + my $mask = prefix_to_netmask($2); + my @ranges; + foreach my $pool (@{ $subnet4->{pools} || [] }) { + next unless $pool->{pool}; + my $range = $pool->{pool}; + $range =~ s/\s*-\s*/-/; + push @ranges, $range; + } + $subnet_hash{"$1/$mask"} = @ranges ? \@ranges : "unknown"; + } + } elsif (!open(FILE, $dhcpconfig)) { probe_utils->send_msg("stdout", "f", $msg); probe_utils->send_msg("stdout", "d", "Cannot open file $dhcpconfig."); return 1; } - while ($config_line = ) { + while ($dhcpconfig && ($config_line = )) { chomp($config_line); $config_line =~ s/^\s+|\s+$//g; @@ -1007,6 +1038,17 @@ sub compare_ip_value { return 0; } +sub prefix_to_netmask { + my $prefix = shift; + + my $mask_num = 0; + for my $idx (0 .. $prefix - 1) { + $mask_num |= (1 << (31 - $idx)); + } + + return join('.', map { ($mask_num >> (8 * (3 - $_))) & 0xff } 0 .. 3); +} + #------------------------------------------ =head3 @@ -1705,6 +1747,3 @@ sub conclusion_report { return 0; } - - - diff --git a/xCAT-probe/subcmds/xcatmn b/xCAT-probe/subcmds/xcatmn index 415c148bb..9677af5eb 100755 --- a/xCAT-probe/subcmds/xcatmn +++ b/xCAT-probe/subcmds/xcatmn @@ -1067,10 +1067,10 @@ sub check_dhcp_service { chomp($checkdhcp); if ($checkdhcp) { - # on sn, just check dhcpd service whether running - my $dhcpoutput = `ps aux 2>&1| grep dhcpd |grep -v grep`; + # on sn, just check DHCP service whether running + my $dhcpoutput = `ps aux 2>&1| grep -E 'dhcpd|kea-dhcp4' |grep -v grep`; if (!$dhcpoutput) { - push @$error_ref, "There isn't 'dhcpd' daemon in current server"; + push @$error_ref, "There isn't a DHCP daemon in current server"; $rst = 1; } } else { @@ -1180,7 +1180,7 @@ sub check_dhcp_leases { my $error_ref = shift; my $rst = 0; - $$checkpoint_ref = "Checking dhcpd.leases file is less than 100M..."; + $$checkpoint_ref = "Checking DHCP lease file is less than 100M..."; @$error_ref = (); my $leasefile = ""; @@ -1190,6 +1190,8 @@ sub check_dhcp_leases { $leasefile = "/var/lib/dhcp/db/dhcpd.leases"; } elsif (-e "/var/lib/dhcp/dhcpd.leases") { $leasefile = "/var/lib/dhcp/dhcpd.leases"; + } elsif (-e "/var/lib/kea/kea-leases4.csv") { + $leasefile = "/var/lib/kea/kea-leases4.csv"; } my @fileinfo = stat("$leasefile"); @@ -1492,4 +1494,3 @@ exit $rst; - diff --git a/xCAT-rmc/resources/mn/IBM.Condition/CheckDHCPonSN.pm b/xCAT-rmc/resources/mn/IBM.Condition/CheckDHCPonSN.pm index 019f84ae3..6162ae5df 100644 --- a/xCAT-rmc/resources/mn/IBM.Condition/CheckDHCPonSN.pm +++ b/xCAT-rmc/resources/mn/IBM.Condition/CheckDHCPonSN.pm @@ -4,10 +4,10 @@ $RES::Condition{'CheckDHCPonSN'} = { Name => q(CheckDHCPonSN), ResourceClass => q(IBM.Program), EventExpression => q(Processes.CurPidCount == 0), - EventDescription => q(An event will be generated when the DHCP server is down on the service node. There may be other nodes in this management domain such as HMCs. To exclude them, just change the SelectionString to: "ProgramName=='dhcpd' && NodeNameList >< {'hmc1','hmc2}" where hmc1 and hmc2 are the names for the nodes that you want to exclude.), + EventDescription => q(An event will be generated when the DHCP server is down on the service node. There may be other nodes in this management domain such as HMCs. To exclude them, just change the SelectionString to: "(ProgramName=='dhcpd' || ProgramName=='kea-dhcp4') && NodeNameList >< {'hmc1','hmc2}" where hmc1 and hmc2 are the names for the nodes that you want to exclude.), RearmExpression => q(Processes.CurPidCount != 0), RearmDescription => q(A rearm event will be generated when the DHCP server is up on the service node.), - SelectionString => q(ProgramName=='dhcpd'), + SelectionString => q(ProgramName=='dhcpd' || ProgramName=='kea-dhcp4'), ManagementScope => q(4), Severity => q(1), }; diff --git a/xCAT-server/lib/xcat/plugins/AAsn.pm b/xCAT-server/lib/xcat/plugins/AAsn.pm index 11744ca52..1d46696c5 100644 --- a/xCAT-server/lib/xcat/plugins/AAsn.pm +++ b/xCAT-server/lib/xcat/plugins/AAsn.pm @@ -14,6 +14,7 @@ use xCAT::Table; use xCAT::Utils; use xCAT::TableUtils; use xCAT::NetworkUtils; +use xCAT::DHCP::Backend; use xCAT::MsgUtils; use xCAT_plugin::dhcp; @@ -613,15 +614,11 @@ sub setup_DHCP my $rc = 0; my $cmd; my $snonly = 0; + my $dhcpbackend = xCAT::DHCP::Backend->new_backend(); + my $dhcpservice = (ref($dhcpbackend) ne 'HASH' && $dhcpbackend->name eq 'kea') ? 'kea-dhcp4' : 'dhcp'; - # if on the MN check to see if dhcpd is running, and start it if not. + # if on the MN check to see if DHCP is running, and start it if not. if (xCAT::Utils->isMN()) { # on the MN - #my @output = xCAT::Utils->runcmd("service dhcpd status", -1); - #if ($::RUNCMD_RC != 0) { # not running - my $dhcpservice = "dhcpd"; - if (-e "/etc/init.d/isc-dhcp-server") { #Ubuntu - $dhcpservice = "isc-dhcp-server"; - } my $retcode = xCAT::Utils->checkservicestatus($dhcpservice); if ($retcode != 0) { $rc = xCAT::Utils->startservice($dhcpservice); @@ -669,7 +666,7 @@ sub setup_DHCP if (xCAT::Utils->isAIX()) { $rc = xCAT::Utils->startService("dhcpd"); } elsif (xCAT::Utils->isLinux()) { - $rc = xCAT::Utils->startservice("dhcp"); + $rc = xCAT::Utils->startservice($dhcpservice); } if ($rc != 0) { diff --git a/xCAT-server/lib/xcat/plugins/dhcp.pm b/xCAT-server/lib/xcat/plugins/dhcp.pm index 05dd98d9e..f458e8923 100644 --- a/xCAT-server/lib/xcat/plugins/dhcp.pm +++ b/xCAT-server/lib/xcat/plugins/dhcp.pm @@ -13,6 +13,7 @@ use xCAT::Table; #use Data::Dumper; use MIME::Base64; +use JSON (); use Getopt::Long; Getopt::Long::Configure("bundling"); Getopt::Long::Configure("pass_through"); @@ -24,6 +25,9 @@ my $candoipv6 = eval { use Sys::Syslog; use IPC::Open2; use xCAT::Utils; +use xCAT::DHCP::BootPolicy; +use xCAT::DHCP::Backend; +use xCAT::DHCP::Range; use xCAT::TableUtils; use xCAT::NetworkUtils qw/getipaddr/; use xCAT::ServiceNodeUtils; @@ -54,7 +58,6 @@ my $iscsients; my $nodetypeents; my $chainents; my $tftpdir = xCAT::TableUtils->getTftpDir(); -use Math::BigInt; my $dhcpconffile = $^O eq 'aix' ? '/etc/dhcpsd.cnf' : '/etc/dhcpd.conf'; my %dynamicranges; #track dynamic ranges defined to see if a host that resolves is actually a dynamic address my %netcfgs; @@ -845,9 +848,6 @@ sub addnode sub addrangedetection { my $net = shift; my $tranges = $net->{dynamicrange}; #temp range, the dollar sign makes it look strange - my $trange; - my $begin; - my $end; my $myip; my @myipd = xCAT::NetworkUtils->my_ip_facing($net->{net}); unless ($myipd[0]) { $myip = $myipd[1]; } @@ -876,40 +876,12 @@ sub addrangedetection { $netcfgs{ $net->{net} }->{nameservers} = $::XCATSITEVALS{nameservers}; } } - foreach $trange (split /;/, $tranges) { - if ($trange =~ /[ ,-]/) { #a range of one number to another.. - $trange =~ s/[,-]/ /g; - $netcfgs{ $net->{net} }->{range} = $trange; - ($begin, $end) = split / /, $trange; - $dynamicranges{$trange} = [ getipaddr($begin, GetNumber => 1), getipaddr($end, GetNumber => 1) ]; - } elsif ($trange =~ /\//) { #a CIDR style specification for a range that could be described in subnet rules - #we are going to assume that this is a subset of the network (it really ought to be) and therefore all zeroes or all ones is good to include - my $prefix; - my $suffix; - ($prefix, $suffix) = split /\//, $trange; - my $numbits; - if ($prefix =~ /:/) { #ipv6 - $netcfgs{ $net->{net} }->{range} = $trange; #we can put in dhcpv6 ranges verbatim as CIDR - $numbits = 128; - } else { - $numbits = 32; - } - my $number = getipaddr($prefix, GetNumber => 1); - my $highmask = Math::BigInt->new("0b" . ("1" x $suffix) . ("0" x ($numbits - $suffix))); - my $lowmask = Math::BigInt->new("0b" . ("1" x ($numbits - $suffix))); - $number &= $highmask; #remove any errant high bits beyond the mask. - $begin = $number->copy(); - $number |= $lowmask; #get the highest number in the range, - $end = $number->copy(); - $dynamicranges{$trange} = [ $begin, $end ]; + foreach my $range_entry ( @{ xCAT::DHCP::Range->parse_dynamic_ranges($tranges) } ) { + my $isc_range = xCAT::DHCP::Range->isc_range($range_entry); + $netcfgs{ $net->{net} }->{range} = $isc_range; - if ($prefix !~ /:/) { #ipv4, must convert CIDR subset to range - my $lowip = inet_ntoa(pack("N*", $begin)); - my $highip = inet_ntoa(pack("N*", $end)); - $netcfgs{ $net->{net} }->{range} = "$lowip $highip"; - - } - } + my ( $begin_number, $end_number ) = xCAT::DHCP::Range->bounds($range_entry); + $dynamicranges{$isc_range} = [ $begin_number, $end_number ] if defined($begin_number) && defined($end_number); } } ###################################################### @@ -1388,6 +1360,20 @@ sub process_request return []; } + my $backend = xCAT::DHCP::Backend->new_backend(check_available => 1); + if ( ref($backend) eq 'HASH' && $backend->{error} ) { + my $rsp = {}; + $rsp->{data}->[0] = $backend->{error}; + xCAT::MsgUtils->message("E", $rsp, $callback, 1); + return; + } + if ( $backend->name eq 'kea' && $statements ) { + my $rsp = {}; + $rsp->{data}->[0] = "The -s option contains ISC DHCP statement text and is not supported with the Kea DHCP backend."; + xCAT::MsgUtils->message("E", $rsp, $callback, 1); + return; + } + # if current node is a servicenode, make sure that it is also a dhcpserver my $isok = 1; if (xCAT::Utils->isServiceNode()) { @@ -1409,23 +1395,21 @@ sub process_request return; } - # if not -n, dhcpd needs to be running + # if not -n, dhcp service needs to be running if (!($opt{n})) { if (xCAT::Utils->isLinux()) { - - #my $DHCPSERVER="dhcpd"; - #if( -e "/etc/init.d/isc-dhcp-server" ){ - # $DHCPSERVER="isc-dhcp-server"; - #} - - #my @output = xCAT::Utils->runcmd("service $DHCPSERVER status", -1); - #if ($::RUNCMD_RC != 0) { # not running my $ret = 0; - $ret = xCAT::Utils->checkservicestatus("dhcp"); - if ($ret != 0) - { + my $service_error = "dhcp server is not running. please start the dhcp server."; + if ( $backend->name eq 'kea' ) { + my $status = $backend->check_services(); + $ret = $status->{error} ? 1 : 0; + $service_error = $status->{error} if $status->{error}; + } else { + $ret = xCAT::Utils->checkservicestatus("dhcp"); + } + if ($ret != 0) { my $rsp = {}; - $rsp->{data}->[0] = "dhcp server is not running. please start the dhcp server."; + $rsp->{data}->[0] = $service_error; xCAT::MsgUtils->message("E", $rsp, $callback, 1); return; } @@ -1453,6 +1437,11 @@ sub process_request # if option is query then call listnode for each node and return if ($opt{q}) { + if ( $backend->name eq 'kea' ) { + kea_process_request($backend, $req, \%opt, {}, $verbose_on_off); + return; + } + # call listnode for each node requested foreach my $node (@{ $req->{node} }) { listnode($node, $callback); @@ -1560,6 +1549,11 @@ sub process_request xCAT::MsgUtils->trace($verbose_on_off, "d", "dhcp: sitelogservers=$sitelogservers sitentpservers=$sitentpservers sitenameservers=$sitenameservers site_domain=$site_domain"); } + if ( $backend->name eq 'kea' ) { + kea_process_request($backend, $req, \%opt, \%activenics, $verbose_on_off); + return; + } + @dhcpconf = (); @dhcp6conf = (); @@ -2150,6 +2144,1079 @@ sub process_request umask $oldmask; } +sub kea_process_request +{ + my ( $backend, $req, $opt, $activenics, $verbose_on_off ) = @_; + + if ($::XCATSITEVALS{externaldhcpservers}) { + xCAT::MsgUtils->trace($verbose_on_off, "d", "dhcp: remote dhcpservers configured, Kea backend has no local work"); + return; + } + + my $dhcplockfd; + mkdir "/tmp/xcat" unless -d "/tmp/xcat"; + open($dhcplockfd, ">", "/tmp/xcat/dhcplock"); + flock($dhcplockfd, LOCK_EX); + + if ($opt->{q}) { + my $loaded4 = $backend->load_dhcp4_config(); + if ($loaded4->{error}) { + $callback->({ error => [ $loaded4->{error} ], errorcode => [1] }); + flock($dhcplockfd, LOCK_UN); + return; + } + my $loaded6 = $backend->load_dhcp6_config(); + $loaded6 = undef if $loaded6->{error}; + foreach my $node (@{ $req->{node} || [] }) { + kea_query_node($backend, $loaded4, $node); + kea_query_node($backend, $loaded6, $node) if $loaded6; + } + flock($dhcplockfd, LOCK_UN); + return; + } + + my $intent4 = kea_build_dhcp4_intent($backend, $activenics); + if ($intent4->{error}) { + $callback->({ error => [ $intent4->{error} ], errorcode => [1] }); + flock($dhcplockfd, LOCK_UN); + return; + } + my $intent6 = kea_build_dhcp6_intent($backend, $activenics); + if ($intent6->{error}) { + $callback->({ error => [ $intent6->{error} ], errorcode => [1] }); + flock($dhcplockfd, LOCK_UN); + return; + } + my $using_dhcp6 = @{ $intent6->{subnets} || [] } ? 1 : 0; + my $ddns_intent = kea_build_ddns_intent(); + my $using_ddns = $ddns_intent && !$ddns_intent->{error} ? 1 : 0; + if ($ddns_intent && $ddns_intent->{error}) { + $callback->({ error => [ $ddns_intent->{error} ], errorcode => [1] }); + flock($dhcplockfd, LOCK_UN); + return; + } + if ($using_ddns) { + my $dhcp_ddns = kea_dhcp_ddns_section(); + $intent4->{'dhcp-ddns'} = $dhcp_ddns; + kea_apply_ddns_behavior($intent4); + $intent6->{'dhcp-ddns'} = $dhcp_ddns if $using_dhcp6; + kea_apply_ddns_behavior($intent6) if $using_dhcp6; + } + + if ($opt->{n}) { + my $result = $backend->write_dhcp4_config($intent4, backup_existing => 1); + if ($result->{error}) { + $callback->({ error => [ $result->{error} ], errorcode => [1] }); + flock($dhcplockfd, LOCK_UN); + return; + } + if ($using_dhcp6) { + my $dhcp6_result = $backend->write_dhcp6_config($intent6, backup_existing => 1); + if ($dhcp6_result->{error}) { + $callback->({ error => [ $dhcp6_result->{error} ], errorcode => [1] }); + flock($dhcplockfd, LOCK_UN); + return; + } + } + if ($using_ddns) { + my $ddns_result = $backend->write_ddns_config($ddns_intent, backup_existing => 1); + if ($ddns_result->{error}) { + $callback->({ error => [ $ddns_result->{error} ], errorcode => [1] }); + flock($dhcplockfd, LOCK_UN); + return; + } + } + if (kea_control_agent_enabled()) { + my $ca_result = $backend->write_ctrl_agent_config({ dhcp6 => $using_dhcp6, ddns => $using_ddns }); + if ($ca_result->{error}) { + $callback->({ error => [ $ca_result->{error} ], errorcode => [1] }); + flock($dhcplockfd, LOCK_UN); + return; + } + } + my $restart = $backend->restart_services(ipv6 => $using_dhcp6, ctrl_agent => kea_control_agent_enabled(), ddns => $using_ddns, enable => 1); + if ($restart->{error}) { + $callback->({ error => [ $restart->{error} ], errorcode => [1] }); + } + flock($dhcplockfd, LOCK_UN); + return; + } + + my $loaded4 = $backend->load_dhcp4_config(); + if ($loaded4->{error}) { + $callback->({ error => [ $loaded4->{error} ], errorcode => [1] }); + flock($dhcplockfd, LOCK_UN); + return; + } + if (!@{ $loaded4->{Dhcp4}{subnet4} || [] }) { + my $result = $backend->write_dhcp4_config($intent4); + if ($result->{error}) { + $callback->({ error => [ $result->{error} ], errorcode => [1] }); + flock($dhcplockfd, LOCK_UN); + return; + } + $loaded4 = $backend->load_dhcp4_config(); + } + if ($loaded4->{error}) { + $callback->({ error => [ $loaded4->{error} ], errorcode => [1] }); + flock($dhcplockfd, LOCK_UN); + return; + } + my $loaded6; + if ($using_dhcp6) { + $loaded6 = $backend->load_dhcp6_config(); + if ($loaded6->{error}) { + $callback->({ error => [ $loaded6->{error} ], errorcode => [1] }); + flock($dhcplockfd, LOCK_UN); + return; + } + if (!@{ $loaded6->{Dhcp6}{subnet6} || [] }) { + my $result = $backend->write_dhcp6_config($intent6); + if ($result->{error}) { + $callback->({ error => [ $result->{error} ], errorcode => [1] }); + flock($dhcplockfd, LOCK_UN); + return; + } + $loaded6 = $backend->load_dhcp6_config(); + } + } + + my $nodes = kea_expand_request_nodes($req, $opt); + unless ($nodes) { + flock($dhcplockfd, LOCK_UN); + return; + } + + my @deleted4; + my @deleted6; + my $reservations4 = []; + my $reservations6 = []; + if ($opt->{d}) { + foreach my $match (@{ kea_reservation_matches_for_nodes($nodes) }) { + push @deleted4, @{ $backend->delete_reservations($loaded4, $match) }; + push @deleted6, @{ $backend->delete_reservations($loaded6, $match) } if $loaded6; + } + } else { + $reservations4 = kea_build_node_reservations($backend, $loaded4, $nodes); + $backend->upsert_reservations($loaded4, $reservations4); + if ($loaded6) { + $reservations6 = kea_build_node_reservations6($backend, $loaded6, $nodes); + $backend->upsert_reservations($loaded6, $reservations6); + } + } + + my $result = $backend->write_dhcp4_json( $backend->encode_config($loaded4) ); + if ($result->{error}) { + $callback->({ error => [ $result->{error} ], errorcode => [1] }); + flock($dhcplockfd, LOCK_UN); + return; + } + if ($loaded6) { + my $dhcp6_result = $backend->write_dhcp6_json( $backend->encode_config($loaded6) ); + if ($dhcp6_result->{error}) { + $callback->({ error => [ $dhcp6_result->{error} ], errorcode => [1] }); + flock($dhcplockfd, LOCK_UN); + return; + } + } + + my $live_ok = 0; + if (kea_control_agent_live_enabled($backend)) { + my $live4 = $opt->{d} + ? $backend->live_delete_reservations(\@deleted4, service => ['dhcp4']) + : $backend->live_upsert_reservations($reservations4, service => ['dhcp4']); + my $live6 = { ok => 1 }; + if ($loaded6) { + $live6 = $opt->{d} + ? $backend->live_delete_reservations(\@deleted6, service => ['dhcp6']) + : $backend->live_upsert_reservations($reservations6, service => ['dhcp6']); + } + $live_ok = !$live4->{error} && !$live6->{error}; + if (!$live_ok) { + my $why = $live4->{error} || $live6->{error}; + $callback->({ warning => ["Kea Control Agent host update failed, restarting Kea services instead: $why"] }); + } + } + + unless ($live_ok) { + my $restart = $backend->restart_services(ipv6 => $using_dhcp6, ctrl_agent => kea_control_agent_enabled(), ddns => $using_ddns); + if ($restart->{error}) { + $callback->({ error => [ $restart->{error} ], errorcode => [1] }); + } + } + + flock($dhcplockfd, LOCK_UN); +} + +sub kea_build_dhcp4_intent +{ + my ($backend, $activenics) = @_; + + %dynamicranges = (); + %netcfgs = (); + @alldomains = (); + + my @interfaces = grep { $_ ne '!remote!' && $_ !~ /!remote!/ } sort keys %$activenics; + @interfaces = ('*') unless @interfaces; + + my $httpport = "80"; + my @hports = xCAT::TableUtils->get_site_attribute("httpport"); + if ($hports[0]) { + $httpport = $hports[0]; + } + + my $nettab = xCAT::Table->new("networks"); + return { error => "Unable to open networks table, please run makenetworks" } unless $nettab; + + my @vnets = $nettab->getAllAttribs('net', 'mgtifname', 'mask', 'dynamicrange', 'nameservers', 'ddnsdomain', 'domain'); + my @doms = $nettab->getAllAttribs('domain'); + foreach my $netdom (@doms) { + if ($netdom->{domain}) { + push(@alldomains, $netdom->{domain}) unless grep(/^$netdom->{domain}$/, @alldomains); + } + } + if ($site_domain) { + push(@alldomains, $site_domain) unless grep(/^$site_domain$/, @alldomains); + } + + foreach (@vnets) { + addrangedetection($_); + } + + my @routes = kea_ipv4_routes(@vnets); + my @subnets; + my $id = 1; + foreach my $route (@routes) { + my ( $net, $netif, $mask, $flags ) = @$route; + next if kea_skip_ipv4_network($net); + next if defined($flags) && $flags =~ /G/; + + my $interface = $netif; + my $remote = 0; + if ($interface =~ /!remote!\S*/) { + $remote = 1; + $interface =~ s/!remote!\s*(.*)$/$1/; + next unless $activenics->{'!remote!'}; + } else { + next unless $activenics->{$interface}; + } + + my $subnet = kea_subnet4_intent($nettab, $net, $mask, $interface, $remote, $id, $httpport); + return $subnet if $subnet->{error}; + push @subnets, $subnet; + $id++; + } + $nettab->close; + + my $intent = { + interfaces => \@interfaces, + valid_lifetime => kea_dhcp_lease_time(), + 'option-def' => kea_option_defs(), + 'option-data' => kea_global_option_data(), + 'client-classes' => kea_boot_client_classes(), + subnets => \@subnets, + }; + + if (kea_control_agent_enabled()) { + $intent->{'control-socket'} = { + 'socket-type' => 'unix', + 'socket-name' => '/var/run/kea/kea4-ctrl-socket', + }; + my $hook = $backend->host_cmds_hook_path(); + if ($hook) { + $intent->{'hooks-libraries'} = [ { library => $hook } ]; + } else { + $callback->({ warning => ["Kea Control Agent was requested, but libdhcp_host_cmds.so was not found. Host reservations will use JSON render and reload."] }); + } + } + + return $intent; +} + +sub kea_build_dhcp6_intent +{ + my ($backend, $activenics) = @_; + + my @interfaces = grep { $_ ne '!remote!' && $_ !~ /!remote!/ } sort keys %$activenics; + @interfaces = ('*') unless @interfaces; + + my $nettab = xCAT::Table->new("networks"); + return { error => "Unable to open networks table, please run makenetworks" } unless $nettab; + + my @vnets = $nettab->getAllAttribs('net', 'mgtifname', 'mask', 'dynamicrange', 'nameservers', 'ddnsdomain', 'domain'); + my @subnets; + my $id = 10001; + foreach my $entry (@vnets) { + next unless $entry->{net} && $entry->{net} =~ /:/; + my $interface = $entry->{mgtifname} || '*'; + my $remote = 0; + if ($interface =~ /!remote!\S*/) { + $remote = 1; + $interface =~ s/!remote!\s*(.*)$/$1/; + next if %$activenics && !$activenics->{'!remote!'}; + } elsif (%$activenics && !$activenics->{$interface} && $interfaces[0] ne '*') { + next; + } + + my $subnet = kea_subnet6_intent($entry, $interface, $remote, $id); + return $subnet if $subnet->{error}; + push @subnets, $subnet; + $id++; + } + $nettab->close; + + my $intent = { + interfaces => \@interfaces, + preferred_lifetime => kea_dhcp_lease_time(), + valid_lifetime => kea_dhcp_lease_time(), + subnets => \@subnets, + }; + + if (kea_control_agent_enabled()) { + $intent->{'control-socket'} = { + 'socket-type' => 'unix', + 'socket-name' => '/var/run/kea/kea6-ctrl-socket', + }; + my $hook = $backend->host_cmds_hook_path(); + if ($hook) { + $intent->{'hooks-libraries'} = [ { library => $hook } ]; + } + } + + return $intent; +} + +sub kea_subnet6_intent +{ + my ( $entry, $interface, $remote, $id ) = @_; + + my $net = $entry->{net}; + my $prefix; + if ($net =~ m{^(.+)/(\d+)$}) { + $prefix = $2; + } elsif (defined $entry->{mask} && $entry->{mask} =~ /^(\d+)$/) { + $prefix = $1; + $net = "$net/$prefix"; + } else { + return { error => "IPv6 network $net must include a prefix length for Kea DHCPv6." }; + } + + my $domain = $entry->{domain} || $site_domain; + my @option_data; + if ($domain) { + push @option_data, { name => 'domain-search', data => $domain }; + } + my $nameservers = $entry->{nameservers} || $sitenameservers; + if ($nameservers && $nameservers =~ /:/) { + push @option_data, { name => 'dns-servers', data => $nameservers }; + } + + my %subnet = ( + id => $id, + subnet => $net, + dynamicrange => $entry->{dynamicrange}, + option_data => \@option_data, + ); + $subnet{interface} = $interface unless $remote || $interface eq '*'; + + return \%subnet; +} + +sub kea_build_ddns_intent +{ + return unless kea_ddns_enabled(); + + my $nettab = xCAT::Table->new("networks"); + return { error => "Unable to open networks table, please run makenetworks" } unless $nettab; + + my @vnets = $nettab->getAllAttribs('net', 'mask', 'nameservers', 'ddnsdomain', 'domain'); + $nettab->close; + + my ( $key_algorithm, $key_secret ) = kea_ddns_key(); + return { error => "Unable to find DDNS key material for Kea D2. Run makedns with dnshandler=ddns first." } unless $key_secret; + + my @tsig_keys = ( + { + name => 'xcat_key', + algorithm => $key_algorithm, + secret => $key_secret, + } + ); + + my ( %forward_seen, %reverse_seen, @forward, @reverse ); + foreach my $entry (@vnets) { + my $dns = $entry->{nameservers} || $sitenameservers || ''; + $dns =~ s/,.*//; + next unless $dns; + + my $domain = $entry->{ddnsdomain} || $entry->{domain} || $site_domain; + if ($domain) { + $domain .= '.' unless $domain =~ /\.$/; + if (!$forward_seen{$domain}++) { + push @forward, kea_ddns_domain($domain, $dns); + } + } + + my $zone_mask = $entry->{net} =~ /:/ ? undef : $entry->{mask}; + foreach my $zone (getzonesfornet($entry->{net}, $zone_mask)) { + $zone .= '.' unless $zone =~ /\.$/; + if (!$reverse_seen{$zone}++) { + push @reverse, kea_ddns_domain($zone, $dns); + } + } + } + + return { + 'tsig-keys' => \@tsig_keys, + forward_domains => \@forward, + reverse_domains => \@reverse, + }; +} + +sub kea_ddns_domain +{ + my ( $name, $server ) = @_; + + return { + name => $name, + 'key-name' => 'xcat_key', + 'dns-servers' => [ + { + 'ip-address' => $server, + port => 53, + } + ], + }; +} + +sub kea_dhcp_ddns_section +{ + return { + 'enable-updates' => JSON::true, + 'server-ip' => '127.0.0.1', + 'server-port' => 53001, + 'ncr-protocol' => 'UDP', + 'ncr-format' => 'JSON', + }; +} + +sub kea_apply_ddns_behavior +{ + my ($intent) = @_; + + $intent->{'ddns-send-updates'} = JSON::true; + $intent->{'ddns-override-no-update'} = JSON::true; + $intent->{'ddns-override-client-update'} = JSON::true; + $intent->{'ddns-qualifying-suffix'} = $site_domain ? "$site_domain." : 'xcat.local.'; + $intent->{'ddns-update-on-renew'} = JSON::true; +} + +sub kea_ddns_enabled +{ + return defined($::XCATSITEVALS{dnshandler}) && $::XCATSITEVALS{dnshandler} =~ /ddns/ ? 1 : 0; +} + +sub kea_ddns_key +{ + my $key_path = "/etc/xcat/ddns.key"; + if (open(my $fh, '<', $key_path)) { + local $/; + my $contents = <$fh>; + close($fh); + my ($algorithm) = $contents =~ /algorithm\s+([A-Za-z0-9-]+)\s*;/; + my ($secret) = $contents =~ /secret\s+"([^"]+)"/; + $algorithm ||= 'HMAC-SHA256'; + $algorithm = uc($algorithm); + $algorithm =~ s/^HMAC-/HMAC-/; + return ($algorithm, $secret) if $secret; + } + + my $passtab = xCAT::Table->new('passwd'); + my $pent = $passtab ? $passtab->getAttribs({ key => 'omapi', username => 'xcat_key' }, ['password']) : undef; + return ('HMAC-SHA256', $pent->{password}) if $pent && $pent->{password}; + return; +} + +sub kea_ipv4_routes +{ + my @vnets = @_; + my @routes; + + my $ipcmd = kea_command_path('ip'); + if ($ipcmd) { + my @route_output = split /\n/, `$ipcmd -4 route show 2>/dev/null`; + foreach my $line (@route_output) { + if ($line =~ /^default\b/ && $line =~ /\bdev\s+(\S+)/) { + push @routes, [ '0.0.0.0', $1, '0.0.0.0', 'G' ]; + next; + } + next unless $line =~ /^(\d+(?:\.\d+){3})\/(\d+)\b.*\bdev\s+(\S+)/; + push @routes, [ $1, $3, kea_prefix_to_mask($2), '' ]; + } + } else { + my $netstat = kea_command_path('netstat'); + if ($netstat) { + my @nsrnoutput = split /\n/, `$netstat -rn 2>/dev/null`; + splice @nsrnoutput, 0, 2; + foreach (@nsrnoutput) { + my @parts = split /\s+/; + next unless $parts[0] && $parts[2] && $parts[7]; + push @routes, [ $parts[0], $parts[7], $parts[2], $parts[3] ]; + } + } + } + + foreach (@vnets) { + my $n = $_->{net}; + my $if = $_->{mgtifname}; + my $nm = $_->{mask}; + if ($if =~ /!remote!/ and $n !~ /:/) { + push @routes, [ $n, $if, $nm, '' ]; + } + } + + return @routes; +} + +sub kea_command_path +{ + my ($command) = @_; + + foreach my $dir (split /:/, $ENV{PATH} || '') { + next unless $dir; + my $path = "$dir/$command"; + return $path if -x $path; + } + + foreach my $path ( "/usr/sbin/$command", "/usr/bin/$command", "/sbin/$command", "/bin/$command" ) { + return $path if -x $path; + } + + return; +} + +sub kea_subnet4_intent +{ + my ( $nettab, $net, $mask, $interface, $remote, $id, $httpport ) = @_; + + my @myipd = xCAT::NetworkUtils->my_ip_facing($net); + my $myip; + unless ($myipd[0]) { $myip = $myipd[1]; } + + my ($ent) = $nettab->getAttribs( + { net => $net, mask => $mask }, + qw(tftpserver nameservers ntpservers logservers gateway dynamicrange dhcpserver domain mtu) + ); + + my $ntpservers = $ent && $ent->{ntpservers} ? $ent->{ntpservers} : $sitentpservers; + my $logservers = $ent && $ent->{logservers} ? $ent->{logservers} : $sitelogservers; + my $domain = $ent && $ent->{domain} ? $ent->{domain} : $site_domain; + return { error => "No domain defined for $net entry in networks table, and no domain defined in site table." } unless $domain; + + my $nameservers; + if ($ent and $ent->{nameservers}) { + $nameservers = $ent->{nameservers}; + } elsif ($sitenameservers) { + $nameservers = $sitenameservers; + } + $nameservers =~ s//$myip/g if $nameservers; + + if (!$ntpservers || ($ntpservers eq '')) { + $ntpservers = $myip; + } + + $nameservers = putmyselffirst($nameservers) if $nameservers; + $ntpservers = putmyselffirst($ntpservers) if $ntpservers; + $logservers = putmyselffirst($logservers) if $logservers; + + my $tftp = $ent && $ent->{tftpserver} ? $ent->{tftpserver} : undef; + if (!$tftp || ($tftp eq '')) { + $tftp = $myip; + } + + my $gateway = $ent && $ent->{gateway} ? $ent->{gateway} : undef; + if ($gateway && $gateway eq '') { + $gateway = xCAT::NetworkUtils->ip_forwarding_enabled() ? $myip : ''; + } + if ($gateway) { + my $maskn = unpack("N", inet_aton($mask)); + my $netn = unpack("N", inet_aton($net)); + my $gaten = unpack("N", inet_aton($gateway)); + if (($gaten & $maskn) != ($maskn & $netn)) { + return { error => "Specified gateway $gateway is not valid for $net/$mask, must be on same network" }; + } + } + + my @option_data; + push @option_data, { name => 'routers', data => $gateway } if $gateway; + push @option_data, { name => 'log-servers', data => $logservers || $myip } if $logservers || $myip; + push @option_data, { name => 'ntp-servers', data => $ntpservers } if $ntpservers; + if ($nameservers) { + push @option_data, { name => 'domain-name', data => $domain }; + push @option_data, { name => 'domain-name-servers', data => $nameservers }; + } + push @option_data, { name => 'interface-mtu', data => $ent->{mtu} } if $ent && $ent->{mtu}; + push @option_data, { name => 'cumulus-provision-url', data => "http://$tftp:$httpport/install/postscripts/cumulusztp" } if $tftp; + + my $domainstring = join(', ', map { $_ eq $domain ? $_ : $_ } grep { $_ } @alldomains); + push @option_data, { name => 'domain-search', data => $domainstring } if $domainstring; + + my $prefix = kea_mask_to_prefix($mask); + my %subnet = ( + id => $id, + subnet => "$net/$prefix", + dynamicrange => $ent ? $ent->{dynamicrange} : undef, + option_data => \@option_data, + next_server => $tftp, + ); + $subnet{interface} = $interface unless $remote; + + return \%subnet; +} + +sub kea_expand_request_nodes +{ + my ( $req, $opt ) = @_; + + my @nodes; + if ($req->{node}) { + my $typehash = xCAT::DBobjUtils->getnodetype(\@{ $req->{node} }); + foreach my $node (@{ $req->{node} }) { + my $ntype = $$typehash{$node}; + if ($ntype =~ /^(cec|frame)$/) { + my $children = xCAT::DBobjUtils->getchildren($node); + push @nodes, @$children; + } else { + push @nodes, $node; + } + } + return \@nodes; + } + + return unless $opt->{a}; + if ($opt->{d}) { + my $nodelist = xCAT::Table->new('nodelist'); + my @entries = ($nodelist->getAllNodeAttribs([qw(node)])); + my @nodeentries = map { $_->{node} } @entries; + my $typehash = xCAT::DBobjUtils->getnodetype(\@nodeentries); + foreach (@entries) { + my $ntype = $$typehash{ $_->{node} }; + push @nodes, $_->{node} unless $ntype =~ /^(cec|frame)$/; + } + } else { + my $mactab = xCAT::Table->new('mac'); + my @entries = $mactab ? ($mactab->getAllNodeAttribs([qw(mac)])) : (); + foreach (@entries) { + push @nodes, $_->{node}; + } + } + + return \@nodes; +} + +sub kea_build_node_reservations +{ + my ( $backend, $config, $nodes ) = @_; + + my $nrtab = xCAT::Table->new('noderes'); + my $chaintab = xCAT::Table->new('chain'); + my $nodetypetab = xCAT::Table->new('nodetype', -create => 0); + my $iscsitab = xCAT::Table->new('iscsi', -create => 0); + my $mactab = xCAT::Table->new('mac'); + + $chainents = $chaintab ? $chaintab->getNodesAttribs($nodes, ['currstate']) : undef; + $nrhash = $nrtab->getNodesAttribs($nodes, [ 'tftpserver', 'netboot', 'proxydhcp', 'xcatmaster', 'servicenode' ]); + $nodetypeents = $nodetypetab ? $nodetypetab->getNodesAttribs($nodes, [qw(os provmethod arch)]) : undef; + $iscsients = $iscsitab ? $iscsitab->getNodesAttribs($nodes, [qw(server target lun iname)]) : undef; + $machash = $mactab ? $mactab->getNodesAttribs($nodes, ['mac']) : undef; + + my @reservations; + foreach my $node (@$nodes) { + push @reservations, @{ kea_node_reservations($backend, $config, $node) }; + } + + return \@reservations; +} + +sub kea_build_node_reservations6 +{ + my ( $backend, $config, $nodes ) = @_; + + my $nrtab = xCAT::Table->new('noderes'); + my $mactab = xCAT::Table->new('mac'); + my $vpdtab = xCAT::Table->new('vpd', -create => 0); + + $nrhash = $nrtab ? $nrtab->getNodesAttribs($nodes, [ 'tftpserver', 'xcatmaster', 'servicenode' ]) : undef; + $machash = $mactab ? $mactab->getNodesAttribs($nodes, ['mac']) : undef; + $vpdhash = $vpdtab ? $vpdtab->getNodesAttribs($nodes, ['uuid']) : undef; + + my @reservations; + foreach my $node (@$nodes) { + push @reservations, @{ kea_node_reservations6($backend, $config, $node) }; + } + + return \@reservations; +} + +sub kea_node_reservations +{ + my ( $backend, $config, $node ) = @_; + + my $nrent = $nrhash && $nrhash->{$node} ? $nrhash->{$node}->[0] : undef; + my $chainent = $chainents && $chainents->{$node} ? $chainents->{$node}->[0] : undef; + my $ntent = $nodetypeents && $nodetypeents->{$node} ? $nodetypeents->{$node}->[0] : undef; + my $ient = $iscsients && $iscsients->{$node} ? $iscsients->{$node}->[0] : undef; + my $macent = $machash && $machash->{$node} ? $machash->{$node}->[0] : undef; + + unless ($macent and $macent->{mac}) { + $callback->({ warning => ["Unable to find mac address for $node"] }); + return []; + } + + my ( $nxtsrv, $tftpserver ) = kea_next_server_for_node($node, $nrent); + my @reservations; + my @macs = split(/\|/, $macent->{mac}); + foreach my $mace (@macs) { + my ( $mac, $hname ) = split(/!/, $mace); + $hname ||= $node; + next unless $mac; + if ($mac !~ /^[0-9a-fA-F]{2}(-[0-9a-fA-F]{2}){5,8}$|^[0-9a-fA-F]{2}(:[0-9a-fA-F]{2}){5,8}$/) { + $callback->({ error => ["Invalid mac address $mac for $node"], errorcode => [1] }); + next; + } + if (!grep /:/, $mac) { + $mac = lc($mac); + $mac =~ s/(\w{2})/$1:/g; + $mac =~ s/:$//; + } + + my $ip = getipaddr($hname, OnlyV4 => 1); + next unless $ip; + + my $subnet_id = $backend->subnet_id_for_ip($config, $ip); + unless ($subnet_id) { + $callback->({ warning => ["Unable to find a Kea subnet for $node ($ip), skipping DHCP reservation"] }); + next; + } + + my %reservation = ( + 'subnet-id' => $subnet_id, + 'hw-address' => lc($mac), + hostname => $hname, + ); + $reservation{'ip-address'} = $ip unless ipIsDynamic($ip); + $reservation{'next-server'} = $nxtsrv if $nxtsrv && $nxtsrv !~ /\$\{/; + + my $boot = kea_boot_for_node($node, $nrent, $chainent, $ntent, $ient, $nxtsrv); + $reservation{'boot-file-name'} = $boot->{'boot-file-name'} if defined $boot->{'boot-file-name'}; + $reservation{'option-data'} = $boot->{'option-data'} if @{ $boot->{'option-data'} || [] }; + + push @reservations, \%reservation; + } + + return \@reservations; +} + +sub kea_node_reservations6 +{ + my ( $backend, $config, $node ) = @_; + + my $macent = $machash && $machash->{$node} ? $machash->{$node}->[0] : undef; + my $vpdent = $vpdhash && $vpdhash->{$node} ? $vpdhash->{$node}->[0] : undef; + unless ($macent and $macent->{mac}) { + $callback->({ warning => ["Unable to find mac address for $node"] }); + return []; + } + + my $duid = kea_duid_from_uuid($vpdent ? $vpdent->{uuid} : undef); + my @reservations; + foreach my $mace (split(/\|/, $macent->{mac})) { + my ( $mac, $hname ) = split(/!/, $mace); + $hname ||= $node; + next unless $mac; + if (!grep /:/, $mac) { + $mac = lc($mac); + $mac =~ s/(\w{2})/$1:/g; + $mac =~ s/:$//; + } + + my $ip = getipaddr($hname, OnlyV6 => 1); + next unless $ip; + + my $subnet_id = $backend->subnet_id_for_ip($config, $ip); + unless ($subnet_id) { + $callback->({ warning => ["Unable to find a Kea DHCPv6 subnet for $node ($ip), skipping DHCPv6 reservation"] }); + next; + } + + my %reservation = ( + 'subnet-id' => $subnet_id, + hostname => $hname, + ); + if ($duid) { + $reservation{duid} = $duid; + } else { + $reservation{'hw-address'} = lc($mac); + } + $reservation{'ip-addresses'} = [$ip] unless ipIsDynamic($ip); + + push @reservations, \%reservation; + } + + return \@reservations; +} + +sub kea_duid_from_uuid +{ + my ($uuid) = @_; + + return unless $uuid; + $uuid =~ s/[^0-9a-fA-F]//g; + return unless length($uuid) == 32; + $uuid =~ s/(..)/$1:/g; + $uuid =~ s/:$//; + return "00:04:$uuid"; +} + +sub kea_reservation_matches_for_nodes +{ + my ($nodes) = @_; + + my $mactab = xCAT::Table->new('mac'); + my $machash_local = $mactab ? $mactab->getNodesAttribs($nodes, ['mac']) : undef; + my $vpdtab = xCAT::Table->new('vpd', -create => 0); + my $vpdhash_local = $vpdtab ? $vpdtab->getNodesAttribs($nodes, ['uuid']) : undef; + my @matches; + + foreach my $node (@$nodes) { + push @matches, { hostname => $node }; + my $ip = getipaddr($node, OnlyV4 => 1); + push @matches, { 'ip-address' => $ip } if $ip; + my $ip6 = getipaddr($node, OnlyV6 => 1); + push @matches, { 'ip-address' => $ip6 } if $ip6; + my $vpdent = $vpdhash_local && $vpdhash_local->{$node} ? $vpdhash_local->{$node}->[0] : undef; + my $duid = kea_duid_from_uuid($vpdent ? $vpdent->{uuid} : undef); + push @matches, { duid => $duid } if $duid; + my $macent = $machash_local && $machash_local->{$node} ? $machash_local->{$node}->[0] : undef; + next unless $macent && $macent->{mac}; + foreach my $mace (split(/\|/, $macent->{mac})) { + my ( $mac, $hname ) = split(/!/, $mace); + if ($hname) { + push @matches, { hostname => $hname }; + my $host_ip = getipaddr($hname, OnlyV4 => 1); + push @matches, { 'ip-address' => $host_ip } if $host_ip; + my $host_ip6 = getipaddr($hname, OnlyV6 => 1); + push @matches, { 'ip-address' => $host_ip6 } if $host_ip6; + } + next unless $mac; + if (!grep /:/, $mac) { + $mac = lc($mac); + $mac =~ s/(\w{2})/$1:/g; + $mac =~ s/:$//; + } + push @matches, { 'hw-address' => lc($mac) }; + } + } + + return \@matches; +} + +sub kea_query_node +{ + my ( $backend, $config, $node ) = @_; + + my @matches = @{ kea_reservation_matches_for_nodes([$node]) }; + my @found; + foreach my $match (@matches) { + push @found, @{ $backend->query_reservations($config, $match) }; + } + + my %seen; + foreach my $reservation (@found) { + my $key = join('|', map { $reservation->{$_} || '' } qw(subnet-id hw-address ip-address hostname)); + next if $seen{$key}++; + my $msg = "$node:"; + $msg .= " ip-address = $reservation->{'ip-address'}" if $reservation->{'ip-address'}; + $msg .= " hardware-address = $reservation->{'hw-address'}" if $reservation->{'hw-address'}; + $msg .= " hostname = $reservation->{hostname}" if $reservation->{hostname}; + $callback->({ data => [$msg] }); + } +} + +sub kea_next_server_for_node +{ + my ( $node, $nrent ) = @_; + + if ($nrent and $nrent->{tftpserver} and $nrent->{tftpserver} ne '') { + my $tmp_name = inet_aton($nrent->{tftpserver}); + unless ($tmp_name) { + $callback->({ error => ["Unable to resolve the tftpserver for node"], errorcode => [1] }); + return; + } + my $server = inet_ntoa($tmp_name); + return ($server, $server); + } + + my $node_server = $nrent && $nrent->{xcatmaster} ? $nrent->{xcatmaster} : undef; + if ($node_server) { + my $tmp_server = inet_aton($node_server); + if ($tmp_server) { + my $server = inet_ntoa($tmp_server); + return ($server, $server); + } + } + + my @nxtsrvd = xCAT::NetworkUtils->my_ip_facing($node); + unless ($nxtsrvd[0]) { + return ($nxtsrvd[1], $nxtsrvd[1]); + } + + return ('${next-server}', undef); +} + +sub kea_boot_for_node +{ + my ( $node, $nrent, $chainent, $ntent, $ient, $nxtsrv ) = @_; + + my $httpport = "80"; + my @hports = xCAT::TableUtils->get_site_attribute("httpport"); + if ($hports[0]) { + $httpport = $hports[0]; + } + + my %boot = ( 'option-data' => [] ); + my $netboot = $nrent ? $nrent->{netboot} : undef; + + if ($ient and $ient->{server} and $ient->{target}) { + $ient->{lun} = 0 unless defined($ient->{lun}); + my $rootpath = 'iscsi:' . $ient->{server} . ':6:3260:' . $ient->{lun} . ':' . $ient->{target}; + push @{ $boot{'option-data'} }, { name => 'root-path', data => $rootpath }; + push @{ $boot{'option-data'} }, { name => 'iscsi-initiator-iqn', data => $ient->{iname} } if defined($ient->{iname}); + } + + if ($netboot and $netboot eq 'pxe') { + $boot{'boot-file-name'} = 'pxelinux.0'; + } elsif ($netboot and $netboot eq 'yaboot') { + $boot{'boot-file-name'} = "/yb/node/yaboot-$node"; + } elsif ($netboot and $netboot =~ /^grub2[-]?.*$/) { + $boot{'boot-file-name'} = "/boot/grub2/grub2-$node"; + } elsif ($netboot and $netboot eq 'petitboot') { + if ($nxtsrv) { + my $petitboot_conf = "http://$nxtsrv:$httpport/tftpboot/petitboot/$node"; + $boot{'boot-file-name'} = $petitboot_conf; + push @{ $boot{'option-data'} }, { name => 'conf-file', data => $petitboot_conf }; + } + } elsif ($netboot and $netboot eq 'onie') { + my $onie_url = kea_onie_url_for_node($node, $ntent, $nxtsrv, $httpport); + push @{ $boot{'option-data'} }, { name => 'www-server', data => $onie_url } if $onie_url; + } + + push @{ $boot{'option-data'} }, { name => 'host-name', data => $node }; + return \%boot; +} + +sub kea_onie_url_for_node +{ + my ( $node, $ntent, $nxtsrv, $httpport ) = @_; + + return unless $nxtsrv; + my $provmethod = $ntent ? $ntent->{provmethod} : undef; + return "http://$nxtsrv:$httpport/install/onie/onie-installer" unless $provmethod; + + my $linuximagetab = xCAT::Table->new('linuximage'); + my $imagetab = $linuximagetab ? $linuximagetab->getAttribs({ imagename => $provmethod }, 'pkgdir') : undef; + return "http://$nxtsrv:$httpport/install/onie/onie-installer" unless $imagetab; + + foreach my $pkgdir (split(/,/, $imagetab->{pkgdir} || '')) { + return "http://$nxtsrv:$httpport$pkgdir" if -f $pkgdir; + } + + $callback->({ warning => ["osimage $provmethod pkgdir doesn't exists"]}); + return; +} + +sub kea_boot_client_classes +{ + return xCAT::DHCP::BootPolicy->kea_client_classes( + xnba_kpxe => -f "$tftpdir/xcat/xnba.kpxe" ? 1 : 0, + xnba_efi => -f "$tftpdir/xcat/xnba.efi" ? 1 : 0, + ); +} + +sub kea_option_defs +{ + return [ + { name => 'conf-file', code => 209, type => 'string', space => 'dhcp4' }, + { name => 'iscsi-initiator-iqn', code => 203, type => 'string', space => 'dhcp4' }, + { name => 'cumulus-provision-url', code => 239, type => 'string', space => 'dhcp4' }, + ]; +} + +sub kea_global_option_data +{ + my @options; + if ($::XCATSITEVALS{timezone}) { + push @options, { name => 'tcode', data => $::XCATSITEVALS{timezone} }; + } + return \@options; +} + +sub kea_dhcp_lease_time +{ + return $::XCATSITEVALS{'dhcplease'} if defined $::XCATSITEVALS{'dhcplease'} && $::XCATSITEVALS{'dhcplease'} ne ""; + return 43200; +} + +sub kea_control_agent_enabled +{ + my @entries = xCAT::TableUtils->get_site_attribute("keacontrolagent"); + my $value = $entries[0]; + return 0 unless defined($value); + return $value =~ /^(1|yes|y|true|enabled)$/i ? 1 : 0; +} + +sub kea_control_agent_live_enabled +{ + my ($backend) = @_; + + return 0 unless kea_control_agent_enabled(); + return $backend->host_cmds_hook_path() ? 1 : 0; +} + +sub kea_mask_to_prefix +{ + my ($mask) = @_; + + my $maskn = unpack("N", inet_aton($mask)); + my $bits = 0; + for my $idx (0 .. 31) { + $bits++ if $maskn & (1 << (31 - $idx)); + } + + return $bits; +} + +sub kea_prefix_to_mask +{ + my ($prefix) = @_; + + return '0.0.0.0' unless defined($prefix) && $prefix =~ /^\d+$/ && $prefix > 0 && $prefix <= 32; + my $maskn = (0xffffffff << (32 - $prefix)) & 0xffffffff; + return inet_ntoa(pack("N", $maskn)); +} + +sub kea_skip_ipv4_network +{ + my ($net) = @_; + + my $firstoctet = $net; + $firstoctet =~ s/^(\d+)\..*/$1/; + return 1 if $net eq "169.254.0.0"; + return 1 if $net eq "127.0.0.0" || $net eq '127'; + return 1 if ($firstoctet >= 224 and $firstoctet <= 239); + return 0; +} + # Restart dhcpd on aix sub restart_dhcpd_aix { @@ -2572,8 +3639,7 @@ sub addnet unless ($ent->{dhcpserver} and xCAT::NetworkUtils->thishostisnot($ent->{dhcpserver})) { #If specific, only one dhcp server gets a dynamic range - $range = $ent->{dynamicrange}; - $range =~ s/[,-]/ /g; + $range = join ';', xCAT::DHCP::Range->isc_ranges($ent->{dynamicrange}); } } else diff --git a/xCAT-server/sbin/xcatconfig b/xCAT-server/sbin/xcatconfig index 7535d91b3..a409f1437 100755 --- a/xCAT-server/sbin/xcatconfig +++ b/xCAT-server/sbin/xcatconfig @@ -23,6 +23,7 @@ BEGIN use lib "$::XCATROOT/lib/perl"; use strict; use xCAT::Utils; +use xCAT::DHCP::Backend; use xCAT::NetworkUtils; use Getopt::Long; use xCAT::MsgUtils; @@ -489,9 +490,12 @@ if ($::INITIALINSTALL || $::FORCE) &makenetworks; &setuphttp; - # chkconfig dhcpd on - #system("chkconfig dhcpd on"); - xCAT::Utils->enableservice("dhcp"); + my $dhcpbackend = xCAT::DHCP::Backend->new_backend(); + if (ref($dhcpbackend) ne 'HASH' && $dhcpbackend->name eq 'kea') { + xCAT::Utils->enableservice("kea-dhcp4"); + } else { + xCAT::Utils->enableservice("dhcp"); + } # Turn off selinux on RedHat if (-f "/etc/redhat-release") @@ -575,7 +579,7 @@ if ($::INITIALINSTALL || $::FORCE) my $linux_note = -"xCAT is now running, it is recommended to tabedit networks \nand set a dynamic ip address range on any networks where nodes \nare to be discovered. Then, run makedhcp -n to create a new dhcpd \nconfiguration file, and \/etc\/init.d\/dhcpd restart. Either examine sample \nconfiguration templates, or write your own, or specify a value per \nnode with nodeadd or tabedit."; +"xCAT is now running, it is recommended to tabedit networks \nand set a dynamic ip address range on any networks where nodes \nare to be discovered. Then, run makedhcp -n to create a new DHCP \nconfiguration file, and restart the selected DHCP service. Either examine sample \nconfiguration templates, or write your own, or specify a value per \nnode with nodeadd or tabedit."; xCAT::MsgUtils->message('I', $linux_note); } else { #AIX diff --git a/xCAT-server/sbin/xcatsnap b/xCAT-server/sbin/xcatsnap index 829c9a02e..b2b01c1ce 100755 --- a/xCAT-server/sbin/xcatsnap +++ b/xCAT-server/sbin/xcatsnap @@ -213,6 +213,9 @@ sub snap_it { "/etc/dhcp/dhcpd.conf", "/etc/dhcp/dhcpd6.conf", "/etc/dhcpd6.conf", "/var/lib/dhcp/db/dhcpd.leases", "/var/lib/dhcp/dhcpd.leases","/etc/goconserver/server.conf", + "/etc/kea/kea-dhcp4.conf", "/etc/kea/kea-dhcp6.conf", + "/etc/kea/kea-ctrl-agent.conf", "/etc/kea/kea-dhcp-ddns.conf", + "/var/lib/kea/kea-leases4.csv", "/var/lib/kea/kea-leases6.csv", "/var/log/goconserver/*", "/etc/logrotate.d/goconserver", "/etc/bind/named.conf"); print("@files_array \n"); diff --git a/xCAT-server/share/xcat/hamn/deactivate-mn b/xCAT-server/share/xcat/hamn/deactivate-mn index e4b61d265..20ed88365 100755 --- a/xCAT-server/share/xcat/hamn/deactivate-mn +++ b/xCAT-server/share/xcat/hamn/deactivate-mn @@ -133,7 +133,16 @@ if [ `uname` = "AIX" ] then runcmd "stopsrc -s dhcpcd" else # Linux - runcmd "service dhcpd stop" + if command -v systemctl >/dev/null 2>&1 + then + runcmd "systemctl stop kea-ctrl-agent kea-dhcp-ddns kea-dhcp6 kea-dhcp4 dhcpd 2>/dev/null || true" + else + runcmd "service kea-ctrl-agent stop 2>/dev/null || true" + runcmd "service kea-dhcp-ddns stop 2>/dev/null || true" + runcmd "service kea-dhcp6 stop 2>/dev/null || true" + runcmd "service kea-dhcp4 stop 2>/dev/null || true" + runcmd "service dhcpd stop 2>/dev/null || true" + fi fi ############################################################################## diff --git a/xCAT-server/share/xcat/tools/dhcpop b/xCAT-server/share/xcat/tools/dhcpop index ef22e4557..79d06316a 100755 --- a/xCAT-server/share/xcat/tools/dhcpop +++ b/xCAT-server/share/xcat/tools/dhcpop @@ -1,6 +1,14 @@ #!/usr/bin/perl -w #use Data::Dumper; +BEGIN +{ + $::XCATROOT = $ENV{'XCATROOT'} ? $ENV{'XCATROOT'} : '/opt/xcat'; +} +use lib "$::XCATROOT/lib/perl"; + use Getopt::Long; +use Fcntl ':flock'; +use xCAT::DHCP::Backend; sub usage{ print "Usage: dhcphelper -h \n"; @@ -26,6 +34,64 @@ if($help){ &usage; exit 0; }elsif($rmop){ + my $backend = xCAT::DHCP::Backend->new_backend(); + if (ref($backend) ne 'HASH' && $backend->name eq 'kea') { + if ($mac && $mac !~ /:/) { + $mac = lc($mac); + $mac =~ s/(\w{2})/$1:/g; + $mac =~ s/:$//; + } + + mkdir "/tmp/xcat" unless -d "/tmp/xcat"; + open(my $dhcplockfd, ">", "/tmp/xcat/dhcplock") or die "Unable to lock DHCP state: $!"; + flock($dhcplockfd, LOCK_EX); + + my $config4 = $backend->load_dhcp4_config(); + if ($config4->{error}) { + print "Error: $config4->{error}\n"; + exit 1; + } + my $config6; + my $have_dhcp6 = -e $backend->dhcp6_config_file(); + if ($have_dhcp6) { + $config6 = $backend->load_dhcp6_config(); + if ($config6->{error}) { + print "Error: $config6->{error}\n"; + exit 1; + } + } + + my @matches; + push @matches, { hostname => $hostname } if $hostname; + push @matches, { 'hw-address' => lc($mac) } if $mac; + push @matches, { 'ip-address' => $ip } if $ip; + + foreach my $match (@matches) { + $backend->delete_reservations($config4, $match); + $backend->delete_reservations($config6, $match) if $config6; + } + + my $result = $backend->write_dhcp4_json($backend->encode_config($config4)); + if ($result->{error}) { + print "Error: $result->{error}\n"; + exit 1; + } + if ($config6) { + my $result6 = $backend->write_dhcp6_json($backend->encode_config($config6)); + if ($result6->{error}) { + print "Error: $result6->{error}\n"; + exit 1; + } + } + + my $restart = $backend->restart_services(ipv6 => $config6 ? 1 : 0); + if ($restart->{error}) { + print "Error: $restart->{error}\n"; + exit 1; + } + exit 0; + } + my $out=qx(tabdump -w key==omapi -w username==xcat_key passwd |tail -n1|awk -F, '{print \$2","\$3}'); $out =~ s/("|\n)//g; my ($id,$passwd)=split(',',$out); diff --git a/xCAT-server/xCAT-wsapi/restapi.pl b/xCAT-server/xCAT-wsapi/restapi.pl index a4427e8d0..a39b17fc6 100755 --- a/xCAT-server/xCAT-wsapi/restapi.pl +++ b/xCAT-server/xCAT-wsapi/restapi.pl @@ -621,9 +621,9 @@ my %URIdef = ( desc => "[URI:/services/dhcp] - The dhcp service resource.", matcher => '^/services/dhcp$', POST => { - desc => "Create the dhcpd.conf for all the networks which are defined in the xCAT Management Node.", + desc => "Create the DHCP server configuration for all the networks which are defined in the xCAT Management Node.", usage => "||$usagemsg{non_getreturn}|", - example => "|Create the dhcpd.conf and restart the dhcpd.|POST|/services/dhcp||", + example => "|Create the DHCP configuration and restart the selected DHCP service.|POST|/services/dhcp||", cmd => "makedhcp", fhandler => \&nonobjhdl, outhdler => \&noout, @@ -2745,4 +2745,3 @@ sub pushFlags { } } - diff --git a/xCAT-server/xCAT-wsapi/xcatws.cgi b/xCAT-server/xCAT-wsapi/xcatws.cgi index 59d8d16ce..894e13011 100755 --- a/xCAT-server/xCAT-wsapi/xcatws.cgi +++ b/xCAT-server/xCAT-wsapi/xcatws.cgi @@ -747,9 +747,9 @@ my %URIdef = ( desc => "[URI:/services/dhcp] - The dhcp service resource.", matcher => '^/services/dhcp$', POST => { - desc => "Create the dhcpd.conf for all the networks which are defined in the xCAT Management Node.", + desc => "Create the DHCP server configuration for all the networks which are defined in the xCAT Management Node.", usage => "||$usagemsg{non_getreturn}|", - example => "|Create the dhcpd.conf and restart the dhcpd.|POST|/services/dhcp||", + example => "|Create the DHCP configuration and restart the selected DHCP service.|POST|/services/dhcp||", cmd => "makedhcp", fhandler => \&nonobjhdl, outhdler => \&noout, @@ -3589,4 +3589,3 @@ sub pushFlags { } } - diff --git a/xCAT-test/unit/dhcp_backend_selection.t b/xCAT-test/unit/dhcp_backend_selection.t new file mode 100644 index 000000000..b674b7085 --- /dev/null +++ b/xCAT-test/unit/dhcp_backend_selection.t @@ -0,0 +1,108 @@ +use strict; +use warnings; + +use FindBin; +use lib "$FindBin::Bin/../../perl-xCAT"; + +use Test::More; + +use xCAT::DHCP::Backend; + +is( xCAT::DHCP::Backend->normalize(undef), 'auto', 'undefined backend defaults to auto' ); +is( xCAT::DHCP::Backend->normalize(' ISC '), 'isc', 'backend values are trimmed and lowercased' ); +is( xCAT::DHCP::Backend->normalize('kea'), 'kea', 'kea is valid' ); +is( xCAT::DHCP::Backend->normalize('bogus'), undef, 'invalid backend is rejected' ); + +is( + xCAT::DHCP::Backend->default_backend( platform => 'el9', os => 'rhel9', os_name => 'rhel', version => 9 ), + 'isc', + 'EL9 defaults to ISC' +); + +is( + xCAT::DHCP::Backend->default_backend( platform => 'el10', os => 'rhel10', os_name => 'rhel', version => 10 ), + 'kea', + 'EL10 defaults to Kea by platform' +); + +is( + xCAT::DHCP::Backend->default_backend( platform => '', os => 'rocky10', os_name => 'rocky', version => 10 ), + 'kea', + 'EL10 derivatives default to Kea by osver' +); + +is( + xCAT::DHCP::Backend->default_backend( platform => '', os => 'ubuntu22.04', os_name => 'ubuntu', version => '22.04' ), + 'isc', + 'Ubuntu 22.04 defaults to ISC' +); + +is( + xCAT::DHCP::Backend->default_backend( platform => '', os => 'ubuntu24.04', os_name => 'ubuntu', version => '24.04' ), + 'kea', + 'Ubuntu 24.04 defaults to Kea' +); + +is( + xCAT::DHCP::Backend->default_backend( platform => '', os => 'ubuntu24.10', os_name => 'ubuntu', version => '24.10' ), + 'kea', + 'Ubuntu releases newer than 24.04 default to Kea' +); + +is( + xCAT::DHCP::Backend->select( requested => 'isc', os => 'rhel10', platform => 'el10' )->{name}, + 'isc', + 'explicit ISC override wins on EL10' +); + +is( + xCAT::DHCP::Backend->select( requested => 'kea', os => 'rhel9', platform => 'el9' )->{name}, + 'kea', + 'explicit Kea override wins on EL9' +); + +is( + xCAT::DHCP::Backend->select( requested => 'auto', os => 'rhel9', platform => 'el9' )->{name}, + 'isc', + 'auto selects ISC on EL9' +); + +is( + xCAT::DHCP::Backend->select( requested => 'auto', os => 'rhel10', platform => 'el10' )->{name}, + 'kea', + 'auto selects Kea on EL10' +); + +is( + xCAT::DHCP::Backend->select( requested => 'auto', os => 'ubuntu24.04', os_name => 'ubuntu', version => '24.04' )->{name}, + 'kea', + 'auto selects Kea on Ubuntu 24.04' +); + +like( + xCAT::DHCP::Backend->select( requested => 'invalid' )->{error}, + qr/Invalid site\.dhcpbackend/, + 'invalid explicit backend returns a clear error' +); + +like( + xCAT::DHCP::Backend->select( + requested => 'kea', + check_available => 1, + available => { kea => 0 }, + )->{error}, + qr/not available/, + 'unavailable forced backend returns a clear error' +); + +is( + xCAT::DHCP::Backend->select( + requested => 'kea', + check_available => 1, + available => { kea => 1 }, + )->{name}, + 'kea', + 'forced Kea succeeds when available' +); + +done_testing(); diff --git a/xCAT-test/unit/dhcp_boot_policy.t b/xCAT-test/unit/dhcp_boot_policy.t new file mode 100644 index 000000000..550e77131 --- /dev/null +++ b/xCAT-test/unit/dhcp_boot_policy.t @@ -0,0 +1,27 @@ +use strict; +use warnings; + +use FindBin; +use lib "$FindBin::Bin/../../perl-xCAT"; + +use Test::More; + +use xCAT::DHCP::BootPolicy; + +my $fallback_classes = xCAT::DHCP::BootPolicy->kea_client_classes(); +is( scalar @$fallback_classes, 4, 'Kea boot policy omits xNBA classes when xNBA loaders are unavailable' ); +my %fallback_by_name = map { $_->{name} => $_ } @$fallback_classes; +is( $fallback_by_name{'xcat-bios'}{'boot-file-name'}, 'pxelinux.0', 'BIOS clients fall back to pxelinux.0 without xNBA loaders' ); +ok( !exists $fallback_by_name{'xcat-xnba-bios'}, 'xNBA user-class is not advertised without xNBA kpxe' ); + +my $classes = xCAT::DHCP::BootPolicy->kea_client_classes(xnba_kpxe => 1, xnba_efi => 1); +is( scalar @$classes, 6, 'Kea boot policy renders expected xNBA client classes' ); + +my %by_name = map { $_->{name} => $_ } @$classes; +is( $by_name{'xcat-bios'}{'boot-file-name'}, 'xcat/xnba.kpxe', 'BIOS clients receive xNBA kpxe' ); +like( $by_name{'xcat-uefi-x64'}{test}, qr/0x0007/, 'UEFI x64 class matches architecture 7' ); +like( $by_name{'xcat-uefi-x64'}{test}, qr/0x0009/, 'UEFI x64 class matches architecture 9' ); +is( $by_name{'xcat-aarch64'}{'boot-file-name'}, 'boot/grub2/grub2.aarch64', 'AArch64 clients receive grub2 boot file' ); +is( $by_name{'xcat-ppc64'}{'boot-file-name'}, '/boot/grub2/grub2.ppc', 'POWER clients receive grub2 Open Firmware boot file' ); + +done_testing(); diff --git a/xCAT-test/unit/dhcp_kea_config_validation.t b/xCAT-test/unit/dhcp_kea_config_validation.t new file mode 100644 index 000000000..ccdff0b61 --- /dev/null +++ b/xCAT-test/unit/dhcp_kea_config_validation.t @@ -0,0 +1,229 @@ +use strict; +use warnings; + +use FindBin; +use lib "$FindBin::Bin/../../perl-xCAT"; + +use File::Temp qw/tempfile/; +use JSON (); +use Test::More; + +use xCAT::DHCP::Backend::Kea; + +my $kea_dhcp4 = command_path('kea-dhcp4'); +plan skip_all => 'kea-dhcp4 is not installed' unless $kea_dhcp4; + +my $validation_dir = validation_temp_dir($kea_dhcp4); +plan skip_all => 'kea-dhcp4 cannot read temporary config files; run as root to validate from /etc/kea' + unless defined $validation_dir; + +my $backend = xCAT::DHCP::Backend::Kea->new(); +my $json = $backend->render_dhcp4_config( + { + interfaces => ['*'], + 'option-def' => [ + { name => 'conf-file', code => 209, type => 'string', space => 'dhcp4' }, + { name => 'iscsi-initiator-iqn', code => 203, type => 'string', space => 'dhcp4' }, + { name => 'cumulus-provision-url', code => 239, type => 'string', space => 'dhcp4' }, + ], + 'client-classes' => [ + { + name => 'xcat-xnba-bios', + test => "option[77].text == 'xNBA' and option[93].hex == 0x0000", + 'boot-file-name' => 'xcat/xnba.kpxe', + }, + { + name => 'xcat-uefi-x64', + test => 'option[93].hex == 0x0007 or option[93].hex == 0x0009', + 'boot-file-name' => 'xcat/xnba.efi', + }, + ], + 'dhcp-ddns' => { + 'enable-updates' => JSON::true, + 'server-ip' => '127.0.0.1', + 'server-port' => 53001, + 'ncr-protocol' => 'UDP', + 'ncr-format' => 'JSON', + }, + 'ddns-send-updates' => JSON::true, + 'ddns-override-no-update' => JSON::true, + 'ddns-override-client-update' => JSON::true, + 'ddns-qualifying-suffix' => 'cluster.test.', + 'ddns-update-on-renew' => JSON::true, + subnets => [ + { + id => 1, + subnet => '192.168.122.0/24', + dynamicrange => '192.168.122.100-192.168.122.120', + next_server => '192.168.122.1', + option_data => [ + { name => 'routers', data => '192.168.122.1' }, + { name => 'domain-name', data => 'cluster.test' }, + { name => 'domain-name-servers', data => '192.168.122.1' }, + { name => 'cumulus-provision-url', data => 'http://192.168.122.1:80/install/postscripts/cumulusztp' }, + ], + reservations => [ + { + 'hw-address' => '52:54:00:12:34:56', + 'ip-address' => '192.168.122.50', + hostname => 'node01', + 'next-server' => '192.168.122.1', + 'boot-file-name' => 'pxelinux.0', + 'option-data' => [ { name => 'host-name', data => 'node01' } ], + }, + ], + }, + ], + } +); + +my $path = write_validation_file( $json, 'xcat-test-kea-dhcp4' ); + +my $result = $backend->validate_dhcp4_config($path); +ok( !$result->{error}, 'generated Kea DHCPv4 config validates with kea-dhcp4 -t' ) + or diag $result->{error}; + +unlink $path; +SKIP: { + skip 'kea-dhcp6 is not installed', 1 unless command_path('kea-dhcp6'); + my $dhcp6_json = $backend->render_dhcp6_config( + { + interfaces => ['*'], + 'dhcp-ddns' => { + 'enable-updates' => JSON::true, + 'server-ip' => '127.0.0.1', + 'server-port' => 53001, + 'ncr-protocol' => 'UDP', + 'ncr-format' => 'JSON', + }, + 'ddns-send-updates' => JSON::true, + 'ddns-override-no-update' => JSON::true, + 'ddns-override-client-update' => JSON::true, + 'ddns-qualifying-suffix' => 'cluster.test.', + 'ddns-update-on-renew' => JSON::true, + subnets => [ + { + id => 6, + subnet => '2001:db8:1::/64', + dynamicrange => '2001:db8:1::100/120', + option_data => [ + { name => 'dns-servers', data => '2001:db8:1::1' }, + { name => 'domain-search', data => 'cluster.test' }, + ], + reservations => [ + { + duid => '00:04:52:54:00:12:34:56', + 'ip-addresses' => ['2001:db8:1::50'], + hostname => 'nodev6', + }, + ], + }, + ], + } + ); + my $dhcp6_path = write_validation_file( $dhcp6_json, 'xcat-test-kea-dhcp6' ); + my $dhcp6_result = $backend->validate_dhcp6_config($dhcp6_path); + ok( !$dhcp6_result->{error}, 'generated Kea DHCPv6 config validates with kea-dhcp6 -t' ) + or diag $dhcp6_result->{error}; + unlink $dhcp6_path; +} + +SKIP: { + skip 'kea-dhcp-ddns is not installed', 1 unless command_path('kea-dhcp-ddns'); + my $ddns_json = $backend->render_ddns_config( + { + 'tsig-keys' => [ + { name => 'xcat_key', algorithm => 'HMAC-SHA256', secret => 'YWJjMTIz' }, + ], + forward_domains => [ + { + name => 'cluster.test.', + 'key-name' => 'xcat_key', + 'dns-servers' => [ { 'ip-address' => '127.0.0.1', port => 53 } ], + }, + ], + reverse_domains => [ + { + name => '122.168.192.in-addr.arpa.', + 'key-name' => 'xcat_key', + 'dns-servers' => [ { 'ip-address' => '127.0.0.1', port => 53 } ], + }, + ], + } + ); + my $ddns_path = write_validation_file( $ddns_json, 'xcat-test-kea-ddns' ); + my $ddns_result = $backend->validate_ddns_config($ddns_path); + ok( !$ddns_result->{error}, 'generated Kea DHCP-DDNS config validates with kea-dhcp-ddns -t' ) + or diag $ddns_result->{error}; + unlink $ddns_path; +} + +SKIP: { + skip 'kea-ctrl-agent is not installed', 1 unless command_path('kea-ctrl-agent'); + my $ctrl_agent_json = $backend->render_ctrl_agent_config( + { + dhcp6 => 1, + ddns => 1, + } + ); + my $ctrl_path = write_validation_file( $ctrl_agent_json, 'xcat-test-kea-ctrl-agent' ); + my $ctrl_result = $backend->validate_ctrl_agent_config($ctrl_path); + ok( !$ctrl_result->{error}, 'generated Kea Control Agent config validates with kea-ctrl-agent -t' ) + or diag $ctrl_result->{error}; + unlink $ctrl_path; +} +done_testing(); + +sub command_path { + my ($command) = @_; + + foreach my $dir ( split /:/, $ENV{PATH} || '' ) { + next unless $dir; + return "$dir/$command" if -x "$dir/$command"; + } + + foreach my $path ( "/usr/sbin/$command", "/usr/bin/$command", "/sbin/$command", "/bin/$command" ) { + return $path if -x $path; + } + + return; +} + +sub validation_temp_dir { + my ($kea_dhcp4) = @_; + + return '/etc/kea' if -d '/etc/kea' && -w '/etc/kea'; + + my ( $fh, $path ) = tempfile(); + print $fh '{"Dhcp4":{"interfaces-config":{"interfaces":[]},"lease-database":{"type":"memfile","name":"/tmp/xcat-test-kea-leases4.csv"},"valid-lifetime":600,"subnet4":[]}}'; + close($fh); + chmod 0644, $path or die "Unable to set $path permissions: $!"; + + my $command = shell_quote($kea_dhcp4) . ' -t ' . shell_quote($path) . ' 2>&1'; + my $output = `$command`; + unlink $path; + return undef if $output =~ /Unable to open file/; + + return ''; +} + +sub write_validation_file { + my ( $content, $template ) = @_; + + my %opts = ( TEMPLATE => "$template-XXXXXX", UNLINK => 0 ); + $opts{DIR} = $validation_dir if defined $validation_dir; + + my ( $fh, $path ) = tempfile(%opts); + print $fh $content; + close($fh); + chmod 0644, $path or die "Unable to set $path permissions: $!"; + + return $path; +} + +sub shell_quote { + my ($value) = @_; + + $value =~ s/'/'\\''/g; + return "'$value'"; +} diff --git a/xCAT-test/unit/dhcp_kea_control_agent_smoke.t b/xCAT-test/unit/dhcp_kea_control_agent_smoke.t new file mode 100644 index 000000000..194b260ee --- /dev/null +++ b/xCAT-test/unit/dhcp_kea_control_agent_smoke.t @@ -0,0 +1,166 @@ +use strict; +use warnings; + +use FindBin; +use lib "$FindBin::Bin/../../perl-xCAT"; + +use File::Path qw/make_path/; +use File::Temp qw/tempdir/; +use Test::More; + +use xCAT::DHCP::Backend::Kea; + +plan skip_all => 'set XCAT_KEA_LIVE_SMOKE=1 to run live Kea daemon smoke test' + unless $ENV{XCAT_KEA_LIVE_SMOKE}; +plan skip_all => 'live Kea daemon smoke test must run as root' unless $> == 0; + +my $kea_dhcp4 = command_path('kea-dhcp4'); +my $kea_ctrl = command_path('kea-ctrl-agent'); +plan skip_all => 'kea-dhcp4 and kea-ctrl-agent are required' + unless $kea_dhcp4 && $kea_ctrl; + +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 $tmp = tempdir(CLEANUP => 1); +make_path('/var/run/kea'); +make_path('/var/lib/kea'); + +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 $dhcp_config = "$tmp/kea-dhcp4.conf"; +my $ctrl_config = "$tmp/kea-ctrl-agent.conf"; +write_file( + $dhcp_config, + $backend->render_dhcp4_config( + { + 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, + } + ) +); + +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 @pids; +END { + kill 'TERM', @pids if @pids; + unlink grep { defined($_) && $_ ne '' } ( $socket, $lease_file ); +} + +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 $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 $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}; + +done_testing(); + +sub command_path { + my ($command) = @_; + + foreach my $dir ( split /:/, $ENV{PATH} || '' ) { + next unless $dir; + return "$dir/$command" if -x "$dir/$command"; + } + + foreach my $path ( "/usr/sbin/$command", "/usr/bin/$command", "/sbin/$command", "/bin/$command" ) { + return $path if -x $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: $!"; +} + +sub start_daemon { + my ( $command, @args ) = @_; + my $log = pop @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: $!"; + } + return $pid; +} + +sub wait_for_process { + my ($pid) = @_; + + for (1 .. 10) { + sleep 1; + return 0 unless kill 0, $pid; + return 1 if $_ >= 2; + } + + return kill 0, $pid; +} diff --git a/xCAT-test/unit/dhcp_kea_renderer.t b/xCAT-test/unit/dhcp_kea_renderer.t new file mode 100644 index 000000000..b6084dab6 --- /dev/null +++ b/xCAT-test/unit/dhcp_kea_renderer.t @@ -0,0 +1,287 @@ +use strict; +use warnings; + +use FindBin; +use lib "$FindBin::Bin/../../perl-xCAT"; + +use File::Temp qw/tempdir/; +use JSON; +use Test::More; + +use xCAT::DHCP::Backend::Kea; + +my $backend = xCAT::DHCP::Backend::Kea->new(); +my $json = $backend->render_dhcp4_config( + { + interfaces => ['eth0'], + valid_lifetime => '600', + subnets => [ + { + id => '1', + subnet => '10.0.0.0/24', + interface => 'eth0', + dynamicrange => '10.0.0.100-10.0.0.120;10.0.0.130,10.0.0.140', + next_server => '10.0.0.1', + option_data => [ + { name => 'routers', data => '10.0.0.1' }, + { name => 'domain-name-servers', data => '10.0.0.2, 10.0.0.3' }, + { name => 'domain-name', data => 'cluster.example.com' }, + ], + reservations => [ + { + 'hw-address' => 'aa:bb:cc:dd:ee:ff', + 'ip-address' => '10.0.0.10', + hostname => 'node01', + }, + ], + }, + ], + 'client-classes' => [ + { + name => 'xcat-uefi-x64', + test => 'option[93].hex == 0x0007', + 'boot-file-name' => 'xcat/xnba.efi', + }, + ], + } +); + +my $config = decode_json($json); +ok( $config->{Dhcp4}, 'renderer creates a Dhcp4 document' ); +is_deeply( $config->{Dhcp4}{'interfaces-config'}{interfaces}, ['eth0'], 'interfaces are rendered' ); +is( $config->{Dhcp4}{'valid-lifetime'}, 600, 'valid lifetime is rendered' ); +is( $config->{Dhcp4}{'lease-database'}{type}, 'memfile', 'memfile lease backend is the default' ); + +my $subnet = $config->{Dhcp4}{subnet4}[0]; +is( $subnet->{id}, 1, 'subnet id is rendered' ); +is( $subnet->{subnet}, '10.0.0.0/24', 'subnet CIDR is rendered' ); +is( $subnet->{interface}, 'eth0', 'subnet interface is rendered' ); +is( $subnet->{'next-server'}, '10.0.0.1', 'next-server is rendered' ); + +is_deeply( + $subnet->{pools}, + [ + { pool => '10.0.0.100 - 10.0.0.120' }, + { pool => '10.0.0.130 - 10.0.0.140' }, + ], + 'dynamicrange is rendered as Kea pools' +); + +is_deeply( + $subnet->{'option-data'}, + [ + { name => 'routers', data => '10.0.0.1' }, + { name => 'domain-name-servers', data => '10.0.0.2, 10.0.0.3' }, + { name => 'domain-name', data => 'cluster.example.com' }, + ], + 'subnet option-data is preserved' +); + +is_deeply( + $subnet->{reservations}, + [ + { + 'hw-address' => 'aa:bb:cc:dd:ee:ff', + 'ip-address' => '10.0.0.10', + hostname => 'node01', + }, + ], + 'host reservations are preserved' +); + +is_deeply( + $config->{Dhcp4}{'client-classes'}, + [ + { + name => 'xcat-uefi-x64', + test => 'option[93].hex == 0x0007', + 'boot-file-name' => 'xcat/xnba.efi', + }, + ], + 'client classes are preserved' +); + +my $empty_boot_json = $backend->render_dhcp4_config( + { + subnets => [ + { + id => 2, + subnet => '10.0.1.0/24', + pools => [], + next_server => '0.0.0.0', + boot_file_name => '', + }, + ], + } +); +my $empty_boot_subnet = decode_json($empty_boot_json)->{Dhcp4}{subnet4}[0]; +is( $empty_boot_subnet->{'next-server'}, '0.0.0.0', 'false-looking next-server value is preserved' ); +is( $empty_boot_subnet->{'boot-file-name'}, '', 'empty boot-file-name is preserved' ); + +my $reservation_config = decode_json( + $backend->render_dhcp4_config( + { + subnets => [ + { + id => 10, + subnet => '10.10.0.0/24', + pools => [], + }, + ], + } + ) +); + +$backend->upsert_reservations( + $reservation_config, + [ + { + 'subnet-id' => 10, + 'hw-address' => '00:11:22:33:44:55', + 'ip-address' => '10.10.0.12', + hostname => 'node12', + }, + ] +); +is( scalar @{ $reservation_config->{Dhcp4}{subnet4}[0]{reservations} }, 1, 'reservation is added to matching subnet' ); + +$backend->upsert_reservations( + $reservation_config, + [ + { + 'subnet-id' => 10, + 'hw-address' => '00:11:22:33:44:55', + 'ip-address' => '10.10.0.13', + hostname => 'node12', + }, + ] +); +is( scalar @{ $reservation_config->{Dhcp4}{subnet4}[0]{reservations} }, 1, 'matching reservation is replaced, not duplicated' ); +is( $reservation_config->{Dhcp4}{subnet4}[0]{reservations}[0]{'ip-address'}, '10.10.0.13', 'replacement reservation is stored' ); + +my $subnet_id = $backend->subnet_id_for_ip( $reservation_config, '10.10.0.13' ); +is( $subnet_id, 10, 'subnet lookup by IPv4 address finds the reservation subnet' ); + +my $found = $backend->query_reservations( $reservation_config, { hostname => 'node12' } ); +is( scalar @$found, 1, 'reservation query finds hostname match' ); +is( $found->[0]{'subnet-id'}, 10, 'reservation query includes subnet id' ); + +my $deleted = $backend->delete_reservations( $reservation_config, { 'hw-address' => '00:11:22:33:44:55' } ); +is( scalar @$deleted, 1, 'reservation delete returns deleted reservation' ); +is( scalar @{ $reservation_config->{Dhcp4}{subnet4}[0]{reservations} }, 0, 'reservation is removed from config' ); + +my $hookdir = tempdir(CLEANUP => 1); +my $hook = "$hookdir/libdhcp_host_cmds.so"; +open(my $hookfh, '>', $hook) or die "Unable to create fake hook: $!"; +close($hookfh); +is( $backend->host_cmds_hook_path($hook), $hook, 'host commands hook lookup accepts an explicit existing path' ); +my $backend_without_default_hooks = xCAT::DHCP::Backend::Kea->new(host_cmds_hook_paths => []); +is( $backend_without_default_hooks->host_cmds_hook_path("$hookdir/missing.so"), undef, 'host commands hook lookup returns undef when no hook exists' ); + +my $backupdir = tempdir(CLEANUP => 1); +my $config_path = "$backupdir/kea-dhcp4.conf"; +open(my $configfh, '>', $config_path) or die "Unable to create fake config: $!"; +print $configfh '{"old":true}'; +close($configfh); +my $write_result = $backend->write_dhcp4_json('{"new":true}', path => $config_path, skip_validate => 1, backup_existing => 1); +ok( !$write_result->{error}, 'write_dhcp4_json succeeds with backup_existing' ); +is( $write_result->{backup}, "$config_path.xcatbak", 'backup path is reported' ); +my $config_mode = ( stat($config_path) )[2] & 07777; +ok( $config_mode == 0640 || $config_mode == 0644, 'written config is readable by the Kea service user' ); +open(my $backupfh, '<', "$config_path.xcatbak") or die "Unable to read backup config: $!"; +my $backup_content = <$backupfh>; +close($backupfh); +is( $backup_content, '{"old":true}', 'existing Kea config is backed up before replacement' ); + +my $dhcp6_json = $backend->render_dhcp6_config( + { + interfaces => ['eth0'], + valid_lifetime => '700', + preferred_lifetime => '500', + subnets => [ + { + id => '11', + subnet => '2001:db8:1::/64', + dynamicrange => '2001:db8:1::100/120', + option_data => [ + { name => 'dns-servers', data => '2001:db8:1::1' }, + { name => 'domain-search', data => 'cluster.example.com' }, + ], + reservations => [ + { + duid => '00:04:52:54:00:12:34:56', + 'ip-addresses' => ['2001:db8:1::50'], + hostname => 'nodev6', + }, + ], + }, + ], + } +); +my $dhcp6_config = decode_json($dhcp6_json); +ok( $dhcp6_config->{Dhcp6}, 'renderer creates a Dhcp6 document' ); +is( $dhcp6_config->{Dhcp6}{subnet6}[0]{subnet}, '2001:db8:1::/64', 'DHCPv6 subnet is rendered' ); +is( $dhcp6_config->{Dhcp6}{'valid-lifetime'}, 700, 'DHCPv6 valid lifetime is numeric' ); +is( $dhcp6_config->{Dhcp6}{'preferred-lifetime'}, 500, 'DHCPv6 preferred lifetime is numeric' ); +is( $dhcp6_config->{Dhcp6}{subnet6}[0]{id}, 11, 'DHCPv6 subnet id is numeric' ); +is( $dhcp6_config->{Dhcp6}{subnet6}[0]{reservations}[0]{duid}, '00:04:52:54:00:12:34:56', 'DHCPv6 DUID reservation is rendered' ); + +my $ddns_json = $backend->render_ddns_config( + { + port => '53001', + 'dns-server-timeout' => '500', + 'tsig-keys' => [ + { name => 'xcat_key', algorithm => 'HMAC-SHA256', secret => 'abc123==' }, + ], + forward_domains => [ + { + name => 'cluster.example.com.', + 'key-name' => 'xcat_key', + 'dns-servers' => [ { 'ip-address' => '10.0.0.1', port => 53 } ], + }, + ], + reverse_domains => [ + { + name => '0.0.10.in-addr.arpa.', + 'key-name' => 'xcat_key', + 'dns-servers' => [ { 'ip-address' => '10.0.0.1', port => 53 } ], + }, + ], + } +); +my $ddns_config = decode_json($ddns_json); +ok( $ddns_config->{DhcpDdns}, 'renderer creates a DhcpDdns document' ); +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' })); +is( $ctrl_agent_config->{'Control-agent'}{'http-port'}, 8000, 'Control Agent HTTP port is numeric' ); + +my @commands; +my $ca_backend = xCAT::DHCP::Backend::Kea->new( + control_agent_handler => sub { + my ($payload) = @_; + push @commands, $payload; + return { ok => 1, result => 0, response => { result => 0 } }; + }, +); +my $live_result = $ca_backend->live_upsert_reservations( + [ + { + 'subnet-id' => 10, + 'hw-address' => '00:11:22:33:44:55', + 'ip-address' => '10.10.0.20', + hostname => 'node20', + 'boot-file-name' => 'pxelinux.0', + }, + ] +); +ok( !$live_result->{error}, 'live reservation upsert succeeds through injected Control Agent handler' ); +is( $commands[0]{command}, 'reservation-del', 'live upsert deletes any existing reservation first' ); +is( $commands[0]{arguments}{'operation-target'}, 'memory', 'live delete targets runtime memory' ); +is( $commands[1]{command}, 'reservation-add', 'live upsert adds reservation through host-commands' ); +is( $commands[1]{arguments}{reservation}{'subnet-id'}, 10, 'live add includes subnet id in reservation body' ); +is_deeply( $commands[1]{service}, ['dhcp4'], 'live add targets dhcp4 service by default' ); + +done_testing(); diff --git a/xCAT-test/unit/dhcp_range.t b/xCAT-test/unit/dhcp_range.t new file mode 100644 index 000000000..4dad80558 --- /dev/null +++ b/xCAT-test/unit/dhcp_range.t @@ -0,0 +1,55 @@ +use strict; +use warnings; + +use FindBin; +use lib "$FindBin::Bin/../../perl-xCAT"; + +use Test::More; + +use xCAT::DHCP::Range; + +my $pair = xCAT::DHCP::Range->parse('10.0.0.10-10.0.0.20'); +is( $pair->{family}, 4, 'IPv4 range is detected' ); +is( $pair->{start}, '10.0.0.10', 'range start is parsed' ); +is( $pair->{end}, '10.0.0.20', 'range end is parsed' ); +is( xCAT::DHCP::Range->isc_range($pair), '10.0.0.10 10.0.0.20', 'ISC range uses space separator' ); +is( xCAT::DHCP::Range->kea_pool($pair), '10.0.0.10 - 10.0.0.20', 'Kea pool uses JSON pool syntax' ); + +is_deeply( + [ xCAT::DHCP::Range->isc_ranges('10.0.0.10,10.0.0.20;10.0.1.10 10.0.1.20') ], + [ '10.0.0.10 10.0.0.20', '10.0.1.10 10.0.1.20' ], + 'multiple ranges are normalized for ISC' +); + +is_deeply( + [ xCAT::DHCP::Range->kea_pools('10.0.0.10,10.0.0.20;10.0.1.10 10.0.1.20') ], + [ + { pool => '10.0.0.10 - 10.0.0.20' }, + { pool => '10.0.1.10 - 10.0.1.20' }, + ], + 'multiple ranges are normalized for Kea pools' +); + +my $cidr = xCAT::DHCP::Range->parse('192.168.50.0/30'); +is( xCAT::DHCP::Range->isc_range($cidr), '192.168.50.0 192.168.50.3', 'IPv4 CIDR is expanded for ISC' ); +is( xCAT::DHCP::Range->kea_pool($cidr), '192.168.50.0 - 192.168.50.3', 'IPv4 CIDR is expanded for Kea' ); + +my ( $start, $end ) = xCAT::DHCP::Range->bounds($cidr); +is( "$start", '3232248320', 'CIDR start numeric bound is tracked' ); +is( "$end", '3232248323', 'CIDR end numeric bound is tracked' ); + +my $zero_start = xCAT::DHCP::Range->parse('0.0.0.0-0.0.0.10'); +is( xCAT::DHCP::Range->isc_range($zero_start), '0.0.0.0 0.0.0.10', 'zero-valued range starts are preserved' ); + +SKIP: { + skip 'Socket6 is not installed', 3 unless eval { require Socket6; 1 }; + my $cidr6 = xCAT::DHCP::Range->parse('2001:db8::/120'); + is( $cidr6->{family}, 6, 'IPv6 CIDR is detected' ); + is( xCAT::DHCP::Range->isc_range($cidr6), '2001:db8::/120', 'IPv6 CIDR is preserved for ISC DHCPv6' ); + is( xCAT::DHCP::Range->kea_pool($cidr6), '2001:db8::/120', 'IPv6 CIDR is preserved for Kea DHCPv6' ); +} + +is_deeply( xCAT::DHCP::Range->parse_dynamic_ranges(undef), [], 'undefined range list returns no ranges' ); +is( xCAT::DHCP::Range->parse('not-a-range'), undef, 'invalid range returns undef' ); + +done_testing(); diff --git a/xCAT/debian/control b/xCAT/debian/control index 3e614d210..a1688a6db 100644 --- a/xCAT/debian/control +++ b/xCAT/debian/control @@ -9,8 +9,8 @@ Homepage: https://xcat.org/ Package: xcat Architecture: amd64 ppc64el -Depends: ${perl:Depends}, goconserver(>= 0.3.3-snap000000000000), xcat-server (>= 2.13-snap000000000000), xcat-client (>= 2.13-snap000000000000), libdbd-sqlite3-perl, isc-dhcp-server, apache2, nfs-kernel-server, libxml-parser-perl, rsync, tftpd-hpa, libnet-telnet-perl, xcat-genesis-scripts-ppc64 (>= 2.13-snap000000000000), xcat-genesis-scripts-amd64 (>= 2.13-snap000000000000) -Recommends: bind9, net-tools, nmap, tftp-hpa, ipmitool-xcat (>= 1.8.17-1), syslinux[any-amd64], libsys-virt-perl, syslinux-xcat, xnba-undi, elilo-xcat, xcat-buildkit (>= 2.13-snap000000000000), xcat-probe (>= 2.13-snap000000000000) +Depends: ${perl:Depends}, goconserver(>= 0.3.3-snap000000000000), xcat-server (>= 2.13-snap000000000000), xcat-client (>= 2.13-snap000000000000), libdbd-sqlite3-perl, isc-dhcp-server | kea, apache2, nfs-kernel-server, libxml-parser-perl, rsync, tftpd-hpa, libnet-telnet-perl, xcat-genesis-scripts-ppc64 (>= 2.13-snap000000000000), xcat-genesis-scripts-amd64 (>= 2.13-snap000000000000) +Recommends: bind9, net-tools, nmap, kea, tftp-hpa, ipmitool-xcat (>= 1.8.17-1), syslinux[any-amd64], libsys-virt-perl, syslinux-xcat, xnba-undi, elilo-xcat, xcat-buildkit (>= 2.13-snap000000000000), xcat-probe (>= 2.13-snap000000000000) Suggests: yaboot-xcat Description: Metapackage for a common, default xCAT setup xCAT is Extreme Cluster/Cloud Administration Toolkit. xCAT offers complete diff --git a/xCAT/postscripts/xcatlib.sh b/xCAT/postscripts/xcatlib.sh index c4c8b4bea..1dbef7b35 100755 --- a/xCAT/postscripts/xcatlib.sh +++ b/xCAT/postscripts/xcatlib.sh @@ -284,7 +284,7 @@ function servicemap { # specified with structure # INIT_(general service name) = "list of possible service names" # - INIT_dhcp="dhcp3-server dhcpd isc-dhcp-server"; + INIT_dhcp="dhcp3-server dhcpd isc-dhcp-server kea-dhcp4"; INIT_nfs="nfsserver nfs-server nfs nfs-kernel-server"; @@ -826,4 +826,3 @@ function msgutil_r { function msgutil { msgutil_r "" "$@" } - diff --git a/xCAT/xCAT.spec b/xCAT/xCAT.spec index 62beb6637..dc71009d7 100644 --- a/xCAT/xCAT.spec +++ b/xCAT/xCAT.spec @@ -70,8 +70,13 @@ Requires: httpd nfs-utils nmap bind perl(CGI) # on RHEL7, need to specify it explicitly Requires: net-tools Requires: /usr/bin/killall -# On RHEL this pulls in dhcp, on SLES it pulls in dhcp-server +# On RHEL this pulls in dhcp, on SLES it pulls in dhcp-server. EL10 uses Kea. +%if 0%{?rhel} >= 10 +Requires: kea +Requires: kea-hooks +%else Requires: /usr/sbin/dhcpd +%endif # On RHEL this pulls in openssh-server, on SLES it pulls in openssh Requires: /usr/bin/ssh %if %nots390x diff --git a/xCATsn/debian/control b/xCATsn/debian/control index 933b94862..8d7baf13e 100644 --- a/xCATsn/debian/control +++ b/xCATsn/debian/control @@ -8,8 +8,8 @@ Homepage: https://xcat.org/ Package: xcatsn Architecture: amd64 ppc64el -Depends: ${perl:Depends}, goconserver (>=0.3.3-snap000000000000), xcat-server (>= 2.13-snap000000000000), xcat-client (>= 2.13-snap000000000000), libdbd-sqlite3-perl, libxml-parser-perl, tftpd-hpa, libnet-telnet-perl, isc-dhcp-server, apache2, nfs-kernel-server, xcat-genesis-scripts-ppc64 (>= 2.13-snap000000000000), xcat-genesis-scripts-amd64 (>= 2.13-snap000000000000) -Recommends: bind9, net-tools, nmap, tftp-hpa, ipmitool-xcat (>= 1.8.17-1), syslinux[any-amd64], libsys-virt-perl, syslinux-xcat, xnba-undi, elilo-xcat, xcat-buildkit (>= 2.13-snap000000000000), xcat-probe (>= 2.13-snap000000000000) +Depends: ${perl:Depends}, goconserver (>=0.3.3-snap000000000000), xcat-server (>= 2.13-snap000000000000), xcat-client (>= 2.13-snap000000000000), libdbd-sqlite3-perl, libxml-parser-perl, tftpd-hpa, libnet-telnet-perl, isc-dhcp-server | kea, apache2, nfs-kernel-server, xcat-genesis-scripts-ppc64 (>= 2.13-snap000000000000), xcat-genesis-scripts-amd64 (>= 2.13-snap000000000000) +Recommends: bind9, net-tools, nmap, kea, tftp-hpa, ipmitool-xcat (>= 1.8.17-1), syslinux[any-amd64], libsys-virt-perl, syslinux-xcat, xnba-undi, elilo-xcat, xcat-buildkit (>= 2.13-snap000000000000), xcat-probe (>= 2.13-snap000000000000) Suggests: yaboot-xcat Description: Metapackage for a common, default xCAT service node setup xCATsn is a service node management package intended for at-scale diff --git a/xCATsn/xCATsn.spec b/xCATsn/xCATsn.spec index 493d8325b..d79632860 100644 --- a/xCATsn/xCATsn.spec +++ b/xCATsn/xCATsn.spec @@ -48,8 +48,13 @@ Requires: /usr/bin/killall Requires: /usr/bin/bc # yaboot-xcat is pulled in so any SN can manage ppc nodes Requires: httpd nfs-utils nmap bind -# On RHEL this pulls in dhcp, on SLES it pulls in dhcp-server +# On RHEL this pulls in dhcp, on SLES it pulls in dhcp-server. EL10 uses Kea. +%if 0%{?rhel} >= 10 +Requires: kea +Requires: kea-hooks +%else Requires: /usr/sbin/dhcpd +%endif # On RHEL this pulls in openssh-server, on SLES it pulls in openssh Requires: /usr/bin/ssh %ifnarch s390x