Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ipaclient: Configure DNS resolver #988

Merged
merged 1 commit into from
Nov 23, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
38 changes: 38 additions & 0 deletions roles/ipaclient/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Features
* Client deployment
* One-time-password (OTP) support
* Repair mode
* DNS resolver configuration support


Supported FreeIPA Versions
Expand Down Expand Up @@ -107,6 +108,40 @@ Example playbook to setup the IPA client(s) using principal and password from in
state: present
```

Example inventory file with configuration of dns resolvers:

```ini
[ipaclients]
ipaclient1.example.com
ipaclient2.example.com

[ipaservers]
ipaserver.example.com

[ipaclients:vars]
ipaadmin_principal=admin
ipaadmin_password=MySecretPassword123
ipaclient_domain=example.com
ipaclient_configure_dns_resolver=yes
ipaclient_dns_servers=192.168.100.1
```

Example inventory file with cleanup of dns resolvers:

```ini
[ipaclients]
ipaclient1.example.com
ipaclient2.example.com

[ipaservers]
ipaserver.example.com

[ipaclients:vars]
ipaadmin_principal=admin
ipaadmin_password=MySecretPassword123
ipaclient_domain=example.com
ipaclient_cleanup_dns_resolver=yes
```

Playbooks
=========
Expand Down Expand Up @@ -198,6 +233,9 @@ Variable | Description | Required
`ipaclient_allow_repair` | The bool value defines if an already joined or partly set-up client can be repaired. `ipaclient_allow_repair` defaults to `no`. Contrary to `ipaclient_force_join=yes` the host entry will not be changed on the server. | no
`ipaclient_install_packages` | The bool value defines if the needed packages are installed on the node. `ipaclient_install_packages` defaults to `yes`. | no
`ipaclient_on_master` | The bool value is only used in the server and replica installation process to install the client part. It should not be set otherwise. `ipaclient_on_master` defaults to `no`. | no
`ipaclient_configure_dns_resolver` | The bool value defines if the DNS resolver is configured. This is useful if the IPA server has internal DNS support. `ipaclient_dns_server` need to be set also. The installation of packages is happening before the DNS resolver is configured, therefore package installation needs to be possible without the configuration of the DNS resolver. The DNS nameservers are configured for `NetworkManager`, `systemd-resolved` (if installed and enabled) and `/etc/resolv.conf` if neither NetworkManager nor systemd-resolved is used. | no
`ipaclient_dns_servers` | The list of DNS server IP addresses. This is only useful with `ipaclient_configure_dns_resolver`. | no
`ipaclient_cleanup_dns_resolver` | The bool value defines if DNS resolvers that have been configured before with `ipaclient_configure_dns_resolver` will be cleaned up again. | no


Authors
Expand Down
3 changes: 3 additions & 0 deletions roles/ipaclient/defaults/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,6 @@ ipaclient_request_cert: no

### packages ###
ipaclient_install_packages: yes

ipaclient_configure_dns_resolver: no
ipaclient_cleanup_dns_resolver: no
321 changes: 321 additions & 0 deletions roles/ipaclient/library/ipaclient_configure_dns_resolver.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,321 @@
# -*- coding: utf-8 -*-

# Authors:
# Thomas Woerner <twoerner@redhat.com>
#
# Based on ipaplatform/redhat/tasks.py code from Christian Heimes
#
# Copyright (C) 2022 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/>.

from __future__ import (absolute_import, division, print_function)

__metaclass__ = type

ANSIBLE_METADATA = {
'metadata_version': '1.0',
'supported_by': 'community',
'status': ['preview'],
}

DOCUMENTATION = """
---
module: ipaclient_configure_dns_resolver
short_description: Configure DNS resolver for IPA client
description:
Configure DNS resolver for IPA client, register files for installer
options:
nameservers:
description: The nameservers, required with state:present.
type: list
elements: str
required: false
searchdomains:
description: The searchdomains, required with state:present.
type: list
elements: str
required: false
state:
description: The state to ensure.
type: str
choices: ["present", "absent"]
default: present
required: false
author:
- Thomas Woerner (@t-woerner)
"""

EXAMPLES = """
# Ensure DNS nameservers and domain are configured
- ipaclient_configure_dns_resolver:
nameservers: groups.ipaservers
searchdomains: "{{ ipaserver_domain | default(ipaclient_domain) }}"
# Ensure DNS nameservers and domain are not configured
- ipaclient_configure_dns_resolver:
state: absent
"""

RETURN = """
"""

import os
import os.path

from ansible.module_utils.basic import AnsibleModule
from ansible.module_utils.ansible_ipa_client import (
check_imports, services, tasks, paths, sysrestore, CheckedIPAddress
)
try:
from ipalib.installdnsforwarders import detect_resolve1_resolv_conf
except ImportError:
def detect_resolve1_resolv_conf():
"""
Detect if /etc/resolv.conf is managed by systemd-resolved.

See man(5) NetworkManager.conf
"""
systemd_resolv_conf_files = {
"/run/systemd/resolve/stub-resolv.conf",
"/run/systemd/resolve/resolv.conf",
"/lib/systemd/resolv.conf",
"/usr/lib/systemd/resolv.conf",
}

try:
dest = os.readlink(paths.RESOLV_CONF)
except OSError:
# not a link
return False
# convert path relative to /etc/resolv.conf to abs path
dest = os.path.normpath(
os.path.join(os.path.dirname(paths.RESOLV_CONF), dest)
)
return dest in systemd_resolv_conf_files


if hasattr(paths, "SYSTEMD_RESOLVED_IPA_CONF"):
SYSTEMD_RESOLVED_IPA_CONF = paths.SYSTEMD_RESOLVED_IPA_CONF
else:
SYSTEMD_RESOLVED_IPA_CONF = "/etc/systemd/resolved.conf.d/zzz-ipa.conf"


if hasattr(paths, "NETWORK_MANAGER_IPA_CONF"):
NETWORK_MANAGER_IPA_CONF = paths.NETWORK_MANAGER_IPA_CONF
else:
NETWORK_MANAGER_IPA_CONF = "/etc/NetworkManager/conf.d/zzz-ipa.conf"


NM_IPA_CONF = """
# auto-generated by IPA client installer
[main]
dns={dnsprocessing}
[global-dns]
searches={searches}
[global-dns-domain-*]
servers={servers}
"""


RESOLVE1_IPA_CONF = """
# auto-generated by IPA client installer
[Resolve]
# use DNS servers
DNS={servers}
# make default DNS server, add search suffixes
Domains=~. {searchdomains}
"""


def configure_dns_resolver(nameservers, searchdomains, fstore=None):
"""
Configure global DNS resolver (e.g. /etc/resolv.conf).

:param nameservers: list of IP addresses
:param searchdomains: list of search domaons
:param fstore: optional file store for resolv.conf backup
"""
if not nameservers or not isinstance(nameservers, list):
raise AssertionError("nameservers must be of type list")
if not searchdomains or not isinstance(searchdomains, list):
raise AssertionError("searchdomains must be of type list")

if fstore is not None and not fstore.has_file(paths.RESOLV_CONF):
fstore.backup_file(paths.RESOLV_CONF)

resolve1_enabled = detect_resolve1_resolv_conf()
if "NetworkManager" not in services.knownservices:
# NetworkManager is not in wellknownservices for old IPA releases
# Therefore create own service for it.
nm_service = services.service("NetworkManager.service")
else:
nm_service = services.knownservices['NetworkManager']

# At first configure systemd-resolved
if resolve1_enabled:
if not os.path.exists(SYSTEMD_RESOLVED_IPA_CONF):
confd = os.path.dirname(SYSTEMD_RESOLVED_IPA_CONF)
if not os.path.isdir(confd):
os.mkdir(confd)
# owned by root, readable by systemd-resolve user
os.chmod(confd, 0o755)
tasks.restore_context(confd, force=True)

# Additionally to IPA server code also set servers
cfg = RESOLVE1_IPA_CONF.format(
servers=' '.join(nameservers),
searchdomains=" ".join(searchdomains)
)
with open(SYSTEMD_RESOLVED_IPA_CONF, "w") as outf:
os.fchmod(outf.fileno(), 0o644)
outf.write(cfg)

tasks.restore_context(
SYSTEMD_RESOLVED_IPA_CONF, force=True
)

if "systemd-resolved" in services.knownservices:
sdrd_service = services.knownservices["systemd-resolved"]
else:
sdrd_service = services.service("systemd-resolved.service")
if sdrd_service.is_enabled():
sdrd_service.reload_or_restart()

# Then configure NetworkManager or resolve.conf
if nm_service.is_enabled():
if not os.path.exists(NETWORK_MANAGER_IPA_CONF):
# write DNS override and reload network manager to have it create
# a new resolv.conf. The file is prefixed with ``zzz`` to
# make it the last file. Global dns options do not stack and last
# man standing wins.
if resolve1_enabled:
# push DNS configuration to systemd-resolved
dnsprocessing = "systemd-resolved"
else:
# update /etc/resolv.conf
dnsprocessing = "default"

cfg = NM_IPA_CONF.format(
dnsprocessing=dnsprocessing,
servers=','.join(nameservers),
searches=','.join(searchdomains)
)
with open(NETWORK_MANAGER_IPA_CONF, 'w') as outf:
os.fchmod(outf.fileno(), 0o644)
outf.write(cfg)
# reload NetworkManager
nm_service.reload_or_restart()

# Configure resolv.conf if NetworkManager and systemd-resoled are not
# enabled
elif not resolve1_enabled:
# no NM running, no systemd-resolved detected
# fall back to /etc/resolv.conf
cfg = [
"# auto-generated by IPA installer",
"search %s" % ' '.join(searchdomains),
]
for nameserver in nameservers:
cfg.append("nameserver %s" % nameserver)
with open(paths.RESOLV_CONF, 'w') as outf:
outf.write('\n'.join(cfg))


def unconfigure_dns_resolver(fstore=None):
"""
Unconfigure global DNS resolver (e.g. /etc/resolv.conf).

:param fstore: optional file store for resolv.conf restore
"""
if fstore is not None and fstore.has_file(paths.RESOLV_CONF):
fstore.restore_file(paths.RESOLV_CONF)

if os.path.isfile(NETWORK_MANAGER_IPA_CONF):
os.unlink(NETWORK_MANAGER_IPA_CONF)
if "NetworkManager" not in services.knownservices:
# NetworkManager is not in wellknownservices for old IPA releases
# Therefore create own service for it.
nm_service = services.service("NetworkManager.service")
else:
nm_service = services.knownservices['NetworkManager']
if nm_service.is_enabled():
nm_service.reload_or_restart()

if os.path.isfile(SYSTEMD_RESOLVED_IPA_CONF):
os.unlink(SYSTEMD_RESOLVED_IPA_CONF)
if "systemd-resolved" in services.knownservices:
sdrd_service = services.knownservices["systemd-resolved"]
else:
sdrd_service = services.service("systemd-resolved.service")
if sdrd_service.is_enabled():
sdrd_service.reload_or_restart()


def main():
module = AnsibleModule(
argument_spec=dict(
nameservers=dict(type="list", elements="str", aliases=["cn"],
required=False),
searchdomains=dict(type="list", elements="str", aliases=["cn"],
required=False),
state=dict(type="str", default="present",
choices=["present", "absent"]),
),
supports_check_mode=False,
)

check_imports(module)

nameservers = module.params.get('nameservers')
searchdomains = module.params.get('searchdomains')

state = module.params.get("state")

if state == "present":
required = ["nameservers", "searchdomains"]
for param in required:
value = module.params.get(param)
if value is None or len(value) < 1:
module.fail_json(
msg="Argument '%s' is required for state:present" % param)
else:
invalid = ["nameservers", "searchdomains"]
for param in invalid:
if module.params.get(param) is not None:
module.fail_json(
msg="Argument '%s' can not be used with state:present" %
param)

# Check nameservers to contain valid IP addresses
if nameservers is not None:
for value in nameservers:
try:
CheckedIPAddress(value)
except Exception as e:
module.fail_json(
msg="Invalid IP address %s: %s" % (value, str(e)))

fstore = sysrestore.FileStore(paths.IPA_CLIENT_SYSRESTORE)

if state == "present":
configure_dns_resolver(nameservers, searchdomains, fstore)
else:
unconfigure_dns_resolver(fstore)

module.exit_json(changed=True)


if __name__ == '__main__':
main()