From 75e8c60fe29a51c19f05d44f2b244fba8e71f05b Mon Sep 17 00:00:00 2001 From: m0duspwnens Date: Fri, 20 Sep 2024 11:03:16 -0400 Subject: [PATCH] add tools to set dhcp/static ip inside the qcow2 image --- salt/_modules/qcow2.py | 73 ++++++++++++ salt/hypervisor/init.sls | 5 + ...ify_network.py => so-qcow2-modify-network} | 75 ++++-------- salt/libvirt/packages.sls | 2 +- salt/manager/tools/sbin/so-salt-cloud | 111 +++++++++++++++--- 5 files changed, 196 insertions(+), 70 deletions(-) create mode 100644 salt/_modules/qcow2.py rename salt/hypervisor/tools/sbin/{modify_network.py => so-qcow2-modify-network} (72%) diff --git a/salt/_modules/qcow2.py b/salt/_modules/qcow2.py new file mode 100644 index 000000000..56ccb36a0 --- /dev/null +++ b/salt/_modules/qcow2.py @@ -0,0 +1,73 @@ +#!py + +# Copyright Security Onion Solutions LLC and/or licensed to Security Onion Solutions LLC under one +# or more contributor license agreements. Licensed under the Elastic License 2.0 as shown at +# https://securityonion.net/license; you may not use this file except in compliance with the +# Elastic License 2.0. + +import logging +import subprocess +import shlex + +log = logging.getLogger(__name__) + +__virtualname__ = 'qcow2' + +def __virtual__(): + return __virtualname__ + +def modify_network_config(image, interface, mode, ip4=None, gw4=None, dns4=None, search4=None): + ''' + Wrapper function to call so-qcow2-modify-network + + :param image: Path to the QCOW2 image. + :param interface: Network interface to modify (e.g., 'eth0'). + :param mode: 'dhcp4' or 'static4'. + :param ip4: IPv4 address with CIDR notation (e.g., '192.168.1.100/24'). Required for static configuration. + :param gw4: IPv4 gateway (e.g., '192.168.1.1'). Required for static configuration. + :param dns4: Comma-separated list of IPv4 DNS servers (e.g., '8.8.8.8,8.8.4.4'). + :param search4: DNS search domain for IPv4. + + :return: A dictionary with the result of the script execution. + + CLI Example: + + .. code-block:: bash + + salt '*' qcow2.modify_network_config image='/path/to/image.qcow2' interface='eth0' mode='static4' ip4='192.168.1.100/24' gw4='192.168.1.1' dns4='8.8.8.8,8.8.4.4' search4='example.com' + + ''' + + cmd = ['/usr/sbin/so-qcow2-modify-network.py', '-I', image, '-i', interface] + + if mode.lower() == 'dhcp4': + cmd.append('--dhcp4') + elif mode.lower() == 'static4': + cmd.append('--static4') + if not ip4 or not gw4: + raise ValueError('Both ip4 and gw4 are required for static configuration.') + cmd.extend(['--ip4', ip4, '--gw4', gw4]) + if dns4: + cmd.extend(['--dns4', dns4]) + if search4: + cmd.extend(['--search4', search4]) + else: + raise ValueError("Invalid mode '{}'. Expected 'dhcp4' or 'static4'.".format(mode)) + + log.info('qcow2 module: Executing command: {}'.format(' '.join(shlex.quote(arg) for arg in cmd))) + + try: + result = subprocess.run(cmd, capture_output=True, text=True, check=False) + ret = { + 'retcode': result.returncode, + 'stdout': result.stdout, + 'stderr': result.stderr + } + if result.returncode != 0: + log.error('qcow2 module: Script execution failed with return code {}: {}'.format(result.returncode, result.stderr)) + else: + log.info('qcow2 module: Script executed successfully.') + return ret + except Exception as e: + log.error('qcow2 module: An error occurred while executing the script: {}'.format(e)) + raise diff --git a/salt/hypervisor/init.sls b/salt/hypervisor/init.sls index e69de29bb..3fcd33934 100644 --- a/salt/hypervisor/init.sls +++ b/salt/hypervisor/init.sls @@ -0,0 +1,5 @@ +hypervisor_sbin: + file.recurse: + - name: /usr/sbin + - source: salt://hypervisor/tools/sbin + - file_mode: 744 diff --git a/salt/hypervisor/tools/sbin/modify_network.py b/salt/hypervisor/tools/sbin/so-qcow2-modify-network similarity index 72% rename from salt/hypervisor/tools/sbin/modify_network.py rename to salt/hypervisor/tools/sbin/so-qcow2-modify-network index de493e75c..a520871d1 100644 --- a/salt/hypervisor/tools/sbin/modify_network.py +++ b/salt/hypervisor/tools/sbin/so-qcow2-modify-network @@ -1,5 +1,10 @@ #!/usr/bin/python3 +# Copyright Security Onion Solutions LLC and/or licensed to Security Onion Solutions LLC under one +# or more contributor license agreements. Licensed under the Elastic License 2.0 as shown at +# https://securityonion.net/license; you may not use this file except in compliance with the +# Elastic License 2.0. + import argparse import guestfs import re @@ -16,55 +21,39 @@ logger = logging.getLogger(__name__) NETWORK_CONFIG_DIR = "/etc/NetworkManager/system-connections" def validate_ip_address(ip_str, description="IP address"): - """ - Validates that the given string is a properly formatted IPv4 address or IPv4 interface. - """ try: - # Try to parse as IPv4 interface (e.g., "192.168.1.10/24") ipaddress.IPv4Interface(ip_str) except ValueError: try: - # Try to parse as IPv4 address (e.g., "192.168.1.10") ipaddress.IPv4Address(ip_str) except ValueError: raise ValueError(f"Invalid {description}: {ip_str}") def validate_dns_addresses(dns_str): - """ - Validates a comma-separated list of DNS server IP addresses. - """ dns_list = dns_str.split(',') for dns in dns_list: dns = dns.strip() validate_ip_address(dns, description="DNS server address") def validate_interface_name(interface_name): - """ - Validates that the network interface name contains only valid characters. - """ if not re.match(r'^[a-zA-Z0-9_\-]+$', interface_name): raise ValueError(f"Invalid interface name: {interface_name}") def update_ipv4_section(content, mode, ip=None, gateway=None, dns=None, search_domain=None): - """ - Updates the IPv4 section of the network configuration file with either DHCP or static settings. - """ config = configparser.ConfigParser(strict=False) - config.optionxform = str # Preserve case sensitivity + config.optionxform = str config.read_string(content) if 'ipv4' not in config.sections(): - # Handle missing [ipv4] section gracefully config.add_section('ipv4') - if mode == "dhcp": + if mode == "dhcp4": config.set('ipv4', 'method', 'auto') - # Remove static addresses, DNS settings, and search domains config.remove_option('ipv4', 'address1') config.remove_option('ipv4', 'addresses') config.remove_option('ipv4', 'dns') config.remove_option('ipv4', 'dns-search') - elif mode == "static": + elif mode == "static4": config.set('ipv4', 'method', 'manual') if ip and gateway: config.set('ipv4', 'address1', f"{ip},{gateway}") @@ -79,9 +68,8 @@ def update_ipv4_section(content, mode, ip=None, gateway=None, dns=None, search_d else: config.remove_option('ipv4', 'dns-search') else: - raise ValueError(f"Invalid mode '{mode}'. Expected 'dhcp' or 'static'.") + raise ValueError(f"Invalid mode '{mode}'. Expected 'dhcp4' or 'static4'.") - # Write the updated content back to a string output = StringIO() config.write(output, space_around_delimiters=False) updated_content = output.getvalue() @@ -89,29 +77,21 @@ def update_ipv4_section(content, mode, ip=None, gateway=None, dns=None, search_d return updated_content +# modify the network config file for the interface inside the qcow2 image def modify_network_config(image_path, interface, mode, ip=None, gateway=None, dns=None, search_domain=None): - """ - Modifies the network configuration file for the given interface inside the QCOW2 image. - """ - # Check for write permissions to the image file if not os.access(image_path, os.W_OK): raise PermissionError(f"Write permission denied for image file: {image_path}") - # Initialize the guestfs instance and add the image g = guestfs.GuestFS(python_return_dict=True) try: - g.set_network(False) # Disable network access if not needed - - # Disable SELinux relabeling - g.selinux = False # Correct way to disable SELinux relabeling - + g.set_network(False) + g.selinux = False g.add_drive_opts(image_path, format="qcow2") g.launch() except RuntimeError as e: raise RuntimeError(f"Failed to initialize GuestFS or launch appliance: {e}") try: - # Detect and mount the operating system os_list = g.inspect_os() if not os_list: raise RuntimeError(f"Unable to find any OS in {image_path}.") @@ -122,14 +102,11 @@ def modify_network_config(image_path, interface, mode, ip=None, gateway=None, dn except RuntimeError as e: raise RuntimeError(f"Failed to mount the filesystem: {e}") - # Check if NetworkManager configuration directory exists if not g.is_dir(NETWORK_CONFIG_DIR): raise FileNotFoundError(f"NetworkManager configuration directory not found in the image at {NETWORK_CONFIG_DIR}.") - # Path to the network configuration file for the given interface config_file_path = f"{NETWORK_CONFIG_DIR}/{interface}.nmconnection" - # Read the current configuration file try: file_content = g.read_file(config_file_path) current_content = file_content.decode('utf-8') @@ -138,16 +115,14 @@ def modify_network_config(image_path, interface, mode, ip=None, gateway=None, dn except UnicodeDecodeError: raise ValueError(f"Failed to decode the configuration file for {interface}.") - # Update the content based on the provided arguments updated_content = update_ipv4_section(current_content, mode, ip, gateway, dns, search_domain) - # Write the updated content back to the configuration file try: g.write(config_file_path, updated_content.encode('utf-8')) except RuntimeError as e: raise IOError(f"Failed to write updated configuration to {config_file_path}: {e}") - logger.info(f"Updated {interface} network configuration in {image_path} using {mode.upper()} mode.") + logger.info(f"so-qcow2-modify-network: Updated {interface} network configuration in {image_path} using {mode.upper()} mode.") except Exception as e: raise e @@ -156,14 +131,11 @@ def modify_network_config(image_path, interface, mode, ip=None, gateway=None, dn g.close() def parse_arguments(): - """ - Parses command-line arguments for the script. - """ parser = argparse.ArgumentParser(description="Modify IPv4 settings in a QCOW2 image for a specified network interface.") parser.add_argument("-I", "--image", required=True, help="Path to the QCOW2 image.") parser.add_argument("-i", "--interface", required=True, help="Network interface to modify (e.g., eth0).") group = parser.add_mutually_exclusive_group(required=True) - group.add_argument("--dhcp", action="store_true", help="Configure interface for DHCP (IPv4).") + group.add_argument("--dhcp4", action="store_true", help="Configure interface for DHCP (IPv4).") group.add_argument("--static4", action="store_true", help="Configure interface for static IPv4 settings.") parser.add_argument("--ip4", help="IPv4 address (e.g., 192.168.1.10/24). Required for static IPv4 configuration.") parser.add_argument("--gw4", help="IPv4 gateway (e.g., 192.168.1.1). Required for static IPv4 configuration.") @@ -172,44 +144,37 @@ def parse_arguments(): args = parser.parse_args() - # Validate arguments if args.static4: if not args.ip4 or not args.gw4: parser.error("Both --ip4 and --gw4 are required for static IPv4 configuration.") return args def main(): - """ - Main entry point for the script. - """ try: args = parse_arguments() validate_interface_name(args.interface) - # Validate mode - if args.dhcp: - mode = "dhcp" + if args.dhcp4: + mode = "dhcp4" elif args.static4: - mode = "static" + mode = "static4" if not args.ip4 or not args.gw4: raise ValueError("Both --ip4 and --gw4 are required for static IPv4 configuration.") - # Validate IP addresses validate_ip_address(args.ip4, description="IPv4 address") validate_ip_address(args.gw4, description="IPv4 gateway") if args.dns4: validate_dns_addresses(args.dns4) else: - raise ValueError("Either --dhcp or --static4 must be specified.") + raise ValueError("Either --dhcp4 or --static4 must be specified.") - # Modify the network configuration inside the image modify_network_config(args.image, args.interface, mode, args.ip4, args.gw4, args.dns4, args.search4) except KeyboardInterrupt: - logger.error("Operation cancelled by user.") + logger.error("so-qcow2-modify-network: Operation cancelled by user.") sys.exit(1) except Exception as e: - logger.error(f"An error occurred: {e}") + logger.error(f"so-qcow2-modify-network: An error occurred: {e}") sys.exit(1) if __name__ == "__main__": diff --git a/salt/libvirt/packages.sls b/salt/libvirt/packages.sls index 9b56c6f64..c79f42dd8 100644 --- a/salt/libvirt/packages.sls +++ b/salt/libvirt/packages.sls @@ -39,6 +39,6 @@ libvirt_python_wheel: libvirt_python_module: cmd.run: - - name: /opt/saltstack/salt/bin/python3.10 -m pip install --no-index --find-links=/opt/so/conf/libvirt/source-packages/libvirt-python libvirt-python + - name: /opt/saltstack/salt/bin/python3 -m pip install --no-index --find-links=/opt/so/conf/libvirt/source-packages/libvirt-python libvirt-python - onchanges: - file: libvirt_python_wheel diff --git a/salt/manager/tools/sbin/so-salt-cloud b/salt/manager/tools/sbin/so-salt-cloud index b34252730..c62519182 100644 --- a/salt/manager/tools/sbin/so-salt-cloud +++ b/salt/manager/tools/sbin/so-salt-cloud @@ -1,4 +1,4 @@ -#!/usr/bin/python3 +#!/opt/saltstack/salt/bin/python3 # Copyright Security Onion Solutions LLC and/or licensed to Security Onion Solutions LLC under one # or more contributor license agreements. Licensed under the Elastic License 2.0 as shown at @@ -8,7 +8,26 @@ import argparse import subprocess import re +import sys import threading +import salt.client + +local = salt.client.LocalClient() + +import logging + +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) + +file_handler = logging.FileHandler('/opt/so/log/salt/so-salt-cloud.log') +console_handler = logging.StreamHandler() + +formatter = logging.Formatter('%(asctime)s %(message)s') +file_handler.setFormatter(formatter) +console_handler.setFormatter(formatter) + +logger.addHandler(file_handler) +logger.addHandler(console_handler) def call_so_firewall_minion(ip, role): try: @@ -20,8 +39,16 @@ def call_so_firewall_minion(ip, role): text=True ) + # Read and log the output + for line in iter(process.stdout.readline, ''): + if line: + logger.info(line.rstrip('\n')) + + process.stdout.close() + process.wait() + except Exception as e: - print(f"An error occurred while calling the command: {e}") + logger.error(f"An error occurred while calling so-firewall-minion: {e}") def call_salt_cloud(profile, vm_name): try: @@ -38,38 +65,94 @@ def call_salt_cloud(profile, vm_name): ip_search_string = '[INFO ] Address =' ip_search_pattern = re.compile(re.escape(ip_search_string)) - # Continuously read the output + # continuously read the output from salt-cloud while True: # Read stdout line by line line = process.stdout.readline() if line: - print(line.rstrip('\n')) + logger.info(line.rstrip('\n')) if ip_search_pattern.search(line): parts = line.split("Address =") if len(parts) > 1: ip_address = parts[1].strip() - print("Extracted IP address:", ip_address) + logger.info(f"Extracted IP address: {ip_address}") # Create and start a thread to run so-firewall-minion - thread = threading.Thread(target=call_so_firewall_minion, args=(ip_address,role.upper())) + thread = threading.Thread(target=call_so_firewall_minion, args=(ip_address, role.upper())) thread.start() else: - print("No IP address found.") + logger.error("No IP address found.") + else: + # check if salt-cloud has terminated + if process.poll() is not None: + break - # Check if the process has terminated - elif process.poll() is not None: - # process finished - break + process.stdout.close() + process.wait() except Exception as e: - print(f"An error occurred while calling the command: {e}") + logger.error(f"An error occurred while calling salt-cloud: {e}") -if __name__ == "__main__": +# This function requires the cloud profile to be in the form: basedomain_hypervisorhostname. The profile name will be used to target the hypervisor. +def run_qcow2_modify_network_config(profile, mode, ip=None, gateway=None, dns=None, search_domain=None): + hv_name = profile.split('-')[1] + target = hv_name + "_*" + image = '/var/lib/libvirt/images/coreol9/coreol9.qcow2.MODIFIED' + interface = 'eth0' + try: + r = local.cmd(target, 'qcow2.modify_network_config', [ + 'image=' + image, + 'interface=' + interface, + 'mode=' + mode, + 'ip4=' + ip if ip else '', + 'gw4=' + gateway if gateway else '', + 'dns4=' + dns if dns else '', + 'search4=' + search_domain if search_domain else '' + ]) + logger.info(f'qcow2.modify_network_config: {r}') + except Exception as e: + logger.error(f"An error occurred while running qcow2.modify_network_config: {e}") + +def parse_arguments(): parser = argparse.ArgumentParser(description="Call salt-cloud and pass the profile and VM name to it.") parser.add_argument('-p', '--profile', type=str, required=True, help="The cloud profile to build the VM from.") parser.add_argument('vm_name', type=str, help="The name of the VM.") + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--dhcp4", action="store_true", help="Configure interface for DHCP (IPv4).") + group.add_argument("--static4", action="store_true", help="Configure interface for static IPv4 settings.") + parser.add_argument("--ip4", help="IPv4 address (e.g., 192.168.1.10/24). Required for static IPv4 configuration.") + parser.add_argument("--gw4", help="IPv4 gateway (e.g., 192.168.1.1). Required for static IPv4 configuration.") + parser.add_argument("--dns4", help="Comma-separated list of IPv4 DNS servers (e.g., 8.8.8.8,8.8.4.4).") + parser.add_argument("--search4", help="DNS search domain for IPv4.") args = parser.parse_args() - call_salt_cloud(args.profile, args.vm_name) + if args.static4: + if not args.ip4 or not args.gw4: + parser.error("Both --ip4 and --gw4 are required for static IPv4 configuration.") + return args + +def main(): + try: + args = parse_arguments() + + if args.dhcp4: + mode = "dhcp4" + elif args.static4: + mode = "static4" + else: + mode = "dhcp4" # Default to DHCP if not specified + + run_qcow2_modify_network_config(args.profile, mode, args.ip4, args.gw4, args.dns4, args.search4) + call_salt_cloud(args.profile, args.vm_name) + + except KeyboardInterrupt: + logger.error("so-salt-cloud: Operation cancelled by user.") + sys.exit(1) + except Exception as e: + logger.error(f"so-salt-cloud: An error occurred: {e}") + sys.exit(1) + +if __name__ == "__main__": + main()