diff --git a/salt/_modules/qcow2.py b/salt/_modules/qcow2.py index d1de6756a..81a00ca66 100644 --- a/salt/_modules/qcow2.py +++ b/salt/_modules/qcow2.py @@ -38,7 +38,7 @@ def modify_network_config(image, interface, mode, ip4=None, gw4=None, dns4=None, image Path to the QCOW2 image file that will be modified interface - Network interface name to configure (e.g., 'eth0') + Network interface name to configure (e.g., 'enp1s0') mode Network configuration mode, either 'dhcp4' or 'static4' ip4 @@ -57,13 +57,13 @@ def modify_network_config(image, interface, mode, ip4=None, gw4=None, dns4=None, Examples: 1. **Configure DHCP:** ```bash - salt '*' qcow2.modify_network_config image='/nsm/libvirt/images/sool9/sool9.qcow2' interface='eth0' mode='dhcp4' + salt '*' qcow2.modify_network_config image='/nsm/libvirt/images/sool9/sool9.qcow2' interface='enp1s0' mode='dhcp4' ``` - This configures eth0 to use DHCP for IP assignment + This configures enp1s0 to use DHCP for IP assignment 2. **Configure Static IP:** ```bash - salt '*' qcow2.modify_network_config image='/nsm/libvirt/images/sool9/sool9.qcow2' interface='eth0' mode='static4' ip4='192.168.1.10/24' gw4='192.168.1.1' dns4='192.168.1.1,8.8.8.8' search4='example.local' + salt '*' qcow2.modify_network_config image='/nsm/libvirt/images/sool9/sool9.qcow2' interface='enp1s0' mode='static4' ip4='192.168.1.10/24' gw4='192.168.1.1' dns4='192.168.1.1,8.8.8.8' search4='example.local' ``` This sets a static IP configuration with DNS servers and search domain diff --git a/salt/hypervisor/tools/sbin/so-qcow2-modify-network b/salt/hypervisor/tools/sbin/so-qcow2-modify-network index 5c0a9e9b7..5f0690542 100644 --- a/salt/hypervisor/tools/sbin/so-qcow2-modify-network +++ b/salt/hypervisor/tools/sbin/so-qcow2-modify-network @@ -14,6 +14,13 @@ The script offers two main configuration modes: 1. DHCP Configuration: Enable automatic IP address assignment 2. Static IP Configuration: Set specific IP address, gateway, DNS servers, and search domains +For both configuration modes, the script automatically sets the following NetworkManager connection properties: +- connection.autoconnect: yes (ensures interface connects automatically) +- connection.autoconnect-priority: 999 (sets connection priority) +- connection.autoconnect-retries: -1 (unlimited connection retries) +- connection.multi-connect: 0 (single connection mode) +- connection.wait-device-timeout: -1 (wait indefinitely for device) + This script is designed to work with Security Onion's virtualization infrastructure and is typically used during VM provisioning and network reconfiguration tasks. @@ -23,7 +30,7 @@ used during VM provisioning and network reconfiguration tasks. **Options:** -I, --image Path to the QCOW2 image. - -i, --interface Network interface to modify (e.g., eth0). + -i, --interface Network interface to modify (e.g., enp1s0). --dhcp4 Configure interface for DHCP (IPv4). --static4 Configure interface for static IPv4 settings. --ip4 IPv4 address (e.g., 192.168.1.10/24). Required for static IPv4 configuration. @@ -36,7 +43,7 @@ used during VM provisioning and network reconfiguration tasks. 1. **Static IP Configuration with DNS and Search Domain:** ```bash - so-qcow2-modify-network -I /nsm/libvirt/images/sool9/sool9.qcow2 -i eth0 --static4 \ + so-qcow2-modify-network -I /nsm/libvirt/images/sool9/sool9.qcow2 -i enp1s0 --static4 \ --ip4 192.168.1.10/24 --gw4 192.168.1.1 --dns4 192.168.1.1,192.168.1.2 --search4 example.local ``` @@ -50,7 +57,7 @@ used during VM provisioning and network reconfiguration tasks. 2. **DHCP Configuration:** ```bash - so-qcow2-modify-network -I /nsm/libvirt/images/sool9/sool9.qcow2 -i eth0 --dhcp4 + so-qcow2-modify-network -I /nsm/libvirt/images/sool9/sool9.qcow2 -i enp1s0 --dhcp4 ``` This command configures the network interface to use DHCP for automatic IP address assignment. @@ -58,7 +65,7 @@ used during VM provisioning and network reconfiguration tasks. 3. **Static IP Configuration without DNS Settings:** ```bash - so-qcow2-modify-network -I /nsm/libvirt/images/sool9/sool9.qcow2 -i eth0 --static4 \ + so-qcow2-modify-network -I /nsm/libvirt/images/sool9/sool9.qcow2 -i enp1s0 --static4 \ --ip4 192.168.1.20/24 --gw4 192.168.1.1 ``` @@ -122,7 +129,9 @@ import logging import os import ipaddress import configparser +import uuid from io import StringIO +import libvirt from so_logging_utils import setup_logging # Set up logging using the so_logging_utils library @@ -154,33 +163,91 @@ def validate_interface_name(interface_name): 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): +def check_base_domain_status(image_path): + """ + Check if the base domain corresponding to the image path is currently running. + Base domains should not be running when modifying their configuration. + + Parameters: + image_path (str): Path to the QCOW2 image. + + Returns: + bool: True if the base domain is running, False otherwise. + """ + base_domain = os.path.basename(os.path.dirname(image_path)) + logger.info(f"Verifying base domain status for image: {image_path}") + logger.info(f"Checking if base domain '{base_domain}' is running...") + + try: + conn = libvirt.open('qemu:///system') + try: + dom = conn.lookupByName(base_domain) + is_running = dom.isActive() + if is_running: + logger.error(f"Base domain '{base_domain}' is running - cannot modify configuration") + return is_running + except libvirt.libvirtError: + logger.info(f"Base domain '{base_domain}' not found or not running") + return False + finally: + conn.close() + except libvirt.libvirtError as e: + logger.error(f"Failed to connect to libvirt: {e}") + return False + +def update_network_config(content, mode, ip=None, gateway=None, dns=None, search_domain=None): config = configparser.ConfigParser(strict=False) config.optionxform = str config.read_string(content) + # Ensure connection section exists and set required properties + if 'connection' not in config.sections(): + logger.info("Creating new connection section in network configuration") + config.add_section('connection') + + # Set mandatory connection properties + config.set('connection', 'autoconnect', 'yes') + config.set('connection', 'autoconnect-priority', '999') + config.set('connection', 'autoconnect-retries', '-1') + config.set('connection', 'multi-connect', '0') + config.set('connection', 'wait-device-timeout', '-1') + + # Ensure ipv4 section exists if 'ipv4' not in config.sections(): + logger.info("Creating new IPv4 section in network configuration") config.add_section('ipv4') if mode == "dhcp4": + logger.info("Configuring DHCP settings:") + logger.info(" method: auto (DHCP enabled)") + logger.info(" Removing any existing static configuration") config.set('ipv4', 'method', 'auto') config.remove_option('ipv4', 'address1') config.remove_option('ipv4', 'addresses') config.remove_option('ipv4', 'dns') config.remove_option('ipv4', 'dns-search') elif mode == "static4": + logger.info("Configuring static IP settings:") + logger.info(" method: manual (static configuration)") config.set('ipv4', 'method', 'manual') if ip and gateway: + logger.info(f" Setting address: {ip}") + logger.info(f" Setting gateway: {gateway}") config.set('ipv4', 'address1', f"{ip},{gateway}") else: + logger.error("Missing required IP address or gateway for static configuration") raise ValueError("Both IP address and gateway are required for static configuration.") if dns: + logger.info(f" Setting DNS servers: {dns}") config.set('ipv4', 'dns', f"{dns};") else: + logger.info(" No DNS servers specified") config.remove_option('ipv4', 'dns') if search_domain: + logger.info(f" Setting search domain: {search_domain}") config.set('ipv4', 'dns-search', f"{search_domain};") else: + logger.info(" No search domain specified") config.remove_option('ipv4', 'dns-search') else: raise ValueError(f"Invalid mode '{mode}'. Expected 'dhcp4' or 'static4'.") @@ -193,50 +260,138 @@ def update_ipv4_section(content, mode, ip=None, gateway=None, dns=None, search_d return updated_content def modify_network_config(image_path, interface, mode, ip=None, gateway=None, dns=None, search_domain=None): + """ + Modifies network configuration in a QCOW2 image, ensuring specific connection settings are set. + + Handles both eth0 and predictable network interface names (e.g., enp1s0). + If the requested interface configuration is not found but eth0.nmconnection exists, + it will be renamed and updated with the proper interface configuration. + """ + # Check if base domain is running + if check_base_domain_status(image_path): + raise RuntimeError("Cannot modify network configuration while base domain is running") + if not os.access(image_path, os.W_OK): + logger.error(f"Permission denied: Cannot write to image file {image_path}") raise PermissionError(f"Write permission denied for image file: {image_path}") + logger.info(f"Configuring network for VM image: {image_path}") + logger.info(f"Network configuration details for interface {interface}:") + logger.info(f" Mode: {mode.upper()}") + if mode == "static4": + logger.info(f" IP Address: {ip}") + logger.info(f" Gateway: {gateway}") + logger.info(f" DNS Servers: {dns if dns else 'Not configured'}") + logger.info(f" Search Domain: {search_domain if search_domain else 'Not configured'}") + g = guestfs.GuestFS(python_return_dict=True) try: + logger.info("Initializing GuestFS and mounting image...") g.set_network(False) g.selinux = False g.add_drive_opts(image_path, format="qcow2") g.launch() except RuntimeError as e: + logger.error(f"Failed to initialize GuestFS: {e}") raise RuntimeError(f"Failed to initialize GuestFS or launch appliance: {e}") try: os_list = g.inspect_os() if not os_list: + logger.error(f"No operating system found in image: {image_path}") raise RuntimeError(f"Unable to find any OS in {image_path}.") root_fs = os_list[0] try: g.mount(root_fs, "/") + logger.info("Successfully mounted VM image filesystem") except RuntimeError as e: + logger.error(f"Failed to mount filesystem: {e}") raise RuntimeError(f"Failed to mount the filesystem: {e}") if not g.is_dir(NETWORK_CONFIG_DIR): + logger.error(f"NetworkManager configuration directory not found: {NETWORK_CONFIG_DIR}") raise FileNotFoundError(f"NetworkManager configuration directory not found in the image at {NETWORK_CONFIG_DIR}.") - config_file_path = f"{NETWORK_CONFIG_DIR}/{interface}.nmconnection" + requested_config_path = f"{NETWORK_CONFIG_DIR}/{interface}.nmconnection" + eth0_config_path = f"{NETWORK_CONFIG_DIR}/eth0.nmconnection" + config_file_path = None + current_content = None + # Try to read the requested interface config first try: - file_content = g.read_file(config_file_path) + file_content = g.read_file(requested_config_path) current_content = file_content.decode('utf-8') + config_file_path = requested_config_path + logger.info(f"Found existing network configuration for interface {interface}") except RuntimeError: - raise FileNotFoundError(f"Configuration file for {interface} not found at {config_file_path}.") + # If not found, try eth0 config + try: + file_content = g.read_file(eth0_config_path) + current_content = file_content.decode('utf-8') + config_file_path = eth0_config_path + logger.info("Found eth0 network configuration, will update for new interface") + except RuntimeError: + logger.error(f"No network configuration found for either {interface} or eth0") + raise FileNotFoundError(f"No network configuration found at {requested_config_path} or {eth0_config_path}") except UnicodeDecodeError: - raise ValueError(f"Failed to decode the configuration file for {interface}.") + logger.error(f"Failed to decode network configuration file") + raise ValueError(f"Failed to decode the configuration file") - updated_content = update_ipv4_section(current_content, mode, ip, gateway, dns, search_domain) + # If using eth0 config, update interface-specific fields + if config_file_path == eth0_config_path: + config = configparser.ConfigParser(strict=False) + config.optionxform = str + config.read_string(current_content) + + if 'connection' not in config.sections(): + config.add_section('connection') + + # Update interface-specific fields + config.set('connection', 'id', interface) + config.set('connection', 'interface-name', interface) + config.set('connection', 'uuid', str(uuid.uuid4())) + + # Write updated content back to string + output = StringIO() + config.write(output, space_around_delimiters=False) + current_content = output.getvalue() + output.close() + + # Update config file path to new interface name + config_file_path = requested_config_path + + logger.info("Applying network configuration changes...") + updated_content = update_network_config(current_content, mode, ip, gateway, dns, search_domain) try: g.write(config_file_path, updated_content.encode('utf-8')) + # Set proper permissions (600) on the network configuration file + g.chmod(0o600, config_file_path) + logger.info("Successfully wrote updated network configuration with proper permissions (600)") + + # If we renamed eth0 to the new interface, remove the old eth0 config + if config_file_path == requested_config_path and eth0_config_path != requested_config_path: + try: + g.rm(eth0_config_path) + logger.info("Removed old eth0 configuration file") + except RuntimeError: + logger.warning("Could not remove old eth0 configuration file - it may have already been removed") + except RuntimeError as e: + logger.error(f"Failed to write network configuration: {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"Successfully updated network configuration:") + logger.info(f" Image: {image_path}") + logger.info(f" Interface: {interface}") + logger.info(f" Mode: {mode.upper()}") + if mode == "static4": + logger.info(f" Settings applied:") + logger.info(f" IP Address: {ip}") + logger.info(f" Gateway: {gateway}") + logger.info(f" DNS Servers: {dns if dns else 'Not configured'}") + logger.info(f" Search Domain: {search_domain if search_domain else 'Not configured'}") except Exception as e: raise e @@ -247,7 +402,7 @@ def modify_network_config(image_path, interface, mode, ip=None, gateway=None, dn def parse_arguments(): 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).") + parser.add_argument("-i", "--interface", required=True, help="Network interface to modify (e.g., enp1s0).") 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.") @@ -265,30 +420,42 @@ def parse_arguments(): def main(): try: + logger.info("Starting network configuration update...") args = parse_arguments() + logger.info("Validating interface name...") validate_interface_name(args.interface) if args.dhcp4: mode = "dhcp4" + logger.info("Using DHCP configuration mode") elif args.static4: mode = "static4" + logger.info("Using static IP configuration mode") if not args.ip4 or not args.gw4: + logger.error("Missing required parameters for static configuration") raise ValueError("Both --ip4 and --gw4 are required for static IPv4 configuration.") + + logger.info("Validating 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: + logger.error("No configuration mode specified") raise ValueError("Either --dhcp4 or --static4 must be specified.") modify_network_config(args.image, args.interface, mode, args.ip4, args.gw4, args.dns4, args.search4) + logger.info("Network configuration update completed successfully") except KeyboardInterrupt: logger.error("Operation cancelled by user.") sys.exit(1) except Exception as e: - logger.error(f"An error occurred: {e}") + if "base domain is running" in str(e): + logger.error("Cannot proceed: Base domain must not be running when modifying network configuration") + else: + logger.error(f"An error occurred: {e}") sys.exit(1) if __name__ == "__main__": diff --git a/salt/hypervisor/tools/sbin/so-qcow2-network-predictable b/salt/hypervisor/tools/sbin/so-qcow2-network-predictable new file mode 100644 index 000000000..9b09465b9 --- /dev/null +++ b/salt/hypervisor/tools/sbin/so-qcow2-network-predictable @@ -0,0 +1,378 @@ +#!/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. + +""" +Script for configuring network interface predictability in Security Onion VMs. +This script modifies the necessary files to ensure consistent network interface naming. + +The script performs the following operations: +1. Modifies the BLS entry to set net.ifnames=1 +2. Removes any existing persistent network rules +3. Updates GRUB configuration + +**Usage:** + so-qcow2-network-predictable -n [-I ] + +**Options:** + -n, --name Domain name of the VM to configure + -I, --image (Optional) Path to the QCOW2 image. If not provided, + defaults to /nsm/libvirt/images//.qcow2 + +**Examples:** + +1. **Configure using domain name:** + ```bash + so-qcow2-network-predictable -n sool9 + ``` + This command will: + - Use default image path: /nsm/libvirt/images/sool9/sool9.qcow2 + - Configure network interface predictability + +2. **Configure using custom image path:** + ```bash + so-qcow2-network-predictable -n sool9 -I /path/to/custom/image.qcow2 + ``` + This command will: + - Use the specified image path + - Configure network interface predictability + +**Notes:** +- The VM must not be running when executing this script +- Requires root privileges +- Will automatically find and modify the appropriate BLS entry +- Removes /etc/udev/rules.d/70-persistent-net.rules if it exists +- Updates GRUB configuration after changes + +**Exit Codes:** +- 0: Success +- 1: General error (invalid arguments, file operations, etc.) +- 2: VM is running +- 3: Required files not found +- 4: Permission denied + +**Logging:** +- Logs are written to /opt/so/log/hypervisor/so-qcow2-network-predictable.log +- Both file and console logging are enabled +- Log entries include: + - Timestamps + - Operation details + - Error messages + - Configuration changes +""" + +import argparse +import guestfs +import glob +import libvirt +import logging +import os +import re +import sys +from so_logging_utils import setup_logging + +# Set up logging +logger = setup_logging( + logger_name='so-qcow2-network-predictable', + log_file_path='/opt/so/log/hypervisor/so-qcow2-network-predictable.log', + log_level=logging.INFO, + format_str='%(asctime)s - %(levelname)s - %(message)s' +) + +def check_domain_status(domain_name): + """ + Check if the specified domain exists and is not running. + + Args: + domain_name (str): Name of the libvirt domain to check + + Returns: + bool: True if domain exists and is not running, False otherwise + + Raises: + RuntimeError: If domain is running or connection to libvirt fails + """ + try: + conn = libvirt.open('qemu:///system') + try: + dom = conn.lookupByName(domain_name) + is_running = dom.isActive() + if is_running: + logger.error(f"Domain '{domain_name}' is running - cannot modify configuration") + raise RuntimeError(f"Domain '{domain_name}' must not be running") + logger.info(f"Domain '{domain_name}' exists and is not running") + return True + except libvirt.libvirtError as e: + if "no domain with matching name" in str(e): + logger.error(f"Domain '{domain_name}' not found") + raise RuntimeError(f"Domain '{domain_name}' not found") + raise + finally: + conn.close() + except libvirt.libvirtError as e: + logger.error(f"Failed to connect to libvirt: {e}") + raise RuntimeError(f"Failed to connect to libvirt: {e}") + +def modify_bls_entry(g): + """ + Find and modify the BLS entry to set net.ifnames=1. + + Args: + g: Mounted guestfs handle + + Returns: + bool: True if successful, False if no changes needed + + Raises: + RuntimeError: If BLS entry cannot be found or modified + """ + bls_dir = "/boot/loader/entries" + logger.info(f"Checking BLS directory: {bls_dir}") + if g.is_dir(bls_dir): + logger.info("BLS directory exists") + else: + logger.info("Listing /boot contents:") + try: + boot_contents = g.ls("/boot") + logger.info(f"/boot contains: {boot_contents}") + if g.is_dir("/boot/loader"): + logger.info("Listing /boot/loader contents:") + loader_contents = g.ls("/boot/loader") + logger.info(f"/boot/loader contains: {loader_contents}") + except Exception as e: + logger.error(f"Error listing /boot contents: {e}") + raise RuntimeError(f"BLS directory not found: {bls_dir}") + + # Find BLS entry file + entries = g.glob_expand(f"{bls_dir}/*.conf") + logger.info(f"Found BLS entries: {entries}") + if not entries: + logger.error("No BLS entry files found") + raise RuntimeError("No BLS entry files found") + + # Use the first entry found + bls_file = entries[0] + logger.info(f"Found BLS entry file: {bls_file}") + + try: + logger.info(f"Reading BLS file contents from: {bls_file}") + content = g.read_file(bls_file).decode('utf-8') + logger.info("Current BLS file content:") + logger.info("---BEGIN BLS CONTENT---") + logger.info(content) + logger.info("---END BLS CONTENT---") + + lines = content.splitlines() + modified = False + + for i, line in enumerate(lines): + if line.startswith('options '): + logger.info(f"Found options line: {line}") + # Check if net.ifnames parameter exists + if 'net.ifnames=' in line: + # Replace existing parameter + new_line = re.sub(r'net\.ifnames=[01]', 'net.ifnames=1', line) + if new_line != line: + lines[i] = new_line + modified = True + logger.info(f"Updated existing net.ifnames parameter to 1. New line: {new_line}") + else: + # Add parameter + lines[i] = f"{line} net.ifnames=1" + modified = True + logger.info(f"Added net.ifnames=1 parameter. New line: {lines[i]}") + break + + if modified: + new_content = '\n'.join(lines) + '\n' + logger.info("New BLS file content:") + logger.info("---BEGIN NEW BLS CONTENT---") + logger.info(new_content) + logger.info("---END NEW BLS CONTENT---") + g.write(bls_file, new_content.encode('utf-8')) + logger.info("Successfully updated BLS entry") + return True + + logger.info("No changes needed for BLS entry") + return False + + except Exception as e: + logger.error(f"Failed to modify BLS entry: {e}") + raise RuntimeError(f"Failed to modify BLS entry: {e}") + +def remove_persistent_net_rules(g): + """ + Remove the persistent network rules file if it exists. + + Args: + g: Mounted guestfs handle + + Returns: + bool: True if file was removed, False if it didn't exist + """ + rules_file = "/etc/udev/rules.d/70-persistent-net.rules" + logger.info(f"Checking for persistent network rules file: {rules_file}") + try: + if g.is_file(rules_file): + logger.info("Found persistent network rules file, removing...") + g.rm(rules_file) + logger.info(f"Successfully removed persistent network rules file: {rules_file}") + return True + logger.info("No persistent network rules file found") + return False + except Exception as e: + logger.error(f"Failed to remove persistent network rules: {e}") + raise RuntimeError(f"Failed to remove persistent network rules: {e}") + +def update_grub_config(g): + """ + Update GRUB configuration. + + Args: + g: Mounted guestfs handle + + Raises: + RuntimeError: If GRUB update fails + """ + try: + logger.info("Updating GRUB configuration...") + output = g.command(['grub2-mkconfig', '-o', '/boot/grub2/grub.cfg']) + logger.info("GRUB update output:") + logger.info(output) + logger.info("Successfully updated GRUB configuration") + except Exception as e: + logger.error(f"Failed to update GRUB configuration: {e}") + raise RuntimeError(f"Failed to update GRUB configuration: {e}") + +def configure_network_predictability(domain_name, image_path=None): + """ + Configure network interface predictability for a VM. + + Args: + domain_name (str): Name of the domain to configure + image_path (str, optional): Path to the QCOW2 image + + Raises: + RuntimeError: If configuration fails + """ + # Check domain status + check_domain_status(domain_name) + + # Use default image path if none provided + if not image_path: + image_path = f"/nsm/libvirt/images/{domain_name}/{domain_name}.qcow2" + + if not os.path.exists(image_path): + logger.error(f"Image file not found: {image_path}") + raise RuntimeError(f"Image file not found: {image_path}") + + if not os.access(image_path, os.R_OK | os.W_OK): + logger.error(f"Permission denied: Cannot access image file {image_path}") + raise RuntimeError(f"Permission denied: Cannot access image file {image_path}") + + logger.info(f"Configuring network predictability for domain: {domain_name}") + logger.info(f"Using image: {image_path}") + + g = guestfs.GuestFS(python_return_dict=True) + try: + logger.info("Initializing guestfs...") + g.set_network(False) + g.selinux = False + g.add_drive_opts(image_path, format="qcow2") + g.launch() + + logger.info("Inspecting operating system...") + roots = g.inspect_os() + if not roots: + raise RuntimeError("No operating system found in image") + + root = roots[0] + logger.info(f"Found root filesystem: {root}") + logger.info(f"Operating system type: {g.inspect_get_type(root)}") + logger.info(f"Operating system distro: {g.inspect_get_distro(root)}") + logger.info(f"Operating system major version: {g.inspect_get_major_version(root)}") + logger.info(f"Operating system minor version: {g.inspect_get_minor_version(root)}") + + logger.info("Getting mount points...") + mountpoints = g.inspect_get_mountpoints(root) + logger.info(f"Found mount points: {mountpoints}") + logger.info("Converting mount points to sortable list...") + # Convert dictionary to list of tuples + mountpoints = list(mountpoints.items()) + logger.info(f"Converted mount points: {mountpoints}") + logger.info("Sorting mount points by path length for proper mount order...") + mountpoints.sort(key=lambda m: len(m[0])) + logger.info(f"Mount order will be: {[mp[0] for mp in mountpoints]}") + + for mp_path, mp_device in mountpoints: + try: + logger.info(f"Attempting to mount {mp_device} at {mp_path}") + g.mount(mp_device, mp_path) + logger.info(f"Successfully mounted {mp_device} at {mp_path}") + except Exception as e: + logger.warning(f"Could not mount {mp_device} at {mp_path}: {str(e)}") + # Continue with other mounts + + # Perform configuration steps + bls_modified = modify_bls_entry(g) + rules_removed = remove_persistent_net_rules(g) + + if bls_modified or rules_removed: + update_grub_config(g) + logger.info("Network predictability configuration completed successfully") + else: + logger.info("No changes were necessary") + + except Exception as e: + raise RuntimeError(f"Failed to configure network predictability: {e}") + finally: + try: + logger.info("Unmounting all filesystems...") + g.umount_all() + logger.info("Successfully unmounted all filesystems") + except Exception as e: + logger.warning(f"Error unmounting filesystems: {e}") + g.close() + +def parse_arguments(): + """Parse command line arguments.""" + parser = argparse.ArgumentParser( + description="Configure network interface predictability for Security Onion VMs" + ) + parser.add_argument("-n", "--name", required=True, + help="Domain name of the VM to configure") + parser.add_argument("-I", "--image", + help="Path to the QCOW2 image (optional)") + return parser.parse_args() + +def main(): + """Main entry point for the script.""" + try: + args = parse_arguments() + configure_network_predictability(args.name, args.image) + sys.exit(0) + except RuntimeError as e: + if "must not be running" in str(e): + logger.error(str(e)) + sys.exit(2) + elif "not found" in str(e): + logger.error(str(e)) + sys.exit(3) + elif "Permission denied" in str(e): + logger.error(str(e)) + sys.exit(4) + else: + logger.error(str(e)) + sys.exit(1) + except KeyboardInterrupt: + logger.error("Operation cancelled by user") + sys.exit(1) + except Exception as e: + logger.error(f"Unexpected error: {e}") + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/salt/hypervisor/tools/sbin/so-wait-cloud-init b/salt/hypervisor/tools/sbin/so-wait-cloud-init new file mode 100644 index 000000000..322b89208 --- /dev/null +++ b/salt/hypervisor/tools/sbin/so-wait-cloud-init @@ -0,0 +1,206 @@ +#!/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. + +""" +Script for waiting for cloud-init to complete on a Security Onion VM. +Monitors VM state to ensure proper cloud-init initialization and shutdown. + +**Usage:** + so-wait-cloud-init -n + +**Options:** + -n, --name Domain name of the VM to monitor + +**Exit Codes:** +- 0: Success (cloud-init completed and VM shutdown) +- 1: General error +- 2: VM never started +- 3: VM stopped too quickly +- 4: VM failed to shutdown + +**Description:** +This script monitors a VM's state to ensure proper cloud-init initialization and completion: +1. Waits for VM to start running +2. Verifies VM remains running (not an immediate crash) +3. Waits for VM to shutdown (indicating cloud-init completion) +4. Verifies VM remains shutdown + +The script is typically used in the libvirt.images state after creating a new VM +to ensure cloud-init completes its initialization before proceeding with further +configuration. + +**Logging:** +- Logs are written to /opt/so/log/hypervisor/so-wait-cloud-init.log +- Both file and console logging are enabled +- Log entries include: + - Timestamps + - State changes + - Error conditions + - Verification steps +""" + +import argparse +import logging +import subprocess +import sys +import time +from so_logging_utils import setup_logging + +# Set up logging +logger = setup_logging( + logger_name='so-wait-cloud-init', + log_file_path='/opt/so/log/hypervisor/so-wait-cloud-init.log', + log_level=logging.INFO, + format_str='%(asctime)s - %(levelname)s - %(message)s' +) + +def check_vm_running(domain_name): + """ + Check if VM is in running state. + + Args: + domain_name (str): Name of the domain to check + + Returns: + bool: True if VM is running, False otherwise + """ + try: + result = subprocess.run(['virsh', 'list', '--state-running', '--name'], + capture_output=True, text=True, check=True) + return domain_name in result.stdout.splitlines() + except subprocess.CalledProcessError as e: + logger.error(f"Failed to check VM state: {e}") + return False + +def wait_for_vm_start(domain_name, timeout=300): + """ + Wait for VM to start running. + + Args: + domain_name (str): Name of the domain to monitor + timeout (int): Maximum time to wait in seconds + + Returns: + bool: True if VM started, False if timeout occurred + """ + logger.info(f"Waiting for VM {domain_name} to start...") + start_time = time.time() + + while time.time() - start_time < timeout: + if check_vm_running(domain_name): + logger.info("VM is running") + return True + time.sleep(1) + + logger.error(f"Timeout waiting for VM {domain_name} to start") + return False + +def verify_vm_running(domain_name): + """ + Verify VM remains running after initial start. + + Args: + domain_name (str): Name of the domain to verify + + Returns: + bool: True if VM is still running after verification period + """ + logger.info("Verifying VM remains running...") + time.sleep(5) # Wait to ensure VM is stable + + if not check_vm_running(domain_name): + logger.error("VM stopped too quickly after starting") + return False + + logger.info("VM verified running") + return True + +def wait_for_vm_shutdown(domain_name, timeout=600): + """ + Wait for VM to shutdown. + + Args: + domain_name (str): Name of the domain to monitor + timeout (int): Maximum time to wait in seconds + + Returns: + bool: True if VM shutdown, False if timeout occurred + """ + logger.info("Waiting for cloud-init to complete and VM to shutdown...") + start_time = time.time() + check_count = 0 + + while time.time() - start_time < timeout: + if not check_vm_running(domain_name): + logger.info("VM has shutdown") + return True + + # Log status every minute (after 12 checks at 5 second intervals) + check_count += 1 + if check_count % 12 == 0: + elapsed = int(time.time() - start_time) + logger.info(f"Still waiting for cloud-init... ({elapsed} seconds elapsed)") + + time.sleep(5) + + logger.error(f"Timeout waiting for VM {domain_name} to shutdown") + return False + +def verify_vm_shutdown(domain_name): + """ + Verify VM remains shutdown. + + Args: + domain_name (str): Name of the domain to verify + + Returns: + bool: True if VM remains shutdown after verification period + """ + logger.info("Verifying VM remains shutdown...") + time.sleep(5) # Wait to ensure VM state is stable + + if check_vm_running(domain_name): + logger.error("VM is still running after shutdown check") + return False + + logger.info("VM verified shutdown") + return True + +def main(): + parser = argparse.ArgumentParser( + description="Wait for cloud-init to complete on a Security Onion VM" + ) + parser.add_argument("-n", "--name", required=True, + help="Domain name of the VM to monitor") + args = parser.parse_args() + + try: + # Wait for VM to start + if not wait_for_vm_start(args.name): + sys.exit(2) # VM never started + + # Verify VM remains running + if not verify_vm_running(args.name): + sys.exit(3) # VM stopped too quickly + + # Wait for VM to shutdown + if not wait_for_vm_shutdown(args.name): + sys.exit(4) # VM failed to shutdown + + # Verify VM remains shutdown + if not verify_vm_shutdown(args.name): + sys.exit(4) # VM failed to stay shutdown + + logger.info("Cloud-init completed successfully") + sys.exit(0) + + except Exception as e: + logger.error(f"Unexpected error: {e}") + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/salt/libvirt/images/init.sls b/salt/libvirt/images/init.sls index 8532760a7..7118400db 100644 --- a/salt/libvirt/images/init.sls +++ b/salt/libvirt/images/init.sls @@ -117,7 +117,7 @@ undefine_vm_sool9: - onlyif: - virsh dominfo sool9 -# Create and start the VM using virt-install +# Create and start the VM, letting cloud-init run create_vm_sool9: cmd.run: - name: | @@ -138,6 +138,21 @@ create_vm_sool9: - file: manage_userdata_sool9 - file: manage_cidata_sool9 +# Wait for cloud-init to complete and VM to shutdown +wait_for_cloud_init_sool9: + cmd.run: + - name: /usr/sbin/so-wait-cloud-init -n sool9 + - require: + - cmd: create_vm_sool9 + - timeout: 600 + +# Configure network predictability after cloud-init +configure_network_predictable_sool9: + cmd.run: + - name: /usr/sbin/so-qcow2-network-predictable -n sool9 + - require: + - cmd: wait_for_cloud_init_sool9 + {% else %} {{sls}}_no_license_detected: test.fail_without_changes: diff --git a/salt/manager/tools/sbin/so-salt-cloud b/salt/manager/tools/sbin/so-salt-cloud index d4c728143..b2cb0cdc2 100644 --- a/salt/manager/tools/sbin/so-salt-cloud +++ b/salt/manager/tools/sbin/so-salt-cloud @@ -420,7 +420,7 @@ def run_qcow2_modify_network_config(profile, mode, ip=None, gateway=None, dns=No hv_name = profile.split('-')[1] target = hv_name + "_*" image = '/nsm/libvirt/images/sool9/sool9.qcow2' - interface = 'eth0' + interface = 'enp1s0' try: r = local.cmd(target, 'qcow2.modify_network_config', [ diff --git a/salt/salt/cloud/cloud.profiles.d/socloud.conf.jinja b/salt/salt/cloud/cloud.profiles.d/socloud.conf.jinja index 2f0323482..20c12d28f 100644 --- a/salt/salt/cloud/cloud.profiles.d/socloud.conf.jinja +++ b/salt/salt/cloud/cloud.profiles.d/socloud.conf.jinja @@ -45,7 +45,7 @@ sool9-{{host}}: inline_script: - | sudo salt-call state.apply salt.mine_functions \ - pillar='{"host": {"mainint": "eth0"}}' + pillar='{"host": {"mainint": "enp1s0"}}' - 'sudo salt-call mine.update' - 'sudo salt-call state.apply setup.virt' # grains to add to the minion diff --git a/salt/setup/virt/fleet.yaml b/salt/setup/virt/fleet.yaml index 8a6aa06de..d6c90e11d 100644 --- a/salt/setup/virt/fleet.yaml +++ b/salt/setup/virt/fleet.yaml @@ -1,5 +1,5 @@ MAINIP: -MNIC: eth0 +MNIC: enp1s0 NODE_DESCRIPTION: 'vm' ES_HEAP_SIZE: PATCHSCHEDULENAME: diff --git a/salt/setup/virt/heavynode.yaml b/salt/setup/virt/heavynode.yaml index 8cf2e0392..a3550021f 100644 --- a/salt/setup/virt/heavynode.yaml +++ b/salt/setup/virt/heavynode.yaml @@ -1,5 +1,5 @@ MAINIP: -MNIC: eth0 +MNIC: enp1s0 NODE_DESCRIPTION: 'vm' ES_HEAP_SIZE: PATCHSCHEDULENAME: diff --git a/salt/setup/virt/idh.yaml b/salt/setup/virt/idh.yaml index 0e1ef8be0..201262db8 100644 --- a/salt/setup/virt/idh.yaml +++ b/salt/setup/virt/idh.yaml @@ -1,5 +1,5 @@ MAINIP: -MNIC: eth0 +MNIC: enp1s0 NODE_DESCRIPTION: 'vm' ES_HEAP_SIZE: PATCHSCHEDULENAME: diff --git a/salt/setup/virt/receiver.yaml b/salt/setup/virt/receiver.yaml index 5a5c714aa..e5dc0b0e7 100644 --- a/salt/setup/virt/receiver.yaml +++ b/salt/setup/virt/receiver.yaml @@ -1,5 +1,5 @@ MAINIP: -MNIC: eth0 +MNIC: enp1s0 NODE_DESCRIPTION: 'vm' ES_HEAP_SIZE: PATCHSCHEDULENAME: diff --git a/salt/setup/virt/searchnode.yaml b/salt/setup/virt/searchnode.yaml index 2e1568f29..032e0e855 100644 --- a/salt/setup/virt/searchnode.yaml +++ b/salt/setup/virt/searchnode.yaml @@ -1,5 +1,5 @@ MAINIP: -MNIC: eth0 +MNIC: enp1s0 NODE_DESCRIPTION: 'vm' ES_HEAP_SIZE: PATCHSCHEDULENAME: diff --git a/salt/setup/virt/sensor.yaml b/salt/setup/virt/sensor.yaml index e7064bdfe..9136f0b78 100644 --- a/salt/setup/virt/sensor.yaml +++ b/salt/setup/virt/sensor.yaml @@ -1,5 +1,5 @@ MAINIP: -MNIC: eth0 +MNIC: enp1s0 NODE_DESCRIPTION: 'vm' ES_HEAP_SIZE: PATCHSCHEDULENAME: