smart_proxy_dhcp_kea_api 1.1.0 → 2.1.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 42b1eec7524d54aa43e9f202c85cb88a92fbfcdf3dbf91a42df43e20d000ac1c
4
- data.tar.gz: 66ae862538c0fc90345eb7a32e487ef63ef536c37cac572a07adfff9d39bb978
3
+ metadata.gz: 950a20554504d738a61674a78b9838c3730d4952e56fcd02d87e95242645e898
4
+ data.tar.gz: 7b1d8c8dfb590d4bcd898aa32204ca04b3a6c06ddbd118b3af0a0241105c36bd
5
5
  SHA512:
6
- metadata.gz: 5cf2dfcf2d1b5b52efa98f274b9abd4d777b5b62182f048401040a01baf3f7c32e70cf38921d7e596090a993b684ac19748257e8e2d67d9f35479ef0cc6f823c
7
- data.tar.gz: 1c54018b7438505c0d8de97337a0363435498c29682e93d7699ffacb2933e7f4af57037c52bbe180b6273066b2c48e6712b06bf3484a83902715e585931d656b
6
+ metadata.gz: 023627a6dcfb40be4e77f83fed7aebb7d96ff7c630105f967baaa4620ea6a1d403f139ecd42aa3a6ca92f92087399c5b515fb58c281da6ae36c8e1cb80bb57b0
7
+ data.tar.gz: d5b5ca744246290728a63e1a22e72b64a74ebf41e3f3ec9ead6b499e61a6b330f9655cb9d51c80c7381e71d878ccb9e479876103a693f810eff5cfdb352c6a69
data/README.md CHANGED
@@ -1,38 +1,51 @@
1
1
  # Foreman Smart Proxy DHCP Kea API
2
+
2
3
  [![Gem Version](https://badge.fury.io/rb/smart_proxy_dhcp_kea_api.svg)](https://badge.fury.io/rb/smart_proxy_dhcp_kea_api)
3
4
  [![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)
4
5
 
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
+ A Foreman Smart Proxy plugin to provide DHCP management by interacting
7
+ with the ISC Kea API. This provider allows Foreman to view subnets and
8
+ leases and to create and delete host reservations directly via Kea's
9
+ JSON-RPC interface.
6
10
 
7
11
  > [!NOTE]
8
12
  > This plugin currently supports **IPv4 only**.
9
13
 
10
- ## Table of Contents
11
-
12
14
  [[_TOC_]]
13
15
 
14
16
  ## Compatibility
15
17
 
16
- * **Foreman Smart Proxy:**
17
- * **ISC Kea:** 3.0.0 or newer
18
+ * **ISC Kea:** >= 3.0.0
18
19
 
19
20
  ## Features
20
21
 
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
22
+ * Adds ISC Kea as a DHCP provider for Foreman.
23
+ * Fetches subnet and pool information directly from the Kea API.
24
+ * Fetches active lease information.
25
+ * Provides host reservation management (add/delete).
26
+ * Provides lease deletion via `lease4-del`.
27
+ * Suggests the next available IP address from a subnet's pool.
28
+ * Passes next-server, PXE boot file, DNS servers, NTP servers,
29
+ and domain-name to Kea.
30
+ * Round-trips reservation options (PXE settings, DNS, routers)
31
+ so Foreman can pre-fill them on edit.
32
+ * Exposes subnet-level DHCP options (next-server, boot-file-name,
33
+ DNS, NTP) to Foreman's UI.
34
+ * Automatic cache refresh with configurable TTL.
35
+ * Optional subnet filtering to manage only a subset of Kea's
36
+ subnets.
27
37
 
28
38
  ## Prerequisites
29
39
 
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).
40
+ * A running Foreman Smart Proxy instance.
41
+ * A running ISC Kea DHCP server.
42
+ * Network connectivity from the Smart Proxy server to the Kea
43
+ server's API endpoint (default port 8000).
33
44
 
34
45
  > [!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.
46
+ > The Kea server **must** be configured with the **`host_cmds`**
47
+ > and **`lease_cmds`** hook libraries loaded for the `dhcp4`
48
+ > service. This plugin will not function without them.
36
49
 
37
50
  ## Installation
38
51
 
@@ -42,31 +55,39 @@ First, install the gem on your Foreman Smart Proxy server:
42
55
  gem install smart_proxy_dhcp_kea_api
43
56
  ```
44
57
 
45
- Add the gem to `/usr/share/foreman-proxy/bundler.d/Gemfile.local.rb`:
58
+ Register the gem with the Smart Proxy bundler by creating a file
59
+ in `/usr/share/foreman-proxy/bundler.d/`:
46
60
 
47
- ```ruby
48
- gem 'smart_proxy_dhcp_kea_api'
61
+ ```bash
62
+ echo "gem 'smart_proxy_dhcp_kea_api'" \
63
+ > /usr/share/foreman-proxy/bundler.d/dhcp_kea_api.rb
49
64
  ```
50
65
 
51
- After installing the gem or changing its configuration, you must restart the `foreman-proxy` service for the changes to take effect.
52
- ```bash
66
+ Then run `bundle install` and restart the proxy:
67
+
68
+ ```bash
69
+ cd /usr/share/foreman-proxy && bundle install
53
70
  systemctl restart foreman-proxy
54
- ```
71
+ ```
55
72
 
56
73
  ## Configuration
57
74
 
58
- Configuration is done in two files in the `/etc/foreman-proxy/settings.d/` directory.
75
+ Configuration is done in two files in the
76
+ `/etc/foreman-proxy/settings.d/` directory.
59
77
 
60
78
  ### 1. Enable the DHCP Module
61
79
 
62
- First, you must enable the main DHCP module and tell it to use this plugin as its provider.
80
+ First, enable the main DHCP module and tell it to use this plugin
81
+ as its provider.
63
82
 
64
83
  **File:** `/etc/foreman-proxy/settings.d/dhcp.yml`
84
+
65
85
  ```yaml
66
86
  ---
67
87
  :enabled: true
68
88
  :use_provider: dhcp_kea_api
69
- # It is highly recommended to enable the ping check for unused IPs to prevent conflicts.
89
+ # It is highly recommended to enable the ping check for unused
90
+ # IPs to prevent conflicts.
70
91
  :ping_free_ip: true
71
92
  ```
72
93
 
@@ -76,43 +97,74 @@ Next, create a configuration file for the Kea provider itself.
76
97
 
77
98
  **File:** `/etc/foreman-proxy/settings.d/dhcp_kea_api.yml`
78
99
 
79
- This file enables the provider and contains all the settings needed to connect to your Kea API server.
100
+ This file enables the provider and contains all the settings
101
+ needed to connect to your Kea API server.
102
+
80
103
 
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 |
104
+ | Setting | Default | Required | Description |
105
+ | ----------------------------- | ------------------------ | -------- | ------------------------------- |
106
+ | `:enabled` | `false` | **Yes** | Enables this provider. |
107
+ | `:kea_api_url` | `http://127.0.0.1:8000/` | **Yes** | Kea API endpoint URL. |
108
+ | `:kea_api_username` | `nil` | No | HTTP Basic Auth username. |
109
+ | `:kea_api_password` | `nil` | No | HTTP Basic Auth password. |
110
+ | `:open_timeout` | `5` | No | Connect timeout (seconds). |
111
+ | `:read_timeout` | `10` | No | Response timeout (seconds). |
112
+ | `:blacklist_duration_minutes` | `5` | No | IP blacklist duration (min). |
113
+ | `:cache_ttl` | `60` | No | Cache refresh interval (sec). |
114
+ | `:managed_subnets` | `nil` | No | CIDRs to manage (all if unset). |
115
+
116
+ **Example configuration:**
117
+
118
+ ```yaml
119
+ ---
120
+ :enabled: true
121
+ :kea_api_url: http://127.0.0.1:8000/
122
+ :kea_api_username: admin
123
+ :kea_api_password: secret
124
+ :cache_ttl: 120
125
+ :managed_subnets:
126
+ - 192.168.1.0/24
127
+ - 10.0.0.0/16
128
+ ```
88
129
 
89
130
  ### Verifying the Installation
90
131
 
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.
132
+ Due to lazy loading, the plugin connects to Kea on the **first
133
+ DHCP request** after a proxy restart, not at boot time.
92
134
 
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.
135
+ Check `/var/log/foreman-proxy/proxy.log` for messages like
136
+ `Loaded subnet 192.168.x.x/255.255.255.0 and mapped to Kea ID 1`
137
+ to confirm the connection is working. If the connection fails,
138
+ the first DHCP request will return an error and the log will
139
+ contain the details.
94
140
 
95
141
  ## Contributing
96
142
 
97
- Bug reports and pull requests are welcome on this project's GitLab page.
143
+ Bug reports and pull requests are welcome on this project's
144
+ GitLab page.
98
145
 
99
146
  ## Licence Information
100
147
 
148
+ <!-- markdownlint-disable MD033 -->
149
+
101
150
  <details>
102
151
  <summary>Copyright and Licence (GPLv3)</summary>
103
152
 
104
- Copyright © 2025 Sam McCarthy
153
+ Copyright (c) 2025 Sam McCarthy
105
154
 
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.
155
+ This program is free software: you can redistribute it and/or
156
+ modify it under the terms of the GNU General Public Licence as
157
+ published by the Free Software Foundation, either version 3 of
158
+ the Licence or (at your option) any later version.
110
159
 
111
160
  This program is distributed in the hope that it will be useful,
112
161
  but WITHOUT ANY WARRANTY; without even the implied warranty of
113
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
162
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
114
163
  GNU General Public Licence for more details.
115
164
 
116
165
  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>
166
+ along with this program. If not, see
167
+ [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
168
+
169
+ </details>
170
+ <!-- markdownlint-enable MD033 -->
@@ -8,7 +8,7 @@ module Proxy
8
8
  module KeaApi
9
9
  # The main provider class for the `dhcp_kea_api` module. This class inherits
10
10
  # from the Foreman Smart Proxy's core `DHCP::Server` and implements the
11
- # Kea-specific logic for adding and deleting DHCP reservations.
11
+ # Kea-specific logic for adding and deleting DHCP reservations and leases.
12
12
  class Provider < ::Proxy::DHCP::Server
13
13
  attr_reader :subnet_service, :client
14
14
 
@@ -25,40 +25,9 @@ module Proxy
25
25
  end
26
26
 
27
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
28
  #
32
29
  # @param options [Hash] A hash containing the details for the new reservation.
33
- # @option options [String] 'mac' The hardware address of the host.
34
- # @option options [String] 'ip' The IP address to reserve.
35
- # @option options [String] 'hostname' The hostname for the reservation.
36
- # @option options [Proxy::DHCP::Subnet] :subnet The subnet object this reservation belongs to.
37
- # @option options [String, nil] 'nextServer' (optional) The IP of the TFTP boot server.
38
- # @option options [String, nil] 'filename' (optional) The boot filename (e.g., 'pxelinux.0').
39
30
  # @return [Proxy::DHCP::Reservation] The created reservation object.
40
- #
41
- # @example Add a new DHCP reservation for a host
42
- # # Assume @provider is an instance of Proxy::DHCP::KeaApi::Provider
43
- # # and 'subnet' is a valid Proxy::DHCP::Subnet object for '192.168.1.0/24'
44
- # options = {
45
- # 'mac' => 'aa:bb:cc:dd:ee:ff',
46
- # 'hostname' => 'test-host.example.com',
47
- # 'ip' => '192.168.1.15',
48
- # subnet: subnet,
49
- # 'nextServer' => 'tftp.example.com',
50
- # 'filename' => 'pxelinux.0',
51
- # 'routers' => ['192.168.1.1'],
52
- # 'ntp_servers' => ['192.168.1.254']
53
- # }
54
- #
55
- # reservation = @provider.add_record(options)
56
- #
57
- # reservation.mac #=> "aa:bb:cc:dd:ee:ff"
58
- # reservation.ip #=> "192.168.1.15"
59
- # reservation.name #=> "test-host.example.com"
60
- # reservation.subnet #=> #<Proxy::DHCP::Subnet ...>
61
- #
62
31
  def add_record(options = {})
63
32
  logger.debug "DHCP options received from Foreman: #{options.inspect}"
64
33
  record = super
@@ -70,43 +39,93 @@ module Proxy
70
39
  reservation_args['option-data'] = option_data unless option_data.empty?
71
40
 
72
41
  @client.post_command('dhcp4', 'reservation-add', { reservation: reservation_args })
73
- subnet_service.add_host(record.subnet.network, record)
42
+ begin
43
+ subnet_service.add_host(record.subnet.network, record)
44
+ rescue StandardError => e
45
+ logger.error "Cache update failed after successful reservation-add for MAC #{record.mac}. " \
46
+ "Kea and cache are out of sync: #{e.message}"
47
+ raise
48
+ end
74
49
 
75
50
  logger.info "Successfully added reservation for MAC #{record.mac} and IP #{record.ip}"
76
51
  record
77
52
  end
78
53
 
79
- # Deletes a DHCP reservation from Kea.
80
- #
81
- # @param record [Proxy::DHCP::Reservation] The reservation object to be deleted.
82
- # This object should be retrieved from the subnet service first.
83
- # @return [Proxy::DHCP::Reservation] The object that was successfully deleted.
84
- # @raise [Proxy::DHCP::Error] if the corresponding Kea subnet-id cannot be found or the API call fails.
54
+ # Deletes a DHCP record from Kea. Handles both reservations and leases.
85
55
  #
86
- # @example Delete a known DHCP reservation
87
- # # Assume @provider is an instance of Proxy::DHCP::KeaApi::Provider
88
- # mac_to_delete = 'aa:bb:cc:dd:ee:ff'
56
+ # @param record [Proxy::DHCP::Reservation, Proxy::DHCP::Lease] The record to be deleted.
57
+ # @return [Proxy::DHCP::Record] The object that was successfully deleted.
58
+ # @raise [Proxy::DHCP::Error] if the record type is unsupported, the corresponding
59
+ # Kea subnet-id cannot be found, or the API call fails.
60
+ def del_record(record)
61
+ logger.debug "Deleting record: #{record.inspect}"
62
+
63
+ if record.is_a?(::Proxy::DHCP::Reservation)
64
+ del_reservation(record)
65
+ elsif record.is_a?(::Proxy::DHCP::Lease)
66
+ del_lease(record)
67
+ else
68
+ raise Proxy::DHCP::Error, "Cannot delete unsupported record type: #{record.class.name}"
69
+ end
70
+
71
+ record
72
+ end
73
+
74
+ # Loads subnet-level DHCP options from the cached Kea configuration.
89
75
  #
90
- # # First, find the reservation object
91
- # record_to_delete = @provider.get_record('mac' => mac_to_delete)
76
+ # @param subnet [Proxy::DHCP::Subnet] The subnet to load options for.
77
+ # @return [void]
78
+ def load_subnet_options(subnet)
79
+ opts = @subnet_service.subnet_options[subnet.network]
80
+ return unless opts
81
+
82
+ apply_boot_subnet_options(subnet, opts)
83
+ apply_mapped_subnet_options(subnet, opts)
84
+ end
85
+
86
+ private
87
+
88
+ # Applies the boot/server fields, which are top-level Kea config fields
89
+ # rather than `option-data` entries (so they are not in OPTION_MAP).
92
90
  #
93
- # # Now, pass the entire object to del_record
94
- # if record_to_delete
95
- # result = @provider.del_record(record_to_delete)
96
- # #=> #<Proxy::DHCP::Reservation ...>
97
- # end
91
+ # @param subnet [Proxy::DHCP::Subnet] The subnet to modify.
92
+ # @param opts [Hash] The cached options hash.
93
+ # @return [void]
94
+ def apply_boot_subnet_options(subnet, opts)
95
+ subnet.options[:nextServer] = opts['next-server'] if opts['next-server']
96
+ subnet.options[:filename] = opts['boot-file-name'] if opts['boot-file-name']
97
+ end
98
+
99
+ # Applies every `option-data`-derived subnet option using OPTION_MAP as the
100
+ # single source of truth for the Kea-name -> Foreman-key (and list) mapping.
98
101
  #
99
- def del_record(record)
100
- logger.debug "Deleting record; #{record.inspect}"
101
- unless record.is_a?(::Proxy::DHCP::Reservation)
102
- logger.warn "Attempted to delete a record for MAC '#{record.mac}' but it is not a Reservation (actual type: #{record.class.name}). No action taken."
103
- return record
102
+ # @param subnet [Proxy::DHCP::Subnet] The subnet to modify.
103
+ # @param opts [Hash] The cached options hash.
104
+ # @return [void]
105
+ def apply_mapped_subnet_options(subnet, opts)
106
+ SubnetService::OPTION_MAP.each do |kea_name, mapping|
107
+ value = opts[kea_name]
108
+ next unless value
109
+
110
+ subnet.options[mapping[:key]] = mapping[:list] ? split_option(value) : value
104
111
  end
112
+ end
113
+
114
+ # Splits a comma-separated option string into a trimmed array.
115
+ #
116
+ # @param value [String] The comma-separated string.
117
+ # @return [Array<String>] The split and stripped values.
118
+ def split_option(value)
119
+ value.split(',').map(&:strip)
120
+ end
105
121
 
106
- subnet_id = @subnet_service.kea_id_map[record.subnet.network]
107
- raise Proxy::DHCP::Error, "Unable to find Kea subnet-id for network #{record.subnet.network}" unless subnet_id
122
+ # Deletes a reservation from Kea by MAC address.
123
+ #
124
+ # @param record [Proxy::DHCP::Reservation] The reservation to delete.
125
+ # @return [void]
126
+ def del_reservation(record)
127
+ subnet_id = find_subnet_id!(record.subnet.network)
108
128
 
109
- # Construct the arguments for the 'reservation-del' command using the identifier triplet.
110
129
  args = {
111
130
  'subnet-id': subnet_id,
112
131
  'identifier-type': 'hw-address',
@@ -114,21 +133,59 @@ module Proxy
114
133
  }
115
134
 
116
135
  @client.post_command('dhcp4', 'reservation-del', args)
117
- subnet_service.delete_host(record)
136
+ begin
137
+ subnet_service.delete_host(record)
138
+ rescue StandardError => e
139
+ logger.error "Cache update failed after successful reservation-del for MAC #{record.mac}. " \
140
+ "Kea and cache are out of sync: #{e.message}"
141
+ raise
142
+ end
118
143
 
119
144
  logger.info "Successfully deleted reservation for MAC #{record.mac} and IP #{record.ip}"
120
- record
121
145
  end
122
146
 
123
- private
147
+ # Deletes a lease from Kea by IP address.
148
+ #
149
+ # @param record [Proxy::DHCP::Lease] The lease to delete.
150
+ # @return [void]
151
+ def del_lease(record)
152
+ subnet_id = find_subnet_id!(record.subnet.network)
153
+
154
+ args = {
155
+ 'subnet-id': subnet_id,
156
+ 'ip-address': record.ip
157
+ }
158
+
159
+ @client.post_command('dhcp4', 'lease4-del', args)
160
+ begin
161
+ subnet_service.delete_lease(record)
162
+ rescue StandardError => e
163
+ logger.error "Cache update failed after successful lease4-del for IP #{record.ip}. " \
164
+ "Kea and cache are out of sync: #{e.message}"
165
+ raise
166
+ end
167
+
168
+ logger.info "Successfully deleted lease for IP #{record.ip}"
169
+ end
170
+
171
+ # Looks up the Kea subnet-id for a network address, raising if not found.
172
+ #
173
+ # @param network [String] The subnet network address.
174
+ # @return [Integer] The Kea subnet-id.
175
+ # @raise [Proxy::DHCP::Error] if the subnet-id is not in the map.
176
+ def find_subnet_id!(network)
177
+ subnet_id = @subnet_service.kea_id_map[network]
178
+ raise Proxy::DHCP::Error, "Unable to find Kea subnet-id for network #{network}" unless subnet_id
179
+
180
+ subnet_id
181
+ end
124
182
 
125
183
  # Builds the initial hash of arguments required for a Kea reservation.
126
184
  #
127
185
  # @param record [Proxy::DHCP::Reservation] The reservation object from the parent class.
128
186
  # @return [Hash] A hash containing the base arguments for the Kea API.
129
187
  def build_base_reservation_args(record)
130
- subnet_id = @subnet_service.kea_id_map[record.subnet.network]
131
- raise Proxy::DHCP::Error, "Unable to find Kea subnet-id for network #{record.subnet.network}" unless subnet_id
188
+ subnet_id = find_subnet_id!(record.subnet.network)
132
189
 
133
190
  {
134
191
  'subnet-id': subnet_id,
@@ -139,16 +196,15 @@ module Proxy
139
196
  end
140
197
 
141
198
  # Adds next-server and boot-file-name options to the reservation arguments hash.
142
- # This method modifies the `reservation_args` hash in place.
143
199
  #
144
200
  # @param reservation_args [Hash] The hash of arguments to be modified.
145
201
  # @param options [Hash] The original options hash from Foreman.
146
202
  # @return [void]
147
203
  def add_boot_and_server_options(reservation_args, options)
148
- next_server_value = options[:nextServer]
204
+ next_server_value = options['nextServer']
149
205
  reservation_args[:'next-server'] = resolve_hostname(next_server_value) unless next_server_value.to_s.empty?
150
206
 
151
- reservation_args[:'boot-file-name'] = options[:filename] unless options[:filename].to_s.empty?
207
+ reservation_args[:'boot-file-name'] = options['filename'] unless options['filename'].to_s.empty?
152
208
  end
153
209
 
154
210
  # Resolves a hostname to an IP address. If the provided string is already
@@ -165,27 +221,26 @@ module Proxy
165
221
  raise Proxy::DHCP::Error, "Could not resolve next-server hostname '#{hostname}': #{e.message}"
166
222
  end
167
223
 
168
- # Builds the array of DHCP options (e.g. routers, ntp-servers) for the reservation.
224
+ # Builds the array of DHCP options (e.g. routers, ntp-servers, dns-servers) for the reservation.
169
225
  #
170
226
  # @param options [Hash] The original options hash from Foreman.
171
227
  # @return [Array<Hash>] An array of option hashes for the Kea API.
172
228
  def build_option_data(options)
173
229
  option_data = []
174
- add_dhcp_option(option_data, 'routers', options[:routers])
175
- add_dhcp_option(option_data, 'ntp-servers', options[:ntp_servers])
176
- add_dhcp_option(option_data, 'domain-name', options[:domain_name])
230
+ SubnetService::OPTION_MAP.each do |kea_name, mapping|
231
+ add_dhcp_option(option_data, kea_name, options[mapping[:key].to_s])
232
+ end
177
233
  option_data
178
234
  end
179
235
 
180
236
  # A helper to add a DHCP option to the data array if the value exists.
181
- # This method modifies the `option_data` array in place.
182
237
  #
183
238
  # @param option_data [Array<Hash>] The array of options to be modified.
184
239
  # @param name [String] The name of the DHCP option (e.g. 'routers').
185
240
  # @param value [String, Array] The value of the option.
186
241
  # @return [void]
187
242
  def add_dhcp_option(option_data, name, value)
188
- return if value.to_s.empty?
243
+ return if value.nil? || (value.respond_to?(:empty?) && value.empty?)
189
244
 
190
245
  option_data << { name: name, data: Array(value).join(',') }
191
246
  end
@@ -21,17 +21,18 @@ module Proxy
21
21
  # that the base classes we inherit from (like DHCP::Server) are available.
22
22
  requires :dhcp, '>= 1.17'
23
23
 
24
- # Defines the default settings for this provider. These values are used if
25
- # they are not explicitly overridden in a user's settings file
26
- # The `kea_api_username` and `kea_api_password` can be used to enable
27
- # HTTP Basic Authentication if required by the Kea control agent.
28
- # (e.g. `/etc/foreman-proxy/settings.d/dhcp_kea_api.yml`).
24
+ # Defines the default settings for this provider. These values are used
25
+ # if they are not overridden in the user's settings file
26
+ # (`/etc/foreman-proxy/settings.d/dhcp_kea_api.yml`).
27
+ # The `kea_api_username` and `kea_api_password` enable HTTP Basic
28
+ # Authentication when the Kea control agent requires it.
29
29
  default_settings kea_api_url: 'http://127.0.0.1:8000/',
30
30
  kea_api_username: nil,
31
31
  kea_api_password: nil,
32
32
  blacklist_duration_minutes: 5,
33
33
  open_timeout: 5,
34
- read_timeout: 10
34
+ read_timeout: 10,
35
+ cache_ttl: 60
35
36
 
36
37
  # Hooks into the Smart Proxy's dependency injection (DI) framework. These lines
37
38
  # delegate the responsibility of loading the required classes and wiring up
@@ -18,11 +18,38 @@ module Proxy
18
18
  class SubnetService < ::Proxy::DHCP::SubnetService
19
19
  include Proxy::Log
20
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
21
+ # Holds the data fetched during a reload before it is atomically swapped
22
+ # into the live cache. Wraps a throwaway parent SubnetService (for its
23
+ # thread-safe stores and lookup helpers) plus the Kea-specific maps, so
24
+ # the loaders can populate it exactly as they would the live object.
25
+ class Staging
26
+ attr_reader :service, :kea_id_map, :subnet_options
27
+
28
+ # @return [void]
29
+ def initialize
30
+ @service = ::Proxy::DHCP::SubnetService.initialized_instance
31
+ @kea_id_map = {}
32
+ @subnet_options = {}
33
+ end
34
+ end
35
+
36
+ # Maps Kea option-data names to their Foreman option keys and whether they are lists.
37
+ OPTION_MAP = {
38
+ 'routers' => { key: :routers, list: true },
39
+ 'domain-name-servers' => { key: :dns_servers, list: true },
40
+ 'domain-name' => { key: :domain_name, list: false },
41
+ 'ntp-servers' => { key: :ntp_servers, list: true }
42
+ }.freeze
43
+
44
+ # A hash mapping a subnet network address (e.g. "192.168.1.0") to its
45
+ # internal Kea API integer ID (e.g. 1). This is crucial for making
23
46
  # API calls that require a `subnet-id`.
24
47
  attr_reader :kea_id_map
25
48
 
49
+ # A hash mapping a subnet network address to its DHCP options hash.
50
+ # Used by the Provider to serve subnet-level options back to Foreman.
51
+ attr_reader :subnet_options
52
+
26
53
  # Initialises the SubnetService.
27
54
  #
28
55
  # @param client [Proxy::DHCP::KeaApi::Client] The client for communicating with the Kea API.
@@ -31,19 +58,31 @@ module Proxy
31
58
  # @param reservations_by_ip [Proxy::MemoryStore] A memory store for reservations, passed to the parent class.
32
59
  # @param reservations_by_mac [Proxy::MemoryStore] A memory store for reservations, passed to the parent class.
33
60
  # @param reservations_by_name [Proxy::MemoryStore] A memory store for reservations, passed to the parent class.
61
+ # @param cache_ttl [Integer] Number of seconds before the cache is considered stale (default: 60).
62
+ # @param managed_subnets [Array<String>, nil] List of CIDR networks to manage. Nil means manage all.
34
63
  # @return [void]
35
64
  # rubocop:disable Metrics/ParameterLists
36
- def initialize(client, leases_by_ip, leases_by_mac, reservations_by_ip, reservations_by_mac, reservations_by_name)
65
+ def initialize(client, leases_by_ip, leases_by_mac, reservations_by_ip, reservations_by_mac, reservations_by_name,
66
+ cache_ttl: 60, managed_subnets: nil)
37
67
  @client = client
38
68
  @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.
69
+ @subnet_options = {}
70
+ @cache_ttl = cache_ttl
71
+ @loaded_at = nil
72
+ @reload_mutex = Mutex.new
73
+ @managed_subnets = parse_managed_subnets(managed_subnets)
41
74
  super(leases_by_ip, leases_by_mac, reservations_by_ip, reservations_by_mac, reservations_by_name)
42
75
  end
43
76
  # rubocop:enable Metrics/ParameterLists
44
77
 
45
78
  # 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.
79
+ #
80
+ # All Kea API calls populate a fresh, off-to-the-side set of stores
81
+ # (`staging`); only once every fetch has succeeded are the new stores
82
+ # swapped into the live cache under the parent's monitor. This keeps the
83
+ # slow network fetch off the live cache so concurrent readers never see a
84
+ # half-populated state, and leaves the previous cache intact if the fetch
85
+ # fails partway through.
47
86
  #
48
87
  # @return [true] on success.
49
88
  # @raise [Proxy::DHCP::Error] if any part of the loading process fails.
@@ -51,31 +90,59 @@ module Proxy
51
90
  # @see #load_leases_from_kea
52
91
  # rubocop:disable Naming/PredicateMethod
53
92
  def load!
54
- subnets.clear
55
- @kea_id_map.clear
93
+ staging = Staging.new
94
+
95
+ load_subnets_and_reservations_from_kea(staging)
96
+ load_reservations_from_database(staging)
97
+ load_leases_from_kea(staging)
56
98
 
57
- load_subnets_and_reservations_from_kea
58
- load_leases_from_kea
99
+ commit(staging)
59
100
  true
60
101
  end
61
102
  # rubocop:enable Naming/PredicateMethod
62
103
 
104
+ # Returns all subnets, refreshing the cache if stale.
105
+ #
106
+ # @return [Array<Proxy::DHCP::Subnet>] All cached subnets.
107
+ def all_subnets
108
+ reload_if_stale!
109
+ super
110
+ end
111
+
112
+ # Returns all host reservations, refreshing the cache if stale.
113
+ #
114
+ # @param subnet_address [String, nil] Optional subnet to filter by.
115
+ # @return [Array<Proxy::DHCP::Reservation>] All cached reservations.
116
+ def all_hosts(subnet_address = nil)
117
+ reload_if_stale!
118
+ super
119
+ end
120
+
121
+ # Returns all leases, refreshing the cache if stale.
122
+ #
123
+ # @param subnet_address [String, nil] Optional subnet to filter by.
124
+ # @return [Array<Proxy::DHCP::Lease>] All cached leases.
125
+ def all_leases(subnet_address = nil)
126
+ reload_if_stale!
127
+ super
128
+ end
129
+
63
130
  # Fetches all subnets and their associated reservations from the Kea API.
64
131
  # This single `config-get` call is the most efficient way to get all static configuration.
132
+ #
133
+ # @param staging [Staging] The buffer to populate with fetched data.
65
134
  # @return [void]
66
135
  # @raise [Proxy::DHCP::Error] if the API call fails.
67
136
  # @raise [IPAddr::InvalidAddressError] if a subnet address from Kea is invalid.
68
137
  # @see Proxy::DHCP::KeaApi::Client#post_command
69
- def load_subnets_and_reservations_from_kea
138
+ def load_subnets_and_reservations_from_kea(staging)
70
139
  config = @client.post_command('dhcp4', 'config-get')
71
140
  subnets_data = config&.dig('Dhcp4', 'subnet4')
72
141
  return unless subnets_data
73
142
 
74
143
  subnets_data.each do |subnet_data|
75
- process_subnet(subnet_data)
144
+ process_subnet(subnet_data, staging)
76
145
  end
77
- # The rescue blocks ensure that if any API or parsing error occurs, the provider
78
- # will fail to load, preventing the proxy from starting in a broken state.
79
146
  rescue Proxy::DHCP::Error => e
80
147
  logger.error "Failed to load subnets and reservations from Kea: #{e.message}"
81
148
  raise
@@ -84,31 +151,47 @@ module Proxy
84
151
  raise
85
152
  end
86
153
 
154
+ # Fetches dynamically added reservations from Kea's hosts-database via
155
+ # `reservation-get-all`. These are not included in `config-get` which only
156
+ # returns static reservations from the config file.
157
+ #
158
+ # @param staging [Staging] The buffer to populate with fetched data.
159
+ # @return [void]
160
+ def load_reservations_from_database(staging)
161
+ return if staging.kea_id_map.empty?
162
+
163
+ staging.kea_id_map.each do |network, subnet_id|
164
+ response = @client.post_command('dhcp4', 'reservation-get-all', { 'subnet-id': subnet_id })
165
+ hosts = response&.[]('hosts')
166
+ next unless hosts
167
+
168
+ subnet_obj = staging.service.find_subnet(network)
169
+ next unless subnet_obj
170
+
171
+ hosts.each do |res_data|
172
+ next if staging.service.find_host_by_mac(network, res_data['hw-address'])
173
+
174
+ process_reservation(res_data, subnet_obj, staging)
175
+ end
176
+ end
177
+ rescue Proxy::DHCP::Error => e
178
+ logger.debug "reservation-get-all not available or failed: #{e.message}"
179
+ end
180
+
87
181
  # Fetches all active leases from the Kea API for the subnets currently in the cache.
182
+ #
183
+ # @param staging [Staging] The buffer to populate with fetched data.
88
184
  # @return [void]
89
185
  # @raise [Proxy::DHCP::Error] if the API call fails.
90
186
  # @see Proxy::DHCP::KeaApi::Client#post_command
91
- def load_leases_from_kea
92
- # This guard is necessary because the `lease4-get-all` command requires
93
- # a list of subnet IDs to query. If no subnets were loaded, we can't get leases.
94
- return if @kea_id_map.empty?
187
+ def load_leases_from_kea(staging)
188
+ return if staging.kea_id_map.empty?
95
189
 
96
- response = @client.post_command('dhcp4', 'lease4-get-all', { subnets: @kea_id_map.values })
190
+ response = @client.post_command('dhcp4', 'lease4-get-all', { subnets: staging.kea_id_map.values })
97
191
  return unless response && response['leases']
98
192
 
99
193
  response['leases'].each do |lease|
100
- ip = lease['ip-address']
101
- mac = lease['hw-address']
102
- # We must find the corresponding Subnet object from our cache to associate with the lease.
103
- subnet_obj = find_subnet(ip)
104
- unless subnet_obj
105
- logger.warn "Skipping lease for IP #{ip} as it does not belong to any known subnet."
106
- next
107
- end
108
-
109
- record = ::Proxy::DHCP::Lease.new(nil, ip, mac, subnet_obj, lease['cltt'], lease['expire'], 'active')
110
- # Add the lease to the parent class's cache.
111
- add_lease(subnet_obj.network, record)
194
+ process_lease(lease, staging)
112
195
  end
113
196
  rescue Proxy::DHCP::Error => e
114
197
  logger.error "Failed to load all leases from Kea: #{e.message}"
@@ -117,68 +200,217 @@ module Proxy
117
200
 
118
201
  private
119
202
 
203
+ # Reloads the cache if it is older than the configured TTL. Only one thread
204
+ # performs the reload at a time (single-flight via `try_lock`); other threads
205
+ # that observe a stale cache serve the current snapshot instead of piling on
206
+ # duplicate, concurrent reloads.
207
+ #
208
+ # @return [void]
209
+ def reload_if_stale!
210
+ return unless stale?
211
+ return unless @reload_mutex.try_lock
212
+
213
+ begin
214
+ return unless stale? # re-check: another thread may have just reloaded
215
+
216
+ logger.debug "Cache TTL (#{@cache_ttl}s) expired, reloading from Kea"
217
+ load!
218
+ ensure
219
+ @reload_mutex.unlock
220
+ end
221
+ end
222
+
223
+ # Atomically replaces the live cache with the freshly-staged data. The swap
224
+ # runs under the parent's monitor so that readers (which take the same lock)
225
+ # observe either the entire old cache or the entire new one, never a mix.
226
+ #
227
+ # @param staging [Staging] The fully-populated buffer to promote.
228
+ # @return [void]
229
+ def commit(staging)
230
+ m.synchronize do
231
+ @subnets = staging.service.subnets
232
+ @leases_by_ip = staging.service.leases_by_ip
233
+ @leases_by_mac = staging.service.leases_by_mac
234
+ @reservations_by_ip = staging.service.reservations_by_ip
235
+ @reservations_by_mac = staging.service.reservations_by_mac
236
+ @reservations_by_name = staging.service.reservations_by_name
237
+ @kea_id_map = staging.kea_id_map
238
+ @subnet_options = staging.subnet_options
239
+ @loaded_at = Time.now
240
+ end
241
+ end
242
+
243
+ # Checks whether the cache has exceeded its TTL.
244
+ #
245
+ # @return [Boolean] true if the cache needs refreshing.
246
+ def stale?
247
+ return true unless @loaded_at
248
+
249
+ (Time.now - @loaded_at) > @cache_ttl
250
+ end
251
+
252
+ # Parses the managed_subnets setting into IPAddr objects for matching.
253
+ #
254
+ # @param managed_subnets [Array<String>, String, nil] CIDR networks to manage.
255
+ # @return [Array<IPAddr>, nil] Parsed networks, or nil to manage all.
256
+ def parse_managed_subnets(managed_subnets)
257
+ return nil if managed_subnets.nil?
258
+
259
+ subnets = Array(managed_subnets)
260
+ return nil if subnets.empty?
261
+
262
+ subnets.map { |cidr| IPAddr.new(cidr) }
263
+ end
264
+
265
+ # Checks whether a subnet should be managed by this proxy.
266
+ #
267
+ # @param subnet_addr [String] The network address of the subnet.
268
+ # @return [Boolean] true if the subnet should be managed.
269
+ def managed?(subnet_addr)
270
+ return true unless @managed_subnets
271
+
272
+ ip = IPAddr.new(subnet_addr)
273
+ # include? already returns true for an exact match (e.g. a /32 entry), so
274
+ # no separate equality check is needed.
275
+ @managed_subnets.any? { |network| network.include?(ip) }
276
+ end
277
+
120
278
  # Parses a single subnet hash from the API response, creates the necessary
121
279
  # Foreman Subnet and Reservation objects, and adds them to the cache.
122
280
  #
123
281
  # @param subnet_data [Hash] The hash representing a single subnet from Kea's `config-get` response.
282
+ # @param staging [Staging] The buffer to populate with the parsed subnet.
124
283
  # @return [void]
125
284
  # @raise [IPAddr::InvalidAddressError] if the subnet string is not a valid IP address.
126
- def process_subnet(subnet_data)
285
+ def process_subnet(subnet_data, staging)
127
286
  ip_object = IPAddr.new(subnet_data['subnet'])
128
287
  subnet_addr = ip_object.to_s
129
- # Correctly derive the netmask string from the IPAddr object's prefix
130
288
  mask = IPAddr.new('255.255.255.255').mask(ip_object.prefix).to_s
131
289
 
290
+ return unless managed?(subnet_addr)
291
+
132
292
  options = {
133
293
  routers: extract_routers(subnet_data),
134
294
  range: extract_range(subnet_data)
135
295
  }.compact
136
296
  subnet = ::Proxy::DHCP::Subnet.new(subnet_addr, mask, options)
137
297
 
138
- # Add the parsed subnet to the parent class's in-memory cache.
139
- add_subnet(subnet)
140
- # Store the mapping between the network address and Kea's internal ID for future API calls.
141
- @kea_id_map[subnet.network] = subnet_data['id']
298
+ staging.service.add_subnet(subnet)
299
+ staging.kea_id_map[subnet.network] = subnet_data['id']
300
+ staging.subnet_options[subnet.network] = extract_subnet_options(subnet_data)
142
301
  logger.info "Loaded subnet #{subnet.network}/#{subnet.netmask} and mapped to Kea ID #{subnet_data['id']}"
143
302
 
144
- # The reservations are conveniently nested within each subnet object in the config.
145
303
  subnet_data['reservations']&.each do |res_data|
146
- process_reservation(res_data, subnet)
304
+ process_reservation(res_data, subnet, staging)
305
+ end
306
+ end
307
+
308
+ # Extracts all DHCP options from a subnet into a normalised hash.
309
+ #
310
+ # @param subnet_data [Hash] The hash representing a single subnet.
311
+ # @return [Hash] A hash of option names to their values.
312
+ def extract_subnet_options(subnet_data)
313
+ opts = {}
314
+ option_data = subnet_data['option-data'] || []
315
+ option_data.each do |opt|
316
+ opts[opt['name']] = opt['data']
147
317
  end
318
+ opts['next-server'] = subnet_data['next-server'] if meaningful_boot_value?(subnet_data['next-server'])
319
+ opts['boot-file-name'] = subnet_data['boot-file-name'] if meaningful_boot_value?(subnet_data['boot-file-name'])
320
+ opts
321
+ end
322
+
323
+ # Returns true when a Kea boot field carries a real value. Kea reports an
324
+ # unset next-server as "0.0.0.0" and an unset boot-file-name as "", which
325
+ # are placeholders that must not be round-tripped back to Foreman as if a
326
+ # user had configured them (doing so triggers spurious DHCP rebuilds).
327
+ #
328
+ # @param value [String, nil] The raw value from Kea.
329
+ # @return [Boolean] true if the value is present and not a placeholder.
330
+ def meaningful_boot_value?(value)
331
+ !value.nil? && !value.to_s.strip.empty? && value != '0.0.0.0'
148
332
  end
149
333
 
150
334
  # Extracts and formats the router data from a subnet's options.
151
335
  #
152
336
  # @param subnet_data [Hash] The hash representing a single subnet.
153
337
  # @return [Array<String>, nil] An array of router IP addresses, or nil if none are found.
154
- # @note The router data in Kea is a single comma-separated string which must be split into an array.
155
338
  def extract_routers(subnet_data)
156
- # The router data is a comma-separated string which must be split into an array.
157
- subnet_data['option-data']&.find { |opt| opt['name'] == 'routers' }&.[]('data')&.split(',')
339
+ router_opt = subnet_data['option-data']&.find { |opt| opt['name'] == 'routers' }
340
+ data = router_opt&.[]('data')
341
+ data&.split(',')&.map(&:strip)
158
342
  end
159
343
 
160
344
  # Extracts the IP range from a subnet's first pool.
161
345
  #
162
346
  # @param subnet_data [Hash] The hash representing a single subnet.
163
347
  # @return [Array<String>, nil] A two-element array containing the start and end of the range, or nil.
164
- # @note The pool range is a hyphen-separated string (e.g. "10.0.0.10-10.0.0.20").
165
348
  def extract_range(subnet_data)
166
- # The pool range is a hyphen-separated string (e.g. "10.0.0.10-10.0.0.20").
167
349
  pool_string = subnet_data.dig('pools', 0, 'pool')
168
- pool_string&.split('-')
350
+ pool_string&.split('-')&.map(&:strip)
169
351
  end
170
352
 
171
353
  # Creates a Foreman Reservation object from Kea data and adds it to the cache.
354
+ # Includes option-data, next-server, and boot-file-name so that Foreman can
355
+ # round-trip these values when querying existing reservations.
172
356
  #
173
357
  # @param res_data [Hash] The hash representing a single reservation.
174
358
  # @param subnet [Proxy::DHCP::Subnet] The subnet object this reservation belongs to.
359
+ # @param staging [Staging] The buffer to populate with the parsed reservation.
175
360
  # @return [void]
176
- def process_reservation(res_data, subnet)
177
- record = ::Proxy::DHCP::Reservation.new(res_data['hostname'], res_data['ip-address'], res_data['hw-address'], subnet)
178
- # Add the reservation to the parent class's cache, making it searchable.
179
- add_host(subnet.network, record)
361
+ def process_reservation(res_data, subnet, staging)
362
+ opts = extract_reservation_options(res_data)
363
+ record = ::Proxy::DHCP::Reservation.new(
364
+ res_data['hostname'], res_data['ip-address'], res_data['hw-address'], subnet, opts
365
+ )
366
+ staging.service.add_host(subnet.network, record)
180
367
  logger.debug "Loaded reservation for #{res_data['hw-address']} on subnet #{subnet.network}"
181
368
  end
369
+
370
+ # Extracts Foreman-compatible options from a Kea reservation hash.
371
+ #
372
+ # @param res_data [Hash] The reservation data from Kea's config-get.
373
+ # @return [Hash] Options hash suitable for Proxy::DHCP::Reservation.
374
+ def extract_reservation_options(res_data)
375
+ opts = {}
376
+ opts[:nextServer] = res_data['next-server'] if meaningful_boot_value?(res_data['next-server'])
377
+ opts[:filename] = res_data['boot-file-name'] if meaningful_boot_value?(res_data['boot-file-name'])
378
+ map_option_data(opts, res_data['option-data'] || [])
379
+ opts
380
+ end
381
+
382
+ # Applies Kea option-data entries to a Foreman options hash using OPTION_MAP.
383
+ #
384
+ # @param opts [Hash] The target options hash to populate.
385
+ # @param option_data [Array<Hash>] The option-data array from Kea.
386
+ # @return [void]
387
+ def map_option_data(opts, option_data)
388
+ option_data.each do |opt|
389
+ mapping = OPTION_MAP[opt['name']]
390
+ next unless mapping
391
+
392
+ data = opt['data']
393
+ opts[mapping[:key]] = mapping[:list] ? data&.split(',')&.map(&:strip) : data
394
+ end
395
+ end
396
+
397
+ # Creates a Foreman Lease object from Kea data and adds it to the cache.
398
+ #
399
+ # @param lease [Hash] The lease data from Kea's lease4-get-all response.
400
+ # @param staging [Staging] The buffer to populate with the parsed lease.
401
+ # @return [void]
402
+ def process_lease(lease, staging)
403
+ ip = lease['ip-address']
404
+ mac = lease['hw-address']
405
+ subnet_obj = staging.service.find_subnet(ip)
406
+ unless subnet_obj
407
+ logger.warn "Skipping lease for IP #{ip} as it does not belong to any known subnet."
408
+ return
409
+ end
410
+
411
+ record = ::Proxy::DHCP::Lease.new(nil, ip, mac, subnet_obj, lease['cltt'], lease['expire'], 'active')
412
+ staging.service.add_lease(subnet_obj.network, record)
413
+ end
182
414
  end
183
415
  end
184
416
  end
@@ -4,7 +4,7 @@ module Proxy
4
4
  module DHCP
5
5
  module KeaApi
6
6
  # The current version of the smart_proxy_dhcp_kea_api gem.
7
- VERSION = '1.1.0'
7
+ VERSION = '2.1.0'
8
8
  end
9
9
  end
10
10
  end
@@ -29,7 +29,7 @@ module Proxy
29
29
  # client = Proxy::DHCP::KeaApi::Client.new(
30
30
  # url: 'http://127.0.0.1:8000',
31
31
  # username: 'myuser',
32
- # password: 'mypassword'
32
+ # password: 'mypassword',
33
33
  # open_timeout: 2,
34
34
  # read_timeout: 5
35
35
  # )
@@ -97,7 +97,7 @@ module Proxy
97
97
  http.read_timeout = @read_timeout
98
98
  request = Net::HTTP::Post.new(@uri.request_uri, header)
99
99
  request.body = payload.to_json
100
- request.basic_auth(@username, @password) if @username && @password
100
+ request.basic_auth(@username, @password.to_s) if @username
101
101
 
102
102
  logger.debug "Sending command to Kea: #{payload.inspect}"
103
103
  response = http.request(request)
@@ -146,11 +146,13 @@ module Proxy
146
146
  # @see https://kea.readthedocs.io/en/stable/api.html For documentation on Kea API result codes.
147
147
  # @private
148
148
  def response_successful?(result, command)
149
- # Universal success is result code 0.
150
- return true if result['result'].zero?
149
+ result_code = result['result']
150
+ raise Proxy::DHCP::Error, "Kea API Error: Response missing 'result' field" if result_code.nil?
151
+
152
+ return true if result_code.zero?
151
153
 
152
154
  # Special case: 'lease4-get-all' is successful even with result code 3 (no leases found).
153
- command == 'lease4-get-all' && result['result'] == 3
155
+ command == 'lease4-get-all' && result_code == 3
154
156
  end
155
157
  end
156
158
  end
@@ -28,16 +28,12 @@ module Proxy
28
28
  # @param settings [Hash] The settings hash for this provider.
29
29
 
30
30
  def load_dependency_injection_wirings(container, settings)
31
- # A standard in-memory key-value store provided by the Smart Proxy framework.
32
- # It's used by the SubnetService to cache DHCP data.
33
- container.singleton_dependency :memory_store, -> { ::Proxy::MemoryStore.new }
34
-
35
31
  # A singleton service that manages the temporary blacklisting of suggested IP addresses
36
32
  # to prevent race conditions. Its duration is configured via the settings file.
37
33
  container.singleton_dependency :unused_ips, -> { ::Proxy::DHCP::FreeIps.new(settings[:blacklist_duration_minutes]) }
38
34
 
39
35
  # The custom client for communicating with the Kea API. This is registered as a singleton
40
- # so that a single, persistent HTTP connection pool is used for all API calls.
36
+ # so that a single client instance (with its configuration) is shared across all requests.
41
37
  # @see Proxy::DHCP::KeaApi::Client#initialize
42
38
  container.singleton_dependency :kea_client, (lambda do
43
39
  ::Proxy::DHCP::KeaApi::Client.new(
@@ -51,18 +47,19 @@ module Proxy
51
47
 
52
48
  # The custom service for caching all subnet, reservation, and lease data.
53
49
  # This is a singleton because we want one central, authoritative cache that all
54
- # requests can share. It depends on the Kea client to fetch data and the memory
55
- # stores to cache it.
50
+ # requests can share. Each store must be a separate instance to avoid collisions
51
+ # between leases and reservations keyed by the same IP/MAC.
56
52
  # @see Proxy::DHCP::KeaApi::SubnetService#initialize
57
53
  container.singleton_dependency :subnet_service, (lambda do
58
- memory_store = container.get_dependency(:memory_store)
59
54
  ::Proxy::DHCP::KeaApi::SubnetService.new(
60
55
  container.get_dependency(:kea_client),
61
- memory_store,
62
- memory_store,
63
- memory_store,
64
- memory_store,
65
- memory_store
56
+ ::Proxy::MemoryStore.new,
57
+ ::Proxy::MemoryStore.new,
58
+ ::Proxy::MemoryStore.new,
59
+ ::Proxy::MemoryStore.new,
60
+ ::Proxy::MemoryStore.new,
61
+ cache_ttl: settings[:cache_ttl],
62
+ managed_subnets: settings.fetch(:managed_subnets, nil)
66
63
  )
67
64
  end)
68
65
 
@@ -70,7 +67,7 @@ module Proxy
70
67
  # for handling DHCP requests from Foreman. It depends on the subnet service,
71
68
  # the API client, and the IP blacklist service to do its job.
72
69
  # @see Proxy::DHCP::KeaApi::Provider#initialize
73
- container.dependency :dhcp_provider, (lambda do
70
+ container.singleton_dependency :dhcp_provider, (lambda do
74
71
  ::Proxy::DHCP::KeaApi::Provider.new(
75
72
  container.get_dependency(:subnet_service),
76
73
  container.get_dependency(:kea_client),
@@ -17,7 +17,9 @@ Gem::Specification.new do |s|
17
17
  s.metadata = {
18
18
  'bug_tracker_uri' => 'https://gitlab.surrey.ac.uk/sm0049/smart-proxy-dhcp-kea-api/-/issues',
19
19
  'homepage_uri' => 'https://gitlab.surrey.ac.uk/sm0049/smart-proxy-dhcp-kea-api',
20
- 'rubygems_mfa_required' => 'true'
20
+ 'rubygems_mfa_required' => 'true',
21
+ 'changelog_uri' => "https://gitlab.surrey.ac.uk/sm0049/smart-proxy-dhcp-kea-api/-/blob/main/CHANGELOG.md",
22
+ 'documentation_uri' => "https://smart-proxy-dhcp-kea-api-1fed73.pages.surrey.ac.uk/",
21
23
  }
22
24
 
23
25
  s.files = Dir.glob('{lib}/**/*', File::FNM_DOTMATCH).reject { |f| File.directory?(f) }
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: smart_proxy_dhcp_kea_api
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.0
4
+ version: 2.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sam McCarthy
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-07-22 00:00:00.000000000 Z
11
+ date: 2026-06-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -152,14 +152,13 @@ dependencies:
152
152
  version: '0'
153
153
  description: Provides DHCP management for Foreman via the ISC Kea API, requiring the
154
154
  host_cmds and lease_cmds hooks.
155
- email:
155
+ email:
156
156
  executables: []
157
157
  extensions: []
158
158
  extra_rdoc_files: []
159
159
  files:
160
160
  - LICENSE
161
161
  - README.md
162
- - lib/.DS_Store
163
162
  - lib/smart_proxy_dhcp_kea_api.rb
164
163
  - lib/smart_proxy_dhcp_kea_api/dhcp_kea_api_main.rb
165
164
  - lib/smart_proxy_dhcp_kea_api/dhcp_kea_api_plugin.rb
@@ -175,7 +174,9 @@ metadata:
175
174
  bug_tracker_uri: https://gitlab.surrey.ac.uk/sm0049/smart-proxy-dhcp-kea-api/-/issues
176
175
  homepage_uri: https://gitlab.surrey.ac.uk/sm0049/smart-proxy-dhcp-kea-api
177
176
  rubygems_mfa_required: 'true'
178
- post_install_message:
177
+ changelog_uri: https://gitlab.surrey.ac.uk/sm0049/smart-proxy-dhcp-kea-api/-/blob/main/CHANGELOG.md
178
+ documentation_uri: https://smart-proxy-dhcp-kea-api-1fed73.pages.surrey.ac.uk/
179
+ post_install_message:
179
180
  rdoc_options: []
180
181
  require_paths:
181
182
  - lib
@@ -193,8 +194,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
193
194
  - !ruby/object:Gem::Version
194
195
  version: '0'
195
196
  requirements: []
196
- rubygems_version: 3.4.19
197
- signing_key:
197
+ rubygems_version: 3.0.3.1
198
+ signing_key:
198
199
  specification_version: 4
199
200
  summary: Foreman Smart Proxy plugin for ISC Kea DHCP API
200
201
  test_files: []
data/lib/.DS_Store DELETED
Binary file