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.
- checksums.yaml +4 -4
- data/README.md +95 -43
- data/lib/smart_proxy_dhcp_kea_api/dhcp_kea_api_main.rb +126 -71
- data/lib/smart_proxy_dhcp_kea_api/dhcp_kea_api_plugin.rb +9 -4
- data/lib/smart_proxy_dhcp_kea_api/dhcp_kea_api_subnet_service.rb +292 -48
- data/lib/smart_proxy_dhcp_kea_api/dhcp_kea_api_version.rb +2 -1
- data/lib/smart_proxy_dhcp_kea_api/kea_api_client.rb +28 -16
- data/lib/smart_proxy_dhcp_kea_api/plugin_configuration.rb +13 -14
- data/lib/smart_proxy_dhcp_kea_api.rb +6 -0
- data/smart_proxy_dhcp_kea_api.gemspec +17 -1
- metadata +152 -8
|
@@ -18,11 +18,38 @@ module Proxy
|
|
|
18
18
|
class SubnetService < ::Proxy::DHCP::SubnetService
|
|
19
19
|
include Proxy::Log
|
|
20
20
|
|
|
21
|
-
#
|
|
22
|
-
#
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
53
|
-
@kea_id_map.clear
|
|
93
|
+
staging = Staging.new
|
|
54
94
|
|
|
55
|
-
load_subnets_and_reservations_from_kea
|
|
56
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
147
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
@@ -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
|
|
96
|
-
#
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
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
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
53
|
-
#
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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.
|
|
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
|