.. or how RIPE Atlas measurement data just got a little bit more complex. Some people say IPv6 is "96 more bits, no magic". And while this is true for most network operators, if you're a RIPE Atlas system programmer, you can run into interesting situations. In this article, we described how link-local addresses in IPv6, and specifically the '%eth0'-part of link-local addresses, can have an impact on RIPE Atlas measurements.

Introduction

IPv6 link-local addresses are addresses that can be used to communicate with nodes (hosts and routers) on an attached link. Packets with those addresses are not forwarded by routers. At least, they should not be. There have been cases where routers would happily forward packets with a link-local source address.

IPv6 link-local addresses are defined by RFC 4291 (IPv6 Addressing Architecture) and are covered by the prefix fe80::/10. In practice, only fe80::/64 is used.

At first glance, IPv6 link-local addresses are similar to IPv4 link-local addresses, which are defined in RFC 3927 (Dynamic Configuration of IPv4 Link-Local Addresses). IPv4 link-local addresses are taken from the prefix 169.254.0.0/16.

However, there is a big difference: an IPv4 link-local address is typically assigned to an interface when DHCP fails to supply an address. So IPv4 link-local addresses show up when there are no other IPv4 addresses.

In contrast, IPv6 link-local addresses are used next to other IPv6 addresses. Typically, a node will assign IPv6 link-local addresses to interfaces as soon as they have become available. Beyond that, IPv6 routers advertise link-local addresses to hosts (and other routers). IPv6 link-local addresses are used quite a lot.

For example, if you run "ip addr" on a Linux system, you will get something like this:

$ ip addr

1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000

link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00

inet 127.0.0.1/8 scope host lo

valid_lft forever preferred_lft forever

inet6 ::1/128 scope host

valid_lft forever preferred_lft forever

2: enp0s5: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP group default qlen 1000

link/ether 00:11:22:33:44:55 brd ff:ff:ff:ff:ff:ff

inet 192.0.2.42/24 brd 192.0.2.255 scope global noprefixroute dynamic enp0s5

valid_lft 767sec preferred_lft 767sec

inet6 2001:db8::8c28:c929:72db:49fe/64 scope global noprefixroute dynamic

valid_lft 2591998sec preferred_lft 604798sec

inet6 fe80::9656:d028:8652:66b6/64 scope link noprefixroute

valid_lft forever preferred_lft forever

This host has two interfaces, a loopback interface "lo", and an ethernet interface "enp0s5". The ethernet interface has an IPv4 address and two IPv6 addresses:

A global address "2001:db8::8c28:c929:72db:49fe"

A link-local address "fe80::9656:d028:8652:66b6"

Link-locals and sockets

IPv6 link-local addresses would have been just a tiny detail of the whole IPv6 ecosystem if it was not for sockets API. The sockets API is a series of system calls, with names like socket() , bind() , connect() , send() , sendto() , recv() , recvfrom() , that were first introduced in version 4.2 of the BSD Unix operating system and later became a standard for almost all Unix-like operating systems.

Specifying an outgoing interface for a connection or a packet is a long standing problem with sockets API. In most cases, the kernel can pick a suitable interface. However, when that fails or when the user wants to override the kernel's decision, there is only an indirect way of specifying an interface.

To make it work, you need to explicitly select a local address on the desired interface (assuming that addresses are unique among interfaces). For IPv6 link-local addresses, we can assume that this is not the case. For example, a router may use the address fe80::1 on all interfaces as shown in Figure 1:

Figure 1: A router with four interfaces

RFC 3493 (Basic Socket Interface Extensions for IPv6) describes extensions to the socket interface to make it usable for IPv6. The RFC describes a field called sin6_scope_id that can be used to specify an interface. Because this field is part of the socket address structure, struct sockaddr_in6 , it transparently extends existing socket calls such as connect() or sendto() with the ability to specify an outgoing interface.

The RFC also defines function calls if_nametoindex() and if_indextoname() to convert interface names to numbers suitable for the sin6_scope_id and the other way around. However, the RFC does not define if or when these numbers have to be used.

At this point, it is important to keep in mind that an organisation called The Open Group maintains a specification that provides a standard for Unix and Unix-like operating systems. This is the official reference for Unix system calls and Unix-specific library calls.

The Open Group Base Specifications Issue 7, 2018 edition, IEEE Std 1003.1-2017 (Revision of IEEE Std 1003.1-2008) has this to say about the sin6_scope_id field:

The sin6_scope_id field is a 32-bit integer that identifies a set of interfaces as appropriate for the scope of the address carried in the sin6_addr field. For a link scope sin6_addr, the application shall ensure that sin6_scope_id is a link index. For a site scope sin6_addr, the application shall ensure that sin6_scope_id is a site index. The mapping of sin6_scope_id to an interface or set of interfaces is implementation-defined.

In short, it is not possible to use an IPv6 link-local address in sockets API without selecting an appropriate interface as well. This is a significant departure from the past: knowing an IP address is no longer enough.

Textual representation of scoped addresses

One question remains: is there a textual representation that contains both an IPv6 address and an outgoing interface? The answer is yes. RFC 4007 (IPv6 Scoped Address Architecture) introduces the percent sign ("%") as a separator between an IPv6 address literal and a zone_id . It describes that a zone identifier can be numeric or a string. Therefore, we can refer to the address fe80::1 on interface eth0 as fe80::1%eth0 and (if eth0 maps to interface number 1) as fe80::1%1 .

RFC 3493 introduced new library calls called getaddrinfo() and getnameinfo() to perform DNS lookups. These calls can also parse (and generate) address literals. Surprisingly, there doesn't seem to be a standard that requires the functions to handle zone identifiers. All I could find is an Internet-Draft. In practice, this format is generally supported and many applications that use getaddrinfo() can transparently accept link-local addresses with zone identifiers as well.

Some applications use the function inet_pton to parse address literals. This function cannot deal with a zone identifier because it operates on struct in6_addr , which contains only an IPv6 address and has no space for a zone identifier.

Finally, RFC 6874 (Representing IPv6 Zone Identifiers in Address Literals and Uniform Resource Identifiers) describes how zone identifiers can be encoded in URLs. The problem is that the percent sign is special in URLs. Indeed, the percent sign that separates the zone identifier from the address literal has to be written as "%25".

DNS resolvers with link-local addresses

How does all of this relate to RIPE Atlas, which performs measurements over the Internet and goes to great length to prevent people from measuring a probe's local network? The answer is that RIPE Atlas does support measurements that involve a probe's DNS resolvers. And these DNS resolvers can have link-local addresses.

Please note that in the following I will only consider hardware probes and will excluded anchors and software probes. For a long time, RIPE Atlas probes did not have a mechanism to recognise DNS resolvers with IPv6 addresses. Neither DHCPv6 was supported nor the Recursive DNS Server (RDNSS) option in Router Advertisements (RFC 8106, IPv6 Router Advertisement Options for DNS Configuration).

Later, when we were working on supporting the RDNSS option, we decided to leave out zone identifiers in the first implementation. The reason is that the use of link-local addresses for DNS resolvers is quite rare and dealing with zone identifiers is complex.

Supporting zone identifiers not only required changes to our code but the libevent library, which is used to perform asynchronous DNS lookups also had to be changed to support them. The changes to libevent have been shared with the maintainers of libevent.

Impact on RIPE Atlas

This would have been a tiny detail of how RIPE Atlas probes work, if it was not for the textual representation of IPv6 addresses. Measurement results have fields called src_addr and dst_addr that contain respectively the local address and the target address of measurement instance. When the measurement target is a link-local address with a zone identifier these fields can be expected to contain addresses with zone identifers as well. This is likely to break existing address parsers that do not take zone identifiers into account.

Reality is even more complex than that. The current measurement code uses inet_ntop() , which cannot handle zone identifiers, to print dst_addr . Whereas src_addr is generated using getnameinfo() , which can handle zone identifiers on Version 4 probes, but not on Version 3 probes. Version 3 probes run an older version of OpenWRT. Presumably, support for zone identifiers has been added later, but we didn't verify that. A future firmware relase should make this more consistent.

Version 1 and Version 2 probes will only get firmware updates to address security problems, so they will probably never support IPv6 link-Local addresses. For anchors and software probes, the measurement code will use whatever it finds in /etc/resolv.conf . So far, none of those contained link-local addresses.

Conclusion

In this post, we gave an overview of what IPv6 address zone identifiers are ("the thing after the %-sign") and show how we handle so called zone identifiers in RIPE Atlas. We hope this was useful for those dealing with making legacy systems IPv6 ready. Comments and questions are, as always, very welcome in the dedicated section below.