Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
# Supported Modules
Currently Supported modules:
* BMC - BMC management of devices supported by freeipmi and ipmitool
* DHCP - ISC DHCP and MS DHCP Servers
* DHCP - ISC KEA, MS DHCP, and ISC DHCP (legacy, EOL 2022) Servers
* DNS - Bind and MS DNS Servers
* Puppet - Puppetserver 6 or 7
* Puppet CA - Manage certificate signing, cleaning and autosign on a Puppet CA server
Expand Down
6 changes: 4 additions & 2 deletions config/settings.d/dhcp.yml.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@
:enabled: false

# valid providers:
# - dhcp_isc (ISC dhcp server)
# - dhcp_kea (ISC KEA dhcp server)
# - dhcp_native_ms (Microsoft native implementation)
# - dhcp_libvirt
#:use_provider: dhcp_isc
# - dhcp_isc (ISC dhcp server - DEPRECATED)
#
#:use_provider: dhcp_kea
#:server: 127.0.0.1
# subnets restricts the subnets queried to a subset, to reduce the query time.
#:subnets: [192.168.205.0/255.255.255.128, 192.168.205.128/255.255.255.128]
Expand Down
16 changes: 16 additions & 0 deletions config/settings.d/dhcp_kea.yml.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
# KEA DHCP provider configuration
# Requires ISC KEA DHCP server with Control Agent API enabled

# URL of the KEA Control Agent
#:dhcp_kea_url: http://127.0.0.1:8000/

# Optional HTTP Basic Authentication credentials
#:dhcp_kea_username: ~
#:dhcp_kea_password: ~

# SSL certificate verification (set to false for self-signed certificates)
#:dhcp_kea_verify_ssl: true

# Timeout for lease operations in seconds
#:dhcp_kea_lease_timeout: 60

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Discussion: 60 seconds is quite generous, and I don't know how it will work with all those queue steps and JS actions in the create host UI form.

IMO, we should lower this to <30 seconds.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where is that used anyway?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

currently it is only passed into the KEA provider and stored as @lease_timeout, but it is not actually used in the lease/reservation operation calls yet.

soeither wire this into the KEA API client timeout handling, or remove the config option for now if we don’t think we need it here. that's my thought on this

2 changes: 2 additions & 0 deletions modules/dhcp_kea/dhcp_kea.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
require 'dhcp_common/dhcp_common'
require 'dhcp_kea/dhcp_kea_plugin'
131 changes: 131 additions & 0 deletions modules/dhcp_kea/dhcp_kea_main.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
require 'dhcp_common/server'

module Proxy::DHCP::Kea
class Provider < ::Proxy::DHCP::Server
include Proxy::Log

attr_reader :kea_client, :lease_timeout

def initialize(kea_client, subnet_service, free_ips_service, lease_timeout = 60)
@kea_client = kea_client
@lease_timeout = lease_timeout

super('kea-dhcp-server', nil, subnet_service, free_ips_service)

load_subnets
end

def load_subnets
logger.info "Loading subnets from KEA DHCP server"

begin
subnets = kea_client.list_subnets
rescue => e
logger.error "Failed to load subnets from KEA: #{e.message}"
raise Proxy::DHCP::Error, "Cannot connect to KEA DHCP server: #{e.message}"
end

if subnets.empty?
logger.warn "No subnets configured in KEA DHCP server"
return
end

subnets.each do |subnet_config|
network_cidr = subnet_config['subnet']
subnet_id = subnet_config['id']

logger.debug "Loading subnet: #{network_cidr} (KEA ID: #{subnet_id})"

# Extract network address and netmask from CIDR (e.g., "192.168.1.0/24" -> "192.168.1.0", "255.255.255.0")
network = network_cidr.split('/').first
netmask = netmask_from_cidr(network_cidr)

subnet = ::Proxy::DHCP::Subnet.new(network, netmask)

subnet.options[:kea_subnet_id] = subnet_id

service.add_subnet(subnet)

logger.debug "Added subnet #{network_cidr} with KEA ID #{subnet_id}"
end

logger.info "Loaded #{subnets.size} subnet(s) from KEA"
end

def add_record(options = {})
logger.debug "Adding DHCP reservation with options: #{options.inspect}"

record = super(options)

# The parent class already validated and set record.subnet
subnet = record.subnet

subnet_id = subnet.options[:kea_subnet_id]
unless subnet_id
raise Proxy::DHCP::Error, "KEA subnet ID not found for #{subnet.network}"
end

kea_options = {}
kea_options[:next_server] = record.nextServer if record.nextServer
kea_options[:boot_file_name] = record.filename if record.filename

begin
kea_client.add_reservation(
subnet_id,
record.ip,
record.mac,
record.name,
kea_options
)
rescue => e
logger.error "Failed to create KEA reservation: #{e.message}"
raise Proxy::DHCP::Error, "Failed to create reservation in KEA: #{e.message}"
end

service.add_host(subnet.network, record)

logger.info "Successfully created KEA DHCP reservation: #{record.ip} for #{record.mac}"
record
end

def del_record(record)
logger.debug "Deleting DHCP record: #{record.inspect}"

# Record already has the subnet object
subnet = record.subnet

subnet_id = subnet.options[:kea_subnet_id]
unless subnet_id
raise Proxy::DHCP::Error, "KEA subnet ID not found for #{subnet.network}"
end

begin
kea_client.delete_reservation_by_ip(subnet_id, record.ip)
rescue => e
logger.error "Failed to delete KEA reservation: #{e.message}"
raise Proxy::DHCP::Error, "Failed to delete reservation from KEA: #{e.message}"
end

if record.is_a?(::Proxy::DHCP::Reservation)
service.delete_host(record)
elsif record.is_a?(::Proxy::DHCP::Lease)
service.delete_lease(subnet.network, record)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

delete_lease also expects just one parameter, seems there is no test for this case.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also, for this to work, we should have issued lease4-del, not reservation-del as done by delete_reservation_by_ip, right?

end

logger.info "Successfully deleted KEA DHCP reservation: #{record.ip}"
end

def load_subnet_options(subnet)
logger.debug "Loading subnet options for #{subnet.network}"
end

private

def netmask_from_cidr(cidr)
prefix = cidr.split('/').last.to_i

mask = (0xffffffff << (32 - prefix)) & 0xffffffff
[mask].pack('N').unpack('C4').join('.')
end
end
end
56 changes: 56 additions & 0 deletions modules/dhcp_kea/dhcp_kea_plugin.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
module Proxy::DHCP::Kea
class Plugin < ::Proxy::Provider
plugin :dhcp_kea, ::Proxy::VERSION

capability 'dhcp_filename_ipv4'
capability 'dhcp_filename_hostname'

default_settings :dhcp_kea_url => 'http://127.0.0.1:8000/',
:dhcp_kea_verify_ssl => true,
:dhcp_kea_lease_timeout => 60

requires :dhcp, ::Proxy::VERSION

load_classes do
require 'dhcp_common/server'
require 'dhcp_common/subnet_service'
require 'dhcp_common/free_ips'
require 'dhcp_kea/kea_api_client'
require 'dhcp_kea/dhcp_kea_main'
end

load_dependency_injection_wirings do |container_instance, settings|
container_instance.dependency :memory_store, ::Proxy::MemoryStore

container_instance.singleton_dependency :kea_api_client, (lambda do
::Proxy::DHCP::Kea::KeaApiClient.new(
settings[:dhcp_kea_url],
settings[:dhcp_kea_username],
settings[:dhcp_kea_password],
verify_ssl: settings[:dhcp_kea_verify_ssl]
)
end)

container_instance.singleton_dependency :subnet_service, (lambda do
::Proxy::DHCP::SubnetService.new(
container_instance.get_dependency(:memory_store),
container_instance.get_dependency(:memory_store),
container_instance.get_dependency(:memory_store),
container_instance.get_dependency(:memory_store),
container_instance.get_dependency(:memory_store)
)
end)

container_instance.singleton_dependency :free_ips, -> { ::Proxy::DHCP::FreeIps.new }

container_instance.dependency :dhcp_provider, (lambda do
::Proxy::DHCP::Kea::Provider.new(
container_instance.get_dependency(:kea_api_client),
container_instance.get_dependency(:subnet_service),
container_instance.get_dependency(:free_ips),
settings[:dhcp_kea_lease_timeout]
)
end)
end
end
end
136 changes: 136 additions & 0 deletions modules/dhcp_kea/kea_api_client.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
require 'net/http'
require 'uri'
require 'json'

module Proxy::DHCP::Kea
class KeaApiClient
include Proxy::Log

attr_reader :api_url, :username, :password, :verify_ssl

def initialize(api_url, username = nil, password = nil, verify_ssl: true)
@api_url = api_url.chomp('/')
@username = username
@password = password
@verify_ssl = verify_ssl
end

def send_command(service, command, arguments = {})
payload = {
'command' => command,
'service' => [service],
'arguments' => arguments,
}

logger.debug "Sending KEA command: #{command} to service: #{service}"
logger.debug "Arguments: #{arguments.inspect}"

response = http_post('/', payload)

result = response.first

if result['result'] != 0
error_msg = "KEA command '#{command}' failed: #{result['text']}"
logger.error error_msg
raise error_msg
end

logger.debug "KEA command successful: #{result['text']}"
result['arguments'] || {}
end

def config
send_command('dhcp4', 'config-get')
end

def list_subnets
conf = config
conf.dig('Dhcp4', 'subnet4') || []
end

def add_reservation(subnet_id, ip_address, hw_address, hostname = nil, options = {})
reservation = {
'subnet-id' => subnet_id.to_i,
'ip-address' => ip_address,
'hw-address' => hw_address,
}

reservation['hostname'] = hostname if hostname

if options[:next_server]
reservation['next-server'] = options[:next_server]
end

if options[:boot_file_name]
reservation['boot-file-name'] = options[:boot_file_name]
end

logger.info "Adding KEA reservation: #{ip_address} for #{hw_address} in subnet #{subnet_id}"
send_command('dhcp4', 'reservation-add', reservation)
end

def delete_reservation_by_ip(subnet_id, ip_address)
logger.info "Deleting KEA reservation: #{ip_address} from subnet #{subnet_id}"
send_command('dhcp4', 'reservation-del', {
'subnet-id' => subnet_id.to_i,
'ip-address' => ip_address,
})
end

def reservation_by_ip(subnet_id, ip_address)
send_command('dhcp4', 'reservation-get', {
'subnet-id' => subnet_id.to_i,
'ip-address' => ip_address,
})
rescue => e
logger.debug "Reservation not found for #{ip_address}: #{e.message}"
nil
end

def list_leases
send_command('dhcp4', 'lease4-get-all')
end

def lease_by_ip(ip_address)
result = send_command('dhcp4', 'lease4-get', {
'ip-address' => ip_address,
})
result['leases']&.first
rescue => e
logger.debug "Lease not found for #{ip_address}: #{e.message}"
nil
end

private

def http_post(path, payload)
uri = URI.parse("#{@api_url}#{path}")

http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = (uri.scheme == 'https')
http.verify_mode = @verify_ssl ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE

request = Net::HTTP::Post.new(uri.path, {'Content-Type' => 'application/json'})
request.body = payload.to_json

if @username && @password
request.basic_auth(@username, @password)
end

logger.debug "HTTP POST to #{uri}"
response = http.request(request)

unless response.is_a?(Net::HTTPSuccess)
error_msg = "HTTP request failed: #{response.code} #{response.message}"
logger.error error_msg
raise error_msg
end

JSON.parse(response.body)
rescue JSON::ParserError => e
error_msg = "Failed to parse KEA response: #{e.message}"
logger.error error_msg
raise error_msg
end
end
end
Loading
Loading