Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,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
Expand Down Expand Up @@ -1037,6 +1042,20 @@ 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();
if (!udpUnicastConnector->start(portduino_config.udp_unicast_peer)) {
delete udpUnicastConnector;
udpUnicastConnector = nullptr;
}
}
Comment thread
Copilot marked this conversation as resolved.
#endif

service = new MeshService();
service->init();

Expand Down
5 changes: 5 additions & 0 deletions src/main.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
2 changes: 1 addition & 1 deletion src/mesh/Channels.h
Original file line number Diff line number Diff line change
Expand Up @@ -112,13 +112,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);

Expand Down
78 changes: 54 additions & 24 deletions src/mesh/Router.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,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);
}
Expand Down Expand Up @@ -451,6 +458,33 @@ 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;
}
#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
bool checkXeddsaReceivePolicy(meshtastic_MeshPacket *p)
{
Expand Down Expand Up @@ -588,30 +622,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_DEBUG("Packet decrypted using channel %d", chIndex);
break;
}
Comment thread
Copilot marked this conversation as resolved.

// 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) {
Comment thread
jp-bennett marked this conversation as resolved.
LOG_DEBUG("Packet decrypted using channel %d with UDP bypass", chIndex);
break;
Comment thread
Copilot marked this conversation as resolved.
}
}
Expand Down
149 changes: 149 additions & 0 deletions src/mesh/udp/UdpUnicastConnector.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
#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 <atomic>
#include <cstdlib>
#include <string>
#include <thread>

#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <unistd.h>

#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());
}
Comment on lines +42 to +48
Comment on lines +45 to +48

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Validate string-to-integer conversion.

The atoi function performs no error detection and returns 0 on non-numeric input, which evaluates to an invalid port. Use std::stoi in a try-catch block to properly handle malformed input.

🛠️ Proposed fix
         auto colon = peer.rfind(':');
         if (colon != std::string::npos) {
             host = peer.substr(0, colon);
-            port = (uint16_t)atoi(peer.substr(colon + 1).c_str());
+            try {
+                port = (uint16_t)std::stoi(peer.substr(colon + 1));
+            } catch (...) {
+                LOG_ERROR("UDP unicast: bad port in peer address '%s'", peer.c_str());
+                return false;
+            }
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (colon != std::string::npos) {
host = peer.substr(0, colon);
port = (uint16_t)atoi(peer.substr(colon + 1).c_str());
}
auto colon = peer.rfind(':');
if (colon != std::string::npos) {
host = peer.substr(0, colon);
try {
port = (uint16_t)std::stoi(peer.substr(colon + 1));
} catch (...) {
LOG_ERROR("UDP unicast: bad port in peer address '%s'", peer.c_str());
return false;
}
}
🧰 Tools
🪛 ast-grep (0.44.1)

[warning] 46-46: Avoid atoi/atol/atoll/atof: they perform no error detection, returning 0 on non-numeric input and invoking undefined behavior on out-of-range values. This rule flags every use of these functions regardless of data provenance. Convert strings with strtol/strtoul/strtod and check errno (and the endptr) so malformed or overflowing input is rejected.
Context: atoi(peer.substr(colon + 1).c_str())
Note: [CWE-20] Improper Input Validation.

(atoi-no-error-detection-c)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/mesh/udp/UdpUnicastConnector.h` around lines 45 - 48, Replace the
unchecked atoi conversion in the peer parsing logic with std::stoi inside a
try-catch block, validating malformed port input and handling conversion errors
explicitly before assigning the result to port. Preserve the existing host
extraction and valid port parsing behavior in the colon branch.

Source: Linters/SAST tools


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<sockaddr *>(&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_UNICAST_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;
#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);
Comment thread
Copilot marked this conversation as resolved.
}

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_UNICAST_UDP;
UniquePacketPoolPacket p = packetPool.allocUniqueCopy(mp);
p->rx_snr = 0;
Comment thread
jp-bennett marked this conversation as resolved.
p->rx_rssi = 0;
if (router)
router->enqueueReceivedMessage(p.release());
}
}

int fd = -1;
std::atomic<bool> isRunning{false};
std::thread rxThread;
};
#endif // ARCH_PORTDUINO
1 change: 1 addition & 0 deletions src/platform/portduino/PortduinoGlue.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1171,6 +1171,7 @@ bool loadConfig(const char *configPath)
TCPPort = (portduino_config.api_port);
}
}
portduino_config.udp_unicast_peer = (yamlConfig["General"]["UDPUnicastPeer"]).as<std::string>("");
portduino_config.mac_address = (yamlConfig["General"]["MACAddress"]).as<std::string>("");
if (portduino_config.mac_address != "") {
portduino_config.mac_address_explicit = true;
Expand Down
3 changes: 3 additions & 0 deletions src/platform/portduino/PortduinoGlue.h
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,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
Comment thread
jp-bennett marked this conversation as resolved.
std::string config_directory = "";
std::string available_directory = "/etc/meshtasticd/available.d/";
int maxtophone = 100;
Expand Down Expand Up @@ -641,6 +642,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 != "")
Expand Down
Loading