diff --git a/molecule/interfaces_assignments/converge.yml b/molecule/interfaces_assignments/converge.yml new file mode 100644 index 00000000..799a1aa8 --- /dev/null +++ b/molecule/interfaces_assignments/converge.yml @@ -0,0 +1,54 @@ +--- +- name: converge + hosts: all + become: true + tasks: + - name: Test interface lan, device em1 description update + puzzle.opnsense.interfaces_assignments: + identifier: "lan" + device: "em1" + description: "LAN1" + + - name: Test interface wan, device update from em2 to em3 + puzzle.opnsense.interfaces_assignments: + identifier: "wan" + device: "em3" + description: "wan_interface" + register: device_already_assigned_result + ignore_errors: yes + + - name: "Verify that the device update failed since the device is already assigned" + ansible.builtin.assert: + that: + - device_already_assigned_result is failed + fail_msg: "This device is already assigned, please unassign this device first" + success_msg: "This device is already assigned, please unassign this device first" + + - name: Test unknown identifier to used device description update + puzzle.opnsense.interfaces_assignments: + identifier: "unknown_identifier" + device: "em1" + description: "unknown_identifier" + register: unknown_identifier_result + ignore_errors: yes + + - name: "Verify that the device update failed since the device is already assigned" + ansible.builtin.assert: + that: + - unknown_identifier_result is failed + fail_msg: "This device is already assigned, please unassign this device first" + success_msg: "This device is already assigned, please unassign this device first" + + - name: Test interface lan, unknown_device update + puzzle.opnsense.interfaces_assignments: + identifier: "lan" + device: "unknown_device" + register: unknown_device_result + ignore_errors: yes + + - name: "Verify that the device update failed due to non-existing device" + ansible.builtin.assert: + that: + - unknown_device_result is failed + fail_msg: "Interface update should fail due to non-existing device" + success_msg: "Interface update failed as expected due to non-existing device" diff --git a/molecule/interfaces_assignments/molecule.yml b/molecule/interfaces_assignments/molecule.yml new file mode 100644 index 00000000..f345f7af --- /dev/null +++ b/molecule/interfaces_assignments/molecule.yml @@ -0,0 +1,67 @@ +--- +scenario: + name: interfaces_assignments + test_sequence: + # - dependency not relevant uless we have requirements + - destroy + - syntax + - create + - converge + - idempotence + - verify + - destroy + +driver: + name: vagrant + parallel: true + +platforms: + - name: "22.7" + hostname: false + box: puzzle/opnsense + box_version: "22.7" + memory: 1024 + cpus: 2 + instance_raw_config_args: + - 'vm.guest = :freebsd' + - 'ssh.sudo_command = "%c"' + - 'ssh.shell = "/bin/sh"' + - name: "23.1" + box: puzzle/opnsense + hostname: false + box_version: "23.1" + memory: 1024 + cpus: 2 + instance_raw_config_args: + - 'vm.guest = :freebsd' + - 'ssh.sudo_command = "%c"' + - 'ssh.shell = "/bin/sh"' + - name: "23.7" + box: puzzle/opnsense + hostname: false + box_version: "23.7" + memory: 1024 + cpus: 2 + instance_raw_config_args: + - 'vm.guest = :freebsd' + - 'ssh.sudo_command = "%c"' + - 'ssh.shell = "/bin/sh"' + - name: "24.1" + box: puzzle/opnsense + hostname: false + box_version: "24.1" + memory: 1024 + cpus: 2 + instance_raw_config_args: + - 'vm.guest = :freebsd' + - 'ssh.sudo_command = "%c"' + - 'ssh.shell = "/bin/sh"' + +provisioner: + name: ansible + env: + ANSIBLE_VERBOSITY: 3 +verifier: + name: ansible + options: + become: true diff --git a/molecule/interfaces_assignments/verify.yml b/molecule/interfaces_assignments/verify.yml new file mode 100644 index 00000000..b447dcf4 --- /dev/null +++ b/molecule/interfaces_assignments/verify.yml @@ -0,0 +1,6 @@ +--- +- name: Verify connectivity to server + hosts: all + tasks: + - name: Ping the server + ansible.builtin.ping: diff --git a/plugins/module_utils/interfaces_assignments_utils.py b/plugins/module_utils/interfaces_assignments_utils.py new file mode 100644 index 00000000..be1f0b5f --- /dev/null +++ b/plugins/module_utils/interfaces_assignments_utils.py @@ -0,0 +1,485 @@ +# Copyright: (c) 2024, Puzzle ITC, Kilian Soltermann +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) + +""" +interfaces_assignments_utils module_utils: Module_utils to configure OPNsense interface settings +""" + +from dataclasses import dataclass, asdict, field +from typing import List, Optional, Dict, Any + + +from xml.etree.ElementTree import Element, ElementTree, SubElement + +from ansible_collections.puzzle.opnsense.plugins.module_utils import ( + xml_utils, + opnsense_utils, +) +from ansible_collections.puzzle.opnsense.plugins.module_utils.config_utils import ( + OPNsenseModuleConfig, +) + + +class OPNSenseDeviceNotFoundError(Exception): + """ + Exception raised when a Device is not found. + """ + + +class OPNSenseDeviceAlreadyAssignedError(Exception): + """ + Exception raised when a Device is already assigned to an Interface + """ + + +class OPNSenseGetInterfacesError(Exception): + """ + Exception raised if the function can't query the local device + """ + + +@dataclass +class InterfaceAssignment: + """ + Represents a network interface with optional description and extra attributes. + + Attributes: + identifier (str): Unique ID for the interface. + device (str): Device name. + descr (Optional[str]): Description of the interface. + extra_attrs (Dict[str, Any]): Additional attributes for configuration. + + Methods: + __init__: Initializes with ID, device, and optional description. + from_xml: Creates an instance from XML. + to_etree: Serializes instance to XML, handling special cases. + from_ansible_module_params: Creates from Ansible params. + """ + + identifier: str + device: str + descr: Optional[str] = None + + # since only the above attributes are needed, the rest is handled here + extra_attrs: Dict[str, Any] = field(default_factory=dict, repr=False) + + def __init__( + self, + identifier: str, + device: str, + descr: Optional[str] = None, + **kwargs, + ): + self.identifier = identifier + self.device = device + if descr is not None: + self.descr = descr + self.extra_attrs = kwargs + + @staticmethod + def from_xml(element: Element) -> "InterfaceAssignment": + """ + Converts XML element to InterfaceAssignment instance. + + Args: + element (Element): XML element representing an interface. + + Returns: + InterfaceAssignment: An instance with attributes derived from the XML. + + Processes XML to dict, assigning 'identifier' and 'device' from keys and + 'if' element. Assumes single key processing. + """ + + interface_assignment_dict: dict = xml_utils.etree_to_dict(element) + + for key, value in interface_assignment_dict.items(): + value["identifier"] = key # Move the key to a new "identifier" field + if "if" in value: + if_key = value.pop("if", None) + if if_key is not None: + value["device"] = if_key + break # Only process the first key, assuming there's only one + + # Return only the content of the dictionary without the key + return InterfaceAssignment(**interface_assignment_dict.popitem()[1]) + + def to_etree(self) -> Element: + """ + Serializes the instance to an XML Element, including extra attributes. + + Returns: + Element: XML representation of the instance. + + Creates an XML element with identifier, device, and description. Handles + serialization of additional attributes, excluding specified exceptions and + handling specific attribute cases like alias and DHCP options. Assumes + boolean values translate to '1' for true. + """ + + interface_assignment_dict: dict = asdict(self) + + exceptions = ["dhcphostname", "mtu", "subnet", "gateway", "media", "mediaopt"] + + # Create the main element + main_element = Element(interface_assignment_dict["identifier"]) + + # Special handling for 'device' and 'descr' + SubElement(main_element, "if").text = interface_assignment_dict.get("device") + SubElement(main_element, "descr").text = interface_assignment_dict.get("descr") + + # handle special cases + if getattr(self, "alias-subnet", None): + interface_assignment_dict["extra_attrs"]["alias-subnet"] = getattr( + self, "alias-subnet", None + ) + + interface_assignment_dict["extra_attrs"]["alias-address"] = getattr( + self, "alias-address", None + ) + + if getattr(self, "dhcp6-ia-pd-len", None): + interface_assignment_dict["extra_attrs"]["dhcp6-ia-pd-len"] = getattr( + self, "dhcp6-ia-pd-len", None + ) + + if getattr(self, "track6-interface", None): + interface_assignment_dict["extra_attrs"]["track6-interface"] = getattr( + self, "track6-interface", None + ) + + if getattr(self, "track6-prefix-id", None): + interface_assignment_dict["extra_attrs"]["track6-prefix-id"] = getattr( + self, "track6-prefix-id", None + ) + + # Serialize extra attributes + for key, value in interface_assignment_dict["extra_attrs"].items(): + if ( + key + in [ + "spoofmac", + "alias-address", + "alias-subnet", + "dhcp6-ia-pd-len", + "adv_dhcp_pt_timeout", + "adv_dhcp_pt_retry", + "adv_dhcp_pt_select_timeout", + "adv_dhcp_pt_reboot", + "adv_dhcp_pt_backoff_cutoff", + "adv_dhcp_pt_initial_interval", + "adv_dhcp_pt_values", + "adv_dhcp_send_options", + "adv_dhcp_request_options", + "adv_dhcp_required_options", + "adv_dhcp_option_modifiers", + "adv_dhcp_config_advanced", + "adv_dhcp_config_file_override", + "adv_dhcp_config_file_override_path", + "dhcprejectfrom", + "track6-interface", + "track6-prefix-id", + ] + and value is None + ): + sub_element = SubElement(main_element, key) + if value is None and key not in exceptions: + continue + sub_element = SubElement(main_element, key) + if value is True: + sub_element.text = "1" + elif value is not None: + sub_element.text = str(value) + + return main_element + + @classmethod + def from_ansible_module_params(cls, params: dict) -> "InterfaceAssignment": + """ + Creates an instance from Ansible module parameters. + + Args: + params (dict): Parameters from an Ansible module. + + Returns: + User: An instance of InterfaceAssignment. + + Filters out None values from the provided parameters and uses them to + instantiate the class, focusing on 'identifier', 'device', and 'descr'. + """ + + interface_assignment_dict = { + "identifier": params.get("identifier"), + "device": params.get("device"), + "descr": params.get("description"), + } + + interface_assignment_dict = { + key: value + for key, value in interface_assignment_dict.items() + if value is not None + } + + return cls(**interface_assignment_dict) + + +class InterfacesSet(OPNsenseModuleConfig): + """ + Manages network interface assignments for OPNsense configurations. + + Inherits from OPNsenseModuleConfig, offering methods for managing + interface assignments within an OPNsense config file. + + Attributes: + _interfaces_assignments (List[InterfaceAssignment]): List of interface assignments. + + Methods: + __init__(self, path="/conf/config.xml"): Initializes InterfacesSet and loads interfaces. + _load_interfaces() -> List["Interface_assignment"]: Loads interface assignments from config. + changed() -> bool: Checks if current assignments differ from the loaded ones. + update(InterfaceAssignment: InterfaceAssignment): Updates an assignment, + errors if not found. + find(**kwargs) -> Optional[InterfaceAssignment]: Finds an assignment matching + specified attributes. + save() -> bool: Saves changes to the config file if there are modifications. + """ + + _interfaces_assignments: List[InterfaceAssignment] + + def __init__(self, path: str = "/conf/config.xml"): + super().__init__( + module_name="interfaces_assignments", + config_context_names=["interfaces_assignments"], + path=path, + ) + + self._config_xml_tree = self._load_config() + self._interfaces_assignments = self._load_interfaces() + + def _load_interfaces(self) -> List["InterfaceAssignment"]: + + element_tree_interfaces: Element = self.get("interfaces") + + return [ + InterfaceAssignment.from_xml(element_tree_interface) + for element_tree_interface in element_tree_interfaces + ] + + @property + def changed(self) -> bool: + """ + Evaluates whether there have been changes to user or group configurations that are not yet + reflected in the saved system configuration. This property serves as a check to determine + if updates have been made in memory to the user or group lists that differ from what is + currently persisted in the system's configuration files. + Returns: + bool: True if there are changes to the user or group configurations that have not been + persisted yet; False otherwise. + The method works by comparing the current in-memory representations of users and groups + against the versions loaded from the system's configuration files. A difference in these + lists indicates that changes have been made in the session that have not been saved, thus + prompting the need for a save operation to update the system configuration accordingly. + Note: + This property should be consulted before performing a save operation to avoid + unnecessary writes to the system configuration when no changes have been made. + """ + + return bool(str(self._interfaces_assignments) != str(self._load_interfaces())) + + def get_interfaces(self) -> List[InterfaceAssignment]: + """ + Retrieves a list of interface assignments from an OPNSense device via a PHP function. + + The function queries the device using specified PHP requirements and config functions. + It processes the stdout, extracts interface data, and handles errors. + + Returns: + list[InterfaceAssignment]: A list of interface assignments parsed + from the PHP function's output. + + Raises: + OPNSenseGetInterfacesError: If an error occurs during the retrieval + or parsing process, + or if no interfaces are found. + """ + + # load requirements + php_requirements = self._config_maps["interfaces_assignments"][ + "php_requirements" + ] + php_command = """ + foreach (get_interface_list() as $key => $item) { + echo $key.','; + } + """ + + # run php function + result = opnsense_utils.run_command( + php_requirements=php_requirements, + command=php_command, + ) + + # check for stderr + if result.get("stderr"): + raise OPNSenseGetInterfacesError( + "error encounterd while getting interfaces" + ) + + # parse list + interface_list: list[str] = [ + item.strip() + for item in result.get("stdout").split(",") + if item.strip() and item.strip() != "None" + ] + + # check parsed list length + if len(interface_list) < 1: + raise OPNSenseGetInterfacesError( + "error encounterd while getting interfaces, less than one interface available" + ) + + return interface_list + + def update(self, interface_assignment: InterfaceAssignment) -> None: + """ + Updates an interface assignment in the set. + + Checks for device existence and updates or raises errors accordingly. + + Args: + interface_assignment (InterfaceAssignment): The interface assignment to update. + + Raises: + OPNSenseDeviceNotFoundError: If device is not found. + """ + + device_list_set: set = set( # pylint: disable=R1718 + [assignment.device for assignment in self._interfaces_assignments] + ) + + identifier_list_set: set = set( # pylint: disable=R1718 + [assignment.identifier for assignment in self._interfaces_assignments] + ) + + device_interfaces_set: set = set(self.get_interfaces()) + + free_interfaces = device_interfaces_set - device_list_set + + if interface_assignment.device not in device_interfaces_set: + raise OPNSenseDeviceNotFoundError( + "Device was not found on OPNsense Instance!" + ) + + interface_to_update: Optional[InterfaceAssignment] = next( + ( + interface + for interface in self._interfaces_assignments + if interface.device == interface_assignment.device + or interface.identifier == interface_assignment.identifier + ), + None, + ) + + if not interface_to_update: + + interface_to_create: InterfaceAssignment = InterfaceAssignment( + identifier=interface_assignment.identifier, + device=interface_assignment.device, + descr=interface_assignment.descr, + ) + + self._interfaces_assignments.append(interface_to_create) + + return + + if ( + interface_assignment.device in free_interfaces + or interface_assignment.device == interface_to_update.device + ): + + if interface_assignment.identifier in identifier_list_set: + + # Merge extra_attrs + interface_assignment.extra_attrs.update(interface_to_update.extra_attrs) + + # Update the existing interface + interface_to_update.__dict__.update(interface_assignment.__dict__) + + else: + raise OPNSenseDeviceAlreadyAssignedError( + "This device is already assigned, please unassign this device first" + ) + + else: + raise OPNSenseDeviceAlreadyAssignedError( + "This device is already assigned, please unassign this device first" + ) + + def find(self, **kwargs) -> Optional[InterfaceAssignment]: + """ + Searches for an interface assignment that matches given criteria. + + Iterates through the list of interface assignments, checking if each one + matches all provided keyword arguments. If a match is found, returns the + corresponding interface assignment. If no match is found, returns None. + + Args: + **kwargs: Key-value pairs to match against attributes of interface assignments. + + Returns: + Optional[InterfaceAssignment]: The first interface assignment that matches + the criteria, or None if no match is found. + """ + + for interface_assignment in self._interfaces_assignments: + match = all( + getattr(interface_assignment, key, None) == value + for key, value in kwargs.items() + ) + if match: + return interface_assignment + return None + + def save(self) -> bool: + """ + Saves the current state of interface assignments to the OPNsense configuration file. + + Checks if there have been changes to the interface assignments. If not, it + returns False indicating no need to save. It then locates the parent element + for interface assignments in the XML tree and replaces existing entries with + the updated set from memory. After updating, it writes the new XML tree to + the configuration file and reloads the configuration to reflect changes. + + Returns: + bool: True if changes were saved successfully, False if no changes were detected. + + Note: + This method assumes that 'parent_element' correctly refers to the container + of interface elements within the configuration file. + """ + + if not self.changed: + return False + + # Use 'find' to get the single parent element + parent_element = self._config_xml_tree.find( + self._config_maps["interfaces_assignments"]["interfaces"] + ) + + # Assuming 'parent_element' correctly refers to the container of interface elements + for interface_element in list(parent_element): + parent_element.remove(interface_element) + + # Now, add updated interface elements + parent_element.extend( + [ + interface_assignment.to_etree() + for interface_assignment in self._interfaces_assignments + ] + ) + + # Write the updated XML tree to the file + tree = ElementTree(self._config_xml_tree) + tree.write(self._config_path, encoding="utf-8", xml_declaration=True) + + return True diff --git a/plugins/module_utils/module_index.py b/plugins/module_utils/module_index.py index 52a99106..61a5eb5a 100644 --- a/plugins/module_utils/module_index.py +++ b/plugins/module_utils/module_index.py @@ -135,6 +135,24 @@ }, }, }, + "interfaces_assignments": { + "interfaces": "interfaces", + # Add other mappings here. + "php_requirements": [ + "/usr/local/etc/inc/config.inc", + "/usr/local/etc/inc/util.inc", + "/usr/local/etc/inc/filter.inc", + "/usr/local/etc/inc/system.inc", + "/usr/local/etc/inc/rrd.inc", + "/usr/local/etc/inc/interfaces.inc", + ], + "configure_functions": { + "filter_configure": { + "name": "filter_configure", + "configure_params": [], + }, + }, + }, }, "23.1": { "system_settings_general": { @@ -242,6 +260,24 @@ }, }, }, + "interfaces_assignments": { + "interfaces": "interfaces", + # Add other mappings here. + "php_requirements": [ + "/usr/local/etc/inc/config.inc", + "/usr/local/etc/inc/util.inc", + "/usr/local/etc/inc/filter.inc", + "/usr/local/etc/inc/system.inc", + "/usr/local/etc/inc/rrd.inc", + "/usr/local/etc/inc/interfaces.inc", + ], + "configure_functions": { + "filter_configure": { + "name": "filter_configure", + "configure_params": [], + }, + }, + }, }, "23.7": { "system_settings_general": { @@ -306,6 +342,29 @@ } }, }, + "system_access_users": { + "users": "system/user", + "uid": "system/nextuid", + "gid": "system/nextgid", + "system": "system", + "php_requirements": [ + "/usr/local/etc/inc/system.inc", + ], + "configure_functions": {}, + }, + "password": { + "php_requirements": [ + "/usr/local/etc/inc/auth.inc", + ], + "configure_functions": { + "name": "echo password_hash", + "configure_params": [ + "'password'", + "PASSWORD_BCRYPT", + "[ 'cost' => 11 ]", + ], + }, + }, "firewall_rules": { "rules": "filter", "php_requirements": [ @@ -326,27 +385,22 @@ }, }, }, - "system_access_users": { - "users": "system/user", - "uid": "system/nextuid", - "gid": "system/nextgid", - "system": "system", + "interfaces_assignments": { + "interfaces": "interfaces", + # Add other mappings here. "php_requirements": [ + "/usr/local/etc/inc/config.inc", + "/usr/local/etc/inc/util.inc", + "/usr/local/etc/inc/filter.inc", "/usr/local/etc/inc/system.inc", - ], - "configure_functions": {}, - }, - "password": { - "php_requirements": [ - "/usr/local/etc/inc/auth.inc", + "/usr/local/etc/inc/rrd.inc", + "/usr/local/etc/inc/interfaces.inc", ], "configure_functions": { - "name": "echo password_hash", - "configure_params": [ - "'password'", - "PASSWORD_BCRYPT", - "[ 'cost' => 11 ]", - ], + "filter_configure": { + "name": "filter_configure", + "configure_params": [], + }, }, }, }, @@ -414,6 +468,29 @@ } }, }, + "system_access_users": { + "users": "system/user", + "uid": "system/nextuid", + "gid": "system/nextgid", + "system": "system", + "php_requirements": [ + "/usr/local/etc/inc/system.inc", + ], + "configure_functions": {}, + }, + "password": { + "php_requirements": [ + "/usr/local/etc/inc/auth.inc", + ], + "configure_functions": { + "name": "echo password_hash", + "configure_params": [ + "'password'", + "PASSWORD_BCRYPT", + "[ 'cost' => 11 ]", + ], + }, + }, "firewall_rules": { "rules": "filter", "php_requirements": [ @@ -434,27 +511,22 @@ }, }, }, - "system_access_users": { - "users": "system/user", - "uid": "system/nextuid", - "gid": "system/nextgid", - "system": "system", + "interfaces_assignments": { + "interfaces": "interfaces", + # Add other mappings here. "php_requirements": [ + "/usr/local/etc/inc/config.inc", + "/usr/local/etc/inc/util.inc", + "/usr/local/etc/inc/filter.inc", "/usr/local/etc/inc/system.inc", - ], - "configure_functions": {}, - }, - "password": { - "php_requirements": [ - "/usr/local/etc/inc/auth.inc", + "/usr/local/etc/inc/rrd.inc", + "/usr/local/etc/inc/interfaces.inc", ], "configure_functions": { - "name": "echo password_hash", - "configure_params": [ - "'password'", - "PASSWORD_BCRYPT", - "[ 'cost' => 11 ]", - ], + "filter_configure": { + "name": "filter_configure", + "configure_params": [], + }, }, }, }, diff --git a/plugins/modules/interfaces_assignments.py b/plugins/modules/interfaces_assignments.py new file mode 100644 index 00000000..2d0b0fe3 --- /dev/null +++ b/plugins/modules/interfaces_assignments.py @@ -0,0 +1,158 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2024, Kilian Soltermann , Puzzle ITC +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +"""interfaces_assignments module: Module to configure OPNsense interface settings""" + +# pylint: disable=duplicate-code +__metaclass__ = type + +# https://docs.ansible.com/ansible/latest/dev_guide/developing_modules_documenting.html +# fmt: off +DOCUMENTATION = r''' +--- +author: + - Kilian Soltermann (@killuuuhh) +module: interfaces_assignments +short_description: This module can be used to assign interfaces to network ports and network IDs to new interfaces. +description: + - Module to assign interfaces to network ports and network IDs to new interfaces. +options: + identifier: + description: + - "Technical identifier of the interface, used by hasync for example" + type: str + required: true + device: + description: + - Physical Device Name eg. vtnet0, ipsec1000 etc,. + type: str + required: true + description: + description: + - Interface name shown in the GUI. Identifier in capital letters if not provided. + - Input will be trimmed, as no whitespaces are allowed. + type: str + required: false +''' + +EXAMPLES = r''' +- name: Assign Vagrant interface to device em4 + puzzle.opnsense.interfaces_assignments: + identifier: "VAGRANT" + device: "em4" + +- name: Create new assignment + puzzle.opnsense.interfaces_assignments: + identifier: "lan" + device: "vtnet1" + description: "lan_interface" +''' + +RETURN = ''' +opnsense_configure_output: + description: A list of the executed OPNsense configure function along with their respective stdout, stderr and rc + returned: always + type: list + sample: + - function: filter_configure + params: + - 'true' + rc: 0 + stderr: '' + stderr_lines: [] + stdout: '' + stdout_lines: [] + + - function: rrd_configure + params: + - 'true' + rc: 0 + stderr: '' + stderr_lines: [] + stdout: Generating RRD graphs...done. + stdout_lines: + - Generating RRD graphs...done. +''' +# fmt: on + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.puzzle.opnsense.plugins.module_utils.interfaces_assignments_utils import ( + InterfacesSet, + InterfaceAssignment, + OPNSenseDeviceNotFoundError, + OPNSenseDeviceAlreadyAssignedError, + OPNSenseGetInterfacesError, +) + + +def main(): + """ + Main function of the interfaces_assignments module + """ + + module_args = { + "identifier": {"type": "str", "required": True}, + "device": {"type": "str", "required": True}, + "description": {"type": "str", "required": False}, + } + + module = AnsibleModule( + argument_spec=module_args, + supports_check_mode=True, + required_one_of=[ + ["identifier", "device", "description"], + ], + ) + + # https://docs.ansible.com/ansible/latest/reference_appendices/common_return_values.html + # https://docs.ansible.com/ansible/latest/dev_guide/developing_modules_documenting.html#return-block + result = { + "changed": False, + "invocation": module.params, + "diff": None, + } + + interface_assignment = InterfaceAssignment.from_ansible_module_params(module.params) + + with InterfacesSet() as interfaces_set: + + try: + interfaces_set.update(interface_assignment) + + except ( + OPNSenseDeviceNotFoundError + ) as opnsense_device_not_found_error_error_message: + module.fail_json(msg=str(opnsense_device_not_found_error_error_message)) + + except ( + OPNSenseDeviceAlreadyAssignedError + ) as opnsense_device_already_assigned_error_message: + module.fail_json(msg=str(opnsense_device_already_assigned_error_message)) + + except OPNSenseGetInterfacesError as opnsense_get_interfaces_error_message: + module.fail_json(msg=str(opnsense_get_interfaces_error_message)) + + if interfaces_set.changed: + result["diff"] = interfaces_set.diff + result["changed"] = True + + if interfaces_set.changed and not module.check_mode: + interfaces_set.save() + result["opnsense_configure_output"] = interfaces_set.apply_settings() + + for cmd_result in result["opnsense_configure_output"]: + if cmd_result["rc"] != 0: + module.fail_json( + msg="Apply of the OPNsense settings failed", + details=cmd_result, + ) + + # Return results + module.exit_json(**result) + + +if __name__ == "__main__": + main() diff --git a/tests/unit/plugins/module_utils/test_interfaces_assignments_utils.py b/tests/unit/plugins/module_utils/test_interfaces_assignments_utils.py new file mode 100644 index 00000000..8118b37c --- /dev/null +++ b/tests/unit/plugins/module_utils/test_interfaces_assignments_utils.py @@ -0,0 +1,1533 @@ +# Copyright: (c) 2024, Puzzle ITC +# GNU General Public License v3.0+ (see LICENSE or https://www.gnu.org/licenses/gpl-3.0.txt) +# pylint: skip-file +import os +from tempfile import NamedTemporaryFile +from unittest.mock import patch, MagicMock +from xml.etree import ElementTree +from xml.etree.ElementTree import Element + + +import pytest + +from ansible_collections.puzzle.opnsense.plugins.module_utils import xml_utils +from ansible_collections.puzzle.opnsense.plugins.module_utils.interfaces_assignments_utils import ( + InterfaceAssignment, + InterfacesSet, + OPNSenseDeviceNotFoundError, + OPNSenseDeviceAlreadyAssignedError, + OPNSenseGetInterfacesError, +) +from ansible_collections.puzzle.opnsense.plugins.module_utils.module_index import ( + VERSION_MAP, +) + +# Test version map for OPNsense versions and modules +TEST_VERSION_MAP = { + "OPNsense Test": { + "interfaces_assignments": { + "interfaces": "interfaces", + "php_requirements": [], + "configure_functions": {}, + }, + } +} + +# pylint: disable=C0301 +TEST_XML: str = """ + + + opnsense + + + Disable the pf ftp proxy handler. + debug.pfftpproxy + default + + + Increase UFS read-ahead speeds to match current state of hard drives and NCQ. More information here: http://ivoras.sharanet.org/blog/tree/2010-11-19.ufs-read-ahead.html + vfs.read_max + default + + + Set the ephemeral port range to be lower. + net.inet.ip.portrange.first + default + + + Drop packets to closed TCP ports without returning a RST + net.inet.tcp.blackhole + default + + + Do not send ICMP port unreachable messages for closed UDP ports + net.inet.udp.blackhole + default + + + Randomize the ID field in IP packets (default is 0: sequential IP IDs) + net.inet.ip.random_id + default + + + Source routing is another way for an attacker to try to reach non-routable addresses behind your box. It can also be used to probe for information about your internal networks. These functions come enabled as part of the standard FreeBSD core system. + net.inet.ip.sourceroute + default + + + Source routing is another way for an attacker to try to reach non-routable addresses behind your box. It can also be used to probe for information about your internal networks. These functions come enabled as part of the standard FreeBSD core system. + net.inet.ip.accept_sourceroute + default + + + Redirect attacks are the purposeful mass-issuing of ICMP type 5 packets. In a normal network, redirects to the end stations should not be required. This option enables the NIC to drop all inbound ICMP redirect packets without returning a response. + net.inet.icmp.drop_redirect + default + + + This option turns off the logging of redirect packets because there is no limit and this could fill up your logs consuming your whole hard drive. + net.inet.icmp.log_redirect + default + + + Drop SYN-FIN packets (breaks RFC1379, but nobody uses it anyway) + net.inet.tcp.drop_synfin + default + + + Enable sending IPv4 redirects + net.inet.ip.redirect + default + + + Enable sending IPv6 redirects + net.inet6.ip6.redirect + default + + + Enable privacy settings for IPv6 (RFC 4941) + net.inet6.ip6.use_tempaddr + default + + + Prefer privacy addresses and use them over the normal addresses + net.inet6.ip6.prefer_tempaddr + default + + + Generate SYN cookies for outbound SYN-ACK packets + net.inet.tcp.syncookies + default + + + Maximum incoming/outgoing TCP datagram size (receive) + net.inet.tcp.recvspace + default + + + Maximum incoming/outgoing TCP datagram size (send) + net.inet.tcp.sendspace + default + + + Do not delay ACK to try and piggyback it onto a data packet + net.inet.tcp.delayed_ack + default + + + Maximum outgoing UDP datagram size + net.inet.udp.maxdgram + default + + + Handling of non-IP packets which are not passed to pfil (see if_bridge(4)) + net.link.bridge.pfil_onlyip + default + + + Set to 0 to disable filtering on the incoming and outgoing member interfaces. + net.link.bridge.pfil_member + default + + + Set to 1 to enable filtering on the bridge interface + net.link.bridge.pfil_bridge + default + + + Allow unprivileged access to tap(4) device nodes + net.link.tap.user_open + default + + + Randomize PID's (see src/sys/kern/kern_fork.c: sysctl_kern_randompid()) + kern.randompid + default + + + Maximum size of the IP input queue + net.inet.ip.intr_queue_maxlen + default + + + Disable CTRL+ALT+Delete reboot from keyboard. + hw.syscons.kbd_reboot + default + + + Enable TCP extended debugging + net.inet.tcp.log_debug + default + + + Set ICMP Limits + net.inet.icmp.icmplim + default + + + TCP Offload Engine + net.inet.tcp.tso + default + + + UDP Checksums + net.inet.udp.checksum + default + + + Maximum socket buffer size + kern.ipc.maxsockbuf + default + + + + normal + OPNsense + localdomain + + + admins + System Administrators + system + 1999 + 0 + 1000 + 2000 + user-shell-access + page-all + + + root + System Administrator + system + admins + $2b$10$YRVoF4SgskIsrXOvOQjGieB9XqHPRra9R7d80B3BZdbY/j21TwBfS + 0 + + + $2y$10$1BvUdvwM.a.dJACwfeNfAOgNT6Cqc4cKZ2F6byyvY8hIK9I8fn36O + user + vagrant + vagrant box management + + + + + /bin/sh + 1000 + + 2001 + 2000 + Etc/UTC + 300 + 0.nl.pool.ntp.org + + https + 5a3951eaa0f49 + + yes + 1 + + 1 + 1 + 1 + + hadp + hadp + hadp + + monthly + + + + + enabled + 1 + 0 + + 60 + aesni + + + 0 + + + + + OPNsense-Backup + + + + + + + + + + + + + + em2 + dhcp + + + + + + + 1 + dhcp6 + 0 + 1 + WAN + 1 + + + em1 + LAN + 1 + 1 + + 1 + 192.168.56.10 + 21 + track6 + wan + 0 + + + em3 + DMZ + + 1 + + + em0 + VAGRANT + 1 + 1 + + dhcp + + + 32 + + + + + + + + SavedCfg + + + + + + + + + + 1 + Loopback + 1 + lo0 + 127.0.0.1 + ::1 + 8 + 128 + none + 1 + + + 1 + 1 + openvpn + OpenVPN + group + 1 + + + + + + + + 10.2.0.2 + 10.2.0.200 + + + + + + + public + + + + + + + automatic + + + + + pass + wan + inet + keep state + Allow SSH access + tcp + + + + + + 22 + + + + pass + wan + inet + keep state + Allow incoming WebGUI access + tcp + + + + + + 443 + + + + pass + inet + Default allow LAN to any rule + lan + + lan + + + + + + + pass + inet6 + Default allow LAN IPv6 to any rule + lan + + lan + + + + + + + pass + opt2 + inet + keep state + allow vagrant management + in + 1 + + 1 + + + 1 + + + root@10.0.5.2 + + /firewall_rules_edit.php made changes + + + root@10.0.5.2 + + /firewall_rules_edit.php made changes + + + + + + + + + ICMP + icmp + ICMP + + + + TCP + tcp + Generic TCP + + + + HTTP + http + Generic HTTP + + / + + 200 + + + + HTTPS + https + Generic HTTPS + + / + + 200 + + + + SMTP + send + Generic SMTP + + + 220 * + + + + + system_information-container:00000000-col3:show,services_status-container:00000001-col4:show,gateways-container:00000002-col4:show,interface_list-container:00000003-col4:show + 2 + + + (root) + + Updated plugin interface configuration + + + + + + + + + + wan + v9 + + + + 0 + + 1800 + 15 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 0 + 0 + wan + 192.168.0.0/16,10.0.0.0/8,172.16.0.0/12 + + + W0D23 + 4 + ac + + medium + + + + 0 + 0 + 0 + + + + + + + + + + + 0 + 120 + 120 + 127.0.0.1 + 25 + + + 0 + auto + 1 + syslog facility log_daemon + + + + 0 + root + R4s6nqQWJXPYfQRNTVxvs3 + 2812 + + + 5 + 1 + + + 0 + root@localhost.local + 0 + + + 10 + + + + 1 + $HOST + + system + + + + 300 + 30 +
+ + + + a60b489a-68c2-40e0-a29e-a3c54feb7116,eb557e4c-8ab0-4291-a58e-3f0871e4b65b,68e944f4-e4fb-415d-a4c5-465a20be0824,60b2a1e3-9607-4322-b759-55bfef6c2c37 + + + + + 1 + RootFs + + filesystem + + + / + 300 + 30 +
+ + + + 27c5cee6-a3b0-47d7-9d49-1a71e97d7492 + + + + + 0 + carp_status_change + + custom + + + /usr/local/opnsense/scripts/OPNsense/Monit/carp_status + 300 + 30 +
+ + + + cd4206a1-c857-461e-9740-e49d1a5821b0 + + + + + 0 + gateway_alert + + custom + + + /usr/local/opnsense/scripts/OPNsense/Monit/gateway_alert + 300 + 30 +
+ + + + 132a13e1-e2d5-4328-8ae8-7b7bd702d434 + + + + + Ping + NetworkPing + failed ping + alert + + + + NetworkLink + NetworkInterface + failed link + alert + + + + NetworkSaturation + NetworkInterface + saturation is greater than 75% + alert + + + + MemoryUsage + SystemResource + memory usage is greater than 75% + alert + + + + CPUUsage + SystemResource + cpu usage is greater than 75% + alert + + + + LoadAvg1 + SystemResource + loadavg (1min) is greater than 2 + alert + + + + LoadAvg5 + SystemResource + loadavg (5min) is greater than 1.5 + alert + + + + LoadAvg15 + SystemResource + loadavg (15min) is greater than 1 + alert + + + + SpaceUsage + SpaceUsage + space usage is greater than 75% + alert + + + + ChangedStatus + ProgramStatus + changed status + alert + + + + NonZeroStatus + ProgramStatus + status != 0 + alert + + + + + + + + + 0 + opnsense + + + + 1 + 1 + + + + + + 0 + on + strip + 1 + 1 + 0 + + admin@localhost.local + localhost + + + 0 + /var/squid/cache + 256 + + + always + 100 + 16 + 256 + 0 + 0 + + + + 0 + 2048 + 1024 + 1024 + 256 + + + 0 + + 0 + username + password + + + + + + + lan + 3128 + 3129 + 0 + 0 + + + 4 + 5 + 0 + 3401 + public + + 2121 + 0 + 1 + 0 + + + + + + + + + + + 80:http,21:ftp,443:https,70:gopher,210:wais,1025-65535:unregistered ports,280:http-mgmt,488:gss-http,591:filemaker,777:multiling http + 443:https + + + + + + + 0 + icap://[::1]:1344/avscan + icap://[::1]:1344/avscan + 1 + 0 + 0 + X-Username + 1 + 1024 + 60 + + + + + + OPNsense proxy authentication + 2 + 5 + + + + +