diff --git a/README.md b/README.md index 44f45db..fc075dd 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,7 @@ not implemented => development => [testing](https://github.com/ansibleguy/collec | **Nginx** | ansibleguy.opnsense.nginx_upstream_server | [Docs](https://opnsense.ansibleguy.net/modules/nginx.html#ansibleguy-opnsense-nginx-upstream-server) | unstable | | **DHCP Relay** | ansibleguy.opnsense.dhcrelay | [Docs](https://opnsense.ansibleguy.net/modules/dhcrelay_relay.html) | unstable | | **DHCP Relay** | ansibleguy.opnsense.dhcrelay_destination | [Docs](https://opnsense.ansibleguy.net/modules/dhcrelay_destination.html) | unstable | +| **DHCP Subnet** | ansibleguy.opnsense.dhcp_subnet | [Docs](https://opnsense.ansibleguy.net/modules/dhcp.html) | unstable | | **DHCP Reservation** | ansibleguy.opnsense.dhcp_reservation | [Docs](https://opnsense.ansibleguy.net/modules/dhcp.html) | unstable | | **DHCP Controlagent** | ansibleguy.opnsense.dhcp_controlagent | [Docs](https://opnsense.ansibleguy.net/modules/dhcp.html) | unstable | | **ACME (Certificates)** | ansibleguy.opnsense.acme_account | [Docs](https://opnsense.ansibleguy.net/modules/acmeclient.html) | unstable | diff --git a/meta/runtime.yml b/meta/runtime.yml index 42762b2..64adf4c 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -127,6 +127,7 @@ action_groups: dhcp: - ansibleguy.opnsense.dhcp_reservation - ansibleguy.opnsense.dhcp_controlagent + - ansibleguy.opnsense.dhcp_subnet acme: - ansibleguy.opnsense.acme_general - ansibleguy.opnsense.acme_account diff --git a/plugins/module_utils/helper/main.py b/plugins/module_utils/helper/main.py index 01f69a6..4ab3f41 100644 --- a/plugins/module_utils/helper/main.py +++ b/plugins/module_utils/helper/main.py @@ -446,6 +446,9 @@ def simplify_translate( # correct value types to match (for diff-checks) for t, fields in typing.items(): for f in fields: + if f in ignore: + continue + if t == 'bool': simple[f] = is_true(simple[f]) diff --git a/plugins/module_utils/main/dhcp_subnet_v4.py b/plugins/module_utils/main/dhcp_subnet_v4.py new file mode 100644 index 0000000..310c74b --- /dev/null +++ b/plugins/module_utils/main/dhcp_subnet_v4.py @@ -0,0 +1,105 @@ +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.ansibleguy.opnsense.plugins.module_utils.base.api import \ + Session +from ansible_collections.ansibleguy.opnsense.plugins.module_utils.helper.main import \ + get_selected_list, simplify_translate +from ansible_collections.ansibleguy.opnsense.plugins.module_utils.base.cls import BaseModule + + +class SubnetV4(BaseModule): + CMDS = { + 'add': 'addSubnet', + 'del': 'delSubnet', + 'set': 'setSubnet', + 'search': 'searchSubnet', + 'detail': 'getSubnet', + } + API_KEY = 'subnet4' + API_KEY_PATH = 'subnet4' + API_MOD = 'kea' + API_CONT = 'dhcpv4' + API_CONT_REL = 'service' + FIELDS_CHANGE = [ + 'subnet', 'description', 'pools', 'auto_options', + ] + FIELDS_ALL = FIELDS_CHANGE + FIELDS_TYPING = { + 'list': ['gateway', 'dns', 'domain_search', 'ntp_servers', 'time_servers'], # 'pools', + 'bool': ['auto_options'], + } + FIELDS_TRANSLATE = { + 'auto_options': 'option_data_autocollect', + } + API_ATTR_OPTIONS = 'option_data' + API_FIELDS_OPTIONS = [ + 'gateway', 'routes', 'dns', 'domain', 'domain_search', 'ntp_servers', 'time_servers', + 'next_server', 'tftp_server', 'tftp_file', + ] + POOL_JOIN_CHAR = '\n' + FIELDS_TRANSLATE_SPECIAL = { + 'dns': 'domain_name_servers', + 'domain': 'domain_name', + 'gateway': 'routers', + 'routes': 'static_routes', + 'tftp_server': 'tftp_server_name', + 'tftp_file': 'boot_file_name', + } + EXIST_ATTR = 'subnet' + + def __init__(self, module: AnsibleModule, result: dict, session: Session = None): + BaseModule.__init__(self=self, m=module, r=result, s=session) + self.subnet = {} + self.existing_subnets = None + + def _simplify_existing(self, entry: dict) -> dict: + simple = simplify_translate( + existing=entry, + typing=self.FIELDS_TYPING, + translate=self.FIELDS_TRANSLATE, + ignore=self.API_FIELDS_OPTIONS, + ) + + simple['pools'] = simple['pools'].split(self.POOL_JOIN_CHAR) + opts = entry[self.API_ATTR_OPTIONS] + if isinstance(opts, dict): + simple['dns'] = get_selected_list(opts[self.FIELDS_TRANSLATE_SPECIAL['dns']]) + simple['domain_search'] = get_selected_list(opts['domain_search']) + simple['gateway'] = get_selected_list(opts[self.FIELDS_TRANSLATE_SPECIAL['gateway']]) + simple['routes'] = opts[self.FIELDS_TRANSLATE_SPECIAL['routes']] + simple['domain'] = opts[self.FIELDS_TRANSLATE_SPECIAL['domain']] + simple['ntp_servers'] = get_selected_list(opts['ntp_servers']) + simple['time_servers'] = get_selected_list(opts['time_servers']) + simple['tftp_server'] = opts[self.FIELDS_TRANSLATE_SPECIAL['tftp_server']] + simple['tftp_file'] = opts[self.FIELDS_TRANSLATE_SPECIAL['tftp_file']] + + else: + opt_keys = list(self.FIELDS_TRANSLATE_SPECIAL.keys()) + opt_keys.extend(['domain_search', 'ntp_servers', 'time_servers']) + + for opt in opt_keys: + if opt in self.FIELDS_TYPING['list']: + simple[opt] = [] + + else: + simple[opt] = '' + + return simple + + def _build_request(self) -> dict: + raw_request = self.b.build_request(ignore_fields=self.API_FIELDS_OPTIONS) + + raw_request[self.API_KEY]['pools'] = self.POOL_JOIN_CHAR.join(self.p['pools']) + raw_request[self.API_KEY][self.API_ATTR_OPTIONS] = { + self.FIELDS_TRANSLATE_SPECIAL['dns']: self.b.RESP_JOIN_CHAR.join(self.p['dns']), + self.FIELDS_TRANSLATE_SPECIAL['gateway']: self.b.RESP_JOIN_CHAR.join(self.p['gateway']), + self.FIELDS_TRANSLATE_SPECIAL['routes']: self.p['routes'], + self.FIELDS_TRANSLATE_SPECIAL['domain']: self.p['domain'], + self.FIELDS_TRANSLATE_SPECIAL['tftp_server']: self.p['tftp_server'], + self.FIELDS_TRANSLATE_SPECIAL['tftp_file']: self.p['tftp_file'], + 'ntp_servers': self.b.RESP_JOIN_CHAR.join(self.p['ntp_servers']), + 'time_servers': self.b.RESP_JOIN_CHAR.join(self.p['time_servers']), + 'domain_search': self.b.RESP_JOIN_CHAR.join(self.p['domain_search']), + } + + return raw_request diff --git a/plugins/modules/dhcp_subnet.py b/plugins/modules/dhcp_subnet.py new file mode 100644 index 0000000..ca8f30e --- /dev/null +++ b/plugins/modules/dhcp_subnet.py @@ -0,0 +1,127 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (C) 2024, AnsibleGuy +# GNU General Public License v3.0+ (see https://www.gnu.org/licenses/gpl-3.0.txt) + +# see: https://docs.opnsense.org/development/api/core/kea.html + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.ansibleguy.opnsense.plugins.module_utils.base.handler import \ + module_dependency_error, MODULE_EXCEPTIONS + +try: + from ansible_collections.ansibleguy.opnsense.plugins.module_utils.helper.wrapper import module_wrapper + from ansible_collections.ansibleguy.opnsense.plugins.module_utils.defaults.main import \ + OPN_MOD_ARGS, STATE_MOD_ARG, RELOAD_MOD_ARG + from ansible_collections.ansibleguy.opnsense.plugins.module_utils.main.dhcp_subnet_v4 import SubnetV4 + +except MODULE_EXCEPTIONS: + module_dependency_error() + + +# DOCUMENTATION = 'https://opnsense.ansibleguy.net/modules/dhcp.html' +# EXAMPLES = 'https://opnsense.ansibleguy.net/modules/dhcp.html' + + +def run_module(): + module_args = dict( + subnet=dict( + type='str', required=True, + description='Subnet to use, should be large enough to hold the specified pools and reservations', + ), + description=dict( + type='str', required=False, aliases=['desc'], default='', + ), + pools=dict( + type='list', elements='str', required=False, default=[], + description='List of pools, one per line in range or subnet format ' + '(e.g. 192.168.0.100 - 192.168.0.200 , 192.0.2.64/26)' + ), + auto_options=dict( + type='bool', required=False, default=True, aliases=['option_data_autocollect'], + description='Automatically update option data for relevant attributes as routers, ' + 'dns servers and ntp servers when applying settings from the gui.' + ), + gateway=dict( + type='list', elements='str', required=False, aliases=['gw', 'routers'], default=[], + description='Default gateways to offer to the clients', + ), + routes=dict( + type='str', required=False, aliases=['static_routes'], default='', + description='Static routes that the client should install in its routing cache, ' + 'defined as dest-ip1,router-ip1;dest-ip2,router-ip2', + ), + dns=dict( + type='list', elements='str', required=False, aliases=['dns_servers', 'dns_srv'], default=[], + description='DNS servers to offer to the clients', + ), + domain=dict( + type='str', required=False, aliases=['domain_name', 'dom_name', 'dom'], default='', + description="The domain name to offer to the client, set to this firewall's domain name when left empty", + ), + domain_search=dict( + type='list', elements='str', required=False, aliases=['dom_search'], default=[], + description="Specifies a ´search list´ of Domain Names to be used by the client to locate " + 'not-fully-qualified domain names.', + ), + ntp_servers=dict( + type='list', elements='str', required=False, aliases=['ntp_srv', 'ntp'], default=[], + description='Specifies a list of IP addresses indicating NTP (RFC 5905) servers available to the client.', + ), + time_servers=dict( + type='list', elements='str', required=False, aliases=['time_srv'], default=[], + description='Specifies a list of RFC 868 time servers available to the client.', + ), + next_server=dict( + type='str', required=False, aliases=['next_srv'], default='', + description='Next server IP address', + ), + tftp_server=dict( + type='str', required=False, aliases=['tftp', 'tftp_srv', 'tftp_server_name'], default='', + description='TFTP server address or fqdn', + ), + tftp_file=dict( + type='str', required=False, aliases=['tftp_boot_file', 'boot_file_name'], default='', + description='Boot filename to request', + ), + ipv=dict(type='int', required=False, default=4, choices=[4, 6], aliases=['ip_version']), + match_fields=dict( + type='list', required=False, elements='str', + description='Fields that are used to match configured interface with the running config - ' + "if any of those fields are changed, the module will think it's a new entry", + choices=['subnet', 'description'], + default=['subnet'], + ), + **RELOAD_MOD_ARG, + **STATE_MOD_ARG, + **OPN_MOD_ARGS, + ) + + result = dict( + changed=False, + diff={ + 'before': {}, + 'after': {}, + } + ) + + module = AnsibleModule( + argument_spec=module_args, + supports_check_mode=True, + ) + + if module.params['ipv'] == 6: + module.fail_json('DHCPv6 is not yet supported!') + + module_wrapper(SubnetV4(module=module, result=result)) + module.exit_json(**result) + + +def main(): + run_module() + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/list.py b/plugins/modules/list.py index 1aa02c5..9e05a23 100644 --- a/plugins/modules/list.py +++ b/plugins/modules/list.py @@ -36,8 +36,8 @@ 'ipsec_child', 'ipsec_vti', 'ipsec_auth_local', 'ipsec_auth_remote', 'frr_general', 'unbound_general', 'unbound_acl', 'ids_general', 'ids_policy', 'ids_rule', 'ids_ruleset', 'ids_user_rule', 'ids_policy_rule', 'openvpn_instance', 'openvpn_static_key', 'openvpn_client_override', 'dhcrelay_destination', 'dhcrelay_relay', - 'interface_lagg', 'interface_loopback', 'unbound_dnsbl', 'dhcp_reservation', 'acme_general', 'acme_account', - 'acme_validation', 'acme_action', 'acme_certificate', + 'interface_lagg', 'interface_loopback', 'unbound_dnsbl', 'dhcp_reservation', 'dhcp_subnet', 'acme_general', + 'acme_account', 'acme_validation', 'acme_action', 'acme_certificate', ] @@ -419,6 +419,10 @@ def run_module(): from ansible_collections.ansibleguy.opnsense.plugins.module_utils.main.acme_certificate import \ Certificate as Target_Obj + elif target == 'dhcp_subnet': + from ansible_collections.ansibleguy.opnsense.plugins.module_utils.main.dhcp_subnet_v4 import \ + SubnetV4 as Target_Obj + except AttributeError: module_dependency_error() diff --git a/scripts/test.sh b/scripts/test.sh index 64a98f9..4027496 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -154,6 +154,7 @@ run_test 'nginx_upstream_server' 1 run_test 'dhcrelay_destination' 1 run_test 'dhcrelay_relay' 1 run_test 'dhcp_controlagent' 1 +run_test 'dhcp_subnet' 1 run_test 'dhcp_reservation' 1 run_test 'system' 1 run_test 'acme_general' 1 diff --git a/tests/1_cleanup.yml b/tests/1_cleanup.yml index b664c03..a3021ed 100644 --- a/tests/1_cleanup.yml +++ b/tests/1_cleanup.yml @@ -717,3 +717,12 @@ - 'ANSIBLE_TEST_1_9' - 'ANSIBLE_TEST_1_10' - 'ANSIBLE_TEST_DUMMY_1_1' + + - name: Cleanup DHCP Subnets + ansibleguy.opnsense.dhcp_subnet: + subnet: "{{ item }}" + state: 'absent' + loop: + - '192.168.69.0/24' + - '192.168.88.0/24' + - '192.168.89.0/24' diff --git a/tests/dhcp_reservation.yml b/tests/dhcp_reservation.yml index a3d1769..f76c623 100644 --- a/tests/dhcp_reservation.yml +++ b/tests/dhcp_reservation.yml @@ -13,8 +13,6 @@ target: 'dhcp_reservation' tasks: - # todo: add subnet via module - - name: Listing ansibleguy.opnsense.list: register: opn_pre1 @@ -61,6 +59,11 @@ failed_when: not opn_fail4.failed when: not ansible_check_mode + - name: Adding dummy DHCP-subnet + ansibleguy.opnsense.dhcp_subnet: + subnet: '192.168.69.0/24' + check_mode: false + - name: Adding 1 ansibleguy.opnsense.dhcp_reservation: subnet: '192.168.69.0/24' @@ -139,6 +142,12 @@ - '192.168.69.86' when: not ansible_check_mode + - name: Cleanup dummy DHCP-subnet + ansibleguy.opnsense.dhcp_subnet: + subnet: '192.168.69.0/24' + state: 'absent' + check_mode: false + - name: Listing ansibleguy.opnsense.list: register: opn_clean1 diff --git a/tests/dhcp_subnet.yml b/tests/dhcp_subnet.yml new file mode 100644 index 0000000..e47534d --- /dev/null +++ b/tests/dhcp_subnet.yml @@ -0,0 +1,133 @@ +--- + +- name: DHCP Subnet + hosts: localhost + gather_facts: no + module_defaults: + group/ansibleguy.opnsense.all: + firewall: "{{ lookup('ansible.builtin.env', 'TEST_FIREWALL') }}" + api_credential_file: "{{ lookup('ansible.builtin.env', 'TEST_API_KEY') }}" + ssl_verify: false + + ansibleguy.opnsense.list: + target: 'dhcp_subnet' + + tasks: + - name: Listing + ansibleguy.opnsense.list: + register: opn_pre1 + failed_when: > + 'data' not in opn_pre1 or + opn_pre1.data | length != 0 + + - name: Removing - does not exist + ansibleguy.opnsense.dhcp_subnet: + subnet: '192.168.88.0/24' + state: 'absent' + register: opn_pre2 + failed_when: > + opn_pre2.failed or + opn_pre2.changed + + # - name: Adding 1 - failing because of non-existent subnet + # ansibleguy.opnsense.dhcp_subnet: + # subnet: '192.168.59.0/24' + # ip: '192.168.59.76' + # register: opn_fail1 + # failed_when: not opn_fail1.failed + + - name: Adding 1 + ansibleguy.opnsense.dhcp_subnet: + subnet: '192.168.88.0/24' + register: opn1 + failed_when: > + opn1.failed or + not opn1.changed + + - name: Adding 2 + ansibleguy.opnsense.dhcp_subnet: + subnet: '192.168.89.0/24' + pools: + - '192.168.89.10-192.168.89.20' + - '192.168.89.40-192.168.89.50' + auto_options: false + gateway: '192.168.89.200' + dns: '1.1.1.1' + domain: 'test.lan' + register: opn2 + failed_when: > + opn2.failed or + not opn2.changed + + - name: Changing 2 + ansibleguy.opnsense.dhcp_subnet: + subnet: '192.168.89.0/24' + pools: + - '192.168.89.40-192.168.89.60' + - '192.168.89.110-192.168.89.120' + auto_options: false + gateway: '192.168.89.240' + dns: ['1.1.1.2', '1.0.0.2'] + domain: 'test.lan' + time_servers: '192.168.89.240' + tftp_server: '192.168.89.210' + tftp_file: 'openwrt.bin' + register: opn3 + failed_when: > + opn3.failed or + not opn3.changed + when: not ansible_check_mode + + - name: Changing 2 - Nothing changed + ansibleguy.opnsense.dhcp_subnet: + subnet: '192.168.89.0/24' + pools: + - '192.168.89.40-192.168.89.60' + - '192.168.89.110-192.168.89.120' + auto_options: false + gateway: '192.168.89.240' + dns: ['1.1.1.2', '1.0.0.2'] + domain: 'test.lan' + time_servers: '192.168.89.240' + tftp_server: '192.168.89.210' + tftp_file: 'openwrt.bin' + register: opn4 + failed_when: > + opn4.failed or + opn4.changed + when: not ansible_check_mode + + - name: Listing + ansibleguy.opnsense.list: + register: opn8 + failed_when: > + 'data' not in opn8 or + opn8.data | length != 2 + when: not ansible_check_mode + + - name: Removing 2 + ansibleguy.opnsense.dhcp_subnet: + subnet: '192.168.89.0/24' + state: absent + register: opn5 + failed_when: > + opn5.failed or + not opn5.changed + when: not ansible_check_mode + + - name: Cleanup + ansibleguy.opnsense.dhcp_subnet: + subnet: "{{ item }}" + state: 'absent' + loop: + - '192.168.88.0/24' + - '192.168.89.0/24' + when: not ansible_check_mode + + - name: Listing + ansibleguy.opnsense.list: + register: opn_clean1 + failed_when: > + 'data' not in opn_clean1 or + opn_clean1.data | length != 0 + when: not ansible_check_mode