Skip to content

Commit

Permalink
Qualify IPv6 link-local addresses with scope_id
Browse files Browse the repository at this point in the history
When a service is advertised on an IPv6 address where
the scope is link local, i.e. fe80::/64 (see RFC 4007)
the resolved IPv6 address must be extended with the
scope_id that identifies through the "%" symbol the
local interface to be used when routing to that address.
A new API is provided to return qualified addresses
to avoid breaking compatibility on the existing
arsed_addresses().

This patch:
 * Extracts the scope_id from an IPv6 link-local address
   and return it in the retrieved information in ServiceInfo.
 * Adjusts the browser test to be able to show IPv6 link-local
   addresses by using the new method parsed_scoped_addresses().
 * Uses scope_id in unicast replies to IPv6 link-local addresses,
   and adds some unit tests to verify the new interface provided.

Co-authored-by: Lokesh Prajapati <lokesh.prajapati@ncipher.com>
  • Loading branch information
2 people authored and de Angelis, Antonio committed Oct 23, 2020
1 parent 4da1612 commit 620564f
Show file tree
Hide file tree
Showing 3 changed files with 166 additions and 59 deletions.
3 changes: 2 additions & 1 deletion examples/browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ def on_service_state_change(
if state_change is ServiceStateChange.Added:
info = zeroconf.get_service_info(service_type, name)
print("Info from zeroconf.get_service_info: %r" % (info))

if info:
addresses = ["%s:%d" % (addr, cast(int, info.port)) for addr in info.parsed_addresses()]
addresses = ["%s:%d" % (addr, cast(int, info.port)) for addr in info.parsed_scoped_addresses()]
print(" Addresses: %s" % ", ".join(addresses))
print(" Weight: %d, priority: %d" % (info.weight, info.priority))
print(" Server: %s" % (info.server,))
Expand Down
63 changes: 51 additions & 12 deletions zeroconf/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -553,9 +553,12 @@ class DNSAddress(DNSRecord):

"""A DNS address record"""

def __init__(self, name: str, type_: int, class_: int, ttl: int, address: bytes) -> None:
def __init__(
self, name: str, type_: int, class_: int, ttl: int, address: bytes, scope_id: Optional[int] = None
) -> None:
DNSRecord.__init__(self, name, type_, class_, ttl)
self.address = address
self.scope_id = scope_id

def write(self, out: 'DNSOutgoing') -> None:
"""Used in constructing an outgoing packet"""
Expand Down Expand Up @@ -721,7 +724,7 @@ class DNSIncoming(QuietLogger):

"""Object representation of an incoming DNS packet"""

def __init__(self, data: bytes) -> None:
def __init__(self, data: bytes, scope_id: Optional[int] = None) -> None:
"""Constructor from string holding bytes of packet"""
self.offset = 0
self.data = data
Expand All @@ -734,6 +737,7 @@ def __init__(self, data: bytes) -> None:
self.num_authorities = 0
self.num_additionals = 0
self.valid = False
self.scope_id = scope_id

try:
self.read_header()
Expand Down Expand Up @@ -840,7 +844,7 @@ def read_others(self) -> None:
self.read_character_string().decode('utf-8'),
)
elif type_ == _TYPE_AAAA:
rec = DNSAddress(domain, type_, class_, ttl, self.read_string(16))
rec = DNSAddress(domain, type_, class_, ttl, self.read_string(16), self.scope_id)
else:
# Try to ignore types we don't know about
# Skip the payload for the resource record so the next
Expand Down Expand Up @@ -1406,6 +1410,9 @@ def handle_read(self, socket_: socket.socket) -> None:
self.log_exception_warning('Error reading from socket %d', socket_.fileno())
return

if _v6:
log.debug('IPv6 scope_id %d associated to the receiving interface', _v6[1])

if self.data == data:
log.debug(
'Ignoring duplicate message received from %r:%r (socket %d) (%d bytes) as [%r]',
Expand All @@ -1418,7 +1425,7 @@ def handle_read(self, socket_: socket.socket) -> None:
return

self.data = data
msg = DNSIncoming(data)
msg = DNSIncoming(data, _v6[1] if _v6 else None)
if msg.valid:
log.debug(
'Received from %r:%r (socket %d): %r (%d bytes) as [%r]',
Expand Down Expand Up @@ -1450,7 +1457,9 @@ def handle_read(self, socket_: socket.socket) -> None:
# If it's not a multicast query, reply via unicast
# and multicast
elif port == _DNS_PORT:
self.zc.handle_query(msg, addr, port)
self.zc.handle_query(
msg, addr, port, _v6 if _v6 and ipaddress.IPv6Address(addr).is_link_local else None
)
self.zc.handle_query(msg, None, _MDNS_PORT)

else:
Expand All @@ -1470,7 +1479,7 @@ def __init__(self, zc: 'Zeroconf') -> None:
self.name = "zeroconf-Reaper_%s" % (getattr(self, 'native_id', self.ident),)

def run(self) -> None:
"""Perodic removal of expired entries from the cache."""
"""Periodic removal of expired entries from the cache."""
while True:
with self.zc.reaper_condition:
self.zc.reaper_condition.wait(10)
Expand Down Expand Up @@ -1769,7 +1778,8 @@ class ServiceInfo(RecordUpdateListener):
* other_ttl: ttl used for PTR/TXT records
* addresses and parsed_addresses: List of IP addresses (either as bytes, network byte order, or in parsed
form as text; at most one of those parameters can be provided)
* interface_index: scope_id or zone_id for IPv6 link-local addresses i.e. an identifier of the interface
where the peer is connected to
"""

text = b''
Expand All @@ -1790,7 +1800,8 @@ def __init__(
other_ttl: int = _DNS_OTHER_TTL,
*,
addresses: Optional[List[bytes]] = None,
parsed_addresses: Optional[List[str]] = None
parsed_addresses: Optional[List[str]] = None,
interface_index: Optional[int] = None
) -> None:
# Accept both none, or one, but not both.
if addresses is not None and parsed_addresses is not None:
Expand Down Expand Up @@ -1822,6 +1833,7 @@ def __init__(
self._set_properties(properties)
self.host_ttl = host_ttl
self.other_ttl = other_ttl
self.interface_index = interface_index
# fmt: on

@property
Expand Down Expand Up @@ -1870,6 +1882,22 @@ def parsed_addresses(self, version: IPVersion = IPVersion.All) -> List[str]:
for addr in result
]

def parsed_scoped_addresses(self, version: IPVersion = IPVersion.All) -> List[str]:
"""Equivalent to parsed_addresses, with the exception that IPv6 Link-Local
addresses are qualified with %<interface_index> when available
"""
if self.interface_index is None:
return self.parsed_addresses(version)
else:

def is_link_local(addr: str) -> Any:
a = ipaddress.ip_address(addr)
return a.version == 6 and a.is_link_local

ll_addrs = list(filter(is_link_local, self.parsed_addresses(version)))
other_addrs = list(filter(lambda addr: not is_link_local(addr), self.parsed_addresses(version)))
return ["{}%{}".format(addr, self.interface_index) for addr in ll_addrs] + other_addrs

def _set_properties(self, properties: Union[bytes, Dict]) -> None:
"""Sets properties and text of this info from a dictionary"""
if isinstance(properties, dict):
Expand Down Expand Up @@ -1935,6 +1963,8 @@ def update_record(self, zc: 'Zeroconf', now: float, record: Optional[DNSRecord])
if record.name == self.server:
if record.address not in self._addresses:
self._addresses.append(record.address)
if record.type is _TYPE_AAAA and ipaddress.IPv6Address(record.address).is_link_local:
self.interface_index = record.scope_id
elif record.type == _TYPE_SRV:
assert isinstance(record, DNSService)
if record.name == self.name:
Expand Down Expand Up @@ -2030,6 +2060,7 @@ def __repr__(self) -> str:
'priority',
'server',
'properties',
'interface_index',
)
),
)
Expand Down Expand Up @@ -2753,7 +2784,9 @@ def handle_response(self, msg: DNSIncoming) -> None:
if entry_to_remove:
self.cache.remove(entry_to_remove)

def handle_query(self, msg: DNSIncoming, addr: Optional[str], port: int) -> None:
def handle_query(
self, msg: DNSIncoming, addr: Optional[str], port: int, _v6: Optional[Tuple[int, int]] = None
) -> None:
"""Deal with incoming query packets. Provides a response if
possible."""
out = None
Expand Down Expand Up @@ -2889,9 +2922,15 @@ def handle_query(self, msg: DNSIncoming, addr: Optional[str], port: int) -> None

if out is not None and out.answers:
out.id = msg.id
self.send(out, addr, port)
self.send(out, addr, port, _v6)

def send(self, out: DNSOutgoing, addr: Optional[str] = None, port: int = _MDNS_PORT) -> None:
def send(
self,
out: DNSOutgoing,
addr: Optional[str] = None,
port: int = _MDNS_PORT,
_v6: Optional[Tuple[int, int]] = None,
) -> None:
"""Sends an outgoing packet."""
packets = out.packets()
packet_num = 0
Expand All @@ -2911,7 +2950,7 @@ def send(self, out: DNSOutgoing, addr: Optional[str] = None, port: int = _MDNS_P
continue
else:
real_addr = addr
bytes_sent = s.sendto(packet, 0, (real_addr, port))
bytes_sent = s.sendto(packet, 0, (real_addr, port, *_v6) if _v6 else (real_addr, port))
except Exception as exc: # TODO stop catching all Exceptions
if (
isinstance(exc, OSError)
Expand Down

0 comments on commit 620564f

Please sign in to comment.