# Licensed under the Apache License, Version 2.0 (the "License"); you may # not use this file except in compliance with the License. You may obtain # a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations # under the License. """Multicast DNS implementation for API discovery. This implementation follows RFC 6763 as clarified by the API SIG guideline https://review.opendev.org/651222. """ import collections import ipaddress import logging import socket import time from urllib import parse as urlparse from oslo_config import cfg from oslo_config import types as cfg_types import zeroconf from ironic_lib.common.i18n import _ from ironic_lib import exception from ironic_lib import utils opts = [ cfg.IntOpt('registration_attempts', min=1, default=5, help='Number of attempts to register a service. Currently ' 'has to be larger than 1 because of race conditions ' 'in the zeroconf library.'), cfg.IntOpt('lookup_attempts', min=1, default=3, help='Number of attempts to lookup a service.'), cfg.Opt('params', # This is required for values that contain commas. type=cfg_types.Dict(cfg_types.String(quotes=True)), default={}, help='Additional parameters to pass for the registered ' 'service.'), cfg.ListOpt('interfaces', help='List of IP addresses of interfaces to use for mDNS. ' 'Defaults to all interfaces on the system.'), ] CONF = cfg.CONF opt_group = cfg.OptGroup(name='mdns', title='Options for multicast DNS') CONF.register_group(opt_group) CONF.register_opts(opts, opt_group) LOG = logging.getLogger(__name__) _MDNS_DOMAIN = '_openstack._tcp.local.' _endpoint = collections.namedtuple('Endpoint', ['addresses', 'hostname', 'port', 'params']) class Zeroconf(object): """Multicast DNS implementation client and server. Uses threading internally, so there is no start method. It starts automatically on creation. .. warning:: The underlying library does not yet support IPv6. """ def __init__(self): """Initialize and start the mDNS server.""" interfaces = (CONF.mdns.interfaces if CONF.mdns.interfaces else zeroconf.InterfaceChoice.All) # If interfaces are set, let zeroconf auto-detect the version ip_version = None if CONF.mdns.interfaces else zeroconf.IPVersion.All self._zc = zeroconf.Zeroconf(interfaces=interfaces, ip_version=ip_version) self._registered = [] def register_service(self, service_type, endpoint, params=None): """Register a service. This call announces the new services via multicast and instructs the built-in server to respond to queries about it. :param service_type: OpenStack service type, e.g. "baremetal". :param endpoint: full endpoint to reach the service. :param params: optional properties as a dictionary. :raises: :exc:`.ServiceRegistrationFailure` if the service cannot be registered, e.g. because of conflicts. """ parsed = _parse_endpoint(endpoint, service_type) all_params = CONF.mdns.params.copy() if params: all_params.update(params) all_params.update(parsed.params) # TODO(dtantsur): allow overriding TTL values via configuration info = zeroconf.ServiceInfo(_MDNS_DOMAIN, '%s.%s' % (service_type, _MDNS_DOMAIN), addresses=parsed.addresses, port=parsed.port, properties=all_params, server=parsed.hostname) LOG.debug('Registering %s via mDNS', info) # Work around a potential race condition in the registration code: # https://github.com/jstasiak/python-zeroconf/issues/163 delay = 0.1 try: for attempt in range(CONF.mdns.registration_attempts): try: self._zc.register_service(info) except zeroconf.NonUniqueNameException: LOG.debug('Could not register %s - conflict', info) if attempt == CONF.mdns.registration_attempts - 1: raise # reset the cache to purge learned records and retry self._zc.cache = zeroconf.DNSCache() time.sleep(delay) delay *= 2 else: break except zeroconf.Error as exc: raise exception.ServiceRegistrationFailure( service=service_type, error=exc) self._registered.append(info) def get_endpoint(self, service_type, skip_loopback=True, skip_link_local=False): """Get an endpoint and its properties from mDNS. If the requested endpoint is already in the built-in server cache, and its TTL is not exceeded, the cached value is returned. :param service_type: OpenStack service type. :param skip_loopback: Whether to ignore loopback addresses. :param skip_link_local: Whether to ignore link local V6 addresses. :returns: tuple (endpoint URL, properties as a dict). :raises: :exc:`.ServiceLookupFailure` if the service cannot be found. """ delay = 0.1 for attempt in range(CONF.mdns.lookup_attempts): name = '%s.%s' % (service_type, _MDNS_DOMAIN) info = self._zc.get_service_info(name, name) if info is not None: break elif attempt == CONF.mdns.lookup_attempts - 1: raise exception.ServiceLookupFailure(service=service_type) else: time.sleep(delay) delay *= 2 all_addr = info.parsed_addresses() # Try to find the first routable address fallback = None for addr in all_addr: try: loopback = ipaddress.ip_address(addr).is_loopback except ValueError: LOG.debug('Skipping invalid IP address %s', addr) continue else: if loopback and skip_loopback: LOG.debug('Skipping loopback IP address %s', addr) continue if utils.get_route_source(addr, skip_link_local): address = addr break elif fallback is None: fallback = addr else: if fallback is None: raise exception.ServiceLookupFailure( _('None of addresses %(addr)s for service %(service)s ' 'are valid') % {'addr': all_addr, 'service': service_type}) else: LOG.warning('None of addresses %s seem routable, using %s', all_addr, fallback) address = fallback properties = {} for key, value in info.properties.items(): try: if isinstance(key, bytes): key = key.decode('utf-8') except UnicodeError as exc: raise exception.ServiceLookupFailure( _('Invalid properties for service %(svc)s. Cannot decode ' 'key %(key)r: %(exc)r') % {'svc': service_type, 'key': key, 'exc': exc}) try: if isinstance(value, bytes): value = value.decode('utf-8') except UnicodeError as exc: LOG.debug('Cannot convert value %(value)r for key %(key)s ' 'to string, assuming binary: %(exc)s', {'key': key, 'value': value, 'exc': exc}) properties[key] = value path = properties.pop('path', '') protocol = properties.pop('protocol', None) if not protocol: if info.port == 80: protocol = 'http' else: protocol = 'https' if info.server.endswith('.local.'): # Local hostname means that the catalog lists an IP address, # so use it host = address if int(ipaddress.ip_address(host).version) == 6: host = '[%s]' % host else: # Otherwise use the provided hostname. host = info.server.rstrip('.') return ('{proto}://{host}:{port}{path}'.format(proto=protocol, host=host, port=info.port, path=path), properties) def close(self): """Shut down mDNS and unregister services. .. note:: If another server is running for the same services, it will re-register them immediately. """ for info in self._registered: try: self._zc.unregister_service(info) except Exception: LOG.exception('Cound not unregister mDNS service %s', info) self._zc.close() def __enter__(self): return self def __exit__(self, *args): self.close() def get_endpoint(service_type): """Get an endpoint and its properties from mDNS. If the requested endpoint is already in the built-in server cache, and its TTL is not exceeded, the cached value is returned. :param service_type: OpenStack service type. :returns: tuple (endpoint URL, properties as a dict). :raises: :exc:`.ServiceLookupFailure` if the service cannot be found. """ with Zeroconf() as zc: return zc.get_endpoint(service_type) def _parse_endpoint(endpoint, service_type=None): params = {} url = urlparse.urlparse(endpoint) port = url.port if port is None: if url.scheme == 'https': port = 443 else: port = 80 addresses = [] hostname = url.hostname try: infos = socket.getaddrinfo(hostname, port, 0, socket.IPPROTO_TCP) except socket.error as exc: raise exception.ServiceRegistrationFailure( service=service_type, error=_('Could not resolve hostname %(host)s: %(exc)s') % {'host': hostname, 'exc': exc}) for info in infos: ip = info[4][0] if ip == hostname: # we need a host name for the service record. if what we have in # the catalog is an IP address, use the local hostname instead hostname = None # zeroconf requires addresses in network format ip = socket.inet_pton(info[0], ip) if ip not in addresses: addresses.append(ip) if not addresses: raise exception.ServiceRegistrationFailure( service=service_type, error=_('No suitable addresses found for %s') % url.hostname) # avoid storing information that can be derived from existing data if url.path not in ('', '/'): params['path'] = url.path if (not (port == 80 and url.scheme == 'http') and not (port == 443 and url.scheme == 'https')): params['protocol'] = url.scheme # zeroconf is pretty picky about having the trailing dot if hostname is not None and not hostname.endswith('.'): hostname += '.' return _endpoint(addresses, hostname, port, params) def list_opts(): """Entry point for oslo-config-generator.""" return [('mdns', opts)]