create predicatble interfaces

This commit is contained in:
m0duspwnens
2025-02-06 15:30:46 -05:00
parent c8e232c598
commit 7159678385
13 changed files with 792 additions and 26 deletions

View File

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

View File

@@ -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,29 +420,41 @@ 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:
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)

View File

@@ -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 <domain_name> [-I <qcow2_image_path>]
**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/<domain_name>/<domain_name>.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()

View File

@@ -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 <domain_name>
**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()

View File

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

View File

@@ -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', [

View File

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

View File

@@ -1,5 +1,5 @@
MAINIP:
MNIC: eth0
MNIC: enp1s0
NODE_DESCRIPTION: 'vm'
ES_HEAP_SIZE:
PATCHSCHEDULENAME:

View File

@@ -1,5 +1,5 @@
MAINIP:
MNIC: eth0
MNIC: enp1s0
NODE_DESCRIPTION: 'vm'
ES_HEAP_SIZE:
PATCHSCHEDULENAME:

View File

@@ -1,5 +1,5 @@
MAINIP:
MNIC: eth0
MNIC: enp1s0
NODE_DESCRIPTION: 'vm'
ES_HEAP_SIZE:
PATCHSCHEDULENAME:

View File

@@ -1,5 +1,5 @@
MAINIP:
MNIC: eth0
MNIC: enp1s0
NODE_DESCRIPTION: 'vm'
ES_HEAP_SIZE:
PATCHSCHEDULENAME:

View File

@@ -1,5 +1,5 @@
MAINIP:
MNIC: eth0
MNIC: enp1s0
NODE_DESCRIPTION: 'vm'
ES_HEAP_SIZE:
PATCHSCHEDULENAME:

View File

@@ -1,5 +1,5 @@
MAINIP:
MNIC: eth0
MNIC: enp1s0
NODE_DESCRIPTION: 'vm'
ES_HEAP_SIZE:
PATCHSCHEDULENAME: