feat: implement Phase 2 RNS carrier
This commit is contained in:
parent
37df976fde
commit
22fe9f1b9f
14 changed files with 1439 additions and 12 deletions
|
|
@ -4,7 +4,7 @@ A Rust client for [Smol Mail](https://code.randogoth.com/randogoth/smolmail): a
|
||||||
|
|
||||||
## Status
|
## 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
|
## Addressing
|
||||||
|
|
||||||
|
|
@ -45,8 +45,14 @@ fumi fetch [--keep] [--reset] retrieve, verify, store, acknowledge
|
||||||
fumi delete <id>... remove from the server explicitly
|
fumi delete <id>... remove from the server explicitly
|
||||||
fumi list [--sent] [--requests]
|
fumi list [--sent] [--requests]
|
||||||
fumi read <id> [--sent]
|
fumi read <id> [--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 <host:port> --rns-udp-forward <host:port> [--rns-storage DIR] <command>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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.
|
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
|
## 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/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/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/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/client.rs` | the six operations, AUTH, chain walking, token sync, fetch pipeline |
|
||||||
| `src/store.rs` | SQLite: state, contacts, accepted, tokens, seen ids, inbox, sent |
|
| `src/store.rs` | SQLite: state, contacts, accepted, tokens, seen ids, inbox, sent |
|
||||||
|
|
||||||
|
|
|
||||||
187
build.rs
Normal file
187
build.rs
Normal file
|
|
@ -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 `<cstdint>` 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 <cstdint> 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 <cstdint>") {
|
||||||
|
let patched = text.replacen(
|
||||||
|
"#include <memory>",
|
||||||
|
"#include <memory>\n#include <cstdint>",
|
||||||
|
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<std::path::PathBuf> = 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() {}
|
||||||
146
flake.lock
generated
146
flake.lock
generated
|
|
@ -37,7 +37,15 @@
|
||||||
"root": {
|
"root": {
|
||||||
"inputs": {
|
"inputs": {
|
||||||
"flake-utils": "flake-utils",
|
"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": {
|
"systems": {
|
||||||
|
|
@ -54,6 +62,142 @@
|
||||||
"repo": "default",
|
"repo": "default",
|
||||||
"type": "github"
|
"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",
|
"root": "root",
|
||||||
|
|
|
||||||
83
flake.nix
83
flake.nix
|
|
@ -4,21 +4,81 @@
|
||||||
inputs = {
|
inputs = {
|
||||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
flake-utils.url = "github:numtide/flake-utils";
|
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_<DEP>_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:
|
flake-utils.lib.eachDefaultSystem (system:
|
||||||
let
|
let
|
||||||
pkgs = import nixpkgs { inherit system; };
|
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 {
|
fumi = { rns ? false }: pkgs.rustPlatform.buildRustPackage {
|
||||||
pname = "fumi";
|
pname = "fumi";
|
||||||
version = "0.1.0";
|
version = "0.1.0";
|
||||||
src = ./.;
|
src = ./.;
|
||||||
cargoLock.lockFile = ./Cargo.lock;
|
cargoLock.lockFile = ./Cargo.lock;
|
||||||
buildFeatures = pkgs.lib.optionals rns [ "rns" ];
|
nativeBuildInputs = [ pkgs.pkg-config ]
|
||||||
# The rns feature vendors microReticulum behind the shim and needs
|
++ lib.optionals rns [ pkgs.cmake pkgs.ninja pkgs.gcc ];
|
||||||
# MICRORETICULUM_SOURCE_DIR; it is added with the RNS carrier.
|
buildFeatures = lib.optionals rns [ "rns" ];
|
||||||
doCheck = !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
|
in
|
||||||
{
|
{
|
||||||
|
|
@ -31,7 +91,18 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
devShells.default = pkgs.mkShell {
|
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;
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
288
shim/smolmail_rns.cpp
Normal file
288
shim/smolmail_rns.cpp
Normal file
|
|
@ -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 <microStore/FileSystem.h>
|
||||||
|
#include <microStore/Adapters/UniversalFileSystem.h>
|
||||||
|
#include <MsgPack.h>
|
||||||
|
|
||||||
|
#include <microReticulum.h>
|
||||||
|
|
||||||
|
#include <chrono>
|
||||||
|
#include <condition_variable>
|
||||||
|
#include <cstring>
|
||||||
|
#include <mutex>
|
||||||
|
#include <thread>
|
||||||
|
|
||||||
|
// 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<std::mutex> lock(link_mutex);
|
||||||
|
link_established = true;
|
||||||
|
link_cv.notify_all();
|
||||||
|
}
|
||||||
|
|
||||||
|
static void on_link_closed(RNS::Link& closed) {
|
||||||
|
(void)closed;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> lock(link_mutex);
|
||||||
|
link_established = false;
|
||||||
|
link_closed_early = false;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> 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<std::mutex> lock(link_mutex);
|
||||||
|
while (!link_established && !link_closed_early
|
||||||
|
&& RNS::Utilities::OS::time() < deadline) {
|
||||||
|
link_cv.wait_for(lock, std::chrono::duration<double>(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<std::mutex> 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<std::mutex> 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<uint8_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});
|
||||||
|
}
|
||||||
|
}
|
||||||
59
shim/smolmail_rns.h
Normal file
59
shim/smolmail_rns.h
Normal file
|
|
@ -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 <stddef.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#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 */
|
||||||
198
shim/udp_interface.cpp
Normal file
198
shim/udp_interface.cpp
Normal 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
64
shim/udp_interface.h
Normal 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
|
||||||
|
|
||||||
|
};
|
||||||
|
|
@ -66,9 +66,32 @@ pub fn connect(
|
||||||
unpinned_static,
|
unpinned_static,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
Scheme::Rns => Err(SmolError::new(
|
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",
|
"smol+rns:// addresses need the rns feature; rebuild with --features rns",
|
||||||
)),
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ pub mod client;
|
||||||
pub mod crypto;
|
pub mod crypto;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod message;
|
pub mod message;
|
||||||
|
#[cfg(feature = "rns")]
|
||||||
|
pub mod rns;
|
||||||
pub mod store;
|
pub mod store;
|
||||||
pub mod tcp;
|
pub mod tcp;
|
||||||
pub mod transport;
|
pub mod transport;
|
||||||
|
|
|
||||||
75
src/main.rs
75
src/main.rs
|
|
@ -27,10 +27,30 @@ struct Cli {
|
||||||
/// Network timeout in seconds
|
/// Network timeout in seconds
|
||||||
#[arg(long, global = true, default_value_t = 30)]
|
#[arg(long, global = true, default_value_t = 30)]
|
||||||
timeout: u64,
|
timeout: u64,
|
||||||
|
#[cfg(feature = "rns")]
|
||||||
|
#[command(flatten)]
|
||||||
|
rns: RnsArgs,
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: Command,
|
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<String>,
|
||||||
|
/// Reticulum storage directory
|
||||||
|
#[arg(long = "rns-storage", global = true, default_value = "fumi.rns")]
|
||||||
|
storage: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
enum Command {
|
enum Command {
|
||||||
/// Create a master secret
|
/// Create a master secret
|
||||||
|
|
@ -132,6 +152,8 @@ fn main() {
|
||||||
|
|
||||||
fn run(cli: Cli) -> Result<()> {
|
fn run(cli: Cli) -> Result<()> {
|
||||||
let store = Store::open(&cli.db)?;
|
let store = Store::open(&cli.db)?;
|
||||||
|
#[cfg(feature = "rns")]
|
||||||
|
fumi::rns::configure(rns_config(&cli));
|
||||||
match &cli.command {
|
match &cli.command {
|
||||||
Command::Keygen { force } => keygen(&cli.key, *force),
|
Command::Keygen { force } => keygen(&cli.key, *force),
|
||||||
Command::Whoami => whoami(&cli.key, &store),
|
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 {
|
fn hex(id: &[u8; ID_LEN]) -> String {
|
||||||
data_encoding::HEXLOWER.encode(id)
|
data_encoding::HEXLOWER.encode(id)
|
||||||
}
|
}
|
||||||
|
|
@ -756,6 +817,20 @@ fn civil_from_days(z: i64) -> (i64, u32, u32) {
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn utc_formatting_matches_known_dates() {
|
fn utc_formatting_matches_known_dates() {
|
||||||
assert_eq!(format_utc(0), "1970-01-01 00:00");
|
assert_eq!(format_utc(0), "1970-01-01 00:00");
|
||||||
|
|
|
||||||
155
src/rns/ffi.rs
Normal file
155
src/rns/ffi.rs
Normal file
|
|
@ -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<Vec<u8>, 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);
|
||||||
71
src/rns/mod.rs
Normal file
71
src/rns/mod.rs
Normal file
|
|
@ -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<String>,
|
||||||
|
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<RnsConfig> = 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(())
|
||||||
|
}
|
||||||
83
src/rns/transport.rs
Normal file
83
src/rns/transport.rs
Normal file
|
|
@ -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<RnsTransport, SmolError> {
|
||||||
|
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<Response, SmolError> {
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue