From 2504fefa6647ba9982df36745c5a6d9d0569b2bf Mon Sep 17 00:00:00 2001 From: torlando-tech Date: Sat, 9 May 2026 13:29:33 -0400 Subject: [PATCH] fix(autointerface): explicit mld6_joingroup + LOOP + RX/TX diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- lib/auto_interface/AutoInterface.cpp | 94 +++++++++++++++++++++++----- lib/auto_interface/AutoInterface.h | 8 +++ 2 files changed, 85 insertions(+), 17 deletions(-) diff --git a/lib/auto_interface/AutoInterface.cpp b/lib/auto_interface/AutoInterface.cpp index 33b63866..a438c0de 100644 --- a/lib/auto_interface/AutoInterface.cpp +++ b/lib/auto_interface/AutoInterface.cpp @@ -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); diff --git a/lib/auto_interface/AutoInterface.h b/lib/auto_interface/AutoInterface.h index 1475c54c..48b283fe 100644 --- a/lib/auto_interface/AutoInterface.h +++ b/lib/auto_interface/AutoInterface.h @@ -172,4 +172,12 @@ private: // Receive buffer RNS::Bytes _buffer; + + // Diagnostic counters (printed periodically as INFO) + uint32_t _stat_announce_sent = 0; + uint32_t _stat_announce_send_fail = 0; + uint32_t _stat_discovery_rx = 0; + uint32_t _stat_discovery_rx_self = 0; + uint32_t _stat_data_rx = 0; + double _last_stats_log = 0; };