diff --git a/README-dnszone.md b/README-dnszone.md new file mode 100644 index 0000000000000000000000000000000000000000..60cc989d0a23df9285234ee4a2b295ee2a69bdc2 --- /dev/null +++ b/README-dnszone.md @@ -0,0 +1,194 @@ +DNSZone Module +============== + +Description +----------- + +The dnszone module allows to configure zones in DNS server. + + +Features +-------- + +* Add, remove, modify, enable or disable DNS zones. + + +Supported FreeIPA Versions +-------------------------- + +FreeIPA versions 4.4.0 and up are supported by ipadnszone module. + + +Requirements +------------ + +**Controller** +* Ansible version: 2.8+ + + +**Node** +* Supported FreeIPA version (see above) + + +Usage +----- + + +```ini +[ipaserver] +ipaserver.test.local +``` + +Example playbook to create a simple DNS zone: + +```yaml + +--- +- name: dnszone present + hosts: ipaserver + become: true + + tasks: + - name: Ensure zone is present. + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + state: present + +``` + + +Example playbook to create a DNS zone with all currently supported variables: +```yaml + +--- +- name: dnszone present + hosts: ipaserver + become: true + + tasks: + - name: Ensure zone is present. + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + allow_sync_ptr: true + dynamic_update: true + dnssec: true + allow_transfer: + - 1.1.1.1 + - 2.2.2.2 + allow_query: + - 1.1.1.1 + - 2.2.2.2 + forwarders: + - ip_address: 8.8.8.8 + - ip_address: 8.8.4.4 + port: 52 + serial: 1234 + refresh: 3600 + retry: 900 + expire: 1209600 + minimum: 3600 + ttl: 60 + default_ttl: 90 + name_server: ipaserver.test.local. + admin_email: admin.admin@example.com + nsec3param_rec: "1 7 100 0123456789abcdef" + skip_overlap_check: true + skip_nameserver_check: true + state: present +``` + + +Example playbook to disable a zone: + +```yaml + +--- +- name: Playbook to disable DNS zone + hosts: ipaserver + become: true + + tasks: + - name: Disable zone. + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + state: disabled +``` + + +Example playbook to enable a zone: +```yaml + +--- +- name: Playbook to enable DNS zone + hosts: ipaserver + become: true + + tasks: + - name: Enable zone. + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + state: enabled +``` + + +Example playbook to remove a zone: +```yaml + +--- +- name: Playbook to remove DNS zone + hosts: ipaserver + become: true + + tasks: + - name: Remove zone. + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + state: absent + +``` + + +Variables +========= + +ipadnszone +---------- + +Variable | Description | Required +-------- | ----------- | -------- +`ipaadmin_principal` | The admin principal is a string and defaults to `admin` | no +`ipaadmin_password` | The admin password is a string and is required if there is no admin ticket available on the node | no +`forwarders` | The list of forwarders dicts. Each `forwarders` dict entry has:| no + | `ip_address` - The IPv4 or IPv6 address of the DNS server. | yes + | `port` - The custom port that should be used on this server. | no +`forward_policy` | The global forwarding policy. It can be one of `only`, `first`, or `none`. | no +`allow_sync_ptr` | Allow synchronization of forward (A, AAAA) and reverse (PTR) records (bool). | no +`state` | The state to ensure. It can be one of `present`, `enabled`, `disabled` or `absent`, default: `present`. | yes +`name_server`| Authoritative nameserver domain name | no +`admin_email`| Administrator e-mail address | no +`update_policy`| BIND update policy | no +`dynamic_update`| Allow dynamic updates | no +`dnssec`| Allow inline DNSSEC signing of records in the zone | no +`allow_transfer`| List of IP addresses or networks which are allowed to transfer the zone | no +`allow_query`| List of IP addresses or networks which are allowed to issue queries | no +`serial`| SOA record serial number | no +`refresh`| SOA record refresh time | no +`retry`| SOA record retry time | no +`expire`| SOA record expire time | no +`minimum`| How long should negative responses be cached | no +`ttl`| Time to live for records at zone apex | no +`default_ttl`| Time to live for records without explicit TTL definition | no +`nsec3param_rec`| NSEC3PARAM record for zone in format: hash_algorithm flags iterations salt | no +`skip_overlap_check`| Force DNS zone creation even if it will overlap with an existing zone | no +`skip_nameserver_check` | Force DNS zone creation even if nameserver is not resolvable | no + + +Authors +======= + +Sergio Oliveira Campos diff --git a/README.md b/README.md index 49060399986294570c7437f02711b7249943e7b8..c6583fd4190fcd1e273aa2e263ac3b9250c80a9f 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Features * One-time-password (OTP) support for client installation * Repair mode for clients * Modules for dns forwarder management +* Modules for dns zone management * Modules for group management * Modules for hbacrule management * Modules for hbacsvc management @@ -410,6 +411,7 @@ Modules in plugin/modules * [ipadnsconfig](README-dnsconfig.md) * [ipadnsforwardzone](README-dnsforwardzone.md) +* [ipadnszone](README-dnszone.md) * [ipagroup](README-group.md) * [ipahbacrule](README-hbacrule.md) * [ipahbacsvc](README-hbacsvc.md) @@ -425,3 +427,5 @@ Modules in plugin/modules * [ipatopologysuffix](README-topology.md) * [ipauser](README-user.md) * [ipavault](README-vault.md) + +If you want to write a new module please read [writing a new module](plugins/modules/README.md). diff --git a/playbooks/dnszone/disable-zone-forwarders.yml b/playbooks/dnszone/disable-zone-forwarders.yml new file mode 100644 index 0000000000000000000000000000000000000000..ba863a036b99a2b9853b6e4799285e56ad5fac79 --- /dev/null +++ b/playbooks/dnszone/disable-zone-forwarders.yml @@ -0,0 +1,11 @@ +--- +- name: Playbook to disable DNS zone forwarders + hosts: ipaserver + become: true + + tasks: + - name: Disable zone forwarders. + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + forward_policy: none diff --git a/playbooks/dnszone/dnszone-absent.yml b/playbooks/dnszone/dnszone-absent.yml new file mode 100644 index 0000000000000000000000000000000000000000..7d47f13d573baef354c969340f095642baaac89f --- /dev/null +++ b/playbooks/dnszone/dnszone-absent.yml @@ -0,0 +1,11 @@ +--- +- name: Playbook to ensure DNS zone is absent + hosts: ipaserver + become: true + + tasks: + - name: Remove zone. + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + state: absent diff --git a/playbooks/dnszone/dnszone-all-params.yml b/playbooks/dnszone/dnszone-all-params.yml new file mode 100644 index 0000000000000000000000000000000000000000..c02485f58285ef94fbb54f434d6dc09cdbcb842c --- /dev/null +++ b/playbooks/dnszone/dnszone-all-params.yml @@ -0,0 +1,35 @@ +- name: dnszone present + hosts: ipaserver + become: true + + tasks: + - name: Ensure zone is present. + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + allow_sync_ptr: true + dynamic_update: true + dnssec: true + allow_transfer: + - 1.1.1.1 + - 2.2.2.2 + allow_query: + - 1.1.1.1 + - 2.2.2.2 + forwarders: + - ip_address: 8.8.8.8 + - ip_address: 8.8.4.4 + port: 52 + #serial: 1234 + refresh: 3600 + retry: 900 + expire: 1209600 + minimum: 3600 + ttl: 60 + default_ttl: 90 + name_server: ipaserver.test.local. + admin_email: admin.admin@example.com + nsec3param_rec: "1 7 100 0123456789abcdef" + skip_overlap_check: true + skip_nameserver_check: true + state: present diff --git a/playbooks/dnszone/dnszone-disable.yml b/playbooks/dnszone/dnszone-disable.yml new file mode 100644 index 0000000000000000000000000000000000000000..a4c2fa7ea50df3710e333dcf0268f52fabdccc29 --- /dev/null +++ b/playbooks/dnszone/dnszone-disable.yml @@ -0,0 +1,11 @@ +--- +- name: Playbook to disable DNS zone + hosts: ipaserver + become: true + + tasks: + - name: Disable zone. + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + state: disabled diff --git a/playbooks/dnszone/dnszone-enable.yml b/playbooks/dnszone/dnszone-enable.yml new file mode 100644 index 0000000000000000000000000000000000000000..912bc8f4d6f1cb208987aa7ae2cbd1c2e7dfbf33 --- /dev/null +++ b/playbooks/dnszone/dnszone-enable.yml @@ -0,0 +1,11 @@ +--- +- name: Playbook to enable DNS zone + hosts: ipaserver + become: true + + tasks: + - name: Enable zone. + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + state: enabled diff --git a/playbooks/dnszone/dnszone-present.yml b/playbooks/dnszone/dnszone-present.yml new file mode 100644 index 0000000000000000000000000000000000000000..c310631790787638074f070ab13d7ff0d4b46ed2 --- /dev/null +++ b/playbooks/dnszone/dnszone-present.yml @@ -0,0 +1,10 @@ +- name: dnszone present + hosts: ipaserver + become: true + + tasks: + - name: Ensure zone is present. + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + state: present diff --git a/plugins/module_utils/ansible_freeipa_module.py b/plugins/module_utils/ansible_freeipa_module.py index b8df38d4f6bc6fe21dc4af8bedab93cd728cbdd2..9c10135f4c86a842e098f23393a0adbbcbd27b97 100644 --- a/plugins/module_utils/ansible_freeipa_module.py +++ b/plugins/module_utils/ansible_freeipa_module.py @@ -2,6 +2,7 @@ # -*- coding: utf-8 -*- # Authors: +# Sergio Oliveira Campos <seocam@redhat.com> # Thomas Woerner <twoerner@redhat.com> # # Copyright (C) 2019 Red Hat @@ -27,10 +28,12 @@ import tempfile import shutil import gssapi from datetime import datetime +from pprint import pformat from ipalib import api -from ipalib import errors as ipalib_errors +from ipalib import errors as ipalib_errors # noqa from ipalib.config import Env from ipalib.constants import DEFAULT_CONFIG, LDAP_GENERALIZED_TIME_FORMAT + try: from ipalib.install.kinit import kinit_password, kinit_keytab except ImportError: @@ -38,7 +41,9 @@ except ImportError: from ipapython.ipautil import run from ipaplatform.paths import paths from ipalib.krb_utils import get_credentials_if_valid +from ansible.module_utils.basic import AnsibleModule from ansible.module_utils._text import to_text + try: from ipalib.x509 import Encoding except ImportError: @@ -52,7 +57,7 @@ if six.PY3: unicode = str -def valid_creds(module, principal): +def valid_creds(module, principal): # noqa """ Get valid credintials matching the princial, try GSSAPI first """ @@ -205,9 +210,24 @@ def date_format(value): raise ValueError("Invalid date '%s'" % value) -def compare_args_ipa(module, args, ipa): +def compare_args_ipa(module, args, ipa): # noqa + """Compare IPA obj attrs with the command args. + + This function compares IPA objects attributes with the args the + module is intending to use to call a command. This is useful to know + if call to IPA server will be needed or not. + In other to compare we have to prepare the perform slight changes in + data formats. + + Returns True if they are the same and False otherwise. + """ + base_debug_msg = "Ansible arguments and IPA commands differed. " + for key in args.keys(): if key not in ipa: + module.debug( + base_debug_msg + "Command key not present in IPA: %s" % key + ) return False else: arg = args[key] @@ -220,25 +240,35 @@ def compare_args_ipa(module, args, ipa): if isinstance(ipa_arg, list): if not isinstance(arg, list): arg = [arg] + if len(ipa_arg) != len(arg): + module.debug( + base_debug_msg + + "List length doesn't match for key %s: %d %d" + % (key, len(arg), len(ipa_arg),) + ) + return False if isinstance(ipa_arg[0], str) and isinstance(arg[0], int): arg = [to_text(_arg) for _arg in arg] if isinstance(ipa_arg[0], unicode) and isinstance(arg[0], int): arg = [to_text(_arg) for _arg in arg] - # module.warn("%s <=> %s" % (repr(arg), repr(ipa_arg))) try: arg_set = set(arg) ipa_arg_set = set(ipa_arg) except TypeError: if arg != ipa_arg: - # module.warn("%s != %s" % (repr(arg), repr(ipa_arg))) + module.debug( + base_debug_msg + + "Different values: %s %s" % (arg, ipa_arg) + ) return False else: if arg_set != ipa_arg_set: - # module.warn("%s != %s" % (repr(arg), repr(ipa_arg))) + module.debug( + base_debug_msg + + "Different set content: %s %s" + % (arg_set, ipa_arg_set,) + ) return False - - # module.warn("%s == %s" % (repr(arg), repr(ipa_arg))) - return True @@ -289,6 +319,16 @@ def encode_certificate(cert): return encoded +def is_valid_port(port): + if not isinstance(port, int): + return False + + if 1 <= port <= 65535: + return True + + return False + + def is_ipv4_addr(ipaddr): """ Test if figen IP address is a valid IPv4 address @@ -309,3 +349,294 @@ def is_ipv6_addr(ipaddr): except socket.error: return False return True + + +class AnsibleFreeIPAParams(dict): + def __init__(self, ansible_module): + self.update(ansible_module.params) + self.ansible_module = ansible_module + + @property + def names(self): + return self.name + + def __getattr__(self, name): + param = self.get(name) + if param is not None: + return _afm_convert(param) + + +class FreeIPABaseModule(AnsibleModule): + """ + Base class for FreeIPA Ansible modules. + + Provides methods useful methods to be used by our modules. + + This class should be overriten and instantiated for the module. + A basic implementation of an Ansible FreeIPA module expects its + class to: + + 1. Define a class attribute ``ipa_param_mapping`` + 2. Implement the method ``define_ipa_commands()`` + 3. Implement the method ``check_ipa_params()`` (optional) + + After instantiating the class the method ``ipa_run()`` should be called. + + Example (ansible-freeipa/plugins/modules/ipasomemodule.py): + + class SomeIPAModule(FreeIPABaseModule): + ipa_param_mapping = { + "arg_to_be_passed_to_ipa_command": "module_param", + "another_arg": "get_another_module_param", + } + + def get_another_module_param(self): + another_module_param = self.ipa_params.another_module_param + # Validate or modify another_module_param + # ... + return another_module_param + + def check_ipa_params(self): + # Validate your params here + # Example: + if not self.ipa_params.module_param in VALID_OPTIONS: + self.fail_json(msg="Invalid value for argument module_param") + + def define_ipa_commands(self): + args = self.get_ipa_command_args() + + self.add_ipa_command( + "some_ipa_command", + name="obj-name", + args=args, + ) + + def main(): + ipa_module = SomeIPAModule(argument_spec=dict( + module_param=dict( + type="str", + default=None, + required=False, + ), + another_module_param=dict( + type="str", + default=None, + required=False, + ), + )) + ipa_module.ipa_run() + + if __name__ == "__main__": + main() + + """ + + ipa_param_mapping = None + + def __init__(self, *args, **kwargs): + super(FreeIPABaseModule, self).__init__(*args, **kwargs) + + # Attributes to store kerberos credentials (if needed) + self.ccache_dir = None + self.ccache_name = None + + # Status of an execution. Will be changed to True + # if something is actually peformed. + self.changed = False + + # Status of the connection with the IPA server. + # We need to know if the connection was actually stablished + # before we start sending commands. + self.ipa_connected = False + + # Commands to be executed + self.ipa_commands = [] + + # Module exit arguments. + self.exit_args = {} + + # Wrapper around the AnsibleModule.params. + # Return the actual params but performing transformations + # when needed. + self.ipa_params = AnsibleFreeIPAParams(self) + + def get_ipa_command_args(self): + """ + Return a dict to be passed to an IPA command. + + The keys of ``ipa_param_mapping`` are also the keys of the return dict. + + The values of ``ipa_param_mapping`` needs to be either: + * A str with the name of a defined method; or + * A key of ``AnsibleModule.param``. + + In case of a method the return of the method will be set as value + for the return dict. + + In case of a AnsibleModule.param the value of the param will be + set in the return dict. In addition to that boolean values will be + automaticaly converted to uppercase strings (as required by FreeIPA + server). + + """ + args = {} + for ipa_param_name, param_name in self.ipa_param_mapping.items(): + + # Check if param_name is actually a param + if param_name in self.ipa_params: + value = self.ipa_params.get(param_name) + if isinstance(value, bool): + value = "TRUE" if value else "FALSE" + + # Since param wasn't a param check if it's a method name + elif hasattr(self, param_name): + method = getattr(self, param_name) + if callable(method): + value = method() + + # We don't have a way to guess the value so fail. + else: + self.fail_json( + msg=( + "Couldn't get a value for '%s'. Option '%s' is not " + "a module argument neither a defined method." + ) + % (ipa_param_name, param_name) + ) + + if value is not None: + args[ipa_param_name] = value + + return args + + def check_ipa_params(self): + """Validate ipa_params before command is called.""" + pass + + def define_ipa_commands(self): + """Define commands that will be run in IPA server.""" + raise NotImplementedError + + def api_command(self, command, name=None, args=None): + """Execute a single command in IPA server.""" + if args is None: + args = {} + + if name is None: + return api_command_no_name(self, command, args) + + return api_command(self, command, name, args) + + def __enter__(self): + """ + Connect to IPA server. + + Check the there are working Kerberos credentials to connect to + IPA server. If there are not we perform a temporary kinit + that will be terminated when exiting the context. + + If the connection fails ``ipa_connected`` attribute will be set + to False. + """ + principal = self.ipa_params.ipaadmin_principal + password = self.ipa_params.ipaadmin_password + + try: + if not valid_creds(self, principal): + self.ccache_dir, self.ccache_name = temp_kinit( + principal, password, + ) + + api_connect() + + except Exception as excpt: + self.fail_json(msg=str(excpt)) + else: + self.ipa_connected = True + + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + """ + Terminate a connection with the IPA server. + + Deal with exceptions, destroy temporary kinit credentials and + exit the module with proper arguments. + + """ + if exc_val: + self.fail_json(msg=str(exc_val)) + + # TODO: shouldn't we also disconnect from api backend? + temp_kdestroy(self.ccache_dir, self.ccache_name) + + self.exit_json(changed=self.changed, user=self.exit_args) + + def get_command_errors(self, command, result): + """Look for erros into command results.""" + # Get all errors + # All "already a member" and "not a member" failures in the + # result are ignored. All others are reported. + errors = [] + for item in result.get("failed", tuple()): + failed_item = result["failed"][item] + for member_type in failed_item: + for member, failure in failed_item[member_type]: + if ( + "already a member" in failure + or "not a member" in failure + ): + continue + errors.append( + "%s: %s %s: %s" + % (command, member_type, member, failure) + ) + + if len(errors) > 0: + self.fail_json(", ".join("errors")) + + def add_ipa_command(self, command, name=None, args=None): + """Add a command to the list of commands to be executed.""" + self.ipa_commands.append((name, command, args or {})) + + def _run_ipa_commands(self): + """Execute commands in self.ipa_commands.""" + result = None + + for name, command, args in self.ipa_commands: + try: + result = self.api_command(command, name, args) + except Exception as excpt: + self.fail_json(msg="%s: %s: %s" % (command, name, str(excpt))) + else: + if "completed" in result: + if result["completed"] > 0: + self.changed = True + else: + self.changed = True + + self.get_command_errors(command, result) + + def require_ipa_attrs_change(self, command_args, ipa_attrs): + """ + Compare given args with current object attributes. + + Returns True in case current IPA object attributes differ from + args passed to the module. + """ + equal = compare_args_ipa(self, command_args, ipa_attrs) + return not equal + + def pdebug(self, value): + """Debug with pretty formatting.""" + self.debug(pformat(value)) + + def ipa_run(self): + """Execute module actions.""" + with self: + if not self.ipa_connected: + return + + self.check_ipa_params() + self.define_ipa_commands() + self._run_ipa_commands() diff --git a/plugins/modules/README.md b/plugins/modules/README.md new file mode 100644 index 0000000000000000000000000000000000000000..4e06fe10f514ef97634d530b58e789f7b2bfa0fd --- /dev/null +++ b/plugins/modules/README.md @@ -0,0 +1,80 @@ +# Writing a new Ansible FreeIPA module + +## Minimum requirements +A ansible-freeipa module should have: + +* Code: + * A module file placed in `plugins/modules/<ipa_module_name>.py` + +* Documentation: + * `README-<module_name>.md` file in the root directory and linked from the main README.md + * Example playbooks in `playbooks/<module_name>/` directory + +* Tests: + * Test cases (also playbooks) defined in `tests/<module_name>/test_<something>.yml`. It's ok to have multiple files in this directory. + +## Code + +The module file have to start with the python shebang line, license header and definition of the constants `ANSIBLE_METADATA`, `DOCUMENTATION`, `EXAMPLES` and `RETURNS`. Those constants need to be defined before the code (even imports). See https://docs.ansible.com/ansible/latest/dev_guide/developing_modules_general.html#starting-a-new-module for more information. + + +Although it's use is not yet required, ansible-freeipa provides `FreeIPABaseModule` as a helper class for the implementation of new modules. See the example bellow: + +```python + +from ansible.module_utils.ansible_freeipa_module import FreeIPABaseModule + + +class SomeIPAModule(FreeIPABaseModule): + ipa_param_mapping = { + "arg_to_be_passed_to_ipa_command": "module_param", + "another_arg": "get_another_module_param", + } + + def get_another_module_param(self): + another_module_param = self.ipa_params.another_module_param + + # Validate or modify another_module_param ... + + return another_module_param + + def check_ipa_params(self): + + # Validate your params here ... + + # Example: + if not self.ipa_params.module_param in VALID_OPTIONS: + self.fail_json(msg="Invalid value for argument module_param") + + def define_ipa_commands(self): + args = self.get_ipa_command_args() + + self.add_ipa_command("some_ipa_command", name="obj-name", args=args) + + +def main(): + ipa_module = SomeIPAModule(argument_spec=dict( + module_param=dict(type="str", default=None, required=False), + another_module_param=dict(type="str", default=None, required=False), + )) + ipa_module.ipa_run() + + +if __name__ == "__main__": + main() +``` + +In the example above, the module will call the command `some_ipa_command`, using "obj-name" as name and, `arg_to_be_passed_to_ipa_command` and `another_arg` as arguments. + +The values of the arguments will be determined by the class attribute `ipa_param_mapping`. + +In the case of `arg_to_be_passed_to_ipa_command` the key (`module_param`) is defined in the module `argument_specs` so the value of the argument is actually used. + +On the other hand, `another_arg` as mapped to something else: a callable method. In this case the method will be called and it's result used as value for `another_arg`. + +**NOTE**: Keep mind that to take advantage of the parameters mapping defined in `ipa_param_mapping` you will have to call `args = self.get_ipa_command_args()` and use `args` in your command. There is no implicit call of this method. + + +## Disclaimer + +The `FreeIPABaseModule` is new and might not be suitable to all cases and every module yet. In case you need to extend it's functionality for a new module please open an issue or PR and we'll be happy to discuss it. diff --git a/plugins/modules/ipadnszone.py b/plugins/modules/ipadnszone.py new file mode 100644 index 0000000000000000000000000000000000000000..52a66c27fc2b573fea6d177307ea34414d8eb1f4 --- /dev/null +++ b/plugins/modules/ipadnszone.py @@ -0,0 +1,457 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Authors: +# Sergio Oliveira Campos <seocam@redhat.com> +# +# Copyright (C) 2020 Red Hat +# see file 'COPYING' for use and warranty information +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see <http://www.gnu.org/licenses/>. + +ANSIBLE_METADATA = { + "metadata_version": "1.0", + "supported_by": "community", + "status": ["preview"], +} + +DOCUMENTATION = """ +module: ipadnszone +short description: Manage FreeIPA dnszone +description: Manage FreeIPA dnszone +options: + ipaadmin_principal: + description: The admin principal + default: admin + ipaadmin_password: + description: The admin password + required: false + + forwarders: + description: The list of global DNS forwarders. + required: false + options: + ip_address: + description: The forwarder nameserver IP address list (IPv4 and IPv6). + required: true + port: + description: The port to forward requests to. + required: false + forward_policy: + description: + Global forwarding policy. Set to "none" to disable any configured + global forwarders. + required: false + choices: ['only', 'first', 'none'] + allow_sync_ptr: + description: + Allow synchronization of forward (A, AAAA) and reverse (PTR) records. + required: false + type: bool + state: + description: State to ensure + default: present + choices: ["present", "absent", "enabled", "disabled"] + name_server: + description: Authoritative nameserver domain name + required: false + type: str + admin_email: + description: Administrator e-mail address + required: false + type: str + update_policy: BIND update policy + description: Allow dynamic updates + required: false + type: str + dnssec: + description: Allow inline DNSSEC signing of records in the zone + required: false + type: bool + allow_transfer: + description: List of IP addresses or networks which are allowed to transfer the zone + required: false + type: bool + allow_query: + description: List of IP addresses or networks which are allowed to issue queries + required: false + type: bool + serial: + description: SOA record serial number + required: false + type: int + refresh: + description: SOA record refresh time + required: false + type: int + retry: + description: SOA record retry time + required: false + type: int + expire: + description: SOA record expire time + required: false + type: int + minimum: + description: How long should negative responses be cached + required: false + type: int + ttl: + description: Time to live for records at zone apex + required: false + type: int + default_ttl: + description: Time to live for records without explicit TTL definition + required: false + type: int + nsec3param_rec: + description: NSEC3PARAM record for zone in format: hash_algorithm flags iterations salt. + required: false + type: str + skip_overlap_check: + description: Force DNS zone creation even if it will overlap with an existing zone + required: false + type: bool + skip_nameserver_check: + description: Force DNS zone creation even if nameserver is not resolvable + required: false + type: bool +""" # noqa: E501 + +EXAMPLES = """ +--- +# Ensure the zone is present (very minimal) +- ipadnszone: + name: test.example.com + +# Ensure the zone is present (all available arguments) +- ipadnszone: + name: test.example.com + ipaadmin_password: SomeADMINpassword + allow_sync_ptr: true + dynamic_update: true + dnssec: true + allow_transfer: + - 1.1.1.1 + - 2.2.2.2 + allow_query: + - 1.1.1.1 + - 2.2.2.2 + forwarders: + - ip_address: 8.8.8.8 + - ip_address: 8.8.4.4 + port: 52 + serial: 1234 + refresh: 3600 + retry: 900 + expire: 1209600 + minimum: 3600 + ttl: 60 + default_ttl: 90 + name_server: ipaserver.test.local. + admin_email: admin.admin@example.com + nsec3param_rec: "1 7 100 0123456789abcdef" + skip_overlap_check: true + skip_nameserver_check: true + state: present + +# Ensure zone is present and disabled +- ipadnszone: + name: test.example.com + state: disabled + +# Ensure zone is present and enabled +- ipadnszone: + name: test.example.com + state: enabled +""" + +RETURN = """ +""" + +from ipapython.dnsutil import DNSName # noqa: E402 +from ansible.module_utils.ansible_freeipa_module import ( + FreeIPABaseModule, + is_ipv4_addr, + is_ipv6_addr, + is_valid_port, +) # noqa: E402 + + +class DNSZoneModule(FreeIPABaseModule): + + ipa_param_mapping = { + # Direct Mapping + "idnsforwardpolicy": "forward_policy", + "idnssoaserial": "serial", + "idnssoarefresh": "refresh", + "idnssoaretry": "retry", + "idnssoaexpire": "expire", + "idnssoaminimum": "minimum", + "dnsttl": "ttl", + "dnsdefaultttl": "default_ttl", + "idnsallowsyncptr": "allow_sync_ptr", + "idnsallowdynupdate": "dynamic_update", + "idnssecinlinesigning": "dnssec", + "idnsupdatepolicy": "update_policy", + # Mapping by method + "idnsforwarders": "get_ipa_idnsforwarders", + "idnsallowtransfer": "get_ipa_idnsallowtransfer", + "idnsallowquery": "get_ipa_idnsallowquery", + "idnssoamname": "get_ipa_idnssoamname", + "idnssoarname": "get_ipa_idnssoarname", + "skip_nameserver_check": "get_ipa_skip_nameserver_check", + "skip_overlap_check": "get_ipa_skip_overlap_check", + "nsec3paramrecord": "get_ipa_nsec3paramrecord", + } + + def validate_ips(self, ips, error_msg): + invalid_ips = [ + ip for ip in ips if not is_ipv4_addr(ip) or is_ipv6_addr(ip) + ] + if any(invalid_ips): + self.fail_json(msg=error_msg % invalid_ips) + + def is_valid_nsec3param_rec(self, nsec3param_rec): + try: + part1, part2, part3, part4 = nsec3param_rec.split(" ") + except ValueError: + return False + + if not all([part1.isdigit(), part2.isdigit(), part3.isdigit()]): + return False + + if not 0 <= int(part1) <= 255: + return False + + if not 0 <= int(part2) <= 255: + return False + + if not 0 <= int(part3) <= 65535: + return False + + try: + int(part4, 16) + except ValueError: + is_hex = False + else: + is_hex = True + + even_digits = len(part4) % 2 == 0 + is_dash = part4 == "-" + + # If not hex with even digits or dash then + # part4 is invalid + if not ((is_hex and even_digits) or is_dash): + return False + + return True + + def get_ipa_nsec3paramrecord(self): + nsec3param_rec = self.ipa_params.nsec3param_rec + if nsec3param_rec is not None: + error_msg = ( + "Invalid nsec3param_rec: %s. " + "Expected format: <0-255> <0-255> <0-65535> " + "even-length_hexadecimal_digits_or_hyphen" + ) % nsec3param_rec + if not self.is_valid_nsec3param_rec(nsec3param_rec): + self.fail_json(msg=error_msg) + return nsec3param_rec + + def get_ipa_idnsforwarders(self): + if self.ipa_params.forwarders is not None: + forwarders = [] + for forwarder in self.ipa_params.forwarders: + ip_address = forwarder.get("ip_address") + if not (is_ipv4_addr(ip_address) or is_ipv6_addr(ip_address)): + self.fail_json( + msg="Invalid IP for DNS forwarder: %s" % ip_address + ) + + port = forwarder.get("port", None) + if port and not is_valid_port(port): + self.fail_json( + msg="Invalid port number for DNS forwarder: %s %s" + % (ip_address, port) + ) + formatted_forwarder = ip_address + port = forwarder.get("port") + if port: + formatted_forwarder += " port %d" % port + forwarders.append(formatted_forwarder) + + return forwarders + + def get_ipa_idnsallowtransfer(self): + if self.ipa_params.allow_transfer is not None: + error_msg = "Invalid ip_address for DNS allow_transfer: %s" + self.validate_ips(self.ipa_params.allow_transfer, error_msg) + + return (";".join(self.ipa_params.allow_transfer) or "none") + ";" + + def get_ipa_idnsallowquery(self): + if self.ipa_params.allow_query is not None: + error_msg = "Invalid ip_address for DNS allow_query: %s" + self.validate_ips(self.ipa_params.allow_query, error_msg) + + return (";".join(self.ipa_params.allow_query) or "any") + ";" + + @staticmethod + def _replace_at_symbol_in_rname(rname): + """ + See RFC 1035 for more information. + + Section 8. MAIL SUPPORT + https://tools.ietf.org/html/rfc1035#section-8 + """ + if "@" not in rname: + return rname + + name, domain = rname.split("@") + name = name.replace(".", r"\.") + + return ".".join((name, domain)) + + def get_ipa_idnssoarname(self): + if self.ipa_params.admin_email is not None: + return DNSName( + self._replace_at_symbol_in_rname(self.ipa_params.admin_email) + ) + + def get_ipa_idnssoamname(self): + if self.ipa_params.name_server is not None: + return DNSName(self.ipa_params.name_server) + + def get_ipa_skip_overlap_check(self): + if not self.zone and self.ipa_params.skip_overlap_check is not None: + return self.ipa_params.skip_overlap_check + + def get_ipa_skip_nameserver_check(self): + if not self.zone and self.ipa_params.skip_nameserver_check is not None: + return self.ipa_params.skip_nameserver_check + + def get_zone(self, zone_name): + get_zone_args = {"idnsname": zone_name, "all": True} + response = self.api_command("dnszone_find", args=get_zone_args) + + if response["count"] == 1: + self.zone = response["result"][0] + self.is_zone_active = self.zone.get("idnszoneactive") == ["TRUE"] + return self.zone + + # Zone doesn't exist yet + self.zone = None + self.is_zone_active = False + + @property + def zone_name(self): + return self.ipa_params.name + + def define_ipa_commands(self): + # Look for existing zone in IPA + self.get_zone(self.zone_name) + args = self.get_ipa_command_args() + just_added = False + + if self.ipa_params.state in ["present", "enabled", "disabled"]: + if not self.zone: + # Since the zone doesn't exist we just create it + # with given args + self.add_ipa_command("dnszone_add", self.zone_name, args) + self.is_zone_active = True + just_added = True + + else: + # Zone already exist so we need to verify if given args + # matches the current config. If not we updated it. + if self.require_ipa_attrs_change(args, self.zone): + self.add_ipa_command("dnszone_mod", self.zone_name, args) + + if self.ipa_params.state == "enabled" and not self.is_zone_active: + self.add_ipa_command("dnszone_enable", self.zone_name) + + if self.ipa_params.state == "disabled" and self.is_zone_active: + self.add_ipa_command("dnszone_disable", self.zone_name) + + if self.ipa_params.state == "absent": + if self.zone: + self.add_ipa_command("dnszone_del", self.zone_name) + + # Due to a bug in FreeIPA dnszone-add won't set + # SOA Serial. The good news is that dnszone-mod does the job. + # See: https://pagure.io/freeipa/issue/8227 + # Because of that, if the zone was just added with a given serial + # we run mod just after to workaround the bug + if just_added and self.ipa_params.serial is not None: + args = { + "idnssoaserial": self.ipa_params.serial, + } + self.add_ipa_command("dnszone_mod", self.zone_name, args) + + +def get_argument_spec(): + forwarder_spec = dict( + ip_address=dict(type=str, required=True), + port=dict(type=int, required=False, default=None), + ) + + return dict( + state=dict( + type="str", + default="present", + choices=["present", "absent", "enabled", "disabled"], + ), + ipaadmin_principal=dict(type="str", default="admin"), + ipaadmin_password=dict(type="str", required=False, no_log=True), + name=dict(type="str", default=None, required=True), + forwarders=dict( + type="list", + default=None, + required=False, + options=dict(**forwarder_spec), + ), + forward_policy=dict( + type="str", + required=False, + default=None, + choices=["only", "first", "none"], + ), + name_server=dict(type="str", required=False, default=None), + admin_email=dict(type="str", required=False, default=None), + allow_sync_ptr=dict(type="bool", required=False, default=None), + update_policy=dict(type="str", required=False, default=None), + dynamic_update=dict(type="bool", required=False, default=None), + dnssec=dict(type="bool", required=False, default=None), + allow_transfer=dict(type="list", required=False, default=None), + allow_query=dict(type="list", required=False, default=None), + serial=dict(type="int", required=False, default=None), + refresh=dict(type="int", required=False, default=None), + retry=dict(type="int", required=False, default=None), + expire=dict(type="int", required=False, default=None), + minimum=dict(type="int", required=False, default=None), + ttl=dict(type="int", required=False, default=None), + default_ttl=dict(type="int", required=False, default=None), + nsec3param_rec=dict(type="str", required=False, default=None), + skip_nameserver_check=dict(type="bool", required=False, default=None), + skip_overlap_check=dict(type="bool", required=False, default=None), + ) + + +def main(): + DNSZoneModule(argument_spec=get_argument_spec()).ipa_run() + + +if __name__ == "__main__": + main() diff --git a/tests/dnszone/test_dnszone.yml b/tests/dnszone/test_dnszone.yml new file mode 100644 index 0000000000000000000000000000000000000000..f7bd1f0960500adeeb6e0d5df86685212ff1d2bc --- /dev/null +++ b/tests/dnszone/test_dnszone.yml @@ -0,0 +1,151 @@ +--- +- name: Test dnszone + hosts: ipaserver + become: true + gather_facts: true + + tasks: + + # Setup + - name: Ensure zone is absent. + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + state: absent + + # Tests + - name: Ensure zone is present. + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + state: present + register: result + failed_when: not result.changed + + - name: Ensure zone is present, again. + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + state: present + register: result + failed_when: result.changed + + - name: Ensure zone is disabled. + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + state: disabled + register: result + failed_when: not result.changed + + - name: Ensure zone is disabled, again. + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + state: disabled + register: result + failed_when: result.changed + + - name: Ensure zone is enabled. + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + state: enabled + register: result + failed_when: not result.changed + + - name: Ensure zone is enabled, again. + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + state: enabled + register: result + failed_when: result.changed + + - name: Ensure forward_policy is none. + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + forward_policy: none + register: result + failed_when: not result.changed + + - name: Ensure forward_policy is none, again. + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + forward_policy: none + register: result + failed_when: result.changed + + - name: Ensure forward_policy is first. + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + forward_policy: first + register: result + failed_when: not result.changed + + - name: Ensure forward_policy is first, again. + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + forward_policy: first + register: result + failed_when: result.changed + + - name: Ensure first forwarder is set. + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + forwarders: + - ip_address: 8.8.8.8 + port: 53 + register: result + failed_when: not result.changed + + - name: Ensure first and second forwarder are set. + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + forwarders: + - ip_address: 8.8.8.8 + port: 53 + - ip_address: 2001:4860:4860::8888 + register: result + failed_when: not result.changed + + - name: Ensure first and second forwarder are set, again. + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + forwarders: + - ip_address: 8.8.8.8 + port: 53 + - ip_address: 2001:4860:4860::8888 + register: result + failed_when: result.changed + + - name: Ensure only second forwarder is set. + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + forwarders: + - ip_address: 2001:4860:4860::8888 + register: result + failed_when: not result.changed + + - name: Nothing changes. + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + register: result + failed_when: result.changed + + - name: Ensure no forwarders are set. + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + forwarders: [] + register: result + failed_when: not result.changed diff --git a/tests/dnszone/test_dnszone_mod.yml b/tests/dnszone/test_dnszone_mod.yml new file mode 100644 index 0000000000000000000000000000000000000000..e4b503ae7d644c01966c6478ac7308b7bf56de30 --- /dev/null +++ b/tests/dnszone/test_dnszone_mod.yml @@ -0,0 +1,319 @@ +--- +- name: Test dnszone + hosts: ipaserver + become: true + gather_facts: true + + tasks: + + # Setup + - name: Ensure zone is absent. + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + state: absent + + # Tests + - name: Ensure zone is present. + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + allow_sync_ptr: true + dynamic_update: true + dnssec: true + allow_transfer: + - 1.1.1.1 + - 2.2.2.2 + allow_query: + - 1.1.1.1 + - 2.2.2.2 + serial: 1234 + refresh: 3600 + retry: 900 + expire: 1209600 + minimum: 3600 + ttl: 60 + default_ttl: 60 + name_server: ipaserver.test.local. + skip_nameserver_check: true + admin_email: admin@example.com + nsec3param_rec: "1 7 100 abcd" + state: present + register: result + failed_when: not result.changed + + - name: Set serial to 1234, again. + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + serial: 1234 + register: result + failed_when: result.changed + + - name: Set different nsec3param_rec. + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + nsec3param_rec: "2 8 200 abcd" + register: result + failed_when: not result.changed + + - name: Set same nsec3param_rec. + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + nsec3param_rec: "2 8 200 abcd" + register: result + failed_when: result.changed + + - name: Set default_ttl to 1200 + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + default_ttl: 1200 + register: result + failed_when: not result.changed + + - name: Set default_ttl to 1200, again + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + default_ttl: 1200 + register: result + failed_when: result.changed + + - name: Set ttl to 900 + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + ttl: 900 + register: result + failed_when: not result.changed + + - name: Set ttl to 900, again + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + ttl: 900 + register: result + failed_when: result.changed + + - name: Set minimum to 1000 + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + minimum: 1000 + register: result + failed_when: not result.changed + + - name: Set minimum to 1000, again + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + minimum: 1000 + register: result + failed_when: result.changed + + - name: Set expire to 1209601 + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + expire: 1209601 + register: result + failed_when: not result.changed + + - name: Set expire to 1209601, again + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + expire: 1209601 + register: result + failed_when: result.changed + + - name: Set retry to 1200. + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + retry: 1200 + register: result + failed_when: not result.changed + + - name: Set retry to 1200, again. + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + retry: 1200 + register: result + failed_when: result.changed + + - name: Set refresh to 4000. + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + refresh: 4000 + register: result + failed_when: not result.changed + + - name: Set refresh to 4000, again. + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + refresh: 4000 + register: result + failed_when: result.changed + + - name: Set serial to 12345. + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + serial: 12345 + register: result + failed_when: not result.changed + + - name: Set serial to 12345, again. + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + serial: 12345 + register: result + failed_when: result.changed + + - name: Set dnssec to false. + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + dnssec: false + register: result + failed_when: not result.changed + + - name: Set dnssec to false, again. + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + dnssec: false + register: result + failed_when: result.changed + + - name: Set allow_sync_ptr to false. + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + allow_sync_ptr: false + register: result + failed_when: not result.changed + + - name: Set allow_sync_ptr to false, again. + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + allow_sync_ptr: false + register: result + failed_when: result.changed + + - name: Set dynamic_update to false. + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + dynamic_update: false + register: result + failed_when: not result.changed + + - name: Set dynamic_update to false, again. + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + dynamic_update: false + register: result + failed_when: result.changed + + - name: Update allow_transfer. + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + allow_transfer: + - 1.1.1.1 + - 2.2.2.2 + - 3.3.3.3 + register: result + failed_when: not result.changed + + - name: Update allow_transfer, again. + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + allow_transfer: + - 1.1.1.1 + - 2.2.2.2 + - 3.3.3.3 + register: result + failed_when: result.changed + + - name: Remove allow transfer. + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + allow_transfer: [] + register: result + failed_when: not result.changed + + - name: Remove allow transfer, again. + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + allow_transfer: [] + register: result + failed_when: result.changed + + - name: Update allow_query. + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + allow_query: + - 1.1.1.1 + - 2.2.2.2 + - 3.3.3.3 + register: result + failed_when: not result.changed + + - name: Update allow_query, again. + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + allow_query: + - 1.1.1.1 + - 2.2.2.2 + - 3.3.3.3 + register: result + failed_when: result.changed + + - name: Ensure allow query is empty. + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + allow_query: [] + register: result + failed_when: not result.changed + + - name: Ensure allow query is empty, again. + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + allow_query: [] + register: result + failed_when: result.changed + + - name: Update admin email. + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + admin_email: admin2@example.com + register: result + failed_when: not result.changed + + - name: Update admin email, again. + ipadnszone: + ipaadmin_password: SomeADMINpassword + name: testzone.local + admin_email: admin2@example.com + register: result + failed_when: result.changed