From b7a97d327f491f2d510a7a85e6fe56bcc1cc2692 Mon Sep 17 00:00:00 2001 From: Pmmlabs Date: Sun, 22 Feb 2026 19:01:42 +0300 Subject: [PATCH 1/2] Add AutoInterface: IPv6 multicast peer discovery for Python RNS interop --- RNode_Firmware.ino | 13 + lib/auto_interface/.gitignore | 1 + lib/auto_interface/AutoInterface.cpp | 1522 ++++++++++++++++++++++ lib/auto_interface/AutoInterface.h | 171 +++ lib/auto_interface/AutoInterfacePeer.h | 74 ++ lib/auto_interface/auto_config.h.example | 19 + lib/auto_interface/library.json | 15 + platformio.ini | 2 + 8 files changed, 1817 insertions(+) create mode 100644 lib/auto_interface/.gitignore create mode 100644 lib/auto_interface/AutoInterface.cpp create mode 100644 lib/auto_interface/AutoInterface.h create mode 100644 lib/auto_interface/AutoInterfacePeer.h create mode 100644 lib/auto_interface/auto_config.h.example create mode 100644 lib/auto_interface/library.json diff --git a/RNode_Firmware.ino b/RNode_Firmware.ino index 316eb9f4..bd920ac9 100644 --- a/RNode_Firmware.ino +++ b/RNode_Firmware.ino @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -222,6 +223,8 @@ void on_transmit_packet(const RNS::Bytes& raw, const RNS::Interface& interface) // CBA RNS RNS::Reticulum reticulum(RNS::Type::NONE); RNS::Interface lora_interface(RNS::Type::NONE); +AutoInterface* auto_interface_impl = nullptr; +RNS::Interface* auto_interface = nullptr; RNS::FileSystem filesystem(RNS::Type::NONE); #endif // HAS_RNS @@ -580,6 +583,16 @@ void setup() { lora_interface.mode(RNS::Type::Interface::MODE_GATEWAY); RNS::Transport::register_interface(lora_interface); + INFO("Initializing AutoInterface (IPv6 peer discovery)..."); + auto_interface_impl = new AutoInterface("Auto"); + auto_interface = new RNS::Interface(auto_interface_impl); + if (!auto_interface->start()) { + ERROR("Failed to initialize AutoInterface!"); + } else { + INFO("AutoInterface started"); + RNS::Transport::register_interface(*auto_interface); + } + HEAD("Creating Reticulum instance...", RNS::LOG_TRACE); reticulum = RNS::Reticulum(); reticulum.transport_enabled(op_mode == MODE_TNC); diff --git a/lib/auto_interface/.gitignore b/lib/auto_interface/.gitignore new file mode 100644 index 00000000..8796cfa6 --- /dev/null +++ b/lib/auto_interface/.gitignore @@ -0,0 +1 @@ +auto_config.h \ No newline at end of file diff --git a/lib/auto_interface/AutoInterface.cpp b/lib/auto_interface/AutoInterface.cpp new file mode 100644 index 00000000..bc81974c --- /dev/null +++ b/lib/auto_interface/AutoInterface.cpp @@ -0,0 +1,1522 @@ +#include "AutoInterface.h" +#include "Log.h" +#include "Utilities/OS.h" + +#include +#include + +#ifdef ARDUINO +#include // For ESP.getMaxAllocHeap() +// ESP32 lwIP headers for raw socket support +#include +#include +#include +#include +#include +#else +#include +#include +#include +#include +#include +#include +#include +#include +#include +#endif + +using namespace RNS; + +// Helper: Convert IPv6 address bytes to compressed string format (RFC 5952) +// This matches Python's inet_ntop output +static std::string ipv6_to_compressed_string(const uint8_t* addr) { + // Build 8 groups of 16-bit values + uint16_t groups[8]; + for (int i = 0; i < 8; i++) { + groups[i] = (addr[i*2] << 8) | addr[i*2+1]; + } + + // Find longest run of zeros for :: compression + int best_start = -1, best_len = 0; + int cur_start = -1, cur_len = 0; + for (int i = 0; i < 8; i++) { + if (groups[i] == 0) { + if (cur_start < 0) cur_start = i; + cur_len++; + } else { + if (cur_len > best_len && cur_len > 1) { + best_start = cur_start; + best_len = cur_len; + } + cur_start = -1; + cur_len = 0; + } + } + if (cur_len > best_len && cur_len > 1) { + best_start = cur_start; + best_len = cur_len; + } + + // Build string + std::string result; + for (int i = 0; i < 8; i++) { + char buf[8]; + if (best_start >= 0 && i >= best_start && i < best_start + best_len) { + if (i == best_start) result += "::"; + continue; + } + if (!result.empty() && result.back() != ':') result += ":"; + snprintf(buf, sizeof(buf), "%x", groups[i]); + result += buf; + } + // Handle trailing :: + if (best_start >= 0 && best_start + best_len == 8 && result.size() == 2) { + // Just "::" for all zeros + } + return result; +} + +AutoInterface::AutoInterface(const char* name) : InterfaceImpl(name) { + _IN = true; + _OUT = true; + _bitrate = BITRATE_GUESS; + _HW_MTU = HW_MTU; + memset(&_multicast_address, 0, sizeof(_multicast_address)); + memset(&_link_local_address, 0, sizeof(_link_local_address)); +} + +AutoInterface::~AutoInterface() { + stop(); +} + +bool AutoInterface::start() { + _online = false; + + INFO("AutoInterface: Starting with group_id: " + _group_id); + INFO("AutoInterface: Discovery port: " + std::to_string(_discovery_port)); + INFO("AutoInterface: Data port: " + std::to_string(_data_port)); + +#ifdef ARDUINO + // ESP32 implementation using WiFiUDP + + // Get link-local address for our interface + if (!get_link_local_address()) { + ERROR("AutoInterface: Could not get link-local IPv6 address"); + return false; + } + + // Calculate multicast address from group_id hash + calculate_multicast_address(); + + // Calculate our discovery token + calculate_discovery_token(); + + // Set up discovery socket (multicast receive) + if (!setup_discovery_socket()) { + ERROR("AutoInterface: Could not set up discovery socket"); + return false; + } + + // Set up unicast discovery socket (reverse peering receive) + if (!setup_unicast_discovery_socket()) { + // Non-fatal - reverse peering won't work but multicast discovery will + WARNING("AutoInterface: Could not set up unicast discovery socket (reverse peering disabled)"); + } + + // Set up data socket (unicast send/receive) + if (!setup_data_socket()) { + // Data socket failure is non-fatal - we can still discover peers + WARNING("AutoInterface: Could not set up data socket (discovery-only mode)"); + _data_socket_ok = false; + } else { + _data_socket_ok = true; + } + + _online = true; + INFO("AutoInterface: Started successfully (data_socket=" + std::string(_data_socket_ok ? "yes" : "no") + + ", unicast_discovery=" + std::string(_unicast_discovery_socket >= 0 ? "yes" : "no") + ")"); + INFO("AutoInterface: Multicast address: " + _multicast_address_str); + INFO("AutoInterface: Link-local address: " + _link_local_address_str); + INFO("AutoInterface: Discovery token: " + _discovery_token.toHex()); + + return true; +#else + // Get link-local address for our interface + if (!get_link_local_address()) { + ERROR("AutoInterface: Could not get link-local IPv6 address"); + return false; + } + + // Calculate multicast address from group_id hash + calculate_multicast_address(); + + // Calculate our discovery token + calculate_discovery_token(); + + // Set up discovery socket (multicast receive) + if (!setup_discovery_socket()) { + ERROR("AutoInterface: Could not set up discovery socket"); + return false; + } + + // Set up unicast discovery socket (reverse peering receive) + if (!setup_unicast_discovery_socket()) { + // Non-fatal - reverse peering won't work but multicast discovery will + WARNING("AutoInterface: Could not set up unicast discovery socket (reverse peering disabled)"); + } + + // Set up data socket (unicast send/receive) + if (!setup_data_socket()) { + // Data socket failure is non-fatal - we can still discover peers + // This happens when Python RNS is already bound to the same address:port + WARNING("AutoInterface: Could not set up data socket (discovery-only mode)"); + WARNING("AutoInterface: Another RNS instance may be using this address"); + } + + _online = true; + INFO("AutoInterface: Started successfully (data_socket=" + + std::string(_data_socket >= 0 ? "yes" : "no") + + ", unicast_discovery=" + std::string(_unicast_discovery_socket >= 0 ? "yes" : "no") + ")"); + INFO("AutoInterface: Multicast address: " + std::string(inet_ntop(AF_INET6, &_multicast_address, + (char*)_buffer.writable(INET6_ADDRSTRLEN), INET6_ADDRSTRLEN))); + INFO("AutoInterface: Link-local address: " + _link_local_address_str); + INFO("AutoInterface: Discovery token: " + _discovery_token.toHex()); + + return true; +#endif +} + +void AutoInterface::stop() { +#ifdef ARDUINO + // ESP32 cleanup - raw sockets for discovery, unicast discovery, and data + if (_discovery_socket > -1) { + close(_discovery_socket); + _discovery_socket = -1; + } + if (_unicast_discovery_socket > -1) { + close(_unicast_discovery_socket); + _unicast_discovery_socket = -1; + } + if (_data_socket > -1) { + close(_data_socket); + _data_socket = -1; + } + _data_socket_ok = false; +#else + if (_discovery_socket > -1) { + close(_discovery_socket); + _discovery_socket = -1; + } + if (_unicast_discovery_socket > -1) { + close(_unicast_discovery_socket); + _unicast_discovery_socket = -1; + } + if (_data_socket > -1) { + close(_data_socket); + _data_socket = -1; + } +#endif + _online = false; + _peers.clear(); +} + +void AutoInterface::loop() { + if (!_online) return; + + double now = Utilities::OS::time(); + + // Send periodic discovery announce + if (now - _last_announce >= ANNOUNCE_INTERVAL) { +#ifdef ARDUINO + // Skip announce if memory is critically low - prevents fragmentation + // Threshold lowered to 8KB since announces are small (32 byte token) + uint32_t max_block = ESP.getMaxAllocHeap(); + if (max_block < 8000) { + WARNING("AutoInterface: Skipping announce - low memory (max_block=" + std::to_string(max_block) + ")"); + _last_announce = now; // Still update timer to avoid tight loop + } else { + send_announce(); + _last_announce = now; + } +#else + send_announce(); + _last_announce = now; +#endif + } + + // Process incoming discovery packets (multicast) + process_discovery(); + + // Process incoming unicast discovery packets (reverse peering) + process_unicast_discovery(); + + // Send reverse peering to known peers + send_reverse_peering(); + + // Process incoming data packets + process_data(); + + // Check multicast echo timeout + check_echo_timeout(); + + // Expire stale peers + expire_stale_peers(); + + // Expire old deque entries + expire_deque_entries(); + + // Periodic peer job (every 4 seconds) - check for address changes + if (now - _last_peer_job >= PEER_JOB_INTERVAL) { + check_link_local_address(); + _last_peer_job = now; + } +} + +void AutoInterface::send_outgoing(const Bytes& data) { + DEBUG(toString() + ".send_outgoing: data: " + data.toHex()); + + if (!_online) return; + +#ifdef ARDUINO + // ESP32: Send to all known peers via unicast using persistent raw IPv6 socket + // (WiFiUDP doesn't support IPv6) + if (_data_socket < 0) { + WARNING("AutoInterface: Data socket not ready, cannot send"); + return; + } + + for (const auto& peer : _peers) { + if (peer.is_local) continue; // Don't send to ourselves + + sockaddr_in6 peer_addr = {}; + peer_addr.sin6_family = AF_INET6; + peer_addr.sin6_port = htons(_data_port); + peer_addr.sin6_scope_id = _if_index; + + // Copy IPv6 address from peer (IPv6Address stores 16 bytes) + for (int i = 0; i < 16; i++) { + ((uint8_t*)&peer_addr.sin6_addr)[i] = peer.address[i]; + } + + ssize_t sent = sendto(_data_socket, data.data(), data.size(), 0, + (struct sockaddr*)&peer_addr, sizeof(peer_addr)); + if (sent < 0) { + WARNING("AutoInterface: Failed to send to peer " + peer.address_string() + + " errno=" + std::to_string(errno)); + } else { + INFO("AutoInterface: Sent " + std::to_string(sent) + " bytes to " + peer.address_string() + + " port " + std::to_string(_data_port)); + } + } + + // Perform post-send housekeeping + handle_outgoing(data); +#else + // POSIX: Send to all known peers via unicast + for (const auto& peer : _peers) { + if (peer.is_local) continue; // Don't send to ourselves + + struct sockaddr_in6 peer_addr; + memset(&peer_addr, 0, sizeof(peer_addr)); + peer_addr.sin6_family = AF_INET6; + peer_addr.sin6_port = htons(_data_port); + peer_addr.sin6_addr = peer.address; + peer_addr.sin6_scope_id = _if_index; + + ssize_t sent = sendto(_data_socket, data.data(), data.size(), 0, + (struct sockaddr*)&peer_addr, sizeof(peer_addr)); + if (sent < 0) { + WARNING("AutoInterface: Failed to send to peer " + peer.address_string() + + ": " + std::string(strerror(errno))); + } else { + TRACE("AutoInterface: Sent " + std::to_string(sent) + " bytes to " + peer.address_string()); + } + } + + // Perform post-send housekeeping + InterfaceImpl::handle_outgoing(data); +#endif +} + +// ============================================================================ +// Platform-specific: get_link_local_address() +// ============================================================================ + +#ifdef ARDUINO + +bool AutoInterface::get_link_local_address() { + // ESP32: Get link-local IPv6 from WiFi + if (WiFi.status() != WL_CONNECTED) { + ERROR("AutoInterface: WiFi not connected"); + return false; + } + + // Enable IPv6 and wait for link-local address + WiFi.enableIpV6(); + DEBUG("AutoInterface: IPv6 enabled, waiting for link-local address..."); + + // Give time for SLAAC to assign link-local address + for (int i = 0; i < 100; i++) { // Increased timeout to 10 seconds + IPv6Address lladdr = WiFi.localIPv6(); + + // Debug: print what we're getting + if (i % 10 == 0) { + DEBUG("AutoInterface: Attempt " + std::to_string(i) + " - IPv6: " + + std::string(lladdr.toString().c_str())); + } + + // Check if we got a valid address (not all zeros) + // IPv6Address stores bytes in network order + if (lladdr[0] != 0 || lladdr[1] != 0) { + // Store as in6_addr - copy from IPv6Address internal storage + // IPv6Address operator[] returns bytes in network order + uint8_t addr_bytes[16]; + for (int j = 0; j < 16; j++) { + addr_bytes[j] = lladdr[j]; + ((uint8_t*)&_link_local_address)[j] = lladdr[j]; + } + + // Store the address string in COMPRESSED format to match Python's inet_ntop + _link_local_address_str = ipv6_to_compressed_string(addr_bytes); + + // Also store as IPAddress for easier ESP32 use + _link_local_ip = lladdr; + + INFO("AutoInterface: Found IPv6 address " + _link_local_address_str); + + // Check if it's link-local (fe80::/10) + if (lladdr[0] == 0xfe && (lladdr[1] & 0xc0) == 0x80) { + INFO("AutoInterface: Confirmed link-local address"); + return true; + } else { + // Got an address, but it's not link-local - might be global + // Still use it for now + WARNING("AutoInterface: Got non-link-local IPv6: " + _link_local_address_str); + return true; + } + } + delay(100); + } + + ERROR("AutoInterface: No IPv6 address after timeout"); + return false; +} + +void AutoInterface::check_link_local_address() { + // ESP32: Check if link-local address changed + if (WiFi.status() != WL_CONNECTED) { + WARNING("AutoInterface: WiFi disconnected during address check"); + return; + } + + IPv6Address current_ip = WiFi.localIPv6(); + + // Check for valid address (not all zeros) + if (current_ip[0] == 0 && current_ip[1] == 0) { + WARNING("AutoInterface: Lost IPv6 address"); + return; + } + + // Compare with stored address + if (current_ip == _link_local_ip) { + return; // No change + } + + // Address changed! + std::string old_addr_str = _link_local_address_str; + + // Update stored addresses + _link_local_ip = current_ip; + for (int i = 0; i < 16; i++) { + ((uint8_t*)&_link_local_address)[i] = current_ip[i]; + } + + // Get new address string in compressed format + uint8_t addr_bytes[16]; + for (int i = 0; i < 16; i++) { + addr_bytes[i] = current_ip[i]; + } + _link_local_address_str = ipv6_to_compressed_string(addr_bytes); + + WARNING("AutoInterface: Link-local address changed from " + old_addr_str + " to " + _link_local_address_str); + + // Close and rebind data socket + if (_data_socket > -1) { + close(_data_socket); + _data_socket = -1; + } + if (!setup_data_socket()) { + WARNING("AutoInterface: Failed to rebind data socket after address change"); + _data_socket_ok = false; + } else { + _data_socket_ok = true; + INFO("AutoInterface: Data socket rebound to new address"); + } + + // Close and rebind unicast discovery socket + if (_unicast_discovery_socket > -1) { + close(_unicast_discovery_socket); + _unicast_discovery_socket = -1; + } + if (!setup_unicast_discovery_socket()) { + WARNING("AutoInterface: Failed to rebind unicast discovery socket after address change"); + } else { + INFO("AutoInterface: Unicast discovery socket rebound to new address"); + } + + // Recalculate discovery token (critical - token includes address) + calculate_discovery_token(); + INFO("AutoInterface: Discovery token recalculated: " + _discovery_token.toHex()); + + // Signal change to Transport layer + _carrier_changed = true; +} + +#else // POSIX/Linux + +bool AutoInterface::get_link_local_address() { + struct ifaddrs* ifaddr; + if (getifaddrs(&ifaddr) == -1) { + ERROR("AutoInterface: getifaddrs failed: " + std::string(strerror(errno))); + return false; + } + + bool found = false; + for (struct ifaddrs* ifa = ifaddr; ifa != nullptr; ifa = ifa->ifa_next) { + if (ifa->ifa_addr == nullptr) continue; + if (ifa->ifa_addr->sa_family != AF_INET6) continue; + + // Skip loopback + if (strcmp(ifa->ifa_name, "lo") == 0) continue; + + // If interface name specified, match it + if (!_ifname.empty() && _ifname != ifa->ifa_name) continue; + + struct sockaddr_in6* addr6 = (struct sockaddr_in6*)ifa->ifa_addr; + + // Check for link-local address (fe80::/10) + if (IN6_IS_ADDR_LINKLOCAL(&addr6->sin6_addr)) { + _link_local_address = addr6->sin6_addr; + _if_index = if_nametoindex(ifa->ifa_name); + _ifname = ifa->ifa_name; + + // Convert to string for token generation + char buf[INET6_ADDRSTRLEN]; + inet_ntop(AF_INET6, &addr6->sin6_addr, buf, sizeof(buf)); + _link_local_address_str = buf; + + INFO("AutoInterface: Found link-local address " + _link_local_address_str + + " on interface " + _ifname); + found = true; + break; + } + } + + freeifaddrs(ifaddr); + return found; +} + +void AutoInterface::check_link_local_address() { + // POSIX: Check if link-local address changed + struct in6_addr old_addr = _link_local_address; + std::string old_addr_str = _link_local_address_str; + + // Temporarily clear to force refresh + memset(&_link_local_address, 0, sizeof(_link_local_address)); + + if (!get_link_local_address()) { + // Lost address entirely - restore old address + WARNING("AutoInterface: Lost link-local address during check"); + _link_local_address = old_addr; + _link_local_address_str = old_addr_str; + return; + } + + // Check if address changed + if (memcmp(&old_addr, &_link_local_address, sizeof(old_addr)) == 0) { + return; // No change + } + + // Address changed! + WARNING("AutoInterface: Link-local address changed from " + old_addr_str + " to " + _link_local_address_str); + + // Close and rebind data socket + if (_data_socket > -1) { + close(_data_socket); + _data_socket = -1; + } + if (!setup_data_socket()) { + WARNING("AutoInterface: Failed to rebind data socket after address change"); + } else { + INFO("AutoInterface: Data socket rebound to new address"); + } + + // Close and rebind unicast discovery socket + if (_unicast_discovery_socket > -1) { + close(_unicast_discovery_socket); + _unicast_discovery_socket = -1; + } + if (!setup_unicast_discovery_socket()) { + WARNING("AutoInterface: Failed to rebind unicast discovery socket after address change"); + } else { + INFO("AutoInterface: Unicast discovery socket rebound to new address"); + } + + // Recalculate discovery token (critical - token includes address) + calculate_discovery_token(); + INFO("AutoInterface: Discovery token recalculated: " + _discovery_token.toHex()); + + // Signal change to Transport layer + _carrier_changed = true; +} + +#endif // ARDUINO + +void AutoInterface::calculate_multicast_address() { + // Python: group_hash = RNS.Identity.full_hash(self.group_id) + Bytes group_id_bytes((const uint8_t*)_group_id.c_str(), _group_id.length()); + Bytes group_hash = Identity::full_hash(group_id_bytes); + + // Build multicast address: ff12:0:XXXX:XXXX:XXXX:XXXX:XXXX:XXXX + // ff = multicast prefix + // 1 = temporary address type (MULTICAST_TEMPORARY_ADDRESS_TYPE) + // 2 = link scope (SCOPE_LINK) + // The remaining 112 bits come from the group hash + + // Python format from AutoInterface.py lines 195-205: + // gt = "0" # literal 0, NOT from hash! + // gt += ":"+"{:02x}".format(g[3]+(g[2]<<8)) # hash bytes 2-3 + // gt += ":"+"{:02x}".format(g[5]+(g[4]<<8)) # hash bytes 4-5 + // gt += ":"+"{:02x}".format(g[7]+(g[6]<<8)) # hash bytes 6-7 + // gt += ":"+"{:02x}".format(g[9]+(g[8]<<8)) # hash bytes 8-9 + // gt += ":"+"{:02x}".format(g[11]+(g[10]<<8)) # hash bytes 10-11 + // gt += ":"+"{:02x}".format(g[13]+(g[12]<<8)) # hash bytes 12-13 + // mcast_discovery_address = "ff12:" + gt + // + // Result: ff12:0:XXXX:XXXX:XXXX:XXXX:XXXX:XXXX (8 groups) + + uint8_t addr[16]; + addr[0] = 0xff; + addr[1] = 0x12; // 1=temporary, 2=link scope + + const uint8_t* g = group_hash.data(); + + // Group 1: Python uses literal "0", NOT hash bytes 0-1! + addr[2] = 0x00; + addr[3] = 0x00; + + // Group 2: g[3]+(g[2]<<8) - starts at hash byte 2 + addr[4] = g[2]; + addr[5] = g[3]; + + // Group 3: g[5]+(g[4]<<8) + addr[6] = g[4]; + addr[7] = g[5]; + + // Group 4: g[7]+(g[6]<<8) + addr[8] = g[6]; + addr[9] = g[7]; + + // Group 5: g[9]+(g[8]<<8) + addr[10] = g[8]; + addr[11] = g[9]; + + // Group 6: g[11]+(g[10]<<8) + addr[12] = g[10]; + addr[13] = g[11]; + + // Group 7: g[13]+(g[12]<<8) + addr[14] = g[12]; + addr[15] = g[13]; + + memcpy(&_multicast_address, addr, 16); + _multicast_address_bytes = Bytes(addr, 16); + + // Convert to string for logging + char buf[INET6_ADDRSTRLEN]; + inet_ntop(AF_INET6, &_multicast_address, buf, sizeof(buf)); + _multicast_address_str = buf; + +#ifdef ARDUINO + // Also store as IPv6Address for ESP32 use + _multicast_ip = IPv6Address(addr); +#endif +} + +// ============================================================================ +// Platform-independent: calculate_discovery_token() +// ============================================================================ + +void AutoInterface::calculate_discovery_token() { + // Python: discovery_token = RNS.Identity.full_hash(self.group_id+link_local_address.encode("utf-8")) + // Python sends the FULL 32-byte hash (not truncated) + Bytes combined; + combined.append((const uint8_t*)_group_id.c_str(), _group_id.length()); + combined.append((const uint8_t*)_link_local_address_str.c_str(), _link_local_address_str.length()); + + Bytes full_hash = Identity::full_hash(combined); + // Use full TOKEN_SIZE (32 bytes) to match Python RNS + _discovery_token = Bytes(full_hash.data(), TOKEN_SIZE); + TRACE("AutoInterface: Discovery token input: " + combined.toHex()); + TRACE("AutoInterface: Discovery token: " + _discovery_token.toHex()); +} + +// ============================================================================ +// Platform-specific: Socket setup +// ============================================================================ + +#ifdef ARDUINO + +bool AutoInterface::setup_discovery_socket() { + // ESP32: Use raw lwIP socket for IPv6 multicast (WiFiUDP.beginMulticast() only supports IPv4) + + // Create IPv6 UDP socket + _discovery_socket = socket(AF_INET6, SOCK_DGRAM, IPPROTO_UDP); + if (_discovery_socket < 0) { + ERROR("AutoInterface: Failed to create discovery socket (errno=" + std::to_string(errno) + ")"); + return false; + } + + // Allow address reuse + int reuse = 1; + setsockopt(_discovery_socket, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)); + + // Bind to discovery port on in6addr_any (ESP32 lwIP doesn't support binding to multicast) + sockaddr_in6 bind_addr = {}; + bind_addr.sin6_family = AF_INET6; + bind_addr.sin6_port = htons(_discovery_port); + bind_addr.sin6_addr = in6addr_any; // Receive from any source + + if (bind(_discovery_socket, (sockaddr*)&bind_addr, sizeof(bind_addr)) < 0) { + ERROR("AutoInterface: Failed to bind discovery socket (errno=" + std::to_string(errno) + ")"); + close(_discovery_socket); + _discovery_socket = -1; + return false; + } + + // Set non-blocking + int flags = fcntl(_discovery_socket, F_GETFL, 0); + fcntl(_discovery_socket, F_SETFL, flags | O_NONBLOCK); + + // Find the station netif (WiFi interface) to get interface index + netif* nif = netif_list; + while (nif != nullptr) { + if (nif->name[0] == 's' && nif->name[1] == 't') { // "st" = station + break; + } + nif = nif->next; + } + + if (nif != nullptr) { + // Get interface index for multicast (needed for send and receive) + _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 + 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 { + INFO("AutoInterface: Joined IPv6 multicast group via setsockopt: " + _multicast_address_str); + } + + // Set multicast interface for outgoing packets (critical for multicast to reach other hosts!) + if (setsockopt(_discovery_socket, IPPROTO_IPV6, IPV6_MULTICAST_IF, + &_if_index, sizeof(_if_index)) < 0) { + WARNING("AutoInterface: Failed to set IPV6_MULTICAST_IF (errno=" + std::to_string(errno) + ")"); + } else { + DEBUG("AutoInterface: Set IPV6_MULTICAST_IF to interface " + std::to_string(_if_index)); + } + } else { + WARNING("AutoInterface: Could not find station netif for multicast join"); + } + + INFO("AutoInterface: Discovery socket listening on port " + std::to_string(_discovery_port)); + return true; +} + +bool AutoInterface::setup_data_socket() { + // ESP32: Use raw IPv6 socket for data port (WiFiUDP doesn't support IPv6) + _data_socket = socket(AF_INET6, SOCK_DGRAM, IPPROTO_UDP); + if (_data_socket < 0) { + ERROR("AutoInterface: Failed to create data socket (errno=" + std::to_string(errno) + ")"); + return false; + } + + // Allow address reuse + int reuse = 1; + setsockopt(_data_socket, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)); + + // Note: _if_index should already be set by setup_discovery_socket() + // Fallback in case discovery socket wasn't set up first + if (_if_index == 0) { + netif* nif = netif_list; + while (nif != nullptr) { + if (nif->name[0] == 's' && nif->name[1] == 't') { // "st" = station + _if_index = netif_get_index(nif); + break; + } + nif = nif->next; + } + INFO("AutoInterface: Using interface index " + std::to_string(_if_index) + " for data socket (fallback)"); + } + + // Bind to our link-local address and data port (helps with routing) + sockaddr_in6 bind_addr = {}; + bind_addr.sin6_family = AF_INET6; + bind_addr.sin6_port = htons(_data_port); + memcpy(&bind_addr.sin6_addr, &_link_local_address, sizeof(_link_local_address)); + bind_addr.sin6_scope_id = _if_index; + + if (bind(_data_socket, (sockaddr*)&bind_addr, sizeof(bind_addr)) < 0) { + WARNING("AutoInterface: Failed to bind to link-local (errno=" + std::to_string(errno) + + "), trying any address"); + // Fallback to any address + bind_addr.sin6_addr = in6addr_any; + bind_addr.sin6_scope_id = 0; + if (bind(_data_socket, (sockaddr*)&bind_addr, sizeof(bind_addr)) < 0) { + ERROR("AutoInterface: Failed to bind data socket (errno=" + std::to_string(errno) + ")"); + close(_data_socket); + _data_socket = -1; + return false; + } + } + + // Set non-blocking + int flags = fcntl(_data_socket, F_GETFL, 0); + fcntl(_data_socket, F_SETFL, flags | O_NONBLOCK); + + INFO("AutoInterface: Data socket listening on port " + std::to_string(_data_port)); + return true; +} + +bool AutoInterface::setup_unicast_discovery_socket() { + // ESP32: Create socket for receiving unicast discovery (reverse peering) + _unicast_discovery_socket = socket(AF_INET6, SOCK_DGRAM, IPPROTO_UDP); + if (_unicast_discovery_socket < 0) { + ERROR("AutoInterface: Failed to create unicast discovery socket (errno=" + std::to_string(errno) + ")"); + return false; + } + + // Allow address reuse + int reuse = 1; + setsockopt(_unicast_discovery_socket, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)); + + // Bind to our link-local address and unicast discovery port + sockaddr_in6 bind_addr = {}; + bind_addr.sin6_family = AF_INET6; + bind_addr.sin6_port = htons(_unicast_discovery_port); + memcpy(&bind_addr.sin6_addr, &_link_local_address, sizeof(_link_local_address)); + bind_addr.sin6_scope_id = _if_index; + + if (bind(_unicast_discovery_socket, (sockaddr*)&bind_addr, sizeof(bind_addr)) < 0) { + WARNING("AutoInterface: Failed to bind unicast discovery to link-local (errno=" + std::to_string(errno) + + "), trying any address"); + // Fallback to any address + bind_addr.sin6_addr = in6addr_any; + bind_addr.sin6_scope_id = 0; + if (bind(_unicast_discovery_socket, (sockaddr*)&bind_addr, sizeof(bind_addr)) < 0) { + ERROR("AutoInterface: Failed to bind unicast discovery socket (errno=" + std::to_string(errno) + ")"); + close(_unicast_discovery_socket); + _unicast_discovery_socket = -1; + return false; + } + } + + // Set non-blocking + int flags = fcntl(_unicast_discovery_socket, F_GETFL, 0); + fcntl(_unicast_discovery_socket, F_SETFL, flags | O_NONBLOCK); + + INFO("AutoInterface: Unicast discovery socket listening on port " + std::to_string(_unicast_discovery_port)); + return true; +} + +bool AutoInterface::join_multicast_group() { + // ESP32: Multicast join handled by beginMulticast() + INFO("AutoInterface: Joined multicast group " + _multicast_address_str); + return true; +} + +void AutoInterface::send_announce() { + // ESP32: Send discovery token to multicast address using raw socket + if (_discovery_socket < 0) { + WARNING("AutoInterface: Discovery socket not initialized"); + return; + } + + sockaddr_in6 dest_addr = {}; + dest_addr.sin6_family = AF_INET6; + dest_addr.sin6_port = htons(_discovery_port); + memcpy(&dest_addr.sin6_addr, &_multicast_address, sizeof(_multicast_address)); + dest_addr.sin6_scope_id = _if_index; // Specify WiFi interface for link-local multicast + + ssize_t sent = sendto(_discovery_socket, _discovery_token.data(), _discovery_token.size(), 0, + (struct sockaddr*)&dest_addr, sizeof(dest_addr)); + if (sent > 0) { + DEBUG("AutoInterface: Sent discovery announce (" + std::to_string(sent) + " bytes) to " + _multicast_address_str); + } else { + WARNING("AutoInterface: Failed to send discovery announce (errno=" + std::to_string(errno) + ")"); + } +} + +void AutoInterface::process_discovery() { + // ESP32: Use raw socket recvfrom for IPv6 multicast + if (_discovery_socket < 0) return; + + uint8_t recv_buffer[128]; + sockaddr_in6 src_addr; + socklen_t src_len = sizeof(src_addr); + + ssize_t len = recvfrom(_discovery_socket, recv_buffer, sizeof(recv_buffer), 0, + (struct sockaddr*)&src_addr, &src_len); + + // Hot path - no logging to avoid heap allocation on every packet + + while (len > 0) { + // Convert source address to COMPRESSED string format (match Python) + std::string src_str = ipv6_to_compressed_string((const uint8_t*)&src_addr.sin6_addr); + + // Verify the peering hash (full TOKEN_SIZE = 32 bytes) + Bytes combined; + combined.append((const uint8_t*)_group_id.c_str(), _group_id.length()); + combined.append((const uint8_t*)src_str.c_str(), src_str.length()); + 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) { + // Valid peer - use IPv6Address (IPAddress is IPv4-only!) + IPv6Address remoteIP((const uint8_t*)&src_addr.sin6_addr); + add_or_refresh_peer(remoteIP, Utilities::OS::time()); + } + + // Try to receive more + src_len = sizeof(src_addr); + len = recvfrom(_discovery_socket, recv_buffer, sizeof(recv_buffer), 0, + (sockaddr*)&src_addr, &src_len); + } +} + +void AutoInterface::process_data() { + // ESP32: Use raw socket for IPv6 data reception + if (_data_socket < 0) return; + + uint8_t recv_buffer[Type::Reticulum::MTU + 64]; + sockaddr_in6 src_addr; + socklen_t src_len = sizeof(src_addr); + + ssize_t len = recvfrom(_data_socket, recv_buffer, sizeof(recv_buffer), 0, + (struct sockaddr*)&src_addr, &src_len); + + while (len > 0) { + _buffer.clear(); + _buffer.append(recv_buffer, len); + + // Check for duplicates + if (is_duplicate(_buffer)) { + TRACE("AutoInterface: Dropping duplicate packet"); + src_len = sizeof(src_addr); + len = recvfrom(_data_socket, recv_buffer, sizeof(recv_buffer), 0, + (sockaddr*)&src_addr, &src_len); + continue; + } + + add_to_deque(_buffer); + + // Convert source address to string for logging + std::string src_str = ipv6_to_compressed_string((const uint8_t*)&src_addr.sin6_addr); + DEBUG("AutoInterface: Received data from " + src_str + " (" + std::to_string(len) + " bytes)"); + + // Pass to transport + InterfaceImpl::handle_incoming(_buffer); + + // Try to receive more + src_len = sizeof(src_addr); + len = recvfrom(_data_socket, recv_buffer, sizeof(recv_buffer), 0, + (sockaddr*)&src_addr, &src_len); + } +} + +void AutoInterface::process_unicast_discovery() { + // ESP32: Process incoming unicast discovery packets (reverse peering) + if (_unicast_discovery_socket < 0) return; + + uint8_t recv_buffer[128]; + sockaddr_in6 src_addr; + socklen_t src_len = sizeof(src_addr); + + ssize_t len = recvfrom(_unicast_discovery_socket, recv_buffer, sizeof(recv_buffer), 0, + (struct sockaddr*)&src_addr, &src_len); + + while (len > 0) { + // Convert source address to COMPRESSED string format (match Python) + std::string src_str = ipv6_to_compressed_string((const uint8_t*)&src_addr.sin6_addr); + + // Verify the peering hash (full TOKEN_SIZE = 32 bytes) + Bytes combined; + combined.append((const uint8_t*)_group_id.c_str(), _group_id.length()); + combined.append((const uint8_t*)src_str.c_str(), src_str.length()); + 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) { + // Valid peer via unicast discovery (reverse peering) + IPv6Address remoteIP((const uint8_t*)&src_addr.sin6_addr); + DEBUG("AutoInterface: Received unicast discovery from " + src_str); + add_or_refresh_peer(remoteIP, Utilities::OS::time()); + } + + // Try to receive more + src_len = sizeof(src_addr); + len = recvfrom(_unicast_discovery_socket, recv_buffer, sizeof(recv_buffer), 0, + (sockaddr*)&src_addr, &src_len); + } +} + +void AutoInterface::reverse_announce(AutoInterfacePeer& peer) { + // ESP32: Send our discovery token directly to a peer's unicast discovery port + // This allows peer to discover us even if multicast is not working + + // Create temporary socket for sending + int sock = socket(AF_INET6, SOCK_DGRAM, IPPROTO_UDP); + if (sock < 0) { + WARNING("AutoInterface: Failed to create reverse announce socket (errno=" + std::to_string(errno) + ")"); + return; + } + + // Build destination address + sockaddr_in6 dest_addr = {}; + dest_addr.sin6_family = AF_INET6; + dest_addr.sin6_port = htons(_unicast_discovery_port); + dest_addr.sin6_scope_id = _if_index; + + // Copy peer's IPv6 address + for (int i = 0; i < 16; i++) { + ((uint8_t*)&dest_addr.sin6_addr)[i] = peer.address[i]; + } + + // Send discovery token + ssize_t sent = sendto(sock, _discovery_token.data(), _discovery_token.size(), 0, + (struct sockaddr*)&dest_addr, sizeof(dest_addr)); + + close(sock); + + if (sent > 0) { + TRACE("AutoInterface: Sent reverse announce to " + peer.address_string()); + } else { + WARNING("AutoInterface: Failed to send reverse announce to " + peer.address_string() + + " (errno=" + std::to_string(errno) + ")"); + } +} + +void AutoInterface::send_reverse_peering() { + // ESP32: Periodically send reverse peering to known peers + // This maintains peer connections even when multicast is unreliable + double now = Utilities::OS::time(); + + for (auto& peer : _peers) { + // Skip local peers (our own announcements) + if (peer.is_local) continue; + + // Check if it's time to send reverse peering to this peer + if (now > peer.last_outbound + REVERSE_PEERING_INTERVAL) { + reverse_announce(peer); + peer.last_outbound = now; + } + } +} + +#else // POSIX/Linux + +bool AutoInterface::setup_discovery_socket() { + // Create IPv6 UDP socket + _discovery_socket = socket(AF_INET6, SOCK_DGRAM, 0); + if (_discovery_socket < 0) { + ERROR("AutoInterface: Could not create discovery socket: " + std::string(strerror(errno))); + return false; + } + + // Enable address reuse + int reuse = 1; + setsockopt(_discovery_socket, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)); +#ifdef SO_REUSEPORT + setsockopt(_discovery_socket, SOL_SOCKET, SO_REUSEPORT, &reuse, sizeof(reuse)); +#endif + + // Set multicast interface + setsockopt(_discovery_socket, IPPROTO_IPV6, IPV6_MULTICAST_IF, &_if_index, sizeof(_if_index)); + + // Join multicast group + if (!join_multicast_group()) { + close(_discovery_socket); + _discovery_socket = -1; + return false; + } + + // Bind to discovery port on multicast address + struct sockaddr_in6 bind_addr; + memset(&bind_addr, 0, sizeof(bind_addr)); + bind_addr.sin6_family = AF_INET6; + bind_addr.sin6_port = htons(_discovery_port); + bind_addr.sin6_addr = _multicast_address; + bind_addr.sin6_scope_id = _if_index; + + if (bind(_discovery_socket, (struct sockaddr*)&bind_addr, sizeof(bind_addr)) < 0) { + ERROR("AutoInterface: Could not bind discovery socket: " + std::string(strerror(errno))); + close(_discovery_socket); + _discovery_socket = -1; + return false; + } + + // Make socket non-blocking + int flags = 1; + ioctl(_discovery_socket, FIONBIO, &flags); + + INFO("AutoInterface: Discovery socket bound to port " + std::to_string(_discovery_port)); + return true; +} + +bool AutoInterface::setup_data_socket() { + // Create IPv6 UDP socket for data + _data_socket = socket(AF_INET6, SOCK_DGRAM, 0); + if (_data_socket < 0) { + ERROR("AutoInterface: Could not create data socket: " + std::string(strerror(errno))); + return false; + } + + // Enable address reuse + int reuse = 1; + setsockopt(_data_socket, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)); +#ifdef SO_REUSEPORT + setsockopt(_data_socket, SOL_SOCKET, SO_REUSEPORT, &reuse, sizeof(reuse)); +#endif + + // Bind to data port on link-local address + struct sockaddr_in6 bind_addr; + memset(&bind_addr, 0, sizeof(bind_addr)); + bind_addr.sin6_family = AF_INET6; + bind_addr.sin6_port = htons(_data_port); + bind_addr.sin6_addr = _link_local_address; + bind_addr.sin6_scope_id = _if_index; + + if (bind(_data_socket, (struct sockaddr*)&bind_addr, sizeof(bind_addr)) < 0) { + ERROR("AutoInterface: Could not bind data socket: " + std::string(strerror(errno))); + close(_data_socket); + _data_socket = -1; + return false; + } + + // Make socket non-blocking + int flags = 1; + ioctl(_data_socket, FIONBIO, &flags); + + INFO("AutoInterface: Data socket bound to port " + std::to_string(_data_port)); + return true; +} + +bool AutoInterface::join_multicast_group() { + 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) { + ERROR("AutoInterface: Could not join multicast group: " + std::string(strerror(errno))); + return false; + } + + INFO("AutoInterface: Joined multicast group " + _multicast_address_str); + return true; +} + +void AutoInterface::send_announce() { + if (_discovery_socket < 0) return; + + // Send discovery token to multicast address + struct sockaddr_in6 mcast_addr; + memset(&mcast_addr, 0, sizeof(mcast_addr)); + mcast_addr.sin6_family = AF_INET6; + mcast_addr.sin6_port = htons(_discovery_port); + mcast_addr.sin6_addr = _multicast_address; + mcast_addr.sin6_scope_id = _if_index; + + ssize_t sent = sendto(_discovery_socket, _discovery_token.data(), _discovery_token.size(), 0, + (struct sockaddr*)&mcast_addr, sizeof(mcast_addr)); + if (sent < 0) { + WARNING("AutoInterface: Failed to send discovery announce: " + std::string(strerror(errno))); + } else { + TRACE("AutoInterface: Sent discovery announce (" + std::to_string(sent) + " bytes)"); + } +} + +void AutoInterface::process_discovery() { + if (_discovery_socket < 0) return; + + uint8_t recv_buffer[1024]; + struct sockaddr_in6 src_addr; + socklen_t addr_len = sizeof(src_addr); + + while (true) { + ssize_t len = recvfrom(_discovery_socket, recv_buffer, sizeof(recv_buffer), 0, + (struct sockaddr*)&src_addr, &addr_len); + if (len <= 0) break; + + // Get source address string + char src_str[INET6_ADDRSTRLEN]; + inet_ntop(AF_INET6, &src_addr.sin6_addr, src_str, sizeof(src_str)); + + DEBUG("AutoInterface: Received discovery packet from " + std::string(src_str) + + " (" + std::to_string(len) + " bytes)"); + + // Verify the peering hash + Bytes combined; + combined.append((const uint8_t*)_group_id.c_str(), _group_id.length()); + combined.append((const uint8_t*)src_str, strlen(src_str)); + Bytes expected_hash = Identity::full_hash(combined); + + // Compare received hash with expected + if (len >= 32 && memcmp(recv_buffer, expected_hash.data(), 32) == 0) { + // Valid peer + add_or_refresh_peer(src_addr.sin6_addr, RNS::Utilities::OS::time()); + } else { + DEBUG("AutoInterface: Invalid discovery hash from " + std::string(src_str)); + } + } +} + +void AutoInterface::process_data() { + if (_data_socket < 0) return; + + struct sockaddr_in6 src_addr; + socklen_t addr_len = sizeof(src_addr); + + while (true) { + _buffer.clear(); + ssize_t len = recvfrom(_data_socket, _buffer.writable(Type::Reticulum::MTU), + Type::Reticulum::MTU, 0, + (struct sockaddr*)&src_addr, &addr_len); + if (len <= 0) break; + + _buffer.resize(len); + + // Check for duplicates (multi-interface deduplication) + if (is_duplicate(_buffer)) { + TRACE("AutoInterface: Dropping duplicate packet"); + continue; + } + + add_to_deque(_buffer); + + char src_str[INET6_ADDRSTRLEN]; + inet_ntop(AF_INET6, &src_addr.sin6_addr, src_str, sizeof(src_str)); + DEBUG("AutoInterface: Received data from " + std::string(src_str) + + " (" + std::to_string(len) + " bytes)"); + + // Pass to transport + InterfaceImpl::handle_incoming(_buffer); + } +} + +bool AutoInterface::setup_unicast_discovery_socket() { + // POSIX: Create socket for receiving unicast discovery (reverse peering) + _unicast_discovery_socket = socket(AF_INET6, SOCK_DGRAM, 0); + if (_unicast_discovery_socket < 0) { + ERROR("AutoInterface: Could not create unicast discovery socket: " + std::string(strerror(errno))); + return false; + } + + // Enable address reuse + int reuse = 1; + setsockopt(_unicast_discovery_socket, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)); +#ifdef SO_REUSEPORT + setsockopt(_unicast_discovery_socket, SOL_SOCKET, SO_REUSEPORT, &reuse, sizeof(reuse)); +#endif + + // Bind to unicast discovery port on link-local address + struct sockaddr_in6 bind_addr; + memset(&bind_addr, 0, sizeof(bind_addr)); + bind_addr.sin6_family = AF_INET6; + bind_addr.sin6_port = htons(_unicast_discovery_port); + bind_addr.sin6_addr = _link_local_address; + bind_addr.sin6_scope_id = _if_index; + + if (bind(_unicast_discovery_socket, (struct sockaddr*)&bind_addr, sizeof(bind_addr)) < 0) { + ERROR("AutoInterface: Could not bind unicast discovery socket: " + std::string(strerror(errno))); + close(_unicast_discovery_socket); + _unicast_discovery_socket = -1; + return false; + } + + // Make socket non-blocking + int flags = 1; + ioctl(_unicast_discovery_socket, FIONBIO, &flags); + + INFO("AutoInterface: Unicast discovery socket bound to port " + std::to_string(_unicast_discovery_port)); + return true; +} + +void AutoInterface::process_unicast_discovery() { + // POSIX: Process incoming unicast discovery packets (reverse peering) + if (_unicast_discovery_socket < 0) return; + + uint8_t recv_buffer[128]; + struct sockaddr_in6 src_addr; + socklen_t addr_len = sizeof(src_addr); + + while (true) { + ssize_t len = recvfrom(_unicast_discovery_socket, recv_buffer, sizeof(recv_buffer), 0, + (struct sockaddr*)&src_addr, &addr_len); + if (len <= 0) break; + + // Get source address string + char src_str[INET6_ADDRSTRLEN]; + inet_ntop(AF_INET6, &src_addr.sin6_addr, src_str, sizeof(src_str)); + + DEBUG("AutoInterface: Received unicast discovery from " + std::string(src_str) + + " (" + std::to_string(len) + " bytes)"); + + // Verify the peering hash + Bytes combined; + combined.append((const uint8_t*)_group_id.c_str(), _group_id.length()); + combined.append((const uint8_t*)src_str, strlen(src_str)); + Bytes expected_hash = Identity::full_hash(combined); + + // Compare received hash with expected + if (len >= 32 && memcmp(recv_buffer, expected_hash.data(), 32) == 0) { + // Valid peer via unicast discovery (reverse peering) + add_or_refresh_peer(src_addr.sin6_addr, RNS::Utilities::OS::time()); + } else { + DEBUG("AutoInterface: Invalid unicast discovery hash from " + std::string(src_str)); + } + } +} + +void AutoInterface::reverse_announce(AutoInterfacePeer& peer) { + // POSIX: Send our discovery token directly to a peer's unicast discovery port + // This allows peer to discover us even if multicast is not working + + // Create temporary socket for sending + int sock = socket(AF_INET6, SOCK_DGRAM, 0); + if (sock < 0) { + WARNING("AutoInterface: Failed to create reverse announce socket: " + std::string(strerror(errno))); + return; + } + + // Build destination address + struct sockaddr_in6 dest_addr; + memset(&dest_addr, 0, sizeof(dest_addr)); + dest_addr.sin6_family = AF_INET6; + dest_addr.sin6_port = htons(_unicast_discovery_port); + dest_addr.sin6_addr = peer.address; + dest_addr.sin6_scope_id = _if_index; + + // Send discovery token + ssize_t sent = sendto(sock, _discovery_token.data(), _discovery_token.size(), 0, + (struct sockaddr*)&dest_addr, sizeof(dest_addr)); + + close(sock); + + if (sent > 0) { + TRACE("AutoInterface: Sent reverse announce to " + peer.address_string()); + } else { + WARNING("AutoInterface: Failed to send reverse announce to " + peer.address_string() + + ": " + std::string(strerror(errno))); + } +} + +void AutoInterface::send_reverse_peering() { + // POSIX: Periodically send reverse peering to known peers + // This maintains peer connections even when multicast is unreliable + double now = RNS::Utilities::OS::time(); + + for (auto& peer : _peers) { + // Skip local peers (our own announcements) + if (peer.is_local) continue; + + // Check if it's time to send reverse peering to this peer + if (now > peer.last_outbound + REVERSE_PEERING_INTERVAL) { + reverse_announce(peer); + peer.last_outbound = now; + } + } +} + +#endif // ARDUINO + +// ============================================================================ +// Platform-specific: Peer management +// ============================================================================ + +#ifdef ARDUINO + +void AutoInterface::add_or_refresh_peer(const IPv6Address& addr, double timestamp) { + // Check if this is our own address (IPv6Address == properly compares all 16 bytes) + if (addr == _link_local_ip) { + // Update echo timestamp + _last_multicast_echo = timestamp; + + // Track initial echo received + if (!_initial_echo_received) { + _initial_echo_received = true; + INFO("AutoInterface: Initial multicast echo received - multicast is working"); + } + + DEBUG("AutoInterface: Received own multicast echo - ignoring"); + return; + } + + // Check if peer already exists + for (auto& peer : _peers) { + if (peer.same_address(addr)) { + peer.last_heard = timestamp; + TRACE("AutoInterface: Refreshed peer " + peer.address_string()); + return; + } + } + + // Add new peer + AutoInterfacePeer new_peer(addr, _data_port, timestamp); + _peers.push_back(new_peer); + + INFO("AutoInterface: Added new peer " + new_peer.address_string()); +} + +#else // POSIX + +void AutoInterface::add_or_refresh_peer(const struct in6_addr& addr, double timestamp) { + // Check if this is our own address + if (memcmp(&addr, &_link_local_address, sizeof(addr)) == 0) { + // Update echo timestamp + _last_multicast_echo = timestamp; + + // Track initial echo received + if (!_initial_echo_received) { + _initial_echo_received = true; + INFO("AutoInterface: Initial multicast echo received - multicast is working"); + } + + DEBUG("AutoInterface: Received own multicast echo - ignoring"); + return; + } + + // Check if peer already exists + for (auto& peer : _peers) { + if (peer.same_address(addr)) { + peer.last_heard = timestamp; + TRACE("AutoInterface: Refreshed peer " + peer.address_string()); + return; + } + } + + // Add new peer + AutoInterfacePeer new_peer(addr, _data_port, timestamp); + _peers.push_back(new_peer); + + INFO("AutoInterface: Added new peer " + new_peer.address_string()); +} + +#endif // ARDUINO + +// ============================================================================ +// Platform-independent: Echo Timeout Checking +// ============================================================================ + +void AutoInterface::check_echo_timeout() { + double now = Utilities::OS::time(); + + // Only check if we've started announcing + if (_last_announce == 0) { + return; // Haven't sent first announce yet + } + + // Calculate time since last echo + double echo_age = now - _last_multicast_echo; + bool timed_out = (echo_age > MCAST_ECHO_TIMEOUT); + + // Detect timeout state transitions + if (timed_out != _timed_out) { + _timed_out = timed_out; + _carrier_changed = true; + + if (!timed_out) { + WARNING("AutoInterface: Carrier recovered on interface"); + } else { + WARNING("AutoInterface: Multicast echo timeout for interface. Carrier lost."); + } + } + + // One-time firewall diagnostic (after grace period) + double startup_grace = ANNOUNCE_INTERVAL * 3.0; // ~5 seconds + + if (!_initial_echo_received && + (now - _last_announce) > startup_grace && + !_firewall_warning_logged) { + ERROR("AutoInterface: No multicast echoes received. " + "The networking hardware or a firewall may be blocking multicast traffic."); + _firewall_warning_logged = true; + } +} + +// ============================================================================ +// Platform-independent: Deduplication +// ============================================================================ + +void AutoInterface::expire_stale_peers() { + double now = Utilities::OS::time(); + + _peers.erase( + std::remove_if(_peers.begin(), _peers.end(), + [this, now](const AutoInterfacePeer& peer) { + if (now - peer.last_heard > PEERING_TIMEOUT) { + INFO("AutoInterface: Removed stale peer " + peer.address_string()); + return true; + } + return false; + }), + _peers.end()); +} + +bool AutoInterface::is_duplicate(const Bytes& packet) { + Bytes packet_hash = Identity::full_hash(packet); + + for (const auto& entry : _packet_deque) { + if (entry.hash == packet_hash) { + return true; + } + } + return false; +} + +void AutoInterface::add_to_deque(const Bytes& packet) { + DequeEntry entry; + entry.hash = Identity::full_hash(packet); + entry.timestamp = Utilities::OS::time(); + + _packet_deque.push_back(entry); + + // Limit deque size + while (_packet_deque.size() > DEQUE_SIZE) { + _packet_deque.pop_front(); + } +} + +void AutoInterface::expire_deque_entries() { + double now = Utilities::OS::time(); + + while (!_packet_deque.empty() && now - _packet_deque.front().timestamp > DEQUE_TTL) { + _packet_deque.pop_front(); + } +} diff --git a/lib/auto_interface/AutoInterface.h b/lib/auto_interface/AutoInterface.h new file mode 100644 index 00000000..7c99a564 --- /dev/null +++ b/lib/auto_interface/AutoInterface.h @@ -0,0 +1,171 @@ +#pragma once + +#include "Interface.h" +#include "Bytes.h" +#include "AutoInterfacePeer.h" + +#ifdef ARDUINO +#include +#include +#include +#include +#else +#include +#include +#include +#endif + +#include +#include +#include +#include + +// AutoInterface - automatic peer discovery via IPv6 multicast +// Matches Python RNS AutoInterface behavior for interoperability +class AutoInterface : public RNS::InterfaceImpl { + +public: + // Protocol constants (match Python RNS) + static constexpr uint16_t DEFAULT_DISCOVERY_PORT = 29716; + static constexpr uint16_t DEFAULT_DATA_PORT = 42671; + static constexpr const char* DEFAULT_GROUP_ID = "reticulum"; + static constexpr double PEERING_TIMEOUT = 22.0; // seconds (matches Python RNS) + static constexpr double ANNOUNCE_INTERVAL = 1.6; // seconds (matches Python RNS) + static constexpr double MCAST_ECHO_TIMEOUT = 6.5; // seconds (matches Python RNS) + static constexpr double REVERSE_PEERING_INTERVAL = ANNOUNCE_INTERVAL * 3.25; // ~5.2 seconds + static constexpr double PEER_JOB_INTERVAL = 4.0; // seconds (matches Python RNS) + static constexpr size_t DEQUE_SIZE = 48; // packet dedup window + static constexpr double DEQUE_TTL = 0.75; // seconds + static constexpr uint32_t BITRATE_GUESS = 10 * 1000 * 1000; + static constexpr uint16_t HW_MTU = 1196; + + // Discovery token is full_hash(group_id + link_local_address) = 32 bytes + // Python RNS sends and expects the full 32-byte hash (HASHLENGTH//8 = 256//8 = 32) + static constexpr size_t TOKEN_SIZE = 32; + +public: + AutoInterface(const char* name = "AutoInterface"); + ~AutoInterface() override; + + // Configuration (call before start()) + void set_group_id(const std::string& group_id) { _group_id = group_id; } + void set_discovery_port(uint16_t port) { _discovery_port = port; } + void set_data_port(uint16_t port) { _data_port = port; } + void set_interface_name(const std::string& ifname) { _ifname = ifname; } + + // InterfaceImpl overrides + bool start() override; + void stop() override; + void loop() override; + + inline std::string toString() const override { + return "AutoInterface[" + _name + "/" + _group_id + "]"; + } + + // Getters for testing + const RNS::Bytes& get_discovery_token() const { return _discovery_token; } + const RNS::Bytes& get_multicast_address() const { return _multicast_address_bytes; } + size_t peer_count() const { return _peers.size(); } + + // Carrier state tracking (matches Python RNS) + bool carrier_changed() { + bool changed = _carrier_changed; + _carrier_changed = false; // Clear flag on read + return changed; + } + void clear_carrier_changed() { _carrier_changed = false; } + bool is_timed_out() const { return _timed_out; } + +protected: + void send_outgoing(const RNS::Bytes& data) override; + +private: + // Discovery and addressing + void calculate_multicast_address(); + void calculate_discovery_token(); + bool get_link_local_address(); + + // Socket operations + bool setup_discovery_socket(); + bool setup_unicast_discovery_socket(); + bool setup_data_socket(); + bool join_multicast_group(); + + // Main loop operations + void send_announce(); + void process_discovery(); + void process_unicast_discovery(); + void send_reverse_peering(); + void reverse_announce(AutoInterfacePeer& peer); + void process_data(); + void check_echo_timeout(); + void check_link_local_address(); + + // Peer management +#ifdef ARDUINO + void add_or_refresh_peer(const IPv6Address& addr, double timestamp); +#else + void add_or_refresh_peer(const struct in6_addr& addr, double timestamp); +#endif + void expire_stale_peers(); + + // Deduplication + bool is_duplicate(const RNS::Bytes& packet); + void add_to_deque(const RNS::Bytes& packet); + void expire_deque_entries(); + + // Configuration + std::string _group_id = DEFAULT_GROUP_ID; + uint16_t _discovery_port = DEFAULT_DISCOVERY_PORT; + uint16_t _unicast_discovery_port = DEFAULT_DISCOVERY_PORT + 1; // 29717 + uint16_t _data_port = DEFAULT_DATA_PORT; + std::string _ifname; // Network interface name (e.g., "eth0", "wlan0") + + // Computed values + RNS::Bytes _discovery_token; // 16 bytes + RNS::Bytes _multicast_address_bytes; // 16 bytes (IPv6) + struct in6_addr _multicast_address; + struct in6_addr _link_local_address; + std::string _link_local_address_str; + std::string _multicast_address_str; // For logging + bool _data_socket_ok = false; // Data socket initialized successfully +#ifdef ARDUINO + IPv6Address _link_local_ip; // ESP32: link-local as IPv6Address + IPv6Address _multicast_ip; // ESP32: multicast as IPv6Address +#endif + + // Sockets +#ifdef ARDUINO + int _discovery_socket = -1; // Raw socket for IPv6 multicast discovery + int _unicast_discovery_socket = -1; // Raw socket for unicast discovery (reverse peering) + int _data_socket = -1; // Raw socket for IPv6 unicast data (WiFiUDP doesn't support IPv6) + unsigned int _if_index = 0; // Interface index for scope_id +#else + int _discovery_socket = -1; + int _unicast_discovery_socket = -1; // Socket for unicast discovery (reverse peering) + int _data_socket = -1; + unsigned int _if_index = 0; // Interface index for multicast +#endif + + // Peers and state + std::vector _peers; + double _last_announce = 0; + double _last_peer_job = 0; // Timestamp of last peer job check + + // Echo tracking (matches Python RNS multicast_echoes / initial_echoes) + double _last_multicast_echo = 0.0; // Timestamp of last own echo received + bool _initial_echo_received = false; // True once first echo received + bool _timed_out = false; // Current timeout state + bool _carrier_changed = false; // Flag for Transport layer notification + bool _firewall_warning_logged = false; // Track firewall warning (log once) + + // Deduplication: pairs of (packet_hash, timestamp) + struct DequeEntry { + RNS::Bytes hash; + double timestamp; + }; + std::deque _packet_deque; + + // Receive buffer + RNS::Bytes _buffer; +}; diff --git a/lib/auto_interface/AutoInterfacePeer.h b/lib/auto_interface/AutoInterfacePeer.h new file mode 100644 index 00000000..d5994e57 --- /dev/null +++ b/lib/auto_interface/AutoInterfacePeer.h @@ -0,0 +1,74 @@ +#pragma once + +#include +#include + +#ifdef ARDUINO +#include +#include +#else +#include +#include +#endif + +// Lightweight peer info holder for AutoInterface +// Not a full InterfaceImpl - just holds address and timing info +struct AutoInterfacePeer { + // IPv6 address storage +#ifdef ARDUINO + IPv6Address address; // Must use IPv6Address, not IPAddress (which is IPv4-only!) +#else + struct in6_addr address; +#endif + uint16_t data_port; + double last_heard; // Timestamp of last activity + double last_outbound; // Timestamp of last reverse peering sent + bool is_local; // True if this is our own announcement (to ignore) + + AutoInterfacePeer() : data_port(0), last_heard(0), last_outbound(0), is_local(false) { +#ifndef ARDUINO + memset(&address, 0, sizeof(address)); +#endif + } + +#ifdef ARDUINO + AutoInterfacePeer(const IPv6Address& addr, uint16_t port, double time, bool local = false) + : address(addr), data_port(port), last_heard(time), last_outbound(0), is_local(local) {} +#else + AutoInterfacePeer(const struct in6_addr& addr, uint16_t port, double time, bool local = false) + : address(addr), data_port(port), last_heard(time), last_outbound(0), is_local(local) {} +#endif + + // Get string representation of address for logging + std::string address_string() const { +#ifdef ARDUINO + // ESP32 IPAddress.toString() is IPv4-only, need manual IPv6 formatting + char buf[64]; + // Read 16 bytes from IPAddress + uint8_t addr[16]; + for (int i = 0; i < 16; i++) { + addr[i] = address[i]; + } + snprintf(buf, sizeof(buf), "%02x%02x:%02x%02x:%02x%02x:%02x%02x:%02x%02x:%02x%02x:%02x%02x:%02x%02x", + addr[0], addr[1], addr[2], addr[3], addr[4], addr[5], addr[6], addr[7], + addr[8], addr[9], addr[10], addr[11], addr[12], addr[13], addr[14], addr[15]); + return std::string(buf); +#else + char buf[INET6_ADDRSTRLEN]; + inet_ntop(AF_INET6, &address, buf, sizeof(buf)); + return std::string(buf); +#endif + } + + // Check if two peers have the same address +#ifdef ARDUINO + bool same_address(const IPv6Address& other) const { + // Use IPv6Address == operator (properly compares all 16 bytes) + return address == other; + } +#else + bool same_address(const struct in6_addr& other) const { + return memcmp(&address, &other, sizeof(address)) == 0; + } +#endif +}; diff --git a/lib/auto_interface/auto_config.h.example b/lib/auto_interface/auto_config.h.example new file mode 100644 index 00000000..5bc83f15 --- /dev/null +++ b/lib/auto_interface/auto_config.h.example @@ -0,0 +1,19 @@ +#pragma once + +// AutoInterface configuration - DO NOT COMMIT auto_config.h +// Copy auto_config.h.example to auto_config.h and fill in your values + +// WiFi credentials (for ESP32) +#define AUTO_WIFI_SSID "your_wifi_ssid" +#define AUTO_WIFI_PASSWORD "your_wifi_password" + +// Network interface to use (optional - leave empty for auto-detect) +// Examples: "eth0", "wlan0", "enp0s3" +#define AUTO_INTERFACE_NAME "" + +// Group ID (default: "reticulum" - must match Python RNS config) +#define AUTO_GROUP_ID "reticulum" + +// Ports (default values match Python RNS) +#define AUTO_DISCOVERY_PORT 29716 +#define AUTO_DATA_PORT 42671 diff --git a/lib/auto_interface/library.json b/lib/auto_interface/library.json new file mode 100644 index 00000000..c1116e4f --- /dev/null +++ b/lib/auto_interface/library.json @@ -0,0 +1,15 @@ +{ + "name": "auto_interface", + "version": "0.0.1", + "description": "AutoInterface implementation for peer discovery via IPv6 multicast", + "keywords": "reticulum, autointerface, ipv6, multicast", + "license": "MIT", + "frameworks": ["arduino"], + "platforms": ["espressif32"], + "dependencies": { + "microReticulum": "*" + }, + "build": { + "flags": "-std=gnu++11" + } +} \ No newline at end of file diff --git a/platformio.ini b/platformio.ini index 149ecdaf..18359a67 100644 --- a/platformio.ini +++ b/platformio.ini @@ -23,6 +23,7 @@ build_flags = -Wno-missing-field-initializers -Wno-format -I. + -I${PROJECT_DIR}/lib ; CBA Define following to disable DEBUG build ;-DNDEBUG ; CBA Define following to include RNS stack @@ -44,6 +45,7 @@ lib_deps = MsgPack@^0.4.2 adafruit/Adafruit SSD1306@^2.5.9 https://github.com/attermann/Crypto.git + auto_interface ; Exclude directories in root from sources build_src_filter = +<*> - extra_scripts = pre:extra_script.py From 949520bb5bbb46b38fbb6039d844c8cd14ebc9fb Mon Sep 17 00:00:00 2001 From: Pmmlabs Date: Mon, 23 Feb 2026 10:00:14 +0300 Subject: [PATCH 2/2] Start AutoInterface only if a board has WiFi --- RNode_Firmware.ino | 2 ++ 1 file changed, 2 insertions(+) diff --git a/RNode_Firmware.ino b/RNode_Firmware.ino index bd920ac9..7396fa02 100644 --- a/RNode_Firmware.ino +++ b/RNode_Firmware.ino @@ -583,6 +583,7 @@ void setup() { lora_interface.mode(RNS::Type::Interface::MODE_GATEWAY); RNS::Transport::register_interface(lora_interface); + #if HAS_WIFI INFO("Initializing AutoInterface (IPv6 peer discovery)..."); auto_interface_impl = new AutoInterface("Auto"); auto_interface = new RNS::Interface(auto_interface_impl); @@ -592,6 +593,7 @@ void setup() { INFO("AutoInterface started"); RNS::Transport::register_interface(*auto_interface); } + #endif HEAD("Creating Reticulum instance...", RNS::LOG_TRACE); reticulum = RNS::Reticulum();