From b31acd7e904e6edc8317461ef16504cc885b63d2 Mon Sep 17 00:00:00 2001 From: David Rosado Date: Fri, 27 Sep 2024 13:36:04 -0400 Subject: [PATCH 1/5] dhcp server initial and tests --- plugins/module_utils/dhcp_server.py | 301 ++++++++++++++++++ plugins/modules/pfsense_dhcp_server.py | 211 ++++++++++++ .../fixtures/pfsense_dhcp_server_config.xml | 89 ++++++ .../modules/test_pfsense_dhcp_server.py | 118 +++++++ 4 files changed, 719 insertions(+) create mode 100644 plugins/module_utils/dhcp_server.py create mode 100644 plugins/modules/pfsense_dhcp_server.py create mode 100644 tests/unit/plugins/modules/fixtures/pfsense_dhcp_server_config.xml create mode 100644 tests/unit/plugins/modules/test_pfsense_dhcp_server.py diff --git a/plugins/module_utils/dhcp_server.py b/plugins/module_utils/dhcp_server.py new file mode 100644 index 00000000..8044833a --- /dev/null +++ b/plugins/module_utils/dhcp_server.py @@ -0,0 +1,301 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2023, Your Name +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +from ipaddress import ip_address, ip_network +import re + +from ansible_collections.pfsensible.core.plugins.module_utils.module_base import PFSenseModuleBase + +DHCPD_SERVER_ARGUMENT_SPEC = dict( + state=dict(type='str', default='present', choices=['present', 'absent']), + interface=dict(required=True, type='str'), + enable=dict(type='bool'), + range_from=dict(type='str'), + range_to=dict(type='str'), + failover_peerip=dict(type='str'), + defaultleasetime=dict(type='int'), + maxleasetime=dict(type='int'), + netmask=dict(type='str'), + gateway=dict(type='str'), + domain=dict(type='str'), + domainsearchlist=dict(type='str'), + ddnsdomain=dict(type='str'), + ddnsdomainprimary=dict(type='str'), + ddnsdomainkeyname=dict(type='str'), + ddnsdomainkeyalgorithm=dict(type='str', default='hmac-md5'), + ddnsdomainkey=dict(type='str'), + mac_allow=dict(type='list', elements='str'), + mac_deny=dict(type='list', elements='str'), + ddnsclientupdates=dict(type='str', default='allow'), + tftp=dict(type='str'), + ldap=dict(type='str'), + nextserver=dict(type='str'), + filename=dict(type='str'), + filename32=dict(type='str'), + filename64=dict(type='str'), + rootpath=dict(type='str'), + numberoptions=dict(type='list', elements='dict'), + ignorebootp=dict(type='bool'), + denyunknown=dict(type='str'), + nonak=dict(type='bool'), + ignoreclientuids=dict(type='bool'), + staticarp=dict(type='bool'), + dhcpinlocaltime=dict(type='bool'), + statsgraph=dict(type='bool'), + disablepingcheck=dict(type='bool'), +) + +class PFSenseDHCPDServerModule(PFSenseModuleBase): + """ module managing pfsense DHCP server settings """ + + @staticmethod + def get_argument_spec(): + """ return argument spec """ + return DHCPD_SERVER_ARGUMENT_SPEC + + ############################## + # init + # + def __init__(self, module, pfsense=None): + super(PFSenseDHCPDServerModule, self).__init__(module, pfsense) + self.name = "pfsense_dhcp_server" + self.obj = dict() + + self.root_elt = self.pfsense.get_element('dhcpd') + self.target = None + self.network = None + + ############################## + # params processing + # + def _get_logical_interface(self, interface): + """ Find the logical interface name """ + for iface in self.pfsense.interfaces: + # Check if it matches the logical name (e.g., 'lan', 'wan', 'opt1') + if iface.tag.lower() == interface.lower(): + return iface.tag + + # Check if it matches the physical interface name (e.g., 'em0', 'igb0') + if_elt = iface.find('if') + if if_elt is not None and if_elt.text.strip().lower() == interface.lower(): + return iface.tag + + # Check if it matches the interface description + descr_elt = iface.find('descr') + if descr_elt is not None and descr_elt.text.strip().lower() == interface.lower(): + return iface.tag + + return None + + def _is_valid_netif(self, netif): + for nic in self.pfsense.interfaces: + if nic.tag == netif: + if nic.find('ipaddr') is not None: + ipaddr = nic.find('ipaddr').text + if ipaddr is not None: + if nic.find('subnet') is not None: + subnet = int(nic.find('subnet').text) + if subnet < 31: + self.network = ip_network(u'{0}/{1}'.format(ipaddr, subnet), strict=False) + return True + return False + + def _is_valid_macaddr(self, macaddr): + return bool(re.fullmatch(r'(?:[0-9a-fA-F]{2}[:-]){5}[0-9a-fA-F]{2}', macaddr, re.I)) + + def _params_to_obj(self): + """ return a dict from module params """ + params = self.params + + obj = dict() + self.obj = obj + + if params['state'] == 'present': + + self._get_ansible_param(obj, 'range', force_value = {}, force=True) + self._get_ansible_param(obj['range'], 'range_from', fname='from', force=True) + self._get_ansible_param(obj['range'], 'range_to', fname='to', force=True) + + # Forced options + for option in [ 'failover_peerip', 'defaultleasetime', 'maxleasetime', + 'netmask', 'gateway', 'domain', 'domainsearchlist', + 'ddnsdomain', 'ddnsdomainprimary', 'ddnsdomainkeyname', + 'ddnsdomainkeyalgorithm', 'ddnsdomainkey', 'mac_allow', + 'mac_deny', 'ddnsclientupdates', 'tftp', 'ldap', + 'nextserver', 'filename', 'filename32', 'filename64', + 'rootpath', 'numberoptions']: + self._get_ansible_param(obj, option, force=True) + + for option in ['mac_allow', 'mac_deny']: + if params[option] is None: + params[option] = "" + self._get_ansible_param(obj, ','.join(params[option])) + + # Non-forced options + for option in [ 'winsserver', 'dnsserver', 'ntpserver']: + self._get_ansible_param(obj, option) + for option in [ 'enable', 'ignorebootp', 'nonak', 'ignoreclientuids', + 'staticarp', 'disablepingcheck']: + self._get_ansible_param_bool(obj, option, value='') + for option in [ 'dhcpinlocaltime', 'statsgraph' ]: + self._get_ansible_param_bool(obj, option, value='yes') + + self._get_ansible_param(obj, 'denyunknown') + + # Defaulted options + self._get_ansible_param(obj, 'ddnsdomainkeyalgorithm', force_value='hmac-md5', force=True) + + return obj + + def _validate_params(self): + """ do some extra checks on input parameters """ + params = self.params + + self.target = self._get_logical_interface(params['interface']) + if self.target is None or self.target.lower() == "wan": + self.module.fail_json(msg=f"The specified interface {params['interface']} is not a valid logical interface or cannot be mapped to one") + + if not self._is_valid_netif(self.target): + self.module.fail_json(msg=f"The specified interface {params['interface']} is not a valid logical interface") + + if params['state'] == 'present' and params['enable']: + if params.get('range_from') is None or params.get('range_to') is None: + self.module.fail_json(msg=f"The specified interface {params['interface']}'requires an IP range") + + if not self.pfsense.is_ipv4_address(params['range_from']): + self.module.fail_json(msg="The 'range_from' address is not a valid IPv4 address") + if not self.pfsense.is_ipv4_address(params['range_to']): + self.module.fail_json(msg="The 'range_to' address is not a valid IPv4 address") + + if not ip_address(params['range_from']) in self.network or not ip_address(params['range_to']) in self.network: + self.module.fail_json(msg=f"The IP address must lie in the {params['interface']} subnet.") + + if ip_address(params['range_from']) >= ip_address(params['range_to']): + self.module.fail_json(msg=f"The interface {params['interface']} must have a valid IP range pool.") + + if params.get('gateway'): + if not self.pfsense.is_ipv4_address(params['gateway']): + self.module.fail_json(msg="The 'gateway' is not a valid IPv4 address") + + if params.get('mac_allow'): + for macaddr in params["mac_allow"]: + is_valid = self._is_valid_macaddr(macaddr) + if not is_valid: + self.module.fail_json(msg=f"The MAC address {macaddr} is invalid.") + + if params.get('mac_deny'): + for macaddr in params["mac_deny"]: + is_valid = self._is_valid_macaddr(macaddr) + if not is_valid: + self.module.fail_json(msg=f"The MAC address {macaddr} is invalid.") + + if params.get('denyunknown'): + if params['denyunknown'] not in ['enabled', 'class']: + self.module.fail_json(msg=f"The option {params['denyunknown']} is invalid, use none, 'enabled' or 'class'") + + + ############################## + # XML processing + # + @staticmethod + def _get_params_to_remove(): + """ returns the list of params to remove if they are not set """ + params = ['enable', 'ignorebootp', 'nonak', 'ignoreclientuids', 'staticarp', 'disablepingcheck', 'dhcpinlocaltime', 'statsgraph'] + return params + + def _create_target(self): + """ create the XML target_elt """ + return self.pfsense.new_element(self.target) + + def _find_target(self): + """ find the XML target_elt """ + return self.pfsense.get_element(self.target, root_elt=self.root_elt) + + ############################## + # Logging + # + def _get_obj_name(self): + """ return obj's name """ + return f"'{self.target}'" + + def _log_fields(self, before=None): + """ generate pseudo-CLI command fields parameters to create an obj """ + values = '' + if before is None: + values += self.format_cli_field(self.obj, 'enable') + values += self.format_cli_field(self.obj, 'range_from') + values += self.format_cli_field(self.obj, 'range_to') + values += self.format_cli_field(self.obj, 'failover_peerip') + values += self.format_cli_field(self.obj, 'defaultleasetime') + values += self.format_cli_field(self.obj, 'maxleasetime') + values += self.format_cli_field(self.obj, 'netmask') + values += self.format_cli_field(self.obj, 'gateway') + values += self.format_cli_field(self.obj, 'domain') + values += self.format_cli_field(self.obj, 'domainsearchlist') + values += self.format_cli_field(self.obj, 'ddnsdomain') + values += self.format_cli_field(self.obj, 'ddnsdomainprimary') + values += self.format_cli_field(self.obj, 'ddnsdomainkeyname') + values += self.format_cli_field(self.obj, 'ddnsdomainkeyalgorithm') + values += self.format_cli_field(self.obj, 'ddnsdomainkey') + values += self.format_cli_field(self.obj, 'mac_allow') + values += self.format_cli_field(self.obj, 'mac_deny') + values += self.format_cli_field(self.obj, 'ddnsclientupdates') + values += self.format_cli_field(self.obj, 'tftp') + values += self.format_cli_field(self.obj, 'ldap') + values += self.format_cli_field(self.obj, 'nextserver') + values += self.format_cli_field(self.obj, 'filename') + values += self.format_cli_field(self.obj, 'filename32') + values += self.format_cli_field(self.obj, 'filename64') + values += self.format_cli_field(self.obj, 'rootpath') + values += self.format_cli_field(self.obj, 'numberoptions') + else: + values += self.format_updated_cli_field(self.obj, before, 'enable') + values += self.format_updated_cli_field(self.obj, before, 'range_from') + values += self.format_updated_cli_field(self.obj, before, 'range_to') + values += self.format_updated_cli_field(self.obj, before, 'failover_peerip') + values += self.format_updated_cli_field(self.obj, before, 'defaultleasetime') + values += self.format_updated_cli_field(self.obj, before, 'maxleasetime') + values += self.format_updated_cli_field(self.obj, before, 'netmask') + values += self.format_updated_cli_field(self.obj, before, 'gateway') + values += self.format_updated_cli_field(self.obj, before, 'domain') + values += self.format_updated_cli_field(self.obj, before, 'domainsearchlist') + values += self.format_updated_cli_field(self.obj, before, 'ddnsdomain') + values += self.format_updated_cli_field(self.obj, before, 'ddnsdomainprimary') + values += self.format_updated_cli_field(self.obj, before, 'ddnsdomainkeyname') + values += self.format_updated_cli_field(self.obj, before, 'ddnsdomainkeyalgorithm') + values += self.format_updated_cli_field(self.obj, before, 'ddnsdomainkey') + values += self.format_updated_cli_field(self.obj, before, 'mac_allow') + values += self.format_updated_cli_field(self.obj, before, 'mac_deny') + values += self.format_updated_cli_field(self.obj, before, 'ddnsclientupdates') + values += self.format_updated_cli_field(self.obj, before, 'tftp') + values += self.format_updated_cli_field(self.obj, before, 'ldap') + values += self.format_updated_cli_field(self.obj, before, 'nextserver') + values += self.format_updated_cli_field(self.obj, before, 'filename') + values += self.format_updated_cli_field(self.obj, before, 'filename32') + values += self.format_updated_cli_field(self.obj, before, 'filename64') + values += self.format_updated_cli_field(self.obj, before, 'rootpath') + values += self.format_updated_cli_field(self.obj, before, 'numberoptions') + return values + + ############################## + # run + # + def _update(self): + """ make the target pfsense reload """ + return self.pfsense.phpshell(""" + require_once("util.inc"); + require_once("services.inc"); + services_dhcpd_configure(); + """) + + def _pre_remove_target_elt(self): + self.diff['after'] = {} + if self.target_elt is not None: + self.diff['before'] = self.pfsense.element_to_dict(self.target_elt) + else: + self.diff['before'] = {} diff --git a/plugins/modules/pfsense_dhcp_server.py b/plugins/modules/pfsense_dhcp_server.py new file mode 100644 index 00000000..63daa45f --- /dev/null +++ b/plugins/modules/pfsense_dhcp_server.py @@ -0,0 +1,211 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2023, Your Name +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +ANSIBLE_METADATA = {'metadata_version': '6.1', + 'status': ['preview'], + 'supported_by': 'community'} + +DOCUMENTATION = """ +--- +module: pfsense_dhcp_server +version_added: "0.1.0" +author: "Your Name (@yourgithubusername)" +short_description: Manage pfSense DHCP servers +description: + - Manage DHCP servers on pfSense +notes: +options: + state: + description: State in which to leave the DHCP server + choices: [ "present", "absent" ] + default: present + type: str + interface: + description: Interface on which to configure the DHCP server + required: true + type: str + enable: + description: Enable DHCP server on the interface + type: bool + default: true + range_from: + description: Start of IP address range + type: str + range_to: + description: End of IP address range + type: str + failover_peerip: + description: Failover peer IP address + type: str + defaultleasetime: + description: Default lease time in seconds + type: int + maxleasetime: + description: Maximum lease time in seconds + type: int + netmask: + description: Subnet mask + type: str + gateway: + description: Gateway IP address + type: str + domain: + description: Domain name + type: str + domainsearchlist: + description: Domain search list + type: str + ddnsdomain: + description: DDNS domain + type: str + ddnsdomainprimary: + description: DDNS domain primary server + type: str + ddnsdomainkeyname: + description: DDNS domain key name + type: str + ddnsdomainkeyalgorithm: + description: DDNS domain key algorithm + type: str + choices: [ 'hmac-md5', 'hmac-sha1', 'hmac-sha224', 'hmac-sha256', 'hmac-sha384', 'hmac-sha512' ] + default: hmac-md5 + ddnsdomainkey: + description: DDNS domain key + type: str + mac_allow: + description: Allowed MAC addresses + type: list + elements: str + mac_deny: + description: Denied MAC addresses + type: list + elements: str + ddnsclientupdates: + description: DDNS client updates + type: str + default: 'allow' + choices: [ 'allow', 'deny', 'ignore' ] + tftp: + description: TFTP server + type: str + ldap: + description: LDAP server + type: str + nextserver: + description: Next server + type: str + filename: + description: Filename + type: str + filename32: + description: 32-bit filename + type: str + filename64: + description: 64-bit filename + type: str + rootpath: + description: Root path + type: str + numberoptions: + description: Additional DHCP options + type: list + elements: dict + suboptions: + number: + description: Option number + type: int + required: true + type: + description: Option type + type: str + required: true + choices: [ 'text', 'string', 'boolean', 'unsigned integer 8', 'unsigned integer 16', 'unsigned integer 32', 'signed integer 8', 'signed integer 16', 'signed integer 32', 'ip-address' ] + value: + description: Option value + type: str + required: true + ignorebootp: + description: Disable BOOTP + type: bool + default: none + denyunknown: + description: Enable DHCP to ignore unknown clients + type: str + default: none + choices: none, enabled, class + nonak: + description: Ignore denied clients + type: bool + default: none + ignoreclientuids: + description: Ignore client identifiers + type: bool + default: none + staticarp: + description: Enable Static ARP entries + type: bool + default: none + dhcpinlocaltime: + description: Change DHCP display lease time from UTC to local time + type: bool + default: none + statsgraph: + description: Enable monitoring graphs for lease DHCP statistics + type: bool + default: none + disablepingcheck: + description: Enable DHCP ping check + type: bool + default: none +""" + +EXAMPLES = """ +- name: Configure DHCP server on IOT interface + pfsense_dhcp_server: + interface: IOT + enable: true + range_from: 192.168.1.100 + range_to: 192.168.1.200 + netmask: 255.255.255.0 + gateway: 192.168.1.1 + domain: example.com + defaultleasetime: 86400 + maxleasetime: 172800 + +- name: Remove DHCP server from opt1 interface + pfsense_dhcp_server: + interface: opt1 + state: absent +""" + +RETURN = """ +commands: + description: The set of commands that would be pushed to the remote device. + returned: always + type: list + sample: [ + "create dhcp_server 'IOT', range_from='192.168.1.100', range_to='192.168.1.200', enable='True'", + "update dhcp_server 'IOT' set domain='example.com'", + "delete dhcp_server 'opt1'" + ] +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.pfsensible.core.plugins.module_utils.dhcp_server import PFSenseDHCPDServerModule, DHCPD_SERVER_ARGUMENT_SPEC + +def main(): + module = AnsibleModule( + argument_spec=DHCPD_SERVER_ARGUMENT_SPEC, + supports_check_mode=True) + pfmodule = PFSenseDHCPDServerModule(module) + pfmodule.run(module.params) + pfmodule.commit_changes() + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/tests/unit/plugins/modules/fixtures/pfsense_dhcp_server_config.xml b/tests/unit/plugins/modules/fixtures/pfsense_dhcp_server_config.xml new file mode 100644 index 00000000..ba346eaa --- /dev/null +++ b/tests/unit/plugins/modules/fixtures/pfsense_dhcp_server_config.xml @@ -0,0 +1,89 @@ + + 21.02 + + pfSense + example.com + + + + em0 + WAN + 203.0.113.1 + 24 + + + em1 + LAN + 192.168.1.1 + 24 + + + em2 + OPT1 + 10.0.0.1 + 24 + + + + + + + 192.168.1.100 + 192.168.1.199 + + + 86400 + 172800 + + + + + + + + hmac-md5 + + + + allow + + + + + + + + + + + + + 10.0.0.100 + 10.0.0.199 + + + 86400 + 172800 + + + opt1.example.com + + + + + hmac-md5 + + + + allow + + + + + + + + + + + diff --git a/tests/unit/plugins/modules/test_pfsense_dhcp_server.py b/tests/unit/plugins/modules/test_pfsense_dhcp_server.py new file mode 100644 index 00000000..f7b34313 --- /dev/null +++ b/tests/unit/plugins/modules/test_pfsense_dhcp_server.py @@ -0,0 +1,118 @@ +import pytest +import sys + +if sys.version_info < (2, 7): + pytestmark = pytest.mark.skip("pfSense Ansible modules require Python >= 2.7") + +from ansible_collections.pfsensible.core.plugins.modules import pfsense_dhcp_server +from ansible_collections.pfsensible.core.plugins.modules.pfsense_dhcp_server import PFSenseDHCPDServerModule +from .pfsense_module import TestPFSenseModule + +class TestPFSenseDHCPServerModule(TestPFSenseModule): + + module = pfsense_dhcp_server + + def __init__(self, *args, **kwargs): + super(TestPFSenseDHCPServerModule, self).__init__(*args, **kwargs) + self.config_file = 'pfsense_dhcp_server_config.xml' + self.pfmodule = PFSenseDHCPDServerModule + + def check_target_elt(self, obj, target_elt, target_idx=-1): + """ test the xml definition """ + self.check_param_equal(obj, target_elt, 'interface') + self.check_param_equal(obj, target_elt, 'enable') + self.check_param_equal_or_present(obj, target_elt, 'range_from', xml_field='range/from') + self.check_param_equal_or_present(obj, target_elt, 'range_to', xml_field='range/to') + self.check_param_equal(obj, target_elt, 'failover_peerip') + self.check_param_equal(obj, target_elt, 'defaultleasetime') + self.check_param_equal(obj, target_elt, 'maxleasetime') + self.check_param_equal(obj, target_elt, 'netmask') + self.check_param_equal(obj, target_elt, 'gateway') + self.check_param_equal(obj, target_elt, 'domain') + self.check_param_equal(obj, target_elt, 'domainsearchlist') + self.check_param_equal(obj, target_elt, 'ddnsdomain') + self.check_param_equal(obj, target_elt, 'ddnsdomainprimary') + self.check_param_equal(obj, target_elt, 'ddnsdomainkeyname') + self.check_param_equal(obj, target_elt, 'ddnsdomainkeyalgorithm') + self.check_param_equal(obj, target_elt, 'ddnsdomainkey') + self.check_param_equal(obj, target_elt, 'mac_allow') + self.check_param_equal(obj, target_elt, 'mac_deny') + self.check_param_equal(obj, target_elt, 'tftp') + self.check_param_equal(obj, target_elt, 'ldap') + self.check_param_equal(obj, target_elt, 'nextserver') + self.check_param_equal(obj, target_elt, 'filename') + self.check_param_equal(obj, target_elt, 'filename32') + self.check_param_equal(obj, target_elt, 'filename64') + self.check_param_equal(obj, target_elt, 'rootpath') + self.check_param_equal(obj, target_elt, 'numberoptions') + + def get_target_elt(self, obj, absent=False, module_result=None): + """ get the generated xml definition """ + root_elt = self.assert_find_xml_elt(self.xml_result, 'dhcpd') + return root_elt.find(obj['interface']) + + ############## + # tests + # + def test_dhcp_server_create(self): + """ test creation of a new DHCP server """ + obj = dict( + interface='opt2', + enable=True, + range_from='172.16.0.100', + range_to='172.16.0.199', + defaultleasetime=86400, + maxleasetime=172800, + domain='opt2.example.com' + ) + command = "create dhcp_server 'opt2', enable=True, range_from='172.16.0.100', range_to='172.16.0.199', defaultleasetime='86400', maxleasetime='172800', domain='opt2.example.com'" + self.do_module_test(obj, command=command) + + def test_dhcp_server_update(self): + """ test updating an existing DHCP server """ + obj = dict( + interface='lan', + enable=True, + range_from='192.168.1.50', + range_to='192.168.1.150', + domain='updated.example.com' + ) + command = "update dhcp_server 'lan' set enable=True, range_from='192.168.1.50', range_to='192.168.1.150', domain='updated.example.com'" + self.do_module_test(obj, command=command) + + def test_dhcp_server_delete(self): + """ test deletion of a DHCP server """ + obj = dict(interface='opt1', state='absent') + command = "delete dhcp_server 'opt1'" + self.do_module_test(obj, command=command, delete=True) + + def test_dhcp_server_create_invalid_interface(self): + """ test creation with an invalid interface """ + obj = dict(interface='invalid_interface', enable=True, range_from='192.168.1.100', range_to='192.168.1.200') + self.do_module_test(obj, failed=True, msg='The interface invalid_interface does not exist') + + def test_dhcp_server_create_invalid_range(self): + """ test creation with an invalid IP range """ + obj = dict(interface='lan', enable=True, range_from='192.168.1.200', range_to='192.168.1.100') + self.do_module_test(obj, failed=True, msg='The start of the IP range must be less than the end of the IP range') + + def test_dhcp_server_create_with_options(self): + """ test creation with additional DHCP options """ + obj = dict( + interface='opt1', + enable=True, + range_from='10.0.0.50', + range_to='10.0.0.150', + defaultleasetime=43200, + maxleasetime=86400, + domain='opt1.example.com', + ddnsdomain='ddns.example.com', + ddnsdomainprimary='10.0.0.2', + tftp='10.0.0.3', + numberoptions=[ + dict(number=66, type='string', value='10.0.0.4'), + dict(number=67, type='string', value='pxeboot.0') + ] + ) + command = "create dhcp_server 'opt1', enable=True, range_from='10.0.0.50', range_to='10.0.0.150', defaultleasetime='43200', maxleasetime='86400', domain='opt1.example.com', ddnsdomain='ddns.example.com', ddnsdomainprimary='10.0.0.2', tftp='10.0.0.3', numberoptions='[{\"number\": 66, \"type\": \"string\", \"value\": \"10.0.0.4\"}, {\"number\": 67, \"type\": \"string\", \"value\": \"pxeboot.0\"}]'" + self.do_module_test(obj, command=command) From c44ac84712faec0474800334af2965090ecc967f Mon Sep 17 00:00:00 2001 From: David Rosado Date: Fri, 27 Sep 2024 13:49:14 -0400 Subject: [PATCH 2/5] add dhcp node --- plugins/module_utils/pfsense.py | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/module_utils/pfsense.py b/plugins/module_utils/pfsense.py index 7236e04d..bff855ae 100644 --- a/plugins/module_utils/pfsense.py +++ b/plugins/module_utils/pfsense.py @@ -70,6 +70,7 @@ def __init__(self, module, config='/cf/conf/config.xml'): self.root = self.tree.getroot() self.config_version = float(self.get_element('version').text) self.aliases = self.get_element('aliases', create_node=True) + self.dhcpd = self.get_element('dhcpd', create_node=True) self.interfaces = self.get_element('interfaces') self.ifgroups = self.get_element('ifgroups') self.rules = self.get_element('filter') From 3910178856fca4bbc1d1dd8d6b7349872b3033be Mon Sep 17 00:00:00 2001 From: David Rosado Date: Fri, 27 Sep 2024 21:19:53 -0400 Subject: [PATCH 3/5] making changes to tests --- plugins/module_utils/dhcp_server.py | 20 +- .../fixtures/pfsense_dhcp_server_config.xml | 288 ++++++++++++------ .../modules/test_pfsense_dhcp_server.py | 21 +- 3 files changed, 221 insertions(+), 108 deletions(-) diff --git a/plugins/module_utils/dhcp_server.py b/plugins/module_utils/dhcp_server.py index 8044833a..311e3e74 100644 --- a/plugins/module_utils/dhcp_server.py +++ b/plugins/module_utils/dhcp_server.py @@ -173,10 +173,10 @@ def _validate_params(self): self.module.fail_json(msg="The 'range_to' address is not a valid IPv4 address") if not ip_address(params['range_from']) in self.network or not ip_address(params['range_to']) in self.network: - self.module.fail_json(msg=f"The IP address must lie in the {params['interface']} subnet.") + self.module.fail_json(msg=f"The IP address must lie in the {params['interface']} subnet") if ip_address(params['range_from']) >= ip_address(params['range_to']): - self.module.fail_json(msg=f"The interface {params['interface']} must have a valid IP range pool.") + self.module.fail_json(msg=f"The interface {params['interface']} must have a valid IP range pool") if params.get('gateway'): if not self.pfsense.is_ipv4_address(params['gateway']): @@ -186,13 +186,13 @@ def _validate_params(self): for macaddr in params["mac_allow"]: is_valid = self._is_valid_macaddr(macaddr) if not is_valid: - self.module.fail_json(msg=f"The MAC address {macaddr} is invalid.") + self.module.fail_json(msg=f"The MAC address {macaddr} is invalid") if params.get('mac_deny'): for macaddr in params["mac_deny"]: is_valid = self._is_valid_macaddr(macaddr) if not is_valid: - self.module.fail_json(msg=f"The MAC address {macaddr} is invalid.") + self.module.fail_json(msg=f"The MAC address {macaddr} is invalid") if params.get('denyunknown'): if params['denyunknown'] not in ['enabled', 'class']: @@ -227,9 +227,9 @@ def _log_fields(self, before=None): """ generate pseudo-CLI command fields parameters to create an obj """ values = '' if before is None: - values += self.format_cli_field(self.obj, 'enable') - values += self.format_cli_field(self.obj, 'range_from') - values += self.format_cli_field(self.obj, 'range_to') + values += self.format_cli_field(self.obj, 'enable', fvalue=self.fvalue_bool) + values += self.format_cli_field(self.obj["range"], 'from', fname="range_from") + values += self.format_cli_field(self.obj["range"], 'to', fname="range_to") values += self.format_cli_field(self.obj, 'failover_peerip') values += self.format_cli_field(self.obj, 'defaultleasetime') values += self.format_cli_field(self.obj, 'maxleasetime') @@ -254,9 +254,9 @@ def _log_fields(self, before=None): values += self.format_cli_field(self.obj, 'rootpath') values += self.format_cli_field(self.obj, 'numberoptions') else: - values += self.format_updated_cli_field(self.obj, before, 'enable') - values += self.format_updated_cli_field(self.obj, before, 'range_from') - values += self.format_updated_cli_field(self.obj, before, 'range_to') + values += self.format_updated_cli_field(self.obj, before, 'enable', fvalue=self.fvalue_bool) + values += self.format_updated_cli_field(self.obj["range"], before["range"], 'from', fname="range_from") + values += self.format_updated_cli_field(self.obj["range"], before["range"], 'to', fname="range_to") values += self.format_updated_cli_field(self.obj, before, 'failover_peerip') values += self.format_updated_cli_field(self.obj, before, 'defaultleasetime') values += self.format_updated_cli_field(self.obj, before, 'maxleasetime') diff --git a/tests/unit/plugins/modules/fixtures/pfsense_dhcp_server_config.xml b/tests/unit/plugins/modules/fixtures/pfsense_dhcp_server_config.xml index ba346eaa..ae2f0fca 100644 --- a/tests/unit/plugins/modules/fixtures/pfsense_dhcp_server_config.xml +++ b/tests/unit/plugins/modules/fixtures/pfsense_dhcp_server_config.xml @@ -1,89 +1,201 @@ - 21.02 - - pfSense - example.com - - - - em0 - WAN - 203.0.113.1 - 24 - - - em1 - LAN - 192.168.1.1 - 24 - - - em2 - OPT1 - 10.0.0.1 - 24 - - - - - - - 192.168.1.100 - 192.168.1.199 - - - 86400 - 172800 - - - - - - - - hmac-md5 - - - - allow - - - - - - - - - - - - - 10.0.0.100 - 10.0.0.199 - - - 86400 - 172800 - - - opt1.example.com - - - - - hmac-md5 - - - - allow - - - - - - - - - - - + 23.3 + + normal + pfSense + acme.com + on + + all + All Users + system + 1998 + + + admins + System Administrators + system + 1999 + 0 + page-all + + + + system + + test + + + + + system + + groupe1 + + + + + system + + groupe2 + + + + admin + System Administrator + system + admins + $2y$10$AMCpA.Z.RNaferLp1yzFq.BvaGgfqaJKtQug7OErbocyNagsEK6xW + 0 + user-shell-access + + 2 + + + pfSense.css + + 2000 + 2000 + 0.pfsense.pool.ntp.org + + http + + 5c00e5f9029df + 2 + + yes + + + + 400000 + hadp + hadp + hadp + + monthly + + + + Etc/UTC + + + + + em0 + + dhcp + dhcp6 + + + + + + + + + 0 + + + + em1 + 192.168.1.1 + 24 + + + + + wan + 0 + + + + + em2 + opt1 + 10.0.0.1 + 24 + + + em1.100 + VLAN 100 + 172.16.0.1 + 24 + + + + + em1 + 100 + + VLAN 100 on LAN + em1.100 + + + + + + + 192.168.1.100 + 192.168.1.199 + + + 86400 + 172800 + + + + + + + + hmac-md5 + + + + allow + + + + + + + + + + + + + 10.0.0.100 + 10.0.0.199 + + + 86400 + 172800 + + + opt1.example.com + + + + + hmac-md5 + + + + allow + + + + + + + + + + + + + aggregated change + + + \ No newline at end of file diff --git a/tests/unit/plugins/modules/test_pfsense_dhcp_server.py b/tests/unit/plugins/modules/test_pfsense_dhcp_server.py index f7b34313..a2f77747 100644 --- a/tests/unit/plugins/modules/test_pfsense_dhcp_server.py +++ b/tests/unit/plugins/modules/test_pfsense_dhcp_server.py @@ -19,10 +19,10 @@ def __init__(self, *args, **kwargs): def check_target_elt(self, obj, target_elt, target_idx=-1): """ test the xml definition """ - self.check_param_equal(obj, target_elt, 'interface') - self.check_param_equal(obj, target_elt, 'enable') - self.check_param_equal_or_present(obj, target_elt, 'range_from', xml_field='range/from') - self.check_param_equal_or_present(obj, target_elt, 'range_to', xml_field='range/to') + # self.check_param_equal(obj, target_elt, 'interface') + self.check_param_bool(obj, target_elt, 'enable') + self.check_param_equal(obj, target_elt, 'range_from', xml_field='range/from') + self.check_param_equal(obj, target_elt, 'range_to', xml_field='range/to') self.check_param_equal(obj, target_elt, 'failover_peerip') self.check_param_equal(obj, target_elt, 'defaultleasetime') self.check_param_equal(obj, target_elt, 'maxleasetime') @@ -33,7 +33,7 @@ def check_target_elt(self, obj, target_elt, target_idx=-1): self.check_param_equal(obj, target_elt, 'ddnsdomain') self.check_param_equal(obj, target_elt, 'ddnsdomainprimary') self.check_param_equal(obj, target_elt, 'ddnsdomainkeyname') - self.check_param_equal(obj, target_elt, 'ddnsdomainkeyalgorithm') + self.check_param_equal(obj, target_elt, 'ddnsdomainkeyalgorithm', default='hmac-md5') self.check_param_equal(obj, target_elt, 'ddnsdomainkey') self.check_param_equal(obj, target_elt, 'mac_allow') self.check_param_equal(obj, target_elt, 'mac_deny') @@ -65,7 +65,7 @@ def test_dhcp_server_create(self): maxleasetime=172800, domain='opt2.example.com' ) - command = "create dhcp_server 'opt2', enable=True, range_from='172.16.0.100', range_to='172.16.0.199', defaultleasetime='86400', maxleasetime='172800', domain='opt2.example.com'" + command = "create dhcp_server 'opt2', enable=True, range_from='172.16.0.100', range_to='172.16.0.199', failover_peerip='', defaultleasetime='86400', maxleasetime='172800', netmask='', gateway='', domain='opt2.example.com', domainsearchlist='', ddnsdomain='', ddnsdomainprimary='', ddnsdomainkeyname='', ddnsdomainkeyalgorithm='hmac-md5', ddnsdomainkey='', mac_allow='', mac_deny='', ddnsclientupdates='allow', tftp='', ldap='', nextserver='', filename='', filename32='', filename64='', rootpath='', numberoptions=''" self.do_module_test(obj, command=command) def test_dhcp_server_update(self): @@ -77,7 +77,7 @@ def test_dhcp_server_update(self): range_to='192.168.1.150', domain='updated.example.com' ) - command = "update dhcp_server 'lan' set enable=True, range_from='192.168.1.50', range_to='192.168.1.150', domain='updated.example.com'" + command = "update dhcp_server 'lan' set , range_from='192.168.1.50', range_to='192.168.1.150', defaultleasetime='', maxleasetime='', domain='updated.example.com'" self.do_module_test(obj, command=command) def test_dhcp_server_delete(self): @@ -89,12 +89,13 @@ def test_dhcp_server_delete(self): def test_dhcp_server_create_invalid_interface(self): """ test creation with an invalid interface """ obj = dict(interface='invalid_interface', enable=True, range_from='192.168.1.100', range_to='192.168.1.200') - self.do_module_test(obj, failed=True, msg='The interface invalid_interface does not exist') + self.do_module_test(obj, failed=True, msg='The specified interface invalid_interface is not a valid logical interface or cannot be mapped to one') def test_dhcp_server_create_invalid_range(self): """ test creation with an invalid IP range """ - obj = dict(interface='lan', enable=True, range_from='192.168.1.200', range_to='192.168.1.100') - self.do_module_test(obj, failed=True, msg='The start of the IP range must be less than the end of the IP range') + interface = 'lan' + obj = dict(interface=interface, enable=True, range_from='192.168.1.200', range_to='192.168.1.100') + self.do_module_test(obj, failed=True, msg=f'The interface {interface} must have a valid IP range pool') def test_dhcp_server_create_with_options(self): """ test creation with additional DHCP options """ From 44943de162d434f8e841908bca46f83ada2131fc Mon Sep 17 00:00:00 2001 From: David Rosado Date: Sat, 28 Sep 2024 00:29:58 -0400 Subject: [PATCH 4/5] improving tests --- plugins/module_utils/dhcp_server.py | 7 ++++++- .../modules/test_pfsense_dhcp_server.py | 18 +++++++++--------- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/plugins/module_utils/dhcp_server.py b/plugins/module_utils/dhcp_server.py index 311e3e74..f40d71a7 100644 --- a/plugins/module_utils/dhcp_server.py +++ b/plugins/module_utils/dhcp_server.py @@ -39,7 +39,10 @@ filename32=dict(type='str'), filename64=dict(type='str'), rootpath=dict(type='str'), - numberoptions=dict(type='list', elements='dict'), + numberoptions=dict(type='str'), + winsserver=dict(type='list', elements='str'), + dnsserver=dict(type='list', elements='str'), + ntpserver=dict(type='list', elements='str'), ignorebootp=dict(type='bool'), denyunknown=dict(type='str'), nonak=dict(type='bool'), @@ -139,9 +142,11 @@ def _params_to_obj(self): # Non-forced options for option in [ 'winsserver', 'dnsserver', 'ntpserver']: self._get_ansible_param(obj, option) + for option in [ 'enable', 'ignorebootp', 'nonak', 'ignoreclientuids', 'staticarp', 'disablepingcheck']: self._get_ansible_param_bool(obj, option, value='') + for option in [ 'dhcpinlocaltime', 'statsgraph' ]: self._get_ansible_param_bool(obj, option, value='yes') diff --git a/tests/unit/plugins/modules/test_pfsense_dhcp_server.py b/tests/unit/plugins/modules/test_pfsense_dhcp_server.py index a2f77747..3aae3a45 100644 --- a/tests/unit/plugins/modules/test_pfsense_dhcp_server.py +++ b/tests/unit/plugins/modules/test_pfsense_dhcp_server.py @@ -100,20 +100,20 @@ def test_dhcp_server_create_invalid_range(self): def test_dhcp_server_create_with_options(self): """ test creation with additional DHCP options """ obj = dict( - interface='opt1', + interface='opt2', enable=True, - range_from='10.0.0.50', - range_to='10.0.0.150', + range_from='172.16.0.50', + range_to='172.16.0.150', defaultleasetime=43200, maxleasetime=86400, domain='opt1.example.com', ddnsdomain='ddns.example.com', - ddnsdomainprimary='10.0.0.2', - tftp='10.0.0.3', - numberoptions=[ - dict(number=66, type='string', value='10.0.0.4'), - dict(number=67, type='string', value='pxeboot.0') + ddnsdomainprimary='172.16.0.60', + tftp='172.16.0.63', + disablepingcheck=True, + winsserver=['172.16.0.80', + '172.16.0.90' ] ) - command = "create dhcp_server 'opt1', enable=True, range_from='10.0.0.50', range_to='10.0.0.150', defaultleasetime='43200', maxleasetime='86400', domain='opt1.example.com', ddnsdomain='ddns.example.com', ddnsdomainprimary='10.0.0.2', tftp='10.0.0.3', numberoptions='[{\"number\": 66, \"type\": \"string\", \"value\": \"10.0.0.4\"}, {\"number\": 67, \"type\": \"string\", \"value\": \"pxeboot.0\"}]'" + command = "create dhcp_server 'opt2', enable=True, range_from='172.16.0.50', range_to='172.16.0.150', failover_peerip='', defaultleasetime='43200', maxleasetime='86400', netmask='', gateway='', domain='opt1.example.com', domainsearchlist='', ddnsdomain='ddns.example.com', ddnsdomainprimary='172.16.0.60', ddnsdomainkeyname='', ddnsdomainkeyalgorithm='hmac-md5', ddnsdomainkey='', mac_allow='', mac_deny='', ddnsclientupdates='allow', tftp='172.16.0.63', ldap='', nextserver='', filename='', filename32='', filename64='', rootpath='', numberoptions=''" self.do_module_test(obj, command=command) From 3ff166e4c6ce7f37d773008a4b05d305aec36cad Mon Sep 17 00:00:00 2001 From: David Rosado Date: Sat, 28 Sep 2024 00:44:05 -0400 Subject: [PATCH 5/5] last changes --- plugins/module_utils/dhcp_server.py | 2 +- plugins/modules/pfsense_dhcp_server.py | 21 +++---------------- .../modules/test_pfsense_dhcp_server.py | 8 ++++++- 3 files changed, 11 insertions(+), 20 deletions(-) diff --git a/plugins/module_utils/dhcp_server.py b/plugins/module_utils/dhcp_server.py index f40d71a7..110551f9 100644 --- a/plugins/module_utils/dhcp_server.py +++ b/plugins/module_utils/dhcp_server.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright: (c) 2023, Your Name +# Copyright: (c) 2024, David Rosado # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) from __future__ import absolute_import, division, print_function diff --git a/plugins/modules/pfsense_dhcp_server.py b/plugins/modules/pfsense_dhcp_server.py index 63daa45f..5fc2db0c 100644 --- a/plugins/modules/pfsense_dhcp_server.py +++ b/plugins/modules/pfsense_dhcp_server.py @@ -1,7 +1,7 @@ #!/usr/bin/python # -*- coding: utf-8 -*- -# Copyright: (c) 2023, Your Name +# Copyright: (c) 2024, David Rosado # GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) from __future__ import absolute_import, division, print_function @@ -113,23 +113,8 @@ description: Root path type: str numberoptions: - description: Additional DHCP options - type: list - elements: dict - suboptions: - number: - description: Option number - type: int - required: true - type: - description: Option type - type: str - required: true - choices: [ 'text', 'string', 'boolean', 'unsigned integer 8', 'unsigned integer 16', 'unsigned integer 32', 'signed integer 8', 'signed integer 16', 'signed integer 32', 'ip-address' ] - value: - description: Option value - type: str - required: true + description: DHCP options currently non applicable + type: str ignorebootp: description: Disable BOOTP type: bool diff --git a/tests/unit/plugins/modules/test_pfsense_dhcp_server.py b/tests/unit/plugins/modules/test_pfsense_dhcp_server.py index 3aae3a45..d91308c4 100644 --- a/tests/unit/plugins/modules/test_pfsense_dhcp_server.py +++ b/tests/unit/plugins/modules/test_pfsense_dhcp_server.py @@ -1,3 +1,9 @@ +# Copyright: (c) 2024, David Rosado +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + import pytest import sys @@ -106,7 +112,7 @@ def test_dhcp_server_create_with_options(self): range_to='172.16.0.150', defaultleasetime=43200, maxleasetime=86400, - domain='opt1.example.com', + domain='opt2.example.com', ddnsdomain='ddns.example.com', ddnsdomainprimary='172.16.0.60', tftp='172.16.0.63',