590d18
From 12cafe49be52deca889c6a909f6451a464085b06 Mon Sep 17 00:00:00 2001
590d18
From: David Kupka <dkupka@redhat.com>
590d18
Date: Sun, 4 Jan 2015 15:04:18 -0500
590d18
Subject: [PATCH] client: Add support for multiple IP addresses during
590d18
 installation.
590d18
590d18
https://fedorahosted.org/freeipa/ticket/4249
590d18
590d18
Reviewed-By: Martin Basti <mbasti@redhat.com>
590d18
---
590d18
 ipa-client/ipa-install/ipa-client-install | 289 +++++++++++++++++++++++-------
590d18
 1 file changed, 223 insertions(+), 66 deletions(-)
590d18
590d18
diff --git a/ipa-client/ipa-install/ipa-client-install b/ipa-client/ipa-install/ipa-client-install
590d18
index 91323ae115a27d221bcbc43fee887c56d99c8635..793de4fc950ad73b1d88f9ab4bd5178afc8b813d 100755
590d18
--- a/ipa-client/ipa-install/ipa-client-install
590d18
+++ b/ipa-client/ipa-install/ipa-client-install
590d18
@@ -32,6 +32,7 @@ try:
590d18
     from optparse import SUPPRESS_HELP, OptionGroup, OptionValueError
590d18
     import shutil
590d18
     from krbV import Krb5Error
590d18
+    import dns
590d18
 
590d18
     import nss.nss as nss
590d18
     import SSSDConfig
590d18
@@ -180,9 +181,15 @@ def parse_options():
590d18
     basic_group.add_option("--configure-firefox", dest="configure_firefox",
590d18
                             action="store_true", default=False,
590d18
                             help="configure Firefox")
590d18
-    parser.add_option_group(basic_group)
590d18
     basic_group.add_option("--firefox-dir", dest="firefox_dir", default=None,
590d18
                             help="specify directory where Firefox is installed (for example: '/usr/lib/firefox')")
590d18
+    basic_group.add_option("--ip-address", dest="ip_addresses", default=[],
590d18
+        action="append", help="Specify IP address that should be added to DNS."
590d18
+        " This option can be used multiple times")
590d18
+    basic_group.add_option("--all-ip-addresses", dest="all_ip_addresses",
590d18
+        default=False, action="store_true", help="All routable IP"
590d18
+        " addresses configured on any inteface will be added to DNS")
590d18
+    parser.add_option_group(basic_group)
590d18
 
590d18
     sssd_group = OptionGroup(parser, "SSSD options")
590d18
     sssd_group.add_option("--permit", dest="permit",
590d18
@@ -223,6 +230,15 @@ def parse_options():
590d18
     if options.no_nisdomain and options.nisdomain:
590d18
         parser.error("--no-nisdomain cannot be used together with --nisdomain")
590d18
 
590d18
+    if options.ip_addresses:
590d18
+        if options.dns_updates:
590d18
+            parser.error("--ip-address cannot be used together with"
590d18
+                         " --enable-dns-updates")
590d18
+
590d18
+        if options.all_ip_addresses:
590d18
+            parser.error("--ip-address cannot be used together with"
590d18
+                         " --all-ip-addresses")
590d18
+
590d18
     return safe_opts, options
590d18
 
590d18
 def logging_setup(options):
590d18
@@ -1285,6 +1301,11 @@ def configure_sssd_conf(fstore, cli_realm, cli_domain, cli_server, options, clie
590d18
 
590d18
     if options.dns_updates:
590d18
         domain.set_option('dyndns_update', True)
590d18
+        if options.all_ip_addresses:
590d18
+            domain.set_option('dyndns_iface', '*')
590d18
+        else:
590d18
+            iface = get_server_connection_interface(cli_server[0])
590d18
+            domain.set_option('dyndns_iface', iface)
590d18
     if options.krb5_offline_passwords:
590d18
         domain.set_option('krb5_store_password_if_offline', True)
590d18
 
590d18
@@ -1501,39 +1522,41 @@ def unconfigure_nisdomain():
590d18
         services.knownservices.domainname.disable()
590d18
 
590d18
 
590d18
-def resolve_ipaddress(server):
590d18
-    """ Connect to the server's LDAP port in order to determine what ip
590d18
-        address this machine uses as "public" ip (relative to the server).
590d18
-
590d18
-        Returns a tuple with the IP address and address family when
590d18
-        connection was successful. Socket error is raised otherwise.
590d18
-    """
590d18
-    last_socket_error = None
590d18
-
590d18
-    for res in socket.getaddrinfo(server, 389, socket.AF_UNSPEC,
590d18
-            socket.SOCK_STREAM):
590d18
-        af, socktype, proto, canonname, sa = res
590d18
-        try:
590d18
-            s = socket.socket(af, socktype, proto)
590d18
-        except socket.error, e:
590d18
-            last_socket_error = e
590d18
-            s = None
590d18
+def get_iface_from_ip(ip_addr):
590d18
+    ipresult = ipautil.run([paths.IP, '-oneline', 'address', 'show'])
590d18
+    for line in ipresult[0].split('\n'):
590d18
+        fields = line.split()
590d18
+        if len(fields) < 6:
590d18
             continue
590d18
-
590d18
+        if fields[2] not in ['inet', 'inet6']:
590d18
+            continue
590d18
+        (ip, mask) = fields[3].rsplit('/', 1)
590d18
+        if ip == ip_addr:
590d18
+            return fields[1]
590d18
+    else:
590d18
+        raise RuntimeError("IP %s not assigned to any interface." % ip_addr)
590d18
+
590d18
+
590d18
+def get_local_ipaddresses(iface=None):
590d18
+    args = [paths.IP, '-oneline', 'address', 'show']
590d18
+    if iface:
590d18
+        args += ['dev', iface]
590d18
+    ipresult = ipautil.run(args)
590d18
+    lines = ipresult[0].split('\n')
590d18
+    ips = []
590d18
+    for line in lines:
590d18
+        fields = line.split()
590d18
+        if len(fields) < 6:
590d18
+            continue
590d18
+        if fields[2] not in ['inet', 'inet6']:
590d18
+            continue
590d18
+        (ip, mask) = fields[3].rsplit('/', 1)
590d18
         try:
590d18
-            s.connect(sa)
590d18
-            sockname = s.getsockname()
590d18
-
590d18
-            # For both IPv4 and IPv6 own IP address is always the first item
590d18
-            return (sockname[0], af)
590d18
-        except socket.error, e:
590d18
-            last_socket_error = e
590d18
-        finally:
590d18
-            if s:
590d18
-                s.close()
590d18
+            ips.append(ipautil.CheckedIPAddress(ip))
590d18
+        except ValueError:
590d18
+            continue
590d18
+    return ips
590d18
 
590d18
-    if last_socket_error is not None:
590d18
-        raise last_socket_error  # pylint: disable=E0702
590d18
 
590d18
 def do_nsupdate(update_txt):
590d18
     root_logger.debug("Writing nsupdate commands to %s:", UPDATE_FILE)
590d18
@@ -1558,21 +1581,24 @@ def do_nsupdate(update_txt):
590d18
 
590d18
     return result
590d18
 
590d18
-UPDATE_TEMPLATE_A = """
590d18
-debug
590d18
+DELETE_TEMPLATE_A = """
590d18
 update delete $HOSTNAME. IN A
590d18
 show
590d18
 send
590d18
-update add $HOSTNAME. $TTL IN A $IPADDRESS
590d18
-show
590d18
-send
590d18
 """
590d18
 
590d18
-UPDATE_TEMPLATE_AAAA = """
590d18
-debug
590d18
+DELETE_TEMPLATE_AAAA = """
590d18
 update delete $HOSTNAME. IN AAAA
590d18
 show
590d18
 send
590d18
+"""
590d18
+ADD_TEMPLATE_A = """
590d18
+update add $HOSTNAME. $TTL IN A $IPADDRESS
590d18
+show
590d18
+send
590d18
+"""
590d18
+
590d18
+ADD_TEMPLATE_AAAA = """
590d18
 update add $HOSTNAME. $TTL IN AAAA $IPADDRESS
590d18
 show
590d18
 send
590d18
@@ -1581,46 +1607,174 @@ send
590d18
 UPDATE_FILE = paths.IPA_DNS_UPDATE_TXT
590d18
 CCACHE_FILE = paths.IPA_DNS_CCACHE
590d18
 
590d18
-def update_dns(server, hostname):
590d18
 
590d18
+def update_dns(server, hostname, options):
590d18
     try:
590d18
-        (ip, af) = resolve_ipaddress(server)
590d18
-    except socket.gaierror, e:
590d18
-        root_logger.debug("update_dns: could not connect to server: %s", e)
590d18
-        root_logger.error("Cannot update DNS records! "
590d18
-                          "Failed to connect to server '%s'.", server)
590d18
-        return
590d18
-
590d18
-    sub_dict = dict(HOSTNAME=hostname,
590d18
-                    IPADDRESS=ip,
590d18
-                    TTL=1200
590d18
-                )
590d18
-
590d18
-    if af == socket.AF_INET:
590d18
-        template = UPDATE_TEMPLATE_A
590d18
-    elif af == socket.AF_INET6:
590d18
-        template = UPDATE_TEMPLATE_AAAA
590d18
+        ips = get_local_ipaddresses()
590d18
+    except CalledProcessError as e:
590d18
+        root_logger.error("Cannot update DNS records. %s" % e)
590d18
+        root_logger.debug("Unable to get local IP addresses.")
590d18
+
590d18
+    if options.all_ip_addresses:
590d18
+        update_ips = ips
590d18
+    elif options.ip_addresses:
590d18
+        update_ips = []
590d18
+        for ip in options.ip_addresses:
590d18
+            update_ips.append(ipautil.CheckedIPAddress(ip))
590d18
     else:
590d18
-        root_logger.info("Failed to determine this machine's ip address.")
590d18
-        root_logger.warning("Failed to update DNS A record.")
590d18
+        try:
590d18
+            iface = get_server_connection_interface(server)
590d18
+        except RuntimeError as e:
590d18
+            root_logger.error("Cannot update DNS records. %s" % e)
590d18
+            return
590d18
+        try:
590d18
+            update_ips = get_local_ipaddresses(iface)
590d18
+        except CalledProcessError as e:
590d18
+            root_logger.error("Cannot update DNS records. %s" % e)
590d18
+            return
590d18
+
590d18
+    if not update_ips:
590d18
+        root_logger.info("Failed to determine this machine's ip address(es).")
590d18
         return
590d18
 
590d18
-    update_txt = ipautil.template_str(template, sub_dict)
590d18
+    update_txt = "debug\n"
590d18
+    update_txt += ipautil.template_str(DELETE_TEMPLATE_A,
590d18
+                                       dict(HOSTNAME=hostname))
590d18
+    update_txt += ipautil.template_str(DELETE_TEMPLATE_AAAA,
590d18
+                                       dict(HOSTNAME=hostname))
590d18
+
590d18
+    for ip in update_ips:
590d18
+        sub_dict = dict(HOSTNAME=hostname, IPADDRESS=ip, TTL=1200)
590d18
+        if ip.version == 4:
590d18
+            template = ADD_TEMPLATE_A
590d18
+        elif ip.version == 6:
590d18
+            template = ADD_TEMPLATE_AAAA
590d18
+        update_txt += ipautil.template_str(template, sub_dict)
590d18
+
590d18
+    if not do_nsupdate(update_txt):
590d18
+        root_logger.error("Failed to update DNS records.")
590d18
+    verify_dns_update(hostname, update_ips)
590d18
+
590d18
 
590d18
-    if do_nsupdate(update_txt):
590d18
-        root_logger.info("DNS server record set to: %s -> %s", hostname, ip)
590d18
+def verify_dns_update(fqdn, ips):
590d18
+    """
590d18
+    Verify that the fqdn resolves to all IP addresses and
590d18
+    that there's matching PTR record for every IP address.
590d18
+    """
590d18
+    # verify A/AAAA records
590d18
+    missing_ips = [str(ip) for ip in ips]
590d18
+    extra_ips = []
590d18
+    for record_type in [dns.rdatatype.A, dns.rdatatype.AAAA]:
590d18
+        root_logger.debug('DNS resolver: Query: %s IN %s' %
590d18
+                          (fqdn, dns.rdatatype.to_text(record_type)))
590d18
+        try:
590d18
+            answers = dns.resolver.query(fqdn, record_type)
590d18
+        except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN):
590d18
+            root_logger.debug('DNS resolver: No record.')
590d18
+        except dns.resolver.NoNameservers:
590d18
+            root_logger.debug('DNS resolver: No nameservers answered the'
590d18
+                              'query.')
590d18
+        except dns.exception.DNSException:
590d18
+            root_logger.debug('DNS resolver error.')
590d18
+        else:
590d18
+            for rdata in answers:
590d18
+                try:
590d18
+                    missing_ips.remove(rdata.address)
590d18
+                except ValueError:
590d18
+                    extra_ips.append(rdata.address)
590d18
+
590d18
+    # verify PTR records
590d18
+    fqdn_name = dns.name.from_text(fqdn)
590d18
+    wrong_reverse = {}
590d18
+    missing_reverse = [str(ip) for ip in ips]
590d18
+    for ip in ips:
590d18
+        ip_str = str(ip)
590d18
+        addr = dns.reversename.from_address(ip_str)
590d18
+        root_logger.debug('DNS resolver: Query: %s IN PTR' % addr)
590d18
+        try:
590d18
+            answers = dns.resolver.query(addr, dns.rdatatype.PTR)
590d18
+        except (dns.resolver.NoAnswer, dns.resolver.NXDOMAIN):
590d18
+            root_logger.debug('DNS resolver: No record.')
590d18
+        except dns.resolver.NoNameservers:
590d18
+            root_logger.debug('DNS resolver: No nameservers answered the'
590d18
+                              'query.')
590d18
+        except dns.exception.DNSException:
590d18
+            root_logger.debug('DNS resolver error.')
590d18
+        else:
590d18
+            missing_reverse.remove(ip_str)
590d18
+            for rdata in answers:
590d18
+                if not rdata.target == fqdn_name:
590d18
+                    wrong_reverse.setdefault(ip_str, []).append(rdata.target)
590d18
+
590d18
+    if missing_ips:
590d18
+        root_logger.warning('Missing A/AAAA record(s) for host %s: %s.' %
590d18
+                            (fqdn, ', '.join(missing_ips)))
590d18
+    if extra_ips:
590d18
+        root_logger.warning('Extra A/AAAA record(s) for host %s: %s.' %
590d18
+                            (fqdn, ', '.join(extra_ips)))
590d18
+    if missing_reverse:
590d18
+        root_logger.warning('Missing reverse record(s) for address(es): %s.' %
590d18
+                            ', '.join(missing_reverse))
590d18
+    if wrong_reverse:
590d18
+        root_logger.warning('Incorrect reverse record(s):')
590d18
+        for ip in wrong_reverse:
590d18
+            for target in wrong_reverse[ip]:
590d18
+                root_logger.warning('%s is pointing to %s instead of %s' %
590d18
+                                    (ip, target, fqdn_name))
590d18
+
590d18
+def get_server_connection_interface(server):
590d18
+    # connect to IPA server, get all ip addresses of inteface used to connect
590d18
+    for res in socket.getaddrinfo(server, 389, socket.AF_UNSPEC, socket.SOCK_STREAM):
590d18
+        (af, socktype, proto, canonname, sa) = res
590d18
+        try:
590d18
+            s = socket.socket(af, socktype, proto)
590d18
+        except socket.error as e:
590d18
+            last_error = e
590d18
+            s = None
590d18
+            continue
590d18
+        try:
590d18
+            s.connect(sa)
590d18
+            sockname = s.getsockname()
590d18
+            ip = sockname[0]
590d18
+        except socket.error as e:
590d18
+            last_error = e
590d18
+            continue
590d18
+        finally:
590d18
+            if s:
590d18
+                s.close()
590d18
+        try:
590d18
+            return get_iface_from_ip(ip)
590d18
+        except (CalledProcessError, RuntimeError) as e:
590d18
+            last_error = e
590d18
     else:
590d18
-        root_logger.error("Failed to update DNS records.")
590d18
+        msg = "Cannot get server connection interface"
590d18
+        if last_error:
590d18
+            msg += ": %s" % (last_error)
590d18
+        raise RuntimeError(msg)
590d18
 
590d18
-def client_dns(server, hostname, dns_updates=False):
590d18
+
590d18
+def client_dns(server, hostname, options):
590d18
 
590d18
     dns_ok = ipautil.is_host_resolvable(hostname)
590d18
 
590d18
     if not dns_ok:
590d18
-        root_logger.warning("Hostname (%s) not found in DNS", hostname)
590d18
+        root_logger.warning("Hostname (%s) does not have A/AAAA record.",
590d18
+                            hostname)
590d18
+
590d18
+    if (options.dns_updates or options.all_ip_addresses or options.ip_addresses
590d18
+            or not dns_ok):
590d18
+        update_dns(server, hostname, options)
590d18
 
590d18
-    if dns_updates or not dns_ok:
590d18
-        update_dns(server, hostname)
590d18
+
590d18
+def check_ip_addresses(options):
590d18
+    if options.ip_addresses:
590d18
+        for ip in options.ip_addresses:
590d18
+            try:
590d18
+                ipautil.CheckedIPAddress(ip, match_local=True)
590d18
+            except ValueError as e:
590d18
+                root_logger.error(e)
590d18
+                return False
590d18
+    return True
590d18
 
590d18
 def update_ssh_keys(server, hostname, ssh_dir, create_sshfp):
590d18
     if not os.path.isdir(ssh_dir):
590d18
@@ -2127,6 +2281,9 @@ def install(options, env, fstore, statestore):
590d18
     if not options.ca_cert_file and get_cert_path(options.ca_cert_file) == CACERT:
590d18
         root_logger.warning("Using existing certificate '%s'.", CACERT)
590d18
 
590d18
+    if not check_ip_addresses(options):
590d18
+        return CLIENT_INSTALL_ERROR
590d18
+
590d18
     # Create the discovery instance
590d18
     ds = ipadiscovery.IPADiscovery()
590d18
 
590d18
@@ -2717,7 +2874,7 @@ def install(options, env, fstore, statestore):
590d18
     root_logger.info("Added CA certificates to the default NSS database.")
590d18
 
590d18
     if not options.on_master:
590d18
-        client_dns(cli_server[0], hostname, options.dns_updates)
590d18
+        client_dns(cli_server[0], hostname, options)
590d18
         configure_certmonger(fstore, subject_base, cli_realm, hostname,
590d18
                              options, ca_enabled)
590d18
 
590d18
-- 
590d18
2.4.3
590d18