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