diff --git a/README-dnszone.md b/README-dnszone.md index 9c9b12c2a75030b1a1d02e647a6b828cf76eab91..48b019a99b7dcd59a6c84a00df9cd72d713e6b94 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 | `ip_address` - The IPv4 or IPv6 address of the DNS server. | yes | `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 0000000000000000000000000000000000000000..56938721a29253c8d350cc079f34a505f37a7454 --- /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 c5e812a724153c5531922fb43eb27680f40e6c57..901bfefd51e7901cd851b312fe5e838033b9f7c4 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 0000000000000000000000000000000000000000..9bd2eb0de2b0da5c5c98702a0905b6ee54161384 --- /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 }}"