Merge pull request #224 from seocam/dnszone

DNSZone module
This commit is contained in:
Rafael Guterres Jeffman
2020-03-24 15:28:29 -03:00
committed by GitHub
13 changed files with 1634 additions and 9 deletions

194
README-dnszone.md Normal file
View File

@@ -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

View File

@@ -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).

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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()

80
plugins/modules/README.md Normal file
View File

@@ -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.

View File

@@ -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()

View File

@@ -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

View File

@@ -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