diff --git a/README.md b/README.md index 3b0e551..fde317d 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A Rust client for [Smol Mail](https://code.randogoth.com/randogoth/smolmail): a ## Status -Phase 1, the TCP carrier (SPEC.md 1.1), is implemented and interoperates in both directions with the reference client `smolmail.py` and the reference server `smolmaild.py`, and with `bunshin`. The Reticulum carrier of RNS.md (1.2) is not built yet: `smol+rns://` addresses parse and fail with a message naming the `rns` feature, and the flake does not yet vendor microReticulum. +Both carriers are implemented: the TCP carrier (SPEC.md 1.1) and the Reticulum carrier of RNS.md (1.2), the latter behind the `rns` cargo feature (`nix build .#rns`). `fumi` interoperates in both directions with the reference stack — `smolmail.py`/`smolmaild.py` over TCP and `smolmail_rns.py`/`smolmaild_rns.py` over Reticulum — and with `bunshin` over both carriers, including the cross-transport case upstream section 15 promises: send over TCP, fetch over the mesh, one mailbox. ## Addressing @@ -45,8 +45,14 @@ fumi fetch [--keep] [--reset] retrieve, verify, store, acknowledge fumi delete ... remove from the server explicitly fumi list [--sent] [--requests] fumi read [--sent] + +# RNS carrier (built with --features rns / nix build .#rns); addresses select +# it by scheme, so these flags apply only to smol+rns:// dials: +fumi --rns-udp --rns-udp-forward [--rns-storage DIR] ``` +On the mesh the destination hash is the pin (RNS.md 13.4): there is no `trust`, no static key, and a client creates no Reticulum identity (13.8). The `--rns-udp-forward` target is effectively required for a client, which speaks first; it should point at an `rnsd` or at the server's UDP interface. Requests carry an explicit 30-second timeout (13.5), links are torn down when a session ends (13.10), and a resend is safe because ids are derived. + Mail is stored sealed and opened on demand; there is no plaintext at rest. A first contact is learned by trust on first use and marked unverified when the server's key was not pinned; a key change is accepted only when a signed rotation chain leads from the key held to the one offered, and is surfaced rather than applied silently. ## Build @@ -68,6 +74,7 @@ nix develop -c cargo test | `src/message.rs` | envelope seal/open (§5.1–§5.3), message id (§5.4), frontmatter (§5.5) | | `src/transport.rs` | `Transport` trait, bind values, operation and status constants | | `src/tcp.rs` | Noise_NX initiator, `len u32 \|\| op u8 \|\| body` framing, static-key pinning | +| `src/rns/` | the Reticulum carrier behind the `rns` feature: FFI over `shim/`, path discovery, one link per session | | `src/client.rs` | the six operations, AUTH, chain walking, token sync, fetch pipeline | | `src/store.rs` | SQLite: state, contacts, accepted, tokens, seen ids, inbox, sent | diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..0c3601a --- /dev/null +++ b/build.rs @@ -0,0 +1,187 @@ +//! Builds the microReticulum client-carrier shim when the `rns` feature is on. +//! +//! The library source is not vendored into this repository: the environment +//! must point at a checkout via MICRORETICULUM_SOURCE_DIR (the Nix flake and +//! the dev shell both set it). The source tree is copied into OUT_DIR and +//! patched there — a one-line `` include the upstream master needs +//! under current libstdc++/libc++ — because the input may be read-only and +//! both build paths should compile identical sources. + +#[cfg(feature = "rns")] +fn main() { + println!("cargo:rerun-if-changed=shim/smolmail_rns.cpp"); + println!("cargo:rerun-if-changed=shim/udp_interface.cpp"); + println!("cargo:rerun-if-env-changed=MICRORETICULUM_SOURCE_DIR"); + for dep in DEP_VARS { + println!("cargo:rerun-if-env-changed={dep}"); + } + + let out_dir = std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap()); + let source = std::env::var("MICRORETICULUM_SOURCE_DIR") + .expect("the rns feature requires MICRORETICULUM_SOURCE_DIR (a microReticulum checkout)"); + + // Copy the parts cmake needs: the root CMakeLists.txt and src/. + let copy = out_dir.join("microReticulum"); + copy_tree( + std::path::Path::new(&source), + ©, + &["CMakeLists.txt", "src", "LICENSE"], + ); + + // Upstream master (2026-07-20) relies on transitive includes + // that libstdc++ 15 dropped; add the include itself. + let memory_h = copy.join("src/microReticulum/Utilities/Memory.h"); + let text = std::fs::read_to_string(&memory_h).expect("Memory.h readable"); + if !text.contains("#include ") { + let patched = text.replacen( + "#include ", + "#include \n#include ", + 1, + ); + std::fs::write(&memory_h, patched).expect("Memory.h writable"); + } + + // Configure + build the library. Dependency overrides (RNS_*_SOURCE_DIR) + // are passed straight through so a sandboxed build never fetches. + let build = out_dir.join("rns-build"); + let mut configure = std::process::Command::new("cmake"); + configure + .arg("-S") + .arg(©) + .arg("-B") + .arg(&build) + .arg("-DCMAKE_BUILD_TYPE=Release") + // The NixOS gcc wrapper injects -Werror=format-security, which + // errors once the project's -Wno-format disables -Wformat. + .arg("-DCMAKE_C_FLAGS=-Wno-error=format-security") + .arg("-DCMAKE_CXX_FLAGS=-Wno-error=format-security") + .arg("-DRNS_BUILD_TESTS=OFF") + .arg("-DRNS_BUILD_EXAMPLES=OFF") + .arg("-DRNS_BUILD_INTEROP=OFF") + .arg("-DRNS_USE_FS=ON") + .arg("-DRNS_PERSIST_PATHS=OFF") + // The known-destinations store is what Identity::recall reads; a + // server never recalls a remote identity (links arrive proved), so + // bunshin disables it, but a client's path discovery is dead without + // it: Transport refuses to add a destination whose identity it + // cannot remember. + .arg("-DRNS_PERSIST_KNOWN_DESTINATIONS=ON") + .arg("-DRNS_PERSIST_HASHLIST=OFF"); + for dep in DEP_VARS { + if let Ok(path) = std::env::var(dep) { + configure.arg(format!("-D{dep}={path}")); + } + } + let status = configure.status().expect("cmake configure"); + assert!(status.success(), "cmake configure failed"); + let status = std::process::Command::new("cmake") + .arg("--build") + .arg(&build) + .arg("--target") + .arg("microReticulum") + .status() + .expect("cmake build"); + assert!(status.success(), "cmake build failed"); + + // The same include roots and defines the library's CMake target exports + // publicly, mirrored so the shim TU sees identical macros (the upstream + // CMakeLists warns about ODR divergence otherwise). A vendored dep (env + // override) lives at its checkout root; a fetched one under _deps. + let deps = build.join("_deps"); + let mut includes: Vec = vec![copy.join("src")]; + for (env_var, fetch_name, sub) in DEP_INCLUDES { + includes.push(match std::env::var(env_var) { + Ok(root) => std::path::Path::new(&root).join(sub), + Err(_) => deps.join(format!("{fetch_name}-src")).join(sub), + }); + } + + let mut build_cc = cc::Build::new(); + build_cc + .cpp(true) + .std("c++17") + .define("NATIVE", None) + .define("USTORE_USE_UNIVERSALFS", None) + .define("RNS_USE_FS", None) + .file("shim/smolmail_rns.cpp") + .file("shim/udp_interface.cpp"); + for include in &includes { + build_cc.include(include); + } + build_cc.compile("smolmail_rns_shim"); + + println!("cargo:rustc-link-search=native={}", build.display()); + println!("cargo:rustc-link-lib=static=microReticulum"); + println!("cargo:rustc-link-lib=static=CryptoLib"); +} + +// microReticulum's own local-checkout override variables. +#[cfg(feature = "rns")] +const DEP_VARS: &[&str] = &[ + "RNS_ARDUINOJSON_SOURCE_DIR", + "RNS_MSGPACK_SOURCE_DIR", + "RNS_CRYPTO_SOURCE_DIR", + "RNS_MICROSTORE_SOURCE_DIR", + "RNS_ARXCONTAINER_SOURCE_DIR", + "RNS_ARXTYPETRAITS_SOURCE_DIR", + "RNS_DEBUGLOG_SOURCE_DIR", +]; + +// (override env var, FetchContent name, include subdir within the dep root). +#[cfg(feature = "rns")] +const DEP_INCLUDES: &[(&str, &str, &str)] = &[ + ("RNS_ARDUINOJSON_SOURCE_DIR", "arduinojson", "src"), + ("RNS_MSGPACK_SOURCE_DIR", "msgpack", ""), + ("RNS_ARXCONTAINER_SOURCE_DIR", "arxcontainer", ""), + ("RNS_ARXTYPETRAITS_SOURCE_DIR", "arxtypetraits", ""), + ("RNS_DEBUGLOG_SOURCE_DIR", "debuglog", ""), + ("RNS_CRYPTO_SOURCE_DIR", "crypto", ""), + ("RNS_MICROSTORE_SOURCE_DIR", "microstore", "include"), +]; + +#[cfg(feature = "rns")] +fn copy_tree(src: &std::path::Path, dst: &std::path::Path, entries: &[&str]) { + std::fs::create_dir_all(dst).expect("OUT_DIR writable"); + for entry in entries { + let from = src.join(entry); + if !from.exists() { + continue; + } + let to = dst.join(entry); + if from.is_dir() { + copy_dir(&from, &to); + } else { + copy_file(&from, &to); + } + } +} + +#[cfg(feature = "rns")] +fn copy_dir(src: &std::path::Path, dst: &std::path::Path) { + std::fs::create_dir_all(dst).expect("OUT_DIR writable"); + for entry in std::fs::read_dir(src).expect("readable source dir") { + let entry = entry.expect("readable source dir").path(); + let to = dst.join(entry.file_name().unwrap()); + if entry.is_dir() { + copy_dir(&entry, &to); + } else { + copy_file(&entry, &to); + } + } +} + +/// fs::copy preserves the source's mode, and a Nix store checkout is +/// read-only, so the copy must be made writable before the patch step. +#[cfg(feature = "rns")] +fn copy_file(from: &std::path::Path, to: &std::path::Path) { + std::fs::copy(from, to).expect("copy file"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(to, std::fs::Permissions::from_mode(0o644)) + .expect("copied file writable"); + } +} + +#[cfg(not(feature = "rns"))] +fn main() {} diff --git a/flake.lock b/flake.lock index ed130fd..e910b2f 100644 --- a/flake.lock +++ b/flake.lock @@ -37,7 +37,15 @@ "root": { "inputs": { "flake-utils": "flake-utils", - "nixpkgs": "nixpkgs" + "nixpkgs": "nixpkgs", + "microReticulum": "microReticulum", + "rns-arduinojson": "rns-arduinojson", + "rns-msgpack": "rns-msgpack", + "rns-arxcontainer": "rns-arxcontainer", + "rns-arxtypetraits": "rns-arxtypetraits", + "rns-debuglog": "rns-debuglog", + "rns-crypto": "rns-crypto", + "rns-microstore": "rns-microstore" } }, "systems": { @@ -54,8 +62,144 @@ "repo": "default", "type": "github" } + }, + "microReticulum": { + "flake": false, + "locked": { + "lastModified": 1784575896, + "narHash": "sha256-cqo+BJ3tsmw4fD+SL0D3X9FKCgf+n0VQng2pSIuyK/8=", + "owner": "attermann", + "repo": "microReticulum", + "rev": "40fa628809d57140180c1c833559ab96fec992c1", + "type": "github" + }, + "original": { + "owner": "attermann", + "repo": "microReticulum", + "rev": "40fa628809d57140180c1c833559ab96fec992c1", + "type": "github" + } + }, + "rns-arduinojson": { + "flake": false, + "locked": { + "lastModified": 1750405034, + "narHash": "sha256-MGspSk9zWdPHMFXm+Oi4sibznHYE1eKXSqi6QHmjVCk=", + "owner": "bblanchon", + "repo": "ArduinoJson", + "rev": "ed69feadb95182dc53ac978975d310122060a767", + "type": "github" + }, + "original": { + "owner": "bblanchon", + "repo": "ArduinoJson", + "rev": "ed69feadb95182dc53ac978975d310122060a767", + "type": "github" + } + }, + "rns-msgpack": { + "flake": false, + "locked": { + "lastModified": 1707898892, + "narHash": "sha256-Zn3cwUaW2RMvOyvpcA1JxHQk3f3X7m2yZ8ilRAwgxNI=", + "owner": "hideakitai", + "repo": "MsgPack", + "rev": "1f552c31b940d6e9063ee17a4b3fa10c47b27169", + "type": "github" + }, + "original": { + "owner": "hideakitai", + "repo": "MsgPack", + "rev": "1f552c31b940d6e9063ee17a4b3fa10c47b27169", + "type": "github" + } + }, + "rns-arxcontainer": { + "flake": false, + "locked": { + "lastModified": 1718955529, + "narHash": "sha256-3XzdM1fNl7irhjWF6hnbn+iw+orOox4KY5ezk1SC0Lg=", + "owner": "hideakitai", + "repo": "ArxContainer", + "rev": "d6affcd0bc83219b863c20abf7c269214db8db2a", + "type": "github" + }, + "original": { + "owner": "hideakitai", + "repo": "ArxContainer", + "rev": "d6affcd0bc83219b863c20abf7c269214db8db2a", + "type": "github" + } + }, + "rns-arxtypetraits": { + "flake": false, + "locked": { + "lastModified": 1760475334, + "narHash": "sha256-rKW85Pt4NsbYDesnwJKSrx6F2bq4ZDSU+7DpRMvH1R0=", + "owner": "hideakitai", + "repo": "ArxTypeTraits", + "rev": "702de9cc59c7e047cdc169ae3547718b289d2c02", + "type": "github" + }, + "original": { + "owner": "hideakitai", + "repo": "ArxTypeTraits", + "rev": "702de9cc59c7e047cdc169ae3547718b289d2c02", + "type": "github" + } + }, + "rns-debuglog": { + "flake": false, + "locked": { + "lastModified": 1731116852, + "narHash": "sha256-vu4jfXY9ulaEFQzv1VJahzBN3ii+8F7AyOxsnKRzjv4=", + "owner": "hideakitai", + "repo": "DebugLog", + "rev": "b581f7dde6c276c5df684e2328f406d9754d2f46", + "type": "github" + }, + "original": { + "owner": "hideakitai", + "repo": "DebugLog", + "rev": "b581f7dde6c276c5df684e2328f406d9754d2f46", + "type": "github" + } + }, + "rns-crypto": { + "flake": false, + "locked": { + "lastModified": 1779476496, + "narHash": "sha256-bv0Hr+1sp9nCERZSmhCYv69C2zn+Jk2N3pZVUXquRcw=", + "owner": "attermann", + "repo": "Crypto", + "rev": "984dc891330986c302a86c4e312d4f5abcc28359", + "type": "github" + }, + "original": { + "owner": "attermann", + "repo": "Crypto", + "rev": "984dc891330986c302a86c4e312d4f5abcc28359", + "type": "github" + } + }, + "rns-microstore": { + "flake": false, + "locked": { + "lastModified": 1784473031, + "narHash": "sha256-gY/r5q4tPDfIj3I0E2ZVj0URPjo1tlQj37Ctg9NQoYQ=", + "owner": "attermann", + "repo": "microStore", + "rev": "0f28567fe00ab8ab14624a34c2e9e0a000a44c46", + "type": "github" + }, + "original": { + "owner": "attermann", + "repo": "microStore", + "rev": "0f28567fe00ab8ab14624a34c2e9e0a000a44c46", + "type": "github" + } } }, "root": "root", "version": 7 -} +} \ No newline at end of file diff --git a/flake.nix b/flake.nix index 496112d..73a2eb4 100644 --- a/flake.nix +++ b/flake.nix @@ -4,21 +4,81 @@ inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; flake-utils.url = "github:numtide/flake-utils"; + + # RNS carrier sources (upstream RNS.md sec 13): the build sandbox has no + # network, so every microReticulum FetchContent dependency is vendored as + # its own flake input and handed over via RNS__SOURCE_DIR. + # microReticulum is pinned at the commit bunshin verified against. + microReticulum = { + url = "github:attermann/microReticulum/40fa628809d57140180c1c833559ab96fec992c1"; + flake = false; + }; + rns-arduinojson = { + url = "github:bblanchon/ArduinoJson/ed69feadb95182dc53ac978975d310122060a767"; + flake = false; + }; + rns-msgpack = { + url = "github:hideakitai/MsgPack/1f552c31b940d6e9063ee17a4b3fa10c47b27169"; + flake = false; + }; + rns-arxcontainer = { + url = "github:hideakitai/ArxContainer/d6affcd0bc83219b863c20abf7c269214db8db2a"; + flake = false; + }; + rns-arxtypetraits = { + url = "github:hideakitai/ArxTypeTraits/702de9cc59c7e047cdc169ae3547718b289d2c02"; + flake = false; + }; + rns-debuglog = { + url = "github:hideakitai/DebugLog/b581f7dde6c276c5df684e2328f406d9754d2f46"; + flake = false; + }; + rns-crypto = { + url = "github:attermann/Crypto/984dc891330986c302a86c4e312d4f5abcc28359"; + flake = false; + }; + rns-microstore = { + url = "github:attermann/microStore/0f28567fe00ab8ab14624a34c2e9e0a000a44c46"; + flake = false; + }; }; - outputs = { self, nixpkgs, flake-utils }: + outputs = { self, nixpkgs, flake-utils, microReticulum, rns-arduinojson + , rns-msgpack, rns-arxcontainer, rns-arxtypetraits, rns-debuglog + , rns-crypto, rns-microstore }: flake-utils.lib.eachDefaultSystem (system: let pkgs = import nixpkgs { inherit system; }; + inherit (pkgs) lib; + + # build.rs consumes these directly; with every dependency vendored, + # the cmake configure step never fetches. + rnsSourceEnv = { + MICRORETICULUM_SOURCE_DIR = "${microReticulum}"; + RNS_ARDUINOJSON_SOURCE_DIR = "${rns-arduinojson}"; + RNS_MSGPACK_SOURCE_DIR = "${rns-msgpack}"; + RNS_ARXCONTAINER_SOURCE_DIR = "${rns-arxcontainer}"; + RNS_ARXTYPETRAITS_SOURCE_DIR = "${rns-arxtypetraits}"; + RNS_DEBUGLOG_SOURCE_DIR = "${rns-debuglog}"; + RNS_CRYPTO_SOURCE_DIR = "${rns-crypto}"; + RNS_MICROSTORE_SOURCE_DIR = "${rns-microstore}"; + }; + fumi = { rns ? false }: pkgs.rustPlatform.buildRustPackage { pname = "fumi"; version = "0.1.0"; src = ./.; cargoLock.lockFile = ./Cargo.lock; - buildFeatures = pkgs.lib.optionals rns [ "rns" ]; - # The rns feature vendors microReticulum behind the shim and needs - # MICRORETICULUM_SOURCE_DIR; it is added with the RNS carrier. - doCheck = !rns; + nativeBuildInputs = [ pkgs.pkg-config ] + ++ lib.optionals rns [ pkgs.cmake pkgs.ninja pkgs.gcc ]; + buildFeatures = lib.optionals rns [ "rns" ]; + env = lib.optionalAttrs rns rnsSourceEnv; + # ninja's setup hook claims buildPhase before the cargo hooks + # can, which would run ninja against no build.ninja instead of + # cargo; ninja here is only for build.rs to invoke through cmake. + dontUseNinjaBuild = rns; + dontUseNinjaCheck = rns; + dontUseNinjaInstall = rns; }; in { @@ -31,7 +91,18 @@ }; devShells.default = pkgs.mkShell { - packages = with pkgs; [ cargo rustc rustfmt clippy sqlite ]; + packages = with pkgs; [ + cargo + rustc + rustfmt + clippy + pkg-config + sqlite + cmake + ninja + gcc + ]; + env = rnsSourceEnv; }; }); } diff --git a/shim/smolmail_rns.cpp b/shim/smolmail_rns.cpp new file mode 100644 index 0000000..d4499da --- /dev/null +++ b/shim/smolmail_rns.cpp @@ -0,0 +1,288 @@ +/* + * microReticulum bridge for the fumi RNS client carrier (upstream spec sec + * 13). Owns the Reticulum instance, the UDP interface and the loop thread + * that drives Reticulum::loop(). Every request/response exchange goes through + * a single slot guarded by a condvar: the CLI has one request in flight at a + * time, so no map of callbacks is needed. + * + * microReticulum splices the request payload into its msgpack envelope + * verbatim (Link.cpp pack_request_envelope), so the request must be packed + * as a msgpack binary by the caller; the response, by contrast, arrives + * already decoded (unpack_response_envelope yields the payload itself), so + * only the outbound direction touches msgpack here. + */ +#include "smolmail_rns.h" +#include "udp_interface.h" + +#include +#include +#include + +#include + +#include +#include +#include +#include +#include + +// Anchored in src/rns/transport.rs and upstream spec sec 13.1/13.5. +static const char* APP_NAME = "smolmail"; +static const char* APP_ASPECT = "server"; +static const char* REQ_PATH = "smolmail/1"; + +static const double LOOP_SLEEP_SECS = 0.01; +static const double CONNECT_POLL_SECS = 0.05; +static const double PATH_POLL_SECS = 0.1; + +static RNS::Reticulum reticulum({RNS::Type::NONE}); +static RNS::Interface udp_interface({RNS::Type::NONE}); +static RNS::Link active_link({RNS::Type::NONE}); +static microStore::FileSystem filesystem{microStore::Adapters::UniversalFileSystem()}; + +static volatile bool running = false; + +// The single response slot (plan: one request in flight at a time). +static std::mutex slot_mutex; +static std::condition_variable slot_cv; +static RNS::Bytes slot_response; +static volatile bool slot_ready = false; // response or failure arrived +static volatile bool slot_failed = false; + +// Link establishment, signalled from the loop thread. +static std::mutex link_mutex; +static std::condition_variable link_cv; +static volatile bool link_established = false; +static volatile bool link_closed_early = false; + +static void on_link_established(RNS::Link& established) { + (void)established; + std::lock_guard lock(link_mutex); + link_established = true; + link_cv.notify_all(); +} + +static void on_link_closed(RNS::Link& closed) { + (void)closed; + { + std::lock_guard lock(link_mutex); + link_closed_early = true; + link_cv.notify_all(); + } + // A dead link fails any request waiting on the slot rather than letting + // it run to the timeout (upstream spec sec 13.5: a local error). + std::lock_guard lock(slot_mutex); + slot_failed = true; + slot_ready = true; + slot_cv.notify_all(); +} + +static void on_response(const RNS::RequestReceipt& receipt) { + RNS::Bytes response = receipt.get_response(); + std::lock_guard lock(slot_mutex); + slot_response = response; + slot_failed = false; + slot_ready = true; + slot_cv.notify_all(); +} + +static void on_failed(const RNS::RequestReceipt& receipt) { + (void)receipt; + std::lock_guard lock(slot_mutex); + slot_failed = true; + slot_ready = true; + slot_cv.notify_all(); +} + +// The unwrapped large-response payload, filled in by smolmail_rns_request. +static RNS::Bytes unwrapped; + +static void loop_thread_main() { + while (running) { + reticulum.loop(); + RNS::Utilities::OS::sleep(LOOP_SLEEP_SECS); + } +} + +extern "C" int smolmail_rns_start(const char* storage_dir, + const char* udp_listen_host, uint16_t udp_listen_port, + const char* udp_forward_host, uint16_t udp_forward_port) { + if (running) { + return 0; // already started; the storage path cannot change mid-run + } + + // Registered before anything else, as the interop examples do, so + // persistence 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_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(); + // Transport mode must be on for a client: the known-destinations store + // (what Identity::recall reads, and what path discovery stores the + // announced identity into) is only initialised inside it. The Python + // client never trips this because its known-destinations map always + // exists in memory; microReticulum with RNS_USE_FS keeps it in a + // FileStore instead. + reticulum.transport_enabled(true); + reticulum.start(); + + running = true; + std::thread(loop_thread_main).detach(); + return 0; +} + +extern "C" int smolmail_rns_connect(const uint8_t* destination_hash, uint32_t timeout_ms, + uint8_t* link_id_out) { + const RNS::Bytes hash(destination_hash, 16); + + // Request a path and WAIT before creating the link (upstream spec sec + // 13.3): with no path RNS assumes the maximum hop count and the link + // fails after minutes instead of promptly. The deadline is the stack's + // own path request timeout, not a number copied from the document. + if (!RNS::Transport::has_path(hash)) { + RNS::Transport::request_path(hash); + const double deadline = RNS::Utilities::OS::time() + + (double)RNS::Type::Transport::PATH_REQUEST_TIMEOUT; + while (!RNS::Transport::has_path(hash) + && RNS::Utilities::OS::time() < deadline) { + RNS::Utilities::OS::sleep(PATH_POLL_SECS); + } + if (!RNS::Transport::has_path(hash)) { + return -1; + } + } + + // Recall can return nothing immediately after a path appears; the caller + // treats that as a retry, not an error. + RNS::Identity identity = RNS::Identity::recall(hash); + if (!identity) { + return -2; + } + + // One fresh link per session, never reused across authentications: + // link_id is what stops an AUTH replaying on another link (upstream + // spec sec 13.6). + RNS::Destination destination(identity, RNS::Type::Destination::OUT, + RNS::Type::Destination::SINGLE, APP_NAME, APP_ASPECT); + { + std::lock_guard lock(link_mutex); + link_established = false; + link_closed_early = false; + } + { + std::lock_guard slot_lock(slot_mutex); + slot_ready = false; + slot_failed = false; + } + active_link = RNS::Link(destination, on_link_established, on_link_closed); + + const double deadline = RNS::Utilities::OS::time() + (double)timeout_ms / 1000.0; + { + std::unique_lock lock(link_mutex); + while (!link_established && !link_closed_early + && RNS::Utilities::OS::time() < deadline) { + link_cv.wait_for(lock, std::chrono::duration(CONNECT_POLL_SECS)); + } + } + if (!link_established || active_link.status() != RNS::Type::Link::ACTIVE) { + active_link.teardown(); + active_link = RNS::Link({RNS::Type::NONE}); + return -3; + } + memcpy(link_id_out, active_link.link_id().data(), 16); + return 0; +} + +extern "C" int smolmail_rns_request(const uint8_t* request, size_t request_len, + uint32_t timeout_ms, uint8_t* out, size_t cap, + size_t* out_len) { + if (!active_link || active_link.status() != RNS::Type::Link::ACTIVE) { + return -1; + } + + // The request payload must be msgpack-encoded itself: Link::request + // splices it verbatim into the envelope's third element. + MsgPack::Packer packer; + packer.packBinary(request, request_len); + RNS::Bytes encoded(packer.data(), packer.size()); + + { + std::lock_guard lock(slot_mutex); + slot_ready = false; + slot_failed = false; + slot_response.clear(); + } + // A client MUST set its own request timeout (upstream spec sec 13.5): + // Reticulum's default is derived from the round trip time and covers a + // packet, not a FETCH page. + RNS::RequestReceipt receipt = active_link.request(RNS::Bytes(REQ_PATH), encoded, + on_response, on_failed, nullptr, + (double)timeout_ms / 1000.0); + if (!receipt) { + return -2; + } + + // No path, a dead link, a rejected resource and a timeout are local + // errors; none of them is a status code. + { + std::unique_lock lock(slot_mutex); + const bool concluded = slot_cv.wait_for(lock, + std::chrono::milliseconds(timeout_ms + 1000), + []() { return slot_ready; }); + if (!concluded) { + return -3; + } + if (slot_failed) { + return -4; + } + } + + // microReticulum decodes the response envelope differently for the two + // transfer modes: a small response arrives as the bare smolmail payload + // (`status u8 || payload`), while a large one, transferred as a + // resource, is still wrapped in its msgpack binary (Link.cpp hands the + // raw remainder to handle_response on that path). The wrapper is + // unambiguous: a smolmail status is a single byte below 16, and the + // msgpack bin headers are 0xC4..0xC6, so only those are unwrapped. + const RNS::Bytes& payload = [&]() -> const RNS::Bytes& { + if (slot_response.size() > 0 && slot_response.data()[0] >= 0xC4 + && slot_response.data()[0] <= 0xC6) { + MsgPack::Unpacker unpacker; + unpacker.feed(slot_response.data(), slot_response.size()); + if (unpacker.isBin()) { + MsgPack::bin_t bin; + unpacker.deserialize(bin); + unwrapped = RNS::Bytes(bin.data(), bin.size()); + } + } + return unwrapped.size() > 0 ? unwrapped : slot_response; + }(); + if (!payload || payload.size() == 0) { + return -5; + } + if (payload.size() > cap) { + return -6; + } + memcpy(out, payload.data(), payload.size()); + *out_len = payload.size(); + return 0; +} + +extern "C" void smolmail_rns_close(void) { + if (active_link) { + active_link.teardown(); + active_link = RNS::Link({RNS::Type::NONE}); + } +} diff --git a/shim/smolmail_rns.h b/shim/smolmail_rns.h new file mode 100644 index 0000000..9cbe732 --- /dev/null +++ b/shim/smolmail_rns.h @@ -0,0 +1,59 @@ +/* + * C ABI between fumi (Rust) and the microReticulum client shim. + * + * All calls block the caller; the shim owns the Reticulum loop thread and + * every callback fires on that thread. link_id and destination_hash are + * always 16 bytes. No client Reticulum identity is created or loaded anywhere + * (upstream spec sec 13.8): a server MUST NOT require identification, and a + * durable client handle is exactly what the wire format withholds. + */ +#ifndef SMOLMAIL_RNS_H +#define SMOLMAIL_RNS_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* Implemented in smolmail_rns.cpp, called from src/rns/ffi.rs. + * udp_forward_host may be NULL: with no forward target the interface sends + * to the source address of the last datagram received, which a client that + * only listens cannot use -- a client speaks first, so the forward target + * should point at the server's UDP interface. + * Returns 0 on success, negative on failure. */ +int smolmail_rns_start(const char *storage_dir, + const char *udp_listen_host, uint16_t udp_listen_port, + const char *udp_forward_host, uint16_t udp_forward_port); + +/* Requests a path if none is known and waits for it (upstream spec sec + * 13.3), recalls the identity, builds the OUT/SINGLE smolmail.server + * destination, opens the link and waits for ACTIVE. Returns the 16-byte + * link id, which the caller's AUTH and REGISTER bind values are derived + * from (upstream spec sec 13.6). + * Returns 0 on success, negative on failure. */ +int smolmail_rns_connect(const uint8_t *destination_hash, uint32_t timeout_ms, + uint8_t *link_id_out); + +/* request is `op u8 || body`, the response `status u8 || payload` + * (upstream spec sec 13.5). A CLI has one request in flight at a time, so + * the shim keeps a single response slot rather than a map. + * Returns 0 on success and writes the response length to *out_len; + * negative on failure: no link (-1), send failure (-2), timeout or closed + * link (-3), failed transfer (-4), malformed response (-5), response larger + * than cap (-6). These are local errors and MUST NOT be reported as status + * codes (upstream spec sec 13.5). */ +int smolmail_rns_request(const uint8_t *request, size_t request_len, + uint32_t timeout_ms, uint8_t *out, size_t cap, + size_t *out_len); + +/* Tears the link down rather than leave it on keepalives, which run for + * minutes (upstream spec sec 13.10). */ +void smolmail_rns_close(void); + +#ifdef __cplusplus +} +#endif + +#endif /* SMOLMAIL_RNS_H */ diff --git a/shim/udp_interface.cpp b/shim/udp_interface.cpp new file mode 100644 index 0000000..a2c5a4a --- /dev/null +++ b/shim/udp_interface.cpp @@ -0,0 +1,198 @@ +#include "udp_interface.h" + +#include +#include + +#ifndef ARDUINO +#include +#include +#include +#include +#include +#include +#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(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); +} diff --git a/shim/udp_interface.h b/shim/udp_interface.h new file mode 100644 index 0000000..84c68bd --- /dev/null +++ b/shim/udp_interface.h @@ -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 +#include +#include + +#ifdef ARDUINO +#include +#include +#else +#include +#endif + +#include +#include + +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 + +}; diff --git a/src/client.rs b/src/client.rs index d71176e..c2cde2c 100644 --- a/src/client.rs +++ b/src/client.rs @@ -66,9 +66,32 @@ pub fn connect( unpinned_static, }) } - Scheme::Rns => Err(SmolError::new( - "smol+rns:// addresses need the rns feature; rebuild with --features rns", - )), + Scheme::Rns => { + // There is nothing to pin on this carrier (upstream spec 13.4): + // the destination hash is the pin, so `require_pin` decides + // nothing here and every session is as authenticated as a + // pinned TCP one. + #[cfg(feature = "rns")] + { + let _ = require_pin; + let destination = addr.destination()?; + let transport = crate::rns::transport::RnsTransport::connect( + &destination, + std::time::Duration::from_secs(timeout), + )?; + Ok(Session { + transport: Box::new(transport), + unpinned_static: None, + }) + } + #[cfg(not(feature = "rns"))] + { + let _ = (addr, require_pin); + Err(SmolError::new( + "smol+rns:// addresses need the rns feature; rebuild with --features rns", + )) + } + } } } diff --git a/src/lib.rs b/src/lib.rs index deb239c..c738484 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,8 @@ pub mod client; pub mod crypto; pub mod error; pub mod message; +#[cfg(feature = "rns")] +pub mod rns; pub mod store; pub mod tcp; pub mod transport; diff --git a/src/main.rs b/src/main.rs index 7a2202f..ba94655 100644 --- a/src/main.rs +++ b/src/main.rs @@ -27,10 +27,30 @@ struct Cli { /// Network timeout in seconds #[arg(long, global = true, default_value_t = 30)] timeout: u64, + #[cfg(feature = "rns")] + #[command(flatten)] + rns: RnsArgs, #[command(subcommand)] command: Command, } +/// RNS carrier flags; addresses select the carrier by scheme, so these apply +/// only to `smol+rns://` dials. +#[cfg(feature = "rns")] +#[derive(clap::Args)] +struct RnsArgs { + /// UDP interface to listen on, host[:port] + #[arg(long = "rns-udp", global = true, default_value = "127.0.0.1:0")] + udp: String, + /// UDP forward target, host[:port]; a client speaks first, so this should + /// point at an rnsd or at the server's UDP interface + #[arg(long = "rns-udp-forward", global = true, value_name = "HOST:PORT")] + udp_forward: Option, + /// Reticulum storage directory + #[arg(long = "rns-storage", global = true, default_value = "fumi.rns")] + storage: String, +} + #[derive(Subcommand)] enum Command { /// Create a master secret @@ -132,6 +152,8 @@ fn main() { fn run(cli: Cli) -> Result<()> { let store = Store::open(&cli.db)?; + #[cfg(feature = "rns")] + fumi::rns::configure(rns_config(&cli)); match &cli.command { Command::Keygen { force } => keygen(&cli.key, *force), Command::Whoami => whoami(&cli.key, &store), @@ -722,6 +744,45 @@ fn read(key: &PathBuf, store: &Store, id: &str, sent: bool) -> Result<()> { } } +/// Parses host[:port]; `default_port` applies when the port is omitted. +#[cfg(feature = "rns")] +fn split_host_port(text: &str, default_port: u16, what: &str) -> Result<(String, u16)> { + match text.rsplit_once(':') { + Some((host, port)) => { + let port: u16 = port + .parse() + .with_context(|| format!("{what} port {port:?} is not a number"))?; + Ok((host.to_string(), port)) + } + None => Ok((text.to_string(), default_port)), + } +} + +/// Carrier configuration from the global flags. +#[cfg(feature = "rns")] +fn rns_config(cli: &Cli) -> fumi::rns::RnsConfig { + let listen = + split_host_port(&cli.rns.udp, 0, "--rns-udp").expect("--rns-udp defaults are valid"); + let forward = cli + .rns + .udp_forward + .as_deref() + .map(|target| split_host_port(target, 4242, "--rns-udp-forward")) + .transpose() + .expect("clap-validated forward target"); + let (forward_host, forward_port) = match forward { + Some((host, port)) => (Some(host), port), + None => (None, 4242), + }; + fumi::rns::RnsConfig { + storage_dir: cli.rns.storage.clone(), + udp_listen_host: listen.0, + udp_listen_port: listen.1, + udp_forward_host: forward_host, + udp_forward_port: forward_port, + } +} + fn hex(id: &[u8; ID_LEN]) -> String { data_encoding::HEXLOWER.encode(id) } @@ -756,6 +817,20 @@ fn civil_from_days(z: i64) -> (i64, u32, u32) { mod tests { use super::*; + #[cfg(feature = "rns")] + #[test] + fn rns_flag_parsing() { + assert_eq!( + split_host_port("127.0.0.1:4242", 0, "--rns-udp").unwrap(), + ("127.0.0.1".to_string(), 4242) + ); + assert_eq!( + split_host_port("127.0.0.1", 4242, "--rns-udp-forward").unwrap(), + ("127.0.0.1".to_string(), 4242) + ); + assert!(split_host_port("host:notaport", 0, "--rns-udp").is_err()); + } + #[test] fn utc_formatting_matches_known_dates() { assert_eq!(format_utc(0), "1970-01-01 00:00"); diff --git a/src/rns/ffi.rs b/src/rns/ffi.rs new file mode 100644 index 0000000..1854210 --- /dev/null +++ b/src/rns/ffi.rs @@ -0,0 +1,155 @@ +//! `extern "C"` declarations for `shim/smolmail_rns.h` and the safe wrapper. +//! +//! Every failure here is a local error — no path, a dead link, a rejected +//! resource, a timeout — and MUST NOT be reported as a status code (upstream +//! spec sec 13.5), so the wrapper turns every shim return code into an +//! `SmolError` message and never into a `Response`. + +use std::ffi::CString; +use std::time::Duration; + +use crate::crypto::KEY_LEN; +use crate::error::SmolError; +use crate::rns::RnsConfig; +use crate::transport::MAX_FRAME; + +mod inner { + use std::os::raw::{c_char, c_int}; + + extern "C" { + // shim/smolmail_rns.cpp + pub fn smolmail_rns_start( + storage_dir: *const c_char, + udp_listen_host: *const c_char, + udp_listen_port: u16, + udp_forward_host: *const c_char, + udp_forward_port: u16, + ) -> c_int; + + pub fn smolmail_rns_connect( + destination_hash: *const u8, + timeout_ms: u32, + link_id_out: *mut u8, + ) -> c_int; + + pub fn smolmail_rns_request( + request: *const u8, + request_len: usize, + timeout_ms: u32, + out: *mut u8, + cap: usize, + out_len: *mut usize, + ) -> c_int; + + pub fn smolmail_rns_close(); + } +} + +/// Starts the Reticulum stack: storage path, UDP interface, loop thread. +pub fn start(config: &RnsConfig) -> Result<(), SmolError> { + let storage_dir = + CString::new(config.storage_dir.as_str()).expect("storage path has no interior NUL"); + let listen_host = + CString::new(config.udp_listen_host.as_str()).expect("listen host has no interior NUL"); + let forward_host = config + .udp_forward_host + .as_ref() + .map(|h| CString::new(h.as_str()).expect("forward host has no interior NUL")); + let rc = unsafe { + inner::smolmail_rns_start( + storage_dir.as_ptr(), + listen_host.as_ptr(), + config.udp_listen_port, + forward_host + .as_ref() + .map(|h| h.as_ptr()) + .unwrap_or(std::ptr::null()), + config.udp_forward_port, + ) + }; + match rc { + 0 => Ok(()), + _ => Err(SmolError::new(format!( + "RNS shim failed to start (code {rc}); is the UDP port free?" + ))), + } +} + +/// Requests a path, recalls the identity, opens a link and waits for ACTIVE, +/// returning the 16-byte link id the bind values derive from. +pub fn connect(destination: &[u8; 16], timeout: Duration) -> Result<[u8; 16], SmolError> { + let hex = data_encoding::HEXLOWER.encode(destination); + let mut link_id = [0u8; 16]; + let rc = unsafe { + inner::smolmail_rns_connect( + destination.as_ptr(), + timeout.as_millis().min(u32::MAX as u128) as u32, + link_id.as_mut_ptr(), + ) + }; + match rc { + 0 => Ok(link_id), + -1 => Err(SmolError::new(format!( + "no path to {hex}; it may be unreachable or has never announced \ + (upstream spec 13.1, 13.3)" + ))), + -2 => Err(SmolError::new(format!( + "path known but identity not yet learned for {hex}; try again shortly" + ))), + -3 => Err(SmolError::new(format!( + "could not establish a link to {hex}" + ))), + _ => Err(SmolError::new(format!( + "RNS connect to {hex} failed (code {rc})" + ))), + } +} + +/// Sends one `op u8 || body` request and returns the `status u8 || payload` +/// response. +pub fn request(request: &[u8], timeout: Duration) -> Result, SmolError> { + // A record is returned whole even when it alone exceeds the server's + // fetch budget (upstream spec sec 6.1), and a mailbox shared with TCP + // can hold a 768 KiB envelope (sec 13.7), so the response buffer is the + // full application frame ceiling. + let cap = MAX_FRAME + 16; + let mut out = vec![0u8; cap]; + let mut out_len: usize = 0; + let rc = unsafe { + inner::smolmail_rns_request( + request.as_ptr(), + request.len(), + timeout.as_millis().min(u32::MAX as u128) as u32, + out.as_mut_ptr(), + cap, + &mut out_len, + ) + }; + match rc { + 0 => { + out.truncate(out_len); + Ok(out) + } + -1 => Err(SmolError::new("no RNS link is open")), + -2 => Err(SmolError::new("failed to send the request over the link")), + -3 | -4 => Err(SmolError::new( + "request failed: no response (closed link, rejected transfer, or timeout \ + -- not a status code, upstream spec 13.5)", + )), + -5 => Err(SmolError::new("malformed response from server")), + -6 => Err(SmolError::new( + "response larger than one application frame; refusing to truncate it", + )), + _ => Err(SmolError::new(format!("RNS request failed (code {rc})"))), + } +} + +/// Tears the link down rather than leave it on keepalives (upstream spec sec +/// 13.10). +pub fn close() { + unsafe { inner::smolmail_rns_close() }; +} + +/// The destination hash length, for callers checking address shapes. +pub const DEST_LEN: usize = 16; +const _: () = assert!(DEST_LEN == 16 && KEY_LEN == 32); diff --git a/src/rns/mod.rs b/src/rns/mod.rs new file mode 100644 index 0000000..1345db9 --- /dev/null +++ b/src/rns/mod.rs @@ -0,0 +1,71 @@ +//! The RNS carrier (Smol Mail 1.2, upstream RNS.md): microReticulum behind +//! the C shim, dispatched through the same operations as TCP. +//! +//! The carrier starts lazily, on the first `smol+rns://` dial, so local-only +//! commands stay usable offline and start instantly. A client creates no +//! Reticulum identity (upstream spec sec 13.8): the shim never calls +//! `Link::identify`, and the flags below configure only the storage path and +//! the UDP interface. + +pub mod ffi; +pub mod transport; + +use std::path::Path; +use std::sync::{Mutex, OnceLock}; + +use crate::error::SmolError; + +/// Carrier configuration, set once by the CLI and consumed on first use. +#[derive(Clone, Debug)] +pub struct RnsConfig { + /// microReticulum storage directory; created before the carrier starts. + pub storage_dir: String, + pub udp_listen_host: String, + pub udp_listen_port: u16, + /// Effectively required for a client (upstream spec sec 13.2 and the + /// plan): a client speaks first, so with no forward target the interface + /// has nowhere to send its path request. + pub udp_forward_host: Option, + pub udp_forward_port: u16, +} + +impl Default for RnsConfig { + fn default() -> Self { + RnsConfig { + storage_dir: "fumi.rns".to_string(), + // An ephemeral listen port avoids colliding with a local rnsd on + // 4242; replies come back to the source address. + udp_listen_host: "127.0.0.1".to_string(), + udp_listen_port: 0, + udp_forward_host: None, + udp_forward_port: 4242, + } + } +} + +static CONFIG: OnceLock = OnceLock::new(); +static STARTED: OnceLock<()> = OnceLock::new(); +static START_MUTEX: Mutex<()> = Mutex::new(()); + +/// Records the carrier configuration; the first dial wins, as in a CLI the +/// flags are constant for the run. +pub fn configure(config: RnsConfig) { + let _ = CONFIG.set(config); +} + +/// Starts the Reticulum stack at most once, on the first RNS dial. +pub fn ensure_started() -> Result<(), SmolError> { + if STARTED.get().is_some() { + return Ok(()); + } + let _guard = START_MUTEX.lock().unwrap(); + if STARTED.get().is_some() { + return Ok(()); + } + let config = CONFIG.get().cloned().unwrap_or_default(); + std::fs::create_dir_all(Path::new(&config.storage_dir)) + .map_err(|e| SmolError::new(format!("cannot create {}: {e}", config.storage_dir)))?; + ffi::start(&config)?; + let _ = STARTED.set(()); + Ok(()) +} diff --git a/src/rns/transport.rs b/src/rns/transport.rs new file mode 100644 index 0000000..659484f --- /dev/null +++ b/src/rns/transport.rs @@ -0,0 +1,83 @@ +//! `impl Transport` for the RNS carrier: one Reticulum link to a +//! `smolmail.server` destination, one request in flight at a time. +//! +//! There is nothing to pin here (upstream spec sec 13.4): a destination hash +//! is a 128-bit truncated hash over the server identity's public keys, and +//! Reticulum proves the link against exactly that identity. The address is +//! the pin, so `pinned()` is always true and RESOLVE results are as good as +//! the channel the address arrived on. + +use std::time::Duration; + +use crate::error::SmolError; +use crate::rns::{ensure_started, ffi}; +use crate::transport::{Response, Transport, TransportBindValues}; + +pub struct RnsTransport { + /// The 16-byte destination hash the address carried. + destination: [u8; 16], + /// What AUTH and REGISTER signatures bind to (upstream spec sec 13.6): + /// derived from destination || link_id, replacing the Noise handshake + /// hash and the server's static key. + bind: TransportBindValues, + /// The per-request timeout a client MUST set itself (upstream spec sec + /// 13.5): Reticulum's default is derived from the round trip time and + /// covers a packet, not a FETCH page. + timeout: Duration, +} + +impl RnsTransport { + /// Starts the carrier if needed, requests a path, and opens a fresh + /// link — never reused across authentications, since link_id is what + /// stops an AUTH replaying on another link (upstream spec sec 13.6). + pub fn connect(destination: &[u8; 16], timeout: Duration) -> Result { + ensure_started()?; + let link_id = ffi::connect(destination, timeout)?; + Ok(RnsTransport { + destination: *destination, + bind: TransportBindValues::rns(destination, &link_id), + timeout, + }) + } + + /// The destination hash, as the `smol+rns://` address host part. + pub fn destination_hex(&self) -> String { + data_encoding::HEXLOWER.encode(&self.destination) + } +} + +impl Transport for RnsTransport { + /// Sends `op u8 || body` and returns the `status u8 || payload` response + /// (upstream spec sec 13.5): every operation body and response payload + /// is reused byte for byte from SPEC.md sec 6.1, with only the `length + /// u32` gone, because Reticulum delimits messages itself. + fn request(&mut self, op: u8, body: &[u8]) -> Result { + let mut wire = Vec::with_capacity(1 + body.len()); + wire.push(op); + wire.extend_from_slice(body); + let response = ffi::request(&wire, self.timeout)?; + // A server MUST answer every registered request with a status, so an + // empty response is a peer misbehaving, not a local error. + let status = response + .first() + .copied() + .ok_or_else(|| SmolError::new("empty response from server"))?; + Ok(Response { + status, + body: response[1..].to_vec(), + }) + } + + fn bind(&self) -> &TransportBindValues { + &self.bind + } + + fn pinned(&self) -> bool { + // The destination hash is the pin (upstream spec sec 13.4). + true + } + + fn close(&mut self) { + ffi::close(); + } +}