0.15.0: simple builder API, v2.1 framing, stream lane, benches
- omnivox::server::<T>()/client::<T>() builders; plain closures, enum in, match in the callback — the original C++ UX with the ceremony removed - serde + bincode replace the hand-rolled Cereal codec (vectors.rs still governs the crypto leaves via the C++ golden oracles) - v2.1 frames: u32 len | type | ct | tag; nonce derived from the sequence counter (no wire nonce), type byte authenticated as AAD; any frame-level failure is fatal, authenticated-but-undecodable payloads are skipped - stream lane: send_stream/on_stream, 1 MiB frames, shared sequence with messages (exact sender interleaving preserved); see dump-agent/viewer - per-connection bounded queues + writer tasks; reply/reply_stream are non-blocking try_send lanes for callbacks - benches: seal/open criterion group + transport harness (spray msg/s, stream GiB/s, raw loopback baseline) - profile.dev.package scrypt/pbkdf2 opt-level 3: golden-vector scrypt at N=2^17 was unusable in debug builds - PROTOCOL-V2.md and README rewritten for v2.1
This commit is contained in:
@@ -1,37 +0,0 @@
|
||||
//! Port of the C++ `common.h`: shared payload type and demo constants.
|
||||
|
||||
use omnivox::{Cereal, CodecError, Reader, write_byte_vec};
|
||||
|
||||
/// Wire-identical to the C++ `DatagramPayload` (cereal field order:
|
||||
/// `id`, `v1.qword`, `payload`; the `PackedValue64` union only ever
|
||||
/// serialized its `qword`, so it ports as a plain `u64`).
|
||||
#[derive(Debug, Default)]
|
||||
pub struct DatagramPayload {
|
||||
pub id: u16,
|
||||
pub v1: u64,
|
||||
pub payload: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Cereal for DatagramPayload {
|
||||
fn encode(&self, out: &mut Vec<u8>) {
|
||||
out.extend_from_slice(&self.id.to_le_bytes());
|
||||
out.extend_from_slice(&self.v1.to_le_bytes());
|
||||
write_byte_vec(out, &self.payload);
|
||||
}
|
||||
|
||||
fn decode(bytes: &[u8]) -> Result<Self, CodecError> {
|
||||
let mut r = Reader::new(bytes);
|
||||
Ok(Self {
|
||||
id: r.u16()?,
|
||||
v1: r.u64()?,
|
||||
payload: r.byte_vec()?,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub const PASSWORD: &str = "SecurePassword123";
|
||||
pub const SALT: &[u8] = &[0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08];
|
||||
pub const SERVER_PORT: u16 = 8080;
|
||||
/// Only used by the client example.
|
||||
#[allow(dead_code)]
|
||||
pub const SERVER_ADDR: &str = "127.0.0.1";
|
||||
@@ -0,0 +1,73 @@
|
||||
//! The original OmniVox use case: a dump **agent** streams physical
|
||||
//! memory to a viewer while spraying small control messages over the same
|
||||
//! encrypted session.
|
||||
//!
|
||||
//! This example fabricates a toy 16 MiB "memory" image (no root needed :),
|
||||
//! streams it in 1 MiB encrypted chunks through the stream lane, and
|
||||
//! interleaves `Progress` messages through the message lane. Run
|
||||
//! `dump-viewer` first, then this.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Control-plane messages. Bulk bytes deliberately do NOT travel as
|
||||
/// messages — the stream lane skips serde entirely.
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
enum AgentMsg {
|
||||
/// "About to stream page {page} of {pages}"
|
||||
Progress { page: u32, pages: u32 },
|
||||
/// "Entire image streamed; {bytes} bytes sent."
|
||||
Done { bytes: u64 },
|
||||
}
|
||||
|
||||
const PAGE: usize = 1024 * 1024; // 1 MiB stream frames
|
||||
const PAGES: u32 = 16;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_target(false)
|
||||
.without_time()
|
||||
.init();
|
||||
|
||||
let agent = omnivox::client::<AgentMsg>()
|
||||
.password("SecurePassword123", b"static-demo-salt")
|
||||
.connect("127.0.0.1", 8080)
|
||||
.await?;
|
||||
|
||||
let mut image = Vec::with_capacity(PAGE * PAGES as usize);
|
||||
for page in 0..(PAGE * PAGES as usize) {
|
||||
// A deterministic fake "memory image": anything recognizable works.
|
||||
image.push((page % 251) as u8);
|
||||
}
|
||||
|
||||
for (n, page) in image.chunks(PAGE).enumerate() {
|
||||
// Control plane: announce the page (tiny, latency-sensitive).
|
||||
agent
|
||||
.send(&AgentMsg::Progress {
|
||||
page: n as u32,
|
||||
pages: PAGES,
|
||||
})
|
||||
.await;
|
||||
|
||||
// Data plane: stream the page. `send_stream` splits anything larger
|
||||
// than Config::max_stream_frame and awaits each chunk, so even a
|
||||
// 4 GiB dump here would flow with proper backpressure — a slow
|
||||
// viewer throttles the agent instead of exploding memory.
|
||||
if !agent.send_stream(page).await {
|
||||
tracing::error!("viewer vanished mid-dump");
|
||||
break;
|
||||
}
|
||||
// Pace the toy dump so the interleaving is visible in the logs.
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
|
||||
agent
|
||||
.send(&AgentMsg::Done {
|
||||
bytes: image.len() as u64,
|
||||
})
|
||||
.await;
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
agent.stop().await;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//! The dump **viewer** side: receives an encrypted memory stream while
|
||||
//! control messages arrive on the same session, in order, interleaved.
|
||||
//!
|
||||
//! Run this first, then `cargo run --example dump-agent`. Pages are
|
||||
//! appended to `/tmp/omnivox-dump.bin`; the `FNV-1a` hash printed at the
|
||||
//! end proves byte-exact arrival (run the agent twice — same hash).
|
||||
|
||||
use omnivox::Config;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io::Write;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
enum AgentMsg {
|
||||
Progress { page: u32, pages: u32 },
|
||||
Done { bytes: u64 },
|
||||
}
|
||||
|
||||
/// Running FNV-1a over everything the stream lane delivers.
|
||||
#[derive(Default)]
|
||||
struct Digest(u64);
|
||||
|
||||
impl Digest {
|
||||
fn add(&mut self, bytes: &[u8]) {
|
||||
for &b in bytes {
|
||||
self.0 ^= b as u64;
|
||||
self.0 = self.0.wrapping_mul(0x0000_0100_0000_01b3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
tracing_subscriber::fmt()
|
||||
.with_target(false)
|
||||
.without_time()
|
||||
.init();
|
||||
|
||||
// Shared across both callbacks (and the Done handler) — a plain
|
||||
// Arc<Mutex<..>> like any app state.
|
||||
let digest = Arc::new(Mutex::new(Digest::default()));
|
||||
let file = Arc::new(Mutex::new(std::fs::File::create("/tmp/omnivox-dump.bin")?));
|
||||
|
||||
let _server = omnivox::server::<AgentMsg>()
|
||||
.password("SecurePassword123", b"static-demo-salt")
|
||||
// Larger stream frames than the default, matching the agent.
|
||||
.config(Config {
|
||||
max_stream_frame: 1024 * 1024,
|
||||
..Config::default()
|
||||
})
|
||||
.on_data({
|
||||
let file = Arc::clone(&file);
|
||||
let digest = Arc::clone(&digest);
|
||||
move |msg, _ctx| match msg {
|
||||
AgentMsg::Progress { page, pages } => {
|
||||
tracing::info!("page {page}/{pages} inbound");
|
||||
file.lock().unwrap().write_all(b"\n").ok(); // page boundary marker
|
||||
}
|
||||
AgentMsg::Done { bytes } => {
|
||||
tracing::info!(
|
||||
"dump complete: {bytes} bytes, fnv1a={:016x}, saved to /tmp/omnivox-dump.bin",
|
||||
digest.lock().unwrap().0
|
||||
);
|
||||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
})
|
||||
// The bulk lane: raw decrypted bytes, zero serde, zero copies beyond
|
||||
// the queue hop. This is where multi-GB dumps land.
|
||||
.on_stream({
|
||||
let file = Arc::clone(&file);
|
||||
let digest = Arc::clone(&digest);
|
||||
move |chunk: &[u8], _ctx| {
|
||||
digest.lock().unwrap().add(chunk);
|
||||
file.lock().unwrap().write_all(chunk).ok();
|
||||
}
|
||||
})
|
||||
.serve(8080)
|
||||
.await?;
|
||||
|
||||
tracing::info!("viewer listening on :8080, waiting for agent...");
|
||||
std::future::pending::<()>().await;
|
||||
Ok(())
|
||||
}
|
||||
+33
-62
@@ -1,12 +1,17 @@
|
||||
//! Port of OmniVox-client.cpp: connects, sends "Hello", prints what the
|
||||
//! server echoes, then stops after 15 seconds like the C++ example.
|
||||
//! The minimal OmniVox client: connect, talk, done.
|
||||
//!
|
||||
//! Companion to `omnivox-server`. Sends `Hello`, prints the echo, then
|
||||
//! exchanges two more notes and stops.
|
||||
|
||||
#[path = "common/mod.rs"]
|
||||
mod common;
|
||||
use omnivox::ConnectionStatus;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
|
||||
use common::{DatagramPayload, PASSWORD, SALT, SERVER_ADDR, SERVER_PORT};
|
||||
use omnivox::{Config, ConnectionContext, ConnectionStatus, OmniVox, derive_encrypt_key};
|
||||
use std::sync::Arc;
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
enum ChatMsg {
|
||||
Hello,
|
||||
Note(String),
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
@@ -15,63 +20,29 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.without_time()
|
||||
.init();
|
||||
|
||||
tracing::info!("Connecting to server at {SERVER_ADDR}:{SERVER_PORT}");
|
||||
|
||||
let client = Arc::new(OmniVox::<DatagramPayload>::new());
|
||||
let key = derive_encrypt_key(PASSWORD.as_bytes(), SALT, 17, 8, 1)?;
|
||||
client.set_encrypt_key(key);
|
||||
|
||||
let data_cb = Arc::new(|_data: &DatagramPayload, ctx: &ConnectionContext| {
|
||||
tracing::info!("Server ID: {}, sent data!", ctx.id);
|
||||
});
|
||||
|
||||
let connector = Arc::clone(&client);
|
||||
let connection_cb = Arc::new(move |ctx: &ConnectionContext| match ctx.connected {
|
||||
ConnectionStatus::Connected => {
|
||||
tracing::info!(
|
||||
"[onConnect] Successfully connected to: {}:{}",
|
||||
ctx.ip,
|
||||
ctx.port
|
||||
);
|
||||
let client = Arc::clone(&connector);
|
||||
tokio::spawn(async move {
|
||||
let payload = DatagramPayload {
|
||||
id: 1,
|
||||
v1: 0,
|
||||
payload: b"Hello".to_vec(),
|
||||
};
|
||||
if client.send_to_server(&payload).await {
|
||||
tracing::info!("Sent initial data to server");
|
||||
}
|
||||
});
|
||||
}
|
||||
ConnectionStatus::ConnectionFailed => {
|
||||
tracing::info!(
|
||||
"[onConnect] Connection failed or timed out to: {}:{}",
|
||||
ctx.ip,
|
||||
ctx.port
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
tracing::info!(
|
||||
"[onConnect] Unexpected connection status to: {}:{}",
|
||||
ctx.ip,
|
||||
ctx.port
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
client
|
||||
.connect_to_server(
|
||||
SERVER_ADDR,
|
||||
SERVER_PORT,
|
||||
Config::default(),
|
||||
Some(data_cb),
|
||||
connection_cb,
|
||||
)
|
||||
let client = omnivox::client::<ChatMsg>()
|
||||
// Same password + salt as the server — they derive the same PSK.
|
||||
.password("SecurePassword123", b"static-demo-salt")
|
||||
.on_data(|msg, _ctx| match msg {
|
||||
ChatMsg::Hello => tracing::info!("server greeted us unprompted?"),
|
||||
ChatMsg::Note(text) => tracing::info!("server note: {text}"),
|
||||
})
|
||||
// Fires on Connected, ConnectionFailed, and later NotConnected.
|
||||
.on_state(|ctx| {
|
||||
tracing::info!("state: {:?}", ctx.status());
|
||||
if ctx.status() == ConnectionStatus::ConnectionFailed {
|
||||
tracing::error!("is the server running? (cargo run --example omnivox-server)");
|
||||
}
|
||||
})
|
||||
.connect("127.0.0.1", 8080)
|
||||
.await?;
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_secs(15)).await;
|
||||
client.send(&ChatMsg::Hello).await;
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
client.send(&ChatMsg::Note("first note".into())).await;
|
||||
client.send(&ChatMsg::Note("second note".into())).await;
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
|
||||
client.stop().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+43
-78
@@ -1,15 +1,21 @@
|
||||
//! Port of OmniVox-server.cpp: echoes each client payload back with the
|
||||
//! client id, refusing no client that holds the key.
|
||||
//! The minimal OmniVox server: one enum, one closure, one call.
|
||||
//!
|
||||
//! Run this, then run `cargo run --example omnivox-client` in another
|
||||
//! terminal. The client sends `Hello`; we echo a `Note` back and finish
|
||||
//! after three exchanges — the same callback shape as the original C++
|
||||
//! demo, minus the boilerplate.
|
||||
|
||||
#[path = "common/mod.rs"]
|
||||
mod common;
|
||||
use omnivox::Decision;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use common::{DatagramPayload, PASSWORD, SALT, SERVER_PORT};
|
||||
use omnivox::{Config, ConnectionContext, Decision, OmniVox, derive_encrypt_key};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
/// The one message type of our tiny protocol. Because this is an enum,
|
||||
/// `on_data`'s `match` is the switch-on-id pattern from the C++ days,
|
||||
/// checked by the compiler instead of a runtime default case.
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
enum ChatMsg {
|
||||
Hello,
|
||||
Note(String),
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
@@ -18,78 +24,37 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.without_time()
|
||||
.init();
|
||||
|
||||
tracing::info!("Starting server on port {SERVER_PORT}");
|
||||
|
||||
let server = Arc::new(OmniVox::<DatagramPayload>::new());
|
||||
let key = derive_encrypt_key(PASSWORD.as_bytes(), SALT, 17, 8, 1)?;
|
||||
server.set_encrypt_key(key);
|
||||
|
||||
// The C++ example's g_clientHalt map, kept for behavioral parity.
|
||||
let client_halt: Arc<Mutex<HashMap<u32, bool>>> = Arc::new(Mutex::new(HashMap::new()));
|
||||
|
||||
let responder = Arc::clone(&server);
|
||||
let received = Arc::new(move |data: &DatagramPayload, ctx: &ConnectionContext| {
|
||||
tracing::info!(
|
||||
"Received data from client {}: Payload size={}",
|
||||
ctx.id,
|
||||
data.payload.len()
|
||||
);
|
||||
if !data.payload.is_empty() {
|
||||
tracing::info!(
|
||||
"Payload content: {}",
|
||||
String::from_utf8_lossy(&data.payload)
|
||||
);
|
||||
}
|
||||
let response = DatagramPayload {
|
||||
id: ctx.id as u16,
|
||||
v1: 0,
|
||||
payload: data.payload.clone(),
|
||||
};
|
||||
let server = Arc::clone(&responder);
|
||||
let id = ctx.id;
|
||||
// C++ echoed synchronously inside the callback; the Rust callback is
|
||||
// sync, so the echo rides a task — per-connection socket order is
|
||||
// still serialized by the client's write lock (contract B3).
|
||||
tokio::spawn(async move {
|
||||
if server.send_to_client(&response, id).await {
|
||||
tracing::info!("Sent response to client {id}");
|
||||
} else {
|
||||
tracing::info!("Error sending data to client {id}");
|
||||
let server = omnivox::server::<ChatMsg>()
|
||||
// Both peers must derive the same key. scrypt runs once, at startup,
|
||||
// on a blocking thread (~1 s, 128 MiB). Prefer a random 32-byte PSK
|
||||
// via `.key([u8; 32])` in real deployments.
|
||||
.password("SecurePassword123", b"static-demo-salt")
|
||||
.on_data(|msg, ctx| match msg {
|
||||
ChatMsg::Hello => {
|
||||
tracing::info!("client {} says hello", ctx.id());
|
||||
// Synchronous, non-blocking enqueue. A `false` return would
|
||||
// mean the peer stopped reading — never a panic, never a stall.
|
||||
let _ = ctx.reply(&ChatMsg::Note(format!("echo {}", ctx.id())));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
let halt = Arc::clone(&client_halt);
|
||||
let disconnect = Arc::new(move |ctx: &ConnectionContext| {
|
||||
tracing::info!(
|
||||
"Client {} {}:{} is disconnecting from the server cleaning up objects.",
|
||||
ctx.id,
|
||||
ctx.ip,
|
||||
ctx.port
|
||||
);
|
||||
*halt.lock().unwrap().entry(ctx.id).or_default() = true;
|
||||
});
|
||||
|
||||
let halt = Arc::clone(&client_halt);
|
||||
let connect = Arc::new(move |ctx: &ConnectionContext| {
|
||||
*halt.lock().unwrap().entry(ctx.id).or_default() = false;
|
||||
tracing::info!("New client has connected - {}:{}", ctx.ip, ctx.port);
|
||||
Decision::Allow
|
||||
});
|
||||
|
||||
server
|
||||
.start_server(
|
||||
SERVER_PORT,
|
||||
Config::default(),
|
||||
received,
|
||||
disconnect,
|
||||
connect,
|
||||
)
|
||||
ChatMsg::Note(text) => {
|
||||
tracing::info!("client {} wrote: {text}", ctx.id());
|
||||
}
|
||||
})
|
||||
// Every authenticated client is welcome; return `Decision::Deny`
|
||||
// to close specific peers (per-IP policy, allowlists, ...).
|
||||
.on_connect(|ctx| {
|
||||
tracing::info!("accepting {}:{}", ctx.ip(), ctx.port());
|
||||
Decision::Allow
|
||||
})
|
||||
.on_disconnect(|ctx| tracing::info!("client {} left", ctx.id()))
|
||||
.serve(8080)
|
||||
.await?;
|
||||
|
||||
tracing::info!("Server started successfully");
|
||||
tracing::info!("listening on :8080 (Ctrl-C to stop)");
|
||||
|
||||
// C++ slept u32::MAX hours; we serve until killed, like the reference.
|
||||
// The handle can live forever; `server.stop().await` tears everything
|
||||
// down cleanly when you want it to.
|
||||
let _ = &server;
|
||||
std::future::pending::<()>().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user