Skip to content

Commit f319ebd

Browse files
committed
Fixes #39429 - 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 Fix Rubocop failures Simplify KEA plugin config loading
1 parent 3e249dd commit f319ebd

13 files changed

Lines changed: 894 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: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
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+
#:use_provider: dhcp_kea
1012
#:server: 127.0.0.1
1113
# subnets restricts the subnets queried to a subset, to reduce the query time.
1214
#: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: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
require 'dhcp_common/dhcp_common'
2+
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: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
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_verify_ssl => true,
10+
:dhcp_kea_lease_timeout => 60
11+
12+
requires :dhcp, ::Proxy::VERSION
13+
14+
load_classes do
15+
require 'dhcp_common/server'
16+
require 'dhcp_common/subnet_service'
17+
require 'dhcp_common/free_ips'
18+
require 'dhcp_kea/kea_api_client'
19+
require 'dhcp_kea/dhcp_kea_main'
20+
end
21+
22+
load_dependency_injection_wirings do |container_instance, settings|
23+
container_instance.dependency :memory_store, ::Proxy::MemoryStore
24+
25+
container_instance.singleton_dependency :kea_api_client, (lambda do
26+
::Proxy::DHCP::Kea::KeaApiClient.new(
27+
settings[:dhcp_kea_url],
28+
settings[:dhcp_kea_username],
29+
settings[:dhcp_kea_password],
30+
verify_ssl: settings[:dhcp_kea_verify_ssl],
31+
timeout: settings[:dhcp_kea_lease_timeout]
32+
)
33+
end)
34+
35+
container_instance.singleton_dependency :subnet_service, (lambda do
36+
::Proxy::DHCP::SubnetService.new(
37+
container_instance.get_dependency(:memory_store),
38+
container_instance.get_dependency(:memory_store),
39+
container_instance.get_dependency(:memory_store),
40+
container_instance.get_dependency(:memory_store),
41+
container_instance.get_dependency(:memory_store)
42+
)
43+
end)
44+
45+
container_instance.singleton_dependency :free_ips, -> { ::Proxy::DHCP::FreeIps.new }
46+
47+
container_instance.dependency :dhcp_provider, (lambda do
48+
::Proxy::DHCP::Kea::Provider.new(
49+
container_instance.get_dependency(:kea_api_client),
50+
container_instance.get_dependency(:subnet_service),
51+
container_instance.get_dependency(:free_ips)
52+
)
53+
end)
54+
end
55+
end
56+
end

modules/dhcp_kea/kea_api_client.rb

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
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 config
43+
send_command('dhcp4', 'config-get')
44+
end
45+
46+
def list_subnets
47+
conf = config
48+
conf.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 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+
def lease_by_ip(ip_address)
95+
result = send_command('dhcp4', 'lease4-get', {
96+
'ip-address' => ip_address,
97+
})
98+
result['leases']&.first
99+
rescue => e
100+
logger.debug "Lease not found for #{ip_address}: #{e.message}"
101+
nil
102+
end
103+
104+
private
105+
106+
def http_post(path, payload)
107+
uri = URI.parse("#{@api_url}#{path}")
108+
109+
http = Net::HTTP.new(uri.host, uri.port)
110+
http.use_ssl = (uri.scheme == 'https')
111+
http.verify_mode = @verify_ssl ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
112+
113+
request = Net::HTTP::Post.new(uri.path, {'Content-Type' => 'application/json'})
114+
request.body = payload.to_json
115+
116+
if @username && @password
117+
request.basic_auth(@username, @password)
118+
end
119+
120+
logger.debug "HTTP POST to #{uri}"
121+
response = http.request(request)
122+
123+
unless response.is_a?(Net::HTTPSuccess)
124+
error_msg = "HTTP request failed: #{response.code} #{response.message}"
125+
logger.error error_msg
126+
raise error_msg
127+
end
128+
129+
JSON.parse(response.body)
130+
rescue JSON::ParserError => e
131+
error_msg = "Failed to parse KEA response: #{e.message}"
132+
logger.error error_msg
133+
raise error_msg
134+
end
135+
end
136+
end

0 commit comments

Comments
 (0)