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
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 <apenney@gmail.com>
Baptiste Agasse <baptiste.agasse@lyra-network.com>
Brandon Weeks <weeks@squareup.com>
Christian Arnold <christian.arnold@t-systems.de>
Christopher Smith <grizzthedj@gmail.com>
Corey Osman <corey@logicminds.biz>
Daniel Baeurer <daniel.baeurer@gmail.com>
Daniel Helgenberger <daniel.helgenberger@m-box.de>
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
Comment thread
lzap marked this conversation as resolved.
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
Comment thread
grizzthedj marked this conversation as resolved.
include ::Proxy::Log
include Proxy::Ipam::IpamHelper

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.

ApiResource probably does not need IpamHelper — that pulls in Sinatra halt semantics and the heavy externalipam/externalipam require chain into a plain HTTP client class.


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
Comment thread
grizzthedj marked this conversation as 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|

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.

No read/open timeout, no TLS options for custom CAs, and no HTTP status check before callers JSON.parse the body. A 401/500 HTML response will surface as a parse error rather than a clear auth/server failure.

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

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.

The cleanup TimerTask starts in initialize via the Singleton, so it runs even when the externalipam module is disabled. Consider starting it lazily on first use, and add a stop hook like Proxy::DHCP::FreeIps.

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)

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.

ip_expired? calls ip_exists? first, then repeats the same cache scan. Callers always use both together — consider a single lookup or an include_expired flag to avoid the double walk.

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
Comment thread
grizzthedj marked this conversation as 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

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.

MAC addresses are used as hash keys without normalizing case/format. 00:0A:95:... and 00:0a:95:... would be treated as different cache entries and could get different suggested IPs.

#
# 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