smart_proxy_dhcp_kea_api 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/README.md ADDED
@@ -0,0 +1,118 @@
1
+ # Foreman Smart Proxy DHCP Kea API
2
+
3
+ [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)
4
+
5
+ A Foreman Smart Proxy plugin to provide DHCP management by interacting with the ISC Kea API. This provider allows Foreman to view subnets and leases and to create and delete host reservations directly via Kea's JSON-RPC interface.
6
+
7
+ > [!NOTE]
8
+ > This plugin currently supports **IPv4 only**.
9
+
10
+ ## Table of Contents
11
+
12
+ [[_TOC_]]
13
+
14
+ ## Compatibility
15
+
16
+ * **Foreman Smart Proxy:**
17
+ * **ISC Kea:** 3.0.0 or newer
18
+
19
+ ## Features
20
+
21
+ - Adds ISC Kea as a DHCP provider for Foreman.
22
+ - Fetches subnet and pool information directly from the Kea API.
23
+ - Fetches active lease information.
24
+ - Provides host reservation management (add/delete).
25
+ - Suggests the next available IP address from a subnet's pool.
26
+ - Passes next-server and PXE boot file to Kea
27
+
28
+ ## Prerequisites
29
+
30
+ - A running Foreman Smart Proxy instance.
31
+ - A running ISC Kea DHCP server.
32
+ - Network connectivity from the Smart Proxy server to the Kea server's API endpoint (default port 8000).
33
+
34
+ > [!WARNING]
35
+ > The Kea server **must** be configured with the **`host_cmds`** and **`lease_cmds`** hook libraries loaded for the `dhcp4` service. This plugin will not function without them.
36
+
37
+ ## Installation
38
+
39
+ First, install the gem on your Foreman Smart Proxy server:
40
+
41
+ ```bash
42
+ gem install smart_proxy_dhcp_kea_api
43
+ ```
44
+
45
+ Add the gem to `/usr/share/foreman-proxy/bundler.d/Gemfile.local.rb`:
46
+
47
+ ```ruby
48
+ gem 'smart_proxy_dhcp_kea_api'
49
+ ```
50
+
51
+ After installing the gem or changing its configuration, you must restart the `foreman-proxy` service for the changes to take effect.
52
+ ```bash
53
+ systemctl restart foreman-proxy
54
+ ```
55
+
56
+ ## Configuration
57
+
58
+ Configuration is done in two files in the `/etc/foreman-proxy/settings.d/` directory.
59
+
60
+ ### 1. Enable the DHCP Module
61
+
62
+ First, you must enable the main DHCP module and tell it to use this plugin as its provider.
63
+
64
+ **File:** `/etc/foreman-proxy/settings.d/dhcp.yml`
65
+ ```yaml
66
+ ---
67
+ :enabled: true
68
+ :use_provider: dhcp_kea_api
69
+ # It is highly recommended to enable the ping check for unused IPs to prevent conflicts.
70
+ :ping_free_ip: true
71
+ ```
72
+
73
+ ### 2. Configure the Kea Provider
74
+
75
+ Next, create a configuration file for the Kea provider itself.
76
+
77
+ **File:** `/etc/foreman-proxy/settings.d/dhcp_kea_api.yml`
78
+
79
+ This file enables the provider and contains all the settings needed to connect to your Kea API server.
80
+
81
+ | Setting | Description | Default | Required |
82
+ | -------------------------- | ---------------------------------------------------------------------------- | ------------------------ | -------- |
83
+ | `:enabled` | Enables or disables this provider module. | `false` | **Yes** |
84
+ | `:kea_api_url` | The full URL to your Kea API endpoint (HTTP or HTTPS). | `http://127.0.0.1:8000/` | **Yes** |
85
+ | `:open_timeout` | Time in seconds to wait for the initial connection to be established. | `5` | No |
86
+ | `:read_timeout` | Time in seconds to wait for a response after connecting. | `10` | No |
87
+ | `:blacklist_duration_minutes` | The duration in minutes to temporarily blacklist a suggested IP. | `5` | No |
88
+
89
+ ### Verifying the Installation
90
+
91
+ The Smart Proxy will test its connection to the Kea API upon initialisation. Due to lazy loading, this check runs **on the first DHCP request** after a proxy restart, not during the initial boot sequence.
92
+
93
+ Check `/var/log/foreman-proxy/proxy.log` for a "Successfully connected to Kea API" message at that time to confirm everything is working. If the connection fails, the first DHCP request will fail with an error in the log, preventing further issues.
94
+
95
+ ## Contributing
96
+
97
+ Bug reports and pull requests are welcome on this project's GitLab page.
98
+
99
+ ## Licence Information
100
+
101
+ <details>
102
+ <summary>Copyright and Licence (GPLv3)</summary>
103
+
104
+ Copyright © 2025 Sam McCarthy
105
+
106
+ This program is free software: you can redistribute it and/or modify
107
+ it under the terms of the GNU General Public Licence as published by
108
+ the Free Software Foundation, either version 3 of the Licence or
109
+ (at your option) any later version.
110
+
111
+ This program is distributed in the hope that it will be useful,
112
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
113
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
114
+ GNU General Public Licence for more details.
115
+
116
+ You should have received a copy of the GNU General Public Licence
117
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
118
+ </details>
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'dhcp_common/server'
4
+ require 'resolv'
5
+
6
+ module Proxy
7
+ module DHCP
8
+ module KeaApi
9
+ # The main provider class for the `dhcp_kea_api` module. This class inherits
10
+ # from the Foreman Smart Proxy's core `DHCP::Server` and implements the
11
+ # Kea-specific logic for adding and deleting DHCP reservations.
12
+ class Provider < ::Proxy::DHCP::Server
13
+ attr_reader :subnet_service, :client
14
+
15
+ # Initialises the Kea API provider.
16
+ #
17
+ # @param subnet_service [Proxy::DHCP::KeaApi::SubnetService] The service that manages the in-memory cache of DHCP data.
18
+ # @param client [Proxy::DHCP::KeaApi::Client] The client for communicating with the Kea API.
19
+ # @param free_ips [Proxy::DHCP::FreeIps] The service that tracks recently suggested IPs to prevent race conditions.
20
+ def initialize(subnet_service, client, free_ips)
21
+ @subnet_service = subnet_service
22
+ @client = client
23
+ subnet_service.load!
24
+ super('localhost', nil, subnet_service, free_ips)
25
+ end
26
+
27
+ # Creates a new DHCP reservation in Kea.
28
+ # This method orchestrates the process by first calling the parent class's
29
+ # `add_record` to perform initial validation and IP selection, then building
30
+ # the Kea-specific payload and sending it via the API client.
31
+ #
32
+ # @param options [Hash] A hash of options for the new record, typically including :mac, :ip, and :name.
33
+ # @return [Proxy::DHCP::Reservation] The created reservation object.
34
+ def add_record(options = {})
35
+ logger.debug "DHCP options received from Foreman: #{options.inspect}"
36
+ record = super
37
+
38
+ reservation_args = build_base_reservation_args(record)
39
+ add_boot_and_server_options(reservation_args, options)
40
+
41
+ option_data = build_option_data(options)
42
+ reservation_args['option-data'] = option_data unless option_data.empty?
43
+
44
+ @client.post_command('dhcp4', 'reservation-add', { reservation: reservation_args })
45
+ subnet_service.add_host(record.subnet.network, record)
46
+
47
+ logger.info "Successfully added reservation for MAC #{record.mac} and IP #{record.ip}"
48
+ record
49
+ end
50
+
51
+ # Deletes a DHCP reservation from Kea.
52
+ #
53
+ # @param record [Proxy::DHCP::Reservation] The reservation object to be deleted.
54
+ # @return [Proxy::DHCP::Reservation] The deleted reservation object.
55
+ def del_record(record)
56
+ logger.debug "Deleting record; #{record.inspect}"
57
+ return record if record.is_a? ::Proxy::DHCP::Lease
58
+
59
+ subnet_id = @subnet_service.kea_id_map[record.subnet.network]
60
+ raise Proxy::DHCP::Error, "Unable to find Kea subnet-id for network #{record.subnet.network}" unless subnet_id
61
+
62
+ # Construct the arguments for the 'reservation-del' command using the identifier triplet.
63
+ args = {
64
+ 'subnet-id': subnet_id,
65
+ 'identifier-type': 'hw-address',
66
+ 'identifier' => record.mac
67
+ }
68
+
69
+ @client.post_command('dhcp4', 'reservation-del', args)
70
+ subnet_service.delete_host(record)
71
+
72
+ logger.info "Successfully deleted reservation for MAC #{record.mac} and IP #{record.ip}"
73
+ record
74
+ end
75
+
76
+ private
77
+
78
+ # Builds the initial hash of arguments required for a Kea reservation.
79
+ #
80
+ # @param record [Proxy::DHCP::Reservation] The reservation object from the parent class.
81
+ # @return [Hash] A hash containing the base arguments for the Kea API.
82
+ def build_base_reservation_args(record)
83
+ subnet_id = @subnet_service.kea_id_map[record.subnet.network]
84
+ raise Proxy::DHCP::Error, "Unable to find Kea subnet-id for network #{record.subnet.network}" unless subnet_id
85
+
86
+ {
87
+ 'subnet-id': subnet_id,
88
+ 'ip-address': record.ip,
89
+ 'hw-address': record.mac,
90
+ hostname: record.name
91
+ }
92
+ end
93
+
94
+ # Adds next-server and boot-file-name options to the reservation arguments hash.
95
+ # This method modifies the `reservation_args` hash in place.
96
+ #
97
+ # @param reservation_args [Hash] The hash of arguments to be modified.
98
+ # @param options [Hash] The original options hash from Foreman.
99
+ # @return [void]
100
+ def add_boot_and_server_options(reservation_args, options)
101
+ next_server_value = options[:nextServer]
102
+ reservation_args[:'next-server'] = resolve_hostname(next_server_value) unless next_server_value.to_s.empty?
103
+
104
+ reservation_args[:'boot-file-name'] = options[:filename] unless options[:filename].to_s.empty?
105
+ end
106
+
107
+ # Resolves a hostname to an IP address. If the provided string is already
108
+ # an IP, it is returned directly.
109
+ #
110
+ # @param hostname [String] The hostname or IP address string to resolve.
111
+ # @return [String] The resolved IPv4 address.
112
+ # @raise [Proxy::DHCP::Error] if the hostname cannot be resolved.
113
+ def resolve_hostname(hostname)
114
+ return hostname if hostname =~ Regexp.union(Resolv::IPv4::Regex)
115
+
116
+ Resolv.getaddress(hostname)
117
+ rescue Resolv::ResolvError => e
118
+ raise Proxy::DHCP::Error, "Could not resolve next-server hostname '#{hostname}': #{e.message}"
119
+ end
120
+
121
+ # Builds the array of DHCP options (e.g. routers, ntp-servers) for the reservation.
122
+ #
123
+ # @param options [Hash] The original options hash from Foreman.
124
+ # @return [Array<Hash>] An array of option hashes for the Kea API.
125
+ def build_option_data(options)
126
+ option_data = []
127
+ add_dhcp_option(option_data, 'routers', options[:routers])
128
+ add_dhcp_option(option_data, 'ntp-servers', options[:ntp_servers])
129
+ add_dhcp_option(option_data, 'domain-name', options[:domain_name])
130
+ option_data
131
+ end
132
+
133
+ # A helper to add a DHCP option to the data array if the value exists.
134
+ # This method modifies the `option_data` array in place.
135
+ #
136
+ # @param option_data [Array<Hash>] The array of options to be modified.
137
+ # @param name [String] The name of the DHCP option (e.g. 'routers').
138
+ # @param value [String, Array] The value of the option.
139
+ # @return [void]
140
+ def add_dhcp_option(option_data, name, value)
141
+ return if value.to_s.empty?
142
+
143
+ option_data << { name: name, data: Array(value).join(',') }
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'smart_proxy_dhcp_kea_api/dhcp_kea_api_version'
4
+ require 'smart_proxy_dhcp_kea_api/plugin_configuration'
5
+
6
+ module Proxy
7
+ module DHCP
8
+ module KeaApi
9
+ # The main plugin class for the `dhcp_kea_api` provider. This class serves
10
+ # as the entry point for the Foreman Smart Proxy to load and configure the
11
+ # plugin. It defines the plugin's name, version, dependencies on other
12
+ # modules, default settings, and hooks into the dependency injection framework.
13
+ class Plugin < ::Proxy::Provider
14
+ # Registers the provider with the Smart Proxy, giving it a unique name
15
+ # (`:dhcp_kea_api`) and sourcing the version from the VERSION constant.
16
+ plugin :dhcp_kea_api, ::Proxy::DHCP::KeaApi::VERSION
17
+
18
+ # Declares a dependency on the core Smart Proxy DHCP module. This ensures
19
+ # that the base classes we inherit from (like DHCP::Server) are available.
20
+ requires :dhcp, '>= 1.17'
21
+
22
+ # Defines the default settings for this provider. These values are used if
23
+ # they are not explicitly overridden in a user's settings file
24
+ # (e.g. `/etc/foreman-proxy/settings.d/dhcp_kea_api.yml`).
25
+ default_settings kea_api_url: 'http://127.0.0.1:8000/',
26
+ blacklist_duration_minutes: 5,
27
+ open_timeout: 5,
28
+ read_timeout: 10
29
+
30
+ # Hooks into the Smart Proxy's dependency injection (DI) framework. These lines
31
+ # delegate the responsibility of loading the required classes and wiring up
32
+ # their dependencies to the `PluginConfiguration` class. This keeps this
33
+ # main plugin file clean and declarative.
34
+ load_classes ::Proxy::DHCP::KeaApi::PluginConfiguration
35
+ load_dependency_injection_wirings ::Proxy::DHCP::KeaApi::PluginConfiguration
36
+
37
+ # Tells the Smart Proxy to start these specific services from our DI container
38
+ # when the provider is enabled. `:subnet_service` manages the data cache,
39
+ # and `:unused_ips` handles IP blacklist management.
40
+ start_services :subnet_service, :unused_ips
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ipaddr'
4
+ require 'dhcp_common/dhcp_common'
5
+ require 'dhcp_common/subnet_service'
6
+
7
+ module Proxy
8
+ module DHCP
9
+ module KeaApi
10
+ # Manages the in-memory cache of DHCP data for the Kea provider.
11
+ #
12
+ # This class is responsible for fetching all subnet, reservation, and lease
13
+ # information from the Kea API. It inherits from the core `DHCP::SubnetService`
14
+ # to get the underlying data structures (e.g. hashes for leases and hosts)
15
+ # and caching logic. Its primary public method, `load!`, orchestrates the
16
+ # population of this cache. It also maintains a mapping of Foreman subnet
17
+ # networks to their internal Kea API subnet IDs.
18
+ class SubnetService < ::Proxy::DHCP::SubnetService
19
+ include Proxy::Log
20
+
21
+ # A hash mapping a subnet network address (e.g., "192.168.1.0") to its
22
+ # internal Kea API integer ID (e.g., 1). This is crucial for making
23
+ # API calls that require a `subnet-id`.
24
+ attr_reader :kea_id_map
25
+
26
+ # Initialises the SubnetService.
27
+ #
28
+ # @param client [Proxy::DHCP::KeaApi::Client] The client for communicating with the Kea API.
29
+ # @param leases_by_ip [Proxy::MemoryStore] A memory store for leases, passed to the parent class.
30
+ # @param leases_by_mac [Proxy::MemoryStore] A memory store for leases, passed to the parent class.
31
+ # @param reservations_by_ip [Proxy::MemoryStore] A memory store for reservations, passed to the parent class.
32
+ # @param reservations_by_mac [Proxy::MemoryStore] A memory store for reservations, passed to the parent class.
33
+ # @param reservations_by_name [Proxy::MemoryStore] A memory store for reservations, passed to the parent class.
34
+
35
+ # rubocop:disable Metrics/ParameterLists
36
+ def initialize(client, leases_by_ip, leases_by_mac, reservations_by_ip, reservations_by_mac, reservations_by_name)
37
+ @client = client
38
+ @kea_id_map = {}
39
+ # The `super` call initialises the parent `DHCP::SubnetService`, setting up all the
40
+ # underlying data stores for caching subnets, leases, and reservations.
41
+ super(leases_by_ip, leases_by_mac, reservations_by_ip, reservations_by_mac, reservations_by_name)
42
+ end
43
+ # rubocop:enable Metrics/ParameterLists
44
+
45
+ # The main entry point for loading all DHCP data from the Kea server.
46
+ # It ensures the cache is cleared before performing a fresh load.
47
+ #
48
+ # @return [true] on success.
49
+ # @raise [Proxy::DHCP::Error] if any part of the loading process fails.
50
+ # rubocop:disable Naming/PredicateMethod
51
+ def load!
52
+ subnets.clear
53
+ @kea_id_map.clear
54
+
55
+ load_subnets_and_reservations_from_kea
56
+ load_leases_from_kea
57
+ true
58
+ end
59
+ # rubocop:enable Naming/PredicateMethod
60
+
61
+ # Fetches all subnets and their associated reservations from the Kea API.
62
+ # This single `config-get` call is the most efficient way to get all static configuration.
63
+ def load_subnets_and_reservations_from_kea
64
+ config = @client.post_command('dhcp4', 'config-get')
65
+ subnets_data = config&.dig('Dhcp4', 'subnet4')
66
+ return unless subnets_data
67
+
68
+ subnets_data.each do |subnet_data|
69
+ process_subnet(subnet_data)
70
+ end
71
+ # The rescue blocks ensure that if any API or parsing error occurs, the provider
72
+ # will fail to load, preventing the proxy from starting in a broken state.
73
+ rescue Proxy::DHCP::Error => e
74
+ logger.error "Failed to load subnets and reservations from Kea: #{e.message}"
75
+ raise
76
+ rescue IPAddr::InvalidAddressError => e
77
+ logger.error "Failed to parse subnet from Kea, invalid address found: #{e.message}"
78
+ raise
79
+ end
80
+
81
+ # Fetches all active leases from the Kea API for the subnets currently in the cache.
82
+ def load_leases_from_kea
83
+ # This guard is necessary because the `lease4-get-all` command requires
84
+ # a list of subnet IDs to query. If no subnets were loaded, we can't get leases.
85
+ return if @kea_id_map.empty?
86
+
87
+ response = @client.post_command('dhcp4', 'lease4-get-all', { subnets: @kea_id_map.values })
88
+ return unless response && response['leases']
89
+
90
+ response['leases'].each do |lease|
91
+ ip = lease['ip-address']
92
+ mac = lease['hw-address']
93
+ # We must find the corresponding Subnet object from our cache to associate with the lease.
94
+ subnet_obj = find_subnet(ip)
95
+ unless subnet_obj
96
+ logger.warn "Skipping lease for IP #{ip} as it does not belong to any known subnet."
97
+ next
98
+ end
99
+
100
+ record = ::Proxy::DHCP::Lease.new(nil, ip, mac, subnet_obj, lease['cltt'], lease['expire'], 'active')
101
+ # Add the lease to the parent class's cache.
102
+ add_lease(subnet_obj.network, record)
103
+ end
104
+ rescue Proxy::DHCP::Error => e
105
+ logger.error "Failed to load all leases from Kea: #{e.message}"
106
+ raise
107
+ end
108
+
109
+ private
110
+
111
+ # Parses a single subnet hash from the API response, creates the necessary
112
+ # Foreman Subnet and Reservation objects, and adds them to the cache.
113
+ #
114
+ # @param subnet_data [Hash] The hash representing a single subnet from Kea's `config-get` response.
115
+ def process_subnet(subnet_data)
116
+ ip_object = IPAddr.new(subnet_data['subnet'])
117
+ subnet_addr = ip_object.to_s
118
+ # Correctly derive the netmask string from the IPAddr object's prefix
119
+ mask = IPAddr.new('255.255.255.255').mask(ip_object.prefix).to_s
120
+
121
+ options = {
122
+ routers: extract_routers(subnet_data),
123
+ range: extract_range(subnet_data)
124
+ }.compact
125
+ subnet = ::Proxy::DHCP::Subnet.new(subnet_addr, mask, options)
126
+
127
+ # Add the parsed subnet to the parent class's in-memory cache.
128
+ add_subnet(subnet)
129
+ # Store the mapping between the network address and Kea's internal ID for future API calls.
130
+ @kea_id_map[subnet.network] = subnet_data['id']
131
+ logger.info "Loaded subnet #{subnet.network}/#{subnet.netmask} and mapped to Kea ID #{subnet_data['id']}"
132
+
133
+ # The reservations are conveniently nested within each subnet object in the config.
134
+ subnet_data['reservations']&.each do |res_data|
135
+ process_reservation(res_data, subnet)
136
+ end
137
+ end
138
+
139
+ # Extracts and formats the router data from a subnet's options.
140
+ #
141
+ # @param subnet_data [Hash] The hash representing a single subnet.
142
+ # @return [Array<String>, nil] An array of router IP addresses, or nil if none are found.
143
+ def extract_routers(subnet_data)
144
+ # The router data is a comma-separated string which must be split into an array.
145
+ subnet_data['option-data']&.find { |opt| opt['name'] == 'routers' }&.[]('data')&.split(',')
146
+ end
147
+
148
+ # Extracts the IP range from a subnet's first pool.
149
+ #
150
+ # @param subnet_data [Hash] The hash representing a single subnet.
151
+ # @return [Array<String>, nil] A two-element array containing the start and end of the range, or nil.
152
+ def extract_range(subnet_data)
153
+ # The pool range is a hyphen-separated string (e.g. "10.0.0.10-10.0.0.20").
154
+ pool_string = subnet_data.dig('pools', 0, 'pool')
155
+ pool_string&.split('-')
156
+ end
157
+
158
+ # Creates a Foreman Reservation object from Kea data and adds it to the cache.
159
+ #
160
+ # @param res_data [Hash] The hash representing a single reservation.
161
+ # @param subnet [Proxy::DHCP::Subnet] The subnet object this reservation belongs to.
162
+ def process_reservation(res_data, subnet)
163
+ record = ::Proxy::DHCP::Reservation.new(res_data['hostname'], res_data['ip-address'], res_data['hw-address'], subnet)
164
+ # Add the reservation to the parent class's cache, making it searchable.
165
+ add_host(subnet.network, record)
166
+ logger.debug "Loaded reservation for #{res_data['hw-address']} on subnet #{subnet.network}"
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Proxy
4
+ module DHCP
5
+ module KeaApi
6
+ VERSION = '1.0.0'
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'json'
5
+ require 'uri'
6
+
7
+ module Proxy
8
+ module DHCP
9
+ module KeaApi
10
+ # A client for interacting with the ISC Kea DHCP server API. This class
11
+ # encapsulates the logic for creating JSON-RPC commands, sending them via
12
+ # HTTP, and handling the responses from the Kea server.
13
+ class Client
14
+ include Proxy::Log
15
+
16
+ # Initialises a new Kea API client.
17
+ #
18
+ # @param url [String] The base URL of the Kea API endpoint (e.g. 'http://127.0.0.1:8000/').
19
+ # @param open_timeout [Integer] Time in seconds to wait for the initial TCP connection to be established (defaults to 5).
20
+ # @param read_timeout [Integer] Time in seconds to wait for a response from the server after the connection is made (defaults to 10).
21
+ # @raise [ArgumentError] if the URL is blank, malformed, or not a valid HTTP/S URL.
22
+ def initialize(url:, open_timeout: 5, read_timeout: 10)
23
+ raise ArgumentError, 'Kea API URL cannot be nil or empty' if url.to_s.empty?
24
+
25
+ @uri = URI.parse(url)
26
+
27
+ 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)
28
+
29
+ raise ArgumentError, "Invalid Kea API URL: '#{url}' is missing a host" unless @uri.host
30
+
31
+ @open_timeout = open_timeout
32
+ @read_timeout = read_timeout
33
+ logger.info "Initializing Kea API client for URL: #{@uri} with timeouts (open: #{@open_timeout}s, read: #{@read_timeout}s)"
34
+ end
35
+
36
+ # Constructs and sends a command to the Kea API and handles its response.
37
+ # This is the main public method for interacting with the Kea server.
38
+ #
39
+ # @param service [String] The Kea service to target (e.g. 'dhcp4').
40
+ # @param command [String] The command to execute (e.g. 'config-get', 'reservation-add').
41
+ # @param arguments [Hash] A hash of arguments required by the command. Defaults to an empty hash.
42
+ #
43
+ # @return [Hash] The 'arguments' hash from the Kea API response on success.
44
+ # @raise [Proxy::DHCP::Error] if the API returns an error or if there's a communication issue.
45
+ def post_command(service, command, arguments = {})
46
+ header = { 'Content-Type' => 'application/json' }
47
+ payload = {
48
+ command: command,
49
+ service: [service],
50
+ arguments: arguments
51
+ }
52
+
53
+ # This guard clause satisfies strict linters by ensuring the host is not nil in the local scope.
54
+ host = @uri.host
55
+ raise 'Internal error: Kea API client URI is missing a host' unless host
56
+
57
+ http = Net::HTTP.new(host, @uri.port)
58
+ http.use_ssl = @uri.scheme == 'https'
59
+ http.open_timeout = @open_timeout
60
+ http.read_timeout = @read_timeout
61
+ request = Net::HTTP::Post.new(@uri.request_uri, header)
62
+ request.body = payload.to_json
63
+
64
+ logger.debug "Sending command to Kea: #{payload.inspect}"
65
+ response = http.request(request)
66
+
67
+ handle_response(response, command)
68
+ # This rescue block catches all standard communication errors (e.g. connection refused,
69
+ # timeouts, DNS failures) and wraps them in a Foreman-specific error type. This ensures
70
+ # consistent error handling and reporting up to the Foreman UI.
71
+ rescue StandardError => e
72
+ logger.error "Failed to send command to Kea API: #{e.message}"
73
+ raise Proxy::DHCP::Error, "Kea API communication error: #{e.message}"
74
+ end
75
+
76
+ private
77
+
78
+ # A private helper to parse the JSON response from Kea and route it based on success or failure.
79
+ #
80
+ # @param response [Net::HTTPResponse] The raw response object from the HTTP request.
81
+ # @param command [String] The original command that was sent, used for context-specific handling.
82
+ #
83
+ # @return [Hash] The 'arguments' hash from the response on success.
84
+ # @raise [Proxy::DHCP::Error] if the response indicates a failure or is malformed.
85
+ def handle_response(response, command)
86
+ body = JSON.parse(response.body)
87
+ logger.debug "Received response from Kea: #{body.inspect}"
88
+
89
+ result = body.first if body.is_a?(Array)
90
+ raise Proxy::DHCP::Error, 'Kea API Error: Invalid or empty response from server' unless result
91
+
92
+ # If the response is successful, return its arguments. Otherwise, raise an error.
93
+ if response_successful?(result, command)
94
+ # Provide a fallback of '{}' to prevent returning nil if 'arguments' key is missing.
95
+ result['arguments'] || {}
96
+ else
97
+ error_message = result['text'] || 'Unknown error from Kea API'
98
+ raise Proxy::DHCP::Error, "Kea API Error: #{error_message}"
99
+ end
100
+ end
101
+
102
+ # A private predicate method to determine if a Kea response is successful.
103
+ #
104
+ # @param result [Hash] The parsed result hash from the Kea response body.
105
+ # @param command [String] The original command sent, needed for special case handling.
106
+ #
107
+ # @return [Boolean] `true` if the response is considered a success, `false` otherwise.
108
+ def response_successful?(result, command)
109
+ # Universal success is result code 0.
110
+ return true if result['result'].zero?
111
+ # Special case: 'lease4-get-all' is successful even with result code 3 (no leases found).
112
+ return true if command == 'lease4-get-all' && result['result'] == 3
113
+
114
+ false
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end