Philipp Lehmann @PhilippTheServer

Multicast Service Discovery on a Host With a WireGuard Interface

A discovery library started one server per IPv4 address, including the mesh VPN's. Kernel WireGuard answers every multicast send with ENOKEY, and the server restarted 68,805 times before anyone asked it why.

The problem

The edge devices in this estate find their peripherals the way devices on a LAN have found each other since the nineties: they shout. A small header-only C++ library called slook sends a query to the multicast group 239.255.13.37:7331, and anything on the segment that offers a service answers with where to find it. There is no registry to keep alive and no DNS to configure, which is exactly what you want on a box that is shipped to a customer and plugged in by someone who has better things to do.

To make “the segment” mean “every segment”, slook’s host implementation walks the machine’s IPv4 addresses and starts one discovery server per address. That was correct for years, because for years the only interfaces on these devices were Ethernet and loopback, and loopback was already skipped.

Then the devices joined a NetBird mesh, and each one grew a wt0 interface with a 100.x address. slook, being thorough, started a server on that one too. The logs of the service embedding it began to look like this, at a steady rhythm of once per second:

slook: starting on 100.66.x.x
slook: Required key not available
slook: starting on 100.66.x.x
slook: Required key not available

Nothing was visibly broken. Discovery on Ethernet kept working, because the servers are independent and only the wt0 one was failing. That is the uncomfortable kind of bug: the kind that works. By the time someone looked, one device had restarted that server 68,805 times, and its wt0 interface had counted 774,830 transmit errors in 72 hours. It was, by some distance, the most persistent process on the machine.

Working through it

“Required key not available” is strerror(ENOKEY), errno 126. It belongs to the kernel keyring, and it is the last error anyone expects from a UDP socket. It is also what kernel WireGuard returns when it is asked to send a packet it has no peer for.

That is the whole mechanism. WireGuard is a layer-3 point-to-point device with cryptokey routing: every outgoing packet is matched against the peers’ AllowedIPs, and the matching peer’s key encrypts it. A packet with no matching peer has no key, so the transmit path drops it and hands -ENOKEY back up the stack. A multicast group is never in anyone’s AllowedIPs: there is no single peer to encrypt 239.255.13.37 for, and WireGuard does not replicate packets to several peers. So every multicast send out of wt0 fails, forever, and “forever” at one retry per second adds up.

The interface tells you this if you ask it. Here is a container with one kernel WireGuard interface next to its ordinary Ethernet interface, on Linux 6.12:

docker network create --subnet 172.30.0.0/24 mcdemo
docker run --rm --network mcdemo --ip 172.30.0.2 --cap-add NET_ADMIN \
  -v "$PWD/enokey.py:/enokey.py:ro" python:3.13-alpine sh -c '
    apk add -q iproute2
    ip link add wt0 type wireguard
    ip addr add 10.99.0.1/32 dev wt0
    ip link set wt0 up
    ip -o link show wt0  | cut -d" " -f2-3
    ip -o link show eth0 | cut -d" " -f2-3
    python /enokey.py'
docker network rm mcdemo

enokey.py sends one datagram to the group through each interface, selecting the outgoing interface by its address, which is what a per-address discovery server does:

import socket

GROUP, PORT = "239.255.13.37", 7331

for iface_ip in ("10.99.0.1", "172.30.0.2"):
    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_IF, socket.inet_aton(iface_ip))
    try:
        s.sendto(b"who is there?", (GROUP, PORT))
        print(f"{iface_ip}: sent")
    except OSError as e:
        print(f"{iface_ip}: {e}")

The output:

wt0: <POINTOPOINT,NOARP,UP,LOWER_UP>
eth0@if746: <BROADCAST,MULTICAST,UP,LOWER_UP>
10.99.0.1: [Errno 126] Required key not available
172.30.0.2: sent

The flags are the answer, printed before the question. eth0 says MULTICAST. wt0 does not, and says POINTOPOINT instead. The kernel knew all along that this interface cannot carry a multicast group. slook never asked, because its interface walk read the address out of each ifaddrs entry and ignored ifa_flags sitting in the same struct.

Two more details shaped the fix.

First, IFF_MULTICAST alone is not a sufficient test. Nothing stops a userspace tunnel implementation from setting it on a TUN device that still cannot deliver a group to anyone. A point-to-point link has exactly one other end, so “multicast” on it means “unicast with extra steps” at best. The filter therefore has to reject IFF_POINTOPOINT even when IFF_MULTICAST is set.

Second, slook did not only take addresses from getifaddrs. It also resolved the host’s own name and added whatever came back. On a host whose name resolves to the mesh address, that path would put wt0 straight back in. Fixing the walk and leaving the resolver alone would have been a fix with a side door.

The solution

The interface test is one function over the flags, kept in its own header so it can be tested without a network:

#include <net/if.h>

// Point-to-point tunnels (WireGuard/NetBird wt0) cannot deliver the discovery group: the
// kernel rejects every send with ENOKEY, and the server restarts into the same error.
inline bool canCarryMulticast(unsigned int flags) {
    return (flags & IFF_UP) != 0 && (flags & IFF_MULTICAST) != 0
        && (flags & (IFF_LOOPBACK | IFF_POINTOPOINT)) == 0;
}

The address walk now skips every ifaddrs entry that fails that test, and the addresses from name resolution are kept only if they belong to an interface that passed it. If getifaddrs itself fails, the resolver’s answer is used unfiltered, as before: degrading to the old behaviour is better than degrading to no discovery at all.

The check that holds it is a plain C++ program with no test framework, which is the right amount of ceremony for five boolean cases:

#include <net/if.h>
#include <cstdio>

inline bool canCarryMulticast(unsigned int flags) {
    return (flags & IFF_UP) != 0 && (flags & IFF_MULTICAST) != 0
        && (flags & (IFF_LOOPBACK | IFF_POINTOPOINT)) == 0;
}

int failures = 0;

void check(bool ok, char const* what) {
    std::printf("%s %s\n", ok ? "ok  " : "FAIL", what);
    failures += ok ? 0 : 1;
}

int main() {
    check(!canCarryMulticast(IFF_UP | IFF_RUNNING | IFF_POINTOPOINT | IFF_NOARP),
          "kernel WireGuard is skipped");
    check(!canCarryMulticast(IFF_UP | IFF_RUNNING | IFF_POINTOPOINT | IFF_NOARP | IFF_MULTICAST),
          "userspace tunnel that claims IFF_MULTICAST is skipped");
    check(!canCarryMulticast(IFF_UP | IFF_RUNNING | IFF_LOOPBACK), "loopback is skipped");
    check(!canCarryMulticast(IFF_BROADCAST | IFF_MULTICAST), "an interface that is down is skipped");
    check(canCarryMulticast(IFF_UP | IFF_BROADCAST | IFF_RUNNING | IFF_MULTICAST), "ethernet is used");
    return failures == 0 ? 0 : 1;
}
g++ -std=c++20 -Wall -Wextra -Werror interface_filter_test.cpp -o interface_filter_test
./interface_filter_test

The first check is not invented: its flags mirror what ip link printed for wt0 above. The library had no tests and no CI before this change. It now has both, which says less about this bug than about how long “it works” can pass for “it is tested”.

The device image picked up the new slook revision, and its README now says which interfaces discovery runs on, so the next person who adds a tunnel to these devices finds the answer written down instead of in a log that scrolls at one line per second.

Conclusion

The bug was one ignored field. getifaddrs returns an address and the interface’s flags in the same struct, and the flags already said, in two separate ways, that wt0 cannot carry multicast. Reading the address and skipping the flags is an easy thing to write, and it stays correct for exactly as long as every interface on the machine is Ethernet.

The error message did its best to mislead. ENOKEY sounds like a keyring or a TLS problem, and in a sense it is a key problem: WireGuard has no key for a packet addressed to everyone. Once you know that WireGuard reports “no peer for this destination” as “no key”, the message is precise. It is just precise in WireGuard’s vocabulary rather than yours.

The broader lesson is about code that enumerates interfaces. A mesh VPN, a container bridge or a hypervisor bridge can appear on a host long after that code was written, and anything that iterates “all interfaces” inherits them without a code change or a review. Deciding what an interface is for, from the flags the kernel already provides, turns a VPN rollout from something that quietly generates 774,830 errors into something the service does not notice at all.