419 lines
15 KiB
Python
419 lines
15 KiB
Python
|
""" Multicast DNS Service Discovery for Python, v0.14-wmcbrine
|
||
|
Copyright 2003 Paul Scott-Murphy, 2014 William McBrine
|
||
|
|
||
|
This module provides a framework for the use of DNS Service Discovery
|
||
|
using IP multicast.
|
||
|
|
||
|
This library is free software; you can redistribute it and/or
|
||
|
modify it under the terms of the GNU Lesser General Public
|
||
|
License as published by the Free Software Foundation; either
|
||
|
version 2.1 of the License, or (at your option) any later version.
|
||
|
|
||
|
This library 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
|
||
|
Lesser General Public License for more details.
|
||
|
|
||
|
You should have received a copy of the GNU Lesser General Public
|
||
|
License along with this library; if not, write to the Free Software
|
||
|
Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301
|
||
|
USA
|
||
|
"""
|
||
|
|
||
|
import enum
|
||
|
import errno
|
||
|
import ipaddress
|
||
|
import socket
|
||
|
import struct
|
||
|
import sys
|
||
|
from typing import Any, List, Optional, Sequence, Tuple, Union, cast
|
||
|
|
||
|
import ifaddr
|
||
|
|
||
|
from .._logger import log
|
||
|
from ..const import _IPPROTO_IPV6, _MDNS_ADDR, _MDNS_ADDR6, _MDNS_PORT
|
||
|
|
||
|
|
||
|
@enum.unique
|
||
|
class InterfaceChoice(enum.Enum):
|
||
|
Default = 1
|
||
|
All = 2
|
||
|
|
||
|
|
||
|
InterfacesType = Union[Sequence[Union[str, int, Tuple[Tuple[str, int, int], int]]], InterfaceChoice]
|
||
|
|
||
|
|
||
|
@enum.unique
|
||
|
class ServiceStateChange(enum.Enum):
|
||
|
Added = 1
|
||
|
Removed = 2
|
||
|
Updated = 3
|
||
|
|
||
|
|
||
|
@enum.unique
|
||
|
class IPVersion(enum.Enum):
|
||
|
V4Only = 1
|
||
|
V6Only = 2
|
||
|
All = 3
|
||
|
|
||
|
|
||
|
# utility functions
|
||
|
|
||
|
|
||
|
def _is_v6_address(addr: bytes) -> bool:
|
||
|
return len(addr) == 16
|
||
|
|
||
|
|
||
|
def _encode_address(address: str) -> bytes:
|
||
|
is_ipv6 = ':' in address
|
||
|
address_family = socket.AF_INET6 if is_ipv6 else socket.AF_INET
|
||
|
return socket.inet_pton(address_family, address)
|
||
|
|
||
|
|
||
|
def get_all_addresses() -> List[str]:
|
||
|
return list({addr.ip for iface in ifaddr.get_adapters() for addr in iface.ips if addr.is_IPv4})
|
||
|
|
||
|
|
||
|
def get_all_addresses_v6() -> List[Tuple[Tuple[str, int, int], int]]:
|
||
|
# IPv6 multicast uses positive indexes for interfaces
|
||
|
# TODO: What about multi-address interfaces?
|
||
|
return list(
|
||
|
{(addr.ip, iface.index) for iface in ifaddr.get_adapters() for addr in iface.ips if addr.is_IPv6}
|
||
|
)
|
||
|
|
||
|
|
||
|
def ip6_to_address_and_index(adapters: List[Any], ip: str) -> Tuple[Tuple[str, int, int], int]:
|
||
|
if '%' in ip:
|
||
|
ip = ip[: ip.index('%')] # Strip scope_id.
|
||
|
ipaddr = ipaddress.ip_address(ip)
|
||
|
for adapter in adapters:
|
||
|
for adapter_ip in adapter.ips:
|
||
|
# IPv6 addresses are represented as tuples
|
||
|
if isinstance(adapter_ip.ip, tuple) and ipaddress.ip_address(adapter_ip.ip[0]) == ipaddr:
|
||
|
return (cast(Tuple[str, int, int], adapter_ip.ip), cast(int, adapter.index))
|
||
|
|
||
|
raise RuntimeError('No adapter found for IP address %s' % ip)
|
||
|
|
||
|
|
||
|
def interface_index_to_ip6_address(adapters: List[Any], index: int) -> Tuple[str, int, int]:
|
||
|
for adapter in adapters:
|
||
|
if adapter.index == index:
|
||
|
for adapter_ip in adapter.ips:
|
||
|
# IPv6 addresses are represented as tuples
|
||
|
if isinstance(adapter_ip.ip, tuple):
|
||
|
return cast(Tuple[str, int, int], adapter_ip.ip)
|
||
|
|
||
|
raise RuntimeError('No adapter found for index %s' % index)
|
||
|
|
||
|
|
||
|
def ip6_addresses_to_indexes(
|
||
|
interfaces: Sequence[Union[str, int, Tuple[Tuple[str, int, int], int]]]
|
||
|
) -> List[Tuple[Tuple[str, int, int], int]]:
|
||
|
"""Convert IPv6 interface addresses to interface indexes.
|
||
|
|
||
|
IPv4 addresses are ignored.
|
||
|
|
||
|
:param interfaces: List of IP addresses and indexes.
|
||
|
:returns: List of indexes.
|
||
|
"""
|
||
|
result = []
|
||
|
adapters = ifaddr.get_adapters()
|
||
|
|
||
|
for iface in interfaces:
|
||
|
if isinstance(iface, int):
|
||
|
result.append((interface_index_to_ip6_address(adapters, iface), iface))
|
||
|
elif isinstance(iface, str) and ipaddress.ip_address(iface).version == 6:
|
||
|
result.append(ip6_to_address_and_index(adapters, iface))
|
||
|
|
||
|
return result
|
||
|
|
||
|
|
||
|
def normalize_interface_choice(
|
||
|
choice: InterfacesType, ip_version: IPVersion = IPVersion.V4Only
|
||
|
) -> List[Union[str, Tuple[Tuple[str, int, int], int]]]:
|
||
|
"""Convert the interfaces choice into internal representation.
|
||
|
|
||
|
:param choice: `InterfaceChoice` or list of interface addresses or indexes (IPv6 only).
|
||
|
:param ip_address: IP version to use (ignored if `choice` is a list).
|
||
|
:returns: List of IP addresses (for IPv4) and indexes (for IPv6).
|
||
|
"""
|
||
|
result: List[Union[str, Tuple[Tuple[str, int, int], int]]] = []
|
||
|
if choice is InterfaceChoice.Default:
|
||
|
if ip_version != IPVersion.V4Only:
|
||
|
# IPv6 multicast uses interface 0 to mean the default
|
||
|
result.append((('', 0, 0), 0))
|
||
|
if ip_version != IPVersion.V6Only:
|
||
|
result.append('0.0.0.0')
|
||
|
elif choice is InterfaceChoice.All:
|
||
|
if ip_version != IPVersion.V4Only:
|
||
|
result.extend(get_all_addresses_v6())
|
||
|
if ip_version != IPVersion.V6Only:
|
||
|
result.extend(get_all_addresses())
|
||
|
if not result:
|
||
|
raise RuntimeError(
|
||
|
'No interfaces to listen on, check that any interfaces have IP version %s' % ip_version
|
||
|
)
|
||
|
elif isinstance(choice, list):
|
||
|
# First, take IPv4 addresses.
|
||
|
result = [i for i in choice if isinstance(i, str) and ipaddress.ip_address(i).version == 4]
|
||
|
# Unlike IP_ADD_MEMBERSHIP, IPV6_JOIN_GROUP requires interface indexes.
|
||
|
result += ip6_addresses_to_indexes(choice)
|
||
|
else:
|
||
|
raise TypeError("choice must be a list or InterfaceChoice, got %r" % choice)
|
||
|
return result
|
||
|
|
||
|
|
||
|
def disable_ipv6_only_or_raise(s: socket.socket) -> None:
|
||
|
"""Make V6 sockets work for both V4 and V6 (required for Windows)."""
|
||
|
try:
|
||
|
s.setsockopt(_IPPROTO_IPV6, socket.IPV6_V6ONLY, False)
|
||
|
except OSError:
|
||
|
log.error('Support for dual V4-V6 sockets is not present, use IPVersion.V4 or IPVersion.V6')
|
||
|
raise
|
||
|
|
||
|
|
||
|
def set_so_reuseport_if_available(s: socket.socket) -> None:
|
||
|
"""Set SO_REUSEADDR on a socket if available."""
|
||
|
# SO_REUSEADDR should be equivalent to SO_REUSEPORT for
|
||
|
# multicast UDP sockets (p 731, "TCP/IP Illustrated,
|
||
|
# Volume 2"), but some BSD-derived systems require
|
||
|
# SO_REUSEPORT to be specified explicitly. Also, not all
|
||
|
# versions of Python have SO_REUSEPORT available.
|
||
|
# Catch OSError and socket.error for kernel versions <3.9 because lacking
|
||
|
# SO_REUSEPORT support.
|
||
|
if not hasattr(socket, 'SO_REUSEPORT'):
|
||
|
return
|
||
|
|
||
|
try:
|
||
|
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) # pylint: disable=no-member
|
||
|
except OSError as err:
|
||
|
if err.errno != errno.ENOPROTOOPT:
|
||
|
raise
|
||
|
|
||
|
|
||
|
def set_mdns_port_socket_options_for_ip_version(
|
||
|
s: socket.socket, bind_addr: Union[Tuple[str], Tuple[str, int, int]], ip_version: IPVersion
|
||
|
) -> None:
|
||
|
"""Set ttl/hops and loop for mdns port."""
|
||
|
if ip_version != IPVersion.V6Only:
|
||
|
ttl = struct.pack(b'B', 255)
|
||
|
loop = struct.pack(b'B', 1)
|
||
|
# OpenBSD needs the ttl and loop values for the IP_MULTICAST_TTL and
|
||
|
# IP_MULTICAST_LOOP socket options as an unsigned char.
|
||
|
try:
|
||
|
s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, ttl)
|
||
|
s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, loop)
|
||
|
except OSError as e:
|
||
|
if bind_addr[0] != '' or get_errno(e) != errno.EINVAL: # Fails to set on MacOS
|
||
|
raise
|
||
|
|
||
|
if ip_version != IPVersion.V4Only:
|
||
|
# However, char doesn't work here (at least on Linux)
|
||
|
s.setsockopt(_IPPROTO_IPV6, socket.IPV6_MULTICAST_HOPS, 255)
|
||
|
s.setsockopt(_IPPROTO_IPV6, socket.IPV6_MULTICAST_LOOP, True)
|
||
|
|
||
|
|
||
|
def new_socket(
|
||
|
bind_addr: Union[Tuple[str], Tuple[str, int, int]],
|
||
|
port: int = _MDNS_PORT,
|
||
|
ip_version: IPVersion = IPVersion.V4Only,
|
||
|
apple_p2p: bool = False,
|
||
|
) -> Optional[socket.socket]:
|
||
|
log.debug(
|
||
|
'Creating new socket with port %s, ip_version %s, apple_p2p %s and bind_addr %r',
|
||
|
port,
|
||
|
ip_version,
|
||
|
apple_p2p,
|
||
|
bind_addr,
|
||
|
)
|
||
|
socket_family = socket.AF_INET if ip_version == IPVersion.V4Only else socket.AF_INET6
|
||
|
s = socket.socket(socket_family, socket.SOCK_DGRAM)
|
||
|
|
||
|
if ip_version == IPVersion.All:
|
||
|
disable_ipv6_only_or_raise(s)
|
||
|
|
||
|
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||
|
set_so_reuseport_if_available(s)
|
||
|
|
||
|
if port == _MDNS_PORT:
|
||
|
set_mdns_port_socket_options_for_ip_version(s, bind_addr, ip_version)
|
||
|
|
||
|
if apple_p2p:
|
||
|
# SO_RECV_ANYIF = 0x1104
|
||
|
# https://opensource.apple.com/source/xnu/xnu-4570.41.2/bsd/sys/socket.h
|
||
|
s.setsockopt(socket.SOL_SOCKET, 0x1104, 1)
|
||
|
|
||
|
bind_tup = (bind_addr[0], port, *bind_addr[1:])
|
||
|
try:
|
||
|
s.bind(bind_tup)
|
||
|
except OSError as ex:
|
||
|
if ex.errno == errno.EADDRNOTAVAIL:
|
||
|
log.warning(
|
||
|
'Address not available when binding to %s, ' 'it is expected to happen on some systems',
|
||
|
bind_tup,
|
||
|
)
|
||
|
return None
|
||
|
raise
|
||
|
log.debug('Created socket %s', s)
|
||
|
return s
|
||
|
|
||
|
|
||
|
def add_multicast_member(
|
||
|
listen_socket: socket.socket,
|
||
|
interface: Union[str, Tuple[Tuple[str, int, int], int]],
|
||
|
) -> bool:
|
||
|
# This is based on assumptions in normalize_interface_choice
|
||
|
is_v6 = isinstance(interface, tuple)
|
||
|
err_einval = {errno.EINVAL}
|
||
|
if sys.platform == 'win32':
|
||
|
# No WSAEINVAL definition in typeshed
|
||
|
err_einval |= {cast(Any, errno).WSAEINVAL} # pylint: disable=no-member
|
||
|
log.debug('Adding %r (socket %d) to multicast group', interface, listen_socket.fileno())
|
||
|
try:
|
||
|
if is_v6:
|
||
|
try:
|
||
|
mdns_addr6_bytes = socket.inet_pton(socket.AF_INET6, _MDNS_ADDR6)
|
||
|
except OSError:
|
||
|
log.info(
|
||
|
'Unable to translate IPv6 address when adding %s to multicast group, '
|
||
|
'this can happen if IPv6 is disabled on the system',
|
||
|
interface,
|
||
|
)
|
||
|
return False
|
||
|
iface_bin = struct.pack('@I', cast(int, interface[1]))
|
||
|
_value = mdns_addr6_bytes + iface_bin
|
||
|
listen_socket.setsockopt(_IPPROTO_IPV6, socket.IPV6_JOIN_GROUP, _value)
|
||
|
else:
|
||
|
_value = socket.inet_aton(_MDNS_ADDR) + socket.inet_aton(cast(str, interface))
|
||
|
listen_socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, _value)
|
||
|
except OSError as e:
|
||
|
_errno = get_errno(e)
|
||
|
if _errno == errno.EADDRINUSE:
|
||
|
log.info(
|
||
|
'Address in use when adding %s to multicast group, '
|
||
|
'it is expected to happen on some systems',
|
||
|
interface,
|
||
|
)
|
||
|
return False
|
||
|
if _errno == errno.EADDRNOTAVAIL:
|
||
|
log.info(
|
||
|
'Address not available when adding %s to multicast '
|
||
|
'group, it is expected to happen on some systems',
|
||
|
interface,
|
||
|
)
|
||
|
return False
|
||
|
if _errno in err_einval:
|
||
|
log.info('Interface of %s does not support multicast, ' 'it is expected in WSL', interface)
|
||
|
return False
|
||
|
if _errno == errno.ENOPROTOOPT:
|
||
|
log.info(
|
||
|
'Failed to set socket option on %s, this can happen if '
|
||
|
'the network adapter is in a disconnected state',
|
||
|
interface,
|
||
|
)
|
||
|
return False
|
||
|
if is_v6 and _errno == errno.ENODEV:
|
||
|
log.info(
|
||
|
'Address in use when adding %s to multicast group, '
|
||
|
'it is expected to happen when the device does not have ipv6',
|
||
|
interface,
|
||
|
)
|
||
|
return False
|
||
|
raise
|
||
|
return True
|
||
|
|
||
|
|
||
|
def new_respond_socket(
|
||
|
interface: Union[str, Tuple[Tuple[str, int, int], int]],
|
||
|
apple_p2p: bool = False,
|
||
|
) -> Optional[socket.socket]:
|
||
|
is_v6 = isinstance(interface, tuple)
|
||
|
respond_socket = new_socket(
|
||
|
ip_version=(IPVersion.V6Only if is_v6 else IPVersion.V4Only),
|
||
|
apple_p2p=apple_p2p,
|
||
|
bind_addr=cast(Tuple[Tuple[str, int, int], int], interface)[0] if is_v6 else (cast(str, interface),),
|
||
|
)
|
||
|
if not respond_socket:
|
||
|
return None
|
||
|
log.debug('Configuring socket %s with multicast interface %s', respond_socket, interface)
|
||
|
if is_v6:
|
||
|
iface_bin = struct.pack('@I', cast(int, interface[1]))
|
||
|
respond_socket.setsockopt(_IPPROTO_IPV6, socket.IPV6_MULTICAST_IF, iface_bin)
|
||
|
else:
|
||
|
respond_socket.setsockopt(
|
||
|
socket.IPPROTO_IP, socket.IP_MULTICAST_IF, socket.inet_aton(cast(str, interface))
|
||
|
)
|
||
|
return respond_socket
|
||
|
|
||
|
|
||
|
def create_sockets(
|
||
|
interfaces: InterfacesType = InterfaceChoice.All,
|
||
|
unicast: bool = False,
|
||
|
ip_version: IPVersion = IPVersion.V4Only,
|
||
|
apple_p2p: bool = False,
|
||
|
) -> Tuple[Optional[socket.socket], List[socket.socket]]:
|
||
|
if unicast:
|
||
|
listen_socket = None
|
||
|
else:
|
||
|
listen_socket = new_socket(ip_version=ip_version, apple_p2p=apple_p2p, bind_addr=('',))
|
||
|
|
||
|
normalized_interfaces = normalize_interface_choice(interfaces, ip_version)
|
||
|
|
||
|
# If we are using InterfaceChoice.Default we can use
|
||
|
# a single socket to listen and respond.
|
||
|
if not unicast and interfaces is InterfaceChoice.Default:
|
||
|
for i in normalized_interfaces:
|
||
|
add_multicast_member(cast(socket.socket, listen_socket), i)
|
||
|
return listen_socket, [cast(socket.socket, listen_socket)]
|
||
|
|
||
|
respond_sockets = []
|
||
|
|
||
|
for i in normalized_interfaces:
|
||
|
if not unicast:
|
||
|
if add_multicast_member(cast(socket.socket, listen_socket), i):
|
||
|
respond_socket = new_respond_socket(i, apple_p2p=apple_p2p)
|
||
|
else:
|
||
|
respond_socket = None
|
||
|
else:
|
||
|
respond_socket = new_socket(
|
||
|
port=0,
|
||
|
ip_version=ip_version,
|
||
|
apple_p2p=apple_p2p,
|
||
|
bind_addr=i[0] if isinstance(i, tuple) else (i,),
|
||
|
)
|
||
|
|
||
|
if respond_socket is not None:
|
||
|
respond_sockets.append(respond_socket)
|
||
|
|
||
|
return listen_socket, respond_sockets
|
||
|
|
||
|
|
||
|
def get_errno(e: Exception) -> int:
|
||
|
assert isinstance(e, socket.error)
|
||
|
return cast(int, e.args[0])
|
||
|
|
||
|
|
||
|
def can_send_to(ipv6_socket: bool, address: str) -> bool:
|
||
|
"""Check if the address type matches the socket type.
|
||
|
|
||
|
This function does not validate if the address is a valid
|
||
|
ipv6 or ipv4 address.
|
||
|
"""
|
||
|
return ":" in address if ipv6_socket else ":" not in address
|
||
|
|
||
|
|
||
|
def autodetect_ip_version(interfaces: InterfacesType) -> IPVersion:
|
||
|
"""Auto detect the IP version when it is not provided."""
|
||
|
if isinstance(interfaces, list):
|
||
|
has_v6 = any(
|
||
|
isinstance(i, int) or (isinstance(i, str) and ipaddress.ip_address(i).version == 6)
|
||
|
for i in interfaces
|
||
|
)
|
||
|
has_v4 = any(isinstance(i, str) and ipaddress.ip_address(i).version == 4 for i in interfaces)
|
||
|
if has_v4 and has_v6:
|
||
|
return IPVersion.All
|
||
|
if has_v6:
|
||
|
return IPVersion.V6Only
|
||
|
|
||
|
return IPVersion.V4Only
|