From ac1f7f0f8013188d1c29edf6b2dd909646931069 Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Mon, 8 Jun 2026 16:48:18 -0500 Subject: [PATCH 1/9] UDP Unicast for Meshtasticd --- src/main.cpp | 16 +++ src/main.h | 5 + src/mesh/Channels.h | 2 +- src/mesh/Router.cpp | 79 +++++++++---- src/mesh/udp/UdpUnicastConnector.h | 144 +++++++++++++++++++++++ src/platform/portduino/PortduinoGlue.cpp | 1 + src/platform/portduino/PortduinoGlue.h | 3 + 7 files changed, 225 insertions(+), 25 deletions(-) create mode 100644 src/mesh/udp/UdpUnicastConnector.h diff --git a/src/main.cpp b/src/main.cpp index 2541c7c20ce..c9aec9f65bd 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -160,6 +160,11 @@ extern void tftSetup(void); UdpMulticastHandler *udpHandler = nullptr; #endif +#ifdef ARCH_PORTDUINO +#include "mesh/udp/UdpUnicastConnector.h" +UdpUnicastConnector *udpUnicastConnector = nullptr; +#endif + #if defined(TCXO_OPTIONAL) float tcxoVoltage = SX126X_DIO3_TCXO_VOLTAGE; // if TCXO is optional, put this here so it can be changed further down. #endif @@ -932,6 +937,17 @@ void setup() } #endif #endif + +#ifdef ARCH_PORTDUINO + // Native unicast-UDP connector to the meshswitch routing daemon, enabled by setting a peer + // address in config.yaml (General -> UDPUnicastPeer). Independent of UDP multicast. + if (!portduino_config.udp_unicast_peer.empty()) { + LOG_DEBUG("Start UDP unicast connector"); + udpUnicastConnector = new UdpUnicastConnector(); + udpUnicastConnector->start(portduino_config.udp_unicast_peer); + } +#endif + service = new MeshService(); service->init(); diff --git a/src/main.h b/src/main.h index d6439f1d1c9..4a2f4be0083 100644 --- a/src/main.h +++ b/src/main.h @@ -67,6 +67,11 @@ extern AudioThread *audioThread; extern UdpMulticastHandler *udpHandler; #endif +#ifdef ARCH_PORTDUINO +#include "mesh/udp/UdpUnicastConnector.h" +extern UdpUnicastConnector *udpUnicastConnector; +#endif + // Global Screen singleton. extern graphics::Screen *screen; diff --git a/src/mesh/Channels.h b/src/mesh/Channels.h index a3cc7791c7c..3a339643f69 100644 --- a/src/mesh/Channels.h +++ b/src/mesh/Channels.h @@ -98,13 +98,13 @@ class Channels int16_t getHash(ChannelIndex i) { return hashes[i]; } - private: /** Given a channel index, change to use the crypto key specified by that index * * @eturn the (0 to 255) hash for that channel - if no suitable channel could be found, return -1 */ int16_t setCrypto(ChannelIndex chIndex); + private: /** Return the channel index for the specified channel hash, or -1 for not found */ int8_t getIndexByHash(ChannelHash channelHash); diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index 300dcb0f6ff..d0f4e3e2e23 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -434,6 +434,13 @@ ErrorCode Router::send(meshtastic_MeshPacket *p) } #endif +#ifdef ARCH_PORTDUINO + // Mirror every outbound packet to the meshswitch routing daemon, if a unicast peer is configured. + if (udpUnicastConnector) { + udpUnicastConnector->onSend(p); + } +#endif + assert(iface); // This should have been detected already in sendLocal (or we just received a packet from outside) return iface->send(p); } @@ -464,6 +471,34 @@ void Router::sniffReceived(const meshtastic_MeshPacket *p, const meshtastic_Rout // FIXME, update nodedb here for any packet that passes through us } +bool attemptAESDecrypt(meshtastic_MeshPacket *p, size_t rawSize) +{ + memcpy(bytes, p->encrypted.bytes, rawSize); + // Try to decrypt the packet if we can + crypto->decrypt(p->from, p->id, rawSize, bytes); + + // printBytes("plaintext", bytes, p->encrypted.size); + + // Take those raw bytes and convert them back into a well structured protobuf we can understand + meshtastic_Data decodedtmp; + memset(&decodedtmp, 0, sizeof(decodedtmp)); + if (!pb_decode_from_bytes(bytes, rawSize, &meshtastic_Data_msg, &decodedtmp)) { + LOG_DEBUG("Invalid protobufs in received mesh packet id=0x%08x (bad psk?)", p->id); + } else if (decodedtmp.portnum == meshtastic_PortNum_UNKNOWN_APP) { + LOG_DEBUG("Invalid portnum (bad psk?)"); +#if !(MESHTASTIC_EXCLUDE_PKI) + } else if (!owner.is_licensed && isToUs(p) && decodedtmp.portnum == meshtastic_PortNum_TEXT_MESSAGE_APP) { + LOG_WARN("Rejecting legacy DM"); + return false; +#endif + } else { + p->decoded = decodedtmp; + p->which_payload_variant = meshtastic_MeshPacket_decoded_tag; // change type to decoded + return true; + } + return false; +} + DecodeState perhapsDecode(meshtastic_MeshPacket *p) { concurrency::LockGuard g(cryptLock); @@ -523,30 +558,26 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p) for (chIndex = 0; chIndex < channels.getNumChannels(); chIndex++) { // Try to use this hash/channel pair if (channels.decryptForHash(chIndex, p->channel)) { - // we have to copy into a scratch buffer, because these bytes are a union with the decoded protobuf. Create a - // fresh copy for each decrypt attempt. - memcpy(bytes, p->encrypted.bytes, rawSize); - // Try to decrypt the packet if we can - crypto->decrypt(p->from, p->id, rawSize, bytes); - - // printBytes("plaintext", bytes, p->encrypted.size); - - // Take those raw bytes and convert them back into a well structured protobuf we can understand - meshtastic_Data decodedtmp; - memset(&decodedtmp, 0, sizeof(decodedtmp)); - if (!pb_decode_from_bytes(bytes, rawSize, &meshtastic_Data_msg, &decodedtmp)) { - LOG_DEBUG("Invalid protobufs in received mesh packet id=0x%08x (bad psk?)", p->id); - } else if (decodedtmp.portnum == meshtastic_PortNum_UNKNOWN_APP) { - LOG_DEBUG("Invalid portnum (bad psk?)"); -#if !(MESHTASTIC_EXCLUDE_PKI) - } else if (!owner.is_licensed && isToUs(p) && decodedtmp.portnum == meshtastic_PortNum_TEXT_MESSAGE_APP) { - LOG_WARN("Rejecting legacy DM"); - return DecodeState::DECODE_FAILURE; -#endif - } else { - p->decoded = decodedtmp; - p->which_payload_variant = meshtastic_MeshPacket_decoded_tag; // change type to decoded - decrypted = true; + decrypted = attemptAESDecrypt(p, rawSize); + if (decrypted) { + LOG_INFO("Packet decrypted using channel %d!", chIndex); + break; + } + + // We have a sticky problem here in a specific case. + // If we're routing packets between presets over UDP + // The channel hash may be bogus. + // So we probably need to try to decrypt with every channel + // even if the hash doesn't match, to see if any of them work. + // If we find one that works, we can just re-write the hash + } else if (p->transport_mechanism == meshtastic_MeshPacket_TransportMechanism_TRANSPORT_UNICAST_UDP) { + // On Portduino, we allow UDP packets to bypass the channel hash check, since the hash may not be + // correct for packets coming over UDP from the meshswitch daemon. + // This allows us to support routing between presets over UDP even if the channel hashes don't match. + channels.setCrypto(chIndex); + decrypted = attemptAESDecrypt(p, rawSize); + if (decrypted) { + LOG_INFO("Packet decrypted using channel %d with UDP bypass!", chIndex); break; } } diff --git a/src/mesh/udp/UdpUnicastConnector.h b/src/mesh/udp/UdpUnicastConnector.h new file mode 100644 index 00000000000..32975e77422 --- /dev/null +++ b/src/mesh/udp/UdpUnicastConnector.h @@ -0,0 +1,144 @@ +#pragma once +#ifdef ARCH_PORTDUINO +// Native unicast-UDP connector for meshtasticd. Forwards every outbound MeshPacket to a single +// configured peer (the meshswitch routing daemon) and injects packets received back from it, +// routing on the cleartext header exactly like the multicast handler. This is a meshtasticd-only +// (Linux) feature, so it uses plain POSIX sockets directly rather than the portduino AsyncUDP +// library. +// +// meshtasticd is the client here: it connect()s one UDP socket to the peer, send()s outbound +// packets, and recv()s the daemon's replies on that same socket (the daemon always answers the +// source endpoint it last heard from, so no well-known listen port is required). + +#include "configuration.h" +#include "main.h" +#include "mesh/Router.h" +#include "mesh/mesh-pb-constants.h" + +#include +#include +#include +#include + +#include +#include +#include +#include + +#define UDP_UNICAST_DEFAULT_PORT 4403 // matches the daemon's default listen port + +class UdpUnicastConnector final +{ + public: + UdpUnicastConnector() = default; + ~UdpUnicastConnector() { stop(); } + + // peer is "host" or "host:port" (IPv4 literal). Returns true once connected. + bool start(const std::string &peer) + { + if (isRunning) + return true; + + std::string host = peer; + uint16_t port = UDP_UNICAST_DEFAULT_PORT; + auto colon = peer.rfind(':'); + if (colon != std::string::npos) { + host = peer.substr(0, colon); + port = (uint16_t)atoi(peer.substr(colon + 1).c_str()); + } + + sockaddr_in addr = {}; + addr.sin_family = AF_INET; + addr.sin_port = htons(port); + if (inet_pton(AF_INET, host.c_str(), &addr.sin_addr) != 1) { + LOG_ERROR("UDP unicast: bad peer address '%s'", peer.c_str()); + return false; + } + + fd = socket(AF_INET, SOCK_DGRAM, 0); + if (fd < 0) { + LOG_ERROR("UDP unicast: socket() failed"); + return false; + } + // connect() pins the destination for send() and filters recv() to datagrams from the peer. + if (::connect(fd, reinterpret_cast(&addr), sizeof(addr)) < 0) { + LOG_ERROR("UDP unicast: connect() to %s:%u failed", host.c_str(), port); + ::close(fd); + fd = -1; + return false; + } + + isRunning = true; + rxThread = std::thread([this]() { receiveLoop(); }); + LOG_INFO("UDP unicast connector to %s:%u started", host.c_str(), port); + return true; + } + + void stop() + { + if (!isRunning) + return; + isRunning = false; + if (fd >= 0) + ::shutdown(fd, SHUT_RDWR); // unblock the recv() in the rx thread + if (rxThread.joinable()) + rxThread.join(); + if (fd >= 0) { + ::close(fd); + fd = -1; + } + } + + // Called from Router::send for every outbound packet. + void onSend(const meshtastic_MeshPacket *mp) + { + if (!isRunning || fd < 0 || !mp) + return; + // Never reflect a packet that arrived over UDP straight back out over UDP. The daemon's + // (from,id) dedup is the backstop if this ever slips through. + if (mp->transport_mechanism == meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MULTICAST_UDP) + return; + + uint8_t buffer[meshtastic_MeshPacket_size]; + size_t len = pb_encode_to_bytes(buffer, sizeof(buffer), &meshtastic_MeshPacket_msg, mp); + if (len == 0) + return; + if (::send(fd, buffer, len, MSG_NOSIGNAL) < 0) + LOG_DEBUG("UDP unicast: send() failed for packet id=%u", mp->id); + } + + private: + void receiveLoop() + { + uint8_t buf[meshtastic_MeshPacket_size]; + while (isRunning) { + ssize_t n = ::recv(fd, buf, sizeof(buf), 0); + if (n <= 0) { + if (!isRunning) + break; + continue; // transient error or shutdown in progress + } + meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero; + if (!pb_decode_from_bytes(buf, (size_t)n, &meshtastic_MeshPacket_msg, &mp)) + continue; + if (mp.which_payload_variant != meshtastic_MeshPacket_encrypted_tag) + continue; + // Drop packets with spoofed local origin — same guard as the multicast handler. + if (isFromUs(&mp)) { + LOG_WARN("UDP unicast packet with spoofed local from=0x%x, dropping", mp.from); + continue; + } + mp.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MULTICAST_UDP; + UniquePacketPoolPacket p = packetPool.allocUniqueCopy(mp); + p->rx_snr = 0; + p->rx_rssi = 0; + if (router) + router->enqueueReceivedMessage(p.release()); + } + } + + int fd = -1; + std::atomic isRunning{false}; + std::thread rxThread; +}; +#endif // ARCH_PORTDUINO diff --git a/src/platform/portduino/PortduinoGlue.cpp b/src/platform/portduino/PortduinoGlue.cpp index c3cb1c780df..db90c24d959 100644 --- a/src/platform/portduino/PortduinoGlue.cpp +++ b/src/platform/portduino/PortduinoGlue.cpp @@ -1063,6 +1063,7 @@ bool loadConfig(const char *configPath) TCPPort = (portduino_config.api_port); } } + portduino_config.udp_unicast_peer = (yamlConfig["General"]["UDPUnicastPeer"]).as(""); portduino_config.mac_address = (yamlConfig["General"]["MACAddress"]).as(""); if (portduino_config.mac_address != "") { portduino_config.mac_address_explicit = true; diff --git a/src/platform/portduino/PortduinoGlue.h b/src/platform/portduino/PortduinoGlue.h index b38cfca25ee..714ada10848 100644 --- a/src/platform/portduino/PortduinoGlue.h +++ b/src/platform/portduino/PortduinoGlue.h @@ -192,6 +192,7 @@ extern struct portduino_config_struct { bool mac_address_explicit = false; std::string mac_address_source = ""; int api_port = -1; + std::string udp_unicast_peer = ""; // meshswitch routing daemon peer, "host" or "host:port"; empty = disabled std::string config_directory = ""; std::string available_directory = "/etc/meshtasticd/available.d/"; int maxtophone = 100; @@ -558,6 +559,8 @@ extern struct portduino_config_struct { out << YAML::Key << "ConfigDirectory" << YAML::Value << config_directory; if (api_port != -1) out << YAML::Key << "TCPPort" << YAML::Value << api_port; + if (udp_unicast_peer != "") + out << YAML::Key << "UDPUnicastPeer" << YAML::Value << udp_unicast_peer; if (mac_address_explicit) out << YAML::Key << "MACAddress" << YAML::Value << mac_address; if (mac_address_source != "") From bcd9b5508f5bcdedae379027b4b710bb7cd26450 Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Mon, 8 Jun 2026 16:50:58 -0500 Subject: [PATCH 2/9] trunk From fe615d7bf1b5da82195065233613c40c3796f846 Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Mon, 8 Jun 2026 18:34:25 -0500 Subject: [PATCH 3/9] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/main.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main.cpp b/src/main.cpp index c9aec9f65bd..a0a1ddeeb31 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -944,7 +944,10 @@ void setup() if (!portduino_config.udp_unicast_peer.empty()) { LOG_DEBUG("Start UDP unicast connector"); udpUnicastConnector = new UdpUnicastConnector(); - udpUnicastConnector->start(portduino_config.udp_unicast_peer); + if (!udpUnicastConnector->start(portduino_config.udp_unicast_peer)) { + delete udpUnicastConnector; + udpUnicastConnector = nullptr; + } } #endif From e05b112f6851d8883654bd9d669a180727ddc25f Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Mon, 8 Jun 2026 18:34:51 -0500 Subject: [PATCH 4/9] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/mesh/Router.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index d0f4e3e2e23..c0b5982e40d 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -560,7 +560,7 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p) if (channels.decryptForHash(chIndex, p->channel)) { decrypted = attemptAESDecrypt(p, rawSize); if (decrypted) { - LOG_INFO("Packet decrypted using channel %d!", chIndex); + LOG_DEBUG("Packet decrypted using channel %d", chIndex); break; } From dad92dea9ef5883940d8eddd1410300379756355 Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Mon, 8 Jun 2026 18:35:35 -0500 Subject: [PATCH 5/9] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/mesh/udp/UdpUnicastConnector.h | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/mesh/udp/UdpUnicastConnector.h b/src/mesh/udp/UdpUnicastConnector.h index 32975e77422..33f9fc18e21 100644 --- a/src/mesh/udp/UdpUnicastConnector.h +++ b/src/mesh/udp/UdpUnicastConnector.h @@ -103,7 +103,12 @@ class UdpUnicastConnector final size_t len = pb_encode_to_bytes(buffer, sizeof(buffer), &meshtastic_MeshPacket_msg, mp); if (len == 0) return; - if (::send(fd, buffer, len, MSG_NOSIGNAL) < 0) +#ifdef MSG_NOSIGNAL + constexpr int kSendFlags = MSG_NOSIGNAL; +#else + constexpr int kSendFlags = 0; +#endif + if (::send(fd, buffer, len, kSendFlags) < 0) LOG_DEBUG("UDP unicast: send() failed for packet id=%u", mp->id); } From 8e29d44e19729aa7c19b4447655e0532d917e654 Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Mon, 8 Jun 2026 18:35:46 -0500 Subject: [PATCH 6/9] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/mesh/Router.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index c0b5982e40d..d5cd4eb268a 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -577,9 +577,10 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p) channels.setCrypto(chIndex); decrypted = attemptAESDecrypt(p, rawSize); if (decrypted) { - LOG_INFO("Packet decrypted using channel %d with UDP bypass!", chIndex); + LOG_DEBUG("Packet decrypted using channel %d with UDP bypass", chIndex); break; } + } } } } From b8edc9ca96076b52049267857008179768ed3ce1 Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Mon, 8 Jun 2026 16:50:58 -0500 Subject: [PATCH 7/9] trunk From 708a1946753fb1eae645698bb9fcfbf86e84c523 Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Mon, 8 Jun 2026 18:42:07 -0500 Subject: [PATCH 8/9] Fix double bracket --- src/mesh/Router.cpp | 1 - 1 file changed, 1 deletion(-) diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index d5cd4eb268a..841dd5ceb00 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -580,7 +580,6 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p) LOG_DEBUG("Packet decrypted using channel %d with UDP bypass", chIndex); break; } - } } } } From 689e290670b18dd0f917830317956452a876c477 Mon Sep 17 00:00:00 2001 From: Jonathan Bennett Date: Mon, 8 Jun 2026 19:17:02 -0500 Subject: [PATCH 9/9] Use the right transport --- src/mesh/udp/UdpUnicastConnector.h | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/mesh/udp/UdpUnicastConnector.h b/src/mesh/udp/UdpUnicastConnector.h index 33f9fc18e21..0b0c5f408dc 100644 --- a/src/mesh/udp/UdpUnicastConnector.h +++ b/src/mesh/udp/UdpUnicastConnector.h @@ -96,7 +96,7 @@ class UdpUnicastConnector final return; // Never reflect a packet that arrived over UDP straight back out over UDP. The daemon's // (from,id) dedup is the backstop if this ever slips through. - if (mp->transport_mechanism == meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MULTICAST_UDP) + if (mp->transport_mechanism == meshtastic_MeshPacket_TransportMechanism_TRANSPORT_UNICAST_UDP) return; uint8_t buffer[meshtastic_MeshPacket_size]; @@ -133,7 +133,7 @@ class UdpUnicastConnector final LOG_WARN("UDP unicast packet with spoofed local from=0x%x, dropping", mp.from); continue; } - mp.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MULTICAST_UDP; + mp.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_UNICAST_UDP; UniquePacketPoolPacket p = packetPool.allocUniqueCopy(mp); p->rx_snr = 0; p->rx_rssi = 0;