#!/opt/saltstack/salt/bin/python3 # Copyright Security Onion Solutions LLC and/or licensed to Security Onion Solutions LLC under one # or more contributor license agreements. Licensed under the Elastic License 2.0 as shown at # https://securityonion.net/license; you may not use this file except in compliance with the # Elastic License 2.0. """ Script to assist with salt-cloud VM provisioning. This is intended to work with a libvirt salt-cloud provider. **Usage:** python so-salt-cloud -p (--dhcp4 | --static4 --ip4 --gw4 ) [-c ] [-m ] [-P ] [-P ...] [--dns4 ] [--search4 ] **Options:** -p, --profile The cloud profile to build the VM from. The name of the VM. --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. --gw4 IPv4 gateway (e.g., 192.168.1.1). Required for static IPv4 configuration. --dns4 Comma-separated list of IPv4 DNS servers (e.g., 8.8.8.8,8.8.4.4). --search4 DNS search domain for IPv4. -c, --cpu Number of virtual CPUs to assign. -m, --memory Amount of memory to assign in MiB. -P, --pci PCI hardware ID(s) to passthrough to the VM (e.g., 0000:00:1f.2). Can be specified multiple times. **Examples:** 1. **Static IP Configuration with Multiple PCI Devices:** ```bash python so-salt-cloud -p core-hype1 vm1_sensor --static4 --ip4 192.168.1.10/24 --gw4 192.168.1.1 \ --dns4 192.168.1.1,192.168.1.2 --search4 example.local -c 4 -m 8192 -P 0000:00:1f.2 -P 0000:00:1f.3 ``` This command provisions a VM named `vm1_sensor` using the `core-hype1` profile with the following settings: - Static IPv4 configuration: - IP Address: `192.168.1.10/24` - Gateway: `192.168.1.1` - DNS Servers: `192.168.1.1`, `192.168.1.2` - DNS Search Domain: `example.local` - Hardware Configuration: - CPUs: `4` - Memory: `8192` MiB - PCI Device Passthrough: `0000:00:1f.2`, `0000:00:1f.3` 2. **DHCP Configuration with Default Hardware Settings:** ```bash python so-salt-cloud -p core-hype1 vm2_master --dhcp4 ``` This command provisions a VM named `vm2_master` using the `core-hype1` profile with DHCP for network configuration and default hardware settings. 3. **Static IP Configuration without Hardware Specifications:** ```bash python so-salt-cloud -p core-hype1 vm3_search --static4 --ip4 192.168.1.20/24 --gw4 192.168.1.1 ``` This command provisions a VM named `vm3_search` with a static IP configuration and default hardware settings. 4. **DHCP Configuration with Custom Hardware Specifications and Multiple PCI Devices:** ```bash python so-salt-cloud -p core-hype1 vm4_node --dhcp4 -c 8 -m 16384 -P 0000:00:1f.4 -P 0000:00:1f.5 ``` This command provisions a VM named `vm4_node` using DHCP for network configuration and custom hardware settings: - CPUs: `8` - Memory: `16384` MiB - PCI Device Passthrough: `0000:00:1f.4`, `0000:00:1f.5` **Notes:** - When using `--static4`, both `--ip4` and `--gw4` options are required. - The script assumes the cloud profile name follows the format `basedomain-hypervisorname`. - Hardware parameters (`-c`, `-m`, `-P`) are optional. If not provided, default values from the profile will be used. - The `-P` or `--pci` option can be specified multiple times to pass through multiple PCI devices to the VM. - The `vm_name` should include the role of the VM after an underscore (e.g., `hostname_role`), as the script uses this to determine the VM's role for firewall configuration. **Description:** The `so-salt-cloud` script automates the provisioning of virtual machines using SaltStack's `salt-cloud` utility. It performs the following steps: 1. **Network Configuration:** - Modifies the network settings of the base QCOW2 image before provisioning. - Supports both DHCP and static IPv4 configurations. - Uses the `qcow2.modify_network_config` module via SaltStack to apply these settings on the target hypervisor. 2. **VM Provisioning:** - Calls `salt-cloud` to provision the VM using the specified profile and VM name. - The VM is provisioned but not started immediately to allow for hardware configuration. 3. **Hardware Configuration:** - Modifies the hardware settings of the newly defined VM. - Supports specifying multiple PCI devices for passthrough. - Uses the `qcow2.modify_hardware_config` module via SaltStack to adjust CPU count, memory allocation, and PCI device passthrough. - Starts the VM after hardware modifications. 4. **Firewall Configuration:** - Monitors the output of `salt-cloud` to extract the VM's IP address. - Calls the `so-firewall-minion` script to apply firewall rules based on the VM's role. **Exit Codes:** - `0`: Success - Non-zero: An error occurred during execution. **Logging:** - Logs are written to `/opt/so/log/salt/so-salt-cloud.log`. - Both file and console logging are enabled for real-time monitoring. """ import argparse import subprocess import re import sys import threading import salt.client import logging # Initialize Salt local client local = salt.client.LocalClient() # Set up logging logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) file_handler = logging.FileHandler('/opt/so/log/salt/so-salt-cloud.log') console_handler = logging.StreamHandler() formatter = logging.Formatter('%(asctime)s %(message)s') file_handler.setFormatter(formatter) console_handler.setFormatter(formatter) logger.addHandler(file_handler) logger.addHandler(console_handler) def call_so_firewall_minion(ip, role): try: # Start so-firewall-minion as a subprocess process = subprocess.Popen( ['/usr/sbin/so-firewall-minion', f'--ip={ip}', f'--role={role}'], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True ) # Read and log the output for line in iter(process.stdout.readline, ''): if line: logger.info(line.rstrip('\n')) process.stdout.close() process.wait() except Exception as e: logger.error(f"An error occurred while calling so-firewall-minion: {e}") def call_salt_cloud(profile, vm_name): try: # Start the salt-cloud command as a subprocess process = subprocess.Popen( ['salt-cloud', '-p', profile, vm_name, '-l', 'info'], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True ) role = vm_name.split("_")[1] ip_search_string = '[INFO ] Address =' ip_search_pattern = re.compile(re.escape(ip_search_string)) # Continuously read the output from salt-cloud while True: # Read stdout line by line line = process.stdout.readline() if line: logger.info(line.rstrip('\n')) if ip_search_pattern.search(line): parts = line.split("Address =") if len(parts) > 1: ip_address = parts[1].strip() logger.info(f"Extracted IP address: {ip_address}") # Create and start a thread to run so-firewall-minion thread = threading.Thread(target=call_so_firewall_minion, args=(ip_address, role.upper())) thread.start() else: logger.error("No IP address found.") else: # Check if salt-cloud has terminated if process.poll() is not None: break process.stdout.close() process.wait() except Exception as e: logger.error(f"An error occurred while calling salt-cloud: {e}") def run_qcow2_modify_hardware_config(profile, vm_name, cpu=None, memory=None, pci_list=None, start=False): hv_name = profile.split('-')[1] target = hv_name + "_*" try: args_list = [ 'vm_name=' + vm_name, 'cpu=' + str(cpu) if cpu else '', 'memory=' + str(memory) if memory else '', 'start=' + str(start) ] # Add PCI devices if provided if pci_list: # Join the list of PCI IDs into a comma-separated string pci_devices = ','.join(pci_list) args_list.append('pci=' + pci_devices) r = local.cmd(target, 'qcow2.modify_hardware_config', args_list) logger.info(f'qcow2.modify_hardware_config: {r}') except Exception as e: logger.error(f"An error occurred while running qcow2.modify_hardware_config: {e}") def run_qcow2_modify_network_config(profile, mode, ip=None, gateway=None, dns=None, search_domain=None): hv_name = profile.split('-')[1] target = hv_name + "_*" image = '/var/lib/libvirt/images/coreol9/coreol9.qcow2.MODIFIED' interface = 'eth0' try: r = local.cmd(target, 'qcow2.modify_network_config', [ 'image=' + image, 'interface=' + interface, 'mode=' + mode, 'ip4=' + ip if ip else '', 'gw4=' + gateway if gateway else '', 'dns4=' + dns if dns else '', 'search4=' + search_domain if search_domain else '' ]) logger.info(f'qcow2.modify_network_config: {r}') except Exception as e: logger.error(f"An error occurred while running qcow2.modify_network_config: {e}") def parse_arguments(): parser = argparse.ArgumentParser(description="Call salt-cloud and pass the profile and VM name to it.") parser.add_argument('-p', '--profile', type=str, required=True, help="The cloud profile to build the VM from.") parser.add_argument('vm_name', type=str, help="The name of the VM.") group = parser.add_mutually_exclusive_group(required=True) group.add_argument("--dhcp4", action="store_true", help="Configure interface for DHCP (IPv4).") group.add_argument("--static4", action="store_true", help="Configure interface for static IPv4 settings.") parser.add_argument("--ip4", help="IPv4 address (e.g., 192.168.1.10/24). Required for static IPv4 configuration.") parser.add_argument("--gw4", help="IPv4 gateway (e.g., 192.168.1.1). Required for static IPv4 configuration.") parser.add_argument("--dns4", help="Comma-separated list of IPv4 DNS servers (e.g., 8.8.8.8,8.8.4.4).") parser.add_argument("--search4", help="DNS search domain for IPv4.") parser.add_argument('-c', '--cpu', type=int, help='Number of virtual CPUs to assign.') parser.add_argument('-m', '--memory', type=int, help='Amount of memory to assign in MiB.') parser.add_argument('-P', '--pci', action='append', help='PCI hardware ID(s) to passthrough to the VM (e.g., 0000:00:1f.2). Can be specified multiple times.') args = parser.parse_args() if args.static4: if not args.ip4 or not args.gw4: parser.error("Both --ip4 and --gw4 are required for static IPv4 configuration.") return args def main(): try: args = parse_arguments() if args.dhcp4: mode = "dhcp4" elif args.static4: mode = "static4" else: mode = "dhcp4" # Default to DHCP if not specified # Step 1: Modify network configuration run_qcow2_modify_network_config(args.profile, mode, args.ip4, args.gw4, args.dns4, args.search4) # Step 2: Provision the VM (without starting it) call_salt_cloud(args.profile, args.vm_name) # Step 3: Modify hardware configuration run_qcow2_modify_hardware_config(args.profile, args.vm_name, cpu=args.cpu, memory=args.memory, pci_list=args.pci, start=True) except KeyboardInterrupt: logger.error("so-salt-cloud: Operation cancelled by user.") sys.exit(1) except Exception as e: logger.error(f"so-salt-cloud: An error occurred: {e}") sys.exit(1) if __name__ == "__main__": main()