Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions src/reflector/application.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -180,8 +180,8 @@ bool Application::ReconcileInterfaces(std::span<const unsigned> indexes, bool re
// proves its interface has not gone anywhere. A rename is the exception — it keeps both
// the index and the capture, so only the name lookup sees it — but it also announces
// itself, which is why a requested interface resolves regardless.
const bool attached = socket.Attached();
if (attached && !refresh_requested) {
const bool capturing = socket.Attached() && socket.GroupsJoined();
if (capturing && !refresh_requested) {
continue;
}

Expand All @@ -201,7 +201,7 @@ bool Application::ReconcileInterfaces(std::span<const unsigned> indexes, bool re
if (!iface->IsValid()) {
continue; // parked, so there is nothing to bind to until it comes back
}
if ((!attached || change == Interface::IdentityChange::Repointed) && !socket.Rebind()) {
if ((!capturing || change == Interface::IdentityChange::Repointed) && !socket.Rebind()) {
outstanding = true; // Rebind logs its own failure
}
}
Expand Down
8 changes: 7 additions & 1 deletion src/reflector/link_socket.h
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,15 @@ class LinkSocket {
[[nodiscard]] virtual bool Attached() const noexcept = 0;

// Re-attaches the capture to its interface's current kernel object, keeping the same fd so
// registrations keyed by it stay valid. False (having logged) if the kernel refuses.
// registrations keyed by it stay valid, and re-programs its group memberships on that object.
// False (having logged) if the kernel refuses either.
[[nodiscard]] virtual bool Rebind() noexcept = 0;

// Whether this socket's multicast memberships are programmed on the interface's current kernel
// object. They do not survive a recreation, and an attached capture whose groups are gone
// receives nothing — so this is a second, independent reason to rebind.
[[nodiscard]] virtual bool GroupsJoined() const noexcept = 0;

protected:
// Mints a membership for `group` owned by this socket; the join must already have happened.
[[nodiscard]] MulticastMembership MakeMembership(const IpAddress& group) noexcept {
Expand Down
80 changes: 65 additions & 15 deletions src/reflector/raw_socket.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,9 @@ bool RawSocket::Rebind() noexcept {
receive_buffer_offset_ = 0;
#endif
logger_.Debug("Re-bound capture to interface index {}", interface_->Index());
return true;
// Nothing else re-joins them: a family that keeps its addresses across a recreation is no
// transition, so the reflector's own bring-up never runs.
return RejoinGroups();
}

RawSocket::RawSocket(TestingTag, const Interface& interface, int owned_fd,
Expand Down Expand Up @@ -475,6 +477,18 @@ LinkSocket::MulticastMembership RawSocket::JoinMulticastGroup(const IpAddress& g
}
}

if (!JoinInKernel(join_fd.Get(), group)) {
if (opened_now) {
join_fd.Reset(); // the fd we just opened holds no membership; drop it
}
return {};
}

memberships.emplace(group, 1);
return MakeMembership(group);
}

bool RawSocket::JoinInKernel(int join_fd, const IpAddress& group) noexcept {
// MCAST_JOIN_GROUP (RFC 3678) is protocol-independent and selects the interface strictly by
// index, so one path covers both families with no IPv4 by-address fallback to a wrong
// (default) interface. The group goes in as a sockaddr; ToSockaddr also sets the BSD sockaddr
Expand All @@ -483,18 +497,55 @@ LinkSocket::MulticastMembership RawSocket::JoinMulticastGroup(const IpAddress& g
request.gr_interface = interface_->Index();
group.ToSockaddr(request.gr_group, /*port=*/0);

const int level = v6 ? IPPROTO_IPV6 : IPPROTO_IP;
if (setsockopt(join_fd.Get(), level, MCAST_JOIN_GROUP, &request, sizeof(request)) != 0) {
logger_.Error("Cannot join multicast group {}: {}", group, Error::FromErrno());
if (opened_now) {
join_fd.Reset(); // the fd we just opened holds no membership; drop it
}
return {};
const int level = group.IsV6() ? IPPROTO_IPV6 : IPPROTO_IP;
if (setsockopt(join_fd, level, MCAST_JOIN_GROUP, &request, sizeof(request)) == 0) {
logger_.Debug("Joined multicast group {} (interface index {})", group, interface_->Index());
return true;
}

memberships.emplace(group, 1);
logger_.Debug("Joined multicast group {} (interface index {})", group, interface_->Index());
return MakeMembership(group);
const int error = errno;
if (error == EADDRINUSE) {
return true; // an any-source re-join of a membership already held: the end state we want
}
if (error == EADDRNOTAVAIL) {
// No address of this group's family yet: the family's teardown drops the membership, or
// the repair retry replays the join once one arrives. A wait, not a failure.
logger_.Debug("Join of multicast group {} deferred: {}", group, Error::FromErrno(error));
return false;
}
logger_.Error("Cannot join multicast group {}: {}", group, Error::FromErrno(error));
return false;
}

bool RawSocket::RejoinGroups() noexcept {
groups_joined_ = true;
for (const auto family : {IpAddress::Family::V4, IpAddress::Family::V6}) {
const auto& memberships = group_memberships_.Get(family);
if (memberships.empty()) {
continue;
}
// A fresh fd rather than a re-join on the old one: where a recreated interface is handed
// back the number it had, the kernel still has the old fd down for that (group, index) and
// refuses the join as a duplicate — and a membership it keeps for a dead index still counts
// against the socket's join cap (Linux igmp_max_memberships, 20 by default, and not
// raisable on a locked-down router), so kept sockets would exhaust it after a handful of
// recreations. Closed before the reopen rather than after, so the descriptor it frees is
// the one the reopen takes: at the process fd limit that is the difference between
// recovering and staying deaf. Refcounts are untouched, so the memberships already handed
// to reflectors stay valid.
auto& join_fd = join_fds_.Get(family);
join_fd.Reset();
join_fd.Reset(socket(family == IpAddress::Family::V6 ? AF_INET6 : AF_INET, SOCK_DGRAM, 0));
if (!join_fd.IsValid()) {
logger_.Error("Cannot reopen the multicast-join socket: {}", Error::FromErrno());
groups_joined_ = false;
continue;
}
for (const auto& [group, count] : memberships) {
groups_joined_ = JoinInKernel(join_fd.Get(), group) && groups_joined_;
}
}
return groups_joined_;
}

bool RawSocket::Unregister(const IpAddress& group) noexcept {
Expand All @@ -511,10 +562,9 @@ bool RawSocket::Unregister(const IpAddress& group) noexcept {

auto& join_fd = join_fds_.Get(family);
if (!join_fd.IsValid()) {
// Invariant: a live membership keeps its family's join fd open (it's closed only here, once
// the family's last group leaves). An invalid fd with a membership still outstanding is a
// bug in this bookkeeping, not a runtime condition.
logger_.Error("Cannot leave multicast group {}: its join fd is already closed", group);
// The family's last group already left, or a rebind closed the socket and could not reopen
// it. Closing is what drops the kernel membership, so the group is left either way.
logger_.Debug("Multicast group {} was already left with its join socket", group);
return true;
}

Expand Down
10 changes: 10 additions & 0 deletions src/reflector/raw_socket.h
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ class RawSocket : public LinkSocket, NoMove {

[[nodiscard]] bool Attached() const noexcept override;
[[nodiscard]] bool Rebind() noexcept override;
[[nodiscard]] bool GroupsJoined() const noexcept override { return groups_joined_; }

[[nodiscard]] bool LinkCarriesMacs() const noexcept override {
#if defined(__linux__)
Expand Down Expand Up @@ -123,6 +124,13 @@ class RawSocket : public LinkSocket, NoMove {
// so a recreated interface re-attaches through exactly the path that first attached it.
[[nodiscard]] bool AttachToInterface() noexcept;

// Re-programs the interface's current kernel object with every group this socket holds a
// membership for, on a freshly opened join fd per family.
[[nodiscard]] bool RejoinGroups() noexcept;
// The kernel join of `group` on `join_fd` at the interface's current index; shared by the
// first join and the re-join after a rebind.
[[nodiscard]] bool JoinInKernel(int join_fd, const IpAddress& group) noexcept;

void Close() noexcept;

// Drops one membership of `group`: leaves the group in the kernel when its last membership
Expand Down Expand Up @@ -155,6 +163,8 @@ class RawSocket : public LinkSocket, NoMove {
// capture/inject socket).
AddressFamilyPair<UniqueFd> join_fds_;
AddressFamilyPair<std::unordered_map<IpAddress, size_t>> group_memberships_;
// False once a re-join failed: the capture is attached but deaf, which nothing else reports.
bool groups_joined_ = true;

// Linux: holds one frame per recv() into receive_buffer_.
// macOS: holds a batch of bpf_hdr-prefixed frames per read(); receive_buffer_filled_
Expand Down
15 changes: 15 additions & 0 deletions tests/application_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,21 @@ TEST_F(ApplicationTest, TheBackstopRebindsADetachedCapture) {
EXPECT_EQ(Socket("dst")->rebinds, 0u);
}

// An attached capture can still be deaf, and this is the only thing that reports it.
TEST_F(ApplicationTest, RebindsACaptureWhoseGroupsAreGone) {
ConfigureSocket("src", {.interface_index = 5});
ConfigureSocket("dst", {.interface_index = 9});
auto app = MakeApp();
ASSERT_TRUE(app.Configure(TestConfigBuilder{}.Add(MakeWolConfig("tv", "src", "dst", {9})).Build()));

Socket("src")->groups_joined = false; // attached stays true
dispatcher_->FireTimers(std::chrono::steady_clock::now());

EXPECT_EQ(Socket("src")->rebinds, 1u);
EXPECT_TRUE(Socket("src")->groups_joined);
EXPECT_EQ(Socket("dst")->rebinds, 0u);
}

// A rebind that failed has no announcement coming, so the pass has to schedule its own retry —
// and stop it again once the repair lands, so a healthy daemon runs only the backstop.
TEST_F(ApplicationTest, RetriesUntilAFailedRepairCompletes) {
Expand Down
5 changes: 5 additions & 0 deletions tests/mocks/fake_link_socket.h
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,15 @@ struct FakeLinkSocket : LinkSocket {

[[nodiscard]] bool Attached() const noexcept override { return attached; }

[[nodiscard]] bool GroupsJoined() const noexcept override { return groups_joined; }

[[nodiscard]] bool Rebind() noexcept override {
++rebinds;
if (fail_rebind) {
return false;
}
attached = true;
groups_joined = true;
return true;
}

Expand All @@ -98,6 +101,8 @@ struct FakeLinkSocket : LinkSocket {
// that went away under the socket; `rebinds` counts recoveries so a test can assert the
// capture was actually re-attached rather than merely re-resolved.
bool attached = true;
// Clear it to model a capture that is still attached but whose memberships are gone.
bool groups_joined = true;
// What Receive() reports; ReceiveError::Failed models a read the kernel refused.
ReceiveError receive_error = ReceiveError::WouldBlock;
bool fail_rebind = false;
Expand Down
61 changes: 61 additions & 0 deletions tests/raw_socket_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,11 @@
#include <chrono>
#include <cstddef>
#include <cstdint>
#include <arpa/inet.h>
#include <cstdio>
#include <cstdlib>
#include <format>
#include <fstream>
#include <ifaddrs.h>
#include <net/if.h>
#include <optional>
Expand Down Expand Up @@ -272,6 +275,37 @@ void ExpectReceived(reflector::UdpSocket& receiver, std::span<const std::byte> e
std::vector<std::byte>(expected.begin(), expected.end()));
}

#if defined(__linux__)
// Whether the kernel currently holds an IPv4 membership for `group` on `interface`, read from
// /proc/net/igmp: the device's line carries its name, and each membership below it carries the
// group as a little-endian hex word. Asking the kernel rather than the socket is the point --
// the socket's own bookkeeping would still claim the group after the interface behind it died.
[[nodiscard]] bool KernelHasMembership(const std::string& interface, const reflector::IpAddress& group) {
std::ifstream igmp{"/proc/net/igmp"};
EXPECT_TRUE(igmp.is_open()) << "cannot read /proc/net/igmp";

// The kernel prints the group's network-order 32 bits with %08X, so formatting s_addr the same
// way matches on either endianness.
in_addr addr{};
EXPECT_EQ(inet_pton(AF_INET, std::string{group.ToString()}.c_str(), &addr), 1);
const auto wanted = std::format("{:08X}", addr.s_addr);

bool in_device = false;
for (std::string line; std::getline(igmp, line);) {
if (!line.starts_with('\t') && !line.starts_with(' ')) {
// A device header: "<idx>\t<name> : ...". Membership lines below it are indented.
in_device = line.find(" " + interface + " ") != std::string::npos
|| line.find("\t" + interface + " ") != std::string::npos;
continue;
}
if (in_device && line.find(wanted) != std::string::npos) {
return true;
}
}
return false;
}
#endif

} // namespace

namespace reflector {
Expand Down Expand Up @@ -1118,6 +1152,33 @@ TEST_F(RawSocketInterfacePairRequiresRootTest, RebindRestoresTheCaptureAfterRecr
EXPECT_EQ(socket.Fd(), fd); // same fd throughout, so dispatcher registrations survive
}

#if defined(__linux__)
// The capture re-attaching is not enough: without the re-join the socket comes back attached and
// permanently deaf on every group it had.
TEST_F(RawSocketInterfacePairRequiresRootTest, RebindRestoresGroupMemberships) {
Interface iface{pair.InjectInterface()};
ASSERT_TRUE(iface.IsValid());
RawSocket socket{iface};
ASSERT_TRUE(socket.IsValid());

const auto group = IpAddress::MdnsGroupV4();
auto membership = socket.JoinMulticastGroup(group);
ASSERT_TRUE(membership.IsValid());
ASSERT_TRUE(KernelHasMembership(pair.InjectInterface(), group)) << "the join did not program";

ASSERT_TRUE(pair.Recreate());
ASSERT_NE(iface.Reidentify(), Interface::IdentityChange::Parked);
ASSERT_FALSE(KernelHasMembership(pair.InjectInterface(), group))
<< "a recreated interface starts with none of the old object's memberships";

ASSERT_TRUE(socket.Rebind());

EXPECT_TRUE(socket.GroupsJoined());
EXPECT_TRUE(KernelHasMembership(pair.InjectInterface(), group))
<< "the membership must be re-programmed on the interface's new kernel object";
}
#endif // defined(__linux__)

TEST_F(RawSocketInterfacePairRequiresRootTest, InjectsIpv4BroadcastCapturedOnPeer) {
Interface inject_iface{pair.InjectInterface()};
RawSocket injector{inject_iface};
Expand Down