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