mirror of
https://github.com/Security-Onion-Solutions/securityonion.git
synced 2025-12-06 09:12:45 +01:00
create predicatble interfaces
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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__":
|
||||
|
||||
378
salt/hypervisor/tools/sbin/so-qcow2-network-predictable
Normal file
378
salt/hypervisor/tools/sbin/so-qcow2-network-predictable
Normal 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()
|
||||
206
salt/hypervisor/tools/sbin/so-wait-cloud-init
Normal file
206
salt/hypervisor/tools/sbin/so-wait-cloud-init
Normal 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()
|
||||
@@ -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:
|
||||
|
||||
@@ -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', [
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
MAINIP:
|
||||
MNIC: eth0
|
||||
MNIC: enp1s0
|
||||
NODE_DESCRIPTION: 'vm'
|
||||
ES_HEAP_SIZE:
|
||||
PATCHSCHEDULENAME:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
MAINIP:
|
||||
MNIC: eth0
|
||||
MNIC: enp1s0
|
||||
NODE_DESCRIPTION: 'vm'
|
||||
ES_HEAP_SIZE:
|
||||
PATCHSCHEDULENAME:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
MAINIP:
|
||||
MNIC: eth0
|
||||
MNIC: enp1s0
|
||||
NODE_DESCRIPTION: 'vm'
|
||||
ES_HEAP_SIZE:
|
||||
PATCHSCHEDULENAME:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
MAINIP:
|
||||
MNIC: eth0
|
||||
MNIC: enp1s0
|
||||
NODE_DESCRIPTION: 'vm'
|
||||
ES_HEAP_SIZE:
|
||||
PATCHSCHEDULENAME:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
MAINIP:
|
||||
MNIC: eth0
|
||||
MNIC: enp1s0
|
||||
NODE_DESCRIPTION: 'vm'
|
||||
ES_HEAP_SIZE:
|
||||
PATCHSCHEDULENAME:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
MAINIP:
|
||||
MNIC: eth0
|
||||
MNIC: enp1s0
|
||||
NODE_DESCRIPTION: 'vm'
|
||||
ES_HEAP_SIZE:
|
||||
PATCHSCHEDULENAME:
|
||||
|
||||
Reference in New Issue
Block a user