Skip to content

Commit c19c502

Browse files
committed
Add KEA DHCP provider
Ref: theforeman/foremanctl#531 (comment) ISC DHCP reached end-of-life in October 2022 and is no longer maintained. ISC officially recommends migrating to KEA DHCP as the replacement. This commit adds Smart Proxy DHCP provider for ISC KEA DHCP server using the KEA Control Agent API, providing a migration path for users currently using the deprecated dhcp_isc provider. Based on the `smart_proxy_dhcp_kea_api` gem by Sam McCarthy with author's permission to integrate into Smart Proxy core. https://gitlab.surrey.ac.uk/sm0049/smart-proxy-dhcp-kea-api/-/issues/3 Fixes: https://redhat.atlassian.net/browse/SAT-27739
1 parent 3e249dd commit c19c502

14 files changed

Lines changed: 910 additions & 3 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
# Supported Modules
1010
Currently Supported modules:
1111
* BMC - BMC management of devices supported by freeipmi and ipmitool
12-
* DHCP - ISC DHCP and MS DHCP Servers
12+
* DHCP - ISC KEA, MS DHCP, and ISC DHCP (legacy, EOL 2022) Servers
1313
* DNS - Bind and MS DNS Servers
1414
* Puppet - Puppetserver 6 or 7
1515
* Puppet CA - Manage certificate signing, cleaning and autosign on a Puppet CA server

config/settings.d/dhcp.yml.example

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,14 @@
33
:enabled: false
44

55
# valid providers:
6-
# - dhcp_isc (ISC dhcp server)
6+
# - dhcp_kea (ISC KEA dhcp server)
77
# - dhcp_native_ms (Microsoft native implementation)
88
# - dhcp_libvirt
9-
#:use_provider: dhcp_isc
9+
# - dhcp_isc (ISC dhcp server - DEPRECATED)
10+
#
11+
# NOTE: ISC DHCP reached end-of-life in October 2022. ISC recommends
12+
# migrating to KEA DHCP. Use `dhcp_kea` for new deployments.
13+
#:use_provider: dhcp_kea
1014
#:server: 127.0.0.1
1115
# subnets restricts the subnets queried to a subset, to reduce the query time.
1216
#:subnets: [192.168.205.0/255.255.255.128, 192.168.205.128/255.255.255.128]
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
# KEA DHCP provider configuration
3+
# Requires ISC KEA DHCP server with Control Agent API enabled
4+
5+
# URL of the KEA Control Agent
6+
#:dhcp_kea_url: http://127.0.0.1:8000/
7+
8+
# Optional HTTP Basic Authentication credentials
9+
#:dhcp_kea_username: ~
10+
#:dhcp_kea_password: ~
11+
12+
# SSL certificate verification (set to false for self-signed certificates)
13+
#:dhcp_kea_verify_ssl: true
14+
15+
# Timeout for lease operations in seconds
16+
#:dhcp_kea_lease_timeout: 60

modules/dhcp_kea/dhcp_kea.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
require 'dhcp_common/dhcp_common'
2+
require 'dhcp_kea/plugin_configuration'
3+
require 'dhcp_kea/dhcp_kea_plugin'

modules/dhcp_kea/dhcp_kea_main.rb

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
require 'dhcp_common/server'
2+
3+
module Proxy::DHCP::Kea
4+
class Provider < ::Proxy::DHCP::Server
5+
include Proxy::Log
6+
7+
attr_reader :kea_client, :lease_timeout
8+
9+
def initialize(kea_client, subnet_service, free_ips_service, lease_timeout = 60)
10+
@kea_client = kea_client
11+
@lease_timeout = lease_timeout
12+
13+
super('kea-dhcp-server', nil, subnet_service, free_ips_service)
14+
15+
load_subnets
16+
end
17+
18+
def load_subnets
19+
logger.info "Loading subnets from KEA DHCP server"
20+
21+
begin
22+
subnets = kea_client.list_subnets
23+
rescue => e
24+
logger.error "Failed to load subnets from KEA: #{e.message}"
25+
raise Proxy::DHCP::Error, "Cannot connect to KEA DHCP server: #{e.message}"
26+
end
27+
28+
if subnets.empty?
29+
logger.warn "No subnets configured in KEA DHCP server"
30+
return
31+
end
32+
33+
subnets.each do |subnet_config|
34+
network = subnet_config['subnet']
35+
subnet_id = subnet_config['id']
36+
37+
logger.debug "Loading subnet: #{network} (KEA ID: #{subnet_id})"
38+
39+
netmask = netmask_from_cidr(network)
40+
41+
subnet = ::Proxy::DHCP::Subnet.new(network, netmask)
42+
43+
subnet.options[:kea_subnet_id] = subnet_id
44+
45+
service.add_subnet(subnet)
46+
47+
logger.debug "Added subnet #{network} with KEA ID #{subnet_id}"
48+
end
49+
50+
logger.info "Loaded #{subnets.size} subnet(s) from KEA"
51+
end
52+
53+
def add_record(options = {})
54+
logger.debug "Adding DHCP reservation with options: #{options.inspect}"
55+
56+
record = super(options)
57+
58+
subnet = find_subnet(record.network)
59+
unless subnet
60+
raise Proxy::DHCP::Error, "Subnet not found for network: #{record.network}"
61+
end
62+
63+
subnet_id = subnet.options[:kea_subnet_id]
64+
unless subnet_id
65+
raise Proxy::DHCP::Error, "KEA subnet ID not found for #{record.network}"
66+
end
67+
68+
kea_options = {}
69+
kea_options[:next_server] = record.nextServer if record.nextServer
70+
kea_options[:boot_file_name] = record.filename if record.filename
71+
72+
begin
73+
kea_client.add_reservation(
74+
subnet_id,
75+
record.ip,
76+
record.mac,
77+
record.name,
78+
kea_options
79+
)
80+
rescue => e
81+
logger.error "Failed to create KEA reservation: #{e.message}"
82+
raise Proxy::DHCP::Error, "Failed to create reservation in KEA: #{e.message}"
83+
end
84+
85+
service.add_host(subnet.network, record)
86+
87+
logger.info "Successfully created KEA DHCP reservation: #{record.ip} for #{record.mac}"
88+
record
89+
end
90+
91+
def del_record(record)
92+
logger.debug "Deleting DHCP record: #{record.inspect}"
93+
94+
subnet = find_subnet(record.network)
95+
unless subnet
96+
raise Proxy::DHCP::Error, "Subnet not found for network: #{record.network}"
97+
end
98+
99+
subnet_id = subnet.options[:kea_subnet_id]
100+
unless subnet_id
101+
raise Proxy::DHCP::Error, "KEA subnet ID not found for #{record.network}"
102+
end
103+
104+
begin
105+
kea_client.delete_reservation_by_ip(subnet_id, record.ip)
106+
rescue => e
107+
logger.error "Failed to delete KEA reservation: #{e.message}"
108+
raise Proxy::DHCP::Error, "Failed to delete reservation from KEA: #{e.message}"
109+
end
110+
111+
if record.is_a?(::Proxy::DHCP::Reservation)
112+
service.delete_host(subnet.network, record)
113+
elsif record.is_a?(::Proxy::DHCP::Lease)
114+
service.delete_lease(subnet.network, record)
115+
end
116+
117+
logger.info "Successfully deleted KEA DHCP reservation: #{record.ip}"
118+
end
119+
120+
def load_subnet_options(subnet)
121+
logger.debug "Loading subnet options for #{subnet.network}"
122+
end
123+
124+
private
125+
126+
def netmask_from_cidr(cidr)
127+
IPAddr.new(cidr).netmask.to_s
128+
end
129+
end
130+
end
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
module Proxy::DHCP::Kea
2+
class Plugin < ::Proxy::Provider
3+
plugin :dhcp_kea, ::Proxy::VERSION
4+
5+
capability 'dhcp_filename_ipv4'
6+
capability 'dhcp_filename_hostname'
7+
8+
default_settings :dhcp_kea_url => 'http://127.0.0.1:8000/',
9+
:dhcp_kea_username => nil,
10+
:dhcp_kea_password => nil,
11+
:dhcp_kea_verify_ssl => true,
12+
:dhcp_kea_lease_timeout => 60
13+
14+
requires :dhcp, ::Proxy::VERSION
15+
16+
load_classes ::Proxy::DHCP::Kea::PluginConfiguration
17+
load_programmable_settings ::Proxy::DHCP::Kea::PluginConfiguration
18+
load_dependency_injection_wirings ::Proxy::DHCP::Kea::PluginConfiguration
19+
end
20+
end

modules/dhcp_kea/kea_api_client.rb

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
require 'net/http'
2+
require 'uri'
3+
require 'json'
4+
5+
module Proxy::DHCP::Kea
6+
class KeaApiClient
7+
include Proxy::Log
8+
9+
attr_reader :api_url, :username, :password, :verify_ssl
10+
11+
def initialize(api_url, username = nil, password = nil, verify_ssl = true)
12+
@api_url = api_url.chomp('/')
13+
@username = username
14+
@password = password
15+
@verify_ssl = verify_ssl
16+
end
17+
18+
def send_command(service, command, arguments = {})
19+
payload = {
20+
'command' => command,
21+
'service' => [service],
22+
'arguments' => arguments
23+
}
24+
25+
logger.debug "Sending KEA command: #{command} to service: #{service}"
26+
logger.debug "Arguments: #{arguments.inspect}"
27+
28+
response = http_post('/', payload)
29+
30+
result = response.first
31+
32+
if result['result'] != 0
33+
error_msg = "KEA command '#{command}' failed: #{result['text']}"
34+
logger.error error_msg
35+
raise error_msg
36+
end
37+
38+
logger.debug "KEA command successful: #{result['text']}"
39+
result['arguments'] || {}
40+
end
41+
42+
def get_config
43+
send_command('dhcp4', 'config-get')
44+
end
45+
46+
def list_subnets
47+
config = get_config
48+
config.dig('Dhcp4', 'subnet4') || []
49+
end
50+
51+
def add_reservation(subnet_id, ip_address, hw_address, hostname = nil, options = {})
52+
reservation = {
53+
'subnet-id' => subnet_id.to_i,
54+
'ip-address' => ip_address,
55+
'hw-address' => hw_address
56+
}
57+
58+
reservation['hostname'] = hostname if hostname
59+
60+
if options[:next_server]
61+
reservation['next-server'] = options[:next_server]
62+
end
63+
64+
if options[:boot_file_name]
65+
reservation['boot-file-name'] = options[:boot_file_name]
66+
end
67+
68+
logger.info "Adding KEA reservation: #{ip_address} for #{hw_address} in subnet #{subnet_id}"
69+
send_command('dhcp4', 'reservation-add', reservation)
70+
end
71+
72+
def delete_reservation_by_ip(subnet_id, ip_address)
73+
logger.info "Deleting KEA reservation: #{ip_address} from subnet #{subnet_id}"
74+
send_command('dhcp4', 'reservation-del', {
75+
'subnet-id' => subnet_id.to_i,
76+
'ip-address' => ip_address
77+
})
78+
end
79+
80+
def get_reservation_by_ip(subnet_id, ip_address)
81+
send_command('dhcp4', 'reservation-get', {
82+
'subnet-id' => subnet_id.to_i,
83+
'ip-address' => ip_address
84+
})
85+
rescue => e
86+
logger.debug "Reservation not found for #{ip_address}: #{e.message}"
87+
nil
88+
end
89+
90+
def list_leases
91+
send_command('dhcp4', 'lease4-get-all')
92+
end
93+
94+
95+
def get_lease_by_ip(ip_address)
96+
result = send_command('dhcp4', 'lease4-get', {
97+
'ip-address' => ip_address
98+
})
99+
result['leases']&.first
100+
rescue => e
101+
logger.debug "Lease not found for #{ip_address}: #{e.message}"
102+
nil
103+
end
104+
105+
private
106+
107+
def http_post(path, payload)
108+
uri = URI.parse("#{@api_url}#{path}")
109+
110+
http = Net::HTTP.new(uri.host, uri.port)
111+
http.use_ssl = (uri.scheme == 'https')
112+
http.verify_mode = @verify_ssl ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
113+
114+
request = Net::HTTP::Post.new(uri.path, {'Content-Type' => 'application/json'})
115+
request.body = payload.to_json
116+
117+
if @username && @password
118+
request.basic_auth(@username, @password)
119+
end
120+
121+
logger.debug "HTTP POST to #{uri}"
122+
response = http.request(request)
123+
124+
unless response.is_a?(Net::HTTPSuccess)
125+
error_msg = "HTTP request failed: #{response.code} #{response.message}"
126+
logger.error error_msg
127+
raise error_msg
128+
end
129+
130+
JSON.parse(response.body)
131+
rescue JSON::ParserError => e
132+
error_msg = "Failed to parse KEA response: #{e.message}"
133+
logger.error error_msg
134+
raise error_msg
135+
end
136+
end
137+
end
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
module Proxy::DHCP::Kea
2+
class PluginConfiguration
3+
def load_classes
4+
require 'dhcp_common/server'
5+
require 'dhcp_common/subnet_service'
6+
require 'dhcp_common/free_ips'
7+
require 'dhcp_kea/kea_api_client'
8+
require 'dhcp_kea/dhcp_kea_main'
9+
end
10+
11+
def load_programmable_settings(settings)
12+
# Nothing to modify for KEA
13+
end
14+
15+
def load_dependency_injection_wirings(container_instance, settings)
16+
container_instance.dependency :memory_store, ::Proxy::MemoryStore
17+
18+
container_instance.singleton_dependency :kea_api_client, (lambda do
19+
::Proxy::DHCP::Kea::KeaApiClient.new(
20+
settings[:dhcp_kea_url],
21+
settings[:dhcp_kea_username],
22+
settings[:dhcp_kea_password],
23+
settings[:dhcp_kea_verify_ssl]
24+
)
25+
end)
26+
27+
container_instance.singleton_dependency :subnet_service, (lambda do
28+
::Proxy::DHCP::SubnetService.new(
29+
container_instance.get_dependency(:memory_store),
30+
container_instance.get_dependency(:memory_store),
31+
container_instance.get_dependency(:memory_store),
32+
container_instance.get_dependency(:memory_store),
33+
container_instance.get_dependency(:memory_store)
34+
)
35+
end)
36+
37+
container_instance.singleton_dependency :free_ips, (lambda do
38+
::Proxy::DHCP::FreeIps.new(
39+
container_instance.get_dependency(:subnet_service),
40+
container_instance.get_dependency(:memory_store),
41+
false
42+
)
43+
end)
44+
45+
container_instance.dependency :dhcp_provider, (lambda do
46+
::Proxy::DHCP::Kea::Provider.new(
47+
container_instance.get_dependency(:kea_api_client),
48+
container_instance.get_dependency(:subnet_service),
49+
container_instance.get_dependency(:free_ips),
50+
settings[:dhcp_kea_lease_timeout]
51+
)
52+
end)
53+
end
54+
end
55+
end

0 commit comments

Comments
 (0)