diff --git a/src/reflector/application.cpp b/src/reflector/application.cpp index 16d7a07..e29a1f2 100644 --- a/src/reflector/application.cpp +++ b/src/reflector/application.cpp @@ -180,8 +180,8 @@ bool Application::ReconcileInterfaces(std::span 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; } @@ -201,7 +201,7 @@ bool Application::ReconcileInterfaces(std::span 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 } } diff --git a/src/reflector/link_socket.h b/src/reflector/link_socket.h index 373c383..cd82d08 100644 --- a/src/reflector/link_socket.h +++ b/src/reflector/link_socket.h @@ -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 { diff --git a/src/reflector/raw_socket.cpp b/src/reflector/raw_socket.cpp index 488d389..c35e7cd 100644 --- a/src/reflector/raw_socket.cpp +++ b/src/reflector/raw_socket.cpp @@ -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, @@ -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 @@ -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 { @@ -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; } diff --git a/src/reflector/raw_socket.h b/src/reflector/raw_socket.h index b31beff..502b05e 100644 --- a/src/reflector/raw_socket.h +++ b/src/reflector/raw_socket.h @@ -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__) @@ -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 @@ -155,6 +163,8 @@ class RawSocket : public LinkSocket, NoMove { // capture/inject socket). AddressFamilyPair join_fds_; AddressFamilyPair> 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_ diff --git a/tests/application_test.cpp b/tests/application_test.cpp index 81190ac..05e832f 100644 --- a/tests/application_test.cpp +++ b/tests/application_test.cpp @@ -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) { diff --git a/tests/mocks/fake_link_socket.h b/tests/mocks/fake_link_socket.h index 73ca35a..f1f9157 100644 --- a/tests/mocks/fake_link_socket.h +++ b/tests/mocks/fake_link_socket.h @@ -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; } @@ -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; diff --git a/tests/raw_socket_test.cpp b/tests/raw_socket_test.cpp index 41a03f1..332ca26 100644 --- a/tests/raw_socket_test.cpp +++ b/tests/raw_socket_test.cpp @@ -16,8 +16,11 @@ #include #include #include +#include #include #include +#include +#include #include #include #include @@ -272,6 +275,37 @@ void ExpectReceived(reflector::UdpSocket& receiver, std::span e std::vector(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: "\t : ...". 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 { @@ -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};