From 8ea6865e7e6665d10359bc15ef0c9f3b368a79b4 Mon Sep 17 00:00:00 2001 From: randogoth Date: Tue, 29 Sep 2026 10:27:44 +0300 Subject: [PATCH] feat: harden the RNS carrier: idle-link reaper, refusal logging, patched microReticulum fork --- README.md | 2 +- RNS.md | 6 ++-- THIRD_PARTY_NOTICES.md | 4 +-- flake.lock | 20 +++++------ flake.nix | 17 +++++++++- src/rns.rs | 77 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 110 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index a4023a3..fcfed78 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ bunshin rns-keygen --key server.rns.key bunshin serve --rns --key server.key --rns-key server.rns.key --db mail.db --rns-udp 0.0.0.0:4242 ``` -`rns-keygen` writes the 64-byte Reticulum identity and prints the destination hash; publish `smol+rns://@` as the server's address. RNS-specific limits (`--rns-max-envelope`, `--rns-fetch-budget`, `--rns-max-links`, `--rns-rate-link-requests`, `--rns-rate-link-bytes`) default to mesh-friendly 32 KiB caps; the UDP interface is the one supported transport interface so far. See [RNS.md](RNS.md). +`rns-keygen` writes the 64-byte Reticulum identity and prints the destination hash; publish `smol+rns://@` as the server's address. RNS-specific limits (`--rns-max-envelope`, `--rns-fetch-budget`, `--rns-max-links`, `--rns-rate-link-requests`, `--rns-rate-link-bytes`) default to mesh-friendly 32 KiB caps; `--rns-link-idle` (default 300 s) reaps links whose client went silent, since nothing else times a link out; the UDP interface is the one supported transport interface so far. See [RNS.md](RNS.md). ## Building diff --git a/RNS.md b/RNS.md index 29eb159..c9a15fe 100644 --- a/RNS.md +++ b/RNS.md @@ -126,6 +126,7 @@ One process serves both carriers: shared `Store`, shared config, one systemd uni | `--rns-max-links` | 100 | concurrent links — refused past the cap | | `--rns-rate-link-requests` | 60 | per link per minute | | `--rns-rate-link-bytes` | 1048576 | per link per minute, via `ByteRateLimiter` | +| `--rns-link-idle` | 300 | seconds of silence before a link is reaped; 0 disables the reaper | | `--rns-udp` | `127.0.0.1:4242` | UDP interface to listen on, host[:port] | | `--rns-udp-forward` | unset | optional forward target; without it the interface replies to the last datagram's source | @@ -144,8 +145,9 @@ The per-link request limiter reuses `RateLimiter` keyed by the link hex. The per ## 9. Remaining open items 1. Cross-transport envelope size: per-transport caps are enforced; a 768 KiB TCP envelope is still delivered whole to an RNS FETCH. Operators should apply the smaller cap mailbox-wide when in doubt (upstream spec 13.7). -2. Interfaces beyond UDP (RNS-compatible TCP hub/client, I2P): microReticulum ships none; the UDP interface is the first supported one. -3. The reference `test_interop/run_request_response.sh` harness was not rerun against the shim; its PlatformIO build step is unavailable here. The equivalent interop was exercised directly against `smolmail_rns.py` (§10.6). +2. Upstream `microReticulum` deadlock: a client dying mid-resource-transfer wedges the single transport loop forever — `Resource::request_next` sends its `RESOURCE_REQ` through `Transport::outbound` from inside the `jobs()` resource tick, and `outbound` busy-waits on `_jobs_running`, a flag `jobs()` itself holds. No announces, no new links, mesh dead until restart. The vendored source is a local fork carrying the fix (same-thread reentrant calls skip the cycle wait); the equivalent report is filed upstream. +3. Interfaces beyond UDP (RNS-compatible TCP hub/client, I2P): microReticulum ships none; the UDP interface is the first supported one. +4. The reference `test_interop/run_request_response.sh` harness was not rerun against the shim; its PlatformIO build step is unavailable here. The equivalent interop was exercised directly against `smolmail_rns.py` (§10.6). Formerly open items resolved during implementation: raw identity bytes exist (`load_private_key`); the shim ABI and run-loop drive pattern are §7.1/§7.2; `ALLOW_ALL` matches the reference; announcing at startup + every 2 h, unconditional even when invite-only (the invite gates REGISTER, not the link); the interface question is §7.4. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index c4bf123..bdfa0d7 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -1,6 +1,6 @@ # Third-Party Notices -bunshin incorporates the third-party components listed below. Components are grouped by license, each with the copyright notice that applies to it, followed by the full license text. A component listed under several licenses is multi-licensed under those options; any one of them may be chosen. All components are redistributed unmodified from their published sources. +bunshin incorporates the third-party components listed below. Components are grouped by license, each with the copyright notice that applies to it, followed by the full license text. A component listed under several licenses is multi-licensed under those options; any one of them may be chosen. Components are redistributed from their published sources, except where a bullet notes a local patch; all other components are unmodified. The Rust crates listed are all crates resolved by `Cargo.lock`, including transitive dependencies, which are statically linked into the bunshin binary. The SQLite C library is bundled in source form by the `libsqlite3-sys` crate and is public domain (see the final section). @@ -102,7 +102,7 @@ The libraries marked "vendored source" are pinned nix flake inputs; each is iden - zerocopy-derive 0.8.59 - Copyright 2023 The Fuchsia Authors - zeroize 1.9.0 - Copyright (c) 2018-2026 The RustCrypto Project Developers - zeroize_derive 1.5.0 - Copyright (c) 2019-2026 The RustCrypto Project Developers -- microReticulum (vendored source, https://github.com/attermann/microReticulum, rev 40fa628809d5) +- microReticulum (vendored source, https://github.com/attermann/microReticulum, rev 40fa628809d5; patched locally — same-thread reentrant calls skip the Transport::outbound/inbound jobs-cycle wait, fixing a transport-loop deadlock, see RNS.md §9) - microStore (vendored source, https://github.com/attermann/microStore, rev 0f28567fe00a) ``` diff --git a/flake.lock b/flake.lock index 4880ea0..f167260 100644 --- a/flake.lock +++ b/flake.lock @@ -21,18 +21,18 @@ "microReticulum": { "flake": false, "locked": { - "lastModified": 1784575896, - "narHash": "sha256-cqo+BJ3tsmw4fD+SL0D3X9FKCgf+n0VQng2pSIuyK/8=", - "owner": "attermann", - "repo": "microReticulum", - "rev": "40fa628809d57140180c1c833559ab96fec992c1", - "type": "github" + "lastModified": 1790668417, + "narHash": "sha256-BrOkpepwcCfB7o9TOYOQ3SnZfwHVx3WyUTJsIGwAdLk=", + "ref": "fix/outbound-reentrancy-deadlock", + "rev": "03be60051d40e556933101289c0203d9f3308b62", + "revCount": 226, + "type": "git", + "url": "file:///mnt/data/Projects/code/microReticulum" }, "original": { - "owner": "attermann", - "repo": "microReticulum", - "rev": "40fa628809d57140180c1c833559ab96fec992c1", - "type": "github" + "ref": "fix/outbound-reentrancy-deadlock", + "type": "git", + "url": "file:///mnt/data/Projects/code/microReticulum" } }, "nixpkgs": { diff --git a/flake.nix b/flake.nix index 8b105fe..71e4afd 100644 --- a/flake.nix +++ b/flake.nix @@ -9,8 +9,13 @@ # 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 RNS.md sec 5 verified against. + # Local fork of attermann/microReticulum at rev 40fa628809d5 plus a + # local patch: Transport::outbound/inbound skip their jobs-cycle wait + # for same-thread reentrant calls, which otherwise deadlock the single + # transport loop when a stalled resource tick sends a RESOURCE_REQ + # from inside jobs() (see RNS.md sec 9). microReticulum = { - url = "github:attermann/microReticulum/40fa628809d57140180c1c833559ab96fec992c1"; + url = "git+file:///mnt/data/Projects/code/microReticulum?ref=fix/outbound-reentrancy-deadlock"; flake = false; }; rns-arduinojson = { @@ -272,6 +277,15 @@ description = "Max request bytes per minute, per link."; }; + linkIdleSecs = mkOption { + type = types.ints.unsigned; + default = 300; + description = '' + Seconds after which an idle RNS link is reaped, freeing + its session and link slot; 0 disables the reaper. + ''; + }; + udpListenPort = mkOption { type = types.port; default = 4242; @@ -337,6 +351,7 @@ --rns-max-links ${toString cfg.rns.maxLinks} --rns-rate-link-requests ${toString cfg.rns.rateLinkRequests} --rns-rate-link-bytes ${toString cfg.rns.rateLinkBytes} + --rns-link-idle ${toString cfg.rns.linkIdleSecs} --rns-udp 0.0.0.0:${toString cfg.rns.udpListenPort} ) ''} diff --git a/src/rns.rs b/src/rns.rs index 94271d1..279329a 100644 --- a/src/rns.rs +++ b/src/rns.rs @@ -12,6 +12,7 @@ use std::collections::{HashMap, HashSet}; use std::ffi::CString; use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant}; use crate::bind::TransportBindValues; use crate::proto::{AUTH_FULL_TOKENS, INTERNAL_ERROR, MALFORMED, RATE_LIMITED, TOO_LARGE}; @@ -59,6 +60,7 @@ pub struct RnsArgs { pub udp_listen_port: u16, pub udp_forward_host: Option, pub udp_forward_port: u16, + pub link_idle_secs: u64, pub max_tokens: u16, pub main_quota: i64, pub requests_quota: i64, @@ -71,6 +73,10 @@ struct RnsState { destination: [u8; 16], sessions: Mutex>>, links: Mutex>, + /// Last-seen instant per link, the reaper's clock. Updated before the + /// limiters so a client still sending — even one being refused — counts + /// as alive; only a silent link is reapable. + last_activity: Mutex>, request_limiter: RateLimiter, byte_limiter: ByteRateLimiter, max_links: usize, @@ -117,6 +123,7 @@ pub fn start(args: RnsArgs) -> anyhow::Result { destination, sessions: Mutex::new(HashMap::new()), links: Mutex::new(HashSet::new()), + last_activity: Mutex::new(HashMap::new()), request_limiter: RateLimiter::new(args.rate_link_requests), byte_limiter: ByteRateLimiter::new(args.rate_link_bytes), max_links: args.max_links, @@ -124,6 +131,13 @@ pub fn start(args: RnsArgs) -> anyhow::Result { }; STATE.get_or_init(|| state); + // microReticulum never times a link out and the shim has no server-side + // close, so a client that dies mid-link would hold its Store handle and + // maxLinks slot forever; 0 keeps the old never-expire behavior. + if args.link_idle_secs > 0 { + std::thread::spawn(move || reap_loop(Duration::from_secs(args.link_idle_secs))); + } + let storage_dir = CString::new(args.storage_dir).unwrap(); let listen_host = CString::new(args.udp_listen_host).unwrap(); let forward_host = args.udp_forward_host.map(|h| CString::new(h).unwrap()); @@ -185,6 +199,9 @@ fn handle_request(request: &[u8], link_id: &[u8; 16]) -> Vec { // an AUTH carrying a full token set, whichever is larger. let max_request = (state.config.max_envelope + 34) .max(AUTH_FULL_TOKENS + 32 * state.config.max_tokens as usize); + // Any request, even one about to be refused, proves the link is alive. + state.last_activity.lock().unwrap().insert(*link_id, Instant::now()); + if request.len() > max_request { log::warn!( "request of {} bytes over {} byte cap", @@ -194,6 +211,9 @@ fn handle_request(request: &[u8], link_id: &[u8; 16]) -> Vec { return response(TOO_LARGE); } if !state.request_limiter.allow(&link) || !state.byte_limiter.allow(&link, request.len()) { + // Pre-dispatch refusals never reach the session metrics, so they get + // their own line or the journal undercounts a spinning client. + log::debug!("link {link} rate limited: request refused"); return response(RATE_LIMITED); } @@ -264,6 +284,45 @@ extern "C" fn smolmail_rns_take_response(out: *mut u8, cap: usize) -> usize { } } +/// Links whose last activity is at least `idle` old, with their age. +fn expired_links( + last_activity: &HashMap<[u8; 16], Instant>, + now: Instant, + idle: Duration, +) -> Vec<([u8; 16], Duration)> { + last_activity + .iter() + .filter(|(_, seen)| now.duration_since(**seen) >= idle) + .map(|(id, seen)| (*id, now.duration_since(*seen))) + .collect() +} + +/// Evicts links that have gone silent: microReticulum never times a link +/// out and the shim has no server-side close, so a client that dies +/// mid-link would otherwise hold its Store handle and maxLinks slot +/// forever. A reaped link that somehow speaks again is treated as a fresh +/// session (unauthenticated until the next AUTH), which is safe. +fn reap_loop(idle: Duration) { + let interval = (idle / 4).clamp(Duration::from_secs(1), Duration::from_secs(60)); + loop { + std::thread::sleep(interval); + let Some(state) = STATE.get() else { + return; + }; + let expired = expired_links(&state.last_activity.lock().unwrap(), Instant::now(), idle); + for (link, age) in expired { + state.links.lock().unwrap().remove(&link); + state.sessions.lock().unwrap().remove(&link); + state.last_activity.lock().unwrap().remove(&link); + log::debug!( + "link {} reaped after {:?} idle", + data_encoding::HEXLOWER.encode(&link), + age + ); + } + } +} + #[no_mangle] extern "C" fn smolmail_rns_on_link_opened(link_id: *const u8) -> i32 { let Some(state) = STATE.get() else { @@ -280,6 +339,9 @@ extern "C" fn smolmail_rns_on_link_opened(link_id: *const u8) -> i32 { return 1; } links.insert(link); + if let Some(state) = STATE.get() { + state.last_activity.lock().unwrap().insert(link, Instant::now()); + } log::debug!("link {} opened ({} open)", data_encoding::HEXLOWER.encode(&link), links.len()); 0 } @@ -290,6 +352,7 @@ extern "C" fn smolmail_rns_on_link_closed(link_id: *const u8) { let link: [u8; 16] = unsafe { std::slice::from_raw_parts(link_id, 16).try_into().unwrap() }; state.links.lock().unwrap().remove(&link); state.sessions.lock().unwrap().remove(&link); + state.last_activity.lock().unwrap().remove(&link); log::debug!("link {} closed", data_encoding::HEXLOWER.encode(&link)); } } @@ -311,4 +374,18 @@ mod tests { "799855f4955f1b09fd20a13cd84f4e71" ); } + + #[test] + fn expired_links_selects_only_silent_ones() { + let mut last_activity = HashMap::new(); + let now = Instant::now(); + let idle = Duration::from_secs(300); + last_activity.insert([1u8; 16], now - Duration::from_secs(301)); // silent + last_activity.insert([2u8; 16], now - Duration::from_secs(299)); // active + last_activity.insert([3u8; 16], now); // just seen + let expired = expired_links(&last_activity, now, idle); + assert_eq!(expired.len(), 1); + assert_eq!(expired[0].0, [1u8; 16]); + assert!(expired[0].1 >= idle); + } }