1159 lines
37 KiB
Markdown
1159 lines
37 KiB
Markdown
|
|
# RNS Integration Plan for Bunshin (Smol Mail 1.2 Compatibility)
|
||
|
|
|
||
|
|
## Overview
|
||
|
|
|
||
|
|
This document outlines how to integrate Reticulum Network Stack (RNS) transport into bunshin to achieve Smol Mail protocol version 1.2 compatibility. The current bunshin implementation supports only TCP with Noise (v1.1). RNS provides an alternative transport that works over mesh networks (LoRa, packet radio, serial links, I2P, TCP) without requiring DNS, IP addresses, or fixed topology.
|
||
|
|
|
||
|
|
## Key Finding: No Official Rust RNS Crate
|
||
|
|
|
||
|
|
After research, **there is no officially recognized Rust crate for Reticulum**. The official implementations are:
|
||
|
|
- **Python**: `RNS` module (reference implementation used by smolmail)
|
||
|
|
- **C++**: [`microReticulum`](https://github.com/attermann/microReticulum) by attermann (most mature, production-ready)
|
||
|
|
- **C++**: Community-maintained ports
|
||
|
|
|
||
|
|
Therefore, we have three options for Rust integration:
|
||
|
|
|
||
|
|
| Option | Approach | Pros | Cons |
|
||
|
|
|--------|----------|------|------|
|
||
|
|
| **A. FFI with microReticulum** | Call C++ from Rust via FFI | Official implementation, full compliance | Complex FFI, C API may be unstable |
|
||
|
|
| **B. Python IPC** | Run `smolmaild_rns.py` as subprocess, share DB | Fast to implement, known-good | Two processes, Python dependency |
|
||
|
|
| **C. Wait for Rust crate** | Track `reticulum-rs`/`rns-core` | Native Rust, clean integration | Unknown timeline, may never be official |
|
||
|
|
|
||
|
|
**Recommendation: Option A (FFI) for production, Option B (IPC) for quick validation**
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Understanding the Python RNS API
|
||
|
|
|
||
|
|
From `smolmaild_rns.py`, the server implementation uses:
|
||
|
|
|
||
|
|
```python
|
||
|
|
# Identity and destination
|
||
|
|
identity = RNS.Identity.from_file("server.rns.key")
|
||
|
|
destination = RNS.Destination(identity, RNS.Destination.IN, RNS.Destination.SINGLE, "smolmail", "server")
|
||
|
|
dest_hash = RNS.Destination.hash(identity, "smolmail", "server") # 16 bytes
|
||
|
|
|
||
|
|
# Request handling on path "smolmail/1"
|
||
|
|
destination.register_request_handler(
|
||
|
|
"smolmail/1",
|
||
|
|
response_generator=handle_request,
|
||
|
|
allow=RNS.Destination.ALLOW_ALL
|
||
|
|
)
|
||
|
|
|
||
|
|
# Link lifecycle
|
||
|
|
link.link_id # 16 bytes, ephemeral per-link
|
||
|
|
link.set_link_established_callback(on_established)
|
||
|
|
link.set_link_closed_callback(on_closed)
|
||
|
|
```
|
||
|
|
|
||
|
|
The **message format** is simply: `u8 type || body` (no length prefix, RNS delimits)
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Architecture
|
||
|
|
|
||
|
|
```
|
||
|
|
┌─────────────────────────────────────────────────────────┐
|
||
|
|
│ bunshin server │
|
||
|
|
├─────────────────────────────────────────────────────────┤
|
||
|
|
│ ┌─────────────────┐ ┌─────────────────────────────┐ │
|
||
|
|
│ │ TCP Transport │ │ RNS Transport │ │
|
||
|
|
│ │ (tcp_transport) │ │ (rns/transport + FFI) │ │
|
||
|
|
│ └──────┬──────────┘ └──────────┬──────────────────┘ │
|
||
|
|
│ │ │ │
|
||
|
|
│ ▼ ▼ │
|
||
|
|
│ ┌─────────────────────────────────────────────────────┐ │
|
||
|
|
│ │ Session (modified) │ │
|
||
|
|
│ │ - Uses TransportBindValues instead of │ │
|
||
|
|
│ │ handshake_hash + server_static │ │
|
||
|
|
│ └────────────────────┬───────────────────────────────┘ │
|
||
|
|
│ │ │
|
||
|
|
│ ▼ │
|
||
|
|
│ ┌─────────────────────────────────────────────────────┐ │
|
||
|
|
│ │ Store (unchanged) │ │
|
||
|
|
│ │ SQLite, shared between transports │ │
|
||
|
|
│ └─────────────────────────────────────────────────────┘ │
|
||
|
|
└─────────────────────────────────────────────────────────┘
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Implementation Phases
|
||
|
|
|
||
|
|
### Phase 1: Transport Abstraction (Do First - No Dependencies)
|
||
|
|
|
||
|
|
This phase makes the codebase ready for any transport implementation.
|
||
|
|
|
||
|
|
#### 1.1 Create `src/transport.rs`
|
||
|
|
|
||
|
|
```rust
|
||
|
|
/// Transport-agnostic session binding values
|
||
|
|
/// From RNS.md S13.6: these substitute the Noise handshake hash and static key
|
||
|
|
pub struct TransportBindValues {
|
||
|
|
/// Replaces Noise handshake hash for AUTH signature
|
||
|
|
pub h: [u8; 32],
|
||
|
|
/// Replaces Noise server_static for REGISTER signature
|
||
|
|
pub server_static: [u8; 32],
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Trait that all transports must implement
|
||
|
|
pub trait Transport {
|
||
|
|
/// Read one frame: (op, body)
|
||
|
|
fn read_frame(&mut self) -> anyhow::Result<(u8, Vec<u8>)>;
|
||
|
|
|
||
|
|
/// Write one frame
|
||
|
|
fn write_frame(&mut self, op: u8, body: &[u8]) -> anyhow::Result<()>;
|
||
|
|
|
||
|
|
/// Peer identifier for rate limiting (IP address or link_id hash)
|
||
|
|
fn peer_id(&self) -> &str;
|
||
|
|
|
||
|
|
/// Get binding values for this session
|
||
|
|
fn bind_values(&self) -> TransportBindValues;
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
#### 1.2 Refactor `channel.rs` to `src/tcp_transport.rs`
|
||
|
|
|
||
|
|
Move existing Noise/TCP logic into a TCP transport implementation:
|
||
|
|
|
||
|
|
```rust
|
||
|
|
use std::net::TcpStream;
|
||
|
|
use snow::TransportState;
|
||
|
|
|
||
|
|
use crate::proto::{NOISE_PAYLOAD, PROLOGUE};
|
||
|
|
use crate::transport::TransportBindValues;
|
||
|
|
|
||
|
|
pub struct TcpTransport {
|
||
|
|
stream: TcpStream,
|
||
|
|
transport: TransportState,
|
||
|
|
buf: Vec<u8>,
|
||
|
|
handshake_hash: Vec<u8>,
|
||
|
|
server_static: [u8; 32],
|
||
|
|
peer_ip: String,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl TcpTransport {
|
||
|
|
pub fn new(
|
||
|
|
stream: TcpStream,
|
||
|
|
transport: TransportState,
|
||
|
|
handshake_hash: Vec<u8>,
|
||
|
|
server_static: [u8; 32],
|
||
|
|
peer_ip: String,
|
||
|
|
) -> Self {
|
||
|
|
Self {
|
||
|
|
stream,
|
||
|
|
transport,
|
||
|
|
buf: Vec::new(),
|
||
|
|
handshake_hash,
|
||
|
|
server_static,
|
||
|
|
peer_ip,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
impl Transport for TcpTransport {
|
||
|
|
fn read_frame(&mut self) -> anyhow::Result<(u8, Vec<u8>)> {
|
||
|
|
while self.buf.len() < 5 {
|
||
|
|
let chunk = self.read_noise()?;
|
||
|
|
self.buf.extend_from_slice(&chunk);
|
||
|
|
}
|
||
|
|
let length = u32::from_be_bytes(self.buf[..4].try_into().unwrap()) as usize;
|
||
|
|
if length < 1 || length > crate::proto::MAX_FRAME {
|
||
|
|
return Err(crate::proto::ProtocolError::new(format!("frame length {} out of range", length)).into());
|
||
|
|
}
|
||
|
|
while self.buf.len() < 4 + length {
|
||
|
|
let chunk = self.read_noise()?;
|
||
|
|
self.buf.extend_from_slice(&chunk);
|
||
|
|
}
|
||
|
|
let frame: Vec<u8> = self.buf[4..4 + length].to_vec();
|
||
|
|
self.buf.drain(..4 + length);
|
||
|
|
Ok((frame[0], frame[1..].to_vec()))
|
||
|
|
}
|
||
|
|
|
||
|
|
fn write_frame(&mut self, op: u8, body: &[u8]) -> anyhow::Result<()> {
|
||
|
|
let mut frame = Vec::with_capacity(5 + body.len());
|
||
|
|
frame.extend_from_slice(&((1 + body.len()) as u32).to_be_bytes());
|
||
|
|
frame.push(op);
|
||
|
|
frame.extend_from_slice(body);
|
||
|
|
for chunk in frame.chunks(NOISE_PAYLOAD) {
|
||
|
|
self.write_noise(chunk)?;
|
||
|
|
}
|
||
|
|
Ok(())
|
||
|
|
}
|
||
|
|
|
||
|
|
fn peer_id(&self) -> &str {
|
||
|
|
&self.peer_ip
|
||
|
|
}
|
||
|
|
|
||
|
|
fn bind_values(&self) -> TransportBindValues {
|
||
|
|
TransportBindValues {
|
||
|
|
h: self.handshake_hash.clone().try_into().unwrap(),
|
||
|
|
server_static: self.server_static,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
impl TcpTransport {
|
||
|
|
fn read_noise(&mut self) -> anyhow::Result<Vec<u8>> {
|
||
|
|
let len = read_u16_len(&mut self.stream)?;
|
||
|
|
let mut ciphertext = vec![0u8; len];
|
||
|
|
read_exact_into(&mut self.stream, &mut ciphertext)?;
|
||
|
|
let mut plaintext = vec![0u8; len];
|
||
|
|
let n = self.transport.read_message(&ciphertext, &mut plaintext)?;
|
||
|
|
plaintext.truncate(n);
|
||
|
|
Ok(plaintext)
|
||
|
|
}
|
||
|
|
|
||
|
|
fn write_noise(&mut self, payload: &[u8]) -> anyhow::Result<()> {
|
||
|
|
let mut packet = vec![0u8; payload.len() + 16];
|
||
|
|
let n = self.transport.write_message(payload, &mut packet)?;
|
||
|
|
packet.truncate(n);
|
||
|
|
write_u16_len(&mut self.stream, &packet)?;
|
||
|
|
Ok(())
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
#### 1.3 Update `session.rs`
|
||
|
|
|
||
|
|
Replace `handshake_hash: Vec<u8>` with `bind_values: TransportBindValues`:
|
||
|
|
|
||
|
|
```rust
|
||
|
|
use crate::transport::TransportBindValues;
|
||
|
|
|
||
|
|
pub struct Session<'a> {
|
||
|
|
config: &'a ServerConfig,
|
||
|
|
store: Store,
|
||
|
|
peer_ip: String,
|
||
|
|
bind_values: TransportBindValues, // NEW: from transport
|
||
|
|
username: Option<String>,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl<'a> Session<'a> {
|
||
|
|
pub fn new(
|
||
|
|
config: &'a ServerConfig,
|
||
|
|
store: Store,
|
||
|
|
peer_ip: String,
|
||
|
|
bind_values: TransportBindValues, // NEW parameter
|
||
|
|
) -> Self {
|
||
|
|
Session { config, store, peer_ip, bind_values, username: None }
|
||
|
|
}
|
||
|
|
|
||
|
|
fn op_auth(&mut self, r: &mut Reader) -> OpResult {
|
||
|
|
let username = Self::read_str(r)?;
|
||
|
|
let identity = r.take(KEY_LEN)?.to_vec();
|
||
|
|
let signature = r.take(64)?.to_vec();
|
||
|
|
let sync = r.u8()?;
|
||
|
|
let count = r.u16()? as usize;
|
||
|
|
let mut tokens = Vec::with_capacity(count);
|
||
|
|
for _ in 0..count {
|
||
|
|
tokens.push(r.take(TOKEN_LEN)?.to_vec());
|
||
|
|
}
|
||
|
|
r.done()?;
|
||
|
|
|
||
|
|
if sync > 1 || (sync == 0 && count != 0) {
|
||
|
|
return Ok((MALFORMED, Vec::new()));
|
||
|
|
}
|
||
|
|
|
||
|
|
let bound = self.store.identity_of(&username)?;
|
||
|
|
if bound.as_deref() != Some(identity.as_slice()) {
|
||
|
|
return Ok((AUTH_FAILED, Vec::new()));
|
||
|
|
}
|
||
|
|
|
||
|
|
// CHANGED: use bind_values.h instead of self.handshake_hash
|
||
|
|
let mut msg = LABEL_AUTH.to_vec();
|
||
|
|
msg.extend_from_slice(&self.bind_values.h);
|
||
|
|
if !verify(&identity, &signature, &msg) {
|
||
|
|
return Ok((AUTH_FAILED, Vec::new()));
|
||
|
|
}
|
||
|
|
|
||
|
|
if sync == 1 {
|
||
|
|
if count > self.config.max_tokens as usize {
|
||
|
|
return Ok((TOO_LARGE, Vec::new()));
|
||
|
|
}
|
||
|
|
self.store.set_tokens(&username, &tokens)?;
|
||
|
|
}
|
||
|
|
|
||
|
|
let accepted = self.store.token_count(&username)?;
|
||
|
|
self.username = Some(username);
|
||
|
|
Ok((OK, accepted.to_be_bytes().to_vec()))
|
||
|
|
}
|
||
|
|
|
||
|
|
fn op_register(&mut self, r: &mut Reader) -> OpResult {
|
||
|
|
let username = Self::read_str(r)?;
|
||
|
|
let identity = r.take(KEY_LEN)?.to_vec();
|
||
|
|
let signature = r.take(64)?.to_vec();
|
||
|
|
let token_len = r.u8()? as usize;
|
||
|
|
let token = r.take(token_len)?.to_vec();
|
||
|
|
let cert_len = r.u8()? as usize;
|
||
|
|
let cert = r.take(cert_len)?.to_vec();
|
||
|
|
r.done()?;
|
||
|
|
|
||
|
|
if !valid_username(&username) {
|
||
|
|
return Ok((MALFORMED, Vec::new()));
|
||
|
|
}
|
||
|
|
|
||
|
|
// CHANGED: use bind_values.server_static instead of self.config.server_static
|
||
|
|
let mut pop_msg = LABEL_REGISTER.to_vec();
|
||
|
|
pop_msg.extend_from_slice(&self.bind_values.server_static);
|
||
|
|
pop_msg.extend_from_slice(username.as_bytes());
|
||
|
|
pop_msg.extend_from_slice(&identity);
|
||
|
|
if !verify(&identity, &signature, &pop_msg) {
|
||
|
|
return Ok((AUTH_FAILED, Vec::new()));
|
||
|
|
}
|
||
|
|
|
||
|
|
// ... rest unchanged ...
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
#### 1.4 Update `server.rs`
|
||
|
|
|
||
|
|
```rust
|
||
|
|
use crate::tcp_transport::TcpTransport;
|
||
|
|
use crate::transport::Transport;
|
||
|
|
|
||
|
|
fn handle_connection(
|
||
|
|
mut stream: TcpStream,
|
||
|
|
config: &ServerConfig,
|
||
|
|
static_key: &[u8],
|
||
|
|
db_path: &str,
|
||
|
|
peer_ip: &str,
|
||
|
|
) -> anyhow::Result<()> {
|
||
|
|
stream.set_read_timeout(Some(Duration::from_secs(IDLE_TIMEOUT_SECS)))?;
|
||
|
|
|
||
|
|
let (transport_state, handshake_hash) = match handshake(&mut stream, static_key) {
|
||
|
|
Ok(v) => v,
|
||
|
|
Err(e) => {
|
||
|
|
log::info!("handshake failed from {peer_ip}: {e}");
|
||
|
|
return Ok(());
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
let store = Store::open(db_path)?;
|
||
|
|
|
||
|
|
// Create TCP transport
|
||
|
|
let mut tcp_transport = TcpTransport::new(
|
||
|
|
stream,
|
||
|
|
transport_state,
|
||
|
|
handshake_hash,
|
||
|
|
config.server_static,
|
||
|
|
peer_ip.to_string(),
|
||
|
|
);
|
||
|
|
|
||
|
|
// Get bind values from transport
|
||
|
|
let bind_values = tcp_transport.bind_values();
|
||
|
|
let mut session = Session::new(config, store, peer_ip.to_string(), bind_values);
|
||
|
|
|
||
|
|
loop {
|
||
|
|
let (op, body) = match tcp_transport.read_frame() {
|
||
|
|
Ok(v) => v,
|
||
|
|
Err(e) => {
|
||
|
|
if is_eof_like(&e) {
|
||
|
|
return Ok(());
|
||
|
|
}
|
||
|
|
log::info!("bad frame from {peer_ip}: {e}");
|
||
|
|
let _ = tcp_transport.write_frame(0, &[MALFORMED]);
|
||
|
|
return Ok(());
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
let (status, payload) = session.dispatch(op, &body);
|
||
|
|
let mut response = Vec::with_capacity(1 + payload.len());
|
||
|
|
response.push(status);
|
||
|
|
response.extend_from_slice(&payload);
|
||
|
|
if tcp_transport.write_frame(op, &response).is_err() {
|
||
|
|
return Ok(());
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Also update `ServerConfig` to remove `server_static` (now transport-specific):
|
||
|
|
|
||
|
|
```rust
|
||
|
|
pub struct ServerConfig {
|
||
|
|
pub max_envelope: usize,
|
||
|
|
pub main_quota: i64,
|
||
|
|
pub requests_quota: i64,
|
||
|
|
pub max_tokens: u16,
|
||
|
|
pub invite_token: Option<Vec<u8>>,
|
||
|
|
pub conn_limiter: RateLimiter,
|
||
|
|
pub send_limiter: RateLimiter,
|
||
|
|
pub token_limiter: RateLimiter,
|
||
|
|
// server_static removed - now per-transport
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
#### 1.5 Update `main.rs`
|
||
|
|
|
||
|
|
Remove `server_static` from config building in `cmd_keygen` and `run`:
|
||
|
|
|
||
|
|
```rust
|
||
|
|
// In run() function, remove server_static from ServerConfig
|
||
|
|
let config = Arc::new(ServerConfig {
|
||
|
|
max_envelope: args.max_envelope,
|
||
|
|
main_quota: args.quota,
|
||
|
|
requests_quota: args.requests_quota,
|
||
|
|
max_tokens: args.max_tokens,
|
||
|
|
invite_token: args.invite_token.map(String::into_bytes),
|
||
|
|
conn_limiter: RateLimiter::new(args.rate_connections),
|
||
|
|
send_limiter: RateLimiter::new(args.rate_sends),
|
||
|
|
token_limiter: RateLimiter::new(args.rate_tokens),
|
||
|
|
// server_static no longer here
|
||
|
|
});
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Phase 2: FFI with microReticulum (For Native RNS)
|
||
|
|
|
||
|
|
#### 2.1 Add microReticulum to Nix
|
||
|
|
|
||
|
|
```nix
|
||
|
|
# In flake.nix
|
||
|
|
{
|
||
|
|
inputs = {
|
||
|
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||
|
|
flake-utils.url = "github:numtide/flake-utils";
|
||
|
|
microReticulum.url = "github:attermann/microReticulum";
|
||
|
|
};
|
||
|
|
|
||
|
|
outputs = { self, nixpkgs, flake-utils, microReticulum, ... }:
|
||
|
|
flake-utils.lib.eachDefaultSystem (system:
|
||
|
|
let
|
||
|
|
pkgs = import nixpkgs { inherit system; };
|
||
|
|
in
|
||
|
|
{
|
||
|
|
packages.default = pkgs.rustPlatform.buildRustPackage {
|
||
|
|
pname = "bunshin";
|
||
|
|
version = "0.1.0";
|
||
|
|
src = ./.;
|
||
|
|
cargoLock.lockFile = ./Cargo.lock;
|
||
|
|
nativeBuildInputs = [ pkgs.pkg-config ];
|
||
|
|
buildInputs = [ pkgs.sqlite ];
|
||
|
|
# Add microReticulum when RNS feature is enabled
|
||
|
|
propagatedBuildInputs = pkgs.lib.optional (self?.inputs?.microReticulum != null)
|
||
|
|
(pkgs.callPackage microReticulum { });
|
||
|
|
};
|
||
|
|
}
|
||
|
|
);
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
#### 2.2 Add RNS CLI Commands
|
||
|
|
|
||
|
|
```rust
|
||
|
|
// In main.rs
|
||
|
|
#[derive(Parser)]
|
||
|
|
#[command(about = "Smol Mail server")]
|
||
|
|
struct Cli {
|
||
|
|
#[arg(short, long, global = true)]
|
||
|
|
verbose: bool,
|
||
|
|
|
||
|
|
#[command(subcommand)]
|
||
|
|
command: Command,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Subcommand)]
|
||
|
|
enum Command {
|
||
|
|
/// Generate the server's static X25519 key (TCP transport)
|
||
|
|
Keygen {
|
||
|
|
#[arg(long, default_value = "server.key")]
|
||
|
|
key: String,
|
||
|
|
#[arg(long)]
|
||
|
|
force: bool,
|
||
|
|
},
|
||
|
|
/// Generate the server's RNS identity key
|
||
|
|
#[cfg(feature = "rns")]
|
||
|
|
RnsKeygen {
|
||
|
|
#[arg(long, default_value = "server.rns.key")]
|
||
|
|
key: String,
|
||
|
|
#[arg(long)]
|
||
|
|
force: bool,
|
||
|
|
},
|
||
|
|
/// Run the mailbox server over TCP
|
||
|
|
Serve {
|
||
|
|
#[arg(long, default_value = "server.key")]
|
||
|
|
key: String,
|
||
|
|
#[arg(long, default_value = "mail.db")]
|
||
|
|
db: String,
|
||
|
|
#[arg(long, default_value = "127.0.0.1")]
|
||
|
|
host: String,
|
||
|
|
#[arg(long, default_value_t = DEFAULT_PORT)]
|
||
|
|
port: u16,
|
||
|
|
#[arg(long = "max-envelope", default_value_t = 768 << 10)]
|
||
|
|
max_envelope: usize,
|
||
|
|
#[arg(long, default_value_t = 64 << 20)]
|
||
|
|
quota: i64,
|
||
|
|
#[arg(long = "requests-quota", default_value_t = 2 << 20)]
|
||
|
|
requests_quota: i64,
|
||
|
|
#[arg(long = "retention-days", default_value_t = 30)]
|
||
|
|
retention_days: i64,
|
||
|
|
#[arg(long = "requests-retention-days", default_value_t = 7)]
|
||
|
|
requests_retention_days: i64,
|
||
|
|
#[arg(long = "max-tokens", default_value_t = 1024)]
|
||
|
|
max_tokens: u16,
|
||
|
|
#[arg(long = "invite-token")]
|
||
|
|
invite_token: Option<String>,
|
||
|
|
#[arg(long = "rate-connections", default_value_t = 120)]
|
||
|
|
rate_connections: u32,
|
||
|
|
#[arg(long = "rate-sends", default_value_t = 60)]
|
||
|
|
rate_sends: u32,
|
||
|
|
#[arg(long = "rate-tokens", default_value_t = 30)]
|
||
|
|
rate_tokens: u32,
|
||
|
|
},
|
||
|
|
/// Run the mailbox server over RNS
|
||
|
|
#[cfg(feature = "rns")]
|
||
|
|
RnsServe {
|
||
|
|
#[arg(long, default_value = "server.rns.key")]
|
||
|
|
key: String,
|
||
|
|
#[arg(long, default_value = "mail.db")]
|
||
|
|
db: String,
|
||
|
|
#[arg(long = "max-envelope", default_value_t = 32 << 10)] // 32 KiB
|
||
|
|
max_envelope: usize,
|
||
|
|
#[arg(long = "fetch-budget", default_value_t = 32 << 10)] // 32 KiB
|
||
|
|
fetch_budget: usize,
|
||
|
|
#[arg(long = "quota", default_value_t = 64 << 20)]
|
||
|
|
quota: i64,
|
||
|
|
#[arg(long = "requests-quota", default_value_t = 2 << 20)]
|
||
|
|
requests_quota: i64,
|
||
|
|
#[arg(long = "retention-days", default_value_t = 30)]
|
||
|
|
retention_days: i64,
|
||
|
|
#[arg(long = "requests-retention-days", default_value_t = 7)]
|
||
|
|
requests_retention_days: i64,
|
||
|
|
#[arg(long = "max-tokens", default_value_t = 1024)]
|
||
|
|
max_tokens: u16,
|
||
|
|
#[arg(long = "invite-token")]
|
||
|
|
invite_token: Option<String>,
|
||
|
|
#[arg(long = "max-links", default_value_t = 100)]
|
||
|
|
max_links: u32,
|
||
|
|
#[arg(long = "rate-link-requests", default_value_t = 60)]
|
||
|
|
rate_link_requests: u32,
|
||
|
|
#[arg(long = "rate-link-bytes", default_value_t = 1048576)] // 1 MiB/min
|
||
|
|
rate_link_bytes: u64,
|
||
|
|
},
|
||
|
|
}
|
||
|
|
|
||
|
|
fn main() -> anyhow::Result<()> {
|
||
|
|
let cli = Cli::parse();
|
||
|
|
|
||
|
|
env_logger::Builder::new()
|
||
|
|
.filter_level(if cli.verbose {
|
||
|
|
log::LevelFilter::Debug
|
||
|
|
} else {
|
||
|
|
log::LevelFilter::Info
|
||
|
|
})
|
||
|
|
.format_timestamp_secs()
|
||
|
|
.init();
|
||
|
|
|
||
|
|
match cli.command {
|
||
|
|
Command::Keygen { key, force } => cmd_keygen(&key, force),
|
||
|
|
#[cfg(feature = "rns")]
|
||
|
|
Command::RnsKeygen { key, force } => rns::cmd_keygen(&key, force),
|
||
|
|
Command::Serve { .. } => server::run(server::ServeArgs { .. }),
|
||
|
|
#[cfg(feature = "rns")]
|
||
|
|
Command::RnsServe { .. } => rns_server::run(rns_server::RnsServeArgs { .. }),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
#### 2.3 Create `src/rns/mod.rs`
|
||
|
|
|
||
|
|
```rust
|
||
|
|
//! RNS transport support via microReticulum FFI
|
||
|
|
|
||
|
|
pub mod ffi;
|
||
|
|
pub mod transport;
|
||
|
|
|
||
|
|
use std::io::Write;
|
||
|
|
|
||
|
|
pub fn cmd_keygen(key_path: &str, force: bool) -> anyhow::Result<()> {
|
||
|
|
if std::path::Path::new(key_path).exists() && !force {
|
||
|
|
anyhow::bail!("{key_path} exists; refusing to overwrite (use --force)");
|
||
|
|
}
|
||
|
|
|
||
|
|
// Call into microReticulum to generate identity
|
||
|
|
let identity = ffi::Identity::generate()?;
|
||
|
|
|
||
|
|
// Written 0600 before any bytes land, so the key is never briefly readable.
|
||
|
|
let mut opts = std::fs::OpenOptions::new();
|
||
|
|
opts.write(true).create(true).truncate(true);
|
||
|
|
#[cfg(unix)]
|
||
|
|
opts.mode(0o600);
|
||
|
|
let mut file = opts.open(key_path)?;
|
||
|
|
file.write_all(&identity.save_to_bytes()?)?;
|
||
|
|
|
||
|
|
let dest_hash = identity.destination_hash("smolmail", "server")?;
|
||
|
|
|
||
|
|
println!("RNS identity key: {key_path}");
|
||
|
|
println!("Destination hash: {}", hex::encode(&dest_hash));
|
||
|
|
println!();
|
||
|
|
println!("Address: smol+rns://@{}", hex::encode(&dest_hash));
|
||
|
|
println!("Publish the destination hash; clients use it as the address.");
|
||
|
|
Ok(())
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
#### 2.4 Create `src/rns/ffi.rs`
|
||
|
|
|
||
|
|
```rust
|
||
|
|
//! FFI bindings to microReticulum C API
|
||
|
|
|
||
|
|
use std::os::raw::{c_char, c_int, c_uchar, c_void};
|
||
|
|
use std::ffi::CString;
|
||
|
|
use std::ptr;
|
||
|
|
|
||
|
|
// Constants
|
||
|
|
pub const RNS_DESTINATION_IN: c_int = 0;
|
||
|
|
pub const RNS_DESTINATION_OUT: c_int = 1;
|
||
|
|
pub const RNS_DESTINATION_SINGLE: c_int = 0;
|
||
|
|
|
||
|
|
// Opaque types
|
||
|
|
extern "C" {
|
||
|
|
pub type rns_identity_t;
|
||
|
|
pub type rns_destination_t;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Identity functions
|
||
|
|
extern "C" {
|
||
|
|
fn rns_identity_create() -> *mut rns_identity_t;
|
||
|
|
fn rns_identity_destroy(identity: *mut rns_identity_t);
|
||
|
|
fn rns_identity_save(identity: *mut rns_identity_t, path: *const c_char) -> c_int;
|
||
|
|
fn rns_identity_load(path: *const c_char) -> *mut rns_identity_t;
|
||
|
|
fn rns_identity_get_destination_hash(
|
||
|
|
identity: *mut rns_identity_t,
|
||
|
|
app_name: *const c_char,
|
||
|
|
aspect: *const c_char,
|
||
|
|
out_hash: *mut c_uchar,
|
||
|
|
) -> c_int;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Destination functions
|
||
|
|
extern "C" {
|
||
|
|
fn rns_destination_create(
|
||
|
|
identity: *mut rns_identity_t,
|
||
|
|
direction: c_int,
|
||
|
|
mode: c_int,
|
||
|
|
app_name: *const c_char,
|
||
|
|
aspect: *const c_char,
|
||
|
|
) -> *mut rns_destination_t;
|
||
|
|
fn rns_destination_destroy(dest: *mut rns_destination_t);
|
||
|
|
fn rns_destination_get_hash(dest: *mut rns_destination_t, out: *mut c_uchar) -> c_int;
|
||
|
|
fn rns_destination_set_max_request_size(dest: *mut rns_destination_t, size: usize) -> c_int;
|
||
|
|
fn rns_destination_start(dest: *mut rns_destination_t) -> c_int;
|
||
|
|
fn rns_destination_stop(dest: *mut rns_destination_t);
|
||
|
|
fn rns_destination_set_request_handler(
|
||
|
|
dest: *mut rns_destination_t,
|
||
|
|
path: *const c_char,
|
||
|
|
callback: extern "C" fn(userdata: *mut c_void, data: *const c_uchar, data_len: usize, link_id: *const c_uchar),
|
||
|
|
userdata: *mut c_void,
|
||
|
|
) -> c_int;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Safe wrappers
|
||
|
|
pub struct Identity {
|
||
|
|
ptr: *mut rns_identity_t,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl Identity {
|
||
|
|
pub fn generate() -> anyhow::Result<Self> {
|
||
|
|
let ptr = unsafe { rns_identity_create() };
|
||
|
|
if ptr.is_null() {
|
||
|
|
anyhow::bail!("Failed to create RNS identity");
|
||
|
|
}
|
||
|
|
Ok(Self { ptr })
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn from_file(path: &str) -> anyhow::Result<Self> {
|
||
|
|
let c_path = CString::new(path)?;
|
||
|
|
let ptr = unsafe { rns_identity_load(c_path.as_ptr()) };
|
||
|
|
if ptr.is_null() {
|
||
|
|
anyhow::bail!("Failed to load RNS identity from {}", path);
|
||
|
|
}
|
||
|
|
Ok(Self { ptr })
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn save_to_bytes(&self) -> anyhow::Result<Vec<u8>> {
|
||
|
|
// In microReticulum, identities are typically saved to files
|
||
|
|
// For FFI, we might need a custom function to export the raw bytes
|
||
|
|
// This is a placeholder - actual implementation depends on microReticulum API
|
||
|
|
anyhow::bail!("save_to_bytes not yet implemented - depends on microReticulum API");
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn destination_hash(&self, app_name: &str, aspect: &str) -> anyhow::Result<[u8; 16]> {
|
||
|
|
let c_app = CString::new(app_name)?;
|
||
|
|
let c_aspect = CString::new(aspect)?;
|
||
|
|
let mut out = [0u8; 16];
|
||
|
|
let result = unsafe {
|
||
|
|
rns_identity_get_destination_hash(self.ptr, c_app.as_ptr(), c_aspect.as_ptr(), out.as_mut_ptr())
|
||
|
|
};
|
||
|
|
if result != 0 {
|
||
|
|
anyhow::bail!("Failed to get destination hash");
|
||
|
|
}
|
||
|
|
Ok(out)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
impl Drop for Identity {
|
||
|
|
fn drop(&mut self) {
|
||
|
|
unsafe { rns_identity_destroy(self.ptr) };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
pub struct Destination {
|
||
|
|
ptr: *mut rns_destination_t,
|
||
|
|
identity: Identity,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl Destination {
|
||
|
|
pub fn create(
|
||
|
|
identity: Identity,
|
||
|
|
direction: c_int,
|
||
|
|
mode: c_int,
|
||
|
|
app_name: &str,
|
||
|
|
aspect: &str,
|
||
|
|
) -> anyhow::Result<Self> {
|
||
|
|
let c_app = CString::new(app_name)?;
|
||
|
|
let c_aspect = CString::new(aspect)?;
|
||
|
|
let ptr = unsafe {
|
||
|
|
rns_destination_create(
|
||
|
|
identity.ptr,
|
||
|
|
direction,
|
||
|
|
mode,
|
||
|
|
c_app.as_ptr(),
|
||
|
|
c_aspect.as_ptr(),
|
||
|
|
)
|
||
|
|
};
|
||
|
|
if ptr.is_null() {
|
||
|
|
anyhow::bail!("Failed to create RNS destination");
|
||
|
|
}
|
||
|
|
Ok(Self { ptr, identity })
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn hash(&self) -> anyhow::Result<[u8; 16]> {
|
||
|
|
let mut out = [0u8; 16];
|
||
|
|
let result = unsafe { rns_destination_get_hash(self.ptr, out.as_mut_ptr()) };
|
||
|
|
if result != 0 {
|
||
|
|
anyhow::bail!("Failed to get destination hash");
|
||
|
|
}
|
||
|
|
Ok(out)
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn start(&self) -> anyhow::Result<()> {
|
||
|
|
let result = unsafe { rns_destination_start(self.ptr) };
|
||
|
|
if result != 0 {
|
||
|
|
anyhow::bail!("Failed to start RNS destination");
|
||
|
|
}
|
||
|
|
Ok(())
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn set_max_request_size(&self, size: usize) -> anyhow::Result<()> {
|
||
|
|
let result = unsafe { rns_destination_set_max_request_size(self.ptr, size) };
|
||
|
|
if result != 0 {
|
||
|
|
anyhow::bail!("Failed to set max request size");
|
||
|
|
}
|
||
|
|
Ok(())
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
impl Drop for Destination {
|
||
|
|
fn drop(&mut self) {
|
||
|
|
unsafe { rns_destination_stop(self.ptr) };
|
||
|
|
unsafe { rns_destination_destroy(self.ptr) };
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
#### 2.5 Create `src/rns/transport.rs`
|
||
|
|
|
||
|
|
```rust
|
||
|
|
//! RNS transport implementation
|
||
|
|
|
||
|
|
use std::sync::mpsc;
|
||
|
|
use std::sync::Arc;
|
||
|
|
use crate::transport::{Transport, TransportBindValues};
|
||
|
|
use crate::proto::MAC_LEN;
|
||
|
|
use sha2::{Sha256, Digest};
|
||
|
|
|
||
|
|
use super::ffi;
|
||
|
|
|
||
|
|
pub struct RnsTransport {
|
||
|
|
destination: Arc<ffi::Destination>,
|
||
|
|
request_tx: mpsc::Sender<RnsRequest>,
|
||
|
|
request_rx: mpsc::Receiver<RnsRequest>,
|
||
|
|
response_tx: mpsc::Sender<RnsResponse>,
|
||
|
|
peer_id: String,
|
||
|
|
link_id: [u8; 16],
|
||
|
|
}
|
||
|
|
|
||
|
|
struct RnsRequest {
|
||
|
|
op: u8,
|
||
|
|
body: Vec<u8>,
|
||
|
|
}
|
||
|
|
|
||
|
|
struct RnsResponse {
|
||
|
|
status: u8,
|
||
|
|
payload: Vec<u8>,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl RnsTransport {
|
||
|
|
pub fn new(identity: ffi::Identity, max_request_size: usize) -> anyhow::Result<Self> {
|
||
|
|
let destination = Arc::new(ffi::Destination::create(
|
||
|
|
identity,
|
||
|
|
ffi::RNS_DESTINATION_IN,
|
||
|
|
ffi::RNS_DESTINATION_SINGLE,
|
||
|
|
"smolmail",
|
||
|
|
"server",
|
||
|
|
)?);
|
||
|
|
|
||
|
|
let dest_hash = destination.hash()?;
|
||
|
|
destination.set_max_request_size(max_request_size)?;
|
||
|
|
|
||
|
|
// Start destination
|
||
|
|
destination.start()?;
|
||
|
|
|
||
|
|
// Set up channels
|
||
|
|
let (request_tx, request_rx) = mpsc::channel();
|
||
|
|
let (response_tx, _response_rx) = mpsc::channel();
|
||
|
|
|
||
|
|
// TODO: Set up request handler with FFI callback
|
||
|
|
// This would call into C to register a handler, which then
|
||
|
|
// sends requests to request_tx
|
||
|
|
|
||
|
|
let peer_id = format!("rns:{}", hex::encode(&dest_hash));
|
||
|
|
|
||
|
|
Ok(Self {
|
||
|
|
destination,
|
||
|
|
request_tx,
|
||
|
|
request_rx,
|
||
|
|
response_tx,
|
||
|
|
peer_id,
|
||
|
|
link_id: [0u8; 16], // Will be set per-link
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn set_link_id(&mut self, link_id: [u8; 16]) {
|
||
|
|
self.link_id = link_id;
|
||
|
|
}
|
||
|
|
|
||
|
|
fn compute_bind_values(&self) -> TransportBindValues {
|
||
|
|
let dest_hash = self.destination.hash().expect("Failed to get destination hash");
|
||
|
|
TransportBindValues {
|
||
|
|
h: bind_h(&dest_hash, &self.link_id),
|
||
|
|
server_static: bind_server_static(&dest_hash),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
impl Transport for RnsTransport {
|
||
|
|
fn read_frame(&mut self) -> anyhow::Result<(u8, Vec<u8>)> {
|
||
|
|
let request = self.request_rx.recv()?;
|
||
|
|
Ok((request.op, request.body))
|
||
|
|
}
|
||
|
|
|
||
|
|
fn write_frame(&mut self, op: u8, body: &[u8]) -> anyhow::Result<()> {
|
||
|
|
// In RNS, responses go back through the link
|
||
|
|
// This would be handled by the FFI callback
|
||
|
|
Ok(())
|
||
|
|
}
|
||
|
|
|
||
|
|
fn peer_id(&self) -> &str {
|
||
|
|
&self.peer_id
|
||
|
|
}
|
||
|
|
|
||
|
|
fn bind_values(&self) -> TransportBindValues {
|
||
|
|
self.compute_bind_values()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fn bind_h(destination: &[u8; 16], link_id: &[u8; 16]) -> [u8; 32] {
|
||
|
|
let mut h = Sha256::new();
|
||
|
|
h.update(b"smolmail/1 bind");
|
||
|
|
h.update(destination);
|
||
|
|
h.update(link_id);
|
||
|
|
h.finalize().into()
|
||
|
|
}
|
||
|
|
|
||
|
|
fn bind_server_static(destination: &[u8; 16]) -> [u8; 32] {
|
||
|
|
let mut h = Sha256::new();
|
||
|
|
h.update(b"smolmail/1 bind");
|
||
|
|
h.update(destination);
|
||
|
|
h.finalize().into()
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
#### 2.6 Create `src/rns_server.rs`
|
||
|
|
|
||
|
|
```rust
|
||
|
|
//! RNS server implementation
|
||
|
|
|
||
|
|
use std::sync::Arc;
|
||
|
|
use std::time::Duration;
|
||
|
|
|
||
|
|
use crate::rns::transport::RnsTransport;
|
||
|
|
use crate::session::{ServerConfig, Session};
|
||
|
|
use crate::store::Store;
|
||
|
|
use crate::transport::Transport;
|
||
|
|
|
||
|
|
pub struct RnsServeArgs {
|
||
|
|
pub key_path: String,
|
||
|
|
pub db_path: String,
|
||
|
|
pub max_envelope: usize,
|
||
|
|
pub fetch_budget: usize,
|
||
|
|
pub quota: i64,
|
||
|
|
pub requests_quota: i64,
|
||
|
|
pub retention_days: i64,
|
||
|
|
pub requests_retention_days: i64,
|
||
|
|
pub max_tokens: u16,
|
||
|
|
pub invite_token: Option<String>,
|
||
|
|
pub max_links: u32,
|
||
|
|
pub rate_link_requests: u32,
|
||
|
|
pub rate_link_bytes: u64,
|
||
|
|
}
|
||
|
|
|
||
|
|
pub fn run(args: RnsServeArgs) -> anyhow::Result<()> {
|
||
|
|
// Load RNS identity
|
||
|
|
let identity = crate::rns::ffi::Identity::from_file(&args.key_path)?;
|
||
|
|
let dest_hash = identity.destination_hash("smolmail", "server")?;
|
||
|
|
|
||
|
|
log::info!("RNS server destination: {}", hex::encode(&dest_hash));
|
||
|
|
|
||
|
|
// Compute max request size per RNS.md S13.5
|
||
|
|
let max_request_size = max_request_bytes(args.max_envelope, args.max_tokens);
|
||
|
|
|
||
|
|
// Create transport
|
||
|
|
let transport = RnsTransport::new(identity, max_request_size)?;
|
||
|
|
|
||
|
|
// Build config (without IP-based rate limiting)
|
||
|
|
let config = Arc::new(ServerConfig {
|
||
|
|
max_envelope: args.max_envelope,
|
||
|
|
main_quota: args.quota,
|
||
|
|
requests_quota: args.requests_quota,
|
||
|
|
max_tokens: args.max_tokens,
|
||
|
|
invite_token: args.invite_token.map(String::into_bytes),
|
||
|
|
// RNS uses per-link rate limiting instead of per-IP
|
||
|
|
conn_limiter: crate::ratelimit::RateLimiter::new(args.max_links as u32),
|
||
|
|
send_limiter: crate::ratelimit::RateLimiter::new(args.rate_link_requests),
|
||
|
|
token_limiter: crate::ratelimit::RateLimiter::new(args.rate_link_requests),
|
||
|
|
});
|
||
|
|
|
||
|
|
// Start purge loop
|
||
|
|
let purge_db_path = args.db_path.clone();
|
||
|
|
let main_retention_secs = args.retention_days * 86400;
|
||
|
|
let requests_retention_secs = args.requests_retention_days * 86400;
|
||
|
|
std::thread::spawn(move || {
|
||
|
|
crate::server::purge_loop(purge_db_path, main_retention_secs, requests_retention_secs)
|
||
|
|
});
|
||
|
|
|
||
|
|
// Main loop - handle incoming links
|
||
|
|
// In practice, microReticulum would call us via FFI callback
|
||
|
|
// This is a placeholder for the actual implementation
|
||
|
|
loop {
|
||
|
|
// Wait for link establishment via FFI callback
|
||
|
|
// For each link:
|
||
|
|
// 1. Get link_id
|
||
|
|
// 2. Create session with bind_values for this link
|
||
|
|
// 3. Process requests on the link
|
||
|
|
// 4. Clean up on link close
|
||
|
|
|
||
|
|
std::thread::sleep(Duration::from_secs(1));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
fn max_request_bytes(max_envelope: usize, max_accepted: u16) -> usize {
|
||
|
|
// From RNS.md S13.5: greater of envelope+34 or AUTH with full tokens
|
||
|
|
let send_max = 1 + 1 + 32 + max_envelope; // type + mac_len + mac + envelope
|
||
|
|
let auth_max = 1 + 1 + 63 + 32 + 64 + 1 + 2 + 32 * max_accepted as usize;
|
||
|
|
std::cmp::max(send_max, auth_max)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
### Phase 3: Update NixOS Module
|
||
|
|
|
||
|
|
```nix
|
||
|
|
# In flake.nix nixosModules.default
|
||
|
|
{
|
||
|
|
options.services.bunshin = {
|
||
|
|
# ... existing options ...
|
||
|
|
|
||
|
|
enableRns = mkOption {
|
||
|
|
type = types.bool;
|
||
|
|
default = false;
|
||
|
|
description = "Enable RNS transport support";
|
||
|
|
};
|
||
|
|
|
||
|
|
rnsKeyFile = mkOption {
|
||
|
|
type = types.path;
|
||
|
|
description = ''
|
||
|
|
Path to the server's RNS identity key file.
|
||
|
|
Generated with `bunshin rns keygen`.
|
||
|
|
'';
|
||
|
|
};
|
||
|
|
|
||
|
|
rnsMaxEnvelope = mkOption {
|
||
|
|
type = types.ints.positive;
|
||
|
|
default = 32768;
|
||
|
|
description = "Max envelope size for RNS transport (bytes). Recommended: 32 KiB";
|
||
|
|
};
|
||
|
|
|
||
|
|
rnsFetchBudget = mkOption {
|
||
|
|
type = types.ints.positive;
|
||
|
|
default = 32768;
|
||
|
|
description = "Fetch budget for RNS transport (bytes). Recommended: 32 KiB";
|
||
|
|
};
|
||
|
|
|
||
|
|
rnsMaxLinks = mkOption {
|
||
|
|
type = types.ints.positive;
|
||
|
|
default = 100;
|
||
|
|
description = "Maximum concurrent RNS links";
|
||
|
|
};
|
||
|
|
|
||
|
|
rnsRateLinkRequests = mkOption {
|
||
|
|
type = types.ints.positive;
|
||
|
|
default = 60;
|
||
|
|
description = "Max requests per link per minute";
|
||
|
|
};
|
||
|
|
|
||
|
|
rnsRateLinkBytes = mkOption {
|
||
|
|
type = types.ints.positive;
|
||
|
|
default = 1048576; # 1 MiB
|
||
|
|
description = "Max bytes per link per minute";
|
||
|
|
};
|
||
|
|
};
|
||
|
|
|
||
|
|
config = mkIf cfg.enable {
|
||
|
|
# ... existing config ...
|
||
|
|
|
||
|
|
# Add RNS service if enabled
|
||
|
|
systemd.services.bunshin-rns = mkIf cfg.enableRns {
|
||
|
|
description = "bunshin Smol Mail server (RNS transport)";
|
||
|
|
wantedBy = [ "multi-user.target" ];
|
||
|
|
after = [ "network.target" ];
|
||
|
|
|
||
|
|
serviceConfig = {
|
||
|
|
ExecStart = pkgs.writeShellScript "bunshin-rns-serve" ''
|
||
|
|
args=(
|
||
|
|
rns-serve
|
||
|
|
--key ${cfg.rnsKeyFile}
|
||
|
|
--db ${cfg.dataDir}/mail.db
|
||
|
|
--max-envelope ${toString cfg.rnsMaxEnvelope}
|
||
|
|
--fetch-budget ${toString cfg.rnsFetchBudget}
|
||
|
|
--quota ${toString cfg.quota}
|
||
|
|
--requests-quota ${toString cfg.requestsQuota}
|
||
|
|
--retention-days ${toString cfg.retentionDays}
|
||
|
|
--requests-retention-days ${toString cfg.requestsRetentionDays}
|
||
|
|
--max-tokens ${toString cfg.maxTokens}
|
||
|
|
--max-links ${toString cfg.rnsMaxLinks}
|
||
|
|
--rate-link-requests ${toString cfg.rnsRateLinkRequests}
|
||
|
|
--rate-link-bytes ${toString cfg.rnsRateLinkBytes}
|
||
|
|
)
|
||
|
|
${lib.optionalString (cfg.inviteToken != null)
|
||
|
|
''args+=(--invite-token ${lib.escapeShellArg cfg.inviteToken})''}
|
||
|
|
exec ${cfg.package}/bin/bunshin "''${args[@]}"
|
||
|
|
'';
|
||
|
|
DynamicUser = true;
|
||
|
|
Restart = "on-failure";
|
||
|
|
};
|
||
|
|
};
|
||
|
|
};
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Implementation Priority
|
||
|
|
|
||
|
|
| Priority | Phase | Task | Dependencies |
|
||
|
|
|----------|-------|------|--------------|
|
||
|
|
| High | 1 | Transport trait abstraction | None |
|
||
|
|
| High | 1 | Refactor TCP transport | None |
|
||
|
|
| High | 1 | Update session.rs to use bind_values | None |
|
||
|
|
| High | 1 | Test Phase 1 | None |
|
||
|
|
| Medium | 2 | Add RNS CLI commands | None |
|
||
|
|
| Medium | 2 | Create FFI module structure | microReticulum |
|
||
|
|
| Medium | 2 | Implement RNS transport | microReticulum |
|
||
|
|
| Medium | 2 | Create RNS server | microReticulum |
|
||
|
|
| Low | 3 | Update NixOS module | Phase 2 |
|
||
|
|
| Low | 3 | Documentation | All above |
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Testing Strategy
|
||
|
|
|
||
|
|
### Phase 1 Tests
|
||
|
|
- ✅ All existing tests still pass
|
||
|
|
- ✅ TCP transport produces same results as before
|
||
|
|
|
||
|
|
### Phase 2+ Tests
|
||
|
|
- ✅ Bind value computation matches RNS.md specification
|
||
|
|
- ✅ Interoperability with `smolmail_rns.py` reference client
|
||
|
|
- ✅ Shared store between TCP and RNS transports
|
||
|
|
- ✅ Rate limiting per link
|
||
|
|
- ✅ Destination hash validation
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Compatibility Notes
|
||
|
|
|
||
|
|
### Backward Compatibility
|
||
|
|
- ✅ TCP transport (v1.1) remains fully functional
|
||
|
|
- ✅ A server can offer both transports simultaneously
|
||
|
|
- ✅ Same database works for both transports (RNS.md S15)
|
||
|
|
- ✅ v1.1 clients work with v1.2 servers over TCP (no negotiation needed)
|
||
|
|
|
||
|
|
### Forward Compatibility
|
||
|
|
- ✅ v1.2 adds the `smol+rns://` scheme
|
||
|
|
- ✅ RNS transport uses the same operations and status codes
|
||
|
|
- ✅ Only AUTH and REGISTER signature bindings differ between transports
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## File Changes Summary
|
||
|
|
|
||
|
|
| File | Action | Phase |
|
||
|
|
|------|--------|-------|
|
||
|
|
| `src/transport.rs` | Create | 1 |
|
||
|
|
| `src/tcp_transport.rs` | Create (from channel.rs) | 1 |
|
||
|
|
| `src/session.rs` | Modify | 1 |
|
||
|
|
| `src/server.rs` | Modify | 1 |
|
||
|
|
| `src/main.rs` | Modify | 1-2 |
|
||
|
|
| `src/channel.rs` | Deprecate/Delete | 1 |
|
||
|
|
| `Cargo.toml` | Modify | 2 |
|
||
|
|
| `flake.nix` | Modify | 2-3 |
|
||
|
|
| `src/rns/mod.rs` | Create | 2 |
|
||
|
|
| `src/rns/ffi.rs` | Create | 2 |
|
||
|
|
| `src/rns/transport.rs` | Create | 2 |
|
||
|
|
| `src/rns_server.rs` | Create | 2 |
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Open Questions
|
||
|
|
|
||
|
|
1. **microReticulum C API stability**: Need to verify the C API exists and is stable enough for FFI
|
||
|
|
2. **Callback handling**: How to safely handle C callbacks that invoke Rust code
|
||
|
|
3. **Threading model**: microReticulum uses its own threads; need to ensure thread safety
|
||
|
|
4. **Error handling**: Map microReticulum error codes to Rust error types
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Next Steps
|
||
|
|
|
||
|
|
1. **Immediate**: Implement Phase 1 (transport abstraction) - no dependencies, high value
|
||
|
|
2. **Parallel**: Investigate microReticulum C API documentation and stability
|
||
|
|
3. **Decision**: Choose RNS integration approach (FFI vs IPC vs wait)
|
||
|
|
4. **Phase 2**: Implement chosen approach
|
||
|
|
5. **Test**: Verify with reference Python implementation
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## References
|
||
|
|
|
||
|
|
- [Reticulum Network Stack](https://reticulum.network/)
|
||
|
|
- [microReticulum (C++)](https://github.com/attermann/microReticulum)
|
||
|
|
- [Smol Mail RNS Specification (RNS.md)](https://code.randogoth.com/randogoth/smolmail/src/branch/main/RNS.md)
|
||
|
|
- [smolmaild_rns.py - Reference RNS server](https://code.randogoth.com/randogoth/smolmail/src/branch/main/smolmaild_rns.py)
|
||
|
|
- [smolmail_rns.py - Reference RNS client](https://code.randogoth.com/randogoth/smolmail/src/branch/main/smolmail_rns.py)
|