Skip to content
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

Fixes #34205 - External IPAM Integration #810

Open
wants to merge 1 commit into
base: develop
Choose a base branch
from
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
1 change: 1 addition & 0 deletions Contributors
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Ashley Penney <[email protected]>
Baptiste Agasse <[email protected]>
Brandon Weeks <[email protected]>
Christian Arnold <[email protected]>
Christopher Smith <[email protected]>
Corey Osman <[email protected]>
Daniel Baeurer <[email protected]>
Daniel Helgenberger <[email protected]>
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Currently Supported modules:
* HTTPBoot - endpoint exposing a (TFTP) directory via HTTP(s) for UEFI HTTP booting
* Logs - log buffer of proxy logs for easier troubleshooting
* Templates - unattended Foreman endpoint proxy
* External IPAM - Integration with External IPAM providers

# Installation
Read the [Smart Proxy Installation section](https://theforeman.org/manuals/latest/index.html#4.3.1SmartProxyInstallation) of the manual.
Expand Down
8 changes: 8 additions & 0 deletions config/settings.d/externalipam.yml.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
:enabled: false

# Built-in providers:
# 1. phpIPAM: externalipam_phpipam
# 2. Netbox: externalipam_netbox

:use_provider: externalipam_netbox
Copy link
Member

Choose a reason for hiding this comment

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

Note for myself / @ekohl - I am fine with bringing this feature into core, after all External IPAM is already in Foreman core, however, I would suggest to ship providers as RPM/DEB subpackages so products owners can decide what to ship and what not. Opinion?

Copy link
Member

Choose a reason for hiding this comment

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

It does not introduce additional dependencies and the Smart Proxy is already quite smart in not loading the module if it's inactive so I'd just ship it in the same RPM/deb. Subpackages only make sense to reduce additional dependencies or reducing runtime overhead at the cost of more complex installation. In this case I think it's not worth it but I appreciate the consideration.

Copy link
Member

Choose a reason for hiding this comment

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

Okay let me rephrase it this way - I do not want to support this in Red Hat Satellite at this moment. Perhaps we can disable it via the installer scenario?

Copy link
Member

Choose a reason for hiding this comment

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

Currently we don't have good facilities for that. It would need some thought.

Copy link
Member

Choose a reason for hiding this comment

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

Ok this is an internal discussion, I do not want block this effort.

6 changes: 6 additions & 0 deletions config/settings.d/externalipam_netbox.yml.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
# url is the hostname and path of the Netbox instance
:url: 'https://netbox.example.com'

# token is the Netbox API token
:token: 'netbox_token'
12 changes: 12 additions & 0 deletions config/settings.d/externalipam_phpipam.yml.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
# url is the hostname and path of the phpIPAM instance.
:url: 'https://phpipam.example.com'

# The phpIPAM user name for authentication. Please note that an API Key also needs to be
# setup with the exact same name as the user name configured here. When setting up the API
# Key in phpIPAM, "User token" must be used for the "App Security" setting.
:user: 'ipam_user'

# The password for above user account. Note that this is the password of the user, and not
# the API Key itself.
:password: 'ipam_password'
19 changes: 19 additions & 0 deletions lib/proxy/validations.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ class Error < RuntimeError; end
class InvalidIPAddress < Error; end
class InvalidMACAddress < Error; end
class InvalidSubnet < Error; end
class InvalidCidr < Error; end
class IpNotInCidr < Error; end

private

Expand Down Expand Up @@ -61,6 +63,23 @@ def validate_subnet(subnet)
subnet
end

def validate_cidr(address, prefix)
cidr = "#{address}/#{prefix}"
network = IPAddr.new(cidr)
if network != IPAddr.new(address)
raise InvalidCidr, "Network address #{address} should be #{network} with prefix #{prefix}"
end
cidr
rescue IPAddr::Error => e
raise Proxy::Validations::Error, e.to_s
end

def validate_ip_in_cidr(ip, cidr)
raise IpNotInCidr, "IP #{ip} is not in #{cidr}" unless IPAddr.new(cidr).include?(IPAddr.new(ip))
rescue IPAddr::Error => e
raise Proxy::Validations::Error, e.to_s
end

def validate_server(server)
raise Proxy::DHCP::Error, "Invalid Server #{server}" unless server.is_a?(Proxy::DHCP::Server)
server
Expand Down
1 change: 1 addition & 0 deletions lib/smart_proxy_main.rb
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ module Proxy
require 'dhcp_isc/dhcp_isc'
require 'dhcp_native_ms/dhcp_native_ms'
require 'dhcp_libvirt/dhcp_libvirt'
require 'externalipam/externalipam'
require 'puppetca/puppetca'
require 'puppetca_http_api/puppetca_http_api'
require 'puppetca_hostname_whitelisting/puppetca_hostname_whitelisting'
Expand Down
55 changes: 55 additions & 0 deletions modules/externalipam/api_resource.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
require 'yaml'
require 'json'
require 'net/http'
require 'uri'
require 'externalipam/ipam_helper'

module Proxy::Ipam
# Class to handle authentication and HTTP transactions with External IPAM providers
class ApiResource
grizzthedj marked this conversation as resolved.
Show resolved Hide resolved
include ::Proxy::Log
include Proxy::Ipam::IpamHelper

def initialize(params = {})
@api_base = params[:api_base]
@token = params[:token]
@auth_header = params[:auth_header] || 'Authorization'
end

def get(path, params = nil)
url = @api_base + path
url += "?#{URI.encode_www_form(params)}" if params
uri = URI(url)
request = Net::HTTP::Get.new(uri)
request[@auth_header] = @token
request['Accept'] = 'application/json'
request(request, uri)
end

def delete(path)
uri = URI(@api_base + path)
request = Net::HTTP::Delete.new(uri)
request[@auth_header] = @token
request['Accept'] = 'application/json'
request(request, uri)
end

def post(path, body = nil)
uri = URI(@api_base + path)
request = Net::HTTP::Post.new(uri)
request.body = body
grizzthedj marked this conversation as resolved.
Show resolved Hide resolved
request[@auth_header] = @token
request['Accept'] = 'application/json'
request['Content-Type'] = 'application/json'
request(request, uri)
end

private

def request(request, uri)
Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
http.request(request)
end
end
end
end
8 changes: 8 additions & 0 deletions modules/externalipam/configuration_loader.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
module ::Proxy::Ipam
class ConfigurationLoader
def load_classes
require 'externalipam/dependency_injection'
require 'externalipam/ipam_api'
end
end
end
8 changes: 8 additions & 0 deletions modules/externalipam/dependency_injection.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
module Proxy::Ipam
module DependencyInjection
include Proxy::DependencyInjection::Accessors
def container_instance
@container_instance ||= ::Proxy::Plugins.instance.find { |p| p[:name] == :externalipam }[:di_container]
end
end
end
2 changes: 2 additions & 0 deletions modules/externalipam/externalipam.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
require 'externalipam/externalipam_plugin'
require 'externalipam/configuration_loader'
11 changes: 11 additions & 0 deletions modules/externalipam/externalipam_plugin.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
require 'externalipam/phpipam/phpipam_plugin'
require 'externalipam/netbox/netbox_plugin'

module Proxy::Ipam
class Plugin < ::Proxy::Plugin
plugin :externalipam, ::Proxy::VERSION
uses_provider
default_settings use_provider: nil
rackup_path File.expand_path('http_config.ru', __dir__)
end
end
5 changes: 5 additions & 0 deletions modules/externalipam/http_config.ru
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
require 'externalipam/ipam_api'

map '/ipam' do
run Proxy::Ipam::Api
end
133 changes: 133 additions & 0 deletions modules/externalipam/ip_cache.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
require 'yaml'
require 'json'
require 'monitor'
require 'concurrent'
require 'time'
require 'externalipam/ipam_helper'
require 'singleton'

module Proxy::Ipam
# Class for managing temp in-memory cache to prevent same IP's being suggested in race conditions
class IpCache
include Singleton
include Proxy::Log
include Proxy::Ipam::IpamHelper

DEFAULT_CLEANUP_INTERVAL = 180

def initialize
@m = Monitor.new
@ip_cache = {'': {}}
start_cleanup_task
end

def provider_name(provider)
@provider = provider
end

def get_cidr(group_name, cidr)
@ip_cache.dig(group_name, cidr)
end

def get_ip(group_name, cidr, mac)
@ip_cache.dig(group_name, cidr, mac, :ip)
end

def ip_exists?(group_name, cidr, ip)
subnet_hash = get_cidr(group_name, cidr)
return false if subnet_hash.nil?
subnet_hash&.any? { |mac, cached_ip| cached_ip[:ip] == ip }
end

def ip_expired?(group_name, cidr, ip)
return true unless ip_exists?(group_name, cidr, ip)
subnet_hash = get_cidr(group_name, cidr)
subnet_hash&.any? { |mac, cached_ip| cached_ip[:ip] == ip && expired(cached_ip[:timestamp]) }
end

def cleanup_interval
DEFAULT_CLEANUP_INTERVAL
end

def add(group_name, cidr, ip, mac = nil)
logger.debug("Adding IP '#{ip}' to cache for subnet '#{cidr}' in group '#{group_name}' for IPAM provider #{@provider}")
@m.synchronize do
mac_addr = mac.nil? || mac.empty? ? SecureRandom.uuid : mac
grizzthedj marked this conversation as resolved.
Show resolved Hide resolved
group_hash = @ip_cache[group_name]

if group_hash&.key?(cidr)
@ip_cache[group_name][cidr][mac_addr] = { ip: ip.to_s, timestamp: Time.now }
else
@ip_cache[group_name] = { cidr => { mac_addr => { ip: ip.to_s, timestamp: Time.now }}}
end
end
end

private

def expired(ip_expiration)
Time.now - ip_expiration > DEFAULT_CLEANUP_INTERVAL
end

def start_cleanup_task
logger.info("Starting ip cache maintenance for External IPAM provider, used by /next_ip.")
@timer_task = Concurrent::TimerTask.new(execution_interval: DEFAULT_CLEANUP_INTERVAL) { clean_cache }
@timer_task.execute
end

# @ip_cache structure
#
# Groups of subnets are cached under the External IPAM Group name. For example,
# "IPAM Group Name" would be the section name in phpIPAM. All IP's cached for subnets
# that do not have an External IPAM group specified, they are cached under the "" key. IP's
# are cached using one of two possible keys:
# 1). Mac Address
# 2). UUID (Used when Mac Address not specified)
#
# {
# "": {
# "192.0.2.0/24":{
# "00:0a:95:9d:68:10": {"ip": "192.0.2.1", "timestamp": "2019-09-17 12:03:43 -D400"},
# "906d8bdc-dcc0-4b59-92cb-665935e21662": {"ip": "192.0.2.2", "timestamp": "2019-09-17 11:43:22 -D400"}
# },
# },
# "IPAM Group Name": {
# "198.51.100.0/24":{
# "00:0a:95:9d:68:33": {"ip": "198.51.100.1", "timestamp": "2019-09-17 12:04:43 -0400"},
# "00:0a:95:9d:68:34": {"ip": "198.51.100.2", "timestamp": "2019-09-17 12:05:48 -0400"},
# "00:0a:95:9d:68:35": {"ip": "198.51.100.3", "timestamp:: "2019-09-17 12:06:50 -0400"}
# }
# },
# "Another IPAM Group": {
# "203.0.113.0/24":{
# "00:0a:95:9d:68:55": {"ip": "203.0.113.1", "timestamp": "2019-09-17 12:04:43 -0400"},
# "00:0a:95:9d:68:56": {"ip": "203.0.113.2", "timestamp": "2019-09-17 12:05:48 -0400"}
# }
# }
# }
def clean_cache
@m.synchronize do
entries_deleted = 0
total_entries = 0

@ip_cache.each do |group, subnets|
subnets.each do |cidr, macs|
macs.each do |mac, ip|
if expired(ip[:timestamp])
@ip_cache[group][cidr].delete(mac)
entries_deleted += 1
end
total_entries += 1
end
@ip_cache[group].delete(cidr) if @ip_cache[group][cidr].nil? || @ip_cache[group][cidr].empty?
@ip_cache.delete(group) if @ip_cache[group].nil? || @ip_cache[group].empty?
end
end

cache_count = total_entries - entries_deleted
logger.debug("Removing #{entries_deleted} entries from IP cache for IPAM provider #{@provider}") if entries_deleted > 0
logger.debug("Current count of IP cache entries for IPAM provider #{@provider}: #{cache_count}") if entries_deleted > 0
end
end
end
end
Loading