-
Notifications
You must be signed in to change notification settings - Fork 229
Fixes #39429 - Add KEA DHCP provider #949
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| 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' |
| 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) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. also, for this to work, we should have issued |
||
| 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 | ||
| 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 |
| 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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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