From 2ed7e21c1ff2acb82d691fb1b4176f8f4c34c773 Mon Sep 17 00:00:00 2001
From: Sergio Oliveira Campos <seocam@redhat.com>
Date: Mon, 9 Mar 2020 22:53:18 -0300
Subject: [PATCH] New IPADNSZone module

    There is a new management module placed in the plugins folder:

    plugins/modules/ipadnszone.py

    The dnszone module allows to manage DNS zones.

    Here is the documentation for the module:

    README-dnszone.md

    New example playbooks have been added:

    playbooks/dnszone/disable-zone-forwarders.yml
    playbooks/dnszone/dnszone-absent.yml
    playbooks/dnszone/dnszone-all-params.yml
    playbooks/dnszone/dnszone-disable.yml
    playbooks/dnszone/dnszone-enable.yml
    playbooks/dnszone/dnszone-present.yml

    New tests for the module:

    tests/dnszone/test_dnszone.yml
    tests/dnszone/test_dnszone_mod.yml
---
 README-dnszone.md                             | 194 ++++++++
 README.md                                     |   4 +
 playbooks/dnszone/disable-zone-forwarders.yml |  11 +
 playbooks/dnszone/dnszone-absent.yml          |  11 +
 playbooks/dnszone/dnszone-all-params.yml      |  35 ++
 playbooks/dnszone/dnszone-disable.yml         |  11 +
 playbooks/dnszone/dnszone-enable.yml          |  11 +
 playbooks/dnszone/dnszone-present.yml         |  10 +
 plugins/modules/README.md                     |  80 +++
 plugins/modules/ipadnszone.py                 | 457 ++++++++++++++++++
 tests/dnszone/test_dnszone.yml                | 151 ++++++
 tests/dnszone/test_dnszone_mod.yml            | 319 ++++++++++++
 12 files changed, 1294 insertions(+)
 create mode 100644 README-dnszone.md
 create mode 100644 playbooks/dnszone/disable-zone-forwarders.yml
 create mode 100644 playbooks/dnszone/dnszone-absent.yml
 create mode 100644 playbooks/dnszone/dnszone-all-params.yml
 create mode 100644 playbooks/dnszone/dnszone-disable.yml
 create mode 100644 playbooks/dnszone/dnszone-enable.yml
 create mode 100644 playbooks/dnszone/dnszone-present.yml
 create mode 100644 plugins/modules/README.md
 create mode 100644 plugins/modules/ipadnszone.py
 create mode 100644 tests/dnszone/test_dnszone.yml
 create mode 100644 tests/dnszone/test_dnszone_mod.yml

diff --git a/README-dnszone.md b/README-dnszone.md
new file mode 100644
index 00000000..60cc989d
--- /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
+&nbsp; | `ip_address` - The IPv4 or IPv6 address of the DNS server. | yes
+&nbsp; | `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 49060399..c6583fd4 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 00000000..ba863a03
--- /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 00000000..7d47f13d
--- /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 00000000..c02485f5
--- /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 00000000..a4c2fa7e
--- /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 00000000..912bc8f4
--- /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 00000000..c3106317
--- /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/modules/README.md b/plugins/modules/README.md
new file mode 100644
index 00000000..4e06fe10
--- /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 00000000..52a66c27
--- /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 00000000..f7bd1f09
--- /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 00000000..e4b503ae
--- /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
-- 
GitLab