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
68 changes: 65 additions & 3 deletions lib/fog/libvirt/models/compute/server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -259,11 +259,14 @@ def generate_config_iso_in_dir(dir_path, user_data, &blk)
user_data_path = File.join(dir_path, 'user-data')
File.write(user_data_path, user_data)

isofile = Tempfile.new(['init', '.iso']).path
iso_tempfile = Tempfile.new(['init', '.iso'])
Comment thread
ajmeese7 marked this conversation as resolved.
isofile = iso_tempfile.path
unless system('xorrisofs', '-output', isofile, '-volid', 'cidata', '-joliet', '-rock', user_data_path, meta_data_path)
raise Fog::Errors::Error.new("Couldn't generate cloud-init iso disk with xorrisofs.")
end
blk.call(isofile)
ensure
iso_tempfile&.close!
end

def create_user_data_iso
Expand Down Expand Up @@ -472,6 +475,46 @@ def read_ceph_args(path = "/etc/foreman/ceph.conf")
args
end

DNSMASQ_LEASE_DIR = '/var/lib/libvirt/dnsmasq'.freeze

# Read IP from a dnsmasq lease file when the libvirt DHCPLeases API
# returns nothing. This happens when DHCP is provided by an external
# dnsmasq (not started by libvirt) -- for example, a dnsmasq running
# inside a network namespace to work around port conflicts on WSL2.
#
# dnsmasq lease file format (space-separated):
# <expiry> <mac> <ip> <hostname> [<client-id>]
def ip_address_from_leasefile(net, mac)
net_name = net.name
return nil unless net_name

lease_file = File.join(DNSMASQ_LEASE_DIR, "#{net_name}.leases")
return nil unless File.exist?(lease_file)

target_mac = mac.to_s.downcase
best_expiry = 0
best_ip = nil

begin
File.foreach(lease_file) do |line|
parts = line.strip.split
next unless parts.length >= 4

expiry = parts[0].to_i
lease_mac = parts[1].downcase
ip = parts[2]
if lease_mac == target_mac && expiry > best_expiry
best_expiry = expiry
best_ip = ip
end
end
rescue Errno::EACCES, Errno::ENOENT
return nil
end

best_ip
end

# This retrieves the ip address of the mac address using dhcp_leases
# It returns an array of public and private ip addresses
# Currently only one ip address is returned, but in the future this could be multiple
Expand All @@ -482,13 +525,32 @@ def addresses(service_arg=service, options={})
net = service.networks.all(:name => nic.network).first
# Assume the lease expiring last is the current IP address
ip_address = net&.dhcp_leases(nic.mac)&.max_by { |lse| lse["expirytime"] }&.dig("ipaddr")

# Fallback: when the network has no libvirt-managed DHCP (e.g. an
# external dnsmasq running in a network namespace on WSL2), the
# DHCPLeases API returns empty. Read the dnsmasq lease file directly.
#
# Only attempt this for a local connection: the lease file lives on
# the libvirt host's filesystem, so for a remote URI (e.g. qemu+ssh)
# we would be reading the client machine's files instead.
if ip_address.nil? && net && !service.uri.remote?
warn_leasefile_fallback(nic.mac)
ip_address = ip_address_from_leasefile(net, nic.mac)
end
end

return { :public => [ip_address], :private => [ip_address] }
end

# Locale-friendly removal of non-alpha nums
DOMAIN_CLEANUP_REGEXP = Regexp.compile('[\W_-]')
# Warn once per MAC (per server instance) when falling back to the
# dnsmasq lease file, to avoid log spam on repeated address lookups.
def warn_leasefile_fallback(mac)
@leasefile_warned_macs ||= {}
return if @leasefile_warned_macs[mac]

Fog::Logger.warning("DHCPLeases API returned no address for #{mac}; falling back to dnsmasq lease file.")
@leasefile_warned_macs[mac] = true
end

def ip_address(key)
addresses[key]&.first
Expand Down
113 changes: 113 additions & 0 deletions minitests/server/leasefile_fallback_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
require 'test_helper'
require 'tmpdir'

class LeasefileFallbackTest < Minitest::Test
def setup
@compute = Fog::Compute[:libvirt]
@server = @compute.servers.new(:name => "test")
@mac = "52:54:00:01:02:03"
@net = stub(:name => "default")
@tmpdir = Dir.mktmpdir("fog-leasefile-test")
@original_dir = Fog::Libvirt::Compute::Server::DNSMASQ_LEASE_DIR
Fog::Libvirt::Compute::Server.send(:remove_const, :DNSMASQ_LEASE_DIR)
Fog::Libvirt::Compute::Server.const_set(:DNSMASQ_LEASE_DIR, @tmpdir)
end

def teardown
FileUtils.remove_entry(@tmpdir)
Fog::Libvirt::Compute::Server.send(:remove_const, :DNSMASQ_LEASE_DIR)
Fog::Libvirt::Compute::Server.const_set(:DNSMASQ_LEASE_DIR, @original_dir)
end

def test_returns_ip_for_matching_mac
write_lease_file("default", "1000 52:54:00:01:02:03 192.168.122.10 host1 *\n")
result = @server.send(:ip_address_from_leasefile, @net, @mac)
assert_equal "192.168.122.10", result
end

def test_returns_ip_with_highest_expiry
content = <<~LEASES
1000 52:54:00:01:02:03 192.168.122.10 host1 *
2000 52:54:00:01:02:03 192.168.122.20 host1 *
1500 52:54:00:01:02:03 192.168.122.15 host1 *
LEASES
write_lease_file("default", content)
result = @server.send(:ip_address_from_leasefile, @net, @mac)
assert_equal "192.168.122.20", result
end

def test_mac_matching_is_case_insensitive
write_lease_file("default", "1000 52:54:00:01:02:03 192.168.122.10 host1 *\n")
result = @server.send(:ip_address_from_leasefile, @net, "52:54:00:01:02:03".upcase)
assert_equal "192.168.122.10", result
end

def test_returns_nil_when_lease_file_missing
result = @server.send(:ip_address_from_leasefile, @net, @mac)
assert_nil result
end

def test_skips_malformed_lines
content = <<~LEASES
short line
1000 52:54:00:01:02:03 192.168.122.10 host1 *
incomplete
LEASES
write_lease_file("default", content)
result = @server.send(:ip_address_from_leasefile, @net, @mac)
assert_equal "192.168.122.10", result
end

def test_returns_nil_when_no_mac_matches
write_lease_file("default", "1000 52:54:00:ff:ff:ff 192.168.122.99 other *\n")
result = @server.send(:ip_address_from_leasefile, @net, @mac)
assert_nil result
end

def test_returns_nil_on_permission_error
skip("Cannot test permission denial as root") if Process.uid.zero?
write_lease_file("default", "1000 52:54:00:01:02:03 192.168.122.10 host1 *\n")
File.chmod(0o000, File.join(@tmpdir, "default.leases"))
result = @server.send(:ip_address_from_leasefile, @net, @mac)
assert_nil result
end

def test_returns_nil_when_net_name_is_nil
net_nil_name = stub(:name => nil)
result = @server.send(:ip_address_from_leasefile, net_nil_name, @mac)
assert_nil result
end

def test_addresses_uses_leasefile_for_local_connection
write_lease_file("default", "1000 52:54:00:01:02:03 192.168.122.10 host1 *\n")
stub_addresses_lookup(:remote => false)
result = @server.send(:addresses)
assert_equal "192.168.122.10", result[:public].first
end

def test_addresses_skips_leasefile_for_remote_connection
write_lease_file("default", "1000 52:54:00:01:02:03 192.168.122.10 host1 *\n")
stub_addresses_lookup(:remote => true)
result = @server.send(:addresses)
assert_nil result[:public].first
end

private

# Wire up the minimum collaborators so #addresses reaches the lease-file
# fallback: a NIC with no libvirt DHCP lease, and a connection whose URI is
# local or remote depending on :remote.
def stub_addresses_lookup(remote:)
nic = stub(:mac => @mac, :network => "default")
@server.stubs(:nics).returns([nic])
net = stub(:name => "default", :dhcp_leases => [])
networks = stub
networks.stubs(:all).returns([net])
@server.service.stubs(:networks).returns(networks)
@server.service.stubs(:uri).returns(stub(:remote? => remote))
end

def write_lease_file(net_name, content)
File.write(File.join(@tmpdir, "#{net_name}.leases"), content)
end
end
Loading