ndp: fix macOS IPv6 compatibility by using link-local source addresses
authorStephen Groat <[email protected]>
Wed, 8 Oct 2025 18:54:51 +0000 (11:54 -0700)
committerÁlvaro Fernández Rojas <[email protected]>
Thu, 9 Oct 2025 06:43:26 +0000 (08:43 +0200)
macOS ignores NDP packets that don't originate from link-local addresses,
causing IPv6 connectivity issues with odhcpd. This change ensures NDP
packets (Neighbor Advertisements and ICMP Echo Requests) are sent using
link-local source addresses for RFC 4861 compliance.

Changes:
* Add ndp_from_link_local configuration flag (defaults to true)
* Add odhcpd_send_with_src() to allow explicit source address control
* Add odhcpd_try_send_with_src() helper to eliminate code duplication
* Add odhcpd_get_interface_linklocal_addr() with caching for performance
* Update send_na() and ping6() to use link-local source addresses when
  enabled
* Add RFC 4861, §4.2 comments explaining the mandated behavior
* Maintain backward compatibility with fallback behavior

Fixes: openwrt/openwrt#7561 #202
Signed-off-by: Stephen Groat <[email protected]>
Link: https://github.com/openwrt/odhcpd/pull/242
Signed-off-by: Álvaro Fernández Rojas <[email protected]>
README.md
src/config.c
src/ndp.c
src/odhcpd.c
src/odhcpd.h

index d0f0aecef7a69ab9f4a909492c21a4f9fc1e2279..47ffd634d30b20bd28321b53a643d7e8296c17dc 100644 (file)
--- a/README.md
+++ b/README.md
@@ -114,6 +114,7 @@ and may also receive information from ubus
 | ra_pref64            |string | -     | Announce PREF64 option for NAT64 prefix (RFC8781) [IPv6 prefix] |
 | ndproxy_routing      |bool   | 1     | Learn routes from NDP |
 | ndproxy_slave                |bool   | 0     | NDProxy external slave |
+| ndp_from_link_local  |bool   | 1     | Use link-local source addresses for NDP operations (RFC 4861, §4.2 compliance) and macOS compatibility |
 | prefix_filter                |string |`::/0` | Only advertise on-link prefixes within the provided IPv6 prefix; others are filtered out. [IPv6 prefix] |
 | ntp                  |list   |`<local address>`| NTP servers to announce accepts IPv4 and IPv6 |
 
index b57372d8fae80360dc11855b0f4116666e6d220e..46b73639dd1fd024013300383f05ef86ac34980b 100644 (file)
@@ -117,6 +117,7 @@ enum {
        IFACE_ATTR_PD_CER,
        IFACE_ATTR_NDPROXY_ROUTING,
        IFACE_ATTR_NDPROXY_SLAVE,
+       IFACE_ATTR_NDP_FROM_LINK_LOCAL,
        IFACE_ATTR_PREFIX_FILTER,
        IFACE_ATTR_MAX_PREFERRED_LIFETIME,
        IFACE_ATTR_MAX_VALID_LIFETIME,
@@ -171,6 +172,7 @@ static const struct blobmsg_policy iface_attrs[IFACE_ATTR_MAX] = {
        [IFACE_ATTR_RA_PREF64] = { .name = "ra_pref64", .type = BLOBMSG_TYPE_STRING },
        [IFACE_ATTR_NDPROXY_ROUTING] = { .name = "ndproxy_routing", .type = BLOBMSG_TYPE_BOOL },
        [IFACE_ATTR_NDPROXY_SLAVE] = { .name = "ndproxy_slave", .type = BLOBMSG_TYPE_BOOL },
+       [IFACE_ATTR_NDP_FROM_LINK_LOCAL] = { .name = "ndp_from_link_local", .type = BLOBMSG_TYPE_BOOL },
        [IFACE_ATTR_PREFIX_FILTER] = { .name = "prefix_filter", .type = BLOBMSG_TYPE_STRING },
        [IFACE_ATTR_MAX_PREFERRED_LIFETIME] = { .name = "max_preferred_lifetime", .type = BLOBMSG_TYPE_STRING },
        [IFACE_ATTR_MAX_VALID_LIFETIME] = { .name = "max_valid_lifetime", .type = BLOBMSG_TYPE_STRING },
@@ -271,6 +273,8 @@ static void set_interface_defaults(struct interface *iface)
        iface->ra = MODE_DISABLED;
        iface->ndp = MODE_DISABLED;
        iface->learn_routes = 1;
+       iface->ndp_from_link_local = true;
+       iface->cached_linklocal_valid = false;
        iface->dhcp_leasetime = 43200;
        iface->max_preferred_lifetime = ND_PREFERRED_LIMIT;
        iface->max_valid_lifetime = ND_VALID_LIMIT;
@@ -1451,6 +1455,9 @@ int config_parse_interface(void *data, size_t len, const char *name, bool overwr
        if ((c = tb[IFACE_ATTR_NDPROXY_SLAVE]))
                iface->external = blobmsg_get_bool(c);
 
+       if ((c = tb[IFACE_ATTR_NDP_FROM_LINK_LOCAL]))
+               iface->ndp_from_link_local = blobmsg_get_bool(c);
+
        if ((c = tb[IFACE_ATTR_PREFIX_FILTER]))
                odhcpd_parse_addr6_prefix(blobmsg_get_string(c),
                                          &iface->pio_filter_addr,
index 0feb8145ed69148981d5994a0affbd4b22eedc93..89b20e62adf25b76319326558bbd33ab47dcb323 100644 (file)
--- a/src/ndp.c
+++ b/src/ndp.c
@@ -280,7 +280,7 @@ static void ndp_netevent_cb(unsigned long event, struct netevent_handler_info *i
 /* Send an ICMP-ECHO. This is less for actually pinging but for the
  * neighbor cache to be kept up-to-date. */
 static void ping6(struct in6_addr *addr,
-               const struct interface *iface)
+               struct interface *iface)
 {
        struct sockaddr_in6 dest = { .sin6_family = AF_INET6, .sin6_addr = *addr , };
        struct icmp6_hdr echo = { .icmp6_type = ICMP6_ECHO_REQUEST };
@@ -291,13 +291,16 @@ static void ping6(struct in6_addr *addr,
        syslog(LOG_DEBUG, "Pinging for %s on %s", ipbuf, iface->name);
 
        netlink_setup_route(addr, 128, iface->ifindex, NULL, 128, true);
-       odhcpd_send(iface->ndp_ping_fd, &dest, &iov, 1, iface);
+
+       /* Use link-local address as source for RFC 4861 compliance and macOS compatibility */
+       odhcpd_try_send_with_src(iface->ndp_ping_fd, &dest, &iov, 1, iface);
+
        netlink_setup_route(addr, 128, iface->ifindex, NULL, 128, false);
 }
 
 /* Send a Neighbor Advertisement. */
 static void send_na(struct in6_addr *to_addr,
-               const struct interface *iface, struct in6_addr *for_addr,
+               struct interface *iface, struct in6_addr *for_addr,
                const uint8_t *mac)
 {
        struct sockaddr_in6 dest = { .sin6_family = AF_INET6, .sin6_addr = *to_addr };
@@ -319,7 +322,8 @@ static void send_na(struct in6_addr *to_addr,
        inet_ntop(AF_INET6, to_addr, ipbuf, sizeof(ipbuf));
        syslog(LOG_DEBUG, "Answering NS to %s on %s", ipbuf, iface->ifname);
 
-       odhcpd_send(iface->ndp_ping_fd, &dest, &iov, 1, iface);
+       /* Use link-local address as source for RFC 4861 compliance and macOS compatibility */
+       odhcpd_try_send_with_src(iface->ndp_ping_fd, &dest, &iov, 1, iface);
 }
 
 /* Handle solicitations */
index 9bb9ddcbdb551dcadbe2614dfede2e379519e6be..e0385480f7b643ce8d703e85731bf5183951fd6b 100644 (file)
@@ -206,10 +206,10 @@ int odhcpd_get_flags(const struct interface *iface)
 }
 
 
-/* Forwards a packet on a specific interface */
-ssize_t odhcpd_send(int socket, struct sockaddr_in6 *dest,
+/* Forwards a packet on a specific interface with optional source address */
+ssize_t odhcpd_send_with_src(int socket, struct sockaddr_in6 *dest,
                struct iovec *iov, size_t iov_len,
-               const struct interface *iface)
+               const struct interface *iface, const struct in6_addr *src_addr)
 {
        /* Construct headers */
        uint8_t cmsg_buf[CMSG_SPACE(sizeof(struct in6_pktinfo))] = {0};
@@ -231,6 +231,10 @@ ssize_t odhcpd_send(int socket, struct sockaddr_in6 *dest,
        struct in6_pktinfo *pktinfo = (struct in6_pktinfo*)CMSG_DATA(chdr);
        pktinfo->ipi6_ifindex = iface->ifindex;
 
+       /* Set source address if provided */
+       if (src_addr)
+               pktinfo->ipi6_addr = *src_addr;
+
        /* Also set scope ID if link-local */
        if (IN6_IS_ADDR_LINKLOCAL(&dest->sin6_addr)
                        || IN6_IS_ADDR_MC_LINKLOCAL(&dest->sin6_addr))
@@ -249,30 +253,75 @@ ssize_t odhcpd_send(int socket, struct sockaddr_in6 *dest,
        return sent;
 }
 
+/* Forwards a packet on a specific interface */
+ssize_t odhcpd_send(int socket, struct sockaddr_in6 *dest,
+               struct iovec *iov, size_t iov_len,
+               const struct interface *iface)
+{
+       return odhcpd_send_with_src(socket, dest, iov, iov_len, iface, NULL);
+}
+
 
-static int odhcpd_get_linklocal_interface_address(int ifindex, struct in6_addr *lladdr)
+int odhcpd_get_interface_linklocal_addr(struct interface *iface, struct in6_addr *addr)
 {
-       int ret = -1;
-       struct sockaddr_in6 addr;
-       socklen_t alen = sizeof(addr);
-       int sock = socket(AF_INET6, SOCK_RAW, IPPROTO_ICMPV6);
+       /* Return cached address if valid */
+       if (iface->cached_linklocal_valid) {
+               *addr = iface->cached_linklocal_addr;
+               return 0;
+       }
 
-       if (sock < 0)
-               return -1;
+       /* First try to get link-local address from interface addresses */
+       for (size_t i = 0; i < iface->addr6_len; ++i) {
+               if (IN6_IS_ADDR_LINKLOCAL(&iface->addr6[i].addr.in6)) {
+                       *addr = iface->addr6[i].addr.in6;
+                       /* Cache the result for future use */
+                       iface->cached_linklocal_addr = *addr;
+                       iface->cached_linklocal_valid = true;
+                       return 0;
+               }
+       }
 
-       memset(&addr, 0, sizeof(addr));
-       addr.sin6_family = AF_INET6;
-       inet_pton(AF_INET6, ALL_IPV6_ROUTERS, &addr.sin6_addr);
-       addr.sin6_scope_id = ifindex;
+       /* Fallback to socket-based method */
+       struct sockaddr_in6 sockaddr;
+       socklen_t alen = sizeof(sockaddr);
+       int sock = socket(AF_INET6, SOCK_RAW, IPPROTO_ICMPV6);
 
-       if (!connect(sock, (struct sockaddr*)&addr, sizeof(addr)) &&
-                       !getsockname(sock, (struct sockaddr*)&addr, &alen)) {
-               *lladdr = addr.sin6_addr;
-               ret = 0;
+       if (sock >= 0) {
+               memset(&sockaddr, 0, sizeof(sockaddr));
+               sockaddr.sin6_family = AF_INET6;
+               inet_pton(AF_INET6, ALL_IPV6_ROUTERS, &sockaddr.sin6_addr);
+               sockaddr.sin6_scope_id = iface->ifindex;
+
+               if (!connect(sock, (struct sockaddr*)&sockaddr, sizeof(sockaddr)) &&
+                               !getsockname(sock, (struct sockaddr*)&sockaddr, &alen)) {
+                       *addr = sockaddr.sin6_addr;
+                       /* Cache the result for future use */
+                       iface->cached_linklocal_addr = *addr;
+                       iface->cached_linklocal_valid = true;
+                       close(sock);
+                       return 0;
+               }
+               close(sock);
        }
 
-       close(sock);
-       return ret;
+       return -1;
+}
+
+/* Try to send with link-local source address for RFC 4861 compliance and macOS compatibility.
+ * RFC 4861, §4.2 mandates that Neighbor Advertisement source address MUST be
+ * the link-local address assigned to the interface from which this message is sent. */
+ssize_t odhcpd_try_send_with_src(int socket, struct sockaddr_in6 *dest,
+               struct iovec *iov, size_t iov_len,
+               struct interface *iface)
+{
+       struct in6_addr src_addr;
+
+       if (iface->ndp_from_link_local && odhcpd_get_interface_linklocal_addr(iface, &src_addr) == 0) {
+               return odhcpd_send_with_src(socket, dest, iov, iov_len, iface, &src_addr);
+       } else {
+               /* Fall back to default behavior if no link-local address is available or flag is disabled */
+               return odhcpd_send(socket, dest, iov, iov_len, iface);
+       }
 }
 
 /*
@@ -320,7 +369,7 @@ int odhcpd_get_interface_dns_addr(const struct interface *iface, struct in6_addr
                return 0;
        }
 
-       return odhcpd_get_linklocal_interface_address(iface->ifindex, addr);
+       return odhcpd_get_interface_linklocal_addr(iface, addr);
 }
 
 struct interface* odhcpd_get_interface_by_index(int ifindex)
index 916d6daeb9c889da137cee5654307f49b5587b2c..4f88727d562c5da67f0af30b9f9b45f3e331f4dd 100644 (file)
@@ -333,6 +333,9 @@ struct interface {
 
        // NDP
        int learn_routes;
+       bool ndp_from_link_local;
+       struct in6_addr cached_linklocal_addr;
+       bool cached_linklocal_valid;
 
        // RA
        uint8_t ra_flags;
@@ -469,11 +472,19 @@ int odhcpd_register(struct odhcpd_event *event);
 int odhcpd_deregister(struct odhcpd_event *event);
 void odhcpd_process(struct odhcpd_event *event);
 
+ssize_t odhcpd_send_with_src(int socket, struct sockaddr_in6 *dest,
+               struct iovec *iov, size_t iov_len,
+               const struct interface *iface, const struct in6_addr *src_addr);
 ssize_t odhcpd_send(int socket, struct sockaddr_in6 *dest,
                struct iovec *iov, size_t iov_len,
                const struct interface *iface);
+ssize_t odhcpd_try_send_with_src(int socket, struct sockaddr_in6 *dest,
+               struct iovec *iov, size_t iov_len,
+               struct interface *iface);
 int odhcpd_get_interface_dns_addr(const struct interface *iface,
                struct in6_addr *addr);
+int odhcpd_get_interface_linklocal_addr(struct interface *iface,
+               struct in6_addr *addr);
 int odhcpd_get_interface_config(const char *ifname, const char *what);
 int odhcpd_get_mac(const struct interface *iface, uint8_t mac[6]);
 int odhcpd_get_flags(const struct interface *iface);