From 67d60e7eca4c6dc76c708a6c48652a029c095b6b Mon Sep 17 00:00:00 2001 From: Sam McCarthy Date: Thu, 25 Jun 2026 13:21:52 +0530 Subject: [PATCH 1/2] Refs #39429 - Import smart_proxy_dhcp_kea_api provider Imported from smart-proxy-dhcp-kea-api. This is a verbatim import of the original KEA DHCP provider code. --- modules/dhcp_kea/dhcp_kea_api_main.rb | 250 +++++++++++ modules/dhcp_kea/dhcp_kea_api_plugin.rb | 51 +++ .../dhcp_kea/dhcp_kea_api_subnet_service.rb | 417 ++++++++++++++++++ modules/dhcp_kea/dhcp_kea_api_version.rb | 10 + modules/dhcp_kea/kea_api_client.rb | 160 +++++++ modules/dhcp_kea/plugin_configuration.rb | 81 ++++ .../dhcp_kea_api_subnet_service_spec.rb | 320 ++++++++++++++ test/dhcp_kea/fixtures/kea_api_stubs.rb | 161 +++++++ test/dhcp_kea/kea_api_client_spec.rb | 53 +++ test/dhcp_kea/provider_spec.rb | 235 ++++++++++ 10 files changed, 1738 insertions(+) create mode 100644 modules/dhcp_kea/dhcp_kea_api_main.rb create mode 100644 modules/dhcp_kea/dhcp_kea_api_plugin.rb create mode 100644 modules/dhcp_kea/dhcp_kea_api_subnet_service.rb create mode 100644 modules/dhcp_kea/dhcp_kea_api_version.rb create mode 100644 modules/dhcp_kea/kea_api_client.rb create mode 100644 modules/dhcp_kea/plugin_configuration.rb create mode 100644 test/dhcp_kea/dhcp_kea_api_subnet_service_spec.rb create mode 100644 test/dhcp_kea/fixtures/kea_api_stubs.rb create mode 100644 test/dhcp_kea/kea_api_client_spec.rb create mode 100644 test/dhcp_kea/provider_spec.rb diff --git a/modules/dhcp_kea/dhcp_kea_api_main.rb b/modules/dhcp_kea/dhcp_kea_api_main.rb new file mode 100644 index 000000000..a5e5c4c63 --- /dev/null +++ b/modules/dhcp_kea/dhcp_kea_api_main.rb @@ -0,0 +1,250 @@ +# frozen_string_literal: true + +require 'dhcp_common/server' +require 'resolv' + +module Proxy + module DHCP + module KeaApi + # The main provider class for the `dhcp_kea_api` module. This class inherits + # from the Foreman Smart Proxy's core `DHCP::Server` and implements the + # Kea-specific logic for adding and deleting DHCP reservations and leases. + class Provider < ::Proxy::DHCP::Server + attr_reader :subnet_service, :client + + # Initialises the Kea API provider. + # + # @param subnet_service [Proxy::DHCP::KeaApi::SubnetService] The service that manages the in-memory cache of DHCP data. + # @param client [Proxy::DHCP::KeaApi::Client] The client for communicating with the Kea API. + # @param free_ips [Proxy::DHCP::FreeIps] The service that tracks recently suggested IPs to prevent race conditions. + def initialize(subnet_service, client, free_ips) + @subnet_service = subnet_service + @client = client + subnet_service.load! + super('localhost', nil, subnet_service, free_ips) + end + + # Creates a new DHCP reservation in Kea. + # + # @param options [Hash] A hash containing the details for the new reservation. + # @return [Proxy::DHCP::Reservation] The created reservation object. + def add_record(options = {}) + logger.debug "DHCP options received from Foreman: #{options.inspect}" + record = super + + reservation_args = build_base_reservation_args(record) + add_boot_and_server_options(reservation_args, options) + + option_data = build_option_data(options) + reservation_args['option-data'] = option_data unless option_data.empty? + + @client.post_command('dhcp4', 'reservation-add', { reservation: reservation_args }) + begin + subnet_service.add_host(record.subnet.network, record) + rescue StandardError => e + logger.error "Cache update failed after successful reservation-add for MAC #{record.mac}. " \ + "Kea and cache are out of sync: #{e.message}" + raise + end + + logger.info "Successfully added reservation for MAC #{record.mac} and IP #{record.ip}" + record + end + + # Deletes a DHCP record from Kea. Handles both reservations and leases. + # + # @param record [Proxy::DHCP::Reservation, Proxy::DHCP::Lease] The record to be deleted. + # @return [Proxy::DHCP::Record] The object that was successfully deleted. + # @raise [Proxy::DHCP::Error] if the record type is unsupported, the corresponding + # Kea subnet-id cannot be found, or the API call fails. + def del_record(record) + logger.debug "Deleting record: #{record.inspect}" + + if record.is_a?(::Proxy::DHCP::Reservation) + del_reservation(record) + elsif record.is_a?(::Proxy::DHCP::Lease) + del_lease(record) + else + raise Proxy::DHCP::Error, "Cannot delete unsupported record type: #{record.class.name}" + end + + record + end + + # Loads subnet-level DHCP options from the cached Kea configuration. + # + # @param subnet [Proxy::DHCP::Subnet] The subnet to load options for. + # @return [void] + def load_subnet_options(subnet) + opts = @subnet_service.subnet_options[subnet.network] + return unless opts + + apply_boot_subnet_options(subnet, opts) + apply_mapped_subnet_options(subnet, opts) + end + + private + + # Applies the boot/server fields, which are top-level Kea config fields + # rather than `option-data` entries (so they are not in OPTION_MAP). + # + # @param subnet [Proxy::DHCP::Subnet] The subnet to modify. + # @param opts [Hash] The cached options hash. + # @return [void] + def apply_boot_subnet_options(subnet, opts) + subnet.options[:nextServer] = opts['next-server'] if opts['next-server'] + subnet.options[:filename] = opts['boot-file-name'] if opts['boot-file-name'] + end + + # Applies every `option-data`-derived subnet option using OPTION_MAP as the + # single source of truth for the Kea-name -> Foreman-key (and list) mapping. + # + # @param subnet [Proxy::DHCP::Subnet] The subnet to modify. + # @param opts [Hash] The cached options hash. + # @return [void] + def apply_mapped_subnet_options(subnet, opts) + SubnetService::OPTION_MAP.each do |kea_name, mapping| + value = opts[kea_name] + next unless value + + subnet.options[mapping[:key]] = mapping[:list] ? split_option(value) : value + end + end + + # Splits a comma-separated option string into a trimmed array. + # + # @param value [String] The comma-separated string. + # @return [Array] The split and stripped values. + def split_option(value) + value.split(',').map(&:strip) + end + + # Deletes a reservation from Kea by MAC address. + # + # @param record [Proxy::DHCP::Reservation] The reservation to delete. + # @return [void] + def del_reservation(record) + subnet_id = find_subnet_id!(record.subnet.network) + + args = { + 'subnet-id': subnet_id, + 'identifier-type': 'hw-address', + 'identifier' => record.mac + } + + @client.post_command('dhcp4', 'reservation-del', args) + begin + subnet_service.delete_host(record) + rescue StandardError => e + logger.error "Cache update failed after successful reservation-del for MAC #{record.mac}. " \ + "Kea and cache are out of sync: #{e.message}" + raise + end + + logger.info "Successfully deleted reservation for MAC #{record.mac} and IP #{record.ip}" + end + + # Deletes a lease from Kea by IP address. + # + # @param record [Proxy::DHCP::Lease] The lease to delete. + # @return [void] + def del_lease(record) + subnet_id = find_subnet_id!(record.subnet.network) + + args = { + 'subnet-id': subnet_id, + 'ip-address': record.ip + } + + @client.post_command('dhcp4', 'lease4-del', args) + begin + subnet_service.delete_lease(record) + rescue StandardError => e + logger.error "Cache update failed after successful lease4-del for IP #{record.ip}. " \ + "Kea and cache are out of sync: #{e.message}" + raise + end + + logger.info "Successfully deleted lease for IP #{record.ip}" + end + + # Looks up the Kea subnet-id for a network address, raising if not found. + # + # @param network [String] The subnet network address. + # @return [Integer] The Kea subnet-id. + # @raise [Proxy::DHCP::Error] if the subnet-id is not in the map. + def find_subnet_id!(network) + subnet_id = @subnet_service.kea_id_map[network] + raise Proxy::DHCP::Error, "Unable to find Kea subnet-id for network #{network}" unless subnet_id + + subnet_id + end + + # Builds the initial hash of arguments required for a Kea reservation. + # + # @param record [Proxy::DHCP::Reservation] The reservation object from the parent class. + # @return [Hash] A hash containing the base arguments for the Kea API. + def build_base_reservation_args(record) + subnet_id = find_subnet_id!(record.subnet.network) + + { + 'subnet-id': subnet_id, + 'ip-address': record.ip, + 'hw-address': record.mac, + hostname: record.name + } + end + + # Adds next-server and boot-file-name options to the reservation arguments hash. + # + # @param reservation_args [Hash] The hash of arguments to be modified. + # @param options [Hash] The original options hash from Foreman. + # @return [void] + def add_boot_and_server_options(reservation_args, options) + next_server_value = options['nextServer'] + reservation_args[:'next-server'] = resolve_hostname(next_server_value) unless next_server_value.to_s.empty? + + reservation_args[:'boot-file-name'] = options['filename'] unless options['filename'].to_s.empty? + end + + # Resolves a hostname to an IP address. If the provided string is already + # an IP, it is returned directly. + # + # @param hostname [String] The hostname or IP address string to resolve. + # @return [String] The resolved IPv4 address. + # @raise [Proxy::DHCP::Error] if the hostname cannot be resolved. + def resolve_hostname(hostname) + return hostname if hostname =~ Regexp.union(Resolv::IPv4::Regex) + + Resolv.getaddress(hostname) + rescue Resolv::ResolvError => e + raise Proxy::DHCP::Error, "Could not resolve next-server hostname '#{hostname}': #{e.message}" + end + + # Builds the array of DHCP options (e.g. routers, ntp-servers, dns-servers) for the reservation. + # + # @param options [Hash] The original options hash from Foreman. + # @return [Array] An array of option hashes for the Kea API. + def build_option_data(options) + option_data = [] + SubnetService::OPTION_MAP.each do |kea_name, mapping| + add_dhcp_option(option_data, kea_name, options[mapping[:key].to_s]) + end + option_data + end + + # A helper to add a DHCP option to the data array if the value exists. + # + # @param option_data [Array] The array of options to be modified. + # @param name [String] The name of the DHCP option (e.g. 'routers'). + # @param value [String, Array] The value of the option. + # @return [void] + def add_dhcp_option(option_data, name, value) + return if value.nil? || (value.respond_to?(:empty?) && value.empty?) + + option_data << { name: name, data: Array(value).join(',') } + end + end + end + end +end diff --git a/modules/dhcp_kea/dhcp_kea_api_plugin.rb b/modules/dhcp_kea/dhcp_kea_api_plugin.rb new file mode 100644 index 000000000..96f4c07ca --- /dev/null +++ b/modules/dhcp_kea/dhcp_kea_api_plugin.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'smart_proxy_dhcp_kea_api/dhcp_kea_api_version' +require 'smart_proxy_dhcp_kea_api/plugin_configuration' + +module Proxy + module DHCP + module KeaApi + # The main plugin class for the `dhcp_kea_api` provider. This class serves + # as the entry point for the Foreman Smart Proxy to load and configure the + # plugin. It defines the plugin's name, version, dependencies on other + # modules, default settings, and hooks into the dependency injection framework. + # + # @see Proxy::DHCP::KeaApi::PluginConfiguration For how dependencies are loaded and wired. + class Plugin < ::Proxy::Provider + # Registers the provider with the Smart Proxy, giving it a unique name + # (`:dhcp_kea_api`) and sourcing the version from the VERSION constant. + plugin :dhcp_kea_api, ::Proxy::DHCP::KeaApi::VERSION + + # Declares a dependency on the core Smart Proxy DHCP module. This ensures + # that the base classes we inherit from (like DHCP::Server) are available. + requires :dhcp, '>= 1.17' + + # Defines the default settings for this provider. These values are used + # if they are not overridden in the user's settings file + # (`/etc/foreman-proxy/settings.d/dhcp_kea_api.yml`). + # The `kea_api_username` and `kea_api_password` enable HTTP Basic + # Authentication when the Kea control agent requires it. + default_settings kea_api_url: 'http://127.0.0.1:8000/', + kea_api_username: nil, + kea_api_password: nil, + blacklist_duration_minutes: 5, + open_timeout: 5, + read_timeout: 10, + cache_ttl: 60 + + # Hooks into the Smart Proxy's dependency injection (DI) framework. These lines + # delegate the responsibility of loading the required classes and wiring up + # their dependencies to the `PluginConfiguration` class. This keeps this + # main plugin file clean and declarative. + load_classes ::Proxy::DHCP::KeaApi::PluginConfiguration + load_dependency_injection_wirings ::Proxy::DHCP::KeaApi::PluginConfiguration + + # Tells the Smart Proxy to start these specific services from our DI container + # when the provider is enabled. `:subnet_service` manages the data cache, + # and `:unused_ips` handles IP blacklist management. + start_services :subnet_service, :unused_ips + end + end + end +end diff --git a/modules/dhcp_kea/dhcp_kea_api_subnet_service.rb b/modules/dhcp_kea/dhcp_kea_api_subnet_service.rb new file mode 100644 index 000000000..6855b2d52 --- /dev/null +++ b/modules/dhcp_kea/dhcp_kea_api_subnet_service.rb @@ -0,0 +1,417 @@ +# frozen_string_literal: true + +require 'ipaddr' +require 'dhcp_common/dhcp_common' +require 'dhcp_common/subnet_service' + +module Proxy + module DHCP + module KeaApi + # Manages the in-memory cache of DHCP data for the Kea provider. + # + # This class is responsible for fetching all subnet, reservation, and lease + # information from the Kea API. It inherits from the core `DHCP::SubnetService` + # to get the underlying data structures (e.g. hashes for leases and hosts) + # and caching logic. Its primary public method, `load!`, orchestrates the + # population of this cache. It also maintains a mapping of Foreman subnet + # networks to their internal Kea API subnet IDs. + class SubnetService < ::Proxy::DHCP::SubnetService + include Proxy::Log + + # Holds the data fetched during a reload before it is atomically swapped + # into the live cache. Wraps a throwaway parent SubnetService (for its + # thread-safe stores and lookup helpers) plus the Kea-specific maps, so + # the loaders can populate it exactly as they would the live object. + class Staging + attr_reader :service, :kea_id_map, :subnet_options + + # @return [void] + def initialize + @service = ::Proxy::DHCP::SubnetService.initialized_instance + @kea_id_map = {} + @subnet_options = {} + end + end + + # Maps Kea option-data names to their Foreman option keys and whether they are lists. + OPTION_MAP = { + 'routers' => { key: :routers, list: true }, + 'domain-name-servers' => { key: :dns_servers, list: true }, + 'domain-name' => { key: :domain_name, list: false }, + 'ntp-servers' => { key: :ntp_servers, list: true } + }.freeze + + # A hash mapping a subnet network address (e.g. "192.168.1.0") to its + # internal Kea API integer ID (e.g. 1). This is crucial for making + # API calls that require a `subnet-id`. + attr_reader :kea_id_map + + # A hash mapping a subnet network address to its DHCP options hash. + # Used by the Provider to serve subnet-level options back to Foreman. + attr_reader :subnet_options + + # Initialises the SubnetService. + # + # @param client [Proxy::DHCP::KeaApi::Client] The client for communicating with the Kea API. + # @param leases_by_ip [Proxy::MemoryStore] A memory store for leases, passed to the parent class. + # @param leases_by_mac [Proxy::MemoryStore] A memory store for leases, passed to the parent class. + # @param reservations_by_ip [Proxy::MemoryStore] A memory store for reservations, passed to the parent class. + # @param reservations_by_mac [Proxy::MemoryStore] A memory store for reservations, passed to the parent class. + # @param reservations_by_name [Proxy::MemoryStore] A memory store for reservations, passed to the parent class. + # @param cache_ttl [Integer] Number of seconds before the cache is considered stale (default: 60). + # @param managed_subnets [Array, nil] List of CIDR networks to manage. Nil means manage all. + # @return [void] + # rubocop:disable Metrics/ParameterLists + def initialize(client, leases_by_ip, leases_by_mac, reservations_by_ip, reservations_by_mac, reservations_by_name, + cache_ttl: 60, managed_subnets: nil) + @client = client + @kea_id_map = {} + @subnet_options = {} + @cache_ttl = cache_ttl + @loaded_at = nil + @reload_mutex = Mutex.new + @managed_subnets = parse_managed_subnets(managed_subnets) + super(leases_by_ip, leases_by_mac, reservations_by_ip, reservations_by_mac, reservations_by_name) + end + # rubocop:enable Metrics/ParameterLists + + # The main entry point for loading all DHCP data from the Kea server. + # + # All Kea API calls populate a fresh, off-to-the-side set of stores + # (`staging`); only once every fetch has succeeded are the new stores + # swapped into the live cache under the parent's monitor. This keeps the + # slow network fetch off the live cache so concurrent readers never see a + # half-populated state, and leaves the previous cache intact if the fetch + # fails partway through. + # + # @return [true] on success. + # @raise [Proxy::DHCP::Error] if any part of the loading process fails. + # @see #load_subnets_and_reservations_from_kea + # @see #load_leases_from_kea + # rubocop:disable Naming/PredicateMethod + def load! + staging = Staging.new + + load_subnets_and_reservations_from_kea(staging) + load_reservations_from_database(staging) + load_leases_from_kea(staging) + + commit(staging) + true + end + # rubocop:enable Naming/PredicateMethod + + # Returns all subnets, refreshing the cache if stale. + # + # @return [Array] All cached subnets. + def all_subnets + reload_if_stale! + super + end + + # Returns all host reservations, refreshing the cache if stale. + # + # @param subnet_address [String, nil] Optional subnet to filter by. + # @return [Array] All cached reservations. + def all_hosts(subnet_address = nil) + reload_if_stale! + super + end + + # Returns all leases, refreshing the cache if stale. + # + # @param subnet_address [String, nil] Optional subnet to filter by. + # @return [Array] All cached leases. + def all_leases(subnet_address = nil) + reload_if_stale! + super + end + + # Fetches all subnets and their associated reservations from the Kea API. + # This single `config-get` call is the most efficient way to get all static configuration. + # + # @param staging [Staging] The buffer to populate with fetched data. + # @return [void] + # @raise [Proxy::DHCP::Error] if the API call fails. + # @raise [IPAddr::InvalidAddressError] if a subnet address from Kea is invalid. + # @see Proxy::DHCP::KeaApi::Client#post_command + def load_subnets_and_reservations_from_kea(staging) + config = @client.post_command('dhcp4', 'config-get') + subnets_data = config&.dig('Dhcp4', 'subnet4') + return unless subnets_data + + subnets_data.each do |subnet_data| + process_subnet(subnet_data, staging) + end + rescue Proxy::DHCP::Error => e + logger.error "Failed to load subnets and reservations from Kea: #{e.message}" + raise + rescue IPAddr::InvalidAddressError => e + logger.error "Failed to parse subnet from Kea, invalid address found: #{e.message}" + raise + end + + # Fetches dynamically added reservations from Kea's hosts-database via + # `reservation-get-all`. These are not included in `config-get` which only + # returns static reservations from the config file. + # + # @param staging [Staging] The buffer to populate with fetched data. + # @return [void] + def load_reservations_from_database(staging) + return if staging.kea_id_map.empty? + + staging.kea_id_map.each do |network, subnet_id| + response = @client.post_command('dhcp4', 'reservation-get-all', { 'subnet-id': subnet_id }) + hosts = response&.[]('hosts') + next unless hosts + + subnet_obj = staging.service.find_subnet(network) + next unless subnet_obj + + hosts.each do |res_data| + next if staging.service.find_host_by_mac(network, res_data['hw-address']) + + process_reservation(res_data, subnet_obj, staging) + end + end + rescue Proxy::DHCP::Error => e + logger.debug "reservation-get-all not available or failed: #{e.message}" + end + + # Fetches all active leases from the Kea API for the subnets currently in the cache. + # + # @param staging [Staging] The buffer to populate with fetched data. + # @return [void] + # @raise [Proxy::DHCP::Error] if the API call fails. + # @see Proxy::DHCP::KeaApi::Client#post_command + def load_leases_from_kea(staging) + return if staging.kea_id_map.empty? + + response = @client.post_command('dhcp4', 'lease4-get-all', { subnets: staging.kea_id_map.values }) + return unless response && response['leases'] + + response['leases'].each do |lease| + process_lease(lease, staging) + end + rescue Proxy::DHCP::Error => e + logger.error "Failed to load all leases from Kea: #{e.message}" + raise + end + + private + + # Reloads the cache if it is older than the configured TTL. Only one thread + # performs the reload at a time (single-flight via `try_lock`); other threads + # that observe a stale cache serve the current snapshot instead of piling on + # duplicate, concurrent reloads. + # + # @return [void] + def reload_if_stale! + return unless stale? + return unless @reload_mutex.try_lock + + begin + return unless stale? # re-check: another thread may have just reloaded + + logger.debug "Cache TTL (#{@cache_ttl}s) expired, reloading from Kea" + load! + ensure + @reload_mutex.unlock + end + end + + # Atomically replaces the live cache with the freshly-staged data. The swap + # runs under the parent's monitor so that readers (which take the same lock) + # observe either the entire old cache or the entire new one, never a mix. + # + # @param staging [Staging] The fully-populated buffer to promote. + # @return [void] + def commit(staging) + m.synchronize do + @subnets = staging.service.subnets + @leases_by_ip = staging.service.leases_by_ip + @leases_by_mac = staging.service.leases_by_mac + @reservations_by_ip = staging.service.reservations_by_ip + @reservations_by_mac = staging.service.reservations_by_mac + @reservations_by_name = staging.service.reservations_by_name + @kea_id_map = staging.kea_id_map + @subnet_options = staging.subnet_options + @loaded_at = Time.now + end + end + + # Checks whether the cache has exceeded its TTL. + # + # @return [Boolean] true if the cache needs refreshing. + def stale? + return true unless @loaded_at + + (Time.now - @loaded_at) > @cache_ttl + end + + # Parses the managed_subnets setting into IPAddr objects for matching. + # + # @param managed_subnets [Array, String, nil] CIDR networks to manage. + # @return [Array, nil] Parsed networks, or nil to manage all. + def parse_managed_subnets(managed_subnets) + return nil if managed_subnets.nil? + + subnets = Array(managed_subnets) + return nil if subnets.empty? + + subnets.map { |cidr| IPAddr.new(cidr) } + end + + # Checks whether a subnet should be managed by this proxy. + # + # @param subnet_addr [String] The network address of the subnet. + # @return [Boolean] true if the subnet should be managed. + def managed?(subnet_addr) + return true unless @managed_subnets + + ip = IPAddr.new(subnet_addr) + # include? already returns true for an exact match (e.g. a /32 entry), so + # no separate equality check is needed. + @managed_subnets.any? { |network| network.include?(ip) } + end + + # Parses a single subnet hash from the API response, creates the necessary + # Foreman Subnet and Reservation objects, and adds them to the cache. + # + # @param subnet_data [Hash] The hash representing a single subnet from Kea's `config-get` response. + # @param staging [Staging] The buffer to populate with the parsed subnet. + # @return [void] + # @raise [IPAddr::InvalidAddressError] if the subnet string is not a valid IP address. + def process_subnet(subnet_data, staging) + ip_object = IPAddr.new(subnet_data['subnet']) + subnet_addr = ip_object.to_s + mask = IPAddr.new('255.255.255.255').mask(ip_object.prefix).to_s + + return unless managed?(subnet_addr) + + options = { + routers: extract_routers(subnet_data), + range: extract_range(subnet_data) + }.compact + subnet = ::Proxy::DHCP::Subnet.new(subnet_addr, mask, options) + + staging.service.add_subnet(subnet) + staging.kea_id_map[subnet.network] = subnet_data['id'] + staging.subnet_options[subnet.network] = extract_subnet_options(subnet_data) + logger.info "Loaded subnet #{subnet.network}/#{subnet.netmask} and mapped to Kea ID #{subnet_data['id']}" + + subnet_data['reservations']&.each do |res_data| + process_reservation(res_data, subnet, staging) + end + end + + # Extracts all DHCP options from a subnet into a normalised hash. + # + # @param subnet_data [Hash] The hash representing a single subnet. + # @return [Hash] A hash of option names to their values. + def extract_subnet_options(subnet_data) + opts = {} + option_data = subnet_data['option-data'] || [] + option_data.each do |opt| + opts[opt['name']] = opt['data'] + end + opts['next-server'] = subnet_data['next-server'] if meaningful_boot_value?(subnet_data['next-server']) + opts['boot-file-name'] = subnet_data['boot-file-name'] if meaningful_boot_value?(subnet_data['boot-file-name']) + opts + end + + # Returns true when a Kea boot field carries a real value. Kea reports an + # unset next-server as "0.0.0.0" and an unset boot-file-name as "", which + # are placeholders that must not be round-tripped back to Foreman as if a + # user had configured them (doing so triggers spurious DHCP rebuilds). + # + # @param value [String, nil] The raw value from Kea. + # @return [Boolean] true if the value is present and not a placeholder. + def meaningful_boot_value?(value) + !value.nil? && !value.to_s.strip.empty? && value != '0.0.0.0' + end + + # Extracts and formats the router data from a subnet's options. + # + # @param subnet_data [Hash] The hash representing a single subnet. + # @return [Array, nil] An array of router IP addresses, or nil if none are found. + def extract_routers(subnet_data) + router_opt = subnet_data['option-data']&.find { |opt| opt['name'] == 'routers' } + data = router_opt&.[]('data') + data&.split(',')&.map(&:strip) + end + + # Extracts the IP range from a subnet's first pool. + # + # @param subnet_data [Hash] The hash representing a single subnet. + # @return [Array, nil] A two-element array containing the start and end of the range, or nil. + def extract_range(subnet_data) + pool_string = subnet_data.dig('pools', 0, 'pool') + pool_string&.split('-')&.map(&:strip) + end + + # Creates a Foreman Reservation object from Kea data and adds it to the cache. + # Includes option-data, next-server, and boot-file-name so that Foreman can + # round-trip these values when querying existing reservations. + # + # @param res_data [Hash] The hash representing a single reservation. + # @param subnet [Proxy::DHCP::Subnet] The subnet object this reservation belongs to. + # @param staging [Staging] The buffer to populate with the parsed reservation. + # @return [void] + def process_reservation(res_data, subnet, staging) + opts = extract_reservation_options(res_data) + record = ::Proxy::DHCP::Reservation.new( + res_data['hostname'], res_data['ip-address'], res_data['hw-address'], subnet, opts + ) + staging.service.add_host(subnet.network, record) + logger.debug "Loaded reservation for #{res_data['hw-address']} on subnet #{subnet.network}" + end + + # Extracts Foreman-compatible options from a Kea reservation hash. + # + # @param res_data [Hash] The reservation data from Kea's config-get. + # @return [Hash] Options hash suitable for Proxy::DHCP::Reservation. + def extract_reservation_options(res_data) + opts = {} + opts[:nextServer] = res_data['next-server'] if meaningful_boot_value?(res_data['next-server']) + opts[:filename] = res_data['boot-file-name'] if meaningful_boot_value?(res_data['boot-file-name']) + map_option_data(opts, res_data['option-data'] || []) + opts + end + + # Applies Kea option-data entries to a Foreman options hash using OPTION_MAP. + # + # @param opts [Hash] The target options hash to populate. + # @param option_data [Array] The option-data array from Kea. + # @return [void] + def map_option_data(opts, option_data) + option_data.each do |opt| + mapping = OPTION_MAP[opt['name']] + next unless mapping + + data = opt['data'] + opts[mapping[:key]] = mapping[:list] ? data&.split(',')&.map(&:strip) : data + end + end + + # Creates a Foreman Lease object from Kea data and adds it to the cache. + # + # @param lease [Hash] The lease data from Kea's lease4-get-all response. + # @param staging [Staging] The buffer to populate with the parsed lease. + # @return [void] + def process_lease(lease, staging) + ip = lease['ip-address'] + mac = lease['hw-address'] + subnet_obj = staging.service.find_subnet(ip) + unless subnet_obj + logger.warn "Skipping lease for IP #{ip} as it does not belong to any known subnet." + return + end + + record = ::Proxy::DHCP::Lease.new(nil, ip, mac, subnet_obj, lease['cltt'], lease['expire'], 'active') + staging.service.add_lease(subnet_obj.network, record) + end + end + end + end +end diff --git a/modules/dhcp_kea/dhcp_kea_api_version.rb b/modules/dhcp_kea/dhcp_kea_api_version.rb new file mode 100644 index 000000000..766cd6cf0 --- /dev/null +++ b/modules/dhcp_kea/dhcp_kea_api_version.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Proxy + module DHCP + module KeaApi + # The current version of the smart_proxy_dhcp_kea_api gem. + VERSION = '2.1.0' + end + end +end diff --git a/modules/dhcp_kea/kea_api_client.rb b/modules/dhcp_kea/kea_api_client.rb new file mode 100644 index 000000000..d5decec51 --- /dev/null +++ b/modules/dhcp_kea/kea_api_client.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +require 'net/http' +require 'json' +require 'uri' + +module Proxy + module DHCP + module KeaApi + # A client for interacting with the ISC Kea DHCP server API. This class + # encapsulates the logic for creating JSON-RPC commands, sending them via + # HTTP, and handling the responses from the Kea server. + class Client + include Proxy::Log + + # Initialises a new Kea API client. + # + # @param url [String] The base URL of the Kea API endpoint (e.g. 'http://127.0.0.1:8000/'). + # @param username [String, nil] The username for HTTP Basic Authentication. + # @param password [String, nil] The password for HTTP Basic Authentication. + # @param open_timeout [Integer] Time in seconds to wait for the initial TCP connection to be established (defaults to 5). + # @param read_timeout [Integer] Time in seconds to wait for a response from the server after the connection is made (defaults to 10). + # @raise [ArgumentError] if the URL is blank, malformed, or not a valid HTTP/S URL. + # + # @example Basic Initialization + # client = Proxy::DHCP::KeaApi::Client.new(url: 'https://kea.example.com:8443') + # + # @example Initialization with Custom Timeouts + # client = Proxy::DHCP::KeaApi::Client.new( + # url: 'http://127.0.0.1:8000', + # username: 'myuser', + # password: 'mypassword', + # open_timeout: 2, + # read_timeout: 5 + # ) + def initialize(url:, username: nil, password: nil, open_timeout: 5, read_timeout: 10) + raise ArgumentError, 'Kea API URL cannot be nil or empty' if url.to_s.empty? + + @uri = URI.parse(url) + + raise ArgumentError, "Invalid Kea API URL: '#{url}' must be an HTTP or HTTPS URL" unless @uri.is_a?(URI::HTTP) || @uri.is_a?(URI::HTTPS) + + raise ArgumentError, "Invalid Kea API URL: '#{url}' is missing a host" unless @uri.host + + @username = username + @password = password + @open_timeout = open_timeout + @read_timeout = read_timeout + logger.info "Initializing Kea API client for URL: #{@uri} with timeouts (open: #{@open_timeout}s, read: #{@read_timeout}s)" + end + + # Constructs and sends a command to the Kea API and handles its response. + # This is the main public method for interacting with the Kea server. + # + # @param service [String] The Kea service to target (e.g. 'dhcp4'). + # @param command [String] The command to execute (e.g. 'config-get', 'reservation-add'). + # @param arguments [Hash] A hash of arguments required by the command. Defaults to an empty hash. + # @return [Hash] The 'arguments' hash from the Kea API response on success. + # @raise [Proxy::DHCP::Error] if the API returns an error or if there's a communication issue. + # This can be caused by underlying errors like `Net::ReadTimeout`, `Net::OpenTimeout`, + # `Errno::ECONNREFUSED`, or `JSON::ParserError`. + # + # @example Get the current DHCPv4 configuration + # client = Proxy::DHCP::KeaApi::Client.new(url: 'http://localhost:8000') + # config_response = client.post_command('dhcp4', 'config-get') + # # => {"Dhcp4"=>{"subnet4"=>[{"id"=>1, "subnet"=>"192.168.1.0/24", ...}]}} + # + # @example Add a DHCPv4 reservation + # client = Proxy::DHCP::KeaApi::Client.new(url: 'http://localhost:8000') + # add_response = client.post_command('dhcp4', 'reservation-add', { + # reservation: { + # 'subnet-id': 1, + # 'ip-address': '192.168.1.100', + # 'hw-address': '00:11:22:33:44:55', + # hostname: 'my-new-host' + # } + # }) + # # => {"text"=>"Reservation added successfully."} + # + # @see https://kea.readthedocs.io/en/latest/api.html General Kea Management API documentation. + # @see https://kea.readthedocs.io/en/latest/api.html#ref-reservation-add For the `reservation-add` command. + def post_command(service, command, arguments = {}) + header = { 'Content-Type' => 'application/json' } + payload = { + command: command, + service: [service], + arguments: arguments + } + + # This guard clause satisfies strict linters by ensuring the host is not nil in the local scope. + host = @uri.host + raise 'Internal error: Kea API client URI is missing a host' unless host + + http = Net::HTTP.new(host, @uri.port) + http.use_ssl = @uri.scheme == 'https' + http.open_timeout = @open_timeout + http.read_timeout = @read_timeout + request = Net::HTTP::Post.new(@uri.request_uri, header) + request.body = payload.to_json + request.basic_auth(@username, @password.to_s) if @username + + logger.debug "Sending command to Kea: #{payload.inspect}" + response = http.request(request) + + handle_response(response, command) + # This rescue block catches specific, expected network and parsing errors, + # wrapping them in a Foreman-specific error type for consistent handling. + rescue Net::ReadTimeout, Net::OpenTimeout, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, JSON::ParserError => e + logger.error "Failed to send command to Kea API: #{e.class.name} - #{e.message}" + raise Proxy::DHCP::Error, "Kea API communication error: #{e.message}" + end + + private + + # A private helper to parse the JSON response from Kea and route it based on success or failure. + # + # @param response [Net::HTTPResponse] The raw response object from the HTTP request. + # @param command [String] The original command that was sent, used for context-specific handling. + # @return [Hash] The 'arguments' hash from the response on success. + # @raise [Proxy::DHCP::Error] if the response indicates a failure, is malformed, or is empty. + # @raise [JSON::ParserError] if the response body is not valid JSON. + # @private + def handle_response(response, command) + body = JSON.parse(response.body) + logger.debug "Received response from Kea: #{body.inspect}" + + result = body.first if body.is_a?(Array) + raise Proxy::DHCP::Error, 'Kea API Error: Invalid or empty response from server' unless result + + # If the response is successful, return its arguments. Otherwise, raise an error. + if response_successful?(result, command) + # Provide a fallback of '{}' to prevent returning nil if the 'arguments' key is missing. + result['arguments'] || {} + else + error_message = result['text'] || 'Unknown error from Kea API' + raise Proxy::DHCP::Error, "Kea API Error: #{error_message}" + end + end + + # A private predicate method to determine if a Kea response is successful. + # + # @param result [Hash] The parsed result hash from the Kea response body. + # @param command [String] The original command sent, needed for special case handling. + # @return [Boolean] `true` if the response is considered a success, `false` otherwise. + # + # @see https://kea.readthedocs.io/en/stable/api.html For documentation on Kea API result codes. + # @private + def response_successful?(result, command) + result_code = result['result'] + raise Proxy::DHCP::Error, "Kea API Error: Response missing 'result' field" if result_code.nil? + + return true if result_code.zero? + + # Special case: 'lease4-get-all' is successful even with result code 3 (no leases found). + command == 'lease4-get-all' && result_code == 3 + end + end + end + end +end diff --git a/modules/dhcp_kea/plugin_configuration.rb b/modules/dhcp_kea/plugin_configuration.rb new file mode 100644 index 000000000..a80bd7acc --- /dev/null +++ b/modules/dhcp_kea/plugin_configuration.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +module Proxy + module DHCP + module KeaApi + # This class manages the setup and configuration of the KeaApi plugin's + # internal components. It follows a pattern used by the Foreman Smart Proxy's + # dependency injection (DI) framework. Its responsibilities are divided into + # two main parts: loading the necessary classes into memory and then "wiring" + # them together by defining how each service gets created and what its + # dependencies are. + class PluginConfiguration + # Loads all the necessary classes for this provider into memory. + # This is called by the Smart Proxy before the dependency injection + # wirings are configured. + + def load_classes + require 'dhcp_common/free_ips' + require 'smart_proxy_dhcp_kea_api/kea_api_client' + require 'smart_proxy_dhcp_kea_api/dhcp_kea_api_subnet_service' + require 'smart_proxy_dhcp_kea_api/dhcp_kea_api_main' + end + + # Configures the dependency injection wirings for the KeaApi provider. + # The container is responsible for creating and managing instances of our services. + # + # @param container [Proxy::DependencyInjection::Container] The DI container to register services with. + # @param settings [Hash] The settings hash for this provider. + + def load_dependency_injection_wirings(container, settings) + # A singleton service that manages the temporary blacklisting of suggested IP addresses + # to prevent race conditions. Its duration is configured via the settings file. + container.singleton_dependency :unused_ips, -> { ::Proxy::DHCP::FreeIps.new(settings[:blacklist_duration_minutes]) } + + # The custom client for communicating with the Kea API. This is registered as a singleton + # so that a single client instance (with its configuration) is shared across all requests. + # @see Proxy::DHCP::KeaApi::Client#initialize + container.singleton_dependency :kea_client, (lambda do + ::Proxy::DHCP::KeaApi::Client.new( + url: settings[:kea_api_url], + username: settings[:kea_api_username], + password: settings[:kea_api_password], + open_timeout: settings[:open_timeout], + read_timeout: settings[:read_timeout] + ) + end) + + # The custom service for caching all subnet, reservation, and lease data. + # This is a singleton because we want one central, authoritative cache that all + # requests can share. Each store must be a separate instance to avoid collisions + # between leases and reservations keyed by the same IP/MAC. + # @see Proxy::DHCP::KeaApi::SubnetService#initialize + container.singleton_dependency :subnet_service, (lambda do + ::Proxy::DHCP::KeaApi::SubnetService.new( + container.get_dependency(:kea_client), + ::Proxy::MemoryStore.new, + ::Proxy::MemoryStore.new, + ::Proxy::MemoryStore.new, + ::Proxy::MemoryStore.new, + ::Proxy::MemoryStore.new, + cache_ttl: settings[:cache_ttl], + managed_subnets: settings.fetch(:managed_subnets, nil) + ) + end) + + # The main provider class that ties everything together. This is the entry point + # for handling DHCP requests from Foreman. It depends on the subnet service, + # the API client, and the IP blacklist service to do its job. + # @see Proxy::DHCP::KeaApi::Provider#initialize + container.singleton_dependency :dhcp_provider, (lambda do + ::Proxy::DHCP::KeaApi::Provider.new( + container.get_dependency(:subnet_service), + container.get_dependency(:kea_client), + container.get_dependency(:unused_ips) + ) + end) + end + end + end + end +end diff --git a/test/dhcp_kea/dhcp_kea_api_subnet_service_spec.rb b/test/dhcp_kea/dhcp_kea_api_subnet_service_spec.rb new file mode 100644 index 000000000..b70a44f35 --- /dev/null +++ b/test/dhcp_kea/dhcp_kea_api_subnet_service_spec.rb @@ -0,0 +1,320 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'dhcp_common/dhcp_common' + +# Specs for the SubnetService, which loads DHCP configuration from the Kea API +# into an in-memory cache for the provider to use. +# @see Proxy::DHCP::KeaApi::SubnetService +describe Proxy::DHCP::KeaApi::SubnetService do + let(:client) { instance_double(Proxy::DHCP::KeaApi::Client) } + + let(:stores) do + { + leases_by_ip: Proxy::MemoryStore.new, + leases_by_mac: Proxy::MemoryStore.new, + reservations_by_ip: Proxy::MemoryStore.new, + reservations_by_mac: Proxy::MemoryStore.new, + reservations_by_name: Proxy::MemoryStore.new + } + end + + let(:service) do + described_class.new(client, stores[:leases_by_ip], stores[:leases_by_mac], stores[:reservations_by_ip], stores[:reservations_by_mac], + stores[:reservations_by_name]) + end + + before do + allow(client).to receive(:post_command) + .with('dhcp4', 'reservation-get-all', anything) + .and_raise(Proxy::DHCP::Error, 'not supported') + end + + describe '#load!' do + before do + allow(client).to receive(:post_command) + .with('dhcp4', 'config-get') + .and_return(successful_config_get) + allow(client).to receive(:post_command) + .with('dhcp4', 'lease4-get-all', { subnets: [1] }) + .and_return(successful_lease_get) + end + + it 'loads one subnet into the cache' do + service.load! + expect(service.subnets.count).to eq(1) + end + + it 'finds the loaded subnet by its network address' do + service.load! + expect(service.find_subnet('192.168.1.0')).not_to be_nil + end + + it 'populates the kea_id_map' do + service.load! + expect(service.kea_id_map).to eq('192.168.1.0' => 1) + end + + context 'when loading leases' do + let(:lease) do + service.load! + leases_by_ip = service.instance_variable_get(:@leases_by_ip) + internal_store = leases_by_ip.instance_variable_get(:@root) + internal_store['192.168.1.0']['192.168.1.11'] + end + + it 'creates a Lease object' do + expect(lease).to be_a(Proxy::DHCP::Lease) + end + + it 'assigns the correct IP to the lease' do + expect(lease.ip).to eq('192.168.1.11') + end + end + end + + describe 'reservation option round-tripping' do + before do + allow(client).to receive(:post_command) + .with('dhcp4', 'config-get') + .and_return(config_get_with_options) + allow(client).to receive(:post_command) + .with('dhcp4', 'lease4-get-all', anything) + .and_return({ 'leases' => [] }) + end + + let(:reservation) do + service.load! + reservations_by_mac = service.instance_variable_get(:@reservations_by_mac) + internal_store = reservations_by_mac.instance_variable_get(:@root) + internal_store['192.168.1.0']['aa:bb:cc:dd:ee:ff'] + end + + it 'preserves next-server on the reservation' do + expect(reservation.options[:nextServer]).to eq('192.168.1.254') + end + + it 'preserves boot-file-name on the reservation' do + expect(reservation.options[:filename]).to eq('pxelinux.0') + end + + it 'preserves routers on the reservation' do + expect(reservation.options[:routers]).to eq(['192.168.1.1']) + end + + it 'preserves dns_servers on the reservation' do + expect(reservation.options[:dns_servers]).to eq(%w[10.0.0.1 10.0.0.2]) + end + end + + describe 'placeholder boot values' do + before do + allow(client).to receive(:post_command) + .with('dhcp4', 'config-get') + .and_return(config_get_with_placeholders) + allow(client).to receive(:post_command) + .with('dhcp4', 'lease4-get-all', anything) + .and_return({ 'leases' => [] }) + service.load! + end + + let(:reservation) do + internal = service.instance_variable_get(:@reservations_by_mac).instance_variable_get(:@root) + internal['192.168.1.0']['aa:bb:cc:dd:ee:ff'] + end + + it 'does not set nextServer when Kea reports the "0.0.0.0" placeholder' do + expect(reservation.options).not_to have_key(:nextServer) + end + + it 'does not set filename when Kea reports an empty boot-file-name' do + expect(reservation.options).not_to have_key(:filename) + end + + it 'omits the placeholder next-server from cached subnet options' do + expect(service.subnet_options['192.168.1.0']).not_to have_key('next-server') + end + + it 'omits the empty boot-file-name from cached subnet options' do + expect(service.subnet_options['192.168.1.0']).not_to have_key('boot-file-name') + end + end + + describe 'loading reservations from the hosts-database' do + before do + allow(client).to receive(:post_command) + .with('dhcp4', 'config-get') + .and_return(successful_config_get) + allow(client).to receive(:post_command) + .with('dhcp4', 'lease4-get-all', anything) + .and_return({ 'leases' => [] }) + allow(client).to receive(:post_command) + .with('dhcp4', 'reservation-get-all', anything) + .and_return(reservation_get_all_success) + service.load! + end + + it 'adds a database-only reservation to the cache' do + expect(service.find_host_by_mac('192.168.1.0', '11:22:33:44:55:66')).not_to be_nil + end + + it 'does not duplicate a reservation already loaded from config-get' do + internal = service.instance_variable_get(:@reservations_by_ip).instance_variable_get(:@root) + expect(internal['192.168.1.0']['192.168.1.5'].size).to eq(1) + end + end + + describe 'subnet options caching' do + before do + allow(client).to receive(:post_command) + .with('dhcp4', 'config-get') + .and_return(config_get_with_options) + allow(client).to receive(:post_command) + .with('dhcp4', 'lease4-get-all', anything) + .and_return({ 'leases' => [] }) + service.load! + end + + it 'stores subnet-level next-server' do + expect(service.subnet_options['192.168.1.0']['next-server']).to eq('192.168.1.254') + end + + it 'stores subnet-level boot-file-name' do + expect(service.subnet_options['192.168.1.0']['boot-file-name']).to eq('pxelinux.0') + end + + it 'stores subnet-level domain-name-servers' do + expect(service.subnet_options['192.168.1.0']['domain-name-servers']).to eq('8.8.8.8,8.8.4.4') + end + + it 'stores subnet-level domain-name' do + expect(service.subnet_options['192.168.1.0']['domain-name']).to eq('example.com') + end + end + + describe 'cache TTL' do + let(:service_with_ttl) do + described_class.new(client, stores[:leases_by_ip], stores[:leases_by_mac], stores[:reservations_by_ip], + stores[:reservations_by_mac], stores[:reservations_by_name], cache_ttl: 30) + end + + before do + allow(client).to receive(:post_command) + .with('dhcp4', 'config-get') + .and_return(successful_config_get) + allow(client).to receive(:post_command) + .with('dhcp4', 'lease4-get-all', anything) + .and_return(successful_lease_get) + end + + it 'does not reload when cache is fresh' do + service_with_ttl.load! + service_with_ttl.all_subnets + expect(client).to have_received(:post_command).with('dhcp4', 'config-get').once + end + + it 'reloads when cache is stale' do + service_with_ttl.load! + service_with_ttl.instance_variable_set(:@loaded_at, Time.now - 60) + service_with_ttl.all_subnets + expect(client).to have_received(:post_command).with('dhcp4', 'config-get').twice + end + end + + describe 'atomic reload' do + before do + allow(client).to receive(:post_command) + .with('dhcp4', 'config-get') + .and_return(successful_config_get) + allow(client).to receive(:post_command) + .with('dhcp4', 'lease4-get-all', anything) + .and_return(successful_lease_get) + service.load! + end + + # The stale-triggered reload must raise but must NOT leave the cache empty: + # data is staged off to the side and only swapped in on success, so a failed + # fetch leaves the previous cache intact (no clear-before-fetch). + it 'preserves the previous cache when a reload fails', :aggregate_failures do + allow(client).to receive(:post_command) + .with('dhcp4', 'config-get') + .and_raise(Proxy::DHCP::Error, 'kea down') + service.instance_variable_set(:@loaded_at, Time.now - 120) + + expect { service.all_subnets }.to raise_error(Proxy::DHCP::Error) + expect(service.subnets.count).to eq(1) + expect(service.find_subnet('192.168.1.0')).not_to be_nil + end + end + + describe 'single-flight reload' do + before do + @config_get_calls = 0 + counter_mutex = Mutex.new + allow(client).to receive(:post_command).with('dhcp4', 'config-get') do + counter_mutex.synchronize { @config_get_calls += 1 } + sleep 0.05 # widen the window so concurrent readers overlap the reload + successful_config_get + end + allow(client).to receive(:post_command) + .with('dhcp4', 'lease4-get-all', anything) + .and_return(successful_lease_get) + end + + it 'reloads only once when many threads observe a stale cache' do + service.load! # 1st config-get + service.instance_variable_set(:@loaded_at, Time.now - 120) + + threads = Array.new(8) { Thread.new { service.all_subnets } } + threads.each(&:join) + + # 1 initial load + exactly 1 single-flighted reload, not one per thread. + expect(@config_get_calls).to eq(2) + end + end + + describe 'managed subnet filtering' do + let(:filtered_service) do + described_class.new(client, stores[:leases_by_ip], stores[:leases_by_mac], stores[:reservations_by_ip], + stores[:reservations_by_mac], stores[:reservations_by_name], + managed_subnets: ['192.168.1.0/24']) + end + + before do + allow(client).to receive(:post_command) + .with('dhcp4', 'config-get') + .and_return(config_get_multi_subnet) + allow(client).to receive(:post_command) + .with('dhcp4', 'lease4-get-all', anything) + .and_return({ 'leases' => [] }) + end + + it 'only loads managed subnets' do + filtered_service.load! + expect(filtered_service.subnets.count).to eq(1) + end + + it 'loads the matching subnet' do + filtered_service.load! + expect(filtered_service.find_subnet('192.168.1.0')).not_to be_nil + end + + it 'excludes the unmanaged subnet' do + filtered_service.load! + expect(filtered_service.find_subnet('10.0.0.0')).to be_nil + end + + context 'when a managed entry is a host address without a prefix' do + let(:host_filtered_service) do + described_class.new(client, stores[:leases_by_ip], stores[:leases_by_mac], stores[:reservations_by_ip], + stores[:reservations_by_mac], stores[:reservations_by_name], + managed_subnets: ['192.168.1.0']) + end + + it 'still matches the subnet whose network equals that address' do + host_filtered_service.load! + expect(host_filtered_service.find_subnet('192.168.1.0')).not_to be_nil + end + end + end +end diff --git a/test/dhcp_kea/fixtures/kea_api_stubs.rb b/test/dhcp_kea/fixtures/kea_api_stubs.rb new file mode 100644 index 000000000..4400745a4 --- /dev/null +++ b/test/dhcp_kea/fixtures/kea_api_stubs.rb @@ -0,0 +1,161 @@ +# frozen_string_literal: true + +# This module provides a set of helper methods that return mock data structures, +# mimicking the JSON responses from the ISC Kea API. This allows us to test the client +# and services without needing a live Kea server. +module KeaApiStubs + # A successful response for a 'config-get' command. + # @return [Hash] A hash representing the Kea DHCPv4 configuration. + def successful_config_get + { + "Dhcp4" => { + "subnet4" => [ + { + "id" => 1, + "subnet" => "192.168.1.0/24", + "pools" => [{ "pool" => "192.168.1.10-192.168.1.20" }], + "option-data" => [{ "name" => "routers", "data" => "192.168.1.1" }], + "reservations" => [ + { "hw-address" => "aa:bb:cc:dd:ee:ff", "ip-address" => "192.168.1.5", "hostname" => "test-host" } + ] + } + ] + } + } + end + + # A config-get response with subnet-level options and reservation options. + # @return [Hash] A hash representing a richer Kea DHCPv4 configuration. + def config_get_with_options + { + "Dhcp4" => { + "subnet4" => [ + { + "id" => 1, + "subnet" => "192.168.1.0/24", + "pools" => [{ "pool" => "192.168.1.10-192.168.1.20" }], + "next-server" => "192.168.1.254", + "boot-file-name" => "pxelinux.0", + "option-data" => [ + { "name" => "routers", "data" => "192.168.1.1" }, + { "name" => "domain-name-servers", "data" => "8.8.8.8,8.8.4.4" }, + { "name" => "domain-name", "data" => "example.com" }, + { "name" => "ntp-servers", "data" => "192.168.1.253" } + ], + "reservations" => [ + { + "hw-address" => "aa:bb:cc:dd:ee:ff", + "ip-address" => "192.168.1.5", + "hostname" => "pxe-host", + "next-server" => "192.168.1.254", + "boot-file-name" => "pxelinux.0", + "option-data" => [ + { "name" => "routers", "data" => "192.168.1.1" }, + { "name" => "domain-name-servers", "data" => "10.0.0.1,10.0.0.2" } + ] + } + ] + } + ] + } + } + end + + # A config-get whose boot fields hold Kea's "unset" placeholders: next-server + # "0.0.0.0" and an empty boot-file-name, at both subnet and reservation level. + # @return [Hash] A hash representing a Kea config with placeholder boot values. + def config_get_with_placeholders + { + "Dhcp4" => { + "subnet4" => [ + { + "id" => 1, + "subnet" => "192.168.1.0/24", + "pools" => [{ "pool" => "192.168.1.10-192.168.1.20" }], + "next-server" => "0.0.0.0", + "boot-file-name" => "", + "option-data" => [{ "name" => "routers", "data" => "192.168.1.1" }], + "reservations" => [ + { + "hw-address" => "aa:bb:cc:dd:ee:ff", "ip-address" => "192.168.1.5", "hostname" => "plain-host", + "next-server" => "0.0.0.0", "boot-file-name" => "", "option-data" => [] + } + ] + } + ] + } + } + end + + # A successful 'reservation-get-all' response (hosts-database backend present). + # Includes the static reservation from successful_config_get (to exercise dedup) + # plus a database-only reservation. + # @return [Hash] A hash containing a list of host reservations. + def reservation_get_all_success + { + "hosts" => [ + { "hw-address" => "aa:bb:cc:dd:ee:ff", "ip-address" => "192.168.1.5", "hostname" => "test-host", "option-data" => [] }, + { "hw-address" => "11:22:33:44:55:66", "ip-address" => "192.168.1.30", "hostname" => "db-host", "option-data" => [] } + ] + } + end + + # A config-get with multiple subnets for managed_subnets filtering tests. + # @return [Hash] A hash with two subnets. + def config_get_multi_subnet + { + "Dhcp4" => { + "subnet4" => [ + { + "id" => 1, + "subnet" => "192.168.1.0/24", + "pools" => [{ "pool" => "192.168.1.10-192.168.1.20" }], + "option-data" => [{ "name" => "routers", "data" => "192.168.1.1" }], + "reservations" => [] + }, + { + "id" => 2, + "subnet" => "10.0.0.0/24", + "pools" => [{ "pool" => "10.0.0.10-10.0.0.100" }], + "option-data" => [{ "name" => "routers", "data" => "10.0.0.1" }], + "reservations" => [] + } + ] + } + } + end + + # A successful response for a 'lease4-get-all' command with one active lease. + # @return [Hash] A hash containing a list of leases. + def successful_lease_get + { + "leases" => [ + { "ip-address" => "192.168.1.11", "hw-address" => "ff:ee:dd:cc:bb:aa", "cltt" => 1678886400, "expire" => 1678890000 } + ] + } + end + + # A successful response for a 'reservation-add' command. + # @return [Hash] A hash indicating success. + def successful_reservation_add + { "result" => 0, "text" => "Reservation added successfully." } + end + + # A successful response for a 'reservation-del' command. + # @return [Hash] A hash indicating success. + def successful_reservation_del + { "result" => 0, "text" => "Reservation deleted successfully." } + end + + # A successful response for a 'lease4-del' command. + # @return [Hash] A hash indicating success. + def successful_lease_del + { "result" => 0, "text" => "Lease deleted successfully." } + end + + # An error response from the API. + # @return [Hash] A hash representing a generic API error. + def error_response + { "result" => 1, "text" => "Something went wrong." } + end +end diff --git a/test/dhcp_kea/kea_api_client_spec.rb b/test/dhcp_kea/kea_api_client_spec.rb new file mode 100644 index 000000000..9408d0ab4 --- /dev/null +++ b/test/dhcp_kea/kea_api_client_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# Specs for the low-level Kea API client. +# @see Proxy::DHCP::KeaApi::Client +describe Proxy::DHCP::KeaApi::Client do + # Defines a reusable client instance for tests. + let(:client) { described_class.new(url: 'http://localhost:8000') } + + # Tests for the client's initialisation process. + describe '#initialize' do + # It should create a new client instance when given a valid URL. + it 'creates a new client with a valid URL' do + expect(client).to be_a(described_class) + end + + # It should raise an error if the URL is not a valid URI. + it 'raises an ArgumentError if the URL is malformed' do + expect { described_class.new(url: 'invalid-url') }.to raise_error(ArgumentError) + end + end + + # Tests for sending commands to the Kea API. + describe '#post_command' do + # This block tests the behaviour when the Kea API returns a successful (result: 0) response. + context 'when the API call is successful' do + # It should correctly parse the response and return the 'arguments' hash. + it 'sends a command and returns the arguments hash from the response' do + # Use WebMock to stub the HTTP POST request to our test server. + response_body = [{ 'result' => 0, 'arguments' => { 'text' => 'Reservation added successfully.' } }] + stub_request(:post, 'http://localhost:8000/').to_return(status: 200, body: response_body.to_json, headers: {}) + + response = client.post_command('dhcp4', 'reservation-add', { 'subnet-id' => 1 }) + + # The client should parse the JSON and extract the 'arguments' hash. + expect(response).to eq({ 'text' => 'Reservation added successfully.' }) + end + end + + # This context tests how the client handles an error response (result: 1) from the API. + context 'when the API call returns an error' do + # It should see the non-zero result and raise a custom error. + it 'raises a Proxy::DHCP::Error' do + # Stub the request to return the standard error response from our stubs. + stub_request(:post, 'http://localhost:8000/').to_return(status: 200, body: [error_response].to_json, headers: {}) + + # We expect the client to see the non-zero result and raise our custom error class. + expect { client.post_command('dhcp4', 'reservation-add', { 'subnet-id' => 1 }) }.to raise_error(Proxy::DHCP::Error) + end + end + end +end diff --git a/test/dhcp_kea/provider_spec.rb b/test/dhcp_kea/provider_spec.rb new file mode 100644 index 000000000..b51514460 --- /dev/null +++ b/test/dhcp_kea/provider_spec.rb @@ -0,0 +1,235 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# Specs for the main Provider class, which is the primary entry point for Foreman +# to interact with the DHCP provider. +# @see Proxy::DHCP::KeaApi::Provider +describe Proxy::DHCP::KeaApi::Provider do + # The `before` block sets up the entire test environment using instance variables + # to explicitly control the setup order and avoid `let`'s lazy-loading. + before do + # rubocop:disable RSpec/VerifiedDoubles + @subnet_service = double('SubnetService') + @client = double('Client') + @free_ips = double('FreeIps') + # rubocop:enable RSpec/VerifiedDoubles + + allow(@subnet_service).to receive_messages( + load!: true, + kea_id_map: { '192.168.1.0' => 1 }, + subnet_options: {}, + find_subnet: subnet, + add_host: true, + delete_host: true, + delete_lease: true, + find_record: nil, + find_hosts_by_ip: [], + find_host_by_mac: nil + ) + allow(@free_ips).to receive(:find_free_ip).and_return('192.168.1.15') + + @provider = described_class.new(@subnet_service, @client, @free_ips) + end + + def subnet + Proxy::DHCP::Subnet.new( + '192.168.1.0', + '255.255.255.0', + range: %w[192.168.1.10 192.168.1.20] + ) + end + + describe '#initialize' do + it 'loads the subnet cache on construction' do + expect(@subnet_service).to have_received(:load!) + end + end + + describe '#add_record' do + let(:options) { { 'mac' => 'aa:bb:cc:dd:ee:ff', 'hostname' => 'test-host', 'ip' => '192.168.1.15', subnet: subnet } } + + context 'when nextServer is given as an IP address' do + it 'passes it through as next-server unchanged' do + allow(@client).to receive(:post_command).and_return(successful_reservation_add) + @provider.add_record(options.merge('nextServer' => '192.168.1.254')) + expect(@client).to have_received(:post_command).with( + 'dhcp4', 'reservation-add', hash_including(reservation: hash_including('next-server': '192.168.1.254')) + ) + end + end + + context 'when nextServer is a hostname that cannot be resolved' do + it 'raises a Proxy::DHCP::Error' do + allow(@client).to receive(:post_command).and_return(successful_reservation_add) + allow(Resolv).to receive(:getaddress).and_raise(Resolv::ResolvError) + expect { @provider.add_record(options.merge('nextServer' => 'tftp.example.com')) } + .to raise_error(Proxy::DHCP::Error, /resolve/) + end + end + + context 'when the Kea API call is successful' do + before do + allow(@client).to receive(:post_command).and_return(successful_reservation_add) + end + + it 'returns a Reservation object' do + expect(@provider.add_record(options)).to be_a(Proxy::DHCP::Reservation) + end + + it 'assigns the correct IP to the record' do + expect(@provider.add_record(options).ip).to eq('192.168.1.15') + end + end + + context 'when dns_servers option is provided' do + let(:options_with_dns) do + options.merge('dns_servers' => %w[8.8.8.8 8.8.4.4]) + end + + it 'includes domain-name-servers in the Kea API call' do + allow(@client).to receive(:post_command).and_return(successful_reservation_add) + @provider.add_record(options_with_dns) + expect(@client).to have_received(:post_command).with( + 'dhcp4', 'reservation-add', + hash_including(reservation: hash_including('option-data' => include(hash_including(name: 'domain-name-servers')))) + ) + end + end + + context 'when routers, ntp_servers and domain_name options are provided' do + let(:options_with_all) do + options.merge('routers' => ['192.168.1.1'], 'ntp_servers' => ['192.168.1.253'], 'domain_name' => 'example.com') + end + + before { allow(@client).to receive(:post_command).and_return(successful_reservation_add) } + + it 'emits every mapped option in the Kea option-data' do + @provider.add_record(options_with_all) + expect(@client).to have_received(:post_command).with( + 'dhcp4', 'reservation-add', + hash_including(reservation: hash_including('option-data' => include( + hash_including(name: 'routers'), hash_including(name: 'ntp-servers'), hash_including(name: 'domain-name') + ))) + ) + end + end + + context 'when the Kea API returns an error' do + it 'raises a Proxy::DHCP::Error' do + allow(@client).to receive(:post_command).and_raise(Proxy::DHCP::Error, 'Kea API Error: Something went wrong.') + expect { @provider.add_record(options) }.to raise_error(Proxy::DHCP::Error, /Something went wrong/) + end + end + end + + describe '#del_record' do + context 'when deleting a reservation' do + let(:record) { Proxy::DHCP::Reservation.new('test-host', '192.168.1.5', 'aa:bb:cc:dd:ee:ff', subnet) } + + context 'when the Kea API call is successful' do + it 'returns the deleted record' do + allow(@client).to receive(:post_command).and_return(successful_reservation_del) + expect(@provider.del_record(record)).to eq(record) + end + + it 'calls reservation-del on the client' do + allow(@client).to receive(:post_command).and_return(successful_reservation_del) + @provider.del_record(record) + expect(@client).to have_received(:post_command).with('dhcp4', 'reservation-del', anything) + end + end + + context 'when the Kea API returns an error' do + it 'raises a Proxy::DHCP::Error' do + allow(@client).to receive(:post_command).and_raise(Proxy::DHCP::Error, 'Kea API Error: Failed to delete.') + expect { @provider.del_record(record) }.to raise_error(Proxy::DHCP::Error, /Failed to delete/) + end + end + end + + context 'when deleting a lease' do + let(:record) { Proxy::DHCP::Lease.new(nil, '192.168.1.11', 'ff:ee:dd:cc:bb:aa', subnet, 1_678_886_400, 1_678_890_000, 'active') } + + context 'when the Kea API call is successful' do + it 'returns the deleted lease' do + allow(@client).to receive(:post_command).and_return(successful_lease_del) + expect(@provider.del_record(record)).to eq(record) + end + + it 'calls lease4-del on the client' do + allow(@client).to receive(:post_command).and_return(successful_lease_del) + @provider.del_record(record) + expect(@client).to have_received(:post_command).with('dhcp4', 'lease4-del', anything) + end + end + + context 'when the Kea API returns an error' do + it 'raises a Proxy::DHCP::Error' do + allow(@client).to receive(:post_command).and_raise(Proxy::DHCP::Error, 'Kea API Error: Failed to delete lease.') + expect { @provider.del_record(record) }.to raise_error(Proxy::DHCP::Error, /Failed to delete lease/) + end + end + end + + context 'when deleting an unsupported record type' do + it 'raises a Proxy::DHCP::Error rather than silently succeeding' do + expect { @provider.del_record(Object.new) }.to raise_error(Proxy::DHCP::Error, /unsupported record type/i) + end + end + + context 'when the Kea subnet-id is not in the cache' do + let(:record) { Proxy::DHCP::Reservation.new('test-host', '192.168.1.5', 'aa:bb:cc:dd:ee:ff', subnet) } + + it 'raises a Proxy::DHCP::Error identifying the missing subnet-id' do + allow(@subnet_service).to receive(:kea_id_map).and_return({}) + expect { @provider.del_record(record) }.to raise_error(Proxy::DHCP::Error, /subnet-id/) + end + end + end + + describe '#load_subnet_options' do + let(:subnet_obj) { subnet } + + context 'when subnet options are cached' do + before do + allow(@subnet_service).to receive(:subnet_options).and_return( + '192.168.1.0' => { + 'next-server' => '192.168.1.254', + 'boot-file-name' => 'pxelinux.0', + 'domain-name' => 'example.com', + 'domain-name-servers' => '8.8.8.8,8.8.4.4', + 'ntp-servers' => '192.168.1.253' + } + ) + end + + it 'populates the subnet nextServer option' do + @provider.load_subnet_options(subnet_obj) + expect(subnet_obj.options[:nextServer]).to eq('192.168.1.254') + end + + it 'populates the subnet filename option' do + @provider.load_subnet_options(subnet_obj) + expect(subnet_obj.options[:filename]).to eq('pxelinux.0') + end + + it 'populates the subnet dns_servers option as an array' do + @provider.load_subnet_options(subnet_obj) + expect(subnet_obj.options[:dns_servers]).to eq(%w[8.8.8.8 8.8.4.4]) + end + + it 'populates the subnet ntp_servers option as an array' do + @provider.load_subnet_options(subnet_obj) + expect(subnet_obj.options[:ntp_servers]).to eq(%w[192.168.1.253]) + end + end + + context 'when no subnet options are cached' do + it 'does not modify the subnet options' do + @provider.load_subnet_options(subnet_obj) + expect(subnet_obj.options).not_to have_key(:nextServer) + end + end + end +end From cb5e99a4dbbb1cb0b7fb6ba64ed749533a5eb688 Mon Sep 17 00:00:00 2001 From: akumari Date: Thu, 25 Jun 2026 13:27:57 +0530 Subject: [PATCH 2/2] Fixes #39429 - Add Kea DHCP provider Adapt the imported smart_proxy_dhcp_kea_api code for Smart Proxy core integration. - Rename classes to match Smart Proxy conventions - Update test structure and fix test issues - Add configuration template - Integrate with Smart Proxy module system --- README.md | 2 +- config/settings.d/dhcp.yml.example | 6 +- config/settings.d/dhcp_kea.yml.example | 16 + modules/dhcp_kea/dhcp_kea.rb | 2 + modules/dhcp_kea/dhcp_kea_api_main.rb | 250 ----------- modules/dhcp_kea/dhcp_kea_api_plugin.rb | 51 --- .../dhcp_kea/dhcp_kea_api_subnet_service.rb | 417 ------------------ modules/dhcp_kea/dhcp_kea_api_version.rb | 10 - modules/dhcp_kea/dhcp_kea_main.rb | 131 ++++++ modules/dhcp_kea/dhcp_kea_plugin.rb | 56 +++ modules/dhcp_kea/kea_api_client.rb | 282 ++++++------ modules/dhcp_kea/plugin_configuration.rb | 81 ---- .../dhcp_kea_api_subnet_service_spec.rb | 320 -------------- test/dhcp_kea/dhcp_kea_main_test.rb | 167 +++++++ test/dhcp_kea/dhcp_kea_plugin_test.rb | 31 ++ .../dhcp_kea_provider_interface_test.rb | 16 + test/dhcp_kea/fixtures/kea_api_stubs.rb | 161 ------- test/dhcp_kea/integration_test.rb | 31 ++ test/dhcp_kea/kea_api_client_spec.rb | 53 --- test/dhcp_kea/kea_api_client_test.rb | 237 ++++++++++ test/dhcp_kea/production_di_wirings_test.rb | 77 ++++ test/dhcp_kea/provider_spec.rb | 235 ---------- 22 files changed, 898 insertions(+), 1734 deletions(-) create mode 100644 config/settings.d/dhcp_kea.yml.example create mode 100644 modules/dhcp_kea/dhcp_kea.rb delete mode 100644 modules/dhcp_kea/dhcp_kea_api_main.rb delete mode 100644 modules/dhcp_kea/dhcp_kea_api_plugin.rb delete mode 100644 modules/dhcp_kea/dhcp_kea_api_subnet_service.rb delete mode 100644 modules/dhcp_kea/dhcp_kea_api_version.rb create mode 100644 modules/dhcp_kea/dhcp_kea_main.rb create mode 100644 modules/dhcp_kea/dhcp_kea_plugin.rb delete mode 100644 modules/dhcp_kea/plugin_configuration.rb delete mode 100644 test/dhcp_kea/dhcp_kea_api_subnet_service_spec.rb create mode 100644 test/dhcp_kea/dhcp_kea_main_test.rb create mode 100644 test/dhcp_kea/dhcp_kea_plugin_test.rb create mode 100644 test/dhcp_kea/dhcp_kea_provider_interface_test.rb delete mode 100644 test/dhcp_kea/fixtures/kea_api_stubs.rb create mode 100644 test/dhcp_kea/integration_test.rb delete mode 100644 test/dhcp_kea/kea_api_client_spec.rb create mode 100644 test/dhcp_kea/kea_api_client_test.rb create mode 100644 test/dhcp_kea/production_di_wirings_test.rb delete mode 100644 test/dhcp_kea/provider_spec.rb diff --git a/README.md b/README.md index 1c240456d..a95d3191a 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ # Supported Modules Currently Supported modules: * BMC - BMC management of devices supported by freeipmi and ipmitool - * DHCP - ISC DHCP and MS DHCP Servers + * DHCP - ISC KEA, MS DHCP, and ISC DHCP (legacy, EOL 2022) Servers * DNS - Bind and MS DNS Servers * Puppet - Puppetserver 6 or 7 * Puppet CA - Manage certificate signing, cleaning and autosign on a Puppet CA server diff --git a/config/settings.d/dhcp.yml.example b/config/settings.d/dhcp.yml.example index 2dce1c887..d34b4166b 100644 --- a/config/settings.d/dhcp.yml.example +++ b/config/settings.d/dhcp.yml.example @@ -3,10 +3,12 @@ :enabled: false # valid providers: -# - dhcp_isc (ISC dhcp server) +# - dhcp_kea (ISC KEA dhcp server) # - dhcp_native_ms (Microsoft native implementation) # - dhcp_libvirt -#:use_provider: dhcp_isc +# - dhcp_isc (ISC dhcp server - DEPRECATED) +# +#:use_provider: dhcp_kea #:server: 127.0.0.1 # subnets restricts the subnets queried to a subset, to reduce the query time. #:subnets: [192.168.205.0/255.255.255.128, 192.168.205.128/255.255.255.128] diff --git a/config/settings.d/dhcp_kea.yml.example b/config/settings.d/dhcp_kea.yml.example new file mode 100644 index 000000000..1f9047bda --- /dev/null +++ b/config/settings.d/dhcp_kea.yml.example @@ -0,0 +1,16 @@ +--- +# KEA DHCP provider configuration +# Requires ISC KEA DHCP server with Control Agent API enabled + +# URL of the KEA Control Agent +#:dhcp_kea_url: http://127.0.0.1:8000/ + +# Optional HTTP Basic Authentication credentials +#:dhcp_kea_username: ~ +#:dhcp_kea_password: ~ + +# SSL certificate verification (set to false for self-signed certificates) +#:dhcp_kea_verify_ssl: true + +# Timeout for lease operations in seconds +#:dhcp_kea_lease_timeout: 60 diff --git a/modules/dhcp_kea/dhcp_kea.rb b/modules/dhcp_kea/dhcp_kea.rb new file mode 100644 index 000000000..ef9ba29e2 --- /dev/null +++ b/modules/dhcp_kea/dhcp_kea.rb @@ -0,0 +1,2 @@ +require 'dhcp_common/dhcp_common' +require 'dhcp_kea/dhcp_kea_plugin' diff --git a/modules/dhcp_kea/dhcp_kea_api_main.rb b/modules/dhcp_kea/dhcp_kea_api_main.rb deleted file mode 100644 index a5e5c4c63..000000000 --- a/modules/dhcp_kea/dhcp_kea_api_main.rb +++ /dev/null @@ -1,250 +0,0 @@ -# frozen_string_literal: true - -require 'dhcp_common/server' -require 'resolv' - -module Proxy - module DHCP - module KeaApi - # The main provider class for the `dhcp_kea_api` module. This class inherits - # from the Foreman Smart Proxy's core `DHCP::Server` and implements the - # Kea-specific logic for adding and deleting DHCP reservations and leases. - class Provider < ::Proxy::DHCP::Server - attr_reader :subnet_service, :client - - # Initialises the Kea API provider. - # - # @param subnet_service [Proxy::DHCP::KeaApi::SubnetService] The service that manages the in-memory cache of DHCP data. - # @param client [Proxy::DHCP::KeaApi::Client] The client for communicating with the Kea API. - # @param free_ips [Proxy::DHCP::FreeIps] The service that tracks recently suggested IPs to prevent race conditions. - def initialize(subnet_service, client, free_ips) - @subnet_service = subnet_service - @client = client - subnet_service.load! - super('localhost', nil, subnet_service, free_ips) - end - - # Creates a new DHCP reservation in Kea. - # - # @param options [Hash] A hash containing the details for the new reservation. - # @return [Proxy::DHCP::Reservation] The created reservation object. - def add_record(options = {}) - logger.debug "DHCP options received from Foreman: #{options.inspect}" - record = super - - reservation_args = build_base_reservation_args(record) - add_boot_and_server_options(reservation_args, options) - - option_data = build_option_data(options) - reservation_args['option-data'] = option_data unless option_data.empty? - - @client.post_command('dhcp4', 'reservation-add', { reservation: reservation_args }) - begin - subnet_service.add_host(record.subnet.network, record) - rescue StandardError => e - logger.error "Cache update failed after successful reservation-add for MAC #{record.mac}. " \ - "Kea and cache are out of sync: #{e.message}" - raise - end - - logger.info "Successfully added reservation for MAC #{record.mac} and IP #{record.ip}" - record - end - - # Deletes a DHCP record from Kea. Handles both reservations and leases. - # - # @param record [Proxy::DHCP::Reservation, Proxy::DHCP::Lease] The record to be deleted. - # @return [Proxy::DHCP::Record] The object that was successfully deleted. - # @raise [Proxy::DHCP::Error] if the record type is unsupported, the corresponding - # Kea subnet-id cannot be found, or the API call fails. - def del_record(record) - logger.debug "Deleting record: #{record.inspect}" - - if record.is_a?(::Proxy::DHCP::Reservation) - del_reservation(record) - elsif record.is_a?(::Proxy::DHCP::Lease) - del_lease(record) - else - raise Proxy::DHCP::Error, "Cannot delete unsupported record type: #{record.class.name}" - end - - record - end - - # Loads subnet-level DHCP options from the cached Kea configuration. - # - # @param subnet [Proxy::DHCP::Subnet] The subnet to load options for. - # @return [void] - def load_subnet_options(subnet) - opts = @subnet_service.subnet_options[subnet.network] - return unless opts - - apply_boot_subnet_options(subnet, opts) - apply_mapped_subnet_options(subnet, opts) - end - - private - - # Applies the boot/server fields, which are top-level Kea config fields - # rather than `option-data` entries (so they are not in OPTION_MAP). - # - # @param subnet [Proxy::DHCP::Subnet] The subnet to modify. - # @param opts [Hash] The cached options hash. - # @return [void] - def apply_boot_subnet_options(subnet, opts) - subnet.options[:nextServer] = opts['next-server'] if opts['next-server'] - subnet.options[:filename] = opts['boot-file-name'] if opts['boot-file-name'] - end - - # Applies every `option-data`-derived subnet option using OPTION_MAP as the - # single source of truth for the Kea-name -> Foreman-key (and list) mapping. - # - # @param subnet [Proxy::DHCP::Subnet] The subnet to modify. - # @param opts [Hash] The cached options hash. - # @return [void] - def apply_mapped_subnet_options(subnet, opts) - SubnetService::OPTION_MAP.each do |kea_name, mapping| - value = opts[kea_name] - next unless value - - subnet.options[mapping[:key]] = mapping[:list] ? split_option(value) : value - end - end - - # Splits a comma-separated option string into a trimmed array. - # - # @param value [String] The comma-separated string. - # @return [Array] The split and stripped values. - def split_option(value) - value.split(',').map(&:strip) - end - - # Deletes a reservation from Kea by MAC address. - # - # @param record [Proxy::DHCP::Reservation] The reservation to delete. - # @return [void] - def del_reservation(record) - subnet_id = find_subnet_id!(record.subnet.network) - - args = { - 'subnet-id': subnet_id, - 'identifier-type': 'hw-address', - 'identifier' => record.mac - } - - @client.post_command('dhcp4', 'reservation-del', args) - begin - subnet_service.delete_host(record) - rescue StandardError => e - logger.error "Cache update failed after successful reservation-del for MAC #{record.mac}. " \ - "Kea and cache are out of sync: #{e.message}" - raise - end - - logger.info "Successfully deleted reservation for MAC #{record.mac} and IP #{record.ip}" - end - - # Deletes a lease from Kea by IP address. - # - # @param record [Proxy::DHCP::Lease] The lease to delete. - # @return [void] - def del_lease(record) - subnet_id = find_subnet_id!(record.subnet.network) - - args = { - 'subnet-id': subnet_id, - 'ip-address': record.ip - } - - @client.post_command('dhcp4', 'lease4-del', args) - begin - subnet_service.delete_lease(record) - rescue StandardError => e - logger.error "Cache update failed after successful lease4-del for IP #{record.ip}. " \ - "Kea and cache are out of sync: #{e.message}" - raise - end - - logger.info "Successfully deleted lease for IP #{record.ip}" - end - - # Looks up the Kea subnet-id for a network address, raising if not found. - # - # @param network [String] The subnet network address. - # @return [Integer] The Kea subnet-id. - # @raise [Proxy::DHCP::Error] if the subnet-id is not in the map. - def find_subnet_id!(network) - subnet_id = @subnet_service.kea_id_map[network] - raise Proxy::DHCP::Error, "Unable to find Kea subnet-id for network #{network}" unless subnet_id - - subnet_id - end - - # Builds the initial hash of arguments required for a Kea reservation. - # - # @param record [Proxy::DHCP::Reservation] The reservation object from the parent class. - # @return [Hash] A hash containing the base arguments for the Kea API. - def build_base_reservation_args(record) - subnet_id = find_subnet_id!(record.subnet.network) - - { - 'subnet-id': subnet_id, - 'ip-address': record.ip, - 'hw-address': record.mac, - hostname: record.name - } - end - - # Adds next-server and boot-file-name options to the reservation arguments hash. - # - # @param reservation_args [Hash] The hash of arguments to be modified. - # @param options [Hash] The original options hash from Foreman. - # @return [void] - def add_boot_and_server_options(reservation_args, options) - next_server_value = options['nextServer'] - reservation_args[:'next-server'] = resolve_hostname(next_server_value) unless next_server_value.to_s.empty? - - reservation_args[:'boot-file-name'] = options['filename'] unless options['filename'].to_s.empty? - end - - # Resolves a hostname to an IP address. If the provided string is already - # an IP, it is returned directly. - # - # @param hostname [String] The hostname or IP address string to resolve. - # @return [String] The resolved IPv4 address. - # @raise [Proxy::DHCP::Error] if the hostname cannot be resolved. - def resolve_hostname(hostname) - return hostname if hostname =~ Regexp.union(Resolv::IPv4::Regex) - - Resolv.getaddress(hostname) - rescue Resolv::ResolvError => e - raise Proxy::DHCP::Error, "Could not resolve next-server hostname '#{hostname}': #{e.message}" - end - - # Builds the array of DHCP options (e.g. routers, ntp-servers, dns-servers) for the reservation. - # - # @param options [Hash] The original options hash from Foreman. - # @return [Array] An array of option hashes for the Kea API. - def build_option_data(options) - option_data = [] - SubnetService::OPTION_MAP.each do |kea_name, mapping| - add_dhcp_option(option_data, kea_name, options[mapping[:key].to_s]) - end - option_data - end - - # A helper to add a DHCP option to the data array if the value exists. - # - # @param option_data [Array] The array of options to be modified. - # @param name [String] The name of the DHCP option (e.g. 'routers'). - # @param value [String, Array] The value of the option. - # @return [void] - def add_dhcp_option(option_data, name, value) - return if value.nil? || (value.respond_to?(:empty?) && value.empty?) - - option_data << { name: name, data: Array(value).join(',') } - end - end - end - end -end diff --git a/modules/dhcp_kea/dhcp_kea_api_plugin.rb b/modules/dhcp_kea/dhcp_kea_api_plugin.rb deleted file mode 100644 index 96f4c07ca..000000000 --- a/modules/dhcp_kea/dhcp_kea_api_plugin.rb +++ /dev/null @@ -1,51 +0,0 @@ -# frozen_string_literal: true - -require 'smart_proxy_dhcp_kea_api/dhcp_kea_api_version' -require 'smart_proxy_dhcp_kea_api/plugin_configuration' - -module Proxy - module DHCP - module KeaApi - # The main plugin class for the `dhcp_kea_api` provider. This class serves - # as the entry point for the Foreman Smart Proxy to load and configure the - # plugin. It defines the plugin's name, version, dependencies on other - # modules, default settings, and hooks into the dependency injection framework. - # - # @see Proxy::DHCP::KeaApi::PluginConfiguration For how dependencies are loaded and wired. - class Plugin < ::Proxy::Provider - # Registers the provider with the Smart Proxy, giving it a unique name - # (`:dhcp_kea_api`) and sourcing the version from the VERSION constant. - plugin :dhcp_kea_api, ::Proxy::DHCP::KeaApi::VERSION - - # Declares a dependency on the core Smart Proxy DHCP module. This ensures - # that the base classes we inherit from (like DHCP::Server) are available. - requires :dhcp, '>= 1.17' - - # Defines the default settings for this provider. These values are used - # if they are not overridden in the user's settings file - # (`/etc/foreman-proxy/settings.d/dhcp_kea_api.yml`). - # The `kea_api_username` and `kea_api_password` enable HTTP Basic - # Authentication when the Kea control agent requires it. - default_settings kea_api_url: 'http://127.0.0.1:8000/', - kea_api_username: nil, - kea_api_password: nil, - blacklist_duration_minutes: 5, - open_timeout: 5, - read_timeout: 10, - cache_ttl: 60 - - # Hooks into the Smart Proxy's dependency injection (DI) framework. These lines - # delegate the responsibility of loading the required classes and wiring up - # their dependencies to the `PluginConfiguration` class. This keeps this - # main plugin file clean and declarative. - load_classes ::Proxy::DHCP::KeaApi::PluginConfiguration - load_dependency_injection_wirings ::Proxy::DHCP::KeaApi::PluginConfiguration - - # Tells the Smart Proxy to start these specific services from our DI container - # when the provider is enabled. `:subnet_service` manages the data cache, - # and `:unused_ips` handles IP blacklist management. - start_services :subnet_service, :unused_ips - end - end - end -end diff --git a/modules/dhcp_kea/dhcp_kea_api_subnet_service.rb b/modules/dhcp_kea/dhcp_kea_api_subnet_service.rb deleted file mode 100644 index 6855b2d52..000000000 --- a/modules/dhcp_kea/dhcp_kea_api_subnet_service.rb +++ /dev/null @@ -1,417 +0,0 @@ -# frozen_string_literal: true - -require 'ipaddr' -require 'dhcp_common/dhcp_common' -require 'dhcp_common/subnet_service' - -module Proxy - module DHCP - module KeaApi - # Manages the in-memory cache of DHCP data for the Kea provider. - # - # This class is responsible for fetching all subnet, reservation, and lease - # information from the Kea API. It inherits from the core `DHCP::SubnetService` - # to get the underlying data structures (e.g. hashes for leases and hosts) - # and caching logic. Its primary public method, `load!`, orchestrates the - # population of this cache. It also maintains a mapping of Foreman subnet - # networks to their internal Kea API subnet IDs. - class SubnetService < ::Proxy::DHCP::SubnetService - include Proxy::Log - - # Holds the data fetched during a reload before it is atomically swapped - # into the live cache. Wraps a throwaway parent SubnetService (for its - # thread-safe stores and lookup helpers) plus the Kea-specific maps, so - # the loaders can populate it exactly as they would the live object. - class Staging - attr_reader :service, :kea_id_map, :subnet_options - - # @return [void] - def initialize - @service = ::Proxy::DHCP::SubnetService.initialized_instance - @kea_id_map = {} - @subnet_options = {} - end - end - - # Maps Kea option-data names to their Foreman option keys and whether they are lists. - OPTION_MAP = { - 'routers' => { key: :routers, list: true }, - 'domain-name-servers' => { key: :dns_servers, list: true }, - 'domain-name' => { key: :domain_name, list: false }, - 'ntp-servers' => { key: :ntp_servers, list: true } - }.freeze - - # A hash mapping a subnet network address (e.g. "192.168.1.0") to its - # internal Kea API integer ID (e.g. 1). This is crucial for making - # API calls that require a `subnet-id`. - attr_reader :kea_id_map - - # A hash mapping a subnet network address to its DHCP options hash. - # Used by the Provider to serve subnet-level options back to Foreman. - attr_reader :subnet_options - - # Initialises the SubnetService. - # - # @param client [Proxy::DHCP::KeaApi::Client] The client for communicating with the Kea API. - # @param leases_by_ip [Proxy::MemoryStore] A memory store for leases, passed to the parent class. - # @param leases_by_mac [Proxy::MemoryStore] A memory store for leases, passed to the parent class. - # @param reservations_by_ip [Proxy::MemoryStore] A memory store for reservations, passed to the parent class. - # @param reservations_by_mac [Proxy::MemoryStore] A memory store for reservations, passed to the parent class. - # @param reservations_by_name [Proxy::MemoryStore] A memory store for reservations, passed to the parent class. - # @param cache_ttl [Integer] Number of seconds before the cache is considered stale (default: 60). - # @param managed_subnets [Array, nil] List of CIDR networks to manage. Nil means manage all. - # @return [void] - # rubocop:disable Metrics/ParameterLists - def initialize(client, leases_by_ip, leases_by_mac, reservations_by_ip, reservations_by_mac, reservations_by_name, - cache_ttl: 60, managed_subnets: nil) - @client = client - @kea_id_map = {} - @subnet_options = {} - @cache_ttl = cache_ttl - @loaded_at = nil - @reload_mutex = Mutex.new - @managed_subnets = parse_managed_subnets(managed_subnets) - super(leases_by_ip, leases_by_mac, reservations_by_ip, reservations_by_mac, reservations_by_name) - end - # rubocop:enable Metrics/ParameterLists - - # The main entry point for loading all DHCP data from the Kea server. - # - # All Kea API calls populate a fresh, off-to-the-side set of stores - # (`staging`); only once every fetch has succeeded are the new stores - # swapped into the live cache under the parent's monitor. This keeps the - # slow network fetch off the live cache so concurrent readers never see a - # half-populated state, and leaves the previous cache intact if the fetch - # fails partway through. - # - # @return [true] on success. - # @raise [Proxy::DHCP::Error] if any part of the loading process fails. - # @see #load_subnets_and_reservations_from_kea - # @see #load_leases_from_kea - # rubocop:disable Naming/PredicateMethod - def load! - staging = Staging.new - - load_subnets_and_reservations_from_kea(staging) - load_reservations_from_database(staging) - load_leases_from_kea(staging) - - commit(staging) - true - end - # rubocop:enable Naming/PredicateMethod - - # Returns all subnets, refreshing the cache if stale. - # - # @return [Array] All cached subnets. - def all_subnets - reload_if_stale! - super - end - - # Returns all host reservations, refreshing the cache if stale. - # - # @param subnet_address [String, nil] Optional subnet to filter by. - # @return [Array] All cached reservations. - def all_hosts(subnet_address = nil) - reload_if_stale! - super - end - - # Returns all leases, refreshing the cache if stale. - # - # @param subnet_address [String, nil] Optional subnet to filter by. - # @return [Array] All cached leases. - def all_leases(subnet_address = nil) - reload_if_stale! - super - end - - # Fetches all subnets and their associated reservations from the Kea API. - # This single `config-get` call is the most efficient way to get all static configuration. - # - # @param staging [Staging] The buffer to populate with fetched data. - # @return [void] - # @raise [Proxy::DHCP::Error] if the API call fails. - # @raise [IPAddr::InvalidAddressError] if a subnet address from Kea is invalid. - # @see Proxy::DHCP::KeaApi::Client#post_command - def load_subnets_and_reservations_from_kea(staging) - config = @client.post_command('dhcp4', 'config-get') - subnets_data = config&.dig('Dhcp4', 'subnet4') - return unless subnets_data - - subnets_data.each do |subnet_data| - process_subnet(subnet_data, staging) - end - rescue Proxy::DHCP::Error => e - logger.error "Failed to load subnets and reservations from Kea: #{e.message}" - raise - rescue IPAddr::InvalidAddressError => e - logger.error "Failed to parse subnet from Kea, invalid address found: #{e.message}" - raise - end - - # Fetches dynamically added reservations from Kea's hosts-database via - # `reservation-get-all`. These are not included in `config-get` which only - # returns static reservations from the config file. - # - # @param staging [Staging] The buffer to populate with fetched data. - # @return [void] - def load_reservations_from_database(staging) - return if staging.kea_id_map.empty? - - staging.kea_id_map.each do |network, subnet_id| - response = @client.post_command('dhcp4', 'reservation-get-all', { 'subnet-id': subnet_id }) - hosts = response&.[]('hosts') - next unless hosts - - subnet_obj = staging.service.find_subnet(network) - next unless subnet_obj - - hosts.each do |res_data| - next if staging.service.find_host_by_mac(network, res_data['hw-address']) - - process_reservation(res_data, subnet_obj, staging) - end - end - rescue Proxy::DHCP::Error => e - logger.debug "reservation-get-all not available or failed: #{e.message}" - end - - # Fetches all active leases from the Kea API for the subnets currently in the cache. - # - # @param staging [Staging] The buffer to populate with fetched data. - # @return [void] - # @raise [Proxy::DHCP::Error] if the API call fails. - # @see Proxy::DHCP::KeaApi::Client#post_command - def load_leases_from_kea(staging) - return if staging.kea_id_map.empty? - - response = @client.post_command('dhcp4', 'lease4-get-all', { subnets: staging.kea_id_map.values }) - return unless response && response['leases'] - - response['leases'].each do |lease| - process_lease(lease, staging) - end - rescue Proxy::DHCP::Error => e - logger.error "Failed to load all leases from Kea: #{e.message}" - raise - end - - private - - # Reloads the cache if it is older than the configured TTL. Only one thread - # performs the reload at a time (single-flight via `try_lock`); other threads - # that observe a stale cache serve the current snapshot instead of piling on - # duplicate, concurrent reloads. - # - # @return [void] - def reload_if_stale! - return unless stale? - return unless @reload_mutex.try_lock - - begin - return unless stale? # re-check: another thread may have just reloaded - - logger.debug "Cache TTL (#{@cache_ttl}s) expired, reloading from Kea" - load! - ensure - @reload_mutex.unlock - end - end - - # Atomically replaces the live cache with the freshly-staged data. The swap - # runs under the parent's monitor so that readers (which take the same lock) - # observe either the entire old cache or the entire new one, never a mix. - # - # @param staging [Staging] The fully-populated buffer to promote. - # @return [void] - def commit(staging) - m.synchronize do - @subnets = staging.service.subnets - @leases_by_ip = staging.service.leases_by_ip - @leases_by_mac = staging.service.leases_by_mac - @reservations_by_ip = staging.service.reservations_by_ip - @reservations_by_mac = staging.service.reservations_by_mac - @reservations_by_name = staging.service.reservations_by_name - @kea_id_map = staging.kea_id_map - @subnet_options = staging.subnet_options - @loaded_at = Time.now - end - end - - # Checks whether the cache has exceeded its TTL. - # - # @return [Boolean] true if the cache needs refreshing. - def stale? - return true unless @loaded_at - - (Time.now - @loaded_at) > @cache_ttl - end - - # Parses the managed_subnets setting into IPAddr objects for matching. - # - # @param managed_subnets [Array, String, nil] CIDR networks to manage. - # @return [Array, nil] Parsed networks, or nil to manage all. - def parse_managed_subnets(managed_subnets) - return nil if managed_subnets.nil? - - subnets = Array(managed_subnets) - return nil if subnets.empty? - - subnets.map { |cidr| IPAddr.new(cidr) } - end - - # Checks whether a subnet should be managed by this proxy. - # - # @param subnet_addr [String] The network address of the subnet. - # @return [Boolean] true if the subnet should be managed. - def managed?(subnet_addr) - return true unless @managed_subnets - - ip = IPAddr.new(subnet_addr) - # include? already returns true for an exact match (e.g. a /32 entry), so - # no separate equality check is needed. - @managed_subnets.any? { |network| network.include?(ip) } - end - - # Parses a single subnet hash from the API response, creates the necessary - # Foreman Subnet and Reservation objects, and adds them to the cache. - # - # @param subnet_data [Hash] The hash representing a single subnet from Kea's `config-get` response. - # @param staging [Staging] The buffer to populate with the parsed subnet. - # @return [void] - # @raise [IPAddr::InvalidAddressError] if the subnet string is not a valid IP address. - def process_subnet(subnet_data, staging) - ip_object = IPAddr.new(subnet_data['subnet']) - subnet_addr = ip_object.to_s - mask = IPAddr.new('255.255.255.255').mask(ip_object.prefix).to_s - - return unless managed?(subnet_addr) - - options = { - routers: extract_routers(subnet_data), - range: extract_range(subnet_data) - }.compact - subnet = ::Proxy::DHCP::Subnet.new(subnet_addr, mask, options) - - staging.service.add_subnet(subnet) - staging.kea_id_map[subnet.network] = subnet_data['id'] - staging.subnet_options[subnet.network] = extract_subnet_options(subnet_data) - logger.info "Loaded subnet #{subnet.network}/#{subnet.netmask} and mapped to Kea ID #{subnet_data['id']}" - - subnet_data['reservations']&.each do |res_data| - process_reservation(res_data, subnet, staging) - end - end - - # Extracts all DHCP options from a subnet into a normalised hash. - # - # @param subnet_data [Hash] The hash representing a single subnet. - # @return [Hash] A hash of option names to their values. - def extract_subnet_options(subnet_data) - opts = {} - option_data = subnet_data['option-data'] || [] - option_data.each do |opt| - opts[opt['name']] = opt['data'] - end - opts['next-server'] = subnet_data['next-server'] if meaningful_boot_value?(subnet_data['next-server']) - opts['boot-file-name'] = subnet_data['boot-file-name'] if meaningful_boot_value?(subnet_data['boot-file-name']) - opts - end - - # Returns true when a Kea boot field carries a real value. Kea reports an - # unset next-server as "0.0.0.0" and an unset boot-file-name as "", which - # are placeholders that must not be round-tripped back to Foreman as if a - # user had configured them (doing so triggers spurious DHCP rebuilds). - # - # @param value [String, nil] The raw value from Kea. - # @return [Boolean] true if the value is present and not a placeholder. - def meaningful_boot_value?(value) - !value.nil? && !value.to_s.strip.empty? && value != '0.0.0.0' - end - - # Extracts and formats the router data from a subnet's options. - # - # @param subnet_data [Hash] The hash representing a single subnet. - # @return [Array, nil] An array of router IP addresses, or nil if none are found. - def extract_routers(subnet_data) - router_opt = subnet_data['option-data']&.find { |opt| opt['name'] == 'routers' } - data = router_opt&.[]('data') - data&.split(',')&.map(&:strip) - end - - # Extracts the IP range from a subnet's first pool. - # - # @param subnet_data [Hash] The hash representing a single subnet. - # @return [Array, nil] A two-element array containing the start and end of the range, or nil. - def extract_range(subnet_data) - pool_string = subnet_data.dig('pools', 0, 'pool') - pool_string&.split('-')&.map(&:strip) - end - - # Creates a Foreman Reservation object from Kea data and adds it to the cache. - # Includes option-data, next-server, and boot-file-name so that Foreman can - # round-trip these values when querying existing reservations. - # - # @param res_data [Hash] The hash representing a single reservation. - # @param subnet [Proxy::DHCP::Subnet] The subnet object this reservation belongs to. - # @param staging [Staging] The buffer to populate with the parsed reservation. - # @return [void] - def process_reservation(res_data, subnet, staging) - opts = extract_reservation_options(res_data) - record = ::Proxy::DHCP::Reservation.new( - res_data['hostname'], res_data['ip-address'], res_data['hw-address'], subnet, opts - ) - staging.service.add_host(subnet.network, record) - logger.debug "Loaded reservation for #{res_data['hw-address']} on subnet #{subnet.network}" - end - - # Extracts Foreman-compatible options from a Kea reservation hash. - # - # @param res_data [Hash] The reservation data from Kea's config-get. - # @return [Hash] Options hash suitable for Proxy::DHCP::Reservation. - def extract_reservation_options(res_data) - opts = {} - opts[:nextServer] = res_data['next-server'] if meaningful_boot_value?(res_data['next-server']) - opts[:filename] = res_data['boot-file-name'] if meaningful_boot_value?(res_data['boot-file-name']) - map_option_data(opts, res_data['option-data'] || []) - opts - end - - # Applies Kea option-data entries to a Foreman options hash using OPTION_MAP. - # - # @param opts [Hash] The target options hash to populate. - # @param option_data [Array] The option-data array from Kea. - # @return [void] - def map_option_data(opts, option_data) - option_data.each do |opt| - mapping = OPTION_MAP[opt['name']] - next unless mapping - - data = opt['data'] - opts[mapping[:key]] = mapping[:list] ? data&.split(',')&.map(&:strip) : data - end - end - - # Creates a Foreman Lease object from Kea data and adds it to the cache. - # - # @param lease [Hash] The lease data from Kea's lease4-get-all response. - # @param staging [Staging] The buffer to populate with the parsed lease. - # @return [void] - def process_lease(lease, staging) - ip = lease['ip-address'] - mac = lease['hw-address'] - subnet_obj = staging.service.find_subnet(ip) - unless subnet_obj - logger.warn "Skipping lease for IP #{ip} as it does not belong to any known subnet." - return - end - - record = ::Proxy::DHCP::Lease.new(nil, ip, mac, subnet_obj, lease['cltt'], lease['expire'], 'active') - staging.service.add_lease(subnet_obj.network, record) - end - end - end - end -end diff --git a/modules/dhcp_kea/dhcp_kea_api_version.rb b/modules/dhcp_kea/dhcp_kea_api_version.rb deleted file mode 100644 index 766cd6cf0..000000000 --- a/modules/dhcp_kea/dhcp_kea_api_version.rb +++ /dev/null @@ -1,10 +0,0 @@ -# frozen_string_literal: true - -module Proxy - module DHCP - module KeaApi - # The current version of the smart_proxy_dhcp_kea_api gem. - VERSION = '2.1.0' - end - end -end diff --git a/modules/dhcp_kea/dhcp_kea_main.rb b/modules/dhcp_kea/dhcp_kea_main.rb new file mode 100644 index 000000000..dd0006020 --- /dev/null +++ b/modules/dhcp_kea/dhcp_kea_main.rb @@ -0,0 +1,131 @@ +require 'dhcp_common/server' + +module Proxy::DHCP::Kea + class Provider < ::Proxy::DHCP::Server + include Proxy::Log + + attr_reader :kea_client, :lease_timeout + + def initialize(kea_client, subnet_service, free_ips_service, lease_timeout = 60) + @kea_client = kea_client + @lease_timeout = lease_timeout + + super('kea-dhcp-server', nil, subnet_service, free_ips_service) + + load_subnets + end + + def load_subnets + logger.info "Loading subnets from KEA DHCP server" + + begin + subnets = kea_client.list_subnets + rescue => e + logger.error "Failed to load subnets from KEA: #{e.message}" + raise Proxy::DHCP::Error, "Cannot connect to KEA DHCP server: #{e.message}" + end + + if subnets.empty? + logger.warn "No subnets configured in KEA DHCP server" + return + end + + subnets.each do |subnet_config| + network_cidr = subnet_config['subnet'] + subnet_id = subnet_config['id'] + + logger.debug "Loading subnet: #{network_cidr} (KEA ID: #{subnet_id})" + + # Extract network address and netmask from CIDR (e.g., "192.168.1.0/24" -> "192.168.1.0", "255.255.255.0") + network = network_cidr.split('/').first + netmask = netmask_from_cidr(network_cidr) + + subnet = ::Proxy::DHCP::Subnet.new(network, netmask) + + subnet.options[:kea_subnet_id] = subnet_id + + service.add_subnet(subnet) + + logger.debug "Added subnet #{network_cidr} with KEA ID #{subnet_id}" + end + + logger.info "Loaded #{subnets.size} subnet(s) from KEA" + end + + def add_record(options = {}) + logger.debug "Adding DHCP reservation with options: #{options.inspect}" + + record = super(options) + + # The parent class already validated and set record.subnet + subnet = record.subnet + + subnet_id = subnet.options[:kea_subnet_id] + unless subnet_id + raise Proxy::DHCP::Error, "KEA subnet ID not found for #{subnet.network}" + end + + kea_options = {} + kea_options[:next_server] = record.nextServer if record.nextServer + kea_options[:boot_file_name] = record.filename if record.filename + + begin + kea_client.add_reservation( + subnet_id, + record.ip, + record.mac, + record.name, + kea_options + ) + rescue => e + logger.error "Failed to create KEA reservation: #{e.message}" + raise Proxy::DHCP::Error, "Failed to create reservation in KEA: #{e.message}" + end + + service.add_host(subnet.network, record) + + logger.info "Successfully created KEA DHCP reservation: #{record.ip} for #{record.mac}" + record + end + + def del_record(record) + logger.debug "Deleting DHCP record: #{record.inspect}" + + # Record already has the subnet object + subnet = record.subnet + + subnet_id = subnet.options[:kea_subnet_id] + unless subnet_id + raise Proxy::DHCP::Error, "KEA subnet ID not found for #{subnet.network}" + end + + begin + kea_client.delete_reservation_by_ip(subnet_id, record.ip) + rescue => e + logger.error "Failed to delete KEA reservation: #{e.message}" + raise Proxy::DHCP::Error, "Failed to delete reservation from KEA: #{e.message}" + end + + if record.is_a?(::Proxy::DHCP::Reservation) + service.delete_host(record) + elsif record.is_a?(::Proxy::DHCP::Lease) + service.delete_lease(subnet.network, record) + end + + logger.info "Successfully deleted KEA DHCP reservation: #{record.ip}" + end + + def load_subnet_options(subnet) + logger.debug "Loading subnet options for #{subnet.network}" + end + + private + + def netmask_from_cidr(cidr) + prefix = cidr.split('/').last.to_i + + mask = (0xffffffff << (32 - prefix)) & 0xffffffff + [mask].pack('N').unpack('C4').join('.') + end + end +end diff --git a/modules/dhcp_kea/dhcp_kea_plugin.rb b/modules/dhcp_kea/dhcp_kea_plugin.rb new file mode 100644 index 000000000..302ef0990 --- /dev/null +++ b/modules/dhcp_kea/dhcp_kea_plugin.rb @@ -0,0 +1,56 @@ +module Proxy::DHCP::Kea + class Plugin < ::Proxy::Provider + plugin :dhcp_kea, ::Proxy::VERSION + + capability 'dhcp_filename_ipv4' + capability 'dhcp_filename_hostname' + + default_settings :dhcp_kea_url => 'http://127.0.0.1:8000/', + :dhcp_kea_verify_ssl => true, + :dhcp_kea_lease_timeout => 60 + + requires :dhcp, ::Proxy::VERSION + + load_classes do + require 'dhcp_common/server' + require 'dhcp_common/subnet_service' + require 'dhcp_common/free_ips' + require 'dhcp_kea/kea_api_client' + require 'dhcp_kea/dhcp_kea_main' + end + + load_dependency_injection_wirings do |container_instance, settings| + container_instance.dependency :memory_store, ::Proxy::MemoryStore + + container_instance.singleton_dependency :kea_api_client, (lambda do + ::Proxy::DHCP::Kea::KeaApiClient.new( + settings[:dhcp_kea_url], + settings[:dhcp_kea_username], + settings[:dhcp_kea_password], + verify_ssl: settings[:dhcp_kea_verify_ssl] + ) + end) + + container_instance.singleton_dependency :subnet_service, (lambda do + ::Proxy::DHCP::SubnetService.new( + container_instance.get_dependency(:memory_store), + container_instance.get_dependency(:memory_store), + container_instance.get_dependency(:memory_store), + container_instance.get_dependency(:memory_store), + container_instance.get_dependency(:memory_store) + ) + end) + + container_instance.singleton_dependency :free_ips, -> { ::Proxy::DHCP::FreeIps.new } + + container_instance.dependency :dhcp_provider, (lambda do + ::Proxy::DHCP::Kea::Provider.new( + container_instance.get_dependency(:kea_api_client), + container_instance.get_dependency(:subnet_service), + container_instance.get_dependency(:free_ips), + settings[:dhcp_kea_lease_timeout] + ) + end) + end + end +end diff --git a/modules/dhcp_kea/kea_api_client.rb b/modules/dhcp_kea/kea_api_client.rb index d5decec51..f52fc5894 100644 --- a/modules/dhcp_kea/kea_api_client.rb +++ b/modules/dhcp_kea/kea_api_client.rb @@ -1,160 +1,136 @@ -# frozen_string_literal: true - require 'net/http' -require 'json' require 'uri' +require 'json' + +module Proxy::DHCP::Kea + class KeaApiClient + include Proxy::Log + + attr_reader :api_url, :username, :password, :verify_ssl + + def initialize(api_url, username = nil, password = nil, verify_ssl: true) + @api_url = api_url.chomp('/') + @username = username + @password = password + @verify_ssl = verify_ssl + end + + def send_command(service, command, arguments = {}) + payload = { + 'command' => command, + 'service' => [service], + 'arguments' => arguments, + } + + logger.debug "Sending KEA command: #{command} to service: #{service}" + logger.debug "Arguments: #{arguments.inspect}" + + response = http_post('/', payload) + + result = response.first -module Proxy - module DHCP - module KeaApi - # A client for interacting with the ISC Kea DHCP server API. This class - # encapsulates the logic for creating JSON-RPC commands, sending them via - # HTTP, and handling the responses from the Kea server. - class Client - include Proxy::Log - - # Initialises a new Kea API client. - # - # @param url [String] The base URL of the Kea API endpoint (e.g. 'http://127.0.0.1:8000/'). - # @param username [String, nil] The username for HTTP Basic Authentication. - # @param password [String, nil] The password for HTTP Basic Authentication. - # @param open_timeout [Integer] Time in seconds to wait for the initial TCP connection to be established (defaults to 5). - # @param read_timeout [Integer] Time in seconds to wait for a response from the server after the connection is made (defaults to 10). - # @raise [ArgumentError] if the URL is blank, malformed, or not a valid HTTP/S URL. - # - # @example Basic Initialization - # client = Proxy::DHCP::KeaApi::Client.new(url: 'https://kea.example.com:8443') - # - # @example Initialization with Custom Timeouts - # client = Proxy::DHCP::KeaApi::Client.new( - # url: 'http://127.0.0.1:8000', - # username: 'myuser', - # password: 'mypassword', - # open_timeout: 2, - # read_timeout: 5 - # ) - def initialize(url:, username: nil, password: nil, open_timeout: 5, read_timeout: 10) - raise ArgumentError, 'Kea API URL cannot be nil or empty' if url.to_s.empty? - - @uri = URI.parse(url) - - raise ArgumentError, "Invalid Kea API URL: '#{url}' must be an HTTP or HTTPS URL" unless @uri.is_a?(URI::HTTP) || @uri.is_a?(URI::HTTPS) - - raise ArgumentError, "Invalid Kea API URL: '#{url}' is missing a host" unless @uri.host - - @username = username - @password = password - @open_timeout = open_timeout - @read_timeout = read_timeout - logger.info "Initializing Kea API client for URL: #{@uri} with timeouts (open: #{@open_timeout}s, read: #{@read_timeout}s)" - end - - # Constructs and sends a command to the Kea API and handles its response. - # This is the main public method for interacting with the Kea server. - # - # @param service [String] The Kea service to target (e.g. 'dhcp4'). - # @param command [String] The command to execute (e.g. 'config-get', 'reservation-add'). - # @param arguments [Hash] A hash of arguments required by the command. Defaults to an empty hash. - # @return [Hash] The 'arguments' hash from the Kea API response on success. - # @raise [Proxy::DHCP::Error] if the API returns an error or if there's a communication issue. - # This can be caused by underlying errors like `Net::ReadTimeout`, `Net::OpenTimeout`, - # `Errno::ECONNREFUSED`, or `JSON::ParserError`. - # - # @example Get the current DHCPv4 configuration - # client = Proxy::DHCP::KeaApi::Client.new(url: 'http://localhost:8000') - # config_response = client.post_command('dhcp4', 'config-get') - # # => {"Dhcp4"=>{"subnet4"=>[{"id"=>1, "subnet"=>"192.168.1.0/24", ...}]}} - # - # @example Add a DHCPv4 reservation - # client = Proxy::DHCP::KeaApi::Client.new(url: 'http://localhost:8000') - # add_response = client.post_command('dhcp4', 'reservation-add', { - # reservation: { - # 'subnet-id': 1, - # 'ip-address': '192.168.1.100', - # 'hw-address': '00:11:22:33:44:55', - # hostname: 'my-new-host' - # } - # }) - # # => {"text"=>"Reservation added successfully."} - # - # @see https://kea.readthedocs.io/en/latest/api.html General Kea Management API documentation. - # @see https://kea.readthedocs.io/en/latest/api.html#ref-reservation-add For the `reservation-add` command. - def post_command(service, command, arguments = {}) - header = { 'Content-Type' => 'application/json' } - payload = { - command: command, - service: [service], - arguments: arguments - } - - # This guard clause satisfies strict linters by ensuring the host is not nil in the local scope. - host = @uri.host - raise 'Internal error: Kea API client URI is missing a host' unless host - - http = Net::HTTP.new(host, @uri.port) - http.use_ssl = @uri.scheme == 'https' - http.open_timeout = @open_timeout - http.read_timeout = @read_timeout - request = Net::HTTP::Post.new(@uri.request_uri, header) - request.body = payload.to_json - request.basic_auth(@username, @password.to_s) if @username - - logger.debug "Sending command to Kea: #{payload.inspect}" - response = http.request(request) - - handle_response(response, command) - # This rescue block catches specific, expected network and parsing errors, - # wrapping them in a Foreman-specific error type for consistent handling. - rescue Net::ReadTimeout, Net::OpenTimeout, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, JSON::ParserError => e - logger.error "Failed to send command to Kea API: #{e.class.name} - #{e.message}" - raise Proxy::DHCP::Error, "Kea API communication error: #{e.message}" - end - - private - - # A private helper to parse the JSON response from Kea and route it based on success or failure. - # - # @param response [Net::HTTPResponse] The raw response object from the HTTP request. - # @param command [String] The original command that was sent, used for context-specific handling. - # @return [Hash] The 'arguments' hash from the response on success. - # @raise [Proxy::DHCP::Error] if the response indicates a failure, is malformed, or is empty. - # @raise [JSON::ParserError] if the response body is not valid JSON. - # @private - def handle_response(response, command) - body = JSON.parse(response.body) - logger.debug "Received response from Kea: #{body.inspect}" - - result = body.first if body.is_a?(Array) - raise Proxy::DHCP::Error, 'Kea API Error: Invalid or empty response from server' unless result - - # If the response is successful, return its arguments. Otherwise, raise an error. - if response_successful?(result, command) - # Provide a fallback of '{}' to prevent returning nil if the 'arguments' key is missing. - result['arguments'] || {} - else - error_message = result['text'] || 'Unknown error from Kea API' - raise Proxy::DHCP::Error, "Kea API Error: #{error_message}" - end - end - - # A private predicate method to determine if a Kea response is successful. - # - # @param result [Hash] The parsed result hash from the Kea response body. - # @param command [String] The original command sent, needed for special case handling. - # @return [Boolean] `true` if the response is considered a success, `false` otherwise. - # - # @see https://kea.readthedocs.io/en/stable/api.html For documentation on Kea API result codes. - # @private - def response_successful?(result, command) - result_code = result['result'] - raise Proxy::DHCP::Error, "Kea API Error: Response missing 'result' field" if result_code.nil? - - return true if result_code.zero? - - # Special case: 'lease4-get-all' is successful even with result code 3 (no leases found). - command == 'lease4-get-all' && result_code == 3 - end + if result['result'] != 0 + error_msg = "KEA command '#{command}' failed: #{result['text']}" + logger.error error_msg + raise error_msg end + + logger.debug "KEA command successful: #{result['text']}" + result['arguments'] || {} + end + + def config + send_command('dhcp4', 'config-get') + end + + def list_subnets + conf = config + conf.dig('Dhcp4', 'subnet4') || [] + end + + def add_reservation(subnet_id, ip_address, hw_address, hostname = nil, options = {}) + reservation = { + 'subnet-id' => subnet_id.to_i, + 'ip-address' => ip_address, + 'hw-address' => hw_address, + } + + reservation['hostname'] = hostname if hostname + + if options[:next_server] + reservation['next-server'] = options[:next_server] + end + + if options[:boot_file_name] + reservation['boot-file-name'] = options[:boot_file_name] + end + + logger.info "Adding KEA reservation: #{ip_address} for #{hw_address} in subnet #{subnet_id}" + send_command('dhcp4', 'reservation-add', reservation) + end + + def delete_reservation_by_ip(subnet_id, ip_address) + logger.info "Deleting KEA reservation: #{ip_address} from subnet #{subnet_id}" + send_command('dhcp4', 'reservation-del', { + 'subnet-id' => subnet_id.to_i, + 'ip-address' => ip_address, + }) + end + + def reservation_by_ip(subnet_id, ip_address) + send_command('dhcp4', 'reservation-get', { + 'subnet-id' => subnet_id.to_i, + 'ip-address' => ip_address, + }) + rescue => e + logger.debug "Reservation not found for #{ip_address}: #{e.message}" + nil + end + + def list_leases + send_command('dhcp4', 'lease4-get-all') + end + + def lease_by_ip(ip_address) + result = send_command('dhcp4', 'lease4-get', { + 'ip-address' => ip_address, + }) + result['leases']&.first + rescue => e + logger.debug "Lease not found for #{ip_address}: #{e.message}" + nil + end + + private + + def http_post(path, payload) + uri = URI.parse("#{@api_url}#{path}") + + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = (uri.scheme == 'https') + http.verify_mode = @verify_ssl ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE + + request = Net::HTTP::Post.new(uri.path, {'Content-Type' => 'application/json'}) + request.body = payload.to_json + + if @username && @password + request.basic_auth(@username, @password) + end + + logger.debug "HTTP POST to #{uri}" + response = http.request(request) + + unless response.is_a?(Net::HTTPSuccess) + error_msg = "HTTP request failed: #{response.code} #{response.message}" + logger.error error_msg + raise error_msg + end + + JSON.parse(response.body) + rescue JSON::ParserError => e + error_msg = "Failed to parse KEA response: #{e.message}" + logger.error error_msg + raise error_msg end end end diff --git a/modules/dhcp_kea/plugin_configuration.rb b/modules/dhcp_kea/plugin_configuration.rb deleted file mode 100644 index a80bd7acc..000000000 --- a/modules/dhcp_kea/plugin_configuration.rb +++ /dev/null @@ -1,81 +0,0 @@ -# frozen_string_literal: true - -module Proxy - module DHCP - module KeaApi - # This class manages the setup and configuration of the KeaApi plugin's - # internal components. It follows a pattern used by the Foreman Smart Proxy's - # dependency injection (DI) framework. Its responsibilities are divided into - # two main parts: loading the necessary classes into memory and then "wiring" - # them together by defining how each service gets created and what its - # dependencies are. - class PluginConfiguration - # Loads all the necessary classes for this provider into memory. - # This is called by the Smart Proxy before the dependency injection - # wirings are configured. - - def load_classes - require 'dhcp_common/free_ips' - require 'smart_proxy_dhcp_kea_api/kea_api_client' - require 'smart_proxy_dhcp_kea_api/dhcp_kea_api_subnet_service' - require 'smart_proxy_dhcp_kea_api/dhcp_kea_api_main' - end - - # Configures the dependency injection wirings for the KeaApi provider. - # The container is responsible for creating and managing instances of our services. - # - # @param container [Proxy::DependencyInjection::Container] The DI container to register services with. - # @param settings [Hash] The settings hash for this provider. - - def load_dependency_injection_wirings(container, settings) - # A singleton service that manages the temporary blacklisting of suggested IP addresses - # to prevent race conditions. Its duration is configured via the settings file. - container.singleton_dependency :unused_ips, -> { ::Proxy::DHCP::FreeIps.new(settings[:blacklist_duration_minutes]) } - - # The custom client for communicating with the Kea API. This is registered as a singleton - # so that a single client instance (with its configuration) is shared across all requests. - # @see Proxy::DHCP::KeaApi::Client#initialize - container.singleton_dependency :kea_client, (lambda do - ::Proxy::DHCP::KeaApi::Client.new( - url: settings[:kea_api_url], - username: settings[:kea_api_username], - password: settings[:kea_api_password], - open_timeout: settings[:open_timeout], - read_timeout: settings[:read_timeout] - ) - end) - - # The custom service for caching all subnet, reservation, and lease data. - # This is a singleton because we want one central, authoritative cache that all - # requests can share. Each store must be a separate instance to avoid collisions - # between leases and reservations keyed by the same IP/MAC. - # @see Proxy::DHCP::KeaApi::SubnetService#initialize - container.singleton_dependency :subnet_service, (lambda do - ::Proxy::DHCP::KeaApi::SubnetService.new( - container.get_dependency(:kea_client), - ::Proxy::MemoryStore.new, - ::Proxy::MemoryStore.new, - ::Proxy::MemoryStore.new, - ::Proxy::MemoryStore.new, - ::Proxy::MemoryStore.new, - cache_ttl: settings[:cache_ttl], - managed_subnets: settings.fetch(:managed_subnets, nil) - ) - end) - - # The main provider class that ties everything together. This is the entry point - # for handling DHCP requests from Foreman. It depends on the subnet service, - # the API client, and the IP blacklist service to do its job. - # @see Proxy::DHCP::KeaApi::Provider#initialize - container.singleton_dependency :dhcp_provider, (lambda do - ::Proxy::DHCP::KeaApi::Provider.new( - container.get_dependency(:subnet_service), - container.get_dependency(:kea_client), - container.get_dependency(:unused_ips) - ) - end) - end - end - end - end -end diff --git a/test/dhcp_kea/dhcp_kea_api_subnet_service_spec.rb b/test/dhcp_kea/dhcp_kea_api_subnet_service_spec.rb deleted file mode 100644 index b70a44f35..000000000 --- a/test/dhcp_kea/dhcp_kea_api_subnet_service_spec.rb +++ /dev/null @@ -1,320 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' -require 'dhcp_common/dhcp_common' - -# Specs for the SubnetService, which loads DHCP configuration from the Kea API -# into an in-memory cache for the provider to use. -# @see Proxy::DHCP::KeaApi::SubnetService -describe Proxy::DHCP::KeaApi::SubnetService do - let(:client) { instance_double(Proxy::DHCP::KeaApi::Client) } - - let(:stores) do - { - leases_by_ip: Proxy::MemoryStore.new, - leases_by_mac: Proxy::MemoryStore.new, - reservations_by_ip: Proxy::MemoryStore.new, - reservations_by_mac: Proxy::MemoryStore.new, - reservations_by_name: Proxy::MemoryStore.new - } - end - - let(:service) do - described_class.new(client, stores[:leases_by_ip], stores[:leases_by_mac], stores[:reservations_by_ip], stores[:reservations_by_mac], - stores[:reservations_by_name]) - end - - before do - allow(client).to receive(:post_command) - .with('dhcp4', 'reservation-get-all', anything) - .and_raise(Proxy::DHCP::Error, 'not supported') - end - - describe '#load!' do - before do - allow(client).to receive(:post_command) - .with('dhcp4', 'config-get') - .and_return(successful_config_get) - allow(client).to receive(:post_command) - .with('dhcp4', 'lease4-get-all', { subnets: [1] }) - .and_return(successful_lease_get) - end - - it 'loads one subnet into the cache' do - service.load! - expect(service.subnets.count).to eq(1) - end - - it 'finds the loaded subnet by its network address' do - service.load! - expect(service.find_subnet('192.168.1.0')).not_to be_nil - end - - it 'populates the kea_id_map' do - service.load! - expect(service.kea_id_map).to eq('192.168.1.0' => 1) - end - - context 'when loading leases' do - let(:lease) do - service.load! - leases_by_ip = service.instance_variable_get(:@leases_by_ip) - internal_store = leases_by_ip.instance_variable_get(:@root) - internal_store['192.168.1.0']['192.168.1.11'] - end - - it 'creates a Lease object' do - expect(lease).to be_a(Proxy::DHCP::Lease) - end - - it 'assigns the correct IP to the lease' do - expect(lease.ip).to eq('192.168.1.11') - end - end - end - - describe 'reservation option round-tripping' do - before do - allow(client).to receive(:post_command) - .with('dhcp4', 'config-get') - .and_return(config_get_with_options) - allow(client).to receive(:post_command) - .with('dhcp4', 'lease4-get-all', anything) - .and_return({ 'leases' => [] }) - end - - let(:reservation) do - service.load! - reservations_by_mac = service.instance_variable_get(:@reservations_by_mac) - internal_store = reservations_by_mac.instance_variable_get(:@root) - internal_store['192.168.1.0']['aa:bb:cc:dd:ee:ff'] - end - - it 'preserves next-server on the reservation' do - expect(reservation.options[:nextServer]).to eq('192.168.1.254') - end - - it 'preserves boot-file-name on the reservation' do - expect(reservation.options[:filename]).to eq('pxelinux.0') - end - - it 'preserves routers on the reservation' do - expect(reservation.options[:routers]).to eq(['192.168.1.1']) - end - - it 'preserves dns_servers on the reservation' do - expect(reservation.options[:dns_servers]).to eq(%w[10.0.0.1 10.0.0.2]) - end - end - - describe 'placeholder boot values' do - before do - allow(client).to receive(:post_command) - .with('dhcp4', 'config-get') - .and_return(config_get_with_placeholders) - allow(client).to receive(:post_command) - .with('dhcp4', 'lease4-get-all', anything) - .and_return({ 'leases' => [] }) - service.load! - end - - let(:reservation) do - internal = service.instance_variable_get(:@reservations_by_mac).instance_variable_get(:@root) - internal['192.168.1.0']['aa:bb:cc:dd:ee:ff'] - end - - it 'does not set nextServer when Kea reports the "0.0.0.0" placeholder' do - expect(reservation.options).not_to have_key(:nextServer) - end - - it 'does not set filename when Kea reports an empty boot-file-name' do - expect(reservation.options).not_to have_key(:filename) - end - - it 'omits the placeholder next-server from cached subnet options' do - expect(service.subnet_options['192.168.1.0']).not_to have_key('next-server') - end - - it 'omits the empty boot-file-name from cached subnet options' do - expect(service.subnet_options['192.168.1.0']).not_to have_key('boot-file-name') - end - end - - describe 'loading reservations from the hosts-database' do - before do - allow(client).to receive(:post_command) - .with('dhcp4', 'config-get') - .and_return(successful_config_get) - allow(client).to receive(:post_command) - .with('dhcp4', 'lease4-get-all', anything) - .and_return({ 'leases' => [] }) - allow(client).to receive(:post_command) - .with('dhcp4', 'reservation-get-all', anything) - .and_return(reservation_get_all_success) - service.load! - end - - it 'adds a database-only reservation to the cache' do - expect(service.find_host_by_mac('192.168.1.0', '11:22:33:44:55:66')).not_to be_nil - end - - it 'does not duplicate a reservation already loaded from config-get' do - internal = service.instance_variable_get(:@reservations_by_ip).instance_variable_get(:@root) - expect(internal['192.168.1.0']['192.168.1.5'].size).to eq(1) - end - end - - describe 'subnet options caching' do - before do - allow(client).to receive(:post_command) - .with('dhcp4', 'config-get') - .and_return(config_get_with_options) - allow(client).to receive(:post_command) - .with('dhcp4', 'lease4-get-all', anything) - .and_return({ 'leases' => [] }) - service.load! - end - - it 'stores subnet-level next-server' do - expect(service.subnet_options['192.168.1.0']['next-server']).to eq('192.168.1.254') - end - - it 'stores subnet-level boot-file-name' do - expect(service.subnet_options['192.168.1.0']['boot-file-name']).to eq('pxelinux.0') - end - - it 'stores subnet-level domain-name-servers' do - expect(service.subnet_options['192.168.1.0']['domain-name-servers']).to eq('8.8.8.8,8.8.4.4') - end - - it 'stores subnet-level domain-name' do - expect(service.subnet_options['192.168.1.0']['domain-name']).to eq('example.com') - end - end - - describe 'cache TTL' do - let(:service_with_ttl) do - described_class.new(client, stores[:leases_by_ip], stores[:leases_by_mac], stores[:reservations_by_ip], - stores[:reservations_by_mac], stores[:reservations_by_name], cache_ttl: 30) - end - - before do - allow(client).to receive(:post_command) - .with('dhcp4', 'config-get') - .and_return(successful_config_get) - allow(client).to receive(:post_command) - .with('dhcp4', 'lease4-get-all', anything) - .and_return(successful_lease_get) - end - - it 'does not reload when cache is fresh' do - service_with_ttl.load! - service_with_ttl.all_subnets - expect(client).to have_received(:post_command).with('dhcp4', 'config-get').once - end - - it 'reloads when cache is stale' do - service_with_ttl.load! - service_with_ttl.instance_variable_set(:@loaded_at, Time.now - 60) - service_with_ttl.all_subnets - expect(client).to have_received(:post_command).with('dhcp4', 'config-get').twice - end - end - - describe 'atomic reload' do - before do - allow(client).to receive(:post_command) - .with('dhcp4', 'config-get') - .and_return(successful_config_get) - allow(client).to receive(:post_command) - .with('dhcp4', 'lease4-get-all', anything) - .and_return(successful_lease_get) - service.load! - end - - # The stale-triggered reload must raise but must NOT leave the cache empty: - # data is staged off to the side and only swapped in on success, so a failed - # fetch leaves the previous cache intact (no clear-before-fetch). - it 'preserves the previous cache when a reload fails', :aggregate_failures do - allow(client).to receive(:post_command) - .with('dhcp4', 'config-get') - .and_raise(Proxy::DHCP::Error, 'kea down') - service.instance_variable_set(:@loaded_at, Time.now - 120) - - expect { service.all_subnets }.to raise_error(Proxy::DHCP::Error) - expect(service.subnets.count).to eq(1) - expect(service.find_subnet('192.168.1.0')).not_to be_nil - end - end - - describe 'single-flight reload' do - before do - @config_get_calls = 0 - counter_mutex = Mutex.new - allow(client).to receive(:post_command).with('dhcp4', 'config-get') do - counter_mutex.synchronize { @config_get_calls += 1 } - sleep 0.05 # widen the window so concurrent readers overlap the reload - successful_config_get - end - allow(client).to receive(:post_command) - .with('dhcp4', 'lease4-get-all', anything) - .and_return(successful_lease_get) - end - - it 'reloads only once when many threads observe a stale cache' do - service.load! # 1st config-get - service.instance_variable_set(:@loaded_at, Time.now - 120) - - threads = Array.new(8) { Thread.new { service.all_subnets } } - threads.each(&:join) - - # 1 initial load + exactly 1 single-flighted reload, not one per thread. - expect(@config_get_calls).to eq(2) - end - end - - describe 'managed subnet filtering' do - let(:filtered_service) do - described_class.new(client, stores[:leases_by_ip], stores[:leases_by_mac], stores[:reservations_by_ip], - stores[:reservations_by_mac], stores[:reservations_by_name], - managed_subnets: ['192.168.1.0/24']) - end - - before do - allow(client).to receive(:post_command) - .with('dhcp4', 'config-get') - .and_return(config_get_multi_subnet) - allow(client).to receive(:post_command) - .with('dhcp4', 'lease4-get-all', anything) - .and_return({ 'leases' => [] }) - end - - it 'only loads managed subnets' do - filtered_service.load! - expect(filtered_service.subnets.count).to eq(1) - end - - it 'loads the matching subnet' do - filtered_service.load! - expect(filtered_service.find_subnet('192.168.1.0')).not_to be_nil - end - - it 'excludes the unmanaged subnet' do - filtered_service.load! - expect(filtered_service.find_subnet('10.0.0.0')).to be_nil - end - - context 'when a managed entry is a host address without a prefix' do - let(:host_filtered_service) do - described_class.new(client, stores[:leases_by_ip], stores[:leases_by_mac], stores[:reservations_by_ip], - stores[:reservations_by_mac], stores[:reservations_by_name], - managed_subnets: ['192.168.1.0']) - end - - it 'still matches the subnet whose network equals that address' do - host_filtered_service.load! - expect(host_filtered_service.find_subnet('192.168.1.0')).not_to be_nil - end - end - end -end diff --git a/test/dhcp_kea/dhcp_kea_main_test.rb b/test/dhcp_kea/dhcp_kea_main_test.rb new file mode 100644 index 000000000..1b576f9f1 --- /dev/null +++ b/test/dhcp_kea/dhcp_kea_main_test.rb @@ -0,0 +1,167 @@ +require 'test_helper' +require 'dhcp_kea/dhcp_kea_main' +require 'dhcp_common/subnet' +require 'dhcp_common/subnet_service' + +class DhcpKeaProviderTest < ::Test::Unit::TestCase + def setup + @subnet_service = Proxy::DHCP::SubnetService.initialized_instance + @free_ips = mock('free_ips') + @kea_client = mock('kea_client') + + @kea_client.stubs(:list_subnets).returns([ + {'subnet' => '192.168.1.0/24', 'id' => 1}, + {'subnet' => '10.0.0.0/8', 'id' => 2}, + ]) + + @provider = Proxy::DHCP::Kea::Provider.new(@kea_client, @subnet_service, @free_ips, 60) + end + + def test_initialize_loads_subnets + assert_equal 2, @provider.subnets.count + + subnet = @provider.find_subnet('192.168.1.0/24') + assert_not_nil subnet + assert_equal 1, subnet.options[:kea_subnet_id] + end + + def test_load_subnets_error_handling + kea_client = mock('kea_client') + kea_client.stubs(:list_subnets).raises(StandardError.new('Connection refused')) + + assert_raise(Proxy::DHCP::Error) do + Proxy::DHCP::Kea::Provider.new(kea_client, @subnet_service, @free_ips) + end + end + + def test_add_record_success + @provider.find_subnet('192.168.1.0/24') + + @kea_client.expects(:add_reservation).with( + 1, + '192.168.1.50', + 'aa:bb:cc:dd:ee:ff', + 'test.example.com', + has_entries(next_server: '192.168.1.1', boot_file_name: 'pxelinux.0') + ).returns({}) + + record = @provider.add_record( + 'hostname' => 'test.example.com', + 'ip' => '192.168.1.50', + 'mac' => 'aa:bb:cc:dd:ee:ff', + 'network' => '192.168.1.0/24', + 'nextServer' => '192.168.1.1', + 'filename' => 'pxelinux.0' + ) + + assert_not_nil record + assert_equal '192.168.1.50', record.ip + assert_equal 'aa:bb:cc:dd:ee:ff', record.mac + assert_equal 'test.example.com', record.name + end + + def test_add_record_without_pxe_options + @kea_client.expects(:add_reservation).with( + 1, + '192.168.1.51', + 'bb:cc:dd:ee:ff:00', + 'server2.example.com', + {} + ).returns({}) + + record = @provider.add_record( + 'hostname' => 'server2.example.com', + 'ip' => '192.168.1.51', + 'mac' => 'bb:cc:dd:ee:ff:00', + 'network' => '192.168.1.0/24' + ) + + assert_not_nil record + end + + def test_add_record_subnet_not_found + assert_raise(Proxy::DHCP::Error) do + @provider.add_record( + 'hostname' => 'test.example.com', + 'ip' => '172.16.0.50', + 'mac' => 'aa:bb:cc:dd:ee:ff', + 'network' => '172.16.0.0/24' + ) + end + end + + def test_add_record_kea_api_error + @kea_client.expects(:add_reservation).raises(StandardError.new('KEA API error')) + + assert_raise(Proxy::DHCP::Error) do + @provider.add_record( + 'hostname' => 'test.example.com', + 'ip' => '192.168.1.50', + 'mac' => 'aa:bb:cc:dd:ee:ff', + 'network' => '192.168.1.0/24' + ) + end + end + + def test_del_record_success + @kea_client.stubs(:add_reservation).returns({}) + record = @provider.add_record( + 'hostname' => 'test.example.com', + 'ip' => '192.168.1.50', + 'mac' => 'aa:bb:cc:dd:ee:ff', + 'network' => '192.168.1.0/24' + ) + + @kea_client.expects(:delete_reservation_by_ip).with(1, '192.168.1.50').returns({}) + + @provider.del_record(record) + + assert_nil @subnet_service.find_host_by_mac('192.168.1.0/24', 'aa:bb:cc:dd:ee:ff') + end + + def test_del_record_subnet_not_found + record = Proxy::DHCP::Reservation.new( + 'test.example.com', + '172.16.0.50', + 'aa:bb:cc:dd:ee:ff', + Proxy::DHCP::Subnet.new('172.16.0.0', '255.255.255.0') + ) + + assert_raise(Proxy::DHCP::Error) do + @provider.del_record(record) + end + end + + def test_del_record_kea_api_error + @kea_client.stubs(:add_reservation).returns({}) + record = @provider.add_record( + 'hostname' => 'test.example.com', + 'ip' => '192.168.1.50', + 'mac' => 'aa:bb:cc:dd:ee:ff', + 'network' => '192.168.1.0/24' + ) + + @kea_client.expects(:delete_reservation_by_ip).raises(StandardError.new('KEA API error')) + + assert_raise(Proxy::DHCP::Error) do + @provider.del_record(record) + end + end + + def test_netmask_from_cidr + provider = @provider + + assert_equal '255.255.255.0', provider.send(:netmask_from_cidr, '192.168.1.0/24') + assert_equal '255.255.0.0', provider.send(:netmask_from_cidr, '10.0.0.0/16') + assert_equal '255.0.0.0', provider.send(:netmask_from_cidr, '10.0.0.0/8') + assert_equal '255.255.255.128', provider.send(:netmask_from_cidr, '192.168.1.0/25') + end + + def test_load_subnet_options + subnet = @provider.find_subnet('192.168.1.0/24') + + assert_nothing_raised do + @provider.load_subnet_options(subnet) + end + end +end diff --git a/test/dhcp_kea/dhcp_kea_plugin_test.rb b/test/dhcp_kea/dhcp_kea_plugin_test.rb new file mode 100644 index 000000000..dea2c3498 --- /dev/null +++ b/test/dhcp_kea/dhcp_kea_plugin_test.rb @@ -0,0 +1,31 @@ +require 'test_helper' +require 'dhcp_kea/dhcp_kea' + +class DhcpKeaPluginTest < ::Test::Unit::TestCase + def test_plugin_loads + assert_nothing_raised do + Proxy::DHCP::Kea::Plugin.load_test_settings + end + end + + def test_default_settings + Proxy::DHCP::Kea::Plugin.load_test_settings + + assert_equal 'http://127.0.0.1:8000/', Proxy::DHCP::Kea::Plugin.settings.dhcp_kea_url + assert_equal true, Proxy::DHCP::Kea::Plugin.settings.dhcp_kea_verify_ssl + assert_equal 60, Proxy::DHCP::Kea::Plugin.settings.dhcp_kea_lease_timeout + end + + def test_plugin_capabilities + Proxy::DHCP::Kea::Plugin.load_test_settings + + assert Proxy::DHCP::Kea::Plugin.capabilities.include?('dhcp_filename_ipv4') + assert Proxy::DHCP::Kea::Plugin.capabilities.include?('dhcp_filename_hostname') + end + + def test_requires_dhcp_plugin + Proxy::DHCP::Kea::Plugin.load_test_settings + + assert Proxy::DHCP::Kea::Plugin.plugin_name == :dhcp_kea + end +end diff --git a/test/dhcp_kea/dhcp_kea_provider_interface_test.rb b/test/dhcp_kea/dhcp_kea_provider_interface_test.rb new file mode 100644 index 000000000..1b8e3cb48 --- /dev/null +++ b/test/dhcp_kea/dhcp_kea_provider_interface_test.rb @@ -0,0 +1,16 @@ +require 'test_helper' +require 'dhcp_kea/dhcp_kea_main' + +class KeaDhcpProviderInterfaceTest < Test::Unit::TestCase + def test_provider_interface + kea_client = mock('kea_client') + kea_client.stubs(:list_subnets).returns([]) + + subnet_service = Proxy::DHCP::SubnetService.initialized_instance + free_ips = Proxy::DHCP::FreeIps.new + + provider = ::Proxy::DHCP::Kea::Provider.new(kea_client, subnet_service, free_ips, 60) + + assert_dhcp_provider_interface(provider) + end +end diff --git a/test/dhcp_kea/fixtures/kea_api_stubs.rb b/test/dhcp_kea/fixtures/kea_api_stubs.rb deleted file mode 100644 index 4400745a4..000000000 --- a/test/dhcp_kea/fixtures/kea_api_stubs.rb +++ /dev/null @@ -1,161 +0,0 @@ -# frozen_string_literal: true - -# This module provides a set of helper methods that return mock data structures, -# mimicking the JSON responses from the ISC Kea API. This allows us to test the client -# and services without needing a live Kea server. -module KeaApiStubs - # A successful response for a 'config-get' command. - # @return [Hash] A hash representing the Kea DHCPv4 configuration. - def successful_config_get - { - "Dhcp4" => { - "subnet4" => [ - { - "id" => 1, - "subnet" => "192.168.1.0/24", - "pools" => [{ "pool" => "192.168.1.10-192.168.1.20" }], - "option-data" => [{ "name" => "routers", "data" => "192.168.1.1" }], - "reservations" => [ - { "hw-address" => "aa:bb:cc:dd:ee:ff", "ip-address" => "192.168.1.5", "hostname" => "test-host" } - ] - } - ] - } - } - end - - # A config-get response with subnet-level options and reservation options. - # @return [Hash] A hash representing a richer Kea DHCPv4 configuration. - def config_get_with_options - { - "Dhcp4" => { - "subnet4" => [ - { - "id" => 1, - "subnet" => "192.168.1.0/24", - "pools" => [{ "pool" => "192.168.1.10-192.168.1.20" }], - "next-server" => "192.168.1.254", - "boot-file-name" => "pxelinux.0", - "option-data" => [ - { "name" => "routers", "data" => "192.168.1.1" }, - { "name" => "domain-name-servers", "data" => "8.8.8.8,8.8.4.4" }, - { "name" => "domain-name", "data" => "example.com" }, - { "name" => "ntp-servers", "data" => "192.168.1.253" } - ], - "reservations" => [ - { - "hw-address" => "aa:bb:cc:dd:ee:ff", - "ip-address" => "192.168.1.5", - "hostname" => "pxe-host", - "next-server" => "192.168.1.254", - "boot-file-name" => "pxelinux.0", - "option-data" => [ - { "name" => "routers", "data" => "192.168.1.1" }, - { "name" => "domain-name-servers", "data" => "10.0.0.1,10.0.0.2" } - ] - } - ] - } - ] - } - } - end - - # A config-get whose boot fields hold Kea's "unset" placeholders: next-server - # "0.0.0.0" and an empty boot-file-name, at both subnet and reservation level. - # @return [Hash] A hash representing a Kea config with placeholder boot values. - def config_get_with_placeholders - { - "Dhcp4" => { - "subnet4" => [ - { - "id" => 1, - "subnet" => "192.168.1.0/24", - "pools" => [{ "pool" => "192.168.1.10-192.168.1.20" }], - "next-server" => "0.0.0.0", - "boot-file-name" => "", - "option-data" => [{ "name" => "routers", "data" => "192.168.1.1" }], - "reservations" => [ - { - "hw-address" => "aa:bb:cc:dd:ee:ff", "ip-address" => "192.168.1.5", "hostname" => "plain-host", - "next-server" => "0.0.0.0", "boot-file-name" => "", "option-data" => [] - } - ] - } - ] - } - } - end - - # A successful 'reservation-get-all' response (hosts-database backend present). - # Includes the static reservation from successful_config_get (to exercise dedup) - # plus a database-only reservation. - # @return [Hash] A hash containing a list of host reservations. - def reservation_get_all_success - { - "hosts" => [ - { "hw-address" => "aa:bb:cc:dd:ee:ff", "ip-address" => "192.168.1.5", "hostname" => "test-host", "option-data" => [] }, - { "hw-address" => "11:22:33:44:55:66", "ip-address" => "192.168.1.30", "hostname" => "db-host", "option-data" => [] } - ] - } - end - - # A config-get with multiple subnets for managed_subnets filtering tests. - # @return [Hash] A hash with two subnets. - def config_get_multi_subnet - { - "Dhcp4" => { - "subnet4" => [ - { - "id" => 1, - "subnet" => "192.168.1.0/24", - "pools" => [{ "pool" => "192.168.1.10-192.168.1.20" }], - "option-data" => [{ "name" => "routers", "data" => "192.168.1.1" }], - "reservations" => [] - }, - { - "id" => 2, - "subnet" => "10.0.0.0/24", - "pools" => [{ "pool" => "10.0.0.10-10.0.0.100" }], - "option-data" => [{ "name" => "routers", "data" => "10.0.0.1" }], - "reservations" => [] - } - ] - } - } - end - - # A successful response for a 'lease4-get-all' command with one active lease. - # @return [Hash] A hash containing a list of leases. - def successful_lease_get - { - "leases" => [ - { "ip-address" => "192.168.1.11", "hw-address" => "ff:ee:dd:cc:bb:aa", "cltt" => 1678886400, "expire" => 1678890000 } - ] - } - end - - # A successful response for a 'reservation-add' command. - # @return [Hash] A hash indicating success. - def successful_reservation_add - { "result" => 0, "text" => "Reservation added successfully." } - end - - # A successful response for a 'reservation-del' command. - # @return [Hash] A hash indicating success. - def successful_reservation_del - { "result" => 0, "text" => "Reservation deleted successfully." } - end - - # A successful response for a 'lease4-del' command. - # @return [Hash] A hash indicating success. - def successful_lease_del - { "result" => 0, "text" => "Lease deleted successfully." } - end - - # An error response from the API. - # @return [Hash] A hash representing a generic API error. - def error_response - { "result" => 1, "text" => "Something went wrong." } - end -end diff --git a/test/dhcp_kea/integration_test.rb b/test/dhcp_kea/integration_test.rb new file mode 100644 index 000000000..7fa92451a --- /dev/null +++ b/test/dhcp_kea/integration_test.rb @@ -0,0 +1,31 @@ +require 'test_helper' +require 'json' +require 'root/root_v2_api' +require 'dhcp/dhcp' +require 'dhcp_kea/dhcp_kea' + +class DhcpKeaApiFeaturesTest < SmartProxyRootApiTestCase + def test_features + Proxy::DefaultModuleLoader.any_instance.expects(:load_configuration_file).with('dhcp.yml').returns(enabled: true, use_provider: 'dhcp_kea') + Proxy::DefaultModuleLoader.any_instance.expects(:load_configuration_file).with('dhcp_kea.yml').returns( + dhcp_kea_url: 'http://127.0.0.1:8000/', + dhcp_kea_verify_ssl: true, + dhcp_kea_lease_timeout: 60 + ) + + # Mock the KEA client to avoid actual API calls during feature loading + Proxy::DHCP::Kea::KeaApiClient.any_instance.stubs(:list_subnets).returns([]) + + get '/features' + + response = JSON.parse(last_response.body) + + mod = response['dhcp'] + refute_nil(mod) + assert_equal('running', mod['state'], Proxy::LogBuffer::Buffer.instance.info[:failed_modules][:dhcp]) + assert_equal(['dhcp_filename_hostname', 'dhcp_filename_ipv4'], mod['capabilities'].sort) + + expected_settings = {'use_provider' => 'dhcp_kea'} + assert_equal(expected_settings, mod['settings']) + end +end diff --git a/test/dhcp_kea/kea_api_client_spec.rb b/test/dhcp_kea/kea_api_client_spec.rb deleted file mode 100644 index 9408d0ab4..000000000 --- a/test/dhcp_kea/kea_api_client_spec.rb +++ /dev/null @@ -1,53 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -# Specs for the low-level Kea API client. -# @see Proxy::DHCP::KeaApi::Client -describe Proxy::DHCP::KeaApi::Client do - # Defines a reusable client instance for tests. - let(:client) { described_class.new(url: 'http://localhost:8000') } - - # Tests for the client's initialisation process. - describe '#initialize' do - # It should create a new client instance when given a valid URL. - it 'creates a new client with a valid URL' do - expect(client).to be_a(described_class) - end - - # It should raise an error if the URL is not a valid URI. - it 'raises an ArgumentError if the URL is malformed' do - expect { described_class.new(url: 'invalid-url') }.to raise_error(ArgumentError) - end - end - - # Tests for sending commands to the Kea API. - describe '#post_command' do - # This block tests the behaviour when the Kea API returns a successful (result: 0) response. - context 'when the API call is successful' do - # It should correctly parse the response and return the 'arguments' hash. - it 'sends a command and returns the arguments hash from the response' do - # Use WebMock to stub the HTTP POST request to our test server. - response_body = [{ 'result' => 0, 'arguments' => { 'text' => 'Reservation added successfully.' } }] - stub_request(:post, 'http://localhost:8000/').to_return(status: 200, body: response_body.to_json, headers: {}) - - response = client.post_command('dhcp4', 'reservation-add', { 'subnet-id' => 1 }) - - # The client should parse the JSON and extract the 'arguments' hash. - expect(response).to eq({ 'text' => 'Reservation added successfully.' }) - end - end - - # This context tests how the client handles an error response (result: 1) from the API. - context 'when the API call returns an error' do - # It should see the non-zero result and raise a custom error. - it 'raises a Proxy::DHCP::Error' do - # Stub the request to return the standard error response from our stubs. - stub_request(:post, 'http://localhost:8000/').to_return(status: 200, body: [error_response].to_json, headers: {}) - - # We expect the client to see the non-zero result and raise our custom error class. - expect { client.post_command('dhcp4', 'reservation-add', { 'subnet-id' => 1 }) }.to raise_error(Proxy::DHCP::Error) - end - end - end -end diff --git a/test/dhcp_kea/kea_api_client_test.rb b/test/dhcp_kea/kea_api_client_test.rb new file mode 100644 index 000000000..f316b38fe --- /dev/null +++ b/test/dhcp_kea/kea_api_client_test.rb @@ -0,0 +1,237 @@ +require 'test_helper' +require 'dhcp_kea/kea_api_client' +require 'webmock/test_unit' + +class KeaApiClientTest < ::Test::Unit::TestCase + def setup + @api_url = 'http://kea-server:8000' + @client = Proxy::DHCP::Kea::KeaApiClient.new(@api_url) + end + + def teardown + WebMock.reset! + end + + def test_initialize + client = Proxy::DHCP::Kea::KeaApiClient.new('http://example.com:8000/', 'admin', 'secret', verify_ssl: false) + + assert_equal 'http://example.com:8000', client.api_url + assert_equal 'admin', client.username + assert_equal 'secret', client.password + assert_equal false, client.verify_ssl + end + + def test_send_command_success + stub_request(:post, "#{@api_url}/") + .with( + body: { + 'command' => 'test-command', + 'service' => ['dhcp4'], + 'arguments' => {'key' => 'value'}, + }.to_json + ) + .to_return( + status: 200, + body: [{'result' => 0, 'text' => 'Success', 'arguments' => {'data' => 'result'}}].to_json, + headers: {'Content-Type' => 'application/json'} + ) + + result = @client.send_command('dhcp4', 'test-command', {'key' => 'value'}) + + assert_equal({'data' => 'result'}, result) + end + + def test_send_command_failure + stub_request(:post, "#{@api_url}/") + .to_return( + status: 200, + body: [{'result' => 1, 'text' => 'Command failed'}].to_json, + headers: {'Content-Type' => 'application/json'} + ) + + assert_raise(RuntimeError) do + @client.send_command('dhcp4', 'test-command', {}) + end + end + + def test_send_command_http_error + stub_request(:post, "#{@api_url}/") + .to_return(status: 500, body: 'Internal Server Error') + + assert_raise(RuntimeError) do + @client.send_command('dhcp4', 'test-command', {}) + end + end + + def test_list_subnets + stub_request(:post, "#{@api_url}/") + .with( + body: hash_including('command' => 'config-get') + ) + .to_return( + status: 200, + body: [{ + 'result' => 0, + 'text' => 'Success', + 'arguments' => { + 'Dhcp4' => { + 'subnet4' => [ + {'subnet' => '192.168.1.0/24', 'id' => 1}, + {'subnet' => '10.0.0.0/8', 'id' => 2}, + ], + }, + }, + }].to_json, + headers: {'Content-Type' => 'application/json'} + ) + + subnets = @client.list_subnets + + assert_equal 2, subnets.length + assert_equal '192.168.1.0/24', subnets[0]['subnet'] + assert_equal 1, subnets[0]['id'] + end + + def test_add_reservation + stub_request(:post, "#{@api_url}/") + .with( + body: hash_including( + 'command' => 'reservation-add', + 'service' => ['dhcp4'] + ) + ) + .to_return( + status: 200, + body: [{'result' => 0, 'text' => 'Host added.'}].to_json, + headers: {'Content-Type' => 'application/json'} + ) + + result = @client.add_reservation(1, '192.168.1.50', 'aa:bb:cc:dd:ee:ff', 'test.example.com') + + assert_not_nil result + end + + def test_add_reservation_with_pxe_options + stub_request(:post, "#{@api_url}/") + .with( + body: hash_including( + 'command' => 'reservation-add', + 'arguments' => hash_including( + 'next-server' => '192.168.1.1', + 'boot-file-name' => 'pxelinux.0' + ) + ) + ) + .to_return( + status: 200, + body: [{'result' => 0, 'text' => 'Host added.'}].to_json, + headers: {'Content-Type' => 'application/json'} + ) + + result = @client.add_reservation( + 1, + '192.168.1.50', + 'aa:bb:cc:dd:ee:ff', + 'test.example.com', + {next_server: '192.168.1.1', boot_file_name: 'pxelinux.0'} + ) + + assert_not_nil result + end + + def test_delete_reservation_by_ip + stub_request(:post, "#{@api_url}/") + .with( + body: hash_including( + 'command' => 'reservation-del', + 'arguments' => hash_including('ip-address' => '192.168.1.50') + ) + ) + .to_return( + status: 200, + body: [{'result' => 0, 'text' => 'Host deleted.'}].to_json, + headers: {'Content-Type' => 'application/json'} + ) + + result = @client.delete_reservation_by_ip(1, '192.168.1.50') + + assert_not_nil result + end + + def test_reservation_by_ip_found + stub_request(:post, "#{@api_url}/") + .with( + body: hash_including('command' => 'reservation-get') + ) + .to_return( + status: 200, + body: [{ + 'result' => 0, + 'text' => 'Success', + 'arguments' => {'ip-address' => '192.168.1.50', 'hw-address' => 'aa:bb:cc:dd:ee:ff'}, + }].to_json, + headers: {'Content-Type' => 'application/json'} + ) + + result = @client.reservation_by_ip(1, '192.168.1.50') + + assert_not_nil result + assert_equal '192.168.1.50', result['ip-address'] + end + + def test_reservation_by_ip_not_found + stub_request(:post, "#{@api_url}/") + .to_return( + status: 200, + body: [{'result' => 3, 'text' => 'reservation not found'}].to_json, + headers: {'Content-Type' => 'application/json'} + ) + + result = @client.reservation_by_ip(1, '192.168.1.99') + + assert_nil result + end + + def test_lease_by_ip + stub_request(:post, "#{@api_url}/") + .with( + body: hash_including('command' => 'lease4-get') + ) + .to_return( + status: 200, + body: [{ + 'result' => 0, + 'text' => 'Success', + 'arguments' => { + 'leases' => [ + {'ip-address' => '192.168.1.50', 'hw-address' => 'aa:bb:cc:dd:ee:ff'}, + ], + }, + }].to_json, + headers: {'Content-Type' => 'application/json'} + ) + + result = @client.lease_by_ip('192.168.1.50') + + assert_not_nil result + assert_equal '192.168.1.50', result['ip-address'] + end + + def test_http_basic_auth + client = Proxy::DHCP::Kea::KeaApiClient.new(@api_url, 'admin', 'secret') + + stub_request(:post, "#{@api_url}/") + .with( + headers: {'Authorization' => 'Basic YWRtaW46c2VjcmV0'} # base64('admin:secret') + ) + .to_return( + status: 200, + body: [{'result' => 0, 'text' => 'Success', 'arguments' => {}}].to_json, + headers: {'Content-Type' => 'application/json'} + ) + + result = client.send_command('dhcp4', 'test', {}) + + assert_not_nil result + end +end diff --git a/test/dhcp_kea/production_di_wirings_test.rb b/test/dhcp_kea/production_di_wirings_test.rb new file mode 100644 index 000000000..5c6f578d9 --- /dev/null +++ b/test/dhcp_kea/production_di_wirings_test.rb @@ -0,0 +1,77 @@ +require 'test_helper' +require 'dhcp_common/subnet_service' +require 'dhcp_common/free_ips' +require 'dhcp_kea/kea_api_client' +require 'dhcp_kea/dhcp_kea_main' + +class DhcpKeaProductionDIWiringsTest < Test::Unit::TestCase + def setup + @settings = { + :dhcp_kea_url => 'http://127.0.0.1:8000/', + :dhcp_kea_verify_ssl => true, + :dhcp_kea_lease_timeout => 60, + } + end + + def test_kea_api_client_initialization + client = ::Proxy::DHCP::Kea::KeaApiClient.new( + @settings[:dhcp_kea_url], + nil, + nil, + verify_ssl: @settings[:dhcp_kea_verify_ssl] + ) + + assert_not_nil client + assert_equal 'http://127.0.0.1:8000', client.api_url + assert_nil client.username + assert_nil client.password + assert_equal true, client.verify_ssl + end + + def test_subnet_service_initialization + service = ::Proxy::DHCP::SubnetService.initialized_instance + assert_not_nil service + assert_instance_of ::Proxy::DHCP::SubnetService, service + end + + def test_free_ips_initialization + free_ips = ::Proxy::DHCP::FreeIps.new + assert_not_nil free_ips + end + + def test_provider_initialization + kea_client = ::Proxy::DHCP::Kea::KeaApiClient.new( + @settings[:dhcp_kea_url], + nil, + nil, + verify_ssl: @settings[:dhcp_kea_verify_ssl] + ) + kea_client.stubs(:list_subnets).returns([]) + + subnet_service = ::Proxy::DHCP::SubnetService.initialized_instance + free_ips = ::Proxy::DHCP::FreeIps.new + + provider = ::Proxy::DHCP::Kea::Provider.new( + kea_client, + subnet_service, + free_ips, + @settings[:dhcp_kea_lease_timeout] + ) + + assert_not_nil provider + assert_instance_of ::Proxy::DHCP::Kea::Provider, provider + assert_equal @settings[:dhcp_kea_lease_timeout], provider.lease_timeout + end + + def test_kea_api_client_with_authentication + client = ::Proxy::DHCP::Kea::KeaApiClient.new( + @settings[:dhcp_kea_url], + 'admin', + 'secret', + verify_ssl: @settings[:dhcp_kea_verify_ssl] + ) + + assert_equal 'admin', client.username + assert_equal 'secret', client.password + end +end diff --git a/test/dhcp_kea/provider_spec.rb b/test/dhcp_kea/provider_spec.rb deleted file mode 100644 index b51514460..000000000 --- a/test/dhcp_kea/provider_spec.rb +++ /dev/null @@ -1,235 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -# Specs for the main Provider class, which is the primary entry point for Foreman -# to interact with the DHCP provider. -# @see Proxy::DHCP::KeaApi::Provider -describe Proxy::DHCP::KeaApi::Provider do - # The `before` block sets up the entire test environment using instance variables - # to explicitly control the setup order and avoid `let`'s lazy-loading. - before do - # rubocop:disable RSpec/VerifiedDoubles - @subnet_service = double('SubnetService') - @client = double('Client') - @free_ips = double('FreeIps') - # rubocop:enable RSpec/VerifiedDoubles - - allow(@subnet_service).to receive_messages( - load!: true, - kea_id_map: { '192.168.1.0' => 1 }, - subnet_options: {}, - find_subnet: subnet, - add_host: true, - delete_host: true, - delete_lease: true, - find_record: nil, - find_hosts_by_ip: [], - find_host_by_mac: nil - ) - allow(@free_ips).to receive(:find_free_ip).and_return('192.168.1.15') - - @provider = described_class.new(@subnet_service, @client, @free_ips) - end - - def subnet - Proxy::DHCP::Subnet.new( - '192.168.1.0', - '255.255.255.0', - range: %w[192.168.1.10 192.168.1.20] - ) - end - - describe '#initialize' do - it 'loads the subnet cache on construction' do - expect(@subnet_service).to have_received(:load!) - end - end - - describe '#add_record' do - let(:options) { { 'mac' => 'aa:bb:cc:dd:ee:ff', 'hostname' => 'test-host', 'ip' => '192.168.1.15', subnet: subnet } } - - context 'when nextServer is given as an IP address' do - it 'passes it through as next-server unchanged' do - allow(@client).to receive(:post_command).and_return(successful_reservation_add) - @provider.add_record(options.merge('nextServer' => '192.168.1.254')) - expect(@client).to have_received(:post_command).with( - 'dhcp4', 'reservation-add', hash_including(reservation: hash_including('next-server': '192.168.1.254')) - ) - end - end - - context 'when nextServer is a hostname that cannot be resolved' do - it 'raises a Proxy::DHCP::Error' do - allow(@client).to receive(:post_command).and_return(successful_reservation_add) - allow(Resolv).to receive(:getaddress).and_raise(Resolv::ResolvError) - expect { @provider.add_record(options.merge('nextServer' => 'tftp.example.com')) } - .to raise_error(Proxy::DHCP::Error, /resolve/) - end - end - - context 'when the Kea API call is successful' do - before do - allow(@client).to receive(:post_command).and_return(successful_reservation_add) - end - - it 'returns a Reservation object' do - expect(@provider.add_record(options)).to be_a(Proxy::DHCP::Reservation) - end - - it 'assigns the correct IP to the record' do - expect(@provider.add_record(options).ip).to eq('192.168.1.15') - end - end - - context 'when dns_servers option is provided' do - let(:options_with_dns) do - options.merge('dns_servers' => %w[8.8.8.8 8.8.4.4]) - end - - it 'includes domain-name-servers in the Kea API call' do - allow(@client).to receive(:post_command).and_return(successful_reservation_add) - @provider.add_record(options_with_dns) - expect(@client).to have_received(:post_command).with( - 'dhcp4', 'reservation-add', - hash_including(reservation: hash_including('option-data' => include(hash_including(name: 'domain-name-servers')))) - ) - end - end - - context 'when routers, ntp_servers and domain_name options are provided' do - let(:options_with_all) do - options.merge('routers' => ['192.168.1.1'], 'ntp_servers' => ['192.168.1.253'], 'domain_name' => 'example.com') - end - - before { allow(@client).to receive(:post_command).and_return(successful_reservation_add) } - - it 'emits every mapped option in the Kea option-data' do - @provider.add_record(options_with_all) - expect(@client).to have_received(:post_command).with( - 'dhcp4', 'reservation-add', - hash_including(reservation: hash_including('option-data' => include( - hash_including(name: 'routers'), hash_including(name: 'ntp-servers'), hash_including(name: 'domain-name') - ))) - ) - end - end - - context 'when the Kea API returns an error' do - it 'raises a Proxy::DHCP::Error' do - allow(@client).to receive(:post_command).and_raise(Proxy::DHCP::Error, 'Kea API Error: Something went wrong.') - expect { @provider.add_record(options) }.to raise_error(Proxy::DHCP::Error, /Something went wrong/) - end - end - end - - describe '#del_record' do - context 'when deleting a reservation' do - let(:record) { Proxy::DHCP::Reservation.new('test-host', '192.168.1.5', 'aa:bb:cc:dd:ee:ff', subnet) } - - context 'when the Kea API call is successful' do - it 'returns the deleted record' do - allow(@client).to receive(:post_command).and_return(successful_reservation_del) - expect(@provider.del_record(record)).to eq(record) - end - - it 'calls reservation-del on the client' do - allow(@client).to receive(:post_command).and_return(successful_reservation_del) - @provider.del_record(record) - expect(@client).to have_received(:post_command).with('dhcp4', 'reservation-del', anything) - end - end - - context 'when the Kea API returns an error' do - it 'raises a Proxy::DHCP::Error' do - allow(@client).to receive(:post_command).and_raise(Proxy::DHCP::Error, 'Kea API Error: Failed to delete.') - expect { @provider.del_record(record) }.to raise_error(Proxy::DHCP::Error, /Failed to delete/) - end - end - end - - context 'when deleting a lease' do - let(:record) { Proxy::DHCP::Lease.new(nil, '192.168.1.11', 'ff:ee:dd:cc:bb:aa', subnet, 1_678_886_400, 1_678_890_000, 'active') } - - context 'when the Kea API call is successful' do - it 'returns the deleted lease' do - allow(@client).to receive(:post_command).and_return(successful_lease_del) - expect(@provider.del_record(record)).to eq(record) - end - - it 'calls lease4-del on the client' do - allow(@client).to receive(:post_command).and_return(successful_lease_del) - @provider.del_record(record) - expect(@client).to have_received(:post_command).with('dhcp4', 'lease4-del', anything) - end - end - - context 'when the Kea API returns an error' do - it 'raises a Proxy::DHCP::Error' do - allow(@client).to receive(:post_command).and_raise(Proxy::DHCP::Error, 'Kea API Error: Failed to delete lease.') - expect { @provider.del_record(record) }.to raise_error(Proxy::DHCP::Error, /Failed to delete lease/) - end - end - end - - context 'when deleting an unsupported record type' do - it 'raises a Proxy::DHCP::Error rather than silently succeeding' do - expect { @provider.del_record(Object.new) }.to raise_error(Proxy::DHCP::Error, /unsupported record type/i) - end - end - - context 'when the Kea subnet-id is not in the cache' do - let(:record) { Proxy::DHCP::Reservation.new('test-host', '192.168.1.5', 'aa:bb:cc:dd:ee:ff', subnet) } - - it 'raises a Proxy::DHCP::Error identifying the missing subnet-id' do - allow(@subnet_service).to receive(:kea_id_map).and_return({}) - expect { @provider.del_record(record) }.to raise_error(Proxy::DHCP::Error, /subnet-id/) - end - end - end - - describe '#load_subnet_options' do - let(:subnet_obj) { subnet } - - context 'when subnet options are cached' do - before do - allow(@subnet_service).to receive(:subnet_options).and_return( - '192.168.1.0' => { - 'next-server' => '192.168.1.254', - 'boot-file-name' => 'pxelinux.0', - 'domain-name' => 'example.com', - 'domain-name-servers' => '8.8.8.8,8.8.4.4', - 'ntp-servers' => '192.168.1.253' - } - ) - end - - it 'populates the subnet nextServer option' do - @provider.load_subnet_options(subnet_obj) - expect(subnet_obj.options[:nextServer]).to eq('192.168.1.254') - end - - it 'populates the subnet filename option' do - @provider.load_subnet_options(subnet_obj) - expect(subnet_obj.options[:filename]).to eq('pxelinux.0') - end - - it 'populates the subnet dns_servers option as an array' do - @provider.load_subnet_options(subnet_obj) - expect(subnet_obj.options[:dns_servers]).to eq(%w[8.8.8.8 8.8.4.4]) - end - - it 'populates the subnet ntp_servers option as an array' do - @provider.load_subnet_options(subnet_obj) - expect(subnet_obj.options[:ntp_servers]).to eq(%w[192.168.1.253]) - end - end - - context 'when no subnet options are cached' do - it 'does not modify the subnet options' do - @provider.load_subnet_options(subnet_obj) - expect(subnet_obj.options).not_to have_key(:nextServer) - end - end - end -end