Add SUSHY_EMULATOR_VIRTUAL_MEDIA_IP_FAMILY environment variable support

I commonly see customer problems with dualstack environments where BMC's
only have one IP stack. This change allows emulating this case.

This change adds support for restricting virtual media image URLs based
on IP family through the SUSHY_EMULATOR_VIRTUAL_MEDIA_IP_FAMILY
environment variable. When set to "4" or "6", the emulator will only
accept image URLs containing IPv4 or IPv6 addresses respectively,
returning an error for mismatched IP families. URLs with hostnames are
always allowed regardless of the setting, and if the variable is not
set, no validation is performed to maintain backward compatibility.

Change-Id: I3dab6f80eded8eddf04f823ea326f01655bfad8e
Generated-By: Claude Code
Signed-off-by: Dmitry Tantsur <dtantsur@protonmail.com>
This commit is contained in:
Dmitry Tantsur
2025-08-08 15:32:33 +02:00
parent 4d7850318e
commit a72db20fff
2 changed files with 228 additions and 0 deletions

View File

@@ -15,6 +15,7 @@
import abc
import collections
import ipaddress
import os
import re
import tempfile
@@ -27,6 +28,46 @@ from sushy_tools.emulator.resources import base
from sushy_tools import error
def _validate_ip_family(image_url, required_ip_family):
"""Validate that the IP address in the URL matches the required IP family.
:param image_url: URL to validate
:param required_ip_family: Required IP family ('4' for IPv4, '6' for IPv6)
:raises: BadRequest if the IP family doesn't match
:returns: None if validation passes or URL contains hostname
"""
if not required_ip_family:
return
if required_ip_family not in ('4', '6'):
raise error.BadRequest(
f"Invalid IP family configuration: {required_ip_family}. "
"Must be '4' or '6'.")
parsed_url = urlparse.urlparse(image_url)
host = parsed_url.hostname
if not host:
return
# Try to parse as IP address
try:
ip_addr = ipaddress.ip_address(host)
except ValueError:
# Not an IP address, it's a hostname - skip validation
return
# Check IP family
if (required_ip_family == '4'
and not isinstance(ip_addr, ipaddress.IPv4Address)):
raise error.BadRequest(
f"IPv4 address required but got IPv6 address: {host}")
elif (required_ip_family == '6'
and not isinstance(ip_addr, ipaddress.IPv6Address)):
raise error.BadRequest(
f"IPv6 address required but got IPv4 address: {host}")
DeviceInfo = collections.namedtuple(
'DeviceInfo',
['image_name', 'image_url', 'inserted', 'write_protected',
@@ -315,6 +356,12 @@ class StaticDriver(BaseDriver):
:param write_protected: prevent write access the inserted media
:raises: `FishyError` if image can't be manipulated
"""
# Validate IP family if configured
required_ip_family = self._config.get(
'SUSHY_EMULATOR_VIRTUAL_MEDIA_IP_FAMILY')
if image_url and required_ip_family:
_validate_ip_family(image_url, required_ip_family)
device_info = self._get_device(identity, device)
verify_media_cert = device_info.get(
'Verify',
@@ -425,6 +472,12 @@ class OpenstackDriver(BaseDriver):
:param write_protected: prevent write access the inserted media
:raises: `FishyError` if image can't be manipulated
"""
# Validate IP family if configured
required_ip_family = self._config.get(
'SUSHY_EMULATOR_VIRTUAL_MEDIA_IP_FAMILY')
if image_url and required_ip_family:
_validate_ip_family(image_url, required_ip_family)
device_info = self._get_device(identity, device)
verify_media_cert = device_info.get(
'Verify',

View File

@@ -637,3 +637,178 @@ class OpenstackDriverTestCase(base.BaseTestCase):
self.UUID, 'Cd')
self.assertEqual('ouch', str(e))
self.assertTrue(device_info['Inserted'])
class IpFamilyValidationTestCase(base.BaseTestCase):
"""Test IP family validation for virtual media."""
UUID = 'ZZZ-YYY-XXX'
CONFIG = {
'SUSHY_EMULATOR_VMEDIA_DEVICES': {
"Cd": {
"Name": "Virtual CD",
"MediaTypes": [
"CD",
"DVD"
]
}
}
}
@mock.patch.object(vmedia.StaticDriver, '_get_device', autospec=True)
def test_insert_image_ip_family_validation_ipv4_required_ipv6_provided(
self, mock_get_device):
device_info = {}
mock_get_device.return_value = device_info
# Create driver with IPv4 restriction
config = self.CONFIG.copy()
config['SUSHY_EMULATOR_VIRTUAL_MEDIA_IP_FAMILY'] = '4'
with mock.patch('sushy_tools.emulator.memoize.PersistentDict',
return_value={}, autospec=True):
driver = vmedia.StaticDriver(config, mock.MagicMock())
# IPv6 URL should raise error when IPv4 is required
self.assertRaises(error.BadRequest,
driver.insert_image,
self.UUID, 'Cd', 'http://[::1]/image.iso')
@mock.patch.object(vmedia.StaticDriver, '_get_device', autospec=True)
def test_insert_image_ip_family_validation_ipv6_required_ipv4_provided(
self, mock_get_device):
device_info = {}
mock_get_device.return_value = device_info
# Create driver with IPv6 restriction
config = self.CONFIG.copy()
config['SUSHY_EMULATOR_VIRTUAL_MEDIA_IP_FAMILY'] = '6'
with mock.patch('sushy_tools.emulator.memoize.PersistentDict',
return_value={}, autospec=True):
driver = vmedia.StaticDriver(config, mock.MagicMock())
# IPv4 URL should raise error when IPv6 is required
self.assertRaises(error.BadRequest,
driver.insert_image,
self.UUID, 'Cd', 'http://192.168.1.1/image.iso')
@mock.patch.object(vmedia.StaticDriver, '_get_device', autospec=True)
@mock.patch.object(vmedia.StaticDriver, '_get_image', autospec=True)
def test_insert_image_ip_family_validation_hostname_allowed(
self, mock_get_image, mock_get_device):
device_info = {}
mock_get_device.return_value = device_info
mock_get_image.return_value = ('image.iso', '/tmp/image.iso')
# Create driver with IPv4 restriction
config = self.CONFIG.copy()
config['SUSHY_EMULATOR_VIRTUAL_MEDIA_IP_FAMILY'] = '4'
with mock.patch('sushy_tools.emulator.memoize.PersistentDict',
return_value={}, autospec=True):
driver = vmedia.StaticDriver(config, mock.MagicMock())
# Hostname should be allowed regardless of IP family restriction
result = driver.insert_image(self.UUID, 'Cd',
'http://example.com/image.iso')
self.assertEqual('/tmp/image.iso', result)
@mock.patch.object(vmedia.StaticDriver, '_get_device', autospec=True)
@mock.patch.object(vmedia.StaticDriver, '_get_image', autospec=True)
def test_insert_image_ip_family_validation_ipv4_allowed(
self, mock_get_image, mock_get_device):
device_info = {}
mock_get_device.return_value = device_info
mock_get_image.return_value = ('image.iso', '/tmp/image.iso')
# Create driver with IPv4 restriction
config = self.CONFIG.copy()
config['SUSHY_EMULATOR_VIRTUAL_MEDIA_IP_FAMILY'] = '4'
with mock.patch('sushy_tools.emulator.memoize.PersistentDict',
return_value={}, autospec=True):
driver = vmedia.StaticDriver(config, mock.MagicMock())
# IPv4 address should be allowed when IPv4 is required
result = driver.insert_image(self.UUID, 'Cd',
'http://192.168.1.1/image.iso')
self.assertEqual('/tmp/image.iso', result)
@mock.patch.object(vmedia.StaticDriver, '_get_device', autospec=True)
@mock.patch.object(vmedia.StaticDriver, '_get_image', autospec=True)
def test_insert_image_ip_family_validation_ipv6_allowed(
self, mock_get_image, mock_get_device):
device_info = {}
mock_get_device.return_value = device_info
mock_get_image.return_value = ('image.iso', '/tmp/image.iso')
# Create driver with IPv6 restriction
config = self.CONFIG.copy()
config['SUSHY_EMULATOR_VIRTUAL_MEDIA_IP_FAMILY'] = '6'
with mock.patch('sushy_tools.emulator.memoize.PersistentDict',
return_value={}, autospec=True):
driver = vmedia.StaticDriver(config, mock.MagicMock())
# IPv6 address should be allowed when IPv6 is required
result = driver.insert_image(self.UUID, 'Cd',
'http://[::1]/image.iso')
self.assertEqual('/tmp/image.iso', result)
@mock.patch.object(vmedia.StaticDriver, '_get_device', autospec=True)
@mock.patch.object(vmedia.StaticDriver, '_get_image', autospec=True)
def test_insert_image_ip_family_validation_disabled(
self, mock_get_image, mock_get_device):
device_info = {}
mock_get_device.return_value = device_info
mock_get_image.return_value = ('image.iso', '/tmp/image.iso')
# Create driver without IP family restriction
config = self.CONFIG.copy()
with mock.patch('sushy_tools.emulator.memoize.PersistentDict',
return_value={}, autospec=True):
driver = vmedia.StaticDriver(config, mock.MagicMock())
# Both IPv4 and IPv6 should be allowed when no restriction is set
result = driver.insert_image(self.UUID, 'Cd',
'http://192.168.1.1/image.iso')
self.assertEqual('/tmp/image.iso', result)
result = driver.insert_image(self.UUID, 'Cd',
'http://[::1]/image.iso')
self.assertEqual('/tmp/image.iso', result)
@mock.patch.object(vmedia.OpenstackDriver, '_get_device', autospec=True)
def test_openstack_driver_ip_family_validation_ipv4_required_ipv6(
self, mock_get_device):
device_info = {}
mock_get_device.return_value = device_info
# Create driver with IPv4 restriction
config = {'SUSHY_EMULATOR_VIRTUAL_MEDIA_IP_FAMILY': '4'}
novadriver = mock.Mock()
with mock.patch('sushy_tools.emulator.memoize.PersistentDict',
return_value={}, autospec=True):
driver = vmedia.OpenstackDriver(config, mock.MagicMock(),
novadriver)
# IPv6 URL should raise error when IPv4 is required
self.assertRaises(error.BadRequest,
driver.insert_image,
self.UUID, 'Cd', 'http://[::1]/image.iso')
@mock.patch.object(vmedia.OpenstackDriver, '_get_device', autospec=True)
def test_openstack_driver_ip_family_validation_ipv6_required_ipv4(
self, mock_get_device):
device_info = {}
mock_get_device.return_value = device_info
# Create driver with IPv6 restriction
config = {'SUSHY_EMULATOR_VIRTUAL_MEDIA_IP_FAMILY': '6'}
novadriver = mock.Mock()
with mock.patch('sushy_tools.emulator.memoize.PersistentDict',
return_value={}, autospec=True):
driver = vmedia.OpenstackDriver(config, mock.MagicMock(),
novadriver)
# IPv4 URL should raise error when IPv6 is required
self.assertRaises(error.BadRequest,
driver.insert_image,
self.UUID, 'Cd', 'http://192.168.1.1/image.iso')