Skip to content

Fixes #34205 - External IPAM Integration - #810

Open
grizzthedj wants to merge 1 commit into
theforeman:developfrom
grizzthedj:external-ipam
Open

Fixes #34205 - External IPAM Integration#810
grizzthedj wants to merge 1 commit into
theforeman:developfrom
grizzthedj:external-ipam

Conversation

@grizzthedj

Copy link
Copy Markdown
Member

@lzap

This long overdue PR is for merging the External IPAM features from the smart_proxy_ipam plugin (https://github.com/grizzthedj/smart_proxy_ipam) into Smart Proxy Core.

This currently adds support for the below IPAM providers:

  1. phpIPAM
  2. Netbox

Tests for the External IPAM module have been written and are green. Please note that I am unable to run the entire test suite due to some mac install issues with the rkerberos gem.

Looking forward to feedback!

Comment thread config/settings.d/externalipam.yml.example
Comment thread config/settings.d/externalipam_netbox.yml.example Outdated
Comment thread modules/externalipam/externalipam_plugin.rb Outdated
Comment thread modules/externalipam/ip_cache.rb Outdated
Comment thread modules/externalipam/ipam_api.rb Outdated
Comment thread modules/externalipam/ipam_api.rb Outdated

@ekohl ekohl left a comment

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.

I went through it, but there's a bunch of repeated patterns. It may feel a bit like an inconsistent review and I think we may have a long back and forth on this for a while. That said, I am in favor of getting this into the Smart Proxy so please don't take it as being against it. Quite the opposite: Foreman itself has the functionality in core so the Smart Proxy should too.

Comment thread modules/externalipam/phpipam/phpipam_client.rb Outdated
Comment thread modules/externalipam/api_resource.rb Outdated
Comment thread modules/externalipam/ip_cache.rb Outdated
Comment thread modules/externalipam/ip_cache.rb Outdated
end

def ip_exists(ip, cidr, group_name)
cidr_key = @ip_cache[group_name.to_sym][cidr.to_sym]&.to_s

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.

Why &.to_s? Would it be better to use IPAddr instance and check if the IP is present in the subnet?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

We are checking if the IP is in the cache, not in the subnet

Comment thread modules/externalipam/ip_cache.rb
Comment thread modules/externalipam/netbox/netbox_client.rb
Comment thread modules/externalipam/netbox/netbox_client.rb Outdated
Comment thread modules/externalipam/api_resource.rb Outdated
Comment thread modules/externalipam/netbox/netbox_client.rb Outdated
Comment thread modules/externalipam/netbox/netbox_client.rb Outdated
Comment thread modules/externalipam/ipam_validator.rb
@grizzthedj

Copy link
Copy Markdown
Member Author

Pushed most of these changes, and still working through the last few. I will squash the commits once everything is in alignment

@lzap

lzap commented Mar 2, 2022

Copy link
Copy Markdown
Member

I have no further comments, @ekohl ?

Comment thread config/settings.d/externalipam.yml.example Outdated
Comment thread config/settings.d/externalipam_netbox.yml.example Outdated
Comment thread config/settings.d/externalipam_phpipam.yml.example Outdated
Comment thread modules/dhcp_common/free_ips.rb Outdated
Comment thread modules/externalipam/ipam_validator.rb Outdated
Comment thread modules/externalipam/ip_cache.rb Outdated
Comment thread modules/externalipam/ip_cache.rb Outdated
Comment thread modules/externalipam/ip_cache.rb Outdated
Comment thread modules/externalipam/ipam_api.rb Outdated
Comment thread modules/externalipam/ipam_api.rb Outdated
@lzap

lzap commented Mar 15, 2022

Copy link
Copy Markdown
Member

I think this is close to merging, please rebase and we can move forward with this.

@grizzthedj

Copy link
Copy Markdown
Member Author

@lzap Almost finished with most of the recommended changes. Will try to rebase and push at some point next week

@grizzthedj

grizzthedj commented Mar 28, 2022

Copy link
Copy Markdown
Member Author

@lzap @ekohl Sorry for the delay. Had to rebuild my local Foreman dev env. Changes are done and rebased. Let me know if anything else is needed here.

Here is the Foreman PR that is also needed for this integration to work

theforeman/foreman#9174

@lzap

lzap commented Apr 8, 2022

Copy link
Copy Markdown
Member

Overall I am okay with the patch, Ewoud had more comments than I anyway I will let him to finish the review. Thanks for this feature!

@grizzthedj

Copy link
Copy Markdown
Member Author

@ekohl Just did a fresh rebase and also wanted to follow-up on this PR. Changes are done and tested. Let me know if you need anything else. Will be great to have this as part of the Foreman/Proxy core!

@lzap
lzap requested a review from ekohl July 20, 2022 08:13
@grizzthedj

grizzthedj commented Jul 22, 2022

Copy link
Copy Markdown
Member Author

@ekohl - Changes are done and tested. Sorry I squashed the commit already but probably should have done that after your review

@grizzthedj

Copy link
Copy Markdown
Member Author

@ekohl Just a friendly ping on this. I hoping the plan is still to merge this into core. I have completed the requested changes - Let me know if there is anything else needed to move this forward

@dmgeurts

Copy link
Copy Markdown

@ekohl Where are things regarding the integration of External IPAM? It would be great to finally have this integrated.

@athompson-a2

Copy link
Copy Markdown

@ekohl I ran across this PR while looking to implement a connection to our IPAM with smart proxies. This is pretty old, so I'm not sure if it's mergeable but it would be very nice to have this feature for our proxies.

@wardhus

wardhus commented Aug 27, 2026

Copy link
Copy Markdown

Can this get merged? @ekohl @lzap

err = []
required_params.each do |param|
unless params[param.to_sym]
err.push errors[param.to_sym]

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.

errors should be ERRORS here — there is no errors method in scope, so this will raise NoMethodError when validation fails on a missing parameter.

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)

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.

Should be ip_cache.cleanup_intervalip_cache is the method parameter here, not an instance variable. This will raise when the usable-IP check fails.

end

def get_request_group(params)
halt 500, { error: errors[:groups_not_supported] }.to_json if params[:group] && !provider.groups_supported?

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.

Same as ipam_validator.rb: errors should be ERRORS.

extend Proxy::Ipam::DependencyInjection
include ::Proxy::Log
include ::Proxy::Validations
helpers ::Proxy::Helpers

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.

Other API modules (DHCP, DNS, Realm, etc.) call authorize_with_trusted_hosts and authorize_with_ssl_client here. This module can allocate/reserve/delete IPs in external systems and should do the same.

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

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.

Returning e.to_s to the client can leak internal/upstream error details. Prefer a generic message in the response and use logger.exception for the real error. Same pattern is repeated on every route in this file.

rescue RuntimeError => e
logger.exception(ERRORS[:runtime_error], e)
halt 500, { error: e.to_s }.to_json
rescue Errno::ECONNREFUSED, Errno::ECONNRESET

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.

e is not bound in this rescue clause — needs rescue Errno::ECONNREFUSED, Errno::ECONNRESET => e. Repeated on every route.

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']

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.

Returning { error: ... } here is not handled by the API layer (it only checks for nil), so phpIPAM errors end up as {"data":{"error":"..."}} with HTTP 200. Raise or return nil and let the API map it to an appropriate error response.

end

def add_ip_to_subnet(ip, params)
data = { subnetId: params[:subnet_id], ip: ip, description: 'Address auto added by Foreman' }

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 hostname is passed when adding an address. Foreman typically has the FQDN available at orchestration time — without it, entries show up unnamed in phpIPAM (see grizzthedj/smart_proxy_ipam#61).

Comment thread modules/externalipam/netbox/netbox_client.rb
end

def authenticated?
!@token.nil?

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.

authenticated? only checks that the token is non-nil; it never validates the token against the Netbox API. A bad token will only fail on the first real request.

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.

# Class to handle authentication and HTTP transactions with External IPAM providers
class ApiResource
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.

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.

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.

@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.

raise Proxy::Validations::Error, err unless err.empty?
end

# def validate_cidr!(address, prefix)

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.

Commented-out dead code — please remove before merge.

@provider ||=
begin
unless client.authenticated?
halt 500, { error: 'Invalid credentials for External IPAM' }.to_json

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.

Invalid credentials returns HTTP 500. Foreman often uses 422 for expected client/config errors rather than 500.

# Test IPAM provider
class ExternalIpamTestProvider
def get_next_ip(mac, cidr, group_name)
{ data: "192.0.2.1" }

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 test provider returns { data: "192.0.2.1" } but the API wraps the provider result again as { data: next_ip }, so the real response is double-wrapped. Mocks should return plain values (e.g. "192.0.2.1") like the real providers do.

@@ -0,0 +1,113 @@
require 'proxy/validations'
require 'externalipam/externalipam'

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.

This require pulls in the full plugin chain (externalipam/externalipam → all providers). That creates a circular/heavy dependency for a helper module — consider moving shared constants (e.g. ERRORS) to a smaller file.

@lzap

lzap commented Sep 2, 2026

Copy link
Copy Markdown
Member

Hello, I joined the team back recently and I can help to push this forward. This will need a rebase for sure.

The biggest problem is that every other Smart Proxy API module uses trusted-host and SSL client verification:

helpers ::Proxy::Helpers
authorize_with_trusted_hosts
authorize_with_ssl_client

Please extend tests for providers, cache, auth, integration and IPv6. Testing is really slim for such a feature.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants