Blob Blame History Raw
From abbd15e6f50718119b4dd0380913d2d646eb7638 Mon Sep 17 00:00:00 2001
From: Rafael Guterres Jeffman <rjeffman@redhat.com>
Date: Mon, 3 Aug 2020 19:23:07 -0300
Subject: [PATCH] Add support for option `name_from_ip` in ipadnszone module.

IPA CLI has an option `name_from_ip` that provide a name for a zone
from the reverse IP address, so that it can be used to, for example,
manage PTR DNS records.

This patch adds a similar attribute to ipadnszone module, where it
will try to find the proper zone name, using DNS resolve, or provide
a sane default, if a the zone name cannot be resolved.

The option `name_from_ip` must be used instead of `name` in playbooks,
and it is a string, and not a list.

A new example playbook was added:

    playbooks/dnszone/dnszone-reverse-from-ip.yml

A new test playbook was added:

    tests/dnszone/test_dnszone_name_from_ip.yml
---
 README-dnszone.md                             |   3 +-
 playbooks/dnszone/dnszone-reverse-from-ip.yml |  10 ++
 plugins/modules/ipadnszone.py                 |  65 +++++++++-
 tests/dnszone/test_dnszone_name_from_ip.yml   | 112 ++++++++++++++++++
 4 files changed, 186 insertions(+), 4 deletions(-)
 create mode 100644 playbooks/dnszone/dnszone-reverse-from-ip.yml
 create mode 100644 tests/dnszone/test_dnszone_name_from_ip.yml

diff --git a/README-dnszone.md b/README-dnszone.md
index 9c9b12c..48b019a 100644
--- a/README-dnszone.md
+++ b/README-dnszone.md
@@ -163,7 +163,8 @@ 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
-`name` \| `zone_name` | The zone name string or list of strings. | yes
+`name` \| `zone_name` | The zone name string or list of strings. | no
+`name_from_ip` | Derive zone name from reverse of IP (PTR). | 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
diff --git a/playbooks/dnszone/dnszone-reverse-from-ip.yml b/playbooks/dnszone/dnszone-reverse-from-ip.yml
new file mode 100644
index 0000000..5693872
--- /dev/null
+++ b/playbooks/dnszone/dnszone-reverse-from-ip.yml
@@ -0,0 +1,10 @@
+---
+- name: Playbook to ensure DNS zone exist
+  hosts: ipaserver
+  become: true
+
+  tasks:
+  - name: Ensure zone exist, finding zone name from IP address.
+    ipadnszone:
+      ipaadmin_password: SomeADMINpassword
+      name_from_ip: 10.1.2.3
diff --git a/plugins/modules/ipadnszone.py b/plugins/modules/ipadnszone.py
index c5e812a..901bfef 100644
--- a/plugins/modules/ipadnszone.py
+++ b/plugins/modules/ipadnszone.py
@@ -43,6 +43,10 @@ options:
     required: true
     type: list
     alises: ["zone_name"]
+  name_from_ip:
+    description: Derive zone name from reverse of IP (PTR).
+    required: false
+    type: str
   forwarders:
     description: The list of global DNS forwarders.
     required: false
@@ -197,6 +201,12 @@ from ansible.module_utils.ansible_freeipa_module import (
     is_ipv6_addr,
     is_valid_port,
 )  # noqa: E402
+import netaddr
+import six
+
+
+if six.PY3:
+    unicode = str
 
 
 class DNSZoneModule(FreeIPABaseModule):
@@ -354,6 +364,31 @@ class DNSZoneModule(FreeIPABaseModule):
         if not zone and self.ipa_params.skip_nameserver_check is not None:
             return self.ipa_params.skip_nameserver_check
 
+    def __reverse_zone_name(self, ipaddress):
+        """
+        Infer reverse zone name from an ip address.
+
+        This function uses the same heuristics as FreeIPA to infer the zone
+        name from ip.
+        """
+        try:
+            ip = netaddr.IPAddress(str(ipaddress))
+        except (netaddr.AddrFormatError, ValueError):
+            net = netaddr.IPNetwork(ipaddress)
+            items = net.ip.reverse_dns.split('.')
+            prefixlen = net.prefixlen
+            ip_version = net.version
+        else:
+            items = ip.reverse_dns.split('.')
+            prefixlen = 24 if ip.version == 4 else 64
+            ip_version = ip.version
+        if ip_version == 4:
+            return u'.'.join(items[4 - prefixlen // 8:])
+        elif ip_version == 6:
+            return u'.'.join(items[32 - prefixlen // 4:])
+        else:
+            self.fail_json(msg="Invalid IP version for reverse zone.")
+
     def get_zone(self, zone_name):
         get_zone_args = {"idnsname": zone_name, "all": True}
         response = self.api_command("dnszone_find", args=get_zone_args)
@@ -368,14 +403,33 @@ class DNSZoneModule(FreeIPABaseModule):
         return zone, is_zone_active
 
     def get_zone_names(self):
-        if len(self.ipa_params.name) > 1 and self.ipa_params.state != "absent":
+        zone_names = self.__get_zone_names_from_params()
+        if len(zone_names) > 1 and self.ipa_params.state != "absent":
             self.fail_json(
                 msg=("Please provide a single name. Multiple values for 'name'"
                      "can only be supplied for state 'absent'.")
             )
 
+        return zone_names
+
+    def __get_zone_names_from_params(self):
+        if not self.ipa_params.name:
+            return [self.__reverse_zone_name(self.ipa_params.name_from_ip)]
         return self.ipa_params.name
 
+    def check_ipa_params(self):
+        if not self.ipa_params.name and not self.ipa_params.name_from_ip:
+            self.fail_json(
+                msg="Either `name` or `name_from_ip` must be provided."
+            )
+        if self.ipa_params.state != "present" and self.ipa_params.name_from_ip:
+            self.fail_json(
+                msg=(
+                    "Cannot use argument `name_from_ip` with state `%s`."
+                    % self.ipa_params.state
+                )
+            )
+
     def define_ipa_commands(self):
         for zone_name in self.get_zone_names():
             # Look for existing zone in IPA
@@ -434,8 +488,9 @@ def get_argument_spec():
         ipaadmin_principal=dict(type="str", default="admin"),
         ipaadmin_password=dict(type="str", required=False, no_log=True),
         name=dict(
-            type="list", default=None, required=True, aliases=["zone_name"]
+            type="list", default=None, required=False, aliases=["zone_name"]
         ),
+        name_from_ip=dict(type="str", default=None, required=False),
         forwarders=dict(
             type="list",
             default=None,
@@ -475,7 +530,11 @@ def get_argument_spec():
 
 
 def main():
-    DNSZoneModule(argument_spec=get_argument_spec()).ipa_run()
+    DNSZoneModule(
+        argument_spec=get_argument_spec(),
+        mutually_exclusive=[["name", "name_from_ip"]],
+        required_one_of=[["name", "name_from_ip"]],
+    ).ipa_run()
 
 
 if __name__ == "__main__":
diff --git a/tests/dnszone/test_dnszone_name_from_ip.yml b/tests/dnszone/test_dnszone_name_from_ip.yml
new file mode 100644
index 0000000..9bd2eb0
--- /dev/null
+++ b/tests/dnszone/test_dnszone_name_from_ip.yml
@@ -0,0 +1,112 @@
+---
+- name: Test dnszone
+  hosts: ipaserver
+  become: yes
+  gather_facts: yes
+
+  tasks:
+
+  # Setup
+  - name: Ensure zone is absent.
+    ipadnszone:
+      ipaadmin_password: SomeADMINpassword
+      name: "{{ item }}"
+      state: absent
+    with_items:
+      - 2.0.192.in-addr.arpa.
+      - 0.0.0.0.0.0.0.0.0.0.0.0.0.0.d.f.ip6.arpa.
+      - 1.0.0.0.e.f.a.c.8.b.d.0.1.0.0.2.ip6.arpa.
+
+  # tests
+  - name: Ensure zone exists for reverse IP.
+    ipadnszone:
+      ipaadmin_password: SomeADMINpassword
+      name_from_ip: 192.0.2.3/24
+    register: ipv4_zone
+    failed_when: not ipv4_zone.changed or ipv4_zone.failed
+
+  - name: Ensure zone exists for reverse IP, again.
+    ipadnszone:
+      ipaadmin_password: SomeADMINpassword
+      name_from_ip: 192.0.2.3/24
+    register: result
+    failed_when: result.changed or result.failed
+
+  - name: Ensure zone exists for reverse IP, given the zone name.
+    ipadnszone:
+      ipaadmin_password: SomeADMINpassword
+      name: "{{ ipv4_zone.dnszone.name }}"
+    register: result
+    failed_when: result.changed or result.failed
+
+  - name: Modify existing zone, using `name_from_ip`.
+    ipadnszone:
+      ipaadmin_password: SomeADMINpassword
+      name_from_ip: 192.0.2.3/24
+      default_ttl: 1234
+    register: result
+    failed_when: not result.changed
+
+  - name: Modify existing zone, using `name_from_ip`, again.
+    ipadnszone:
+      ipaadmin_password: SomeADMINpassword
+      name_from_ip: 192.0.2.3/24
+      default_ttl: 1234
+    register: result
+    failed_when: result.changed or result.failed
+
+  - name: Ensure ipv6 zone exists for reverse IPv6.
+    ipadnszone:
+      ipaadmin_password: SomeADMINpassword
+      name_from_ip: fd00::0001
+    register: ipv6_zone
+    failed_when: not ipv6_zone.changed or ipv6_zone.failed
+
+  # - debug:
+  #     msg: "{{ipv6_zone}}"
+
+  - name: Ensure ipv6 zone was created.
+    ipadnszone:
+      ipaadmin_password: SomeADMINpassword
+      name: "{{ ipv6_zone.dnszone.name }}"
+    register: result
+    failed_when: result.changed or result.failed
+
+  - name: Ensure ipv6 zone exists for reverse IPv6, again.
+    ipadnszone:
+      ipaadmin_password: SomeADMINpassword
+      name_from_ip: fd00::0001
+    register: result
+    failed_when: result.changed
+
+  - name: Ensure second ipv6 zone exists for reverse IPv6.
+    ipadnszone:
+      ipaadmin_password: SomeADMINpassword
+      name_from_ip: 2001:db8:cafe:1::1
+    register: ipv6_sec_zone
+    failed_when: not ipv6_sec_zone.changed or ipv6_zone.failed
+
+  - name: Ensure second ipv6 zone was created.
+    ipadnszone:
+      ipaadmin_password: SomeADMINpassword
+      name: "{{ ipv6_sec_zone.dnszone.name }}"
+    register: result
+    failed_when: result.changed or result.failed
+
+  - name: Ensure second ipv6 zone exists for reverse IPv6, again.
+    ipadnszone:
+      ipaadmin_password: SomeADMINpassword
+      name_from_ip: 2001:db8:cafe:1::1
+    register: result
+    failed_when: result.changed
+
+  # Cleanup
+  - name: Ensure zone is absent.
+    ipadnszone:
+      ipaadmin_password: SomeADMINpassword
+      name: "{{ item }}"
+      state: absent
+    with_items:
+      - "{{ ipv6_zone.dnszone.name }}"
+      - "{{ ipv6_sec_zone.dnszone.name }}"
+      - "{{ ipv4_zone.dnszone.name }}"
-- 
2.26.2

From 531e544b30e69f436d14c4ce18c67998c1a0774b Mon Sep 17 00:00:00 2001
From: Rafael Guterres Jeffman <rjeffman@redhat.com>
Date: Wed, 5 Aug 2020 15:13:46 -0300
Subject: [PATCH] Added support for client defined result data in
 FReeIPABaseModule

Modified support for processing result of IPA API commands so that
client code can define its own processing and add return values to
self.exit_args based on command result.

If a subclass need to process the result of IPA API commands it should
override the method `process_command_result`. The default implementation
will simply evaluate if `changed` should be true.
---
 .../module_utils/ansible_freeipa_module.py    | 22 +++++++++++++------
 plugins/modules/ipadnszone.py                 |  8 +++++++
 2 files changed, 23 insertions(+), 7 deletions(-)

diff --git a/plugins/module_utils/ansible_freeipa_module.py b/plugins/module_utils/ansible_freeipa_module.py
index 4799e5a..30302b4 100644
--- a/plugins/module_utils/ansible_freeipa_module.py
+++ b/plugins/module_utils/ansible_freeipa_module.py
@@ -619,7 +619,7 @@ class FreeIPABaseModule(AnsibleModule):
         if exc_val:
             self.fail_json(msg=str(exc_val))
 
-        self.exit_json(changed=self.changed, user=self.exit_args)
+        self.exit_json(changed=self.changed, **self.exit_args)
 
     def get_command_errors(self, command, result):
         """Look for erros into command results."""
@@ -658,14 +658,22 @@ class FreeIPABaseModule(AnsibleModule):
             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.process_command_result(name, command, args, result)
             self.get_command_errors(command, result)
 
+    def process_command_result(self, name, command, args, result):
+        """
+        Process an API command result.
+
+        This method can be overriden in subclasses, and change self.exit_values
+        to return data in the result for the controller.
+        """
+        if "completed" in result:
+            if result["completed"] > 0:
+                self.changed = True
+        else:
+            self.changed = True
+
     def require_ipa_attrs_change(self, command_args, ipa_attrs):
         """
         Compare given args with current object attributes.
diff --git a/plugins/modules/ipadnszone.py b/plugins/modules/ipadnszone.py
index 901bfef..6a90fa2 100644
--- a/plugins/modules/ipadnszone.py
+++ b/plugins/modules/ipadnszone.py
@@ -472,6 +472,14 @@ class DNSZoneModule(FreeIPABaseModule):
                 }
                 self.add_ipa_command("dnszone_mod", zone_name, args)
 
+    def process_command_result(self, name, command, args, result):
+        super(DNSZoneModule, self).process_command_result(
+            name, command, args, result
+        )
+        if command == "dnszone_add" and self.ipa_params.name_from_ip:
+            dnszone_exit_args = self.exit_args.setdefault('dnszone', {})
+            dnszone_exit_args['name'] = name
+
 
 def get_argument_spec():
     forwarder_spec = dict(
-- 
2.26.2

From 41e8226d0c03e06816626d78cecbc2aebf547691 Mon Sep 17 00:00:00 2001
From: Rafael Guterres Jeffman <rjeffman@redhat.com>
Date: Wed, 5 Aug 2020 15:14:43 -0300
Subject: [PATCH] Return the zone_name when adding a zone with name_from_ip.

When adding a zone using the option name_from_ip, the user have
little control over the final name of the zone, and if this name
is to be used in further processing in a playbook it might lead to
errors if the inferred name does not match what the user wanted to.

By returning the actual inferred zone name, the name can be safely
used for other tasks in the playbook.
---
 README-dnszone.md                             | 11 +++++++++++
 playbooks/dnszone/dnszone-reverse-from-ip.yml |  7 ++++++-
 plugins/modules/ipadnszone.py                 |  8 ++++++++
 3 files changed, 25 insertions(+), 1 deletion(-)

diff --git a/README-dnszone.md b/README-dnszone.md
index 48b019a..3f4827b 100644
--- a/README-dnszone.md
+++ b/README-dnszone.md
@@ -190,6 +190,17 @@ Variable | Description | Required
 `skip_nameserver_check` | Force DNS zone creation even if nameserver is not resolvable | no
 
 
+Return Values
+=============
+
+ipadnszone
+----------
+
+Variable | Description | Returned When
+-------- | ----------- | -------------
+`dnszone` | DNS Zone dict with zone name infered from `name_from_ip`. <br>Options: |  If `state` is `present`, `name_from_ip` is used, and a zone was created.
+&nbsp; | `name` - The name of the zone created, inferred from `name_from_ip`. | Always
+
 Authors
 =======
 
diff --git a/playbooks/dnszone/dnszone-reverse-from-ip.yml b/playbooks/dnszone/dnszone-reverse-from-ip.yml
index 5693872..218a318 100644
--- a/playbooks/dnszone/dnszone-reverse-from-ip.yml
+++ b/playbooks/dnszone/dnszone-reverse-from-ip.yml
@@ -7,4 +7,9 @@
   - name: Ensure zone exist, finding zone name from IP address.
     ipadnszone:
       ipaadmin_password: SomeADMINpassword
-      name_from_ip: 10.1.2.3
+      name_from_ip: 10.1.2.3/24
+    register: result
+
+  - name: Zone name inferred from `name_from_ip`
+    debug:
+      msg: "Zone created: {{ result.dnszone.name }}"
diff --git a/plugins/modules/ipadnszone.py b/plugins/modules/ipadnszone.py
index 6a90fa2..93eac07 100644
--- a/plugins/modules/ipadnszone.py
+++ b/plugins/modules/ipadnszone.py
@@ -192,6 +192,14 @@ EXAMPLES = """
 """
 
 RETURN = """
+dnszone:
+  description: DNS Zone dict with zone name infered from `name_from_ip`.
+  returned:
+    If `state` is `present`, `name_from_ip` is used, and a zone was created.
+  options:
+    name:
+      description: The name of the zone created, inferred from `name_from_ip`.
+      returned: always
 """
 
 from ipapython.dnsutil import DNSName  # noqa: E402
-- 
2.26.2

From 46bbc7bbd7a4e01d07b0390aee8c799aaa5ac895 Mon Sep 17 00:00:00 2001
From: Rafael Guterres Jeffman <rjeffman@redhat.com>
Date: Mon, 17 Aug 2020 15:52:38 -0300
Subject: [PATCH] Document usage of `name_from_ip`.

Since `name_from_ip` has a similar, but not equal, behavior to `name`,
and as the inferred DNS zone might depend on DNS configuration and
can be different than the user expects, it has some limited usage,
and the user must be aware of its effects.

This change to the documentation enhance the documentation including
more details on the attribute usage.
---
 README-dnszone.md             | 42 ++++++++++++++++++++++++++++++++++-
 plugins/modules/ipadnszone.py |  4 +++-
 2 files changed, 44 insertions(+), 2 deletions(-)

diff --git a/README-dnszone.md b/README-dnszone.md
index 3f4827b..c5a7ab3 100644
--- a/README-dnszone.md
+++ b/README-dnszone.md
@@ -152,6 +152,46 @@ Example playbook to remove a zone:
 
 ```
 
+Example playbook to create a zone for reverse DNS lookup, from an IP address:
+
+```yaml
+
+---
+- name: dnszone present
+  hosts: ipaserver
+  become: true
+
+  tasks:
+  - name: Ensure zone for reverse DNS lookup is present.
+    ipadnszone:
+      ipaadmin_password: SomeADMINpassword
+      name_from_ip: 192.168.1.2
+      state: present
+```
+
+Note that, on the previous example the zone created with `name_from_ip` might be "1.168.192.in-addr.arpa.", "168.192.in-addr.arpa.", or "192.in-addr.arpa.", depending on the DNS response the system get while querying for zones, and for this reason, when creating a zone using `name_from_ip`, the inferred zone name is returned to the controller, in the attribute `dnszone.name`. Since the zone inferred might not be what a user expects, `name_from_ip` can only be used with `state: present`. To have more control over the zone name, the prefix length for the IP address can be provided.
+
+Example playbook to create a zone for reverse DNS lookup, from an IP address, given the prefix length and displaying the resulting zone name:
+
+```yaml
+
+---
+- name: dnszone present
+  hosts: ipaserver
+  become: true
+
+  tasks:
+      - name: Ensure zone for reverse DNS lookup is present.
+    ipadnszone:
+      ipaadmin_password: SomeADMINpassword
+      name_from_ip: 192.168.1.2/24
+      state: present
+    register: result
+  - name: Display inferred zone name.
+    debug:
+      msg: "Zone name: {{ result.dnszone.name }}"
+```
+
 
 Variables
 =========
@@ -164,7 +204,7 @@ 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
 `name` \| `zone_name` | The zone name string or list of strings. | no
-`name_from_ip` | Derive zone name from reverse of IP (PTR). | no
+`name_from_ip` | Derive zone name from reverse of IP (PTR). Can only be used with `state: present`. | 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
diff --git a/plugins/modules/ipadnszone.py b/plugins/modules/ipadnszone.py
index 93eac07..ff6bfff 100644
--- a/plugins/modules/ipadnszone.py
+++ b/plugins/modules/ipadnszone.py
@@ -44,7 +44,9 @@ options:
     type: list
     alises: ["zone_name"]
   name_from_ip:
-    description: Derive zone name from reverse of IP (PTR).
+    description: |
+      Derive zone name from reverse of IP (PTR).
+      Can only be used with `state: present`.
     required: false
     type: str
   forwarders:
-- 
2.26.2