From d60bbbebd3cd40564cf1f2835b462da955900651 Mon Sep 17 00:00:00 2001 From: Christopher Smith Date: Mon, 3 Jan 2022 10:12:25 -0500 Subject: [PATCH] Fixes #34205 - External IPAM Integration --- Contributors | 1 + README.md | 1 + config/settings.d/externalipam.yml.example | 8 + .../externalipam_netbox.yml.example | 6 + .../externalipam_phpipam.yml.example | 12 + lib/proxy/validations.rb | 19 + lib/smart_proxy_main.rb | 1 + modules/externalipam/api_resource.rb | 55 +++ modules/externalipam/configuration_loader.rb | 8 + modules/externalipam/dependency_injection.rb | 8 + modules/externalipam/externalipam.rb | 2 + modules/externalipam/externalipam_plugin.rb | 11 + modules/externalipam/http_config.ru | 5 + modules/externalipam/ip_cache.rb | 133 +++++++ modules/externalipam/ipam_api.rb | 358 ++++++++++++++++++ modules/externalipam/ipam_helper.rb | 113 ++++++ modules/externalipam/ipam_validator.rb | 36 ++ modules/externalipam/netbox/netbox_client.rb | 158 ++++++++ modules/externalipam/netbox/netbox_plugin.rb | 12 + .../netbox/netbox_plugin_configuration.rb | 11 + .../externalipam/phpipam/phpipam_client.rb | 161 ++++++++ .../externalipam/phpipam/phpipam_plugin.rb | 12 + .../phpipam/phpipam_plugin_configuration.rb | 11 + test/externalipam/externalipam_api_test.rb | 273 +++++++++++++ 24 files changed, 1415 insertions(+) create mode 100644 config/settings.d/externalipam.yml.example create mode 100644 config/settings.d/externalipam_netbox.yml.example create mode 100644 config/settings.d/externalipam_phpipam.yml.example create mode 100644 modules/externalipam/api_resource.rb create mode 100644 modules/externalipam/configuration_loader.rb create mode 100644 modules/externalipam/dependency_injection.rb create mode 100644 modules/externalipam/externalipam.rb create mode 100644 modules/externalipam/externalipam_plugin.rb create mode 100644 modules/externalipam/http_config.ru create mode 100644 modules/externalipam/ip_cache.rb create mode 100644 modules/externalipam/ipam_api.rb create mode 100644 modules/externalipam/ipam_helper.rb create mode 100644 modules/externalipam/ipam_validator.rb create mode 100644 modules/externalipam/netbox/netbox_client.rb create mode 100644 modules/externalipam/netbox/netbox_plugin.rb create mode 100644 modules/externalipam/netbox/netbox_plugin_configuration.rb create mode 100644 modules/externalipam/phpipam/phpipam_client.rb create mode 100644 modules/externalipam/phpipam/phpipam_plugin.rb create mode 100644 modules/externalipam/phpipam/phpipam_plugin_configuration.rb create mode 100644 test/externalipam/externalipam_api_test.rb diff --git a/Contributors b/Contributors index 07a9ba820..1b2768598 100644 --- a/Contributors +++ b/Contributors @@ -10,6 +10,7 @@ Ashley Penney Baptiste Agasse Brandon Weeks Christian Arnold +Christopher Smith Corey Osman Daniel Baeurer Daniel Helgenberger diff --git a/README.md b/README.md index 5f31edd73..7217bf716 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/config/settings.d/externalipam.yml.example b/config/settings.d/externalipam.yml.example new file mode 100644 index 000000000..6570fc843 --- /dev/null +++ b/config/settings.d/externalipam.yml.example @@ -0,0 +1,8 @@ +--- +:enabled: false + +# Built-in providers: +# 1. phpIPAM: externalipam_phpipam +# 2. Netbox: externalipam_netbox + +:use_provider: externalipam_netbox diff --git a/config/settings.d/externalipam_netbox.yml.example b/config/settings.d/externalipam_netbox.yml.example new file mode 100644 index 000000000..4733318d3 --- /dev/null +++ b/config/settings.d/externalipam_netbox.yml.example @@ -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' diff --git a/config/settings.d/externalipam_phpipam.yml.example b/config/settings.d/externalipam_phpipam.yml.example new file mode 100644 index 000000000..f1ec4d8c6 --- /dev/null +++ b/config/settings.d/externalipam_phpipam.yml.example @@ -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' diff --git a/lib/proxy/validations.rb b/lib/proxy/validations.rb index b90a60a9d..c890c1f96 100644 --- a/lib/proxy/validations.rb +++ b/lib/proxy/validations.rb @@ -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 @@ -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 diff --git a/lib/smart_proxy_main.rb b/lib/smart_proxy_main.rb index 0ebc22b2c..0a6e79c52 100644 --- a/lib/smart_proxy_main.rb +++ b/lib/smart_proxy_main.rb @@ -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' diff --git a/modules/externalipam/api_resource.rb b/modules/externalipam/api_resource.rb new file mode 100644 index 000000000..f0d03d2a9 --- /dev/null +++ b/modules/externalipam/api_resource.rb @@ -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 + 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 + 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 diff --git a/modules/externalipam/configuration_loader.rb b/modules/externalipam/configuration_loader.rb new file mode 100644 index 000000000..cc86dd13a --- /dev/null +++ b/modules/externalipam/configuration_loader.rb @@ -0,0 +1,8 @@ +module ::Proxy::Ipam + class ConfigurationLoader + def load_classes + require 'externalipam/dependency_injection' + require 'externalipam/ipam_api' + end + end +end diff --git a/modules/externalipam/dependency_injection.rb b/modules/externalipam/dependency_injection.rb new file mode 100644 index 000000000..cc3078900 --- /dev/null +++ b/modules/externalipam/dependency_injection.rb @@ -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 diff --git a/modules/externalipam/externalipam.rb b/modules/externalipam/externalipam.rb new file mode 100644 index 000000000..ade1bce74 --- /dev/null +++ b/modules/externalipam/externalipam.rb @@ -0,0 +1,2 @@ +require 'externalipam/externalipam_plugin' +require 'externalipam/configuration_loader' diff --git a/modules/externalipam/externalipam_plugin.rb b/modules/externalipam/externalipam_plugin.rb new file mode 100644 index 000000000..497a4e42e --- /dev/null +++ b/modules/externalipam/externalipam_plugin.rb @@ -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 diff --git a/modules/externalipam/http_config.ru b/modules/externalipam/http_config.ru new file mode 100644 index 000000000..7c13f5921 --- /dev/null +++ b/modules/externalipam/http_config.ru @@ -0,0 +1,5 @@ +require 'externalipam/ipam_api' + +map '/ipam' do + run Proxy::Ipam::Api +end diff --git a/modules/externalipam/ip_cache.rb b/modules/externalipam/ip_cache.rb new file mode 100644 index 000000000..a945ca48b --- /dev/null +++ b/modules/externalipam/ip_cache.rb @@ -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 + 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 diff --git a/modules/externalipam/ipam_api.rb b/modules/externalipam/ipam_api.rb new file mode 100644 index 000000000..cb6476291 --- /dev/null +++ b/modules/externalipam/ipam_api.rb @@ -0,0 +1,358 @@ +require 'proxy/validations' +require 'externalipam/ipam_helper' +require 'externalipam/ipam_validator' +require 'externalipam/dependency_injection' + +module Proxy::Ipam + # Generic API for External IPAM interactions + class Api < ::Sinatra::Base + extend Proxy::Ipam::DependencyInjection + include ::Proxy::Log + include ::Proxy::Validations + helpers ::Proxy::Helpers + include Proxy::Ipam::IpamHelper + include Proxy::Ipam::IpamValidator + + inject_attr :externalipam_client, :client + + # Gets the next available IP address based on a given External IPAM subnet + # + # Inputs: 1. address: Network address of the subnet(e.g. 100.55.55.0) + # 2. prefix: Network prefix(e.g. 24) + # 3. group(optional): The External IPAM group + # + # Returns: + # Response if success: + # ====================== + # Http Code: 200 + # JSON Response: + # "100.55.55.3" + # + # Response if missing parameter(e.g. 'mac') + # ====================== + # Http Code: 400 + # JSON Response: + # {"error": "A 'mac' address must be provided(e.g. 00:0a:95:9d:68:10)"} + # + # Response if no free ip's available + # ====================== + # Http Code: 404 + # JSON Response: + # {"error": "There are no free IP's in subnet 100.55.55.0/24"} + get '/subnet/:address/:prefix/next_ip' do + content_type :json + + validate_required_params!([:address, :prefix], params) + mac_param = params[:mac] + mac = validate_mac(params[:mac]) unless mac_param.nil? || mac_param.empty? + cidr = validate_cidr(params[:address], params[:prefix]) + group_name = get_request_group(params) + next_ip = provider.get_next_ip(mac, cidr, group_name) + halt 404, { error: ERRORS[:no_free_ips] }.to_json if next_ip.nil? + { data: next_ip }.to_json + rescue Proxy::Validations::Error => e + logger.exception(ERRORS[:proxy_validation_error], e) + halt 400, { error: e.to_s }.to_json + rescue RuntimeError => e + logger.exception(ERRORS[:runtime_error], e) + halt 500, { error: e.to_s }.to_json + rescue Errno::ECONNREFUSED, Errno::ECONNRESET + logger.exception(ERRORS[:no_connection], e) + halt 500, { error: ERRORS[:no_connection] }.to_json + end + + # Gets the subnet from External IPAM + # + # Inputs: 1. address: Network address of the subnet + # 2. prefix: Network prefix(e.g. 24) + # 3. group(optional): The name of the External IPAM group + # + # Returns: + # Response if subnet exists: + # =========================== + # Http Code: 200 + # JSON Response: + # {"data": { + # "id": "33", + # "subnet": "10.20.30.0", + # "description": "Subnet description", + # "mask": "29"} + # } + # + # Response if subnet does not exist: + # =========================== + # Http Code: 404 + # JSON Response: + # {"error": "No subnets found"} + get '/subnet/:address/:prefix' do + content_type :json + + validate_required_params!([:address, :prefix], params) + cidr = validate_cidr(params[:address], params[:prefix]) + group_name = get_request_group(params) + subnet = provider.get_ipam_subnet(cidr, group_name) + halt 404, { error: ERRORS[:no_subnet] }.to_json if subnet.nil? + subnet.to_json + rescue Proxy::Validations::Error => e + logger.exception(ERRORS[:proxy_validation_error], e) + halt 400, { error: e.to_s }.to_json + rescue RuntimeError => e + logger.exception(ERRORS[:runtime_error], e) + halt 500, { error: e.to_s }.to_json + rescue Errno::ECONNREFUSED, Errno::ECONNRESET + logger.exception(ERRORS[:no_connection], e) + halt 500, { error: ERRORS[:no_connection] }.to_json + end + + # Get a list of groups from External IPAM + # + # Returns: + # Response if success: + # =========================== + # Http Code: 200 + # JSON Response: + # {"data": [ + # {"id": "1", "name": "Group 1", "description": "This is group 1"}, + # {"id": "2", "name": "Group 2", "description": "This is group 2"} + # ]} + # + # Response if no groups exist: + # =========================== + # Http Code: 404 + # JSON Response: + # {"error": "Groups are not supported"} + # + # Response if groups are not supported: + # =========================== + # Http Code: 422 + # JSON Response: + # {"error": "Groups are not supported"} + get '/groups' do + content_type :json + + halt 422, { error: ERRORS[:groups_not_supported] }.to_json unless provider.groups_supported? + groups = provider.ipam_groups + groups.to_json + rescue Proxy::Validations::Error => e + logger.exception(ERRORS[:proxy_validation_error], e) + halt 400, { error: e.to_s }.to_json + rescue RuntimeError => e + logger.exception(ERRORS[:runtime_error], e) + halt 500, { error: e.to_s }.to_json + rescue Errno::ECONNREFUSED, Errno::ECONNRESET + logger.exception(ERRORS[:no_connection], e) + halt 500, { error: ERRORS[:no_connection] }.to_json + end + + # Get a group from External IPAM + # + # Inputs: 1. group: The name of the External IPAM group + # + # Returns: + # Response if success: + # =========================== + # Http Code: 200 + # JSON Response: + # {"data": {"id": "1", "name": "Group 1", "description": "This is group 1"}} + # + # Response if group not found: + # =========================== + # Http Code: 404 + # JSON Response: + # {"error": "Group not Found"} + # + # Response if groups are not supported: + # =========================== + # Http Code: 500 + # JSON Response: + # {"error": "Groups are not supported"} + get '/groups/:group' do + content_type :json + + validate_required_params!([:group], params) + group_name = get_request_group(params) + group = provider.get_ipam_group(group_name) + halt 404, { error: ERRORS[:no_group] }.to_json if group.nil? + group.to_json + rescue Proxy::Validations::Error => e + logger.exception(ERRORS[:proxy_validation_error], e) + halt 400, { error: e.to_s }.to_json + rescue RuntimeError => e + logger.exception(ERRORS[:runtime_error], e) + halt 500, { error: e.to_s }.to_json + rescue Errno::ECONNREFUSED, Errno::ECONNRESET + logger.exception(ERRORS[:no_connection], e) + halt 500, { error: ERRORS[:no_connection] }.to_json + end + + # Get a list of subnets for a given External IPAM group + # + # Input: 1. group: The name of the External IPAM group + # + # Returns: + # Response if success: + # =========================== + # Http Code: 200 + # JSON Response: + # {"data":[ + # {"subnet":"10.20.30.0","mask":"29","description":"This is a subnet"}, + # {"subnet":"40.50.60.0","mask":"29","description":"This is another subnet"} + # ]} + # + # Response if no subnets exist in group: + # =========================== + # Http Code: 404 + # JSON Response: + # {"error": "No subnets found in External IPAM group"} + # + # Response if groups are not supported: + # =========================== + # Http Code: 500 + # JSON Response: + # {"error": "Groups are not supported"} + get '/groups/:group/subnets' do + content_type :json + + validate_required_params!([:group], params) + group_name = get_request_group(params) + subnets = provider.get_ipam_subnets(group_name) + halt 404, { error: ERRORS[:no_subnets_in_group] }.to_json if subnets == [] + subnets.to_json + rescue Proxy::Validations::Error => e + logger.exception(ERRORS[:proxy_validation_error], e) + halt 400, { error: e.to_s }.to_json + rescue RuntimeError => e + logger.exception(ERRORS[:runtime_error], e) + halt 500, { error: e.to_s }.to_json + rescue Errno::ECONNREFUSED, Errno::ECONNRESET + logger.exception(ERRORS[:no_connection], e) + halt 500, { error: ERRORS[:no_connection] }.to_json + end + + # Checks whether an IP address has already been taken in External IPAM + # + # Inputs: 1. address: The network address of the IPv4 or IPv6 subnet. + # 2. prefix: The subnet prefix(e.g. 24) + # 3. ip: IP address to be queried + # 4. group(optional): The name of the External IPAM Group, containing the subnet to check + # + # Returns: + # Response if exists: + # =========================== + # Http Code: 200 + # Response: true + # + # Response if not exists: + # =========================== + # Http Code: 200 + # JSON Response: false + get '/subnet/:address/:prefix/:ip' do + content_type :json + + validate_required_params!([:address, :prefix, :ip], params) + ip = validate_ip(params[:ip]) + cidr = validate_cidr(params[:address], params[:prefix]) + group_name = get_request_group(params) + subnet = provider.get_ipam_subnet(cidr, group_name) + halt 404, { error: ERRORS[:no_subnet] }.to_json if subnet.nil? + validate_ip_in_cidr(ip, cidr) + ip_exists = provider.ip_exists?(ip, subnet[:id], group_name) + halt 200, { result: ip_exists }.to_json + rescue Proxy::Validations::Error => e + logger.exception(ERRORS[:proxy_validation_error], e) + halt 400, { error: e.to_s }.to_json + rescue RuntimeError => e + logger.exception(ERRORS[:runtime_error], e) + halt 500, { error: e.to_s }.to_json + rescue Errno::ECONNREFUSED, Errno::ECONNRESET + logger.exception(ERRORS[:no_connection], e) + halt 500, { error: ERRORS[:no_connection] }.to_json + end + + # Adds an IP address to the specified subnet for the specified IPAM provider + # + # Params: 1. address: The network address of the IPv4 or IPv6 subnet + # 2. prefix: The subnet prefix(e.g. 24) + # 3. ip: IP address to be added + # 4. group(optional): The name of the External IPAM Group, containing the subnet to add ip to + # + # Returns: + # Response if added successfully: + # =========================== + # Http Code: 201 + # Response: Empty + # + # Response if not added successfully: + # =========================== + # Http Code: 500 + # JSON Response: + # {"error": "Unable to add IP to External IPAM"} + post '/subnet/:address/:prefix/:ip' do + content_type :json + + validate_required_params!([:address, :ip, :prefix], params) + ip = validate_ip(params[:ip]) + cidr = validate_cidr(params[:address], params[:prefix]) + group_name = get_request_group(params) + subnet = provider.get_ipam_subnet(cidr, group_name) + halt 404, { error: ERRORS[:no_subnet] }.to_json if subnet.nil? + add_ip_params = { cidr: cidr, subnet_id: subnet[:id], group_name: group_name } + validate_ip_in_cidr(ip, cidr) + ip_added = provider.add_ip_to_subnet(ip, add_ip_params) # Returns nil on success + halt 500, ip_added.to_json unless ip_added.nil? + status 201 + rescue Proxy::Validations::Error => e + logger.exception(ERRORS[:proxy_validation_error], e) + halt 400, { error: e.to_s }.to_json + rescue RuntimeError => e + logger.exception(ERRORS[:runtime_error], e) + halt 500, { error: e.to_s }.to_json + rescue Errno::ECONNREFUSED, Errno::ECONNRESET + logger.exception(ERRORS[:no_connection], e) + halt 500, { error: ERRORS[:no_connection] }.to_json + end + + # Deletes IP address from a given subnet + # + # Params: 1. address: The network address of the IPv4 or IPv6 subnet + # 2. prefix: The subnet prefix(e.g. 24) + # 3. ip: IP address to be deleted + # 4. group(optional): The name of the External IPAM Group, containing the subnet to delete ip from + # + # Returns: + # Response if deleted successfully: + # =========================== + # Http Code: 200 + # Response: Empty + # + # Response if not added successfully: + # =========================== + # Http Code: 500 + # JSON Response: + # {"error": "Unable to delete IP from External IPAM"} + delete '/subnet/:address/:prefix/:ip' do + content_type :json + + validate_required_params!([:address, :ip, :prefix], params) + ip = validate_ip(params[:ip]) + cidr = validate_cidr(params[:address], params[:prefix]) + group_name = get_request_group(params) + subnet = provider.get_ipam_subnet(cidr, group_name) + halt 404, { error: ERRORS[:no_subnet] }.to_json if subnet.nil? + del_ip_params = { cidr: cidr, subnet_id: subnet[:id], group_name: group_name } + validate_ip_in_cidr(ip, cidr) + ip_deleted = provider.delete_ip_from_subnet(ip, del_ip_params) # Returns nil on success + halt 500, ip_deleted.to_json unless ip_deleted.nil? + halt 204 + rescue Proxy::Validations::Error => e + logger.exception(ERRORS[:proxy_validation_error], e) + halt 400, { error: e.to_s }.to_json + rescue RuntimeError => e + logger.exception(ERRORS[:runtime_error], e) + halt 500, { error: e.to_s }.to_json + rescue Errno::ECONNREFUSED, Errno::ECONNRESET + logger.exception(ERRORS[:no_connection], e) + halt 500, { error: ERRORS[:no_connection] }.to_json + end + end +end diff --git a/modules/externalipam/ipam_helper.rb b/modules/externalipam/ipam_helper.rb new file mode 100644 index 000000000..86ea8bfcf --- /dev/null +++ b/modules/externalipam/ipam_helper.rb @@ -0,0 +1,113 @@ +require 'proxy/validations' +require 'externalipam/externalipam' + +# Module containing helper methods for use by all External IPAM provider implementations +module Proxy::Ipam::IpamHelper + include ::Proxy::Validations + + MAX_IP_RETRIES = 10 + ERRORS = { + cidr: "A 'cidr' parameter for the subnet must be provided(e.g. IPv4: 100.10.10.0/24, IPv6: 2001:db8:abcd:12::/124)", + mac: "A 'mac' address must be provided(e.g. 00:0a:95:9d:68:10)", + ip: "Missing 'ip' parameter. An IPv4 or IPv6 address must be provided(e.g. IPv4: 100.10.10.22, IPv6: 2001:db8:abcd:12::3)", + group_name: "A 'group_name' must be provided", + no_ip: 'IP address not found', + no_free_ips: 'No free addresses found', + no_connection: 'Unable to connect to External IPAM server', + no_group: 'Group not found in External IPAM', + no_groups: 'No groups found in External IPAM', + no_subnet: 'Subnet not found in External IPAM', + no_subnets_in_group: 'No subnets found in External IPAM group', + provider: "The IPAM provider must be specified(e.g. 'externalipam_phpipam' or 'externalipam_netbox')", + groups_not_supported: 'Groups are not supported', + add_ip: 'Error adding IP to External IPAM', + bad_mac: 'Mac address is invalid', + bad_ip: 'IP address is invalid', + bad_cidr: 'The network cidr is invalid', + proxy_validation_error: 'Proxy validation error', + runtime_error: 'Runtime error', + }.freeze + + def provider + @provider ||= + begin + unless client.authenticated? + halt 500, { error: 'Invalid credentials for External IPAM' }.to_json + end + client + end + end + + # Called when next available IP from External IPAM has been cached by another user/host, but + # not actually persisted in External IPAM yet. This method will increment the IP, up to + # MAX_IP_RETRIES times, and check if it is available in External IPAM each iteration. It + # will return the original IP(the 'ip' param) if no new IP's are found after MAX_IP_RETRIES + # iterations. + def find_new_ip(ip_cache, subnet_id, ip, mac, cidr, group_name) + found_ip = nil + temp_ip = ip + retry_count = 0 + + while retry_count < MAX_IP_RETRIES + new_ip = increment_ip(temp_ip) + ipam_ip = ip_exists?(new_ip, subnet_id, group_name) + + # If new IP doesn't exist in IPAM and not in the cache + if !ipam_ip && !ip_cache.ip_exists?(group_name, cidr, new_ip) + found_ip = new_ip.to_s + ip_cache.add(group_name, cidr, found_ip, mac) + break + end + + temp_ip = new_ip + retry_count += 1 + end + + return ip if found_ip.nil? + + found_ip + end + + # Checks the cache for existing ip, and returns it if it exists. If not exists, it will + # find a new ip (using find_new_ip), and it is added to the cache. + def cache_next_ip(ip_cache, ip, mac, cidr, subnet_id, group_name) + group = group_name.nil? ? '' : group_name + subnet_hash = ip_cache.get_cidr(group, cidr) + next_ip = nil + + if mac && subnet_hash&.key?(mac) + next_ip = ip_cache.get_ip(group, cidr, mac) + else + new_ip = ip + ip_in_cache = ip_cache.ip_exists?(group, cidr, new_ip) + ip_expired = ip_cache.ip_expired?(group, cidr, new_ip) + + if ip_in_cache && !ip_expired + next_ip = find_new_ip(ip_cache, subnet_id, new_ip, mac, cidr, group) + else + next_ip = new_ip.to_s + ip_cache.add(group, cidr, new_ip, mac) + end + + halt 404, { error: "No free addresses found in subnet #{cidr}. Some available ip's may be cached. Try again in #{@ip_cache.cleanup_interval} seconds after cache is cleared." }.to_json unless usable_ip(next_ip, cidr) + end + + next_ip + end + + def increment_ip(ip) + IPAddr.new(ip.to_s).succ.to_s + end + + def usable_ip(ip, cidr) + ip_addr = IPAddr.new(ip) + return true if ip_addr.ipv6? + network = IPAddr.new(cidr) + network.include?(ip_addr) && network.to_range.last != ip && network.to_range.first != ip + end + + def get_request_group(params) + halt 500, { error: errors[:groups_not_supported] }.to_json if params[:group] && !provider.groups_supported? + CGI.unescape(params[:group].to_s) + end +end diff --git a/modules/externalipam/ipam_validator.rb b/modules/externalipam/ipam_validator.rb new file mode 100644 index 000000000..281d234e2 --- /dev/null +++ b/modules/externalipam/ipam_validator.rb @@ -0,0 +1,36 @@ +require 'proxy/validations' +require 'externalipam/externalipam' +require 'resolv' + +# Module containing validation methods for use by all External IPAM provider implementations +module Proxy::Ipam::IpamValidator + include ::Proxy::Validations + include Proxy::Ipam::IpamHelper + + def validate_required_params!(required_params, params) + err = [] + required_params.each do |param| + unless params[param.to_sym] + err.push errors[param.to_sym] + end + end + raise Proxy::Validations::Error, err unless err.empty? + end + + # def validate_cidr!(address, prefix) + # cidr = "#{address}/#{prefix}" + # network = IPAddr.new(cidr).to_s + # if IPAddr.new(cidr).to_s != IPAddr.new(address).to_s + # raise Proxy::Validations::Error, "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) + # unless IPAddr.new(cidr).include?(IPAddr.new(ip)) + # raise Proxy::Validations::Error.new, "IP #{ip} is not in #{cidr}" + # end + # end +end diff --git a/modules/externalipam/netbox/netbox_client.rb b/modules/externalipam/netbox/netbox_client.rb new file mode 100644 index 000000000..3abdb54a3 --- /dev/null +++ b/modules/externalipam/netbox/netbox_client.rb @@ -0,0 +1,158 @@ +require 'yaml' +require 'externalipam/externalipam' +require 'externalipam/ipam_helper' +require 'externalipam/ipam_validator' +require 'externalipam/api_resource' +require 'externalipam/ip_cache' + +module Proxy::Netbox + # Implementation class for External IPAM provider Netbox + class NetboxClient + include Proxy::Log + include Proxy::Ipam::IpamHelper + include Proxy::Ipam::IpamValidator + + def initialize(conf) + @api_base = "#{conf[:url]}/api/" + @token = conf[:token] + @api_resource = Proxy::Ipam::ApiResource.new(api_base: @api_base, token: "Token #{@token}") + @ip_cache = Proxy::Ipam::IpCache.instance + @ip_cache.provider_name('netbox') + end + + def get_ipam_subnet(cidr, group_name = nil) + if group_name.nil? || group_name.empty? + get_ipam_subnet_by_cidr(cidr) + else + group_id = get_group_id(group_name) + get_ipam_subnet_by_group(cidr, group_id) + end + end + + def get_ipam_subnet_by_group(cidr, group_id) + params = { status: 'active', prefix: cidr, vrf_id: group_id } + response = @api_resource.get("ipam/prefixes/", params) + json_body = JSON.parse(response.body) + return nil if json_body['count'].zero? + subnet_from_result(json_body['results'][0]) + end + + def get_ipam_subnet_by_cidr(cidr) + params = { status: 'active', prefix: cidr } + response = @api_resource.get("ipam/prefixes/", params) + json_body = JSON.parse(response.body) + return nil if json_body['count'].zero? + subnet_from_result(json_body['results'][0]) + end + + def ipam_groups + response = @api_resource.get('ipam/vrfs/') + json_body = JSON.parse(response.body) + return [] if json_body['count'].zero? + json_body['results'].map do |group| + { name: group['name'], description: group['description'] } + end + end + + def get_ipam_group(group_name) + raise ERRORS[:groups_not_supported] unless groups_supported? + response = @api_resource.get("ipam/vrfs/?name=#{group_name}") + json_body = JSON.parse(response.body) + return nil if json_body['count'].zero? + group_from_result(json_body['results'][0]) + end + + def get_group_id(group_name) + return nil if group_name.nil? || group_name.empty? + group = get_ipam_group(group_name) + raise ERRORS[:no_group] if group.nil? + group[:id] + end + + def get_ipam_subnets(group_name) + params = { status: 'active' } + params[:vrf_id] = get_group_id(group_name) if group_name + response = @api_resource.get("ipam/prefixes/", params) + json_body = JSON.parse(response.body) + return [] if json_body['count'].zero? + subnets_from_result(json_body['results']) + end + + def ip_exists?(ip, subnet_id, group_name) + group_id = get_group_id(group_name) + params = { address: ip } + params[:prefix_id] = subnet_id unless subnet_id.nil? + params[:vrf_id] = group_id unless group_id.nil? + url = "ipam/ip-addresses/" + response = @api_resource.get(url, params) + json_body = JSON.parse(response.body) + !json_body['count'].zero? + end + + def add_ip_to_subnet(ip, params) + desc = 'Address auto added by Foreman' + address = "#{ip}/#{params[:cidr].split('/').last}" + group_name = params[:group_name] + data = { address: address, nat_outside: 0, description: desc } + data[:vrf] = get_group_id(group_name) unless group_name.nil? || group_name.empty? + response = @api_resource.post('ipam/ip-addresses/', data.to_json) + return nil if response.code == '201' + { error: "Unable to add #{address} in External IPAM server" } + end + + def delete_ip_from_subnet(ip, params) + group_name = params[:group_name] + params = { address: ip } + params[:vrf_id] = get_group_id(group_name) unless group_name.nil? || group_name.empty? + response = @api_resource.get("ipam/ip-addresses/", params) + json_body = JSON.parse(response.body) + return { error: ERRORS[:no_ip] } if json_body['count'].zero? + address_id = json_body['results'][0]['id'] + response = @api_resource.delete("ipam/ip-addresses/#{address_id}/") + return nil if response.code == '204' + { error: "Unable to delete #{ip} in External IPAM server" } + end + + def get_next_ip(mac, cidr, group_name) + subnet = get_ipam_subnet(cidr, group_name) + raise ERRORS[:no_subnet] if subnet.nil? + params = { limit: 1 } + response = @api_resource.get("ipam/prefixes/#{subnet[:id]}/available-ips/", params) + json_body = JSON.parse(response.body) + return nil if json_body.empty? + ip = json_body[0]['address'].split('/').first + cache_next_ip(@ip_cache, ip, mac, cidr, subnet[:id], group_name) + end + + def groups_supported? + true + end + + def authenticated? + !@token.nil? + end + + private + + def subnet_from_result(result) + { + subnet: result['prefix'].split('/').first, + mask: result['prefix'].split('/').last, + description: result['description'], + id: result['id'], + } + end + + def subnets_from_result(result) + result.map { |subnet| subnet_from_result(subnet) } + end + + def group_from_result(result) + { + id: result['id'], + name: result['name'], + description: result['description'], + } + end + end +end diff --git a/modules/externalipam/netbox/netbox_plugin.rb b/modules/externalipam/netbox/netbox_plugin.rb new file mode 100644 index 000000000..d46f4753a --- /dev/null +++ b/modules/externalipam/netbox/netbox_plugin.rb @@ -0,0 +1,12 @@ +require 'externalipam/netbox/netbox_plugin_configuration' + +module Proxy::Netbox + class Plugin < ::Proxy::Provider + plugin :externalipam_netbox, ::Proxy::VERSION + requires :externalipam, ::Proxy::VERSION + validate :url, url: true + validate_presence :token + load_classes ::Proxy::Netbox::PluginConfiguration + load_dependency_injection_wirings ::Proxy::Netbox::PluginConfiguration + end +end diff --git a/modules/externalipam/netbox/netbox_plugin_configuration.rb b/modules/externalipam/netbox/netbox_plugin_configuration.rb new file mode 100644 index 000000000..4efd28764 --- /dev/null +++ b/modules/externalipam/netbox/netbox_plugin_configuration.rb @@ -0,0 +1,11 @@ +module ::Proxy::Netbox + class PluginConfiguration + def load_classes + require 'externalipam/netbox/netbox_client' + end + + def load_dependency_injection_wirings(container, settings) + container.dependency :externalipam_client, -> { ::Proxy::Netbox::NetboxClient.new(settings) } + end + end +end diff --git a/modules/externalipam/phpipam/phpipam_client.rb b/modules/externalipam/phpipam/phpipam_client.rb new file mode 100644 index 000000000..fc0d3b004 --- /dev/null +++ b/modules/externalipam/phpipam/phpipam_client.rb @@ -0,0 +1,161 @@ +require 'yaml' +require 'json' +require 'net/http' +require 'uri' +require 'sinatra' +require 'externalipam/externalipam' +require 'externalipam/ipam_helper' +require 'externalipam/ipam_validator' +require 'externalipam/api_resource' +require 'externalipam/ip_cache' + +module Proxy::Phpipam + # Implementation class for External IPAM provider phpIPAM + class PhpipamClient + include Proxy::Log + include Proxy::Ipam::IpamHelper + include Proxy::Ipam::IpamValidator + + def initialize(conf) + @conf = conf + @api_base = "#{@conf[:url]}/api/#{@conf[:user]}/" + @token = authenticate + @api_resource = Proxy::Ipam::ApiResource.new(api_base: @api_base, token: @token, auth_header: 'Token') + @ip_cache = Proxy::Ipam::IpCache.instance + @ip_cache.provider_name('phpipam') + end + + def get_ipam_subnet(cidr, group_name = nil) + if group_name.nil? || group_name.empty? + get_ipam_subnet_by_cidr(cidr) + else + group = get_ipam_group(group_name) + get_ipam_subnet_by_group(cidr, group[:id]) + end + end + + def get_ipam_subnet_by_group(cidr, group_id) + subnets = get_ipam_subnets(group_id) + subnet = subnets&.find { |s| cidr == "#{s[:subnet]}/#{s[:mask]}" } + return nil if subnet.nil? || subnet[:id].nil? + response = @api_resource.get("subnets/#{subnet[:id]}/") + json_body = JSON.parse(response.body) + subnet_from_result(json_body['data']) + end + + def get_ipam_subnet_by_cidr(cidr) + subnet = @api_resource.get("subnets/cidr/#{cidr}") + json_body = JSON.parse(subnet.body) + return nil if json_body['data'].nil? + subnet_from_result(json_body['data'][0]) + end + + def get_ipam_group(group_name) + return nil if group_name.nil? + group = @api_resource.get("sections/#{CGI.escape(group_name)}/") + json_body = JSON.parse(group.body) + raise ERRORS[:no_group] if json_body['data'].nil? + group_from_result(json_body['data']) + end + + def ipam_groups + groups = @api_resource.get('sections/') + json_body = JSON.parse(groups.body) + return [] if json_body['data'].nil? + groups_from_result(json_body['data']) + end + + def get_ipam_subnets(group_name) + group = get_ipam_group(group_name) + raise ERRORS[:no_group] if group.nil? + subnets = @api_resource.get("sections/#{group[:id]}/subnets/") + json_body = JSON.parse(subnets.body) + return [] if json_body['data'].nil? + subnets_from_result(json_body['data']) + end + + def ip_exists?(ip, subnet_id, _group_name) + ip = @api_resource.get("subnets/#{subnet_id}/addresses/#{ip}/") + json_body = JSON.parse(ip.body) + json_body['success'] + end + + def add_ip_to_subnet(ip, params) + data = { subnetId: params[:subnet_id], ip: ip, description: 'Address auto added by Foreman' } + subnet = @api_resource.post('addresses/', data.to_json) + json_body = JSON.parse(subnet.body) + return nil if json_body['code'] == 201 + { error: 'Unable to add IP to External IPAM' } + end + + def delete_ip_from_subnet(ip, params) + subnet = @api_resource.delete("addresses/#{ip}/#{params[:subnet_id]}/") + json_body = JSON.parse(subnet.body) + return nil if json_body['success'] + { error: 'Unable to delete IP from External IPAM' } + end + + def get_next_ip(mac, cidr, group_name) + subnet = get_ipam_subnet(cidr, group_name) + raise ERRORS[:no_subnet] if subnet.nil? + response = @api_resource.get("subnets/#{subnet[:id]}/first_free/") + json_body = JSON.parse(response.body) + return { error: json_body['message'] } if json_body['message'] + ip = json_body['data'] + cache_next_ip(@ip_cache, ip, mac, cidr, subnet[:id], group_name) + end + + def groups_supported? + true + end + + def authenticated? + !@token.nil? + end + + private + + def authenticate + auth_uri = URI("#{@api_base}/user/") + request = Net::HTTP::Post.new(auth_uri) + request.basic_auth @conf[:user], @conf[:password] + + response = Net::HTTP.start(auth_uri.hostname, auth_uri.port, use_ssl: auth_uri.scheme == 'https') do |http| + http.request(request) + end + + response = JSON.parse(response.body) + logger.warn(response['message']) if response['message'] + response.dig('data', 'token') + end + + def subnet_from_result(result) + { + id: result['id'], + subnet: result['subnet'], + mask: result['mask'], + description: result['description'], + } + end + + def subnets_from_result(result) + result.map do |subnet| + subnet_from_result(subnet) + end + end + + def group_from_result(result) + { + id: result['id'], + name: result['name'], + description: result['description'], + } + end + + def groups_from_result(result) + result.map do |group| + group_from_result(group) + end + end + end +end diff --git a/modules/externalipam/phpipam/phpipam_plugin.rb b/modules/externalipam/phpipam/phpipam_plugin.rb new file mode 100644 index 000000000..7a0408b95 --- /dev/null +++ b/modules/externalipam/phpipam/phpipam_plugin.rb @@ -0,0 +1,12 @@ +require 'externalipam/phpipam/phpipam_plugin_configuration' + +module Proxy::Phpipam + class Plugin < ::Proxy::Provider + plugin :externalipam_phpipam, ::Proxy::VERSION + requires :externalipam, ::Proxy::VERSION + validate :url, url: true + validate_presence :user, :password + load_classes ::Proxy::Phpipam::PluginConfiguration + load_dependency_injection_wirings ::Proxy::Phpipam::PluginConfiguration + end +end diff --git a/modules/externalipam/phpipam/phpipam_plugin_configuration.rb b/modules/externalipam/phpipam/phpipam_plugin_configuration.rb new file mode 100644 index 000000000..118d7a33e --- /dev/null +++ b/modules/externalipam/phpipam/phpipam_plugin_configuration.rb @@ -0,0 +1,11 @@ +module ::Proxy::Phpipam + class PluginConfiguration + def load_classes + require 'externalipam/phpipam/phpipam_client' + end + + def load_dependency_injection_wirings(container, settings) + container.dependency :externalipam_client, -> { ::Proxy::Phpipam::PhpipamClient.new(settings) } + end + end +end diff --git a/test/externalipam/externalipam_api_test.rb b/test/externalipam/externalipam_api_test.rb new file mode 100644 index 000000000..33fc6aa6c --- /dev/null +++ b/test/externalipam/externalipam_api_test.rb @@ -0,0 +1,273 @@ +require 'test_helper' +require 'externalipam/dependency_injection' + +ENV['RACK_ENV'] = 'test' + +# Test IPAM provider +class ExternalIpamTestProvider + def get_next_ip(mac, cidr, group_name) + { data: "192.0.2.1" } + end + + def get_ipam_subnet(cidr, group_name) + { data: { + id: "33", + subnet: "192.0.2.0", + description: "Subnet description", + mask: "24" }, + } + end + + def get_ipam_group(group_name) + { data: { + id: 1, + name: "Group 1", + description: "This is a group" }, + } + end + + def ipam_groups + { data: [ + { id: 1, name: "Group 1", description: "This is group 1" }, + { id: 2, name: "Group 2", description: "This is group 2" }, + ]} + end + + def get_ipam_subnets(group_name) + { data: [ + { subnet: "192.0.2.0", mask: "24", description: "This is a subnet" }, + { subnet: "198.51.100.0", mask: "24", description: "This is another subnet" }, + ]} + end + + def add_ip_to_subnet(ip, add_ip_params) + nil + end + + def delete_ip_from_subnet(ip, del_ip_params) + nil + end + + def ip_exists?(ip, subnet_id, group_name) + true + end + + def groups_supported? + true + end + + def authenticated? + true + end +end + +# Inject test IPAM provider +module Proxy::Ipam + module DependencyInjection + include Proxy::DependencyInjection::Accessors + def container_instance + Proxy::DependencyInjection::Container.new do |container| + container.dependency :externalipam_client, ExternalIpamTestProvider + end + end + end +end + +require 'externalipam/ipam_api' + +class ExternalIpamApiTest < Test::Unit::TestCase + include Rack::Test::Methods + + def app + Proxy::Ipam::Api.new + end + + def setup + @invalid_address = "1234.1234.1234.1234" + @valid_address = "192.0.2.0" + @invalid_prefix = "XY" + @valid_prefix = "24" + @invalid_mac = "bad_mac" + @valid_mac = "b6:9d:1e:13:2d:2c" + end + + def test_get_next_ip_throws_error_when_address_invalid + get "/subnet/#{@invalid_address}/#{@valid_prefix}/next_ip?mac=#{@valid_mac}" + assert_equal 400, last_response.status + assert_match /invalid address/, last_response.body + end + + def test_get_next_ip_throws_error_when_prefix_invalid + get "/subnet/#{@valid_address}/#{@invalid_prefix}/next_ip?mac=#{@valid_mac}" + assert_equal 400, last_response.status + assert_match /invalid address/, last_response.body + end + + def test_get_next_ip_throws_error_when_mac_addr_is_invalid + get "/subnet/#{@valid_address}/#{@valid_prefix}/next_ip?mac=#{@invalid_mac}" + assert_equal 400, last_response.status + assert_match /Invalid MAC/, last_response.body + end + + def test_get_next_ip_suceeds_when_mac_addr_is_valid + get "/subnet/#{@valid_address}/#{@valid_prefix}/next_ip?mac=#{@valid_mac}" + assert_equal 200, last_response.status + end + + def test_get_next_ip_returns_success_when_all_params_are_valid + get "/subnet/#{@valid_address}/#{@valid_prefix}/next_ip?mac=#{@valid_mac}" + assert_equal 200, last_response.status + assert_match /data/, last_response.body + end + + def test_get_ipam_subnet_throws_error_when_address_invalid + get "/subnet/#{@invalid_address}/#{@valid_prefix}" + assert_equal 400, last_response.status + assert_match /invalid address/, last_response.body + end + + def test_get_ipam_subnet_throws_error_when_prefix_invalid + get "/subnet/#{@valid_address}/#{@invalid_prefix}" + assert_equal 400, last_response.status + assert_match /invalid address/, last_response.body + end + + def test_get_ipam_subnet_throws_error_when_address_is_nil + get "/subnet//#{@valid_prefix}" + assert_equal 404, last_response.status + end + + def test_get_ipam_subnet_throws_error_when_prefix_is_nil + get "/subnet/#{@valid_address}/" + assert_equal 404, last_response.status + end + + def test_get_ipam_subnet_returns_success_when_all_params_valid + get "/subnet/#{@valid_address}/#{@valid_prefix}" + assert_equal 200, last_response.status + assert_match /data/, last_response.body + end + + def test_get_groups_should_return_success_when_supported + get "/groups" + assert_equal 200, last_response.status + end + + def test_get_ipam_group_throws_error_when_group_name_is_nil + get "/group/" + assert_equal 404, last_response.status + end + + def test_get_ipam_group_returns_success_when_group_name_is_specified + get "/groups/testgroup" + assert_equal 200, last_response.status + assert_match /data/, last_response.body + end + + def test_get_ipam_subnets_throws_error_when_group_name_is_nil + get "/group//subnets" + assert_equal 404, last_response.status + end + + def test_get_ipam_subnets_returns_success_when_group_name_is_specified + get "/groups/testgroup/subnets" + assert_equal 200, last_response.status + assert_match /data/, last_response.body + end + + def test_get_ip_throws_error_when_address_invalid + get "/subnet/#{@invalid_address}/#{@valid_prefix}/#{@valid_address}" + assert_equal 400, last_response.status + assert_match /invalid address/, last_response.body + end + + def test_get_ip_throws_error_when_prefix_invalid + get "/subnet/#{@valid_address}/#{@invalid_prefix}/#{@valid_address}" + assert_equal 400, last_response.status + assert_match /invalid address/, last_response.body + end + + def test_get_ip_returns_error_when_invalid_ip_provided + get "/subnet/#{@valid_address}/#{@valid_prefix}/#{@invalid_address}" + assert_equal 400, last_response.status + assert_match /Invalid IP Address/, last_response.body + end + + def test_get_ip_throws_error_when_ip_address_does_not_exist_in_cidr + get "/subnet/192.0.2.0/24/221.34.56.3" + assert_equal 400, last_response.status + assert_match /not in/, last_response.body + end + + def test_get_ip_returns_success_when_ip_address_exists_in_cidr + get "/subnet/192.0.2.0/24/192.0.2.1" + assert_equal 200, last_response.status + assert_match /true/, last_response.body + end + + def test_put_ip_throws_error_when_address_is_nil + post "/subnet//#{@valid_prefix}/#{@valid_address}" + assert_equal 404, last_response.status + end + + def test_put_ip_throws_error_when_prefix_is_nil + post "/subnet/#{@valid_address}//#{@valid_address}" + assert_equal 404, last_response.status + end + + def test_put_ip_throws_error_when_ip_address_is_nil + post "/subnet/#{@valid_address}/#{@valid_prefix}/" + assert_equal 404, last_response.status + end + + def test_put_ip_throws_error_when_address_invalid + post "/subnet/#{@invalid_address}/#{@valid_prefix}/#{@valid_address}" + assert_equal 400, last_response.status + assert_match /invalid address/, last_response.body + end + + def test_put_ip_throws_error_when_prefix_invalid + post "/subnet/#{@valid_address}/#{@invalid_prefix}/#{@valid_address}" + assert_equal 400, last_response.status + assert_match /invalid address/, last_response.body + end + + def test_put_ip_returns_error_when_invalid_ip_provided + post "/subnet/#{@valid_address}/#{@valid_prefix}/#{@invalid_address}" + assert_equal 400, last_response.status + assert_match /Invalid IP Address/, last_response.body + end + + def test_delete_ip_throws_error_when_address_is_nil + delete "/subnet//#{@valid_prefix}/#{@valid_address}" + assert_equal 404, last_response.status + end + + def test_delete_ip_throws_error_when_prefix_is_nil + delete "/subnet/#{@valid_address}//#{@valid_address}" + assert_equal 404, last_response.status + end + + def test_delete_ip_throws_error_when_ip_address_is_nil + delete "/subnet/#{@valid_address}/#{@valid_prefix}/" + assert_equal 404, last_response.status + end + + def test_delete_ip_throws_error_when_address_invalid + delete "/subnet/#{@invalid_address}/#{@valid_prefix}/#{@valid_address}" + assert_equal 400, last_response.status + assert_match /invalid address/, last_response.body + end + + def test_delete_ip_throws_error_when_prefix_invalid + delete "/subnet/#{@valid_address}/#{@invalid_prefix}/#{@valid_address}" + assert_equal 400, last_response.status + assert_match /invalid address/, last_response.body + end + + def test_delete_ip_returns_error_when_invalid_ip_provided + delete "/subnet/#{@valid_address}/#{@valid_prefix}/#{@invalid_address}" + assert_equal 400, last_response.status + assert_match /Invalid IP Address/, last_response.body + end +end