smart_proxy_dhcp_kea_api 1.0.1 → 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.
@@ -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,46 +58,91 @@ 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.
34
-
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.
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.
89
+ # @see #load_subnets_and_reservations_from_kea
90
+ # @see #load_leases_from_kea
50
91
  # rubocop:disable Naming/PredicateMethod
51
92
  def load!
52
- subnets.clear
53
- @kea_id_map.clear
93
+ staging = Staging.new
54
94
 
55
- load_subnets_and_reservations_from_kea
56
- load_leases_from_kea
95
+ load_subnets_and_reservations_from_kea(staging)
96
+ load_reservations_from_database(staging)
97
+ load_leases_from_kea(staging)
98
+
99
+ commit(staging)
57
100
  true
58
101
  end
59
102
  # rubocop:enable Naming/PredicateMethod
60
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
+
61
130
  # Fetches all subnets and their associated reservations from the Kea API.
62
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.
134
+ # @return [void]
135
+ # @raise [Proxy::DHCP::Error] if the API call fails.
136
+ # @raise [IPAddr::InvalidAddressError] if a subnet address from Kea is invalid.
63
137
  # @see Proxy::DHCP::KeaApi::Client#post_command
64
- def load_subnets_and_reservations_from_kea
138
+ def load_subnets_and_reservations_from_kea(staging)
65
139
  config = @client.post_command('dhcp4', 'config-get')
66
140
  subnets_data = config&.dig('Dhcp4', 'subnet4')
67
141
  return unless subnets_data
68
142
 
69
143
  subnets_data.each do |subnet_data|
70
- process_subnet(subnet_data)
144
+ process_subnet(subnet_data, staging)
71
145
  end
72
- # The rescue blocks ensure that if any API or parsing error occurs, the provider
73
- # will fail to load, preventing the proxy from starting in a broken state.
74
146
  rescue Proxy::DHCP::Error => e
75
147
  logger.error "Failed to load subnets and reservations from Kea: #{e.message}"
76
148
  raise
@@ -79,29 +151,47 @@ module Proxy
79
151
  raise
80
152
  end
81
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
+
82
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.
184
+ # @return [void]
185
+ # @raise [Proxy::DHCP::Error] if the API call fails.
83
186
  # @see Proxy::DHCP::KeaApi::Client#post_command
84
- def load_leases_from_kea
85
- # This guard is necessary because the `lease4-get-all` command requires
86
- # a list of subnet IDs to query. If no subnets were loaded, we can't get leases.
87
- return if @kea_id_map.empty?
187
+ def load_leases_from_kea(staging)
188
+ return if staging.kea_id_map.empty?
88
189
 
89
- 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 })
90
191
  return unless response && response['leases']
91
192
 
92
193
  response['leases'].each do |lease|
93
- ip = lease['ip-address']
94
- mac = lease['hw-address']
95
- # We must find the corresponding Subnet object from our cache to associate with the lease.
96
- subnet_obj = find_subnet(ip)
97
- unless subnet_obj
98
- logger.warn "Skipping lease for IP #{ip} as it does not belong to any known subnet."
99
- next
100
- end
101
-
102
- record = ::Proxy::DHCP::Lease.new(nil, ip, mac, subnet_obj, lease['cltt'], lease['expire'], 'active')
103
- # Add the lease to the parent class's cache.
104
- add_lease(subnet_obj.network, record)
194
+ process_lease(lease, staging)
105
195
  end
106
196
  rescue Proxy::DHCP::Error => e
107
197
  logger.error "Failed to load all leases from Kea: #{e.message}"
@@ -110,32 +200,135 @@ module Proxy
110
200
 
111
201
  private
112
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
+
113
278
  # Parses a single subnet hash from the API response, creates the necessary
114
279
  # Foreman Subnet and Reservation objects, and adds them to the cache.
115
280
  #
116
281
  # @param subnet_data [Hash] The hash representing a single subnet from Kea's `config-get` response.
117
- def process_subnet(subnet_data)
282
+ # @param staging [Staging] The buffer to populate with the parsed subnet.
283
+ # @return [void]
284
+ # @raise [IPAddr::InvalidAddressError] if the subnet string is not a valid IP address.
285
+ def process_subnet(subnet_data, staging)
118
286
  ip_object = IPAddr.new(subnet_data['subnet'])
119
287
  subnet_addr = ip_object.to_s
120
- # Correctly derive the netmask string from the IPAddr object's prefix
121
288
  mask = IPAddr.new('255.255.255.255').mask(ip_object.prefix).to_s
122
289
 
290
+ return unless managed?(subnet_addr)
291
+
123
292
  options = {
124
293
  routers: extract_routers(subnet_data),
125
294
  range: extract_range(subnet_data)
126
295
  }.compact
127
296
  subnet = ::Proxy::DHCP::Subnet.new(subnet_addr, mask, options)
128
297
 
129
- # Add the parsed subnet to the parent class's in-memory cache.
130
- add_subnet(subnet)
131
- # Store the mapping between the network address and Kea's internal ID for future API calls.
132
- @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)
133
301
  logger.info "Loaded subnet #{subnet.network}/#{subnet.netmask} and mapped to Kea ID #{subnet_data['id']}"
134
302
 
135
- # The reservations are conveniently nested within each subnet object in the config.
136
303
  subnet_data['reservations']&.each do |res_data|
137
- 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']
138
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'
139
332
  end
140
333
 
141
334
  # Extracts and formats the router data from a subnet's options.
@@ -143,8 +336,9 @@ module Proxy
143
336
  # @param subnet_data [Hash] The hash representing a single subnet.
144
337
  # @return [Array<String>, nil] An array of router IP addresses, or nil if none are found.
145
338
  def extract_routers(subnet_data)
146
- # The router data is a comma-separated string which must be split into an array.
147
- 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)
148
342
  end
149
343
 
150
344
  # Extracts the IP range from a subnet's first pool.
@@ -152,21 +346,71 @@ module Proxy
152
346
  # @param subnet_data [Hash] The hash representing a single subnet.
153
347
  # @return [Array<String>, nil] A two-element array containing the start and end of the range, or nil.
154
348
  def extract_range(subnet_data)
155
- # The pool range is a hyphen-separated string (e.g. "10.0.0.10-10.0.0.20").
156
349
  pool_string = subnet_data.dig('pools', 0, 'pool')
157
- pool_string&.split('-')
350
+ pool_string&.split('-')&.map(&:strip)
158
351
  end
159
352
 
160
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.
161
356
  #
162
357
  # @param res_data [Hash] The hash representing a single reservation.
163
358
  # @param subnet [Proxy::DHCP::Subnet] The subnet object this reservation belongs to.
164
- def process_reservation(res_data, subnet)
165
- record = ::Proxy::DHCP::Reservation.new(res_data['hostname'], res_data['ip-address'], res_data['hw-address'], subnet)
166
- # Add the reservation to the parent class's cache, making it searchable.
167
- add_host(subnet.network, record)
359
+ # @param staging [Staging] The buffer to populate with the parsed reservation.
360
+ # @return [void]
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)
168
367
  logger.debug "Loaded reservation for #{res_data['hw-address']} on subnet #{subnet.network}"
169
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
170
414
  end
171
415
  end
172
416
  end
@@ -3,7 +3,8 @@
3
3
  module Proxy
4
4
  module DHCP
5
5
  module KeaApi
6
- VERSION = '1.0.1'
6
+ # The current version of the smart_proxy_dhcp_kea_api gem.
7
+ VERSION = '2.1.0'
7
8
  end
8
9
  end
9
10
  end
@@ -16,6 +16,8 @@ module Proxy
16
16
  # Initialises a new Kea API client.
17
17
  #
18
18
  # @param url [String] The base URL of the Kea API endpoint (e.g. 'http://127.0.0.1:8000/').
19
+ # @param username [String, nil] The username for HTTP Basic Authentication.
20
+ # @param password [String, nil] The password for HTTP Basic Authentication.
19
21
  # @param open_timeout [Integer] Time in seconds to wait for the initial TCP connection to be established (defaults to 5).
20
22
  # @param read_timeout [Integer] Time in seconds to wait for a response from the server after the connection is made (defaults to 10).
21
23
  # @raise [ArgumentError] if the URL is blank, malformed, or not a valid HTTP/S URL.
@@ -26,10 +28,12 @@ module Proxy
26
28
  # @example Initialization with Custom Timeouts
27
29
  # client = Proxy::DHCP::KeaApi::Client.new(
28
30
  # url: 'http://127.0.0.1:8000',
31
+ # username: 'myuser',
32
+ # password: 'mypassword',
29
33
  # open_timeout: 2,
30
34
  # read_timeout: 5
31
35
  # )
32
- def initialize(url:, open_timeout: 5, read_timeout: 10)
36
+ def initialize(url:, username: nil, password: nil, open_timeout: 5, read_timeout: 10)
33
37
  raise ArgumentError, 'Kea API URL cannot be nil or empty' if url.to_s.empty?
34
38
 
35
39
  @uri = URI.parse(url)
@@ -38,6 +42,8 @@ module Proxy
38
42
 
39
43
  raise ArgumentError, "Invalid Kea API URL: '#{url}' is missing a host" unless @uri.host
40
44
 
45
+ @username = username
46
+ @password = password
41
47
  @open_timeout = open_timeout
42
48
  @read_timeout = read_timeout
43
49
  logger.info "Initializing Kea API client for URL: #{@uri} with timeouts (open: #{@open_timeout}s, read: #{@read_timeout}s)"
@@ -49,9 +55,10 @@ module Proxy
49
55
  # @param service [String] The Kea service to target (e.g. 'dhcp4').
50
56
  # @param command [String] The command to execute (e.g. 'config-get', 'reservation-add').
51
57
  # @param arguments [Hash] A hash of arguments required by the command. Defaults to an empty hash.
52
- #
53
58
  # @return [Hash] The 'arguments' hash from the Kea API response on success.
54
59
  # @raise [Proxy::DHCP::Error] if the API returns an error or if there's a communication issue.
60
+ # This can be caused by underlying errors like `Net::ReadTimeout`, `Net::OpenTimeout`,
61
+ # `Errno::ECONNREFUSED`, or `JSON::ParserError`.
55
62
  #
56
63
  # @example Get the current DHCPv4 configuration
57
64
  # client = Proxy::DHCP::KeaApi::Client.new(url: 'http://localhost:8000')
@@ -69,6 +76,9 @@ module Proxy
69
76
  # }
70
77
  # })
71
78
  # # => {"text"=>"Reservation added successfully."}
79
+ #
80
+ # @see https://kea.readthedocs.io/en/latest/api.html General Kea Management API documentation.
81
+ # @see https://kea.readthedocs.io/en/latest/api.html#ref-reservation-add For the `reservation-add` command.
72
82
  def post_command(service, command, arguments = {})
73
83
  header = { 'Content-Type' => 'application/json' }
74
84
  payload = {
@@ -87,16 +97,16 @@ module Proxy
87
97
  http.read_timeout = @read_timeout
88
98
  request = Net::HTTP::Post.new(@uri.request_uri, header)
89
99
  request.body = payload.to_json
100
+ request.basic_auth(@username, @password.to_s) if @username
90
101
 
91
102
  logger.debug "Sending command to Kea: #{payload.inspect}"
92
103
  response = http.request(request)
93
104
 
94
105
  handle_response(response, command)
95
- # This rescue block catches all standard communication errors (e.g. connection refused,
96
- # timeouts, DNS failures) and wraps them in a Foreman-specific error type. This ensures
97
- # consistent error handling and reporting up to the Foreman UI.
98
- rescue StandardError => e
99
- logger.error "Failed to send command to Kea API: #{e.message}"
106
+ # This rescue block catches specific, expected network and parsing errors,
107
+ # wrapping them in a Foreman-specific error type for consistent handling.
108
+ rescue Net::ReadTimeout, Net::OpenTimeout, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, JSON::ParserError => e
109
+ logger.error "Failed to send command to Kea API: #{e.class.name} - #{e.message}"
100
110
  raise Proxy::DHCP::Error, "Kea API communication error: #{e.message}"
101
111
  end
102
112
 
@@ -106,9 +116,9 @@ module Proxy
106
116
  #
107
117
  # @param response [Net::HTTPResponse] The raw response object from the HTTP request.
108
118
  # @param command [String] The original command that was sent, used for context-specific handling.
109
- #
110
119
  # @return [Hash] The 'arguments' hash from the response on success.
111
- # @raise [Proxy::DHCP::Error] if the response indicates a failure or is malformed.
120
+ # @raise [Proxy::DHCP::Error] if the response indicates a failure, is malformed, or is empty.
121
+ # @raise [JSON::ParserError] if the response body is not valid JSON.
112
122
  # @private
113
123
  def handle_response(response, command)
114
124
  body = JSON.parse(response.body)
@@ -119,7 +129,7 @@ module Proxy
119
129
 
120
130
  # If the response is successful, return its arguments. Otherwise, raise an error.
121
131
  if response_successful?(result, command)
122
- # Provide a fallback of '{}' to prevent returning nil if 'arguments' key is missing.
132
+ # Provide a fallback of '{}' to prevent returning nil if the 'arguments' key is missing.
123
133
  result['arguments'] || {}
124
134
  else
125
135
  error_message = result['text'] || 'Unknown error from Kea API'
@@ -131,16 +141,18 @@ module Proxy
131
141
  #
132
142
  # @param result [Hash] The parsed result hash from the Kea response body.
133
143
  # @param command [String] The original command sent, needed for special case handling.
134
- #
135
144
  # @return [Boolean] `true` if the response is considered a success, `false` otherwise.
145
+ #
146
+ # @see https://kea.readthedocs.io/en/stable/api.html For documentation on Kea API result codes.
136
147
  # @private
137
148
  def response_successful?(result, command)
138
- # Universal success is result code 0.
139
- return true if result['result'].zero?
140
- # Special case: 'lease4-get-all' is successful even with result code 3 (no leases found).
141
- return true if command == 'lease4-get-all' && result['result'] == 3
149
+ result_code = result['result']
150
+ raise Proxy::DHCP::Error, "Kea API Error: Response missing 'result' field" if result_code.nil?
142
151
 
143
- false
152
+ return true if result_code.zero?
153
+
154
+ # Special case: 'lease4-get-all' is successful even with result code 3 (no leases found).
155
+ command == 'lease4-get-all' && result_code == 3
144
156
  end
145
157
  end
146
158
  end
@@ -28,20 +28,18 @@ 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(
44
40
  url: settings[:kea_api_url],
41
+ username: settings[:kea_api_username],
42
+ password: settings[:kea_api_password],
45
43
  open_timeout: settings[:open_timeout],
46
44
  read_timeout: settings[:read_timeout]
47
45
  )
@@ -49,18 +47,19 @@ module Proxy
49
47
 
50
48
  # The custom service for caching all subnet, reservation, and lease data.
51
49
  # This is a singleton because we want one central, authoritative cache that all
52
- # requests can share. It depends on the Kea client to fetch data and the memory
53
- # 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.
54
52
  # @see Proxy::DHCP::KeaApi::SubnetService#initialize
55
53
  container.singleton_dependency :subnet_service, (lambda do
56
- memory_store = container.get_dependency(:memory_store)
57
54
  ::Proxy::DHCP::KeaApi::SubnetService.new(
58
55
  container.get_dependency(:kea_client),
59
- memory_store,
60
- memory_store,
61
- memory_store,
62
- memory_store,
63
- 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)
64
63
  )
65
64
  end)
66
65
 
@@ -68,7 +67,7 @@ module Proxy
68
67
  # for handling DHCP requests from Foreman. It depends on the subnet service,
69
68
  # the API client, and the IP blacklist service to do its job.
70
69
  # @see Proxy::DHCP::KeaApi::Provider#initialize
71
- container.dependency :dhcp_provider, (lambda do
70
+ container.singleton_dependency :dhcp_provider, (lambda do
72
71
  ::Proxy::DHCP::KeaApi::Provider.new(
73
72
  container.get_dependency(:subnet_service),
74
73
  container.get_dependency(:kea_client),
@@ -1,10 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # The top-level namespace for the Foreman Smart Proxy application.
4
+ # All core Smart Proxy modules and plugins are defined within this namespace.
3
5
  module Proxy
6
+ # The namespace for DHCP-related functionality within the Foreman Smart Proxy.
7
+ # All DHCP providers and their associated services are defined here.
4
8
  module DHCP
5
9
  # The top-level namespace for this DHCP provider. All classes, modules,
6
10
  # and services related to the Kea API integration will be defined within
7
11
  # this KeaApi module to prevent naming conflicts with other plugins.
12
+ # @see Proxy::DHCP::KeaApi::Client
13
+ # @see Proxy::DHCP::KeaApi::SubnetService
8
14
  module KeaApi; end
9
15
  end
10
16
  end