feat: implement RNS transport (Smol Mail 1.2) over microReticulum

This commit is contained in:
randogoth 2026-09-28 15:52:37 +03:00
parent af1d31be83
commit 7203556186
19 changed files with 1939 additions and 1141 deletions

178
shim/smolmail_rns.cpp Normal file
View file

@ -0,0 +1,178 @@
/*
* microReticulum bridge for the bunshin RNS carrier (RNS.md sec 7).
*
* Owns the Reticulum instance, the smolmail.server IN/SINGLE destination
* with its request handler, and the loop thread that drives
* Reticulum::loop(). Every Rust callback fires on that thread; per-link
* teardowns decided inside a callback are deferred until after the current
* loop iteration so they never re-enter transport processing.
*/
#include "smolmail_rns.h"
#include "udp_interface.h"
#include <microStore/FileSystem.h>
#include <microStore/Adapters/UniversalFileSystem.h>
#include <MsgPack.h>
#include <microReticulum.h>
#include <cstring>
#include <mutex>
#include <thread>
#include <vector>
// Anchored in src/rns.rs.
static const char* APP_NAME = "smolmail";
static const char* APP_ASPECT = "server";
static const char* REQ_PATH = "smolmail/1";
// Interfaces rate-limit announces to roughly one an hour (RNS.md sec 1);
// every two hours stays well under that ceiling (upstream spec 13.1).
static const double ANNOUNCE_INTERVAL_SECS = 2.0 * 3600.0;
static const double LOOP_SLEEP_SECS = 0.01;
static RNS::Reticulum reticulum({RNS::Type::NONE});
static RNS::Interface udp_interface({RNS::Type::NONE});
static RNS::Destination destination({RNS::Type::NONE});
static microStore::FileSystem filesystem{microStore::Adapters::UniversalFileSystem()};
static std::mutex teardown_mutex;
static std::vector<RNS::Link> pending_teardowns;
static volatile bool running = false;
static void on_link_closed(RNS::Link& link) {
const RNS::Bytes& id = link.link_id();
smolmail_rns_on_link_closed(id.data());
}
static void on_link_established(RNS::Link& link) {
// link_id is the map key on the Rust side; the closed callback evicts.
link.set_link_closed_callback(on_link_closed);
const RNS::Bytes& id = link.link_id();
if (smolmail_rns_on_link_opened(id.data()) != 0) {
std::lock_guard<std::mutex> lock(teardown_mutex);
pending_teardowns.push_back(link);
}
}
// The request data arrives msgpack-bin-wrapped: Link::handle_request splices
// the generator's return value verbatim into the response envelope, so both
// directions must carry the smolmail payload as a msgpack binary.
static RNS::Bytes handle_smolmail_request(const RNS::Bytes& path, const RNS::Bytes& data,
const RNS::Bytes& request_id, const RNS::Bytes& link_id,
const RNS::Identity& remote_identity, double requested_at) {
(void)path; (void)request_id; (void)remote_identity; (void)requested_at;
RNS::Bytes request;
{
MsgPack::Unpacker u;
u.feed(data.data(), data.size());
if (u.isBin()) {
MsgPack::bin_t<uint8_t> bin;
u.deserialize(bin);
request = RNS::Bytes(bin.data(), bin.size());
}
}
size_t len = 0;
if (request) {
len = smolmail_rns_on_request(request.data(), request.size(), link_id.data());
}
RNS::Bytes response(len);
if (len > 0) {
smolmail_rns_take_response(response.writable(len), len);
}
else {
// The request was not a msgpack binary, or Rust produced nothing;
// always answer (upstream spec 13.5) with MALFORMED.
response = RNS::Bytes(1);
response.writable(1)[0] = 1;
}
MsgPack::Packer p;
p.packBinary(response.data(), response.size());
return RNS::Bytes(p.data(), p.size());
}
static void loop_thread_main() {
double last_announce = RNS::Utilities::OS::time();
destination.announce();
while (running) {
reticulum.loop();
std::vector<RNS::Link> teardowns;
{
std::lock_guard<std::mutex> lock(teardown_mutex);
teardowns.swap(pending_teardowns);
}
for (RNS::Link& link : teardowns) {
link.teardown();
}
double now = RNS::Utilities::OS::time();
if (now - last_announce >= ANNOUNCE_INTERVAL_SECS) {
destination.announce();
last_announce = now;
}
RNS::Utilities::OS::sleep(LOOP_SLEEP_SECS);
}
}
extern "C" int smolmail_rns_destination_hash(const uint8_t* identity,
uint8_t* destination_hash_out) {
RNS::Identity rns_identity(false);
if (!rns_identity.load_private_key(RNS::Bytes(identity, 64))) {
return -1;
}
const RNS::Bytes& hash = RNS::Destination::hash(rns_identity, APP_NAME, APP_ASPECT);
memcpy(destination_hash_out, hash.data(), 16);
return 0;
}
extern "C" int smolmail_rns_start(const uint8_t* identity,
const char* storage_dir,
const char* udp_listen_host, uint16_t udp_listen_port,
const char* udp_forward_host, uint16_t udp_forward_port,
uint8_t* destination_hash_out) {
if (running) {
return -1;
}
// Registered before anything else, as the interop examples do, so
// persistence and identity writes have a filesystem to go through.
filesystem.init();
RNS::Utilities::OS::register_filesystem(filesystem);
RNS::Reticulum::storagepath(storage_dir);
udp_interface = new UDPInterface("smolmail_rns_udp",
udp_listen_host, udp_listen_port,
udp_forward_host ? udp_forward_host : "",
udp_forward_port);
udp_interface.mode(RNS::Type::Interface::MODE_GATEWAY);
RNS::Transport::register_interface(udp_interface);
if (!udp_interface.start()) {
return -2;
}
reticulum = RNS::Reticulum();
reticulum.transport_enabled(false);
reticulum.start();
RNS::Identity rns_identity(false);
if (!rns_identity.load_private_key(RNS::Bytes(identity, 64))) {
return -3;
}
destination = RNS::Destination(rns_identity, RNS::Type::Destination::IN,
RNS::Type::Destination::SINGLE, APP_NAME, APP_ASPECT);
destination.accepts_links(true);
destination.register_request_handler(RNS::Bytes(REQ_PATH), handle_smolmail_request,
RNS::Type::Destination::ALLOW_ALL);
destination.set_link_established_callback(on_link_established);
memcpy(destination_hash_out, destination.hash().data(), 16);
running = true;
std::thread(loop_thread_main).detach();
return 0;
}

55
shim/smolmail_rns.h Normal file
View file

@ -0,0 +1,55 @@
/*
* C ABI between bunshin (Rust) and the microReticulum carrier shim.
*
* The shim owns the Reticulum run loop on a dedicated thread; every callback
* below fires on that thread. link_id is always 16 bytes.
*/
#ifndef SMOLMAIL_RNS_H
#define SMOLMAIL_RNS_H
#include <stddef.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
/* Implemented in smolmail_rns.cpp, called from src/rns.rs.
* identity: 64 bytes, x25519 private (32) || ed25519 private (32), the same
* layout Identity::to_file writes and load_private_key expects.
* udp_forward_host may be NULL: with no forward target the interface sends
* to the source address of the last datagram received.
* Returns 0 on success, negative on failure. */
int smolmail_rns_start(const uint8_t *identity,
const char *storage_dir,
const char *udp_listen_host, uint16_t udp_listen_port,
const char *udp_forward_host, uint16_t udp_forward_port,
uint8_t *destination_hash_out);
/* Pure computation, no Reticulum state: the 16-byte destination hash of
* smolmail.server under this identity. microReticulum derives the identity's
* x25519 public key with an unclamped scalar multiplication, which differs
* from RFC 7748, so the hash must come from the library itself rather than
* an independent Rust derivation. */
int smolmail_rns_destination_hash(const uint8_t *identity,
uint8_t *destination_hash_out);
/* Implemented in src/rns.rs, called from the shim on the Reticulum loop
* thread. A request is `op u8 || body` (RNS.md sec 1); the response placed
* into the slot is `status u8 || payload`. on_request returns the response
* length and leaves the bytes in the slot; the shim must copy them out with
* take_response before the next on_request call. */
size_t smolmail_rns_on_request(const uint8_t *request, size_t request_len,
const uint8_t *link_id);
size_t smolmail_rns_take_response(uint8_t *out, size_t cap);
/* 0 = link admitted, 1 = over the concurrent-link cap (the shim tears the
* link down after the current loop iteration). */
int smolmail_rns_on_link_opened(const uint8_t *link_id);
void smolmail_rns_on_link_closed(const uint8_t *link_id);
#ifdef __cplusplus
}
#endif
#endif /* SMOLMAIL_RNS_H */

198
shim/udp_interface.cpp Normal file
View file

@ -0,0 +1,198 @@
#include "udp_interface.h"
#include <microReticulum/Transport.h>
#include <microReticulum/Log.h>
#ifndef ARDUINO
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netdb.h>
#include <unistd.h>
#include <cerrno>
#include <cstring>
#endif
using namespace RNS;
UDPInterface::UDPInterface(const char* name,
const std::string& local_host, int local_port,
const std::string& remote_host, int remote_port)
: RNS::InterfaceImpl(name) {
_IN = true;
_OUT = true;
_bitrate = BITRATE_GUESS;
_HW_MTU = 1064;
_local_host = local_host;
_local_port = local_port;
if (!remote_host.empty()) {
_forward_configured = true;
_remote_host = remote_host;
_remote_port = remote_port;
}
}
/*virtual*/ UDPInterface::~UDPInterface() {
stop();
}
/*virtual*/ bool UDPInterface::start() {
_online = false;
#ifdef ARDUINO
udp.begin(_local_port);
#else
// resolve local host
struct in_addr local_addr;
if (inet_aton(_local_host.c_str(), &local_addr) == 0) {
struct hostent* host_ent = gethostbyname(_local_host.c_str());
if (host_ent == nullptr || host_ent->h_addr_list[0] == nullptr) {
ERRORF("Unable to resolve local host %s", _local_host.c_str());
return false;
}
_local_address = *((in_addr_t*)(host_ent->h_addr_list[0]));
}
else {
_local_address = local_addr.s_addr;
}
_remote_address = INADDR_NONE;
if (_forward_configured) {
struct in_addr remote_addr;
if (inet_aton(_remote_host.c_str(), &remote_addr) == 0) {
struct hostent* host_ent = gethostbyname(_remote_host.c_str());
if (host_ent == nullptr || host_ent->h_addr_list[0] == nullptr) {
ERRORF("Unable to resolve remote host %s", _remote_host.c_str());
return false;
}
_remote_address = *((in_addr_t*)(host_ent->h_addr_list[0]));
}
else {
_remote_address = remote_addr.s_addr;
}
}
_socket = socket( PF_INET, SOCK_DGRAM, 0 );
if (_socket < 0) {
ERRORF("Unable to create socket with error %d", errno);
return false;
}
int broadcast = 1;
setsockopt(_socket, SOL_SOCKET, SO_BROADCAST, &broadcast, sizeof(broadcast));
int reuse = 1;
setsockopt(_socket, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));
#ifdef SO_REUSEPORT
setsockopt(_socket, SOL_SOCKET, SO_REUSEPORT, &reuse, sizeof(reuse));
#endif
INFOF("Binding UDP socket %d to %s:%d", _socket, _local_host.c_str(), _local_port);
sockaddr_in bind_addr;
memset(&bind_addr, 0, sizeof(bind_addr));
bind_addr.sin_family = AF_INET;
bind_addr.sin_addr.s_addr = _local_address;
bind_addr.sin_port = htons(_local_port);
if (bind(_socket, (struct sockaddr*)&bind_addr, sizeof(bind_addr)) == -1) {
ERRORF("Unable to bind socket with error %d", errno);
close(_socket);
_socket = -1;
return false;
}
#endif
_online = true;
return true;
}
/*virtual*/ void UDPInterface::stop() {
#ifndef ARDUINO
if (_socket > -1) {
close(_socket);
_socket = -1;
}
#endif
_online = false;
}
/*virtual*/ void UDPInterface::loop() {
if (!_online) {
return;
}
#ifdef ARDUINO
udp.parsePacket();
size_t len = udp.read(_buffer.writable(Type::Reticulum::MTU), Type::Reticulum::MTU);
if (len > 0) {
_buffer.resize(len);
on_incoming(_buffer);
}
#else
// One datagram per recvfrom() with MSG_DONTWAIT, looping until the
// kernel queue is empty — the same drain pattern as the microReticulum
// example, which avoids stale/zero FIONREAD counts on some platforms.
while (true) {
sockaddr_in src_addr{};
socklen_t src_addr_len = sizeof(src_addr);
ssize_t len = recvfrom(_socket,
_buffer.writable(_HW_MTU),
_HW_MTU,
MSG_DONTWAIT,
(struct sockaddr*)&src_addr,
&src_addr_len);
if (len <= 0) {
break;
}
_buffer.resize(static_cast<size_t>(len));
if (!_forward_configured) {
_last_src_addr = src_addr;
_have_src = true;
}
on_incoming(_buffer);
}
#endif
}
/*virtual*/ bool UDPInterface::send_outgoing(const RNS::Bytes& data) {
bool success = true;
try {
if (_online) {
#ifdef ARDUINO
udp.beginPacket(_remote_host.c_str(), _remote_port);
udp.write(data.data(), data.size());
udp.endPacket();
#else
sockaddr_in sock_addr;
if (_forward_configured) {
memset(&sock_addr, 0, sizeof(sock_addr));
sock_addr.sin_family = AF_INET;
sock_addr.sin_addr.s_addr = _remote_address;
sock_addr.sin_port = htons(_remote_port);
}
else if (_have_src) {
// No forward target: reply to whoever spoke to us last.
sock_addr = _last_src_addr;
}
else {
WARNING("UDPInterface: no forward target and no peer heard from yet, dropping outgoing packet");
return false;
}
ssize_t sent = sendto(_socket, data.data(), data.size(), 0, (struct sockaddr*)&sock_addr, sizeof(sock_addr));
if (sent != (ssize_t)data.size()) {
WARNINGF("Failed sending %d bytes via UDP", (int)data.size());
success = false;
}
#endif
}
InterfaceImpl::handle_outgoing(data);
}
catch (const std::exception& e) {
ERRORF("Could not transmit on %s. The contained exception was: %s", toString().c_str(), e.what());
success = false;
}
return success;
}
void UDPInterface::on_incoming(const RNS::Bytes& data) {
InterfaceImpl::handle_incoming(data);
}

64
shim/udp_interface.h Normal file
View file

@ -0,0 +1,64 @@
/*
* UDP interface for the bunshin RNS shim, adapted from microReticulum's
* examples/common/udp_interface (Apache-2.0). The example version hardcodes
* compile-time defaults; this one takes its endpoints from the caller, and
* with no forward target configured it sends to the source address of the
* last datagram received, so a listen-only server can answer any client.
*/
#pragma once
#include <microReticulum/Interface.h>
#include <microReticulum/Bytes.h>
#include <microReticulum/Type.h>
#ifdef ARDUINO
#include <WiFi.h>
#include <WiFiUdp.h>
#else
#include <netinet/in.h>
#endif
#include <stdint.h>
#include <string>
class UDPInterface : public RNS::InterfaceImpl {
public:
static const uint32_t BITRATE_GUESS = 10*1000*1000;
UDPInterface(const char* name,
const std::string& local_host, int local_port,
const std::string& remote_host, int remote_port);
virtual ~UDPInterface();
virtual bool start();
virtual void stop();
virtual void loop();
virtual inline std::string toString() const { return "UDPInterface[" + _name + "/" + _local_host + ":" + std::to_string(_local_port) + "]"; }
protected:
virtual bool send_outgoing(const RNS::Bytes& data);
void on_incoming(const RNS::Bytes& data);
private:
RNS::Bytes _buffer;
std::string _local_host;
int _local_port;
std::string _remote_host;
int _remote_port;
bool _forward_configured = false;
#ifdef ARDUINO
WiFiUDP udp;
#else
int _socket = -1;
in_addr_t _local_address = INADDR_ANY;
in_addr_t _remote_address = INADDR_NONE;
sockaddr_in _last_src_addr = {};
bool _have_src = false;
#endif
};