From 1c17f426acad60b6db49ed88488fcca9d80d2baa Mon Sep 17 00:00:00 2001 From: Thomas Woerner Date: Tue, 22 Nov 2022 16:03:06 +0100 Subject: [PATCH] ipaclient: Configure DNS resolver The configuration of the DNS resolver is useful if the IPA server has internal DNS support. 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. Example inventory: [ipaserver] ipaserver.example.com [ipaclients] ipaclient1.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 ipaclient_cleanup_dns_resolver=yes New parameters: ipaclient_configure_dns_resolver The bool value defines if the DNS resolver is configured. before deploying the client. This is useful if the IPA server has internal DNS support. ipaclient_dns_server need to be set also. ipaclient_dns_servers The list of DNS server IP addresses. This is only useful with ipaclient_configure_dns_resolver. 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. New module: roles/ipaclient/library/ipaclient_configure_dns_resolver.py Fixes: #902 (Consider adding support for client DNS resolver configuration) --- roles/ipaclient/README.md | 38 +++ roles/ipaclient/defaults/main.yml | 3 + .../ipaclient_configure_dns_resolver.py | 321 ++++++++++++++++++ roles/ipaclient/tasks/install.yml | 19 ++ roles/ipaclient/tasks/uninstall.yml | 5 + 5 files changed, 386 insertions(+) create mode 100644 roles/ipaclient/library/ipaclient_configure_dns_resolver.py diff --git a/roles/ipaclient/README.md b/roles/ipaclient/README.md index 4804de73ce..342369bd14 100644 --- a/roles/ipaclient/README.md +++ b/roles/ipaclient/README.md @@ -11,6 +11,7 @@ Features * Client deployment * One-time-password (OTP) support * Repair mode +* DNS resolver configuration support Supported FreeIPA Versions @@ -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 ========= @@ -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 diff --git a/roles/ipaclient/defaults/main.yml b/roles/ipaclient/defaults/main.yml index 5776178dd7..3b13d11b33 100644 --- a/roles/ipaclient/defaults/main.yml +++ b/roles/ipaclient/defaults/main.yml @@ -28,3 +28,6 @@ ipaclient_request_cert: no ### packages ### ipaclient_install_packages: yes + +ipaclient_configure_dns_resolver: no +ipaclient_cleanup_dns_resolver: no diff --git a/roles/ipaclient/library/ipaclient_configure_dns_resolver.py b/roles/ipaclient/library/ipaclient_configure_dns_resolver.py new file mode 100644 index 0000000000..0c10376b44 --- /dev/null +++ b/roles/ipaclient/library/ipaclient_configure_dns_resolver.py @@ -0,0 +1,321 @@ +# -*- coding: utf-8 -*- + +# Authors: +# Thomas Woerner +# +# 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 . + +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() diff --git a/roles/ipaclient/tasks/install.yml b/roles/ipaclient/tasks/install.yml index 46cfc3aa78..1f5f78b148 100644 --- a/roles/ipaclient/tasks/install.yml +++ b/roles/ipaclient/tasks/install.yml @@ -27,6 +27,25 @@ ipaadmin_principal: admin when: ipaadmin_principal is undefined and ipaclient_keytab is undefined +- name: Install - Configure DNS resolver Block + block: + + - name: Install - Fail on missing ipaclient_domain and ipaserver_domain + fail: msg="ipaclient_domain or ipaserver_domain is required for ipaclient_configure_dns_resolver" + when: ipaserver_domain is not defined and ipaclient_domain is not defined + + - name: Install - Fail on missing ipaclient_servers + fail: msg="ipaclient_dns_servers is required for ipaclient_configure_dns_resolver" + when: ipaclient_dns_servers is not defined + + - name: Install - Configure DNS resolver + ipaclient_configure_dns_resolver: + nameservers: "{{ ipaclient_dns_servers }}" + searchdomains: "{{ ipaserver_domain | default(ipaclient_domain) }}" + state: present + + when: ipaclient_configure_dns_resolver + - name: Install - IPA client test ipaclient_test: ### basic ### diff --git a/roles/ipaclient/tasks/uninstall.yml b/roles/ipaclient/tasks/uninstall.yml index 90078542ef..7165f2ea4a 100644 --- a/roles/ipaclient/tasks/uninstall.yml +++ b/roles/ipaclient/tasks/uninstall.yml @@ -11,6 +11,11 @@ failed_when: uninstall.rc != 0 and uninstall.rc != 2 changed_when: uninstall.rc == 0 +- name: Uninstall - Unconfigure DNS resolver + ipaclient_configure_dns_resolver: + state: absent + when: ipaclient_cleanup_dns_resolver | bool + #- name: Remove IPA client package # package: # name: "{{ ipaclient_packages }}"