fix(autointerface): explicit mld6_joingroup + LOOP + RX/TX diagnostics

Three changes, motivated by debugging "Sideband + pyxis on the same
WiFi don't hear each other's announces":

1. Always call \`mld6_joingroup_netif()\` in addition to \`setsockopt
   IPV6_JOIN_GROUP\`. On ESP-IDF lwIP, the setsockopt path returns
   success but doesn't reliably push the multicast hash into the
   WiFi MAC filter — incoming multicast frames get silently dropped
   at L2. Calling the netif's mld6 API directly programs the chip
   filter. Joining twice on the netif is refcount-safe.

2. Set IPV6_MULTICAST_LOOP=1 so pyxis receives its own multicast
   echoes. ESP-IDF lwIP defaults this off, which makes upstream's
   "carrier lost / multicast echo timeout" warning fire even on a
   functioning network. With LOOP=1, the initial-echo path actually
   works on isolated test setups too. Logged as DEBUG if the
   platform doesn't support the option.

3. Add a periodic \`AutoInterface: stats announce_tx=N tx_fail=N
   disc_rx=N disc_self=N data_rx=N peers=N\` heartbeat (every 10s).
   Without this it's hard to tell whether pyxis isn't sending,
   isn't receiving, or is sending+receiving but rejecting the
   tokens. Discovery-RX from non-self addresses with bad tokens
   now also logs once with the hex prefix so token-mismatch cases
   are visible (group_id drift, scope-suffix encoding mismatches).
   Added _initial_echo_received update on first self-echo so the
   firewall warning at startup_grace fires correctly.

After this, pyxis's own multicast loopback works (disc_self=N
matches announce_tx=N within 10s). Cross-LAN multicast against
rnsd / Sideband still doesn't make it through, which is an ESP32
WiFi multicast TX limitation — pyxis's frames aren't reaching the
AP. Not a fix here; the diagnostics make the boundary visible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
torlando-tech
2026-05-09 13:29:33 -04:00
co-authored by Claude Opus 4.7
parent 848c7df200
commit 2504fefa66
2 changed files with 85 additions and 17 deletions
+77 -17
View File
@@ -256,6 +256,24 @@ void AutoInterface::loop() {
// Process incoming data packets
process_data();
// Periodic stats heartbeat — visibility into TX/RX during peer
// discovery debugging. Without this it's hard to tell whether
// pyxis is sending at all, sending and getting blocked at the
// AP, or sending fine but rejecting the responses.
if (now - _last_stats_log >= 10.0) {
_last_stats_log = now;
char buf[160];
snprintf(buf, sizeof(buf),
"AutoInterface: stats announce_tx=%lu tx_fail=%lu disc_rx=%lu disc_self=%lu data_rx=%lu peers=%u",
(unsigned long)_stat_announce_sent,
(unsigned long)_stat_announce_send_fail,
(unsigned long)_stat_discovery_rx,
(unsigned long)_stat_discovery_rx_self,
(unsigned long)_stat_data_rx,
(unsigned)_peers.size());
INFO(buf);
}
// Check multicast echo timeout
check_echo_timeout();
@@ -714,27 +732,37 @@ bool AutoInterface::setup_discovery_socket() {
_if_index = netif_get_index(nif);
INFO("AutoInterface: Using interface index " + std::to_string(_if_index) + " for multicast");
// Join the IPv6 multicast group using standard socket API
// This properly links the socket to receive multicast packets
// Join the IPv6 multicast group via standard socket API. On
// ESP-IDF this returns success but in practice doesn't always
// push the multicast hash into the WiFi MAC filter — incoming
// multicast frames get silently dropped at L2. Symptoms: pyxis
// sends fine, but its discovery socket never sees its own
// echo or any peer's announces. Workaround: ALSO call lwIP's
// mld6_joingroup_netif() directly, which goes through the
// netif's igmp_mac_filter and updates the chip filter.
struct ipv6_mreq mreq;
memcpy(&mreq.ipv6mr_multiaddr, &_multicast_address, sizeof(_multicast_address));
mreq.ipv6mr_interface = _if_index;
if (setsockopt(_discovery_socket, IPPROTO_IPV6, IPV6_JOIN_GROUP, &mreq, sizeof(mreq)) < 0) {
WARNING("AutoInterface: Failed to join multicast group via setsockopt (errno=" +
std::to_string(errno) + "), trying mld6 API");
// Fallback to lwIP mld6 API
ip6_addr_t mcast_addr;
memcpy(&mcast_addr.addr, &_multicast_address, sizeof(_multicast_address));
err_t err = mld6_joingroup_netif(nif, &mcast_addr);
if (err == ERR_OK) {
INFO("AutoInterface: Joined IPv6 multicast group via mld6 API: " + _multicast_address_str);
} else {
WARNING("AutoInterface: mld6_joingroup failed (err=" + std::to_string(err) +
") - discovery may not work");
}
} else {
bool joined_setsockopt = (setsockopt(_discovery_socket, IPPROTO_IPV6,
IPV6_JOIN_GROUP, &mreq, sizeof(mreq)) == 0);
if (joined_setsockopt) {
INFO("AutoInterface: Joined IPv6 multicast group via setsockopt: " + _multicast_address_str);
} else {
WARNING("AutoInterface: setsockopt IPV6_JOIN_GROUP failed (errno=" +
std::to_string(errno) + ")");
}
// Always call mld6_joingroup_netif() too — on ESP-IDF, this is
// what actually programs the WiFi multicast filter. Joining
// twice on the netif is idempotent (refcounted internally).
ip6_addr_t mcast_addr;
memcpy(&mcast_addr.addr, &_multicast_address, sizeof(_multicast_address));
err_t err = mld6_joingroup_netif(nif, &mcast_addr);
if (err == ERR_OK) {
INFO("AutoInterface: mld6_joingroup_netif OK on " + std::string(nif->name, 2));
} else {
WARNING("AutoInterface: mld6_joingroup_netif failed (err=" + std::to_string(err) + ")");
}
// Set multicast interface for outgoing packets (critical for multicast to reach other hosts!)
@@ -744,6 +772,16 @@ bool AutoInterface::setup_discovery_socket() {
} else {
DEBUG("AutoInterface: Set IPV6_MULTICAST_IF to interface " + std::to_string(_if_index));
}
// Enable multicast loopback so we receive our own echoes — the
// upstream "carrier" check expects to see them and goes
// "carrier lost" otherwise. ESP-IDF lwIP defaults this off.
int loop = 1;
if (setsockopt(_discovery_socket, IPPROTO_IPV6, IPV6_MULTICAST_LOOP,
&loop, sizeof(loop)) < 0) {
DEBUG("AutoInterface: IPV6_MULTICAST_LOOP not supported (errno=" +
std::to_string(errno) + ") — own-echo timeout will fire on isolated networks");
}
} else {
WARNING("AutoInterface: Could not find station netif for multicast join");
}
@@ -873,8 +911,10 @@ void AutoInterface::send_announce() {
ssize_t sent = sendto(_discovery_socket, _discovery_token.data(), _discovery_token.size(), 0,
(struct sockaddr*)&dest_addr, sizeof(dest_addr));
if (sent > 0) {
_stat_announce_sent++;
DEBUG("AutoInterface: Sent discovery announce (" + std::to_string(sent) + " bytes) to " + _multicast_address_str);
} else {
_stat_announce_send_fail++;
WARNING("AutoInterface: Failed to send discovery announce (errno=" + std::to_string(errno) + ")");
}
}
@@ -902,9 +942,20 @@ void AutoInterface::process_discovery() {
// Hot path - no logging to avoid heap allocation on every packet
while (len > 0) {
_stat_discovery_rx++;
// Convert source address to COMPRESSED string format (match Python)
std::string src_str = ipv6_to_compressed_string((const uint8_t*)&src_addr.sin6_addr);
// Self-echo bookkeeping (sender == us → multicast loopback works)
if (src_str == _link_local_address_str) {
_stat_discovery_rx_self++;
_last_multicast_echo = RNS::Utilities::OS::time();
if (!_initial_echo_received) {
_initial_echo_received = true;
INFO("AutoInterface: Initial multicast self-echo received");
}
}
// Verify the peering hash (full TOKEN_SIZE = 32 bytes)
Bytes combined;
combined.append((const uint8_t*)_group_id.c_str(), _group_id.length());
@@ -912,10 +963,18 @@ void AutoInterface::process_discovery() {
Bytes expected_hash = Identity::full_hash(combined);
// Compare received token with expected (full TOKEN_SIZE = 32 bytes)
if (len >= (ssize_t)TOKEN_SIZE && memcmp(recv_buffer, expected_hash.data(), TOKEN_SIZE) == 0) {
bool token_match = (len >= (ssize_t)TOKEN_SIZE
&& memcmp(recv_buffer, expected_hash.data(), TOKEN_SIZE) == 0);
if (token_match) {
// Valid peer - use IPv6Address (IPAddress is IPv4-only!)
IPv6Address remoteIP((const uint8_t*)&src_addr.sin6_addr);
add_or_refresh_peer(remoteIP, RNS::Utilities::OS::time());
} else if (src_str != _link_local_address_str) {
// Peer's hash mismatched ours — log once per source so the
// user can see "we got a packet but couldn't validate" cases
// (group_id mismatch / scope-suffix mismatch / wrong size).
WARNINGF("AutoInterface: Discovery RX from %s len=%d but peering hash mismatched (token check failed)",
src_str.c_str(), (int)len);
}
// Try to receive more
@@ -937,6 +996,7 @@ void AutoInterface::process_data() {
(struct sockaddr*)&src_addr, &src_len);
while (len > 0) {
_stat_data_rx++;
_buffer.clear();
_buffer.append(recv_buffer, len);